본문 바로가기
카테고리 없음

쿠폰 동시성 발급 문제 레디스(분산 락)으로 해결하기

by 풍댕이 2026. 2. 4.

동시에 여러 스레드가 쿠폰을 발급하는 기능에 대해 발생되는 동시성 문제가 있었습니다.

100개의 스레드가 동시에 쿠폰 발급을 요청하는 통합 테스트코드를 작성해보겠습니다.

테스트를 위한 사전 작업

private Long couponId;

@BeforeEach
void setUp(){
    //테스트용 쿠폰 100장 생성
    String uniqueName = "Coupon-" + UUID.randomUUID().toString();
    Coupon coupon = new Coupon(uniqueName, 100, 3000L);
    Coupon saveCoupon = couponRepository.save(coupon);

    coupon = saveCoupon;
    this.couponId = saveCoupon.getId();
}

@AfterEach
void tearDown() {
    // 자식 테이블부터 삭제 (순서 중요!)
    ordersRepository.deleteAll();       // 1. 주문 삭제
    memberCouponRepository.deleteAll(); // 2. 쿠폰 발급 내역 삭제

    // 부모 테이블 삭제
    couponRepository.deleteAll();       // 3. 쿠폰 삭제
    memberRepository.deleteAll();       // 4. 회원 삭제 (이제 삭제됨)
}

@BeforEach

  • 100개의 선착순 쿠폰 발급 테스트를 위한 쿠폰 생성

@AfterEach

  • 다음 테스트의 영향을 주지 않기 위해 데이터를 삭제해야 합니다.
@Test
@DisplayName("동시에_100장_쿠폰_발급")
public void 동시에_100장_쿠폰_발급() throws InterruptedException {
   //given
	int thread = 100;
	//왜 32개인가?, executors에 대해서, 역할
	ExecutorService executorService = Executors.newFixedThreadPool(32);
	// 모든 스레드가 작업을 모두 마칠때까지 대기하기 위한 래치
	CountDownLatch latch = new CountDownLatch(thread);

	//when
	for (int i = 0; i < thread; i++) {
		long memberId = i+1;	//유저 아이디

		executorService.submit(() -> {
			try {
				//각 스레드마다 새로운 유저 생성
				Member member = Member.builder()
										.email("member" + memberId + "@gmail.com")
										.password("password")
										.nickName("nickname" + memberId)
										.build();
				memberRepository.save(member);

				UserDetailsImpl userDetails = new UserDetailsImpl(member, "email");

				memberCouponService.registerMemberCoupon(userDetails, couponId);
			}catch (Exception e) {
				e.printStackTrace();
			}
			finally{
				latch.countDown();
			}
		});
	}
	latch.await();
    //then
	Coupon updatedCoupon = couponRepository.findById(couponId).orElseThrow();

	assertThat(updatedCoupon.getAmount()).isEqualTo(0);
}

쓰레드풀을 100개가 아닌 왜 32개인가요?

  1. 스레드를 무작정 많이 만든다고 빨리지지 않습니다.
    1. 스레드가 100개, 1000개로 늘어나면 CPU가 이 작업 저 작업 왔다 갔다 하기(컨텍스트 스위칭) 때문에 실제 일하는 시간보다 자리 옮기는 시간이 더 걸리기 때문입니다.
  2. 테스트 목적 : Race만 동작하면 됩니다.
    1. 2개 이상만 동시에 돌아가도 경쟁 조건은 발생하기 때문에 불필요하게 100개를 다 띄울 필요가 없기 때문입니다.

테스트 실패 - 예상과 다른 결과값

예상과 다르게 총 100개의 쿠폰 중 13개만 발급 된 것을 확인할 수 있습니다.

동시성 이슈의 원인

동시성 이슈가 발생하는 이유로 먼저 공유된 자원(쿠폰)이라는게 전제되어야 합니다.

멀티 스레딩 환경에서 해당 공유 자원(쿠폰)에 대해서 2가지 이상의 액션을 여러 개의 스레드에서 할 때 발생하게 됩니다.

쿠폰을 발급하는 로직

@Transactional
public void registerMemberCoupon(UserDetailsImpl userDetails, Long couponId) {

    Coupon coupon = couponRepository.findById(couponId).orElseThrow(
            () -> new BookException(NOT_FOUND_COUPON));

    isAlreadyHasCoupon(couponId, userDetails.getMember());

    coupon.canIssuedCouponCheck();

    MemberCoupon memberCoupon = MemberCoupon.createMemberCoupon(userDetails.getMember(), coupon);
    memberCouponRepository.save(memberCoupon);
}
  1. 현재 DB에 저장되어 있는 쿠폰을 불러온다.
  2. 이미 쿠폰을 발급받은 적이 있는지 유효성 검사
  3. 발급할 쿠폰의 재고 확인 후 쿠폰 발급
  4. 발급한 쿠폰을 DB에 업데이트합니다.

쿠폰 테이블 중 amount(재고)라는 공유 자원에 읽기(유효성 검사)와 쓰기 작업(재고 확인 후 쿠폰 발급)이 존재하는 상황입니다.

왜 Redis인가?

현재 프로젝트는 단일서버로 동작하기 때문에 MySQL, synchronized, 분산락을 사용한 동시성 제어도 가능하지만
이후 프로젝트를 진행하면서 다중 서버로 구현 예정이기 때문에 Redis를 이용해 동시성 제어 처리를 해보려고 합니다.

Lettuce에서의 동시성 제어 방법

setnx() 명령어를 사용하여 락을 구현한다.

Spin Lock(만약 다른 스레드가 Lock을 소유하고 있다면 그 lock이 반환될 때까지 계속 확인하며 기다리는) 방식으로 Retry 로직을 개발자가 직접 작성해야 합니다.

 

Redis에서 동시성 제어 방법

  1. Lettuce
    1. 개발자가 직접 락 획득을 위한 코드 작성 필요.
    2. 다음 락을 획득하기 위해 대기하는 스레드가 레디스에게 계속 락 획득을 위해 접근
  2. Redisson
    1. pub/sub 구조, 대기하는 스레드는 해당 채널을 구독(sub)하고, 레디스는 락 획득이 가능할 경우 다음 스레드에게 해제를 알려주면(발행, pub) 안내를 받은 스레드가 락 획득을 시도하는 방식입니다.

Redisson이 Lettuce보다 더욱 효과적인 이유

Lettuce는 락을 획득하기 위한 Retry 로직을 setnx()명령어를 사용하여 개발자가 직접 작성해야하기 때문입니다.

  • Lettuce는 Redis에게 락을 획득할때까지 계속 요구하는 과정이 불필요한 CPU 사용이 발생합니다.

 

DistributedLock -> 분산락 사용을 위한 어노테이션

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {

	/**
	 * 락 이름
	 */
	String key();

	/**
	 * 락의 시간 단위
	 */
	TimeUnit timeUnit() default TimeUnit.SECONDS;

	/**
	 * 락을 기다리는 시간 - default 5s(5초)
	 * 락 획득을 위해 waitTime만큼 대기한다.
	 */
	long timeWait() default 5L;

	/**
	 * 락 임대 시간 - default 3s(3초)
	 *. 락을 획득한 이후 설정한 leaseTime 이후 락이 해제된다.
	 */
	long leaseTime() default 3L;
}

key는 필수, 나머지 값들은 커스텀할 수 있도록 설정하였습니다.

@Target(ElementType.METHOD)

  • 메서드 위에만 붙일 수 있는 어노테이션임을 명시
  • 분산 락을 보통 특정 메서드가 실행될 때 락을 걸고, 끝나면 풀어야 하므로 METHOD로 설정하는 것이 정확하다.

@Retention(RetentionPolicy.RUNTIME)

  • 어노테이션이 언제까지 동작할 것인가?
  • AOP를 사용하는 분산 락에서는 반드시 RUNTIME이어야 합니다.
    • 컴파일 타임 : 살아있음(.class 파일에 남음)
    • 런타임(실행 중) : 살아있음(JVM이 읽을 수 있음)
    • 애플리케이션이 실행 중일때 리플렉션을 사용해서 어노테이션(@DistributedLock)을 확인하고 락을 걸게 됩니다.
    • 만약 이걸 CLASS나 SOURCE로 설정하면, 실행 중에 어노테이션이 증발해버려서 AOP가 락을 걸어야 할지 모르게 됩니다.

SpEL 파서(CustomSpringELParser)

key = "#couponId"라고 적었을 때, 실제 파라미터 couponId의 값(예 : 100)을 가져오기 위한 유틸리티 클래스입니다.

public class CustomSpringELParser {

	private CustomSpringELParser() {

	}

	public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
		//SpEL 표현식을 이해하고 분석하는 번역 엔진을 만듭니다.
		SpelExpressionParser parser = new SpelExpressionParser();
		//변수들의 이름과 값을 담아둘 상자(환경)를 만듭니다. 파서가 나중에 여기서 값을 찾아냅니다.
		StandardEvaluationContext context = new StandardEvaluationContext();

		/* 변수 등록(이름-값 매칭)
		파라미터의 이름과 실제 값을 짝지어서 컨텍스트(사전)에 등록합니다. */
		for (int i = 0; i < parameterNames.length; i++) {
			context.setVariable(parameterNames[i], args[i]);
		}
		/* 이제 파서에게 키(key)를 해석해서, 컨텍스트(context)에 있는 값을 가져와!라고 시킵니다.
		1. parser.parseExpression("#couponId") : 파서가 #을 보고 "변수구나"라고 인식합니다.
		2. .getValue(context, ...) : 컨텍스트(사전)을 뒤져서 couponId라는 이름의 값이 뭔지 찾습니다.
		3. 3번 단게에서 저장해둔 100L을 발견하고 리턴합니다. */
		return parser.parseExpression(key).getValue(context, Object.class);
	}
}

최종 요약

  1. 입력값 : "#couponId" (문자열)
  2. 처리 : SpEL 파서가 couponId 변수를 찾아서 값으로 바꿈
  3. 결과 : 100 (Long 타입 실제 값)

이 값(100)앞에 접두어 (LOCK:)를 붙여서 LOCK:100이라는 고유한 락 키가 완성되는 것입니다.

 

트랜잭션 분리기(AopForTransaction)

락을 풀기 전에 커밋을 먼저 수행하는 역할입니다. 

@Component
public class AopForTransaction {

	@Transactional(propagation = Propagation.REQUIRES_NEW)
	public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
		return joinPoint.proceed();
	}
}

AOP에서 바로 proceed()를 호출하는 것보다, 별도의 컴포넌트(@Transactional)를 통해 호출하면 트랜잭션 전파 속성(REQUIRES_NEW)을 다루거나 프록시 경계를 명확히 하는 데 유리합니다. 

@Transactional의 역할

프록시(대리인)이 가로채서 커밋을 해주는 방식입니다.

  • 별도 컴포넌트가 없는 경우 : 락 반납과 DB 커밋의 순서가 뒤바뀔 위험이 큼.(락은 풀렸는데 데이터는 그대로)
  • AopForTransaction을 쓰는 경우 : 락 안에서 트랜잭션을 시작하고, 락 안에서 트랜잭션을 끝낸다는 순서를 강제할 수 있음.

 

Propagation.REQUIRES_NEW를 사용하면 락 내부 로직이 부모 트랜잭션과 무관하게 독립적으로 커밋되므로, 락 해제 시점과 커밋 시점의 충돌을 완벽하게 방지할 수 있습니다.

AOP Aspect(DistributedLockAop)

이제 위 요소들을 조합하여 실제 로직을 수행하는 Aspect입니다.

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAOP {
    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    private final RedissonClient redissonClient;
	//보안 요원이 문을 닫기(unlock)전에, 파트너가 일을 확실히 끝냈는지(commit)보장하기 위해서입니다.
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(com.bookservice.common.aop.DistributedLock)")
    //ProceedingJoinPoint -> 지금 락을 걸려고 하는 실제 메서드입니다.
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
       MethodSignature signature = (MethodSignature) joinPoint.getSignature();
       Method method = signature.getMethod();
       DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

       String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(
             signature.getParameterNames(),
             joinPoint.getArgs(),
             distributedLock.key()
       );
       RLock lock = redissonClient.getLock(key);

       try {
          boolean available = lock.tryLock(distributedLock.timeWait(), distributedLock.leaseTime(), distributedLock.timeUnit());
          if(! available){
             throw new RuntimeException("락 획득에 실패 하였습니다.");
          }
          return aopForTransaction.proceed(joinPoint);
       } catch (InterruptedException e) {
          throw new InterruptedException();
       } finally {
          try {
             lock.unlock();
          } catch (IllegalMonitorStateException e){
             log.info("Redisson Lock Already UnLock serviceName = {}, key = {}",
                   method.getName(),
                   key
             );
          }
       }
    }
}