데이터 규모 증가와 확장성¶
한 줄 요약: 데이터가 늘면 시스템은 한꺼번에 무너지지 않는다. 가장 약한 한 곳(병목)부터 깨진다. 그곳이 어디인지 미리 짚는 눈이 확장성 설계의 출발점이다.
📍 만난 상황¶
- 2026-11-10 — 아키텍처 설명을 준비하던 중이었다. 직전에 "정기 동기화는 CronJob, 큰 CSV 내보내기는 작업 큐 + 워커"라는 설명을 들었다. 그러자 더 먼 앞날이 궁금해졌다(13:28). "데이터 종류가 점점 많아지고 양도 계속 쌓이면 어떤 문제가 왜 생기고, 어떻게 조치하나?" 이어서 "FastAPI로 바꾸면 이 문제들이 사라지나?"(13:33)도 물었다. → 그날 일지
🧩 쉽게 말하면¶
동네 도서관이 시립 도서관으로 커진다고 해 보자. 책 종류가 늘면 사서가 새 책을 하나씩 등록하는 시간이 길어진다. 책 권수가 늘면 "2022년에 나온 강남구 책"을 찾을 때 서가 전체를 훑게 된다. 미리 연도별 서가(파티션)와 정렬 순서(정렬 키)를 정해 두지 않으면, 도서관이 클수록 찾는 시간도 끝없이 길어진다.
📖 제대로 알기¶
확장성과 병목¶
- 확장성(scalability: 데이터·사용자가 늘어도 성능을 유지하는 능력)은 "얼마나 빠른가"가 아니다. "늘어날 때 얼마나 덜 느려지는가"다.
- 병목(bottleneck: 전체 속도를 정하는 가장 좁은 구간)은 보통 한두 곳이다. 그곳을 고치면 다음 병목이 드러난다.
성장은 두 방향으로 나눠 본다¶
| 방향 | 예 | 먼저 깨지는 곳 |
|---|---|---|
| 종류(데이터셋 수) | 20종 → 수백 종 | 데이터셋마다 반복하는 일: 동기화, 분류, 검색 |
| 양(행 수) | 31만 행 → 수억 행 | 행을 훑는 일: 조회, 페이지 넘기기, 다운로드 |
둘을 섞어 생각하면 원인을 잘못 짚는다. "느려졌다"만 보지 말고 무엇이 늘었는지부터 본다.
오늘 나온 용어¶
- 증분 동기화(incremental sync: 지난번 이후 바뀐 것만 가져오기). 반대말은 매번 전부 가져오는 전체 동기화다.
- 파티션(partition: 표를 월별처럼 큰 덩어리로 나눠 저장하는 것). 조건에 맞지 않는 덩어리는 아예 열지 않는다.
- 정렬 키(sorting key: ClickHouse가 데이터를 이 순서로 정렬해 저장하는 열 묶음). 정렬돼 있으면 필요한 범위만 골라 읽는다.
- 건너뛰기 색인(data skipping index: 블록마다 "이 안에 어떤 값이 있나" 요약을 적어 둔 것). 요약에 없으면 그 블록을 건너뛴다.
- OFFSET 페이지네이션:
LIMIT 1000 OFFSET 40000처럼 "앞에서 4만 개 건너뛰고 1,000개". - 키셋 페이지네이션(keyset pagination: "마지막으로 본 값 다음부터 1,000개"). No Offset 방식이라고도 부른다.
⚙️ 어떻게 동작하나¶
flowchart TD
G["데이터가 늘어난다"] --> K["종류 증가<br/>20종 → 수백 종"]
G --> V["양 증가<br/>31만 → 수억 행"]
K --> K1["동기화가 수 분<br/>버튼 시간 초과"]
K --> K2["원하는 데이터를<br/>찾기 어려움"]
V --> V1["건수·미리보기 느려짐"]
V --> V2["CSV가 뒤로 갈수록 느려짐"]
V --> V3["큰 다운로드가 끊김"]
같은 "느려짐"이라도 갈래가 다르면 원인과 처방이 다르다. 아래에서 하나씩 본다.
1. 종류가 늘 때: 동기화가 길어진다¶
- 지금 동기화는 데이터셋마다 플랫폼을 2~3번씩 차례로 부른다. 20종에 수십 초다.
- 시간은 데이터셋 수에 정비례한다. 수백 종이면 수 분이 걸린다.
- 게다가 관리자 버튼은 요청-응답 방식이다. 중간 장치(프록시·로드밸런서)가 약 60초에서 연결을 끊는다.
- 처방은 세 가지다. ① CronJob으로 뒤에서 돌리기 ②
updated_at을 비교해 바뀐 것만 ③ 몇 개씩 동시에 호출.
2. 양이 늘 때: 조회가 표 전체를 읽는다¶
- 조건은
t_start,sgg_code,adm_code,h3_r9로 건다. - ClickHouse 표의 정렬 키·파티션이 이 조건과 맞지 않으면, 매번 표 전체를 읽고 나서 거른다.
- 31만 행일 땐 티가 안 난다. 수억 행이면 건수 하나 세는 데 수 초~수십 초가 된다.
- 처방은 표 설계다. 월 단위 파티션, 자주 거는 조건 순서의 정렬 키, 나머지 열에 건너뛰기 색인을 둔다.
3. 양이 늘 때: OFFSET은 뒤로 갈수록 느려진다¶
- DB는 "4만 번째 행"으로 바로 뛰지 못한다. 앞의 4만 행을 실제로 읽고 버린 뒤 1,000행을 준다.
- 그래서 쪽 번호가 커질수록 한 쪽을 받는 시간이 길어진다.
- 전체 다운로드로 보면 읽는 양이 행 수의 제곱에 비례한다(아래 예시의 숫자 참고).
- 키셋 방식은 "마지막 값보다 큰 것"을 조건으로 건다. 키에 색인이나 정렬 키가 있으면 바로 그 자리부터 읽어서 매 쪽 속도가 같다.
4. 양이 늘 때: 긴 다운로드가 끊긴다¶
- 플랫폼 SQL 창구는 한 번에 1,000행까지만 준다. 50만 행이면 호출이 500번이다.
- 요청 하나가 몇 분씩 이어지면 프록시 시간 제한, 배포, 와이파이 끊김에 그대로 잘린다.
- 처방은 요청과 작업을 떼는 것이다. "만들기 요청 → 워커가 파일 생성 → 다 되면 받기"(작업 큐).
🧪 예시¶
-
예시 1 · 일상에 빗대어: 책 5만 권을 1,000권씩 50번 나눠 옮긴다고 하자. OFFSET 방식의 일꾼은 매번 1번 책부터 센다. 10번째 묶음을 옮길 때는 9,000권을 세고 지나간 뒤 1,000권을 든다. 50번 동안 센 책을 다 더하면 1,000 × (1+2+…+50) = 약 127만 권이다. 실제로 옮긴 건 5만 권뿐이다. 키셋 방식의 일꾼은 "어디까지 옮겼는지" 책갈피를 꽂아 둔다. 센 책은 딱 5만 권이다. 50만 권이면 차이가 더 벌어진다. OFFSET은 약 1억 2,500만 권, 키셋은 50만 권이다.
-
예시 2 · 실제로는: 대시보드의
iterateRows가 1,000행씩 받는 상황이다(표 이름은 예시).① DB가 정렬된 앞부분 4만 행을 읽고 버린 뒤 1,000행을 준다. 쪽이 넘어갈수록 버리는 양이 늘어난다. ② 코드가 앞 쪽의 마지막-- ① OFFSET 방식: 41번째 쪽 SELECT * FROM disaster.norm_flood_trace ORDER BY row_key LIMIT 1000 OFFSET 40000; -- ② 키셋 방식: 앞 쪽의 마지막 row_key 다음부터 SELECT * FROM disaster.norm_flood_trace WHERE row_key > '앞 쪽의 마지막 row_key' ORDER BY row_key LIMIT 1000;row_key를 기억해 둔다. DB는 그 값 다음부터 바로 읽는다. ② 대신 "17쪽으로 바로 가기"는 못 한다. 그래서 순서대로 끝까지 받는 다운로드에 특히 맞다.
표 설계는 이렇게 생긴다(ClickHouse).
CREATE TABLE disaster.norm_example ( ... )
ENGINE = MergeTree
PARTITION BY toYYYYMM(t_start) -- ① 월별 덩어리
ORDER BY (sgg_code, t_start); -- ② 구 → 시각 순 정렬
⚖️ 비교 · 흔한 오해¶
| 처방 | 푸는 문제 | 못 푸는 문제 |
|---|---|---|
| 서버 늘리기(수평 확장) | 동시 사용자가 많음 | 쿼리 하나가 표 전체를 읽음 |
| 파티션·정렬 키 | 조회가 표 전체를 읽음 | 요청이 몇 분씩 이어짐 |
| 키셋 페이지네이션 | 뒤 쪽이 점점 느려짐 | 1,000행 제한으로 호출 수백 번 |
| 작업 큐 + 워커 | 긴 요청이 끊김 | 쿼리 자체의 느림 |
- 오해 1: "FastAPI로 바꾸면 사라진다." 오늘 실제로 이렇게 물었다. 답은 "대부분 그대로"다. 위 문제들은 언어가 아니라 데이터를 다루는 방식(차례로 호출, OFFSET, 표 설계, 요청-응답)에서 생긴다. 같은 방식으로 짜면 Python에서도 같은 곳에서 같은 문제가 난다.
- 왜 그렇게 생각하기 쉬운가: 느려지면 눈에 보이는 건 "우리 서버"뿐이다. 그래서 서버의 언어를 의심한다. 실제 시간은 대부분 ClickHouse 쿼리와 플랫폼 응답을 기다리는 데 쓴다. 서버 코드가 계산하는 시간은 아주 짧다.
- 오해 2: "서버를 늘리면 다 빨라진다." 서버를 10대로 늘려도 쿼리 하나가 표 전체를 읽는 건 그대로다. 느린 곳이 DB면 서버를 늘려도 안 빨라진다.
- 오해 3: "OFFSET은 건너뛰니까 공짜다." 이름 때문에 그렇게 보인다. 실제로는 읽고 버린다.
🛠️ 실무에서는¶
- 체크리스트
- 데이터셋마다 반복하는 일이 차례로 도는가? → 동시 호출 수를 정해 병렬로.
- 매번 전부 가져오는가? →
updated_at같은 기준으로 증분. - 자주 거는 조건(기간·지역)이 파티션·정렬 키와 맞는가?
- 끝까지 훑는 코드에
OFFSET이 있는가? → 키셋으로. - 1분 넘게 걸릴 수 있는 요청이 있는가? → CronJob이나 작업 큐로.
- 피할 것: 아직 31만 행인데 미리 다 바꾸는 것. 지금 규모에선 문제가 안 보인다. 병목이 될 곳만 메모해 두고, 수치로 느려지기 시작할 때 손댄다.
- 확인이 필요한 것: 플랫폼 미러 표의 파티션·정렬 키를 대시보드 쪽에서 정할 수 있는지. 표 설계는 플랫폼 쪽 권한이다.
- 문제가 생기면 먼저 볼 것
- 시간이 어디서 쓰이는지 쪼갠다. 우리 서버 계산, 플랫폼 왕복, 쿼리 실행 중 어디인가?
- 쿼리라면 읽은 행 수를 본다. 결과는 100행인데 읽은 행이 수억이면 표 설계 문제다.
- 쪽마다 시간을 찍어 본다. 뒤로 갈수록 늘면 OFFSET 문제다.
❓ 자주 묻는 질문¶
- Q. 지금 당장 바꿔야 하나? A. 아니다. 지금 규모(20종, 31만 행)에선 대부분 티가 안 난다. 다만 OFFSET → 키셋은 고치기 쉽고 효과가 확실해서 먼저 해 둘 만하다.
- Q. 키셋 방식이면 화면의 "3쪽, 4쪽" 버튼은 못 만드나? A. 임의의 쪽으로 바로 뛰는 건 어렵다. 그래서 화면 목록은 OFFSET, 끝까지 받는 다운로드는 키셋처럼 섞어 쓰는 경우가 많다.
- Q. 파티션을 잘게(하루 단위) 나눌수록 좋은가? A. 아니다. 덩어리가 너무 많으면 관리 비용이 커진다. ClickHouse에서는 보통 월 단위가 무난하다.
🔭 더 깊이 보기¶
- 쿼리가 실제로 몇 행을 읽었는지 어떻게 보나? — ClickHouse
EXPLAIN과 쿼리 로그를 보면 "느낌" 대신 숫자로 병목을 찾는다. - 증분 동기화에서 "지운 데이터"는 어떻게 알아채나? —
updated_at만으로는 삭제가 안 보인다. 다음에 막힐 지점이다. - 동시 호출은 몇 개까지가 안전한가? — 플랫폼이 감당할 수 있는 양을 넘기면 우리가 병목을 옮겨 놓은 것이 된다.
- 관련 주제: 작업 큐와 백그라운드 워커 · 서버 동시성 모델과 I/O 병목 · OLTP와 OLAP의 차이 · 메타데이터 카탈로그 기반 조회 · 데이터 파이프라인 실행과 갱신
📚 참고 자료¶
- 페이징 성능 개선하기 - 1. No Offset 사용하기 (국내 기술 블로그 · 기억보단 기록을) — OFFSET이 느린 이유와 키셋 방식으로 바꾸는 법을 한국어로 · 블로그
- 크론잡(CronJob) (Kubernetes 한국어 문서) — 정기 동기화를 뒤에서 돌리는 방법 · 링크
- MergeTree (ClickHouse 공식 문서, 영문) —
PARTITION BY,ORDER BY(정렬 키), 건너뛰기 색인 · 링크
🗓️ 이력¶
- 2026-11-10: 데이터 종류·양이 늘면 어디서 왜 깨지고 어떻게 조치하는지 물음 → 일지
🕸️ 연결된 주제¶
바로 이어진 주제 5개전체 지도에서 보기 →