Engineering Note

[SW Engineering] @Transactional과 Redis 분산락, 왜 같이 쓰면 안 될까? (feat. Facade 패턴) 본문

SW Engineering/선착순 이벤트

[SW Engineering] @Transactional과 Redis 분산락, 왜 같이 쓰면 안 될까? (feat. Facade 패턴)

Software Engineer Kim 2025. 12. 27. 16:17

이벤트 기반 선착순 주문 기능을 개발하던 중 발생한 동시성 문제를 Redis 분산락으로 해결하는 과정에서 생긴 트랜잰션 적용 문제 Facade 패턴을 통해 해결한 과정을 정리한 내용입니다.

 

선착순 이벤트 기능을 개발하면서 동시성 문제가 발생하여 Redis를 도입하여 문제를 해결 하는 중 뜻하지 않은 문제가 발생했습니다. 우선 문제 해결 과정 전에 DB 레벨에서 락을 걸어 동시성 문제를 해결하지 않고 Redis를 도입한 이유는 다음과 같습니다.

 

Redis 도입 이유

1. 인메모리 기반 빠른 응답속도

비관적 락으로 DB 레벨 자체에서 락으로 동시성 문제를 해결할 수도 있지만 비관적락은 데이터 정합성을 높여주는 장점이 있지만, 트랜잭션 쓰레드에 대해 직렬실행을 보장하기 때문에 성능저하라는 단점으로 이러진 수 있습니다. 한편 Redis는 인메모리기반의 저장소로, 디스크 기반의 RDBMS보다 처리속도가 빠르므로 선착순 이벤트 기능 자체를 빠르게 처리하기 위해 선택했습니다.

 

2. 커넥션 풀 관리를 통해 DB부하 감소

또, DB 서버 앞에 Redis를 두어, 비즈니스로직 단 애플리케이션 레벨에서 선제적으로 락을 관리할 수 있습니다. 애플리케이션 레벨에서 락을 관리하면 DB의 커넥션 풀 자원을 효율적으로 관리할 수 있습니다. DB는 시스템에서 중요한 자원으로 여러 API의 요청에 대해 필요한 데이터를 저장하고 조회해야 하는 중요한 자원입니다. 선착순 이벤트의 경우 짧은 시간에 많은 트래픽이 몰릴 수 있고, 이렇게 되면 선착순 이벤트 기능으로 인해 다른 기능에 대한 응답도 처리해야 하는 DB에 많은 부하가 생겨 시스템에 전체 시스템의 장애로 이어질 수 있습니다.

 

3.분산환경을 고려한 아키텍처 설계

레디스 분산락을 통해 동시성 문제를 해결하면서 대용량 트래픽 환경에서 고가용성과 확장성을 고려한 아키텍처를 설계하였습니다.

 

 트래픽이 낮은 단일 서버, 단일 DB 인스턴스라면 DB레벨에서 락을 관리하는 비관적락만으로 동시성 문제를 해결하면서 트래픽을 감당할 수 있지만, 대용량 트래픽을 처리하기 위해 고가용성과 확장성을 고려한 분산환경에서는 DB에서 락을 관리하는 비관적락만으로는 여러 노드의 트래픽 처리와 여러 DB의 상태를 동일하게 유지하는건 어렵기 때문에 중앙에서 락을 관리할 수 있도록 하기 위해 Redis를 도입하여 동시성 문제와 대용량 트래픽 처리를 위해 클라우드 환경에서 확장성을 고려한 아키텍처를 설계하여 문제를 해결하였습니다.

먼저 서버 확장성 측면에서 Redis 분산락의 장점은 DB의 부하를 줄여서 트래픽이 몰리는 서비스와 관계 없는 다른 서비스의 트래픽을 안정적으로 처리할 수 있습니다. 예를 들어 특정 서비스의 트래픽 증가로 인해 서버를 Scale Out 해야 하는 경우, 결국 DB라는 단일 지점으로 트래픽이 몰리기 때문에 서버의 Scale Out의 효과는 줄어듭니다. API요청을 처리할 WAS는 증가했지만 최종적으로 DB의 병목이 발생해 전체적인 처리량이 줄어들기 때문입니다. 예를 들어, 선착순 이벤트 기능에 대비하기 위해 WAS 서버를 Scale Out하고 동시성 제어는 DB에서 비관적락으로 해결하는 경우, WAS 서버의 처리량은 Scale Out으로 해결할 수 있지만 결국 최종 DB로 트래픽이 몰리게 되고 동시성 제어를 위해 DB자원의 병목이 되어 전체적인 서비스의 처리량이 줄어들게 됩니다. 결국 DB의 병목으로 인해 전체적인 서비스 처리량이 함께 증가하지 못해 메인페이지 요청 같은 서비스에 대한 지연시간이 길어 질 수 있습니다.

하지만 분산환경에서 WAS와 DB 사이에 Redis를 둔다면, 선착순 기능을 위한 요청은 Redis를 통해 락을 관리하므로 DB는 데이터 처리 작업에만 집중할 수 있기 때문에 선착순 이벤트 기능과 상관이 없는 메인페이지 조회나, 마이페이지 조회 같은 기능을 안정적으로 처리할 수 있습니다.

 

정리하면 Redis를 도입하여 분산락으로 DB 서버 앞단에서 사용자 요청에 대해 락을 선제적으로 관리하여 재고 데이터의 유실을 막을 수 있고 데이터 정합성을 유지할 수 있습니다. 그리고 클라우드 환경에서 대용량 트래픽 처리에 대비하여 WAS 서버와 DB 서버를 느슨하게 결합하여 각 서버를 개별적으로 확장할 수 있고, DISK기반이 아닌 인메모리 기반으로 빠른 응답속도를 보장할 수 있습니다.

 

=> 선착순 이벤트 특성상 짧은 시간에 대량의 트래픽이 집중되며, 이 과정에서 동시성 제어 방식이 시스템 전체 안정성에 직접적인 영향을 준다고 판단했습니다. 

 

이유는 비관적 락은 트랜잭션을 직렬화함으로써 DB단일 병목지점으로 만들고, 특정 기능의 트래픽 증가가 다른 API의 응답 지연 및 장애로 전파될 위헙이 있다고 판단해 Redis를 이용해 분산락 형태로 동시성 문제를 해결하기로 하였습니다.

 

 

 

 

 

우선 Redis를 도입하기 전 발생한 상품 주문 과정에서 발생한 동시성 문제를 알아 보겠습니다.

 

상품 주문 트랜잭션

  • 상품 주문은 재고조회, 재고 수량 업데이트를 하나의 트랜잭션으로 관리
- 재고 조회(read)
- 재고 = 재고 - 주문수
- 재고 업데이트(write)

 

 

하지만 이러한 상품 주문 트랜잭션이 순차적으로 실행된다면 문제가 없지만, 이벤트 기간 중에는 여러 사용자가 동시에 같은 상품을 주문하기 위해 주문을 요청하기 때문에 적절히 제어를 해주지 않으면 데이터가 유실되는 문제가 생길 수 있습니다.


Lost Update 문제

  • 여러 요청이 동시에 오면 하나의 자원에 대해 Race Condition으로 인해 데이터가 유실되는 Lost Update 문제

 

동시성 문제를 해결하는 방법

  • 비관적 락 : 비관적 락은 데이터에 접근하기 전에 락을 걸어 다른 트랜잭션이 해당 데이터에 접근하지 못하도록 합니다
  • 낙관적 락

 

 

 

비관적 락으로 동시성 문제 해결

Lost Update문제를 해결하기 위해서 하나의 자원을 다른 트랜잭션이 점유하고 있다면 다른 트랜잭션을 접근하지 못하도록 데이터 조회시 락을 걸어서 해결

 

 /**
     * 비관적 락을 사용한 재고 차감
     */
    @Transactional
    public void decreaseStockWithPessimisticLock(Long eventId, int quantity) {
        Event event = eventRepository.findByIdForUpdate(eventId)
            .orElseThrow(() -> new IllegalArgumentException("이벤트를 찾을 수 없습니다."));
        
        event.decreaseStock(quantity);
        
        log.debug("이벤트 재고 차감 완료. eventId={}, 남은재고={}", eventId, event.getRemainingStock());
    }

 

 

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM Event e WHERE e.id = :eventId")
Optional<Event> findByIdForUpdate(@Param("eventId") Long eventId);

 

=> 상품 주문을 처리하기 위해 하나의 트랜잭션이 재고를 조회할 때 락을 걸어 다른 트랜잭션과의 경합문제를 없앴습니다.

 

 

비관적 락과 비교를 위해 Redis 도입, 동시성 문제 다시 발생

 

 

 

 

 

이러한 이유로 Redis를 도입하고 동시성 제어 테스트를 하면서 문제가 발생했습니다.

Redis 분산락 적용 후 문제가 발생한 코드

/**
 * Redis 분산 락을 사용한 재고 차감 (1차 시도 코드)
 */
public void decreaseStockWithRedisLock(Long eventId, int quantity) {
    String lockKey = "lock:event:" + eventId;
    RLock lock = redissonClient.getLock(lockKey);
    
    try {
        // 락 획득 시도 (최대 5초 대기, 락 점유 시간 3초)
        boolean available = lock.tryLock(5, 3, TimeUnit.SECONDS);
        
        if (!available) {
            log.warn("락 획득 실패. eventId={}", eventId);
            throw new IllegalStateException("락을 획득할 수 없습니다.");
        }
        
        // 같은 클래스 내부 메서드 호출 (문제 발생 지점!)
        decreaseStockInternal(eventId, quantity);
        
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new IllegalStateException("락 획득 중 인터럽트 발생", e);
    } finally {
        // 락 해제
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

/**
 * 실제 재고 차감 로직 (내부 호출용)
 */
@Transactional
public void decreaseStockInternal(Long eventId, int quantity) {
    Event event = eventRepository.findById(eventId)
        .orElseThrow(() -> new IllegalArgumentException("이벤트를 찾을 수 없습니다."));
    
    event.decreaseStock(quantity);
    
    log.debug("Redis 락 - 재고 차감 완료. eventId={}, 남은재고={}", eventId, event.getRemainingStock());
}

 

레디스 적용 테스트 코드

@Test
@DisplayName("Redis 분산 락 적용: 100명이 동시에 주문해도 재고가 정확하게 차감된다")
void redis_lock_solution() throws InterruptedException {
    int threadCount = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    AtomicInteger successCount = new AtomicInteger(0);
    AtomicInteger failCount = new AtomicInteger(0);

    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                eventService.decreaseStockWithRedisLock(testEvent.getId(), 1);
                successCount.incrementAndGet();
            } catch (Exception e) {
                failCount.incrementAndGet();
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();
    executorService.shutdown();

    Event result = eventRepository.findById(testEvent.getId()).orElseThrow();

    System.out.println("\n===== Redis 분산 락 적용 결과 =====");
    System.out.println("성공 요청: " + successCount.get());
    System.out.println("실패 요청: " + failCount.get());
    System.out.println("예상 재고: 0");
    System.out.println("실제 재고: " + result.getRemainingStock());
    System.out.println("이벤트 상태: " + result.getStatus());
    System.out.println("===================================\n");

    assertThat(result.getRemainingStock()).isEqualTo(0);
    assertThat(result.getStatus()).isEqualTo(EventStatus.SOLD_OUT);
    assertThat(successCount.get()).isEqualTo(100);
}

 

 

테스트 결과

decreaseStockWithRedis()메서드는 잘 호출돼서 주문은 100건 처리 되었지만, 재고가 전혀 감소하지 않은 문제가 있었습니다.

 

 

문제 원인 분석 과정

DB업데이트가 반영 되지 않았다는 뜻이므로 트랜잭션 활성화여부를 확인했습니다.

decreaseStockWithIntenal() 메서드 시작지점에 아래코드로 

@Transactional
public void decreaseStockInternal(Long eventId, int quantity) {
    // 트랜잭션 활성화 여부 확인
    boolean isTransactionActive = TransactionSynchronizationManager
            .isActualTransactionActive();

    log.info("트랜잭션 활성화 여부: {}", isTransactionActive);

 

트랜잭션 활성화 여부를 확인했습니다. 확인 결과 잘 활성화 되어 있었습니다. 트랜잭션이 활성화 되어 있지만, 데이터가 갱신되지 않은 이유는 ReadOnly 옵션이 활성화되어 있을 가능성이 있어 다음으로는 아래코드로 수정하여 확인했습니다.

 

boolean isTransactionReadOnlyActive = TransactionSynchronizationManager
        .isCurrentTransactionReadOnly();

log.info("트랜잭션 읽기전용 활성화 여부: {}", isTransactionReadOnlyActive);

 

트랜잭션 읽기전용 활성화 결과 확인

'decreaseStockWithRedis()'와 'decreaseStockWithIntenal()' 메서드가 있는 클래스 레벨에서 readOnly 옵션이 활성화가 되어 있었습니다.

클래스 레벨의 readOnly 옵션을 지우자 읽기전용 활성화는 당연히 해제되었습니다. 그런데 decreaseInternal()메서드에 우선 적용 되어야할 Transactional()마저도 해제가 되었습니다.

즉 원인은 ReadOnly 문제가 아니라 decreaseStockWithInternal()에 있는 Transactional이 적용되지 않은게 원인입니다.

 

decreaseStockWithIntenal() 메서드의 @Transactional 어노테이션이 적용되지 않은 이유

Transactional 어노테이션은 아래처럼 스프링 프레임워크에 포함된 어노테이션인데, 스프링은 Transactional을 AOP를 통해서 관리합니다.

import org.springframework.transaction.annotation.Transactional;

 

Spring AOP(Aspect Object Programming)

AOP는 관점지향프로그래밍으로 프레임워크가 메서드 호출을 가로채고 그 메서드의 실행을 변경할 수 있는 방법으로, 사용자가 선택한 특정 메서드 호출에 영향을 줄 수 있습니다. 스프링의 이러한 기능을 이용해서 사용자는 비즈니스로직에만 집중하고, log, Transaction, 보안 같은 공통 로직은 프레임워크가 담당하게 할 수 있습니다.

 

 

 

 

문제 원인

문제의 원인은 decreaseStockInternal() 메서드의 트랜잭션이 작동하지 않았습니다. @Transactional 어노테이션도 Spring AOP의 대상이 되는 어노테이션 입니다. 그런데 AOP는 내부메서드에는 적용이 되지 않습니다. decreaseStockWithInternal()메서드는 decreaseStockWithRedis()메서드 내부에서 호출하고 있기 때문에 프록시가 작동하지 않았고, 결국 트랜잭션이 적용되지 않아서

DB 커밋이 일어나지 않았습니다.

 

 

Spring AOP self-invocation 

이렇게 한 메소드에서 같은 클래스에 정의된 다른 트랜잭션 메소드를 호출했을 때 this로 인해 트랜잭션이 적용되지 않는 문제를 self-invocation 문제라고 합니다.

 

 

문제 해결 2차 시도

Spring AOP self-invocation 문제 해결

AOP에서 Transactional이 내부 메서드에 있을 때 프록시를 거치지 않는 문제를 해결하기 위해 컨트롤러가 호출하는 메서드인 decreaseStockWithRedis()메서드에 Transactional 설정을 해주었습니다. 

 

 

redis 락 해제 제어 오류로 동시성 문제 재발생

다시 테스트 코드를 실행해보았습니다.

트랜잭션은 제대로 활성화 되었고, 그런데 주문요청 성공 결가와 감소된 재고 결과가 일치하지 않았습니다. 100개의 주문을 요청했는데 64건만 성공하고, 36건은 재고감소가 이루어지지 않았습니다.

 

트랜잭션 활성화 여부 체크 및 Redis 분산락 적용 결과 테스트

 

2025-12-27T16:04:02.981+09:00  INFO 16591 --- [ool-2-thread-10] cohttp://m.shop.core.event.EventService : 트랜잭션 활성화 여부: true
2025-12-27T16:04:02.981+09:00  INFO 16591 --- [ool-2-thread-10] cohttp://m.shop.core.event.EventService : 트랜잭션 읽기전용 활성화 여부: false

 

===== Redis 분산 락 적용 결과 =====
성공 요청: 100
실패 요청: 0
예상 재고: 0
실제 재고: 36
이벤트 상태: ACTIVE
===================================

 

 

64건만 제대로 데이터 업데이트가 이루어졌고, 36건이 실패했다는 의미는 다시 동시성 문제가 발생했다는 뜻입니다. 문제의 원인은 이렇습니다. 아마 예상하신 분도 계시겠지만, Redis 분산락 해제 시점입니다. @Transactional 어노테이션이 적용되면, 메서드가 return 될때 트랜잭션이 같이 종료되어 데이터를 DB에 반영(commit)하는데 현재의 코드는 return 전에 redis 락을 해제하는 코드가 있어, 정확하게 트랜잭션의 락을 관리하지 못하는 상황입니다. Spring Tomcat은 멀티 쓰레드기 때문에 여러 요청을 동시에 처리할 수 있기 때문에 lock 적절히 관리해주지 않으면 동시성 문제가 발생할 수 있습니다. 즉, 트랜잭션커밋이 완료가 되고 Redis가 분산락을 해제 해야 하는데 커밋 직전에 락을 해제 해버리니, 락해제와 커밋사이에 다른 트랜잭션이 재고 데이터를 조회할 수 있었고, 결국 다시 Race Condition으로 Lost Update문제가 생긴것입니다.

 

실행 흐름 정리

**문제점**:
```
시간 순서:
1. 락 획득
2. 재고 감소 (메모리)
3. 락 해제 ⚠️
4. 커밋 (DB 반영)

→ 3과 4 사이에 다른 스레드가 락을 획득하면
  아직 커밋 안 된 데이터를 읽음 = Lost Update!

 

 

Redis 락을 적용하고 Spring AOP self-invocation 문제 해결했지만 동시성 문제가 발생한 코드

/**
 * Redis 분산 락을 사용한 재고 차감 (3차 시도 코드)
 */
@Transactional
public void decreaseStockWithRedisLock(Long eventId, int quantity) {
    String lockKey = "lock:event:" + eventId;
    RLock lock = redissonClient.getLock(lockKey);
    
    try {
        // 락 획득 시도 (최대 5초 대기, 락 점유 시간 3초)
        boolean available = lock.tryLock(5, 3, TimeUnit.SECONDS);
        
        if (!available) {
            log.warn("락 획득 실패. eventId={}", eventId);
            throw new IllegalStateException("락을 획득할 수 없습니다.");
        }
        
        // 같은 클래스 내부 메서드 호출 (문제 발생 지점!)
        decreaseStockInternal(eventId, quantity);
        
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new IllegalStateException("락 획득 중 인터럽트 발생", e);
    } finally {
        // 락 해제
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

/**
 * 실제 재고 차감 로직 (내부 호출용)
 */
public void decreaseStockInternal(Long eventId, int quantity) {
    Event event = eventRepository.findById(eventId)
        .orElseThrow(() -> new IllegalArgumentException("이벤트를 찾을 수 없습니다."));
    
    event.decreaseStock(quantity);
    
    log.debug("Redis 락 - 재고 차감 완료. eventId={}, 남은재고={}", eventId, event.getRemainingStock());
}



 

 

 

Facade 패턴으로 트랜잭션밖에서 Redis 락 제어하기

문제를 해결하기 위해 Facade 디자인 패턴을 적용했습니다. 원래 Facade 디자인패턴은 복잡한 내부 시스템 로직을 통합하여 단순한 인터페이스를 제공하여 클라이언트가 복잡한 내부로직을 모르더라도 편리하게 사용하기 위한 용도의 디자인 패턴입니다. 자세한 내용은 여기(https://techbook11.tistory.com/781 )에 정리해 두었습니다. 

여기서는 복잡한 로직을 통합하진 않았지만, 트랜잭션밖에서 Redis의 락을 제어하기 위해 사용했습니다. 

 

 

Facade 패턴 적용코드

public class StockFacade {
    
    public void decrease(Long eventId, Long quantity) {
        // 1. Redis 락 획득 (트랜잭션 외부)
        redisLockRepository.lock(id);
        
        try {
            // 2. 트랜잭션 실행
            eventService.decreaseStockInternal(eventId, quantity);
            // 3. 트랜잭션 커밋 완료
            
        } finally {
            // 4. Redis 락 해제 (트랜잭션 커밋 이후)
            redisLockRepository.unlock(id);
        }
    }
}

 

Facade 패턴 적용 Redis 분산락 테스트 결과

 

 

참고 자료 : https://velog.io/@ksah3756/%ED%94%84%EB%A1%9D%EC%8B%9C%EC%9D%98-%EA%B4%80%EC%A0%90%EC%9C%BC%EB%A1%9C-%EB%B0%94%EB%9D%BC%EB%B3%B8Transactional%EC%9D%98-%EC%9E%91%EB%8F%99-%EC%9B%90%EB%A6%AC

Comments