@@ -0,0 +1,121 @@
+package store.model;
+
+import java.text.NumberFormat;
+import java.time.LocalDateTime;
+import java.util.Locale;
+
+public class Product {
+    private static final String PRICE_UNIT = "원";
+    private static final String STOCK_UNIT = "%d개";
+    private static final String OUT_OF_STOCK = "재고 없음";
+
+    private String name;
+    private int price;
+    private int quantity;
+    private Promotion promotion;
+
+    public Product(String name, int price, int quantity, Promotion promotion) {
+        this.name = name;
+        this.price = price;
+        this.quantity = quantity;
+        this.promotion = promotion;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public int getPrice() {
+        return price;
+    }
+
+    public int getQuantity() {
+        return quantity;
+    }
+
+    public Promotion getPromotion() {
+        return promotion;
+    }
+
+    public int getPromotionGet() {
+        if (promotion == null) {
+            return 0;
+        }
+        return this.promotion.getGet();
+    }
+
+    public boolean isPromotion() {
+        return promotion != null;
+    }
+
+    public boolean matchName(String name) {
+        return this.name.equals(name);
+    }
+
+    public boolean inStock() {
+        return quantity > 0;
+    }
+
+    public boolean canBuy(int quantity) {
+        return this.quantity >= quantity;
+    }
+
+    public void decreaseQuantity(int amount) {
+        quantity -= amount;
+    }
+
+    public boolean isApplyPromotion(LocalDateTime localDateTime) {
+        if (promotion == null) {
+            return false;
+        }
+        return promotion.isApplyPromotion(localDateTime);
+    }
+
+    public boolean canGetForFreeByPromotion(int quantity) {
+        if (promotion == null) {
+            return false;
+        }
+        return this.promotion.canGetForFree(quantity) && this.quantity - quantity >= this.promotion.getGet();
+    }
+
+    public int getMaxCanGetForFreeByPromotion(int quantity) {
+        if (promotion == null) {
+            return 0;
+        }
+        if (quantity > this.quantity) {
+            quantity = this.quantity;
+        }
+        return this.promotion.getMaxCanGetForFree(quantity);
+    }
+
+    public int getMaxCanApplyPromotion(int quantity) {
+        if (promotion == null) {
+            return 0;
+        }
+        if (quantity > this.quantity) {
+            quantity = this.quantity;
+        }
+        return this.promotion.getMaxCanApply(quantity);
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append(name).append(" ");
+        sb.append(formatCurrency(price)).append(" ");
+        String stock = String.format(STOCK_UNIT, quantity);
+        if (quantity == 0) {
+            stock = OUT_OF_STOCK;
+        }
+        sb.append(stock).append(" ");
+        if (promotion != null) {
+            sb.append(promotion.getName());
+        }
+        return sb.toString();
+    }
+
+    private String formatCurrency(int price) {
+        NumberFormat numberFormat = NumberFormat.getNumberInstance(Locale.KOREA);
+        return numberFormat.format(price) + PRICE_UNIT;
+    }
+}

리뷰: 요구 사항을 보면 `quantity` 외에는 변할 게 없지만, `Product`를 설계할 때 '상품의 이름과 가격은 변할 수 있지 않을까?' 라는 생각에 좀 `final`로 설정하지 않았습니다. 지금 보니 앞서나간 부분이라 생각되네요. `final`로 하는 것이 좋아보입니다!