티스토리 뷰

안녕하세요. 저는 Club & Discount Engineering 팀에서 지마켓 할인/쿠폰 개발 업무를 맡고 있는 윤영택입니다. 저는 올해 상반기에 G마켓 쿠폰적용가 개발에 참여했으며, 과정을 포스팅을 통해 나누어보고자 합니다.

 


들어가며

먼저 G마켓 쿠폰적용가 도입 전/후를 직접 눈으로 보면서 비교해보겠습니다.

G마켓 쿠폰적용가 도입 전)

여러분은 G마켓을 어떤 이유로 사용하시나요? 딱 하나만 뽑자면 단연코 폭넓고 다양한 쿠폰 할인 혜택이라고 할 수 있을 것입니다. 그러나 이와 같은 강점에도 불구하고 소비자들은 온전한 혜택을 누리지 못하고 있었고, 오히려 쿠폰 사용에 있어 불편함을 겪어야 했습니다.

 

가장 먼저 고객들은 상품 상세 페이지 진입 시 쿠폰을 포함한 할인가를 곧바로 확인할 수 없었습니다. 그와 더불어 어떠한 할인을 받고 있는지에 대한 정보도 확인이 불가능했습니다.

 

그리고 쿠폰을 다운로드 받을 땐 실제로 할인되는 금액이 표시되지 않아서 모든 쿠폰을 전부 다운로드 받아서 일일이 확인해야 하는 불편함도 존재했습니다.

 

또한, 여러 개의 상품을 구매할 경우 최적 할인가를 판단하기 위해선 소비자가 직접 쿠폰을 가지고 시뮬레이션을 했어야 했습니다.

 

G마켓 쿠폰적용가 도입 후)

그러나 올해 5월 빅스마일데이를 앞두고 런칭된 G마켓 쿠폰적용가 프로젝트를 통해 이러한 문제들이 다수 개선되었습니다. 상품 상세페이지 진입 시점부터 곧바로 최적 할인가를 확인할 수 있으며, 해당 할인가를 구성하는 할인정보도 함께 볼 수 있습니다.

 

쿠폰받기 영역에서 쿠폰을 통해 실제로 할인받을 수 있는 금액을 별도로 표시해주기 때문에 불필요한 다운로드 또한 필요가 없어졌습니다.

 

마지막으로 시뮬레이션 없이도 자동으로 최적 쿠폰이 선택되는 기능이 추가되었습니다.

 

 

이처럼 상품 상세페이지 쿠폰적용가 도입 쿠폰받기 영역의 개선을 통해 고객의 쇼핑경험을 개선하고 자사의 가격경쟁력을 강화시키는 효과를 가져올 있었습니다.

 


개발과정

그럼 본격적으로 개발과정에 대해 이야기를 나눠보도록 하겠습니다.

1. from RDB to Redis 

아래는 기존 상품 상세 페이지 진입 시의 조회 로직을 아주 간단히 표현한 도식입니다. 할인 금액을 표현하기 위한 정보 대다수를 RDB에 의존했기 때문에 DB 부하도 상당했을뿐더러 응답 속도도 느렸습니다. 그리고 할인/쿠폰 도메인과 관계없는 정보(상품 정보)까지 함께 조회한다는 문제 또한 안고 있었습니다. 이에 필연적으로 캐시 도입을 검토하게 되었으며, 불필요하게 결합된 도메인을 분리하는 과정도 함께 진행했습니다.

 

 

캐시를 사용할 땐 다음과 같은 조건을 고려해야 합니다.

1) 데이터가 변경에 민감한지

  • 예를 들어 티켓팅, 수강신청의 상황에선 데이터의 변화가 실시간으로 빠르게 반영되는 것이 중요합니다. 만약 빠르게 반영되지 않는다면 주문 완료 후 결제과정 등에서 취소가 발생할 수도 있습니다. 그러나 인스타그램이나 페이스북 등의 SNS 피드는 데이터가 실시간으로 갱신되지 않더라도 사용성에 큰 영향을 주지는 않습니다.
  • 쿠폰 적용가의 경우 후자와 마찬가지로 실시간으로 반영이 되지 않는다고 하더라도 구매행위 자체에 영향을 미치지는 않습니다. 쿠폰적용가는 주문 등에 영향을 미치지 않고 부가적인 정보를 제공하는 기능이기 때문입니다.

2) 연산이 얼마나 비싼지

  • 비싼 연산일수록 반복적인 수행을 줄이는 것이 중요합니다. 캐시의 TTL(Time-To-Live)를 적절하게 설정하여 캐시 hit 비율을 높인다면 성능을 크게 높이고 리소스를 아낄 수 있습니다.
  • 지마켓의 코어 데이터베이스는 많은 도메인에서 의존 중이므로 가용 리소스를 줄이는 과제를 안고 있고, 이에 따라 DB I/O를 줄이는 것이 중요했습니다.
  • 또한, 쿠폰적용가 계산에 필요한 데이터의 종류가 많으므로 DB 조회는 API 응답속도를 크게 떨어뜨리는 요소가 될 수 있었습니다.

3) 얼마나 자주 호출되는지

  • 이는 2번과 연결되는 문제로서, 연산이 비쌀수록 캐시의 효용은 커지지만 자주 호출되지 않는다면 캐시 miss의 비율이 높아져서 효용이 떨어지게 됩니다.
  • 쿠폰적용가 API는 상세 페이지 진입 시에 기본적으로 1회 이상 호출되며, 상품의 종류에 따라 연관상품이나 옵션에 대한 쿠폰적용가 API도 추가적으로 호출되므로 호출이 매우 잦다고 할 수 있습니다.

 

상기 사유들로 인해 쿠폰적용가 계산을 위해 캐시를 사용하기에 적절하다는 생각이 들었습니다. 이에 따라 DB 기반의 조회 로직을 다음과 같이 캐시 기반으로 수정했으며, 캐시 솔루션으로는 클러스터링과 여러 데이터 구조를 사용하기 위해 Redis를 선택했습니다.

 

그리고 할인/쿠폰 도메인에서 불필요한 도메인인 "상품 정보"를 외부 API를 통해서 가져옴으로써 분리했습니다. 특히 "상품 정보 API"는 Shared MySQL Cluster를 기반으로 구성되어 있습니다. 이에 대한 자세한 내용은 앞선 포스팅에서 소개된 바 있고, 너무나도 좋은 글이기에 읽어보시길 강력하게 추천드립니다.

 

2. 캐시 설계하기

캐시(메모리)는 데이터베이스(디스크)에 비해 성능이 좋지만, 비싸고 용량이 적습니다. 이러한 이유로 캐시를 사용할 때는 어떤 데이터를 얼마나, 그리고 어떻게 캐싱할 것인지가 중요합니다.

 

1) 어떤 데이터를 얼마나 캐싱할 것인지?

먼저 "어떤 데이터를 얼마나 캐싱할 것인지"에 대해 알아보겠습니다.

 

이야기에 앞서 할인과 쿠폰의 특성에 대해 살펴보겠습니다. 할인은 상품과 "결합"하는 개념으로서 상품 상세 페이지에서 노출되는 상품의 경우 할인의 종류가 2가지(상품할인, 판매자할인)로 제한된다는 특징이 있습니다. 이에 하나의 상품을 기준으로 조회하는 데이터양도 확연하게 적습니다. 반면 쿠폰은 상품과 무관하게 "발급"되는 개념으로 한 상품에 적용할 수 있는 쿠폰의 종류는 N개 입니다. 한 상품에 적용 가능한 종류도 많고, 매번 발급이 되기 때문에 처리해야 할 데이터의 양이 할인에 비해 월등히 많습니다.

  할인 쿠폰
적용방법 결합 발급
적용할 수 있는 종류 2개 (상품 상세 페이지 노출 기준) N개
조회하는 데이터양 적음 많음

 

결국 핵심은 수많은 쿠폰 데이터 중 가장 많이 사용되는 쿠폰, 즉 캐시에 저장할 때 효용가치가 가장 높은 데이터를 저장하는 것이었습니다. 개발 시작 시점 기준으로 사용자에게 실제 발급된 쿠폰 데이터를 분석해본 결과 사용할 수 있는 1000종류의 쿠폰 중 상위 100가지의 쿠폰이 총발급량의 86%를 차지하는 것을 확인했습니다.

 

쿠폰을 100개만 캐싱한다고 했을 때 단순한 수치로 캐시 히트율(Cache Hit Rate)는 10%에 불과하지만, 분석을 통해 실제론 그보다 훨씬 웃돈다는 것을 파악할 수 있었습니다. 이처럼 캐시를 도입할 때는 캐싱할 데이터를 사전에 분석한다면 보다 적은 비용으로 효율적으로 운영할 수 있습니다.

 

결과적으로 쿠폰적용가를 구성하는 캐시 구조는 다음과 같습니다.  

 

고객의 보유쿠폰과 상품별 쿠폰은 처리해야 하는 양이 많으므로 리스트 형태로 캐싱했으며, 다른 정보들은 단건으로 캐싱했습니다. 특히 상품별 쿠폰 정보 내엔 고객이 보유한 쿠폰과 받을 수 있는(=아직 보유하지 않은) 쿠폰의 사용 가능 여부가 포함되어 있습니다. 이는 쿠폰의 사용 가능 여부를 알아야 불필요한 체크 로직을 거치지 않을 수 있기 때문입니다. 예를 들어 쿠폰 사용이 불가능한 상품이나 카테고리의 경우 사용 가능 여부가 false로 기록됩니다. 쿠폰 정책은 용량이 크므로 별도로 분리하여 정책 번호로 매핑하여 조회하게끔 구성했습니다.

 

그 이외의 정보 중 제휴사 정보 등의 잘 바뀌지 않는 데이터들은 레디스에 캐싱하는 대신 서버 내에 로컬 캐싱하여 처리했습니다. 로컬 캐시와 관련된 자세한 내용은 여기를 참고해주세요.

@Cacheable(value = [CacheName.AVAILABLE_COUPON_FOR_JAEHU_COMPANY], key = "+#couponNo+':'+#companyNo")

fun isAvailableCouponForJaehuCompany(couponNo: Long, companyNo: Long): Boolean {

    // ...

}

 

2) 어떻게 캐싱할 것인지?

다음으로는 "어떻게 캐싱할 것인지"에 대해 알아보겠습니다.

 

앞서 이야기했던 데이터의 종류 및 구조도 물론 중요하지만, 캐시의 TTL 및 갱신 로직도 그에 못지않습니다. 캐시의 TTL이 길면 길수록 DB 접근이 줄어들기 때문에 성능적인 이점이 있지만, 데이터의 정합성은 상대적으로 떨어지게 됩니다. 갱신 로직 또한 성능과 정합성에 미치는 영향이 크기 때문에 마찬가지로 중요합니다. 

 

쿠폰적용가를 구성하는 캐시의 TTL을 다음과 같이 가정해보겠습니다.

종류 TTL
상품별 쿠폰 상품의 마지막 view + α 
고객의 보유 쿠폰 고객의 마지막 활동 시간 + α
상품 할인 3분 이내
판매자 할인 3분 이내
쿠폰 정책 없음

 

상품 할인과 판매자 할인 같은 경우엔 자주 변경되지는 않지만, 데이터양이 적고 정합성이 중요하기에 TTL을 짧게 설정했습니다. 고객 보유 쿠폰과 상품별 쿠폰은 각각 고객의 마지막 활동 시간과 상품 상세 페이지의 마지막 view를 기준으로 TTL이 결정됩니다. 쿠폰 정책은 자주 변경되지 않고 정합성이 중요하다는 측면에서 상품 할인 및 판매자 할인과 유사하지만, TTL이 별도로 없습니다. 자세한 내용은 이어서 살펴보시겠습니다.

 

위와 같이 TTL이 각기 다른 이유는 데이터의 성질과 더불어 캐시 갱신 로직 때문입니다. 이벤트 등을 통해 데이터 변경 시점을 추적할 수 있는 경우엔 TTL을 비교적 길게 가져가더라도 변경사항이 발생하는 즉시 캐시를 갱신하여 최신 데이터를 보여주기 쉽지만, 그렇지 않은 경우엔 TTL 자체를 줄여서 데이터의 갱신 속도를 빠르게 하는 것이 대안이 될 수 있습니다. G마켓 쿠폰적용가를 구성하는 캐시들은 아래 그림과 같이 교체 알고리즘, API 호출, 이벤트 등 다양한 요소에 의해 갱신되고 있습니다.

 

VIP2.0 쿠폰 API 개선하기

위에서 봤던 그림을 다시 한 번 보겠습니다. 

 

여기서 받을 수 있는 쿠폰을 조회하는 역할을 하는 VIP2.0 쿠폰 API 또한 쿠폰적용가 API와 마찬가지로 Redis에서 쿠폰 정보를 조회하고 있습니다.

 

기존에는 VIP2.0 쿠폰 API에 단건 조회 기능만 있었기에 큰 이슈가 없었으나, G마켓 쿠폰적용가를 도입하면서 연관 상품에 대해서도 받을 수 있는 쿠폰을 조회하는 요구사항이 생기면서 다건 조회 기능이 필요해졌습니다.

 

그러나 문제는 단건만을 조회하는 상황에서도 컨슈머에 상당한 부하가 있다는 사실이었습니다. 컨슈머는 메시지를 소비하면 Elastic Search의 여러 인덱스에 쿼리를 하고, 그 결과에 따라 데이터를 필터링하여 Redis에 쿠폰 데이터를 저장해주고 있습니다.

 

부하를 최대한 줄이면서 다건을 처리하기 위해선 다음과 같은 조치가 필요했습니다.

1) 컨슈머에 유입되는 부하(=발행 메시지의 양)를 최대한 줄이기

2) 컨슈머 자체의 처리속도 높이기

 

1번의 경우 다건 조회에 한해 캐싱 주기를 길게 가져가는 방식으로 간단하게 처리할 수 있었습니다. 예컨대 단건 조회 요청이 들어오는 경우 데이터가 저장된 지 10분이 경과했다면 메세지를 발행하였으며, 다건 조회 요청이 들어오는 경우엔 3시간이 지난 후에 메시지를 발행했습니다. 다건 조회에 의해 갱신된 캐시 데이터라 하더라도 단건 조회 요청이 들어오면 짧은 캐싱주기로 갱신되기 때문에 데이터의 정합성에 대한 큰 이슈 없이 효과적으로 컨슈머에 가해지는 부하를 줄일 수 있었습니다.

 

2번의 경우는 다소 까다로웠습니다. 이미 컨슈머의 Pod Count와 Kafka Partition Count와 같았기 때문에 Scale out을 시도한다 하더라도 큰 의미가 없었으며, 이미 하드웨어적으로도 최고 수준이었기 때문이었습니다. 따라서 성능을 가장 지연시키는 요소를 찾아내고자 했고, 곧 컨슈머에서 Elastic Search에 쿠폰 정책을 조회하는 부분에서 지연이 발생하고 있음을 찾아낼 수 있었습니다.

 

다양한 인덱스 중 쿠폰 정책은 변경 가능성이 적은데도 불구하고, 실시간으로 ES 조회를 거치고 있었습니다. 쿠폰 정책의 경우 요청이 복수로 들어옴과 동시에 그 조합이 매번 다르기 때문에 단일 key 기반의 Spring의 로컬 캐시도 적용하기 어려웠습니다. 이에 따라 직접 다음과 같이 ConcurrentHashMap과 Spring Scheduler를 사용하여 간단하게 로컬 캐시를 구현했습니다.

 

1) ConcurrentHashMap을 이용한 로컬 캐시

@Component

public class CouponPolicyCache {



    // ...

    

    private final Map<String, CouponPolicy> map = new ConcurrentHashMap<>();



    public void put(List<CouponPolicy> couponPolicyList) {

        couponPolicyList.forEach(policy -> map.put(policy.getNo(), policy));

    }



    public List<CouponPolicy> get(List<String> couponNoList) {

        return couponNoList.stream()

                .map(no -> map.getOrDefault(no, null))

                .filter(Objects::nonNull)

                .collect(Collectors.toList());

    }



    // ...

}

2) Spring Scheduler

@Component

@RequiredArgsConstructor

@Slf4j

public class CouponPolicyScheduler {



    // ...



    private final CouponPolicyRepository couponPolicyRepository;

    private final CouponPolicyCache couponPolicyCache;



    @PostConstruct

    public void init() {

        syncCouponPolicies();

    }



    /**

     * 로딩 시점에서 중복 실행을 막기 위해 initialDelay을 설정

     */

    @Scheduled(fixedDelay = INTERVAL_MIN * 1000 * 60, initialDelay = INTERVAL_MIN * 1000 * 60)

    public void syncCouponPolicies() {

        // ElasticSearch에서 데이터를 조회한 후 캐시에 저장한다.

        couponPolicyCache.put(couponPolicyRepository.getAllCouponPolicies());

    }

    

    // ...

}

 

위와 같이 구성하여 Bean이 초기화되는 시점에 Elastic Search에 쿼리를 날려 캐시에 데이터를 채우고, 그 후 적절한 간격으로 동기화가 되도록 만들었습니다. 아주 단순한 형태의 캐시이지만, 30% 이상의 성능 향상을 확인할 수 있었습니다.

 

필요에 따라 아래의 예제처럼 캐시 갱신 로직을 추가하는 것도 가능합니다. 실제로 로컬 캐시를 구현할 땐 이와 같이 조회 대상이 되는 데이터의 양과 JVM의 메모리를 고려하여 적절한 용량을 설정해야 합니다. 그렇지 않으면 Out Of Memory 에러가 발생할 수도 있기 때문입니다.

@Component

public class CouponPolicyCache {

    

    // ...

    

    private final Map<String, CouponPolicyResponse> map = new ConcurrentHashMap<>();

    

    public void put(List<CouponPolicyResponse> couponPolicyResponseList) {

        if (map.size() >= MAX_CACHE_SIZE) {

            /**

             * 캐시에서 데이터 제거

             */

        }

        couponPolicyResponseList.forEach(policy -> map.put(policy.getNo(), policy));

    }



    // cache를 호출하는 쪽에서 value가 null이면 ElasticSearch에서 데이터를 조회한 후 캐시에 넣어준다.

    public Map<String, CouponPolicyResponse> get(List<String> couponNoList) {

        return couponNoList.stream()

                .collect(Collectors.toMap(

                        no -> no,

                        no -> this.map.getOrDefault(no, null)

                ));

    }



}

마무리

G마켓 쿠폰적용가 도입 이후 가격비교 서비스가 추가로 도입되어 판매자들이 상품을 등록할 때 동일 상품군 내에서 간편하게 최저가를 비교하고 적용할 수 있게 되었습니다. 이는 판매자들에게는 상품의 가격경쟁력 강화를 통해 판매 실적 향상의 기회를 제공함과 동시에 고객들의 쇼핑을 더욱 편리하게 만들 것입니다. 이와 더불어 옥션에도 하반기 이내로 쿠폰적용가가 도입될 예정입니다.

 

1) ESM+

2) 상품 상세 페이지

 

개발과정에서 여러 문제가 있었고 앞으로도 많은 어려움이 있겠지만, 앞으로 더 많은 고객이 즐겁게 쇼핑할 수 있는 플랫폼을 만들어 가겠습니다.

 

부족한 읽어주셔서 감사합니다.

댓글