Spring Boot 3 & JPA 기반의 동시성 제어 전략 실습 프로젝트입니다. 재고 감소 로직에서 발생할 수 있는 Race Condition 문제를 재현하고, 이를 해결하기 위한 다양한 동시성 제어 방법을 구현합니다.
- 동시성 문제(Race Condition) 시뮬레이션
- Synchronized 키워드를 활용한 기본적인 동시성 제어
- 비관적 락(Pessimistic Lock): JPA
@Lock(PESSIMISTIC_WRITE)활용 - 낙관적 락(Optimistic Lock): JPA
@Version+ 서비스 계층@Retryable로 낙관적 재시도 적용 - MySQL Named Lock: 네이티브
get_lock/release_lock으로 트랜잭션 분리 처리 - Redis 기반 분산 락: Lettuce 스핀 락과 Redisson
tryLock을 이용한 분산 환경 대응 - 100개의 동시 요청을 처리하는 통합 테스트 (
ExecutorService+CountDownLatch)
- Java 17
- Spring Boot 3.5.7
- Spring Data JPA
- Spring Data Redis
- Redisson
- MySQL 8
- JUnit 5
- Spring Retry
- Gradle
src/
├── main/
│ ├── java/com/example/
│ │ ├── ConcurrencyExamplesApplication.java # 진입점 (@EnableRetry 활성화)
│ │ ├── stock/ # 재고 동시성 제어 도메인
│ │ │ ├── domain/Stock.java # JPA Entity with @Version
│ │ │ ├── repository/StockRepository.java # 비관적/낙관적 락 쿼리
│ │ │ ├── service/StockService.java # 동시성 제어 비즈니스 로직 & @Retryable 적용
│ │ │ └── facade/
│ │ │ ├── OptimisticLockFacade.java # 낙관적 락 서비스 진입점
│ │ │ ├── NamedLockFacade.java # MySQL Named Lock 처리
│ │ │ ├── SimpleRedisLockFacade.java # RedisTemplate 기반 스핀 락
│ │ │ └── RedissonLockFacade.java # Redisson tryLock 기반 분산 락
│ │ └── order/ # 주문 도메인 (외부 TX + 낙관적 락 비교)
│ │ ├── domain/Order.java
│ │ ├── repository/OrderRepository.java
│ │ ├── service/OrderService.java
│ │ └── facade/OrderFacade.java # 격리 수준별 외부 TX 비교
│ └── resources/
│ └── application.yaml # MySQL 설정
├── test/
│ └── java/com/example/
│ ├── stock/
│ │ ├── service/StockServiceTest.java # 서비스 계층 동시성 테스트
│ │ └── facade/*FacadeTest.java # 각 락별 통합 테스트
│ └── order/
│ └── facade/OrderFacadeTest.java # 외부 TX/격리 수준 시나리오 검증
└── docs/
└── concurrency_slides.marp.md # 학습용 슬라이드
MySQL 서버가 실행 중이어야 하며, 아래 설정으로 데이터베이스를 생성합니다:
CREATE DATABASE stock_example;접속 정보 (application.yaml):
- Host:
localhost:3306 - Database:
stock_example - User:
root - Password:
1234
# 전체 테스트 실행
./gradlew test
# 특정 테스트 실행
./gradlew test --tests StockServiceTest.동시에_100개의_요청
# Spring Boot 애플리케이션 실행
./gradlew bootRun
# Redis 의존 기능 검증 (Lettuce/Redisson)
docker run --name redis-lock -p 6379:6379 -d redis:7-alpineRedis 컨테이너를 사용하지 않는다면, 로컬 또는 클라우드 Redis 서버를 6379 포트에 띄운 뒤 RedisTemplate/Redisson 설정을 맞춰주세요.
- Named Lock (
NamedLockFacade): MySQL 네이티브 락으로 재고 감소를 감싸고,REQUIRES_NEW트랜잭션(StockService.decreaseWithNewTransaction)으로 커밋 타이밍을 분리합니다. - Spring Data Redis 기반 분산 락 (
SimpleRedisLockFacade):RedisTemplate#setIfAbsent와 3초 TTL로 스핀락을 구현하고, 획득 실패 시 100ms 대기 후 재시도합니다. - Redisson 분산 락 (
RedissonLockFacade):RLock.tryLock(10, 1, TimeUnit.SECONDS)로 10초 이내 락을 기다리고, 실패 시 경고 로그를 남깁니다.
각 전략별로 docs/ 폴더와 테스트(src/test/java/com/example/stock/facade/*)를 참고해 재현 시나리오를 확인할 수 있습니다.
| 방법 | 구현 위치 | 장점 | 단점 |
|---|---|---|---|
| Synchronized | StockService:46 | 구현 간단 | 단일 JVM에서만 안전 |
| Pessimistic Lock | StockService:55 | 강력한 데이터 일관성 | 트랜잭션 지연, 데드락 위험 |
| Optimistic Lock + Spring Retry | StockService:63-74 | 높은 성능, 선언적 재시도 | 충돌 잦을 때 재시도 오버헤드 |
| MySQL Named Lock | NamedLockFacade:18-25 | DB 수준 전역 락, 레거시 환경 호환 | 락 남용 시 병목, 락 누수 주의 |
| Redis Spin Lock | SimpleRedisLockFacade:16-26 | 구현 단순, TTL로 락 해제 보장 | 스핀으로 인한 CPU 사용량 |
| Redisson TryLock | RedissonLockFacade:20-44 | 분산 환경 안정성, 자동 재진입 | Redis 인프라 필요, 외부 종속성 |
@Entity
public class Stock {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version // 낙관적 락을 위한 버전 필드
private Long version;
public void decrease(Long quantity) {
if(this.quantity - quantity < 0) {
throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
}
this.quantity -= quantity;
}
}public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
}@Service
public class StockService {
@Retryable(
retryFor = {ObjectOptimisticLockingFailureException.class},
maxAttempts = 50,
backoff = @Backoff(delay = 50)
)
@Transactional
public void decreaseWithOptimisticLock(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithOptimisticLock(id);
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}@Component
public class NamedLockFacade {
@Transactional
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decreaseWithNewTransaction(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
}@Component
public class SimpleRedisLockFacade {
public void decrease(Long id, Long quantity) throws InterruptedException {
while (!redisLockRepository.lock(id)) {
Thread.sleep(100);
}
try {
stockService.decrease(id, quantity);
} finally {
redisLockRepository.unlock(id);
}
}
}@Component
public class RedissonLockFacade {
public void decrease(Long id, Long quantity) {
RLock lock = redissonClient.getLock(id.toString());
try {
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
System.out.println("lock 획득 실패");
}
stockService.decrease(id, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
optimisticLockStockFacade.decrease(stockId, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(stockId).orElseThrow();
assertEquals(0, stock.getQuantity()); // 100 - 100 = 0
}@Service
public class StockService {
@Retryable(
retryFor = {ObjectOptimisticLockingFailureException.class},
maxAttempts = 50,
backoff = @Backoff(delay = 50)
)
@Transactional
public void decreaseWithOptimisticLock(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithOptimisticLock(id);
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}주의: Self-invocation 발생 시 @Retryable 작동 안 함
여러 Service를 조합하거나 접근 제어가 필요한 경우에만 사용
@Component
public class OptimisticLockFacade {
private final StockService stockService;
public void decrease(Long id, Long quantity) {
stockService.decreaseWithOptimisticLock(id, quantity);
}
}
@Service
public class StockService {
@Retryable(...)
@Transactional
void decreaseWithOptimisticLock(Long id, Long quantity) {
// package-private으로 외부 직접 접근 차단
}
}참고: 계층 분리가 self-invocation 문제를 해결하는 것은 아님
synchronized 키워드는 @Transactional과 함께 사용할 수 없습니다.
이유: Spring AOP는 트랜잭션을 프록시로 처리하므로 커밋 타이밍 문제가 발생합니다.
TransactionProxy (Spring AOP가 생성, 싱글톤) {
startTransaction(); // 1. 트랜잭션 시작
realService.synchronizedMethod();
// 2. synchronized 메서드 실행
// 3. synchronized 끝 -> 🔓 락 해제
commitTransaction(); // 4. 커밋 (synchronized 밖에서!)
}
문제의 타임라인:
- Thread A: synchronized 진입 (🔒)
- Thread A: DB 작업 수행
- Thread A: synchronized 종료 (🔓 락 해제)
- Thread B: synchronized 진입 (🔒) - 이 시점에 Thread A는 아직 커밋 전
- Thread B: DB 읽기 → 커밋되지 않은 옛날 데이터 읽음 (Race Condition!)
- Thread A: 커밋 완료
해결: synchronized를 사용할 때는 @Transactional을 제거해야 함 (StockService:45-51 참고)
분산 락은 단일 JVM의 synchronized와 달리 네트워크 너머의 Redis에 의존하므로,
"락이 의도한 시점에 해제되는가"를 항상 보장할 수 없습니다.
실패 양상은 크게 두 가지로 나뉘며, 데이터 일관성에 미치는 영향이 정반대입니다.
leaseTime(Redis TTL)이 비즈니스 로직 수행 시간보다 짧을 때 발생합니다.
Redis가 TTL 만료로 키를 자동 삭제하면, 락 소유자는 그 사실을 모른 채 작업을 계속하는데,
같은 시점에 다른 스레드가 락을 획득해 동일한 임계 구역에 진입할 수 있습니다.
타임라인 예시 (tryLock(10, 1, TimeUnit.SECONDS)로 leaseTime을 1초로 명시한 경우):
T=0.0 Thread A 락 획득 (TTL=1초)
T=0.5 Thread A decrease() 실행 중... (DB 트랜잭션 진행)
T=1.0 Redis ⚠️ TTL 만료 → 키 자동 삭제 (A는 모름)
T=1.0 Thread B 락 획득 성공
T=1.2 Thread A & B 동시에 임계 구역 실행 → race condition 발생
T=1.5 Thread A unlock() 호출 → 내 field 없음 → IllegalMonitorStateException
결과:
- 재고가 음수로 떨어지는 등 동시성 보호가 무력화됨
- unlock 시점에 예외 발생하지만, 데이터는 이미 깨진 뒤
- 평소엔 작업이 leaseTime 안에 끝나서 정상으로 보이다가, GC pause / DB slow query / 네트워크 지연 시 간헐적으로 터져 디버깅이 매우 어려움
Redisson의 안전장치 — Lua 스크립트로 unlock 시 소유자 검증:
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil; -- 내 field 없음 = 내 락 아님 → unlock 거부
end;→ 남의 락을 실수로 푸는 사고는 막지만, 이미 발생한 동시 진입은 되돌리지 못함.
해결책:
- ✅ leaseTime 생략 + Watchdog 사용 (권장)
Redisson이 기본 30초 TTL을 걸고 10초마다 자동으로 PEXPIRE로 갱신. 클라이언트가 살아있는 한 락이 만료되지 않음.
lock.tryLock(10, TimeUnit.SECONDS); // leaseTime 미지정
- ✅ leaseTime을 명시하려면 비즈니스 로직 최대 시간 × 충분한 마진으로 설정.
Watchdog = 클라이언트가 살아있는 동안에는 락이 만료되지 않도록 주기적으로 Redis 키의 TTL을 갱신해주는 백그라운드 타이머
이름은 시스템 분야에서 자주 쓰이는 "감시견" 메타포에서 왔습니다 — 무언가 정상 동작하는지 주기적으로 확인하고, 비정상이면 자동으로 조치를 취하는 메커니즘이라는 의미예요.
활성화 조건: tryLock/lock 호출 시 leaseTime을 생략하면 자동으로 동작합니다.
lock.tryLock(10, TimeUnit.SECONDS); // ✅ Watchdog 활성화
lock.tryLock(10, 1, TimeUnit.SECONDS); // ❌ Watchdog 비활성화 (leaseTime=1초 명시)동작 방식 (기본값 기준):
T=0 락 획득
Redis: SET lock:1, PEXPIRE 30000 ← TTL 30초
▼
클라이언트 JVM에 타이머 작업 등록
"10초마다 PEXPIRE 호출하라"
T=10 타이머 실행 → Redis: PEXPIRE lock:1 30000 ← TTL 다시 30초로 리셋
T=20 타이머 실행 → PEXPIRE 30000 ← 또 30초로 리셋
T=30 타이머 실행 → PEXPIRE 30000
...
(작업이 60초 걸려도 락 절대 만료 안 됨)
T=60 unlock() 호출
Redis: DEL + PUBLISH (대기자 깨움)
Client: 타이머 작업 cancel ← 좀비 PEXPIRE 방지
주요 파라미터:
| 항목 | 기본값 | 설명 |
|---|---|---|
TTL (lockWatchdogTimeout) |
30초 | Redis 키에 설정되는 만료 시간 |
| 갱신 주기 | 10초 (TTL ÷ 3) | PEXPIRE를 호출하는 간격 |
| 동작 위치 | 클라이언트 JVM 내부 | Netty HashedWheelTimer 사용 |
| 변경 방법 | Config.setLockWatchdogTimeout(ms) |
RedissonClient 생성 시 지정 |
갱신 주기가 TTL의 1/3인 이유: 네트워크 일시 장애나 GC pause로 갱신이 한두 번 실패해도 TTL이 만료되지 않도록 충분한 마진을 두기 위함. 30초 안에 3번의 기회를 주는 셈입니다.
클라이언트가 죽으면 어떻게 되나 — Watchdog의 진짜 우아한 점:
T=0 락 획득, watchdog 등록 (TTL=30초)
T=15 JVM 크래시 ⚡
→ 타이머도 함께 사라짐 → 더 이상 PEXPIRE 안 옴
T=30 Redis가 TTL 만료로 키 자동 삭제
→ 다른 클라이언트가 락 획득 가능
→ "살아있으면 무한정 보유, 죽으면 30초 후 자동 회수". 짧은 leaseTime의 위험(작업 중 만료)과 긴 leaseTime의 위험(클라이언트 사망 시 회수 지연)을 양쪽 모두 회피하는 설계입니다.
redis-cli로 동작 관찰:
> redis-cli MONITOR
1700000000.000 [...] "EVAL" "..." "1" "lock:1" "30000" "UUID:42" ← 락 획득
1700000010.123 [...] "EVAL" "..." "1" "lock:1" "30000" ← 10초 후 갱신
1700000020.234 [...] "EVAL" "..." "1" "lock:1" "30000" ← 또 갱신
1700000030.345 [...] "EVAL" "..." "1" "lock:1" "30000" ← 또10초 간격으로 같은 PEXPIRE가 반복 호출되는 것이 watchdog의 정체입니다.
unlock 호출이 네트워크 장애 등으로 실패하거나, JVM 크래시로 호출 자체가 안 되는 경우입니다. 이때는 TTL이 마지막 안전망으로 작동해 데이터는 보호됩니다.
unlock 실패 시 동작:
| 측면 | 동작 |
|---|---|
| Redis 측 | 키 그대로 유지, TTL 카운트다운만 진행 |
| JVM 측 | watchdog 타이머는 cancelExpirationRenewal()로 정리 (성공/실패 무관) |
| 다른 스레드 | TTL 만료까지 진입 불가 (가용성 일시 저하) |
| 복구 | TTL 만료 → Redis가 키 자동 삭제 → 자연 해제 |
JVM 크래시 시나리오:
T=0 Thread A 락 획득 (watchdog: TTL=30초, 10초마다 갱신)
T=15 JVM 크래시 ⚡
→ watchdog 타이머도 같이 사라짐 → PEXPIRE 멈춤
T=30 Redis가 TTL 만료로 키 자동 삭제
T=30+ 다른 클라이언트가 락 획득 가능
네트워크 장애 시나리오:
T=0 Thread A 락 획득 (TTL=30초)
T=20 Thread A unlock() 호출 → Redis 응답 timeout
→ Redisson은 RedisException 던지지만, watchdog 타이머는 cancel
T=20+ Redis 측 키는 남아있지만 TTL 갱신 중단
T=50 TTL 만료 → 키 자동 삭제 → 다른 클라이언트 진입 가능
핵심: 데이터 일관성은 안전, 단지 최대 leaseTime만큼 가용성 저하만 발생.
| 항목 | 작업 종료 전 락 해제 (1) | 락 해제 실패 (2) |
|---|---|---|
| 원인 | leaseTime이 너무 짧음 | 네트워크 장애, JVM 크래시 |
| 데이터 일관성 | ❌ 깨짐 (동시 진입) | ✅ 안전 |
| 가용성 | 정상 (다음 스레드 즉시 진입) | 일시 저하 (TTL만큼 대기) |
| 복구 | 없음 (이미 데이터 손상) | TTL이 자동 회수 |
| 예방책 | Watchdog 사용, leaseTime 충분히 | 별도 조치 불필요 |
leaseTime은 "내가 안전하게 점유할 시간"이 아니라 "최악의 경우에도 이 시간 안에는 풀린다"는 보장이다.
분산 락은 unlock 명시 호출에 의존하지 않고 TTL 기반 자동 회수로 안전성을 확보해야 합니다.
짧은 leaseTime은 가용성 측면에선 빨리 풀려서 좋아 보이지만, 데이터 일관성을 깨는 경로가
열려있어 분산 락의 본래 목적을 잃게 만듭니다.
이래서 Redisson의 Watchdog 기본 동작(leaseTime 생략 → 30초 TTL + 10초 갱신)이
사실상 정답에 가깝고, 명시적 leaseTime은 정말 필요한 경우에만 사용합니다.
이 프로젝트는 팀 세미나 발표, 블로그 포스팅, 기술 면접 준비 등에서 활용 가능한 실전 중심 동시성 예제입니다.