데이터 쏠림 : 스큐와 핫스팟
이번 포스팅부터는 대규모 데이터와 트래픽에서 실제로 터지는 문제들을 정리해보려 합니다. 지금까지 기술별로 공부를 해왔는데, 정리하면서 한 가지가 계속 걸렸습니다. 같은 모양의 문제가 기술만 바꿔가며 반복해서 나온다는 것이었습니다. Spark 에서 태스크 하나가 안 끝나는 것과, Kafka 에서 컨슈머 하나만 랙이 쌓이는 것과, Redshift 에서 슬라이스 하나만 바쁜 것은 이름만 다를 뿐 같은 이야기였습니다.
그래서 이번 시리즈는 기술이 아니라 증상을 기준으로 묶어보려 합니다. 첫 편은 쏠림입니다.
이 시리즈에서 가정하는 환경
숫자가 있어야 "우리 환경은 여기서 어디쯤인가" 를 가늠할 수 있으므로, 가상의 증권 데이터 플랫폼을 하나 잡아두고 시리즈 내내 같은 환경을 씁니다.
[수집] Kafka 3.6 브로커 6대 (8 vCPU / 64GB / gp3 2TB)
토픽 trade-events 파티션 48 복제 3 보관 7일
장중 평균 초당 3만 건, 개장 피크 초당 18만 건, 메시지 평균 1.2KB
하루 약 8.6억 건 / 압축 후 약 400GB
[처리] Flink 1.18 TaskManager 12대 (4 vCPU / 16GB) 병렬도 48 체크포인트 5분
Spark 3.5 on EKS 익스큐터 200개 (4 vCPU / 24GB) 드라이버 8GB
[저장] Iceberg on S3 trades 42TB 파일 약 30만 개 Glue 카탈로그
Redshift ra3.4xlarge 4노드 (슬라이스 32)
[레거시] 온프레 Hadoop 데이터노드 50대 (24코어 / 128GB / 4TB x 12)
네임노드 힙 48GB, HDFS 1.4PB, 파일 8,000만 개
[운영] EKS 노드 60대 Pod 약 2,400개 Airflow DAG 180개 Argo CD Application 340개
1. 증상
쏠림은 지표를 어떻게 보느냐에 따라 아예 안 보일 수도 있습니다.
- 클러스터 전체 CPU 는 34% 인데 작업이 안 끝납니다.
- 태스크 999개는 3분 만에 끝났는데 1개가 두 시간째 돌고 있습니다.
- 컨슈머 48개 중 2개만 랙이 분당 150만 건씩 쌓입니다.
- 평균 응답 시간은 20ms 인데 p99 가 3초입니다.
공통점이 있습니다. 평균을 보면 멀쩡합니다.
1.1. 왜 전체가 느려지는가
병렬 처리에서 전체 작업 시간은 가장 느린 하나가 결정합니다. 999개가 아무리 빨리 끝나도 1개가 두 시간이면 그 작업은 두 시간짜리입니다.
왜 위험한가. 느려지는 것만이 아닙니다.
- 자원이 함께 낭비됩니다. 위 예에서 익스큐터 199개가 800 vCPU 를 잡은 채 놀고 있습니다. 뒤에 대기 중인 잡이 있어도 그 자원을 못 씁니다.
- 뒤로 밀립니다. 야간 배치가 두 시간 늦어지면 그 결과를 쓰는 아침 리포트도 늦습니다.
- 증설로 해결되지 않습니다. 노드를 두 배로 늘려도 그 한 태스크는 여전히 두 시간입니다. 돈을 써도 안 풀리는 종류의 문제입니다.
혼자 뒤처지는 작업을 스트래글러(straggler) 라고 부릅니다.
2. 왜 쏠리는가
2.1. 현실의 데이터는 균등하지 않는다
가장 근본적인 이유입니다. 데이터를 나눌 때 우리는 은연중에 고르게 퍼질 것이라고 가정하는데, 실제 데이터는 거의 항상 한쪽으로 몰려 있습니다.
상황. 이 플랫폼의 실제 분포를 보면 이렇습니다.
전체 체결 중 상위 2종목 비중 38%
전체 주문 중 상위 10계좌 비중 21%
전체 API 호출 중 상위 3엔드포인트 64%
주문 테이블의 외래 키 중 특정 값 8.2% (시스템 계정)
이런 분포를 멱법칙 또는 지프 분포라고 부릅니다. 순위가 낮아질수록 빈도가 급격히 떨어지는 모양입니다.
왜 위험한가. 균등을 가정하고 용량을 계산하면 실제 피크를 크게 과소평가하게 됩니다. 파티션 48개에 초당 18만 건이면 파티션당 3,750건이라고 계산하는데, 실제로는 두 파티션이 각 3.4만 건을 받습니다. 9배 차이입니다.
어떻게 푸나. 설계 단계에서 키의 실제 빈도 분포를 먼저 확인합니다. 4장의 방법을 씁니다. 균등하지 않다면 5장의 기법으로 인위적으로 흩습니다.
2.2. 해시가 균등해도 소용없다
자주 나오는 오해입니다. "해시 함수가 균등하니까 고르게 퍼지지 않나요" 라는 질문입니다. 해시 함수는 서로 다른 입력을 고르게 흩어줍니다. 그런데 쏠림은 같은 입력이 너무 많을 때 생깁니다.
종목코드 005930 이 전체의 22%
해시 결과는 언제나 같은 값 → 언제나 같은 파티션
→ 22% 가 통째로 한 파티션으로
해시는 키의 종류를 흩어주는 것이지, 키의 빈도를 고르게 만들어주지 않습니다.
왜 위험한가. 이 오해가 있으면 문제를 엉뚱한 곳에서 찾습니다. 해시 함수를 바꿔보거나 파티션 수를 늘려보는데, 둘 다 효과가 없습니다. 파티션을 48개에서 480개로 늘려도 그 종목은 여전히 한 파티션에 들어갑니다.
어떻게 푸나. 키 자체를 바꿔야 합니다. 5.1 의 솔팅이 정확히 이 일을 합니다.
2.3. NULL 과 기본값
실무에서 가장 흔한 범인입니다.
상황. 체결 테이블의 account_id 에 특정 값이 몰려 있었습니다.
NULL 비중 0% (정상)
'SYS0000001' 비중 8.2% (자전거래·시스템 주문 계정)
행 수로는 8.6억 x 0.082 = 약 7,100만 건
왜 위험한가. 정상 값이라 품질 검사에 안 걸립니다. NULL 이었다면 데이터 품질 규칙에서 잡혔을 텐데, 실제로 존재하는 계정이라 아무 경고가 없습니다. 그래서 조인이 느린 이유를 한참 다른 곳에서 찾게 됩니다.
어떻게 푸나. 조인 키나 분산 키로 쓰는 컬럼은 상위 값의 비중을 반드시 확인합니다. -1, 0, unknown, 빈 문자열 같은 기본값도 함께 봅니다.
2.4. 카디널리티가 파티션 수보다 적을 때
상황. Redshift 분산 키를 거래 구분 코드로 잡았습니다. 값이 매수와 매도 두 가지입니다.
슬라이스 32개 중 데이터를 가진 것 2개
나머지 30개 영구히 빔
왜 위험한가. 노드를 늘려도 빨라지지 않습니다. 새로 생긴 슬라이스에도 데이터가 가지 않습니다. 비용만 늘고 성능은 그대로라, 증설 판단 자체가 틀어집니다.
어떻게 푸나. 분산이나 파티션 키는 값의 종류가 파티션 수보다 충분히 많은 컬럼으로 잡습니다. 카디널리티가 낮은 컬럼은 정렬이나 조건절에서 다룹니다.
2.5. 시간이 만드는 쏠림
상황. 일자로 파티션을 나눈 테이블입니다.
과거 499개 파티션 쓰기 0
오늘 1개 파티션 하루 8.6억 건 전부
증가하는 ID 나 타임스탬프를 키로 쓰면 항상 마지막 구간만 뜨겁습니다.
왜 위험한가. 읽기는 고르게 퍼지는데 쓰기가 한 곳에 몰립니다. 그 파티션을 담당하는 노드나 슬라이스만 계속 바쁘고, 나머지는 읽기만 합니다. 그리고 이 쏠림은 시간이 지나도 해소되지 않고 자리만 옮겨갑니다.
어떻게 푸나. 쓰기 대상 파티션 안에서 다시 나누는 방법을 씁니다. 해시를 접두사로 붙이거나 버킷을 함께 쓰면, 오늘 파티션 안에서도 여러 곳으로 흩어집니다.
3. 기술별로 이렇게 나타난다
쏠림은 특정 기술의 문제가 아닙니다. 데이터를 나눠 처리하는 모든 곳에서 같은 모양으로 나타나는데, 드러나는 지표와 대응이 다릅니다.
3.1. Kafka : 인기 종목 두 개가 파티션을 태운다
상황. 파티션 키가 종목 코드입니다. 같은 종목의 체결이 순서대로 처리되어야 하기 때문입니다.
전체 체결 초당 180,000 건 (개장 피크)
상위 2종목 비중 38% → 초당 68,000 건
그 종목이 배정된 파티션 2개 → 각 34,000 건/초
나머지 46개 파티션 각 2,400 건/초
파티션 하나의 처리 능력 초당 약 8,000 건
→ 46개는 30% 사용, 2개는 4배 초과
왜 위험한가. 전체 CPU 는 34% 라 여유가 있어 보입니다. 그런데 그 두 파티션의 랙이 분당 150만 건씩 쌓이고, 대시보드에서 그 두 종목의 실시간 거래대금만 5분씩 밀립니다. 가장 많이 보는 종목의 데이터가 가장 늦게 도착합니다. 그리고 보관 기간 7일 안에 따라잡지 못하면 데이터를 잃습니다.
어떻게 확인하나. 컨슈머 랙을 전체 합이 아니라 파티션별로 봅니다. 두어 개만 튀어 있으면 이 상황입니다.
어떻게 푸나. 상위 종목만 005930#0 ~ 005930#7 처럼 8개로 쪼개 48개 파티션에 흩고, 종목 단위 집계는 최종 단계에서 합칩니다. 순서가 꼭 필요한 처리는 종목 상태에서 다루고 나머지는 흩습니다.
3.2. Spark : 태스크 1개가 두 시간을 잡아먹는다
상황. 하루치 체결 8.6억 건과 계좌 마스터 1,200만 건을 account_id 로 조인합니다.
전체 태스크 1,000개
3분 안에 끝난 것 999개
두 시간째 도는 것 1개
그 태스크의 셔플 읽기 38GB (중앙값 340MB, 112배)
원인 'SYS0000001' 이 8.2% → 7,100만 건이 한 파티션으로
익스큐터 실행 메모리 약 14GB → 38GB 가 안 들어감 → 스필 24GB
왜 위험한가. 배치가 두 시간 늦어지는 것에 더해, 익스큐터 199개가 놀면서 자원을 잡고 있습니다. 그리고 그 태스크는 스필까지 겹쳐 GC 를 반복하다 OOM 으로 죽을 수도 있습니다. 죽으면 재시도하는데 같은 조건이라 또 죽습니다.
어떻게 확인하나. Spark UI 의 스테이지에서 태스크 지속 시간과 셔플 읽기 크기의 최대값과 중앙값 비율을 봅니다. 10배를 넘으면 쏠림입니다.
어떻게 푸나. 적응형 실행이 켜져 있어도 키 하나가 통째로 큰 경우는 못 쪼갭니다. 그 계정만 따로 뽑아 처리하고 나머지와 합치거나, 계좌 마스터가 2GB 정도라면 브로드캐스트로 바꿔 셔플 자체를 없앱니다.
3.3. Redshift : 슬라이스 32개 중 2개만 일한다
상황. 일별 체결 요약 테이블 18억 행, 약 2.4TB. 분산 키가 거래 구분 코드입니다.
데이터를 가진 슬라이스 2개 (각 1.2TB)
skew_rows 16.0
집계 쿼리 소요 14분 (고르게 분산 시 예상 1분 이내)
왜 위험한가. 2.4 에서 본 대로 증설이 듣지 않습니다. 그리고 두 슬라이스가 각 1.2TB 를 처리하느라 디스크 스필이 발생해, 다른 쿼리의 임시 공간까지 잠식합니다.
어떻게 확인하나. SVV_TABLE_INFO 의 skew_rows 를 봅니다. 1에 가까우면 고르고, 크면 몰린 것입니다.
어떻게 푸나. 분산 키를 account_id 처럼 카디널리티가 높고 조인에 쓰이는 컬럼으로 바꿉니다. 다만 2.4TB 테이블을 다시 써야 하므로, 처음 설계가 오래간다는 점을 함께 느끼게 되는 지점입니다.
3.4. 쿠버네티스 : Pod 를 3배로 늘렸는데 그대로다
상황. 시세 조회 API 를 gRPC 로 제공합니다. Pod 를 4개에서 12개로 늘렸습니다.
확장 전 Pod 4개, 클라이언트 연결 20개
확장 후 Pod 12개, 기존 연결 20개는 그대로 처음 4개에 붙어 있음
새로 뜬 8개 연결 0개
처리량 초당 12,000 → 12,400 (3% 증가)
CPU 기존 4개 85%, 새로 뜬 8개 2%
왜 위험한가. 여기서는 데이터가 아니라 연결이 쏠립니다. 원인이 애플리케이션이 아니라 커널의 연결 추적에 있으므로, 애플리케이션 지표만 보면 왜 안 늘어나는지 설명할 수 없습니다. 그리고 자원만 3배 쓰고 효과가 없으니 비용이 그대로 늘어납니다.
어떻게 확인하나. Pod 별 요청 수를 봅니다. 배포 직후에는 고르다가 시간이 지날수록 기우는 모양이면 이것입니다.
어떻게 푸나. 클라이언트가 주기적으로 연결을 다시 맺게 하거나, 서비스 메시나 L7 프록시를 두어 요청 단위로 분산합니다.
4. 어떻게 찾는가
4.1. 평균을 버리고 분포를 본다
가장 먼저 바꿔야 할 습관입니다. 평균은 쏠림을 완벽하게 숨깁니다.
파티션 48개, 전체 초당 18만 건
평균 3,750 건/초 → "여유 있음"
실제 최대 34,000 건/초 → "9배 초과"
봐야 할 값은 이렇습니다.
- 최대값과 중앙값의 비율. 1에 가까우면 고르고, 크면 쏠린 것입니다.
- p99 와 p50 의 차이. 응답 시간 쪽에서 같은 역할을 합니다.
- 파티션별 크기 히스토그램. 눈으로 보는 것이 가장 빠릅니다.
왜 중요한가. 이 지표가 없으면 문제가 있다는 사실 자체를 모릅니다. 대시보드가 전부 초록색인데 사용자만 느리다고 하는 상황이 여기서 나옵니다.
4.2. 키 빈도를 직접 센다
상황. Iceberg 주문 테이블 42TB, 12억 건입니다. 조인이 유독 느려 join_key 의 분포를 재봤더니 상위 3개 값이 전체의 38% 를 차지하고 있었습니다. 이 정도면 셔플 뒤 파티션 하나가 4억 건 이상을 받습니다.
의심되는 컬럼의 상위 값과 비중을 직접 확인합니다.
SELECT join_key, COUNT(*) AS cnt,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 2) AS pct
FROM big_table
GROUP BY join_key
ORDER BY cnt DESC
LIMIT 20;
상위 몇 개가 전체의 몇 퍼센트인지를 보면 답이 바로 나옵니다. NULL 을 빠뜨리지 않는 것이 중요합니다. 데이터가 커서 전수 조사가 부담이면 표본을 뽑거나, Count-Min Sketch 나 HyperLogLog 처럼 적은 메모리로 빈도와 카디널리티를 추정하는 자료구조를 씁니다.
4.3. 실행 계획을 확인한다
상황. Redshift ra3 4노드, 슬라이스 32개입니다. 쿼리가 평소 40초에서 11분으로 늘어 슬라이스별 처리량을 봤더니 한 슬라이스가 4.2GB, 나머지는 100MB 안팎이었습니다. 숫자만 봐도 재분배가 한쪽으로 몰렸다는 뜻입니다.
Spark 라면 Spark UI 에서 스테이지의 태스크 시간과 셔플 읽기 크기 분포를 봅니다. Redshift 라면 SVV_TABLE_INFO 의 스큐 지표와 실행 계획의 재분배 방식을 봅니다.
무엇을 보나. 실행 계획에 방송(broadcast)이 보이는데 그 테이블이 크다면, 데이터가 통째로 복사되고 있다는 뜻입니다. 재분배가 보이면 네트워크 비용이 발생하고 있다는 뜻입니다.
5. 어떻게 푸는가
해법은 크게 네 갈래입니다. 키를 바꾸거나, 나누거나, 따로 처리하거나, 아예 옮기지 않는 것입니다.
5.1. 솔팅
가장 널리 쓰이는 방법입니다. 쏠리는 키에 임의의 접미사를 붙여 인위적으로 쪼갭니다.
005930 → 005930#0, 005930#1, ... 005930#7
한 키가 8개로 나뉘어 8개 파티션에 퍼짐
초당 34,000 건 → 각 4,250 건
대가가 있습니다. 조인 상대편도 그만큼 복제해야 합니다. 상대 테이블의 각 행을 #0 부터 #7 까지 8벌로 늘려야 짝이 맞습니다. 집계라면 두 단계로 나뉩니다. 먼저 쪼갠 단위로 집계하고, 그 결과를 다시 원래 키로 합칩니다.
언제 쓰나. 전부에 걸 필요는 없습니다. 문제가 되는 상위 몇 개 키에만 적용하고 나머지는 그대로 두는 방식이 효율적입니다.
5.2. 두 단계 집계
솔팅과 같은 발상인데 더 일반적입니다. 옮기기 전에 미리 줄이는 것입니다.
1억 건이 키 하나로 몰려도
노드별로 미리 합쳐두면 넘어가는 것은 노드 수만큼
200개 익스큐터 → 네트워크로 가는 것은 200건
MapReduce 의 컴바이너가 정확히 이것이었습니다. 네트워크로 옮기는 양 자체를 줄이기 때문에 쏠림이 있어도 견딜 수 있게 됩니다.
언제 못 쓰나. 부분 집계가 가능한 연산에만 됩니다. 합계나 개수는 되지만 중앙값처럼 전체를 봐야 하는 연산에는 쓸 수 없습니다.
5.3. 뜨거운 키를 따로 뺀다
상황. 12억 건 주문에서 상위 5개 판매자가 31% 를 차지합니다. 솔팅으로 흩으면 이 5개 때문에 나머지 수백만 개 키까지 모두 두 단계를 거치게 됩니다. 문제를 일으키는 쪽이 명확히 소수일 때 쓰는 방법입니다.
상위 몇 개가 문제라면 그것만 다른 경로로 처리합니다.
- 조인에서 뜨거운 키만 뽑아 브로드캐스트 조인으로 처리하고, 나머지는 일반 조인으로 처리한 뒤 합칩니다.
- 캐시에서는 뜨거운 키를 각 노드에 복제해 로컬 캐시로 둡니다.
- 트래픽이라면 그 키만 전용 자원으로 분리합니다.
대가. 처리 경로가 둘로 갈리므로 코드가 복잡해지고, 뜨거운 키 목록을 계속 갱신해야 합니다. 어제의 인기 종목이 오늘도 인기라는 보장이 없습니다.
5.4. 옮기지 않도록 설계한다
상황. Redshift 팩트 테이블 8억 건을 차원 테이블과 조인하는데, 분산 키가 조인 키와 달라 실행할 때마다 슬라이스 32개 사이로 2.1TB 가 오갔습니다. 옮기는 데만 6분 넘게 쓰고 있었습니다.
가장 근본적인 해법입니다. 앞서 본 Redshift 의 분산 키가 이것이었습니다. 조인 키로 함께 분산해두면 애초에 데이터를 옮길 일이 없습니다. 옮기지 않으면 쏠릴 일도 없습니다. Spark 의 브로드캐스트 조인도 같은 맥락입니다. 작은 쪽을 통째로 복사해두면 큰 쪽을 재분배하지 않아도 됩니다.
대가. 특정 조인에 맞춘 최적화이므로, 조인 패턴이 바뀌면 설계도 다시 봐야 합니다. 그리고 큰 테이블을 다시 쓰는 작업이라 바꾸기가 쉽지 않습니다.
5.5. 엔진이 알아서 해주는 것들
최근 엔진들은 쏠림을 실행 중에 감지해 대응합니다.
- Spark 의 적응형 실행 : 셔플이 끝난 뒤 실제 파티션 크기를 보고 지나치게 큰 파티션을 자동으로 쪼갭니다.
- Tez 의 병렬도 자동 조정 : 앞 단계의 출력 크기를 보고 다음 단계의 병렬도를 정합니다.
한계를 알아야 합니다. 자동 처리는 "여러 키가 섞여 커진 파티션" 만 나눌 수 있습니다. 3.2 의 사례처럼 키 하나가 통째로 7,100만 건이면 더 쪼갤 수 없습니다. 같은 키는 같은 곳에 가야 하기 때문입니다.
"적응형 실행을 켜뒀으니 괜찮다" 고 생각하는 것이 가장 위험합니다. 켜두되 실제로 듣고 있는지는 지표로 확인해야 합니다.
6. 왜 작을 때는 보이지 않는가
지금까지 본 문제들은 작은 환경에서는 존재하지 않습니다. 개발 노트북에서도 멀쩡히 돕니다. 왜 규모가 커지면 나타나는지를 정리해두면, 사례들이 "특정 기술의 버그" 가 아니라 규모가 만든 필연으로 읽힙니다.
6.1. 늘어나는 방식이 선형이 아니다
- 노드 간 통신은 제곱에 가깝습니다. 4대의 전체 교환은 12갈래인데 40대면 1,560갈래입니다. 10배 늘렸는데 130배입니다.
- 파티션 조합은 곱입니다. 일자 500 × 종목 2,800 이면 140만 개입니다.
- 큐 대기는 이용률이 100%에 가까워질수록 발산합니다. 부하가 1.8배 늘 때 대기가 9배가 되는 구간이 실재합니다.
작을 때는 이 비선형 구간에 도달하지 못합니다.
6.2. 상수 비용이 어느 순간 지배한다
1GB 파일 1개 여는 비용 1번 + 읽기 1GB → 여는 비용 무시 가능
1MB 파일 1,000개 여는 비용 1,000번 + 읽기 1GB → 여는 비용이 전체를 지배
읽는 데이터는 똑같습니다. 바뀐 것은 개수뿐인데 작업 시간이 완전히 달라집니다.
6.3. 드물던 일이 상시가 된다
초당 3만 건 x 1만 건당 1회 실패 = 초당 3건, 하루 26만 건
개발 환경 초당 10건이면 1,000초에 한 번 → 테스트 중 한 번 볼까 말까
같은 확률인데 규모가 그것을 상시 현상으로 바꿉니다. 예외 경로가 부실해도 작은 환경에서는 티가 안 나는 이유입니다.
6.4. 여유가 흡수해주던 것이 사라진다
상황. 데이터노드 5대에 2TB 를 두고 돌리던 시절에는 같은 배치가 12분이면 끝났습니다. 50대 42TB 가 된 지금 같은 코드가 4시간을 넘깁니다. 코드는 그대로인데 시간만 20배가 됐습니다.
작은 환경에는 여유가 많아 비효율이 흡수됩니다. 파티션이 쏠려도 데이터가 적으면 금방 끝나고, 메모리를 낭비해도 남아돕니다. 규모가 커지면 이 여유가 사라지고, 그동안 가려져 있던 설계 결함이 한꺼번에 드러납니다. "지난달까지 잘 돌았는데" 라는 말이 자주 나오는 이유입니다.
왜 위험한가. 이 결함은 한꺼번에, 그리고 가장 바쁠 때 드러납니다. 여유가 흡수해주는 동안에는 지표가 정상이라 미리 알아챌 방법이 없고, 임계를 넘는 순간 여러 개가 동시에 터집니다. 원인을 하나로 좁히기 어려워 장애 시간이 길어집니다.
어떻게 대비하나. 여유가 아니라 비율을 봅니다. 평균 처리 시간 대신 최대/중앙값 비율, 사용률 대신 대기 시간을 지표로 잡습니다. 이 값들은 데이터가 적을 때도 이미 나빠져 있어서, 규모가 커지기 전에 결함을 보여줍니다.
6.5. 나눌 수 없는 것이 상한을 만든다
상황. 네임노드 힙 48GB 에 파일 8천만 개가 올라가 있습니다. 블록 하나당 메타데이터가 150바이트 안팎이니 이미 힙의 상당 부분을 쓰고 있고, 데이터노드를 100대로 늘려도 이 숫자는 줄지 않습니다.
네임노드, 메타스토어, etcd, Spark 드라이버, Iceberg 카탈로그가 전부 그렇습니다. 작을 때는 이것들이 여유롭습니다. 규모가 커져야 비로소 이 하나가 전체의 한계가 됩니다.
왜 위험한가. 나머지를 아무리 늘려도 전체 용량이 이 하나에 묶입니다. 그리고 이 지점은 대체로 단일 장애 지점이기도 해서, 한계에 닿으면 성능 저하가 아니라 클러스터 전체 정지로 이어집니다.
어떻게 푸나. 그 하나에 들어가는 일의 양 자체를 줄입니다. 작은 파일을 합쳐 메타데이터 건수를 낮추고, 조회는 Observer NameNode 같은 읽기 전용 노드로 빼고, 그래도 부족하면 페더레이션으로 네임스페이스를 쪼갭니다. 자세한 내용은 뒤에서 다시 정리합니다.
6.6. 그래서 숫자와 함께 봐야 한다
"작은 파일이 많으면 나쁘다" 는 말보다 "파일 8천만 개에 네임노드 힙 48GB 면 위험하다" 는 말이 훨씬 쓸모 있습니다. 앞의 것은 방향만 알려주고, 뒤의 것은 지금 우리 환경이 어디쯤인지 가늠하게 해주기 때문입니다.
7. 조금 더 들어가보기 : 나누는 방법 자체의 문제
지금까지는 데이터 쪽 쏠림이었습니다. 그런데 나누는 방법 자체가 쏠림을 만드는 경우도 있습니다.
7.1. 나머지 연산의 함정
가장 단순한 분배는 hash(key) % N 입니다. 잘 동작하지만 N 이 바뀌는 순간 거의 모든 키의 자리가 바뀝니다.
왜 위험한가. 노드가 4대에서 5대가 되면 대부분의 키가 다른 노드로 옮겨갑니다. 캐시라면 적중률이 거의 0 이 되어 원본 저장소로 요청이 한꺼번에 몰립니다. 저장소라면 데이터를 전부 옮겨야 합니다. 증설이 곧 장애가 되는 구조입니다.
7.2. 일관 해싱
상황. 캐시 노드 12대에 키 4억 개를 hash % 12 로 나눠 두고 있었습니다. 한 대를 늘려 13대가 되는 순간 키의 92% 가 다른 노드로 옮겨갑니다. 그 순간 캐시 적중률이 8% 로 떨어지고, 뒤에 있는 DB 가 평소의 12배 요청을 받습니다.
이 문제를 푸는 것이 일관 해싱입니다. 키와 노드를 모두 하나의 원 위에 해싱해 배치하고, 키는 시계 방향으로 만나는 첫 노드가 담당합니다. 노드가 하나 빠지면 그 노드가 맡던 구간만 다음 노드로 넘어갑니다.
다만 노드를 원 위에 그냥 뿌리면 구간 크기가 들쭉날쭉해집니다. 그래서 가상 노드를 씁니다. 물리 노드 하나를 원 위 여러 지점에 올려두면 구간이 평균화됩니다.
7.3. 두 개 중 덜 바쁜 쪽 고르기
상황. EKS 60노드에 Pod 2,400개가 떠 있고, 요청은 초당 3만 건입니다. 모든 노드의 부하를 매번 확인하면 요청 하나마다 60번을 물어야 하고, 무작위로 하나만 고르면 이미 바쁜 노드에 또 얹게 됩니다.
부하 분산에서 쓰이는 기법입니다. 무작위로 하나를 고르면 운 나쁘게 바쁜 곳이 걸릴 수 있고, 모든 노드를 확인하는 것은 비쌉니다. 절충안이 무작위로 두 개를 고른 뒤 둘 중 덜 바쁜 쪽을 고르는 것입니다. 확인 비용은 거의 그대로인데 최대 부하가 크게 줄어듭니다.
7.4. 스트래글러를 기다리지 않는 방법
쏠림을 없앨 수 없다면 느린 것을 기다리지 않는 방법도 있습니다. 추측 실행은 눈에 띄게 느린 태스크의 복사본을 다른 노드에서 함께 돌리고 먼저 끝나는 쪽을 씁니다.
언제 듣고 언제 안 듣나. 노드 고장이나 디스크 문제처럼 환경 때문에 느린 경우에는 효과가 좋습니다. 그런데 데이터가 몰려서 느린 경우에는 소용이 없습니다. 복사본도 같은 7,100만 건을 처리해야 하므로 자원만 두 배로 씁니다.
그래서 스트래글러의 원인이 환경인지 데이터인지를 구분하는 것이 먼저입니다. 같은 태스크가 노드를 바꿔가며 매번 느리다면 데이터 쪽입니다.
결론
쏠림을 정리하면서 가장 크게 남은 것은, 평균을 보는 습관이 문제를 숨긴다는 점이었습니다. 클러스터 CPU 30%, 평균 응답 20ms 같은 숫자는 아무 문제가 없어 보이는데, 그 안에서 하나가 죽어가고 있을 수 있습니다. 지표를 고를 때부터 최대와 중앙값의 비율처럼 분포를 드러내는 것을 함께 두어야 한다는 것을 배웠습니다.
두 번째는 해시가 균등해도 쏠린다는 사실이었습니다. 해시 함수가 흩어주는 것은 키의 종류이지 빈도가 아니라는 구분을 하고 나니, 왜 좋은 해시를 써도 문제가 남는지가 설명되었습니다. 그리고 결국 현실의 데이터가 고르지 않다는 전제에서 출발해야 한다는 결론에 이르렀습니다.
세 번째는 해법이 대부분 "옮기는 양을 줄이는 쪽" 으로 모인다는 점이었습니다. 솔팅도, 두 단계 집계도, 분산 키 설계도, 브로드캐스트 조인도 결국 네트워크를 타는 데이터를 줄이는 일이었습니다. 병렬 처리에서 가장 비싼 것이 계산이 아니라 이동이라는 사실이 여기서도 반복되었습니다.
마지막으로 자동화된 대응의 한계를 알아둔 것이 유용했습니다. AQE 가 있으니 신경 안 써도 된다고 생각하기 쉬운데, 키 하나가 거대한 경우에는 엔진도 어쩌지 못합니다. 도구가 무엇까지 해주고 어디서부터 사람의 설계가 필요한지를 아는 것이 결국 실력이라는 생각이 들었습니다.
공부 참고 자료
📑 1. Spark - Adaptive Query Execution
📑 2. Kafka - Producer Partitioning
📑 4. Consistent Hashing and Random Trees (원 논문)