티켓팅 시스템에서는 좌석이라는 고유 자원을 여러 사용자가 동시에 접근할 때 중복 예약을 방지해야 합니다. 이를 해결하기 위해 다음과 같은 요구 사항을 고려해야 합니다:
Redis는 빠른 데이터 접근 속도와 분산락 기능을 제공하며, Redisson은 Redis의 Pub/Sub 기능을 활용해 락 점유 재시도를 효율적으로 처리합니다.
@DistributedLock
어노테이션특정 메서드에 대해 분산락을 적용할 수 있도록 어노테이션을 정의합니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
/**
* 락의 이름
*/
String key();
/**
* 락 임대 시간 (default - 8m)
* 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
*/
long leaseTime() default 480L;
/**
* 락을 기다리는 시간 (default - 3s)
* 락 획득을 위해 waitTime 만큼 대기한다
*/
long waitTime() default 3L;
/**
* 락의 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
Redisson을 활용하여 락 점유 및 해제 로직을 AOP로 구현합니다.
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
String lockKey = (String) CustomSpringELParser
.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(lockKey);
try {
boolean acquired = rLock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit()
);
if (!acquired) {
throw new BusinessException(ErrorCode.FAILED_TRY_ROCK);
}
log.info("락 키 {} 획득 성공", lockKey);
return aopForTransaction.proceed(joinPoint);
} catch (BusinessException e) {
if (rLock.isHeldByCurrentThread()) rLock.unlock();
throw new BusinessException(ErrorCode.FAILED_DURING_TRANSACTION);
}
}
}
스프링 EL 표현식을 사용하여 동적으로 락 키를 생성하는 커스텀 파서입니다.
@Slf4j
@NoArgsConstructor
public class CustomSpringELParser {
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
return parser.parseExpression(key).getValue(context, Object.class);
}
}
좌석 선점 비즈니스 로직은 PreoccupyService
에 정의됩니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class PreoccupyService {
private final RedissonClient redissonClient;
private final PreoccupyInternalService preoccupyInternalService;
@Transactional
public void lockSeat(SelectSeat request, Long userId) {
executeWithRateLimiter(
rateLimiterRegistry,
"preoccupySeatLimiter",
() -> {
preoccupySeat(request, userId);
return null; // Supplier<T>이므로 Void를 처리하기 위해 null 반환
}
);
}
public void preoccupySeat(SelectSeat request, Long userId) {
UserSimpleDto user = CircuitBreakerUtils.executeWithCircuitBreaker(circuitBreakerRegistry,
"getUserCircuitBreaker",
() -> userServiceClient.getUser(userId)
);
log.info("요청한 유저의 ID: {}", user.getUserId());
List<SelectSeatInfo> selectSeatInfoList = request.getSelectSeatInfoList();
Set<Long> seatMappingIds = selectSeatInfoList.stream().map(SelectSeatInfo::getSeatMappingId).collect(Collectors.toSet());
Long eventScheduleId = request.getEventScheduleId();
// 해당 유저가 이미 예약한 좌석이 있는지 확인
ensureUserHasNoSelectedSeats(eventScheduleId, user.getUserId());
// 요청된 좌석 수와 예약 제한을 검증
Integer reservationLimit = CircuitBreakerUtils.executeWithCircuitBreaker(
circuitBreakerRegistry,
"enterTicketingCircuitBreaker",
() -> ticketingServiceClient.enterTicketing(String.valueOf(user.getUserId()), eventScheduleId)
);
validateSeatCount(seatMappingIds, reservationLimit);
// 요청 좌석 중 이미 선점된 좌석이 있는지 확인
validateSeatsAvailability(eventScheduleId, seatMappingIds);
// 각 좌석을 사용자에 대해 잠금 처리
selectSeatInfoList.forEach(selectSeatInfo -> lockSeat(eventScheduleId, user.getUserId(), selectSeatInfo.getSeatMappingId(), selectSeatInfo.getSeatGrade(), selectSeatInfo.getSeatPrice()));
}
private void lockSeat(Long eventScheduleId, Long userId, Long seatMappingId, String seatGrade, BigDecimal seatPrice) {
String lockKey = RedisKeyHelper.getLockKey(eventScheduleId, seatMappingId); // 키 생성
preoccupyInternalService.lockSeat(lockKey, userId, seatMappingId, eventScheduleId, seatGrade, seatPrice);
log.info("좌석 {}가 사용자 {}에 의해 선점되었습니다. (등급: {}, 가격: {})", seatMappingId, userId, seatGrade, seatPrice);
}
}
AOP 내부 호출 문제를 방지하기 위해 로직을 PreoccupyInternalService
로 분리합니다.
@Service
@RequiredArgsConstructor
public class PreoccupyInternalService {
private final RedissonClient redissonClient;
/**
* 좌석에 대해 분산 락을 획득하고 선점 처리
*
* @param lockName 락 키 (Redis에서 사용할 고유 락 이름)
* @param userId 좌석을 선점하는 사용자 ID
* @param seatMappingId 좌석 매핑 ID
* @param eventScheduleId 이벤트 일정 ID
*/
@DistributedLock(key = "#lockName")
public void lockSeat(String lockName, Long userId, Long seatMappingId, Long eventScheduleId, String seatGrade, BigDecimal seatPrice) {
log.info("lockName: {}", lockName);
holdSeat(eventScheduleId, userId, seatMappingId, seatGrade, seatPrice);
}
/**
* Redis에 사용자 및 좌석 정보를 저장하여 좌석 선점 처리
*
* @param eventScheduleId 이벤트 일정 ID
* @param userId 좌석을 선점하는 사용자 ID
* @param seatMappingId 좌석 매핑 ID
*/
public void holdSeat(Long eventScheduleId, Long userId, Long seatMappingId, String seatGrade, BigDecimal seatPrice) {
String seatKey = RedisKeyHelper.getSeatKey(eventScheduleId);
String userKey = RedisKeyHelper.getUserKey(userId);
try {
// 좌석 상태 저장
RMap<String, String> seatStates = redissonClient.getMap(seatKey);
String seatField = "seat_" + seatMappingId;
String seatInfo = createSeatInfoJson(userId, seatGrade, seatPrice);
seatStates.put(seatField, seatInfo);
// 사용자 예약 정보 병합
RMap<String, String> userEvents = redissonClient.getMap(userKey);
String eventField = "event_" + eventScheduleId;
userEvents.merge(eventField, "[" + seatMappingId + "]", (oldValue, newValue) -> mergeSeatIds(oldValue, seatMappingId));
// TTL 설정
setTTL(seatKey, userKey);
} catch (Exception e) {
log.error("Redis 작업 중 오류 발생 (eventScheduleId: {}, userId: {}, seatMappingId: {})", eventScheduleId, userId, seatMappingId, e);
throw new RuntimeException("좌석 선점 처리 중 오류가 발생했습니다.", e);
}
}
}
1000명의 사용자가 동시에 하나의 좌석을 요청하는 상황을 테스트합니다.
@Test
@DisplayName("1000명의 사용자가 동일한 좌석 예약 - 동시성 테스트")
void testMultiReserve() throws InterruptedException {
int threadNum = 100;
CountDownLatch latch = new CountDownLatch(threadNum);
AtomicInteger successCounter = new AtomicInteger(0);
ExecutorService executorService = Executors.newFixedThreadPool(threadNum);
for (long i = 1L; i <= threadNum; i++) {
Long userId = i;
executorService.submit(() -> {
try {
preoccupyService.preoccupySeat(request, userId);
successCounter.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
executorService.shutdown();
assertEquals(1, successCounter.get(), "한 명만 예약에 성공해야 합니다.");
}
Reference