Spring BootRedisConcurrencyArchitecture

분산 환경에서의 동시성 이슈 해결기: Redis 분산 락(Redisson) vs DB 락

한정된 재고나 선착순 이벤트에서 흔히 발생하는 동시성 문제를 해결하기 위해, DB 비관적 락(Pessimistic Lock)과 Redis 분산 락(Redisson)을 비교하고 적용해 본 경험입니다.

Srue2026년 4월 7일
분산 환경에서의 동시성 이슈 해결기: Redis 분산 락(Redisson) vs DB 락

분산 환경에서의 동시성 이슈 해결기: Redis 분산 락 vs DB 락

이전 글 트랜잭션과 동시성 노트에서 개발 단계에서의 낙관적 연산이나 로컬 환경 트랜잭션 격리 수준을 간단히 정리했습니다.

하지만 실무에서 운영하는 백엔드 서버가 2대 이상으로 늘어나자(스케일 아웃), 단일 애플리케이션 안의 @Transactional이나 synchronized 블록만으로는 데이터 정합성을 보장할 수 없다는 것을 뼈저리게 느꼈습니다.

대표적인 시나리오가 '사용자 포인트 차감'이나 '선착순 쿠폰 발급'이었습니다. 여러 서버에서 거의 동일한 시점에 DB 데이터를 읽고 수정하려 들면서 불과 1초 만에 마이너스 통장이 되는 이른바 'Race Condition(경쟁 상태)'을 목격했습니다.

이 동시성 이슈를 극복하기 위해 검토했던 **데이터베이스 비관적 락(Pessimistic Lock)**과 궁극적으로 정착한 Redis 분산 락(Distributed Lock, Redisson 기반) 비교 도입기를 풀어보려 합니다.

가장 직관적인 해법: DB 비관적 락 (Pessimistic Lock)

Spring Data JPA를 사용 중이라면, 해결책은 문법적으로 매우 한 줄짜리입니다. 단순히 쿼리를 쏠 때 레코드 자체에 "아무도 건드리지 마!" 하고 자물쇠를 거는 행위입니다.

public interface CouponRepository extends JpaRepository<Coupon, Long> {
 
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT c FROM Coupon c WHERE c.id = :id")
    Optional<Coupon> findByIdForUpdate(@Param("id") Long id);
}

이렇게 하면 내부적으로 SELECT ... FOR UPDATE 쿼리가 발송되며, 한 트랜잭션이 행의 락을 놓을 때까지(commit) 다른 트랜잭션은 대기(Wait) 상태에 빠집니다.

DB 락이 주었던 딜레마

완벽하게 동시성을 방어했지만, 트래픽이 몰리는 이벤트 시간대에는 치명적이었습니다. 락을 획득하기 위해 대기하는 커넥션 수가 기하급수적으로 늘어나고, 결국 HikariCP 커넥션 풀이 고갈되어 다른 정상적인 API(예: 로그인, 상품 목록 조회 등)까지 모조리 타임아웃(Timeout)이 발생하는 데드락과 성능 지연 현상을 마주했습니다.

즉, "문은 잠갔는데 도둑뿐만 아니라 손님까지 못 들어오는" 구조였습니다.

성능과 정합성을 잡는 대안: Redis 분산 락

데이터베이스에 가중된 부하를 분산시키기 위해 기존에 적용했던 Redis 캐시 인프라를 십분 활용하기로 했습니다. 이 중에서도 만료 시간 관리(Expiration)와 재시도(Retry)가 편하게 구현된 Redisson 클라이언트를 썼습니다.

Lettuce 대신 왜 Redisson인가요?

Lettuce로 락을 구현하려면 SETNX(Set if Not eXists) 명령어를 기반으로 스핀 락(Spin Lock, 락을 얻을 때까지 무한 반복 질의)을 개발자가 직접 구현해야 합니다. 이는 Redis 서버 커넥션에 상당한 스트레스를 줍니다. 반면 Redisson은 Pub/Sub 방식으로 작동하여, "앞 사람이 락 해제했어~"라고 알림을 주면 그때 대기하던 스레드가 락을 시도하므로 네트워크 오버헤드가 압도적으로 적습니다.

AOP 기반 분산 락 어노테이션 제작

로직마다 Redisson 클라이언트를 주입받아 쓰기엔 보일러플레이트(의미 없는 반복 코드)가 끔찍했습니다. 그래서 이 로직을 격리하기 위해 @DistributedLock이라는 커스텀 어노테이션을 만들어 렌더링했습니다.

DistributedLockAop.java
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
 
    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;
 
    @Around("@annotation(distributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
        String key = "LOCK:" + distributedLock.key();
        RLock rLock = redissonClient.getLock(key);
 
        try {
            // 최대 5초 대기, 락 획득 시 3초 후 자동 해제
            boolean available = rLock.tryLock(5, 3, TimeUnit.SECONDS);
            if (!available) {
                throw new CustomException("현재 요청이 많아 처리가 지연되고 있습니다.");
            }
            
            // 핵심: 락 획득 후 새로운 트랜잭션을 시작하여 정합성 커밋 보장
            return aopForTransaction.proceed(joinPoint);
            
        } finally {
            if (rLock.isLocked() && rLock.isHeldByCurrentThread()) {
                rLock.unlock();
            }
        }
    }
}

여기서 가장 중요한 부분은 aopForTransaction.proceed() 였습니다. 락을 거는 행위와 DB 커밋 트랜잭션의 생명주기를 완벽히 분리해야 합니다. 락이 먼저 풀려버리고 DB 트랜잭션이 커밋되기 그 찰나의 순간에 또 다른 타겟 스레드가 락을 잡고 변경되지 않은 값을 읽어버리는 환장할 노릇(Dirty Read)을 겪었기 때문입니다.

락 자동 해제(leaseTime)가 숨겨둔 함정

앞선 코드에서 tryLock(5, 3, TimeUnit.SECONDS)의 두 번째 인자 3초가 바로 **leaseTime(임대 시간)**입니다. 락을 잡은 스레드가 죽거나 무한 루프에 빠져 영영 락을 반납하지 않는 상황을 막기 위해, "3초가 지나면 무슨 일이 있어도 락을 자동으로 풀어라"라는 안전장치입니다.

문제는 DB 트랜잭션이 이 leaseTime보다 길어질 때 발생합니다.

leaseTime을 넘기는 트랜잭션의 위험
// leaseTime=3초로 잡았는데, 내부 DB 트랜잭션이 4초가 걸린다면?
boolean available = rLock.tryLock(5, 3, TimeUnit.SECONDS);
// → 3초 시점에 Redis 락이 먼저 자동 해제된다.
// → 아직 커밋되지 않은 상태에서 다른 스레드가 같은 락을 잡고
//    변경 전 값을 읽어, 애써 막았던 동시성 문제가 그대로 재발한다.

즉, leaseTime을 너무 짧게 잡으면 상호 배제(Mutual Exclusion)가 조용히 깨집니다. 실제로 GC가 길게 튀거나 DB가 순간적으로 느려지면 3초는 생각보다 쉽게 넘어갑니다.

대응 방법은 크게 두 가지였습니다.

① leaseTime을 트랜잭션 최악 지연시간보다 넉넉히 잡기. 임계 구역을 최대한 짧게 유지하고, p99 트랜잭션 시간에 여유를 더해 leaseTime을 설정합니다. 다만 값이 너무 크면, 진짜로 장애가 난 스레드가 잡은 락이 오래 살아 있는 부작용이 생깁니다.

② leaseTime을 아예 지정하지 않고 Redisson의 Watchdog에 맡기기.

Watchdog으로 락을 자동 연장
// leaseTime 인자를 빼면(-1로 동작), Redisson watchdog가
// lockWatchdogTimeout(기본 30초)을 만료 시간으로 잡고,
// 락 보유 스레드가 살아 있는 한 주기적으로 만료 시간을 자동 연장한다.
boolean available = rLock.tryLock(5, TimeUnit.SECONDS);

Watchdog을 쓰면 트랜잭션이 길어져도 락이 중간에 풀리지 않아 안전합니다. 대신 스레드가 정상적으로 락을 반납하지 못하면 최대 watchdog 타임아웃만큼 락이 유지되므로, 임계 구역을 짧게 유지하고 느린 트랜잭션에 대한 모니터링·알람을 함께 두는 편이 안전했습니다.

마무리

Redis 분산 락(Redisson)을 도입한 결과는 아주 훌륭했습니다. 선착순 이벤트 1,000건의 동시 요청 트래픽 데스트(JMeter 활용)에서 단 하나의 오차나 마이너스 통장 없이 완벽히 1,000의 수치가 차감되었고, DB 커넥션 병목 역시 말끔하게 해소되었습니다.

이 삽질을 거치며 동시성 제어는 단순히 코딩 기술이 아니라 "내 시스템 아키텍처 중 어느 지점이 병목인가"를 파악하고 그 부하를 적절한 컴포넌트로 이동(Offloading)시키는 전략적 행위임을 배웠습니다.