이전에 야구 예매 사이트를 구현하면서 좌석 선점 및 결제 로직을 구현했었습니다. Redis를 처음 사용해보기도 했고, 큰 프로젝트가 처음이라 엄청나게 고생했던 기억이 납니다... 그래서 복습 및 회고의 개념으로 제가 좌석 선점 로직을 어떻게 구현했는지에 대해 작성해보려고 합니다..!
목표 및 기본 세팅
저는 일단 목표를 다음과 같이 설정했습니다.
- 동시 클릭에도 오버셀이나 이중 선점 없이 10분 동안 좌석을 임시 선점
- 결제 성공 시 영구 점유
- 결제 실패/이탈 시 자동 반환
또한 구역은
1. 일반 구역 ex) A구역 12번
2. 외야석 (자동배정좌석)
이렇게 두 가지로 구성했습니다.
공통 구조/흐름
공통적으로 적용한 구조입니다.
좌석 선점
| 클라이언트가 좌석 선택 ↓ [Redisson 분산락] 좌석 잠금 ↓ [Redis] preempt:* 키 등록으로10분 선점 ↓ [Redisson 분산락] 좌석 잠금 해제 |
좌석 결제
| 결제 성공 | 미결제/취소/이탈 |
| [Redisson 분산락] 좌석 잠금 ↓ [Redis] 선점 키 삭제, paid:* 키 등록으로 영구 점유 ↓ DB 반영 |
[Redis Key 만료] ↓ [RedisExpirationListener] DB 선점 레코드 삭제 |
Redis Key 네이밍 컨벤션
다음은 Redis Key 네이밍 컨벤션입니다.
| 좌석별 락 | 자동배정: lock:seat:{gamePk}:{zonePk}:auto 일반구역: lock:seat:{gamePk}:{zonePk}:{seatNum} |
| 선점 키(10 min TTL) | 자동배정: preempt:seat:{gamePk}:{zonePk}:auto:{userPk} 일반구역: preempt:seat:{gamePk}:{zonePk}:{seatNum} |
| 결제 완료 키 (영구, TTL X) | 자동배정: 존재 X 일반구역: paid:seat:{gamePk}:{zonePk}:{seatNum} |

Redisson Lock
단일 좌석 락
- getLockKey(gamePk, seatNum, zonePk) 메서드로 좌석 별 락 키 생성
- tryLock(3, 10, TimeUnit.SECONDS)로 락 획득
- 처리 후 락 해제: isHeldByCurrentThread() 확인 후 unlock()
다중 좌석 락
- getMultiLock() 사용해 여러 좌석을 원자적으로 lock
- 하나라도 실패하면 전체 작업을 실패 처리
- 성공/실패 후 finally에서 잡아서 락 일괄 해제
| 외야석(자동배정) | 일반 구역 |
| 단일 좌석 락 | 다중 좌석 락 |
| 예를 들어 좌석이 1개만 남았을 때 여러 사용자가 동시에 자동 배정을 요청하는 경우, 남은 좌석 수보다 많은 예약이 발생하는 것을 막기 위해 전체 자동 배정 영역에 대한 락 설정. | 같은 좌석을 동시에 여러 사용자가 선택할 수 없도록 하기 위해 좌석 단위로 락 설정. |
getLockKey 메서드
/* lock key 얻기
* 자동배정: lock:seat:{gamePk}:{zonePk}:auto
* 일반구역: lock:seat:{gamePk}:{zonePk}:{seatNum}
*/
public String getLockKey(int gamePk, String seatNum, int zonePk) {
StringBuilder sb = new StringBuilder("lock:seat:" + gamePk + ":" + zonePk + ":");
if(seatNum == null || seatNum.isEmpty()){
sb.append("auto");
} else {
sb.append(seatNum);
}
return sb.toString();
}
단일 좌석 락/언락
/* lock 얻기 - 자동배정 */
public boolean tryLockSeat(int gamePk, String seatNum, int zonePk) {
//락 키 생성
String lockKey = getLockKey(gamePk, seatNum, zonePk);
//락 생성
RLock lock = redissonClient.getLock(lockKey);
//락 시도
try {
if(lock.tryLock(3, 10, TimeUnit.SECONDS)) {
//락 시도 성공 시
log.info("락 획득: {}, thread: {}", lockKey, Thread.currentThread().getId());
return true;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
//락 시도 실패 시
return false;
}
/*lock 해제 - 자동배정 */
public void unlockSeat(int gamePk, String seatNum, int zonePk) {
//락 키 생성
String lockKey = getLockKey(gamePk, seatNum, zonePk);
RLock lock = redissonClient.getLock(lockKey);
//락 해제
try {
log.warn("락 해제 시도 thread: {}", Thread.currentThread().getId());
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
} catch (IllegalMonitorStateException e) {
log.info("락 해제 실패: 락 소유자가 아님", e);
}
}
선점 로직

외야석(자동 배정 좌석) 선점 - 단일 락 이용
1. 단일 락 이용해 lock 잡기
2. 이전에 선점한 기록이 있는지 확인 - 세션 스토리지와 Redis 간 불일치 확인
3. DB 탐색해 남은 외야석 좌석 개수 확인
4. 조건 충족 시 DB 상 선점 + Redis 선점
5. 락 해제
/* 외야석 선점 */
@Transactional
public PreemptionResDTO getBleachers(PreemptionDTO preemptionDTO, UserDTO userDTO, PreemptionResDTO result, String reserveCode) {
int zonePk = preemptionDTO.getZonePk();
int gamePk = preemptionDTO.getGamePk();
int quantity = preemptionDTO.getQuantity();
List<String> seats = preemptionDTO.getSeats();
int userPk = userDTO.getUserPk();
try {
//락 잡기
if (!redisService.tryLockSeat(gamePk, null, zonePk)) {
result.setPreempted(0);
result.setErrorMsg("이미 선점된 좌석입니다.");
return result;
}
//이전 기록이 있는지 확인
if(redisService.confirmPreempt(gamePk, null, userPk, zonePk)) {
result.setPreempted(0);
result.setErrorMsg("이미 선점된 좌석입니다.");
return result;
}
//DB 탐색해 남은 좌석 개수 판단
SoldSeatsReqDTO soldSeatsReqDTO = new SoldSeatsReqDTO(gamePk, zonePk);
List<String> soldSeats = getSoldSeats(soldSeatsReqDTO); //남은 좌석 정보
ZoneDTO zoneDTO = getZoneInfo(preemptionDTO.getZonePk()); //구역 정보
int remainingSeats = zoneDTO.getTotalNum() - soldSeats.size(); //남은 좌석 수
if (remainingSeats < quantity) {
//좌석 수가 충분하지 않다면
result.setPreempted(2);
result.setErrorMsg("좌석 수가 충분하지 않습니다.");
return result;
}
result.setPreempted(1);
//DB상 선점
PreemptionListDTO preemptionListDTO = PreemptionListDTO
.builder()
.quantity(quantity)
.userPk(userPk)
.gamePk(gamePk)
.reserveCode(reserveCode)
.build();
reservationMapper.setPreemptionList(preemptionListDTO);
int reservelistPk = preemptionListDTO.getReservelistPk();
result.setReservelistPk(reservelistPk);
for (String seatNum : preemptionDTO.getSeats()) {
PreemptionReserveDTO preemptionReserveDTO = PreemptionReserveDTO
.builder()
.reservelistPk(reservelistPk)
.zonePk(zonePk)
.seatNum(seatNum)
.build();
reservationMapper.setPreemptionReserve(preemptionReserveDTO);
}
//Redis에 선점 정보 등록
if (!redisService.getBleachers(gamePk, userPk, zonePk)) {
//롤백
deletePreemptionInfo(reservelistPk);
result.setPreempted(0);
result.setErrorMsg("이미 선점된 좌석입니다.");
return result;
}
} finally {
//락 해제
redisService.unlockSeat(gamePk, null, zonePk);
}
return result;
}
일반 구역 선점
1. Redis 상 해당 좌석이 선점 또는 판매되었는지 여부 확인
2. DB 선점
3. 유저가 선택한 모든 좌석을 멀티락으로 모두 잠금
4. 좌석마다 선점 키를 등록, 중간 오류 발생 시 롤백(해당 메서드에서 생성한 모든 선점 키 삭제, DB 등록 삭제 )
3. 완료 후 락 해제
/* 일반 구역 선점 */
@Transactional
public PreemptionResDTO getPreempt(PreemptionDTO preemptionDTO, UserDTO userDTO, PreemptionResDTO result, String reserveCode) {
int zonePk = preemptionDTO.getZonePk();
int gamePk = preemptionDTO.getGamePk();
//Redis상 선점/판매 여부 확인
for(String seatNum : preemptionDTO.getSeats()) {
int check = reservationMapper.confirmPreemption(zonePk, seatNum, gamePk);
if(check >= 1) {
result.setPreempted(0);
result.setErrorMsg("이미 선점된 좌석입니다.");
return result;
}
}
result.setPreempted(1);
//선점/판매가 되지 않았다면 DB 상 선점
PreemptionListDTO preemptionListDTO = PreemptionListDTO
.builder()
.quantity(preemptionDTO.getQuantity())
.userPk(userDTO.getUserPk())
.gamePk(preemptionDTO.getGamePk())
.reserveCode(reserveCode)
.build();
reservationMapper.setPreemptionList(preemptionListDTO);
int reservelistPk = preemptionListDTO.getReservelistPk();
result.setReservelistPk(reservelistPk);
for(String seatNum : preemptionDTO.getSeats()) {
PreemptionReserveDTO preemptionReserveDTO = PreemptionReserveDTO
.builder()
.reservelistPk(reservelistPk)
.zonePk(preemptionDTO.getZonePk())
.seatNum(seatNum)
.build();
reservationMapper.setPreemptionReserve(preemptionReserveDTO);
}
//Redis 상 선점
if(!redisService.preemptSeat(gamePk, preemptionDTO.getSeats(), userDTO.getUserPk(), zonePk)) {
//redis 선점 실패 시 DB 롤백
deletePreemptionInfo(reservelistPk);
result.setPreempted(0);
result.setErrorMsg("이미 선점된 좌석입니다.");
return result;
}
return result;
}
/* 일반 구역 선점 등록 함수(TTL: 10M): lock 획득 -> 선점 등록 */
public boolean preemptSeat(int gamePk, List<String> seats, int userPk, int zonePk) {
List<RLock> locks = new ArrayList<>();
List<String> preemptedKeys = new ArrayList<>();
try {
//락 리스트 생성
for(String seatNum: seats) {
RLock lock = redissonClient.getLock(getLockKey(gamePk, seatNum, zonePk));
locks.add(lock);
}
//Multilock 획득 시도
RLock multiLock = redissonClient.getMultiLock(locks.toArray(new RLock[0]));
if(!multiLock.tryLock(3, 10, TimeUnit.SECONDS)) {
//락 획득 실패
return false;
}
//모든 좌석에 대해 선점 등록
for(String seatNum: seats) {
String preemptKey = getPreemptkey(gamePk, seatNum, zonePk, userPk);
redisTemplate.opsForValue().setIfAbsent(preemptKey, String.valueOf(userPk), PREEMPT_TTL, TimeUnit.MINUTES); //todo: minute으로 변경
preemptedKeys.add(preemptKey); //나중에 실패 시 롤백용
}
return true;
} catch (Exception e) {
//롤백
for(String key: preemptedKeys) {
redisTemplate.delete(key);
}
return false;
} finally {
//락 해제
for(RLock lock: locks) {
if (lock.isHeldByCurrentThread()) {
try {
lock.unlock();
} catch (Exception ignore) {
}
}
}
}
}
일괄 선점 처리 함수
해당 함수에서 외야석인지 일반 구역 좌석인지 확인 후 선점합니다.
@Transactional
public PreemptionResDTO preemptSeat(PreemptionDTO preemptionDTO, UserDTO user) {
PreemptionResDTO result = new PreemptionResDTO();
int zonePk = preemptionDTO.getZonePk();
//Redis에 선점 정보 넣기 (int gamePk, List<String> seats, int userPk)
if(zonePk == 1101 || zonePk == 1100) {
//외야석이라면
result = getBleachers(preemptionDTO, user, result, createReserveCode());
} else {
//그 외 구역이라면
result = getPreempt(preemptionDTO, user, result, createReserveCode());
}
return result;
}
여기서 아쉬운 점은
1. 일반 구역 선점 시 락을 DB 등록 이후에 잡는데 DB 등록 전에 잡으면 롤백 처리가 더욱 쉽지 않았을까 하는 아쉬움이 있습니다.
2. 구현할 때는 몰랐는데 일반 구역 선점 키 등록 함수와 일괄 선점 처리 함수 이름이 동일하다는 점입니다. 이건 다시 보면 너무너무너무 부끄러운 코드입니다... 😂
결제 로직
결제 처리
1. 멀티락 잡기
2. 각 좌석에 대해 내 선점인지 검증: preempt:* 값이 userPk와 일치하는지 여부 확인
3. 선점 키 삭제 후 결제 키 등록
4. 락 해제
/* 타 구역에 대한 선점 해제 및 결제완료 등록(TTL X) -> preempt:seat:{gamePk}:{zonePk}:{seatNum} / paid:seat:{gamePk}:{zonePk}:{seatNum} */
public boolean confirmPayment(int gamePk, List<String> seats, int userPk, int zonePk) {
List<RLock> locks = new ArrayList<>();
List<String> paidKeys = new ArrayList<>();
List<String> preemptKeys = new ArrayList<>();
try {
//락 리스트 생성
for (String seatNum : seats) {
RLock lock = redissonClient.getLock(getLockKey(gamePk, seatNum, zonePk));
locks.add(lock);
}
//Multilock 획득 시도
RLock multiLock = redissonClient.getMultiLock(locks.toArray(new RLock[0]));
//락 획득 실패 시
if (!multiLock.tryLock(3, 30, TimeUnit.SECONDS)) {
return false;
}
//선점 키 삭제 -> 판매 키 등록
for (String seatNum : seats) {
//내 선점 자리인지 확인
String preemptKey = getPreemptkey(gamePk, seatNum, zonePk, userPk);
String preemptUser = redisTemplate.opsForValue().get(preemptKey);
preemptKeys.add(preemptKey);
if (!String.valueOf(userPk).equals(preemptUser)) {
throw new Exception("이미 선점된 좌석입니다.");
}
redisTemplate.delete(preemptKey);
//이미 팔린 좌석인지 확인
String paidKey = getPaidKey(gamePk, seatNum, zonePk, userPk);
if(redisTemplate.hasKey(paidKey)) {
throw new Exception("이미 선점된 좌석입니다");
}
redisTemplate.opsForValue().setIfAbsent(paidKey, String.valueOf(userPk));
paidKeys.add(paidKey); //나중에 실패 시 롤백 용
}
return true;
} catch (Exception e) {
//롤백
for (String key : paidKeys) {
redisTemplate.delete(key);
}
//선점 롤백
for (String key : preemptKeys) {
redisTemplate.opsForValue().setIfAbsent(key, String.valueOf(userPk), PREEMPT_TTL, TimeUnit.MINUTES);
}
return false;
} finally {
//락 해제
for (RLock lock : locks) {
if (lock.isHeldByCurrentThread()) {
try {
lock.unlock();
} catch (Exception ignore) {
}
}
}
}
}
결제 취소
1. 멀티락 잡기
2. 각 좌석의 결제 키가 내 소유인지 확인
3. 취소
4. 락 해제
/* 결제 취소 */
public boolean cancelPayment(int gamePk, List<String> seats, int userPk, int zonePk) {
log.info("[cancelPayment] 시작");
if(seats == null || seats.isEmpty()) {
return true;
}
List<RLock> locks = new ArrayList<>();
List<String> paidKeys = new ArrayList<>(); //롤백용
try {
// 락 리스트 생성
for (String seatNum : seats) {
RLock lock = redissonClient.getLock(getLockKey(gamePk, seatNum, zonePk));
locks.add(lock);
}
// MultiLock 획득 시도
RLock multiLock = redissonClient.getMultiLock(locks.toArray(new RLock[0]));
if (!multiLock.tryLock(3, 10, TimeUnit.SECONDS)) {
return false;
}
// paid 키가 본인 것인지 확인 후 삭제
for (String seatNum : seats) {
String paidKey = getPaidKey(gamePk, seatNum, zonePk, userPk);
String owner = redisTemplate.opsForValue().get(paidKey);
if (String.valueOf(userPk).equals(owner)) {
redisTemplate.delete(paidKey);
paidKeys.add(paidKey);
} else {
// 내 결제가 아닌 경우 전체 실패 처리
throw new Exception("결제 취소에 실패하였습니다.");
}
}
return true;
} catch (Exception e) {
e.printStackTrace();
//paid 키 롤백
for (String key : paidKeys) {
redisTemplate.opsForValue().setIfAbsent(key, String.valueOf(userPk));
}
return false;
} finally {
// 락 해제
for (RLock lock : locks) {
if (lock.isHeldByCurrentThread()) {
try {
lock.unlock();
} catch (Exception ignore) {
}
}
}
}
}
결제 실패 복구
결제 실패 시 특정 단계에서 영구 점유 키를 되돌리는 복구 절차입니다.
1. 멀티락 잡기
2. paid:* 키를 다시 세팅(롤백 리스트 활용)
/* 결제 실패 시 복구 */
public boolean restorePayment(int gamePk, List<String> seats, int userPk, int zonePk) {
if(seats == null || seats.isEmpty()) {
System.out.println(gamePk + zonePk + userPk);
return true;
}
List<RLock> locks = new ArrayList<>();
List<String> paidKeys = new ArrayList<>();
try {
//락 리스트 생성
for(String seatNum: seats) {
RLock lock = redissonClient.getLock(getLockKey(gamePk, seatNum, zonePk));
locks.add(lock);
}
//Multilock 획득 시도
RLock multiLock = redissonClient.getMultiLock(locks.toArray(new RLock[0]));
if(!multiLock.tryLock(3, 10, TimeUnit.SECONDS)) {
//락 획득 실패
return false;
}
//모든 좌석에 대해 선점 등록
for(String seatNum: seats) {
String paidKey = getPaidKey(gamePk, seatNum, zonePk, userPk);
redisTemplate.opsForValue().setIfAbsent(paidKey, String.valueOf(userPk));
paidKeys.add(paidKey); //나중에 실패 시 롤백용
}
return true;
} catch (Exception e) {
//롤백
for(String key: paidKeys) {
redisTemplate.delete(key);
}
return false;
} finally {
//락 해제
for(RLock lock: locks) {
if (lock.isHeldByCurrentThread()) {
try {
lock.unlock();
} catch (Exception ignore) {
}
}
}
}
}
좌석 선점 만료 처리(TTL 만료 처리, 자동 반환)
Redis의 키 만료 이벤트를 구독해 선점 키 만료를 감지합니다. 키를 파싱해서 게임 고유 번호, 구역 번호, 좌석 번호, 유저 번호를 식별한 뒤 DB에 선점 레코드를 삭제합니다.
@Slf4j
public class RedisExpirationListener extends KeyExpirationEventMessageListener {
@Autowired
ReservationMapper reservationMapper;
public RedisExpirationListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}
@Override
public void onMessage(Message message, byte[] pattern) {
String expiredKey = message.toString();
log.info("만료된 Redis 키 감지: {}", expiredKey);
if (expiredKey.startsWith("preempt:seat")) {
String[] parts = expiredKey.split(":");
int gamePk = Integer.parseInt(parts[2]);
String seatNum = parts[4];
int zonePk = Integer.parseInt(parts[3]);
Integer reservelistPk = null;
if(parts.length == 6) {
//자동 배정(gamePk, userPk로 삭제)
int userPk = Integer.parseInt(parts[5]);
//로컬 DB 삭제 로직
reservelistPk = reservationMapper.getReservelistPkAuto(gamePk, userPk, zonePk);
log.info("리스너-reservelistPk (type: {}): {}",
(reservelistPk == null ? "null" : reservelistPk.getClass().getSimpleName()),
reservelistPk);
if(reservelistPk == null) {
log.warn("해당 예약 없음: 자동배정");
return;
}
reservationMapper.deletePreemptionReserve(reservelistPk);
reservationMapper.deletePreemptionList(reservelistPk);
} else if(parts.length == 5) {
//그 외 구역
//로컬 DB 삭제 로직
reservelistPk = reservationMapper.getReservelistPk(gamePk, seatNum, zonePk);
log.info("리스너-reservelistPk (type: {}): {}",
(reservelistPk == null ? "null" : reservelistPk.getClass().getSimpleName()),
reservelistPk);
if(reservelistPk == null) {
log.warn("해당 예약 없음: 자동배정X");
return;
}
reservationMapper.deletePreemptionReserve(reservelistPk);
reservationMapper.deletePreemptionList(reservelistPk);
}
log.info("gamePk={" + gamePk + "} seat={" + seatNum + "} reservelistPk={" + reservelistPk + "} 선점 정보 DB에서 제거");
}
}
}
돌아보면
돌아보면, 선점 로직이나 결제 로직을 구현하기 전에 충분히 구상하고 설계했더라면 지금처럼 스파게티 코드가 되지는 않았을 것 같습니다. 시간에 쫓기다 보니 곧바로 구현부터 시작했고, 예외나 롤백 같은 중요한 부분을 고려하지 않은 채 진행하여 여러 문제들이 발생했습니다.
앞으로는 기능 구현에 앞서 흐름을 설계하고, 예외 상황과 롤백 전략까지 미리 고민하는 습관을 들이려고 합니다. 짧은 시간 안에 코드를 완성하는 것보다, 유지보수와 확장까지 고려한 구조적인 코드를 작성하는 것이 결국 더 효율적이라는 것을 이번 경험을 통해 배웠습니다!
'자바 > 스프링부트' 카테고리의 다른 글
| Jenkins를 이용해 스프링 부트 프로젝트 CI/CD 파이프 라인 구축하기 (0) | 2025.11.20 |
|---|---|
| [Java / Spring] Redis 이용한 대기열 시스템 구현 (0) | 2025.10.27 |
| [JAVA / SSE] 스프링부트에서 SSE 구현하기 (SSE + Redis pub/sub) - 2 (0) | 2025.10.21 |
| [JAVA / 스프링 부트] 커스텀 필터 & AuthorizationManager로 그룹별 API 접근 제어하기 (1) | 2025.10.21 |
| [JAVA / 스프링부트] UTC 저장 + KST 응답 구조로 타임존 문제 해결하기 (Spring Boot + MariaDB) (0) | 2025.10.20 |