feat: 문서 구조 변경 및 tech-visual 스킬 추가
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
---
|
||||
name: rewriting-technical-prose-naturally
|
||||
description: Use when an existing Korean technical document, section, or heading already contains grounded facts but sounds AI-written, slogan-like, overly polished, abstract, compressed, or mechanically contrasted and must be rewritten without changing its technical meaning.
|
||||
---
|
||||
|
||||
# Rewriting Technical Prose Naturally
|
||||
|
||||
## Scope
|
||||
|
||||
Rewrite the style of existing Korean technical prose so it reads like a Korean engineer wrote it. Do not
|
||||
use this skill to create the technical content, fill an evidence gap, correct an implementation, or add a
|
||||
claim about this system that the source did not make.
|
||||
|
||||
Two things that look like new content but are not, and that this skill is expected to supply: **the
|
||||
standard definition of a term the source already uses**, and **the ordering slots in
|
||||
[references/document-skeleton.md](references/document-skeleton.md)** — moving an existing definition ahead
|
||||
of its first use, or an existing outcome into the closing. Both rearrange or unpack what is already there.
|
||||
Neither invents a fact.
|
||||
|
||||
**Priority: source preservation > natural Korean > polish.** If preservation and naturalness conflict,
|
||||
keep the meaning — but fix the sentence by *adding a short clause that states the condition*, not by
|
||||
twisting a word into a shape no one uses. A sentence that is technically exact and unspeakable is a
|
||||
sentence that still needs work.
|
||||
|
||||
Before the first rewrite in a task, read both references:
|
||||
|
||||
- [references/document-skeleton.md](references/document-skeleton.md) — how a Korean tech blog article
|
||||
is ordered: opener, audience bar, definition section, case template, closing.
|
||||
- [references/korean-tech-blog-register.md](references/korean-tech-blog-register.md) — what Korean
|
||||
tech blogs actually do with definitions, verbs, particles, subjects, headings, and numbers.
|
||||
- [references/regression-examples.md](references/regression-examples.md) — rewrites that failed and why.
|
||||
|
||||
Read the complete source and the nearby context needed to interpret pronouns, comparisons, and causes.
|
||||
Do not rewrite an isolated paragraph when its protected meaning depends on the surrounding section.
|
||||
|
||||
## Establish the meaning contract
|
||||
|
||||
Make an internal claim ledger before editing. Do not print it unless asked. Record:
|
||||
|
||||
- every number, sign, unit, date, version, identifier, annotation, command, path, status, and observed output;
|
||||
- success and failure results;
|
||||
- each stated cause and its stated result;
|
||||
- comparison targets and axes;
|
||||
- environment, dataset, topology, timing, and other verification conditions;
|
||||
- confirmed facts, inferences, possibilities, assumptions, recommendations, unknowns, and excluded scope;
|
||||
- exceptions, limitations, and facts the source explicitly did not verify.
|
||||
|
||||
Every material sentence in the rewrite must map to the source ledger. Every material source claim must
|
||||
remain represented. Do not combine claims when the combination creates a stronger generalization.
|
||||
|
||||
## Structure comes from the source, not from a checklist
|
||||
|
||||
An earlier version of this skill listed nine slots a document "must have" and a checker that failed a
|
||||
document for missing them. That was wrong, and it produced a new defect: every rewrite came out in the
|
||||
same order — 요약 → 지도 → 호출 순서 → 테스트 → 공백 → 다음 읽기 — with a Findings list at the end. The
|
||||
sentences read like Korean; the document read like a report generator's stable output format.
|
||||
|
||||
[references/document-skeleton.md](references/document-skeleton.md) records what five reference articles
|
||||
happen to do. **It is an observation, not a form to fill in.** Read it to see what moves exist, then let
|
||||
the source decide which of them this document needs.
|
||||
|
||||
### Five things not to do
|
||||
|
||||
1. **Do not promote every verified fact to a section.** A code reading turns up dozens of true
|
||||
observations. Only the ones the document's central question needs belong in the flow. The rest stay
|
||||
out, even though you confirmed them and it feels wasteful to drop them. Wanting to include everything
|
||||
confirmed is the most reliable machine tell there is.
|
||||
2. **Do not build a fixed running order.** No document owes you 지도 → 순서 → 테스트 → 공백 → 다음 읽기.
|
||||
Two documents about the same subsystem should not have the same section skeleton.
|
||||
3. **Do not re-package what the body already said as a closing Findings list.** Eight bullets of
|
||||
"현재 구현 공백" after the body already explained each one reads as an agent's analysis output, not as
|
||||
a person writing. If a limitation matters, it belongs next to the thing it limits.
|
||||
4. **Do not write sentences that instruct the reader how to think.** `먼저 결론을 구분해야 합니다`,
|
||||
`여기서 typed label과 end-to-end 동작을 구분해야 합니다` — go straight to the event instead:
|
||||
`CacheAsideExecutor까지 따라가면 동작이 달라집니다`. One or two orienting sentences in a whole
|
||||
document is plenty; more than that and you are narrating your own analysis process.
|
||||
5. **Do not keep working notes in the published document.** `다음에 열어볼 source 순서`,
|
||||
`잘못 읽기 쉬운 지점` are an agent's memo to itself. A reader did not ask what you plan to open next.
|
||||
|
||||
### What to keep
|
||||
|
||||
Where the source genuinely carries one of these, keep it and put it in the right place: what the document
|
||||
is about, who it is for and what they need to know first, a definition before its first use, what happened
|
||||
and why. **A slot the source is silent about stays absent — and so does a slot the source could fill but
|
||||
this particular document does not need.**
|
||||
|
||||
## Write Korean, not translated Korean
|
||||
|
||||
This is the part that keeps failing. Details and quoted corpus examples are in
|
||||
[references/korean-tech-blog-register.md](references/korean-tech-blog-register.md); the rules below are
|
||||
the ones to apply on every sentence.
|
||||
|
||||
### Define the term before you use it
|
||||
|
||||
At the first appearance of an API name, metric, counter, annotation, or domain word, write one sentence
|
||||
saying **what it is and what it does**. Korean tech blogs open this way as a matter of course:
|
||||
`MDC(Mapped Diagnostic Context)는 ... 메타 정보를 넣고 관리하는 공간입니다`,
|
||||
`화면에 렌더링되어 사용자에게 노출되는 문자열을 '문구'라고 하겠습니다`.
|
||||
|
||||
Keep the original name. Expand an acronym in parentheses at first use. Where a plain-text field cannot
|
||||
carry a code name, put the meaning first and the name in parentheses: `준비된 SQL 문장(PreparedStatement)`.
|
||||
|
||||
**This is not "adding content."** A standard definition of a term the source already uses is prerequisite
|
||||
knowledge the reader needs, and supplying it is part of the job. What you may not add is a new claim
|
||||
about this system, this measurement, or this decision. Definition: yes. New finding: no.
|
||||
|
||||
**A definition you can only produce by reading the identifier's name is not a definition — it is a guess.**
|
||||
`deniedCommandCount`는 거절된 명령의 수이고 `rejectedRequestCount`는 거절된 요청의 수다 looks like a
|
||||
harmless gloss, but if the source never said what either one counts, you just decided it. A definition may
|
||||
come from the source document, the codebase, or the framework's own documentation — nowhere else. When
|
||||
none of those give you the meaning, keep the name, say what the source does say, and leave the rest alone:
|
||||
`두 값은 1과 200으로 달랐습니다. 각각이 무엇을 세는지는 이 문서에서 확인하지 않았습니다.`
|
||||
This applies hardest to internal counters and metrics, where a plausible-sounding gloss silently redefines
|
||||
what was measured.
|
||||
|
||||
### Write Korean words in Korean. Latin script is for identifiers only
|
||||
|
||||
This is the single largest difference between this repository's prose and the reference articles, and the
|
||||
earlier version of this skill made it worse by telling you to "keep the name" without saying which names.
|
||||
|
||||
Measured over the five reference articles versus nine rewritten sections here:
|
||||
|
||||
| | 우아한형제들 | 이 저장소 |
|
||||
|---|---|---|
|
||||
| 문장당 영문 토큰 | **1.4** | **4.4** |
|
||||
| 글자 중 한글 비율 | **0.58** | **0.34** |
|
||||
| 쿼리 / `query` | 66 / 5 | 5 / 4 |
|
||||
| 캐시 / `cache` | 2 / 0 | 0 / 19 |
|
||||
| 상태 / `status` | 48 / 0 | 6 / 7 |
|
||||
| 설정 / `config` | 49 / 3 | 5 / 6 |
|
||||
|
||||
A page of Latin nouns strung together with Korean particles is what "AI가 정리한 기술 보고서" actually
|
||||
means. Fixing it changes no fact, because a bare common noun was never a protected span.
|
||||
|
||||
**Four buckets. Only the first stays in Latin script.**
|
||||
|
||||
1. **식별자 — 그대로 둔다.** Class, method, field, config key, command, constant, file name, annotation:
|
||||
`CacheAsideExecutor`, `getLoadCount()`, `min-replicas-to-write 1`, `application.yml`, `@ManyToOne`.
|
||||
These are protected spans. Keep them in backticks and never translate them.
|
||||
2. **한국어에 자리잡은 외래어 — 한글로 적는다.** 쿼리 · 캐시 · 클래스 · 테스트 · 요청 · 응답 · 상태 ·
|
||||
설정 · 키 · 스레드 · 세션 · 토큰 · 인덱스 · 라이브러리 · 컴포넌트 · 메서드 · 필드 · 어댑터 ·
|
||||
인스턴스 · 클라이언트 · 커넥션 · 타임아웃. The reference articles write every one of these in Hangul.
|
||||
3. **한국어 낱말이 이미 있는 영어 일반명사 — 한국어로 쓴다.** `credential` 자격 증명 · `budget` 상한 ·
|
||||
`owner` 소유자 · `source` 원본 · `reply` 응답 · `warning` 경고 · `account` 계정 · `material` 값 ·
|
||||
`group` 묶음 · `lane` 갈래 · `contributor` 항목. Where the document has already declared one as its
|
||||
own term, keep that term — but declare it once, in Korean, rather than leaving the English in every
|
||||
sentence.
|
||||
4. **고유명사·제품명 — 그대로 둔다.** Redis, Nginx, Hibernate, Spring, Actuator, Keycloak, PostgreSQL.
|
||||
|
||||
**After the first mention, refer back in Korean.** `optional contributor는 … optional contributor가 …`
|
||||
becomes `… 이 항목이 …`. Repeating the full English name in every sentence is what pushes the count to
|
||||
four per sentence. Pointing back with 이/그 + a Korean noun changes nothing about which thing you mean.
|
||||
|
||||
An unavoidably English term that has no Korean equivalent gets introduced once as `한글 뜻(English)` and
|
||||
then used in Hangul. Do not carry the Latin form through the whole section.
|
||||
|
||||
### Order: define, then what happens, then the problem, then the replacement
|
||||
|
||||
Where the source explains a term or a mechanism, keep that order: what it is → how it is used → what goes
|
||||
wrong → what to use instead. Deferring the definition makes the reader carry an unknown word through two
|
||||
paragraphs. Skip the fourth step when the source never considered an alternative.
|
||||
|
||||
TechLog records link to each other, so a record can be short. It still has to carry its own core claim and
|
||||
the prerequisite knowledge that claim needs. Deeper background belongs in a linked record; the definition
|
||||
a reader needs to parse *this* sentence does not.
|
||||
|
||||
### Join cause and effect inside one sentence
|
||||
|
||||
Use `~기 때문에`, `~다 보니`, `~어서`, `~(으)므로`, `~는데`, `~니`, `~면`. Do not chop a reason into
|
||||
separate sentences to satisfy a one-fact-per-sentence rule — `A였다. B였다. 그래서 C였다.` is machine
|
||||
Korean, and no Korean tech blog writes that way.
|
||||
|
||||
**Break a sentence when the subject changes, not when the second clause starts.** Same subject carrying a
|
||||
reason or condition stays in one sentence.
|
||||
|
||||
Measured across the five reference articles: prose sentences average **56–66 characters**, and fewer than
|
||||
5% run past 120.
|
||||
|
||||
**That average is a mixture, not a target length for every sentence.** Joining every reason into a
|
||||
compound sentence pushes the average to 80+ and makes the section as hard to read as the choppy version
|
||||
it replaced. Some sentences are *supposed* to be short, and they are always the same five jobs:
|
||||
|
||||
| 짧게 끊는 문장 (20~35자) | 예 |
|
||||
|---|---|
|
||||
| 정의 한 줄 | `'진입점'은 사용자 요청의 시작점을 의미합니다.` |
|
||||
| 다음에 볼 것 예고 | `먼저 할당 API를 살펴보겠습니다.` |
|
||||
| 수치 한 줄 | `쿼리를 수행한 인덱스의 문서 수는 4천만 건입니다.` |
|
||||
| 코드·표로 넘기기 | `당시 쿼리는 다음과 같은 구조로 작성되어 있었습니다.` |
|
||||
| 방향 전환 | `다만, 이와 같은 해결 방법에도 문제점이 있습니다.` |
|
||||
|
||||
Only the explanatory sentence — the one carrying a cause, a condition, or a consequence — earns 60–90
|
||||
characters. Definitions, announcements, bare numbers, and hand-offs stay short. Do not weld them onto the
|
||||
sentence next door to satisfy the joining rule.
|
||||
|
||||
None of these five require the vivid register. They are the reason the reference articles have short
|
||||
sentences without inventing an experience.
|
||||
|
||||
This cuts both ways. Joining is the fix for choppy prose, but a sentence that runs through two subjects,
|
||||
two measurement scales, or two results is now too long — split it at the point where the subject changes.
|
||||
`N=10에서는 ~ 문제가 보이지 않았는데, N=1,000에서는 ~ 50.0×까지 벌어졌습니다` is two sentences wearing
|
||||
one comma.
|
||||
|
||||
### Vary how sentences end
|
||||
|
||||
The reference articles use **four to six different sentence endings**; every document in this repository
|
||||
before the rewrite used two. That single number is most of what makes the prose feel machine-made, and it
|
||||
is the easiest thing to fix.
|
||||
|
||||
| 끝맺음 | 쓰는 자리 |
|
||||
|---|---|
|
||||
| `~합니다` / `~했습니다` | 사실·측정·코드 동작. 대부분 여기다 |
|
||||
| `~입니다` | 정의, 지금 무엇인지 |
|
||||
| `~하겠습니다` / `~살펴보겠습니다` | 다음에 무엇을 볼지 예고 |
|
||||
| `~할까요?` / `~뭐죠?` | 독자가 품을 물음을 대신 꺼낼 때 |
|
||||
| `~해봅시다` / `~확인해봅시다` | 수치나 코드로 넘어갈 때 |
|
||||
| `~지만` / `~인데요` | 앞과 어긋나는 것을 이어 붙일 때 |
|
||||
|
||||
Do not sprinkle these to hit a count. Each one belongs to a job: a heading that asks a question, a
|
||||
sentence that hands off to a table, a line that announces the next section. When those jobs are being
|
||||
done, the variety appears on its own. When the whole section is flat `~했습니다`, it usually means those
|
||||
jobs are not being done at all — the document is a list of facts with no one walking the reader through it.
|
||||
|
||||
### Say what happened with a verb, and pick the verb the context takes
|
||||
|
||||
`쿼리가 나갔습니다` / `응답 속도가 개선되었습니다` / `약 1분이 소요되었습니다` / `문제가 발생합니다` /
|
||||
`AST 노드를 순회합니다` / `위반으로 잡습니다`. The register file has the full context→verb table.
|
||||
|
||||
Do not turn an event into a counted noun phrase — `채워진 목록 수`, `준비한 SQL 문장`,
|
||||
`획득한 문장 객체 수` — to keep a metric name technically safe. Code behavior takes present tense
|
||||
(`~합니다`); measurements and things that happened take past tense (`~했습니다`). Do not mix them.
|
||||
|
||||
### Never invent a private idiom for a numeric relationship
|
||||
|
||||
`~를 따라 늘었다`, `~를 따라갔다`, `~를 좇았다` are not Korean. `따라가다` takes a person, a path, or a
|
||||
standard — not a count. Write the relationship the way it is actually said:
|
||||
|
||||
| 관계 | 쓴다 |
|
||||
|---|---|
|
||||
| 같은 수 | `아이템이 100개면 조회도 100번 나갔습니다` · `N과 같은 수로 늘었습니다` |
|
||||
| 비례 | `N에 비례해 늘었습니다` · `N이 커진 만큼 늘었습니다` |
|
||||
| 배수 | `약 3배 증가했습니다` · `50배까지 벌어졌습니다` |
|
||||
| 변하지 않음 | `N을 바꿔도 2개로 그대로였습니다` · `페이지 크기에 고정되었습니다` |
|
||||
| 단위마다 증가 | `배치 크기마다 한 번씩 늘었습니다` |
|
||||
|
||||
Numbers take the shape `<잰 것>이/가 <수치만큼> <동사>했습니다`, with before/after as
|
||||
`기존에는 ~, 개선 후에는 ~`. Keep `약`, `이상`, `정도`, and every unit exactly as the source had them.
|
||||
|
||||
### Particles
|
||||
|
||||
`이/가` marks the measured subject. `은/는` marks a before/after contrast. `(으)로` marks the resulting
|
||||
state. `에 비해`/`보다` marks a comparison. Do not chain `의` three deep — `조회 수의 증가 형태의 비교`
|
||||
becomes `조회 수가 어떻게 늘었는지`. Do not join nouns with `~에 대한`; use the verb —
|
||||
`쿼리 수에 대한 측정` becomes `쿼리 수를 측정했습니다`.
|
||||
|
||||
### Choose the subject by what kind of sentence it is
|
||||
|
||||
Decisions and actions take a person (`저는 ~하기로 했습니다`). Results and observations take the measured
|
||||
thing with a passive verb (`슬로우쿼리가 모두 제거되었습니다`). Code explanations take the code element
|
||||
(`이 규칙은 ~를 허용하는데`). Drop the subject when the previous sentence already fixed it.
|
||||
|
||||
## Name the thing, not its role in your argument
|
||||
|
||||
`기준선`, `비교 대상`, `최소한의 선`, `위반`, `핵심`, `본질`, `구조적 문제`, `증가 형태`, `실체`, and a
|
||||
bare `관계` name a slot in an argument instead of naming the thing. A word that only tells the reader how
|
||||
to read — `읽으면 안 된다`, `봐야 한다`, `주의해서 보자` — is not a fact either. State the observation
|
||||
that would make them read it that way.
|
||||
|
||||
| 쓰지 않는다 | 쓴다 |
|
||||
|---|---|
|
||||
| 이 구현을 기준선으로 삼았다 | 이 코드를 그대로 두고 측정했다 |
|
||||
| 같은 기준선에 두 가지 위반이 있었다 | 어떤 요구가 어떤 두 가지 방식으로 깨졌는지 적는다 |
|
||||
| 현재 기준선에는 batch가 없다 | 이 구현에는 batch 설정이 없다 |
|
||||
| 두 값은 비교 대상이 아니다 | 두 값은 세는 것이 다르다. A는 `<A가 세는 것>`, B는 `<B가 세는 것>`이다 |
|
||||
| 최소한의 선은 지켰다 | `<지킨 조건>`은 지켰다 |
|
||||
| 두 엔티티의 관계가 문제였다 | `FeedItem`과 `Highlight`의 `@OneToMany` 매핑이 문제였다 |
|
||||
| 이 실행계획을 최적이라고 읽으면 안 된다 | 실행 시간이 0.173 ms라고 해서 필요한 만큼만 읽는 것은 아니다 |
|
||||
| 증가 기준은 A가 아니라 B였다 | A가 늘어도 그대로였고, B가 늘 때 같이 늘었다 |
|
||||
|
||||
These are replacements, not deletions. The fact the framing word was standing in for still has to be in
|
||||
the rewrite. `관계` is fine as part of a real name (`연관 관계`, `@ManyToOne 관계`); it is not fine as a
|
||||
stand-in for a mapping you did not name.
|
||||
|
||||
## Delete the sentence that only sets up the next one
|
||||
|
||||
Cut every sentence that prepares, frames, or restates:
|
||||
|
||||
- a first sentence that repeats the heading (`반복되는 ~를 실행계획으로 확인했다` under a heading that says so);
|
||||
- a scene-setter before the explanation (`이 코드는 반복문이 없는 상황이다`, `여기서는 ~를 다룬다`);
|
||||
- a wrap-up that announces what you just showed (`이 관찰은 두 가지를 보여준다`).
|
||||
|
||||
**Test: delete it and ask what the reader lost.** If nothing, it stays deleted.
|
||||
|
||||
A forward-looking sentence that tells the reader *from what angle* the next part is examined is different,
|
||||
and Korean tech blogs do write it — `이번에는 ~를 ~ 중심으로 살펴보겠습니다`. That adds information the
|
||||
heading did not carry. Keep at most one per section, and only when it names the angle.
|
||||
|
||||
## Do not package the source
|
||||
|
||||
Do not newly introduce slogans, metaphors, or polished conclusions such as:
|
||||
|
||||
- `이는 ~를 보여준다`
|
||||
- `결국 문제는 ~이다`
|
||||
- `단순히 ~가 아니라 ~이다`
|
||||
- `비용이 ~로 이동했다`
|
||||
- `새로운 책임이 생긴다`
|
||||
- `정반대의 결과를 보였다`
|
||||
- `회계 항등식`
|
||||
|
||||
These strings are not a blind deletion list. If the source explicitly makes the same claim, restate it
|
||||
with the concrete facts that support it. Do not add a lesson, advantage, drawback, recommendation, or
|
||||
causal explanation just because it would complete the paragraph. End after the supported cause or result;
|
||||
do not force every paragraph into observation → interpretation → lesson.
|
||||
|
||||
**Two things this rule does not ban.** Korean tech blogs use both, and cutting them makes the prose
|
||||
worse, not cleaner:
|
||||
|
||||
- **Steering the reader inside the article.** `중요한 것은 아래 세 가지 컨벤션을 반드시 지켜야 한다는
|
||||
점입니다` picks which of the things just listed to carry forward. That is navigation, not a
|
||||
manufactured conclusion. The banned use is the same phrase pasted next to a measurement to make the
|
||||
data look like it proved something it did not.
|
||||
- **A closing opinion in the closing section.** `26388` ends with `AI는 요술램프가 아닙니다 … 안목이 더욱
|
||||
중요해지고 있습니다`. That belongs in 맺는 글, is the author's own view, and appears once. Keep one the
|
||||
source already states; never write a new one, and never let it migrate into the middle of the document.
|
||||
|
||||
Use contrast words only when the contrast is needed to understand the facts. Do not manufacture symmetry
|
||||
with `반면`, `반대로`, `이에 비해`, or `하지만`.
|
||||
|
||||
Korean tech blogs also carry vivid, personal, sometimes funny sentences. **Do not import that register.**
|
||||
It comes from something the author actually lived through. Inventing an experience, a failure, an emotion,
|
||||
or a first-person aside that the source does not record breaks this repository's rules. What transfers
|
||||
without a source is plain verbs, concrete nouns, reasons joined inside the sentence, and definitions
|
||||
placed first.
|
||||
|
||||
## Headings
|
||||
|
||||
A heading names what the section examines or what it does. These are the shapes the reference articles
|
||||
actually use — none of them builds a contrast or poses a riddle:
|
||||
|
||||
| 형태 | 실제 제목 |
|
||||
|---|---|
|
||||
| 용어를 묻는다 | `WMS란?` · `진입점이 뭐죠?` · `MDC를 아시나요?` · `공간 (Spatial) 데이터 타입이란?` |
|
||||
| 이유를 묻는다 | `근데 왜 진입점 정보가 남아야 해요?` |
|
||||
| 상황을 묻는다 | `할당과 취소를 동시에 요청한다면?` |
|
||||
| 동작 + 목적 | `한글 문구에 번역 API를 사용해 번역 누락 막기` · `<Trans> 계열 컴포넌트는 필요할 때만 사용해 코드 복잡도 낮추기` |
|
||||
| 단계 | `1 단계: 분산 락 추가하기` · `2 단계: 분산 락 대기하기` |
|
||||
| 청유 | `할당과 취소가 동시에 처리되는 것을 막아보자` |
|
||||
| 대상 | `공간데이터 및 GeoJSON` · `@lib/i18n과 세 가지 컨벤션` |
|
||||
| 상태·한계 | `사람과 AI 검수의 한계` · `한계점` · `남은 과제들` |
|
||||
|
||||
`현재 구현 공백과 잘못 읽기 쉬운 지점` 같은 분류형 제목은 이 목록에 없다. 보고서의 절 이름이지
|
||||
블로그 글의 제목이 아니다. 한계를 분류해서 한곳에 모으지 말고, 그것이 제한하는 대상 옆에 적는다.
|
||||
| 고정 칸 | `현상` · `문제 원인 분석 및 해결` · `개선 결과` · `해결방법` · `문제점` |
|
||||
|
||||
`동작 + 목적`(`~해 ~하기`) is the one to reach for when a section describes a fix: it names the action and
|
||||
what the action buys, and it cannot become a slogan because both halves are concrete.
|
||||
|
||||
The `현상 / 문제 원인 분석 및 해결 / 개선 결과` triple repeats five times in `20161`. When a document walks
|
||||
through several independent cases, reusing one fixed set of headings is clearer than inventing a fresh
|
||||
phrase per case.
|
||||
|
||||
## Explain one scale, and let the table carry the series
|
||||
|
||||
Pick one N for the worked example and stay there. Picking the largest N to sound dramatic is padding.
|
||||
Restating 10/100/1,000 in every sentence forces the reader to re-orient each time; the table already
|
||||
shows the shape of the growth.
|
||||
|
||||
## Figures
|
||||
|
||||
A figure earns its place only when it carries something the sentences cannot: a sequence with actors and
|
||||
order, a structure with parts and boundaries, a measurement with axes and values, or a captured artifact —
|
||||
a log, a plan, a screen. Three boxes and two arrows that redraw one sentence
|
||||
(`요청 → 초기화 N회 → SELECT N회`) add nothing; the sentence already said it, and the alt text says it a
|
||||
third time. Delete the figure instead of writing a caption that apologizes for it.
|
||||
|
||||
Before keeping a figure, say what a reader learns from it that the paragraph next to it does not tell
|
||||
them. If there is no answer, remove it.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Symptom | Rewrite direction |
|
||||
|---|---|
|
||||
| Numbers became an adjective or trend | Restore every value and its condition |
|
||||
| A cause became `캐시 효과` or another summary | State the actual reuse, query, or state change |
|
||||
| Two results became a polished contrast | Explain each result in the order observed |
|
||||
| A sentence became shorter but denser | Restore the subject, action, and reason |
|
||||
| A paragraph ends with a generic lesson | Remove the lesson unless the source stated it |
|
||||
| A heading sounds like a slogan or riddle | Name the checked operation, object, or limit |
|
||||
| An API or metric name appears with no explanation | Add one sentence defining it at first use and keep the name |
|
||||
| A framing noun (`기준선`, `비교 대상`, `관계`) stands in for the thing | Name the method, request, mapping, or requirement |
|
||||
| The sentence tells the reader how to read | Replace it with the observation that supports it |
|
||||
| A metric name pushed the sentence into a noun phrase | Say what happened with a verb; leave the metric name and its caution in the body |
|
||||
| `~를 따라 늘었다` / `~를 따라갔다` | Use the real relationship: 같은 수 · 비례 · 배수 · 고정 |
|
||||
| Sentences are all short and choppy | Rejoin with `~기 때문에`, `~다 보니`, `~어서`; break only where the subject changes |
|
||||
| One sentence runs through two subjects, scales, or results | Split it at the subject change |
|
||||
| A metric is glossed from its identifier name | Only define it from the source, the code, or the framework docs; otherwise leave it undefined and say so |
|
||||
| `의`가 세 겹, or `~에 대한` | Unfold into a verb |
|
||||
| Code and measurement mix tense | Code `~합니다`, measurement `~했습니다` |
|
||||
| A sentence only prepares or restates the next one | Delete it |
|
||||
| The example jumps between N=10, 100, 1,000 | Pick one scale and explain there; leave the series to the table |
|
||||
| A figure redraws a sentence | Remove it, or replace it with a log, plan, or measurement it cannot say |
|
||||
| A possibility sounds certain | Restore the original modality and unverified scope |
|
||||
|
||||
## Mechanical pass
|
||||
|
||||
Run the checker on the rewritten file before the final check.
|
||||
|
||||
```bash
|
||||
node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs [--doc] [--warn] <파일.md>
|
||||
```
|
||||
|
||||
`--doc` adds the whole-document checks (prerequisite knowledge, running order, closing); use it when you
|
||||
rewrote a full document, not a single section. `--warn` shows the judgment-call findings too.
|
||||
|
||||
`--rules` is for rule documents — `README.md`, `CLAUDE.md`, the skill files themselves. Those are lists of
|
||||
items ending in `~한다`, and mixing in `~살펴보겠습니다` to satisfy a count makes them worse, so it turns
|
||||
off `monotone-endings` and `no-reader-steering`. Every other rule still runs. Do not reach for it on prose:
|
||||
those two errors are the ones that catch machine writing in an article.
|
||||
|
||||
It reports two levels. **`error` must be zero before you call the rewrite done** — these are the
|
||||
regressions that keep coming back, plus the four things whose absence made earlier rewrites read like a
|
||||
machine: a term defined after its first use, one single sentence ending used throughout, no sentence that
|
||||
carries the reader, and a missing closing. **`warn` is a prompt to look**, not a defect: `반면` is right
|
||||
where the source really contrasts, `관계` is right inside `연관 관계`, and plenty of acronyms
|
||||
(`SKU`, `GS`, `AOP`) are left unexpanded by good writers.
|
||||
|
||||
**The baseline is the reference articles themselves.** All five Woowahan articles in
|
||||
[references/document-skeleton.md](references/document-skeleton.md) pass with zero errors. If you add a
|
||||
rule, re-run it against them — a rule those articles fail is a rule that is stricter than the standard,
|
||||
and it will push you into contorting prose to satisfy a check no human writer meets.
|
||||
|
||||
Then take the style profile:
|
||||
|
||||
```bash
|
||||
node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs <파일.md>
|
||||
```
|
||||
|
||||
It prints six numbers and flags any that fall outside the band measured on the five reference articles.
|
||||
The two that catch machine prose almost every time are **종결어미 종류 수** (reference 4–6; this
|
||||
repository's documents scored 2 across the board) and **이유 연결어미 / 문장 100개** (reference 6.7–23.6).
|
||||
A number outside the band is a symptom to trace back to a real sentence, never something to fix by
|
||||
padding — inserting `~해봅시다` to raise a count produces exactly the kind of writing this skill exists to
|
||||
remove.
|
||||
|
||||
Clean output does not mean the rewrite is good. Both tools read surface patterns and cannot see meaning;
|
||||
every rule above still applies, and the read-aloud test below is the one that decides.
|
||||
|
||||
## Final check
|
||||
|
||||
First, read every rewritten sentence aloud and ask: **would a Korean-speaking developer say this to a
|
||||
colleague this way?** A sentence that is accurate but unspeakable is not finished. Fix it by adding the
|
||||
condition as a short clause, never by bending a word into an unusual grammatical role.
|
||||
|
||||
Then compare the source and rewrite sentence by sentence:
|
||||
|
||||
1. Are all numbers, units, code names, identifiers, and observed results preserved?
|
||||
2. Are cause and result still connected in the same direction?
|
||||
3. Are comparison targets and axes unchanged?
|
||||
4. Did a possibility, inference, proposal, or unknown become a confirmed fact?
|
||||
5. Was any new cause, benefit, drawback, conclusion, or recommendation added?
|
||||
6. Did any exception, failure, condition, or unverified scope disappear?
|
||||
7. Did a concrete technical statement become a broader abstraction?
|
||||
8. Can every rewritten claim be pointed back to a specific source claim?
|
||||
9. Is every API name, counter, and internal metric defined where it first appears, with what it counts unchanged?
|
||||
10. Does the reader have the prerequisite knowledge to follow the core claim, or does an undefined term still block them?
|
||||
11. Did any experience, emotion, or first-person aside appear that the source does not record?
|
||||
|
||||
If any answer reveals a mismatch, rewrite again or restore the original sentence. Do not declare the edit
|
||||
complete until the mismatch is gone.
|
||||
@@ -0,0 +1,182 @@
|
||||
# 글의 뼈대 — 관찰 기록
|
||||
|
||||
> **이 문서는 채워 넣을 틀이 아니다.** 아래 다섯 편이 실제로 어떤 순서를 썼는지 적어 둔 것이다.
|
||||
> 여기 있는 칸을 전부 채우려 들면 문서마다 같은 목차가 나오고, 문장은 한국어인데 글은
|
||||
> 보고서 생성기 출력처럼 읽힌다. 실제로 그렇게 됐고, 그래서 이 경고를 맨 앞에 둔다.
|
||||
>
|
||||
> 쓰는 법: 어떤 수가 있는지 보고, **이 문서에 필요한 것만 source가 정하게 한다.**
|
||||
> 자료가 말하지 않는 칸은 비우고, 자료가 채울 수 있어도 이 글에 필요 없으면 역시 비운다.
|
||||
|
||||
# 글의 뼈대
|
||||
|
||||
우아한형제들 기술블로그 5편의 목차와 도입·마무리를 그대로 읽고 정리한 것이다. 낱낱의 문장이 아니라
|
||||
**글 전체가 어떤 순서로 서는지**를 담는다. 인용은 아래 글에서 가져왔고, 브라우저로 페이지를 직접 열어
|
||||
옮겼다.
|
||||
|
||||
- [사람도 AI도 놓친 번역 누락, ESLint 플러그인을 만들어 해결하기](https://techblog.woowahan.com/26388/) — 이하 `26388`
|
||||
- [WMS 재고 이관을 위한 분산 락 사용기](https://techblog.woowahan.com/17416/) — `17416`
|
||||
- [로그 및 SQL 진입점 정보 추가 여정](https://techblog.woowahan.com/13429/) — `13429`
|
||||
- [검색 성능 개선을 위한 Elasticsearch 인덱스 구조와 쿼리 최적화](https://techblog.woowahan.com/20161/) — `20161`
|
||||
- [나 4년 차 서버개발자, 배달의민족의 지리 체계를 뒤흔들다](https://techblog.woowahan.com/11238/) — `11238`
|
||||
|
||||
이 다섯 편은 `scripts/style_profile.mjs`가 쓰는 기준선이기도 하다. 원문을 다시 받으려면
|
||||
`node scripts/fetch_reference.mjs <디렉터리>`를 쓴다. 사이트가 curl과 리더 프록시를 403으로 막으므로
|
||||
실제 브라우저가 필요하고, `playwright-core`가 있어야 한다. 받은 뒤
|
||||
`node scripts/style_profile.mjs --baseline <디렉터리>/*.md`로 값을 다시 잰다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 다섯 편이 공유하는 순서
|
||||
|
||||
| 자리 | 26388 | 17416 | 13429 | 20161 | 11238 |
|
||||
|---|---|---|---|---|---|
|
||||
| 왜 이 글인가 | 여는 글 | (첫 문단) | (첫 문단) | (첫 문단) | 포스팅 목적 |
|
||||
| **말 뜻 정하기** | @lib/i18n과 세 가지 컨벤션 | **WMS란?** | **진입점이 뭐죠?** · **MDC를 아시나요?** | 성능개선을 돕는 도구 | **공간 (Spatial) 데이터 타입이란?** |
|
||||
| 무슨 일이 있었나 | 험난한 컨벤션 준수의 길 | 할당과 취소를 동시에 요청한다면? | 근데 왜 진입점 정보가 남아야 해요? | 현상 | 프로젝트 배경 |
|
||||
| 왜 그랬나 | 사람과 AI 검수의 한계 | 동시성 이슈 원인 | — | 문제 원인 분석 및 해결 | 방향성 검토 |
|
||||
| 어떻게 했나 | 린트로 위반 탐지하고 AI로 교정하기 | 1 단계: 분산 락 추가하기 | 기본 작업 · 추가 작업 | 해결 방안 | 개발 |
|
||||
| 결과 | 린트 플러그인의 성과 | (단계마다 문제점) | — | 개선 결과 | 결과 · 검증 |
|
||||
| 닫기 | 맺는 글 | 마무리 | 마무리 | 맺으며 | 회고 |
|
||||
|
||||
**용어를 정하는 자리가 항상 문제보다 앞에 있다.** 다섯 편 예외가 없다. 독자가 모르는 말을 안고
|
||||
문제 설명을 따라가게 두지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 첫 문단은 세 가지를 한다
|
||||
|
||||
`17416`의 도입은 세 문장이고, 셋이 각각 다른 일을 한다.
|
||||
|
||||
> "WMS 재고 이관 과정에서 발생한 동시성 이슈를 분산 락(Distributed Lock)을 사용해 해결한 경험을 공유하는 글입니다. 본 글은 분산 락에 대해 알고 있는 분들을 대상으로 작성되었습니다. 제가 경험한 내용들이 여러분들의 비즈니스에 도움이 되는 글이 되길 바랍니다."
|
||||
|
||||
1. **이 글이 무엇인가** — `<무엇>에서 <무슨 일>을 <어떻게> 한 <경험/과정>을 공유하는 글입니다`
|
||||
2. **누가 읽는 글이고, 무엇을 알고 있어야 하는가**
|
||||
3. 바람 한 줄
|
||||
|
||||
`26388`도 같은 자리에 같은 문장을 둔다.
|
||||
|
||||
> "이 글은 다국어 라이브러리를 사용하거나, 팀의 까다로운 컨벤션 유지를 위해 AI 및 린트를 활용하는 개발자를 대상으로 합니다. 린트 플러그인을 구현한 경험이 없어도 쉽게 읽을 수 있게 정리했습니다."
|
||||
|
||||
`11238`은 독자를 둘로 나눠서 각각에게 읽는 법을 준다.
|
||||
|
||||
> "취업 준비 중인 분들이라면 프로젝트 과정을 간접적으로 경험해 보시면 좋겠고, 현업에 계신 분들이라면 속한 부서에서 진행하는 방법과 차이를 비교해 보면서 읽으시면 좋겠습니다."
|
||||
|
||||
**독자와 선수 지식의 바를 도입에서 못 박는다.** 이 문장이 있으면 본문에서 어디까지 풀어 써야 하는지가
|
||||
정해진다. 없으면 글 전체가 흔들린다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 도입 끝에 차례를 알린다
|
||||
|
||||
> `17416` — "본 글에서는 WMS 재고를 이관하는 과정에서 마주친 동시성 문제에 대해 살펴보고, 어떤 방법으로 동시성 이슈를 해결해 나갔는지에 대해 공유합니다."
|
||||
|
||||
> `11238` — "프로젝트는 다음 순서대로 소개해 보겠습니다. — 프로젝트 배경 / 방향성 검토 / 개발 / 검증 / 회고"
|
||||
|
||||
> `26388` — "먼저 문제의 출발점이 된 @lib/i18n 라이브러리와 컨벤션부터 살펴보겠습니다."
|
||||
|
||||
한 줄이든 목록이든, **읽는 사람이 지금 어디쯤인지 알 수 있게 한다.**
|
||||
|
||||
---
|
||||
|
||||
## 4. 용어 절의 생김새
|
||||
|
||||
제목부터 묻는 형태다.
|
||||
|
||||
| 제목 | 글 |
|
||||
|---|---|
|
||||
| `WMS란?` | 17416 |
|
||||
| `진입점이 뭐죠?` | 13429 |
|
||||
| `MDC를 아시나요?` | 13429 |
|
||||
| `공간 (Spatial) 데이터 타입이란?` | 11238 |
|
||||
| `근데 왜 진입점 정보가 남아야 해요?` | 13429 |
|
||||
|
||||
안에서 하는 일은 셋이다.
|
||||
|
||||
1. **한 문장 정의** — `WMS(Warehouse Management System, 창고 관리 시스템)는 물류센터에서 반복되는 수기 작업을 시스템화하여 안정적으로 운영될 수 있도록 합니다.`
|
||||
2. **이 글에서 쓸 말을 직접 정함** — `편의상 "화면에 렌더링되어 사용자에게 노출되는 문자열"을 "문구"라고 하겠습니다.` · `이들을 모두 묶어서 "번역 API"라고 표현하겠습니다.`
|
||||
3. **주변 관계를 한 문단으로** — `WMS 재고들은 중앙물류기지라고 불리는 DC(Distribution Center)로 입고되며, DC에 입고된 상품들은 지역 거점 센터인 PPC(Picking Packing Center)로 재고가 이관됩니다.`
|
||||
|
||||
약어는 나오는 자리에서 전부 편다. `WMS(Warehouse Management System, 창고 관리 시스템)`,
|
||||
`DC(Distribution Center)`, `PPC(Picking Packing Center)`, `MDC(Mapped Diagnostic Context)`,
|
||||
`AST(Abstract Syntax Tree)`, `분산 락(Distributed Lock)`, `보간(Interpolation)`.
|
||||
|
||||
---
|
||||
|
||||
## 5. 사례 하나를 다루는 작은 틀
|
||||
|
||||
`20161`은 같은 세 칸을 다섯 번 반복한다.
|
||||
|
||||
```
|
||||
현상 → 문제 원인 분석 및 해결 → 개선 결과
|
||||
```
|
||||
|
||||
`17416`은 단계마다 자기 문제를 달고 간다.
|
||||
|
||||
```
|
||||
1 단계: 분산 락 추가하기 → 해결방법 → 문제점
|
||||
2 단계: 분산 락 대기하기 → 해결방법 → 문제점
|
||||
3 단계: 분산 락과 상태 키 함께 사용하기 → 해결방법
|
||||
```
|
||||
|
||||
**고친 방법마다 남은 문제를 같이 적는다.** 마지막 단계에 와서야 `문제점`이 없다. 처음부터 정답을
|
||||
내놓지 않고, 왜 다음 단계가 필요했는지를 앞 단계의 `문제점`이 만든다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 독자를 데리고 다니는 문장
|
||||
|
||||
이 글들은 독자가 무엇을 궁금해할지 알고 미리 처리한다. 내 스킬이 가장 크게 놓쳤던 부분이다.
|
||||
|
||||
| 하는 일 | 문장 |
|
||||
|---|---|
|
||||
| 곁길 막기 | "여기서 번역 API의 내부 동작이 궁금할 수 있겠지만 딴 길로 새지 맙시다." |
|
||||
| 나중으로 미루기 | "그리고 유형별로는 문구의 포맷 차이가 있는데, 나중에 살펴보겠습니다." |
|
||||
| 초점 잡기 | "중요한 것은 아래 세 가지 컨벤션을 반드시 지켜야 한다는 점입니다." |
|
||||
| 다음 칸 예고 | "이번에는 그렇게 탄생한 규칙들을 깊게 파보겠습니다." |
|
||||
| 수치로 넘어가기 | "숫자 없이 복잡한 글만으로는 효능이 마음에 와닿지 않는 듯하니, 제작한 플러그인의 규칙이 적발한 컨벤션 위반 개수를 확인해봅시다." |
|
||||
| 범위 좁히기 | "전체적인 내용은 기술적인 내용보다는 ~ 전체 과정을 소개하는 데 집중했습니다." |
|
||||
|
||||
**`중요한 것은 ~입니다`는 금지어가 아니다.** 여기서는 앞에 늘어놓은 것 중 무엇을 들고 갈지 고르는
|
||||
말이고, 자료에 없는 결론을 만드는 말이 아니다. 금지되는 쓰임과 구분해야 한다.
|
||||
|
||||
- 쓴다 — 이 글 안에서 독자의 눈을 어디로 보낼지 정할 때
|
||||
- 안 쓴다 — 측정값 옆에 붙여 자료가 증명하지 않은 해석을 결론처럼 얹을 때
|
||||
|
||||
---
|
||||
|
||||
## 7. 마무리 절
|
||||
|
||||
`26388`의 맺는 글은 네 걸음이다.
|
||||
|
||||
> "지금까지 커머스 웹프론트에서 다국어 지원을 위해 도입한 @lib/i18n의 컨벤션 준수 이슈와 그 해결 과정을 살펴봤습니다. 사람은 실수를 하고 AI는 확률론적이다 보니 컨벤션 위반의 미탐과 오탐이 빈번했기 때문에, 결정론적인 린트 규칙을 구현해 탐지하고 교정은 자연어에 능숙한 AI에게 맡기는 하이브리드 접근을 택했습니다. 그 결과 다량의 번역 누락과 오역을 방지하고 코드 복잡도도 낮췄습니다."
|
||||
>
|
||||
> "AI는 요술램프가 아닙니다. ... 결정론과 확률론의 경계를 구분하고 적재적소에 일을 맡기는 안목이 더욱 중요해지고 있습니다. 이 글이 비슷한 고민을 하시는 분들에게 도움이 되면 좋겠습니다."
|
||||
|
||||
1. `지금까지 ~를 살펴봤습니다` — 다룬 범위를 되짚는다
|
||||
2. `[원인]이다 보니 [문제]했기 때문에, [해결]을 택했습니다` — 한 문장으로 압축한 줄거리
|
||||
3. `그 결과 ~` — 성과
|
||||
4. 글쓴이 자신의 생각 + 독자에게 건네는 인사
|
||||
|
||||
**4번은 마무리 절에만 온다.** 본문 문단 끝마다 붙는 교훈과는 다른 것이다. 그리고 이것은 **글쓴이가
|
||||
실제로 가진 생각**이라 자료에 있을 때만 옮긴다. 없으면 1~3만 쓰고 끝낸다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 이 저장소에 적용할 때
|
||||
|
||||
TechLog 기록은 서로 링크로 이어지고, 우아한형제들 글보다 짧다. 그래도 위 뼈대에서 **빼면 안 되는
|
||||
자리**가 있다.
|
||||
|
||||
| 자리 | 필수 여부 |
|
||||
|---|---|
|
||||
| 이 기록이 무엇을 다루는지 한 문장 | 필수 |
|
||||
| 독자와 선수 지식의 바 | 필수 |
|
||||
| 처음 쓰는 말의 정의 (본문 안, 첫 사용 앞) | 필수 |
|
||||
| 무슨 일이 있었나 · 왜 그랬나 | 필수 |
|
||||
| 어떻게 했나 · 결과 | 자료에 있으면 필수 |
|
||||
| 단계마다 남은 문제 | 자료에 있으면 필수 |
|
||||
| 차례 예고 | 절이 셋 이상이면 |
|
||||
| 글쓴이의 생각 | 자료에 있을 때만 |
|
||||
|
||||
없는 자리를 지어내지 않는다. **자료에 없으면 그 칸은 비운다.** 이 문서는 무엇을 채울 수 있는지를
|
||||
말할 뿐, 채울 내용을 만들어도 된다는 뜻이 아니다.
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
# 한국 기술 블로그 문장 규범
|
||||
|
||||
한국 대기업 기술 블로그가 실제로 쓰는 문장을 모아 정리한 것이다. 인용문은 아래 글에서 가져왔다.
|
||||
|
||||
- 우아한형제들 — [번역 누락을 막는 ESLint 플러그인](https://techblog.woowahan.com/26388/)
|
||||
- 우아한형제들 — [분산 락으로 재고 이관 동시성 해결](https://techblog.woowahan.com/17416/)
|
||||
- 우아한형제들 — [Elasticsearch 인덱스 구조와 쿼리 최적화](https://techblog.woowahan.com/20161/)
|
||||
- 우아한형제들 — [배달의민족 지리 체계 개선](https://techblog.woowahan.com/11238/)
|
||||
- 우아한형제들 — [로그 및 SQL 진입점 정보 추가 여정](https://techblog.woowahan.com/13429/)
|
||||
- 토스 — [브라우저에서 번들링하기](https://toss.tech/article/engineering-note-6)
|
||||
|
||||
**인용문 출처에 관한 한계.** 원 사이트가 자동 수집을 막고 있어, 아래 인용문은 페이지를 그대로 내려받지
|
||||
않고 추출 도구를 거쳐 옮겼다. 문장의 어투·어미·낱말 선택을 보기에는 충분하지만 **한 글자까지 원문과
|
||||
같다고 보장하지 못한다.** 이 파일의 인용문을 문서에 직접 인용으로 옮기지 말고, 필요하면 원문 링크에서
|
||||
직접 확인한 뒤 옮긴다. 이 파일의 쓰임은 문체 관찰이다.
|
||||
|
||||
**문장 틀을 베끼라는 뜻이 아니다.** 같은 표현을 반복해서 쓰면 그것이 또 하나의 기계 문체가 된다.
|
||||
여기서 가져갈 것은 *어떤 자리에 어떤 품사와 어떤 동사를 쓰는가*이고, 버릴 것은 문장 자체다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 쓸 말은 쓰기 전에 정의한다
|
||||
|
||||
기술 블로그는 처음 쓰는 말을 그 자리에서 한 문장으로 풀고 시작한다. 정의는 그 말이 **무엇인지**와
|
||||
**무엇을 하는지**를 말하지, 이 글에서 어떤 역할을 맡는지를 말하지 않는다.
|
||||
|
||||
> "'진입점'은 사용자 요청의 시작점을 의미합니다. 애플리케이션 또는 시스템에서 사용자 요청이 최초 진입되는 지점이 바로 진입점 입니다."
|
||||
|
||||
> "MDC(Mapped Diagnostic Context)는 자바 로깅 프레임워크(slf4j 등)에서 지원하는, 현재 실행중인 쓰레드 단위에 메타 정보를 넣고 관리하는 공간입니다."
|
||||
|
||||
> "WMS(Warehouse Management System, 창고 관리 시스템)는 물류센터에서 반복되는 수기 작업을 시스템화하여 안정적으로 운영될 수 있도록 합니다."
|
||||
|
||||
> "화면에 렌더링되어 사용자에게 노출되는 문자열을 '문구'라고 하겠습니다."
|
||||
|
||||
> "할당이란? 동일한 상품이 물류 센터 내 여러 로케이션(위치)에 흩어져 있는 경우, 작업자가 출고할 상품을 선점하는 작업이 필요한데 이 작업을 할당이라고 합니다."
|
||||
|
||||
> "Payload는 특정 term에 추가로 저장할 수 있는 메타데이터를 의미합니다."
|
||||
|
||||
> "샌드박스는 브라우저에서 바로 연동 흐름을 체험하고, 테스트 연동을 해볼 수 있는 개발자 도구예요."
|
||||
|
||||
정의에 쓰는 서술어는 좁다. **`~를 의미합니다` · `~입니다` · `~하는 공간입니다` · `~하는 설정입니다`
|
||||
· `~라고 하겠습니다` · `~를 X라고 합니다`.**
|
||||
|
||||
두 가지 습관을 같이 본다.
|
||||
|
||||
- **약어는 처음 나올 때 편다.** `MDC(Mapped Diagnostic Context)`, `WMS(Warehouse Management System, 창고 관리 시스템)`, `AST(Abstract Syntax Tree)`.
|
||||
- **정의한 뒤 한 번 더 구체적으로 바꿔 말한다.** 진입점 예시가 그렇다. 첫 문장은 사전적으로,
|
||||
두 번째 문장은 이 시스템에서 어디를 가리키는지로 다시 말한다.
|
||||
|
||||
정의를 넣는 자리는 **그 말을 처음 쓰기 직전**이다. 글 끝의 용어집이나 각주가 아니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 원인과 결과는 한 문장 안에서 잇는다
|
||||
|
||||
한국어 기술 문장은 이유를 연결어미로 문장 안에 넣는다. 사실 하나마다 문장을 끊지 않는다.
|
||||
|
||||
> "행정동은 변경이 잦기 때문에, 실시간으로 반영하지 않으면 내부에서 관리하는 행정동과 실제 행정동이 달라 배달팁이 실제 '동' 기준으로 부과되지 못하는 문제가 발생합니다."
|
||||
|
||||
> "폴리곤 데이터는 실시간으로 변경되는 데이터가 아니기 때문에 메모리에 올려놓고 사용해도 무방했습니다."
|
||||
|
||||
> "카테고리ID 필드는 숫자이기 때문에 integer로 색인을 하였는데, 정확하게 일치하는 값을 찾아내는 용도로만 쓰고 있기 때문에 keyword로 타입을 변경했습니다."
|
||||
|
||||
> "높은 집중력이 요구되는 작업에서 사람은 실수 덩어리이고 LLM은 확률적이다 보니 판단력이 다소 아쉬웠습니다."
|
||||
|
||||
> "물론 린트가 자동 교정까지 해주면 가장 이상적이겠지만 자연어를 기계적으로 교정하기는 어렵다 보니, 역할을 나눠서 린트가 탐지하고 AI가 교정하는 하이브리드 접근법으로 가겠습니다."
|
||||
|
||||
자주 쓰는 이음말: **`~기 때문에` · `~다 보니` · `~어서` · `~(으)므로` · `~는데` · `~니` · `~면`.**
|
||||
|
||||
문장 길이는 대체로 40–120자다. 한 문장에 사실 하나만 담으라는 규칙을 기계적으로 적용하면
|
||||
"A였다. B였다. 그래서 C였다."처럼 끊기는데, 이렇게 쓰는 한국 기술 블로그는 없다.
|
||||
**끊는 기준은 사실의 개수가 아니라 주어가 바뀌는 지점이다.** 주어가 같고 이유·조건으로 이어지면
|
||||
한 문장에 둔다. 주어가 바뀌면 끊는다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 수치는 동사로 말한다
|
||||
|
||||
> "색인 문서의 양이 약 3배 증가했습니다."
|
||||
> "검색 및 리스팅 API 호출 수는 약 1.5배 증가했습니다."
|
||||
> "p99.9와 p99.99의 응답 속도가 20% 개선되었습니다."
|
||||
> "aggregation 수행 속도가 2배 이상 향상되었습니다."
|
||||
> "응답시간 0.7초 이상 슬로우쿼리가 모두 제거되었습니다."
|
||||
> "기존에는 약 4시간이 소요되었고, 개선 후에는 약 1분이 소요되었습니다."
|
||||
> "성능을 약 150배 향상 할 수 있었습니다."
|
||||
> "파일에서 컬럼을 읽어서 저장하기에 INSERT에 비해 약 20배 정도까지 빠를 수 있습니다."
|
||||
|
||||
틀은 단순하다. **`<잰 것>이/가 <수치만큼> <동사>했습니다`**. 앞뒤 비교는 `기존에는 ~, 개선 후에는 ~`로 둔다.
|
||||
`약`, `이상`, `정도까지`로 정밀도를 솔직하게 낮춘다.
|
||||
|
||||
### 증가 관계를 말하는 법
|
||||
|
||||
두 값이 같이 늘어난다는 말을 억지로 만들지 않는다. 실제로 쓰는 말은 이렇다.
|
||||
|
||||
| 관계 | 쓰는 표현 |
|
||||
|---|---|
|
||||
| 같은 수만큼 | `아이템이 100개면 쿼리도 100번 나갔습니다` · `N과 같은 수로 늘었습니다` |
|
||||
| 비례 | `N에 비례해 늘었습니다` · `N이 커진 만큼 늘었습니다` |
|
||||
| 배수 | `약 3배 증가했습니다` · `50배까지 벌어졌습니다` |
|
||||
| 안 변함 | `N을 바꿔도 2개로 그대로였습니다` · `페이지 크기에 고정되었습니다` |
|
||||
| 단위로 증가 | `배치 크기마다 한 번씩 늘었습니다` |
|
||||
|
||||
`~를 따라 늘었다`, `~를 따라갔다`, `~를 좇았다`는 쓰지 않는다. 한국어에서 `따라가다`의 목적어는
|
||||
사람·길·기준 같은 것이지 개수가 아니다. **`조회 수는 아이템 수 100을 따라갔다`는 한국어 문장이
|
||||
아니다.** 무엇이 몇이면 무엇이 몇이었는지를 그대로 적으면 된다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 문맥에 따른 동사 선택
|
||||
|
||||
같은 뜻이라도 자리마다 쓰는 동사가 다르다. 아래는 관찰한 글에서 실제로 쓰인 동사다.
|
||||
|
||||
| 무엇을 말할 때 | 쓰는 동사 |
|
||||
|---|---|
|
||||
| 쿼리·요청이 실행됨 | 나갔습니다 · 실행되었습니다 · 호출했습니다 |
|
||||
| 수가 늘어남 | 늘었습니다 · 증가했습니다 · 벌어졌습니다 · 부풀었습니다 |
|
||||
| 수가 줄어듦 | 줄었습니다 · 감소했습니다 · 제거되었습니다 |
|
||||
| 빨라짐·좋아짐 | 개선되었습니다 · 향상되었습니다 · 빨라졌습니다 |
|
||||
| 시간이 걸림 | 소요되었습니다 · 걸렸습니다 |
|
||||
| 문제가 나타남 | 발생합니다 · 생겼습니다 · 드러났습니다 · 초래했습니다 |
|
||||
| 문제가 사라짐 | 해소되었습니다 · 사라졌습니다 · 막았습니다 |
|
||||
| 설정을 바꿈 | 변경했습니다 · 조정했습니다 · 분기했습니다 |
|
||||
| 기능을 넣음 | 적용했습니다 · 도입했습니다 · 추가했습니다 |
|
||||
| 재보고 확인함 | 측정했습니다 · 확인했습니다 · 살펴보겠습니다 · 파보겠습니다 |
|
||||
| 코드가 훑음 | 순회합니다 · 탐색합니다 · 마주합니다 |
|
||||
| 코드가 찾아냄 | 찾아냅니다 · 잡습니다 · 탐지합니다 |
|
||||
| 코드가 판정함 | 판단합니다 · 허용합니다 · 제한합니다 · 위반으로 잡습니다 |
|
||||
| 코드가 저장·전달함 | 넣고 관리합니다 · 삽입합니다 · 표시합니다 · 전달합니다 |
|
||||
| 원인을 지목함 | ~ 때문입니다 · ~에서 비롯되었습니다 |
|
||||
| 판단을 밝힘 | ~라고 판단했습니다 · ~해도 무방했습니다 · 도입하기 무리였습니다 · 한계가 있었습니다 |
|
||||
|
||||
코드 동작을 설명하는 문장의 예시다.
|
||||
|
||||
> "린터의 원리는 AST 노드를 순회하면서 설정된 규칙 기반으로 패턴을 찾아내는 것입니다."
|
||||
> "노드에 진입·퇴장하는 이벤트마다 스택에 삽입·회수할 플래그들을 정의합니다."
|
||||
> "계속 탐색하다 보면 어느새 말단에서 세 가지 타입의 문자열에 각각 상응하는 노드를 마주합니다."
|
||||
> "내부에 JSX 텍스트만 있고 엘리먼트나 컴포넌트가 없는 `<Trans>` 컴포넌트를 위반으로 잡을 뿐입니다."
|
||||
> "이 규칙은 함수 파라미터의 기본값으로 문자열 리터럴이 들어가는 것을 허용하는데, 프로젝트에서는 최종적으로 이런 기본값이 노출될 수도 있으니 제한해야 합니다."
|
||||
|
||||
코드는 `~합니다` 현재형으로 쓴다. 측정과 겪은 일은 `~했습니다` 과거형으로 쓴다. 둘을 섞지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 조사
|
||||
|
||||
| 자리 | 조사 | 예 |
|
||||
|---|---|---|
|
||||
| 잰 대상 | `이/가` | `응답 속도가 20% 개선되었습니다` |
|
||||
| 앞뒤 대비 | `은/는` | `기존에는 4시간, 개선 후에는 1분` |
|
||||
| 바뀐 결과 상태 | `(으)로` | `keyword로 타입을 변경` · `1분이 소요` |
|
||||
| 비교 기준 | `에 비해` · `보다` | `INSERT에 비해 약 20배` |
|
||||
| 비례 기준 | `에 비례해` · `만큼` | `N에 비례해` · `N이 커진 만큼` |
|
||||
| 출처·주체 | `로부터` · `에서` | `DC 관리자로부터 문의가 들어왔습니다` |
|
||||
| 용도 한정 | `용도로만` | `일치하는 값을 찾는 용도로만 쓰고 있기 때문에` |
|
||||
|
||||
- `~를 따라`를 개수 증가에 붙이지 않는다. (3절)
|
||||
- `의`를 세 번 이상 잇지 않는다. `조회 수의 증가 형태의 비교`는 `조회 수가 어떻게 늘었는지`로 푼다.
|
||||
- 명사를 `~에 대한`으로 잇지 말고 동사로 푼다. `쿼리 수에 대한 측정` → `쿼리 수를 측정했습니다`.
|
||||
|
||||
---
|
||||
|
||||
## 6. 명사: 역할 이름이 아니라 물건 이름
|
||||
|
||||
기술 블로그는 대상을 그 대상의 이름으로 부른다. 논증에서 맡은 역할로 부르지 않는다.
|
||||
|
||||
| 쓰지 않는 말 | 쓰는 말 |
|
||||
|---|---|
|
||||
| 기준선 / 비교 대상 | 처음 만든 `loadFeed` 구현 · 이 코드를 그대로 두고 잰 값 |
|
||||
| 최소한의 선 / 마지노선 | 반드시 지켜야 하는 조건은 `<조건>`입니다 |
|
||||
| 관계 (막연한) | `FeedItem`과 `Highlight`의 `@OneToMany` 매핑 · `user_id` 외래 키 |
|
||||
| 위반 | 어떤 요구를 어떻게 어겼는지 |
|
||||
| 핵심 / 본질 / 실체 | 실제로 일어난 일 |
|
||||
| 구조적 문제 | 어떤 코드가 어떤 조건에서 무엇을 하는지 |
|
||||
| 증가 형태 / 비용 | 쿼리 수 · 조회 행 수 · 응답 시간 |
|
||||
| ~는 비교 대상이 아니다 | 두 값은 세는 것이 다릅니다. A는 `<A가 세는 것>`, B는 `<B가 세는 것>`입니다 |
|
||||
|
||||
`관계`는 JPA `연관 관계`처럼 이름의 일부일 때만 쓴다. 무엇과 무엇이 어떻게 연결되는지를
|
||||
`관계`라는 낱말로 덮으면 독자는 어느 매핑인지 알 수 없다.
|
||||
|
||||
---
|
||||
|
||||
## 6.5 라틴 문자는 식별자에만
|
||||
|
||||
관찰한 다섯 편은 자리잡은 외래어를 모두 한글로 적는다. 라틴 문자로 남는 것은 실제 식별자와 제품명뿐이다.
|
||||
|
||||
| 한글로 적는다 | 라틴으로 둔다 |
|
||||
|---|---|
|
||||
| 쿼리 · 캐시 · 인덱스 · 라이브러리 · 컴포넌트 · 플러그인 · 스레드 · 클래스 · 메서드 · 필드 · 테스트 · 세션 · 토큰 · 커넥션 · 타임아웃 · 어댑터 · 인스턴스 · 클라이언트 | `CacheAsideExecutor` · `getLoadCount()` · `min-replicas-to-write 1` · `application.yml` · `@ManyToOne` · `GETDEL` |
|
||||
| 자격 증명 · 상한 · 소유자 · 원본 · 응답 · 경고 · 계정 · 묶음 · 갈래 · 상태 · 설정 · 키 | Redis · Nginx · Hibernate · Spring · Keycloak · PostgreSQL |
|
||||
|
||||
실측: 우아한형제들은 문장당 맨몸 영문 낱말이 **1.4개**, 글자 중 한글이 **58%**다.
|
||||
같은 자리에서 이 저장소 문서는 **4.4개 / 34%**였다. 영어 낱말을 조사로 이어 붙인 문장이
|
||||
"AI가 정리한 기술 보고서"처럼 읽히는 가장 큰 이유다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 주어
|
||||
|
||||
- **결정과 행동은 사람이 주어다.** `저는 ~하기로 했습니다`, `역할을 나눠서 ~로 가겠습니다`,
|
||||
`거의 전부 AI에게 맡겼습니다`.
|
||||
- **결과와 현상은 잰 대상이 주어이고 서술어는 피동이다.** `응답 속도가 개선되었습니다`,
|
||||
`슬로우쿼리가 모두 제거되었습니다`, `약 40시간 이상이 걸릴 것으로 예측이 되었습니다`.
|
||||
- **코드를 설명할 때는 코드 요소가 주어다.** `린터의 원리는 ~`, `이 규칙은 ~를 허용하는데`,
|
||||
`@rollup/browser는 파일 시스템이 아닌 메모리상의 데이터를 다뤄요`.
|
||||
- 주어를 생략해도 앞 문장에서 분명하면 생략한다. 문단마다 주어를 다시 세우지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 문제는 사건으로 쓴다
|
||||
|
||||
> "DC 관리자로부터 취소된 이관요청서에 재고가 할당되어있다는 문의가 들어왔습니다."
|
||||
> "동시성 이슈의 원인은 취소 작업에는 분산 락이 걸려 있지 않기 때문입니다."
|
||||
> "데이터를 추출하고 보니, 이 배치를 통해 정확한 매핑 데이터를 추출하기에는 한계가 있었습니다."
|
||||
> "최초에 해당 배치를 개발하고 성능 측정을 해보았을 때, 운영환경의 데이터 기준 약 40시간 이상이 걸릴 것으로 예측이 되었습니다."
|
||||
> "그래서 기존 코드에서 많은 한글 문구들이 탐지되지 않아 번역이 누락되었고, 내부 개발용 코드의 한글 문자열이 잘못 잡히는 문제도 있었습니다."
|
||||
|
||||
`문제가 있었습니다`로 끝내지 않는다. **누가 무엇을 겪었는지, 어떤 조건에서 무엇이 어긋났는지**를
|
||||
적는다. 원인은 `원인은 ~ 때문입니다`로 한 번에 지목한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 선택과 권고
|
||||
|
||||
> "처음에는 이상적인 린트 플러그인을 섭외하여 공수를 절감하려 했지만 눈앞의 생태계는 상당히 척박했습니다."
|
||||
> "옵션 조절로도 해결이 어려운 문제가 다수 있어서 도입하기 무리였습니다."
|
||||
> "결국 컨벤션들을 충족시키는 커스텀 린트 규칙들과 이들을 포함하는 플러그인을 직접 개발하기로 했습니다."
|
||||
> "단일 term일 경우 match_phrase 쿼리가 아니라 match 쿼리로도 요구사항을 만족할 수 있기 때문에 분석된 term에 따라 쿼리를 변경하도록 쿼리를 분기했습니다."
|
||||
> "폴리곤 데이터는 실시간으로 변경되는 데이터가 아니기 때문에 메모리에 올려놓고 사용해도 무방했습니다."
|
||||
|
||||
순서가 일정하다. **먼저 해보려던 것 → 안 된 이유 → 그래서 고른 것 → 고른 이유.**
|
||||
대안을 `대안으로는 A, B가 있다`처럼 목록으로 늘어놓지 않고, 실제로 검토했다가 접은 것만 이유와 함께 쓴다.
|
||||
|
||||
권고할 때 쓰는 말: `~해야 합니다` · `~하는 편이 낫습니다` · `가급적 ~를 씁니다` · `~해도 무방했습니다`
|
||||
· `도입하기 무리였습니다`.
|
||||
|
||||
---
|
||||
|
||||
## 9.5 설명의 순서
|
||||
|
||||
용어 하나를 설명하는 대목은 대체로 같은 순서로 흘러간다.
|
||||
|
||||
1. **정의** — 그 말이 무엇이고 무엇을 하는지 (`화면에 렌더링되어 사용자에게 노출되는 문자열을 '문구'라고 하겠습니다`)
|
||||
2. **그래서 무슨 일이 벌어지는가** — 그 말이 실제 코드·운영에서 어떻게 쓰이는지
|
||||
3. **거기서 생기는 문제** — 어떤 조건에서 무엇이 어긋나는지 (`판단력이 다소 아쉬웠습니다`, `미탐과 오탐이 생겨 ~ 하락을 초래했습니다`)
|
||||
4. **그래서 무엇으로 대신하는가** — 대안과 고른 이유 (`역할을 나눠서 린트가 탐지하고 AI가 교정하는 하이브리드 접근법으로 가겠습니다`)
|
||||
|
||||
읽는 사람은 이 순서대로 알게 된다. **정의를 뒤로 미루면 2번과 3번을 읽는 동안 무슨 말인지 모른 채
|
||||
따라가야 한다.** 문제를 먼저 던지고 정의를 나중에 붙이는 구성은 극적이지만, 기술 문서에서는 독자가
|
||||
같은 문단을 두 번 읽게 만든다.
|
||||
|
||||
TechLog 기록은 서로 링크로 이어지는 관계형 문서라 분량이 짧을 수 있다. 그렇더라도 **핵심 주장과,
|
||||
그 주장을 이해하는 데 필요한 선수 지식은 그 기록 안에 있어야 한다.** 다른 기록으로 넘겨도 되는 것은
|
||||
더 깊은 배경이지, 이 문장을 읽는 데 당장 필요한 정의가 아니다.
|
||||
|
||||
4번은 자료에 근거가 있을 때만 쓴다. 대안을 검토한 적이 없으면 3번에서 멈춘다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 소제목
|
||||
|
||||
| 형태 | 예 |
|
||||
|---|---|
|
||||
| 질문형 | `진입점이 뭐죠?` · `근데 왜 진입점 정보가 남아야 해요?` · `MDC를 아시나요?` |
|
||||
| 행동형 | `1 단계: 분산 락 추가하기` · `할당과 취소가 동시에 처리되는 것을 막아보자` · `브라우저에서 번들링하기` |
|
||||
| 대상형 | `공간데이터 및 GeoJSON` · `@lib/i18n과 세 가지 컨벤션` · `세 가지 린트 규칙과 위반 탐지 과정` |
|
||||
| 한계·상태형 | `사람과 AI 검수의 한계` · `험난한 컨벤션 준수의 길` · `남은 과제들` |
|
||||
|
||||
셋 다 **이 절에서 다루는 대상이나 하려는 일**을 이름으로 말한다. 대비를 만들거나
|
||||
수수께끼를 내지 않는다. `같은 EAGER가 정반대 곡선을 그린다` 같은 제목은 이 목록에 없다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 절 첫 문장 — 예고는 되고 되풀이는 안 된다
|
||||
|
||||
앞으로 무엇을 어떤 각도에서 볼지 알려 주는 문장은 실제로 쓴다.
|
||||
|
||||
> "이번에는 그렇게 탄생한 규칙들을 깊게 파보겠습니다."
|
||||
> "그러므로 각 규칙이 위반·허용 패턴을 정의하는 방식과, 특정 노드의 진입·퇴장 이벤트에서 패턴을 찾아내고 처리하는 로직을 중심으로 살펴보겠습니다."
|
||||
|
||||
이 문장은 **읽는 각도**라는 새 정보를 준다. 반면 아래 같은 문장은 뒤 문장이 이미 하는 말이라 지운다.
|
||||
|
||||
- 제목이 `반복되는 하이라이트 조회 하나의 실행계획`인데 첫 문장이 `반복되는 하이라이트 조회 하나를 실행계획으로 확인했다`
|
||||
- 설명을 시작하기 전에 붙이는 `이 코드는 반복문이 없는 상황이다` / `여기서는 조회가 여러 번 일어나는 경우를 다룬다`
|
||||
- 관찰을 적고 나서 붙이는 `이 관찰은 두 가지를 보여준다`
|
||||
|
||||
**판별법: 그 문장을 지웠을 때 독자가 잃는 정보가 있는가.** 없으면 지운다.
|
||||
|
||||
---
|
||||
|
||||
## 12. 가져오지 않는 것
|
||||
|
||||
관찰한 글에는 이런 문장도 많다.
|
||||
|
||||
> "AI를 향한 무한한 숭배심은 던져버렸습니다."
|
||||
> "더 깐깐한 컨벤션 경찰이 필요합니다."
|
||||
> "쉬운 길은 없었습니다."
|
||||
> "어느정도 개발이 많이 진행된 상태에서 이런 상황이 닥치면 의욕이 상실되기도 하고, 대상 없는 원망이 생기기도 합니다."
|
||||
|
||||
**이 활력은 필자가 실제로 겪은 일에서 나온다. 자료에 없으면 만들지 않는다.**
|
||||
감정, 실패담, 비유, 1인칭 서술을 문체를 살리려고 지어내면 이 저장소의 작업 규칙을 어긴다.
|
||||
|
||||
자료 없이도 가져올 수 있는 것은 따로 있다. **평범한 동사, 구체적인 명사, 문장 안에서 이어지는 이유,
|
||||
정의를 먼저 두는 순서**다. 문장을 사람처럼 만드는 것은 감탄사가 아니라 이 네 가지다.
|
||||
|
||||
---
|
||||
|
||||
## 13. 소리 내어 읽기 검사
|
||||
|
||||
고친 문장마다 묻는다. **한국어를 쓰는 개발자가 동료에게 이 말을 이대로 하는가.**
|
||||
|
||||
- `조회 수는 아이템 수 100을 따라갔다` → 아무도 이렇게 말하지 않는다. → `아이템이 100개면 조회도 100번 나갔습니다`
|
||||
- `채워진 목록 수가 반환 아이템 수와 정확히 같았다` → 말하지 않는다. → `아이템 하나당 목록을 한 번씩 채웠습니다`
|
||||
- `이 값은 비교 대상이 아니다` → 말하지 않는다. → `두 값은 세는 것이 다릅니다`
|
||||
- `최소한의 선을 지켰다` → 말하지 않는다. → `<지킨 조건>은 지켰습니다`
|
||||
|
||||
정확한데 아무도 그렇게 말하지 않는 문장은 고쳐야 할 문장이다. 정확성은 낱말을 비틀어서가 아니라
|
||||
조건을 한 문장 더 적어서 지킨다.
|
||||
@@ -0,0 +1,469 @@
|
||||
# Regression Examples
|
||||
|
||||
Use these examples to calibrate decisions, not as sentence templates. The acceptable rewrites are intentionally plain. Reusing their sentence frames across a corpus would create another AI pattern.
|
||||
|
||||
한국 기술 블로그가 각 자리에서 실제로 쓰는 표현은 [korean-tech-blog-register.md](korean-tech-blog-register.md)에 있다. 이 파일은 그 규범을 어겼을 때 어떤 문장이 나오는지를 모은 것이다.
|
||||
|
||||
## 1. 수치를 추세 표현으로 바꾸지 않는다
|
||||
|
||||
원문:
|
||||
|
||||
> `Page`는 N=10, 100, 1,000에서 추가 쿼리가 각각 10번, 100번, 1,000번 발생했다. `User`는 3번, 20번, 20번 발생했다. 여러 `Feed Item`이 같은 `User`를 참조했고, 한 번 조회한 `User`는 1차 캐시에 남아 있었다.
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> 데이터가 증가하면서 Page 조회 비용은 선형적으로 증가한 반면 User는 캐시 효과로 일정하게 유지됐다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> `Page`는 N=10, 100, 1,000에서 각각 10번, 100번, 1,000번의 추가 쿼리가 발생했다. `User` 같은 경우는 여러 `Feed Item`에서 같은 사용자를 참조하고 있어서 추가 쿼리가 3번, 20번, 20번 발생했다. 한 번 조회한 `User`는 1차 캐시에 남아 있었다.
|
||||
|
||||
실패 이유: 잘못 고친 문장은 수치를 삭제하고 `조회 비용`, `선형적`, `캐시 효과`, `일정하게 유지`라는 더 넓은 해석으로 바꿨다. `User`의 3, 20, 20도 일정한 값이 아니다.
|
||||
|
||||
## 2. 제목에 대비를 만들지 않는다
|
||||
|
||||
원문에서 확인한 내용:
|
||||
|
||||
> `Page`와 `User`는 모두 `@ManyToOne(EAGER)`였다. 두 연관 관계에서 발생한 추가 쿼리 수가 달랐다.
|
||||
|
||||
잘못 고친 제목:
|
||||
|
||||
> 같은 EAGER가 정반대 곡선을 그린다
|
||||
|
||||
허용하는 제목:
|
||||
|
||||
> `EAGER` 연관 관계에서 발생한 추가 조회
|
||||
|
||||
실패 이유: `정반대 곡선`은 원문에 없는 모양과 대비를 만든다.
|
||||
|
||||
## 3. 추상적인 결정 요인으로 압축하지 않는다
|
||||
|
||||
원문:
|
||||
|
||||
> 이미 조회한 `User`는 1차 캐시에 남아 있어서 다시 조회하지 않았다.
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> Persistence Context의 재사용 여부가 비용을 결정했다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> 한 번 조회한 `User`는 1차 캐시에 남아 있어서 다시 조회하지 않았다.
|
||||
|
||||
실패 이유: `비용을 결정했다`는 측정 대상과 범위를 넓힌다.
|
||||
|
||||
## 4. 구체적인 변화는 그대로 적는다
|
||||
|
||||
원문:
|
||||
|
||||
> `Feed Item`을 100개에서 1,000개로 늘리자 `Page` 추가 쿼리도 100번에서 1,000번으로 늘었다.
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> 조회 비용이 데이터셋의 카디널리티에 비례했다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> `Feed Item`을 100개에서 1,000개로 늘리자 `Page` 추가 쿼리도 100번에서 1,000번으로 늘었다.
|
||||
|
||||
실패 이유: 구체적인 대상과 수치가 사라지고, 원문보다 넓은 비례 관계가 생겼다.
|
||||
|
||||
## 5. 비용을 다른 곳으로 이동시켰다고 포장하지 않는다
|
||||
|
||||
원문:
|
||||
|
||||
> `fetch join`을 적용한 뒤 쿼리 수는 줄었다. 조인으로 조회되는 행 수와 메모리 사용량은 늘었다.
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> 비용이 네트워크와 메모리로 이동했다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> 쿼리 수는 줄었지만 조인으로 조회되는 행 수와 메모리 사용량은 늘었다.
|
||||
|
||||
실패 이유: 원문에 없는 네트워크를 추가했고, 서로 다른 관측값을 하나의 `비용`으로 일반화했다.
|
||||
|
||||
## 6. 내부 측정 용어는 정확한 뜻이 있을 때만 푼다
|
||||
|
||||
원문:
|
||||
|
||||
> 총 `PreparedStatement`에서 collection fetch를 제외한 뒤에도 추가 쿼리가 남았다.
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> ORM 내부 실행 비용을 제거한 뒤에도 숨은 부하가 존재했다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> 컬렉션을 조회하는 쿼리를 제외하고도 추가 쿼리가 남았다.
|
||||
|
||||
실패 이유: `PreparedStatement`를 `실행 비용`으로, 추가 쿼리를 `숨은 부하`로 바꿔 의미를 넓혔다. 허용 예는 이 문서에서 collection fetch가 컬렉션 조회 쿼리를 뜻한다고 앞 문맥이 확인해 준 경우에만 사용할 수 있다.
|
||||
|
||||
## 7. 문장을 짧게 압축하기보다 설명 흐름을 남긴다
|
||||
|
||||
원문:
|
||||
|
||||
> 여러 `Feed Item`이 같은 `User`를 참조하고 있었다. 한 번 조회한 `User`는 1차 캐시에 남았다.
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> 동일 User 참조가 Persistence Context에서 재사용됐다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> `User` 같은 경우는 여러 `Feed Item`에서 같은 사용자를 참조하고 있어서, 한 번 조회한 `User`는 1차 캐시에 남았다.
|
||||
|
||||
실패 이유: 잘못 고친 문장은 무엇을 다시 사용했는지와 실제 조회 동작을 압축했다.
|
||||
|
||||
## 8. 원문에 없는 교훈을 붙이지 않는다
|
||||
|
||||
원문:
|
||||
|
||||
> N=100에서 `Page` 추가 쿼리가 100번 발생했다. 각 `Feed Item`이 서로 다른 `Page`를 참조하고 있었기 때문이다.
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> N=100에서 `Page` 추가 쿼리가 100번 발생했다. 따라서 EAGER 연관 관계는 반드시 피해야 한다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> N=100에서 `Page` 추가 쿼리가 100번 발생했다. 각 `Feed Item`이 서로 다른 `Page`를 참조하고 있었기 때문이다.
|
||||
|
||||
실패 이유: 측정 결과만으로 일반적인 설계 권고를 만들었다.
|
||||
|
||||
## 9. 확인하지 않은 결과를 확정하지 않는다
|
||||
|
||||
원문:
|
||||
|
||||
> 같은 refresh token의 두 번째 사용은 rotation 정책 때문에 거부될 가능성이 있다. 실제 응답과 session 영향은 아직 재현하지 않았다.
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> rotation이 적용되므로 두 번째 refresh token 사용은 거부된다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> 같은 refresh token을 두 번째로 사용했을 때 거부될 가능성이 있다. 실제 응답과 session에 미치는 영향은 아직 확인하지 않았다.
|
||||
|
||||
실패 이유: 가능성을 확정된 결과로 바꾸고 미검증 범위를 삭제했다.
|
||||
|
||||
## 10. 대상을 `기준선`이라고 부르지 않는다
|
||||
|
||||
원문:
|
||||
|
||||
> 피드 아이템을 엔티티로 조회한 뒤 Stream으로 DTO에 옮기는 구현을 기준선으로 삼았다.
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> 같은 기준선에 두 가지 위반이 함께 있었다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> 피드 아이템을 엔티티로 조회한 뒤 Stream으로 DTO에 옮기는 코드를 그대로 두고 측정했다.
|
||||
>
|
||||
> 하이라이트가 아무리 많아도 조회량이 그에 비례해 늘지 않아야 한다는 요구가 두 가지 방식으로 깨졌다.
|
||||
|
||||
실패 이유: `기준선`은 그 코드가 무엇인지 말하지 않고 비교 대상이라는 역할만 붙인다. 뒤에서 `같은 기준선에`로 되풀이되면 무엇을 가리키는지 더 흐려진다. `위반`도 무엇을 어긴 것인지 말하지 않는다. 어긴 요구를 문장에 적는다.
|
||||
|
||||
## 11. API·지표 이름은 남기고 뜻을 옆에 적는다
|
||||
|
||||
원문:
|
||||
|
||||
> | N | 초기화 Highlight 컬렉션 | 총 PreparedStatement |
|
||||
>
|
||||
> N=1,000에서 총 PreparedStatement는 2,022개였다.
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> N=1,000에서 총 쿼리가 2,022개 실행됐다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> 총 PreparedStatement는 Hibernate가 SQL 한 건을 실행하려고 JDBC에서 얻은 문장 객체 수다. 이 요청이 SQL 문장을 몇 건 준비했는지를 뜻한다.
|
||||
>
|
||||
> N=1,000에서 총 PreparedStatement는 2,022개였다.
|
||||
|
||||
실패 이유: 잘못 고친 문장은 이름을 지우면서 뜻까지 바꿨다. 원문은 이 값이 SQL 실행 수와 항상 같지는 않다고 적었다. 이름은 그대로 두고, 처음 나오는 자리에 그것이 무엇인지 한 문장으로 적는다. 평문 칸처럼 이름을 그대로 쓰기 어려운 자리에서는 `준비된 SQL 문장(PreparedStatement)`처럼 뜻을 앞에 두고 이름을 괄호에 남긴다.
|
||||
|
||||
## 12. 지표를 지키려다 문장을 비틀지 않는다
|
||||
|
||||
원문(측정값):
|
||||
|
||||
> 초기화 Highlight 컬렉션 : N=10에서 10, N=100에서 100, N=1,000에서 1,000
|
||||
> 총 PreparedStatement : 25, 222, 2,022
|
||||
> 본문에 적힌 조건 : batch나 subselect가 없어 컬렉션 하나를 초기화할 때 SQL 하나가 나간다
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> 매핑이 getHighlights()에 접근할 때마다 비워 뒀던 하이라이트 목록이 하나씩 채워졌고, 채워진 목록 수가 반환 아이템 수 N과 정확히 같았다. N=1,000에서 이 요청 하나가 준비한 SQL 문장은 2,022건이었다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> 매핑이 getHighlights()에 접근하는 시점에 N개의 쿼리가 추가로 나갔다.
|
||||
> N=1,000이라면 추가 쿼리를 포함해 총 2,022개가 나갔다.
|
||||
|
||||
실패 이유: 잘못 고친 문장은 `초기화된 컬렉션 수는 SELECT 수가 아니다`라는 주의를 요약 칸에서까지 지키려다 사건을 명사구(`채워진 목록 수`, `준비한 SQL 문장`)로 바꿨다. 정확하지만 아무도 그렇게 말하지 않는다. 문서가 조건(batch 없음)을 이미 밝혔으므로 요약과 결론에서는 일어난 일을 동사로 적고, 지표 이름과 주의는 본문 표 옆에 남긴다.
|
||||
|
||||
## 13. 준비하거나 되풀이하는 문장은 지운다
|
||||
|
||||
원문:
|
||||
|
||||
> ## 반복되는 하이라이트 조회 하나의 실행계획
|
||||
>
|
||||
> 반복되는 하이라이트 조회 하나를 실행계획으로 확인했다.
|
||||
>
|
||||
> ```text
|
||||
> Index Scan using ...
|
||||
> ```
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> ## 반복되는 하이라이트 조회 하나의 실행계획
|
||||
>
|
||||
> ```text
|
||||
> Index Scan using ...
|
||||
> ```
|
||||
|
||||
실패 이유: 제목이 이미 말한 것을 문장이 한 번 더 말한다. `코드에 반복문은 없다`, `이 관찰은 두 위반을 드러낸다`처럼 다음 문장을 준비하기만 하는 문장도 같다. 측정한 사실과 자료에 있는 이유만 남긴다.
|
||||
|
||||
## 14. 예시는 한 규모로 고정한다
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> N=10에서 10개, N=100에서 100개, N=1,000에서 1,000개였다. 총 쿼리는 25개, 222개, 2,022개였다. N=1,000에서 피드 한 번 로딩은 194 ms였다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> N=100이면 100번이고, 추가 쿼리를 포함한 총 쿼리는 222개였다. 피드 한 번 로딩의 지연 중앙값은 85.9 ms였다.
|
||||
|
||||
실패 이유: 세 규모를 문장마다 늘어놓으면 읽는 사람이 매번 어느 규모의 이야기인지 다시 맞춰야 한다. 어떻게 늘어나는지는 표가 이미 보여 준다. 설명은 한 규모에서 하고, 그 규모의 수치만 문장에 남긴다. 가장 큰 N을 고르는 것은 설명이 아니라 과장이다.
|
||||
|
||||
## 15. 문장을 그림으로 옮기지 않는다
|
||||
|
||||
본문에 있던 그림의 `<text>`:
|
||||
|
||||
> loadFeed(0, N) → FeedItem N개 · Highlight 컬렉션 초기화 N회 · Highlight SELECT N회
|
||||
|
||||
바로 옆 문단:
|
||||
|
||||
> 매핑이 getHighlights()에 접근하는 시점에 아이템마다 쿼리가 한 번씩 나갔다.
|
||||
|
||||
실패 이유: 그림이 문단을 다시 그렸을 뿐이라 읽는 사람이 그림에서 새로 얻는 것이 없다. `alt`까지 같은 말을 세 번째로 반복한다. 그림은 순서·구조·측정값·실제 산출물(로그, 실행계획, 화면)처럼 문장이 담지 못하는 것을 담을 때만 남긴다.
|
||||
|
||||
## 16. 없는 관용구를 만들어 쓰지 않는다
|
||||
|
||||
원문(측정값):
|
||||
|
||||
> returned : N=10에서 10, N=100에서 20, N=1,000에서 20
|
||||
> feedItemLoaded : 10, 100, 1,000
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> `returned`는 페이지 크기에 고정되었지만 `feedItemLoaded`는 N을 따라 늘었다.
|
||||
> 조회 수는 아이템 수 100을 따라갔다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> `returned`는 페이지 크기인 20에 그대로 머물렀지만, `feedItemLoaded`는 N이 커지는 만큼 같이 늘어 N=1,000에서 1,000이 되었다.
|
||||
|
||||
실패 이유: 한국어에서 `따라가다`의 목적어는 사람, 길, 기준 같은 것이지 개수가 아니다. `100을 따라갔다`는
|
||||
한국어 문장이 아니다. 앞의 규칙(`캐시 효과`처럼 뭉뚱그리지 말 것)을 지키려다 아무도 쓰지 않는 관용구를
|
||||
새로 만든 경우다. **금지 표현을 피한 자리에 들어가는 대체 표현도 똑같이 검사한다.** 수가 같이 늘어난다는
|
||||
말은 `~에 비례해`, `~가 커지는 만큼`, `~와 같은 수로`, 또는 그냥 값을 적어 `아이템이 100개면 조회도
|
||||
100번 나갔다`로 쓴다.
|
||||
|
||||
## 17. 논증 속 역할로 부르지 않는다 — `비교 대상`, `최소한의 선`, `관계`
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> 초기화 컬렉션 수와 `PreparedStatement` 수는 비교 대상이 아니다.
|
||||
> 이 구현도 최소한의 선은 지켰다.
|
||||
> 두 엔티티의 관계 때문에 추가 쿼리가 생겼다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> 두 값은 세는 것이 다르다. 초기화 컬렉션 수는 지연 로딩이 채운 컬렉션 개수이고, `PreparedStatement` 수는 JDBC에서 얻은 문장 객체 수다.
|
||||
> 이 구현도 공개 범위 판정은 요구대로 적용했다.
|
||||
> `FeedItem.page`에 걸린 `@ManyToOne(EAGER)` 매핑 때문에 아이템마다 `Page` 조회가 한 번씩 더 나갔다.
|
||||
|
||||
실패 이유: `비교 대상이 아니다`는 두 값이 왜 다른지를 말하지 않고 독자에게 비교하지 말라는 지시만 남긴다.
|
||||
`최소한의 선`은 무엇을 지켰는지 말하지 않는다. `관계`는 어느 매핑인지 말하지 않는다. 세 낱말 모두
|
||||
글쓴이의 머릿속에 있는 논증 구조를 가리킬 뿐 코드나 측정값을 가리키지 않는다. `관계`는 `연관 관계`,
|
||||
`@ManyToOne 관계`처럼 이름의 일부일 때만 쓴다.
|
||||
|
||||
## 18. 설명 앞에 상황 서술을 덧대지 않는다
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> 이 코드는 반복문 없이 목록을 매핑하는 상황이다. 그런데도 매핑이 `getHighlights()`에 접근하는 시점에 아이템마다 쿼리가 한 번씩 나갔다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> 매핑 코드에 반복문은 없지만, `getHighlights()`에 접근하는 시점에 아이템마다 쿼리가 한 번씩 나갔다.
|
||||
|
||||
실패 이유: 첫 문장이 말한 내용을 두 번째 문장이 그대로 다시 말한다. `~한 상황이다`, `여기서는 ~를 다룬다`,
|
||||
`이 절은 ~에 관한 내용이다`는 설명을 미루기만 한다. 조건이 정말 필요하면 설명 문장 안에 `~지만`,
|
||||
`~인데`로 넣는다. 앞으로 어떤 각도에서 볼지 알려 주는 예고 문장(`이번에는 ~를 ~ 중심으로 살펴보겠습니다`)은
|
||||
새 정보를 주므로 다르다.
|
||||
|
||||
## 19. 이유를 문장 밖으로 밀어내지 않는다
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> 폴리곤 데이터는 실시간으로 변경되지 않는다. 그래서 메모리에 올렸다. 메모리에 올려도 문제가 없었다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> 폴리곤 데이터는 실시간으로 변경되는 데이터가 아니기 때문에 메모리에 올려놓고 사용해도 무방했다.
|
||||
|
||||
실패 이유: 한 문장에 사실 하나라는 규칙을 기계적으로 적용하면 주어가 같은 문장이 셋으로 쪼개지고,
|
||||
`그래서`가 접착제로 붙는다. 한국어 기술 문장은 이유를 `~기 때문에`, `~다 보니`, `~어서`로 문장 안에
|
||||
넣는다. 문장을 끊는 자리는 두 번째 절이 아니라 **주어가 바뀌는 지점**이다.
|
||||
|
||||
## 20. 처음 쓰는 말은 그 자리에서 정의한다
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> MDC에 진입점 정보를 넣고 스레드가 바뀔 때 복사했다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> MDC(Mapped Diagnostic Context)는 slf4j 같은 자바 로깅 프레임워크가 제공하는, 실행 중인 스레드 단위로 메타 정보를 담아 두는 공간이다. 여기에 진입점 정보를 넣고, 스레드가 바뀔 때 새 스레드로 복사했다.
|
||||
|
||||
실패 이유: 잘못 고친 문장은 독자가 MDC를 이미 안다고 가정한다. TechLog 기록은 짧아도 되지만, 핵심 내용을
|
||||
이해하는 데 필요한 선수 지식은 글 안에 있어야 한다. 처음 나오는 API·지표·도메인 용어는 **그것이 무엇이고
|
||||
무엇을 하는지** 한 문장으로 적고 이름은 그대로 둔다. 약어는 처음 나올 때 괄호로 편다. 이렇게 붙이는 정의는
|
||||
`자료에 없는 내용 추가`가 아니다. 금지되는 것은 이 시스템·이 측정·이 결정에 대한 새 주장이다.
|
||||
|
||||
## 21. 코드와 측정의 시제를 섞지 않는다
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> `RedisCommandGuard`는 요청받은 명령을 catalog에서 찾았고, 등록되지 않은 명령이면 거절한다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> `RedisCommandGuard`는 요청받은 명령을 catalog에서 찾고, 등록되지 않은 명령이면 거절한다.
|
||||
> 테스트에서 `EVAL`을 보내자 이 guard가 거절했고, 응답에는 `command not allowed`가 담겼다.
|
||||
|
||||
실패 이유: 코드가 늘 하는 일은 현재형(`~한다`, `~합니다`)으로, 실제로 재거나 겪은 일은 과거형(`~했다`,
|
||||
`~했습니다`)으로 쓴다. 한 문장 안에서 섞이면 독자가 지금 읽는 것이 코드 동작인지 측정 결과인지 알 수 없다.
|
||||
|
||||
## 22. 이름만 보고 지표의 뜻을 지어내지 않는다
|
||||
|
||||
원문:
|
||||
|
||||
> `deniedCommandCount`와 `rejectedRequestCount`는 비교 대상이 아니다.
|
||||
> `deniedCommandCount`는 1이었고 `rejectedRequestCount`는 200이었다.
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> `deniedCommandCount`와 `rejectedRequestCount`는 세는 것이 다르다. `deniedCommandCount`는 거절된 명령의 수이고, `rejectedRequestCount`는 거절된 요청의 수다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> `deniedCommandCount`는 1이었고 `rejectedRequestCount`는 200이었다. 두 값은 세는 단위가 달라서 함께 놓고 크기를 견주면 안 되는데, 각각이 정확히 무엇을 세는지는 이 문서에서 확인하지 않았다.
|
||||
|
||||
실패 이유: 원문은 두 값이 다르다고만 적었고 각각이 무엇을 세는지는 적지 않았다. 잘못 고친 문장은
|
||||
`비교 대상이 아니다`(규칙 17)를 고치고 지표에 뜻을 붙이라는 규칙(규칙 11, 20)을 따르다가, **식별자
|
||||
이름에서 뜻을 추론해 확정 사실로 적었다.** 그럴듯해 보이지만 이것은 측정 대상에 대한 새 주장이다.
|
||||
|
||||
정의를 가져올 수 있는 곳은 셋뿐이다. **원문, 코드, 그 프레임워크의 공식 문서.** 셋 다 답을 주지
|
||||
않으면 이름을 그대로 두고, 원문이 말한 것까지만 적고, 확인하지 않았다고 밝힌다. 규칙 17을 지키려고
|
||||
규칙 11을 넘겨 쓰지 않는다. 두 규칙이 부딪히면 **원문 보존이 이긴다.**
|
||||
|
||||
## 23. 영어 일반명사를 한국어로 쓴다
|
||||
|
||||
원문:
|
||||
|
||||
> `RedisCacheRegionAdapter`는 단일 key invalidation에서 `GETDEL`을 호출해 `INVALIDATED`와 `ALREADY_ABSENT`를 구분합니다.
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> 단일 key invalidation은 값을 읽으면서 그 자리에서 지우는 `GETDEL`을 호출하기 때문에, 지워진 값이 있었으면 `INVALIDATED`를, 처음부터 없었으면 `ALREADY_ABSENT`를 돌려줍니다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> 키 하나를 무효화할 때는 값을 읽으면서 그 자리에서 지우는 `GETDEL`을 부릅니다. 지워진 값이 있었으면 `INVALIDATED`를, 처음부터 없었으면 `ALREADY_ABSENT`를 돌려줍니다.
|
||||
|
||||
실패 이유: 잘못 고친 문장은 규칙을 다 지켰다. 정의를 앞에 뒀고, 이유를 문장 안에서 이었고, 금지어도
|
||||
없다. 그런데도 기계가 쓴 것처럼 읽힌다. `key`와 `invalidation`이 라틴 문자로 남아 있기 때문이다.
|
||||
둘 다 식별자가 아니다. `GETDEL`, `INVALIDATED`, `ALREADY_ABSENT`는 식별자라 그대로 두고,
|
||||
`key`는 `키`, `invalidation`은 `무효화`로 적는다.
|
||||
|
||||
우아한형제들 5편과 이 저장소 9개 절을 재보면 이렇다.
|
||||
|
||||
| | 우아한형제들 | 이 저장소 |
|
||||
|---|---|---|
|
||||
| 문장당 맨몸 영문 낱말 | 1.4 | 4.4 |
|
||||
| 글자 중 한글 비율 | 0.58 | 0.34 |
|
||||
| 쿼리 / `query` | 66 / 5 | 5 / 4 |
|
||||
| 캐시 / `cache` | 2 / 0 | 0 / 19 |
|
||||
| 상태 / `status` | 48 / 0 | 6 / 7 |
|
||||
|
||||
기술 블로그는 `쿼리`, `캐시`, `인덱스`, `라이브러리`, `컴포넌트`, `스레드`, `플러그인`처럼
|
||||
자리잡은 외래어를 한글로 적는다. 라틴 문자는 진짜 식별자에만 쓴다. 이것을 고치면 사실은 하나도
|
||||
바뀌지 않는다. 맨몸 일반명사는 애초에 보호 구간이 아니기 때문이다.
|
||||
|
||||
**첫 등장 뒤에는 한국어로 받는다.** `optional contributor는 … optional contributor가 …`를
|
||||
`… 이 항목이 …`로 받는다. 매 문장에 영어 이름을 되풀이하는 것이 문장당 영문 낱말을 넷까지 끌어올린다.
|
||||
|
||||
## 24. 확인한 것을 끝에서 목록으로 다시 포장하지 않는다
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> ## 현재 구현 공백과 잘못 읽기 쉬운 지점
|
||||
>
|
||||
> - semantic Redis 조립은 … 4/5입니다.
|
||||
> - `CacheRegionPort` 빈은 있지만 …
|
||||
> - `CacheRefreshCoordinationPort` 운영 구현은 없습니다.
|
||||
> - … (여덟 개)
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> (각 한계를 그것이 제한하는 대상 옆에 둔다. 갱신 조정자를 설명한 문단 끝에
|
||||
> `운영 구현은 아직 없고 테스트용 가짜 구현만 있습니다`를 붙이는 식이다.)
|
||||
|
||||
실패 이유: 여덟 항목 모두 본문이 이미 설명한 것이다. 끝에 모아 놓으면 사람이 쓴 글이 아니라
|
||||
**에이전트가 분석을 마치고 Findings를 정리한 출력**처럼 읽힌다. 문장은 자연스러운데 문서가 기계다.
|
||||
한계는 그것이 제한하는 대상 바로 옆에 있을 때 독자에게 쓸모가 있다.
|
||||
|
||||
같은 이유로 아래도 하지 않는다.
|
||||
|
||||
- `다음에 열어볼 source 순서` — 글쓴이가 자기한테 남기는 작업 메모다. 독자는 묻지 않았다.
|
||||
- `잘못 읽기 쉬운 지점` — AI 기술 문서에 반복해서 나오는 분류다. 잘못 읽기 쉬운 대목이 있으면
|
||||
그 대목에서 바로 적는다.
|
||||
- 확인한 사실을 빠짐없이 절로 승격하기. 코드를 읽으면 참인 관찰이 수십 개 나온다. **글의 중심
|
||||
질문에 필요한 것만 넣고 나머지는 버린다.** 확인한 것을 다 넣고 싶은 마음이 가장 확실한 기계 신호다.
|
||||
|
||||
## 25. 독자에게 사고를 지시하지 않는다
|
||||
|
||||
잘못 고친 예:
|
||||
|
||||
> 먼저 결론을 구분해야 합니다.
|
||||
> 여기서 typed label과 end-to-end 동작을 구분해야 합니다.
|
||||
|
||||
허용하는 예:
|
||||
|
||||
> `RedisCacheRegionAdapter`는 운영 빈으로 조립됩니다. 그런데 `CacheAsideExecutor`와 묶어 쓰는
|
||||
> 운영 유스케이스는 찾지 못했습니다.
|
||||
>
|
||||
> `CacheAsideExecutor`까지 따라가면 동작이 달라집니다.
|
||||
|
||||
실패 이유: `구분해야 합니다`는 독자에게 사고 절차를 지시할 뿐 아무 사건도 말하지 않는다. 사람이
|
||||
작업 기록을 쓰면 바로 사건으로 들어간다. 방향을 알려 주는 문장은 글 전체에 한둘이면 충분하고,
|
||||
절마다 붙으면 자기 분석 과정을 중계하는 글이 된다.
|
||||
|
||||
## 제목 회귀 목록
|
||||
|
||||
| 피할 제목 | 사실을 적은 제목 |
|
||||
|---|---|
|
||||
| 같은 EAGER가 정반대 곡선을 그린다 | `EAGER` 연관 관계에서 발생한 추가 조회 |
|
||||
| 쿼리 하나에 숨어 있던 비용 | 한 컬렉션을 `fetch join`했을 때 조회되는 행 수 |
|
||||
| 페이지가 아닌 데이터셋에 비례한다 | `Feed Item` 수에 따라 늘어난 `Page` 추가 쿼리 |
|
||||
| 비용은 사라지지 않고 이동한다 | 쿼리 수는 줄었지만 추가 조회는 남았다 |
|
||||
| fetch join의 회계 항등식 | `fetch join` 적용 전후의 쿼리 수와 조회 행 수 |
|
||||
| 기준선 구현 | 측정한 `loadFeed` 구현 |
|
||||
| 각 쿼리는 빠른데 느리다 | 반복되는 하이라이트 조회 하나의 실행계획 |
|
||||
| 두 지표를 같은 것으로 읽지 않는다 | 초기화 컬렉션 수와 `PreparedStatement` 수가 뜻하는 것 |
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
// 한국 기술 블로그 문장 규범 검사기.
|
||||
// 표면 패턴만 본다. 뜻은 못 본다. 통과가 곧 좋은 글이라는 뜻은 아니다.
|
||||
//
|
||||
// node scripts/check_prose.mjs [--doc|--rules] [--warn] <file.md ...>
|
||||
// --doc 글 전체 기준(도입·차례·마무리)까지 검사
|
||||
// --rules 규칙 문서(README·CLAUDE.md·스킬 문서)용. 읽는 사람을 데리고 다니는 규칙을 끈다
|
||||
// --warn 판단이 필요한 경고도 함께 출력
|
||||
//
|
||||
// 기준선: 우아한형제들 기술블로그 5편이 error 0건으로 통과한다.
|
||||
// 규칙을 더할 때는 그 5편을 다시 돌려서 통과하는지 확인한다.
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const ERR = 'error', WARN = 'warn';
|
||||
|
||||
const RULES = [
|
||||
{ id: 'idiom-follow', sev: ERR, re: /[를을]\s*(따라갔|따라\s*늘|따라\s*증가|좇았|좇아)/g,
|
||||
msg: '개수에 `따라가다/좇다`를 붙였습니다. `~에 비례해`, `~가 커지는 만큼`, `~와 같은 수로`, 또는 값을 그대로 적으세요.' },
|
||||
|
||||
{ id: 'role-noun', sev: ERR, re: /(비교\s*대상이\s*아니|최소한의\s*선|기준선|구조적\s*문제|증가\s*형태|의\s*실체)/g,
|
||||
msg: '논증에서 맡은 역할로 불렀습니다. 그 대상의 이름과 실제로 일어난 일을 적으세요.' },
|
||||
|
||||
// 글/코드 자체를 가리키는 메타 상황 서술만 잡는다. 세상의 상태를 말하는 `~는 상황입니다`는 정상.
|
||||
{ id: 'scene-setter', sev: ERR,
|
||||
re: /((이|본|해당)\s*(코드|절|장|문서|글|부분|예제)[^.\n]{0,40}(상황이다|상황입니다)|(이|본|해당)\s*(절|장|문서|글)은[^.\n]{0,30}에\s*대한\s*내용(이다|입니다))/g,
|
||||
msg: '설명을 미루는 상황 서술입니다. 조건이 필요하면 설명 문장 안에 `~지만`, `~인데`로 넣으세요.' },
|
||||
|
||||
{ id: 'wrap-up', sev: ERR, re: /(이\s*(관찰|결과|측정)은[^.\n]{0,40}(보여준|드러낸|말해\s*준)|이는[^.\n]{0,30}보여준다)/g,
|
||||
msg: '방금 보여 준 것을 다시 선언합니다. 지우세요.' },
|
||||
|
||||
{ id: 'nominalized', sev: ERR, re: /(채워진\s*목록\s*수|준비한\s*SQL\s*문장|획득한[^.\n]{0,10}객체\s*수|[가-힣]+에\s*대한\s*(측정|비교|확인|분석))/g,
|
||||
msg: '사건을 명사구로 바꿨습니다. 동사로 적으세요.' },
|
||||
|
||||
{ id: 'ui-chain', sev: ERR, re: /[가-힣A-Za-z0-9)\]]+의\s*[가-힣A-Za-z0-9]+의\s*[가-힣A-Za-z0-9]+의/g,
|
||||
msg: '`의`가 세 겹입니다. 동사로 푸세요.' },
|
||||
|
||||
// 아래는 판단이 필요한 자리. 참고 글도 문맥에 따라 쓴다.
|
||||
{ id: 'slogan', sev: WARN, re: /(결국\s*문제는|단순히[^.\n]{0,30}가\s*아니라|비용이[^.\n]{0,20}(이동|옮겨)|새로운\s*책임이\s*생|정반대의?\s*(결과|곡선)|회계\s*항등식)/g,
|
||||
msg: '원문에 없는 결론·표어일 수 있습니다. 원문이 같은 주장을 했는지 확인하세요.' },
|
||||
|
||||
{ id: 'bare-relation', sev: WARN, re: /(?<!연관\s)(?<!상속\s)관계(가\s|는\s|를\s|의\s|\s*때문)/g,
|
||||
msg: '`관계`가 어느 매핑인지 말하지 않을 수 있습니다. 필드·애너테이션·외래 키 이름을 적으세요.' },
|
||||
|
||||
{ id: 'reading-order', sev: WARN, re: /(읽으면\s*안\s*된다|주의해서\s*보|눈여겨\s*보)/g,
|
||||
msg: '독자에게 읽는 법을 지시합니다. 그렇게 읽게 만드는 관측을 적으세요.' },
|
||||
|
||||
{ id: 'forced-contrast', sev: WARN, re: /(^|[.\n]\s*)(반면|반대로|이에\s*비해)/g,
|
||||
msg: '대비어가 문장 앞에 섰습니다. 대비가 정말 필요한지 확인하세요.' },
|
||||
];
|
||||
|
||||
// 풀지 않아도 되는 말. 업계에서 그대로 쓰거나, SQL·자리표시자.
|
||||
const ACRONYM_OK = new Set([
|
||||
'SQL','API','ID','URL','URI','JSON','YAML','XML','HTML','CSS','HTTP','HTTPS','CPU','GPU','RAM',
|
||||
'JVM','DB','UI','UX','IO','OK','TTL','CI','CD','AI','ML','LLM','ORM','JDBC','JPA','MVC','REST',
|
||||
'UUID','TCP','UDP','DNS','CSV','PDF','PNG','SVG','RPS','TPS','QPS','QA','PK','FK','GPS','CTR',
|
||||
'SDK','IDE','CLI','GUI','AWS','GCP','SQS','SNS','JSX','TSX','DTO','VO','CRUD','ES','NPE','GC',
|
||||
'IT','SRE','PR','MR','OS','VM','K8S','MSA','TDD','DDD','JWT','SSO','OTP','ACL','CORS','CDN',
|
||||
'SELECT','FROM','WHERE','INSERT','UPDATE','DELETE','JOIN','GROUP','ORDER','TABLE','INDEX','POINT',
|
||||
'CANCEL','GREEN','RED','TODO','NOTE','CODE','BLOCK','AND','OR','NOT','NULL','TRUE','FALSE',
|
||||
]);
|
||||
|
||||
function strip(src) {
|
||||
return src
|
||||
.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, ' '))
|
||||
.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
|
||||
}
|
||||
|
||||
function positiveChecks(text, lines, docMode, rulesMode) {
|
||||
const out = [];
|
||||
const sentences = text.split(/(?<=[.?!])\s+|\n{2,}/).map(x => x.trim()).filter(Boolean);
|
||||
|
||||
// 1. 정의가 첫 사용보다 뒤에 오는가
|
||||
const defRe = /`([^`\n]{2,60})`\s*(?:는|은)\s+[^\n]{5,}?(?:입니다|이다|말한다|뜻한다|의미합니다|의미한다)/g;
|
||||
let m;
|
||||
const flagged = new Set();
|
||||
while ((m = defRe.exec(text)) !== null) {
|
||||
const name = m[1];
|
||||
if (flagged.has(name)) continue;
|
||||
const firstAt = text.indexOf('`' + name + '`');
|
||||
if (firstAt >= 0 && firstAt < m.index) {
|
||||
flagged.add(name);
|
||||
out.push({ id: 'define-after-use', sev: ERR,
|
||||
msg: `\`${name}\`을(를) 먼저 쓰고 뒤에서 정의합니다. 정의는 첫 사용 바로 앞에 둡니다.` });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 글 전체 어디에서도 풀지 않은 약어
|
||||
const acroRe = /(?<![A-Za-z0-9_.\/-])([A-Z]{2,6})(?![A-Za-z0-9_])/g;
|
||||
const seen = new Set();
|
||||
while ((m = acroRe.exec(text)) !== null) {
|
||||
const a = m[1];
|
||||
if (ACRONYM_OK.has(a) || seen.has(a)) continue;
|
||||
seen.add(a);
|
||||
// 문서 어디에든 `약어(...)` 형태가 있으면 푼 것으로 본다
|
||||
if (!new RegExp(a + '\\s*\\(').test(text)) {
|
||||
out.push({ id: 'unexpanded-acronym', sev: WARN,
|
||||
msg: `약어 \`${a}\`을(를) 글 어디에서도 풀지 않았습니다. 처음 나오는 자리에 \`${a}(전체 이름, 우리말 뜻)\`으로 폅니다.` });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 종결어미가 한 가지뿐인가
|
||||
const kinds = new Set();
|
||||
for (const st of sentences) {
|
||||
if (/(습니다|았습니다|었습니다)[.!]?$/.test(st)) kinds.add('습니다');
|
||||
if (/입니다[.!]?$/.test(st)) kinds.add('입니다');
|
||||
if (/(했다|이다|였다|된다|한다)[.!]?$/.test(st)) kinds.add('한다');
|
||||
if (/(겠습니다|하겠습니다|보겠습니다)[.!]?$/.test(st)) kinds.add('겠습니다');
|
||||
if (/까요\??$/.test(st) || /\?$/.test(st)) kinds.add('물음');
|
||||
if (/(봅시다|보자|맙시다|주세요)[.!]?$/.test(st)) kinds.add('청유');
|
||||
}
|
||||
if (!rulesMode && sentences.length >= 8 && kinds.size <= 1) {
|
||||
out.push({ id: 'monotone-endings', sev: ERR,
|
||||
msg: `문장 ${sentences.length}개가 모두 같은 종결어미입니다. 예고(~살펴보겠습니다)·물음(~할까요?)·권유(~봅시다)를 섞습니다.` });
|
||||
}
|
||||
|
||||
// 4. 독자를 데리고 다니는 문장
|
||||
const steer = /(살펴보|알아보|파보|확인해\s*봅|정리해\s*보|소개해\s*보|다뤄\s*보|짚어\s*보|나중에\s*살펴|딴 길로|먼저[^\n]{0,25}부터|이번에는|공유합니다|공유하고자|다루겠습니다|보겠습니다|하겠습니다)/;
|
||||
// 강제하지 않는다. 강제했더니 `먼저 ~를 구분해야 합니다` 같은 지도형 문장이 절마다 붙어서
|
||||
// 문장이 아니라 구조가 기계처럼 읽히게 됐다.
|
||||
if (!rulesMode && !steer.test(text)) {
|
||||
out.push({ id: 'no-reader-steering', sev: WARN,
|
||||
msg: '독자를 안내하는 문장이 없습니다. 필요하면 하나 두되, 없어도 됩니다.' });
|
||||
}
|
||||
|
||||
// 독자에게 사고를 지시하는 문장 — 사건으로 바로 들어가면 될 자리
|
||||
const instruct = text.match(/(구분해야 합니다|주의해야 합니다|유의해야 합니다|기억해야 합니다|이해해야 합니다|먼저 결론|짚고 넘어)/g);
|
||||
if (!rulesMode && instruct) {
|
||||
out.push({ id: 'instructing-the-reader', sev: ERR,
|
||||
msg: `독자에게 사고를 지시하는 문장이 ${instruct.length}개 있습니다(예: "${instruct[0]}"). 사건을 바로 적으세요.` });
|
||||
}
|
||||
|
||||
// 본문이 이미 말한 것을 끝에서 목록으로 다시 포장 — 에이전트 Findings 출력처럼 읽힌다
|
||||
const fh = lines.findIndex(l => /^#{2,4}\s.*(공백|한계|주의|잘못 읽|남은 문제|정리하면|Findings|알아야 할)/.test(l));
|
||||
if (!rulesMode && fh >= 0) {
|
||||
const bullets = lines.slice(fh + 1, fh + 25).filter(l => /^\s*[-*+\d]/.test(l)).length;
|
||||
if (bullets >= 5) {
|
||||
out.push({ id: 'findings-list', sev: ERR,
|
||||
msg: `"${lines[fh].replace(/^#+\s*/,'')}" 아래 항목이 ${bullets}개입니다. 본문이 이미 설명한 것을 끝에서 목록으로 다시 포장하지 않습니다. 한계는 그것이 제한하는 대상 옆에 둡니다.` });
|
||||
}
|
||||
}
|
||||
|
||||
if (!docMode) return out;
|
||||
|
||||
const heads = lines.filter(l => /^#{2,4}\s/.test(l)).map(l => l.replace(/^#+\s*/, '').trim());
|
||||
|
||||
// 5. 선수 지식을 주는 곳
|
||||
const audienceLine = /(대상으로|읽는 분|독자|알고 있는 분|아시는 분|분들이라면|경험이 없어도|읽으시면|도움이 되)/.test(text);
|
||||
// 용어 절 제목(`X란?`, `X가 뭐죠?`, `X를 아시나요?`)이 있으면 선수 지식을 그쪽에서 준 것으로 본다
|
||||
const defSection = heads.some(h => /(란\?|이란|는 뭐|가 뭐|아시나요|무엇인가|이 뭔가)/.test(h));
|
||||
if (!audienceLine && !defSection) {
|
||||
out.push({ id: 'no-prereq', sev: WARN,
|
||||
msg: '선수 지식을 주는 곳이 없습니다. 도입에 독자·선수 지식 한 줄을 넣거나, `X란?` 형태의 용어 절을 둡니다.' });
|
||||
}
|
||||
|
||||
// 6. 차례 예고 — 같은 제목 묶음을 반복하는 글은 제목이 차례 노릇을 하므로 면제
|
||||
const dup = heads.length - new Set(heads).size;
|
||||
const staged = heads.filter(h => /^(\d+[).\s]|\d+\s*단계|[①-⑨])/.test(h)).length >= 2;
|
||||
if (heads.length >= 3 && dup < 2 && !staged &&
|
||||
!/(다음 순서대로|본 글에서는|이 글에서는|순서로 소개|차례로|먼저[^\n]{0,60}부터|살펴보고|공유합니다|공유하고자)/.test(text)) {
|
||||
out.push({ id: 'no-route', sev: WARN,
|
||||
msg: `절이 ${heads.length}개인데 차례를 알리는 문장이 없습니다.` });
|
||||
}
|
||||
|
||||
// 7. 마무리
|
||||
if (!/(지금까지|마무리|맺으며|맺는 글|살펴봤습니다|살펴보았습니다|정리하면|회고)/.test(text)) {
|
||||
out.push({ id: 'no-closing', sev: WARN,
|
||||
msg: '마무리가 없습니다. "지금까지 ~를 살펴봤습니다 → 줄거리 한 문장 → 그 결과 ~"로 닫습니다.' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const docMode = args.includes('--doc');
|
||||
// 규칙 문서는 「~한다」로 끝나는 항목의 나열이 맞다. 거기에 예고·물음·권유를 섞으면
|
||||
// 오히려 이상해지므로 그 두 규칙만 끈다. 나머지 규칙은 그대로 돈다.
|
||||
const rulesMode = args.includes('--rules');
|
||||
const showWarn = args.includes('--warn');
|
||||
const files = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
let errTotal = 0;
|
||||
for (const file of files) {
|
||||
const raw = readFileSync(file, 'utf8');
|
||||
const text = strip(raw);
|
||||
const lines = text.split('\n');
|
||||
const hits = [];
|
||||
lines.forEach((line, i) => {
|
||||
for (const rule of RULES) {
|
||||
rule.re.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = rule.re.exec(line)) !== null) {
|
||||
hits.push({ line: i + 1, id: rule.id, sev: rule.sev, msg: rule.msg, match: m[0].trim() });
|
||||
if (m.index === rule.re.lastIndex) rule.re.lastIndex++;
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const p of positiveChecks(text, lines, docMode, rulesMode)) hits.push({ line: null, ...p, match: null });
|
||||
|
||||
const errs = hits.filter(h => h.sev === ERR);
|
||||
const warns = hits.filter(h => h.sev === WARN);
|
||||
errTotal += errs.length;
|
||||
|
||||
const name = file.replace(/^.*\//, '');
|
||||
if (errs.length === 0) console.log(`OK ${name}${warns.length ? ` (경고 ${warns.length}건)` : ''}`);
|
||||
else console.log(`FAIL ${name} — error ${errs.length}건${warns.length ? ` · 경고 ${warns.length}건` : ''}`);
|
||||
|
||||
for (const h of errs) {
|
||||
console.log(` ${h.line ? name + ':' + h.line + ' ' : ''}[${h.id}]${h.match ? ` "${h.match}"` : ''}\n ${h.msg}`);
|
||||
}
|
||||
if (showWarn) for (const h of warns) {
|
||||
console.log(` · ${h.line ? name + ':' + h.line + ' ' : ''}[${h.id}]${h.match ? ` "${h.match}"` : ''}\n ${h.msg}`);
|
||||
}
|
||||
}
|
||||
process.exit(errTotal === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
// 기준선 글을 다시 받아 온다. style_profile.mjs의 BASE 값을 다시 재려면 이 파일로 원문을 받는다.
|
||||
//
|
||||
// npm i playwright-core # 브라우저 바이너리는 ~/.cache/ms-playwright 에 있어야 한다
|
||||
// node scripts/fetch_reference.mjs <출력디렉터리>
|
||||
//
|
||||
// techblog.woowahan.com은 curl·fetch·리더 프록시를 403으로 막는다. 실제 브라우저라야 통과한다.
|
||||
import { chromium } from 'playwright-core';
|
||||
import { writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const URLS = [
|
||||
'https://techblog.woowahan.com/26388/',
|
||||
'https://techblog.woowahan.com/17416/',
|
||||
'https://techblog.woowahan.com/13429/',
|
||||
'https://techblog.woowahan.com/20161/',
|
||||
'https://techblog.woowahan.com/11238/',
|
||||
];
|
||||
|
||||
const outDir = process.argv[2] || 'reference-corpus';
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// 설치된 chromium 아무거나 고른다
|
||||
const root = join(process.env.HOME, '.cache/ms-playwright');
|
||||
const dir = readdirSync(root).filter(d => d.startsWith('chromium-')).sort().pop();
|
||||
const exe = join(root, dir, 'chrome-linux64', 'chrome');
|
||||
|
||||
const browser = await chromium.launch({ executablePath: exe, headless: true });
|
||||
const ctx = await browser.newContext({
|
||||
locale: 'ko-KR',
|
||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
});
|
||||
|
||||
for (const url of URLS) {
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForTimeout(2500);
|
||||
const text = await page.evaluate(() => {
|
||||
const root = document.querySelector('.post-content, .entry-content, article, main') || document.body;
|
||||
const out = [];
|
||||
const walk = (el) => {
|
||||
for (const n of el.children) {
|
||||
const tag = n.tagName.toLowerCase();
|
||||
if (['script', 'style', 'nav', 'aside', 'footer'].includes(tag)) continue;
|
||||
if (/^h[1-6]$/.test(tag)) out.push(`\n## ${n.innerText.trim()}\n`);
|
||||
else if (tag === 'p') { const t = n.innerText.trim(); if (t) out.push(t); }
|
||||
else if (tag === 'li') { const t = n.innerText.trim(); if (t) out.push('- ' + t); }
|
||||
else if (tag === 'pre') out.push('```\n[CODE]\n```');
|
||||
else if (tag === 'table') out.push('[TABLE]');
|
||||
else walk(n);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return out.join('\n\n');
|
||||
});
|
||||
const id = url.match(/(\d+)/)[1];
|
||||
writeFileSync(join(outDir, `woowa-${id}.md`), text);
|
||||
console.log(`OK ${url} (${text.length}자)`);
|
||||
} catch (e) {
|
||||
console.log(`FAIL ${url}: ${e.message.split('\n')[0]}`);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
console.log(`\n기준선 다시 재기: node scripts/style_profile.mjs --baseline ${outDir}/*.md`);
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
// 글의 문체를 수치로 찍는다. 우아한형제들 5편의 값이 기준선이다.
|
||||
// node scripts/style_profile.mjs <file.md ...>
|
||||
// node scripts/style_profile.mjs --baseline <ref/*.md> 기준선 범위를 다시 계산
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// 우아한형제들 5편에서 잰 값 (scripts/style_profile.mjs --baseline 으로 재계산)
|
||||
// 우아한형제들 5편 실측(산문만):
|
||||
// avgLen 56.5~66.5 · longRatio 0~.05 · shortRatio .012~.136
|
||||
// enderKinds 4~6 · connPer100 7.1~25.9 · steerPer100 3.7~11.9
|
||||
// 아래는 거기에 약간의 여유를 준 값이다. 규칙을 고치면 --baseline으로 다시 잰다.
|
||||
const BASE = {
|
||||
avgLen: { lo: 48, hi: 75, label: '문장 평균 길이(자)' },
|
||||
longRatio: { lo: 0, hi: 0.08, label: '120자 넘는 문장 비율' },
|
||||
shortRatio: { lo: 0.01, hi: 0.20, label: '25자 미만 문장 비율' },
|
||||
enderKinds: { lo: 3, hi: 8, label: '종결어미 종류 수' },
|
||||
connPer100: { lo: 6, hi: 30, label: '이유 연결어미 / 문장 100개' },
|
||||
steerPer100: { lo: 0, hi: 16, label: '독자 안내 표현 / 문장 100개' }, // 하한 없음: 강제하면 지도형 문장이 생긴다
|
||||
engPerSent: { lo: 0, hi: 3.5, label: '문장당 맨몸 영문 낱말' }, // 기준선 0.71~3.14
|
||||
hangulRatio: { lo: 0.60, hi: 1, label: '한글 비율(식별자 제외)' }, // 기준선 0.64~0.92
|
||||
};
|
||||
|
||||
// 산문만 남긴다. 코드블록·표·제목·목록·링크주소·인라인코드는 문장이 아니다.
|
||||
function strip(src) {
|
||||
let t = src;
|
||||
// 짝이 맞는 코드펜스 제거
|
||||
t = t.replace(/```[\s\S]*?```/g, '\n');
|
||||
// 짝이 안 맞는 펜스(구획을 중간에서 잘랐을 때): 남은 펜스부터 끝까지 버린다
|
||||
const stray = t.indexOf('```');
|
||||
if (stray >= 0) t = t.slice(0, stray);
|
||||
return t
|
||||
.replace(/^\s*\|.*$/gm, '') // 표
|
||||
.replace(/^\s*#{1,6}\s.*$/gm, '') // 제목
|
||||
.replace(/^\s*[-*+]\s.*$/gm, '') // 목록
|
||||
.replace(/^\s*\d+[.)]\s.*$/gm, '') // 번호 목록
|
||||
.replace(/^\s*<!--[\s\S]*?-->/gm, '') // 주석
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // 링크는 글자만 남기고 주소 제거
|
||||
.replace(/`[^`\n]*`/g, 'X') // 인라인 코드는 한 글자로
|
||||
.replace(/[*_>]/g, '');
|
||||
}
|
||||
|
||||
export function profile(raw) {
|
||||
const _raw = raw;
|
||||
const text = strip(raw);
|
||||
const sents = text.split(/(?<=[.?!])\s+|\n{2,}/)
|
||||
.map(s => s.replace(/\s+/g, ' ').trim())
|
||||
.filter(s => s.length > 4 && /[가-힣]/.test(s));
|
||||
const n = sents.length || 1;
|
||||
const lens = sents.map(s => s.length);
|
||||
const avgLen = lens.reduce((a, b) => a + b, 0) / n;
|
||||
|
||||
const kinds = new Set();
|
||||
for (const s of sents) {
|
||||
if (/(습니다|았습니다|었습니다)[.!]?$/.test(s)) kinds.add('습니다');
|
||||
if (/입니다[.!]?$/.test(s)) kinds.add('입니다');
|
||||
if (/(했다|이다|였다|된다|한다)[.!]?$/.test(s)) kinds.add('한다');
|
||||
if (/(겠습니다|보겠습니다)[.!]?$/.test(s)) kinds.add('겠습니다');
|
||||
if (/\?$/.test(s)) kinds.add('물음');
|
||||
if (/(봅시다|보자|맙시다|주세요)[.!]?$/.test(s)) kinds.add('청유');
|
||||
if (/(네요|는데요|거든요|어요|아요)[.!]?$/.test(s)) kinds.add('해요체');
|
||||
if (/(합니다만|지만)[.!]?$/.test(s)) kinds.add('지만');
|
||||
}
|
||||
const conn = (text.match(/(기 때문에|다 보니|으므로|이므로|해서|어서|아서|는데|으니|니까)/g) || []).length;
|
||||
const steer = (text.match(/(살펴보|알아보|파보|확인해\s*봅|정리해\s*보|소개해\s*보|짚어\s*보|이번에는|먼저|나중에|다루겠|보겠습니다|공유)/g) || []).length;
|
||||
|
||||
// 백틱 안(식별자)은 빼고, 맨몸으로 쓰인 영문만 센다
|
||||
const bare = raw
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ') // HTML 주석(techviz 등)은 산문이 아니다
|
||||
.replace(/<\/?[a-zA-Z][^>]*>/g, ' ') // <details>, <summary> 같은 태그
|
||||
.replace(/^\s*\|.*$/gm, ' ') // 표
|
||||
.replace(/`[^`\n]*`/g, ' ')
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1');
|
||||
const PROPER = /^(Redis|Nginx|Hibernate|Spring|Actuator|Keycloak|PostgreSQL|Java|Gradle|Lettuce|Kubernetes|Docker|OAuth|Sentinel|Lua|SQL|API|TTL|ACL|TLS|HTTP|JSON|YAML|CI|AI|DB|ID|URL)$/i;
|
||||
const engWords = (bare.match(/[A-Za-z][A-Za-z0-9_.-]{1,}/g) || []).filter(w => !PROPER.test(w));
|
||||
// 한글 비율은 글쓴이가 고를 수 있는 산문만 본다. 백틱 안 식별자는 보호 구간이라 제외한다.
|
||||
const hangul = (bare.match(/[가-힣]/g) || []).length;
|
||||
const letters = (bare.match(/[가-힣A-Za-z]/g) || []).length || 1;
|
||||
|
||||
return {
|
||||
sentences: n,
|
||||
engPerSent: +(engWords.length / n).toFixed(2),
|
||||
hangulRatio: +(hangul / letters).toFixed(2),
|
||||
avgLen: +avgLen.toFixed(1),
|
||||
longRatio: +(lens.filter(l => l > 120).length / n).toFixed(3),
|
||||
shortRatio: +(lens.filter(l => l < 25).length / n).toFixed(3),
|
||||
enderKinds: kinds.size,
|
||||
connPer100: +((conn / n) * 100).toFixed(1),
|
||||
steerPer100: +((steer / n) * 100).toFixed(1),
|
||||
};
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === '--baseline') {
|
||||
const rows = args.slice(1).map(f => ({ f: f.replace(/^.*\//, ''), p: profile(readFileSync(f, 'utf8')) }));
|
||||
for (const k of Object.keys(BASE)) {
|
||||
const vals = rows.map(r => r.p[k]);
|
||||
console.log(`${k.padEnd(12)} min=${Math.min(...vals)} max=${Math.max(...vals)}`);
|
||||
}
|
||||
console.table(rows.map(r => ({ file: r.f, ...r.p })));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let bad = 0;
|
||||
const rows = [];
|
||||
for (const f of args) {
|
||||
const p = profile(readFileSync(f, 'utf8'));
|
||||
const flags = [];
|
||||
for (const [k, b] of Object.entries(BASE)) {
|
||||
if (p[k] < b.lo || p[k] > b.hi) { flags.push(`${b.label}=${p[k]} (기준 ${b.lo}~${b.hi})`); bad++; }
|
||||
}
|
||||
rows.push({ file: f.replace(/^.*\//, ''), ...p, 벗어남: flags.length });
|
||||
if (flags.length) console.log(`· ${f.replace(/^.*\//, '')}\n ` + flags.join('\n '));
|
||||
}
|
||||
console.table(rows);
|
||||
process.exit(bad === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user