개인 프로젝트/영화예매사이트

[Spring Boot 프로젝트] 영화 예매 사이트 개발기 (4) — Redis를 활용한 동시성 제어와 티켓 예매 시스템 구현기

sik-code 2026. 10. 10. 00:28

안녕하세요!

 

드디어 영화 예매 시스템 개발의 꽃(이자 가장 머리 아픈 관문)인 동시성 제어와 티켓 예매 로직 구현 파트까지 왔습니다.

 

상영 시간표를 열고 좌석을 골라 예매 버튼을 누르는 순간은 사용자에게는 단순하지만, 백엔드 입장에서는 가장 긴장되는 순간입니다. 만약 두 명의 사용자가 0.1초 차이로 똑같은 명당 좌석을 동시에 클릭한다면? 생각만 해도 식은땀이 흐르는 이 '중복 예매 대참사'를 막기 위해, Redis를 꺼내 들고 고군분투했던 과정을 풀어보겠습니다.

 

1. 🚨 동시성 이슈란 무엇이고 왜 막아야 할까?

영화 예매 시스템은 특성상 특정 인기 영화(예: 천만 관객 영화 개봉일!)에 트래픽이 순식간에 몰립니다.

  • 문제 상황: 사용자 A와 사용자 B가 동시에 같은 상영관의 E열 5번 좌석을 골라 예매 요청을 날립니다.
  • 만약 데이터베이스 조회만 믿고 낙관적으로 코드를 짰다면, 두 요청 모두 "아직 예매 안 됐네?" 하고 통과시켜 버려서 한 좌석에 두 명의 결제가 승인되는 끔찍한 데이터 정합성 파괴가 일어납니다.
  • 데이터베이스의 유니크 제약조건(UQ)으로 최종 방어선은 쳐두었지만, 사용자 경험(UX) 측면에서 이미 결제 단계까지 진행된 후에 "아, 이미 다른 사람이 선점했습니다"라고 에러를 뱉는 것은 최악의 시나리오입니다. 따라서 애초에 진입 단계부터 락(Lock)을 걸어 동시 접근을 통제해야 했습니다.

2. 💡 왜 하필 Redisson의 MultiLock을 선택했을까? (동시성 제어 기술 비교)

동시성 제어를 구현하기 앞서, "어떤 방식으로 락을 걸어야 가장 안전하고 효율적일까?"에 대해 깊은 고민을 했습니다. 백엔드 진영에서 주로 고려하는 3가지 후보(비관적 락, 낙관적 락, 레디스 분산 락)를 두고 각각의 장단점을 저울질해 보았습니다.

① 데이터베이스 비관적 락 (Pessimistic Lock)

  • 방식: SELECT ... FOR UPDATE 구문을 사용해 데이터에 직접 락을 걸어버리는 방법입니다.
  • 장점: 데이터 정합성 보장이 확실하고 구현이 비교적 직관적입니다.
  • 단점: 트랜잭션이 끝날 때까지 DB 커넥션을 쥐고 있어야 하므로, 예매 전쟁처럼 트래픽이 몰리는 상황에서는 DB 부하가 치명적으로 커지고 병목 현상(대기 시간 증가)이 발생할 위험이 큽니다.

② 데이터베이스 낙관적 락 (Optimistic Lock)

  • 방식: 락을 직접 걸지 않고 버전(Version) 컬럼을 두어, 수정 시점에 버전이 일치하는지 확인하고 다르면 예외를 터뜨리는 방법입니다.
  • 장점: 락을 실제로 걸지 않기 때문에 충돌이 적은 환경에서는 성능이 좋습니다.
  • 단점: 영화 예매 특성상 인기 좌석에는 수많은 사용자가 동시에 몰려 충돌(Conflict)이 빈번하게 발생합니다. 이 경우 재시도(Retry) 로직을 개발자가 직접 복잡하게 구현해야 하고, 실패 빈도가 높아질수록 사용자 경험이 급격히 떨어집니다.

③ 레디스 분산 락 (Redis Distributed Lock - Redisson)

  • 방식: 인메모리 기반의 레디스를 활용해 외부에서 별도의 락 서버로 동시 접근을 통제하는 방법입니다.
  • 장점: DB에 직접 부하를 주지 않으면서 싱글 스레드 기반의 원자성(Atomic)을 보장받을 수 있어 성능과 속도 면에서 압도적으로 유리합니다.
  • 해결책 (MultiLock): 특히 영화 예매는 사용자가 여러 개의 좌석을 동시에 선택할 수 있어 "좌석별로 락을 따로 잡아야 하나?" 하는 고민이 있었습니다. 이때 Redisson의 MultiLock을 활용하면, 선택한 여러 좌석의 락을 하나의 묶음으로 처리하여 "모든 좌석의 락을 동시에 획득하거나, 아니면 전부 안 하거나(All or Nothing)"를 완벽하게 보장할 수 있었습니다.

  • 결과적으로 대규모 트래픽 환경에서의 성능 이점과 다중 좌석을 안전하게 묶어 처리할 수 있는 Redisson의 장점 때문에 최종적으로 레디스 분산 락을 채택하게 되었습니다.

 

3. 🛠️ 실제 구현 코드: Redisson Facade와 컨트롤러

직접 구현한 예매 동시성 제어 코드의 핵심 구조입니다. 비즈니스 트랜잭션과 락 제어의 책임을 분리하기 위해 Facade 패턴을 적용했습니다.

① 동시성 제어를 담당하는 RedissonLockFacade

@Slf4j
@Component
@RequiredArgsConstructor
public class RedissonLockFacade {
    private final RedissonClient redissonClient;
    private final ReservationService reservationService;

    /**
     * 다중 좌석 분산 락 적용 후 좌석 선점 실행
     */
    public ReservationCreateResponseDto createPendingReservationWithLock(ReservationCreateRequestDto request, Long memberId) {
        Long showtimeId = request.getShowtimeId();
        List<Long> seatIds = request.getSeatIds();

        // 1. 선택한 좌석별로 RLock 생성
        List<RLock> locks = seatIds.stream()
                .map(seatId -> redissonClient.getLock("lock:showtime:" + showtimeId + ":seat:" + seatId))
                .collect(Collectors.toList());

        // 2. 여러 락을 하나의 묶음으로 제어하는 MultiLock 생성
        RLock multiLock = redissonClient.getMultiLock(locks.toArray(new RLock[0]));

        try {
            // 3. waitTime: 3초 대기, leaseTime: 10초 후 자동 해제
            boolean available = multiLock.tryLock(3, 10, TimeUnit.SECONDS);

            if (!available) {
                throw new IllegalStateException("다른 사용자가 예매 중인 좌석이 포함되어 있습니다. 다시 시도해 주세요.");
            }

            // 4. 락 획득 성공 후 안전하게 DB 트랜잭션 수행
            return reservationService.createPendingReservation(request, memberId);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("좌석 선점 작업 중 인터럽트가 발생했습니다.", e);
        } finally {
            // 5. 내가 락을 쥔 상태일 때만 안전하게 해제
            if (multiLock.isHeldByCurrentThread()) {
                multiLock.unlock();
            }
        }
    }
}

 

② 세션 검증 및 파사드를 호출하는 Controller

@PostMapping("/hold")
public String holdSeatsAndMoveToPayment(@ModelAttribute ReservationCreateRequestDto request, 
                                        HttpSession session, Model model) {

    Long memberId = (Long) session.getAttribute("memberId");

    if (memberId == null) {
        return "redirect:/members/login";
    }

    // Facade를 통해 동시성 락 제어와 임시 예매(PENDING) 생성을 동시에 처리
    ReservationCreateResponseDto reservationInfo = 
        redissonLockFacade.createPendingReservationWithLock(request, memberId);

    return "redirect:/reservations/payment?reservationId=" + reservationInfo.getReservationId();
}
  • 구현 포인트:
    • tryLock(3, 10, TimeUnit.SECONDS)을 통해 3초 동안 락 획득을 시도하고, 락을 획득하더라도 10초가 지나면 자동으로 풀리도록(Deadlock 방지) 안전장치를 걸었습니다.
    • finally 블록에서 multiLock.isHeldByCurrentThread()를 체크하여 내가 쥔 락만 깔끔하게 해제하도록 설계했습니다.

4. 🧹 백그라운드 스케줄러로 PENDING 상태 관리하기

임시 예매(PENDING) 상태로 좌석을 찜해 둔 뒤, 사용자가 결제를 하지 않고 이탈해 버리면 다른 사람들은 그 좋은 자리를 영원히 예매하지 못하는 사태가 벌어집니다.

  • 이를 해결하기 위해 스프링 부트의 @Scheduled 기능을 활용해, 일정 시간(예: 5분) 이상 결제 완료(COMPLETED)로 전환되지 않고 PENDING 상태에 머물러 있는 예매 건들을 주기적으로 추적하여 자동 취소하고 좌석을 풀어주는 백그라운드 스케줄러를 함께 구현했습니다.
  • 시스템이 알아서 유령 데이터를 정리해주니 자원 누수를 깔끔하게 방지할 수 있었습니다.

5. 💡 이번 동시성 제어를 구현하며 깨달은 점

  • "동시성 제어는 이론으로 들을 때는 그냥 락 걸면 끝나는 줄 알았는데..." 막상 코드로 구현하고 Redisson의 MultiLock을 적용해보니 "다중 좌석을 어떻게 묶어서 원자적으로 처리할 것인가", "트랜잭션 커밋 시점과 락 해제 시점의 타이밍(Facade 분리의 이유)" 등 실무적인 고민들이 왜 필요한지 뼈저리게 느꼈습니다.
  • 직접 코드를 짜고 예외 처리를 붙여가며 동시성 제어의 메커니즘을 온전히 내 것으로 만들 수 있었던 아주 값진 경험이었습니다.

6. 📚 다음 포스팅 예고

Redisson MultiLock과 파사드 패턴을 통해 튼튼한 예매 프로세스를 완성했습니다! 다음 5편(마지막 시리즈)에서는 프로젝트를 마무리하며 겪었던 전체적인 리팩토링 과정, 트러블슈팅, 그리고 주니어 백엔드 개발자로서 이번 프로젝트를 통해 얻은 최종 소회를 털어놓겠습니다.

마지막까지 함께 달려봐요! 🚀