Git repository
https://github.com/howudong/java-christmas-6-howudong
4주 차 후기
4주 차는 지금까지 했던 어떤 미션과는 요구사항이 복잡했다.
그리고 요구사항을 과제식으로 설명한 것이 아니라, 클라이언트가 기능을 요청하는 방식을 사용했다.
그러다 보니 뭔가 미션을 위한 문제라기보다는 실제 서비스 개발이라는 느낌이 강했다.
이번에는 내가 실무자라는 느낌으로 미션에 임했다.
클라이언트에게 프로그램을 전달하고, 협업을 위해 할 수 있는 설계를 하기 위해 노력했다.
또한 기능이 실제로 확장될 수 있고 프로그램이 유지보수가 된다는 점을 생각했다.
도메인 설계 다이어그램
이번 주차의 핵심이라고 할 수 있는 도메인의 다이어그램이다.
값 객체(Value Object) 사용
이번 주차에는 Enum 클래스를 기존 주차 미션보다 조금 더 많이 사용했다.
또한 BadgeType, Product, MenuType, EventCalendar를 domain/vo 패키지로 묶어주었다.
Entity가 아닌 Value Object로 묶어준 이유는, 순수 데이터베이스가 존재하지 않기 때문이다.
만약 데이터베이스가 존재하여 매핑할 테이블이 있었다면, 변경에 대비하여 Entity로 만들었을 것이다.
하지만 그렇지 않기 때문에 이러한 정보들을 클래스로 정의해 주고,
이를 불변객체를 뜻하는 Value Object로 만들어줬다.
public enum BadgeType {
STAR("별", 5_000L),
TREE("트리", 10_000L),
SANTA("산타", 20_000L);
private final String badgeName;
private final Long goalPrice;
BadgeType(String badgeName, Long goalPrice) {
this.badgeName = badgeName;
this.goalPrice = goalPrice;
}
...
}
public enum MenuType {
APPETIZER(List.of(MUSHROOM_SOUP, TAPAS, CAESAR_SALAD)),
MAIN(List.of(T_BONE_STAKE, BBQ_LIBS, SEA_FOOD_PASTA, CHRISTMAS_PASTA)),
DRINK(List.of(ZERO_COKE, RED_WINE, CHAMPAGNE)),
DESERT(List.of(ICE_CREAM, CHOCOLATE_CAKE));
private final List<Product> products;
MenuType(List<Product> products) {
this.products = products;
}
...
}
package christmas.domain.vo;
import java.util.Arrays;
public enum Product {
MUSHROOM_SOUP("양송이수프", 6_000L),
TAPAS("타파스", 5_500L),
CAESAR_SALAD("시저샐러드", 8_000L),
T_BONE_STAKE("티본스테이크", 55_000L),
BBQ_LIBS("바비큐립", 54_000L),
SEA_FOOD_PASTA("해산물파스타", 35_000L),
...
private final String name;
private final Long price;
Product(String name, Long price) {
this.name = name;
this.price = price;
}
...
}
public record EventCalendar(int orderDay) {
public static final int EVENT_YEAR = 2023;
public static final int EVENT_MONTH = 12;
public static final List<Integer> SPECIAL = List.of(3, 10, 17, 24, 25, 31);
private static final int FIRST_DAY = 1;
private static final int LAST_DAY = 31;
public EventCalendar {
validateDate(orderDay);
}
private void validateDate(int orderDay) {
if (orderDay < FIRST_DAY || orderDay > LAST_DAY) {
throw new IllegalArgumentException(getText(INVALID_DATE));
}
}
}
해당 코드에선 길이상 생략하였지만,
모든 vo는 자신이 가지고 있는 필드를 반환하는 getter를 가지고 있다.
이는 메뉴 정보, 가격 등 이미 설정된 값들을 가져와야 하기 때문이다.
예외적으로 가장 아래에 EventCalendar에는 주문을 한 날짜(orderDay)가 포함된다.
주문 날짜는 생성 이후엔 바뀌지 않고, 식별값을 가질 필요가 없기 때문에 vo에 속한다고 판단했다.
또한, EventCalendar는 비즈니스 로직 또한 가진다.
EventCalendar가 생성될 때, orderDay가 1 ~ 31일 사이인지 검사하고 아니라면 예외를 발생시킨다.
다른 vo들은 그저 사전의 정보만을 담고 있는 데에 비해, 이는 vo다운 역할을 한다고 생각한다.
이를 제외한 나머지 vo는 vo라기보다는 데이터베이스 테이블의 대체품 정도로 느껴진다.
메뉴의 정보를 나눈 이유
오랫동안 고민한 부분이었다.
처음에는 `메뉴 타입(메뉴 이름, 가격)`이런 식의 enum 클래스를 만들려고 했는데,
곰곰이 생각해 보니 메뉴 타입과 상품의 특성이 다르다고 생각했다.
메뉴 타입은 "디저트, 드링크, 애피타이저, 메인" 이렇게 4가지로 고정되어 있고, 잘 바뀌지 않는다.
하지만 상품은 변동 가능성이 매우 크다.
해당 메뉴에서 빠질 수도 있고, 가격 변동이 일어날 수도 있다.
또한 구조적으로 메뉴 타입이라는 분류 안에 여러 가지 메뉴들이 있는 것이다.
즉, 메뉴 타입이 메뉴(상품)들을 가지고 있는 것이기 때문에, 이를 필드로 가지는 것이 맞다고 판단했다.
그래서 메뉴 타입과 메뉴의 이름 및 가격 정보 이런 식으로 2개의 클래스로 분리했다.
할인 정책의 설계
이번 미션에서 다양한 시도를 많이 해봤던 부분이다. 아마 가장 중요한 부분이지 않았나 싶다.
고민이 많이 됐던 이유는 여러 가지 할인 정책이 있는데, 그중 하나만 적용되는 것이 아니다.
여러 가지 할인 정책이 조건에 따라, 중복 적용될 수 있다는 점 때문에 설계 방법에 고민이 많았다.
막 설계했다가는 과도하게 의존적인 코드가 될 것이라 고민이었다.
처음에는 enum 클래스를 통한 할인정책 구현을 생각해 봤다.
enum class를 통해서 구현한다면, 할인 정책을 통해 내부적인 연산을 마친 값만 가져오면 된다.
즉 할인 정책의 사용이 쉽고 간단해질 것이라는 장점이 있다.
하지만 enum 클래스를 public으로 열어두지 않는 이상, 테스트 코드를 작성하는 것이 어려워진다.
왜냐하면 enum은 new를 통한 호출이 되지 않기 때문이다.
그래서 이를 테스트하기 위해서는 해당 할인 조건에 맞는 주문을 넣는 방법밖에 없는데,
지금은 할인 정책이 간단해서 그렇지, 복잡한 할인 정책이 들어온다면 테스트가 힘들어진다.
또한 할인 정책이 추가될 때마다 해당 enum 클래스 안에 로직을 추가해야 한다.
즉 할인 정책이 많아진다면 enum 클래스 자체의 코드가 많아져 가독성이 떨어질 것 같다.
이러한 단점들에 의해 enum을 통한 구현은 하지 않았다.
두 번째로 생각한 방식은 상속을 통한 구현이었다.
할인 정책이라는 추상 클래스를 상속받는 4가지 자식 클래스를 만드는 방식이었다.
해당 방식 사용하지 않은 이유는, 할인 정책 간의 결합이 너무 강해지기 때문이다.
또한 추후 서비스 확장 시 할인 정책이 특정 클래스를 상속받아야 하는 경우가 생길 수 있다.
자바에서는 다중 상속을 허용하지 않는데, 이 경우 할인 정책은 그 특정 클래스를 상속받을 수 없다.
이러한 난처한 상황을 막고자 상속을 통해 구현하는 것을 하지 않았다.
인터페이스를 통한 할인 정책 설계
최종적으로 선택한 방식은 인터페이스를 통한 구현이었다.
하나로 묶어주는 역할을 위한 역할로 사용한다면, 상속보다는 인터페이스를 사용하는 것이 낫다고 생각했다.
인터페이스는 상속과는 다르게 여러 인터페이스를 구현할 수 있다.
또한 상속보다 낮은 결합도를 유지하면서 관계를 유지해 주기 때문에,
객체지향적인 측면에서 인터페이스를 사용하는 것이 괜찮은 판단이라고 생각했다.
인터페이스 DiscountStrategy를 만들고, 4가지 할인 정책을 구현했다.
구현 함수에는 매개변수로 Orders가 들어가 있는데, 이는 Orders에 주문 날짜가 들어가 있기 때문이다.
할인 정책에 주문 날짜가 필요한 할인 정책이 일부 존재하기 때문에, 날짜 정보를 함께 보낸다.
Orders 자체를 보내는 이유는 Orders에 있는 날짜 정보의 노출을 막기 위해서이다.
discount 함수에게 일을 맡기면, Orders만 넘겨도 내부적으로 날짜 정보를 알고 있기 때문에,
이를 다른 계층에 노출하지 않고 할인을 진행할 수 있다.
그리고 인터페이스를 만든 이상, 다른 계층에서는 모두 인터페이스만 알고 있어야 한다.
인터페이스의 구현체를 아는 순간, 직접적인 종속성이 생기기 때문에 인터페이스의 의미가 사라진다.
그래서 구현체들을 생성해 주고, DiscountCalculator를 통해 실질적인 계산을 한다.
DiscountCalculator가 반환하는 것은 각 정책 이름과 할인 금액을 반환한다.
이로써 다른 계층에 할인 로직 자체를 노출하는 것을 막을 수 있다.
Getter 지양
이번 주차는 스프링에 더 가까웠던 미션이어서 getter를 최대한 안 쓰는 것이 가능했다.
최대한 getter를 지양하는 방식을 통해, 도메인 자체의 노출도를 낮췄다.
값을 전달하는 vo 클래스와, 입출력에 필요한 정보를 모아놓은 Dto를 제외하고는 getter를 쓰지 않았다.
대신 필요한 값을 가진 객체에게 일을 시키고, 그 결과만을 받아오는 방식을 사용했다.
getter를 최대한 지양하는 하는 것은 올바른 방향인 것 같다.
클래스를 수정할 때, 그 범위를 줄여주는 효과도 있지만 가독성 또한 높여준다.
public final class Benefit {
private final Map<String, Long> discounts;
private final OrderProduct rewardProduct;
public Benefit(Map<String, Long> discounts, OrderProduct rewardProduct) {
this.discounts = discounts;
this.rewardProduct = rewardProduct;
}
// 이름은 get이지만 내부를 보면 비즈니스 로직을 수행함
// 그리고 반환하는 Map<String,Long>은 ("할인정책","할인결과 금액")으로 계산의 결과값임)
public Map<String, Long> getDiscounts() {
Map<String, Long> copiedDiscounts = new HashMap<>(discounts);
if (rewardProduct != null) {
copiedDiscounts.put("증정 상품", rewardProduct.sumOrderProduct());
}
return Collections.unmodifiableMap(copiedDiscounts);
}
// 총혜택금액을 통해 배지의 타입을 판단하는 비즈니스 로직을 수행
public BadgeType getRewardBadge() {
Long benefitPrice = getBenefitPrice();
return getBadgeType(benefitPrice);
}
// rewardProduct가 있는지를 판단하고 더하는 작업을 수행하는 비즈니스 로직을 수행
public Long getBenefitPrice() {
if (rewardProduct == null) {
return sumAllDiscount();
}
return sumAllDiscount() + rewardProduct.sumOrderProduct();
}
private BadgeType getBadgeType(Long benefitPrice) {
if (benefitPrice >= BadgeType.SANTA.getGoalPrice()) {
return BadgeType.SANTA;
}
if (benefitPrice >= BadgeType.TREE.getGoalPrice()) {
return BadgeType.TREE;
}
if (benefitPrice >= BadgeType.STAR.getGoalPrice()) {
return BadgeType.STAR;
}
return null;
}
private Long sumAllDiscount() {
return discounts.values()
.stream()
.reduce(Long::sum)
.orElse(0L);
}
}
도메인 클래스 중 일부인 Benefit.class이다.
이름은 다 get으로 시작하지만 내부 로직을 보면,
필드를 반환하는 것이 아니라 비즈니스 로직을 수행하는 것을 알 수 있다.
이렇게 클래스에게 일을 시키고 그 결괏값을 반환받는다.
이로써 클래스의 책임을 강하게 만들고 더 독립적인 모듈로 만들 수 있다.
객체지향에서의 유틸리티 사용
유틸리티는 static 함수로 이루어져 범용성이 높기 때문에,
객체지향설계에서는 사용을 지양해야 한다는 의견이 있다.
그래서 그 이유에 의문이 들어 자세히 알아보았다.
유틸리티 클래스는 범용성이 있어 어디서든 호출할 수 있기 때문에,
유틸리티 클래스의 변경이 다른 클래스에 치명적일 확률이 높아 사용을 지양해야 한다고 한다.
내가 사용하려고 할 유틸리티가 정말 내 다른 클래스에게 치명적인 영향을 끼칠까? 한편 살펴봤다.
package christmas.util;
public final class ErrorManager {
private static final String PREFIX = "[ERROR] ";
public static final String INVALID_ORDER = "유효하지 않은 주문입니다. 다시 입력해 주세요.";
public static final String INVALID_DATE = "유효하지 않은 날짜입니다. 다시 입력해 주세요.";
private ErrorManager() {
}
public static void tryUntilNoError(Runnable runMethod, Runnable methodOnExcept) {
try {
runMethod.run();
} catch (IllegalArgumentException e) {
methodOnExcept.run();
tryUntilNoError(runMethod, methodOnExcept);
}
}
public static String getText(String errorMessage) {
return PREFIX + errorMessage;
}
}
내가 사용하려고 할 유틸리티의 핵심 역할은
매개변수로 들어온 함수가 예외가 발생하지 않을 때까지 실행시키는 것이다.
(tryUntilNoError 함수)
이 부분은 요구사항에서 명시된 부분이다.
또한 해당 유틸리티 함수를 실행해도 아무런 값을 반환하지 않는다.
따라서 해당 클래스의 내부 구현이 변한다고 해도,
요구사항에서 명시한 역할이 있기 때문에 영향이 있을 확률은 낮다고 생각한다.
만약 예외에 대한 요구사항이 변경되어 예외가 발생했을 때,
특정한 무엇인가를 반환해야 한다고 가정해 봤다.
결국 예외처리와 관련된 요구사항 이기 때문에 예외 처리에 관한 로직을 써야 한다.
그렇기 때문에 이 유틸리티가 없더라도, 코드 변경에는 비슷한 비용이 들어갈 것이라고 생각한다.
따라서 코드 가독성과 효율성 측면에서 해당 유틸리티를 사용하는 것이 낫다고 생각한다.
@Override
public void process(Map<String, InputDto> inputs, Map<String, OutputDto> outputs) {
inputs.put(ORDER_INPUT_DTO, orderService.createOrderInput());
tryUntilNoError(
() -> displayOrderDayView(inputs, outputs),
() -> displayErrorView(inputs, outputs, getText(INVALID_DATE)));
tryUntilNoError(
() -> displayOrderProductsView(inputs, outputs),
() -> displayErrorView(inputs, outputs, getText(INVALID_ORDER)));
tryUntilNoError(
() -> displayOrderResultView(inputs, outputs),
() -> displayErrorView(inputs, outputs, getText(INVALID_ORDER)));
}
위처럼 유틸리티 클래스를 사용하자,
try-catch를 사용했을 때보다 코드가 훨씬 깔끔한 것을 볼 수 있다.
또한 함수의 네이밍을 통해 코드의 동작을 유추하고, 가독성을 높일 수 있었다.
아쉬운 점
잘못 설계된 DiscountCalculator
제출을 하고 난 뒤, 이에 대해 알았다.
DiscountCalculator는 잘못 설계됐다.
1. 인터페이스와 구현체를 모두 의존하고 있다.
2. Orders를 의존하고 있다.
이게 어떤 문제가 되냐면,
DiscountCalculator가 의도치 않게 변경되는 시점이 너무 많아진다.
DiscountCalculator가 변경되는 경우
1. 인터페이스 로직이 바뀔 경우
2. 새로운 할인 정책이 추가되는 경우
3. 기존 할인 정책의 생성자가 변경되는 경우
4. Orders의 로직이 변경되는 경우
객체지향적으로 설계하는 이유는 최대한 모듈화 된 클래스를 만들기 위해,
그러니까 코드 수정이 발생했을 때, 영향받는 클래스를 줄이기 위해서이다.
그런데 이 클래스는 영향받는 포인트가 많다. 의미상 변경이 의도치 않은 부분도 있다.
왜냐하면 이름이 Calculator인데 Orders 클래스가 변경되면 함께 수정해야 한다.
Orders 클래스가 커질수록 점점 귀찮아질 것이기 때문에 이런 설계는 지양해야 한다.
적어도 여기서 2,3,4번은 분리할 수 있다.
할인 정책을 생성하는 DiscountGenerator 클래스를 만들어주는 것이다.
DiscountCalculator는 DiscountGenerator에서 생성해 준 구현체로 로직을 수행한다.
이렇게 하면 DiscountCalculator은 Orders와 구현체와의 의존관계를 없앨 수 있다.
그럼 이제 DiscountCalculator는 인터페이스 로직이 바뀔 경우에만 영향을 받는다.
과도한 구조화
평소에도 구조화를 좋아하긴 했지만, 마지막 주차인 만큼 더 구조화를 한 것 같다.
이번에는 dto, service, util까지 추가로 사용했다.
view는 무조건 dto만을 받아서 사용할 수 있도록 강제했다.
service에는 여러 도메인 클래스가 필요한 작업이나, dto를 생성하는 역할을 맡게 했다.
dto는 Input에 필요한 정보, output에 필요한 정보
이렇게 둘로 나누어 InputView, OutputView로 나누어 보냈다,.
프로그램이 커질수록 코드는 많아지고, 체계가 없다면 무너지기가 쉽다.
그래서 체계를 지키기 위해서는 철저한 구조화가 필요하다고 생각했다.
그래서 코드를 통해 이를 강제해 둔다면 확실한 체계를 갖출 수 있다고 판단했다.
그런데 너무 구조화를 하다 보니, 일정 부분을 억지로 구조에 끼워서 맞춘 부분이 있다.
Map에 String만 넘기기 위해 InputDto 부분에 Null을 넘긴다는 둥, 임기응변식의 코드를 넣었다.
또한 구조화에 너무 집중한 나머지, 코드의 가독성을 떨어뜨린 건 부정할 수 없다.
가독성을 중요시하면서도 이를 챙기지 못한 점이 부끄럽다.
오히려 너무 많은 부분을 고려한 점이 그런 부분들을 다 잡지 못한 것 같다.
코드가 복잡해진 것만 같아서 그다지 완벽하게 마음에 들진 않는다.