개발자는 기록이 답이다

우테코 6기 프리코스 4주차 회고 본문

우아한테크코스

우테코 6기 프리코스 4주차 회고

slow-walker 2023. 11. 15. 15:02

 

 

마지막 4주차 미션이 마무리 되었습니다!😊

 

우아한 테크 코스 프리코스를 진행하면서 이번 한 달 동안 객체 지향 프로그래밍에 대해 깊이 고민하며 클래스와 객체를 어떻게 분리하고 설계해야 하는지에 대한 고찰이 많았습니다. 특히, 클래스가 가져야 하는 역할과 책임에 대한 고민을 통해 코드의 가독성과 유지보수성을 향상시키는 방법을 찾는 데에 큰 진전이 있었습니다.

도메인 로직에 집중하고 UI와의 분리를 고려하면서, 어떻게 클래스를 구성하고 역할을 할당하는지를 주요 고려 사항으로 삼았습니다. 이러한 고민을 통해 클래스 간의 결합도를 최소화하고 코드를 유연하게 만드는게 중요하다는것을 깨닫게 되었습니다.

좋은 코드의 기준이 무엇인지에 대한 깊은 이해를 얻을 수 있었으며, 객체 지향의 핵심 원리를 실제 코드에 적용해보면서 개발 방법에 대한 새로운 시각을 얻게 되었습니다.

또한 4주차 미션은 비공개 저장소로 진행되어서 따로 PR은 하지 않았습니다. fork가 아닌 비공개로 템플릿따라 만든 저장소에 woowa-course를 Collaborators로 초대해야되는데, 우테코랑 협업하는 사람이 되었다는 기분이 좋더라구요😊
코드 리뷰는 아마 4주차 끝나고 나서 퍼블릭으로 변경해서 할 수 있지 않을까 생각해봅니다.

 

퍼블릭 공개 : 4주차 저장소 링크

크리스마스 프로모션

 

 

 

🚀 기능 요구 사항

보낸 사람: 비즈니스팀 <biz@woowacourse.io>
받는 사람: 개발팀 <dev@woowacourse.io>

제목: 12월 이벤트를 위한 개발 요청

안녕하세요. 비즈니스팀입니다!

다가오는 2023년 12월에 우테코 식당에서 1년 중 제일 큰 이벤트를 개최하려고 합니다.
12월을 위해 이벤트 예산을 넉넉히 확보해 두었으니, 예산은 걱정하지 마세요~

특별히 이번 12월 이벤트를 진행하기 위해서, 개발팀의 도움이 많이 필요합니다.
아래 메뉴와 달력 이미지를 보면서 12월 이벤트 계획과 요청 내용을 본격적으로 설명해 드릴게요.
<애피타이저>
양송이수프(6,000), 타파스(5,500), 시저샐러드(8,000)

<메인>
티본스테이크(55,000), 바비큐립(54,000), 해산물파스타(35,000), 크리스마스파스타(25,000)

<디저트>
초코케이크(15,000), 아이스크림(5,000)

<음료>
제로콜라(3,000), 레드와인(60,000), 샴페인(25,000)

 

안녕하세요! 우테코 식당 12월 이벤트 플래너입니다.
12월 중 식당 예상 방문 날짜는 언제인가요? (숫자만 입력해 주세요!)
3
주문하실 메뉴를 메뉴와 개수를 알려 주세요. (e.g. 해산물파스타-2,레드와인-1,초코케이크-1)
티본스테이크-1,바비큐립-1,초코케이크-2,제로콜라-1
12월 3일에 우테코 식당에서 받을 이벤트 혜택 미리 보기!
 
<주문 메뉴>
티본스테이크 1개
바비큐립 1개
초코케이크 2개
제로콜라 1개
 
<할인 전 총주문 금액>
142,000원
 
<증정 메뉴>
샴페인 1개
 
<혜택 내역>
크리스마스 디데이 할인: -1,200원
평일 할인: -4,046원
특별 할인: -1,000원
증정 이벤트: -25,000원
 
<총혜택 금액>
-31,246원
 
<할인 후 예상 결제 금액>
135,754원
 
<12월 이벤트 배지>
산타

 

할인 이벤트

1. 크리스마스 디데이 할인 : 1000원부터 시작해서, 25일 다가올 수록 할인 금액이 100원씩 증가

2. 평일 할인 : 평일에는 디저트 메뉴 1개당 2,023원 할인

3. 주말 할인 : 주말에는 메인 메뉴 1개당 2,023원 할인

4. 특별 할인 : 달력에 별이 있으면 총 주문 금액에서 1,000원 할인

5. 증정 이벤트 : 할인 전 총 주문 금액이 12만원 이상 일때 샴페인 1개 증정

 

추가된 요구 사항

  • InputView, OutputView 클래스를 참고하여 입출력 클래스를 구현한다.
    • 입력과 출력을 담당하는 클래스를 별도로 구현한다.
    • 해당 클래스의 패키지, 클래스명, 메서드의 반환 타입과 시그니처는 자유롭게 구현할 수 있다.
public class InputView {
    public int readDate() {
        System.out.println("12월 중 식당 예상 방문 날짜는 언제인가요? (숫자만 입력해 주세요!)");
        String input = Console.readLine();    
        // ...
    }
    // ...
}

public class OutputView {
    public void printMenu() {
        System.out.println("<주문 메뉴>");
        // ...
    }
    // ...
}

 

이번 미션은 디테일한 요구사항들이 많았습니다.

⭐️ 핵심 기능은 입력받은 주문서대로 총 주문 금액을 책정하고, 입력받은 날짜를 이용해서 할인 혜택 적용하는 것이라고 생각합니다.


디렉토리 구조

src                                         
├─ main                                     
│  └─ java                                  
│     └─ christmas                          
│        ├─ controller                      
│        │  ├─ InitDto.java                 
│        │  └─ Promotion.java               
│        ├─ domain                          
│        │  ├─ discount                     
│        │  │  ├─ DDayDiscount.java         
│        │  │  ├─ Gift.java                 
│        │  │  ├─ SpecialDiscount.java      
│        │  │  ├─ WeekDayDiscount.java      
│        │  │  ├─ WeekDiscount.java         
│        │  │  └─ WeekendDiscount.java      
│        │  ├─ Badge.java                   
│        │  ├─ BadgeType.java               
│        │  ├─ Discount.java                
│        │  ├─ Menu.java                    
│        │  ├─ Order.java                   
│        │  └─ VisitDay.java                
│        ├─ utils                           
│        │  ├─ Constants.java               
│        │  ├─ ErrorMessages.java           
│        │  └─ ViewMessages.java            
│        ├─ view                            
│        │  ├─ InputValidator.java          
│        │  ├─ InputView.java               
│        │  └─ OutputView.java              
│        └─ Application.java                
└─ test                                     
   └─ java                                  
      └─ christmas                          
         ├─ domain                          
         │  ├─ discount                     
         │  │  ├─ DDayDiscountTest.java     
         │  │  ├─ GiftTest.java             
         │  │  ├─ SpecialDiscountTest.java  
         │  │  ├─ WeekDayDiscountTest.java  
         │  │  ├─ WeekDiscountTest.java     
         │  │  └─ WeekendDiscountTest.java  
         │  ├─ BadgeTest.java               
         │  ├─ DiscountTest.java            
         │  ├─ MenuTest.java                
         │  ├─ OrderTest.java               
         │  └─ VisitDayTest.java            
         └─ ApplicationTest.java

 

 

이번 4주차 미션은 3주차 공통 피드백을 최대한 반영하는 것을 목표로 했습니다.

 

1. 비즈니스 로직과 UI 로직을 분리한다

 

비즈니스 로직과 UI로직(입력, 출력)을 한 클래스가 담당하지 않도록 한다. 단일 책임의 원칙에 위배되기 때문이다.

현재 객체의 상태를 보기 위한 로그 메시지 성격이 강하다면 toString()을 통해 구현한다. View에서 사용할 데이터라면 getter 메서드를 통해 데이터를 전달한다.

 

따로 출력만 담당하는 OutputView클래스에서 객체의 상태를 읽어올때 getter를 사용했습니다.

public class OutputView {

    public void printMenu(Order order) {
		...
    }

    public void printOrderAmountBeforeDiscount(Order order) {
        System.out.println(TOTAL_ORDER_AMOUNT);
        System.out.println(String.format(AMOUNT, order.getOrderAmount())); // getter 사용
    }

    public void printGift(Gift gift) {
		...
    }

    public void printDiscountHistories(Discount discount) {
		...
    }

    private void appendDiscountHistory(StringBuilder print, String discountType, int discountAmount) {
		...
    }

    public void printTotalDiscount(Discount discount) {
		...
    }

    public String minusAmountFormat(int amount) {
		...
    }

    public void printFinalAmount(Order order, Discount discount) {
		...
    }

    public void printBadge(Discount discount) {
		...
    }
}

 

 

2. 연관성이 있는 상수는 static final 대신 enum을 활용한다

 

해당 피드백이 없었다면 요구사항에 있는 뱃지 부분도 static final을 사용한 매직 넘버, 매직 레터럴로 처리했을테지만, 이번에는 enum을 활용했습니다.

 

enum 활용 이점

 

1. enum을 사용하면 연관된 상수들을 하나의 그룹으로 묶을 수 있어서 코드의 의마가 명확해지고, 가독성이 향상된다고 합니다.

2. 상수가 어떤 의미를 가지고 있는지 직관적으로 파악도 가능해집니다.

3. 상수 값이 열거형으로 정의되어 있기 때문에 타입 안전성이 보장됩니다. 컴파일러가 상수의 유효성을 체크해주기 때문에 잘못된 값이 사용될 가능성이 줄어듭니다.

4. 상수를 정의할 때 각 상수에 고유한 이름을 부여할 수 있습니다. 이는 일반적인 명명 규칙을 따르는 것보다 더 명시적이며, 중복된 상수 값의 가능성을 줄여줍니다.

 

public enum BadgeType {

    STAR("별",5000),
    TREE("트리",10000),
    SANTA("산타",20000);

    private final String name;
    private final int badgeLimit;

    BadgeType(String name,int badgeLimit) {
        this.name = name;
        this.badgeLimit = badgeLimit;
    }

    public String getName() {
        return name;
    }

    public int getBadgeLimit() {
        return badgeLimit;
    }
}

 

 

이외에도 평일인지 주말인지 체크하는 부분과, 메뉴 카테고리별로 할인 혜택을 적용하는 부분을 위해 enum을 활용했습니다.

아래 내용은 각각 VisitDay 클래스와 Menu Enum내부에 선언된 enum으로 해당 클래스에서만 사용되도록 결합도와 응집도를 모두 높이기 위해 사용했습니다.

    public enum DayType {
        WEEKDAY,
        WEEKEND
    }
    public enum MenuType {
        APPETIZER,
        MAIN,
        DESSERT,
        DRINK
    }


3. 테스트 코드도 코드다

 

테스트 코드도 코드이므로 리팩터링을 통해 개선해 나가야 한다. 특히 반복적으로 하는 부분을 중복되지 않게 만들어야 한다. 예를 들어 단순히 파라미터의 값만 바뀌는 @ValueSource를 이용할 수 있다.

 

원래라면 아래처럼 매개변수와 결과값만 다른 경우 테스트 코드를 평일,주말 총 합쳐서 6개로 나눠서 작성했을 테지만, 피드백을 반영해서 @ParameterizedTest, @MethodSource와 추가적인 메소드를 이용해서 코드 중복을 피하고자 했습니다.

 

// 수정 전
    @Test
    @DisplayName("calculateDiscount 메서드 - 평일에 디저트 주문 안했을 경우")
    public void calculateDiscount_NoDessertOnWeekday() {
        Map<String, Integer> orderItems = new HashMap<>();
        orderItems.put("타파스", 1);
        orderItems.put("레드와인", 1);
        Order order = Order.create(orderItems);

        VisitDay weekdayVisitDay = VisitDay.create(4);// 월요일

        int amount = WeekDiscount.calculateDiscount(order, weekdayVisitDay, VisitDay.DayType.WEEKDAY, Menu.MenuType.DESSERT);
        assertThat(amount).isEqualTo(DEFAULT_AMOUNT);
    }

    @Test
    @DisplayName("calculateDiscount 메서드 - 평일에 디저트 1개 주문했을 경우")
    public void calculateDiscount_1DessertOnWeekday() {
        Map<String, Integer> orderItems = new HashMap<>();
        orderItems.put("아이스크림", 1);
        orderItems.put("타파스", 1);
        orderItems.put("레드와인", 1);
        Order order = Order.create(orderItems);

        VisitDay weekdayVisitDay = VisitDay.create(4);// 월요일

        int amount = WeekDiscount.calculateDiscount(order, weekdayVisitDay, VisitDay.DayType.WEEKDAY, Menu.MenuType.DESSERT);
        assertThat(amount).isEqualTo(WEEK_DISCOUNT_PER_TYPE);
    }

    @Test
    @DisplayName("calculateDiscount 메서드 - 평일에 디저트 3개 주문했을 경우")
    public void calculateDiscount_3DessertOnWeekday() {
        Map<String, Integer> orderItems = new HashMap<>();
        orderItems.put("아이스크림", 3);
        orderItems.put("타파스", 1);
        orderItems.put("레드와인", 1);
        Order order = Order.create(orderItems);

        VisitDay weekdayVisitDay = VisitDay.create(4);// 월요일

        int amount = WeekDiscount.calculateDiscount(order, weekdayVisitDay, VisitDay.DayType.WEEKDAY, Menu.MenuType.DESSERT);
        assertThat(amount).isEqualTo(3 * WEEK_DISCOUNT_PER_TYPE);
    }

... 주말도 테스트코드 3개

 

테스트 코드 자체는 6개에서 2개로 줄여서 라인 수가 짧게 되고 가독성을 챙긴것 같습니다. 라인 수로만 따지면 80줄에서 45줄로 변경되었으니 1/2배로 줄인것 입니다. 그러나 중복을 제거하고자 추가적인 인터페이스를 도입했는데, 다른 사람이 테스트 코드를 봤을때 @DisplayName으로 테스트코드의 목적성이 나뉘어져있어서 아래보다 위의 코드가 더 명확하게 돌아가는 내용을 파악할 수 있을 것 같다는 생각이 드는데, 이 부분에 대해서 다른사람들은 어떻게 생각하는지 궁금합니다.

// 수정 후
    private static final int WEEKDAY = 4; // 월요일
    private static final int WEEKEND = 2; // 토요일

    @ParameterizedTest
    @DisplayName("calculateDiscount 메서드 - 평일에 디저트 주문 테스트")
    @MethodSource("weekdayDessertParameters")
    public void calculateDiscount_WeekdayDessertTest(String menuName, int quantity, int expectedAmount) {
        testCalculator(WEEKDAY, Menu.MenuType.DESSERT, menuName, quantity, expectedAmount);
    }

    private static Stream<Object[]> weekdayDessertParameters() {
        return Stream.of(
                new Object[]{"타파스", 1, DEFAULT_AMOUNT},
                new Object[]{"아이스크림", 1, WEEK_DISCOUNT_PER_TYPE},
                new Object[]{"초코케이크", 3, 3 * WEEK_DISCOUNT_PER_TYPE}
        );
    }
    @ParameterizedTest
    @DisplayName("calculateDiscount 메서드 - 주말에 메인 주문 테스트")
    @MethodSource("weekendMainParameters")
    public void calculateDiscount_WeekendMainTest(String menuName, int quantity, int expectedAmount) {
        testCalculator(WEEKEND, Menu.MenuType.MAIN, menuName, quantity, expectedAmount);
    }

    private static Stream<Object[]> weekendMainParameters() {
        return Stream.of(
                new Object[]{"타파스", 1, DEFAULT_AMOUNT},
                new Object[]{"해산물파스타", 1, WEEK_DISCOUNT_PER_TYPE},
                new Object[]{"바비큐립", 3, 3 * WEEK_DISCOUNT_PER_TYPE}
        );
    }
    private void testCalculator(int dayOfMonth, Menu.MenuType menuType, String itemName, int quantity, int expectedAmount) {
        Map<String, Integer> orderItems = new HashMap<>();
        orderItems.put(itemName, quantity);
        Order order = Order.create(orderItems);

        VisitDay visitDay = VisitDay.create(dayOfMonth);

        int amount = WeekDiscount.calculateDiscount(order, visitDay, getDayType(dayOfMonth), menuType);
        assertThat(amount).isEqualTo(expectedAmount);
    }

    private VisitDay.DayType getDayType(int dayOfMonth) {
        if (dayOfMonth == WEEKDAY) return VisitDay.DayType.WEEKDAY;
        return VisitDay.DayType.WEEKEND;

    }

 

 

 

4. 평일/주말 할인 상속을 이용한 리팩토링

 

상속은 2주차 미션때도 사용했지만, Car객체와 각 자동차의 위치를 담은 Position의 관계를 구현할때 사용했습니다. 하지만 Position이Car의 종속적인 개념이 아니다 보니까 ( 자동차는 위치다 X ) is-a 관계가 아니었습니다. has-a관계 임에도 불구하고 단순히 코드를 재사용할 목적으로 서로 상속관계를 설정해서 잘못 설계했다고 판단했습니다.

 

그래서 상속을 무작정 사용하면 안되는 것이라고 생각하고 있었습니다. 그리고 이번 4주차 미션에서는 평일 할인, 주말 할인이 동일하게 로직이 작동하되 평일/주말, 메뉴타입에 따라서 다르게 적용되는 것을 파악했습니다.

 

그래서 위의 그림 처럼 WeekDiscount에서 관련된 내용을 상속받아서 자식 클래스의 생성자 내에서 super()를 통해 호출 할 수 있도록 설계했습니다.

 

추가적으로 저는 이번 우테코에서 생성자는 접근제어자를 private으로 설정하고, public한 정적 팩토리 메서드에서 생성자를 반환해주도록 만들었는데, 이러한 상속의 경우 정적 팩토리 메소드는 상위 클래스에서 필요가 없었습니다. 그래서 상위 클래스인 WeekDiscount의 생성자는 자식 클래스가 접근할 수 있도록 접근제어자를 protected로 설정했습니다.