[페스티맵] 티켓 예매 시스템 개선하기

축제 SaaS 서비스 페스티맵(Festimap)은 고객마다 하나의 축제를 등록하고, 각 축제의 메인페이지를 위젯 단위로 구성해 축제 분위기에 맞는 사이트를 만들어주는 서비스입니다. 그러던 중 고객사로부터 티켓 예매 기능 요청이 들어왔고, 빠른 일정 안에 비관적 락 기반의 예매 시스템을 구현하여 실제 목포 W쇼 티켓팅에 도입했습니다.

다행히 초과 발급은 발생하지 않았지만, 운영 과정에서 응답 지연과 커넥션 풀 고갈 문제가 드러났습니다. 이 글에서는 비관적 락을 선택한 배경부터, 기존 코드의 문제 분석, 그리고 두 번의 걸친 개선 과정을 정리해 보겠습니다.

 

1. 비관적 락 도입 배경

축제 티켓 예매는 평소에는 트래픽이 거의 없다가, 오픈 시점에 수백 명이 동시에 같은 잔여 티켓을 두고 경쟁하는 구조다. 동시성 제어 없이는 잔여 수량이 0인데도 예매가 성공하는 초과 판매가 발생할 수 있으므로, 락을 통한 동시성 제어가 필수적이었다.

JPA 환경에서 DB 레벨의 동시성 제어 방법은 크게 두 가지다. `@Version`을 사용하는 낙관적 락과, `@Lock(PESSIMISTIC_WRITE)`를 사용하는 비관적 락(Pessimistic Lock). 이 두 가지를 비교했다.

티켓 예매는 오픈 시점에 충돌이 거의 100% 발생하는 시나리오다. 낙관적 락을 적용하면 대부분의 요청이 버전 충돌로 실패하고, 클라이언트가 재시도해야 한다. 재시도가 몰리면 충돌은 더 심해지고, 사용자 경험은 최악이 된다고 생각했다.

반면 비관적 락은 DB 레벨에서 순서를 보장하므로, 한 번 대기하면 반드시 처리된다는 확실성이 있다. 빠른 일정 안에서 정합성을 확실히 보장하는 선택이 필요했기 때문에 비관적 락을 채택했다.

실제로 목포 W쇼 티켓팅에서 초과 판매는 발생하지 않았고, 비관적 락의 정합성은 검증되었다. 하지만 운영 중 다음 두 가지 문제가 드러났다.

 

  1. 응답 지연: 락 경합이 심해지면서 사용자 응답 시간이 수 초~수십 초로 늘어났다. 락을 잡은 채로 불필요한 쿼리를 실행하고 있었기 때문이다.
  2. 커넥션 풀 고갈: 트랜잭션 내부에서 SMS 외부 API를 호출하고 있었고, SMS 응답을 기다리는 동안 DB 커넥션이 묶여 전체 서비스가 느려졌다.

돌이켜보면 이 문제들은 처음 코드를 작성할 때 트랜잭션 경계와 락 범위에 대한 이해가 부족해서 발생한 것이었다. 이렇게라도 실제 운영을 통해 문제점을 직접 파악하고 개선할 수 있었다는 점에서 좋은 경험이었다고 생각한다.

이 문제들의 원인을 실제 코드 레벨에서 분석해 보자.

 

2. 기존 코드의 문제 분석

먼저 간단하게 이 예매 시스템에 대해 설명해보면, SMS 인증 후 게스트 JWT 토큰을 발급하는 방식으로 동작한다. 토큰에는 유효기간이 존재하는데, 미리 발급받은 토큰으로 오픈 전에 API 요청을 보내는 것을 막기 위해 예매 시점에 인증 상태를 다시 검증해야 했다. 그래서 당시에는 검증을 최대한 철저하게 하는 방향으로 코드를 작성했다.

문제 1: 불필요하게 긴 락 홀딩 시간

기존 `LockBasedTicketService.reserve()` 메서드를 보자.

// BEFORE: LockBasedTicketService.java
@Override
@Transactional
public void reserve(TicketRequest request) {
    validateVerification(request);                              // ① DB 조회 (인증 확인)
    Event event = loadEventWithLockOrThrow(request.getEventId()); // ② 락 획득 (FOR UPDATE)
    event.validateIsOpenAt();                                    // ③ 오픈 시간 검증
    isExistTicketBy(request.getEventId(), request.getPhoneNumber()); // ④ 중복 예매 조회
    event.decreaseRemainingTickets(request.getTicketCount());    // ⑤ 재고 차감
    ticketRepository.save(Ticket.of(request, event));            // ⑥ 티켓 저장
}

 

의도 자체는 틀리지 않았지만, 문제는 검증과 락의 순서에 있었다. ②에서 락을 획득한 뒤, ④에서 중복 예매 여부를 조회하고 있다. `isExistTicketBy()`는 단순 읽기 쿼리임에도 락을 잡은 채로 실행되므로, 다른 모든 스레드가 이 쿼리가 끝날 때까지 대기해야 한다.

트래픽이 폭주하는 시점에 이 차이는 크게 벌어진다. 100명이 동시에 예매를 시도하면, 99명은 한 명의 검증 쿼리가 끝나기를 기다려야 하고, 이 대기 시간이 누적되어 전체 처리량(throughput)이 급격히 떨어진다.

문제 2: 트랜잭션 내부에서 SMS 발송

@Transactional
public void sendVerificationCode(VerificationReqDto verificationReqDto) {
    validateEventIsActive(verificationReqDto.getEventId());

    Verification verification = verificationRepository.findByPhoneNumber(...)
            .map(existing -> {
                existing.updateVerificationCode();
                return existing;
            })
            .orElseGet(() -> verificationRepository.save(Verification.from(verificationReqDto)));

    smsClient.send(SmsSendRequest.from(verification)); // 외부 API 호출이 트랜잭션 안에!
}

 

이 코드에는 두 가지 심각한 문제가 있다.

첫째, 커넥션 풀 고갈 위험 

`@Transactional` 메서드가 시작되면 DB 커넥션을 하나 점유한다. SMS 발송은 외부 네트워크 I/O로, 통상 1~5초가 걸린다. 이 시간 동안 DB 커넥션이 묶여 있으므로, 동시 요청이 쏟아지면 커넥션 풀이 빠르게 고갈된다.

[요청 1] ── DB 저장(10ms) ── SMS 발송(3000ms) ── 커밋 ──→  총 3010ms 커넥션 점유
[요청 2] ── DB 저장(10ms) ── SMS 발송(2500ms) ── 커밋 ──→  총 2510ms 커넥션 점유
[요청 3] ── 커넥션 대기... ─────────────────────────→  HikariCP timeout!


둘째, 불필요한 롤백

SMS 발송이 실패하면 `smsClient.send()`에서 예외가 발생하고, `@Transactional`에 의해 인증코드 저장까지 롤백된다. 하지만 인증코드는 이미 생성되었으므로, SMS만 재시도하면 되는 상황이다. DB까지 롤백하면 사용자는 처음부터 다시 요청해야 한다.

 

문제 3: DB 비관적 락의 커넥션 풀 고갈 위험

모든 동시성 제어가 `@Lock(PESSIMISTIC_WRITE)`, 즉 `SELECT ... FOR UPDATE` 하나에 의존하고 있다.

// EventRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM Event e WHERE e.id = :id")
Optional<Event> findByIdWithLock(@Param("id") Long id);


DB 비관적 락은 정합성을 확실히 보장하지만, 구조적으로 락 대기가 곧 커넥션 점유라는 문제가 다. `SELECT FOR UPDATE`는 DB 커넥션을 잡은 상태에서 락 획득을 대기한다. 즉, 100명이 동시에 예매를 시도하면 100개의 DB 커넥션이 동시에 점유되고, 그 중 99개는 락이 풀릴 때까지 아무 일도 하지 않으면서 커넥션만 잡고 있는 셈이다.

[스레드 1] ── 커넥션 획득 ── FOR UPDATE (락 획득) ── 처리 ── 커밋 ── 커넥션 반환
[스레드 2] ── 커넥션 획득 ── FOR UPDATE (대기중...) ─────────────── 커넥션 점유 중
[스레드 3] ── 커넥션 획득 ── FOR UPDATE (대기중...) ─────────────── 커넥션 점유 중
   ...
[스레드 N] ── 커넥션 획득 실패 → HikariCP timeout!


HikariCP의 기본 커넥션 풀 크기는 10개다. 동시 요청이 10개를 넘는 순간 나머지 요청은 커넥션 자체를 획득하지 못해 락 경합과는 별개로 타임아웃이 발생한다. 이전까지는 쿼리 타임아웃으로 커넥션을 반납하도록 코드를 작성했지만, 트래픽이 더 증가하면 근본적인 해결이 필요하다.

 

3. 첫 번째 개선 : 트랜잭션 및 동시성 최적화

기존 코드를 구조적으로 변경하지 않으면서 동시성과 트랜잭션 문제를 해결해보려 했다.

개선 1: 락 홀딩 시간 최소화

// AFTER: LockBasedTicketService.java
@Override
@Transactional
public void reserve(TicketRequest request) {
    // ── 검증 구간 (락 없이 수행) ──
    validateVerification(request);                              // ① 인증 확인
    isExistTicketBy(request.getEventId(), request.getPhoneNumber()); // ② 중복 예매 확인

    // ── 임계 구간 (락 획득 후 변경) ──
    Event event = loadEventWithLockOrThrow(request.getEventId()); // ③ 락 획득
    event.validateIsOpenAt();                                    // ④ 오픈 시간 (빠른 체크)
    event.decreaseRemainingTickets(request.getTicketCount());    // ⑤ 재고 차감
    ticketRepository.save(Ticket.of(request, event));            // ⑥ 저장
}

 

변경한 점 :  `isExistTicketBy()`를 락 획득 전(②)으로 이동했다.

 

이렇게 결정하게 된 근거는 다음과 같다.

  • 중복 예매 확인은 읽기 작업이다. 락 밖에서 사전 필터링하면, 이미 예매한 사용자는 락을 잡지도 않고 빠르게 탈락시킬 수 있다.
  • 단, 중복 체크를 락 밖으로 빼면 동시 요청 시 두 건 모두 통과할 수 있으므로, Ticket 테이블에 `(event_id, phone_number)` 유니크 제약조건을 추가하여 DB 레벨에서 중복을 최종 방어했다.
  • 검증을 락 밖으로 빼면 락 홀딩 시간이 검증 쿼리 시간만큼 줄어든다. 검증 쿼리가 50ms라면 100건 기준 총 5초의 대기 시간이 절약된다.
BEFORE: ──[락 획득]──[검증 쿼리 50ms]──[재고 차감 5ms]──[저장 10ms]──[락 해제]──
          └──────────── 락 홀딩: 65ms ────────────────┘

AFTER:  ──[검증 쿼리 50ms]──[락 획득]──[재고 차감 5ms]──[저장 10ms]──[락 해제]──
                              └─────── 락 홀딩: 15ms ──────────┘

개선 2: 트랜잭션 경계 재설계

앞서 문제 분석에서 살펴봤듯이, 기존 `sendVerificationCode()`는 `@Transactional` 안에서 SMS 외부 API를 호출하고 있었다. SMS 응답을 기다리는 수 초 동안 DB 커넥션이 묶여 커넥션 풀이 고갈되고, SMS 실패 시 인증코드까지 롤백되는 문제가 있었다. 해결 방향은 단순하다. DB 작업과 외부 I/O를 분리하면 된다.

 

초기에는 `private` 메서드에 `@Transactional`을 붙여서 DB 저장과 SMS 발송을 분리하는 방식을 시도했다. 하지만 여기에는 근본적인 문제가 있다. Spring AOP는 프록시 기반으로 동작하기 때문에, `private` 메서드에 `@Transactional`을 붙여도 실제로는 트랜잭션이 적용되지 않는다. 같은 클래스 내부에서 `this.saveVerificationCode()`를 호출하면 프록시를 거치지 않고 직접 호출되기 때문이다.

이 문제를 근본적으로 해결하기 위해 Spring의 `@TransactionalEventListener`를 도입했다. 트랜잭션이 커밋된 후에 SMS가 발송되는 것을 프레임워크 레벨에서 보장한다.

// 이벤트 클래스
@Getter
@RequiredArgsConstructor
public class VerificationCodeSavedEvent {
    private final String phoneNumber;
    private final String code;
}

// AFTER: VerificationService.java
@Transactional
public void sendVerificationCode(VerificationReqDto verificationReqDto) {
    validateEventIsActive(verificationReqDto.getEventId());

    Verification verification = verificationRepository.findByPhoneNumber(verificationReqDto.getPhoneNumber())
            .map(existing -> {
                existing.updateVerificationCode();
                return existing;
            })
            .orElseGet(() -> verificationRepository.save(Verification.from(verificationReqDto)));

    eventPublisher.publishEvent(
            new VerificationCodeSavedEvent(verification.getPhoneNumber(), verification.getCode()));
}

// 이벤트 리스너 — 커밋 후 SMS 발송
@Component
@RequiredArgsConstructor
public class VerificationEventListener {
    private final SmsClient smsClient;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleVerificationCodeSaved(VerificationCodeSavedEvent event) {
        try {
            smsClient.send(SmsSendRequest.ofVerification(event.getPhoneNumber(), event.getCode()));
        } catch (Exception e) {
            log.error("SMS send failed for phone: {}", event.getPhoneNumber(), e);
        }
    }
}

 

이렇게 결정한 근거를 정리하면 다음과 같다.

핵심 원칙은 외부 I/O는 트랜잭션 커밋 후에, DB 작업만 트랜잭션 안에서다.

`@TransactionalEventListener(phase = AFTER_COMMIT)`를 사용하면 SMS 발송이 트랜잭션 커밋 이후에 실행되는 것을 Spring이 보장하므로, 호출 순서나 프록시 동작 여부에 의존하지 않는 안정적인 구조가 된다.

 

4. 두 번째 개선 : 비관적 락 -> 메모리 락으로 전환 및 락 추상화

 

첫 번째 개선이 기존 구조 안에서의 최적화였다면, 두 번째 개선은 락 메커니즘 자체를 전환하는 작업이다.

첫 번째 개선으로 락 홀딩 시간과 트랜잭션 경계 문제는 해결했지만, 앞서 문제 분석(섹션 3)에서 짚었던 근본적인 한계는 그대로 남아 있었다. DB 비관적 락은 `SELECT FOR UPDATE`로 락을 대기하는 동안 DB 커넥션을 점유하기 때문에, 동시 요청이 커넥션 풀 크기를 넘는 순간 커넥션 고갈이 발생한다. 락 홀딩 시간을 아무리 줄여도, 락 대기 = 커넥션 점유라는 구조 자체가 바뀌지 않는 한 트래픽 증가에 취약할 수밖에 없다.

처음에는 Redis 기반 분산 락(Redisson)을 도입하려고 했다. 분산 락은 DB 커넥션 없이 락을 대기할 수 있고, 멀티 인스턴스 환경에서도 동작하니 가장 확실한 해결책이었다. 하지만 한 발 물러서 생각해보니, 현재 Festimap의 티켓팅 서버는 단일 인스턴스로 운영되고 있었다. Redis를 도입하면 인프라 비용이 증가하고 운영 복잡도도 올라간다. 단일 인스턴스에서 분산 락은 명백한 오버 엔지니어링이었다.

그래서 방향을 전환했다. 지금 당장은 JVM의 `ReentrantLock`을 사용하는 메모리 락으로 커넥션 고갈 문제를 해결하고, 나중에 트래픽이 커져 서버를 스케일 아웃할 때를 대비해 락 메커니즘을 인터페이스로 추상화하기로 했다. 이렇게 하면 지금은 추가 인프라 없이 문제를 해결하면서도, 분산 락이 필요한 시점에 서비스 코드 변경 없이 구현체만 교체할 수 있다.

개선 : 락 추상화 with 전략 패턴

먼저 락의 획득과 해제를 `LockManager` 인터페이스로 정의했다. 서비스 계층은 이 인터페이스에만 의존하고, 실제 락 구현이 메모리인지 Redis인지는 알 필요가 없다.

// LockManager.java — 락 추상화 인터페이스
public interface LockManager {
    boolean tryLock(String key, long waitTime, long leaseTime, TimeUnit timeUnit)
            throws InterruptedException;
    void unlock(String key);
}

 

현재 사용하는 구현체는 JVM의 `ReentrantLock`을 활용한 `LocalLockManager`다. `ConcurrentHashMap`으로 key별 락을 관리하며, DB 커넥션 없이 메모리에서 락 대기가 이루어진다.

@Component
public class LocalLockManager implements LockManager {
    private final ConcurrentHashMap<String, ReentrantLock> locks = new ConcurrentHashMap<>();

    @Override
    public boolean tryLock(String key, long waitTime, long leaseTime, TimeUnit timeUnit)
            throws InterruptedException {
        ReentrantLock lock = locks.computeIfAbsent(key, k -> new ReentrantLock());
        boolean acquired = lock.tryLock(waitTime, timeUnit);
        if (acquired) log.debug("Lock acquired for key: {}", key);
        return acquired;
    }

    @Override
    public void unlock(String key) {
        ReentrantLock lock = locks.get(key);
        if (lock != null && lock.isHeldByCurrentThread()) {
            lock.unlock();
            // 대기 중인 스레드가 없으면 메모리에서 제거
            if (!lock.hasQueuedThreads()) {
                locks.remove(key);
            }
        }
    }
}

 

그리고 나중에 분산 환경으로 전환할 때를 대비해 `RedisLockManager` 스켈레톤도 준비해두었다.

// @Component
// @Profile("prod")
public class RedisLockManager implements LockManager {
    // TODO: Redisson 라이브러리 추가 후 구현
    //   RLock lock = redissonClient.getLock(key);
    //   return lock.tryLock(waitTime, leaseTime, timeUnit);
}

 

정리하면, `LockManager` 인터페이스를 두고 `LocalLockManager`(메모리 락)와 `RedisLockManager`(분산 락) 두 구현체를

전략 패턴으로 구성했다. 현재는 `LocalLockManager`가 활성화되어 있고, 분산 환경이 필요한 시점에 `@Profile`만 전환하면

서비스 코드 변경 없이 분산 락으로 교체할 수 있다.

 

6. 최종 정리 : 무엇이 바뀌었는가?

두 번의 개선에 걸쳐 세 가지 문제를 해결했다. 각 문제가 어떻게 변했는지 정리해보자.

 

  1. 문제 1: 불필요하게 긴 락 홀딩 시간 → 검증을 락 밖으로 이동
    읽기 전용 검증(`isExistTicketBy`)을 락 획득 전으로 이동하여, 락은 재고 차감과 저장에만 사용하도록 변경했다. 동시 
    요청 시 대기열에서 기다리는 시간이 검증 쿼리 수만큼 줄어든다.
  2. 문제 2: 트랜잭션 내부 SMS 발송 → @TransactionalEventListener로 커밋 후 분리
    DB 저장과 SMS 발송을 이벤트 기반으로 분리했다. `@TransactionalEventListener(phase = AFTER_COMMIT)`로 
    트랜잭션 커밋 후에 SMS가 발송되는 것을 Spring이 보장한다. DB 커넥션 점유 시간이 3~5초에서 10~50ms로 
    줄었고, SMS 실패 시에도 인증코드가 롤백되지 않는다.
  3. 문제 3: DB 비관적 락의 커넥션 고갈 → 메모리 락 전환 + 락 추상화
    DB `SELECT FOR UPDATE`에서 JVM `ReentrantLock` 기반 메모리 락으로 전환했다. 락 대기가 DB 커넥션 없이 메모리에서 이루어지므로, 동시 요청이 아무리 많아도 커넥션 사용량은 락을 획득한 스레드 수로 제한된다. `LockManager` 
    인터페이스로 추상화하여 분산 환경 전환 시 구현체만 교체하면 된다.

 

7. 마무리 : 이 과정에서 배운 것

처음 티켓 예매 시스템을 구현할 때는 동시성 제어를 낙관적 락 vs 비관적 락 중 하나를 고르는 문제로만 바라봤다. 비관적 락을 선택하면 정합성이 보장되고, 그걸로 끝이라고 생각했다.

하지만 실제 운영을 겪으면서 시야가 넓어졌다. 락 자체보다 락을 잡고 있는 동안 무엇이 일어나는지가 더 중요했다. 락 안에서 불필요한 쿼리를 실행하면 대기 시간이 누적되고, 트랜잭션 안에서 외부 API를 호출하면 커넥션이 묶인다. 더 나아가 DB 비관적 락은

락을 기다리는 것 자체가 커넥션을 점유하기 때문에, 동시 요청이 커넥션 풀 크기를 넘는 순간 락 경합과는 별개로 시스템이 멈출 수 있다는 것도 알게 되었다.

결국 이번 개선 과정은 트랜잭션 경계, 락 범위, 커넥션 풀이라는 세 가지가 서로 밀접하게 연결되어 있다는 것을 체감한 경험이었다. 락의 종류를 고르는 것은 시작일 뿐이고, 그 락이 트랜잭션과 커넥션에 미치는 영향까지 함께 설계해야 한다.

여기서 한 발 더 나아가 생각해보면, 현재 구조는 메모리 락으로 커넥션 고갈을 해결했지만 여전히 동기 처리 방식이다. 100명이 동시에 요청하면 99명은 메모리에서 락이 풀릴 때까지 대기해야 한다. 만약 트래픽 규모가 지금보다 훨씬 커진다면, 메시지 큐를 도입해 예매 요청을 비동기로 처리하는 방식도 고려해볼 수 있을 것이다. 요청을 큐에 넣고 순차 처리하면 락 자체가 필요 없어지고, 사용자에게는 즉시 응답을 돌려줄 수 있다.