1. 문제 상황 : 좌석 선점 시스템을 Redis로 구현하다가 발생한 문제들
프로젝트에서 좌석 선점 기능을 Redis를 활용해서 구현하고 있었다. 처음에는 다음과 같은 방식으로 간단하게 설계를 했다.
- 사용자가 좌석을 선점하면 Redis에 seat-hold:{sessionId}:{seatId}라는 키를 생성
- 이 키에 사용자 ID를 저장하고, TTL(Time To Live)을 5분 설정
- 동시에, Redisson을 사용해 좌석 락(lock) 을 걸어 선점 중복을 방지
-> Redisson 락 획득
-> Redis에 userid 저장
-> Redis에 5분 TTL 설정
-> 락 해제
이렇게 구성하면 "5분 내에 결제를 완료하지 못하면 좌석이 자동으로 풀리겠지"라고 단순히 생각했다. 하지만...
2. 첫 번째 문제 : 락(Lock)과 TTL 시간의 불일치로 인한 동시성 문제
- 문제 발생
- Redisson락은 10초 동안만 유지되는데
- 좌석 hold 상태는 Redis TTL 때문에 5분 동안 유지된다.
이 결과 락은 먼저 풀리고 좌석은 여전히 HOLD인 상태가 되어버려서 다른 사용자가 같은 좌석을 선점하려고 해도 AVAILABLE 상태가 아니라 선점 실패하는 문제가 발생하였다.
- 현상
- 락은 풀렸지만 좌석 상태는 여전히 HOLD
- 새로운 사용자는 "이미 선택된 좌석입니다." 에러 발생
- 심지어 실제로는 이미 다른 사용자는 결제를 포기하고 나간 상태
- 해결 방법
- 락은 짧게 : 10초 (임계 영역 보호용)
- 좌석 점유는 TTL : 5분(좌석 점유 시간 관리용)
- 역할을 명확히 분리
boolean locked = lock.tryLock(2, 10, TimeUnit.SECONDS); // 락: 10초
redisTemplate.opsForValue().set(holdKey, userId.toString(), Duration.ofMinutes(5)); // TTL: 5분
3. 두 번째 문제 : TTL 만료 후 userId 조회 실패
- 문제 발생
좌석을 선점한 후 5분이 지나면 TTL이 만료되어 Redis키가 사라진다. 그런데 스케쥴러에서 TTL 만료를 감지하고 "userId"를 이용하여 Kafka로 만료 이벤트를 발행하려고 할 때, 이미 키가 사라져있어서 userId를 조회할 수 없는 문제가 발생한다.
- 현상
- 스케쥴러가 holdKey에 접근을 했지만, TTL 만료키가 삭제되어 있어 null 반환
- 누가 좌석을 선점했는지 알 수 없음
- Kafka로 정상적인 만료 이벤트 발행 불가
- 원인
- Redis TTL 은 "시간이 지나면 자동 삭제"만 제공할 뿐 삭제 직전에 콜백 같은 개념은 없다.
- 키 만료가 "데이터 삭제"와 동시에 발생하는 Redis 특성 미이해
- 키가 지워진 후에는 데이터를 복구할 방법이 없다.
- 해결 방법
- TTL 만료 전에 userId를 미리 조회해서 메모리에 보관
- 이후 키 존재 여부를 체크하고 만료 처리를 진행
String userIdStr = redisTemplate.opsForValue().get(holdKey);
Boolean stillExists = redisTemplate.hasKey(holdKey);
if (Boolean.FALSE.equals(stillExists)) {
redisTemplate.opsForHash().put(redisKey, seatIdStr, "AVAILABLE");
if (userIdStr != null) {
seatEventProducer.sendSeatExpired(List.of(seatId), UUID.fromString(userIdStr));
}
}
4. 세 번째 문제 : TTL + 스케줄러 기반 처리의 근본적인 한계
앞서 나는 좌석 선점 기능을 설계할 때, Redis TTL 만료와 스케줄러를 결합해서 좌석만료를 감지하려고 했다.
- 좌석 선점 시 Redis에 holdKey 저장 + TTL(5분) 설정
- 5분마다 스케줄러가 Redis를 순회하며 만료 여부 체크
처음에는 이런 흐름이 잘 동작하는 것 같았지만 심각한 문제를 발견했다.
- 문제 : 스케쥴러 주기 vs TTL 만료 타이밍 간의 간극
Redis TTL은 "초 단위"로 만료되고 스케줄러는 보통 "수초~수분 간격"으로 실행된다.
무슨 의미냐하면 좌석 TTL이 5분으로 설정되어 있어도 스케쥴러는 5분 간격으로만 체크하기 때문에 TTL이 정확히 만료된 시점과 스케쥴러 실행 시점 사이에 최대 5분 가까운 오차가 발생할 수 있다는 말이다.
예를 들어, 사용자가 13: 00 : 00에 좌석을 선점했다고 하자 그러면 TTL 설정은 5분이기 때문에 13: 05 : 00에 만료될 예정이다.
그러고 스케쥴러는 매 5분에 한 번 실행하기 때문에 13:00, 13:05, 13:10 ...이렇게 동작한다고 하자 하지만 만약 스케쥴러가 13:05:01에 실행된다면? 이미 TTL은 만료가 됐지만 최대 5초 이상 차이가 발생한다.
즉, TTL 만료 후 이벤트 발행이 지연될 수 있고 심지어 아직 만료되지 않은 좌석을 조기에 복구해버릴 위험도 있다.
그래서 TTL 대신 expireAt 방식을 도입하기로 결정했다.
5. 왜 expireAt 방식이 필요한가?
- 기존 TTL 기반 문제
- TTL이 만료되는 정확한 시점을 알 수 없다.
- TTL 자체가 삭제만 처리할 뿐, 언제 만료될 것이라는 정보를 보관하지 않는다.
- 스케쥴러가 "지금 만료됐는지" 정확히 판단할 근거가 없다.
- expireAt 개선 방식
- 선점 시점에 정확한 만료 예상 시간 (expireAt)을 Redis에 기록
- 스케쥴러가 매번 현재 시간과 expireAt을 비교해 "만료 여부"를 직접 판단
- TTL 의존 없이 자체적으로 명확한 만료 시점 제어 가능
// 좌석 선점 시
long expireAtMillis = System.currentTimeMillis() + Duration.ofMinutes(5).toMillis();
redisTemplate.opsForValue().set(holdKey, String.valueOf(expireAtMillis));
// 스케줄러
String expireAtStr = redisTemplate.opsForValue().get(holdKey);
long expireAt = Long.parseLong(expireAtStr);
if (System.currentTimeMillis() > expireAt) {
// 정확히 만료된 좌석만 복구
}
- 정리
- TTL 은 삭제만 담당, 만료 감지는 별도로 직접 해야 한다.
- 락과 점유 시간 관리(TTL)은 완전히 다른 책임이다.
- 만료 이벤트 트리거가 필요한 경우에는 TTL 대신 expireAt 기반 관리를 도입하자
- 비즈니스 이벤트 발행은 키 삭제가 아닌 시스템 시간 기반으로 제어해야한다.
- 추가로
- 이 개선으로 만료된 좌석의 복구 정확도가 엄청나게 올라갔다.
- Kafka 이벤트 발행도 완벽하게 연계할 수 있었다.
- 나중에 장애나 이슈가 발생했을 때도, 문제 원인을 정확히 추적할 수 있게 되었다.
'DBMS > Redis' 카테고리의 다른 글
[Spring Boot + Redis 캐싱] 좌석 목록 조회 캐싱 리팩토링 & 역직렬화 이슈 해결기 (1) | 2025.04.18 |
---|---|
[Redis] redis를 Spring Boot에 추가 (0) | 2025.02.14 |
[Redis] Redis 캐싱 전략 (0) | 2025.01.14 |
[Redis] 기본 명령어 (0) | 2025.01.14 |
[Redis] Redis란?, 설치 (0) | 2025.01.07 |