Place2Page 공개 랜딩 몇 개가 갑자기 느려졌다. 로그만 보면 데이터베이스가 전반적으로 무거워진 것처럼 보였다. 같은 시각에 GET / 하나가 24-27초씩 걸렸고 slow query warning은 0.8-2.1초 단위로 여러 번 찍혔다.
처음에는 인덱스부터 봤다. 그런데 로그를 다시 보니 row를 찾는 과정만으로는 설명이 부족했다. locale 목록만 필요한 코드가 어떤 column까지 읽는지, 그 row에 붙은 HTML payload가 얼마나 큰지, 그걸 매 요청마다 어디까지 옮기는지가 더 중요했다.
결론부터 쓰면 DB가 갑자기 망가진 게 아니었다. 공개 페이지 렌더링 경로가 필요 없는 HTML blob을 너무 많이 읽고 있었다. 여기에 한 페이지의 HTML 안에 큰 inline media payload까지 들어가면서 작은 메타데이터 조회가 매 요청마다 MB 단위 payload 전송으로 바뀌었다.
요약
- 문제: public page
GET /가 24-27초까지 늘고, 같은 시간대에 0.8-2.1초 slow DB query warning이 연속으로 찍혔다. - 원인: locale switcher에 필요한 것은 locale 목록뿐인데 ORM이 큰 HTML column까지 포함한 full row를 읽고 있었다.
- 증폭 요인: 특정 페이지 HTML 안에 큰 inline media payload가 있었고, 같은 payload가 localized row에도 복사되어 있었다.
- 해결: 조회를 필요한 locale column으로 줄여 projection을 되살리고, 반복 조회 경로에는 보조 인덱스를 추가했다. inline media data source는 HTML normalizer에서 제거했다.
- 결과: root 응답 body는 1,936,992 bytes에서 75,784 bytes로 96.09% 줄었고, warm TTFB median은 1.012초에서 0.123초로 약 8.23배 개선됐다.
처음 보인 증상
로그의 신호는 이렇게 보였다. 요청 식별자는 글에서 제거했다.
API request completed: GET / - Status: 200 - Duration: 24850.14ms
Slow database query: 1390.34ms
Slow database query: 1437.01ms
Slow database query: 1277.03ms
...
API request completed: GET / - Status: 200 - Duration: 25093.56ms
Slow database query: 1615.34ms
...
API request completed: GET / - Status: 200 - Duration: 27724.10ms
Slow database query: 2113.47ms여기서 조심해야 할 점이 있었다. "25초짜리 단일 쿼리"가 보인 게 아니었다. 한 요청 안에서 여러 DB query가 느리게 관측됐고, 같은 초에 public request 여러 개가 겹쳤다. 그래서 첫 질문을 바꿨다.
어떤 쿼리 하나가 index를 못 타나?
-> public render path가 요청마다 무엇을 얼마나 읽고 있나?전공서식으로 말하면 selection predicate만 볼 일이 아니었다. access path, projection, tuple width를 같이 봐야 했다.
전공서 기준으로 문제를 다시 쓰기
Database System Concepts 6판은 11장에서 indexing, 12장에서 query processing과 query cost, 13장에서 query optimization을 다룬다. 이번 문제를 그 순서로 다시 쓰면 꽤 단순해진다.
첫째, 인덱스는 "어떤 row를 찾을지"를 도와준다. 하지만 찾은 row에서 어떤 attribute를 가져올지는 다른 문제다.
둘째, SQL의 select *는 결과 relation의 모든 attribute를 고른다. 책에서는 이걸 SQL 기본 문법으로 설명하지만, 운영에서는 비용 모델이 된다. row 안에 큰 TEXT column이 있으면 select *는 "필요한 값 하나"가 아니라 "큰 payload 전체"를 가져오겠다는 뜻이다.
셋째, relational algebra에서 projection은 단순한 문법 장식이 아니다. 이번 케이스에서는 locale만 projecting 해야 했다. 그래야 DB에서 API process로 넘어오는 tuple 폭이 작아지고, public render request 하나가 localization HTML 전체를 끌고 오지 않는다.
그래서 디버깅 기준을 이렇게 잡았다.
Selection: 현재 페이지에 맞는 localization row를 찾는다.
Projection: switcher에 필요한 locale column만 읽는다.
Cost: row count보다 row width와 transfer size를 먼저 확인한다.
Index: selection predicate와 order by에 맞춰 보조한다.public render path에서 본 실제 쿼리
문제가 난 경로는 공개 페이지 렌더링이었다. 단순화하면 흐름은 이랬다.
GET /{page}
-> 공개 page 조회
-> localization locale 목록 조회
-> locale별 switcher/hreflang 구성
-> stored HTML normalizing
-> HTML response여기서 locale 목록 조회는 실제 HTML이 필요 없다. 루트 페이지에서 필요한 값은 "이 페이지에 어떤 locale이 있는가" 정도다.
그런데 코드상 helper는 LandingLocalization entity 전체를 조회한 뒤 Python에서 .locale만 읽고 있었다.
필요한 값: locale
실제로 읽은 값: localization full row
문제 column: stored HTMLORM 코드에서 흔한 실수다. Python에서는 .locale 하나만 읽는다. 하지만 SQLAlchemy가 entity 전체를 load하면 DB와 application은 full row 비용을 낸다. row 안에 큰 TEXT column이 있으면 SELECT *의 의미가 완전히 달라진다.
DB에서 확인한 숫자
DB에서 본 테이블 크기는 row 수만 보고 예상한 것과 달랐다. row는 많지 않았다. 문제는 tuple 폭이었다.
| 항목 | 값 |
|---|---|
| localization row 수 | 39 rows |
| localization table 총 크기 | 18 MB |
| stored HTML 합계 | 12 MB |
rendered_html 평균 |
328,346 bytes |
rendered_html 최대 |
1,934,756 bytes |
특히 대표 페이지는 더 극단적이었다.
| 항목 | 수정 전 |
|---|---|
| root page HTML | 1,931,827 bytes |
| 현재 locale row 수 | 3 rows |
| 현재 locale HTML 합계 | 5,799,877 bytes |
즉 root page 하나를 렌더링할 때 locale switcher를 만들려고 localized HTML 5.8MB를 추가로 읽을 수 있는 구조였다. 화면에는 언어 목록 몇 글자만 필요했는데 DB에서는 MB 단위 HTML blob을 읽고 있었다.
EXPLAIN도 방향을 확인해줬다. 캐시가 따뜻한 상태에서는 실행 시간 자체가 아주 길지 않았지만, query shape가 문제였다.
-- 기존 의도와 다른 형태
select *
from localization_rows
where page_scope = ...;
-- 필요한 형태
select locale
from landing_localizations
where page_scope = ...;이 차이는 row 수가 적을 때는 잘 안 보인다. 하지만 rendered_html이 2MB 가까이 커지고 같은 요청이 동시에 몰리면 API process, DB connection, network transfer, response normalizing 비용이 함께 커진다.
왜 인덱스만으로 끝낼 수 없었나
인덱스는 필요했다. 같은 페이지와 locale 기준으로 조회하는 경로가 반복되기 때문이다. 그래도 이것만으로는 부족했다.
전공서에서 query cost를 다룰 때도 access path만 보지 않는다. disk I/O, block transfer, intermediate result, 선택한 plan 전체가 비용을 만든다. 이 사건에서는 "찾는 row 수"보다 "찾은 뒤 가져오는 column 크기"가 더 컸다. 인덱스가 row 세 개를 빠르게 찾아줘도, 그 row 세 개에 5.8MB HTML이 붙어 있으면 public request는 여전히 무겁다.
그래서 수정 순서는 이렇게 잡았다.
1. projection 수정: locale 목록 조회는 locale column만 읽는다.
2. index 추가: 반복되는 selection predicate를 보조한다.
3. payload 정리: stored HTML 안의 inline media를 없앤다.
4. guardrail 추가: normalizer가 같은 payload를 다시 저장하지 못하게 막는다.더 큰 증폭 요인: inline video
왜 HTML이 2MB 가까이 됐는지도 따로 봤다. 원인은 하나의 attribute였다.
<source src="data:video/webm;base64,..." type="video/webm" />이 data:video payload 하나가 거의 1.9MB였다. 페이지 HTML 안에는 fallback image도 이미 있었기 때문에 public page에서 반드시 inline video를 들고 있을 이유가 없었다.
문제는 이 payload가 원본 페이지에만 있는 것이 아니었다. localization row에도 HTML이 저장되기 때문에 같은 inline video가 locale별 row에 반복 저장됐다. 그래서 root HTML 1.9MB, localized HTML 1.9MB, 또 다른 locale 1.9MB 식으로 커졌다.
한 번의 운영 데이터 정리
먼저 이미 생성된 HTML을 정리했다. inline video를 제거하고, <video> 내부 fallback <img>가 있으면 그 이미지를 남기는 방식이었다.
대표 페이지 기준 변화는 이랬다.
| 대상 | 수정 전 | 수정 후 | 감소 |
|---|---|---|---|
| root page HTML | 1,931,827 bytes | 70,619 bytes | 96.34% 감소, 27.36배 축소 |
| 3개 locale HTML 합계 | 5,799,877 bytes | 216,231 bytes | 96.27% 감소, 26.82배 축소 |
| public root response body | 1,936,992 bytes | 75,784 bytes | 96.09% 감소, 25.56배 축소 |
public /ko/ response body |
1,938,888 bytes | 77,674 bytes | 95.99% 감소, 24.96배 축소 |
이 정리는 코드 수정 전에도 즉시 효과가 있었다. root와 locale page가 더 이상 2MB 가까운 HTML을 내려주지 않았기 때문이다.
코드상 재발 방지
이미 생성된 데이터를 정리하는 것만으로는 부족했다. 다음 HTML import나 localization 생성에서 같은 문제가 다시 들어올 수 있기 때문이다. 코드는 네 군데를 고쳤다.
첫째, locale 목록 조회를 full row query에서 scalar query로 바꿨다. 이게 이번 수정의 중심이다.
select localization.locale
from localization
where page_scope = :page_scope
order by locale;둘째, 이 조회 패턴에 맞춰 인덱스를 추가했다. projection으로 tuple 폭을 줄이고, index로 lookup 경로를 안정시킨 셈이다.
create index on localization (page_scope, locale);셋째, localization status 조회도 큰 HTML column을 불필요하게 로드하지 않도록 필요한 metadata만 읽게 했다. status 화면에 필요한 것은 locale, 상태, timestamp, metadata이지 전체 HTML이 아니다.
넷째, HTML normalizer에서 inline data:video source를 제거하도록 했다. <video> 안에 fallback <img>가 있으면 video wrapper를 image로 치환하고, fallback이 없으면 video data source만 제거한다. public response normalizer와 stored HTML normalizer 양쪽에 같은 규칙을 적용했다.
응답 시간 개선
정리 전후 curl로 같은 public URL을 여러 번 확인했다.
| 측정 | 수정 전 | 수정 후 |
|---|---|---|
| root response size | 1,936,992 bytes | 75,784 bytes |
| root warm TTFB median | 1.012s | 0.123s |
| root warm TTFB 개선 | - | 87.85% 감소, 8.23배 개선 |
| root total time range | 1.328-1.650s | 0.099-0.140s |
/ko/ response size |
1,938,888 bytes | 77,674 bytes |
/ko/ warm TTFB range |
약 1.00-1.10s | 0.124-0.154s |
/ko/ total time range |
최대 2.46s | 0.131-0.161s |
배포 직후 첫 요청은 container warmup 영향으로 5.5초가 한 번 나왔다. 그 다음 warm request들은 root가 0.09-0.14초, /ko/가 0.13-0.16초 범위로 안정됐다.
검증
검증은 세 단계로 나눴다.
- 로컬 테스트
git diff --check
lint
targeted tests for indexing, HTML normalization, and public rendering- 마이그레이션 상태
schema migration head matched the deployed database
the expected localization lookup index existed- 배포 후 smoke
GET / -> 200, 75,784 bytes, warm total 0.099-0.140s
GET /ko/ -> 200, 77,674 bytes, warm total 0.131-0.161s
slow query warnings for the checked path no longer appearedCI/CD에서도 API static check, typing, tests, image build, deploy job이 통과했다.
이번에 배운 점
row 수는 테이블 무게를 말해주지 않는다. 작은 테이블도 row 안에 2MB TEXT가 있으면 public request path에서 충분히 위험하다. 전공서의 cost model을 운영 코드에 대입하면 이 말은 더 단순해진다. relation cardinality만 보지 말고 tuple width와 transfer size를 같이 봐야 한다.
ORM entity 조회는 생각보다 비싸다. Python에서는 .locale 하나만 읽더라도 SQL이 full row를 가져오면 DB와 application은 full row 비용을 낸다. public render path에서는 "필요한 column만 select한다"가 성능 최적화라기보다 안전 규칙에 가깝다.
stored HTML 기반 제품에서는 HTML 크기 budget이 필요하다. 특히 data:image, data:video, base64 font, 거대한 inline script 같은 값은 한 번 들어오면 원본 HTML, localized HTML, revision HTML로 복제되기 쉽다.
마지막으로 slow query warning은 "DB가 느리다"는 결론이 아니라 "이 요청이 DB와 주고받는 일이 느리다"는 신호로 봐야 한다. 이번 문제는 index 하나만의 문제가 아니었다. query shape, stored blob, HTML import, localization lifecycle이 겹친 결과였다.
다음에는 이렇게 막을 것
- public render path에서 full HTML column을 읽는 query를 테스트로 잡는다.
- HTML import/generation 단계에서 inline media budget을 둔다.
- localization status와 locale switcher는 HTML blob 없이 metadata만 읽게 한다.
- landing HTML과 localization HTML의 byte size를 운영 지표로 남긴다.
- slow query 로그는 SQL text, row width, request group, response size를 함께 볼 수 있게 개선한다.
참고
- Abraham Silberschatz, Henry F. Korth, S. Sudarshan, Database System Concepts, 6th edition
- Chapter 11: Indexing and Hashing
- Chapter 12: Query Processing, Measures of Query Cost, Selection Operation
- Chapter 13: Query Optimization
