본문 바로가기
Database/SQL

Lock과 성능 개선

by curious week 2026. 2. 11.

1) PostgreSQL Lock 전체 구조

PostgreSQL은 두 가지 lock을 동시에 사용한다.

(1) Table-level lock

(2) Row-level lock

핵심은 가능한 한 “최소 범위만 잠근다”

(1) Table-level Lock 종류

이건 “테이블 전체”에 대한 lock이다. 하지만 대부분 읽기/쓰기 막지는 않는다.

ACCESS SHARE

SELECT가 거는 lock.

특징:

  • UPDATE/INSERT/DELETE 허용
  • SELECT끼리는 충돌 없음
  • SELECT는 거의 시스템에 영향 없음

ROW EXCLUSIVE

INSERT / UPDATE / DELETE 시 걸림.

특징:

  • 다른 DML 허용
  • DDL은 막힘
  • 일반 서비스 트래픽은 이 lock 위에서 돌아감

SHARE

CREATE INDEX 등에서 발생.

  • 읽기 허용, 쓰기 제한.

EXCLUSIVE / ACCESS EXCLUSIVE

가장 위험한 lock.

발생 작업:

  • ALTER TABLE
  • DROP TABLE
  • TRUNCATE

효과:

  • SELECT 포함 모든 작업 대기
  • 서비스 “멈춘 것처럼 보이는” 순간은 거의 이 lock 때문이다.

(2) Row-level Lock

UPDATE/DELETE 시

대상 row만 잠김.

UPDATE users SET age=20 WHERE id=1;
  • → id=1 row만 lock
  • 다른 row는 영향 없음.

SELECT … FOR UPDATE

SELECT * FROM users WHERE id=1 FOR UPDATE;
  • 읽으면서 row lock 확보
  • 용도: 재고, 계좌 등 동시 수정 방지

2) Deadlock

Deadlock 정의

서로 lock을 기다리며 영원히 진행 못하는 상태.

트랜잭션 A: row1 lock → row2 lock 대기
트랜잭션 B: row2 lock → row1 lock 대기
  • 서로 기다림.
  • PostgreSQL은 일정 시간 후 하나를 죽인다.

에러:

deadlock detected

(1) Deadlock 가장 흔한 패턴

패턴 1 — 업데이트 순서 다름

A: UPDATE users WHERE id=1;
A: UPDATE users WHERE id=2;

B: UPDATE users WHERE id=2;
B: UPDATE users WHERE id=1;

100% deadlock 가능.

패턴 2 — FK + DELETE

DELETE parent
→ child FK 검사

다른 트랜잭션이 child 수정 중

패턴 3 — SELECT FOR UPDATE 충돌

동시에 같은 자원 선점.

(2) PostgreSQL Deadlock 대응 방식

자동 탐지 후:

  • 한 트랜잭션 kill 다른 트랜잭션 진행

즉, deadlock은 “발생하면 해결”이 아니라 설계로 방지해야 하는 문제다.

(3) 실무에서 deadlock 방지 원칙

원칙 1 — 항상 같은 순서로 접근

나쁜 예:

A: user → order
B: order → user

좋은 예:

항상 user → order 순서

원칙 2 — 트랜잭션 짧게 유지

긴 트랜잭션:

  • lock 오래 유지
  • deadlock 확률 상승

원칙 3 — WHERE 정확히

불필요한 row lock 방지.

원칙 4 — FK 삭제 조심

CASCADE 설계 시:

  • 대량 lock 발생 가능

(4) Deadlock 로그 읽는 법

Process 123 waits for ShareLock on transaction ...
blocked by process 456
  • 누가 누구를 기다리는지
  • 어떤 테이블/row인지

3) Lock과 성능 관계

Lock은 단순 대기가 아니다.

  • latency 증가
  • TPS 감소
  • 타임아웃
  • 장애

위험도:

ALTER TABLE 매우 높음
DELETE + FK 높음
UPDATE batch 높음
SELECT FOR UPDATE 높음

쿼리 실행
→ 옵티마이저가 계획 생성
→ 인덱스 사용 여부 결정
→ 실행
→ WAL 기록

 

4) 실행계획 (EXPLAIN)

DB가 “이 쿼리를 어떻게 실행할지” 미리 보여주는 명령

실행은 안 하고 계획만: EXPLAIN 

EXPLAIN SELECT * FROM users WHERE id = 1;

또는 실제 실행까지 포함: EXPLAIN ANALYZE

EXPLAIN ANALYZE SELECT * FROM users WHERE id = 1;

(1) Scan 방식

Seq Scan 테이블 전체 읽음
Index Scan 인덱스 사용
Bitmap Heap Scan 대량 조건 인덱스 사용

(2) cost

cost=0.29..8.30
  • 옵티마이저가 계산한 예상 비용
  • 실제 시간 아님
  • 비교용 지표

(3) rows

rows=1
  • 예상 결과 개수

이게 틀리면 옵티마이저 판단도 틀린다.

Seq Scan vs Index Scan

성능의 핵심 분기점

Seq Scan

Seq Scan on users
  • 테이블 전체 읽음
  • row 적으면 괜찮음
  • 많으면 느림

Index Scan

Index Scan using users_pkey
  • 인덱스 통해 row 찾음
  • 조건 정확할 때 빠름

EXPLAIN ANALYZE

실제 실행 결과 포함.

actual time=0.02..0.03

이걸 봐야 진짜 성능 분석 가능.


5) 인덱스 구조 

PostgreSQL 기본 인덱스: B-tree

B-tree 구조

루트
 ├─ 중간 노드
 │   ├─ leaf
 │   └─ leaf

정렬된 트리.

인덱스가 빠른 이유

테이블이 1억 개의 row라면, 검색:

  • Seq Scan → 1억 row 읽기
  • Index Scan → 트리 3~4단계 탐색
  • 시간 복잡도: O(n) → O(log n)

인덱스가 느려지는 경우

(1) 조건이 broad

WHERE age > 1

→ 대부분 row 포함 → Seq Scan 선택

(2) 함수 사용

WHERE LOWER(email) = 'a@test.com'

→ 인덱스 못 탐 (함수 인덱스 필요)

(3) 인덱스 컬럼 순서 문제

복합 인덱스:

INDEX (a, b)

검색:

WHERE b = ?

→ 인덱스 못 씀

인덱스는 많을수록 좋다? 아니다.

  • INSERT 느림
  • UPDATE 느림
  • 디스크 증가

6) 옵티마이저 — DB가 쿼리를 해석하는 방식

옵티마이저는 “이 쿼리를 가장 적은 비용으로 실행하는 방법”을 선택

Cost-based Optimizer

PostgreSQL은:

  • 통계 기반
  • 비용 계산

판단 기준

  • cardinality: 조건 결과 row 수 예측
  • selectivity: 조건이 얼마나 좁은지
  • statistics: 컬럼 분포 정보

옵티마이저 판단 흐름

조건 분석
→ 통계 조회
→ cost 계산
→ 실행 계획 선택

 

통계 틀리면? 옵티마이저도 틀림.


7) WAL 구조 — 쓰기 성능의 핵심

PostgreSQL은 “로그 먼저 쓰고 데이터 쓰는 구조”

Write-Ahead Log

  • 순서: WAL 기록 → 디스크 데이터 쓰기
  • 이유는 crash recovery (비정상 처리를 복구하기 위해)

INSERT 흐름: row 생성 → WAL 기록 → 인덱스 업데이트 → commit

WAL는 성능에 직접적인 영향을 주기 때문에 중요하다

  • INSERT 속도
  • UPDATE 속도
  • replication
  • recovery

WAL 부담이 커지는 경우

  • 인덱스 많음
  • UPDATE 많음
  • 대량 INSERT

WAL과 TRUNCATE 차이

  • DELETE: row마다 WAL
  • TRUNCATE: 최소 WAL → 빠름

실습 해보기

테이블 생성

DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS users;

CREATE TABLE users (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE orders (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id BIGINT NOT NULL REFERENCES users(id),
  status TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE order_items (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_id BIGINT NOT NULL REFERENCES orders(id),
  product_id BIGINT NOT NULL,
  quantity INT NOT NULL CHECK (quantity > 0),
  price INT NOT NULL CHECK (price >= 0)
);

 

데이터 대량 생성

1. users 100,000명

INSERT INTO users(email, created_at)
SELECT
  'user_' || g || '@test.com',
  NOW() - (g % 365) * INTERVAL '1 day'
FROM generate_series(1, 100000) g;

ANALYZE users;

2. orders 2,000,000건 (상태 분포 포함)

 
INSERT INTO orders(user_id, status, created_at)
SELECT
  (random() * 99999 + 1)::bigint,
  CASE
    WHEN random() < 0.70 THEN 'PAID'
    WHEN random() < 0.90 THEN 'PENDING'
    ELSE 'CANCELLED'
  END,
  NOW() - (random() * 365) * INTERVAL '1 day'
FROM generate_series(1, 2000000) g;

ANALYZE orders;

3. order_items 6,000,000건

 
INSERT INTO order_items(order_id, product_id, quantity, price)
SELECT
  (random() * 1999999 + 1)::bigint,
  (random() * 200000 + 1)::bigint,
  (random() * 3 + 1)::int,
  (random() * 50000)::int
FROM generate_series(1, 6000000) g;

ANALYZE order_items;

느린 쿼리: 기본적인 '기간+상태+조인+집계+정렬+LIMIT'

EXPLAIN (ANALYZE, BUFFERS)
SELECT
  o.user_id,
  SUM(oi.quantity * oi.price) AS total_amount
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'PAID'
  AND o.created_at >= NOW() - INTERVAL '30 days'
GROUP BY o.user_id
ORDER BY total_amount DESC
LIMIT 20;

 

  • Seq Scan on orders 또는 Seq Scan on order_items가 큰 비중
  • Hash Join / Hash Aggregate / Sort가 무겁게 잡힘
  • BUFFERS에서 shared read가 크게 뜰 수 있음(디스크 읽음)
 Limit  (cost=127058.84..127058.89 rows=20 width=16) (actual time=489.459..489.662 rows=20 loops=1)
	Buffers: shared hit=10697 read=48230
->  Sort  (cost=127058.84..127226.79 rows=67180 width=16) (actual time=489.457..489.659 rows=20 loops=1)
	Sort Key: (sum((oi.quantity * oi.price))) DESC
	Sort Method: top-N heapsort  Memory: 26kB
	Buffers: shared hit=10697 read=48230
->  Finalize HashAggregate  (cost=124599.41..125271.21 rows=67180 width=16) (actual time=483.339..486.902 rows=66344 loops=1)
	Group Key: o.user_id
	Batches: 1  Memory Usage: 7441kB
	Buffers: shared hit=10697 read=48230
->  Gather  (cost=109819.81..123927.61 rows=134360 width=16) (actual time=456.953..464.956 rows=154766 loops=1)
	Workers Planned: 2
	Workers Launched: 2
	Buffers: shared hit=10697 read=48230
->  Partial HashAggregate  (cost=108819.81..109491.61 rows=67180 width=16) (actual time=449.579..452.390 rows=51589 loops=3)
	Group Key: o.user_id
	Batches: 1  Memory Usage: 4881kB
	Buffers: shared hit=10697 read=48230
	Worker 0:  Batches: 1  Memory Usage: 4881kB
	Worker 1:  Batches: 1  Memory Usage: 4881kB
->  Parallel Hash Join  (cost=32069.87..107750.26 rows=142607 width=16) (actual time=234.228..430.784 rows=114954 loops=3)
	Hash Cond: (oi.order_id = o.id)
	Buffers: shared hit=10697 read=48230
->  Parallel Seq Scan on order_items oi  (cost=0.00..69117.90 rows=2499990 width=16) (actual time=0.070..67.374 rows=2000000 loops=3)
	Buffers: shared hit=841 read=43277
->  Parallel Hash  (cost=31475.67..31475.67 rows=47536 width=16) (actual time=233.996..233.996 rows=38332 loops=3)
	Buckets: 131072  Batches: 1  Memory Usage: 6464kB
	Buffers: shared hit=9856 read=4953
->  Parallel Seq Scan on orders o  (cost=0.00..31475.67 rows=47536 width=16) (actual time=0.103..228.041 rows=38332 loops=3)
	Filter: ((status = 'PAID'::text) AND (created_at >= (now() - '30 days'::interval)))
	Rows Removed by Filter: 628335
	Buffers: shared hit=9856 read=4953

Planning:
   Buffers: shared hit=8
Planning Time: 1.043 ms
Execution Time: 490.229 ms

 

order_items를 보면:

  • workers 2명 + leader = loops=3
  • 각 루프가 rows=2,000,000씩 스캔
  • order_items(order_id) 인덱스가 없으면, “필터된 orders를 기준으로 order_items를 인덱스로 찌르는 Nested Loop”가 불가능해져서 옵티마이저는 “그냥 큰 쪽을 다 읽고 해시로 붙이자”를 선택한다.

orders 쪽:

  • rows=38339 (각 worker)
  • Rows Removed by Filter: 628328 (각 worker)
  • 즉 “최근 30일 + PAID”여도 남는 row가 수만 단위라서 (정확한 전체는 parallel 분산이라 체감치가 크지만) 옵티마이저는 “orders도 인덱스 안 타고 그냥 스캔해도 되겠다” 쪽으로 판단했을 가능성이 높다.

Buffer를 보면:

  • 처음 읽을 때는 읽기쓰기 작업이 많이 발생해 더 느리고 read 숫자도 더 높게 나온다.
  • 현재는 한 번 읽기를 끝낸 후라서 조금 더 적게 읽었지만 여전히 디스크에서 많이 읽었다는 것을 알 수 있다.

orders 필터를 위한 복합 인덱스

 

CREATE INDEX idx_orders_status_createdat_id
ON orders(status, created_at, id);

ANALYZE orders;
  • status + created_at로 먼저 줄이고, 조인에 필요한 id를 함께 고려

order_items 조인을 위한 인덱스

CREATE INDEX idx_order_items_order_id
ON order_items(order_id);

ANALYZE order_items;
  • 조인 키가 order_id이므로 위처럼 인덱스 생성

재측정

 Limit  (cost=114725.34..114725.39 rows=20 width=16) (actual time=370.419..370.587 rows=20 loops=1)
   Buffers: shared hit=15233 read=44257
->  Sort  (cost=114725.34..114893.03 rows=67076 width=16) (actual time=370.416..370.583 rows=20 loops=1)
	Sort Key: (sum((oi.quantity * oi.price))) DESC
	Sort Method: top-N heapsort  Memory: 26kB
	Buffers: shared hit=15233 read=44257
->  Finalize HashAggregate  (cost=112269.72..112940.48 rows=67076 width=16) (actual time=364.247..367.815 rows=66334 loops=1)
	Group Key: o.user_id
	Batches: 1  Memory Usage: 7441kB
	Buffers: shared hit=15233 read=44257
->  Gather  (cost=97513.00..111598.96 rows=134152 width=16) (actual time=338.210..346.130 rows=154904 loops=1)
	Workers Planned: 2
	Workers Launched: 2
	Buffers: shared hit=15233 read=44257
->  Partial HashAggregate  (cost=96513.00..97183.76 rows=67076 width=16) (actual time=327.274..330.172 rows=51635 loops=3)
	Group Key: o.user_id
	Batches: 1  Memory Usage: 4881kB
	Buffers: shared hit=15233 read=44257
	Worker 0:  Batches: 1  Memory Usage: 4881kB
	Worker 1:  Batches: 1  Memory Usage: 4881kB
->  Parallel Hash Join  (cost=19770.03..95450.42 rows=141677 width=16) (actual time=102.629..308.096 rows=114931 loops=3)
	Hash Cond: (oi.order_id = o.id)
	Buffers: shared hit=15233 read=44257
->  Parallel Seq Scan on order_items oi  (cost=0.00..69117.90 rows=2499990 width=16) (actual time=0.071..69.075 rows=2000000 loops=3)
	Buffers: shared hit=414 read=43704
->  Parallel Hash  (cost=19179.70..19179.70 rows=47226 width=16) (actual time=102.325..102.325 rows=38324 loops=3)
	Buckets: 131072  Batches: 1  Memory Usage: 6464kB
	Buffers: shared hit=14819 read=553
->  Parallel Bitmap Heap Scan on orders o  (cost=3426.19..19179.70 rows=47226 width=16) (actual time=15.448..93.900 rows=38324 loops=3)
	Recheck Cond: ((status = 'PAID'::text) AND (created_at >= (now() - '30 days'::interval)))
	Heap Blocks: exact=4897
	Buffers: shared hit=14819 read=553
->  Bitmap Index Scan on idx_orders_status_createdat_id  (cost=0.00..3397.85 rows=113342 width=0) (actual time=21.316..21.316 rows=114971 loops=1)
	Index Cond: ((status = 'PAID'::text) AND (created_at >= (now() - '30 days'::interval)))
	Buffers: shared hit=569
 
 Planning:
   Buffers: shared hit=102 read=2
 Planning Time: 3.284 ms
 Execution Time: 372.746 ms

약 17% 정도 성능이 개선 됌.

핵심 변화:

  • Before: Parallel Seq Scan on orders
  • After:
    • Bitmap Index Scan on idx_orders_status_createdat_id
    • Parallel Bitmap Heap Scan on orders

즉, status='PAID' AND created_at >= now()-30d 필터가 인덱스로 먼저 줄어든 다음 heap을 읽는 구조로 바뀜.

여전히 가장 큰 병목은 이거야:

  • Parallel Seq Scan on order_items oi ... rows=2000000 loops=3
  • Parallel Hash Join (oi.order_id = o.id)

즉, order_items를 여전히 풀스캔하고 있다.

order_items 인덱스를 안 탈까? order_items를 병렬로 쭉 읽고 해시로 붙이자”가 더 싸다고 본 것

조금 더 최적화를 위해 '커버링/복합' 인덱스를 추가해본다.

CREATE INDEX idx_order_items_order_id_cover
ON order_items(order_id)
INCLUDE (quantity, price);

ANALYZE order_items;
  • order_id (조인) quantity, price (집계 계산)을 함께 묶는 인덱스 사용
 Execution Time: 301.517 ms

조인 순서를 강제로 유도하고, CTE로 orders를 먼저 확정하도록 쿼리 변경해보기

EXPLAIN (ANALYZE, BUFFERS)
WITH paid_orders AS (
  SELECT id, user_id
  FROM orders
  WHERE status = 'PAID'
    AND created_at >= NOW() - INTERVAL '30 days'
)
SELECT
  po.user_id,
  SUM(oi.quantity * oi.price) AS total_amount
FROM paid_orders po
JOIN order_items oi ON oi.order_id = po.id
GROUP BY po.user_id
ORDER BY total_amount DESC
LIMIT 20;
Execution Time: 172.833 ms

가장 처음보다 2.6배 정도 빨라졌다.

실습 후 DROP 하기

\di --인덱스 확인
\d -- 테이블/시퀀스 확인
DROP INDEX IF EXISTS idx_orders_status_createdat_id;
DROP INDEX IF EXISTS idx_order_items_order_id;
DROP INDEX IF EXISTS idx_order_items_order_id_cover;

DROP TABLE IF EXISTS order_items CASCADE;
DROP TABLE IF EXISTS orders CASCADE;
DROP TABLE IF EXISTS users CASCADE;

 

'Database > SQL' 카테고리의 다른 글

TCL  (0) 2026.02.11
INSERT, UPDATE, DELETE  (0) 2026.02.10
ALTER, DROP, TRUNCATE  (0) 2026.02.10
CREATE  (0) 2026.02.10
String, Date  (0) 2026.02.09