feat: 문서 구조 변경 및 tech-visual 스킬 추가

This commit is contained in:
DongHyeonka
2026-09-04 18:20:00 +09:00
parent 43901f0abf
commit 2efb7ee1f2
683 changed files with 61180 additions and 10479 deletions
@@ -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 **5666 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 6090
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 46; this
repository's documents scored 2 across the board) and **이유 연결어미 / 문장 100개** (reference 6.723.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 기록은 서로 링크로 이어지고, 우아한형제들 글보다 짧다. 그래도 위 뼈대에서 **빼면 안 되는
자리**가 있다.
| 자리 | 필수 여부 |
|---|---|
| 이 기록이 무엇을 다루는지 한 문장 | 필수 |
| 독자와 선수 지식의 바 | 필수 |
| 처음 쓰는 말의 정의 (본문 안, 첫 사용 앞) | 필수 |
| 무슨 일이 있었나 · 왜 그랬나 | 필수 |
| 어떻게 했나 · 결과 | 자료에 있으면 필수 |
| 단계마다 남은 문제 | 자료에 있으면 필수 |
| 차례 예고 | 절이 셋 이상이면 |
| 글쓴이의 생각 | 자료에 있을 때만 |
없는 자리를 지어내지 않는다. **자료에 없으면 그 칸은 비운다.** 이 문서는 무엇을 채울 수 있는지를
말할 뿐, 채울 내용을 만들어도 된다는 뜻이 아니다.
@@ -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);