티스토리 뷰

들어가며

우선 Silver 레이어 구축은 다른 레이어보다 고려해야할 부분들이 많고,

중요한 기능들이 있기에, 여러 포스팅으로 나눠 적어볼 예정이다. (현재 작성 시점으론 3개 포스팅 계획중)

 

Bronze에서 raw 데이터를 S3에 영구 저장했다.

하지만 그 데이터는 분석에 바로 쓸 수 없는 상태이다.

 

Bronze에 저장된 것:

value = '{"campaign_id":10046,...}'  (dummy)
value = '{"campaign":9100690,"cost":0.00003,"cat1":...}'  (criteo)

  → JSON 문자열 덩어리, 두 원천의 모양이 다름

 

Silver의 일은 이 raw를 분석 가능한 깨끗한 테이블로 바꾸는 것이다.

Bronze(받아서 쌓기)나 Gold(집계)보다 신경 쓸 게 많은 이유는,

정제, 통일, 중복제거, 지연 데이터 반영이 전부 여기서 일어나기 때문이다.

 

이 글은 Silver를 어떤 틀과 설계 결정으로 구축하는지 다룬다. (실제 코드는 다음 편)


1. 전체 틀 - 브랜치 전략

Silver는 한 번에 다 만들지 않고, 단계를 쪼개 스택형 브랜치로 진행한다.

핵심 개발과 인프라(Airflow)를 섞으면 디버깅이 어려워지기 때문이다. (그리고 작업 단위를 한번에 볼 수 있어서 좋기도 하다)

[1] feature-root/silver-layer   ← Silver 통합 브랜치 (최종 main으로)
     ↑
[2] feature/silver-core         ← 핵심 기능 (Spark 잡)        ← 지금 여기
     ↑
[3] feature/silver-to-airflow   ← Airflow 오케스트레이션 전환
     ↑
[4] refactor/silver-detail      ← 품질 검증(validation) 등 디테일

 

왜 이렇게 나눴나:

  • [2] 핵심 먼저, Airflow 나중
    • 처음부터 Airflow를 도입하면 디버깅이 어렵다. Spark 잡 단독으로 로직을 검증한 뒤 문제가 없을때 Airflow로 감싸도록 했다.
  • [4] 디테일 마지막
    • 이상값 제거 같은 데이터 품질 작업은 파이프라인이 동작한 뒤 붙이는게 자연스럽다.

머지는 아래에서 위로 cascade: `[4] → [3] → [2] → [1] → main` 순서대로 한다.


2. 핵심 설계 결정 ① - grain은 이벤트 단위

가장 중요한 결정이다.

`processed_events`의 한 행을 무엇으로 볼것인가? 이걸 바로 grain(행의 단위)라고 한다.

두가지 선택지

방식 A - funnel wide-row (스터디에서 배운 방식): 한 퍼널을 한 행으로 합친다(collapse).

auction_id │ impression │ click │ conversion
    Z      │    1       │   1   │     1

하나의 경매에서 이뤄진 이벤트들이 한 행이 되는 것이다.

 

방식 B - 이벤트 단위(이번 프로젝트에서 선택한 것): 이벤트를 각자 따로 둔다.

event_id │ event_type  │ auction_id
   i1    │ impression  │    Z
   c1    │ click       │    Z
   v1    │ conversion  │    Z

이벤트마다 한 행을 두는 것이다.

왜 이벤트 단위의 방식을 골랐는가?

A방식에는 문제가 있었다.

Criteo 데이터 셋의 합성 구조를 보면 클릭 1건당 아래와 같이 이벤트를 만들게 된다.

auction_id = Z 하나에:
  impression  40개   ← 여기가 문제
  click        1개
  conversion   1개

 

방식 A는 impression 행에 click을 auction_id로 붙여 한 행으로 합친다.

그치만 impression이 40개라, 아래와 같이 된다.

impression 40개 ─┐
                 ├ auction_id=Z로 연결 → click 1개가 40개 전부에 복제됨
click 1개       ─┘

결과:
imp_1  + click  →  click 1
imp_2  + click  →  click 1
 ...
imp_40 + click  →  click 1      ← click 1개가 40번 count 됨

클릭이 한번 일어났는데, 합치는 과정에서 40번으로 부풀려진다.

나중에 CTR을 집계하면 클릭 수가 40배라 완전히 틀린 값이 나온다.

폭증의 원리: 연결할 때 한쪽이 40개, 다른 쪽이 1개면 `40 x 1 =40`개 행이 된다. impression이 40개라 40배.

 

그래서 이벤트를 합치지 않는 방식 B로 진행했다.

이 방식에서는 impression 40개를 40개 행으로, click 1개를 1개 행으로 그냥 둔다. 

합치지 않으니 부풀려질 일이 없는 것이다.

  • 클릭 수 세기 → `event_type = 'click'` 행 count = 1
  • 노출 수 세기 → `event_type = 'impression' 행 count = 40

CTR 깉은 퍼널 지표는 나중에 Gold에서 event_type별로 세서 계산한다. (click 행 수 / impression 행 수)

 

내가 헷갈렸던 부분

grain과 join키랑 헷갈렸다.

  • grain (행의 단위) = `event_id` → "한 행 = 한 이벤트" (안 합침)
  • join 키 (연결 기준) = `auction_id` → "전환과 클릭을 묶을 때"

이벤트를 따로 두면 한 가지가 필요해진다.

이 conversion이 어느 클릭에서 온건지를 알려면 conversion과 click을 연결해야한다.

이때 `auction_id`로 conversion과 click을 잇는다.

그래야 이 conversion이 클릭한후 얼마나 지났을때 전환이 된건지를 알 수 있게 된다. (앞에서 말한 `conversion_delay_sec`값이 바로 이 값이다.) - 두번째 포스팅에서 자세히 코드로 설명할 예정이다.

 

그럼 이 conversion과 click도 impression - click 처럼 폭증하는거 아닌가?
그렇지 않다.

한 경매에 click과 conversion은 각각 1개씩이라, 아래와 같이 1:1로 안전하다.

conversion 1개 x click 1개 = 1개 

 

정리

  방식 A (퍼널 단위) 방식 B (이벤트 단위)
행 단위 퍼널 1개 = 1행 (합침) 이벤트 1개 = 1행 (안 합침)
criteo impression 40개  click이 40배 폭증 40행 그대로
퍼널 지표 이미 합쳐진 행에서 Gold에서 event_type 별 count
conversion_delay_sec 합쳐져 있음 auction_id로 conversion-click 1:1 연결
설계 원칙: 스터디에서 배운것을 그대로 쓴다기 보단, 데이터의 특성(criteo가 impression 40개를 합성)에 맞는 grain을 고른다.
합치면 40배 폭증하니, 이벤트를 따로 두고(grain=event_id) 필요한 연결만 auction_id로 1:1 한다.

3. 핵심 설계 결정 ② - 책임 분리 (Bronze=raw, Silver=정제)

  • Bronze: raw JSON 그대로 보존 (파싱 안 함)
  • Silver: 파싱 + 통일 + 중복제거 + 지연 반영

이 경계 덕분에 Bronze는 원본으로 남고, Silver 로직을 고쳐도 Bronze에서 다시 정제하면 된다. (Medallion 아키텍쳐의 핵심)

 

왜 통일이 필요했냐면, 현재 우리 Bronze에는 한 토픽에 두 스키마가 섞여 있기 때문이다.

→ 같은 click이라도, 이 click이 Criteo에서 왔는지, dummy에서 왔는지에 따라 스키마가 달라지기 때문이라는 소리!

  • `campaign_id` (dummy) vs `campaign` (criteo) → `campaign_id`로 통일
  • `bid_price` (CPM) vs `cost` (CPC) → `cost`로 (criteo x1000)
  • 절대 epoch vs 상대초 → `event_timestamp` (criteo BASE+상대)

4. 테이블 설계 -` processed_events`

사용한 DDL

CREATE TABLE glue.silver.processed_events (
    event_id, event_type, source, auction_id,
    campaign_id, event_timestamp, event_date,
    uid, banner_id, site_cat, device_type, os, country,
    cost, conversion, conversion_delay_sec, updated_at
)
USING iceberg
PARTITIONED BY (event_date)
TBLPROPERTIES (
    'format-version' = '2',
    'write.merge.mode' = 'copy-on-write',
    'write.target-file-size-bytes' = '134217728'
);

 

세 가지 설계 포인트:

  1. `event_date` 파티션 - 분석, 집계가 일 단위가, 날짜별 폴더로 나누면 특정 날짜만 빠르게 읽는다 (Iceberg table의 partition pruning)
  2. `format-version 2` - Iceberg v1은 append만 된다. v2여야 MERGE/UPDATE가 가능하다. conversion_delay를 MERGE로 반영하려면 이 설정값이 필수다(자세한건 2편에서)
  3. `COW`(Copy-on-Write) - S3 Parquet는 불변이라 UPDATE시 전략이 필요하다.
COW: 수정 시 파일 전체를 새로 씀. 쓰기 느림, 읽기 빠름.
MOR: 변경분만 delta로. 쓰기 빠름, 읽기 느림.

 

지금 프로젝트는 가끔 배치 MERGE + 분석 쿼리로 자주 읽는 성격을 띈다.

따라서 읽기가 빠른 COW선택했다.

 

MOR는 초당 수천 갱신 같은 고빈도 쓰기용으로 쓰기에 적합하다.


5. 처리 방식 - sliding window

Silver는 매번 Bronze 전체를 읽지 않도록 하였다.

최근 N일치만 읽는것이다.

오늘이 6/17, window=7 → Bronze에서 dt >= 6/10 만 읽음

 

왜 이렇게 설계하였냐면:

  • 전체 재처리(full recompute)는 데이터가 쌓이면 쌓일수록 선형적으로 무거워지기 때문이다.
  • 지연 전환(conversion_delay)는 보통 바로 오지 않고 며칠 내 도착한다. → 최근 N일만 다시 보면 충분한것이다.
  • 같은 날을 N일간 반복해서 읽지만, MERGE가 멱등이라 중복 저장 되지 않는다. (이건 실제 코드 보면서 다시 추가 설명)
전체를 매번 갈아 엎기 대신 최근 변동 가능 구간만 다시 보고 MERGE로 반영.

6. 다음 포스팅 예고

6-2 Silver 구현 - 실제 코드 로직:

  • sliding window 읽기
  • source 분기 파싱, 통일
  • dedup(Window 함수)
  • conversion_delay(JOIN)
  • MERGE INTO
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/06   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함