PostgreSQL 트랜잭션 & 동시성 제어 완전 정복
0. 왜 트랜잭션과 동시성이 필요한가?
데이터베이스는 혼자 쓰는 프로그램이 아니다.
여러 사용자, 여러 프로그램, 여러 서버가 동시에 접근한다.
이때 발생하는 근본적인 문제가 있다.
같은 데이터를 동시에 읽고, 동시에 바꾸면
무엇이 최종적으로 맞는 값인가?
이 질문에 답하기 위해 만들어진 것이 트랜잭션과 동시성 제어이다.
1. 트랜잭션의 기초 개념
트랜잭션은 DB 작업을 하나의 논리 단위로 묶는다.
BEGIN;
-- 작업
COMMIT;
또는
ROLLBACK;
왜 필요할까?
예를 들어 계좌이체:
- A 계좌 -100
- B 계좌 +100
중간에 장애 나면?
돈이 사라질 수도 있다.
그래서 DB는 말한다.
둘 다 성공하거나
둘 다 없던 일이어야 한다.
이게 트랜잭션이다.
2. ACID
트랜잭션의 4대 원칙.
Atomicity (원자성)
전부 성공 or 전부 실패.
Consistency (일관성)
제약조건을 깨지 않는다.
Isolation (격리성)
동시에 실행될 때 어떻게 보일 것인가.
Durability (지속성)
커밋된 건 장애 후에도 살아있다.
(WAL 덕분)
3. 동시성이 왜 어려운가?
다음 상황을 생각해보자.
사용자 A
재고 확인 → 1개 있음
사용자 B
재고 확인 → 1개 있음
둘 다 주문.
결과? -1개.
문제 발생.
즉,
읽기 → 판단 → 수정
사이에 다른 트랜잭션이 끼어들 수 있다.
이 틈을 어떻게 막을 것인가가 동시성 제어다.
4. PostgreSQL이 선택한 해결 방법
PostgreSQL은 말한다.
읽는 사람을 막지 말자.
대신 각자 다른 버전을 보여주자.
이게 MVCC다.
5. MVCC (Multi-Version Concurrency Control)
5-1. UPDATE는 덮어쓰기가 아니다
새 버전을 만든다.
과거 + 현재가 함께 존재한다.
5-2. 각 row 내부 정보
| 컬럼 | 의미 |
|---|---|
| xmin | 생성 트랜잭션 |
| xmax | 삭제 트랜잭션 |
| ctid | 물리 위치 |
5-3. 스냅샷
트랜잭션 시작 시:
✔ 누가 커밋됐고
✔ 누가 진행 중인지
기준을 저장한다.
그리고 그 기준에 맞는 데이터만 본다.
그래서 읽기가 멈추지 않는다.
6. Dead Tuple & VACUUM
이전 버전은 언젠가 필요 없어지고
VACUUM이 정리한다.
긴 트랜잭션이 있으면?
VACUUM이 삭제를 못 한다.
→ bloat
→ 성능 저하
7. 그래도 락은 필요하다
버전은 읽기를 해결한다.
하지만 같은 row를 동시에 수정하면 충돌 난다.
그래서 락이 필요하다.
8. Lock 동작
UPDATE / DELETE
행을 잠근다.
SELECT
기본적으로 안 잠근다.
잠그는 조회
SELECT ... FOR UPDATE;
멀티 워커 필수 패턴
FOR UPDATE SKIP LOCKED
9. 누가 막고 있는지 찾기 (운영 필수)
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked
ON blocked.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking
ON blocking.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
10. 격리수준
READ COMMITTED
문장마다 최신 커밋 기준.
→ 결과가 바뀔 수 있다.
REPEATABLE READ
처음 스냅샷 유지.
조회 안정.
하지만 write skew 가능.
SERIALIZABLE
직렬 실행과 동일해야 함.
위험하면 강제 롤백.
재시도 필요.
11. 이상현상
| 현상 | 발생 여부 |
|---|---|
| Dirty Read | 없음 |
| Non-repeatable read | READ COMMITTED |
| Phantom read | READ COMMITTED |
| Write skew | REPEATABLE READ |
| Lost update | 로직 따라 |
12. Lost Update 방지 정석
UPDATE stock
SET qty = qty - 1
WHERE id = 1
AND qty > 0
RETURNING *;
읽기 + 검증 + 수정을 한 번에.
13. 좋은 트랜잭션 설계 철학
- 짧게
- 적게
- 빨리
- 재시도 가능
- 오래 잡지 않기
14. 전문가가 장애 때 보는 포인트
- 오래 열린 트랜잭션
- vacuum 지연
- 락 대기
- 튜플 증가
- 인덱스 비대화
요약
트랜잭션
- 이 작업은 전부 성공하거나 전부 취소되어야 하냐? → 트랜잭션
- 여러 SQL을 하나의 업무 단위로 묶어 처리하는 개념
트랜잭션 사용 이유
- INSERT, UPDATE를 나눠 실행하면 일부만 성공해 데이터가 어정쩡하게 남을 수 있음
- 업무적으로 의미 있는 완료 시점을 하나로 만들 필요가 있음
- 예: 자재 입고 등록 중 LOT는 생성됐는데 재고 반영이 실패하면 수량이 맞지 않게 됨
트랜잭션을 사용하면 좋은 점
- 모든 작업이 정상일 때만 COMMIT 가능
- 오류 발생 시 ROLLBACK으로 전체 취소 가능
- 여러 테이블이 함께 변경될 때 정합성을 보장
- 예: LOT, BOX, 재고 수량이 항상 같은 기준으로 맞춰짐
트랜잭션 주의점
- 오래 유지하면 락 대기와 성능 저하 발생
- 필요한 범위만, 최대한 짧게 사용하는 것이 핵심
- 예: 한 작업자가 오래 잡고 있으면 뒤 작업이 전부 대기하게 됨
동시성 제어
- 여러 사용자가 동시에 접근해도 값이 깨지지 않게 하는 방법
동시성 제어 사용 이유
- 동시에 조회 후 수정하면 덮어쓰기, 수량 불일치 같은 문제가 발생
- 읽는 시점과 바뀌는 시점의 차이를 제어해야 함
- 예: 두 작업자가 동시에 같은 자재를 출고하면 실제보다 많이 빠질 수 있음
동시성 제어의 효과
- 병렬 작업 환경에서도 데이터 무결성 유지
- 재고, 상태, 금액 같은 중요한 값 보호
- 서비스 규모가 커져도 안정적 운영 가능
- 예: 여러 라인에서 동시에 차감해도 최종 재고는 정확함
정리
- 트랜잭션: 하나의 업무를 안전하게 끝내는 장치
- 동시성 제어: 여러 사람이 동시에 써도 올바른 결과를 보장하는 기술
- 즉, 현장의 실제 수량과 시스템 수량을 맞추기 위한 최소한의 안전장치