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)을 겪었기 때문입니다.

마무리

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

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