← 문제로

22. 게임 로그·텔레메트리 파이프라인 설계

난이도 중
내 답안
모범답안

문제 22 해설 — 게임 로그·텔레메트리 파이프라인 설계

난이도: 중상

0. 큰 그림

[게임 서버 프로세스]
  로그 호출 (논블로킹, 구조화 이벤트)
      └─> 링버퍼(MPSC) ──> 전송 스레드 ──┐
                                        │ (로컬 UDS/TCP, 배치+압축)
[로컬 에이전트: Vector/Fluent Bit]  <────┘
      └─ 디스크 스풀(감사 로그) / 드롭 정책(텔레메트리)
      └─> Kafka  ──┬──> 실시간 소비자(알림, 어뷰징 탐지, 대시보드)
                   ├──> ClickHouse (핫: 최근 7~30일)
                   └──> S3/Parquet (콜드: 1~2년, 배치 분석)
[감사 로그만] 게임 로직 트랜잭션 == DB outbox 테이블 → CDC → Kafka

핵심 원칙 세 가지:

  1. 게임 스레드는 절대 I/O 를 하지 않는다. 로그 호출은 메모리 쓰기까지만.
  2. 종류마다 파이프라인의 보장 수준을 다르게 한다. 전부 무손실로 만들면 비용도 지연도 감당 못 하고, 전부 손실 허용으로 만들면 감사가 무너진다.
  3. 게임 서버는 상태를 오래 들고 있지 않는다. 버퍼링·재시도·스풀은 에이전트와 브로커의 일이다.

1. 로그 종류별 요구사항

항목 디버그/에러 로그 감사(audit) 로그 행동 텔레메트리
목적 장애 원인 추적 CS 대응, 어뷰징 조사, 회계 대사 밸런싱, LiveOps, A/B 테스트
볼륨 중(평시 적고 장애 시 폭증) 소~중(초당 수천~수만) 초대(초당 수백만)
유실 허용 허용(샘플링·레벨 필터) 불허(0%) 허용(0.1~1%)
지연 요구 초 단위(장애 시 빨라야) 분 단위 허용, 대신 확실히 도착 분~시간 단위
순서 요구 인스턴스 내 순서면 충분 엔티티(계정/아이템) 단위 순서 필수 거의 불필요
정확성 best-effort 정확·불변·감사 가능(append-only) 통계적으로 맞으면 됨
보존 7~30일 법정/정책상 1~5년, 삭제 불가 구간 존재 핫 30일 + 콜드 1~2년
조회 패턴 "이 시각 이 서버의 이 요청" — 텍스트 검색, 낮은 카디널리티 필터 "이 계정의 전 이력" — 키 기반 정확 조회 "전 유저 집계" — 대규모 스캔·집계
저장소 Loki/OpenSearch RDBMS 또는 append-only 원장 + 오브젝트 스토리지 ClickHouse + Parquet

"유실 불허" vs "0.1% 허용"이 만드는 구현 차이

감사 로그(무손실)

  • 게임 로직의 트랜잭션과 같은 커밋 단위에 들어가야 한다. 로그가 별도 경로로 나가면 "DB 는 커밋됐는데 로그는 없는" 상태가 생기고, 그 순간 회계 대사가 깨진다. → outbox 패턴(3번).
  • 동기 경로에 최소 한 번의 내구성 있는 쓰기(DB 또는 fsync 된 WAL)가 필요하다. 즉 지연 비용을 지불한다.
  • 프로듀서 설정: Kafka acks=all, min.insync.replicas=2, enable.idempotence=true, 무한 재시도. 브로커 장애 시 드롭이 아니라 블로킹/스풀.
  • 이벤트마다 고유 ID + 시퀀스를 넣어 소비자가 중복을 제거할 수 있게 한다.

텔레메트리(손실 허용)

  • 게임 로직과 완전히 분리된 fire-and-forget 경로. 링버퍼가 가득 차면 그냥 버리고 드롭 카운터만 메트릭으로 올린다(얼마나 버렸는지는 반드시 알아야 한다).
  • 프로듀서 설정: acks=1, 큰 배치(linger.ms=50~200), 강한 압축(zstd), 큐 가득 차면 block.on.buffer.full=false.
  • 샘플링을 정책으로 내장한다. 단, 샘플링은 엔티티 단위 해시 샘플링(같은 유저는 전부 포함/전부 제외)이어야 퍼널 분석이 깨지지 않는다. 랜덤 이벤트 단위 샘플링은 "구매 이벤트만 살아남고 그 앞의 노출 이벤트는 사라진" 분석 불가 데이터를 만든다.
  • 정확한 숫자가 필요한 지표(DAU, 매출)는 텔레메트리가 아니라 감사 로그/DB 를 소스 오브 트루스로 삼는다. 이 구분을 흐리면 "대시보드 매출과 회계 매출이 다르다"는 사고가 난다.

2. 수집 경로와 각 단계의 실패 모드

(1) 게임 서버 프로세스 내 비동기 버퍼

  • 역할: 호출 스레드는 이벤트를 구조화된 형태 그대로 큐에 넣고 즉시 반환. 직렬화·포맷팅·압축은 전송 스레드가 한다.
  • 구현: 고정 크기 MPSC 링버퍼. 사전 할당된 슬롯에 값 형식 이벤트를 복사(할당 0), 또는 ArrayPool 로 빌린 버퍼에 바이너리 직렬화.
  • 실패 모드: 링버퍼 포화(소비가 느림), 프로세스 크래시(버퍼 내용 전량 유실), 전송 스레드 데드락.
  • 대응: 감사 로그는 애초에 이 경로를 쓰지 않는다(3번). 텔레메트리는 드롭 + 드롭 카운터. 전송 스레드는 워치독으로 감시.

(2) 로컬 에이전트(Vector, Fluent Bit, 사이드카)

  • 역할: 프로세스 밖으로 버퍼링 책임을 옮긴다. 게임 서버가 재시작/배포돼도 에이전트는 살아 있어 인플라이트 데이터를 지킨다. 여기서 배치·압축·라우팅·필드 마스킹·재시도를 담당.
  • 실패 모드: 디스크 가득, 에이전트 크래시, Kafka 장애로 스풀 무한 증가.
  • 대응: 스풀에 크기 상한 + 오래된 것부터 폐기(텔레메트리) / 상한 도달 시 알람 후 블로킹(감사). 디스크 사용률은 반드시 메트릭·알람 대상. 지금 겪는 "디스크가 차서 서버가 죽는" 사고는 로그 디스크를 게임 서버 디스크와 물리적으로 분리하고 상한을 강제하는 것으로 먼저 막는다.

(3) 메시지 브로커(Kafka)

  • 역할: 생산자와 소비자의 속도·가용성 격리, 재처리 가능한 로그(replayable log), 다중 소비자 팬아웃.
  • 실패 모드: 브로커 다운, 파티션 리더 이동 중 쓰기 실패, 컨슈머 랙 폭증, 리텐션 초과로 미처리 데이터 삭제.
  • 대응: 토픽을 종류별로 분리(audit.*, telemetry.*, debug.*)하여 텔레메트리 폭주가 감사 로그를 굶기지 못하게 한다. 감사 토픽은 복제 3, min.insync.replicas=2, 리텐션 길게. 컨슈머 랙은 최우선 알람 지표.

(4) 저장소

  • 실패 모드: 적재 지연, 스키마 불일치로 인한 적재 실패, 파티션 폭증.
  • 대응: DLQ(dead letter queue) 로 파싱 실패 이벤트를 격리하고 원본을 보존. 조용히 버리면 데이터 신뢰가 무너진다.

배치 / 압축 / 백프레셔

  • 배치: 이벤트 하나당 왕복은 파산이다. linger.ms + batch.size 로 수십 ms 모아 보낸다. 텔레메트리는 배치를 크게(지연 200ms 허용), 감사는 작게(지연 20ms). 배치는 압축률과 처리량을 동시에 개선한다.
  • 압축: 브로커 앞단에서 zstd(높은 압축률/속도 균형) 또는 lz4(CPU 우선). 게임 이벤트는 필드가 반복되어 압축률이 매우 높다(보통 5~10배). 압축은 전송 스레드에서 하고 게임 스레드에서는 절대 하지 않는다.
  • 백프레셔: 하류가 느릴 때 그 압력을 어디까지 전파할지가 설계의 핵심이다. 게임 틱 루프까지 전파되면 안 된다. 압력은 에이전트/스풀에서 흡수하고, 흡수 한계를 넘으면 종류별 정책으로 갈린다.

버퍼 포화 시: 드롭 / 블로킹 / 디스크 스풀

선택 언제 이유
드롭(오래된 것부터 또는 새것부터) 행동 텔레메트리, 디버그 로그 게임 플레이가 최우선. 통계는 일부 없어도 된다. 드롭 수를 반드시 계측하고, 오래된 것부터 버리면(FIFO drop) 최신 상황 파악에 유리
블로킹 원칙적으로 게임 서버 안에서는 금지 로그가 게임을 멈추는 것은 본말전도. 예외: 재화 감사 로그를 DB 트랜잭션 안에서 쓰는 경우 — 이건 이미 게임 로직의 일부이므로 블로킹이 정당하다
디스크 스풀 감사 로그, 그리고 브로커 장애 시 전반 프로세스 밖(에이전트)에서. 상한과 폐기 정책 없이는 그 자체가 장애 원인이 된다

실전 조합: 텔레메트리 = 인프로세스 드롭, 감사 = DB outbox(블로킹, 단 트랜잭션의 일부), 에러 로그 = 에이전트 스풀 + 상한 초과 시 드롭.

3. 무손실 로그의 보장 — outbox 와 멱등성

어디에 먼저 쓰는가

재화 지급을 예로 들면, 잘못된 설계는 이렇다.

1. DB: 잔액 += 100 (커밋)
2. Kafka: "지급 100" 발행     <- 여기서 서버가 죽으면 로그 없음

반대로 해도 마찬가지(로그는 있는데 지급이 없음). 두 시스템에 걸친 원자성이 필요하고, 분산 트랜잭션(2PC)은 게임서버에서 비용·가용성 모두 감당하기 어렵다.

트랜잭션 아웃박스(outbox) 패턴

같은 DB 트랜잭션 안에서 잔액 변경과 이벤트 기록을 함께 커밋한다.

BEGIN;
  UPDATE wallet SET balance = balance + 100 WHERE account_id = 42;
  INSERT INTO outbox(event_id, aggregate_id, seq, type, payload, created_at)
       VALUES (@uuid, 42, @seq, 'currency.grant', @json, now());
COMMIT;

그 다음 별도 릴레이(폴링 또는 CDC — Debezium 등 WAL 테일링)가 outbox 를 읽어 Kafka 로 보내고 발행 완료를 표시한다.

  • 원자성 확보: 하나의 로컬 트랜잭션이므로 "지급됐는데 로그 없음"이 원천 봉쇄된다.
  • at-least-once: 릴레이가 발행 후 마킹 전에 죽으면 같은 이벤트를 다시 보낸다. 중복은 허용하고 소비자에서 제거하는 것이 정석.
  • exactly-once 의 현실: Kafka 의 exactly-once(트랜잭셔널 프로듀서 + read_committed)는 Kafka 내부(소비→처리→생산) 에서만 성립한다. 외부 DB·게임 서버까지 포함한 진짜 E2E exactly-once 는 존재하지 않는다. 따라서 목표는 at-least-once + 멱등 소비 = 효과적 exactly-once.
  • 멱등성 설계: 이벤트마다 event_id(UUID) 를 부여하고 소비자 측 저장소에 UNIQUE(event_id) 를 걸어 중복 삽입을 무시한다. 또는 (aggregate_id, seq) 단조 증가 시퀀스를 두어 이미 처리한 seq 이하는 무시한다. seq 방식은 순서 검증과 유실 탐지까지 공짜로 얻는다 — 42번 계정의 seq 가 7 다음 9 면 8이 유실된 것이므로 즉시 알람.
  • 순서 보장: aggregate_id(계정 ID)를 파티션 키로 쓰면 같은 계정의 이벤트는 같은 파티션 = 순서 보장. 계정 간 순서는 보장할 필요가 없다.

지연에 미치는 영향

outbox INSERT 는 같은 트랜잭션 안의 추가 쓰기 한 번이다. 재화 변경은 이미 DB 를 쓰는 연산이므로 증분 비용은 작다(수백 μs). 대신 지켜야 할 것:

  • outbox 테이블은 append-only + 좁은 인덱스(발행 안 된 행만 부분 인덱스)로 유지하고, 발행 완료 행은 주기적으로 파티션 드롭으로 삭제한다(DELETE 로 지우면 곧 테이블이 팽창한다).
  • 이 경로는 재화·아이템처럼 정말 무손실이어야 하는 이벤트에만 쓴다. 전투 로그까지 outbox 로 보내면 DB 가 죽는다.
  • 게임 로직이 DB 쓰기를 비동기로 미루는 구조(캐시 후 배치 영속화)라면, 재화 변경만은 동기 커밋으로 예외 처리하는 것이 일반적인 타협이다.

4. 스키마와 파티셔닝

스키마 정의와 진화

  • 구조화 이벤트를 강제한다. "플레이어 42가 아이템 7을 3개 획득" 같은 문자열이 아니라 {event:"item.gain", account_id:42, item_id:7, count:3, ...}. 텍스트 로그는 파싱 비용·정규식 지옥·분석 불가를 부른다.
  • 스키마 레지스트리(Confluent Schema Registry 등) + Avro/Protobuf. 메시지에는 스키마 ID 만 실려 페이로드가 작아지고, 생산자/소비자가 독립 배포될 수 있다.
  • 호환 규칙:
    • 하위 호환(backward): 새 스키마로 옛 데이터를 읽을 수 있다 → 필드 삭제와 기본값 있는 필드 추가가 안전. 소비자를 먼저 배포.
    • 상위 호환(forward): 옛 스키마로 새 데이터를 읽을 수 있다 → 기본값 있는 필드 추가가 안전. 생산자를 먼저 배포.
    • 완전 호환(full): 둘 다. 실무 권장 기본값.
    • 금지: 필드 타입 변경, 필드 의미 변경, 필드 재사용(같은 이름/태그 번호를 다른 뜻으로). Protobuf 면 삭제한 태그 번호를 reserved 로 막는다.
  • 버전 정책: 500대 서버는 절대 동시에 배포되지 않는다. 배포 중에는 구·신 스키마가 동시에 흐른다는 전제로 설계한다. 파괴적 변경이 필요하면 새 이벤트 타입/토픽을 만들고 이중 기록 후 전환한다.
  • CI 에서 스키마 호환성 검사를 게이트로 걸어 배포 전에 막는다.

파티셔닝 키

키 장점 단점
플레이어/계정 ID 같은 유저 이벤트가 한 파티션 = 순서 보장, 유저 단위 집계·중복 제거·세션화가 쉬움. 감사 로그의 필수 선택 핫 파티션 위험: 특정 유저·봇·GM 계정이 이벤트를 폭발적으로 만들면 한 파티션만 포화. 키 분포가 균등하다는 보장이 없음
시간(타임스탬프) 시계열 저장소와 궁합 좋음 최악의 핫스팟 — 현재 시각 파티션 하나에 전 트래픽이 몰린다. 사실상 금지
랜덤/라운드로빈 완벽한 부하 분산, 최대 처리량 순서 보장 없음, 유저 단위 처리 시 소비자가 셔플해야 함
복합 키(account_id % N 또는 hash(account_id, bucket)) 순서 보장 범위를 좁히면서 핫스팟 완화 순서 보장이 버킷 단위로 약해짐

실전 선택:

  • 감사 로그 = account_id(순서와 중복 제거가 정확성의 전제).
  • 행동 텔레메트리 = account_id 해시(유저 단위 퍼널 분석을 위해) 또는 순수 순서가 필요 없다면 라운드로빈. 볼륨이 커서 핫 파티션이 실제 문제가 되므로 파티션 수를 넉넉히(수백) 잡는다.
  • 디버그 로그 = 서버 인스턴스 ID(한 인스턴스 로그의 순서만 맞으면 된다).

핫 파티션 대응: 파티션별 처리량 메트릭 상시 감시, 상위 키에 대해 salted key(account_id:shard 로 N 분할) 적용, 어뷰징성 폭주 계정은 생산자 단에서 레이트 리밋. 파티션 수는 늘리기만 가능하고 늘리면 기존 키의 매핑이 바뀌어 순서 보장이 깨지므로 처음부터 넉넉히 잡는다.

5. 저장 계층과 조회

핫 계층 — OLAP(ClickHouse)

  • 최근 7~30일. 대시보드, 실시간 알림, 어뷰징 탐지, LiveOps 즉시 조회.
  • 컬럼 지향이라 SELECT sum(gold) ... WHERE event='item.gain' AND date >= ... 같은 소수 컬럼 대량 스캔에서 행 지향 대비 10~100배 빠르다. 필요한 컬럼만 읽고, 같은 타입 값이 연속해 있어 압축률이 높다(게임 이벤트는 흔히 10:1 이상).
  • 테이블 설계: PARTITION BY toYYYYMMDD(ts) + ORDER BY (event_type, account_id, ts). ORDER BY 가 곧 스파스 인덱스이므로 주 조회 패턴에 맞춰 정한다. 보존은 TTL ts + INTERVAL 30 DAY 로 자동 삭제.
  • 파티션 단위 DROP 은 O(1) 이므로 삭제를 DELETE 가 아니라 파티션 드롭으로 설계하는 것이 핵심.

콜드 계층 — 오브젝트 스토리지 + Parquet

  • 1~2년치. 배치 분석, 머신러닝 학습, 감사 재구성.
  • S3/GCS + Parquet + Hive 스타일 파티셔닝: s3://logs/event=item.gain/dt=2026-07-19/hour=03/part-0001.parquet
  • Parquet 이 적합한 이유: 컬럼 지향 + 열별 압축(딕셔너리, RLE) + 푸시다운을 위한 row group 통계(min/max) → 쿼리 엔진이 필요 없는 파일·row group 을 아예 안 읽는다. 파티션 프루닝과 결합하면 1년치에서 하루치만 스캔한다.
  • 작은 파일 문제: 스트리밍으로 쓰면 수많은 작은 파일이 생겨 메타데이터가 폭발하고 스캔이 느려진다. compaction 배치(시간별로 128MB~512MB 단위 병합)를 반드시 운영한다. Iceberg/Delta 같은 테이블 포맷을 쓰면 compaction·스키마 진화·시간여행을 관리해준다.
  • 스토리지 클래스 라이프사이클: 30일 후 저빈도 접근, 180일 후 아카이브로 자동 전환.

메트릭 / 로그 / 트레이스 역할 분담

메트릭 로그 트레이스
형태 사전 집계된 시계열 수치 개별 이벤트 레코드 요청 하나의 인과 경로(span 트리)
비용 매우 저렴(고정) 볼륨 비례 볼륨 비례(보통 샘플링)
답하는 질문 "무엇이 이상한가" (틱 시간 p99, CCU, GC 시간) "정확히 무슨 일이 있었나" "어디서 시간을 썼나"
게임서버 예 틱 시간, 패킷 처리량, 큐 길이 재화 지급 내역, 에러 스택 매칭 요청이 게이트웨이→매치서버→DB 를 거친 경로

운영 흐름은 메트릭 → 트레이스 → 로그 순이다. 메트릭 알람으로 이상을 감지하고, 트레이스로 병목 구간을 좁히고, 그 구간의 로그로 원인을 확정한다. 이 순서를 지키면 비싼 로그 검색을 최소화할 수 있다.

카디널리티 폭발

메트릭 시스템(Prometheus 계열)은 레이블 조합마다 별도 시계열을 만든다. player_id 를 레이블에 넣으면 20만 개 시계열이 지표마다 생겨 메모리와 저장소가 폭발하고 쿼리가 죽는다.

  • 원칙: 카디널리티가 높은 값(플레이어 ID, 세션 ID, 아이템 인스턴스 ID, 요청 ID, URL 원문)은 메트릭 레이블에 절대 넣지 않는다. 메트릭 레이블은 서버 인스턴스, 존 타입, 이벤트 타입, 지역처럼 유한하고 작은 집합만.
  • 개별 엔티티 단위 분석이 필요하면 그건 로그/OLAP 의 일이다. ClickHouse 는 고카디널리티 컬럼을 잘 다룬다(그게 컬럼 스토어의 강점).
  • 트레이스는 샘플링으로 볼륨을 통제하되, 에러/느린 요청은 항상 샘플링(tail-based sampling) 하여 문제 케이스를 놓치지 않는다.
  • 로그에서도 카디널리티 폭발은 있다 — 동적 값을 메시지 문자열에 박으면 인덱스가 폭발한다. 메시지 템플릿은 고정하고 동적 값은 구조화 필드로("item gained" + {item_id: 7}).

6. 운영 제약 — PII, 보존, 틱 루프 보호

개인정보와 보존 정책

  • 수집 단계에서 최소화: 필요 없는 PII 는 애초에 이벤트에 넣지 않는다. IP, 이메일, 결제 정보, 기기 식별자는 기본 배제하고 필요한 경우만 승인된 필드로.
  • 가명화(pseudonymization): 파이프라인 진입 시점(에이전트 단)에 계정 ID 를 키드 해시로 치환하고, 원본 ↔ 가명 매핑은 별도의 접근 통제된 저장소에만 둔다. 분석가는 가명 ID 로 모든 분석이 가능하고, 실제 신원 조회는 감사 로그가 남는 경로로만.
  • 삭제 요청(GDPR right to erasure) 대응:
    • 로그를 파일에서 개별 행 단위로 지우는 것은 사실상 불가능하다. → crypto-shredding: 유저별(또는 유저 그룹별) 데이터 암호화 키를 두고, 삭제 요청이 오면 키를 파기한다. 데이터는 남아도 복호화 불가 = 실질 삭제.
    • 또는 Iceberg/Delta 같은 테이블 포맷의 row-level delete + compaction 으로 처리(비용은 크다).
    • 예외 처리: 재화·결제 감사 로그는 회계·세무·부정거래 조사 목적의 법적 보존 의무가 있어 삭제 대상에서 제외될 수 있다. 법무와 함께 필드 단위 보존 정책 매트릭스를 만들어 두는 것이 실무의 핵심. "다 지운다"도 "안 지운다"도 정답이 아니다.
    • 삭제 요청은 핫·콜드·백업·다운스트림 사본 전부에 전파돼야 한다. 데이터 계보(lineage)를 관리하지 않으면 불가능하므로, 파생 테이블 목록을 카탈로그로 유지한다.
  • 접근 통제·감사: 어떤 분석가가 어떤 PII 를 조회했는지 자체가 감사 대상이다. 컬럼 단위 권한(핫 저장소)과 버킷 정책(콜드)을 분리 운영.

틱 루프를 갉아먹지 않는 코드 규칙

  1. 호출 지점에서 I/O 금지, 락 금지. 로그 호출은 링버퍼에 값 복사 + 인덱스 증가(CAS)까지. 파일 쓰기·네트워크·직렬화는 전부 전송 스레드.
  2. 문자열 포맷팅을 하지 마라. Log.Info($"player {id} gained {n}") 는 로그 레벨이 꺼져 있어도 보간 문자열과 박싱이 먼저 실행된다. 구조화 로깅의 템플릿 API(Log.Info("player {Id} gained {N}", id, n))나 소스 제너레이터 기반 로깅([LoggerMessage])을 써서 비활성 시 비용 0을 만든다.
  3. 레벨 체크를 먼저: if (Log.IsEnabled(Debug)) 가드로 인자 계산 자체를 건너뛴다. 특히 인자에 ToString(), LINQ, 컬렉션 순회가 들어가는 경우.
  4. 할당 제로 지향: 이벤트를 struct 로 정의해 사전 할당된 링버퍼 슬롯에 복사. 매 틱 수만 개의 로그 객체를 할당하면 그것만으로 Gen0 GC 가 틱을 튀게 한다. 버퍼가 필요하면 ArrayPool.
  5. 샘플링과 동적 레벨 조정: 반복되는 로그는 레이트 리밋(초당 N개, 이후 "이하 M건 생략"). 로그 레벨은 재시작 없이 런타임에 변경 가능해야 하고, 이상적으로는 특정 계정·존만 상세 로그를 켤 수 있어야 한다(장애 조사 시 전체를 Debug 로 올리면 그 자체가 2차 장애).
  6. 로그 디스크 분리와 상한: 로그 볼륨을 게임 서버 디스크와 분리하고 쿼터를 강제한다. 지금 발생 중인 "디스크가 차서 서버가 죽는" 사고는 이 한 가지로 즉시 막힌다.
  7. 틱 예산 계측: 틱 루프 안에서 로깅에 쓴 시간을 별도 메트릭으로 뽑는다. "로깅이 틱의 몇 %를 먹는가"를 측정하지 않으면 회귀를 못 잡는다. 상한(예: 틱의 1%)을 정하고 초과 시 알람.
  8. 장애 시 로그 폭증 대비: 에러가 나면 에러 로그가 폭증하고 그게 다시 서버를 죽이는 양성 피드백이 흔하다. 에러 로그에도 반드시 레이트 리밋을 걸고, 동일 스택은 집계해서 한 줄 + 카운트로 보낸다.

마이그레이션 순서(현실적 로드맵)

  1. 응급 처치: 로그 디스크 분리 + 로테이션·쿼터 강제, 동기 파일 쓰기를 비동기 큐로 교체. (사고 재발 차단)
  2. 구조화 로깅 도입: 텍스트 → 이벤트 스키마. 이후 모든 작업의 전제.
  3. 에이전트 + Kafka 도입: 텔레메트리부터 옮긴다(유실 허용이라 리스크 낮음).
  4. 감사 로그를 outbox 로 전환: 정확성이 걸린 부분이라 가장 신중히, 이중 기록 후 대사(reconciliation) 검증을 거쳐 전환.
  5. 저장 계층 정리: ClickHouse(핫) + Parquet(콜드), 보존·삭제 정책 자동화.
  6. PII 정책 적용: 가명화, crypto-shredding, 접근 통제.

한 줄 요약: 로그는 한 종류가 아니다. 유실 허용 텔레메트리는 게임 스레드와 완전히 분리해 드롭 가능한 파이프로 흘리고, 무손실 감사 로그는 게임 로직의 트랜잭션 안(outbox)으로 끌어들인다. 이 둘을 같은 파이프에 태우는 순간 비용도 정확성도 둘 다 잃는다.