Search

도메인 리팩토링 (근데 이제 VO와 클린코드를 곁들인..)

Created
2023/11/23 04:57
Tags
Clean Code
Value Object
Date
설명
카테고리
백엔드 🧬
우리 팀은 프로젝트를 진행하면서 요구사항을 한번에 구현하는 것이 아닌, 일 주일에 요구사항이 하나 씩 추가되는 방식으로 진행했다.
그러다 보니 테스트 코드와 유지보수성이 높은 코드가 매우 중요했고, 그 중에서도 도메인을 다루는 방식이 중요했다.
주문 앱 프로젝트를 진행하면서 초기에 만들었던 도메인이다.
Setter 메서드를 없애서 변경이 외부에 일어나지 못하도록 했고
생성자의 접근 제한자를 패키지 레벨로 낮추어 인스턴스가 함부로 생성되지 않도록 했다.
사실 아직은 도메인이 작은 상태이고 서브 모듈로 분리할 필요성을 못느끼는 상태였다.

리팩토링 이전 상품 도메인

@Entity @Table(name = "product", uniqueConstraints = @UniqueConstraint(columnNames = {"store_id", "product_name"})) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Product extends BaseEntity { @Id @GeneratedValue(strategy = IDENTITY) private Long id; // 아메리카노, 라떼 @Column(name = "product_name", nullable = false) private String name; // 4500 @Column(name = "product_price", nullable = false) private int price; @OneToMany private Set<Category> categories = new HashSet<>(); @ManyToOne @JoinColumn(name = "store_id") private Store store; private Product(Store store, String name, int price) { this.store = store; this.name = name; this.price = price; } public static Product makeProductWith(Store store, String name, int price) { return new Product(store, name, price); } public void addCategory(final Category category) { categories.add(category); } }
Java
복사
상품을 생성하기 위해 상품 도메인을 만들 때 아래 코드처럼 생성될 것을 기대했다.

상품이 생성된다.

Product product = Product.makeProductWith(foundStore, "Iced Americano", 3500);
Java
복사
지금 당장은 상품 도메인은 오류 없이 만들어진다.
그러나 나중에 도메인 객체가 좀 더 비대 해질거라 생각하니 리팩토링의 필요성을 느꼈다.

프로젝트 1년 후.. 상품이 생성된다.

Product product = Product.makeProductWith(foundStore, "Iced Americano", 3500, 3100,5012,3029,"Big", "hello","world",021,0.341,3.14,1.592);
Java
복사
물론 과장이 조금 있지만 각 인자에 어떤 값이 들어가는 것인지 확실하게 알 수 없다..
현재는 상품 명, 상품 가격만 받아 처리하기에 문제되지 않지만 나중에 요구사항이 늘어 남에 따라 환불 가격, 쿠폰 사용 가능 가격 등등 해당 도메인을 사용하는 다른 비즈니스 로직이 힘들어 질 것 같았다.
또한 매개 변수의 타입만 일치하면 따로 컴파일 에러를 발생시키지 않기에 매개변수의 순서를 틀린다 해도 알 수가 없다.

상품 도메인 : 상품 가격 객체

@Embeddable public class ProductPrice { private static final int DIVIDER = 100; @Column(name = "product_price", nullable = false) private int price; protected ProductPrice() { // 리플렉션을 위해 기본 생성자는 열어놓는다. // 다만 다른 패키지에서 필드레벨로 새 객체를 생성하지 못하도록 Protected로 정했다. } public ProductPrice(int price) { // 필요에 따라 생성자에서 값을 검증한다. this.price = price; } public void throwIsNotPositive() { // 정수형이 아닐 경우 예외를 던진다. if (!isPositive()) { throw new BusinessException(PRODUCT_PRICE_IS_INVALID); } } public void throwIsNotDivisibleBy100() { // 가격이 100으로 나누어 떨어지지 않기에 예외를 던진다. if (!isDivisible()) { throw new BusinessException(PRODUCT_PRICE_IS_INVALID); } } private boolean isPositive() { return this.price > 0; } private boolean isDivisible() { return this.price % DIVIDER == 0; } public int getValue() { return this.price; } // 객체의 동등성을 price 필드로 비교한다. public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final ProductPrice productPrice = (ProductPrice) o; return productPrice.price == price; } public int hashCode() { return Objects.hash(price); } }
Java
복사
가격을 정수형 자료구조로 정의하기 보다 한 단계 추상화하여 ProductPrice라는 객체로 만들었다.
ProductPrice를 검증하는 책임, ProductPrice에 대한 비즈니스 로직을 처리하는 책임을 주고 내부적으로 검증하였다.
ProductPrice의 생성자를 제외하고 외부에서는 값이 변경되지 않도록 설계하여 ProductPrice 내부의 메서드로만 price 값을 변경할 수 있도록 만들었다.

상품 도메인 : 상품 이름 객체

@Embeddable public class ProductName { @Column(name = "product_name", nullable = false) private String name; protected ProductName() { } public ProductName(String name) { this.name = name; } public void isValid() { if (!StringUtils.hasText(name)) { throw new BusinessException(PRODUCT_NAME_IS_EMPTY); } } public String toString() { return name; } public boolean equals(final Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; final ProductName productName = (ProductName) o; return name.equals(productName.name); } public int hashCode() { return Objects.hash(name); } }
Java
복사
위 같은 코드로 상품 명 객체를 생성하기 때문에 매개변수의 순서가 틀리면 컴파일 오류를 일으킨다.
또한 상품 명 객체 내부에서 name 필드의 무결성을 유지할 책임이 생기기에 다른 도메인 혹은 비즈니스 로직에서 상품 명이 올바른지 검증하는 책임을 가지지 않는다.

상품

@Entity @Table(name = "product", uniqueConstraints = @UniqueConstraint(columnNames = {"store_id", "product_name"})) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Product extends BaseEntity { @Id @GeneratedValue(strategy = IDENTITY) private Long id; // 아메리카노, 라떼 @Embedded private ProductName name; // 4500 @Embedded private ProductPrice price; @ManyToOne private Category category; @ManyToOne @JoinColumn(name = "store_id") private Store store; public static Product makeProductWith(final Store store, final ProductName name, final ProductPrice price, final Category category) { return new Product(store, name, price, category); } public static Product makeProductWith(final Store store, final ProductName name, final ProductPrice price) { return new Product(store, name, price, null); } public void changeCategory(final Category category) { if (this.category == category) { throw new BusinessException(CATEGORY_IS_NOT_CHANGED); } this.category = category; } private Product(Store store, ProductName name, ProductPrice price, final Category category) { checkValidate(name, price); this.store = store; this.name = name; this.price = price; this.category = category; } private void checkValidate(ProductName name, ProductPrice price) { name.isValid(); price.throwIsNotPositive(); price.throwIsNotDivisibleBy100(); } }
Java
복사
임베디드 타입과 VO을 사용해서 조금 더 유지보수성 좋은 코드를 적용했다.
ggc-backend.git
ing9990