DAU ~1,000 규모의 서비스에서 MySQL과 PostgreSQL 중 어느 DB가 적합한지 판단하기 위한 성능 벤치마크
프로젝트를 진행하던 중 한 가지 의문이 들었다.
RDB를 선택할 때 우리는 왜 항상 MySQL을 사용하는가?
물론 러닝 커브와 익숙함을 고려했을 때 MySQL을 선택하는 것은 충분히 합리적이다.
하지만 이번 프로젝트에서는 이러한 관성적인 선택에서 벗어나, 실제 서비스 요구사항에 기반한 의사결정을 해보고자 했다.
이를 위해 서비스의 데이터 구조와 쿼리 패턴을 기준으로 가설을 수립하고, 벤치마크를 통해 이를 검증하여 DB를 선택하는 과정을 정리한다.
현재 서비스 요구사항은 다음과 같습니다.
- 여러 생활 습관 조건을 조합하는 동적 필터 검색이 자주 발생한다.
- 검색 결과는 정렬 및 페이지네이션을 포함한다.
- 체크리스트 수정이 반복적으로 발생하므로 update 비용과 안정성도 중요하다.
- 현재 단계에서는 무조건적인 분산 확장성보다, 지금 서비스 규모에서의 응답 속도와 운영 단순성이 더 중요하다.
- 동적 쿼리의 복잡성: 필터 조합이 많아 고정된 인덱스 전략만으로 대응하기 어렵다.
- 데이터 정합성 vs 성능: 쓰기가 잦아도 검색은 빠르고 정확해야 한다.
- MVCC 오버헤드: wide table에서 update가 발생하면 DB 아키텍처에 따라 비용 차이가 커질 수 있다.
이 보고서는 아래 순서로 진행한다.
- 엔진 테스트로 조회, 생성, 수정 성능 차이를 확인한다.
- 그 결과로 초기 가설(H1, H2, H3) 을 검증한다.
- 이후 k6 API 부하 테스트로 애플리케이션 레벨에서 다시 비교한다.
- 마지막으로 두 결과를 함께 해석해 DorumDorum에 더 적합한 DB를 판단한다.
| 가설 | 내용 | 근거 |
|---|---|---|
| H1 (조회) | 5개 이상 다중 필터 조건에서 PostgreSQL의 Bitmap Index Scan이 MySQL의 Index Merge보다 우세할 것이다. | 여러 단일 인덱스를 메모리에서 결합하여 비트맵으로 연산하는 능력의 차이 |
| H2 (수정) | 잦은 Update 발생 시, MySQL(Undo Log)이 PostgreSQL(Tuple Copy)보다 Table Bloat 현상이 적고 성능이 안정적일 것이다. | MVCC 구현 방식에 따른 구버전 데이터 관리 및 디스크 I/O 효율성 |
| H3 (생성) | 데이터 삽입(Insert) 시, Clustered Index 구조인 MySQL이 물리적 정렬 이득으로 인해 처리량이 높을 것이다. | 데이터가 PK 순서대로 물리적으로 정렬되는지(Clustered) vs Heap에 쌓이는지 차이 |
가설 검증 시 근거는 시나리오별로 다르게 잡는다.
- Read:
EXPLAIN/EXPLAIN ANALYZE를 통해 실제 실행계획을 직접 비교한다. - Update:
EXPLAIN으로 대상 row 탐색 방식을 확인하고, PostgreSQL의n_dead_tup,n_tup_hot_upd같은 내부 통계를 함께 본다. - Insert: 실행계획보다 쓰기 경로의 구조적 비용을 본다. 즉 인덱스 개수, 트랜잭션 단위, insert 대상 테이블 구조, k6 결과(avg/p95/throughput)를 함께 해석한다.
본 벤치마크는 두 DB의 동시성 제어 방식 차이도 함께 본다.
- 메커니즘: 원본 데이터를 직접 수정하고, 이전 값은 Undo Log에 기록한다.
- 영향: 변경된 값만 기록하므로 스토리지 오버헤드는 적다. 대신 긴 트랜잭션이 유지되면 Undo Log가 커질 수 있다.
- 메커니즘: 기존 행을 수정하지 않고 새 행(Tuple) 을 만든다. 기존 행은 Dead Tuple이 된다.
- 영향: 일부 컬럼만 바꿔도 행 전체가 다시 기록되므로 Table Bloat가 생길 수 있다. 이를 정리하는
VACUUM이 중요하다.
- 상황: 유저가 흡연, 수면시간 등 20개 조건 중 5~10개를 선택해 검색한다.
- 검증: 여러 단일 인덱스를 얼마나 효율적으로 병합하는지 본다.
- 상황: 새로운 유저가 생활 습관 20개 항목을 입력하고 저장한다.
- 검증: 다수의 인덱스가 걸린 상태에서 insert latency를 비교한다.
- 상황: 유저가 체크리스트의 여러 항목을 수정한다.
- 검증: wide-row update의 처리량, 응답 시간, MVCC 특성 차이를 확인한다.
- Target: MySQL 8.4 / PostgreSQL 16 (Docker 컨테이너)
- API: Spring Boot 3.5.3 (QueryDSL 기반 동적 필터링)
- Load Tool: k6 (동시성 부하 테스트), sysbench (MySQL), pgbench (PostgreSQL)
- Monitoring: Prometheus + Grafana (CPU, Memory, Disk I/O, DB Lock 현황 시각화)
두 DB를 동일 조건으로 비교하기 위해 아래 설정을 맞췄다.
| 항목 | MySQL | PostgreSQL |
|---|---|---|
| 버퍼/캐시 | innodb_buffer_pool_size=128M | shared_buffers=128MB |
| 정렬/해시 메모리 | sort_buffer_size=2M | work_mem=2MB |
| max_connections | 300 | 300 |
| 기본 격리 수준 | READ COMMITTED | READ COMMITTED |
| 동시 클라이언트 | 10 (CLIENTS) | 10 (CLIENTS) |
| 컨테이너 메모리 | 1GB | 1GB |
| 기본 시나리오 duration | 300초 | 300초 |
아래 결과는 엔진 벤치마크 실행 기준이다.
해당 실행은 TIME_SECONDS=1200, WINDOW_SECONDS=5, CLIENTS=10으로 각 시나리오를 20분씩 수행했다.
| DB | Throughput | Processing Time |
|---|---|---|
| MySQL | 55.09 TPS | 179.83 ms |
| PostgreSQL | 138.87 TPS | 72.01 ms |
해석
- PostgreSQL이 처리량에서 약
2.5배, 평균 처리시간에서 약60%수준으로 우세했다. - 현재 Read 시나리오는 단순 정렬 조회가 아니라, 다중 필터로 후보를 좁힌 뒤 남은 인원수로 정렬하는 형태다.
- 이 쿼리는
room + checklist조인, 여러 조건 선택도 계산, 정렬이 함께 걸린다. - 이 조건에서는 PostgreSQL이 더 높은 TPS와 낮은 latency를 보였다.
가설 대조
H1 (조회): PostgreSQL 우세- 현재 결과는 H1을 지지한다.
근거
- MySQL의 최신 실행계획에서는
checklist조건에 대해index_merge가 사용되며,idx_checklist_refrigerator,idx_checklist_smoking,idx_checklist_phone_call,idx_checklist_return_home,idx_checklist_sleep_light를 교집합 형태로 결합한다. - 하지만 MySQL은 여전히
using_temporary_table: true,using_filesort: true가 나타난다. 즉 후보 집합을 줄인 뒤에도 정렬과 임시 테이블 비용을 추가로 부담한다. - PostgreSQL 실행계획에서는
Bitmap Index Scan,BitmapAnd,Bitmap Heap Scan,Hash Join, 작은Sort가 나타난다. 즉 후보 집합을 먼저 줄인 뒤 조인과 정렬로 넘어가는 구조가 더 명확하게 보인다. - 즉 H1은 단순한 결과 수치뿐 아니라, 실행계획 차이까지 포함해 지지한다고 볼 수 있다.
| DB | Throughput | Processing Time |
|---|---|---|
| MySQL | 1635.72 TPS | 6.11 ms |
| PostgreSQL | 2872.84 TPS | 3.48 ms |
해석
- PostgreSQL이 처리량과 평균 처리시간 모두에서 더 좋았다.
- Insert 시나리오는
room1건과checklist1건을 생성하는 비교적 단순한 쓰기 트랜잭션이다. - 원래 가설은 MySQL 우세였지만, 현재 조건에서는 PostgreSQL이 더 효율적으로 나타났다.
- 현재 워크로드가 대량 배치가 아니라 짧은 단건 트랜잭션 반복이라는 점도 영향을 준 것으로 보인다.
가설 대조
H3 (생성): MySQL 우세- 현재 결과는 H3를 지지하지 않는다.
근거
- 현재 insert 시나리오는
room1건 생성 후checklist1건을 이어서 저장하는 짧은 단건 트랜잭션 반복이다. checklist에는 다수의 보조 인덱스가 걸려 있으므로, 이 시나리오는 단순 row append가 아니라 인덱스 유지 비용까지 함께 포함한다.- H3는 InnoDB의 clustered index가 순차 insert에 유리할 것이라는 가설이지만, 현재 워크로드는 대량 배치 적재가 아니라 애플리케이션 레벨의 반복 생성 API에 가깝다.
- 즉 현재 서비스형 생성 패턴에서는 clustered index 이점이 예상만큼 성능 우위로 이어지지 않았다고 볼 수 있다.
- 다만 Insert는 실행계획만으로 단정하기 어렵다. 따라서 이 해석은 결과 중심이다.
| DB | Throughput | Processing Time |
|---|---|---|
| MySQL | 1174.81 TPS | 8.50 ms |
| PostgreSQL | 2720.71 TPS | 3.68 ms |
해석
- PostgreSQL이 처리량과 평균 처리시간 모두에서 우세했다.
- 현재 Update 시나리오는 단건 경량 수정이 아니라,
bedtime,wake_up,cleaning,phone_call,sleep_light,smoking,other_notes,updated_at를 함께 바꾸는 wide-row update다. - 원래는 PostgreSQL이 불리할 것으로 봤지만, 현재 실험 시간과 데이터 규모에서는 PostgreSQL 쪽 처리량이 더 좋게 나왔다.
- 즉 현재 20분 기준으로는 PostgreSQL의 MVCC 오버헤드가 MySQL보다 더 크게 드러나지 않았다.
가설 대조
H2 (수정): MySQL 우세- 현재 결과는 H2를 지지하지 않는다.
근거
- MySQL과 PostgreSQL 모두
room_no기준 유니크 인덱스를 타고 단건 row를 찾는다. 즉 “대상 row를 찾는 비용” 자체는 두 DB 모두 크지 않다. - 현재 시나리오는
bedtime,wake_up,cleaning,phone_call,sleep_light,smoking,other_notes,updated_at를 함께 바꾸는 wide-row update다. - PostgreSQL 쪽 내부 통계를 보면, 짧은 wide update 후
n_tup_upd = 1000,n_dead_tup = 1000,n_tup_hot_upd = 0이 확인된다. - 의미는 다음과 같다.
- PostgreSQL에서는 실제로 dead tuple이 생성되고 있다.
- 인덱스 컬럼을 바꾸기 때문에 HOT update는 사용되지 않는다.
- 즉 H2가 예상한 PostgreSQL의 MVCC 불리함은 실제로 존재하는 현상이다.
- 그럼에도 PostgreSQL이 더 높은 처리량과 더 낮은 평균 처리시간을 보였다.
- 따라서 H2는 가능한 가설이었지만, 이번 실험에서는 재현되지 않았다고 생각한다.
현재 벤치마크 기준으로는 PostgreSQL이 DorumDorum에 더 적합하다고 볼 수 있다.
이유는 세 가지다.
- 서비스 핵심 기능은 다중 필터 기반 룸메이트 검색이고, 가장 중요한 Read 시나리오에서 PostgreSQL이 큰 차이로 우세하다.
- Update 역시 실제 서비스형 wide-row 수정 시나리오로 바꾼 뒤에도 PostgreSQL이 더 높은 처리량과 더 낮은 처리시간을 보였다.
- Insert는 서비스의 핵심 병목은 아니지만, 현재 측정에서는 PostgreSQL이 여기서도 앞섰다.
따라서 현재 실험 결과를 서비스 선택 관점에서 해석하면:
- 검색 성능이 가장 중요하다면 PostgreSQL이 더 적합하다.
- 현재 데이터 구조와 쿼리 패턴에서는 PostgreSQL이 읽기/쓰기 모두 더 안정적인 후보로 보인다.
- MySQL이 우세하다는 초기 가설(H2, H3)은 이번 실험 조건에서는 재현되지 않았다.
단, 이 결론은 어디까지나 현재 인덱스 구성, 현재 시나리오, 현재 동시성(10 clients), 현재 실행 시간(20분) 기준이다. 만약 더 장시간의 update-hotspot 시나리오나 더 큰 데이터 규모에서 다시 측정하면 H2의 결론은 달라질 수 있다.
엔진 테스트는 DB 자체 특성에 강하고, k6는 실제 서비스 응답에 가깝다.
따라서 최종 판단은 k6 API 부하 테스트로 한 번 더 확인한다.
이 단계에서는 다음을 확인한다.
- 엔진 테스트에서 나타난 우열이 실제 API 응답 시간에서도 유지되는가
- 평균값뿐 아니라
p95,p99,failed rate에서도 같은 방향이 나타나는가 - DB 차이보다 애플리케이션 레이어 오버헤드가 더 크게 작용하는 시나리오가 있는가
| DB | avg | p95 | p99 | failed | throughput |
|---|---|---|---|---|---|
| MySQL | 16.51 ms | 19.25 ms | 22.22 ms | 0% | 39.93 req/s |
| PostgreSQL | 12.47 ms | 10.17 ms | 13.01 ms | 0% | 39.90 req/s |
검증 포인트:
- 엔진 테스트의 Read 우세가 API 검색 응답 시간에서도 유지되는가
- 다중 필터 + 정렬 + 페이지네이션이 실제 서비스 레이어에서 어느 정도 증폭되는가
해석:
- 이번 API 레벨 측정에서는 PostgreSQL이
avg,p95,p99모두 앞섰고, 처리량은 사실상 동일했다. - 즉 엔진 테스트에서 보였던 Read 우세 방향이, 최신 k6 결과에서는 API 레벨에서도 같은 방향으로 재현됐다.
- 요청이 랜덤 필터 + 랜덤 정렬 + 페이지네이션을 포함하는 실제 서비스형 시나리오였다는 점을 감안하면, 이번 결과는 PostgreSQL이 단순 고정 쿼리뿐 아니라 더 다양한 조회 패턴에서도 안정적인 응답 시간을 보였다는 근거로 사용할 수 있다.
| DB | avg | p95 | p99 | failed | throughput |
|---|---|---|---|---|---|
| MySQL | 12.28 ms | 18.87 ms | 22.36 ms | 0% | 266.01 req/s |
| PostgreSQL | 8.70 ms | 13.99 ms | 17.55 ms | 0% | 274.60 req/s |
검증 포인트:
- 생성 API에서 DB 차이가 실제 애플리케이션 응답 시간 차이로도 이어지는가
- insert 시나리오의 엔진 비교 결과가 API 레벨에서도 같은 방향을 보이는가
해석:
- PostgreSQL이 평균 응답 시간,
p95, 처리량에서 모두 앞섰고 실패율도 동일하게 0%였다. - 최신 측정에서
p99까지 포함해도 PostgreSQL 쪽이 더 낮았고, tail latency에서도 우세가 유지됐다. - 즉 엔진 테스트에서 보였던 insert 우세 방향이 API 레벨에서도 그대로 유지됐다.
- 현재 생성 API는
room1건과checklist1건을 짧은 트랜잭션으로 처리하므로, 애플리케이션 레이어가 추가되더라도 DB write path 차이가 그대로 드러난 것으로 볼 수 있다.
| DB | avg | p95 | p99 | failed | throughput |
|---|---|---|---|---|---|
| MySQL | 14.12 ms | 20.41 ms | 26.35 ms | 0% | 93.12 req/s |
| PostgreSQL | 9.85 ms | 14.73 ms | 20.48 ms | 0% | 94.89 req/s |
검증 포인트:
- wide-row update의 엔진 비교 결과가 실제 수정 API에서도 유지되는가
- 평균값보다 tail latency와 실패율에서 차이가 크게 나는가
해석:
- PostgreSQL이 평균 응답 시간과
p95모두 더 낮았고, 처리량도 소폭 높았다. - 최신 측정에서
p99까지 포함해도 PostgreSQL 우세가 유지됐다. - 즉 wide-row update에서 PostgreSQL이 더 잘 나왔던 엔진 테스트 결과가 API 레벨에서도 같은 방향으로 나타났다.
- 엔진 테스트에서 확인한 것처럼 PostgreSQL은 실제로 dead tuple을 만들고 HOT update도 쓰지 못했지만, 현재 트래픽과 실행 시간 범위에서는 그 비용보다 처리 경로의 이점이 더 크게 나타난 것으로 해석할 수 있다.
| 항목 | 엔진 테스트 결론 | API 부하 테스트 결론 | 최종 판단 |
|---|---|---|---|
| Read | PostgreSQL 우세 | PostgreSQL 우세 | PostgreSQL 우세 |
| Insert | PostgreSQL 우세 | PostgreSQL 우세 | PostgreSQL 우세 |
| Update | PostgreSQL 우세 | PostgreSQL 우세 | PostgreSQL 우세 |
이 표는 엔진 테스트와 k6 API 부하 테스트 결과를 함께 놓고, 최종적으로 DorumDorum 서비스에 더 적합한 DB를 판단하는 마지막 검증 단계로 사용한다.
정리:
- API 부하 테스트 기준으로는
Insert,Update모두 PostgreSQL 우세가 다시 확인됐다. Read도 최신 API 부하 테스트에서 PostgreSQL 우세가 다시 확인됐다.- 따라서 최종 결론은 현재 DorumDorum 서비스 요구사항에는 PostgreSQL이 더 적합하다로 정리할 수 있다.
- 단, 이번 API 부하 테스트는 반복 평균 실험이 아니라 방향성을 확인하는 검증에 가깝다. 따라서 수치는 절대값보다 방향성 중심으로 해석하는 편이 맞다.
이번 k6 검증을 통해 엔진 테스트만으로는 보이지 않던 몇 가지 차이를 확인할 수 있었다.
- 첫째, 엔진 테스트와 API 부하 테스트는 같은 방향을 보였지만, 차이의 크기는 달랐다. 실제 서비스에서는 ORM, 직렬화, 커넥션 풀, HTTP 처리 비용이 함께 작용한다.
- 둘째,
write-heavy와update-heavy는 엔진 테스트와 API 테스트가 비교적 같은 방향을 보였다. 생성과 수정처럼 DB write path 비중이 큰 시나리오는 애플리케이션 레이어가 끼어도 DB 차이가 더 직접적으로 드러난다. - 셋째, 관측 환경 자체도 결과 해석에 영향을 줬다. 포트 매핑 오류, Prometheus scrape 포트 불일치, Mongo 연결 설정, MySQL 인덱스 초기화 방식처럼 벤치마크 외부의 설정 문제가 결과를 크게 왜곡할 수 있었다. 실제 비교 이전에 실험 환경을 안정화하는 작업이 선행돼야 한다는 점이 분명했다.
- 넷째,
read-heavy는 지표 cardinality가 높아 k6 경고가 발생했다. 이는 페이지 번호와 동적 URL이 메트릭 시계열을 과도하게 늘렸기 때문이며, 향후 장기 테스트에서는 URL grouping이나name태그 고정이 필요하다.
이번 테스트의 핵심은 엔진 테스트는 원인을 설명하는 데 강하고, k6 API 부하 테스트는 실제 서비스 체감을 확인하는 데 강하다는 점이다. 따라서 둘 중 하나만 보기보다, 엔진 근거와 API 결과를 함께 해석하는 편이 더 설득력 있다.
펴서 보기
docker compose -f docker-compose.bench.yml down -v
python3 scripts/generate-seed-data.py
docker compose -f docker-compose.bench.yml up -d
sleep 120접속 포인트:
- Grafana:
http://127.0.0.1:3001 - Prometheus:
http://127.0.0.1:9091 - Pushgateway:
http://127.0.0.1:19092
기본값:
TIME_SECONDS=300(각 시나리오 5분)WINDOW_SECONDS=5(Grafana 시계열 갱신 단위)SCENARIOS="room-checklist-read room-checklist-insert room-checklist-update"
순차 비교:
WINDOW_SECONDS=5 bash scripts/run-engine-bench.sh all동시 관측:
TIME_SECONDS=1200 WINDOW_SECONDS=5 bash scripts/run-engine-bench.sh parallelMySQL만:
WINDOW_SECONDS=5 bash scripts/run-engine-bench.sh mysqlPostgreSQL만:
WINDOW_SECONDS=5 bash scripts/run-engine-bench.sh postgres튜닝 예시:
CLIENTS=20 TIME_SECONDS=600 WINDOW_SECONDS=5 bash scripts/run-engine-bench.sh all
CLIENTS=20 JOBS=8 TIME_SECONDS=600 WINDOW_SECONDS=5 bash scripts/run-engine-bench.sh postgres
SCENARIOS=room-checklist-update WINDOW_SECONDS=5 bash scripts/run-engine-bench.sh parallelMySQL:
bash scripts/run-api-bench.sh mysql read-heavy 3
bash scripts/run-api-bench.sh mysql write-heavy 3
bash scripts/run-api-bench.sh mysql update-heavy 3PostgreSQL:
bash scripts/run-api-bench.sh postgres read-heavy 3
bash scripts/run-api-bench.sh postgres write-heavy 3
bash scripts/run-api-bench.sh postgres update-heavy 3

