AI 와 함께 Pogstres 데이터베이스 테이블 파티셔닝하기

Sangwoo Park·2026년 4월 4일

개요

2억 건짜리 테이블이 있었다. 인덱스가 11개였고, 크기는 데이터(25 GB)의 3.3배인 82 GB였다. 어느 시점부터 이 테이블의 쿼리 CPU 사용량과 읽기 대기 시간이 점점 증가하는 게 모니터링에 잡히기 시작했다. 데이터는 앞으로도 계속 늘어날 예정이었다.

비즈니스 로직 특성상 한 유저는 자신이 지금까지 생성한 모든 데이터를 조회하고 페이지네이션 할 수 있어야 했다. 파티셔닝 없이는 그 쿼리가 수억 건의 데이터를 풀스캔할 수밖에 없었다.

user_id 기준으로 HASH 파티셔닝을 적용하면 그 쿼리가 파티션 중 1개만 스캔하게 된다.

32개 파티션으로 파티셔닝을 진행했고, 인덱스도 불필요한 것들을 정리해 11개 -> 5개로 줄였다.

결과적으로 DB 피크 부하가 50% → 30% 로 내려갔고, 부하 패턴이 불규칙했었는데 예측 가능해졌다. 앞으로 트래픽이 더 늘어도 감당할 수 있는 구조가 됐다. 이 글은 그 과정을 기록한다.

데이터베이스 테이블명은 회사 정보 유출 방지를 위해 대체한 이름으로 표기했다.


AI에게 물었다

관계형 데이터베이스 테이블들의 구조를 관찰하다가 AI에게 물었다.

"users, events, event_participants 테이블이 있고, 한 유저가 자신이 참여한 모든 이벤트를 조회해야 합니다. 1000만 명의 유저와 10만 개의 이벤트를 안정적으로 서빙하려면 어떤 구조로 데이터베이스를 설계해야 할까요?"

"HASH 파티셔닝" 이라는 말이 눈에 들어왔다.

파티셔닝 자체는 알고 있었다. 서비스에서 이미 날짜 기준으로 쪼개는 테이블이 있었기 때문이다. 근데 그게 RANGE 파티셔닝이라는 걸 이번에 알았다. HASH 파티셔닝이 있다는 것도 이번에 처음 알았다.

개념은 간단했다. user_id를 해시해서 32개 파티션에 고르게 분산시킨다. 한 유저의 데이터는 항상 같은 파티션에만 들어간다. 그러면 그 유저의 이벤트를 조회할 때 32개 파티션 중 1개만 스캔하면 된다.

다음 문제는 어떻게 전환하느냐였다. 라이브 중인 테이블을 파티셔닝 구조로 변경하는 방법은 없다고 한다. 기존 테이블을 수정하는 게 아니라, 새로운 파티션 테이블을 만들고 거기에 데이터를 옮겨야 한다. 서비스를 멈추지 않으면서.


사전준비

방향은 잡혔는데, 바로 실행에 옮기기 전에 현황을 제대로 파악해야 했다.

소스코드 전체를 훑어서 이 테이블에 접근하는 모든 쿼리를 취합했다.
AWS RDS 성능 개선 도우미와 Datadog을 사용하고 있었어서 실제로 얼마나 자주, 어떤 패턴으로 쿼리가 나가는지 분석하기 용이했다.
파티셔닝 후 프루닝이 제대로 작동하려면 파티션 키(user_id)가 WHERE 절에 직접 포함된 쿼리와 그렇지 않은 쿼리를 모두 파악해두어야 했다.

파티션 개수도 결정해야 했다. 16개와 32개를 놓고 고민했다.
파티션이 많을수록 각 파티션이 작아져 스캔 효율은 올라가지만, 인덱스 관리나 VACUUM 같은 유지보수 오버헤드도 같이 늘어난다.
현재 데이터 규모와 앞으로의 성장 속도를 고려했을 때 32개가 적정선이라고 판단했다.


계획

전체 과정을 8개 Phase로 나눴다.

Phase 0: 사전 검증
Phase 1: 신규 테이블 생성 + 인덱스 설계
Phase 2: 백필 — 2억 건 이관
Phase 3: 듀얼라이팅 — 실시간 동기화 시작
Phase 4: 누락 데이터 재동기화
Phase 5: 정합성 검증 + 성능 벤치마크
Phase 6: 테이블 전환 (RENAME)
Phase 7: 롤백 계획 유지 (24시간)
Phase 8: 안정화 확인 후 정리

실제 라이브 전환 전에 개발 환경에서 전체 과정을 먼저 돌려봤다.
그리고 예상치 못한 돌발 상황과 대응책을 AI와 상담하며 시나리오를 정리해뒀다.
라이브하는 서비스의 DB를 마이그레이션하는 작업이라 불안감을 줄이고 싶었고 실제로 도움이 많이 되었다.


실행

인덱스는 파티션 생성 전에 만든다

PostgreSQL에서 파티션 테이블에 인덱스를 정의하면 이후 생성되는 모든 파티션이 자동으로 그 인덱스를 상속한다. 파티션을 먼저 만들고 인덱스를 붙이려면 32개 파티션에 각각 붙여야 하므로, 순서가 중요하다. 빈 테이블이라 CONCURRENTLY 없이 해도 잠금 문제가 없다.

백필은 야간에 10만 건씩

운영 DB에 무리가 가지 않도록 10만 건씩 배치로, 새벽 시간에만 돌렸다. 백필 진행 상황을 기록하는 임시 테이블(migration_progress)을 만들어서 배치가 끝날 때마다 처리한 범위와 건수를 기록했다. Replication lag가 임계값을 넘으면 자동으로 멈추고, 연결이 끊어지면 이 테이블을 참조해 마지막 성공 배치부터 이어갔다.

2026-03-06 01:32:41 [INFO] [Batch 1853] +100,000 | 누적 1,100,000/201,229,454 (0.5%) | 64s
2026-03-06 01:32:44 [WARNING] Replication lag 121.0 MB — 60초 대기 후 재시도
2026-03-06 01:33:44 [INFO] DB 연결이 끊어짐 — 재연결합니다.
2026-03-06 01:34:51 [INFO] [Batch 1854] +100,000 | 누적 1,200,000/201,229,454 (0.6%) | 66s

트래픽이 없는 야간에 자동으로 시작하고, 트래픽이 들어오는 아침시간이 되면 멈추도록 스크립트를 만들어서 며칠에 걸쳐 crontab으로 자동으로 진행했다.

듀얼라이팅 트리거

백필이 끝나면 실시간 동기화를 시작해야 한다. 방법은 두 가지를 고려했다. 애플리케이션 레벨에서 두 테이블에 동시에 쓰거나, DB 트리거로 처리하거나.

애플리케이션 레벨 듀얼라이팅은 배치 스크립트나 직접 쿼리처럼 앱을 거치지 않는 쓰기는 자동으로 미러링되지 않는 점이 리스크였다. 그리고 본질적으로 데이터베이스를 마이그레이션하는데 애플리케이션이 서비스 레벨에서 깊에 관여한다는게 부자연스럽다고 생각했다.

DB 트리거는 원본 테이블에 쓰기가 발생하는 순간 같은 트랜잭션 안에서 새 테이블에도 반영된다. 경로를 일일이 찾을 필요 없이 DDL 하나로 모든 쓰기를 커버할 수 있었다. 그래서 DB 트리거가 더 좋은 선택이라고 판단했다.

역방향 트리거로 롤백 안전망 확보

전환(RENAME) 후 24시간 동안은 새 테이블 → 구 테이블로도 듀얼라이팅을 유지했다. 문제가 생겼을 때 RENAME을 되돌리기만 하면 되고, 데이터 손실이 없다.


실전에서 마주친 문제

파티션 프루닝이 안 되는 쿼리

이 과정에서 파티션 프루닝이라는 개념을 처음 제대로 이해하게 됐다. 파티션 프루닝이란 쿼리가 실행될 때 PostgreSQL이 파티션 키 조건을 보고 관련 없는 파티션을 아예 스캔 대상에서 제외하는 최적화다. user_id = $1 조건이 WHERE 절에 있으면 32개 파티션 중 해당 유저의 파티션 1개만 읽는다. 이게 작동하지 않으면 파티셔닝을 했어도 32개를 전부 스캔하게 된다.

라이브 전환 직후, 모니터링에서 이상한 게 잡혔다. 특정 쿼리가 실행될 때마다 DB 부하가 튀었다. BufferIO 경합이 폭증하고 CPU가 치솟았다.

원인을 추적해보니 UPDATE 쿼리 하나가 user_id 없이 id만으로 행을 찾고 있었다.

-- 이렇게 하면 32개 파티션 전체를 스캔한다
UPDATE event_participants SET status = 'completed' WHERE id = $1;

-- 이렇게 해야 1개 파티션만 스캔한다
UPDATE event_participants SET status = 'completed' WHERE id = $1 AND user_id = $2;

HASH 파티셔닝에서 파티션 프루닝은 WHERE 절에 파티션 키가 직접 있을 때만 작동한다. JOIN으로 연결된 다른 테이블의 user_id 조건은 프루닝에 작동하지 않는다. 사전에 쿼리를 전수 조사했음에도 이 쿼리를 놓쳤다.
빈번하게 일어나는 수정 쿼리였어서 부하가 올라오는걸 전환 직후 감지했고 바로 쿼리를 찾아 수정하고 배포해서 안정화 했다.

알게 된 PostgreSQL 제약

파티션 테이블에는 CREATE INDEX CONCURRENTLY를 부모에 직접 쓸 수 없다. 이걸 모르면 운영 중에 인덱스를 추가할 방법이 없다고 오해하기 쉽다. 실제로는 3단계로 처리할 수 있다.

1. 각 파티션에 CREATE INDEX CONCURRENTLY (32번)
2. 부모에 CREATE INDEX ON ONLY (데이터 스캔 없이 메타데이터만 생성)
3. 각 파티션 인덱스를 ALTER INDEX ... ATTACH PARTITION

3단계에서 32개가 모두 ATTACH되면 부모 인덱스가 유효한 상태로 전환된다.

이 제약을 파악해두고, 전환 후 성능 모니터링 결과에 따라 추가 인덱싱이 필요한 경우를 대비해 위 절차를 시나리오로 미리 정리해두었다. 실제로 전환 후 쿼리 패턴을 보면서 인덱스를 조정할 가능성이 있었기 때문이다. (결국 이 시나리오가 발생하지는 않았지만)


결과

항목
인덱스 수11개5개
인덱스 크기82 GB~25–30 GB
PKid(user_id, event_id)
DB 피크 부하~50%~35%
서비스 중단약 5분

서비스 중단 5분은 전환 시퀀스에 소요된 시간이다. 듀얼라이팅 트리거를 DROP하고, 테이블을 RENAME한 뒤, 역방향 듀얼라이팅 트리거를 CREATE하는 세 단계를 순서대로 진행했다. 역방향 트리거가 올라오는 것까지 확인한 후 쓰기를 재개했다.

다만 DB는 여러 요인이 복합적으로 작용하기 때문에 이 수치가 파티셔닝만의 효과라고 단정할 수는 없다. 인덱스 정리, 파티션 분산, 쿼리 패치가 함께 작동한 결과일 것이다.


배운 것

파티션 키 호환성 점검은 전환 전 필수다. id만으로 접근하는 쿼리가 하나라도 있으면 전환 후 예상치 못한 성능 저하가 발생한다. 직접 겪었다.

트리거 에러 처리는 반드시 non-blocking으로. 트리거가 실패했을 때 원본 트랜잭션까지 롤백되면 안 된다. EXCEPTION에서 잡아서 에러 테이블에 기록하고, 원본은 그대로 진행시킨다.

역방향 트리거는 전환 직후의 안전망이다. 전환 후 문제가 생겼을 때 RENAME만 되돌리면 되도록 준비해두면, 판단하고 결정하는 시간이 생긴다.

파티션 인덱스 추가는 CONCURRENTLY → ON ONLY → ATTACH 3단계다.

AI 를 활용해 사전 준비부터 계획, 검증, 예상치 못한 돌발 시나리오 준비, 실제 실행, 모니터링까지를 안전하고 빠르게 진행할 수 있었던것 같다. 이 경험을 기반으로 다음에 또 파티셔닝할 일이 있을 때 써먹을 수 있을 것 같다.

profile
going up

0개의 댓글