동시성 문제의 여러가지 해결 전략
동시성 문제가 무엇이고 어떤 문제가 있는지, 어떻게 해결할 수 있는지 공부한 내용을 정리
해당 글은 node.js + MySQL을 예제로 작성했습니다.
1. 동시성 문제란 무엇인가
1-1. 서버는 요청을 "동시에" 처리한다
서버는 요청을 한 줄로 세워서 처리하지 않는다.

Node.js는 싱글 스레드라서 안전하다고 오해하기 쉽지만, 그렇지 않다. await를 만나는 순간 이벤트 루프는 다른 요청을 처리하러 떠난다. "DB에서 읽고(await) → 계산하고 → DB에 쓰는(await)" 코드는 두 await 사이에 다른 요청이 끼어들 수 있다. 게다가 운영 환경에서는 서버 인스턴스가 여러 대다. 어떤 언어, 어떤 런타임이든 공유 자원(DB)을 여러 실행 흐름이 동시에 만진다는 조건은 같다.
1-2. 임계 구역과 경쟁 상태
- 임계 구역(Critical Section): 공유 자원을 다루기 때문에 한 번에 하나의 실행 흐름만 들어가야 하는 코드 구간.
- 경쟁 상태(Race Condition): 둘 이상의 실행 흐름이 임계 구역에 동시에 들어가서, 실행 순서(타이밍)에 따라 결과가 달라지는 상태.
경쟁 상태의 무서운 점은 "결과가 틀린다"가 아니라 "결과가 타이밍에 달렸다" 는 것이다. 운이 좋으면 맞고, 트래픽이 몰리면 틀린다. 그래서 개발 환경에서는 재현되지 않다가 프로모션 날에 터진다.
1-3. 사고가 나는 코드의 두 가지 모양
경쟁 상태를 만드는 코드는 거의 항상 아래 두 모양 중 하나다. 공통점은 "읽는 순간"과 "쓰는 순간" 사이에 시간의 틈이 있다는 것.

②는 ①을 포함하는 더 넓은 개념이다. 핵심은 같다: 판단의 근거가 실행 시점에는 이미 낡은 정보일 수 있다.
2. 어떤 문제가 일어나는가
증상 1 — Lost Update (갱신 손실)
두 요청이 같은 값을 읽고, 각자 계산하고, 각자 저장하면 한쪽의 갱신이 소리 없이 사라진다.
// 좋아요 +1: 전형적인 read-modify-write
const post = await this.postRepository.findOneBy({ id: postId }); // 읽기
post.likeCount += 1; // 계산
await this.postRepository.save(post); // 쓰기
B와 C가 계산에 쓴 재료(0)는 읽는 순간엔 참이었지만 쓰는 순간엔 거짓이었다. 좋아요, 재고, 잔액, 포인트 — "읽어서 고쳐서 다시 쓰는" 모든 숫자에서 발생한다.
증상 2 — 중복 생성 (불변식 위반)
불변식(invariant) 이란 시스템이 어떤 순간에도 지켜야 하는 규칙이다. "주문 1건당 라이선스 1개", "아이디는 유일하다" 같은 것. check-then-act의 틈에 두 요청이 함께 들어오면 이 규칙이 깨진다.
// 주문번호당 라이선스 1개가 규칙인 발급 로직
const exists = await this.entitlementRepo.findOneBy({ orderNo }); // check
if (exists) throw new ConflictException('이미 발급된 주문입니다');
await this.entitlementRepo.save({ orderNo, userId }); // act
증상 1과의 차이: 1은 갱신이 덮여서 사라지고, 2는 있으면 안 되는 것이 생긴다. 증상이 다르므로 처방도 다르다.
동시성 문제 (원인: 경쟁 상태)
│
├─ ① Lost Update 갱신이 덮여서 사라짐 → 좋아요, 재고, 잔액
└─ ② 중복 생성 있으면 안 되는 행이 생김 → 중복 발급, 중복 가입, 쿠폰 중복 사용3. 해결 전략
도구는 다섯 개지만, 전략은 세 가지뿐이다.
전략 1. 틈을 없앤다 → 원자적 연산
전략 2. 규칙을 DB에 박는다 → 제약 (Constraint)
전략 3. 순서를 강제한다 → 락 (비관적 / 낙관적 / 분산)3-1. 원자적 연산 — 틈 자체를 제거한다
Lost Update의 원인은 "읽기와 쓰기 사이의 틈"이었다. 그렇다면 읽지 않으면 된다. 계산을 애플리케이션이 아니라 DB에게 시키는 것이다.

MySQL은 하나의 UPDATE 문을 실행하는 동안 해당 행을 내부적으로 잠근다. 요청 3개가 동시에 와도 행 단위로 줄을 세워서 정확히 +3이 된다. 조건까지 한 문장에 넣을 수 있다.
-- 재고가 있을 때만 차감. 갱신된 행이 0개면 "품절"
UPDATE stock SET quantity = quantity - 1
WHERE product_id = ? AND quantity > 0;const result = await this.dataSource.query(
'UPDATE stock SET quantity = quantity - 1 WHERE product_id = ? AND quantity > 0',
[productId],
);
if (result.affectedRows === 0) throw new ConflictException('품절되었습니다');항상 첫 번째로 검토해야 할 전략이다. 락도, 재시도도, 인프라 추가도 없이 문제 자체가 사라지는 경우가 생각보다 많다.
3-2. 제약(Constraint) — 불변식은 스키마에 박는다
"주문당 라이선스 1개" 같은 개수 불변식은 애플리케이션의 사전 조회로는 못 지킨다. 사전 조회 자체가 check-then-act니까. 지킬 수 있는 곳은 단 한 곳, 모든 요청이 결국 통과하는 관문인 DB 스키마다.

ALTER TABLE entitlement ADD CONSTRAINT uq_order UNIQUE (order_no);try {
await this.entitlementRepo.insert({ orderNo, userId });
} catch (e) {
if (e.code === 'ER_DUP_ENTRY') {
return this.entitlementRepo.findOneBy({ orderNo }); // 거절 대신 기존 것을 반환 → 멱등 API
}
throw e;
}동시 요청 만 개가 와도 뚫리지 않는다. 락은 구현 실수나 인프라 장애로 뚫릴 수 있지만, 제약은 DB가 살아있는 한 절대적이다. 불변식이 스키마 어디에도 강제되어 있지 않다면, 그 규칙은 없는 것과 같다. 애플리케이션의 사전 조회는 친절한 에러 메시지를 위한 UX 장치로만 쓴다.
3-3. 비관적 락 — "충돌이 잦을 것"이라 비관하고 미리 잠근다
읽는 시점부터 행을 잠가서, 임계 구역에 한 번에 하나만 들어가게 줄을 세운다. MySQL의 SELECT ... FOR UPDATE.

await this.dataSource.transaction(async (manager) => {
const stock = await manager.findOne(Stock, {
where: { productId },
lock: { mode: 'pessimistic_write' }, // SELECT ... FOR UPDATE
});
if (stock.quantity <= 0) throw new ConflictException('품절되었습니다');
stock.quantity -= 1;
await manager.save(stock);
}); // 잠금은 이 블록이 끝나야 풀리므로 반드시 이 안에서 쓴다- 강점: 확실하다. 여러 테이블을 갱신하는 복잡한 로직도 안전하다.
- 비용: 대기가 생겨 처리량이 떨어진다.
- 함정 — 데드락: 두 요청이 서로가 잡은 잠금을 기다리면 영원히 멈춘다.
요청A: [상품1 🔒]───상품2를 기다림────────┐
요청B: [상품2 🔒]───상품1을 기다림──── 서로 무한 대기 💀
예방: 잠금 획득 순서를 통일한다 (예: 항상 id 오름차순으로 잠근다)3-4. 낙관적 락 — "충돌이 드물 것"이라 낙관하고 검사만 한다
잠그지 않는다. 대신 데이터에 version 번호를 붙이고, 쓰는 순간에 "내가 읽은 뒤로 누가 고쳤는지"를 검사한다.

락이 아니라 충돌 감지 + 재시도 전략이다. TypeORM에서는 @VersionColumn 하나로 검사가 자동화되고, 재시도 루프만 직접 짠다.
@Entity()
export class Stock {
@PrimaryGeneratedColumn()
id: number;
@Column()
quantity: number;
@VersionColumn() // UPDATE마다 version을 검사하고 +1
version: number;
}- 강점: 잠그지 않으니 대기가 없다. 충돌이 드물면 가장 빠르다.
- 비용: 충돌이 잦으면 재시도가 반복되어 오히려 비관적 락보다 느려진다.
충돌 빈도에 따른 선택:
드묾 ◀────────────────────────────▶ 잦음
낙관적 락 (검사+재시도) 비관적 락 (잠금+대기)3-5. 분산 락 — 지켜야 할 구간이 DB 밖까지 이어질 때
지금까지의 도구는 모두 DB 안의 이야기다. 그런데 임계 구역에 DB 바깥 작업이 포함되면? 예: "중복 요청이 와도 외부 쇼핑몰 검증 API를 두 번 호출하고 싶지 않다." DB 락은 DB 밖을 지켜주지 못한다. 이때 서버 여러 대가 공유하는 외부 잠금 저장소(Redis)를 쓴다.

// ioredis
const ok = await this.redis.set(`lock:order:${orderNo}`, '1', 'EX', 10, 'NX');
if (ok !== 'OK') throw new ConflictException('이미 처리 중인 요청입니다');
try {
await this.verifyOrderWithMall(orderNo); // 비싼 외부 API — 한 번만 호출됨
await this.saveEntitlement(orderNo, userId);
} finally {
await this.redis.del(`lock:order:${orderNo}`);
}주의할 이론적 한계 — TTL의 딜레마

그래서 분산 락은 "거의 항상 막아주는" 1차 방어선이지 절대 보장이 아니다. 최종 방어선은 여전히 3-2의 유니크 제약이어야 한다.
Redis 락 = 문 앞의 안내원, 유니크 제약 = 금고 자물쇠.
안내원은 뚫려도 금고는 안 뚫린다. 그리고 락 키는 요청에 이미 들어있는 값(주문번호)으로 잡는다 — 발급 결과물(라이선스 키)은 아직 존재하지 않으니 키가 될 수 없다.
4. 어떤 전략을 언제 쓰나
동시성 문제를 만났다
│
├─ Q1. 단순 증감/조건부 갱신인가? (좋아요, 재고)
│ └─ YES → 원자적 UPDATE 한 문장으로 끝내라. 락 불필요.
│
├─ Q2. "1개만 존재해야 한다"류 불변식인가? (중복 발급/가입/쿠폰)
│ └─ YES → 유니크 제약. 필요하면 분산 락을 앞단에 추가(UX/비용 최적화).
│
├─ Q3. 복잡한 read-modify-write인가? (여러 테이블, 조건 분기)
│ ├─ 충돌 잦음 → 비관적 락 (FOR UPDATE, 데드락 순서 주의)
│ └─ 충돌 드묾 → 낙관적 락 (version + 재시도)
│
└─ Q4. 임계 구역에 DB 밖 작업이 포함되나? (외부 API, 파일)
└─ YES → 분산 락 + 최종 방어는 반드시 DB 제약/원자적 연산과 병행| 상황 | 1순위 전략 | 원리 한 줄 |
|---|---|---|
| 좋아요, 조회수, 재고 증감 | 원자적 UPDATE | 틈을 제거한다 |
| 중복 발급·가입·쿠폰 | 유니크 제약 | 불변식을 스키마에 박는다 |
| 복잡한 갱신 + 충돌 잦음 | 비관적 락 | 줄을 세운다 (대기) |
| 복잡한 갱신 + 충돌 드묾 | 낙관적 락 | 충돌을 감지하고 재시도한다 |
| 다중 서버 + DB 밖 작업 | 분산 락 | 전역으로 줄을 세운다 (단, 1차 방어선) |
5. 정리
- 락을 걸기 전에, 락이 필요 없는 구조를 먼저 찾아라. read-modify-write를 원자적 UPDATE 한 문장으로 바꿀 수 있다면 문제 자체가 사라진다.
- 불변식은 DB 제약으로 강제하라. 애플리케이션의 사전 검사는 UX 장치일 뿐, 최종 보장은 스키마의 몫이다.
- 동시성 버그의 본질은 "결과가 타이밍에 달렸다"는 것이다. 그래서 평소엔 조용하다가 트래픽이 몰리는 최악의 순간에 터진다.