22. 게임 로그·텔레메트리 파이프라인 설계
난이도 중문제 22 해설 — 게임 로그·텔레메트리 파이프라인 설계
난이도: 중상
0. 큰 그림
[게임 서버 프로세스]
로그 호출 (논블로킹, 구조화 이벤트)
└─> 링버퍼(MPSC) ──> 전송 스레드 ──┐
│ (로컬 UDS/TCP, 배치+압축)
[로컬 에이전트: Vector/Fluent Bit] <────┘
└─ 디스크 스풀(감사 로그) / 드롭 정책(텔레메트리)
└─> Kafka ──┬──> 실시간 소비자(알림, 어뷰징 탐지, 대시보드)
├──> ClickHouse (핫: 최근 7~30일)
└──> S3/Parquet (콜드: 1~2년, 배치 분석)
[감사 로그만] 게임 로직 트랜잭션 == DB outbox 테이블 → CDC → Kafka
핵심 원칙 세 가지:
- 게임 스레드는 절대 I/O 를 하지 않는다. 로그 호출은 메모리 쓰기까지만.
- 종류마다 파이프라인의 보장 수준을 다르게 한다. 전부 무손실로 만들면 비용도 지연도 감당 못 하고, 전부 손실 허용으로 만들면 감사가 무너진다.
- 게임 서버는 상태를 오래 들고 있지 않는다. 버퍼링·재시도·스풀은 에이전트와 브로커의 일이다.
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 를 조회했는지 자체가 감사 대상이다. 컬럼 단위 권한(핫 저장소)과 버킷 정책(콜드)을 분리 운영.
틱 루프를 갉아먹지 않는 코드 규칙
- 호출 지점에서 I/O 금지, 락 금지. 로그 호출은 링버퍼에 값 복사 + 인덱스 증가(CAS)까지. 파일 쓰기·네트워크·직렬화는 전부 전송 스레드.
- 문자열 포맷팅을 하지 마라.
Log.Info($"player {id} gained {n}")는 로그 레벨이 꺼져 있어도 보간 문자열과 박싱이 먼저 실행된다. 구조화 로깅의 템플릿 API(Log.Info("player {Id} gained {N}", id, n))나 소스 제너레이터 기반 로깅([LoggerMessage])을 써서 비활성 시 비용 0을 만든다. - 레벨 체크를 먼저:
if (Log.IsEnabled(Debug))가드로 인자 계산 자체를 건너뛴다. 특히 인자에ToString(), LINQ, 컬렉션 순회가 들어가는 경우. - 할당 제로 지향: 이벤트를
struct로 정의해 사전 할당된 링버퍼 슬롯에 복사. 매 틱 수만 개의 로그 객체를 할당하면 그것만으로 Gen0 GC 가 틱을 튀게 한다. 버퍼가 필요하면ArrayPool. - 샘플링과 동적 레벨 조정: 반복되는 로그는 레이트 리밋(초당 N개, 이후 "이하 M건 생략"). 로그 레벨은 재시작 없이 런타임에 변경 가능해야 하고, 이상적으로는 특정 계정·존만 상세 로그를 켤 수 있어야 한다(장애 조사 시 전체를 Debug 로 올리면 그 자체가 2차 장애).
- 로그 디스크 분리와 상한: 로그 볼륨을 게임 서버 디스크와 분리하고 쿼터를 강제한다. 지금 발생 중인 "디스크가 차서 서버가 죽는" 사고는 이 한 가지로 즉시 막힌다.
- 틱 예산 계측: 틱 루프 안에서 로깅에 쓴 시간을 별도 메트릭으로 뽑는다. "로깅이 틱의 몇 %를 먹는가"를 측정하지 않으면 회귀를 못 잡는다. 상한(예: 틱의 1%)을 정하고 초과 시 알람.
- 장애 시 로그 폭증 대비: 에러가 나면 에러 로그가 폭증하고 그게 다시 서버를 죽이는 양성 피드백이 흔하다. 에러 로그에도 반드시 레이트 리밋을 걸고, 동일 스택은 집계해서 한 줄 + 카운트로 보낸다.
마이그레이션 순서(현실적 로드맵)
- 응급 처치: 로그 디스크 분리 + 로테이션·쿼터 강제, 동기 파일 쓰기를 비동기 큐로 교체. (사고 재발 차단)
- 구조화 로깅 도입: 텍스트 → 이벤트 스키마. 이후 모든 작업의 전제.
- 에이전트 + Kafka 도입: 텔레메트리부터 옮긴다(유실 허용이라 리스크 낮음).
- 감사 로그를 outbox 로 전환: 정확성이 걸린 부분이라 가장 신중히, 이중 기록 후 대사(reconciliation) 검증을 거쳐 전환.
- 저장 계층 정리: ClickHouse(핫) + Parquet(콜드), 보존·삭제 정책 자동화.
- PII 정책 적용: 가명화, crypto-shredding, 접근 통제.
한 줄 요약: 로그는 한 종류가 아니다. 유실 허용 텔레메트리는 게임 스레드와 완전히 분리해 드롭 가능한 파이프로 흘리고, 무손실 감사 로그는 게임 로직의 트랜잭션 안(outbox)으로 끌어들인다. 이 둘을 같은 파이프에 태우는 순간 비용도 정확성도 둘 다 잃는다.