분류 번호 없는 도서관 — 당근이 행동 로그의 카오스를 정리한 방법

10 min read
Event TrackingData PlatformDaangnProtobufKafka
분류 번호 없는 도서관 — 당근이 행동 로그의 카오스를 정리한 방법

책이 수만 권인 도서관에 분류 체계가 없다면 어떻게 될까. 사서마다 자기 방식으로 책을 꽂고, "이 책 어디 있어요?"라고 물으면 "3층 어딘가에..."라는 답이 돌아온다. 당근의 사용자 행동 로그가 정확히 이 상태였다. 수십 개 팀이 각자의 코드에서 각자의 방식으로 로그를 찍고 있었고, 전사적으로 관리할 체계는 존재하지 않았다.


체계 없는 로그가 만든 혼란

사용자 행동 로그는 프로덕트 개선의 원재료다. 사용자가 어떤 화면을 보고, 어떤 버튼을 누르고, 어디서 이탈하는지를 추적해야 서비스를 발전시킬 수 있다. MAU 수천만의 서비스에서 이 로그의 규모는 상상을 초월한다.

문제는 로그를 코드 레벨에서 직접 관리했다는 데 있다. 클라이언트 개발자가 이벤트 이름, 속성, 전송 시점을 각자 코드에 하드코딩했다. 같은 "구매 버튼 클릭"이라는 행위를 팀 A는 click_buy_button, 팀 B는 purchase_click, 팀 C는 btn_buy_tap으로 기록했다. 이건 마치 같은 단어를 쓰면서 사전마다 뜻이 다른 것과 같다.

구체적으로 다음과 같은 문제들이 있었다.

  • 이벤트 이름 불일치: 같은 행위에 대한 이름이 팀마다 달랐다
  • 속성 누락·불일치: user_id, uid, 아예 빠트림 — 세 가지가 공존했다
  • 스키마 관리 부재: 이벤트의 정확한 구조가 문서화되지 않아 분석팀이 필드 의미를 추측해야 했다
  • 변경 이력 소실: 이벤트가 언제, 왜, 누구에 의해 바뀌었는지 추적할 수 없었다

[💡 잠깐! 이 용어는?] 이벤트 택소노미(Event Taxonomy): 서비스에서 발생하는 모든 사용자 행동 이벤트를 체계적으로 분류하고 명명하는 규칙. 이름, 속성, 데이터 타입, 설명을 포함하며 전사적 일관성의 기반이 된다.


이벤트센터라는 해법

이벤트센터는 당근이 구축한 사용자 행동 로그 관리 플랫폼이다. 목표는 세 가지로 압축된다.

  1. 중앙화된 이벤트 정의: 모든 이벤트의 스키마를 한 곳에서 정의하고 관리한다
  2. 코드 자동 생성: 정의된 스키마에서 클라이언트/서버 코드를 자동으로 뽑아내 타입 안전성을 보장한다
  3. 변경 이력 추적: 이벤트의 모든 변경사항을 버전으로 관리한다

이벤트센터는 회사 전체가 공유하는 단일 사전이다. 새 단어(이벤트)를 추가하려면 편찬 위원회(이벤트센터)를 거쳐야 하고, 등록된 단어는 모든 부서가 동일한 의미로 사용한다. 사전 없이 각자 통용어를 쓰던 시대를 끝내는 것이다.


아키텍처: 세 개의 축

1. Protobuf 기반 스키마 정의

이벤트 스키마를 코드가 아닌 선언적 방식으로 정의한다. 언어에 독립적이면서 강타입 시스템을 제공하는 Protocol Buffers를 채택했다.

event_schema.proto
syntax = "proto3";
 
package daangn.events.commerce;
 
message ProductViewEvent {
  string event_id = 1;
  string user_id = 2;
  string product_id = 3;
  string category = 4;
  int64 timestamp_ms = 5;
  string screen_name = 6;
  string referrer = 7;
 
  enum ViewType {
    VIEW_TYPE_UNSPECIFIED = 0;
    ORGANIC = 1;
    SEARCH = 2;
    RECOMMENDATION = 3;
  }
  ViewType view_type = 8;
}
 
message ProductClickEvent {
  string event_id = 1;
  string user_id = 2;
  string product_id = 3;
  string button_name = 4;
  int64 timestamp_ms = 5;
}

[💡 잠깐! 이 용어는?] Protocol Buffers(Protobuf): Google이 개발한 언어/플랫폼 중립적 직렬화 포맷. JSON보다 직렬화 크기가 작고 속도가 빠르며, .proto 파일에서 다양한 언어의 코드를 자동 생성할 수 있다.

2. 자동 코드 생성 파이프라인

스키마가 승인되면 CI/CD 파이프라인이 각 플랫폼용 코드를 자동으로 찍어낸다.

codegen-pipeline.yml
event_codegen:
  triggers:
    - schema_approved
  targets:
    - platform: ios
      language: swift
      output: "daangn-events-ios/Sources/Generated/"
    - platform: android
      language: kotlin
      output: "daangn-events-android/src/generated/"
    - platform: server
      language: kotlin
      output: "daangn-events-server/src/generated/"
  validation:
    - backward_compatibility_check
    - required_fields_check
    - naming_convention_check

생성된 코드는 타입이 안전(type-safe)하다. 속성 이름 오타나 필수 필드 누락은 런타임이 아닌 빌드 시점에 에러로 잡힌다. 마치 맞춤법 검사기가 문서를 검토하듯, 컴파일러가 이벤트 로그의 정합성을 자동으로 검증하는 것이다.

3. Kafka 기반 이벤트 수집·전달

클라이언트에서 전송된 이벤트는 수집 서버를 거쳐 Kafka 토픽에 적재된다. 이후 분석 파이프라인, 실시간 대시보드, 추천 엔진 등 다양한 컨슈머가 소비한다.

EventCollector.kt
@RestController
class EventCollectorController(
    private val kafkaTemplate: KafkaTemplate<String, ByteArray>,
    private val eventValidator: EventValidator
) {
    @PostMapping("/v1/events")
    fun collectEvents(@RequestBody events: List<RawEvent>): ResponseEntity<CollectResult> {
        val validationResults = events.map { event ->
            val result = eventValidator.validate(event)
            if (result.isValid) {
                kafkaTemplate.send(
                    "user-events-${event.eventType}",
                    event.userId,
                    event.toProtobuf()
                )
            }
            result
        }
 
        return ResponseEntity.ok(CollectResult(
            accepted = validationResults.count { it.isValid },
            rejected = validationResults.count { !it.isValid },
            errors = validationResults.filter { !it.isValid }.map { it.error }
        ))
    }
}

전과 후 — 무엇이 달라졌나

기준기존 방식 (코드 하드코딩)이벤트센터
이벤트 정의코드 곳곳에 분산중앙 스키마로 일원화
네이밍 일관성팀마다 다름전사 컨벤션 강제
타입 안전성런타임 오류 발생 가능컴파일 타임 검증
변경 추적불가버전 관리 + 이력 추적
온보딩기존 코드 분석 필요웹 UI에서 이벤트 검색
스키마 문서화별도 작성 (보통 누락)자동 생성

실전 체크리스트: 이벤트 로그 체계를 세울 때

event-log-system-checklist.yml
이벤트_정의:
  - 전사 네이밍 컨벤션을 먼저 확립했는가
  - 이벤트 스키마를 선언적으로 관리하는가 (Protobuf, JSON Schema 등)
  - 필수 공통 필드를 정의했는가 (event_id, user_id, timestamp 등)
 
코드_생성:
  - 스키마에서 클라이언트/서버 코드를 자동 생성하는가
  - 하위 호환성 검사를 자동화했는가
  - 타입 안전한 API를 제공하는가
 
수집_파이프라인:
  - 이벤트 유효성 검증을 수집 시점에 수행하는가
  - 유실 방지를 위한 버퍼링/재시도가 있는가
  - 대시보드에서 실시간 수집 현황을 모니터링할 수 있는가
 
거버넌스:
  - 이벤트 변경에 대한 리뷰 프로세스가 있는가
  - 미사용 이벤트를 정기적으로 정리하는가
  - 데이터 분석팀과 협업 채널이 있는가

[💡 잠깐! 이 용어는?] 하위 호환성(Backward Compatibility): 새 버전의 스키마가 이전 버전으로 생성된 데이터를 문제없이 읽을 수 있는 성질. 이벤트 스키마 변경 시 기존에 적재된 데이터가 깨지지 않도록 보장하는 것이 핵심이다.


마무리

이벤트 로그는 쌓기 쉽지만 잘 쌓기는 어렵다. 당근의 이벤트센터가 보여주는 교훈은 명확하다 — 스키마 중심의 관리, 자동 코드 생성, 컴파일 타임 검증은 조직이 커질수록 그 효과가 기하급수적으로 커진다. 행동 데이터가 서비스의 핵심 자산이라면, 그 자산을 담는 그릇에도 체계적인 투자가 필요한 시점이다.

관심 있을 만한 포스트

메시지는 보냈는데 DB는 롤백됐다면? — 아웃박스 패턴과 재시도 토픽으로 만드는 자가 치유 파이프라인

아웃박스 패턴과 Spring Kafka RetryableTopic으로 외부 채널 입고 정보를 안전하게 동기화하는 컬리의 방법.

KafkaOutbox Pattern

AI 코딩의 맹점 — Artifacts 없이 에이전트는 기억을 잃는다

PRD, ADR, TDD가 AI 코딩 워크플로우에서 왜 선택이 아닌 필수인지, 실전 구조와 함께 살펴본다.

AI 코딩Artifacts

Next-Translate 3.0 — Turbopack과 App Router를 위한 i18n 재건

1년간 공백 후 돌아온 Next-Translate 3.0이 Turbopack 지원, 비동기 params, App Router 안정화를 한 번에 처리하는 방법.

Next.jsi18n

V8 WasmGC 투기적 최적화 — 가상 메서드를 인라인으로 만드는 법

V8이 WasmGC의 가상 메서드 디스패치에 투기적 인라이닝을 도입해 Dart와 Java 앱에서 최대 8% 성능을 끌어낸 방법.

V8WebAssembly

Vinext — Vite 위에서 Next.js를 1주일 만에 다시 만든 이야기

Cloudflare가 AI와 함께 단 일주일, $1,100의 API 비용으로 Next.js 호환 프레임워크를 Vite 위에 구축한 과정.

VinextNext.js

Tsonic — TypeScript를 네이티브 바이너리로 컴파일하는 실험

TypeScript → C# → NativeAOT 파이프라인으로 네이티브 실행 파일을 만드는 Tsonic. 어떻게 동작하고, 어떤 한계가 있는지 살펴봤다.

TypeScriptNativeAOT

VS Code 팀의 AI 에이전트 병렬화 — 월간 릴리스를 주간으로 만든 워크플로우

VS Code 팀이 월간 릴리스에서 주간 릴리스로 전환한 비결. 에이전트 세션 병렬화, 자동화 파이프라인, 품질 게이트 설계 전반을 공개했다.

VS CodeAI

React Compiler의 한계 — 뭘 최적화하고 뭘 못 하는가

React Compiler가 자동 메모이제이션으로 해결하는 것과 해결하지 못하는 것. 컴파일러 기반 UI 프레임워크의 능력 경계를 정리했다.

ReactReact Compiler