[ 보고서 ] 동시성 이슈 - 비관적 락 vs 낙관적 락
🔍 이 문서를 작성한 목적
이 문서는 e-커머스 시스템에서 발생할 수 있는 대표적인 동시성 문제를
시나리오를 기반으로 문제 정의, 원인 분석, 해결 방안을 중심으로 정리했습니다.
특히 아래 세 가지 주요 기능에서의 동시성 이슈를 중점적으로 다룹니다.
- 📦 상품 재고 차감 및 복원
- 💰 사용자의 잔액 차감
- 🎟️ 선착순 쿠폰 발급 처리
* 주로 RDBMS의 DB Lock 방법을 적용한 내용입니다.
🔬 문제 식별
로직 | 문제 정의 |
📦 상품 재고 차감 | ✔ 사용자 A와 B가 동시에 동일 상품의 재고를 차감하려 할 때 재고 중복 차감, 초과 차감 등의 문제가 발생할 수 있습니다. |
💰잔액 충전 / 차감 | ✔ 동일 사용자가 여러 기기에서 동시 결제나 더블 클릭을 통해 여러 요청을 보낼 경우, 중복 충전이나 잔액 차감 오류가 발생할 수 있습니다. |
🎟️ 선착순 쿠폰 발급 |
✔ 다수의 사용자가 동시에 쿠폰을 요청하면 중복 발급 문제나 초과 발급 문제가 발생할 수 있습니다. |
✅ 결론
락이 필요한 근본적인 이유
여러 사용자가 동시에 동일한 자원에 접근하려 할 때,
경합 상태 (race condition)가 발생할 수 있습니다.
이로 인해 두 개 이상의 트랜잭션이 동일한 자원을 동시에 수정하려고 할 경우
중복 처리나 잘못된 데이터가 발생할 위험이 있습니다.
이러한 문제를 방지하기 위해
트랜잭션 간 자원 접근을 제어하는 락(Lock) 메커니즘이 필요합니다.
로직 | 선택한 전략 |
이유 |
📦 상품 재고 차감 | 비관적 락 | ✔ 인기 상품의 경우, 낙관적 락은 재시도가 너무 많아져 부하가 많이 발생할 수 있습니다. 이런 경우를 대비해 비관적 락이 상대적으로 더 안정적이라고 판단했습니다. |
🎟️ 선착순 쿠폰 발급 | 비관적 락 | ✔ 선착순 쿠폰은 동시 요청이 몰릴 가능성이 크기 때문에, 낙관적 락을 사용할 경우 상대적으로 부하가 증가하고 성능이 저하될 수 있어 비관적 락을 선택했습니다. |
💰잔액 충전 / 차감 | 낙관적 락 | ✔ 잔액 관련 동시성 이슈는 본인 계정에서 발생하며, 상대적으로 자주 일어나지 않는 편입니다. 낙관적 락을 통해서 낮은 오버헤드로 정합성 보장이 가능하다고 판단했습니다. |
⚙️ 동작원리, 장/단점
낙관적 락 (Optimistic Lock)
동작 원리:
데이터에 접근할 때 락을 걸지 않고, 트랜잭션이 완료될 때 충돌이 발생했는지 확인하는 방식입니다.
- 장점
- 충돌이 드물 때 성능이 더 좋고, 락을 미리 걸지 않아 자원을 많이 활용할 수 있습니다.
- 단점
- 충돌이 발생하면 롤백 후 재시도해야 하므로,
- 충돌이 잦은 경우 재시도 횟수가 많아져 시스템 부하가 증가하고 성능이 저하될 수 있습니다.
- 충돌이 발생하면 롤백 후 재시도해야 하므로,
비관적 락 (Pessimistic Lock)
동작 원리:
트랜잭션이 자원에 접근할 때 다른 트랜잭션이 접근하지 못하도록 자원을 잠그는 방식입니다.
- 장점
- 자원에 대한 접근을 차단하는 방식으로 충돌을 예방하기 때문에
- 데이터 불일치가 발생할 가능성을 낮출 수 있습니다.
- 자원에 대한 접근을 차단하는 방식으로 충돌을 예방하기 때문에
- 단점
- 자원을 잠그는 방식이기 때문에 대기 시간이 발생할 수 있습니다.
- 여러 트랜잭션이 자원을 동시에 요청하면, 대기 트랜잭션이 많아져 시스템 효율성이 저하될 수 있습니다.
📦 상품 재고 차감
✅ 비관적 락(Pessimistic Lock) 사용 이유
public interface ProductInventoryJpaRepository extends JpaRepository<ProductInventory, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT pi FROM ProductInventory pi WHERE pi.product.id = :productId")
Optional<ProductInventory> findByProductIdForUpdate(@Param("productId") Long productId);
}
인기 상품의 경우, 많은 사용자가 동시에 주문을 시도하면서 충돌이 빈번하게 발생할 수 있습니다.
낙관적 락은 충돌이 자주 발생할 경우
재시도가 반복되어 성능 저하와 시스템 부하가 증가할 수 있습니다.
따라서 충돌 가능성이 높은 경우에는,
자원을 사전에 잠그는 방식의 비관적 락을 사용하여 충돌 자체를 줄이는 것이
보다 안정적인 처리를 위해 적절하다고 판단했습니다.
시나리오 1️⃣
사용자 A와 B가 동시에 같은 상품의 마지막 1개를 주문하는 경우
[ 🔴 AS-IS ] 동시성 제어 전 Test Fail
단계 | 트랜잭션 A (User A) | 트랜잭션 B (User B) | 설명 |
1 | 재고 조회 (stock = 1) | 재고 조회 (stock = 1) | 두 사용자 모두 거의 동시에 재고 1 확인 |
2 | 조건 통과 (stock >= 1) | 조건 통과 (stock >= 1) | 둘 다 차감 조건을 만족함 |
3 | 재고 차감 → stock = 0 | 재고 차감 → stock = 0 | 두 트랜잭션 모두 차감 처리됨 (중복 차감) |
4 | 최종 재고: 0 | 최종 재고: 0 | |
문제점 | 예외 없음 | 예외 없음 | ⚠️ 실제로는 1개 재고로 2건의 주문 발생 (동시성 이슈) |
[ 🟢 TO-BE ] 동시성 제어 with 비관적 락
✅ 정상 케이스
시나리오 1 – 재고 1개에 2건의 동시 주문
- User A: 재고 차감 성공
- User B: 예외 발생 (락 획득 실패 또는 재고 부족)
- 최종 재고: 0
→ 중복 차감 없이 하나만 성공한 정상적인 동시성 제어 결과
시나리오 2️⃣
사용자 A가 10개 주문, 사용자 B가 5개 주문하는데 재고가 12개인 경우
[ 🔴 AS-IS ] 동시성 제어 전 Test Fail
단계 | 트랜잭션 A (User A - 10개 주문) | 트랜잭션 B (User B - 5개 주문) | 설명 |
1 | 재고 조회 (stock = 12) | 재고 조회 (stock = 12) | 두 사용자 모두 거의 동시에 재고 12 확인 |
2 | 조건 통과 (stock ≥ 10) | 조건 통과 (stock ≥ 5) | 각자 주문 수량 기준 조건을 통과함 |
3 | 재고 차감 → stock = 2 | 재고 차감 → stock = -3 | A가 먼저 차감하고 B도 이어서 시도 |
4 | 성공 | ⚠️ 성공 (문제 상황 발생) | ⚠️ B는 남은 재고보다 많은 수량을 차감했음 |
문제점 | - | 예외 없이 중복 차감됨 → 재고 음수 발생 가능 |
동시성 제어 필요 |
[ 🟢 TO-BE ] 동시성 제어 with 비관적 락
✅ 정상 케이스
시나리오 2 – 재고 12개에 10개 & 5개 동시 주문
- User A: 재고 차감 성공 (10개)
- User B: 예외 발생 (재고 부족)
- 최종 재고: 2
→ 재고 초과 요청에 대해 정확히 1건 예외 발생, 정상 처리
💰 사용자 잔액
✅ 낙관적락(Optimistic Lock) 사용 이유
//UserBalance
@Entity
@Table(name = "user_balance")
public class UserBalance {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
private BigDecimal amount;
private LocalDateTime updatedAt;
@Version
private Long version = 0L;
....
}
// BalanceService
@Retryable(
value = { OptimisticLockException.class, ObjectOptimisticLockingFailureException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2)
)
@Transactional
public BalanceResult chargeBalance(BalanceCommand balanceCommand) {
final Long userId = balanceCommand.userId();
UserBalance userBalance = balanceRepository.findOrCreateById(userId);
BigDecimal before = userBalance.getAmount();
userBalance.charge(balanceCommand.amount());
balanceHistoryRepository.save(BalanceHistory.create(
userId, before, userBalance.getAmount(), CHARGE
));
return new BalanceResult(userId, userBalance.getAmount());
}
- 재고 / 선착순 쿠폰 케이스에서는 수량이 남아 있음에도 불구하고락 충돌로 인해 예외가 발생하면
- 사용자는 "수량도 남아있는데 왜 실패하지?"라는 의문을 가지며 서비스에 대한 신뢰를 잃을 수 있음
- 반면 잔액 (포인트) 케이스는
- 동일한 사용자가 여러 요청을 동시에 보내는 경우는 흔치 않으며
- 충돌이 발생해도 개인 자원이라는 점에서 사용자가 체감할 수 있는 불쾌감이지 상대적으로 적음
- 오히려 동일한 요청이 중복 반영되지 않도록 1회만 성공하고
- 나머지는 충돌로 취소되는 것이 바람직한 처리 방식이라고 판단함
- 동일한 사용자가 여러 요청을 동시에 보내는 경우는 흔치 않으며
시나리오 1️⃣
동시에 잔액 충전 요청하는 경우
[ 🔴 AS-IS ] 동시성 제어 전 Test Fail
항목 | 설명 | 예상 결과 | 실제 결과 |
초기 잔액 | 현재 사용자의 잔액 : 10,000원 | 10,000원 | 10,000원 |
충전 요청 1 | 첫 번째 충전 요청 : 1,000원 | 11,000원 | 11,000원 |
충전 요청 2 | 두 번째 충전 요청 : 1,000원 | 실패 | 성공 처리로 보이나, 실제 반영된 잔액은 덮어쓰기 발생 |
최종 잔액 | 단 한 번만 충전되어야 함 | 11,000원 (정상) | ⚠️ 11,000원 (충전은 두 번 발생) |
[ 🟢 TO-BE ] 동시성 제어 with 낙관적 락
✅ 정상 케이스
시나리오 1 - 잔액 2건 동시 충전
- 유저가 가진 초기 잔액은 10,000원입니다.
- 동일 사용자가 동시에 1,000원씩 두 건의 층전 요청을 보냅니다.
- 낙관적 락이 적용되어 있으므로, 두 요청 중 하나는 충돌로 인해 실패하고 롤백됩니다.
- 최종 잔액: 11,000원
👉 충전 요청 중 하나만 성공했고, 나머지는 충돌로 인해 처리되지 않았습니다.
정상적으로 낙관적 락에 의해 중복 충전을 방지한 케이스입니다.
시나리오 2️⃣
동시에 잔액 차감 요청하는 경우
[ 🔴 AS-IS ] 동시성 제어 전 Test Fail
항목 | 설명 | 예상 결과 | 실제 결과 |
초기 잔액 | 현재 사용자의 잔액이 5,000원입니다. | 5,000원 | 5,000원 |
차감 요청 1 | 첫 번째 차감 요청: 3,000원 | 2,000원 | 2,000원 |
차감 요청 2 | 두 번째 차감 요청: 3,000원 | 실패 (충돌 발생) | 성공 처리로 보이나, 실제 반영된 잔액은 덮어쓰기 발생 |
최종 잔액 | 한 번만 차감되어야 함 | 2,000원 (정상) | ⚠️ 2,000원 (차감은 두 번 발생) |
[ 🟢 TO-BE ] 동시성 제어 with 낙관적 락
✅ 정상 케이스
시나리오 1 - 잔액 2건 동시 차감
- 유저가 가진 초기 잔액은 5,000원입니다.
- 동일 사용자가 동시에 3,000원씩 두 건의 차감 요청을 보냅니다.
- 낙관적 락이 적용되어 있으므로, 두 요청 중 하나는 충돌로 인해 실패하고 롤백됩니다.
- 최종 잔액: 2,000원
👉 차감 요청 중 하나만 성공했고, 나머지는 충돌로 인해 처리되지 않았습니다.
정상적으로 낙관적 락에 의해 중복 충전을 방지한 케이스입니다.
시나리오 3️⃣
잔액 순차 요청 정합성 테스트
[ 🔴 AS-IS ] 동시성 제어 전
초기 잔액: 500원
요청 동시 실행: 충전 1,000원 / 차감 1,000원
실행 순서 | 충전 성공 | 차감 성공 | 최종 잔액 | 설명 |
⚠️ 둘 다 실패 | ❌ | ❌ | 500원 | ❌ 잘못된 흐름: 처리 안 됨 |
⚠️ 둘 다 성공 + 잔액 1500 | ✅ | ✅ | 1500원 | ❌ 중복 처리됨: 차감 반영되지 않음 |
[ 🟢 TO-BE ] 테스트에서 허용하는 정합 결과
✅ 정상 케이스 1 (충전 먼저 → 차감 성공)
- 초기 잔액: 500원
- 충전 요청: +1000원 → 잔액: 1500원
- 차감 요청: -1000원 → 잔액: 500원
- 최종 잔액: 500원 → 정합성 OK
✅ 정상 케이스 2 (차감 먼저 → 실패, 충전만 성공)
- 초기 잔액: 500원
- 차감 요청: -1000원 → 💥 실패 (잔액 부족)
- 충전 요청: +1000원 → 잔액: 1500원
- 최종 잔액: 1500원 → 정합성 OK
시나리오 4️⃣
충전과 차감이 동시에 요청된 경우
[ 🔴 AS-IS ] 동시성 제어 전
초기 잔액: 500원
동시에 요청: 충전 +1000원, 차감 -1000원
실행 순서 | 충전 결과 | 차감 결과 | 최종 잔액 | 설명 |
둘 다 성공, 잔액 1500 | 성공 | 성공 | 1500원 | ⚠️ 차감이 반영되지 않음 (정합성 실패) |
[ 🟢 TO-BE ] 테스트에서 허용하는 정합 결과
✅ 정상 케이스 1
충전이 먼저 성공 → 차감도 성공
잔액 흐름
500 → +1000 → 1500 → -1000 → 500
결과
- 충전 성공
- 차감 성공
- 최종 잔액: 500원
✅ 정상 케이스 2
차감이 먼저 시도되었지만 실패 → 충전만 성공
잔액 흐름
500 → -1000 (실패) → +1000 → 1500
결과
- 충전 성공
- 차감 실패
- 최종 잔액: 1500원
🎟️ 선착순 쿠폰 발급 처리
✅ 비관적 락(Pessimistic Lock) 사용 이유
public interface CouponRepository {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Coupon> findByIdForUpdate(Long id);
}
public interface CouponJpaRepository extends JpaRepository<Coupon, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Coupon c WHERE c.id = :couponId")
Optional<Coupon> findByIdForUpdate(@Param("couponId") Long couponId);
}
낙관적 락 단점
- 충돌이 자주 발생하여 재시도를 반복해야 하므로 시스템 부하가 증가하고 성능 저하가 일어날 수 있습니다.
- 재시도가 많아지면 중복 처리나 오류가 발생할 위험이 높아지고, 사용자 경험이 나빠질 수 있습니다.
비관적 락 단점
- 첫 번째 사용자가 자원을 잠그면 다른 사용자는 대기해야 하므로 대기 시간이 길어질 수 있습니다.
그래도 비관적 락을 선택한 이유
- 선착순 쿠폰은 충돌이 빈번할 가능성이 높은 기능이므로
- 비관적 락은 자원을 미리 잠그고 다른 사용자가 대기하도록 하여 충돌을 방지할 수 있습니다.
- 상대적으로 성능 부하를 더 줄이고, 안정적인 처리를 보장하는 데는 비관적 락이 더 적합할 것이라고 판단했습니다.
시나리오 1️⃣
여러명이 동시에 하나의 선착순 쿠폰을 발급받는 경우
항목 | 설명 |
발급 가능한 총 쿠폰 수량 | 500개 |
이미 발급된 수량 | 493개 |
남은 발급 수량 | 7개 (발급 가능한 총 쿠폰 수량 - 이미 발급된 수량) |
동시 요청 수 | 10개 |
성공하는 요청 수 | 7개 |
실패하는 요청 수 | 3개 |
최종 발급 수량 | 500개 |
기대 예외 발생 수 | 3개 |
예상 경합 상황 | 1. 모든 요청이 동시에 시작되면, 선착순으로 발급 가능 2. 초과된 요청은 실패 (예외 발생) |
[ 🔴 AS-IS ] 동시성 제어 전 Test Fail
[ 🟢 TO-BE ] 동시성 제어 with 비관적 락
✅ 정상 케이스
시나리오 1 – 선착순 쿠폰 발급 시 동시 요청 처리
- 발급 가능한 총 쿠폰 수량: 500개
- 이미 발급된 수량: 493개
- 동시 요청 수: 10개
최종 결과:
- 성공하는 요청 수: 7개 (7명에게 정상적으로 쿠폰 발급)
- 실패하는 요청 수: 3개 (3명은 요청한 수량 초과로 실패, 예외 발생)
- 최종 발급 수량: 500개
💡 대안
DB 락 외에도 동시성 문제를 해결하기 위한 다양한 접근법이 존재합니다.
1. 분산락
설명
분산 락은 여러 컴퓨터가 동시에 같은 데이터를 업데이트하려고 할 때
발생할 수 있는 문제를 해결하기 위한 기술입니다.
분산 락을 구현하는 방법에는 여러 가지가 있습니다.
대표적으로 레디스를 사용하여 TTL(Time To Live)을 설정하고
일정 시간이 지나면 자동으로 락을 해제하는 방법이 있습니다.
장점:
- 일관성 유지: 분산 환경에서 데이터 일관성을 보장합니다.
단점:
- 성능 저하: 락 설정 및 해제 과정에서 시간이 소요되어 성능 저하를 초래할 수 있습니다.
2. 이벤트 큐 기반 처리
설명:
비동기 큐는 메시지 큐 시스템을 사용하여 요청을 순차적으로 처리하는 방식입니다.
RabbitMQ나 Kafka 같은 시스템을 활용하여 처리합니다.
장점:
- 부하 분산: 시스템의 부하를 분산시키고, 피크 타임의 병목 현상을 방지합니다.
- 성능 향상: 여러 작업을 동시에 처리할 수 있어 시스템 성능을 향상시킵니다.
단점:
- 지연: 실시간 처리가 필요한 시스템에서는 처리 지연이 발생할 수 있습니다.
- 복잡성: 메시지 순서 보장 및 실패 시 재시도 메커니즘 등을 추가해야 하므로 구현이 복잡할 수 있습니다.
🔗 해당 프로젝트 깃헙
GitHub - developerOlive/hhplus-e-commerce: [항해플러스] 이커머스 서비스
[항해플러스] 이커머스 서비스. Contribute to developerOlive/hhplus-e-commerce development by creating an account on GitHub.
github.com