우리 팀은 프로젝트를 진행하면서 요구사항을 한번에 구현하는 것이 아닌, 일 주일에 요구사항이 하나 씩 추가되는 방식으로 진행했다.
그러다 보니 테스트 코드와 유지보수성이 높은 코드가 매우 중요했고, 그 중에서도 도메인을 다루는 방식이 중요했다.
주문 앱 프로젝트를 진행하면서 초기에 만들었던 도메인이다.
•
Setter 메서드를 없애서 변경이 외부에 일어나지 못하도록 했고
•
생성자의 접근 제한자를 패키지 레벨로 낮추어 인스턴스가 함부로 생성되지 않도록 했다.
사실 아직은 도메인이 작은 상태이고 서브 모듈로 분리할 필요성을 못느끼는 상태였다.
리팩토링 이전 상품 도메인
@Getter
@Entity
@Table(name = "product",
uniqueConstraints = @UniqueConstraint(columnNames = {"store_id", "product_name"}))
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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을 사용해서 조금 더 유지보수성 좋은 코드를 적용했다.