ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -0,0 +1,64 @@
+package store.domain;
+
+import camp.nextstep.edu.missionutils.DateTimes;
+import java.time.LocalDate;
+import store.dto.PromotionDetailDto;
+
+public class Promotion {
+
+    private final String name;
+    private final int buy;
+    private final int get;
+    private final LocalDate startDate;
+    private final LocalDate endDate;
+
+    public Promotion(final String name, final int buy, final int get, final LocalDate startDate,
+                     final LocalDate endDate) {
+        this.name = name;
+        this.buy = buy;
+        this.get = get;
+        this.startDate = startDate;
+        this.endDate = endDate;
+    }
+
+    public boolean isWithinPromotionPeriod() {
+        LocalDate today = DateTimes.now().toLocalDate();
+        return (today.isEqual(startDate) || today.isAfter(startDate))
+                && (today.isEqual(endDate) || today.isBefore(endDate));
+    }
+
+    public int calculateAdditionalPurchase(int purchaseQuantity) {
+        int remain = purchaseQuantity % sumOfBuyAndGet();
+
+        if (remain != 0) {
+            return sumOfBuyAndGet() - remain;
+        }
+
+        return remain;
+    }
+
+    public PromotionDetailDto calculatePromotionAndFree(int requestedQuantity, int promotionStock) {
+        int availablePromotion = (promotionStock / sumOfBuyAndGet()) * sumOfBuyAndGet();
+        return new PromotionDetailDto(availablePromotion, requestedQuantity - availablePromotion);
+    }
+
+    public int calculateFreeQuantity(int requestedQuantity, int promotionStock) {
+        if (requestedQuantity > promotionStock) {
+            return promotionStock / sumOfBuyAndGet();
+        }
+
+        return requestedQuantity / sumOfBuyAndGet();
+    }
+
+    private int sumOfBuyAndGet() {
+        return buy + get;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public int getGet() {
+        return get;
+    }
+}

리뷰: 이부분은 저도 동일하게 구현했지만 buy와 get을 포장하고 startDate와 endDate를 포장하여 필드 수를 줄였으면 어땠을지 궁금하네요
@@ -0,0 +1,34 @@
+package store.view;
+
+public enum Sentence {
+
+    PRODUCT_SELECT_STATEMENT("구매하실 상품명과 수량을 입력해 주세요. (예: [사이다-2],[감자칩-1])"),
+    HELLO_STATEMENT("안녕하세요. W편의점입니다."),
+    CURRENT_PRODUCTS_STATEMENT("현재 보유하고 있는 상품입니다."),
+    NUMBER_FORMAT("#,###"),
+    PRODUCT_FORMAT("- %s %s원 "),
+    QUANTITY_FORMAT("%s개 "),
+    OUT_OF_STOCK("재고 없음 "),
+    ADDITIONAL_PURCHASE_FORMAT("현재 %s은(는) %d개를 무료로 더 받을 수 있습니다. 추가하시겠습니까? (Y/N)"),
+    NO_PROMOTION_STATEMENT("현재 %s %d개는 프로모션 할인이 적용되지 않습니다. 그래도 구매하시겠습니까? (Y/N)"),
+    MEMBERSHIP_STATEMENT("멤버십 할인을 받으시겠습니까? (Y/N)"),
+    START_OF_RECEIPT("==============W 편의점================"),
+    HEADER_OF_PURCHASE("상품명\t\t수량\t금액"),
+    PURCHASE_FORMAT("%s\t\t%s \t%s"),
+    HEADER_OF_GIVEN("=============증\t정==============="),
+    GIVEN_FORMAT("%s\t\t%s"),
+    DIVIDE_LINE("===================================="),
+    TOTAL_PRICE_FORMAT("총구매액\t\t%s\t%s"),
+    PROMOTION_DISCOUNT_FORMAT("행사할인\t\t\t-%s"),
+    MEMBERSHIP_DISCOUNT_FORMAT("멤버십할인\t\t\t-%s"),
+    PAY_PRICE_FORMAT("내실돈\t\t\t %s"),
+    PURCHASE_AGAIN_STATEMENT("감사합니다. 구매하고 싶은 다른 상품이 있나요? (Y/N)"),
+    BLANK(""),
+    NEW_LINE(System.lineSeparator());
+
+    final String message;
+
+    Sentence(final String message) {
+        this.message = message;
+    }
+}

리뷰: 출력할 문구들을 enum으로 만드는 것이 테스트할 때 용이하다고 생각됩니다!
이번 테스트 코드에서는 그런 부분들을 테스트하지 못했지만, 개인적으로 출력 문구들을 모아두는 것이 구현 시 편리하다고 생각합니다.
@@ -0,0 +1,52 @@
+package store.view;
+
+import store.domain.Product;
+import store.domain.Products;
+import store.dto.ReceiptDto;
+
+public class View {
+
+    private final InputView inputView;
+    private final OutputView outputView;
+
+    public View(InputView inputView, OutputView outputView) {
+        this.inputView = inputView;
+        this.outputView = outputView;
+    }
+
+    public String requestProductSelect() {
+        return inputView.requestProductSelect();
+    }
+
+    public String askAdditionalPurchase(Product product) {
+        return inputView.askAdditionalPurchase(product);
+    }
+
+    public String askNoPromotion(Product product, int shortageQuantity) {
+        return inputView.askNoPromotion(product, shortageQuantity);
+    }
+
+    public String askMembership() {
+        return inputView.askMembership();
+    }
+
+    public String askPurchaseAgain() {
+        return inputView.askPurchaseAgain();
+    }
+
+    public void printHello() {
+        outputView.printHello();
+    }
+
+    public void printCurrentProducts(Products products) {
+        outputView.printCurrentProducts(products);
+    }
+
+    public void printReceipt(ReceiptDto receiptDto) {
+        outputView.printReceipt(receiptDto);
+    }
+
+    public void printBlank() {
+        outputView.printBlank();
+    }
+}

리뷰: controller가 많은 것을 의존하고 있다는 피드백을 받았어서, 최대한 줄여보려 View를 만들었습니다. 중간부터 View의 역할이 없다는 생각이 들었지만 수정할 시간이 없어 그대로 제출했습니다 ㅎㅎ... 좋은 점까지 적어주셔서 감사합니다!
@@ -0,0 +1,42 @@
+package store.domain;
+
+public record Product(
+        String name,
+        int price,
+        Stock stock,
+        Promotion promotion) {
+
+    public int calculateTotalPrice(int quantity) {
+        return price * quantity;
+    }
+
+    public void updateStock(int purchasedQuantity) {
+        int availablePromotionStock = stock.getPromotionStock();
+        int promotionQuantity = Math.min(availablePromotionStock, purchasedQuantity);
+        int regularQuantity = purchasedQuantity - promotionQuantity;
+
+        stock.minusPromotionStock(promotionQuantity);
+        stock.minusRegularStock(regularQuantity);
+    }
+
+    public int calculatePromotionDiscount(int purchasedQuantity) {
+        if (promotion == null || !promotion.isWithinPromotionPeriod()) {
+            return 0;
+        }
+
+        int eligibleForPromotion = promotion.calculateFreeQuantity(purchasedQuantity, stock.getPromotionStock());
+        return eligibleForPromotion * price;
+    }
+
+    public int getPromotionStock() {
+        return stock.getPromotionStock();
+    }
+
+    public int getRegularStock() {
+        return stock.getRegularStock();
+    }
+
+    public int getFullStock() {
+        return getPromotionStock() + getRegularStock();
+    }
+}

리뷰: name, price는 변경되지 않을 것으로 생각되고,
Stock도 한 번 생성된 객체를 가지고 계속 활용할 것으로 생각했습니다.
그치만 Promotion은 final이 아니면 더 좋을 것 같네요 ㅎㅎ...
@@ -0,0 +1,64 @@
+package store.domain;
+
+import camp.nextstep.edu.missionutils.DateTimes;
+import java.time.LocalDate;
+import store.dto.PromotionDetailDto;
+
+public class Promotion {
+
+    private final String name;
+    private final int buy;
+    private final int get;
+    private final LocalDate startDate;
+    private final LocalDate endDate;
+
+    public Promotion(final String name, final int buy, final int get, final LocalDate startDate,
+                     final LocalDate endDate) {
+        this.name = name;
+        this.buy = buy;
+        this.get = get;
+        this.startDate = startDate;
+        this.endDate = endDate;
+    }
+
+    public boolean isWithinPromotionPeriod() {
+        LocalDate today = DateTimes.now().toLocalDate();
+        return (today.isEqual(startDate) || today.isAfter(startDate))
+                && (today.isEqual(endDate) || today.isBefore(endDate));
+    }
+
+    public int calculateAdditionalPurchase(int purchaseQuantity) {
+        int remain = purchaseQuantity % sumOfBuyAndGet();
+
+        if (remain != 0) {
+            return sumOfBuyAndGet() - remain;
+        }
+
+        return remain;
+    }
+
+    public PromotionDetailDto calculatePromotionAndFree(int requestedQuantity, int promotionStock) {
+        int availablePromotion = (promotionStock / sumOfBuyAndGet()) * sumOfBuyAndGet();
+        return new PromotionDetailDto(availablePromotion, requestedQuantity - availablePromotion);
+    }
+
+    public int calculateFreeQuantity(int requestedQuantity, int promotionStock) {
+        if (requestedQuantity > promotionStock) {
+            return promotionStock / sumOfBuyAndGet();
+        }
+
+        return requestedQuantity / sumOfBuyAndGet();
+    }
+
+    private int sumOfBuyAndGet() {
+        return buy + get;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public int getGet() {
+        return get;
+    }
+}

리뷰: 좋은 의견인 것 같습니다! 확실히 필드가 간결해질 것 같네요 ㅎ.ㅎ
@@ -0,0 +1,34 @@
+package store.view;
+
+public enum Sentence {
+
+    PRODUCT_SELECT_STATEMENT("구매하실 상품명과 수량을 입력해 주세요. (예: [사이다-2],[감자칩-1])"),
+    HELLO_STATEMENT("안녕하세요. W편의점입니다."),
+    CURRENT_PRODUCTS_STATEMENT("현재 보유하고 있는 상품입니다."),
+    NUMBER_FORMAT("#,###"),
+    PRODUCT_FORMAT("- %s %s원 "),
+    QUANTITY_FORMAT("%s개 "),
+    OUT_OF_STOCK("재고 없음 "),
+    ADDITIONAL_PURCHASE_FORMAT("현재 %s은(는) %d개를 무료로 더 받을 수 있습니다. 추가하시겠습니까? (Y/N)"),
+    NO_PROMOTION_STATEMENT("현재 %s %d개는 프로모션 할인이 적용되지 않습니다. 그래도 구매하시겠습니까? (Y/N)"),
+    MEMBERSHIP_STATEMENT("멤버십 할인을 받으시겠습니까? (Y/N)"),
+    START_OF_RECEIPT("==============W 편의점================"),
+    HEADER_OF_PURCHASE("상품명\t\t수량\t금액"),
+    PURCHASE_FORMAT("%s\t\t%s \t%s"),
+    HEADER_OF_GIVEN("=============증\t정==============="),
+    GIVEN_FORMAT("%s\t\t%s"),
+    DIVIDE_LINE("===================================="),
+    TOTAL_PRICE_FORMAT("총구매액\t\t%s\t%s"),
+    PROMOTION_DISCOUNT_FORMAT("행사할인\t\t\t-%s"),
+    MEMBERSHIP_DISCOUNT_FORMAT("멤버십할인\t\t\t-%s"),
+    PAY_PRICE_FORMAT("내실돈\t\t\t %s"),
+    PURCHASE_AGAIN_STATEMENT("감사합니다. 구매하고 싶은 다른 상품이 있나요? (Y/N)"),
+    BLANK(""),
+    NEW_LINE(System.lineSeparator());
+
+    final String message;
+
+    Sentence(final String message) {
+        this.message = message;
+    }
+}

리뷰: 저도 @SeongUk52 님과 마찬가지로 출력 문구들을 상수화 해야하는지 의문이 듭니다.
`OutputView`를 보았을 때 함수명으로 유추가 가능하나, 만약 출력부분을 수정한다고 가능하면, 결코 유지보수가 쉬울 것 같지 않습니다.

#### 역으로 질문 드리고 싶은 것이 있습니다. 테스트 할 때 용이하다고 말씀하셨는데, 출력되는 내용들에 대해 테스트가 필요할까요?

저의 의견을 먼저 말씀드리자면, OutputView는 결국엔 데이터를 시각적으로 표현하는것 외에는 담당하는 것이 없습니다. 

OutputView의 본질적인 역할은 데이터를 단순히 표현하는 것에 있으므로, 출력되는 문구 자체가 테스트의 주요 대상이 되기보다는, View에 전달되는 데이터가 올바른지에 중점을 두는 것이 타당하다고 생각합니다.

특히, 출력 문구를 상수화했을 때의 장점이 명확하지 않다면, 오히려 코드가 복잡해지고 유지보수 비용이 늘어날 수 있습니다. 

이와 관련하여, 코드 수정 시 규리님이 OutputView의 메서드명 잘 작성하였기 때문에, 메서드 명을 통해 기능을 유추할 수 있는 점은 충분히 장점으로 작용할 수 있을 거라고 생각합니다~!

따라서, OutputView가 단순히 데이터 전달의 최종 단계에 있다고 보고, 이 데이터가 올바르게 전달되는지를 검증하는 데 더 초점을 맞추는 편이 OutputView의 본질적인 역할에 더 부합하는 방식이라 생각합니다.
@@ -0,0 +1,13 @@
+package store.dto;
+
+import java.util.List;
+
+public record ReceiptDto(
+        List<PurchasedDto> purchasedItems,
+        List<GivenItemDto> givenItems,
+        int totalQuantity,
+        int totalPrice,
+        int promotionDiscountPrice,
+        int membershipDiscountPrice,
+        int payPrice) {
+}

리뷰: 해당 DTO에 파라미터가 많은 상황인데, Builder패턴을 사용하는건 어떨까요???
@@ -0,0 +1,42 @@
+package store.domain;
+
+public record Product(
+        String name,
+        int price,
+        Stock stock,
+        Promotion promotion) {
+
+    public int calculateTotalPrice(int quantity) {
+        return price * quantity;
+    }
+
+    public void updateStock(int purchasedQuantity) {
+        int availablePromotionStock = stock.getPromotionStock();
+        int promotionQuantity = Math.min(availablePromotionStock, purchasedQuantity);
+        int regularQuantity = purchasedQuantity - promotionQuantity;
+
+        stock.minusPromotionStock(promotionQuantity);
+        stock.minusRegularStock(regularQuantity);
+    }
+
+    public int calculatePromotionDiscount(int purchasedQuantity) {
+        if (promotion == null || !promotion.isWithinPromotionPeriod()) {
+            return 0;
+        }
+
+        int eligibleForPromotion = promotion.calculateFreeQuantity(purchasedQuantity, stock.getPromotionStock());
+        return eligibleForPromotion * price;
+    }
+
+    public int getPromotionStock() {
+        return stock.getPromotionStock();
+    }
+
+    public int getRegularStock() {
+        return stock.getRegularStock();
+    }
+
+    public int getFullStock() {
+        return getPromotionStock() + getRegularStock();
+    }
+}

리뷰: record는 기본적으로 데이터 컨테이너 역할에 집중하기 때문에 다소 어울리지 않을 수 있지 않을까 생각합니다.
또한 만약 Product가 상태값을 가져야 하는 상황이 생긴다면 비용이 크게 발생할 수 있을 것 같습니다.
@@ -0,0 +1,42 @@
+package store.domain;
+
+public record Product(
+        String name,
+        int price,
+        Stock stock,
+        Promotion promotion) {
+
+    public int calculateTotalPrice(int quantity) {
+        return price * quantity;
+    }
+
+    public void updateStock(int purchasedQuantity) {
+        int availablePromotionStock = stock.getPromotionStock();
+        int promotionQuantity = Math.min(availablePromotionStock, purchasedQuantity);
+        int regularQuantity = purchasedQuantity - promotionQuantity;
+
+        stock.minusPromotionStock(promotionQuantity);
+        stock.minusRegularStock(regularQuantity);
+    }
+
+    public int calculatePromotionDiscount(int purchasedQuantity) {
+        if (promotion == null || !promotion.isWithinPromotionPeriod()) {
+            return 0;
+        }
+
+        int eligibleForPromotion = promotion.calculateFreeQuantity(purchasedQuantity, stock.getPromotionStock());
+        return eligibleForPromotion * price;
+    }
+
+    public int getPromotionStock() {
+        return stock.getPromotionStock();
+    }
+
+    public int getRegularStock() {
+        return stock.getRegularStock();
+    }
+
+    public int getFullStock() {
+        return getPromotionStock() + getRegularStock();
+    }
+}

리뷰: 혹시 일반상품과 프로모션 상품의 가격이 다르다면 어떻게 처리가 되나요??
@@ -0,0 +1,50 @@
+package store.config;
+
+import store.controller.StoreController;
+import store.service.StoreService;
+import store.util.ProductLoader;
+import store.util.PromotionLoader;
+import store.util.PurchaseItemProcessor;
+import store.view.InputView;
+import store.view.OutputView;
+import store.view.View;
+
+public enum AppConfig {
+
+    INSTANCE;
+
+    private static final String PROMOTION_FILE_PATH = "src/main/resources/promotions.md";
+
+    public StoreController storeController() {
+        return new StoreController(view(), storeService());
+    }
+
+    private StoreService storeService() {
+        return new StoreService(productLoader(), purchaseItemParser());
+    }
+
+    private ProductLoader productLoader() {
+        return new ProductLoader(promotionLoader()
+                .loadPromotions(PROMOTION_FILE_PATH));
+    }
+
+    private PromotionLoader promotionLoader() {
+        return new PromotionLoader();
+    }
+
+    private PurchaseItemProcessor purchaseItemParser() {
+        return new PurchaseItemProcessor();
+    }
+
+    private View view() {
+        return new View(inputView(), outputView());
+    }
+
+    private InputView inputView() {
+        return new InputView();
+    }
+
+    private OutputView outputView() {
+        return new OutputView();
+    }
+}

리뷰: IOC를 적용한 부분이 좋습니다! 
다만, 메서드로 나누지 않아도 충분히 가독성 있는 코드가 될 수 있었을 것 같습니다!
@@ -0,0 +1,34 @@
+package store.view;
+
+public enum Sentence {
+
+    PRODUCT_SELECT_STATEMENT("구매하실 상품명과 수량을 입력해 주세요. (예: [사이다-2],[감자칩-1])"),
+    HELLO_STATEMENT("안녕하세요. W편의점입니다."),
+    CURRENT_PRODUCTS_STATEMENT("현재 보유하고 있는 상품입니다."),
+    NUMBER_FORMAT("#,###"),
+    PRODUCT_FORMAT("- %s %s원 "),
+    QUANTITY_FORMAT("%s개 "),
+    OUT_OF_STOCK("재고 없음 "),
+    ADDITIONAL_PURCHASE_FORMAT("현재 %s은(는) %d개를 무료로 더 받을 수 있습니다. 추가하시겠습니까? (Y/N)"),
+    NO_PROMOTION_STATEMENT("현재 %s %d개는 프로모션 할인이 적용되지 않습니다. 그래도 구매하시겠습니까? (Y/N)"),
+    MEMBERSHIP_STATEMENT("멤버십 할인을 받으시겠습니까? (Y/N)"),
+    START_OF_RECEIPT("==============W 편의점================"),
+    HEADER_OF_PURCHASE("상품명\t\t수량\t금액"),
+    PURCHASE_FORMAT("%s\t\t%s \t%s"),
+    HEADER_OF_GIVEN("=============증\t정==============="),
+    GIVEN_FORMAT("%s\t\t%s"),
+    DIVIDE_LINE("===================================="),
+    TOTAL_PRICE_FORMAT("총구매액\t\t%s\t%s"),
+    PROMOTION_DISCOUNT_FORMAT("행사할인\t\t\t-%s"),
+    MEMBERSHIP_DISCOUNT_FORMAT("멤버십할인\t\t\t-%s"),
+    PAY_PRICE_FORMAT("내실돈\t\t\t %s"),
+    PURCHASE_AGAIN_STATEMENT("감사합니다. 구매하고 싶은 다른 상품이 있나요? (Y/N)"),
+    BLANK(""),
+    NEW_LINE(System.lineSeparator());
+
+    final String message;
+
+    Sentence(final String message) {
+        this.message = message;
+    }
+}

리뷰: 좋은 의견 감사합니다!
출력 문구를 테스트해야 하는지에 대해 깊게 고민해 본 적이 없는 것 같습니다...
남겨주신 글을 읽고 나니 생각이 많아지네요.

다만 저에게 있어 OutputView는 단순히 데이터를 전달 받아 출력하는 역할을 담당한다고 생각됩니다. 데이터가 올바르게 전달되는지 검증한다는 것이 어떤 점에서의 검증을 의미하는지 여쭤보고 싶습니다!
@@ -0,0 +1,42 @@
+package store.domain;
+
+public record Product(
+        String name,
+        int price,
+        Stock stock,
+        Promotion promotion) {
+
+    public int calculateTotalPrice(int quantity) {
+        return price * quantity;
+    }
+
+    public void updateStock(int purchasedQuantity) {
+        int availablePromotionStock = stock.getPromotionStock();
+        int promotionQuantity = Math.min(availablePromotionStock, purchasedQuantity);
+        int regularQuantity = purchasedQuantity - promotionQuantity;
+
+        stock.minusPromotionStock(promotionQuantity);
+        stock.minusRegularStock(regularQuantity);
+    }
+
+    public int calculatePromotionDiscount(int purchasedQuantity) {
+        if (promotion == null || !promotion.isWithinPromotionPeriod()) {
+            return 0;
+        }
+
+        int eligibleForPromotion = promotion.calculateFreeQuantity(purchasedQuantity, stock.getPromotionStock());
+        return eligibleForPromotion * price;
+    }
+
+    public int getPromotionStock() {
+        return stock.getPromotionStock();
+    }
+
+    public int getRegularStock() {
+        return stock.getRegularStock();
+    }
+
+    public int getFullStock() {
+        return getPromotionStock() + getRegularStock();
+    }
+}

리뷰: 그렇다면 dto에서는 record를 사용하는 것이 이점이 될 수 있을까요?
필드들을 final로 가진다면 record로 구현하였는데, 상태가 변할 수 있는 domain들은 class로 구현하는 것이 좋을지 궁금합니다!
@@ -0,0 +1,13 @@
+package store.dto;
+
+import java.util.List;
+
+public record ReceiptDto(
+        List<PurchasedDto> purchasedItems,
+        List<GivenItemDto> givenItems,
+        int totalQuantity,
+        int totalPrice,
+        int promotionDiscountPrice,
+        int membershipDiscountPrice,
+        int payPrice) {
+}

리뷰: Builder를 따로 만들기에 시간이 부족했습니다 ㅎ.ㅎ... 리팩토링하며 구현해보겠습니다!
@@ -0,0 +1,42 @@
+package store.domain;
+
+public record Product(
+        String name,
+        int price,
+        Stock stock,
+        Promotion promotion) {
+
+    public int calculateTotalPrice(int quantity) {
+        return price * quantity;
+    }
+
+    public void updateStock(int purchasedQuantity) {
+        int availablePromotionStock = stock.getPromotionStock();
+        int promotionQuantity = Math.min(availablePromotionStock, purchasedQuantity);
+        int regularQuantity = purchasedQuantity - promotionQuantity;
+
+        stock.minusPromotionStock(promotionQuantity);
+        stock.minusRegularStock(regularQuantity);
+    }
+
+    public int calculatePromotionDiscount(int purchasedQuantity) {
+        if (promotion == null || !promotion.isWithinPromotionPeriod()) {
+            return 0;
+        }
+
+        int eligibleForPromotion = promotion.calculateFreeQuantity(purchasedQuantity, stock.getPromotionStock());
+        return eligibleForPromotion * price;
+    }
+
+    public int getPromotionStock() {
+        return stock.getPromotionStock();
+    }
+
+    public int getRegularStock() {
+        return stock.getRegularStock();
+    }
+
+    public int getFullStock() {
+        return getPromotionStock() + getRegularStock();
+    }
+}

리뷰: 주어진 예시도 그렇고, 같은 상품인데 가격이 다른 상황이 있지 않을 거라 가정했습니다.
실제로 편의점에서 물건을 구입할 때 N + 1 행사를 하는 경우 서로 가격이 다른 경우는 없기 때문입니다.
@@ -0,0 +1,22 @@
+package store.exception;
+
+import java.util.function.Supplier;
+import store.view.OutputView;
+
+public class ExceptionHandler {
+
+    private static final int MAX_ATTEMPTS = 5;
+
+    public static <T> T getValidInput(final Supplier<T> supplier) {
+        int attempts = 0;
+        while (attempts < MAX_ATTEMPTS) {
+            try {
+                return supplier.get();
+            } catch (IllegalArgumentException e) {
+                attempts++;
+                OutputView.printErrorMessage(e.getMessage());
+            }
+        }
+        throw new IllegalArgumentException(ErrorMessage.TOO_MANY_INVALID_INPUT.message);
+    }
+}
\ No newline at end of file

리뷰: posix new line에 대해서 알아보시면 좋을 것 같아요 !
@@ -0,0 +1,36 @@
+package store.domain;
+
+public class Stock {
+
+    private int promotionStock;
+    private int regularStock;
+
+    public Stock(int promotionStock, int regularStock) {
+        this.promotionStock = promotionStock;
+        this.regularStock = regularStock;
+    }
+
+    public void minusPromotionStock(int quantity) {
+        promotionStock -= quantity;
+    }
+
+    public void minusRegularStock(int quantity) {
+        regularStock -= quantity;
+    }
+
+    public int getRegularStock() {
+        return regularStock;
+    }
+
+    public int getPromotionStock() {
+        return promotionStock;
+    }
+
+    public void setRegularStock(int regularStock) {
+        this.regularStock = regularStock;
+    }
+
+    public void setPromotionStock(int promotionStock) {
+        this.promotionStock = promotionStock;
+    }
+}

리뷰: Product 클래스의 불변성을 강조하고 싶으시다면 Stock 클래스 또한 setter를 제거하고 새 객체를 만드는 방식으로 하시는건 어떨까요 ? 변동성이 클 경우 리소스 소모가 있기 때문에 고려하시면 좋을 것 같아요
@@ -0,0 +1,28 @@
+package store.domain;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class Products {
+    private List<Product> products;
+
+    public Products(
+            List<Product> products) {
+        this.products = new ArrayList<>(products);
+    }
+
+    public Product findProductByName(String name) {
+        return products.stream()
+                .filter(product -> product.name().equals(name))
+                .findFirst()
+                .orElse(null);
+    }
+
+    public void addProduct(Product product) {
+        products.add(product);
+    }
+
+    public List<Product> getProducts() {
+        return products.stream().toList();
+    }
+}

리뷰: 리스트 자체는 변경할 수 없지만 
`Product product = new Product("item1", 100, new Stock(10, 5), null);
product.stock().minusPromotionStock(2);` 처럼 내부 객체는 변경이 가능해요.
Stock을 불변객체로 만드는게 좋을 것 같아요
@@ -0,0 +1,60 @@
+package store.view;
+
+import camp.nextstep.edu.missionutils.Console;
+import store.domain.Product;
+import store.exception.ErrorMessage;
+import store.exception.ValidatorBuilder;
+
+public class InputView {
+
+    private static final String YES_ANSWER = "Y";
+    private static final String NO_ANSWER = "N";
+
+    public String requestProductSelect() {
+        System.out.println(Sentence.PRODUCT_SELECT_STATEMENT.message);
+        return Console.readLine();
+    }
+
+    public String askAdditionalPurchase(Product product) {
+        System.out.printf(
+                Sentence.NEW_LINE.message + Sentence.ADDITIONAL_PURCHASE_FORMAT.message + Sentence.NEW_LINE.message,
+                product.name(), product.promotion().getGet());
+        String userInput = Console.readLine();
+
+        validateInput(userInput);
+        return userInput;
+    }
+
+    public String askNoPromotion(Product product, int shortageQuantity) {
+        System.out.printf(Sentence.NEW_LINE.message + Sentence.NO_PROMOTION_STATEMENT.message
+                + Sentence.NEW_LINE.message, product.name(), shortageQuantity);
+        String userInput = Console.readLine();
+
+        validateInput(userInput);
+        return userInput;
+    }
+
+    public String askMembership() {
+        System.out.println(Sentence.NEW_LINE.message + Sentence.MEMBERSHIP_STATEMENT.message);
+        String userInput = Console.readLine();
+
+        validateInput(userInput);
+        return userInput;
+    }
+
+    public String askPurchaseAgain() {
+        System.out.println(Sentence.NEW_LINE.message + Sentence.PURCHASE_AGAIN_STATEMENT.message);
+        String userInput = Console.readLine();
+
+        validateInput(userInput);
+        return userInput;
+    }
+
+    void validateInput(String userInput) {
+        ValidatorBuilder.from(userInput)
+                .validate(input -> input == null || input.isBlank(), ErrorMessage.INPUT_NOT_EMPTY)
+                .validate(input -> !input.equals(YES_ANSWER) && !input.equals(
+                        NO_ANSWER), ErrorMessage.INVALID_YN_INPUT)
+                .get();
+    }
+}

리뷰: 컨트롤러에서도 같은 상수를 사용하는데 public으로 사용하면 어떨까요 ? private로 하신 이유가 있으신지 궁금합니다 !