Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[크리스마스 프로모션] 변해빈 과제 제출합니다. #1

Open
wants to merge 83 commits into
base: review
Choose a base branch
from

Conversation

h-beeen
Copy link
Owner

@h-beeen h-beeen commented Nov 16, 2023

header

  • 해당 README.md 는 PR 환경에서 최적화되어 작동합니다. PR 코드리뷰 페이지에서 깔끔한 UI로 확인이 가능합니다 :)

🎄 126명의 리뷰어에게 전하는 프리코스 마지막 이야기

12월 25일에 우테코 식당에서 받을 이벤트 혜택 미리 보기!

<주문 메뉴>
아이스크림 2개

<할인 전 총주문 금액>
10,000원

<증정 메뉴>
없음

<혜택 내역>
크리스마스 디데이 할인: -3,400원
평일 할인: -4,046원
특별 할인: -1,000원

<총혜택 금액>
-8,446원

<할인 후 예상 결제 금액>
1,554원

<12월 이벤트 배지>
별

▪️ 리뷰가 많아 로딩이 오래 걸립니다!
   🦄 오류 발생시 새로고침 하거나 네트워크 상태를 확인해 주세요!!

▪️ 프리코스를 열심히 완주한 당신! 이번 크리스마스 🎄 우테코 식당으로 모여주세요.
   맛있는 1,554원의 아이스크림별 배지가 기다리고 있답니다.
▪️ 지난 3주 동안, 성장곡선을 지수함수로 그릴 수 있도록 만들어 주신 4주간 101명의 크루 여러분
   크리스마스에 우테코 식당에서 같이 아이스크림 먹어요! (제가 사겠습니다)
▪️ 추신) 지수함수의 정의역, 치역이 0인 지수함수라고 가정합니다.
   (완전 가파르다는 뜻) 참고로 저는 문과입니다. ^____^!


📦  패키지 구조

Package Class Description
  controller   OrderController 주문 목록 입력 요청 / 결과 출력
  PromotionController 할인, 증정 등 프로모션 혜택 결과 출력 컨트롤러
  VisitDayController 방문 일자 입력 요청 / 결과 출력 컨트롤러
  controller
➜  dto
  BadgeResponse 이벤트 배지 DTO
  DiscountResponse 할인 프로모션 종류 및 가격, 총 할인 금액 DTO
  GiftResponse 증정 프로모션 종류 및 갯수, 총 증정품 금액 DTO
  OrderResponse 사용자의 주문 메뉴와 갯수를 갖는 DTO
  VisitDayResponse 방문월, 방문일 DTO
  domain
➜  consumer
  Orders 사용자의 주문 메뉴와 갯수를 갖는 일급 컬렉션 클래스
  VisitDay 사용자가 방문한 날짜를 갖는 클래스
  domain
➜  constants
  Menu 주문 가능 메뉴, 가격, 카테고리를 갖는 열거형 클래스
  MenuCategory 메뉴의 카테고리를 갖는 열거형 클래스
  PlannerConstraint 이벤트 진행에 필요한 전역 설정(Contraint)을 갖는 열거형 클래스
  domain
➜  promotion
➜  badge
  BadgeContext 조건에 맞게 배지를 생성하는 전략 클래스
  BadgePromotion 배지의 이름, 프로모션 적용 기간, 적용 조건을 갖는 열거형 클래스
  domain
➜  promotion
➜  discount
  AppliedDiscountPromotions 조건에 맞게 적용된 할인 프로모션을 갖는 일급 컬렉션 클래스
  DiscountContext 적용 가능한 할인 프로모션을갖는 일급 컬렉션 전략 클래스
  DiscountPromotion 할인 프로모션의 이름, 조건, 적용 기능을 갖는 열거형 클래스
  domain
➜  promotion
➜  gift
  AppliedGiftPromotions 조건에 맞게 적용된 증정 프로모션을 담는 일급 컬렉션 클래스
  GiftContext 적용 가능한 증정 프로모션을 갖는 일급 컬렉션 전략 클래스
  GiftPromotion 증정 프로모션의 이름, 조건, 갯수를 갖는 열거형 클래스
  domain
➜  promotion
➜  constants
  PromotionCondition 프로모션의 적용 기간 및 조건, 필요한 배지 정보를 갖는 열거형 클래스
  PromotionPeriod 프로모션의 적용 기간에 대한 조건을 상세히 갖는 열거형 클래스
  PromotionType 상품의 종류를 갖는 열거형 클래스
  SpecialPromotionPeriod 특별 이벤트 적용 기간에 대한 조건을 상세히 갖는 열거형 클래스
  domain
➜  utility
  Parser 유효성 검사를 진행하고, 조건에 맞게 날짜/숫자를 파싱하는 기능 클래스
  exception   BusinessException 전역에서 에러를 핸들링하는 Custom Exception
  ErrorCode 에러 메세지, 검증 로직을 갖는 전역 예외 열거형 클래스
  ExceptionHandler 사용자의 이상값 입력에 대한 예외처리 핸들링 클래스
  view
➜  constants
  ResponseFormat 사용자의 입력을 바탕으로 포맷팅이 필요한 출력 조건 열거형 클래스
  ResponseMessage 시스템 제약조건을 바탕으로 출력하는 정적 메세지 열거형 클래스
  view
➜  input
  InputReader 표준 입력을 담당하는 클래스
  view
➜  output
  ErrorOutputWriter Exception 상황에서 에러메세지 표준 출력을 담당하는 클래스
  OrderOutputWriter 사용자에게 주문 응답 표준 출력을 담당하는 클래스
  OutputWriter 필수 표준 출력 메소드 구현 클래스
  PromotionOutputWriter 사용자에게 혜택 응답 표준 출력을 담당하는 클래스
  VisitDayOutputWriter 사용자에게 방문 월/일 표준 출력을 담당하는 클래스


✨  구현 목록

1️⃣ Non-Functional Requirement


✅  크리스마스 프로모션 프로그램은 java17 환경에서 MVC 패턴에 따라 설계한다.
✅  1월 이벤트 간, 본 프로그램을 추가 개발하여 활용할 예정이므로 YAGNI 원칙에 입각해 개발한다.

▪️  중복된 할인과 증정을 허용해서, 고객들이 혜택을 많이 받는다는 것을 체감할 수 있게 하는 것
▪️  올해 12월에 지난 5년 중 최고의 판매 금액을 달성
▪️  12월 이벤트 참여 고객의 5%가 내년 1월 새해 이벤트에 재참여하는 것
▪️  이벤트 배지는 2024 새해 이벤트에서 활용할 예정입니다.
▪️  배지에 따라 새해 이벤트 참여 시, 각각 다른 새해 선물을 증정할 예정입니다.

✅  필요 구현 요소를 구현함에 있어, 추후 요구 조건을 염두하고, 확장성있게 구현하는 것을 목표로 한다.
✅  DTO/Response/Test 계층에서 사용하는 @Getter를 제외하고, @Getter, @Setter 사용을 금지한다.



2️⃣ Functional Requirement (Controller Code Flow)


✅  [Application.main] VisitDayController.requestVisitDay()

▪️  사용자에게 웰컴 메세지를 출력하고 방문일자 입력을 요청한다.

안녕하세요! 우테코 식당 12월 이벤트 플래너입니다.
12월 중 식당 예상 방문 날짜는 언제인가요? (숫자만 입력해 주세요!)

▪️  방문 일자를 LocalDate 형식을 멤버 변수로 갖는 VisitDay 객체로 생성한다.

💥 요청이 숫자가 아닐 경우 예외처리
💥 요청이 12월 기준 (1~31)의 값이 아닐 경우 예외처리

✅  [Application.main] OrderController.requestOrders(visitDay)

▪️  사용자에게 메뉴 입력 메세지를 출력하고 메뉴 입력을 요청한다.

주문하실 메뉴를 메뉴와 개수를 알려 주세요. (e.g. 해산물파스타-2,레드와인-1,초코케이크-1)

▪️  사용자 주문을 EnumMap<음식, 수량> 컬렉션을 갖는 일급컬렉션 Orders 객체로 생성한다.

💥 요청이 콤마로 끝날 경우 예외처리
💥 요청에 whiteSpace가 포함될 경우 예외처리
💥 음식-수량의 형태 정규표현식 패턴("^[가-힣]+-\d{1,20}$")이 아니라면 예외처리
💥 수량이 정수가 아닐 경우 예외처리
💥 같은 음식을 중복 입력했을 때 예외처리
💥 주문이 양의 정수가 아닐 경우 예외처리
💥 음료만 주문했을 경우 예외처리
💥 총 주문이 20개를 초과했을 경우 예외처리

✅  [Application.main] VisitDayController.responseVisitDay(visitDay);

▪️  사용자에게 월/일에 해당하는 방문일 응답을 내린다.

12월 26일에 우테코 식당에서 받을 이벤트 혜택 미리 보기!

✅  [Application.main] OrderController.responseOrdersResult(orders);

▪️  사용자에게 주문 메뉴 응답을 내린다.

<주문 메뉴>
타파스 1개
제로콜라 1개

✅  [Application.main] OrderController.responseTotalOriginPriceResult(orders);

▪️  사용자에게 totalOriginPrice 응답을 내린다.

<할인 전 총주문 금액>
8,500원

✅  [Application.main] PromotionController.responseAppliedBenefitResult(visitDay, orders);

▪️  사용자에게 적용된 증정과 총 혜택 내역과 금액, 예상 결제금액과 배지 응답을 내린다.

<혜택 내역>
크리스마스 디데이 할인: -1,200원
평일 할인: -4,046원
특별 할인: -1,000원
증정 이벤트: -25,000원

<총혜택 금액>
-31,246원

<할인 후 예상 결제 금액>
135,754원

<12월 이벤트 배지>
산타

✅  [Application.main] Console.close();

▪️  표준 입력 간 사용된 Scanner API에서 사용 중인 리소스를 명시적으로 해제한다.

🆘  고민일기

1️⃣   단위 테스트의 비용과 효능

▪️  이번 과제를 진행하면서 단위테스트의 비용에 대한 부분에 의문을 갖게 되었습니다.
▪️  단위테스트는 각 클래스에서 public 접근제어자를 활용하는 모든 메소드에 대해 테스트 할 것을 권고하고 있습니다.
   DDD패턴 + Getter 사용을 제한하면서, public 메소드가 기존 코드보다 더 많이 생성되었기 때문에,
   같은 기능을 수행하는 여러 메소드가 많이 생기게 되면서, 테스트 코드 작성의 피로도가 많이 증가했습니다.
▪️  이번 과제에서, 가장 적절한 테스트 방법은 무엇이었을까요? (통합테스트? 단위테스트?)

2️⃣   Enum + Functional Interface VS Strategy Pattern

전략 패턴으로 최초 구현(8e5400)
Enum + 함수형 인터페이스 사용 리팩토링(ea786c)

▪️  전략패턴은 유연하고, 확장성이 높아 자주 사용되는 디자인 패턴이지만, 코드의 양이 매우 증가한다.
   (각 전략에 따른 클래스 구현)

▪️  또한 모든 전략이 동일한 인터페이스를 공유해야 하는데, 아래와 같이 조건이 복잡하게 산정됩니다.
      - 할인 : 사용자의 주문을 바탕으로 할인 조건 분기
      - 증정 : 사용자의 할인 총 가격을 바탕으로 증정 조건 분기
      - 배지 : 사용자의 할인 조건 + 증정 조건의 합을 바탕으로 배지 조건 분기

▪️  3가지 전략을 각자 다른 Context와 Strategy로 구현한다면, 매우 많은 클래스가 생성 되었을 것입니다.
▪️  따라서 Enum에 함수형 인터페이스를 적용해, 프로모션 열거형 상수 집합 각각의 변수에, 
   프로모션 적용 조건과, 적용 함수를 넣어, 이를 해결하는 방식으로 슈팅

👬  101명과 함께한 906개의 소중한 코드리뷰 적용기

  • [숫자 야구] 31명과 함께한 224개의 이야기
    (🦄 오류 발생시 새로고침 해주세요!)

    ▪️  0x00. 어떤 객체가 기능들을 할당하는지도 같이 리드미에 명시되어 있으면 좋을 것 같다.
    ▪️  0x01. 검증 메소드에서 긍정 조건을 사용하는게 가독성에 좋다.
    ▪️  0x02. 컨벤션이 없는 상황에서 정적 팩토리 메소드 사용은 혼선을 가져올 수 있다.
    ▪️  0x03. MVC 패턴에서 모델에서 뷰의 정보를 알아서는 안 된다.
    ▪️  0x04. 일부 접근지정자가 세밀하게 조정되지 않았다.
    ▪️  0x05. Open-Closed Principle vs YAGNI
    ▪️  0x06. Flag 네이밍은 boolean의 의미가 강하다. 정수는 다른 네이밍이 필요하다.
    ▪️  0x07. 클래스 내부 함수의 선언 순서가 세밀하게 조정될 필요가 있다.
    ▪️  0x08. 커스텀 예외 선언은 정적 팩토리 메소드보다, 직접 선언하는게 정확하다.
    ▪️  0x09. Protected 생성자 vs Private 생성자
    ▪️  0x0a. 조건에 따라 분기하는 것은 검증(Validator)계층이 아니다.
    
  • [자동차 경주] 39명과 함께한 296개의 이야기
    (🦄 오류 발생시 새로고침 해주세요!)

    ▪️  0x00. 메소드 파라미터를 final로 선언하면 어떤 이점이 있을까?
    ▪️  0x01. Static Import를 과하게 사용해, 코드를 읽는데 혼선이 생긴다.
    ▪️  0x02. Primitive Type Vs Wrapper Class
    ▪️  0x03. 도메인과 Dto의 의존성 끊어내기
    ▪️  0x04. Exception을 try-catch할 때, catch한 Exception도 같이 처리하면 좋다!
    ▪️  0x05. Enum의 극한 활용! 조건과 검증 기능까지 수행해보기
    
  • [로또] 22명과 함께한 213개의 이야기
    (🦄 오류 발생시 새로고침 해주세요!)



🌱 0x00 자식 클래스부모 클래스 인가? LSP 원칙에 대한 고찰

▪️  자식 클래스는 최소한 부모 클래스에 있는것들은 다 수행이 가능해야 한다.
▪️  부모 클래스가 A를 구현했다면, 자식 클래스도 최소한 A는 구현해야 한다.
▪️  How to Resolve) Fish(자식)이 Animal(부모)의 Speak()를 구현하지 못한다면? 
   → 부모의 Speak()를 인터페이스로 뺀다

출처 : 리스코프 치환 원칙 네이버 블로그

▪️  OutputWriter는 `System.out.print**` 명령어를 활용해 구현체를 구현한다.
▪️  각각의 **OutputWriter는 각 객체의 출력 책임을 가지면서, println, print를 상속해 사용한다.
graph TD;
    OutputWriter --> ErrorOutputWriter
    OutputWriter --> OrderOutputWriter
    OutputWriter --> PromotionOutputWriter
    OutputWriter --> VisitDayOutputWriter
Loading
▪️  이번에 사용한 View Layer 추상클래스 상속 방법은, LSP를 잘 지켜서 설계되었는지, 좋은 설계였는지 리뷰 받아보고 싶어요.

🌱 0x01 Fixture 방식 테스트 코드 설계 간 가독성의 문제점

// ▪️  3주차 코드 작성 방법
        
void Should_ThrowException_When_OutOfRangePayment() {
    // given
    final BuyerFixture tooBig=BuyerFixture.TOO_BIG;
    // when && then
    assertThatThrownBy(tooBig::toEntity)
        .isInstanceOf(IllegalArgumentException.class);
}
// ▪️  4주차 코드 작성 방법

/**
 * Given Fixture Information
 * OrderList : "양송이수프-1,티본스테이크-2,초코케이크-3,제로콜라-4"
 * TotalOriginPrice : 173,000
 */

void calculateTotalOriginPrice() {
    // given -> expect 173,000
    Orders orders=VALID__A.toEntity();
    // when
    final var calculatedTotalOriginPrice=orders.calculateTotalOriginPrice();
    // then 173,000원
    assertEquals(calculatedTotalOriginPrice,173_000);
}
▪️  Fixture 방식의 강력한 엔티티 반환 기능을 보장하면서, 가독성도 해치지 않도록, 주석을 직관적으로 추가했습니다.

🌱 0x02 예외 발생 간 재귀를 활용한 재요청시, Stack Overflow Issue

public static <T> T retryOnBusinessException(Supplier<T> supplier) {
    while (true) {
        try {
            return supplier.get();
        } catch (BusinessException exception) {
            ErrorOutputWriter.println(exception.getMessage());
        }
    }
}

public static <T> T tryOnParseIntException(Supplier<T> supplier) {
    try {
        return supplier.get();
    } catch (DateTimeException | NumberFormatException exception) {
        throw BusinessException.of(INVALID_DATE, exception);
    }
}
▪️  기존에 재귀를 이용해 호출하던 구조를 @JaeHongDev님의 코드리뷰 덕분에 깔끔한 Supplier문으로 변경할 수 있었어요!
▪️  Stack Overflow에서 자유롭고, 코드리뷰 받은 방식에서 조금 더 커스텀해, 깔끔한 코드라인을 만들 수 있었답니다.
public static Orders requestOrders() {
    OrderOutputWriter.printMessageResponse(REQUEST_MENU_ORDERS);
    return ExceptionHandler.retryOnBusinessException(OrderController::createMenuOrdersFromInput);
}
▪️  Supplier를 활용한 Exception Handling + ErrorCode의 validation 활용! 지금 코드리뷰를 통해 만나보세요

🌱 0x03 검증 메소드의 네이밍 컨벤션에 대해서

Prefix Feature Example
is True? Or False? isSameCategory()
has Contain ? Or not? hasOnlyBeverage()
can Can? Or CanNot? canOrder()
▪️  해당 네이밍 컨벤션을 잘 지키며, boolean 메소드 자체가, 해당 기능을 명확하게 드러내도록 설계했습니다.
▪️  ErrorCode의 validate() 예외처리 메소드와 boolean 검증 메소드의 포스트 컨디션을 조합해 사용했습니다. 

🌱 0x04 인터페이스에 일반 구현메소드 작성 방법

public interface MyInterface {
    
    // regular interface methods
    
    default void defaultMethod() {
        // default method implementation
    }
}

출처 : Static and Default Methods in Interfaces in Java
Thanks To @IMWoo94

▪️  해당 개념을 이번 OutputWriter에 적용하고 싶었지만, Static Method로 활용하면서 적용하지 않게 되었습니다.
▪️  만약 OutputWriter를, 의존관계 주입을 통해 활용한다면, 해당 방법을 꼭 활용해, 더 객체지향적인 설계를 유도하고 싶어요.

🌱 0x05 중복되는 테스트 코드를 줄이는 방법

▪️  assertThatIllegalArgumentException() 과 같은 상세 구현 메소드를 활용한다.
▪️  @ParmiterizedTest, @EnumSource, @ValueSource를 적극 활용해, 반복 테스트를 구현한다.
▪️  가독성을 해치지 않는 선에서, 적절한 Fixture 사용을 통해, 테스트에 필요한 생성값을 구현한다.

🌱 0x06 DTO VS Mapper Class 설계

▪️  아직까지도 가장 강하게 의문이 있는 로직입니다.
▪️  DTO가 Getter를 제외한 어떤 기능도 가지면 안된다.
▪️  DTO는 어원 그대로, Data Transfer Object의 기능만 수행해야 한다.
▪️  그러면 우리는, 클라이언트에게 리턴할 때 마다, Mapper Class를 구현하고, 설계해야 할까?
▪️  해당 비용을 지불하지 않는 Trade-off로 적정 수준의 도메인 의존성을 갖는다면, 안티패턴일까?
▪️  이 부분에 대해서도 꼭 토론하고 리뷰 받고 싶어요!!

🌱 0x07 방어적 복사에 대해서

UnmodifiableList로 해도 내부의 객체 요소에 대해서는 수정이 가능하며,
원본이 수정이 되었을 때 같이 수정이 된다는 문제점이 있다.

stream을 통해 컬렉션의 복사본을 생성하고
해당 복사본을 다시 unmodifiable로 반환하는 방법은
복사본과 원본이 서로에게 영향을 미치지 않게 된다.

하지만 이러한 방법 또한 비용 발생하기 때문에,
List.copyOf()나 Collectors.toUnmodifiableList()를 적절하게 사용하는 것도 좋다.

출처 : [3주차] 로또 코드리뷰 중 발췌 후 정리
참고 : [Java] Collection 복사 - 복사 방법(방어적 복사, 얕은 복사, 깊은 복사) 및 상황별 최적의 복사 방법
Thanks To @SSung023

▪️  이번 과제에서는 방어적 복사를 포함한 복사 방법을 활용하지 않았습니다.
▪️  DTO/Response/Test 코드를 제외하고는 Getter 코드를 일체 사용하지 않았기 때문입니다.
▪️  로또 과제에서 리뷰를 받았던 내용이라, 깊이 있게 학습했고, 추후 적극적으로 응용해볼 계획입니다 :)

🌱 0x08 컨트롤러호출책임

public class LottoMainController {
    private LottoMainController() {
    }

    public static void start() {
        Buyer buyer = BuyerController.requestPayment();
        Lottos lottos = PurchaseController.purchase(buyer);
        Prize prize = PrizeController.requestPrizeNumbers();
        // <후략>
    }

▪️ 지난 로또 과제에서 리뷰를 통해 알게 된 문제 사항
️ 1. Controller는 View와 Domain의 다리인데, Domain에게만 메세지를 전달할 경우 Controller의 책임일까?
️ 2. Controller는 View와 Domain의 다리인데, Controller간 통신은 좋은 패턴일까?
️ 3. MainController와 Application.main은 어떤 기능적 차이를 갖는가? 왜 컨트롤러 성격을 가져야 할까?
️ 4. MainController의 단일 메소드에 모든 기능이 몰려 있어, 단위 테스트가 통합 API 테스트가 되어버린다.

public class Application {
  public static void main(String[] args) {
    VisitDay visitDay = VisitDayController.requestVisitDay();
    Orders orders = OrderController.requestOrders();

    VisitDayController.responseVisitDay(visitDay);
    OrderController.responseOrdersResult(orders);
    OrderController.responseTotalOriginPriceResult(orders);
    PromotionController.responseAppliedBenefitResult(visitDay, orders);

    Console.close();
  }
}

▪️ 개선 사항
️ 1. Controller는 궁극적으로 View와의 통신을 목표로 한다. Model -> Controller -> View의 성격을 계속 염두하자.
️ 2. Controller간 호출을 막기 위해, Main 로직을 Application.main에 구현한다. (이 부분의 책임에 대해 리뷰 받고 싶어요)
️ 3. Controller의 각 메소드의 책임을 줄이고, Application에서 호출함으로써, 단위 테스트 구현을 가능하게 설계한다.


📝  개발 시 작성한 기능 구현 목록

✅  사용자에게 방문 날자 메세지 출력 후 요청 -> Return VisitDay

▪️  `안녕하세요! 우테코 식당 12월 이벤트 플래너입니다.`
▪️  `12월 중 식당 예상 방문 날짜는 언제인가요? (숫자만 입력해 주세요!)`

✅  사용자에게 예상 방문 날짜 입력 (1~31)
✅  숫자가 아닌 문자열 예외처리
✅  System Constraint 기준으로 해당 달의 달력에 없는 날짜 예외처리
✅  사용자에게 메뉴 입력 메세지 출력 후 요청 -> Return Orders

▪️  `주문하실 메뉴를 메뉴와 개수를 알려 주세요. (e.g. 해산물파스타-2,레드와인-1,초코케이크-1)`

✅  자체 제약조건 : 음식은 무조건 한글이다. (제로콜라, 타파스가 영어임을 생각했을때)
✅  콤마로 끝나거나, 스페이스바가 들어가거나, 정규표현식 패턴이 맞지 않거나 whiteSpace를 포함하지 않을 때 예외처리
✅  input을 콤마 단위로 파싱하기 (파싱 후 한글-숫자 형태 검증) -> 정규표현식
✅  정규표현식) 단일 메뉴 주문 갯수가 1~20 범위 밖이면 예외처리
✅  정규표현식) 파싱된 각 인자가 음식-숫자 형식이 아닐경우 예외처리
✅  Orders 생성자 호출 -> EnumMap 일급 컬렉션 객체 생성
✅  Orders) 메뉴판에 없는 메뉴 예외처리
✅  Orders) 메뉴 전체의 총 합이 20개를 초과 할 경우 예외처리
✅  Orders) 음료만 주문하면 예외처리

✅  주문 메뉴 출력

<주문 메뉴>
티본스테이크 1개
바비큐립 1개
초코케이크 2개
제로콜라 1개

✅  할인 전 총 주문 금액 출력

<할인 전 총주문 금액>
142,000원

✅  DailyDiscount (12/1 ~ 12/25)
✅  WeekdayDiscount 평일 할인 (일,월,화,수,목) 디저트 메뉴당 2,023원 할인
✅  WeekendDiscount 주말 할인 (금, 토) 메인 메뉴 1개당 2,023원 할인
✅  SpecialDiscount 매주 일요일 + 크리스마스(별이 있으면) 총 주문금액 1,000원 할인
✅  (매주 일요일 + 크리스마스 당일) : 총 주문금액에서 1,000원 할인 -> 날짜 enum 하드코딩

✅  최종 할인가를 종합해 증정 이벤트 판별
✅  마지막 최종 주문금액 산출 후 // 혜택 금액에 증정가 추가
✅  새해 선물 : 총 혜택 금액에 따른 배지 부여
✅  증정 이벤트 : 총 주문 금액 12만 이상시 샴페인 증정(혜택 25,000원!!)

✅  증정 메뉴 출력 : 총 혜택 금액을 바탕으로
    - TotalDiscountPrice

<증정 메뉴>
샴페인 1개

✅  혜택 내역 출력 : 총 할인 금액과, 증정 메뉴 가격을 바탕으로
    - Entry<DiscountPromotion, DiscountedAmount> + TotalGiftPrice

<혜택 내역>
크리스마스 디데이 할인: -1,200원
평일 할인: -4,046원
특별 할인: -1,000원
증정 이벤트: -25,000원

✅  총 혜택 금액 출력 : 총 할인 금액과 증정 메뉴 가격을 바탕으로
    - TotalDiscountAmount + TotalGiftPrice

<총혜택 금액>
-31,246원

✅  할인 후 예상 결제 금액 : 총 주문 금액와 총 할인 금액을 바탕으로
    - TotalOriginPrice - TotalDiscountAmount

<할인 후 예상 결제 금액>
135,754원

✅  이벤트 배지 : 총 혜택 금액을 바탕으로
    - 5000원 - 별
    - 10000원 - 트리
    - 20000원 - 산타

<12월 이벤트 배지>
산타

Special Thanks to Human Papago 🦜@saeyeonn during 4 Weeks

Copy link

@wooteco-daram wooteco-daram left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️사랑❤️
OutputWriter 상속 구조는 잘 된 것 같습니다!!!!
추가로 PromotionOutputWriter 도 나눠볼 수 있지 않을까 생각도 들었어요. :)

Comment on lines +22 to +23
@DisplayName("[requestOrders] 주문할 메뉴, 갯수를 요청하고, 요청이 제약조건에 맞지 않으면 예외를 던진다.")
class requestOrders extends NsTest {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔고민🤔

저도 NsTest 를 쓸 때 마다 고민 되는 사항이 몇 있었어요.
NsTest 가 메인 출력을 테스트하는 기본 코드에 포함이 되긴 하지만요..!

첫 번째로 모든 출력 테스트 코드가 외부 라이브러리에 의존한다는 고민도 있었어요.
두 번째로 NsTest 이름만으로 무엇을 상속한다는 건지 꽤 알아차리기 힘들었어요.
세 번째로 메소드 재정의가 불가능해 (runMain() 만 재정의 가능) 제가 원하는 출력 형식을 제대로 테스트하지 못한다는 느낌도 받았어요. 예를 들어 출력에 좌우 공백이 들어가면, 그 공백이 trim() 메소드로 짤리더군요. 그럼 기존 상수 String 과 다른 출력이 이뤄졌어요. 하지만 실제 출력은 공백이 들어가는데, 제대로 된 테스트가 맞나..? 생각이 들더라고요.

그래서 저는 ConsoleOutputCapture, PrintOutputTest 등등으로 조금 더 이름을 구체적으로 붙여서,
프로젝트 자체적으로 새로 만들었는데, 혹시 저와 같은 고민을 해보셨을까요~? 의견이 궁금합니다. :)

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 설계에서는 NsTest를 배제하는 방향으로 하려고 합니다!
NsTest 자체가, 표준 입출력에 종속되어있는 설계기도 하고,
컨트롤러 레벨을 테스트하기에 부적합한것은 아니나, 여러가지 커스텀해서 구현하기에는
다소 제한적이라는 생각이 저도 많이 들었습니다!

AssertJ나, JUnit을 적극 활용해 테스트코드를 짜는 것이 좋을 것 같아요!

@wooteco-daram 님께서 말씀 주신 대로,
프로젝트 자체적으로 테스트 할 수 있는 모듈을 만들고
이를 상속받아 사용하는 것도 큰 도움이 될 것 같군요 👍


//== Constructor ==//
private VisitDay(final int visitDay) {
this.visitDay = ExceptionHandler.tryOnParseIntException(() -> convertLocalDate(visitDay));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

생성자는 무조건 public으로 해서 다른 곳에서 쓰는건줄 알았는데 혜빈님은 private으로 하셨군요...!

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

생성자를 private으로 닫고, 정적 팩토리 메소드로 호출하도록 강제했습니다.
의미를 부여한 정적 팩토리 메소드 호출을 강제해, 무분별한 객체 생성에 대한 휴먼 에러를 막고자 하는 의도입니다 :)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExceptionHandler를 VisitDay에서 호출한 이유를 여쭙고 싶습니다.
개인적으로는 ExceptionHandler를 통한 예외 처리는 Controller 단에서 끝내고, VisitDay에선 비즈니스 로직 (1 ~ 31일)에 대한 검증만 해주면 명료해질 것 같아 질문 드립니다!

}

public Integer multiplyDate(final int valueToMultiply) {
return visitDay.getDayOfMonth() * valueToMultiply;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getter는 이런 상황에서는 써도 되는걸까요?? 정확히 어느 범위까지 getter를 안 쓰고 메세지를 보내줘야하는지
헷갈리네요ㅜ

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

multiplyDate의 경우, 객체 내부에서 필드 변수에 대한 값을 가져와, 값을 곱해 리턴하는 함수입니다.
반대로, 해당 함수를 호출하는 곳에서 getVisitDay() 로 객체 전체를 가져와 값을 비교하는 방법이 있을수 있겠네요.
VisitDay로 포장된 객체를 외부해 노출하지 않고, 해당 도메인 내부에서 위와같이 사용함으로써 캡슐화를 조금 더 지킬 수 있어요!

Comment on lines +38 to +43
public boolean containPeriod(
LocalDate startDate,
LocalDate endDate
) {
return !(visitDay.isBefore(startDate) || visitDay.isAfter(endDate));
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

게터를 쓰는 대신에 이렇게 boolean 함수를 따로 만드셨군요...!

Comment on lines +13 to +30
MUSHROOM_SOUP(APPETIZER, "양송이수프", 6_000),
TAPAS(APPETIZER, "타파스", 5_500),
CAESAR_SALAD(APPETIZER, "시저샐러드", 8_000),

//== MAIN_DISH ==//
T_BONE_STEAK(MAIN_DISH, "티본스테이크", 55_000),
BBQ_RIB(MAIN_DISH, "바비큐립", 54_000),
SEAFOOD_PASTA(MAIN_DISH, "해산물파스타", 35_000),
CHRISTMAS_PASTA(MAIN_DISH, "크리스마스파스타", 25_000),

//== DESSERT ==//
CHOCOLATE_CAKE(DESSERT, "초코케이크", 15_000),
ICE_CREAM(DESSERT, "아이스크림", 5_000),

//== BEVERAGE ==//
ZERO_COLA(BEVERAGE, "제로콜라", 3_000),
RED_WINE(BEVERAGE, "레드와인", 60_000),
CHAMPAGNE(BEVERAGE, "샴페인", 25_000);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPETIZER 안에 mushroom, tapas, salad 가 있는게 아니라
각 메뉴 안에 에피타이저를 넣어주셨군요...! 이렇게 구현을 할 수도 있겠네요 배워갑니당!

Comment on lines +3 to +10
public enum MenuCategory {
APPETIZER,
MAIN_DISH,
DESSERT,
BEVERAGE;

MenuCategory() {
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메뉴 카테고리를 따로 만드셨군요..! 이렇게 분리하면 어떤 장점이 있을까요?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

평일엔 DESSERT, 주말엔 MAIN_DISH, BEVERAGE만 주문했을 때 예외처리.
해당 부분을 문자열로 처리하는 것 보다, type-safety 하게 처리할 수 있습니다 :)

Comment on lines +47 to +51
private boolean hasOnlyBeverages(EnumMap<Menu, Integer> menus) {
return menus.keySet()
.stream()
.allMatch(this::isBeverage);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메뉴에 대한 오류 상황들을 다른 오류 클래스를 생성하지 않고 바로 Orders 안에서 처리해주셨네요.
이게 훨씬 더 직관적인거 같네요 저도 이렇게 수정해보겠습니다 !


//== Static Factory Method ==//
public static Orders create(EnumMap<Menu, Integer> menus) {
return new Orders(menus);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Menu 객체를 따로 만들어서 Menus에 넣었었는데 EnumMap이라는게 존재하는군요
배워갑니다!

VisitDay visitDay,
Orders orders,
BadgePromotion badge
) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

생성자 안에 이렇게 초기화를 하는게 훨씬 더 효율적인 코드같네요. 저도 수정해보겠습니다 !

Comment on lines +4 to +9
PRODUCT_QUANTITY_RESULT("%s %d개"),
PRICE_RESULT("%,d원"),
MINUS_PRICE_RESULT("-%,d원"),
BENEFIT_PRICE_RESULT("%s: -%,d원"),
GIFT_RESULT("증정 이벤트: -%,d원"),
PROMOTION_PREVIEW("%d월 %d일에 우테코 식당에서 받을 이벤트 혜택 미리 보기!");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분을 public static final이 아닌 enum으로 하신 이유가 있을까요??
3주차 피드백에 연관성이 있는 상수는 static final 대신 enum을 활용한다 라고 나와있는데,
위의 부분은 단순한 포맷팅이라 enum을 쓴 이유가 궁금합니다!!
저도 원래는 enum으로 했다가 3주차 피드백을 보고 이 부분은 static final을 썼거든요,,,
과연 어느방식이 좋은 방법일까요?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제 친구 이넘이는

  • static final을 자동으로 보장합니다.
  • type-safety 합니다.

자세한 내용은 레퍼런스 한 번 읽어보셔도 도움이 될 듯 합니다!

Comment on lines +15 to +18
VisitDayController.responseVisitDay(visitDay);
OrderController.responseOrdersResult(orders);
OrderController.responseTotalOriginPriceResult(orders);
PromotionController.responseAppliedBenefitResult(visitDay, orders);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

하나의 controller에서 전부 처리를 하는게 아니라, controller를 나누셨군요!! 저도 나눠보겠습니다

Comment on lines +26 to +43
BadgePromotion defaultBadge = DEFAULT;
AppliedDiscountPromotions discountPromotions = AppliedDiscountPromotions.create(visitDay, orders, defaultBadge);
AppliedGiftPromotions giftPromotions = AppliedGiftPromotions.create(visitDay, orders, defaultBadge);
BadgePromotion badge = BadgeContext.applyPromotions(visitDay, discountPromotions, giftPromotions);

final int totalDiscountAmount = discountPromotions.getExpectedPayment(orders);
final int totalBenefit = giftPromotions.getTotalBenefit(discountPromotions);

DiscountResponse discountResponse = DiscountResponse.from(discountPromotions);
GiftResponse giftResponse = GiftResponse.from(giftPromotions);
BadgeResponse badgeResponse = BadgeResponse.from(badge);

responseGiftResult(giftResponse);
responseBenefitResult(discountResponse, giftResponse);
responseTotalBenefitResult(totalBenefit);
responseExpectPaymentResult(totalDiscountAmount);
responseBadgeResult(badgeResponse);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 15라인이 넘어가도 괜찮은건가요??

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[ERROR]

제가 설계했던 부분 중, 가장 고민하고, 고민하다 조금은 망가진 로직입니다 😢
실제 코드라인 수는 개행을 제외하고 15줄 이하이긴 하지만,
큰 리팩토링이 필요한 신호라는 사실은 변함이 없죠.

객체 자체의 설계가 강타입으로 구현된 경향이 있어,
이 부분은 조금 더 머리를 싸매고 뜯어봐야 할 것 같아요 ㅠ

Copy link

@Uniaut Uniaut left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다른 분들이 많이 코멘트 달아주셔서 남는 포인트 중 보이는 부분 달아봤습니다.
4주동안 고생하셨어요! 앞으로도 파이팅입니다!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promotion의 조건이 더 까다로워진다면 enum으로 정의하는 한줄짜리 조건은 괜찮은 구현방법일까요?

예시) 기존에 산타 뱃지를 받은 고객 중, 특별 할인 기간에 방문하여 메인 디쉬를 두 개 이상 주문한 고객에게 총 금액의 5% 할인 적용

너무 억지스러운 예시일지 모르겠지만, 조건이 조금만 복잡해져도 한 줄에 너무 코드가 길어질 것 같아요. 고민 해보면 좋을 것 같습니다.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enum 내부 함수형 인터페이스로 조건문을 분기하면 장단점이 명확하게 갈리는 것 같아요.

  • Enum 내부의 정적인 인자들로 동작하는 신규 프로모션 추가의 구현은 정말 간단합니다.
  • 특수한 조건으로 동작하는 신규 프로모션의 추가는, 새로운 구체 클래스의 구현이 불가피합니다.

전략패턴과 Enum-함수형 인터페이스의 선택에서, 저는 후자를 선택했고,
후자를 선택하면서 겪는 자연스러운 문제이지 않을까 싶습니다 😢
조금은 강타입으로 구현체(Enum)에 의존하면서 설계하다 보니
말씀주신 부분에 우려가 생긴다는 점 동의합니다!!

조금은 유연하지 못한 설계였다고 반성하며,
더 좋은 방법(최소비용 + 유연함)을 고민해보겠습니다 :)

private static final int POSITIVE_NUMBER_MINIMUM_RANGE = 1;
private static final String DELIMITER = ",";
private static final String HYPHEN = "-";
private static final Pattern REGEX_PATTERN = Pattern.compile("^[가-힣]+-\\d{1,20}$");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private static final Pattern REGEX_PATTERN = Pattern.compile("^[가-힣]+-\\d{1,20}$");
private static final Pattern REGEX_PATTERN = Pattern.compile("^[가-힣]+-\\d+$");

혹시 REGEX의 \d{1, 20}가 1~20의 정수를 받는다는 의도셨다면, 바로잡아드리고싶네요.
다음의 부분은 1에서 20개 사이의 digit 이라는 뜻입니다! 다른 메서드에서 Integer.parseInt 이후에 검증하는게 좋을 것 같아요.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이번 과제에서 정규표현식을 처음으로 사용해보았는데요..
사용 과정에서 조금 문제가 있었네요!

말씀주신 내용을 바탕으로, 다시 공부해보니
1~20자리의 숫자를 보장하는 정규표현식이라는 것을 확인하게 되었습니다!!
물론, 총 주문횟수에서 이를 검증하면서 오류가 발생하지는 않지만
개별 주문이 20개가 넘으면 에러를 던지려고 기존에 설계했던 로직 자체는
조금 의도하는 바와 다르게 동작했던 것 같습니다!!

날카롭고 예리한 리뷰 감사드립니다 :)

Copy link

@youngsu5582 youngsu5582 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해빈님 4주간 정말 수고하셨습니다!!🎄🎉🎉
졸업작품 마무리 때문에 리뷰를 너무 늦게 달아드렸습니다..
항상 정성스런 README 에 감탄하고 , 보일러 플레이트를 찾은거 같아서 행복했고
기존에 함수형 프로그래밍에 겉멋 과 가독성을 해친다는 인식이 있었는데 ,
코드를 보며 더 깔끔하다고 생각이 바뀐거 같아요!
너무 늦게 리뷰를 남겨 ,
쓸데없고 불필요한 부분만 리뷰 하고 감탄만 하고 가는거 같네요,, 감사했습니다!

public int countOrdersByMenuType(MenuCategory menuCategory) {
return menus.keySet()
.stream()
.filter(key -> key.isSameCategory(menuCategory))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 해당 부분은 , keySet 을 통해 , 순회하기 때문에

key 라는 변수명도 좋을거 같지만 , enumMap 을 사용하기 때문에
바로 알아차리기 쉽게 menu -> menu.isSameCategory 도 괜찮을 거 같아요!

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀주신대로 Enum의 Key에 집중하는 것이 아니라, menu와 같이 직관적인 이름이,
직관적인 코드리뷰에 도음이 될 것 같습니다 :)

package christmas.domain.consumer.constants;

public enum PlannerConstraint {
PROMOTION_YEAR(2_023),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

12월 까지는 생각했으나
다가오는 1월 이벤트 이니까 , 년도가 바뀌는데 이거 까지는 생각 못했네요!

);

private final LocalDate startDate;
private final LocalDate endDate;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맨날 , Date 만 쓰고 LocalDate 에 대해서는
자세히 안봤었는데 사용해서 매우 깔끔하게 코드를 짤 수 있네요!

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LocalDate 문법! 생각보다 편리한 문법이 많습니다!
꼭 사용해보세요 :)

Comment on lines +17 to +18
) {
DiscountContext promotionContext = DiscountContext.create(visitDay, orders, badge);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promotion 들을
공통적으로 어떻게 사용할 수 있을까 계속 생각했는데 ,
정말 깔끔하게 되네요... 해당 부분은 리팩토링 할때도 사용할 거 같습니다.👍👍

Comment on lines +46 to +47
return visitDay.getDayOfWeek().getValue() > THURSDAY_VALUE
&& visitDay.getDayOfWeek().getValue() < SUNDAY_VALUE;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 해당 부분은 어색함이 있는거 같습니다!
억지적인 부분이긴 하나 주말이
금요일 & 토요일 -> 토요일 & 일요일로 바뀔 여지가 있다고 생각하면
배열에 요소를 넣고 contains 로 비교하는 것도 괜찮을 거 같아요!

(visitDay, orders) -> true,
always -> true
),
WEEKDAY_PROMOTION_CONDITION(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이벤트 처리를 어떻게 하나 계속 생각했는데
처음에 날짜 처리
두번째 조건 처리
구분지어서 하니 되게 깔끔하게 되네요..!

BadgePromotion badge
) {
return hasApplicableTotalOriginPrice(orders)
&& applicableFunction.test(visitDay, orders)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분에서 test 메소드가 있길래 움찔 했는데 ,
해당 타입을 사용하기 위해선 어쩔수 없는 부분 이군요!
BiPredicate 에 대해서도 , 공부 해봐야 겠네요! 감사합니다!!

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apply() 메소드와 동일한 기능을 하는 메소드라고 생각하시면 이해하기 편할 것 같습니다 :)

import java.time.LocalDate;
import java.util.List;

public enum SpecialPromotionPeriod {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discount 와 Present 로만 나눌 생각을 했는데 ,
종류별로 나눌 수 있는 방법도 괜찮네요!

@h-beeen
Copy link
Owner Author

h-beeen commented Nov 19, 2023

Reference Memo

Enum 사용 간 HashMap 캐싱 @june-777

Copy link

@june-777 june-777 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 코드 잘 봤습니다.
리뷰가 이미 많이 있어서, 안 된 부분 위주로 살펴봤네요.
LocalDate의 활용, 프로모션 조건 자체를 래핑하는 점 등등 많이 배우고 갑니다 :)

우테코 4 주간 정말 고생 많으셨습니다 😊

Comment on lines +12 to +18
VisitDay visitDay = VisitDayController.requestVisitDay();
Orders orders = OrderController.requestOrders();

VisitDayController.responseVisitDay(visitDay);
OrderController.responseOrdersResult(orders);
OrderController.responseTotalOriginPriceResult(orders);
PromotionController.responseAppliedBenefitResult(visitDay, orders);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아래의 예시와 같이 동일한 depth에 위치한 컨트롤러 간 호출은 안티패턴이 맞다고 생각합니다.
여기서의 동일한 depth는 비즈니스에 맞는 적절한 Service / Domain Layer를 호출하는 컨트롤러간의 호출로 보시면 될 것 같습니다.

View <- OrderController -> XXService
                    |
                    V
View <- PromotionController -> XXService

하지만 Controller를 View에게 응답결과를 적절히 전달, Service / Domain을 적절히 호출 하는 역할로 분리하여 아래와 같은 그림이 된다면 이야기는 조금 달라진다고 생각합니다.

View <- FrontController -> XXXController -> XXService
                                         -> XXXController -> XX

여기서 FrontController는 마치 스프링의 서블릿 디스패처와 같은 결이라 보면 되지 않을까 싶어요. (매우 매우 다르지만)
그리고 이러한 구조의 장점은 XXXController는 적절한 Service를 호출하여 응답 결과를 FrontController에게 내려주는 역할을 수행하기 때문에 Service Layer 전반에 걸친 테스트(Layer Test? 있는 말인진 모르겠네요)를 하기 굉장히 수월하다 느껴졌습니다.

이 부분에 대해서 @h-beeen 님의 생각을 여쭙고 싶습니다 :)

Comment on lines +19 to +22
public static Orders requestOrders() {
OrderOutputWriter.printMessageResponse(REQUEST_MENU_ORDERS);
return ExceptionHandler.retryOnBusinessException(OrderController::createMenuOrdersFromInput);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 현재 미션에선 크게 상관은 없다고 생각하지만, 있으면 가독성은 더 좋아질 것이라는 생각은 듭니다.

해당 미션을 진행하며, 인스턴스 필드의 존재 여부이 객체가 어떤 객체들과 협력 관계에 놓여 있구나를 굉장히 직관적으로 보여줄 수 있는 장점이 있다고 느껴졌습니다.

해빈님의 OrderController 코드를 예시로 들면,

public class OrderController {
    private final OrderOutputWriter;

    // something
}

인스턴스 필드만으로, OrderController가 도메인 결과값을 OrderOuputWriter에게 렌더링하겠구나를 쉽게 파악할 수 있다고 생각합니다. static으로 클래스 레벨에서 참조하나, 인스턴스 필드로 참조하나 모두 강한 결합상태이기에, 협력 관계에 놓여 있는 객체들이라면 인스턴스 필드로 두는게 낫지 않을까? 라는 생각입니다. 이 부분에 대해서 해빈님 생각을 여쭙고 싶습니다 😀

ErrorCode(String message) {
this.message = message;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BooleanSupplier 라는 것도 있군요! 배워갑니다 👍

해빈님의 domain 쪽 validate 로직을 보면, ErrorCode의 validate 메서드를 람다로 호출하는 것으로 보입니다. 확실히 코드 재사용성 측면에서 장점이 있는 것 같습니다 :)

하지만 domain쪽 검증 로직을 exception 패키지에서 throw하는게 좋은 구조인가? 에 대한 생각도 드는 것 같아요! 약간 어색한 느낌도 들긴 합니다. 하지만 throw를 한 곳에서 관리하는 구조도 설명은 못하겠지만 괜찮은 구조인 것 같습니다.

좋다/나쁘다 단정 짓는� 느낌보단 순수한 궁금증이라 명확히 딱 말씀을 못드리겠네요 ㅎㅎ.. 그냥 코드 재사용성 이외에 어떤 장점을 염두해 두시고 구현하신건지 여쭙고 싶습니다 :)

Copy link
Owner Author

@h-beeen h-beeen Nov 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분도 사실, 4주차 과제에서 꼭 리뷰를 받아보고 싶어 시험 삼아 설계해본 로직입니다.

ErrorCode의 로직의 책임과 관심사를 어디까지 보느냐에 따라 다를 것 같은데,
사용해본 결과, 로직을 직관적으로 줄여주고, throw 문을 담당하는 예외처리 메소드를 극한으로 줄일 수 있는 장점이 있지만,
ErrorCode의 책임과 권한이 전역에 굉장히 크게 퍼져있다는 생각도 들었어요!

물론 말씀주신 것 처럼 코드 재사용성을 염두해두고 작성했던 구조인 것은 사실입니다!

저도 좋은 구조라고 단정을 짓지는 못하곘지만, 인상적인 구조라고 생각합니다.
다만! 최종 코딩테스트 환경이라면, 사용을 지양할 것 같아요!

this.name = name;
this.price = price;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HashMap 캐싱과 관련한 코멘트가 있어서 코드만 추가로 첨부드리면

public enum Menu {
    // 열거형 상수들 ..

    private final Map<String, Menu> cachedMenu = new HashMap<>();
    static {
        for (Menu menu : values()) {
            cachedMenu.put(menu.getName(), menu);
        }
    }

Key: String에 대한 NPE 우려가 있으니,

    public static Menu findMenuByName(String name) {
        return Optional.ofNullable(cachedMenu.get(name)).stream()
                .filter(menu -> menu.isSameName(name))
                .findFirst()
                .orElseThrow(() -> BusinessException.from(INVALID_ORDER));
    }

해빈님 코드에선 이런식으로 바꿔볼 수 있을 것 같습니다!


이건 오늘 들었던 고민인데 위의 코드와 관련된 고민이라 해빈님 생각도 여쭙고 싶네요!
위의 코드 처럼 캐싱을 도입하면 Optional은 따라올 수밖에 없는 구조라고 생각합니다.
이 때, findMenuByName 메서드에서 String name에 대한 메뉴가 없을 경우

  1. Optional.empty() 반환
  2. 예외 throw

어떤 구조가 더 좋다고 생각되시나요? 명확한 기준이 안서, 의견을 여쭙고 싶습니다.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 @june-777 님의 질문에 대한 다른분들의 의견이 궁금합니다!
저는 find 라는 역할에 맞게 해당하지 않는 메뉴가 없다면 '없다' 라는 의미의 Optional.empty()를 반환하는 방향으로 설계했는데 다른 분들의 의견이 궁금합니다!

Copy link
Owner Author

@h-beeen h-beeen Nov 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사실상 지금 Menu의 기능을 확장해서 판단하자면 MenuRepository 의 기능을 간접적으로 수행하고 있다고 생각합니다.
JPA에서 예외처리를 작성하는 것 처럼, 예외를 던지는 것이 훨씬 더 안정적인 설계라고 생각합니다.

    return sampleRepository.findById(id)
                           .orElseThrow(IllegalArgumentException::new);

그렇지 않다면 하위 비즈니스 로직에서 Null과 empty에 대한 제약을 항상 강제적으로 갖춰야 할 것 같아요.
휴먼 에러에 따른 NPE 이슈도 간과할 수 없구요!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@june-777 님께서 남겨주신 의견에 대한 생각을 짧게나마 남겨보자면..!!

이번 미션에서 주어진 요구사항에서는 메뉴가 존재하지 않는다면 예외를 발생하는 사항이 있었기 때문에,
h-beeen님께서 말씀하신 것처럼 .orElseThrow()와 같이 값이 존재하지 않는다면 예외를 발생시키는 것이 맞는 것 같습니다.
.orElseThrow() 자체가 Optional을 검사해서 값이 없는 경우에만 예외를 발생시키기 때문에,
Optional을 직접 반환받지 않고도 안정적으로 구현할 수 있다고 생각합니다!
(Optional을 반환받은 쪽에서도 if문을 통해 한번 더 확인하고, 예외를 던져야하는 번거로움이 생길수도 있구요)

하지만 만약 요구사항에 메뉴가 존재하지 않는 경우, 메뉴판을 준다와 같이 추가적인 처리가 필요한 경우에는
Optional.empty()를 반환하고, 추가적인 처리를 하는 것이 더 안정적일 것 같아요

결론적으로 요구사항에 맞춰서 추가적인 처리가 필요할 때에는 Optional을 그렇지 않을 때에는 기본적으로 .orElseThrow를 사용하는 편인 것 같습니다! ☺️

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@h-beeen @SSung023 여러 의견 남겨주셔서 감사합니다!

결론적으로 요구사항에 맞춰서 추가적인 처리가 필요할 때에는 Optional을 그렇지 않을 때에는 기본적으로 .orElseThrow를 사용하는 편인 것 같습니다! ☺️

SSung님 말씀처럼 이렇게 딱 정리할 수 있을 것 같아요! 두 분 덕분에 기준을 세울 수 있게 되었네요ㅎㅎ 인사이트 얻고 갑니다 😊


import static christmas.domain.consumer.constants.PlannerConstraint.PROMOTION_MONTH;
import static christmas.domain.consumer.constants.PlannerConstraint.PROMOTION_YEAR;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 VisitDay의 인스턴스 필드를 int로 두다보니 사실상 DTO처럼 밖에 사용이 안되었는데,
LocalDate를 활용 하신점, 그리고 래핑해서 사용하신 점이 정말 좋은 것 같습니다. 배워갑니다! 👍

import static christmas.domain.consumer.constants.PlannerConstraint.MINIMUM_APPLICABLE_PURCHASE_TOTAL_PRICE;
import static christmas.domain.promotion.constants.PromotionPeriod.MONTHLY_DECEMBER;
import static christmas.domain.promotion.constants.PromotionPeriod.UNTIL_CHRISTMAS;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프로모션 조건 자체를 캡슐화할 생각은 전혀 못했는데 굉장히 신박한 것 같습니다.. 👍

  • 조건 자체를 Enum으로 캡슐화해서 관리하면 어떤 이점이 있을 것으로 판단하셨는지 여쭙고 싶어요!

  • 그리고 PromotionCondition할인 프로모션에 대한 조건, 배지 프로모션에 대한 조건을 모두 담고 있는데, 두 조건의 변경 사이클은 충분히 달라질 수도 있을 것 같아요. 그런 측면에서 둘을 분리하여 각각 discount, gift 패키지에 위치해 두는 것은 어떨까요?

  • 그리고 Orders를 참조함으로 인해 consumer <-> promotion 패키지 간 순환 참조가 발생하는 것으로 보입니다. 이 부분에 대한 해결책을 드리고 싶지만 저도 마땅히 떠오르진 않는데,, 어떤 방법이 있을까요? 🤔
    Enum에서 Domain 객체를 의존해도 되는가?에 대해 디코 토론방에 글을 올렸는데, 한 번 슥 보고 오셔도 좋을 것 같습니다. 이야기 나누고 싶네요 :)

import christmas.domain.consumer.VisitDay;

import java.time.LocalDate;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

날짜를 하드코딩했던 저로썬 LocalDate을 활용하신게 너무나도 훌륭한 것 같습니다 :)

return Arrays.stream(BadgePromotion.values())
.filter(promotion -> promotion.isPromotionPeriod(visitDay))
.filter(promotion -> promotion.isApplicable(totalBenefit))
.findFirst()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 해빈님 의견에 동의합니다.
열거형 상수들의 순서에 의존적인 비즈니스 로직은 위험한 요소라 생각됩니다!

열거형 상수들은 해당 Enum 타입의 static final 인스턴스 라 볼 수 있는데, 마치 인스턴스 필드 배치 순서에 따라 public API의 결과물이 달라지는 것과 동일하다고 생각됩니다 :)

이펙티브 자바 서적에 Enum ordinal 관련 내용이 있어서 참고해보시면 좋을 것 같습니다 :)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

배지에 대한 조건을 Predicate 을 활용하신게 정말 좋은 전략 같습니다 👍

배지 없음을 나타내기 위한 수단으로 Optional도 고려해보시면 좋을 것 같습니다. DEFAULT로 없음을 나타내는 전략도 훌륭하다 생각합니다:)

import static christmas.domain.promotion.badge.BadgePromotion.DEFAULT;
import static christmas.view.constants.ResponseFormat.PRICE_RESULT;
import static christmas.view.constants.ResponseMessage.*;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PromotionController 전반적인 리뷰입니다!

아무래도 Promotion에 대한 모든 내용을 처리하다보니 필연적으로 메서드가 길어진 것 같습니다. 하지만 Promotion도 할인 이벤트 프로모션, 증정 이벤트 프로모션, 배지 프로모션으로 나뉘게 되니, 각각에 대한 Service Layer를 구상하신다면 PromotionController가 한층 얇아지는데 도움이 될 것 같습니다 🤔

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

점점 MVC 3계층이 MVC 5계층으로 변하게 된 이유를 실감하는 것 같아요!
비즈니스 로직이 복잡해지고, 이를 도메인에 모두 담으려고 하니, 서비스 로직의 책임까지 지면서
관리해야할 인자도 늘어나고, 전체적으로 강한 결합을 보이는 것 같아요!
Service Layer로 분리하면서 설계하는 방향으로 리팩토링 해봐야겠어요!!

좋은 리뷰 감사드립니다 :)

Copy link

@JaeHongDev JaeHongDev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요 혜빈님!
이미 많은 분들께서 좋은 리뷰를 남겨주셔서 제가 도움 드릴 내용이 없는 것 같습니다 :)
4주 차 미션 고생 많으셨습니다 ㅎㅎ

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요 혜빈님!
리뷰를 남기려고 코드를 내려받고 리뷰할 내용을 찾아서 정리했지만 이미 다른 리뷰어분들께서 이미 말씀해주셨네요 :)

어쩔 수 없이 남은 픽스처 부분만 리뷰 남기게 된 점 양해 부탁드립니다 ㅎㅎ

먼저 테스트 대상 시스템 (System Under Test, 이하 SUT) 를 실행하기 위해 해줘야 하는 모든 것을 테스트 픽스처라고 부릅니다.

테스트 픽스처가 등장하게 된 계기는 매 테스트마다 SUT을 생성할 때 중복되는 생성 코드가 발생하는데
이는 너무 많은 사전 준비가 필요하기 때문에 중복을 줄이기 위해서로 알고 있습니다.

보통 테스트 클래스내부에서 지역변수로 만들어서 setup메서드를 통해 초기화하는 방법을 사용합니다.
이방법은 모든 테스트가 setup에 강하게 의존하고 있기 때문에 setup의 코드를 수정하면 다른 테스트가 실패할 수 있기 때문에 권장하지 않습니다.

두 번째 방법은 테스트 클래스 내부에서 private 메서드 형태로 테스트 픽스처를 생성할 수 있는 형태로 제공하는 방법입니다.
이전에 설명드렸던 사용자 생성 예시를 떠올리시면 될 것 같습니다 :)

혜빈님께서 사용하신 방법은 두번째 방법과 유사하지만 조금 다른점이 있습니다.
팩토리 형태로 제공하는 것과 달리 테스트 하는 대상 클래스를 미리 생성한 방법입니다.
이는 테스트 메서드에서 어떤 값을 테스트하고 있는지 확인하기 위해서 직접 테스트 픽스처 클래스 코드를 직접 봐야하기 때문입니다.

아마 예시로 보시면 조금 더 쉽게 이해할 수 있으리라 생각합니다!
도움이 될 만한 참고 자료를 몇개 가져왔습니다 :)

아래는 우테코 지원 페이지에서 사용하는 테스트 픽스처
https://github.com/woowacourse/service-apply/blob/master/src/test/kotlin/apply/UserFixtures.kt

개인적으로 테스트 관련해서 가장 인상 깊게 봤던 책 (91p 참고)
https://www.yes24.com/Product/Goods/104084175

테스트 픽스처와는 관련 없지만 도움이 될 것 같아서 가져왔습니다
https://enterprisecraftsmanship.com/posts/dry-damp-unit-tests

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixture 설계를 꼭 적용해, 우테코에서 추구하는 간결한 테스트코드를 작성하고자 했던 소망이 있기에,
꼭 깊이있게 학습하고 연구해서 실제 프로덕트 테스트 코드에 녹여내고 싶었습니다 :)

이 부분에 대해서 좋은 레퍼런스를 많이 참고해주셔서 진심으로 감사드립니다!!
코틀린 코드여서, 다소 읽기에는 어려움이 있지만, 직관적으로 어떤 설계다! 라는 부분은 캐치가 가능했습니다!!

두 번째 방법은 테스트 클래스 내부에서 private 메서드 형태로 테스트 픽스처를 생성할 수 있는 형태로 제공하는 방법입니다. 이전에 설명드렸던 사용자 생성 예시를 떠올리시면 될 것 같습니다 :)

재홍님께서 남겨주신 부분을 참고해서 공부해보고,
혹시 궁금한 점이 생기면 다시 리뷰 남기겠습니다!!
깊이있는 리뷰 감사드립니다 :)

4주간 정말 감사했어요!!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

주문이라는 도메인 객체 내에서 다양한 로직들을 처리함에도 일관성이 있는 모습이 좋은 것 같습니다!

Copy link

@ldhapple ldhapple left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

바빠서 리뷰를 늦게 달았더니 이미 많은 리뷰가 달려 새로 할 리뷰가 지금의 제 실력에서는 마땅히 보이지 않네요 ㅠㅠ
달린 리뷰들을 읽어보니 저에게도 도움되는 리뷰들이 있어 많이 도움이 되었습니다. 여러 고민을 해보게 되네요.

마지막 미션까지 수고하셨습니다!

Comment on lines +12 to +18
VisitDay visitDay = VisitDayController.requestVisitDay();
Orders orders = OrderController.requestOrders();

VisitDayController.responseVisitDay(visitDay);
OrderController.responseOrdersResult(orders);
OrderController.responseTotalOriginPriceResult(orders);
PromotionController.responseAppliedBenefitResult(visitDay, orders);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 이번 미션을 하면서 Controller를 분리했었는데, 코드를 다시 보니 Controller의 분리 자체를 먼저 고민했어야 했더라구요.

물론 현재 미션의 구조에서는 네이밍 정도의 차이이겠지만, 컨트롤러가 컨트롤러를 가지는 패턴에 대해 고민하기 전 컨트롤러 자체의 역할에 대해 고민해보시면 좋을 것 같습니다.

제 경우는 컨트롤러가 아닌 서비스에 속했어야 됐다는 판단을 하게되었었거든요.

물론 컨트롤러가 이렇게 분리되어야 한다는 결론이 나온다면 현재 방법도 나쁘지 않다고 생각합니다!

Comment on lines +27 to +32
AppliedDiscountPromotions discountPromotions = AppliedDiscountPromotions.create(visitDay, orders, defaultBadge);
AppliedGiftPromotions giftPromotions = AppliedGiftPromotions.create(visitDay, orders, defaultBadge);
BadgePromotion badge = BadgeContext.applyPromotions(visitDay, discountPromotions, giftPromotions);

final int totalDiscountAmount = discountPromotions.getExpectedPayment(orders);
final int totalBenefit = giftPromotions.getTotalBenefit(discountPromotions);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이벤트 결과의 저장에 대해서 저도 고민해봤었고, 실제로 기능을 추가하기도 했었습니다. 다만 제 결론은 미션은 이벤트 플래너로 예상 방문일과, 예상 주문에 따른 혜택을 미리 보여주는 것이라고 이해했기 때문에 실제 배지를 제공하는 부분은 일어나서는 안된다고 생각해 기능을 제거했습니다. 실제 방문일에 주문하는 메뉴가 달라지는 등의 상황이 있을 수 있기 때문입니다.

물론 이 부분은 다양한 해석으로 바라볼 수 있을 것 같습니다.

이 로직이 컨트롤러가 아닌 서비스에 존재해야 하는 것에 대해서는 위에서도 남겼듯 이 부분은 컨트롤러에 알맞은 로직인가에 대한 고민이 한 번 더 이루어져야 할 것 같다고 생각합니다.

public class Orders {
private static final int ORDERS_MAXIMUM_RANGE = 20;

private final EnumMap<Menu, Integer> menus;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 Orders에서 Map을 사용하도록 구현했는데 코드리뷰를 받고 다시 생각해보니 Order 클래스를 따로 만들고 Order 클래스들을 담는 일급 컬렉션의 개념으로 Orders를 사용하는 것이 더 낫다는 판단을 하게 되었습니다.

물론 Map을 사용해 Orders를 구현하는 방식도 장점이 있지만 Order 클래스를 따로 만들었을 때의 장점도 생각해보시면 좋을 것 같아요!

Copy link

@NaMooJoon NaMooJoon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2주차때 @h-beeen 님 코드를 보며, 클론 코딩하면서 많이 배웠습니다.
그때나 지금이나 h-been님 코드에 리뷰 달 실력은 안되지만, 몇 가지 달아봤습니다!

마지막 과제 수고하셨고, 꼭 우테코 합격하셨으면 좋겠습니다 :)

Comment on lines +19 to +22
public static Orders requestOrders() {
OrderOutputWriter.printMessageResponse(REQUEST_MENU_ORDERS);
return ExceptionHandler.retryOnBusinessException(OrderController::createMenuOrdersFromInput);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 의견을 남겨도 괜찮을까요?!
저도 과제 중간에 static을 사용해서 controller와 view를 관리했었는데요!

다음 글을 읽고 이번에는 조금 static을 지양하려고 했었습니다.
물론 static의 사용 목적이 이번 과제로만 국한 했을 때 얘기가 달라질 수도 있지만, 참고할 만한 내용이라 생각해서 공유합니다!
https://unabated.tistory.com/1041

@h-beeen님 의견은 어떠신가요?


import static christmas.exception.ErrorCode.INVALID_DATE;

public class ExceptionHandler {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예외처리 방식에 대해서 많이 배워갑니다.
감사합니다

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

덕분에 record라는 개념에 대해 새롭게 알게 되었습니다.
dto에 깔끔하게 적용하셔서 정말 좋은 것 같습니다.

this.menus = menus;
}

//== Static Factory Method ==//

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정적 팩토리 메서드 네이밍 컨벤션에 관하여 질문드립니다!

정적 팩토리 메서드 이름을 create로 지으셨는데, 저는 아래 첨부드린 사이트에서 네이밍 컨벤션� 상 여러개의 매개 변수를 받아서 객체를 생성할 땐 of를 사용해야 한다고 판단했습니다.

혹시 제가 잘 못 이해한 부분이 있을 지 여쭙고 싶습니다!

참고

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 정적 팩토리 메서드 네이밍에 대해 고민한 적이 있는데요..!
@NaMooJoon 님 말씀대로, 여러 개의 매개 변수를 받아서 객체를 생성할 때에 of를 사용하는 것을 권장하고 있지만,
create같은 경우, 항상 새로운 인스턴스를 생성할 때 사용할 수 있으므로 create의 사용도 괜찮을 것 같습니다! ☺️

@jexnjeux
Copy link

많은 분들과 함께한 리뷰를 통해서 정말 많이 배웠습니다. 방법에 대해 여러 의견을 주고받으면서 더 나은 코드를 고민하는 모습이 인상적입니다. 덕분에 좋은 코드 뿐만 아니라 리뷰의 긍정적 효과 또한 배웠습니다! 4주간 고생 많으셨습니다!

Copy link

@hwan2-99 hwan2-99 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프리코스 기간동안 고생하셨습니다!!
너무 많은분들이 양질의 질문과 해빈님의 퀄리티 높은 답변덕분에 리뷰를 남기러와서도 배우고갑니다 :)
매주 남기고싶었지만 리뷰마감덕에 참여를 못했지만 이번에 참가하게 되었어요 정말 고생많으셨고 좋은 결과 있으시길 바랄게요

Comment on lines +33 to +35
OrderOutputWriter.printNewLine();
OrderOutputWriter.printMessageResponse(RESPONSE_MENU_ORDERS_RESULT);
OrderOutputWriter.printMenuOrdersResponse(orderResponse);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정말 어려운 트레이드 오프인 것 같네요 레페런스 참고해서 학습해보겠습니다!!

entry -> entry.getKey().getName(),
Entry::getValue)
);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

중복처리를 이렇게하면 되게 간단하게 처리할 수 있군요... entrySet이라니... 잘 배워 갑니다!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헉..! entrySet()이라는게 있었군요...! 매주 코드 리뷰를 하면서 몰랐던 api들을 많이 배워갑니다..!! 👍

"없음",
ALWAYS,
always -> true
);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enum안에서도 이런 로직을 작성할 수 있다는점 배워갑니다!

Copy link

@ffolabear ffolabear left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4주차도 고생하셨습니다! 코드도 코드지만 여러 리뷰어분들이 계셔서 더 의미있는 리뷰였던것 같습니다 👏

Comment on lines +16 to +17
private OrderController() {
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런 식의 방법도 있군요 😮 방법에 정답은 없기에 고려해볼만한 방법인 것 같습니다.

Comment on lines +60 to +64
private static void responseTotalBenefitResult(final int totalBenefit) {
PromotionOutputWriter.printNewLine();
PromotionOutputWriter.printMessageResponse(RESPONSE_TOTAL_BENEFIT_RESULT);
PromotionOutputWriter.printTotalBenefitResponse(totalBenefit);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 개인적으로 하나로 묶는 방법이 더 낫다고 생각합니다. 해당 메소드를 비롯한 다른 형식의 출력 메소드는 요구사항대로 출력하기 위한 기능들을 묶어서 호출하는 것으로 보입니다.

이 방법은 반복되는 메소드 호출 작업을 줄인다는 장점도 있지만 요구사항을 만족하기 위한 출력을 통일시키고 강제하는 효과도 있다고 생각합니다. 마치 로또 과제의 Lotto 객체의 생성자 안에서 검증 메소드를 넣어서 생성시점에 유효성 체크를 강제하는 것 처럼 말이죠.

더 나아가서 협업을 할때에도 요구사항을 전혀 몰라도 이 메서드 호출을 통해 요구사항을 만족하는 올바른 출력기능을 사용할 수 있기 때문에 더 나은 방법이라고 생각합니다.

좋은 유지보수인가 라는 관점에서도 기존의 출력 방식과 다른 출력을 원할때에도 새로운 메소드를 만들어서 호출하는 부분만 바꾸면 되니까 적절한 방법이지 않을까요? 만약 출력하는 방식이 여러개라서 이런식으로 만들어야하는 메소드가 많아 진다면 그건 이미 묶는것의 의미가 없으니 논외라고 생각합니다.

Copy link

@leemiyeonn leemiyeonn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요! 리드미는 항상 화려하고, 코드들, 특히 테스트 코드를 꼼꼼하게 구현하신 점에서 많이 배워갑니다!
마지막주차 답게 의논할 점이 많은 재미있는 코드리뷰였습니다! 4주차까지 정말 고생많으셨습니다!

Comment on lines +12 to +18
VisitDay visitDay = VisitDayController.requestVisitDay();
Orders orders = OrderController.requestOrders();

VisitDayController.responseVisitDay(visitDay);
OrderController.responseOrdersResult(orders);
OrderController.responseTotalOriginPriceResult(orders);
PromotionController.responseAppliedBenefitResult(visitDay, orders);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@h-beeen 은 각각 역할을 다르게 하는 컨트롤러 이기 때문에 분리하신것으로 이해됩니다! 제 생각엔 '각 컨트롤러가 역할이 명확해지고 조금 더 간결해진다'가 분리 함으로써 얻을 수 있는 이점이라고 생각하는데, 이 외에 더 얻을 수 있는 이점이 있을까요?

Comment on lines +60 to +64
private static void responseTotalBenefitResult(final int totalBenefit) {
PromotionOutputWriter.printNewLine();
PromotionOutputWriter.printMessageResponse(RESPONSE_TOTAL_BENEFIT_RESULT);
PromotionOutputWriter.printTotalBenefitResponse(totalBenefit);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정답이 없는 문제라지만, 유지보수성, 책임관계, 타인과의 협업 등 신경쓸게 많은 문제네요! 저는 타인과의 협업, 코드의 가독성을 높이기 위해 하나의 메소드로 묶는것을 선호하는데, 다른분들은 어느 곳에 중점을 두시는지 궁금해지는 문제네요! ㅎㅎ

this.name = name;
this.price = price;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 @june-777 님의 질문에 대한 다른분들의 의견이 궁금합니다!
저는 find 라는 역할에 맞게 해당하지 않는 메뉴가 없다면 '없다' 라는 의미의 Optional.empty()를 반환하는 방향으로 설계했는데 다른 분들의 의견이 궁금합니다!

Copy link

@eora21 eora21 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고민이 보이는 깔끔한 코드와 좋은 리뷰들 덕분에 오히려 배워만 가는 것 같아 죄송하네요.. ㅠ
4주동안 정말 고생 많으셨습니다! 열정 얻어갑니다!!

Comment on lines +27 to +32
AppliedDiscountPromotions discountPromotions = AppliedDiscountPromotions.create(visitDay, orders, defaultBadge);
AppliedGiftPromotions giftPromotions = AppliedGiftPromotions.create(visitDay, orders, defaultBadge);
BadgePromotion badge = BadgeContext.applyPromotions(visitDay, discountPromotions, giftPromotions);

final int totalDiscountAmount = discountPromotions.getExpectedPayment(orders);
final int totalBenefit = giftPromotions.getTotalBenefit(discountPromotions);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 결과 저장에 대해서는 '미리 보여준다'에 의거하여 미구현으로 남겨놓았어요!
배지 제공을 하기 위해선 '누가 언제 어떠한 주문을 통해 해당 배지를 받았다'라는 기록을 작성해야 할텐데 이는 실제 결제 시스템을 통해야 한다고 생각했거든요.
서비스에 해당 로직이 존재해야 한다는 의견에는 동의합니다!

Comment on lines +45 to +49
private static void responseGiftResult(GiftResponse giftResponse) {
PromotionOutputWriter.printNewLine();
PromotionOutputWriter.printMessageResponse(RESPONSE_GIFT_RESULT);
PromotionOutputWriter.printGiftQuantityResponse(giftResponse);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 출력 포맷과 방법은 View에 책임이 있다고 생각했고, 컨트롤러에서는 데이터만 넘기는 방향으로 설계했습니다!
말씀하신대로 출력 방법이 단순 콘솔에서 터치스크린 등으로 변경될 경우 출력 메시지 형태는 바뀔 수 밖에 없다고 판단했어요.
따라서 인터페이스를 통해 특정 데이터 출력을 지정하고, 콘솔 뷰에서 보여줄 메시지 및 데이터 형식에 맞는 출력문구를 작성했습니다!

DESSERT,
BEVERAGE;

MenuCategory() {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

빈 생성자를 default로 설정하신 이유가 궁금합니다!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엇! Enum의 생성자는 기본적으로 private로 생성되기 때문에, 멤버가 없을 경우에는 생성자가 없어도 될 것 같은데
빈 생성자를 명시하신 이유가 궁금합니다!

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

원래 생성자 내부 로직이 있었는데, 리팩토링 과정에서 제거하지 않은 로직이에요!
섬세한 리뷰 감사합니다 :)

private boolean hasOnlyBeverages(EnumMap<Menu, Integer> menus) {
return menus.keySet()
.stream()
.allMatch(this::isBeverage);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

와 저도 생각 못하고 필터 사용했었는데.. 정말 깔끔하게 구현이 가능한 부분이었군요!!

@DisplayName("[OrderController] - Controller Layer")
class OrderControllerTest {

@Nested
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Nested가 어떤 역할을 하는 지 배울 수 있었습니다! 감사해요!

Copy link

@wooteco-daram wooteco-daram left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요, 해빈님! 세 번째 리뷰입니다.
이번에는 SonarLint 플러그인을 이용해 코드를 분석했습니다.
플러그인 설치 방법은 디스코드에 올렸어요.
한 번 설치해보시는 걸 추천드려요!
링크 : https://discord.com/channels/1149138870433230900/1179345425791205437/1179345425791205437

private static final int THURSDAY_VALUE = 4;
private static final int SUNDAY_VALUE = 7;

private final LocalDate visitDay;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔고민🤔

SonarLint 에 따르면, 필드명은 클래스명과 중복되지 않는게 좋다고 합니다.
클래스명과 같다면 혼란을 줄 수 있다고 합니다.
조금 더 설명할 수 있는 이름으로 변경하는 걸 권장합니다.

Bad Practice

public class Foo {
  private String foo;

  public String getFoo() { }
}

Foo foo = new Foo();
foo.getFoo() // what does this return?

Best Practice

public class Foo {
  private String name;

  public String getName() { }
}

//...

Foo foo = new Foo();
foo.getName()
Suggested change
private final LocalDate visitDay;
private final LocalDate visitDate;

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분에 대해서도 인지하고 있었지만, 고민이 많았던 로직이에요.
특히나, 일급 컬렉션 객체의 변수명을 짓는 과정은 꽤나 많은 고민을 하게 만들었어요.
여전히 변수명을 짓는데 오랜 시간을 할애하고, 고민하지만, 직관적인 변수명을 작명하는데는
어려움이 많은 것 같아요 :(

소나린트도 사실 깔려는 있지만, 이번 과제에서 많이 활용하지는 않았던 것 같아요...!
린트를 준수하면서 조금 더 좋은 코드 작성을 하도록 노력해볼게요 :)

Comment on lines +37 to +40
public EnumMap<DiscountPromotion, Integer> applyPromotions(
VisitDay visitDay,
Orders orders
) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔고민🤔

SonarLint 에 따르면, 구체적인 구현체인 EnumMap를 반환하는 것 보단 인터페이스인 Map 을 사용하는 걸 권장한다고 합니다. EnumMap 을 반환하고 외부에서 계속 쓰는 것 보다 Map 을 사용하는 건 어떨까요? 구현체를 외부로 노출해선 안된다고 주장합니다.

Java Collections API는 수집 구현 세부 정보를 숨기도록 설계된 인터페이스의 잘 구성된 계층 구조를 제공합니다.
목록, 집합, 지도 등 다양한 수집 데이터 구조의 경우 특정 인터페이스(java.util.List, java.util.Set, java.util.Map)가 필수 기능을 포함합니다.
컬렉션을 메서드 매개 변수, 반환 값으로 전달하거나 필드를 노출할 때는 구현 클래스 대신 이러한 인터페이스를 사용하는 것이 일반적으로 권장됩니다.
java.util.LinkedList, java.util.ArrayList 및 java.util.HasMap과 같은 구현 클래스는 컬렉션 인스턴스화에만 사용해야 합니다.
이러한 구조의 성능 특성을 보다 세밀하게 제어할 수 있으며 개발자는 사용 사례에 따라 선택합니다.
예를 들어 빠른 랜덤 요소 접근이 필수적이라면 java.util.ArrayList를 인스턴스화해야 합니다.
만약 임의의 위치에 요소를 삽입하는 것이 중요하다면 java.util.LinkedList를 선호해야 합니다.
하지만 이것은 API가 노출해서는 안 되는 구현 정보입니다.
Suggested change
public EnumMap<DiscountPromotion, Integer> applyPromotions(
VisitDay visitDay,
Orders orders
) {
public Map<DiscountPromotion, Integer> applyPromotions(
VisitDay visitDay,
Orders orders
) {

// when
int totalDiscountAmount = discountPromotions.getTotalDiscountAmount();
// then
assertEquals(totalDiscountAmount, 6_446);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔고민🤔

assertEquals 의 시그니처는 다음과 같습니다.
assertEquals(int expected, int actual)

현재 코드에서 expected 의 역할과 actual 의 역할이 서로 뒤바뀐 것 같습니다.
두 개의 파라미터를 서로 뒤 바꿔보는 건 어떨까요?

테스트 결과에는 문제가 없지만, 테스트 실패 오류 메시지가 잘못 표기될 수도 있습니다.

The standard assertions library methods such as org.junit.Assert.assertEquals, and org.junit.Assert.assertSame expect the first argument to be the expected value and the second argument to be the actual value. For AssertJ instead, the argument of org.assertj.core.api.Assertions.assertThat is the actual value, and the subsequent calls contain the expected values.
What is the potential impact?
Having the expected value and the actual value in the wrong order will not alter the outcome of tests, (succeed/fail when it should) but the error messages will contain misleading information.
This rule raises an issue when the actual argument to an assertions library method is a hard-coded value and the expected argument is not.

@h-beeen h-beeen changed the title [피날레] 리드미는 회고록이다 [크리스마스 프로모션] 변해빈 과제 제출합니다. Nov 30, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.