뭘 고민하고 있는 건지
고객 데이터에 적립금(포인트) 정보를 추가해야 합니다. 선택지는 세 가지예요.
- 방안 A: 기존 고객 테이블에 INTEGER 컬럼 3개를 추가한다
- 방안 B-1: 적립금 전용 테이블을 별도로 만들고, 모든 고객과 1:1로 연결한다
- 방안 B-2: 적립금 전용 테이블을 별도로 만들되, 포인트가 있는 고객만 행을 생성한다
“관심사에 따라 테이블을 분리하는 게 좋은 설계 아닌가?”라는 질문이 자연스럽게 나오는데, 실제로 우리 환경에서 그게 맞는지 수치로 따져보려고 합니다.
아래 그림으로 방안 A와 B의 차이를 먼저 보시면 이해가 쉽습니다.
방안 A vs 방안 B 테이블 구조
우리 환경
| 항목 | 값 |
|---|---|
| DBMS | PostgreSQL (Amazon RDS) |
| 고객 테이블 행 수 | 3억 행 |
| 추가할 적립금 필드 | INTEGER 3개 |
| NULL 여부 | 방안 A: 모든 행에 값 존재 (기본값 0) |
| 포인트 보유 고객 비율 | 전체의 약 25% (7,500만 명) |
| 적립금 변경 빈도 | 주당 약 100만 건 (일 ~14.3만, 분 ~100건) |
| 외부 연동 | DMS CDC → CDP |
| 삭제 정책 | Soft Delete (물리적 삭제 없음) |
먼저 알아두면 좋은 것들
이 검토서에서 자주 등장하는 PostgreSQL 내부 개념들을 정리했습니다. 이미 아시는 분은 건너뛰셔도 됩니다.
페이지(Page)
PostgreSQL은 데이터를 한 건씩 읽지 않습니다. 8KB 페이지라는 블록 단위로 디스크에서 읽어옵니다. 책에서 한 줄만 보고 싶어도 페이지 전체를 펼쳐야 하는 것처럼, 행 하나를 읽으려면 그 행이 담긴 8KB 페이지 전체를 메모리에 올려야 합니다.
PostgreSQL 페이지 구조
고객 행 하나가 200바이트라면 한 페이지에 약 35~40개 행이 들어갑니다. 행이 커지면 페이지당 행 수가 줄고, 같은 양의 데이터를 읽기 위해 더 많은 페이지를 읽어야 합니다.
Shared Buffers (캐시)
디스크 읽기는 느리기 때문에, PostgreSQL은 자주 쓰는 페이지를 메모리에 보관합니다. 이게 shared_buffers이고, 웹 브라우저 캐시와 같은 원리입니다. 한 페이지 안에 유효한 데이터가 빽빽할수록 캐시 효율이 높아요. 반대로 쓸모없는 데이터가 섞여 있으면 같은 캐시 공간에서 읽을 수 있는 유효 행 수가 줄어듭니다.
MVCC (Multi-Version Concurrency Control)
여러 사용자가 동시에 같은 데이터를 읽고 쓸 수 있게 하는 핵심 메커니즘입니다.
공유 문서를 편집할 때 원본을 직접 고치지 않고 복사본을 만들어 수정하는 방식이라고 생각하면 됩니다. 고객 A의 적립금을 1,000원에서 2,000원으로 바꾸면, PostgreSQL은 기존 행(적립금=1,000)을 그대로 두고 새 행(적립금=2,000)을 별도로 만듭니다. 수정이 완료(커밋)되면 새 버전이 공식 데이터가 되고, 기존 버전은 쓸모없는 복사본이 됩니다.
Dead Tuple
위에서 말한 “쓸모없는 복사본”이 dead tuple입니다. 중요한 점은, dead tuple은 원래 행의 전체 크기를 그대로 차지한다는 것입니다.
MVCC와 Dead Tuple
테이블을 분리하면 적립금 변경으로 생기는 dead tuple이 작은 포인트 테이블에만 발생하므로, 고객 테이블의 페이지는 깨끗하게 유지됩니다. 다만 이 효과는 변경 빈도가 매우 높을 때만 체감됩니다.
VACUUM
dead tuple을 정리하는 프로세스입니다. 사무실 청소부와 같아서, 쓰레기(dead tuple)가 쌓이면 와서 치우고 그 공간을 새 데이터가 쓸 수 있게 합니다.
PostgreSQL의 autovacuum은 기본적으로 테이블 전체 행의 20%가 dead tuple이 되면 청소를 시작합니다. 3억 행 테이블에서 20%는 6,000만 행이에요. 주당 100만 건 변경으로는 이 임계치에 도달하는 데 60주(약 1년 이상)가 걸립니다.
테이블이 클수록 VACUUM에 걸리는 시간도 길어지는데, 변경 빈도가 낮으면 이 차이는 사실상 의미가 없습니다.
NULL 비트맵
PostgreSQL은 각 행마다 23바이트의 고정 헤더(HeapTupleHeader)를 가집니다. 행에 NULL 값이 하나라도 있으면, 각 컬럼의 NULL 여부를 1비트씩 기록하는 비트맵이 헤더에 추가됩니다. 크기는 컬럼 수를 8로 나눈 뒤 올림한 바이트 수이고(예: 컬럼 20개면 3바이트), 모든 컬럼이 NOT NULL이면 비트맵 자체가 생기지 않습니다.
다만 우리 프로젝트에서는 모든 행에 0 값을 넣기로 했으므로 NULL 절약 효과는 해당되지 않습니다.
정규화 vs 비정규화
**정규화(Normalization)**는 데이터 중복을 줄이고 무결성을 위해 테이블을 분리하는 원칙이고, **비정규화(Denormalization)**는 조회 성능을 위해 합치는 전략입니다. 어느 쪽이 절대적으로 옳다고 할 수 없고, 실제 데이터 규모와 사용 패턴에 따라 정량적으로 판단해야 합니다.
CDC와 DMS
**CDC(Change Data Capture)**는 데이터베이스 변경사항을 실시간으로 포착해 다른 시스템에 전달하는 기술입니다. AWS DMS는 PostgreSQL의 WAL을 읽어 변경 이벤트를 생성하고, CDP 같은 외부 시스템에 전달합니다.
핵심은 CDC 변경 이벤트가 행 단위로 발생한다는 점입니다. 행의 컬럼 하나만 바뀌어도 행 전체가 변경 이벤트로 전달됩니다.
우리 케이스에 적용해보기
저장 공간
방안 A는 단순합니다. 행당 INTEGER 4바이트 x 3개 = 12바이트 추가. 3억 행이면 약 3.6GB입니다. 만약 포인트가 없는 고객의 컬럼을 NULL로 둔다면, NULL인 행은 12바이트를 절약해서 7,500만 x 12B = 약 0.9GB로 줄어듭니다.
방안 B-1 (전체 고객 1:1 매핑)은 생각보다 비쌉니다. 분리 테이블도 3억 행이 필요합니다. 행마다 헤더(23B), 패딩(2B), line pointer(4B), customer_id FK(8B), 데이터(12B)에 PK/FK 인덱스(32B)까지 합치면 행당 약 81바이트. 3억 행이면 약 24.3GB입니다.
방안 B-2 (포인트 보유 고객만)는 어떨까요? 전체 고객의 25%만 포인트를 가지고 있다면 7,500만 행만 필요합니다. 7,500만 x 81B = 약 6.1GB입니다. 방안 B-1보다 훨씬 낫지만, 여전히 방안 A의 0.9GB~3.6GB보다 큽니다.
저장 공간 비교
| 항목 | 방안 A (NULL 허용) | 방안 A (전체 0) | 방안 B-1 (전체 1:1) | 방안 B-2 (25%만) |
|---|---|---|---|---|
| 추가 데이터 | 0.9GB | 3.6GB | 14.7GB | 3.7GB |
| 추가 인덱스 | 0 | 0 | 9.6GB | 2.4GB |
| 총합 | ~0.9GB | ~3.6GB | ~24.3GB | ~6.1GB |
방안 B-2는 B-1보다 확실히 효율적이지만, 방안 A(NULL 허용) 대비 여전히 약 6.8배 많은 공간을 사용합니다.
조회 성능
방안 A는 단일 인덱스 스캔으로 끝납니다. 방안 B(B-1, B-2 모두)는 PK 기반 JOIN이 필요한데, 벤치마크상 단건 조회에서는 0.1ms 미만의 추가 비용이라 체감 불가 수준입니다. 다만 수만 건 이상의 배치 조회에서는 JOIN 비용이 누적될 수 있습니다.
방안 B-2에서 추가로 고려할 점이 있습니다. 포인트가 없는 고객을 조회할 때 LEFT JOIN을 써야 하고, 결과에서 NULL 처리 로직이 필요합니다. 방안 A라면 그냥 0이 들어있으니까 별도 처리 없이 쓸 수 있어요.
MVCC / VACUUM
주당 100만 건 변경 / 3억 행 = 주당 0.33% 변경률입니다.
| 항목 | 방안 A | 방안 B-1 | 방안 B-2 |
|---|---|---|---|
| Dead tuple 크기/건 | ~200B (고객 행 전체) | ~49B (포인트 행만) | ~49B |
| 주당 dead tuple 총량 | ~200MB | ~49MB | ~49MB |
| Autovacuum 임계치 도달 | ~60주 | ~60주 (3억 행 기준) | ~15주 (7,500만 행 기준) |
방안 B-2는 포인트 테이블이 7,500만 행으로 작기 때문에 autovacuum 임계치(20%)에 더 빨리 도달합니다. 이건 단점이 아니라 오히려 장점이에요. VACUUM이 더 자주, 더 빠르게 돌면서 dead tuple을 깨끗하게 정리해줍니다. 다만 주당 0.33%의 변경률에서는 어느 방안이든 MVCC/VACUUM이 실질적 병목이 되지 않습니다.
Row Lock 경합
분당 약 100건의 변경이 3억 행에 분산되므로, 같은 행에 동시 접근할 확률은 사실상 0입니다. 시간대별로 뭉쳐서 들어오는 패턴이지만, 1초에 수천 건이 동일 행에 집중되는 수준은 아니므로 두 방안 모두 문제없습니다.
DMS/CDC (CDP 연동)
방안 A에서는 적립금 하나만 바뀌어도 고객 행 전체(~200B)가 변경 이벤트로 캡처됩니다. CDP 입장에서는 “적립금이 바뀐 것”과 “고객 프로필이 바뀐 것”을 구분할 수 없어서, downstream 워크플로우(마케팅 트리거, 세그먼트 재계산 등)가 오발동할 수 있습니다.
방안 B(B-1, B-2 모두)에서는 포인트 테이블 변경만 캡처되므로(~49B), 이벤트 구분이 깔끔합니다.
CDC 변경 이벤트 비교
| 항목 | 방안 A | 방안 B-1 / B-2 |
|---|---|---|
| 변경 이벤트 크기 | ~200B | ~49B |
| 주당 WAL/네트워크 | ~200MB | ~49MB |
| CDP 변경 유형 구분 | 불가능 | 가능 |
| Downstream 오발동 | 위험 있음 | 없음 |
관심사의 분리 원칙
“관심사를 분리하려면 테이블도 분리해야 하지 않나?”라는 질문이 있을 수 있는데, 핵심을 짚자면 이렇습니다.
코드에서 CustomerService와 PointService를 분리하는 건 런타임 비용이 거의 없지만, 테이블을 분리하면 매번 물리적 I/O 비용(JOIN)이 발생합니다. 즉 코드의 관심사 분리는 무료이고, 테이블의 관심사 분리는 유료입니다.
테이블은 하나로 두고, 애플리케이션 레이어에서 별도의 Service/Repository 클래스로 관심사를 나누는 것만으로도 충분합니다.
스키마 확장성과 YAGNI
“나중에 포인트 만료일이나 등급별 적립률이 추가되면 어쩌지?”라는 걱정이 있을 수 있습니다. 타당한 우려지만, 소프트웨어 공학에는 YAGNI(You Aren’t Gonna Need It) 원칙이 있습니다. 현재 존재하지 않는 요구사항을 예측해서 설계를 복잡하게 만드는 건 과잉 설계입니다.
지금 요구사항은 INTEGER 3개입니다. 향후 분리가 필요해지더라도 INSERT INTO ... SELECT 한 방이면 마이그레이션할 수 있으므로, 기술적 난이도가 높은 작업이 아닙니다.
변경 빈도: 분리를 결정하는 진짜 기준
“데이터 변경이 잦으니까 분리하는 게 낫지 않나?”라는 주장도 있었는데, “변경이 잦다”만으로는 근거가 부족합니다. 실제로는 세 가지를 함께 따져야 합니다.
테이블 분리 판단 기준
첫째, 변경 비율. 전체 행 대비 변경 비율이 autovacuum 동작에 영향을 줄 정도인지. 우리 케이스의 0.33%는 VACUUM 관점에서 무의미하고, 10~20% 이상이 되어야 분리 실익이 생깁니다.
둘째, 변경 집중도. 같은 총량이라도 특정 시간에 몰리면 다릅니다. 우리는 시간대별로 뭉쳐서 들어오는 패턴이지만, 동일 행에 1초 수천 건이 집중되는 수준은 아니므로 문제없습니다.
셋째, 변경 컬럼과 조회 컬럼의 겹침. 이게 가장 본질적인 기준입니다. 포인트만 자주 바뀌는데 고객 프로필만 따로 읽는 쿼리가 압도적으로 많다면, 포인트 UPDATE로 인한 dead tuple이 프로필 조회의 페이지 효율을 떨어뜨립니다. 하지만 우리처럼 포인트를 읽을 때 항상 고객 정보도 함께 읽는 패턴이라면, 같은 페이지에 있는 게 I/O와 캐시 모두에서 유리합니다.
Soft Delete 환경
우리는 soft delete를 사용하므로 고객 데이터가 물리적으로 삭제되지 않고 계속 누적됩니다. 이 조건에서는 방안 A가 더욱 유리합니다.
- 관리 대상 테이블 수: 방안 A는 1개, 방안 B-1은 같은 속도로 커지는 2개, 방안 B-2는 고객 테이블만 무한히 커지고 포인트 테이블은 활성 고객 비율에 비례
- VACUUM/ANALYZE 대상: 하나만 관리 vs 둘 다 관리
- 필터링 단순성:
WHERE deleted_at IS NULL을 한 테이블에 한 번만 적용. Partial index도 하나만 필요 - 방안 B-2의 추가 복잡성: 탈퇴한 고객의 포인트 행을 삭제할지 남길지에 대한 정책도 필요. 남기면 결국 B-1과 비슷해지고, 삭제하면 soft delete 정책과 충돌
종합
| 평가 항목 | 방안 A | 방안 B-1 (전체 1:1) | 방안 B-2 (25%만) | 우위 |
|---|---|---|---|---|
| 추가 저장 공간 | ~3.6GB (NULL 허용 시 ~0.9GB) | ~24.3GB | ~6.1GB | A |
| 단건 조회 성능 | JOIN 불필요 | ~0.1ms 추가 | ~0.1ms 추가 | A |
| 쿼리 복잡도 | 단순 | JOIN 필요 | LEFT JOIN + NULL 처리 | A |
| MVCC dead tuple | ~200MB/주 | ~49MB/주 | ~49MB/주 | 동등 |
| Row lock 경합 | 무시 가능 | 무시 가능 | 무시 가능 | 동등 |
| Soft Delete 호환성 | 유리 | 불리 | 더 불리 (삭제 정책 충돌) | A |
| ORM/코드 복잡도 | 단순 | 관계 정의 필요 | 관계 정의 + 존재 여부 분기 | A |
| DMS WAL 크기 | ~200MB/주 | ~49MB/주 | ~49MB/주 | B |
| CDP 이벤트 구분 | 불가능 | 가능 | 가능 | B |
결론
RDS만 고려한 현재 환경에서는 **방안 A(고객 테이블에 컬럼 3개 추가)**가 합리적입니다.
저장 공간에서 방안 A가 압도적으로 유리하고(NULL 허용 시 0.9GB vs B-2의 6.1GB), 주당 0.33%의 변경률에서 MVCC/VACUUM/Row Lock 모두 유의미한 차이가 없습니다. 포인트를 읽을 때 항상 고객 정보도 함께 읽으므로 JOIN 없는 단일 테이블이 I/O와 캐시에서 유리하고, soft delete 환경에서 테이블 하나를 관리하는 게 운영 부담이 적습니다.
방안 B-2(포인트 보유 고객만 행 생성)는 B-1보다 저장 공간은 낫지만, LEFT JOIN과 NULL 처리의 코드 복잡도, soft delete와의 정책 충돌, 포인트 생성/삭제 시점 관리 등 운영 복잡도가 추가됩니다. 저장 공간 절감(6.1GB)이 이 복잡도를 정당화할 만큼 크지 않습니다.
다만, 향후 포인트가 독립적인 도메인으로 성장하는 신호가 보이면 분리를 재검토합니다. 구체적으로는 복잡한 비즈니스 로직(적립 유형별 정책, 유효기간 관리 등)이 추가되거나, 포인트 스키마만 독립적으로 변경해야 하는 상황이 반복되거나, 변경 빈도가 급격히 증가(주당 수천만 건 이상)하는 경우입니다. 분리 마이그레이션 자체는 INSERT INTO ... SELECT로 수행 가능하므로 기술적 부담은 크지 않습니다.
참고 자료
- PostgreSQL Documentation: Database Page Layout
- Brian Likes Postgres: Cost of a Join
- CYBERTEC: Join Strategies and Performance in PostgreSQL
- AWS DMS: Using PostgreSQL as a Source
- AWS DMS: Best Practices
- Microsoft: Optimizing CDC on PostgreSQL
- CelerData: Normalization vs Denormalization Trade-offs