Redis 를 활용한 티켓 예매 동시성 제어와 성능 최적화 경험기(분산락 X)

은찬·2024년 1월 14일

Redis

목록 보기
1/1
post-thumbnail

배경


티켓팅 서비스 프로젝트를 진행하며 티켓팅 서비스에서 가장 중요한 것은 예매

처음엔 다른 팀원이 해당 기능을 맡으며 분산락 으로 구현했다.

그래서 분산락 을 구현된 코드는 동시성도 잡히고 문제없이 잘 돌아갔지만 나는 계속 아쉬움이 있었다.

우리 프로젝트의 타겟으로 잡은 서비스가 인터파크인데, 인터파크 티켓팅을 보면 진짜 살벌하다… 즉 티켓 예매는 정말 많은 트래픽이 몰린다.

그래서 더 좋은 성능의 예매 기능을 만들어보고 싶었고, 그 여정을 소개해보자 한다! 👊

전략


사실 전략이 제일 어려웠다. 분산 환경에서도 동시성을 보장해줘야했고, 분산락보다 성능이 좋아야했다.

정말 하루넘게 고민하다가 다른 동료가 Redis 로 스핀락을 구현하는걸 보고 아이디어가 하나 떠올랐다.

Redis 로 구현한 낙관적(?)락

낙관적락이라고 했는데, 의미상 낙관적락의 역할을 한다고 생각해서 이렇게 이름 지었다. 앞으로도 낙관적 락이라고 종종 언급할 예정이니 햇갈리지 말자!! 👊

전략은 아래와 같다

Redis Key 체크를 두번 진행하는걸 볼 수 있는데, 이유가 있다.

메소드 시작 시 Redis Key 체크 & Key 세팅

처음 전략을 짰을 때는 메소드 진입 시 Redis Key 체크 & Key 세팅을 수행했다. 근데 이런 경우를 생각해보자.

1번 요청이 먼저 진입하고 key 세팅까지 수행한 후 DB 업데이트를 하는데 예외가 발생한 경우, 예외처리를 해서 Key 를 다시 해제한다고 해도 그 잠깐 사이에 1번 요청이 세팅한 Key 때문에 예외처리되는 요청들이 생길 수 있다.

메소드 종료 시 Redis Key 체크 & Key 세팅

그리고 다음에 생각한게 메소드 종료 시에 Redis Key 체크 & Key 세팅을 수행하는 것이다. 그러면 DB 업데이트까지 성공적으로 수행된 후에 Redis 에 접근하기 때문에 위와같은 문제는 해결된다.

근데 치명적인 단점이 이미 좌석 선점에 밀린 요청들도 DB 조회, 업데이트 과정을 거친다는 것이다. 해당 과정에 대해 성능 테스트를 해보니 분산락을 사용한 방법과 성능차이가 얼마없어서 버렸다…

Redis Key 체크를 두번 수행

그래서 생각해낸 방법이 메소드 시작 시 Redis Key 체크 + 메소드 종료 시 Redis Key 체크 & Key 세팅이다.

이렇게 수행하면 처음과 같은 문제를 유발하지 않고 불필요한 DB 조회, 업데이트 과정도 수행하지 않아 성능까지 챙길 수 있었다.

Redis 로 구현한 낙관적(?)락 전략의 장점

일단 락을 사용하지 않는다.
그리고 이미 선점된 좌석에 대해서 Redis 로 최전방에서 예외처리를 하기 때문에 (사용자 조회 쿼리 + 좌석 검증을 위한 조회 쿼리) 를 생략할 수 있다. 그래서 처리속도가 빠르다!

그리고 동시성도 문제없이 잡아준다 👍

구현


Redis 낙관적 락 전략 구현

나는 부가로직인 동시성 관리 코드를 aop 로 분리해서 구현했다.

[실제 예매 로직]

@Transactional
@BookingConcurrency // 동시성 Aspect 시그니처 어노테이션
public TicketBookingResponse bookTicketWithRedisOptimisticLock(Long userId, List<Long> seatIds) {
	// 사용자, 좌석 조회
	User user = userService.getUserById(userId);
	List<Seat> seats = seatService.findByIdIn(seatIds);

	// 좌석 검증
	validateSeatsPresent(seatIds.size(), seats);
	validateSeatsAbleToBook(seats);

	// 예매 생성 후 저장 & 좌석 상태 '예매됨' 상태로 변경
	Booking booking = Booking.of(user, seats.get(0).getShow(), seats);
	seatService.updateSeatToBooked(seatIds);
	bookingService.bookingTicket(booking);

	return BookingMapper.toTicketBookingResponse(booking);
}

[동시성 관리 Aspect 로직]

@Aspect
@Component
@RequiredArgsConstructor
public class BookingConcurrencyHandlerAspect {

	private static final String KEY_PREFIX = "seat_";

	private final RedisTemplate<String, Object> redisTemplate;

	@Transactional
	@Around("@annotation(dev.hooon.booking.aop.BookingConcurrency) && args(Long, seatIds, ..)")
	public Object handleBookingConcurrency(
		ProceedingJoinPoint joinPoint,
		List<Long> seatIds
	) throws Throwable {

		// 좌석이 선점 가능한지 검증
		validateIsPreemptibleSeat(seatIds);
		Object bookingResponse = joinPoint.proceed();
		// 좌석이 선점 가능한지 검증 & 좌석 선점을 Redis 에 기록
		validateIsPreemptibleSeatAndPreempt(seatIds);

		return bookingResponse;
	}

	// 좌석이 선점 가능한지 검증
	private void validateIsPreemptibleSeat(List<Long> seatIds) {
		// (1)
		List<String> seatIdKeys = seatIds.stream().map(seatId -> KEY_PREFIX + seatId).toList();

		// (2)
		List<Object> seatsData = redisTemplate.opsForValue().multiGet(seatIdKeys);
	    if (seatsData != null && seatsData.stream().anyMatch(Objects::nonNull)) {
                throw new ValidationException(NOT_AVAILABLE_SEAT);
            }
        }
	
	// 좌석이 선점 가능한지 검증 & 좌석 선점을 Redis 에 기록
	private void validateIsPreemptibleSeatAndPreempt(List<Long> seatIds) {
		// (3)
		Map<String, Boolean> seatIdKeyMap = seatIds.stream()
			.collect(Collectors.toMap(seatId -> KEY_PREFIX + seatId, seatId -> true));
		// (4)
		Boolean isPreempted = redisTemplate.opsForValue().multiSetIfAbsent(seatIdKeyMap);
		if (Boolean.FALSE.equals(isPreempted)) {
			throw new ValidationException(NOT_AVAILABLE_SEAT);
		}
		// (5)
		seatIdKeyMap.keySet().forEach(key -> redisTemplate.expire(key, 30, TimeUnit.SECONDS));
	}
}

(1) : Redis 에 사용될 key 값을 좌석의 id 로 만든다.

(2) : 모든 key 에 대해서 Redis 에 값이 하나라도 존재한다면 예외

(3) : Redis 에 사용될 key 값을 좌석의 id 로 만든다. value 는 단순히 true 를 넣는다.

(4) : multiSetIfAbsent() 를 이용하는데, Map 등록한 key 들에 대한 데이터가 Redis 에 하나라도 존재하면 모든 key 에 대한 값을 저장하지 않고 false 를 리턴하고, Redis 에 key 들에 대한 데이터가 모두 존재하지 않는다면 모든 Key 를 등록하고 true 를 리턴한다.

multiSetIfAbsent() 결과값이 false 라면 이미 예매가 진행된 좌석이 포함됐다는 뜻이기 때문에 예외를 던진다

(5) : 좌석들이 모두 예매 가능해서 통과됐다면 TTL 을 부여한다(동시성을 잡기위한 데이터기 때문에 금방 날려버린다)

동시성 테스트


이제 내가 짠 코드가 동시성을 잡아주는지 테스트해보자

시나리오는 다음과 같다

  • 총 100개의 요청이 동시적으로 들어온다
  • 50번은 {0, 1, 2} 번 좌석에 대한 예매 요청
  • 다른 50번은 {2, 3, 4} 번 좌석에 대한 예매 요청

좌석 번호를 보면 2번 좌석이 겹치므로 최종적으로 성공해야하는 예매는 딱 한개다

@Test
@DisplayName("[동시에 100개의 예매를 할 때, 단 한건의 예매만 성공한다]")
void bookingTicket_concurrency_test() throws InterruptedException {
	//given
	<...> 데이터 세팅
	List<Long> seatIds = seats.stream().map(Seat::getId).toList();
	// {0, 1, 2}, {2, 3, 4} 좌석번호 분할
	List<Long> seatIds1 = List.of(seatIds.get(0), seatIds.get(1), seatIds.get(2));
	List<Long> seatIds2 = List.of(seatIds.get(2), seatIds.get(3), seatIds.get(4));

	int threadCount = 100;
	CountDownLatch countDownLatch = new CountDownLatch(threadCount);
	ExecutorService executorService = Executors.newFixedThreadPool(threadCount);

	//when -> 50번씩 요청 분할하고 한번에 실행
	for (int i = 0; i < 50; i++) {
		executorService.execute(() -> {
			try {
				ticketBookingFacade.bookTicketWithRedisOptimisticLock(user.getId(), seatIds1);
			} catch (ValidationException e) {
				// 예외 먹기
			}

			countDownLatch.countDown();
		});
	}

	for (int i = 0; i < 50; i++) {
		executorService.execute(() -> {
			try {
				ticketBookingFacade.bookTicketWithRedisOptimisticLock(user.getId(), seatIds2);
			} catch (ValidationException e) {
				// 예외 먹기
			}

			countDownLatch.countDown();
		});
	}

	countDownLatch.await();

	//then -> 한개의 예매만 성공했는지 검증
	List<Booking> allBooking = bookingRepository.findAll();
	assertThat(allBooking).hasSize(1);
}

실행해보면

성공 👍

성능 비교


분산락보다 더 나은 성능의 전략을 적용하는게 목적이기 때문에 해당 파트가 핵심이라고 볼 수 있다

측정 환경

서버 장비 : MacBook Air 2022년 M2 (로컬환경)

장비 스펙 : 8코어 CPU/10코어 GPU/16코어 Neural Engine/16GB 메모리

측정 툴 : nGrinder

비교 대상 : TPS

테스트 설정

상황은 예매가 딱 풀리때 트래픽이 몰리는 상황을 가정해서 400 명의 유저로 설정했다.

짧은순간이기 때문에 20초로 설정했고 Ramp-Up 은 사용하지 않았다.

테스트 결과 및 비교

[분산락을 사용한 예매]

[레디스 낙관적 락을 이용한 예매]

평균 TPS : 6444,259

최고 TPS : 7515,178

깜짝 놀랐다…🫨 TPS 가 약 7배 정도의 차이를 보인다.

마치며


티켓 예매라는 동시성을 이슈를 유발하는 문제에 대해서 더 좋은 성능으로 해결하기 위한 과정을 쭉 소개해봤다.

한개라는 한정적인 자원을 두고 경쟁하는 상황이라면 해당 전략이 꽤 유용하지 않을까 싶다

하지만 단점도 분명히 있다.

단점

단점은 크게 세가지가 있다.

  1. [동시적인 요청에서의 DB 부하]

정말 동시적인 요청에서는 2차 키 체크에서 동시성을 잡아주기 때문에 롤백이 되긴하지만 DB 에 업데이트가 발생한다. 이때 DB 에 부하가 발생할 수 있다.

  1. [코드의 복잡성]

구현할 때는 AOP 로 분리해서 크게 복잡해보이진 않지만, 그 안에 세부 구현에 대한 복잡성이 추가된다.

  1. [Redis 키 관리]

좌석마다의 키를 생성하는 과정이기 때문에 Redis 의 자원을 많이 차지하게된다.

해당 전략에 대한 내 생각

결론부터 말하자면 나는 해당 전략을 적용해볼 생각이다!

각 단점들에 대해서 내 생각을 한번 얘기해보겠다 🫡

[동시적인 요청에서의 DB 부하]

동시적인 요청에서 추가적인 DB 업데이트 쿼리가 발생하는건 맞지만 해당 전략은 그 찰나의 순간을 벗어난 요청에 대해서는 사용자 조회좌석 조회 쿼리를 막아주기 때문에 많은 조회 쿼리를 막아준다.

위에서 테스트한 100번의 요청을 예로 보면

select 쿼리insert 쿼리
분산락 전략200번4번
레디스 전략20번40번

해당 표를 보면 분산락 전략이 insert 쿼리의 수는 확실히 줄여주지만 select 쿼리는 압도적으로 많이 사용되는걸 볼 수 있다.

[코드의 복잡성]

확실히 분산락 코드보다 복잡하지만 흐름 자체는 단순해서 괜찮다고 생각한다 😎

[Redis 키 관리]

순간적으로 Key 가 많아질 순 있게지만 TTL 을 짧게 가져가서 관리에 대해서는 신경쓸게 없을거라고 생각한다!

내 생각을 쭉 소개해봤는데, 나는 결과적으로 티켓 예매라는 기능은 많은 트래픽을 유발할 수 있기 때문에 더 높은 성능을 보여주는 Redis 를 활용한 낙관적락 전략을 선택했다.
하지만 실제로 운영해본 것은 아니기 때문에 무조건적으로 옳은 방법은 아니며, 실제 운영환경에서는 예상치 못한 문제가 생길 수 있고 상황에 따라 다른 전략을 선택해야할 수도 있을 것 같다 🤔

profile
서버 개발하는 사람입니다

5개의 댓글

comment-user-thumbnail
2024년 1월 16일
  1. BookingConcurrencyHandlerAspect - @Around에 @Transactional을 적용한 이유는 뭔가용?
  2. Redis Writable Command들에 대한 Transaction을 적용하는것이 의도였다면 단순한 RedisTemplate에 대한 빈 등록으로는 Spring Transaction에 참여할 수 없는데 별도로 설정을 하셨나요?
  3. 동일한 @Aspect 내부에서 @Transactional이 의도한대로 동작하나요?
1개의 답글