[데브툰] 리팩토링: 쿠키 결제 로직 4단계로 개선하기 (feat. 원시값 포장)

2024. 5. 20. 17:30Project/데브툰

이번 포스팅에서는 결제 로직4단계에 걸쳐 점차적으로 개선하는 과정을 소개할 예정입니다. 해당 글을 마지막으로 데브툰 프로젝트를 1차적으로 마무리할 계획인데요. 왜냐하면 이력서와 포트폴리오 작업이 밀렸기 때문입니다.(미-소) 어느 정도 정리가 되면 팀원과의 회의를 통해 2차 작업(프론트 작업, 배포 및 운영, 조회 성능 최적화)을 이어서 진행할 예정입니다. 앞으로의 글은 가독성을 위해 말을 편하게 할 예정입니다. 양해 부탁드립니다. 🍎🍎🍎


목차

문제 상황

문제 코드

리팩토링 후보군 분석

  후보1: 원시값으로 포장 → Price 클래스

  후보2: 다양한 숫자 형식을 처리하기 위한 몇 가지 인터페이스 및 클래스 분석 → 적용 

해결: (원시)값 타입 클래스 도입

  코드 변화 4단계 및 관련 PR

회고

  만족한 점

  아쉬운 점

  다음에는 이렇게


문제 상황

CookiePaymentEntity 클래스 내 쿠키 결제 관련 필드들이 하나의 공통된 데이터 타입이 아닌 Integer 또는 BigDecimal이 혼재되어 있어 계산 로직이 복잡해짐


문제 코드

CookiePaymentEntity

@Entity
public class CookiePaymentEntity extends BaseEntity {
    ...

    @Column(name = "quantity", nullable = false)
    private Integer quantity;

    @Column(name = "cookie_price", nullable = false)
    private BigDecimal cookiePrice; 

    @Column(name = "total_discount_rate", nullable = false)
    private Integer totalDiscountRate; 
    
    ...
    
}

 

CookiePaymentService

1️⃣ 주석 4번: 결제 로직의 복잡성

필드별로 서로 다른 데이터 타입(Integer, BigDecimal)을 사용함으로써 캐스팅의 복잡성 존재

→ 결제할 때마다 캐스팅을 반복하면 재사용성이 떨어짐

 

2️⃣ 할인율 계산 로직 → 현재는 임시 코드로 존재

@Transactional
public void register(final CookiePaymentCreateRequest request) {
    // 1. webtoonViewerId가 DB에 존재하는지 확인

    // 2. cookie policy에서 현재 cookie 가격 조회: activeCookiePrice

    // 3. 현재 활성 프로모션 조회 : activePromotions
  
    /*
       4. 쿠키 결제 가격 계산
       요청한 쿠키 개수 * 현재 쿠키 가격 - 프로모션 할인율(회원등급,각종 프로모션 적용)
       calculateTotalDiscountRate는 임시로직
    */
    Integer totalDiscountRate = calculateTotalDiscountRate(activePromotions);
    Integer quantity = request.getQuantity();
    // 캐스팅
    BigDecimal totalPrice = activeCookiePrice.multiply(new BigDecimal(quantity)); 
    Integer totalDiscountRatePercentage = 1 - totalDiscountRate;
    // 캐스팅
    BigDecimal discountedTotalPrice = totalPrice.multiply(new BigDecimal(totalDiscountRatePercentage)); 

    // 5. cookiePaymentEntity 생성 후 DB저장
    CookiePaymentEntity cookiePayment = CookiePaymentEntity.create(
            webtoonViewerId,
            quantity,
            activeCookiePrice,
            totalDiscountRate
    );
    cookiePaymentRepository.save(cookiePayment);

    // 6. webtoonViewerId에 해당하는 cookie wallet 조회 후 quantity만큼 증가
 
}

 

리팩토링 후보군 분석

공식 문서를 참고해 후보들을 선정한 후, 각 후보의 특징과 장단점을 분석해 보자. 그런 다음, 현재 프로젝트에 적합한지를 평가하고, 적합한 부분이 있다면 구체적인 구현 계획을 추가할 예정이다. (간단히 말해, 후보 선정 → 장단점 추려서 ⇒ 적용)

후보 1: 원시값으로 포장 → Price클래스

현재 BigDecimal 필드를 사용하는 부분을 별도의 값 타입 클래스(Price 클래스)로 분리하여 해당 클래스 내에서 필요한 메서드 구현하는 방법이 있다.

 

여기서 "왜 원시 타입을 포장해야 할까?"라는 의문이 들 수 있다. 원시 타입의 값을 객체로 포장하면 얻을 수 있는 이점은 책임 명확, 유지 보수 향상, 확장성이 있다. 

 

1️⃣ 객체가 자신의 상태를 관리할 수 있다. 이는 코드의 유지 보수에도 도움이 된다.

예를 들어 WebtoonViewerEntity에 age 멤버 변수가 있다고 가정해 보자. 이 변수의 유효성 검사를 해당 클래스 내에서 진행해야 한다면, 이는 엔티티 클래스에 부담이 된다. 하지만, 원시 타입 변수를 포장해(private Age age) Age 클래스 한 곳에서 유효성 검증, 상태 값 관리 및 연산을 담당하게 하면 책임이 명확해진다.

@Entity
public class WebtoonViewerEntity {

    @Column(name = "age", nullable = false)
    private int age;

    public WebtoonViewerEntity(int age) {
        if (age < 0 || age > 120) {
            throw new IllegalArgumentException("Invalid age: " + age);
        }
        this.age = age;
    }
}

 

public class Age {
    private final int value;

    public Age(int value) {
        if (value < 0 || value > 120) {
            throw new IllegalArgumentException("Invalid age: " + value);
        }
        this.value = value;
    }
}

// ========================================================================

@Entity
public class WebtoonViewerEntity {

    @Column(name = "age", nullable = false)
    private Age age;

}

 

2️⃣ 다양한 타입을 지원하여 자료형에 구애받지 않고 처리할 수 있다. 향후 새로운 타입을 추가해야 할 경우, 기존 코드를 수정하지 않고 새로운 생성자만 추가하면 손쉽게 확장할 수 있다.

public class Score {
    private int score;
    private double doubleScore;

    public Score(int score) {
        validateScore(score);
        this.score = score;
    }

    public Score(double score) {
        validateScore(score);
        this.doubleScore = score;
    }

    private void validateScore(int score) {
        if (score < 0) {
            throw new IllegalArgumentException("Score cannot be negative");
        }
    }

    private void validateScore(double score) {
        if (score < 0.0) {
            throw new IllegalArgumentException("Score cannot be negative");
        }
    }

    // 기타 메서드들
}

참고 블로그: 원시 타입을 포장해야 하는 이유

 

후보 2: 다양한 숫자 형식을 처리하기 위한 몇 가지 인터페이스 및 클래스 분석 → 적용

 Number 추상클래스

 래퍼클래스

 BigDecimal 클래스

 AtomicInteger, AtomicLong 클래스

Number 추상클래스란?

자바에서 숫자 관련 클래스의 상위 클래스 (공식문서)

package java.lang;

public abstract class Number implements java.io.Serializable {
    public abstract int intValue();
    public abstract long longValue();
    public abstract float floatValue();
    public abstract double doubleValue();
}

 

1️⃣ 특징

다양한 숫자 형식을 변환하는 메서드를 제공

기본적인 숫자 변환 메서드만 제공하며, 복잡한 수학 연산을 처리하는 기능은 어차피 서브클래스에서 구현해야 함

 

2️⃣ 그렇다면 Price 클래스는 Number 클래스를 상속할 것인가?

불필요한 오버라이드가 많을 것으로 판단되어 상속하지 않기로 결정함.

 

래퍼클래스란? 

Number 클래스의 서브 클래스

 

1️⃣ 개념

자바의 래퍼 클래스는 기본 자료형을 객체로 다룰 수 있도록 도와주는 클래스

주요 래퍼 클래스는 Byte, Short, Integer, Long, Float, Double, Character, Boolean 등이 있음

래퍼 클래스들은 모두 Number 클래스를 상속받음 (Character 및 Boolean 제외)

 

2️⃣ 장점

기본 자료형을 객체로 다룰 수 있음:

래퍼 클래스를 사용하면 기본 자료형을 객체로 다룰 수 있어 컬렉션 클래스에 저장하거나 함수형 인터페이스에 사용할 수 있음

유틸리티 메서드 제공:

각 래퍼 클래스는 다양한 유틸리티 메서드를 제공하여 기본 자료형과의 변환을 쉽게 함 (parseInt, parseLong, parseFloat, parseDouble 등)

박싱 및 언박싱 지원:

자바에서는 기본 자료형과 래퍼 클래스 간의 자동 박싱과 언박싱을 지원하여 코드를 더 간결하게 작성할 수 있음

 

3️⃣ 단점
메모리 사용 증가: 기본 자료형보다 객체를 사용하므로 더 많은 메모리를 사용

성능 저하: 박싱 및 언박싱 과정에서 성능이 저하될 수 있음

 

4️⃣ 그래서 어디에 적용할 것인가?

Price 클래스 내 calculateTotalPrice()에서 Integer 래퍼 클래스 사용

public Price calculateTotalPrice(Integer quantity) {
        BigDecimal quantityAsBigDecimal = BigDecimal.valueOf(quantity);
        BigDecimal result = quantityAsBigDecimal.multiply(this.amount);
        return new Price(result);
}

 

BigDecimal 클래스

Number클래스를 상속받는 클래스 (공식문서)

 

1️⃣ 특징, 단점

높은 정밀도를 제공하여 금융 계산에 적합, 다양한 수학 연산 메서드 제공 (add, subtract, multiply, divide 등)

단점: 상대적으로 느린 성능 (기본 자료형에 비해)

 

2️⃣ 그래서 어디에 적용할 것인가?

Price클래스, Calculator enum클래스

BigDecimal을 일관되게 사용하여 금액 계산을 수행하고, Calculator enum을 사용하여 연산을 캡슐화했다.

  Price 클래스는 금액(amount)을 BigDecimal 타입으로 저장하고, 금액을 처리하는 모든 메서드(plus, minus, multiply, divide, calculateTotalPrice)에서 BigDecimal을 사용하여 금액 계산의 일관성을 유지했다.

  Calculator enum을 사용하여 덧셈, 뺄셈, 곱셈, 나눗셈 연산을 정의하고, 이를 캡슐화하여 재사용성을 높였다.

 

AtomicInteger, AtomicLong 클래스

(AtomicInteger공식문서, AtomicLong공식문서)

 

1️⃣ 특징

java.util.concurrent.atomic 패키지에 속하는 클래스로 멀티스레드 환경에서 안전하게 숫자 연산을 수행할 수 있음

 

2️⃣ 장점

동시성 제어를 위한 락을 사용하지 않음

멀티스레드 환경에서 안전한 숫자 연산 제공 (incrementAndGet, decrementAndGet, addAndGet 등)

 

3️⃣ 단점, 코드 적용 여부

멀티스레드 환경이 아닌 경우 불필요하게 복잡할 수 있음 → 이런 이유로 적용하지 않음

 

해결: (원시) 값 타입 클래스 도입

원시값을 분리하여 관련 데이터와 연산을 하나의 클래스 안에서 캡슐화함으로써 코드의 가독성을 향상했고, 다른 클래스에서도 금액 계산 로직을 재사용할 수 있게 되었다. 또한, 적용된 서비스 계층의 비즈니스 로직도 더욱 깔끔해졌다.

코드 변화 4단계 및 관련 PR

1단계 : 문제상황 코드

feat: 쿠키 결제 기능 구현 #20

 

2단계: BigDecimal필드를 (원시) 값 타입 클래스로 분리

refactor: BigDecimal필드를 (원시) 값 타입 클래스로 분리 #44

CookiePaymentEntity

더보기
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class CookiePaymentEntity extends BaseEntity {
    ...
 
    @Column(name = "quantity", nullable = false)
    private Integer quantity;

    @Embedded   // Price 클래스가 다른 엔티티에 내장된 값 타입 객체임을 나타냄
    @Column(name = "cookie_price", nullable = false)
    private Price price;

    @Column(name = "total_discount_rate", nullable = false)
    private BigDecimal totalDiscountRate;

    public Price calculateTotalPrice() {
        return this.price.calculateTotalPrice(this.quantity);
    }

    public Price calculatePaymentPrice() {
        Price totalPrice = calculateTotalPrice();
        BigDecimal discountedPercentage = BigDecimal.ONE.subtract(this.totalDiscountRate);

        return totalPrice.multiply(Price.of(discountedPercentage));
    }

}

Price

더보기

 

@Embeddable	  // 이 클래스가 다른 엔티티 클래스에 내장될 수 있음을 나타냄
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Price {

    // precision은 숫자의 전체 자릿수, scale은 소수점 이하 자릿수
    @Column(name = "cookie_price", precision = 8, scale = 2)
    private BigDecimal amount;
    
    /*
        정적 팩토리 메서드 : 
        BigDecimal 타입의 amount를 받아 Price 객체를 생성
        외부에서 사용할 수 있게 public, 해당 클래스 내에서만 처리할 경우 private처리
    */ 
    public static Price of(final BigDecimal amount) {
        return new Price(amount);
    }
    
    /*
        정적 팩토리 메서드 : 
        int 타입의 amount를 받아 Price 객체를 생성
        int 값을 BigDecimal로 변환한 후 Price 객체를 생성
    */
    public static Price of(final int amount) {
        return new Price(BigDecimal.valueOf(amount));
    }

    private Price(final BigDecimal amount) {
        // 직접 만들기 전, 자바 라이브러리 존재하는지 확인 후 사용
        Objects.requireNonNull(amount);
        this.amount = toBigDecimal(amount);
    }

    private BigDecimal toBigDecimal(BigDecimal amount) {
        Objects.requireNonNull(amount);
        return new BigDecimal(amount.toString());
    }

    public Price add(final Price price) {
        return new Price(add(price.amount));
    }

    private BigDecimal add(final BigDecimal amount) {
        return this.amount.add(amount);
    }

    public Price subtract(final Price price) {
        return new Price(subtract(price.amount));
    }

    private BigDecimal subtract(final BigDecimal amount) {
        return this.amount.subtract(amount);
    }

    public Price multiply(final Price price) {
        return new Price(multiply(price.amount));
    }

    private BigDecimal multiply(final BigDecimal amount) {
        return this.amount.multiply(amount);
    }

    public Price divide(final Price price) {
        return new Price(divide(price.amount));
    }

    private BigDecimal divide(final BigDecimal amount) {
        return this.amount.divide(amount, 3, RoundingMode.HALF_UP);
    }

    public Price calculateTotalPrice(Integer quantity) {
        BigDecimal quantityAsBigDecimal = BigDecimal.valueOf(quantity);
        BigDecimal result = quantityAsBigDecimal.multiply(this.amount);
        return new Price(result);
    }

}

 

CookiePaymentService

더보기
@Slf4j
@RequiredArgsConstructor
@Service
public class CookiePaymentService {

    /**
     * 쿠키 결제
     * : 쿠키는 현금으로 결제한다.
     */
    @Transactional
    public void register(final CookiePaymentCreateRequest request) {

        // 1. webtoonViewerId가 DB에 존재하는지 확인

        // 2. cookie policy에서 현재 cookie 가격 조회 : activeCookie

        // 3. 현재 활성 프로모션 조회 : activePromotions

        // 4. 쿠키 결제 가격 계산 (totalDiscountRate은 임시로 적용) 
        BigDecimal totalDiscountRate = calculateTotalDiscountRate(activePromotions);
        Integer quantity = request.getQuantity();
        
        // 5. cookiePaymentEntity 생성 후 DB저장
        CookiePaymentEntity cookiePayment = CookiePaymentEntity.create(
                webtoonViewerId,
                quantity,
                activeCookie,
                totalDiscountRate
        );
        cookiePaymentRepository.save(cookiePayment);
        
        ...
    }

}

 

3단계: 계산과 관련된 책임을 하나의 Calculator라는 객체에서 수행 (Enum 클래스), BiFunction 함수형 인터페이스 사용

refactor: 계산 로직 Calculator enum 클래스로 분리 #51

Calculator

더보기
public enum Calculator {
    PLUS("더하기", BigDecimal::add),
    MINUS("빼기", BigDecimal::subtract),
    MULTIPLY("곱하기", BigDecimal::multiply),
    DIVIDE("나누기", (a, b) -> a.divide(b, 3, RoundingMode.HALF_UP));

    private final String name;
    private final BiFunction<BigDecimal, BigDecimal, BigDecimal> biFunction;

    Calculator(String name, BiFunction<BigDecimal, BigDecimal, BigDecimal> biFunction) {
        this.name = name;
        this.biFunction = biFunction;
    }

    public BigDecimal calculate(BigDecimal a, BigDecimal b) {
        return this.biFunction.apply(a, b);
    }
}

Price클래스 리팩토링

더보기
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Price {

    public Price plus(final Price price) {
        BigDecimal result = Calculator.PLUS.calculate(this.amount, price.getAmount());
        return new Price(result);
    }

    public Price minus(final Price price) {
        BigDecimal result = Calculator.MINUS.calculate(this.amount, price.getAmount());
        return new Price(result);
    }

    public Price multiply(final Price price) {
        BigDecimal result = Calculator.MULTIPLY.calculate(this.amount, price.getAmount());
        return new Price(result);
    }

    public Price divide(final Price price) {
        BigDecimal result = Calculator.DIVIDE.calculate(this.amount, price.getAmount());
        return new Price(result);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Price price = (Price) o;
        return Objects.equals(amount, price.amount);
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(amount);
    }
}

 

4단계: 프로모션, 프로모션 속성 인터페이스 적용 → 최종 결제 로직 작성

refactor: 쿠키 및 웹툰 미리 보기 결제 기능 구현 #60 중 refactor: 쿠키 결제 구현

CookiePaymentService 리팩토링

더보기
@RequiredArgsConstructor
@Service
public class CookiePaymentService {

    /**
     * 쿠키 결제
     * : 쿠키는 현금으로 결제한다.
     */
    @Transactional
    public void register(final CookiePaymentCreateRequest request) {
        // 1. webtoonViewerId가 DB에 존재하는지 확인
        
        // 2. cookie policy에서 현재 cookie 가격 조회
        
        // 3. 현재 활성 프로모션 조회 : activePromotions

        // 4. 3번 중 DiscountType이 CASH_DISCOUNT 인것만 골라냄
        
        // 5-1. 4번 중 중복 할인 가능한 프로모션 중 적용 가능한 것을 골라냄
        // 5-2. 4번 중 중복 할인 불가능한 프로모션 중 적용 가능한 것을 골라냄
        
        // 6. 할인율: 중복 할인 불가능한 프로모션의 경우 가장 할인율이 큰 것을 골라냄
        BigDecimal maxRate = isApplyNotDuplicatables.stream()
                .map(PromotionEntity::getDiscountRate)
                .max(Comparator.naturalOrder())
                .orElse(BigDecimal.ZERO);

        // 7. 할인율: 중복 할인 가능한 프로모션의 경우 할인율을 더함.
        BigDecimal sumRate = isApplyDuplicatables.stream()
                .map(PromotionEntity::getDiscountRate)
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        BigDecimal finalRate = min(BigDecimal.valueOf(50), maxRate.add(sumRate));

        // 8. 쿠키 결제 가격 계산 
        BigDecimal totalDiscountRate = finalRate.divide(BigDecimal.valueOf(100));
  
        ...
 
    }

}

 


회고

만족한 점

1. 현재에 만족하지 않고 계속해서 개선하려고 한 점
어디가 문제인지 인식하고, 스스로 부족함을 알고 개선하려는 점이 만족스러웠다.

 

2. 다방면 조사 및 공식 문서 활용
공식 문서를 통해 각각의 특징을 파악하고 검색을 통해 다양한 해결책을 조사한 뒤 바로 복붙 하는 게 아닌 우리 프로젝트에 맞게 가공해 과한 적용을 지양하려 했다.

 

아쉬운 점

1. 체화 필요
현재는 4단계를 통해 개선했지만, 다음에는 바로 코드에 적용할 수 있도록 체화가 필요하다.

 

2. 불필요한 코드 삭제 → 혼동 방지
리팩토링을 하면서 더 이상 필요 없는 코드 부분이 존재했는데 그 당시 바로바로 삭제하지 못해 혼동이 있었다.

 

3. 주석의 필요성
바로 이해가 어려운 부분에는 적절한 주석도 필요하다고 생각한다.

 

다음에는 이렇게

요즘 객체지향 코드 작성에 관심이 있어 원시값 타입, 함수형 인터페이스, 객체 간의 협력을 적극적으로 이용해 코드를 구현하고 있다. 다음 프로젝트 시작 전에는 클린 코드, 객체지향 관련 도서를 통해 체계적으로 공부하고, 관련 코드 연습을 더 진행할 계획이다. 예전에는 돌아가는 코드에 집중했다면, 지금은 깔끔한 코드에 관심이 가는 만큼 현재가 공부하기에 적기라고 생각한다. 그리고 코드 리뷰를 통해 한층 성장할 수 있었고 타인의 시선을 이해할 수 있어 많은 도움이 되었다. 다음에도 꼭 코드 리뷰를 진행해야겠다! 😀😀