결제 API 응답속도를 30% 개선하면서 배운 SQL 튜닝
Slow Query의 실행 계획을 따라 Subquery와 JOIN, 인덱스를 다시 설계해 결제 API 응답속도를 30% 개선한 과정을 적었다.
Spring Boot 전환 뒤 결제 API의 응답이 느려지는 구간을 확인했다. 애플리케이션 코드보다 데이터 조회 시간이 길었고, 같은 조건의 요청에서도 데이터 양에 따라 편차가 커졌다. Slow Query를 모아 보니 결제 내역과 가맹점 조건을 함께 찾는 쿼리가 반복해서 나타났다.
처음에는 쿼리를 짧게 만드는 데 집중했다. 실제로 필요했던 것은 문장의 길이가 아니라 데이터베이스가 어떤 순서로 얼마나 많은 행을 읽는지 확인하는 일이었다.
실행 계획에서 시작했다
운영 쿼리는 회사 정보가 드러나지 않도록 단순화했다. 구조는 결제 테이블에서 조건에 맞는 대상을 찾고, 가맹점과 상태 정보를 다시 확인하는 형태였다.
SELECT p.*
FROM payment p
WHERE p.merchant_id IN (
SELECT m.id
FROM merchant m
WHERE m.status = 'ACTIVE'
)
AND p.approved_at BETWEEN :from AND :to;
실행 계획을 확인하니 조건을 좁히기 전에 읽는 행이 많았고, Subquery 안의 결과와 결제 범위를 결합하는 과정에서 비용이 커졌다. 인덱스가 있다는 사실보다 실제 조건 순서와 맞는지가 중요했다.
Subquery를 JOIN으로 바꾸기 전에 확인한 것
Subquery가 항상 느리고 JOIN이 항상 빠른 것은 아니었다. 두 형태의 실행 계획과 반환 행 수를 같은 조건에서 비교했다. JOIN으로 바꿨을 때 필터가 더 이른 단계에 적용되는 것을 확인한 뒤 구조를 바꿨다.
SELECT p.*
FROM payment p
JOIN merchant m ON m.id = p.merchant_id
WHERE m.status = 'ACTIVE'
AND p.approved_at BETWEEN :from AND :to;
중복 행이 생기지 않는 관계인지 확인했고, 조회 결과가 기존 쿼리와 같은지도 비교했다. SQL 튜닝은 속도만 빨라지고 결과가 달라지면 실패였다.
인덱스는 조회 조건의 순서로 다시 봤다
단일 컬럼 인덱스를 여러 개 두는 대신 실제 조회에서 함께 사용하는 조건과 정렬을 기준으로 복합 인덱스를 검토했다. 선택도가 낮은 상태값을 앞에 두는 대신 가맹점과 승인 시각처럼 범위를 빠르게 줄이는 조건을 중심으로 순서를 잡았다.
쓰기 비용도 함께 확인했다. 결제 테이블은 조회만 하는 테이블이 아니므로 인덱스를 늘릴수록 승인과 상태 변경 비용이 커진다. 사용하지 않는 인덱스를 남겨 두지 않고, 개선 대상 쿼리에 필요한 인덱스만 유지했다.
느린 API에서 실제 SQL까지 연결했다
Slow Query 목록만 보면 어떤 사용자 요청에서 실행됐는지 알기 어려웠다. 반대로 API 로그만 보면 전체 시간 중 데이터베이스가 차지한 비중을 추측하게 됐다. 요청 식별자와 쿼리 측정 정보를 연결해 느린 API에서 실제 SQL까지 따라갈 수 있게 했다.
requestId=...
api=payment-history
elapsed=...
query=payment-search
rows=...
개별 결제 ID나 개인정보를 성능 태그로 남기지는 않았다. API 종류, 조회 조건의 유형, 쿼리 이름처럼 값의 종류가 제한된 정보만 사용했다. 상세 거래는 권한이 통제된 로그에서 요청 식별자로 확인했다.
느린 요청 하나만 고르지 않고 같은 API의 여러 조회 범위를 봤다. 최근 데이터는 빠르고 기간이 길어질수록 급격히 느려지는지, 특정 가맹점 조건에서만 느린지에 따라 원인이 달랐다. 재현 조건을 고정한 뒤에야 실행 계획을 비교했다.
실행 계획에서 네 가지를 읽었다
| 항목 | 확인한 질문 |
|---|---|
| 접근 방식 | 전체 스캔인가, 인덱스로 범위를 줄이는가 |
| 조인 순서 | 작게 줄어든 결과에서 다음 테이블을 찾는가 |
| 예상 행 수 | 옵티마이저의 추정과 실제 분포가 맞는가 |
| 추가 작업 | 정렬·임시 결과·중복 제거 비용이 생기는가 |
인덱스를 사용한다는 표시만 보고 끝내지 않았다. 인덱스를 탔어도 너무 넓은 범위를 읽은 뒤 필터링하면 비용은 그대로 컸다. 반대로 데이터가 적은 조건에서는 전체 스캔이 더 단순할 수도 있었다.
예상 행 수가 실제와 크게 다르면 쿼리 모양만 바꿔도 계획이 불안정했다. 통계 정보와 값의 분포를 확인하고, 운영 데이터와 비슷한 조건에서 계획이 유지되는지 봤다.
복합 인덱스는 쿼리 한 줄이 아니라 접근 패턴으로 설계했다
결제 조회는 보통 가맹점 같은 동등 조건으로 범위를 좁힌 뒤 승인 시각 범위를 사용했다. 그 접근 순서에 맞춰 인덱스 후보를 만들었다. 정렬과 반환 컬럼까지 무조건 넣어 큰 인덱스를 만드는 방식은 피했다.
CREATE INDEX ix_payment_merchant_approved_at
ON payment (merchant_id, approved_at);
인덱스 첫 컬럼을 사용하지 않는 다른 조회에서도 효과가 있을 것이라고 기대하지 않았다. API별 쿼리 패턴을 모아 같은 인덱스를 실제로 공유할 수 있는지 확인했다. 이름만 비슷하고 조건 순서가 다른 인덱스가 중복으로 생기지 않게 했다.
승인과 취소는 쓰기가 빈번하므로 인덱스 추가 뒤 쓰기 경로도 다시 봤다. 조회 하나가 빨라지는 대신 전체 거래 저장이 무거워진다면 다른 방법을 골라야 했다. 튜닝 대상 API와 핵심 승인 경로를 함께 측정했다.
결과가 같은지를 자동으로 비교했다
JOIN 변경은 중복 행과 누락을 만들기 쉬웠다. 테스트 데이터 몇 건만 눈으로 보지 않고 동일한 입력 집합에 기존 쿼리와 새 쿼리를 실행해 키와 주요 컬럼을 비교했다.
oldResult = legacyQuery(condition)
newResult = tunedQuery(condition)
assert samePaymentIds(oldResult, newResult)
assert sameAmounts(oldResult, newResult)
assert sameOrder(oldResult, newResult)
빈 결과, 경계 시각, 비활성 가맹점, 같은 시각의 여러 결제처럼 결과가 흔들리기 쉬운 조건을 따로 넣었다. 페이지네이션이 있는 조회는 정렬 키가 유일한지도 확인했다. 속도가 빨라도 페이지 사이에서 데이터가 빠지거나 겹치면 적용하지 않았다.
쿼리만 고치고 끝내지 않았다
애플리케이션이 같은 조회를 반복 호출하거나 필요 없는 컬럼을 모두 가져오면 SQL 하나의 개선 효과가 제한됐다. 한 요청에서 쿼리가 몇 번 실행되는지, 반환한 데이터를 실제로 얼마나 사용하는지도 봤다.
목록 API는 화면에 필요한 컬럼만 조회하고 상세 데이터는 상세 API에서 읽게 했다. 다만 조회를 무조건 쪼개 네트워크 왕복을 늘리지 않도록 사용자 흐름을 기준으로 결정했다. 데이터베이스 비용을 애플리케이션 비용으로 옮기는 튜닝은 피했다.
측정 기준을 고정했다
변경 전후를 같은 데이터 범위와 같은 API 조건에서 비교했다. 한 번의 가장 빠른 결과가 아니라 반복 측정한 응답 분포를 봤다. 쿼리 구조와 인덱스를 바꾼 뒤 결제 API 응답속도는 30% 개선됐다.
배포 뒤에도 같은 쿼리 이름과 API 지표를 관찰했다. 데이터가 늘어도 계획이 유지되는지, 다른 조회가 느려지지 않았는지 확인했다. 문제가 생기면 인덱스와 쿼리를 각각 되돌릴 수 있게 변경 단위를 나눴다.
30%라는 결과보다 중요한 것은 재현 가능한 측정 절차가 생긴 것이었다. 다음 성능 문제에서도 감으로 인덱스를 추가하지 않고 요청, SQL, 실행 계획, 결과 동일성, 쓰기 비용을 같은 순서로 확인할 수 있었다.
가장 크게 배운 점은 SQL을 문법으로만 보면 안 된다는 것이었다. 실행 계획, 읽은 행 수, 반환 결과, 쓰기 비용을 함께 봐야 했다. 이후 Slow Query를 만날 때는 쿼리부터 고치지 않고 어떤 데이터 접근이 비용을 만드는지 먼저 확인했다.