비정규화로 읽기를 잡으면 쓰기는 왜 터졌나?
결론부터 쓰면, 비정규화는 읽기 비중이 90% 이상이고 데이터가 메모리에 다 올라가는 동안에만 믿을 만했다. 데이터가 커지면서 캐시 밖으로 밀려나는 순간, 그리고 복제된 필드를 한꺼번에 갱신하는 배치가 들어오는 순간 성능은 반대로 뒤집혔다. 이 글은 그 전환과 역전을 숫자로 복기한 기록이다.
이 글의 핵심
- 비정규화의 읽기 이점은 "행 수"가 아니라 "데이터가 메모리에 남아 있는가"에서 갈린다.
- 복제 필드가 많을수록 쓰기 처리량은 정규화 대비 자릿수 단위로 떨어질 수 있다.
- 격리수준은 스키마와 별개 선택이 아니다. 복제본을 동시에 고치면 경합 비용이 그대로 더해진다.
- 측정 없이 "조인이 느리니 비정규화"로 바로 가는 결정은 장애의 시작점이 되기 쉽다.
2026년 초, 판교의 한 커머스 백엔드에서 있었던 일이다. 주문 상세 조회 API의 p99가 420ms를 넘어가면서 모니터링 알람이 반복됐다. 원래 스키마는 orders - order_items - products - users를 조인하는 전형적인 정규화 구조였다. 조인 fan-out이 커 보여서 "주문 요약 테이블"을 만들어 상품명, 가격, 사용자명을 통째로 복제해 넣는 비정규화로 갈아탔다. 초반 2주는 p99가 180ms대로 떨어지며 성공처럼 보였다. 문제는 한 달 뒤, 상품 가격 일괄 조정 배치가 돌던 새벽에 터졌다. 배치는 2시간 넘게 끝나지 않았고, 그사이 신규 주문 생성 API의 타임아웃이 치솟았다.
읽기는 빨라졌는데 왜 쓰기 배치가 멈췄나?
원인은 복제 필드 갱신이 쓰기 경로를 증폭시킨 데 있었다. 주문 요약 테이블에는 상품명·가격이 주문 건수만큼 중복 저장돼 있었고, 가격 하나를 바꾸면 해당 상품이 걸린 수만 건의 행을 전부 UPDATE해야 했다. 공개된 비교 자료에서도 정규화 모델의 쓰기 처리량이 약 13,000 records/s였던 반면 비정규화 모델은 약 1,100 records/s에 그쳐, 조건은 다르지만 방향성만은 우리가 겪은 것과 비슷했다 출처. 물론 이 수치는 DB 제품·트랜잭션 크기·인덱스 수가 공개되지 않은 자료라서 그대로 "12배 느리다"로 인용할 수는 없다. 다만 우리 로그에서도 배치의 평균 UPDATE 지연이 정규화 구조 대비 체감상 크게 늘었다는 점은 명확했고, 이는 중복 필드마다 걸어둔 인덱스까지 함께 갱신되면서 쓰기 증폭이 겹친 결과였다.
데이터가 늘어나자 왜 읽기까지 같이 느려졌나?
데이터셋이 커지면서 비정규화 테이블이 메모리 밖으로 밀려난 것이 두 번째 원인이었다. 공개 비교에서 200만 사용자 규모일 때 비정규화 모델은 약 30K reads/s, 응답시간 1ms 미만이었고 정규화 모델은 약 12K reads/s, 2ms 미만이었다. 그런데 2,000만 사용자 규모로 커지자 비정규화 모델의 저장 크기가 가용 RAM을 넘어서면서 처리량이 30K/s에서 9K/s로 떨어졌고 응답시간은 3~4ms로 늘었다. 반대로 정규화 모델은 약 12K/s, 2ms대를 그대로 유지했다 출처. 우리 테이블도 주문 요약본이 원본 대비 3배 가까이 커져 있었고, 버퍼 캐시 적중률을 뽑아보니 비정규화 전환 직후보다 확연히 낮아져 있었다. "행 수가 늘어서 느려졌다"가 아니라 "복제 때문에 커진 테이블이 캐시 밖으로 밀려나서 느려졌다"는 게 더 정확한 설명이었다.
격리수준을 올린 게 왜 지연을 더 키웠나?
가격 배치의 락 경합을 줄이겠다고 격리수준을 Serializable로 올린 것이 세 번째 원인이었다. 2025년 공개된 시뮬레이션 연구는 Serializable이 Read Committed 대비 처리량을 25~45% 낮추고, 읽기 중심·중간 경합 조건에서 지연시간을 최대 3배까지 높일 수 있다고 보고했다 출처. 다만 이 수치는 특정 상용 DB의 표준 벤치마크가 아니라 자체 시뮬레이터 기반 결과라서 제품 성능으로 곧바로 치환할 수는 없다. AWS re:Invent 2025 발표 요약도 비슷한 방향을 짚는다. 충돌이 적을 때는 Read Committed와 Repeatable Read 성능이 비슷했지만, 충돌이 늘어나면 Serializable의 처리량이 크게 떨어졌고, 순수 업데이트 워크로드에서는 스냅샷 격리가 Serializable보다 딱히 빠르지 않았다는 관찰도 있었다 출처. 우리는 배치가 수만 건을 건드리는 "순수 갱신 중심" 상황이었는데, 거기에 Serializable을 더하니 락 대기 체인이 길어졌고 결과적으로 신규 주문 생성까지 밀린 것이었다.
장애를 어떻게 재현해서 원인을 확정했나?
재현은 운영 DB의 백업 스냅샷을 스테이징에 올려 EXPLAIN ANALYZE와 pgbench로 워크로드를 재생하는 방식으로 진행했다. 가격 배치가 건드리는 상품 ID 분포와 동시 주문 생성 트래픽을 비슷한 비율로 섞어 돌렸더니, Read Committed로 되돌린 스테이징에서는 배치 완료 시간이 운영 장애 당시의 절반 가까이로 줄었고, 동시에 돌던 주문 생성 API의 p99도 안정됐다. 버퍼 캐시 적중률도 함께 뽑았는데, 주문 요약 테이블의 hot 영역이 전체 RAM 대비 차지하는 비율이 장애 시점에 유독 높아져 있었다. 이 두 지표 — 배치 완료 시간과 캐시 적중률 — 를 같이 보고 나서야 "쓰기 증폭 + 격리수준 + 캐시 이탈"이 동시에 겹친 복합 장애였다는 결론을 낼 수 있었다. 단일 지표만 봤다면 격리수준 탓만 하고 테이블 설계 문제를 놓쳤을 것이다.
무엇을 바꿨고 어떤 지표로 확인했나?
세 가지를 동시에 바꿨다. 첫째, 상품명·가격처럼 자주 바뀌는 필드는 복제를 풀고 다시 products 테이블 조인으로 돌렸다. 대신 조회 빈도가 높고 거의 바뀌지 않는 사용자명만 복제를 유지했다. 둘째, 주문 요약 테이블에는 (tenant_id, created_at) 형태의 복합 인덱스를 새로 설계해 조회 조건과 정렬 방향을 맞췄고, 상태값이 고정된 쿼리에는 부분 인덱스를 추가해 인덱스 크기를 줄였다. 셋째, 가격 배치의 격리수준은 Read Committed로 되돌리고, 대신 배치를 작은 트랜잭션 단위로 쪼개 락 보유 시간을 줄였다. 변경 후 모니터링한 지표는 배치 완료 시간, 주문 생성 API의 p95·p99, 버퍼 캐시 적중률 세 가지였다. 참고로 이번 변경은 스키마 변경과 인덱스 재생성이 포함돼 있어, 비슷한 마이그레이션 사례에서 보고된 12~18분 수준의 다운타임 가능성도 함께 고려해 배포 시간을 새벽 트래픽 최저 구간으로 잡았다 출처.
같은 함정을 피하려면 무엇을 기본값으로 삼아야 하나?
이번 장애 이후 팀의 기본값은 "조인이 느려 보인다고 바로 비정규화하지 않는다"로 바뀌었다. 대신 대표 쿼리의 읽기·쓰기 비율, p95·p99 지연시간, 조인 fan-out, 인덱스 크기, 갱신 충돌률, 허용 가능한 stale read 시간을 먼저 측정표로 만들고 나서 스키마를 정한다. 2026년 기준으로도 "정규화 또는 비정규화 중 무엇이 항상 빠르다"고 단정할 만한 독립 재현 벤치마크는 확인되지 않았다. 결국 판단은 데이터가 메모리에 얼마나 들어맞는지, 복제 필드가 몇 군데서 바뀌는지, 트랜잭션이 얼마나 많은 행을 동시에 건드리는지에 달려 있었다.
핵심 정리
- 비정규화 읽기 이점은 데이터가 메모리에 올라가 있을 때 두드러지며, RAM을 초과하면 처리량이 크게 떨어질 수 있다 출처.
- 복제 필드가 많을수록 쓰기 증폭이 커지고, 여기에 인덱스가 더해지면 저장공간과 쓰기 비용이 함께 늘어난다.
- Serializable 같은 높은 격리수준은 경합이 적을 때는 차이가 작지만, 경합이 늘어나면 처리량이 비선형적으로 떨어질 수 있다 출처.
- 스키마 변경은 성능 수치뿐 아니라 백필·인덱스 재생성·락 대기로 인한 다운타임도 함께 기록해야 한다.
- 결정은 "정규화냐 비정규화냐"가 아니라 읽기·쓰기 비율과 캐시 적중률을 먼저 측정한 뒤에 내려야 한다.