🎟️ Redis로 좌석 차감을 옮겼는데도 Lock Wait이 사라지지 않았다.

2026. 5. 31. 17:35·프로젝트/공연 티켓팅 프로젝트

배경

공연 예매 시스템을 구현하면서 가장 중요하게 생각했던 것은 성능보다 정합성이었습니다.

정원이 100석인 공연이라면 어떤 상황에서도 101번째 예매가 발생하면 안 된다고 생각했습니다.

특히 티켓 오픈 직후에는 수백 명의 사용자가 동시에 동일한 공연을 예매하기 때문에 일반적인 CRUD 방식으로는 초과 예매를 막기 어렵습니다.

그래서 처음에는 MySQL의 비관적 락(Pessimistic Lock)을 사용했습니다.

SELECT * 
FROM concert 
WHERE id = ? 
FOR UPDATE;

공연 정보를 조회하는 동시에 Row Lock을 획득하고 남은 좌석을 검증한 뒤 예매를 생성하는 구조였습니다.

기능 테스트에서는 문제가 없었습니다.

100석 공연에 1,000명이 동시에 요청해도 정확히 100명만 성공했고 초과 예매도 발생하지 않았습니다.

하지만 부하 테스트를 진행하면서 예상하지 못했던 문제가 발생했습니다.


문제 상황

대기열 진입
 ↓
입장권 발급
 ↓
좌석 검증
 ↓
Booking 생성
 ↓
결제

예매 로직은 다음과 같이 구현했습니다.

@Transactional
public Booking book(Long concertId) {

    Concert concert =
            concertRepository.findByIdWithLock(concertId);

    concert.validateRemainingSeat();

    Booking booking =
            Booking.create(concert);

    bookingRepository.save(booking);

    concert.increaseBooked();

    return booking;
}

여기서 핵심은 findByIdWithLock() 이었습니다.

SELECT *
FROM concert
WHERE id = ?
FOR UPDATE

MySQL의 SELECT FOR UPDATE는 조회와 동시에 Exclusive Lock(X Lock)을 획득합니다.

동일 Row를 수정하려는 다른 트랜잭션은 현재 트랜잭션이 종료될 때까지 대기하게 됩니다.

좌석 정합성을 보장하기에는 적절한 방법이라고 생각했습니다.


500명 동시 예매 테스트

실제 서비스 규모를 가정해 다음과 같이 부하 테스트를 진행했습니다.

  • DAU 10,000 ~ 15,000
  • 인기 공연 티켓 오픈 상황
  • 동일 공연 대상 500명 동시 요청

JMeter를 사용해 측정한 결과는 다음과 같았습니다.

항목 결과
평균 응답시간 3,778ms
p95 6,822ms
TPS 69.8/sec
Error 0%

정합성은 완벽하지만 문제는 성능이었습니다.

평균 응답시간이 3초를 넘었고 일부 요청은 7초 가까이 걸렸습니다.

사용자가 예매 버튼을 눌렀는데 7초 뒤에 성공 여부가 반환된다면 실제 티켓팅 서비스에서는 사용하기 어렵다고 판단했습니다.


처음에는 Connection Pool 문제라고 생각했다.

가장 먼저 의심한 것은 HikariCP 설정이었습니다.

maximum-pool-size: 10

동시 요청이 많기 때문에 DB Connection이 부족한 것이라고 생각했습니다.

그래서 다음과 같이 늘려봤습니다.

maximum-pool-size: 50

하지만 결과는 예상과 달랐습니다.

응답시간은 거의 개선되지 않았고 Lock Wait 건수만 증가했습니다. CPU 사용률도 크게 높지 않았습니다.

커넥션 수를 늘렸는데도 처리량이 증가하지 않는다는 것은 병목이 다른 곳에 있다는 의미였습니다.


Hot Row 문제

문제는 MySQL 전체가 아니었습니다. 딱 하나의 Row였습니다.

concert(id=1)

500명이 동시에 요청해도 결국 모든 요청은 동일한 공연 데이터를 수정합니다.

요청 1
 ↓
Lock 획득

요청 2
 ↓
대기

요청 3
 ↓
대기

...

요청 500
 ↓
대기

애플리케이션 입장에서는 500명이 동시에 요청한 것처럼 보입니다.

하지만 DB 입장에서는 사실상 순차 처리되고 있었습니다.

이를 Hot Row 문제 (특정 Row 하나에 모든 쓰기 경쟁이 집중되는 현상입니다.)라고 합니다.


Lock Wait은 왜 발생했을까

처음에는 Deadlock을 의심했습니다.

하지만 실제 상황은 달랐습니다.

Deadlock은 서로가 서로를 기다리는 상황입니다.

A → B Lock 필요 
B → A Lock 필요

반면 현재 상황은 다음과 같았습니다.

A → Lock 보유 

B → A 종료 대기 
C → A 종료 대기 
D → A 종료 대기

순환 의존성은 없습니다.

모든 트랜잭션이 단순히 하나의 트랜잭션 종료를 기다리고 있을 뿐입니다.

실제로 Lock 통계를 확인해보니 Row Lock Wait 수치가 지속적으로 증가하고 있었습니다.

즉 문제는 Lock 종류가 아니라 모든 요청이 동일한 Row를 수정한다는 사실 자체였습니다.


해결 방법 검토

원인을 확인한 뒤 바로 Redis를 도입한 것은 아니었습니다.

다음 기준으로 여러 방법을 먼저 검토했습니다.

  1. 초과 예매를 방지할 수 있는가
  2. Hot Row를 제거할 수 있는가
  3. HTTP 요청 안에서 즉시 결과를 반환할 수 있는가
  4. 운영 복잡도를 감당할 수 있는가

1. 낙관적 락

@Version 
private Long version;

낙관적 락은 충돌이 적은 환경에서는 좋은 선택입니다.

하지만 티켓팅은 대부분의 요청이 동시에 같은 데이터를 수정하는 환경입니다.

조회
 ↓
version = 1

500명 동시 수정

499명 충돌
 ↓
재시도

결국 Lock Wait이 Retry로 바뀔 뿐이라고 판단했습니다.

충돌이 반복될수록 재시도 횟수가 증가하고 응답시간도 함께 늘어납니다.

Hot Row는 그대로 존재하고 처리 방식만 달라지는 셈입니다.

티켓팅처럼 특정 순간에 트래픽이 집중되는 환경에는 적합하지 않다고 판단했습니다.

2. Atomic Update

UPDATE concert
SET remaining_seat = remaining_seat - 1
WHERE id = ?
AND remaining_seat > 0;

영향받은 Row수로 좌석 차감 성공 여부를 판단할 수 있습니다.

구현도 단순하고 별도의 인프라도 필요하지 않습니다.

하지만 결국 모든 요청이 동일한 Row를 Update합니다.

 

하나의 행에 모든 쓰기 경쟁이 집중되는 구조는 변하지 않는다고 판단했습니다.

비관적 락보다 나아질 수는 있지만 Hot Row 자체는 제거되지 않습니다.

Redis를 선택한 이유

처음에는

어떻게 Lock을 줄일까? 를 고민했습니다.

하지만 분석을 진행할수록 진짜 질문은 달랐습니다.

왜 모든 요청이 같은 Row를 수정해야 할까?

실제로 경쟁이 필요한 작업은 하나뿐이었습니다.

남은 좌석 확인
 ↓
좌석 1개 차감

이 작업만 안전하게 처리할 수 있다면 나머지는 일반적인 데이터 저장 문제였습니다.

그래서 좌석 차감 자체를 DB 밖으로 분리하기로 결정했습니다.

Redis의 실행 모델

Redis는 메모리 기반 저장소이기 때문에 MySQL보다 훨씬 빠르게 데이터를 처리할 수 있습니다.

하지만 Redis를 선택한 이유는 속도가 전부는 아니었습니다.

Redis는 단일 스레드 이벤트 루프 기반으로 동작합니다. 

수백 개의 요청이 동시에 들어오더라도 Redis 내부에서는 명령을 하나씩 순차적으로 처리합니다.

남은 좌석이 2석인 상황에서 3명이 동시에 예매를 시도하더라도,

A 요청
B 요청
C 요청

Redis 내부에서는 다음과 같이 처리됩니다.

A 처리
 ↓
B 처리
 ↓
C 처리

즉, 동일 키에 대한 동시 수정 경쟁 자체가 발생하지 않습니다.

MySQL에서는 여러 스레드가 동일 Row Lock을 두고 경쟁하지만 Redis에서는 애초에 경쟁 상황을 만들지 않는 구조였습니다.

Lock을 줄인 것이 아니라 경쟁이 발생하는 위치 자체를 바꿨다고 생각합니다.

또한 Redis는 요청을 처리한 직후 바로 결과를 반환할 수 있기 때문에 Kafka와 달리 즉시 응답도 만족할 수 있었습니다.

왜 Lua Script를 사용했는가

처음에는 단순히 DECR만 사용하려고 했습니다.

redisTemplate.opsForValue().decrement(key);

하지만 남은 좌석이 1석인 상황에서 문제가 발생합니다.

동시에 두 명이 요청하면

A → DECR → 0
B → DECR → -1

Redis 입장에서는 정상입니다.

그냥 값을 감소시켰을 뿐 하지만 비즈니스 관점에서는 좌석이 음수가 되면 안 됩니다. 

그래서 아래와 같은 작업이 필요합니다.

차감
 ↓
음수 확인
 ↓
음수면 원복

문제는 이것을 여러 명령으로 나누면 중간 상태를 다른 요청이 볼 수 있다는 점입니다.

그래서 Lua Script를 사용하였습니다.

local key = KEYS[1]
if redis.call('EXISTS', key) == 0 then
  return -2
end
local remaining = redis.call('DECRBY', key, 1)
if remaining < 0 then
  redis.call('INCRBY', key, 1)
  return -1
end
return remaining

Redis는 Lua Script 전체를 하나의 명령처럼 처리합니다.

스크립트 실행 중에는 다른 명령이 끼어들 수 없습니다.

반환값의 의미는 다음과 같습니다.

>= 0 : 차감 성공, 남은 좌석 수
  -1 : 좌석 없음 (SOLD_OUT)
  -2 : 키 미존재 (초기화 누락 또는 Redis 장애)

키 존재 확인부터 차감, 음수, 검증, 원복까지 이 모든 과정이 하나의 원자적 작업으로 수행됩니다.


Redis 적용 결과

항목 개선 전 개선 후
평균 응답시간 3,778ms 1,445ms
p95 6,822ms 2,518ms
TPS 69.8/sec 186.6/sec

평균 응답시간은 약 62% 감소했고 TPS는 약 2.7배 증가했습니다. 성능 문제는 해결된 것처럼 보였습니다.

그런데 예상하지 못한 문제가 남아 있었습니다.


Redis를 도입했는데도 Lock Wait이 사라지지 않았다.

성능은 좋아졌지만 Lock Wait 통계를 확인해보니 여전히 증가하고 있었습니다.

Innodb_row_lock_waits가 8,012건까지 증가했습니다.

Redis가 좌석 차감을 처리하고 있는데도 Lock Wait이 발생한다는 것은 누군가 여전히 동일 Row를 수정하고 있다는 의미였습니다.

그래서 코드를 다시 확인했습니다.

bookingRepository.save(booking);

concert.increaseBooked();

그리고 문제를 발견했습니다.

UPDATE concert
SET booked_count = booked_count + 1
WHERE id = ?

좌석 차감은 Redis가 담당하고 있었습니다.

하지만 예매가 성공할 때마다 여전히 동일한 concert Row를 수정하고 있었습니다.

결국 Hot Row는 제거되지 않았던 것입니다.


booked_count는 정말 필요한 데이터였을까?

이 시점에서 booked_count의 역할을 다시 분석했습니다.

기능 실제 사용 데이터
좌석 검증 Redis
매진 판단 Redis
예매 가능 여부 Redis
예매 기록 Booking
통계 Booking

이미 좌석 정합성의 기준은 Redis가 되어 있었습니다.

booked_count는 조회 편의를 위한 파생 데이터에 가까웠습니다.

반면, 이 데이터를 유지하기 위해 모든 요청이 동일 행을 수정하고 있었습니다.

그래서 예매 경로에서 concert.increaseBooked()호출을 제거했습니다.

booked_count 필드는 DB에 남겨두되, 예매 요청 중에는 건드리지 않는 구조로 변경한 것입니다.

서버 재기동 시 Redis 좌석 카운터를 복원하는 초기화 로직에서만 참조합니다.

실제로 변경 후 테스트를 보면 예매 성공 후에도 bookedCount가 0으로 유지되는 것을 확인하였습니다.

@Test
void 정상_신청이면_BOOKED_AND_DB에_저장된다() {
    given(queueService.claimAdmitted(anyLong(), anyLong())).willReturn(600L);
    given(queueService.decrementSeat(anyLong())).willReturn(5L);

    BookingResult result = bookingService.book(concert.getId(), userId);

    assertThat(result.getStatus()).isEqualTo("BOOKED");
    assertThat(bookingRepository.existsByConcertIdAndUserId(concert.getId(), userId)).isTrue();

    // bookedCount는 예매 경로에서 갱신하지 않으므로 0 유지
    Concert reloaded = concertRepository.findById(concert.getId()).orElseThrow();
    assertThat(reloaded.getBookedCount()).isEqualTo(0);
}

최종 결과

동일 조건으로 다시 부하 테스트를 진행했습니다.

항목 초기 구조 Redis 적용 최종 구조
평균 응답시간 3,778ms 1,445ms 1,445ms
TPS 69.8/sec 186.6/sec 186.6/sec
Lock wait 발생 발생 0건

성능 개선은 Redis 도입으로 얻었습니다.

Lock Wait 제거는 booked_count 제거로 얻었습니다.

즉 Redis가 문제를 해결한 것이 아니라 Hot Row를 제거한 구조 변경이 문제를 해결한 것입니다.


트레이드오프

Redis가 좌석 정합성의 기준이 된다.

booked_count 업데이트를 제거한 순간부터 좌석 상태의 기준은 MySQL이 아니라 Redis가 되었습니다.

Redis는

  • 좌석 수 관리
  • 매진 여부 판단
  • 예매 가능 여부 판단

을 담당합니다.

반면, MySQL은

  • 예매 내역 저장
  • 결제 상태 저장
  • 통계 저장

을 담당합니다.

Redis와 DB는 하나의 트랜잭션이 아니다.

다음과 같은 상황도 발생할 수 있습니다.

Redis 차감 성공
 ↓
DB 저장 실패

그래서 좌석 복구 로직과 입장권 복구 로직을 별도로 구현해야 했습니다.

운영 복잡도 증가

Redis 도입 이후에는

  • Redis Latency
  • Redis 메모리 사용량
  • Redis Seat Count
  • Redis 복구 횟수
  • Redis-DB 불일치

까지 관리해야 했습니다.


마무리

처음에는 Redis를 도입하면 문제가 해결될 것이라고 생각했습니다.

실제로 응답시간은 크게 개선되었습니다.

하지만 끝까지 추적해보니 Lock Wait의 진짜 원인은 Redis가 없어서가 아니었습니다.

모든 요청이 동일한 Row를 수정하는 구조 자체가 문제였습니다.

이번 경험을 통해 성능 문제를 해결할 때는 저장소를 바꾸기 전에 먼저 경쟁이 어디에 집중되고 있는지 확인해야 한다는 점을 배웠습니다.

결국 제가 해결한 것은 Redis가 아니라 Hot Row였습니다.

'프로젝트 > 공연 티켓팅 프로젝트' 카테고리의 다른 글

🎟️ 결제 확정 API 중복 호출 방지 - 3계층 멱등성으로 중복 결제 막기  (0) 2026.06.01
🎟️ 대기열 시스템에서 WebSocket 대신 Polling을 선택한 이유  (0) 2026.06.01
🎟️ Docker-compose up 한 번으로 실행되는 백엔드 만들기  (1) 2026.05.18
🎟️ MySQL 인덱스 실험: 예약 만료 배치 쿼리 성능 개선 (10만 건 실험)  (1) 2026.05.13
🎟️ 결제 API는 정말 중복 결제를 막을 수 있을까?  (1) 2026.04.27
'프로젝트/공연 티켓팅 프로젝트' 카테고리의 다른 글
  • 🎟️ 결제 확정 API 중복 호출 방지 - 3계층 멱등성으로 중복 결제 막기
  • 🎟️ 대기열 시스템에서 WebSocket 대신 Polling을 선택한 이유
  • 🎟️ Docker-compose up 한 번으로 실행되는 백엔드 만들기
  • 🎟️ MySQL 인덱스 실험: 예약 만료 배치 쿼리 성능 개선 (10만 건 실험)
hak0622
hak0622
개발하면서 “이게 뭐지?”라는 순간마다 궁금한 점을 바탕으로 정리한 개발 블로그입니다.
  • hak0622
    궁금한 개발 이야기 Why?
    hak0622
  • 전체
    오늘
    어제
    • 분류 전체보기 (81)
      • 공부 (36)
        • 1. 자바 ORM 표준 JPA 프로그래밍 - 기본.. (35)
        • 시험 (1)
      • 프로젝트 (45)
        • 스프링 부트 3 백엔드 개발자 되기 Blog + .. (35)
        • 공연 티켓팅 프로젝트 (10)
  • 인기 글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
hak0622
🎟️ Redis로 좌석 차감을 옮겼는데도 Lock Wait이 사라지지 않았다.
상단으로

티스토리툴바