From fe9f4c8e35c9b92ced33b74f785b42119f725657 Mon Sep 17 00:00:00 2001 From: jeonseyeong Date: Thu, 20 Nov 2025 14:52:06 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AC=B8=EC=84=9C=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + backend/apm_bulk_design.md | 198 ----- backend/apm_data_structure.md | 417 ----------- backend/apm_guidelines.md | 158 ---- backend/apm_query_api_spec_v2.yaml | 916 ----------------------- backend/bulk_indexer_backpressure_fix.md | 610 --------------- backend/redis_bucket_cache_spec.md | 508 ------------- backend/report.md | 198 ----- backend/rollup_metrics_spec.md | 225 ------ 9 files changed, 1 insertion(+), 3230 deletions(-) delete mode 100644 backend/apm_bulk_design.md delete mode 100644 backend/apm_data_structure.md delete mode 100644 backend/apm_guidelines.md delete mode 100644 backend/apm_query_api_spec_v2.yaml delete mode 100644 backend/bulk_indexer_backpressure_fix.md delete mode 100644 backend/redis_bucket_cache_spec.md delete mode 100644 backend/report.md delete mode 100644 backend/rollup_metrics_spec.md diff --git a/.gitignore b/.gitignore index 4c9823c..4fd488e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ pnpm-debug.log* lerna-debug.log* # Ignore all Markdown files repo-wide… *.md +apm_query_api_spec_v2.yaml # …but keep README.md files tracked !README.md # Keep project-specific ignores inside subdirectories (.gitignore within backend/, frontend/, etc.) diff --git a/backend/apm_bulk_design.md b/backend/apm_bulk_design.md deleted file mode 100644 index 7c61c46..0000000 --- a/backend/apm_bulk_design.md +++ /dev/null @@ -1,198 +0,0 @@ -# Design Specification: Bulk + Limited Concurrency Ingestion for Elasticsearch - -## 배경과 기존 문제 - -현행 APM 파이프라인은 Kafka에서 로그 메시지를 하나씩 읽어와 `index()` API로 Elasticsearch(E) 데이터스트림에 저장한다. 네트워크 왕복을 매 메시지마다 수행하고, 단건 `index()`가 끝날 때까지 기다린 뒤 다음 메시지를 처리하는 구조 때문에 초당 처리량이 낮고 병렬성이 사실상 없었다. 특히 수십만 건 이상의 로그를 처리할 때 소비자 스레드가 몇 시간 동안 대기하는 현상이 발생하였다. Elastic API 문서는 대규모 데이터 적재 시 단건 `index()` 대신 Bulk API를 사용해야 네트워크 오버헤드를 줄이고 색인 속도를 높일 수 있다고 강조한다【102369654029175†L753-L762】. 그리고 적절한 배치 크기를 찾기 위해 실험이 필요하며, HTTP 요청은 100 MB를 넘지 않도록 주의해야 한다【102369654029175†L753-L757】. 또한 다른 사용자들도 네트워크 왕복을 아끼기 위해 Bulk API를 병렬로 실행하면서 적당한 청크 크기(1 MB–10 MB)와 적정 동시 플러시 수를 찾아야 한다고 조언한다【750595237790637†L52-L61】. - -본 문서는 이러한 문제를 해결하고, **대량 로그(수십만~수백만 건)도 빠르게 색인하면서 at‑least‑once 보장**을 유지하는 아키텍처를 설계한다. Node (NestJS) + Kafka + Elasticsearch 환경을 대상으로 하지만, 설계 개념은 다른 언어와 프레임워크에도 적용 가능하다. - -## 목표 - -- Kafka에서 들어오는 로그를 **at‑least‑once**로 Elasticsearch에 저장하고, 색인 실패 시 재처리를 지원한다. -- **Bulk API**를 사용하여 단건 요청 대비 색인 속도를 크게 높인다. Medium 기사에서도 bulk indexing이 단건 저장보다 훨씬 빠르다고 강조한다【85091753486274†L114-L120】. Python 테스트에서도 1000개 문서를 단건으로 index 하는 데 124 초가 걸린 반면, `streaming_bulk`는 0.67 초, `parallel_bulk`는 0.46 초만에 끝났다【596974854923973†L30-L124】. -- 과도한 동시성으로 Elasticsearch 클러스터를 과부하하지 않도록 **동시 플러시 수를 제한**한다. 실제 사례에서도 32개의 동시 프로세스로 색인하던 것을 1개 프로세스로 줄이자 클러스터 지표가 크게 개선되었다【901591924758741†L125-L143】. -- Kafka consumer가 색인을 기다리지 않고 무제한으로 enqueue하여 메모리를 고갈시키는 일을 방지하기 위해 **back‑pressure** 메커니즘을 도입한다. -- 로그 데이터 변환/검증 과정과 색인 과정의 책임을 분리하여 재사용성을 높이고, 서비스 코드와 분리된 Bulk 인덱싱 모듈을 제공한다. - -## 아키텍처 개요 - -### 데이터 흐름 - -``` -Kafka (topic: apm.logs) ──> NestJS consumer ──> DTO 변환/검증 ──> BulkIndexer (버퍼/플러시) ──> Elasticsearch 데이터스트림 -``` - -![Bulk ingestion flow]({{file:file-3hAzDofY4oYDp2MfohfxJ2}}) - -1. **Kafka consumer**는 메시지를 받아 DTO 변환 및 필수 검증을 수행한다. 검증에서 오류가 발생하면 해당 메시지를 건너뛰고 Kafka offset을 커밋한다. -2. DTO를 Elasticsearch 문서 구조(`LogDocument`)로 매핑한 뒤 **BulkIndexer**에 enqueue 한다. 이 단계에서는 실제 색인 작업을 수행하지 않고, 메모리 버퍼에 문서와 offset 정보를 저장한다. enqueue 호출은 `Promise`를 반환하며, 문서가 색인되면 resolve되고 실패 시 reject된다. -3. **BulkIndexer**는 배치 크기 또는 시간 조건이 만족될 때 버퍼에 쌓인 문서를 `_bulk` API로 전송한다. 클라이언트는 `Content‑Type: application/x‑ndjson`를 사용하여 NDJSON 포맷으로 전송해야 한다는 Elastic 문서를 준수한다【102369654029175†L738-L752】. -4. Flush 결과를 보고 개별 문서의 `resolve/reject`를 호출한다. 한 아이템이라도 실패하면 단순화된 전략으로 모든 문서를 실패로 취급하여 Kafka 재처리를 유도한다. 이로써 at‑least‑once 보장을 유지하지만 중복 삽입 가능성을 인정한다. -5. Consumer handler는 enqueue 반환값을 `await`하므로, 문서가 성공적으로 색인돼야만 offset을 커밋한다. 실패 시 예외를 발생시켜 Kafka consumer가 재시도하게 한다. - -### BulkIndexer 버퍼 설계 - -BulkIndexer는 싱글톤 서비스로서 다음과 같은 상태를 가진다: - -- `buffer: Array` — 각 항목은 `document`, Kafka offset 메타데이터, `resolve`/`reject` 콜백을 포함한다. -- `flushTimer: NodeJS.Timeout | null` — 시간 기반 플러시 예약용 타이머. -- `inFlightFlushes: number` — 현재 진행 중인 flush의 수. `MAX_PARALLEL_FLUSHES` 한도를 초과하면 새 flush를 미뤄서 클러스터 과부하를 방지한다. - -#### enqueue 동작 - -`enqueue(document, offsetInfo): Promise`는 다음과 같이 동작한다. - -1. `Promise`를 생성하고, `resolve`/`reject`를 `BufferedItem`에 저장한다. -2. `buffer`에 아이템을 추가한다. -3. 버퍼 길이가 `BATCH_SIZE` 이상이면 즉시 `flush()`를 시도한다. `flush()`는 `inFlightFlushes < MAX_PARALLEL_FLUSHES`일 때만 실행되며, 그렇지 않으면 다음 enqueue 또는 timer에서 재시도한다. -4. `flushTimer`가 설정되어 있지 않으면 `setTimeout(flush, FLUSH_INTERVAL_MS)`를 등록하여 일정 시간이 지나도 남아있는 문서를 flush할 수 있게 한다. -5. `Promise`를 반환하여 consumer handler가 `await`할 수 있도록 한다. - -#### flush 동작 - -`flush()`는 버퍼에서 현재 쌓여있는 항목을 모두 꺼내 하나의 bulk 요청으로 전송한다. - -- 메모리 버퍼는 매 flush 후 비워진다. 다른 메시지가 들어오면 새 버퍼에서 다시 쌓인다. -- `_bulk` 요청은 NDJSON 형식으로 `{ "index" : {"_index": data_stream} }\n{document}\n` 패턴으로 구성한다. -- HTTP 요청은 100 MB를 넘지 않도록 해야 한다는 Elastic 문서를 준수한다【102369654029175†L753-L757】. 배치 크기 기준은 문서 수(`BATCH_SIZE`) 또는 추정 바이트 수(예: 5 MB)를 동시에 고려할 수 있다. -- 요청 결과에서 `errors` 필드가 `true`이면 **간단한 전략으로 전체 배치를 실패로 간주**한다. Jörg Prante의 조언처럼 하나의 bulk 내 아이템들이 실패하면 동시에 재시도하는 것이 구현을 단순화한다【750595237790637†L52-L61】. -- 성공 시 각 아이템의 `resolve()`를 호출하여 consumer handler가 정상적으로 offset을 커밋할 수 있도록 한다. 실패 시 `reject()`를 호출하여 예외를 propagate 하고 Kafka에서 재처리한다. - -#### 플러시 조건 - -- `BATCH_SIZE` (문서 개수 기준): 500 ~ 1000개부터 시작하여 환경에 따라 조정한다. Elastic 커뮤니티에서는 1 MB ~ 10 MB 정도의 bulk 크기를 권장하며, 너무 큰 청크(예: 64 MB 이상)는 GC나 heap 부하를 유발할 수 있다고 한다【750595237790637†L52-L61】. -- `FLUSH_INTERVAL_MS` (시간 기준): 배치 크기에 도달하지 않더라도 일정 시간(예: 1000 ms) 이상 버퍼에 머무는 문서가 있으면 flush한다. 낮은 트래픽 환경에서 문서가 너무 오래 지연되는 것을 막는다. -- `MAX_PARALLEL_FLUSHES`: 동시에 몇 개의 bulk 요청을 허용할지 제어한다. 값이 1이면 한 번에 하나만 flush하고, 나머지는 버퍼에 대기한다. 높은 동시성을 주려면 2 ~ 4까지 늘리고, 클러스터에서 429 (too many requests) 에러가 발생하면 줄인다【85091753486274†L118-L129】. - -### Kafka offset 커밋 전략 - -NestJS Kafka 마이크로서비스는 handler가 `Promise`를 반환하고 예외를 발생시키는지 여부를 기준으로 offset을 커밋하거나 재처리한다. 따라서 bulk flush 결과가 성공했을 때만 enqueue에서 반환된 `Promise`를 resolve하고, 메시지 처리 함수(`handleLogEvent`)는 `await`를 통해 색인 완료를 보장해야 한다. 실패 시 reject하여 예외가 전파되면 offset이 커밋되지 않고 Kafka가 재전송한다. 이 방식은 **at‑least‑once** 보장을 유지하는 동시에 기본 NestJS API를 변경하지 않아 구현이 간단하다. - -### 오류 처리 전략 - -1. **DTO 검증 오류**: 현재 구조처럼 `InvalidLogEventError`를 구분하여 메시지를 skip 하고 성공적으로 offset을 커밋한다. 잘못된 이벤트는 재시도해도 성공할 가능성이 없으므로 유실을 허용한다. -2. **bulk 전체 실패(네트워크 오류 등)**: flush한 모든 문서를 실패로 간주하고 각 `reject`를 호출한다. Kafka에서는 재처리되어 중복 색인이 발생할 수 있으나 로그 데이터는 idempotent하지 않아도 되므로 허용한다. -3. **bulk 부분 실패**: Elasticsearch 응답에서 `errors`가 `true`일 때 성공/실패 항목을 따로 구분할 수 있다. 구현이 복잡해지므로 초기에는 **전체 배치를 실패**로 간주하는 단순 전략을 사용한다. 향후 요구 사항에 따라 실패한 문서만 재처리하거나 별도의 DLQ(Dead Letter Queue)를 도입할 수 있다. - -### 동시성 및 back‑pressure - -노드 프로세스는 이벤트 루프를 사용하므로 무제한으로 I/O를 발행할 경우 메모리나 소켓 수가 고갈될 수 있다. 따라서 BulkIndexer는 `MAX_PARALLEL_FLUSHES`를 통해 한 번에 수행할 flush 수를 제한한다. 버퍼에 데이터가 넘치면 Kafka consumer는 자연스럽게 `await enqueue` 호출에서 대기하게 되어 back‑pressure가 형성된다. 이를 통해 consumer가 처리 가능한 속도 이상으로 메시지를 읽어오지 않게 된다. 실제 사례에서도 색인 동시성을 32에서 1로 줄이자 클러스터 CPU와 검색 지연이 크게 줄었다【901591924758741†L125-L143】. - -#### 다중 작업자(Workers) 사용과 429 대응 - -Elastic Docs는 단일 스레드가 bulk 요청을 보내서는 클러스터 자원을 모두 활용할 수 없다고 설명한다【389495691530842†L924-L931】. 따라서 여러 스레드나 프로세스를 사용해 동시적으로 bulk 요청을 보내면 I/O 비용을 나누고 전체 처리량을 높일 수 있다. 그러나 너무 많은 병렬 작업자는 클러스터의 메모리와 CPU를 고갈시켜 `TOO_MANY_REQUESTS (429)` 오류를 초래할 수 있다【389495691530842†L932-L941】. 권장 사항은 다음과 같다: - -* **복수 작업자/스레드 사용**: 한 스레드만으로는 클러스터를 포화시키기 어렵다. 여러 작업자를 통해 병렬로 bulk 요청을 전송해 전체 throughput을 높인다【389495691530842†L924-L931】. -* **적정 동시성 찾기**: 적정한 작업자 수는 환경마다 다르므로 실험적으로 찾아야 한다. 한 번에 작업자 수를 조금씩 늘리면서 CPU나 디스크 I/O가 포화되는 지점을 확인한다【389495691530842†L943-L945】. -* **429 오류 감지와 백오프**: 클러스터가 과부하되면 `EsRejectedExecutionException`을 통해 429 코드를 반환한다【389495691530842†L937-L941】. 이때는 indexer가 잠시 대기 후 재시도하는 지수 백오프 로직을 적용해야 한다【389495691530842†L937-L941】. 앞서 언급한 Medium 기사에서도 429 오류가 발생할 때는 임의의 지연으로 확장 지수 백오프를 쓰라고 권장한다【85091753486274†L118-L129】. - -이러한 지침을 따라 `MAX_PARALLEL_FLUSHES`와 Kafka consumer 인스턴스 수를 조정하면 클러스터 리소스를 적절히 활용할 수 있다. 너무 적으면 쓰기 throughput이 낮아지고, 너무 많으면 429 오류와 지연이 발생한다. 따라서 모니터링 도구를 활용해 적절한 지점을 찾아야 한다. - -## Elasticsearch 설정 가이드 - -Bulk 인덱싱 성능은 클라이언트 설계뿐 아니라 Elasticsearch 클러스터 설정에도 크게 좌우된다. - -### Bulk API와 배치 크기 - -- Elastic 문서는 bulk 요청에 정답이 되는 문서 수가 없으며, 시스템 환경에 맞는 최적값을 찾아야 한다고 설명한다【102369654029175†L753-L755】. 요청의 크기는 100 MB 이하로 제한되어야 한다【102369654029175†L753-L757】. -- Elastic 커뮤니티의 조언에 따르면, 1 MB~10 MB 또는 500 ~ 1000개의 문서를 한 배치로 보내는 것이 일반적이다【750595237790637†L52-L61】. 너무 큰 배치(수십 MB 이상)는 GC와 heap 부하를 유발하고, 너무 작은 배치는 네트워크 왕복이 많아진다. - -### Refresh interval 조정 - -- 색인된 문서를 검색에서 볼 수 있도록 만드는 **refresh** 작업은 비용이 크며, 지나치게 자주 호출하면 색인 속도가 저하된다. Elastic Docs는 대용량 bulk 작업을 수행할 때 `index.refresh_interval`을 `-1`로 설정하여 refresh를 비활성화한 뒤, 작업 완료 후 다시 원하는 값으로 설정할 것을 권장한다【389495691530842†L947-L986】. refresh를 비활성화하면 색인 중에는 문서가 검색되지 않지만, 로그 파이프라인은 약간의 지연을 허용하므로 적합하다. -- 기본값(1 초 refresh)은 색인량이 적고 검색량이 적을 때 최적이지만, 정기적인 검색 요청이 있는 경우 refresh interval을 30 초로 늘리면 색인 성능이 개선될 수 있다【389495691530842†L953-L966】. - -### Replica 비활성화 - -- 대량 초기 적재 시 `index.number_of_replicas`를 0으로 설정하여 색인 성능을 높일 수 있다. Elastic Docs는 초기 로드를 빠르게 끝낸 뒤 다시 원래 값으로 되돌릴 것을 권장한다【389495691530842†L1011-L1018】. 다만 replica를 0으로 설정하면 노드 장애 시 데이터 손실 위험이 있으므로 외부 저장소나 Kafka에 데이터가 안전하게 남아있어야 한다. - -### 기타 튜닝 사항 - -- **index buffer size**와 **translog flush threshold**: 노드가 heavy indexing만 수행한다면 `indices.memory.index_buffer_size`를 충분히 크게(예: 최소 512 MB / shard) 설정하여 버퍼가 너무 자주 flush되지 않도록 한다【85091753486274†L241-L266】. translog의 `flush_threshold_size`를 늘려서 디스크 flush 빈도를 줄이면 성능이 향상될 수 있다【85091753486274†L241-L266】. -- **Auto‑generated IDs**: 자체 아이디를 지정하면 Elasticsearch가 기존 문서를 조회하여 중복 여부를 확인해야 하므로 느려질 수 있다. Elastic Docs는 auto‑generated id를 사용할 때 색인 성능이 향상된다고 설명한다【389495691530842†L1054-L1060】. 하지만 로그 데이터는 추적을 위해 trace id나 timestamp를 key로 사용할 수도 있으며, 이 경우 id 조회 비용을 감수하거나 idempotent 파이프라인을 설계해야 한다. -- **Refresh interval과 replica 변경 이후 복구**: bulk 적재가 끝나면 refresh interval을 원래 값으로, replica를 원래 개수로 되돌린 뒤 필요하다면 `_forcemerge`를 수행하여 검색 성능을 최적화한다【389495691530842†L947-L1003】. - -## Node / NestJS 구현 지침 - -### BulkIndexer 서비스 인터페이스 - -```ts -interface OffsetInfo { - topic: string; - partition: number; - offset: string; -} - -interface BulkIndexer { - enqueue(document: LogDocument, offsetInfo: OffsetInfo): Promise; - flush(): Promise; -} -``` - -BulkIndexer를 NestJS의 provider로 등록하여 전체 프로세스에서 하나의 인스턴스를 사용한다. `enqueue()`는 위에서 설명한 버퍼 로직을 구현하고, `flush()`는 현재 버퍼를 `_bulk` API로 전송한다. flush는 `MAX_PARALLEL_FLUSHES`를 넘지 않는 범위에서 실행된다. - -### 서비스 연동 예시 흐름 - -1. **컨슈머** (`handleLogEvent`): DTO 파싱, 검증 후 `logIngestService.ingest(dto)`를 호출한다. -2. **logIngestService**: DTO를 Elasticsearch 문서로 변환하고 `bulkIndexer.enqueue(document, offsetInfo)`를 호출한다. 반환되는 `Promise`를 `await`하여 색인 완료를 기다린다. -3. **BulkIndexer**: 내부 버퍼에 데이터와 offset을 저장하고 플러시 조건을 점검한다. -4. **Kafka consumer config**: `eachMessage`/`handleLogEvent`에서 발생한 예외는 NestJS Kafka가 재시도를 처리하게 한다. `autoCommit: false` 또는 기본 자동 커밋 설정과 함께 at‑least‑once를 유지할 수 있도록 NestJS handler 패턴을 그대로 사용한다. - -### 제한된 동시성 구현 - -`MAX_PARALLEL_FLUSHES` 값을 통해 몇 개의 bulk 요청을 동시에 처리할지 제어한다. 예를 들어 1로 두면 항상 하나씩 flush하고, 2 이상으로 올리면 Elastic 클러스터의 thread pool과 대기열이 허용하는 범위까지 동시 요청을 늘린다. 실험을 통해 적정 값을 찾아야 한다. Elastic 커뮤니티에서는 먼저 1 MB batch / 1 thread, 다음 2 MB / 1 thread, 다음 2 MB / 2 threads 등으로 조합을 실험해 보고 throughput이 떨어지는 지점을 찾으라고 조언한다【750595237790637†L52-L61】. - -### 배치 크기 추정 - -Batch size를 문서 개수와 바이트 수 기준으로 동시에 제어하는 것이 이상적이다. 각 문서의 JSON 문자열 길이(또는 Buffer 길이)를 측정하여 누적 크기를 추적하고, 5 MB ~ 10 MB를 넘지 않도록 flush한다. Jörg Prante는 64 MB bulk가 너무 크며 GC 지연을 유발할 수 있다고 지적했다【750595237790637†L52-L61】. - -### 에러·재시도 정책 - -Elasticsearch `_bulk` API는 응답에 `items` 배열을 포함하여 각 작업의 성공/실패 여부를 알려준다. 단순한 첫 구현에서는 **하나의 문서라도 실패하면 전체 배치 실패**로 처리하여 메시지를 재수신하도록 한다. 향후에는 부분 실패 문서만 재시도하는 로직을 추가하거나 Kafka DLQ(Dead Letter Queue)를 도입할 수 있다. 클러스터가 과부하되어 `429 Too Many Requests`가 발생하는 경우에는 bulkIndexer가 **지수 백오프**를 적용하여 재시도할 수 있다【85091753486274†L118-L129】. - -### DTO 검증 최적화 - -대량 ingest 시 class‑validator 기반 DTO 검증이 병목이 될 수 있다. 필수 필드만 간단히 체크하여 검증 속도를 높이고, 복잡한 검증은 API 단이나 별도의 경로에서 수행하는 것이 좋다. 적재 모드에서는 debug 로그를 문서마다 찍지 말고 일정 건수마다 집계 로그를 찍어 I/O 오버헤드를 줄인다. - -### 멀티 파티션·병렬 컨슈머 - -Kafka topic의 파티션 수를 늘리고 동일한 consumer group으로 여러 인스턴스를 실행하면 파티션 단위로 workload가 분산된다. BulkIndexer는 프로세스 내부에서만 상태를 공유하므로 각 컨슈머 인스턴스에 하나씩 생성된다. 클러스터 전체를 고려할 때, BulkIndexer의 동시 flush 수(`MAX_PARALLEL_FLUSHES`) 곱하기 인스턴스 수가 Elasticsearch cluster의 쓰기 스레드 풀을 넘지 않도록 주의한다. 클러스터가 429 에러를 반환하면 consumer 인스턴스 수 또는 flush 동시성을 줄이고, 지수 백오프를 도입하여 쓰기 속도를 조절한다. - -## 벤치마킹과 튜닝 - -### 초기 파라미터 제안 - -| 파라미터 | 초기값 | 설명 | -|---|---|---| -| `BATCH_SIZE` | 500 ~ 1000 | 한 batch당 문서 수. 작은 값은 네트워크 오버헤드가 크고, 너무 큰 값은 ES heap/GC에 부담을 준다【750595237790637†L52-L61】. | -| `BATCH_BYTE_LIMIT` | 5 MB | 문서 크기를 합산하여 5 MB를 넘기 전에 flush. Elastic 커뮤니티에서 1 MB ~ 10 MB 사이를 권장함【750595237790637†L52-L61】. | -| `FLUSH_INTERVAL_MS` | 1000 ms | 문서가 적게 들어오는 상황에서 1 초마다 flush하여 지연을 줄인다. | -| `MAX_PARALLEL_FLUSHES` | 1 | 한 번에 하나의 bulk 요청을 수행하여 클러스터 과부하를 방지하고 관찰하기 쉽게 한다. 필요시 2 ~ 4까지 늘려 실험한다【901591924758741†L125-L143】. | - -### 튜닝 방법 - -1. **배치 크기 조정**: 모니터링 도구(Kibana, Elastic APM 등)를 통해 bulk 요청의 처리 시간(`took`), 성공률, CPU 사용량을 관찰한다. 1 MB batch / 1 thread부터 시작해서 조금씩 크기나 동시성을 늘린 뒤 throughput이 떨어지는 지점을 찾는다【750595237790637†L52-L61】. -2. **Refresh interval/replica 변경**: 대량 적재를 진행하기 직전 해당 인덱스의 `refresh_interval`을 `-1`로 변경하고, `number_of_replicas`를 0으로 변경한다. 적재가 끝나면 원래 값으로 복구하고 필요 시 force merge를 수행한다【389495691530842†L947-L1003】. -3. **동시성 조정**: bulk flush의 동시에 실행되는 개수를 늘리면 throughput이 증가할 수 있으나, `429 Too Many Requests`가 나타나거나 검색 지연이 급증하면 값을 줄여야 한다. People.ai 사례에서는 동시 프로세스를 32에서 1로 줄여 클러스터 부하가 줄었다【901591924758741†L125-L143】. 용량이 충분한 클러스터에서는 2 ~ 4까지 늘려볼 수 있다. -4. **지수 백오프**: flush가 실패하거나 `429`를 받으면 1 초, 2 초, 4 초 등 점점 길게 대기한 뒤 재시도하여 클러스터에 과도한 부하를 주지 않도록 한다【85091753486274†L118-L129】. -5. **문서 설계 최적화**: 불필요하게 큰 문서나 깊은 중첩을 피하고, 필요하지 않은 필드는 매핑에서 제외한다. Search Guard 블로그에서도 인덱싱 성능을 높이려면 불필요한 필드를 줄이고 문서 크기를 최소화해야 한다고 권고한다【71945213822368†L100-L110】. - -## 추가 고려 사항 - -### 인덱스 롤오버 및 시간 기반 설계 - -로그와 APM 데이터는 시간이 지남에 따라 방대해지므로 **데이터스트림 + ILM(인덱스 라이프사이클 관리)**를 사용하여 새로운 세그먼트를 주기적으로 롤오버하고 오래된 세그먼트를 삭제하는 것이 좋다. 롤오버 간격(예: 하루, 10GB 등)을 정의하여 각 데이터스트림이 적당한 크기를 유지하도록 한다. 이는 클러스터의 merge 부하를 줄이고 검색 성능을 안정적으로 유지한다. - -### 롤업/집계 파이프라인 분리 - -1분 단위 버킷 카운트 같은 집계 작업은 ingest 경로에서 수행하지 말고, 별도의 후처리 파이프라인(ES Transform, Aggregation API + cron job, Redis 카운터 등)으로 분리하여 색인 성능에 영향을 주지 않도록 한다. 같은 컨슈머 경로에서 집계까지 수행하면 per-message 처리 시간이 길어져 throughput을 크게 떨어뜨린다. - -### 하드웨어 및 운영 환경 - -- SSD 디스크 사용, 충분한 RAM, 그리고 Elasticsearch heap 사이즈를 전체 메모리의 50% 이하로 설정해 filesystem cache를 활용하는 것이 좋다【71945213822368†L66-L72】. 또한 swap을 비활성화하고 JVM heap 사이즈를 적절히 설정해야 한다【389495691530842†L1024-L1037】. -- Translog 크기(`flush_threshold_size`)와 인덱싱 버퍼를 조절해 flush/merge 빈도를 줄이면 성능이 향상될 수 있다【85091753486274†L241-L266】. -- Node 애플리케이션에서 CPU 집약적인 로직(class-validator, JSON 변환 등)을 줄이고, 로그 출력을 최소화해 I/O 부하를 줄인다. - -## 결론 - -Bulk API와 제한된 동시성 전략을 통해 APM 로그 파이프라인의 색인 속도를 **수십 배 이상** 향상시킬 수 있다. 핵심은 메시지를 메모리 버퍼에 모았다가 일정 크기·시간마다 `_bulk` 요청으로 묶어 보내는 **BulkIndexer**를 도입하고, 동시에 플러시되는 요청 수를 제어하여 Elasticsearch 클러스터를 과부하하지 않는 것이다. Elastic 문서는 적절한 배치 크기와 refresh interval 조정을 통해 색인 성능을 최적화할 수 있다고 강조한다【389495691530842†L947-L986】, 【102369654029175†L753-L757】. 커뮤니티 경험도 1 MB~10 MB 정도의 청크와 실험을 통한 sweet spot 찾기를 권장하며, replica를 0으로 두고 refresh를 비활성화하면 초기 적재 속도를 크게 높일 수 있다고 말한다【750595237790637†L52-L61】. 이러한 설계를 코드화하면 단건 `index()` 기반 구조를 리팩터링하여 수십만 건의 로그도 수 분 이내에 색인할 수 있을 것이다. \ No newline at end of file diff --git a/backend/apm_data_structure.md b/backend/apm_data_structure.md deleted file mode 100644 index eaae0a4..0000000 --- a/backend/apm_data_structure.md +++ /dev/null @@ -1,417 +0,0 @@ -# APM 데이터 수집 및 API 설계를 위한 구체적인 스펙 - -이 문서는 **APM MVP** 개발을 위해 *필수 데이터*만 추려서 수집하고, Elasticsearch 인덱싱 및 백엔드 API 구조를 구체적으로 정의한 것이다. 기존 APM 지침에서 설명한 전체 스펙을 참고하되, 이번 프로젝트에서는 **Logs**와 **Spans**만 수집하고, **Metrics**는 Elasticsearch 집계에서 파생한다. - -## 1 수집 데이터 예시(Logs / Spans) - -### 1.1 로그(Log) 이벤트 - -로그는 특정 시점에 발생한 이벤트를 기록한다. MVP에서는 디버깅과 상태 추적에 필요한 핵심 필드만 남기고, 불필요한 세부 속성은 제거한다. - -**필수 필드** - -- `timestamp` – 이벤트 발생 시각(ISO‑8601). -- `service_name` – 로그를 발생시킨 서비스 이름. Elastic의 공식 문서에 따르면 서비스 이름은 불변의 고유값이며 `keyword` 타입으로 저장한다【629151000030751†L150-L170】. -- `environment` – 실행 환경(`prod`, `stage`, `dev` 등). 역시 `keyword` 타입이다【629151000030751†L150-L170】. -- `level` – 로그 레벨(`DEBUG|INFO|WARN|ERROR`). -- `message` – 사람이 읽을 수 있는 메시지. -- `trace_id`(선택) – 이 로그가 속한 트레이스 식별자. APM 문서에서 `trace.id`는 `keyword` 타입으로 정의된다【629151000030751†L228-L233】. -- `span_id`(선택) – 관련 스팬 식별자. -- `labels`(선택) – 낮은 카디널리티의 추가 태그. Elastic APM 참조에 따르면 `labels`는 문자열/숫자/불리언 값의 평면 맵이며 전체 객체로 저장한다【629151000030751†L144-L148】. - -**예시 1 – 일반 로그** - -```json -{ - "type": "log", - "timestamp": "2025-11-10T12:00:12.123Z", - "service_name": "order-service", - "environment": "prod", - "level": "INFO", - "message": "Created order successfully", - "trace_id": "8e3b9f5bcf214ea7", - "span_id": "a1b2c3d4e5f6g7h8" -} -``` - -**예시 2 – HTTP 오류 로그** - -```json -{ - "type": "log", - "timestamp": "2025-11-10T12:02:01.500Z", - "service_name": "payment-service", - "environment": "prod", - "level": "ERROR", - "message": "Payment declined by card issuer", - "trace_id": "c3d4e5f6a1b2c3d4", - "span_id": "abcd1234efef5678", - "http_method": "POST", - "http_path": "/pay", - "http_status_code": 502, - "labels": { - "error_code": "CARD_DECLINED" - } -} -``` - -### 1.2 스팬(Span) 이벤트 - -스팬은 요청·트랜잭션을 구성하는 **시간 구간**이다. 하나의 `trace_id` 아래 여러 스팬이 트리 구조로 구성된다. Elastic APM 문서에 따르면 `trace.id`와 `parent.id`(부모 스팬 ID)는 `keyword` 타입【629151000030751†L228-L238】이며, `service.name` / `service.environment`도 `keyword`이다【629151000030751†L150-L170】. 프로젝트에서는 요청 지연 시간 분석을 위해 `duration_ms`(밀리초)와 상태(`OK` 또는 `ERROR`)만 저장한다. - -**필수 필드** - -- `timestamp` – 스팬 시작 시각. -- `service_name`, `environment` – 위와 동일. -- `trace_id` – 트레이스 ID【629151000030751†L228-L233】. -- `span_id` – 스팬 ID. -- `parent_span_id` – 부모 스팬 ID(루트 스팬은 `null`). -- `name` – 작업 이름(예: `POST /orders`). -- `kind` – 스팬 종류: `SERVER`(외부 요청 처리), `CLIENT`(외부 호출), `INTERNAL`(내부 연산). -- `duration_ms` – 수행 시간(밀리초). -- `status` – `OK` 또는 `ERROR`. -- `labels`(선택) – 낮은 카디널리티의 부가 태그. - -HTTP 요청·응답과 관련된 스팬에는 최소한 `http_method` / `http_path` / `http_status_code`를 포함한다. 데이터베이스 쿼리나 외부 API 호출일 때는 필요한 태그(`db.system`, `db.name` 등)만 `labels`에 넣고, 전체 쿼리문이나 연결 문자열처럼 카디널리티가 높은 정보는 저장하지 않는다. - -**예시 3 – HTTP 서버 스팬** - -```json -{ - "type": "span", - "timestamp": "2025-11-10T12:00:12.100Z", - "service_name": "order-service", - "environment": "prod", - "trace_id": "8e3b9f5bcf214ea7", - "span_id": "a1b2c3d4e5f6g7h8", - "parent_span_id": null, - "name": "POST /orders", - "kind": "SERVER", - "duration_ms": 45.3, - "status": "OK", - "http_method": "POST", - "http_path": "/orders", - "http_status_code": 201 -} -``` - -**예시 4 – DB 호출 스팬(Client)** - -```json -{ - "type": "span", - "timestamp": "2025-11-10T12:00:12.150Z", - "service_name": "order-service", - "environment": "prod", - "trace_id": "8e3b9f5bcf214ea7", - "span_id": "db1ef2345a6b7c8d", - "parent_span_id": "a1b2c3d4e5f6g7h8", - "name": "SELECT orders", - "kind": "CLIENT", - "duration_ms": 5.8, - "status": "OK", - "labels": { - "db.system": "postgresql", - "db.name": "panopticon" - } -} -``` - -스팬에 포함될 태그는 **카디널리티가 낮은 정보만** 선택한다. 예를 들어 `db.statement`나 `db.connection_string`처럼 값이 길고 매번 다른 정보는 저장하지 않는다. 필요 시 별도 필드(`db_statement`)로 저장하되 `index: false`로 지정하여 검색을 비활성화한다. - -## 2 Elasticsearch 인덱싱 템플릿 구조 - -MVP에서는 로그와 스팬을 각각 **데이터 스트림**에 저장한다. Elastic 문서에서는 시간 기반 데이터에 데이터 스트림을 사용하면 롤오버·ILM 관리가 쉬워지고 필드 수가 줄어드는 장점이 있다고 설명한다【897321232077862†L819-L827】. 데이터 스트림 이름은 `--` 패턴을 따르며【897321232077862†L831-L840】, 여기서는 `type=logs` 또는 `traces`, `dataset=apm.<서비스이름>` , `namespace=prod|stage|dev` 구조를 사용한다. 예시: `logs-apm.order-service-prod`, `traces-apm-prod`. - -### 2.1 로그 템플릿 - -아래 템플릿은 `logs-apm.*` 데이터 스트림에 적용된다. 공통 필드는 Elastic APM 스펙에 따라 `keyword`로 매핑된다【629151000030751†L150-L170】【629151000030751†L228-L233】. - -```json -PUT _index_template/logs-apm-template -{ - "index_patterns": ["logs-apm.*"], - "data_stream": {}, - "template": { - "mappings": { - "dynamic_templates": [ - { - "labels": { - "path_match": "labels.*", - "mapping": { - "type": "keyword", - "ignore_above": 256 - } - } - } - ], - "properties": { - "@timestamp": { "type": "date" }, - "type": { "type": "keyword" }, - "service_name": { "type": "keyword" }, - "environment": { "type": "keyword" }, - "trace_id": { "type": "keyword" }, - "span_id": { "type": "keyword" }, - "level": { "type": "keyword" }, - "message": { "type": "text" }, - "http_method": { "type": "keyword" }, - "http_path": { "type": "keyword" }, - "http_status_code": { "type": "integer" } - } - } - }, - "composed_of": [], - "priority": 500 -} -``` - -**설명** - -- `dynamic_templates.labels` – 모든 `labels.*` 필드를 `keyword` 로 저장하여 집계·필터에 사용한다. 값 길이를 256자 이하로 제한한다. -- `message`는 검색을 위해 `text` 타입을 사용했다. 필요시 `keyword` 서브필드를 추가해 정확도 위주의 검색을 지원할 수 있다. -- `http_*` 필드는 HTTP 로그에만 존재한다. 다른 로그에는 나타나지 않는다. - -### 2.2 스팬 템플릿 - -스팬은 `traces-apm.*` 데이터 스트림에 저장한다. Elastic 스펙에서 `trace.id`, `service.name` 등은 `keyword` 타입이다【629151000030751†L150-L170】【629151000030751†L228-L233】. - -```json -PUT _index_template/traces-apm-template -{ - "index_patterns": ["traces-apm.*"], - "data_stream": {}, - "template": { - "mappings": { - "dynamic_templates": [ - { - "labels": { - "path_match": "labels.*", - "mapping": { - "type": "keyword", - "ignore_above": 256 - } - } - } - ], - "properties": { - "@timestamp": { "type": "date" }, - "type": { "type": "keyword" }, - "service_name": { "type": "keyword" }, - "environment": { "type": "keyword" }, - "trace_id": { "type": "keyword" }, - "span_id": { "type": "keyword" }, - "parent_span_id": { "type": "keyword" }, - "name": { "type": "keyword" }, - "kind": { "type": "keyword" }, - "duration_ms": { "type": "double" }, - "status": { "type": "keyword" }, - "http_method": { "type": "keyword" }, - "http_path": { "type": "keyword" }, - "http_status_code": { "type": "integer" }, - "db_statement": { "type": "text", "index": false } - } - } - }, - "composed_of": [], - "priority": 500 -} -``` - -**설명** - -- `duration_ms`는 밀리초 단위 실수형(`double`)으로 저장한다. -- `db_statement`는 검색을 비활성화하여 저장 크기만 유지한다. 필요 시 스팬 상세 화면에서만 확인한다. -- 트랜잭션 수준 집계는 스팬 데이터를 이용해 계산한다. 개별 트레이스 문서(루트 스팬 요약)를 별도로 저장하지 않는다. - -### 2.3 데이터 스트림 및 ILM 간단 규칙 - -- 데이터 스트림 명명법은 `-apm.-`를 따른다【897321232077862†L831-L840】. 예: `logs-apm.order-service-prod`, `traces-apm-prod`. -- 기본 ILM 정책은 로그를 14일, 트레이스를 7일 보관하는 정도의 단순 규칙으로 시작한다. 이후 트래픽에 따라 샘플링과 보존 기간을 조정할 수 있다. - -## 3 백엔드 API를 위한 데이터 구조 - -NestJS 기반 백엔드에서 데이터 수집과 조회를 구현하기 위해 다음과 같이 DTO(Data Transfer Object)를 정의한다. 모든 DTO는 `class-validator`를 이용해 필수 필드를 검증하지만, MVP에서는 과도한 검증 로직을 피한다. - -### 3.1 수집용 DTO - -#### 3.1.1 `LogDto` - -```ts -import { IsISO8601, IsString, IsOptional, IsObject, IsIn } from 'class-validator'; - -export class LogDto { - readonly type = 'log'; - - @IsISO8601() - timestamp: string; - - @IsString() - service_name: string; - - @IsString() - environment: string; - - @IsIn(['DEBUG', 'INFO', 'WARN', 'ERROR']) - level: string; - - @IsString() - message: string; - - @IsOptional() - @IsString() - trace_id?: string; - - @IsOptional() - @IsString() - span_id?: string; - - @IsOptional() - @IsString() - http_method?: string; - - @IsOptional() - @IsString() - http_path?: string; - - @IsOptional() - http_status_code?: number; - - @IsOptional() - @IsObject() - labels?: Record; -} -``` - -#### 3.1.2 `SpanDto` - -```ts -import { IsISO8601, IsString, IsNumber, IsOptional, IsIn, IsObject } from 'class-validator'; - -export class SpanDto { - readonly type = 'span'; - - @IsISO8601() - timestamp: string; - - @IsString() - service_name: string; - - @IsString() - environment: string; - - @IsString() - trace_id: string; - - @IsString() - span_id: string; - - @IsOptional() - @IsString() - parent_span_id?: string; - - @IsString() - name: string; - - @IsIn(['SERVER', 'CLIENT', 'INTERNAL']) - kind: 'SERVER' | 'CLIENT' | 'INTERNAL'; - - @IsNumber() - duration_ms: number; - - @IsIn(['OK', 'ERROR']) - status: 'OK' | 'ERROR'; - - @IsOptional() - @IsString() - http_method?: string; - - @IsOptional() - @IsString() - http_path?: string; - - @IsOptional() - http_status_code?: number; - - @IsOptional() - @IsObject() - labels?: Record; - - /** - * 긴 쿼리 문자열이나 민감한 정보는 저장만 하고 검색하지 않기 위해 분리한다. - */ - @IsOptional() - @IsString() - db_statement?: string; -} -``` - -### 3.2 조회용 DTO 및 API 설계 - -API는 읽기 전용으로 설계한다. 대표적인 엔드포인트와 응답 구조는 아래와 같다. - -#### 3.2.1 `/traces/{traceId}` – 트레이스 조회 - -- **요청**: `GET /traces/{traceId}` -- **응답**: 아래 `TraceResponse` 모델. - -```ts -export interface SpanItem { - timestamp: string; - span_id: string; - parent_span_id?: string; - name: string; - kind: string; - duration_ms: number; - status: string; - service_name: string; - environment: string; - labels?: Record; -} - -export interface LogItem { - timestamp: string; - level: string; - message: string; - service_name: string; - span_id?: string; - labels?: Record; -} - -export interface TraceResponse { - trace_id: string; - spans: SpanItem[]; - logs: LogItem[]; -} -``` - -컨슈머는 Elasticsearch에서 `traces-apm.*` 스트림에서 해당 `trace_id`의 스팬을 조회하고, `logs-apm.*`에서 같은 `trace_id`의 로그를 찾는다. 이 응답은 호출 그래프를 재구성할 수 있는 기본 정보를 제공한다. - -#### 3.2.2 `/services/{serviceName}/metrics` – 메트릭 집계 - -메트릭은 개별 이벤트에서 파생된다. Query‑API는 Elasticsearch aggregation을 사용해 QPS, p95/p90/p50 latency, error rate 등을 계산하여 아래 구조로 반환한다. - -```ts -export interface MetricPoint { - timestamp: string; // 버킷의 시작 시각 - value: number; // 측정값 - labels?: Record; -} - -export interface MetricResponse { - metric_name: string; // ex: "http_requests_total", "latency_p95_ms", "latency_p90_ms", "latency_p50_ms", "error_rate" - service_name: string; - environment: string; - points: MetricPoint[]; -} -``` - -예를 들어 QPS를 계산할 때 Query‑API는 `kind=SERVER` 스팬을 1분 버킷으로 집계하여 `http_requests_total` 값을 반환한다. latency는 `duration_ms`에 대해 단일 `percentiles` 집계를 수행하면서 p95/p90/p50 값을 동시에 계산하고, 에러율은 `status='ERROR'` 스팬 비율로 계산한다. 메트릭 라벨(`labels`)은 HTTP 메서드나 엔드포인트 등 저카디널리티 필터에만 사용한다. - -## 4 요약 및 개발 지침 - -- **데이터 수집 범위** – Logs와 Spans만 수집하며, Metrics는 나중에 집계한다. 각각의 데이터 예시는 위 JSON을 참고한다. -- **불필요한 필드 제거** – 수집 시점에 `db.connection_string`, `db.statement` 등 고카디널리티 또는 민감한 정보는 제외한다. -- **Elasticsearch 구조** – 데이터 스트림을 사용해 `logs-apm.*`와 `traces-apm.*`에 저장한다【897321232077862†L819-L827】. 공통 필드는 `keyword`와 `date` 타입으로 매핑한다【629151000030751†L150-L170】【629151000030751†L228-L233】. `labels.*`는 `keyword`로 동적 매핑한다. -- **백엔드 구현** – NestJS DTO와 컨트롤러/서비스 계층을 이용해 자료형을 검증하고 분리한다. Producer → Kafka → Consumer → Elasticsearch 파이프라인에서 데이터 가공을 최소화하고, Query‑API는 Elasticsearch DSL 집계로 메트릭을 계산한다. - -이 문서를 기반으로 직접 코드를 작성하면, 3주짜리 MVP에서도 확장 가능하고 유지보수성 높은 APM 시스템을 구현할 수 있다. diff --git a/backend/apm_guidelines.md b/backend/apm_guidelines.md deleted file mode 100644 index 992899b..0000000 --- a/backend/apm_guidelines.md +++ /dev/null @@ -1,158 +0,0 @@ -# APM 프로젝트를 위한 ChatGPT·Codex 지침 - -이 문서는 APM 프로젝트를 원활히 수행하기 위한 **ChatGPT**(웹 인터페이스)와 **Codex**(프로젝트 루트에서 실행할 CLI)용 지침입니다. 두 도구는 한 팀처럼 협업하며, 세부 사항을 준수해 짧은 기간 내에 최소 기능 제품(MVP)을 완성하는 것을 목표로 합니다. 아래의 지침은 프로젝트 구성, 관찰 데이터 정의와 저장 구조, 구현 전략, 코딩 원칙, 두 도구의 역할과 협업 방법을 명확히 설명합니다. 필요 시 최신 정보 검색이나 문서 참고를 통해 보완합니다. - -## 1. 프로젝트 개요 - -### 1.1 시스템 구성 - -APM은 **fluent‑bit**와 **OpenTelemetry Collector**와 같은 수집기로부터 **Logs**, **Metrics**, **Traces** 및 **Spans** 데이터를 수집합니다. 데이터는 다음과 같은 단계로 흐릅니다: - -1. **Producer (NestJS)** – 수집된 데이터를 받아 카프카에 발행합니다. -2. **Kafka** – 고성능 분산 메시징 플랫폼으로 데이터를 버퍼링하고 전송합니다. 카프카는 스트림을 퍼블리시·서브스크라이브, 저장 및 실시간 처리할 수 있는 플랫폼으로 설계되었습니다【550419036444003†L224-L233】. -3. **Stream-Processor (NestJS)** – 카프카에서 메시지를 소비하고 데이터를 정제한 후 Elasticsearch에 색인하는 쓰기 전용 서버입니다. -4. **Query‑API (NestJS)** – 브라우저/클라이언트 요청에 따라 Elasticsearch에서 검색·집계만 수행하는 읽기 전용 서버입니다. -5. **Elasticsearch** – 관찰 데이터 저장소로, 분산 검색·집계에 최적화되어 있습니다. 데이터 스트림을 이용해 로그, 메트릭, 트레이스를 분리 저장합니다【897321232077862†L819-L827】. - -### 1.2 MVP 개발 범위 - -이번 프로젝트는 두 명의 인원이 3주 동안 수행하는 MVP입니다. 따라서: - -* **핵심 기능에 집중합니다.** 데이터 수집과 저장, 기본 검색·집계가 동작하는지 확인합니다. UI나 복잡한 알람 시스템, 대시보드는 후순위입니다. -* **과도한 기능 추가를 피합니다.** 코드 및 구성 파일은 최소한의 책임만 수행하며, 필요 이상의 설정값, 플러그인, 커스텀 로직을 넣지 않습니다. -* **확장성을 염두에 둡니다.** MVP라도 SOLID 원칙을 지켜 추후 기능 추가나 리팩터링이 수월하도록 설계합니다. - -## 2. 관찰 데이터 정의 및 저장 설계 - -### 2.1 Logs, Metrics, Traces, Spans 정의 - -아래 정의는 APM 프로젝트 전반에 걸쳐 사용되며, 저장 구조와 집계 로직에 직접적인 영향을 줍니다. - -| 타입 | 요약 정의 | 예시 및 핵심 필드 | -|---------|-----------|------------------| -| **Log** | 특정 시점에 발생한 이벤트를 텍스트나 JSON 형태로 기록한 것입니다. 로그는 디버깅이나 이벤트 추적에 사용되며 `timestamp`, `level`, `service.name`, `trace.id`, `span.id` 등의 필드를 포함합니다. | 예: `{"timestamp":"2025-11-09T10:15:32Z","level":"INFO","service.name":"order-service","trace.id":"b7f3…","message":"Created order"}` | -| **Metric** | 집계 가능한 숫자 값의 시계열 데이터입니다. 메트릭은 CPU 사용률, 요청 수, 지연 시간(p95/p90/p50) 등 시스템 상태를 추상화합니다. 고카디널리티를 피하도록 레이블을 신중히 설계합니다. | 예: `{"metric.name":"http.server.requests.count","service.name":"order-service","labels":{"path":"/orders","status":"2xx"},"value":152}` | -| **Trace** | 하나의 요청·트랜잭션 전체 여정을 표현하는 상위 개념입니다. 단일 `trace.id`에 여러 스팬이 속하며 분산 시스템의 호출 그래프를 복원할 수 있습니다. | 예: `trace_id=b7f3…`에 루트 스팬(HTTP 요청)과 하위 스팬(DB 호출, 외부 결제 호출 등)이 포함됩니다. | -| **Span** | Trace를 구성하는 가장 작은 단위의 작업입니다. `span.id`, `parent.id`, `service.name`, `start_time`, `end_time`, `attributes`를 갖습니다. 스팬들의 트리로 전체 트레이스를 재구성합니다. | 예: `{"span.id":"payment-1","parent.id":"order-1","service.name":"payment-service","span.name":"ChargeCard","attributes":{"payment.amount":39000}}` | - -이 네 가지 데이터는 서로 연결되어야 합니다. 로그·메트릭·스팬에 공통적으로 **`trace.id`**, **`span.id`**를 포함해 같은 요청을 추적할 수 있도록 합니다. - -### 2.2 Elasticsearch 저장 구조 설계 - -Elasticsearch는 **데이터 스트림**을 사용해 시계열 데이터를 효율적으로 저장합니다. 데이터 스트림은 숨겨진 백업 인덱스들의 논리적 집합으로, 자동 롤오버·ILM(인덱스 수명 주기 관리)을 쉽게 적용할 수 있습니다. Elastic 문서는 데이터 스트림이 로그, 메트릭, 트레이스 같은 계속 생성되는 데이터에 적합하고, 필드 수 감소, 세밀한 데이터 제어, 유연한 명명 규칙 등의 장점을 제공한다고 설명합니다【897321232077862†L819-L827】. - -#### 2.2.1 데이터 스트림 네이밍 스킴 - -APM 데이터 스트림의 이름은 `--` 형식을 따릅니다【897321232077862†L831-L840】. 여기서: - -* **type**: `logs`, `metrics`, `traces` -* **dataset**: APM 플러그인이 정의한 세부 데이터셋(`apm.error`, `apm.transaction`, `apm.service_summary` 등) -* **namespace**: 환경(`dev`, `stage`, `prod`) 또는 사업부 등 자유롭게 지정 - -예를 들어 **생산 환경의 주문 서비스 로그**는 `logs-apm.app.order-service-prod`에, **트레이스 데이터**는 `traces-apm-prod`에, **내부 메트릭**은 `metrics-apm.internal-prod`에 저장됩니다【897321232077862†L846-L895】. 서비스 이름에 대문자나 특수문자가 포함되면 밑줄로 변환되므로, 애초에 소문자와 간결한 이름을 사용합니다【897321232077862†L870-L879】. - -#### 2.2.2 공통 필드와 매핑 - -모든 데이터 스트림에 다음 공통 필드를 포함해야 합니다: - -* `@timestamp` – 이벤트 발생 시간 (ISO8601) -* `trace.id` – 트레이스 식별자 -* `span.id` – 스팬 식별자 (해당되는 경우) -* `service.name`, `service.environment`, `service.version` -* `host.name`, `container.id`, `kubernetes.*` – 배포 환경 식별자 -* `labels` – 추가 메타데이터 (카디널리티 관리 필수) - -Logs, Metrics, Traces 각각의 인덱스 템플릿에서 위 필드는 `keyword` 또는 `date` 타입으로 지정합니다. 로그 도큐먼트에는 `log.level`, `message`, `error.*`, HTTP 필드 등을, 메트릭 도큐먼트에는 `metric.name`, `metric.unit`, `value` 등을 추가합니다. 트레이스(스팬) 도큐먼트에는 `event.duration`(나노초), `parent.id`, `transaction.id`, `span.name`, `span.type` 등을 포함합니다. 데이터 구조와 예시는 앞선 설명을 참고해 표준화합니다. - -#### 2.2.3 인덱스 수명 주기(ILM) - -MVP 단계에서는 기본 ILM 정책을 사용해 저장 비용을 관리합니다. 예를 들어 **logs**는 14일 보관 후 삭제, **traces**는 7일간 전체 저장 후 샘플링·압축, **metrics**는 최대 90일 보관 등 단순한 규칙을 적용할 수 있습니다. 세부 정책은 추후 확장 시 조정합니다. - -## 3. 구현 가이드라인 - -### 3.1 공통 코딩 원칙 (NestJS + TypeScript) - -1. **SOLID 준수** – NestJS는 의존성 주입과 데코레이터가 잘 설계되어 있어 SOLID를 적용하기 쉽습니다. - - 각 원칙(단일 책임, 개방/폐쇄, 리스코프 치환, 인터페이스 분리, 의존 역전)을 지키면 코드가 간결하고 확장 가능해집니다. NestJS 애플리케이션을 유지보수하기 위해 이러한 원칙이 검증된 방법임을 강조합니다【497485247725756†L229-L233】. 예를 들어, 컨트롤러는 HTTP 요청만 처리하고 비즈니스 로직은 서비스로 분리하며, 인터페이스를 사용해 테스트 시 구현을 손쉽게 교체할 수 있습니다【497485247725756†L243-L347】. -2. **모듈 간 결합 최소화** – 각 모듈(AppModule, ProducerModule, ConsumerModule, QueryModule 등)은 의존성 주입을 통해 서로를 느슨하게 연결합니다. 공통 인터페이스와 DTO만 공유하고 내부 구현은 은닉합니다. 특정 서비스에만 필요한 설정이나 로직을 다른 모듈에 노출하지 않도록 합니다. -3. **단순한 설정** – Kafka 연결, Elasticsearch 클라이언트 설정 등은 별도의 `ConfigurationService`에서 환경 변수로만 관리하고, 복잡한 동적 로직을 추가하지 않습니다. NestJS 문서에 따라 Kafka 마이크로서비스는 `Transport.KAFKA`와 `brokers` 옵션만으로도 기본 설정이 가능하며【550419036444003†L244-L269】, 필요 이상으로 세부 옵션을 조정하지 않습니다. ElasticSearch도 클러스터 주소, 인증 정보, 인덱스 네임 정도만 설정합니다. -4. **DTO와 유효성 검사** – 생산자/소비자/쿼리 API에서 사용하는 입력 데이터는 DTO 클래스로 정의하고 `class-validator`와 `pipes`를 이용해 필수 필드를 확인합니다. 단, MVP에서는 과도한 유효성 검사나 커스텀 변환을 추가하지 않고 기본 타입 확인에 그칩니다. -5. **구조화 로그** – 모든 서비스는 Winston 또는 Pino와 같은 로거를 사용해 JSON 형식으로 로그를 남깁니다. 로그에는 `trace.id`와 `span.id`를 포함하여 트레이스와 연동이 가능하도록 합니다. -6. **에러 처리** – NestJS의 ExceptionFilter를 이용해 에러를 전역 처리하며, 생산자/소비자에서 처리하지 못한 에러는 잡아 JSON 형태로 로깅하고 카프카 메시지 실패를 방지합니다. -7. **테스트** – Jest 기반 단위 테스트를 작성하되, MVP 범위에서는 핵심 로직(파싱, 매핑, 프로듀싱/컨섬)만 테스트합니다. 추후 통합 테스트 추가를 고려합니다. - -### 3.2 프로듀서 구현 (Producer – NestJS) - -1. **마이크로서비스 설정** – NestJS에서 카프카 프로듀서를 만들 때 `NestFactory.createMicroservice`를 사용하고 `Transport.KAFKA`를 지정합니다【550419036444003†L244-L269】. `brokers`는 환경 변수에서 읽어옵니다. `clientId`와 `producerOnlyMode`를 설정해 소비자 그룹에 가입하지 않는 순수 프로듀서로 구현합니다. -2. **데이터 수신** – Fluent‑bit나 OTel Collector에서 수집한 데이터를 HTTP나 gRPC로 수신할 경우, NestJS `Controller`와 `Service`로 분리합니다. 컨트롤러는 요청을 DTO로 변환하고 서비스는 카프카에 메시지를 발행합니다. 이때 메시지 헤더에 OpenTelemetry의 `traceparent`를 포함시켜 컨텍스트를 전파합니다【701703879168268†L165-L183】. -3. **Kafka 발행** – `@nestjs/microservices`의 `ClientKafka`를 주입 받아 사용합니다. 토픽 이름은 관찰 데이터 타입에 따라 `logs`, `metrics`, `traces` 등으로 분리합니다. 메시지 본문은 구조화된 JSON이며, 필요한 필드를 검증 후 그대로 전송합니다. -4. **OpenTelemetry 연동** – 프로듀서 모듈에는 `NodeSDK`를 초기화하고 `NestInstrumentation`, `HttpInstrumentation`, `ExpressInstrumentation`, `KafkaJsInstrumentation`을 설정합니다【701703879168268†L165-L183】. 이를 통해 HTTP 요청과 카프카 발행 과정 모두에서 스팬을 자동 생성하고 `traceparent` 헤더를 추가합니다. - -### 3.3 카프카 (중앙 메시징 버스) - -* 카프카는 퍼블리시·서브스크라이브와 durable storage를 제공하는 플랫폼이며, 실시간 데이터 처리에 적합합니다【550419036444003†L224-L233】. 토픽과 파티션 설계를 단순화해, `logs`, `metrics`, `traces` 3개 토픽(각각 몇 개의 파티션)으로 나누는 방식을 권장합니다. 후속 서비스 확장 시 파티션 개수를 늘립니다. -* 테스트 환경은 도커‑컴포즈를 이용해 zookeeper, kafka, kafka-ui 등을 구성할 수 있으며, 예제에서는 `brokers: ['kafka:29092']` 식으로 설정합니다【701703879168268†L214-L234】. - -### 3.4 컨슈머 구현 (Stream-Processor – NestJS) - -1. **마이크로서비스 설정** – `NestFactory.createMicroservice`에 `Transport.KAFKA`와 `consumer.groupId`를 지정하여 컨슈머를 만듭니다. 각 서비스는 고유 그룹ID를 사용하여 토픽 메시지를 모두 처리합니다. 필요하다면 동적 파티션 분배를 방지하기 위해 NestJS의 커스텀 파티셔너를 사용할 수 있습니다【550419036444003†L352-L383】. -2. **메시지 파싱** – 카프카 메시지에서 `key`, `value`, `headers`는 Buffer 타입으로 전달되므로, NestJS는 문자열로 변환 후 JSON 파싱을 시도합니다【550419036444003†L420-L437】. 컨슈머 서비스는 이 구조화된 데이터를 도메인 객체로 매핑합니다. 과도한 정규화나 추가 파싱 로직을 피합니다. -3. **데이터 정제** – 수신된 JSON에 기본 필드(`@timestamp`, `trace.id`, `span.id`, `service.name` 등)가 존재하는지 확인하고, 누락 시 필수값만 채웁니다. 추가 가공은 최소화하여 Elastic에 인덱싱 가능한 형태로 변환합니다. -4. **Elasticsearch 인덱싱** – `@elastic/elasticsearch` 클라이언트를 사용하여 배치로 문서를 전송합니다. 데이터 스트림 이름은 메시지 타입과 서비스 환경에 따라 결정합니다. 인덱싱 에러는 재시도 로직을 간단히 구현하거나 Dead Letter Queue로 전송합니다. -5. **OpenTelemetry 연동** – 컨슈머도 프로듀서와 동일한 SDK 설정을 사용하여 카프카 메시지를 소비할 때 스팬을 생성하고, 컨텍스트를 유지합니다. 메시지 헤더에서 `traceparent`를 읽어 `parent_span_id`로 설정합니다【701703879168268†L137-L152】. - -### 3.5 Query‑API 구현 (NestJS) - -1. **읽기 전용 설정** – Query‑API는 HTTP 서버 형태로 동작하며, Elasticsearch 클라이언트로 검색과 집계만 수행합니다. 쓰기, 삭제 등은 허용하지 않습니다. -2. **엔드포인트 설계** – 주요 엔드포인트는 아래와 같습니다: - - `/traces/{traceId}` – 특정 트레이스의 모든 스팬과 관련 로그를 조회합니다. - - `/services/{serviceName}/metrics` – 서비스의 메트릭(p95/p90/p50 latency, error rate 등)을 집계하여 반환합니다. - - `/logs/search` – 조건(기간, 서비스, level 등)에 따른 로그 검색. -3. **ES 쿼리 추상화** – 쿼리 로직은 Repository 계층에서 관리하여 컨트롤러가 단순해지도록 합니다. 쿼리 문자열을 하드코딩하지 말고 파라미터에 따라 동적으로 작성하지만, 복잡한 DSL을 사용자에게 노출하지 않습니다. -4. **성능 최적화** – 검색 결과는 pagination(`from`/`size` 또는 `search_after`)을 지원하고, 집계는 시간 버킷을 사용하여 메트릭을 효율적으로 계산합니다. 필요 시 캐싱을 도입합니다. -5. **CORS 구성** – Query‑API는 `CORS_ALLOWED_ORIGINS` 환경 변수를 통해 허용 오리진을 콤마(`,`)로 구분하여 지정합니다(예: `https://www.jungle-panopticon.cloud,http://localhost:3000`). 값이 없으면 전체 오리진을 허용하여 로컬 개발을 지원합니다. -6. **환경 별칭** – `environment` 파라미터는 `prod`/`stage`/`dev` 같은 축약형을 `production`/`staging`/`development`로 자동 정규화하여 필터링합니다. 저장된 값과 동일한 문자열을 보내도 되고, 축약형으로 보내도 동일한 결과를 얻습니다. - -### 3.6 공통 Instrumentation (OpenTelemetry) - -* **SDK 초기화** – 각 마이크로서비스에서 애플리케이션 시작 시 `@opentelemetry/sdk-node`의 `NodeSDK`를 생성합니다. `traceExporter`는 OTLP gRPC/HTTP exporter를 사용하여 수집기가 받아볼 수 있도록 합니다. -* **Instrumentations 추가** – `instrumentations` 배열에 `NestInstrumentation`, `HttpInstrumentation`, `ExpressInstrumentation`, `KafkaJsInstrumentation`을 추가합니다【701703879168268†L165-L183】. 이를 통해 HTTP 요청·응답, Express 미들웨어, NestJS 내부, Kafka 생산·소비 로직에서 스팬이 자동으로 생성됩니다. -* **컨텍스트 전파** – Kafka 메시지 헤더에 `traceparent` 값을 포함하여 컨슈머가 부모 스팬을 연결할 수 있도록 합니다【701703879168268†L137-L152】. OTel JS SDK는 이를 자동으로 처리합니다. 수집기(Collector)는 Jaeger, OTLP exporter 등을 통해 트레이스를 수집합니다. -* **메트릭 수집** – 필요 시 `@opentelemetry/api-metrics`를 사용해 HTTP 지연 시간, 카프카 큐 길이 등의 메트릭을 기록합니다. 그러나 MVP에서는 기본 지표만 수집해도 충분합니다. - -### 3.7 Error Stream 서버 (Kafka → WebSocket) - -* **역할** – `apm.logs.error` 토픽을 구독하여 `ERROR_STREAM_WS_PATH`(기본 `/ws/error-logs`) 엔드포인트로 접속한 NEXT.js 프런트엔드에 실시간으로 push 합니다. -* **Kafka 설정** – `ERROR_STREAM_KAFKA_CLIENT_ID`, `ERROR_STREAM_KAFKA_GROUP_ID`, `KAFKA_APM_LOG_ERROR_TOPIC`(기본 `apm.logs.error`) 환경 변수를 통해 MSK 접속 정보를 분리합니다. 기존 `KAFKA_*` 보안 설정(SSL/SASL)은 `shared/common/kafka` 모듈을 그대로 사용합니다. -* **WebSocket 보안** – `ERROR_STREAM_WS_ORIGINS`에 허용 오리진을 콤마로 지정합니다. 설정이 비어 있으면 로컬 개발 편의를 위해 모든 오리진에서 연결할 수 있도록 설정됩니다. -* **배포** – Query‑API, Stream Processor와 동일하게 독립 Docker 타깃(`error-stream`)과 ECR/ECS 태스크 정의를 사용합니다. 포트는 `ERROR_STREAM_PORT`(기본 3010)으로 노출합니다. - -## 4. ChatGPT 지침 (웹 인터페이스) - -1. **정확한 정보 제공** – 사용자 질문에 답할 때, 지식 컷오프 이후의 정보나 시간에 민감한 내용이 나오면 먼저 검색 도구를 이용해 최신 자료를 찾아 인용합니다. 카프카, Elastic, OTel 버전 업데이트 등은 최신 정보를 확인해야 합니다. -2. **논리적·비판적 응답** – 사용자가 제안한 설계나 코드에 잠재적 문제(예: 고카디널리티 라벨, 과도한 기능 추가, 성능 병목)를 발견하면 이유를 논리적으로 설명하고 대안을 제시합니다. 무비판적으로 동의하지 않습니다. -3. **SOLID와 간결성 유지** – 코드 예시나 설계를 제시할 때 단일 책임을 지키고 모듈 간 결합을 최소화합니다. 불필요한 추상화나 복잡성은 피합니다. 예를 들어 Kafka 설정은 NestJS 기본 옵션만 보여주고 필요 이상의 설정은 생략합니다. -4. **구조화와 명확성** – 답변은 서론·본론·결론 구조로 명확히 구분하고, 표나 리스트는 키워드·숫자만 포함하도록 합니다(긴 문장 X). 긴 설명은 본문에 기술합니다. -5. **예시와 패턴 제공** – 필요 시 간단한 코드 스니펫(모듈 생성, DTO 정의, 서비스 초기화 등)을 제공하지만, 핵심 로직과 패턴에 집중합니다. 전체 애플리케이션을 작성하지 않고 skeleton을 제시하는 데 그칩니다. -6. **지속적 개선 제안** – MVP가 완료된 후 성능 향상, 보안 강화, 고가용성 확보 방법 등을 제안할 수 있습니다. 하지만 현재 범위를 넘는 요구가 나오면 이유를 설명하고 MVP에 필요한 최소 사항만 수행합니다. - -## 5. Codex 지침 (CLI 활용) - -1. **프로젝트 구조 관리** – CLI는 실제 코드 파일을 작성·수정·삭제하는 역할을 합니다. 폴더 구조를 명확히 하고, 각 마이크로서비스는 독립된 NestJS 프로젝트(예: `producer`, `consumer`, `query-api`)로 구성하거나 모노레포 내에서 도메인별 모듈로 관리합니다. -2. **명령 실행** – 패키지 설치(`npm install kafkajs @opentelemetry/sdk-node @elastic/elasticsearch` 등), NestJS CLI 생성(`npx nest new` 또는 `nest g module service` 등), 빌드·테스트 명령 등을 수행합니다. 명령 실행 전에는 필요한 옵션을 설정하고, 실행 후 결과를 확인합니다. -3. **파일 수정** – 코드 변경 시 `apply_patch` 형식으로 패치를 작성하여 클래스, 모듈, DTO, 테스트 파일 등을 업데이트합니다. 코드는 TypeScript로 작성하고, NestJS 데코레이터와 의존성 주입을 활용해 SOLID 원칙을 지킵니다. -4. **즉각적인 결과 제공** – CLI는 비동기 작업을 미루지 않습니다. 명령 실행 결과는 즉시 사용자에게 보고하며, 시간 소요를 예측해 기다리라고 안내하지 않습니다. -5. **검색·외부 의존성** – CLI는 인터넷에 접근하지 못하므로, 패키지 버전이나 외부 API 정보가 필요하면 ChatGPT가 먼저 검색 후 공유해야 합니다. CLI는 그 정보에 기반해 작업합니다. -6. **검증 및 테스트** – 코드 작성 후 `npm run test`로 핵심 테스트를 돌려 성공 여부를 확인합니다. 실패 시 오류 메시지를 공유하고 수정합니다. 린트 도구(ESLint)나 포매터(Prettier)를 적용해 일관된 코드 스타일을 유지합니다. -7. **환경 변수 관리** – `.env` 파일이나 `ConfigService`를 통해 카프카 브로커 주소, Elasticsearch 호스트, OTLP exporter URL 등을 설정합니다. CLI는 환경 파일을 생성하거나 업데이트할 수 있습니다. - -## 6. 추가 고려 사항 및 권장 사항 - -* **보안** – MVP에서도 민감한 데이터가 로그나 트레이스에 노출되지 않도록 주의합니다. 예를 들어 사용자 개인정보, 결제 정보 등은 `attributes`나 `labels`에 직접 저장하지 않고 마스킹하거나 제외합니다. -* **고카디널리티 방지** – 사용자 ID, 주문 ID 등 가변성이 큰 값은 메트릭 라벨로 사용하지 않고, 로그나 스팬의 `labels`에 포함합니다. Elastic 검색 시 필요한 경우 `terms` 쿼리로 필터링합니다. -* **샘플링** – 트레이스 데이터는 볼륨이 크므로, Collector나 SDK 수준에서 확률적 샘플링을 적용해 저장 부담을 줄입니다. 에러·고지연 스팬은 항상 수집하도록 규칙을 설정할 수 있습니다. -* **확장 및 유지보수** – MVP 이후에는 로그/메트릭/트레이스에 대한 알람, 대시보드, 보안, 멀티테넌시 등을 고려해야 합니다. 그러나 지금은 핵심 파이프라인을 구축하는 데 집중합니다. - ---- - -이 지침은 프로젝트 기간 동안 ChatGPT와 Codex가 일관되게 참고할 핵심 문서입니다. 각 도구는 역할을 명확히 분리하여 협력하고, NestJS 기반의 APM 시스템을 **단순하지만 확장 가능한 형태로** 구현하는 데 집중해야 합니다. 필요 시 Elastic 문서나 NestJS 공식 문서를 추가로 참고하여 최신 정보를 반영합니다. diff --git a/backend/apm_query_api_spec_v2.yaml b/backend/apm_query_api_spec_v2.yaml deleted file mode 100644 index 498a966..0000000 --- a/backend/apm_query_api_spec_v2.yaml +++ /dev/null @@ -1,916 +0,0 @@ -openapi: 3.0.0 -info: - title: APM Query API v2 - version: 1.1.0 - description: | - This version of the APM Query API expands on the initial design to include a - service overview endpoint. The API is built on Elasticsearch indices for logs - and spans and provides search, trace retrieval, service metrics, endpoint - metrics and trace search functionality. The new `/services` endpoint - enumerates all services with their aggregated metrics over a given time range. - -servers: - - url: http://localhost:3000 - -paths: - ############################################################################################################## - # Service overview - ############################################################################################################## - "/services": - get: - summary: Retrieve overview metrics for all services - description: | - Returns a list of services with aggregated metrics over the specified time range. Each - service summary includes the total request count, 95th percentile latency, and error rate. - Optional query parameters allow filtering by environment and controlling sorting and limits. - parameters: - - name: from - in: query - description: Start time (inclusive) of the aggregation window. - required: false - schema: - type: string - format: date-time - - name: to - in: query - description: End time (exclusive) of the aggregation window. - required: false - schema: - type: string - format: date-time - - name: environment - in: query - description: Deployment environment filter (e.g. `prod`, `stage`, `dev`). Shorthand values are normalized to their canonical names (e.g. `prod` → `production`). When not provided, all environments are included. - required: false - schema: - type: string - - name: sort_by - in: query - description: | - Field to sort the service list by. Valid values are: - * `request_count` – Sort services by descending request count. - * `latency_p95_ms` – Sort by descending 95th percentile latency. - * `error_rate` – Sort by descending error rate. - Default is `request_count`. - required: false - schema: - type: string - enum: [request_count, latency_p95_ms, error_rate] - - name: limit - in: query - description: Maximum number of services to return. Default is 50. - required: false - schema: - type: integer - minimum: 1 - - name: name_filter - in: query - description: Optional substring to filter service names by (case‑insensitive). - required: false - schema: - type: string - responses: - "200": - description: Service overview metrics returned - content: - application/json: - schema: - $ref: "#/components/schemas/ServiceOverviewResponse" - - ############################################################################################################## - # Trace retrieval (unchanged from v1) - ############################################################################################################## - "/traces/{traceId}": - get: - summary: Retrieve a trace by its trace ID - description: | - Returns all spans and associated logs for the specified trace ID. This endpoint allows clients to - reconstruct the call graph of a request or transaction. The `traceId` corresponds to the `trace_id` - stored on span and log documents【629151000030751†L228-L233】. Optionally the result set can be - filtered by `environment` and/or limited to a specific service. - parameters: - - name: traceId - in: path - description: Unique identifier of the trace. - required: true - schema: - type: string - - name: environment - in: query - description: Deployment environment to filter by (e.g. `prod`, `stage`, `dev`). - required: false - schema: - type: string - - name: service - in: query - description: Optional service name filter. When specified, only spans and logs belonging to this service are returned. - required: false - schema: - type: string - responses: - "200": - description: Trace found - content: - application/json: - schema: - $ref: "#/components/schemas/TraceResponse" - "404": - description: Trace not found - - ############################################################################################################## - # Service‑level aggregated metrics (unchanged from v1) - ############################################################################################################## - "/services/{serviceName}/metrics": - get: - summary: Retrieve aggregated metrics for a service - description: | - Returns a series of service‑level metrics derived from span data. Supported metrics include - total request counts (`http_requests_total`), latency percentiles (`latency_p95_ms`, `latency_p90_ms`, - `latency_p50_ms`) and error rate (`error_rate`). When the `metric` parameter is omitted, all supported - metrics are returned. The `serviceName` corresponds to the `service_name` field on stored events and - is case‑insensitive【629151000030751†L150-L170】. - parameters: - - name: serviceName - in: path - description: Name of the service to aggregate metrics for. - required: true - schema: - type: string - - name: metric - in: query - description: | - Metric to compute. If omitted, all supported metrics are returned. Supported values are: - * `http_requests_total` – Number of server‑side spans (kind `SERVER`) per interval. - * `latency_p95_ms` – 95th percentile of `duration_ms` over server‑side spans. - * `latency_p90_ms` – 90th percentile of `duration_ms` over server‑side spans. - * `latency_p50_ms` – 50th percentile (median) of `duration_ms` over server‑side spans. - * `error_rate` – Ratio of spans with `status=ERROR` to total spans in the interval. - required: false - schema: - type: string - enum: [http_requests_total, latency_p95_ms, latency_p90_ms, latency_p50_ms, error_rate] - - name: from - in: query - description: Start time of the time window (inclusive) in ISO 8601 format. - required: false - schema: - type: string - format: date-time - - name: to - in: query - description: End time of the time window (exclusive) in ISO 8601 format. - required: false - schema: - type: string - format: date-time - - name: environment - in: query - description: Deployment environment filter (e.g. `prod`, `stage`, `dev`). Shorthand values are normalized to their canonical names (e.g. `prod` → `production`). - required: false - schema: - type: string - - name: interval - in: query - description: | - Aggregation interval for bucketing points (e.g. `1m` for one minute, - `5m` for five minutes). Valid time units are `s`, `m`, `h`. If omitted, - the implementation chooses a sensible default based on the date range. - required: false - schema: - type: string - responses: - "200": - description: Aggregated metrics returned - content: - application/json: - schema: - oneOf: - - $ref: "#/components/schemas/MetricResponse" - - type: array - items: - $ref: "#/components/schemas/MetricResponse" - - ############################################################################################################## - # Log search (unchanged from v1) - ############################################################################################################## - "/logs": - get: - summary: Search logs across services - description: | - Retrieves log events from the `logs-apm.*` data streams. Clients can filter by service name, - environment, log level, trace ID, span ID, and a time window. Messages may be searched with - a full‑text query, subject to the underlying Elasticsearch mapping (the `message` field is indexed - as text). Pagination is supported via `page` and `size` parameters. All filters are optional; - when omitted, logs from all services and environments within the date range are returned. - parameters: - - name: service_name - in: query - description: Filter by service name【629151000030751†L150-L170】. - required: false - schema: - type: string - - name: environment - in: query - description: Filter by deployment environment (e.g. `prod`, `stage`, `dev`). - required: false - schema: - type: string - - name: level - in: query - description: Filter by log level. Allowed values are `DEBUG`, `INFO`, `WARN`, `ERROR`. - required: false - schema: - type: string - enum: [DEBUG, INFO, WARN, ERROR] - - name: trace_id - in: query - description: Filter by trace ID (logs with this `trace_id`). - required: false - schema: - type: string - - name: span_id - in: query - description: Filter by span ID (logs associated with this `span_id`). - required: false - schema: - type: string - - name: message - in: query - description: Full‑text query to search within the `message` field. - required: false - schema: - type: string - - name: from - in: query - description: Start time (inclusive) of the search window. - required: false - schema: - type: string - format: date-time - - name: to - in: query - description: End time (exclusive) of the search window. - required: false - schema: - type: string - format: date-time - - name: page - in: query - description: Page number for pagination (1‑based index). Defaults to 1. - required: false - schema: - type: integer - minimum: 1 - - name: size - in: query - description: Number of log items to return per page. Defaults to 50; maximum 1000. - required: false - schema: - type: integer - minimum: 1 - maximum: 1000 - - name: sort - in: query - description: Sort order by timestamp: `asc` for ascending or `desc` for descending (default is `desc`). - required: false - schema: - type: string - enum: [asc, desc] - responses: - "200": - description: Log search results - content: - application/json: - schema: - $ref: "#/components/schemas/LogSearchResponse" - - ############################################################################################################## - # Span search (unchanged from v1) - ############################################################################################################## - "/spans": - get: - summary: Search spans across services - description: | - Retrieves span events from the `traces-apm.*` data streams. Clients can filter spans by - service name, environment, span name, kind, status, duration range, trace ID, and parent span ID. - Results can be paginated and sorted by duration or start time. If no filters are specified, - spans from all services and environments within the date range are returned. - parameters: - - name: service_name - in: query - description: Filter by service name【629151000030751†L150-L170】. - required: false - schema: - type: string - - name: environment - in: query - description: Filter by deployment environment (e.g. `prod`, `stage`, `dev`). - required: false - schema: - type: string - - name: name - in: query - description: Filter spans by their `name` (operation or endpoint name). Exact match only. - required: false - schema: - type: string - - name: kind - in: query - description: Filter spans by kind. Allowed values are `SERVER`, `CLIENT`, `INTERNAL`. - required: false - schema: - type: string - enum: [SERVER, CLIENT, INTERNAL] - - name: status - in: query - description: Filter spans by status (`OK` or `ERROR`). - required: false - schema: - type: string - enum: [OK, ERROR] - - name: min_duration_ms - in: query - description: Minimum span duration in milliseconds. - required: false - schema: - type: number - format: double - - name: max_duration_ms - in: query - description: Maximum span duration in milliseconds. - required: false - schema: - type: number - format: double - - name: trace_id - in: query - description: Filter spans by trace ID. - required: false - schema: - type: string - - name: parent_span_id - in: query - description: Filter spans by parent span ID. - required: false - schema: - type: string - - name: from - in: query - description: Start time (inclusive) of the search window. - required: false - schema: - type: string - format: date-time - - name: to - in: query - description: End time (exclusive) of the search window. - required: false - schema: - type: string - format: date-time - - name: page - in: query - description: Page number for pagination (1‑based index). Defaults to 1. - required: false - schema: - type: integer - minimum: 1 - - name: size - in: query - description: Number of span items to return per page. Defaults to 50; maximum 1000. - required: false - schema: - type: integer - minimum: 1 - maximum: 1000 - - name: sort - in: query - description: Sort order. Use `duration_asc` or `duration_desc` to sort by duration, or - `start_time_asc`/`start_time_desc` to sort by start time. Default is `start_time_desc`. - required: false - schema: - type: string - enum: [duration_asc, duration_desc, start_time_asc, start_time_desc] - responses: - "200": - description: Span search results - content: - application/json: - schema: - $ref: "#/components/schemas/SpanSearchResponse" - - ############################################################################################################## - # Endpoint metrics per service (unchanged from v1) - ############################################################################################################## - "/services/{serviceName}/endpoints": - get: - summary: Retrieve aggregated metrics for each endpoint of a service - description: | - Returns metrics aggregated by endpoint (span name or HTTP path) for the specified service. This - endpoint enables analysis of the most frequently called or slowest endpoints within a service. - The implementation derives request counts, latency percentiles and error rates from server spans - (kind `SERVER`). Endpoints are sorted by the `sort_by` parameter and limited by `limit`. - parameters: - - name: serviceName - in: path - description: Name of the service to aggregate endpoints for. - required: true - schema: - type: string - - name: from - in: query - description: Start time (inclusive) of the aggregation window. - required: false - schema: - type: string - format: date-time - - name: to - in: query - description: End time (exclusive) of the aggregation window. - required: false - schema: - type: string - format: date-time - - name: environment - in: query - description: Deployment environment filter (e.g. `prod`, `stage`, `dev`). Shorthand values are normalized to their canonical names (e.g. `prod` → `production`). - required: false - schema: - type: string - - name: metric - in: query - description: | - Metric to sort and filter by. Supported values are: - * `request_count` – Total number of requests per endpoint. - * `latency_p95_ms` – 95th percentile latency per endpoint. - * `error_rate` – Error rate per endpoint. - When omitted, all metrics are computed and endpoints are sorted by `request_count`. - required: false - schema: - type: string - enum: [request_count, latency_p95_ms, error_rate] - - name: sort_by - in: query - description: | - Field to sort the endpoints by. Valid values mirror the `metric` parameter (`request_count`, - `latency_p95_ms`, `error_rate`). Default is `request_count`. - required: false - schema: - type: string - enum: [request_count, latency_p95_ms, error_rate] - - name: limit - in: query - description: Maximum number of endpoints to return. Default is 10. - required: false - schema: - type: integer - minimum: 1 - - name: name_filter - in: query - description: Optional substring to filter endpoint names by (case‑insensitive). - required: false - schema: - type: string - responses: - "200": - description: Endpoint metrics aggregated for the service - content: - application/json: - schema: - $ref: "#/components/schemas/EndpointMetricsResponse" - - ############################################################################################################## - # Trace search per service (unchanged from v1) - ############################################################################################################## - "/services/{serviceName}/traces": - get: - summary: Search root traces for a service - description: | - Retrieves a list of root traces (top‑level server spans) for the specified service. Clients can - filter by status, duration range, and time window. Results are paginated and can be sorted by - duration or start time. This endpoint enables discovery of slow or error traces within a service. - parameters: - - name: serviceName - in: path - description: Name of the service whose traces are being queried. - required: true - schema: - type: string - - name: status - in: query - description: Filter traces by status (`OK` or `ERROR`). - required: false - schema: - type: string - enum: [OK, ERROR] - - name: min_duration_ms - in: query - description: Minimum trace duration in milliseconds (sum of durations of all spans in the trace). - required: false - schema: - type: number - format: double - - name: max_duration_ms - in: query - description: Maximum trace duration in milliseconds. - required: false - schema: - type: number - format: double - - name: from - in: query - description: Start time (inclusive) of the search window. - required: false - schema: - type: string - format: date-time - - name: to - in: query - description: End time (exclusive) of the search window. - required: false - schema: - type: string - format: date-time - - name: environment - in: query - description: Deployment environment filter (e.g. `prod`, `stage`, `dev`). Shorthand values are normalized to their canonical names (e.g. `prod` → `production`). - required: false - schema: - type: string - - name: page - in: query - description: Page number for pagination (1‑based index). Defaults to 1. - required: false - schema: - type: integer - minimum: 1 - - name: size - in: query - description: Number of trace summaries to return per page. Defaults to 20; maximum 200. - required: false - schema: - type: integer - minimum: 1 - maximum: 200 - - name: sort - in: query - description: | - Sort order. Use `duration_desc` or `duration_asc` to sort by total trace duration, or - `start_time_desc`/`start_time_asc` to sort by start time. Default is `duration_desc`. - required: false - schema: - type: string - enum: [duration_desc, duration_asc, start_time_desc, start_time_asc] - responses: - "200": - description: Trace search results for the service - content: - application/json: - schema: - $ref: "#/components/schemas/TraceSearchResponse" - -components: - schemas: - ########################################################################################################## - # Shared schemas from v1 - ########################################################################################################## - SpanItem: - type: object - description: A span event representing a discrete operation in a trace. - properties: - timestamp: - type: string - format: date-time - span_id: - type: string - parent_span_id: - type: string - nullable: true - name: - type: string - kind: - type: string - enum: [SERVER, CLIENT, INTERNAL] - duration_ms: - type: number - format: double - status: - type: string - enum: [OK, ERROR] - service_name: - type: string - environment: - type: string - http_method: - type: string - nullable: true - http_path: - type: string - nullable: true - http_status_code: - type: integer - nullable: true - labels: - type: object - additionalProperties: - oneOf: - - type: string - - type: number - - type: boolean - db_statement: - type: string - nullable: true - required: - - timestamp - - span_id - - name - - kind - - duration_ms - - status - - service_name - - environment - - LogItem: - type: object - description: A log event capturing a discrete occurrence within a trace or service. - properties: - timestamp: - type: string - format: date-time - level: - type: string - enum: [DEBUG, INFO, WARN, ERROR] - message: - type: string - service_name: - type: string - span_id: - type: string - nullable: true - trace_id: - type: string - nullable: true - labels: - type: object - additionalProperties: - oneOf: - - type: string - - type: number - - type: boolean - required: - - timestamp - - level - - message - - service_name - - TraceResponse: - type: object - description: Aggregated view of a trace including its spans and logs. - properties: - trace_id: - type: string - spans: - type: array - items: - $ref: "#/components/schemas/SpanItem" - logs: - type: array - items: - $ref: "#/components/schemas/LogItem" - required: - - trace_id - - spans - - logs - - MetricPoint: - type: object - properties: - timestamp: - type: string - format: date-time - value: - type: number - labels: - type: object - nullable: true - additionalProperties: - type: string - required: - - timestamp - - value - - MetricResponse: - type: object - properties: - metric_name: - type: string - service_name: - type: string - environment: - type: string - points: - type: array - items: - $ref: "#/components/schemas/MetricPoint" - required: - - metric_name - - service_name - - environment - - points - - LogSearchResponse: - type: object - properties: - total: - type: integer - page: - type: integer - size: - type: integer - items: - type: array - items: - $ref: "#/components/schemas/LogItem" - required: - - total - - page - - size - - items - - SpanSearchResponse: - type: object - properties: - total: - type: integer - page: - type: integer - size: - type: integer - items: - type: array - items: - $ref: "#/components/schemas/SpanItem" - required: - - total - - page - - size - - items - - EndpointMetrics: - type: object - properties: - endpoint_name: - type: string - service_name: - type: string - environment: - type: string - request_count: - type: number - latency_p95_ms: - type: number - error_rate: - type: number - labels: - type: object - nullable: true - additionalProperties: - type: string - required: - - endpoint_name - - service_name - - environment - - request_count - - latency_p95_ms - - error_rate - - EndpointMetricsResponse: - type: object - properties: - service_name: - type: string - environment: - type: string - from: - type: string - format: date-time - nullable: true - to: - type: string - format: date-time - nullable: true - endpoints: - type: array - items: - $ref: "#/components/schemas/EndpointMetrics" - required: - - service_name - - environment - - endpoints - - TraceSummary: - type: object - properties: - trace_id: - type: string - root_span_name: - type: string - status: - type: string - enum: [OK, ERROR] - duration_ms: - type: number - format: double - start_time: - type: string - format: date-time - service_name: - type: string - environment: - type: string - labels: - type: object - nullable: true - additionalProperties: - oneOf: - - type: string - - type: number - - type: boolean - required: - - trace_id - - root_span_name - - status - - duration_ms - - start_time - - service_name - - environment - - TraceSearchResponse: - type: object - properties: - total: - type: integer - page: - type: integer - size: - type: integer - traces: - type: array - items: - $ref: "#/components/schemas/TraceSummary" - required: - - total - - page - - size - - traces - - ########################################################################################################## - # New schemas for v2 - ########################################################################################################## - ServiceSummary: - type: object - description: Aggregate metrics for a single service. - properties: - service_name: - type: string - environment: - type: string - request_count: - type: number - latency_p95_ms: - type: number - error_rate: - type: number - labels: - type: object - nullable: true - additionalProperties: - type: string - required: - - service_name - - environment - - request_count - - latency_p95_ms - - error_rate - - ServiceOverviewResponse: - type: object - description: Collection of service summaries over a given time range. - properties: - from: - type: string - format: date-time - nullable: true - to: - type: string - format: date-time - nullable: true - environment: - type: string - nullable: true - services: - type: array - items: - $ref: "#/components/schemas/ServiceSummary" - required: - - services diff --git a/backend/bulk_indexer_backpressure_fix.md b/backend/bulk_indexer_backpressure_fix.md deleted file mode 100644 index 047b4f9..0000000 --- a/backend/bulk_indexer_backpressure_fix.md +++ /dev/null @@ -1,610 +0,0 @@ -# Kafka + BulkIndexer 병목 원인 분석 및 리팩토링 명세 - -본 문서는 **APM 로그 컨슈머에서 bulk 인덱싱으로 전환한 이후 성능이 기존 단건 index 대비 크게 떨어진 문제**의 원인을 정리하고, -그에 대한 **구체적인 코드 리팩토링 가이드**를 제공한다. 이 문서를 Codex에 넘겨서 자동으로 리팩토링할 수 있도록 -**의도·배경·변경 포인트·코드 예시**를 모두 포함한다. - ---- - -## 1. 현상 요약 - -- Kafka 토픽에 약 **100만 개 로그 메시지**가 적재되어 있음. -- 컨슈머 lag는 **100만 개 모두 소비 완료**로 표시됨. -- 하지만 **Elasticsearch 실제 인덱싱 속도는 분당 ~60건 수준**으로 매우 느림. -- bulk 도입 전, 각 메시지마다 `client.index()`로 단건 인덱싱할 때는 **분당 ~7,000건** 수준의 처리량이 나왔음. -- 즉, **bulk 인덱싱 도입 이후 소비는 잘 되는데 색인은 거의 안 되는 상황**. - -이 문제는 Kafka 컨슈머 쪽의 **back-pressure 설계와 BulkIndexer의 Promise 사용 방식**이 겹치면서, -**“파티션당 1초에 1건” 수준으로 소비가 직렬화되어 버린 것**이 근본 원인이다. - ---- - -## 2. 현재 처리 흐름 정리 - -### 2.1 Kafka → LogConsumerController - -```ts -@EventPattern(process.env.KAFKA_APM_LOG_TOPIC ?? "apm.logs") -async handleLogEvent(@Ctx() context: KafkaContext): Promise { - const value = context.getMessage().value; - if (value == null) { - this.logger.warn("Kafka 메시지에 본문이 없어 처리를 건너뜁니다."); - return; - } - - try { - const dto = this.parsePayload(value); - await this.logIngestService.ingest(dto); // (1) 여기서 bulk enqueue를 await - await this.errorLogForwarder.forward(dto); // (2) 에러 로그 포워딩 - this.throughputTracker.markProcessed(); - ... - } catch (error) { - ... - throw error; - } -} -``` - -핵심 포인트: `handleLogEvent`는 **`logIngestService.ingest()`를 await** 한다. - ---- - -### 2.2 LogIngestService (Kafka DTO → ES 문서) - -```ts -@Injectable() -export class LogIngestService { - private static readonly STREAM_KEY: LogStreamKey = "apmLogs"; - - constructor(private readonly bulkIndexer: BulkIndexerService) {} - - async ingest(dto: LogEventDto): Promise { - const document: LogDocument = { - "@timestamp": this.resolveTimestamp(dto.timestamp), - type: "log", - service_name: dto.service_name, - environment: dto.environment, - trace_id: dto.trace_id, - span_id: dto.span_id, - level: dto.level, - message: dto.message, - http_method: dto.http_method, - http_path: dto.http_path, - http_status_code: dto.http_status_code, - labels: this.normalizeLabels(dto.labels), - ingestedAt: new Date().toISOString(), - }; - - // 로그 문서를 bulk 버퍼에 적재하고 flush가 끝날 때까지 기다린다. - await this.bulkIndexer.enqueue(LogIngestService.STREAM_KEY, document); - } -} -``` - -핵심 포인트: `ingest()`는 **BulkIndexerService.enqueue()가 resolve될 때까지 기다리는 async 함수**이다. - ---- - -### 2.3 BulkIndexerService (버퍼 → ES `_bulk`) - -```ts -interface BufferedItem { - index: string; - document: BaseApmDocument; - size: number; - resolve: () => void; - reject: (error: Error) => void; -} - -enqueue(streamKey: LogStreamKey, document: BaseApmDocument): Promise { - const indexName = this.storage.getDataStream(streamKey); - const size = - Buffer.byteLength(JSON.stringify({ index: { _index: indexName } })) + - Buffer.byteLength(JSON.stringify(document)) + - 2; - - return new Promise((resolve, reject) => { - this.buffer.push({ index: indexName, document, size, resolve, reject }); - this.bufferedBytes += size; - if (this.shouldFlushBySize()) { - this.triggerFlush(); - } else { - this.ensureFlushTimer(); - } - }); -} -``` - -`enqueue()`는 **해당 문서가 포함된 bulk flush가 끝났을 때 resolve되는 Promise**를 반환한다. - -```ts -private async executeFlush(batch: BufferedItem[]): Promise { - const operations = this.buildOperations(batch); - try { - const response = await this.client.bulk({ operations }); - if (response.errors) { - this.logBulkError(response); - const error = new Error("Bulk 색인 중 일부 문서가 실패했습니다."); - batch.forEach((item) => item.reject(error)); - return; - } - batch.forEach((item) => item.resolve()); - this.logger.debug( - `Bulk 색인 완료 batch=${batch.length} took=${response.took ?? 0}ms`, - ); - } catch (error) { - const wrapped = - error instanceof Error - ? error - : new Error(`Bulk 색인 실패: ${String(error)}`); - batch.forEach((item) => item.reject(wrapped)); - this.logger.warn( - "Bulk 색인 요청이 실패했습니다. Kafka 컨슈머가 재시도합니다.", - wrapped.stack, - ); - } -} -``` - -핵심 포인트: **flush 결과에 따라 batch 내 모든 Promise를 resolve/reject** 한다. - ---- - -## 3. 근본 원인: Kafka 파티션 단위 직렬 처리 + bulk Promise 연동 - -### 3.1 Kafka eachMessage / NestJS EventPattern의 기본 동작 - -- NestJS Kafka 마이크로서비스는 내부적으로 `kafkajs`의 `eachMessage` 패턴을 사용한다. -- `eachMessage`는 **같은 파티션에 대해서는 한 번에 하나의 메시지 handler만 실행**한다. -- 즉, `handleLogEvent()`가 반환되기 전까지 **같은 파티션에서 다음 메시지를 넘겨주지 않는다.** - -### 3.2 현재 구조에서 실제로 일어나는 일 - -1. 파티션 P0에 메시지 M1이 들어온다. -2. NestJS가 `handleLogEvent(M1)`을 호출한다. -3. `handleLogEvent()` 내부에서: - - `parsePayload()`로 DTO로 변환. - - `await this.logIngestService.ingest(dto);` 호출. -4. `logIngestService.ingest()`에서: - - `await this.bulkIndexer.enqueue(...);` 호출. -5. `BulkIndexer.enqueue()`는: - - `buffer.push(M1)` 한 뒤, - - **해당 문서를 포함하는 bulk flush가 끝날 때까지 resolve되지 않는 Promise를 반환**. -6. `enqueue()`의 Promise가 resolve될 때까지: - - `ingest()`는 반환하지 않음. - - `handleLogEvent()`도 반환하지 않음. - - 따라서 **kafkajs는 P0 파티션의 다음 메시지(M2)를 handler에 넘기지 않는다.** - -### 3.3 버퍼 / 타이머와의 상호작용 - -- 기본 설정 (예시): - - `BULK_BATCH_SIZE = 1000` - - `BULK_FLUSH_INTERVAL_MS = 1000` (1초) -- 하지만 **동시에 여러 메시지를 소비하지 못하기 때문에** 버퍼에는 항상 “현재 처리 중인 메시지 1개 정도만” 들어온다. -- `shouldFlushBySize()` 조건은 거의 만족되지 않고, - - 타이머(`flushIntervalMs`)가 1초마다 bulk flush를 수행한다. -- 결과적으로: - - **파티션당 1초에 1개씩 bulk flush가 수행**된다. - - 각 flush에는 보통 1개 문서만 포함된다. - - => **파티션당 대략 초당 1건, 분당 60건 수준의 처리량**으로 떨어진다. -- 반대로, 단건 index일 때는: - - 각 메시지에서 `client.index()` 하나만 await → 대략 수 ms~수십 ms 내에 반환. - - 따라서 파티션당 초당 수십~수백 건을 처리할 수 있었다. - -즉, **“bulk flush가 끝날 때까지 Kafka 컨슈머가 block되는 구조”** 때문에, -bulk가 이득을 못 보고 **극단적인 under-utilization**을 만드는 것이 문제의 핵심이다. - ---- - -## 4. 리팩토링 목표 - -1. **Kafka 컨슈머는 bulk flush 완료까지 block되지 않고** 가능한 한 빨리 메시지를 소비한다. - - 메시지 소비 속도 = Kafka → 메모리 버퍼 적재 속도. -2. **BulkIndexer는 별도의 비동기 작업으로 버퍼를 묶어서 ES `_bulk` 호출**을 수행한다. -3. MVP 단계에서는: - - **엄격한 “flush 성공 후에만 offset commit”** 대신, - - **최소한의 코드 변경으로 throughput을 회복하는 전략**을 사용한다. - - 즉, flush 실패 시 Kafka에서 재처리하기보다는 **로그를 남기고 버리는(best-effort)** 쪽에 가깝다. - - 추후 필요하면 `eachBatch + manual commit` 패턴으로 고도화할 수 있다. - ---- - -## 5. 설계 변경안 (MVP 버전) - -### 5.1 BulkIndexerService.enqueue를 *non-blocking*으로 변경 - -#### 5.1.1 BufferedItem 타입 단순화 - -**변경 전** - -```ts -interface BufferedItem { - index: string; - document: BaseApmDocument; - size: number; - resolve: () => void; - reject: (error: Error) => void; -} -``` - -**변경 후** - -```ts -interface BufferedItem { - index: string; - document: BaseApmDocument; - size: number; -} -``` - -- 더 이상 각 문서마다 개별 Promise를 resolve/reject하지 않는다. -- Bulk flush는 **“fire-and-forget 배치 작업”**으로 취급한다. - -#### 5.1.2 enqueue() 시그니처 및 동작 변경 - -**변경 전** - -```ts -enqueue(streamKey: LogStreamKey, document: BaseApmDocument): Promise { - const indexName = this.storage.getDataStream(streamKey); - const size = - Buffer.byteLength(JSON.stringify({ index: { _index: indexName } })) + - Buffer.byteLength(JSON.stringify(document)) + - 2; - - return new Promise((resolve, reject) => { - this.buffer.push({ index: indexName, document, size, resolve, reject }); - this.bufferedBytes += size; - if (this.shouldFlushBySize()) { - this.triggerFlush(); - } else { - this.ensureFlushTimer(); - } - }); -} -``` - -**변경 후 (핵심 아이디어)** - -```ts -enqueue(streamKey: LogStreamKey, document: BaseApmDocument): void { - const indexName = this.storage.getDataStream(streamKey); - const size = - Buffer.byteLength(JSON.stringify({ index: { _index: indexName } })) + - Buffer.byteLength(JSON.stringify(document)) + - 2; - - // 즉시 버퍼에 쌓고 반환한다. (Promise 없음) - this.buffer.push({ index: indexName, document, size }); - this.bufferedBytes += size; - - if (this.shouldFlushBySize()) { - this.triggerFlush(); - } else { - this.ensureFlushTimer(); - } -} -``` - -- `enqueue()`는 더 이상 `Promise`를 반환하지 않는다. -- 호출자는 **flush 완료 여부를 기다리지 않고 바로 다음 로직으로 진행**한다. -- Kafka 컨슈머 입장에서는 **메시지를 메모리 버퍼에 적재하는 순간 이미 “처리 완료”로 간주**하게 된다. - -#### 5.1.3 executeFlush()에서 Promise 관련 코드 제거 - -**변경 전** - -```ts -private async executeFlush(batch: BufferedItem[]): Promise { - const operations = this.buildOperations(batch); - try { - const response = await this.client.bulk({ operations }); - if (response.errors) { - this.logBulkError(response); - const error = new Error("Bulk 색인 중 일부 문서가 실패했습니다."); - batch.forEach((item) => item.reject(error)); - return; - } - batch.forEach((item) => item.resolve()); - this.logger.debug( - `Bulk 색인 완료 batch=${batch.length} took=${response.took ?? 0}ms`, - ); - } catch (error) { - const wrapped = - error instanceof Error - ? error - : new Error(`Bulk 색인 실패: ${String(error)}`); - batch.forEach((item) => item.reject(wrapped)); - this.logger.warn( - "Bulk 색인 요청이 실패했습니다. Kafka 컨슈머가 재시도합니다.", - wrapped.stack, - ); - } -} -``` - -**변경 후 (MVP용 단순 버전)** - -```ts -private async executeFlush(batch: BufferedItem[]): Promise { - const operations = this.buildOperations(batch); - try { - const response = await this.client.bulk({ operations }); - - if (response.errors) { - // 일부 문서 실패 → 상세 에러 로그만 남기고, Kafka 재처리는 하지 않는다. - this.logBulkError(response); - this.logger.warn( - `Bulk 색인 중 일부 문서가 실패했습니다. batch=${batch.length} took=${response.took ?? 0}ms`, - ); - } else { - this.logger.debug( - `Bulk 색인 완료 batch=${batch.length} took=${response.took ?? 0}ms`, - ); - } - } catch (error) { - const wrapped = - error instanceof Error - ? error - : new Error(`Bulk 색인 실패: ${String(error)}`); - - this.logger.warn( - "Bulk 색인 요청이 실패했습니다. Kafka 컨슈머는 메시지를 계속 처리합니다.", - wrapped.stack, - ); - } -} -``` - -- flush 실패 시 **Kafka 쪽으로 예외를 전달하지 않는다.** -- 대신 **실패 로그만 남기고 다음 batch로 넘어가는 구조**이다. -- 이로 인해 “ES 인덱싱 실패 시 동일 메시지를 Kafka에서 재처리하는” 기능은 사라지지만, - **현재 문제(throughput 급락)를 해결하는 것이 우선**인 MVP 단계에서는 합리적인 트레이드오프다. - -> 이후 고도화 단계에서, `eachBatch + manual commit` 구조를 도입해 -> flush 성공 여부에 따라 offset commit을 조절하는 전략으로 개선할 수 있다. - ---- - -### 5.2 LogIngestService.ingest를 sync 스타일로 변경 - -`BulkIndexerService.enqueue()`가 이제 `void`를 반환하므로, -`LogIngestService.ingest()`도 더 이상 async/await가 필요 없다. - -**변경 전** - -```ts -@Injectable() -export class LogIngestService { - private static readonly STREAM_KEY: LogStreamKey = "apmLogs"; - - constructor(private readonly bulkIndexer: BulkIndexerService) {} - - async ingest(dto: LogEventDto): Promise { - const document: LogDocument = { - "@timestamp": this.resolveTimestamp(dto.timestamp), - type: "log", - service_name: dto.service_name, - environment: dto.environment, - trace_id: dto.trace_id, - span_id: dto.span_id, - level: dto.level, - message: dto.message, - http_method: dto.http_method, - http_path: dto.http_path, - http_status_code: dto.http_status_code, - labels: this.normalizeLabels(dto.labels), - ingestedAt: new Date().toISOString(), - }; - - await this.bulkIndexer.enqueue(LogIngestService.STREAM_KEY, document); - } -} -``` - -**변경 후** - -```ts -@Injectable() -export class LogIngestService { - private static readonly STREAM_KEY: LogStreamKey = "apmLogs"; - - constructor(private readonly bulkIndexer: BulkIndexerService) {} - - ingest(dto: LogEventDto): void { - const document: LogDocument = { - "@timestamp": this.resolveTimestamp(dto.timestamp), - type: "log", - service_name: dto.service_name, - environment: dto.environment, - trace_id: dto.trace_id, - span_id: dto.span_id, - level: dto.level, - message: dto.message, - http_method: dto.http_method, - http_path: dto.http_path, - http_status_code: dto.http_status_code, - labels: this.normalizeLabels(dto.labels), - ingestedAt: new Date().toISOString(), - }; - - // ES bulk 버퍼에만 적재하고, flush 완료는 기다리지 않는다. - this.bulkIndexer.enqueue(LogIngestService.STREAM_KEY, document); - } -} -``` - -- 반환 타입을 `Promise` → `void`로 변경. -- 내부에서 `await` 제거. - ---- - -### 5.3 LogConsumerController.handleLogEvent에서 ingest await 제거 - -**변경 전** - -```ts -@EventPattern(process.env.KAFKA_APM_LOG_TOPIC ?? "apm.logs") -async handleLogEvent(@Ctx() context: KafkaContext): Promise { - const value = context.getMessage().value; - if (value == null) { - this.logger.warn("Kafka 메시지에 본문이 없어 처리를 건너뜁니다."); - return; - } - - try { - const dto = this.parsePayload(value); - await this.logIngestService.ingest(dto); - await this.errorLogForwarder.forward(dto); - this.throughputTracker.markProcessed(); - this.logger.debug( - `로그가 색인되었습니다. topic=${context.getTopic()} partition=${context.getPartition()}`, - ); - } catch (error) { - ... - throw error; - } -} -``` - -**변경 후** - -```ts -@EventPattern(process.env.KAFKA_APM_LOG_TOPIC ?? "apm.logs") -async handleLogEvent(@Ctx() context: KafkaContext): Promise { - const value = context.getMessage().value; - if (value == null) { - this.logger.warn("Kafka 메시지에 본문이 없어 처리를 건너뜁니다."); - return; - } - - try { - const dto = this.parsePayload(value); - - // 1) ES bulk 버퍼에 비동기로만 적재하고, Kafka 소비는 block하지 않는다. - this.logIngestService.ingest(dto); - - // 2) 에러 로그 포워딩은 기존과 같이 await (필요시 나중에 비동기로 바꿀 수 있음) - await this.errorLogForwarder.forward(dto); - - this.throughputTracker.markProcessed(); - this.logger.debug( - `로그 처리 완료: topic=${context.getTopic()} partition=${context.getPartition()}`, - ); - } catch (error) { - if (error instanceof InvalidLogEventError) { - this.logger.warn( - `유효하지 않은 로그 이벤트를 건너뜁니다: ${error.message}`, - ); - return; - } - this.logger.error( - "로그 이벤트 처리에 실패했습니다.", - error instanceof Error ? error.stack : String(error), - ); - throw error; - } -} -``` - -- `logIngestService.ingest(dto)`에서 `await` 제거. -- ES bulk flush는 **백그라운드에서 진행**되고, - Kafka 컨슈머는 빠르게 다음 메시지를 처리한다. -- 에러 로그 포워딩이 상대적으로 가벼운 작업이라면 굳이 비동기화하지 않아도 - **전체 throughput의 병목은 BulkIndexer 쪽에서 해소**된다. - -> 필요시, `errorLogForwarder.forward(dto)`도 fire-and-forget으로 바꿀 수 있지만, -> 현재 병목의 주범은 bulk flush이므로 우선 priority는 낮다. - ---- - -### 5.4 다른 모듈에서 BulkIndexerService 사용 시 점검 - -`BulkIndexerService`는 로그 외에 **스팬 등 다른 도메인에서도 재사용**될 수 있으므로, -코드베이스 전체에서 다음 패턴을 검색해 한 번에 수정해야 한다. - -- 검색 키워드 예시: - - `await this.bulkIndexer.enqueue(` - - `await bulkIndexer.enqueue(` -- 각 사용처를 다음과 같이 변경: - - `await bulkIndexer.enqueue(...);` → `bulkIndexer.enqueue(...);` - - 해당 서비스 메서드의 반환 타입도 필요 시 `Promise` → `void`로 변경. - ---- - -## 6. 이 리팩토링으로 기대되는 효과 - -1. **Kafka 소비 속도 회복** - - 컨슈머는 더 이상 bulk flush 완료를 기다리지 않으므로, - **파티션당 초당 수천 건 수준까지** 다시 메시지를 소비할 수 있다 - (실제 값은 ES/네트워크 성능에 따라 달라짐). - - 현재 관찰된 **분당 60건 수준의 throughput 문제는 사라지고**, 최소한 - bulk 도입 이전(분당 ~7,000건) 수준 이상으로 회복될 가능성이 매우 높다. - -2. **bulk의 이점 유지** - - ES 쪽에서는 여전히 **NDJSON `_bulk` API**를 사용하므로, - 단건 index 대비 **round-trip 횟수 감소 및 CPU/네트워크 효율 증가** 효과를 유지한다. - - `maxBatchSize`, `maxBatchBytes`, `flushIntervalMs` 설정을 조절하여 - 최적의 batch 크기를 찾을 수 있다. - -3. **구현 복잡도 최소** - - Kafka offset과 ES flush 간의 엄격한 트랜잭션 일관성은 포기하지만, - 그 대신 **코드 변경량은 작고, 논리도 단순**하다. - - MVP 단계에서 현실적인 타협점이다. - ---- - -## 7. 후속 고도화 아이디어 (필수 아님, 참고용) - -> 이 섹션은 Codex가 당장 구현할 필요는 없고, 차후 개선 시 참고용이다. - -1. **각 파티션별 in-flight 문서 수 제한** - - 메모리 사용량을 제한하기 위해 `MAX_BUFFERED_ITEMS` 같은 옵션을 두고, - 일정 개수 이상 쌓이면 `enqueue()`에서 잠시 block하는 전략 적용 가능. - -2. **eachBatch + manual commit 패턴** - - kafkajs의 `eachBatch`를 사용해: - - 특정 배치(예: N개의 메시지)를 메모리 버퍼에 넣은 뒤, - - 해당 배치에 대한 bulk flush가 성공하면 offset commit, - - 실패하면 배치 전체 재처리 등의 로직 구현 가능. - - 이 패턴은 **throughput + at-least-once 보장**을 모두 고려하는 고급 설계다. - -3. **Dead Letter Queue(DLQ) 도입** - - ES flush가 반복적으로 실패하는 문서는 Kafka DLQ 토픽으로 보내고, - 별도의 복구/분석 파이프라인에서 처리할 수 있다. - ---- - -## 8. Codex를 위한 구현 체크리스트 - -Codex가 이 문서를 바탕으로 코드를 수정할 때 따라야 할 **구체적인 단계**는 다음과 같다. - -1. **`bulk-indexer.service.ts` 수정** - - `BufferedItem` 인터페이스에서 `resolve`, `reject` 제거. - - `enqueue()`의 반환 타입을 `Promise` → `void`로 변경하고, - 내부에서 `new Promise` 생성 로직 제거. - - `executeFlush()`에서 `batch.forEach(item => item.resolve/reject)` 호출 제거. - - 로그 메시지를 위 예시처럼 정리. - -2. **`log-ingest.service.ts` 수정** - - `ingest()`의 시그니처를 `async ingest(...): Promise` → `ingest(...): void`로 변경. - - 내부의 `await this.bulkIndexer.enqueue(...)`를 `this.bulkIndexer.enqueue(...)`로 변경. - -3. **`log-consumer.controller.ts` 수정** - - `handleLogEvent()` 내부에서 `await this.logIngestService.ingest(dto);` 를 - `this.logIngestService.ingest(dto);`로 변경. - - 나머지 로직은 그대로 두되, 로그 메시지는 상황에 맞게 약간 수정 가능. - -4. **BulkIndexerService 다른 사용처 점검** - - 전체 코드베이스에서 `await bulkIndexer.enqueue` 패턴 검색. - - 동일한 방식으로 `await` 제거 및 함수 시그니처 조정. - -5. **빌드 및 테스트** - - TypeScript 컴파일 오류가 없는지 확인. - - 로컬 환경에서 Kafka → ES 파이프라인을 실행하고, - - 컨슈머 lag가 빠르게 감소하는지, - - ES에 초당 수천 건 수준으로 색인되는지 확인. - - ES `_cat/indices` 또는 Kibana에서 로그 도큐먼트 수를 확인해 throughput을 체감. - -이 체크리스트를 모두 수행하면, **현재 bulk 인덱싱으로 인해 발생한 극단적인 성능 저하 문제는 해결**되고, -MVP에 적합한 수준의 고성능 로그 파이프라인을 확보할 수 있다. diff --git a/backend/redis_bucket_cache_spec.md b/backend/redis_bucket_cache_spec.md deleted file mode 100644 index 08df3f4..0000000 --- a/backend/redis_bucket_cache_spec.md +++ /dev/null @@ -1,508 +0,0 @@ -# APM 프로젝트: 실시간 집계용 10초 버킷화 & Redis 캐시 설계 명세 - -이 문서는 기존 **APM Query-API**에 **Redis 기반 캐시 + 10초 시간 버킷(bucketing)**을 추가하여, 반복되는 실시간 집계 요청의 부하를 줄이기 위한 설계 명세다. -현재 시스템에는 **Redis 캐시와 버킷화 로직이 전혀 구현되어 있지 않으며**, 모든 메트릭 조회는 Elasticsearch(이하 ES)에 대해 실시간 집계를 수행한다. - -이 문서는 CLI codex 또는 개발자가 이 설계를 바탕으로 기존 코드를 수정/추가할 수 있도록 **구체적인 요구사항, 실패 시 동작, 모듈 구조, 키 설계, 알고리즘**까지 포함한다. - ---- - -## 1. 현재 상태 및 목표 - -### 1.1 현재 상태 (전제) - -- 서비스 구성 - - `stream-processor` - - Kafka에서 로그/스팬 토픽(예: `apm.logs`, `apm.spans`)을 실시간으로 consume - - ES 데이터 스트림(`logs-apm.*`, `traces-apm.*`)에 인덱싱 - - `query-api` - - NestJS 기반 HTTP API - - 브라우저/대시보드에서 들어오는 요청을 받아 ES에 **실시간 집계 쿼리**를 수행 - - 현재는 **어떠한 캐시도 사용하지 않고**, - `from`/`to`/`interval` 등 요청 파라미터를 그대로 ES에 전달 - -- 메트릭 엔드포인트 (대표) - - `GET /services/{serviceName}/metrics` - - latency(p50/p90/p95), `request_total`, `error_rate` 등 집계 - - 이외에 서비스/엔드포인트 단위 메트릭을 제공하는 유사한 API들이 존재할 수 있음. - -- 문제점 - - 대시보드에서 **짧은 주기(예: 5~15초)**로 동일/유사한 시간 범위(예: “최근 1시간”)를 반복 조회할 때마다 ES에서 동일한 집계를 다시 수행. - - 사용자/탭 수가 늘어나면, 같은 구간에 대한 집계 쿼리가 ES에 중복으로 들어가 부하 증가. - -### 1.2 개선 목표 - -1. **10초 버킷팅(bucketing)** - - “최근 1시간”과 같은 슬라이딩 윈도우 조회에 대해 - **10초 단위로 시간 범위를 정규화**하여, - 10초 동안 들어오는 동일 유형의 요청이 **동일한 from/to로 정규화**되도록 한다. -2. **짧은 TTL Redis 캐시** - - 정규화된 쿼리를 기준으로 **Redis 키-값 캐시를 도입**하여, - 동일 쿼리에 대해 ES 집계를 반복 수행하는 대신 캐시 결과를 재사용한다. - - TTL은 10초 버킷화 주기를 고려해 **약간 더 긴 값(예: 20~30초)**으로 설정한다. -3. **기존 기능과의 호환성 유지** - - 캐시/버킷화 기능은 **점진적 최적화 레이어**로 동작해야 하며, - 캐시가 동작하지 않더라도 기존과 동일한 결과를 제공해야 한다. - - Redis 장애 시에도 **기능 저하(graceful degradation)**만 있을 뿐, API는 계속 정상 동작해야 한다. - ---- - -## 2. 적용 범위 및 정책 - -### 2.1 적용 대상 API - -우선 적용 대상은 다음 엔드포인트로 한정한다. - -- `GET /services/{serviceName}/metrics` - - 슬라이딩 윈도우로 “최근 5분/15분/1시간” 등을 조회하는 **대표 메트릭 API**. - -이후 필요 시, 동일 패턴으로 확장 가능한 후보: - -- `GET /services` -- `GET /services/{serviceName}/endpoints` - -**본 명세에서는 1차 대상 엔드포인트로 -`/services/{serviceName}/metrics`만을 필수 구현 범위로 정의**한다. - -### 2.2 캐싱 대상/비대상 쿼리 구분 - -캐시/버킷화 적용 여부는 쿼리 유형에 따라 달라진다. - -1. **슬라이딩 윈도우 기본 조회 (캐싱/버킷화 대상)** - - 클라이언트가 `from`/`to`를 명시하지 않고, - 예를 들어 다음과 같은 의미로 요청하는 경우: - - “최근 1시간” (default) - - “최근 N분/시간” 등 서버에서 기본 윈도우를 결정하는 경우 - - 이 경우: - - 서버가 **현재 시각 기준 슬라이딩 윈도우**를 정의하고 - - **10초 버킷화를 적용** - - 정규화된 쿼리에 대해 Redis 캐시를 사용 - -2. **사용자 지정 `from`/`to` 조회 (기본적으로 캐시 비대상)** - - 클라이언트가 과거 특정 시점 구간을 직접 지정하는 경우: - - 예: `from=2025-11-14T00:00:00Z&to=2025-11-14T01:00:00Z` - - 기본 정책: - - 이 경우는 “ad-hoc 분석”으로 간주하고, - **캐시/버킷화의 이점이 작고 구현 복잡도가 증가하므로** - 1차 구현에서는 **캐시를 사용하지 않는다.** - - 필요 시 향후: - - 요청 파라미터 전체를 포함한 캐시 키를 설계하여 - 사용자 지정 구간도 캐시 대상으로 확장 가능. - ---- - -## 3. 10초 버킷화 설계 - -### 3.1 버킷화 개념 - -- 버킷 단위(bucketing unit) `Q` = **10초** -- 기본 슬라이딩 윈도우 길이 `W` = **예: 1시간 (3600초)** - - 실제 값은 기존 시스템 기본값으로 맞춘다 (예: 15분/1시간 중 하나). -- 현재 시각 `now` (ms 단위)를 다음과 같이 버킷화: - -```text -quantizedNowMs = floor(nowMs / (Q * 1000)) * (Q * 1000) -to = new Date(quantizedNowMs) -from = new Date(quantizedNowMs - W * 1000) -``` - -- 효과: - - 10초 이내에 들어오는 모든 슬라이딩 윈도우 요청은 - **동일한 from/to 구간을 사용**하게 되어 캐시 키도 동일해진다. - -### 3.2 버킷화 적용 조건 - -- 다음 조건을 모두 만족할 때만 10초 버킷화를 적용한다. - 1. 클라이언트가 `from`/`to` 파라미터를 제공하지 않았다. - 2. 쿼리 타입이 “슬라이딩 윈도우 기본 조회”로 간주되는 경우. - 3. 서비스 환경이 “실시간 대시보드”로 명확한 경우 (별도 플래그 필요 시 확장). - -- 반대로, 다음 경우에는 버킷화를 적용하지 않는다. - - `from`/`to`가 명시된 ad-hoc 조회 - - 내부적으로 정확한 경계가 중요한 특수 분석 API (현재 범위 밖이므로 고려하지 않음) - -### 3.3 정규화 결과 타입 - -정규화된 쿼리를 표현하는 공통 타입(의사 코드): - -```ts -type NormalizedMetricsQuery = { - serviceName: string; - metric: 'latency' | 'request_total' | 'error_rate' | 'all'; - from: Date; // 버킷화 또는 사용자 지정 결과 (항상 값 존재) - to: Date; // 버킷화 또는 사용자 지정 결과 (항상 값 존재) - environment: string | null; - interval: string; // 예: "10s", "30s", "1m" 등 실제 ES date_histogram interval - isSlidingWindow: boolean; // 버킷화된 슬라이딩 윈도우인지 여부 -}; -``` - -- `isSlidingWindow = true`인 경우에만 캐시/버킷화 대상이며, - `from/to`는 **항상 10초 단위로 맞춰져 있게 된다.** - ---- - -## 4. Redis 캐시 설계 - -### 4.1 캐시 계층 위치 - -- 캐시 로직은 **Query-API 서비스 계층**에 위치한다. -- 처리 순서: - 1. 컨트롤러에서 DTO를 통해 쿼리 파라미터 파싱 - 2. **쿼리 정규화 서비스**가 `NormalizedMetricsQuery` 생성 (버킷화 포함) - 3. **MetricsService**가 다음 로직 수행: - - 캐시 키 생성 - - Redis 조회 (hit 시 즉시 반환) - - 미스 시 ES 집계 → 결과 Redis에 저장 → 응답 반환 - -- NestJS 구조 예 (파일명/클래스명은 예시): - - - `metrics.controller.ts` - - `GET /services/:serviceName/metrics` - - `metrics.service.ts` - - `getServiceMetrics(serviceName, dto)` - - `metrics-query-normalizer.service.ts` - - `normalizeServiceMetrics(serviceName, dto): NormalizedMetricsQuery` - - `metrics-cache.service.ts` - - Redis 연동 및 캐시 get/set 추상화 - -### 4.2 캐시 키 설계 - -캐시 키는 **정규화된 쿼리의 의미가 동일하면 항상 동일**하게 생성되어야 한다. -구성 요소는 다음과 같다. - -필수 요소: - -- `serviceName` – path 파라미터 -- `environment` – 쿼리 파라미터 (없으면 `"all"`) -- `metric` – 단일 메트릭 또는 `"all"` -- `from` – 정규화된 시작 시각 (ISO 문자열, 초 단위까지) -- `to` – 정규화된 종료 시각 (ISO 문자열, 초 단위까지) -- `interval` – ES date_histogram interval (예: `"10s"`, `"30s"`, `"1m"`) - -추가 요소 (있다면 반드시 포함): - -- 필터링 필드 (예: `endpoint`, `http_status_class` 등) - -키 포맷 예시 (문자열): - -```text -apm:metrics:v1: -service:{serviceName}: -env:{environmentOrAll}: -metric:{metricOrAll}: -from:{fromIso}: -to:{toIso}: -interval:{interval}: -filters:{normalizedFilterString} -``` - -- 예시 키: - -```text -apm:metrics:v1: -service:order-service: -env:prod: -metric:all: -from:2025-11-14T07:00:00Z: -to:2025-11-14T08:00:00Z: -interval:30s: -filters:endpoint=/api/orders,status=all -``` - -> **주의**: -> - `from`/`to`는 반드시 **버킷화된 시각(10초 배수)**로 정규화된 값이어야 한다. -> - `filters`는 항상 동일 순서/형태로 직렬화해야 한다. (예: key를 정렬한 후 `key=value` 조합을 `,`로 연결) - -### 4.3 캐시 값(Value) 설계 - -- Value는 해당 API의 **최종 응답 JSON 전체**를 그대로 직렬화해서 저장한다. - - 장점: - - 응답 스키마 변경 시, 캐시 구조를 별도로 맞출 필요가 없음. - - 복수 메트릭(p50/p90/p95/requests/error_rate)을 한 번에 캐싱 가능. -- 직렬화 형식: - - JSON 문자열 그대로 저장 (Redis string) - - 또는 캐시 라이브러리에서 제공하는 직렬화 기능 사용 (간단하게 string으로 가정한다). - -예시: - -```json -{ - "serviceName": "order-service", - "environment": "prod", - "window": { - "from": "2025-11-14T07:00:00Z", - "to": "2025-11-14T08:00:00Z", - "interval": "30s" - }, - "metrics": { - "latency": { - "p50": [...], - "p90": [...], - "p95": [] - }, - "request_total": [], - "error_rate": [] - } -} -``` - -이 JSON 전체가 Redis value로 저장된다. - -### 4.4 TTL 정책 - -- 버킷화 단위 `Q` = 10초를 고려할 때, TTL 설정 기준: - - **최소**: 10초 (동일 구간 안에서만 재사용) - - **권장**: 20~30초 -- 권장값 예: - - `TTL_SECONDS = 20` -- 이유: - - 10초 구간 동안 들어온 요청이 같은 결과를 재사용하도록 하고, - - 네트워크/스케줄링 지터로 인한 소폭 시각 차이를 흡수. -- TTL이 만료되면: - - 다음 동일 쿼리에서 캐시 미스 → ES 집계 재실행 → 새 결과로 캐시 갱신. - -### 4.5 Redis 장애 시 동작 - -- Redis에 연결 실패 / get 오류 / set 오류 발생 시: - - **절대 요청을 실패시키지 않는다.** - - 처리 전략: - - 캐시 read: - - 오류 발생 시 → 캐시 미스처럼 취급 → ES 집계 진행 - - 캐시 write: - - 오류 발생 시 → 로그만 남기고 무시 (ES 결과는 그대로 반환) -- 모든 캐시 연산은 **“best effort”**로 동작해야 하며, - 캐시 없이도 시스템은 원래처럼 동작해야 한다. - ---- - -## 5. NestJS 레벨 구조/로직 - -### 5.1 모듈/서비스 구조 - -**필수 신규/수정 요소:** - -1. **Redis 클라이언트/캐시 모듈** - - 예) `RedisModule` / `CacheModule` (구현 방식은 프로젝트 컨벤션에 맞춘다) - - 환경변수: - - `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` 등 - -2. **MetricsQueryNormalizerService** - - 책임: - - `ServiceMetricsQueryDto` → `NormalizedMetricsQuery` 변환 - - 10초 버킷화 로직 적용 - - `interval` 자동 설정 (예: window 길이에 따라 10s/30s/1m 결정) - -3. **MetricsCacheService** - - 책임: - - `buildKey(normalized: NormalizedMetricsQuery): string` - - `get(key: string): Promise` - - `set(key: string, value: string, ttlSeconds: number): Promise` - -4. **MetricsService** - - 책임: - - 정규화된 쿼리 기반으로 캐시를 조회하고, - 캐시 미스 시 ES에 실시간 집계 쿼리를 수행한 뒤 캐시에 저장. - -### 5.2 요청 처리 흐름 (의사 코드) - -컨트롤러: - -```ts -@Get('/services/:serviceName/metrics') -async getServiceMetrics( - @Param('serviceName') serviceName: string, - @Query() queryDto: ServiceMetricsQueryDto, -): Promise { - return this.metricsService.getServiceMetrics(serviceName, queryDto); -} -``` - -서비스: - -```ts -async getServiceMetrics(serviceName: string, queryDto: ServiceMetricsQueryDto) { - // 1) 쿼리 정규화 (10초 버킷화 포함) - const normalized = this.metricsQueryNormalizer.normalizeServiceMetrics(serviceName, queryDto); - - // 2) 캐시 사용 여부 판단 - const shouldUseCache = normalized.isSlidingWindow; // 1차 구현 기준 - - let cacheKey: string | null = null; - if (shouldUseCache) { - cacheKey = this.metricsCache.buildKey(normalized); - const cached = await this.metricsCache.get(cacheKey); - if (cached) { - return JSON.parse(cached); - } - } - - // 3) ES 집계 수행 (기존 로직 그대로 재사용) - const esResult = await this.metricsEsRepository.queryServiceMetrics(normalized); - - // 4) 캐시에 저장 (best-effort) - if (shouldUseCache && cacheKey) { - try { - await this.metricsCache.set(cacheKey, JSON.stringify(esResult), 20 /* TTL seconds */); - } catch (e) { - // 로그만 남기고 무시 - } - } - - return esResult; -} -``` - ---- - -## 6. MetricsQueryNormalizer 세부 설계 - -### 6.1 입력 DTO 예시 (이미 존재한다고 가정) - -```ts -class ServiceMetricsQueryDto { - metric?: string; // 'latency', 'request_total', 'error_rate', 'all' - from?: string; // ISO 8601 - to?: string; // ISO 8601 - environment?: string; // 'prod', 'dev' 등 - interval?: string; // 옵셔널, '10s', '30s', '1m' 등 - // 기타 필터 필드 (endpoint, status 등) -} -``` - -### 6.2 정규화 로직 - -#### 6.2.1 metric, environment 정규화 - -- `metric` - - 값이 없으면 `'all'` - - 허용 값 외 입력 시 400 또는 기본값 `'all'`로 치환 (정책에 따라 선택) -- `environment` - - 값이 없으면 `null`로 저장 (캐시 키에서는 `"all"`로 표현) - -#### 6.2.2 from/to, isSlidingWindow 결정 - -1. `from`과 `to` 모두 제공된 경우: - - `isSlidingWindow = false` - - `from = new Date(dto.from)` - - `to = new Date(dto.to)` - - **버킷화 적용 안 함** - -2. 둘 다 제공되지 않은 경우: - - `isSlidingWindow = true` - - `now = Date.now()` - - `quantizedNow = floor(now / (10s)) * 10s` - - `to = new Date(quantizedNow)` - - `from = new Date(quantizedNow - DEFAULT_WINDOW_MS)` - - `DEFAULT_WINDOW_MS`는 기존 시스템 정책(예: 1시간 = 3600000) 사용 - -3. 한쪽만 있는 경우: - - 1차 구현에서는 **요청을 400으로 처리**하거나, - 내부 정책으로 보정 (예: `from`만 있을 때 `to=now`) 중 하나를 선택해야 한다. - - 단순화를 위해: - - **버킷화/캐싱 대상에서 제외**하고 그대로 ES 실시간 집계만 수행하는 것도 가능. - (이 케이스는 자주 쓰이지 않는다고 가정) - -#### 6.2.3 interval 자동 결정 - -- `interval`이 명시되지 않은 경우: - - `(to - from)` 길이에 따라 자동 결정: - - 0~30분: `"10s"` 또는 `"30s"` - - 30분~2시간: `"30s"` 또는 `"1m"` - - 2시간 이상: `"1m"` 또는 `"5m"` -- 정해진 interval 값은 반드시 `NormalizedMetricsQuery.interval`에 저장되어 - 캐시 키 생성과 ES `date_histogram` 설정에 사용된다. - ---- - -## 7. 테스트 전략 - -### 7.1 기능 테스트 케이스 - -1. **슬라이딩 윈도우 기본 조회 – 캐시 미스 → 히트** - - 시나리오: - 1. `from/to` 없이 `/services/order-service/metrics` 호출 (현재 시각 기준) - 2. ES 집계 수행, Redis에 값 저장 - 3. 10초 이내에 동일 요청 반복 - 4. 두 번째 요청은 ES에 쿼리 없이 Redis 캐시에서 응답 - - 검증: - - 두 응답 내용이 동일해야 함. - - 두 번째 요청에서 ES 쿼리가 발생하지 않아야 함(가능하면 모킹/메트릭으로 검증). - -2. **버킷화 동작 검증** - - 시각 차이가 약간 다른 요청(예: 2초/7초 차이)이라도 - `from/to`가 동일한 값으로 정규화되는지 확인. - - 예: 17:00:01, 17:00:09에 호출 → 둘 다 `to=17:00:00`, `from=16:00:00`. - -3. **TTL 만료 후 재집계** - - 캐시 TTL을 테스트용으로 짧게 설정(예: 3초). - - 요청 → 캐시 miss → 저장 → 2초 후 재요청 → hit → 4초 후 재요청 → miss → 재집계. - -4. **사용자 지정 from/to – 캐시 사용 안 함** - - `from`, `to`를 명시한 요청에서: - - `isSlidingWindow=false`, 캐시 미사용. - - 동일 요청 두 번 호출 시, ES 쿼리가 매번 실행되는지 확인. - -5. **Redis 장애 시 fallback** - - Redis 클라이언트 mock을 이용해 `get`/`set` 호출에서 예외 발생시키기. - - API는 정상 응답을 반환해야 하고, - - 에러 로그만 남는지 확인. - -### 7.2 경계 조건 테스트 - -- **버킷화 경계 시각** - - 정확히 10초 배수 시각(예: `...:00`, `...:10`)에서 요청이 들어왔을 때 - `to`가 해당 시각으로 설정되는지 확인. -- **대상 외 파라미터** - - 잘못된 `metric`, `interval` 값 입력 시 처리 정책 검증. -- **캐시 키 일관성** - - 필터 순서가 다른 두 요청(예: `status=500&endpoint=/a` vs `endpoint=/a&status=500`) - 에 대해 캐시 키가 동일하게 생성되는지 검증. - ---- - -## 8. 구현 체크리스트 (Codex용 요약) - -다음 단계는 CLI codex 또는 개발자가 실제 코드 변경 시 참고할 수 있는 **구현 단계 체크리스트**다. - -1. **Redis 의존성 추가** - - NestJS 프로젝트에 Redis 클라이언트/캐시 모듈 추가 - - 환경변수 및 설정 모듈에 `REDIS_*` 값 반영 - -2. **`MetricsQueryNormalizerService` 생성** - - 입력 DTO(`ServiceMetricsQueryDto`) → `NormalizedMetricsQuery` 변환 - - 10초 버킷화 로직 구현 (`isSlidingWindow=true` 케이스) - - `interval` 자동 결정 로직 구현 - -3. **`MetricsCacheService` 생성** - - `buildKey(normalized)` 구현 - - `get(key)`/`set(key, value, ttl)` 구현 - - Redis 오류 시 예외 전파하지 않고 무시하도록 처리 - -4. **`MetricsService` 수정** - - `getServiceMetrics()` 내부에서: - - Normalizer 호출 - - 캐시 키 생성 및 조회 - - 캐시 미스 시 기존 ES 집계 로직 호출 - - 결과 캐시에 저장 후 반환 - - `isSlidingWindow=false`인 경우 캐시/버킷화 미적용 - -5. **테스트 코드 추가** - - 위 7장에 정의된 주요 시나리오에 대한 단위/통합 테스트 작성 - - 최소: - - 캐시 히트/미스 - - 버킷화 적용 여부 - - Redis 장애 fallback - ---- - -이 문서는 **현재 “실시간 ES 집계만 있는 상태”**를 기준으로, -**추가로 “10초 버킷화 + 짧은 TTL Redis 캐시”를 도입하기 위한 설계**만을 다룬다. - -롤업 인덱스(1분 버킷) 도입은 별도의 `rollup_metrics_spec.md`에서 정의한 대로 독립적으로 진행할 수 있으며, -최종적으로는: - -- 짧은 구간(예: 최근 5~15분): **raw 집계 + 이 명세의 캐시/버킷화** -- 긴 구간(예: 1시간 이상): **롤업 인덱스 기반 조회 + 별도 캐시** - -로 조합해서 사용하면 된다. diff --git a/backend/report.md b/backend/report.md deleted file mode 100644 index 5ad29eb..0000000 --- a/backend/report.md +++ /dev/null @@ -1,198 +0,0 @@ -# APM 로그 Kafka 소비에서 bulk 인덱싱 성능 문제 분석 및 개선 방안 - -## 문제 상황 요약 - -- 배포 환경에서 APM 로그를 Kafka → Elasticsearch로 이전하는 도중 **bulk index** 모드로 리팩터링하였다. 기존의 단건 `index()` 방식에서는 1분에 약 7 000개 문서가 Elasticsearch에 색인되었지만, bulk index로 전환한 뒤에는 Kafka 토픽에 **100만건**의 로그가 쌓여 있음에도 불구하고 Elasticsearch 색인이 1분에 **약 60건** 수준으로 급격히 감소하였다. -- Kafka consumer의 lag는 모두 소비한 것으로 나타나지만, Elasticsearch에는 거의 데이터가 들어오지 않는다. 이는 Kafka에서 메시지를 빠르게 가져왔지만, 어딘가에서 블로킹이 발생해 메시지 처리가 지연되고 있다는 의미다. - -## 원인 분석 - -### 1. `handleLogEvent`가 bulk flush 완료까지 블록(block)한다 - -현재의 `LogConsumerController.handleLogEvent()`는 Kafka 메시지를 DTO로 변환한 뒤 `logIngestService.ingest(dto)`를 **`await`** 하고, 그 다음 `errorLogForwarder.forward(dto)`를 `await`한다. `LogIngestService.ingest()`는 내부적으로 `bulkIndexer.enqueue()`를 호출한다. - -`BulkIndexerService.enqueue()`는 문서를 메모리 버퍼에 추가하고, 버퍼가 일정 크기(`BULK_BATCH_SIZE`, 기본값 1000개)나 크기(`BULK_BATCH_BYTES_MB`)를 넘어서거나 플러시 간격(`BULK_FLUSH_INTERVAL_MS`, 기본 1 s)이 만료될 때만 `_bulk` API를 호출한다. 중요 포인트는 `enqueue()`가 **`Promise`를 반환하며, flush가 완료된 뒤에야 `resolve()`** 된다는 점이다. `ingest()`는 이 Promise를 그대로 `await` 하므로 **해당 문서가 포함된 bulk flush가 끝날 때까지 consumer가 멈춘다**. - -KafkaJS의 `eachMessage` API는 기본적으로 같은 파티션에서는 다음 메시지를 호출하지 않고 **이전 메시지 처리가 끝날 때까지 순차적으로(block) 처리한다**. KafkaJS 공식 문서는 `eachMessage` 핸들러가 세션 타임아웃보다 오래 블록되어서는 안 된다고 명시한다【880035443235562†L100-L107】. 또한 기본적으로 `eachMessage`는 각 파티션에 대해 순차 호출되며, 여러 메시지를 동시에 처리하려면 `partitionsConsumedConcurrently` 옵션을 설정해야 한다【880035443235562†L205-L223】. - -따라서 현재 구조는 다음과 같이 동작한다: - -1. KafkaJS가 특정 파티션에서 메시지를 하나 전달한다. `handleLogEvent()`는 `bulkIndexer.enqueue()`가 포함된 `ingest()`를 `await` 한다. -2. `enqueue()`는 버퍼가 가득 찼거나 타이머가 만료될 때까지(최대 1 s) 반환되지 않으므로, `handleLogEvent()`는 최대 1초 동안 멈춘다. -3. KafkaJS는 같은 파티션에 대해 다음 메시지를 전달하지 않기 때문에, **버퍼가 1 000개가 차기 전까지(또는 1초가 지난 후)** consumer는 새로운 메시지를 가져오지 못한다. -4. 결국 1 000개를 모으기 전에 flush 타이머가 만료되고, 1 초마다 1건만 flush되는 현상이 발생한다. 이 구조는 생산자(back‑pressure)를 고려해 설계된 것이나, 파티션당 직렬 처리와 겹쳐지면서 throughput을 수십건/분 수준으로 떨어뜨렸다. - -### 2. 동시성 부족과 작업 단위가 큼 - -- `eachMessage`는 같은 파티션에 대해 순차적으로 실행되므로, 네트워크 I/O나 _bulk_ API 같은 **비동기 작업이 긴 경우** 성능이 크게 떨어진다. KafkaJS 문서에 따르면 여러 파티션에서 메시지를 동시에 처리하려면 `partitionsConsumedConcurrently` 값을 늘려야 한다【880035443235562†L205-L223】. -- Elasticsearch 공식 가이드에서는 개별 문서 색인보다 bulk API를 사용하는 것이 성능에 유리하지만, **단일 스레드로 bulk 요청을 전송하면 Elasticsearch 클러스터의 자원을 충분히 활용하지 못한다**고 설명한다. 여러 스레드/프로세스로 동시에 bulk 요청을 보내는 것이 이상적이며, 요청 크기는 실험을 통해 찾되 너무 큰 요청은 오히려 메모리 압박을 줄 수 있다고 경고한다【676536220397772†L905-L931】. -- WWT의 KafkaJS 경험 보고서는 `eachMessage`를 이용하여 외부 API 호출을 하던 코드를 `eachBatch` + 배치 엔드포인트로 전환한 결과 **8–10배 성능 향상**을 얻었다고 언급한다【901272165489513†L281-L285】. 이는 단건 처리보다 배치 처리, 그리고 배치별 커밋이 성능에 큰 영향을 준다는 실증적인 사례다. - -## 설계상의 문제점 정리 - -| 문제 | 증상/근거 | -|---|---| -| **await로 인해 Kafka consumer가 bulk flush까지 블로킹** | `enqueue()`의 Promise가 flush 완료 후에야 resolve돼 `handleLogEvent()`가 멈추고, KafkaJS는 같은 파티션의 다음 메시지를 호출하지 않는다. `eachMessage`는 블로킹 연산을 피해야 한다는 문서【880035443235562†L100-L107】. | -| **파티션당 직렬 처리** | 기본 설정에서는 `eachMessage`가 순차적으로 실행되어 네트워크 지연이 throughput을 제한한다. KafkaJS는 `partitionsConsumedConcurrently` 옵션으로 여러 메시지를 동시에 처리할 수 있음을 명시한다【880035443235562†L205-L223】. | -| **Bulk 요청 크기/타이머 설정이 부적합** | `maxBatchSize=1000`, `flushIntervalMs=1000`은 파티션당 초당 하나의 flush만 발생해 메시지가 버퍼에 오래 머무른다. Elastic 문서는 bulk 요청 크기와 워커 수를 실험적으로 맞추고 너무 크면 성능이 떨어질 수 있다고 설명한다【676536220397772†L905-L931】. | -| **단일 스레드에서만 bulk 호출** | Elastic 문서는 여러 스레드/프로세스로 bulk 요청을 보내야 클러스터 자원을 최대한 활용할 수 있다고 권장한다【676536220397772†L924-L946】. 현재 `BulkIndexerService`는 `maxParallelFlushes` 옵션을 제공하지만 기본값 2로 제한돼 있다. | - -## 개선 방안 - -### 1. 비동기 처리 개선 – consumer를 flush 완료까지 기다리지 않기 - -메시지 소비는 빠르게 진행되고, bulk flush는 백그라운드에서 비동기로 처리되도록 설계하는 것이 중요하다. 따라서 `bulkIndexer.enqueue()`를 호출할 때 반환되는 `Promise`를 **기다리지 않고** Fire‑and‑forget 방식으로 큐에 추가해야 한다. 실패 시에는 로깅하고 메트릭을 올리거나 Kafka 오프셋 커밋을 별도로 관리해야 한다. - -구체적으로: - -- `LogIngestService.ingest()`에서 `await this.bulkIndexer.enqueue(...)`를 제거하고, 반환되는 Promise를 처리하지 않은 채로 버퍼에 넣는다. 에러는 `.catch()`에서 로깅한다. 이렇게 하면 Kafka consumer는 flush 타이밍과 무관하게 다음 메시지를 계속 받을 수 있다. -- `LogConsumerController.handleLogEvent()`에서도 `await`를 제거해 consumer 핸들러가 블로킹되지 않도록 한다. 에러 처리를 위해 반환된 Promise를 별도로 캐치하고 로깅하거나, 실패한 경우 해당 오프셋을 별도 큐에 저장해 재처리할 수 있다. - -이 방법의 단점은 **오프셋 커밋 타이밍이 빨라질 수 있어 일부 문서가 Elasticsearch에 완전히 색인되기 전에 메시지를 성공으로 간주할 수 있다는 것**이다. 만약 정확한 처리 보장이 필요하다면, 아래의 `eachBatch` 패턴이나 manual commit을 활용해 flush 이후에만 커밋하도록 관리하는 방식을 채택한다. - -### 2. 각 파티션 병렬 처리 – `partitionsConsumedConcurrently` 사용 - -KafkaJS는 기본적으로 파티션당 하나의 `eachMessage` 호출만 실행한다. 비동기 작업이 길어지면 전체 처리량이 급격히 떨어지므로, 여러 파티션을 동시에 처리하도록 `partitionsConsumedConcurrently`를 늘려야 한다. KafkaJS 문서에 따르면 `partitionsConsumedConcurrently` 값을 올리면 여러 파티션의 메시지를 동시에 처리하면서도 **같은 파티션 내 순서를 보장**한다【880035443235562†L205-L223】. NestJS에서는 microservice 옵션에 `consumer: { partitionsConsumedConcurrently: N }`를 지정하여 이를 설정할 수 있다. - -예를 들어, 토픽이 4개 파티션으로 구성돼 있고 CPU 코어가 충분하다면 `partitionsConsumedConcurrently: 2` 또는 `3`을 설정하여 파티션 간 병렬성을 올릴 수 있다. 이 값은 파티션 수보다 크게 설정해도 효과가 없으므로, 토픽의 파티션 수와 시스템 성능을 고려해 실험적으로 결정한다. - -### 3. `eachBatch` 및 manual commit 패턴으로 전환 - -bulk 인덱싱은 본질적으로 여러 레코드를 하나의 요청으로 묶는 작업이므로, Kafka consumer에서도 **배치 단위로 메시지를 가져와 처리**하는 것이 더 적합하다. `eachBatch` 핸들러는 메시지 배열과 함께 `resolveOffset`/`commitOffsetsIfNecessary`/`heartbeat` 등 유용한 API를 제공한다【880035443235562†L109-L166】. 이를 이용하면 다음과 같은 장점이 있다: - -- 한 배치의 메시지를 모두 `bulkIndexer.enqueue()`로 넣은 뒤 바로 flush를 요청할 수 있다. -- flush가 완료된 후 `commitOffsetsIfNecessary()`를 호출하여 해당 배치의 마지막 오프셋만 커밋함으로써 **at‑least‑once** 처리를 보장한다. -- `eachBatch`는 기본적으로 KafkaJS 내부에서 batch 당 자동 커밋을 수행하며, `eachBatchAutoResolve`를 제어하여 원하는 커밋 시점을 조정할 수 있다. - -WWT의 사례에서도 `eachMessage` 대신 `eachBatch` 패턴을 사용하고 외부 API도 배치로 묶자 **8–10배 이상 성능 향상**을 경험했다고 보고한다【901272165489513†L281-L285】. - -### 4. Bulk flush 파라미터 조정 및 병렬 flush 증가 - -- **배치 크기와 플러시 주기 조정** : 현재 `BULK_BATCH_SIZE=1000`, `BULK_FLUSH_INTERVAL_MS=1000`은 빈도가 너무 낮아 batch가 1000개가 채워지지 않는 상황에서 1초마다 하나의 flush만 발생한다. Elastic 문서에서는 최적의 bulk 크기를 실험적으로 찾고, 너무 큰 요청은 메모리 압박을 유발할 수 있으므로 수십 MB를 넘기지 않도록 권고한다【676536220397772†L905-L916】. 예를 들어 초기 배치 크기를 200–500개로 줄이고 flush 간격을 100–200 ms로 줄이면 메시지가 버퍼에 오래 머무르지 않고 빠르게 색인된다. -- **동시 flush 수 늘리기** : `BulkIndexerService`의 `maxParallelFlushes`는 동시 flush 요청 수를 제한한다. 현재 기본값은 2인데, Elasticsearch 클러스터 여유가 있다면 이 값을 늘려서 동시에 여러 bulk 요청을 보내도록 한다. Elastic 문서에서도 단일 스레드로는 클러스터의 색인 성능을 충분히 활용하지 못한다고 지적하며, 여러 워커/스레드를 통해 데이터를 전송해야 한다고 조언한다【676536220397772†L924-L931】. - -## 코드 수정안 (예시) - -아래 수정안은 기존 구조를 크게 바꾸지 않으면서 consumer 블로킹 문제를 해결하는 접근법이다. 정확한 구현은 서비스 요구 사항(정확한 처리 보장 vs. 처리량 우선)을 고려해 조정한다. - -### 1. `LogIngestService.ingest()` – bulk enqueue를 Fire‑and‑forget으로 변경 - -```ts -// src/apm/log-ingest/log-ingest.service.ts -async ingest(dto: LogEventDto): Promise { - const document: LogDocument = { - "@timestamp": this.resolveTimestamp(dto.timestamp), - type: "log", - service_name: dto.service_name, - environment: dto.environment, - trace_id: dto.trace_id, - span_id: dto.span_id, - level: dto.level, - message: dto.message, - http_method: dto.http_method, - http_path: dto.http_path, - http_status_code: dto.http_status_code, - labels: this.normalizeLabels(dto.labels), - ingestedAt: new Date().toISOString(), - }; - - // 버퍼에 적재한 뒤 기다리지 않는다. 실패 시에는 로깅만 하고, 필요하다면 재시도 로직을 별도로 구현한다. - void this.bulkIndexer - .enqueue(LogIngestService.STREAM_KEY, document) - .catch((err) => { - // handle error: 메트릭 증가, 모니터링, 재큐잉 등 - this.logger.error('Bulk enqueue failed', err); - }); -} -``` - -### 2. `LogConsumerController.handleLogEvent()` – 비동기 호출로 변경 - -```ts -// src/log-consumer/log-consumer.controller.ts -@EventPattern(process.env.KAFKA_APM_LOG_TOPIC ?? 'apm.logs') -async handleLogEvent(@Ctx() context: KafkaContext): Promise { - const value = context.getMessage().value; - if (value == null) { - this.logger.warn('Kafka 메시지에 본문이 없어 처리를 건너뜁니다.'); - return; - } - - try { - const dto = this.parsePayload(value); - // bulk 인덱싱은 기다리지 않고 큐에 추가 - this.logIngestService.ingest(dto); - // 에러 로그 포워딩은 빠르게 끝나므로 동시에 실행하도록 하되, 필요하다면 await - void this.errorLogForwarder.forward(dto).catch((err) => { - this.logger.error('Error forward failed', err); - }); - // 처리된 메시지 카운트는 즉시 증가시킨다 - this.throughputTracker.markProcessed(); - this.logger.debug( - `로그 이벤트 처리 시작: topic=${context.getTopic()} partition=${context.getPartition()}`, - ); - } catch (error) { - if (error instanceof InvalidLogEventError) { - this.logger.warn( - `유효하지 않은 로그 이벤트를 건너뜁니다: ${error.message}`, - ); - return; - } - this.logger.error( - '로그 이벤트 처리에 실패했습니다.', - error instanceof Error ? error.stack : String(error), - ); - // 오류를 throw하면 KafkaJS가 해당 오프셋을 커밋하지 않고 재처리한다. - throw error; - } -} -``` - -이렇게 수정하면 `handleLogEvent()`는 bulk flush 완료 여부와 무관하게 다음 메시지를 받을 수 있어 consumer가 블로킹되지 않는다. 단, flush 실패와 오프셋 커밋 사이의 정확한 처리 보증을 위해서는 manual commit을 적용하거나 실패한 문서를 별도 큐에 저장하는 전략이 필요하다. - -### 3. Kafka consumer 동시성 설정 - -KafkaJS에서 파티션을 병렬로 처리하려면 consumer 실행 시 `partitionsConsumedConcurrently`를 설정한다. NestJS에서는 microservice 옵션에 다음과 같이 추가한다. 이 설정은 토픽의 파티션 수와 시스템 리소스를 고려해 조정한다. - -```ts -// 예시: KafkaModule 등록 시 consumer 옵션에 partitionsConsumedConcurrently 설정 -KafkaModule.register({ - client: { - clientId: 'apm-log-consumer', - brokers: [process.env.KAFKA_BROKER_URL], - }, - consumer: { - groupId: process.env.KAFKA_GROUP_ID ?? 'apm-log-group', - // 파티션 3개를 동시에 소비. 값은 파티션 수보다 크지 않아야 함 - partitionsConsumedConcurrently: Number.parseInt(process.env.KAFKA_CONCURRENT_PARTITIONS ?? '2', 10), - }, -}); -``` - -`ConsumerRunConfig` 타입 정의에는 `partitionsConsumedConcurrently` 옵션이 존재하며, `eachBatch`/`eachMessage`와 함께 사용할 수 있다【217620888615763†L2674-L2678】. 이 값을 증가시키면 여러 파티션에서 메시지를 동시에 처리하면서도 **각 파티션 내 순서는 유지**된다【880035443235562†L205-L223】. - -### 4. `eachBatch` 패턴으로 전환 (선택적) - -보다 강력한 처리량과 정확한 오프셋 관리가 필요하다면 `eachBatch`를 활용한 구조를 고려한다. 간략한 흐름은 다음과 같다: - -1. Kafka consumer를 `autoCommit: false` 또는 `eachBatchAutoResolve: false`로 설정한다. -2. `eachBatch` 핸들러에서 `batch.messages` 배열을 순회하며 각 메시지를 DTO로 변환하고 `bulkIndexer.enqueue()`에 넣는다. 이 때 `enqueue()`는 기다리지 않고 바로 반환한다. -3. 배치의 모든 메시지가 큐에 들어가면 즉시 `bulkIndexer.triggerFlush()`(별도의 public API를 만들거나 `maxBatchSize`/`flushIntervalMs`를 낮춤)로 flush를 요청한다. -4. flush가 성공하면 `resolveOffset`으로 배치의 마지막 오프셋을 표시하고 `commitOffsetsIfNecessary()`를 호출해 해당 오프셋을 커밋한다. 실패하면 예외를 throw해 KafkaJS가 동일 배치를 재처리하게 한다. - -이 방식은 메시지 처리와 flush를 분리하면서도 flush가 성공해야만 오프셋을 커밋하므로 **at‑least‑once** 보장에 적합하다. 구현 난이도가 높지만 성능 향상과 신뢰성을 동시에 얻을 수 있다. - -### 5. Bulk flush 파라미터 조정 - -환경 변수나 설정 파일을 통해 다음 값을 조정한다: - -- `BULK_BATCH_SIZE` – 한 번에 묶을 문서 수. 초기에는 100–500 사이로 설정해 메시지가 버퍼에 머무는 시간을 줄인다. -- `BULK_BATCH_BYTES_MB` – 배치의 최대 바이트 수. 너무 크면 Elasticsearch가 과부하될 수 있으므로 수십 MB 이하를 권장한다【676536220397772†L905-L916】. -- `BULK_FLUSH_INTERVAL_MS` – 타이머 기반 flush 주기. 실험적으로 100–200 ms로 줄이면 작은 배치라도 지연 없이 flush된다. -- `BULK_MAX_PARALLEL_FLUSHES` – 동시 flush 횟수. 클러스터가 견딜 수 있는 범위에서 값을 늘리면 여러 배치를 동시에 색인할 수 있다【676536220397772†L924-L931】. - -## 결론 - -현재 문제는 bulk 인덱싱 자체가 아니라, **consumer가 flush 완료까지 블로킹**되는 설계와 **파티션당 직렬 처리**가 결합되면서 발생한 병목이다. KafkaJS 문서가 경고하듯 `eachMessage` 핸들러는 장시간 블로킹을 피해야 하며, 여러 파티션을 동시에 처리하도록 `partitionsConsumedConcurrently`를 설정해야 한다【880035443235562†L100-L107】【880035443235562†L205-L223】. 또한 Elasticsearch에서는 bulk 요청을 여러 스레드에서 보내야 최대 색인 속도를 달성할 수 있고, 배치 크기와 flush 주기를 실험적으로 튜닝해야 한다【676536220397772†L905-L931】. - -위에서 제시한 Fire‑and‑forget 방식, `partitionsConsumedConcurrently` 활용, `eachBatch` 패턴, flush 파라미터 조정 등을 적용하면 배포 환경에서도 **초당 수천 건** 수준의 처리량을 회복할 수 있을 것이다. 추가로, 실패한 문서의 재처리와 정확한 오프셋 관리를 위해 로그 저장소 상태와 Kafka 오프셋을 함께 관리하는 로직을 설계하는 것이 좋다. diff --git a/backend/rollup_metrics_spec.md b/backend/rollup_metrics_spec.md deleted file mode 100644 index f6233ff..0000000 --- a/backend/rollup_metrics_spec.md +++ /dev/null @@ -1,225 +0,0 @@ -# APM 프로젝트: 롤업 인덱스와 실시간 집계 설계 명세 - -이 문서는 기존 **APM 프로젝트**에서 제공하는 실시간 집계 기능을 확장하여, **롤업(Roll‑up) 인덱스**를 도입하고 **짧은 구간은 원본(raw) 집계 + 캐시**로 처리하는 하이브리드 방식을 구현하기 위한 상세 명세입니다. 이 명세는 CLI codex나 개발자가 읽고 그대로 구현할 수 있을 정도로 구체적으로 작성되었습니다. 문서의 모든 시간은 **Asia/Seoul** 기준이며, 현재 날짜는 2025‑11‑14입니다. - -## 1. 배경 및 목표 - -현재 APM 시스템은 다음과 같이 동작합니다: - -- **데이터 수집 파이프라인**: 애플리케이션에서 생성되는 로그/스팬을 Kafka 토픽(`apm.logs`, `apm.spans`)에 발행하고, `stream‑processor` 서비스가 이를 소비하여 Elasticsearch 데이터 스트림(`logs-apm.*`, `traces-apm.*`)에 인덱싱합니다. 이때 stream-processor는 `apm_bulk_design.md`에 정의된 BulkIndexer로 문서를 버퍼링한 뒤 `_bulk` API로 저장하며, 이 단계에서는 어떤 롤업/집계도 수행하지 않습니다. -- **조회 API(Query‑API)**: NestJS 기반 서비스로, Elasticsearch에서 시간 범위에 맞는 로그/스팬을 조회하면서 집계(`percentiles`, `sum`, `filter` 등)를 수행하여 메트릭(p50, p90, p95, request_total, error_rate)을 실시간으로 계산하고 응답합니다. - -**문제점**: 장기 구간(예: 최근 1시간 이상)을 지속적으로 조회할 경우, 매번 원본 로그/스팬 전체를 스캔해야 하므로 Elasticsearch 및 `stream‑processor`의 부하가 증가합니다. 또한 SLA/SLO 보고서나 주간/월간 대시보드와 같이 장기 데이터를 자주 조회하는 경우 성능과 비용 문제가 발생합니다. - -**목표**: 다음과 같은 하이브리드 방식을 구현합니다. - -1. **롤업(Roll‑up) 인덱스 도입**: 로그/스팬 원본을 일정한 시간 단위(이 문서에서는 1분 버킷으로 정의)로 미리 집계하여 별도의 인덱스에 저장합니다. 이 인덱스는 p50/p90/p95, 요청 수, 오류 수 등을 포함한 요약 메트릭을 제공합니다. -2. **짧은 구간은 원본 집계 + 캐시 유지**: 최근 몇 분(예: 5분 이내)은 여전히 원본 인덱스에서 실시간 집계하여 최신 데이터를 제공합니다. 해당 부분은 Redis 캐시(10초 시간 버킷) 전략을 사용합니다. -3. **Query‑API 개선**: 요청 범위에 따라 raw 인덱스, 롤업 인덱스 또는 두 데이터를 결합하여 응답을 생성합니다. 이 과정은 완전히 자동화되어야 하며, 기존 엔드포인트와 스키마를 유지합니다. -4. **롤업/집계 파이프라인 분리**: ingest 단계(Bulk 색인)에서는 오로지 문서 저장에만 집중하고, 1분 버킷 카운트 같은 집계는 Elasticsearch Transform·Aggregation API·Cron job 등 별도 후처리 경로로 구현합니다. 컨슈머에서 집계까지 수행하면 per-message 처리 시간이 길어져 throughput이 크게 떨어지므로, 본 스펙의 모든 롤업/통계 계산은 **색인 후 비동기 파이프라인**에서 이루어져야 합니다. - -## 2. 롤업 인덱스 설계 - -### 2.1 버킷 크기 선택 - -- 기본 버킷 크기를 **1분**으로 정의합니다. 1분은 짧은 간격의 실시간 분석과 비교적 긴 기간 조회(24시간, 7일 등) 사이의 균형을 고려한 값입니다. -- 필요에 따라 추가 해상도(10초, 5분, 1시간) 버킷을 도입할 수 있지만, 이 명세에서는 1분 버킷만을 정의합니다. - -### 2.2 인덱스 네이밍 규칙 - -- **데이터 스트림/인덱스 패턴**: `metrics-apm.<서비스이름>-` - - 예시: `metrics-apm.order-service-prod`, `metrics-apm.user-service-dev` -- 각 데이터 스트림에는 1분 단위의 데이터가 저장됩니다. Kibana 등에서 이를 데이터 스트림으로 관리하면 rollover 및 ILM 정책 적용이 용이합니다. - -### 2.3 매핑과 필드 정의 - -롤업 문서의 필드는 다음과 같습니다. - -- `@timestamp_bucket` (`date`) – 집계된 버킷의 시작 시각(UTC). 예: `2025-11-14T08:05:00Z` (집계 대상은 08:05:00~08:05:59.999 사이의 이벤트) -- `service_name` (`keyword`) – 메트릭이 속한 서비스 이름 -- `environment` (`keyword`) – 실행 환경 (예: `prod`, `stage` 등) -- `request_count` (`long`) – 해당 버킷 내 전체 요청/스팬 수(분모) -- `error_count` (`long`) – 오류로 분류된 요청/스팬 수(분자) -- `latency_p50_ms` (`double`) – 지연 시간 50번째 퍼센타일(밀리초) -- `latency_p90_ms` (`double`) – 지연 시간 90번째 퍼센타일(밀리초) -- `latency_p95_ms` (`double`) – 지연 시간 95번째 퍼센타일(밀리초) -- `latency_p99_ms` (`double`, 옵션) – 지연 시간 99번째 퍼센타일 (선택 사항) -- `error_rate` (`double`) – `error_count / request_count` 값 (실제 요청 시 계산해도 되지만 저장 시 계산하여 정확도를 높이는 것이 좋음) -- `target` (`keyword`, 옵션) – 서비스 내 특정 endpoint 등 추가 식별자에 사용 가능 - -매핑 예시는 다음과 같습니다(ES 템플릿으로 등록하는 형태): - -```json -{ - "index_patterns": ["metrics-apm.*"], - "data_stream": {}, - "settings": { - "number_of_shards": 1, - "number_of_replicas": 1 - }, - "mappings": { - "properties": { - "@timestamp_bucket": { "type": "date" }, - "service_name": { "type": "keyword" }, - "environment": { "type": "keyword" }, - "request_count": { "type": "long" }, - "error_count": { "type": "long" }, - "latency_p50_ms": { "type": "double" }, - "latency_p90_ms": { "type": "double" }, - "latency_p95_ms": { "type": "double" }, - "latency_p99_ms": { "type": "double" }, - "error_rate": { "type": "double" }, - "target": { "type": "keyword" } - } - } -} -``` - -### 2.4 ILM(인덱스 수명 관리) 정책 - -- **보존 기간**: 메트릭 롤업 데이터는 로그보다 긴 기간이 요구될 수 있습니다. 기본 값으로 30일을 추천하며, 필요시 환경 변수로 조정합니다. -- **Rollover 조건**: 데이터 스트림을 사용하는 경우 ES가 자동으로 관리합니다. 단일 인덱스를 사용하는 경우 롤오버 정책을 구성해야 합니다(예: 일 단위 혹은 인덱스 크기 기준). - -## 3. 롤업 데이터 생성 방법 - -### 3.1 Stream‑Processor 변경 - -현재 `stream‑processor`는 Kafka `apm.logs`/`apm.spans`를 BulkIndexer를 통해 raw 데이터 스트림에 저장합니다. 롤업 생산은 ingest 경로가 아닌 **별도 후처리 파이프라인**으로 구현합니다. 접근 방식은 다음 중 하나를 택합니다. - -1. **Elasticsearch Transform**: - - `logs-apm.*`와 `traces-apm.*`를 원천 인덱스로 지정하고 1분 버킷 Transform을 생성합니다. - - Transform은 elasticsearch가 관리하므로 stream‑processor의 CPU/메모리를 소모하지 않고 롤업 인덱스(`metrics-apm.*`)를 채웁니다. - - 퍼센타일 계산은 ES `percentiles` 집계를 사용합니다. -2. **별도 Aggregator(Worker)**: - - Query-API 혹은 전용 워커 프로세스가 일정 주기(예: 30초)에 raw 인덱스를 집계하여 롤업 문서를 생성한 뒤 Bulk로 색인합니다. - - 이 워커는 stream‑processor와 별도의 애플리케이션으로 운영되어 ingest 경로와 자원을 공유하지 않습니다. -3. **Redis/메시지 기반 파이프라인**: - - 임시 저장소에 엔드포인트별 카운터를 적재 후, 일정 주기에 롤업 인덱스로 밀어 넣는 구조도 가능합니다. 핵심은 ingest 서비스와 집계 로직을 명확히 분리하는 것입니다. - -선택한 방식과 관계없이 롤업 파이프라인은 실패해도 raw 색인을 막지 않아야 하며, stream‑processor의 BulkIndexer 처리량에 영향을 주지 않도록 독립적으로 운영해야 합니다. - -### 3.2 ES Transform 사용 (대체안) - -Elasticsearch 자체 기능인 Transform을 활용하는 방법도 있습니다. 이 경우: - -1. `logs-apm.*`와 `traces-apm.*` 데이터 스트림을 대상으로 Transform을 생성하여 1분 단위 롤업을 수행합니다. -2. 대상 인덱스를 `metrics-apm`과 동일한 매핑으로 지정합니다. -3. Transform job은 지속형(continuous)으로 동작하게 설정하여 새 데이터가 들어올 때마다 롤업 레코드를 생성합니다. -4. Transform의 집계 파이프라인은 Elasticsearch가 관리하기 때문에, 서비스 코드 수정 없이 롤업 구현이 가능합니다. 단, Transform의 퍼센타일 계산에서는 ES 기본 `percentiles` 집계를 사용합니다. - -Transform 방식은 인프라 차원에서 설정하면 되지만, 이번 명세에서는 애플리케이션 레벨에서 구현하는 방안을 기본으로 설명합니다. - -## 4. Query‑API 개선 - -### 4.1 쿼리 범위 분기 기준 - -`Query‑API`에서 메트릭 조회 요청을 받을 때 다음 로직을 적용합니다. - -1. **쿼리 파라미터 파싱**: `metric`(p50/p90/p95/requests_total/error_rate), `from`, `to`, `environment`, `interval`을 DTO로 파싱합니다. `from/to`가 없으면 기본값은 “현재 시각 기준 최근 15분”으로 설정합니다. -2. **시간 범위 계산**: `duration = to - from`을 계산합니다. -3. **분기 조건**: `duration`이 **사용자 지정 임계값**보다 짧으면 raw 집계, 길면 롤업 인덱스를 사용합니다. - - 예시 임계값: `ROLLUP_THRESHOLD_MINUTES = 5`. 즉, 최근 5분 이내는 raw 조회, 그 이상은 롤업 사용. -4. **부분 범위 병합**: 쿼리 범위가 임계값을 넘을 경우, `to - threshold` 이전 부분은 롤업 인덱스에서, 마지막 임계 구간은 raw 인덱스에서 조회하여 결과를 합칩니다. - -### 4.2 메트릭 계산 로직 변경 - -#### 4.2.1 Raw 집계 (기존 방식 유지) - -기존 로직을 그대로 유지하되, 아래를 유의합니다: - -- 시간 버킷화: `to`를 10초 단위로 내림하여 캐싱 키를 통일합니다. -- 필터 및 집계 DSL은 v2 스펙과 동일하게 작성합니다. -- 응답 스키마도 기존 `MetricResponse`를 유지합니다. -- 캐시 TTL은 10~20초로 유지합니다. - -#### 4.2.2 롤업 집계 - -롤업을 사용하는 경우에는 다음과 같이 진행합니다. - -1. **쿼리 작성** - - 타임 필드는 `@timestamp_bucket`을 사용합니다. - - 집계 시 `date_histogram` interval을 사용하지 않고, 롤업 레코드 자체가 이미 1분 버킷이므로 단순히 `range` 필터와 정렬만 필요합니다. - - 예를 들어, 2025‑11‑14T08:00:00Z~08:59:59Z 구간의 `latency_p95_ms`를 조회하면 다음과 같은 DSL을 사용합니다. - - ```json - { - "query": { - "bool": { - "filter": [ - { "term": { "service_name": "order-service" } }, - { "term": { "environment": "prod" } }, - { "range": { "@timestamp_bucket": { "gte": "2025-11-14T08:00:00Z", "lt": "2025-11-14T09:00:00Z" } } } - ] - } - }, - "sort": [ { "@timestamp_bucket": "asc" } ], - "size": 1000 - } - ``` - -2. **결과 가공** - - ES에서 반환된 문서 리스트를 시간순으로 정렬하여 시계열 데이터로 변환합니다. - - 요청된 metric 종류(`metric` 파라미터)에 따라 `latency_p95_ms`, `latency_p50_ms`, `request_count`, `error_rate` 등 필요한 필드를 추출합니다. -3. **캐싱 전략** - - 롤업 데이터는 기본적으로 이미 요약된 데이터여서 raw 조회보다 부하가 작습니다. 하지만 동일 범위의 요청이 반복될 수 있으므로 캐싱을 적용합니다. - - 캐시 키: `metrics-rollup:{service}:{env}:{metric}:{from}:{to}` - - TTL: `30s`~`60s` 정도로 설정하여 최근 쿼리에 대한 재사용성을 높입니다. - -#### 4.2.3 혼합 구간 처리 - -예를 들어, `from=2025-11-14T08:00:00Z`, `to=2025-11-14T08:06:00Z` (6분)인 요청에서 임계값이 5분이라면: - -1. `splitPoint = to - threshold = 2025-11-14T08:01:00Z` -2. **롤업 부분**: [08:00:00Z ~ 08:01:00Z) → 롤업 인덱스에서 1분 버킷 1개 조회 -3. **raw 부분**: [08:01:00Z ~ 08:06:00Z) → raw 인덱스에서 기존 집계 DSL 사용 -4. 두 결과를 시간순으로 합쳐 응답합니다. - -### 4.3 코드 구조 변경 제안 (NestJS) - -1. **RollupMetricsRepository** (`metrics-apm` 전용) - - 인덱스 이름, 환경 등을 받아 롤업 쿼리를 수행하는 서비스. - - `queryRollupMetrics(normalizedQuery: NormalizedServiceMetricsQuery): Promise` 메서드 제공. -2. **RawMetricsRepository** (기존 코드) - - 기존 `SpansRepository`/`LogsRepository`에 있는 집계 기능을 모듈화하여 메트릭 전용으로 추출. -3. **ServiceMetricsService** (조정 필요) - - `normalizeServiceMetrics()`에서 범위 길이를 체크하여 raw/rollup/혼합을 결정합니다. - - 캐시 키를 raw와 rollup 쿼리에 대해 구분하여 생성합니다. -4. **환경 변수 추가** - - `ROLLUP_ENABLED` (기본 `true`) - - `ROLLUP_THRESHOLD_MINUTES` (기본 `5`) - - `ROLLUP_INDEX_PREFIX` (기본 `metrics-apm`) - - `ROLLUP_BUCKET_MINUTES` (기본 `1`) - - `ROLLUP_CACHE_TTL_SECONDS` (기본 `60`) - -이 구조를 통해 Query‑API는 기존 엔드포인트와 호환성을 유지하면서도 롤업 기반 조회를 도입할 수 있습니다. - -## 5. 배포 및 운영 지침 - -1. **롤업 인덱스 템플릿/ILM 적용**: 앞서 제시한 매핑과 보존 정책을 Elasticsearch 클러스터에 등록합니다. 수집 환경별(namespace별)로 템플릿 이름을 구분합니다. -2. **stream‑processor 업데이트 배포**: 롤업 집계 모듈을 통합한 후, Canary 배포 등으로 점진적으로 도입합니다. 배포 전에 Kafka lag과 ES 인덱싱 지연을 모니터링하여, 롤업 연산이 **별도의 후처리 파이프라인에서 동작하고 ingest 경로를 방해하지 않는지** 확인합니다. -3. **모니터링**: 롤업 인덱스 생성률, 문서 수, 집계 지연 등을 대시보드로 구성하여 이상을 감지합니다. raw 인덱스와 롤업 인덱스의 데이터가 동일 기간에 대해 일치하는지도 확인합니다. -4. **백필(backfill)**: 롤업 도입 이전 기간에 대해 과거 데이터를 롤업 인덱스에 채워 넣어야 하는 경우, 별도의 배치 프로세스를 만들어 일정 구간씩 raw 데이터를 읽어 롤업 문서를 생성합니다. 백필 작업은 스로틀링(throttling)을 걸어서 클러스터 부하를 피하도록 합니다. - -## 6. 테스트 및 검증 시나리오 - -1. **기본 동작 테스트** - - 새로 생성된 롤업 인덱스에 1분 단위 문서가 적절히 작성되는지 확인합니다. - - Query‑API에서 `from/to`를 임계값보다 짧은 범위로 지정할 때 raw 집계 결과가 반환되는지 검증합니다. - - `from/to`가 긴 범위일 때 롤업 데이터만 사용되는지, 혼합 범위에서 결과가 올바르게 합쳐지는지 확인합니다. -2. **성능 테스트** - - 기존 구조 대비 롤업 도입 후 장기 구간(예: 1시간, 24시간) 조회의 응답 시간이 얼마나 줄어드는지 측정합니다. - - Redis 캐시가 히트할 때/미스할 때의 응답 속도 차이를 분석합니다. -3. **에지 케이스** - - 데이터가 아예 없는 구간(과거 or 미래)에 대한 조회 - - 동일한 `@timestamp_bucket` 값이 여러 서비스에 존재할 때 결과 필터링 - - 임계값이 매우 작거나 큰 값으로 설정될 때 동작 - -## 7. 향후 확장 가능성 - -- **다중 버킷 크기**: 10초, 1분, 5분 등 다양한 해상도의 롤업을 동시에 저장하면 UX에 맞춰 더 세밀한 그래프를 그릴 수 있습니다. -- **메트릭 종류 확장**: 데이터베이스 쿼리 시간, 외부 호출 실패율 등 다른 메트릭도 롤업에 포함할 수 있습니다. 필드 추가 시 매핑을 확장하고 stream‑processor의 집계 로직을 수정해야 합니다. -- **다른 지표의 스트리밍**: 에러 로그 스트리밍(WebSocket + Kafka)을 롤업과 연계하여, 에러가 급증할 때 롤업 지표를 기반으로 알람 트리거 등으로 활용할 수 있습니다. - -## 8. 결론 - -이 명세는 APM 프로젝트에 롤업 인덱스를 도입하여 장기 구간 조회 성능을 개선하는 동시에, 최근 구간에 대해서는 기존과 동일한 실시간 집계와 캐시 전략을 유지하기 위한 구현 지침을 제공합니다. 문서에서 정의한 데이터 구조, 인덱스 매핑, stream‑processor 변경, Query‑API 수정 사항을 따르면, 기존 실시간 집계에 의존하던 아키텍처를 확장하여 데이터 조회 효율과 운영 안정성을 크게 향상시킬 수 있습니다.