docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
43bccd08a8
commit
b2963105a8
@@ -0,0 +1,410 @@
|
||||
---
|
||||
schemaVersion: 1
|
||||
exampleOnly: true
|
||||
generationAllowed: false
|
||||
project: backend-clean-architecture
|
||||
sourceDocument: final/document.md
|
||||
sourceDocumentSha256: <example-only>
|
||||
sourceRevision: <example-only>
|
||||
generatedAt: <example-only>
|
||||
---
|
||||
|
||||
# Root Tree Example
|
||||
|
||||
> 이 파일은 **구조 예시**다. 실제 `/shared/codebase/backend-clean-architecture` 분석을 수행해 만든 결과가 아니므로 downstream 문서 생성에 사용하지 않는다. 실제 프로젝트에서는 동일한 형식으로 source anchor와 evidence를 채우고 readiness를 판정한다.
|
||||
|
||||
PROJECT
|
||||
backend-clean-architecture
|
||||
|
||||
TOPIC
|
||||
JPA 피드 조회 성능
|
||||
jpa-feed-query-performance
|
||||
|
||||
├── CASE
|
||||
│ ├── DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1
|
||||
│ ├── 필드 접근 없이 발생한 EAGER ToOne N+1
|
||||
│ ├── Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증
|
||||
│ ├── Collection Fetch Join Pagination의 In-memory Paging
|
||||
│ ├── Projection 이후에도 1,509행을 읽은 Row Over-fetch
|
||||
│ └── Visibility OR이 Keyset Index를 깨뜨린 문제
|
||||
│
|
||||
├── REFERENCE
|
||||
│ ├── JPA N+1 정량 진단 기준
|
||||
│ ├── Fetch Type과 Fetch Strategy 구분
|
||||
│ ├── Fetch Join · Batch · Projection 선택 기준
|
||||
│ ├── Top-N-per-group 선택 기준
|
||||
│ ├── Keyset Pagination 설계 기준
|
||||
│ ├── Feed Visibility Query Pattern
|
||||
│ └── PostgreSQL Query Plan 측정 기준
|
||||
│
|
||||
├── OPEN QUESTION
|
||||
│ ├── Highlight 없는 FeedItem을 허용할 것인가
|
||||
│ ├── Round Trip과 Row Volume을 독립 측정할 것인가
|
||||
│ ├── ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가
|
||||
│ ├── feed_visible을 Production CQRS로 승격할 것인가
|
||||
│ └── 실제 동시 트래픽에서도 이 구조가 안정적인가
|
||||
│
|
||||
└── DECISION
|
||||
├── Query Plan은 실제 PostgreSQL에서 측정한다
|
||||
├── Query Strategy는 FeedQueryPort 뒤에서 소유한다
|
||||
├── Collection Fetch Join과 Pagination을 같이 사용하지 않는다
|
||||
├── Entity Graph 조회에는 Batch Fetch를 사용한다
|
||||
├── 화면 조회는 Read Projection을 사용한다
|
||||
├── Feed Pagination은 Keyset을 사용한다
|
||||
└── 현재 Read Model은 CQRS-lite로 유지한다
|
||||
|
||||
# Node Specifications
|
||||
## CASE — DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1
|
||||
|
||||
- slug: `highlight-collection-n-plus-one`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#컬렉션-n1-정량화`
|
||||
- code:
|
||||
- `<actual source path/symbol from analyzed project>`
|
||||
- evidence:
|
||||
- `<actual raw/query-plan/test evidence path>`
|
||||
- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.`
|
||||
- missing-verification: `example only — 실제 project source/evidence 확인 필요`
|
||||
- relations:
|
||||
- `<related Reference/Question/Decision slug and reason>`
|
||||
|
||||
## CASE — 필드 접근 없이 발생한 EAGER ToOne N+1
|
||||
|
||||
- slug: `eager-to-one-n-plus-one`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#user-page-연관-숨은-추가-쿼리-정량화`
|
||||
- code:
|
||||
- `<actual source path/symbol from analyzed project>`
|
||||
- evidence:
|
||||
- `<actual raw/query-plan/test evidence path>`
|
||||
- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.`
|
||||
- missing-verification: `example only — 실제 project source/evidence 확인 필요`
|
||||
- relations:
|
||||
- `<related Reference/Question/Decision slug and reason>`
|
||||
|
||||
## CASE — Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증
|
||||
|
||||
- slug: `fetch-join-multibag-row-explosion`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#fetch-join을-적용하며-확인한-두-가지-문제`
|
||||
- code:
|
||||
- `<actual source path/symbol from analyzed project>`
|
||||
- evidence:
|
||||
- `<actual raw/query-plan/test evidence path>`
|
||||
- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.`
|
||||
- missing-verification: `example only — 실제 project source/evidence 확인 필요`
|
||||
- relations:
|
||||
- `<related Reference/Question/Decision slug and reason>`
|
||||
|
||||
## CASE — Collection Fetch Join Pagination의 In-memory Paging
|
||||
|
||||
- slug: `collection-fetch-join-in-memory-pagination`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#컬렉션-fetch-join-페이징`
|
||||
- code:
|
||||
- `<actual source path/symbol from analyzed project>`
|
||||
- evidence:
|
||||
- `<actual raw/query-plan/test evidence path>`
|
||||
- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.`
|
||||
- missing-verification: `example only — 실제 project source/evidence 확인 필요`
|
||||
- relations:
|
||||
- `<related Reference/Question/Decision slug and reason>`
|
||||
|
||||
## CASE — Projection 이후에도 1,509행을 읽은 Row Over-fetch
|
||||
|
||||
- slug: `projection-row-over-fetch`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#dto-프로젝션`
|
||||
- code:
|
||||
- `<actual source path/symbol from analyzed project>`
|
||||
- evidence:
|
||||
- `<actual raw/query-plan/test evidence path>`
|
||||
- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.`
|
||||
- missing-verification: `example only — 실제 project source/evidence 확인 필요`
|
||||
- relations:
|
||||
- `<related Reference/Question/Decision slug and reason>`
|
||||
|
||||
## CASE — Visibility OR이 Keyset Index를 깨뜨린 문제
|
||||
|
||||
- slug: `visibility-or-breaks-keyset-index`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#가시성-조건`
|
||||
- code:
|
||||
- `<actual source path/symbol from analyzed project>`
|
||||
- evidence:
|
||||
- `<actual raw/query-plan/test evidence path>`
|
||||
- classification: `상세 분석에서 이 제목에 해당하는 구체적 발생 조건, 관측 결과, 진단 순서가 확인될 때 Case가 된다.`
|
||||
- missing-verification: `example only — 실제 project source/evidence 확인 필요`
|
||||
- relations:
|
||||
- `<related Reference/Question/Decision slug and reason>`
|
||||
|
||||
## REFERENCE — JPA N+1 정량 진단 기준
|
||||
|
||||
- slug: `jpa-n-plus-one-quantitative-diagnosis`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#컬렉션-n1-정량화`
|
||||
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
|
||||
- scope: `<actual applicability derived from analysis>`
|
||||
- exceptions: `<actual exceptions or none>`
|
||||
- relations:
|
||||
- `<originating Case/Decision and reason>`
|
||||
|
||||
## REFERENCE — Fetch Type과 Fetch Strategy 구분
|
||||
|
||||
- slug: `fetch-type-vs-fetch-strategy`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#최초-구현과-첫-관찰`
|
||||
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
|
||||
- scope: `<actual applicability derived from analysis>`
|
||||
- exceptions: `<actual exceptions or none>`
|
||||
- relations:
|
||||
- `<originating Case/Decision and reason>`
|
||||
|
||||
## REFERENCE — Fetch Join · Batch · Projection 선택 기준
|
||||
|
||||
- slug: `fetch-join-batch-projection-selection`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#배치-페치`
|
||||
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
|
||||
- scope: `<actual applicability derived from analysis>`
|
||||
- exceptions: `<actual exceptions or none>`
|
||||
- relations:
|
||||
- `<originating Case/Decision and reason>`
|
||||
|
||||
## REFERENCE — Top-N-per-group 선택 기준
|
||||
|
||||
- slug: `top-n-per-group-selection`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#top-n-per-group`
|
||||
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
|
||||
- scope: `<actual applicability derived from analysis>`
|
||||
- exceptions: `<actual exceptions or none>`
|
||||
- relations:
|
||||
- `<originating Case/Decision and reason>`
|
||||
|
||||
## REFERENCE — Keyset Pagination 설계 기준
|
||||
|
||||
- slug: `keyset-pagination-design`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#keyset-vs-offset`
|
||||
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
|
||||
- scope: `<actual applicability derived from analysis>`
|
||||
- exceptions: `<actual exceptions or none>`
|
||||
- relations:
|
||||
- `<originating Case/Decision and reason>`
|
||||
|
||||
## REFERENCE — Feed Visibility Query Pattern
|
||||
|
||||
- slug: `feed-visibility-query-pattern`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#가시성-조건`
|
||||
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
|
||||
- scope: `<actual applicability derived from analysis>`
|
||||
- exceptions: `<actual exceptions or none>`
|
||||
- relations:
|
||||
- `<originating Case/Decision and reason>`
|
||||
|
||||
## REFERENCE — PostgreSQL Query Plan 측정 기준
|
||||
|
||||
- slug: `postgresql-query-plan-measurement`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#측정-환경과-데이터셋`
|
||||
- classification: `관련 Case를 다시 서술하지 않고 다른 조회 문제에도 적용할 수 있는 판단 기준이 상세 분석에서 확인될 때 Reference가 된다.`
|
||||
- scope: `<actual applicability derived from analysis>`
|
||||
- exceptions: `<actual exceptions or none>`
|
||||
- relations:
|
||||
- `<originating Case/Decision and reason>`
|
||||
|
||||
## OPEN QUESTION — Highlight 없는 FeedItem을 허용할 것인가
|
||||
|
||||
- slug: `allow-feed-item-without-highlight`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#확인된-문제와-이후-검증할-가설`
|
||||
- known:
|
||||
- `<grounded fact from detailed analysis>`
|
||||
- unknown:
|
||||
- `<specific unresolved uncertainty>`
|
||||
- next-verification: `<concrete experiment/measurement/decision input>`
|
||||
- decision-criterion: `<condition that would close the question>`
|
||||
- relations:
|
||||
- `<related Case/Reference/Decision and reason>`
|
||||
|
||||
## OPEN QUESTION — Round Trip과 Row Volume을 독립 측정할 것인가
|
||||
|
||||
- slug: `measure-round-trip-and-row-volume-separately`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#측정-환경과-데이터셋`
|
||||
- known:
|
||||
- `<grounded fact from detailed analysis>`
|
||||
- unknown:
|
||||
- `<specific unresolved uncertainty>`
|
||||
- next-verification: `<concrete experiment/measurement/decision input>`
|
||||
- decision-criterion: `<condition that would close the question>`
|
||||
- relations:
|
||||
- `<related Case/Reference/Decision and reason>`
|
||||
|
||||
## OPEN QUESTION — ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가
|
||||
|
||||
- slug: `cardinality-estimate-after-analyze`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#postgresql-query-plan-측정`
|
||||
- known:
|
||||
- `<grounded fact from detailed analysis>`
|
||||
- unknown:
|
||||
- `<specific unresolved uncertainty>`
|
||||
- next-verification: `<concrete experiment/measurement/decision input>`
|
||||
- decision-criterion: `<condition that would close the question>`
|
||||
- relations:
|
||||
- `<related Case/Reference/Decision and reason>`
|
||||
|
||||
## OPEN QUESTION — feed_visible을 Production CQRS로 승격할 것인가
|
||||
|
||||
- slug: `promote-feed-visible-to-production-cqrs`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#cqrs-lite-읽기-모델`
|
||||
- known:
|
||||
- `<grounded fact from detailed analysis>`
|
||||
- unknown:
|
||||
- `<specific unresolved uncertainty>`
|
||||
- next-verification: `<concrete experiment/measurement/decision input>`
|
||||
- decision-criterion: `<condition that would close the question>`
|
||||
- relations:
|
||||
- `<related Case/Reference/Decision and reason>`
|
||||
|
||||
## OPEN QUESTION — 실제 동시 트래픽에서도 이 구조가 안정적인가
|
||||
|
||||
- slug: `stability-under-concurrent-traffic`
|
||||
- readiness: `BLOCKED`
|
||||
- source:
|
||||
- `final/document.md#다음-단계`
|
||||
- known:
|
||||
- `<grounded fact from detailed analysis>`
|
||||
- unknown:
|
||||
- `<specific unresolved uncertainty>`
|
||||
- next-verification: `<concrete experiment/measurement/decision input>`
|
||||
- decision-criterion: `<condition that would close the question>`
|
||||
- relations:
|
||||
- `<related Case/Reference/Decision and reason>`
|
||||
|
||||
## DECISION — Query Plan은 실제 PostgreSQL에서 측정한다
|
||||
|
||||
- slug: `measure-query-plan-on-postgresql`
|
||||
- readiness: `NEEDS_DECISION`
|
||||
- decision-status: `NOT_DECIDED`
|
||||
- source:
|
||||
- `final/document.md#측정-환경과-데이터셋`
|
||||
- decision-evidence:
|
||||
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
|
||||
- grounds:
|
||||
- `<Case or Reference that supports the choice>`
|
||||
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
|
||||
- relations:
|
||||
- `<related nodes and reason>`
|
||||
|
||||
## DECISION — Query Strategy는 FeedQueryPort 뒤에서 소유한다
|
||||
|
||||
- slug: `query-strategy-behind-feed-query-port`
|
||||
- readiness: `NEEDS_DECISION`
|
||||
- decision-status: `NOT_DECIDED`
|
||||
- source:
|
||||
- `final/document.md#조회-전략은-포트-뒤-어댑터의-책임`
|
||||
- decision-evidence:
|
||||
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
|
||||
- grounds:
|
||||
- `<Case or Reference that supports the choice>`
|
||||
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
|
||||
- relations:
|
||||
- `<related nodes and reason>`
|
||||
|
||||
## DECISION — Collection Fetch Join과 Pagination을 같이 사용하지 않는다
|
||||
|
||||
- slug: `no-collection-fetch-join-with-pagination`
|
||||
- readiness: `NEEDS_DECISION`
|
||||
- decision-status: `NOT_DECIDED`
|
||||
- source:
|
||||
- `final/document.md#컬렉션-fetch-join-페이징`
|
||||
- decision-evidence:
|
||||
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
|
||||
- grounds:
|
||||
- `<Case or Reference that supports the choice>`
|
||||
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
|
||||
- relations:
|
||||
- `<related nodes and reason>`
|
||||
|
||||
## DECISION — Entity Graph 조회에는 Batch Fetch를 사용한다
|
||||
|
||||
- slug: `batch-fetch-for-entity-graph`
|
||||
- readiness: `NEEDS_DECISION`
|
||||
- decision-status: `NOT_DECIDED`
|
||||
- source:
|
||||
- `final/document.md#배치-페치`
|
||||
- decision-evidence:
|
||||
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
|
||||
- grounds:
|
||||
- `<Case or Reference that supports the choice>`
|
||||
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
|
||||
- relations:
|
||||
- `<related nodes and reason>`
|
||||
|
||||
## DECISION — 화면 조회는 Read Projection을 사용한다
|
||||
|
||||
- slug: `read-projection-for-screen-query`
|
||||
- readiness: `NEEDS_DECISION`
|
||||
- decision-status: `NOT_DECIDED`
|
||||
- source:
|
||||
- `final/document.md#dto-프로젝션`
|
||||
- decision-evidence:
|
||||
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
|
||||
- grounds:
|
||||
- `<Case or Reference that supports the choice>`
|
||||
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
|
||||
- relations:
|
||||
- `<related nodes and reason>`
|
||||
|
||||
## DECISION — Feed Pagination은 Keyset을 사용한다
|
||||
|
||||
- slug: `keyset-for-feed-pagination`
|
||||
- readiness: `NEEDS_DECISION`
|
||||
- decision-status: `NOT_DECIDED`
|
||||
- source:
|
||||
- `final/document.md#keyset-vs-offset`
|
||||
- decision-evidence:
|
||||
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
|
||||
- grounds:
|
||||
- `<Case or Reference that supports the choice>`
|
||||
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
|
||||
- relations:
|
||||
- `<related nodes and reason>`
|
||||
|
||||
## DECISION — 현재 Read Model은 CQRS-lite로 유지한다
|
||||
|
||||
- slug: `keep-cqrs-lite-read-model`
|
||||
- readiness: `NEEDS_DECISION`
|
||||
- decision-status: `NOT_DECIDED`
|
||||
- source:
|
||||
- `final/document.md#cqrs-lite-읽기-모델`
|
||||
- decision-evidence:
|
||||
- `<ADR/commit/PR/config + recorded rationale/user-supplied decision>`
|
||||
- grounds:
|
||||
- `<Case or Reference that supports the choice>`
|
||||
- classification: `상세 분석에 실제 프로젝트 선택의 근거가 확인될 때만 READY로 바뀐다. 기술적으로 합리적인 권고만으로 Decision을 만들지 않는다.`
|
||||
- relations:
|
||||
- `<related nodes and reason>`
|
||||
|
||||
@@ -22,12 +22,34 @@ keep the meaning — but fix the sentence by *adding a short clause that states
|
||||
twisting a word into a shape no one uses. A sentence that is technically exact and unspeakable is a
|
||||
sentence that still needs work.
|
||||
|
||||
Before the first rewrite in a task, read both references:
|
||||
Before the first rewrite in a task, read all three references:
|
||||
|
||||
- [references/document-skeleton.md](references/document-skeleton.md) — how a Korean tech blog article
|
||||
is ordered: opener, audience bar, definition section, case template, closing.
|
||||
- [references/article-shape.md](references/article-shape.md) — **how much explanation goes inside a
|
||||
section.** Ordering can be right while the piece still reads thin. This is the one that fails most often.
|
||||
- [references/korean-tech-blog-register.md](references/korean-tech-blog-register.md) — what Korean
|
||||
tech blogs actually do with definitions, verbs, particles, subjects, headings, and numbers.
|
||||
|
||||
## Unpacking is not inventing
|
||||
|
||||
A compressed source and a thin source look the same on the page, and they are not. Four moves recover
|
||||
what the source already holds. None of them adds a fact.
|
||||
|
||||
| Move | Where the material comes from |
|
||||
|---|---|
|
||||
| **Restore a number the prose rounded off** | The record's own fields — `검증 환경`, `재현 조건`, the evidence file, the `확인한 것` table. A body that says 「세 곳에서 막는다」 while `검증 환경` lists ports 8088 · 8081 · 4180 dropped those numbers on the way in. Put them back. |
|
||||
| **Define a term at its first use** | The standard, public definition of a term the source already uses. `MessageDigest.isEqual`, `auth_request`, `forward-auth` — one clause each, where the reader first needs it. |
|
||||
| **Turn a table row into a sentence** | The table already in the source. A row like `host port 닫힘 \| 외부 직접 경로` becomes the sentence that says which ports, closed how, and what still gets through. The table stays as the summary. |
|
||||
| **Show the code the source only named** | The repository, at the revision the record pins. `EdgeIdentityController.currentUser` named in prose becomes the method body — trimmed to what the section is about, cut marked `// …`. |
|
||||
|
||||
What is still off limits: a measurement nobody took, a cause the source did not establish, a benefit or
|
||||
drawback the source did not claim, a failure story that did not happen. If a section needs a number that
|
||||
was never measured, write that it was not measured and leave the slot open — `article-shape.md` shows the
|
||||
six articles doing exactly that.
|
||||
|
||||
**The test:** point at the sentence you added and name the field, file, or line it came from. If you
|
||||
cannot, it is invention, not unpacking.
|
||||
- [references/regression-examples.md](references/regression-examples.md) — rewrites that failed and why.
|
||||
|
||||
Read the complete source and the nearby context needed to interpret pronouns, comparisons, and causes.
|
||||
@@ -57,6 +79,11 @@ named here so the spine stays visible.
|
||||
|
||||
- Do not shorten merely to look more human.
|
||||
- **Do not remove implementation detail because the paragraph feels dense.** Density is not a style defect.
|
||||
- **A section is a claim plus its evidence.** Evidence is code, output, a number, or a table — at least
|
||||
one. A section that is two paragraphs of prose with neither was cut at the wrong place; merge it with
|
||||
its neighbour. `references/article-shape.md`.
|
||||
- **Do not let a table carry an explanation that was never written.** Tables summarise prose that already
|
||||
ran. If the table is the first place a distinction appears, write the prose first and keep the table.
|
||||
- Do not create `처음에는`, `해보니`, `놀랍게도`, `저희는` or other experience language unless the source
|
||||
records that experience.
|
||||
- **Do not imitate one company's or one author's voice.** `style_profile.mjs` measures against five
|
||||
@@ -459,6 +486,20 @@ where the source really contrasts, `관계` is right inside `연관 관계`, and
|
||||
rule, re-run it against them — a rule those articles fail is a rule that is stricter than the standard,
|
||||
and it will push you into contorting prose to satisfy a check no human writer meets.
|
||||
|
||||
Then measure depth. `check_prose.mjs` reads sentences; this reads how much is in them.
|
||||
|
||||
```bash
|
||||
node .agents/skills/rewriting-technical-prose-naturally/scripts/density.mjs [--구조] <파일.md>
|
||||
```
|
||||
|
||||
Default thresholds are for a piece that reports something measured — a Case. Pass `--구조` for one that
|
||||
explains how something works — a Concept — where two to five figures is normal and thirteen is not.
|
||||
Ranges come from the six articles in [examples/](examples/).
|
||||
|
||||
**These are not targets to hit.** A low count names a place where explanation is missing; adding
|
||||
paragraphs to move the number produces a worse document that passes. Read the flagged line, find the
|
||||
section it points at, and decide whether the source actually holds what belongs there.
|
||||
|
||||
Then take the style profile:
|
||||
|
||||
```bash
|
||||
@@ -477,6 +518,8 @@ every rule above still applies, and the read-aloud test below is the one that de
|
||||
|
||||
## 참조
|
||||
|
||||
- `examples/` — **통과 기준이 되는 글 여섯 편.** 이 정도로 읽히면 통과다
|
||||
- `references/article-shape.md` — 한 절 안을 무엇으로 채우는가. 잰 값과 그 값을 만드는 것
|
||||
- `references/regression-examples.md` — 고친 예와 실패 이유
|
||||
- `references/protected-content.md` — 옮길 때 한 글자도 바꾸면 안 되는 것
|
||||
- `references/editorial-rules.md` — 편집 규칙
|
||||
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
<!-- 출처: https://techblog.woowahan.com/13569/ · 우아한형제들 기술블로그 · 참고용 원문 사본 -->
|
||||
|
||||
# 누구나 할 수 있는 10배 더 빠른 배치 만들기
|
||||
|
||||
## 비운영 시간 데이터
|
||||
|
||||
셀러시스템에서는 가게와 업주에 대한 다양한 데이터를 관리합니다.
|
||||
|
||||
배달의민족에 입점한 사장님들이 가게의 요일별 휴무일, 임시 휴무일, 공휴일 휴무 여부, 임시 운영 중지 등 다양한 휴무 설정할 수 있는데요.
|
||||
|
||||
실제 가게가 노출되고 음식을 주문하는 과정에서는 '가게가 운영하는지 안하는지'만 중요하기 때문에
|
||||
|
||||
셀러시스템팀에서는 이러한 정보를 조합하여 계산된 결과만을 유관부서에 전달하기도 합니다.
|
||||
|
||||
이것이 바로 '비운영시간 데이터'입니다.
|
||||
|
||||
셀러시스템은 다양한 채널에서 입력되는 각종 운영과 휴무 데이터를 취합하고 비운영시간 데이터를 계산합니다.
|
||||
|
||||
그 후 이러한 데이터가 클라이언트까지 잘 전달될 수 있도록 각 지면에 적절한 형태로 가공하여 제공하는 역할을 합니다.
|
||||
|
||||
현재 연동 구조에서는 실시간으로 수정되는 정보를 반영하는 것뿐만 아니라
|
||||
|
||||
매일 새벽에 전체 데이터를 계산하고 그 결과를 미리 갱신해둔 후 유관부서에 전파하는 작업 또한 하고 있습니다.
|
||||
|
||||
## 문제 상황
|
||||
|
||||
새벽에 배치 작업을 할 때, 배달의민족에 등록된 수많은 가게의 데이터를 매일 갱신하기 때문에 배치 수행 시간이 상당히 오래 걸립니다.
|
||||
|
||||
이른 새벽에 배치가 실행되는 덕분에 일과 시간 이전에 배치가 모두 끝나고 DB 부하도 큰 수준은 아니어서 여태까지는 큰 문제없이 운영되고 있었습니다.
|
||||
|
||||
하지만 최근 배포를 새벽에 진행할 일이 여러 번 있었는데요.
|
||||
|
||||
배포를 할 때마다 배포 예정 시간과 배치 실행 시간이 겹치는 바람에, 배치가 없었다면 매끄럽게 진행될 배포가 여러 번 복잡한 절차를 밟아 진행할 필요가 생겼습니다.
|
||||
|
||||
팀에서는 이렇듯 새벽 시간까지 오래 실행되는 배치가 배포 및 운영에 영향을 끼치는 것은 잠재적인 리스크라고 판단하였습니다.
|
||||
|
||||
셀러시스템팀에서는 이러한 리스크가 발견되었을 때 당장 조치가 필요한 것이 아니라면 우선 백로그(개발을 기다리는 과제 목록)에 등록합니다.
|
||||
|
||||
배치 성능 개선 과제 또한 우선 백로그에 등록하였고 이후 정기적인 백로그 그루밍(백로그 항목을 살펴보고 유지 관리하는 프로세스) 회의에서 우선순위를 재평가하고, 팀원 사이에서 의견을 취합하였습니다.
|
||||
|
||||
그루밍 회의에서 배치 성능 개선이 필요하다는 것에 공감대가 있었고 우선순위를 높여 스프린트에서 과제를 진행하게 되었습니다.
|
||||
|
||||
## I/O 최적화
|
||||
|
||||
배치 수행시간 개선을 위해 우선적으로 살펴본 부분은 I/O 병목이었습니다.
|
||||
|
||||
> ##### I/O (Input / Output) 병목이란?
|
||||
>
|
||||
> 컴퓨팅에서 부하를 설명할 때에는 크게 CPU 부하와 I/O 부하로 나뉩니다.
|
||||
>
|
||||
> 데이터를 계산하고 처리하는 과정인 CPU 부하와 달리, I/O 부하는 디스크에 파일을 읽고 쓰거나 DB 및 외부 컴포넌트와 통신하는 과정에서 발생합니다.
|
||||
>
|
||||
> I/O 병목은 이러한 I/O 부하가 시스템의 전체적인 효율성을 떨어뜨리는 부분을 말합니다.
|
||||
|
||||
배치에서 사용하는 I/O 부하 중 가장 핵심은 DB 쿼리였기 때문에
|
||||
|
||||
코드 및 로컬 환경에서 실제 호출하는 DB 쿼리를 살펴보면서 I/O 병목 지점을 살펴보았는데요.
|
||||
|
||||
JPA 지연 로딩으로 설정된 연관 관계 엔티티를 가져오는 과정에서 N+1 문제가 발생하는 것을 확인하였습니다.
|
||||
|
||||
위에서 언급하였듯 비운영시간 데이터 계산을 위해서는 다양한 종류의 데이터를 가져와야 하는데요.
|
||||
|
||||
이러한 데이터가 모두 1 : N 구조의 연관관계로 설정되어 있어서 관련 데이터를 가져오는 데에 오랜 시간이 걸리는 것을 확인할 수 있었습니다.
|
||||
|
||||
N+1 문제의 해결방식은 다양한데요.
|
||||
|
||||
연관관계로 설정된 엔티티의 종류가 많고 실제 연관관계 데이터의 수정은 불필요하다는 점 등을 고려하여
|
||||
|
||||
각 엔티티 정보를 연관관계를 통해 가져오는 것이 아닌 별도 쿼리 호출을 통해 명시적으로 한 번에 읽어오게끔 수정했습니다.
|
||||
|
||||
##### 수정 전
|
||||
|
||||
```java
|
||||
public List<LiveShopClose> generateLiveShopClose(Shop shop, LocalDate startDate, LocalDate endDate) {
|
||||
final List<ShopCalendar> shopCalendars = shopCalendarRepository.findAllByCalendarDateBetween(startDate, endDate);
|
||||
final List<ShopTemporaryClosed> shopTemporaryCloses = shop.getActiveShopTemporaryClosed();
|
||||
final List<ShopClosed> shopCloses = shop.getActiveShopClosed();
|
||||
final List<ShopOperationHour> operationHours = shop.getShopOperationHourIsType(OperationHourType.OPERATION);
|
||||
|
||||
return /* LiveShopClose 데이터 생성 */
|
||||
}
|
||||
```
|
||||
|
||||
##### 수정 후
|
||||
|
||||
```java
|
||||
List<LiveShopClose> generateLiveShopCloses(List<Long> shopNos, LocalDate startDate, LocalDate endDate) {
|
||||
List<ShopNo> shopNoEntities = shopNos.stream().map(ShopNo::new).collect(Collectors.toList());
|
||||
|
||||
List<ShopCalendar> shopCalendars = shopCalendarRepository.findAllByCalendarDateBetween(startDate, endDate);
|
||||
Map<Long, List<ShopTemporaryClosed>> activeShopTemporaryClosedMap =
|
||||
shopTemporaryClosedRepository.findActiveByShopNos(shopNoEntities).stream()
|
||||
.collect(groupingBy(ShopTemporaryClosed::getShopNo, Collectors.toList()));
|
||||
Map<Long, List<ShopClosed>> activeShopClosedMap =
|
||||
shopClosedRepository.findActiveByShopNos(shopNoEntities).stream()
|
||||
.collect(groupingBy(ShopClosed::getShopNo, Collectors.toList()));
|
||||
Map<Long, List<ShopOperationHour>> operationHoursMap =
|
||||
shopOperationHoursRepository.findOperationHoursByShopNos(shopNoEntities).stream()
|
||||
.collect(groupingBy(ShopOperationHour::getShopNo, Collectors.toList()));
|
||||
|
||||
return shopNos.stream()
|
||||
.flatMap(shopNo -> generateLiveShopClose(
|
||||
shopNo,
|
||||
shopCalendars,
|
||||
ListUtils.emptyIfNull(activeShopTemporaryClosedMap.get(shopNo)),
|
||||
ListUtils.emptyIfNull(activeShopClosedMap.get(shopNo)),
|
||||
ListUtils.emptyIfNull(operationHoursMap.get(shopNo))
|
||||
).stream())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
List<LiveShopClose> generateLiveShopClose(Long shopNo, List<ShopCalendar> shopCalendars,
|
||||
List<ShopTemporaryClosed> activeShopTemporaryCloses,
|
||||
List<ShopClosed> activeShopCloses,
|
||||
List<ShopOperationHour> operationHours) {
|
||||
|
||||
return /* LiveShopClose 데이터 생성 */
|
||||
}
|
||||
```
|
||||
|
||||
## 도메인 로직 및 기타 최적화
|
||||
|
||||
그 다음으로는 도메인 로직을 고려해 더 최적화할 수 있는 부분이 있을지 살펴보았습니다.
|
||||
|
||||
현재 가게의 비운영시간 데이터가 업데이트될 경우, 변경된 가게에 대한 이벤트를 발행하고 있는데요.
|
||||
|
||||
기존 로직에서는 실제 데이터의 변경 여부와는 관계없이 D-1~D+2 데이터를 무조건 재생성하기 때문에,
|
||||
|
||||
실제로는 데이터가 변경되지 않을 테지만 다시 데이터가 생성되어 변경 이벤트가 전송되는 케이스가 있었습니다.
|
||||
|
||||
이러한 케이스에 대응하여 데이터가 바뀌었는지 여부를 확인한 후 실제로 바뀐 경우에만 변경 사항을 적용하도록 개선하였습니다.
|
||||
|
||||
이를 통해 불필요한 DB 부하를 줄여 배치 수행시간을 줄일 수 있을 뿐만 아니라 변경 이벤트로 인한 간접적인 부하 또한 개선할 수 있었습니다.
|
||||
|
||||
## 최적화 검토
|
||||
|
||||
잠시 다른 얘기를 해보겠습니다.
|
||||
|
||||
유명한 개발 서적인 "Effective Java"(3rd ed, Joshua Bloch, 2017)에서는 아래와 같은 격언을 소개하고 있습니다.
|
||||
|
||||
> "성능 효율을 높이기 위해 컴퓨팅 업계에서는 많은 죄악이 저질러지는데(심지어 효율적이지조차 않을 때도 있다)"
|
||||
>
|
||||
> William A. Wulf (1972)
|
||||
|
||||
> "우리는 세세한 성능 효율에 대해서는 무시할 필요가 있다. 말하자면 97%가 이 경우에 해당한다. 섣부른 최적화는 만악의 근원이다."
|
||||
>
|
||||
> Donald E. Knuth (1974)
|
||||
|
||||
> "우리는 최적화에 대해서 다음 두가지 규칙을 따른다. 첫째. 하지 마라. 둘째. (전문가 한정) 아직은 하지 마라."
|
||||
>
|
||||
> M. A. Jackson (1975)
|
||||
|
||||
Java(1995년)는 물론이고 SQL(1978년)과 C++(1980년)이 등장하기도 전에 프로그래밍 대선배들은 위와 같은 발언들을 쏟아냈습니다.
|
||||
|
||||
최적화 얘기를 한참 하다가 최적화가 죄악이란 격언을 가져오다니 뜬금이 없으실 텐데요.
|
||||
|
||||
사실 이는 무작정 최적화하지 말란 것은 아니고 효율만 좇다가 득보다 실이 큰 경우를 경계하라는 뜻에 가깝다고 생각합니다.
|
||||
|
||||
최적화를 하기 전에 항상 아래 두가지를 검토해보면 좋을 것 같습니다.
|
||||
|
||||
> ##### 최적화 이전에 먼저 좋은 코드를 작성하기
|
||||
>
|
||||
> 코드를 작성하는 데 있어서 성능을 염두에 두는 것은 물론 중요합니다.
|
||||
>
|
||||
> 하지만 많은 경우 대부분의 코드는 성능상 영향이 크지 않고 실제로 병목이 되는 부분은 극히 일부분입니다.
|
||||
>
|
||||
> 좋은 코드를 최적화하기는 쉽지만, 섣부르게 최적화된 코드를 좋은 코드로 만드는 건 어렵습니다.
|
||||
>
|
||||
> 빠른 코드보다는 좋은 코드를 짜는 데에 먼저 집중하고 최적화는 그 다음에 생각해야 합니다.
|
||||
>
|
||||
> ##### 정량적으로 성능을 측정하면서 병목을 파악하기
|
||||
>
|
||||
> 정량화된 지표를 통해 실제로 병목이 되는 부분을 파악해야 합니다.
|
||||
>
|
||||
> 지엽적인 부분을 일일히 개선하는 마이크로 최적화는 많은 경우 100ms 를 99ms로 줄이는 것에 그칩니다.
|
||||
>
|
||||
> 마이크로 최적화 보다는 거시적인 관점에서 중요한 병목을 찾고 이를 구조적으로 해결하는 것이 중요합니다.
|
||||
>
|
||||
> 그리고 실질적으로 얼마나 빨라졌는지 정량적인 성과로 나타낼 수 있어야 합니다.
|
||||
|
||||
이번에 진행한 최적화는 유의미한 최적화였을까요?
|
||||
|
||||
우선 기존에 안정적으로 동작하며 비즈니스 로직이 명확한 코드가 있었습니다.
|
||||
|
||||
하지만 이러한 안정적인 코드의 수행시간이 오래 걸려 운영 및 유지보수에 있어서 잠재적인 큰 리스크라는 공감대가 있었습니다.
|
||||
|
||||
최적화를 위해 구조적인 병목을 찾았고 성능 테스트 결과 5배 이상 더 빨리 실행되는 것을 확인하였습니다.
|
||||
|
||||
> 베타 환경 테스트 결과, 특정 조건에서는 20배 이상 빨라지기도 하였습니다.
|
||||
|
||||
이 정도의 성능 개선이라면 최초 문제가 되었던 상황을 깔끔하게 해결함과 동시에
|
||||
|
||||
다소 복잡해진 코드를 고려해도 유의미한 최적화라는 결론을 내렸습니다.
|
||||
|
||||
## 빨라도 문제
|
||||
|
||||
근데 이거 빨라도 너무 빨라진 것 같습니다.
|
||||
|
||||
개발 환경에서의 지표를 통해 운영 환경 소요시간을 유추해 보면 6시간 걸리던 것이 1시간 조금 넘게 걸리는 것으로 나오는데요.
|
||||
|
||||
'내가 뭘 놓친 게 있나?' 아니면 '코드를 잘못 짰나?' 생각이 들었지만…
|
||||
|
||||
설령 정상 동작하더라도 무작정 빠른 게 능사가 아니기 때문에 안정적인 서비스 제공이 가능한지 다시 검토해보았습니다.
|
||||
|
||||
MSA 구조에서는 애플리케이션과 직접적으로 연동되는 DB와 로드밸런서 등 뿐만 아니라
|
||||
|
||||
많은 모듈 및 유관부서들이 유기적으로 연결되어 있기 때문에 영향 범위를 면밀히 검토해야 합니다.
|
||||
|
||||
특히 위에서 한 번 간단하게 언급한 것처럼 현재는 다음과 같은 형태이기 때문에 이 부분에 문제가 없을지 주로 검토했습니다.
|
||||
|
||||
* 데이터 변경이 발생하면
|
||||
* 변경 사항이 큐를 통해서 유관부서에 전달되고
|
||||
* 필요에 따라 유관부서가 추가적인 API 호출을 하는
|
||||
|
||||
구체적으로는 아래와 같은 사항들을 확인해보았습니다.
|
||||
|
||||
* 개발 환경에서 테스트 당시 애플리케이션이 실행되는 서버의 CPU 및 I/O 지표
|
||||
* 개발 환경에서 테스트 당시 DB CPU, 쿼리 지연 시간 등 지표
|
||||
* 예상 트래픽을 산출, 현재 운영 환경에서의 피크 트래픽과 비교하여 문제가 없을지 검토
|
||||
* 변경 사항을 전달하는 큐에서 지연이 발생해도 문제가 없을지 검토
|
||||
|
||||
예상 트래픽 비교 및 도메인 로직 최적화 과정에서 변경이벤트 또한 상당히 많이 줄어든 점을 감안하여서
|
||||
|
||||
문제가 없을 것으로 확인하고 운영 환경에 배포하였습니다.
|
||||
|
||||
## 배포 이후
|
||||
|
||||
문제가 없을 것으로 예상하였지만 일들이 항상 마음처럼 굴러가던가요?
|
||||
|
||||
운영환경에서 추정했던 속도보다 더 빠르게 동작을 하는 바람에
|
||||
|
||||
유관 부서 트래픽 또한 예상 이상으로 인입되어 DB CPU가 다소 높아지는 문제가 있었습니다.
|
||||
|
||||
그렇지만 너무 빨라서 발생하는 문제에 대해서 사전에 미리 검토를 해보았던 덕분에
|
||||
|
||||
당황하지 않고 빠르게 문제 원인을 좁히고 대응 방안을 도출할 수 있었습니다.
|
||||
|
||||
근본적으로는 실행 속도가 너무 빨라진 것이 문제이기 때문에
|
||||
|
||||
모순적이지만 우선 단기적인 대응 방안으로 의도적으로 지연 시간을 설정해 천천히 실행하도록 수정하였습니다.
|
||||
|
||||
```java
|
||||
@Bean(STEP_NAME)
|
||||
@JobScope
|
||||
public Step liveShopCloseCreateStep() {
|
||||
return stepBuilderFactory.get(STEP_NAME)
|
||||
.<Long, Long>chunk(CHUNK_SIZE)
|
||||
.reader(shopCloseScheduleReader(null))
|
||||
.writer(liveShopCloseWriter(null, null, null))
|
||||
.transactionManager(storeTransactionManager)
|
||||
.listener(new AfterChunkSleepListener(200))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Slf4j
|
||||
public class AfterChunkSleepListener implements ChunkListener {
|
||||
private final long sleepMillis;
|
||||
|
||||
public AfterChunkSleepListener(long sleepMillis) {
|
||||
this.sleepMillis = sleepMillis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterChunk(ChunkContext context) {
|
||||
try {
|
||||
log.info("Chunk 실행 후 sleep {} millis. 현재 read Count : {}",
|
||||
sleepMillis,
|
||||
context.getStepContext().getStepExecution().getReadCount());
|
||||
TimeUnit.MILLISECONDS.sleep(sleepMillis);
|
||||
} catch (InterruptedException e) {
|
||||
log.error("Thread sleep interrupted.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterChunkError(ChunkContext context) {
|
||||
// 사용안함.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeChunk(ChunkContext context) {
|
||||
// 사용안함.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
그리고 위와 같이 지연 시간을 설정해도 **기존 390분이 소요되던 배치가 30분 소요되는 결과**를 얻을 수 있었습니다.
|
||||
|
||||
마지막으로 이러한 변경이벤트를 유관부서에 전달하고 유관부서가 다시 우리 API를 호출하는 방식에 대해서
|
||||
|
||||
좀 더 효율적인 해결책은 없을지 고민하게 되는 계기가 되었습니다.
|
||||
|
||||
## 결론
|
||||
|
||||
이번 최적화 작업을 요약하면 다음과 같습니다.
|
||||
|
||||
* 리스크를 확인하고 과제에 대한 우선순위를 조정하기
|
||||
* 문제 상황을 분석하고 병목을 확인하기
|
||||
* I/O의 경우 최대한 한번에 여러건을 읽고 쓰도록 하여 효율성 높이기
|
||||
* 도메인 로직을 검토하여 개선할 수 있는 부분이 있는지 살피기
|
||||
* 유의미한 최적화인가? 정량적인 지표로 다시 검토하기
|
||||
* 빨라도 문제일 수 있으니 최적화에 의한 영향 범위를 검토하고 운영 환경에서도 문제가 없을지 확인하기
|
||||
|
||||
대단한 알고리즘을 작성하지도, 유행하는 신규 프레임워크를 사용한 것도 아니지만
|
||||
|
||||
생각보다 좋은 결과를 얻었고 그 과정도 좋은 사례라고 생각되어 공유드립니다.
|
||||
|
||||
당연하게 생각되는 부분이라도 돌다리를 한 번 더 두드려보듯이
|
||||
|
||||
항상 한 번 더 고민해 본다면 누구라도 저보다 더 잘하실 수 있으리라고 생각합니다.
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<!-- 출처: https://techblog.woowahan.com/17386/ · 우아한형제들 기술블로그 · 참고용 원문 사본 -->
|
||||
|
||||
# 우리 팀은 카프카를 어떻게 사용하고 있을까
|
||||
|
||||
## 누가 읽으면 좋을까
|
||||
|
||||
카프카(Kafka)가 무엇인지 알고 있는 독자를 대상으로 합니다. 기술적 구현방식을 다루기보단 카프카를 기반으로 한 다양한 기술적 개념에 대해서 얇고 넓게 소개하고, 우리팀에서 어떻게 적용하는지 사례를 공유합니다. 기술적 내용에 대해 자세히 알고 싶다면 공식문서나 다른 기술블로그를 참고해 주세요. 글 하단 "참고 자료"에 관련 링크도 첨부하였습니다.
|
||||
|
||||
아래와 같은 키워드가 등장합니다. 각 문단에서 개념을 간단하게 설명하며 진행할 예정이나 관련 배경지식이 있다면 더 쉽게 이해할 수 있을 것으로 예상됩니다.
|
||||
|
||||
* Kafka
|
||||
* Transactional Outbox Pattern
|
||||
* Event Bus
|
||||
* Kafka Streams
|
||||
|
||||
## 카프카, 한 섹션 요약
|
||||
|
||||
먼저 카프카를 매우 간단하게 알아보겠습니다. 카프카는 분산 스트리밍 플랫폼으로, 대량의 데이터를 처리하고 실시간으로 전송하는 데 사용됩니다. 모든 데이터는 로그 형식으로 파일 시스템에 기록됩니다. 여기서 말하는 로그는 추가만 가능하며, 시간순으로 완전히 정렬된 데이터의 흐름(레코드 시퀀스)을 의미합니다. 로그를 한곳에 모아 처리할 수 있도록 중앙집중화되어 있으며, 대용량 데이터를 수집하고 실시간 스트리밍으로 소비가 가능합니다.
|
||||
|
||||
메시지(레코드)는 발행처(프로듀서)가 보낸 순서로 기록되어 순서가 보장되며, 메시지의 위치 값(offset)으로 소비자(컨슈머)가 소비한 메시지의 위치를 표시합니다. 각 컨슈머 그룹마다 메시지의 위치 값을 가지고 있기 때문에 같은 소스에서 서로 다른 여러 개의 컨슈머 그룹이 개별적으로 소비가 가능합니다. 한 소스(Single Origin)에서 여러 소비자가 손실이나 변형 없이 메시지를 소비할 수 있으며, 원천 데이터를 기반으로 데이터 분석도 가능합니다.
|
||||
|
||||
아래는 이 글을 이해하는 데 필요한 카프카의 기본적인 용어와 개념입니다.
|
||||
|
||||
* **토픽(Topic)**: 데이터의 주제를 나타내며, 이름으로 분리된 로그입니다. 메시지를 보낼 때는 특정 토픽을 지정합니다.
|
||||
* **파티션(Partition)**: 토픽은 하나 이상의 파티션으로 나누어질 수 있으며, 각 파티션은 순서가 있는 연속된 메시지의 로그입니다. 파티션은 병렬 처리를 지원하고, 데이터의 분산 및 복제를 관리합니다.
|
||||
* **레코드(Record)**: 레코드는 데이터의 기본 단위로 키와 값(key-value pair) 구성입니다.
|
||||
* **오프셋(Offset)**: 특정 파티션 내의 레코드 위치를 식별하는 값입니다.
|
||||
* **프로듀서(Producer)**: 데이터를 토픽에 보내는 역할을 하며, 메시지를 생성하고 특정 토픽으로 보냅니다.
|
||||
* **컨슈머(Consumer)**: 토픽에서 데이터를 읽는 역할을 하며, 특정 토픽의 메시지를 가져와서(poll) 처리합니다. 컨슈머 그룹은 여러 개의 컨슈머 인스턴스를 그룹화하여 특정 토픽의 파티션을 공유하도록 구성합니다. 이를 통해 데이터를 병렬로 처리하고 처리량을 증가시킬 수 있습니다.
|
||||
* **카프카 커넥터(Connector)**: 카프카와 외부 시스템을 연동 시 쉽게 연동 가능하도록 하는 프레임워크로 MySQL, S3 등 다양한 프로토콜과 연동을 지원합니다.
|
||||
* **소스커넥터(source connector)**: 메시지 발행과 관련 있는 커넥터
|
||||
* **싱크커넥터(sink connector)**: 메시지 소비와 관련 있는 커넥터
|
||||
|
||||
## 우리팀에서 활용하는 방식
|
||||
|
||||
딜리버리서비스팀은 하루 100만 건 이상 생성되는 배민배달(배달의 민족에서 관리하는 자체 배달)을 중계하는 역할을 합니다. 배달의 민족에서 제공하는 여러 주문서비스(배민배달, B마트, 배민스토어)의 배민배달을 받아 여러 배달서비스 중 하나로 분배하고, 배달과정을 중계하고 관리하는 역할을 합니다. 주문과 배달을 처리하는 방식으로 분산시스템 이벤트 기반 아키텍처를 사용하고 있으며, 카프카를 팀에서 주요 기술 중 하나로 사용하고 있습니다. 팀의 분산시스템이 어떻게 나뉘어 있는지 간략하게 설명하고, 카프카를 팀에서 활용하는 방식을 소개하겠습니다.
|
||||
|
||||
딜리버리서비스팀의 분산서버 구조에 대해 간략하게 설명하면 아래 그림과 같습니다.
|
||||
|
||||
주문이벤트를 받아 배달 프로세스를 관리하는 주문/배달서버, 발행한 이벤트를 기반으로 분석하는 분석서버로 구성되어 있습니다. 처리량을 높이고 성능을 향상시키기 위해 많은 서비스에서 그러하듯 각 서버그룹은 N개의 여러 서버로 구성됩니다.
|
||||
|
||||
## [1] 주문-배달을 안전하게 처리하자
|
||||
|
||||
### 미리보기
|
||||
|
||||
– 도메인 이벤트에 대해 카프카를 이벤트 브로커로 사용하여 이벤트 순서를 보장한다.
|
||||
– MySQL source connector를 이용한 Transactional Outbox Pattern을 사용하여 분산시스템에서 데이터와 메시지 전송을 하나의 트랜잭션으로 관리하여 데이터 정합성을 확보한다.
|
||||
|
||||
주문이 발생하면 고객에게 배달이 완료될 때까지 안전하게 처리하는 것이 가장 큰 목표입니다. 그 과정을 혼란스럽지 않게 처리하기 위해서는 주문과 배달의 이벤트 순서가 중요하며, 이벤트가 누락되지 않도록 관리해야 합니다. 카프카를 이벤트 브로커로 사용하고, 이벤트 발생 순서를 보장하고 있습니다. 배달을 놓치지 않고 처리하기 위해서 Transactional Outbox Pattern을 사용하여 순서를 보장한 재시도를 통해 이벤트 누락이 없도록 처리하고 있습니다.
|
||||
|
||||
### 순서보장
|
||||
|
||||
배달프로세스를 간략하게 나타내면 아래 그림과 같습니다. 배달이 진행되면서 여러 이벤트가 발행되고, 몇 가지 이벤트는 배달상태를 변경시킵니다. 배달상태는 순서가 있기 때문에 순서대로 진행되며, 특정 이벤트들은 거의 동시에 발생하기도 하고, 배달상태를 변경시기키도 합니다. 혼란스럽지 않은 배달프로세스를 관리하기 위해서는 이벤트 발행과 관련하여 순서를 보장하는 것이 중요합니다.
|
||||
|
||||
예를 들어, 배차완료와 거의 동시에 픽업준비요청이 발생할 수 있습니다. 프로듀서는 배차완료 이후 픽업준비요청을 발행하였으나 네트워크 등의 이슈로 컨슈머는 픽업준비요청 이후, 배차완료를 수신할 수도 있습니다. 이런 경우, 순서가 보장되지 않는다면 컨슈머 측에서는 거의 동시에 발생한 이벤트에 대해서 어떤 이벤트가 먼저 발생한 것인지 혼란스러워 비즈니스 로직 처리에 문제가 발생할 수 있습니다.
|
||||
|
||||
카프카는 메시지 발행 순서에 따라 소비할 수 있도록 순서를 보장합니다. 같은 카프카 클러스터에서 주문, 배달, 분석 토픽 등 목적에 따라 토픽을 구성할 수 있으며, 하나의 토픽은 병렬처리로 처리량을 높이기 위해 여러 개의 파티션으로 구성됩니다. 카프카에서는 같은 파티션에 대해서 프로듀서가 보낸 데이터의 순서를 보장합니다. 같은 키를 가진다면 같은 파티션으로 할당되고, 하나의 파티션에 하나의 컨슈머가 할당됩니다. 따라서 같은 키에 대해서는 분산시스템에서도 같은 서버가 소비하게 되어 이벤트 순서가 보장될 수 있습니다. 주문식별자, 배달식별자 등과 같이 순서관리가 필요한 식별자를 키로 관리하여 순서를 보장합니다. 메시지 공급자가 발행 순서를 보장하기에 거의 비슷한 시점에 발행되는 메시지 동시성 이슈 발생 상황을 줄일 수 있습니다.
|
||||
|
||||
### 데이터 정합성
|
||||
|
||||
비즈니스 로직을 처리하기 위한 데이터를 MySQL 데이터베이스에 저장하고, 카프카로 이벤트를 발행하는 방식으로 데이터와 이벤트를 관리하고 있습니다. 카프카에 문제가 발생할 경우, 데이터베이스에는 변경된 배달상태가 저장되었으나 이벤트는 발행되지 않을 수도 있습니다. 예를 들어 주문취소가 발생한 경우를 생각해 봅시다. 주문취소로 배달취소가 발생하게 되면 데이터베이스에는 해당 배달은 취소된 상태로 저장될 것입니다. 하지만 이벤트 발행에 실패하게 된다면 컨슈머는 메시지를 수신하지 못해 여전히 배달을 진행할 수 있습니다. 취소된 배달이 진행되는 문제가 발생할 수 있습니다. 데이터와 메시지 발행의 트랜잭션을 하나로 관리하여 데이터 정합성을 확보할 필요가 있었습니다.
|
||||
|
||||
인프라와 커넥션 이슈, 타임아웃 등의 문제로 토픽에 메시지를 넣는 과정에서 실패할 수도 있습니다. 메시지 발행에 실패하는 경우, 메시지가 누락되어 정합성이 보장되지 않기에 누락 방지를 위해서 재시도가 필요했습니다. 재시도 과정에서도 메시지의 순서는 보장되기를 바랐습니다. 하지만, 다른 비즈니스 이벤트 처리에 미치는 영향은 최소화하며 재시도를 하고 싶었습니다. 이벤트 발행에 실패하는 경우, 순서와 영향도를 고려하여 재시도를 시도하는 방법으로 [Transactional Outbox Pattern](https://microservices.io/patterns/data/transactional-outbox.html)을 이용하였습니다.
|
||||
|
||||
Transactional Outbox Pattern은 분산 시스템에서 데이터베이스 트랜잭션과 메시지 큐를 조합하여 데이터 일관성과 메시지 전송의 원자성을 보장하는 패턴입니다. 분산시스템에서 트랜잭션 완료 후, 이벤트를 보내야 하는 경우에 트랜잭션에 실패할 경우 데이터는 롤백되지만, 이벤트는 발송될 수 있고, 메시지 전송 중 문제가 발생하는 경우 메시지 전송 원자성이 보장되지 않을 수 있습니다. 문제를 해결하기 위한 이 패턴의 핵심 아이디어는 다음과 같은 흐름으로 진행됩니다.
|
||||
|
||||
1. 트랜잭션 데이터베이스에 Outbox 테이블을 도입하여, 트랜잭션 완료 시 변경 사항을 기록합니다.
|
||||
2. Outbox 테이블에 새로운 레코드가 추가될 때마다 변경 사항을 메시지로 전송합니다.
|
||||
|
||||
설명한 패턴을 구현하기 위해 [Debezium](https://debezium.io/)이라는 라이브러리에서 지원하는 MySQL 카프카 커넥터를 이용하고 있습니다. Debezium은 데이터베이스의 변경 사항을 감지하고 이벤트 스트림으로 변환하는 오픈 소스 라이브러리입니다. 데이터베이스의 기록인 binlog의 변경 사항을 감지(Change Data Capture)하여 읽는 로그 테일링 기법을 사용되어 있습니다. 변경사항을 읽어 설정한 토픽으로 보내주는 방식으로 동작합니다. 트랜잭션의 성공 내역을 binlog 기록하고, 기록을 순서대로 읽어가도록 동작합니다. 메시지 발행에 실패하면 아웃박스테이블의 데이터도 롤백되기 때문에 하나의 트랜잭션으로 데이터 정합성을 관리하고 있습니다. Debezium에서 메시지 발행에 사용되는 MySQL source connector는 태스크를 하나만 사용하도록 강제하기 때문에, 단일 커넥터에서 메시지 전송 순서를 보장할 수 있습니다.
|
||||
|
||||
하나의 태스크로 동작하면 테이블에 데이터가 쌓이는 속도보다 커넥터가 처리하는 속도가 느릴 경우 메시지 지연이 발생할 수 있습니다. 처리량을 높이기 위해 토픽별로 outbox 테이블을 분리하여 만들고, 각 outbox 테이블은 식별자 기반으로 N개의 테이블로 구성하였습니다. delivery-outbox1, delivery-outbox2, delivery-outbox3과 같이 여러 개의 outbox 테이블을 구성하고, 각 테이블에 커넥터를 연결하여 한 커넥터가 처리하는 양을 분산하여 처리량을 확보하였습니다. outbox 테이블은 쓰기(insert)만 동작하는 테이블로 저장된 순서대로 이벤트 메시지 발행을 보장하도록 설정되어 있습니다. 같은 키는 같은 테이블에 저장되며, 한 테이블에서는 하나의 커넥터를 사용하기 때문에 같은 키에 대해서는 순서를 보장됩니다.
|
||||
|
||||
## [2] 카프카를 이벤트 버스로도 활용해보자
|
||||
|
||||
### 미리보기
|
||||
|
||||
– 카프카를 이벤트 버스로 활용하여 분산시스템에 알린다.
|
||||
|
||||
분산 시스템에서는 서버 여러 대로 서버군을 이룹니다. 한 서버에서 값을 변경하면 서버군에 속한 모든 서버의 변경된 값을 관리해야 할 때가 있습니다. 배달서버에서 어떤 배달서비스로 분배할지 결정하며, 분배 규칙은 인메모리로 관리합니다. 이 경우, 분배 규칙 이벤트를 소비한 배달서버만 분배규칙이 변경되고 다른 배달서버들은 기존 분배 규칙을 유지할 수 있습니다. 운영자가 필요에 의해 분배 규칙을 변경하면, 모든 배달서버는 해당 변경 값을 알아야 합니다. 카프카를 이벤트버스로 활용하여 값 관리가 필요한 서버군에 변경된 값을 알리고, 변경된 내용을 반영하도록 관리하고 있습니다.
|
||||
|
||||
스프링 클라우드에서 제공하는 RemoteApplicationEvent를 사용하여 [이벤트 버스](https://cloud.spring.io/spring-cloud-bus/reference/html/index.html)로 카프카를 사용하고 있습니다. 이벤트 버스 토픽(예: event-bus)을 설정하고, id는 고유해야 하기에 `${서버명}:${식별자}` 형식으로 설정합니다. RemoteApplicationEvent를 상속한 이벤트를 정의하고, 원하는 목적지(서버군)를 명시하여 발행하면 이벤트 버스는 목적 서버군에 이벤트를 전달합니다. 스프링 클라우드에서 id 서버명의 인스턴스에 애플리케이션 이벤트를 발행하기 때문에, 목적 서버에서 발행한 팀에서 정의한 이벤트를 구독하여 처리한다면, 정의한 이벤트를 수신하여 변경된 값을 반영할 수 있게 됩니다. 이렇게 되면 분산시스템에서 인메모리로 관리되는 값도 변경되어 같은 기준으로 비즈니스 로직을 처리할 수 있습니다.
|
||||
|
||||
RemoteApplicationEvent를 상속한 DeliveryServiceRemoteApplicationEvent를 추상클래스로 설정하고, 각 특성에 맞게 구현체를 구성하여 이벤트를 발행하여 필요한 곳에서 활용하고 있습니다. 서버군에 속한 서버들이 각자 인메모리로 저장하고 있는 값을 모두 초기화나 변경이 필요한 경우에 사용됩니다. 아래는 분배규칙 변경에 대해 RemoteApplicationEvent를 정의한 예시 코드입니다.
|
||||
|
||||
```java
|
||||
public abstract class DeliveryServiceRemoteApplicationEvent extends RemoteApplicationEvent {
|
||||
protected DeliveryServiceRemoteApplicationEvent(String destination) {
|
||||
super(SOURCE, ORIGIN, DESTINATION_FACTORY.getDestination(destination));
|
||||
}
|
||||
}
|
||||
// 분배규칙 CustomRemoteEvent
|
||||
public class RouteRuleRemoteEvent extends DeliveryServiceRemoteApplicationEvent {
|
||||
public RouteRuleRemoteEvent() {
|
||||
super("delivery"); // destination: 배달서버
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
아래는 분배규칙이 변경되어 새롭게 내려받아야 하는 경우, 서버에서 메서드를 수행하고 배달서버군 전체에도 변경된 규칙이 반영될 수 있도록 RemoteApplicationEvent를 발행하고, 소비하는 예시 코드입니다.
|
||||
|
||||
```java
|
||||
public void load() {
|
||||
routeRuleSetStore.load();
|
||||
remoteApplicationEventPublisher.publishEvent(new RouteRuleRemoteEvent());
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void handle(RouteRuleRemoteEvent event) {
|
||||
routeRuleSetStore.load();
|
||||
}
|
||||
```
|
||||
|
||||
딜리버리서비스팀에서 이벤트 버스 토픽은 하나의 파티션으로 관리하고 있습니다. 설정값을 변경하는 데 높은 처리량이 필요하지 않고, 같은 서버군은 같은 변경사항을 수신해야 하기 때문입니다. 파티션에서 같은 컨슈머 그룹은 오프셋을 공유하므로 모든 서버는 다른 컨슈머 그룹 아이디(consumer group id)를 가져야 합니다. 여러 컨슈머에게 개별적으로 소비될 수 있기 때문에 각 서버가 다른 컨슈머 그룹 아이디를 사용한다면 한 번의 이벤트 발행으로도 여러 서버군의 설정을 바꾸는 것이 가능합니다. 특수하게 컨슈머의 이름을 정하지 않으면, 스프링 클라우드에서는 anomymous라는 프리픽스를 붙여 랜덤하게 컨슈머 그룹 아이디를 설정합니다. 즉, 연결된 서버만큼 `anonymous.{식별자}` 형식의 컨슈머 그룹 아이디가 생성되고 있고, 시스템 모니터링 시에는 anomymous 컨슈머는 필터링하고 있습니다.
|
||||
|
||||
## [3] 더 나은 배달을 위해 분석하자
|
||||
|
||||
### 미리보기
|
||||
|
||||
– 분석에 적합하게 가공된 형태로 데이터를 제공한다.
|
||||
– 카프카 스트림즈를 활용하여 실시간 배달 정보를 집계하여 배달 상황을 파악할 수 있도록 한다.
|
||||
|
||||
배치 등을 사용하여 분석을 위한 데이터를 제공할 수도 있지만, 일정 주기로 배치를 수행하기 때문에 실시간 데이터를 반영하기 어려운 문제가 있습니다. 우리 팀에서는 실시간 혹은 준실시간에 해당하는 데이터를 조회하여 배달현황을 파악하고 서비스에 반영하기를 원했습니다. 요구사항을 만족시킬 기술로 카프카 스트림즈를 활용하고 있습니다.
|
||||
|
||||
카프카 스트림즈는 카프카에서 실행하는 이벤트별 데이터(레코드) 처리를 수행할 수 있게 하는 라이브러리입니다. 간단히 말하자면, 카프카 스트림즈는 메시지를 활용한 실시간 집계, 분석 시스템으로 실시간 데이터 스트리밍 및 분석 시스템에 적합한 플랫폼으로 폭넓게 활용되는 도구입니다. 카프카 스트림즈 애플리케이션이 처리하는 것은 데이터의 흐름입니다. 전처리 단계와 스트림 연결로 데이터 스트림을 입력받아 필요한 처리를 수행 후, 새로운 스트림을 생성하여 데이터를 처리하고 결과를 산출하는 방식으로 동작합니다.
|
||||
|
||||
### 분석용으로 가공한 데이터 제공
|
||||
|
||||
분석 서버에서는 배달 이벤트를 수신한 후 전처리 과정을 거쳐, 조회하기 편한 형태로 가공하여 분석 토픽으로 이벤트를 재발행합니다. 원본 이벤트를 가공하여 분석할 수 있도록 또 다른 토픽과 스트림으로 생성합니다. 목적이 다르기에 원본 토픽과 분석용 토픽을 분리하여 사용합니다. 서비스 토픽과 분석용 토픽은 서로 다른 데이터 처리량과 리소스가 필요하기에 토픽과 서버를 분리하여 특성에 맞는 리소스를 사용하고 조정할 수 있도록 구성하였습니다. 주요한 서비스 로직에 사용되는 토픽과 분석에 사용되는 토픽은 문제가 발행하더라도 영향범위를 분리하여 관리할 수 있습니다.
|
||||
|
||||
배달은 생성, 배차, 픽업, 완료 등 순서를 가지고 진행되며, 특정 행위마다 배달이벤트를 발행합니다. 분석이 필요한 경우, 배달의 이벤트를 하나하나 보는 것이 아닌 배달 건별로 정리된 정보를 확인하고 싶은 경우가 많습니다. 주요 정보는 어떻게 되는지, 언제 생성되어 배차, 완료가 되었는지 등 배달 한 건에 주요 정보를 집계해 확인하고자 하는 수요가 있었습니다. 분석하기 편하도록 전처리 과정을 거쳐 한 배달건에 대해 발생한 여러 이벤트를 하나로 모아 완료된 배달 건의 요약된 종합 정보를 제공하고 있습니다. 이때 Redis를 임시저장소로 활용하여 종합데이터를 관리합니다. 원본 배달 토픽에서 배달생성 이벤트를 수신하면 Redis에 주요한 주문과 배달 정보를 저장합니다. 이후, 배달 진행에 따라 발행된 이벤트를 수신하면 각 배달이벤트 시점 등 주요한 정보를 업데이트합니다. 완료된 배달은 Redis에서 삭제하고, 의미 있는 정보로 구성한 새로운 배달통합이벤트를 분석 토픽에 발행하여 배달 건별 종합데이터를 제공합니다.
|
||||
|
||||
S3 싱크 커넥터를 사용하여 분석토픽에 들어간 이벤트는 AWS S3 객체저장소에 보내 영구 저장하고 있습니다. 이벤트 영구 저장소와 비즈니스 로직을 처리하기 위한 저장소를 분리하여, 분석용 서비스와 비즈니스 서비스의 상호 영향을 최소화합니다. S3 객체저장소에 저장된 데이터는 [AWS Athena](https://aws.amazon.com/ko/athena/)를 사용해 비즈니스 서비스 저장소에 부하를 주지 않고 오래된 기록까지 조회할 수 있습니다. 데이터를 분석할 수 있는 도구를 연동하여 사업이나 운영 부서에서 지난 배달 건을 월단위로 분석하기도 하고, 정산에 활용하기도 합니다.
|
||||
|
||||
### 실시간 데이터 제공
|
||||
|
||||
배차 대기로 남아있는 배달 건이 얼마나 되는지, 현재 배차에 시간이 얼마나 걸리는지, 주문서비스별 유입량은 어떻게 되는지 등 실시간 배달 데이터를 알고 싶었기에 스트림즈 애플리케이션을 활용하여 실시간 배달데이터를 집계하고 있습니다. 실시간 집계된 내용은 그라파나 대시보드로 시각화하여 운영 상황에 대응할 수 있도록 제공하고 있고, 배달인프라 상황을 파악하여 분석하는 데도 사용됩니다.
|
||||
|
||||
각 배달상태에 따른 배달 건수가 얼마나 되는지 실시간 집계하는 한 가지 예시를 들어보겠습니다. 분석용 배달토픽에 들어온 레코드 흐름(Stream)을 기반으로 최신 배달 상태저장소(latest-delivery)를 구축합니다. 상태저장소(statestore)는 키-값 임시저장소입니다. 최신 배달 상태저장소에는 레코드의 시간을 기준값으로 최신 배달을 판단하며, 키를 배달식별자로 하고 값을 배달데이터로 합니다. 최신 배달을 기준으로 배달 상태별 개수를 집계할 수 있습니다. 키는 배달상태, 값은 집계된 배달 상태별 개수로 배달상태별 상태저장소(count-per-status)를 구성합니다. 그 결과로 배달상태별 상태저장소에서 실시간으로 배달상태별로 집계된 결과를 빠르게 조회할 수 있습니다. 배달 상태별 개수를 조회하는 그라파나 게이지를 등록하여 조회한 결과를 대시보드로 시각화하여 나타내고 있습니다. 대시보드를 통해 하나의 배달 상태에 몰려있진 않은 지, 배달진행에 문제가 있는 건 아닐지 대시보드를 보며 추이를 실시간으로 파악할 수 있습니다.
|
||||
|
||||
이외에도 다양하게 집계되는 실시간 현황은 현재 배달상황을 파악하고 대응하는 데 도움이 됩니다. 다양하게 집계되는 데이터를 활용하여 다른 유용한 기능을 제공할 수도 있습니다. 실시간으로 이상 상황으로 감지되는 판단을 자동화하여 알람으로 빠르게 장애인지를 할 수도 있고, 큰 장애로 번지기 전에 주문유입을 최소화하여 장애 범위를 최소화하는 데 활용될 수도 있습니다.
|
||||
|
||||
## 끗!
|
||||
|
||||
지금까지 카프카의 개념과 특성을 간략하게 설명하고, 팀에서 카프카를 어떻게 활용하고 있는지 소개했습니다. 저는 개발자로 일한 지 갓 2년이 넘었고, 팀에서 사용하는 시스템 설계나 구현을 주도해본 경험은 아직 없습니다. 그래서 각 기술의 자세한 설명은 더 전문가들이 작성한 자료나 공식 문서를 참고하실 수 있게 마지막에 정리해 두었습니다.
|
||||
|
||||
이 글에서는 카프카와 관련된 기술을 상세히 다루기보다는 우리팀에서 카프카를 어떻게 사용하는지 전반적으로 소개하는 데 중점을 두었습니다. 스스로도 더 잘 이해하고 기술을 사용하고 싶었고, 사례를 공유하며 함께 더 알아가고 싶은 마음이었습니다. 이 글의 내용과 연관된 분들이 발표한 우아콘 영상이 있어 마지막 참고 자료 부분에 링크를 넣어두었으니 더 자세한 내용이 궁금하신 분들은 영상도 참고해 주시면 좋겠습니다.
|
||||
|
||||
### 참고 자료
|
||||
|
||||
#### 책
|
||||
|
||||
* [아파치 카프카 애플리케이션 프로그래밍 with 자바](https://product.kyobobook.co.kr/detail/S000001842177)
|
||||
* [Kafka Streams in action](https://product.kyobobook.co.kr/detail/S000001804837)
|
||||
|
||||
#### 링크
|
||||
|
||||
* [이벤트 소싱 패턴](https://learn.microsoft.com/ko-kr/azure/architecture/patterns/event-sourcing)
|
||||
* [Pattern: Transactional Outbox](https://microservices.io/patterns/data/transactional-outbox.html)
|
||||
* [Pattern: Transaction log tailing](https://microservices.io/patterns/data/transaction-log-tailing.html)
|
||||
* [Debezium](https://debezium.io/)
|
||||
* [Spring Cloud Bus](https://cloud.spring.io/spring-cloud-bus/reference/html/index.html)
|
||||
|
||||
#### WOOWACON 2023 영상
|
||||
|
||||
* [Kafka를 활용한 이벤트 기반 아키텍처 구축](https://woowacon.com/presentations?presentationId=621)
|
||||
* [Kafka Streams를 활용한 이벤트 스트림 처리 삽질기](https://woowacon.com/presentations?presentationId=607)
|
||||
+603
@@ -0,0 +1,603 @@
|
||||
<!-- 출처: https://techblog.woowahan.com/20161/ · 우아한형제들 기술블로그 · 참고용 원문 사본 -->
|
||||
|
||||
# 검색 성능 개선을 위한 Elasticsearch 인덱스 구조와 쿼리 최적화
|
||||
|
||||
2024. 11. 28. 이승효, 김민규, 김범수
|
||||
|
||||
## 배경
|
||||
|
||||
2023년 12월, 배달의민족 앱 상단 **검색** 기능에 **장보기•쇼핑** 탭이 추가되었습니다. **검색** 창에 검색어를 입력하고 **장보기•쇼핑** 탭을 누르면 비마트와 배민스토어 상품만 검색할 수 있는 기능입니다.
|
||||
|
||||
커머스검색개발파트에서는 Elasticsearch(이하 ES)를 활용해 커머스 검색과 상품 목록 페이지를 제공하는 리스팅 API를 개발하고 운영하고 있으며, ES를 보다 효율적으로 활용하기 위해 다양한 최적화 작업을 진행하고 있습니다.
|
||||
|
||||
비마트와 배민스토어 상품만 검색할 수 있는 서비스를 오픈한 이후, GS25, GS the Fresh, 이마트에브리데이, CU 등 대형 셀러의 지속적인 추가로 색인 문서의 양이 약 3배 증가했습니다. 또한, 검색 API에 다양한 필터와 검색어 매칭 필드가 추가되었고, 리스팅 API를 새롭게 제공하면서 검색 및 리스팅 API 호출 수는 약 1.5배 증가했습니다. 특히 새로운 영역에서도 리스팅 API 호출이 계속 확대되고 있는 상황입니다.
|
||||
|
||||
이처럼 서버가 처리해야 할 기능과 요청량이 급격히 증가하면서 성능 최적화의 중요성이 대두되었습니다. 올해 초부터 커머스 검색 API의 레이턴시 개선을 목표로 다양한 작업을 진행했으며, 특히 ES 인덱스 구조와 쿼리 최적화를 통해 성능을 크게 개선할 수 있었습니다. 이번 글에서는 이러한 성능 개선 과정과 쿼리 최적화에 적용한 주요 방법들을 자세히 공유드리려 합니다.
|
||||
|
||||
## 성능개선을 돕는 도구
|
||||
|
||||
### API 응답값 비교 스크립트
|
||||
|
||||
API 서버는 Nginx가 리버스 프록시 역할을 하여 Spring Boot Web Server로 요청을 전달하고 있습니다.
|
||||
API 응답값 비교 스크립트는 Nginx의 access log를 기반으로 운영버전 API와 변경버전 API를 호출하여, 응답값의 차이를 검증하는 데 사용됩니다. 이 스크립트는 성능 개선, 대규모 리팩토링, 마이그레이션 작업 등에서 기존 API와 변경된 API의 응답값이 동일한지 확인하기 위해 개발되었습니다.
|
||||
|
||||
여러 차례의 검색 API의 성능개선 과정에서 이 스크립트를 이용하여 변경 코드가 응답값에 영향을 주는지 검증하도록 하여 서비스 장애가 발생하지 않고 안전하게 성능 개선을 진행했습니다.
|
||||
|
||||
#### 참고 자료
|
||||
|
||||
* [Migrating Critical Traffic At Scale with No Downtime — Part 1](https://netflixtechblog.com/migrating-critical-traffic-at-scale-with-no-downtime-part-1-ba1c7a1c7835)
|
||||
* [Migrating Netflix to GraphQL Safely](https://netflixtechblog.com/migrating-netflix-to-graphql-safely-8e1e4d4f1e72)
|
||||
|
||||
### 슬로우 쿼리 수집기
|
||||
|
||||
[Nginx의 access.log](https://nginx.org/en/docs/http/ngx_http_log_module.html)에는 요청의 응답 시간(request_time)이 기록됩니다. 검색 API에서는 응답 시간이 0.7초 이상 소요되는 요청을 슬로우 쿼리로 간주하며, 이를 모니터링하여 개선점을 도출했습니다.
|
||||
|
||||
> $request_time
|
||||
> request processing time in seconds with a milliseconds resolution; time elapsed between the first bytes were read from the client and the log write after the last bytes were sent to the client
|
||||
|
||||
이제 검색 성능 개선을 위한 5가지 과정들을 공유드리도록 하겠습니다.
|
||||
|
||||
* 카테고리 필터 적용 시 레이턴시 지연현상 개선
|
||||
* 포켓몬 키워드로 인한 레이턴시 지연현상 개선
|
||||
* Painless 스크립트를 활용한 정렬 제거
|
||||
* track_scores: true → false 로 변경, 분석되는 term 개수에 따른 쿼리 최적화
|
||||
* analyzer 라이브러리화
|
||||
|
||||
## 카테고리 필터 적용 시 레이턴시 지연현상
|
||||
|
||||
### 현상
|
||||
|
||||
대부분의 커머스 검색에서 제공하는 '카테고리 필터링'기능으로, 검색 결과를 특정 카테고리로 좁혀 볼 수 있게 하는 요청에 레이턴시 지연 현상이 발생했습니다.
|
||||
|
||||
특정 카테고리를 필터링하는 경우 검색 API의 요청 파라미터로는 categoryId 값이 들어갑니다. (예. categoryId=1000) 검색 API에서는 ES 상품 인덱스에 아래 쿼리를 포함하여 이 카테고리에 대한 상품만을 필터링합니다.
|
||||
|
||||
```
|
||||
{
|
||||
"query": {
|
||||
"bool": {
|
||||
"filter": [
|
||||
...
|
||||
{
|
||||
"term": {
|
||||
"categoryId": 1000
|
||||
}
|
||||
}
|
||||
...
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
겉으로 보기에는 문제가 없어 보이는 쿼리였지만, 카테고리 필터가 있을 때와 없을 때 검색 API의 응답 속도 차이는 매우 큰 편이었습니다.
|
||||
|
||||
카테고리 필터 유무에 따른 API 응답 속도 비교
|
||||
|
||||
* 카테고리 필터가 없는 경우 : 115ms
|
||||
* 카테고리 필터가 있는 경우 : 980ms
|
||||
|
||||
### 문제 원인 분석 및 해결
|
||||
|
||||
원인을 파악해 보니 카테고리ID의 필드 타입 설정에 문제가 있었습니다. 카테고리ID 필드는 숫자이기 때문에 integer로 색인을 하였는데요. 카테고리ID 는 범위 검색을 하는 필드가 아니고 정확하게 일치하는 값을 찾아내는 용도로만 쓰고 있기 때문에 keyword로 타입을 변경하고, term필드로 쿼리가 수행될 수 있도록 변경하였습니다.
|
||||
|
||||
### 개선 결과
|
||||
|
||||
* 카테고리 필터로 인한 슬로우 쿼리는 더 이상 발생하지 않게 되었습니다. 아래는 개선 전후 달라진 내용입니다.
|
||||
|
||||
| 구분 | 개선 전 | 개선 후 |
|
||||
|------|--------|--------|
|
||||
| categoryId 필드 타입 | `{"categoryId": {"type": "integer"}}` | `{"categoryId": {"type": "integer", "fields": {"keyword": {"type": "keyword"}}}}` |
|
||||
| 쿼리 예시 | `{"term": {"categoryId": 1000}}` | `{"term": {"categoryId.keyword": 1000}}` |
|
||||
| API 응답시간 | 980ms | 104ms |
|
||||
|
||||
* 이 문제를 해결하고 나니 특정 키워드에 대한 슬로우 쿼리를 확인할 수 있었습니다.
|
||||
|
||||
### numeric, keyword 타입 색인/쿼리 비교
|
||||
|
||||
numeric, keyword 타입이 내부적으로 어떻게 색인이 되고 term쿼리를 수행했을 때 어떻게 데이터를 찾아오게 되는지 조금 더 조사해보았습니다.
|
||||
|
||||
#### 색인
|
||||
|
||||
* numeric (숫자) 타입 : Lucene 내부적으로는 [PointValues](https://lucene.apache.org/core/8_11_1/core/org/apache/lucene/index/PointValues.html)로 색인하게 되는데 이는 KD-Tree 자료구조를 사용하여 저장이 되어있다고 합니다.
|
||||
|
||||
> Points represent numeric values and are indexed differently than ordinary text. Instead of an inverted index, points are indexed with data structures such as [KD-trees](https://en.wikipedia.org/wiki/K-d_tree). These structures are optimized for operations such as _range_, _distance_, _nearest-neighbor_, and _point-in-polygon_ queries.
|
||||
|
||||
* keyword 타입 : 역인덱스타입으로 저장되어 있습니다. 정확하게 일치하는 값으로만 쿼리가 가능합니다.
|
||||
|
||||
#### 쿼리
|
||||
|
||||
kibana로 아래 쿼리를 프로파일링해 보면, 내부적으로 수행되는 Lucene 쿼리가 다른 것을 확인할 수 있었습니다.
|
||||
쿼리를 수행한 인덱스의 문서 수는 4천만 건입니다.
|
||||
|
||||
```
|
||||
{
|
||||
"query": {
|
||||
"term": {
|
||||
"$content.catalogPath0.catalogId": {
|
||||
"value": 100001
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* integer 타입을 term 쿼리했을 때: [PointRangeQuery](https://lucene.apache.org/core/8_11_1/core/org/apache/lucene/search/PointRangeQuery.html)가 수행됨.
|
||||
* 숫자,날짜 등의 범위 검색에 적합한 쿼리라고 할 수 있습니다.
|
||||
* [KD-trees](https://en.wikipedia.org/wiki/K-d_tree)를 기반으로 범위 검색을 수행합니다.
|
||||
* keyword 타입을 term 쿼리했을 때: [TermQuery](https://lucene.apache.org/core/8_11_1/core/org/apache/lucene/search/TermQuery.html)가 수행됨.
|
||||
* 말그대로 매칭된 term 이 있는지를 찾는 쿼리입니다. keyword 타입은 역색인으로 저장되어 있기에 바로 접근하여 가져옵니다.
|
||||
* 단일 값에 대한 검색은 빠르지만 범위 검색은 불가능합니다.
|
||||
|
||||
## 포켓몬 키워드로 인한 레이턴시 지연현상
|
||||
|
||||
### 현상
|
||||
|
||||
카테고리 필터 문제를 해결하고 나니 대부분의 슬로우쿼리를 유발하는 키워드는 "포켓몬" 키워드로 검색하는 경우인 것을 확인할 수 있었습니다.
|
||||
|
||||
### 문제 원인 분석 및 해결
|
||||
|
||||
ES 쿼리의 성능 문제를 분석한 결과, 특정 카테고리에 속한 키워드 점수를 부스팅하기 위해 사용한 function_score 쿼리에서 원인을 발견했습니다.
|
||||
|
||||
당시 쿼리는 다음과 같은 구조로 작성되어 있었습니다.
|
||||
|
||||
```
|
||||
{
|
||||
"from": 0,
|
||||
"size": N,
|
||||
"query": {
|
||||
"bool": {
|
||||
"filter": [
|
||||
1️⃣ 상품상태와 관련된 매칭 조건, 키워드 매칭 조건 필터링
|
||||
]
|
||||
"must": [
|
||||
{
|
||||
"bool": {
|
||||
"must": [
|
||||
{
|
||||
"function_score": {
|
||||
"query": {
|
||||
"match_all": { } 2️⃣
|
||||
},
|
||||
"functions": [
|
||||
부스팅 조건을 만족할때의 점수부여하는 쿼리들
|
||||
],
|
||||
"score_mode": "",
|
||||
"boost_mode": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"should": [
|
||||
필드별 매칭 조건에 따른 점수부여
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"aggregation": {
|
||||
각종 aggregation 쿼리
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 문제점
|
||||
|
||||
* 1️⃣에서 필터링된 결과에 function_score 쿼리가 적용될 것으로 예상했으나, 실제로는 2️⃣ (`"query": { "match_all": {} }`)에 의해 모든 문서에 대해 부스팅 조건을 실행하고 있었습니다.
|
||||
* 이로 인해 특정 키워드(예: "포켓몬")가 최상위 카테고리에 속하고 대부분의 문서에 색인된 상황에서, 불필요하게 functions 내의 부스팅 연산을 수행하고 있었습니다.
|
||||
|
||||
#### 해결 방안
|
||||
|
||||
function_score 쿼리의 "filter" 조건에 1️⃣ (기존 bool – filter의 쿼리)를 포함하도록 수정했습니다. 이를 통해 부스팅 조건이 사전에 필터링된 결과에만 적용되도록 변경했으며, 그 결과 쿼리 속도가 크게 개선된 것을 확인했습니다.
|
||||
|
||||
개선 후 쿼리
|
||||
|
||||
```
|
||||
{
|
||||
"from": 0,
|
||||
"size": N,
|
||||
"query": {
|
||||
"bool": {
|
||||
"filter": [
|
||||
1️⃣ 상품상태와 관련된 매칭 조건, 키워드 매칭 조건 필터링
|
||||
],
|
||||
"must": [
|
||||
{
|
||||
"bool": {
|
||||
"must": [
|
||||
{
|
||||
"function_score": {
|
||||
"query": {
|
||||
"bool": {
|
||||
"filter": [
|
||||
1️⃣ 상품상태와 관련된 매칭 조건, 키워드 매칭 조건 필터링
|
||||
]
|
||||
}
|
||||
},
|
||||
"functions": [
|
||||
부스팅 조건을 만족할때의 점수 부여하는 쿼리들
|
||||
],
|
||||
"score_mode": "",
|
||||
"boost_mode": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"should": [
|
||||
필드별 매칭 조건에 따른 점수부여
|
||||
],
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"aggregation": {
|
||||
각종 aggregation 쿼리
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 개선 결과
|
||||
|
||||
포켓몬 키워드로 인해 발생한 슬로우쿼리가 모두 사라지게 되었습니다.
|
||||
|
||||
이 작업 이후, function_score 쿼리에서 1️⃣ 블록이 두 군데에 중복으로 filter가 있게 되었는데, 중복 쿼리 및 의미없는 depth가 생긴 쿼리들을 최적화하는 작업을 진행해 쿼리 가독성을 높이도록 했습니다. 이러한 개선 작업으로 쿼리 구조가 더 명확해지고, 쿼리 분석 및 변경 작업이 더욱 효율적으로 이루어질 수 있게 되었습니다.
|
||||
|
||||
## Painless 스크립트를 활용한 정렬 제거하기
|
||||
|
||||
### 현상
|
||||
|
||||
배민의 커머스 상품은 대형 셀러(GS25, GS the Fresh, 이마트에브리데이, CU 등)의 입점으로 셀러 * 지점수에 비례하여 상품 수가 증가합니다. 서비스 초기에는 약 1천만 건이었던 상품 수가 현재는 5천만 건을 넘어섰으며, 앞으로도 대형 셀러의 지속적인 입점으로 상품 수는 계속 증가할 전망입니다. 또한, 커머스검색개발파트에서 지원하는 API는 키워드 검색뿐만 아니라 상품 리스팅을 위한 API, 상품 목록 화면의 카테고리별 상품 수를 반환하는 aggregation API 등 다양한 기능을 제공하며, 이에 따라 성능 개선 작업도 꾸준히 진행되고 있습니다.
|
||||
|
||||
키워드 검색 API에서는 여전히 무거운 쿼리로 인해 레이턴시 지연 현상이 발생하고 있었습니다. 위에서 특정 사례를 중심으로 쿼리를 개선하는 작업을 진행했으나, 근본적인 원인을 파악하고 이를 해결하기 위한 추가적인 개선이 필요했습니다.
|
||||
|
||||
### 문제 원인 분석 및 해결
|
||||
|
||||
원인을 분석한 결과, 검색에서 셀러별로 [aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html)을 수행하고, 이를 그룹화된 형태로 정렬을 하기 위해 [top_hits aggregation](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-metrics-top-hits-aggregation.html)을 사용하는 과정에서 성능 차이가 발생하는 것을 확인했습니다. 특히, 정렬 과정에서 사용된 [Painless script](https://www.elastic.co/guide/en/elasticsearch/painless/current/painless-sort-context.html)가 근본적인 부하의 원인임을 알게 되었습니다. 이 스크립트는 ES 쿼리로 반환된 `_score` 값을 기반으로 점수를 역산하여 스케일링하고, 입력된 키워드와 매칭되는 점수를 계산하는 작업을 수행했습니다.
|
||||
추가로, 스크립트 내에서 계산하던 `keywordMatchingScore`, `ctrScore`, `recommendScore`와 같은 로직을 제거하고, 이를 쿼리 단계에서 처리할 수 있도록 수정하여 성능 개선을 이루었습니다.
|
||||
|
||||
```
|
||||
{
|
||||
"_script": {
|
||||
"script": {
|
||||
"source": """
|
||||
_score 역산하여 추출 후 점수 보정 연산한 키워드 매칭 점수
|
||||
def keywordMatchingScore = ...
|
||||
|
||||
검색 키워드에 대한 ctrFeature가 반영된 점수
|
||||
def ctrScore = ...
|
||||
|
||||
상품의 점수 필드를 위해 여러 값들의 수식을 적용하여 합산한 점수
|
||||
def recommendScore = ...
|
||||
|
||||
return keywordMatchingScore + ctrScore + recommendScore;
|
||||
""",
|
||||
"lang": "painless",
|
||||
"params": {
|
||||
"keyword": "우유"
|
||||
}
|
||||
},
|
||||
"type": "number",
|
||||
"order": "desc"
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
#### keywordMatchingScore
|
||||
|
||||
검색한 키워드에 문서 필드가 매칭되었을때 가중치를 부여하는 부분입니다. 이 점수를 전체 점수를 보정하기 위해서 `_score` 내에서 keywordMatchingScore를 추출해서 값을 역산하게 되었는데요. 아주 간단한 원리를 이용해서 분리했습니다.
|
||||
필드 productName, sellerName, shopName에 매칭될 점수가 productName = 10 , sellerName = 20, shopName = 30이라면 **점수보정연산**을 미리 적용한 뒤 쿼리에 적용하여 해결했습니다.
|
||||
|
||||
* 개선 전: (10 + 20 + 30) * 점수보정연산
|
||||
* 개선 후: 10 * 점수보정연산 + 20 * 점수보정연산 + 30 * 점수보정연산
|
||||
|
||||
```
|
||||
{
|
||||
"bool": {
|
||||
"should": [
|
||||
{
|
||||
"constant_score": {
|
||||
"filter": {
|
||||
필드 productName에 대한 조건 쿼리
|
||||
}
|
||||
},
|
||||
"boost": 10 * 점수보정연산
|
||||
},
|
||||
{
|
||||
"constant_score": {
|
||||
"filter": {
|
||||
필드 sellerName에 대한 조건 쿼리
|
||||
}
|
||||
},
|
||||
"boost": 20 * 점수보정연산
|
||||
},
|
||||
{
|
||||
"constant_score": {
|
||||
"filter": {
|
||||
필드 shopName에 대한 조건 쿼리
|
||||
}
|
||||
},
|
||||
"boost": 30 * 점수보정연산
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### CTR Score
|
||||
|
||||
검색한 키워드가 특정 키워드에 매칭되었을때 가산점을 부여하는 부분입니다. Painless 스크립트를 사용해 반복문으로 질의 키워드와 일치하는 키워드를 찾고 점수를 부여하는 방식으로 구현되었습니다. 의도한 대로 동작하긴 했지만, 반복문 처리로 조회 속도에 부정적인 영향을 미치는 문제가 있었습니다.
|
||||
|
||||
```
|
||||
샘플 데이터
|
||||
{
|
||||
"ctrFeatures":[
|
||||
{
|
||||
"ctrKeyword":"배추",
|
||||
"ctrScore":0.0127
|
||||
},
|
||||
{
|
||||
"ctrKeyword":"종가집",
|
||||
"ctrScore":0.7036
|
||||
},
|
||||
{
|
||||
"ctrKeyword":"김치",
|
||||
"ctrScore":0.4284
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
검색한 키워드와 일치하는 키워드에 ctrScore 를 반환하는 함수
|
||||
def getCtrScore(def ctrFeatures, def keyword) {
|
||||
if (ctrFeatures == null) {
|
||||
return 0.0;
|
||||
}
|
||||
return ctrFeatures.stream()
|
||||
.filter(feature -> feature.ctrKeyword.equals(keyword))
|
||||
.map(feature -> feature.ctrScore)
|
||||
.mapToDouble(Double::doubleValue)
|
||||
.max()
|
||||
.orElse(0.0);
|
||||
}
|
||||
```
|
||||
|
||||
위 문제를 해결하기 위해 Lucene에서 제공하는 Payload 값을 활용하는 방식을 도입했습니다. Payload는 특정 term에 추가로 저장할 수 있는 메타데이터를 의미합니다. ES는 Payload 값을 색인할 수 있도록 [Delimited payload token filter](https://www.elastic.co/guide/en/elasticsearch/reference/7.17/analysis-delimited-payload-tokenfilter.html)를 제공합니다.
|
||||
|
||||
> Payloads
|
||||
> A payload is user-defined binary data associated with a token position and stored as base64-encoded bytes.
|
||||
|
||||
아래와 같은 형태로 데이터를 색인하면서 Payload 값을 점수로 저장해두고, 검색한 키워드가 매칭될 경우 해당 점수를 부여하도록 구현했습니다. 다만, ES는 기본적으로 검색 시 Payload 값을 활용한 스코어링 기능을 제공하지 않습니다. 이를 해결하기 위해 커스텀 쿼리 플러그인을 구현하여 검색 시 Payload 값을 점수에 반영할 수 있도록 처리했습니다.
|
||||
|
||||
```
|
||||
"ctrScore": "배추|0.0127 종가집|0.7036 김치|0.4284"
|
||||
|
||||
ctrScore 점수 연산 쿼리
|
||||
{
|
||||
커스텀 쿼리 플러그인으로 정의한 쿼리 이름
|
||||
"woowa_payload_score": {
|
||||
"query": {
|
||||
Lucene의 PayloadScoreQuery는 SpanQuery를 파라미터로 전달받게 되어 있어 span_term 을 사용
|
||||
"span_term": {
|
||||
"ctrScore": {
|
||||
"value": "검색키워드"
|
||||
}
|
||||
}
|
||||
},
|
||||
"score_mode": "max",
|
||||
"decode_type": "float",
|
||||
"include_span_score": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Recommend Score
|
||||
|
||||
이 점수는 상품에 대한 정적인 데이터로, 추천팀에서 생성한 다양한 상품 랭킹 피처 값을 기반으로 합니다. 기존 스크립트 코드에서는 여러 필드의 값을 합산하거나 특정 수식을 적용했지만, 이를 색인 시점에 미리 계산하여 저장하도록 변경했습니다. 이후 검색 시에는 **function_score** 쿼리의 `field_value_factor`를 활용해 해당 점수를 반영하도록 구현했습니다.
|
||||
|
||||
쿼리 예시
|
||||
|
||||
```
|
||||
{
|
||||
"bool": {
|
||||
"should": [
|
||||
{
|
||||
"function_score": {
|
||||
"query": {
|
||||
"term": {
|
||||
"recommendScore.enable": {
|
||||
"value": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"functions": [
|
||||
{
|
||||
"filter": {
|
||||
"match_all": {}
|
||||
},
|
||||
"field_value_factor": {
|
||||
"field": "recommendScore 필드",
|
||||
"factor": 1,
|
||||
"missing": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"score_mode": "sum",
|
||||
"boost_mode": "replace"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
여기서도 마찬가지로, function_score 쿼리에 별도의 조건을 지정하지 않으면 match_all 쿼리가 적용됩니다. 이에 따라, 스코어가 있는 문서들만 대상으로 필터링을 수행하도록 설정했습니다.
|
||||
|
||||
이 과정을 통해 Painless 스크립트 쿼리를 제거하였으며, 최종 쿼리는 다음과 같은 형태가 되었습니다.
|
||||
|
||||
```
|
||||
{
|
||||
"from": 0,
|
||||
"size": N,
|
||||
"query": {
|
||||
"bool": {
|
||||
"must": [
|
||||
{
|
||||
"bool": {
|
||||
"must": [
|
||||
{
|
||||
"function_score": {
|
||||
"query": {
|
||||
"bool": {
|
||||
"filter": [
|
||||
상품상태와 관련된 매칭 조건, 키워드 매칭 조건 필터링
|
||||
]
|
||||
}
|
||||
},
|
||||
"functions": [
|
||||
부스팅 조건을 만족할때의 점수부여하는 쿼리들
|
||||
],
|
||||
"score_mode": "",
|
||||
"boost_mode": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
필드별 매칭 조건에 따른 점수부여
|
||||
"should": [
|
||||
keywordMatchingScore 점수 연산 쿼리
|
||||
{
|
||||
"constant_score": {
|
||||
"filter": {
|
||||
필드 A 에 대한 조건 쿼리
|
||||
}
|
||||
},
|
||||
"boost": 10 * 점수보정연산
|
||||
},
|
||||
|
||||
ctrScore 점수 연산 쿼리
|
||||
{
|
||||
"woowa_payload_score": {
|
||||
"query": {
|
||||
"span_term": {
|
||||
"ctrScore: {
|
||||
"value": "검색키워드"
|
||||
}
|
||||
}
|
||||
},
|
||||
"score_mode": "max",
|
||||
"decode_type": "float",
|
||||
"include_span_score": false
|
||||
}
|
||||
},
|
||||
|
||||
recommendScore 점수 연산 쿼리
|
||||
{
|
||||
"function_score": {
|
||||
"query": {
|
||||
"term": {
|
||||
"recommendScore.enable": {
|
||||
"value": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"functions": [
|
||||
{
|
||||
"filter": {
|
||||
"match_all": {}
|
||||
},
|
||||
"field_value_factor": {
|
||||
"field": "recommendScore 필드",
|
||||
"factor": 1,
|
||||
"missing": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"score_mode": "sum",
|
||||
"boost_mode": "replace"
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
],
|
||||
(이하 생략)
|
||||
```
|
||||
|
||||
### 개선 결과
|
||||
|
||||
* aggregation 수행 속도가 2배 이상 향상되었습니다.
|
||||
|
||||
* 배포 전후 API의 평균 레이턴시 비교한 결과, p99.9와 p99.99의 응답 속도가 20% 개선되었습니다.
|
||||
|
||||
* API 응답 시간이 0.7초 이상인 슬로우 쿼리 횟수가 절반으로 감소했습니다.
|
||||
|
||||
## track_scores: true → false 로 변경, 분석되는 term 개수에 따른 쿼리 최적화
|
||||
|
||||
### 현상
|
||||
|
||||
Painless 정렬 스크립트를 제거한 이후, 레이턴시가 크게 개선된 것을 확인한 뒤 ES 데이터 노드의 스펙을 기존의 절반 수준으로 낮춰보았습니다. 그러나 트래픽이 집중되는 시간대에는 줄어든 코어 개수로 인해 데이터 노드의 CPU 사용률이 85%까지 치솟는 문제가 발생했습니다. 안정성을 확보하기 위해 데이터 노드 스펙을 다시 이전 스펙으로 되돌렸고, 슬로우 쿼리가 완전히 제거되지 않아 추가적인 쿼리 개선 포인트를 찾아내야만 했습니다.
|
||||
|
||||
### 문제 원인 분석 및 해결
|
||||
|
||||
#### track_scores: true → false 로 변경
|
||||
|
||||
기존 Painless 정렬 스크립트에서는 `_score` 값을 사용하여 스코어를 역산하여 추가적인 점수를 부여하는 형태로 구성되어 있었습니다. Painless 스크립트에서 `_score`를 참조하려면 `track_scores` 값을 `true`로 설정해야만 참조가 가능합니다. 이 track_scores 값은 검색 성능에 아주 큰 영향을 주는 부분이었습니다.
|
||||
|
||||
* track_scores : ES 검색 쿼리에서 각 문서의 관련성 점수(`_score`)를 계산하고 저장할지 여부를 결정하는 설정
|
||||
* true / false 차이
|
||||
* true: 모든 문서에 대해 점수를 계산하고 저장
|
||||
* false: 점수를 계산할 필요가 있는 경우에만 계산, 상위 N개에 대한 문서를 찾기 위해 ES에서는 효율적인 알고리즘을 사용하여 필요한 문서만을 선별하여 계산함.
|
||||
|
||||
Painless 정렬 스크립트는 이제 더 이상 사용하지 않고 있기에 `track_scores` 값을 `false` 로 변경하였습니다.
|
||||
|
||||
#### 분석되는 term 개수에 따른 쿼리 최적화
|
||||
|
||||
슬로우 쿼리를 계속해서 분석하다보니, 특정 일부 키워드들에 대해서 오래 걸리는 현상이 발견되었습니다. 쿼리를 분석하다보니 성능에 영향을 주는 부분은 키워드의 부스팅 조건을 만족할때 추가점수를 부여하도록 하는 쿼리에서 발생하는 것을 확인했습니다. 부스팅 조건을 확인할 때 입력한 검색어와 매칭되는 필드의 순서 보정을 위하여 [match_phrase](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query-phrase.html)쿼리를 사용하고 있는데요.
|
||||
|
||||
예) `피자치즈` 키워드로 검색시, 피자 / 치즈로 형태소분석되지만 문서에는 피자 – 치즈 순서로 색인되어 있는 문서만 적용되어야 함. 치즈 – 피자에는 적용되면 안 됨
|
||||
|
||||
단일 term일 경우 match_phrase 쿼리가 아니라 match 쿼리로도 요구사항을 만족할 수 있기 때문에 분석된 term에 따라 쿼리를 변경하도록 쿼리를 분기하였습니다. 커머스 검색 API 는 API 호출 흐름상 검색 쿼리를 수행하기 전 검색어에 대해 `_analyze` 를 수행하고 있기에 term 개수를 미리 확인할 수 있었습니다.
|
||||
|
||||
```
|
||||
if (tokens.size() == 1) {
|
||||
return QueryBuilders.matchQuery(fieldName, keyword);
|
||||
} else {
|
||||
return QueryBuilders.matchPhraseQuery(fieldName, keyword)
|
||||
.slop(0);
|
||||
}
|
||||
```
|
||||
|
||||
### 개선 결과
|
||||
|
||||
* 검색 API 레이턴시 2배 개선되었고, 연초부터 모니터링하던 응답시간 0.7초 이상 슬로우쿼리가 모두 제거되었습니다.
|
||||
* 피크 시간대 기준으로 ES 데이터노드 CPU 사용량이 10% 감소하였습니다.
|
||||
|
||||
## analyzer 라이브러리화
|
||||
|
||||
### 현상
|
||||
|
||||
커머스검색 시스템 내에서 검색 API, admin, batch 동작 중 키워드 분석에 대한 형태소 분석 과정이 존재합니다. 이를 ES의 analyze API로 이용하고 있었는데 admin, batch에서는 큰 문제가 발생하진 않지만 검색 API의 경우 아래와 같은 현상이 있었습니다.
|
||||
|
||||
* 검색 API 수행 중 1회 요청에 총 2회의 analyze API 요청이 발생하게 되는데, 이때의 네트워크 통신 비용과 ES 부하가 생기게 됩니다. 또한, 짧은 timeout으로 인한 실패하는 경우도 발생했습니다.
|
||||
* 색인이 다량 발생하는 시점에 ES 내부에서 segment merge가 발생할 경우 analyze 요청이 reject되는 상황이 생겼습니다.
|
||||
|
||||
### 문제 원인 분석 및 해결
|
||||
|
||||
형태소분석기는 ES의 plugin 형태로 적용이 되어있습니다. plugin의 내용을 라이브러리화하고, 이를 사내 nexus에 업로드하여 application(검색 API , admin, batch)에서 분석하도록 코드를 심었습니다. ES 인덱스의 분석기에 적용된 char filter, tokenizer, filter 등 모든 정보를 analyzer 라이브러리에서도 동일하게 적용되도록 하여 `_analyzer` API와 analyzer 라이브러리의 동작이 일치하도록 구현했습니다.
|
||||
|
||||
### 개선 결과
|
||||
|
||||
* ES analyze API 호출 없이(네트워크 호출 없이) 내부 라이브러리로 analyze를 할 수 있게 되었습니다.
|
||||
* ES 에서 analyze 요청이 다량 발생했을 때 rejected 현상이 더 이상 발생하지 않도록 개선했습니다.
|
||||
* ES coordinate node (searcher) CPU 사용률이 max 기준 20% → 13%으로 개선되었습니다.
|
||||
* analyze rejected 현상이 사라졌습니다.
|
||||
* analyze timeout 현상 제거 : timeout 발생시 error 로그로 남던 케이스들이 배포 이후 완전히 사라졌습니다.
|
||||
|
||||
## 개선 후 결과
|
||||
|
||||
위 5가지 작업을 통해 커머스 검색 API의 레이턴시 매트릭이 개선되었습니다. 특히, p99.9와 p99.99 지표가 눈에 띄게 향상되었습니다.
|
||||
|
||||
* 개선 전(2024년 1월)
|
||||
* 개선 후(2024년 10월)
|
||||
|
||||
## 맺으며
|
||||
|
||||
소개한 다섯 가지 API 성능 개선 사례는 Elasticsearch 공식 문서에서 제공하는 기본적인 원칙과 내용을 바탕으로 이루어졌습니다. 단순히 "쿼리는 ES가 알아서 최적화해 주겠지"라는 생각에서 벗어나, 원리를 이해하고 직접 개선한 결과였습니다. 이번 글에서는 검색 쿼리와 관련된 성능 개선 작업에 초점을 맞췄지만, 색인 구조와 관련한 성능 최적화 작업도 병행하고 있습니다.
|
||||
|
||||
커머스검색개발파트에서는 배달의민족 커머스의 성장에 발맞추어 색인량과 검색량의 증가에 따른 다양한 개선 포인트를 찾아내고 해결하는 과정에서 즐거움을 느끼고 있습니다. 앞으로도 커머스에서 더 편리한 검색 경험을 제공하도록 노력하겠습니다.
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
<!-- 출처: https://techblog.woowahan.com/22396/ · 우아한형제들 기술블로그 · 참고용 원문 사본 -->
|
||||
|
||||
# 배차 정확도를 높이는 실거리 시스템 구축하기: OSRM, Kafka, 그리고 Redis
|
||||
|
||||
## 들어가며
|
||||
|
||||
배민커넥트 배차시스템에서는, 배차에 활용되는 거리를 단순한 직선거리에서 실제 경로 기반의 실거리로 고도화하는 작업을 진행했습니다. 고도화 과정에서 이벤트 드리븐 아키텍처를 도입하고 대량의 트래픽을 효율적으로 처리하기 위해 Redis를 다방면으로 활용했습니다. 이 글은 배차시스템팀에서 얻은 경험과 지식을 정리한 것으로, 유사한 서비스를 개발하시는 분들께 많은 도움이 되는 글이 되었으면 좋겠습니다.
|
||||
|
||||
## 배차시스템이란?
|
||||
|
||||
### 배차시스템 소개
|
||||
|
||||
배차시스템은 배달을 고객에게 전달할 수 있도록 적합한 라이더를 찾아 배정하는 시스템입니다. 따라서 주문과 라이더의 상태, 그리고 특성을 잘 고려해 빠르고 효율적으로 배차하는 것이 중요합니다.
|
||||
|
||||
5만 개의 배달이 존재하고 5만 명의 라이더가 활동 중이라고 가정했을 때, 단순히 경우의 수를 계산하면 배차시스템은 25억 건의 계산을 수행해야 합니다. 그러나 현실적으로 매 배차 사이클마다 25억 건을 모두 계산하는 것은 불가능하므로, 이를 최적화하는 과정을 거치게 됩니다. 따라서 배차시스템은 다음과 같은 과정을 통해 배차를 최적화합니다.
|
||||
|
||||
### 라이더의 상태를 수치화하는 방법
|
||||
|
||||
앞서 소개드린 배차 과정에서는 '비용 계산'이라는 용어가 등장합니다. 배차 최적화 과정은 배달과 라이더의 매칭 문제를 수리 최적화로 해결하기 때문에, 현재 라이더의 상태와 신규 배차를 반영한 상태 변화를 수치적으로 모델링해야 합니다. 배달과 라이더의 특성에는 다양한 속성이 있지만, 그중 가장 중요한 속성은 다음과 같습니다:
|
||||
|
||||
- 라이더의 속도
|
||||
- 라이더가 이미 수행 중인 배달들의 거리 및 소요 시간
|
||||
- 라이더가 새로 배차받을 배달들의 예상 거리 및 소요 시간
|
||||
|
||||
이 세 가지 속성을 종합해보면, 결국 라이더가 배달을 어떤 순서로, 얼마나 빠르게 수행하는지가 배차에서 매우 중요한 요소가 됩니다.
|
||||
|
||||
다음은 오토바이 라이더와 자전거 라이더가 동일한 경로를 수행하는 예시입니다.
|
||||
|
||||
A와 B 두 개의 배달 건을 위와 같은 순서로 평균 시속 30km로 주행하는 오토바이 라이더가 수행한다면, 주행 시간만 약 4분 36초가 걸립니다. 같은 상황에서 평균 시속 5km인 자전거 라이더가 수행한다면 약 13분 48초가 걸립니다. 그러나 오토바이 라이더가 더 빠르게 배달을 수행할 수 있다고 해서 무조건 오토바이 라이더에게 배차하는 것이 좋은 것은 아닙니다. 배달이 많은 상황에서는 오토바이 라이더와 자전거 라이더를 모두 활용해, 시스템 입장에서 최대한 효율적인 조합을 찾아 배차해야 합니다. 따라서 최적화 알고리즘의 입력으로 활용하기 위해, 라이더가 수행할 것으로 예상되는 배달의 진행 순서를 시퀀스화해 수치화 작업을 진행하게 됩니다.
|
||||
|
||||
이 글의 주제가 되는 `실거리`는, 이러한 수치화 작업의 성능과 정확도를 좌우하는 중요한 요소입니다. 이어서 배차시스템의 경로 문제를 설명하면서, 실거리의 중요성에 대해 자세히 이야기해보겠습니다.
|
||||
|
||||
## 왜 실거리가 중요할까?
|
||||
|
||||
### 배차시스템의 경로 문제
|
||||
|
||||
배달 진행 순서의 시퀀스, 다시 말해 라이더가 배달을 수행하기 위한 위치들의 순서를 `경로`라고 부릅니다.
|
||||
|
||||
여기서 '배달을 수행하기 위한 위치'란 배달의 픽업지와 전달지를 의미합니다. 그렇다면, 한 명의 라이더가 배달을 수행하는 과정에서 가능한 경로의 경우의 수는 몇이나 될까요? 배달의 진행 상태와 배달 개수에 따라 가능한 경로는 매우 다양하게 달라집니다.
|
||||
|
||||
경로를 계산하기 위해 그래프를 한 번 그려보면, 꽤 많은 간선이 만들어집니다. 라이더에게 할당된 신규 배달이 N개라고 했을 때, 정점과 간선의 수는 다음과 같습니다.
|
||||
|
||||
- 정점: 픽업지와 전달지를 모두 거쳐야 하므로 2N개
|
||||
- 간선: C(2N, 2)개
|
||||
|
||||
이때 그려본 것은 하나의 조합(라이더:매칭=1:1)에 대한 그래프에 불과합니다. 실제 최적화 과정에서는 한 명의 라이더가 수행할 수 있는 여러 배달 조합(라이더:매칭=1:N)에 대해서도 모두 수치화를 해보아야 합니다. 즉, 라이더당 배차 가능한 모든 배달 조합에 대해 경로를 계산하고 수치화하여 최적화 알고리즘에 전달해야 합니다. 그리고 이 수치화를 위해서는 각 간선에 대한 거리값을 반드시 알아야 합니다.
|
||||
|
||||
### 거리는 예민한 문제이다
|
||||
|
||||
배달 도메인에서 거리는 예민한 문제입니다. 라이더분들은 짧은 시간과 짧은 이동 거리로 최대한 많은 배달을 수행해야 하기 때문입니다. 다만 한 가지 강조할 것은, 배차 경로에서의 거리는 실제 배달료를 책정하는 데 사용되는 수행 거리는 아니라는 점입니다.
|
||||
|
||||
> **참고: 배달료 책정이나 배민커넥트 앱상에서 활용되는 거리 계산에는 상용 내비게이션이 사용됩니다.**
|
||||
|
||||
그럼에도 거리는 중요합니다. 배차 최적화를 하려면, 거리값이 정확하고 일관돼야 합니다. 거리를 기반으로 효율적인 경로를 만들어보고, 그 경로를 기반으로 한 수치들이 최적화에 사용됩니다. 그러므로 단순히 직선거리로 셈하기보다는 가능한 현실 세계에 가까운 거리를 사용할 수 있어야 합니다.
|
||||
|
||||
### 작은 변화가 배차시스템 전체의 성능을 좌우한다
|
||||
|
||||
두 지점 간의 거리를 계산하는 일은 얼핏 단순해 보일 수 있습니다. 하지만 수많은 경우의 수에 따른 경로를 고려해야 하기 때문에, 작은 변화도 배차시스템 성능에 큰 영향을 미칠 수 있습니다. 마치 나비효과와도 같죠. 배차해야 할 배달이 많아질수록 이러한 영향은 더욱 커집니다. 이처럼 직선거리에서 실거리로의 전환은 단순한 변화가 아니라, 배차 전반의 효율성과 정확도에 영향을 미치는 중요한 변화가 될 수 있습니다.
|
||||
|
||||
지금까지 배차시스템에서 실거리가 왜 중요한지 설명드렸습니다. 배차시스템은 한 번의 배차 과정에서 필요한 모든 간선의 실거리를 계산합니다. 이 실거리는 배차의 정확도를 결정짓는 중요한 요소입니다. 하지만 작은 변화만으로도 시스템 성능이 쉽게 저하될 수 있는 민감한 부분이기도 합니다. 따라서 배차의 정확도와 성능을 모두 고려하기 위해 실거리 시스템을 도입했던 과정을 소개합니다.
|
||||
|
||||
## 배차시스템에서 실거리를 계산하는 방법
|
||||
|
||||
### 한 번의 배차에는 몇 번의 실거리 계산이 필요할까?
|
||||
|
||||
배차시스템은 라이더분들에게 배차 요청을 하기 위해 주말 피크 시간 기준 분당 약 15만~20만 건의 경로를 계산합니다. 만약 모든 라이더가 무배차 상태이고, 1건의 배달을 배차한다면 각 경로마다 1건의 배달 위치 간 실거리가 필요합니다.
|
||||
|
||||
- n=1, C(2, 2) = 1
|
||||
|
||||
알뜰배달 서비스가 론칭된 이후부터는 라이더가 1개 이상의 배달을 한 번에 수행할 수 있게 되었습니다. 예를 들어, 라이더가 이미 1건의 배달을 수행 중인 상태에서 시스템이 1건을 추가 배차하는 상황이나, 한 번에 2건을 배차하는 상황이라면 6건의 실거리가 필요하고, 한 번에 3건을 배차하는 상황이라면 15건의 실거리가 필요합니다.
|
||||
|
||||
- n=2, C(4, 2) = 6
|
||||
- n=3, C(6, 2) = 15
|
||||
|
||||
물론, 반대 방향의 경우도 고려하면 계산해야 하는 거리는 2배가 됩니다. 실제로 A-B 거리와 B-A 거리는 다르지만, 이 포스팅에서는 동일하다고 가정하고 해당 내용은 다루지 않겠습니다.
|
||||
|
||||
만약 시스템이 모든 라이더를 대상으로 2건의 배달 경로를 계산해야 하고 주말 피크 기준 분당 20만 건의 경로를 계산해야 한다면, 시스템은 분당 120만 건의 실거리 계산을 수행해야 합니다. 이는 초당 약 2만 건으로 시스템 성능의 중요 지표인 TPS(Transaction Per Second) 기준으로도 매우 높은 수준입니다.
|
||||
|
||||
배달들의 위치 사이의 거리를 직선거리가 아닌 실제 거리로 반영하려면 내비게이션이 필요합니다. 실거리 계산이 분당 수백만 건에 이르는 만큼, 이를 뒷받침할 수 있는 내비게이션 시스템이 필요했습니다.
|
||||
|
||||
### 어떤 내비게이션을 사용할까?
|
||||
|
||||
배달의민족의 딜리버리플랫폼은 여러 기능에서 상용 내비게이션을 활용합니다. 예를 들어, 배달료 계산이나 배달 수행 중 이동 경로 추천 기능 등이 있습니다. 하지만 초당 2만 건에 달하는 경로 계산을 위해 API 호출마다 비용이 발생하는 상용 내비게이션을 사용하는 것은 현실적으로 어렵습니다. 게다가 배차를 추천받은 라이더분이 바로 수락하는 경우, 한 번 계산한 거리를 오랜 시간 재활용하기도 쉽지 않습니다.
|
||||
|
||||
이번 배차 실거리 프로젝트는 배차 정확도와 시스템 성능을 동시에 높이기 위해 기획되었습니다. 단순히 직선거리를 사용하는 대신, 실제 경로를 고려한 실존 거리를 활용하면 기존보다 더욱 현실적인 수행 거리와 시간을 기반으로 배차 최적화를 할 수 있다는 가정에서 시작되었습니다.
|
||||
|
||||
비용 문제를 고려하여 상용 내비게이션 대신, 우리나라 지리 정보가 잘 반영되고 최신화와 업데이트가 빈번하고, 오픈소스 지도 API 중 가장 성능이 좋다고 알려진(Open Source Routing Machine, [Project OSRM](https://project-osrm.org/))을 선택하게 되었습니다. OSRM은 공개된 지도 데이터 정보를 기반으로 경로를 제공하는 엔진입니다. 공통시스템팀에서 여러 지도 서비스를 필요에 따라 제공하기 위해 운영하는 지도 미디에이터 서비스 덕분에, 안전하고 편리하게 API 방식으로 OSRM 실거리를 받아올 수 있었습니다.
|
||||
|
||||
> 앞서 언급했지만 중요한 내용이기에 다시 한번 강조드립니다.
|
||||
> **배달료 책정이나 배민커넥트 앱상에서 활용되는 거리 계산에는 상용 내비게이션이 사용됩니다.**
|
||||
|
||||
### 거리를 저장해서 활용해야만 하는 이유?
|
||||
|
||||
오픈소스 지도를 활용하더라도 여전히 초당 2만 TPS 수준의 부하는 신중히 고민해야 합니다. 현실적인 배차 상황에서는 여러 라이더에게 다양한 배달 조합이 고려되어 경로가 계산됩니다.
|
||||
|
||||
또한, 한 번의 배차로 라이더분이 바로 수락하여 배달이 수행되는 경우가 드물어, 한 번 경로가 계산된 배달도 평균 3~5회 정도 다른 조합으로 재계산되는 경우가 많습니다. 이처럼 매번 거리를 재계산하면 중복된 요청이 다수 발생하고, 중복 요청까지 포함된 초당 2만 건의 TPS를 OSRM 서버가 모두 처리해야 하므로 OSRM 서버에 과도한 부하가 발생하게 됩니다.
|
||||
|
||||
앞서 살펴본 배차 예시에서 활용된 모든 배달 간 실거리를 미리 구하면 효율적인 구조로 표현할 수 있습니다. 실제 배차 환경에서는 가까운 거리의 배달들이 짧은 시간 내에 여러 번 반복되기 때문에, 유사한 조합의 배차가 자연스럽게 여러 차례 발생할 수밖에 없습니다. 그리고 유사한 조합의 배차가 반복된다면, 동일한 거리를 재사용할 가능성이 높아집니다. 따라서 매번 필요한 실거리를 실시간으로 모두 계산하는 데 리소스를 쏟기보다는, 보다 효율적인 방식으로 미리 계산한 거리 데이터를 저장하고 재활용하는 방안을 고민하게 되었습니다.
|
||||
|
||||
이러한 이유로 시스템 아키텍처를 설계할 때, 거리 데이터의 재활용을 적극 고려하여 진행했습니다.
|
||||
|
||||
## 이벤트 드리븐 아키텍처 기반 실거리 데이터 관리
|
||||
|
||||
### 배달의 라이프 사이클에 따라 거리를 저장해보자
|
||||
|
||||
배달의민족의 딜리버리플랫폼에서 수행되는 배달의 라이프 사이클은 다음과 같습니다.
|
||||
|
||||
또한 배달의 상태와 직접적으로 연관되지는 않지만, 전달 위치 변경이나 조리 완료 등 배달 수행에 중요한 정보가 변경되는 상황에서도 이벤트가 발행됩니다. 배달 처리 시스템은 이러한 다양한 상태 변경 사항을 Kafka를 통해 이벤트로 발행합니다.
|
||||
|
||||
하지만 거리 계산을 위해 모든 배달 상태 변경 이벤트를 수신할 필요는 없습니다. 따라서 거리 계산에 필요한 배달 생성, 위치 변경, 배달 완료, 배달 취소 이벤트만을 수신하여 도메인 로직을 처리합니다.
|
||||
|
||||
각 이벤트에서 처리해야 할 내용을 간단히 정리하면 다음과 같습니다:
|
||||
|
||||
| 배달이벤트 | 거리 계산 |
|
||||
|-----------|---------|
|
||||
| 배달 생성 (DeliveryCreated) | • 픽업지 → 전달지 거리 계산<br>• 픽업지 → 지역 내 모든 픽업지, 전달지 거리 계산<br>• 전달지 → 지역 내 모든 픽업지, 전달지 거리 계산 |
|
||||
| 픽업지 변경 (PickupLocationChanged) | • 변경된 픽업지 → 전달지 거리 계산<br>• 변경된 픽업지 → 지역 내 모든 픽업지, 전달지 거리 계산 |
|
||||
| 전달지 변경 (DeliveryLocationChanged) | • 변경된 전달지 → 픽업지 거리 계산<br>• 전달지 → 지역 내 모든 픽업지, 전달지 거리 계산 |
|
||||
| 배달 완료 (DeliveryDelivered) / 배달취소 (DeliveryCanceled) | • 지역 내 동일 위치가 더이상 존재하지 않는 경우<br> • 픽업지 → 지역 내 모든 픽업지, 전달지 거리 제거<br> • 전달지 → 지역 내 모든 픽업지, 전달지 거리 제거 |
|
||||
|
||||
위 표에 정리한 내용을 각 이벤트 프로세서를 나누어 수행하도록 `EventProcessorFactory`를 정의합니다.
|
||||
|
||||
```java
|
||||
public class EventProcessorFactory {
|
||||
public EventProcessor create(Event<?> event) {
|
||||
switch (event.getEventType()) {
|
||||
case DeliveryCreated.EVENT_TYPE:
|
||||
return new DeliveryCreatedProcessor(DeliveryCreatedSpec.from(event), ...);
|
||||
case PickupLocationChanged.EVENT_TYPE:
|
||||
return new PickupLocationChangedProcessor(PickupLocationChangedSpec.from(event), ...);
|
||||
case DeliveryLocationChanged.EVENT_TYPE:
|
||||
return new DeliveryLocationChangedProcessor(DeliveryLocationChangedSpec.from(event), ...);
|
||||
case DeliveryDelivered.EVENT_TYPE:
|
||||
return new CompletedDeliveryProcessor(DeliveryStatusChangedSpec.from(event), ...);
|
||||
case DeliveryCanceled.EVENT_TYPE:
|
||||
return new CanceledDeliveryProcessor(DeliveryStatusChangedSpec.from(event), ...);
|
||||
default:
|
||||
throw new IllegalArgumentException("...");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
이벤트는 Kafka를 통해 발행되므로, Kafka 컨슈머가 각 이벤트를 수신하여 처리합니다. 다음은 각 이벤트에 따라 `EventProcessorFactory`를 사용해 적절한 이벤트 프로세서를 반환하고, 로직을 수행하는 예제 코드입니다.
|
||||
|
||||
```java
|
||||
public class EventConsumer {
|
||||
private static final List<String> SUPPORTED_EVENT_TYPES = List.of(...);
|
||||
@KafkaListener(topics = "...", groupId = "...", containerFactory = ...)
|
||||
public void consume(Message<Event<?>> message, Acknowledgment acknowledgment) {
|
||||
if (!SUPPORTED_EVENT_TYPES.contains(event.getEventType())) {
|
||||
acknowledgment.acknowledge();
|
||||
return;
|
||||
}
|
||||
EventProcessor processor = processorFactory.create(event);
|
||||
processor.execute();
|
||||
acknowledgment.acknowledge();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
이와 같이 발행되는 모든 이벤트를 무작정 수신하지 않고, 프로세서가 정의된 이벤트 타입, 즉 컨슈머 도메인의 관심사에 해당하는 이벤트만 처리하도록 명확히 구분할 수 있습니다. 또한, 관심 없는 이벤트는 조기에 리턴하여 처리하지 않음으로써 성능 향상에도 기여할 수 있습니다.
|
||||
|
||||
### Kafka 스트림 리파티션
|
||||
|
||||
배달 처리 후에는 배달 ID가 키로 구성된 이벤트가 발행됩니다. 만약 배달 이벤트를 실거리 컨슈머가 그대로 받아 처리한다면 어떤 문제가 발생할까요?
|
||||
|
||||
예를 들어, 하나의 지역에서 여러 배달이 동시에 생성된다면, 어떤 이벤트는 처리가 완료되었음에도 결과가 저장되지 못할 수도 있습니다. 즉, 나중에 들어온 배달 이벤트로 트리거된 작업이 직전에 들어온 배달 데이터를 포함하지 못하는 상황이 발생할 수 있는 것입니다.
|
||||
|
||||
Kafka는 기본적으로 각 파티션에 들어오는 이벤트를 오프셋 기반으로 처리하기 때문에, 동일 파티션 내 이벤트는 순차로 처리됩니다. 배달 이벤트를 지역별로 순차 처리하려면, 이벤트 키를 배달 ID가 아닌 배달이 속한 지역 ID로 변경해야 합니다. 이에 따라 이벤트 처리 기준을 변경하는 리파티션 작업을 자체적으로 수행하여, 지역별 배달 이벤트가 순서를 유지하며 처리되도록 재발행했습니다.
|
||||
|
||||
리파티션된 이벤트를 처리하면, 배달 생성, 픽업 완료, 배달 취소와 같은 이벤트들이 순서가 꼬이지 않고 처리됩니다. 이를 통해 배달의 라이프사이클에 맞춰 지역별 거리 계산과 데이터 제거가 정확하게 이루어질 수 있습니다.
|
||||
|
||||
## 실거리는 어떻게 효율적으로 저장할까
|
||||
|
||||
### 실거리 저장소에서의 제약 사항
|
||||
|
||||
실거리는 배달의 라이프사이클에 따라 단기간 활용되는 데이터이기 때문에, 빠른 읽기/쓰기가 가능한 인메모리 저장소인 Redis(AWS ElastiCache Redis)를 저장소로 사용하고 있습니다. 거대한 트래픽의 실거리 문제를 해결할 때 큰 제약 사항은 Redis의 저장 용량과 네트워크 대역폭이었습니다. 이를 극복하기 위해 자료 구조와 데이터 형식 등을 다양하게 변경하며 여러 시행착오를 겪었습니다.
|
||||
|
||||
### 지역별로 실거리 그래프를 관리한다면
|
||||
|
||||
배차시스템에서는 동일한 지역에 속한 배달들끼리 묶어 배차를 수행합니다. 따라서 서로 묶일 수 있는 배달들 간의 실거리만 모아 관리하는 것이 효율적입니다. 예를 들어, 종로구에 있는 가게와 송파구에 있는 가게 간의 거리를 계산할 필요는 없습니다. 그렇다면 아래와 같이, 각 지역별로 실거리 그래프를 저장하는 방법은 어떨까요?
|
||||
|
||||
위 방식은 인터페이스가 간단하고 직관적이라는 장점이 있습니다. 다만, 한 지역 내 모든 실거리를 하나의 데이터로 저장하려면 다음 세 가지 정보를 모두 포함할 수 있는 구조화된 형식이 필요합니다.
|
||||
|
||||
1. 첫 번째 지점의 좌표 (위도, 경도)
|
||||
2. 두 번째 지점의 좌표 (위도, 경도)
|
||||
3. 두 지점 간의 실거리 값
|
||||
|
||||
위 정보를 하나의 JSON 데이터로 표현해보면 아래와 같습니다. 단순한 인터페이스를 위하여 Redis String에 지역 ID를 key로, 아래 실거리 JSON 데이터를 value로 저장하는 것이 초기의 계획이었습니다.
|
||||
|
||||
```
|
||||
Key: "region:1234567"
|
||||
Value:
|
||||
{
|
||||
"distanceGraph": {
|
||||
"37.479482,127.135229-37.511794,127.101274": 5612,
|
||||
"37.479482,127.135229-37.496764,127.133687": 2311,
|
||||
"37.496764,127.133687-37.511794,127.101274": 3973,
|
||||
"37.479482,127.135229-37.480794,127.144663": 1014,
|
||||
"37.480794,127.144663-37.490578,127.113274": 3570,
|
||||
"37.480794,127.144663-37.511794,127.101274": 6180,
|
||||
"37.490578,127.113274-37.511794,127.101274": 3102,
|
||||
"37.490578,127.113274-37.496764,127.133687": 2312,
|
||||
"37.479482,127.135229-37.490578,127.113274": 2755,
|
||||
"37.480794,127.144663-37.496764,127.133687": 2426
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 실거리 그래프의 단점과 네트워크 대역폭의 관계
|
||||
|
||||
다만, 이 구조는 데이터가 커질수록 치명적인 단점이 드러납니다. 특정 실거리 하나만 조회하거나 갱신하더라도, 해당 지역에 저장된 모든 데이터를 한꺼번에 가져와 수정한 뒤 다시 저장해야 하기 때문입니다.
|
||||
|
||||
매번 전체 데이터를 주고받다 보니, 데이터가 클수록 네트워크 대역폭 초과 위험이 커집니다. 네트워크 대역폭이 초과되면 커넥션 장애와 부하가 발생해 시스템 다운으로 이어질 수 있습니다.
|
||||
|
||||
여기서 이야기하는 네트워크 대역폭은, ElastiCache 노드가 네트워크를 통해 초당 전송할 수 있는 최대 데이터 용량을 의미합니다. [AWS ElastiCache 사용자 가이드 문서](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/CacheNodes.SupportedTypes.html)에서 다음과 같이 노드 타입별 네트워크 대역폭 정보를 확인할 수 있습니다. Baseline bandwidth은 항상 보장되는 최소 처리량을, Burst bandwidth은 순간적으로 사용할 수 있는 최대 처리량을 의미합니다.
|
||||
|
||||
| Instance type | Baseline bandwidth (Gbps) | Burst bandwidth (Gbps) |
|
||||
|---------------|---------------------------|----------------------|
|
||||
| cache.r6g.large | 0.75 | 10.0 |
|
||||
| cache.r6g.xlarge | 1.25 | 10.0 |
|
||||
| cache.r6g.2xlarge | 2.5 | 10.0 |
|
||||
|
||||
대역폭 사용량은 CloudWatch에서 제공하는 아래 지표로 모니터링이 가능합니다.
|
||||
|
||||
- NetworkBandwidthInAllowanceExceeded
|
||||
- NetworkBandwidthOutAllowanceExceeded
|
||||
|
||||
### 실거리 그래프 관리를 효율화하기 위한 시도 1. 데이터 압축
|
||||
|
||||
실거리 그래프의 비효율 문제를 해결하고자 가장 먼저 데이터 압축을 시도했습니다.
|
||||
|
||||
Spring Data Redis에서는 `RedisSerializer`를 직접 구현하여 원하는 압축 알고리즘을 사용할 수 있습니다. 이를 활용해 `ZstdRedisSerializer`를 주입한 `RedisTemplate`을 생성하면 됩니다. 압축 알고리즘으로는 높은 압축률과 빠른 처리 속도를 제공하는 효율적인 알고리즘인 Zstd(Zstandard)를 사용했습니다.
|
||||
|
||||
```java
|
||||
public static <T> RedisTemplate<String, T> zstdRedisTemplate(RedisConnectionFactory connectionFactory, Class<T> clazz) {
|
||||
RedisSerializer<T> serializer = new ZstdRedisSerializer<>(jackson2JsonRedisSerializer(clazz));
|
||||
return objectRedisTemplate(connectionFactory, serializer);
|
||||
}
|
||||
```
|
||||
|
||||
하나의 지역에 1000개의 서로 다른 좌표 간 모든 실거리를 저장할 경우, JSON 데이터 원본 크기는 약 24MB였으며, 압축한 크기는 3MB였습니다. 만약 최대 대역폭이 10Gbps라고 했을 때, 3MB 크기로는 초당 3400회의 조회도 버티기 어렵습니다. 압축률이 높더라도 원본 데이터가 워낙 커서 압축만으로는 대역폭 문제를 해결할 수 없었습니다.
|
||||
|
||||
또한, 문자열은 숫자에 비해 메모리 사용량이 많아 소수점 생략이나 구분자 축소 등 형식 변경을 시도했으나 효과는 미미했습니다. 데이터 대부분이 숫자임에도 불구하고, 결국 Redis에 String 타입으로 저장되기 때문입니다. 따라서, 실거리 그래프를 하나의 문자열로 표현하는 구조에서는 더 이상의 개선이 어렵다고 판단했습니다.
|
||||
|
||||
### 실거리 그래프 관리를 효율화하기 위한 시도 2. Redis 자료구조 활용
|
||||
|
||||
데이터 타입이나 압축보다도 자료구조 자체의 변경이 필요했습니다. 지역별로 실거리 데이터를 관리하는 콘셉트는 유지하되, 데이터를 단일 String으로 압축해 사용하는 방식 대신 Redis Hash 자료구조를 활용했습니다. Redis Hash 자료구조로 지역별 실거리 관리 인터페이스를 어느 정도 유지하면서도 읽기와 쓰기 성능을 크게 개선할 수 있었습니다.
|
||||
|
||||
구체적으로는 지역 ID를 Key로 하고, 각 Hash의 필드에는 실거리를 이루는 두 좌표를, 값에는 실거리를 저장하도록 설계했습니다. 이 구조에서는 매번 모든 데이터를 읽고 쓸 필요 없이 필요한 Hash 필드에만 접근할 수 있어, 네트워크 송수신 데이터량을 크게 줄일 수 있었습니다. 또한, 거리 값 자체를 Integer로 저장함으로써 메모리 사용도 더욱 효율적으로 관리할 수 있었습니다.
|
||||
|
||||
더 나아가 해시 필드 접근 시 단일 커맨드를 여러 번 호출하는 대신, 다중 인자를 한 번에 전달해 여러 필드에 동시에 접근하도록 하여 네트워크 오버헤드를 줄였습니다. 다만 다중 인자를 받는 커맨드의 시간 복잡도는 O(N)이므로 호출 횟수와 인자 수에 주의가 필요합니다. 또, [AWS ElastiCache 사용자 가이드](https://docs.aws.amazon.com/ko_kr/AmazonElastiCache/latest/dg/RedisConfiguration.html)를 보면 요청당 최대 인자 수를 3,999개로 제한하는 것을 확인할 수 있습니다. 이를 감안해 인자 개수를 적절한 크기로 청크 처리하여 슬로우 쿼리 없이 적용할 수 있었습니다.
|
||||
|
||||
## Redis 성능 최적화
|
||||
|
||||
### EXPIRE 커맨드 줄이기
|
||||
|
||||
TTL은 Redis가 제공하는 매우 유용한 기능이지만, 지나치게 많이 사용될 경우 네트워크 오버헤드가 증가할 수 있습니다. 또한 만료 시간이 긴 경우 내부적으로 CPU와 메모리 리소스도 불필요하게 소모될 수 있습니다.
|
||||
|
||||
실거리 관리 시스템 초기 배포 시에는 버그나 롤백 상황에 대비해 일정 시점마다 메모리를 반드시 비워야 한다는 판단 하에, 모든 HMSET 요청에 6시간 EXPIRE 명령을 함께 실행하도록 설계했습니다. 하지만 배달이 N시간 안에 모두 완료된다는 가정은 현실적이지 못하고 비효율적이었습니다. TTL을 너무 짧게 설정하면 캐시 히트율이 떨어지고, 너무 길면 리소스 낭비로 이어집니다.
|
||||
|
||||
특히 실거리 시스템은 Redis Hash 구조를 사용해 지역 ID별로 거리 데이터를 관리하기 때문에, HMSET 요청 시마다 EXPIRE 명령을 함께 실행하면 Hash key의 TTL이 불필요하게 자주 갱신됩니다. 이로 인해 네트워크 비용이 낭비되는 문제가 발생합니다.
|
||||
|
||||
위 단점을 극복하기 위해, 일정 시간 TTL을 설정하는 대신 배달이 완료 또는 취소되는 시점에 해당 배달과 동일한 위치가 더 이상 사용되지 않는 경우에만 실거리 데이터를 명시적으로 삭제하는 방식을 선택했습니다.
|
||||
|
||||
이 방식은 별도의 삭제 로직이 필요하다는 단점이 있지만, 불필요한 가정을 하지 않아도 되고 CPU와 메모리 자원을 보다 효율적으로 관리할 수 있습니다. 또한 HMSET 수행 시마다 EXPIRE 명령을 함께 실행하지 않아도 되므로 네트워크 비용이 줄어듭니다. 더 나아가, 레플리카 복제 과정에서 발생하는 쓰기 트래픽 역시 감소하여 네트워크 송수신 데이터 양을 효과적으로 줄일 수 있습니다.
|
||||
|
||||
| 구분 | TTL로 삭제 (expire) | 명시적 삭제 (delete) |
|
||||
|-----|------------------|------------------|
|
||||
| 장점 | • 배달 주기 신경 쓸 필요 없음<br>• 별도 로직 구현 불필요 | • 배달 주기에 맞춰 실거리 그래프 관리 가능<br>• TTL 대비 단순하고 명확한 처리 가능 |
|
||||
| 단점 | • 배달마다 완료 시간이 달라 TTL 설정 모호<br>• 해시 필드별 TTL은 저수준 직접 구현 필요 (Spring Data Redis 미지원)<br>• 지역별 해시 TTL은 매번 expire 연장 발생<br>• TTL 만료 시 콜드 스타트 이슈 발생 가능<br>• 대량 만료 시 CPU 사용량 증가 | • 명시적 삭제 로직 구현 필요 |
|
||||
|
||||
### 쓰기 분산을 위한 Redis 클러스터 모드 적용
|
||||
|
||||
TTL과 명시적 삭제 방식을 비교해보며, 실거리 그래프는 쓰기 트래픽이 많다는 점을 확인했습니다. 따라서 이를 위한 분산 전략이 매우 중요합니다.
|
||||
|
||||
Redis는 고가용성을 위해 마스터-레플리카 모드와 클러스터 모드를 지원합니다. 마스터-레플리카 모드에서는 모든 쓰기 작업이 Primary 노드에 집중되고, Primary 노드는 변경사항을 Replica 노드에 전송해야 합니다. 이 과정에서 Primary 노드의 네트워크 병목과 대역폭 초과가 발생할 수 있습니다. 반면 클러스터 모드는 쓰기 작업도 여러 노드에 분산할 수 있습니다.
|
||||
|
||||
Redis 클러스터 모드의 샤딩은 키 공간을 해시 슬롯으로 나누어 동작합니다. 실거리 그래프는 지역 ID를 키로 관리하므로, 서로 다른 지역에 대한 HMGET, HMSET 요청을 각기 다른 샤드가 분산 처리할 수 있어 성능 향상에 유리합니다.
|
||||
|
||||
멀티 키 연산이 필요한 경우 클러스터 모드 적용 시 해시 태그 등의 추가 고려가 필요하지만, 실거리 그래프는 동일 키에 대해서만 멀티 연산(HMGET, HMSET, HDEL)이 발생하므로 서비스 코드 수정 없이 적용할 수 있었습니다. 따라서 클러스터 모드로의 전환만으로도 쓰기 트래픽 분산이 가능했습니다. 물론 트래픽 급증 시 네트워크 부하 문제는 여전히 발생할 수 있으나, 클러스터 모드는 노드를 수평 확장하기 쉬워 새로운 노드 추가로 유연한 대응이 가능합니다.
|
||||
|
||||
이처럼 클러스터 모드 적용을 통해 네트워크 용량 확장과 분산 처리, 성능 최적화를 통해 보다 안정적인 실거리 관리 시스템을 구축할 수 있었습니다. 실거리 관리 시스템 도입 후 Redis 지표 모니터링과 최적화 시도를 병행하며 지역별 점진 배포를 진행했고, 클러스터 모드 적용 후에는 전 지역으로 안정적 확장을 빠르게 마칠 수 있었습니다.
|
||||
|
||||
## 마무리
|
||||
|
||||
지금까지 배차시스템에서 OSRM 지도, Kafka, Redis를 활용한 이벤트 기반 실거리 시스템 개발 사례와 과정을 공유드렸습니다. 배차시스템팀은 배달에서 발생하는 대량 트래픽을 처리하기 위해 다양한 고민과 개발을 이어가고 있으며, 이번 실거리 시스템은 그중에서도 가장 많은 트래픽을 다루는 핵심 도메인 중 하나입니다.
|
||||
|
||||
이번 프로젝트를 통해 Kafka 기반 이벤트 드리븐 아키텍처(EDA)를 경험하고, Redis를 활용한 대규모 트래픽 처리 기술을 적용할 수 있었습니다. 또한 라이더분들의 효율적인 배달 수행을 지원하여 배차 효율 향상에 기여할 수 있었습니다. 저희의 경험과 사례가 글을 읽으시는 분들의 서비스 개선에 도움이 되길 기대합니다.
|
||||
@@ -0,0 +1,73 @@
|
||||
<!-- 출처: https://techblog.woowahan.com/23625/ · 우아한형제들 기술블로그 · 참고용 원문 사본 -->
|
||||
<!-- 이 페이지는 자바스크립트로 본문을 그려서 전문을 받지 못했다. 아래는 받아낸 부분이다.
|
||||
인용부호가 붙은 문장만 원문 그대로이고, 나머지는 원문을 줄인 것이다. 문장 표본으로 쓰지 말고
|
||||
글의 골격(요구사항 목록 → 표 설계 → 트레이드오프)을 보는 데만 쓴다. -->
|
||||
|
||||
# 장시간 비동기 작업, Kafka 대신 RDB 기반 Task Queue로 해결하기
|
||||
|
||||
박민규 · Backend · 2025년 11월 25일
|
||||
|
||||
## 문제 상황
|
||||
|
||||
전자계약서 시스템에서 대용량 엑셀 파일 생성 작업은 Kafka를 통해 비동기로 처리되고 있었습니다. 초기에는 대부분의 작업이 10분 이내에 완료되었지만, 신규 엑셀 타입 추가로 인해 각 행마다 여러 외부 API를 호출해야 하면서 처리 시간이 30분 이상으로 증가했습니다.
|
||||
|
||||
심각한 문제는 사용자가 "같은 엑셀 파일을 여러 번 받았다"는 문의였습니다. 조사 결과, Kafka의 5분 타임아웃(`max.poll.interval.ms`)을 초과하면서 리밸런싱이 발생하여 Worker가 비동기 처리한 뒤 즉시 ACK하도록 변경했지만, 이는 작업 유실 위험을 초래했습니다.
|
||||
|
||||
## 근본 원인
|
||||
|
||||
"5분 동안 `poll()`이 호출되지 않으면 Consumer가 죽은 것으로 판단하고" 리밸런싱이 일어납니다. 장시간 작업 중 리밸런싱이 발생하면서 동일 메시지가 다른 Consumer에게 재할당되었습니다. 타임아웃을 증가시키는 임시 해결책은 실제 Worker 장애 감지를 지연시키는 부작용을 초래했습니다.
|
||||
|
||||
## 기존 Kafka 방식의 한계
|
||||
|
||||
팀은 엑셀 생성에 Kafka가 정말 필요한지 검토했습니다. Kafka는 대량 트래픽과 다중 consumer group에 유리하지만, 실제로는 트래픽이 일정하고 생산자/소비자가 모두 내부 서비스였습니다. 장시간 작업 특성상 타임아웃 문제가 지속적으로 발생했고, 타임아웃을 늘리면 실제 장애 감지가 1시간 이상 지연되는 딜레마가 생겼습니다.
|
||||
|
||||
## RDB 기반 Task Queue 아키텍처로 전환
|
||||
|
||||
새로운 구조는 다음과 같은 요구사항을 반영했습니다:
|
||||
|
||||
- **시간 제한 없음**: 1~2시간 걸리는 작업도 안정적 완료
|
||||
- **배포 영향 없음**: 서버 배포 시 즉시 중단되어야 함
|
||||
- **작업 유실 방지**: 서버 다운 시에도 작업 재처리
|
||||
- **자동 재시도**: 일시적 오류 시 최대 3회 재시도
|
||||
- **병렬 처리**: 여러 Worker의 분산 처리
|
||||
- **중복 방지**: 동일 작업의 중복 처리 차단
|
||||
|
||||
### 핵심 구조
|
||||
|
||||
**테이블 설계**:
|
||||
|
||||
```sql
|
||||
CREATE TABLE excel_download_request (
|
||||
id BIGINT PRIMARY KEY,
|
||||
status VARCHAR(20), -- PENDING, IN_PROGRESS, DONE, FAILED
|
||||
last_heartbeat_at DATETIME, -- Worker 생존 신호 마지막 수신 시각
|
||||
retry_count INT DEFAULT 0, -- 재시도 횟수 (최대 3회)
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
```
|
||||
|
||||
**작업 선점 및 처리**: Worker는 3초마다 PENDING 작업을 조회하고 Redis 분산 락으로 선점합니다. 동시에 최대 2개 작업을 병렬 처리합니다(10대 서버 × 2 = 20개의 시스템 처리 용량).
|
||||
|
||||
**Heartbeat 메커니즘**: 작업 중인 Worker는 1분마다 마지막 활동 시각을 갱신합니다. 2분 이상 갱신이 없으면 Fallback 스케줄러가 작업을 PENDING으로 되돌려 다른 Worker가 복구하도록 합니다. 복구 스케줄러는 ShedLock 으로 한 인스턴스만 수행합니다.
|
||||
|
||||
**재시도 처리**: 실패한 작업은 retryCount를 증가시킨 뒤 PENDING으로 되돌립니다. 3회 이상 실패하면 최종적으로 FAILED 상태로 처리됩니다.
|
||||
|
||||
## 개선 효과
|
||||
|
||||
**강점**:
|
||||
|
||||
- Kafka 메시지 플로우 제거로 디버깅 용이
|
||||
- 단일 데이터 소스(RDB)로 상태 관리 단순화
|
||||
- 메시지 재발행/유실 문제 원천 차단
|
||||
- Worker 수평 확장이 단순함
|
||||
- 단순 쿼리로 실시간 모니터링 가능
|
||||
|
||||
**트레이드오프**:
|
||||
|
||||
- 지속적인 폴링 쿼리로 DB 부하 증가(커버링 인덱스로 최적화, 분당 20~30회 정도)
|
||||
- 폴링 주기(3초)만큼 처리 시작 지연 발생(엑셀 다운로드에서는 허용 가능)
|
||||
|
||||
## 핵심 인사이트
|
||||
|
||||
"작업 트랜잭션 특성에 따라 메시징 시스템 선택이 달라져야 한다"는 결론에 도달했습니다. 짧고 빠른 작업은 Kafka 같은 이벤트 스트리밍에 적합하지만, 수십 분 소요되는 복잡한 작업은 상태 관리와 재시도가 용이한 RDB 기반 Task Queue가 더 안정적입니다.
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
<!-- 출처: https://techblog.woowahan.com/7835/ · 우아한형제들 기술블로그 · 참고용 원문 사본 -->
|
||||
|
||||
# 회원시스템 이벤트기반 아키텍처 구축하기
|
||||
|
||||
## 배달의민족의 마이크로서비스 여정
|
||||
|
||||
최초의 배달의민족은 하나의 프로젝트로 만들어졌습니다. 배달의민족의 주문수는 J 커브를 그리는 빠른 속도로 성장했고, 주문수가 커지면서 자연스럽게 트래픽 또한 매우 커졌습니다.
|
||||
|
||||
하나의 시스템, 하나의 데이터베이스로 폭발적으로 늘어가는 트래픽을 감당하지 못하고, 결국 배달의민족은 대장애 시대를 맞이했습니다. 이에 배달의민족은 마이크로서비스로 전환을 시도하였고, 2019년 11월 1일 모든 시스템이 분리되며 마이크로서비스를 완성하였고 시스템의 안정화를 찾을 수 있었습니다.
|
||||
|
||||
배달의민족은 마이크로서비스로 전환을 하게 되었고, 이벤트 기반 아키텍처의 시대를 맞이했습니다.
|
||||
|
||||
## 회원 시스템 이벤트 기반 아키텍처 구축하기
|
||||
|
||||
### 무엇을 이벤트로 발행할 것인가?
|
||||
|
||||
MicroService Architecture (이하 MSA) 에서 Event Driven 이 함께 언급되는 이유는 MSA 핵심 키워드 중 `느슨한 결합`과 연관이 있습니다. 각 마이크로서비스는 서로 간 느슨한 결합을 가져감으로써 타 시스템에 대한 의존과 영향도를 줄이고 각 시스템의 목적에 집중함으로써 강한 응집을 갖는 시스템을 만들 수 있습니다. Event Driven 은 이를 돕습니다.
|
||||
|
||||
이해를 돕기 위하여 배달의민족의 회원과 가족계정이라는 두 가지 도메인의 관계를 예시로 들겠습니다.
|
||||
|
||||
"회원의 본인인증이 초기화되는 경우 가족계정 서비스에서 탈퇴되어야 한다" 라는 정책이 있습니다.
|
||||
|
||||
이를 코드로 작성하면 가족계정 서비스 탈퇴 로직은 회원의 본인인증 해제 로직에 깊게 관여되어 강한 결합을 가지고 있습니다.
|
||||
|
||||
마이크로서비스를 구성 함에 따라 두 도메인은 서로 다른 시스템으로 분리되어 회원 시스템, 가족계정 시스템이 되었습니다. 이 때 하나의 시스템에 존재하던 두 도메인의 물리적인 분리가 이루어집니다.
|
||||
|
||||
물리적인 시스템의 분리로 인해 코드 레벨의 호출이 동기적인 HTTP 통신으로 변했습니다. 그러나 여전히 대상 도메인을 호출해야한다는 의도가 남아있기 때문에 물리적인 시스템 분리만으로는 결합이 느슨해졌다고 볼 수는 없습니다.
|
||||
|
||||
물리적인 의존을 제거하는 방법으로 쉽게 떠올릴 수 있는 것은 비동기 방식입니다. 대표적인 비동기 방식으로는 별도 스레드를 통한 HTTP 방식과 메시징 시스템을 이용한 방식이 있습니다.
|
||||
|
||||
주 흐름에서 분리된 별도 스레드를 통해 HTTP 요청을 합니다. 별도 스레드에서 진행되기 때문에 주 흐름과 직접적인 결합이 제거될 수 있습니다. 그러나 시스템 관점에서는 여전히 별도 스레드에서 대상 도메인을 호출한다는 의도가 남아있기 때문에 이 또한 결합이 느슨해졌다고 볼 수 없습니다.
|
||||
|
||||
메시징 시스템을 이용하여 메시지를 전송합니다. 메시징 시스템을 사용하면서 느슨한 결합을 가져갈 수 있을 것이라고 기대하겠지만 메시징시스템을 사용하는 아키텍처가 항상 느슨한 결합을 보장하지는 않습니다.
|
||||
|
||||
회원의 본인인증 해제가 발생할 때 가족계정 탈퇴 메시지를 발송하였습니다. 메시지를 발송하는 것으로 물리적인 의존이 제거되었습니다. 그러나 결합은 느슨해지지 않습니다.
|
||||
|
||||
가족계정 탈퇴를 기대하는 메시지를 발행했기 때문에 가족계정 시스템의 정책이 변경될 때 회원 시스템의 메시지도 함께 변경되어야 합니다. 어떤 일을 해야 하는 지를 메시지 발행자가 알려주는 경우(Command), 해야하는 일이 변경될 때 메시지 발행자와 수신자 양쪽 모두의 코드가 변경돼야 하기 때문에 높은 결합도가 존재하게 됩니다. 또한 회원시스템은 여전히 가족계정의 비지니스를 알고 있는 논리적인 의존관계가 남아있기 때문에 결합이 느슨해졌다고 볼 수 없습니다. 물리적으로는 결합도가 높지 않지만 개념적으로는 결합도가 높은 상태인 것 입니다.
|
||||
|
||||
메시지를 발행하였음에도 의존관계가 남아있는 이유는 대상 도메인에게 기대하는 목적을 담은 메시지를 발행하였기 때문입니다. 메시징 시스템으로 보낸 메시지가 대상 도메인에게 기대하는 목적을 담았다면, 이것은 이벤트라 부르지 않습니다. 이것은 메시징 시스템을 이용한 비동기 요청일 뿐 입니다.
|
||||
|
||||
회원의 본인인증 해제가 발생할 때 본인인증 해제 이벤트를 발송하였습니다. 회원시스템은 더 이상 가족계정 시스템의 정책을 알지 못합니다. 가족계정 시스템은 본인인증 해제 이벤트를 구독하여 가족계정 시스템의 비지니스를 구현합니다. 회원시스템은 가족계정 시스템의 비지니스 변경에 더 이상 영향을 받지 않습니다. 이로써 두 시스템 간의 결합이 느슨해졌습니다.
|
||||
|
||||
물리적인 시스템 분리부터 비동기 HTTP 통신, 이벤트 방식까지 살펴보며 의존 관계의 흐름을 살펴보았습니다.
|
||||
|
||||
메시징 시스템을 사용해 물리적인 의존을 제거할 수 있었지만, **메시지가 담는 의도에 따라 전혀 다른 결과**를 얻는다는 것을 알 수 있습니다.
|
||||
|
||||
**우리가 발행해야할 이벤트는 `도메인 이벤트로 인해 달성하려는 목적`이 아닌 `도메인 이벤트` 그 자체입니다.**
|
||||
|
||||
> `도메인`이란 해결하고자 하는 문제 영역이며, `도메인 이벤트` 는 문제 영역에서 발생할 수 있는 핵심 가치나 행위입니다. `도메인` 이라는 용어로 Domain-Driven Design(이하 DDD) 과 관계를 지어 글을 보시는 분들이 계실 것 같아 DDD 와 큰 관련이 없음을 미리 명시합니다.
|
||||
|
||||
도메인의 핵심 가치나 행위를 정의하기 어렵다면 `이벤트 스토밍` 을 추천드립니다. `이벤트 스토밍` 은 DDD 의 전략적 설계 도구 중 하나이지만, 도메인 주도 설계를 위해서가 아니더라도 문제 영역 식별과 해결에 좋은 도구입니다.
|
||||
|
||||
### 이벤트 발행과 구독
|
||||
|
||||
회원시스템에서는 다양한 고민을 해결하기 위하여 3가지의 이벤트 종류와 3가지의 이벤트 구독자 계층을 정의하였습니다. 각 계층과 이벤트가 왜 만들어졌는지, 무엇을 해결해주는지 살펴보겠습니다.
|
||||
|
||||
#### 어플리케이션 이벤트 & 첫번째 구독자 계층
|
||||
|
||||
메시징 시스템을 사용하기 전 `Spring Framework` 의 `Application Event` 를 먼저 다루었습니다.
|
||||
|
||||
어플리케이션 이벤트를 먼저 다루는 이유는 이벤트를 통해 느슨한 결합을 만들어야 하는 일이 외부 세상에만 존재하는게 아니기 때문입니다.
|
||||
|
||||
스프링의 어플리케이션 이벤트는 분산-비동기를 다룰 수 있는 이벤트 버스를 제공하며, 트랜잭션을 제어할 수 있도록 지원합니다.
|
||||
|
||||
어플리케이션 이벤트를 구독하는 첫번째 구독자 계층은 스프링의 어플리케이션 이벤트가 제공하는 기능으로 한 어플리케이션 내에서 도메인 내부의 비관심사를 효율적으로 처리할 수 있습니다.
|
||||
|
||||
어플리케이션 내에서 반드시 해결해야만 하는 대표적인 도메인의 비관심사는 메시징 시스템으로 이벤트를 발행하는 것 입니다. 이벤트 구독은 발행 시스템에 영향 없이 자유롭게 확장이나 변경이 가능하므로, 우리는 도메인에 영향 없이 메시징 시스템에 대한 연결을 쉽게 작성하고 확장하고 변경할 수 있습니다.
|
||||
|
||||
또한 스프링 어플리케이션 이벤트를 통해 트랜잭션을 제어할 수 있습니다. 도메인에서 정의된 트랜잭션의 범위가 외부로부터 제어 될 수 있다는 것을 도메인에 대한 침해로 볼 수 있지만, 이 침해를 감수하는 대신 강력한 구독자를 만들 수 있습니다.
|
||||
|
||||
상태 변경을 야기하는 모든 도메인 행위는 메시징 시스템으로 전달해야한다는 시스템 정책을 세웠습니다. 이벤트를 메시징 시스템으로 전달하는 것은 도메인에게는 관심사가 아니지만 시스템에서는 중요한 정책입니다. 이런 경우 도메인 정책에 변경없이 트랜잭션을 확장하여 구독자의 행위를 트랜잭션 내에서 처리되도록 변경할 수 있습니다.
|
||||
|
||||
회원시스템은 메시징 시스템으로 `AWS SNS` 를 사용하고 있으므로, 첫번째 구독자 계층의 `SNS` 발행을 책임지는 이벤트 구독자가 만들어졌습니다.
|
||||
|
||||
```java
|
||||
@Async(EVENT_HANDLER_TASK_EXECUTOR)
|
||||
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
|
||||
public void handleJoinEvent(MemberJoinApplicationEvent event) {
|
||||
MemberJoinEventPayload payload = MemberJoinEventPayload.from(event);
|
||||
notificationMessagingTemplate.sendNotification(clientNameProperties.getSns().getJoin(), payload, null);
|
||||
}
|
||||
```
|
||||
|
||||
그리고 이 구독자를 통해 발행되는 이벤트는 `내부 이벤트` 입니다.
|
||||
|
||||
#### 내부 이벤트 & 두번째 구독자 계층
|
||||
|
||||
어플리케이션 이벤트로 내부 이벤트를 처리할 수 있지만, 어플리케이션 이벤트 처리기는 어플리케이션의 리소스를 사용하기 때문에 도메인의 주요 기능 처리 성능에 영향을 미치게 됩니다. 또한 `Application Event` 가 잘 구현되어 있다지만, 메시지 유실과 장애 복구를 최소화해주는 메시징 시스템의 장점을 가져갈 수 없습니다.
|
||||
|
||||
첫번째 구독자 계층이 어플리케이션 내에서 해결해야하는 비관심사를 처리했다면, 내부 이벤트를 구독하는 두번째 구독자 계층은 이 외의 모든 도메인 내의 비관심사를 처리합니다.
|
||||
|
||||
##### 비관심사 분리
|
||||
|
||||
도메인 행위가 수행될 때 함께 수행되어야 하는 정책들이 있을 수 있습니다. 이러한 부가 정책들이 도메인의 주 행위인 것으로 착각될 수 있으며, 의존성 관계를 확장시키고 도메인의 주 행위에 대한 응집을 방해하게 됩니다.
|
||||
|
||||
도메인 내의 비관심사 분리의 예시로 로그인 프로세스를 살펴보겠습니다.
|
||||
|
||||
회원이 로그인을 할 때
|
||||
|
||||
- 회원을 로그인 상태로 변경
|
||||
- "동일 계정 로그인 수 제한" 규칙에 따라 동일 계정이 로그인된 타 디바이스 로그아웃 처리
|
||||
- 회원이 어느 디바이스에서 로그인되었는지 기록
|
||||
- 동일 디바이스의 다른 계정 로그아웃 기록
|
||||
|
||||
을 해야 합니다.
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void login(MemberNumber memberNumber, DeviceNumber deviceNumber) {
|
||||
devices.login(memberNumber, deviceNumber);
|
||||
devices.logoutMemberOtherDevices(memberNumber, deviceNumber);
|
||||
devices.logoutOtherMemberDevices(memberNumber, deviceNumber);
|
||||
member.login(memberNumber);
|
||||
applicationEventPublisher.publishEvent(MemberLoginApplicationEvent.from(memberNumber, deviceNumber));
|
||||
}
|
||||
```
|
||||
|
||||
이 코드를 살펴보았을 때 도메인의 주 행위가 무엇인지 알기 어렵습니다. 부가 정책들이 도메인 로직에 함께 작성되어 있기 때문입니다. 주요 기능을 찾고 비관심사를 분리하여 도메인 행위의 응집을 높이고 비관심사에 대한 결합을 느슨하게 만들어야 합니다. 도메인의 주요행위는 정책을 살펴보았을 때 알 수 있을 것 입니다. 정책마저 모호하다면 즉시 처리되어야 하는 것과 언젠가 처리되어야 하는 것을 분리함으로써 도메인의 주요 기능을 찾을 수 있습니다.
|
||||
|
||||
로그인 기능의 주 행위는 **"회원을 로그인 상태로 변경"** 하는 것 입니다. 이 외의 행위들은 로그인 행위에 부가적으로 붙어있는 정책들입니다. 부가적인 정책들을 도메인 로직에서 분리시킵니다.
|
||||
|
||||
```java
|
||||
@Transactional
|
||||
public void login(MemberNumber memberNumber, DeviceNumber deviceNumber) {
|
||||
member.login(memberNumber);
|
||||
applicationEventPublisher.publishEvent(MemberLoginApplicationEvent.from(memberNumber, deviceNumber));
|
||||
}
|
||||
```
|
||||
|
||||
또한 3가지 비관심사 작업이 서로 간의 의존이 없음을 알 수 있습니다. 우리는 `AWS SNS-SQS` 메시징 시스템을 통해 하나의 이벤트를 여러 구독으로 나누어서 처리할 수 있습니다.
|
||||
|
||||
```java
|
||||
@SqsListener(value = "${sqs.login-device-login}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
|
||||
public void loginDevice(@Payload MemberLoginApplicationEvent payload) {
|
||||
devices.login(payload.getMemberNumber(), payload.getDeviceNumber());
|
||||
}
|
||||
|
||||
@SqsListener(value = "${sqs.login-member-other-device-logout}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
|
||||
public void logoutMemberOtherDevices(@Payload MemberLoginApplicationEvent payload) {
|
||||
devices.logoutMemberOtherDevices(payload.getMemberNumber(), payload.getDeviceNumber());
|
||||
}
|
||||
|
||||
@SqsListener(value = "${sqs.login-other-member-device-logout}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
|
||||
public void logoutOtherMemberDevices(@Payload MemberLoginApplicationEvent payload) {
|
||||
devices.logoutOtherMemberDevices(payload.getMemberNumber(), payload.getDeviceNumber());
|
||||
}
|
||||
```
|
||||
|
||||
이렇게 도메인 내의 비관심사를 분리함으로써 도메인 행위의 응집을 높이고, 비관심사에 대한 결합을 느슨하게 만들 수 있습니다. 또한 분리된 비관심사는 각자 구현이 되어 강한 응집과 높은 재사용성을 확보할 수 있습니다.
|
||||
|
||||
##### 외부 이벤트 발행
|
||||
|
||||
시스템 내의 비관심사를 분리했지만, MSA 를 위한 외부 시스템과의 관심사 분리를 위한 외부 이벤트 발행이 필요합니다. 외부 시스템에 이벤트를 전파하는 행위 또한 도메인 내에 존재하던 비관심사로 볼 수 있습니다.
|
||||
|
||||
```java
|
||||
@SqsListener(value = "${sqs-join-broadcast}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
|
||||
public void handleBroadcast(@Payload MemberJoinApplicationEvent payload) {
|
||||
messageBroadcastExecutor.broadcast(MemberBroadcastMessage.from(payload));
|
||||
}
|
||||
```
|
||||
|
||||
다른 내부 이벤트 처리와 동일하게 두번째 구독자 계층의 `SNS 발행` 을 책임지는 이벤트 구독자로부터 `외부 이벤트` 가 발행되게 됩니다.
|
||||
|
||||
#### 외부 이벤트 & 세번째 구독자 계층
|
||||
|
||||
내부이벤트를 외부에서 구독하도록 할 수 있지만, 내부 이벤트와 외부 이벤트를 분리함으로써 내부에는 열린, 외부에는 닫힌 이벤트를 제공할 수 있다는 장점이 있습니다.
|
||||
|
||||
동일한 이벤트를 수신하더라도 각 구독자마다 서로 다른 목적을 가지고 있습니다. 이로인해 각 구독자는 이벤트를 인지하는 것 이상으로 데이터가 더 필요하게 될 수 있습니다.
|
||||
|
||||
##### 열린 내부이벤트, 닫힌 외부이벤트
|
||||
|
||||
**내부이벤트** 에는 구독자가 필요한 데이터를 페이로드에 제공하여 이벤트 처리의 효율을 챙길 수 있습니다. 이런 페이로드의 확장을 열어둘 수 있는 것은 이 이벤트가 내부 이벤트이기 때문입니다. **내부 이벤트는 시스템 내에 존재하기 때문에 이벤트의 발행이 구독자에게 미치는 영향을 파악하고 관리할 수 있습니다.** 또한 외부에 알릴 필요없는 내부의 개념을 이벤트에 녹일 수도 있습니다. 이러한 확장이 가능한 것 또한 내부 이벤트는 시스템 내에 존재하는 이벤트이기 때문입니다.
|
||||
|
||||
반면 외부 시스템 으로 전파되는 **외부이벤트**는 내부이벤트와는 다릅니다. 내부 이벤트는 도메인에 존재하는 비관심사를 분리하여 도메인의 응집도를 높이고 비관심사를 효율적으로 처리하는 것을 목적으로 하며, 외부 이벤트는 시스템과 시스템의 결합을 줄이는 것을 목적으로 합니다. 시스템 간의 결합을 느슨하게 만들기 위해 발행되는 **외부 이벤트는 이벤트 발행처에서 이벤트 구독자가 어떤 행위를 하는지 관심을 가지면 안되며, 관리할 수 없습니다**. 이벤트 발행처가 이벤트 구독자의 행위에 관심을 갖게 된다면 이는 또 다시 논리적인 의존 관계를 형성하게 되는 것 입니다.
|
||||
|
||||
외부시스템에서도 이벤트를 처리하기 위해 더 많은 정보가 필요할 것 입니다. 그러나 외부시스템의 비지니스에서 필요한 데이터를 페이로드에 추가하게 되면, 외부시스템의 비지니스 변화에 직접적인 의존 관계를 형성하게 될 것 입니다. 외부시스템과의 의존을 갖지 않는 이벤트를 만들기 위해 하나의 형태로 이벤트를 전달할 수 있는 **이벤트에 대한 일반화**가 필요합니다.
|
||||
|
||||
##### 이벤트 일반화
|
||||
|
||||
외부 시스템이 이벤트로 수행하려는 행위는 광범위하겠지만, 이벤트를 인지하는 과정은 쉽게 일반화할 수 있습니다.
|
||||
|
||||
**"언제, 어떤 회원이(식별자) 무엇을(행위) 하여 어떤 변화(변화 속성)가 발생했는가"**
|
||||
|
||||
`식별자`와 `행위`, `속성`, `이벤트 시간` 이 있다면 어떠한 시스템에서도 필요한 이벤트를 인지할 수 있음을 알 수 있습니다. 이를 페이로드로 구현하면 이벤트를 수신하는 측에서 필요한 이벤트를 분류하여 각 시스템에서 필요한 행위를 수행할 수 있습니다.
|
||||
|
||||
```java
|
||||
public class ExternalEvent {
|
||||
private final String memberNumber;
|
||||
private final MemberEventType eventType;
|
||||
private final List<MemberEventAttributeType> attributeTypes;
|
||||
private final LocalDateTime eventDateTime;
|
||||
}
|
||||
```
|
||||
|
||||
외부 시스템들은 정해진 이벤트 형식 내에서 필요한 행위를 수행하면 되므로, 이벤트를 발행하는 시스템은 외부 시스템의 변화에 영향을 받지 않을 수 있습니다.
|
||||
|
||||
> TIP. **SNS 속성을 이용하여 구독자들이 원하는 이벤트만 구독하기**
|
||||
>
|
||||
> "AWS SNS" 의 속성을 기반으로 구독자마다 이벤트를 필터링할 수 있는 기능을 사용할 수 있습니다.
|
||||
>
|
||||
> 각 구독자는 필요한 이벤트 형식 혹은 속성 종류를 필터로 정의하여 어플리케이션에 필요한 이벤트만 유입되도록 만들 수도 있습니다. 필터링 기능을 통해 어플리케이션이 직접 이벤트를 분류해야하는 리소스 낭비를 줄일 수 있습니다.
|
||||
|
||||
##### ZERO-PAYLOAD 방식
|
||||
|
||||
닫혀있는 외부이벤트의 부가 데이터를 전달하는 방식으로는 `ZERO-PAYLOAD` 방식을 선택했습니다.
|
||||
|
||||
`ZERO-PAYLOAD` 방식은 이벤트의 순서에 대한 보장 문제를 해소하는 방식으로 주로 소개되곤 하지만, 페이로드에 외부시스템에 대한 의존을 제거하여 느슨한 결합을 만들 수 있는 장점 또한 있습니다.
|
||||
|
||||
외부시스템은 일반화된 이벤트를 필터링하여 필요한 이벤트를 구독하고, 필요한 부가 정보는 API 를 통해 보장된 최신상태의 데이터를 사용할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
**어플리케이션 이벤트를 통해 이벤트의 트랜잭션 제어를 할 수 있었으며, 내부 이벤트를 통해 내부의 비관심사를 효율적으로 분리할 수 있었으며, 외부 이벤트를 통해 외부시스템과 의존없는 이벤트를 발행하게 되었습니다.**
|
||||
|
||||
**이렇게 회원 시스템에 이벤트 기반의 아키텍처가 구축되었습니다.**
|
||||
|
||||
## 이벤트 저장소 구축
|
||||
|
||||
이벤트의 계층을 분리하고, 메시징 시스템을 통해 안정적인 이벤트를 처리할 수 있게 되었지만 여전히 문제점들이 존재하고 있습니다.
|
||||
|
||||
### 첫번째 문제. 이벤트 발행에 대한 보장 유실
|
||||
|
||||
`SNS-SQS-어플리케이션` 구간에서는 SQS의 정책을 통해 안정적인 실패 처리, 재시도 처리가 가능하지만 `어플리케이션-SNS` 구간에서는 HTTP 통신을 사용하므로 이벤트를 발행하는 과정에 문제가 발생할 수 있습니다.
|
||||
|
||||
내부 이벤트를 발행하는 과정을 트랜잭션 내부로 정의하면서, 메시징 시스템의 장애가 곧 시스템의 장애로 이어질 수 있습니다. 메시징 시스템의 장애가 시스템 장애로 이어지는 문제는 굉장히 큰 문제이므로 반드시 해결이 필요합니다.
|
||||
|
||||
```java
|
||||
@Async(EVENT_HANDLER_TASK_EXECUTOR)
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
public void handleJoinEvent(MemberJoinApplicationEvent event) {
|
||||
MemberJoinEventPayload payload = MemberJoinEventPayload.from(event);
|
||||
notificationMessagingTemplate.sendNotification(clientNameProperties.getSns().getJoin(), payload, null);
|
||||
}
|
||||
```
|
||||
|
||||
이 문제는 내부 이벤트 발행을 트랜잭션 이후로 정의를 하면서 해결할 수 있습니다.
|
||||
|
||||
그러나 트랜잭션 외부에서 처리되기 때문에 이벤트 발행에 대한 보장이 사라지게 되었습니다. `어플리케이션-SNS` 구간에서는 HTTP 통신을 사용하므로 네트워크 구간에서는 다양한 문제로 충분히 실패가 발생할 수 있습니다.
|
||||
|
||||
### 두번째 문제. 이벤트 재발행
|
||||
|
||||
구독자들이 이벤트를 정상적으로 처리하더라도, 이벤트 처리를 잘못할 수 있기 때문에 언제든 이벤트를 재발행 해줄 수 있어야 합니다.
|
||||
|
||||
이 때 구독자들이 원하는 이벤트들의 형태는 자유롭습니다. 특정 이벤트, 특정 기간, 특정 회원이나, 특정 타입, 특정 속성 의 이벤트 발행을 원할 수 있습니다. 일부 메시징 시스템은 재발행에 대한 기능을 제공하지만, 모든 메시징 시스템이 이 기능을 제공하지 않으며 모든 요구사항을 수용하기도 힘듭니다.
|
||||
|
||||
대부분의 데이터는 최종 상태만을 보관하여 특정 시점의 상태를 복원하기 어려우며, 변경 내역을 가지고 있다고 하더라도 이벤트를 고려하지 않고 저장된 데이터로 이벤트를 복원하기는 쉽지 않습니다.
|
||||
|
||||
**이 두 가지 문제점을 해결하기 위해 우리는 이벤트 저장소를 구축하기로 하였습니다.**
|
||||
|
||||
### 이벤트 저장 시점
|
||||
|
||||
메시징 시스템의 장애가 시스템의 장애로 이어지지 않도록 메시징 시스템으로 이벤트 발행을 별도 트랜잭션으로 정의를 하였습니다. 이는 "메시징 시스템으로 이벤트 발행을 도메인의 중요한 행위로 본다"는 정의를 깨버리는 것이었고, 이것이 이벤트 발행에 대한 보장을 사라지게 만들었습니다.
|
||||
|
||||
이 정의를 이벤트 저장소로 다시 복구를 하기 위해 우리는 "이벤트 저장소에 이벤트 저장하는 것을 도메인의 중요한 행위로 본다" 고 정의를 하였습니다. 모든 도메인 이벤트는 반드시 저장소에 저장되어야 하며, 저장소에 저장이 실패하게 되었을 때 도메인 행위도 실패했다고 간주한다는 리스크가 있지만, 어딘가에서는 반드시 데이터를 보장을 해야하기 때문에 이런 정의가 필요합니다.
|
||||
|
||||
```java
|
||||
@EventListener
|
||||
@Transactional
|
||||
public void handleEvent(MemberJoinApplicationEvent event) {
|
||||
memberEventRecorder.record(event.toEventCommand());
|
||||
}
|
||||
```
|
||||
|
||||
이 정의를 통해 이벤트 저장소에 대한 저장을 트랜잭션 범위 내에서 처리하는 구독자를 만들었습니다.
|
||||
|
||||
### 저장소의 종류
|
||||
|
||||
이벤트는 작은 단위로 저장이 되고, 고속 처리되어야하기 때문에 RDBMS 가 아닌 다른 데이터베이스를 선택해야한다고 생각할 수 있습니다.
|
||||
|
||||
도메인 저장소와 다른 종류의 데이터베이스를 사용할 경우 두 저장소에 대한 트랜잭션 처리를 할 수 있어야 합니다. 그러나 다종 데이터베이스의 분산 트랜잭션을 구현 하는 것은 굉장히 어려운 일 입니다.
|
||||
|
||||
이벤트 저장소를 도메인 저장소와 동일한 저장소로 선택을 했을 경우 트랜잭션에 대한 처리는 DBMS 를 믿고 맡길 수 있으며, 인프라에 장애가 발생해도 트랜잭션을 통해 데이터 일관성을 보장할 수 있습니다.
|
||||
|
||||
동일 저장소를 통해 데이터베이스를 저장하고 이벤트를 발행함에 안정적인 정합성을 보장하는 방식은 [Transactional outbox Pattern](https://microservices.io/patterns/data/transactional-outbox.html) 이라고 소개되기도 합니다. 이 패턴의 핵심은 로컬 트랜잭션(동일 저장소를 사용한 트랜잭션)을 사용하여 데이터베이스를 저장하고 이벤트를 발행함에 정합성을 보장하는 내용입니다. 이벤트 저장소를 사용하기로 한 것이 이벤트 발행에 대한 보장 문제를 해결하기 위함이니 이 구현은 `Transactional outbox Pattern` 의 또다른 구현이라고 볼 수도 있습니다.
|
||||
|
||||
단일 저장소의 쓰기량 및 읽기량에 대한 성능적 리스크를 동반할 수 있겠지만, 이는 스케일업/아웃 혹은 샤딩을 하는 등 충분히 확장하여 대응 가능합니다.
|
||||
|
||||
그래서 이벤트 저장소로는 도메인 저장소와 동일한 저장소인 RDBMS 를 선택하게 되었습니다.
|
||||
|
||||
### 데이터의 형태
|
||||
|
||||
#### 이벤트 발행을 보장하기 위해 이벤트가 발행되었는지 확인할 수 있어야 한다.
|
||||
|
||||
이벤트 발행에 대한 여부를 확인할 수 있도록 발행 여부 플래그가 필요했으며, 이벤트 자체에 대한 식별자가 필요했습니다.
|
||||
|
||||
```sql
|
||||
create table member_event
|
||||
(
|
||||
id varchar(128) not null primary key,
|
||||
published tinyint not null,
|
||||
published_at datetime null,
|
||||
created_at datetime not null
|
||||
);
|
||||
|
||||
create index ix_member_event_created_at_published
|
||||
on member_event (created_at, published);
|
||||
```
|
||||
|
||||
#### 특정 회원, 특정 행위, 특정 속성 변화, 특정 기간을 조회하여 재발행할 수 있어야 한다.
|
||||
|
||||
다행히 이벤트 조회를 해결할 수 있는 일반화는 이미 진행되었었습니다. 바로 외부 이벤트 발행에서 입니다. "식별자"와 "행위", "속성", "이벤트 시간" 이 있다면 어떠한 시스템에서도 필요한 이벤트를 인지할 수 있다는 것을 알고 있습니다.
|
||||
|
||||
"식별자"와 "행위", "속성", "이벤트 시간" 를 정의하여 이벤트 조회를 해결합니다.
|
||||
|
||||
```sql
|
||||
alter table member_event add member_number varchar(12) not null;
|
||||
alter table member_event add event_type varchar(255) not null;
|
||||
alter table member_event add attributes text not null;
|
||||
|
||||
create index ix_member_event_event_type_created_at
|
||||
on member_event (event_type, created_at);
|
||||
|
||||
create index ix_member_event_member_number
|
||||
on member_event (member_number);
|
||||
```
|
||||
|
||||
하나의 행위에서 여러 속성이 변화될 수 있습니다. 속성을 풀어서 외래키를 갖는 별도 테이블로 작성할 수 있겠지만, 이벤트-속성에 대한 카디널러티는 다소 작기 때문에 속성을 JSON 형태로 보관하고 어플리케이션에서 필터링하도록 설계하였습니다.
|
||||
|
||||
- 회원 시스템은 이벤트 타입과 속성 타입의 N-M 관계를 정의하여 스팩 문서로 제공하고 있습니다.
|
||||
|
||||
#### 사용자 활동 추적
|
||||
|
||||
정의된 이벤트를 다시 살펴보았을 때 이벤트 저장소가 회원에 대한 모든 활동과 변화를 추적할 수 있는 데이터가될 수 있다는 것을 알게 되었습니다. **우리가 정의한 이벤트는 구독자의 필요로 의해 만들어진 이벤트가 아닌, 이벤트 스토밍을 통해 회원에게 발생할 수 있는 모든 이벤트를 정의하였기 때문입니다.**
|
||||
|
||||
활동과 변화를 추적할 수 있는 데이터가 될 수 있도록 "수행 시스템", "수행 주체", "수행 사유" 를 추가로 기록하기로 했습니다. 또한 도메인의 상태 변화까지 추적할 수 있도록, 속성 타입 뿐만 아니라 속성 자체도 기록을 하기로 하였습니다.
|
||||
|
||||
```sql
|
||||
alter table member_event add reason text not null;
|
||||
alter table member_event add event_channel varchar(36) not null;
|
||||
alter table member_event add requested_by varchar(36) not null;
|
||||
```
|
||||
|
||||
**이렇게 문제 해결을 위한 저장소 스키마가 구성되었습니다.**
|
||||
|
||||
### 문제해결
|
||||
|
||||
#### 이벤트 발행 보장
|
||||
|
||||
이벤트 발행에 대한 보장이 필요한 지점은 **내부 이벤트를 발행하는 과정** 이었습니다. 최초 이벤트를 기록할 때는 발행 여부를 `false`로 저장하고, 두번째 구독자 계층에 이벤트 발행 여부를 기록하는 구독자를 추가하여 데이터를 업데이트 처리하였습니다.
|
||||
|
||||
이 때 이벤트 발행 여부를 기록하는 구독자는 이벤트의 ID만 있다면 처리할 수 있습니다. 모든 이벤트의 `super class`를 정의하여 모든 이벤트가 이벤트 ID를 가지도록 만들었습니다.
|
||||
|
||||
```java
|
||||
public abstract class EventPayload {
|
||||
private final String eventId;
|
||||
}
|
||||
```
|
||||
|
||||
구독자는 이벤트의 공통 페이로드를 사용하므로, 모든 `SNS`의 이벤트를 하나의 `Queue`를 통해 구독하여 처리할 수 있습니다.
|
||||
|
||||
```java
|
||||
@SqsListener(value = "${sqs.event-publish-record}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
|
||||
public void recordEventPublish(@Payload EventPayload eventPayload) {
|
||||
eventPublishRecordCommand.record(eventPayload.getEventId());
|
||||
}
|
||||
```
|
||||
|
||||
**1) 도메인 이벤트가 발생할 때 `첫번째 계층의 이벤트 저장 구독자` 는 트랜잭션을 확장하여 도메인 행위와 함께 이벤트가 저장소에 저장되게 됩니다.**
|
||||
|
||||
**2) `첫번째 계층의 SNS 발행 구독자`는 `AFTER_COMMIT` 옵션으로 인해 도메인의 트랜잭션이 정상 처리되었을 때 SNS로 내부이벤트를 발행하게 됩니다.**
|
||||
|
||||
**3) `두번째 계층의 이벤트 발행 기록 구독자`는 내부이벤트를 수신하여 이벤트가 정상 발행되었음을 기록합니다.**
|
||||
|
||||
이제 내부 이벤트가 메시징 시스템으로 정상 발행되었다면 반드시 이벤트의 발행여부가 업데이트될 것 입니다.
|
||||
|
||||
우리는 이벤트 발행이 누락된 케이스를 사람이 감지하는 것이 아닌 시스템이 감지하여 자동으로 재발행할 수 있도록 배치 프로그램을 구성했습니다.
|
||||
|
||||
이 배치 프로그램은 이벤트 저장 시간을 기준으로 5분이 지나도 발행처리 되지 않은 이벤트를 SNS 에 재발행 합니다.
|
||||
|
||||
- 5분을 기준으로 한 이유는 `AWS SQS`의 재시도 처리가 최대 5분까지 진행될 수 있도록 설정을 해두었기 때문입니다.
|
||||
- 이 배치 프로그램은 직접 이벤트의 상태를 변경하지 않습니다. 이벤트를 재발행하여 메시징 시스템에 정상적으로 전달이 된다면 이벤트 발행 처리 구독자에 의해 구독 처리가 될 것이기 때문입니다.
|
||||
|
||||
**4) 정상 발행되지 않은 이벤트는 `이벤트 발행 감지 배치` 를 통해 자동 재발행 처리됩니다.**
|
||||
|
||||
**이렇게 이벤트 저장소와 발행 처리 구독자, 배치 프로그램을 통해 메시지 발행이 보장되는 이벤트 시스템을 구축하였습니다.**
|
||||
|
||||
#### 이벤트 재발행
|
||||
|
||||
이벤트 저장소에 모든 이벤트가 남아있기 때문에 이벤트 저장소를 통해 모든 이벤트를 재발행할 수 있습니다.
|
||||
|
||||
이를 쉽게 처리할 수 있는 배치 프로그램을 구성했습니다.
|
||||
|
||||
`기간` `특정 행위` `특정 속성` `특정 회원` `특정 이벤트` 의 조건을 통해 `내부 이벤트` `외부 이벤트`를 선택하여 이벤트를 발행할 수 있도록 하였습니다.
|
||||
|
||||
> TIP. **SNS 속성을 이용하여 특정 구독자 계층으로 이벤트 전송하기**
|
||||
>
|
||||
> `AWS SNS` 의 속성을 기반으로 구독자마다 이벤트를 필터링할 수 있는 기능을 사용할 수 있습니다.
|
||||
>
|
||||
> 모든 SNS 속성에 "target" 이라는 속성을 정의하였습니다.
|
||||
>
|
||||
> 각 구독자에게 고유 ID 를 발급하고, `target` 에 대한 조건으로 `고유 ID`, `ALL` 을 정의합니다.
|
||||
>
|
||||
> `ALL` 은 모든 구독자에게 대한 공통 속성으로 모든 이벤트를 구독받게 하기 위함입니다.
|
||||
>
|
||||
> 평상시에는 `target` 속성에 `ALL` 타입을 사용하여 모든 구독자가 이벤트를 사용할 수 있도록 발급을 하며, 특정 구독자로 발행이 필요한 이벤트는 배치시스템에서 `고유 ID` 를 `target` 속성에 작성하여 발행하도록 합니다.
|
||||
>
|
||||
> 이 방법을 통해 특정 구독자로만 이벤트를 발행하는 메커니즘을 만들 수 있습니다.
|
||||
|
||||
#### 기록 테이블 통합
|
||||
|
||||
회원시스템은 개인정보를 처리하는 시스템으로 데이터 조회에 대한 많은 요구사항을 가지고 있습니다.
|
||||
|
||||
고객센터 인입 문제를 해결을 위해, 부정 사용자를 추적하기 위하여, 수사 기관의 협조를 하기 위하는 등 회원의 활동을 추적할 수 있어야 합니다. 그래서 회원시스템에는 이 요구사항을 수행하기 위한 수십개의 기록 테이블이 존재하였습니다.
|
||||
|
||||
이벤트 저장소를 구축함으로써 회원에 대한 모든 활동이 일관성있는 방식으로 저장되었고, 이로 인해 더 이상 별도 기록 테이블들이 필요하지 않게 되었습니다.
|
||||
|
||||
---
|
||||
|
||||
이벤트 저장소까지 구축하며 회원시스템의 이벤트기반 아키텍처 만들기는 완료되었습니다.
|
||||
|
||||
## 마무리
|
||||
|
||||
회원은 대부분의 시스템에 존재하는 도메인입니다. 어떤 시스템에나 존재하는 가장 평범한 도메인이기도 하지만, 동시에 모든 도메인이 회원을 의존하지 않을 수 없는 가장 중심적인 도메인이기도 합니다. 또한 개인정보를 집중적으로 다루고 있는 가장 치명적인 도메인이기도 합니다.
|
||||
|
||||
MSA 의 가장 중심에 위치한 회원 도메인이 외부 시스템에 의한 영향이 없기 위한, 외부 시스템에 영향을 주지 않기 위한, 회원의 개인정보를 안전하게 다루기 위한 고민 끝에 이러한 이벤트 기반의 아키텍처가 만들어졌습니다.
|
||||
|
||||
MSA 중심에서 가장 안정적인 시스템이기 위한 회원시스템의 고민은 계속되고 있습니다.
|
||||
@@ -0,0 +1,31 @@
|
||||
# 통과 기준이 되는 글 여섯 편
|
||||
|
||||
우아한형제들 기술블로그에서 가져왔다. **이 저장소의 글이 이 정도로 읽히면 통과다.**
|
||||
|
||||
| 파일 | 원문 | 무엇을 보나 |
|
||||
|---|---|---|
|
||||
| `17386-kafka-in-our-team.md` | [17386](https://techblog.woowahan.com/17386/) | 개념을 모르는 독자를 데리고 가는 법. 용어 정의 → 우리 사례 |
|
||||
| `20161-elasticsearch-query-optimization.md` | [20161](https://techblog.woowahan.com/20161/) | 현상 → 원인 분석 → 해결 → 개선 결과를 다섯 번 반복 |
|
||||
| `22396-real-distance-system.md` | [22396](https://techblog.woowahan.com/22396/) | 왜 이 문제가 어려운지를 수로 먼저 세운다. 시도 1 실패 → 시도 2 |
|
||||
| `7835-member-event-architecture.md` | [7835](https://techblog.woowahan.com/7835/) | 같은 코드를 네 번 고쳐 보이며 「왜 아직 부족한가」를 쌓는다 |
|
||||
| `13569-10x-faster-batch.md` | [13569](https://techblog.woowahan.com/13569/) | 수정 전/후 코드를 나란히. 빨라져서 생긴 문제까지 |
|
||||
| `23625-rdb-task-queue.md` | [23625](https://techblog.woowahan.com/23625/) | 요구사항 목록 → 표 설계 → 트레이드오프 (본문 일부) |
|
||||
|
||||
`23625` 는 페이지가 자바스크립트로 그려져 본문 일부만 받았다. 나머지 다섯은 전문이다.
|
||||
|
||||
## 이 여섯 편의 공통 골격
|
||||
|
||||
여섯 편이 서로 다른 주제인데도 같은 뼈대를 쓴다. `../references/article-shape.md` 가 그 뼈대를
|
||||
규칙으로 적은 것이다.
|
||||
|
||||
1. **누가 읽으면 좋은지, 무엇을 알아야 하는지 먼저 말한다** — 17386 의 「누가 읽으면 좋을까」
|
||||
2. **문제를 수로 세운다** — 22396 의 「분당 20만 건 × 6 = 120만 건, 초당 2만 TPS」
|
||||
3. **코드를 보여 준다. 이름과 줄 번호로 대신하지 않는다** — 13569 의 수정 전/수정 후 전문
|
||||
4. **틀린 시도를 지우지 않는다** — 22396 의 「시도 1. 데이터 압축」이 실패한 채로 남아 있다
|
||||
5. **결과를 수로 닫는다** — 20161 의 「980ms → 104ms」, 13569 의 「390분 → 30분」
|
||||
6. **감수한 것을 적는다** — 23625 의 「트레이드오프」, 22396 의 TTL 대 명시적 삭제 표
|
||||
|
||||
## 쓰지 않는 방법
|
||||
|
||||
이 글들을 흉내 내지 않는다. 문체를 베끼면 남의 목소리가 된다. 보는 것은 **골격과 밀도**다 —
|
||||
독자가 모르는 것을 어디서 채워 주는가, 주장 하나에 근거를 몇 개 대는가, 코드를 언제 꺼내는가.
|
||||
@@ -0,0 +1,135 @@
|
||||
# 밀도 — 설명을 어디까지 하는가
|
||||
|
||||
순서는 [document-skeleton.md](document-skeleton.md)가 맡는다. 이 문서는 **한 절 안을 무엇으로
|
||||
채우는가**를 맡는다. 순서가 맞는데도 얇게 읽히는 글이 있고, 그것이 이 저장소가 실제로 겪은 문제다.
|
||||
|
||||
기준이 되는 글은 [`../examples/`](../examples/) 여섯 편이다.
|
||||
|
||||
## 잰 값
|
||||
|
||||
`scripts/density.mjs`로 잰 것이다.
|
||||
|
||||
| | 여섯 편 | 이 저장소의 잘 쓴 기록 (AP4) |
|
||||
|---|---|---|
|
||||
| 낱말 | 1,480 ~ 3,164 (중앙값 2,508) | 1,726 |
|
||||
| 문장 평균 낱말 | 17.8 | 17.3 |
|
||||
| 코드블록 | 1 ~ 13 | 8 |
|
||||
| **본문 속 수치** | **10 ~ 32개** | **1개** |
|
||||
| **`##` 절** | **4 ~ 9개** | **18개** |
|
||||
| 표 줄 | 0 ~ 15 | 37 |
|
||||
|
||||
문장 길이도 코드블록 수도 이미 맞다. 갈리는 것은 셋이다.
|
||||
|
||||
## 1. 수치 — 크기를 말하지 않으면 독자는 판단할 수 없다
|
||||
|
||||
여섯 편은 문단마다 잰 값을 댄다. 구조만 말하고 크기를 말하지 않는 문단이 거의 없다.
|
||||
|
||||
> 라이더에게 할당된 신규 배달이 N개라고 했을 때 … 간선: C(2N, 2)개
|
||||
> n=2, C(4, 2) = 6 … 분당 20만 건의 경로를 계산해야 한다면, 시스템은 분당 120만 건의
|
||||
> 실거리 계산을 수행해야 합니다. 이는 초당 약 2만 건으로 … (22396)
|
||||
|
||||
세는 과정을 보여 준다. 결과만 적지 않는다. 이 절이 없으면 뒤에 나오는 Redis 자료구조 선택이
|
||||
과한 짓처럼 읽힌다.
|
||||
|
||||
**무엇을 세는가.** 자료에 있는 것 중에서 고른다 — 몇 개인가, 몇 번 도는가, 몇 바이트인가,
|
||||
몇 밀리초인가, 몇 곳에서 부르는가, 그중 몇이 프로덕션인가, 전과 후가 각각 얼마인가.
|
||||
|
||||
**없으면 만들지 않는다.** 재지 않았으면 「재지 않았다」로 적고 그 자리를 비운다. 다만 자료에
|
||||
숫자가 있는데 「여러 곳」·「대부분」·「크게」로 뭉갠 자리는 되돌린다. 그것이 이 저장소가 가장
|
||||
자주 하는 실수다.
|
||||
|
||||
## 2. 절 — 잘게 자르면 설명이 자리를 못 잡는다
|
||||
|
||||
여섯 편의 `##` 절은 4~9개다. 절 하나가 600~900 낱말인 것도 있다.
|
||||
|
||||
한 절은 **주장 하나 + 그 근거**다. 근거는 코드·출력·수치·표 중 하나 이상이다. 절이 문단
|
||||
두 개로 끝나면 자른 자리가 틀린 것이므로 앞뒤와 합친다.
|
||||
|
||||
절 제목은 그 절이 무엇을 하는지 말한다. 「현상」·「문제 원인 분석 및 해결」·「개선 결과」처럼
|
||||
일하는 이름이 낫다. 「같은 이름의 헤더」처럼 명사구만 두면 무엇을 할 절인지 알 수 없다.
|
||||
|
||||
## 3. 표 — 산문을 대신하지 못한다
|
||||
|
||||
표는 **이미 산문으로 말한 것을 대조할 때** 쓴다. 22396의 TTL 표는 앞 세 문단이 설명한 것을 두
|
||||
열로 정리한 것이고, 20161의 개선 전/후 표도 마찬가지다.
|
||||
|
||||
표로 설명을 시작하면 독자가 각 칸을 스스로 풀어야 한다. 표 줄이 산문 문단보다 많으면 그 글은
|
||||
명세서다.
|
||||
|
||||
## 코드는 이름과 줄 번호로 대신하지 않는다
|
||||
|
||||
읽는 사람은 그 파일을 열 수 없다. `InboxCleanupJob:56`이라고만 적으면 독자에게는 아무 일도
|
||||
일어나지 않는다.
|
||||
|
||||
13569는 「수정 전」과 「수정 후」 메서드를 통째로 싣는다. 7835는 같은 `login` 메서드를 두 번
|
||||
싣는다 — 비관심사가 섞인 판과 분리한 판. 20161은 문제의 ES 쿼리 전문을 싣고 `1️⃣` `2️⃣`로 어디가
|
||||
문제인지 표시한다.
|
||||
|
||||
코드블록 앞이나 뒤에 **무엇을 보라는 한 줄**을 붙인다. 코드만 던지지 않는다.
|
||||
|
||||
인용할 코드가 길면 그 절에 필요한 부분만 자른다. 자른 자리는 `// …`로 표시한다. 자르는 것과
|
||||
이름만 대는 것은 다르다.
|
||||
|
||||
## 틀린 시도를 지우지 않는다
|
||||
|
||||
22396은 「시도 1. 데이터 압축」을 실패한 채로 남긴다.
|
||||
|
||||
> 하나의 지역에 1000개의 서로 다른 좌표 간 모든 실거리를 저장할 경우, JSON 데이터 원본
|
||||
> 크기는 약 24MB였으며, 압축한 크기는 3MB였습니다. 만약 최대 대역폭이 10Gbps라고 했을 때,
|
||||
> 3MB 크기로는 초당 3400회의 조회도 버티기 어렵습니다.
|
||||
|
||||
실패도 수로 닫는다. 24MB → 3MB는 성공한 압축률인데, 그것으로도 모자란다는 것을 3400회로 보인다.
|
||||
|
||||
자료에 실패한 시도가 있으면 살린다. 없는 실패를 지어내지 않는다.
|
||||
|
||||
## 감수한 것을 적고 닫는다
|
||||
|
||||
좋아진 것만 적고 닫는 글은 여섯 편에 없다. 23625는 「트레이드오프」를 목록으로 적고, 22396은
|
||||
TTL 삭제와 명시적 삭제를 장단점 표로 나란히 놓고 **왜 단점이 있는 쪽을 골랐는지** 적는다.
|
||||
13569는 빨라져서 생긴 문제와 그 대응 코드까지 싣는다.
|
||||
|
||||
## 독자를 어디서 채워 주나
|
||||
|
||||
**쓰기 직전에** 채운다. 앞에 몰아 두지 않고 뒤로 미루지도 않는다.
|
||||
|
||||
> 여기서 이야기하는 네트워크 대역폭은, ElastiCache 노드가 네트워크를 통해 초당 전송할 수
|
||||
> 있는 최대 데이터 용량을 의미합니다. (22396 — 대역폭 이야기를 시작하는 자리)
|
||||
|
||||
> track_scores : ES 검색 쿼리에서 각 문서의 관련성 점수(`_score`)를 계산하고 저장할지 여부를
|
||||
> 결정하는 설정 (20161 — 그 설정을 바꾼 절의 첫머리)
|
||||
|
||||
기준은 하나다. **이 낱말을 모르면 다음 문단을 못 읽는가.** 그러면 한 줄로 편다.
|
||||
|
||||
용어 절을 따로 두는 것은 그 글의 중심 개념일 때다(17386의 카프카 용어 일곱 개). 한두 개면
|
||||
쓰는 자리에서 푼다.
|
||||
|
||||
## 검사기를 만족시키려고 문장을 넣지 않는다
|
||||
|
||||
이것은 겪은 일이다. `check_prose.mjs`의 `monotone-endings`가 「물음(~할까요?)·권유(~봅시다)를
|
||||
섞으라」고 안내했고, 그 글을 고친 사람이 절 제목을 물음으로 바꾸고 문단 가운데 수사 의문을 끼워
|
||||
넣었다. 수치는 통과했고 글에는 없던 화자가 생겼다.
|
||||
|
||||
**종결어미 변화는 문장이 하는 일에서 나온다.** 한다체 안에도 어미는 여럿이다.
|
||||
|
||||
| 문장이 하는 일 | 어미 |
|
||||
|---|---|
|
||||
| 확인한 것 | ~였다 · ~했다 · ~됐다 |
|
||||
| 지금 그러한 것 | ~한다 · ~된다 · ~넘긴다 |
|
||||
| 아닌 것 | ~아니다 · ~없다 · ~않는다 |
|
||||
| 이유 | ~때문이다 · ~뿐이다 · ~까지다 |
|
||||
| 값·이름으로 끝나는 문장 | 명사 종결 |
|
||||
|
||||
물음은 그 절이 실제로 답할 때만 쓴다. 22396의 「왜 실거리가 중요할까?」·「어떤 내비게이션을
|
||||
사용할까?」는 그 절이 답하는 물음이라 제목으로 맞다. 답이 예·아니오뿐인 물음
|
||||
(「~인 걸까?」·「~되지 않을까?」)은 답하지 않고 분위기만 만든다. `rhetorical-question`이 잡는다.
|
||||
|
||||
마무리도 같다. 「지금까지 ~를 살펴봤다」로 끝나면 앞 내용을 한 번 더 읽힌 것뿐이다. 17386은 그
|
||||
문장으로 마무리를 **열고** 회고와 참고 자료로 닫는다. 되풀이 뒤에 남은 일이 오면 두고, 되풀이가
|
||||
마무리의 전부면 지운다. `closing-recap`이 경고로 알린다.
|
||||
|
||||
## 낱말 수는 목표가 아니다
|
||||
|
||||
설명을 다 하면 따라오는 값이다. 늘리려고 문단을 넣으면 검사기는 통과하고 글은 나빠진다.
|
||||
|
||||
반대로 1,700 낱말짜리 글이 코드도 수치도 없이 표만 서른 줄이라면, 그것은 짧아서가 아니라
|
||||
**설명을 안 해서** 얇은 것이다.
|
||||
@@ -28,6 +28,18 @@ const RULES = [
|
||||
{ id: 'wrap-up', sev: ERR, re: /(이\s*(관찰|결과|측정)은[^.\n]{0,40}(보여준|드러낸|말해\s*준)|이는[^.\n]{0,30}보여준다)/g,
|
||||
msg: '방금 보여 준 것을 다시 선언합니다. 지우세요.' },
|
||||
|
||||
// 마무리가 되풀이로 끝나는 것을 본다. 17386 은 「지금까지 ~ 소개했습니다」로 열고 회고로
|
||||
// 닫으므로 그 자체는 defect 가 아니다. 뒤에 남은 일이 오는지는 사람이 본다 — 그래서 경고다.
|
||||
{ id: 'closing-recap', sev: WARN,
|
||||
re: /(지금까지|여기까지)[^.\n]{0,80}(살펴봤|살펴보았|알아봤|알아보았|소개했|정리했|다뤘|다루었)/g,
|
||||
msg: '앞 내용을 다시 늘어놓았습니다. 이 뒤에 남은 일이나 감수한 것이 오면 두고, 이것으로 끝나면 지우세요.' },
|
||||
|
||||
// 검사기의 종결어미 수를 채우려고 끼워 넣는 물음. 「왜 ~할까?」·「어떤 ~할까?」처럼 그 절이
|
||||
// 실제로 답하는 물음은 참고 글도 쓴다(22396). 잡는 것은 답이 예·아니오뿐인 수사 의문이다.
|
||||
{ id: 'rhetorical-question', sev: ERR,
|
||||
re: /[^\n?]{4,60}([가-힣]\s*걸까\?|지\s*않을까\?|[가-힣]\s*게\s*아닐까\?|[가-힣]\s*것일까\?)/g,
|
||||
msg: '답이 예·아니오뿐인 물음을 끼워 넣었습니다. 종결어미 수를 채우려고 넣은 문장이면 지우고, 답할 물음이면 무엇을 묻는지 적으세요.' },
|
||||
|
||||
{ id: 'nominalized', sev: ERR, re: /(채워진\s*목록\s*수|준비한\s*SQL\s*문장|획득한[^.\n]{0,10}객체\s*수|[가-힣]+에\s*대한\s*(측정|비교|확인|분석))/g,
|
||||
msg: '사건을 명사구로 바꿨습니다. 동사로 적으세요.' },
|
||||
|
||||
@@ -104,18 +116,29 @@ function positiveChecks(text, lines, docMode, rulesMode) {
|
||||
}
|
||||
|
||||
// 3. 종결어미가 한 가지뿐인가
|
||||
//
|
||||
// 한다체 문서는 `한다`·`였다`·`아니다`·`없다`·명사 종결이 전부 다른 어미다. 이것을 한 덩어리로
|
||||
// 세면 잘 쓴 한다체 글이 단조롭다고 잡히고, 고치려는 사람은 물음표 문장을 끼워 넣게 된다.
|
||||
// 실제로 그렇게 됐다. 그래서 어미를 잘게 센다.
|
||||
const kinds = new Set();
|
||||
for (const st of sentences) {
|
||||
if (/(습니다|았습니다|었습니다)[.!]?$/.test(st)) kinds.add('습니다');
|
||||
if (/입니다[.!]?$/.test(st)) kinds.add('입니다');
|
||||
if (/(했다|이다|였다|된다|한다)[.!]?$/.test(st)) kinds.add('한다');
|
||||
if (/(겠습니다|하겠습니다|보겠습니다)[.!]?$/.test(st)) kinds.add('겠습니다');
|
||||
if (/(한다|된다|만든다|넘긴다|받는다)[.!]?$/.test(st)) kinds.add('한다');
|
||||
if (/(했다|였다|됐다|되었다|았다|었다)[.!]?$/.test(st)) kinds.add('했다');
|
||||
if (/(아니다|없다|같다|다르다|이다)[.!]?$/.test(st)) kinds.add('이다');
|
||||
if (/(못한다|않는다|않았다|못했다)[.!]?$/.test(st)) kinds.add('부정');
|
||||
if (/(뿐이다|때문이다|까지다|것이다)[.!]?$/.test(st)) kinds.add('설명');
|
||||
if (/[가-힣A-Za-z0-9`)\]]$/.test(st.replace(/[.!]$/, ''))) kinds.add('명사');
|
||||
if (/까요\??$/.test(st) || /\?$/.test(st)) kinds.add('물음');
|
||||
if (/(봅시다|보자|맙시다|주세요)[.!]?$/.test(st)) kinds.add('청유');
|
||||
}
|
||||
if (!rulesMode && sentences.length >= 8 && kinds.size <= 1) {
|
||||
out.push({ id: 'monotone-endings', sev: ERR,
|
||||
msg: `문장 ${sentences.length}개가 모두 같은 종결어미입니다. 예고(~살펴보겠습니다)·물음(~할까요?)·권유(~봅시다)를 섞습니다.` });
|
||||
msg: `문장 ${sentences.length}개가 모두 같은 종결어미입니다. 문장이 하는 일이 다르면 어미도 달라집니다 — `
|
||||
+ `확인한 것은 ~였다, 지금 그러한 것은 ~한다, 아닌 것은 ~아니다, 이유는 ~때문이다. `
|
||||
+ `물음이나 권유를 끼워 넣어 수를 채우지 마세요.` });
|
||||
}
|
||||
|
||||
// 4. 독자를 데리고 다니는 문장
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
// 글이 얼마나 채워져 있는지 잰다. 문체가 아니라 밀도를 본다.
|
||||
//
|
||||
// node density.mjs 초안.md [...] 기준값과 나란히 보여 준다
|
||||
// node density.mjs --baseline dir/*.md 기준값을 다시 잰다
|
||||
//
|
||||
// 기준값은 ../examples 의 여섯 편에서 잰 것이다. 맞히려고 문단을 넣지 않는다 —
|
||||
// 낮게 나오면 설명이 빠진 자리를 찾으라는 뜻이지 분량을 늘리라는 뜻이 아니다.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
|
||||
// examples 의 여섯 편에서 잰 값. 23625 는 본문 일부만 받아서 낱말 수 기준에서 뺐다.
|
||||
const BASE = {
|
||||
words: [1277, 2871], sentWords: [14.2, 18.3], code: [1, 13],
|
||||
sections: [4, 9], tableLines: [0, 15],
|
||||
};
|
||||
|
||||
// 수치 개수는 장르가 가른다. 잰 것을 쓰는 글과 구조를 쓰는 글의 기준이 다르다.
|
||||
// 측정 글 13569(13) · 23625(18) · 20161(24) · 22396(36)
|
||||
// 구조 글 17386(2) · 7835(5)
|
||||
const NUMBERS = { 측정: [13, 36], 구조: [2, 12] };
|
||||
|
||||
const NUM = /\d[\d,.]*\s*(?:ms|초|분|시간|일|년|개월|주|배|%|건|개|줄|번|회|명|자|KB|MB|GB|TB|Gbps|TPS|QPS|만|천|억)/g;
|
||||
|
||||
function measure(text) {
|
||||
// frontmatter 와 주석은 글이 아니다
|
||||
let t = text.replace(/^---\n.*?\n---\n/s, "").replace(/<!--.*?-->/gs, "");
|
||||
const marked = t.match(/<!-- body:start -->([\s\S]*?)<!-- body:end -->/);
|
||||
if (marked) t = marked[1];
|
||||
|
||||
const code = (t.match(/^```/gm) || []).length / 2;
|
||||
const tableLines = (t.match(/^\s*\|/gm) || []).length;
|
||||
const sections = (t.match(/^## /gm) || []).length;
|
||||
const prose = t.replace(/```[\s\S]*?```/g, "").replace(/^\s*\|.*$/gm, "");
|
||||
const words = prose.split(/\s+/).filter(Boolean).length;
|
||||
const sentences = prose.split(/(?<=[.!?다])\s+/).filter((s) => s.trim().length > 10);
|
||||
const numbers = (t.match(NUM) || []).length;
|
||||
const proseParas = prose.split(/\n\s*\n/).filter((p) => p.trim().length > 40).length;
|
||||
|
||||
return { words, sentWords: +(words / Math.max(sentences.length, 1)).toFixed(1),
|
||||
code, numbers, sections, tableLines, proseParas };
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
// 잰 것을 쓴 글인가 구조를 쓴 글인가. Case 는 측정, Concept 은 `--구조` 로 부른다.
|
||||
const genre = args.includes("--구조") ? "구조" : "측정";
|
||||
const files = args.filter((a) => !a.startsWith("--"));
|
||||
|
||||
if (args.includes("--baseline")) {
|
||||
const all = files.map((f) => measure(readFileSync(f, "utf8")));
|
||||
const range = (k) => [Math.min(...all.map((m) => m[k])), Math.max(...all.map((m) => m[k]))];
|
||||
for (const k of [...Object.keys(BASE), "numbers"]) console.log(` ${k}: [${range(k)}]`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (!files.length) {
|
||||
console.error("쓰는 법: node density.mjs [--구조] 초안.md");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const LABEL = { words: "낱말", sentWords: "문장 평균 낱말", code: "코드블록",
|
||||
numbers: "수치", sections: "## 절", tableLines: "표 줄" };
|
||||
|
||||
let bad = 0;
|
||||
for (const file of files) {
|
||||
const m = measure(readFileSync(file, "utf8"));
|
||||
console.log(`\n${basename(file)} (${genre} 글 기준)`);
|
||||
for (const [k, [lo, hi]] of Object.entries({ ...BASE, numbers: NUMBERS[genre] })) {
|
||||
const v = m[k];
|
||||
const ok = v >= lo && v <= hi;
|
||||
const note = ok ? "" : v < lo ? " ← 모자람" : " ← 넘침";
|
||||
if (!ok && k !== "words" && k !== "code") bad++;
|
||||
console.log(` ${LABEL[k].padEnd(16)} ${String(v).padStart(6)} 기준 ${lo}~${hi}${note}`);
|
||||
}
|
||||
if (m.tableLines > m.proseParas)
|
||||
console.log(` ! 표 ${m.tableLines}줄이 산문 ${m.proseParas}문단보다 많다 — 표가 설명을 대신하고 있다`);
|
||||
if (m.numbers < NUMBERS[genre][0])
|
||||
console.log(` ! 수치가 ${m.numbers}개다 — 자료에 있는 크기를 「여러」·「대부분」으로 뭉갠 자리를 찾는다`);
|
||||
if (m.sections > 9)
|
||||
console.log(` ! 절이 ${m.sections}개다 — 문단 두 개짜리 절을 앞뒤와 합친다`);
|
||||
}
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -44,6 +44,16 @@ slug를 비우면 제목에서 만든다. 한글 제목도 로마자로 옮겨
|
||||
**Project에 slug가 없으면 공개 화면에 프로젝트가 표시되지 않는다.** 공개 계약의
|
||||
`ProjectSummary`가 `slug`와 `path`를 요구하기 때문이다. `주제·프로젝트` 화면에서 확인한다.
|
||||
|
||||
## frontmatter 의 `topic`
|
||||
|
||||
**폴더 이름과 같은 slug 를 적는다.** 화면에 보이는 한글 주제 이름은 `topicName` 에 따로 둔다.
|
||||
`topic` 이 표시 이름이면 폴더와 대조할 수 없다.
|
||||
|
||||
```yaml
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
```
|
||||
|
||||
## 기록이 가리키는 로컬 파일
|
||||
|
||||
기록은 `docs/<프로젝트>/tech-log-studio/<주제>/<종류>/` 에 있고, 그림과 증거는 같은 프로젝트의
|
||||
@@ -69,6 +79,13 @@ evidence:
|
||||
본문(`bodyMarkdown`)을 뺀 모든 칸은 평문이다. 백틱·파이프·`#`는 글자 그대로 보이고 줄바꿈만
|
||||
살아난다. 그 줄바꿈이 유일한 서식이므로 아끼지 않는다.
|
||||
|
||||
**무엇을 지우나.** 백틱·별표·코드펜스는 글자 그대로 보인다. 식별자는 남기고 표시 문자만 뺀다.
|
||||
`InboxCleanupJob:56` 은 InboxCleanupJob:56 으로, `**this is the parameter**` 는 그 문장만 남긴다.
|
||||
코드펜스 안이 `이름 : 값` 꼴이면 펜스 줄만 지우면 그대로 읽힌다. 줄바꿈이 유일한 서식이므로
|
||||
문단 사이 빈 줄은 지킨다.
|
||||
|
||||
관계(`근거`) 절은 평문 칸이 아니다. 기록을 거는 목록이라 `- **제목**` 표기를 그대로 둔다.
|
||||
|
||||
**나열은 `이름 : 값`으로 끊는다.** 쉼표로 이으면 읽는 사람이 항목을 세어야 한다.
|
||||
|
||||
```text
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
칸 목록과 상한은 `record-kinds.md`, 문장 규칙은 `explaining.md`, 문서군의 리듬은 `ai-tells.md`에
|
||||
있다. 이 문서는 **그 칸을 무엇으로 채우는가**다.
|
||||
|
||||
**이미 쓴 47건에서 뽑았다.** keycloak 23건(Case 4·Concept 6·Reference 7·Question 4·Decision 2),
|
||||
n+1liner 24건(Case 5·Reference 7·Question 5·Decision 7). 「대개 이렇게 쓴다」는 말은 그 47건이
|
||||
그렇게 돼 있다는 뜻이다.
|
||||
**Studio 에 실제로 올라간 47건에서 뽑았다.** keycloak 23건(Case 4·Concept 6·Reference 7·
|
||||
Question 4·Decision 2), n+1liner 24건(Case 5·Reference 7·Question 5·Decision 7).
|
||||
「대개 이렇게 쓴다」는 말은 그 47건이 그렇게 돼 있다는 뜻이다.
|
||||
|
||||
`clean-architecture-backend-template` 의 949건은 다른 절 이름으로 쓰여 있었고 이 기준에 맞춰
|
||||
고쳤다. 올라간 적 없는 초안이 아니라 **올라간 것**이 기준이다.
|
||||
|
||||
## 파일 뼈대 — 다섯 종류가 같다
|
||||
|
||||
@@ -30,6 +33,18 @@ id · kind · slug · title · topic · project · status · studio
|
||||
|
||||
frontmatter 는 메타데이터, `##` 는 Studio 의 칸, 제목 아래 첫 문단은 요약이다. 47건 모두 이 모양이다.
|
||||
|
||||
**`## 요약` 이라는 절을 만들지 않는다.** 요약은 제목 바로 아래 문단이다. 절로 만들면 Studio 에
|
||||
그런 칸이 없어서 통째로 사라진다.
|
||||
|
||||
**`## 출처` 도 칸이 아니다.** 원본 분석 문서나 파일 경로는 frontmatter 의 `source` 에 적는다.
|
||||
본문 마지막에 두면 게시된 글에 저장소 내부 경로가 그대로 실린다.
|
||||
|
||||
```yaml
|
||||
source:
|
||||
- analysis/05-adapter-outbound-persistence-jpa.md#L354
|
||||
module: adapter-inbound-graphql
|
||||
```
|
||||
|
||||
**관계 항목은 굵은 제목 한 줄 + 이유 한 줄**이다.
|
||||
|
||||
```markdown
|
||||
@@ -58,6 +73,8 @@ frontmatter 는 메타데이터, `##` 는 Studio 의 칸, 제목 아래 첫 문
|
||||
테스트로 확인한 범위」, 「Redirect URI와 CORS에서 아직 확인하지 않은 부분」), 2건이 「다음 선택」,
|
||||
나머지가 지표 읽는 법이나 남긴 이유다. **재지 않은 것을 적지 않고 닫는 Case 는 없다**
|
||||
|
||||
그 마지막 절은 **본문 안**이다. 칸으로 빼면 Studio 에 그런 칸이 없어 사라진다.
|
||||
|
||||
코드블록에는 무엇을 보라는 한 줄을 붙인다. 표 앞이나 뒤에 그 표를 어떻게 읽는지 적는다. 예시는
|
||||
한 규모로 고정한다 — 표가 전체 계열을 이미 보여 준다.
|
||||
|
||||
@@ -131,3 +148,35 @@ basisVersion: oauth2-proxy 7.15.2 · Nginx auth_request
|
||||
- 그림 안에는 이름만 넣는다. 문장은 `<desc>` 와 옆 문단에 둔다
|
||||
- 그림과 증거는 frontmatter 의 `assets` · `evidence` 로 잇는다. 같은 파일을 기록 옆에 복사하지 않는다
|
||||
- 본문은 `<!-- body:start -->` 와 `<!-- body:end -->` 사이다. 그 밖은 Studio 로 가지 않는다
|
||||
|
||||
## 관계를 어디서 가져오나
|
||||
|
||||
관계는 **다른 기록을 가리키는 링크**다. 지어내지 않는다. 분해 계약(`root-tree.md`)이 노드마다
|
||||
`relations` 를 적어 두면 그것을 그대로 옮긴다.
|
||||
|
||||
계약이 관계를 적지 않은 노드는 한 가지 규칙만 쓸 수 있다 — **Reference 의 근거 사건은 같은
|
||||
`source` 리프의 판정이 소유한다.** 그래서 같은 분석 문서에서 나온 Reference 와 Case 는 서로
|
||||
걸 수 있다. 그 밖의 짝은 읽고 정해야 한다. 「같은 모듈이다」는 관계가 아니라 분류다.
|
||||
|
||||
```markdown
|
||||
## 관계
|
||||
|
||||
- **SQL 실패가 재시도 불가로 분류된다**
|
||||
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
|
||||
```
|
||||
|
||||
Studio 는 Decision 만 관계를 1개 이상 요구한다. 나머지는 비어도 게시되지만, 관계 없는 기록은
|
||||
다른 기록에서 도달할 수 없다.
|
||||
|
||||
## Studio 파서가 거절하는 세 가지
|
||||
|
||||
`check_body.mjs` 가 잡는다. 셋 다 글자를 바꾸지 않고 고칠 수 있다.
|
||||
|
||||
| 쓴 것 | 파서가 읽는 것 | 고치는 법 |
|
||||
|---|---|---|
|
||||
| `` `:11`~`:14` `` | `~…~` 를 취소선으로 | `` `:11`\~`:14` `` |
|
||||
| `afterPropertiesSet(:43)` | `:43` 을 인라인 디렉티브로 | `(\:43)` 또는 백틱으로 감싼다 |
|
||||
| `:::note` 안에 문단 둘 | 거절 | 문단 하나만 담거나 절 제목 아래 평문으로 푼다 |
|
||||
|
||||
`## 확인하지 못한 것` 처럼 절 제목이 이미 무엇인지 말하는 자리에서는 `:::note` 로 다시 감싸지
|
||||
않는다. 제목과 콜아웃이 같은 말을 두 번 한다.
|
||||
|
||||
Reference in New Issue
Block a user