ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -0,0 +1,39 @@
+package store.constant;
+
+import static store.constant.ConstantNumbers.EXACT_GET_PROMOTION;
+import static store.constant.ConstantNumbers.MAX_BUY_PROMOTION;
+import static store.constant.ConstantNumbers.MAX_LENGTH_ITEM_NAME;
+import static store.constant.ConstantNumbers.MAX_LENGTH_PROMOTION_NAME;
+import static store.constant.ConstantNumbers.MAX_STOCK_PRICE;
+import static store.constant.ConstantNumbers.MIN_BUY_PROMOTION;
+import static store.constant.ConstantNumbers.MIN_LENGTH_ITEM_NAME;
+import static store.constant.ConstantNumbers.MIN_LENGTH_PROMOTION_NAME;
+import static store.constant.ConstantNumbers.MIN_STOCK_PRICE;
+
+public enum Errors {
+
+    NOT_NEGATIVE_QUANTITY("수량은 음수일 수 없습니다."),
+    LENGTH_STOCK_NAME("상품 이름은 %d자에서 %d자 사이여야 합니다.",
+        MIN_LENGTH_ITEM_NAME.get(), MAX_LENGTH_ITEM_NAME.get()),
+    LENGTH_STOCK_PRICE("상품 가격은 최소 %d원에서 %d원 사이여야 합니다.",
+        MIN_STOCK_PRICE.get(), MAX_STOCK_PRICE.get()),
+    LENGTH_PROMOTION_NAME("프로모션 이름은 %d자에서 %d자 사이여야 합니다.",
+        MIN_LENGTH_PROMOTION_NAME.get(), MAX_LENGTH_PROMOTION_NAME.get()),
+    LENGTH_PROMOTION_BUY("프로모션 혜택은 최소 %d개에서 최대 %d까지 구매해야 합니다.",
+        MIN_BUY_PROMOTION.get(), MAX_BUY_PROMOTION.get()),
+    EXACT_PROMOTION_GET("프로모션 혜택 당 증정상품은 반드시 %d개입니다.", EXACT_GET_PROMOTION.get()),
+    NOT_ENOUGH_STOCK("재고 수량을 초과하여 구매할 수 없습니다. 다시 입력해 주세요."),
+    NOT_FOUND_PROMOTION("존재하지 않는 프로모션입니다."),
+    NOT_FOUND_ITEM("존재하지 않는 상품입니다. 다시 입력해 주세요.");
+
+    private static final String PREFIX = "[ERROR] ";
+    private final String message;
+
+    Errors(String messageFormat, Object... args) {
+        this.message = PREFIX + String.format(messageFormat, args);
+    }
+
+    public String message() {
+        return message;
+    }
+}

리뷰: 오,, 이렇게 상수의 조합으로 에러까지 처리하다니 되게 좋네요! 근데, 와일드 카드 사용이 아무리 위험하다곤 해도 이렇게 명백하면 사용해도 괜찮을 것 같은데 어떻게 생각하세요?
@@ -0,0 +1,16 @@
+package store.constant;
+
+public enum FilePath {
+    PRODUCTS("products.md"),
+    PROMOTIONS("promotions.md");
+
+    private final String name;
+
+    FilePath(String name) {
+        this.name = name;
+    }
+
+    public String get() {
+        return System.getProperty("user.dir") + "/src/main/resources/" + name;
+    }
+}
\ No newline at end of file

리뷰: FilePath도 상수 처리하시는 점이 매우 꼼꼼하시네요 !!
@@ -0,0 +1,16 @@
+package store.constant;
+
+public enum FilePath {
+    PRODUCTS("products.md"),
+    PROMOTIONS("promotions.md");
+
+    private final String name;
+
+    FilePath(String name) {
+        this.name = name;
+    }
+
+    public String get() {
+        return System.getProperty("user.dir") + "/src/main/resources/" + name;
+    }
+}
\ No newline at end of file

리뷰: 여기 마지막 줄 개행이 누락되어있네요. 사소하지만 POSIX 표준에 의하면 파일 마지막 줄에 개행이 있어야합니다!
@@ -0,0 +1,19 @@
+package store.discount.membership;
+
+import static store.constant.ConstantNumbers.MAX_MEMBERSHIP;
+import static store.constant.ConstantNumbers.PERCENT_MEMBERSHIP;
+
+public class MembershipImpl implements Membership {
+
+    @Override
+    public int discount(int amount) {
+        double discountAmount = Math.min(
+            amount * percentToRate(PERCENT_MEMBERSHIP.get()), MAX_MEMBERSHIP.get()
+        );
+        return (int) Math.round(discountAmount);
+    }
+
+    private double percentToRate(double percent) {
+        return percent * 0.01;
+    }
+}

리뷰: 이렇게 인터페이스와 구현을 분리하는 이유는 유연한 설계를 위해서인건가요? 정말 몰라서 질문드립니다..!
@@ -0,0 +1,4 @@
+package store.dto;
+
+public record BuyRequest(String itemName, int amount) {
+}

리뷰: record 사용하신 점 인상 깊습니다! 저도 IntelliJ가 record로 변환할 수 있어서 하려다가 부작용이 생길까봐 안 했는데, record 를 사용하면 좋은 점이 무엇이라고 생각하시나요?
@@ -0,0 +1,55 @@
+package store.facade;
+
+import java.io.IOException;
+import store.config.StoreInitializer;
+import store.discount.promotion.domain.Promotions;
+import store.discount.promotion.domain.PromotionsImpl;
+import store.item.inventory.Inventory;
+import store.order.domain.Order;
+import store.order.exception.OrderCanceledException;
+import store.presentation.view.input.BuyMoreInputView;
+import store.presentation.view.output.OrderOutputView;
+import store.presentation.view.output.ProductsOutputView;
+import store.presentation.view.output.WelcomeOutputView;
+
+public class ConvenienceStore {
+
+    private final OrderFacade orderFacade;
+    private final Inventory inventory;
+
+    private final WelcomeOutputView welcomeOutputView = new WelcomeOutputView();
+    private final ProductsOutputView productsOutputView = new ProductsOutputView();
+    private final OrderOutputView orderOutputView = new OrderOutputView();
+    private final BuyMoreInputView buyMoreInputView = new BuyMoreInputView();
+
+    public ConvenienceStore(OrderFacade orderFacade, Inventory inventory) {
+        this.orderFacade = orderFacade;
+        this.inventory = inventory;
+    }
+
+    public void setUp() {
+        Promotions PROMOTIONS = new PromotionsImpl();
+        try {
+            StoreInitializer.initialize(PROMOTIONS, inventory);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public void enter() {
+        do {
+            welcomeOutputView.print();
+            productsOutputView.print(inventory);
+            orderHandlingCancelException();
+        }
+        while (ExceptionFacade.process(buyMoreInputView::read));
+    }
+
+    private void orderHandlingCancelException() {
+        try {
+            Order order = orderFacade.process();
+            orderOutputView.print(order);
+        } catch (OrderCanceledException exception) {
+        }
+    }
+}

리뷰: Facade 패턴은 듣기만 했지 처음 보네요 👀
@@ -0,0 +1,41 @@
+package store.common.collector;
+
+import java.io.BufferedReader;
+import java.io.FileReader;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import store.constant.FilePath;
+
+public abstract class FileContentCollector<T> {
+
+    private final FilePath filePath;
+    private long sequence = 0L;
+
+    public FileContentCollector(FilePath filePath) {
+        this.filePath = filePath;
+    }
+
+    public final List<T> collect() throws IOException {
+        BufferedReader bufferedReader = new BufferedReader(new FileReader(filePath.get()));
+        bufferedReader.readLine();
+        List<T> result = executeCollect(bufferedReader);
+
+        bufferedReader.close();
+        return result;
+    }
+
+    private List<T> executeCollect(BufferedReader bufferedReader) throws IOException {
+        List<T> instances = new ArrayList<>();
+        while (true) {
+            String line = bufferedReader.readLine();
+            if (line == null) {
+                break;
+            }
+            instances.add(toInstance(line, sequence++));
+        }
+        return instances;
+    }
+
+    protected abstract T toInstance(String line, long sequence);
+}

리뷰: 음.. 별 생각없이 관성적으로 long타입을 사용한 것 같습니다.
말씀하신 대로 long타입까지 쓸 필요가 없어보이네요!
@@ -0,0 +1,39 @@
+package store.common.collector;
+
+import store.constant.FilePath;
+import store.discount.promotion.domain.Promotion;
+import store.discount.promotion.domain.Promotions;
+import store.item.domain.Item;
+import store.item.domain.NormalItem;
+import store.item.domain.PromotionItem;
+
+public class ItemCollector extends FileContentCollector<Item> {
+
+    private final Promotions promotions;
+    public static final String PROMOTION_NOTHING = "null";
+
+    public ItemCollector(FilePath filePath, Promotions promotions) {
+        super(filePath);
+        this.promotions = promotions;
+    }
+
+    @Override
+    protected Item toInstance(String line, long sequence) {
+        String[] split = line.trim().split(",");
+        String name = split[0];
+        int price = Integer.parseInt(split[1]);
+        int quantity = Integer.parseInt(split[2]);
+        String promotionName = split[3].trim();
+
+        return generateItem(name, price, quantity, promotionName);
+    }
+
+    private Item generateItem(String name, int price, int quantity, String promotionName) {
+        if (promotionName.equals(PROMOTION_NOTHING)) {
+            return new NormalItem(name, price, quantity);
+        }
+
+        Promotion promotion = promotions.getByName(promotionName);
+        return new PromotionItem(name, price, quantity, promotion);
+    }
+}

리뷰: 프로모션과 상품 md파일을 읽는 두 곳에서 파일 입력의 중복을 없애고자 함이었습니다.
또 여러가지 파일 입력이 생길 수 있다는 확장성을 생각해 상속을 이용하여 파일입력은 공통된 메서드로 처리하고 변환에만 신경쓰기를 의도했습니다!
@@ -0,0 +1,32 @@
+package store.config;
+
+import store.discount.membership.Membership;
+import store.discount.membership.MembershipImpl;
+import store.facade.ConvenienceStore;
+import store.facade.OrderFacade;
+import store.item.inventory.Inventory;
+import store.item.inventory.InventoryImpl;
+import store.order.service.ConfirmListener;
+import store.order.service.OrderService;
+import store.order.service.OrderServiceImpl;
+import store.presentation.view.input.PromotionAppendInputView;
+import store.presentation.view.input.PromotionFailedInputView;
+
+public class AppConfig {
+
+    public static final Inventory INVENTORY = new InventoryImpl();
+
+    private static final ConfirmListener<String, Integer> promotionAppendListener = new PromotionAppendInputView();
+    private static final ConfirmListener<String, Integer> promotionFailedListener = new PromotionFailedInputView();
+    public static final OrderService ORDER_SERVICE = new OrderServiceImpl(INVENTORY,
+        promotionAppendListener, promotionFailedListener);
+
+    public static final OrderFacade ORDER_FACADE = new OrderFacade(ORDER_SERVICE);
+    public static final ConvenienceStore CONVENIENCE_STORE = new ConvenienceStore(ORDER_FACADE,
+        INVENTORY);
+
+    public static final Membership MEMBERSHIP = new MembershipImpl();
+
+    private AppConfig() {
+    }
+}

리뷰: 애플리케이션을 실행했을 때 상수로 선언된 객체 중 사용되지 않는 객체가 없기도 하고,
모든 구현체들에 똑같은 싱글톤 구현 코드를 넣고싶지 않아서 상수로 선언하였습니다!
사실 메모리에 대해서는 생각해보지 않았는데, 말씀하신 덕분에 생각해보게 되었습니다 ㅎㅎ 감사합니다
@@ -0,0 +1,25 @@
+package store.constant;
+
+public enum ConstantNumbers {
+    MIN_STOCK_PRICE(100),
+    MAX_STOCK_PRICE(100_000),
+    MIN_LENGTH_ITEM_NAME(2),
+    MAX_LENGTH_ITEM_NAME(10),
+    MIN_LENGTH_PROMOTION_NAME(4),
+    MAX_LENGTH_PROMOTION_NAME(12),
+    MIN_BUY_PROMOTION(1),
+    MAX_BUY_PROMOTION(4),
+    EXACT_GET_PROMOTION(1),
+    PERCENT_MEMBERSHIP(30),
+    MAX_MEMBERSHIP(8000);
+
+    private final int value;
+
+    ConstantNumbers(int value) {
+        this.value = value;
+    }
+
+    public int get() {
+        return value;
+    }
+}

리뷰: get()이라는 이름을 사용한 이유는 이 Enum에 필드가 1개밖에 없고,
ConstantNumbers.MIN_STOCK_PRICE.get() 처럼 호출할 때 어떤 값이 나올 지 예측 가능하다고 생각했습니다.
만약 value 말고도 다른 필드가 있거나 정수로 한정되지 않는 등의 경우에는 getValue()를 사용했을 것 같습니다!
이 생각은 제 주관이라.. 저도 알아봐야겠습니다 👍
@@ -0,0 +1,39 @@
+package store.constant;
+
+import static store.constant.ConstantNumbers.EXACT_GET_PROMOTION;
+import static store.constant.ConstantNumbers.MAX_BUY_PROMOTION;
+import static store.constant.ConstantNumbers.MAX_LENGTH_ITEM_NAME;
+import static store.constant.ConstantNumbers.MAX_LENGTH_PROMOTION_NAME;
+import static store.constant.ConstantNumbers.MAX_STOCK_PRICE;
+import static store.constant.ConstantNumbers.MIN_BUY_PROMOTION;
+import static store.constant.ConstantNumbers.MIN_LENGTH_ITEM_NAME;
+import static store.constant.ConstantNumbers.MIN_LENGTH_PROMOTION_NAME;
+import static store.constant.ConstantNumbers.MIN_STOCK_PRICE;
+
+public enum Errors {
+
+    NOT_NEGATIVE_QUANTITY("수량은 음수일 수 없습니다."),
+    LENGTH_STOCK_NAME("상품 이름은 %d자에서 %d자 사이여야 합니다.",
+        MIN_LENGTH_ITEM_NAME.get(), MAX_LENGTH_ITEM_NAME.get()),
+    LENGTH_STOCK_PRICE("상품 가격은 최소 %d원에서 %d원 사이여야 합니다.",
+        MIN_STOCK_PRICE.get(), MAX_STOCK_PRICE.get()),
+    LENGTH_PROMOTION_NAME("프로모션 이름은 %d자에서 %d자 사이여야 합니다.",
+        MIN_LENGTH_PROMOTION_NAME.get(), MAX_LENGTH_PROMOTION_NAME.get()),
+    LENGTH_PROMOTION_BUY("프로모션 혜택은 최소 %d개에서 최대 %d까지 구매해야 합니다.",
+        MIN_BUY_PROMOTION.get(), MAX_BUY_PROMOTION.get()),
+    EXACT_PROMOTION_GET("프로모션 혜택 당 증정상품은 반드시 %d개입니다.", EXACT_GET_PROMOTION.get()),
+    NOT_ENOUGH_STOCK("재고 수량을 초과하여 구매할 수 없습니다. 다시 입력해 주세요."),
+    NOT_FOUND_PROMOTION("존재하지 않는 프로모션입니다."),
+    NOT_FOUND_ITEM("존재하지 않는 상품입니다. 다시 입력해 주세요.");
+
+    private static final String PREFIX = "[ERROR] ";
+    private final String message;
+
+    Errors(String messageFormat, Object... args) {
+        this.message = PREFIX + String.format(messageFormat, args);
+    }
+
+    public String message() {
+        return message;
+    }
+}

리뷰: 에러메세지와 ConstantNumbers의 상수들처럼 대부분이 사용될 수 밖에 없는 관계에서는 와일드 카드를 사용해도 큰 문제 없을것 같다고 생각합니다!
@@ -0,0 +1,19 @@
+package store.discount.membership;
+
+import static store.constant.ConstantNumbers.MAX_MEMBERSHIP;
+import static store.constant.ConstantNumbers.PERCENT_MEMBERSHIP;
+
+public class MembershipImpl implements Membership {
+
+    @Override
+    public int discount(int amount) {
+        double discountAmount = Math.min(
+            amount * percentToRate(PERCENT_MEMBERSHIP.get()), MAX_MEMBERSHIP.get()
+        );
+        return (int) Math.round(discountAmount);
+    }
+
+    private double percentToRate(double percent) {
+        return percent * 0.01;
+    }
+}

리뷰: 네! 멤버십 할인에대한 테스트, 멤버십을 의존하는 테스트를 쉽게하기 위해서도 있고,
할인 정책을 전략으로써 주입할 수 있게 하기 위해 인터페이스와 구현을 분리했습니다!
@@ -0,0 +1,4 @@
+package store.dto;
+
+public record BuyRequest(String itemName, int amount) {
+}

리뷰: 행위(메서드)를 가지지 않고 단순한 데이터로서 활용할 수 있는 점이 좋았습니다!
DTO 클래스로 만들어서 getter를 이용하는 것과 차이가 없다고 느껴서,
코드량도 줄고 DTO라는 것을 명확하게 보여줄 수 있는 것 같습니다.
@@ -0,0 +1,50 @@
+package store.model.domain;
+
+import store.util.CommonValidator;
+
+public class Stock {
+
+    private final Product product;
+    private Integer quantity;
+
+    private Stock(Product product, Integer quantity) {
+        this.product = product;
+        this.quantity = quantity;
+    }
+
+    public static Stock of(String name, Integer price, Integer quantity, String promotionName) {
+        Product product = Product.of(name, price, promotionName);
+        return new Stock(product, quantity);
+    }
+
+    public void addQuantity(Integer addQuantity) {
+        CommonValidator.validateNonNegative(addQuantity);
+        this.quantity += addQuantity;
+    }
+
+    public void reduceQuantity(Integer reducedQuantity) {
+        CommonValidator.validateNonNegative(reducedQuantity);
+        this.quantity -= reducedQuantity;
+        CommonValidator.validateNonNegative(this.quantity);
+    }
+
+    public Product getProduct() {
+        return product;
+    }
+
+    public String getProductName() {
+        return product.getName();
+    }
+
+    public Integer getProductPrice() {
+        return product.getPrice();
+    }
+
+    public String getPromotionName() {
+        return product.getPromotionName();
+    }
+
+    public Integer getQuantity() {
+        return quantity;
+    }
+}

리뷰: this.quantity - recuedQuantity가 음수가 되는지 먼저 검증 후에 수량을 감소 시키는 방법은 어떻게 생각하시나요?

수량을 감소한 뒤에 음수 검증을 한다면 이미 수량은 음수가 된 상태로 예외가 발생할 것이고 이후에 다시 해당 메서드를 호출하여도 동일한 예외가 발생할 것 같습니다.
@@ -1,7 +1,29 @@
 package store;
 
+import store.controller.ConvenienceController;
+import store.controller.InventoryController;
+import store.model.PromotionManager;
+import store.model.StockManager;
+import store.model.ReceiptManager;
+import store.service.convenience.ConvenienceService;
+import store.service.inventory.InventoryService;
+
 public class Application {
     public static void main(String[] args) {
-        // TODO: 프로그램 구현
+        PromotionManager promotionManager = PromotionManager.getInstance();
+        StockManager stockManager = StockManager.getInstance();
+        ReceiptManager receiptManager = new ReceiptManager();
+        stockManager.clearStocks();
+
+        InventoryService inventoryService = new InventoryService(promotionManager, stockManager);
+        ConvenienceService convenienceService = new ConvenienceService(promotionManager, stockManager, receiptManager);
+        InventoryController inventoryController = new InventoryController(inventoryService);
+        ConvenienceController convenienceController = new ConvenienceController(convenienceService);
+        start(inventoryController, convenienceController);
+    }
+
+    private static void start(InventoryController inventoryController, ConvenienceController convenienceController) {
+        inventoryController.setup();
+        convenienceController.run();
     }
 }

리뷰: static으로 생성하신 이유가 궁금해요!
또 대부분의 클래스들은 정적팩토리 메서드를 사용하신 것 같은데
소은님의 기준이 궁금합니다ㅎㅎ
@@ -0,0 +1,14 @@
+package store.constant;
+
+public class ConvenienceConstant {
+
+    public static final int MAX_RETRIES = 3;
+    public static final int MAX_MEMBERSHIP_DISCOUNT = 8000;
+    public static final double MEMBERSHIP_DISCOUNT_RATE = 0.3;
+    public static final String NO_PROMOTION = "null";
+    public static final String EMPTY_STRING = "";
+    public static final String NOT_IN_STOCK = "재고 없음";
+
+    public ConvenienceConstant() {
+    }
+}

리뷰: 자주 사용되는 상수들을 별도 클래스로 묶어주셨네요! 👍
@@ -0,0 +1,14 @@
+package store.constant;
+
+public class ConvenienceConstant {
+
+    public static final int MAX_RETRIES = 3;
+    public static final int MAX_MEMBERSHIP_DISCOUNT = 8000;
+    public static final double MEMBERSHIP_DISCOUNT_RATE = 0.3;
+    public static final String NO_PROMOTION = "null";
+    public static final String EMPTY_STRING = "";
+    public static final String NOT_IN_STOCK = "재고 없음";
+
+    public ConvenienceConstant() {
+    }
+}

리뷰: 상수만 유지하는 클래스에 public 생성자를 명시한 이유가 있을까요!?
@@ -0,0 +1,24 @@
+package store.constant;
+
+public enum InputConstant {
+
+    SQUARE_BRACKETS_PATTERN("[\\[\\]]"),
+    NUMERIC_PATTERN("\\d+"),
+    YES_NO_PATTERN("[YyNn]"),
+    DATE_PATTERN("^\\d{4}-\\d{2}-\\d{2}$"),
+    PRODUCT_SEPARATOR(","),
+    DATE_SEPARATOR("-"),
+    TRUE_STRING("Y"),
+    EMPTY_STRING(""),
+    ;
+
+    private final String content;
+
+    InputConstant(String content) {
+        this.content = content;
+    }
+
+    public String getContent() {
+        return content;
+    }
+}

리뷰: Yes No 입력을 정규표현식을 통해 검증하셨네요! 좋은 방법인 것 같아요 👍
@@ -0,0 +1,18 @@
+package store.constant;
+
+public enum PathConstant {
+
+    PROMOTION_FILE_PATH("/promotions.md"),
+    PRODUCT_FILE_PATH("/products.md"),
+    ;
+
+    private final String path;
+
+    PathConstant(String path) {
+        this.path = path;
+    }
+
+    public String getPath() {
+        return path;
+    }
+}

리뷰: resources 경로는 루트 경로로 쳐주는군요..! 몰랐네요 👍
@@ -0,0 +1,18 @@
+package store.constant;
+
+public enum PathConstant {
+
+    PROMOTION_FILE_PATH("/promotions.md"),
+    PRODUCT_FILE_PATH("/products.md"),
+    ;
+
+    private final String path;
+
+    PathConstant(String path) {
+        this.path = path;
+    }
+
+    public String getPath() {
+        return path;
+    }
+}

리뷰: 클래스명과 get메서드명에서 Path임을 명시하고 있으니 상수명에서는 `_PATH`를 제외하는 건 어떨까요!?
@@ -0,0 +1,25 @@
+package store.constant.message;
+
+public enum ErrorMessage {
+
+    INVALID_FORMAT("올바르지 않은 형식으로 입력했습니다. 다시 입력해 주세요."),
+    NOT_FOUND_PRODUCT("존재하지 않는 상품입니다. 다시 입력해 주세요."),
+    INSUFFICIENT_STOCK("재고 수량을 초과하여 구매할 수 없습니다. 다시 입력해 주세요."),
+    GENERAL_INVALID_INPUT("잘못된 입력입니다. 다시 입력해 주세요."),
+    NOT_FOUND_FILE("파일이 존재하지 않습니다."),
+    INVALID_FILE_VALUE("파일의 값이 유효하지 않습니다."),
+    NOT_FOUND_PROMOTION("존재하지 않는 프로모션입니다."),
+    NOT_FOUND_RECEIPT("영수증이 존재하지 않습니다."),
+    ;
+
+    private static final String PREFIX = "[ERROR] ";
+    private final String message;
+
+    ErrorMessage(String message) {
+        this.message = message;
+    }
+
+    public String getMessage() {
+        return PREFIX + message;
+    }
+}

리뷰: `다시 입력해 주세요.`가 반복되고 있네요! 별도 상수로 분리해보는 건 어떨까요!?
@@ -0,0 +1,25 @@
+package store.constant.message;
+
+public enum ErrorMessage {
+
+    INVALID_FORMAT("올바르지 않은 형식으로 입력했습니다. 다시 입력해 주세요."),
+    NOT_FOUND_PRODUCT("존재하지 않는 상품입니다. 다시 입력해 주세요."),
+    INSUFFICIENT_STOCK("재고 수량을 초과하여 구매할 수 없습니다. 다시 입력해 주세요."),
+    GENERAL_INVALID_INPUT("잘못된 입력입니다. 다시 입력해 주세요."),
+    NOT_FOUND_FILE("파일이 존재하지 않습니다."),
+    INVALID_FILE_VALUE("파일의 값이 유효하지 않습니다."),
+    NOT_FOUND_PROMOTION("존재하지 않는 프로모션입니다."),
+    NOT_FOUND_RECEIPT("영수증이 존재하지 않습니다."),
+    ;
+
+    private static final String PREFIX = "[ERROR] ";
+    private final String message;
+
+    ErrorMessage(String message) {
+        this.message = message;
+    }
+
+    public String getMessage() {
+        return PREFIX + message;
+    }
+}

리뷰: 공통되는 문구를 상수로 분리해주셨네요! 👍
@@ -0,0 +1,37 @@
+package store.constant.message;
+
+public enum OutputMessage {
+    START_GUIDANCE("안녕하세요. W편의점입니다."),
+    EXISTING_STOCKS_GUIDANCE("현재 보유하고 있는 상품입니다."),
+    EXISTING_STOCKS("- %s %,d원 %s %s"),
+    STOCKS_COUNT("%%s개"),
+    PURCHASE_PRODUCTS_GUIDANCE("구매하실 상품명과 수량을 입력해 주세요. (예: [사이다-2],[감자칩-1])"),
+    ADDING_QUANTITY_STATUS_GUIDANCE("현재 %s은(는) %d개를 무료로 더 받을 수 있습니다. 추가하시겠습니까? (Y/N))"),
+    REGULAR_PRICE_PAYMENT_STATUS_GUIDANCE("현재 %s %d개는 프로모션 할인이 적용되지 않습니다. 그래도 구매하시겠습니까? (Y/N)"),
+    MEMBERSHIP_APPLICATION_STATUS_GUIDANCE("멤버십 할인을 받으시겠습니까? (Y/N)"),
+    ADDITIONAL_PURCHASE_STATUS_GUIDANCE("감사합니다. 구매하고 싶은 다른 상품이 있나요? (Y/N)"),
+
+    RECEIPT_TITLE_HEADER("===========W 편의점============="),
+    RECEIPT_PURCHASE_PRODUCTS_HEADER("상품명\t\t\t수량\t\t금액"),
+    RECEIPT_GIFTS_HEADER("===========증\t정============="),
+    RECEIPT_DIVISION_HEADER("=============================="),
+    RECEIPT_TOTAL_PURCHASE_AMOUNT("총구매액\t\t\t%d\t%,10d"),
+    RECEIPT_PROMOTION_DISCOUNT("행사할인\t\t\t\t\t-%,d"),
+    RECEIPT_MEMBERSHIP_DISCOUNT("멤버십할인\t\t\t\t\t-%,d"),
+    RECEIPT_FINAL_AMOUNT("내실돈\t%,22d"),
+    RECEIPT_PURCHASE_PRODUCT_LINE("%-11s\t%d\t%,10d"),
+    RECEIPT_GIFT_LINE("%-11s\t%d"),
+
+    NEW_LINE("%n"),
+    ;
+
+    private final String message;
+
+    OutputMessage(String message) {
+        this.message = message;
+    }
+
+    public String getMessage(Object... args) {
+        return String.format(message, args);
+    }
+}

리뷰: 가변인자를 잘 활용해주셨네요! 👍