본문으로 건너뛰기
목록으로 돌아가기
스터디
22

게임 로그를 뜨겁고 차갑게 나누기: Kafka·조회 인덱스·오브젝트 스토리지 이중 경로

고객 지원에는 수초 내 사용자 타임라인이, 분석에는 저렴하고 완전한 장기 보관이 필요합니다. 하나의 이벤트 스트림을 hot 조회 경로와 cold 원본 경로로 나누고 중복·지연·재처리·개인정보까지 설계합니다.

박효열 (Hyoyoul Park)
#Data Engineering#Kafka#Game Backend#Object Storage#Observability

이 글은 개인 학습 아카이브에 남아 있던 오래된 게임 로그 설계를 출발점으로 삼아 현재의 데이터 엔지니어링 관점에서 독립적으로 다시 구성한 학습 노트입니다. 특정 회사의 수치·화면·내부 경로는 사용하지 않았고, 당시에는 자연스러웠지만 지금은 위험한 가정은 본문에서 명시적으로 바로잡습니다.

먼저 결론

게임 로그 시스템은 저장소 하나를 고르는 문제가 아니다. 같은 이벤트를 서로 다른 시간 감각으로 쓰는 소비자를 분리하는 문제다.

  • 고객 지원은 특정 사용자의 최근 행동을 수초 안에 찾아야 한다.
  • 운영자는 장애 시점의 요청·서버·빌드 흐름을 분 단위로 추적해야 한다.
  • 분석과 정산은 늦게 도착한 이벤트까지 포함한 완전한 장기 이력이 필요하다.

세 요구를 하나의 데이터베이스로 만족시키면 빠른 조회를 위해 비싼 인덱스를 끝없이 유지하거나, 저렴한 파일 보관을 택한 뒤 지원 조회가 느려지는 쪽으로 기울기 쉽다. 더 안정적인 기본형은 Kafka 같은 내구성 있는 이벤트 로그를 분기점으로 삼고, 짧게 유지하는 hot 조회 경로와 오래 유지하는 cold 원본 경로를 따로 만드는 것이다.

게임 서버
   │ 검증된 이벤트
   ▼
수집기 ──> Kafka
             ├── hot consumer ──> 사용자·시간 인덱스 ──> 지원/운영 API
             ├── cold writer  ──> 오브젝트 스토리지 ──> SQL·배치·ML
             └── 품질 검사    ──> 격리 큐·스키마 지표

제어면: 스키마 레지스트리 · 접근 정책 · 보존 정책 · 오프셋/매니페스트

Kafka는 이 그림의 장기 백업 그 자체도, 검색 엔진도 아니다. hot 저장소도 최종 원본이 아니다. 각 구성요소의 책임을 좁게 잡아야 장애와 비용을 함께 통제할 수 있다.

하나의 로그를 쓰는 세 개의 시계

먼저 소비자별 질문과 허용 지연을 적는다. 제품 이름보다 이 표가 아키텍처를 더 많이 결정한다.

소비자대표 질문목표 지연필요한 보존·정확성
고객 지원“이 사용자는 결제 직전에 무엇을 했나?”1~5초최근 수일, 사용자별 순서와 원문 링크
실시간 운영“어느 빌드·지역에서 오류가 증가했나?”수초~수분집계 지표와 대표 표본, 빠른 상관관계
분석·정산“지난달 재화 흐름과 전환은 어땠나?”수십 분~수시간장기 원본, 지연 이벤트·중복 보정, 재현 가능성
감사·분쟁 대응“당시 시스템이 어떤 결정을 기록했나?”분~시간승인된 이벤트의 무결성, 엄격한 접근·보존

여기서 중요한 구분은 발생 시간(event time)도착 시간(ingestion time) 이다. 모바일 클라이언트가 오프라인이었다가 뒤늦게 전송하면 두 시간은 달라진다. 분석은 발생 시간을 중심으로 다시 계산해야 하지만, 운영은 “언제 파이프라인에 들어왔는가”도 알아야 지연을 진단할 수 있다.

인프라보다 먼저 이벤트 계약을 만든다

임의의 JSON을 먼저 쌓고 나중에 의미를 정하면 로그는 데이터가 아니라 문자열 무덤이 된다. 모든 이벤트가 최소한 다음 계약을 공유하도록 한다.

{
  "event_id": "deterministic-or-random-unique-id",
  "event_type": "inventory.item_granted",
  "schema_version": 3,
  "occurred_at": "2026-07-22T10:15:30.123Z",
  "ingested_at": "2026-07-22T10:15:31.004Z",
  "subject_id": "tokenized-player-id",
  "session_id": "opaque-session-id",
  "trace_id": "cross-service-correlation-id",
  "producer": "inventory-service",
  "build": "server-build-id",
  "payload": {
    "item_type": "ticket",
    "quantity": 1,
    "reason_code": "quest_reward"
  }
}

필드마다 소유자, 타입, null 허용 여부, 단위, 개인정보 등급, 호환성 정책을 둔다. 특히 다음 불변조건이 중요하다.

  1. event_id는 재시도해도 변하지 않아야 한다. 그래야 sink가 중복을 제거할 수 있다.
  2. event_typeschema_version은 함께 해석된다. 필드 이름을 몰래 재사용하지 않는다.
  3. 발생 시간과 수집 시간을 둘 다 UTC로 기록하고 정밀도를 고정한다.
  4. 사용자 식별자는 원본 계정값 대신 목적별 토큰을 사용한다. 토큰화는 익명화가 아니므로 여전히 보호 대상이다.
  5. 금액·수량·통화·재화 종류는 “숫자 하나”로 뭉개지 말고 단위와 의미를 분리한다.
  6. 비밀번호, 인증 토큰, 전체 채팅 원문, 결제수단 원문처럼 필요하지 않은 민감값은 애초에 이벤트에 넣지 않는다.

스키마 변경은 producer만의 일이 아니다. hot index, cold writer, 지원 UI, 분석 테이블이 모두 같은 계약의 소비자다. 새 필드는 먼저 선택적으로 읽게 배포하고, 그다음 쓰기를 시작하며, 마지막에 필수값으로 승격하는 순서가 안전하다.

파티션 키는 순서와 확장성의 계약이다

Apache Kafka 설계 문서가 설명하듯 producer는 key를 기준으로 partition을 선택할 수 있고, 같은 사용자 ID를 key로 삼으면 그 사용자의 이벤트가 같은 partition으로 간다. 이 선택은 사용자별 순서를 복원하기 쉽게 하지만 공짜가 아니다.

키 전략장점위험
subject_id사용자 타임라인의 partition 내 순서 유지초대형 계정·봇·공용 계정이 hot partition을 만들 수 있음
event_type타입별 소비와 압축이 단순한 사용자 흐름이 흩어져 별도 정렬 필요
무작위쓰기 부하가 비교적 균등의미 있는 지역성과 순서 상실
hash(subject_id) % bucket을 포함한 복합 키부하 제어와 지역성의 절충bucket 변경·재파티셔닝이 어려움

“전체 이벤트의 전역 순서”는 대개 비싸고 필요하지 않다. 실제 불변조건이 “한 사용자·한 세션·한 주문 안에서의 순서”인지 먼저 확인한다. 동시에 Kafka partition 순서는 broker에 기록된 순서이지 여러 기기의 실제 발생 순서를 자동으로 복원해 주는 시계가 아니다. 사건의 인과관계가 중요하다면 sequence number나 도메인 version을 이벤트에 별도로 둔다.

exactly-once라는 말을 경계한다

로그 파이프라인의 기본 현실은 재시도와 중복이다. 수집기는 응답을 받기 전에 끊길 수 있고, consumer는 sink 쓰기 후 offset 저장 전에 죽을 수 있다. 따라서 기본 설계는 at-least-once 전송과 idempotent 처리를 전제로 하는 편이 안전하다.

Kafka의 전달 의미 문서는 idempotent producer가 재전송으로 생기는 broker 로그의 중복을 막고, transaction이 여러 Kafka partition과 consumer offset을 원자적으로 묶을 수 있다고 설명한다. 그러나 같은 문서도 외부 저장소에 쓸 때는 그 저장소의 협력이 필요하다고 명시한다. 즉 Kafka 내부의 exactly-once 처리와 오브젝트 스토리지·검색 인덱스·결제 DB까지 포함한 종단 간 exactly-once는 같은 말이 아니다.

실무적으로는 다음 조합이 이해하기 쉽다.

  • producer 재시도에는 안정적인 event_id를 유지한다.
  • sink는 event_id unique key, upsert, 이미 처리한 ID 집합 등으로 멱등성을 만든다.
  • 가능하면 sink 결과와 source offset을 같은 transaction 또는 같은 매니페스트에 기록한다.
  • 그것이 불가능하면 “데이터는 썼지만 offset은 못 쓴” 구간이 다시 들어와도 결과가 같게 만든다.
  • 외부 알림·아이템 지급처럼 되돌릴 수 없는 side effect는 outbox/inbox와 별도 idempotency key를 사용한다.

중복을 0으로 약속하기보다 중복률을 측정하고 재처리해도 결과가 변하지 않는지 검증하는 것이 더 강한 운영 계약이다.

hot path: 최근 이력을 빠르게, 그러나 작게

hot path의 목적은 “모든 로그를 영원히 검색”하는 것이 아니다. 고객 지원과 운영에 필요한 최근 창을 빠르게 제공하는 것이다.

대표 조회 키는 subject_id + occurred_at이다. 지원 API는 다음 기능 정도로 책임을 제한할 수 있다.

  • 최근 24~72시간의 사용자 이벤트 시간순 조회
  • event type, session, trace ID 필터
  • 원본 cold object와 offset을 가리키는 provenance
  • 민감 payload의 역할 기반 마스킹
  • 조회 자체에 대한 감사 로그

embedded key-value store나 로컬 인덱스는 빠르고 저렴할 수 있지만 “프로세스 안에 DB를 열었다”는 것만으로 고가용 서비스가 되지는 않는다. 어느 인스턴스에 어떤 사용자가 있는지 routing해야 하고, 인스턴스 장애 시 replica 또는 Kafka replay로 복구해야 하며, rebalance 동안 중복 인스턴스가 같은 shard를 서비스하지 않도록 fencing해야 한다. index 포맷이 바뀔 때의 rebuild 시간과 디스크 여유도 SLO에 들어간다.

그래서 hot 저장소에는 명확한 TTL과 복구 원칙을 둔다.

hot index는 버려도 다시 만들 수 있는 파생물이고, cold archive와 검증된 offset/manifest가 복구 기준이다.

이 원칙이 있으면 hot DB 백업을 장기 원본처럼 떠안지 않아도 된다. 반대로 cold path가 불완전하다면 hot index를 “재생성 가능”하다고 부를 수 없다.

cold path: 파일을 쌓는 일이 아니라 테이블을 운영하는 일

오브젝트 스토리지는 저렴하고 내구성 있는 장기 보관에 적합하지만, 매 이벤트를 작은 JSON 파일 하나로 쓰면 object 수와 요청 비용, listing, query planning이 병목이 된다. consumer는 이벤트를 메모리나 임시 파일에 모아 적당한 크기의 immutable object로 닫고 매니페스트를 남겨야 한다.

logs/
  event_date=2026-07-22/
    event_hour=10/
      service=inventory/
        schema_version=3/
          part-....parquet

partition은 날짜·시간·서비스처럼 cardinality가 제한되고 실제 query filter에 자주 쓰이는 필드로 잡는다. 사용자 ID처럼 값이 수백만 개인 필드를 directory partition으로 쓰면 작은 파일과 metadata가 폭발한다. 사용자별 조회는 hot index나 Parquet 내부 정렬·통계에 맡기는 편이 낫다.

Apache Parquet 형식은 row group 안에서 column별 chunk를 분리하고 reader가 필요한 column 위치를 metadata에서 찾도록 한다. page index를 지원하는 reader는 통계로 불필요한 page를 건너뛸 수도 있다. 그래서 “모든 필드를 가진 긴 JSON”보다 선택한 열만 읽는 분석에 유리하지만, 파일 크기·정렬·reader 구현이 잘못되면 자동으로 빨라지는 것은 아니다.

파일 운영의 핵심은 다음 네 가지다.

  1. small-file compaction: 스트리밍 writer가 만든 작은 파일을 더 큰 파일로 합친다.
  2. 완료 매니페스트: 파일 경로, record 수, byte 수, 최소·최대 event time, Kafka partition별 offset 범위, checksum을 기록한다.
  3. late-data 처리: watermark 이후 도착한 이벤트를 별도 파일로 쓰고 후속 compaction이나 table snapshot에 포함한다.
  4. 원자적 공개: data object 여러 개를 먼저 쓰고, 모두 검증된 뒤 manifest/table commit을 노출한다.

Amazon S3를 예로 들면 공식 consistency 문서는 성공한 PUT 뒤 GET과 LIST에 strong consistency를 제공한다고 설명한다. 그러나 key 하나의 atomic update가 여러 key의 atomic transaction으로 확장되는 것은 아니다. 그래서 여러 object로 이루어진 batch에는 완료 marker나 table metadata commit이 여전히 필요하다.

schema·partition 변경, snapshot, delete가 잦아지면 raw directory 규칙만으로는 복잡해진다. 이때 Apache Iceberg 같은 table format이 schema evolution, hidden partitioning, snapshot 기반 처리를 제공할 수 있다. 다만 작은 팀의 단순 append-only 로그까지 무조건 table format으로 시작할 필요는 없다. 동시 writer, 재현 가능한 snapshot, 행 단위 수정·삭제, partition evolution이 실제 요구가 되는 시점이 도입 기준이다.

재처리와 정합성은 기능이다

Kafka consumer가 뒤처지면 catch-up할 수 있지만, Kafka retention보다 오래된 구간은 broker에서 다시 읽을 수 없다. Kafka topic 설정retention.msretention.bytes는 보관 창을 구성하는 값이지 영구 아카이브 보증이 아니다. cold archive의 completeness를 별도로 검사해야 한다.

partition별로 다음 상태를 비교하면 누락과 중복을 찾기 쉽다.

Kafka committed offsets
        vs
cold manifest [first_offset, last_offset, record_count, checksum]
        vs
hot index checkpoint

연속 offset이라고 해서 business event가 모두 유효하다는 뜻은 아니다. schema reject, producer-side drop, 개인정보 필터, quarantine도 별도 수치로 남긴다. 매 시간 작은 범위를 대조하고, 매일 object count·record count·checksum을 집계하며, 정기적으로 새 환경에 replay해 복구 시간을 재는 방식이 좋다.

backfill은 live consumer와 같은 sink에 무작정 쓰지 않는다. run_id, 입력 snapshot, 코드 버전, schema 버전, 대상 namespace를 기록하고 dry run 결과를 비교한다. 알림 전송이나 게임 재화 지급처럼 side effect를 내는 consumer는 replay mode에서 반드시 비활성화하거나 idempotency store로 차단한다.

실패를 구성요소가 아니라 데이터 결과로 본다

실패눈에 보이는 증상필요한 설계
수집기 timeout 후 재시도동일 이벤트 두 번안정적 event ID, 멱등 sink, duplicate 지표
hot consumer 중단지원 화면이 오래됨lag/freshness SLO, replica, offset 기반 rebuild
cold writer가 파일 쓰고 죽음data object는 있으나 manifest 없음임시 prefix, commit marker, orphan 청소
schema 비호환 배포일부 이벤트 격리호환성 검사, producer canary, quarantine replay
특정 key에 트래픽 집중한 partition만 lag 증가key 분포 지표, bucket 전략, rate limit
늦은 모바일 이벤트과거 날짜 partition이 뒤늦게 변함event/ingestion time 분리, watermark, late-data merge
잘못된 backfill지표 급변·중복 side effect격리 namespace, run manifest, 승인·rollback
권한 과다원문 개인정보 대량 조회최소 권한, masking, 조회 감사, 목적별 dataset

장애를 “Kafka가 죽었다”처럼 부품 이름으로만 기록하면 실제 손실을 판단하기 어렵다. “10:00~10:07의 이벤트 0.4%가 hot path에서 늦었지만 cold archive에는 모두 존재한다”처럼 freshness, completeness, correctness로 나눠 말해야 한다.

개인정보와 보존은 나중에 붙일 수 없다

append-only 로그는 디버깅에는 편하지만 개인정보 삭제와 목적 제한에는 불편하다. 이벤트를 만들 때부터 다음 경계를 둔다.

  • 계정 원본 ID와 분석용 token의 mapping은 별도 권한 영역에 둔다.
  • 지원에 필요한 payload와 분석에 필요한 feature를 분리한다.
  • raw, curated, aggregate dataset마다 접근자와 보존 기간을 다르게 둔다.
  • object·전송 암호화뿐 아니라 복호화 key 권한과 query 결과 유출을 통제한다.
  • deletion 요청은 원본, 파생 테이블, hot index, cache, backup별 처리 상태를 추적한다.
  • 법적 보존이 필요한 소수 이벤트와 “언젠가 쓸지도 모르는” 전체 로그를 같은 정책으로 묶지 않는다.

변조 방지가 필요한 감사 데이터에는 versioning과 WORM 보존을 검토할 수 있다. 예를 들어 S3 Object Lock은 object version을 일정 기간 덮어쓰거나 삭제하지 못하게 하는 기능이다. 그러나 잠금을 켜면 삭제 의무와 충돌할 수 있고 encryption key를 잃는 문제까지 해결하지는 않는다. 규제 이름만 보고 전체 bucket에 적용하는 것이 아니라 데이터 분류와 법적 근거를 먼저 확인해야 한다.

SLO는 서버 상태가 아니라 데이터 상태를 측정한다

최소 dashboard는 다음을 포함한다.

  • 수집 성공률과 producer error/retry rate
  • Kafka partition별 lag와 가장 오래된 미처리 이벤트 나이
  • hot index p50/p95/p99 write latency와 query latency
  • event time 대비 hot availability의 freshness
  • cold manifest가 덮은 offset 범위와 시간별 completeness
  • schema reject, quarantine, duplicate, late-event 비율
  • 시간당 object 수, 평균 파일 크기, compaction backlog
  • replay 처리량과 예상 복구 시간
  • 민감 dataset 조회량과 비정상 접근 경보

평균 lag만 보면 한 partition의 정체를 숨긴다. 전체 record count만 보면 특정 event type의 누락을 숨긴다. service, event type, schema version, partition을 기준으로 cardinality 폭발을 피하는 범위에서 쪼개 본다.

비용은 세 식으로 먼저 근사한다

정밀한 견적 전에도 규모를 가늠할 수 있다.

일일 원시 입력량
= 초당 이벤트 수 × 평균 이벤트 byte × 86,400

hot 저장 용량
≈ 일일 입력량 × hot 보존 일수 × 복제 계수 × 인덱스 오버헤드

cold query 비용의 주요 입력
≈ 실제 scan한 byte

예를 들어 payload에 쓰지 않는 필드를 계속 추가하면 network, Kafka, hot index, cold storage, query 비용을 모두 키운다. 반대로 분석 편의를 위해 중요한 이벤트를 무작위 sampling하면 재화·정산·보안 사건의 정확성을 잃는다.

sampling은 목적별로 다르게 적용한다.

  • 청구·재화·보안·감사 이벤트: 원칙적으로 전량 보존하고 품질을 검증
  • 고빈도 성능 trace: head/tail 또는 오류 중심 sampling
  • 제품 행동 분석: 대표성 편향을 측정할 수 있는 층화 sampling
  • 집계 가능한 지표: 원본과 aggregate의 보존 기간을 분리

오래된 설계에서 바로잡아야 할 다섯 가지

오래된 가정현재의 수정
로컬 embedded DB 하나면 실시간 조회가 끝난다routing, replication, fencing, rebuild, TTL까지 있어야 서비스다
매일 JSON을 한 번 백업하면 분석 저장소가 된다columnar 파일, partition, compaction, manifest, late data가 필요하다
Kafka에 들어갔으니 백업되었다Kafka retention과 장기 archive의 목적·SLO를 분리한다
exactly-once 옵션을 켜면 모든 sink의 중복이 사라진다보장 범위를 적고 외부 sink에는 event ID와 멱등 처리를 둔다
로그는 schema 없이도 나중에 파싱할 수 있다versioned contract, 호환성 검사, quarantine을 producer 배포 전에 만든다

당시의 hot/cold 분리는 여전히 좋은 핵심 아이디어다. 바뀐 것은 주변의 운영 계약이다. 단일 서버의 빠른 조회와 하루 한 번의 파일 복사에서, 재생성 가능한 파생 인덱스·transactional table metadata·privacy lifecycle·정합성 SLO까지 책임 범위가 넓어졌다.

단계적으로 만드는 순서

1단계: 계약과 원본부터

  • 핵심 event 3~5개의 schema와 owner 정의
  • event ID, 두 timestamp, tokenized subject ID 적용
  • Kafka topic과 cold writer 구축
  • offset 범위·record count·checksum manifest 생성
  • hourly completeness 검사

2단계: 가장 비싼 지원 질문 하나를 hot path로

  • 사용자 최근 타임라인 query 하나만 구현
  • 명확한 TTL과 query SLO 설정
  • masking, role, audit log 포함
  • index 전체 삭제 후 cold/Kafka로 rebuild하는 훈련

3단계: 분석 테이블과 품질

  • Parquet 변환과 small-file compaction
  • event time partition과 late-data 정책
  • schema reject/quarantine replay
  • raw→curated 변환의 코드·snapshot version 기록

4단계: 실제 요구가 생길 때 고도화

  • 다중 writer·snapshot·delete가 필요하면 table format 검토
  • 지역 장애 요구에 맞춰 broker와 object 복제 설계
  • 법적 보존 대상에만 immutable retention 적용
  • game-day 방식으로 broker, writer, index node를 끄고 RTO/RPO 측정

사실과 설계 판단의 경계

Kafka의 partition ordering, transaction 범위, S3의 key 단위 atomicity, Parquet의 column layout, Iceberg의 schema·partition evolution은 각 공식 문서로 확인할 수 있는 기능이다. 반면 “hot window를 72시간으로 둘지”, “어떤 key로 partition할지”, “언제 table format을 도입할지”는 트래픽 분포, query, 팀 규모, 개인정보 정책에 따라 달라지는 설계 판단이다.

좋은 로그 플랫폼은 가장 많은 데이터를 저장하는 시스템이 아니다. 필요한 사건을 정해진 시간 안에 찾고, 누락과 중복을 설명하며, 다시 계산할 수 있고, 더 이상 보관할 이유가 없을 때 안전하게 지울 수 있는 시스템이다.