동시성 문제 — 한정판 선착순 판매 시 발생 가능한 문제
시스템에서 NFT를 발행하는데 이때 총 발행량을 200개로 고정되어 있었고, 특정 시각에 선착순으로 판매하는 드롭(Drop) 이벤트 형식이였다.
이때에 실제 토큰 발행은 블록체인 상에서 이루어졌지만 매 요청마다 온라인 트랜잭션을 보내고, 컴펌을 기다리면 너무 시간이 많이 걸리고, 가스비도 감당하기 어려울 것이라고 판단되어, 선착순에 대한 판정을 백엔드에서 먼저 처리 후 확정된 경우에 민팅 트랜잭션을 수행하도록 했다.
예시 코드)
@Service
@RequiredArgsConstructor
public class NftDropService {
private final NftDropRepository nftDropRepository;
@Transactional
public void mint(Long dropId, int quantity) {
// 1. 드롭 조회 (잔여 발행 수량 확인)
NftDrop drop = nftDropRepository.findById(dropId)
.orElseThrow(() -> new IllegalArgumentException("드롭이 없습니다."));
// 2. 잔여 수량 차감 비즈니스 로직 (drop.getRemainingSupply() - quantity)
drop.decrease(quantity);
// 3. 더티 체킹(Dirty Checking)으로 트랜잭션 종료 시 UPDATE 쿼리 발생
}
}
이벤트 전에 임시로 테스트를 해보는 과정에서 실제 방행량과 민팅이 확정된 데이터의 갯수가 다름을 알게 되었는데, 심지어 NftDrop 테이블의 remaining_supply는 -200이라는 기적의 음수를 기록하여 심각한 시스템 신뢰도 저하 문제가 발생하였다.
원인 — Race Condition과 Lost Update
이러한 문제가 발생한 것은 "조회 → 차감 → 저장"이 하나의 원자적 연산이 아니었기 때문이다. 여러 스레드가 거의 동시에 같은 데이터를 읽고, 각자 차감한 뒤 저장하면서, 나중에 저장된 값이 먼저 저장된 값을 덮어쓰는 Lost Update(갱신 손실)가 발생하였다. (결과적으로 먼저 수행된 차감 즉 업데이트된 값이 사라지는 문제)
예를 들어 잔여 수량이 100인 상태에서 스레드 A, B가 동시에 진입했다고 가정하면 다음과 같이 흘러간다.
| 시점 | 스레드 A | 스레드 B | DB의 잔여 수량 |
|---|---|---|---|
| t1 | 수량 조회 → 100 | 100 | |
| t2 | 수량 조회 → 100 | 100 | |
| t3 | 100 - 1 = 99 계산 | 100 - 1 = 99 계산 | 100 |
| t4 | UPDATE 수량 = 99 | UPDATE 수량 = 99 | 99 |
위와 같이 두 건이 민팅됐는데 잔여 수량은 1만 줄어드는 문제가 발생하게 된다. @Transactional 어플리케이션 단계에서 설정하였다 하더라도 기본 격리 수준(보통 READ COMMITTED)은 이런 동시 갱신을 막아주지 않기 때문에, 트랜잭션을 걸었다고 해서 안전을 보장할 수 없다.
(선착순 판매나 재고가 존재하는 대규모 커머스 시스템 등에서는 이러한 동시성 문제에 대한 깊은 고려가 반드시 필요하다.)
해결 — JPA의 락(Lock) 전략
동시성 문제 해결을 위해 DB가 제공하는 락(Lock)을 JPA에서 활용할 수 있다. 대표적으로 낙관적 락과 비관적 락이 있다.
① 낙관적 락 (Optimistic Lock) — @Version 활용
충돌이 거의 발생하지 않을 것이라고 낙관하는 방식이다. DB에 실제 락을 걸지 않고, 테이블에 version 컬럼을 두어 관리한다. 데이터 수정 시 내가 읽은 버전과 DB의 버전이 다르면 예외(OptimisticLockException)를 발생시킨다.
@Entity
public class NftDrop {
@Id @GeneratedValue
private Long id;
private int remainingSupply; // 잔여 발행 수량
@Version // 이 컬럼으로 충돌을 감지
private Long version;
public void decrease(int quantity) {
if (this.remainingSupply < quantity) {
throw new IllegalStateException("발행 수량이 모두 소진되었습니다.");
}
this.remainingSupply -= quantity;
}
}
충돌 시 예외가 나므로, 호출하는 쪽에서 재시도(Retry) 로직을 직접 구현해야 한다.
// 충돌 시 재시도 (의사 코드)
public void mintWithRetry(Long dropId, int quantity) {
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
try {
nftDropService.mint(dropId, quantity);
return; // 성공하면 종료
} catch (OptimisticLockException e) {
// 버전이 어긋남 → 잠시 후 다시 시도
}
}
throw new RuntimeException("민팅 실패");
}
- 장점: DB에 락을 걸지 않아 평상시 성능 저하가 적다.
- 단점: 충돌 시 개발자가 직접 재시도 로직을 구현해야 한다.
② 비관적 락 (Pessimistic Lock) — SELECT … FOR UPDATE
무조건 충돌이 발생할 것이라고 비관하는 방식이다. 데이터를 읽는 시점부터 DB에 락을 건다. 스레드 A가 데이터를 읽고 커밋할 때까지 스레드 B는 해당 데이터에 접근하지 못하고 대기한다.
public interface NftDropRepository extends JpaRepository<NftDrop, Long> {
// SELECT ... FOR UPDATE 쿼리가 나간다
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT d FROM NftDrop d WHERE d.id = :id")
Optional<NftDrop> findByIdForUpdate(@Param("id") Long id);
}
@Transactional
public void mint(Long dropId, int quantity) {
// 조회 시점에 락을 잡는다 → 다른 스레드는 대기
NftDrop drop = nftDropRepository.findByIdForUpdate(dropId)
.orElseThrow(() -> new IllegalArgumentException("드롭이 없습니다."));
drop.decrease(quantity);
// 트랜잭션 커밋 시 락 해제
}
- 장점: 데이터 정합성을 확실하게 보장한다. 재시도 로직이 필요 없다.
- 단점: 다른 스레드가 대기해야 하므로 성능이 크게 저하될 수 있고, 데드락 위험이 있다.
이 상황에서는 어떤 락을 선택할까?
선착순 NFT 드롭이나 대규모 커머스 시스템 등에서 선착순 판매와 같이 트래픽이 극도로 몰리고 충돌이 거의 확실한 상황에서는 비관적 락이 적절하다.
낙관적 락을 쓰면, 동시에 들어온 수많은 요청 중 1명만 성공하고 나머지 무수한 요청이 OptimisticLockException을 맞고 재시도한다. 이 재시도가 DB에 쿼리를 폭격하면서 DB CPU 사용률이 치솟고, 결국 서버가 뻗을 수 있다. (이는 충돌이 빈번한 구간에서는 낙관적 락의 "충돌이 드물다"는 전제 자체가 깨지기 때문이다.)
| 항목 | 낙관적 락 | 비관적 락 |
|---|---|---|
| 방식 | version 비교 | DB 락 (FOR UPDATE) |
| 충돌이 드문 경우 | 유리 | 불필요 |
| 충돌이 잦은 경우 | 재시도 폭증 | 유리 |
| 재시도 로직 | 필요 | 불필요 |
| 이 드롭 적합도 | 낮음 | 권장 |
부하 분산 전략
비관적 락도 결국 DB에 부하를 준다. 모든 요청이 DB의 단일 행 락을 두고 줄을 서기 때문이다. 특히 한정 수량 선착순 판매 등과 같은 이벤트를 시작 순간에 트래픽이 한 점으로 폭발하는 전형적인 핫스팟(hot key) 상황이라, DB 락만으로는 버티기 어렵다. 그래서 실무에서는 트래픽이 DB에 닿기 전에 앞단에서 거르는 전략을 함께 쓴다.
- Redis 원자적 카운터:
DECR같은 원자적 연산으로 잔여 수량을 인메모리에서 먼저 차감해, 수량 소진 후 요청은 DB까지 가지 않고 즉시 걸러낸다. 핫키 하나에 트래픽이 몰리므로 클러스터 구성이나 키 분산도 함께 고려한다. - Redis 분산 락:
Redisson등으로 애플리케이션 레벨에서 락을 잡아 DB 락 경합을 줄인다. - 메시지 큐(Queue): 구매 요청을 큐에 쌓고 컨슈머가 순차 처리해, DB로 들어가는 동시 요청 수 자체를 제어한다.
캐싱과 대규모 트래픽 처리 — Redis로 동시성 넘기
Redis 같은 인메모리 저장소 활용.
1)Redis를 공용 자물쇠(분산 락)로 쓰는 방식, 2) 선착순 판별을 Redis에서 끝내고 DB를 뒤로 빼는 방식이다.
방안 1 — 분산 락 (Distributed Lock, Redisson)
여러 대의 API 서버가 떠 있는 분산 환경에서는, 한 서버 안의 락(예: synchronized)만으로는 다른 서버의 스레드를 막을 수 없다. 그래서 모든 서버가 공유하는 Redis를 공용 자물쇠로 사용한다.
동작 방식은 스레드가 DB에 접근하기 전에 Redis에 "나 지금 이 드롭의 재고를 건드린다"라고 락을 획득하고, 작업이 끝나면 락을 반환한다. 락을 못 잡은 스레드는 대기한다.
@RequiredArgsConstructor
@Component
public class NftMintFacade {
private final RedissonClient redissonClient;
private final NftDropService nftDropService;
public void mint(Long dropId, int quantity) {
RLock lock = redissonClient.getLock("drop:lock:" + dropId);
try {
// 최대 10초 대기, 락 획득 후 2초간 점유
boolean acquired = lock.tryLock(10, 2, TimeUnit.SECONDS);
if (!acquired) {
throw new IllegalStateException("요청이 많아 잠시 후 다시 시도해주세요.");
}
// 락을 잡은 동안만 DB 재고 차감 수행
nftDropService.mint(dropId, quantity);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock(); // 반드시 해제
}
}
}
}
왜 Lettuce가 아니라 Redisson인가? Lettuce로 분산 락을 구현하면 보통 스핀 락(Spin Lock), 즉 락을 얻었는지 계속 찔러보는 방식이 되는데 이러한 폴링은 Redis에 끊임없이 부하를 준다. 반면 Redisson은 Pub/Sub 구조를 사용해, 락이 해제되면 대기하던 스레드에게 "이제 네 차례"라고 알려준다. 불필요한 폴링이 사라져 Redis 부하가 줄어든다.
방안 2 — Redis 원자적 연산 + 메시지 큐
대규모 트래픽을 다루는 빅테크의 전형적인 방식이다. 핵심 발상은 "선착순 판별 자체를 DB가 아닌 Redis에서 끝내고, DB는 트래픽의 정점에서 한 발 빼놓는 것"이다.
이 방식이 동작하는 결정적 이유는 Redis가 싱글 스레드라는 점에 있다. 명령어가 한 번에 하나씩 순차 실행되므로, DECR(1 감소) 같은 연산은 별도의 락 없이도 그 자체로 원자적이다. 레이스 컨디션이 끼어들 틈이 없다.
동작 프로세스
- 이벤트 시작 전, DB의 발행 수량(100개)을 미리 Redis에 캐싱한다. (
drop:100:stock = 100) - 수만 명이 민팅 요청을 보내면, 서버는 Redis에
DECR명령어만 실행한다. - Redis는 싱글 스레드이므로
DECR가 충돌 없이 99, 98, 97… 순차적으로 깎인다. DECR결과가 0 이상이면 '당첨', 0 미만이면 '품절'로 즉시 클라이언트에 응답한다.- 핵심: 당첨된 100명의 정보만 Kafka·RabbitMQ 같은 메시지 큐(MQ)에 이벤트로 던진다.
- DB는 큐에 쌓인 100건의 데이터를 비동기로 천천히 꺼내 안전하게 저장한다. DB는 트래픽 정점의 타격을 전혀 받지 않는다.
@RequiredArgsConstructor
@Service
public class NftMintService {
private final StringRedisTemplate redisTemplate;
private final MintEventProducer mintEventProducer; // Kafka 등으로 발행
public MintResult tryMint(Long dropId, Long userId) {
String key = "drop:" + dropId + ":stock";
// DECR은 그 자체로 원자적 → 락 불필요
Long remaining = redisTemplate.opsForValue().decrement(key);
if (remaining == null || remaining < 0) {
// 0 미만이면 품절. (음수가 무한히 커지는 것을 막으려면 보정 가능)
return MintResult.SOLD_OUT;
}
// 당첨자만 큐로 전달 → DB 저장/온체인 민팅은 비동기 처리
mintEventProducer.publish(new MintEvent(dropId, userId));
return MintResult.SUCCESS;
}
}
이렇게 하면 트래픽의 정점은 Redis가 받아내고, DB는 확정된 소수의 데이터만 여유 있게 처리 가능해진다.
위와 같은 방식은 강력하지만 구현 시 복잡도가 높다. Redis 카운터와 DB의 최종 상태가 어긋날 수 있어, 결제 실패·취소 시 재고를 되돌리는 보상 로직, Redis 장애 시 복구 전략, 큐 처리 실패 대응과 같은 사항들이 최종 일관성 관리를 위해 고려되어야 한다.
'JAVA' 카테고리의 다른 글
| MSA와 분산 트랜잭션 — Saga 패턴 (0) | 2026.06.26 |
|---|---|
| ThreadLocal과 OOM (Out of Memory) 발생 (0) | 2026.06.26 |
| @Transactional 동작 원리 (feat. 롤백이 되지 않는 경우) (0) | 2026.06.01 |
| Java 날짜 타입의 타임존 처리 (0) | 2026.05.31 |
| [ Java ] - QueryDSL이란 무엇인가? (+기본 세팅) (0) | 2024.05.19 |