동시성 제어 구현(설명 위주)

김신영·2025년 7월 11일

1. 동시성 문제란?

동시성 문제의 본질

결국 같은 자원에 여러 스레드(메서드)가 접근해서 데이터를 수정하려고 해서 생기는 일

이 동시성 문제를 해결하기 위해:

  • 트랜잭션 격리 수준이 나뉘고
  • 선정된 수준에 따라 트랜잭션을 격리하기 위한 방법들 중
  • Lock이라는 개념을 채택한 것

핵심 해결 과제: 데이터가 수정된 것이 확정되기 전에 또 다른 스레드가 데이터를 수정하지 못하도록 해야한다


2. 구현한 Lock 방식들

2.1 Lettuce + Spin Lock + Lua Script (직접 구현)

  • 락 연장 기능 없음
  • 기본 타임아웃: 100ms
  • 락 획득 재시도 간격: 50ms
  • 최대 재시도 시간: 4초
  • 최대 시도 횟수: 80회

2.2 JPA 비관적 락

  • MySQL Exclusive 행락 사용

2.3 Redisson의 tryLock()

  • 락 유지 시간 100ms
  • lock 획득 최대 대기시간 4초
  • Watch-dog 없이 구현

3. 동시성 테스트 시나리오

3.1 테스트 환경

  • 재고: 1,000개
  • 동시 요청: 1,000개 스레드
  • 스레드 풀: CPU 코어 수 × 2 (I/O-bound 작업 특성 고려)

3.2 실패 테스트 (Lock 미적용)

@Test
void should_fail_when_1000_threads_decrease() {
    // Lock 없이 1000개 스레드가 동시에 재고 차감
    // 결과: 동시성 문제로 재고 불일치 발생
}

3.3 성공 테스트 (Lock 적용)

@Test
void should_success_when_1000_threads_decrease_with_lock() {
    // Lock 적용하여 1000개 스레드가 순차적으로 재고 차감
    // 결과: 재고가 정확히 0개
}

4. Lettuce Lock 구현 상세

4.1 논리 흐름

┌─────────────────┐
│   Lock 요청      │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ setIfAbsent()   │ ← Redis SETNX와 유사
└────────┬────────┘
         │
    ┌────┴────┐
    │  성공?   │
    └────┬────┘
         │
    Yes  │  No
    ┌────┴────┐
    │         │
    ▼         ▼
┌───────┐ ┌────────┐
│ 작업   │ │재시도    │
│ 수행   │ │(50ms)  │
└───┬───┘ └────────┘
    │
    ▼
 ┌───────┐
 │ 락해제  │
 └───────┘

4.2 락 획득 핵심 로직

Lock 획득

redisTemplate.opsForValue().setIfAbsent(lockKey, value, duration)
  • Redis SETNX 명령 활용
  • 원자적 연산으로 Lock 설정
  • 만약 키가 없으면 → 값을 저장하고 true 반환
  • 키가 이미 있으면 → 아무것도 하지 않고 false 반환

매개변수:

  • lockKey: 락 키 (예: "stock:lock:1")
  • value: 락의 식별자 (UUID-랜덤값)
  • duration: 락이 자동으로 해제될 시간 (100ms)

4.3 Spin Lock 방식 채택 이유

while (attempts < MAX_RETRY_COUNT && System.currentTimeMillis() < deadline) {
    if (tryLock()) return true;
    Thread.sleep(RETRY_INTERVAL_MS);
}

스핀락: 락을 얻을 때까지 CPU를 점유하며 계속 시도하는 방식

재고 차감에 있어서 락 충돌이 자주 일어날 것에 대비해 락을 최대한 얻기 위해선 spin lock 방식이 적합하다고 판단

4.4 락 해제 - Lua Script 사용

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

Lua Script 사용 이유:

  • 소유자 확인과 삭제를 원자적으로 처리
  • 다른 스레드가 획득한 락을 실수로 해제하는 것을 방지

사용 흐름:
1. Java 코드에서 unlock() 호출
2. Lua 스크립트를 포함한 Redis 명령 생성
3. RedisTemplate이 Redis 서버에 Lua 코드 실행 요청
4. Redis 서버가 스크립트를 원자적으로 처리


4. 시스템 최적화

4.1 스레드 풀 크기 설정

ExecutorService pool = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors() * 2
);

이유:

  • 재고 차감은 I/O-bound 작업 (DB/Redis/네트워크)
  • 스레드가 자주 대기 상태에 빠짐
  • CPU-bound: 코어 수 + 1
  • I/O-bound: 코어 수 × 2~4

4.2 락 타이밍 파라미터 계산

DEFAULT_LOCK_TIMEOUT = 100ms

DB_Processing_Time (10ms) + 
Network_Latency (3ms) + 
Buffer_Time (87ms) = 100ms

MAX_RETRY_DURATION = 4초

Active_Threads (24) × 
Avg_Processing_Time (15ms) × 
Safety_Factor (2.0) = 1440ms ≈ 4초

왜 Active Threads를 곱하나?
MAX_RETRY_DURATION = "락을 못 잡고 최대 기다리는 시간"

이걸 계산할 때 스레드 개수를 곱하는 이유는, 경합 상황에서 내가 언제 락을 잡을 차례가 올지를 보수적으로 예측하기 위해서

RETRY_INTERVAL_MS = 50ms

DEFAULT_LOCK_TIMEOUT / 2 = 100ms / 2 = 50ms

4.3 재시도 제한 추가

  • 최대 재시도 횟수: 80회 (50ms × 80 = 4초)
  • 시간과 횟수 둘 다 체크하여 무한 대기 방지

5. 구현 특징 요약(lettuce 기준)

Redis Lock 구현 시 고려사항

  1. Lock 획득 실패 시: Spin Lock으로 재시도 (최대 4초, 80회)
  2. Redis 사용 이유:
    • 분산 환경 지원
    • 빠른 성능
    • TTL 자동 만료
  3. Lock Key 설계: stock:lock:{stockId} - 재고별로 독립적인 락

아키텍처 구조

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│StockService │ --> │ LockService  │ --> │LockRedis    │
│             │     │              │     │Repository   │
└─────────────┘     └──────────────┘     └─────────────┘

트랜잭션 분리 전략

// Lock 외부에서 트랜잭션 시작 X
public void decreaseWithLock(Long stockId, int quantity) {
    lockService.executeWithLock(key, () -> {
        // Lock 내부에서 새로운 트랜잭션 시작
        stockTransactionService.decreaseInNewTransaction(stockId, quantity);
        return null;
    });
}

5. Lettuce vs Redisson 성능 비교

5.1 차이점 분석

Lettuce (Spin Lock)

┌─────────┐  락 요청   ┌─────────┐
│Thread 1 │ ────────> │  Redis  │
└─────────┘           └─────────┘
     │                     │
     │   실패 시 Sleep     │
     │<─────────────────── 
                          
        재시도 (50ms 후)  
     │────────────────────>

특징:

  • Busy Waiting 방식
  • 주기적 재시도 (50ms 간격)
  • CPU 리소스 지속적 사용
  • 락 해제 시점을 알 수 없음

Redisson (Pub/Sub)

┌─────────┐  락 요청   ┌─────────┐
│Thread 1 │ ────────> │  Redis  │
└─────────┘           └─────────┘
     │                     │
     │   구독 & 대기       │
     │<─────────────────── 
                          
         해제 알림      
     │<═══════════════════ 
                          
        즉시 재시도       
     │────────────────────>

특징:

  • Event-driven 방식
  • 락 해제 시 즉시 알림
  • CPU 효율적 사용
  • 더 빠른 락 획득

명확히 하자면:

Redisson의 RLock은 이렇게 동작:

락을 점유 중이면 Pub/Sub 채널에 기다리는 클라이언트를 등록

락 해제 이벤트가 발생하면 알림(메시지) 브로드캐스트

클라이언트가 이 알림을 받고 즉시 락 시도

실패하면 Polling으로 재시도

그래서 Redisson이 Polling만 쓰는 Lettuce, Jedis 기반 Lock보다 락 획득 성능이 좋고 네트워크 효율적.


5.3 Redisson의 추가 기능

1. 비동기 락 처리

RFuture<Boolean> future = lock.tryLockAsync(waitTime, leaseTime, unit);

2. 락 획득 실패 시 정교한 처리

  • 내부 재시도 메커니즘
  • 백오프 전략
  • 타임아웃 관리

3. Watch-dog 메커니즘 (lock() 메서드 사용 시)

// leaseTime을 지정하지 않으면 Watch-dog 활성화
lock.lock(); // 기본 30초마다 자동 연장

4. 멀티 스레드 환경 최적화

  • 별도 스레드에서 락 관리
  • 효율적인 이벤트 처리

6. Redis Lock vs MySQL Lock

6.1 MySQL 비관적 락

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Optional<Stock> findByIdWithPessimisticLock(@Param("id") Long id);

장점:

  • 별도 인프라 불필요
  • 트랜잭션과 자연스럽게 통합
  • 데이터 정합성 보장 강력

단점:

  • DB 부하 증가
  • 단일 DB 환경에서만 동작
  • 분산 환경 확장성 제한

6.2 Redis Lock

장점:

  • 분산 환경 지원
  • 높은 성능 (메모리 기반)
  • TTL 자동 만료
  • MSA 전환 용이

단점:

  • 별도 Redis 인프라 필요
  • 네트워크 장애 시 Lock 관리 복잡

7. 프로젝트 아키텍처와 Redis Lock 선택 이유

7.1 현재 아키텍처

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│   Domain A  │────>│ RestTemplate │────>│  Domain B   │
└─────────────┘     └──────────────┘     └─────────────┘
       │                                         │
       └──────────── Event System ───────────────┘

7.2 Redis Lock 선택 이유

  1. MSA 전환 고려

    • 도메인 간 완전 분리
    • RestTemplate & Event 통신
    • 향후 서비스 분리 시 Lock 메커니즘 유지
  2. 확장성

    • 분산 환경에서도 동일하게 동작
    • 다중 인스턴스 배포 지원
  3. 성능

    • 메모리 기반 빠른 응답
    • DB 부하 분산

8. 최종 구현: Redisson 선택 이유

8.1 기술적 우위

  1. Pub/Sub 기반 효율성

    • CPU 자원 절약
    • 더 빠른 락 획득
  2. 안정성

    • 정교한 예외 처리
    • 락 획득 실패 시 견고한 후속 처리
  3. 확장성

    • Watch-dog 지원 (필요 시)
    • 다양한 Lock 타입 지원

8.2 운영 관점

  1. 모니터링 용이

    • 상세한 메트릭 제공
    • 디버깅 정보 풍부
  2. 유지보수성

    • 검증된 라이브러리
    • 활발한 커뮤니티 지원

9. 구현 시 고려사항 정리

9.1 Lock 획득 실패 시 처리

  • Lettuce: 단순 재시도 (Spin Lock)
  • Redisson: Event 기반 대기 + 정교한 재시도

9.2 Redis 사용 이유

  • 분산 환경 지원
  • 높은 성능
  • MSA 전환 용이성

9.3 Lock Key 설계

  • stock:lock:{stockId} 형식
  • 재고별 독립적 Lock으로 동시성 향상

10. 결론

프로젝트의 MSA 전환 가능성과 분산 환경 확장성을 고려하여 Redis Lock을 선택했으며, 그 중에서도 Redisson은:

  1. Pub/Sub 기반으로 효율적인 리소스 사용
  2. 정교한 예외 처리로 높은 안정성
  3. Watch-dog 등 추가 기능으로 확장 가능성
  4. 비동기 처리 지원으로 성능 최적화

이러한 이유로 최종적으로 Redisson을 채택하여 동시성 문제를 해결했습니다.


참고:

백오프 전략

백오프 전략이란 재시도 간격을 점점 늘려가는 방법
락을 점유 중인 프로세스에 기회를 주어 락을 해제하게 할 시간을 벌어줌
짧은 간격으로 무한 시도를 하면 redis에 과부하가 걸림
redis나 네트워크에 부하를 줄이면 장애 회복에 도움이 되기 때문에

첫 번째 실패 후: 100ms 대기
두 번째 실패 후: 150ms 대기
세 번째 실패 후: 225ms 대기
...
점점 간격이 커짐 (최대 1,000ms까지만)
이걸 Exponential Backoff (지수 백오프) 라고 부릅니다.

0개의 댓글