[Troubleshooting] Spark가 데이터를 두 번 읽는 방법: JDBC 파티셔닝과 격리 수준의 환장 콜라보

“어제 매출이 왜 2배로 뛰었죠? 마케팅 대박 났나요?”
“아뇨… 그럴 리가요…” (등줄기에 땀이 흐른다)

1. 사건의 발단: “데이터가 뻥튀기 됐어요”


어느 평화로운 오전, 분석가님으로부터 메시지가 왔다. 후행 마트 테이블의 집계 수치가 평소보다 훨씬 높게 나온다는 것.
확인해 보니 특정 시간대의 데이터가 정확히 중복(Duplicate) 적재되어 있었다. PK가 중복되었으니 당연히 Sum 집계는 뻥튀기될 수밖에.

사용 중인 기술 스택은 AWS Glue (Spark) 였고, 소스 DB는 Aurora MySQLOracle을 혼용 중이었다. 논리적으로 중복이 발생할 수 없는 Upsert 로직을 쓰고 있었는데, 도대체 왜 중복이 발생했을까?

2. 범인을 찾아라 (삽질 로그)


처음엔 당연히 내 코드를 의심했고, 그다음엔 AWS를 의심했다. (미안하다 AWS…)

  • ⛏️ 삽질 1 (원천 의심): 소스 DB에 중복이 있나? => 없음.
  • ⛏️ 삽질 2 (인프라 의심): Spark나 Glue가 파일을 잘못 썼나? 혹은 재시도(Retry) 과정에서 꼬였나? => 로그 깨끗함. 재시도 없음.
  • ⛏️ 삽질 3 (로직 의심): 코드의 Drop Duplicates가 안 먹혔나? => 아님. Spark가 읽어온 시점에 이미 중복 데이터가 포함되어 있었음.

3. 원인 분석: 범인은 partitionColumn에 있었다


범인은 바로 Spark JDBC Read 옵션 중 하나인 partitionColumn 설정이었다.

당시 해당 테이블은 적절한 숫자형 증분 PK(Sequence ID)가 없는 상황이었다.
대안으로 user_id를 고려했으나, 코인 거래소 특성상 소수의 ‘고래’나 ‘봇’이 막대한 트랜잭션을 일으키므로 Data Skew(데이터 쏠림)가 발생할 위험이 컸다. (특정 Executor만 늦게 끝나서 전체 Job 지연)

그래서 차선책으로 “시간은 누구에게나 공평하게 흐르니까, 시간 컬럼으로 쪼개면 데이터가 고르게 분포되겠지”라는 판단하에 updated_at을 파티션 키로 선택했다.

updated_at이 문제인가? (Spark의 동작 원리)

Spark는 데이터를 병렬로 읽기 위해 partitionColumn을 기준으로 데이터를 쪼갠다. 이때 중요한 건, Spark가 중복을 제거해 주는 게 아니라, 애초에 중복되지 않도록 범위를 나누어 쿼리를 던진다는 점이다.

  • Executor A: SELECT ... WHERE hash(updated_at) % 10 = 0
  • Executor B: SELECT ... WHERE hash(updated_at) % 10 = 1

이론상 하나의 Row는 하나의 해시 값만 가지므로, 한 곳에서만 조회되어야 한다. 하지만 여기에 DB 격리 수준이 개입하면 이야기가 달라진다.


💡 기초 지식: DB 격리 수준 (Isolation Level)

  • READ COMMITTED (Default): 커밋된 데이터만 읽지만, 한 트랜잭션 내에서도 조회 시점에 따라 값이 바뀔 수 있음. (Non-Repeatable Read)
  • REPEATABLE READ (MySQL 기본): 트랜잭션 시작 시점의 스냅샷을 보장함.
  • 🚨 Spark의 함정: Spark는 여러 Executor가 각자 독립된 커넥션(트랜잭션)을 맺는다. 즉, DB가 REPEATABLE READ여도 Executor A와 B는 서로 다른 시점의 스냅샷을 볼 수 있다.

[중복 발생 시나리오]

  1. Executor A가 쿼리를 날린다. 이때 Row X의 updated_at 해시값은 0이었다. -> A가 가져감.
  2. 찰나의 순간, 고객이 주문을 변경하여 Row X의 updated_at이 갱신되었다.
  3. Executor B가 쿼리를 날린다. 바뀐 updated_at의 해시값은 이제 1이 되었다. -> B도 가져감.

결국 Row X는 A와 B 모두의 조건(Filter)을 만족시키며 두 번 조회(Duplicate Select) 된다. Spark는 잘못이 없다. 그저 시키는 대로 쿼리를 날렸고, DB는 그 시점의 데이터를 줬을 뿐이다.

4. 해결: 역할의 분리 (Filter vs Partition)


이 문제를 겪으며 깨달은 핵심은 “데이터를 거르는 기준(Filter)”과 “데이터를 찢는 기준(Partition)”을 분리해야 한다는 것이다.

  • Filter (Pushdown Predicate): “무슨 데이터를 가져올 것인가?” (증분 추출용 updated_at)
  • Partition Column (Split): “데이터를 어떻게 나눠서 가져올 것인가?” (불변값이어야 함)

💡 Tip: Partition Key 선정 가이드

  1. Immutable: 절대 변하지 않는 컬럼이어야 한다. (필수)
  2. Even Distribution: 데이터가 고르게 퍼져 있어야 한다. (Skew 방지)
    • 숫자형 PK가 없다면, 차라리 created_at 같은 불변 시간 컬럼을 사용하는 것이 Skew와 중복을 모두 피하는 방법이다.

결국 파티션 키를 created_at(불변 시간값)으로 변경하여 데이터의 물리적 위치를 고정시켰고, 중복 이슈는 깔끔하게 해결되었다.

5. 결론


“변하는 것으로 기준을 세우지 말라.”

“Skew를 피하고 싶다”는 욕심에 “Immutable 해야 한다”는 대전제를 놓쳤던 것이 이번 트러블슈팅의 핵심이었다. 이후에는 DBA 분들과 협의하여 DB 인덱스 전략에 대해서도 더 깊게 고민하게 되었다. (이 이야기는 다음 편에…)


Posted

in

, ,

by

댓글 남기기

이 사이트는 Akismet을 사용하여 스팸을 줄입니다. 댓글 데이터가 어떻게 처리되는지 알아보세요.