실시간 데이터 파이프라인을 위한 메달리온 아키텍처
출처: https://m.youtube.com/watch?v=tHBPV_iwh00 · 우아한테크 · 35:47 · 2025-12-07
요지
- 여러 도메인의 데이터를 한곳에서 처리하는 중앙 데이터 허브는 협업 접점을 줄이지만, 서비스가 성장하면 태스크 간 결합과 파이프라인 복잡도가 빠르게 증가한다.
- 브론즈·실버·골드 레이어를 도입하면 데이터의 품질과 용도에 따라 수집, 검증·최적화, 비즈니스·조회 로직의 책임을 분리할 수 있다.
- 조회 시스템이 제공 팀의 원본 스키마를 직접 참조하지 않고 실버 스키마부터 사용하게 하면 원본 스키마 변경의 전파를 차단할 수 있다.
- 실버 레이어에서 의미 있는 변경만 다음 단계로 전달하면 중복 실행과 불필요한 쓰기 부하를 줄일 수 있다.
- 공통 상품과 지점별 상품을 분리해 조회 시 조합하면 대규모 팬아웃 쓰기를 읽기 부하로 전환하고 저장 공간과 인프라 비용도 절감할 수 있다.
- 레이어는 많을수록 좋은 것이 아니며, 각 경계가 시스템을 실제로 더 이해하기 쉽고 변경하기 안전하게 만드는지 검증해야 한다.
개요
배달·커머스의 검색 화면 하나를 만들 때도 상품, 셀러, 주문, 재고, 혜택, 카탈로그, 검색 데이터가 함께 필요하다. 이 데이터들을 소유한 팀이 서로 직접 연결되면 협업 관계와 변경 영향 범위가 급격히 커진다. 우아한형제들의 데이터 허브는 제공 팀과 조회 팀 사이에서 데이터를 중앙 수집·가공함으로써 각 팀의 접점을 데이터 허브 하나로 줄인다.
그러나 중앙화만으로 복잡성이 사라지지는 않는다. 커머스 데이터 허브가 하루 약 10억 건을 처리하고 분당 약 5만 건의 조회를 수행할 정도로 성장하면서, 워크플로의 가독성 저하, 스키마 변경의 연쇄 전파, 특정 태스크의 병목이 나타났다. 발표는 데이터 레이크하우스에서 쓰이는 메달리온 아키텍처의 아이디어를 실시간 Spring 기반 처리 시스템에 맞게 변형하고, 실제 지점 상품 팬아웃 문제를 개선한 과정을 설명한다.
배경 / 사전 지식
데이터 허브의 생산자는 상품·재고·혜택 같은 원천 데이터를 관리하는 도메인 팀이고, 소비자는 모바일 앱에 필요한 데이터를 제공하는 조회 팀이다. 허브는 이들 사이에서 다음 작업을 수행한다.
- 여러 출처의 원본 데이터를 Kafka로 수집한다.
- Spring 기반 내부 프레임워크의 워크플로로 데이터를 처리한다.
- 처리 결과를 Redis 등의 저장소에 기록한다.
- 조회 전용 서버가 앱 요청에 맞는 데이터를 반환한다.
- 내부 시스템 간 통신에는 RPC를 사용한다.
워크플로는 한 가지 역할을 맡은 태스크들을 그래프처럼 연결한 실행 단위다. 저장 태스크, 데이터 결합 태스크, 외부 팀 전송 태스크 등이 메시지에 맞춰 순서대로 실행된다. 초기에는 수집한 데이터를 약간 수정해 저장하는 정도였지만, 정책과 데이터 종류가 늘면서 하나의 데이터가 여러 태스크에서 재사용되고 태스크 하나가 여러 책임을 맡게 되었다.
메달리온 아키텍처는 데이터를 품질과 활용 단계에 따라 보통 세 영역으로 나눈다.
- 브론즈: 수집한 원시 데이터
- 실버: 검증, 중복 제거, 정제와 최적화를 거친 데이터
- 골드: 분석이나 비즈니스에 바로 사용할 수 있는 데이터
전통적인 메달리온 아키텍처는 Spark, 정형 테이블, SQL, 데이터 엔지니어 중심 환경에서 주로 설명된다. 발표의 시스템은 Spring 애플리케이션, JSON 값, Java DTO와 IDL, 백엔드 개발자 중심이라는 차이가 있다. 따라서 이름만 가져오는 것이 아니라 실시간 제공 성능과 팀 간 협업이라는 문제에 맞춰 각 레이어의 책임과 제약을 다시 정의해야 한다.
핵심 개념
중앙 데이터 허브의 양면성
중앙 허브는 제공 팀과 조회 팀이 서로 모두 연결되는 다대다 협업 구조를 단순화한다. 제공 팀은 허브에 데이터를 전달하고, 조회 팀은 허브가 만든 데이터만 사용하면 된다. 반면 모든 정책과 변환이 허브로 모이기 때문에 내부 구조를 체계화하지 않으면 복잡성도 중앙에 축적된다.
발표에서 확인한 주요 문제는 세 가지다.
- 가독성 저하: 신규 개발자가 특정 정책이 어느 태스크에서 처리되는지 알기 어렵다.
- 변경 전파: 원본 스키마나 공유 태스크의 변경이 관심 없는 조회 팀과 모듈까지 영향을 준다.
- 병목 발생: 역할이 큰 태스크와 불필요한 연쇄 실행 때문에 뒤쪽 처리가 지연된다.
브론즈: 원본 수집의 경계
브론즈 레이어는 제공 팀에서 받은 데이터를 원형에 가깝게 저장하고 다음 단계로 전달한다. 비즈니스 판단이나 조회 편의를 위한 가공은 하지 않는다. 원본을 보존하면 장애 분석과 재처리가 쉬워지고, 제공 계약이 바뀌었을 때 무엇이 실제로 들어왔는지도 확인할 수 있다.
가장 중요한 제약은 조회 팀이 브론즈 스키마를 직접 참조하지 못하게 하는 것이다. 원본 스키마는 제공 팀의 사정에 따라 바뀔 수 있으므로 이를 조회 계약으로 사용하면 양쪽 팀이 다시 강하게 결합된다.
실버: 안정된 내부 모델과 최적화 경계
실버 레이어는 비즈니스 로직에 필요한 최적 단위로 데이터를 검증하고 가공해 저장한다. 브론즈 하나마다 실버 하나를 기계적으로 만들 필요는 없다. 여러 원본을 결합할 수도 있고, 별도의 최적화가 필요하지 않다면 실버 모델을 만들지 않을 수도 있다.
이 레이어의 검증은 단순한 null 검사나 형식 검사에 그치지 않는다. 뒤쪽 파이프라인을 실행할 가치가 있는 변경인지 판단하는 것도 검증이다. 예를 들어 상품 이벤트에 수십 개 필드가 들어 있어도 공통 상품 모델이 상품명과 카탈로그 정보만 사용한다면, 그 두 필드가 바뀌었을 때만 골드 단계를 실행할 수 있다.
실버 스키마는 제공 팀의 원본 계약을 조회 팀이 사용할 수 있는 안정된 내부 계약으로 번역한다. 이 경계 덕분에 원본 스키마가 바뀌어도 실버 변환만 수정하고 조회 계약은 유지할 수 있다.
골드: 비즈니스와 조회에 맞춘 데이터
골드 레이어는 조회 팀의 요구에 맞는 비즈니스 로직과 전시 로직을 구현한다. 정렬, 노출 여부, 최종 상품 구성처럼 앱이 바로 소비할 수 있는 모델을 만든다.
여기서 최소 부하는 단순히 쓰기를 적게 한다는 뜻이 아니다. 미리 모든 데이터를 조합하면 읽기는 빨라지지만 쓰기와 저장 비용이 커지고, 조회 시 조합하면 쓰기는 줄지만 읽기 비용이 증가한다. 요청량, 변경 빈도, 데이터 크기, 일관성 요구를 비교해 전체 비용이 가장 작은 지점을 선택해야 한다.
팬아웃
팬아웃은 하나의 원천 변경이 여러 파생 데이터의 쓰기로 증폭되는 현상이다. 퀵커머스에서는 본사 상품 하나가 수천 또는 1만 개가 넘는 지점 상품으로 복제될 수 있다. 상품 2,000개를 약 15,000개 지점에 반영하면 수천만 건의 쓰기가 발생할 수 있다.
모든 팬아웃이 나쁜 것은 아니다. 특정 프로모션이 실제로 300개 상품에 적용된다면 300개 갱신은 필요한 작업이다. 제거해야 하는 것은 조회 편의를 위해 동일한 공통 정보를 모든 지점 데이터에 복제하는 불필요한 팬아웃이다.
레이어는 실행 가능한 아키텍처 규칙이어야 한다
폴더 이름이나 다이어그램만 나눈다고 책임이 분리되지는 않는다. 각 레이어가 무엇을 할 수 있고 무엇을 해서는 안 되는지 명시해야 한다. 발표 시점에는 이를 컨벤션으로 적용했으며, 이후 인터페이스와 모듈 경계를 이용해 컴파일 단계에서 잘못된 참조를 막는 것을 목표로 한다.
명확한 규칙은 사람뿐 아니라 코딩 에이전트에도 유용하다. 예를 들어 “브론즈에서는 가공하지 않는다”, “골드는 브론즈 DTO를 참조하지 않는다” 같은 규칙은 코드 위치와 변경 범위를 판단할 수 있는 구체적인 명세가 된다.
작동 원리
1. 원천 이벤트를 브론즈에 보존한다
상품, 재고, 혜택 등의 제공 팀이 Kafka에 이벤트를 발행하면 브론즈 태스크가 이를 수집한다. 이 단계에서는 제공된 스키마를 임의로 조회 모델에 맞추지 않고 원본 값과 식별자를 보존한다.
2. 실버가 안정된 내부 모델로 변환한다
실버 태스크는 원본을 검증하고 필요한 필드만 내부 스키마로 변환한다. 서로 다른 원천을 결합하거나 중복 이벤트를 제거할 수도 있다. 조회 팀은 브론즈가 아니라 이 내부 스키마부터 참조한다.
3. 의미 있는 변경인지 판별한다
새 이벤트와 기존 실버 상태를 비교해 골드 모델에 영향을 주는 필드가 바뀌었는지 확인한다. 영향이 없다면 파이프라인을 여기서 종료한다. 이 완충 지점이 중복 실행과 뒤쪽 태스크의 부하를 줄인다.
4. 공통 정보와 지점 정보를 분리한다
기존 방식은 본사 상품의 이름·카탈로그 같은 공통 정보까지 모든 지점 상품에 복제했다. 개선 방식은 실버 모델을 다음처럼 분리한다.
- 공통 상품: 본사에서 관리하며 모든 지점이 공유하는 상품명, 카탈로그 정보
- 지점별 상품: 재고, 지점별 노출 상태처럼 지점마다 달라지는 정보
본사 상품이 바뀌면 공통 상품 한 건만 갱신하고, 특정 지점의 재고가 바뀌면 해당 지점 상품 한 건만 갱신한다.
5. 골드 또는 조회 시점에 조합한다
조회 서버는 공통 상품과 지점별 상품을 읽어 앱 노출 상품으로 조합한다. 읽기 횟수와 애플리케이션 조인 비용은 늘지만, 대량 복제 쓰기와 저장 공간은 크게 줄어든다. 발표 사례에서는 지점 상품 한 건의 크기, Redis 메모리, 쓰기 인스턴스 부하가 감소했고 인프라 장비 축소에도 도움이 되었다.
6. 레이어별로 관측한다
브론즈 인입량, 실버 필터링률, 골드 생성량을 따로 측정하면 병목의 위치와 최적화 효과를 구분할 수 있다. 발표에서는 이를 향후 구축할 ‘데이터 내시경’ 형태의 모니터링 대시보드로 제안한다.
코드 예시
다음은 외부 라이브러리 없이 실행할 수 있는 Java 예시다. 브론즈 원본 이벤트를 실버 공통 상품으로 변환하고, 골드에 영향을 주는 변경이 있을 때만 저장한다. 지점별 재고는 별도 모델로 유지한 뒤 조회 시 조합한다.
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public class MedallionPipelineDemo {
// Bronze: 제공 팀의 원본 계약
record ProductEvent(long productId, String name, String catalogId,
String description) {}
// Silver: 조회 시스템이 의존할 안정된 내부 계약
record CommonProduct(long productId, String name, String catalogId) {}
record BranchProduct(long productId, long branchId, int stock) {}
// Gold: 앱이 바로 사용할 조회 모델
record DisplayProduct(long productId, long branchId, String name,
String catalogId, int stock, boolean soldOut) {}
static CommonProduct refine(ProductEvent bronze) {
if (bronze.productId() <= 0) {
throw new IllegalArgumentException("productId must be positive");
}
if (bronze.name() == null || bronze.name().isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
return new CommonProduct(
bronze.productId(), bronze.name().trim(), bronze.catalogId()
);
}
// description처럼 골드 결과에 쓰이지 않는 필드 변경은 전파하지 않는다.
static boolean affectsGold(CommonProduct before, CommonProduct after) {
return before == null
|| !Objects.equals(before.name(), after.name())
|| !Objects.equals(before.catalogId(), after.catalogId());
}
static DisplayProduct compose(CommonProduct common, BranchProduct branch) {
if (common.productId() != branch.productId()) {
throw new IllegalArgumentException("product IDs do not match");
}
return new DisplayProduct(
common.productId(), branch.branchId(), common.name(),
common.catalogId(), branch.stock(), branch.stock() <= 0
);
}
public static void main(String[] args) {
Map<Long, CommonProduct> commonStore = new HashMap<>();
Map<String, BranchProduct> branchStore = new HashMap<>();
ProductEvent event = new ProductEvent(
101L, "말랑말랑 호떡", "CAT-7", "겨울 한정 설명"
);
CommonProduct silver = refine(event);
CommonProduct previous = commonStore.get(silver.productId());
if (affectsGold(previous, silver)) {
commonStore.put(silver.productId(), silver);
}
BranchProduct gangnam = new BranchProduct(101L, 10_001L, 3);
branchStore.put("101:10001", gangnam);
DisplayProduct gold = compose(commonStore.get(101L), gangnam);
System.out.println(gold);
}
}
예시는 공통 상품을 지점 수만큼 복제하지 않는다. 상품명이 변경되어도 commonStore의 한 건만 바뀐다. 반대로 재고 변경은 해당 BranchProduct에만 반영된다. affectsGold는 실버 레이어의 의미 기반 검증으로, 사용하지 않는 설명 필드만 변경된 이벤트가 불필요한 후속 처리를 일으키지 않게 한다.
실제 시스템에서는 저장소 쓰기와 이벤트 처리를 원자적으로 다루고, 이벤트 ID를 이용한 중복 제거, 재시도, 순서 역전 방지, 실패 이벤트 격리도 추가해야 한다.
함정·실수
레이어 이름만 붙이고 책임을 강제하지 않는다
태스크를 브론즈·실버·골드 폴더로 옮기는 것만으로는 결합이 사라지지 않는다. 골드 코드가 브론즈 DTO를 직접 참조할 수 있다면 원본 변경은 여전히 전파된다. 모듈 의존 방향, 공개 인터페이스, 정적 분석 규칙으로 금지 관계를 강제해야 한다.
실버를 모든 원본의 일대일 복사본으로 만든다
브론즈 레코드마다 실버 레코드를 만들면 이름만 다른 중간 계층이 늘어난다. 실버는 안정된 계약, 결합, 중복 제거, 변경 감지처럼 분명한 가치를 제공할 때 만들어야 한다.
모든 검증을 형식 검사로 한정한다
null과 타입만 검사하면 불필요한 후속 실행을 막지 못한다. 최종 결과에 영향을 주는 필드 집합을 정의하고 이전 상태와 비교해야 한다. 단, 필드 누락으로 필요한 갱신까지 차단하지 않도록 계약 테스트를 둔다.
쓰기 부하를 줄이면서 읽기 비용을 무시한다
조회 시 조인은 팬아웃을 줄이지만 읽기 횟수, 네트워크 왕복, 애플리케이션 CPU 사용량을 늘릴 수 있다. 캐시 적중률과 조회 지연 목표를 측정하지 않고 적용하면 병목이 쓰기에서 읽기로 이동할 뿐이다.
이벤트 중복과 순서 역전을 고려하지 않는다
Kafka 기반 처리에서는 같은 이벤트가 재전달되거나 오래된 이벤트가 뒤늦게 도착할 수 있다. 멱등 키, 버전 또는 발생 시각을 활용하지 않으면 최신 실버 상태가 과거 값으로 덮일 수 있다.
추상화 계층을 과도하게 늘린다
레이어마다 DTO, 변환기, 저장소를 무조건 추가하면 추적해야 할 코드만 많아진다. 새 경계가 변경 전파를 막거나 성능·관측성을 개선하는지 확인하고, 가치가 없는 중간 단계는 만들지 않는다.
베스트 프랙티스
- 각 레이어의 책임뿐 아니라 금지 사항도 문서화한다. 예를 들어 브론즈에서는 비즈니스 가공 금지, 골드에서는 브론즈 스키마 직접 참조 금지처럼 작성한다.
- 의존 방향을
bronze → silver → gold로 제한하고, 별도 모듈과 공개 인터페이스로 역방향 참조를 컴파일 단계에서 차단한다. - 실버 모델은 제공 팀의 스키마 변경과 조회 팀의 요구 변경을 흡수할 수 있는 내부 계약으로 설계한다. 원본 필드 이름을 그대로 복사하는 데 그치지 않는다.
- 이벤트별로 최종 결과에 영향을 주는 필드를 명시하고, 변경 데이터 캡처나 이전 상태 비교를 이용해 의미 있는 변경만 전파한다.
- 팬아웃 최적화 전후에 증폭 계수를 측정한다.
입력 이벤트 수 대비 실버 쓰기 수,실버 쓰기 대비 골드 생성 수를 보면 불필요한 실행을 찾기 쉽다. - 공통 데이터와 개별 데이터를 분리할 때는 쓰기 절감량뿐 아니라 조회 조인 비용, 캐시 전략, 일관성 요구, 장애 시 부분 데이터 처리 방법을 함께 결정한다.
- 브론즈 인입 지연, 실버 탈락률, 레이어별 처리 시간, 골드 생성 실패율을 대시보드로 분리해 관찰한다. 추적 ID를 전 레이어에 전달하면 한 이벤트의 경로를 복원할 수 있다.
- 마이그레이션은 트래픽이 큰 전체 파이프라인을 한 번에 바꾸기보다 팬아웃이 크고 효과를 계량할 수 있는 흐름부터 적용한다. 구·신 모델을 병행 생성해 결과를 비교한 뒤 조회 경로를 전환한다.
- 아키텍처 규칙을 개발자 문서와 에이전트 지침에 함께 기록하고, 계약 테스트와 아키텍처 테스트로 문서와 구현의 불일치를 탐지한다.
참고
- 2023 우아콘에서 소개된 우아한형제들 데이터 허브 아키텍처 발표들: 영상에서 선행 발표로 언급했으나 구체적인 제목과 URL은 명시하지 않음.
- Databricks가 제안한 메달리온 아키텍처: 브론즈·실버·골드 데이터 레이어의 출처로 언급됨.
- Apache Spark: 일반적인 메달리온 아키텍처의 기반 플랫폼 사례로 언급됨.
- John Wheeler의 간접 계층에 관한 격언: 간접화로 문제를 해결할 수 있지만 과도한 간접화 역시 문제가 된다는 발표의 결론에 활용됨.