프로모션 시스템 엿보기: 파일럿 프로젝트

Mar.21.2023 김동규

Backend Culture

안녕하세요. 1월 3일, 2023년 새해를 맞아 배민푸드서비스개발팀 신입 개발자로 합류하게 된 김동규입니다.

“우아한형제들 기술블로그"를 보면서 언젠가 제 이름으로 된 글을 올리겠다는 꿈을 가지고 있었는데요! 오늘 파일럿 프로젝트 글을 통해 그 꿈을 이루게 되어 너무 감격스럽습니다.

오늘은 신입으로서 파일럿 프로젝트를 통해 팀에 어떻게 적응해나갔는지 이야기를 풀어보려고 합니다.

프로모션 시스템 구현하기

파일럿 프로젝트로 프로모션 시스템 API 서버 구현하기를 진행하게 되었습니다.
프로모션 시스템은 배달의 민족 사용자에게 다양한 이벤트의 노출 및 참여를 지원하는 시스템을 말하는데요!

실제로는 위와 같이 많은 요구사항에 대응해야 하겠지만, 저는 다음과 같은 두 마케터의 요구사항을 만족하도록 하는 프로모션 API 서버를 구현하는 것이 목표였습니다. (지면 관계상 최대한 간략하게 작성해 보았습니다.)

A 마케터

  • 매일 1회씩 1번만 발급 가능합니다.
  • 신규 회원(주문이 없는 회원)에게는 10,000원의 쿠폰을 주고 싶어요.
  • 그리고 기존 회원 중 마지막 주문일이 4개월 이상 경과된 회원은 3,000원, 5,000원, 7,000원 중 랜덤으로 쿠폰을 지급하고 싶어요.
  • 마지막 주문일이 4개월 미만인 회원은 1,000원, 2,000원 쿠폰을 랜덤으로 지급하고 싶어요.
  • 아! 각각의 쿠폰은 발급 수량이 정해져 있으며 만약 지급하려는 쿠폰이 모두 소진됐다면 다른 쿠폰을 발급해 주세요.

B 마케터

  • 이벤트 기간 동안 1번만 발급 가능합니다.
  • 회원이 전달에 주문한 주문수 별로 다른 쿠폰이 노출되어야 합니다.
    • 5번 주문했으면 쿠폰 1개, 10번 주문했으면 쿠폰 2개, 20번 주문했으면 쿠폰 3개를 지급해야 합니다.
  • 회원의 닉네임과 누적 주문수를 노출해야 합니다.
  • 쿠폰을 받은 경우 받았다는 표시가 나야 합니다.

그리고 생각보다 많이 중요했던 공통 요구사항!!

  • A, B 마케터가 기획한 프로모션을 원활히 진행할 수 있도록 구현해 주세요.
  • 공통적으로 이벤트 참여가 불가능한 회원이라면 에러 응답을 내려야 합니다.
  • 중요! 구현한 스펙을 재활용하여 프로모션을 하는 경우가 많습니다. 구현한 기능을 재사용 할 수 있도록 구현해 주세요.
  • 프로모션 참여에는 수많은 이슈가 있을 수 있습니다. 따라서 프로모션 참여에 대한 이력을 남겨주세요.

기술 요구사항은 아래와 같이 정해졌습니다.

  • Spring Webflux
  • OOP & Clean Code
  • Spring Boot 2.x & JDK 17 & Gradle
  • Git Flow 브랜치 전략
  • 단위 테스트 & 통합 테스트
  • 테이블 설계 ERD

요구사항 분석

제일 먼저 진행한 작업은 요구사항 분석이었습니다. 요구사항을 분석하며 해당 요구사항을 풀기 위한 정보가 무엇이고, 요구사항에서 핵심이 될 부분을 고려하며 분석을 진행하였습니다.

각 마케터 분들의 요구사항을 토대로 정리한 핵심 내용은 다음과 같습니다.

A 마케터 : 특정 회원이 마지막으로 주문한 날짜를 조회할 수 있어야 한다. 예를 들어, A 회원이 “언제” 마지막 주문을 하였는지를 확인하고 이에 따라 다른 쿠폰을 지급해야 한다.

B 마케터: 특정 기간을 범위로 주문수를 조회할 수 있어야 한다. 또한 쿠폰의 발급 여부를 저장할 수 있어야 한다.

그리고 이를 위해서 필요한 정보(데이터)는 다음과 같습니다.

  • 마지막 주문일 정보 (회원이 마지막으로 주문한 일시)
  • 특정 기간에 주문한 주문수
  • 회원의 닉네임 정보

여기서 마지막 주문일 정보의 경우 직접 제공하는 API 가 없어 "2016-01-01" 을 기준으로 startDate를 지정하고, 오늘 날짜로 endDate를 지정하여 주문수를 조회함으로써 전체 기간 주문수, 그리고 4개월간의 주문수를 확인하는 방식으로 마지막 주문일 정보를 간접적으로 알 수 있도록 하였습니다.

이후 확실하게 이해되지 않았거나 애매하다고 생각되는 요구사항들이 있어 이에 대해서도 질문하고, 나름대로 정리를 진행하였습니다.

1. 랜덤 지급

“쿠폰이 모두 소진되었다면 다른 쿠폰을 발급해 주세요.” 라는 요구사항이 3000원 쿠폰이 모두 소진되면 1000원 쿠폰 3장을 발급해야 하는 것인지 혹은 5,000원 혹은 7,000원을 발급해야 하는 것인지 요구사항이 애매하여 이에 대한 구체화를 진행하였습니다. 이 부분은 후자가 맞는 경우로 3,000원에 당첨되었어도 해당 쿠폰이 모두 소진되었다면 5,000원 혹은 7,000원 쿠폰을 지급하여야 하는 요구사항이었습니다.

2. 쿠폰 수량

쿠폰의 수량은 프로모션에서 별도로 관리하여야 하는지 혹은 쿠폰을 발급해 주는 서버에서 수량에 대한 관리를 하고, 이에 대한 응답을 함께 주는지 궁금하였습니다. 쿠폰 쪽 API 응답을 확인해 보니 쿠폰이 소진되었는지를 포함하고 있지 않아 프로모션 쪽에서 별도로 쿠폰 수량에 대해서 관리하는 것으로 이해하고 진행하였습니다.

3. 쿠폰 그룹 시퀀스와 쿠폰 시퀀스

"쿠폰 그룹 시퀀스", "쿠폰 시퀀스"라는 용어가 처음이었고, 어떤 의미인지 쉽게 와닿지 않았습니다.

예를 들어, 배민1 3,000원 쿠폰도 있을 것이고, 5,000원 쿠폰도 있을 것입니다. 이러한 쿠폰들에 대한 하나의 id 값이 쿠폰 그룹 시퀀스입니다. 그리고 “배민1 3,000원” 쿠폰이라고 해도 정말 많은 쿠폰을 발급하게 될 텐데요, 그 각각의 쿠폰에 해당하는 id를 쿠폰 시퀀스라고 합니다.

이렇게 애매하다고 생각되는 요구사항에 대해서 구체화하고, 모르는 부분에 대해서 질문하여 요구사항을 분석한 것을 바탕으로 프로모션 히스토리 저장 및 쿠폰 발급 여부 저장을 위한 ERD를 설계하였습니다.

ERD (Entity Relationship Diagram)

코드리뷰 이후, ERD에 많은 변화가 있지만 코드리뷰 이전 ERD는 다음과 같습니다.

한눈에 들어오는 정말 간단한 테이블 구조이면서 어떻게 보면 중복되는 데이터도 많아보입니다.. 큰 의미를 찾기 힘든 테이블들도 보이구요..

이 테이블 구조에서 가장 중심이 되는 테이블은 프로모션 이력(promotion_history) 테이블입니다. 요구사항에 맞게 프로모션에 어떤 회원(member_number)이 언제(participate_date) 어떤 프로모션(promotion_id)에 참여하고 어떤 쿠폰(coupon_id) 을 발급받았었는지를 저장할 수 있도록 테이블을 설계하였습니다. 또한 쿠폰 발급 여부를 저장해 주어야 하기에 쿠폰(coupon) 테이블도 함께 두었으며, 어떤 쿠폰 그룹의 쿠폰을 발급해 주어야 할지 결정하기 위해 쿠폰 그룹(coupon_group) 테이블도 두었습니다.

쿠폰의 수량은 쿠폰 그룹(coupon_group) 테이블의 수량(quantity)과 쿠폰(coupon) 테이블을 쿠폰 그룹 식별자(coupon_group_id)로 그룹핑해서 count한 값을 통해 얻도록 하였습니다.

여기서 특히 주목해서 봐주실 점은 마케터 B의 회원 등급별 요구사항에 따른 등급(grade) 테이블을 별도로 관리한다는 점입니다. 마케터 B 요구사항을 만족하기 위해서 등급 테이블을 두고 등급에 따라 쿠폰 그룹 식별자를 가지도록 설계하였습니다. 하지만 각 프로모션 조건별로 이러한 테이블이 생긴다면 관리할 부분이 많아지겠죠..🥲 코드리뷰 이후 테이블은 이러한 재사용성과 중복 데이터 제거를 중점으로 두고 설계를 하였습니다.

Webflux + R2DBC

기술 요구사항을 보면 Webflux라는 키워드를 발견하실 수 있습니다.

Webflux는 스프링5에서 추가된 모듈로 reactive 스타일의 애플리케이션 개발을 지원해 주는 모듈입니다.

Spring MVC와 같은 서블릿 기반의 웹 프레임워크는 스레드 블로킹과 다중 스레드로 요청을 처리합니다. 즉, 요청이 처리될 때 스레드 풀에서 작업 스레드를 가져와서 해당 요청을 처리하며 작업이 종료될 때까지 요청 스레드는 블로킹됩니다. 이렇듯 MVC의 경우에는 특정 작업이 완료될 때까지 스레드는 차단되며 아무것도 할 수 없는 상태가 됩니다. 하지만 Webflux에서 제공하는 reactive 프로그래밍은 함수적이고 선언적이며 각각의 작업 단계를 나타내기보다는 데이터가 흘러가는 파이프라인 혹은 스트림을 나타내어 데이터 전체를 사용할 수 있을 때까지 기다리지 않고 가능한 데이터가 있을 때마다 처리를 진행합니다. Webflux는 이렇게 비동기 처리를 수행함으로써 적은 스레드 양으로 많은 요청을 처리할 수 있게 해줍니다.

이러한 Webflux의 특징을 몇 가지 짚고 넘어가자면 다음과 같습니다.

  • @Controller, @RequestMapping과 같이 MVC 와 같은 Spring MVC의 핵심 컴포넌트를 공유한다.
  • Servlet 이 아닌 Netty 서버를 기반으로 하며 비동기식 이벤트 드리븐 아키텍처를 사용한다.
  • 외부 네트워크, 데이터베이스 연동 또한 비동기로 수행되어야 의미가 있다.

배민 프로모션 시스템의 경우, 많은 사용자가 한 번에 몰리는 경우에 효율적으로 요청을 처리하기 위해서 이렇게 Webflux를 이용해 구성되어 있습니다.

그런데 앞서 Webflux의 특징을 이야기하며 외부 네트워크나 데이터베이스 연동의 경우에도 비동기로 수행되어야 의미가 있다고 언급하였습니다. 즉, 특정 요청을 수행하는 중에 DB와의 연동이 필요해 DB에 요청을 보내는데 결국 해당 스레드가 blocking 된다면 Webflux를 사용하는 의미가 무색합니다. 오히려 비용이 더 많이 소모될 수 있습니다.

따라서 쿠폰 API 서버, 회원 API 서버 등 연관된 서버들과의 통신에는 RestTemplate 이 아닌 WebClient를 사용했습니다.

또한 DB와의 연동을 위해 익숙하게 사용하던 JPA 가 아닌 R2DBC를 webflux 와 함께 사용했습니다.

R2DBC란?

WebClient에 대한 내용은 한 번쯤 들어보거나 사용해 보셨을 것 같습니다. 그런데 R2DBC는 용어도 생소하고 처음 들어보시는 분들도 많을 것 같습니다.

우선 용어를 살펴봅시다! R2DBC는 Reactive Relational Database Connectivity의 약자입니다. 단어를 하나씩 파헤쳐 보면 그 의미를 파악할 수 있습니다! 즉, "관계형 데이터베이스에서 reactive promogramming 이 가능하도록 지원해 주는 것"을 우리는 R2DBC라고 합니다. (r2dbc.io 에서도 “reactive programming APIs to relational databases.”라고 소개되어 있습니다.)

그럼 JDBC와는 어떤 점이 다를까요??

기존의 JDBC는 동기식으로 구현되어서 DB에서 쿼리를 실행하는 동안 애플리케이션 스레드는 블로킹됩니다. 하지만 R2DBC는 기존의 JDBC를 통해서 데이터베이스 접근하던 것과는 달리 쿼리를 실행하는 동안 애플리케이션 스레드를 블로킹하지 않고도 결과를 받아올 수 있도록 해줍니다.

그리고 JDBC와 공통되게 기존의 JDBC처럼 데이터베이스 벤더에 독립적인 API로 구현되어 여러 데이터베이스 벤더에서 R2DBC를 지원해줄 수 있습니다.

이렇게 장점만 있는 것처럼 보이는 R2DBC를 사용하며 느낀 점은 아직은 지원되지 않는 기능이 많다는 것이었습니다. 우선 익숙하게 사용한 OneToXXX 와 같은 연관관계를 지정해 줄 수 없었습니다. 또한 Id 어노테이션에 대해서도 여러 가지 전략을 제공하지 않는 것으로 보입니다. 그리고 Embedded를 지원하지 않습니다. 하지만 Spring Data JPA 와 같이 Spring Data R2DBC 또한 간단하게 인터페이스만 우리가 정의해 주면 쉽게 Repository 계층의 구현체를 만들어주며 Embedded 나 연관관계 등을 사용하지 않는 간단한 경우에 대해서는 JPA 와 크게 차이가 없으므로 JPA에 익숙하다면 쉽게 데이터베이스와 연동할 수 있습니다.

그런데 이렇게 R2DBC를 사용하다 보니 “비동기 환경에서 트랜잭션을 어떻게 보장해 주는 것일까?” 하는 의문이 들었습니다. 트랜잭션을 보장해 줄 수 없다면 현재 프로모션 조회나 각 프로모션 참여 로직 수행 중 예외가 발생하게 된다면 롤백을 해주었어야 하는데, 즉 트랜잭션 하나가 원자적으로 커밋되거나 롤백되는 것을 보장해 주어야 하는데 그렇지 못할 테니 R2DBC를 사용하지 못하게 됩니다.ㅠㅠ

비동기환경에서의 트랜잭션

앞서 Webflux는 기존 스프링에서 사용하던 여러 컴포넌트를 공유한다고 하였습니다. 따라서 Webflux 에서도 @Controller나 @Transactional 등을 기존과 동일하게 사용할 수 있습니다.

그런데, Webflux + R2DBC 환경에서의 @Transactional 을 통한 트랜잭션 보장은 기존의 방식과 차이가 있습니다.

Webflux + R2DBC 환경에서의 @Transactional 은 기존의 MVC + JPA에서 ThreadLocal을 이용해 각 스레드만의 고유 공간을 통해서 트랜잭션을 보장하는 방법과는 달리 비동기 방식이기 때문에 Context라고 하는 별도의 객체를 통해서 트랜잭션을 보장해 줍니다. 즉, R2DBC 환경에서는 비동기적인 특성 때문에 ThreadLocal 객체를 사용할 수 없고, Reactor Context를 사용합니다.

Reactor Context는 Reactor 라이브러리에서 제공하는 기능으로, Publisher 및 Subscriber 사이에서 데이터를 공유할 수 있는 맵 형태의 데이터 저장소입니다. (Reactive Seuqnece 상에서 공유) 따라서 트랜잭션 정보를 Reactor Context에 저장하고, R2DBC 연결에서 트랜잭션 정보를 검색해 사용합니다.

그리고 이를 위해서 Spring에서는 R2DBC Connection을 생성할 때, ConnectionFactory를 사용합니다. 이 ConnectionFactory는 트랜잭션 정보를 Reactor Context에 저장하고, Connection이 생성될 때마다 해당 정보를 Connection에 설정합니다. 따라서 R2DBC를 사용하는 애플리케이션에서는 Reactor Context를 사용해 트랜잭션을 관리하고, 이를 통해 비동기적인 환경에서도 안전하게 트랜잭션을 처리할 수 있게 해줍니다.

아래와 같은 간단한 테스트를 통해서 Context에 대해서 확인해 볼 수 있습니다.

class ContextTest {

    @Test
    void contextTest() {
        final Flux<String> result = Flux.deferContextual(contextView ->
                Flux.just("A", "B", "C")
                        .parallel(3)
                            .runOn(Schedulers.newParallel("Thread", 3))
                        .map(value -> {
                            final String transactionId = contextView.getOrDefault("transactionId", "");
                            return Thread.currentThread().getName() + " - " + value + " - " + transactionId;
                        })
        );

        result.contextWrite(Context.of("transactionId", "tx-1"))
                .subscribe(System.out::println);
    }

deferContextual() 메소드를 통해서 Reactor Context를 추출하고 각각의 값을 생성합니다. ContextView는 Context를 읽기만 할 수 있는 전용 뷰입니다. getOrDefault()를 통해서 각 key에 해당하는 value를 가져오며 존재하지 않는 경우, 두 번째 인자인 defaultValue를 사용합니다.

Context.of()를 통해서 새로운 Reactor Context를 생성하고, contextWrite()를 통해 Flux에 전달합니다.

각각의 데이터 A, B, C는 Flux.parallel()에 의해서 RR으로 신호를 나누어 runOn() 메소드에 다중 스레드를 사용하는 스케줄러를 전달해 각 레일에서 별도의 스레드로 신호를 처리하게 됩니다.

위 결과를 보면 각각의 스레드에서 데이터를 처리하지만 Conext를 통해서 전달되는 transactionId 값은 동일한 것을 확인할 수 있습니다.

이렇게 Reactor Context를 통해서 여러 스레드에서 실행되는 동안 하나의 체인 안에서 데이터를 공유함으로써 우리는 트랜잭션을 보장받을 수 있게 됩니다.

코드리뷰

온라인 코드리뷰는 우아한테크코스 과정을 통해서도 많이 받아보았지만 오프라인으로 많은 사람들 앞에서 코드리뷰를 받는 경험은 처음이라 많이 떨렸습니다. 또한 팀원분들에게 저를 처음 보여주는 자리이다 보니..🥲 많이 긴장된 상태로 리뷰 시간을 보냈습니다.

제가 파일럿 프로젝트를 진행하며 어떤 고민들을 하였고, 집중적으로 리뷰 받고 싶은 부분이 무엇인지 고민하고 공유해 주면 좋겠다고 하셔서 구현한 내용들과 함께 고민했던 것들 그리고 리뷰 받고 싶은 부분에 대해서 발표하는 시간을 짧게 먼저 가졌습니다.

이후 본격적으로 리뷰가 시작되었습니다. (고민했던 내용들은 생략하고 리뷰 위주로 내용을 이어가겠습니다.)

쿠폰을 받은 경우 받았다는 표시 (N+1)

    @Transactional(readOnly = true)
    public Mono<CouponBookResponse> getCouponBook(final String memberNumber) {
        final Mono<CouponGroupWithOrderResponse> couponGroupResponse = couponGroupService.getCouponGroupsForCouponBook(memberNumber);

        return couponGroupResponse
                .flatMap(it -> {
                    final List<CouponGroupWithReceivedResponse> couponGroups = it.getCouponGroups();
                    final Mono<MemberResponse> memberResponse = memberClient.inquireMemberInfo(new MemberRequest(memberNumber));
                    return memberResponse.map(member -> new CouponBookResponse(member.getNickName(), it.getOrderCount(), couponGroups));
                });
    }
    public Mono<CouponGroupWithOrderResponse> getCouponGroupsForCouponBook(final String memberNumber) {
        final Mono<Integer> orderCount = queryOrderCount(new OrderCountRequest(memberNumber, MONTHLY.getBaseDate()));
        final Mono<Integer> currentMonthOrderCount = queryOrderCount(new OrderCountRequest(memberNumber, LocalDate.now().with(firstDayOfMonth())));

        final Mono<List<CouponGroupWithReceivedResponse>> couponGroups = getCouponGroups(memberNumber, orderCount);
        return Mono.zip(couponGroups, currentMonthOrderCount)
                .map(it -> new CouponGroupWithOrderResponse(it.getT1(), it.getT2()));
    }

마케터 B 요구사항에서 쿠폰북을 조회할 때 쿠폰을 이미 발급받았는지 여부를 함께 표시해야 합니다. 또한 회원의 닉네임 정보도 함께 표시해 주어야 합니다. 따라서 멤버 정보를 조회해오는 로직과 함께 쿠폰 그룹을 조회(getCouponGroupsForCouponBook)해 오는 로직이 필요합니다.

여기서 getCouponGroups() 내부에서 CouponGroupRepository의 findByGradeNameAndMemberNumber() 메소드를 사용하며 다음과 같은 쿼리가 존재합니다.

    @Query("SELECT coupon_group.id as id, coupon_group.coupon_group_seq as couponGroupSequence, "
            + "coupon_group.coupon_amount as couponAmount, "
            + "coupon_group.coupon_group_seq = (SELECT coupon_group.coupon_group_seq "
            + "FROM coupon_group "
            + "JOIN coupon_group_grade ON coupon_group.id = coupon_group_grade.coupon_group_id "
            + "JOIN grade ON coupon_group_grade.grade_id = grade.id "
            + "JOIN coupon ON coupon_group.id = coupon.coupon_group_id "
            + "WHERE grade.grade_name = :gradeName "
            + "AND coupon.member_number = :memberNumber "
            + "AND coupon_group.coupon_group_seq = coupon.coupon_group_id) as received "
            + "FROM coupon_group "
            + "JOIN coupon_group_grade ON coupon_group.id = coupon_group_grade.coupon_group_id "
            + "JOIN grade ON coupon_group_grade.grade_id = grade.id "
            + "WHERE grade.grade_name = :gradeName")
    Flux<CouponGroupResponse> findByGradeNameAndMemberNumber(final GradeName gradeName, final String memberNumber);

한눈에 알아보기 힘든 쿼리이며 굉장히 많은 테이블들 간의 조인이 발생합니다.

하지만 이렇게 작성한 이유는 아래의 쿼리를 사용하면 N + 1 문제가 발생하기 때문입니다.

@Query("SELECT * FROM coupon_group "
            + "JOIN coupon_group_grade ON coupon_group.id = coupon_group_grade.coupon_group_id "
            + "JOIN grade ON coupon_group_grade.grade_id = grade.id "
            + "WHERE grade.grade_name = :gradeName")
Flux<CouponGroup> findByGradeName(final GradeName gradeName);

위 쿼리를 활용하여 먼저 CouponGroup만을 조회해오고, 조회해온 CouponGroup N 개에 대해서 사용 여부(발급 여부)를 판단하기 위해 추가적인 쿼리를 N 번 호출할 수 있습니다.

하지만 findByGradeNameAndMemberNumber() 와 같이 sub query를 활용하여 CouponGroup을 조회하면서 발급 여부를 함께 조회하면 한 번의 쿼리로 원하는 데이터를 모두 조회해 올 수 있게 됩니다. (반환형도 CouponGroup이 아닌 발급 여부를 포함한 DTO인 CouponGroupResponse를 사용해 주었습니다.)

이에 대해서 고민 포인트로 발표도 하고 리뷰도 받았습니다. 우선 쿼리가 매우 복잡하고 길기 때문에 유지 보수하는 관점에서 적절한 쿼리인지 고민해 볼 필요가 있습니다. 즉, 여기서 만약에 "coupon_group_seq"를 "coupon_group_sequence"로 변경하게 되면 어떻게 될까요?? @Query에 작성된 문자열에서 "coupon_group_seq"를 모두 찾아 수정을 진행해 주어야 합니다. 또한 많은 테이블들을 조인해서 연산해야 한다는 것도 DB에 부담이 될 수 있습니다.

하지만 이렇게 한 번에 필요한 데이터를 조회해오는 방법은 네트워크를 여러 번 타며 DB에 부하를 주는 것에 비하면 훨씬 DB의 부담은 적을 것입니다.

따라서 많은 조회가 이루어지는 쿼리인지 유지 보수가 많이 필요한 쿼리인지를 적절히 판단해서 사용하는 것이 좋을 것 같다는 의견을 받을 수 있었습니다. 저는 쿠폰북에 대한 조회는 참여와는 별개로 빈번하게 조회가 가능하기 때문에 많은 조회가 이루어지는 쿼리라고 판단하였고, 그럴 때마다 N + 1 번의 쿼리가 나가는 것은 비효율적이라고 판단하였습니다. 수정 또한 많이 일어나지 않을 것으로 예상하여 결론적으로 findByGradeNameAndMemberNumber() 를 사용하도록 해주었습니다.

동시성

사용자의 이벤트와 관련된 서비스이다 보니 “동시성” 문제 또한 고려하지 않을 수 없었습니다.

사용자가 같은 이벤트에 대해서 1회 참여 제한인 경우, 여러 번 동시에 API를 호출한다고 해서 여러 번 참여가 가능하면 안 되기 때문입니다!

동시성 문제가 발생할 수 있는 부분은 사용자가 이벤트에 참여하기 위해 이전 참여 이력을 조회하는 부분!(코드리뷰를 통해 프로모션 참여 이력을 조회하는 곳에서도 동시성 문제가 발생할 수 있음을 인지할 수 있었습니다.)과 쿠폰 수량이 감소하는 곳입니다.

프로모션의 참여 이력이 아직 저장되지 않은 시점에 프로모션 참여 신청을 하여 중복 참여가 되는 문제가 발생하면 안됩니다!

또한 쿠폰이 마지막 1개 남아 있는 시점에서 동시에 두 명의 유저가 요청을 하게 되었을 때 둘 모두 성공하는 것이 아니라 한 명만이 성공해야 합니다!

이러한 동시성 문제를 해결하기 위해서 1차적으로 synchronized 키워드를 통해서 해결하는 것을 고려해 볼 수 있습니다. 하지만 이는 동시성 문제를 완벽히 해결하기 어렵습니다.

일반적으로 synchronized를 붙이는 코드는 @Transactional로 감싸진 메소드입니다. 즉, AOP 를 통해서 해당 메소드 앞뒤로 트랜잭션 시작과 트랜잭션 커밋 또는 롤백을 수행해 줍니다. 즉 다음과 같은 형태입니다.

우선 트랜잭션 시작은 동시에 요청이 온 두 트랜잭션 모두가 시작을 할 수 있게 됩니다. 트랜잭션 프록시를 호출하게 되면 트랜잭션 프록시는 데이터 소스를 찾아서 사용하게 되면서 이때 커넥션 풀에서 커넥션을 획득하게 됩니다. 따라서 동시에 온 두 요청 모두가 각각의 커넥션을 소유할 수 있게 됩니다. 이렇게 커넥션을 맺은 이후에 실제 Target Method에 대해서는 synchronized를 걸었기에 순차적으로 진행됩니다.

하지만 커밋하는 시점, 즉 하나의 스레드가 Target Method를 처리하고 나오는 순간 다른 트랜잭션에서 해당 메소드에 진입이 가능하게 됩니다.(아직 첫 번째 트랜잭션 커밋 X인 시점) 앞선 트랜잭션에서는 커밋이 완료되지 않았고 두 번째 실행된 트랜잭션에서 쿠폰을 발급받게 되면 둘 모두 성공하게 됩니다. 즉, 처음 트랜잭션이 아직 커밋 되지 않은 시점에 두 번째 트랜잭션이 DB로부터 데이터를 조회할 수 있게 되고 해당 데이터를 통해서 검증을 수행하게 되면 여전히 동시성 문제가 존재합니다.

그리고 이러한 방식은 근본적으로 하나의 서버(Application Server)를 고려하였을 때 생각해 볼 수 있는 방법으로 여러 대의 서버가 존재한다고 하면 진입점이 여러 개가 되므로 결국 동일한 문제가 발생합니다.
예를 들어, 각각의 서버에서 quantity를 조회해올 수 있고 이때 만약 A, B 서버 모두 “quantity = 1” 을 조회해온다면 둘 다 reduce 시키는 문제가 발생할 수 있습니다.

이를 해결하기 위한 방법으로 메시지 큐나 Redis를 이용한 글로벌 락과 같은 방법도 있겠지만 파일럿 프로젝트이기에 WAS, DB 이외에 추가적인 인프라 리소스를 활용하지 않고, 코드 레벨 혹은 DB만을 가지고 해결이 가능한 비관적 락, 낙관적 락 2가지 방법을 고려하였습니다.

결론적으로는 비관적 락을 사용하여 공유되는 데이터인 DB 데이터 row에 직접 Exclusive Lock 을 거는 방법을 택하였습니다. 따라서 여러 요청이 왔을 때 한 번에 하나의 요청만이 해당 row에 접근할 수 있게 되고 순차적으로 처리되는 효과를 얻을 수 있습니다. 이러한 비관적 락은 DB row에 Exclusive Lock 을 걸기에 속도가 많이 저하되는 문제가 있지만, 낙관적 락을 사용하지 않은 이유는 다음과 같습니다.

개인적으로 낙관적 락은 “선착순 1명”과 같이 여러 요청 중 하나의 요청만을 성공시켜야 할 때 적절한 방법이라고 생각합니다.(혹은 인원수가 고정적으로 제한된 상황이나..) 낙관적 락은 실패 시에 개발자가 직접 재시도를 해주어야 하는데 특정 예외가 발생했을 때 해당 메소드를 재시도하는 Spring에서 제공해 주는 @Retryable 어노테이션을 함께 사용하는 것을 고려해 볼 수 있습니다. 하지만 이 경우 몇 번 재시도를 하는 것이 적절한지 명확하지 않은 요구사항에서는 조금 어려운 문제가 될 수 있습니다.

특히 지금과 같이 쿠폰의 수량은 어떤 경우엔 10개 어떤 경우엔 1,000개, 5,000개 와 같이 제각각일 수 있으므로 적절한 방법이 아니라고 판단했습니다.

    @Transactional
    public Mono<Integer> reduceQuantity(final Long couponGroupId) {
        return couponGroupRepository.findByIdForUpdate(couponGroupId)
                .flatMap(couponGroup -> {
                    couponGroup.reduceQuantity();
                    return couponGroupRepository.updateQuantity(couponGroup.getQuantity(), couponGroup.getId());
                });
    }
    @Lock(PESSIMISTIC_WRITE)
    @Query("SELECT * FROM coupon_group WHERE coupon_group.id = :couponGroupId FOR UPDATE")
    Mono<CouponGroup> findByIdForUpdate(Long couponGroupId);

동시성 테스트는 아래와 같이 CountDownLatch를 이용했습니다.

public class ConcurrentHttpRequester {

    private final ExecutorService executorService;
    private final CountDownLatch start;
    private final CountDownLatch end;
    private final AtomicInteger successCount;
    private final AtomicInteger failCount;

    public ConcurrentHttpRequester(final int poolSize) {
        this.executorService = Executors.newFixedThreadPool(poolSize);
        this.start = new CountDownLatch(poolSize);
        this.end = new CountDownLatch(poolSize);
        this.successCount = new AtomicInteger(0);
        this.failCount = new AtomicInteger(0);
    }

    public void submit(HttpRequestExecutor executor) {
        executorService.submit(() -> {
            try {
                start.countDown();
                start.await();
                final HttpStatus status = executor.execute();
                if (status.is2xxSuccessful()) {
                    successCount.incrementAndGet();
                } else if (status.is4xxClientError()) {
                    failCount.incrementAndGet();
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                end.countDown();
            }
        });
    }

    ...
}

ConcurrentHttpRequest는 CountDownLatch를 이용해 인자로 주어진 HttpRequesetExecutor(Functional Interface) 을 수행합니다.

execute()를 실행하기 전에 countDown() 을 호출해 값을 1씩 감소시키며 await()으로 해당 CountDownLatch의 값이 0 이 될 때까지 대기합니다. 즉, 모든 요청이 한 번에 수행되기 이전에 출발선에 멈춰서 있다고 생각해 주시면 좋습니다.

그리고 모든 요청이 출발선에 도착해 시작할 준비가 되면 execute()를 통해서 동작을 수행하게 되고 이때 응답이 2xx 인지 4xx 인지에 따라 AtomicInteger의 값을 증가시킵니다.

    @DisplayName("동시에 다수의 사용자가 쿠폰을 발급해도 순차적으로 처리되어 수량이 정상적으로 감소해야한다.")
    @Test
    void concurrentReduceQuantity() {
        // given
        final ConcurrentHttpRequester requester = new ConcurrentHttpRequester(POOL_SIZE);

        // when
        for (int i = 0; i < POOL_SIZE; i++) {
            final String memberNumber = String.valueOf(i);
            requester.submit(() -> tryReduceQuantity(new PromotionRequest(memberNumber)));
        }
        requester.await();

        // then
        assertThat(requester.getSuccessCount()).isEqualTo(COUPON_QUANTITY);
        assertThat(requester.getFailCount()).isEqualTo(POOL_SIZE - COUPON_QUANTITY);
    }
    @DisplayName("동시에 요청을 해도 프로모션 내역 조회는 순차적으로 처리되며 여러번 참여가 불가능하다.")
    @Test
    void concurrentGetPromotion() {
        // given
        final ConcurrentHttpRequester requester = new ConcurrentHttpRequester(POOL_SIZE);

        // when
        for (int i = 0; i < POOL_SIZE; i++) {
            requester.submit(() -> tryParticipate(new PromotionRequest(MEMBER_NUMBER)));
        }
        requester.await();

        // then
        assertThat(requester.getSuccessCount()).isEqualTo(SUCCESS);
        assertThat(requester.getFailCount()).isEqualTo(POOL_SIZE - SUCCESS);
    }

이렇게 CountDownLatch를 이용하면 실제 사용자 환경과 비슷하게 동시에 하나의 동일한 요청을 보낼 수 있게 되고 성공한 횟수와 실패한 횟수를 통해서 우리가 의도한 대로 동시성이 제대로 제어되는지, 즉 정말 쿠폰의 수만큼만 통과하고, 프로모션 참여 또한 한 번만 참여가 되는지를 확인해 줄 수 있습니다.

subscribe 명시적 호출

기존에 아래와 같이 프로모션 참여 이력을 저장하는 로직에서 명시적으로 subscribe() 을 호출해 주고 있었습니다.

    private PromotionResponse savePromotionHistory(final CouponResponse coupon, final String memberNumber, final Promotion promotion) {
        final PromotionHistory promotionHistory = new PromotionHistory(memberNumber, promotion.getId(), coupon.getCouponId());
        promotionHistoryRepository.save(promotionHistory).subscribe();
        return new PromotionResponse(coupon.getMemberCouponSequence(), coupon.getCouponAmount(), coupon.getIssuedDate());
    }

그리고 이 부분에 대해서 리뷰를 받았는데, 명시적으로 subscribe()을 호출하지 말고 Mono로 이어서 반환을 하라는 내용이었습니다.

여기서 명시적으로 subscribe() 을 호출한 이유는 promotionHistoryRepository.save()에서 반환되는 Mono를 savePromotionHistory()에서 반환하는 PromotionResponse 을 만드는 데 있어 필요하지 않았기 때문이었습니다.

그래서 “왜 명시적으로 subscribe() 을 호출하지 않고, Mono 혹은 Flux로 넘겨야 하는 것일까??" 하는 의문이 들었습니다.

나름대로 생각해 본 근거는 다음과 같습니다. 우선 실제로 해당 private 메소드를 호출하는 public 메소드인 participate() 메소드가 subscribe() 되지 않을 수 있습니다.

    public Mono<PromotionResponse> participate(final PromotionType promotionType, final String memberNumber) {
        return promotionHistoryRepository.findPromotionHistory(promotionType, memberNumber)
                .map(Optional::of)
                .defaultIfEmpty(Optional.empty())
                .flatMap(promotionHistory -> {
                    if (promotionHistory.isPresent()) {
                        return Mono.error(new AlreadyParticipationException(promotionHistory.get().getId()));
                    }
                    final Mono<Promotion> promotion = promotionRepository.findByPromotionType(promotionType);
                    final Mono<CouponGroupResponse> couponGroup = couponGroupService.getCouponGroup(memberNumber, promotionType);
                    return participatePromotion(memberNumber, promotion, couponGroup);
                });
    }

즉 실질적인 참여에 대한 응답이 사용자에게 전달되지 않고, 참여 이력만 저장될 수도 있다는 것입니다.

명시적인 subscribe() 호출은 실제 public 한 메소드가 구독되지 않았음에도(subscribe() 되지 않았음에도) 실행되는 문제를 마주할 수 있습니다.

추가적으로 이러한 발행 – 구독 패턴은 비동기 메시징 패러다임 중 하나인데, 특정한 수신자가 정해져 있지 않고, 정해진 범주에 따라서 구독을 신청한 수신자에게 메시지가 전달되는 방식입니다. 즉, webflux에서는 subscribe을 하지 않으면 결국 아무 일도 일어나지 않아야 합니다. 하지만 명시적인 subscribe()의 호출은 이를 방해하는 요소가 됩니다.

하지만 이러한 이론적인 측면뿐 아니라 실질적으로 Webflux를 사용하는 측면에서도 문제가 될 수 있습니다. WebFlux에서 Mono나 Flux에 대한 subscribe() 을 직접 수행하면 비동기 및 논 블로킹 방식의 이점을 상실하게 됩니다. 저의 코드와 같이 subscribe() 을 명시적으로 호출하게 되면 현재 스레드는 차단되게 됩니다. 따라서 성능적인 측면에서 당연히 단점이 될 수 있습니다.

그럼 subscribe() 을 우리가 직접 호출하지 않으면 어디서 호출해 줄까요?? 어디선가는 subscribe() 을 호출해주어야 구독이 발행되어 처리가 이루어질 것입니다. 스프링 내부적으로 Controller에서 return 되는 publisher를 subscribe 해줍니다. 특히 Netty를 사용중일 경우, HttpServerHandle 클래스의 onStateChange 메소드에서 subscribe() 을 호출해 줍니다. Servlet의 경우에는 ServletHttphandlerAdapter 클래스의 service 메소드에서 subscribe() 을 호출해 줍니다.’

다음의 코드는 HttpServer의 inner static class인 HttpServerHandleonStateChange() 부분입니다.

여기서 subscribe() 메소드는 Mono 또는 Flux 형태의 Http 응답으로 클라이언트에 전송될 데이터 스트림을 실제 응답 데이터로 클라이언트에 보내는 역할을 하게 되는 것이고, disposeSubscriber() 메소드는 응답이 완전히 전송된 후 응답과 관련된 모든 리소스(네트워크 연결 해제, 버퍼 리소스 해제 등)를 해제하는 역할을 수행합니다.

        @Override
        @SuppressWarnings("FutureReturnValueIgnored")
        public void onStateChange(Connection connection, State newState) {
            if (newState == HttpServerState.REQUEST_RECEIVED) {
                try {
                    if (log.isDebugEnabled()) {
                        log.debug(format(connection.channel(), "Handler is being applied: {}"), handler);
                    }
                    HttpServerOperations ops = (HttpServerOperations) connection;
                    Publisher<Void> publisher = handler.apply(ops, ops);
                    Mono<Void> mono = Mono.deferContextual(ctx -> {
                        ops.currentContext = Context.of(ctx);
                        return Mono.fromDirect(publisher);
                    });
                    if (ops.mapHandle != null) {
                        mono = ops.mapHandle.apply(mono, connection);
                    }
                    mono.subscribe(ops.disposeSubscriber());
                }
                catch (Throwable t) {
                    ...
                }
            }
        }

정리하면 우리는 Controller를 통해서 publisher만 반환해 주면 스프링에서 subscribe() 을 호출해 줌으로써 비동기 및 논 블로킹 방식의 이점을 누릴 수 있는 것이고, 명시적인 subscribe() 을 호출하면 현재 스레드가 차단되므로 webflux의 이점을 상실하게 됩니다.

따라서 위의 코드를 아래와 같이 변경을 해주었습니다.

    private Mono<PromotionResponse> savePromotionHistory(final CouponResponse coupon, final String memberNumber, final Promotion promotion) {
        final PromotionHistory promotionHistory = new PromotionHistory(memberNumber, promotion.getId(), coupon.getCouponId());
        return promotionHistoryRepository.save(promotionHistory)
                .map(it -> new PromotionResponse(coupon.getMemberCouponSequence(), coupon.getCouponAmount(), coupon.getIssuedDate()));
    }
    private Mono<PromotionResponse> participatePromotion(final String memberNumber, final Mono<Promotion> promotion,
                                                         final Mono<CouponGroupResponse> couponGroup) {
        return Mono.zip(couponGroup, promotion)
                .flatMap(tuple -> {
                    final Mono<CouponResponse> coupon = issueCoupon(memberNumber, tuple.getT1());
                    return coupon.flatMap(it -> savePromotionHistory(it, memberNumber, tuple.getT2()));
                });
    }

savePromotionHistory()promotionHistoryResponse.save() 호출 이후 map() 을 통해서 Mono를 반환해 줍니다. 그리고 이를 호출하는 participatePromotion()에서는 flatMap 을 호출하도록 수정해 주었습니다.

여기서 flatMap() 을 사용해 준 이유는 Mono<Mono> 를 flat 하게 펴주기 위함도 있지만 DB 에 대한 접근(save 호출) 을 별도의 스레드에서 수행시키기 위함도 있습니다.

하지만 여전히 궁금한 점이 남아있습니다. 지금 현재 promotionHistoryRepository.save()의 결과를 이용하지 않고 PromotionResponse로 map() 을 통해 변환한 후 반환하고 있는데 과연 이것이 적절한가 하는 생각이 듭니다. save() 결과로 나온 PromotionHistory(map 안에서 “it”)를 함께 사용해서 map 변환이 이루어져야 한다고 생각하기 때문입니다.

map() 메소드의 목적은 원본 데이터를 다른 데이터의 형태로 변환하는 것인데, 이 경우 원본 데이터를 사용하고 있지 않습니다. 단순히 새로운 데이터를 생성한다고 보여집니다. 하지만 현재는 반드시 원본 데이터를 사용해 다른 형태로 변환해야 하는 이유에 대한 명확한 답을 스스로 찾지 못했습니다. 또한 그렇게 하지 않고 사용하였을 때 발생하는 문제가 없다고 판단하여 위와 같이 리팩터링을 진행하였습니다.

Opgional.of vs Optional.ofNullable

아래와 같은 코드를 통해서 프로모션 참여 이력(PromotionHistory)을 한 번 조회하고 참여 이력이 있는지 확인해 주고 있습니다.

그런데 여기서 Optional.of()를 사용해도 NPE 이 발생하지 않는 이유가 무엇인지 질문을 받았습니다.

리뷰 당시에는 해당 코드에 대해서 깊이 있기 고민해 보지 못했었고, webflux 가 처음이었어서 돌아가게끔 작성하는데 급급해 제대로 된 답변을 하지 못했었습니다. 단순히 조회된 데이터가 없는 경우에 complete 나는 것을 방지해 주기 위해 이러한 코드를 작성하였던 것만을 이야기할 수 있었습니다.

    public Mono<PromotionResponse> participate(final PromotionType promotionType, final String memberNumber) {
        return promotionHistoryRepository.findPromotionHistory(promotionType, memberNumber)
                .map(Optional::of)
                .defaultIfEmpty(Optional.empty())
                .flatMap(promotionHistory -> {
                    if (promotionHistory.isPresent()) {
                        return Mono.error(new AlreadyParticipationException(promotionHistory.get().getId()));
                    }
                    final Mono<Promotion> promotion = promotionRepository.findByPromotionType(promotionType);
                    final Mono<CouponGroupResponse> couponGroup = couponGroupService.getCouponGroup(memberNumber, promotionType);
                    return participatePromotion(memberNumber, promotion, couponGroup);
                });
    }

위의 코드는 findPromotionHisotry() 호출 이후에 반환되는 값을 map() 을 이용해 Optional로 변환해 주고 있습니다.

이렇게 해준 이유는 앞서 잠깐 언급한 대로 만약 위와 같은 과정 없는 채로 findPromotionHistory()로 조회를 하게 되면 결과가 없는 경우 바로 complete가 나서 이후 로직이 수행되지 않기 때문입니다. 실제로 Optional로 한 번 감싸는 것과 감싸지 않은 상태인 아래와 같은 로직을 구성해서 조회되지 않는 경우에 대해서 테스트 코드를 실행해 보면 그 차이를 알 수 있습니다.

public Mono<PromotionResponse> participate(final PromotionType promotionType, final String memberNumber) {
    return promotionHistoryRepository.findPromotionHisotry(promotionType, memberNumber)
                .flatMap(promotionHistory -> {
                            ...
                });
}

여기서 문제는 Repository에서 반환하는 데이터가 없어 null이 반환된다면, Optional.of()의 경우 NPE 이 발생한다는 것입니다. 따라서 Repository에서 데이터가 없는 경우 null을 반환한다면 Optional.ofNullable() 을 사용하는 것이 적절합니다.

Optioanl.of()Optional.ofNullable() 모두 객체를 담은 Optional 객체를 만들어 반환하지만 of() 메소드는 null 이 들어오지 않는다는 것을 확신할 수 있을 때, 그리고 ofNullable()는 null 여부를 확신할 수 없을 때 사용하는 것이 적절합니다.

하지만 실제로 Repository에서 조회하는 데이터인 PromotionHistory 가 없어도 NPE 이 발생하지 않습니다. 그 이유는 R2DBC를 이용한 PromotionHistoryRepository에서 데이터가 없는 경우 Mono.empty()를 통해서 결과를 반환해 주기 때문입니다. 실제 Mono.empty()에 대한 설명을 보면 emitting 하지 않고 완료되는(complete 되는) Mono를 만들어 반환한다고 명시되어 있습니다.

그렇기에 Mono.empty() 도 null이 아닌 하나의 객체이므로 NPE 이 발생하지 않았던 것이고, Mono.empty()는 complete 되는 Mono이기에 Optional로 감싸주는 작업을 수행해 주지 않으면 complete 가 나서 이후 로직이 실행되지 않았던 것입니다.

위의 로직을 다시 설명해 보면 findPrmotionHistory의 결과가 Mono.empty() 로 넘어올 수 있기에 Optional.of()로 감싸준 후 (NPE 발생 X), Mono.empty라면 defaultIfEmpty()에서 걸려 Optional.empty()로 변환을 해주고 이후 isPresent()로 분기를 수행합니다.

따라서 findPromotionHistory의 결과가 없는 경우에 대해서 ofNullable()로 감싸줄 필요없이 of()를 사용해서 NPE 이 발생하지 않고 이후 로직이 수행할 수 있습니다.

기능의 재활용성

공통 요구사항에 보면 “구현한 기능을 재사용 할 수 있도록 구현해주세요.”라는 요구사항이 있습니다. 기능 요구사항에만 집중하다 보니 1차 코드리뷰까지 깊게 고민하지 못한 부분이었습니다. 그리고 이 부분이 핵심이었음을 리뷰를 받으며 깨달았습니다.🥲

프로모션(이벤트)의 요구사항은 계속해서 들어옵니다. 요구사항에 포함되어 있는 추석 이벤트뿐 아니라 새해맞이 이벤트, 새 학기 이벤트 등등 굉장히 많은 이벤트 요구사항이 들어오게 되고, 대부분 이러한 요구사항은 마감기한이 정해져있습니다. (추석 이벤트를 추석 한참 지나서 하면 의미가 없겠죠..😅) 그렇다고 이러한 요구사항이 들어올 때마다 새로운 기능을 구현하고 테스트하고 QA를 진행한 후 배포하는 것은 너무 많은 비용이며 일정에 맞추지 못할 가능성도 높아집니다. 따라서 기존에 만들어놓은 기능을 재활용해서 최대한 새로운 기능 개발에 대한 비용을 줄이는 것이 프로모션 시스템의 핵심입니다!!

하지만 기존의 제가 작성한 코드는 새로운 프로모션이 추가되거나 수정될 때마다 전체적으로 손을 봐주어야 합니다.ㅠㅠ 따라서 기능을 재활용할 수 있는 부분이 없거나 극히 드뭅니다. 리뷰 과정에서 “주문수 확인(조건 확인), 쿠폰 (1개, 2개, 3개) 발급 등 각각을 하나의 기능으로 보고 이를 조립하여 기능을 완성”이라는 힌트를 주셨고, 이전에 프로모션 하위 개념으로 "퀘스트"라고 하는 단위를 둔다는 힌트도 받았었기에 이를 참고해서 다시 한번 설계를 진행 해보았습니다.

아래와 같이 프로모션 조회프로모션 참여를 구분하여 큰 틀을 정해놓고 구현하면 코드를 재활용할 수 있을 것이라 생각했습니다.

프로모션 조회 (GET)
request : memberNumber(회원번호) + 특정 promotion (pathVariable)
response : [questId + 참여 여부] + optional(쿠폰 금액, 닉네임, 이번달 주문수)
프로모션 참여(쿠폰 발급) (POST)
request : questId(pathVariable)
response : couponAmount(쿠폰 금액), memberCouponSequence(쿠폰 번호), IssuedDate(발급날짜)

여기서 퀘스트는 프로모션의 하위 단위로 프로모션마다 여러 개의 퀘스트를 가진다고 이해하시면 됩니다. 예를 들어 마케터 A의 경우 최근 주문 O, 최근 주문 X, 첫 주문 각각에 대해서 퀘스트가 존재합니다. 그리고 프로모션 조회 시 어떤 퀘스트를 수행하게 될지를 응답으로 받게 되고, 그 퀘스트의 id를 가지고 프로모션 참여라는 요청을 수행하게 됩니다.

우선 위에서 정리한 내용을 바탕으로 보면 프로모션 조회와 쿠폰 발급(프로모션 참여)이라는 두 개의 개념으로 구분한 것을 확인하실 수 있습니다.
이전에는 "쿠폰북 조회", "프로모션 A 참여", "프로모션 B 참여"와 같이 각 프로모션에 필요한 API를 각각 만들어주었습니다. 심지어는 프로모션 A에 대한 조회 API를 구현하지 않고, 바로 참여가 되도록 하였었습니다..😅

하지만 이렇게 되면 계속해서 새로운 프로모션마다 API 가 추가되게 되므로, 모든 프로모션은 공통되게 프로모션 상태에 대한 조회(참여 여부 등)와 프로모션 참여 두 개로 이뤄진다고 생각했습니다.

예를 들어 VIP 쿠폰북 페이지와 같이 처음 프로모션 페이지에 접근하면 프로모션 조회 API 가 호출됩니다. client는 이때 요청에 memberNumber 와 함께 어떤 프로모션인지에 해당하는 정보를 넘겨주게 됩니다. 그리고 응답으로서 퀘스트와 함께 참여 여부를 반환해줍니다. 여기서 퀘스트는 이후 쿠폰을 실제로 발급받을 때(프로모션에 참여할 때) 요청 값으로 사용됩니다. 그리고 어떤 퀘스트냐에 따라서 optional 하게 "쿠폰 금액 + 닉네임 + 이번 달 주문수 정보"를 반환해 줍니다. (프로모션 B의 경우에는 최대 3개의 quesetId를 넘겨받게 됩니다.)

프로모션 참여(쿠폰 발급) 요청에는 questId를 포함해서 요청을 보내게 됩니다. 그러면 questId를 가지고 프로모션 A의 경우에는 랜덤하게 memberCouponSequence를 반환하고, 프로모션 B의 경우에도 마찬가지로 memberCouponSequence를 반환해주게 됩니다.

위의 기본적인 흐름에 따라 ERD 도 아래와 같이 수정했습니다. 중간에 많은 시행착오를 겪었었지만 생략하고 결론적으로 도출된 ERD입니다!!😄

우선 기존의 재활용성에 가장 큰 장애물이 되었다고 생각되는 promotion_type 을 제거하는 것으로 시작하였습니다.

새롭게 quest 테이블을 추가하고 coupon_group 테이블에 quest_id를 추가해 주었습니다. 그리고 각각의 쿠폰 그룹 시퀀스(coupon_group_seq)들은 하나의 quest 와 연관을 가집니다.

또한 기존에는 promotion_history에 coupon에 대한 정보를 함께 저장해 주고 있었습니다. 하지만 현재는 “어떤 사용자가 언제 어떤 quest를 통해서 어떤 쿠폰을 발급받았는지”에 대한 정보를 promotion_hisotry – coupon_group의 quest_id를 통한 조인으로 확인할 수 있으므로 중복해서 데이터를 저장할 필요가 없어 제거해 주었습니다.

마지막으로 핵심이 될 수 있는 부분은 프로모션 참여 조건뿐 아니라 출력해야 하는 데이터들에 대한 정보를 DB에 저장해둔다는 점입니다. 이전에는 프로덕션 코드에서 promotion_type에 따른 분기를 수행해 주었습니다. 하지만 어떤 값을 보여줄지(display), 어떻게 참여할지(condition), 참여 기간(date_unit) 등의 조합에 따라 굉장히 많은 경우의 수가 존재하게 되고 그렇게 되면 promotion_type 이 굉장히 많아지고 그에 따른 분기 코드를 작성하는 등 수정이 따릅니다.

따라서 프로모션에는 날짜 단위나 프로모션 조건, 디스플레이와 같이 프로모션 단위의 조건들을 연관시켜 두고, 프로모션 하위의 참여 단위라고 볼 수 있는 퀘스트에서는 조건에 따른 옵션들 그리고 쿠폰 그룹과 같은 보상과 연관을 가지도록 설계하였습니다. 이를 통해서 새로운 프로모션이 추가되더라도 date_unit, promotion_condition, condition_option, display 등을 조합한 새로운 promotion, quest 그리고 그에 따른 보상(coupon_group)만 추가해 주면 코드에는 변경 없이 동작을 수행할 수 있게 됩니다.

간단하게 지금까지 다시 설계한 내용을 정리해 보면 다음과 같습니다.

  • 프로모션은 여러 개의 퀘스트와 연관을 가집니다.

    • 예를 들어, 3,000원, 5,000원, 7,000원 쿠폰 발급과 연관된 퀘스트, 1,000원, 2,000원과 연관된 퀘스트와 같이 하나의 프로모션은 여러 개의 퀘스트와 연관될 수 있습니다.
    • 퀘스트 개념을 파악하기 위해 다음과 같이 참고할 수 있습니다.

      • Promotion A

        • 마지막 주문 일자에 따라서 크게 3개의 questId로 나뉩니다. (신규, 최근 주문 X, 최근 주문 O)

        • 사용자가 “프로모션 조회 시” 조건에 따라 하나의 questId를 받습니다.

        • 실제 프로모션 참여 시에 questId를 가지고 요청을 보내게 되고, 조회되는 여러 coupon_group 중 랜덤하게 하나의 쿠폰 시퀀스를 발급합니다.

        • 퀘스트 참여 이후에는 참여 이력을 저장합니다.

      • Promotion B

        • 회원 등급에 따라 questId를 발급합니다.

        • 귀한분 – 1개, 더귀한분 – 2개, 천생연분 – 3개

        • 각각의 퀘스트 별로 참여하게 되면 쿠폰 시퀀스를 발급하고 이후 참여 이력을 저장합니다.

  • 하나의 퀘스트는 여러 개의 쿠폰 그룹과 연관이 있을 수 있으므로 쿠폰 그룹은 연관된 퀘스트에 대한 id를 가집니다.

    • 예를 들어 3000원, 5000원, 7000원 각각은 하나의 쿠폰 그룹 시퀀스를 가지게 됩니다. 그리고 이러한 쿠폰들은 모두 동일한 하나의 퀘스트와 연관되는 다대일 연관관계입니다.
  • 기존과는 다르게 promotion 테이블은 프로모션에 대한 정보를 저장합니다.

    • 기존의 프로모션 테이블은 큰 의미가 없는 테이블이었습니다. 하지만 프로모션에 대한 정보들, 여기서는 참여 단위나 프로모션의 조건, 디스플레이하는 조건 등에 대한 정보를 담고 있습니다. (추가적으로 프로모션에 대한 설명과 같은 것들을 저장할 수도 있을 것입니다.)
  • promotion_history는 각각의 quest에 대한 참여 여부를 저장합니다.

    • 말 그대로 프로모션에 대한 참여를 저장하는데 프로모션 하위 개념인 quest의 id (식별자)를 저장합니다.
    • 한 프로모션에 여러 퀘스트가 존재하다 보니 어떤 퀘스트에 참여하였는지를 저장하는 것이 필요하고 promotion에 대한 정보가 필요하다면 join 해서 결과를 얻어올 수 있기에 quest_id만 가지도록 하였습니다.
  • 기존의 coupon 테이블은 불필요하므로 제거하였습니다.

  • 프로모션은 참여 단위와 같은 프로모션에 대한 조건을 가집니다. 따라서 promotion 은 date_unit 과의 연관을 가집니다.

  • 프로모션은 마찬가지로 출력하는 내용에 대한 조건을 가지도록 하였습니다. display에 따라 예를 들어 프로모션 B는 회원의 닉네임이나 이번 달 주문수와 같은 정보를 노출하게 됩니다.

  • 각 quest는 연관된 condition_option을 가집니다. 이 condition_option은 어떤 정보를 퀘스트에 대한 참여 조건으로 사용할지와 연관됩니다. 예를 들어 첫주문, 직전 달 주문수 와 같은 조건입니다.

그리고 이러한 ERD 설계와 함께 사용자 요청에 따른 흐름은 다음과 같이 정리할 수 있습니다.

  • 프로모션 조회
    • promotionId를 통해서 특정 questId를 응답해 준다.
      • promotionId에 따른 promotion_condition.conditions의 값을 조회한다.
      • conditions를 이용해서 condition_option을 구체화한다. (ex. 실버등급, 3,000 or 5,000 or 7,000 원)
      • condition_option을 가지고 특정 quest를 구해 반환한다. (Flux)
    • 참여 여부를 함께 반환한다.
      • date_unit에 따라 참여 여부를 결정한다. (DAILY인 경우 어제 참여한 것은 무관)
    • 응답 데이터 형태에 따라 응답한다. (Promotion 테이블에 응답 형태와 관련된 부분도 필요해 보임)
  • 프로모션 참여
    • 앞선 프로모션 조회를 통해 발급받은 questId를 포함해 요청한다.
    • quest에 따라 발급 조건을 수행한다. (ex. 3,000 or 5,000 or 7,000 인 경우 랜덤하게, 실버등급과 같이 프로모션 B의 경우 특별한 조건 X)
    • 주요 응답 데이터: couponGroupSequence

그리고 여기서 프로모션마다 달라지는 부분과 공통된 부분을 나눠보면 아래 그림과 같습니다. (달라지는 부분은 마름모 모양으로 구분해 주었습니다.)

프로모션 조회에서는 조건 옵션에 따라서 questId를 구하는 부분과 출력 형태를 정하는 부분을 제외하고는 모두 동일합니다.

예를 들어, 마케터 A의 경우에는 전체 주문수(totalOrderCount)와 4달간의 주문수(fourMonthOrderCount) 를 가지고 조건 옵션(ConditionOption)이 결정되어 그에 따른 questId 가 하나 반환되지만 마케터 B의 경우에는 직전달 주문수(lastMonthOrderCount)를 가지고 그에 따른 questId 가 하나가 아닌 여러 개 반환 가능합니다. 또한 출력 형태의 경우 마케터 A는 회원의 닉네임 정보등이 불필요하지만 마케터 B는 필요합니다.

다음으로 프로모션 참여의 경우에는 참여 여부를 판단하는 것과 쿠폰 그룹을 구하는 부분이 다릅니다.

예를 들어, 마케터A는 랜덤하게 쿠폰을 1,000원 혹은 2,000원과 같이 발급하여야 하지만, 마케터B는 퀘스트와 쿠폰 그룹이 일대일로 대응하므로 랜덤과 같은 사항 없어 바로 조회해서 발급해 주면 됩니다.

따라서 이렇게 달라지는 부분을 Strategy (전략)으로 분리하고 열거형에 따라서 적절한 전략을 선택해 로직을 수행하도록 수정하였습니다.

프로모션 조회

[프로모션 조회: promotionId 를 이용해 특정 questId 를 응답해준다.]

  • promotionId에 따른 promotion_condition.conditions의 값을 DB로부터 조회해 적절한 PromotionCondition을 찾는다.
  • promotionId를 통해서 적절한 DisplayCondition 와 함께 DateUnit 을 찾는다.
  • conditions를 이용해서 condition_option을 구체화한다. (ex. 실버등급, 3,000 or 5,000 or 7,000 원)
  • condition_option을 가지고 특정 quest를 구해 반환한다. (Flux)

요청에 포함된 promotionId를 가지고 각 프로모션 별로 조건(최근 4개월 혹은 직전달 주문수)을 구하고, 각 조건을 지원하는 전략(strategy)에 따라서 주문수를 조회해오고 적절한 조건 옵션(Gold, Recently …)을 구해줍니다. 이렇게 얻은 각 옵션은 quest 와 연관을 가지는데 이때 여러 개의 quest와 연관을 가질 수 있고, 이를 Flux 형태로 응답을 반환해 줍니다. (예: Gold 등급의 경우 총 3개의 quest와 연관)

위에서 설명한 전체적인 내용이 PromotionService의 아래 두 메소드로 구성됩니다.

    @Transactional(readOnly = true)
    public Mono<PromotionInquiryResponse> getPromotion(final Long promotionId, final String memberNumber) {
        final Mono<PromotionCondition> promotionCondition = promotionRepository.findCondition(promotionId)
                .map(PromotionCondition::of);
        final Mono<DisplayCondition> displayCondition = promotionRepository.findDisplay(promotionId)
                .map(DisplayCondition::of);
        final Mono<DateUnit> dateUnit = promotionRepository.findDateUnitByPromotionId(promotionId).map(DateUnit::of);

        return Mono.zip(promotionCondition, displayCondition, dateUnit)
                .flatMap(tuple -> getPromotionDisplay(memberNumber, tuple));
    }
    private Mono<PromotionInquiryResponse> getPromotionDisplay(final String memberNumber,
                                                               final Tuple3<PromotionCondition, DisplayCondition, DateUnit> tuple) {
        final ConditionStrategy conditionStrategy = conditionOptionFinder.find(tuple.getT1());
        final DisplayStrategy displayStrategy = displayFinder.find(tuple.getT2());
        final Flux<Long> questIds = conditionStrategy.getConditionOption(memberNumber)
                .flatMapMany(questRepository::findByOption);
        return displayStrategy.getPromotionDisplay(questIds, tuple.getT3().getUnit(), memberNumber);
    }

먼저 각 프로모션 조건에 따라 전략(ConditionStrategy)를 구하는 ConditionOptionFinder 을 위의 코드에서 확인하실 수 있는데요!

ConditionOptionFinder는 다음과 같습니다.

@Component
public class ConditionOptionFinder {

    public final List<ConditionStrategy> conditionStrategies;

    public ConditionOptionFinder(final List<ConditionStrategy> conditionStrategies) {
        this.conditionStrategies = conditionStrategies;
    }

    public ConditionStrategy find(final PromotionCondition promotionCondition) {
        return conditionStrategies.stream()
                .filter(it -> it.isSupport(promotionCondition))
                .findAny()
                .orElseThrow(() -> new NotSupportConditionOptionException(promotionCondition.name()));
    }
}

여기서 DB로부터 조회해온 promotion_condition.conditions에 따라 적절한 PromotionCondition을 찾고, 그에 따른 전략을 구하게 됩니다.

public interface ConditionStrategy {

    Mono<ConditionOption> getConditionOption(final String memberNumber);
    boolean isSupport(PromotionCondition promotionCondition);
}

ConditionStrategy는 옵션을 구하는 각 전략의 상위 인터페이스이고, 각 전략들은 이 인터페이스를 구현(implements) 합니다.

크게 2가지 메소드를 제공합니다. PromotionCondition 열거형 상수를 받아서 지원하는 전략인지를 확인한 후 적절한 옵션을 반환해 주는 메소드를 가지고 있습니다.

여기서 isSupport() 메소드는 ConditionOptionFinder에서 수행하여 적절한 ConditionStrategy를 찾는데 사용됩니다.

[RecentlyStrategy]

ConditionStrategy를 구현하는 구체(concrete) 클래스의 예시로 프로모션 A를 위한 RecentlyStrategy를 볼 수 있을 것 같습니다.

전체 주문수와 최근 4개월 동안의 주문수를 OrderClient를 통해서 구해오고 적절한 option을 생성해 반환하는 것을 확인하실 수 있습니다.

@Component
@RequiredArgsConstructor
public class RecentlyStrategy implements ConditionStrategy {

    private static final LocalDate RECENTLY_BASE_DATE = LocalDate.now().minusMonths(4).with(firstDayOfMonth());

    private final OrderClient orderClient;

    @Value("${SERVER.URI.ORDER}")
    private String orderCountUri;

    @Override
    public Mono<ConditionOption> getConditionOption(final String memberNumber) {
        final Mono<Integer> totalOrderCount = queryOrderCount(new OrderClientRequeset(memberNumber));
        final Mono<Integer> fourMonthOrderCount = queryOrderCount(new OrderClientRequeset(memberNumber, RECENTLY_BASE_DATE));

        return Mono.zip(totalOrderCount, fourMonthOrderCount)
                .map(orderCount -> ConditionOption.of(orderCount.getT1(), orderCount.getT2()));
    }

    @Override
    public boolean isSupport(final PromotionCondition promotionCondition) {
        return promotionCondition.equals(RECENTLY_ORDER_COUNT);
    }

    private Mono<Integer> queryOrderCount(final OrderClientRequeset request) {
        return orderClient.inquireRequest(request, orderCountUri)
                .map(OrderClientResponse::getData);
    }
}

프로모션 B와 연관된 구체(concrete) 클래스에서는 직전 달 주문수만을 OrderClient를 통해서 조회해오고, 적절한 option을 찾아서 반환해 주게 될 것입니다.

이처럼 프로모션 조건에 따른 조건 옵션(ConditionOption) 을 구하는 로직은 프로모션마다 달라지게 될 것입니다. 예를 들어, 현재는 주문수와만 연관이 있지만 평균 주문 금액 등이 조건이 될 수도 있습니다.

하지만 현재와 같은 구조를 유지한다면 요구사항에 따른 ConditionStrategy만 추가로 구현해 주면 되고, PromotionService의 로직, 즉 프로모션 조회에 대한 전체적인 틀은 유지된 채로 새로운 프로모션을 지원해 줄 수 있게 됩니다. 👍

[프로모션 조회 응답에 quest 참여 여부 함께 반환]

프로모션 조회 시에 참여 여부를 함께 반환해 주어야 합니다. 이때 각 프로모션 별로 DateUnit에 따라 이미 참여하였는지를 판단하고 응답에 포함해 주는 작업을 DisplayStrategy에서 진행해 주도록 하였습니다.

퀘스트 별로 참여 여부를 조회해와야 하므로 quest를 조회하면서 참여 여부를 함께 조회하여 DTO로 반환하도록 구현하였습니다. 또한 @Query 어노테이션을 이용해 쿼리를 직접 작성하고 있기 때문에 테스트 코드에서 사용하는 H2와 실제 사용되는 DBMS인 Mysql, 양쪽 모두에서 지원되는 CASE WHEN THEN 구문을 활용해 boolean 값을 받아오도록 쿼리를 구성하였습니다.

여기서 참여 여부에 해당하는 서브 쿼리 부분을 별도의 쿼리로 조회해와도 되겠지만, N + 1 쿼리 문제가 발행하기에 아래와 같이 한 번의 쿼리로 조회해오도록 해주었습니다.

    @Query("SELECT quest.id, (CASE WHEN (SELECT COUNT(*) FROM promotion_history "
            + "JOIN quest ON quest.id = promotion_history.quest_id "
            + "WHERE promotion_history.participate_date >= :dateUnit "
            + "AND promotion_history.member_number = :memberNumber "
            + "AND promotion_history.quest_id = :questId LIMIT 1) = 1 THEN 'true' ELSE 'false' END) as participation, "
            + "(SELECT coupon_group.coupon_amount FROM coupon_group "
            + "JOIN quest ON coupon_group.quest_id = quest.id WHERE quest.id = :questId LIMIT 1) as coupon_amount "
            + "FROM quest WHERE quest.id = :questId")
    Mono<QuestDto> findQuestWithParticipationAndAmount(final Long questId, final LocalDate dateUnit, final String memberNumber);

    @Query("SELECT quest.id, (CASE WHEN (SELECT COUNT(*) FROM promotion_history "
            + "JOIN quest ON quest.id = promotion_history.quest_id "
            + "WHERE promotion_history.participate_date >= :dateUnit "
            + "AND promotion_history.member_number = :memberNumber "
            + "AND promotion_history.quest_id = :questId LIMIT 1) = 1 THEN 'true' ELSE 'false' END) as participation "
            + "FROM quest WHERE quest.id = :questId")
    Mono<QuestDto> findQuestWithParticipation(final Long questId, final LocalDate dateUnit, final String memberNumber);

[프로모션B의 경우 회원의 닉네임과 주문수, 금액을 함께 응답한다.]

프로모션 B의 경우에만 “회원의 닉네임”, “이번 달 주문수”, “쿠폰 금액” 을 함께 응답하도록 구현해 주어야 합니다. 이 부분 또한 DisplayStrategy를 활용해 주었습니다.

공통적으로 “프로모션 조회”의 경우 promotionId 와 memberNumber 가 요청으로 들어오게 되고, 그에 따른 적절한 questId(List 형태 가능)를 응답해준다는 틀 안에서 출력 결과에 대한 전략 패턴 적용과 적절한 quest를 찾는 과정에서의 전략 패턴 적용을 통해서 프로모션에 따른 서로 다른 로직을 수행하고 응답을 내뱉어줄 수 있도록 구현해 주었습니다.

만약 새로운 프로모션 요구사항이 추가된다면 기존의 PromotionService 쪽의 코드에는 변경이 없이 새로운 조건과 전략만 추가해 주면 되므로 기존 기능을 재활용할 수 있게 됩니다.

프로모션 B의 경우에만 회원의 닉네임과 주문수, 금액을 함께 응답한다.” 부분에 대한 DisplayStrategy 코드는 아래와 같습니다.

@Component
@RequiredArgsConstructor
public class GradeDisplayStrategy implements DisplayStrategy {

    private static final LocalDate CURRENT_MONTH = LocalDate.now().with(firstDayOfMonth());

    private final QuestRepository questRepository;
    private final MemberClient memberClient;
    private final OrderClient orderClient;

    @Value("${SERVER.URI.MEMBER}")
    private String memberInfoUri;

    @Value("${SERVER.URI.ORDER}")
    private String orderCountUri;

    @Override
    public Mono<PromotionInquiryResponse> getPromotionDisplay(final Flux<Long> questIds, final LocalDate dateUnit,
                                                              final String memberNumber) {
        final Mono<List<QuestDto>> questResponses = questIds
                .flatMap(it -> questRepository.findQuestWithParticipationAndAmount(it, dateUnit, memberNumber))
                .collectList();
        final Mono<MemberClientResponse> memberClientResponse = memberClient
                .inquireRequest(new MemberClientRequest(memberNumber), memberInfoUri);
        final Mono<Integer> currentMonthOrderCount = queryOrderCount(new OrderClientRequeset(memberNumber, CURRENT_MONTH));

        return Mono.zip(memberClientResponse, currentMonthOrderCount, questResponses)
                .map(it -> new PromotionInquiryResponse(it.getT1().getNickName(), it.getT2(), it.getT3()));
    }

    @Override
    public boolean isSupport(final DisplayCondition displayCondition) {
        return displayCondition.equals(GRADE);
    }

    private Mono<Integer> queryOrderCount(final OrderClientRequeset request) {
        return orderClient.inquireRequest(request, orderCountUri)
                .map(OrderClientResponse::getData);
    }
}

프로모션 B와 달리 프로모션 A는 단순히 questId만을 응답으로 내리면 되므로 아래와 같이 간단하게 DisplayStrategy 구현해 주었습니다.

@Component
@RequiredArgsConstructor
public class OnlyQuestDisplayStrategy implements DisplayStrategy {

    private final QuestRepository questRepository;

    @Override
    public Mono<PromotionInquiryResponse> getPromotionDisplay(final Flux<Long> questIds, final LocalDate dateUnit,
                                                              final String memberNumber) {
        return questIds
                .flatMap(it -> questRepository.findQuestWithParticipation(it, dateUnit, memberNumber))
                .collectList()
                .map(PromotionInquiryResponse::new);
    }

    @Override
    public boolean isSupport(final DisplayCondition displayCondition) {
        return displayCondition.equals(ONLY_QUEST);
    }
}

프로모션 참여

앞서 조회에서와 비슷하게 프로모션 참여 이후 발급되는 쿠폰의 조건이 다양하므로(Random 등등) 이 또한 전략 패턴을 활용하여 기존 틀에서 전략만 추가하여 새롭게 추가되는 프로모션에 대응할 수 있도록 구현하려고 합니다.

코드 구성은 아래와 같습니다.

    public Mono<PromotionResponse> participate(final Long questId, final String memberNumber) {
        return promotionRepository.findDateUnitByQuestId(questId)
                .map(DateUnit::valueOf)
                .flatMap(dateUnit -> getPromotionHistoryByDateUnit(questId, memberNumber, dateUnit.getUnit()))
                .flatMap(promotionHistory -> participateIfNotPresent(questId, memberNumber, promotionHistory));
    }

PromotionHistory와 함께 DateUnit을 가지고, 날짜 단위로 참여한 여부가 있는지를 먼저 확인합니다.

이때 프로모션 히스토리에 대한 조회는 동시성 문제를 해결하기 위해 for update 쿼리를 활용하였습니다.

이후 만약 참여한 이력이 없다면 questId를 가지고 해당 퀘스트에 대해서 참여를 수행하고 결과로서 쿠폰에 대한 쿠폰 시퀀스와 함께 쿠폰 금액 그리고 참여 날짜를 반환해 주며, promotionHistory를 저장하게 됩니다.

아래는 questId 와 ConditionOption을 통해 적절한 쿠폰 그룹을 찾는 CouponGroupStrategy 인터페이스입니다.

public interface CouponGroupStrategy {

    Mono<CouponGroup> findCouponGroup(final Long questId, final ConditionOption conditionOption);
    boolean isSupport(PromotionCondition promotionCondition);
}

추석 프로모션의 경우에는 1,000원 혹은 2,000원과 같이 랜덤하게 발급해야 하는 요구사항이 있었는데, 그 부분을 Strategy 안쪽으로 밀어 넣어주었습니다. 여기서 남은 수량이 없는 경우 같은 금액 그룹 안에서 랜덤하게 발급하는 부분도 구현해 주고 있습니다.

@Component
@RequiredArgsConstructor
public class ChuseokCouponGroupStrategy implements CouponGroupStrategy {

    private static final NumberGenerator NUMBER_GENERATOR = new RandomNumberGenerator();

    private final CouponGroupRepository couponGroupRepository;

    @Override
    public Mono<CouponGroup> findCouponGroup(final Long questId, final ConditionOption conditionOption) {
        final Flux<CouponGroup> couponGroup = couponGroupRepository.findByQuestId(questId);

        return couponGroup
                .map(CouponGroup::getCouponAmount)
                .collectList()
                .flatMap(it -> getLeftQuantityCouponGroups(
                        CouponAmount.of(conditionOption, NUMBER_GENERATOR), couponGroup, it)
                );
    }

    @Override
    public boolean isSupport(final PromotionCondition promotionCondition) {
        return promotionCondition.equals(RECENTLY_ORDER_COUNT);
    }

    private Mono<CouponGroup> getLeftQuantityCouponGroups(final CouponAmount couponAmount,
                                                          final Flux<CouponGroup> couponGroups,
                                                          final List<Integer> amountGroup) {
        Collections.shuffle(amountGroup);
        return couponGroups.filter(it -> !it.isExhaustion())
                .filter(it -> it.getCouponAmount() == couponAmount.getAmount())
                .switchIfEmpty(couponGroups
                        .filter(it -> !it.isExhaustion())
                        .filter(it -> amountGroup.contains(it.getCouponAmount()))
                        .single()
                )
                .single();
    }
}

여기서 같은 금액 그룹인지는 CouponAmount의 predicate 조건에서 ConditionOption 을 통해 판단하도록 해주었습니다.

public enum CouponAmount {

    ONE_THOUSAND(1_000, (option, percentage) -> option.equals(RECENTLY) && percentage <= 70),
    TWO_THOUSAND(2_000, (option, percentage) -> option.equals(RECENTLY) && (70 < percentage && percentage <= 100)),
    THREE_THOUSAND(3_000, (option, percentage) -> option.equals(NOT_RECENTLY) && percentage <= 50),
    FIVE_THOUSAND(5_000, (option, percentage) -> option.equals(NOT_RECENTLY) && (50 < percentage && percentage <= 85)),
    SEVEN_THOUSAND(7_000, (option, percentage) -> option.equals(NOT_RECENTLY) && (85 < percentage && percentage <= 100)),
    TEN_THOUSAND(10_000, (option, percentage) -> option.equals(NEW));

    private final int amount;
    private final BiPredicate<ConditionOption, Integer> biPredicate;

    CouponAmount(final int amount, final BiPredicate<ConditionOption, Integer> biPredicate) {
        this.amount = amount;
        this.biPredicate = biPredicate;
    }

    public static CouponAmount of(final ConditionOption option, final NumberGenerator generator) {
        final int percentage = generator.generate();
        return Arrays.stream(values())
                .filter(it -> it.biPredicate.test(option, percentage))
                .findAny()
                .orElseThrow(InvalidAmountException::new);
    }

    public int getAmount() {
        return amount;
    }
}

회원 등급에 따른 프로모션은 퀘스트와 연관된 couponGroupSequence를 가지고 새로운 쿠폰 시퀀스를 발급하도록 구현해 주었습니다.

@Component
@RequiredArgsConstructor
public class GradeCouponGroupStrategy implements CouponGroupStrategy {

    private final CouponGroupRepository couponGroupRepository;

    @Override
    public Mono<CouponGroup> findCouponGroup(final Long questId, final ConditionOption conditionOption) {
        return couponGroupRepository.findByQuestId(questId).single();
    }

    @Override
    public boolean isSupport(final PromotionCondition promotionCondition) {
        return promotionCondition.equals(PromotionCondition.LAST_MONTH_ORDER_COUNT);
    }
}

마무리

이렇게 해서 약 한 달간 짧다면 짧고, 길다면 긴 파일럿 프로젝트를 마무리하게 되었습니다. (이제 실무 투입 준비 끝)

우아한테크코스를 하며 페어 프로그래밍이나 팀으로 활동하는 시간이 많아지며 정말 순수하게 혼자서 처음부터 끝까지 개발하는 것이 정말 오랜만이라 어색하기도 하고, 쓸쓸하기도 했습니다.🥲

하지만 이렇게 혼자서 개발하는 시간을 가지다 보니 이제까지는 팀으로 함께하며 감춰졌던 스스로 부족한 점을 다시 한번 인지할 수 있었고, 함께하는 소중함을 깨닫는 의미 있는 시간이 되었습니다.

특히 파일럿을 진행하며 ERD부터 설계까지 여러 번 새롭게 하는 과정을 거치면서 확장성 있게 그리고 재활용성을 고려하며 설계하는 것이 아직 많이 힘들다는 것을 느꼈고, 앞으로 많이 배워나갈 부분이라는 생각이 들었습니다.

앞으로 팀원들과 함께 좋은 인사이트를 주는 팀원으로서 성장해나가려고 합니다!!
긴 글 읽어주셔서 감사합니다.😄