본문 바로가기

IT

65. 초당 100만 이벤트로 개인화를 만든다: 에어비앤비 User Signals Platform 아키텍처 분석

원문: Building a User Signals Platform at Airbnb — Kidai Kwon 외, The Airbnb Tech Blog 이 글은 원문의 내용을 아키텍처 중심으로 재구성하고, 설계 의도와 트레이드오프에 대한 제 해석을 덧붙인 글입니다.

 

들어가며

"방금 본 숙소와 비슷한 곳", "찜한 곳의 가격이 내려갔어요" 같은 개인화는 사용자 입장에선 그냥 자연스러운 기능처럼 보입니다. 하지만 이걸 초당 100만 건의 이벤트 규모에서, 1초 이내의 지연으로, 그것도 스트림 처리를 모르는 팀들도 직접 만들 수 있게 제공하려면 이야기가 완전히 달라집니다.

에어비앤비는 이 문제를 USP(User Signals Platform) 라는 사내 플랫폼으로 풀었습니다. 이 글에서는 USP를 세 가지 관점으로 나눠서 봅니다.

  1. 요구사항 — 왜 이런 구조가 될 수밖에 없었나
  2. 아키텍처 — 무엇을 어떻게 조립했나
  3. 운영 — 수백 개의 Flink 잡을 굴리면서 배운 것

1. 요구사항: "실시간 조회"와 "정확한 데이터"를 동시에

문제 정의

게스트는 목적지 탐색 → 여행 계획 → 위시리스트 → 숙소 비교 → 예약이라는 여정을 거칩니다. 각 단계마다 다른 개인화가 필요하죠. 그런데 개인화의 재료가 되는 "사용자 행동 데이터"는 성격이 아주 까다롭습니다.

  • 양이 많다. 검색, 조회, 스크롤, 찜 — 모든 게 이벤트입니다.
  • 빨라야 한다. 방금 본 숙소를 5분 뒤에 추천하면 의미가 없습니다.
  • 정확해야 한다. 오프라인 분석과 ML 학습에도 같은 데이터가 쓰입니다.
  • 여러 팀이 쓴다. 검색팀, 알림팀, 추천팀이 각자 필요한 신호를 정의합니다.

정리된 요구사항

구분요구사항
기능 실시간 + 과거 사용자 행동 데이터 저장
기능 온라인 서빙과 오프라인 분석 양쪽에서 쿼리 가능
기능 사용자 세그먼트, 세션 인게이지먼트 같은 파생 데이터의 비동기 연산 지원
비기능 End-to-end 스트리밍 지연 1초 미만
비기능 여러 팀이 스트림 처리를 몰라도 파이프라인을 정의할 수 있어야 함

마지막 줄이 이 시스템의 성격을 결정합니다. USP는 "데이터 파이프라인"이 아니라 "플랫폼" 이고, 그래서 뒤에서 볼 설정 기반 워크플로우가 핵심 설계 요소로 들어갑니다.


2. 전체 아키텍처: Lambda 아키텍처 + KV 서빙

USP는 크게 데이터 파이프라인 레이어온라인 서빙 레이어로 나뉩니다.

파이프라인 레이어는 Lambda 아키텍처를 따릅니다. Kafka 이벤트를 준실시간으로 처리하는 스트리밍 경로와, 데이터 보정 및 백필을 담당하는 배치 경로가 나란히 존재하고, 둘 다 같은 KV 스토어에 씁니다.

준실시간배치 exportAirbnb 앱 / 서버사용자 이벤트KafkaFlinkUSP TransformKV StoreHiveOffline BatchUSP TransformUSP Service검색 / 추천 / 알림프로덕트 팀

읽기 시점의 연산은 서빙 레이어(USP Service)가 KV 스토어를 조회하면서 처리합니다. 즉 쓰기 경로에서 최대한 미리 계산해두고, 읽기는 가볍게 가져가는 전형적인 구조입니다.

설계 결정 ① Spark Streaming이 아니라 Flink

에어비앤비는 이전에 Spark Streaming에서 이벤트 지연을 겪었다고 밝힙니다. 원인은 처리 모델의 차이입니다.

  • Spark Streaming: 마이크로 배치 — 스트림을 작은 배치 잡의 연속으로 처리
  • Flink: 이벤트 기반 — 이벤트를 건건이 처리

1초 미만이라는 목표 앞에서 마이크로 배치의 배치 간격은 그 자체로 지연 하한선이 됩니다. "1초 미만"이라는 비기능 요구사항이 프레임워크 선택을 사실상 결정한 셈이죠. 요구사항이 기술 선택을 강제하는 좋은 예입니다.

설계 결정 ② Append-only + 처리 타임스탬프를 버전으로

이게 개인적으로 이 글에서 가장 우아하다고 느낀 부분입니다.

변환된 데이터를 KV 스토어에 덮어쓰지 않고 append-only로 저장하되, 이벤트 처리 타임스탬프를 버전으로 사용합니다. 이 한 가지 결정이 분산 스트림 처리의 고질적인 문제를 통째로 우회합니다.

Flink를 포함한 대부분의 스트림 처리는 현실적으로 at-least once로 동작합니다. 즉 같은 이벤트가 두 번 처리될 수 있습니다. 게다가 Lambda 아키텍처에서는 배치 경로가 같은 데이터를 다시 처리합니다. 보통 이걸 해결하려면 exactly-once 시맨틱을 구현하거나, 중복 제거 테이블을 두거나, 트랜잭션을 걸어야 합니다. 전부 복잡하고 느립니다.

하지만 "같은 키 + 같은 버전 = 같은 값" 이 보장되면, 몇 번을 쓰든 결과가 같습니다. 멱등성이 스토리지 모델 자체에서 나오는 겁니다.

💡 다시 볼 지점: 대신 append-only는 저장 공간을 계속 먹습니다. 시그널 타입별 TTL과 리텐션 정책이 반드시 함께 설계되어야 합니다. 원문에는 이 부분이 명시적으로 나오지 않지만, 실제 운영에서는 여기가 비용의 대부분을 차지할 겁니다.

설계 결정 ③ 설정 기반 개발자 워크플로우

USP의 목표 중 하나가 "Flink를 모르는 팀도 쓸 수 있게"였습니다. 그래서 개발자는 설정 파일 + 변환 클래스 두 가지만 정의하고, 나머지(Flink 잡 설정, 백필 스크립트, 알럿 설정)는 스크립트가 생성합니다.

그리고 이 변환 로직은 Flink 잡과 배치 잡이 공유합니다. Lambda 아키텍처의 가장 큰 함정이 "스트리밍 코드와 배치 코드가 각자 진화하다가 결과가 달라지는 것"인데, 변환 로직을 하나로 묶어서 이 문제를 구조적으로 차단했습니다.


3. USP가 제공하는 세 가지 능력

USP는 위 스트리밍 구조 위에서 세 가지 종류의 처리를 제공합니다. 여기서 중요한 건 파이프라인이 2단으로 이어진다는 점입니다.

원본 이벤트가 먼저 User Signals로 정제되고, 그 결과가 다시 Kafka로 발행되어 User SegmentsSession Engagements 잡의 입력이 됩니다. 정제 레이어와 해석 레이어를 분리한 구조죠. 덕분에 새로운 "사용자 이해" 로직을 추가할 때 원본 이벤트 스키마를 몰라도 됩니다.

3-1. User Signals — 정제된 행동 기록

가장 기본이 되는 계층입니다. 검색, 숙소 조회, 예약 같은 시그널 타입 / 시작 시각 / 종료 시각으로 조회 가능한 최근 활동 목록입니다.

개발자는 이런 설정을 작성합니다.

- name: listing_view_signal
  type: simple
  signal_class: com.example.usp.api.ListingViewSignal
  event_sources:
    - kafka_topic: listing_view_event
      transform: com.example.usp.transforms.ListingViewTransform

그리고 변환 클래스를 구현합니다. 원문은 Java 예시지만, 저는 Kotlin으로 옮겨봤습니다.

class ListingViewTransform : AbstractSignalTransform<ListingViewEvent, ListingViewSignal>() {

    // 이 이벤트를 시그널로 만들지 판단 (봇 트래픽, 테스트 계정 필터링 등)
    override fun isValidEvent(event: ListingViewEvent): Boolean =
        event.userId != null && !event.isBot && event.dwellTimeMs >= MIN_DWELL_MS

    // 원본 이벤트 → 도메인 시그널 변환
    override fun transform(event: ListingViewEvent): ListingViewSignal =
        ListingViewSignal(
            userId = event.userId!!,
            listingId = event.listingId,
            viewedAt = event.timestamp,
            dwellTimeMs = event.dwellTimeMs,
        )

    companion object {
        private const val MIN_DWELL_MS = 1_000L
    }
}

개발자가 신경 쓰는 건 필터링 조건과 매핑 로직뿐입니다. 체크포인팅, 워터마크, 파티셔닝 같은 Flink 개념은 전부 플랫폼 뒤로 숨습니다. 이게 "플랫폼화"의 실체입니다.

여러 이벤트를 조인해야 한다면? USP는 조인 시그널도 지원합니다. 조인 키를 설정에 명시하면, RocksDB를 상태 저장소로 쓰는 stateful 스트리밍으로 준실시간 조인이 이뤄집니다.

- name: search_to_view_signal
  type: left_join
  signal_class: com.example.usp.api.SearchToViewSignal
  transform: com.example.usp.transforms.SearchToViewTransform
  left_event_source:
    kafka_topic: search_event
    join_key_field: search_id
  right_event_source:
    kafka_topic: listing_view_event
    join_key_field: search_id

💡 다시 볼 지점: 스트림 조인의 상태는 무한정 커질 수 없습니다. 조인 윈도우와 상태 TTL이 실질적인 운영 리스크입니다. RocksDB 상태가 커지면 체크포인트 시간이 늘고, 체크포인트가 느려지면 장애 복구 시간이 늘어납니다.

설정과 변환 클래스를 정의한 뒤 생성 스크립트를 돌리면, Flink 잡 설정 / 백필 배치 파일 / 알럿 정의(쓰기량 이상 감지, 지연 임계치, 성공률 하한)까지 한 번에 만들어집니다. 알럿이 자동 생성된다는 점이 특히 중요합니다. 플랫폼이 관측 가능성을 기본값으로 강제하는 거니까요.

3-2. User Segments — 준실시간 사용자 코호트

세그먼트는 "지금 이 사용자가 어떤 그룹에 속하는가"입니다. 대표 예시가 active trip planner(적극적 여행 계획자)입니다.

  • 게스트가 검색을 하면 → 즉시 세그먼트에 편입
  • 14일간 활동이 없으면 → 세그먼트에서 제외
  • 예약을 완료하면 → 세그먼트에서 제외

개발자는 세 개의 메서드만 구현합니다.

class ActiveTripPlannerTransform : AbstractSegmentTransform() {

    // 이 시그널들을 보고 세그먼트 소속 여부 판단
    override fun inSegment(inputSignals: List<Signal>): Boolean =
        inputSignals.any { it.type == SignalType.SEARCH } &&
            inputSignals.none { it.type == SignalType.BOOKING }

    // 세그먼트 진입 시각
    override fun getStartTimestamp(inputSignals: List<Signal>): Instant =
        inputSignals.filter { it.type == SignalType.SEARCH }
            .minOf { it.timestamp }

    // 세그먼트 이탈 시각 — 새 시그널이 들어올 때마다 갱신된다
    override fun getExpirationTimestamp(inputSignals: List<Signal>): Instant =
        inputSignals.maxOf { it.timestamp }.plus(INACTIVITY_WINDOW)

    companion object {
        private val INACTIVITY_WINDOW: Duration = Duration.ofDays(14)
    }
}

 

여기서 눈여겨볼 설계는 "만료 시각을 미리 계산해서 저장한다" 는 점입니다. "14일간 활동 없음"을 판정하려면 보통 타이머나 주기적 스캔이 필요한데, 대신 새 시그널이 올 때마다 만료 시각을 앞으로 밀어두고, 조회 시점에 만료 여부를 판단하면 됩니다.

쓰기 시점의 계산으로 읽기 시점의 판정을 대체하는 방식이라, 이벤트가 없는 사용자에 대해서는 아무 연산도 하지 않습니다. 수억 명 규모에서 이 차이는 큽니다.

3-3. Session Engagements — 세션 단위 행동 해석

세 번째는 짧은 시간 동안의 행동 묶음을 해석하는 겁니다. "이번 세션에서 이 게스트가 어떤 숙소 사진들을 봤는가"를 알면 다가올 여행의 취향을 추론할 수 있습니다.

 

#mermaid-rul-r4 { font-family: anthropic-sans, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 16px; fill: rgb(25, 25, 25); } #mermaid-rul-r4 .edge-animation-slow { stroke-dashoffset: 900; animation: 50s linear 0s infinite normal none running dash; stroke-linecap: round; stroke-dasharray: 9, 5 !important; } #mermaid-rul-r4 .edge-animation-fast { stroke-dashoffset: 900; animation: 20s linear 0s infinite normal none running dash; stroke-linecap: round; stroke-dasharray: 9, 5 !important; } #mermaid-rul-r4 .error-icon { fill: rgb(204, 120, 92); } #mermaid-rul-r4 .error-text { fill: rgb(51, 135, 163); stroke: rgb(51, 135, 163); } #mermaid-rul-r4 .edge-thickness-normal { stroke-width: 1px; } #mermaid-rul-r4 .edge-thickness-thick { stroke-width: 3.5px; } #mermaid-rul-r4 .edge-pattern-solid { stroke-dasharray: 0; } #mermaid-rul-r4 .edge-thickness-invisible { stroke-width: 0; fill: none; } #mermaid-rul-r4 .edge-pattern-dashed { stroke-dasharray: 3; } #mermaid-rul-r4 .edge-pattern-dotted { stroke-dasharray: 2; } #mermaid-rul-r4 .marker { fill: rgb(145, 145, 141); stroke: rgb(145, 145, 141); } #mermaid-rul-r4 .marker.cross { stroke: rgb(145, 145, 141); } #mermaid-rul-r4 svg { font-family: anthropic-sans, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 16px; } #mermaid-rul-r4 p { margin: 0px; } #mermaid-rul-r4 .label { font-family: anthropic-sans, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; color: rgb(25, 25, 25); } #mermaid-rul-r4 .cluster-label text { fill: rgb(51, 135, 163); } #mermaid-rul-r4 .cluster-label span { color: rgb(51, 135, 163); } #mermaid-rul-r4 .cluster-label span p { background-color: transparent; } #mermaid-rul-r4 .label text, #mermaid-rul-r4 span { fill: rgb(25, 25, 25); color: rgb(25, 25, 25); } #mermaid-rul-r4 .node rect, #mermaid-rul-r4 .node circle, #mermaid-rul-r4 .node ellipse, #mermaid-rul-r4 .node polygon, #mermaid-rul-r4 .node path { fill: rgb(240, 240, 235); stroke: rgb(217, 216, 213); stroke-width: 1px; } #mermaid-rul-r4 .rough-node .label text, #mermaid-rul-r4 .node .label text, #mermaid-rul-r4 .image-shape .label, #mermaid-rul-r4 .icon-shape .label { text-anchor: middle; } #mermaid-rul-r4 .node .katex path { fill: rgb(0, 0, 0); stroke: rgb(0, 0, 0); stroke-width: 1px; } #mermaid-rul-r4 .rough-node .label, #mermaid-rul-r4 .node .label, #mermaid-rul-r4 .image-shape .label, #mermaid-rul-r4 .icon-shape .label { text-align: center; } #mermaid-rul-r4 .node.clickable { cursor: pointer; } #mermaid-rul-r4 .root .anchor path { stroke-width: 0; stroke: rgb(145, 145, 141); fill: rgb(145, 145, 141) !important; } #mermaid-rul-r4 .arrowheadPath { fill: rgb(11, 11, 11); } #mermaid-rul-r4 .edgePath .path { stroke: rgb(145, 145, 141); stroke-width: 1px; } #mermaid-rul-r4 .flowchart-link { stroke: rgb(145, 145, 141); fill: none; } #mermaid-rul-r4 .edgeLabel { background-color: rgb(245, 230, 216); text-align: center; } #mermaid-rul-r4 .edgeLabel p { background-color: rgb(245, 230, 216); } #mermaid-rul-r4 .edgeLabel rect { opacity: 0.5; background-color: rgb(245, 230, 216); fill: rgb(245, 230, 216); } #mermaid-rul-r4 .labelBkg { background-color: rgba(245, 230, 216, 0.5); } #mermaid-rul-r4 .cluster rect { fill: rgb(204, 120, 92); stroke: rgb(138, 115, 107); stroke-width: 1px; } #mermaid-rul-r4 .cluster text { fill: rgb(51, 135, 163); } #mermaid-rul-r4 .cluster span { color: rgb(51, 135, 163); } #mermaid-rul-r4 div.mermaidTooltip { position: absolute; text-align: center; max-width: 200px; padding: 2px; font-family: anthropic-sans, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 12px; background: rgb(204, 120, 92); border: 1px solid rgb(138, 115, 107); border-radius: 2px; pointer-events: none; z-index: 100; } #mermaid-rul-r4 .flowchartTitleText { text-anchor: middle; font-size: 18px; fill: rgb(25, 25, 25); } #mermaid-rul-r4 rect.text { fill: none; stroke-width: 0; } #mermaid-rul-r4 .icon-shape, #mermaid-rul-r4 .image-shape { background-color: rgb(245, 230, 216); text-align: center; } #mermaid-rul-r4 .icon-shape p, #mermaid-rul-r4 .image-shape p { background-color: rgb(245, 230, 216); padding: 2px; } #mermaid-rul-r4 .icon-shape .label rect, #mermaid-rul-r4 .image-shape .label rect { opacity: 0.5; background-color: rgb(245, 230, 216); fill: rgb(245, 230, 216); } #mermaid-rul-r4 .label-icon { display: inline-block; height: 1em; overflow: visible; vertical-align: -0.125em; } #mermaid-rul-r4 .node .label-icon path { fill: currentcolor; stroke: revert; stroke-width: revert; } #mermaid-rul-r4 .node .neo-node { stroke: rgb(217, 216, 213); } #mermaid-rul-r4 [data-look="neo"].node rect, #mermaid-rul-r4 [data-look="neo"].cluster rect, #mermaid-rul-r4 [data-look="neo"].node polygon { stroke: url("#mermaid-rul-r4-gradient"); filter: drop-shadow(rgb(185, 185, 185) 1px 2px 2px); } #mermaid-rul-r4 [data-look="neo"].swimlane.cluster rect { filter: none; } #mermaid-rul-r4 [data-look="neo"].node path { stroke: url("#mermaid-rul-r4-gradient"); stroke-width: 1px; } #mermaid-rul-r4 [data-look="neo"].node .outer-path { filter: drop-shadow(rgb(185, 185, 185) 1px 2px 2px); } #mermaid-rul-r4 [data-look="neo"].node .neo-line path { stroke: rgb(217, 216, 213); filter: none; } #mermaid-rul-r4 [data-look="neo"].node circle { stroke: url("#mermaid-rul-r4-gradient"); filter: drop-shadow(rgb(185, 185, 185) 1px 2px 2px); } #mermaid-rul-r4 [data-look="neo"].node circle .state-start { fill: rgb(0, 0, 0); } #mermaid-rul-r4 [data-look="neo"].icon-shape .icon { fill: url("#mermaid-rul-r4-gradient"); filter: drop-shadow(rgb(185, 185, 185) 1px 2px 2px); } #mermaid-rul-r4 [data-look="neo"].icon-shape .icon-neo path { stroke: url("#mermaid-rul-r4-gradient"); filter: drop-shadow(rgb(185, 185, 185) 1px 2px 2px); } #mermaid-rul-r4 :root { --mermaid-font-family: anthropic-sans,system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }Transform Kafka시그널 이벤트Keyed Stream(key = user id)WindowingSliding / Session의미 있는행동 시퀀스 식별비동기 연산ML 추론 / 서비스 호출KV Store

핵심 기법 두 가지:

① user id로 keyed stream 분할. 사용자별로 스트림을 쪼개서 병렬 처리합니다. 사용자 간에는 상태 공유가 필요 없으니 자연스러운 파티셔닝 키죠.

② 윈도잉. 슬라이딩 윈도우와 세션 윈도우를 상황에 맞게 씁니다.

윈도우동작적합한 경우
슬라이딩 윈도우 고정 간격으로 계속 전진 (예: 10분 크기 / 5분마다) "최근 N분간의 행동"을 주기적으로 갱신
세션 윈도우 활동 패턴에 따라 동적으로 경계 결정 "한 번의 방문"을 자연스럽게 묶기

에어비앤비는 앱에서 여러 숙소를 둘러보는 사용자에 대해 10분 크기, 5분마다 전진하는 슬라이딩 윈도우로 단기 여행 선호도를 뽑아냅니다.

③ 비동기 연산 패턴. ML 모델 추론이나 외부 서비스 호출처럼 무거운 작업을 실시간 파이프라인을 막지 않고 수행합니다. Flink에서 이런 작업을 동기로 처리하면 백프레셔가 걸려 전체 파이프라인이 밀립니다. 비동기로 빼고, 결과는 KV 스토어에 넣어 조회만 빠르게 가져가는 구조입니다.


4. 운영: 수백 개 Flink 잡을 굴리며 배운 것

이 부분이 원문에서 가장 실전적인 내용입니다.

4-1. 지연을 네 구간으로 쪼개 측정하기

"느리다"는 말은 디버깅에 아무 도움이 안 됩니다. 에어비앤비는 지연을 어디서 어디까지인지로 나눠서 측정합니다.

#mermaid-rum-r5 { font-family: anthropic-sans, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 16px; fill: rgb(25, 25, 25); } #mermaid-rum-r5 .edge-animation-slow { stroke-dashoffset: 900; animation: 50s linear 0s infinite normal none running dash; stroke-linecap: round; stroke-dasharray: 9, 5 !important; } #mermaid-rum-r5 .edge-animation-fast { stroke-dashoffset: 900; animation: 20s linear 0s infinite normal none running dash; stroke-linecap: round; stroke-dasharray: 9, 5 !important; } #mermaid-rum-r5 .error-icon { fill: rgb(204, 120, 92); } #mermaid-rum-r5 .error-text { fill: rgb(51, 135, 163); stroke: rgb(51, 135, 163); } #mermaid-rum-r5 .edge-thickness-normal { stroke-width: 1px; } #mermaid-rum-r5 .edge-thickness-thick { stroke-width: 3.5px; } #mermaid-rum-r5 .edge-pattern-solid { stroke-dasharray: 0; } #mermaid-rum-r5 .edge-thickness-invisible { stroke-width: 0; fill: none; } #mermaid-rum-r5 .edge-pattern-dashed { stroke-dasharray: 3; } #mermaid-rum-r5 .edge-pattern-dotted { stroke-dasharray: 2; } #mermaid-rum-r5 .marker { fill: rgb(145, 145, 141); stroke: rgb(145, 145, 141); } #mermaid-rum-r5 .marker.cross { stroke: rgb(145, 145, 141); } #mermaid-rum-r5 svg { font-family: anthropic-sans, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 16px; } #mermaid-rum-r5 p { margin: 0px; } #mermaid-rum-r5 .label { font-family: anthropic-sans, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; color: rgb(25, 25, 25); } #mermaid-rum-r5 .cluster-label text { fill: rgb(51, 135, 163); } #mermaid-rum-r5 .cluster-label span { color: rgb(51, 135, 163); } #mermaid-rum-r5 .cluster-label span p { background-color: transparent; } #mermaid-rum-r5 .label text, #mermaid-rum-r5 span { fill: rgb(25, 25, 25); color: rgb(25, 25, 25); } #mermaid-rum-r5 .node rect, #mermaid-rum-r5 .node circle, #mermaid-rum-r5 .node ellipse, #mermaid-rum-r5 .node polygon, #mermaid-rum-r5 .node path { fill: rgb(240, 240, 235); stroke: rgb(217, 216, 213); stroke-width: 1px; } #mermaid-rum-r5 .rough-node .label text, #mermaid-rum-r5 .node .label text, #mermaid-rum-r5 .image-shape .label, #mermaid-rum-r5 .icon-shape .label { text-anchor: middle; } #mermaid-rum-r5 .node .katex path { fill: rgb(0, 0, 0); stroke: rgb(0, 0, 0); stroke-width: 1px; } #mermaid-rum-r5 .rough-node .label, #mermaid-rum-r5 .node .label, #mermaid-rum-r5 .image-shape .label, #mermaid-rum-r5 .icon-shape .label { text-align: center; } #mermaid-rum-r5 .node.clickable { cursor: pointer; } #mermaid-rum-r5 .root .anchor path { stroke-width: 0; stroke: rgb(145, 145, 141); fill: rgb(145, 145, 141) !important; } #mermaid-rum-r5 .arrowheadPath { fill: rgb(11, 11, 11); } #mermaid-rum-r5 .edgePath .path { stroke: rgb(145, 145, 141); stroke-width: 1px; } #mermaid-rum-r5 .flowchart-link { stroke: rgb(145, 145, 141); fill: none; } #mermaid-rum-r5 .edgeLabel { background-color: rgb(245, 230, 216); text-align: center; } #mermaid-rum-r5 .edgeLabel p { background-color: rgb(245, 230, 216); } #mermaid-rum-r5 .edgeLabel rect { opacity: 0.5; background-color: rgb(245, 230, 216); fill: rgb(245, 230, 216); } #mermaid-rum-r5 .labelBkg { background-color: rgba(245, 230, 216, 0.5); } #mermaid-rum-r5 .cluster rect { fill: rgb(204, 120, 92); stroke: rgb(138, 115, 107); stroke-width: 1px; } #mermaid-rum-r5 .cluster text { fill: rgb(51, 135, 163); } #mermaid-rum-r5 .cluster span { color: rgb(51, 135, 163); } #mermaid-rum-r5 div.mermaidTooltip { position: absolute; text-align: center; max-width: 200px; padding: 2px; font-family: anthropic-sans, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 12px; background: rgb(204, 120, 92); border: 1px solid rgb(138, 115, 107); border-radius: 2px; pointer-events: none; z-index: 100; } #mermaid-rum-r5 .flowchartTitleText { text-anchor: middle; font-size: 18px; fill: rgb(25, 25, 25); } #mermaid-rum-r5 rect.text { fill: none; stroke-width: 0; } #mermaid-rum-r5 .icon-shape, #mermaid-rum-r5 .image-shape { background-color: rgb(245, 230, 216); text-align: center; } #mermaid-rum-r5 .icon-shape p, #mermaid-rum-r5 .image-shape p { background-color: rgb(245, 230, 216); padding: 2px; } #mermaid-rum-r5 .icon-shape .label rect, #mermaid-rum-r5 .image-shape .label rect { opacity: 0.5; background-color: rgb(245, 230, 216); fill: rgb(245, 230, 216); } #mermaid-rum-r5 .label-icon { display: inline-block; height: 1em; overflow: visible; vertical-align: -0.125em; } #mermaid-rum-r5 .node .label-icon path { fill: currentcolor; stroke: revert; stroke-width: revert; } #mermaid-rum-r5 .node .neo-node { stroke: rgb(217, 216, 213); } #mermaid-rum-r5 [data-look="neo"].node rect, #mermaid-rum-r5 [data-look="neo"].cluster rect, #mermaid-rum-r5 [data-look="neo"].node polygon { stroke: url("#mermaid-rum-r5-gradient"); filter: drop-shadow(rgb(185, 185, 185) 1px 2px 2px); } #mermaid-rum-r5 [data-look="neo"].swimlane.cluster rect { filter: none; } #mermaid-rum-r5 [data-look="neo"].node path { stroke: url("#mermaid-rum-r5-gradient"); stroke-width: 1px; } #mermaid-rum-r5 [data-look="neo"].node .outer-path { filter: drop-shadow(rgb(185, 185, 185) 1px 2px 2px); } #mermaid-rum-r5 [data-look="neo"].node .neo-line path { stroke: rgb(217, 216, 213); filter: none; } #mermaid-rum-r5 [data-look="neo"].node circle { stroke: url("#mermaid-rum-r5-gradient"); filter: drop-shadow(rgb(185, 185, 185) 1px 2px 2px); } #mermaid-rum-r5 [data-look="neo"].node circle .state-start { fill: rgb(0, 0, 0); } #mermaid-rum-r5 [data-look="neo"].icon-shape .icon { fill: url("#mermaid-rum-r5-gradient"); filter: drop-shadow(rgb(185, 185, 185) 1px 2px 2px); } #mermaid-rum-r5 [data-look="neo"].icon-shape .icon-neo path { stroke: url("#mermaid-rum-r5-gradient"); filter: drop-shadow(rgb(185, 185, 185) 1px 2px 2px); } #mermaid-rum-r5 :root { --mermaid-font-family: anthropic-sans,system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }앱에서이벤트 생성Kafka 도착Flink 처리 시작변환 완료KV Store 기록
메트릭측정 구간의미
Event latency 앱에서 이벤트 생성 → KV 기록 End-to-end. 사용자 행동이 "조회 가능해지는" 시점
Ingestion latency Kafka 도착 → KV 기록 플랫폼이 실제로 통제 가능한 구간
Job latency Flink 처리 시작 → KV 기록 잡 자체의 성능
Transform latency Flink 처리 시작 → 변환 완료 순수 변환 로직의 비용

여기서 중요한 통찰: Event latency는 end-to-end 지표라 가장 의미 있어 보이지만, 통제가 어렵습니다. 클라이언트 사이드 이벤트에 의존하면 기기의 느린 네트워크나 성능을 위한 로그 배칭 때문에 이벤트 자체가 늦게 도착합니다. 그래서 에어비앤비는 가능하면 클라이언트 이벤트보다 서버 사이드 이벤트를 소스로 선호한다고 말합니다.

그리고 실제로 상시 모니터링하는 주 지표는 Ingestion latency입니다. 이 구간이 Kafka 토픽 과부하, KV 스토어 쓰기 지연(클라이언트 풀 문제, 레이트 리밋, 서비스 불안정) 같은 실제 장애 원인을 대부분 포착하기 때문입니다.

💡 이건 스트림 처리에 국한된 이야기가 아닙니다. SLO를 잡을 때 "의미 있는 지표"와 "통제 가능한 지표"를 구분하고, 알럿은 후자에 거는 게 맞습니다. 전자는 대시보드에, 후자는 페이저에.

4-2. Hot-standby Task Manager로 안정성 확보

Flink는 하나의 Job Manager가 여러 Task Manager를 오케스트레이션하는 분산 시스템입니다. 그리고 Kafka 토픽의 파티션은 Task Manager에 고정 할당됩니다.

문제는 여기서 발생합니다. Task Manager 하나가 죽으면, 그 TM에 할당된 파티션의 이벤트는 대체 파드가 뜰 때까지 통째로 막힙니다. 다른 TM들은 멀쩡히 자기 파티션을 처리하고 있는데, 죽은 TM의 파티션만 백로그가 쌓이는 거죠.

일반적인 온라인 서비스의 수평 확장과는 성격이 다릅니다. 웹 서버 파드가 죽으면 로드밸런서가 트래픽을 재분배하면 그만이지만, Flink는 자동 재할당을 하지 않습니다.

에어비앤비의 해법은 단순합니다. 핫 스탠바이 파드를 미리 띄워두는 것.

flowchart TB
    subgraph normal["정상 상태"]
        direction TB
        JM1["Job Manager"]
        JM1 --- T1["TM 1"]
        JM1 --- T2["TM 2"]
        JM1 --- T3["TM 3"]
        JM1 --- T4["TM 4"]
        JM1 -.대기.- T5["TM 5<br/>hot-standby"]
    end

    subgraph failure["TM 4 장애 발생"]
        direction TB
        JM2["Job Manager"]
        JM2 --- U1["TM 1"]
        JM2 --- U2["TM 2"]
        JM2 --- U3["TM 3"]
        JM2 -.X.- U4["TM 4 ❌"]
        JM2 ==="즉시 인계"==> U5["TM 5<br/>작업 수행"]
        JM2 -.신규 대기.- U6["TM 6<br/>hot-standby"]
    end

    normal ==> failure

TM 4가 죽으면 대기 중이던 TM 5가 파드 기동을 기다리지 않고 즉시 해당 작업을 이어받습니다. 그 후에 새 스탠바이 파드가 천천히 만들어집니다.

트레이드오프는 명확합니다. 놀고 있는 파드 하나의 비용을 지불하고, 장애 시 파드 기동 시간(수십 초~수 분)만큼의 백로그를 없애는 겁니다. 파드 하나 값이 백로그 복구 비용보다 싸다면 명백히 이득이죠.


5. 정리: 이 설계에서 가져갈 것

USP는 현재 100개 이상의 Flink 잡으로 초당 100만 건 이상의 이벤트를 처리하고, USP 서비스는 초당 7만 건의 쿼리를 서빙합니다.

규모는 다르더라도, 설계 원칙 자체는 훨씬 작은 시스템에도 그대로 적용됩니다.

원칙USP에서의 구현일반화
요구사항이 기술을 결정한다 1초 미만 지연 → Flink 비기능 요구사항을 먼저 숫자로 못 박기
멱등성은 만들지 말고 설계하라 append-only + 처리 ts를 버전으로 중복 제거 로직 대신 데이터 모델로 해결
읽기 대신 쓰기에서 계산하라 만료 시각 사전 계산, 파생 데이터 사전 저장 읽기 QPS가 쓰기보다 높다면 항상 유효
파이프라인을 계층화하라 원본 → 시그널 → 세그먼트/세션 하위 계층이 상위 원본 스키마를 몰라도 되게
관측 가능성을 기본값으로 잡 생성 시 알럿 자동 생성 플랫폼이 강제하지 않으면 아무도 안 만든다
지연은 구간별로 측정하라 4단계 latency 메트릭 통제 가능한 구간에만 알럿을 건다

규모가 작다면 어떻게 시작할까

초당 100만 이벤트가 아니어도 개인화는 필요합니다. 같은 원칙을 유지하면서 스택만 낮춰볼 수 있습니다.

  • Kafka + Flink → Kafka Streams나 Kafka Consumer + 코루틴. Flink의 운영 부담은 결코 작지 않습니다.
  • KV Store → Redis(TTL 내장) 또는 DynamoDB. 시그널별 TTL 설계는 처음부터 필수입니다.
  • Lambda 아키텍처 → 초기엔 스트리밍 경로만. 다만 변환 로직은 처음부터 배치에서도 호출 가능하게 분리해두는 게 좋습니다. 나중에 백필이 필요해질 때 이 결정이 큰 차이를 만듭니다.
  • 설정 기반 워크플로우 → 팀이 하나뿐이면 오버엔지니어링입니다. 다만 시그널 정의를 코드가 아니라 데이터로 다루는 습관은 초기부터 들일 만합니다.

가장 먼저 훔쳐올 만한 건 append-only + 버전 기반 멱등성구간별 지연 측정이라고 생각합니다. 이 둘은 규모와 무관하게 이득이 나면서, 나중에 도입하려면 훨씬 비싸지는 종류의 결정입니다.


참고

 

반응형