서버가 느린 이유 #5 데이터베이스가 느려질 때: 인덱스, 락, 커넥션 풀

6 분 소요

시리즈 마지막은 데이터베이스입니다. 데이터베이스의 느려짐에는 다른 자원과 구별되는 특징이 하나 있습니다. 어제까지 멀쩡하던 시스템이 코드 변경 없이 느려진다는 점입니다. 데이터는 매일 자라고, 트래픽 패턴은 조금씩 바뀌는데, 쿼리 실행 계획과 락과 커넥션 풀은 그 변화에 계단식으로 반응하기 때문입니다. 이 글에서는 그 계단 셋, 인덱스와 락과 커넥션 풀을 순서대로 짚겠습니다. 예시는 PostgreSQL 기준이고, 같은 개념이 MySQL 등 다른 엔진에도 이름만 바꿔 적용됩니다.

인덱스: 데이터 성장이 임계를 넘는 날 #

인덱스 없는 조회는 테이블 전체를 읽는 풀 스캔이 됩니다. 중요한 것은 풀 스캔이 처음에는 문제가 아니라는 점입니다. 만 건짜리 테이블의 풀 스캔은 밀리초면 끝나고, 테이블 전체가 캐시에 들어가 있는 동안은 수십만 건도 버팁니다. 문제는 테이블이 캐시를 넘어서는 날 시작됩니다. 2편의 워킹셋 개념이 여기에 그대로 적용됩니다. 풀 스캔이 디스크 읽기로 바뀌고, 3편에서 본 스토리지 지연이 쿼리마다 곱해집니다. “데이터가 쌓이니 느려졌다"의 실체는 대부분 선형 성장이 아니라 이런 임계 돌파입니다.

느린 쿼리를 찾았다면 판정은 EXPLAIN입니다.

EXPLAIN ANALYZE
=# EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_email = 'a@example.com';
 Seq Scan on orders  (cost=0.00..184230.10 rows=3 width=112)
                     (actual time=2841.334..2841.336 rows=2 loops=1)
   Filter: (customer_email = 'a@example.com'::text)
   Rows Removed by Filter: 4183921

400만 행을 읽어 2행을 얻는 Seq Scan, 전형적인 인덱스 부재입니다. 인덱스를 만들면 되지만, 실무에서 더 자주 만나는 것은 인덱스가 있는데도 사용되지 않는 경우입니다. 대표 패턴은 셋입니다.

  • 컬럼을 가공한 조건: WHERE lower(email) = ..., WHERE created_at::date = ...처럼 컬럼에 함수를 씌우면 원 컬럼의 인덱스는 못 씁니다. 함수 기반 인덱스를 만들거나 조건을 범위로 바꿉니다.
  • 타입 불일치: 문자열 컬럼을 숫자와 비교하는 식의 암묵적 형변환도 같은 효과를 냅니다. ORM이 만들어 내는 쿼리에서 흔합니다.
  • 낡은 통계: 옵티마이저는 통계로 계획을 고릅니다. 대량 적재·삭제 직후 통계가 현실과 어긋나면 멀쩡한 인덱스를 두고 풀 스캔을 고르기도 합니다. ANALYZE로 통계를 갱신하고 autovacuum이 그 테이블에서 실제로 돌고 있는지 확인합니다.

애플리케이션 쪽에서는 쿼리 하나의 속도만큼 쿼리 개수도 문제입니다. 목록 하나에 N+1로 수백 개의 빠른 쿼리를 날리는 패턴은 4편의 왕복 곱셈이 데이터베이스에서 재현되는 것입니다. ORM 관점의 N+1과 EXPLAIN 활용은 장고 고급 #3에서 다뤘습니다.

락: 한 트랜잭션이 만드는 대기 체인 #

쿼리 자체는 빠른데 어떤 요청만 몇 초씩 멈춘다면 락 대기를 봅니다. 로우 락은 같은 행을 고치려는 트랜잭션을 줄 세우는데, 이 줄은 체인이 됩니다. 트랜잭션 A가 행을 잡은 채 오래 있으면 B가 기다리고, B가 잡고 있는 다른 행을 C가 기다리는 식으로 대기가 전파됩니다. 증상이 “특정 기능만 간헐적으로 멈춘다"로 나타나는 이유입니다.

체인의 뿌리는 거의 항상 긴 트랜잭션입니다. 트랜잭션을 열어 둔 채 외부 API를 호출하는 코드, 배치 작업이 수백만 행을 한 트랜잭션으로 고치는 경우, 사람이 콘솔에서 BEGIN만 치고 점심을 먹으러 간 경우가 모두 여기에 해당합니다. DDL도 조심해야 합니다. ALTER TABLE은 테이블 락을 요구하는데, 긴 SELECT 하나가 그 앞을 막으면 ALTER 뒤로 모든 쿼리가 줄을 섭니다. 배포 중 마이그레이션이 서비스 전체를 세우는 전형적인 경로입니다.

지금 누가 누구를 막고 있는지는 통계 뷰로 바로 봅니다.

pg_stat_activity: 대기 확인
=# SELECT pid, state, wait_event_type, now() - xact_start AS xact_age, query
   FROM pg_stat_activity WHERE state <> 'idle' ORDER BY xact_age DESC;
  pid  | state  | wait_event_type | xact_age  | query
-------+--------+-----------------+-----------+--------------------------
  8123 | idle in transaction |    | 00:41:02  | UPDATE orders SET ...
  9310 | active | Lock            | 00:03:11  | UPDATE orders SET ...

41분째 idle in transaction인 8123이 뿌리입니다. pg_blocking_pids()로 차단 관계를 확정하고, 급하면 해당 백엔드를 종료해 체인을 끊습니다. 재발 방지는 코드 쪽입니다. 트랜잭션 안에서 외부 호출 금지, 배치는 작은 단위로 커밋, idle_in_transaction_session_timeoutlock_timeout 설정이 기본기입니다.

커넥션 풀: 데이터베이스는 멀쩡한데 앱이 기다리는 경우 #

마지막 계단은 데이터베이스 밖에 있습니다. 애플리케이션의 커넥션 풀이 고갈되면 요청은 빈 커넥션을 기다리며 줄을 섭니다. 이때 데이터베이스 쪽 지표는 오히려 한가합니다. 1편에서 본 “지표에 안 남는 대기"의 데이터베이스판입니다. 느린 쿼리 하나가 커넥션을 오래 점유하면 풀이 마르고, 그때부터는 빠른 쿼리들까지 풀 대기로 함께 느려져 원인이 가려집니다.

반대 방향의 실수도 있습니다. 풀이 마른다고 최대 커넥션을 무작정 올리면, 데이터베이스는 동시 실행 쿼리가 코어 수를 한참 넘는 순간부터 컨텍스트 스위치와 락 경합으로 오히려 총 처리량이 떨어집니다. 커넥션 수천 개를 열어 둔 채 각각이 느려지는 것보다, 풀을 코어 수의 몇 배 수준으로 제한하고 그 앞에 줄을 세우는 쪽이 전체적으로 빠릅니다. PostgreSQL이라면 PgBouncer 같은 외부 풀러로 애플리케이션 인스턴스가 늘어도 데이터베이스 쪽 동시성을 일정하게 묶는 구성이 표준입니다.

진단 신호를 정리하면 이렇습니다. 애플리케이션의 “커넥션 획득 대기 시간” 메트릭이 튀는데 데이터베이스의 활성 쿼리 수·CPU가 한가하면 풀 크기 또는 커넥션 점유(느린 쿼리·긴 트랜잭션) 문제이고, 데이터베이스의 활성 쿼리가 코어 수를 크게 넘어 포화라면 풀 축소·쿼리 개선이 처방입니다.

진단 순서 정리 #

증상이 “데이터베이스가 느려졌다"일 때, 순서는 이렇습니다.

  1. 느린 쿼리 식별: pg_stat_statements(또는 slow query log)로 시간을 가장 많이 먹는 쿼리를 순위로 뽑습니다. 체감과 범인이 다른 경우가 많습니다.
  2. EXPLAIN ANALYZE: 풀 스캔인지, 인덱스가 사용되는지, 추정 행수와 실제 행수가 어긋나는지(통계 문제) 봅니다.
  3. pg_stat_activity: idle in transaction과 Lock 대기를 확인합니다. 간헐적 멈춤이면 이쪽이 먼저입니다.
  4. 풀 메트릭 대조: 앱의 커넥션 대기와 DB의 활성 쿼리 수를 나란히 놓고, 풀 고갈인지 DB 포화인지 가릅니다.
  5. 자원 확인: 여기까지 없으면 2편(캐시·워킹셋)과 3편(fsync·스토리지)의 진단을 데이터베이스 서버에 적용합니다.

정리: 시리즈를 닫으며 #

  • 데이터베이스의 느려짐은 계단식입니다. 데이터 성장이 캐시·통계·계획의 임계를 넘는 순간 코드 변경 없이 시작됩니다.
  • 인덱스는 존재가 아니라 사용이 문제입니다. 컬럼 가공, 형변환, 낡은 통계가 멀쩡한 인덱스를 무력화합니다.
  • 락 대기의 뿌리는 거의 항상 긴 트랜잭션입니다. pg_stat_activity에서 idle in transaction부터 찾습니다.
  • 커넥션 풀 고갈은 데이터베이스가 한가한데 앱이 느린 형태로 나타납니다. 이때는 풀을 무작정 키우기보다 커넥션 점유 시간을 줄이는 것이 우선입니다.

시리즈 전체를 관통하는 결론은 하나입니다. “스펙은 충분한데 느리다"의 원인은 거의 언제나 어딘가의 대기이고, 대기는 평균 사용률 그래프에 잡히지 않습니다. CPU 앞의 줄(1편), 캐시 밖의 워킹셋(2편), 동기 쓰기와 얕은 큐(3편), 왕복의 곱셈(4편), 그리고 락과 풀(이번 편)이 그 대기들입니다. 증상에서 출발해 대기가 쌓이는 곳을 찾으면, 처방은 대개 증설이 아니라 구조 쪽에 있습니다.

X