"방금 본 숙소와 비슷한 곳", "찜한 곳의 가격이 내려갔어요" 같은 개인화는 사용자 입장에선 그냥 자연스러운 기능처럼 보입니다. 하지만 이걸 초당 100만 건의 이벤트 규모에서, 1초 이내의 지연으로, 그것도 스트림 처리를 모르는 팀들도 직접 만들 수 있게 제공하려면 이야기가 완전히 달라집니다.
에어비앤비는 이 문제를 USP(User Signals Platform) 라는 사내 플랫폼으로 풀었습니다. 이 글에서는 USP를 세 가지 관점으로 나눠서 봅니다.
요구사항 — 왜 이런 구조가 될 수밖에 없었나
아키텍처 — 무엇을 어떻게 조립했나
운영 — 수백 개의 Flink 잡을 굴리면서 배운 것
1. 요구사항: "실시간 조회"와 "정확한 데이터"를 동시에
문제 정의
게스트는 목적지 탐색 → 여행 계획 → 위시리스트 → 숙소 비교 → 예약이라는 여정을 거칩니다. 각 단계마다 다른 개인화가 필요하죠. 그런데 개인화의 재료가 되는 "사용자 행동 데이터"는 성격이 아주 까다롭습니다.
양이 많다. 검색, 조회, 스크롤, 찜 — 모든 게 이벤트입니다.
빨라야 한다. 방금 본 숙소를 5분 뒤에 추천하면 의미가 없습니다.
정확해야 한다. 오프라인 분석과 ML 학습에도 같은 데이터가 쓰입니다.
여러 팀이 쓴다. 검색팀, 알림팀, 추천팀이 각자 필요한 신호를 정의합니다.
정리된 요구사항
구분요구사항
기능
실시간 + 과거 사용자 행동 데이터 저장
기능
온라인 서빙과 오프라인 분석 양쪽에서 쿼리 가능
기능
사용자 세그먼트, 세션 인게이지먼트 같은 파생 데이터의 비동기 연산 지원
비기능
End-to-end 스트리밍 지연 1초 미만
비기능
여러 팀이 스트림 처리를 몰라도 파이프라인을 정의할 수 있어야 함
마지막 줄이 이 시스템의 성격을 결정합니다. USP는 "데이터 파이프라인"이 아니라 "플랫폼" 이고, 그래서 뒤에서 볼 설정 기반 워크플로우가 핵심 설계 요소로 들어갑니다.
2. 전체 아키텍처: Lambda 아키텍처 + KV 서빙
USP는 크게 데이터 파이프라인 레이어와 온라인 서빙 레이어로 나뉩니다.
파이프라인 레이어는 Lambda 아키텍처를 따릅니다. Kafka 이벤트를 준실시간으로 처리하는 스트리밍 경로와, 데이터 보정 및 백필을 담당하는 배치 경로가 나란히 존재하고, 둘 다 같은 KV 스토어에 씁니다.
읽기 시점의 연산은 서빙 레이어(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 Segments와 Session Engagements 잡의 입력이 됩니다. 정제 레이어와 해석 레이어를 분리한 구조죠. 덕분에 새로운 "사용자 이해" 로직을 추가할 때 원본 이벤트 스키마를 몰라도 됩니다.
3-1. User Signals — 정제된 행동 기록
가장 기본이 되는 계층입니다. 검색, 숙소 조회, 예약 같은 시그널 타입 / 시작 시각 / 종료 시각으로 조회 가능한 최근 활동 목록입니다.
💡 다시 볼 지점: 스트림 조인의 상태는 무한정 커질 수 없습니다. 조인 윈도우와 상태 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 — 세션 단위 행동 해석
세 번째는 짧은 시간 동안의 행동 묶음을 해석하는 겁니다. "이번 세션에서 이 게스트가 어떤 숙소 사진들을 봤는가"를 알면 다가올 여행의 취향을 추론할 수 있습니다.
① user id로 keyed stream 분할. 사용자별로 스트림을 쪼개서 병렬 처리합니다. 사용자 간에는 상태 공유가 필요 없으니 자연스러운 파티셔닝 키죠.
② 윈도잉. 슬라이딩 윈도우와 세션 윈도우를 상황에 맞게 씁니다.
윈도우동작적합한 경우
슬라이딩 윈도우
고정 간격으로 계속 전진 (예: 10분 크기 / 5분마다)
"최근 N분간의 행동"을 주기적으로 갱신
세션 윈도우
활동 패턴에 따라 동적으로 경계 결정
"한 번의 방문"을 자연스럽게 묶기
에어비앤비는 앱에서 여러 숙소를 둘러보는 사용자에 대해 10분 크기, 5분마다 전진하는 슬라이딩 윈도우로 단기 여행 선호도를 뽑아냅니다.
③ 비동기 연산 패턴. ML 모델 추론이나 외부 서비스 호출처럼 무거운 작업을 실시간 파이프라인을 막지 않고 수행합니다. Flink에서 이런 작업을 동기로 처리하면 백프레셔가 걸려 전체 파이프라인이 밀립니다. 비동기로 빼고, 결과는 KV 스토어에 넣어 조회만 빠르게 가져가는 구조입니다.
4. 운영: 수백 개 Flink 잡을 굴리며 배운 것
이 부분이 원문에서 가장 실전적인 내용입니다.
4-1. 지연을 네 구간으로 쪼개 측정하기
"느리다"는 말은 디버깅에 아무 도움이 안 됩니다. 에어비앤비는 지연을 어디서 어디까지인지로 나눠서 측정합니다.
여기서 중요한 통찰: 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는 자동 재할당을 하지 않습니다.