트랜잭션(Transaction)이란?
트랜잭션은 데이터베이스에서 하나의 논리적인 작업을 수행하기 위한 SQL 연산의 집합이다. 여러 개의 작업을 하나의 단위로 묶어, 모든 작업이 정상적으로 완료되거나 전체 작업이 취소되도록 보장한다.
예를 들어 게임 서버에서 플레이어 A가 플레이어 B에게 100골드를 전송한다고 가정하자.
이 작업은 다음과 같은 두 가지 변경으로 구성된다.
- 플레이어 A의 골드 100 감소
- 플레이어 B의 골드 100 증가
만약 A의 골드만 감소한 상태에서 서버 오류가 발생한다면 어떻게 될까?
A의 골드는 사라졌지만 B는 골드를 받지 못한다. 이는 데이터 정합성이 깨진 상태다.
트랜잭션을 사용하면 두 작업을 하나의 단위로 처리할 수 있다. 모든 작업이 성공하면 COMMIT으로 확정하고, 중간에 오류가 발생하면 ROLLBACK으로 변경 사항을 취소한다.
START TRANSACTION;
UPDATE players
SET gold = gold - 100
WHERE player_id = 1;
UPDATE players
SET gold = gold + 100
WHERE player_id = 2;
COMMIT;
실제 서비스에서는 송금 금액이 양수인지, A에게 충분한 골드가 있는지, 두 플레이어가 정상적으로 존재하는지 등의 검증도 필요하다.
오류 발생 시에는 다음과 같이 처리한다.
START TRANSACTION;
UPDATE players
SET gold = gold - 100
WHERE player_id = 1;
-- 이후 작업에서 오류가 발생했다고 가정
ROLLBACK;
단, 트랜잭션을 사용했다고 해서 모든 동시성 문제가 자동으로 해결되는 것은 아니다. 어떤 격리 수준을 사용하는지, 데이터를 어떻게 읽고 수정하는지에 따라 추가적인 동시성 제어가 필요하다.
ACID란?
ACID는 데이터베이스 트랜잭션이 신뢰성 있게 동작하기 위해 요구되는 네 가지 속성이다.
Atomicity — 원자성
원자성은 트랜잭션에 포함된 작업이 전부 성공하거나 전부 취소되어야 한다는 성질이다.
게임 재화 거래에서 송신자의 재화 차감만 성공하고 수신자의 재화 지급이 실패한다면 안 된다.
트랜잭션은 이러한 부분적인 변경을 방지한다.
Consistency — 일관성
일관성은 트랜잭션이 완료된 후에도 데이터베이스가 정의된 제약 조건과 비즈니스 규칙을 만족해야 한다는 성질이다.
예를 들어 다음과 같은 규칙이 있다고 가정하자.
- 골드는 0보다 작을 수 없다.
- 계정 이메일은 중복될 수 없다.
- 주문에 연결된 사용자는 실제로 존재해야 한다.
이러한 규칙은 적절한 데이터베이스 제약 조건과 애플리케이션 검증을 통해 보장해야 한다.
CREATE TABLE players (
player_id BIGINT PRIMARY KEY,
gold BIGINT NOT NULL CHECK (gold >= 0)
);
CHECK 제약 조건의 실제 지원 및 동작은 DBMS와 버전에 따라 확인해야 한다.
트랜잭션 자체가 모든 비즈니스 규칙을 자동으로 검증하는 것은 아니다. 잘못된 로직으로 실행된 트랜잭션도 데이터베이스 제약 조건을 위반하지 않는다면 커밋될 수 있다.
Isolation — 격리성
격리성은 여러 트랜잭션이 동시에 실행될 때 서로의 중간 작업에 의해 잘못된 결과가 발생하지 않도록 실행 간섭을 제어하는 성질이다.
예를 들어 두 요청이 동시에 동일한 계정의 골드를 읽고 수정한다면, 한 요청의 변경 사항이 다른 요청에 의해 덮어써질 수 있다.
격리 수준은 이러한 동시 실행의 간섭을 어느 정도 허용할 것인지 결정한다.
완전한 직렬 실행에 가까울수록 데이터 정합성을 확보하기 쉽지만, 잠금과 재시도에 따른 비용이 증가할 수 있다.
Durability — 지속성
지속성은 트랜잭션이 성공적으로 커밋된 이후 장애가 발생하더라도 확정된 데이터가 유지되어야 한다는 성질이다.
데이터베이스는 일반적으로 트랜잭션 로그와 복구 메커니즘을 활용해 이를 보장한다.
다만 실제 장애 상황에서의 보장 범위는 DBMS 설정, 스토리지, 복제 방식, 동기화 정책에 따라 달라질 수 있다.
트랜잭션의 주요 명령어
트랜잭션을 다룰 때 주로 사용하는 명령어는 다음과 같다.
| 명령어 | 설명 |
|---|---|
START TRANSACTION | 트랜잭션 시작 |
COMMIT | 변경 사항 확정 |
ROLLBACK | 변경 사항 취소 |
SAVEPOINT | 트랜잭션 내부의 복구 지점 설정 |
ROLLBACK TO SAVEPOINT | 지정한 복구 지점까지 되돌리기 |
예를 들어 여러 작업 중 일부만 취소해야 한다면 SAVEPOINT를 사용할 수 있다.
START TRANSACTION;
UPDATE players
SET gold = gold - 100
WHERE player_id = 1;
SAVEPOINT after_deduction;
UPDATE players
SET gold = gold + 100
WHERE player_id = 2;
-- 두 번째 작업만 취소
ROLLBACK TO SAVEPOINT after_deduction;
-- 첫 번째 작업까지 포함해 전체 취소
ROLLBACK;
트랜잭션은 가능한 한 짧게 유지하는 것이 좋다. 트랜잭션이 오래 지속되면 잠금 유지 시간이 길어지고, 다른 요청의 대기 시간이 증가하며, 교착 상태가 발생할 가능성도 높아진다.
외부 API 호출이나 사용자 입력 대기처럼 시간이 오래 걸리는 작업을 데이터베이스 트랜잭션 내부에 넣는 것도 피해야 한다.
격리 수준(Isolation Level)
격리 수준은 동시에 실행되는 트랜잭션이 서로의 데이터를 얼마나 볼 수 있는지 결정하는 설정이다.
대표적인 격리 수준은 다음과 같다.
- Read Uncommitted
- Read Committed
- Repeatable Read
- Serializable
격리 수준을 이해하려면 세 가지 현상을 먼저 알아야 한다.
Dirty Read — 더티 리드
다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽는 현상이다.
트랜잭션 A가 골드를 1,000에서 500으로 변경했지만 아직 커밋하지 않았다고 가정하자. 이때 트랜잭션 B가 500이라는 값을 읽는다.
이후 A가 롤백하면 실제 골드는 다시 1,000이 된다. B는 최종적으로 확정되지 않은 데이터를 읽은 셈이다.
Non-repeatable Read — 반복 불가능 읽기
하나의 트랜잭션에서 동일한 조건으로 같은 행을 두 번 읽었는데 결과가 달라지는 현상이다.
트랜잭션 A가 플레이어의 골드를 읽고, 그사이에 트랜잭션 B가 골드를 변경한 뒤 커밋한다. 이후 A가 같은 행을 다시 읽으면 다른 값이 반환될 수 있다.
Phantom Read — 팬텀 리드
하나의 트랜잭션에서 동일한 조건의 조회를 반복했는데, 다른 트랜잭션의 변경으로 조회 대상 행의 집합이 달라지는 현상이다.
예를 들어 A가 골드가 1,000 이상인 플레이어를 조회한다.
SELECT *
FROM players
WHERE gold >= 1000;
이후 B가 조건에 해당하는 플레이어를 추가하거나 기존 플레이어의 골드를 변경해 커밋하면, A가 같은 쿼리를 다시 실행했을 때 새로운 행이 나타날 수 있다.
팬텀 리드는 단순히 기존 행의 값이 바뀌는 문제와 달리, 조건에 일치하는 행의 집합이 달라지는 문제다.
Read Committed
Read Committed는 커밋된 데이터만 읽을 수 있도록 하는 격리 수준이다.
다른 트랜잭션이 변경한 데이터가 커밋되기 전에는 그 변경 사항을 읽을 수 없다.
일반적으로 Dirty Read를 방지하지만, 같은 트랜잭션 안에서도 동일한 행을 다시 읽었을 때 값이 달라질 수 있다.
예를 들어 다음과 같은 상황이다.
- 트랜잭션 A가 플레이어의 골드를 조회한다. 결과는 1,000이다.
- 트랜잭션 B가 골드를 500으로 변경하고 커밋한다.
- 트랜잭션 A가 같은 골드를 다시 조회한다. 결과는 500이다.
트랜잭션 A는 커밋된 데이터만 읽었지만, 조회 시점마다 서로 다른 값을 관찰했다.
장점
- 커밋되지 않은 데이터에 대한 읽기를 방지한다.
- 비교적 낮은 격리 비용으로 동시성을 확보할 수 있다.
- 조회 결과가 매번 달라질 수 있다는 점을 감안하면 다양한 일반 업무 처리에 적합하다.
주의점
Read Committed만으로는 읽은 값을 기준으로 수행하는 후속 계산이 안전하다고 보장할 수 없다.
예를 들어 현재 골드가 1,000이라는 사실을 읽은 뒤 900을 차감하는 로직이 있다고 가정하자. 여러 요청이 동시에 실행되면 각 요청이 동일한 초기값을 읽을 수 있다.
따라서 재화 차감처럼 정합성이 중요한 작업에는 조건부 UPDATE, 행 잠금 또는 버전 기반 동시성 제어를 함께 고려해야 한다.
Repeatable Read
Repeatable Read는 하나의 트랜잭션에서 동일한 행을 반복해서 읽을 때 일관된 값을 관찰하도록 하는 격리 수준이다.
일반적으로 Dirty Read와 Non-repeatable Read를 방지한다.
예를 들어 다음과 같다.
- 트랜잭션 A가 골드를 조회한다. 결과는 1,000이다.
- 트랜잭션 B가 골드를 500으로 변경하고 커밋한다.
- 트랜잭션 A가 같은 행을 다시 조회한다. 여전히 1,000을 관찰할 수 있다.
- 트랜잭션 A가 종료된다.
이러한 동작은 MVCC(Multi-Version Concurrency Control)와 같은 메커니즘을 통해 구현될 수 있다. MVCC는 데이터의 여러 버전을 활용해 읽기 작업이 특정 시점의 데이터를 관찰할 수 있도록 한다.
다만 Repeatable Read의 정확한 동작은 DBMS에 따라 다르다.
예를 들어 MySQL InnoDB는 일반적인 일관 읽기에서 트랜잭션 스냅샷을 활용한다. 반면 잠금 읽기인 SELECT ... FOR UPDATE는 일반적인 일관 읽기와 다른 동작을 보일 수 있다.
또한 Repeatable Read가 모든 동시성 문제를 해결하는 것은 아니다. 특히 여러 행에 걸친 비즈니스 규칙이나 조건을 검사한 뒤 데이터를 변경하는 경우에는 별도의 잠금 또는 제약 조건이 필요할 수 있다.
장점
- 트랜잭션 내부에서 반복 조회의 일관성을 확보하기 쉽다.
- 조회와 계산이 여러 단계로 이루어지는 작업에 유용하다.
주의점
- 오래 지속되는 트랜잭션은 오래된 스냅샷을 유지할 수 있다.
- DBMS별로 팬텀 리드와 잠금 동작이 다르다.
- 반복 조회의 일관성이 보장된다고 해서 모든 쓰기 충돌이 자동으로 해결되는 것은 아니다.
Serializable
Serializable은 여러 트랜잭션의 동시 실행 결과가 해당 트랜잭션들을 어떤 순서로든 하나씩 실행한 결과와 동등하도록 보장하는 격리 수준이다.
일반적으로 가장 강한 격리 수준으로 분류된다.
예를 들어 플레이어가 한 번에 하나의 활성 파티에만 가입할 수 있다고 가정하자.
트랜잭션 A와 B가 동시에 활성 파티 가입 여부를 조회한 뒤 각각 다른 파티에 가입하려고 하면, 단순한 조회 후 삽입 로직만으로는 규칙이 깨질 수 있다.
Serializable은 이러한 트랜잭션 간 의존성을 제어해 직렬 실행과 동등한 결과를 보장하도록 한다.
구현 방식은 DBMS마다 다르다. 잠금을 활용할 수도 있고, 충돌을 감지해 트랜잭션을 중단시키고 재시도를 요구할 수도 있다.
장점
- 복잡한 동시성 상황에서 데이터 정합성을 강하게 보장한다.
- 여러 행을 포함하는 조건과 비즈니스 규칙을 처리할 때 유용하다.
단점
- 잠금 대기, 충돌 감지, 트랜잭션 재시도로 인해 처리량이 낮아질 수 있다.
- 트랜잭션이 실패할 수 있으므로 애플리케이션에서 재시도 정책을 설계해야 한다.
Serializable을 사용한다고 해서 애플리케이션의 재시도 로직이나 비즈니스 규칙 검증이 불필요해지는 것은 아니다.
격리 수준 비교
| 격리 수준 | Dirty Read | Non-repeatable Read | 팬텀 리드 |
|---|---|---|---|
| Read Uncommitted | 허용 가능 | 허용 가능 | 허용 가능 |
| Read Committed | 방지 | 발생 가능 | 발생 가능 |
| Repeatable Read | 방지 | 방지 | DBMS별 차이 |
| Serializable | 방지 | 방지 | 방지 |
이 표는 SQL 표준의 일반적인 격리 수준 특성을 기준으로 한 것이다. 실제 DBMS의 구현과 잠금 읽기 방식에 따라 구체적인 동작은 달라질 수 있다.
낙관적 동시성 제어(Optimistic Concurrency Control)
낙관적 동시성 제어는 대부분의 작업이 충돌하지 않는다고 가정하고, 데이터를 변경하는 시점에 충돌 여부를 검사하는 방식이다.
일반적으로 데이터를 읽을 때 버전 번호를 함께 조회하고, 수정할 때 기존 버전이 그대로인지 확인한다.
예를 들어 플레이어 데이터에 version 컬럼을 추가한다.
CREATE TABLE players (
player_id BIGINT PRIMARY KEY,
gold BIGINT NOT NULL,
version BIGINT NOT NULL DEFAULT 0
);
데이터를 조회한다.
SELECT player_id, gold, version
FROM players
WHERE player_id = 1;
조회 결과가 다음과 같다고 가정하자.
player_id: 1
gold: 1000
version: 3
이제 골드를 100 감소시키면서 버전도 증가시킨다.
UPDATE players
SET
gold = gold - 100,
version = version + 1
WHERE player_id = 1
AND version = 3
AND gold >= 100;
업데이트 결과가 한 행이면 해당 버전을 기준으로 변경에 성공한 것이다.
반대로 다른 요청이 먼저 데이터를 수정해 버전이 4가 되었다면, version = 3 조건이 일치하지 않아 업데이트 결과는 0행이 된다.
이 경우 애플리케이션은 최신 데이터를 다시 조회하고 작업을 재시도하거나, 충돌을 사용자에게 반환해야 한다.
장점
- 잠금을 장시간 유지하지 않고도 충돌을 감지할 수 있다.
- 충돌이 드문 환경에서 효율적이다.
- 웹 API의 프로필 수정, 설정 변경, 재고 수정 등에 적용할 수 있다.
단점
- 충돌이 빈번하면 재시도 비용이 증가한다.
- 충돌이 발생했을 때 데이터를 다시 조회하고 비즈니스 로직을 재실행해야 할 수 있다.
- 모든 변경 경로가 버전 조건을 지켜야 한다.
낙관적 동시성 제어는 충돌을 무조건 방지하는 방식이 아니라, 충돌을 감지해 잘못된 덮어쓰기를 방지하는 방식이다.
비관적 동시성 제어(Pessimistic Concurrency Control)
비관적 동시성 제어는 동시에 실행되는 작업이 충돌할 가능성이 있다고 보고, 데이터를 변경하기 전에 잠금을 획득하는 방식이다.
대표적인 방법은 SELECT ... FOR UPDATE를 사용하는 것이다.
START TRANSACTION;
SELECT gold
FROM players
WHERE player_id = 1
FOR UPDATE;
-- 애플리케이션에서 잔액 확인
UPDATE players
SET gold = gold - 100
WHERE player_id = 1
AND gold >= 100;
COMMIT;
지원되는 트랜잭션 및 테이블 엔진에서 FOR UPDATE는 해당 행에 대한 잠금을 획득해 다른 트랜잭션의 충돌하는 변경 작업을 제어한다.
일반적으로 잠금은 트랜잭션이 커밋되거나 롤백될 때 해제된다.
장점
- 동시에 같은 데이터를 변경하는 요청을 직접 제어할 수 있다.
- 충돌이 잦은 데이터에 적합하다.
- 읽기와 수정 사이에 발생하는 경쟁 상황을 줄일 수 있다.
단점
- 잠금 대기가 발생할 수 있다.
- 잠금을 오래 유지하면 처리량이 감소한다.
- 잠금 획득 순서가 꼬이면 교착 상태가 발생할 수 있다.
낙관적 방식과 비관적 방식의 비교
| 항목 | 낙관적 제어 | 비관적 제어 |
|---|---|---|
| 기본 가정 | 충돌이 드물다 | 충돌 가능성이 높다 |
| 제어 시점 | 수정 시 충돌 검사 | 수정 전 잠금 획득 |
| 주요 기법 | 버전 컬럼, 조건부 UPDATE | 행 잠금, FOR UPDATE |
| 충돌 처리 | 실패 감지 후 재시도 | 잠금 대기 또는 실패 |
| 적합한 상황 | 충돌이 적은 일반 수정 | 경합이 잦은 핵심 데이터 |
둘 중 하나가 항상 우월한 것은 아니다. 데이터의 충돌 빈도, 트랜잭션 길이, 재시도 비용, 허용 가능한 지연 시간을 기준으로 선택해야 한다.
행 잠금(Row Lock)과 교착 상태(Deadlock)
행 잠금이란?
행 잠금은 데이터베이스에서 특정 행에 대한 동시 접근을 제어하는 잠금이다.
예를 들어 한 플레이어의 골드를 차감하는 동안 다른 트랜잭션이 같은 행을 수정하려고 하면, 잠금 충돌로 인해 후속 작업이 대기할 수 있다.
START TRANSACTION;
SELECT gold
FROM players
WHERE player_id = 1
FOR UPDATE;
UPDATE players
SET gold = gold - 100
WHERE player_id = 1;
COMMIT;
행 잠금은 여러 요청이 동일한 데이터를 동시에 변경할 때 정합성을 확보하는 데 유용하다.
다만 잠금 범위와 실제 잠금 방식은 DBMS, 인덱스, 격리 수준, 실행 계획 등에 따라 달라진다. 특히 인덱스를 적절하게 사용하지 않으면 예상보다 넓은 범위의 잠금이나 더 많은 대기가 발생할 수 있다.
교착 상태란?
교착 상태는 두 개 이상의 트랜잭션이 서로 상대방이 보유한 잠금이 해제되기를 기다리면서 진행할 수 없는 상태다.
예를 들어 다음과 같은 상황을 생각할 수 있다.
- 트랜잭션 A가 플레이어 1의 행을 잠근다.
- 트랜잭션 B가 플레이어 2의 행을 잠근다.
- 트랜잭션 A가 플레이어 2의 잠금을 요청하고 대기한다.
- 트랜잭션 B가 플레이어 1의 잠금을 요청하고 대기한다.
이제 A는 B를 기다리고, B는 A를 기다린다.
<box border radius=”lg” padding={3} gap={2}>
<title size=”md”>교착 상태의 대기 관계</title>
<box align=”center” gap={1}>
<box background=”surface-secondary” radius=”md” padding={3} width=”70%” align=”center”>
**트랜잭션 A**
<text size="sm">플레이어 1 잠금 보유</text>
</box>
<icon name="arrow-down" size="lg"/>
<text color="danger" weight="medium">플레이어 2 잠금 대기</text>
<divider/>
<box background="surface-secondary" radius="md" padding={3} width="70%" align="center">
**트랜잭션 B**
<text size="sm">플레이어 2 잠금 보유</text>
</box>
<icon name="arrow-up" size="lg"/>
<text color="danger" weight="medium">플레이어 1 잠금 대기</text>
</box>
<text color=”secondary” size=”sm”>서로가 보유한 잠금을 기다리므로 어느 트랜잭션도 스스로 진행할 수 없다.</text>
</box>
데이터베이스는 일반적으로 교착 상태를 감지하면 트랜잭션 중 하나를 중단시켜 순환 대기를 해소한다. 따라서 교착 상태가 발생했다고 해서 반드시 데이터베이스 전체가 멈추는 것은 아니다.
교착 상태 예방 방법
일관된 잠금 순서 유지
모든 트랜잭션이 플레이어 ID 오름차순처럼 동일한 순서로 행을 잠그도록 설계한다.
START TRANSACTION;
SELECT player_id, gold
FROM players
WHERE player_id IN (1, 2)
ORDER BY player_id
FOR UPDATE;
-- 검증 후 두 플레이어의 데이터 변경
COMMIT;
실제 잠금 획득 순서는 실행 계획과 DBMS 구현의 영향을 받을 수 있으므로, ORDER BY만으로 모든 교착 상태가 제거된다고 가정해서는 안 된다.
트랜잭션을 짧게 유지
잠금을 획득한 상태에서 외부 API를 호출하거나 불필요한 연산을 수행하지 않는다.
인덱스 활용
조건에 맞는 행을 효율적으로 찾으면 불필요한 데이터 접근과 잠금 범위를 줄이는 데 도움이 된다.
재시도 정책 구현
교착 상태로 트랜잭션이 중단되면 애플리케이션에서 전체 트랜잭션을 다시 실행할 수 있도록 설계한다.
재시도에는 횟수 제한과 지수형 백오프(Exponential Backoff)를 적용하는 것이 좋다. 단, 무조건 반복해서 재시도하면 서버 부하가 더 커질 수 있다.
Race Condition(경쟁 상태)
Race Condition은 여러 작업이 동시에 실행될 때 실행 순서나 타이밍에 따라 결과가 달라지는 문제다.
데이터베이스뿐 아니라 멀티스레드 프로그램, 분산 시스템, 실시간 게임 서버에서도 발생한다.
재화 차감에서 발생하는 Race Condition
플레이어의 골드가 1,000이라고 가정하자.
두 개의 요청이 동시에 각각 800골드짜리 아이템을 구매하려고 한다.
잘못된 구현은 다음과 같다.
const player = await repository.findById(playerId);
if (player.gold >= 800) {
await repository.updateGold(
playerId,
player.gold - 800
);
await purchaseItem(playerId, itemId);
}
두 요청의 실행 순서가 다음과 같다면 문제가 발생한다.
| 순서 | 요청 A | 요청 B |
|---|---|---|
| 1 | 골드 1,000 조회 | |
| 2 | 골드 1,000 조회 | |
| 3 | 구매 가능 판정 | |
| 4 | 구매 가능 판정 | |
| 5 | 골드를 200으로 변경 | |
| 6 | 골드를 200으로 변경 | |
| 7 | 아이템 구매 완료 | |
| 8 | 아이템 구매 완료 |
최종 골드는 200인데 아이템은 두 개 지급될 수 있다.
이는 두 요청이 동일한 초기값을 읽고 각각 구매가 가능하다고 판단했기 때문이다. 이와 같은 문제를 Lost Update 또는 체크 후 사용(TOCTOU) 문제와 연관 지어 설명할 수 있다.
해결 방법: 조건부 UPDATE
조건 검사와 데이터 변경을 하나의 SQL 문장으로 결합할 수 있다.
UPDATE players
SET gold = gold - 800
WHERE player_id = 1
AND gold >= 800;
업데이트 결과가 한 행이면 차감에 성공한 것이고, 0행이면 플레이어가 없거나 골드가 부족한 것이다.
두 요청이 동시에 실행되더라도 동일한 행에 대한 변경은 데이터베이스의 동시성 제어를 거친다. 먼저 처리된 요청이 골드를 200으로 줄였다면, 나중 요청은 최신 값에 대해 조건을 확인하고 실패하게 된다.
다만 실제 아이템 구매는 골드 차감만으로 끝나지 않는다. 차감과 아이템 지급을 하나의 트랜잭션으로 묶어야 한다.
START TRANSACTION;
UPDATE players
SET gold = gold - 800
WHERE player_id = 1
AND gold >= 800;
-- 애플리케이션에서 UPDATE 영향 행 수 확인
-- 0행이면 ROLLBACK
-- 1행이면 인벤토리에 아이템 지급
INSERT INTO inventory (player_id, item_id, quantity)
VALUES (1, 101, 1);
COMMIT;
이 예제에서 애플리케이션은 반드시 UPDATE 영향 행 수를 확인해야 한다. 차감에 실패했다면 아이템을 지급하지 않고 트랜잭션을 롤백해야 한다.
또한 동일한 구매 요청이 네트워크 재시도로 중복 처리되는 문제까지 방지하려면 별도의 주문 ID 또는 멱등성 키를 사용해야 한다.
해결 방법: 행 잠금
조건부 UPDATE 대신 행 잠금으로 데이터를 보호할 수도 있다.
START TRANSACTION;
SELECT gold
FROM players
WHERE player_id = 1
FOR UPDATE;
-- 조회된 골드가 800 이상인지 확인
UPDATE players
SET gold = gold - 800
WHERE player_id = 1;
-- 아이템 지급
COMMIT;
이 방식에서는 잠금을 획득한 트랜잭션이 검증과 변경을 수행하는 동안 충돌하는 변경 작업이 대기한다.
다만 예제는 핵심 동작을 보여 주기 위한 것이다. 실제 구현에서는 골드가 부족한 경우 롤백하고, 플레이어가 존재하는지 확인하며, 아이템 지급 실패 시 차감도 취소해야 한다.
Race Condition을 방지하는 핵심 원칙
- 검증과 변경을 가능한 한 하나의 원자적 작업으로 결합한다.
- 여러 데이터 변경이 함께 성공해야 한다면 트랜잭션으로 묶는다.
- 충돌이 발생할 수 있는 행은 조건부 UPDATE, 낙관적 잠금 또는 비관적 잠금으로 보호한다.
- 동일한 요청이 반복 실행될 수 있다면 멱등성을 확보한다.
- 서버 메모리의 값만 신뢰하지 말고, 중요한 상태는 데이터베이스에서 원자적으로 검증하고 변경한다.
실무에서 어떤 방법을 선택해야 할까?
모든 상황에 가장 강한 격리 수준이나 가장 많은 잠금을 적용하는 것이 정답은 아니다. 중요한 것은 데이터의 정합성 요구 사항과 동시성 특성을 분석해 적절한 제어 방법을 선택하는 것이다.
| 상황 | 권장 접근 |
|---|---|
| 일반적인 목록 조회 | Read Committed 등 서비스에 적합한 격리 수준 |
| 여러 번의 조회에서 동일한 스냅샷 필요 | Repeatable Read 또는 DBMS의 스냅샷 기능 |
| 골드·포인트 차감 | 조건부 UPDATE와 트랜잭션 |
| 동시에 수정될 가능성이 낮은 프로필 | 버전 기반 낙관적 동시성 제어 |
| 경합이 잦은 핵심 재화 | 조건부 UPDATE 또는 짧은 행 잠금 트랜잭션 |
| 여러 행에 걸친 복잡한 정합성 규칙 | 제약 조건, 잠금, 필요에 따라 Serializable |
| 중복 구매 요청 방지 | 멱등성 키와 고유 제약 조건 |
| 여러 트랜잭션의 잠금 충돌 | 일관된 잠금 순서와 제한된 재시도 |