1. 개요
지난 5월 지마켓 사이트 내 상품 상세 페이지에 쿠폰적용가(이하 VIP 쿠폰적용가)를 도입했었다.
사이트 내 쿠폰적용가를 노출하고자 하는 니즈가 늘어나면서 검색 결과 페이지에도 쿠폰적용가(이하 SRP 쿠폰적용가)를 도입하게 되었는데, 프로젝트를 진행하며 병목현상을 제거하여 성능을 향상한 경험을 공유하고자 한다.
기존의 VIP 쿠폰적용가에 비해 SRP 쿠폰적용가는 타임아웃이 훨씬 짧았다. 구체적인 수치를 말할 순 없지만 대략 1/4 수준이었다.
아무래도 검색 결과 페이지 자체가 불러들이는 데이터가 훨씬 많기 때문이 아닐까 싶다.
아무튼 이런 이유로 SRP 쿠폰적용가는 기존 VIP 쿠폰적용가보다 빠른 성능이 요구됐지만, 실제 성능 테스트 결과 목표에 훨씬 못 미치는 결과가 나왔다. 목표는 평균 100ms이었으나, 실제로는 2배 정도인 200ms를 상회하는 수준이었다.
APM에서 확인해본 결과 병렬/동시성 처리가 되지 않고, 순차적으로 작업이 이뤄져서 병목현상이 발생하는 포인트를 발견할 수 있었다.
1차적인 문제는 레디스 연산이었다.
2. 해결
1) SISMEMBER
- Set 타입에서 사용되는 연산으로, 특정 값이 Set 안에 존재하는지 여부를 반환한다
해당 연산은 쿠폰적용가를 비노출하기 위한 예외조건 체크 시 호출되고 있었는데, 이때 로컬 캐싱이 되어 있었다.
@Cacheable(value = [CacheName.EXCLUDED_ITEM], key = "#itemNo")
fun isExcludedItem(itemNo: Long): Boolean {
// ...
}
여기까지는 괜찮았는데, 문제는 Spring Webflux + Kotlin Coroutine 환경의 프로젝트 내에서 Mono나 Flux와 같은 리액티브 타입 대신 논 리액티브 타입을 로컬 캐싱하려다 보니 runBlocking 내에서 레디스 연산을 호출하고 있었던 것이었다....
fun existsExcludedItem(itemNo: Long): Boolean {
// 여기서 메인 쓰레드가 블락됨..
return runBlocking {
reactiveRedisTemplate.opsForSet().isMember(key, itemNo.toString()).awaitFirstOrDefault(false)
}
}
runBlocking은 코루틴 빌더의 하나로서, 내부의 코루틴 연산이 끝날 때까지 메인 쓰레드를 blocking시키는 함수이다.
이러한 특징 때문에 공식문서에서도 메인 함수나 테스트 내에서만 사용할 것을 권장하고 있다.
로컬 캐싱을 통한 성능적 이점은 분명 있었지만, 가용 쓰레드가 적어서 blocking 연산이 치명적인 Webflux의 특성상 제거하는 편이 좋겠다고 판단했다. 동시에 단건 조회 대신 다건 조회를 지원하는 SMISMEMBER라는 좋은 대체제가 있기에 로컬 캐싱 대신 레디스 상에서 다건 조회하는 방식으로 문제를 해결했다.
2) LLEN & LRANGE
- List 타입에서 사용되는 연산으로, 리스트의 사이즈를 반환한다
- List 타입에서 사용되는 연산으로, 특정 오프셋을 기준으로 데이터를 반환한다
- 쉽게 말해 List의 원소들을 가져오는 연산이다
쿠폰적용가 API 내부적으로 상품별로 쿠폰의 사용 가/불가 여부를 리스트로 캐싱하고 있다. 이는 리스트에서 오래된 쿠폰을 밀어내는(pop) 형태로 동작하는데, 여기서 리스트의 현재 사이즈를 계산하기 위해 LLEN 연산이 호출되었다. 그리고 상품별 쿠폰 리스트의 요소들을 조회할 때 LRANGE 연산이 사용되었다. (이와 관련된 자세한 내용은 여기를 참고)
먼저 결론부터 말하자면 루프 안에서 레디스를 조회한 것이 화근이었다. 쿠폰적용가는 크게 두 가지 파트(쿠폰적용가 계산을 위한 정보 조회 + 쿠폰적용가 계산)로 나눠서 계산되는데, 상품별로 루프를 돌며 쿠폰적용가를 계산하는 부분에서 상품별 쿠폰을 조회한 것이었다. 이미 상품번호를 들고 있기 때문에 상품별 쿠폰은 사실 루프 바깥에서 조회해도 되는 정보였다.
// AS-IS
// 1. 쿠폰적용가 계산을 위한 정보 조회 (할인, 쿠폰, 배송비 등)
// (생략...)
items.map {
// 상품별 쿠폰 조회 (루프 안에서 반복 조회)
val couponsPerItem = redisService.findCouponsPerItem(it.no)
// 2. 상품별 쿠폰적용가 계산
// (생략...)
}
이를 바깥으로 빼고, 비동기로 수행되도록 코드를 수정했다.
// TO-BE
// 1. 쿠폰적용가 계산을 위한 정보 조회 (할인, 쿠폰, 배송비 등)
// (생략...)
// 상품별 쿠폰 조회 (루프를 돌기 전에 한 번에 조회)
val couponsPerItem = coroutineScope {
val job = items.assciate {
it.itemNo to async {
redisService.findCouponsPerItem(it.no)
}
}
job.await()
}
items.map {
// 2. 상품별 쿠폰적용가 계산
// (생략...)
}
깔끔하게 비동기로 처리되는 것을 확인할 수 있었다!
그래도 이 정도면 어느 정도 해결된 게 아닐까하고 생각했지만, 끝이 아니었다...
3) 코루틴 스코프 함수 변경
위와 같이 레디스 연산을 개선했음에도 불구하고 쿠폰적용가를 계산하는 부분이 순차 처리가 되고 있음을 확인했다.
분명히 코루틴 스코프 내에서 비동기로 처리했다고 생각했는데 의아했다.
items.map {
val res = coroutineScope {
// 쿠폰 적용가 계산을 비동기로 수행
async {
calculateCouponPrice(...)
}
}
}
이 문제는 coroutineScope 함수의 잘못된 이해에서 비롯됐다. coroutineScope 함수는 블럭 내의 코루틴이 모두 완료될 때까지 기다린다. 코루틴은 경량화된 스레드라고 간단하게 생각하면 된다. 하나의 프로세스 안에 여러 스레드가 존재할 수 있듯이, 하나의 스레드 안에 여러 코루틴이 존재하여 작업을 수행할 수 있다. 자세한 내용은 공식문서를 참고하자.
즉, 아래와 같이 비동기로 처리하려고 해도 실제로는 blocking이 걸린다.
items.map {
// 실제로는 blocking이 걸려서 순차적으로 수행
val res = coroutineScope {
// 쿠폰 적용가 계산을 비동기로 수행
async {
calculateCouponPrice(...)
}
}
}
반면, CoroutineScope 함수는 블럭 내의 코루틴이 모두 완료될 때까지 기다리지 않는다.
기존에 coroutineScope 함수로 코루틴 스코프를 만들던 부분을, CoroutineScope 함수로 바꿔서 문제를 해결할 수 있었다.
items.map {
// 기다리지 않는다! - 작업은 별도 쓰레드에서 실행
val res = CoroutineScope(Dispatchers.Default) {
// 쿠폰 적용가 계산을 비동기로 수행
async {
calculateCouponPrice(...)
}
}
}
// ...
res.await() // 코루틴 작업이 완료되어야 반환값을 얻을 수 있으므로 기다림
확실히 비동기 처리가 된 것을 확인할 수 있다.
결과적으로 개선 작업 전 보다 대략 41.7% 가량 향상된 응답속도를 갖게 되었다.
이와 더불어 공통로직을 쓰는 다른 쿠폰적용가 API들의 경우에도 10% ~ 20% 내외로 응답속도가 향상되었다.
3. 후기
간단한 작업 같아 보이지만 APM 상의 trace만 보고 역추적해가는 과정이 쉽지만은 않았다. 비동기 작업이 워낙 많아서 작업의 흐름을 추적하는 것도 어려웠고, trace 상에 단순한 연산의 종류 말고는 정보가 거의 전무했기 때문이다. 함께 프로젝트를 진행했던 다른 팀원 한 분과 낮밤 가리지 않고 열심히 한 결과 생각보다 빠르게 문제를 해결할 수 있었다. 아직 원인불명의 오라클 쿼리가 튀는 이슈가 있어서 100% 성능이 향상된 것은 아니지만 단기간 내에 좋은 결과를 낼 수 있어서 좋은 경험이었다.
'프로그래밍 언어 > Java + Kotlin' 카테고리의 다른 글
[Kotlin] 특정 프로퍼티 Json 직렬화/역직렬화 시 제외시키기 (0) | 2023.11.09 |
---|---|
[Java] 제네릭 타입 소거(Generic Type Erasure)에 대해 알아보자 (0) | 2023.08.02 |
[Java] Collection과 Collections의 차이 (0) | 2023.03.16 |
[Java] 생성자 총정리 (0) | 2023.01.06 |
[Kotlin] 코틀린에서 애너테이션 사용하기 (vs 자바) (0) | 2022.12.19 |