Skip to content
목록으로

누적 : 작은 파일, 파티션, 스키마 진화

대규모 시스템2025년 9월 14일
누적 : 작은 파일, 파티션, 스키마 진화

저번 포스팅에서는, 정합성 문제를 공부하였습니다. 이번에는 시간이 지나며 쌓여서 생기는 문제들입니다. 처음에는 아무 문제가 없다가 몇 달, 몇 년이 지나면서 서서히 무너지는 것들이라, 만들 때는 보이지 않고 운영하면서 만나게 됩니다.

이 문제들의 공통점은 어제까지 멀쩡했다는 점입니다.


1. 작은 파일


데이터 플랫폼에서 가장 흔하고 가장 끈질긴 문제입니다.

1.1. 왜 쌓이는가

작은 파일은 누가 일부러 만드는 것이 아닙니다. 구조적으로 생깁니다.

상황. Flink 가 체크포인트 5분마다 Iceberg 테이블에 커밋합니다.

커밋 주기        5분   →  하루 288회
커밋당 생성 파일   평균 24개 (파티션 분포에 따라)
하루 생성         약 6,900개
6개월 누적        약 125만 개  (컴팩션 없이)
평균 파일 크기     42TB / 125만 = 약 34MB   (목표 256MB 의 7.5분의 1)

들어오는 양이 아니라 들어오는 횟수가 파일 수를 정합니다. 데이터가 적어도 자주 쓰면 파일은 많아집니다. 병렬 쓰기도 같습니다. 태스크 200개가 각자 파일을 쓰면 한 번에 200개가 생깁니다.

1.2. 무엇이 문제인가

파일 하나를 여는 데는 크기와 무관한 고정 비용이 있습니다.

1GB 파일 1개      여는 비용 1번   →  무시 가능
1MB 파일 1,000개  여는 비용 1,000번  →  전체를 지배

상황. 위 테이블에서 "어제 하루" 를 조회하면 이렇습니다.

파일 목록과 매니페스트 읽기(계획)   40초
실제 데이터 스캔                   8초
전체                              48초

컴팩션 후 (파일 17만 개, 스냅샷 7일치)
계획 3초 + 스캔 7초 = 10초

왜 위험한가. 데이터 양은 그대로인데 5배 가까이 느려집니다. 그리고 매일 조금씩 나빠지므로 "어느 날 나빠졌다" 고 말할 수 있는 날이 없습니다. 여섯 달 뒤에 돌아보면 이미 느려져 있고, 원인을 최근 변경에서 찾다가 헤맵니다.

여기에 더해 컬럼 포맷의 이점도 사라집니다. Parquet 은 파일 안에서 블록 단위 통계로 건너뛰는데, 파일이 작으면 블록도 작아 건너뛸 것이 없습니다. 압축률도 떨어집니다. 그리고 앞 편에서 본 대로 S3 요청 수와 비용이 함께 늘어납니다.

1.3. 메타데이터가 먼저 무너진다

더 심각한 쪽입니다. 데이터가 아니라 메타데이터를 관리하는 쪽이 먼저 한계에 부딪힙니다.

상황. 온프레 하둡 클러스터입니다.

파일 8,000만 개 중 1MB 미만    5,100만 개
그 파일의 용량 비중             18TB = 전체 1.4PB 의 1.3%
그 파일의 메타데이터 비중        약 27GB = 네임노드 힙 48GB 의 56%

용량으로는 1.3% 인데 메타데이터로는 56% 를 씁니다.

왜 위험한가. 디스크가 400TB 남았는데 파일을 더 못 만듭니다. 네임노드가 먼저 죽기 때문입니다. 그리고 힙이 차면 full GC 가 30초씩 걸려 클러스터 전체가 그동안 멈춥니다. 오브젝트 스토리지라면 목록 조회가 느려집니다. 접두사 아래 객체가 수십만 개면 목록을 받아오는 데만 한참 걸리고, 쿼리 엔진은 그것을 다 받아야 읽기를 시작합니다.

1.4. 어떻게 다루는가

  1. 쓰는 쪽을 줄입니다. 커밋 주기를 늘리거나, 쓰기 직전에 파티션을 합쳐 출력 파일 수를 줄입니다. 5분 커밋을 15분으로 바꾸면 파일이 3분의 1이 됩니다. 가장 싼 해법은 애초에 안 만드는 것입니다.
  2. 주기적으로 합칩니다. 이미 생긴 것은 컴팩션으로 정리합니다. Airflow 에 매일 도는 작업으로 걸어둡니다.
  3. 읽기 경로에서 합쳐 읽습니다. 작은 파일 여러 개를 한 태스크가 묶어 읽도록 설정합니다. 근본 해결은 아니지만 완충은 됩니다.

파일 크기 목표는 대체로 수백 MB 근처입니다. 너무 크면 병렬도가 떨어지고 너무 작으면 고정 비용이 커지는데, 그 사이 지점입니다.


2. 파티션이 너무 많아질 때


2.1. 잘게 나눌수록 좋아 보인다

파티션은 읽을 범위를 줄여주는 장치이므로, 처음에는 잘게 나눌수록 좋아 보입니다.

상황. 체결 테이블을 일자 + 종목코드로 파티션했습니다.

일자 500 x 종목 2,800   =  조합 140만 개
실제 존재하는 파티션      약 62만 개
파티션당 평균 파일        1.4개  →  총 파일 약 87만 개
파티션당 평균 크기        42TB / 62만 = 약 68MB
거래 없는 종목의 파티션    파일 하나가 3~20KB

파티션 조합은 곱으로 늘어납니다. 각 축은 선형으로 늘었는데 결과는 곱입니다.

2.2. 무엇이 무너지는가

파티션 조건 없는 조회
  파티션 목록 수신   62만 개, 약 90초
  실제 스캔          하루치 1.2GB, 약 6초
파티션 조건 있는 조회
  목록 수신 0.3초 + 스캔 6초

왜 위험한가.

  1. 조건 하나 유무로 96초와 6초가 갈립니다. 그리고 실수하기 쉽습니다.
  2. 그 쿼리 하나만의 문제가 아닙니다. 조건 없는 조회가 몇 개 동시에 들어오면 메타스토어 DB 의 커넥션과 CPU 가 소진되어 다른 모든 쿼리가 함께 느려집니다.
  3. 파티션마다 최소 하나의 파일이 생기므로 1장의 작은 파일 문제가 곱절로 악화됩니다.

2.3. 기준

  1. 카디널리티가 높은 컬럼으로 나누지 않습니다. 사용자 ID 나 주문 ID 로 나누면 파티션이 행 수만큼 생깁니다.
  2. 파티션 하나가 충분히 커야 합니다. 수백 MB 이하로 잘게 쪼개지면 나눈 이득보다 손해가 큽니다.
  3. 잘게 나누고 싶으면 파티션이 아니라 정렬을 씁니다.

어떻게 바꾸나.

변경 전   일자 x 종목  →  파티션 62만 개, 파일 87만 개, 목록 조회 90초
변경 후   일자만        →  파티션 500개, 파일 약 17만 개, 목록 조회 1초 미만
          종목은 정렬 키  →  종목 조건 조회 성능은 거의 유지 (존 맵으로 건너뜀)

종목별로 꼭 나눠야 한다면 해시로 묶어 버킷 수를 고정합니다. 2,800개가 아니라 32개 버킷이면 충분한 경우가 많습니다.


3. 스키마가 바뀔 때


3.1. 바뀌지 않는 스키마는 없다

상황. Iceberg 테이블 42TB, 파일 30만 개가 3년치 쌓여 있습니다. 그 사이 컬럼이 14개 추가됐고 타입이 두 번 바뀌었습니다. 오래된 파일에는 새 컬럼이 아예 없고, 바뀐 타입은 옛 형식으로 들어 있습니다.

컬럼이 추가되고, 타입이 바뀌고, 이름이 바뀝니다. 문제는 이미 쌓인 데이터는 옛 스키마로 되어 있다는 것입니다.

왜 위험한가. 한 번에 다 고칠 수 없다는 점이 위험합니다. 42TB 를 새 스키마로 다시 쓰려면 Spark 익스큐터 200개로도 반나절이 넘고, 그동안 읽는 쪽은 옛 데이터와 새 데이터를 동시에 보게 됩니다. 그래서 "쌓인 것은 그대로 두고 읽을 때 맞춰주는" 방식이 필요해집니다.

어떻게 푸나. 테이블 포맷의 스키마 진화를 씁니다. 아래에서 이어 정리합니다.

3.2. 호환성의 방향

  1. 하위 호환 : 새 스키마로 옛 데이터를 읽을 수 있는가.
  2. 상위 호환 : 옛 스키마로 새 데이터를 읽을 수 있는가.
  3. 완전 호환 : 양쪽 다 됩니다.

어느 방향이 필요한지는 누가 먼저 배포되느냐로 정해집니다. 생산자를 먼저 올린다면 상위 호환이, 소비자를 먼저 올린다면 하위 호환이 필요합니다.

상황. 주문 이벤트 스키마에 channel 필드를 추가하고 생산자를 먼저 배포했습니다.

생산자 배포     11:00
소비자 배포     11:30  (7개 서비스, Pod 68개)

그 사이 30초 x 60 = 1,800초
유입            초당 127건  →  약 23만 건
옛 스키마 소비자가 읽지 못한 메시지   23만 건
재시작 루프에 빠진 Pod               68개 중 52개

왜 위험한가. 30분 동안 23만 건이 처리되지 않고 랙으로 쌓이며, 소비자가 계속 죽었다 살아납니다. 그리고 작을 때는 이 창이 거의 0 이었습니다. 소비자가 1개 서비스 2개 Pod 였다면 동시에 바꿀 수 있었습니다. 서비스가 많아질수록 "전부 동시에 바꾸기" 가 불가능해지고 그 사이 구간이 생깁니다.

어떻게 푸나. 스키마 레지스트리에 호환성 규칙을 걸어 등록 시점에 거절되게 합니다. 깨지는 변경을 배포 시점이 아니라 등록 시점에 막는 것이 핵심입니다. 그리고 배포 순서를 함께 정합니다.

3.3. 가장 위험한 것은 의미 변경

  1. 대체로 안전 : 기본값이 있는 컬럼 추가, 컬럼 이름 변경(ID 로 추적하는 포맷), 타입 넓히기
  2. 위험 : 컬럼 삭제, 타입 좁히기, 필수 컬럼 추가
  3. 가장 위험 : 의미 변경

상황. amount 컬럼의 단위를 원에서 전으로 바꿨습니다.

왜 가장 위험한가. 컬럼 이름과 타입이 그대로라 어떤 자동 검사도 통과합니다. 스키마 레지스트리도, 타입 검사도, 데이터 품질 규칙도 전부 통과합니다. 그런데 집계 값이 100배 틀어집니다. 그리고 그 값으로 만든 리포트가 이미 배포된 뒤에 발견됩니다.

어떻게 막나. 기술로는 막을 수 없습니다. 새 컬럼을 만들고 옛 것을 폐기하는 방식으로만 안전합니다. amount_krw 를 두고 amount 를 단계적으로 없앱니다. 그리고 이런 변경은 사람 사이의 합의와 문서로만 통제된다는 것을 인정하고, 리뷰 절차에 넣습니다.

3.4. 위치가 아니라 ID 로 추적해야 한다

컬럼을 순서로 식별하는 포맷은 중간에 컬럼을 추가하거나 지우면 전체가 밀립니다. 현대적인 테이블 포맷은 컬럼마다 고유 ID 를 부여하고 그것으로 추적합니다.

무엇이 달라지나. 데이터 파일을 다시 쓰지 않고도 컬럼을 추가하고 이름을 바꿀 수 있습니다. 42TB 테이블에서 이 차이는 몇 초와 며칠의 차이입니다.


4. 다시 돌려야 할 때


4.1. 백필은 반드시 필요해진다

상황. 새 지표 컬럼 하나를 추가했습니다. 과거치를 채우려면 180일 구간, 압축 기준 6TB 를 다시 읽어 계산해야 합니다. 정규 배치가 매일 새벽 2시에 Spark 익스큐터 200개를 전부 쓰고 있습니다.

새 컬럼의 과거치를 채워야 하거나, 로직 버그를 고쳐 다시 계산해야 하거나, 장애로 빠진 구간을 메워야 합니다. "한 번 만들고 끝" 인 파이프라인은 없습니다.

왜 위험한가. 백필은 대체로 정규 파이프라인과 같은 자원을 놓고 다툽니다. 준비 없이 돌리면 그날 정규 배치가 밀리고, 밀린 배치가 다음 날 것과 겹치면서 며칠에 걸쳐 지연이 번집니다. 백필 때문에 멀쩡하던 파이프라인이 무너지는 쪽이 더 흔합니다.

어떻게 푸나. 백필을 예외 작업이 아니라 정규 기능으로 만들어둡니다. 구간을 인자로 받게 하고, 동시 실행 수를 제한하고, 별도 큐나 별도 클러스터에서 돌립니다. 아래에서 이어 정리합니다.

4.2. 백필이 운영을 무너뜨리는 방식

상황. 6개월치 백필을 Airflow 에 일자별 태스크로 걸었습니다.

대상 기간        180일
태스크당 익스큐터  200개
동시 실행 제한     설정하지 않음  →  180개 동시 요청

요청 익스큐터 총합  180 x 200 = 36,000개
클러스터 전체 용량  EKS 노드 60대 x 4 = 약 240개 익스큐터 분량
→ 자원의 150배를 요청

왜 위험한가.

  1. 정규 배치와 실시간 잡이 자원을 받지 못합니다. 백필이 운영을 멈춥니다.
  2. 실행된 일부가 같은 Iceberg 테이블에 동시 커밋을 시도해 재시도가 반복되고 기본 4회를 넘겨 실패합니다.
  3. 원본을 Aurora 에서 읽는다면 운영 DB 커넥션이 소진됩니다.
  4. 앞 편에서 본 대로 이용률이 한계에 가까워지며 대기 시간이 폭증합니다.

작을 때는 괜찮았습니다. 7일치라면 태스크 7개, 익스큐터 1,400개 요청입니다. 여전히 용량을 넘지만 대기하다 순차로 처리되어 티가 안 납니다.

어떻게 푸나.

  1. 동시 실행을 제한합니다. 4개로 제한하면 45회에 나눠 돌아갑니다.
  2. 전용 큐나 별도 클러스터로 분리해 정규 작업과 자원을 나눕니다.
  3. 속도를 제한합니다. 최대한 빨리 끝내는 것보다 운영에 영향을 주지 않는 속도로 꾸준히 미는 편이 낫습니다.

4.3. 다시 돌릴 수 있는 구조

  1. 구간 단위로 나눠 돌릴 수 있어야 합니다.
  2. 멱등해야 합니다. 앞 편의 내용입니다.
  3. 덮어쓰기 단위가 파티션과 일치해야 합니다. 날짜 파티션을 통째로 지우고 다시 쓰는 방식이 가장 안전합니다.
  4. 원본을 보존해야 합니다.

왜 미리 정해야 하나. 이 네 가지는 처음 설계할 때 정해집니다. 급해진 뒤에 붙일 수 없습니다. "다시 돌려야 하는데 돌릴 수가 없는" 상황을 만나지 않으려면, 만들 때부터 다시 돌릴 일을 전제로 두어야 합니다.

테이블 포맷의 스냅샷과 시점 조회가 여기서 도움이 됩니다. 백필이 잘못되었을 때 이전 스냅샷으로 되돌릴 수 있으면 위험이 크게 줄어듭니다.

4.4. 정규 파이프라인과 같은 코드를 써야 한다

백필용 스크립트를 따로 만들면, 그 스크립트와 정규 파이프라인의 로직이 서서히 달라집니다.

왜 위험한가. 백필한 구간과 평소 구간의 값이 미묘하게 다른 상황이 생기고, 원인을 찾기가 아주 어렵습니다. 두 코드가 같다고 믿고 있으므로 그쪽을 의심하지 않기 때문입니다.

어떻게 하나. 같은 코드에 처리 구간만 인자로 넘기는 형태로 만들어둡니다.


5. 무한정 쌓아둘 수는 없다


5.1. 보관 정책은 처음에 정해야 한다

상황. S3 에 42TB 가 쌓여 있고 매달 1.8TB 씩 늘고 있습니다. 이 중 어디까지가 아직 쓰이는 데이터인지 아무도 답하지 못합니다.

저장 비용이 싸다 보니 일단 다 쌓아두게 됩니다. 그런데 데이터는 쌓일수록 비용이 선형으로 늘지 않습니다.

  1. 저장 비용은 선형으로 늘지만
  2. 스캔 비용, 백업 시간, 목록 조회 시간, 메타데이터 크기가 함께 늘고
  3. 삭제 요구가 들어왔을 때 찾아서 지우는 비용도 늘어납니다.

왜 미리 정해야 하나. 나중에 정하려 하면 "지워도 되는지 아무도 모르는" 데이터가 이미 쌓여 있습니다. 그러면 못 지웁니다. 만든 사람이 퇴사했고, 쓰는 사람이 있는지 확인할 방법이 없으면 결국 남겨둡니다.

5.2. 계층을 나눈다

  1. 자주 보는 최근 데이터 : 빠른 저장소에 두고 쿼리 성능을 챙깁니다.
  2. 가끔 보는 과거 데이터 : 싼 저장소로 내리고 필요할 때만 조회합니다. Redshift Spectrum 이 이 용도였습니다.
  3. 거의 안 보는 것 : 아카이브 계층으로 내립니다. 꺼내는 데 시간이 걸리는 대신 아주 쌉니다.

무엇을 얻나. 저장 비용뿐 아니라 스캔 대상이 줄어 쿼리도 빨라집니다. 2년치 중 최근 3개월만 자주 본다면, 나머지를 내리는 것만으로 일상 쿼리의 대상이 8분의 1이 됩니다.

5.3. 지울 수 있게 만들어두어야 한다

상황. 개인정보 삭제 요구로 특정 고객의 데이터를 지워야 합니다.

대상 고객의 주문      2년치 약 4,200건
흩어진 파일           약 3,800개 (일자별로)
파일 평균 크기         256MB
다시 써야 할 총량       약 970GB
파일 하나 재작성        약 12초
순차 처리 시           약 12.7시간
락과 커밋 충돌 포함     실제 3~4일

왜 위험한가. 법적 기한이 있는 요구인데 며칠이 걸립니다. 그리고 그동안 그 테이블의 다른 작업과 충돌합니다. 데이터가 100GB 이고 파일이 400개였다면 한 시간이면 끝날 일입니다.

어떻게 대비하나.

  1. 어디에 있는지 찾을 수 있어야 합니다. 고객 ID 로 정렬해두면 대상 파일이 3,800개가 아니라 수십 개로 좁혀집니다.
  2. 행 단위 삭제를 지원하는 포맷을 씁니다. 삭제 표시만 남기고 정기 컴팩션에서 반영합니다.
  3. 보관 기간을 줄이면 대상 자체가 줄어듭니다. 2년을 1년으로 줄이면 절반입니다.
  4. 암호화 키로 지우는 방법도 있습니다. 사용자별 키로 암호화해두고 키를 폐기하면, 데이터는 남아 있어도 복호화할 수 없습니다.

6. 조금 더 들어가보기 : 왜 메타데이터가 먼저 무너지는가


이번 편에서 반복해 나온 것이 있습니다. 데이터보다 메타데이터가 먼저 한계에 부딪힙니다.

6.1. 데이터는 선형, 메타데이터는 건수에 비례하다

파일이 1TB 하나든 1MB 백만 개든 데이터는 1TB 로 같습니다. 그런데 메타데이터는 백만 배 차이가 납니다. 여기서 어긋남이 시작됩니다.

6.2. 메타데이터는 대체로 한곳에 모여 있다

상황. 데이터노드는 50대로 늘렸는데 네임노드는 여전히 한 대입니다. 저장 용량은 10배가 됐지만 "파일이 어디 있는지" 를 아는 쪽은 한 대 그대로입니다.

데이터는 노드마다 나눠 저장할 수 있습니다. 그런데 "무엇이 어디에 있는지" 를 아는 것은 나누기 어렵습니다.

  1. HDFS 의 네임노드는 한 대입니다.
  2. Hive 메타스토어 뒤의 관계형 DB 도 대체로 한 대입니다.
  3. 쿠버네티스의 etcd 는 모든 노드가 같은 내용을 갖습니다. 나누는 것이 아니라 복제입니다.

데이터는 수평 확장되는데 메타데이터는 그렇지 않습니다.

6.3. 메모리에 올려두어야 빠르다

상황. 네임노드 힙 48GB 에 파일 8천만 개의 위치가 전부 올라가 있습니다. 파일 하나를 읽을 때마다 여기에 물어야 하고, 이 요청이 초당 수만 건입니다.

메타데이터는 모든 요청 경로에 관여합니다. 파일을 읽으려면 먼저 위치를 물어야 하고, 쿼리를 계획하려면 파티션 목록을 받아야 합니다. 그래서 디스크에 두면 너무 느리고 메모리에 올려야 하는데, 메모리는 한 대의 물리적 한계에 묶입니다. 네임노드 힙이 파일 수의 한계가 되는 이유입니다.

6.4. 그래서 나온 해법들

상황. 파일 8천만 개로 네임노드 힙 48GB 가 한계에 가까워졌습니다. 장비를 키우는 것으로는 몇 배 이상 가기 어렵고, 파일 수는 계속 늘어납니다.

  1. 쪼갭니다. 페더레이션으로 네임스페이스를 나눠 여러 네임노드가 나눠 맡게 합니다.
  2. 읽기를 분산합니다. Observer NameNode 처럼 조회만이라도 다른 노드가 받게 합니다.
  3. 계층으로 만듭니다. 테이블 포맷들은 메타데이터 파일 → 매니페스트 목록 → 매니페스트 → 데이터 파일 처럼 층으로 나눕니다. 필요한 가지만 따라 내려가면 되고, 전체를 메모리에 들고 있지 않아도 됩니다.
  4. 아예 구조를 바꿉니다. Ozone 처럼 메타데이터 관리 자체를 분리해 설계합니다.

메타데이터를 나눌 수 있는 구조로 만드는 것이 결국 대규모에서의 확장성을 정합니다.


결론


이번 편을 정리하면서 가장 크게 남은 것은, 양이 아니라 횟수가 문제를 만든다는 점이었습니다. 작은 파일도, 파티션 폭증도, 메타데이터 한계도 전부 데이터 크기가 아니라 항목 개수에서 왔습니다. 용량 계획을 세울 때 테라바이트만 세고 파일 개수를 세지 않으면 엉뚱한 곳에서 막힌다는 것을 알게 되었습니다.

두 번째는 스키마 변경에서 가장 위험한 것이 의미 변경이라는 점이었습니다. 타입이나 컬럼 이름이 바뀌는 것은 도구가 잡아주는데, 단위가 바뀌거나 뜻이 바뀌는 것은 어떤 검사도 통과합니다. 결국 사람 사이의 합의와 문서화로만 막을 수 있는 영역이 남아 있다는 것이 인상 깊었습니다.

세 번째는 재처리를 나중에 붙일 수 없다는 사실이었습니다. 멱등성도, 구간 단위 실행도, 원본 보존도 전부 처음 설계할 때 정해집니다. 급해진 뒤에 "다시 돌려야 하는데 돌릴 수가 없는" 상황을 만나지 않으려면, 만들 때부터 다시 돌릴 일을 전제로 두어야 한다는 것을 배웠습니다.

마지막으로 메타데이터가 먼저 무너지는 이유를 따라가본 것이 유용했습니다. 데이터는 나눌 수 있는데 "무엇이 어디에 있는지" 는 나누기 어렵다는 비대칭이 여러 시스템에서 똑같이 나타났고, 그것을 푸는 방식도 쪼개기와 계층화로 수렴했습니다. 기술은 달라도 부딪히는 벽은 같다는 것을 다시 확인했습니다.


공부 참고 자료


📑 1. HDFS - Small Files Problem 과 Federation

📑 2. Apache Iceberg - Maintenance

📑 3. Apache Iceberg - Schema Evolution

📑 4. Apache Iceberg - Partitioning

📑 5. Confluent Schema Registry - Compatibility Types

📑 6. Apache Parquet - File Format

📑 7. Spark - Coalesce 와 Repartition

Comments