junome

트랜잭션, 동시성 제어

PostgreSQL 트랜잭션 & 동시성 제어 완전 정복

0. 왜 트랜잭션과 동시성이 필요한가?

데이터베이스는 혼자 쓰는 프로그램이 아니다.

여러 사용자, 여러 프로그램, 여러 서버가 동시에 접근한다.

이때 발생하는 근본적인 문제가 있다.

같은 데이터를 동시에 읽고, 동시에 바꾸면
무엇이 최종적으로 맞는 값인가?

이 질문에 답하기 위해 만들어진 것이 트랜잭션과 동시성 제어이다.

1. 트랜잭션의 기초 개념

트랜잭션은 DB 작업을 하나의 논리 단위로 묶는다.

BEGIN;
-- 작업
COMMIT;

또는

ROLLBACK;

왜 필요할까?

예를 들어 계좌이체:

  1. A 계좌 -100
  2. 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, 재고 수량이 항상 같은 기준으로 맞춰짐

트랜잭션 주의점
- 오래 유지하면 락 대기와 성능 저하 발생
- 필요한 범위만, 최대한 짧게 사용하는 것이 핵심
- 예: 한 작업자가 오래 잡고 있으면 뒤 작업이 전부 대기하게 됨

동시성 제어
- 여러 사용자가 동시에 접근해도 값이 깨지지 않게 하는 방법

동시성 제어 사용 이유
- 동시에 조회 후 수정하면 덮어쓰기, 수량 불일치 같은 문제가 발생
- 읽는 시점과 바뀌는 시점의 차이를 제어해야 함
- 예: 두 작업자가 동시에 같은 자재를 출고하면 실제보다 많이 빠질 수 있음

동시성 제어의 효과
- 병렬 작업 환경에서도 데이터 무결성 유지
- 재고, 상태, 금액 같은 중요한 값 보호
- 서비스 규모가 커져도 안정적 운영 가능
- 예: 여러 라인에서 동시에 차감해도 최종 재고는 정확함

정리
- 트랜잭션: 하나의 업무를 안전하게 끝내는 장치
- 동시성 제어: 여러 사람이 동시에 써도 올바른 결과를 보장하는 기술
- 즉, 현장의 실제 수량과 시스템 수량을 맞추기 위한 최소한의 안전장치