[데브툰] 리팩토링: 프로모션 조회 설계 및 성능 개선 도전하기 - 설계 편

2024. 5. 14. 22:12Project/데브툰

이번 포스팅의 주제는 '프로모션 조회 설계 개선기'입니다. 주요 내용은 기존 설계의 근본적인 문제를 파악하고 이를 개선해 나가는 과정입니다. 앞으로 글의 가독성을 위해 말을 편하게 할 예정입니다. 양해 부탁드립니다. 🍎🍎🍎


목차

기존 설계 문제점 파악

프로모션, 프로모션 속성 테이블 파악

  기획 단계 확인
  프로모션 및 프로모션 속성 테이블 완벽 이해

프로모션 서비스 로직을 객체지향적으로 개선

  인터페이스 활용
  문자열 대신 enum 타입 사용
  람다식 적용

개선 후 로직 및 서비스 코드에 적용

회고

  만족한 점

  아쉬운 점

  다음에는 이렇게

 

기존 설계 문제점 파악

첫 번째, 프로모션과 속성은 일대다 관계임에도 프로모션당 하나의 속성만 등록할 수 있다.

🔥 프로모션 등록 시 여러 속성을 등록 가능하도록 변경이 필요하다.

여기서 ‘속성’이란, “프로모션 적용 대상”을 의미한다. 예) 7월 프로모션의 경우 '7월'이 프로모션 적용 대상인 속성이다.

 

두 번째, 프로모션 조회 시 하나의 프로모션에 속한 여러 속성을 출력해야 하는데 하나의 속성만 출력된다. 그리고 불필요한 페이지네이션이 존재한다. 

아래는 Postman을 사용한 프로모션 조회 테스트 결과이다. 예를 들어, 5월 프로모션을 등록할 때 프로모션 속성(attributeName)으로 target_month 뿐만 아니라 함께 등록한 target_genre도 출력되어야 하지만, 현재는 target_month만 출력되고 있다. 또한, 하단 부분에 불필요한 페이지네이션 관련 정보들이 함께 출력되는 것도 확인할 수 있다.

더보기
{
    "statusMessage": "성공",
    "data": {
        "content": [
            {
                "promotionId": 1,
                "description": "5월 프로모션입니다.",
                "startDate": "2024-05-20T22:49:00",
                "endDate": "2024-07-13T23:59:59",
                "attributeName": "target-month",
                "attributeValue": "5"
            },
            {
                "promotionId": 6,
                "description": "공포 프로모션입니다.",
                "startDate": "2024-05-23T13:33:00",
                "endDate": "2024-08-31T23:59:59",
                "attributeName": "target-genre",
                "attributeValue": "horror"
            }
        ],
        "pageable": {
            "pageNumber": 0,
            "pageSize": 20,
            "sort": {
                "empty": true,
                "unsorted": true,
                "sorted": false
            },
            "offset": 0,
            "paged": true,
            "unpaged": false
        },
        "last": true,
        "totalElements": 4,
        "totalPages": 1,
        "first": true,
        "size": 20,
        "number": 0,
        "sort": {
            "empty": true,
            "unsorted": true,
            "sorted": false
        },
        "numberOfElements": 4,
        "empty": false
    }
}

🔥 구체적인 조회 방식을 결정해야 한다. 두 가지 방법을 생각했고 1번을 선택했다.

1번, (api 2개) 프로모션만 조회 / 프로모션 내 속성이 궁금하다면 프로모션 아이디를 통해 속성들 리스트 반환

2번, (api 1개) 프로모션 조회 시 연관된 프로모션 속성들 리스트로 한 번에 반환

 

세 번째, 할인율 로직 없음 (할인율은 결제 시 사용함)

현재 할인율 로직은 임시로 적어둔 것이다. 

더보기
private BigDecimal calculateTotalDiscountRate(List<PromotionEntity> promotions) {
    /**
     * TODO 현재 적용 가능한 프로모션 할인율 계산 로직 적용
     */
    BigDecimal totalDiscountRate = BigDecimal.ZERO;

    for (PromotionEntity promotion : promotions) {
        totalDiscountRate = totalDiscountRate.add(new BigDecimal("5"));  // 각 프로모션에 대해 5% 추가
    }
    totalDiscountRate = totalDiscountRate.divide(new BigDecimal("100")); // 0.05

    return totalDiscountRate;
}

 

🔥 한 프로모션 내 여러 속성 중 하나라도 할인 대상이 되면 프로모션 할인율이 단 1번 적용된다. 프로모션 별 중복 할인 가능 여부에 따라 할인율 로직을 다르게 가져갈 생각이다. 생각해 본 것을 그림으로 나타내보면 다음과 같다.

 

프로모션, 프로모션 속성 테이블 파악

기획 단계 확인

개선하기 전에 기획 단계에서 프로모션, 프로모션 속성 테이블 설계 부분을 살펴보도록 하자

 

나름 어떤 프로모션들이 있을지 생각한 것 같지만, 그래서 어떻게 구현할지에 대한 내용은 없다. 이번 프로젝트에서 기획자, 백엔드 개발자 역할을 맡았는데 기획 단계에서 기획자 입장에서만 서술한 것 같아 아쉬움이 남는다. 

 

이제 발등에 불이 떨어졌다. 어떤 프로모션과 프로모션 속성을 가져가고 싶어 했는지 처음부터 다시 찬찬히 살펴보기로 했다.

프로모션 및 프로모션 속성 테이블 완벽 이해 

우선 프로모션은 크게 2종류가 존재한다. (enum Type으로 관리)

1. 쿠키를 구매할 때 현금 할인 = 쿠키 구매의 할인(현금) = CASH_DISCOUNT

2. 웹툰을 구매할 때 쿠키 개수 할인 = 웹툰 구매할 때 쿠키 개수 할인 = COOKIE_QUANTITY_DISCOUNT

 

기획 단계에서 한 방식인 현재 발생 가능한 프로모션을 단순히 나열하는 대신, 어떤 요청(request)을 하고 어떤 응답(response)을 받을지 구체적으로 작성하여 모든 경우에 부합하는 설계로 개선하려한다.

1️⃣ 월간 프로모션: → 웹툰 구매할 때 쿠키 개수 할인
웹툰 출시 월(createdAt)에 미리 보기 구매 시 쿠키 1개 할인

프로모션테이블
{
    "id" : 1
    "description" : "5월 프로모션입니다.",
    "type" : COOKIE_QUANTITY_DISCOUNT,
    "discount_rate" : null,
    "discount_quantity" : 1,
    "isDiscountDuplicatable" : true,
    "startDate" : "2024-05-01T22:49:00",
    "endDate" : "2024-05-31T23:59:59"
}

속성테이블
{
    "attribute_id : 1,
    "promotion_id" : 1,
    "attributeName" : "웹툰_출시_월",
    "attributeValue" : "5"
}

 

2️⃣ 장르별 프로모션: → 웹툰 구매할 때 쿠키 개수 할인

7월에 스릴러 장르 웹툰 구매 시 쿠키 개수 1개 할인

프로모션테이블
{
    "id" : 2,
    "description" : "7월 스릴러 장르 파격 할인 행사입니다",
    "type" : COOKIE_QUANTITY_DISCOUNT, 
    "discount_rate" : null,
    "discount_quantity" : 1,
    "isDiscountDuplicatable" : ture,
    "startDate" : "2024-07-01T00:00:00", // -> 기간을 처음부터 7월로 맞춤
    "endDate" : "2024-07-31T23:59:59"
}

속성테이블
{
    "attribute_id : 3,
    "promotion_id" : 2,
    "attributeName" : "target_genre",
    "attributeValue" : "thriller"
}

 

3️⃣ 구매 개수별 프로모션: 많이 살 수록 쿠키 가격 할인 → 쿠키 구매의 할인(현금)

10개 : 2,000원

프로모션테이블
{
    "id" : 3,
    "description" : "대량 쿠키 구매 시 할인 행사입니다",
    "type" : CASH_DISCOUNT,
    "discount_rate" : 5,
    "discount_quantity" : null,
    "isDiscountDuplicatable" : false,
    "startDate" : "2024-05-01T22:49:00",
    "endDate" : "2024-05-13T23:59:59"
}

속성테이블
{
    "attribute_id : 5,
    "promotion_id" : 3,
    "attributeName" : "쿠키_구매_갯수",
    "attributeValue" : "10"
}

 

4️⃣ 구매 개수별 프로모션: 많이 살 수록 쿠키 가격 할인 → 쿠키 구매의 할인(현금)

30개 : 5,000원

프로모션테이블
{
    "id" : 3,
    "description" : "대량 쿠키 구매 시 할인 행사입니다",
    "type" : CASH_DISCOUNT,
    "discount_rate" : 10 ,
    "discount_quantity" : null,
    "isDiscountDuplicatable" : false,
    "startDate" : "2024-05-01T22:49:00",
    "endDate" : "2024-05-13T23:59:59"
}

속성테이블
{
    "attribute_id : 5,
    "promotion_id" : 3,
    "attributeName" : "쿠키_구매_갯수",
    "attributeValue" : "30"
}

50개 : 8,000원,

100개 : 17,000원...

 

5️⃣ 회원 등급별 프로모션:  

프리미엄 회원 할인: 쿠키 가격(현금) 20% 할인 → 쿠키 구매의 할인(현금)

프로모션테이블
{
    "id" : 6,
    "description" : "프리미엄 회원 쿠키 구매 할인 행사입니다",
    "type" : CASH_DISCOUNT,
    "discount_rate" : 20 ,
    "discount_quantity" : null,
    "isDiscountDuplicatable" : ture,
    "startDate" : "2024-05-01T22:49:00",
    "endDate" : "2024-05-13T23:59:59"
}

속성테이블
{
    "attribute_id : 5,
    "promotion_id" : 6,
    "attributeName" : "프리미엄_회원_할인",
    "attributeValue" : "premium"
}

 

6️⃣ 회원 등급별 프로모션:  

일반 회원 할인: 쿠키 가격(현금) 5% 할인 → 쿠키 구매의 할인(현금)

프로모션테이블
{
    "id" : 6,
    "description" : "프리미엄 회원 쿠키 구매 할인 행사입니다",
    "type" : CASH_DISCOUNT,
    "discount_rate" : 5 ,
    "discount_quantity" : null,
    "isDiscountDuplicatable" : true,
    "startDate" : "2024-05-01T22:49:00",
    "endDate" : "2024-05-13T23:59:59"
}

속성테이블
{
    "attribute_id : 5,
    "promotion_id" : 6,
    "attributeName" : "일반_회원_할인",
    "attributeValue" : "general"
}

 

7️⃣ 추가 할인 프로모션 → 쿠키 구매의 할인(현금)

이전 달에 50개 이상 쿠키를 구매한 회원은 다음 달 구매 시 추가 5% 현금 할인 적용

  • 이전 달 startDate ~ endDate 내 쿠키 구매 내역 중 50개 이상만 가져와서 확인

  isDiscountDuplicatable : ture → 5% 추가 할인 가능

중복 할인 가능하다는 것은 예를 들어, 프리미엄 회원이면서 이전 달 혜택을 받는 회원은 총 25% 할인받을 수 있다는 의미

프로모션테이블
{
    "id" : 7,
    "description" : "추가 할인 행사입니다 (중복 가능)",
    "type" : CASH_DISCOUNT,
    "discount_rate" : 5 ,
    "discount_quantity" : null,
    "isDiscountDuplicatable" : ture,
    "startDate" : "2024-05-01T22:49:00",
    "endDate" : "2024-05-13T23:59:59"
}

속성테이블
{
    "attribute_id : 5,
    "promotion_id" : 7,
    "attributeName" : "이전달_구매한_개수",
    "attributeValue" : "50"
}

 

확실히 어떤 데이터들이 오가는지 눈으로 확인할 수 있어 설계를 어떤 식으로 개선해야 할지 감을 잡을 수 있었다. 

 

프로모션 서비스 로직을 객체지향적으로 개선

인터페이스 적극 활용

문자열 대신 Enum Type 활용

람다식을 통한 코드 가독성 향상

인터페이스를 고려한 이유

구현 클래스가 인터페이스의 모든 메서드를 구현해야 하기 때문에 공통된 행동을 보장할 수 있다. 이는 추후 코드 변경이 발생해도 인터페이스를 통해 이루어지므로 유지보수성이 높아진다. 또한, 다양한 구현체를 쉽게 교체할 수 있어 확장성 면에서도 이점이 존재해 선택했다.

 

추상 클래스를 고려하지 않은 이유는 무엇일까?

현재 프로모션 설계에서는 여러 프로모션을 쉽게 등록하고 삭제하며, 할인율과 할인 개수를 계산하는 공통 로직이 필요한 상황이다. 인터페이스는 특정 행동을 강제하고 구현체를 쉽게 교체할 수 있어 시스템 확장에 적합하다. 반면, 추상 클래스는 코드 재사용에는 유리하지만 단일 상속만 지원하기 때문에 확장성 면에서 제한적이라 판단했다.

 

어디에 인터페이스를 적용할까?

1. 프로모션 Promotion 인터페이스화

왜? 다양한 프로모션이 생성되면 해당 인터페이스를 구현해서 사용하면 됨 → 할인율 계산 메서드 존재

예) CookiePromotion implements Promotion → 최종 쿠키 할인 수량을 반환

 

2. 프로모션 속성 Attribute 인터페이스화

왜? 다양한 프로모션 속성(AttributeName)이 생성되면 해당 인터페이스를 구현해서 사용하면 됨 → 프로모션 적용 대상인지 확인하는 메서드 존재

예) Genre, Author implements Attribute 

문자열대신 Enum Type을 고려한 이유

문자열로 프로모션 속성을 입력받는 방식은 개발자의 입력 실수로 런타임 에러가 발생할 수 있다. 반면, Enum 타입을 적용하면 잘못된 값 사용을 방지하고, 컴파일 시점에 오류를 감지할 수 있어 코드의 안정성을 높일 수 있다. 그리고 관련 속성을 한 클래스에서 관리할 수 있어 유지보수하기 쉽다는 이점도 존재한다.

람다식을 고려한 이유

코드를 간결하게 표현하고 싶었다. 복잡해 보이는 로직을 간단하게 표현하는 데 람다식이 적합하다고 판단했다. 그리고 코드가 간결하면 이해하기 쉬워지므로, 메서드 이름만 직관적으로 잘 짓는다면 코드를 이해하는 데 도움이 될 것이라 생각했다.

실제로 람다식을 사용하여 행동을 캡슐화 한 부분이 있고 컬렉션을 처리할 때 내부 반복을 통해 코드의 가독성을 높여 비즈니스 로직에 집중할 수 있도록 했다.

 

개선 후 로직 및 서비스 코드에 적용

AttributeName

어떤 프로모션이 있는지 해당 enum 클래스에 저장

@Getter
public enum AttributeName {

    TARGET_AUTHOR("target_author"),
    TARGET_GENRE("target_genre"),
    TARGET_MONTH("target_month"),
    RELEASE_MONTH("release_month"),
    COOKIE_PURCHASE_QUANTITY("cookie_purchase_quantity"),
    PREMIUM_MEMBER_DISCOUNT("premium_member_discount"),
    PREVIOUS_MONTH_COOKIE_PURCHASE("previous_month_cookie_purchase");

    private final String name;

    AttributeName(String name) {
        this.name = name;
    }

    public static AttributeName create(final String attributeName) {
        return Arrays.stream(AttributeName.values())
                .filter(a -> a.getName().equals(attributeName))
                .findFirst()
                .orElseThrow(() -> new DevtoonException(
                        ErrorCode.NOT_FOUND,
                        ErrorMessage.getResourceNotFound(ResourceType.PROMOTION, attributeName)));
    }

}

 

Promotion 인터페이스

  1. 구현 클래스 CookiePromotion
  2. 구현 클래스 CashPromotion
public interface Promotion {
    int calculateDiscount(WebtoonEntity webtoon);
}

// -------------------------------------------------------------------------

public class CookiePromotion implements Promotion {

    private Integer discountQuantity;
    private List<Attribute> attributes; 

    public CookiePromotion(
            Integer discountQuantity,
            List<Attribute> attributes
    ) {
        this.discountQuantity = discountQuantity;
        this.attributes = attributes;
    }

    /**
     * 할인 수량을 반환하는 메서드
     * : 모든 속성이 충족되어야만 쿠키 개수가 할인됩니다.
     */
    @Override
    public int calculateDiscount(WebtoonEntity webtoon) {
        boolean isPossible = attributes.stream()
                .allMatch(a -> a.isApply(webtoon));
        if (isPossible) {
            return discountQuantity;
        }
        return 0;
    }

}

 

Attribute 인터페이스

  1. 구현 클래스 Author
  2. 구현 클래스 Genre...
public interface Attribute {
    boolean isApply(WebtoonEntity webtoon);
}

// --------------------------------------------------------------------------

public class Author implements Attribute {
    private final String name;

    public Author(String name) {
        this.name = name;
    }

    @Override
    public boolean isApply(WebtoonEntity webtoon) {
        return webtoon.isWriter(name);
    }
}

// ---------------------------------------------------------------------------

public class Genre implements Attribute {
    private final String name;

    public Genre(String name) {
        this.name = name;
    }

    @Override
    public boolean isApply(WebtoonEntity webtoon) {
        return webtoon.isGenre(name);
    }
}

 

서비스 로직에 적용 : WebtoonPaymentService

4-1번, 5번

/**
 * 웹툰 미리보기 결제
 * : 웹툰 미리보기는 쿠키로 결제한다.
 */
@Transactional
public void register(final WebtoonPaymentCreateRequest request) {
   
    ...

    // 4. 현재 적용 가능한 프로모션 중
    List<PromotionEntity> activePromotions = promotionService.retrieveActivePromotions();

    // 4-1. COOKIE_QUANTITY_DISCOUNT에 해당하는 프로모션 조회 >>>>>>>>>>>>>>>>>>>>>>
    List<PromotionEntity> cookieQuantityDiscountActivePromotion = activePromotions.stream()
            .filter(PromotionEntity::isCookieQuantityDiscountApplicable)
            .toList();

    List<Promotion> promotions = new ArrayList<>();
    for (PromotionEntity promotionEntity : cookieQuantityDiscountActivePromotion) {
        List<Attribute> attributes =
                promotionAttributeRepository.findByPromotionEntityId(promotionEntity.getId()).stream()
                        .map(PromotionAttributeEntity::toModel)
                        .toList();

        Promotion promotion = new CookiePromotion(
                promotionEntity.getDiscountQuantity(),
                attributes
        );
        promotions.add(promotion);
    }

    // 5. 웹툰 구매시 쿠키 할인 개수 >>>>>>>>>>>>>>>>>>>>>>>>>>>>
    int discount = promotions.stream()
            .map(p -> p.calculateDiscount(webtoon))
            .reduce(0, Integer::sum);

    ...

}

private Long getWebtoonViewerIdOrThrow(final Long webtoonViewerId) {
    return webtoonViewerRepository.findById(webtoonViewerId)
            .orElseThrow(() -> new DevtoonException(
                    ErrorCode.NOT_FOUND,
                    ErrorMessage.getResourceNotFound(ResourceType.WEBTOON_VIEWER,
                            webtoonViewerId))
            ).getId();
}

private Long getWebtoonIdOrThrow(final Long webtoonId) {
    return webtoonRepository.findById(webtoonId)
            .orElseThrow(() -> new DevtoonException(
                    ErrorCode.NOT_FOUND,
                    ErrorMessage.getResourceNotFound(ResourceType.WEBTOON, webtoonId))
            ).getId();
}

 

회고

만족한 점

기존 설계에 어떤 문제점이 있는지 먼저 파악한 점.

개선에 초점을 맞추지 않고 우선 현재 설계에서의 문제를 파악한 후, 이를 개선하기 위해 어떤 방식을 도입할지 중점적으로 고민한 결과 코드가 이전보다 깔끔해졌다고 생각한다.

 

아쉬운 점

기획 단계에서는 서비스가 간단할 것이라 생각하여 도메인과 엔티티를 분리하지 않았다. 그러나 설계를 개선하면서 엔티티에 특정 비즈니스 로직에 사용될 여러 메서드를 추가하게 되어, 결과적으로 엔티티의 순수성을 유지하지 못하고 결합도가 높아져 아쉬움이 남는다.

 

아래는 관련 코드이다.

PromotionEntity

더보기
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Table(name = "promotion")
@Entity
public class PromotionEntity extends BaseEntity {

    ...
    
    /**
     * 프로모션 할인 유형 중 '현금 할인'에 해당하는지 확인하는 메서드
     */
    public boolean isCashDiscount() {
        return discountType.equals(DiscountType.CASH_DISCOUNT);
    }

    /**
     * 중복 할인이 불가능함을 확인하는 메서드
     */
    public boolean isNotDiscountDuplicatable() {
        return !isDiscountDuplicatable;
    }

    /**
     * 프로모션 할인 유형 중 '웹툰 구매시 쿠키 개수 할인'에 해당하는지 확인하는 메서드
     */
    public boolean isCookieQuantityDiscountApplicable() {
        return discountType.equals(DiscountType.COOKIE_QUANTITY_DISCOUNT);
    }
}

WebtoonEntity

더보기
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Table(name = "webtoon")
public class WebtoonEntity extends BaseEntity {

    ...
    
    public boolean isGenre(final String attributeValue) {
        return genre.isSame(attributeValue);
    }

    public boolean isWriter(String name) {
        return writerName.equals(name);
    }
}

 

 

다음에는 이렇게

도메인과 엔티티를 분리하는 이유를 명확히 학습한 후, 다음 프로젝트에서는 이를 나누어 진행해 보고자 한다.