선착순 쿠폰 이벤트 개발기(feat.Redis)

고승원·2023년 2월 10일
4

Spring

목록 보기
2/14

서론

문득 "선착순 이벤트" 같은 트래픽이 몰리는 상황에서 서버가 트래픽을 어떻게 받아내는지 궁금증이 생겼다.

선착순 이벤트는 한번에 많은 사람이 몰리는 서비스이기 때문에 tps를 최대한 높게, mtt는 최대한 낮게 만드는게 목표이다.

모든 코드는 여기서 볼 수 있다.

ERD

erd는 최대한 가볍게 만들었다.

정해진 수량 이상 발급하면 안되기 때문에 수량 컬럼을 넣었고, 그 외에 유효기간, 사용범위 등은 고려하지 않았다.

첫 번째 방법

처음 구상한 플로우를 도식화해봤다.
요청이 들어오면 db를 확인하고 쓰기 연산을 하는 기본만 하는 로직이다.

public CouponEventResponse registerCouponEvent(CouponEventServiceRequest couponEventServiceRequest) {
	if (checkOverLapApply(couponEventServiceRequest.member())) {
		CouponEvent entity = toCouponEvent(couponEventServiceRequest);
		CouponEvent savedCouponEvent = couponEventRepository.save(entity);
		return toCouponEventResponse(savedCouponEvent);
	} else {
		throw new DuplicateKeyException("쿠폰을 중복으로 받을 수 없습니다.");
	}	
 }

//중복 발급 방지
private boolean checkOverLapApply(Member member) {
	return couponEventRepository.findByMember(member).isEmpty();
}

요청이 들어오면 db를 확인하고 coupon event 테이블에 넣는다.

부하테스트는 네이버의 nGrinder를 사용했으며, 관련 자료들은 공식문서블로그에 잘 나와있다.

nGrinder는 테스트 스크립트를 작성해야 하는데, 테스트에서 사용한 스크립트는 다음과 같다.

@RunWith(GrinderRunner)
class TestRunner {

	public static GTest test
	public static HTTPRequest request
	public static Map<String, String> headers = [:]
	public static Map<String, Object> params = [:]
	public static List<Cookie> cookies = []

	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		test = new GTest(1, "127.0.0.1")
		request = new HTTPRequest()
		
		// Set header data
		headers.put("Content-Type", "application/json")

		// Set param data
		grinder.logger.info("before process.")
	}

	@BeforeThread
	public void beforeThread() {

		test.record(this, "test")
		grinder.statistics.delayReports = true
		grinder.logger.info("before thread.")
	}

	@Before
	public void before() {
		request.setHeaders(headers)
		CookieManager.addCookies(cookies)
		long memberId = Math.abs(new Random().nextInt() % 200000) + 1
		params.put("memberId", memberId);
		params.put("couponId", 1);
		grinder.logger.info("before. init headers and cookies")
	}

	@Test
	public void test() {
		HTTPResponse response = request.POST("http://127.0.0.1:8080/api/v1/coupons", params)			

		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(201))
		}
	}
}

이벤트 요청을 하는 member는 1부터 20만중에 랜덤으로 요청하게 만들었고, 중복또한 일어날 수 있다.

결과

m1 pro 기본 모델 기준으로 tps 282, mtt 871로 1분동안 약 10000건의 요청을 처리했다.

생각보다 준수한 성능이라고 생각한다. 평범한 서비스의 경우 문제가 없을거라 생각하지만, 선착순 이벤트같이 단기간에 트래픽이 몰리는 경우에는 서버가 감당하지 못하는 상황이 야기된다.

개선된 방법

첫 번째 방법을 통해 이벤트를 만들게 되면 서버를 많이 올리게 되고, 비용적인 측면과 맞닿게 된다. tps를 더 높게, mtt를 더 낮게 만드는 방법은 없을까?

DB와 커넥션 비용이 크다고 생각해서 최소화 해야 한다고 생각했다.
그렇기 위해선 모든 요청을 쌓아두는 Queue를 만들고, 차례대로 쿠폰 API에게 흘려준다.

요청 Queue를 DB테이블에 두고 사용하게 되면, 성능은 개선되지 않을 것이다.
요청 Queue를 서버에서 Queue를 생성해서 사용하게 되면, 여러 서버를 가용했을 때 동기화 이슈가 생길 것이다.
-> 속도도 빠르고 여러 서버가 접근 할 수있는 Redis를 사용하자!

Redis는 in memory 저장소로 1초에 10만건의 입출력과 싱글스레드이기 때문에 동시성 문제로부터 자유롭다. 자세한건 여기를 참고하자.

Redis에는 여러가지 자료구조가 있는데 중복 참여를 방지하고, 요청 시간을 기준으로 정렬하기 적합하다 생각해서 SortedSet을 사용했다.

public void register(CouponEventRegisterRequest couponEventRegisterRequest) {
		couponEventRedisTemplate.opsForZSet()
				.addIfAbsent(
						CouponProperties.getKey(),
						couponEventRegisterRequest, 
                        System.currentTimeMillis()
				);
	}

요청이 존재하지 않으면 SortedSet에 추가하고 존재하면 추가하지 않는다.
이후 레디스에 쌓인 요청들은 차례대로 쿠폰 API가 처리하게 된다.

@Scheduled(fixedRate = 1000, initialDelay = 1_000 * 60 * 5)
	public void registerRedisToLocalQueueSchedule() {
		if (soldOut) {
			couponEventRedisService.deleteAllCouponEvent(CouponProperties.getKey());
			return;
		}
		couponEventRedisService.registerCouponEventToLocalQueue(CouponProperties.getKey());
	}

첫 번째 방법과 같은 스크립트로 부하테스트를 실행해봤다.

이렇게 쿠폰 이벤트 처리 로직을 개선해 기존의 방식보다 3배나 빨라진 선착순 쿠폰 이벤트 서비스를 만들어 보았다. 또한 여러번의 테스트에도 쿠폰이 초과지급되거나, 중복 지급되는 사례도 없었다.

Todo

  • 레디스 외에도 이벤트 브로커를 사용해 많은 양의 데이터를 효과적으로 처리할 수 있을 것이다.
  • nGrinder를 사용할때 한 개의 agent만 사용했었는데, 여러 agent를 사용한다면 실제 서비스 환경과 더욱 흡사해질 것이라고 생각한다.
  • vuser를 1000으로 고정하고 사용했는데, vuser수를 증가하면서 tps가 더이상 증가하지 않는 변곡점을 찾아보고 서버 사용에 대해 생각해보면 좋을 것 같다.
    추가적으로 scale out도 좋지만 병목 지점을 찾아서 성능을 높이는 방법도 고려해보자.

마무리

선착순 쿠폰 이벤트를 진행하면서 여러 자료를 찾아보고 고민했었는데 생각보다 tps가 높게 나오지 않아 조금은 실망했지만 3배라는 성능개선을 이뤄봤다.
Redis와 nGrinder를 평소에 사용해본적 없는 도구라 겁먹었었는데, 완벽하진 않아도 하면 된다라는 자신감을 얻었다.

추가

저 때는 Lettuce를 사용한 스핀락 방식을 사용했는데, 분산락이 더 효율적이라 판단하여 Redisson의 분산락으로 변경하였다. 2편


참고
우아한테크토크 선착순 이벤트 서버 생존기! 47만 RPM에서 살아남다?!

profile
봄은 영어로 스프링

0개의 댓글