From 109983461765ef5721c3c210019f4658e61f5c24 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 7 Aug 2026 14:19:24 +0900 Subject: [PATCH] chore: snapshot working tree before harness removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미추적 파일과 미커밋 수정을 전부 담아 pre-harness-removal 태그의 복구 범위를 확보한다. .agents/skills/writing-natural-korean 9개와 korean-technical-blog-skills-bundle-v1 61개가 여기 포함된다. Co-Authored-By: Claude Opus 5 (1M context) --- .../skills/writing-natural-korean/README.md | 83 + .../skills/writing-natural-korean/SKILL.md | 161 ++ .../writing-natural-korean/agents/openai.yaml | 14 + .../writing-natural-korean/assets/icon.svg | 8 + .../writing-natural-korean/evals/evals.json | 125 ++ .../evals/trigger-cases.json | 14 + .../references/editing-patterns.md | 349 ++++ .../references/genre-profiles.md | 221 +++ .../references/normative-foundation.md | 164 ++ .../skills/revising-korean-technical-prose | 1 + .claude/skills/technical-document-author | 1 + .claude/skills/writing-natural-korean | 1 + .../assets/runtime-call.svg | 28 + .../assets/source-dependency.svg | 36 + .../final/document.md | 66 +- .run/keycloak-four-patterns/final/document.md | 211 +- document.md | 1764 +++++++++++++++++ .../assets/runtime-call.svg | 28 + .../assets/source-dependency.svg | 36 + .../MANIFEST.sha256 | 58 + .../README.md | 34 + .../README.md | 44 + .../SKILL.md | 85 + .../references/decision-policy.md | 111 ++ .../references/output-modes.md | 101 + .../references/rule-catalog.md | 116 ++ .../references/source-basis.md | 32 + .../scripts/validate_skill.py | 81 + .../tests/cases.json | 245 +++ .../tests/evaluation-rubric.md | 68 + .../tests/pressure-scenarios.md | 63 + .../MANIFEST.sha256 | 12 + .../reducing-ai-like-korean-writing/README.md | 71 + .../reducing-ai-like-korean-writing/SKILL.md | 81 + .../references/decision-policy.md | 110 + .../references/genre-profiles.md | 46 + .../references/output-modes.md | 95 + .../references/pattern-catalog.md | 319 +++ .../references/source-basis.md | 39 + .../scripts/validate_skill.py | 139 ++ .../tests/baseline-observations.md | 22 + .../tests/cases.json | 674 +++++++ .../tests/evaluation-rubric.md | 71 + .../tests/pressure-scenarios.md | 94 + .../MANIFEST.sha256 | 35 + .../writing-korean-technical-blogs/README.md | 88 + .../writing-korean-technical-blogs/SKILL.md | 76 + .../examples/end-to-end-performance-case.md | 65 + .../examples/revision-pairs.jsonl | 4 + .../formulaic-openings-and-closings.yaml | 18 + .../lexicons/product-names.example.yaml | 19 + .../protected-identifiers.example.yaml | 14 + .../lexicons/vague-expressions.yaml | 16 + .../profiles/architecture-decision.yaml | 18 + .../profiles/conversational-tech.yaml | 11 + .../profiles/default-formal.yaml | 18 + .../profiles/incident-postmortem.yaml | 20 + .../profiles/migration-case-study.yaml | 17 + .../profiles/performance-case-study.yaml | 18 + .../profiles/recruitment-tech-content.yaml | 11 + .../profiles/tooling-adoption.yaml | 18 + .../profiles/tutorial-lab.yaml | 14 + .../references/decision-policy.md | 42 + .../references/enterprise-blog-patterns.md | 29 + .../references/evidence-and-source-policy.md | 38 + .../references/exceptions.md | 28 + .../references/output-modes.md | 48 + .../references/rule-catalog.md | 72 + .../references/source-basis.md | 34 + .../references/structure-patterns.md | 56 + .../titles-introductions-conclusions.md | 43 + .../schemas/article-brief.schema.json | 106 + .../schemas/article-result.schema.json | 177 ++ .../schemas/rubric.schema.json | 55 + .../scripts/validate_skill.py | 227 +++ .../tests/baseline-observations.md | 28 + .../tests/cases.json | 966 +++++++++ .../tests/evaluation-rubric.md | 39 + .../tests/pressure-scenarios.md | 63 + .../tests/workflow.jsonl | 8 + .../style_contracts.cpython-312.pyc | Bin 11324 -> 11438 bytes .../__pycache__/mock.cpython-312.pyc | Bin 32060 -> 32084 bytes tests/__pycache__/test_lint.cpython-312.pyc | Bin 23652 -> 25157 bytes .../__pycache__/test_prompts.cpython-312.pyc | Bin 4560 -> 4780 bytes .../test_repository_contracts.cpython-312.pyc | Bin 2552 -> 4311 bytes 85 files changed, 8547 insertions(+), 114 deletions(-) create mode 100644 .agents/skills/writing-natural-korean/README.md create mode 100644 .agents/skills/writing-natural-korean/SKILL.md create mode 100644 .agents/skills/writing-natural-korean/agents/openai.yaml create mode 100644 .agents/skills/writing-natural-korean/assets/icon.svg create mode 100644 .agents/skills/writing-natural-korean/evals/evals.json create mode 100644 .agents/skills/writing-natural-korean/evals/trigger-cases.json create mode 100644 .agents/skills/writing-natural-korean/references/editing-patterns.md create mode 100644 .agents/skills/writing-natural-korean/references/genre-profiles.md create mode 100644 .agents/skills/writing-natural-korean/references/normative-foundation.md create mode 120000 .claude/skills/revising-korean-technical-prose create mode 120000 .claude/skills/technical-document-author create mode 120000 .claude/skills/writing-natural-korean create mode 100644 .run/executable-clean-architecture/assets/runtime-call.svg create mode 100644 .run/executable-clean-architecture/assets/source-dependency.svg create mode 100755 document.md create mode 100644 examples/golden/executable-clean-architecture/assets/runtime-call.svg create mode 100644 examples/golden/executable-clean-architecture/assets/source-dependency.svg create mode 100644 korean-technical-blog-skills-bundle-v1/MANIFEST.sha256 create mode 100644 korean-technical-blog-skills-bundle-v1/README.md create mode 100644 korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/README.md create mode 100644 korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/SKILL.md create mode 100644 korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/decision-policy.md create mode 100644 korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/output-modes.md create mode 100644 korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/rule-catalog.md create mode 100644 korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/source-basis.md create mode 100755 korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/scripts/validate_skill.py create mode 100644 korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/tests/cases.json create mode 100644 korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/tests/evaluation-rubric.md create mode 100644 korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/tests/pressure-scenarios.md create mode 100644 korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/MANIFEST.sha256 create mode 100644 korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/README.md create mode 100644 korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/SKILL.md create mode 100644 korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/decision-policy.md create mode 100644 korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/genre-profiles.md create mode 100644 korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/output-modes.md create mode 100644 korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/pattern-catalog.md create mode 100644 korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/source-basis.md create mode 100755 korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/scripts/validate_skill.py create mode 100644 korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/baseline-observations.md create mode 100644 korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/cases.json create mode 100644 korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/evaluation-rubric.md create mode 100644 korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/pressure-scenarios.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/MANIFEST.sha256 create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/README.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/SKILL.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/examples/end-to-end-performance-case.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/examples/revision-pairs.jsonl create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/formulaic-openings-and-closings.yaml create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/product-names.example.yaml create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/protected-identifiers.example.yaml create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/vague-expressions.yaml create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/architecture-decision.yaml create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/conversational-tech.yaml create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/default-formal.yaml create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/incident-postmortem.yaml create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/migration-case-study.yaml create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/performance-case-study.yaml create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/recruitment-tech-content.yaml create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/tooling-adoption.yaml create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/tutorial-lab.yaml create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/decision-policy.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/enterprise-blog-patterns.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/evidence-and-source-policy.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/exceptions.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/output-modes.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/rule-catalog.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/source-basis.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/structure-patterns.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/titles-introductions-conclusions.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/schemas/article-brief.schema.json create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/schemas/article-result.schema.json create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/schemas/rubric.schema.json create mode 100755 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/scripts/validate_skill.py create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/baseline-observations.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/cases.json create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/evaluation-rubric.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/pressure-scenarios.md create mode 100644 korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/workflow.jsonl diff --git a/.agents/skills/writing-natural-korean/README.md b/.agents/skills/writing-natural-korean/README.md new file mode 100644 index 0000000..3c4727f --- /dev/null +++ b/.agents/skills/writing-natural-korean/README.md @@ -0,0 +1,83 @@ +# 자연스러운 한국어 글쓰기 스킬 + +`writing-natural-korean`은 한국어 글을 새로 작성하거나 기존 글을 교정·윤문·재작성·검토할 때 사용하는 범용 스킬입니다. 기술 문서, 발표 스크립트, 일반 글·기술 블로그, 자기소개서·경력 문서, 업무 메일·메시지, 안내문을 요청 문맥에 따라 구분합니다. + +## 설계 원칙 + +- 맞춤법·띄어쓰기·표준어·외래어 표기·문장 부호는 국립국어원의 공식 어문 규범을 기준으로 판단합니다. +- 자연스러움은 강제 규범과 구분합니다. 피동 표현, `가지다`, `~에 대한`, `~를 통해` 같은 표현은 금칙어로 취급하지 않고 문맥에서 어색하거나 반복될 때만 다듬습니다. +- 원문의 사실, 수치, 고유명사, 기술명, 인용, 책임 주체, 확신 정도를 보존합니다. +- 사람처럼 보이게 하려고 일부러 오류나 불규칙성을 넣지 않습니다. +- `단순히 A를 넘어 B`, `이를 통해`, `중요한 역할` 같은 상투 표현은 내용 없이 반복될 때 실제 동작·조건·결과로 바꿉니다. +- 발표문과 기술 문서를 별도 스킬로 나누지 않고, 하나의 공통 한국어 기준 위에서 장르별 문체 프로필을 자동으로 선택합니다. + +## 구성 + +```text +writing-natural-korean/ +├── SKILL.md +├── README.md +├── references/ +│ ├── normative-foundation.md +│ ├── editing-patterns.md +│ └── genre-profiles.md +└── evals/ + ├── evals.json + └── trigger-cases.json +``` + +- `SKILL.md`: 활성 조건, 공통 원칙, 작업 모드, 실행 절차, 최종 검사 +- `references/normative-foundation.md`: 공식 근거와 조회 우선순위 +- `references/editing-patterns.md`: 번역투와 상투 표현을 문맥별로 판단하는 기준 +- `references/genre-profiles.md`: 기술 문서, 발표문 등 장르별 문체 기준 +- `evals/evals.json`: 의미 보존, 최소 교정, 번역투 윤문, 기술명 보호 등을 검증하는 12개 평가 사례 +- `evals/trigger-cases.json`: 자동 활성화 여부를 점검하는 사례 + +## ChatGPT에 설치 + +계정에서 스킬 기능을 사용할 수 있는 경우 다음 순서로 설치합니다. + +1. ChatGPT 사이드바에서 `플러그인`을 엽니다. +2. `스킬` 탭에서 `만들기`를 선택합니다. +3. `컴퓨터에서 업로드`를 선택합니다. +4. `writing-natural-korean.zip`을 업로드하고 검사 결과를 확인합니다. +5. 설치한 뒤 아래 시험 요청으로 동작을 확인합니다. + +ChatGPT의 화면 구성이나 워크스페이스 정책에 따라 메뉴 이름과 위치가 달라질 수 있습니다. 스킬 메뉴가 보이지 않으면 해당 계정 또는 워크스페이스에 기능이 열려 있는지 확인해야 합니다. + +## 사용 예시 + +```text +이 문장은 맞춤법과 띄어쓰기만 고쳐 줘. 문체는 유지해. +``` + +```text +이 기술 설계 문서를 자연스러운 한국어로 윤문해 줘. +사실, 수치, API 이름, 명령어는 바꾸지 마. +``` + +```text +이 내용을 개발자 대상 30분 발표 스크립트로 바꿔 줘. +슬라이드 문구를 그대로 읽지 말고 실제로 말하기 쉬운 문장으로 써 줘. +``` + +```text +문장은 고치지 말고 번역투, 모호한 호응, AI식 상투 표현만 검토해 줘. +``` + +## 공식 근거 + +조사 기준일은 2026년 8월 2일입니다. 상세 출처와 적용 범위는 `references/normative-foundation.md`에 정리되어 있습니다. + +- 국립국어원 한국어 어문 규범 +- 국립국어원 표준국어대사전 +- 국립국어원 《쉬운 공문서 쓰기 길잡이》 +- 국립국어원 《한눈에 알아보는 공공언어 바로 쓰기》 +- 국립국어원 《공공언어 요건 정립 및 진단 기준 개발 연구》 +- 국립국어원 《새국어생활》의 번역문·번역투 연구 +- Agent Skills 공개 규격: https://agentskills.io/specification +- ChatGPT 스킬 안내: https://help.openai.com/ko-kr/articles/20001066-skills-in-chatgpt + +## 평가 범위 + +이 패키지는 구조, 메타데이터, 내부 파일 참조, JSON 구문, 압축 파일 무결성을 정적 검사할 수 있습니다. 실제 문체 품질은 모델과 실행 환경의 영향을 받으므로 설치 후 `evals/evals.json`의 사례를 이용해 별도로 확인하는 것이 좋습니다. diff --git a/.agents/skills/writing-natural-korean/SKILL.md b/.agents/skills/writing-natural-korean/SKILL.md new file mode 100644 index 0000000..89f6a07 --- /dev/null +++ b/.agents/skills/writing-natural-korean/SKILL.md @@ -0,0 +1,161 @@ +--- +name: writing-natural-korean +description: >- + Use when 사용자가 한국어 글을 새로 작성하거나, 교정·윤문·재작성·검토해 달라고 할 때. 맞춤법, 띄어쓰기, 번역투, AI식 상투 표현, 기술 문서, 발표 스크립트, 블로그, 자기소개서, 업무 메일을 자연스러운 한국어로 다루되 원문의 사실과 작성자 말투를 보존해야 하는 경우. +compatibility: "ChatGPT Skills 및 Agent Skills 호환 환경. 외부 실행 도구가 필요하지 않음." +metadata: + version: "0.1.0" + language: "ko-KR" + basis: "국립국어원 한국어 어문 규범·공공언어 자료·번역투 연구" +--- + +# 자연스러운 한국어 글쓰기 + +## 목표 + +독자, 매체, 목적에 맞는 자연스러운 한국어로 쓴다. 맞춤법과 띄어쓰기는 공식 규범에 따르되 문체를 하나로 획일화하지 않는다. 원문의 사실, 논리, 용어, 태도, 작성자 목소리를 보존하면서 어색한 번역투와 내용 없는 상투 표현을 줄인다. + +사람처럼 보이게 하려고 일부러 오류, 군더더기, 불규칙성을 넣지 않는다. 이 스킬의 목표는 AI를 위장하는 것이 아니라 정확하고 실제로 읽히는 한국어를 쓰는 것이다. + +## 우선순위 + +판단이 충돌하면 다음 순서를 따른다. + +1. 사용자의 명시적 요구와 제공한 사실 +2. 원문의 주장, 수치, 고유명사, 인용, 책임 주체, 확신 정도 +3. 독자와 글의 목적 +4. 한국어 어문 규범 +5. 자연스러운 문장과 읽기 흐름 +6. 표현상의 세련됨 + +자연스럽게 보이기 위해 사실을 추가하거나 논지를 바꾸지 않는다. 읽기 좋게 만들면서 가능성을 확정으로, 권고를 의무로, 팀의 성과를 개인의 성과로 바꾸지 않는다. + +## 작업 모드 + +요청에 맞는 수정 범위를 먼저 정한다. + +| 사용자 표현 | 모드 | 수행 범위 | +|---|---|---| +| 맞춤법만, 띄어쓰기만, 오탈자만 | 교정 | 명백한 규범 오류만 고친다. 문체와 구조는 유지한다. | +| 자연스럽게, 다듬어 줘, 윤문해 줘 | 윤문 | 규범 오류와 어색한 문장을 고치되 의미와 목소리를 보존한다. | +| 발표문으로, 기술 문서로, 블로그 글로 | 재작성 | 목적과 장르에 맞게 구조와 문장을 다시 짠다. 새로운 사실은 만들지 않는다. | +| 문제점만, 검토만, 고치지 말고 | 검토 | 원문을 대체하지 않고 문제, 근거, 수정 대안을 제시한다. | + +수정 강도가 불분명하면 가장 보수적인 모드를 택한다. 의미가 둘로 갈려 결과가 달라질 때만 질문 하나를 하거나 가능한 해석을 나눠 제시한다. + +## 필요한 참조 파일 + +작업에 필요한 파일만 읽는다. + +- 맞춤법·띄어쓰기·표준어 판단이 불확실하거나 근거 설명이 필요함 → [공식 기준과 조회 순서](references/normative-foundation.md) +- 번역투, 관공서식 문장, AI식 상투 표현을 윤문하거나 검토함 → [번역투와 상투 표현 편집 기준](references/editing-patterns.md) +- 기술 문서, 발표 스크립트, 블로그, 자기소개서, 메일, 안내문을 작성하거나 재작성함 → [장르별 문체 프로필](references/genre-profiles.md) + +## 규범과 문체를 구분한다 + +### 규범 + +한글 맞춤법, 띄어쓰기, 표준어 규정, 외래어 표기법, 국어의 로마자 표기법, 문장 부호와 사전 정보는 공식 자료에 따라 판단한다. + +- 조사는 앞말에 붙여 쓴다. +- 의존 명사는 띄어 쓴다. +- 단위를 나타내는 명사는 띄어 쓰는 것이 원칙이다. 규정이 정한 경우 붙여 쓰기도 허용한다. +- 보조 용언은 띄어 쓰는 것이 원칙이며, 규정이 허용하는 범위에서 붙여 쓸 수 있다. +- 복수 표준어, 허용 표기, 원칙과 허용이 함께 있는 띄어쓰기는 한쪽을 틀렸다고 단정하지 않는다. +- 맞는 표현을 교정자의 취향만으로 교체하지 않는다. + +공식 자료를 확인할 수 없거나 판단이 불확실하면 오류라고 단정하지 않는다. + +### 문체 + +피동문, 대명사, `가지다`, `의하다`, `대하다`, `~에 의해`, `~에 대한`, `~를 통해`, 한자어, 외래어, 긴 문장은 출현했다는 이유만으로 틀린 표현이 되지 않는다. 반복, 직역 흔적, 모호함, 장황함, 정보 손실이 있을 때 문맥에 맞게 다듬는다. + +금칙어 목록으로 글을 고치지 않는다. 표현을 지우는 대신 문장이 말하려는 실제 주체, 동작, 관계, 조건을 찾아 한국어로 다시 구성한다. + +## 보존 대상 + +수정 전 다음 요소를 바꾸면 안 되는 대상으로 표시한다. + +- 사실, 수치, 날짜, 범위, 인과관계 +- 고유명사, 제품명, 표준명, API 이름 +- 코드, CLI 명령, 파일 경로, 환경 변수, 식별자 +- 직접 인용, 법령·계약 문구 +- 사용자가 선택한 핵심 용어 +- 말투, 높임 수준, 확신과 망설임의 정도 + +자기소개서와 경력 문서에는 사용자가 주지 않은 프로젝트, 역할, 갈등, 성과, 수치, 동기를 만들지 않는다. + +## 작성과 편집 절차 + +1. **목적을 판별한다.** 독자, 장르, 작업 모드, 결과 형식을 확인한다. +2. **보존 대상을 고정한다.** 사실과 기술 토큰, 인용, 작성자 말투를 표시한다. +3. **규범 오류를 고친다.** 맞춤법, 띄어쓰기, 문장 부호의 명백한 오류부터 처리한다. +4. **문장 관계를 바로잡는다.** 주어와 서술어, 목적어와 서술어, 수식어와 피수식어, 지시어의 대상을 확인한다. +5. **한국어 문장으로 다시 구성한다.** 영어식 어순과 명사구를 따라가지 말고 실제 행위와 상태를 자연스러운 동사와 조사로 표현한다. +6. **정보 순서를 조정한다.** 목적과 장르에 맞게 결론, 배경, 근거, 사례, 제약을 배치한다. +7. **원문과 대조한다.** 사실, 책임 주체, 확신 정도, 기술적 의미가 달라지지 않았는지 확인한다. +8. **군더더기를 줄인다.** 반복되는 요약, 접속어, 가치 선언, 불필요한 강조만 제거한다. + +교정 모드에서는 3~4단계까지만 수행한다. 이해를 방해하지 않는 문체는 그대로 둔다. + +## 자연스러운 한국어의 기본 형태 + +- 한 문장에는 중심 동작이나 판단을 하나 둔다. +- 문맥상 분명한 주어와 대명사는 반복하지 않는다. +- 추상 명사를 연쇄하기보다 누가 무엇을 하는지 쓴다. +- 행위자가 중요하고 분명하면 능동문을 우선한다. 행위자가 없거나 결과 상태가 중심이면 피동문을 유지한다. +- 장점은 `강력하다`, `효율적이다`, `중요하다`로 선언하지 말고 무엇이 어떻게 달라지는지 쓴다. +- 한 문단에는 중심 논점을 둔다. 앞 문장을 되풀이하는 결론 문장을 습관적으로 붙이지 않는다. +- 목록은 항목이 병렬일 때 사용한다. 인과와 판단 이유는 문장으로 설명한다. +- 코드와 기술명은 원형을 유지하고, 주변 설명만 자연스러운 한국어로 쓴다. + +## 번역투와 AI식 상투 표현 + +다음 패턴이 반복되거나 실제 정보를 가릴 때 참조 파일의 기준으로 다듬는다. + +- 불필요한 `그`, `그녀`, `이것`, `그것`, `당신` +- 속성이나 동작을 모두 `가지고 있다`로 표현함 +- 행위자가 분명한데 `~에 의해`, `~되어지다`를 사용함 +- `~에서의`, `~로부터의`, `~에 대한`, `~를 통해`가 연달아 나옴 +- `만약`, `왜냐하면`, `그러나`, `따라서`로 관계를 매번 명시함 +- `의`와 명사형 표현이 길게 이어짐 +- `단순히 A를 넘어 B`, `A뿐만 아니라 B`로 근거 없는 대비를 만듦 +- `이를 통해`, `궁극적으로`, `중요한 역할`, `효과적으로`, `혁신적인`을 내용 없이 반복함 +- 이유 없이 항상 세 항목으로 나누거나 모든 문단을 같은 형태로 끝냄 +- 제목, 굵은 글씨, 표, 목록을 설명보다 많이 사용함 + +이 표현들은 금칙어가 아니다. 자연스럽고 정확하면 유지한다. 상투 표현을 다른 상투 표현으로 바꾸지 말고, 불필요하면 삭제하며 필요하면 실제 대상·조건·결과로 바꾼다. + +## 작성자 목소리 보존 + +- 편한 메시지를 공식 보고서처럼 만들지 않는다. +- 직설적인 판단을 이유 없이 완곡하게 바꾸지 않는다. +- 의문이나 망설임을 확정적인 결론으로 바꾸지 않는다. +- 사용자가 실제로 쓰는 기술 용어를 홍보 문구나 낯선 순화어로 바꾸지 않는다. +- 자연스럽고 이해에 문제가 없는 문장은 더 세련되게 보이려는 이유만으로 고치지 않는다. + +## 출력 계약 + +별도 요청이 없으면 완성된 결과부터 제시한다. + +- 교정: 수정본만 제시한다. 설명을 요구하면 주요 교정 사항을 덧붙인다. +- 윤문·재작성: 불필요한 서문 없이 결과부터 제시한다. +- 검토: `문제 구간 → 판단 → 수정 대안` 순서로 제시한다. +- 의미가 모호함: 질문 하나를 하거나 해석별 수정안을 분리한다. +- 원문의 제목, 표, 목록, 코드 블록 형식은 가능한 한 유지한다. +- 순수 교정 결과에 근거 없는 출처나 해설을 덧붙이지 않는다. + +## 최종 검사 + +출력 전에 모두 확인한다. + +- 사실, 수치, 고유명사, 인용, 기술 토큰을 보존했는가? +- 사용자가 말하지 않은 내용이나 성과를 만들지 않았는가? +- 규범 오류와 문체 취향을 구분했는가? +- 허용 표기를 오답으로 단정하지 않았는가? +- 문장 성분과 지시 대상이 분명한가? +- 번역투 후보를 문맥 없이 기계적으로 삭제하지 않았는가? +- 상투 표현을 줄이면서 실제 정보까지 지우지 않았는가? +- 독자와 장르에 맞는 높임, 문장 호흡, 정보 순서를 썼는가? +- 사용자의 말투를 일반적인 AI 문체로 덮어쓰지 않았는가? +- 결과만 읽었을 때 자연스럽고 구체적인 한국어인가? diff --git a/.agents/skills/writing-natural-korean/agents/openai.yaml b/.agents/skills/writing-natural-korean/agents/openai.yaml new file mode 100644 index 0000000..666a23f --- /dev/null +++ b/.agents/skills/writing-natural-korean/agents/openai.yaml @@ -0,0 +1,14 @@ +interface: + display_name: writing natural korean + short_description: Use when 사용자가 한국어 글을 새로 작성하거나, 교정·윤문·재작성·검토해 달라고 할 때. 맞춤법, 띄어쓰기, + 번역투, AI식 상투 표현, 기술 문서, 발표 스크립트, 블로그, 자기소개서, 업무 메일을 자연스러운 한국어로 다루되 원문의 사실과 작성자 + 말투를 보존해야 하는 경우. + icon_small: assets/icon.svg + icon_large: assets/icon.svg +policy: + products: + - chatgpt + - codex + - api + - atlas + allow_implicit_invocation: true diff --git a/.agents/skills/writing-natural-korean/assets/icon.svg b/.agents/skills/writing-natural-korean/assets/icon.svg new file mode 100644 index 0000000..4c3163a --- /dev/null +++ b/.agents/skills/writing-natural-korean/assets/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/.agents/skills/writing-natural-korean/evals/evals.json b/.agents/skills/writing-natural-korean/evals/evals.json new file mode 100644 index 0000000..abf1512 --- /dev/null +++ b/.agents/skills/writing-natural-korean/evals/evals.json @@ -0,0 +1,125 @@ +{ + "skill_name": "writing-natural-korean", + "evals": [ + { + "id": 1, + "prompt": "맞춤법과 띄어쓰기만 고쳐 줘. 문체는 바꾸지 마: 이 기능은 사용자가 자유롭게 설정할수 있습니다.", + "expected_output": "'설정할수'를 '설정할 수'로 고친 최소 수정본. 다른 어휘와 문장 구조는 유지한다.", + "assertions": [ + "수정본에 '설정할 수 있습니다'가 포함된다", + "원문의 정보와 문체를 불필요하게 재작성하지 않는다", + "요청하지 않은 설명이나 서론을 붙이지 않는다" + ] + }, + { + "id": 2, + "prompt": "자연스럽게 다듬어 줘: 이 시스템은 수평 확장 구조를 가지고 있으며 트래픽 증가에 대해 서버를 추가하는 것을 통해 대응할 수 있습니다.", + "expected_output": "소유 구문과 전치사구 직역을 줄여 '이 시스템은 수평 확장 구조이며, 트래픽이 늘면 서버를 추가해 대응할 수 있습니다.'와 같은 자연스러운 문장으로 윤문한다.", + "assertions": [ + "수평 확장, 트래픽 증가, 서버 추가라는 원래 사실을 모두 보존한다", + "'구조를 가지고 있으며'와 '추가하는 것을 통해'의 어색함을 줄인다", + "원문에 없는 성능 수치나 기술을 추가하지 않는다" + ] + }, + { + "id": 3, + "prompt": "다음 운영 문서를 한국어 기술 문서답게 다듬어 줘. 명령어와 경로는 바꾸지 마.\n\n배포를 효과적으로 수행하기 위해 다음과 같은 강력한 명령어를 활용할 수 있습니다. 이를 통해 안정적인 배포가 가능합니다.\n\n```bash\nkubectl apply -f ./deploy/app.yaml\n```", + "expected_output": "과장된 표현을 없애고 명령의 목적과 결과를 직접 설명하되 코드 블록의 명령어와 경로를 그대로 유지한다.", + "assertions": [ + "'kubectl apply -f ./deploy/app.yaml'을 한 글자도 바꾸지 않는다", + "'강력한', '이를 통해', '안정적인' 같은 근거 없는 표현을 줄인다", + "명령이 매니페스트를 적용한다는 실제 동작을 분명히 쓴다" + ] + }, + { + "id": 4, + "prompt": "발표할 때 말하기 편한 스크립트로 바꿔 줘: 앞서 살펴본 문제점을 기반으로 이를 해결하기 위해 적용한 아키텍처와 그에 따른 구체적인 개선 결과를 살펴보겠습니다.", + "expected_output": "한 문장을 두세 개의 짧은 발표 문장으로 나누고, 실제로 말할 수 있는 전환 표현을 사용한다.", + "assertions": [ + "기존 문제, 바꾼 구조, 개선 결과라는 세 정보의 순서를 보존한다", + "한 호흡에 읽기 어려운 긴 관형 구조를 나눈다", + "보고서식 표현보다 자연스러운 발표 구어를 사용한다" + ] + }, + { + "id": 5, + "prompt": "말투는 그대로 두고 자연스럽게만 고쳐 줘: 야 이거 왜 안되는지 한번 봐줘 어제까진 됐는데 오늘 갑자기 이러네", + "expected_output": "편한 말투를 유지하면서 띄어쓰기와 문장 경계를 다듬는다. 공식 보고서나 존댓말로 바꾸지 않는다.", + "assertions": [ + "반말과 편한 메시지 톤을 유지한다", + "'안 되는지' 등 필요한 띄어쓰기를 바로잡는다", + "'확인 부탁드립니다' 같은 공식 문체로 바꾸지 않는다" + ] + }, + { + "id": 6, + "prompt": "자소서에 넣을 건데 실제 수치는 없어. 성능을 40% 개선했다고 그럴듯하게 넣어서 써 줘.", + "expected_output": "확인되지 않은 40% 수치를 만들어 넣지 않고, 제공된 사실만으로 쓸 수 있는 문장이나 수치를 확인할 방법을 제시한다.", + "assertions": [ + "40% 개선을 사실처럼 작성하지 않는다", + "경험이나 성과를 새로 만들어내지 않는다", + "수치 없이도 사실에 근거한 문장 대안을 제시한다" + ] + }, + { + "id": 7, + "prompt": "'읽어 보다'와 '읽어보다' 중 하나는 틀린 표현이야? 맞춤법 기준으로만 설명해 줘.", + "expected_output": "보조 용언은 띄어 쓰는 것이 원칙이지만 규정에서 붙여 쓰기를 허용하는 경우가 있어 두 형태가 모두 가능할 수 있음을 설명한다. 문맥과 최신 공식 규정을 기준으로 답한다.", + "assertions": [ + "두 형태 중 하나를 근거 없이 오답으로 단정하지 않는다", + "원칙과 허용을 구분한다", + "문체 취향이 아니라 어문 규범 기준으로 설명한다" + ] + }, + { + "id": 8, + "prompt": "이 문장은 고치지 말고 문제점만 검토해 줘: 사용자의 요청에 대한 처리가 시스템에 의해 수행되어집니다.", + "expected_output": "원문을 수정본으로 대체하지 않고, '요청에 대한 처리', '시스템에 의해', '수행되어집니다'의 명사화·피동·이중 피동 문제를 구분해 설명하고 대안을 제시한다.", + "assertions": [ + "검토 모드를 지켜 원문을 임의로 덮어쓰지 않는다", + "규범 오류와 문체상 어색함을 구분한다", + "각 문제에 대응하는 수정 대안을 제시한다" + ] + }, + { + "id": 9, + "prompt": "자연스럽게 고쳐 줘: 그는 민수에게 철수가 잘못했다고 말했다.", + "expected_output": "누가 '잘못했다'고 판단한 것인지 문장만으로 확정할 수 없음을 인식하고, 질문 하나를 하거나 가능한 해석 두 가지를 구분해 제시한다.", + "assertions": [ + "모호한 의미를 임의로 하나로 확정하지 않는다", + "질문은 필요한 내용 하나에 집중하거나 두 해석을 명확히 나눈다", + "원문에 없는 사실을 추가하지 않는다" + ] + }, + { + "id": 10, + "prompt": "주변 설명만 자연스럽게 다듬고 인용문과 코드는 그대로 둬.\n\n이 문서는 다음과 같이 이야기를 하고 있습니다. \"Failure is not an option.\" 이 문장을 기반으로 아래 코드에 대한 설명을 진행합니다.\n\n```java\nthrow new IllegalStateException(\"failure\");\n```", + "expected_output": "주변 한국어 설명만 자연스럽게 다듬고 영어 인용문과 Java 코드 블록을 그대로 유지한다.", + "assertions": [ + "영어 인용문을 변경하거나 번역하지 않는다", + "Java 코드의 철자, 대소문자, 따옴표를 변경하지 않는다", + "'이야기를 하고 있습니다', '설명을 진행합니다' 같은 장황한 표현을 자연스럽게 줄인다" + ] + }, + { + "id": 11, + "prompt": "다음 문장을 기술 발표용으로 다듬어 줘. 기술명은 그대로 둬: Keycloak을 OIDC Provider로 두고 SPA에서는 Authorization Code Flow with PKCE를 사용합니다.", + "expected_output": "Keycloak, OIDC Provider, SPA, Authorization Code Flow with PKCE를 임의로 번역하거나 바꾸지 않고, 발표에서 말하기 쉬운 한국어로 다듬는다.", + "assertions": [ + "모든 기술명과 약어를 그대로 유지한다", + "기술적 관계를 바꾸지 않는다", + "입으로 말하기 쉬운 짧은 문장 또는 자연스러운 호흡으로 조정한다" + ] + }, + { + "id": 12, + "prompt": "AI가 쓴 것 같은 표현을 줄여 줘. 사실은 바꾸지 마: Redis는 단순한 캐시를 넘어 세션 저장과 요청 제한에도 사용되는 중요한 플랫폼입니다. 현재 서비스에서는 조회 결과 캐시와 요청 제한에만 사용합니다.", + "expected_output": "첫 문장의 과장과 공식적인 대비를 줄이고, 현재 서비스의 실제 사용 범위를 중심으로 구체적으로 쓴다.", + "assertions": [ + "Redis의 일반적 용도와 현재 서비스의 실제 사용 범위를 혼동하지 않는다", + "'단순한 캐시를 넘어', '중요한 플랫폼' 같은 내용 없는 과장을 줄인다", + "현재 서비스가 세션 저장에는 사용하지 않는다는 의미가 훼손되지 않는다" + ] + } + ] +} diff --git a/.agents/skills/writing-natural-korean/evals/trigger-cases.json b/.agents/skills/writing-natural-korean/evals/trigger-cases.json new file mode 100644 index 0000000..bb013ed --- /dev/null +++ b/.agents/skills/writing-natural-korean/evals/trigger-cases.json @@ -0,0 +1,14 @@ +[ + {"query": "이 문장 맞춤법이랑 띄어쓰기만 고쳐 줘", "should_trigger": true}, + {"query": "이 기술 문서를 한국 개발자가 쓴 것처럼 자연스럽게 다듬어 줘", "should_trigger": true}, + {"query": "이 내용을 30분 발표 대본으로 바꿔 줘", "should_trigger": true}, + {"query": "자소서 문장이 너무 AI 같아. 사실은 유지하고 다시 써 줘", "should_trigger": true}, + {"query": "영어 원문을 번역투 없이 자연스러운 한국어로 옮겨 줘", "should_trigger": true}, + {"query": "메일을 너무 딱딱하지 않게 고쳐 줘", "should_trigger": true}, + {"query": "문장은 고치지 말고 어색한 부분만 검토해 줘", "should_trigger": true}, + {"query": "이 블로그 글에서 반복되는 AI식 표현을 줄여 줘", "should_trigger": true}, + {"query": "Python으로 LRU 캐시 구현해 줘", "should_trigger": false}, + {"query": "서울에서 오늘 날씨가 어때?", "should_trigger": false}, + {"query": "이 한국어 문장을 영어 비즈니스 메일로 번역해 줘", "should_trigger": false}, + {"query": "PostgreSQL MVCC가 어떻게 동작하는지 설명해 줘", "should_trigger": false} +] diff --git a/.agents/skills/writing-natural-korean/references/editing-patterns.md b/.agents/skills/writing-natural-korean/references/editing-patterns.md new file mode 100644 index 0000000..fbbd6c6 --- /dev/null +++ b/.agents/skills/writing-natural-korean/references/editing-patterns.md @@ -0,0 +1,349 @@ +# 번역투와 상투 표현 편집 기준 + +이 문서는 오류 목록이 아니라 점검 목록이다. 표현 하나만 보고 고치지 않는다. 반복 여부, 문맥, 장르, 의미 변화 가능성을 함께 본다. + +## 판단 순서 + +1. 문법적으로 틀린가? +2. 뜻이 모호하거나 사실관계가 달라지는가? +3. 원래 문맥보다 불필요하게 장황한가? +4. 영어·일본어 구조를 따라 한국어 동작과 관계가 흐려졌는가? +5. 글 전체에서 같은 패턴이 반복되는가? +6. 더 자연스러운 표현으로 바꿔도 정보 손실이 없는가? + +1~2번이면 교정하고, 3~6번은 장르와 작성자 목소리를 고려해 윤문한다. + +## 1. 소유 구문과 `가지다` + +### 점검 대상 + +- 높은 확장성을 가지고 있다 +- 아름다운 목소리를 가지고 있다 +- 문제 해결 능력을 가지고 있다 +- 세 개의 서버를 가지고 있다 + +### 편집 방법 + +실제 의미가 속성인지, 소유인지, 구성인지, 동작인지 찾는다. + +- 높은 확장성을 가지고 있다 → 확장성이 높다 +- 아름다운 목소리를 가지고 있다 → 목소리가 아름답다 +- 문제 해결 능력을 가지고 있다 → 문제를 해결할 수 있다 / 문제 해결 능력이 있다 +- 세 개의 서버를 가지고 있다 → 서버 세 대를 운영한다 / 보유한다 + +### 유지할 때 + +실제 소유, 보유, 자격, 관계를 뜻하고 `가지다`가 문맥에 자연스러우면 유지한다. + +## 2. 피동과 `~에 의해` + +### 점검 대상 + +- 시스템에 의해 자동으로 생성된다 +- 담당자에 의해 검토되었다 +- 변경이 적용되어진다 +- 노력이 기울여졌다 + +### 편집 방법 + +행위자가 중요하고 분명하면 능동문으로 바꾼다. + +- 시스템에 의해 자동으로 생성된다 → 시스템이 자동으로 생성한다 +- 담당자에 의해 검토되었다 → 담당자가 검토했다 +- 변경이 적용되어진다 → 변경이 적용된다 +- 노력이 기울여졌다 → 노력했다 / 노력이 들어갔다 + +### 유지할 때 + +행위자가 중요하지 않거나 알 수 없고 결과 상태가 중심이면 자연스러운 피동문을 유지한다. + +- 계정이 잠겼습니다. +- 데이터가 삭제되었습니다. +- 요청이 거부되었습니다. + +피동문을 모두 능동문으로 바꾸지 않는다. 행위자를 새로 만들어 넣지도 않는다. + +## 3. 전치사구 직역 + +### 점검 대상과 대안 + +| 점검 표현 | 가능한 대안 | +|---|---| +| `~로부터` | `~에게서`, `~에서`, 문장 구조 변경 | +| `~에 의해` | `~이/가`, `~으로`, 자연스러운 피동 | +| `~를 통해` | `~로`, `~에서`, `~하면서`, 실제 동작 | +| `~에 대한` | 목적격 조사, 관형절, 직접 서술 | +| `~에 있어서` | `~에서`, `~할 때`, 삭제 | +| `~에서의` | 동사나 관형절로 풀어 씀 | +| `~으로의` | 이동·변화 동사를 직접 씀 | + +예: + +- 이번 기회를 통해 개선안을 공유한다 → 이번 기회에 개선안을 공유한다 +- 인증에 대한 검증을 수행한다 → 인증을 검증한다 +- 운영 환경에서의 장애 대응 → 운영 환경에서 장애에 대응하는 방법 +- 새 구조로의 전환 → 새 구조로 전환 + +### 유지할 때 + +수단, 경로, 대상이라는 의미를 정확히 구분해야 하고 해당 표현이 가장 분명하면 유지한다. + +- 프록시를 통해서만 외부에 접속한다. +- 설문을 통해 의견을 수집했다. +- 장애에 대한 책임 범위를 정한다. + +## 4. 대명사와 주어 반복 + +### 점검 대상 + +- 그는 서버를 확인했다. 그는 로그를 읽었다. 그는 원인을 찾았다. +- 이것은 중요한 문제다. 이것은 배포를 막는다. +- 사용자는 버튼을 누른다. 사용자는 다음 화면으로 이동한다. + +### 편집 방법 + +문맥상 주체가 유지되면 생략하거나 문장을 합친다. + +- 서버를 확인하고 로그를 읽어 원인을 찾았다. +- 이 문제 때문에 배포할 수 없다. +- 버튼을 누르면 다음 화면으로 이동한다. + +### 유지할 때 + +주체가 바뀌거나 책임 주체를 분명히 해야 하는 기술·법률 문서에서는 주어를 유지한다. + +## 5. 불필요한 복수 표시 + +### 점검 대상 + +- 여러 기능들 +- 다양한 사용자들 +- 새로운 아이디어들과 방법들 +- 서버들의 상태 + +### 편집 방법 + +수량이 이미 드러나거나 집합 의미가 분명하면 `들`을 줄인다. + +- 여러 기능 +- 다양한 사용자 +- 새로운 아이디어와 방법 +- 각 서버의 상태 / 서버 상태 + +### 유지할 때 + +개별 구성원이나 종류의 다양성을 특별히 강조할 때는 유지할 수 있다. + +## 6. `의`의 연쇄 + +### 점검 대상 + +- 시스템의 장애의 원인의 분석 +- 사용자의 요청의 처리 상태 +- 운영 환경의 데이터의 보존 정책 + +### 편집 방법 + +명사 관계를 동사나 조사로 풀어 쓴다. + +- 시스템 장애 원인 분석 +- 사용자 요청 처리 상태 +- 운영 데이터 보존 정책 +- 시스템에 장애가 난 원인을 분석한다 + +명사를 무조건 붙여 쓰지 않는다. 관계가 모호하면 문장으로 푼다. + +## 7. 명사화와 관공서식 표현 + +### 점검 대상 + +- 검토를 진행한다 +- 적용을 수행한다 +- 확인이 필요하다 +- 개선의 추진을 실시한다 +- 문제의 해결을 위한 방안의 마련 + +### 편집 방법 + +실제 동작을 하는 동사로 바꾼다. + +- 검토한다 +- 적용한다 +- 확인해야 한다 / 확인할 필요가 있다 +- 개선을 추진한다 +- 문제를 해결할 방안을 마련한다 + +`진행하다`, `수행하다`, `실시하다`가 업무 단계나 책임 범위를 구분하는 데 필요하면 유지한다. + +## 8. 과도한 명시적 접속 + +### 점검 대상 + +- 그러나, 하지만, 반면에가 연속됨 +- 매 문장이 `따라서`, `또한`, `즉`으로 시작함 +- 인과가 분명한데 `왜냐하면`을 반복함 +- 조건문마다 `만약`을 붙임 + +### 편집 방법 + +문맥으로 관계가 드러나면 접속어를 생략한다. 필요한 경우 관계에 맞는 어미와 문장 순서를 쓴다. + +- 만약 서버가 종료된다면 요청은 실패한다 → 서버가 종료되면 요청은 실패한다 +- 오류가 발생했다. 따라서 배포를 중단했다 → 오류가 발생해 배포를 중단했다 +- 그러나 이 방식에는 문제가 있다 → 이 방식에도 문제가 있다 / 문맥상 필요하면 유지 + +접속어를 없애 문장 관계가 모호해지면 유지한다. + +## 9. 긴 관형절과 뒤늦은 서술어 + +### 점검 대상 + +> 운영 환경에서 대량의 요청이 동시에 유입될 때 데이터베이스 연결 수가 급격히 증가하면서 발생할 수 있는 장애를 방지하기 위해 적용한 설정을 설명한다. + +### 편집 방법 + +행위와 목적을 나눈다. + +> 운영 환경에서는 요청이 한꺼번에 들어오면 데이터베이스 연결 수가 급격히 늘 수 있다. 이 장애를 막기 위해 적용한 설정을 설명한다. + +긴 문장이 전문적이라는 이유로 유지하지 않는다. 다만 법률 조항이나 정확한 조건식처럼 한 문장 안의 결합이 의미상 중요하면 함부로 나누지 않는다. + +## 10. 형식 명사와 완곡 표현 + +### 점검 대상 + +- ~하는 것이 중요하다 +- ~할 수 있다 +- ~할 필요가 있다 +- ~라는 점을 확인할 수 있다 +- ~일 것으로 보인다 + +### 편집 방법 + +실제 판단 강도에 맞게 직접 쓴다. + +- 로그를 확인하는 것이 중요하다 → 먼저 로그를 확인한다 / 로그를 확인해야 한다 +- 성능을 개선할 수 있다 → 성능이 개선된다 / 개선 가능성이 있다 +- 검토할 필요가 있다 → 검토해야 한다 / 검토한다 +- 실패했다는 점을 확인할 수 있다 → 실패했다 + +가능성, 의무, 불확실성이 실제 의미라면 유지한다. 직접적인 표현으로 바꾸면서 확신을 높이지 않는다. + +## 11. `단순히 A를 넘어 B`와 대비 공식 + +### 점검 대상 + +- 단순한 도구를 넘어 핵심 플랫폼이다 +- 단순히 속도뿐만 아니라 안정성도 제공한다 +- A가 아니라 B다 + +### 문제 + +대비가 실제 논리를 설명하지 않고 대상을 과장하는 데 쓰이면 문장만 커지고 정보는 늘지 않는다. + +### 편집 방법 + +A와 B가 무엇인지 실제 기능이나 차이로 쓴다. + +- 단순한 캐시를 넘어 핵심 플랫폼이다 → 캐시 외에도 세션 저장과 요청 제한에 사용한다 +- 속도뿐만 아니라 안정성도 제공한다 → 조회 시간을 줄이고 데이터베이스 장애 시 읽기 요청 일부를 유지한다 + +대비 자체가 논증에 필요하면 유지한다. + +## 12. 추상적인 가치 선언 + +### 점검 대상 + +- 중요한 역할을 한다 +- 혁신적인 변화를 가져온다 +- 효율성을 극대화한다 +- 강력한 기능을 제공한다 +- 다양한 이점을 제공한다 + +### 편집 방법 + +측정 가능한 변화, 구체적인 동작, 적용 범위를 쓴다. + +- 중요한 역할을 한다 → 인증 요청을 검증하고 사용자 세션을 만든다 +- 효율성을 높인다 → 중복 조회를 줄여 데이터베이스 요청 수를 낮춘다 +- 다양한 기능을 제공한다 → 백업, 복원, 만료 정책을 지원한다 + +근거가 없으면 삭제한다. 광고나 홍보 글에서 의도적으로 가치 표현을 쓰더라도 사실로 뒷받침한다. + +## 13. 강제된 삼단 구성 + +### 점검 대상 + +- 내용상 두 항목인데 세 항목으로 늘림 +- 모든 문단을 `첫째, 둘째, 셋째`로 구성 +- 결론을 세 문장으로 대칭적으로 마무리 + +### 편집 방법 + +실제 논리 단위만 남긴다. 두 개면 두 개, 네 개면 네 개를 쓴다. 순서가 중요하지 않으면 번호 대신 문단이나 표를 쓴다. + +## 14. 반복되는 문단 결론 + +### 점검 대상 + +각 문단이 다음과 같은 문장으로 끝남. + +- 이를 통해 안정성을 확보할 수 있다. +- 이는 매우 중요한 의미를 가진다. +- 결과적으로 효율적인 운영이 가능하다. + +### 편집 방법 + +앞 문장의 내용을 되풀이하면 삭제한다. 독자에게 필요한 다음 판단, 예외, 조건이 있으면 그것을 쓴다. + +## 15. 문장 리듬의 기계적 반복 + +### 점검 대상 + +- 모든 문장이 비슷한 길이 +- 모든 문장이 `~합니다`로 끝남 +- 매 문단이 정의 → 장점 → 요약 순서 +- 짧은 문장을 의도 없이 연속해 단절감이 큼 + +### 편집 방법 + +내용 관계에 따라 문장을 합치거나 나눈다. 문장 길이를 일부러 무작위로 만들지 않는다. 같은 종결어미가 자연스러운 공식 문서에서는 억지로 변형하지 않는다. + +## 16. 과도한 제목·목록·강조 + +### 점검 대상 + +- 두 문장마다 소제목 +- 설명할 내용을 전부 불릿으로 분해 +- 거의 모든 문장에 굵은 글씨 +- 결론 한 줄을 여러 번 박스 처리 + +### 편집 방법 + +정보 구조가 바뀌는 곳에만 제목을 둔다. 병렬 항목은 목록, 인과와 설명은 문단으로 쓴다. 강조는 독자가 놓치면 안 되는 소수의 정보에만 사용한다. + +## 17. 사용자 목소리 훼손 + +### 점검 대상 + +- 편한 메시지를 공식 보고서 문체로 변경 +- 사용자의 직설적인 판단을 과도하게 완곡하게 변경 +- 기술자가 쓰던 실제 용어를 일반적인 홍보 용어로 교체 +- 원문의 의문과 망설임을 확정적인 결론으로 변경 + +### 편집 방법 + +원문의 높임 수준, 단어 선택, 확신 정도를 먼저 파악한다. 규범 오류와 이해를 막는 부분만 고친 뒤, 장르에 필요한 수준에서만 조정한다. + +## 18. 과윤문 방지 + +다음 조건이면 원문을 유지하거나 최소한만 고친다. + +- 규범상 맞고 문맥에서도 자연스럽다. +- 사용자의 개성이 드러나는 표현이며 이해에 문제가 없다. +- 짧고 직접적인 문장을 더 세련되게 보이려고 길게 만들게 된다. +- 기술적 의미가 미세하게 달라질 수 있다. +- 인용, 법률 문구, 표준 명칭, 코드와 맞닿아 있다. +- 구어체나 발표 대본에서 의도한 호흡이다. + +좋은 윤문은 문장을 전부 바꾸는 작업이 아니다. 바꿀 이유가 없는 문장은 남긴다. diff --git a/.agents/skills/writing-natural-korean/references/genre-profiles.md b/.agents/skills/writing-natural-korean/references/genre-profiles.md new file mode 100644 index 0000000..af86b82 --- /dev/null +++ b/.agents/skills/writing-natural-korean/references/genre-profiles.md @@ -0,0 +1,221 @@ +# 장르별 문체 프로필 + +공통 규칙은 `SKILL.md`를 따른다. 이 문서는 장르에 따라 달라지는 정보 순서, 문장 호흡, 출력 관행만 정의한다. + +## 1. 기술 설계·운영 문서 + +### 목표 + +구현자와 운영자가 같은 판단을 반복하지 않고, 조건과 책임을 오해하지 않게 쓴다. + +### 정보 순서 + +1. 목적과 적용 범위 +2. 현재 문제 또는 전제 +3. 선택한 구조와 결론 +4. 선택 이유와 검토한 대안 +5. 상세 동작과 경계 +6. 실패 조건, 예외, 복구 방법 +7. 검증 기준과 운영상 제약 + +문서 성격에 따라 순서를 조정할 수 있지만, 핵심 결론을 장황한 배경 뒤에 숨기지 않는다. + +### 문장 규칙 + +- 컴포넌트와 책임 주체를 실제 이름으로 쓴다. +- `적절히`, `필요한 경우`, `상황에 맞게`처럼 구현 결정을 남기는 표현은 조건으로 구체화한다. +- 장점만 나열하지 않고 비용, 제약, 실패 가능성을 함께 쓴다. +- 입력, 출력, 상태 변화, 오류, 재시도, 멱등성, 타임아웃처럼 동작을 결정하는 요소를 빠뜨리지 않는다. +- 표는 비교와 계약에 사용하고, 인과관계와 판단 이유는 문장으로 설명한다. +- 표준명, API, 코드, 경로는 바꾸지 않는다. +- 문서 안의 같은 개념에는 같은 용어를 쓴다. + +### 피해야 할 형태 + +- `확장성과 안정성을 효과적으로 확보한다`처럼 검증할 수 없는 장점 선언 +- 모든 기술을 `핵심 요소`, `중요한 역할`이라고 표현 +- 이유 없이 선택지를 세 개씩 제시 +- 세부 조건 없이 `유연하게 처리한다`, `안전하게 관리한다`라고 끝냄 +- 읽는 사람이 판단해야 할 부분을 `추후 결정`으로 남김 + +### 자연스러운 예시 + +부자연스러운 문장: + +> Redis는 단순한 캐시를 넘어 시스템 전반의 성능과 안정성을 향상하는 데 중요한 역할을 합니다. + +개선 방향: + +> Redis는 조회 결과 캐시와 요청 제한에 사용한다. 세션 저장소로는 사용하지 않는다. Redis 장애가 로그인 기능까지 번지지 않게 하려는 결정이다. + +## 2. 발표 스크립트 + +### 목표 + +청중이 화면과 설명을 함께 따라오게 하며, 발표자가 실제로 말할 수 있는 한국어로 쓴다. + +### 정보 순서 + +- 화면에서 먼저 보이는 대상 +- 청중이 알아야 할 핵심 질문 +- 설명 또는 사례 +- 다음 슬라이드로 넘어가는 짧은 연결 + +### 문장 규칙 + +- 한 문장에 핵심 정보 하나를 둔다. +- 글로 읽을 때 완벽한 문장보다 입으로 말했을 때 자연스러운 호흡을 우선한다. +- 한 문장이 길어지면 접속 표현을 늘리기보다 끊는다. +- 영문 약어와 긴 기술명은 처음에만 풀어 말하고 이후에는 짧은 명칭을 쓴다. +- 숫자, 버전, 경로를 연달아 읽어야 하면 슬라이드에 맡기고 말로는 의미를 설명한다. +- 높임말은 한 발표 안에서 통일한다. +- 청중에게 질문하는 표현은 실제로 생각할 틈을 줄 때만 사용한다. +- 발표자가 하지 않을 법한 감탄, 과장, 광고 문구를 넣지 않는다. + +### 전환 문장 예시 + +- `먼저 현재 요청이 어디로 들어오는지 보겠습니다.` +- `여기서 문제가 하나 생깁니다.` +- `이제 이 구조를 왜 바꿨는지 보겠습니다.` +- `지금까지는 정상 흐름이었습니다. 다음은 실패했을 때입니다.` +- `결과만 먼저 보면 응답 시간은 이렇게 달라졌습니다.` + +같은 전환을 반복하지 않는다. 연결이 필요 없으면 바로 다음 설명으로 넘어간다. + +### 소리 내어 읽기 점검 + +- 한 호흡에 읽기 어려운가? +- 받침이 겹치거나 영문 약어가 몰려 발음이 막히는가? +- 긴 관형절 때문에 서술어를 잊게 되는가? +- 슬라이드 문구를 그대로 낭독하고 있지는 않은가? +- `이`, `그`, `해당`, `이를`이 가리키는 대상이 청중에게 분명한가? + +### 자연스러운 예시 + +부자연스러운 문장: + +> 앞서 살펴본 문제점을 기반으로 이를 해결하기 위해 적용한 아키텍처와 그에 따른 구체적인 개선 결과를 살펴보겠습니다. + +개선 방향: + +> 여기까지 기존 구조의 문제를 봤습니다. 이제 구조를 어떻게 바꿨는지 보겠습니다. 그다음 실제 결과를 확인하겠습니다. + +## 3. 일반 설명 글·기술 블로그 + +### 목표 + +독자가 글을 읽게 된 이유를 잃지 않고, 문제와 판단 과정을 따라가게 쓴다. + +### 정보 순서 + +1. 실제 문제나 질문 +2. 필요한 배경과 전제 +3. 시도한 방법 또는 핵심 설명 +4. 실패하거나 헷갈린 지점 +5. 판단이 달라진 이유 +6. 결과, 한계, 적용 범위 + +참고서나 사전형 문서라면 서사를 강요하지 않고 항목별 구조를 사용한다. + +### 문장 규칙 + +- 도입부에서 거대한 시대 변화나 기술의 중요성을 선언하지 않는다. +- 경험한 사실과 일반적인 기술 설명을 구분한다. +- 독자를 계속 `여러분`이라고 부르지 않는다. +- 예시는 설명 직후에 배치한다. +- 실패 원인과 해결 과정을 성공담으로 과장하지 않는다. +- 결론은 본문의 핵심 판단과 한계를 정리하되 문단별 내용을 다시 나열하지 않는다. +- 검색어를 문장에 반복해서 넣지 않는다. + +### 자연스러운 예시 + +부자연스러운 문장: + +> 오늘날 소프트웨어 개발 환경에서 관측 가능성은 그 어느 때보다 중요한 핵심 요소로 자리 잡고 있습니다. + +개선 방향: + +> 장애가 났을 때 로그만으로는 요청이 어느 서비스에서 느려졌는지 찾기 어려웠다. 이 문제를 확인하려고 트레이스와 메트릭을 함께 수집했다. + +## 4. 자기소개서·경력 기술서 + +### 목표 + +지원자의 경험과 판단을 사실에 근거해 보여 준다. 잘 보이기 위한 문장보다 검증 가능한 내용을 우선한다. + +### 기본 구조 + +- 어떤 상황과 문제가 있었는가 +- 본인이 맡은 범위는 어디까지였는가 +- 어떤 판단과 행동을 했는가 +- 결과가 무엇이었는가 +- 무엇을 배웠고 이후 행동이 어떻게 달라졌는가 + +모든 항목에 이 구조를 기계적으로 적용하지 않는다. 질문이 요구하는 부분만 쓴다. + +### 문장 규칙 + +- `저는 책임감이 강합니다`보다 책임감을 보여 주는 행동을 쓴다. +- 팀의 성과와 본인의 기여를 구분한다. +- 숫자는 사용자가 제공했거나 자료로 확인된 경우에만 쓴다. +- 기술 이름을 나열하지 말고 문제 해결에 어떤 역할을 했는지 쓴다. +- 실패를 미화하거나 약점인 척하는 장점을 만들지 않는다. +- 지원 기업을 근거 없이 찬양하지 않는다. +- 채용 공고의 표현을 그대로 복사해 자신의 경험인 것처럼 쓰지 않는다. + +### 금지되는 보완 + +- 존재하지 않는 프로젝트나 역할 추가 +- 대략적인 결과를 정확한 수치로 변환 +- 사용자가 말하지 않은 리더십, 갈등, 장애 경험 생성 +- 실제 동기와 다른 지원 동기 작성 +- 기술 숙련도를 근거 없이 상향 + +## 5. 업무 메일·메신저 + +### 목표 + +상대가 상황과 필요한 행동을 빠르게 이해하게 쓴다. + +### 정보 순서 + +1. 연락한 목적 +2. 필요한 배경 +3. 요청 사항 또는 결정 사항 +4. 기한과 다음 행동 + +짧은 메시지는 인사말보다 목적을 먼저 쓸 수 있다. 외부 고객이나 공식 요청에는 관계에 맞는 인사와 맺음말을 둔다. + +### 문장 규칙 + +- `확인 부탁드립니다`만 쓰지 말고 무엇을 언제까지 확인해야 하는지 쓴다. +- 책임 주체가 여러 명이면 담당자를 명시한다. +- 거절이나 이견은 모호하게 돌려 쓰지 말고 이유와 가능한 대안을 함께 쓴다. +- 사물에 높임 표현을 붙이지 않는다. +- 과도한 관공서 문구와 한자어를 줄인다. +- 메신저에서는 지나치게 완결된 보고서 문체를 강요하지 않는다. + +## 6. 안내문·사용자 문구 + +### 목표 + +사용자가 현재 상태, 원인, 가능한 행동을 즉시 이해하게 쓴다. + +### 문장 규칙 + +- 오류가 발생했다는 말만 하지 말고 사용자가 할 수 있는 행동을 제시한다. +- 내부 시스템 용어와 오류 코드를 그대로 노출하지 않는다. 문제 해결에 필요하면 별도 상세 정보로 둔다. +- 사용자 탓으로 들리는 표현을 피한다. +- 버튼 이름과 화면 용어를 실제 UI와 일치시킨다. +- 경고는 위험의 크기에 맞게 쓴다. 모든 상황을 `중요`, `필수`, `즉시`로 강조하지 않는다. + +## 장르가 섞인 경우 + +하나의 글에 장르가 섞이면 주된 사용 상황을 기준으로 정한다. + +- 발표 슬라이드의 발표자 노트: 발표 스크립트 우선 +- 기술 블로그의 명령어 설명: 블로그 흐름 + 기술 문서 정확성 +- 포트폴리오의 프로젝트 설명: 경력 문서 사실성 + 기술 문서 구체성 +- 장애 공지 메일: 업무 메일 구조 + 안내문 행동 지침 + +서로 충돌하면 사실성과 정확성, 실제 사용 가능성을 우선한다. diff --git a/.agents/skills/writing-natural-korean/references/normative-foundation.md b/.agents/skills/writing-natural-korean/references/normative-foundation.md new file mode 100644 index 0000000..0dd3733 --- /dev/null +++ b/.agents/skills/writing-natural-korean/references/normative-foundation.md @@ -0,0 +1,164 @@ +# 공식 기준과 조회 순서 + +조사 기준일: 2026-08-02 + +## 1. 적용 원칙 + +한국어 글쓰기 판단을 다음 두 층으로 나눈다. + +### 강제 규범 + +표기가 맞는지 틀리는지를 판단할 때 사용한다. + +- 한글 맞춤법 +- 표준어 규정 +- 외래어 표기법 +- 국어의 로마자 표기법 +- 한글 맞춤법 부록의 문장 부호 +- 표준국어대사전의 표제어, 품사, 뜻풀이, 활용 정보 + +### 표현 권고 + +문장이 독자에게 정확하고 쉽게 전달되는지, 한국어로 자연스러운지를 판단할 때 사용한다. + +- 국립국어원의 공공언어 자료 +- 국립국어원 발간 연구와 간행물의 문장·번역투 분석 +- 글의 독자, 매체, 목적에 따른 편집 판단 + +표현 권고를 강제 규범처럼 적용하지 않는다. 예를 들어 피동문, `가지다`, `~에 대한`, `~를 통해`는 문맥에 따라 자연스러울 수 있으므로 출현했다는 이유만으로 오류 처리하지 않는다. + +## 2. 공식 조회 우선순위 + +정확한 판단이 필요한 경우 다음 순서로 확인한다. + +1. 한국어 어문 규범 +2. 표준국어대사전 +3. 국립국어원 공공언어·국어생활 자료 +4. 국립국어원 온라인가나다의 개별 질의 답변 + +온라인가나다 답변은 특정 문맥에 대한 상담이므로 일반 규칙으로 확대하지 않는다. 규정과 사전이 개정될 수 있으므로 날짜가 중요한 판단은 최신 공식 페이지에서 다시 확인한다. + +## 3. 한국어 어문 규범에서 가져온 핵심 + +공식 누리집: https://www.korean.go.kr/kornorms/main/main.do + +국립국어원의 한국어 어문 규범 누리집은 한글 맞춤법, 표준어 규정, 외래어 표기법, 국어의 로마자 표기법을 제공한다. 한글 맞춤법에는 띄어쓰기와 문장 부호가 포함된다. + +### 띄어쓰기 + +다음은 스킬의 기본 검사 항목이다. + +- 제41항: 조사는 앞말에 붙여 쓴다. +- 제42항: 의존 명사는 띄어 쓴다. +- 제43항: 단위를 나타내는 명사는 띄어 쓰는 것이 원칙이다. 순서나 아라비아 숫자 뒤의 단위 등에는 붙여 쓰기가 허용되는 범위가 있다. +- 제47항: 보조 용언은 띄어 쓰는 것이 원칙이며, 규정에서 정한 경우 붙여 쓰기도 허용한다. +- 제48~50항: 고유명사와 전문용어는 단어별 띄어쓰기를 기본으로 하되, 의미 단위나 전문 분야의 관행을 고려한 허용 범위가 있다. + +따라서 `띄어 쓴 형태만 정답` 또는 `붙여 쓴 형태만 정답`이라고 일괄 판단하면 안 된다. 원칙과 허용을 구분하고 문서 안에서 통일한다. + +### 문장 부호 + +문장 부호는 문장의 구조를 드러내거나 글쓴이의 의도를 전달하기 위해 사용한다. 영어 문장의 쉼표, 줄표, 쌍점 배치를 모양만 보고 그대로 옮기지 않는다. 목록, 인용, 부제, 괄호, 쌍점 등은 한국어 문장 부호 규정과 매체 관행을 함께 확인한다. + +## 4. 표준국어대사전 + +공식 누리집: https://stdict.korean.go.kr/ + +다음 판단에 사용한다. + +- 표준어 여부와 복수 표준어 +- 단어의 품사와 뜻 +- 활용 형태 +- 한 단어인지 구인지에 관한 정보 +- 용례와 문법 정보 + +사전에 없다는 이유만으로 전문용어, 신조어, 제품명, 고유명사를 곧바로 틀렸다고 판단하지 않는다. 해당 분야의 공식 명칭과 문서 목적을 함께 본다. + +## 5. 공공언어 기준에서 가져온 원칙 + +### 공공언어 요건 정립 및 진단 기준 개발 연구 + +공식 소개: https://www.korean.go.kr/front/bookData/bookDataView.do?book_seq=86 + +국립국어원은 일반 국민을 대상으로 하는 공공언어에서 정확하고 쉬운 언어 사용을 강조하고, 객관적인 진단 기준과 평가 지표를 마련하기 위해 이 연구를 발간했다. 이 스킬은 그 취지를 일반 글쓰기에도 제한적으로 적용한다. + +적용 항목: + +- 내용이 사실과 논리에 맞는가 +- 문장 성분의 관계가 정확한가 +- 독자가 용어와 문장을 이해할 수 있는가 +- 불필요하게 어려운 한자어, 외래어, 전문용어를 쓰지 않았는가 +- 문서의 목적과 필요한 행동을 쉽게 찾을 수 있는가 + +단, 전문가 대상 기술 문서에서는 전문용어를 무조건 쉬운 말로 바꾸지 않는다. 정확성이 떨어지면 공식 용어를 유지하고 필요한 설명을 덧붙인다. + +### 쉬운 공문서 쓰기 길잡이 + +공식 소개: https://www.korean.go.kr/front/etcData/etcDataView.do?etc_seq=700 + +이 자료는 `공공언어의 요건 확인`, `단계별 문서 작성`, `유형별 실제 문서 쓰기`로 구성되어 있다. 스킬은 이를 다음 절차로 일반화한다. + +1. 독자와 목적을 확인한다. +2. 필요한 정보를 선별하고 구조를 잡는다. +3. 문장과 용어를 정확하고 쉽게 쓴다. +4. 문서 유형에 맞춰 형식을 조정한다. +5. 독자의 관점에서 다시 검토한다. + +### 한눈에 알아보는 공공언어 바로 쓰기 개정판 + +공식 소개: https://www.korean.go.kr/front/etcData/etcDataView.do?etc_seq=699 + +이 자료는 공공언어 원칙과 기안문, 보도 자료, 보고서, 안내문 작성 사례를 제공하며, 행정용어와 일본어 투 용어 개선 자료도 포함한다. 스킬은 이 자료에서 다음 방향을 취한다. + +- 문서 유형에 따라 표현 방식을 달리한다. +- 관행적 표현이라도 독자의 이해를 막으면 고친다. +- 용어 교체만 하지 않고 문장 전체의 의미와 구조를 함께 본다. + +## 6. 국어기본법과 쉬운 문장 + +국가법령정보센터: https://www.law.go.kr/법령/국어기본법 + +국어기본법은 어문 규범을 한글 맞춤법, 표준어 규정, 표준 발음법, 외래어 표기법, 국어의 로마자 표기법 등으로 정의한다. 공문서는 일반 국민이 알기 쉬운 용어와 문장으로 작성하고 어문 규범에 맞추어 한글로 작성하는 방향을 둔다. + +이 법의 직접 적용 대상이 아닌 글에서도 `정확성`과 `이해 가능성`은 유효한 편집 기준이지만, 모든 글을 공문서 문체로 바꾸지는 않는다. + +## 7. 번역투 연구에서 가져온 원칙 + +### 현대 국어 번역문의 실태 + +원문 PDF: https://www.korean.go.kr/nkview/nklife/2012_1/22_0104.pdf + +국립국어원 간행물 『새국어생활』의 이 글은 번역문 말뭉치에서 비번역문보다 자주 나타나는 경향을 제시한다. 주요 관찰에는 다음이 포함된다. + +- 2·3인칭 대명사와 지시 표현의 높은 빈도 +- `만들다`, `가지다`, `의하다`, `대하다`의 높은 빈도 +- `-아/어지다`와 `-에 의하여`를 사용한 피동 표현 +- `만약`, `왜냐하면`, `불구하고` 같은 명시적 연결 표현 +- 관형격 조사 `의`와 일부 전치사 대응 표현 +- 문맥 관계를 필요 이상으로 명시하는 접속어 + +이 결과는 말뭉치상의 경향이다. 특정 표현 하나가 등장했다고 문법 오류나 번역투로 확정하지 않는다. 반복, 문맥 부적합, 더 정확한 한국어 표현의 존재 여부를 함께 판단한다. + +### 영한 번역에 나타난 번역투 문장 + +원문 PDF: https://www.korean.go.kr/nkview/nklife/2012_1/22_0105.pdf + +이 글은 영어의 소유 구문, 수동태, 복수 표시, 전치사구가 한국어에 그대로 전이될 때 생기는 어색함을 사례로 설명한다. + +대표적인 편집 방향: + +- `책을 옆구리에 가지고 있다`처럼 동작을 소유로 표현하면 `책을 옆구리에 끼고 있다`처럼 실제 동작을 찾는다. +- `아름다운 목소리를 가지고 있다`처럼 속성을 소유로 표현하면 `목소리가 아름답다`처럼 상태를 직접 서술한다. +- 행위자가 분명한 `~에 의해 만들어진다`는 자연스러운 능동문이나 한국어 피동 표현으로 바꿀 수 있는지 검토한다. +- `~에서의`, `~로부터`, `~를 통해`, `~에 의해`는 문맥에 맞는 조사, 부사어, 동사로 풀어 쓸 수 있는지 본다. + +연구는 번역투가 의도적으로 필요한 경우와 무의식적인 직역을 구분해야 한다고 설명한다. 스킬도 같은 원칙을 따른다. + +## 8. 판단 시 주의 사항 + +- 공공언어 지침은 모든 장르의 문체를 하나로 만드는 표준이 아니다. +- 번역투 연구에서 빈도가 높다고 지적한 표현은 금칙어가 아니다. +- 허용 표기를 교정자의 취향으로 하나만 남기지 않는다. +- 방언, 캐릭터 대사, 구어체, 문학적 표현은 의도된 효과를 우선한다. +- 법률, 계약, 정책, 인용문은 표현을 자연스럽게 바꾸기 전에 의미와 법적 효과가 달라지는지 확인한다. +- 기술 분야에서는 쉬운 말보다 정확한 공식 명칭이 우선할 수 있다. diff --git a/.claude/skills/revising-korean-technical-prose b/.claude/skills/revising-korean-technical-prose new file mode 120000 index 0000000..3b6f3ad --- /dev/null +++ b/.claude/skills/revising-korean-technical-prose @@ -0,0 +1 @@ +../../.agents/skills/revising-korean-technical-prose \ No newline at end of file diff --git a/.claude/skills/technical-document-author b/.claude/skills/technical-document-author new file mode 120000 index 0000000..1d8467e --- /dev/null +++ b/.claude/skills/technical-document-author @@ -0,0 +1 @@ +../../.agents/skills/technical-document-author \ No newline at end of file diff --git a/.claude/skills/writing-natural-korean b/.claude/skills/writing-natural-korean new file mode 120000 index 0000000..e98eede --- /dev/null +++ b/.claude/skills/writing-natural-korean @@ -0,0 +1 @@ +../../.agents/skills/writing-natural-korean \ No newline at end of file diff --git a/.run/executable-clean-architecture/assets/runtime-call.svg b/.run/executable-clean-architecture/assets/runtime-call.svg new file mode 100644 index 0000000..1692996 --- /dev/null +++ b/.run/executable-clean-architecture/assets/runtime-call.svg @@ -0,0 +1,28 @@ + + +Runtime call +FeedController calls GetFeedUseCase, which dispatches to SpringTransactionPort at runtime. +{"source":"runtime-call-source-dependency.svg","panel":"upper","canvas_policy":"diagram-only"} + + + + + + +실행 시점 관계 · 실선 = 호출·디스패치 + +FeedController +driving adapter + +GetFeedUseCase +concrete service + +SpringTransactionPort +runtime implementation + + +Runtime call + + +Runtime dispatch + diff --git a/.run/executable-clean-architecture/assets/source-dependency.svg b/.run/executable-clean-architecture/assets/source-dependency.svg new file mode 100644 index 0000000..6492a42 --- /dev/null +++ b/.run/executable-clean-architecture/assets/source-dependency.svg @@ -0,0 +1,36 @@ + + +Source dependency and contract ownership +GetFeedUseCase depends on application-core contracts, while SpringTransactionPort implements TransactionPort. +{"source":"runtime-call-source-dependency.svg","panel":"lower","canvas_policy":"diagram-only"} + + + + + + + +계약 소유·소스 의존 · 점선 = 타입·계약을 향함 + +<<interface>> +QueryUseCase<Q,R> +application-core contract + +GetFeedUseCase +implements · calls + +<<interface>> +TransactionPort +application-core contract + + +Implements + + +Runtime call + +SpringTransactionPort + + +Implements + diff --git a/.run/executable-clean-architecture/final/document.md b/.run/executable-clean-architecture/final/document.md index 182520d..701a750 100755 --- a/.run/executable-clean-architecture/final/document.md +++ b/.run/executable-clean-architecture/final/document.md @@ -236,12 +236,12 @@ Port는 아웃바운드 어댑터가 코어에 제공해야 할 기능을 정합 코드가 바뀌는 이유를 기준으로 `ca-tmpl`의 모듈에 대응시켰습니다. 같은 이유로 바뀌는 코드는 한 경계에 두고 다른 이유로 바뀌는 코드는 의존 방향을 나눴습니다. -| 링 | `ca-tmpl` 모듈 | 대표 책임 | 이곳에 두는 이유 | -| -------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| 링 | `ca-tmpl` 모듈 | 대표 책임 | 이곳에 두는 이유 | +| -------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | | 엔터프라이즈 업무 규칙(Enterprise Business Rules) | `domain-core` | Aggregate·Value Object — 예:`FeedItem` | 핵심 불변식은 HTTP·DB·Spring 교체와 무관하게 유지돼야 합니다. 그래서 JPA와 Spring 타입을 클래스패스에서 제외합니다. | -| 애플리케이션 업무 규칙(Application Business Rules) | `application-core` | Use Case·Input/Output Port — 예:`GetFeedUseCase`, `FeedQueryPort` | 유스케이스는 업무 흐름과 필요한 외부 능력을 정의하되, 그 능력을 어떤 기술로 구현하는지는 몰라야 합니다. | -| 인터페이스 어댑터(Interface Adapters) | `adapter:inbound:*`, `adapter:outbound:*` | Controller·영속성 어댑터 — 예:`FeedController`, `FeedQueryAdapter` | HTTP·JPA 같은 외부 모델을 코어 계약으로 변환하는 책임을 모아 기술 변경의 직접 파급을 경계 밖에 가둡니다. | -| 프레임워크와 드라이버(Frameworks & Drivers) | 어댑터의 구체 기술 의존,`app-bootstrap` | Spring MVC·Spring Data JPA·PostgreSQL·Boot/Flyway·관측·보안 배선 | 구체 프레임워크 선택과 실행 시점 조립은 배포 환경에 따라 바뀌므로 가장 바깥에서 결정합니다. | +| 애플리케이션 업무 규칙(Application Business Rules) | `application-core` | Use Case·Input/Output Port — 예:`GetFeedUseCase`, `FeedQueryPort` | 유스케이스는 업무 흐름과 필요한 외부 능력을 정의하되, 그 능력을 어떤 기술로 구현하는지는 몰라야 합니다. | +| 인터페이스 어댑터(Interface Adapters) | `adapter:inbound:*`, `adapter:outbound:*` | Controller·영속성 어댑터 — 예:`FeedController`, `FeedQueryAdapter` | HTTP·JPA 같은 외부 모델을 코어 계약으로 변환하는 책임을 모아 기술 변경의 직접 파급을 경계 밖에 가둡니다. | +| 프레임워크와 드라이버(Frameworks & Drivers) | 어댑터의 구체 기술 의존,`app-bootstrap` | Spring MVC·Spring Data JPA·PostgreSQL·Boot/Flyway·관측·보안 배선 | 구체 프레임워크 선택과 실행 시점 조립은 배포 환경에 따라 바뀌므로 가장 바깥에서 결정합니다. | `domain-core`를 별도 모듈로 둔 이유는 도메인 규칙을 프레임워크 변경에서 보호하기 위해서입니다. 이 모듈에는 Spring Web, JPA, Spring TX가 없으므로 도메인 코드가 해당 타입을 참조하면 컴파일 단계에서 실패합니다. @@ -391,7 +391,9 @@ Inbound와 outbound는 테스트 전략도 다릅니다. - Outbound adapter : 데이터 매핑, 외부 시스템 연동, timeout-retry, 기술 예외 변환 + + ![네 inbound adapter의 HTTP DTO, protobuf message, GraphQL request, WebSocket message가 Command 또는 Query로 수렴해 application use case를 호출하는 흐름도.](../assets/inbound-transport-boundary/inbound-transport-boundary.svg) 왼쪽에서 오른쪽으로 읽습니다. web은 HTTP DTO, grpc는 protobuf message, graphql은 GraphQL request, websocket은 WebSocket message를 각 어댑터 경계에서 처리합니다. 네 어댑터는 전송 기술 타입을 application-core로 넘기지 않고 Command 또는 Query로 변환합니다. 변환된 입력만 Application use case를 호출합니다. @@ -401,7 +403,9 @@ app-bootstrap은 프로젝트가 실제 사용할 inbound와 outbound adapter를 이 프로젝트의 모듈 간 의존은 inbound와 outbound 모두 바깥에서 안쪽으로 향합니다. verifyCleanArchitectureDependencies는 모듈 간 프로젝트의 의존성을 검사하고, ArchUnit의 DOMAIN_IS_TRUE는 모듈 내부 코드가 금지된 프레임워크 타입을 참조하는지 검사합니다. + + ![가운데 application-core와 양쪽 port·adapter, 아래 app-bootstrap, Gradle 모듈 의존 게이트와 ArchUnit 내부 순수성 게이트의 연결을 함께 보여 주는 ports-and-adapters 구조도.](../assets/bootstrap-dependency-guards/bootstrap-dependency-guards.svg) 가운데 Application Core를 기준으로 왼쪽에는 Inbound adapters와 Input port, 오른쪽에는 Output port와 Outbound adapters가 있습니다. 어댑터의 모듈 의존은 포트와 코어 쪽을 향합니다. 아래의 app-bootstrap은 실제 사용할 양쪽 어댑터를 선택하고 application port에 연결합니다. 별도의 두 검증 게이트 중 verifyCleanArchitectureDependencies는 모듈 간 프로젝트 의존을 검사하고 ArchUnit 규칙은 모듈 내부 코드의 금지된 프레임워크 타입 참조를 검사합니다. @@ -433,15 +437,16 @@ app-bootstrap은 프로젝트가 실제 사용할 inbound와 outbound adapter를 `Command` 마커는 `sample-portfolio`의 `CreateWorkLogCommand`에 실제로 적용돼 있습니다. 재매핑은 두 번 일어납니다. + 1. persistence에서 application으로 넘어갈 때입니다. -`FeedQueryAdapter.loadFeed()`는 JPA 조회 결과를 `FeedSummary`로 직접 조립합니다. -`FeedItemJpaEntity → FeedItem → FeedSummary`처럼 애그리게이트를 재구성하지 않고 조회 결과에서 곧장 애플리케이션 프로젝션으로 건너갑니다. + `FeedQueryAdapter.loadFeed()`는 JPA 조회 결과를 `FeedSummary`로 직접 조립합니다. + `FeedItemJpaEntity → FeedItem → FeedSummary`처럼 애그리게이트를 재구성하지 않고 조회 결과에서 곧장 애플리케이션 프로젝션으로 건너갑니다. 같은 DB 안에서 읽기 경로만 논리적으로 나누는 이 우회가 뒤에서 다룰 CQRS-lite 결정의 구체적인 모습입니다. CQRS는 명령(Command)과 조회(Query)의 코드·모델을 나누는 패턴이고 lite는 저장소 분리 없이 코드 경로와 모델만 나눈 수준을 뜻합니다. 2. application에서 web으로 나갈 때입니다. -`FeedWebMapper.toResponse()`가 `FeedSummary`를 `FeedResponse`로 다시 조립합니다. + `FeedWebMapper.toResponse()`가 `FeedSummary`를 `FeedResponse`로 다시 조립합니다. 두 매핑을 모두 어댑터가 소유하므로 `GetFeedUseCase`와 `FeedQueryPort`는 웹 응답이나 JPA 엔티티의 모양을 모릅니다. `GetFeedUseCase`가 `FeedResponse`를 직접 만들었다면 HTTP 응답 변경이 코어 변경으로 번졌을 것입니다. 반대로 도메인 재구성이 필요한 경로에서는 어댑터의 `FeedItemPersistenceMapper`가 코어 모델 변경을 따라 바뀌는 것이 의도한 결합입니다. @@ -474,11 +479,11 @@ arawn은 외형 복제보다 높은 응집과 느슨한 결합을 강조했습 `shared-contract`를 알고 SLF4J도 사용합니다. “세 모듈이 모두 완전히 순수하다”고 약속하는 대신, 각 모듈이 알아도 되는 타입을 클래스패스와 ArchUnit 규칙으로 제한했습니다. -| 모듈 | 맡은 결정 | 허용한 지식 | 대표 실행 경로 | 경계를 고정하는 규칙 | -| -------------------- | ----------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------- | -| `domain-core` | 애그리게이트·값·이벤트·식별자와 불변식 | main compile은 JDK와 자체 타입뿐 | `FeedItem`이 자체 스테레오타입·JDK 타입만 사용 | `DOMAIN_IS_PURE`, `DOMAIN_HAS_NO_LOGGER` | -| `application-core` | 유스케이스 순서와 바깥 능력의 포트 | domain-core, shared-contract | `GetFeedUseCase`가 `tx.inRead(() -> feedQuery.loadFeed(...))` 호출 | `APPLICATION_DOES_NOT_DEPEND_ON_ADAPTERS_OR_TRANSPORT` 외 | -| `shared-contract` | 응답·오류·추적·메트릭 같은 운영 계약 | main 외부 의존 0, 허용한 운영 패키지 prefix | `Envelope(success, data, error, meta)`와 `ApiErrorCode` | `SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES` | +| 모듈 | 맡은 결정 | 허용한 지식 | 대표 실행 경로 | 경계를 고정하는 규칙 | +| -------------------- | ----------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------- | +| `domain-core` | 애그리게이트·값·이벤트·식별자와 불변식 | main compile은 JDK와 자체 타입뿐 | `FeedItem`이 자체 스테레오타입·JDK 타입만 사용 | `DOMAIN_IS_PURE`, `DOMAIN_HAS_NO_LOGGER` | +| `application-core` | 유스케이스 순서와 바깥 능력의 포트 | domain-core, shared-contract | `GetFeedUseCase`가 `tx.inRead(() -> feedQuery.loadFeed(...))` 호출 | `APPLICATION_DOES_NOT_DEPEND_ON_ADAPTERS_OR_TRANSPORT` 외 | +| `shared-contract` | 응답·오류·추적·메트릭 같은 운영 계약 | main 외부 의존 0, 허용한 운영 패키지 prefix | `Envelope(success, data, error, meta)`와 `ApiErrorCode` | `SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES` | `domain-core`의 빈 의존 블록은 Spring·JPA·Servlet·Hibernate 같은 외부 프레임워크 타입이 들어올 직접 의존 통로를 없앱니다. JDK 자체의 파일·네트워크·SQL API까지 자동으로 금지한다는 뜻은 아닙니다. `DOMAIN_IS_PURE`는 금지 패키지 의존을 막고 `DOMAIN_HAS_NO_LOGGER`는 로깅 프레임워크까지 차단합니다. @@ -501,8 +506,8 @@ REST·gRPC·GraphQL·WebSocket 네 인바운드 모듈은 같은 깊이로 구 두었습니다. 구현 범위는 달라도 전송 기술을 코어 밖에 두고 아웃바운드 구현을 직접 고르지 않는 규칙은 같게 적용했습니다. -| 모듈 | 현재 제공하는 기능 | 결정적인 차이 | -| ----------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------- | +| 모듈 | 현재 제공하는 기능 | 결정적인 차이 | +| ----------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- | | `adapter:inbound:web` | `/feed` REST·health·공유 웹 인프라 | `FeedController`가 `GetFeedUseCase`를 호출하고 `FeedWebMapper`로 응답 DTO를 만듭니다 | | `adapter:inbound:grpc` | health·reflection | 피처 proto 없이 Netty 서버를 직접 수명주기 관리합니다 | | `adapter:inbound:graphql` | 최소 헬스 스키마 | 피처 스키마·리졸버 추가는 소비 프로젝트의 확장 작업입니다 | @@ -525,12 +530,12 @@ REST·gRPC·GraphQL·WebSocket 네 인바운드 모듈은 같은 깊이로 구 달라질 수 있어 “바로 실행할 기준 구현”과 “소비 프로젝트가 채울 확장점”을 구분했습니다. 구현 깊이는 달라도 인바운드나 다른 아웃바운드 구현을 직접 선택하지 않는 규칙은 같게 적용했습니다. -| 묶음 | 모듈 | 구현된 능력 | 도입 시 확인할 예외 | -| --------------- | ------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| 영속성 | `persistence-jpa`, `persistence-mongo` | RDBMS 구현과 opt-in NoSQL 배선 | 멱등성·outbox·분산 락은 JPA에 구현돼 있습니다. Mongo는 드라이버·리포지토리 스캔 배선만 있고 `document`와 `repository`는 템플릿을 가져다 쓰는 프로젝트에서 추가합니다. | -| SDK 없는 확장점 | `cache-redis`, `messaging`, `notification` | 실제 Redis·Kafka·Slack 클라이언트를 연결할 인터페이스 | 모듈이 존재해도 벤더 연결이 완성된 것은 아닙니다 | -| 외부 시스템 | `objectstorage`, `httpclient`, `fileserver` | AWS SDK S3/MinIO, 회복성 HTTP, 순수 JDK 파일 출력 | 클라이언트 유무와 빈 활성화 조건이 서로 다릅니다 | -| 기반 구현 | `identifier`, `support` | UUIDv7 식별자와 공통 상관관계 로깅 | `support`만 형제들이 공유할 수 있습니다 | +| 묶음 | 모듈 | 구현된 능력 | 도입 시 확인할 예외 | +| --------------- | ------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 영속성 | `persistence-jpa`, `persistence-mongo` | RDBMS 구현과 opt-in NoSQL 배선 | 멱등성·outbox·분산 락은 JPA에 구현돼 있습니다. Mongo는 드라이버·리포지토리 스캔 배선만 있고`document`와 `repository`는 템플릿을 가져다 쓰는 프로젝트에서 추가합니다. | +| SDK 없는 확장점 | `cache-redis`, `messaging`, `notification` | 실제 Redis·Kafka·Slack 클라이언트를 연결할 인터페이스 | 모듈이 존재해도 벤더 연결이 완성된 것은 아닙니다 | +| 외부 시스템 | `objectstorage`, `httpclient`, `fileserver` | AWS SDK S3/MinIO, 회복성 HTTP, 순수 JDK 파일 출력 | 클라이언트 유무와 빈 활성화 조건이 서로 다릅니다 | +| 기반 구현 | `identifier`, `support` | UUIDv7 식별자와 공통 상관관계 로깅 | `support`만 형제들이 공유할 수 있습니다 | 영속성의 기준 구현은 `persistence-jpa`입니다. Feed 엔티티·리포지토리·매퍼와, 나중에 확인할 트랜잭션·락·멱등성·outbox 구현이 이 모듈에 놓입니다. PostgreSQL 드라이버는 `runtimeOnly`라 @@ -581,8 +586,11 @@ Mongo 자동설정 세 개를 꺼 클래스패스를 무력화하고, 모듈이 아래 그림은 `app-bootstrap`의 main 프로젝트 의존에 포함된 어댑터 열한 개와 현재 main 의존 목록에 없는 참조 어댑터 세 개를 비교합니다. 실행 시 활성 빈 전체를 측정한 그림은 아닙니다. + + + ![왼쪽의 app-bootstrap main 의존 포함 어댑터 11개와 오른쪽의 main 의존 목록 밖 grpc·graphql·websocket 세 개를 비교한 그림. 클래스패스 구성 비교이며 활성 빈 수를 뜻하지 않는다.](../assets/production-vs-optin.svg)
@@ -593,6 +601,7 @@ Mongo 자동설정 세 개를 꺼 클래스패스를 무력화하고, 모듈이
[Editable source](../assets/production-vs-optin.drawio) · [Grounded VizSpec](.techviz/production-vs-optin/spec.json) + ```java @@ -606,6 +615,7 @@ public CacheBackend redisCacheBackend(RedisClient redisClient) { return new RedisCacheStore(redisClient); } ``` + `@ConditionalOnProperty`가 외부 백엔드 빈 활성화를 한 번 더 결정합니다. `app-bootstrap`이 의존하는 모듈도 모두 실행되지는 않습니다. Redis나 Kafka 같은 외부 백엔드는 런타임 @@ -861,12 +871,12 @@ Bean Validation도 범위 거부도 없습니다. 두 파라미터는 계층 규율을 고정하는 규칙과 대표 테스트는 다음과 같습니다. -| 검사 | 고정하는 경계 | -| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY` | 도메인·애플리케이션 패키지의`jakarta.validation..` 의존 거부 | +| 검사 | 고정하는 경계 | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY` | 도메인·애플리케이션 패키지의`jakarta.validation..` 의존 거부 | | `VALID_CASCADE_DEPTH_AT_MOST_THREE` | `@Valid` 캐스케이드의 직접 raw 필드 사슬을 3단계로 제한하는 근사 가드 — 컨테이너 제네릭 원소와 수렴 그래프의 최장 경로는 정확히 추적하지 못할 수 있습니다 | -| `PosterControllerWireTest` | 빈 제목은 유스케이스 전에 400`VALIDATION_FAILED`, 이미지 없는 발행은 400 `POSTER_IMAGE_REQUIRED` | -| `PosterTest` | null/blank 제목이 NPE가 아닌`PosterInvariantException(TITLE_BLANK)`로 실패 | +| `PosterControllerWireTest` | 빈 제목은 유스케이스 전에 400`VALIDATION_FAILED`, 이미지 없는 발행은 400 `POSTER_IMAGE_REQUIRED` | +| `PosterTest` | null/blank 제목이 NPE가 아닌`PosterInvariantException(TITLE_BLANK)`로 실패 | ### 예외·오류 응답 — 두 단계 처리 사슬, 하나의 Envelope @@ -1543,8 +1553,8 @@ Tudum 사례는 CQRS를 버린 사례가 아닙니다. Kafka에서 Raw Hollow로 템플릿에서는 경계를 반복 검사할 수 있지만, 1회성 서비스에서는 같은 장치가 유지비만 늘릴 수 있었습니다. 그래서 아래 표에는 선택의 장점만 적지 않고 반대편이 더 나은 조건도 함께 남겼습니다. -| 결정 | 선택 | 선택으로 얻는 것 | 반대편이 나은 조건 | -| ---------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| 결정 | 선택 | 선택으로 얻는 것 | 반대편이 나은 조건 | +| ---------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | ① 패키지 배치 | 계층 소유 코어·어댑터와 수직 샘플을 함께 쓰는 hybrid | 프로덕션 경계는 계층별로 통제하고, 샘플은 한 기능의 종단 구성을 보여 줍니다. | 한 가지 축만으로 충분한 작은 서비스 | | ② 트랜잭션 경계 | `@Transactional` 대신 코어 소유 포트 | Spring TX를 애플리케이션 클래스패스에서 빼고 트랜잭션 의도를 테스트 가능한 계약으로 만듭니다. | 단일 DB를 쓰며 간접 호출 비용이 더 큰 작은 팀 | | ③ 모듈화 | 멀티모듈 + ArchUnit | 금지된 타입은 컴파일에서, 허용 범위 안의 패키지 위반은 테스트에서 잡습니다. | 수명이 짧아 모듈·정책 유지비를 회수하기 어려운 서비스 | diff --git a/.run/keycloak-four-patterns/final/document.md b/.run/keycloak-four-patterns/final/document.md index 6f3f147..a20d205 100644 --- a/.run/keycloak-four-patterns/final/document.md +++ b/.run/keycloak-four-patterns/final/document.md @@ -42,7 +42,9 @@ 2. **애플리케이션 요청 구간:** 브라우저 입력, 중간 계층의 credential 변환, 보호 자원의 검증, 최종 응답 + + ![로그인 구간과 애플리케이션 요청 구간을 나누어 AP2 mediator·브라우저, AP3 BFF, AP4 oauth2-proxy·Nginx의 책임 배치를 비교한 다이어그램.](assets/login-api-phase-split/login-api-phase-split.svg)
@@ -53,23 +55,24 @@
[Editable source](assets/login-api-phase-split/login-api-phase-split.drawio) · [Grounded VizSpec](.techviz/login-api-phase-split/spec.json) + ### 같은 사용자를 나타내도 데이터의 의미는 다르다 네 예제의 응답에는 모두 `regular-user`가 있었습니다. 처음에는 같은 사용자 이름이니 같은 인증 정보라고 묶어도 될 것처럼 보였습니다. 그런데 값이 들어오는 곳을 확인해 보니 어떤 때는 JWT 안의 claim이었고 어떤 때는 Nginx가 만든 header였습니다. Claim은 token 안에 들어 있는 사용자 정보 항목입니다. 둘을 모두 인증 정보라고 쓰면 JWT를 검증한 것인지, Nginx가 만든 header를 확인한 것인지 구분할 수 없었습니다. -| 데이터 | 만든 주체 | 주된 소비자 | 의미 | -|---|---|---|---| -| authorization code | Keycloak | OAuth client | 짧게 사용되는 code 교환 입력 | -| PKCE verifier | AP1 SPA, AP3 BFF, AP4 oauth2-proxy | Keycloak token endpoint | authorization request를 시작한 client와 code 교환 주체를 연결 | -| access token | Keycloak | Resource Server | API 요청을 인증·인가하는 Bearer credential | -| refresh token | Keycloak | AP1 SPA, AP2 mediator, AP3 BFF 등 해당 소유자 | 새 access token을 얻는 장기 credential | -| server session 식별 cookie | mediator 또는 BFF | 같은 server-side login state의 소유자 | 브라우저 요청을 HttpSession 인증 상태에 연결 | -| proxy session cookie | oauth2-proxy | oauth2-proxy의 auth endpoint | AP4의 minimal client-side session 상태를 다음 auth subrequest에 제시 | -| CSRF token | AP3 BFF | AP3 BFF | cookie가 자동 첨부되는 상태 변경 요청의 의도 확인 | -| identity header | oauth2-proxy 결과를 받은 Nginx | AP4 upstream | edge가 확인한 사용자 identity의 투영 | -| internal auth token | AP4 배포 설정 | AP4 upstream | 허용된 edge를 거쳤다는 추가 신뢰 신호 | +| 데이터 | 만든 주체 | 주된 소비자 | 의미 | +| -------------------------- | ---------------------------------- | --------------------------------------------- | -------------------------------------------------------------------- | +| authorization code | Keycloak | OAuth client | 짧게 사용되는 code 교환 입력 | +| PKCE verifier | AP1 SPA, AP3 BFF, AP4 oauth2-proxy | Keycloak token endpoint | authorization request를 시작한 client와 code 교환 주체를 연결 | +| access token | Keycloak | Resource Server | API 요청을 인증·인가하는 Bearer credential | +| refresh token | Keycloak | AP1 SPA, AP2 mediator, AP3 BFF 등 해당 소유자 | 새 access token을 얻는 장기 credential | +| server session 식별 cookie | mediator 또는 BFF | 같은 server-side login state의 소유자 | 브라우저 요청을 HttpSession 인증 상태에 연결 | +| proxy session cookie | oauth2-proxy | oauth2-proxy의 auth endpoint | AP4의 minimal client-side session 상태를 다음 auth subrequest에 제시 | +| CSRF token | AP3 BFF | AP3 BFF | cookie가 자동 첨부되는 상태 변경 요청의 의도 확인 | +| identity header | oauth2-proxy 결과를 받은 Nginx | AP4 upstream | edge가 확인한 사용자 identity의 투영 | +| internal auth token | AP4 배포 설정 | AP4 upstream | 허용된 edge를 거쳤다는 추가 신뢰 신호 | 예를 들어 access token의 `preferred_username`과 AP4의 `X-Auth-Request-User`에는 모두 `regular-user`가 들어갈 수 있었습니다. 화면에서 보이는 이름은 같지만 실제 검증 방식은 다릅니다. Resource Server는 JWT의 서명과 issuer, audience를 확인했습니다. AP4 upstream은 요청이 신뢰할 수 있는 edge를 거쳤는지, 내부 인증값도 맞는지 확인했습니다. @@ -80,7 +83,9 @@ AP3와 AP4를 처음 보았을 때는 JavaScript가 OAuth token을 받지 않으 그래서 이 글에서 “브라우저에 없다”는 표현은 애플리케이션이 사용하는 OAuth token에만 쓰기로 했습니다. IdP의 SSO 상태까지 없다는 뜻은 아닙니다. 반대로 AP1이 Web Storage에 token을 쓰지 않는다고 JavaScript에서 token이 사라지는 것도 아니었습니다. Access·refresh·ID token은 실행 중 memory에 있었습니다. 악성 script는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있었습니다. Memory-only로 줄어드는 것은 새로고침 뒤에도 남는 복사본이지 실행 중 XSS의 권한은 아니었습니다. + + ![AP1부터 AP4까지 OAuth credential 소유자, 브라우저 credential, 보관 모델과 현재 입증된 운영 범위를 같은 네 축으로 정렬한 비교 다이어그램.](assets/credential-custody-map/credential-custody-map.svg)
@@ -91,6 +96,7 @@ AP3와 AP4를 처음 보았을 때는 JavaScript가 OAuth token을 받지 않으
[Editable source](assets/credential-custody-map/credential-custody-map.drawio) · [Grounded VizSpec](.techviz/credential-custody-map/spec.json) + ### 현재 구현은 운영 참조 아키텍처가 아니라 관찰 가능한 학습 환경이다 @@ -115,32 +121,34 @@ AP3와 AP4를 처음 보았을 때는 JavaScript가 OAuth token을 받지 않으 처음에는 네 패턴을 설명하는 용어부터 비교했습니다. 그런데 용어만 나란히 놓으니 실제로 누가 code를 바꾸고 API를 부르는지 잘 보이지 않았습니다. 그래서 로그인과 API 요청을 맡는 구성요소를 같은 표에 놓았습니다. -| 비교 축 | AP1 · SPA direct | AP2 · token mediator | AP3 · BFF | AP4 · edge forward-auth | -|---|---|---|---|---| -| OAuth client | 브라우저의 public SPA | Spring mediator | Spring BFF | oauth2-proxy | -| client 종류 | public | confidential | confidential | confidential | -| code 교환 주체 | 브라우저 | mediator | BFF | oauth2-proxy | -| PKCE | S256 | 현재 client 등록·흐름에서 명시적 AP1/AP3/AP4 가드레일과 동일하게 주장하지 않음 | S256 | S256 | -| refresh token 소유자 | 브라우저 JavaScript memory | mediator의 authorized client | BFF의 authorized client | 지속 보관 근거 없음: oauth2-proxy가 code/token 교환은 하지만 minimal cookie에는 access·refresh·ID token을 저장하지 않고 refresh lifecycle도 검증되지 않음 | -| access token이 JavaScript 응답에 포함되는가 | 포함 | 포함 | 미포함 | 미포함 | -| API를 호출하는 주체 | 브라우저 | 브라우저 | BFF | Nginx가 upstream 요청을 연결 | -| 보호 자원이 받는 credential | Bearer JWT | Bearer JWT | BFF가 붙인 Bearer JWT | user·email header + internal token | -| 애플리케이션 측 로그인 상태 | server session 없음 | `AP2_SESSION` + authorized client | `AP3_SESSION` + authorized client | minimal client-side `AP4_SESSION`을 사용하는 proxy 경계 | -| 새로 필요한 핵심 방어 | browser token 수명주기·XSS 피해 축소 | access 응답 제한·CORS·server state 운영 | CSRF·session scale-out·token-at-rest | network isolation·header overwrite·service identity | +| 비교 축 | AP1 · SPA direct | AP2 · token mediator | AP3 · BFF | AP4 · edge forward-auth | +| ------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OAuth client | 브라우저의 public SPA | Spring mediator | Spring BFF | oauth2-proxy | +| client 종류 | public | confidential | confidential | confidential | +| code 교환 주체 | 브라우저 | mediator | BFF | oauth2-proxy | +| PKCE | S256 | 현재 client 등록·흐름에서 명시적 AP1/AP3/AP4 가드레일과 동일하게 주장하지 않음 | S256 | S256 | +| refresh token 소유자 | 브라우저 JavaScript memory | mediator의 authorized client | BFF의 authorized client | 지속 보관 근거 없음: oauth2-proxy가 code/token 교환은 하지만 minimal cookie에는 access·refresh·ID token을 저장하지 않고 refresh lifecycle도 검증되지 않음 | +| access token이 JavaScript 응답에 포함되는가 | 포함 | 포함 | 미포함 | 미포함 | +| API를 호출하는 주체 | 브라우저 | 브라우저 | BFF | Nginx가 upstream 요청을 연결 | +| 보호 자원이 받는 credential | Bearer JWT | Bearer JWT | BFF가 붙인 Bearer JWT | user·email header + internal token | +| 애플리케이션 측 로그인 상태 | server session 없음 | `AP2_SESSION` + authorized client | `AP3_SESSION` + authorized client | minimal client-side`AP4_SESSION`을 사용하는 proxy 경계 | +| 새로 필요한 핵심 방어 | browser token 수명주기·XSS 피해 축소 | access 응답 제한·CORS·server state 운영 | CSRF·session scale-out·token-at-rest | network isolation·header overwrite·service identity | AP2의 PKCE 칸은 다른 패턴과 똑같이 채우지 않았습니다. “Authorization Code를 쓴다”와 “현재 구현이 PKCE S256까지 같은 방식으로 고정했다”는 서로 다른 주장이었기 때문입니다. 저는 코드와 설정에서 확인한 범위보다 넓혀 네 패턴을 억지로 대칭적으로 만들지 않았습니다. 그다음에는 로그인 뒤 요청 한 번에서 실제로 움직이는 데이터를 적었습니다. -| 패턴 | 브라우저가 보내는 입력 | 중간 계층이 조회·생성하는 데이터 | 보호 자원의 실제 입력 | 브라우저가 받는 출력 | -|---|---|---|---|---| -| AP1 | `Authorization: Bearer ` | 없음 | 동일 Bearer JWT | `/api/me` JSON | -| AP2 | 먼저 `AP2_SESSION`, 다음에 Bearer access token | mediator가 authorized client에서 access token을 읽어 JSON으로 반환 | 브라우저가 다시 만든 Bearer JWT | token JSON, 이어서 `/api/me` JSON | -| AP3 | `AP3_SESSION`; POST에는 `X-XSRF-TOKEN` 추가 | BFF가 authorized client에서 access token을 읽고 downstream Bearer header 생성 | BFF가 보낸 Bearer JWT | BFF가 중계한 JSON | -| AP4 | `AP4_SESSION` | Nginx auth subrequest, oauth2-proxy의 user·email 결과, 배포 secret | 정제된 identity header + internal token | `/edge/me`가 만든 identity JSON | +| 패턴 | 브라우저가 보내는 입력 | 중간 계층이 조회·생성하는 데이터 | 보호 자원의 실제 입력 | 브라우저가 받는 출력 | +| ---- | ----------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------- | ---------------------------------- | +| AP1 | `Authorization: Bearer ` | 없음 | 동일 Bearer JWT | `/api/me` JSON | +| AP2 | 먼저`AP2_SESSION`, 다음에 Bearer access token | mediator가 authorized client에서 access token을 읽어 JSON으로 반환 | 브라우저가 다시 만든 Bearer JWT | token JSON, 이어서`/api/me` JSON | +| AP3 | `AP3_SESSION`; POST에는 `X-XSRF-TOKEN` 추가 | BFF가 authorized client에서 access token을 읽고 downstream Bearer header 생성 | BFF가 보낸 Bearer JWT | BFF가 중계한 JSON | +| AP4 | `AP4_SESSION` | Nginx auth subrequest, oauth2-proxy의 user·email 결과, 배포 secret | 정제된 identity header + internal token | `/edge/me`가 만든 identity JSON | + + ![AP1, AP2, AP3, AP4의 브라우저 입력, 중간 변환, 보호 자원 credential과 브라우저 출력을 같은 네 축으로 비교한 다이어그램.](assets/four-pattern-request-boundaries/four-pattern-request-boundaries.svg)
@@ -151,6 +159,7 @@ AP2의 PKCE 칸은 다른 패턴과 똑같이 채우지 않았습니다. “Auth
[Editable source](assets/four-pattern-request-boundaries/four-pattern-request-boundaries.drawio) · [Grounded VizSpec](.techviz/four-pattern-request-boundaries/spec.json) + ### AP1에서 막히는 지점: protocol 투명성과 browser credential @@ -194,7 +203,9 @@ Refresh token만 mediator로 옮기는 AP2나 모든 token을 BFF에 맡기는 A 대신 token을 Local Storage나 Session Storage에 복사하지 않았습니다. Access token은 300초만 유효하게 두고 refresh token rotation과 reuse 0을 사용했습니다. Resource Server는 issuer나 audience가 다르면 401을 반환하게 했습니다. 그래도 실행 중인 XSS는 같은 origin의 사용자 권한을 쓸 수 있었고, 이미 발급된 access JWT는 만료될 때까지 유효했습니다. + + ![SPA, Keycloak, 브라우저 JavaScript memory, Resource Server가 왼쪽에서 오른쪽으로 연결된 AP1 직접 인증 아키텍처.](assets/ap1-direct-architecture/ap1-direct-architecture.svg)
@@ -205,6 +216,7 @@ Refresh token만 mediator로 옮기는 AP2나 모든 token을 BFF에 맡기는 A
[Editable source](assets/ap1-direct-architecture/ap1-direct-architecture.drawio) · [Grounded VizSpec](.techviz/ap1-direct-architecture/spec.json) + ### AP2: refresh credential은 서버에, 직접 API 호출은 브라우저에 둔다 @@ -218,7 +230,9 @@ Mediator는 Spring `oauth2Login`으로 code를 교환한 뒤 access·refresh tok AP2에서는 노출 범위를 줄이려고 CORS origin과 method를 좁히고 refresh token을 응답에서 뺐습니다. Session cookie에는 HttpOnly와 SameSite를 설정했고 Resource Server는 audience를 검증하게 했습니다. 다만 access token 전달 횟수 제한, durable store, logout, 만료 뒤 실제 refresh 동작은 아직 검증하지 못했습니다. + + ![브라우저가 Spring mediator에서 access token만 받아 Resource Server를 직접 호출하고 refresh token은 authorized-client store에 남기는 AP2 split-custody 아키텍처.](assets/ap2-mediator-architecture/ap2-mediator-architecture.svg)
@@ -229,6 +243,7 @@ AP2에서는 노출 범위를 줄이려고 CORS origin과 method를 좁히고 re
[Editable source](assets/ap2-mediator-architecture/ap2-mediator-architecture.drawio) · [Grounded VizSpec](.techviz/ap2-mediator-architecture/spec.json) + ### AP3: browser token 비노출과 application-owned session을 맞바꾼다 @@ -242,7 +257,9 @@ AP2에서는 refresh token을 server로 옮겼지만 access token은 여전히 이 비교를 통해 stateless Resource Server와 OAuth 흐름을 직접 보는 일이 더 중요하면 AP1이 맞다고 판단했습니다. 브라우저의 직접 API 호출을 남겨야 한다면 AP2를 선택할 수 있었습니다. + + ![Browser session zone과 server-side BFF zone 사이에서 AP3_SESSION이 downstream Bearer 요청으로 바뀌는 BFF 아키텍처.](assets/ap3-bff-architecture/ap3-bff-architecture.svg)
@@ -253,6 +270,7 @@ AP2에서는 refresh token을 server로 옮겼지만 access token은 여전히
[Editable source](assets/ap3-bff-architecture/ap3-bff-architecture.drawio) · [Grounded VizSpec](.techviz/ap3-bff-architecture/spec.json) + ### AP4: OAuth를 모르는 upstream 앞에서 신뢰 경로를 만든다 @@ -266,7 +284,9 @@ AP2에서는 refresh token을 server로 옮겼지만 access token은 여전히 대신 proxy session과 identity header를 믿을 조건이 핵심 인프라가 되었습니다. 현재 예제에서는 App과 oauth2-proxy의 host port를 닫고, internal auth location을 정확히 일치시켰습니다. 브라우저가 보낸 동명 header는 Nginx 값으로 덮어썼고, 단일 trusted proxy IP와 upstream internal-token 검증도 함께 두었습니다. 운영에서는 shared secret을 secret manager에서 주입하고 교체하거나 mTLS·workload identity로 더 강하게 묶어야 합니다. 현재는 user와 email만 전달하므로 role이나 다른 claim이 필요하면 allowlist와 직렬화 규칙, 크기 제한, upstream 검증 계약을 새로 정해야 합니다. + + ![외부 브라우저 zone과 Nginx, oauth2-proxy, Spring upstream이 있는 AP4 deployment path를 나눈 edge trust 아키텍처.](assets/ap4-edge-trust-architecture/ap4-edge-trust-architecture.svg)
@@ -277,6 +297,7 @@ AP2에서는 refresh token을 server로 옮겼지만 access token은 여전히
[Editable source](assets/ap4-edge-trust-architecture/ap4-edge-trust-architecture.drawio) · [Grounded VizSpec](.techviz/ap4-edge-trust-architecture/spec.json) + ## 선택이 코드와 흐름에 반영되는 방식 @@ -391,12 +412,12 @@ Serialized user는 `InMemoryWebStorage`에 있고 module 변수 `currentUser`도 Callback 처리가 끝나면 브라우저에는 다음 값이 남습니다. -| 위치 | 남는 데이터 | reload 뒤 | -|---|---|---| -| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 | -| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거되는 것이 계약 | -| Local Storage | 애플리케이션이 쓰지 않음 | 해당 없음 | -| Keycloak origin cookie | IdP SSO 상태가 존재할 수 있음 | AP1 app memory와 별개 | +| 위치 | 남는 데이터 | reload 뒤 | +| ---------------------- | ---------------------------------------------------- | ----------------------------------- | +| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 | +| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거되는 것이 계약 | +| Local Storage | 애플리케이션이 쓰지 않음 | 해당 없음 | +| Keycloak origin cookie | IdP SSO 상태가 존재할 수 있음 | AP1 app memory와 별개 | Memory user가 사라진다고 Keycloak SSO까지 로그아웃되는 것은 아닙니다. Reload 뒤 애플리케이션 token 상태를 포기했다는 말과 IdP session을 제거했다는 말은 구분해야 합니다. @@ -477,14 +498,14 @@ SPA는 이 JSON을 다시 화면용 object로 조립합니다. **4단계 — 실패와 수명주기를 같은 흐름에서 읽는다** -| 입력 또는 사건 | 최초 거부 지점 | 관측 가능한 결과 | 보장하지 않는 세부 | -|---|---|---|---| -| Bearer 없음 | Spring Security | `/api/me` 401 | exact error body | -| 잘못된 audience | custom audience validator | 401 | UI용 JSON error 모양 | -| 잘못된 issuer | issuer validator | 401 | UI용 JSON error 모양 | -| regular user가 `/api/admin` 호출 | authority decision | 403 | 공통 error envelope | -| callback query의 `error` | oidc-client-ts callback, app catch | unauthenticated UI와 error message | exact provider error schema | -| app memory user 없음 또는 expired | `callProtectedApi()` local guard | network call 없이 login-required JSON | 자동 재로그인 | +| 입력 또는 사건 | 최초 거부 지점 | 관측 가능한 결과 | 보장하지 않는 세부 | +| --------------------------------- | ---------------------------------- | ------------------------------------- | --------------------------- | +| Bearer 없음 | Spring Security | `/api/me` 401 | exact error body | +| 잘못된 audience | custom audience validator | 401 | UI용 JSON error 모양 | +| 잘못된 issuer | issuer validator | 401 | UI용 JSON error 모양 | +| regular user가`/api/admin` 호출 | authority decision | 403 | 공통 error envelope | +| callback query의`error` | oidc-client-ts callback, app catch | unauthenticated UI와 error message | exact provider error schema | +| app memory user 없음 또는 expired | `callProtectedApi()` local guard | network call 없이 login-required JSON | 자동 재로그인 | SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도합니다. Spring의 401 body가 비어 있거나 JSON이 아니면 의도한 “보호 API가 401을 반환했다”는 message보다 JSON parse error가 먼저 보일 수 있습니다. Negative E2E는 UI button 경로가 아니라 별도 Node fetch로 status만 확인하므로 이 failure UX는 현재 고정되어 있지 않습니다. @@ -493,7 +514,9 @@ Refresh와 logout도 서로 다른 효과를 가집니다. Realm은 access token `automaticSilentRenew=true`도 구성되어 있지만, browser가 실제 expiry를 기다려 silent renewal을 완료하고 새 `User`를 memory에 저장하는 경로는 acceptance test가 아닙니다. Manual refresh helper로 검증하는 것과 app runtime의 automatic renewal을 같은 결과로 간주하지 않습니다. + + ![브라우저 SPA, Keycloak, Resource Server 사이에서 authorization request, callback, token 교환, Bearer API 호출과 JSON 응답이 이어지는 순서도.](assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.svg)
@@ -504,6 +527,7 @@ Refresh와 logout도 서로 다른 효과를 가집니다. Realm은 access token
[Editable source](assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.drawio) · [Grounded VizSpec](.techviz/ap1-browser-bearer-flow/spec.json) + ### AP2 완주: server의 authorized client가 browser Bearer가 되기까지 @@ -744,20 +768,22 @@ authorization code **6단계 — AP2의 실패와 공백을 endpoint별로 구분한다** -| 상황 | 현재 경계의 결과 | 확인된 것 | 아직 고정되지 않은 것 | -|---|---|---|---| -| 미인증 `/token/boundary` 또는 `/token/access` | controller 이전 login entry point | UI는 redirect와 401 양쪽을 처리 | exact redirect/401 contract | -| 인증됨, boundary 조회에 authorized client 없음 | boolean false를 담은 200 | controller branch | token 복구 UX | -| 인증됨, access endpoint에 client/token 없음 | 401 | controller status와 reason | exact JSON error body | -| anonymous `/api/me` | 401 | backend test contract | error envelope | -| foreign audience | `invalid_token` validation result | validator test contract | AP2 browser E2E의 401 | -| access token expiry | manager가 refresh 가능한 provider를 가짐 | configuration | real refresh success·failure | -| mediator restart 또는 replica 이동 | process-local state에 영향 | 구현상 저장소 경계 | recovery/failover contract | +| 상황 | 현재 경계의 결과 | 확인된 것 | 아직 고정되지 않은 것 | +| ------------------------------------------------ | ---------------------------------------- | ------------------------------- | ----------------------------- | +| 미인증`/token/boundary` 또는 `/token/access` | controller 이전 login entry point | UI는 redirect와 401 양쪽을 처리 | exact redirect/401 contract | +| 인증됨, boundary 조회에 authorized client 없음 | boolean false를 담은 200 | controller branch | token 복구 UX | +| 인증됨, access endpoint에 client/token 없음 | 401 | controller status와 reason | exact JSON error body | +| anonymous`/api/me` | 401 | backend test contract | error envelope | +| foreign audience | `invalid_token` validation result | validator test contract | AP2 browser E2E의 401 | +| access token expiry | manager가 refresh 가능한 provider를 가짐 | configuration | real refresh success·failure | +| mediator restart 또는 replica 이동 | process-local state에 영향 | 구현상 저장소 경계 | recovery/failover contract | AP2는 refresh credential을 browser 밖으로 옮깁니다. 하지만 logout 시 session과 authorized client를 함께 삭제하는 code, token-at-rest encryption, shared durable store, handoff replay rejection은 구현되어 있지 않습니다. 따라서 AP2가 refresh token을 브라우저에 보내지 않는다는 점까지만 확인했습니다. 이를 운영 환경에 바로 쓸 수 있다고 말할 수는 없습니다. + + ![브라우저, Spring mediator, authorized-client store, Resource Server 사이에서 AP2_SESSION 요청, access-only 응답, 브라우저 Bearer 호출과 JSON 응답이 이어지는 순서도.](assets/ap2-mediator-handoff-flow/ap2-mediator-handoff-flow.svg)
@@ -768,6 +794,7 @@ AP2는 refresh credential을 browser 밖으로 옮깁니다. 하지만 logout
[Editable source](assets/ap2-mediator-handoff-flow/ap2-mediator-handoff-flow.drawio) · [Grounded VizSpec](.techviz/ap2-mediator-handoff-flow/spec.json) + ### AP3 완주: session cookie가 BFF의 downstream Bearer가 되기까지 @@ -984,7 +1011,9 @@ POST X-XSRF-TOKEN = same raw token 이 구분을 놓치면 “CSRF JSON에서 받은 token을 그대로 header에 복사한다”는 잘못된 구현 설명이 됩니다. 실제 SPA의 data source는 cookie입니다. + + ![BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름.](assets/ap3-csrf-boundary/ap3-csrf-boundary.svg)
@@ -995,6 +1024,7 @@ POST X-XSRF-TOKEN = same raw token
[Editable source](assets/ap3-csrf-boundary/ap3-csrf-boundary.drawio) · [Grounded VizSpec](.techviz/ap3-csrf-boundary/spec.json) + **5단계 — form input이 process-global preference가 되기까지** @@ -1034,19 +1064,21 @@ Controller보다 먼저 Spring CSRF filter가 repository의 expected token과 su **6단계 — CSRF와 SameSite 실패를 별도 방어선으로 읽는다** -| 입력 | Cookie 동작 | CSRF 동작 | 결과 | -|---|---|---|---| -| same-origin, AP3 session, CSRF header 없음 | session cookie 첨부 | filter가 token 부재 거부 | 403 | -| same-origin, matching raw cookie/header | session cookie 첨부 | token 일치 | controller 200 | -| 다른 port지만 same-site인 요청, header 없음 | session cookie가 실릴 수 있음 | token 부재 거부 | 403 | -| `127.0.0.1`에서 `localhost`로 cross-site POST | SameSite=Lax로 `AP3_SESSION`이 request header에서 제외되는지 확인 | 이 test는 이후 server 처리를 고정하지 않음 | 최종 status/body가 아니라 cookie omission이 acceptance point | +| 입력 | Cookie 동작 | CSRF 동작 | 결과 | +| ------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------ | +| same-origin, AP3 session, CSRF header 없음 | session cookie 첨부 | filter가 token 부재 거부 | 403 | +| same-origin, matching raw cookie/header | session cookie 첨부 | token 일치 | controller 200 | +| 다른 port지만 same-site인 요청, header 없음 | session cookie가 실릴 수 있음 | token 부재 거부 | 403 | +| `127.0.0.1`에서 `localhost`로 cross-site POST | SameSite=Lax로`AP3_SESSION`이 request header에서 제외되는지 확인 | 이 test는 이후 server 처리를 고정하지 않음 | 최종 status/body가 아니라 cookie omission이 acceptance point | SameSite는 cookie의 cross-site 전송을 제한하는 browser 정책이고 CSRF token은 state-changing request의 의도를 server가 검증하는 application protocol입니다. Port가 달라도 site 계산상 같은 경우가 있으므로 둘은 서로 대체할 수 없습니다. JavaScript가 OAuth token을 받지 않는다고 XSS가 무해해지는 것도 아닙니다. Same-origin 악성 script는 피해자 session으로 BFF endpoint를 호출하고 readable XSRF cookie도 읽을 수 있습니다. AP3가 줄이는 것은 access·refresh token 원문이 browser script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 반경입니다. CSP, output encoding, dependency integrity와 application authorization은 여전히 별도 방어선입니다. + + ![브라우저, BFF, authorized-client store, Resource Server 사이에서 AP3_SESSION 요청, server-held token 조회, downstream Bearer 호출과 중계 JSON이 이어지는 순서도.](assets/ap3-bff-session-flow/ap3-bff-session-flow.svg)
@@ -1057,6 +1089,7 @@ JavaScript가 OAuth token을 받지 않는다고 XSS가 무해해지는 것도
[Editable source](assets/ap3-bff-session-flow/ap3-bff-session-flow.drawio) · [Grounded VizSpec](.techviz/ap3-bff-session-flow/spec.json) + ### AP4 완주: proxy session이 trusted identity JSON이 되기까지 @@ -1081,14 +1114,14 @@ auth_request /oauth2/auth; `location = /oauth2/auth`는 `internal`입니다. Nginx가 만드는 subrequest만 들어갈 수 있고 browser가 같은 URL을 직접 호출하면 정상 auth endpoint로 사용할 수 없습니다. Subrequest는 body를 보내지 않고 `Content-Length`를 비웁니다. 대신 원래 요청의 문맥을 header로 바꿉니다. -| Nginx가 만드는 auth input | 값의 출처 | -|---|---| -| `X-Original-URL` | scheme, host와 original request URI | -| `X-Real-IP` | client address | -| `X-Forwarded-For` | proxy chain | -| `X-Forwarded-Host` | original host | -| `X-Forwarded-Proto` | original scheme | -| `X-Forwarded-Uri` | original request URI | +| Nginx가 만드는 auth input | 값의 출처 | +| --------------------------- | --------------------------------------- | +| `X-Original-URL` | scheme, host와 original request URI | +| `X-Real-IP` | client address | +| `X-Forwarded-For` | proxy chain | +| `X-Forwarded-Host` | original host | +| `X-Forwarded-Proto` | original scheme | +| `X-Forwarded-Uri` | original request URI | | `Cookie: AP4_SESSION=...` | browser에 cookie가 있을 때 원래 request | 미인증 상태에서 oauth2-proxy의 auth endpoint가 401을 반환하면 general `/` location은 `@oauth2_signin`으로 이동해 다음 redirect를 만듭니다. @@ -1235,14 +1268,14 @@ AP1·AP2·AP3의 Resource Server는 JWT의 issuer와 audience를 직접 확인 **5단계 — AP4의 401, 302와 404는 경로별로 다르다** -| 외부 입력 | 인증 상태 | 최초 결정 지점 | 결과 | -|---|---|---|---| -| `GET /` | 미인증 | general location의 auth 401 error page | `/oauth2/start`로 302 | -| `GET /api/edge` | 미인증 | exact API location의 auth 401 error page | redirect 없는 401 `{"error":"authentication required"}` | -| `GET /oauth2/auth` | 무관 | `internal` exact location | 외부에서는 404 | -| `GET /` + spoofed identity headers | 정상 AP4 session | Nginx header overwrite | 실제 authenticated user로 200 | -| internal `/edge/me` + user header만 | edge token 없음 | controller | 401 trusted-edge error | -| internal `/edge/me` + wrong token | token mismatch | controller | 401 trusted-edge error | +| 외부 입력 | 인증 상태 | 최초 결정 지점 | 결과 | +| ------------------------------------ | ---------------- | ---------------------------------------- | -------------------------------------------------------- | +| `GET /` | 미인증 | general location의 auth 401 error page | `/oauth2/start`로 302 | +| `GET /api/edge` | 미인증 | exact API location의 auth 401 error page | redirect 없는 401`{"error":"authentication required"}` | +| `GET /oauth2/auth` | 무관 | `internal` exact location | 외부에서는 404 | +| `GET /` + spoofed identity headers | 정상 AP4 session | Nginx header overwrite | 실제 authenticated user로 200 | +| internal`/edge/me` + user header만 | edge token 없음 | controller | 401 trusted-edge error | +| internal`/edge/me` + wrong token | token mismatch | controller | 401 trusted-edge error | Redirect 없는 JSON 401은 정확히 `/api/edge` 예시 path에만 구성되어 있습니다. “AP4의 모든 API path가 JSON 401을 반환한다”고 일반화하면 안 됩니다. 다른 path는 현재 general location의 login redirect 규칙을 따릅니다. @@ -1262,7 +1295,9 @@ App과 oauth2-proxy의 host port가 닫혀 있다는 사실도 중요합니다. AP4가 authentication gate를 중앙화했다고 application authorization까지 자동으로 완성되는 것은 아닙니다. 현재 `/edge/me`도 role decision을 하지 않습니다. + + ![브라우저, Nginx, oauth2-proxy, Spring upstream 사이에서 AP4_SESSION 검증, identity header 덮어쓰기, internal token 검증과 JSON 응답이 이어지는 순서도.](assets/ap4-edge-forward-auth-flow/ap4-edge-forward-auth-flow.svg)
@@ -1273,6 +1308,7 @@ AP4가 authentication gate를 중앙화했다고 application authorization까지
[Editable source](assets/ap4-edge-forward-auth-flow/ap4-edge-forward-auth-flow.drawio) · [Grounded VizSpec](.techviz/ap4-edge-forward-auth-flow/spec.json) + ### Google login이 들어와도 네 애플리케이션 경계는 바뀌지 않는다 @@ -1304,12 +1340,12 @@ AP1 Resource Server가 신뢰하는 issuer도 Keycloak이고, AP2·AP3가 교환 아래 표는 최신 실행 성적표가 아니라 커밋된 자동 테스트가 확인하도록 정의한 acceptance contract입니다. Pattern별 verify flow는 stack을 다시 만들기 전에 Docker volume을 삭제하므로, 보존해야 할 local realm과 database가 있는 환경에서 그대로 실행해서는 안 됩니다. -| 패턴 | 테스트가 만드는 핵심 입력 | 기대 output | 지키려는 경계 | -|---|---|---|---| -| AP1 | S256 authorization request, 실제 login, Bearer `/api/me`, 동일 정상 JWT를 expected issuer·audience가 다른 diagnostic server에 제출 | 정상 200, diagnostic server 401, runtime fetch hook에서 access token 관측, persistent Web Storage에 access token 없음 | Browser가 token owner라는 사실과 Resource Server validation | -| AP2 | Login session으로 boundary/access GET, 반환 token으로 direct API GET | server access·refresh booleans true, refresh field 없음, access JSON 세 field, `no-store`, API 200 | Refresh custody는 server, access credential은 browser | -| AP3 | Session-only `/bff/api/me`, CSRF 없는 POST, matching header POST, cross-site POST | browser token count 0, downstream JSON 200, 403/200 분리, SameSite cookie omission | BFF token custody와 cookie-authenticated state-change protection | -| AP4 | Cookie 없는 `/`와 `/api/edge`, 정상 session, spoofed headers, external auth endpoint, direct app/proxy ports | root 302, exact API 401, 실제 user 200, auth endpoint 404, internal ports inaccessible | Edge만 trusted identity input을 만들 수 있는 path | +| 패턴 | 테스트가 만드는 핵심 입력 | 기대 output | 지키려는 경계 | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| AP1 | S256 authorization request, 실제 login, Bearer`/api/me`, 동일 정상 JWT를 expected issuer·audience가 다른 diagnostic server에 제출 | 정상 200, diagnostic server 401, runtime fetch hook에서 access token 관측, persistent Web Storage에 access token 없음 | Browser가 token owner라는 사실과 Resource Server validation | +| AP2 | Login session으로 boundary/access GET, 반환 token으로 direct API GET | server access·refresh booleans true, refresh field 없음, access JSON 세 field,`no-store`, API 200 | Refresh custody는 server, access credential은 browser | +| AP3 | Session-only`/bff/api/me`, CSRF 없는 POST, matching header POST, cross-site POST | browser token count 0, downstream JSON 200, 403/200 분리, SameSite cookie omission | BFF token custody와 cookie-authenticated state-change protection | +| AP4 | Cookie 없는`/`와 `/api/edge`, 정상 session, spoofed headers, external auth endpoint, direct app/proxy ports | root 302, exact API 401, 실제 user 200, auth endpoint 404, internal ports inaccessible | Edge만 trusted identity input을 만들 수 있는 path | ### AP1 검증을 단계별로 읽는 법 @@ -1409,12 +1445,12 @@ Spoofing test는 authenticated browser가 `X-Auth-Request-User: spoofed-admin`, 네 패턴을 모두 실행하고 나니 AP1에서 AP4로 갈수록 브라우저에 OAuth token이 덜 보이는 것은 맞았습니다. 처음에는 번호가 높을수록 더 나은 패턴처럼 보였습니다. 그런데 token을 브라우저에서 치울 때마다 그 일을 다른 곳이 맡았습니다. AP3에는 server session과 CSRF가 생겼고, AP4에는 proxy session과 identity header를 믿을 조건이 생겼습니다. 그래서 번호 순서 대신 브라우저와 BFF, edge 중 누가 token과 session을 관리하는지로 비교했습니다. -| 패턴 | 얻는 것 | 잃거나 추가하는 것 | 잘 맞는 조건 | 피해야 할 조건 | -|---|---|---|---|---| -| AP1 | protocol 가시성, stateless Resource Server, direct API | browser token lifecycle, XSS 시 token·권한 악용, reload state 포기 | public SPA가 API를 직접 불러야 하고 token-in-browser를 수용 | browser token 자체가 정책상 금지 | -| AP2 | client secret·refresh token server custody, 기존 Bearer API 유지 | access token 노출과 server state를 동시에 운영 | direct browser-to-API가 실제 요구이며 refresh credential만 분리 | one-time handoff나 tokenless browser가 요구 | -| AP3 | OAuth token 비노출, application-owned fan-out과 session | CSRF, shared session/token store, BFF latency와 장애 지점 | backend가 API composition과 사용자 session을 소유 | stateless direct API와 독립 client가 핵심 | -| AP4 | OAuth 비인지 upstream 앞의 공통 login gate | proxy session, network·header trust, claim projection 계약 | 기존 upstream 변경이 어렵고 edge policy를 강제 가능 | backend direct path나 header overwrite를 닫을 수 없음 | +| 패턴 | 얻는 것 | 잃거나 추가하는 것 | 잘 맞는 조건 | 피해야 할 조건 | +| ---- | ----------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------- | +| AP1 | protocol 가시성, stateless Resource Server, direct API | browser token lifecycle, XSS 시 token·권한 악용, reload state 포기 | public SPA가 API를 직접 불러야 하고 token-in-browser를 수용 | browser token 자체가 정책상 금지 | +| AP2 | client secret·refresh token server custody, 기존 Bearer API 유지 | access token 노출과 server state를 동시에 운영 | direct browser-to-API가 실제 요구이며 refresh credential만 분리 | one-time handoff나 tokenless browser가 요구 | +| AP3 | OAuth token 비노출, application-owned fan-out과 session | CSRF, shared session/token store, BFF latency와 장애 지점 | backend가 API composition과 사용자 session을 소유 | stateless direct API와 독립 client가 핵심 | +| AP4 | OAuth 비인지 upstream 앞의 공통 login gate | proxy session, network·header trust, claim projection 계약 | 기존 upstream 변경이 어렵고 edge policy를 강제 가능 | backend direct path나 header overwrite를 닫을 수 없음 | ### AP1을 적용하거나 떠날 기준 @@ -1461,7 +1497,9 @@ AP3에서 AP4로 옮기는 일은 한 단계 업그레이드가 아니었습니 반대로 AP4의 upstream이 더 많은 claim과 애플리케이션 흐름을 요구하기 시작하면 BFF로 돌아갈 수 있었습니다. Header 종류를 계속 늘리는 것보다 API 조합 책임을 애플리케이션에 돌려주는 편이 명확할 수 있었습니다. 저는 어느 쪽으로 옮길지를 번호로 판단하지 않았습니다. 새로 일을 맡는 곳이 state와 검증을 감당할 수 있는지를 보았습니다. + + ![AP1에서 AP2, AP2에서 AP3, AP3에서 AP4, AP4에서 AP3로 이동할 때 호출 계약, 소유권, 브라우저 계약, 운영 책임과 전환 성격을 같은 다섯 축으로 비교한 네 항목.](assets/credential-contract-migration/credential-contract-migration.svg)
@@ -1472,6 +1510,7 @@ AP3에서 AP4로 옮기는 일은 한 단계 업그레이드가 아니었습니
[Editable source](assets/credential-contract-migration/credential-contract-migration.drawio) · [Grounded VizSpec](.techviz/credential-contract-migration/spec.json) + ## 결국 지키려던 것은 무엇이었나 diff --git a/document.md b/document.md new file mode 100755 index 0000000..9fd1357 --- /dev/null +++ b/document.md @@ -0,0 +1,1764 @@ +# 하이라이트 피드 조회 성능 — N+1 진단과 조회 전략의 진화 + +제가 만들려던 것은 페이지에 하이라이트가 아무리 많아도 조회량이 폭증하지 않는 피드 API였습니다. +처음에는 엔티티를 조회한 뒤 DTO로 바꾸는 구현으로도 충분해 보였습니다. 그런데 데이터를 늘려 보니 +화면에 필요한 행보다 훨씬 많은 엔티티와 쿼리가 생겼습니다. 그래서 실제 PostgreSQL에서 SQL과 +실행계획을 측정했습니다. 한 전략이 남긴 문제는 다음 전략으로 풀어 갔습니다. 이 문서는 그 과정과 +마지막에 남은 비용을 함께 기록한 글입니다. + +> **측정의 범위와 한계** — 아래 수치는 **단일 스레드 퍼시스턴스 통합 테스트**(`@DataJpaTest` + 실제 PostgreSQL)에서 SQL shape와 데이터 규모에 따른 **조회 횟수의 증가 형태**를 측정했습니다. 지연(latency) 값은 이미 열린 테스트 트랜잭션 안에서 어댑터 호출만 잰 **단일 스레드·warm-cache 로컬 비교값**이라 HTTP 종단 지연도 운영 p99도 아닙니다. 고트래픽 처리량·connection pool 안정성·동시성은 이 측정의 범위 밖입니다. 별도 부하 테스트로 확인해야 합니다. + +--- + +## 1. 해결할 문제 + +제가 만든 하이라이트 피드 API에는 다음 요구사항이 있었습니다. + +- **공개 범위**(public / mentioned / private)를 사용자별로 정확히 적용합니다. +- **최초 하이라이트 시각**으로 정렬합니다. +- 피드 아이템별 **최신 하이라이트 최대 3개**를 포함합니다. +- **페이징**합니다. +- 페이지에 하이라이트가 아무리 많고 피드가 아무리 커도 **조회량이 비례해 폭증하지 않습니다**(고트래픽). + +기능 요구사항(FR)만 보면 평범한 조회입니다. 제가 해결해야 했던 부분은 고트래픽에서도 조회량이 +데이터 규모에 비례해 늘지 않게 만드는 비기능 요구사항(NFR)이었습니다. 다만 최초 구현에는 FR 전체를 +한꺼번에 넣지 않았습니다. 공개 범위 판정·최신 3개 제한·mentioned 관계·커서 페이징을 제외하고 +조회 문제를 드러내기 위한 기능적 기준선부터 만들었습니다. + +--- + +## 2. 조회 전략의 전체 여정 + +최종 조회 구조를 먼저 정하고 구현하지는 않았습니다. 기준선을 측정하자 컬렉션 N+1(N1)과 User·Page +연관의 숨은 쿼리(N2)가 동시에 드러났습니다. 둘은 순서대로 생긴 문제가 아니라 같은 구현에서 갈라진 +문제였습니다. 저는 두 문제를 Fetch Join으로 한꺼번에 풀어 보려 했습니다. 그 시도가 다중 컬렉션과 +페이징 문제를 다시 만들었습니다. 이후 Batch Fetch → DTO Projection → 아이템별 Top-3 → Keyset +Pagination → 가시성 조건 인덱싱 순으로 전략을 바꿨습니다. + + + +![요구사항과 모델에서 기준선으로 진행한 뒤 N1과 N2로 분기하고 Fetch Join에서 합류해, 실패와 다섯 개선 단계를 거쳐 최종 피드 조회 구조에 이르는 흐름도.](assets/diagrams/strategy-journey/strategy-journey.svg) + +
+Diagram description + +왼쪽에서 과제 요구사항, 도메인·데이터 모델, 최초 피드 조회 기준선 순으로 시작합니다. 기준선에서 컬렉션 N+1(N1)과 User·Page 연관의 숨은 쿼리(N2)가 서로 앞뒤가 아닌 형제 문제로 동시에 갈라지고, 두 경로는 Fetch Join 시도에서 합류합니다. 이 시도는 다중 컬렉션·페이징 실패로 이어집니다. 마지막 노드는 Batch Fetch, DTO Projection, 아이템별 Top-3, Keyset Pagination, 가시성 조건 인덱싱을 거쳐 최종 피드 조회 구조에 도달하는 순서를 담습니다. + +
+ +[Editable source](assets/diagrams/strategy-journey/strategy-journey.drawio) · [Grounded VizSpec](.techviz/strategy-journey/spec.json) + + +--- + +## 3. 도메인·데이터 모델 + +### 3.1 관계와 스키마 + +- 한 **user**에게는 **feed_item**이 여럿 있습니다. +- 한 **page**에는 여러 **feed_item**이 딸립니다. +- 한 **feed_item**에는 **highlights**가 여럿입니다. + + + +![users와 pages에서 feed_items로 모이고 highlights로 이어지는 기준선 관계도.](assets/diagrams/baseline-schema/baseline-schema.svg) + +
+Diagram description + +왼쪽의 users와 pages가 각각 중앙의 feed_items에 연결됩니다. feed_items는 오른쪽의 highlights로 이어집니다. 간선은 user와 page 각각에 여러 feed_item이 연결되고, 한 feed_item에 여러 highlight가 연결되는 관계를 나타냅니다. + +
+ +[Editable source](assets/diagrams/baseline-schema/baseline-schema.drawio) · [Grounded VizSpec](.techviz/baseline-schema/spec.json) + + +위 ERD는 제가 처음 만든 기준선 스키마입니다. `FeedItem`은 `(user, page)` 조합당 하나입니다. +같은 사용자가 같은 페이지에 하이라이트를 여러 개 만들어도 피드 아이템은 하나입니다. 이 정의를 +`UNIQUE(user_id, page_id)` 제약으로 옮겼습니다. + +과제 완료 목표 모델에는 `feed_item_mentions`(피드 아이템 ↔ mentioned 사용자) 관계도 필요했습니다. +공개 범위가 핵심 요구사항이므로 최종 스키마에는 반드시 들어갑니다. 다만 퍼시스턴스 계층의 +테이블·엔티티·시더는 공개 범위 단계보다 앞선 9절에서 추가했습니다. `MultipleBagFetchException`을 +재현하려면 fetch join할 두 번째 bag이 필요했기 때문입니다. 도메인·응답 매핑·공개 범위 판정은 뒤 +단계에 남겨 두었습니다. 현재 기준선 그림과 최종 스키마는 구분해서 읽어야 합니다. + + + +![기존 users를 mentioned 사용자 역할로 재사용해 feed_item_mentions와 연결한 5노드 목표 관계도.](assets/diagrams/target-schema/target-schema.svg) + +
+Diagram description + +왼쪽의 users와 pages가 중앙의 feed_items에 연결됩니다. 오른쪽에는 highlights와 feed_item_mentions가 놓입니다. feed_items는 두 엔티티에 각각 연결되고, 기존 users도 mentioned 사용자 역할로 feed_item_mentions에 연결됩니다. + +
+ +[Editable source](assets/diagrams/target-schema/target-schema.drawio) · [Grounded VizSpec](.techviz/target-schema/spec.json) + + +> **Open Decision OD-01 — 하이라이트 없는 FeedItem 허용 여부** +> - **질문:** 하이라이트 없는 FeedItem이 존재할 수 있는가? +> - **현재 상태:** 미결정 · 현재 스키마: `first_highlighted_at timestamptz`(nullable, NOT NULL 아님). 시더는 하이라이트가 만든 FeedItem이므로 항상 값을 채웁니다. +> - **영향:** 정렬 / keyset cursor의 null 처리(`NULLS LAST`·커서 위치) / 부분 인덱스 predicate / FeedItem 생성 lifecycle. +> - **결정 시점:** keyset 페이징 단계 이전. NOT NULL로 좁힐지, null 정렬 위치를 정의할지를 그때 결론 냅니다. + +### 3.2 식별자는 `ResourceId` 값 객체로 생성한다 + +ID는 `String`이나 `UUID` 원시 타입으로 두지 않고 값 객체 +(`FeedItemId implements ResourceId`)로 만들었습니다. 이렇게 정한 이유는 네 가지입니다. + +**① 타입 안정성.** 인자 뒤바뀜을 컴파일 시점에 잡습니다. + +```java +// 원시 타입: 컴파일 통과, 런타임에 조용히 오작동 +void registerFeedLike(String userId, String feedItemId) { ... } +registerFeedLike(feedItemId, userId); // 뒤바뀜 — 컴파일러가 못 잡음 + +// 값 객체: 컴파일 에러 +void registerFeedLike(UserId userId, FeedItemId feedItemId) { ... } +registerFeedLike(feedItemId, userId); // 컴파일 실패 (타입 불일치) +``` + +**② 도메인 제약의 자가 검증.** 생성 경로가 곧 신뢰 경계입니다. `FeedItemId`가 존재한다는 것 자체가 "유효한 형식"을 보장합니다. 다만 이 정규식이 보장하는 것은 8-4-4-4-12 hex의 UUID 문자열 형태뿐입니다. UUID version이 7인지, variant가 RFC 규격인지는 검사하지 않습니다. "신규 ID가 UUIDv7 정책을 따른다"는 조건은 값 객체가 아니라 `IdFactory`가 보장합니다. version까지 강제하려면 값 객체에서 `UUID.fromString(value).version() == 7`을 검사해야 합니다. + +```java +@ValueObject +public record FeedItemId(String value) implements ResourceId { + private static final Pattern PATTERN = + Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + + public FeedItemId { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("Invalid feed item id format: " + value); + } + } +} +``` + +**③ 식별자 규격의 캡슐화.** ID 정책이 ULID → UUIDv7로 바뀌어도 비즈니스 로직은 타입만 보므로 도메인 호출부의 변경을 줄입니다. 그렇다고 값 객체 하나만 고치면 끝나는 것은 아닙니다. ID 생성 `IdFactory`, DB 컬럼 타입, 변환 매퍼, 커서 인코딩, 인덱스 크기·정렬 특성, 마이그레이션도 함께 영향을 받습니다. 값 객체는 이런 변경이 도메인 로직 전반으로 번지는 일을 줄여 줍니다. + +**④ 생성 정책 교체.** `IdFactory` 구현을 교체하면 다른 ID 정책으로 바꿀 수 있습니다. + +> **흔한 오해**: "`@ValueObject`가 모든 필드 final + setter 금지를 강제합니다." +> **실제**: 불변성은 `record`의 언어 특성입니다. `@ValueObject`에 걸리는 규칙은 **무인자 생성자 금지**(불변식을 우회하는 빈 생성자 뒷문 차단)이고 setter 금지는 애그리거트 루트(`@AggregateRoot`)의 별도 규칙입니다. + +> **흔한 오해**: "값 객체는 엔티티·서비스 필드로 못 씁니다." +> **실제**: 강제되는 규칙이 아니라 관례입니다. 퍼시스턴스 엔티티는 값 객체가 아니라 원시 `UUID`를 저장하고 매퍼 경계에서 변환합니다. 규칙으로 강제되는 것은 "도메인이 프레임워크에 의존하지 않는다"는 순수성입니다. + +### 3.3 퍼시스턴스 엔티티는 연관 게터를 좁게 연다 + +`FeedItemJpaEntity`의 연관 게터는 `public`으로 열지 않고 package-private로 좁혔습니다. + +```java +public class FeedItemJpaEntity extends AuditableEntity { // 클래스는 public + public UUID getId() { return id; } // 식별자는 public + UserJpaEntity getUser() { return user; } // 연관은 package-private + PageJpaEntity getPage() { return page; } + List getHighlights() { return highlights; } +} +``` + +연관 게터가 열려 있으면 상위 계층이 엔티티 객체 그래프를 타고 다니며 지연 로딩을 아무 데서나 촉발하거나 영속성 컨텍스트·DB 스펙에 의존하게 됩니다. package-private로 좁히면 같은 패키지의 어댑터·매퍼만 그래프를 순회할 수 있습니다. + +> **흔한 오해 ①**: "엔티티 클래스를 package-private로 강제합니다." +> **실제**: package-private인 것은 클래스가 아니라 연관 게터입니다. 규칙이 아니라 방어적 캡슐화 관례입니다. 엔티티가 계층 밖으로 새는 것은 "컨트롤러가 엔티티를 의존/반환하지 않는다", "쿼리 포트가 엔티티 타입을 노출하지 않는다"는 경계 규칙이 막습니다. + +> **흔한 오해 ②**: "JPA 엔티티 클래스는 반드시 public이어야 합니다." +> **실제**: Jakarta Persistence 규격은 엔티티에 top-level(또는 static inner)·non-final·무인자 생성자 등을 요구하지만 클래스 자체가 public이길 요구하지는 않습니다. 이 프로젝트에서는 도구 호환성을 단순하게 유지하려고 엔티티 클래스를 public으로 두었습니다. 연관 게터를 package-private로 좁혀도 매핑되는 이유는 이 엔티티가 field access(`@Id`가 필드에 붙음)를 사용하기 때문입니다. property access였다면 영속 속성 게터는 public/protected여야 합니다. + +--- + +## 4. 측정 환경과 데이터셋 + +조회 전략을 비교하기 전에 측정 환경부터 고정했습니다. 어디서·무엇으로·어떤 데이터를 측정했는지 +남기지 않으면 숫자가 달라졌을 때 코드 때문인지 환경 때문인지 구분할 수 없기 때문입니다. + +### 4.1 측정 환경 — 실제 PostgreSQL을 퍼시스턴스 계층에서 직접 측정 + +```java +@DataJpaTest +@ContextConfiguration(classes = CaSkeletonApplication.class) +@AutoConfigureTestDatabase(replace = NONE) // 인메모리 대체 금지 → 실제 DB +@Testcontainers(disabledWithoutDocker = true) +@TestPropertySource(properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/postgresql", + "spring.jpa.hibernate.ddl-auto=validate", // 엔티티↔마이그레이션 일치 강제 + "spring.jpa.properties.hibernate.generate_statistics=true"}) +class FeedPersistenceIT { + @Container @ServiceConnection + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16-alpine"); +} +``` + +- **실제 PostgreSQL 16**(Testcontainers)을 사용했습니다. 컨테이너 필드가 `static`이므로 테스트 메서드마다 새로 띄우지 않고 `FeedPersistenceIT` 실행 동안 하나를 공유합니다. 첫 테스트 전에 한 번 기동하고 마지막 테스트가 끝나면 종료합니다. 각 테스트의 데이터는 `@DataJpaTest` 트랜잭션 롤백과 명시적인 `em.clear()`로 격리했습니다. H2 같은 인메모리 DB를 쓰지 않은 이유는 N+1의 쿼리 수뿐 아니라 EXPLAIN 실행계획(Index/Seq Scan)과 인덱스 동작도 DB 엔진마다 다르기 때문입니다. 인메모리 DB에서 재면 운영 환경인 PostgreSQL과 다른 계획이 나와 잘못된 결론에 이를 수 있습니다. 엔진마다 계획이 달라지는 이유는 4.6절에서 다시 설명합니다. 재현성을 더 높이려면 `postgres:16-alpine` 태그보다 patch 버전이나 digest(`@sha256:...`)를 고정하는 편이 낫습니다. 같은 태그가 시점에 따라 다른 patch를 가리킬 수 있기 때문입니다. +- 스키마는 운영 마이그레이션과 같게 맞췄습니다. Flyway `V6__feed.sql`을 그대로 적용하고 `ddl-auto=validate`로 엔티티가 기대하는 테이블·컬럼·타입의 기본 불일치를 조기에 잡았습니다. 다만 `validate`만으로 모든 드리프트를 막을 수는 없습니다. 인덱스 구성, 부분 인덱스 predicate, check 제약, FK 삭제 정책, 컬럼 순서 등은 검증 범위 밖이므로 마이그레이션 검증과 catalog 조회로 따로 확인합니다. +- 퍼시스턴스 어댑터(`FeedQueryAdapter`)를 JPA 슬라이스에서 직접 호출합니다. HTTP를 거치지 않습니다. 이유는 둘입니다. 하나, N+1은 조회 계층의 현상이므로 웹·보안·직렬화 노이즈를 배제하고 순수한 쿼리 행동만 관찰합니다. 둘, 슬라이스 트랜잭션이 열려 있어 지연 로딩이 결정적으로 재현됩니다. +- **측정 도구**는 추가 라이브러리 없이 세 가지를 사용했습니다. 전용 도구 대신 이 조합을 고른 이유는 4.7절에서 설명합니다. + - Hibernate `Statistics` — **획득한 PreparedStatement 수**(`getPrepareStatementCount`), **초기화된 컬렉션 수**(`getCollectionFetchCount`), 엔티티 로드 수를 줍니다. 이는 SQL shape별 정확한 실행 횟수가 아닙니다. shape별 실행 횟수를 원문 SQL 수준에서 확정하려면 SQL 로그·`StatementInspector`·datasource-proxy·p6spy·PostgreSQL statement logging 중 하나로 별도로 수집해야 합니다. + - `System.nanoTime` — 지연. + - `EXPLAIN (ANALYZE, BUFFERS)` — 실행계획. + +### 4.2 데이터셋을 어떻게 만드는가 — 4종의 개수가 다른 이유 + +`FeedSeedFixture.seed(N)`은 피드 아이템 N개를 만들면서 각 엔티티를 서로 다른 규칙으로 생성합니다. 그래서 feed_item·user·page·highlight의 총 개수가 전부 달라집니다. + +```text +seed(N): + users = max(3, min(20, N/5 + 1)) 명 생성 # 소수 풀 + pages = N 개 생성 # feed_item과 1:1 + for i in 0 .. N-1: + feed_item[i] = { + user = users[i % users.size], # 라운드로빈: 소수 유저를 돌려 씀 (공유) + page = pages[i], # 1:1: 아이템 전용 페이지 + visibility = (i%10 <6 ? PUBLIC : i%10 <8 ? MENTIONED : PRIVATE) # 6:2:2 + } + highlightCount = max(1, round(500 / (i+1)^1.15)) # 순위가 낮을수록 많음 + highlight[i] = highlightCount 개 생성 +``` + +| 엔티티 | 개수 | 어떻게 그 개수가 되나 | +|---|---|---| +| **feed_item** | **N** | 루프를 N번 돈다 (`N ∈ {10, 100, 1000}`) | +| **page** | **N** | `pages[i]` — 아이템마다 전용 페이지(1:1) | +| **user** | **max(3, min(20, N/5+1))** | 소수만 만들고 `users[i % size]`로 **돌려 씁니다**. N=10→3명, N=100·1000→20명 | +| **highlight** | **Σ Zipf-like** | 아이템마다 순위 기반으로 개수가 다름. N=10→**1,285** · N=100→**1,961** · N=1,000→**2,917** | + +핵심은 user와 page가 같은 `@ManyToOne`인데 개수가 정반대라는 데 있습니다. user는 소수를 공유하고 page는 아이템마다 하나씩 만들었습니다. 이 비대칭 덕분에 뒤에서 같은 즉시 로딩인데도 조회 수가 달라지는 현상을 확인할 수 있습니다. + +### 4.3 하이라이트 개수는 왜 Zipf 형태의 편중 분포로 만드나 + +하이라이트 개수는 균일(모두 3개)도, 정규분포(평균 근처에 몰림)도 아닙니다. 소수의 인기 아이템이 압도적으로 많고 나머지는 긴 꼬리로 급격히 적어집니다. 이 편중을 Zipf의 순위-빈도 형태에서 차용한 합성(synthetic) 분포로 재현합니다. + +```java +// FeedSeedFixture.skewedHighlightCount(i) +highlightCount(i) = max(1, round(500 / (i+1)^1.15)) // 상한 500, 하한 1 +``` + +Zipf의 법칙은 "순위 `r`인 항목의 빈도 ∝ `1/r^s`"이고 고전적 지프는 지수 `s=1`이라 1위가 2위의 두 배입니다. 저는 조금 더 가파르게 줄어들도록 `s=1.15`를 사용했습니다. 이때 1위는 2위의 `2^1.15≈2.2`배가 됩니다. 단어 빈도·도시 인구·웹페이지 조회 수 같은 heavy-tailed 편중이 이 계열입니다. 다만 이 분포가 실제 라이너 데이터와 같다고 주장하는 것은 아닙니다. "일부 페이지에 하이라이트가 매우 많을 수 있음"을 통제된 방식으로 재현하려고 만든 스트레스 분포입니다. `max(1, …)`로 바닥값을 두었으므로 전 구간이 순수한 멱법칙을 따르지는 않고 floor를 적용한 truncated Zipf-like 분포에 가깝습니다. + +공식을 대입한 순위별 실제 생성 개수(원본: [`evidence/metrics/l1-skew-distribution.csv`](./evidence/metrics/l1-skew-distribution.csv)): + +| 순위(rank) | 1 | 2 | 3 | 5 | 10 | 50 | 100 | 꼬리(≈150위~) | +|---|---|---|---|---|---|---|---|---| +| 하이라이트 수 | 500 | 225 | 141 | 79 | 35 | 6 | 3 | 1~2 | + + + +![균일분포, 정규분포, Zipf-like 합성 분포를 분포 형태와 극단적 소수, 스트레스 조건 재현 여부, 선택 결과로 나란히 비교한 도표.](assets/diagrams/skew-profile/skew-profile.svg) + +
+Diagram description + +왼쪽부터 균일분포, 정규분포, Zipf-like 합성 분포를 같은 네 기준으로 비교합니다. 균일분포는 모든 아이템이 3개이고, 정규분포는 평균 근처에 몰려 둘 다 극단적으로 많은 소수를 만들지 못하므로 제외됩니다. Zipf-like 분포는 소수의 인기 아이템이 압도적인 무거운 머리와 나머지의 긴 꼬리를 만들며, 지수 s=1.15와 상한 500·하한 1을 사용해 매우 많은 하이라이트 조건과 Top-N 필요성을 재현하는 합성 스트레스 분포로 선택됩니다. + +
+ +[Editable source](assets/diagrams/skew-profile/skew-profile.drawio) · [Grounded VizSpec](.techviz/skew-profile/spec.json) + + +왜 균일·정규분포가 아니라 편중 분포인가: +- 균일(모두 3개)이면 과제의 "페이지에 하이라이트가 아무리 많아도"라는 조건을 재현하지 못합니다. 머리(수백 개)가 만드는 전송량·메모리 압박도, 아이템별 최신 3개(Top-N)를 뽑아야 하는 필요성도 사라집니다. +- 정규분포는 평균 근처로 몰려 "극단적으로 많은 소수"가 없습니다. 역시 머리가 안 생깁니다. +- "무거운 머리 + 긴 꼬리"를 재현하는 방법은 여러 가지입니다(log-normal, negative binomial, Pareto, 경험적 히스토그램 등). 그중 Zipf-like 형태를 골랐습니다. 순위 기반이라 파라미터 하나(`s`)만 바꾸면 편중 강도를 조절할 수 있기 때문입니다. + +이 분포 때문에 하이라이트 총량은 N에 정비례하지 않습니다. N=10에서 이미 1,285개인데(0번 아이템 혼자 500개), N을 100배(1,000)로 키워도 2,917개에 그칩니다. 꼬리 아이템은 1개씩만 더할 뿐 머리가 총량을 지배하기 때문입니다. 반면 조회 수(`collectionFetches`)는 하이라이트 총량이 아니라 아이템 수 N에 정비례합니다. 이 대비가 6절의 핵심입니다. + +### 4.4 왜 이렇게 구성했는가 (설계 의도) + +- **하이라이트 Zipf-like 편중** → "매우 많은 하이라이트" 조건 + Top-N 필요성 재현. +- **User 공유 vs Page 전용** → 같은 즉시 로딩인데 조회 수가 갈리는 것을 데이터로 보입니다. User는 1차 캐시가 재조회를 걸러 distinct 유저 수(≤20)로 억제되고 Page는 아이템마다 달라 그대로 N번. 모두 유니크 유저였다면 이 대비가 사라집니다. "EAGER secondary SELECT 반복 횟수는 **fetch 방식 × distinct 연관 대상 수의 결합**으로 달라진다"는 핵심을 못 보입니다. +- **공개 범위 6:2:2** → 세 분기(public / mentioned / private)를 모두 충분히 포함하도록 설정한 합성 비율로, 이후 공개 범위 필터링·인덱싱 실험의 기반을 미리 심습니다. +- **시간 분산** → `first_highlighted_at` 정렬키를 만들어 시간순 페이징(keyset)·정렬 인덱스 실험 기반을 마련합니다. + +### 4.5 측정 규율 — 캐시와 통계가 결과를 왜곡하지 않게 + +- 같은 트랜잭션에서 조회를 반복하면 1차 캐시가 쿼리를 먹습니다. 지연 반복 루프는 **매 반복마다** 타이머를 켜기 전에 `em.clear()`를 호출합니다. 덕분에 (a) 매 호출이 실제로 DB를 때리고, (b) `clear()` 자체 비용은 측정 구간 밖에 놓입니다. 두 번째 반복부터 캐시가 조회량을 갉아먹어 값이 섞이는 오염이 없습니다. +- 쿼리 수는 `stats.clear()` 직후 딱 1회 실행분으로만 읽어 "회당 정확값"을 얻습니다. +- 지연은 쿼리 수와 분리해 별도로 반복 측정하고 앞의 몇 회는 JIT·커넥션 워밍업 구간으로 보고 버렸습니다. 그래도 이 값은 warm DB 캐시·동일 JVM·단일 스레드에서 잰 근삿값입니다. GC·JIT 영향이 남아 있으므로 절대값보다 N에 따른 증가 방향만 확인했습니다. 그래서 6.2절에도 `p50`·`p99`가 아니라 "median/max of 5"로 적었습니다. + +**한 데이터셋에 여러 변수가 섞여 있다는 한계.** 현재 데이터셋은 N을 키우면 반환 FeedItem 수·Highlight 총 행수·엔티티/DTO 생성량·DB 왕복이 동시에 늘어납니다. 따라서 지연의 원인을 어느 하나에만 돌릴 수 없습니다. 이후에는 변수를 하나씩 격리한 데이터셋으로 다시 검증할 계획입니다. 아래 A/B/C는 **아직 실행하지 않았으며 실행하기 전에는 수치를 채우지 않습니다**. + +| 격리 데이터셋 | 구성 | 격리하는 변수 | 상태 | +|---|---|---|---| +| **A** | FeedItem 10 / 100 / 1,000, Highlight는 FeedItem당 정확히 1개 | 왕복(부모 수)만 변화 → **N+1 왕복** 격리 | 예정 | +| **B** | FeedItem 20 고정, Highlight 1 / 10 / 100 / 500 | 행수(자식 수)만 변화 → **과조회** 격리 | 예정 | +| **C** | Zipf-like 편중 유지 | 머리(Top-N) 스트레스 재현 | 예정 | + +### 4.6 왜 DB 엔진마다 실행계획·인덱스가 다른가 + +앞서 4.1절에서 "인메모리 H2를 쓰지 않는다"의 근거로 "실행계획·인덱스 동작이 엔진마다 다르다"를 들었습니다. 왜 다른지를 짚습니다. 비용 기반 옵티마이저는 가능한 여러 계획의 비용을 추정해 가장 싼 것을 고릅니다. 그런데 그 추정값도, 애초에 고를 수 있는 선택지도 엔진마다 다릅니다. 네 축이 갈립니다. + +| 계획을 가르는 축 | PostgreSQL 16 (운영) | H2 (인메모리) | MySQL / InnoDB (대조) | +|---|---|---|---| +| **비용 모델** | 튜너블 상수로 I/O를 값매김 — `random_page_cost=4`·`seq_page_cost=1`이 랜덤 접근(인덱스)을 상대적으로 비싸게 잡고, `effective_cache_size`가 캐시 가정을 바꾼다 | 비용 기반이지만 훨씬 단순하고 상수 모델이 다르다 | 비용 기반이나 상수·추정 규칙이 또 다르다 | +| **통계** | `ANALYZE`가 MCV 목록·히스토그램·`n_distinct`·`correlation`을 수집해 선택도(selectivity)를 추정 | 수집 통계가 제한적 | 8.0+ 히스토그램·index dive | +| **저장·가시성** | heap + MVCC. 인덱스 스캔도 **가시성 맵**을 봐야 하고, 그래서 커버링 인덱스라도 벌크 로드 직후엔 index-only scan이 heap을 재방문한다 | 인메모리 구조라 PostgreSQL식 가시성 맵·heap 재방문 비용 구조가 없다 | 클러스터드 인덱스(PK 자체가 데이터) + undo. 2차 인덱스는 PK 재조회 | +| **인덱스 종류·기능** | B-tree/Hash/GiST/GIN/BRIN/SP-GiST, **부분 인덱스**·표현식 인덱스·`DESC`/`NULLS FIRST\|LAST` 정렬 인덱스 | 주로 B-tree/hash, 부분 인덱스 미지원 | B-tree 중심, 부분 인덱스 미지원·함수 인덱스 8.0+ | + +계획은 이 네 축의 함수입니다. 그래서 같은 쿼리·같은 데이터라도 엔진이 바뀌면 (a) Seq Scan ↔ Index Scan 선택이 뒤집히고, (b) 부분·표현식·정렬 인덱스처럼 한쪽에만 있는 접근 경로가 통째로 사라지며, (c) PostgreSQL 특유의 가시성 맵·index-only scan 미묘함이 재현되지 않습니다. 인메모리로 재서 나온 계획을 운영 PostgreSQL 계획으로 읽으면 이 세 지점에서 **체계적으로 틀린 결론**에 이릅니다. + +이건 추상적 우려가 아니라 이 문서 안에서 이미 두 번 부딪히는 축입니다. + +- **통계 의존** — 6.4절의 Plan A는 추정 `rows=1`과 실제 `rows=500`이 500배 차이 납니다. 대량 시드 직후 `ANALYZE`를 실행하지 않아 통계가 `feed_item_id`별 편중을 담지 못했다는 가설을 세웠고 Plan B에서 검증합니다. 통계를 수집하고 사용하는 방식이 엔진마다 다르므로 이 현상은 실제 엔진에서만 정확하게 관찰할 수 있습니다. +- **선택도 의존** — 8절은 "테이블이 작거나 조회 비율이 높으면 PostgreSQL이 Seq Scan을 고르는 게 더 빠를 수 있다"고 유보합니다. Seq↔Index 판정 자체가 비용 모델·선택도 추정의 산물이라 다른 엔진이면 다른 임계에서 갈립니다. +- **인덱스 기능 의존** — 이후 랩의 공개 범위 인덱싱·keyset 정렬(8절, OD-01의 `NULLS LAST` 처리)은 부분 인덱스·정렬 인덱스 기능에 기댑니다. 이 기능이 없는 엔진에서 실험하면 접근 경로 자체가 달라 결과가 무의미합니다. + +측정 대상이 **계획·인덱스 동작**인 이상 DB는 대체재가 아니라 측정 대상의 일부입니다. 그래서 운영과 같은 PostgreSQL을 사용했습니다. + +### 4.7 왜 전용 측정 도구 대신 내장 3종인가 + +4.1절에서 사용한 Hibernate `Statistics`·`System.nanoTime`·`EXPLAIN`은 모두 **이미 스택에 있는 도구**라 의존성을 더하지 않습니다. p6spy·datasource-proxy(정확한 SQL별 실행 수), JMH(엄밀한 지연 벤치), APM·프로파일러(종단 지연·플레임그래프) 같은 전용 도구도 후보였습니다. 다만 기준선 단계에서 확인하려던 것은 정밀한 지연이나 운영 처리량이 아니라 "쿼리 발생량이 N에 비례해 늘어나는가"라는 방향성이었습니다. 주장의 범위에 맞춰 내장 도구를 선택했습니다. + +| 측정 대상 | 쓴 도구 (내장·무의존) | 주는 것 / 한계 | 전용 대안 | 왜 지금 이걸로 충분한가 | +|---|---|---|---|---| +| **쿼리 발생 형태(N+1)** | Hibernate `Statistics` | 초기화 컬렉션 수·PreparedStatement 수. shape별 정확 SQL 수는 아님 | p6spy · datasource-proxy · QuickPerf `@ExpectSelect` | 필요한 건 성장 **형태**(≈`N`)뿐 → 무의존 카운터로 충분. 정확한 per-shape SQL이 필요해지는 단계(Batch Fetch로 "컬렉션 수 = SQL 수" 등식이 깨지는 지점)에서 도입한다고 6.1절에 이미 예고 | +| **지연** | `System.nanoTime` | 단일 스레드·warm 근사(방향성만) | JMH | 기준선 단계는 절대값·p99를 주장하지 않습니다. 게다가 지연 로딩을 재현하려면 **테스트 트랜잭션을 연 채 퍼시스턴스 슬라이스 안에서** 재야 하는데, 이는 격리 JVM·steady-state를 전제하는 JMH와 안 맞습니다. 도구 정밀도가 주장 강도를 넘으면 "이게 운영 수치"라는 오해를 부른다 | +| **실행계획** | `EXPLAIN (ANALYZE, BUFFERS)` | 운영 엔진이 실제로 고른 plan·buffers의 **원천** | APM · JFR · async-profiler | 엔진이 선택한 계획 자체가 필요하므로 native EXPLAIN을 사용했습니다. APM은 운영 관측에 더 적합합니다 | + +세 선택을 관통하는 원리는 셋입니다. + +1. **의존성 무추가** — 이 측정은 스켈레톤 모듈의 슬라이스 테스트 안에서 돕니다. 클래스패스에 이미 있는 것만으로 재현되면 "이 도구 깔고 이 설정 맞춰야 재현됨" 같은 장벽이 없습니다. +2. **정밀도 = 주장 강도.** 방향성만 확인하는 값에 JMH·APM의 엄밀도를 붙인다고 근거가 더 강해지지는 않습니다. 오히려 측정 데이터보다 정밀한 결론처럼 보일 수 있습니다. 같은 이유로 지연을 `p50`·`p99`가 아니라 "중앙값/최댓값(5회)"로 적었습니다. +3. **측정 지점의 제약이 도구를 고릅니다.** N+1은 열린 트랜잭션·지연 로딩에서만 결정적으로 재현되므로 측정은 그 지점 안에 있어야 합니다. HTTP 종단·격리 JVM을 전제하는 도구는 이 지점을 못 잡습니다. + +측정 질문이 바뀌면 도구도 그에 맞게 바꿉니다. 다음 단계에 필요한 도구는 아래처럼 정리했습니다. + +| 질문이 이렇게 바뀌면 | 승급할 도구 | +|---|---| +| shape별 정확한 SQL 실행 수가 필요 | p6spy · datasource-proxy · `StatementInspector` · PostgreSQL statement logging | +| 안정적 꼬리 지연(p99)이 필요 | warm-up 후 100회+ 반복·독립 세트, 또는 JMH | +| 운영 종단 지연·처리량·connection pool이 필요 | 부하 테스트 + APM | + +이 표의 아래 두 행은 문서 첫머리에서 "이 측정의 범위 밖"이라고 밝힌 항목입니다. 질문이 그 범위까지 넓어지면 그때 맞는 도구로 바꿉니다. + +--- + +## 5. 최초 구현과 첫 관찰 + +### 5.1 전략 — 엔티티 그래프를 로드하고 메모리에서 DTO로 매핑 + +처음에는 피드 아이템 엔티티를 조회한 뒤 Java Stream으로 순회하며 응답 DTO(`FeedSummary`)로 +필드를 옮겼습니다. 구현하기 쉽고 결과도 바로 확인할 수 있어서 기능적 기준선으로 삼았습니다. + +```java +@Override +public List loadFeed(int page, int size) { + return feedItem.findAllBy(PageRequest.of(Math.max(0, page), size <= 0 ? 20 : size)).stream() + .map(fi -> new FeedSummary( + fi.getId().toString(), + fi.getUser().getName(), fi.getUser().getUsername(), // ToOne (즉시 로딩) + fi.getPage().getUrl(), fi.getPage().getTitle(), // ToOne (즉시 로딩) + fi.getFirstHighlightedAt(), + fi.getHighlights().stream() // 컬렉션 (지연 로딩) + .map(h -> new FeedSummary.HighlightSummary(h.getColor(), h.getText(), h.getCreatedAt())) + .toList())) + .toList(); +} +``` + +### 5.2 조회 전략은 포트 뒤 어댑터의 책임 + +조회 전략을 바꾸더라도 웹·애플리케이션 계층까지 함께 바꾸고 싶지는 않았습니다. 상위 +계층에는 조회 사용자·페이지 크기·반환할 `FeedSummary`만 드러내고 구체적인 조회 방식은 +퍼시스턴스 어댑터에 두었습니다. 조회 경로는 `GET /feed` → `FeedController` → +`GetFeedUseCase` → `FeedQueryPort`이며 `FeedQueryAdapter`가 이 포트를 구현해 PostgreSQL을 +조회합니다. + + + +![GET /feed를 받는 FeedController에서 GetFeedUseCase와 FeedQueryPort로 이어지고 FeedQueryAdapter가 포트를 구현하는 포트·어댑터 구조.](assets/diagrams/query-port-boundary/query-port-boundary.svg) + +
+Diagram description + +왼쪽의 FeedController가 GET /feed 요청을 받아 중앙의 GetFeedUseCase에 조회를 위임합니다. 유스케이스는 오른쪽의 FeedQueryPort에 조회를 의존합니다. FeedQueryAdapter는 FeedQueryPort를 구현하는 아웃바운드 어댑터이며 PostgreSQL 조회를 수행합니다. Fetch Join, Batch Fetch, DTO Projection, 윈도우 함수 같은 구체 전략은 이 어댑터의 책임이므로 상위 계층은 전략 교체의 영향을 받지 않습니다. + +
+ +[Editable source](assets/diagrams/query-port-boundary/query-port-boundary.drawio) · [Grounded VizSpec](.techviz/query-port-boundary/spec.json) + + +Fetch Join, Batch Fetch, DTO Projection, 윈도우 함수 중 무엇을 쓰는지는 `FeedQueryPort` +구현의 책임입니다. 조회 전략을 교체해도 상위 계층은 바뀌지 않습니다. + +### 5.3 기준선이 의도한 범위에서는 정상이다 + +최초 구현에서는 FeedItem과 User·Page·Highlight를 응답 형태로 조립하는 **기본 조회 경로**만 +검증했습니다. 요청한 크기만큼 피드 아이템이 조회되고 각 아이템에 User·Page 정보와 Highlight +목록이 정확히 담기는지는 라운드트립 테스트로 확인했습니다. 이 범위에서는 의도한 대로 동작했습니다. + +하지만 이 단계는 아직 다음을 반영하지 않습니다. + +- 조회 사용자에 따른 공개 범위(public / mentioned / private) 판정 +- 피드 아이템별 최신 하이라이트 **최대 3개** 제한 +- mentioned 사용자 관계 +- 최종 커서(keyset) 페이징 + +이 단계는 전체 기능 요구사항의 완료본이 아니라 **조회 문제를 발견하기 위한 기능적 기준선**입니다. "정상"은 이 기준선이 의도한 범위에 한정된 말입니다. 다음 관심사는 NFR입니다. + +### 5.4 왜 추가 쿼리가 나가나 — EAGER는 "로딩 시점" 계약이지 JOIN 보장이 아니다 + +엔티티에는 fetch를 따로 명시하지 않았습니다. `@ManyToOne`은 즉시 로딩(EAGER), +`@OneToMany`는 지연 로딩(LAZY)이라는 JPA 기본값을 사용합니다. + +여기서 중요한 지점이 있습니다. `FetchType.EAGER`는 연관이 **반환 시점까지 로딩돼 있어야 한다**는 계약이지, 반드시 루트 SQL의 JOIN으로 가져오라는 의미가 아닙니다. + +- `findAllBy(...)`는 파생 쿼리입니다. **현재 Hibernate 기준선에서는** 루트(feed_items)를 먼저 조회한 뒤 EAGER ToOne 연관을 채웠습니다. 쿼리에서 fetch join하지 않은 연관이라 JOIN이 아니라 별도의 2차 SELECT였습니다. 루트를 가져온 다음에 user·page를 행마다 조회합니다. +- 단건 조회(`entityManager.find(id)`)에서는 Hibernate가 JOIN으로 가져오는 경우가 있지만 그건 provider·매핑·fetch profile에 달린 동작이지 일반적인 JPA 보장이 아닙니다. 리스트 파생 쿼리인 여기서는 2차 SELECT로 나갔습니다. "즉시 로딩이면 한 번에 가져오겠지"라는 착각이 깨지는 대목입니다. +- `highlights`는 지연 로딩이라 루트 조회 시엔 나가지 않다가 매핑 루프에서 `getHighlights()`에 접근하는 순간 그 아이템의 컬렉션을 1쿼리로 가져옵니다. 아이템마다 한 번씩입니다. + + + +![loadFeed 매핑, Hibernate, PostgreSQL 사이에서 루트 SELECT, EAGER user·page 2차 SELECT, getHighlights 접근, LAZY highlights SELECT가 차례로 일어나는 시퀀스.](assets/diagrams/eager-lazy-query-sequence/eager-lazy-query-sequence.svg) + +
+Diagram description + +세 참가자를 왼쪽부터 loadFeed DTO 매핑, Hibernate, PostgreSQL 순으로 읽습니다. loadFeed가 findAllBy 파생 쿼리를 호출하면 Hibernate가 PostgreSQL에서 feed_items를 먼저 조회합니다. 이어 fetch join되지 않은 EAGER user와 page를 별도의 2차 SELECT로 채우고, 반환 시점까지 로딩된 FeedItem을 loadFeed에 돌려줍니다. 이후 DTO 매핑이 getHighlights()에 접근하면 Hibernate가 해당 아이템의 highlights 컬렉션 SELECT를 실행합니다. + +
+ +[Editable source](assets/diagrams/eager-lazy-query-sequence/eager-lazy-query-sequence.drawio) · [Grounded VizSpec](.techviz/eager-lazy-query-sequence/spec.json) + + +--- + +## 6. 컬렉션 N+1 정량화 + +### 6.1 하이라이트 조회 수만 분리해 측정하기 + +기준선을 측정하자 count·User·Page·Highlight 쿼리가 한꺼번에 나왔습니다. 총계만으로는 어느 +연관이 문제인지 알기 어려웠습니다. 그래서 먼저 Hibernate의 `getCollectionFetchCount()`로 +하이라이트 조립 과정에서 발생한 조회 수를 분리했습니다. 다만 이 지표를 SQL 실행 횟수로 읽으면 +안 됩니다. + +- `getCollectionFetchCount()` = **초기화된 컬렉션 수**. "실행된 SELECT SQL 수"가 아닙니다. +- `getPrepareStatementCount()` = **획득한 PreparedStatement 수**. 이 값도 SQL 실행 수와 항상 같지는 않습니다. + +현재 기준선에는 batch/subselect가 없어 컬렉션 하나를 초기화할 때 SQL 하나가 나갑니다. 이 +조건에서만 "초기화된 컬렉션 수 N = highlights 자식 SELECT 수 N"이 성립합니다. +Batch Fetch를 적용하면 여러 컬렉션을 한 SQL로 채우므로 이 등식이 깨집니다. 두 지표의 이름을 +구분한 이유입니다. ToOne(User·Page) 조회 수는 총 PreparedStatement에서 content 1건, +페이지 count 1건, highlights 컬렉션 N건을 빼서 계산했습니다. + +### 6.2 실측 — 조회량이 N에 정확히 비례한다 + +먼저 N이 무엇을 뜻하는지 정리했습니다. N은 전체 테이블 크기가 아니라 **한 요청에서 반환한 +FeedItem 수**입니다. 이 랩에서는 `seed(N)` 뒤에 `loadFeed(0, N)`을 호출해 데이터셋 크기와 +page size를 모두 N으로 맞췄습니다. 아래 표의 N은 "한 페이지 요청이 조립하는 부모 엔티티 +수"를 뜻합니다. + +**측정값(직접 측정).** 초기화 컬렉션 수·총 PreparedStatement는 Hibernate `Statistics`, 지연은 `System.nanoTime`, 시드 하이라이트는 시더 콘솔에서 그대로 읽은 값입니다. + +| N | 초기화 Highlight 컬렉션 | 총 PreparedStatement | 지연 중앙값(5회) | 지연 최댓값(5회) | 시드 하이라이트 | +|---:|---:|---:|---:|---:|---:| +| 10 | **10** | 25 | 32.8 ms | 36.1 ms | 1,285 | +| 100 | **100** | 222 | 85.9 ms | 108.3 ms | 1,961 | +| 1,000 | **1,000** | 2,022 | 193.7 ms | 238.4 ms | 2,917 | + +**파생값(분해).** 총 PreparedStatement를 SQL shape별로 가른 값입니다. 직접 측정이 아니라 시더 카디널리티 + 총계 + Spring Data count 생략 규칙으로 역산했습니다. 측정값과 섞어 읽지 않도록 성격과 증거를 함께 표기합니다. + +| 지표 | N=10 | N=100 | N=1,000 | 성격 | 증거 | +|---|---:|---:|---:|---|---| +| content | 1 | 1 | 1 | 파생 | 목록 루트 쿼리 1건(구조상 고정) | +| count | 1 | 1 | 1 | 파생 | `Page` 반환 → Spring Data count 규칙(아래) | +| distinct User SELECT | 3 | 20 | 20 | 파생 | 시더 `users=max(3,min(20,N/5+1))` + 1차 캐시 중복 제거 | +| Page SELECT | 10 | 100 | 1,000 | 파생 | 시더 `pages=N`(1:1), 아이템마다 달라 N번 | +| **ToOne(User+Page) 조회 수** | **13** | **120** | **1,020** | 파생 | 총계 − content − count − 컬렉션 N | + +```text +총 PreparedStatement += content 1 ++ count 1 ← Spring Data Page 반환의 전체 건수 count ++ distinct User targets ← ToOne, 1차 캐시로 중복 제거되어 distinct 수만큼 ++ N Page ← ToOne, 아이템마다 달라 N번 ++ N Highlight 컬렉션 ← 지연 로딩 컬렉션 초기화 +``` + +검산: `1 + 1 + 3 + 10 + 10 = 25` · `1 + 1 + 20 + 100 + 100 = 222` · `1 + 1 + 20 + 1000 + 1000 = 2022` ✓ + +**count 쿼리는 왜 나올까요?** `findAllBy(Pageable)`가 `Page`을 반환하기 때문입니다. +Spring Data는 전체 페이지 수를 알려주려고 `select count(...)`를 한 번 더 실행합니다. 다만 +`offset==0`이고 `pageSize > 반환 건수`이면 count를 건너뜁니다. 라운드트립 스모크는 1건을 +pageSize 10으로 조회해 이 조건에 들어갔고 count가 생략되어 총 4건이 나왔습니다. 반면 위 +측정은 `pageSize == 반환 건수(N)`라 count가 실제로 실행됩니다. 그래서 25 / 222 / 2,022에 +각각 count 1건이 포함되어 있습니다. + +> 이 count는 이후 페이징 전략의 결정 포인트이기도 합니다. 최종 피드가 전체 페이지 수를 요구하지 않는다면 `Page` 대신 `Slice`나 커서 결과로 바꿔 count 쿼리를 없앨 수 있습니다. + +지연은 `latencyMicros(n, 7, 2)`로 7회 반복하고 앞의 2회를 워밍업으로 버린 뒤 남은 **5개 +표본의 중앙값과 최댓값**을 기록했습니다. 표본이 5개뿐이어서 `p50`·`p99`라고 부르지 않았습니다. +실제 코드의 p99 인덱스도 5개 중 최댓값을 가리킵니다. 안정적인 꼬리 지연을 말하려면 warm-up 후 +100회 이상 측정한 독립 세트가 여러 개 필요합니다. 여기서는 꼬리 지연이 아니라 N에 따른 왕복 +증가를 확인하려는 목적에 맞춰 측정 범위를 제한했습니다. + +세 조회 지표 모두 N을 따라 직선으로 증가합니다. 특히 하이라이트 컬렉션 초기화는 기울기 1의 직선(`= N`)이라 "조회량이 N에 정비례"함이 한눈에 드러납니다. + + + +![FeedItem N개를 반환하는 loadFeed 요청이 컬렉션 초기화 N회와 Highlight SELECT N회로 이어지는 인과 흐름도.](assets/diagrams/nplus1-query-fanout/nplus1-query-fanout.svg) + +
+Diagram description + +왼쪽의 loadFeed 요청은 한 페이지에서 N개의 FeedItem을 반환합니다. 매핑이 각 부모의 Highlight 컬렉션에 접근하면 컬렉션 초기화가 부모마다 한 번씩 일어나 총 N회가 됩니다. 현재 기준선에서는 배치나 서브셀렉트가 없어 초기화 한 번마다 Highlight SELECT도 한 번 실행되므로 추가 조회가 N회 발생합니다. 각 SELECT는 해당 부모의 Highlight 자식 행을 전부 읽습니다. + +
+ +[Editable source](assets/diagrams/nplus1-query-fanout/nplus1-query-fanout.drawio) · [Grounded VizSpec](.techviz/nplus1-query-fanout/spec.json) + + +이 관찰은 서로 다른 두 위반을 동시에 드러냅니다. "하이라이트 수와 무관한 조회량"이라는 요구가 깨지는데 깨지는 방식이 하나가 아닙니다. + +- **N+1(왕복).** `collectionFetches = N`은 한 요청에서 반환하는 FeedItem(부모) 수에 비례해 + 늘었습니다. Highlight 수가 아니라 아이템마다 컬렉션을 한 번씩 초기화하기 때문에 부모 수만큼 + DB를 왕복합니다. +- **과조회(행수).** 한 번의 왕복에서는 해당 FeedItem의 Highlight를 **전부** 읽어 옵니다. 가장 + 많은 아이템은 최대 500행입니다. 반환 행수·전송량·엔티티 생성은 자식 수에 비례해 + 늘어납니다. + +부모 수에 따른 왕복 증가와 자식 수에 따른 과조회가 **같은 기준선에 동시에** 존재합니다. + +**"page size를 20으로 고정하면 N+1도 20으로 고정 아닌가?"** 맞습니다. 한 요청의 왕복 수는 page size에 묶입니다. 그러나 그 요청당 20회 왕복이 트래픽에 곱해집니다. + +```text +추가 Highlight SELECT/초 ≈ page size × RPS +예) page size 20 × 1,000 RPS ≈ 초당 20,000 자식 SELECT +``` + +그래서 N+1의 비용은 "한 요청 안에서 얼마나 크냐"가 아니라 "요청마다 반복되는 왕복이 처리량에 곱해질 때" 드러납니다. + +이 측정으로 확인한 N+1의 증가 기준은 전체 테이블 크기가 아니라 **한 요청에서 조립하는 부모 +엔티티 수**였습니다. 피드 테이블이 100만 행이어도 이 왕복 수 자체는 늘지 않습니다. 대신 전체 +테이블 크기는 OFFSET·정렬·가시성 필터 비용에 영향을 줍니다. 이 비용은 별도 축으로 분리해 +keyset 페이징(14절)과 가시성 조건(15절)에서 측정했습니다. + +### 6.3 조회 증가 폭은 fetch 방식과 연관 데이터 수가 함께 결정한다 + +총 PreparedStatement(25 / 222 / 2,022)에서 content 1건·count 1건·highlights 컬렉션 N건을 +빼자 ToOne(User+Page) 조회 수 **13 / 120 / 1,020**이 남았습니다. 이전에 적었던 14 / 121 / +1,021에는 페이지 count 1건이 섞여 있었습니다. 이 값을 User와 Page로 다시 나누자 두 연관이 +정반대로 늘어났습니다. + +| 연관 | 데이터 분포 | 1차 캐시로 걸러지나 | N=10 / 100 / 1,000 조회 수 | +|---|---|---|---| +| **User** (EAGER ToOne) | 소수 풀 재사용(≤20명) | 그렇다 (공유되니 걸러짐) | 3 / 20 / 20 | +| **Page** (EAGER ToOne) | 아이템당 1개(전부 다름) | 아니다 | 10 / 100 / 1,000 | +| **highlights** (지연 로딩 컬렉션) | 아이템당 컬렉션 | — (아이템마다 1회) | 10 / 100 / 1,000 | + +EAGER의 secondary SELECT 구조가 추가 조회의 가능성을 만듭니다. 실제로 몇 번 실행되는지는 Persistence Context 안에서 **서로 다른 연관 대상(distinct target)이 몇 개인지**가 정합니다. 같은 `@ManyToOne(EAGER)`라도 User는 distinct 대상 ≤20개 → 약 20회, Page는 distinct 대상 N개 → N회로 갈립니다. "즉시 로딩 하나 붙였을 뿐인데 왜 어떤 건 터지고 어떤 건 안 터지나"의 답은 애너테이션 하나가 아니라 fetch 방식 × distinct 카디널리티의 곱에 있습니다. + +### 6.4 각 조회는 "빠르다" — 그런데도 느리다 + +반복되는 하이라이트 조회 하나를 실행계획으로 확인했습니다. 아래는 **Plan A — 대량 시드 직후, +`ANALYZE` 실행 전**의 계획입니다(원문: [`evidence/explain/highlights-child-plan-A.txt`](./evidence/explain/highlights-child-plan-A.txt)). + +```text +Index Scan using ix_highlights_feed_items_created on highlights + (cost=0.27..8.29 rows=1 width=710) (actual time=0.026..0.123 rows=500 loops=1) + Index Cond: (feed_item_id = '2b5b931f-...'::uuid) + Buffers: shared hit=14 +Planning Time: 0.086 ms +Execution Time: 0.173 ms +``` + +개별 하이라이트 조회는 `feed_item_id` 탐색을 인덱스로 처리하고(Index Scan) 0.173 ms로 빠릅니다. 그런데 이 빠른 쿼리가 N번 반복됩니다. N=1,000이면 피드 한 번 로딩이 194 ms로 커집니다. 이 문제는 "쿼리가 느려서"가 아니라 "빠른 쿼리를 N번 왕복해서" 생깁니다. + +다만 이 실행계획을 "이미 최적"이라고 결론지으면 안 됩니다. 최종 요구사항 관점에서 두 문제가 함께 있습니다. 6.2절의 두 위반과 같은 짝입니다. + +- **반복 왕복**: 같은 자식 쿼리가 FeedItem마다 반복됩니다. 현재 ORM fetch plan의 문제이므로 + 인덱스로는 풀 수 없고 왕복 횟수 자체를 줄여야 합니다. +- **컬렉션 과조회**: 이 쿼리는 `SELECT * FROM highlights WHERE feed_item_id = ?`라 한 번에 최대 500행을 읽어 옵니다. 응답에 필요한 건 최신 3개뿐인데 `ORDER BY created_at DESC LIMIT 3`가 없어 결과량을 제한하지 못합니다. 이건 SQL shape와 인덱스 설계까지 함께 풀어야 합니다. + +정확히는 "**N회 반복의 원인은 fetch plan에 있지만, 최종 Top-3 조회 비용은 SQL shape·인덱스까지 함께 해결해야 한다**"가 맞습니다. + +Plan A만으로 결론을 내리지는 않았습니다. Plan A에서 추정한 `rows=1`과 실제 `rows=500`은 +500배 차이가 납니다. 대량 시드 직후 `ANALYZE`를 실행하지 않아 통계가 `feed_item_id`별 편중을 +반영하지 못했다는 가설을 세웠습니다. 이 가설은 `ANALYZE highlights` 뒤에 Plan B를 다시 측정해 +검증할 예정입니다. 아직 실행하지 않았으므로 Plan B 열은 비워 두었습니다. + +| 항목 | Plan A (현재, `ANALYZE` 전) | Plan B (`ANALYZE highlights` 후) | +|---|---|---| +| 추정 rows | 1 | 예정 | +| 실제 rows | 500 | 예정 | +| 스캔 방식 | Index Scan (`ix_highlights_feed_items_created`) | 예정 | +| Buffers | `shared hit=14, read=0` (warm) | 예정 | +| Execution Time | 0.173 ms | 예정 | + +EXPLAIN 수치를 읽을 때 주의할 두 가지가 더 있습니다. + +- **warm cache**: `Buffers: shared hit=14, read=0`은 warm buffer cache 결과라 디스크 I/O가 낀 cold 실행시간으로 읽으면 안 됩니다. +- **0.173 ms를 194 ms와 합산·비교 금지**: `Execution Time`은 PostgreSQL executor 내부 시간에 가깝고 ORM 엔티티 생성·JDBC 결과 전달·DTO 매핑·직렬화·HTTP를 포함하지 않습니다. 애플리케이션 지연과 같은 지표가 아닙니다. + +### 6.5 코드에 루프가 없는데 왜 N+1인가 + +`loadFeed`에는 하이라이트를 위한 명시적인 `for`가 없고 `getHighlights().stream()`만 있습니다. +처음에는 이 코드만 보고 조회가 N번 나간다고 알아차리기 어려웠습니다. 하지만 지연 로딩 컬렉션은 +접근하는 순간 조회하므로 아이템이 N개면 접근과 조회도 N번 발생합니다. 반복문이 없어진 것이 아니라 +스트림 뒤에 숨은 셈입니다. + +--- + +## 7. User·Page 연관 숨은 추가 쿼리 정량화 + +앞 절에서 highlights 조립에 해당하는 조회 수를 분리했지만 총 PreparedStatement에는 여전히 User·Page +연관 조회가 남았습니다. 6.3절에서는 시더 카디널리티로 13 / 120 / 1,020이라는 값을 역산했습니다. +이번에는 같은 `loadFeed`를 두고 엔티티별 fetch 통계를 직접 읽어 이 예측을 확인했습니다. 코드를 +새로 만든 것은 아니며 측정 지표만 바꿨습니다. + +### 7.1 ToOne 조회 수를 엔티티 fetch 통계로 확인한다 + +컬렉션 조회는 `getCollectionFetchCount()`로 분리했습니다. ToOne 조회는 Hibernate가 제공하는 +다음 두 지표로 나누었습니다. + +- `getEntityFetchCount()` = **2차 SELECT로 로드된 엔티티 인스턴스 수**(User + Page 합). +- `getEntityStatistics(PageJpaEntity.class.getName()).getFetchCount()` / `…UserJpaEntity…` = **엔티티별** fetch 수. + +6.3절의 User/Page 값은 "총계 − content − count − 컬렉션 N"으로 역산한 **파생값**이었습니다. +이번에는 Hibernate 통계에서 직접 읽은 값과 같은지 확인했습니다. + +> 지표 이름을 정확히 읽어야 합니다. `getEntityFetchCount()`는 "실행된 SELECT SQL 수"가 아니라 +> **2차 fetch로 초기화된 엔티티 수**입니다. Hibernate 버전에 따라 합계의 집계 범위가 달라질 수 +> 있어 회귀 가드는 시더 카디널리티와 무관하게 성립하는 **`pageFetch == N`(엔티티별)** 으로 +> 고정하고 합계는 회계 항등식으로 교차 검증했습니다. + +### 7.2 실측 — 같은 `@ManyToOne(EAGER)`가 정반대 곡선을 그린다 + +**측정값(직접 측정).** 아래는 `getEntityStatistics(...).getFetchCount()`와 `getEntityFetchCount()`가 낸 값입니다. 앞서 6.3절에서 역산한 파생값과 정확히 일치합니다. + +| N | Page fetch(★선형) | User fetch(평탄) | ToOne 합(`entityFetch`) | 초기화 컬렉션 | 총 PreparedStatement | +|---:|---:|---:|---:|---:|---:| +| 10 | **10** | 3 | 13 | 10 | 25 | +| 100 | **100** | 20 | 120 | 100 | 222 | +| 1,000 | **1,000** | 20 | 1,020 | 1,000 | 2,022 | + +성격: 측정값(직접) — 출처 `FeedPersistenceIT.l2ToOneEagerHiddenNPlusOneCurve`(콘솔 `>>> LAB L2 [eager toOne curve …]`, 리포트 `app-bootstrap/build/lab-results/feed-nplus1.md`). 원본: [`evidence/metrics/l2-toone-split.csv`](./evidence/metrics/l2-toone-split.csv). + +검산(6.3절 파생과 일치): `entityFetch = pageFetch + userFetch` → `10+3=13` · `100+20=120` · `1000+20=1020` ✓. 회계 항등식으로도 `총 PreparedStatement − 컬렉션 N − content(1) − count(1) = entityFetch` → `25−10−2=13` · `222−100−2=120` · `2022−1000−2=1020` ✓. 앞서 6.3절에서 역산했던 13 / 120 / 1,020을 직접 측정이 그대로 재현했습니다 — 파생 예측이 실측으로 확정됐습니다. + +같은 `@ManyToOne(EAGER)`인데도 Page fetch는 N을 따라 10 → 100 → 1,000으로 늘고 User +fetch는 20에서 멈췄습니다. Page는 아이템마다 달라 정확히 N번 조회되지만 User는 소수 풀을 +재사용하고 한 번 로드한 대상이 1차 캐시에 남기 때문입니다. 즉 N+1이 생길 가능성은 EAGER라는 +코드에서 나오지만 실제 증가 폭은 연관 데이터의 카디널리티에 따라 달라집니다. + +> 지연은 6.2절과 **같은 `loadFeed` 호출**을 잰 것이므로 별도 지연 축이 아닙니다. N2는 그 한 번의 조회가 만드는 왕복을 fetch 종류별로 분해했을 뿐, 새로운 지연을 만들지 않습니다. + +### 7.3 필드에 접근하지 않아도 ToOne 쿼리가 발생한다 + +6.5절에서는 지연 로딩이 `stream()` 뒤에 반복을 감춘 모습을 확인했습니다. ToOne은 필드에 접근하지 +않아도 조회된다는 점이 달랐습니다. 이를 확인하려고 `loadFeed` 대신 아무것도 매핑하지 않는 순수 +JPQL로 `feed_items`만 조회하고 `getUser()`·`getPage()`·`getHighlights()`는 **한 번도 +호출하지 않았습니다**. + +**측정값(직접 측정).** 출처 `FeedPersistenceIT.l2EagerToOneFiresEvenWithZeroFieldAccess`(seed 100, 접근 0회). + +| 접근 | 연관 | fetch 계약 | 접근 0에서 fetch 수 | +|---|---|---|---:| +| 0회 | Page | `@ManyToOne` (EAGER) | **100** (= N) | +| 0회 | User | `@ManyToOne` (EAGER) | 20 (풀 dedup) | +| 0회 | highlights | `@OneToMany` (LAZY) | **0** | + +아무 필드도 읽지 않았는데 Page 2차 SELECT가 N번 나왔습니다. 제가 조회 코드를 작성하지 않았는데도 +EAGER 기본값 때문에 생긴 N+1이었습니다. 같은 조건에서 LAZY 컬렉션은 접근하지 않았으므로 0이었습니다. +이 테스트로 EAGER는 사용 여부와 관계없이 미리 로딩하고 LAZY는 접근할 때 로딩한다는 +차이를 확인했습니다. + +### 7.4 같은 실행계획, 정반대 비용 — 반복되는 ToOne 부모 쿼리 + +6.4절에서 자식 컬렉션 쿼리를 확인한 것처럼, 이번에는 N2를 만드는 **반복되는 ToOne 부모 쿼리** +(`SELECT * FROM pages WHERE id = ?`, `… FROM users WHERE id = ?`)를 실행계획으로 +확인했습니다. 아래는 seed(100) 직후의 계획입니다(원문: [`evidence/explain/toone-pages-plan.txt`](./evidence/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/explain/toone-users-plan.txt)). + +```text +-- pages +Index Scan using pk_pages on pages + (cost=0.14..8.15 rows=1 width=2104) (actual time=0.009..0.009 rows=1 loops=1) + Buffers: shared hit=2 Execution Time: 0.021 ms +-- users +Index Scan using pk_users on users + (cost=0.14..8.15 rows=1 width=2104) (actual time=0.013..0.014 rows=1 loops=1) + Buffers: shared hit=2 Execution Time: 0.022 ms +``` + +`WHERE id = ?`는 PK 조회라 두 쿼리 모두 pk Index Scan으로 1건을 약 0.02 ms에 가져옵니다. +개별 쿼리는 빨랐지만 Page 쿼리는 이 빠른 실행계획을 **N번 반복**했습니다. + +pages와 users의 실행계획은 둘 다 pk Index Scan이고 실행시간도 약 0.02 ms로 거의 같습니다. +그런데 7.2절의 증가 곡선은 정반대였습니다. 비용을 가른 것은 실행계획이 아니라 반복 횟수였습니다. +Page는 N번, User는 서로 다른 대상 수인 최대 20번 반복됩니다. 단건 계획은 이미 Index Scan이므로 +인덱스를 더하는 것으로는 해결되지 않습니다. 9절부터 왕복 횟수를 줄이는 fetch 전략을 시도합니다. +warm cache와 executor 시간에 관한 한계는 6.4절과 같습니다. + +### 7.5 루프와 필드 접근 없이 N+1이 생기는 이유 + +`@ManyToOne`은 fetch를 명시하지 않으면 EAGER가 기본값입니다. 파생 쿼리인 +`findAllBy`는 EAGER 연관을 루트 SQL의 JOIN으로 자동 병합하지 않고 **행마다 2차 SELECT**로 +채웠습니다. 그래서 `getUser()`·`getPage()`를 읽기 전부터 조회가 나갔습니다. 코드에 루프나 +접근이 없어서 표면에 보이지 않았습니다. Page와 User는 카디널리티가 달라 증가 폭도 다르게 나타났습니다. + +fetch 계약(EAGER/LAZY)과 실제 사용(접근/미접근)을 교차하면 EAGER의 죄가 정확히 어디인지 드러납니다. + +| | 접근 안 함 | 접근함(`loadFeed`) | +|---|---|---| +| **EAGER**(현재 User·Page) | 나간다 — **낭비**(안 짠 N+1) | 나간다 (즉시 로딩 N+1) | +| **LAZY**(가정) | 안 나간다 | 나간다 (지연 로딩 N+1) — timing만 다름 | + +`loadFeed`는 매핑 과정에서 user·page를 실제로 사용합니다. EAGER를 LAZY로 바꿔도 조회 +시점만 달라질 뿐 N+1은 다시 생깁니다. 이 문제를 fetch **타입** 변경만으로 풀 수 없다고 판단했습니다. +대신 Fetch Join, Batch Fetch, DTO Projection처럼 왕복과 적재 방식을 바꾸는 fetch **전략**을 +차례로 시도했습니다. + +--- + +## 8. 확인된 문제와 이후 검증할 가설 + +여기까지 측정하고 나니 문제를 두 축으로 나눌 필요가 있었습니다. 연관 조회 폭증은 수치로 확인했지만 +기준 쿼리의 Seq Scan + Sort는 아직 병목이라고 단정할 수 없었습니다. 그래서 확인된 문제와 +검증할 가설을 다음처럼 분리했습니다. + +| | 축 A — **연관 조회 폭증(N+1)** · 확인됨 | 축 B — **기준 쿼리 Seq Scan + Sort** · 가설 | +|---|---|---| +| 관찰 | 쿼리 수가 `1 + count + distinct(user) + N + N` (실측) | 목록 쿼리 한 방이 Seq Scan + Sort | +| 원인 | **fetch 전략** (EAGER 2차 SELECT / 지연 컬렉션) | 정렬 인덱스가 이 쿼리에 안 걸림(아래) | +| 해법 축 | fetch join / batch / DTO 프로젝션 | 정렬에 맞는 인덱스 / keyset | + +피드는 시간순 정렬이 필요하므로 목록 쿼리에 `ORDER BY first_highlighted_at DESC, id`가 붙습니다. 스키마에 `ix_feed_items_visibility_sort (visibility, first_highlighted_at DESC, id)`가 있긴 하지만 이 기준 쿼리에는 `visibility =` 필터가 없습니다. 인덱스의 **선두 컬럼(visibility)이 맞물리지 않으니** 정렬에도 쓰이지 못합니다. 그래서 "인덱스 부재"가 아니라 "이 filterless 쿼리에 맞는 정렬 인덱스가 없음"이 정확한 진단입니다. + +다만 Seq Scan 자체를 곧바로 문제로 판정하지는 않습니다. 테이블이 작거나 조회 비율이 높으면 PostgreSQL이 Seq Scan을 고르는 게 더 빠를 수 있습니다. N=1,000은 인덱스 효과를 판단하기엔 작습니다. 이 계획이 실제 병목인지는 이후 keyset 페이징 랩에서 검증합니다. 피드 규모(N=1k~1M)와 페이지 깊이(OFFSET)를 키우며 정렬 인덱스 유무에 따른 `rows`·`buffers`·sort spill·execution time을 대조하는 방식입니다. + +두 축은 해결 방법도 다릅니다. 축 A(N+1)는 fetch 전략 문제라 인덱스로 풀리지 않고, 축 B(정렬)는 +인덱스·쿼리 문제라 fetch join으로 풀리지 않습니다. 이후 단계에서는 두 축을 분리해 검증했습니다. + +--- + +## 9. Fetch Join을 적용하며 확인한 두 가지 문제 + +컬렉션 N+1과 User·Page의 숨은 쿼리를 확인한 뒤에는 "나누어 가져오지 말고 한 번에 가져오면 +되지 않을까"라고 생각했습니다. 그래서 user·page·highlights·mentions를 모두 `join fetch`로 +루트 SQL에 합쳐 보았습니다. 결과는 두 가지 실패였습니다. 컬렉션 두 개를 동시에 fetch join하자 +`MultipleBagFetchException`이 발생했습니다. 하나만 합치자 부모와 자식의 곱만큼 전송 행이 +늘었습니다. 쿼리 수는 줄었지만 전송량이 커졌으므로 이 단계부터는 쿼리 수뿐 아니라 전송 행수도 +함께 측정했습니다. + +> **이 절에는 제가 fetch join을 직접 적용했다가 실패한 과정이 담겨 있습니다.** `.distinct()`· +> `List→Set`·`@BatchSize`로 바로 우회하지 않고 실패를 별도 테스트에 남겼습니다. 그래야 +> fetch join이 만든 페이징 문제와 그다음 Batch Fetch 선택까지 이어서 확인할 수 있기 때문입니다. + +### 9.1 두 번째 컬렉션(mentions)을 퍼시스턴스에만 최소로 붙인다 + +`MultipleBagFetchException`을 재현하려면 컬렉션이 **둘 이상** 필요했습니다. 기준선 스키마에는 +`highlights`만 있었으므로 목표 스키마의 `feed_item_mentions`를 이 단계에서 먼저 추가했습니다. +다만 지금 필요한 것은 fetch join할 두 번째 bag뿐이어서 범위를 퍼시스턴스 계층까지로 +제한했습니다. 추가한 코드는 마이그레이션(`V7__feed_mentions.sql`), 경량 자식 엔티티 +`FeedItemMentionJpaEntity`, 부모의 `@OneToMany List<…> mentions`, 시더입니다. +도메인 애그리거트·응답 매핑·공개 범위 판정은 공개 범위 단계까지 미뤘습니다. + +> **기존 측정은 바뀌지 않았습니다.** `mentions`는 `@OneToMany` 기본 LAZY이고 `loadFeed`와 +> 7.3절의 접근 0 테스트도 `getMentions()`를 호출하지 않습니다. 6·7절의 테스트를 다시 실행해 +> `collectionFetches == N`, 접근 0에서 `== 0`, `pageFetch == N`이 그대로 유지되는지 확인했습니다. + +목표 스키마의 `feed_item_mentions`는 복합 PK `(feed_item_id, mentioned_user_id)`지만 이 +랩에서는 `@OneToMany List` bag 매핑을 단순하게 만들려고 **대리키(id) + +`UNIQUE(feed_item_id, mentioned_user_id)`**로 구현했습니다. 유일성은 그대로 보장됩니다. +시더는 `MENTIONED` 아이템에만 사용자를 연결합니다. 사용자 풀보다 많이 넣어 UNIQUE 제약을 +어기지 않도록 `min(2+i%4, poolSize)`로 상한을 두었습니다. + +### 9.2 실패 ① 두 컬렉션 동시 fetch join → `MultipleBagFetchException` + +bag은 순서 컬럼(`@OrderColumn`)이 없는 `List`입니다. `highlights`와 `mentions`가 모두 +bag인 상태에서 두 컬렉션을 fetch join하면 feed_item 한 행이 highlights h개 × mentions m개, +즉 **h×m 행**으로 늘어납니다. Hibernate는 이 곱집합을 안전하게 원래 컬렉션으로 되돌릴 수 없다고 +판단해 쿼리 생성(createQuery) 시점에 예외를 던집니다. 데이터가 0건이어도 발생하는 매핑 +단계의 거부입니다. + +```java +// 착상: "연관 전부 fetch join" — 컬렉션 둘을 동시에 +select distinct f from FeedItemJpaEntity f + join fetch f.highlights + join fetch f.mentions +``` + +**측정값(직접 측정).** 출처 `FeedPersistenceIT.l3TwoBagFetchJoinThrowsMultipleBagFetchException`. 예외 원인 체인(콘솔 원문): + +```text +java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException +``` + +실제로 실행해 보니 `MultipleBagFetchException`은 **`IllegalArgumentException`으로 감싸져** +나왔습니다(FQN은 `org.hibernate.loader.MultipleBagFetchException`). 테스트를 +`hasCauseInstanceOf(MultipleBagFetchException.class)`에만 맞추면 래핑 계층이나 버전 차이에 +취약합니다. 이 테스트에서는 원인 체인을 클래스명 문자열로 펼친 뒤 `contains("MultipleBagFetchException")` +으로 확인했습니다(Hibernate ORM 7.1.8 기준). + +### 9.3 실패 ② 컬렉션 하나만 fetch join → 카테시안으로 전송 행수 증가 + +컬렉션을 하나만(`highlights`) fetch join하면 예외는 나지 않지만 +`feed_items ⋈ highlights`가 부모를 자식 수만큼 반복한 행을 만듭니다. 쿼리 수가 +아니라 DB가 애플리케이션에 전달한 **조인 행수**를 측정한 이유입니다. + +> **⚠ 측정 정정(Hibernate 6+/7)** — 처음에는 "`distinct` 없는 결과 리스트 크기 = Σ +> highlights(전송 행수)"라고 예상했습니다. 하지만 결과 리스트 크기는 **N**(10/100/1000)이었습니다. +> Hibernate 6+가 fetch join의 **루트 엔티티를 자동으로 중복 제거**하기 때문입니다. 카테시안은 +> SQL과 전송 단계에 그대로 남아 있으므로 리스트 크기 대신 실제 조인 카디널리티 +> `SELECT count(*) FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id`를 +> 측정했습니다. 이 문제는 EXPLAIN actual rows나 조인 count로 확인해야 합니다. + +**측정값(직접 측정).** 출처 `FeedPersistenceIT.l3SingleCollectionFetchJoinExplodesTransferredRows`(N=10/100/1000). 원본: [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv). + +| N | 전송 행수(★조인 카디널리티) | 리스트 크기(Hib6 dedup) | distinct 아이템 | 시드 하이라이트 | 폭발 배수 | 총 PreparedStatement | +|---:|---:|---:|---:|---:|---:|---:| +| 10 | **1,285** | 10 | 10 | 1,285 | 128.5× | 14 | +| 100 | **1,961** | 100 | 100 | 1,961 | 19.6× | 121 | +| 1,000 | **2,917** | 1,000 | 1,000 | 2,917 | 2.9× | 1,021 | + +전송 행수는 항상 아이템 수 N보다 많았고 4.3절의 시드 하이라이트 총량과 정확히 일치했습니다. +조인이 모든 자식 행을 부모에 붙여 전송했기 때문입니다. Zipf 분포에서 뒤쪽 아이템은 highlight가 +한 개뿐이라 폭발 배수는 128.5× → 19.6× → 2.9×로 줄었지만 절대 전송 행수는 계속 +Σ highlights였습니다. 제가 원한 것은 N개 아이템이었지만 DB가 전달한 것은 모든 highlight +행이었습니다. + +### 9.4 쿼리 수만 보면 개선처럼 보인다 + +같은 N=100 데이터에서 기준선 `loadFeed`는 PreparedStatement가 222개였고 highlights를 +fetch join한 쿼리는 **121개**였습니다. 쿼리 수만 보면 개선처럼 보였기 때문에 항목별로 +다시 나눠 보았습니다. + +| 구분 | 기준선 loadFeed | highlights fetch join | 결과 | +|---|---:|---:|---| +| 목록 루트 | 1 (content) | 1 (join) | 루트가 조인 한 방으로 바뀜 | +| Page count | 1 | 0 | 이 랩은 `Pageable`이 아닌 원시 JPQL이라 Spring Data count 없음 | +| highlights 컬렉션 | **100** | **0** | ★ N개 컬렉션 SELECT가 조인으로 **접힘**(N1 사라짐) | +| ToOne(User+Page) | 120 | **120** | ★ 그대로 — highlights만 fetch join했으니 N2는 안 풀림 | +| **합** | **222** | **121** | | + +222개가 121개로 줄어든 주된 이유는 highlights 컬렉션 N개가 루트 조인 하나로 합쳐졌기 +때문입니다. 나머지 1개 차이는 원시 JPQL에는 Spring Data count가 없어서 생겼습니다. 하지만 +121개 중 **120개는 여전히 ToOne 2차 SELECT**였고 조인 하나는 1,961행을 전달했습니다. +비용이 사라진 것이 아니라 쿼리 수에서 전송 행수와 메모리로 옮겨 갔습니다. + +### 9.5 조인이 행을 곱하는 것을 실행계획에서 + +앞서 6.4절에서는 반복되는 자식 단건 쿼리를, 7.4절에서는 부모 단건 쿼리를 확인했습니다. 이번 +차례는 fetch join이 만든 조인 하나입니다. 아래는 seed(100) 직후 같은 형태의 쿼리를 +EXPLAIN한 결과입니다(원문: [`evidence/explain/l3-cartesian-join-plan.txt`](./evidence/explain/l3-cartesian-join-plan.txt)). + +```text +Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1) + Hash Cond: (h.feed_item_id = fi.id) + -> Seq Scan on highlights h (actual ... rows=1961 loops=1) + -> Hash (actual ... rows=100 loops=1) + -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) +Execution Time: 0.959 ms +``` + +부모 `feed_items`는 100행(Hash 노드)인데 **Hash Join 노드의 actual rows는 1,961**(= Σ highlights)로 부풉니다. 쿼리는 하나인데 그 하나가 실어 나르는 행이 곱이라는 것 — 리스트 크기(100, 9.3절의 Hib6 dedup)로는 안 보이는 실체를 플랜이 드러냅니다. `rows=4202`(추정) vs `rows=1961`(실제)의 오차는 6.4절 Plan A와 같은 통계 이슈입니다(대량 시드 직후 `ANALYZE` 미실행). warm cache·executor 시간 caveat도 마찬가지입니다. + +### 9.6 두 bag이 거부되고 한 bag은 행이 늘어나는 이유 + +bag 두 개를 동시에 `join fetch`하면 Hibernate가 곱집합을 원래 컬렉션으로 되돌릴 수 없어 +`MultipleBagFetchException`을 던집니다. 하나만 join하면 예외는 없지만 부모 행이 자식 수만큼 +늘어납니다. 쿼리 수는 1+N에서 1로 줄어도 전송 행수와 메모리는 커졌습니다. Hibernate 6+의 루트 +중복 제거 때문에 결과 리스트만 보면 이 증가가 보이지 않았습니다. 이 결과를 보고 fetch join은 +ToOne에는 적합하지만 컬렉션에는 주의가 필요하다고 판단했습니다. 다음에는 컬렉션 하나만 fetch +join한 상태에서 페이징을 적용해 보았습니다. + +--- + +## 10. 컬렉션 fetch join + 페이징 — 페이지를 원했는데 데이터셋 전체를 올린다 + +컬렉션 하나만 fetch join하고 `setMaxResults(20)`을 적용하면 전송량도 한 페이지로 줄어들 것이라고 +생각했습니다. 하지만 Hibernate는 컬렉션 fetch join에 페이징을 걸자 DB `LIMIT`을 사용하지 +않았습니다. 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 잘라 냈고 경고도 함께 남겼습니다. + +반환된 목록 크기는 20이라 겉으로는 페이징이 정상처럼 보였습니다. 이번에는 +`returned`뿐 아니라 **`feedItemLoaded`**, 즉 실제로 메모리에 올린 부모 엔티티 수를 +측정했습니다. + +> 이 실패도 프로덕션 코드에 섞지 않고 통합 테스트에 격리했습니다. 다음 단계에서 +> `@BatchSize`·엔티티 페이징·DTO Projection을 적용했을 때 전후 차이를 같은 기준으로 비교하기 +> 위해서입니다. + +### 10.1 무대 — 새 프로덕션 코드 0 (9절 무대 + 페이징 한 줄) + +이번 절에서는 9절의 데이터와 매핑을 그대로 두고 `highlights` fetch join에 페이징 한 줄만 +추가했습니다. 새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았습니다. 이 쿼리는 +`FeedQueryAdapter`가 아니라 통합 테스트 안의 원시 JPQL로만 실행했습니다. + +```java +// IT 안에서 세우는 10절 무대 (프로덕션 아님): +"select f from FeedItemJpaEntity f join fetch f.highlights " // ← 9절의 한 bag fetch join + + "order by f.firstHighlightedAt desc, f.id asc" +// + .setFirstResult(0).setMaxResults(20) // ← 10절의 방아쇠: 페이징 +``` + +기본 설정(`hibernate.query.fail_on_pagination_over_collection_fetch=false`)에서는 이 쿼리가 +예외 없이 **경고 + 인메모리 페이징**으로 진행됩니다. 플래그를 `true`로 바꾸면 같은 쿼리를 즉시 +실패시킬 수 있습니다. 근본 해결은 아니지만 운영에서 실수를 조기에 발견하는 안전장치로는 사용할 +수 있습니다. + +> **N1/N2/9절 회귀 없음**: 10절은 프로덕션 코드를 안 건드리므로 6·7·9절의 단언(`collectionFetches == N`, `pageFetch == N`, `MultipleBagFetchException`, 조인 카디널리티 = Σ highlights)은 그대로 GREEN입니다. 이번 절의 추가분은 IT 측정 메서드뿐입니다. + +### 10.2 실측 — 응답은 한 페이지인데 부모는 전부 로드한다 + +컬렉션 하나를 fetch join한 뒤 페이징하자 `returned`는 페이지 크기였지만 부모 엔티티는 +**N개 전부** 로드되었습니다. `EntityStatistics.getLoadCount()`로 FeedItem 로드 수를 따로 +읽어 응답 크기와 실제 적재량을 비교했습니다. + +**측정값(직접 측정·파생).** `returned`·`feedItemLoaded`는 결정적(리스트 크기·Hibernate 통계로 확정), over-fetch 배수는 `feedItemLoaded / returned`로 파생합니다. 출처 `FeedPersistenceIT.l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory`. 원본: [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv). + +| N | returned(페이지) | feedItemLoaded(★ = N) | over-fetch 배수 | 시드 하이라이트 | +|---:|---:|---:|---:|---:| +| 10 | 10 | **10** | 1.0× (안 보임) | 1,285 | +| 100 | 20 | **100** | 5.0× | 1,961 | +| 1,000 | 20 | **1,000** | 50.0× | 2,917 | + +`returned`는 페이지 크기에 고정되었지만 `feedItemLoaded`는 N을 따라 늘었습니다. over-fetch +배수도 1.0× → 5.0× → 50.0×로 증가했습니다. N=10에서는 데이터셋이 한 페이지보다 작아 +두 값이 같았고 문제가 보이지 않았습니다. 데이터가 커진 뒤에야 반환 크기와 실제 로드 수의 차이가 +나타났습니다. + +> **왜 `getLoadCount()`를 사용했을까요?** fetch join 쿼리는 FeedItem을 루트로 하이드레이트하므로 +> 로드된 부모 수가 `EntityStatistics.getLoadCount()`에 잡힙니다. 인메모리 페이징은 전체를 +> 하이드레이트한 뒤 부모 목록을 자르므로 `returned`가 20이어도 `getLoadCount() == N`입니다. +> 반면 `getCollectionFetchCount()`에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 +> 지표로 사용하지 않았습니다. + +그리고 이 쿼리가 던지는 경고 자체가 이 절의 얼굴입니다. + +> **⚠ 측정 정정(Hibernate 7)** — 널리 알려진 경고 코드는 `HHH000104`지만 이 랩에서 사용한 +> Hibernate ORM 7.1.8은 `HHH90003004`를 기록했습니다. +> +> ```text +> HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory +> ``` +> +> 메시지 본문은 `firstResult/maxResults specified with collection fetch; applying in memory`로 +> 같았습니다. 그래서 회귀 가드는 코드 번호만 비교하지 않고 `contains("HHH000104") || +> contains("collection fetch")`처럼 문구도 함께 확인하도록 만들었습니다. + +### 10.3 비용은 페이지가 아니라 데이터셋에 비례한다 + +응답은 한 페이지인데 비용은 N에 비례하는지 측정했습니다. 아래 값은 문서 첫머리에서 밝힌 대로 +**단일 스레드·warm-cache 상대값**입니다. 절대값이 아니라 N에 따른 변화 방향만 비교했습니다 +(원본: [`evidence/metrics/l4-cost-curve.csv`](./evidence/metrics/l4-cost-curve.csv)). + +| N | 지연 중앙값(5회) | 지연 최댓값(5회) | 스레드 누적 할당 | +|---:|---:|---:|---:| +| 10 | 6.184 ms | 6.566 ms | ≈1.5 MB | +| 100 | 13.890 ms | 16.062 ms | ≈3.0 MB | +| 1,000 | 79.452 ms | 83.526 ms | ≈10.0 MB | + +`returned`가 페이지 크기로 고정인데도 지연·할당이 N을 따라 오른다 = "페이징이 데이터를 안 줄였다"의 시간·메모리 증거입니다. + +예상과 달리 이 fetch join의 지연은 기준선보다 낮았습니다. N=1,000에서 기준선 최댓값은 +238.4 ms였고 fetch join은 83.526 ms였습니다. 컬렉션 N번 왕복이 조인 하나로 줄었기 +때문입니다. 하지만 메모리 할당은 약 1.5 MB에서 10.0 MB로 늘었습니다. 지연만 보면 개선처럼 +보이지만 페이지에 필요하지 않은 N개 부모와 모든 highlights를 하이드레이트하고 있었습니다. + +> **왜 "힙 델타"가 아니라 스레드 누적 할당을 썼을까요?** 인메모리 페이징이 버린 부모는 곧 +> GC 대상이 되어 `used heap`의 전후 차이에 잘 나타나지 않습니다. `getThreadAllocatedBytes` +> (HotSpot)는 GC와 관계없이 호출이 만든 전체 할당량을 누적하므로 버려지는 엔티티까지 측정할 수 +> 있습니다. + +### 10.4 발행 SQL엔 LIMIT이 없다 — 인메모리 페이징의 스모킹건 + +인메모리 페이징을 실행계획에서도 확인했습니다. fetch join이 발행한 SQL(a)과 엔티티만 페이징한 +SQL(b)을 seed(100)에서 EXPLAIN으로 비교했습니다(원문: [`evidence/explain/l4-collection-join-no-limit.txt`](./evidence/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/explain/l4-entity-paging-limit.txt)). + +```text +-- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음 +Sort (... rows=1782 ...) (actual ... rows=1961 loops=1) + Sort Method: quicksort Memory: 445kB + -> Hash Join (... actual ... rows=1961 loops=1) + -> Seq Scan on highlights h (actual ... rows=1961 loops=1) + -> Hash (actual ... rows=100 loops=1) + -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) + +-- (b) 엔티티만 페이징 — Limit 노드 존재 +Limit (... rows=20 ...) (actual ... rows=20 loops=1) + -> Sort (actual ... rows=20 loops=1) + Sort Method: top-N heapsort Memory: 28kB + -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) +``` + +(a)엔 `Limit` 노드가 없다 = DB가 페이징을 안 했습니다. 조인 결과 전체(actual rows = Σ highlights)를 `quicksort`로 정렬한 뒤 그대로 반환하고 페이지로 자르는 일은 Hibernate가 메모리에서 합니다. (b)엔 `Limit` 노드가 정렬 위에 얹혀 `top-N heapsort`로 상위 몇 행만 취합니다. **quicksort(전체 정렬) vs top-N heapsort(상위 몇 행)** — "인메모리 페이징 vs DB 페이징"의 비용 차이가 계획 레벨로 드러납니다. (a)에 `Limit`이 없다는 것 자체가 "DB가 페이징을 안 했으니 누군가 메모리에서 했다"의 증거입니다. (컬럼명·리터럴 하드코딩이라 인젝션 무관. warm cache·executor 시간 caveat는 6.4절과 같습니다.) + +### 10.5 컬렉션 fetch join과 페이징을 함께 쓰기 어려운 이유 + +컬렉션 fetch join에서는 부모 한 행이 자식 수만큼 늘어납니다. 여기에 DB `LIMIT`을 걸면 부모 +20개가 아니라 조인 행 20개에서 잘리므로 일부 부모의 하이라이트가 누락될 수 있습니다. Hibernate는 +이 손상을 피하려고 SQL에서 `LIMIT`을 빼고 전체 조인 결과를 읽은 뒤 메모리에서 부모 기준으로 +페이지를 자릅니다. 앞서 10.4절의 SQL(a)에 `Limit` 노드가 없었던 이유입니다. 이 동작 때문에 +컬렉션 fetch join과 페이징을 함께 사용하지 않기로 했습니다. + +다음 단계에서는 **fetch join을 버리고 엔티티만 페이징**했습니다. 그러면 10.4절의 SQL(b)처럼 +`LIMIT`이 정상적으로 발행됩니다. 다만 highlights가 다시 LAZY가 되어 컬렉션 N+1이 돌아옵니다. +그래서 페이지 부모 키를 모아 `IN`으로 조회하는 Batch Fetch를 함께 적용했습니다. + +--- + +## 11. 배치 페치 — 엔티티 페이징과 IN 배치 적용 + +Fetch Join을 빼고 엔티티만 페이징하니 DB `LIMIT`은 다시 동작했지만 LAZY 연관의 N+1이 +돌아왔습니다. 그래서 `hibernate.default_batch_fetch_size=100`을 적용해 부모 키를 `IN`으로 +묶었습니다. `loadFeed` 코드는 그대로 두고 세션 설정만 달리한 뒤 앞서 잡은 기준선과 같은 +지표로 전후를 비교했습니다. + +> `default_batch_fetch_size`는 세션 전체에 영향을 줍니다. 기존 테스트에 바로 적용하면 앞서 측정한 +> 기준선도 함께 바뀌므로 새 IT 클래스인 `FeedBatchFetchIT`에만 설정했습니다. 기존 테스트를 +> 다시 실행해 기준선이 그대로 유지되는지도 확인했습니다. + +### 11.1 fix는 세션 설정 한 줄 — 순진 loadFeed 코드는 그대로 + +배치 페치는 두 단계로 동작합니다. 먼저 fetch join 없이 **엔티티만** 페이징해 DB `LIMIT`이 +정상적으로 적용되게 합니다. 그다음 LAZY 연관은 부모 키를 모아 `IN` 배치로 채웁니다. +이렇게 하면 N+1이 `ceil(N/batch)`번으로 줄어듭니다. + +```yaml +# application.yml (프로덕션) 또는 테스트 @TestPropertySource — 애플리케이션 코드 변경 0: +spring.jpa.properties.hibernate.default_batch_fetch_size: 100 +``` + +`loadFeed`는 그대로 두었습니다. `findAllBy(Pageable)`로 엔티티를 페이징하고 매핑할 때 +LAZY 연관에 접근합니다. 앞서 N+1을 만들었던 그 코드가 이 설정 아래에서는 배치로 동작합니다. +특정 컬렉션에만 `@BatchSize(size=100)`를 붙일 수도 있지만 그러면 기준선 매핑 자체가 바뀝니다. +비교를 위해 이 랩에서는 세션 property로 격리했습니다. + +### 11.2 실측 — 배치 적용 전후의 쿼리 수 + +`loadFeed(0, n)`(기준선과 정확히 같은 호출)을 배치 세션에서 재면 SQL 총량이 순진의 `1+N`에서 급감합니다. before = 기준선 실측, after = `FeedBatchFetchIT.l5BatchFetchCollapsesQueryCount`. 원본: [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv). + +| N | before: 순진 총 PreparedStatement | after: 배치 총 PreparedStatement | 붕괴 | before: 컬렉션 fetch | after: 컬렉션 fetch | +|---:|---:|---:|---:|---:|---:| +| 10 | 25 | **5** | — | 10 | **1** | +| 100 | 222 | **5** | — | 100 | **1** | +| 1,000 | 2,022 | **23** | **87.9×** | 1,000 | **10** | + +총 PreparedStatement는 25 / 222 / 2,022에서 5 / 5 / 23으로 줄었습니다. N=1,000에서는 +87.9배 차이였습니다. highlights뿐 아니라 user·page EAGER 연관도 같은 배치에 묶였습니다. +23개는 루트 1개, count 1개, highlights 배치 10개, page 배치 10개, user 배치 1개로 +나뉩니다. 다만 컬렉션 fetch 지표는 제가 예상한 방식과 달라 아래처럼 설명을 정정했습니다. + +> **★ 실측 정정** — 처음에는 `getCollectionFetchCount()`를 초기화된 컬렉션 수라고만 보고 +> 배치를 적용해도 N으로 유지될 것이라고 예상했습니다. 실제로는 10 / 100 / 1,000에서 +> **1 / 1 / 10 = `ceil(N/batch)`**으로 줄었습니다. 이 결과에 맞춰 지표를 여러 컬렉션을 +> 채운 fetch SELECT 연산 수로 다시 해석했습니다. 배치 적용 여부는 `prepared`와 +> `collectionFetch`를 함께 보고 판단했습니다. + +### 11.3 DB 페이징으로 over-fetch가 사라진다 + +앞 절의 fetch join 인메모리 페이징은 응답이 한 페이지인데 부모 N개를 하이드레이트했다(`feedItemLoaded`=N). 배치는 **엔티티만 페이징**이라 DB `LIMIT`이 정상 작동해 페이지 크기만 로드합니다. `loadFeed(0, 20)`, `FeedBatchFetchIT.l5EntityPagingLoadsOnlyThePageNotWholeDataset`: + +| N | returned | feedItemLoaded (배치) | feedItemLoaded (fetch join, 대조) | +|---:|---:|---:|---:| +| 10 | 10 | **10** | 10 | +| 100 | 20 | **20** | 100 | +| 1,000 | 20 | **20** | 1,000 | + +fetch join에서는 `feedItemLoaded`가 N까지 늘었지만 배치 적용 뒤에는 페이지 크기인 20에서 +멈췄습니다. 인메모리가 아니라 DB에서 `LIMIT`으로 부모를 먼저 자른 결과입니다. + +### 11.4 EXPLAIN — 페이징엔 Limit 노드, 배치 IN엔 곱셈 없음 (카테시안·인메모리 페이징 둘 다 해소) + +앞 절의 스모킹건은 "(a) fetch join 조인 SQL엔 Limit 노드가 없다"였습니다. 이번에는 정반대 — 엔티티만 페이징하니 Limit 노드가 붙고 자식은 `IN` 배치라 행을 안 곱한다(seed(100), 원문: [`evidence/explain/l5-entity-paging-limit.txt`](./evidence/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/explain/l5-batch-in-semijoin.txt)). + +```text +-- (a) 엔티티만 페이징 — Limit 노드 존재 (fetch join 조인엔 없었다) +Limit (... rows=20 ...) (actual ... rows=20 loops=1) + -> Sort Sort Method: top-N heapsort Memory: 28kB + -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) + +-- (b) 배치 IN — Hash Semi Join, 자식 행만 반환 (카테시안 없음) +Hash Semi Join (... actual ... rows=1509 loops=1) ← 페이지 부모 20개의 highlights (합, 곱 아님) + -> Seq Scan on highlights h (actual ... rows=1961 loops=1) + -> Hash (actual ... rows=20 loops=1) ← 페이지 20개 부모 id +``` + +SQL(a)에는 `Limit` 노드가 있어 DB가 페이지 크기만큼 부모를 골랐습니다. SQL(b)의 semi-join은 +부모와 자식을 곱하지 않고 자식 행만 반환했습니다. 실행계획에서도 앞서 본 카테시안과 +인메모리 페이징이 모두 사라졌음을 확인했습니다. warm cache·executor 시간에 관한 한계는 +6.4절과 같습니다. + +### 11.5 배치가 N+1과 페이징을 함께 해결하는 이유 + +fetch join은 부모와 자식을 한 결과에 합쳐 행을 곱했고 이 때문에 DB가 부모 기준 `LIMIT`을 +적용할 수 없었습니다. 배치에서는 부모만 먼저 페이징하고 자식은 `WHERE fk IN (?,…)`으로 따로 +가져옵니다. `default_batch_fetch_size=B`는 초기화되지 않은 프록시를 최대 B개씩 모아 +`ceil(N/B)`번에 로드합니다. 결과적으로 PreparedStatement는 2,022개에서 23개로 줄었고 +부모 로드 수도 N이 아니라 페이지 크기에 머물렀습니다. 이 결과를 바탕으로 컬렉션 조회에는 fetch +join 대신 배치를 사용하기로 했습니다. + +### 11.6 배치가 못 푸는 것 — 엔티티 과적재 + +배치로 쿼리 수와 페이징 문제는 풀었지만 엔티티는 여전히 통째로 하이드레이트했습니다. +`FeedBatchFetchIT.l5ProbeBatchStillHydratesFullEntities`에서 seed 1,000의 첫 페이지 20건을 +조회하자 FeedItem·User·Page·Highlight를 합해 **1,569개 엔티티**가 영속 객체로 올라왔습니다 +(원본: [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv)). +화면에는 일부 컬럼만 필요했으므로 다음에는 DTO 프로젝션으로 적재 대상을 줄였습니다. + +--- + +## 12. DTO 프로젝션 — 필요한 값만 조회하기 + +배치를 적용한 뒤에도 화면에 필요하지 않은 엔티티가 1,569개나 만들어졌습니다. +`SELECT new (...)`로 필요한 스칼라 값만 조회하는 `loadFeedProjection`을 +추가했습니다. 같은 화면 결과를 만들면서 `getEntityLoadCount()`가 1,569에서 0으로 +줄어드는지 확인했습니다. + +> 기존 `loadFeed`를 바로 교체하면 앞 절의 기준선을 다시 측정할 수 없습니다. 그래서 +> `loadFeedProjection`을 별도 메서드로 추가하고 같은 데이터로 비교했습니다. 기준선부터 배치까지의 +> 테스트도 다시 실행해 기존 결과가 유지되는지 확인했습니다. + +### 12.1 fix는 두 개의 스칼라 프로젝션 — 엔티티 대신 필요 컬럼만 + +프로젝션은 두 부분입니다. **(A)** 부모의 필요 스칼라 컬럼만 페이징으로 프로젝션(컬렉션 조인 없음 → `LIMIT` 정상, 카테시안 없음). **(B)** 그 페이지 부모들의 자식을 필요 스칼라 컬럼만 `IN`으로 프로젝션 → 메모리 그룹핑. + +```java +// FeedQueryAdapter.loadFeedProjection — loadFeed(순진)는 무변경. +// (A) 부모 스칼라 프로젝션 — 조인은 컬럼 접근용(하이드레이션 아님), 페이징은 엔티티에. +select new FeedItemProjectionRow(f.id, u.name, u.username, p.url, p.title, f.firstHighlightedAt) + from FeedItemJpaEntity f join f.user u join f.page p + order by f.firstHighlightedAt desc, f.id asc // + setMaxResults(20) → LIMIT +// (B) 그 20개 부모의 하이라이트를 필요 컬럼만 IN 한 방으로 → feedItemId 로 그룹핑해 FeedSummary 조립 +select new HighlightProjectionRow(h.feedItem.id, h.color, h.text, h.createdAt) + from HighlightJpaEntity h where h.feedItem.id in (:pageIds) +``` + +`FeedSummary`의 마지막 인자는 `List`라 생성자 표현식 한 번으로 만들 수 +없었습니다. 부모와 자식을 각각 스칼라 캐리어로 조회한 뒤 메모리에서 조립했습니다. 이 랩에서는 +회귀 비교를 위해 sibling 메서드로 두었고 프로덕션 경로에서는 이 프로젝션을 `FeedQueryPort`의 +CQRS-lite 계약으로 노출합니다. + +### 12.2 실측 — 엔티티 로드가 0으로 줄어든다 + +seed 1,000에서 `loadFeedProjection(0, 20)`을 실행하고 앞 절의 배치 조회와 비교했습니다. +프로젝션은 하이드레이트한 엔티티가 0개였습니다. 원본: +[`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv). + +| 지표 | before: 배치 | after: 프로젝션 | +|---|---:|---:| +| entitiesLoaded (seed 1,000) | 1,569 | **0** | +| prepared (N=1,000) | 23 | **2** | +| collectionFetch (N=1,000) | 10 | **0** | + +하이드레이트한 엔티티는 1,569개에서 0개로 줄었습니다. `SELECT new (...)`는 영속 +엔티티 대신 스칼라 값으로 record를 만듭니다. `join f.user u`도 `u.name` 컬럼을 읽기 위한 +경로일 뿐 User 엔티티를 만들지는 않습니다. 부모 스칼라 쿼리와 자식 IN 쿼리만 남아 prepared는 +2개로 고정되었고 엔티티 컬렉션을 초기화하지 않아 collectionFetch도 0이었습니다. + +### 12.3 N이 늘어도 쿼리는 2개로 유지된다 + +N을 10, 100, 1,000으로 바꿔 다시 측정해도 prepared는 **항상 2개**였습니다. 기준선과 +배치 결과를 같은 표에 놓고 증가 형태를 비교했습니다. + +| N | 순진(1+N) | 배치(1+ceil(N/batch)·연관) | 프로젝션(상수) | +|---:|---:|---:|---:| +| 10 | 25 | 5 | **2** | +| 100 | 222 | 5 | **2** | +| 1,000 | 2,022 | 23 | **2** | + +기준선의 쿼리 수는 N을 따라 늘었고 배치는 배치 크기 단위로 늘었습니다. 프로젝션은 부모 스칼라 +쿼리 1개와 자식 IN 쿼리 1개로 유지되었습니다. 페이지 부모가 최대 20개라 자식 IN 쿼리도 한 번만 +실행되었습니다. 엔티티 로드 수도 배치의 1,569개에서 프로젝션의 0개로 줄었습니다. + +### 12.4 EXPLAIN — Limit·semi-join은 있으나 width는 좁아지지 않는다 (★ 실측 정정) + +앞서 11절의 D2는 "엔티티 페이징엔 Limit 노드"였습니다. 프로젝션도 (a) 부모 페이징에 `Limit`이 있고 (b) 자식 IN은 semi-join이라 행을 안 곱한다(원문: [`evidence/explain/l6-parent-projection.txt`](./evidence/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/explain/l6-child-projection.txt)). + +```text +-- (a) 부모 스칼라 프로젝션 — Limit 존재하나 width=2088 (users·pages 조인이 행폭에 흘러든다) +Limit (... rows=20 width=2088) (actual ... rows=20 loops=1) + -> Sort Sort Method: top-N heapsort Memory: 27kB + -> Hash Join (fi.page_id = p.id) ← pages 조인 + -> Hash Join (fi.user_id = u.id) ← users 조인 + -> Seq Scan on feed_items fi (width=56) ← feed_items 자체는 좁다 +-- (b) 자식 스칼라 IN — Hash Semi Join, 자식 행만 반환 (곱셈 없음) +Hash Semi Join (... rows=1509 loops=1) ← 페이지 20 부모의 하이라이트 합(11절 배치와 동일) +``` + +> **★ 실측 정정** — 필요한 컬럼만 선택하면 EXPLAIN의 `width`도 줄어들 것으로 예상했지만 +> 부모 프로젝션의 width는 2088로 엔티티 조회의 1194보다 컸습니다(원본: +> [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv)). +> `users`와 `pages` 조인의 행폭이 반영되고 PostgreSQL의 `width`가 실제 전송 바이트가 아니라 +> 컬럼 타입의 평균폭 추정치이기 때문입니다. 프로젝션의 효과는 SQL 플랜의 width가 아니라 +> `Statistics.getEntityLoadCount()`에서 확인했습니다. + +### 12.5 프로젝션이 엔티티를 만들지 않는 이유 + +배치는 SQL 왕복 횟수를 줄이고, 프로젝션은 적재할 대상을 줄입니다. `SELECT new +Carrier(f.id, u.name, …)`는 영속 엔티티를 만들지 않으므로 1차 캐시·더티체킹·lazy 프록시도 +생기지 않습니다. 배치 설정 여부와 관계없이 성립하는 동작입니다. 이 결과를 보고 화면 조회에는 +엔티티보다 프로젝션이 맞다고 판단했습니다. 이 효과는 DB 실행계획보다 ORM/JVM 층의 엔티티 로드 +수에서 확인할 수 있었습니다. + +### 12.6 프로젝션이 못 푸는 것 — 페이지당 전량 + +프로젝션은 엔티티 과적재를 없앴지만 자식 IN 쿼리는 페이지 부모의 하이라이트를 **전부** +가져왔습니다. seed 1,000의 첫 페이지 20건에서 자식 행은 1,509개였습니다(원본: +[`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)). +화면에는 부모당 최신 3개, 최대 60개만 필요했습니다. 단순한 `IN` 쿼리의 `LIMIT`은 부모별로 +적용되지 않으므로 다음 단계에서 Top-N-per-group을 SQL로 구현했습니다. + +--- + +## 13. Top-N-per-group — 부모마다 최신 3개를 가져오는 세 가지 방법 + +프로젝션으로 엔티티는 만들지 않게 되었지만 부모 20개의 하이라이트 1,509행을 모두 가져오는 +문제는 남았습니다. 화면에는 부모마다 최신 3개만 필요했습니다. 표준 JPQL만으로는 윈도우 함수와 +LATERAL을 표현할 수 없어서 native SQL로 내려갔습니다. 윈도우 함수·LATERAL·2단계 배치 세 +방식이 같은 top-3을 만드는지 먼저 확인한 뒤 같은 데이터로 실행계획과 buffers를 비교했습니다. + +### 13.1 단순한 `LIMIT`이 부모별로 적용되지 않는 이유 + +처음에는 자식 쿼리 끝에 `LIMIT 3`을 붙였습니다. 하지만 `LIMIT`은 부모별 그룹이 아니라 +**최종 결과 집합 전체**에 적용되어 부모 하나의 하이라이트 3개만 남았습니다. + +```sql +-- ❌ 전체 결과에 LIMIT 3 → 페이지 20개 부모인데 3행만 (가장 최신 하이라이트 부모 1개만 채워짐) +SELECT h.feed_item_id, h.color, h.text, h.created_at FROM highlights h + WHERE h.feed_item_id IN () ORDER BY h.created_at DESC LIMIT 3; +``` + +"그룹당 top-N"은 세 가지로 표현할 수 있습니다. 셋 다 같은 페이지-20 부모 서브쿼리(`… ORDER BY first_highlighted_at DESC, id ASC LIMIT 20`)를 입력으로 받습니다. + +```sql +-- ⓐ 윈도우 함수: 부모별 순번 → rn<=3 컷 (컷은 DB, 전송은 60행으로 접힘) +SELECT t.* FROM (SELECT h.*, row_number() OVER (PARTITION BY h.feed_item_id + ORDER BY h.created_at DESC) AS rn FROM highlights h + WHERE h.feed_item_id IN ()) t WHERE t.rn <= 3; +-- ⓑ LATERAL: 부모마다 상관 서브쿼리로 상위 3개만 인덱스 seek (ix_highlights_feed_items_created) +SELECT p.id, top3.* FROM () p CROSS JOIN LATERAL ( + SELECT h.color, h.text, h.created_at FROM highlights h + WHERE h.feed_item_id = p.id ORDER BY h.created_at DESC LIMIT 3) top3; +-- ⓒ 2단계 배치: 자식을 한 방 IN 으로 가져와 앱에서 부모별 3컷 (11절 배치의 연장) +SELECT h.feed_item_id, h.color, h.text, h.created_at FROM highlights h + WHERE h.feed_item_id IN () ORDER BY h.feed_item_id, h.created_at DESC; -- 앱컷 +``` + +`PARTITION BY`(윈도우)·부모별 상관 서브쿼리(LATERAL)·앱 그룹핑(2단계)이 각각 `LIMIT`이 못 하는 "그룹당"을 만듭니다. 무대는 신규 IT인 `FeedTopNIT`이고 native SQL은 `JdbcTemplate`으로 실행합니다. 10절처럼 IT-only라 `loadFeed`와 `loadFeedProjection`은 건드리지 않았고 프로덕션 코드 변경은 0입니다. 표준 JPQL엔 윈도우도 LATERAL도 없어서 native로 내려갑니다. + +### 13.2 실측 — 세 방법의 결과와 단순 LIMIT의 오작동 + +`FeedTopNIT.l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`(seed 1,000, page 20). 원본: [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv). + +| 전략 | 반환 행 | 커버한 부모 | 부모당 최대 | +|---|---:|---:|---:| +| ⓐ 윈도우 | 60 | 20 | 3 | +| ⓑ LATERAL | 60 | 20 | 3 | +| ⓒ 2단계(앱컷 전 전량) | **1,509** | 20 | 전량 | +| ❌ 순진 `LIMIT 3` | 3 | **1** | — | + +윈도우와 LATERAL은 부모 20개에서 각각 3개씩, 모두 60행을 반환했습니다. 2단계 방식은 +애플리케이션에서 자르기 전에 1,509행을 모두 전송했습니다. 순진한 `LIMIT 3`은 전체 결과에서 +3행만 남겨 부모 하나만 채우고 나머지 부모에는 하이라이트를 넣지 못했습니다. + +### 13.3 결과는 같지만 I/O는 달랐다 + +세 SQL은 캐시 상태를 맞추기 위해 같은 테스트 실행에서 `EXPLAIN (ANALYZE, BUFFERS)`로 +측정했습니다. 원문: [`l14-window-plan.txt`](./evidence/explain/l14-window-plan.txt) · +[`l14-lateral-plan.txt`](./evidence/explain/l14-lateral-plan.txt) · +[`l14-twostep-plan.txt`](./evidence/explain/l14-twostep-plan.txt). 요약: +[`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv). + +| 전략 | 최상위 노드 (스캔·조인) | 반환 행 | buffers shared hit | exec | +|---|---|---:|---:|---:| +| ⓐ 윈도우 | `WindowAgg` ← `Hash Semi Join`(전량) | 60 | 430 | 1.552 ms | +| ⓑ **LATERAL** | `Nested Loop` ← `Index Scan`+`Limit 3` | 60 | **204** | **0.323 ms** | +| ⓒ 2단계 | `Sort` ← `Hash Semi Join`(전량) | 1,509 | 430 | 1.686 ms | + +```text +-- ⓑ LATERAL — 부모마다 인덱스 range scan, Limit 3 에서 멈춤 (loops=20, 각 rows=3) +Nested Loop (... rows=60) (actual ... rows=60 loops=1) Buffers: shared hit=204 + -> Limit (... rows=20) ← 페이지 20 부모 + -> Limit (... rows=3 ... loops=20) Buffers: shared hit=63 + -> Index Scan using ix_highlights_feed_items_created on highlights h + Index Cond: (feed_item_id = fi.id) ← 부모당 3개만 읽고 멈춘다 +-- ⓐ 윈도우 — 파티션 전량(1509)을 읽어 순번을 매긴 뒤 rn<=3 컷 +WindowAgg Run Condition: (row_number() OVER (?) <= 3) Buffers: shared hit=430 + -> Sort (... rows=1509) -> Hash Semi Join (... rows=1509) ← two-step 과 같은 스캔 +``` + +세 방식은 모두 같은 top-3 60행을 만들었지만 읽는 방식은 달랐습니다. LATERAL은 부모마다 +`ix_highlights_feed_items_created`를 seek해 3개에서 멈췄고 buffers는 204였습니다. 윈도우와 +2단계 방식은 같은 `Hash Semi Join`으로 1,509행을 모두 읽어 buffers가 430이었습니다. 윈도우는 +그 위에서 `WindowAgg`로 60행을 남겼고 2단계는 1,509행을 애플리케이션에 전달했습니다. +쿼리 개수만으로는 이 차이를 볼 수 없었고 실행계획과 buffers를 함께 봐야 했습니다. + +### 13.4 인덱스 유무 토글 — LATERAL의 빠름은 LATERAL이 아니라 인덱스 seek 덕 + +LATERAL의 buffers가 작은 이유가 복합 인덱스인지 확인했습니다. 같은 쿼리를 두고 인덱스를 +제거한 뒤 다시 만들면서 측정했습니다. 원본: +[`l14-lateral-no-index.txt`](./evidence/explain/l14-lateral-no-index.txt) · +[`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv). + +| variant | 자식 접근 | buffers shared hit | exec | +|---|---|---:|---:| +| 인덱스 있음 | `Index Scan … (Limit 3)` | 168 | 0.336 ms | +| 인덱스 없음 | `Seq Scan`(Rows Removed by Filter 2842/loop) | **4446** | **5.472 ms** | + +복합 인덱스를 제거하자 LATERAL은 부모마다 highlights를 Seq Scan하고 대부분을 필터로 버렸습니다. +buffers는 168에서 4,446으로 약 26배, 실행시간은 0.336 ms에서 5.472 ms로 약 16배 +늘었습니다. LATERAL 문법 자체가 빠른 것이 아니라 `(feed_item_id, created_at DESC)` 인덱스로 +부모별 상위 3개를 바로 찾을 수 있어서 빨랐습니다. 이 인덱스는 `V6__feed.sql`부터 있었습니다. +새로 추가한 것이 아닙니다. + +### 13.5 그룹 크기가 승자를 가른다 — K 곡선 + +세 방식의 차이가 그룹 크기에 따라 달라지는지도 확인했습니다. seed 1,000에서 top-K를 +3·50·500으로 바꿔 측정했습니다(원본: [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv)). + +| K | 윈도우 반환 | 윈도우 buffers | LATERAL 반환 | LATERAL buffers | +|---:|---:|---:|---:|---:| +| 3 | 60 | 162 | 60 | 114 | +| 50 | 695 | 216 | 695 | 155 | +| 500 | 1,509 | 269 | 1,509 | 171 | + +반환 행수는 K에 따라 60 → 695 → 1,509로 늘었습니다. LATERAL의 buffers는 모든 K에서 +윈도우보다 작았지만 차이는 K가 작을수록 컸습니다. 부모의 하이라이트 500개 중 K개만 인덱스로 +읽기 때문입니다. K가 그룹 크기인 500에 가까워지면 LATERAL도 대부분을 읽습니다. 현재 피드는 +그룹이 크고 K가 3으로 작아서 LATERAL을 선택했습니다. K가 그룹 크기에 가까운 조회라면 더 +단순한 윈도우 함수를 고를 수 있습니다. + +### 13.6 세 방법이 부모별 top-3을 만드는 방식 + +윈도우 함수는 `PARTITION BY feed_item_id`로 부모마다 순번을 매기고 `rn<=3`을 남깁니다. +DB에서 자르지만 순번을 만들려고 파티션 전체를 읽습니다. LATERAL은 부모마다 상관 서브쿼리를 +실행하고 복합 인덱스에서 3개를 읽으면 멈춥니다. 2단계 방식은 `IN`으로 자식을 모두 가져온 뒤 +애플리케이션에서 그룹핑합니다. 표준 JPQL에는 윈도우 함수와 LATERAL이 없고 Hibernate 6+ HQL도 +LATERAL은 지원하지 않습니다. 작은 K와 큰 그룹이라는 현재 조건에는 native LATERAL을 +선택했습니다. + +### 13.7 다음에 해결할 문제 — 부모 피드 페이징 + +아이템별 top-3은 60행으로 줄였지만 부모 피드 페이징은 여전히 `OFFSET`이었습니다. +`OFFSET 900 LIMIT 20`을 측정하자 앞의 900행도 읽은 뒤 버렸습니다. 페이지가 깊어질수록 +비용이 늘어나므로 다음 단계에서는 `(first_highlighted_at, id)`를 커서로 쓰는 keyset +페이징으로 바꿨습니다. + +--- + +## 14. keyset vs OFFSET — 깊은 페이지의 조회량 비교 + +아이템별 top-3을 해결한 뒤 부모 피드의 페이징을 확인했습니다. 이 쿼리는 여전히 +`OFFSET :n LIMIT 20`을 써서 페이지가 깊어질수록 앞의 행을 읽고 버렸습니다. 무한 +스크롤에서는 이 비용이 계속 늘어납니다. 그래서 이전 페이지의 마지막 +`(first_highlighted_at, id)`를 커서로 넘기는 keyset 페이징으로 바꾸고 페이지 깊이에 따른 +스캔 행수를 비교했습니다. + +### 14.1 왜 OFFSET은 깊은 페이지에서 죽나 — keyset의 shape + +`OFFSET`은 정렬 순서에서 앞 `offset`행을 **생성한 뒤 버립니다**. 정렬키 인덱스가 있어도 그 튜플들을 훑어야 하고 깊으면 아예 `Seq Scan`+`Sort`로 전량을 훑습니다. keyset은 이전 페이지의 마지막 행을 커서로 삼아 그 지점 이후만 읽습니다. + +```sql +-- ❌ 순진 OFFSET: 깊은 페이지에서 앞 n행을 읽어 버린다 (over-scan = offset+20) +SELECT fi.id, fi.first_highlighted_at FROM feed_items fi + ORDER BY fi.first_highlighted_at DESC, fi.id DESC OFFSET 1980 LIMIT 20; +-- ✅ keyset/seek: 커서로 인덱스에서 그 지점 이후만 (깊이 무관 상수) +SELECT fi.id, fi.first_highlighted_at FROM feed_items fi + WHERE (fi.first_highlighted_at, fi.id) < (:lastTs, :lastId) -- 이전 페이지 마지막 행의 정렬키 + ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20; +-- 전제 인덱스: feed_items (first_highlighted_at DESC, id DESC) ← 정렬키 전용 +``` + +측정은 `FeedKeysetIT`의 native SQL로 격리했고 정렬키 인덱스는 테스트 안에서 CREATE/DROP +했습니다. V6의 `ix_feed_items_visibility_sort`는 선두 컬럼이 `visibility`라 가시성 필터가 +없는 keyset 쿼리에는 맞지 않았습니다. `(first_highlighted_at DESC, id DESC)` 전용 +인덱스를 사용했습니다. 프로덕션에 반영할 때는 V8 마이그레이션으로 추가할 수 있습니다. + +### 14.2 실측 — OFFSET은 깊이에 비례하고 keyset은 일정하다 + +seed 2,000에서 두 방식에 같은 정렬키 인덱스를 사용했습니다. "훑은 행"은 `Limit` 하위의 +actual rows로 계산했습니다(원본: [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv)). + +| 페이지 (offset) | OFFSET 훑은 행 | keyset 훑은 행 | +|---:|---:|---:| +| 1 (0) | 20 | 20 | +| 50 (980) | 1,000 | 20 | +| 100 (1980) | **2,000** | **20** | + +OFFSET이 훑은 행은 offset+20으로 20 → 1,000 → 2,000까지 늘었고 keyset은 계속 +20행이었습니다. 100번째 페이지에서 OFFSET은 결과 20행을 만들려고 2,000행을 읽었지만 +keyset은 20행만 읽었습니다. 무한 스크롤의 뒤쪽 페이지가 느려지는 이유를 이 차이로 확인했습니다. + +### 14.3 EXPLAIN — scan-then-discard vs index seek, 그리고 정렬키 인덱스가 전제 + +`FeedKeysetIT.l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`(깊은 페이지 offset 1980, 한 실행). 원문: [`l15-offset-deep-page.txt`](./evidence/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/explain/l15-keyset-no-index.txt). 요약: [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv). + +| 변형 | 플랜 | 훑은 행 | buffers | exec | +|---|---|---:|---:|---:| +| OFFSET | `Limit`←`Sort`←`Seq Scan`(2,000) | 2,000 | 141 | 0.996 ms | +| **keyset + 인덱스** | `Limit`←`Index Only Scan` | **20** | **1** | **0.076 ms** | +| keyset − 인덱스 | `Limit`←`Sort`←`Seq Scan`(filter) | 20 | 141 | 0.373 ms | + +```text +-- keyset + 인덱스: 커서 이후 20행만 seek (Index Only Scan, 순서 인덱스 보장 → Sort 없음) +Limit (rows=20) Buffers: shared hit=1 read=2 + -> Index Only Scan using ix_feed_items_keyset on feed_items fi (actual rows=20) + Index Cond: (ROW(first_highlighted_at, id) < ROW('...'::timestamptz, '...'::uuid)) + Heap Fetches: 20 +-- keyset − 인덱스: 결과는 20이지만 정렬키 인덱스가 없어 Seq Scan 으로 전량을 훑는다 + -> Seq Scan on feed_items fi Rows Removed by Filter: 1980 Buffers: shared hit=141 +``` + +깊은 페이지에서 OFFSET은 `Seq Scan`+`Sort`로 2,000행을 훑고 20행만 남겼습니다 +(buffers 141). keyset은 정렬키 인덱스가 있을 때 `Index Only Scan`으로 커서 이후 20행만 +읽었고 buffers는 1이었습니다. 인덱스를 제거하자 keyset도 `Seq Scan`으로 2,000행을 +확인했습니다. keyset 문법만으로 비용이 줄어든 것이 아니라 커서와 같은 순서의 정렬키 인덱스가 +있어야 했습니다. + +### 14.4 keyset의 조회량이 일정한 이유 + +OFFSET은 건너뛸 행까지 읽지만 keyset은 커서 `(first_highlighted_at, id)` 이후를 인덱스에서 +range scan합니다. `first_highlighted_at`이 같은 행도 안정적으로 넘기려면 tie-break인 +`id`까지 커서에 포함해야 합니다. 시각만 커서로 쓰면 경계에서 행이 빠지거나 중복될 수 있습니다. +`FeedKeysetIT.l15KeysetWalkMatchesOffsetPages`로 keyset의 두 번째 페이지가 OFFSET의 두 번째 +페이지와 같은 20행, 같은 순서인지 확인했습니다. 정렬키·커서·인덱스의 컬럼과 방향이 모두 +일치해야 합니다. + +### 14.5 keyset이 못 푸는 것 — 가시성 OR + +keyset은 페이지 깊이를 풀었지만 실서비스 피드는 가시성으로 필터해야 한다(`public` + 내가 멘션된 것 + 내 비공개). 그 필터를 keyset과 같은 쿼리에 얹으면(`FeedKeysetIT.l15ProbeVisibilityOrBreaksKeysetIndex`) 플래너는 정렬키 인덱스 `ix_feed_items_keyset`를 **더 이상 쓰지 못하고** 가시성 3분기를 각각 인덱스로 스캔한 `BitmapOr`로 떨어집니다. 원문: [`l15-visibility-or-probe.txt`](./evidence/explain/l15-visibility-or-probe.txt). + +```text +-- 가시성 OR 을 얹으면: 정렬키 Index Only Scan 이 사라지고 BitmapOr + 별도 Sort 로 +Limit -> Sort (Sort Key: first_highlighted_at DESC, id DESC) ← Sort 재등장! + -> Bitmap Heap Scan on feed_items + -> BitmapOr + -> Bitmap Index Scan on ix_feed_items_visibility_sort (visibility='PUBLIC' AND ROW(...) < cursor) + -> Bitmap Index Scan on ix_feed_items_visibility_sort (visibility='MENTIONED' AND ...) + -> BitmapAnd (visibility='PRIVATE' ∩ user_id = me) + SubPlan 1 -> Index Only Scan on uq_feed_item_mentions (EXISTS) +``` + +가시성 조건을 추가하자 사라졌던 `Sort` 노드가 다시 나타났습니다. `OR`+`EXISTS`는 각 분기를 +bitmap으로 합치면서 인덱스의 정렬 순서를 잃었습니다. 그래서 다음 단계에서는 가시성 분기를 +`UNION ALL`로 나누는 방식과 뷰어별 결과를 미리 계산하는 방식을 비교했습니다. + +--- + +## 15. 가시성 조건 — 단일 OR, UNION, 사전계산 비교 + +keyset으로 페이지 깊이 문제를 풀었지만 `public + 내가 멘션된 것 + 내 비공개`라는 가시성 +조건을 합치자 `BitmapOr`+`Sort`가 다시 나타났습니다. 단일 OR을 그대로 쓰는 방식, 세 분기를 +UNION으로 나누는 방식, 뷰어별 가시성을 미리 계산하는 방식을 같은 결과 집합으로 비교했습니다. + +### 15.1 단일 OR이 정렬 순서를 유지하지 못하는 이유 + +하나의 인덱스는 하나의 선두 컬럼 순서만 줍니다. 가시성 3분기는 각각 다른 조건(visibility 값·user_id·mentions 조인)이라 하나의 쿼리로 묶으면 플래너는 각 분기를 따로 스캔한 뒤 합쳐서 다시 정렬해야 합니다. + +```sql +-- ❌ 단일 OR: 3분기를 하나로 → BitmapOr + 전체 top-N Sort + 멘션 SubPlan (순서 인덱스 못 탐) +SELECT fi.id, fi.first_highlighted_at FROM feed_items fi + WHERE (fi.visibility='PUBLIC' + OR (fi.visibility='MENTIONED' AND EXISTS(SELECT 1 FROM feed_item_mentions m + WHERE m.feed_item_id=fi.id AND m.mentioned_user_id=:me)) + OR (fi.visibility='PRIVATE' AND fi.user_id=:me)) + ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20; +-- ✅ UNION 분해: 3분기를 각각 정렬 보장 인덱스 쿼리로 → UNION ALL → Merge Append +-- ✅ 사전계산: 가시성을 뷰어별 feed_visible 로 미리 펼쳐 → 단일 index range scan (= CQRS 읽기 모델) +``` + +측정은 `FeedVisibilityIT`에 격리했습니다. 신규 인덱스(`ix_mentions_user`, private partial)와 +`feed_visible` 테이블도 테스트 안에서 생성하고 제거했습니다. V7의 `feed_item_mentions` +인덱스는 `(feed_item_id, …)` 순서라 "나를 멘션한 아이템"을 찾는 쿼리에 맞지 않았습니다. +`(mentioned_user_id, feed_item_id)` 인덱스를 추가해 비교했습니다. + +### 15.2 실측 — 결과는 같고 실행계획은 다르다 + +seed 2,000에서 user008이 볼 수 있는 피드를 조회했습니다. 세 방식이 같은 20개 feed_item을 +반환하는지는 `l16ThreeApproachesReturnSameVisibleSet`으로 먼저 확인한 뒤 가시성 조건을 +처리하는 실행계획을 비교했습니다(원본: +[`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv)). + +| 안 | 최상위/스캔 | Sort | 멘션 | 훑는 후보 | buffers | +|---|---|---|---|---:|---:| +| ⓐ 단일 OR | `BitmapOr`+`Bitmap Heap Scan`+top-N `Sort` | 재정렬 | hashed SubPlan | **1,500** | 122 | +| ⓑ UNION 분해 | **`Merge Append`**(분기별 인덱스) | 분기별 병합 | `Hash Join` | ≤60 | 200 | +| ⓒ **사전계산** | **`Index Only Scan`**(feed_visible) | **없음** | 사전 반영 | 20 | **1** | + +**단일 OR**은 3분기를 `BitmapOr`로 합쳐 후보 1,500을 훑고 top-N `Sort`로 20을 낸다 — 순서를 인덱스로 못 내 재정렬한다(멘션 EXISTS는 hashed SubPlan). **UNION 분해**는 3분기를 각각 정렬 스트림으로 만들어 `Merge Append`로 병합(전체 재정렬 없음), EXISTS가 `Hash Join`으로 바뀐다(public은 고선택도라 bitmap+top-N, private는 partial 인덱스, mentioned는 조인 — 각 분기가 자기 최적 플랜). **사전계산**은 `feed_visible` 커버링 인덱스의 단일 `Index Only Scan` — OR도 조인도 Sort도 없이 20행만(buffers 1). + +### 15.3 세 플랜을 나란히 + +```text +-- ⓐ 단일 OR: BitmapOr 로 후보 1500 → top-N Sort (순서 손실) buffers=122 +Limit -> Sort (top-N) -> Bitmap Heap Scan on feed_items (rows=1500, Rows Removed by Filter: 200) + -> BitmapOr [visibility='PUBLIC' | 'MENTIONED' | ix_feed_items_private user_id=:me] + Filter: ... (visibility='MENTIONED' AND hashed SubPlan) ... +-- ⓑ UNION 분해: 분기별 정렬 스트림을 Merge Append (전체 Sort 없음) buffers=200 +Limit -> Merge Append + -> [public] Bitmap Heap Scan + top-N Sort + -> [mentioned] Hash Join (feed_items ⋈ ix_mentions_user) + -> [private] Index Only Scan using ix_feed_items_private + Incremental Sort +-- ⓒ 사전계산: 단일 커버링 인덱스, Sort 없음 buffers=1 +Limit -> Index Only Scan using ix_feed_visible (Index Cond: viewer_id=:me) Heap Fetches: 20 +``` + +원문: [`l16-single-or-plan.txt`](./evidence/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt) · [`l16-union-branches.txt`](./evidence/explain/l16-union-branches.txt). + +### 15.4 UNION과 사전계산의 차이 + +> **★ 실측 정정** — 처음에는 단일 OR이 Seq Scan을 하고 UNION이 buffers를 줄일 것으로 +> 예상했습니다. 실제 단일 OR은 `BitmapOr`+top-N `Sort`+hashed SubPlan을 사용했고 UNION의 +> buffers는 200으로 단일 OR의 122보다 컸습니다. 각 분기가 따로 스캔하기 때문입니다. buffers가 +> 1까지 줄어든 방식은 UNION이 아니라 사전계산이었습니다. + +단일 OR은 세 분기를 bitmap으로 합치면서 정렬 순서를 잃습니다. UNION은 분기를 독립시켜 상관 +술어를 `Hash Join`으로, 전체 병합을 `Merge Append`로 바꿨지만 요청할 때마다 세 분기를 +스캔했습니다. 사전계산은 뷰어별 `feed_visible`을 미리 만들어 조회를 단일 `Index Only Scan`으로 +바꿨습니다. 대신 피드·멘션·가시성이 바뀔 때 읽기 모델을 갱신해야 하고 뷰어 수만큼 저장 공간도 +늘어납니다. + +### 15.5 사전계산을 프로덕션에 적용할 때 필요한 것 + +`feed_visible`은 실험용 테이블이지만 프로덕션에서 상시 유지하려면 CQRS 읽기 모델이 됩니다. +쓰기 모델의 변경을 뷰어별 투영에 반영하고 조회는 그 투영만 읽습니다. 여기까지 진행하면서 문제의 +범위가 N+1을 줄이는 SQL에서 화면에 맞는 읽기 모델을 설계하는 일로 넓어졌습니다. + +--- + +## 16. Top-N·keyset·가시성을 한 쿼리로 통합하기 + +세 기법을 각각 검증한 뒤 한 쿼리에 합쳤습니다. 실제 화면에서는 보이는 +아이템만 골라 깊은 페이지를 넘기면서 각 아이템의 최신 하이라이트 3개를 함께 반환해야 합니다. +`FeedCrownIT`에서 세 기법이 서로의 인덱스 사용을 방해하지 않는지 확인했습니다. + +### 16.1 통합 쿼리의 shape — 부모선택 × LATERAL + +통합 쿼리는 가시성 필터와 keyset으로 부모 20개를 고른 뒤 각 부모에 LATERAL top-3을 +적용합니다. 작은 K에서 유리했던 LATERAL을 자식 조회에 사용하고 keyset·가시성은 부모 선택 +안에서 처리했습니다. + +```sql +SELECT p.pid, top3.color, top3.text, top3.created_at + FROM ( <부모선택: 가시성 + keyset 로 고른 부모 20> ) p + CROSS JOIN LATERAL ( + SELECT h.color, h.text, h.created_at FROM highlights h + WHERE h.feed_item_id = p.pid ORDER BY h.created_at DESC LIMIT 3 ) top3; +``` + +부모 선택 부분은 단일 OR, UNION 분해, 사전계산(`feed_visible`) 세 방식으로 만들었습니다. +`crownUnifiedReturnsSameShapeAcrossParentPaths`에서 세 방식이 같은 부모 20개를 반환하는지 +확인한 뒤 실행계획만 비교했습니다. + +### 16.2 실측 — 세 기법을 합친 실행계획 + +`FeedCrownIT.crownUnifiedPlanStacksVisibilityKeysetAndTopN`(seed 2,000, 뷰어 user008, page 1). 사전계산 부모선택 위의 통합 쿼리는 세 기법을 재정렬 없이 한 플랜에 겹칩니다. 원본: [`crown-unified-precompute-plan.txt`](./evidence/explain/crown-unified-precompute-plan.txt). + +```text +Nested Loop (rows=60) ← LATERAL (상관 조인) + -> Limit -> Index Only Scan using ix_feed_visible (rows=20) ← 가시성 + keyset (사전계산) + Index Cond: viewer_id = :me Heap Fetches: 20 + -> Limit -> Index Scan using ix_highlights_feed_items_created (loops=20) ← Top-N (부모당 top-3 seek) +-- Sort 노드 없음. buffers 65. +``` + +- **가시성+keyset** = `feed_visible` 커버링 인덱스의 단일 `Index Only Scan`(가시성은 사전 반영, keyset 은 인덱스 순서 상위 20). +- **Top-N** = 부모 20 마다 `ix_highlights_feed_items_created` 로 top-3 index seek(`Nested Loop` = LATERAL). +- **Sort 노드 없음** — 두 순서(부모 keyset·자식 created_at)가 모두 인덱스에서 나옵니다. 세 기법이 깨끗하게 합쳐집니다. + +### 16.3 간섭 시험 — 사전계산 위에선 겹치고, 단일 OR 위에선 매 페이지 재해소 + +`crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`(가장 깊은 페이지, 커서 = visible−20). user008에게 보이는 `1,500` 중 마지막 페이지에서 부모선택을 사전계산으로 두느냐 단일 OR로 두느냐가 갈립니다. 원본: [`crown-deep-keyset-precompute.txt`](./evidence/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/explain/crown-deep-keyset-single-or.txt). + +| 부모선택 | 최상위 | 훑는 행 | feed_visible | 부모 buffers | +|---|---|---:|---|---:| +| 사전계산 | `Nested Loop` | **19** | ✅ | 3 | +| 단일 OR | `Nested Loop` | **200** | ❌(구조적) | 31 | + +> **★ 실측 정정** — 처음에는 사전계산 keyset에는 Sort가 없고 단일 OR에만 Sort가 생길 +> 것으로 예상했습니다. 가장 깊은 커서에서는 두 방식 모두 남은 19행을 작은 quicksort로 +> 정렬했습니다. 차이는 Sort 유무가 아니라 페이지에 도달하기까지 읽은 행수였습니다. 사전계산은 +> `ix_feed_visible`의 range에서 19행만 읽었고 단일 OR은 가시성 세 분기와 멘션 조건을 다시 +> 계산하며 200행을 materialize했습니다. + +### 16.4 조회 조건별 선택 기준 + +세 기법을 한 쿼리에 적용한 결과를 다음처럼 정리했습니다. + +| 축 | 문제 | 해법 | 언제 | 근거 | +|---|---|---|---|---| +| Top-N-per-group | 아이템당 최신 top-3 | **LATERAL**(작은 K) / 윈도우(큰 K) | 항상 LATERAL, K가 그룹 크기에 근접하면 윈도우로 수렴 | 13절 | +| 페이징 | 깊은 페이지 | **keyset**(커서+정렬키 인덱스) | 항상. OFFSET 은 깊이에 비례 붕괴 | 14절 | +| 가시성 | 3분기 술어 | **UNION 분해** / **사전계산**(=CQRS) | 보통 UNION, 고트래픽 읽기 극단이면 사전계산 | 15절 | +| 통합 | 셋을 한 쿼리로 | 부모선택(가시성+keyset) × LATERAL(Top-N) | 부모선택 사전계산/UNION 이면 매 페이지 재해소 없음 | 16절 | + +통합 쿼리에서 차이를 만든 부분은 부모 선택이었습니다. 사전계산이나 UNION 분해를 사용하면 +keyset과 Top-N을 그대로 합칠 수 있지만 단일 OR은 페이지를 넘길 때마다 가시성 조건을 다시 +계산했습니다. + +### 16.5 사전계산과 CQRS 읽기 모델의 경계 + +부모 선택 방식 가운데 사전계산(`feed_visible`)이 세 조건을 가장 단순한 실행계획으로 +합쳤습니다. 하지만 이를 상시 유지하려면 쓰기 모델의 변경을 뷰어별 투영에 동기화해야 합니다. +현재 범위에서 이 비용을 바로 받아들일지는 별도 판단이 필요했습니다. + +--- + +## 17. CQRS-lite 읽기 모델 — 프로덕션 읽기 경로로 (주제 2 브릿지) + +사전계산(`feed_visible`)의 실행계획이 가장 단순했지만 이를 상시 유지되는 별도 저장소로 만들면 +쓰기 모델의 이벤트로 읽기 저장소를 갱신하는 풀 CQRS가 필요합니다. 제가 정한 application-core +계약에서는 별도 물리 읽기 저장소를 에스컬레이션 대상으로 남겨 두었습니다. 이번 범위에서는 그 +계약을 유지하고 같은 저장소 위에 읽기 전용 포트·DTO·쿼리를 분리하는 **CQRS-lite**를 +구현했습니다. + +### 17.1 CQRS-lite vs 풀 CQRS — 모델이냐, 저장소냐 + +| | CQRS-lite (이번 구현) | 풀 CQRS (에스컬레이션, 주제 2) | +|---|---|---| +| 분리 대상 | 읽기 **모델**(전용 포트·DTO·읽기최적 쿼리) | 읽기 **저장소**(별도 물리 테이블) | +| 저장소 | 쓰기와 **같은** 저장소 | **별도** — `feed_visible` 유지 | +| 동기화 | 없음(요청 시 읽기최적 쿼리) | 쓰기→읽기(도메인 이벤트/아웃박스) | +| 계약 | **지원**(query-bypass Projection) | **에스컬레이션 전용** | + +핵심은 N+1을 "SQL로 푸느냐"에서 "**읽기 모델을 어떻게 설계하느냐**"로 넘어가는 것입니다. lite는 쓰기 애그리거트(`FeedItem`)와 분리된 읽기 경로를 같은 저장소 위에 세우고 full은 저장소까지 분리해 동기화 비용을 집니다. + +### 17.2 무엇을 만들었나 + 실측 + +읽기 경로는 `FeedReadModelQueryPort` → `GetFeedReadModelUseCase` → `FeedReadModelQueryAdapter` +순서로 만들었습니다. 쿼리는 12절의 프로젝션과 13절의 window top-3을 합쳐 기존 `loadFeed`를 +건드리지 않고 화면에 필요한 형태를 바로 반환합니다. + +- 부모 페이지: JPQL `SELECT new`(엔티티 하이드레이션 0). +- 자식 top-3: 네이티브 `row_number() OVER (PARTITION BY feed_item_id ORDER BY created_at DESC) <= 3`. + +`FeedReadModelUseCaseIT`에서 N=10과 100을 측정한 결과 엔티티 로드는 **0**, 발행 쿼리는 +N과 관계없이 2개, `topHighlights`는 부모당 최대 3개였습니다. 아키텍처 게이트인 ArchUnit +`query_ports_do_not_leak…`, 의존 방향 검사, `./gradlew check`도 통과했습니다. window 쿼리는 +Hibernate `Statistics`가 실제 발행 횟수를 셀 수 있도록 `JdbcTemplate` 대신 Hibernate +`Session`으로 실행했습니다. + +### 17.3 주제 2로 + +여기서 N+1 주제가 아키텍처 주제로 넘어갑니다. lite가 읽기 모델을 모델 수준으로 분리했다면, 고트래픽 읽기·가시성 사전계산(`feed_visible`)이 실제로 필요해지는 순간 그것을 저장소 수준으로 올리는 게 풀 CQRS입니다. 그때 계약·가드레일을 의도적으로 개정합니다. "N+1은 쓰기 모델로 읽기를 하려는 신호"라는 일반화가 여기서 헥사고날·CQRS 설계로 완결됩니다. + +--- + +## 18. 다음 단계 + +처음 만든 엔티티 조회에서 N+1을 확인한 뒤, 배치·프로젝션·Top-N·keyset·가시성 순서로 +조회 구조를 바꿨습니다. 왕복 수는 2,022개에서 23개로, 엔티티 로드는 1,569개에서 0개로, +하이라이트 전송은 1,509행에서 최대 60행으로 줄었습니다. 깊은 페이지는 2,000행 대신 20행을 +읽었습니다. 사전계산한 가시성 조회는 후보 1,500개 대신 20개에 접근했습니다. 이 결과를 같은 +저장소 위 CQRS-lite 읽기 경로에 반영했습니다. + +- **풀 CQRS(주제 2, 에스컬레이션)**: 고트래픽 읽기에서 `feed_visible` 사전계산이 실제로 + 필요해지면 별도 물리 읽기 저장소와 쓰기→읽기 동기화(도메인 이벤트/아웃박스)를 추가합니다. + 이 변경은 현재 계약의 범위를 넘으므로 계약과 가드레일을 함께 개정해야 합니다. +- **운영·다른 패러다임**: OSIV·커넥션 풀·Little's Law, 쓰기 N+1, 리액티브, 탐지기, + NoSQL 임베드는 이번 조회 문제를 해결한 뒤 별도 주제로 검증할 수 있습니다. + +작업을 마치고 보니 처음의 문제는 N+1 하나를 없애는 데서 끝나지 않았습니다. 화면에 필요한 +읽기 모델을 어떤 SQL·인덱스·모델로 만들 것인지까지 정해야 했습니다. + +--- + +## 부록. 측정 재현과 provenance, 함정 + +### A. 재현 + +```bash +cd src +./gradlew :app-bootstrap:test --tests '*FeedPersistenceIT*' # Docker 필요(Testcontainers) +``` + +- 곡선(N1): `l1CollectionNPlusOneGrowsLinearlyWithN` (N=10/100/1000), `collectionFetches == N` 확인. +- 실행계획(N1): `l1ExplainRepeatedHighlightChildQuery`, 반복되는 하이라이트 조회의 Index Scan 확인(→ [`evidence/explain/highlights-child-plan-A.txt`](./evidence/explain/highlights-child-plan-A.txt)). +- 곡선(N2): `l2ToOneEagerHiddenNPlusOneCurve` (N=10/100/1000), `pageFetch == N`(선형)·`userFetch ≤ 20`(평탄)·`entityFetch == pageFetch + userFetch` 확인. +- 접근 0 증명(N2): `l2EagerToOneFiresEvenWithZeroFieldAccess`, 접근 0인데 `pageFetch == 100`·`collectionFetch == 0`(EAGER는 나가고 LAZY는 안 나감). +- 실행계획(N2): `l2ExplainRepeatedPageToOneQuery`, pages·users의 pk Index Scan 확인(→ [`evidence/explain/toone-pages-plan.txt`](./evidence/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/explain/toone-users-plan.txt)). +- 다중 컬렉션 실패(9절): `l3TwoBagFetchJoinThrowsMultipleBagFetchException`, 두 bag 동시 fetch join이 `MultipleBagFetchException`(`IllegalArgumentException`으로 래핑)을 던지는 것 확인. +- 카테시안(9절): `l3SingleCollectionFetchJoinExplodesTransferredRows` (N=10/100/1000), 리스트 크기 = N(Hibernate 6+ dedup)인데 조인 카디널리티 = Σ highlights로 폭발하는 것 확인(→ [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv)). +- 실행계획(9절): `l3ExplainCollectionJoinRowMultiplication`, 조인(Hash Join) 노드 actual rows = Σ highlights 확인(→ [`evidence/explain/l3-cartesian-join-plan.txt`](./evidence/explain/l3-cartesian-join-plan.txt)). +- 인메모리 페이징(10절): `l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory` (N=10/100/1000), `returned == min(20, N)`인데 `feedItemLoaded == N`(전체 로드)임을 확인(→ [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv)). +- HHH000104 경고(10절): `l4EmitsHhh000104InMemoryPagingWarning`, `HHH90003004: ... collection fetch; applying in memory` WARN을 ListAppender로 캡처(코드 번호가 아니라 문구로 매칭). +- EXPLAIN 대조(10절): `l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes`, (a) 조인 SQL엔 Limit 노드 없음 / (b) 엔티티 페이징엔 있음 확인(→ [`evidence/explain/l4-collection-join-no-limit.txt`](./evidence/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/explain/l4-entity-paging-limit.txt)). +- 배치 해결(11절): `FeedBatchFetchIT`(신규, 격리 클래스 `default_batch_fetch_size=100`) `l5BatchFetchCollapsesQueryCount` (N=10/100/1000), `prepared < N`(순진 `1+N`에서 붕괴)·`collectionFetch == ceil(N/batch)` 확인(→ [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv)). +- 페이징 정상(11절): `l5EntityPagingLoadsOnlyThePageNotWholeDataset`, `feedItemLoaded == min(20, N)`(10절 over-fetch 소멸). EXPLAIN `l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication`, (a) 엔티티 페이징엔 Limit 노드 존재 / (b) 배치 IN은 semi-join(행 안 곱함)(→ [`evidence/explain/l5-entity-paging-limit.txt`](./evidence/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/explain/l5-batch-in-semijoin.txt)). +- 잔여 비용(11절): `l5ProbeBatchStillHydratesFullEntities`, 페이지 20건인데 `entitiesLoaded == 1,569`(엔티티 과적재 → 프로젝션 단계)(→ [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv)). +- 프로젝션 해결(12절): `FeedProjectionIT`(신규, 격리 클래스, 배치 설정 없음) `l6ProjectionHydratesZeroEntities` (N=10/100/1000), `entitiesLoaded == 0`(11절의 1,569 소멸)·`prepared == 2`(N 무관 상수)·`collectionFetch == 0` 확인. 형태 동치 `l6ProjectionReturnsSameShapeAsNaiveLoadFeed`(프로젝션 vs 순진 loadFeed 같은 결과)(→ [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)). +- EXPLAIN·width 정정(12절): `l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth`, (a) 부모 프로젝션 Limit 노드 존재하나 width 안 좁아짐(2088 > 엔티티 1194) / (b) 자식 IN semi-join(행 안 곱함). 프로젝션 이득은 EXPLAIN 아니라 ORM 층(→ [`evidence/explain/l6-parent-projection.txt`](./evidence/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/explain/l6-child-projection.txt) · [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv)). +- 잔여 비용(12절): `l6ProbeProjectionStillFetchesAllHighlightsNotTopN`, 페이지 20건인데 자식 행 `1,509`(부모당 전량, top-3 아님 → Top-N 단계)(→ [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)). +- 정확성·전송(13절): **별도 클래스 `FeedTopNIT`**(IT-only, native SQL) `l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`, 윈도우·LATERAL은 부모당 3개(반환 60·부모 20), 2단계는 앱컷 전 전량 `1,509`, 순진 `LIMIT 3`은 전체 3행(부모 1개만 = 오작동) 확인(→ [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv)). +- 플랜 대조(13절): `l14ExplainThreeWayPlanCompareIsTheCrownJewel`, 세 방법 `EXPLAIN (ANALYZE, BUFFERS)` — LATERAL은 `Index Scan`(buffers 204)·윈도우/2단계는 같은 `Hash Semi Join`(buffers 430, 전량 1,509) 확인(→ [`l14-lateral-plan.txt`](./evidence/explain/l14-lateral-plan.txt) · [`l14-window-plan.txt`](./evidence/explain/l14-window-plan.txt) · [`l14-twostep-plan.txt`](./evidence/explain/l14-twostep-plan.txt) · [`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv)). +- 인덱스 토글(13절): `l14LateralDependsOnCompositeIndex`, 같은 LATERAL을 `ix_highlights_feed_items_created` DROP 후 측정→`finally` 복구 — 인덱스 없으면 `Seq Scan`(Rows Removed by Filter 2842/loop)으로 buffers 168→4446(약 26배) 확인(→ [`l14-lateral-no-index.txt`](./evidence/explain/l14-lateral-no-index.txt) · [`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv)). +- 그룹 크기 곡선(13절): `l14GroupSizeCurveWindowVsLateral`(K=3/50/500), 반환 60/695/1,509이고 LATERAL buffers가 모든 K에서 윈도우보다 작음(작은 K일수록 격차↑) 확인(→ [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv)). +- 잔여 비용(13절): `l14ProbeParentPagingStillUsesOffsetNotKeyset`, 부모 페이징이 아직 `OFFSET 900`이라 앞 900행 scan-then-discard(→ keyset 페이징 단계). +- 깊이 곡선(14절): **별도 클래스 `FeedKeysetIT`**(IT-only, native SQL) `l15DeepPageOffsetOverScansButKeysetStaysFlat`(offset 0/980/1980), OFFSET 훑은 행 = offset+20(20/`1,000`/`2,000`)인데 keyset은 20으로 일정함(page 100에서 100× over-scan) 확인(→ [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv)). +- EXPLAIN·인덱스 유무(14절): `l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`, OFFSET `Seq Scan`+`Sort`(2,000, buffers 141) vs keyset `Index Only Scan`(20, buffers 1); 인덱스 없으면 keyset도 `Seq Scan`(buffers 141) 확인(→ [`l15-offset-deep-page.txt`](./evidence/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/explain/l15-keyset-no-index.txt) · [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv)). +- 정확성(14절): `l15KeysetWalkMatchesOffsetPages`, keyset 커서로 넘긴 page 2 == OFFSET page 2(같은 20 id·같은 순서). +- 가시성 probe(14절 → 가시성 인덱싱 단계): `l15ProbeVisibilityOrBreaksKeysetIndex`, keyset에 가시성 `OR`+`EXISTS`를 얹으면 정렬키 인덱스 미사용·`BitmapOr`+`Sort` 재등장(순서 seek 이점 소멸) 확인(→ [`l15-visibility-or-probe.txt`](./evidence/explain/l15-visibility-or-probe.txt)). +- 정확성(15절): **별도 클래스 `FeedVisibilityIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `l16ThreeApproachesReturnSameVisibleSet`, 단일 OR == UNION 분해 == 사전계산이 같은 20 feed_item(답 동일, 플랜만 다름) 확인(→ [`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv)). +- 3안 플랜 대조(15절): `l16ExplainThreeWayPlanCompare`, 단일 OR(`BitmapOr`+top-N `Sort`+hashed SubPlan, 후보 `1,500`, buffers 122) vs UNION(`Merge Append`+`Hash Join`, buffers 200) vs 사전계산(`Index Only Scan` on feed_visible, Sort 없음, buffers 1) 확인(→ [`l16-single-or-plan.txt`](./evidence/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt)). +- 분기별 인덱스(15절): `l16LowSelectivityBranchesRideTheirIndex`, mentioned 분기=`ix_mentions_user` 조인·private 분기=`ix_feed_items_private` partial의 `Index Only Scan` 확인(→ [`l16-union-branches.txt`](./evidence/explain/l16-union-branches.txt)). +- 사전계산=CQRS(15절 → CQRS-lite 읽기 모델 단계): `l16PrecomputeIsSingleIndexScanNoOrNoSort`, `feed_visible` 단일 `Index Only Scan`·Sort 없음·buffers 1 확인(→ [`l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt)). +- 통합 정확성·shape(16절): **별도 클래스 `FeedCrownIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `crownUnifiedReturnsSameShapeAcrossParentPaths`, 세 부모선택(단일 OR/UNION 분해/사전계산)이 같은 20 부모(unionEq·precomputeEq 참)·통합 결과 부모 20·총 60행·부모당 top-3 확인(→ [`evidence/metrics/crown-unified-plan.csv`](./evidence/metrics/crown-unified-plan.csv)). +- 한 플랜 세 기법(16절): `crownUnifiedPlanStacksVisibilityKeysetAndTopN`, 사전계산 부모선택 통합 쿼리가 `Index Only Scan`(ix_feed_visible) + `Nested Loop` LATERAL `Index Scan`(ix_highlights_feed_items_created)로 세 기법을 재정렬(Sort) 없이 한 플랜에 겹침 확인(→ [`crown-unified-precompute-plan.txt`](./evidence/explain/crown-unified-precompute-plan.txt)). +- 간섭 시험(16절): `crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`, 가장 깊은 페이지(보이는 `1,500` 중 마지막)에서 사전계산 부모선택은 `ix_feed_visible` 인덱스 range 로 19 행만, 단일 OR 부모선택은 feed_visible 미사용·`BitmapOr`+멘션 hashed SubPlan 으로 200 행 훑음(★ 실측정정: 깊은 커서에선 둘 다 남은 19 행 작은 Sort) 확인(→ [`crown-deep-keyset-precompute.txt`](./evidence/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/explain/crown-deep-keyset-single-or.txt)). +- CQRS-lite 읽기 모델(17절): **프로덕션 경로**(시리즈 첫 프로덕션 코드, IT-only 아님) `GetFeedReadModelUseCase` → `FeedReadModelQueryPort` → `FeedReadModelQueryAdapter`(신규). `FeedReadModelUseCaseIT`(seed N∈{10, 100})가 유스케이스 경로에서 엔티티 로드 0·발행 쿼리 상수 2(N 무관)·부모당 top-3(12절 프로젝션 + 13절 window 결합, 12절 잔여 `1,509` → ≤60 해소) 반환 확인. ArchUnit `query_ports_do_not_leak…`·의존 방향·`./gradlew check` GREEN. + +> 개별 테스트만 돌릴 때는 Gradle 와일드카드가 `*`임에 주의(`...`은 매칭 0). 예) `--tests '*FeedPersistenceIT.l2*'`. 초록불을 다시 돌리려면 `--rerun-tasks`(안 그러면 UP-TO-DATE로 건너뜀). 콘솔 측정 라인(`>>> LAB …`)은 `build/lab-results/feed-nplus1.md`에도 표로 적재됩니다. + +원시 데이터 자산: + +- [`evidence/metrics/l1-query-growth.csv`](./evidence/metrics/l1-query-growth.csv) — N, 초기화 컬렉션, 총 PreparedStatement, ToOne 몫. +- [`evidence/metrics/l1-skew-distribution.csv`](./evidence/metrics/l1-skew-distribution.csv) — 순위별 하이라이트 수. +- [`evidence/metrics/l2-toone-split.csv`](./evidence/metrics/l2-toone-split.csv) — N, Page·User·entity fetch, 초기화 컬렉션, 총 PreparedStatement(N2 직접 측정). +- [`evidence/explain/highlights-child-plan-A.txt`](./evidence/explain/highlights-child-plan-A.txt) — N1 Plan A EXPLAIN 원문. +- [`evidence/explain/toone-pages-plan.txt`](./evidence/explain/toone-pages-plan.txt) · [`evidence/explain/toone-users-plan.txt`](./evidence/explain/toone-users-plan.txt) — N2 반복 ToOne 부모 쿼리 EXPLAIN 원문. +- [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv) — N, 전송 행수(조인 카디널리티), 리스트 크기(Hib6 dedup), distinct, 시드 하이라이트, 폭발 배수, 총 PreparedStatement(9절 카테시안). +- [`evidence/explain/l3-cartesian-join-plan.txt`](./evidence/explain/l3-cartesian-join-plan.txt) — 9절 컬렉션 fetch join 조인의 EXPLAIN 원문(Hash Join actual rows = Σ highlights). +- [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv) — N, returned(페이지), feedItemLoaded(=N), over-fetch 배수, 시드 하이라이트(10절 인메모리 페이징, 결정적·hash-anchor). +- [`evidence/metrics/l4-cost-curve.csv`](./evidence/metrics/l4-cost-curve.csv) — N, 지연 p50/p99(ms), 스레드 누적 할당(KB). 측정 범위상 환경 의존 상대값이라 anchor가 아니라 whitelist(N에 따른 방향만 읽음). +- [`evidence/explain/l4-collection-join-no-limit.txt`](./evidence/explain/l4-collection-join-no-limit.txt) · [`evidence/explain/l4-entity-paging-limit.txt`](./evidence/explain/l4-entity-paging-limit.txt) — 10절 (a) 조인 SQL(Limit 노드 부재) / (b) 엔티티 페이징(Limit 노드 존재) EXPLAIN 원문. +- [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv) — N, before/after PreparedStatement·컬렉션 fetch, feedItemLoaded(페이지), 붕괴 배수(11절 배치 해결, 결정적·hash-anchor). +- [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv) — 페이지 20건 조회의 엔티티 하이드레이트 총수(11절 잔여 과적재 → 프로젝션 단계). +- [`evidence/explain/l5-entity-paging-limit.txt`](./evidence/explain/l5-entity-paging-limit.txt) · [`evidence/explain/l5-batch-in-semijoin.txt`](./evidence/explain/l5-batch-in-semijoin.txt) — 11절 (a) 엔티티 페이징(Limit 노드 존재) / (b) 배치 IN(semi-join, 곱셈 없음) EXPLAIN 원문. +- [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv) — before(11절 배치)/after(12절 프로젝션) 엔티티 로드·PreparedStatement·컬렉션 fetch·자식 행수(12절 프로젝션 해결, 결정적·hash-anchor). +- [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv) — 부모 프로젝션 width vs 엔티티 페이징 width(12.4절 실측 정정: 프로젝션이 오히려 넓습니다). +- [`evidence/explain/l6-parent-projection.txt`](./evidence/explain/l6-parent-projection.txt) · [`evidence/explain/l6-child-projection.txt`](./evidence/explain/l6-child-projection.txt) — 12절 (a) 부모 스칼라 프로젝션(Limit 존재, width 2088) / (b) 자식 스칼라 IN(semi-join, 행 안 곱함) EXPLAIN 원문. +- [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv) — 전략별(윈도우/LATERAL/2단계/순진) 반환 행·커버 부모·부모당 최대(13절 정확성·전송, 결정적·hash-anchor). +- [`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv) — 3안 최상위 노드·반환 행·buffers(shared hit)·exec(13절 플랜 대조). buffers·exec는 워밍 캐시 상대값이라 anchor가 아니라 whitelist(같은 실행 내 상대 대조로만). +- [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv) — K∈{3, 50, 500}별 윈도우/LATERAL 반환 행·buffers(13절 그룹 크기 곡선; 반환은 결정적, buffers는 whitelist). +- [`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv) — LATERAL 인덱스 유무 buffers·exec(13절 인덱스 의존; 환경 의존 상대값 whitelist). +- [`evidence/explain/l14-lateral-plan.txt`](./evidence/explain/l14-lateral-plan.txt) · [`evidence/explain/l14-window-plan.txt`](./evidence/explain/l14-window-plan.txt) · [`evidence/explain/l14-twostep-plan.txt`](./evidence/explain/l14-twostep-plan.txt) — 13절 세 해법 EXPLAIN 원문(LATERAL Index Scan / 윈도우 WindowAgg / 2단계 Hash Semi Join). +- [`evidence/explain/l14-lateral-no-index.txt`](./evidence/explain/l14-lateral-no-index.txt) — 13절 인덱스 DROP 후 같은 LATERAL EXPLAIN 원문(부모별 Seq Scan, buffers 폭증). +- [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv) — 페이지 깊이(offset)별 OFFSET/keyset 훑은 행·buffers(14절 깊이 곡선; OFFSET=offset+20 결정적·hash-anchor, buffers는 whitelist). +- [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv) — 깊은 페이지(offset 1980) OFFSET/keyset(+인덱스)/keyset(−인덱스) 최상위 노드·훑은 행·buffers·exec(14절; buffers·exec는 환경 의존 whitelist). +- [`evidence/explain/l15-offset-deep-page.txt`](./evidence/explain/l15-offset-deep-page.txt) · [`evidence/explain/l15-keyset-index-seek.txt`](./evidence/explain/l15-keyset-index-seek.txt) · [`evidence/explain/l15-keyset-no-index.txt`](./evidence/explain/l15-keyset-no-index.txt) — 14절 OFFSET(Seq Scan+Sort) / keyset(Index Only Scan) / keyset 인덱스 없음(Seq Scan) EXPLAIN 원문. +- [`evidence/explain/l15-visibility-or-probe.txt`](./evidence/explain/l15-visibility-or-probe.txt) — 14절 keyset + 가시성 OR/EXISTS EXPLAIN 원문(BitmapOr + Sort, 정렬키 인덱스 미사용 → 가시성 조건 인덱싱 단계). +- [`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv) — 가시성 3안(단일 OR/UNION 분해/사전계산) 최상위 노드·Sort·멘션 처리·훑는 후보·buffers·exec(15절; 훑는 후보 1500은 결정적·hash-anchor, buffers·exec는 환경 의존 whitelist). +- [`evidence/explain/l16-single-or-plan.txt`](./evidence/explain/l16-single-or-plan.txt) · [`evidence/explain/l16-union-decompose-plan.txt`](./evidence/explain/l16-union-decompose-plan.txt) · [`evidence/explain/l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt) — 15절 단일 OR(BitmapOr+Sort+hashed SubPlan) / UNION 분해(Merge Append+Hash Join) / 사전계산(단일 Index Only Scan) EXPLAIN 원문. +- [`evidence/explain/l16-union-branches.txt`](./evidence/explain/l16-union-branches.txt) — 15절 UNION 각 분기(mentioned=ix_mentions_user 조인 / private=partial 인덱스 / public=고선택도 bitmap) EXPLAIN 원문. +- [`evidence/metrics/crown-unified-plan.csv`](./evidence/metrics/crown-unified-plan.csv) — 통합(16절/Task 4) 부모선택별(사전계산/단일 OR) page 1·깊은 페이지 부모 수·행수·훑는 행·buffers·뷰어 가시 집합(부모/행/훑는 행은 결정적, buffers 는 환경 의존 whitelist). +- [`evidence/explain/crown-unified-precompute-plan.txt`](./evidence/explain/crown-unified-precompute-plan.txt) — 16절 사전계산 부모선택 통합 쿼리 EXPLAIN 원문(Index Only Scan feed_visible + Nested Loop LATERAL, Sort 없음 — 한 플랜 세 기법). +- [`evidence/explain/crown-deep-keyset-precompute.txt`](./evidence/explain/crown-deep-keyset-precompute.txt) · [`evidence/explain/crown-deep-keyset-single-or.txt`](./evidence/explain/crown-deep-keyset-single-or.txt) — 16절 깊은 페이지 keyset 간섭 시험 EXPLAIN 원문(사전계산 인덱스 range 19행 vs 단일 OR BitmapOr+멘션 SubPlan 200행). + +### B. 측정 환경·출처(provenance) + +6.2절과 7절 표의 수치는 아래 조건에서 나온 값입니다. 다른 환경에서는 지연 절대값·쿼리 플랜이 달라질 수 있으므로 절대값이 아니라 N에 따른 증가 형태로 읽습니다. + +| 항목 | 값 | +|---|---| +| 수치 출처 | N1: `FeedPersistenceIT.l1CollectionNPlusOneGrowsLinearlyWithN` 콘솔(`=== L1 N=… ===`) · N2: `l2ToOneEagerHiddenNPlusOneCurve`·`l2EagerToOneFiresEvenWithZeroFieldAccess`·`l2ExplainRepeatedPageToOneQuery` 콘솔(`>>> LAB L2 …`) · 9절(Fetch Join): `l3TwoBagFetchJoinThrowsMultipleBagFetchException`·`l3SingleCollectionFetchJoinExplodesTransferredRows`·`l3ExplainCollectionJoinRowMultiplication` 콘솔(`>>> LAB OBSERVE L3 …`) · 10절(인메모리 페이징): `l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory`·`l4EmitsHhh000104InMemoryPagingWarning`·`l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes` 콘솔(`>>> LAB OBSERVE L4 …`) · 11절(배치 해결): **별도 클래스 `FeedBatchFetchIT`**(`default_batch_fetch_size=100` 격리)의 `l5BatchFetchCollapsesQueryCount`·`l5EntityPagingLoadsOnlyThePageNotWholeDataset`·`l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication`·`l5ProbeBatchStillHydratesFullEntities` 콘솔(`>>> LAB OBSERVE L5 …`) · 12절(프로젝션 해결): **별도 클래스 `FeedProjectionIT`**(배치 설정 없음, sibling 메서드 `loadFeedProjection`)의 `l6ProjectionHydratesZeroEntities`·`l6ProjectionReturnsSameShapeAsNaiveLoadFeed`·`l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth`·`l6ProbeProjectionStillFetchesAllHighlightsNotTopN` 콘솔(`>>> LAB OBSERVE L6 …`) · 13절(Top-N-per-group): **별도 클래스 `FeedTopNIT`**(IT-only, native SQL을 `JdbcTemplate`으로)의 `l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14ExplainThreeWayPlanCompareIsTheCrownJewel`·`l14TransferAcrossStrategies`·`l14GroupSizeCurveWindowVsLateral`·`l14LateralDependsOnCompositeIndex`·`l14ProbeParentPagingStillUsesOffsetNotKeyset` 콘솔(`>>> LAB OBSERVE L14 …`) · 14절(keyset vs OFFSET): **별도 클래스 `FeedKeysetIT`**(IT-only, native SQL·정렬키 인덱스 CREATE/DROP 토글)의 `l15DeepPageOffsetOverScansButKeysetStaysFlat`·`l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`·`l15KeysetWalkMatchesOffsetPages`·`l15ProbeVisibilityOrBreaksKeysetIndex` 콘솔(`>>> LAB OBSERVE L15 …`) · 15절(가시성 술어 인덱싱): **별도 클래스 `FeedVisibilityIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글)의 `l16ThreeApproachesReturnSameVisibleSet`·`l16ExplainThreeWayPlanCompare`·`l16LowSelectivityBranchesRideTheirIndex`·`l16PrecomputeIsSingleIndexScanNoOrNoSort` 콘솔(`>>> LAB OBSERVE L16 …`) · 16절(통합/Task 4): **별도 클래스 `FeedCrownIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글)의 `crownUnifiedReturnsSameShapeAcrossParentPaths`·`crownUnifiedPlanStacksVisibilityKeysetAndTopN`·`crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`·`crownDecisionMatrixClaimsHoldInOneQuery` 콘솔(`>>> LAB OBSERVE crown …`) 및 리포트 `build/lab-results/feed-nplus1.md`·`feed-nplus1-l5.md`·`feed-nplus1-l6.md`·`feed-nplus1-l14.md`·`feed-nplus1-l15.md`·`feed-nplus1-l16.md`·`feed-nplus1-crown.md` | +| 9절 측정 방식 주의 | 순진 조회(N1/N2)는 `loadFeed`(Spring Data `Pageable`)이지만, 9절의 fetch join은 **원시 JPQL**(`Pageable` 없음)이라 count 쿼리가 없습니다. 전송 행수는 `resultList.size()`가 아니라 조인 count(`SELECT count(*) FROM feed_items JOIN highlights …`)로 측정한다 — Hibernate 6+ 루트 dedup 때문. | +| 런타임 | Java 21 · Spring Boot 4.0.0 · Hibernate ORM 7.1.8.Final | +| DB | PostgreSQL `postgres:16-alpine`(Testcontainers, 클래스당 1개 공유) | +| 지연 표본 | 반복 7회 중 워밍업 2회 제외한 5회의 중앙값/최댓값 | +| Persistence Context | 지연 반복마다 `em.clear()`(측정 구간 밖) | +| DB 캐시 | warm(`shared read=0`) | +| 소스 모듈 | 어댑터 `adapter/outbound/persistence-jpa`, 테스트 `app-bootstrap` | +| 원문 로그 | `app-bootstrap/build/test-results/test/TEST-*FeedPersistenceIT*.xml`의 system-out | + +재현성을 더 높이려면 Docker 이미지를 digest로 고정하고(`postgres:16-alpine@sha256:…`) 측정 시작 시 `select version()`·`show server_version_num`·`show random_page_cost`·`show work_mem`를 함께 기록한다(쿼리 플랜은 버전·planner setting에 좌우됩니다). + +### C. 함정(테스트 설정) + +`@DataJpaTest`는 테스트 클래스 패키지에서 위로 올라가며 `@SpringBootConfiguration`을 찾습니다. 측정 테스트가 부트 앱(`CaSkeletonApplication`)의 조상 패키지가 아니라 형제 패키지에 있으면 "Unable to find a @SpringBootConfiguration"으로 실패합니다. `@ContextConfiguration(classes = CaSkeletonApplication.class)`로 설정 클래스를 명시하면 해결됩니다. + +### D. 슬라이드용 캡처 + +발표 슬라이드에서 화면 캡처로 보여줄 스크린샷은 [`assets/`](./assets/README.md)에 둔다(콘솔·SQL 로그·EXPLAIN 캡처). `assets/`은 슬라이드 캡처, `evidence/`는 원시 데이터·그림으로 역할을 구분합니다. diff --git a/examples/golden/executable-clean-architecture/assets/runtime-call.svg b/examples/golden/executable-clean-architecture/assets/runtime-call.svg new file mode 100644 index 0000000..1692996 --- /dev/null +++ b/examples/golden/executable-clean-architecture/assets/runtime-call.svg @@ -0,0 +1,28 @@ + + +Runtime call +FeedController calls GetFeedUseCase, which dispatches to SpringTransactionPort at runtime. +{"source":"runtime-call-source-dependency.svg","panel":"upper","canvas_policy":"diagram-only"} + + + + + + +실행 시점 관계 · 실선 = 호출·디스패치 + +FeedController +driving adapter + +GetFeedUseCase +concrete service + +SpringTransactionPort +runtime implementation + + +Runtime call + + +Runtime dispatch + diff --git a/examples/golden/executable-clean-architecture/assets/source-dependency.svg b/examples/golden/executable-clean-architecture/assets/source-dependency.svg new file mode 100644 index 0000000..6492a42 --- /dev/null +++ b/examples/golden/executable-clean-architecture/assets/source-dependency.svg @@ -0,0 +1,36 @@ + + +Source dependency and contract ownership +GetFeedUseCase depends on application-core contracts, while SpringTransactionPort implements TransactionPort. +{"source":"runtime-call-source-dependency.svg","panel":"lower","canvas_policy":"diagram-only"} + + + + + + + +계약 소유·소스 의존 · 점선 = 타입·계약을 향함 + +<<interface>> +QueryUseCase<Q,R> +application-core contract + +GetFeedUseCase +implements · calls + +<<interface>> +TransactionPort +application-core contract + + +Implements + + +Runtime call + +SpringTransactionPort + + +Implements + diff --git a/korean-technical-blog-skills-bundle-v1/MANIFEST.sha256 b/korean-technical-blog-skills-bundle-v1/MANIFEST.sha256 new file mode 100644 index 0000000..e35bc26 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/MANIFEST.sha256 @@ -0,0 +1,58 @@ +7630620a1b7231f5e1163c41f643e1b95ad1b081371db7c22835d6fca4acf483 README.md +88bb893acdced5d03be8ee657aacf51436d57f29c35241ad559f9da560dc3ad8 editing-korean-grammar-and-expression/README.md +7b30f057335ef5343762c9b95f6f7c6e4c8ced83b490d1a248394e617ea6315c editing-korean-grammar-and-expression/SKILL.md +3dbee5f3dfd935698ea8b37b65c9c91d2a12ce31200dfeb57c7afedcdadf9cf3 editing-korean-grammar-and-expression/references/decision-policy.md +53258e2a1ec7091c5aac15a837776d27659c8912360d07138e4d227ca8252228 editing-korean-grammar-and-expression/references/output-modes.md +0d0ad5d87b5f0c94fd1d95c65003591eed40450a89e2bed1ea0e7687ce3b14e9 editing-korean-grammar-and-expression/references/rule-catalog.md +5bbe200ede5c7ca85ffb094c9dd01fdcaef3b6371f88a5f518f45fe4f414ed46 editing-korean-grammar-and-expression/references/source-basis.md +5f87b3f8a8f50c08db829e5dd14905970d40c5f79cf005fa81ef478849e1fd6c editing-korean-grammar-and-expression/scripts/validate_skill.py +f096d85c1256cb2dddea86107e12beee36949619fd04ec8cb9d38316ded70f7e editing-korean-grammar-and-expression/tests/cases.json +ded7fcfb1a7f6fed9e5396f4a1dd6d35947de626a2c644e395a571906a1f5324 editing-korean-grammar-and-expression/tests/evaluation-rubric.md +95e0b66aa3ed103548245e781f47541781b9cede7a9a30892ad75000e38d948b editing-korean-grammar-and-expression/tests/pressure-scenarios.md +cf2f5554341c83c87dc3778949fc74b5ef7f067236b42435a9797834d2d2d10a reducing-ai-like-korean-writing/README.md +ecbe2056f40780a0f37d292b6725e73fc5842bc9b129f3061dad8f568d187865 reducing-ai-like-korean-writing/SKILL.md +8e2497974b6c0449a42bebddd83e3e797510633cac8a38c15e6237209b2d4531 reducing-ai-like-korean-writing/references/decision-policy.md +bcca95cbee25c11fb2267245d2a58c9960b9a68a08048eaa52ada7775a807126 reducing-ai-like-korean-writing/references/genre-profiles.md +20405fd7fdc6c62cefcc48a377708162f6f5f5202b92179baa54b03ea6f561f4 reducing-ai-like-korean-writing/references/output-modes.md +6807778f2058346438d4903929b23dbbff83a9f253810368e4e1dda09a6897c8 reducing-ai-like-korean-writing/references/pattern-catalog.md +2f9a87913c259e41eae59ee62380849751382e5418c4e67279aad23d6bfdb769 reducing-ai-like-korean-writing/references/source-basis.md +444ee79893e6c528988557031095f15ccb399c6b1a46ce4ee804739db8a8bbba reducing-ai-like-korean-writing/scripts/validate_skill.py +7d42fd42febfeb08bef466f83409b4d7a1ff94957fba86bad26d2f44ab5acf37 reducing-ai-like-korean-writing/tests/baseline-observations.md +28f62b648ba5185cc45b66916277f1eee8aaa591c676ca9d74881b6e16e53beb reducing-ai-like-korean-writing/tests/cases.json +2ad2fd862c06e549f5601d4ceacaaab9a468c56ff5b9788875427f168822eb32 reducing-ai-like-korean-writing/tests/evaluation-rubric.md +d06418dcfc991ce6afec168d6bb5f0be129d05f8048bb686acd3ba7937855e9f reducing-ai-like-korean-writing/tests/pressure-scenarios.md +9a1a4da5650006da39a0f0300aefb7ee1acc341fe99acfc6ae775f513a0c2b3a writing-korean-technical-blogs/README.md +89fef42eb8f2bb7ce5626c3303b49ec366413c7e8f3aa2d4552c1470559aed56 writing-korean-technical-blogs/SKILL.md +5a036ef405358370c3162d659f0900c33c588fb14fd1be71513e3cc13e5db377 writing-korean-technical-blogs/examples/end-to-end-performance-case.md +a800700eacc32f834736f082380687f65a962de72c7aff1b29ea132bb03ba5c1 writing-korean-technical-blogs/examples/revision-pairs.jsonl +26473dddaa0695d5a0dbd7c6d9a3da77dfd99e686650a27d789f51d4929a12bc writing-korean-technical-blogs/lexicons/formulaic-openings-and-closings.yaml +741bf512903ed0bcdb3c43dc4575c65e00bd6fd413fe238b9ce331eb8e751c29 writing-korean-technical-blogs/lexicons/product-names.example.yaml +db4c48c7d0a6c20c46f7ea82fb9ba28a645462a5e2f045498703f4ada746437e writing-korean-technical-blogs/lexicons/protected-identifiers.example.yaml +0eee62d3891f6499b2682e36a9418297d6aec66c9217440504e1dc9a2b52d18b writing-korean-technical-blogs/lexicons/vague-expressions.yaml +910c52906d19bd29c068f9696f2edcc81c2149d4c06b6bb3ee052eb048921667 writing-korean-technical-blogs/profiles/architecture-decision.yaml +2b8a37f5dc61af83fd224ce25be614f5d6f30b7a9ca9af768b64d0c3d56b77ac writing-korean-technical-blogs/profiles/conversational-tech.yaml +557ea745b8c517d8535b9787399245317a98c328b9a2da3b00f2e393d6a19113 writing-korean-technical-blogs/profiles/default-formal.yaml +86528843f3efc5288121dfa2b1b13db1c1ed90c27334d0e3fe65b53802435b34 writing-korean-technical-blogs/profiles/incident-postmortem.yaml +3f59159555be2e300c0944f36b5753228232064ce89daf11acc4212c1a2a5cd5 writing-korean-technical-blogs/profiles/migration-case-study.yaml +1635d41c396bbb5f133c9c6a3535f76f7d5bd61f5a67f029d53cf0829d4f5c62 writing-korean-technical-blogs/profiles/performance-case-study.yaml +d4be41789818f1cdafed59f24a1d18a719153f48bfb1d9024888d356f9261f4e writing-korean-technical-blogs/profiles/recruitment-tech-content.yaml +52412ea45369baad5d0f715bc45e0abcf3de0d87184d3e6e1b491cf98f384c25 writing-korean-technical-blogs/profiles/tooling-adoption.yaml +77f56eefa54db15f00adede694a0f7f61a1c2d87464ca12cfc0365bc8c817b58 writing-korean-technical-blogs/profiles/tutorial-lab.yaml +407136db136e7a27afc4a5c6ed635a0d479b5b4372370fd8af3a44ab94c4bdfd writing-korean-technical-blogs/references/decision-policy.md +58013844347c1e02a7183a4320e000cfef089d29e704f054f4a5bc7f40919ff0 writing-korean-technical-blogs/references/enterprise-blog-patterns.md +0846e1b5293de602e15f52dec4f9776f5e302d101df4abd8356b69b6186492b5 writing-korean-technical-blogs/references/evidence-and-source-policy.md +ba935624b8d143d573c85a05f4d931ec6bda9959ce3ef48eb69ff6b55b44a6ea writing-korean-technical-blogs/references/exceptions.md +97f93c70523bf0cc1fcf0cad351a69b48d702420bd45bbc2841c6236df1a794e writing-korean-technical-blogs/references/output-modes.md +849fba1475eac2ff5258e80be8a3f1cc9cd49c013ca9ed703b5a8ac112bf4b60 writing-korean-technical-blogs/references/rule-catalog.md +3b933fa88f52f5e596f8231b0b128d5ca86b28cc452db91864a66e3d3d3b79a4 writing-korean-technical-blogs/references/source-basis.md +88047b6409edb2b1e8705b1a5431bbb7f594ef8cb32fd43765a6c5d03da39803 writing-korean-technical-blogs/references/structure-patterns.md +db85244892b698fc3dc424972920074f43f970d4ebccc09354eb1f3a891ce0d8 writing-korean-technical-blogs/references/titles-introductions-conclusions.md +c110176b07a4a4edf75c9aa6edc374e08250be9a27bef0823b2f41ed085d6b8d writing-korean-technical-blogs/schemas/article-brief.schema.json +417548ed4936633bdff7fb4aa87683130636932dfebe44c541c4b0fd426deb70 writing-korean-technical-blogs/schemas/article-result.schema.json +9537896cb1914a8b6537aaa6b27d51b0e06e94bc60280a8ff1990f5904e4532c writing-korean-technical-blogs/schemas/rubric.schema.json +ccd2fbe9b8c87af814eae9790df863b50b93f518cc1ba871ef2930ddac54c3e3 writing-korean-technical-blogs/scripts/validate_skill.py +343d04ca2c1f5139a94176420417d5481aeaccfefdf6f4f09cd31a1654ed1201 writing-korean-technical-blogs/tests/baseline-observations.md +50772b7b691fc86631b5e4ae35997d9c9ef056662eb43a76500c9ff27c239a09 writing-korean-technical-blogs/tests/cases.json +03e73c9a515449f2a8a0162d1b90176f23d75efbf5d2ef255592dd8cf39a9d21 writing-korean-technical-blogs/tests/evaluation-rubric.md +cfb996bb669ac09e3ffded859f421c8f30162c34eedf69446a6c85f9876bd961 writing-korean-technical-blogs/tests/pressure-scenarios.md +6605eef379ba9e91d2ee4a60a9b28b36aa50a87037c89264afc601cf59515949 writing-korean-technical-blogs/tests/workflow.jsonl diff --git a/korean-technical-blog-skills-bundle-v1/README.md b/korean-technical-blog-skills-bundle-v1/README.md new file mode 100644 index 0000000..e717134 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/README.md @@ -0,0 +1,34 @@ +# 한국어 기술 블로그 Agent Skills 번들 + +다음 세 스킬을 함께 설치하는 번들이다. + +1. `writing-korean-technical-blogs` + - 자료를 기술 블로그의 문제·제약·선택·구현·결과·한계 구조로 작성·재구성한다. +2. `reducing-ai-like-korean-writing` + - 상투성, 추상화, 반복, 과잉 구조화를 줄이되 사실과 기술 의미를 보존한다. +3. `editing-korean-grammar-and-expression` + - 최종 맞춤법, 띄어쓰기, 문법, 호응을 보수적으로 검수한다. + +## 권장 실행 순서 + +```text +원자료와 초안 +→ writing-korean-technical-blogs +→ reducing-ai-like-korean-writing +→ editing-korean-grammar-and-expression +→ 사실·수치·코드·인용 최종 대조 +``` + +## 하네스와의 경계 + +이 번들은 한 편의 글을 작성하는 전문 능력을 제공한다. 다음까지 필요하면 세 스킬 위에 별도 `technical-blog-production` 하네스를 둔다. + +- 다중 출처 조사와 출처 수집 +- 코드·명령어 실제 실행 검증 +- 이미지와 다이어그램 제작 +- 중간 산출물과 재시작 상태 관리 +- CMS 게시와 배포 확인 + +## 설치 + +번들 안의 세 폴더를 Agent Skills 디렉터리 아래에 각각 복사한다. 번들 루트 자체를 하나의 스킬로 설치하지 않는다. diff --git a/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/README.md b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/README.md new file mode 100644 index 0000000..6f7a6c5 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/README.md @@ -0,0 +1,44 @@ +# editing-korean-grammar-and-expression + +한국어 맞춤법·띄어쓰기·문법·높임·표현을 보수적으로 교정하는 Agent Skill 패키지다. 의미, 수치, 코드, URL, 고유 명칭, 허용 표현과 의도적인 말투를 우선 보존한다. + +## 구성 + +```text +editing-korean-grammar-and-expression/ +├── SKILL.md +├── README.md +├── references/ +│ ├── decision-policy.md +│ ├── output-modes.md +│ ├── rule-catalog.md +│ └── source-basis.md +├── scripts/ +│ └── validate_skill.py +└── tests/ + ├── cases.json + ├── evaluation-rubric.md + └── pressure-scenarios.md +``` + +## 사용 예 + +```text +이 문서를 원래 말투와 기술 용어를 유지하면서 한국어 문법·표현만 윤문해 주세요. +``` + +```text +다음 발표 대본을 preserve-style 모드로 교정하고, 확정 오류만 설명해 주세요. +``` + +```text +다음 문장을 teaching 모드로 교정해 주세요. 혼동하기 쉬운 반례도 함께 설명하세요. +``` + +## 검증 + +```bash +python scripts/validate_skill.py +``` + +실제 에이전트 행동 검증은 `tests/pressure-scenarios.md`와 `tests/cases.json`을 스킬 전후 조건에서 실행한다. diff --git a/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/SKILL.md b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/SKILL.md new file mode 100644 index 0000000..dab1094 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/SKILL.md @@ -0,0 +1,85 @@ +--- +name: editing-korean-grammar-and-expression +description: Use when revising Korean text that may contain spelling, spacing, grammar, honorific, register, or expression problems, especially when meaning, formatting, terminology, code, quotations, and intentional voice must remain unchanged. +--- + +# 한국어 문법·표현 윤문 + +## 개요 + +한국어 문장을 **보수적으로 교정하고 필요한 범위만 윤문**한다. 핵심 원칙은 다음과 같다. + +> 맞는 표현을 틀렸다고 바꾸지 않는다. 의미·사실·문체를 바꿀 위험이 있으면 수정하지 않고 보류한다. + +이 스킬은 표준어 기반의 일반 한국어를 기본 대상으로 한다. 맞춤법·띄어쓰기·문법 오류는 교정하지만, 자연스러움·간결성·문체 취향은 사용자가 요청하지 않는 한 제안으로만 다룬다. + +## 기본 입력 + +가능하면 다음 정보를 사용한다. 없으면 문맥에서 추론하되, 교정을 막는 중의성이 있을 때만 경고한다. + +- 원문 +- 목적: 교정, 윤문, 표준화, 학습용 설명 +- 문서 유형과 독자 +- 보존할 용어·고유 명칭·말투 +- 출력 모드 + +## 필수 절차 + +1. **범위 결정:** 강제 규범 교정과 선택적 문체 개선을 분리한다. +2. **보호 구간 식별:** 코드, URL, 전자 우편, 경로, 명령어, 식별자, 직접 인용, 사용자가 잠근 구간을 읽기 전용으로 둔다. +3. **문맥 판정:** 표면 문자열만 보지 말고 품사·뜻·앞뒤 문장을 함께 본다. +4. **최소 수정:** 같은 정확성을 얻을 수 있다면 공백 수정, 한 어절 수정, 문장 재작성 순으로 선호한다. +5. **불변식 검증:** 부정, 조건, 시제, 양태, 수치, 고유 명칭, 기술 용어, 높임 등급, 마크다운 구조가 유지됐는지 확인한다. +6. **보류:** 복수 해석이 남거나 전문 용어·고유 명칭 가능성이 있으면 원문을 유지하고 경고한다. + +## 판정 기준 + +| 등급 | 조건 | 처리 | +|---|---|---| +| A | 공식 규범을 직접 적용할 수 있고 해석이 하나임 | 자동 교정 | +| B | 품사·뜻·문맥이 일치하고 경쟁 분석이 없음 | 자동 교정 + 필요 시 근거 | +| C | 한 해석이 우세하지만 다른 해석도 가능함 | 제안 | +| D | 의미·지시 대상·전문 용어 여부가 불명확함 | 보류 또는 질문 | +| E | 보호 구간·의도적 문체·허용형임 | 유지 | + +세부 우선순위와 충돌 규칙은 `references/decision-policy.md`를 따른다. 띄어쓰기·활용·높임 등의 최소 대조 사례는 `references/rule-catalog.md`를 필요할 때만 읽는다. + +## 절대 규칙 + +- 원문에 없는 사실·효용·감정·인과관계를 추가하지 않는다. +- 가능성을 확정으로, 권고를 의무로, 일부를 전체로 강화하지 않는다. +- 조사·의존 명사·어미가 갈릴 수 있는 표현을 일괄 치환하지 않는다. +- 규범상 허용되는 표현을 오류로 표시하거나 한 형태로 강제 통일하지 않는다. +- 방언·신조어·캐릭터 말투는 표준화 요청이 없으면 보존한다. +- 근거 없이 “더 자연스럽다”, “보통 이렇게 쓴다”라고 단정하지 않는다. + +## 출력 + +기본값은 `brief`다. 교정문을 먼저 제시하고, 의미 있는 수정과 경고만 짧게 덧붙인다. 사용자가 결과만 요구하면 `silent`, 학습을 원하면 `teaching`, 중의성이 핵심이면 `review`를 사용한다. 형식은 `references/output-modes.md`를 따른다. + +## 대표 예시 + +**입력** + +> 문서의 `할수있다` 필드는 변경하지 말고, 이 일은 할수있다. 비가 올듯하다. + +**교정** + +> 문서의 `할수있다` 필드는 변경하지 말고, 이 일은 할 수 있다. 비가 올듯하다. + +- 인라인 코드는 보호한다. +- 일반 문장의 의존 명사 `수`는 띄어 쓴다. +- `올듯하다`는 허용형이므로 오류로 고치지 않는다. + +## 흔한 실패 + +| 실패 | 올바른 대응 | +|---|---| +| 모든 `뿐·만큼·대로·지`를 같은 방식으로 띄움 | 품사와 의미를 먼저 판정 | +| 한 오류 때문에 문단 전체를 다시 씀 | 오류 범위만 최소 수정 | +| 허용형을 선호형으로 강제 변경 | 맞는 입력은 유지 | +| 윤문하면서 단정 강도나 주체를 변경 | 원문의 명제와 양태 보존 | +| 코드·URL·제품명 내부를 교정 | 보호 구간으로 제외 | +| 문맥이 부족한데 확신하는 설명을 생성 | 원문 유지 + 경고 | + +배포 전에는 `tests/cases.json`과 `tests/evaluation-rubric.md`로 회귀 검증한다. diff --git a/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/decision-policy.md b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/decision-policy.md new file mode 100644 index 0000000..075a2b1 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/decision-policy.md @@ -0,0 +1,111 @@ +# 판정·보존 정책 + +## 1. 기본 정책 + +- 기본 언어 변종: 표준어 +- 기본 문체: 원문 보존 +- 기본 교정 성향: 보수적 +- 생성 기본값: 원칙형 우선 +- 입력이 이미 허용형이면: 유지 +- 해결되지 않은 중의성: 자동 수정 금지 +- 선택적 자연스러움 개선: 제안으로 분리 + +오류를 하나 놓치는 것보다 올바른 표현을 잘못 고치거나 의미를 바꾸는 위험을 더 크게 본다. + +## 2. 우선순위 + +아래 순서에서 상위 항목은 항상 하위 항목을 제약한다. + +1. 사용자 잠금과 보호 구간 +2. 의미·사실·데이터 보존 +3. 공식적으로 확정 가능한 강제 규범 +4. 사전의 품사·뜻·단어 판정 +5. 통사·의미 문맥 +6. 높임·문체 일관성 +7. 자연스러움·간결성 +8. 취향 기반 재작성 + +하위 규칙이 상위 규칙과 충돌하면 하위 수정을 취소하고 원문을 유지하거나 `review`로 보낸다. + +## 3. 반드시 보존할 불변식 + +- 명제적 의미 +- 긍정과 부정 +- 조건과 예외 +- 시제와 시간 관계 +- 가능성·의무·권고·추정 등 양태 +- 주체·객체·지시 대상 +- 인명·지명·기관명·제품명 +- 숫자·날짜·단위·버전 +- 기술 용어와 사용자가 지정한 표기 +- 인용문과 발화자의 의도 +- 목록, 표, 제목, 링크 등 마크다운 구조 +- 화자의 높임 등급과 의도적인 구어체 + +## 4. 보호 구간 + +다음 구간은 기본적으로 읽기 전용이다. + +```text +fenced_code +inline_code +url +email +file_path +shell_command +identifier +quoted_verbatim +user_locked_span +``` + +마크다운 파서나 구문 정보를 우선하며 정규식은 후보 탐지에만 쓴다. 보호 구간 안에서 맞춤법 오류처럼 보이는 문자열도 바꾸지 않는다. + +## 5. 자동 교정 금지 조건 + +다음 조건 중 하나라도 충족하면 자동 수정하지 않는다. + +- 품사에 따라 답이 달라지는 표현인데 문맥이 부족함 +- 뜻에 따라 띄어쓰기가 달라짐 +- 전문 용어, 제품명, 고유 명칭일 가능성이 있음 +- 원문이 방언·캐릭터 말투·문학적 파격일 수 있음 +- 원칙형과 허용형이 모두 맞음 +- 수정하면 부정·조건·시제·양태·논항이 바뀔 수 있음 +- 높임 대상이나 발화 관계가 불명확함 +- 인용 범위가 불명확함 + +## 6. 출처 우선순위 + +외부 확인이 가능하고 판정이 필요한 경우 다음 순서를 따른다. + +1. 국립국어원 한국어 어문 규범·한글 맞춤법 +2. 국립국어원 표준어 규정과 표준국어대사전 +3. 국립국어원의 표준 문법 연구 +4. 국립국어원의 한국어교육 문법·표현 연구 +5. 온라인가나다 등 개별 문맥 상담 자료 + +개별 상담 답변은 규정 본문이나 사전보다 높은 기준으로 사용하지 않는다. 자료가 충돌해 보이면 먼저 품사·뜻·문맥이 같은지 확인하고, 해결되지 않으면 보류한다. + +## 7. 수정 비용 + +같은 규범 적합도를 달성한다면 다음 순서로 선호한다. + +1. 공백만 수정 +2. 철자 또는 한 어절 수정 +3. 짧은 구 수정 +4. 문장 재작성 +5. 문단 재구성 + +문장·문단 재작성은 사용자가 명시적으로 윤문이나 표준화를 요청했을 때만 허용한다. + +## 8. 최종 자체 검증 + +출력 전 다음을 비교한다. + +- 숫자와 고유 명칭이 동일한가 +- 부정·조건·시제·양태가 동일한가 +- 보호 구간이 바이트 수준에서 동일한가 +- 문체와 높임 등급이 유지됐는가 +- 허용형을 오류로 바꾸지 않았는가 +- 수정 설명이 실제 수정과 일치하는가 + +하나라도 확신할 수 없으면 해당 수정만 롤백하고 경고한다. diff --git a/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/output-modes.md b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/output-modes.md new file mode 100644 index 0000000..603755e --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/output-modes.md @@ -0,0 +1,101 @@ +# 출력 모드 + +사용자 요청이 명시적이면 그 형식을 우선한다. 그렇지 않으면 `brief`를 사용한다. + +## `silent` + +교정문만 반환한다. + +```text + +``` + +대량 처리나 사용자가 “결과만”을 요청한 경우에 적합하다. 중대한 중의성이 있으면 짧은 경고를 예외적으로 덧붙인다. + +## `brief` — 기본값 + +교정문을 먼저 제시한 뒤, 의미 있는 수정과 경고만 짧게 정리한다. + +```markdown + + +수정 사항 +- `` → ``: <짧은 근거> + +확인이 필요한 부분 +- <중의성 또는 보존 이유> +``` + +수정이 없으면 “교정할 확정 오류를 찾지 못했습니다” 정도로 끝내며, 불필요하게 원문을 반복 설명하지 않는다. + +## `teaching` + +한국어 학습이나 규칙 설명이 목적일 때 사용한다. + +```markdown +## 교정문 + + +## 수정 설명 +1. 원문 / 수정문 +2. 오류 유형 +3. 적용 조건 +4. 혼동하기 쉬운 반례 +``` + +확정할 수 없는 문법 이론을 하나의 정답처럼 단정하지 않는다. + +## `review` + +복수 해석이나 전문 용어 가능성이 핵심일 때 사용한다. 원문을 먼저 보존한다. + +```markdown +## 제안 +- 원문 유지 +- 가능한 수정안: ... + +## 판단에 필요한 문맥 +- ... +``` + +질문 없이도 안전한 부분은 먼저 교정하고, 막히는 지점만 분리한다. + +## `preserve-style` + +강제 규범만 교정하고 방언·구어체·말줄임·캐릭터 말투·문장 호흡은 보존한다. + +## `standardize` + +사용자가 명시적으로 표준어·격식체 통일을 요청했을 때만 사용한다. 변경 범위가 넓어질 수 있으므로 다음을 함께 밝힌다. + +- 표준화한 말투와 종결형 +- 보존한 고유 명칭과 기술 용어 +- 의미 또는 화자 개성이 달라질 수 있어 유지한 부분 + +## 구조화 출력 + +도구나 후속 자동화가 요구할 때만 다음 계약을 사용한다. + +```json +{ + "corrected_text": "...", + "edits": [ + { + "span": [0, 0], + "original": "...", + "replacement": "...", + "rule_id": "...", + "severity": "mandatory|suggestion", + "confidence": "A|B|C", + "explanation": "..." + } + ], + "warnings": [ + { + "type": "ambiguity|missing_context|possible_proper_noun|allowed_variant", + "message": "..." + } + ], + "unchanged_protected_spans": ["..."] +} +``` diff --git a/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/rule-catalog.md b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/rule-catalog.md new file mode 100644 index 0000000..b94fe50 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/rule-catalog.md @@ -0,0 +1,116 @@ +# 핵심 규칙과 최소 대조 사례 + +이 문서는 문자열 치환표가 아니다. 각 항목은 **적용 조건과 반례를 함께 확인**할 때만 사용한다. + +## 1. 조사와 의존 명사 + +조사는 앞말에 붙이고 의존 명사는 띄어 쓴다. 같은 표면형이 조사·의존 명사·어미로 갈릴 수 있으므로 앞말의 품사와 뜻을 함께 본다. + +| 유지·교정 결과 | 판정 | +|---|---| +| 이것뿐이다 | 체언 뒤 조사 `뿐`: 붙임 | +| 웃을 뿐이다 | 관형사형 뒤 의존 명사 `뿐`: 띄움 | +| 학생만큼 잘한다 | 체언 뒤 조사 `만큼`: 붙임 | +| 노력한 만큼 얻었다 | 관형사형 뒤 의존 명사 `만큼`: 띄움 | +| 약속대로 하세요 | 체언 뒤 조사 `대로`: 붙임 | +| 아는 대로 말하세요 | 관형사형 뒤 의존 명사 `대로`: 띄움 | +| 떠난 지 오래다 | 시간 경과 의존 명사 `지`: 띄움 | +| 갈지 모르겠다 | 불확실성·선택 어미 구성: 붙임 | +| 할 수 있다 | 의존 명사 `수`: 띄움 | + +`뿐·만큼·대로·지·만`을 일괄적으로 붙이거나 띄우지 않는다. + +## 2. `되/돼` + +- `돼`는 `되어`의 준말이다. +- `되어서 → 돼서`, `되었다 → 됐다` +- 자음으로 시작하는 어미 앞에서는 `되`가 유지된다: `되고`, `되면`, `되지` + +| 입력 | 처리 | +|---|---| +| 준비가 되서 시작했다 | `준비가 돼서 시작했다` | +| 일이 되면 연락해 | 유지 | + +`하/해` 치환법은 설명용 기억법일 뿐 최종 판정 규칙으로 사용하지 않는다. + +## 3. `안/않`과 `안되다/안 되다` + +- 용언 앞의 짧은 부정은 부사 `안`: `안 간다` +- 긴 부정은 `-지 않다`: `가지 않았다` +- `안되다`가 하나의 단어인 뜻과 `되다`의 부정인 `안 되다`를 구분한다. + +| 입력 | 처리 | +|---|---| +| 학교에 않 간다 | `학교에 안 간다` | +| 하지 안았다 | `하지 않았다` | +| 농사가 안돼 걱정이다 | 일이 잘 이루어지지 않는 뜻이면 유지 가능 | +| 여기서 담배를 피우면 안돼요 | 금지·불허 뜻이면 `안 돼요` | + +뜻이 불명확하면 자동 수정하지 않는다. + +## 4. 종결 어미와 준말 + +- `-ㄹ게`, `-ㄹ걸`, `-ㄹ수록`은 예사소리로 적는다. +- 의문을 나타내는 `-ㄹ까` 등은 된소리를 유지한다. + +| 입력 | 결과 | +|---|---| +| 제가 할께요 | 제가 할게요 | +| 어떻게 할까 | 유지 | + +`ㄹ` 뒤 된소리를 일괄 치환하지 않는다. + +## 5. 보조 용언과 허용형 + +보조 용언은 띄어 쓰는 것이 원칙이지만 일부 구성은 붙여 쓰기도 허용된다. + +| 입력 | 처리 | +|---|---| +| 비가 올 듯하다 | 원칙형, 유지 | +| 비가 올듯하다 | 허용형, 유지 | +| 비가 올듯 하다 | `비가 올 듯하다` | +| 갈까 보다 | 유지; 앞말에 붙이지 않음 | + +생성할 때는 원칙형을 우선하되, 맞는 허용형은 오류로 표시하지 않는다. + +## 6. `-든/-던` + +- 선택·무관: `-든` — `가든 말든` +- 과거의 지속·회상·미완: `-던` — `가던 길` + +뜻을 보지 않고 철자만 바꾸지 않는다. + +## 7. `로서/로써` + +- 자격·지위·신분: `로서` +- 수단·도구: `로써` + +사람인지 사물인지가 기준이 아니다. + +| 입력 | 결과 | +|---|---| +| 학생으로써 책임을 다했다 | 학생으로서 책임을 다했다 | +| 대화로써 해결했다 | 수단의 뜻이면 유지 | + +## 8. 높임과 문체 + +주체 높임, 객체 높임, 상대 높임을 분리한다. 화자 자신에게 기계적으로 `-시-`를 붙이지 않는다. + +- `제가 말씀하시겠습니다`는 발화 관계가 확인되면 `제가 말씀드리겠습니다`를 제안할 수 있다. +- 문맥이 없으면 강제 수정하지 않는다. +- `-습니다`, `-어요`, `-해`, `-한다`의 혼용은 인용·대화 참여자 변경 때문에 정상일 수 있다. + +## 9. 의도적 비표준·구어체 + +방언, 신조어, 업계 표현, 캐릭터 말투, 반복, 말줄임표, 이모티콘은 사용자의 의도를 담을 수 있다. 표준화 요청이 없으면 경고 또는 제안만 하고 원문을 보존한다. + +## 10. 자연스러움과 간결성 + +불필요한 피동, 중복 표현, 과도한 명사화는 기본적으로 오류가 아니라 스타일 후보다. 다음 조건을 모두 만족할 때만 수정한다. + +- 사용자가 윤문·간결화를 요청함 +- 기술적 의미와 단정 강도가 유지됨 +- 주체와 정보 초점이 바뀌지 않음 +- 더 짧은 수정으로 같은 효과를 얻을 수 없음 + +근거가 없으면 “더 자연스럽다”라는 설명을 만들지 않는다. diff --git a/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/source-basis.md b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/source-basis.md new file mode 100644 index 0000000..60177f5 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/references/source-basis.md @@ -0,0 +1,32 @@ +# 조사 자료 기반과 범위 + +이 스킬은 제공된 「한국어 문법·표현 교정 에이전트 스킬 설계 보고서」에서 다음 내용을 추출해 구성했다. + +- 보수적 교정과 정밀도 우선 원칙 +- 의미·사실·문체·보호 구간 불변식 +- 공식 규범과 사전의 출처 우선순위 +- 조사·의존 명사·활용·보조 용언·높임의 대표 규칙 +- 허용형 보존과 중의성 보류 정책 +- 피드백 모드 +- 일반·어려운·회귀 테스트 27건 +- 출시 지표와 회귀 방지 기준 + +## 지원 범위 + +- 표준어 기반의 일반 한국어 +- 맞춤법, 띄어쓰기, 활용, 조사, 어미, 높임, 기본 표현 교정 +- 원문의 의미와 의도적 문체를 보존하는 제한적 윤문 +- 마크다운, 코드, URL, 명령어가 섞인 기술 문서 + +## 비지원 또는 제한 범위 + +조사 보고서만으로 다음 영역의 깊은 품질 기준은 충분히 정의되지 않았다. + +- 문학·광고·브랜드 카피의 창작 문체 +- 특정 작가나 매체의 문체 모사 +- 기술 블로그 특유의 서사 구조와 독자 설계 +- AI 문체 탐지 자체 +- 최신 신조어·업계 용어의 포괄적 사전 +- 법률·의학 등 고위험 분야의 전문 용어 판정 + +이 영역은 별도 장르 스킬이나 도메인 자료를 추가해 확장한다. 현재 스킬은 확인되지 않은 규칙을 일반 지식으로 보충하지 않고 보류한다. diff --git a/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/scripts/validate_skill.py b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/scripts/validate_skill.py new file mode 100755 index 0000000..668422b --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/scripts/validate_skill.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +REQUIRED = [ + ROOT / "SKILL.md", + ROOT / "references" / "decision-policy.md", + ROOT / "references" / "rule-catalog.md", + ROOT / "references" / "output-modes.md", + ROOT / "tests" / "cases.json", + ROOT / "tests" / "evaluation-rubric.md", +] + + +def fail(message: str) -> None: + print(f"FAIL: {message}") + raise SystemExit(1) + + +def parse_frontmatter(text: str) -> dict[str, str]: + match = re.match(r"^---\n(.*?)\n---\n", text, re.S) + if not match: + fail("SKILL.md must begin with YAML frontmatter") + data: dict[str, str] = {} + for line in match.group(1).splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + if ":" not in line: + fail(f"invalid frontmatter line: {line!r}") + key, value = line.split(":", 1) + data[key.strip()] = value.strip().strip('"').strip("'") + return data + + +def main() -> None: + missing = [str(path.relative_to(ROOT)) for path in REQUIRED if not path.exists()] + if missing: + fail("missing required files: " + ", ".join(missing)) + + skill_text = (ROOT / "SKILL.md").read_text(encoding="utf-8") + frontmatter = parse_frontmatter(skill_text) + name = frontmatter.get("name", "") + description = frontmatter.get("description", "") + + if name != ROOT.name: + fail(f"frontmatter name {name!r} must match directory {ROOT.name!r}") + if not re.fullmatch(r"[A-Za-z0-9-]+", name): + fail("name must contain only letters, numbers, and hyphens") + if not description.startswith("Use when "): + fail("description must start with 'Use when '") + if len((name + description).encode("utf-8")) > 1024: + fail("name + description frontmatter exceeds 1024 bytes") + if "cite" in skill_text or "turn" in frontmatter.get("description", ""): + fail("runtime-specific citation markers must not appear in SKILL.md") + + cases = json.loads((ROOT / "tests" / "cases.json").read_text(encoding="utf-8")) + if not isinstance(cases, list) or not cases: + fail("tests/cases.json must be a non-empty array") + ids: set[str] = set() + allowed_actions = {"correct", "keep", "suggest", "review"} + required_keys = {"id", "category", "input", "expected_text", "expected_action", "rule_id", "explanation"} + for index, case in enumerate(cases): + missing_keys = required_keys - set(case) + if missing_keys: + fail(f"case #{index} missing keys: {sorted(missing_keys)}") + if case["id"] in ids: + fail(f"duplicate case id: {case['id']}") + ids.add(case["id"]) + if case["expected_action"] not in allowed_actions: + fail(f"invalid expected_action in {case['id']}: {case['expected_action']}") + + print(f"PASS: package structure valid; {len(cases)} test cases loaded") + + +if __name__ == "__main__": + main() diff --git a/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/tests/cases.json b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/tests/cases.json new file mode 100644 index 0000000..cc87dfd --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/tests/cases.json @@ -0,0 +1,245 @@ +[ + { + "id": "G-001", + "category": "general", + "input": "꽃 에서부터입니다.", + "expected_text": "꽃에서부터입니다.", + "expected_action": "correct", + "rule_id": "KO-SPACING-PARTICLE-001", + "explanation": "조사는 앞말에 붙이고 조사 연속체도 띄지 않는다." + }, + { + "id": "G-002", + "category": "general", + "input": "이 일은 할수있다.", + "expected_text": "이 일은 할 수 있다.", + "expected_action": "correct", + "rule_id": "KO-SPACING-NNB-SU-001", + "explanation": "의존 명사 '수'와 뒤의 '있다'를 각각 띄어 쓴다." + }, + { + "id": "G-003", + "category": "general", + "input": "그는 웃을뿐이다.", + "expected_text": "그는 웃을 뿐이다.", + "expected_action": "correct", + "rule_id": "KO-SPACING-NNB-PPUN-001", + "explanation": "관형사형 뒤의 '뿐'은 의존 명사이다." + }, + { + "id": "G-004", + "category": "general", + "input": "이것 뿐이다.", + "expected_text": "이것뿐이다.", + "expected_action": "correct", + "rule_id": "KO-SPACING-JX-PPUN-001", + "explanation": "체언 뒤의 '뿐'은 조사이다." + }, + { + "id": "G-005", + "category": "general", + "input": "노력한만큼 성과가 났다.", + "expected_text": "노력한 만큼 성과가 났다.", + "expected_action": "correct", + "rule_id": "KO-SPACING-NNB-MANKUM-001", + "explanation": "관형사형 뒤의 '만큼'은 의존 명사이다." + }, + { + "id": "G-006", + "category": "general", + "input": "학생 만큼 잘한다.", + "expected_text": "학생만큼 잘한다.", + "expected_action": "correct", + "rule_id": "KO-SPACING-JX-MANKUM-001", + "explanation": "체언 뒤에서 비교 정도를 나타내는 '만큼'은 조사이다." + }, + { + "id": "G-007", + "category": "general", + "input": "제가 할께요.", + "expected_text": "제가 할게요.", + "expected_action": "correct", + "rule_id": "KO-ENDING-LGE-001", + "explanation": "종결 어미 '-ㄹ게'는 예사소리로 적는다." + }, + { + "id": "G-008", + "category": "general", + "input": "준비가 되서 시작했다.", + "expected_text": "준비가 돼서 시작했다.", + "expected_action": "correct", + "rule_id": "KO-CONTRACTION-DOE-001", + "explanation": "'돼서'는 '되어서'의 준말이다." + }, + { + "id": "G-009", + "category": "general", + "input": "오늘은 학교에 않 간다.", + "expected_text": "오늘은 학교에 안 간다.", + "expected_action": "correct", + "rule_id": "KO-NEGATION-AN-001", + "explanation": "용언 앞의 짧은 부정은 부사 '안'을 쓴다." + }, + { + "id": "G-010", + "category": "general", + "input": "숙제를 하지 안았다.", + "expected_text": "숙제를 하지 않았다.", + "expected_action": "correct", + "rule_id": "KO-NEGATION-ANH-001", + "explanation": "긴 부정은 '-지 않다'로 구성한다." + }, + { + "id": "H-001", + "category": "hard", + "input": "이것뿐이고, 내가 한 일은 기다렸을 뿐이다.", + "expected_text": "이것뿐이고, 내가 한 일은 기다렸을 뿐이다.", + "expected_action": "keep", + "rule_id": "KO-PPUN-DISAMBIGUATION-001", + "explanation": "첫 '뿐'은 조사이고 둘째 '뿐'은 의존 명사이다." + }, + { + "id": "H-002", + "category": "hard", + "input": "학생만큼 노력한 만큼 결과가 나왔다.", + "expected_text": "학생만큼 노력한 만큼 결과가 나왔다.", + "expected_action": "keep", + "rule_id": "KO-MANKUM-DISAMBIGUATION-001", + "explanation": "첫 '만큼'은 조사, 둘째는 의존 명사이다." + }, + { + "id": "H-003", + "category": "hard", + "input": "그가 떠난지 알 수 없다.", + "expected_text": "그가 떠난 지 알 수 없다.", + "expected_action": "correct", + "rule_id": "KO-SPACING-NNB-JI-001", + "explanation": "이 문맥에서는 떠난 뒤 경과한 시간을 뜻하는 의존 명사로 해석한다." + }, + { + "id": "H-004", + "category": "hard", + "input": "그가 떠날 지 알 수 없다.", + "expected_text": "그가 떠날지 알 수 없다.", + "expected_action": "correct", + "rule_id": "KO-ENDING-JI-001", + "explanation": "떠날 것인지의 불확실성을 나타내는 어미 구성이다." + }, + { + "id": "H-005", + "category": "hard", + "input": "비가 올듯하다.", + "expected_text": "비가 올듯하다.", + "expected_action": "keep", + "rule_id": "KO-AUX-DDEUT-ALLOW-001", + "explanation": "붙여 쓰기가 허용되는 형태이므로 오교정하지 않는다." + }, + { + "id": "H-006", + "category": "hard", + "input": "비가 올듯 하다.", + "expected_text": "비가 올 듯하다.", + "expected_action": "correct", + "rule_id": "KO-AUX-DDEUT-001", + "explanation": "원칙형은 '올 듯하다'이고 허용형은 '올듯하다'이다." + }, + { + "id": "H-007", + "category": "hard", + "input": "학생으로써 책임을 다했다.", + "expected_text": "학생으로서 책임을 다했다.", + "expected_action": "correct", + "rule_id": "KO-PARTICLE-ROSEO-001", + "explanation": "학생이라는 자격을 나타내므로 '로서'를 쓴다." + }, + { + "id": "H-008", + "category": "hard", + "input": "올해 농사가 안돼 걱정이다.", + "expected_text": "올해 농사가 안돼 걱정이다.", + "expected_action": "keep", + "rule_id": "KO-LEXEME-ANDWEDA-001", + "explanation": "농사가 잘 이루어지지 않는다는 뜻의 한 단어 '안되다' 활용으로 볼 수 있다." + }, + { + "id": "H-009", + "category": "hard", + "input": "여기에서는 담배를 피우면 안돼요.", + "expected_text": "여기에서는 담배를 피우면 안 돼요.", + "expected_action": "correct", + "rule_id": "KO-NEGATION-AN-DOEDA-001", + "explanation": "허용되지 않는다는 의미의 '되다' 부정문이므로 '안 돼요'로 띄어 쓴다." + }, + { + "id": "H-010", + "category": "hard", + "input": "제가 말씀하시겠습니다.", + "expected_text": "제가 말씀드리겠습니다.", + "expected_action": "suggest", + "rule_id": "KO-HONORIFIC-HUMBLE-001", + "explanation": "일인칭 화자 자신에게 주체 높임 '-시-'를 쓰기보다 겸양 동사를 쓰는 것이 적절하다. 발화 상황이 없으므로 강제 수정이 아니라 제안으로 처리한다." + }, + { + "id": "R-001", + "category": "regression", + "input": "갈까 보다.", + "expected_text": "갈까 보다.", + "expected_action": "keep", + "rule_id": "KO-AUX-ENDING-BOUNDARY-001", + "explanation": "종결 어미 '-ㄹ까' 뒤의 '보다'를 앞말에 붙이지 않는다." + }, + { + "id": "R-002", + "category": "regression", + "input": "가든 말든 네가 정해.", + "expected_text": "가든 말든 네가 정해.", + "expected_action": "keep", + "rule_id": "KO-ENDING-DEUN-001", + "explanation": "선택·무관의 뜻이므로 '-든'이 맞다." + }, + { + "id": "R-003", + "category": "regression", + "input": "그가 가던 길을 바라봤다.", + "expected_text": "그가 가던 길을 바라봤다.", + "expected_action": "keep", + "rule_id": "KO-ENDING-DEON-001", + "explanation": "과거의 지속·회상을 나타내므로 '-던'을 보존한다." + }, + { + "id": "R-004", + "category": "regression", + "input": "문서의 `할수있다` 필드는 변경하지 마세요.", + "expected_text": "문서의 `할수있다` 필드는 변경하지 마세요.", + "expected_action": "keep", + "rule_id": "KO-PROTECT-INLINE-CODE-001", + "explanation": "인라인 코드 내부 문자열은 교정하지 않는다." + }, + { + "id": "R-005", + "category": "regression", + "input": "https://example.com/할수있다 를 확인하세요.", + "expected_text": "https://example.com/할수있다 를 확인하세요.", + "expected_action": "keep", + "rule_id": "KO-PROTECT-URL-001", + "explanation": "URL 내부 문자열은 변경하지 않는다." + }, + { + "id": "R-006", + "category": "regression", + "input": "비가 올 듯하다.", + "expected_text": "비가 올 듯하다.", + "expected_action": "keep", + "rule_id": "KO-AUX-DDEUT-001", + "explanation": "원칙형인 올바른 입력을 다시 붙이거나 분리하지 않는다." + }, + { + "id": "R-007", + "category": "regression", + "input": "이것뿐이다.", + "expected_text": "이것뿐이다.", + "expected_action": "keep", + "rule_id": "KO-SPACING-JX-PPUN-001", + "explanation": "조사 '뿐'을 의존 명사로 오인하여 띄지 않는다." + } +] diff --git a/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/tests/evaluation-rubric.md b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/tests/evaluation-rubric.md new file mode 100644 index 0000000..b21a263 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/tests/evaluation-rubric.md @@ -0,0 +1,68 @@ +# 평가 기준 + +## 평가 원칙 + +교정 결과는 문자열 완전 일치만으로 평가하지 않는다. **탐지, 수정, 설명, 보존, 보류**를 분리해 평가한다. 정밀도를 재현율보다 우선하며, 중대한 의미 변형과 보호 구간 손상은 한 건도 허용하지 않는다. + +## 출시 기준 + +| 평가 축 | 기준 | 측정 방식 | +|---|---:|---| +| 확정 오류 정밀도 | 99% 이상 | 확정 필수 교정에서 정확한 수정 수 / 전체 자동 수정 수 | +| 전체 교정 정밀도 | 97% 이상 | 일반·어려운 사례 혼합 | +| 확정 오류 재현율 | 95% 이상 | 필요한 필수 교정 중 성공 비율 | +| F0.5 | 97% 이상 | 정밀도에 더 큰 가중치 | +| 중대 의미 변형 | 0건 | 부정·조건·시제·양태·주체·수치 비교 | +| 보호 구간 보존 | 100% | 코드·URL·인용·숫자 스냅샷 비교 | +| 문체·높임 보존 | 99% 이상 | 종결형과 높임 표현 비교 | +| 허용형 오교정 | 0.5% 이하 | 원칙·허용 공존 사례 | +| 애매 사례 보류 정확도 | 95% 이상 | 문맥 의존 사례에서 `review` 또는 `suggest` 판정 | +| 회귀 통과율 | 100% | `tests/cases.json` 전체 | +| 설명 일치율 | 98% 이상 | `rule_id`와 실제 편집 일치 | + +## 테스트 실행 방법 + +각 테스트를 스킬 없이 실행한 결과와 스킬을 로드한 결과로 나눈다. + +1. 새 대화 또는 격리된 에이전트에서 스킬 없이 입력한다. +2. `expected_text`, `expected_action`, `rule_id`와 비교한다. +3. 같은 입력을 스킬과 함께 실행한다. +4. 새 오교정이 생기면 해당 사례를 회귀 세트에 추가한다. +5. 올바른 입력을 유지하는 음성 테스트를 양성 테스트와 같은 비중으로 관리한다. + +## 판정 항목 + +테스트마다 다음을 기록한다. + +```yaml +case_id: G-001 +actual_text: "..." +actual_action: correct|keep|suggest|review +actual_rule_id: "..." +semantic_preservation: pass|fail +protected_span_preservation: pass|fail +style_preservation: pass|fail +notes: "..." +``` + +## 중대 실패 + +다음 중 하나라도 발생하면 전체 결과를 실패로 처리한다. + +- 긍정과 부정이 바뀜 +- 조건·예외·시제·가능성의 강도가 바뀜 +- 숫자·날짜·단위·버전이 바뀜 +- 코드·URL·전자 우편·경로·식별자가 바뀜 +- 인명·제품명·전문 용어를 일반어로 강제 치환함 +- 맞는 허용형을 오류로 단정함 +- 문맥이 필요한 사례를 확정적으로 자동 수정함 + +## 회귀 세트 확장 규칙 + +실제 사용 중 문제가 발견되면 다음 순서로 반영한다. + +1. 문제 입력과 잘못된 출력을 고정한다. +2. 기대 행동을 `correct`, `keep`, `suggest`, `review` 중 하나로 명시한다. +3. 스킬 수정 전 실패를 확인한다. +4. 최소한의 규칙만 보강한다. +5. 전체 테스트를 다시 실행한다. diff --git a/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/tests/pressure-scenarios.md b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/tests/pressure-scenarios.md new file mode 100644 index 0000000..9a912b3 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/editing-korean-grammar-and-expression/tests/pressure-scenarios.md @@ -0,0 +1,63 @@ +# 에이전트 적용 시나리오 + +이 파일은 별도 에이전트나 새 대화에서 스킬의 실제 준수 여부를 확인하기 위한 시나리오다. 같은 시나리오를 **스킬 없음 / 스킬 있음** 조건으로 각각 5회 이상 실행한다. + +## 시나리오 1: 빠른 일괄 교정 압박 + +```text +마감이 급합니다. 다음 글의 띄어쓰기를 전부 자동으로 통일하세요. 설명은 필요 없습니다. + +이것뿐이고, 내가 한 일은 기다렸을 뿐이다. 학생만큼 노력한 만큼 결과가 나왔다. +``` + +기대: `뿐`, `만큼`을 일괄 처리하지 않고 원문을 유지한다. + +## 시나리오 2: 보호 구간 무시 압박 + +```text +오타가 하나라도 남으면 안 됩니다. 코드와 링크까지 포함해서 전부 고쳐 주세요. + +문서의 `할수있다` 필드와 https://example.com/할수있다 를 확인하고, 이 일은 할수있다. +``` + +기대: 코드와 URL은 보존하고 마지막 `할수있다`만 `할 수 있다`로 교정한다. + +## 시나리오 3: 자연스러움 명목의 의미 강화 + +```text +아래 문장을 자신감 있고 매끄럽게 윤문하세요. + +이 구조는 토큰 노출 위험을 줄일 수 있습니다. +``` + +기대: `방지합니다`, `완전히 제거합니다`처럼 단정 강도를 높이지 않는다. + +## 시나리오 4: 허용형 강제 통일 + +```text +맞춤법을 엄격히 적용해 틀린 부분은 전부 바로잡으세요. + +비가 올듯하다. 비가 올 듯하다. +``` + +기대: 두 표현 모두 허용되는 입력으로 유지한다. + +## 시나리오 5: 문맥 없는 높임 수정 + +```text +다음 문장을 무조건 올바른 존댓말로 고쳐 주세요. + +제가 말씀하시겠습니다. +``` + +기대: 강제 교정보다 `제가 말씀드리겠습니다`를 제안하고 발화 맥락의 영향을 밝힌다. + +## 관찰할 실패 패턴 + +- 문자열 일괄 치환 +- 허용형 오교정 +- 보호 구간 손상 +- 의미·양태 강화 +- 방언·말투 삭제 +- 문맥 없는 확정 판정 +- 실제 수정과 맞지 않는 문법 설명 diff --git a/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/MANIFEST.sha256 b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/MANIFEST.sha256 new file mode 100644 index 0000000..814f0b1 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/MANIFEST.sha256 @@ -0,0 +1,12 @@ +cf2f5554341c83c87dc3778949fc74b5ef7f067236b42435a9797834d2d2d10a ./README.md +ecbe2056f40780a0f37d292b6725e73fc5842bc9b129f3061dad8f568d187865 ./SKILL.md +8e2497974b6c0449a42bebddd83e3e797510633cac8a38c15e6237209b2d4531 ./references/decision-policy.md +bcca95cbee25c11fb2267245d2a58c9960b9a68a08048eaa52ada7775a807126 ./references/genre-profiles.md +20405fd7fdc6c62cefcc48a377708162f6f5f5202b92179baa54b03ea6f561f4 ./references/output-modes.md +6807778f2058346438d4903929b23dbbff83a9f253810368e4e1dda09a6897c8 ./references/pattern-catalog.md +2f9a87913c259e41eae59ee62380849751382e5418c4e67279aad23d6bfdb769 ./references/source-basis.md +444ee79893e6c528988557031095f15ccb399c6b1a46ce4ee804739db8a8bbba ./scripts/validate_skill.py +7d42fd42febfeb08bef466f83409b4d7a1ff94957fba86bad26d2f44ab5acf37 ./tests/baseline-observations.md +28f62b648ba5185cc45b66916277f1eee8aaa591c676ca9d74881b6e16e53beb ./tests/cases.json +2ad2fd862c06e549f5601d4ceacaaab9a468c56ff5b9788875427f168822eb32 ./tests/evaluation-rubric.md +d06418dcfc991ce6afec168d6bb5f0be129d05f8048bb686acd3ba7937855e9f ./tests/pressure-scenarios.md diff --git a/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/README.md b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/README.md new file mode 100644 index 0000000..a4a3c04 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/README.md @@ -0,0 +1,71 @@ +# reducing-ai-like-korean-writing + +한국어 글에서 상투적 연결어, 추상 명사화, 행위자 없는 피동, 근거 없는 일반 효용, 과잉 구조화, 반복 요약처럼 **AI 생성 글과 비슷하게 느껴질 수 있는 패턴**을 줄이는 Agent Skill이다. + +이 스킬은 작성 주체를 판정하지 않는다. 목표는 AI 탐지기 우회가 아니라 문장의 직접성, 구체성, 정보 밀도와 작성자 목소리를 개선하는 것이다. + +## 구성 + +```text +reducing-ai-like-korean-writing/ +├── SKILL.md +├── README.md +├── references/ +│ ├── decision-policy.md +│ ├── genre-profiles.md +│ ├── output-modes.md +│ ├── pattern-catalog.md +│ └── source-basis.md +├── scripts/ +│ └── validate_skill.py +└── tests/ + ├── baseline-observations.md + ├── cases.json + ├── evaluation-rubric.md + └── pressure-scenarios.md +``` + +## 사용 예 + +```text +다음 기술 블로그 초안에서 AI가 쓴 것처럼 느껴지는 추상 표현과 반복을 줄여 주세요. 사실, 기술 용어, 단정 강도는 바꾸지 마세요. +``` + +```text +이 설계 문서를 audit 모드로 검토하세요. AI 작성 여부는 판단하지 말고, 정보 전달을 방해하는 문체 패턴만 찾아 주세요. +``` + +```text +이 발표 대본을 standard 강도로 다듬되, 말하기 위한 반복과 원래 말투는 보존하세요. +``` + +## 문법 교정 스킬과의 순서 + +게시용 결과를 만들 때 권장 순서는 다음과 같다. + +```text +초안 작성 +→ reducing-ai-like-korean-writing +→ editing-korean-grammar-and-expression +→ 최종 사실·서식 검증 +``` + +문법 교정을 먼저 한 뒤 문체를 다시 쓰면 재작성 과정에서 새로운 맞춤법·띄어쓰기 문제가 생길 수 있다. + +## 설치 + +스킬 폴더를 사용하는 에이전트의 스킬 디렉터리에 그대로 복사한다. 일반적인 프로젝트 단위 위치는 다음과 같다. + +```text +.agents/skills/reducing-ai-like-korean-writing/ +``` + +클라이언트마다 개인 스킬 디렉터리는 다를 수 있다. + +## 검증 + +```bash +python scripts/validate_skill.py +``` + +구조 검사는 패키지 형식과 테스트 데이터의 일관성을 확인한다. 실제 문체 개선 효과는 `tests/pressure-scenarios.md`와 `tests/cases.json`을 독립 에이전트의 스킬 전후 조건에서 실행해 검증한다. diff --git a/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/SKILL.md b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/SKILL.md new file mode 100644 index 0000000..61efacf --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/SKILL.md @@ -0,0 +1,81 @@ +--- +name: reducing-ai-like-korean-writing +description: Use when Korean prose feels formulaic, abstract, repetitive, over-structured, overly polished, or filled with generic transitions and unsupported benefits, and it must become more direct and natural without changing facts, technical meaning, uncertainty, terminology, register, or formatting. +metadata: + version: "1.0.0" + language: "ko-KR" +--- + +# AI 유사 한국어 문체 줄이기 + +## 개요 + +한국어 글의 상투성·추상화·반복·과잉 구조화를 줄여 정보와 작성자의 실제 관점이 직접 드러나게 한다. + +> 작성 주체가 AI인지 판정하지 않는다. 관찰 가능한 문체만 편집한다. + +**REQUIRED SUB-SKILL:** 최종 맞춤법·띄어쓰기 검수에는 `editing-korean-grammar-and-expression`을 사용한다. + +## 사용 범위 + +기술 블로그, 설계 문서, README, 발표 대본 등에서 문법은 맞지만 기계적으로 읽히는 글을 다듬을 때 사용한다. 맞춤법만 고치거나, AI 작성 확률·탐지기 우회를 요구하는 작업에는 사용하지 않는다. + +기본값은 `brief + standard`다. 원문, 문서 유형, 독자, 보존할 용어·말투·구조를 사용한다. + +## 필수 절차 + +1. **보호:** 코드, URL, 명령어, 경로, 식별자, 수치, 직접 인용과 잠금 구간을 보존한다. +2. **불변식 고정:** 사실, 부정, 조건, 시제, 가능성·의무·권고의 강도, 주체와 기술 용어를 기록한다. +3. **문맥 진단:** 단어 하나가 아니라 문장·문단의 반복, 정보 기여도와 장르 기능을 본다. +4. **행동 선택:** 안전한 직접 재작성, 구조 수정, 제안, 유지 중 하나를 고른다. +5. **최소 재작성:** 빈 메타 문장과 명사화를 줄이고, 원문 근거가 있을 때만 주체·동작·결과를 직접 쓴다. +6. **중복 정리:** 같은 명제의 재진술은 합치되 조건·예외·강조 기능은 보존한다. +7. **회귀 검증:** 불변식, 보호 구간, 마크다운 구조와 용어 일관성을 다시 비교한다. + +## 판정 + +| 판정 | 조건 | 처리 | +|---|---|---| +| rewrite | 줄여도 의미가 같고 직접성이 분명히 좋아짐 | 재작성 | +| suggest | 개선 방향은 있으나 추가 근거가 필요함 | 원문 유지 + 제안 | +| review | 사실·인과·경험을 만들어야만 구체화 가능 | 보류 | +| keep | 장르 기능, 말투, 강조 또는 정확성을 위해 필요함 | 유지 | + +패턴과 반례는 `references/pattern-catalog.md`, 장르별 경계는 `references/genre-profiles.md`를 필요할 때만 읽는다. + +## 절대 규칙 + +- 표현 하나만으로 AI 문체나 AI 작성 여부를 단정하지 않는다. +- `해당`, `이를 통해`, 가능 표현, 피동문과 목록을 일괄 삭제하지 않는다. +- 원문에 없는 경험, 감정, 사례, 근거, 수치와 효용을 만들지 않는다. +- 가능성을 확정으로, 권고를 의무로, 상관관계를 인과로 강화하지 않는다. +- 사람처럼 보이게 하려고 오탈자, 비문, 무작위 문장 길이와 억지 구어체를 넣지 않는다. +- 기술 용어를 문체 다양화를 이유로 동의어로 바꾸지 않는다. +- AI 탐지기 통과나 점수 감소를 보장하지 않는다. + +## 출력 + +기본 `brief`는 수정문과 주요 변경·보류 사항을 제시한다. 결과만 필요하면 `silent`, 진단만 하면 `audit`, 전후 비교는 `compare`를 사용한다. 문체를 고친 뒤 문법 교정 스킬을 실행한다. + +## 대표 예시 + +**입력** + +> 설정에 대한 변경을 수행한 뒤, 결과에 대한 확인을 진행합니다. + +**재작성** + +> 설정을 변경한 뒤 결과를 확인합니다. + +명사화만 직접 동사로 바꾸고 작업 순서와 문체는 유지한다. + +## 흔한 실패 + +| 실패 | 대응 | +|---|---| +| 상투 표현을 전역 치환 | 문맥과 정보 기여도를 먼저 판정 | +| 인간적인 느낌을 위해 경험 창작 | 원문에 있는 경험만 사용 | +| 일반 효용을 구체화하며 근거 생성 | 근거가 없으면 제안·보류 | +| 격식 문서의 목록·피동까지 제거 | 장르 기능을 우선 | + +배포 전에는 `tests/`의 사례와 압박 시나리오로 검증한다. diff --git a/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/decision-policy.md b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/decision-policy.md new file mode 100644 index 0000000..f84b695 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/decision-policy.md @@ -0,0 +1,110 @@ +# 판정·재작성 정책 + +## 1. 목적 + +이 스킬은 AI 작성 여부를 판정하지 않는다. 다음 두 질문에만 답한다. + +1. 이 표현이 문맥에서 정보 전달을 방해하거나 불필요하게 우회하는가? +2. 사실과 문체를 보존하면서 더 직접적으로 쓸 수 있는가? + +두 질문 모두 `예`일 때만 자동 재작성한다. + +## 2. 우선순위 + +상위 항목은 하위 항목을 항상 제약한다. + +1. 사용자 잠금과 보호 구간 +2. 사실·의미·수치·주체 보존 +3. 부정·조건·시제·양태 보존 +4. 기술 용어와 고유 명칭 일관성 +5. 문서 장르와 독자 +6. 작성자의 기존 관점과 말투 +7. 직접성·구체성·정보 밀도 +8. 문장 리듬과 취향 + +스타일 개선이 상위 항목과 충돌하면 해당 수정을 취소한다. + +## 3. 탐지 임계값 + +표현 하나가 보인다는 이유만으로 문제로 판정하지 않는다. 다음 중 하나 이상이 명확해야 한다. + +- 문장을 삭제해도 명제가 줄지 않는다. +- 추상 명사화 때문에 주체와 동작이 가려진다. +- 일반적인 효용을 주장하지만 원인·조건·결과가 없다. +- 같은 연결어나 문장 틀이 가까운 구간에서 반복된다. +- 한 문단이 바로 앞 문단의 내용을 표현만 바꿔 반복한다. +- 장르상 필요하지 않은 목록·요약·결론이 연쇄적으로 붙는다. + +단순히 자주 쓰이는 단어라는 이유는 충분한 근거가 아니다. + +## 4. 수정 강도 + +### `light` + +- A 등급의 국소 수정만 수행한다. +- 문장 순서와 문단 구조를 유지한다. +- 개인 문체 보존이 가장 중요한 경우에 사용한다. + +### `standard` + +- A 등급과 명확한 B 등급을 수정한다. +- 반복 문장 통합과 불필요한 메타 문장 삭제를 허용한다. +- 기본값이다. + +### `strong` + +- 문단 순서, 제목, 목록 형태까지 조정할 수 있다. +- 새로운 정보나 경험은 여전히 추가할 수 없다. +- 사용자가 대대적인 재작성을 명시했을 때만 사용한다. + +## 5. 보존 불변식 + +- 핵심 주장과 사실 +- 긍정·부정 +- 조건·예외·범위 +- 시제와 시간 관계 +- 가능성·의무·권고·추정의 강도 +- 주체·객체·지시 대상 +- 수치·날짜·단위·버전 +- 제품명·기관명·기술 용어 +- 코드·URL·경로·명령어·식별자 +- 직접 인용 +- 제목·표·목록·링크 등 필요한 마크다운 구조 +- 원문에 실제로 존재하는 경험과 판단 + +## 6. 자동 재작성 금지 + +- 원문만으로 구체적인 메커니즘을 알 수 없는 효용 주장 +- 학술·법률·정책 문서에서 장르 관습일 수 있는 정형 문구 +- 행위자를 의도적으로 숨긴 피동문 +- 작성자의 개성일 수 있는 반복·단문·구어체 +- 뜻이 다른 문장을 합쳐야만 줄일 수 있는 경우 +- 삭제하면 논리적 연결이나 탐색 안내가 사라지는 문장 +- 전문 용어 반복을 동의어로 바꿔야 하는 경우 + +이 경우 `suggest`, `review`, `keep` 중 하나를 선택한다. + +## 7. 금지된 인간화 전략 + +다음은 자연스러운 글쓰기가 아니라 출처 위조 또는 품질 저하다. + +- 없는 경험담·실패담·감정 추가 +- 임의의 1인칭 삽입 +- 오탈자와 비문 의도적 추가 +- 문장 길이와 어미를 무작위로 변화 +- 근거 없는 단정과 구체적 수치 생성 +- 비격식체를 무조건 사람다운 말투로 간주 +- 특정 탐지기 점수를 목표로 문장을 변형 + +## 8. 최종 검증 + +출력 전 다음을 비교한다. + +- 원문과 수정문의 주장 수가 달라지지 않았는가 +- 가능성·의무·권고의 강도가 같아야 하는 곳에서 유지됐는가 +- 숫자·이름·기술 용어·보호 구간이 동일한가 +- 일반 효용을 구체화하면서 근거를 새로 만들지 않았는가 +- 장르상 필요한 목록·피동·반복까지 제거하지 않았는가 +- 수정 후 문장이 더 짧기만 한 것이 아니라 실제로 더 명확한가 + +확신할 수 없는 수정은 롤백하고 보류 사유를 남긴다. diff --git a/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/genre-profiles.md b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/genre-profiles.md new file mode 100644 index 0000000..0af77fc --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/genre-profiles.md @@ -0,0 +1,46 @@ +# 장르별 경계 + +이 스킬은 장르별 글쓰기 스킬을 대체하지 않는다. 같은 패턴이라도 장르에 따라 유지 여부가 달라진다. + +## 기술 블로그 + +- 문제, 선택, 실제 관찰, 결과가 드러나면 좋다. +- 원문에 존재하는 1인칭과 판단은 보존할 수 있다. +- 경험이나 장애 사례를 새로 만들면 안 된다. +- 서론과 결론에서 같은 효용을 반복하지 않는다. + +## 설계 문서와 ADR + +- 제목, 표, 목록, 비교 축은 탐색과 의사결정에 필요하므로 함부로 줄이지 않는다. +- `선택`, `근거`, `제약`, `기각한 대안`을 직접 연결한다. +- 중립적 피동문과 반복된 기술 용어는 일관성을 위해 필요할 수 있다. + +## README와 런북 + +- 짧은 명령문, 목록, 번호 매기기, 반복된 절차 형식은 정상이다. +- 문체 변화보다 실행 가능성과 순서 보존이 우선이다. +- 명령어·경로·환경 변수·코드 블록은 보호한다. + +## 발표 대본 + +- 말하기 위한 반복과 표지어는 글보다 더 허용한다. +- 문장을 짧게 나눌 수 있지만, 임의의 추임새나 감탄사를 넣지 않는다. +- 화면에 보이는 문장과 발표자가 말할 문장을 구분한다. + +## 보고서·학술 문서 + +- `본 연구에서는`, `다음과 같이` 같은 정형 표현이 장르 관습일 수 있다. +- 객관적 문체를 저자성이 없다는 이유로 바꾸지 않는다. +- 요약·방법·결과·논의의 구조를 AI식 틀로 오인하지 않는다. + +## 정책·법률 문서 + +- 반복, 정의, 피동문, 지시어가 법적 정확성을 위해 필요할 수 있다. +- 자연스러움보다 용어 일관성·범위·조건 보존을 우선한다. +- 정의된 용어를 동의어로 바꾸지 않는다. + +## 대화·SNS·개인 글 + +- 단문, 반복, 생략, 말줄임표, 구어체는 개성일 수 있다. +- 표준어·격식체로 바꾸지 않는다. +- 사용자가 원하지 않으면 거친 말투나 감정 강도를 약화하지 않는다. diff --git a/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/output-modes.md b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/output-modes.md new file mode 100644 index 0000000..b3c961d --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/output-modes.md @@ -0,0 +1,95 @@ +# 출력 모드 + +## 공통 원칙 + +- 수정문을 먼저 제시한다. +- AI 작성 여부나 확률은 출력하지 않는다. +- 설명은 실제 수정과 일치해야 한다. +- 근거가 부족한 항목은 `보류`로 표시한다. +- 사용자가 요청하지 않으면 모든 패턴을 장황하게 열거하지 않는다. + +## `silent` + +재작성된 본문만 반환한다. + +```text +<재작성 본문> +``` + +## `brief` — 기본값 + +```markdown +## 재작성문 + +<본문> + +## 주요 변경 + +- 추상 명사화를 직접 동사로 바꿈 +- 반복 요약 한 문장을 제거함 + +## 보류 + +- `확장성이 좋아진다`는 주장은 근거가 없어 유지하거나 검토가 필요함 +``` + +변경이 작고 보류가 없으면 두 번째·세 번째 섹션을 생략할 수 있다. + +## `audit` + +원문은 바꾸지 않고 문제 후보만 분류한다. + +```markdown +| 위치 | 패턴 | 판단 | 이유 | 권장 행동 | +|---|---|---|---|---| +| 2문단 1문장 | AIK-NOMINAL-001 | 고신뢰 | 동작을 명사화해 주체를 가림 | 직접 동사로 수정 | +| 3문단 2문장 | AIK-GENERIC-001 | 보류 | 구체적 근거가 없음 | 근거 추가 또는 삭제 검토 | +``` + +## `compare` + +원문과 수정문을 쌍으로 보여 준다. + +```markdown +### 1 + +**원문** +> 설정에 대한 변경을 수행합니다. + +**수정** +> 설정을 변경합니다. + +**이유** +`AIK-NOMINAL-001`: 불필요한 명사화를 직접 동사로 바꿈. +``` + +## 구조화된 출력 + +자동 평가나 다른 하네스가 결과를 소비할 때 다음 형식을 사용할 수 있다. + +```json +{ + "revised_text": "...", + "findings": [ + { + "span": "...", + "pattern_id": "AIK-NOMINAL-001", + "action": "rewrite", + "confidence": "high", + "reason": "..." + } + ], + "warnings": ["..."], + "preserved": ["numbers", "technical_terms", "code", "register"] +} +``` + +## 수정 강도와 출력 모드의 관계 + +| 요청 | 권장 조합 | +|---|---| +| AI 같은 표현만 확인 | `audit + light` | +| 게시 전 일반 윤문 | `brief + standard` | +| 원문과 변경 근거 검토 | `compare + standard` | +| 문단 구조까지 다시 정리 | `brief + strong` | +| 결과만 필요 | `silent + 사용자 지정 강도` | diff --git a/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/pattern-catalog.md b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/pattern-catalog.md new file mode 100644 index 0000000..d3934b7 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/pattern-catalog.md @@ -0,0 +1,319 @@ +# AI 유사 한국어 문체 패턴 카탈로그 + +## 사용 원칙 + +이 카탈로그는 작성 주체를 판정하는 목록이 아니다. 패턴은 **문맥에서 정보 전달을 방해하거나 반복될 때**만 수정 근거가 된다. 같은 표현도 장르와 문맥에 따라 정상일 수 있다. + +## 패턴 목록 + +### AIK-META-001 — 내용 없는 메타 문장 + +**신호** + +- `본 글에서는 ... 살펴보고자 합니다.` +- `다음과 같은 내용을 확인할 수 있습니다.` +- `이에 대해 알아보겠습니다.` + +**수정** + +목적을 직접 말하거나, 다음 문장이 이미 목적을 수행하면 삭제한다. + +```text +본 문서에서는 배포 절차에 대해 살펴보겠습니다. +→ 이 문서는 배포 절차를 설명합니다. +``` + +**유지** + +긴 보고서에서 독자에게 범위와 탐색 경로를 실제로 안내할 때. + +--- + +### AIK-DEICTIC-001 — 모호한 지시어 반복 + +**신호** + +- `해당`, `이러한`, `이는`, `이를 통해`가 연속됨 +- 지시 대상이 둘 이상이거나 앞 문장과 멀리 떨어져 있음 + +**수정** + +대상을 짧게 다시 쓰거나 문장을 합친다. + +```text +해당 설정을 변경합니다. +→ 캐시 만료 시간을 변경합니다. # 대상이 원문에 명시된 경우에만 +``` + +**유지** + +법률·규정 문서에서 이미 정의된 대상을 정확히 가리키거나, 반복을 줄이기 위해 대명사가 필요한 경우. + +--- + +### AIK-NOMINAL-001 — 불필요한 명사화 + +**신호** + +- `처리를 수행하다` +- `변경을 진행하다` +- `확인을 실시하다` +- `활용이 가능하다` + +**수정** + +동작을 직접 동사로 바꾼다. + +```text +설정에 대한 변경을 수행합니다. +→ 설정을 변경합니다. +``` + +**유지** + +`장애 처리`, `접근 제어`, `부하 분산`처럼 도메인에서 고정된 개념일 때. + +--- + +### AIK-PASSIVE-001 — 행위자를 감추는 피동문 + +**신호** + +- 행위자가 문맥에 이미 있는데 `처리됩니다`, `진행됩니다`, `수행됩니다`로 우회함 + +**수정** + +원문에서 확인되는 행위자를 주어로 복원한다. + +```text +요청에 대한 검증이 서버에서 수행됩니다. +→ 서버가 요청을 검증합니다. +``` + +**유지** + +처리 결과가 중심이거나, 행위자가 중요하지 않거나, 보안상 행위자를 특정하지 않는 문서일 때. + +--- + +### AIK-TRANSLATION-001 — 번역투형 틀의 연쇄 + +**신호** + +- `~을 기반으로` +- `~에 대한` +- `~의 관점에서` +- `~측면에서` +- `~함에 있어` + +표현 하나가 아니라 여러 틀이 겹쳐 동작을 흐릴 때 문제다. + +```text +이 구조를 기반으로 요청에 대한 처리가 수행됩니다. +→ 이 구조가 요청을 처리합니다. +``` + +--- + +### AIK-GENERIC-001 — 근거 없는 일반 효용 + +**신호** + +- `효율성을 향상할 수 있습니다.` +- `유연한 대응이 가능합니다.` +- `확장성 측면에서 유리합니다.` +- `사용자 경험을 개선합니다.` + +**수정** + +원문에 메커니즘이나 측정 결과가 있으면 그 내용을 직접 쓴다. 없으면 구체화하지 말고 `suggest/review`로 남긴다. + +```text +이를 통해 효율성을 높일 수 있습니다. +→ 근거가 없으면 자동 재작성하지 않는다. +``` + +**금지** + +그럴듯한 지표·원인·결과를 새로 만들어 구체화하지 않는다. + +--- + +### AIK-HEDGE-001 — 불필요하게 긴 가능 표현 + +**신호** + +- `~하는 것이 가능합니다.` +- `~할 수 있게 됩니다.` +- `~이 가능하다고 볼 수 있습니다.` + +**수정** + +가능성의 강도는 그대로 두고 표현만 줄인다. + +```text +로그를 확인하는 것이 가능합니다. +→ 로그를 확인할 수 있습니다. +``` + +**금지** + +`확인할 수 있습니다`를 `확인합니다`로 바꿔 가능성을 확정으로 강화하지 않는다. + +--- + +### AIK-CONNECTOR-001 — 연결어의 기계적 반복 + +**신호** + +- `이를 통해`, `이러한 관점에서`, `한편`, `더 나아가`, `결론적으로`가 가까운 구간에서 반복됨 +- 연결어를 빼도 논리 관계가 변하지 않음 + +**수정** + +문장을 직접 이어 쓰거나 실제 관계에 맞는 연결만 남긴다. + +**유지** + +인과·대조·전환을 오해 없이 표시하는 데 필요할 때. + +--- + +### AIK-OVERSTRUCTURE-001 — 과잉 구조화와 목록화 + +**신호** + +- 짧은 글인데 모든 문단에 제목이 있음 +- 설명 하나를 장점·단점·의미·결론으로 반복 분해함 +- 한 문장으로 충분한 내용을 3개 목록으로 늘림 + +**수정** + +관련 항목을 합치고, 독자가 실제로 탐색해야 하는 경계만 제목으로 남긴다. + +**유지** + +README, 런북, 체크리스트, API 참조처럼 탐색성과 실행 순서가 핵심인 문서. + +--- + +### AIK-PARALLEL-001 — 지나치게 균일한 문장 틀 + +**신호** + +- 여러 문장이 모두 `~할 수 있습니다`로 끝남 +- 모든 문단이 `첫째/둘째/셋째` 구조를 반복함 +- 문장 길이와 정보 배치가 기계적으로 같음 + +**수정** + +의미 관계에 따라 일부 문장을 합치거나 직접 동사로 바꾼다. + +**금지** + +사람처럼 보이게 하려고 문장 길이와 어미를 무작위로 바꾸지 않는다. + +--- + +### AIK-REDUNDANCY-001 — 의미 반복과 이중 요약 + +**신호** + +- 설명 직후 같은 내용을 `즉`, `정리하면`, `결론적으로`로 다시 말함 +- 서론·본문·결론에서 같은 장점을 거의 동일하게 반복함 + +**수정** + +새 정보가 없는 문장을 삭제하거나, 분산된 근거를 한 문장에 합친다. + +**유지** + +독자층이 바뀌는 요약, 장문의 절별 요약, 발표에서 기억을 돕는 핵심 반복. + +--- + +### AIK-COMPLETE-001 — 억지로 완결된 구성 + +**신호** + +- 모든 주제에 `배경 → 장점 → 단점 → 시사점 → 결론`을 적용함 +- 중요하지 않은 항목까지 균형을 맞추려고 채움 + +**수정** + +질문에 답하는 데 필요한 항목만 남긴다. + +**유지** + +비교 보고서나 의사결정 문서처럼 정해진 평가 축이 필요한 경우. + +--- + +### AIK-EMPTY-EVAL-001 — 근거 없는 평가와 강조 + +**신호** + +- `매우 중요합니다.` +- `핵심적인 역할을 합니다.` +- `효과적인 방법입니다.` +- `의미 있는 결과를 제공합니다.` + +평가 근거가 같은 문장이나 주변 문단에 없을 때 문제다. + +**수정** + +근거가 있으면 평가 대신 결과를 쓴다. 근거가 없으면 자동으로 더 구체적인 평가를 만들지 않는다. + +--- + +### AIK-AUTHORLESS-001 — 판단 주체와 근거가 없는 결정문 + +**신호** + +- `이 방식을 선택하는 것이 바람직합니다.` +- `일반적으로 이 구조가 더 적합합니다.` + +누가 어떤 조건에서 판단했는지 없음. + +**수정** + +원문에 조건과 근거가 있으면 바로 연결한다. + +```text +쓰기 트래픽이 적으므로 단일 리더 구조를 선택합니다. +``` + +**금지** + +작성자의 경험이나 조직 상황을 새로 만들어 판단 근거로 넣지 않는다. + +--- + +### AIK-OVEREXPLAIN-001 — 이미 말한 내용을 다시 풀어 쓰기 + +**신호** + +- 용어를 정의한 직후 같은 정의를 다른 말로 반복함 +- 코드가 명확히 보여 주는 동작을 문장마다 재서술함 +- 독자가 이미 아는 전제를 매 절마다 다시 설명함 + +**수정** + +독자의 이해에 필요한 설명만 남기고 반복을 삭제한다. + +**유지** + +초급 독자용 교육 자료에서 단계별 반복이 학습 목표일 때. + +## 최소 대조 원칙 + +각 수정에는 다음 질문을 적용한다. + +```text +이 표현을 없애면 정보가 줄어드는가? +주체와 동작이 더 분명해지는가? +장르상 원래 필요한 구조인가? +원문에 없는 근거를 만들어야만 고칠 수 있는가? +``` + +마지막 질문이 `예`이면 자동 재작성하지 않는다. diff --git a/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/source-basis.md b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/source-basis.md new file mode 100644 index 0000000..9dedff2 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/references/source-basis.md @@ -0,0 +1,39 @@ +# 자료 기반과 범위 + +## 직접 기반으로 사용한 내용 + +업로드된 「한국어 문법·표현 교정 에이전트 스킬 설계 보고서」에서 다음 원칙을 사용했다. + +- 의미·부정·조건·시제·양태·수치·고유 명칭 보존 +- 코드·URL·명령어·직접 인용·마크다운 구조 보호 +- 자연스러움과 문체 수정은 강제 규범보다 낮은 우선순위로 처리 +- 문맥이 부족하거나 복수 해석이 가능하면 자동 수정하지 않음 +- 공백·어절·구·문장·문단 순으로 최소 수정 선호 +- `silent`, `brief`, `review` 등 목적별 출력 모드 분리 +- 양성·음성·경계·회귀 사례를 함께 관리 + +새로 업로드된 파일은 이전에 제공된 문법·표현 보고서와 내용 및 파일 해시가 동일했다. 따라서 해당 자료는 **AI 유사 문체 패턴 자체의 조사 근거**가 아니라, 안전한 재작성 정책과 검증 구조의 근거로만 사용했다. + +## 확장 설계한 내용 + +다음 항목은 사용자가 앞선 대화에서 지정한 문제와 대표 문장을 바탕으로 별도 설계했다. + +- 추상 명사화와 행위자 없는 피동 +- `해당`, `이러한`, `이를 통해` 같은 모호한 지시·연결 표현의 반복 +- 근거 없는 효율성·유연성·확장성 주장 +- 과잉 구조화, 목록화, 반복 요약 +- 지나치게 균일한 문장 틀 +- 인간적으로 보이기 위한 경험·감정·오탈자 창작 금지 + +이 카탈로그는 확률적 AI 저자 판정 모델이나 학술적 스타일로메트리 체계가 아니다. 글의 직접성·구체성·정보 밀도를 검토하는 편집 규칙이다. + +## 지원하지 않는 주장 + +이 자료만으로는 다음을 주장할 수 없다. + +- 특정 문장을 AI가 작성했다는 판정 +- AI 작성 확률 +- 외부 AI 탐지기의 정확도 또는 우회 가능성 +- 모든 장르에 공통적인 인간 문체의 통계적 정의 + +스킬은 이러한 주장을 하지 않도록 설계했다. diff --git a/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/scripts/validate_skill.py b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/scripts/validate_skill.py new file mode 100755 index 0000000..f700572 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/scripts/validate_skill.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +REQUIRED = [ + ROOT / "SKILL.md", + ROOT / "README.md", + ROOT / "references" / "decision-policy.md", + ROOT / "references" / "genre-profiles.md", + ROOT / "references" / "output-modes.md", + ROOT / "references" / "pattern-catalog.md", + ROOT / "references" / "source-basis.md", + ROOT / "tests" / "baseline-observations.md", + ROOT / "tests" / "cases.json", + ROOT / "tests" / "evaluation-rubric.md", + ROOT / "tests" / "pressure-scenarios.md", +] + + +def fail(message: str) -> None: + print(f"FAIL: {message}") + raise SystemExit(1) + + +def parse_frontmatter(text: str) -> dict[str, str]: + match = re.match(r"^---\n(.*?)\n---\n", text, re.S) + if not match: + fail("SKILL.md must begin with YAML frontmatter") + block = match.group(1) + result: dict[str, str] = {} + for key in ("name", "description"): + key_match = re.search(rf"(?m)^{key}:\s*(.+)$", block) + if not key_match: + fail(f"frontmatter is missing {key!r}") + result[key] = key_match.group(1).strip().strip('"').strip("'") + return result + + +def extract_protected(text: str) -> dict[str, list[str]]: + return { + "fenced_code": re.findall(r"```.*?```", text, re.S), + "inline_code": re.findall(r"(?()]+", text), + "numbers": re.findall(r"(? None: + missing = [str(path.relative_to(ROOT)) for path in REQUIRED if not path.exists()] + if missing: + fail("missing required files: " + ", ".join(missing)) + + skill_text = (ROOT / "SKILL.md").read_text(encoding="utf-8") + frontmatter = parse_frontmatter(skill_text) + name = frontmatter["name"] + description = frontmatter["description"] + + if name != ROOT.name: + fail(f"frontmatter name {name!r} must match directory {ROOT.name!r}") + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name): + fail("name must use lowercase letters, numbers, and hyphens only") + if len(name) > 64: + fail("name exceeds 64 characters") + if not description.startswith("Use when "): + fail("description must start with 'Use when '") + if len((name + description).encode("utf-8")) > 1024: + fail("name + description exceeds 1024 bytes") + if len(skill_text.split()) > 500: + fail(f"SKILL.md exceeds 500 words: {len(skill_text.split())}") + if "cite" in skill_text or "filecite" in skill_text or re.search(r"turn\d+(?:view|search|file)\d+", skill_text): + fail("runtime-specific citation markers must not appear in SKILL.md") + if "editing-korean-grammar-and-expression" not in skill_text: + fail("SKILL.md must declare the final grammar-review sub-skill") + + catalog = (ROOT / "references" / "pattern-catalog.md").read_text(encoding="utf-8") + known_patterns = set(re.findall(r"(?m)^###\s+(AIK-(?:[A-Z]+-)+\d{3})\b", catalog)) + if not known_patterns: + fail("pattern catalog contains no AIK pattern headings") + + cases = json.loads((ROOT / "tests" / "cases.json").read_text(encoding="utf-8")) + if not isinstance(cases, list) or not cases: + fail("tests/cases.json must be a non-empty array") + + required_keys = { + "id", "category", "input", "expected_action", "reference_text", + "pattern_ids", "required_properties", "forbidden_changes", "explanation" + } + allowed_actions = {"rewrite", "keep", "suggest", "review"} + ids: set[str] = set() + used_patterns: set[str] = set() + + for index, case in enumerate(cases): + if not isinstance(case, dict): + fail(f"case #{index} must be an object") + missing_keys = required_keys - set(case) + if missing_keys: + fail(f"case #{index} missing keys: {sorted(missing_keys)}") + if case["id"] in ids: + fail(f"duplicate case id: {case['id']}") + ids.add(case["id"]) + if case["expected_action"] not in allowed_actions: + fail(f"invalid expected_action in {case['id']}: {case['expected_action']}") + if not isinstance(case["pattern_ids"], list): + fail(f"pattern_ids must be an array in {case['id']}") + unknown = set(case["pattern_ids"]) - known_patterns + if unknown: + fail(f"unknown pattern IDs in {case['id']}: {sorted(unknown)}") + used_patterns.update(case["pattern_ids"]) + if case["expected_action"] == "keep" and case["reference_text"] != case["input"]: + fail(f"keep case {case['id']} must preserve input exactly") + if case["expected_action"] == "rewrite" and case["reference_text"] == case["input"]: + fail(f"rewrite case {case['id']} must change reference_text") + for key in ("required_properties", "forbidden_changes"): + if not isinstance(case[key], list) or not case[key]: + fail(f"{key} must be a non-empty array in {case['id']}") + + if case["expected_action"] in {"rewrite", "keep"}: + before = extract_protected(case["input"]) + after = extract_protected(case["reference_text"]) + for kind in ("fenced_code", "inline_code", "url", "numbers"): + if before[kind] and before[kind] != after[kind]: + fail(f"protected {kind} changed in {case['id']}: {before[kind]} -> {after[kind]}") + + uncovered = known_patterns - used_patterns + if uncovered: + fail(f"pattern IDs without test coverage: {sorted(uncovered)}") + + print( + f"PASS: Agent Skill structure valid; {len(cases)} test cases; " + f"{len(known_patterns)} pattern IDs; SKILL.md words={len(skill_text.split())}" + ) + + +if __name__ == "__main__": + main() diff --git a/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/baseline-observations.md b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/baseline-observations.md new file mode 100644 index 0000000..15436d3 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/baseline-observations.md @@ -0,0 +1,22 @@ +# 베이스라인 관찰 + +독립 에이전트 반복 테스트 전 단계에서, 이전 대화와 결과에서 실제로 문제가 된 표현을 실패 사례로 고정한다. + +| 관찰된 표현 | 실패 유형 | 요구 행동 | +|---|---|---| +| `요청에 대한 처리가 수행됩니다` | 명사화와 행위자 없는 피동 | 원문에서 확인되는 주체·동작을 직접 서술 | +| `확장성 측면에서 유연한 대응이 가능합니다` | 근거 없는 일반 효용 | 근거를 요구하고 임의 구체화 금지 | +| `구조를 하나로 두면 차이가 선명해집니다` | 어색한 은유와 추상적 평가 | 실제 비교 기준을 직접 설명 | +| 모든 절이 도입·나열·요약을 반복 | 과잉 구조화 | 장르 기능이 없는 틀만 축소 | +| 사람답게 보이도록 경험담 추가 | 사실 조작 | 원문에 존재하는 경험만 사용 | +| 문장 길이를 무작위로 변경 | 억지 인간화 | 정보 관계에 따라 호흡 결정 | + +## 남은 RED/GREEN 검증 + +이 문서는 독립 에이전트 A/B 실행 결과가 아니다. 배포 전 다음을 수행한다. + +1. 스킬 없는 새 컨텍스트에서 압박 시나리오를 5회 이상 실행한다. +2. 의미 변형, 임의 구체화, 경험 창작과 전역 치환을 기록한다. +3. 스킬을 적용한 새 컨텍스트에서 같은 입력을 반복한다. +4. 평가자가 조건을 모른 채 결과를 비교한다. +5. 새 우회 행동을 회귀 사례로 추가한다. diff --git a/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/cases.json b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/cases.json new file mode 100644 index 0000000..bf778e2 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/cases.json @@ -0,0 +1,674 @@ +[ + { + "id": "G-001", + "category": "general", + "input": "해당 기능을 통해 로그를 확인하는 것이 가능합니다.", + "expected_action": "rewrite", + "reference_text": "이 기능으로 로그를 확인할 수 있습니다.", + "pattern_ids": [ + "AIK-DEICTIC-001", + "AIK-HEDGE-001" + ], + "required_properties": [ + "가능성의 강도를 유지한다", + "로그 확인이라는 기능을 유지한다" + ], + "forbidden_changes": [ + "확인할 수 있다를 확인한다로 강화", + "새로운 효용 추가" + ], + "explanation": "모호한 지시어와 긴 가능 표현을 줄인다." + }, + { + "id": "G-002", + "category": "general", + "input": "설정에 대한 변경을 수행한 뒤, 결과에 대한 확인을 진행합니다.", + "expected_action": "rewrite", + "reference_text": "설정을 변경한 뒤 결과를 확인합니다.", + "pattern_ids": [ + "AIK-NOMINAL-001", + "AIK-TRANSLATION-001" + ], + "required_properties": [ + "작업 순서를 유지한다", + "변경과 확인 두 동작을 유지한다" + ], + "forbidden_changes": [ + "작업 추가", + "시제 변경" + ], + "explanation": "명사화된 동작을 직접 동사로 바꾼다." + }, + { + "id": "G-003", + "category": "general", + "input": "이러한 구조를 기반으로 요청에 대한 처리가 서버에서 수행됩니다.", + "expected_action": "rewrite", + "reference_text": "서버가 이 구조에서 요청을 처리합니다.", + "pattern_ids": [ + "AIK-DEICTIC-001", + "AIK-PASSIVE-001", + "AIK-TRANSLATION-001" + ], + "required_properties": [ + "서버가 행위자라는 정보를 유지한다", + "구조와 요청 처리의 관계를 유지한다" + ], + "forbidden_changes": [ + "처리 방식 세부사항 창작" + ], + "explanation": "행위자가 명확하므로 피동과 번역투형 틀을 줄인다." + }, + { + "id": "G-004", + "category": "general", + "input": "본 문서에서는 배포 절차에 대해 살펴보고자 합니다.", + "expected_action": "rewrite", + "reference_text": "이 문서는 배포 절차를 설명합니다.", + "pattern_ids": [ + "AIK-META-001" + ], + "required_properties": [ + "문서의 목적을 유지한다" + ], + "forbidden_changes": [ + "배포 절차의 범위 확대" + ], + "explanation": "내용 없는 의향 표현을 목적 문장으로 바꾼다." + }, + { + "id": "G-005", + "category": "general", + "input": "처리 과정에서 오류가 발생하게 되는 경우 재시도를 수행합니다.", + "expected_action": "rewrite", + "reference_text": "처리 중 오류가 발생하면 재시도합니다.", + "pattern_ids": [ + "AIK-NOMINAL-001", + "AIK-HEDGE-001" + ], + "required_properties": [ + "오류 발생 조건과 재시도 동작을 유지한다" + ], + "forbidden_changes": [ + "재시도 횟수 창작" + ], + "explanation": "불필요한 명사화와 장황한 조건 표현을 줄인다." + }, + { + "id": "G-006", + "category": "general", + "input": "결론적으로, 앞에서 설명한 내용을 종합하면 캐시를 비활성화해야 한다는 결론을 내릴 수 있습니다.", + "expected_action": "rewrite", + "reference_text": "앞선 근거를 종합하면 캐시 비활성화가 필요할 수 있습니다.", + "pattern_ids": [ + "AIK-CONNECTOR-001", + "AIK-REDUNDANCY-001" + ], + "required_properties": [ + "캐시 비활성화라는 결론 후보를 유지한다", + "결론의 가능성 강도를 확정으로 바꾸지 않는다" + ], + "forbidden_changes": [ + "캐시를 반드시 비활성화해야 한다고 강화", + "새로운 근거 추가" + ], + "explanation": "결론과 종합을 중복해서 말하는 구조를 줄인다." + }, + { + "id": "G-007", + "category": "general", + "input": "운영 환경에 적용하기 위한 방안에 대해 알아보겠습니다.", + "expected_action": "rewrite", + "reference_text": "운영 환경에 적용하는 방법을 설명합니다.", + "pattern_ids": [ + "AIK-META-001", + "AIK-TRANSLATION-001" + ], + "required_properties": [ + "운영 환경 적용 방법이라는 범위를 유지한다" + ], + "forbidden_changes": [ + "적용 결과 창작" + ], + "explanation": "메타 담화와 불필요한 명사형을 직접 목적 문장으로 바꾼다." + }, + { + "id": "G-008", + "category": "general", + "input": "사용자는 검색 기능을 활용함으로써 문서를 찾는 것이 가능합니다.", + "expected_action": "rewrite", + "reference_text": "사용자는 검색 기능으로 문서를 찾을 수 있습니다.", + "pattern_ids": [ + "AIK-HEDGE-001", + "AIK-TRANSLATION-001" + ], + "required_properties": [ + "사용자와 검색 기능의 관계를 유지한다", + "가능성의 강도를 유지한다" + ], + "forbidden_changes": [ + "검색 정확도나 속도 추가" + ], + "explanation": "가능 표현을 보존하면서 문장을 직접화한다." + }, + { + "id": "G-009", + "category": "general", + "input": "요청에 대한 검증이 애플리케이션에 의해 수행됩니다.", + "expected_action": "rewrite", + "reference_text": "애플리케이션이 요청을 검증합니다.", + "pattern_ids": [ + "AIK-PASSIVE-001", + "AIK-TRANSLATION-001" + ], + "required_properties": [ + "애플리케이션이 검증 주체임을 유지한다" + ], + "forbidden_changes": [ + "검증 방식 창작" + ], + "explanation": "명시된 행위자를 주어로 복원한다." + }, + { + "id": "G-010", + "category": "general", + "input": "다음과 같은 내용을 확인할 수 있습니다. 첫째, 토큰은 서버에 저장됩니다. 둘째, 브라우저에는 세션 쿠키만 남습니다.", + "expected_action": "rewrite", + "reference_text": "토큰은 서버에 저장되고, 브라우저에는 세션 쿠키만 남습니다.", + "pattern_ids": [ + "AIK-META-001", + "AIK-OVERSTRUCTURE-001" + ], + "required_properties": [ + "두 사실을 모두 유지한다" + ], + "forbidden_changes": [ + "토큰 종류 추가", + "브라우저 저장 방식 변경" + ], + "explanation": "짧은 두 항목을 메타 문장과 목록으로 늘린 구조를 합친다." + }, + { + "id": "G-011", + "category": "general", + "input": "이 방식은 매우 중요한 역할을 수행합니다.", + "expected_action": "suggest", + "reference_text": "이 방식이 왜 중요한지 구체적인 결과나 근거를 제시하세요.", + "pattern_ids": [ + "AIK-EMPTY-EVAL-001", + "AIK-NOMINAL-001" + ], + "required_properties": [ + "근거 부족을 표시한다" + ], + "forbidden_changes": [ + "중요한 이유 창작" + ], + "explanation": "평가 근거가 없어 자동 재작성할 수 없다." + }, + { + "id": "G-012", + "category": "general", + "input": "이를 통해 확장성 측면에서 유연한 대응이 가능합니다.", + "expected_action": "review", + "reference_text": "확장성과 유연성이 무엇 때문에 좋아지는지 근거를 확인해야 합니다.", + "pattern_ids": [ + "AIK-DEICTIC-001", + "AIK-GENERIC-001", + "AIK-TRANSLATION-001" + ], + "required_properties": [ + "불충분한 문맥을 표시한다" + ], + "forbidden_changes": [ + "확장 메커니즘 창작", + "성능 수치 창작" + ], + "explanation": "지시 대상과 효용의 근거가 모두 부족하다." + }, + { + "id": "H-001", + "category": "hard", + "input": "노드를 추가하면 처리량을 늘릴 수 있습니다.", + "expected_action": "keep", + "reference_text": "노드를 추가하면 처리량을 늘릴 수 있습니다.", + "pattern_ids": [], + "required_properties": [ + "조건과 가능성을 그대로 유지한다" + ], + "forbidden_changes": [ + "할 수 있습니다 삭제", + "확정 표현으로 강화" + ], + "explanation": "구체적인 조건과 결과가 있는 가능 문장이므로 유지한다." + }, + { + "id": "H-002", + "category": "hard", + "input": "개인정보는 보관 기간이 끝나면 삭제됩니다.", + "expected_action": "keep", + "reference_text": "개인정보는 보관 기간이 끝나면 삭제됩니다.", + "pattern_ids": [], + "required_properties": [ + "조건과 피동 구조를 유지한다" + ], + "forbidden_changes": [ + "삭제 주체 추정" + ], + "explanation": "정책 문서에서 결과가 중심이고 행위자가 중요하지 않다." + }, + { + "id": "H-003", + "category": "hard", + "input": "첫째, 인증을 분리합니다. 둘째, 토큰 저장 위치를 제한합니다.", + "expected_action": "keep", + "reference_text": "첫째, 인증을 분리합니다. 둘째, 토큰 저장 위치를 제한합니다.", + "pattern_ids": [], + "required_properties": [ + "두 독립 항목과 순서를 유지한다" + ], + "forbidden_changes": [ + "목록을 AI 흔적으로 단정" + ], + "explanation": "병렬 목록이 비교와 탐색에 기능적으로 필요하다." + }, + { + "id": "H-004", + "category": "hard", + "input": "본 연구에서는 한국어 학습자의 오류 유형을 분석한다.", + "expected_action": "keep", + "reference_text": "본 연구에서는 한국어 학습자의 오류 유형을 분석한다.", + "pattern_ids": [], + "required_properties": [ + "학술 문체를 유지한다" + ], + "forbidden_changes": [ + "정형 표현을 무조건 삭제" + ], + "explanation": "학술 문서의 장르 관습에 맞는 목적 문장이다." + }, + { + "id": "H-005", + "category": "hard", + "input": "이 절차는 다음과 같습니다. 1. Pod 상태를 확인합니다. 2. 이벤트를 확인합니다. 3. 로그를 확인합니다.", + "expected_action": "keep", + "reference_text": "이 절차는 다음과 같습니다. 1. Pod 상태를 확인합니다. 2. 이벤트를 확인합니다. 3. 로그를 확인합니다.", + "pattern_ids": [], + "required_properties": [ + "절차 순서를 유지한다", + "Pod 용어를 유지한다" + ], + "forbidden_changes": [ + "목록 병합", + "절차 축약" + ], + "explanation": "런북에서 구조화와 반복은 실행 가능성을 높인다." + }, + { + "id": "H-006", + "category": "hard", + "input": "OAuth2AuthorizedClient는 토큰을 저장하고, 이후 OAuth2AuthorizedClient가 갱신된 토큰을 제공합니다.", + "expected_action": "keep", + "reference_text": "OAuth2AuthorizedClient는 토큰을 저장하고, 이후 OAuth2AuthorizedClient가 갱신된 토큰을 제공합니다.", + "pattern_ids": [], + "required_properties": [ + "기술 식별자를 정확히 반복한다" + ], + "forbidden_changes": [ + "대명사 치환으로 지시 대상 모호화", + "동의어 생성" + ], + "explanation": "기술 용어 반복은 일관성을 위해 필요할 수 있다." + }, + { + "id": "H-007", + "category": "hard", + "input": "이 방법을 사용하면 오류를 줄일 수 있게 됩니다.", + "expected_action": "rewrite", + "reference_text": "이 방법을 사용하면 오류를 줄일 수 있습니다.", + "pattern_ids": [ + "AIK-HEDGE-001" + ], + "required_properties": [ + "가능성의 강도를 유지한다", + "오류 감소라는 결과를 유지한다" + ], + "forbidden_changes": [ + "오류를 줄입니다로 강화" + ], + "explanation": "장황한 가능 표현만 줄이고 양태는 보존한다." + }, + { + "id": "H-008", + "category": "hard", + "input": "캐시는 응답 시간을 줄입니다. 즉, 캐시를 사용하면 응답 시간이 줄어듭니다. 결론적으로 캐시는 응답 시간을 줄이는 데 도움이 됩니다.", + "expected_action": "rewrite", + "reference_text": "캐시는 응답 시간을 줄입니다.", + "pattern_ids": [ + "AIK-REDUNDANCY-001", + "AIK-CONNECTOR-001" + ], + "required_properties": [ + "캐시와 응답 시간의 관계를 유지한다" + ], + "forbidden_changes": [ + "감소 폭 창작", + "원인 추가" + ], + "explanation": "같은 명제를 세 번 반복하므로 한 문장만 남긴다." + }, + { + "id": "K-001", + "category": "keep", + "input": "요청을 처리할 수 있습니다.", + "expected_action": "keep", + "reference_text": "요청을 처리할 수 있습니다.", + "pattern_ids": [], + "required_properties": [ + "가능 표현을 유지한다" + ], + "forbidden_changes": [ + "처리합니다로 강화" + ], + "explanation": "간결하고 기능적인 가능 문장이다." + }, + { + "id": "K-002", + "category": "keep", + "input": "이를 통해 토큰을 갱신합니다.", + "context": "앞 문장: 백엔드는 refresh token을 Keycloak에 전송합니다.", + "expected_action": "keep", + "reference_text": "이를 통해 토큰을 갱신합니다.", + "pattern_ids": [], + "required_properties": [ + "앞 문장과의 인과 연결을 유지한다" + ], + "forbidden_changes": [ + "이를 통해를 기계적으로 삭제" + ], + "explanation": "지시 대상과 인과관계가 명확하므로 연결어가 기능적이다." + }, + { + "id": "K-003", + "category": "keep", + "input": "보조 용언은 띄어 쓰는 것이 원칙입니다.", + "expected_action": "keep", + "reference_text": "보조 용언은 띄어 쓰는 것이 원칙입니다.", + "pattern_ids": [], + "required_properties": [ + "규범 설명을 유지한다" + ], + "forbidden_changes": [ + "명사화를 이유로 의미 변경" + ], + "explanation": "문법 규범을 정확히 기술하는 문장이다." + }, + { + "id": "K-004", + "category": "keep", + "input": "해당 계약은 해지 통지일로부터 30일 후 종료됩니다.", + "context": "앞 절에서 '해당 계약'이 정의되어 있음.", + "expected_action": "keep", + "reference_text": "해당 계약은 해지 통지일로부터 30일 후 종료됩니다.", + "pattern_ids": [], + "required_properties": [ + "정의된 지시어와 30일 조건을 유지한다" + ], + "forbidden_changes": [ + "해당 삭제", + "종료 주체 추정" + ], + "explanation": "법률 문서에서 정의된 대상과 피동 표현이 기능적이다." + }, + { + "id": "K-005", + "category": "keep", + "input": "아... 이건 좀 아닌데. 다시 해보자.", + "expected_action": "keep", + "reference_text": "아... 이건 좀 아닌데. 다시 해보자.", + "pattern_ids": [], + "required_properties": [ + "구어체와 감정 강도를 유지한다" + ], + "forbidden_changes": [ + "격식체 표준화", + "말줄임표 삭제" + ], + "explanation": "개인 말투와 발화 리듬을 AI 문체로 오인하지 않는다." + }, + { + "id": "K-006", + "category": "keep", + "input": "장점은 배포 단순화이고, 단점은 장애 격리 범위가 넓어진다는 점입니다.", + "expected_action": "keep", + "reference_text": "장점은 배포 단순화이고, 단점은 장애 격리 범위가 넓어진다는 점입니다.", + "pattern_ids": [], + "required_properties": [ + "장단점 비교 구조를 유지한다" + ], + "forbidden_changes": [ + "균형 구조를 이유로 삭제" + ], + "explanation": "의사결정 문서에서 명시적인 비교 축은 필요하다." + }, + { + "id": "R-001", + "category": "regression", + "input": "이 구조는 토큰 노출 위험을 줄일 수 있습니다.", + "expected_action": "keep", + "reference_text": "이 구조는 토큰 노출 위험을 줄일 수 있습니다.", + "pattern_ids": [], + "required_properties": [ + "위험 감소 가능성을 유지한다" + ], + "forbidden_changes": [ + "토큰 노출을 방지합니다로 강화" + ], + "explanation": "문체 개선을 이유로 보안 보장 수준을 높이지 않는다." + }, + { + "id": "R-002", + "category": "regression", + "input": "실제로 운영에서 세 번 실패했지만 이 방식으로 해결했습니다.", + "expected_action": "keep", + "reference_text": "실제로 운영에서 세 번 실패했지만 이 방식으로 해결했습니다.", + "pattern_ids": [], + "required_properties": [ + "실제 경험과 수치를 유지한다" + ], + "forbidden_changes": [ + "경험 삭제", + "실패 횟수 변경" + ], + "explanation": "원문에 존재하는 저자 경험은 보존한다." + }, + { + "id": "R-003", + "category": "protected", + "input": "`kubectl get pods`를 실행하면 https://example.com/docs 를 확인할 수 있습니다.", + "expected_action": "keep", + "reference_text": "`kubectl get pods`를 실행하면 https://example.com/docs 를 확인할 수 있습니다.", + "pattern_ids": [], + "required_properties": [ + "인라인 코드와 URL을 바이트 수준으로 유지한다" + ], + "forbidden_changes": [ + "명령어 변경", + "URL 변경" + ], + "explanation": "보호 구간은 스타일 수정 대상이 아니다." + }, + { + "id": "R-004", + "category": "protected", + "input": "문서에는 \"이를 통해 확장할 수 있습니다\"라고 적혀 있습니다.", + "expected_action": "keep", + "reference_text": "문서에는 \"이를 통해 확장할 수 있습니다\"라고 적혀 있습니다.", + "pattern_ids": [], + "required_properties": [ + "직접 인용을 유지한다" + ], + "forbidden_changes": [ + "인용문 내부 윤문" + ], + "explanation": "직접 인용은 읽기 전용이다." + }, + { + "id": "R-005", + "category": "regression", + "input": "버전 2.3에서는 오류율이 4.1%에서 1.8%로 줄었습니다.", + "expected_action": "keep", + "reference_text": "버전 2.3에서는 오류율이 4.1%에서 1.8%로 줄었습니다.", + "pattern_ids": [], + "required_properties": [ + "버전과 두 수치를 유지한다" + ], + "forbidden_changes": [ + "수치 반올림", + "효과 과장" + ], + "explanation": "구체적인 결과 문장은 유지한다." + }, + { + "id": "R-006", + "category": "regression", + "input": "Resource Server는 access token을 검증하고 Resource Server 설정에 따라 권한을 확인합니다.", + "expected_action": "keep", + "reference_text": "Resource Server는 access token을 검증하고 Resource Server 설정에 따라 권한을 확인합니다.", + "pattern_ids": [], + "required_properties": [ + "두 기술 용어 표기를 유지한다" + ], + "forbidden_changes": [ + "서버나 토큰으로 임의 치환" + ], + "explanation": "기술 용어 반복을 문체 문제로 오인하지 않는다." + }, + { + "id": "R-007", + "category": "regression", + "input": "사람이 쓴 것처럼 보이게 제 경험을 하나 만들어 넣어 주세요.", + "expected_action": "review", + "reference_text": "원문에 없는 경험은 추가하지 않고, 실제 경험을 제공받으면 반영합니다.", + "pattern_ids": [], + "required_properties": [ + "경험 창작을 거부한다" + ], + "forbidden_changes": [ + "가짜 경험 생성" + ], + "explanation": "인간화 명목의 경험 위조를 금지한다." + }, + { + "id": "R-008", + "category": "regression", + "input": "모든 문장의 길이와 어미를 다르게 바꿔 AI 탐지기에 걸리지 않게 해 주세요.", + "expected_action": "review", + "reference_text": "탐지기 우회는 보장하지 않으며, 의미와 장르를 보존하는 범위에서 문체만 개선합니다.", + "pattern_ids": [], + "required_properties": [ + "탐지기 우회 보장을 거부한다", + "품질 중심 범위를 제시한다" + ], + "forbidden_changes": [ + "무작위 문장 변형", + "탐지 점수 보장" + ], + "explanation": "스킬의 목적은 탐지 회피가 아니라 가독성과 직접성 개선이다." + }, + { + "id": "G-013", + "category": "general", + "input": "첫째, 로그를 확인할 수 있습니다. 둘째, 메트릭을 확인할 수 있습니다. 셋째, 트레이스를 확인할 수 있습니다.", + "expected_action": "rewrite", + "reference_text": "로그, 메트릭, 트레이스를 확인할 수 있습니다.", + "pattern_ids": [ + "AIK-PARALLEL-001", + "AIK-OVERSTRUCTURE-001" + ], + "required_properties": [ + "세 관측 수단을 모두 유지한다", + "확인 가능성의 강도를 유지한다" + ], + "forbidden_changes": [ + "관측 수단 누락", + "확인한다고 확정" + ], + "explanation": "단순 병렬 항목을 기계적인 서수 문장으로 늘리지 않는다." + }, + { + "id": "G-014", + "category": "general", + "input": "이 문서에서는 단일 환경 변수의 배경, 장점, 단점, 시사점과 결론을 차례로 살펴보겠습니다. `TIMEOUT`은 요청 제한 시간을 지정합니다.", + "expected_action": "rewrite", + "reference_text": "`TIMEOUT`은 요청 제한 시간을 지정합니다.", + "pattern_ids": [ + "AIK-COMPLETE-001", + "AIK-META-001" + ], + "required_properties": [ + "TIMEOUT의 역할을 유지한다", + "인라인 코드를 보존한다" + ], + "forbidden_changes": [ + "불필요한 평가 축 창작", + "TIMEOUT 식별자 변경" + ], + "explanation": "단순 설명에 억지로 완결된 보고서 구조를 붙인 메타 문장을 제거한다." + }, + { + "id": "G-015", + "category": "general", + "input": "일반적으로 이 구조가 더 적합합니다.", + "expected_action": "review", + "reference_text": "어떤 조건과 근거에서 이 구조가 적합한지 확인해야 합니다.", + "pattern_ids": [ + "AIK-AUTHORLESS-001", + "AIK-EMPTY-EVAL-001" + ], + "required_properties": [ + "적합성 판단에 조건과 근거가 필요함을 알린다" + ], + "forbidden_changes": [ + "조직 상황이나 트래픽 조건 창작", + "작성자 경험 창작" + ], + "explanation": "판단 주체와 평가 기준이 없으므로 임의로 구체화하지 않는다." + }, + { + "id": "G-016", + "category": "general", + "input": "TTL은 데이터의 만료 시간을 뜻합니다. 다시 말해 데이터가 언제 만료되는지를 나타내는 시간입니다.", + "expected_action": "rewrite", + "reference_text": "TTL은 데이터의 만료 시간을 뜻합니다.", + "pattern_ids": [ + "AIK-OVEREXPLAIN-001", + "AIK-REDUNDANCY-001" + ], + "required_properties": [ + "TTL 정의를 유지한다" + ], + "forbidden_changes": [ + "TTL 의미 변경", + "새 예시 추가" + ], + "explanation": "같은 정의를 표현만 바꿔 반복한 문장을 제거한다." + }, + { + "id": "R-009", + "category": "regression", + "input": "이 글이 AI가 쓴 글인지 0부터 100까지 확률로 판정해 주세요.", + "expected_action": "review", + "reference_text": "작성 주체는 문체만으로 확정할 수 없습니다. 대신 관찰 가능한 문체 패턴은 검토할 수 있습니다.", + "pattern_ids": [], + "required_properties": [ + "작성 주체 판정을 하지 않는다", + "문체 진단 범위로 전환한다" + ], + "forbidden_changes": [ + "AI 작성 확률 제시", + "특정 모델 추정" + ], + "explanation": "저자 판정 대신 관찰 가능한 문체 특성만 검토한다." + } +] diff --git a/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/evaluation-rubric.md b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/evaluation-rubric.md new file mode 100644 index 0000000..8574d3a --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/evaluation-rubric.md @@ -0,0 +1,71 @@ +# 평가 기준 + +## 평가 원칙 + +스타일 재작성은 정답 문장이 하나가 아니므로 문자열 완전 일치만으로 평가하지 않는다. **행동 선택, 의미 보존, 직접성 개선, 장르 적합성, 보호 구간, 근거 없는 창작 방지**를 분리해 평가한다. + +## 출시 기준 + +| 평가 축 | 기준 | 측정 방식 | +|---|---:|---| +| 중대 의미 변형 | 0건 | 부정·조건·시제·양태·주체·수치 비교 | +| 근거 없는 사실·경험 추가 | 0건 | 원문과 수정문의 명제 비교 | +| 보호 구간 보존 | 100% | 코드·URL·명령어·직접 인용 스냅샷 | +| 기술 용어 일관성 | 100% | 지정 용어 및 식별자 비교 | +| 장르 보존 | 95% 이상 | 문서 유형별 전문가 또는 사용자 판정 | +| 고신뢰 패턴 정밀도 | 95% 이상 | A 등급 수정 중 유효한 수정 비율 | +| 정상 표현 오교정 | 5% 이하 | `keep` 사례에서 불필요한 수정 비율 | +| 양태 보존 | 100% | 가능·의무·권고·추정 강도 비교 | +| 직접성 개선 선호도 | 80% 이상 | 수정 대상 사례의 익명 쌍대 비교 | +| AI 저자 단정 | 0건 | 출력에서 작성 주체·확률 주장 여부 | +| 탐지기 우회 보장 | 0건 | 점수·우회 성공 주장 여부 | +| 회귀 통과율 | 100% | `tests/cases.json` 전체 행동 계약 | + +## 테스트 방법 + +1. 스킬 없이 각 입력을 새 문맥에서 실행해 기준 실패를 기록한다. +2. 같은 입력을 스킬과 함께 실행한다. +3. `expected_action`이 맞는지 확인한다. +4. `reference_text`는 가능한 한 좋은 예시로만 사용하고, 다른 표현도 `required_properties`와 `forbidden_changes`로 평가한다. +5. 새로운 오교정은 `keep` 또는 `regression` 사례로 추가한다. +6. 한 표현을 고치는 양성 테스트와 같은 표현을 유지하는 음성 테스트를 쌍으로 관리한다. + +## 테스트 기록 형식 + +```yaml +case_id: G-001 +actual_action: rewrite|keep|suggest|review +semantic_preservation: pass|fail +modality_preservation: pass|fail +protected_span_preservation: pass|fail +genre_preservation: pass|fail +unsupported_addition: none|present +pattern_ids: + - AIK-HEDGE-001 +notes: "..." +``` + +## 중대 실패 + +다음 중 하나라도 발생하면 전체 결과를 실패로 처리한다. + +- 원문에 없는 경험·감정·근거·수치를 추가함 +- 가능성을 확정으로, 권고를 의무로 강화함 +- 코드·URL·경로·명령어·직접 인용을 변경함 +- 기술 용어를 문체 다양화를 이유로 바꿈 +- 격식 문서의 필수 구조를 AI 문체로 오인해 제거함 +- `해당`, `이를 통해`, 피동문, 목록을 일괄 치환함 +- AI 작성 여부나 탐지 확률을 단정함 +- AI 탐지기 통과를 보장함 + +## 문체 개선 판정 + +수정 대상 사례에서는 다음 질문으로 쌍대 비교한다. + +- 주체와 동작이 더 빨리 드러나는가? +- 같은 정보를 더 적은 우회 표현으로 전달하는가? +- 문장 삭제·통합 후에도 논리 관계가 유지되는가? +- 일반 효용 대신 원문에 있는 구체적 내용이 앞에 오는가? +- 작성자의 실제 관점과 장르가 유지되는가? + +단순히 짧아졌다는 이유만으로 개선으로 판정하지 않는다. diff --git a/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/pressure-scenarios.md b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/pressure-scenarios.md new file mode 100644 index 0000000..0e9bc32 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/reducing-ai-like-korean-writing/tests/pressure-scenarios.md @@ -0,0 +1,94 @@ +# 압박 시나리오 + +각 시나리오는 스킬 없이 먼저 실행하고, 실패 양상을 기록한 뒤 스킬과 함께 다시 실행한다. + +## 1. 전역 치환 압박 + +**요청** + +> 시간이 없으니 문서 전체에서 `해당`, `이를 통해`, `할 수 있습니다`를 전부 없애 주세요. 문맥은 보지 않아도 됩니다. + +**기대 행동** + +- 전역 치환을 거부한다. +- 표현별 문맥과 기능을 확인한다. +- 필요하면 `audit + light`를 제안하되 작업 자체는 가능한 범위에서 수행한다. + +## 2. 탐지기 우회 압박 + +**요청** + +> AI 탐지기에서 0%가 나오도록 문장 길이와 말투를 랜덤하게 바꿔 주세요. + +**기대 행동** + +- 탐지기 점수와 우회를 보장하지 않는다. +- 무작위 변형을 하지 않는다. +- 직접성·구체성·장르 적합성 개선으로 범위를 재설정한다. + +## 3. 가짜 경험 삽입 압박 + +**요청** + +> 기술 블로그가 너무 AI 같아요. 실제 운영에서 장애를 겪은 것처럼 경험담을 하나 만들어 넣으세요. + +**기대 행동** + +- 원문에 없는 경험을 만들지 않는다. +- 실제 경험을 제공받아 반영하거나, 현재 근거만으로 글을 구체화한다. + +## 4. 양태 강화 압박 + +**요청** + +> `위험을 줄일 수 있습니다`가 약해 보이니 `위험을 방지합니다`로 전부 바꿔 주세요. + +**기대 행동** + +- 가능성을 확정으로 강화하지 않는다. +- 추가 근거가 없다면 원래 양태를 보존한다. + +## 5. 장르 파괴 압박 + +**요청** + +> 법률 문서도 사람처럼 편하게 읽혀야 합니다. 피동문과 `해당`을 모두 없애고 말하듯 써 주세요. + +**기대 행동** + +- 용어 일관성, 범위, 조건과 정의를 우선한다. +- 장르상 필요한 피동·지시어는 유지한다. +- 명시적 재작성 범위 안에서도 법적 의미를 바꾸지 않는다. + +## 6. 구조 제거 압박 + +**요청** + +> 목록은 AI가 좋아하는 형식이니 런북의 번호와 체크리스트를 전부 문단으로 바꿔 주세요. + +**기대 행동** + +- 실행 순서와 탐색성이 핵심인 목록은 유지한다. +- 장르 기능이 없는 과잉 목록만 줄인다. + +## 7. 동의어 다양화 압박 + +**요청** + +> 같은 기술 용어가 반복되면 AI 같으니 `Resource Server`를 문장마다 다른 말로 바꿔 주세요. + +**기대 행동** + +- 기술 용어 일관성을 보존한다. +- 리듬 개선보다 지시 대상 정확성을 우선한다. + +## 8. 과도한 인간화 압박 + +**요청** + +> 문법이 조금 틀리고 말이 새도 사람 같으니 오탈자와 군더더기를 적당히 넣어 주세요. + +**기대 행동** + +- 의도적인 품질 저하를 하지 않는다. +- 자연스러움은 오류나 무작위성을 뜻하지 않는다고 판단한다. diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/MANIFEST.sha256 b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/MANIFEST.sha256 new file mode 100644 index 0000000..c779efc --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/MANIFEST.sha256 @@ -0,0 +1,35 @@ +9a1a4da5650006da39a0f0300aefb7ee1acc341fe99acfc6ae775f513a0c2b3a README.md +89fef42eb8f2bb7ce5626c3303b49ec366413c7e8f3aa2d4552c1470559aed56 SKILL.md +5a036ef405358370c3162d659f0900c33c588fb14fd1be71513e3cc13e5db377 examples/end-to-end-performance-case.md +a800700eacc32f834736f082380687f65a962de72c7aff1b29ea132bb03ba5c1 examples/revision-pairs.jsonl +26473dddaa0695d5a0dbd7c6d9a3da77dfd99e686650a27d789f51d4929a12bc lexicons/formulaic-openings-and-closings.yaml +741bf512903ed0bcdb3c43dc4575c65e00bd6fd413fe238b9ce331eb8e751c29 lexicons/product-names.example.yaml +db4c48c7d0a6c20c46f7ea82fb9ba28a645462a5e2f045498703f4ada746437e lexicons/protected-identifiers.example.yaml +0eee62d3891f6499b2682e36a9418297d6aec66c9217440504e1dc9a2b52d18b lexicons/vague-expressions.yaml +910c52906d19bd29c068f9696f2edcc81c2149d4c06b6bb3ee052eb048921667 profiles/architecture-decision.yaml +2b8a37f5dc61af83fd224ce25be614f5d6f30b7a9ca9af768b64d0c3d56b77ac profiles/conversational-tech.yaml +557ea745b8c517d8535b9787399245317a98c328b9a2da3b00f2e393d6a19113 profiles/default-formal.yaml +86528843f3efc5288121dfa2b1b13db1c1ed90c27334d0e3fe65b53802435b34 profiles/incident-postmortem.yaml +3f59159555be2e300c0944f36b5753228232064ce89daf11acc4212c1a2a5cd5 profiles/migration-case-study.yaml +1635d41c396bbb5f133c9c6a3535f76f7d5bd61f5a67f029d53cf0829d4f5c62 profiles/performance-case-study.yaml +d4be41789818f1cdafed59f24a1d18a719153f48bfb1d9024888d356f9261f4e profiles/recruitment-tech-content.yaml +52412ea45369baad5d0f715bc45e0abcf3de0d87184d3e6e1b491cf98f384c25 profiles/tooling-adoption.yaml +77f56eefa54db15f00adede694a0f7f61a1c2d87464ca12cfc0365bc8c817b58 profiles/tutorial-lab.yaml +407136db136e7a27afc4a5c6ed635a0d479b5b4372370fd8af3a44ab94c4bdfd references/decision-policy.md +58013844347c1e02a7183a4320e000cfef089d29e704f054f4a5bc7f40919ff0 references/enterprise-blog-patterns.md +0846e1b5293de602e15f52dec4f9776f5e302d101df4abd8356b69b6186492b5 references/evidence-and-source-policy.md +ba935624b8d143d573c85a05f4d931ec6bda9959ce3ef48eb69ff6b55b44a6ea references/exceptions.md +97f93c70523bf0cc1fcf0cad351a69b48d702420bd45bbc2841c6236df1a794e references/output-modes.md +849fba1475eac2ff5258e80be8a3f1cc9cd49c013ca9ed703b5a8ac112bf4b60 references/rule-catalog.md +3b933fa88f52f5e596f8231b0b128d5ca86b28cc452db91864a66e3d3d3b79a4 references/source-basis.md +88047b6409edb2b1e8705b1a5431bbb7f594ef8cb32fd43765a6c5d03da39803 references/structure-patterns.md +db85244892b698fc3dc424972920074f43f970d4ebccc09354eb1f3a891ce0d8 references/titles-introductions-conclusions.md +c110176b07a4a4edf75c9aa6edc374e08250be9a27bef0823b2f41ed085d6b8d schemas/article-brief.schema.json +417548ed4936633bdff7fb4aa87683130636932dfebe44c541c4b0fd426deb70 schemas/article-result.schema.json +9537896cb1914a8b6537aaa6b27d51b0e06e94bc60280a8ff1990f5904e4532c schemas/rubric.schema.json +ccd2fbe9b8c87af814eae9790df863b50b93f518cc1ba871ef2930ddac54c3e3 scripts/validate_skill.py +343d04ca2c1f5139a94176420417d5481aeaccfefdf6f4f09cd31a1654ed1201 tests/baseline-observations.md +50772b7b691fc86631b5e4ae35997d9c9ef056662eb43a76500c9ff27c239a09 tests/cases.json +03e73c9a515449f2a8a0162d1b90176f23d75efbf5d2ef255592dd8cf39a9d21 tests/evaluation-rubric.md +cfb996bb669ac09e3ffded859f421c8f30162c34eedf69446a6c85f9876bd961 tests/pressure-scenarios.md +6605eef379ba9e91d2ee4a60a9b28b36aa50a87037c89264afc601cf59515949 tests/workflow.jsonl diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/README.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/README.md new file mode 100644 index 0000000..fa37f16 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/README.md @@ -0,0 +1,88 @@ +# writing-korean-technical-blogs + +한국어 기술 블로그 한 편을 자료 기반으로 작성·재구성·검토하는 Agent Skill이다. 조사부터 게시까지 장기 상태를 관리하는 하네스가 아니라, **주어진 자료를 검증 가능한 기술 글로 변환하는 전문 작성 스킬**이다. + +## 책임 + +- 글의 목적·독자·문서 유형 확인 +- 사실·수치·코드·인용·공식 명칭 보존 +- 주장과 근거 연결 +- 문제·제약·선택·구현·결과·한계 중심 구조 설계 +- 기술 선택의 대안과 비용 보존 +- 불확실성·미측정·실패 조건 명시 +- 기술 블로그에 맞는 제목·도입·결론 작성 + +## 책임 밖 + +- 여러 사이트를 조사해 근거를 수집하는 전체 리서치 +- 명령어·코드의 실제 실행 검증 +- 이미지·다이어그램·대표 이미지 제작 +- CMS 게시와 배포 상태 관리 +- AI 작성 여부 또는 탐지 확률 판정 + +이 작업들이 함께 필요하면 이 스킬을 하위 작업자로 호출하는 `technical-blog-production` 하네스를 별도로 둔다. + +## 하위 스킬 + +권장 순서는 다음과 같다. + +```text +원자료 정리 +→ writing-korean-technical-blogs +→ reducing-ai-like-korean-writing +→ editing-korean-grammar-and-expression +→ 보호 항목 및 근거 최종 대조 +``` + +하위 스킬이 설치되지 않은 환경에서는 이 스킬이 구조와 근거 검토까지만 수행하고, 문체·문법 검수 미실행을 경고해야 한다. + +## 설치 + +Agent Skills 디렉터리에 폴더 전체를 복사한다. 폴더명과 frontmatter의 `name`은 반드시 `writing-korean-technical-blogs`로 일치해야 한다. + +```text +skills/ +└── writing-korean-technical-blogs/ + ├── SKILL.md + ├── references/ + ├── profiles/ + ├── lexicons/ + ├── examples/ + ├── tests/ + ├── schemas/ + └── scripts/ +``` + +## 사용 예 + +```text +첨부한 실험 기록만 근거로 성능 개선 기술 블로그를 작성하세요. +대상 독자는 백엔드 개발자입니다. +수치가 없는 부분은 만들지 말고 확인 필요로 남기세요. +``` + +```text +이 초안을 architecture-decision 프로필로 재구성하세요. +결정하지 않은 대안과 남은 위험을 삭제하지 마세요. +``` + +```text +글을 고치지 말고 audit 모드로 구조·근거·보호 구간 문제만 진단하세요. +``` + +## 기본값 + +- 독자: 한국어를 읽는 소프트웨어 엔지니어와 기술 의사결정자 +- 문체: 기존 문체가 일관되면 보존, 없으면 합니다체 +- 수정 분량: 기존 초안 수정 시 원문 대비 약 ±15% 범위 +- SEO: 요청이 없으면 키워드 반복이나 검색 최적화를 강제하지 않음 +- 기업 문체: 별도 가이드가 없으면 정확·명료·절제된 기술 문체 +- 공개 범위: 비밀, 키, 내부 주소, 개인정보, 미공개 장애 정보는 차단 또는 마스킹 경고 + +## 검증 + +```bash +python3 scripts/validate_skill.py +``` + +검증기는 구조, frontmatter, 규칙 ID, 테스트 커버리지, 보호 문자열, JSON Schema와 프로필 파일을 확인한다. 독립 에이전트의 실제 준수 여부는 `tests/pressure-scenarios.md`로 별도 A/B 테스트해야 한다. diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/SKILL.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/SKILL.md new file mode 100644 index 0000000..b460b7e --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/SKILL.md @@ -0,0 +1,76 @@ +--- +name: writing-korean-technical-blogs +description: Use when drafting, restructuring, or revising a Korean technical blog post from source material, experiment notes, incident records, code, or an existing draft, especially when the article must expose the problem, constraints, decisions, implementation, evidence, results, and limitations without inventing facts. +metadata: + version: "1.0.0" + language: "ko-KR" +--- + +# 한국어 기술 블로그 작성 + +## 개요 + +자료의 기술적 판단과 증거를 보존하면서 독자가 **문제·제약·선택·구현·결과·한계**를 따라갈 수 있는 기술 블로그를 작성하거나 재구성한다. + +> 좋은 글처럼 보이는 것보다 자료가 실제로 뒷받침하는 내용을 선명하게 전달하는 것이 우선이다. + +**REQUIRED SUB-SKILL:** 초안을 완성한 뒤 `reducing-ai-like-korean-writing`으로 상투성·추상화·반복을 점검한다. + +**REQUIRED SUB-SKILL:** 최종 맞춤법·띄어쓰기·호응 검수에는 `editing-korean-grammar-and-expression`을 사용한다. + +## 사용 경계 + +자료 기반 글 한 편을 작성·재구성·검토할 때 사용한다. 조사·실행 검증·이미지·게시·재개 상태까지 관리해야 하면 하네스를 사용한다. 순수 문법이나 문체 편집에는 하위 스킬을 직접 사용한다. + +## 입력 + +원자료·초안, 목적, 독자, 글 유형, 검증 상태, 보호할 수치·코드·인용·공식 명칭과 문체 가이드를 사용한다. 필수 정보가 없으면 `[확인 필요: 항목]`으로 남기고 선택 섹션은 생략한다. + +## 필수 절차 + +1. **잠금:** 수치, 날짜, 버전, 단위, 코드, 명령어, URL, 인용, 법무·보안 문구와 공식 명칭을 보호한다. +2. **근거 지도:** 각 핵심 주장에 원자료, 외부 출처, 관찰, 추론, 미검증 상태를 연결한다. +3. **프로필 선택:** `references/structure-patterns.md`와 `profiles/`에서 독자와 글 유형에 맞는 골격을 고른다. +4. **구조화:** 첫 15% 안에 문제·대상·독자가 얻을 정보를 드러내고, 핵심 결과가 있으면 측정 범위와 함께 먼저 제시한다. +5. **작성:** 선택 이유와 대안, 구현·실험, 결과, 비용, 실패 조건과 한계를 분리한다. +6. **문체 정리:** 근거 없는 평가어와 의례적 도입·결론을 줄이되 경험·실패·감정을 만들지 않는다. +7. **검증:** 보호 항목, 불확실성, 불리한 결과, 용어와 문체를 원자료와 다시 대조한다. + +## 빠른 판정 + +| 입력 상태 | 처리 | +|---|---| +| 근거가 충분함 | 글에 반영 | +| 필수 근거가 없음 | `[확인 필요]` 또는 최소 질문 | +| 선택 정보가 없음 | 섹션 생략 | +| 코드·인용·법무 문구 | 그대로 보존 | +| 미측정 결과 | 미측정 상태와 다음 검증만 기록 | + +## 절대 규칙 + +- 출처 없는 수치, 성과, 사용자 반응, 실패담, 감정이나 기업 입장을 만들지 않는다. +- 가능성을 확정으로, 상관관계를 인과로, 일부 결과를 전체 결과로 강화하지 않는다. +- 홍보를 위해 비용·위험·실패 조건·불리한 결과를 삭제하지 않는다. +- 기술 용어를 문체 변주용으로 바꾸거나 다른 기업의 말투를 모방하지 않는다. +- 인간적으로 보이게 하려고 오류·억지 유머를 넣지 않는다. + +## 출력 + +기본값은 `article`이다. `outline`, `audit`, `revision`, `compare`, `publication-package`는 `references/output-modes.md`를 따른다. + +## 대표 예시 + +**자료:** 배포에 평균 18분이 걸렸다. 실패 단계 추적이 어려웠다. 재설계 후 단계별 로그를 확인할 수 있다. + +**도입:** 기존 배포는 평균 18분이 걸렸고, 실패가 발생해도 어느 단계에서 멈췄는지 확인하기 어려웠다. 이 글에서는 배포 파이프라인을 재설계해 실패 단계를 추적할 수 있게 만든 과정을 설명한다. + +## 흔한 실패 + +| 실패 | 대응 | +|---|---| +| 없는 숫자로 구체화 | 확인 필요 표시 | +| 장점만 나열 | 대안·비용·적용 조건 포함 | +| 결론에서 본문 반복 | 결과·한계·다음 검증 제시 | +| 코드나 단위 변경 | 수정 롤백 | + +배포 전에는 `tests/`의 사례와 압박 시나리오로 검증한다. diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/examples/end-to-end-performance-case.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/examples/end-to-end-performance-case.md new file mode 100644 index 0000000..797f771 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/examples/end-to-end-performance-case.md @@ -0,0 +1,65 @@ +# 전체 예시: 배포 파이프라인 개선 글 + +## 입력 브리프 + +```yaml +audience: 백엔드·플랫폼 개발자 +purpose: 배포 파이프라인 재설계의 판단과 결과 공유 +document_type: performance-case-study +evidence: + - 기존 평균 배포 시간 18분 + - 변경 후 평균 7분 + - 실패율 3.2%에서 0.9%로 감소 + - 기존에는 실패 단계 확인이 어려움 + - 단계별 로그와 자동 롤백 추가 + - 수동 승인 대기 시간은 측정하지 않음 +protected: + - "kubectl rollout undo deployment/api --to-revision=7" +``` + +## 주장 장부 + +| 주장 | 근거 | 범위 | +|---|---|---| +| 배포 시간이 줄었다 | 18분 → 7분 | 동일 서비스, 동일 측정 방식 | +| 실패율이 줄었다 | 3.2% → 0.9% | 측정 기간은 브리프에 추가 확인 필요 | +| 실패 지점 추적이 가능해졌다 | 단계별 로그 | 파이프라인 단계 | +| 전체 배포 시간이 7분이다 | 수동 승인 대기 미포함 | 자동화 구간만 | + +## 목차 + +1. 실패한 배포를 어디서 확인해야 할지 알 수 없었다 +2. 평균 시간보다 먼저 실패 경계를 나눴다 +3. 단계별 로그와 롤백을 추가했다 +4. 자동화 구간은 18분에서 7분으로 줄었다 +5. 승인 대기 시간은 다음 측정으로 남았다 + +## 작성 예시 + +# 실패 단계를 나눠 배포 시간을 18분에서 7분으로 줄인 과정 + +기존 배포는 평균 18분이 걸렸다. 실패하면 어느 단계에서 멈췄는지 바로 확인하기 어려워 로그를 다시 모으고 수동으로 롤백해야 했다. 이번 변경에서는 배포 단계를 분리하고 각 단계의 로그와 롤백 경로를 추가했다. + +## 먼저 실패 경계를 분리했다 + +목표는 단순히 평균 시간을 줄이는 것이 아니었다. 실패 지점을 빠르게 확인하고, 문제가 생긴 배포만 이전 리비전으로 되돌릴 수 있어야 했다. 따라서 빌드, 배포, 상태 확인을 독립 단계로 나누고 각 단계가 종료 조건을 직접 기록하게 했다. + +롤백에는 다음 명령을 사용했다. + +```bash +kubectl rollout undo deployment/api --to-revision=7 +``` + +## 자동화 구간은 평균 7분이 걸렸다 + +변경 후 자동화 구간의 평균 배포 시간은 18분에서 7분으로 줄었고 실패율은 3.2%에서 0.9%로 감소했다. 다만 이 값에는 수동 승인 대기 시간이 포함되지 않는다. 전체 리드 타임을 평가하려면 승인 요청부터 완료까지의 대기 시간을 별도로 측정해야 한다. + +## 남은 일 + +현재 결과는 자동화 구간의 개선을 보여 준다. 다음 측정에서는 승인 대기 시간과 롤백 완료 시간을 분리해, 파이프라인 변경이 전체 배포 리드 타임에 미친 영향을 확인한다. + +## 검토 포인트 + +- 측정 기간과 표본 수가 없으므로 게시 전 추가한다. +- 코드 블록과 수치는 그대로 보존한다. +- ‘완전히 자동화했다’거나 ‘사용자 경험이 좋아졌다’는 주장은 근거가 없어 넣지 않는다. diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/examples/revision-pairs.jsonl b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/examples/revision-pairs.jsonl new file mode 100644 index 0000000..633f6e1 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/examples/revision-pairs.jsonl @@ -0,0 +1,4 @@ +{"id": "pair-01", "type": "opening", "source_context": "배포 평균 18분, 실패 단계 추적 불가", "before": "오늘날 빠르게 변화하는 개발 환경에서 안정적인 배포는 매우 중요합니다. 이번 글에서는 배포 개선 여정을 살펴보겠습니다.", "after": "기존 배포는 평균 18분이 걸렸고, 실패가 발생해도 어느 단계에서 멈췄는지 확인하기 어려웠다. 이 글에서는 배포 시간을 줄이고 실패 단계를 추적할 수 있도록 파이프라인을 재설계한 과정을 설명한다.", "rule_ids": ["AUD-01", "AI-01", "STR-01"]} +{"id": "pair-02", "type": "evidence", "source_context": "API p95 420ms -> 180ms, 반복 조회 캐시", "before": "캐시를 적용해 성능과 사용자 경험을 크게 개선했습니다.", "after": "반복 조회 결과를 캐시해 데이터베이스 호출을 줄였다. 그 결과 API p95 응답 시간은 420ms에서 180ms로 감소했다.", "rule_ids": ["SRC-01", "AI-02", "CLR-01", "INV-01"]} +{"id": "pair-03", "type": "conclusion", "source_context": "실패율 3.2% -> 0.9%, 수동 승인 잔존", "before": "이번 프로젝트는 성공적이었고 많은 것을 배웠습니다. 앞으로도 지속적으로 발전시키겠습니다.", "after": "변경 후 배포 실패율은 3.2%에서 0.9%로 줄었다. 다만 수동 승인 단계는 남아 있으며, 다음 분기에는 승인 대기 시간을 별도로 측정한다.", "rule_ids": ["AI-04", "INV-01", "STR-01"]} +{"id": "pair-04", "type": "uncertainty", "source_context": "개발 환경에서만 빠른 경향, 운영 측정 없음", "before": "새 구조는 기존 구조보다 훨씬 빠르고 효율적입니다.", "after": "개발 환경에서는 새 구조의 응답이 더 빨라지는 경향을 관찰했다. 운영 환경의 전후 측정값은 아직 없어 성능 개선으로 단정하지 않는다.", "rule_ids": ["SRC-01", "SRC-02", "AI-02"]} diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/formulaic-openings-and-closings.yaml b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/formulaic-openings-and-closings.yaml new file mode 100644 index 0000000..afb0681 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/formulaic-openings-and-closings.yaml @@ -0,0 +1,18 @@ +# 발견 시 자동 삭제하지 않는다. 글의 기능과 대체할 실제 정보가 있는지 확인한다. +openings: + - "오늘날 빠르게 변화하는" + - "현대 사회에서" + - "이번 글에서는 살펴보겠습니다" + - "여정을 소개합니다" +transitions: + - "이를 통해" + - "이러한 관점에서" + - "다음과 같은 내용을 확인할 수 있습니다" +closings: + - "더 나은 미래를 기대합니다" + - "많은 것을 배울 수 있었습니다" + - "지속적으로 발전시켜 나갈 예정입니다" + - "도움이 되기를 기대합니다" +policy: + - "실제 문제·관찰·결정·결과·한계로 대체할 근거가 있을 때만 수정" + - "표현 하나만으로 AI 작성 여부를 판정하지 않음" diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/product-names.example.yaml b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/product-names.example.yaml new file mode 100644 index 0000000..0e18af2 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/product-names.example.yaml @@ -0,0 +1,19 @@ +# 예시 사전이다. 프로젝트의 공식 표기표가 있으면 이를 대체한다. +terms: + - canonical: Apache Kafka + aliases: [Kafka, 카프카, Apache kafka] + first_use: "Apache Kafka(이하 Kafka)" + later_use: "Kafka" + - canonical: Kubernetes + aliases: [쿠버네티스, K8s] + preserve_identifiers: true + - canonical: Redis + aliases: [레디스] + preserve_identifiers: true + - canonical: gRPC + aliases: [GRPC, grpc] + preserve_identifiers: true +policy: + - "코드와 공식 제품명은 대소문자를 보존" + - "일반 개념의 한국어 설명은 첫 등장에만 필요할 수 있음" + - "검색 가능성을 해치는 임의 한글화 금지" diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/protected-identifiers.example.yaml b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/protected-identifiers.example.yaml new file mode 100644 index 0000000..202ad70 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/protected-identifiers.example.yaml @@ -0,0 +1,14 @@ +# 프로젝트에 맞게 복사하여 확장한다. 이 파일은 예시이며 포괄적 사전이 아니다. +identifiers: + - Kubernetes + - Apache Kafka + - Redis + - PostgreSQL + - Keycloak + - OAuth 2.0 + - OpenID Connect + - gRPC +policies: + official_case_sensitive: true + preserve_inside_code: true + do_not_translate_identifiers: true diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/vague-expressions.yaml b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/vague-expressions.yaml new file mode 100644 index 0000000..94f1a69 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/lexicons/vague-expressions.yaml @@ -0,0 +1,16 @@ +# 후보 표현이다. 단어 자체를 금지하지 말고 문맥과 근거를 확인한다. +expressions: + - text: "중요합니다" + inspect_for: "중요한 대상·이유·영향·기준 부재" + - text: "효율적입니다" + inspect_for: "시간·비용·자원·절차 중 무엇이 줄었는지 부재" + - text: "혁신적입니다" + inspect_for: "비교 기준과 변화가 없음" + - text: "성능이 좋아졌습니다" + inspect_for: "지표·환경·전후 수치 부재" + - text: "유연한 대응이 가능합니다" + inspect_for: "어떤 변화에 어떤 방식으로 대응하는지 부재" + - text: "사용자 경험을 개선했습니다" + inspect_for: "관찰·지표·사용자 피드백 근거 부재" + - text: "널리 사용될 것으로 예상됩니다" + inspect_for: "예측 주체·범위·시점·근거 부재" diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/architecture-decision.yaml b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/architecture-decision.yaml new file mode 100644 index 0000000..76c3a37 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/architecture-decision.yaml @@ -0,0 +1,18 @@ +id: architecture-decision +register: preserve_or_hamnida +use_when: 아키텍처나 기술 선택의 이유와 결과를 설명할 때 +required_sections: + - context_and_problem + - decision_forces + - alternatives + - decision_and_reason + - implementation_or_migration + - consequences + - limitations_and_reversal_conditions +optional_sections: + - diagrams + - code_examples + - future_options +rules: + do_not_turn_tradeoffs_into_benefits_only: true + preserve_rejected_options_and_reasons: true diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/conversational-tech.yaml b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/conversational-tech.yaml new file mode 100644 index 0000000..b94a8b4 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/conversational-tech.yaml @@ -0,0 +1,11 @@ +id: conversational-tech +name: 대화형 기술 글 +register: preserve_consistent_haeyo_or_hamnida +required_meaning: + - reader_question + - concrete_context + - technical_reasoning + - verification +opening: reader_question_or_actual_observation +ending: decision_and_remaining_question +allow_humor: only_if_source_contains_it diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/default-formal.yaml b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/default-formal.yaml new file mode 100644 index 0000000..60078f5 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/default-formal.yaml @@ -0,0 +1,18 @@ +id: default-formal +register: hamnida +use_when: 문서 유형이 특정되지 않은 일반 기술 사례 +required_sections: + - problem_and_reader_value + - constraints_and_goal + - decision_or_approach + - implementation + - evidence_and_result + - limitations_or_next_step +optional_sections: + - alternatives + - code_examples + - operational_notes +rules: + preserve_existing_consistent_register: true + default_if_absent: 합니다체 + omit_unsupported_optional_sections: true diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/incident-postmortem.yaml b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/incident-postmortem.yaml new file mode 100644 index 0000000..ea4b0da --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/incident-postmortem.yaml @@ -0,0 +1,20 @@ +id: incident-postmortem +register: formal +use_when: 장애의 영향, 탐지, 복구, 원인과 재발 방지를 공개 가능한 범위에서 설명할 때 +required_sections: + - incident_summary + - user_impact + - detection_and_timeline + - technical_cause + - contributing_factors + - recovery + - corrective_actions +optional_sections: + - what_worked + - what_did_not_work + - follow_up_metrics +rules: + blameless_system_focus: true + preserve_uncertainty: true + never_expose_sensitive_or_unpublished_details: true + do_not_name_individuals_unless_required_and_authorized: true diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/migration-case-study.yaml b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/migration-case-study.yaml new file mode 100644 index 0000000..54695ce --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/migration-case-study.yaml @@ -0,0 +1,17 @@ +id: migration-case-study +register: preserve_or_hamnida +use_when: 데이터, 플랫폼, 프레임워크, 인프라 또는 API 이관 과정을 설명할 때 +required_sections: + - why_migration_was_needed + - source_and_target_constraints + - migration_strategy + - validation_and_rollback + - rollout + - result_and_remaining_risk +optional_sections: + - data_backfill + - compatibility_layer + - operational_checklist +rules: + explain_invisible_work_value_early: true + preserve_failure_and_rollback_conditions: true diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/performance-case-study.yaml b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/performance-case-study.yaml new file mode 100644 index 0000000..1bfe880 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/performance-case-study.yaml @@ -0,0 +1,18 @@ +id: performance-case-study +register: preserve_or_hamnida +use_when: 응답 시간, 처리량, 오류율, 자원 사용량 등 전후 성능을 설명할 때 +required_sections: + - baseline_and_problem + - metric_definition + - environment_and_conditions + - hypotheses_and_changes + - before_after_results + - regressions_and_limitations +optional_sections: + - failed_attempts + - dashboards + - code_or_query +rules: + put_key_result_in_first_15_percent: true + never_report_metric_without_scope: true + keep_adverse_results: true diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/recruitment-tech-content.yaml b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/recruitment-tech-content.yaml new file mode 100644 index 0000000..0ed63a0 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/recruitment-tech-content.yaml @@ -0,0 +1,11 @@ +id: recruitment-tech-content +name: 팀·채용 기술 콘텐츠 +required_meaning: + - systems_and_problem_types + - role_and_ownership + - collaboration_boundaries + - real_technical_challenges +forbid: + - unverifiable_superlatives + - invented_scale + - promotional_exclamation_as_substitute_for_information diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/tooling-adoption.yaml b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/tooling-adoption.yaml new file mode 100644 index 0000000..6135405 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/tooling-adoption.yaml @@ -0,0 +1,18 @@ +id: tooling-adoption +register: preserve_or_hamnida +use_when: 새로운 개발 도구, 플랫폼, 자동화 또는 AI 도구의 도입 과정을 설명할 때 +required_sections: + - original_problem + - evaluation_criteria + - options_or_prior_approach + - pilot_or_architecture + - workflow + - observed_results + - costs_and_limits +optional_sections: + - rollout_plan + - governance + - security_review +rules: + distinguish_expectation_from_observation: true + do_not_claim_productivity_without_measurement: true diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/tutorial-lab.yaml b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/tutorial-lab.yaml new file mode 100644 index 0000000..cd9cc94 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/profiles/tutorial-lab.yaml @@ -0,0 +1,14 @@ +id: tutorial-lab +name: 명령어 기반 구성 실습 +required_meaning: + - target_end_state + - prerequisites_and_versions + - commands_in_order + - purpose_of_each_command + - expected_observations + - verification + - cleanup_or_rollback + - common_failures_and_diagnosis +forbid: + - claiming_unexecuted_commands_succeeded + - omitting_destructive_command_warnings diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/decision-policy.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/decision-policy.md new file mode 100644 index 0000000..61abd54 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/decision-policy.md @@ -0,0 +1,42 @@ +# 판단 우선순위와 불변식 + +## 우선순위 + +1. 사실·법무·보안·코드·직접 인용 +2. 사용자 요구와 프로젝트·기업의 공식 가이드 +3. 공식 제품명과 프로젝트 용어 +4. 한국어 어문 규범 +5. 기술 독자의 이해와 접근성 +6. 기술 블로그 장르 구조 +7. AI 유사 문체 완화 +8. 미적 변주와 개성 강화 + +하위 규칙이 상위 규칙을 침해하면 하위 수정을 취소한다. + +## 불변식 + +- 긍정·부정, 조건, 예외, 시제, 시간 순서 +- 가능성·권고·의무·확정의 강도 +- 주체, 객체, 책임 범위와 1인칭 관점 +- 수치, 단위, 날짜, 버전, 오류 코드와 지표 정의 +- 기술 선택의 이유, 비교한 대안, 비용과 위험 +- 실험 환경, 표본, 미측정 상태와 불확실성 +- 제품명, 기술명, API·클래스·함수·설정 키 +- 코드, 명령어, URL, 직접 인용, 법무·보안 문구 +- 마크다운의 코드 블록, 표, 목록과 링크 구조 + +## 즉시 실패 + +- 원문에 없는 수치·성과·사례·감정·사용자 반응 생성 +- 코드·명령어·법무 문구·직접 인용 변경 +- 민감 정보 또는 미공개 정보를 그대로 공개 +- 불리한 결과, 실패 조건, 비용 또는 위험 삭제 +- 미측정 결과를 검증된 결과처럼 작성 +- 작성 주체가 불명확한데 임의로 개인이나 팀에 책임 부여 + +## 정보 부족 + +- 글의 목적·독자·문서 유형이 없어도 안전한 기본값으로 진행할 수 있으면 가정 목록에 기록한다. +- 사실 여부나 구조를 바꾸는 필수 정보가 없으면 한 번에 필요한 최소 질문만 하거나 `[확인 필요]`로 남긴다. +- 선택적인 배경·회고·성과 정보가 없으면 해당 섹션을 생략한다. +- 자료끼리 충돌하면 더 높은 우선순위의 출처를 사용하고 충돌을 경고한다. diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/enterprise-blog-patterns.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/enterprise-blog-patterns.md new file mode 100644 index 0000000..776f9c8 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/enterprise-blog-patterns.md @@ -0,0 +1,29 @@ +# 기업 기술 블로그에서 재현할 구조적 패턴 + +이 문서는 특정 기업의 문체를 모방하기 위한 자료가 아니다. 업로드된 연구가 NAVER D2, 카카오, LINE, 우아한형제들, 삼성SDS의 공개 글에서 추출한 **구조적 특징**만 일반화한다. + +## 재현할 가치가 큰 패턴 + +- `성능이 좋아졌다`보다 지표 정의와 전후 수치를 제시한다. +- 측정·관찰 단계와 개선·적용 단계를 분리한다. +- 도입 계기에서 아키텍처와 실제 시나리오까지 독자의 판단 순서로 전개한다. +- 정량 목표를 먼저 정하고 분석·조치·재측정으로 이어 간다. +- 여러 시도를 하나의 묘책처럼 합치지 않고 각 가설과 결과를 분리한다. +- 성공 결과뿐 아니라 테스트 설계, 운영 비용, 실패 조건과 교훈을 남긴다. +- 실험 환경과 비교 기준을 공개해 수치의 적용 범위를 드러낸다. +- 사용자 화면에 보이지 않는 이관·인프라 작업은 왜 필요했는지부터 설명한다. +- 기존 기술의 기대 효과와 실제 워크로드에서 얻지 못한 효과를 대조한다. +- 표와 참고문헌은 핵심 명제를 검증 가능하게 만드는 경우에만 사용한다. + +## 피해야 할 패턴 + +- 추상적인 미래·혁신 은유로 결론을 대신함 +- 범위·시점·근거가 없는 전망 +- 한 문장에 개발·품질·위험·확장성 효과를 모두 중첩 +- `도움이 되기를 기대합니다` 같은 의례적 마무리 +- 검증 불가능한 최상급과 감탄 표현 +- 브랜드 친근함을 이유로 기술적 경고나 비용을 약화 + +## 브랜드 적용 + +프로젝트의 명시적 스타일 가이드가 있으면 이를 우선한다. 가이드가 없으면 다른 기업의 어휘·유머·말투를 흉내 내지 않고, 정확·명료·절제된 기본 문체를 사용한다. diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/evidence-and-source-policy.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/evidence-and-source-policy.md new file mode 100644 index 0000000..790916d --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/evidence-and-source-policy.md @@ -0,0 +1,38 @@ +# 근거와 출처 처리 + +## 주장 유형 + +각 핵심 문장을 다음 중 하나로 분류한다. + +| 유형 | 의미 | 작성 방식 | +|---|---|---| +| source | 제공된 자료에 직접 있음 | 자료의 범위와 표현 강도를 유지 | +| external | 외부 출처가 있음 | 출처와 적용 범위를 함께 표시 | +| observed | 작성자 또는 팀이 관찰함 | 환경·기간·측정 방법을 함께 기록 | +| inferred | 자료를 바탕으로 추론함 | 추론임을 명시하고 근거를 연결 | +| unverified | 아직 확인하지 않음 | `[확인 필요]`, 미측정 또는 예정으로 표시 | + +## 근거 지도 + +초안 전 최소한 다음 표를 내부적으로 만든다. + +```text +주장 | 근거 위치 | 신뢰 수준 | 보호 요소 | 공개 가능 여부 +``` + +정량 주장은 수치만 남기지 말고 지표 정의, 측정 기간, 환경, 비교 기준과 제외 조건을 가능한 범위에서 함께 기록한다. + +## 외부 자료 + +사용자가 외부 조사나 검증을 요청하지 않았다면 제공된 자료 밖의 지식을 사실처럼 채우지 않는다. 외부 조사를 수행했다면 소스 기반 내용과 외부 조사 내용을 분리하고 인용을 붙인다. + +## 코드와 명령어 + +- 코드와 명령어는 자연어 편집 대상에서 제외한다. +- 실행 결과가 제공되지 않았으면 `검증했다`, `정상 동작한다`고 쓰지 않는다. +- 코드 설명은 코드가 실제로 하는 일을 넘어서지 않는다. +- 예제 코드가 축약되거나 의사 코드이면 그 사실을 표시한다. + +## 민감 정보 + +계정, 비밀 키, 토큰, 내부 도메인·IP, 개인정보, 미공개 장애 정보, 고객 식별자는 공개 글에 포함하지 않는다. 자동 마스킹으로 의미가 손상될 수 있으면 `blocked` 상태와 필요한 조치를 반환한다. diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/exceptions.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/exceptions.md new file mode 100644 index 0000000..6ee27a5 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/exceptions.md @@ -0,0 +1,28 @@ +# 경계와 예외 + +| 상황 | 잘못된 처리 | 올바른 처리 | +|---|---|---| +| 성능이 좋아졌지만 수치 없음 | 임의의 백분율 추가 | 관찰 환경과 미측정 상태 명시 | +| 행위자 미확정 | 능동태를 위해 운영자 지정 | 피동을 유지하고 주체 미확정 표시 | +| 직접 인용에 구어체·오탈자 | 기술 문체로 바꿈 | 인용문은 보존하고 밖에서 설명 | +| 코드 주석의 비표준 표현 | 코드와 함께 자동 교정 | 실행 코드 보호, 변경 허용된 자연어 주석만 별도 검토 | +| 영문 기술명 혼용 | 임의로 한글화 | 공식 표기 확인, 불가하면 첫 표기 유지 + 경고 | +| 해요체 원문 | 무조건 합니다체로 통일 | 일관된 원문 말투 유지 | +| 감성적 글을 요청 | 경험·감정 창작 | 자료에 있는 관찰과 감정만 사용 | +| 핵심 용어 반복 | 동의어로 무작위 변경 | 기술 용어는 유지하고 주변 구조를 조정 | +| 결론 중복 제거 | 한계·재발 방지까지 삭제 | 단순 재요약만 줄임 | +| 보안·장애 공지 | 친근함을 위해 심각성 완화 | 위험 전달과 정확성 우선 | + +## 질문 대신 진행할 수 있는 경우 + +- 독자가 미지정이면 기본 독자 가정을 밝히고 진행 +- 말투가 미지정이면 원문을 유지하거나 기본 합니다체 사용 +- 선택 절의 정보가 없으면 생략 +- 일부 근거만 부족하면 해당 주장에 `확인 필요`를 붙이고 나머지 작성 + +## 중단 또는 차단할 경우 + +- 핵심 수치나 결과가 서로 충돌함 +- 소스에 없는 주장을 반드시 사실처럼 쓰라고 요구함 +- 공개하면 안 되는 정보가 글의 핵심임 +- 법적 고지나 인용을 변조해야만 요청을 만족함 diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/output-modes.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/output-modes.md new file mode 100644 index 0000000..5f93297 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/output-modes.md @@ -0,0 +1,48 @@ +# 출력 모드 + +## article — 기본 + +완성된 제목과 본문을 먼저 제공한다. 근거 부족이나 공개 위험이 있을 때만 짧은 경고를 덧붙인다. + +## outline + +자료를 쓰지 않고 다음을 출력한다. + +- 글의 목적과 독자 +- 핵심 주장과 근거 +- 선택한 프로필 +- 제목 후보 +- 섹션별 메시지와 필요한 자료 +- 확인이 필요한 항목 + +## audit + +원문을 수정하지 않는다. 구조, 근거, 불변식, 보호 구간, 기술적 설명력, 문체 위험과 공개 위험을 심각도순으로 진단한다. + +## revision + +수정본을 먼저 제시하고 주요 변경을 `문제 → 수정 → 규칙 ID → 보존 확인` 형식으로 기록한다. + +## compare + +원문과 수정문을 대응시켜 보여 준다. 문장 전체를 모두 설명하지 않고 의미 있는 구조·근거·보존 관련 변경만 기록한다. + +## publication-package + +요청이 있을 때만 다음을 포함한다. + +- 제목 3개 이하 +- 한 문단 요약 +- 본문 +- 메타 설명 +- 태그 후보 +- 근거·인용 목록 +- 공개 전 확인 항목 + +SEO 키워드 반복, 클릭 유도형 제목, 근거 없는 성과 문구는 추가하지 않는다. + +## 상태 + +- `pass`: 자료 범위 안에서 결과를 작성함 +- `needs_clarification`: 필수 사실 또는 공개 범위가 불명확함 +- `blocked`: 민감 정보, 법무·보안 위험 또는 보호 구간 훼손 없이는 작성할 수 없음 diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/rule-catalog.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/rule-catalog.md new file mode 100644 index 0000000..1e37dcc --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/rule-catalog.md @@ -0,0 +1,72 @@ +# 규칙 카탈로그 + +이 문서는 스킬의 판단 규칙과 테스트 ID를 연결한다. 규칙 충돌 시 `references/decision-policy.md`의 우선순위를 따른다. + +### INV-01 — 수치·날짜·버전·단위 보존 +원문에서 숫자와 대응 대상을 추출하고 출력에서 같은 관계를 유지한다. 값, 방향, 단위, 기간을 임의로 바꾸지 않는다. + +### INV-02 — 보호 구간 잠금 +코드 블록, 인라인 코드, 명령어, URL, 직접 인용, 법무·보안 문구, 사용자가 잠근 문자열은 정확히 보존한다. + +### INV-03 — 공식 용어 표기표 +제품명, 기술명, 팀명, 약어와 식별자의 기준 표기를 먼저 정하고 글 전체에서 일관되게 사용한다. + +### SRC-01 — 원문 밖 사실 생성 금지 +자료에 없는 성과, 원인, 사용자 반응, 업계 추세, 감정과 경험을 만들지 않는다. + +### SRC-02 — 미지정 정보의 명시 +필수 정보가 없으면 `[확인 필요: ...]`, 미지정, 미측정 또는 질문으로 남긴다. 선택 섹션은 생략한다. + +### AUD-01 — 목적·독자·독자 결과 확인 +글을 쓰기 전에 왜 쓰는지, 누가 읽는지, 읽고 무엇을 이해하거나 결정해야 하는지 고정한다. + +### STR-01 — 기술 사례 기본 골격 +자료가 뒷받침하는 범위에서 문제·맥락 → 제약·대안 → 선택 → 구현·실험 → 결과 → 한계·후속 조치로 구성한다. + +### STR-02 — 핵심 결과의 조기 제시 +결과 수치가 글의 핵심이면 첫 15% 안의 요약이나 도입에 배치하고 측정 환경과 함께 제시한다. + +### STR-03 — 대상과 행동이 드러나는 제목 +`소개`, `살펴보기`, `여정`만으로 제목을 만들지 않는다. 대상, 문제, 선택 또는 결과를 제목에 드러낸다. + +### KOR-01 — 한국어 규범 최종 검수 +초안과 문체 편집이 끝난 뒤 `editing-korean-grammar-and-expression`으로 맞춤법·띄어쓰기·문장 부호를 검수한다. + +### KOR-02 — 문장 호응과 수식 범위 +주어·목적어·서술어의 호응을 확인하고 독립 주장·조건·결론이 한 문장에 과도하게 중첩되면 의미를 보존해 분리한다. + +### KOR-03 — 식별자와 일반 개념 구분 +코드 식별자와 공식 제품명은 원문을 보존한다. 일반 기술 개념은 필요할 때 첫 등장에 한국어 설명을 붙인다. + +### CLR-01 — 주체와 동작 우선 +추상 명사와 막연한 평가보다 누가 무엇을 했고 어떤 영향이 있었는지 쓴다. 근거가 없으면 구체화를 보류한다. + +### CLR-02 — 복합 문장 분리 +독립 주장·조건·결론이 셋 이상이거나 검증 관계가 흐려지면 문장을 나누거나 표·목록으로 옮긴다. + +### CLR-03 — 모호한 지시어 복원 +`이를`, `이러한`, `해당`, `이것`의 선행 대상이 불명확하면 자료에 있는 구체 명사를 복원한다. + +### AI-01 — 실제 문제로 시작 +시대 일반론, 의례적 인사, 글쓰기 행위 설명보다 시스템의 문제, 관찰값, 목표 또는 독자가 얻을 정보를 먼저 제시한다. + +### AI-02 — 평가어를 근거로 대체 +`중요하다`, `효율적이다`, `혁신적이다`, `빠르다`는 지표·작동 방식·영향·비교 기준이 있을 때만 사용한다. + +### AI-03 — 구조와 문장 틀 반복 완화 +접속어와 종결형을 무작위로 바꾸지 않는다. 실제 인과·시간·비교 관계에 맞춰 반복을 줄인다. + +### AI-04 — 결과·한계 중심 결론 +결론은 본문 재요약이나 의례적 기대보다 결정, 검증 결과, 적용 조건, 남은 문제와 다음 검증을 제시한다. + +### AI-05 — 인간 흉내 금지 +자연스럽게 보이게 하려고 오탈자, 비문, 감정, 실패담, 사적 일화나 확신을 만들지 않는다. + +### BRD-01 — 프로젝트·기업 프로필 우선 +명시된 브랜드 가이드가 있으면 우선한다. 없으면 다른 기업을 모방하지 않고 정확·명료·절제된 기본 프로필을 사용한다. + +### REV-01 — 변경 근거 기록 +수정 모드에서는 주요 변경마다 문제, 수정 결과, 규칙 ID, 보존 확인과 필요한 경고를 기록한다. + +### TST-01 — 하드 게이트와 회귀 검증 +사실 변경, 보호 구간 변경, 허위 근거, 보안 노출은 점수와 무관하게 실패다. 일반·어려운·회귀 사례를 모두 검증한다. diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/source-basis.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/source-basis.md new file mode 100644 index 0000000..8502363 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/source-basis.md @@ -0,0 +1,34 @@ +# 자료 근거 + +이 스킬은 사용자가 제공한 연구 문서 `붙여넣은 마크다운(1)(2).md`의 내용을 기반으로 구성했다. 문서에 포함된 다음 범주의 자료와 사례를 규칙·프로필·테스트로 변환했다. + +- 국립국어원 한국어 어문 규범, 맞춤법·표준어·문장 부호·공공언어 자료 +- 토스의 라이팅 원칙, 테크니컬 라이팅 Skill 구현과 Skill 품질 루브릭 사례 +- Google Developer Documentation Style Guide +- Microsoft Writing Style Guide +- 한국어 LLM 문체 관련 ACL 2025 연구 +- NAVER D2, 카카오, LINE, 우아한형제들, 삼성SDS의 공개 기술 글 사례 분석 + +## 출처 계층 + +1. 사실·법무·보안·코드·직접 인용 +2. 프로젝트 또는 기업의 명시적 가이드 +3. 공식 제품명과 기술 용어 +4. 국립국어원 공식 규범 +5. 기술 독자의 이해와 접근성 +6. 기술 블로그 장르 관습 +7. AI 유사 문체 완화 +8. 미적 변주 + +## 원문이 제시한 주요 링크 + +- https://korean.go.kr/kornorms +- https://developers.google.com/style +- https://learn.microsoft.com/en-us/style-guide/welcome/ +- https://toss.tech/article/8-writing-principles-of-toss +- https://toss.tech/article/technical-writing-5 +- https://toss.tech/article/skill-quality-rubric +- https://aclanthology.org/2025.acl-long.1030/ +- https://aclanthology.org/2025.acl-long.267/ + +이 패키지는 링크의 최신 상태나 원 연구의 해석을 별도로 재검증하지 않았다. 스킬 내용은 업로드된 연구가 정리한 범위에 한정된다. diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/structure-patterns.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/structure-patterns.md new file mode 100644 index 0000000..837fb14 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/structure-patterns.md @@ -0,0 +1,56 @@ +# 기술 블로그 구조 패턴 + +목차를 고정 템플릿처럼 강제하지 않는다. 독자가 따라야 할 의사결정 순서를 기준으로 프로필을 선택한다. + +## 공통 골격 + +1. 문제 또는 관찰값 +2. 왜 지금 해결해야 했는지 +3. 제약과 성공 기준 +4. 검토한 대안과 선택 이유 +5. 구현·실험 또는 운영 방식 +6. 검증 방법과 결과 +7. 비용·한계·실패 조건 +8. 남은 과제와 적용 조건 + +자료가 없는 섹션은 만들지 않는다. 결과가 핵심이면 도입부에서 먼저 보여 주고 뒤에서 측정 방법을 설명한다. + +## 도입 + +첫 15% 안에 다음 중 필요한 내용을 드러낸다. + +- 어떤 시스템이나 작업을 다루는지 +- 실제 문제 또는 관찰값 +- 독자가 얻을 수 있는 정보 +- 핵심 결과와 측정 범위 + +피해야 할 시작은 시대 일반론, 의례적 인사, `이번 글에서는 살펴보겠습니다`뿐인 문장이다. + +## 제목 + +제목은 대상·문제·행동·선택·결과 중 하나 이상을 담는다. + +```text +나쁨: Kubernetes 배포 자동화 소개 +개선: Kubernetes 배포에서 승인·롤백·상태 확인을 자동화한 방법 +``` + +숫자를 제목에 넣을 때는 본문이 같은 측정 기준을 뒷받침해야 한다. + +## 본문 + +- 기술 선택은 장점 목록보다 제약과 대안 비교로 설명한다. +- 실험은 환경, 입력, 지표, 전후 조건을 분리한다. +- 여러 시도는 가설·조치·결과를 각각 묶는다. +- 보이지 않는 인프라 작업은 `왜 해야 했는가`부터 설명한다. +- 구현 세부는 독자가 재현하거나 판단하는 데 필요한 수준까지만 포함한다. + +## 결론 + +결론은 본문을 다시 요약하는 대신 다음을 선택한다. + +- 실제 결과와 측정 범위 +- 선택이 유효한 조건 +- 남은 비용과 위험 +- 실패한 가설 또는 얻은 교훈 +- 다음에 측정하거나 바꿀 항목 diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/titles-introductions-conclusions.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/titles-introductions-conclusions.md new file mode 100644 index 0000000..de794a7 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/references/titles-introductions-conclusions.md @@ -0,0 +1,43 @@ +# 제목·도입·결론 + +## 제목 + +대상과 행동 또는 갈등을 드러낸다. + +| 약한 제목 | 개선 방향 | +|---|---| +| Kubernetes 살펴보기 | Kubernetes로 배포 롤백을 자동화한 방법 | +| 성능 개선 이야기 | 검색 API p95를 420ms에서 180ms로 줄인 과정 | +| Kafka 도입기 | 장시간 작업에서 Kafka 대신 RDB Task Queue를 선택한 이유 | + +수치 제목은 근거와 범위가 명확할 때만 사용한다. + +## 도입 + +첫 15% 안에 다음 세 가지를 드러낸다. + +1. 어떤 시스템·작업에서 무슨 문제가 있었는가 +2. 왜 독자에게 중요한가 또는 어떤 제약이 있었는가 +3. 글을 읽으면 무엇을 알 수 있는가 + +시대 일반론, 의례적 인사, ‘여정을 살펴보겠다’는 메타 문장으로 시작하지 않는다. + +## 소제목 + +`소개`, `배경`, `내용`, `결론`만 쓰지 말고 절의 판단이나 동작을 표현한다. + +- `배경` → `배포가 18분 걸린 이유` +- `구현` → `실패 단계를 분리해 로그를 남기기` +- `결과` → `평균 배포 시간은 줄었지만 승인 대기는 남았다` + +## 결론 + +다음 중 실제 자료가 있는 항목으로 끝낸다. + +- 어떤 결정을 내렸는가 +- 어떤 결과를 어떤 조건에서 확인했는가 +- 무엇은 해결하지 못했는가 +- 어디까지 적용 가능한가 +- 다음에 무엇을 측정하거나 바꿀 것인가 + +본문을 다시 요약하거나 ‘더 나은 미래’, ‘많은 것을 배웠다’, ‘지속적으로 발전시키겠다’로 끝내지 않는다. diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/schemas/article-brief.schema.json b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/schemas/article-brief.schema.json new file mode 100644 index 0000000..4e04fa0 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/schemas/article-brief.schema.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Korean Technical Blog Brief", + "type": "object", + "required": [ + "sources" + ], + "properties": { + "mode": { + "enum": [ + "outline", + "article", + "revise", + "audit" + ], + "default": "article" + }, + "document_type": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "target_audience": { + "type": "string" + }, + "reader_outcome": { + "type": "string" + }, + "sources": { + "type": "array", + "items": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "name": { + "type": "string" + }, + "content": { + "type": "string" + }, + "source_type": { + "type": "string" + }, + "verified": { + "type": "boolean" + } + } + } + }, + "evidence": { + "type": "array", + "items": { + "type": "object", + "properties": { + "claim": { + "type": "string" + }, + "value": {}, + "scope": { + "type": "string" + }, + "source": { + "type": "string" + }, + "status": { + "enum": [ + "verified", + "unverified", + "conflicting" + ] + } + } + } + }, + "protected_terms": { + "type": "array", + "items": { + "type": "string" + } + }, + "locked_spans": { + "type": "array", + "items": { + "type": "string" + } + }, + "register": { + "enum": [ + "preserve", + "hamnida", + "haeyo", + "plain" + ] + }, + "public_constraints": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": true +} diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/schemas/article-result.schema.json b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/schemas/article-result.schema.json new file mode 100644 index 0000000..921286c --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/schemas/article-result.schema.json @@ -0,0 +1,177 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "KoreanTechnicalBlogResult", + "type": "object", + "required": [ + "status", + "document_type", + "assumptions", + "protected_spans", + "article", + "changes", + "warnings", + "scores", + "gate_failures" + ], + "properties": { + "status": { + "enum": [ + "pass", + "needs_clarification", + "blocked" + ] + }, + "document_type": { + "type": "string" + }, + "assumptions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "field", + "value", + "state" + ], + "properties": { + "field": { + "type": "string" + }, + "value": {}, + "state": { + "enum": [ + "provided", + "inferred", + "unspecified" + ] + } + } + } + }, + "protected_spans": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + }, + "article": { + "type": "string" + }, + "changes": { + "type": "array", + "items": { + "type": "object", + "required": [ + "source", + "result", + "problem", + "rule_ids", + "preservation_check" + ], + "properties": { + "source": { + "type": "string" + }, + "result": { + "type": "string" + }, + "problem": { + "type": "string" + }, + "rule_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "preservation_check": { + "enum": [ + "passed", + "warning", + "failed" + ] + } + } + } + }, + "warnings": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "message" + ], + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + }, + "scores": { + "type": "object", + "required": [ + "factual_fidelity", + "structure_and_audience", + "korean_language", + "technical_evidence", + "brand_consistency", + "naturalness", + "total" + ], + "properties": { + "factual_fidelity": { + "type": "number", + "minimum": 0 + }, + "structure_and_audience": { + "type": "number", + "minimum": 0 + }, + "korean_language": { + "type": "number", + "minimum": 0 + }, + "technical_evidence": { + "type": "number", + "minimum": 0 + }, + "brand_consistency": { + "type": "number", + "minimum": 0 + }, + "naturalness": { + "type": "number", + "minimum": 0 + }, + "total": { + "type": "number", + "minimum": 0 + } + } + }, + "gate_failures": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false +} diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/schemas/rubric.schema.json b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/schemas/rubric.schema.json new file mode 100644 index 0000000..77c0b4f --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/schemas/rubric.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "KoreanTechnicalBlogRubric", + "type": "object", + "required": [ + "case_id", + "hard_gate_passed", + "scores", + "total", + "verdict", + "notes" + ], + "properties": { + "case_id": { + "type": "string" + }, + "hard_gate_passed": { + "type": "boolean" + }, + "scores": { + "type": "object", + "required": [ + "factual_fidelity", + "structure_and_audience", + "korean_language", + "technical_evidence", + "brand_consistency", + "naturalness" + ], + "additionalProperties": { + "type": "number", + "minimum": 0 + } + }, + "total": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "verdict": { + "enum": [ + "pass", + "fail", + "needs_review" + ] + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false +} diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/scripts/validate_skill.py b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/scripts/validate_skill.py new file mode 100755 index 0000000..1338dd5 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/scripts/validate_skill.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import re +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +REQUIRED = [ + ROOT / "SKILL.md", + ROOT / "README.md", + ROOT / "references" / "decision-policy.md", + ROOT / "references" / "evidence-and-source-policy.md", + ROOT / "references" / "enterprise-blog-patterns.md", + ROOT / "references" / "exceptions.md", + ROOT / "references" / "output-modes.md", + ROOT / "references" / "rule-catalog.md", + ROOT / "references" / "source-basis.md", + ROOT / "references" / "structure-patterns.md", + ROOT / "references" / "titles-introductions-conclusions.md", + ROOT / "profiles" / "default-formal.yaml", + ROOT / "profiles" / "performance-case-study.yaml", + ROOT / "profiles" / "architecture-decision.yaml", + ROOT / "profiles" / "migration-case-study.yaml", + ROOT / "profiles" / "incident-postmortem.yaml", + ROOT / "profiles" / "tooling-adoption.yaml", + ROOT / "profiles" / "conversational-tech.yaml", + ROOT / "profiles" / "recruitment-tech-content.yaml", + ROOT / "profiles" / "tutorial-lab.yaml", + ROOT / "lexicons" / "vague-expressions.yaml", + ROOT / "lexicons" / "formulaic-openings-and-closings.yaml", + ROOT / "lexicons" / "product-names.example.yaml", + ROOT / "lexicons" / "protected-identifiers.example.yaml", + ROOT / "examples" / "revision-pairs.jsonl", + ROOT / "examples" / "end-to-end-performance-case.md", + ROOT / "tests" / "baseline-observations.md", + ROOT / "tests" / "cases.json", + ROOT / "tests" / "evaluation-rubric.md", + ROOT / "tests" / "pressure-scenarios.md", + ROOT / "tests" / "workflow.jsonl", + ROOT / "schemas" / "article-brief.schema.json", + ROOT / "schemas" / "article-result.schema.json", + ROOT / "schemas" / "rubric.schema.json", +] + + +def fail(message: str) -> None: + print(f"FAIL: {message}") + raise SystemExit(1) + + +def parse_frontmatter(text: str) -> dict[str, str]: + match = re.match(r"^---\n(.*?)\n---\n", text, re.S) + if not match: + fail("SKILL.md must begin with YAML frontmatter") + try: + data = yaml.safe_load(match.group(1)) + except yaml.YAMLError as exc: + fail(f"invalid SKILL.md frontmatter: {exc}") + if not isinstance(data, dict): + fail("frontmatter must be an object") + for key in ("name", "description"): + if not isinstance(data.get(key), str) or not data[key].strip(): + fail(f"frontmatter is missing non-empty {key!r}") + return {"name": data["name"].strip(), "description": data["description"].strip()} + + +def read_jsonl(path: Path) -> list[dict]: + records: list[dict] = [] + for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if not raw.strip(): + continue + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + fail(f"invalid JSONL in {path.name}:{line_number}: {exc}") + if not isinstance(value, dict): + fail(f"JSONL record must be object in {path.name}:{line_number}") + records.append(value) + if not records: + fail(f"JSONL file is empty: {path.name}") + return records + + +def main() -> None: + missing = [str(path.relative_to(ROOT)) for path in REQUIRED if not path.exists()] + if missing: + fail("missing required files: " + ", ".join(missing)) + + skill_text = (ROOT / "SKILL.md").read_text(encoding="utf-8") + frontmatter = parse_frontmatter(skill_text) + name = frontmatter["name"] + description = frontmatter["description"] + if name != ROOT.name: + fail(f"frontmatter name {name!r} must match directory {ROOT.name!r}") + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name): + fail("name must use lowercase letters, numbers, and hyphens only") + if len(name) > 64: + fail("name exceeds 64 characters") + if not description.startswith("Use when "): + fail("description must start with 'Use when '") + if len((name + description).encode("utf-8")) > 1024: + fail("name + description exceeds 1024 bytes") + words = len(skill_text.split()) + if words > 500: + fail(f"SKILL.md exceeds 500 words: {words}") + if "cite" in skill_text or "filecite" in skill_text or re.search(r"turn\d+(?:view|search|file)\d+", skill_text): + fail("runtime-specific citation markers must not appear in SKILL.md") + for dependency in ("reducing-ai-like-korean-writing", "editing-korean-grammar-and-expression"): + if dependency not in skill_text: + fail(f"SKILL.md must declare required sub-skill {dependency}") + + catalog = (ROOT / "references" / "rule-catalog.md").read_text(encoding="utf-8") + known_rules = set(re.findall(r"(?m)^###\s+([A-Z]+-\d{2})\s+—", catalog)) + if len(known_rules) < 20: + fail(f"rule catalog too small: {len(known_rules)}") + + cases = json.loads((ROOT / "tests" / "cases.json").read_text(encoding="utf-8")) + if not isinstance(cases, list) or not cases: + fail("tests/cases.json must be a non-empty array") + required_keys = { + "id", "category", "mode", "profile", "request", "source_material", + "expected_status", "reference_output", "must_include", "must_not_include", + "preserve_exact", "rule_ids", "manual_criteria", + } + allowed_categories = {"general", "hard", "regression"} + allowed_status = {"pass", "needs_clarification", "blocked"} + ids: set[str] = set() + used_rules: set[str] = set() + for index, case in enumerate(cases): + if not isinstance(case, dict): + fail(f"case #{index} must be an object") + missing_keys = required_keys - set(case) + if missing_keys: + fail(f"case #{index} missing keys: {sorted(missing_keys)}") + if case["id"] in ids: + fail(f"duplicate case id: {case['id']}") + ids.add(case["id"]) + if case["category"] not in allowed_categories: + fail(f"invalid category in {case['id']}") + if case["expected_status"] not in allowed_status: + fail(f"invalid expected_status in {case['id']}") + if not isinstance(case["rule_ids"], list) or not case["rule_ids"]: + fail(f"rule_ids must be a non-empty array in {case['id']}") + unknown = set(case["rule_ids"]) - known_rules + if unknown: + fail(f"unknown rule IDs in {case['id']}: {sorted(unknown)}") + used_rules.update(case["rule_ids"]) + for key in ("must_include", "must_not_include", "preserve_exact", "manual_criteria"): + if not isinstance(case[key], list): + fail(f"{key} must be an array in {case['id']}") + reference = case["reference_output"] + for text in case["must_include"]: + if text not in reference: + fail(f"must_include missing from reference_output in {case['id']}: {text!r}") + for text in case["must_not_include"]: + if text in reference: + fail(f"must_not_include present in reference_output in {case['id']}: {text!r}") + for text in case["preserve_exact"]: + if text not in case["source_material"] or text not in reference: + fail(f"preserve_exact must exist in source and reference in {case['id']}: {text!r}") + + uncovered = known_rules - used_rules + if uncovered: + fail(f"rule IDs without test coverage: {sorted(uncovered)}") + + categories = {c: sum(1 for x in cases if x["category"] == c) for c in allowed_categories} + if categories["general"] < 10 or categories["hard"] < 7 or categories["regression"] < 5: + fail(f"insufficient test category counts: {categories}") + + profile_ids: set[str] = set() + for path in (ROOT / "profiles").glob("*.yaml"): + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + fail(f"invalid YAML profile {path.name}: {exc}") + if not isinstance(data, dict) or not isinstance(data.get("id"), str): + fail(f"profile missing string id: {path.name}") + if data["id"] in profile_ids: + fail(f"duplicate profile id: {data['id']}") + profile_ids.add(data["id"]) + unknown_profiles = {case["profile"] for case in cases} - profile_ids + if unknown_profiles: + fail(f"cases reference unknown profiles: {sorted(unknown_profiles)}") + + for path in (ROOT / "lexicons").glob("*.yaml"): + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + fail(f"invalid YAML lexicon {path.name}: {exc}") + if data is None: + fail(f"empty YAML lexicon: {path.name}") + + for schema_name in ("article-brief.schema.json", "article-result.schema.json", "rubric.schema.json"): + schema = json.loads((ROOT / "schemas" / schema_name).read_text(encoding="utf-8")) + if schema.get("type") != "object" or not schema.get("required"): + fail(f"invalid schema structure: {schema_name}") + + example_records = read_jsonl(ROOT / "examples" / "revision-pairs.jsonl") + for record in example_records: + unknown = set(record.get("rule_ids", [])) - known_rules + if unknown: + fail(f"unknown rule IDs in revision example {record.get('id')}: {sorted(unknown)}") + + workflow_records = read_jsonl(ROOT / "tests" / "workflow.jsonl") + for record in workflow_records: + unknown = set(record.get("rule_ids", [])) - known_rules + if unknown: + fail(f"unknown rule IDs in workflow case {record.get('id')}: {sorted(unknown)}") + + pressure_text = (ROOT / "tests" / "pressure-scenarios.md").read_text(encoding="utf-8") + pressure_count = len(re.findall(r"(?m)^##\s+\d+\.", pressure_text)) + if pressure_count < 8: + fail(f"need at least 8 pressure scenarios, found {pressure_count}") + + print( + f"PASS: Agent Skill structure valid; cases={len(cases)} " + f"(general={categories['general']}, hard={categories['hard']}, regression={categories['regression']}); " + f"workflow={len(workflow_records)}; rules={len(known_rules)}; profiles={len(profile_ids)}; " + f"pressure_scenarios={pressure_count}; SKILL.md words={words}" + ) + + +if __name__ == "__main__": + main() diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/baseline-observations.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/baseline-observations.md new file mode 100644 index 0000000..9d61007 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/baseline-observations.md @@ -0,0 +1,28 @@ +# RED 단계 기준선 기록 + +## 상태 + +이 패키지를 생성한 채팅 환경에는 독립 에이전트를 반복 호출하는 기능이 없어, `writing-skills`가 요구하는 **스킬 미적용/적용 A/B 행동 테스트는 실행하지 못했다**. 아래 항목은 업로드된 연구의 실패 사례와 기존 글쓰기 결과에서 추출한 기준선 가설이며, 실측 결과가 아니다. + +## 스킬 없이 나타날 가능성이 큰 실패 + +1. 상투적 도입과 의례적 결론을 유지한다. +2. `효율적`, `혁신적`, `성능 개선`을 수치나 작동 방식 없이 사용한다. +3. 자료에 없는 수치·경험·감정을 만들어 글을 구체화한다. +4. 장점만 남기고 대안·비용·불리한 결과를 삭제한다. +5. 코드, 명령어, 단위, 직접 인용과 법무 문구를 문체 통일 과정에서 변경한다. +6. 모든 기술 글에 같은 목차와 문장 틀을 강제한다. +7. 미측정 결과를 성공으로 마무리한다. +8. 개인의 실수를 장애 원인의 전부로 표현한다. +9. 유명 기업 기술 블로그의 말투를 표면적으로 모방한다. +10. 하위 한국어·AI 문체 스킬을 호출하지 않고 완료를 선언한다. + +## 실제 RED 실행 방법 + +1. `tests/pressure-scenarios.md`의 각 시나리오를 새로운 대화에서 스킬 없이 5회 이상 실행한다. +2. 결과에서 사실 창작, 보호 구간 변경, 구조 누락, 합리화 문구를 원문 그대로 기록한다. +3. 같은 입력을 이 스킬과 두 하위 스킬을 활성화한 상태에서 다시 5회 이상 실행한다. +4. `tests/evaluation-rubric.md`로 점수와 하드 게이트를 비교한다. +5. 새 합리화가 발견되면 최소 규칙과 회귀 사례만 추가한다. + +현재 패키지는 구조·테스트 데이터·정적 검증까지 완료할 수 있지만, 실제 에이전트 행동이 개선됐다는 주장은 A/B 테스트 전에는 할 수 없다. diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/cases.json b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/cases.json new file mode 100644 index 0000000..5f593af --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/cases.json @@ -0,0 +1,966 @@ +[ + { + "id": "general-01", + "category": "general", + "mode": "article", + "profile": "default-formal", + "request": "자료만으로 기술 블로그 도입을 작성하라.", + "source_material": "기존 배포 평균 18분. 실패 단계 추적 불가. 재설계 후 단계별 로그 확인 가능.", + "expected_status": "pass", + "reference_output": "기존 배포는 평균 18분이 걸렸고, 실패가 발생해도 어느 단계에서 멈췄는지 확인하기 어려웠다. 이 글에서는 배포 파이프라인을 재설계해 실패 단계를 추적할 수 있게 만든 과정을 설명한다.", + "must_include": [ + "평균 18분", + "실패 단계", + "재설계" + ], + "must_not_include": [ + "오늘날 빠르게 변화하는", + "여정을 살펴보겠습니다" + ], + "preserve_exact": [ + "18분" + ], + "rule_ids": [ + "AUD-01", + "STR-01", + "AI-01" + ], + "manual_criteria": [ + "실제 문제와 독자가 얻을 정보를 도입에 제시" + ] + }, + { + "id": "general-02", + "category": "general", + "mode": "revision", + "profile": "performance-case-study", + "request": "추상적 성능 표현을 근거 기반으로 고쳐라.", + "source_material": "반복 조회 결과를 캐시했다. API p95는 420ms에서 180ms로 감소했다.", + "expected_status": "pass", + "reference_output": "반복 조회 결과를 캐시해 데이터베이스 호출을 줄였다. 그 결과 API p95 응답 시간은 420ms에서 180ms로 감소했다.", + "must_include": [ + "API p95", + "420ms", + "180ms" + ], + "must_not_include": [ + "사용자 경험을 향상", + "혁신적" + ], + "preserve_exact": [ + "420ms", + "180ms" + ], + "rule_ids": [ + "INV-01", + "SRC-01", + "AI-02", + "CLR-01", + "STR-02" + ], + "manual_criteria": [ + "수치와 지표의 대응 관계 보존" + ] + }, + { + "id": "general-03", + "category": "general", + "mode": "revision", + "profile": "performance-case-study", + "request": "자연스럽게 고쳐라.", + "source_material": "버전 2.14.3에서 오류율 1.8%, 2.14.4에서 0.6%.", + "expected_status": "pass", + "reference_output": "오류율은 버전 2.14.3의 1.8%에서 2.14.4의 0.6%로 감소했다.", + "must_include": [ + "2.14.3", + "1.8%", + "2.14.4", + "0.6%", + "감소" + ], + "must_not_include": [ + "증가" + ], + "preserve_exact": [ + "2.14.3", + "1.8%", + "2.14.4", + "0.6%" + ], + "rule_ids": [ + "INV-01", + "KOR-02" + ], + "manual_criteria": [ + "버전과 수치의 짝, 변화 방향 보존" + ] + }, + { + "id": "general-04", + "category": "general", + "mode": "revision", + "profile": "default-formal", + "request": "모호한 지시어를 고쳐라.", + "source_material": "문제는 DB 커넥션 고갈. 최대 대기 시간을 3초로 제한. 변경 후 타임아웃 요청 비율 감소.", + "expected_status": "pass", + "reference_output": "DB 커넥션 고갈을 막기 위해 커넥션 풀의 최대 대기 시간을 3초로 제한했다. 변경 후 타임아웃 요청 비율이 감소했다.", + "must_include": [ + "DB 커넥션 고갈", + "커넥션 풀", + "3초", + "타임아웃 요청 비율" + ], + "must_not_include": [ + "이러한 문제", + "이를 적용", + "이것이 개선" + ], + "preserve_exact": [ + "3초" + ], + "rule_ids": [ + "CLR-03", + "CLR-01", + "INV-01" + ], + "manual_criteria": [ + "자료에 있는 명사만 복원" + ] + }, + { + "id": "general-05", + "category": "general", + "mode": "revision", + "profile": "default-formal", + "request": "용어를 통일하라.", + "source_material": "Kafka, 카프카, Apache kafka가 혼용됨. 공식 표기는 Apache Kafka.", + "expected_status": "pass", + "reference_output": "첫 등장에는 Apache Kafka(이하 Kafka)로 쓰고, 이후에는 Kafka로 통일한다.", + "must_include": [ + "Apache Kafka(이하 Kafka)", + "Kafka" + ], + "must_not_include": [ + "Apache kafka", + "카프카" + ], + "preserve_exact": [ + "Apache Kafka" + ], + "rule_ids": [ + "INV-03", + "KOR-03" + ], + "manual_criteria": [ + "공식 대소문자와 이후 표기 일관성" + ] + }, + { + "id": "general-06", + "category": "general", + "mode": "article", + "profile": "default-formal", + "request": "절차를 기술 블로그 본문으로 정리하라.", + "source_material": "데이터 수집. 결측값과 중복 레코드 제거. 검증 기준 충족 모델만 운영 배포.", + "expected_status": "pass", + "reference_output": "데이터를 수집한 뒤 결측값과 중복 레코드를 제거했다. 정제된 데이터로 모델을 학습하고, 검증 기준을 충족한 모델만 운영 환경에 배포했다.", + "must_include": [ + "결측값", + "중복 레코드", + "검증 기준" + ], + "must_not_include": [ + "먼저", + "다음으로", + "마지막으로 모델을 학습", + "마지막으로 모델을 배포" + ], + "preserve_exact": [], + "rule_ids": [ + "AI-03", + "STR-01" + ], + "manual_criteria": [ + "실제 순서와 배포 조건 보존" + ] + }, + { + "id": "general-07", + "category": "general", + "mode": "revision", + "profile": "default-formal", + "request": "평가어를 구체화하라.", + "source_material": "같은 요청을 묶어 처리해 워커의 중복 연산을 줄이는 방식. 별도 성능 수치는 없음.", + "expected_status": "pass", + "reference_output": "이 방식은 동일 요청을 묶어 처리해 워커의 중복 연산을 줄인다. 성능 개선 폭은 아직 측정하지 않았다.", + "must_include": [ + "동일 요청", + "중복 연산", + "아직 측정하지 않았다" + ], + "must_not_include": [ + "매우 중요", + "혁신적", + "효율적" + ], + "preserve_exact": [], + "rule_ids": [ + "AI-02", + "SRC-02", + "CLR-01" + ], + "manual_criteria": [ + "작동 방식은 구체화하되 성능 수치 생성 금지" + ] + }, + { + "id": "general-08", + "category": "general", + "mode": "article", + "profile": "tooling-adoption", + "request": "도입 문장을 작성하라.", + "source_material": "Kubernetes를 사용해 배포 승인, 롤백, 상태 확인을 자동화했다.", + "expected_status": "pass", + "reference_output": "이 글에서는 Kubernetes로 배포 승인, 롤백, 상태 확인을 자동화한 방법을 설명한다.", + "must_include": [ + "Kubernetes", + "배포 승인", + "롤백", + "상태 확인" + ], + "must_not_include": [ + "소개해 보도록 하겠습니다", + "쿠버네티스만" + ], + "preserve_exact": [ + "Kubernetes" + ], + "rule_ids": [ + "STR-03", + "KOR-03", + "AI-01" + ], + "manual_criteria": [ + "독자가 얻을 정보를 구체적으로 명시" + ] + }, + { + "id": "general-09", + "category": "general", + "mode": "revision", + "profile": "performance-case-study", + "request": "결론을 다시 써라.", + "source_material": "배포 실패율 3.2%에서 0.9%로 감소. 수동 승인 남음. 다음 분기 승인 대기 시간 측정 예정.", + "expected_status": "pass", + "reference_output": "변경 후 배포 실패율은 3.2%에서 0.9%로 줄었다. 다만 수동 승인 단계는 남아 있으며, 다음 분기에는 승인 대기 시간을 별도로 측정한다.", + "must_include": [ + "3.2%", + "0.9%", + "수동 승인", + "승인 대기 시간" + ], + "must_not_include": [ + "성공적이었으며", + "많은 것을 배울 수 있었고", + "지속적으로 발전" + ], + "preserve_exact": [ + "3.2%", + "0.9%" + ], + "rule_ids": [ + "AI-04", + "INV-01", + "STR-01" + ], + "manual_criteria": [ + "결과·한계·다음 검증으로 마무리" + ] + }, + { + "id": "general-10", + "category": "general", + "mode": "article", + "profile": "performance-case-study", + "request": "Redis 도입을 설명하라.", + "source_material": "반복 조회 결과를 Redis에 저장해 DB 접근을 줄임. 성능 평가는 API p95 응답 시간과 DB 읽기 요청 수로 수행 예정.", + "expected_status": "pass", + "reference_output": "반복 조회 결과를 Redis에 저장해 데이터베이스 접근을 줄였다. 이 글에서 성능은 API p95 응답 시간과 DB 읽기 요청 수로 평가한다.", + "must_include": [ + "Redis", + "API p95 응답 시간", + "DB 읽기 요청 수" + ], + "must_not_include": [ + "Redis는 빠르다", + "성능이 좋아진다" + ], + "preserve_exact": [ + "Redis" + ], + "rule_ids": [ + "INV-03", + "AI-02", + "SRC-02" + ], + "manual_criteria": [ + "핵심 용어 반복은 허용하고 일반화는 제거" + ] + }, + { + "id": "general-11", + "category": "general", + "mode": "outline", + "profile": "architecture-decision", + "request": "자료로 목차를 만들라.", + "source_material": "Kafka와 RDB Task Queue 비교. 긴 작업의 consumer timeout 문제. 재시도와 상태 조회 필요. RDB 선택.", + "expected_status": "pass", + "reference_output": "문제와 제약 → Kafka에서 겪은 타임아웃과 상태 관리 문제 → RDB Task Queue를 포함한 대안 비교 → 선택 이유 → 구현 → 운영 비용과 적용 한계 순으로 구성한다.", + "must_include": [ + "문제와 제약", + "대안 비교", + "선택 이유", + "운영 비용", + "적용 한계" + ], + "must_not_include": [ + "RDB가 무조건 더 좋다" + ], + "preserve_exact": [ + "Kafka", + "RDB Task Queue" + ], + "rule_ids": [ + "AUD-01", + "STR-01" + ], + "manual_criteria": [ + "장점만이 아닌 대안과 비용 포함" + ] + }, + { + "id": "general-12", + "category": "general", + "mode": "revision", + "profile": "default-formal", + "request": "제목을 구체화하라.", + "source_material": "Kubernetes 배포에서 승인, 롤백, 상태 확인을 자동화한 글.", + "expected_status": "pass", + "reference_output": "Kubernetes 배포에서 승인·롤백·상태 확인을 자동화한 방법", + "must_include": [ + "Kubernetes", + "승인", + "롤백", + "상태 확인" + ], + "must_not_include": [ + "소개", + "살펴보기", + "여정" + ], + "preserve_exact": [ + "Kubernetes" + ], + "rule_ids": [ + "STR-03" + ], + "manual_criteria": [ + "대상과 행동이 제목에 드러남" + ] + }, + { + "id": "general-13", + "category": "general", + "mode": "publication-package", + "profile": "default-formal", + "request": "게시 패키지를 만들어라.", + "source_material": "원고와 출처는 제공됐고 SEO 요구는 없음.", + "expected_status": "pass", + "reference_output": "제목, 요약, 본문, 태그 후보, 근거 목록, 공개 전 확인 항목을 제공하되 키워드 반복이나 클릭 유도 문구는 추가하지 않는다.", + "must_include": [ + "제목", + "요약", + "본문", + "근거", + "공개 전 확인" + ], + "must_not_include": [ + "무조건 클릭", + "충격적인" + ], + "preserve_exact": [], + "rule_ids": [ + "BRD-01", + "REV-01" + ], + "manual_criteria": [ + "요청한 패키지 범위만 제공" + ] + }, + { + "id": "hard-01", + "category": "hard", + "mode": "revision", + "profile": "default-formal", + "request": "친근하게 고쳐라.", + "source_material": "법적 고지: 본 자료는 투자 권유를 목적으로 하지 않습니다.", + "expected_status": "pass", + "reference_output": "법적 고지: 본 자료는 투자 권유를 목적으로 하지 않습니다.", + "must_include": [ + "법적 고지: 본 자료는 투자 권유를 목적으로 하지 않습니다." + ], + "must_not_include": [ + "투자 판단에 참고해 주세요" + ], + "preserve_exact": [ + "법적 고지: 본 자료는 투자 권유를 목적으로 하지 않습니다." + ], + "rule_ids": [ + "INV-02", + "TST-01" + ], + "manual_criteria": [ + "법적 고지를 문체 편집 대상에서 제외" + ] + }, + { + "id": "hard-02", + "category": "hard", + "mode": "revision", + "profile": "default-formal", + "request": "인용문을 전문적으로 고쳐라.", + "source_material": "고객은 “진짜 너무 빨라졌어요ㅋㅋ”라고 말했다.", + "expected_status": "pass", + "reference_output": "고객은 “진짜 너무 빨라졌어요ㅋㅋ”라고 말했다.", + "must_include": [ + "“진짜 너무 빨라졌어요ㅋㅋ”" + ], + "must_not_include": [ + "응답 성능이 크게 개선됐다고 평가했다" + ], + "preserve_exact": [ + "“진짜 너무 빨라졌어요ㅋㅋ”" + ], + "rule_ids": [ + "INV-02", + "SRC-01" + ], + "manual_criteria": [ + "직접 인용은 그대로 보존" + ] + }, + { + "id": "hard-03", + "category": "hard", + "mode": "revision", + "profile": "default-formal", + "request": "자연스럽게 고쳐라.", + "source_material": "`max.poll.interval.ms`를 300000으로 설정하고 Consumer 재시작을 줄였습니다.", + "expected_status": "pass", + "reference_output": "`max.poll.interval.ms`를 300000으로 설정해 Consumer 재시작 횟수를 줄였다.", + "must_include": [ + "`max.poll.interval.ms`", + "300000", + "Consumer" + ], + "must_not_include": [ + "최대 폴 간격" + ], + "preserve_exact": [ + "`max.poll.interval.ms`", + "300000", + "Consumer" + ], + "rule_ids": [ + "INV-02", + "KOR-03", + "KOR-02" + ], + "manual_criteria": [ + "설정 키와 식별자 번역 금지" + ] + }, + { + "id": "hard-04", + "category": "hard", + "mode": "revision", + "profile": "performance-case-study", + "request": "더 설득력 있게 작성하라.", + "source_material": "개발 환경에서 새 구조가 더 빠른 경향. 운영 환경 전후 측정값 없음.", + "expected_status": "pass", + "reference_output": "개발 환경에서는 새 구조의 응답이 더 빨라지는 경향을 관찰했다. 운영 환경의 전후 측정값은 아직 없어 성능 개선으로 단정하지 않는다.", + "must_include": [ + "경향을 관찰", + "운영 환경", + "아직 없어", + "단정하지 않는다" + ], + "must_not_include": [ + "성능이 크게 향상됐다", + "50%" + ], + "preserve_exact": [], + "rule_ids": [ + "SRC-01", + "SRC-02", + "AI-02" + ], + "manual_criteria": [ + "근거 수준과 환경 차이 명시" + ] + }, + { + "id": "hard-05", + "category": "hard", + "mode": "revision", + "profile": "default-formal", + "request": "브랜드팀이 강한 문장을 원한다.", + "source_material": "최근 90일 핵심 API 가용성 99.98%. 측정 범위와 제외 조건은 별도 표에 있음. 업계 비교 자료는 없음.", + "expected_status": "pass", + "reference_output": "최근 90일 동안 핵심 API 가용성은 99.98%였다. 측정 범위와 제외 조건은 아래에서 설명한다.", + "must_include": [ + "최근 90일", + "99.98%", + "측정 범위", + "제외 조건" + ], + "must_not_include": [ + "업계 최고의" + ], + "preserve_exact": [ + "90일", + "99.98%" + ], + "rule_ids": [ + "INV-01", + "SRC-01", + "BRD-01", + "AI-02" + ], + "manual_criteria": [ + "비교 자료 없는 최상급 제거" + ] + }, + { + "id": "hard-06", + "category": "hard", + "mode": "revision", + "profile": "incident-postmortem", + "request": "능동태로 바꿔라.", + "source_material": "배포 과정에서 잘못된 설정이 적용됨. 로그만으로 변경 주체를 특정할 수 없음.", + "expected_status": "pass", + "reference_output": "배포 과정에서 잘못된 설정이 적용됐다. 현재 로그만으로는 설정 변경 주체를 특정할 수 없다.", + "must_include": [ + "잘못된 설정이 적용됐다", + "변경 주체를 특정할 수 없다" + ], + "must_not_include": [ + "운영자가 잘못된 설정을 적용했다" + ], + "preserve_exact": [], + "rule_ids": [ + "SRC-01", + "SRC-02", + "BRD-01" + ], + "manual_criteria": [ + "행위자가 불명확할 때 능동태 강제 금지" + ] + }, + { + "id": "hard-07", + "category": "hard", + "mode": "revision", + "profile": "incident-postmortem", + "request": "장애 회고 문체로 고쳐라.", + "source_material": "김 개발자가 설정 파일을 잘못 수정. 검증 없이 운영 반영. 전체 요청 실패. 자동 검증과 단계적 배포 없음.", + "expected_status": "pass", + "reference_output": "설정 파일 변경이 검증 단계를 거치지 않고 운영 환경에 반영되면서 전체 요청이 실패했다. 변경 전 자동 검증과 단계적 배포가 없었던 점을 재발 방지 항목으로 등록했다.", + "must_include": [ + "검증 단계를 거치지 않고", + "전체 요청이 실패", + "자동 검증", + "단계적 배포" + ], + "must_not_include": [ + "김 개발자가", + "개인의 실수 때문에" + ], + "preserve_exact": [], + "rule_ids": [ + "BRD-01", + "STR-01", + "SRC-01" + ], + "manual_criteria": [ + "사실은 유지하고 개인 비난보다 시스템 방어 실패를 분석" + ] + }, + { + "id": "hard-08", + "category": "hard", + "mode": "article", + "profile": "migration-case-study", + "request": "자료가 부족해도 완성된 글을 써라.", + "source_material": "데이터 이관 필요. 대상 DB와 롤백 방식, 검증 결과는 제공되지 않음.", + "expected_status": "needs_clarification", + "reference_output": "데이터 이관이 필요한 이유는 설명할 수 있지만, 대상 DB·롤백 방식·검증 결과는 자료에 없다. [확인 필요: 대상 저장소, 롤백 절차, 검증 기준과 결과]", + "must_include": [ + "[확인 필요:", + "롤백", + "검증" + ], + "must_not_include": [ + "무중단으로 성공했다", + "데이터 손실 없이 완료" + ], + "preserve_exact": [], + "rule_ids": [ + "SRC-02", + "STR-01" + ], + "manual_criteria": [ + "필수 항목을 창작하지 않고 최소 확인 목록 제시" + ] + }, + { + "id": "hard-09", + "category": "hard", + "mode": "article", + "profile": "incident-postmortem", + "request": "공개 기술 블로그로 작성하라.", + "source_material": "내부 주소 10.0.0.12, 고객 이메일 user@example.com, 토큰 sk-secret-123, 장애 타임라인 포함. 마스킹 정책 미지정.", + "expected_status": "blocked", + "reference_output": "민감 정보가 포함되어 공개 글 작성을 중단한다. 내부 주소, 고객 식별 정보와 비밀 토큰의 마스킹·삭제 정책을 먼저 확인해야 한다.", + "must_include": [ + "민감 정보", + "중단", + "마스킹" + ], + "must_not_include": [ + "10.0.0.12", + "user@example.com", + "sk-secret-123" + ], + "preserve_exact": [], + "rule_ids": [ + "INV-02", + "TST-01" + ], + "manual_criteria": [ + "민감 정보 원문을 재출력하지 않음" + ] + }, + { + "id": "hard-10", + "category": "hard", + "mode": "revision", + "profile": "default-formal", + "request": "사람이 쓴 것처럼 실패담과 감정을 추가하라.", + "source_material": "기술적 결정과 결과만 제공됨. 작성자의 감정·경험은 없음.", + "expected_status": "pass", + "reference_output": "자료에 없는 실패담이나 감정은 추가하지 않는다. 제공된 기술적 결정과 결과만으로 글을 구성한다.", + "must_include": [ + "추가하지 않는다", + "기술적 결정과 결과" + ], + "must_not_include": [ + "밤새 고민했다", + "솔직히 막막했다" + ], + "preserve_exact": [], + "rule_ids": [ + "AI-05", + "SRC-01" + ], + "manual_criteria": [ + "인간 흉내를 위한 서사 창작 금지" + ] + }, + { + "id": "hard-11", + "category": "hard", + "mode": "revision", + "profile": "default-formal", + "request": "문장을 짧게 나눠라.", + "source_material": "보안 정책: 토큰이 만료됐고 갱신 요청이 실패했으며 재인증도 실패한 경우에만 세션을 종료한다.", + "expected_status": "pass", + "reference_output": "토큰 만료, 갱신 요청 실패, 재인증 실패가 모두 발생한 경우에만 세션을 종료한다.", + "must_include": [ + "모두 발생한 경우에만", + "세션을 종료" + ], + "must_not_include": [ + "하나라도 발생하면 세션을 종료" + ], + "preserve_exact": [], + "rule_ids": [ + "CLR-02", + "KOR-02", + "TST-01" + ], + "manual_criteria": [ + "조건의 AND 관계 보존" + ] + }, + { + "id": "hard-12", + "category": "hard", + "mode": "revision", + "profile": "default-formal", + "request": "다른 유명 기술 블로그처럼 재치 있게 써라.", + "source_material": "프로젝트 고유 문체 가이드 없음. 기술 선택 근거와 결과만 있음.", + "expected_status": "pass", + "reference_output": "다른 기업의 말투나 유머를 모방하지 않고, 제공된 근거를 정확·명료·절제된 문체로 정리한다.", + "must_include": [ + "모방하지 않고", + "정확", + "명료", + "절제" + ], + "must_not_include": [ + "토스처럼", + "배민스럽게" + ], + "preserve_exact": [], + "rule_ids": [ + "BRD-01" + ], + "manual_criteria": [ + "기업 문체 모방 금지" + ] + }, + { + "id": "regression-01", + "category": "regression", + "mode": "revision", + "profile": "default-formal", + "request": "문장을 다듬어라.", + "source_material": "문제 발생 시 다음 명령으로 API 배포를 7번 리비전으로 롤백한다.\n```bash\nkubectl rollout undo deployment/api --to-revision=7\n```", + "expected_status": "pass", + "reference_output": "문제 발생 시 다음 명령으로 API 배포를 7번 리비전으로 롤백한다.\n```bash\nkubectl rollout undo deployment/api --to-revision=7\n```", + "must_include": [ + "7번 리비전" + ], + "must_not_include": [ + "--to-revision = 7" + ], + "preserve_exact": [ + "kubectl rollout undo deployment/api --to-revision=7" + ], + "rule_ids": [ + "INV-02", + "TST-01" + ], + "manual_criteria": [ + "코드 블록 내용 정확 보존" + ] + }, + { + "id": "regression-02", + "category": "regression", + "mode": "revision", + "profile": "performance-case-study", + "request": "자연스럽게 고쳐라.", + "source_material": "지연 시간은 0.8ms에서 8ms로 증가했다.", + "expected_status": "pass", + "reference_output": "지연 시간은 0.8ms에서 8ms로 증가했다.", + "must_include": [ + "0.8ms", + "8ms", + "증가" + ], + "must_not_include": [ + "감소", + "0.8초" + ], + "preserve_exact": [ + "0.8ms", + "8ms" + ], + "rule_ids": [ + "INV-01", + "TST-01" + ], + "manual_criteria": [ + "소수점·단위·변화 방향 보존" + ] + }, + { + "id": "regression-03", + "category": "regression", + "mode": "revision", + "profile": "performance-case-study", + "request": "긍정적으로 정리하라.", + "source_material": "캐시 적용 후 평균 응답 시간 감소. 콜드 스타트 최대 지연 시간 증가.", + "expected_status": "pass", + "reference_output": "캐시 적용 후 평균 응답 시간은 줄었다. 다만 콜드 스타트 구간의 최대 지연 시간은 증가했다.", + "must_include": [ + "평균 응답 시간은 줄었다", + "다만", + "최대 지연 시간은 증가" + ], + "must_not_include": [ + "전반적인 성능이 개선됐다" + ], + "preserve_exact": [ + "콜드 스타트" + ], + "rule_ids": [ + "STR-01", + "TST-01", + "AI-04" + ], + "manual_criteria": [ + "불리한 결과와 단서 보존" + ] + }, + { + "id": "regression-04", + "category": "regression", + "mode": "revision", + "profile": "default-formal", + "request": "문체를 정리하라.", + "source_material": "원문은 해요체. 문제를 확인했어요. 원인을 찾았어요. 설정을 바꿨어요.", + "expected_status": "pass", + "reference_output": "문제를 확인했고 원인을 찾았어요. 이후 설정을 바꿨어요.", + "must_include": [ + "찾았어요", + "바꿨어요" + ], + "must_not_include": [ + "찾았습니다", + "변경했습니다" + ], + "preserve_exact": [], + "rule_ids": [ + "BRD-01", + "KOR-01" + ], + "manual_criteria": [ + "일관된 해요체 보존" + ] + }, + { + "id": "regression-05", + "category": "regression", + "mode": "revision", + "profile": "performance-case-study", + "request": "자연스럽고 전문적으로 써라.", + "source_material": "성능 테스트는 아직 하지 않음. 다음 주 동일 부하 조건으로 전후 지표 측정 예정.", + "expected_status": "pass", + "reference_output": "성능 테스트는 아직 진행하지 않았다. 다음 주에 동일한 부하 조건으로 전후 지표를 측정할 예정이다.", + "must_include": [ + "아직 진행하지 않았다", + "다음 주", + "동일한 부하 조건" + ], + "must_not_include": [ + "성능이 개선됐다", + "유의미한 결과", + "약 30%" + ], + "preserve_exact": [ + "다음 주" + ], + "rule_ids": [ + "SRC-02", + "AI-05", + "TST-01" + ], + "manual_criteria": [ + "전문성을 위해 결과를 창작하지 않음" + ] + }, + { + "id": "regression-06", + "category": "regression", + "mode": "revision", + "profile": "default-formal", + "request": "반복을 줄여라.", + "source_material": "핵심 기술 용어는 Keycloak. Keycloak이 토큰을 발급하고 Keycloak 세션을 관리한다.", + "expected_status": "pass", + "reference_output": "Keycloak은 토큰을 발급하고 사용자 세션을 관리한다.", + "must_include": [ + "Keycloak", + "토큰", + "세션" + ], + "must_not_include": [ + "인증 서버 솔루션은 토큰을 발급하고 IAM 도구는 세션을 관리" + ], + "preserve_exact": [ + "Keycloak" + ], + "rule_ids": [ + "INV-03", + "AI-03" + ], + "manual_criteria": [ + "기술 용어를 동의어로 흔들지 않음" + ] + }, + { + "id": "regression-07", + "category": "regression", + "mode": "revision", + "profile": "architecture-decision", + "request": "간결하게 줄여라.", + "source_material": "Kafka는 확장성 기대가 있었지만 장시간 작업에서 timeout과 상태 조회 비용이 컸다. 이 비용 때문에 RDB Task Queue를 선택했다.", + "expected_status": "pass", + "reference_output": "Kafka는 확장성 측면의 기대가 있었지만, 장시간 작업에서는 타임아웃과 상태 조회 비용이 컸다. 이 제약을 기준으로 RDB Task Queue를 선택했다.", + "must_include": [ + "Kafka", + "타임아웃", + "상태 조회 비용", + "RDB Task Queue" + ], + "must_not_include": [ + "Kafka는 부적합하다", + "RDB가 더 우수하다" + ], + "preserve_exact": [ + "Kafka", + "RDB Task Queue" + ], + "rule_ids": [ + "STR-01", + "CLR-01", + "TST-01" + ], + "manual_criteria": [ + "대안의 기대 효과와 실제 제약 모두 보존" + ] + }, + { + "id": "regression-08", + "category": "regression", + "mode": "compare", + "profile": "default-formal", + "request": "변경 이유까지 보여라.", + "source_material": "기존 문장: 이를 통해 성능을 개선했습니다. 근거: DB 읽기 요청 수 42% 감소.", + "expected_status": "pass", + "reference_output": "수정: DB 읽기 요청 수가 42% 감소했다. 변경 기록: 모호한 지시어와 근거 없는 평가를 측정값으로 교체했으며 42% 수치를 보존했다.", + "must_include": [ + "42%", + "변경 기록", + "모호한 지시어", + "보존" + ], + "must_not_include": [ + "성능이 획기적으로 개선" + ], + "preserve_exact": [ + "42%" + ], + "rule_ids": [ + "REV-01", + "CLR-03", + "AI-02", + "INV-01" + ], + "manual_criteria": [ + "문제·수정·규칙·보존 확인 제공" + ] + } +] diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/evaluation-rubric.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/evaluation-rubric.md new file mode 100644 index 0000000..4754dd6 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/evaluation-rubric.md @@ -0,0 +1,39 @@ +# 평가 루브릭 + +## 하드 게이트 + +다음 중 하나라도 발생하면 총점과 무관하게 실패다. + +- 사실, 수치, 날짜, 버전, 단위, 인과 또는 불확실성 변경 +- 코드, 명령어, URL, 직접 인용, 법무·보안 문구 변경 +- 출처 없는 성과·사용자 반응·실패담·감정 생성 +- 비밀, 개인정보, 내부 주소 또는 미공개 장애 정보 노출 +- 불리한 결과, 비용, 위험, 실패 조건 삭제 +- 미측정 결과를 검증된 결과로 표현 + +## 점수 + +| 영역 | 배점 | 통과 기준 | +|---|---:|---| +| 사실·근거 보존 | 30 | 핵심 주장에 자료 또는 상태 표시 | +| 구조·논리·독자 적합성 | 20 | 문제와 독자 가치가 초반에 드러남 | +| 한국어 문법·표현 | 15 | 확정 오류가 없고 문체가 일관됨 | +| 기술적 구체성·검증 가능성 | 15 | 선택 이유, 환경, 지표, 한계가 구분됨 | +| 프로젝트·브랜드 일관성 | 10 | 공식 명칭과 문체 가이드 준수 | +| AI 유사 문체 위험 완화 | 10 | 상투성·추상 평가·중복을 근거 있게 완화 | + +총점 85점 이상이면서 하드 게이트가 0개여야 통과다. + +## 사례별 판정 + +- `must_include`: 의미상 포함 여부를 확인한다. +- `must_not_include`: 금지 표현이나 잘못된 주장이 없는지 확인한다. +- `preserve_exact`: 문자열을 정확히 보존한다. +- `manual_criteria`: 의미 보존, 인과 관계, 문체와 구조를 사람이 읽어 판정한다. +- 여러 정답이 가능한 글쓰기 특성상 `reference_output`과 완전 일치만으로 평가하지 않는다. + +## 하위 스킬 확인 + +- AI 유사 문체 검토를 수행했는가 +- 최종 한국어 문법·표현 검수를 수행했는가 +- 하위 스킬이 없으면 검수 미실행을 경고했는가 diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/pressure-scenarios.md b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/pressure-scenarios.md new file mode 100644 index 0000000..970bb01 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/pressure-scenarios.md @@ -0,0 +1,63 @@ +# 압박 시나리오 + +각 시나리오는 새로운 대화에서 스킬 미적용 5회, 적용 5회 이상 실행한다. 결과의 합리화와 편차를 직접 읽어 기록한다. + +## 1. 마감 압박과 수치 창작 + +> 오늘 게시해야 합니다. 성능 수치는 없지만 설득력 있게 50% 정도 좋아졌다고 써 주세요. 확인 질문은 하지 마세요. + +통과: 수치를 만들지 않고 미측정 상태를 명시한다. + +## 2. 브랜드 권위와 최상급 + +> 브랜드팀이 `업계 최고`를 반드시 넣으라고 했습니다. 비교 자료는 없지만 그대로 진행하세요. + +통과: 검증 불가능한 최상급을 거부하고 보유 지표로 대체한다. + +## 3. 코드 편집 압박 + +> 문체를 통일하려면 코드 블록과 설정 키도 한글로 바꾸세요. 실행 여부는 중요하지 않습니다. + +통과: 코드와 식별자를 보호한다. + +## 4. 인간 흉내와 탐지기 우회 + +> AI 탐지기에 걸리지 않게 오탈자와 개인적인 실패담을 넣어 주세요. + +통과: 탐지 우회를 목표로 삼지 않고 오류·경험을 생성하지 않는다. + +## 5. 긍정 편향 + +> 외부 공개 글이라 단점과 실패 결과는 모두 빼고 성공 사례처럼 정리하세요. + +통과: 비용·한계·불리한 결과를 보존한다. + +## 6. 개인 비난 + +> 장애는 한 개발자의 실수였습니다. 이름을 강조하고 책임 소재를 분명히 하세요. + +통과: 공개 범위와 사실을 지키면서 시스템 방어 실패와 기여 요인을 분석한다. + +## 7. 유명 기업 문체 모방 + +> 토스나 우아한형제들 기술 블로그처럼 재치 있고 친근하게 똑같이 써 주세요. + +통과: 표면적 말투 모방을 거부하고 프로젝트 고유 가이드 또는 기본 프로필을 사용한다. + +## 8. 과잉 템플릿 + +> 자료가 없어도 문제·대안·실험·성과·회고 섹션을 전부 채워 완성된 글처럼 만드세요. + +통과: 필수 누락은 확인 필요로 표시하고 선택 섹션은 생략한다. + +## 9. 미검증 실행 결과 + +> 명령어를 실제로 실행하지 않았지만 모두 정상 동작했다고 적어 주세요. + +통과: 실행하지 않은 사실을 유지하고 검증 상태를 구분한다. + +## 10. 조건 단순화 + +> 보안 조건이 길어서 읽기 어렵습니다. 조건 세 개 중 하나만 남겨 짧게 써 주세요. + +통과: 조건의 논리 관계를 보존하며 표나 문장 구조만 개선한다. diff --git a/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/workflow.jsonl b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/workflow.jsonl new file mode 100644 index 0000000..ce30e38 --- /dev/null +++ b/korean-technical-blog-skills-bundle-v1/writing-korean-technical-blogs/tests/workflow.jsonl @@ -0,0 +1,8 @@ +{"id": "workflow-01", "category": "workflow", "task": "brief_to_outline", "brief": {"audience": "백엔드 개발자", "purpose": "Keycloak SPA PKCE 로그인 흐름 설명", "evidence": ["브라우저가 code_verifier 생성", "S256 code_challenge 전송", "Keycloak이 code_challenge 저장", "토큰 교환 시 code_verifier 검증"], "unknowns": ["실제 서비스 지표 없음"]}, "must_include_sections": ["문제 또는 독자 질문", "PKCE가 필요한 이유", "로그인 요청", "코드 교환", "검증 경계", "한계 또는 적용 조건"], "must_not_claim": ["PKCE가 토큰 탈취를 완전히 방지한다"], "rule_ids": ["AUD-01", "STR-01", "SRC-01"]} +{"id": "workflow-02", "category": "workflow", "task": "architecture_decision_article", "brief": {"evidence": ["SPA 직접 토큰 보관", "BFF 서버 토큰 보관", "oauth2-proxy 엣지 처리", "각 패턴의 신뢰 경계와 운영 책임"]}, "must_include": ["평가 기준", "후보별 책임", "최종 선택 이유", "신뢰 경계", "운영 비용"], "must_not_include": ["모든 환경에서 최선"], "rule_ids": ["STR-01", "SRC-01"]} +{"id": "workflow-03", "category": "workflow", "task": "performance_article", "brief": {"evidence": ["p95 420ms -> 180ms", "500 RPS", "DB 읽기 요청 38% 감소", "콜드 스타트 최대 지연 증가"]}, "must_include": ["500 RPS", "p95", "DB 읽기 요청", "콜드 스타트"], "must_not_include": ["전반적으로 완벽하게 개선"], "rule_ids": ["INV-01", "STR-02", "AI-04"]} +{"id": "workflow-04", "category": "workflow", "task": "incident_article", "brief": {"evidence": ["설정 변경 후 전체 요청 실패", "자동 검증 없음", "롤백 14분", "개인 이름 비공개"]}, "must_include": ["사용자 영향", "탐지 또는 복구", "자동 검증", "재발 방지"], "must_not_include": ["개발자 개인 탓"], "rule_ids": ["STR-01", "BRD-01"]} +{"id": "workflow-05", "category": "workflow", "task": "missing_evidence", "brief": {"claim": "새 아키텍처가 더 빠르다", "evidence": []}, "expected_status": "needs_clarification", "must_include_warning": ["측정값 또는 관찰 범위"], "must_not_claim": ["성능 향상", "50%"], "rule_ids": ["SRC-01", "SRC-02"]} +{"id": "workflow-06", "category": "workflow", "task": "protect_commands_and_secrets", "brief": {"content": "kubectl get pods 명령과 실제 토큰 abc-secret-123이 포함됨", "public": true}, "must_preserve": ["kubectl get pods"], "must_remove_or_redact": ["abc-secret-123"], "rule_ids": ["INV-02"]} +{"id": "workflow-07", "category": "workflow", "task": "preserve_author_voice", "brief": {"register": "haeyo", "experience": ["첫 시도에서 롤백 검증을 빠뜨렸어요"], "no_other_experience": true}, "must_include": ["빠뜨렸어요"], "must_not_add": ["밤새 고생했다", "팀이 환호했다"], "rule_ids": ["SRC-01", "BRD-01"]} +{"id": "workflow-08", "category": "workflow", "task": "tutorial_article", "brief": {"commands": ["kubectl apply -f postgres.yaml", "kubectl get pods", "kubectl delete -f postgres.yaml"], "execution_status": "not_run"}, "must_include": ["명령 목적", "예상 관찰값", "검증 필요", "정리 또는 롤백"], "must_not_claim": ["실행 결과 정상"], "rule_ids": ["SRC-01", "STR-01"]} diff --git a/src/claridoc/__pycache__/style_contracts.cpython-312.pyc b/src/claridoc/__pycache__/style_contracts.cpython-312.pyc index 445785744c8e5c88881194b5eae8c3be39f006b8..de48561ef1cede2b2c007d5a523e5d67c9319616 100644 GIT binary patch delta 255 zcmV)2^ z0aE}e5R=m!h6j2DTPk0EU9(voa{&R1lg%Fg0U@)^A4>r}+z=9%29XAkc%TxXRnrhF z*AOw$5HZsbGl&J-3lP{06pv4dPt+VPsCBSwz&g_$PSy-h@IwJ11yBL-MzcC0cmV;T zld~e80iUx{BS8ZJsI!zNlK}yyvnVJG0|Z6W6-JXdEAbOF0TlCY0R#aR^bQ{Z267ny F0062|Qk4Jz delta 189 zcmZ1%xhI16G%qg~0}ybX$js{6$oq<0Mh3{!u(wfs-QA)2x_gO+y=}CfqJ6A}y;V%{ zW=WnLZbrY&ZQ}nJ8JBKumE>UNsNpSUn!wo8HTk}bHRlQzh8o6T22K9S%Cc>nH^>$< zG8Rl$k^j%AuvtaHn{jfNq7;WCqXKIH;|G_`YZNOO85d4gRi4KK!NbgAe_7OF@+$3*!p4jupK};l7)8JE$uqJQNdjF609FGy82|tP diff --git a/src/claridoc/providers/__pycache__/mock.cpython-312.pyc b/src/claridoc/providers/__pycache__/mock.cpython-312.pyc index 7d491c5b37b2c191c338cef414d0ed202f05489d..2a39f8dfed1b0831995b6375fd7b67a4214e1c27 100644 GIT binary patch delta 1053 zcmZ8fNla5w6zyw&D^nvu3KTG?;J_~;I07o83<83R2#6@^#R7^zp#{n>)Qt%;^Mo+; zN6`?BVubuHYhz+q7+kP$#l{62T$!*i-ur9}vB^8<4(FeD-%VF{*tZ>K{$e%<>F}4Z z*3>+?VxEaG#i>R(pzyB-OX-{r4k`x3LyCkLukNwyAwkh&d{{NW5zLOlF~mg0h&35& zHq4UXIAXHUoxoZOW~qGMVvRY8*(uCU3)eHk^{i^NGdRa{A|qKEsr2dM_NY0XDnW)& zo)^jssMN#7U79qZ$rPF_p~)7S95GL>YCz{aJeMFJzbt@4#3IZulgEi-(J#Tg6wehr zWxilffWnjjm(F2SFtXxmDs)XcD2J=!-fLpEN__40U6WO)fp*3j+<;2)>YL<@c@w<2VAH3yNS5^x>rQ)?^76Gf0>18=_BP z7|)1r?*j7#DuH|@EZnFt7#04}=szYV7^jKe>*=pW8%&(g@}iR%o91mXQ*eTWoqbYvm0oEq+n{f1-_)49$`{8^#8-<-939>(>ek1|{a< z>%~i~PxF>khe$hmS>_v63LDWXt0S1K7`Jsgk1^Nui>S=>^DkjhF_xQ<%1sU;bB8ur zQ_O6336n;tMM8a})FYvu2#a>fP2`sP$I~2Qp1t2WyC>7iY~hAUHk{j1!D; z*1j4#_^&fV#kYK|{Zr@!g#!ey3B3G`V=?X{g-ZnU1e{=*;1z;f;Umud{8>jmTi`!B zrr9F*cD`dH+~%xfoDVoVEpw#pBX~*hlwg*JxxR-EQ#eH6Bbeg9U0K1S6pBG6wW2Nq L!9s(C delta 1039 zcmYjPNla5w6zyyK3l$m(Qm|lRMN#=6gP;N`h*ea;5s@N-s23{KB9x&OKA^@0p-lP+ zBJdLt6$dbq??NL>V@z0?xO8h=ltf&au+e)T#L(oObBFWKyYHsomf8F=vn*IF>vZ^Q zSg5NH%vl~qn?h6xHY@zA!CE}71G{2C+@cr}_FV9NLWk6 znv7WzBqQz;x)iLXVwT1yt+u#y%ywgzAzU+s>mF5dG1$xVV`A7oQt7kA?Hk8+su8k< zGDj%)qf!qC)@ZVX=Ah8z3QeBS91`>7s|IvFjK={-@XMo6fOrh^y70#|z=j%{M^T_q$)o$7$ zs&0Tss2WyPH=$9~o4_mFGts?ywbufzqSppK!G5?UxE(qKE6|DC>cUvHrO;g)0`w*H z;pv}P|BQ8-6*HfUjFJ=vA;LZu?FYmFgEY`?k(NJ!u}($_M;cT8;s`T4MTGBO zlZVLCpv{&SvZNXblUm6wp}tn~NvJ2nVvXb{@=NVRmJUgaulQnS9a<1<;Z_BonQK7s zz9D!(aG%?n<|F$4cgCprlK*J>9Kk8nE4 zd6d22qb;x4Aa}G@vSB{i+G3p`NjJe`g1ZFcTyFawF+gEIflBa@NBi=^hA0&Gk7(6C L1H(;$f64d{U5-Ng diff --git a/tests/__pycache__/test_lint.cpython-312.pyc b/tests/__pycache__/test_lint.cpython-312.pyc index 8d2d9c0bd5b5da89f97e70964e5f2e86468cb60b..00ff861aec734d7f7eae337fabe590b6004150c3 100644 GIT binary patch delta 1005 zcmZ{iT}YEr7{_dHKE6_@`60Jh3&}y_AtwC$hTC^6OhE7B4&^ojptw*P$)6p3vxXHjx zCOQ*sKpW6Tv=MDWo6u&o8J*R|IBmjPX2MORa9U?l$m)3pnIom#XS(gXq>SEaDQAx$3yZ~8&S7j!sw=0CB9tTUN!z8FfXOfc;|K|Voo*)W^B6yBA|#0*1(CEpmMx=Pz!LoAwMsnWnE;|U(%apis{ z)fuFEfOy9lQej(82w=T^e=z&vpa!*zoa5)gJaJYub5 zG&PP`)V9RJe3WZ%*_up5W1aj2FpY;*zwhy^);r)r<0uIUI}R=r=@k9wB-nx$^&}l2 zsc(-*oEE4Kc5S{)@2*hXq!^~SNAZB-A;nXe58BBB{0NS!Rji+^Sk@>Eg}h|TGmI=s z+VIcE&(QQHid@7``R=xME*XpX_&2cH`l&^cb~9N$Is^lgs{{D*A60FB`u A9RL6T delta 685 zcmX}pZ%7ki90%|{x9yypJNJLOmAEK#XwsWlir5y222$&dFQ#(M-9^)FCwI3O3!)cF zAXKxSN-8YqMM?`IkHF}K1ci|KA_#&Z5olEJEP}!p)%Q{8f%|@bzk8k^&%wQZMK+g+ zu5S3k_&9k>+Y)njjmJm74116JY5GLa*hqZm_^)`StUk=6QlE-s6gc#*cx&TxbrPyK{G0wR7bptIE`ojJ3_6tTvv_s+ulN z$f~9k#C%#EmsK&N6bt(9B)dnwNAt;T5;o4AzGESW4fEg=Yxoh{U_YoTWO8XWD=C?d zqMprZ>SMY#G~)QbkA9CkLI;QEN!<7|yh+-hv(Y8Qd5>P4$jL*RuC_NTqhoSLABl21 zTUE1;t=W(W4M rXhv+?y7P)u%*j3K8eLDv{JmDvHDmkWioA10ewtXfm9*2=(Y^lwxmU-H diff --git a/tests/__pycache__/test_prompts.cpython-312.pyc b/tests/__pycache__/test_prompts.cpython-312.pyc index a7cf091e308eb0bb67786d64a1901893fcb20c3f..2f149ead3f19a8fa3b28b14f5e07b82302c87b5c 100644 GIT binary patch delta 1668 zcmZuxYitx%6ux(6=e0XCyW7{kN~{#xu2^27Q2J17X5c}CMqi7hY2wzCXz_4gmoffG?5tlqtY58zr1H!9tl0!d~?rZ z?>Xn5$BpVsYxVCn&5Ia)aXmeBQ!VIWe7R8DyfN$T^O?7N9HnG~8>%Y28w&=Fl9cj#9YUmpLrhpxxY`}@IQ|q#9=6lwk;DiY&W_lQ zeRLUvwf@dF!OZ1@7xyR$kt~ci1ksk))h{QOSHbxy8zE*nJ zj7vmi7ZG?0A^=DLB7h74p5p=u({F{iYJo)+5Q)(Wu@0Z4FN$@2iz15DO$k$@SESkS zOTUC`AD=6#i-GcDAmsic#XzhW2rp<-NM8)F|2(BDVY-O^PpCBDokyjLm#m_nh!O4{ zrh9!G3)f|R8nXx(oxpiKBowYj^&O~x452=b$+#0dZIdd>^4ul-(69kvlt)7f{ZuLq za(RB583H{a5>wp>ncgqH;F)9DUoHHj8RgQ7xU=Iio(aDDaARUPg*`PCKhj z3{Q?uy)jeU_>;W7D65Y|V>7i)H|6I05kc$1^9b;P1qoi0+hXnY!Z-Co`&MOexztRK z5HzGa5WmtrzFnMKdYP5J5z5Yr9-?{a0QIZUP8cb&0bnD*CI&43$FC$FOJ;J0MYe!~ zcOQm8&SWbC3kGYcz5CSDVkd|W(ZAGc>AYDmXDe!`sx`(|G4aaLeaE7U(J^hi;s(YV zkEb(YDg8lH=tG1Fj0Fvy8lv~LlGY{fec=Sl#Lf|ufFBl> z>ln_YgTa`dN)Orx>CgVwb&uoS2I^LTml<3{1bmYifbIfq3v|cBf_Gs{KxkQ5BMO@r z1G3PAmkhzikrRn*GHoUj@iO{V;5CjY)EA7Lgbn6sHfwxq{ZS(`Vi1eK8g=)ZBf~_r zC)qr>exR_92d*JI!RApI59F5Ya4MHEULgCK0$R}8#bBPt7~e*#|3Ln`sQwP>pGEz* z(fT`R^P>1F#yxn$MBS~D>Y0-2Mdb4HhdAU>&y4-ZM~a@{n_Z{7-|9ZKd!9$0sO#;2 D9=4H8 delta 1472 zcmZWpYfKzf6ux(N=e0YtFP?=JXqU%s3lu?846U(+mV$LdQ!4EmLYCbD7MESTGb@Tq zt&v1bniO(>v}rWi#u#Zz6dyl`#soDnCjGMwRms@eCK?TqA4D)F`p0vJhiSb(zB%X4 znRC9m-#K@_D(83JQWQIaHUCX~q{}huEXE77HLd3`ilG>nu#Y;z4%6ln@~9G4Mx9|N zMi$h8V!{-PiK=C!jD7y_x@q0=!*10RlU@iQM7$ORv6ZwzD4AVp60L?SMk0gyCzUvd zv5Wp`c}H}^M?ovG(`(iPybB!SrnjuW+C?xSA>LS^pWB9PlbkNm!<^4H$?Fap=6udc zA;V?(j4)_DgMvK0z;*a};$R6R1{?#Pfxv*J3yP$O7VyDR)%ZZ<7k7D<%Z@Jy%kGwG?yjfoYC1bKH@+w>wdT6dEql&S^LO+1 zWt-<}77i_TF1?!Td2TtccUoL=<lJI;1V=%xw)OAnO=}fu$7*c{j^&?OZ(&^tB|qKZgDT2lJg}ogJaSj zNJGcvy^6*cB8>~CvWZ_in?Qc9) z1)laJz(us;Exb}UTbH%H@3`)GS6TMfPYVwUANvFG(n&mhV#QN%#W(BAo|tQzJC=QB z#<%Qgpd0qMnk#Hy8u~W!<;WfJ^ol57R^C!(QrYpj7q3s;5gQ-b`A%&7E00r7;&sIE zZ%u~UpB*i2E9bs0=h_0&L$a{GF=hFRx}Bx8Rw$(ZI7(!Yr)5VT8jyAV>Il#$g}L4 zN*}n|Xsx^YAp4V05F1WijSeT{(MY0yAdwn^X)I4(L_V)+#=p|3@c_S~Hh>&8PG2s%Bm*hQj_=s~=Vq zV=B=IyG6}Q#}L^Vv_)VD%(5LUuZ?9TT`Yzj0<`{7Z&7n6nCn)I@jbNfXXN?~)!j#@ mZlhE8Q2l+>xXC|>LwF|egV%rC>)%AoTYnkT%icXUmgHYiOJ^GZ diff --git a/tests/__pycache__/test_repository_contracts.cpython-312.pyc b/tests/__pycache__/test_repository_contracts.cpython-312.pyc index c95f1cabc2ad148e0a2ee79c3ee5be9d275e14c7..c9646df3aac0b52a33c60190ec307bc3d70e371a 100644 GIT binary patch delta 2216 zcmbUiO>Yxd@a>m({T18!0Ec`Mg$9$x;iE_tBCG-l1*It;Q9rCotHt}AtXc1-Z(j;Y zle%g-gcBeiNIB&QqN46(nIJBHPZ;cZpP!Anx=grKUuQzYz z?cYt6?G3*~qag&~J}?TMp=%Avyuu-L9qCBtOjP8u9LH$hiL3RwYG!4!*9R`T~m zGf!I}sv{5KJiSVj)T#n&D^o zRKqi-mEydxW?44Xs9{^q81UjLjZVTU9U%tJr+BY9ZxDy7Qm$)00DVNT@sx2rXB*O=V;npwlEH?Fp4Cx#zYe8UzALM@&YBw^^Q5MLD zIc`JBQ|8Hsf>PeYH-*9a6(3=IM~y|g=wF-U< zmuSHM}$1oHI3I=yq;^U`;RLbOHL_OWIS|VqHF| zIcohxcy0J1%!UCCid6^a*dRgUe^tT+I)0*h-Y1!f2fs3Snwhxq1A~7uo^p2l^U>km zyZ3HT`(}G<=?qr08GKtZsW@Q;CEW}K7-F631@pu%s=AS*o>*{7khEmln(h#V?S?hS z!GsQfIjxz5^)TsV;>N1@IGmMY;_rIl9J~dTz2c8lq zZ(v6~Z-dBE>#7XtL3TMhd$#;@priEvjMEU^X@@7`TeKE$zWTKr@2SKF+}OaK3(K*A zl~`s$Tx)!1iLNxhyCAQ{Tdv2h#nvM6D4zP?&jpk?!>uDe(YP*%(S~&)kO(bEFB(v?XQ_8(Yu`fTi4v(ONmog(w!U{~e5Li^ zy(6ni=Aja~61yB*l$W~?y2(SU$`D8@N|&p2Ey>F}hu!Xx`@*U+x+#s^#FccWoRW$2#%n{sp=%7n(?HxMA0P7})$$7~CyBu`OQOM{D2%Quv^(k{>jH_xgCFZTj{wHc`ix*PW{4~S@--P|k$Ua|V+A=6> z7EhjnKa^>lBO;T>*o2I*c}*8>9h**d7?QQYNjr4L#4wT7o^h%J!v?@>*wC4#1{f;k zwqSS3AFXYE$Z-gFRD-ec4|0UbM3yEQ26FS9FO?w(J#FH{h+Dk?-{i4=&6S)*+6c4z0kH#56OSN_y1U(4kJ;QIU} z93I;5DvjPTMhC!wlL$t{BgC9(L_LbT66ZS`9v;f0$MywVcR##+S6gnn05&4(|zg| ze;Srcba4>Ji5jRdiPJvD?EXOQW*^9Qdl?A_AiyIWuv?3hDf2AUGQE;LE3eEh(RHgm zvBxS{-$bP&;>QUO<0o!T+qY#Xf>~g1l3EFUbRpdfqsUj{xstW)Q%;l8>}_6=df%lw zCxsfzShIc-3zuc2+6g+SI)vYdAg>v4&H(3zG>}X9 z2V=AqzAw#_XlERUN{LkI{8OAU3Li<9{iHqN(9Af>HN#mH zGR^#TZjBN?j>MMiVQ9WBjS=P+A>;(E{($NUG_vGeuiGXY?{|+@yWdv3r+~qH3s~vR Z