이번 주 목표는 저번 주차와 같이 "클래스 분리" 이다. 저번 주차를 회고할 때 클래스를 미리 정해두지 않고 개발할 때 기능을 분리해야겠다 싶으면 메서드를 분리하고, 나아가 클래스를 분리하면서 클래스에 적절한 책임을 부여해야겠다는 다짐을 했다.
그래서 이번 주차의 플로우 차트는 클래스를 할당하지 않고 기능 목록을 작성했고 코딩을 시작했다. 처음부터 클래스에 적절한 책임을 할당하려 하지 않았다는 것이다.
나와 같은 주니어들은 어떤 코드부터 짜야할 지 고민한다.
Write small But useful program everyday - 워드 커닝햄
3주차 코수타 들었던 어록이다.
"어떻게 하면 낮선 문제를 똑똑하게 적응할까"라는 고민을 가진 분들에게 들려주고 싶은 말이라고 한다. 이번 미션이 굉장히 어렵다고 느낀 만큼 내게 필요한 말이라고 생각한다.
직역하면 "작지만 유용한 프로그램을 작성해라"라는 뜻이고 좀 더 의도에 맞게 변경하면 "가장 핵심적인 기능의 동작 가능한 가장 작은 기능을 만들어라" 라는 뜻이다.
그래서 이번 미션에서 가장 핵심적이지만 가장 작은 버전이 무엇인가 기능 목록을 보면서 생각했다. 그럼에도 당최 가장 핵심적이고 작은 버전이 무엇인지 막연하기만해서 이 미션의 핵심 기능을 한 줄로 적어보았다.
"주문에 대한 혜택의 적용을 보여주는 프로그램"
핵심 기능을 한 줄로 적으니 무엇이 핵심이어야 하는지 어느정도 감이 왔다.
주문 입력하는 기능도 중요하고 결과를 출력하는 것도 중요하다. 하지만 가장 중요한 핵심적인 기능은 혜택을 적용하는 것이다.
다음은 기능 목록 혜택을 적용하는 부분 중 일부를 가져온 것이다.
...
## 할인 이벤트
- [ ] 크리스마스 디데이 할인 기능
- [ ] 할인이 적용 가능한 지 확인하는 기능
- [ ] 주문 날짜가 1~25일에 있는지 확인하는 기능
- [ ] 디데이를 계산해서 할인 금액을 계산하는 기능
- [ ] 100원 단위로 추가될 날짜를 계산하는 기능
- [ ] 평일 할인 기능
- [ ] 할인이 적용 가능한 지 확인하는 기능
- [ ] 평일인 지 확인하는 기능
...
크리스마스 디데이 할인 중 가장 작은 기능을 꼽으라고 하면 "주문 날짜가 1에서 25일 안에 있는 지 확인하는 기능"이다. 이 작은 기능이 있어야 할인 택을 적용하는 큰 기능을 수행할 수 있다.
그 다음은 "주문 날짜가 1에서 25일 안에 있는 지 확인하는 기능"를 Day
라는 클래스에서 확인하는 것으로 결정했다. 그 결과로 클래스가 가져야할 최소 단위의 책임을 가지게 되었다.
여기서 깨달은 점은
1. 핵심적이고 작은 기능을 구현하려고 하면 적절하게 분리된 클래스를 만들게 된다는 것이다. 작은 기능을 수행하는 클래스는 당연히 작은 책임을 가지게 되는 것은 당연한 상식이지만 우리는 이제껏 TopDown 방식으로 개발해왔고 이런 방식으로 개발했다면 ChristmasEvent
라는 클래스가 날짜를 계산하게 되어 클래스가 크고 복잡해졌을 것이다.
2. BottomUp 방식으로 개발하게 되니까 이번 주차처럼 아무리 어려워보이는 미션이라도 나눠서 생각하게 되니까 이전처럼 어렵고 복잡하게 느껴지지 않는다는 것이다. 알고리즘으로 치면 분할 정복 같은 느낌이다.
그럼에도 리팩토링을 통해 클래스 분리를 하는 일은 필요했다.
public class HolidayEvent {
private static final int UNIT_AMOUNT = 2023;
...
private final Map<Menu, Integer> orderSheet;
...
public int getDiscountedAmount() {
int menuCount = orderSheet.keySet().stream()
.filter(menu -> menu.compareType(MenuType.MAIN))
.map(orderSheet::get)
.reduce(0, Integer::sum);
return UNIT_AMOUNT * menuCount;
}
}
"주말 할인을 계산하는 기능"을 구현하기 위해 HolidayEvent
클래스가 메뉴를 카운트하고 기준 금액을 곱하는 getDiscountedAmount
이다.
내심 불편했던 점은 HolidayEvent
는 할인 혜택을 계산하는 클래스인데 메인 메뉴 타입이 몇개 있는 지 계산하는 일까지 수행했던 것이다. 게다가 메뉴 타입을 계산하는 일은 HolidayEvent
만이 수행하는 일이 아니기 때문에 코드 중복으로 이루어질 수 있어 즉시 OrderSheet
라는 클래스로 분리시켰다.
public class HolidayEvent {
...
public int getDiscountedAmount() {
int menuCount = orderSheet.getMenuCountByMenuType(MenuType.MAIN);
return UNIT_AMOUNT * menuCount;
}
public class OrderSheet {
private Map<Menu, Integer> orderSheet;
...
public int getMenuCountByMenuType(MenuType menuType) {
int menuCount = orderSheet.keySet().stream()
.filter(menu -> menu.compareType(menuType))
.map(menu -> orderSheet.get(menu))
.reduce(0, Integer::sum);
return menuCount;
}
}
OrderSheet.getMenuCountByMenuType
은 메뉴 타입에 맞는 메뉴들을 리턴하는 메서드다.
클래스를 분리하고 나니 한결 편안해졌다. 이벤트 담당자가 직접 메뉴 타입에 맞는 메뉴의 갯수를 새는 것보다 이벤트 담당자가 주문서 담당자에게 메뉴의 갯수를 새달라고 요청하는 것이 더욱 자연스럽기 때문이다.
(Enum Factory Method 학습 내용 정리 글)
저번 주차에서 Enum의 강력함을 느끼고 나서인지 이번 주차 미션에서도 활용을 했다.
enum을 사용했던 부분은 이벤트들에 대해 통합적으로 "이벤트 적용이 가능한지"와 "이벤트 적용될 때 할인되는 값"을 구하는 기능을 구현할 때 사용했다.
만약 enum을 배우지 않았더라면 리스트로 만들었을 것이다.
Event[] events = new Event[] {new ChristmasEvent(day, orderSheet), new ...};
for (Event event : events) {
if (event.isDiscountable) {
price -= event.getDiscountPrice();
}
}
이벤트에 변경이 없다면 문제가 없는 코드다. 하지만 "만약 이벤트가 추가되거나 삭제된다면" 이라는 가정을 했을 때는 이 코드는 변경에 자유롭지 못하다고 생각했다. 이벤트를 생성하려고 new
를 사용하면 이벤트를 이용하는 클라이언트는 이벤트들에 강하게 의존하기 때문이다.
이를 해결하기 위해서는 생성과 사용을 분리시켜야했다. 보통이면 이 두 기능을 분리하기 위해 "팩토리 패턴"을 사용하지만 이전 주차에서 enum을 활용한 경험이 있고 enum에서 이벤트를 추가, 삭제하고 이벤트가 적용 가능한 지 체크하고 리스트로 반환하는 "행위를 한 곳에 모으고자" enum으로 해결할 수 있는 방법을 서칭하다가 Enum Factory Method에 대한 내용을 찾을 수 있었다. 그리고 Enum Factory Method을 내 과제에 적용했다.
public enum DiscountEventManager {
CHRISTMAS {
@Override
protected DiscountEvent create(Day day, OrderSheet orderSheet) {
return new ChristmasDiscountEvent(day);
}
},
HOLIDAY {
@Override
protected DiscountEvent create(Day day, OrderSheet orderSheet) {
return new HolidayDiscountEvent(day, orderSheet);
}
},
...
abstract protected DiscountEvent create(Day day, OrderSheet orderSheet);
public static DiscountResult getDiscountResult(Day day, OrderSheet orderSheet) {
List<DiscountEvent> discountEvents = Arrays.stream(values())
.map(event -> event.create(day, orderSheet))
.filter(DiscountEvent::isDiscountable)
.toList();
return new DiscountResult(discountEvents);
}
}
나중에 결과 출력을 위해 "적용 가능한" 이벤트를 모으는 기능을 구현해야 했는데 enum 클래스를 통해 이벤트 객체를 생성하고, 적용 가능한 지 확인할 수 있었다.
게다가 enum을 활용하면서 여러 장점이 있었는데
(DTO 학습 내용 정리 글)
MVC를 학습하면서, 그리고 코드 리뷰를 통해 DTO의 존재를 알게 되었다. MVC를 배우면서 "View 에서 Model 객체를 가지고 출력하면 어쨌든 간에 의존성이 생기는 게 아닌가? 그럼 분리했다고 보기 어렵지 않을까? 라는 의문이 들었다. 이번 주차에 DTO를 학습하고 과제에 적용하면서 이 의문을 해결할 수 있었다.
DTO를 사용하면서 여러 장점들을 알 수 있었다. 이전에는 모델 객체를 그대로 뷰에 전달하니 뷰에서는 객체의 속성에 자유롭게 접근할 수 있었고 보여주기 민감한 정보에도 접근할 수 있었지만 DTO에서 그런 정보들을 싣지 않으니 민감한 정보를 은닉시킬 수 있었다. 그리고 비즈니스 로직을 뷰로부터 분리시킬 수 있었다. DTO로 오직 전달할 데이터만 전달하고 어떤 비즈니스 로직도 없으니 뷰에서 DTO 클래스로 어떤 짓을 하더라도 모델의 비즈니스 로직을 더럽히는 일이 없었다.
이번 주차는 격이 다르게 어려운 과제가 있었고 프리코스 외적인 일들이 있었기 때문에 여러모로 바쁜 한 주였다. 그리고 길지만 짧았던 4주간의 프리코스가 끝이 났다.
매주 과제를 수행하고 코드 리뷰를 주고 받음으로써 배움이 많았던 프리코스 기간이었다. 코드 리뷰를 받으신 분들은 열정적으로 답변해주셔서 고마웠고 리뷰 받으시고 내 코드도 리뷰해주신 분들의 성의에 너무 고마웠다. 혼자서만 학습했다면 부족한 점을 깨우치지 못했을 것이고 이보다 더 성장하지는 못했을 것 같다.
프리코스 과제 마감 날짜를 지나면 일단 미뤄뒀던 일들 먼저 끝낸 다음에 지금까지 배웠던 내용을 가지고 지난 주차 과제들을 리펙토링할 계획이다.