Place2Page의 프로젝트 대시보드는 로그인한 사용자가 자주 지나가는 기본 경로다. 이 화면은 /v1/projects를 호출해서 프로젝트 목록을 가져온다.
Sentry에서 이 경로를 보다가 이상한 신호를 봤다. 최근 24시간 기준으로는 DB span p95가 한 자릿수 ms였고 장애라고 부를 정도는 아니었다. 하지만 기간을 30일로 넓히자 과거 /v1/projects 조회에서 projects row 전체를 읽는 쿼리가 반복적으로 보였다.
문제는 응답에 필요한 데이터보다 DB에서 읽는 데이터가 훨씬 컸다는 점이었다. 목록 화면에는 프로젝트 이름, 상태, slug, 업데이트 시각 같은 작은 필드만 필요했다. 그런데 ORM은 Project 모델 전체를 materialize하고 있었고, 그 안에는 place_data_json, review_signal_payload_json 같은 큰 JSON 컬럼까지 포함되어 있었다.
먼저 본 숫자
운영 Sentry에서 확인한 DB span 기준 수치는 이랬다.
| 기준 | 이전 full-row 조회 | 이후 projection 조회 |
|---|---|---|
| p95 | 144.8~214.5ms | 3.09ms |
| 개선폭 | 약 47배 | - |
| 큰 JSON 컬럼 | 포함 | 제외 |
| 최적화 방식 | row 전체 로드 | 필요한 컬럼만 projection |
이 수치는 전체 API 응답 시간이 아니라 /v1/projects 안에서 실행된 DB query duration 기준이다. 당시 Sentry transaction 이름이 GET /v1/projects와 POST /v1/projects를 충분히 분리하지 못했기 때문에 전체 request latency는 생성 요청과 섞일 수 있었다. 그래서 이 글에서는 전체 API latency가 아니라 문제였던 DB query shape과 DB span만 근거로 삼는다.
로컬에서는 같은 schema에 wide row를 크게 만든 synthetic benchmark도 돌렸다. 절대값은 운영 PostgreSQL 성능을 그대로 재현한 값이 아니지만, 같은 조건에서 full-row fetch와 projection query의 차이를 비교하기에는 충분했다.
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| median elapsed | 605.9ms | 97.1ms |
| median peak allocation | 163.7MB | 11.8MB |
| result rows | 2,000 | 2,000 |
| avoided wide payload | 80.1MB | 0MB fetched |
운영에서는 실제 query shape이 바뀌었는지 확인했다. 로컬에서는 그 shape이 커졌을 때 어떤 비용 차이를 만드는지 확인했다.
왜 인덱스가 아니라 projection이 먼저였나
ORM 사용에 익숙해지다 보면 DB에서 tuple 전체를 가져온 뒤 DTO로 매핑하는 방식이 정석처럼 느껴진다.
그러다 보면 실제로 어떤 SQL이 실행되는지, 불필요한 컬럼까지 조회하고 있지는 않은지에 점점 둔감해진다.
데이터베이스 비용 모델 관점에서 보면, 불필요한 컬럼은 tuple width를 키우고, 같은 row count에서도 더 많은 I/O, 더 큰 memory allocation, 더 많은 serialization 비용으로 이어진다.
이 문제는 cardinality, 즉 row 수만의 문제가 아니었다. 목록에 필요한 row 수는 같아도 각 row가 너무 넓었다. 응답 DTO가 작아도, DB driver와 ORM이 이미 큰 tuple을 애플리케이션으로 옮긴 뒤라면 늦다.
전공서에서 projection은 결과에 필요한 attribute만 남기는 relational algebra 연산으로 등장한다. API 서버에서는 이 개념이 더 물리적으로 다가온다.
logical projection:
결과에 필요한 attribute만 남긴다.
physical consequence:
DB가 읽고, driver가 전달하고, ORM이 materialize하고,
serializer가 처리해야 하는 data width를 줄인다.그래서 이번 최적화의 핵심은 "어떤 row를 더 빨리 찾을까"가 아니라 "찾은 row에서 무엇을 읽지 않을까"였다.
바꾼 것
기존 route는 session.query(Project)로 Project 모델 전체를 읽었다. 목록 응답 schema가 작아도 ORM 입장에서는 이미 전체 row를 materialize한 뒤였다.
수정 후에는 SQLAlchemy의 load_only()로 목록에 필요한 컬럼만 즉시 로드하도록 제한했다.
q = session.query(Project).options(load_only(*_PROJECT_LIST_LOAD_COLUMNS))
if not user.is_admin:
q = q.filter(Project.owner_user_id == user.id)
projects = q.order_by(Project.updated_at.desc()).all()또 신경 쓴 부분은 lazy load였다. Project.place_provider 같은 property가 내부에서 place_data_json을 읽으면 load_only()를 걸어도 row마다 deferred column을 다시 가져올 수 있다. 그러면 projection 최적화가 N번의 lazy load로 무너진다.
그래서 목록 경로에서는 큰 JSON payload를 보지 않고 URL 기반으로 provider를 추론하도록 했다. 상세 화면에서는 기존처럼 전체 project context를 유지한다.
인덱스는 왜 미뤘나
다음으로는 복합 인덱스를 검토했다. 비관리자 쿼리는 보통 다음 패턴이다.
WHERE owner_user_id = ?
ORDER BY updated_at DESC이 패턴에는 (owner_user_id, updated_at DESC) 복합 인덱스가 자연스럽다. 필터링과 정렬을 동시에 도와 scan 범위와 sort 비용을 줄일 수 있다.
하지만 이번에는 추가하지 않았다. projection만으로 최근 운영 DB span p95가 3.09ms 수준까지 내려왔고, 해당 정렬 SELECT가 최근 24시간에는 샘플로 잡히지 않을 정도로 신호가 약해졌다. 인덱스는 read path를 빠르게 만들 수 있지만 write amplification과 migration 관리 비용도 만든다.
현재 데이터 규모에서는 복합 인덱스까지 넣는 것이 오버킬에 가깝다고 판단했다. 프로젝트 수가 훨씬 늘고 /v1/projects가 다시 상위 DB span으로 올라오면 그때 (owner_user_id, updated_at DESC) 인덱스를 추가하는 편이 더 맞다.
제품 관점의 의미
이 최적화는 단일 요청 하나를 빠르게 만든 작업이라기보다 반복 호출되는 기본 경로에서 불필요한 payload와 memory allocation을 줄인 작업이다.
프로젝트 대시보드는 사용자가 서비스를 열 때마다 지나가는 화면이다. 이런 경로에서 큰 JSON payload를 매번 읽고 Python object로 만들면 latency뿐 아니라 worker memory, GC pressure, serialization 비용까지 같이 커진다.
이번 작업의 핵심은 load_only()를 넣었다는 사실이 아니다. Sentry로 운영 query shape을 확인했고, DB 비용 모델 관점에서 row width가 병목이라는 점을 잡았다. 그런 다음 projection으로 실제로 읽는 데이터를 줄였다.
성능 최적화는 "더 빠른 방법을 아는 것"보다 "지금 어떤 비용을 줄여야 하는지 고르는 것"에 가깝다. 이번에는 인덱스가 아니라 projection이 먼저였다.
참고
- Abraham Silberschatz, Henry F. Korth, S. Sudarshan, Database System Concepts, 7th edition, Chapter 15 Query Processing
- SQLAlchemy ORM Querying Guide: Column Loading Options
- Sentry trace search:
environment:production span.op:dbonplace2page-server
