요즘IT
위시켓
최근 검색어
전체 삭제
최근 검색어가 없습니다.

국내 유명 IT 기업은 한국을 넘어 세계를 무대로 할 정도로 뛰어난 기술과 아이디어를 자랑합니다. 이들은 기업 블로그를 통해 이러한 정보를 공개하고 있습니다. 요즘IT는 각 기업의 특색 있고 유익한 콘텐츠를 소개하는 시리즈를 준비했습니다. 이들은 어떻게 사고하고, 어떤 방식으로 일하는 걸까요?

회원가입을 하면 원하는 문장을
저장할 수 있어요!

다음

회원가입을 하면
성장에 도움이 되는 콘텐츠를
스크랩할 수 있어요!

확인

개발

값 객체(Value Object)로 변경 용이성 개선하기

년차,
어떤 스킬
,
어떤 직무
독자들이 봤을까요?
어떤 독자들이 봤는지 궁금하다면?
로그인

국내 유명 IT 기업은 한국을 넘어 세계를 무대로 할 정도로 뛰어난 기술과 아이디어를 자랑합니다. 이들은 기업 블로그를 통해 이러한 정보를 공개하고 있습니다. 요즘IT는 각 기업의 특색 있고 유익한 콘텐츠를 소개하는 시리즈를 준비했습니다. 이들은 어떻게 사고하고, 어떤 방식으로 일하는 걸까요?

 

이번 글에서는 패션 플랫폼 29CM의 배송경험 스쿼드 백엔드 개발자가 계산 관련 로직 개선을 진행하며 값 객체를 활용한 경험에 대해 소개합니다.

 

안녕하세요. 29CM 배송경험 스쿼드 백엔드 개발자 설연수입니다. 배송경험 스쿼드는 ‘고객이 구매 이후 걱정할 것이 없도록 한다.’라는 비전을 가지고 끊임없이 문제들을 해결해 나가고 있습니다. 이번에 주문취소, 반품 환불금액 계산에 관련된 로직 개선을 진행했는데요. 이 작업에서 값 객체를 활용한 경험을 공유하고자 글을 작성하게 되었습니다.

 

 

값 객체란

먼저 값 객체란 무엇인지 살펴보고 코드로 넘어가도록 하겠습니다.

 

값 객체란 무엇일까요?

  • 값 객체(Value Object)는 데이터를 나타내고 표현하는 객체입니다.

 

값 객체는 기본 자료형과 어떤 차이가 있을까요?

  • 값을 기본 자료형(Primitive Data Type) 대신, 클래스로 캡슐화하여 의미와 값을 둘 다 표현하는 데 사용됩니다.

 

도메인에서 값에 대한 여러 개념이 존재할 것입니다. 이러한 개념들을 클래스로 분류(Classification)하는 것으로 볼 수 있습니다. 값 객체를 사용하여 도메인 개념을 도메인 객체로 표현합니다.

 

 

예를 들어 이름, 나이, 금액, 마일리지와 같은 값들을 클래스로 표현했다면 값 객체로 표현한 것입니다. 이 글에서는 다른 도메인 객체(ex. 엔티티 — 고유한 식별자와 생명주기를 가진 객체)에 대한 설명은 없습니다. 값 객체에 중점을 두고 설명하고자 합니다.

 

 

예제 설명

29CM의 반품 환불 금액을 계산하는 기능을 예시로 들고자 합니다.

 

 

여기에는 상품금액, 환불배송비, 쿠폰할인, 차감금액, 추가배송비, 회수배송비, 환불예정금액과 같이 여러 도메인 개념이 포함되어 있습니다.

 

 

금액으로 추상화한 값 객체 활용

이러한 도메인 개념들은 모두 ‘금액’이라는 공통점을 가지고 있습니다. 따라서 금액이라는 도메인 개념을 나타내기 위해 Money라는 값 객체를 만들어 활용해 보겠습니다.

 

/**
 * 금액을 표현한 값 객체.
 */
@EqualsAndHashCode // 등가성 비교: 속성 전체가 같으면 같은것으로 취급한다.
public class Money {
  private final BigDecimal amount; // 불변(immutable)

  public Money(final BigDecimal amount) {
    if (Objects.isNull(amount)) { // 무결성 유지 Validation
      throw new IllegalArgumentException("금액이 Null일 수 없습니다.")
    }
    this.amount = amount;
  }

  // ...도메인 객체의 행동을 표현한 메서드...
  // ex) 덧셈, 뺄셈, 곱하기, 나누기, 비교(동등,이상,초과,...), 통화(원화,달러,...)관련, ...
}
/**
 * 환불 금액 정보.
 */
public class ReturnRefundAmountInfo {
  private final Money payAmount; // 결제금액 (상품금액 - 쿠폰할인 + 배송비)
  private final Money itemPrice; // 상품금액
  private final Money couponSaleAmount; // 쿠폰할인
  private final Money orderDeliveryFee; // 배송비
  // ...필드 생략... 
}

// ...금액을 연산하는 비즈니스 컴포넌트 생략...

 

Money를 활용하여 모든 금액을 표현했습니다. 기본자료형 또는 BigDecimal로 작성했을 때와 Money로 표현했을 때 차이점이 느껴지시나요?

 

위 코드는 기본자료형 BigDecimalMoney로 조금 구체화된 점 그리고 메서드를 더 확장할 수 있다는 장점밖에 얻지 못했다고 생각합니다. 여전히 기본 자료형을 사용할 때와 같은 문제점이 존재합니다.

 

 

하나의 자료형에 집착하여 발생한 문제점

1. 추상화 수준이 여전히 너무 높습니다.

  • Money로 모든 금액을 표현하고 있습니다.
  • 추상화는 코드의 유연성을 높일 수 있지만, 지나치게 추상적인 표현은 코드를 이해하고 유지보수하기 어렵게 만듭니다. 만약 필드명이 추상적이라면 코드를 해석해야만 의미를 알 수 있습니다.

 

2. 객체지향 설계를 했다고 보기 힘듭니다.

  • 개념 간의 명확한 구분이 없기 때문에 ‘객체의 책임과 역할을 명확하게 정의하지 못했다’라고 볼 수 있습니다.
  • 코드 곳곳에 같은 도메인 개념이 다양한 이름으로 네이밍 되어있을 수 있습니다.

 

3. 코드 응집도가 낮아집니다.

  • 응집되어야 할 코드들이 각 클래스에 분산되거나 중복이 발생할 수 있습니다.

 

4. 엉뚱한 값을 전달할 수 있습니다.

  • 매개변수 순서가 바뀌어도 컴파일 오류가 발생하지 않기 때문에, 클라이언트에서 값을 잘못 전달하면 오계산이 발생할 수 있습니다.

 

 

목적 중심 이름 설계를 적용한 값 객체 활용

목적 중심 이름 설계란 목적에 맞게 이름을 설계하는 것을 말합니다. 소프트웨어로 달성하고 싶은 목적과 의도를 이름만으로도 알 수 있게 하는 것입니다. 기존 코드에서 문제점을 파악했으니 ‘구체적이고 의미가 좁으면서 목적에 특화한’ 클래스로 분류해 보겠습니다.

 

/**
 * 주문 시, 상품 판매가.
 */
@EqualsAndHashCode
public class ItemPrice {
  private final Money price;

  public ItemPrice(final Money price) {
    this.price = price;
  }
}

/**
 * 주문 시, 쿠폰 할인 금액.
 */
@EqualsAndHashCode
public class CouponSaleAmount {
  private final Money amount;

  public CouponSaleAmount(final Money amount) {
    this.amount = amount;
  }
}

/**
 * 주문 시, 고객이 결제한 배송비.
 */
@EqualsAndHashCode
public class OrderDeliveyFee {
  private final Money fee;

  public OrderDeliveyFee(final Money fee) {
    this.fee = fee;
  }
}

/**
 * 총 결제금액.
 */
@EqualsAndHashCode
public class TotalPayAmount {
  private final Money amount; 

  // 총 결제금액을 생성하기 위해서는 구체적인 값 객체를 매개변수로 전달해야 한다.
  public TotalPayAmount(
    final ItemPrice itemPrice, 
    final CouponSaleAmount couponSaleAmount, 
    final OrderDeliveyFee orderDeliveyFee
  ) {
    var totalPayAmount = itemPrice.getPrice()
            .subtract(couponSaleAmount.getAmount())
            .add(orderDeliveyFee.getFee());  
    if (totalPayAmount.isNegative()) { // 무결성 유지 Validation
      throw new IllegalStateException("결제금액이 0원 이하일 수 없습니다");
    }
    this.amount = totalPayAmount;
  }
}
/**
 * 상품이 불량일때, 고객에게 되돌려주는 배송비.
 */
@EqualsAndHashCode
public class RefundOrderDeliveryFee implements RefundDeliveryFee {
  private final Money fee;
  // ...생략...
}

/**
 * 고객변심 반품일때, 고객이 지불해야하는 반품상품 회수배송비.
 */
@EqualsAndHashCode
public class SubtractReturnDeliveryFee implements SubtractDeliveryFee {
  private final Money fee;
  // ...생략...
}

/**
 * 고객변심 반품이면서 무료배송 혜택받았을때, 고객이 지불해야하는 추가배송비.
 */
@EqualsAndHashCode
public class SubtractAdditionalDeliveryFee implements RefundDeliveryFee {
  private final Money fee;
  // ...생략...
}
/**
 * 환불금액.
 * Service는 인터페이스에 의존하여 코드를 작성한다.
 */
interface RefundAmount {
  add(RefundDeliveryFee deliveryFee); // 차감해야 하는금액이 add의 매개변수가 될 수 없다.

  subtract(SubtractDeliveryFee deliveryFee); // 환불해야하는 금액이 subtract의 매개변수가 될 수 없다.

  RefundPayAmount getRefundPayAmount();

  RefundMileage getRefundMileage();

  TotalRefundAmount getTotalRefundAmount();
}

/**
 * 환불금액 구현체.
 * 계산 정책이 변경되면, 구현체만 새로 구현하면 된다.
 */
@EqualsAndHashCode
public class RefundAmountImpl implements RefundAmount {
  private final RefundPayAmount refundPayAmount; // 환불금액
  private final RefundMileage refundMileage; // 환불마일리지
 
  public RefundAmountImpl(
    // ...매개변수(구체적인 값 객체) 생략...
  ) {
    // ...환불금액과 환불마일리지 계산로직 생략...
    this.refundPayAmount = ...
    this.refundMileage = ...
  }
 
  public RefundPayAmount getRefundPayAmount() {
    return refundPayAmount;
  }
 
  public RefundMileage getRefundMileage() {
    return refundMileage;
  }
 
  public TotalRefundAmount getTotalRefundAmount() {
    return new TotalRefundAmount(refundPayAmount, refundMileage);
  }

  // ...생략...
}

/**
 * 총 환불 예정 금액
 */
@EqualsAndHashCode
public class TotalRefundAmount {
  private final Money amount;

  // 총 환불 예정 금액을 생성하기 위해서는 구체적인 값 객체를 매개변수로 전달해야 한다.
  public TotalRefundAmount(
    final RefundPayAmount refundPayAmount,
    final RefundMileage refundMileage
  ) {
    this.amount = refundPayAmount.getAmount()
                  .add(refundMileage.getMileage());
  }
}

 

구체적이고 의미가 좁은 값 객체의 장점

1. 목적 중심 이름 설계를 함으로써 표현력이 높아집니다.

  • 표현력이 높은 코드는 읽는 사람의 이해를 돕습니다.

 

2. 응집도가 높아집니다.

  • 객체 생성 로직, 도메인 로직을 응집시킬 수 있습니다.
  • 데이터 무결성을 유지하기 용이해집니다.

 

3. 구체적인 자료형을 전달함에 따라 잘못된 자료형이 넘어올 수 없습니다.

  • 순서가 잘못 변경되면 컴파일오류가 발생할 테니, 엉뚱한 값을 전달할 수 없게 됩니다.

 

4. 개념을 문서화(JavaDoc)하기 용이합니다.

 

 

생성자에 비즈니스 로직을 구현하기 힘들다면

쉽게 내용을 전달하기 위해서 주제와 벗어난 많은 코드를 생략했습니다만 복잡한 도메인에서는 값 객체 생성(계산)을 위해 다른 컴포넌트와 상호작용이 필요한 경우가 있을 것입니다. 이런 경우에서는 전략 패턴(Strategy Pattern), 팩토리 패턴(Factory Pattern) 등을 고려해 볼 수 있을 것 같습니다.

 

 

마치며

값 객체를 위한 클래스를 더 만들어야 하는 점, 코드 작성하는 시간이 더 오래 걸릴 수 있는 점에서 거부감이 느껴지실 수도 있습니다. 하지만 클린 코드 책에 따르면 일반적으로 기존 코드를 변경하고자 할 때 해석하는 시간과 수정하는 비율이 10:1이라고 합니다. 한 번 작성된 코드는 읽히는 횟수가 훨씬 더 많습니다. 이해하기 힘들고 변경 용이성이 낮은 코드는 오류로 이어질 가능성이 큽니다.

 

코드를 읽는 사람이 수정을 빠르게 할 수 없다면 더 많은 문제를 빠르게 해결할 수 없다는 의미입니다. 결국 시장에서 뒤처질 수밖에 없을 것입니다. 설계부터 운영까지 전체적인 시간(총량)으로 보면 ‘변경 용이성이 높은 코드’는 ‘변경 용이성이 낮은 코드’와 비교 자체가 되지 않는다고 생각합니다. 값 객체는 변경 용이성을 높이는 수많은 방법의 하나입니다. 변경 용이성이 높은 코드 작성을 위해 투자해 보는 것 어떨까요?

 

<참고 문헌>

  • 내 코드가 그렇게 이상한가요?
  • 클린 코드

 

<원문>

값 객체(Value Object)를 활용하여 변경 용이성 개선하기

 

요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.

좋아요

댓글

공유

공유

댓글 0
29CM TECH
기업
29CM TECH
97
명 알림 받는 중

작가 홈

29CM TECH
기업
29CM TECH
97
명 알림 받는 중
29CM 이십구센티미터를 만드는 모든 이야기를 담습니다.

좋아요

댓글

스크랩

공유

공유

요즘IT가 PICK한 뉴스레터를 매주 목요일에 만나보세요

요즘IT가 PICK한 뉴스레터를
매주 목요일에 만나보세요

뉴스레터를 구독하려면 동의가 필요합니다.
https://auth.wishket.com/login