19장. 동시성과 일관성
동시에 차감 요청이 오면 어떻게 할 것인가
사용자의 포인트가 5,000이라고 하자.
두 개의 요청이 동시에 들어온다.
요청 A: -4,000
요청 B: -4,000
둘 다 “잔액 충분”이라고 판단하면
최종 잔액은 -3,000이 된다.
이 문제는 어떤 구조를 쓰든 반드시 해결해야 한다.
핵심 질문은 이것이다.
동시에 상태를 바꾸려고 할 때
어떻게 안전하게 처리할 것인가?
상태 기반 시스템에서의 해결 방법
전통적인 RDB 구조에서는 보통 이렇게 한다.
SELECT balance FROM user_balance
WHERE user_id = 10
FOR UPDATE;
- 해당 행을 잠근다.
- 한 요청이 끝날 때까지 다른 요청은 기다린다.
- 순서대로 처리된다.
이 방법은 단순하지만
트래픽이 많아지면 대기 시간이 늘어날 수 있다.
Event Sourcing에서는 무엇이 달라지는가
Event Sourcing에서는
잔액이 직접 저장되지 않는다.
잔액은 이벤트를 모두 더해서 계산한 결과다.
그래서 문제는 이렇게 바뀐다.
같은 사용자에 대해
동시에 이벤트를 추가하려 하면 어떻게 막을 것인가?
여기서 사용하는 방식이 낙관적 락이다.
version을 이용한 동시성 제어
Event Store는 보통 이런 구조를 가진다.
event_store
-----------------------------------------
id (PK)
aggregate_id
version
event_type
payload (JSON)
created_at
그리고 반드시 다음 제약을 둔다.
UNIQUE (aggregate_id, version)
이 제약이 동시성의 핵심이다.
실제 동작 방식
현재 사용자의 마지막 version이 5라고 하자.
두 요청이 동시에 들어온다.
- 둘 다 version=5를 읽는다.
- 둘 다 다음 version=6으로 계산한다.
- 둘 다 version=6으로 INSERT 시도한다.
INSERT INTO event_store
(aggregate_id, version, event_type, payload)
VALUES
(10, 6, 'PointSpent', '{...}');
- 먼저 실행된 요청은 성공
- 두 번째 요청은 UNIQUE 제약 위반으로 실패
이것이 낙관적 락이다.
락을 미리 잡지 않고,
충돌이 나면 실패시키는 방식이다.
재시도는 어떻게 하나
UNIQUE 오류가 발생하면:
- 다시 이벤트를 조회한다.
- 현재 version을 다시 확인한다.
- 잔액을 다시 계산한다.
- 가능하면 새 version으로 다시 INSERT한다.
이 과정은 애플리케이션에서 처리한다.
여기서 현실적인 질문
balance 테이블을 같이 두고
거기서 낙관적 락을 적용하면 더 빠르지 않나?
이 질문은 매우 현실적이다.
그래서 실무에서는 두 가지 방식이 존재한다.
1️⃣ 순수 Event Sourcing 방식
- 잔액(balance) 테이블이 없다.
- 이벤트만 저장한다.
- 잔액은 이벤트를 계산해서 얻는다.
- 동시성은 version + UNIQUE 제약으로 제어한다.
장점:
- 구조가 명확하다.
- 언제든지 다시 계산할 수 있다.
단점:
- 구현이 복잡하다.
- 계산 비용이 있다.
2️⃣ 현실적인 혼합 방식 (실무에서 많이 사용)
- balance 테이블을 유지한다.
- balance에 version 컬럼을 둔다.
- balance UPDATE로 동시성 제어한다.
- 이벤트는 함께 저장한다.
처리 흐름
- 현재 balance와 version 조회
- 잔액이 충분한지 확인
- 다음 SQL 실행
UPDATE user_balance
SET balance = balance - 4000,
version = version + 1
WHERE user_id = 10
AND version = 5;
영향받은 row가 1개면 성공
0개면 충돌 → 재시도
- 같은 트랜잭션 안에서 이벤트 INSERT
INSERT INTO event_store
(aggregate_id, version, event_type, payload)
VALUES
(10, 6, 'PointSpent', '{...}');
중요한 점은 이것이다.
balance UPDATE와 이벤트 INSERT는
반드시 같은 트랜잭션 안에서 실행되어야 한다.
두 방식의 가장 큰 차이
순수 방식에서는
이벤트가 모든 것의 기준이다.
혼합 방식에서는
실시간 판단은 balance가 맡고,
이벤트는 변경 기록을 남긴다.
여기서 반드시 정해야 할 것이 있다.
balance와 이벤트가 다를 경우
무엇을 기준으로 삼을 것인가?
이걸 설계 단계에서 정하지 않으면
운영 중에 혼란이 생긴다.
나중에 이벤트로 검증하면 되지 않나?
가능하다.
예를 들어 배치 작업으로:
- 이벤트를 모두 더해서 잔액 계산
- balance 테이블과 비교
- 차이가 있으면 알림 또는 보정 처리
하지만 이것은 사후 점검이다.
실시간 차감의 안전성은
트랜잭션과 낙관적 락이 보장한다.
중복 요청 문제 (멱등성)
네트워크 오류로 같은 요청이 두 번 들어오면
이벤트가 두 번 저장될 수 있다.
이를 막기 위해 request_id를 둔다.
UNIQUE (request_id)
같은 요청은 한 번만 처리된다.
이 구조에서는 멱등성이 매우 중요하다.
정리
동시성과 일관성 문제는 피할 수 없다.
Event Sourcing을 쓰든,
상태 기반 시스템을 쓰든 반드시 해결해야 한다.
핵심은 다음과 같다.
-
동시에 들어오는 요청은
version 기반 낙관적 락으로 제어한다. -
트랜잭션은 여전히 RDB가 보장한다.
-
순수 Event Sourcing과
현실적인 혼합 구조는 다르다. -
어떤 데이터를 기준으로 삼을지
반드시 설계 단계에서 정해야 한다.