DB·SQL 실무 가이드 · Part 15

ORM이 만든 쿼리를 DB에서 추적하기

애플리케이션이 실제로 무슨 쿼리를 던졌는지 DB에서 확인하기

작성 기준2026년 7월버전과 지원 현황은 이후 달라질 수 있으니 공식 문서를 함께 확인하세요.

이 파트에서 다루는 내용

코드에 없는 SQL실제 SQL 확인N+1 진단동적 SQL과 커서ORM 쿼리 튜닝
01

이 트랙은 DB 쪽에서 본 애플리케이션입니다

JPA를 어떻게 매핑하고 MyBatis 매퍼를 어떻게 쓰는지는 Spring Boot 코스에서 다룹니다. 이 트랙은 반대 방향입니다. 그렇게 작성한 코드가 DB에 무엇을 남기는지, 문제가 생겼을 때 DB 쪽에서 어떻게 찾아내는지를 다룹니다.

이 구분이 실무에서 의미가 있는 이유가 있습니다. 장애 상황에서 우리가 처음 받는 정보는 대개 애플리케이션 코드가 아니라 DB 쪽 증상이기 때문입니다. 어떤 SQL이 부하 상위에 올라와 있는데 코드에서 검색해도 안 나오는 상황, 이걸 푸는 것이 이 파트입니다.

Spring Boot 코스
쓰는 쪽

엔티티 매핑, 영속성 컨텍스트, 지연 로딩 설정, 매퍼 작성과 동적 SQL 문법을 다룹니다.

이 파트
보는 쪽

그 결과로 나간 SQL을 V$SQL에서 찾고, 실행 횟수와 커서 개수로 문제를 판별합니다.

왜 나누는가
실무

코드를 볼 수 없는 상황에서도 진단해야 할 때가 있습니다. 운영 DB 접근 권한만 있고 소스는 다른 팀이 관리하는 경우가 흔합니다.

02

실제로 나간 SQL은 세 곳에서 볼 수 있습니다

ORM이 만든 SQL은 소스 코드에 문자열로 존재하지 않습니다. 실행 시점에 조립되기 때문에 검색으로는 찾을 수 없습니다.

확인할 수 있는 위치가 세 곳인데, 각각 보이는 것이 다릅니다.

프레임워크 로그
가장 가까움

Hibernate의 SQL 로그나 MyBatis 로그입니다. 개발 중에는 편하지만 바인드 값이 물음표로 나오는 경우가 많고, 운영에서 켜 두면 로그가 폭증합니다.

JDBC 프록시
값까지

P6Spy 같은 도구를 끼우면 바인드 값이 채워진 완전한 SQL을 볼 수 있습니다. 개발·검증 환경에서 유용하고 운영 상시 적용은 부하를 봐야 합니다.

DB의 V$SQL
최종 진실

DB가 실제로 받은 것입니다. 애플리케이션 설정과 무관하고, 실행 횟수와 누적 시간까지 함께 나옵니다. 운영 진단의 기준으로 삼을 곳입니다.

SQL Trace
증거

Part 14의 방법입니다. 특정 요청 구간에서 어떤 SQL이 몇 번 나갔는지 파일로 남습니다. N+1 확인에 가장 확실합니다.

03

N+1은 실행 횟수로 드러납니다

N+1 문제 자체는 Spring Boot 코스에서 다룹니다. 여기서는 그것이 DB 쪽에서 어떤 모양으로 보이는지가 중요합니다.

특징이 뚜렷합니다. 한 건씩 조회하는 아주 단순한 SQL이 있는데, 실행 횟수가 비정상적으로 많고, 한 번당 시간은 짧아서 개별로는 문제로 보이지 않습니다. 그런데 누적 시간을 보면 상위권입니다.

실행 횟수가 튀는 SQL 찾기sql
SELECT sql_id,
       executions,
       ROUND(elapsed_time / 1000000, 1)                        AS total_sec,
       ROUND(elapsed_time / GREATEST(executions, 1) / 1000, 2) AS avg_ms,
       ROUND(buffer_gets / GREATEST(executions, 1), 1)         AS gets_per_exec,
       SUBSTR(sql_text, 1, 100)                                AS sql_head
FROM   v$sql
WHERE  executions > 1000
AND    elapsed_time / GREATEST(executions, 1) < 10000   -- 한 번은 10ms 미만
ORDER BY elapsed_time DESC
FETCH FIRST 20 ROWS ONLY;

평균은 빠른데 실행 횟수가 수만 번인 단건 조회가 상위에 보이면 N+1을 의심합니다. 화면 한 번 열 때 몇 번 실행되는지 세어 보면 확실해집니다. Part 14의 SQL Trace를 특정 요청에만 켜면 정확한 횟수가 나옵니다.

판단 기준

실행 횟수가 많다고 전부 문제는 아닙니다. 기준은 단순합니다. 사용자 요청 한 번에 그 SQL이 몇 번 나가야 정상인가를 먼저 정하고, 실제 횟수와 비교합니다. 목록 화면 한 번에 100번 나간다면 대상 건수만큼 반복되고 있는 것입니다.

04

동적 SQL은 커서를 폭발시킵니다

MyBatis의 조건부 동적 SQL은 검색 화면에서 아주 편리합니다. 사용자가 입력한 조건만 WHERE 절에 붙습니다.

그런데 DB 입장에서는 조건 조합마다 다른 SQL 문장입니다. 조건이 6개면 이론상 64가지 문장이 생기고, 각각 따로 파싱되어 공유 풀을 차지합니다. Part 9에서 다룬 하드 파싱 문제가 여기서 규모 있게 나타납니다.

IN 절 개수 가변
같은 원인

IN 목록의 항목 수가 매번 다르면 문장도 매번 달라집니다. 10개, 50개, 100개 단위로 맞추고 남는 자리를 채우는 방식으로 줄일 수 있습니다.

IN 절 1000개 제한
Oracle 제약

IN 목록에 표현식을 1000개 넘게 넣으면 ORA-01795 가 납니다. 대상이 많으면 임시 테이블에 넣고 조인하는 편이 낫습니다.

공유 풀 압박
증상

커서가 과도하게 쌓이면 파싱 대기와 메모리 경합이 생깁니다. 특정 시간대에 전체가 느려지는 형태로 나타납니다.

확인 방법
진단

위 쿼리로 변형 개수를 세어 보고, V$SESSION 의 대기 이벤트에 파싱 관련 경합이 잡히는지 봅니다.

비슷한데 조금씩 다른 SQL 찾기sql
-- 같은 뿌리에서 나온 변형들이 몇 개나 쌓였는지
SELECT SUBSTR(sql_text, 1, 60) AS sql_head,
       COUNT(*)                AS variant_cnt,
       SUM(executions)         AS total_exec,
       SUM(sharable_mem)       AS mem_bytes
FROM   v$sql
GROUP BY SUBSTR(sql_text, 1, 60)
HAVING COUNT(*) > 20
ORDER BY variant_cnt DESC;

variant_cnt 가 수백 개면 같은 화면의 조건 조합이 전부 별도 문장으로 쌓인 것입니다. 조건 조합이 많은 검색 화면은 어느 정도 불가피하지만, 값까지 문장에 박혀 있다면 그건 바인딩 문제이므로 고쳐야 합니다.

값이 문장에 박히는 경우sql
-- MyBatis에서 이렇게 쓰면 값이 문장에 그대로 들어갑니다
--   WHERE customer_id = ${customerId}
-- V$SQL 에 이런 문장이 값만 다르게 수천 개 쌓입니다
SELECT * FROM orders WHERE customer_id = 1001;
SELECT * FROM orders WHERE customer_id = 1002;

-- #{} 를 쓰면 문장 하나가 재사용됩니다
SELECT * FROM orders WHERE customer_id = :1;

값에 문자열 치환을 쓰면 하드 파싱과 SQL 인젝션이 함께 따라옵니다. 정렬 컬럼명이나 테이블명처럼 구조가 바뀌는 자리에만 쓰고, 그때도 허용 목록으로 검증합니다. Spring Boot 코스의 MyBatis 파트와 이어지는 내용입니다.

05

ORM이 만든 쿼리는 튜닝하기 어렵습니다

느린 SQL을 찾았는데 그것이 ORM이 자동 생성한 것이면, 힌트를 넣거나 쿼리 구조를 바꾸는 일이 코드 쪽에서 쉽지 않습니다. 매핑을 바꾸면 다른 기능에 영향이 갑니다.

이때의 판단 기준은 단순합니다. 그 쿼리가 성능이 중요한 소수의 지점이라면, ORM에 맡기지 말고 SQL을 직접 작성하는 쪽으로 분리합니다.

  • 조회가 복잡하고 성능이 중요한 화면은 네이티브 쿼리나 MyBatis로 분리하는 것이 현실적입니다.
  • 분리한 쿼리는 Part 7의 방법으로 실행계획을 직접 확인하고 관리합니다.
  • 대량 저장은 ORM의 반복 저장 대신 JDBC 배치나 `INSERT ... SELECT` 로 처리합니다. Part 13의 집합 처리와 이어집니다.
  • Part 14의 SQL 출처 태깅을 함께 적용하면, 어떤 기능의 쿼리인지 DB에서 바로 보입니다.
  • 전부를 SQL로 되돌릴 필요는 없습니다. 문제가 확인된 지점만 분리하는 것이 유지보수 비용이 가장 낮습니다.
이 파트의 핵심

ORM을 쓰든 안 쓰든 DB에 도착하는 것은 결국 SQL입니다. 그 SQL을 볼 수 없으면 진단도 튜닝도 못 합니다. 개발 단계에서 생성된 쿼리를 한 번은 눈으로 확인하는 습관, 그리고 운영에서 V$SQL로 확인하는 경로를 갖추는 것이 이 파트의 목표입니다.

체크

이 파트 완료 기준