Skip to content

Latest commit

 

History

24 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Concurrency Examples

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-alpine

Redis 컨테이너를 사용하지 않는다면, 로컬 또는 클라우드 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 인프라 필요, 외부 종속성

핵심 구현 코드

1. Stock Entity (낙관적 락)

@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;
    }
}

2. Repository (비관적/낙관적 락 쿼리)

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);
}

3. 서비스 계층 재시도 (Spring Retry)

@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);
    }
}

4. MySQL Named Lock

@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());
        }
    }
}

5. Redis 분산 락

@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();
        }
    }
}

6. 동시성 테스트

@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
}

학습 포인트

Spring Retry 적용 방법

Service에 직접 적용 (권장)

@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의 한계

synchronized 키워드는 @Transactional과 함께 사용할 수 없습니다.

이유: Spring AOP는 트랜잭션을 프록시로 처리하므로 커밋 타이밍 문제가 발생합니다.

TransactionProxy (Spring AOP가 생성, 싱글톤) {
    startTransaction();              // 1. 트랜잭션 시작

    realService.synchronizedMethod();
    // 2. synchronized 메서드 실행
    // 3. synchronized 끝 -> 🔓 락 해제

    commitTransaction();             // 4. 커밋 (synchronized 밖에서!)
}

문제의 타임라인:

  1. Thread A: synchronized 진입 (🔒)
  2. Thread A: DB 작업 수행
  3. Thread A: synchronized 종료 (🔓 락 해제)
  4. Thread B: synchronized 진입 (🔒) - 이 시점에 Thread A는 아직 커밋 전
  5. Thread B: DB 읽기 → 커밋되지 않은 옛날 데이터 읽음 (Race Condition!)
  6. Thread A: 커밋 완료

해결: synchronized를 사용할 때는 @Transactional을 제거해야 함 (StockService:45-51 참고)

분산 락의 두 가지 실패 시나리오

분산 락은 단일 JVM의 synchronized와 달리 네트워크 너머의 Redis에 의존하므로, "락이 의도한 시점에 해제되는가"를 항상 보장할 수 없습니다. 실패 양상은 크게 두 가지로 나뉘며, 데이터 일관성에 미치는 영향이 정반대입니다.

1. 작업 종료 전 락이 해제되는 경우 — 데이터 일관성 깨짐 ❌

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 사용 (권장)
    lock.tryLock(10, TimeUnit.SECONDS);   // leaseTime 미지정
    Redisson이 기본 30초 TTL을 걸고 10초마다 자동으로 PEXPIRE로 갱신. 클라이언트가 살아있는 한 락이 만료되지 않음.
  • ✅ leaseTime을 명시하려면 비즈니스 로직 최대 시간 × 충분한 마진으로 설정.
Watchdog이란?

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의 정체입니다.

2. 락 해제가 실패하는 경우 — 데이터 일관성 안전 ✅

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은 정말 필요한 경우에만 사용합니다.

참고 자료

이 프로젝트는 팀 세미나 발표, 블로그 포스팅, 기술 면접 준비 등에서 활용 가능한 실전 중심 동시성 예제입니다.

About

동시성 처리 전략(비관적 락, 낙관적 락, 분산락)

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages