느린 SQL 원인 진단: 잠금 대기·I/O·연결 지연과 차단 세션 확인
1장. 실행 계획은 정상인데 사용자는 2초를 기다렸다#
고객 7의 주문을 조회하는 SQL이 있습니다.
SELECT
order_id,
amount
FROM orders
WHERE customer_id = 7
AND ordered_at >= DATE '2026-09-01';개발자는 실행 계획을 확인했습니다.
인덱스도 사용합니다.
결과도 12건뿐입니다.
DB에서 직접 실행하면:
80ms정도입니다.
그런데 실제 사용자는 화면을 보기까지:
2,000ms를 기다립니다.
이 상황에서 바로:
인덱스를 더 만들어야 한다.
라고 결론 내리면 원인을 놓칠 수 있습니다.
실제 2초는 다음처럼 구성되어 있을 수 있습니다.
연결 풀 대기
300ms
DB SQL 경과 시간
1,500ms
결과 변환·전송
200ms그리고 DB의 1,500ms 안에는:
잠금 대기
1,200ms
실제 읽기·계산
300ms가 포함되어 있을 수도 있습니다.
이 경우 인덱스보다 먼저 봐야 할 것은 누가 이 SQL을 막고 있는가입니다.
2장. 느린 SQL과 느린 요청은 같은 말이 아니다#
사용자가 경험하는 전체 응답 시간은 SQL 실행 시간보다 넓습니다.
개념적으로:
flowchart LR
A["요청 시작"] --> B["연결 획득 대기"]
B --> C["SQL 전달"]
C --> D["DB 실행"]
D --> E["결과 전송"]
E --> F["애플리케이션 변환"]
F --> G["응답 완료"]따라서:
사용자 응답시간
=
DB 실행시간이라고 단정할 수 없습니다.
3장. 요청 시간을 먼저 구간으로 나눠야 한다#
한 요청이 2초 걸렸다고 하겠습니다.
| 구간 | 시간 |
|---|---|
| 연결 풀 대기 | 300ms |
| DB 경과 시간 | 1,500ms |
| 결과 처리·전송 | 200ms |
| 전체 | 2,000ms |
이 표만으로도 문제 범위가 상당히 좁아집니다.
만약 DB 시간이:
200ms이고 연결 풀이:
1,600ms라면 실행 계획 튜닝이 주원인은 아닙니다.
4장. 성능 진단의 첫 질문은 “어디에서 기다렸는가”다#
느리다는 현상을 다음 범주로 나눌 수 있습니다.
연결 획득 대기
SQL 파싱·계획 생성
CPU 실행
메모리 처리
버퍼 접근
물리 I/O
락 대기
커밋 대기
복제 대기
결과 네트워크 전송각 원인의 해결 방법은 다릅니다.
5장. SQL 내부에서도 하나의 직선 경로로만 움직이지 않는다#
SQL 처리 구조를 단순화하면 다음과 같습니다.
flowchart TD
Q["SQL"] --> P["파싱·분석"]
P --> O["옵티마이저"]
O --> E["실행기"]
E --> B["버퍼"]
B --> S["저장 장치"]
E --> L["잠금·동시성"]
E --> R["결과 반환"]하지만 실제 실행 중에는 실행기가 반복해서:
버퍼 확인
페이지 요청
조건 평가
잠금 확인
행 반환을 수행합니다.
따라서:
파서
→ 옵티마이저
→ 디스크
→ 끝처럼 한 번만 흐르는 구조로 보면 안 됩니다.
6장. 파싱·분석 단계에서는 SQL 자체를 이해한다#
DBMS는 먼저 SQL을 해석합니다.
예:
SELECT order_id, amount
FROM orders
WHERE customer_id = 7;여기에서 확인할 수 있는 것은:
SQL 문법
테이블 이름
컬럼 이름
자료형
권한등입니다.
이 단계가 비정상적으로 반복되거나 비슷한 SQL이 수없이 다른 문자열 형태로 생성되면 CPU 사용량이 늘 수 있습니다.
7장. 리터럴만 다른 SQL이 계속 생성되는 상황#
애플리케이션이 다음처럼 SQL을 만든다고 하겠습니다.
customer_id = 1
customer_id = 2
customer_id = 3
customer_id = 4SQL 문자열 자체도 계속 달라집니다.
제품과 설정에 따라 계획 재사용과 파싱 비용에 영향을 줄 수 있습니다.
그래서 매개변수 사용은 보안뿐 아니라 실행 관리 측면에서도 도움이 될 수 있습니다.
다만:
매개변수화하면 항상 최적 계획이 나온다.
라고 단정해서는 안 됩니다.
데이터 분포에 따라 값별로 적합한 계획이 달라질 수 있기 때문입니다.
8장. 옵티마이저는 실제 결과를 미리 아는 것이 아니다#
옵티마이저는 통계 정보를 이용해 예상합니다.
예:
customer_id = 7
예상 결과
10행이라고 판단합니다.
그런데 실제 실행 결과가:
50,000행이라면 큰 추정 오류입니다.
이 차이가 잘못된 조인 순서나 접근 경로 선택으로 이어질 수 있습니다.
9장. 느린 SQL을 볼 때 예상 행과 실제 행을 비교해야 한다#
PostgreSQL에서는 다음처럼 볼 수 있습니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT
order_id,
amount
FROM orders
WHERE customer_id = 7
AND ordered_at >= DATE '2026-09-01';개념적으로:
estimated rows
10
actual rows
50,000이라면 가장 먼저 통계와 데이터 분포를 의심할 수 있습니다.
10장. EXPLAIN ANALYZE는 실제 SQL을 실행한다#
다음 차이를 구분해야 합니다.
EXPLAIN
SELECT ...;은 실행 계획을 보여줍니다.
반면:
EXPLAIN ANALYZE
SELECT ...;는 실제로 SQL을 실행하면서 실행 정보를 수집합니다.
따라서 UPDATE나 DELETE에 무심코 붙이면 실제 데이터 변경이 일어날 수 있습니다.
운영에서는 반드시 실행 영향을 고려해야 합니다.
11장. BUFFERS는 얼마나 많은 페이지를 접근했는지 보는 데 도움을 준다#
결과 행은 12건뿐이라고 하겠습니다.
그런데 실행 계획에서:
shared hit
45,000과 같은 큰 접근량이 나타났습니다.
사용자에게 반환한 것은 12행이지만 내부에서는 많은 페이지를 검사했을 수 있습니다.
결과 행 수가 작다
≠
실제 작업량이 작다입니다.
12장. 어제와 오늘을 같은 SQL로 비교해 보자#
가상 관찰값입니다.
| 관찰 | 어제 | 오늘 |
|---|---|---|
| 반환 행 | 12 | 12 |
| DB 경과 시간 | 50ms | 2,000ms |
| 버퍼 접근 | 80 | 45,000 |
| 예상 중간 행 | 10 | 10 |
| 실제 중간 행 | 12 | 50,000 |
| 락 대기 | 없음 | 없음 |
이 경우에는:
락보다:
카디널리티 추정 오류
접근 경로 변화
통계 문제
데이터 분포 변화를 먼저 확인하는 편이 자연스럽습니다.
13장. 반대로 실행 계획이 같아도 느려질 수 있다#
어제와 오늘 계획이 완전히 같습니다.
그런데 오늘만 느립니다.
가능한 원인은:
디스크 지연 증가
버퍼 캐시 상태 변화
잠금 대기
CPU 경쟁
스토리지 큐 증가
동기 복제 지연등입니다.
따라서:
실행 계획 동일
=
성능 문제 없음이 아닙니다.
14장. 캐시가 따뜻한지 차가운지도 결과에 영향을 준다#
처음 실행:
300ms두 번째 실행:
20ms이라고 하겠습니다.
첫 실행에서는 저장 장치에서 페이지를 읽고, 두 번째에서는 버퍼 캐시에서 읽었을 수 있습니다.
이를:
인덱스가 두 번째 실행부터 빨라졌다.라고 해석하면 안 됩니다.
캐시 상태가 달라진 것일 수 있습니다.
15장. 캐시 적중률 하나만 보고 성능을 판단하면 안 된다#
전체 DB의 캐시 적중률이:
99%라고 하겠습니다.
매우 좋아 보입니다.
그런데 특정 SQL 하나가 매 실행마다:
수백만 페이지를 반복해서 접근한다면 여전히 비효율적일 수 있습니다.
높은 전체 캐시 적중률이 잘못된 SQL 접근량을 숨길 수 있습니다.
16장. 물리 I/O가 많다고 항상 문제인 것도 아니다#
대형 분석 쿼리가 테이블 대부분을 읽어야 한다고 하겠습니다.
이 경우 순차 읽기가 많아지는 것이 정상일 수 있습니다.
반면 고객 한 명의 주문 10건을 찾는 API가 수백 GB를 읽는다면 문제가 될 가능성이 큽니다.
따라서 I/O 양은 항상 SQL 목적과 결과량에 연결해서 봐야 합니다.
17장. 락 대기는 SQL 자체의 계산 시간이 아니다#
세션 A가 주문 100번 행을 수정합니다.
BEGIN;
UPDATE orders
SET status = 'PAID'
WHERE order_id = 100;아직 COMMIT하지 않았습니다.
세션 B가 같은 행을 수정합니다.
UPDATE orders
SET status = 'CANCELLED'
WHERE order_id = 100;세션 B는 기다릴 수 있습니다.
이때 B가 10초 걸렸다고 해서 UPDATE 계산에 10초가 필요했던 것은 아닙니다.
대부분은 다른 트랜잭션을 기다린 시간일 수 있습니다.
18장. 락 대기 문제에서 가장 먼저 찾아야 할 것은 기다리는 세션보다 막고 있는 세션이다#
사용자가 느린 세션 B만 보면:
B가 느리다.라고 생각할 수 있습니다.
하지만 실제 원인은:
A가 트랜잭션을 오래 열어 둠일 수 있습니다.
따라서 락 문제에서는:
누가 기다리는가?
누가 막고 있는가?
차단 세션은 언제 트랜잭션을 시작했는가?
왜 아직 COMMIT하지 않았는가?를 함께 봐야 합니다.
19장. PostgreSQL에서 현재 차단 관계를 확인할 수 있다#
예:
SELECT
a.pid AS waiting_pid,
pg_blocking_pids(a.pid) AS blocking_pids,
now() - a.query_start AS query_elapsed,
a.wait_event_type,
a.wait_event,
a.query
FROM pg_stat_activity AS a
WHERE cardinality(pg_blocking_pids(a.pid)) > 0;예상 결과:
waiting_pid
802
blocking_pids
{701}
query_elapsed
00:00:42라면 세션 802가 현재 701에 의해 차단되어 있다는 것을 확인할 수 있습니다.
20장. query_elapsed 42초를 곧바로 “락 대기 42초”라고 말하면 안 된다#
query_start 이후 42초가 지났다고 하겠습니다.
그동안 세션이:
CPU 실행
페이지 읽기
락 대기를 순서대로 경험했을 수도 있습니다.
따라서:
query_elapsed
42초
=
락 대기
42초라고 단정하면 안 됩니다.
정확한 대기 지속 시간을 알고 싶다면 반복 관찰이나 별도의 계측이 필요할 수 있습니다.
21장. wait_event는 현재 무엇을 기다리는지 보여주는 단서다#
PostgreSQL 활동 정보에서:
wait_event_type
wait_event를 볼 수 있습니다.
예를 들어 현재:
Lock관련 대기가 보인다면 잠금 문제를 의심할 수 있습니다.
하지만 한 번의 스냅샷만 보고 전체 10초가 모두 락 대기였다고 단정하면 안 됩니다.
22장. 오래 열린 트랜잭션을 함께 확인해야 한다#
차단 세션 701이 다음 상태라고 하겠습니다.
트랜잭션 시작
10:00
현재
10:2020분 동안 트랜잭션이 열려 있습니다.
원인을 조사해 보니:
BEGIN
↓
UPDATE
↓
사용자 입력 기다림
↓
COMMIT구조였습니다.
이 경우 인덱스보다 트랜잭션 경계를 줄이는 것이 핵심 개선일 수 있습니다.
23장. 사용자 입력을 기다리면서 DB 트랜잭션을 열어 두지 않는 것이 좋다#
예:
1. BEGIN
2. 주문 수정
3. 결제 승인 팝업 사용자 확인
4. 2분 대기
5. COMMIT이라면 2분 동안 잠금과 트랜잭션 자원을 유지할 수 있습니다.
가능하면:
사용자 결정
↓
짧은 DB 트랜잭션형태로 만드는 편이 좋습니다.
24장. 락 대기는 연쇄적으로 퍼질 수 있다#
세션 A가 B를 막습니다.
B가 다시 C가 필요로 하는 자원을 잡고 있습니다.
flowchart LR
A["세션 A"] -->|"차단"| B["세션 B"]
B -->|"차단"| C["세션 C"]
C -->|"차단"| D["세션 D"]하나의 오래 열린 트랜잭션이 여러 요청의 지연으로 확산될 수 있습니다.
25장. 가장 느린 세션이 항상 최초 원인은 아니다#
D의 요청이:
20초걸렸다고 하겠습니다.
하지만 D 자체는 단순한 SQL일 수 있습니다.
실제 원인은 가장 앞쪽의 A입니다.
그래서 락 문제에서는 차단 트리의 루트를 찾는 것이 중요합니다.
26장. 잠금 문제가 없는데 SQL이 느리다면 I/O를 본다#
현재 세션은 어떤 락도 기다리지 않습니다.
하지만 SQL이 매우 느립니다.
실행 계획에서:
대량 블록 읽기가 나타납니다.
이 경우 확인할 수 있는 것은:
테이블 스캔 범위
인덱스 선택 여부
필터 선택도
물리 읽기 증가
스토리지 지연입니다.
27장. 같은 I/O 양이어도 저장 장치 상태에 따라 시간이 달라진다#
어제:
10,000 블록 읽기
50ms오늘:
10,000 블록 읽기
500ms라고 하겠습니다.
SQL 작업량은 비슷합니다.
하지만 저장 장치 지연이 커졌습니다.
이 경우 SQL 재작성보다 스토리지 경합이나 시스템 부하를 조사해야 할 수 있습니다.
28장. I/O 문제와 잘못된 SQL 문제는 함께 존재할 수도 있다#
SQL이 불필요하게:
100만 페이지를 읽고 있습니다.
동시에 저장 장치도 느립니다.
이 경우:
SQL 접근량 감소와:
스토리지 지연 개선두 가지가 모두 효과가 있을 수 있습니다.
성능 문제를 하나의 원인으로만 설명하려고 하면 놓칠 수 있습니다.
29장. 커밋 자체가 느릴 수도 있다#
UPDATE 실행은 빠릅니다.
그런데 COMMIT에서 오래 기다립니다.
가능한 원인은:
WAL 저장 지연
스토리지 동기화
동기 복제본 응답 대기등입니다.
이런 상황에서 SELECT용 인덱스를 추가해도 COMMIT 지연은 줄지 않습니다.
30장. 쓰기 SQL에서 실행과 커밋을 따로 측정해 보자#
예:
UPDATE
20ms
COMMIT
800ms전체 요청:
820ms 이상입니다.
애플리케이션에서는 단순히:
DB 처리 820ms라고 보일 수 있습니다.
DB 내부에서는:
실제 변경 계산
20ms
내구성 확보 대기
800ms로 나뉩니다.
31장. 연결 풀 대기도 흔한 병목이다#
DB SQL은 모두 빠릅니다.
하지만 요청 수가 늘자 사용자는 느려졌습니다.
연결 풀:
최대 연결
20개동시 요청:
200개라고 하겠습니다.
앞선 요청이 연결을 반환하기 전까지 나머지는 기다립니다.
flowchart LR
R["200개 요청"] --> P["연결 풀 20개"]
P --> D["DB"]
R --> W["연결 대기 큐"]DB 실행 계획이 완벽해도 사용자 응답은 느릴 수 있습니다.
32장. 연결 풀 크기를 무조건 키우는 것도 해결책이 아니다#
20개가 부족하니:
200개로 늘리자.라고 생각할 수 있습니다.
그러면 DB에 동시에 더 많은 SQL이 들어옵니다.
결과:
CPU 경합 증가
메모리 증가
락 경쟁 증가
I/O 큐 증가가 발생할 수 있습니다.
풀 크기는 DB 처리 용량과 함께 조정해야 합니다.
33장. 연결을 오래 점유하는 트랜잭션부터 찾아야 할 수 있다#
연결 풀이 20개인데 10개가:
idle in transaction상태로 오래 머문다면 실제 처리 가능한 연결은 크게 줄어듭니다.
따라서 연결 풀이 부족할 때는:
풀 크기
연결 점유 시간
긴 트랜잭션
반환 누락을 함께 확인해야 합니다.
34장. 결과 전송 시간이 길 수도 있다#
SQL 실행:
100ms결과 행:
500만 건이라고 하겠습니다.
DB 계산은 끝났지만 애플리케이션으로 전송하는 데 오래 걸립니다.
사용자는 여전히 느리다고 느낍니다.
따라서:
SQL 실행 완료
≠
사용자 응답 완료입니다.
35장. SELECT 별표는 결과 전송 비용까지 늘릴 수 있다#
화면에 필요한 열은:
order_id
status두 개입니다.
그런데 SQL은:
SELECT *
FROM orders;입니다.
테이블에:
긴 메모
JSON
주소
로그 필드등이 있다면 불필요한 데이터까지 전송됩니다.
SQL 실행 계획뿐 아니라 반환 데이터 크기도 봐야 합니다.
36장. 네트워크가 느린 경우도 DB 시간과 분리해야 한다#
DB 서버에서는:
query completed
100ms로 기록됩니다.
하지만 애플리케이션은:
800ms뒤에 결과를 받습니다.
가능한 원인은:
네트워크 지연
대량 결과
클라이언트 처리 속도
TLS 처리
중간 프록시등입니다.
DB 로그만으로는 전체 사용자 지연을 설명하지 못합니다.
37장. 한 요청 ID로 모든 계층을 연결하면 진단이 쉬워진다#
예:
request_id
REQ-20261001-8721애플리케이션:
00.000
요청 시작
00.280
DB 연결 획득
00.300
SQL 시작
01.800
SQL 종료
02.000
응답 완료DB:
SQL fingerprint
Q-812
pid
802이렇게 연결하면 사용자의 2초가 어디에서 발생했는지 재구성하기 쉽습니다.
38장. SQL 문자열 전체보다 지문으로 묶는 것이 유용할 수 있다#
다음 SQL들은 값만 다릅니다.
customer_id = 1
customer_id = 2
customer_id = 3논리적으로 같은 쿼리 유형입니다.
모니터링에서는 리터럴을 제거한 SQL 지문이나 쿼리 ID를 사용하면 같은 유형의 실행을 집계하기 쉽습니다.
39장. 실행 횟수와 한 번당 비용을 함께 봐야 한다#
SQL A:
평균 2초
하루 1회SQL B:
평균 20ms
초당 2,000회어느 SQL을 먼저 개선할까요?
사용자 영향과 서버 자원 측면에서 B가 더 큰 비용을 만들 수 있습니다.
따라서:
한 번당 비용
×
실행 빈도를 함께 봐야 합니다.
40장. 전체 실행 시간의 합과 실제 벽시계 시간은 다르다#
SQL B가:
20ms × 2,000회
=
40초라고 계산할 수 있습니다.
하지만 여러 요청은 병렬로 실행됩니다.
따라서 이 40초를:
실제 서버가 40초 동안 멈췄다.라고 해석하면 안 됩니다.
총 작업량을 비교하는 지표로 보는 것이 적절합니다.
41장. 평균 응답시간만 보면 긴 꼬리가 숨는다#
100건 요청이 있습니다.
99건:
20ms1건:
2,000ms평균:
(99 × 20 + 2,000)
/
100
=
39.8ms입니다.
평균만 보면:
약 40ms로 매우 빠릅니다.
하지만 한 사용자는 2초를 기다렸습니다.
42장. p95·p99 같은 백분위를 함께 봐야 하는 이유#
사용자 대부분은 빠르지만 일부가 지속적으로 느린 서비스가 있습니다.
평균만 보면 정상입니다.
백분위는 분포의 꼬리를 보는 데 도움을 줍니다.
예:
p50
20ms
p95
120ms
p99
2,000ms이라면 일부 요청에서 큰 지연이 발생하고 있음을 알 수 있습니다.
43장. p99 하나만 보고 전체 경험을 설명하는 것도 부족하다#
샘플 수가 너무 적거나 계산 방식이 다르면 백분위 경계가 달라질 수 있습니다.
따라서:
표본 수
측정 구간
백분위 계산 방식을 함께 알아야 합니다.
평균과 백분위 모두 맥락이 필요합니다.
44장. 특정 고객만 느릴 수도 있다#
전체 평균은 정상입니다.
그런데 고객 999만 느립니다.
확인해 보니 고객 999의 주문이:
500만 건입니다.
다른 고객은:
평균 20건입니다.
같은 SQL이라도 데이터 분포에 따라 특정 입력만 느릴 수 있습니다.
45장. 느린 요청의 입력값을 정상 요청과 비교하자#
정상:
customer_id = 7
결과
12건느림:
customer_id = 999
결과
500만 건이라면 실행 계획뿐 아니라 업무 데이터 편향을 확인해야 합니다.
이런 문제는 평균적인 테스트 데이터에서는 발견하기 어렵습니다.
46장. 파라미터별 데이터 분포가 다르면 같은 계획이 항상 최적은 아니다#
대부분 고객:
10건특수 고객:
500만 건이라고 하겠습니다.
하나의 실행 전략이 두 경우 모두 최적이라고 보장할 수 없습니다.
제품의 계획 캐시와 파라미터 처리 방식에 따라 성능이 달라질 수 있습니다.
47장. 느린 SQL을 발견했다고 무조건 인덱스를 추가하지 말자#
다음 문제들은 인덱스만으로 해결되지 않습니다.
락 대기
연결 풀 대기
커밋 지연
네트워크 전송
대량 결과 반환
잘못된 트랜잭션 범위따라서 성능 진단 순서를 지키는 것이 중요합니다.
48장. 2초 요청을 다시 분해해 보자#
가상 사례입니다.
전체 요청
2,000ms구성:
연결 대기
300ms
DB 구간
1,500ms
응답 처리
200msDB 구간 내부:
락 대기
1,200ms
실제 작업
300ms49장. 락 대기를 제거했을 때의 최대 개선폭을 계산해 보자#
락 대기 1,200ms를 완전히 제거할 수 있다고 가정하면:
2,000
-
1,200
=
800ms정도까지 줄어들 가능성이 있습니다.
반면 실제 작업 300ms를 절반으로 줄이면:
300
→
150이므로 전체 요청은:
2,000
-
150
=
1,850ms정도입니다.
이 예에서는 인덱스보다 락 해결의 잠재 효과가 훨씬 큽니다.
50장. 다만 이 계산은 우선순위 추정을 위한 것이다#
실제 시스템에서는 하나의 대기를 없애면 다른 병목이 나타날 수 있습니다.
예를 들어 락 대기를 줄였더니 동시에 더 많은 SQL이 실행되면서 I/O가 증가할 수 있습니다.
따라서:
예상 개선
=
실제 개선 보장은 아닙니다.
조치 뒤 반드시 다시 측정해야 합니다.
51장. 실행 시간 안에 들어 있는 대기를 다시 더하면 안 된다#
예를 들어:
전체 요청
2.0초
DB 실행 경과
0.8초
DB 내부 락 대기
0.6초라고 하겠습니다.
잘못된 계산:
2.0
+
0.8
+
0.6
=
3.4초입니다.
DB 실행 0.8초는 이미 전체 요청 2초에 포함되어 있습니다.
락 대기 0.6초도 DB 실행 0.8초 안에 포함되어 있을 수 있습니다.
52장. 계측 구간을 부모·자식 관계로 생각하면 쉽다#
flowchart TD
A["전체 요청 2.0s"] --> B["연결 대기 1.0s"]
A --> C["DB 구간 0.8s"]
A --> D["응답 처리 0.2s"]
C --> E["락 대기 0.6s"]
C --> F["CPU·I/O 등 0.2s"]부모 구간과 자식 구간을 모두 더하면 같은 시간을 중복 계산하게 됩니다.
53장. 모니터링 도구의 숫자끼리 바로 더하지 말자#
APM:
DB span
800msDB 모니터:
Lock wait
600ms스토리지 모니터:
Read latency
100ms가 있다고 하겠습니다.
이 세 숫자가 서로 포함 관계인지 병렬 관계인지 확인하지 않고:
800 + 600 + 100을 하면 틀릴 수 있습니다.
54장. Oracle에서는 세션과 SQL 커서를 제품 고유 뷰로 본다#
예:
SELECT
s.sid,
s.serial#,
s.status,
s.sql_id,
s.sql_child_number,
s.last_call_et,
q.sql_text
FROM v$session s
LEFT JOIN v$sql q
ON q.sql_id = s.sql_id
AND q.child_number = s.sql_child_number
WHERE s.status = 'ACTIVE'
AND s.type = 'USER';이 쿼리는 Oracle 환경에서 사용하는 예입니다.
PostgreSQL의 pg_stat_activity와 같은 열 이름을 섞어 사용할 수 없습니다.
55장. Oracle SQL ID만 보고 모든 실행이 같은 상황이라고 보면 안 된다#
같은 SQL ID에서도:
child cursor가 여러 개 존재할 수 있습니다.
환경과 바인드, 객체 상태 등에 따라 다른 커서가 사용될 수 있습니다.
따라서 sql_id만 연결해 모든 실행 상태를 하나로 합치면 세부 차이를 놓칠 수 있습니다.
56장. LAST_CALL_ET도 현재 SQL의 정확한 실행 시간으로 단정하면 안 된다#
Oracle의 세션 상태 정보는 관찰 시점의 세션 상태를 나타냅니다.
LAST_CALL_ET 같은 값을:
현재 SQL이 정확히 42초 동안 CPU를 사용했다.라고 직접 해석하면 안 됩니다.
제품별 통계의 정의를 확인해야 합니다.
57장. DBMS별 대기 이벤트 이름을 섞지 말자#
Oracle에서 자주 보는 표현:
db file sequential read
log file sync등은 Oracle의 이벤트 이름입니다.
PostgreSQL에서는 다른 통계와 wait event 체계를 사용합니다.
개념은 비교할 수 있지만 명칭과 내부 구현을 그대로 옮기면 안 됩니다.
58장. 고정된 임계값만으로 장애를 판단하지 말자#
다음과 같은 기준을 절대 규칙처럼 사용하는 경우가 있습니다.
캐시 적중률 95% 미만이면 문제
로그 전환 시간당 3회 이상이면 문제
CPU 80% 이상이면 장애하지만 워크로드와 환경이 다르면 적정 값도 달라질 수 있습니다.
중요한 것은:
평소 기준
문제 시간대 변화
서비스 영향입니다.
59장. 성능 진단은 정상 시간과 장애 시간을 비교하면 강해진다#
예:
| 항목 | 정상 | 장애 |
|---|---|---|
| 요청 p95 | 120ms | 2.2s |
| DB 경과 | 80ms | 1.6s |
| 연결 대기 | 10ms | 300ms |
| 블로킹 세션 | 0 | 15 |
| 버퍼 접근 | 500 | 510 |
| 물리 읽기 | 유사 | 유사 |
이 경우 실행 계획보다 잠금과 연결 경합을 먼저 의심하는 것이 자연스럽습니다.
60장. 반대로 버퍼 접근만 폭증했다면#
| 항목 | 정상 | 장애 |
|---|---|---|
| 요청 p95 | 100ms | 2s |
| 락 대기 | 0 | 0 |
| 연결 대기 | 5ms | 5ms |
| 버퍼 접근 | 500 | 200,000 |
| 예상 행 | 100 | 100 |
| 실제 행 | 110 | 80,000 |
통계·데이터 분포·계획 문제를 먼저 조사할 수 있습니다.
61장. 결과 건수가 갑자기 늘었는지도 확인해야 한다#
어제 API:
20건 반환오늘:
20만 건 반환이라면 SQL 자체가 같은 계획이어도 전체 응답은 훨씬 느려집니다.
가능한 원인:
페이지네이션 누락
필터 조건 변경
날짜 조건 오류
업무 데이터 증가입니다.
62장. 페이지네이션 방식도 성능에 영향을 준다#
예를 들어:
ORDER BY ordered_at DESC
OFFSET 100000
LIMIT 20;처럼 매우 큰 OFFSET을 사용하면 앞의 많은 행을 건너뛰기 위해 작업이 필요할 수 있습니다.
대규모 목록에서는 키셋 페이지네이션 같은 다른 방식이 더 적합할 수 있습니다.
하지만 데이터 정렬 요구와 중복 키 문제까지 함께 설계해야 합니다.
63장. 정렬도 메모리와 디스크를 사용할 수 있다#
결과를 정렬하는 데 필요한 데이터가 메모리를 초과하면 임시 파일이나 디스크 작업이 발생할 수 있습니다.
실행 계획에서:
Sort
Disk관련 흔적이 나타난다면 정렬 데이터량과 메모리 조건을 확인합니다.
64장. 해시 연산도 메모리가 부족하면 비용이 커질 수 있다#
해시 조인이나 해시 집계가 큰 데이터를 처리하면서 메모리가 부족하면 여러 배치나 디스크 작업이 필요할 수 있습니다.
따라서:
Hash Join이라는 연산자 이름만 보고 빠르다고 판단하면 안 됩니다.
실제 입력 크기와 메모리 사용을 봐야 합니다.
65장. 반복 횟수 loops도 중요하다#
Nested Loop 계획에서 내부 인덱스 조회가:
1회 0.1ms라고 하겠습니다.
빠릅니다.
하지만:
loops = 100,000이라면 전체 작업량은 커집니다.
작은 비용
×
많은 반복
=
큰 총비용입니다.
66장. Rows Removed by Filter도 작업량을 보여준다#
실제 반환:
10행인데:
Rows Removed by Filter
1,000,000이라면 많은 행을 읽은 뒤 버리고 있습니다.
조건과 인덱스 설계를 다시 볼 근거가 됩니다.
67장. 실행 계획만 보고 락 대기를 찾을 수 있는 것은 아니다#
실행 계획은 SQL이 데이터를 어떻게 처리했는지 보여주는 핵심 도구입니다.
하지만 다른 세션 때문에 30초 기다렸다는 모든 정보가 실행 계획 하나에 완전히 표현되는 것은 아닙니다.
그래서:
실행 계획
+
활성 세션
+
잠금 관계
+
애플리케이션 요청 시간을 함께 연결해야 합니다.
68장. 성능 문제의 시간축을 기록하자#
예:
14:01
배포
14:03
p95 상승 시작
14:04
블로킹 세션 증가
14:06
연결 풀 대기 증가
14:10
장애 인지이런 타임라인이 있으면:
배포 후 긴 트랜잭션 발생
↓
락 증가
↓
연결 점유 증가
↓
연결 풀 대기같은 인과관계를 추적하기 쉬워집니다.
69장. DB가 느려진 뒤 연결 풀이 막힐 수도 있다#
SQL 하나가 오래 기다립니다.
연결을 계속 점유합니다.
동일한 요청이 늘어납니다.
DB 락 대기 증가
↓
연결 반환 지연
↓
연결 풀 소진
↓
새 요청 연결 대기
↓
사용자 응답 급증이렇게 2차 장애가 만들어질 수 있습니다.
70장. 연결 풀만 보면서 원인을 찾으면 순서가 뒤집힐 수 있다#
사용자가 연결 대기 때문에 느립니다.
그래서:
연결 풀이 문제다.
라고 할 수 있습니다.
하지만 연결 풀이 소진된 원인이:
DB 락 대기일 수 있습니다.
관찰된 병목과 최초 원인을 구분해야 합니다.
71장. 긴 SQL을 강제로 종료하기 전에 영향 범위를 확인해야 한다#
차단 세션을 발견했다고 바로 종료하면 안 됩니다.
해당 세션이:
대량 배치
재무 마감
복구 작업
중요한 트랜잭션일 수 있습니다.
강제 종료하면 롤백 시간이 길거나 다른 업무 문제가 생길 수 있습니다.
운영 절차와 영향 범위를 확인해야 합니다.
72장. 차단 세션 종료는 임시 대응이지 원인 해결이 아닐 수 있다#
오늘 차단 세션을 종료했습니다.
서비스가 정상화됐습니다.
하지만 애플리케이션 구조가:
트랜잭션 열기
↓
외부 API 30초 대기
↓
COMMIT라면 다시 같은 문제가 발생할 수 있습니다.
재발 방지에는 트랜잭션 설계 수정이 필요합니다.
73장. 성능 개선 전후는 같은 조건에서 비교해야 한다#
개선 전:
월요일 오전 10시
동시 사용자 1,000명개선 후:
일요일 새벽 3시
사용자 10명을 비교하면 결과가 왜곡됩니다.
가능한 한:
같은 SQL
비슷한 입력
비슷한 데이터량
비슷한 부하
같은 측정 구간으로 비교해야 합니다.
74장. SQL만 빨라지고 전체 요청은 그대로일 수도 있다#
개선 전:
연결 대기
300ms
SQL
1,000ms
응답 처리
200ms
총
1,500ms개선 후:
연결 대기
1,000ms
SQL
200ms
응답 처리
200ms
총
1,400msSQL은 80% 빨라졌습니다.
하지만 사용자는 거의 차이를 느끼지 못합니다.
병목이 다른 구간으로 이동했기 때문입니다.
75장. 개선 성공 여부는 전체 서비스 지표까지 확인해야 한다#
예:
SQL p95
API p95
연결 대기
락 대기
DB CPU
I/O
오류율을 함께 봅니다.
성능 튜닝은 SQL 숫자 하나를 줄이는 것이 아니라 사용자 체감과 시스템 부담을 줄이는 작업입니다.
76장. PostgreSQL에서 현재 활동을 볼 때 민감한 SQL도 주의해야 한다#
pg_stat_activity의 query에는 실제 SQL 내용이 포함될 수 있습니다.
SQL에 민감한 값이 직접 들어가 있다면 모니터링 화면에서도 노출될 수 있습니다.
따라서:
모니터링 계정 권한
로그 접근 권한
보존 기간도 관리해야 합니다.
성능 모니터링도 보안 범위 안에 있습니다.
77장. 모니터링 권한을 관리자 권한과 동일하게 줄 필요는 없다#
운영 모니터링 도구에 DB 전체 관리자 권한을 부여하면 편리할 수 있습니다.
하지만 필요 이상으로 강한 권한입니다.
가능하면:
활동 조회
통계 조회
필요한 모니터링 뷰에 필요한 최소 권한만 부여하는 것이 좋습니다.
78장. 느린 SQL 진단 순서를 정리하면#
flowchart TD
A["느린 요청 발견"] --> B["전체 요청 시간 분해"]
B --> C{"DB 구간이 큰가?"}
C -->|아니오| D["연결·네트워크·앱 확인"]
C -->|예| E{"락 대기인가?"}
E -->|예| F["차단 세션·긴 트랜잭션 확인"]
E -->|아니오| G["실행 계획·I/O 확인"]
G --> H["예상·실제 행 비교"]
H --> I["버퍼·반복·정렬·필터 확인"]
F --> J["조치"]
I --> J
D --> J
J --> K["같은 조건으로 재측정"]이 순서를 따르면 무작정 인덱스부터 추가하는 일을 줄일 수 있습니다.
79장. 장애 시 빠르게 확인할 체크리스트#
사용자 구간#
전체 응답시간은 얼마인가?
평균뿐 아니라 p95·p99는 어떤가?연결#
연결 획득 대기가 있는가?
풀을 오래 점유하는 요청은 무엇인가?DB 실행#
DB 경과 시간은 얼마인가?
반환 행과 중간 행은 몇 개인가?잠금#
누가 기다리는가?
누가 막고 있는가?
차단 트랜잭션은 얼마나 오래 열려 있는가?I/O#
버퍼 접근이 급증했는가?
물리 읽기 지연이 증가했는가?실행 계획#
추정 행과 실제 행 차이가 큰가?
loops가 지나치게 큰가?
많은 행을 읽고 버리는가?80장. 실행 계획 진단 체크리스트#
- 결과 행은 몇 개인가?
- 중간 단계에서 실제로 몇 행을 처리했는가?
- 예상 행과 실제 행 차이가 큰가?
- 어느 노드에서 처음 큰 차이가 발생하는가?
loops는 몇 번인가?- 버퍼 접근량은 얼마인가?
- 불필요한 필터 제거 행이 많은가?
- 정렬이 메모리에서 끝났는가?
- 해시 연산이 큰 입력을 받는가?
- 같은 SQL의 정상 계획과 비교했는가?
- 데이터 분포가 바뀌었는가?
- 통계가 현재 데이터를 충분히 반영하는가?
81장. 락 대기 진단 체크리스트#
- 현재 차단된 PID는 무엇인가?
- 차단한 PID는 무엇인가?
- 차단 세션의 트랜잭션 시작 시각은 언제인가?
- 어떤 객체와 행을 수정했는가?
- 사용자의 입력이나 외부 API를 기다리고 있는가?
- 같은 형태의 트랜잭션이 반복되는가?
- 잠금 순서를 통일할 수 있는가?
- 트랜잭션 범위를 줄일 수 있는가?
- 차단 세션 종료 시 롤백 영향은 얼마인가?
- 재발 방지 코드를 수정했는가?
82장. 연결 풀 진단 체크리스트#
- 최대 연결 수는 얼마인가?
- 현재 사용 중인 연결 수는 얼마인가?
- 연결 획득 p95는 얼마인가?
- 평균 연결 점유 시간은 얼마인가?
- 긴 트랜잭션이 연결을 점유하고 있는가?
- 연결 반환 누락이 있는가?
- 풀을 늘리면 DB가 감당할 수 있는가?
- DB CPU와 I/O가 이미 포화 상태인가?
- 요청 큐를 제한해야 하는가?
- 문제 SQL을 먼저 줄이는 것이 더 효과적인가?
83장. 성능 모니터링에서 가장 피해야 할 오류#
첫째:
SQL이 느리다
→ 무조건 인덱스 추가둘째:
평균 50ms
→ 모든 사용자가 빠르다셋째:
DB 실행 800ms
+
락 대기 600ms
=
1.4초처럼 포함 관계를 무시한 합산입니다.
넷째:
현재 차단됨
→ 전체 실행 시간이 전부 락 대기라고 단정하는 것입니다.
다섯째:
캐시 적중률 높음
→ SQL 효율적이라고 판단하는 것입니다.
84장. 운영 기록을 한 표로 남겨 보자#
예:
| 항목 | 장애 시 | 조치 후 |
|---|---|---|
| API p95 | 2.1s | 280ms |
| 연결 대기 p95 | 320ms | 25ms |
| DB p95 | 1.6s | 180ms |
| 락 대기 관찰 | 다수 | 거의 없음 |
| 차단 트랜잭션 최대 시간 | 8분 | 4초 |
| 버퍼 접근 | 유사 | 유사 |
| 오류율 | 1.2% | 0.1% |
이 경우 인덱스보다 긴 트랜잭션과 연결 점유 문제가 핵심이었음을 설명할 수 있습니다.
85장. 핵심 정리#
느린 SQL을 진단할 때 가장 먼저 해야 하는 것은 실행 계획을 열어보는 것이 아닐 수도 있습니다.
먼저 물어야 합니다.
사용자가 기다린 전체 시간은 어디에서 사용됐는가?
한 요청이:
2,000ms걸렸다면 먼저:
연결 대기
DB 실행
결과 전송
애플리케이션 처리로 나눠 봅니다.
DB 실행 시간이 크다면 다시:
락 대기
I/O
CPU
정렬
조인
커밋등으로 원인을 좁힙니다.
PostgreSQL의 EXPLAIN (ANALYZE, BUFFERS)에서는 단순히 어떤 인덱스를 사용했는지만 보는 것이 아니라:
예상 행
실제 행
loops
버퍼 접근
필터 제거 행을 함께 봐야 합니다.
특히:
예상
10행
실제
50,000행처럼 큰 차이가 있다면 통계·데이터 분포·접근 계획을 확인할 근거가 됩니다.
반면 SQL이 다른 트랜잭션에 막혀 있다면 실행 계획 튜닝보다 차단 세션을 찾아야 합니다.
waiting_pid
802
blocking_pid
701처럼 관계를 확인하고:
왜 701의 트랜잭션이 오래 열려 있는가?를 조사해야 합니다.
연결 풀도 마찬가지입니다.
연결 대기 증가가 관찰됐다고 해서 연결 풀 자체가 최초 원인이라고 단정해서는 안 됩니다.
DB 락으로 연결 반환이 늦어지고:
락 대기
↓
연결 장시간 점유
↓
풀 소진
↓
새 요청 대기가 만들어졌을 수도 있습니다.
또 하나 중요한 것은 시간을 중복 계산하지 않는 것입니다.
전체 요청
2초안에:
DB 실행
0.8초가 이미 포함되어 있고 그 DB 실행 안에:
락 대기
0.6초가 포함돼 있다면 이 숫자들을 모두 더해서는 안 됩니다.
성능 모니터링에서는 부모 구간과 자식 구간을 구분해야 합니다.
평균 응답시간만으로도 충분하지 않습니다.
99건
20ms
1건
2,000ms이면 평균은 약 40ms에 불과하지만 한 사용자는 2초를 기다립니다.
그래서 평균과 함께 p95·p99, 느린 요청의 입력값과 동시 실행 상황을 확인해야 합니다.
결국 느린 SQL 진단의 핵심은 하나입니다.
느리다는 현상을 하나의 숫자로 보지 않고 실제 요청 경로의 여러 구간으로 나누는 것입니다.
그리고 조치를 한 뒤에는 반드시 같은 질문으로 돌아와야 합니다.
사용자가 기다린 전체 시간이 실제로 줄었는가?
SQL 실행 시간이 빨라졌더라도 연결 대기나 락 대기가 다른 곳에서 늘었다면 성능 문제는 해결되지 않은 것입니다.
좋은 모니터링은 숫자를 많이 모으는 시스템이 아닙니다.
한 사용자의 느린 요청을 애플리케이션·연결·SQL·잠금·I/O까지 이어서 설명하고, 어떤 조치가 실제로 그 기다림을 줄였는지 다시 검증할 수 있는 시스템입니다.