성능2026년 4월 30일3분 읽기

느린 API를 EXPLAIN으로 봤더니 SQL은 0.5ms였다

체감이 느린 API를 쿼리 탓이라 의심하고 EXPLAIN을 찍었는데 실행 시간이 0.5ms였다. 진짜 병목은 SQL 바깥, N+1과 직렬화와 중복 호출에 있었다. 밑단부터 보는 습관이 이번엔 DB가 범인이 아니라고 알려준 이야기.

#성능#EXPLAIN#N+1#PostgreSQL#Backend

느린 API를 EXPLAIN으로 봤더니 SQL은 0.5ms였다

상황

수검자 상세 화면이 느리다는 이야기에서 시작됐다. 앱이 이 화면을 그릴 때 testSub(검사 하위 항목)를 28번이나 호출하고 있었다. 그래서 처음 나온 질문은 이거였다.

"앱에서 testSub를 28번 호출하는데, 한 방에 받는 API를 만들면 빨라지지 않을까?"

자연스러운 접근이다. 호출이 많으니 묶으면 빨라질 것 같고, 그 28번이 느린 쿼리라면 더더욱. 그런데 API를 새로 만들기 전에 확인할 게 있었다. 정말 쿼리가 느린 게 맞는지다.

일단 EXPLAIN부터

나는 성능 문제를 만나면 밑단부터 보는 편이다. 대부분의 서비스에서 병목은 DB에서 일어나기 때문이다. 그래서 이번에도 메인 쿼리부터 EXPLAIN (ANALYZE, BUFFERS, VERBOSE)로 실행 계획을 떴다.

결과가 예상과 달랐다.

text
Execution Time: 0.522 ms
Planning Time:  0.412 ms

실행 시간이 0.5ms였다. 느릴 게 없었다. 체감 지연이 초 단위인데 쿼리는 1ms도 안 걸린다. 범인이 SQL이 아니라는 뜻이다.

계획을 더 뜯어봐도 문제될 게 없었다.

text
Seq Scan on test    (Rows Removed by Filter: 2156)
Seq Scan on staff   (146 rows filter)

Seq Scan이 보이니까 "인덱스가 없어서 풀스캔이네" 싶지만, 이 경우엔 정상이다. test 테이블이 2158행, staff가 작은 테이블이라 planner가 인덱스를 타는 것보다 그냥 전부 읽는 게 빠르다고 판단한 거다. 데이터가 더 쌓이면 인덱스가 필요해지겠지만 지금은 아니다.

한 가지 눈에 띈 건 Planning Time이 Execution Time과 비슷하다는 점이었다. 실행만큼 계획 짜는 데 시간을 쓴다는 건, Hibernate가 native query를 매번 새로 파싱하고 있다는 신호다. 그래도 둘 다 1ms 미만이라 이게 체감 지연의 원인일 수는 없었다.

결론은 분명했다. SQL은 빠르다. 지연은 SQL 바깥에서 온다.

진짜 범인들

쿼리가 아니라면 어디서 시간이 새고 있나. 응답 경로를 따라가 보니 범인이 한 둘이 아니었다.

1. N+1 (이게 제일 컸다)

메인 쿼리로 결과 N개를 가져온 뒤, 후처리에서 각 결과마다 testSubRepository.findAllByTestId를 다시 호출하고 있었다. 결과가 N개면 추가 쿼리가 N번. 개별 쿼리는 0.5ms로 빠르지만, 그게 수십 번 쌓이면 왕복 비용이 선형으로 늘어난다. 각각은 빠른데 합치면 느린, 전형적인 N+1이다.

2. 같은 응답을 3번 받고 있었다

이건 좀 황당했다. 앱이 검사 stage 3종(SNSB 계열 세 단계)을 각각 따로 호출하는데, 서버 쪽 필터가 stage 파라미터를 제대로 안 걸러서 세 번 다 똑같은 전체 응답을 받고 있었다. 즉 같은 데이터를 세 번 받아서 앱이 그중 필요한 걸 골라 쓰고 있었던 거다. 네트워크도, 직렬화도 세 배로 쓰면서.

3. JSONB 직렬화

검사 원본 데이터(rawData)가 JSONB로 들어있는데, 이게 응답마다 직렬화/역직렬화되면서 비용을 더했다. 한 건이면 작지만 위의 N+1과 3중 호출에 곱해지면 무시 못 할 양이 된다.

4. testSub 28번 호출

처음 질문의 그 28번. 이건 위 문제들의 결과이기도 했다. 화면이 데이터를 잘게 나눠 부르는 구조라 호출 수 자체가 많았다.

그래서 새 API는 안 만들었다

처음 질문이 "28번을 한 방에 받는 API를 만들까"였는데, EXPLAIN을 떠보고 나니 그게 답이 아니었다. 묶는 API를 새로 만들어도 N+1과 3중 중복 호출이 남아있으면 근본 해결이 안 된다. 게다가 testSub를 한 번에 받는 엔드포인트는 이미 있었다. 새로 만들 게 아니라 앱의 호출 구조를 정리하고, N+1을 묶고, 중복 stage 호출을 없애는 게 맞는 방향이었다.

만약 EXPLAIN 안 보고 "느리다니까 쿼리겠지" 하고 바로 묶음 API부터 만들었으면, 일은 일대로 하고 체감은 별로 안 나아졌을 거다.

마치며

성능 문제를 만나면 나는 밑단부터 본다. 대부분의 서비스에서 병목은 DB에서 일어나니까, 쿼리와 실행 계획부터 보는 게 보통 빠른 길이다. 그런데 이번엔 그 습관이 정반대 결론을 내줬다. 밑단을 봤더니 밑단은 멀쩡했고, 문제는 그 위 애플리케이션 단에 있었다.

전에 슬로우 쿼리를 복합 인덱스로 잡은 적이 있다. 그때는 EXPLAIN이 "인덱스가 답"이라고 알려줬다. 이번엔 같은 EXPLAIN이 "인덱스는 답이 아니다, SQL을 건드리지 마라"라고 알려줬다. 도구는 같은데 결론이 정반대인 게 핵심이다. EXPLAIN은 "어디를 고쳐야 하는가"가 아니라 "여기가 문제인가 아닌가"를 먼저 알려준다. 그 답이 "아니오"면 시선을 위로 올려야 한다.

대부분의 지연은 SQL에서 온다. 하지만 항상은 아니다. 애플리케이션 단에도 얼마든지 병목이 숨어 있을 수 있다. 그걸 가르는 건 추측이 아니라 측정이다. 느리다는 말을 들었을 때 코드부터 고치는 대신 EXPLAIN 한 줄을 먼저 떠보는 게, 엉뚱한 데 일하는 걸 막아준다.

#성능#EXPLAIN#N+1#PostgreSQL#Backend

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js