· 7분 읽기
트랜잭션 실전 가이드: ACID, 격리수준 선택, Upsert 패턴
ACID는 교과서적 정의를 안다고 해서 지켜지지 않습니다. 트랜잭션 경계를 어디까지 잡을지, 격리수준을 무엇으로 설정할지, 동시성 문제를 코드에서 어떻게 걸러낼지를 실제로 결정해야 비로소 정합성이 유지됩니다. 이 글은 ACID의 정의와 실제로 깨지는 원인, 분산 환경에서의 확장, 격리수준 선택 기준, 그리고 대량 쓰기에서 경쟁 조건을 해결하는 Upsert 패턴까지 트랜잭션 관련 실무 지식을 하나의 흐름으로 정리합니다.
ACID 정의
- Atomicity: 트랜잭션은 전부 성공하거나 전부 실패해야 합니다.
- Consistency: 트랜잭션 전후로 제약조건과 데이터 규칙이 유지되어야 합니다.
- Isolation: 동시에 실행되는 트랜잭션 간 간섭이 제어되어야 합니다.
- Durability: 커밋된 결과는 장애 이후에도 보존되어야 합니다.
ACID를 실제로 깨뜨리는 원인
- 애플리케이션에서 트랜잭션 경계를 너무 넓게 잡아 락 경합이 증가합니다.
- 격리수준을 이해하지 않고 기본값을 그대로 사용해 팬텀 리드나 갱신 손실이 발생합니다.
- DB 커밋 전에 외부 시스템(메시지 발행, 캐시 갱신)부터 수행합니다.
- 장애 복구 절차가 없어 durability를 백업에만 의존합니다.
격리수준: 무엇을 고를 것인가
격리수준에 정답은 없습니다. "허용 가능한 이상 현상"과 "필요한 처리량" 사이에서 선택해야 합니다.
빠른 기준
| 격리수준 | 특징 |
|---|---|
READ COMMITTED |
일반 OLTP의 기본 선택 |
REPEATABLE READ |
MySQL 기본값, 동일 트랜잭션 내 조회 일관성이 중요할 때 |
SERIALIZABLE |
충돌 허용이 매우 낮고 처리량 희생이 가능할 때 |
READ UNCOMMITTED |
일반 서비스에는 비권장 |
선택할 때 물어야 할 4가지 질문
- 더티 리드가 절대 허용 불가인가?
- 같은 트랜잭션에서 재조회 결과가 바뀌면 문제인가?
- 팬텀 리드를 애플리케이션이 처리할 수 있는가?
- 락 경합으로 인한 지연을 감당할 수 있는가?
실무 권장 패턴
- 전역 기본값은
READ COMMITTED또는 DB 기본 전략을 유지합니다. - 민감한 트랜잭션만 상위 격리수준으로 승격합니다.
- 격리수준을 변경하기 전에 deadlock/latency 리허설을 수행합니다.
- 낙관적 잠금(버전 컬럼)을 함께 검토합니다. 격리수준을 올리는 대신, 갱신 시점에 버전 컬럼을 비교해 충돌을 감지하는 방식이 락 경합 없이도 정합성을 지킬 수 있는 경우가 많습니다.
격리수준은 DB 파라미터가 아니라 비즈니스 정책입니다. 트랜잭션마다 필요한 정합성 수준을 분리해서 설정하는 것이 핵심입니다.
분산 환경에서의 확장: 2PC와 Saga
단일 DB 트랜잭션으로 끝나지 않으면 보통 두 가지를 고려합니다.
- 2PC/XA: 강한 일관성 우선, 운영 복잡도와 지연 증가.
- Saga: 보상 트랜잭션 기반, 최종 일관성 모델.
정답은 하나가 아니라, 도메인의 실패 허용치에 따라 선택합니다.
커밋 이후 정합성: Outbox 패턴과 체크포인트
트랜잭션 내부에서는 DB 변경만 다루고, DB 밖으로 나가는 부수 효과(이벤트 발행, 캐시 갱신)는 커밋 이후로 미뤄야 합니다.
- 트랜잭션 안에서는 DB 변경 외 작업을 최소화합니다.
- 교착상태 재시도 정책(횟수/백오프)을 명시합니다.
- "커밋 후 이벤트 발행" 패턴(outbox 등)으로 정합성을 맞춥니다.
- 백업 복구 리허설 없이는 durability를 만족했다고 보기 어렵습니다.
트랜잭션 제어 예시: SAVEPOINT로 부분 롤백하기
트랜잭션 전체를 롤백하지 않고 일부 구간만 되돌려야 할 때는 SAVEPOINT를 사용합니다. 예를 들어 주문 생성은 유지하되 결제 검증 실패 시 결제 관련 변경만 되돌리는 흐름은 다음과 같이 작성할 수 있습니다.
START TRANSACTION;
INSERT INTO orders (id, status) VALUES (1001, 'created');
SAVEPOINT before_payment;
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
-- 결제 검증 실패 시 결제 관련 변경만 되돌리기
ROLLBACK TO SAVEPOINT before_payment;
COMMIT;
이렇게 하면 주문 레코드는 커밋되고, 결제 관련 변경만 부분 롤백됩니다. SAVEPOINT는 격리수준과 무관하게 동작하므로 긴 트랜잭션 안에서 실패 지점을 세분화하고 싶을 때 유용합니다.
Upsert 패턴: SELECT 후 INSERT의 경쟁 조건 해결
Upsert란
Upsert는 "없으면 INSERT, 있으면 UPDATE"를 한 문장으로 처리하는 방식입니다. 핵심은 원자성과 경합 감소입니다.
왜 SELECT 후 INSERT/UPDATE보다 유리한가
기존 패턴은 다음과 같습니다.
SELECT 1 FROM users WHERE id = 1;
-- 없으면 INSERT, 있으면 UPDATE
이 방식의 문제점은 명확합니다.
- 왕복 쿼리 수가 늘어납니다.
- SELECT 이후 경쟁 트랜잭션이 개입할 수 있습니다(이 글 앞부분에서 다룬 격리수준 문제와 직결됩니다).
- 애플리케이션 분기 로직의 복잡도가 증가합니다.
Upsert는 이 분기를 DB 엔진 내부로 밀어 넣어 경합 구간을 없앱니다.
DB별 기본 문법
PostgreSQL
INSERT INTO users (id, name, email)
VALUES (1, 'Kim', 'kim@example.com')
ON CONFLICT (id) DO UPDATE
SET
name = EXCLUDED.name,
email = EXCLUDED.email,
updated_at = NOW();
MySQL
INSERT INTO users (id, name, email)
VALUES (1, 'Kim', 'kim@example.com')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
email = VALUES(email),
updated_at = NOW();
배치 Upsert 패턴
INSERT INTO transactions (tx_id, amount, status)
VALUES
('tx_001', 100, 'pending'),
('tx_002', 200, 'confirmed'),
('tx_003', 150, 'pending')
ON CONFLICT (tx_id) DO UPDATE
SET
amount = EXCLUDED.amount,
status = EXCLUDED.status,
updated_at = NOW();
설계 체크리스트
- 고유키/유니크 인덱스가 명확한가.
- UPDATE가 정말 필요한 컬럼만 갱신하는가.
- 동일 데이터를 재처리해도 결과가 동일한가(멱등성).
- 배치 크기가 락 경합을 폭증시키지 않는가.
실수하기 쉬운 지점
- 큰 TEXT/BLOB 컬럼까지 매번 갱신해서 쓰기 비용이 급증합니다.
- 트리거나 CDC가 UPDATE 이벤트 폭증으로 병목을 일으킵니다.
- 애플리케이션 타임스탬프와 DB 타임스탬프를 혼용해 정합성이 깨집니다.
운영 팁
- 배치 크기부터 고정하고 성능 기준선을 먼저 잡습니다.
EXPLAIN (ANALYZE, BUFFERS)로 실제 실행 경로를 확인합니다.- 실패 재시도는 "같은 입력이면 같은 결과"가 되도록 설계합니다.
대량 인덱싱이나 동기화 작업에서 Upsert는 사실상 기본 선택입니다. 다만 성능은 문법이 아니라 인덱스 설계 + 배치 전략 + 갱신 컬럼 최소화로 결정됩니다.
정리
ACID는 이론이 아니라 운영 계약입니다. 트랜잭션 경계, 격리수준, 장애 복구 시나리오를 코드와 문서로 함께 관리해야 실제 품질이 유지됩니다. 격리수준은 전역 기본값을 낮게 유지하고 민감한 트랜잭션만 승격하는 방식이 현실적이며, 낙관적 잠금과 병행하면 락 경합 없이도 충돌을 감지할 수 있습니다. 그리고 SELECT 후 분기하는 방식이 만드는 경쟁 조건은 Upsert로 DB 엔진에 위임해 근본적으로 없앨 수 있습니다. 트랜잭션 경계, 격리수준, 쓰기 패턴은 서로 독립적인 문제가 아니라 하나의 정합성 전략 안에서 함께 설계해야 할 대상입니다.