ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -0,0 +1,22 @@
+package racingcar.domain;
+
+public enum ErrorMessage {
+    ERROR("[ERROR] "),
+    INVALID_NAME_LENGTH(ERROR + "차량 이름은 1-5글자 사이여야 합니다."),
+    INVALID_NAME_TYPE(ERROR + "차량 이름은 특수문자를 포함할 수 없습니다."),
+    DUPLICATED_NAME(ERROR + "차량 이름은 중복될 수 없습니다."),
+    INVALID_RACING_CARS_SIZE(ERROR + "경주에는 최소 2대의 차량이 참여해야합니다."),
+    INVALID_TURN_RANGE(ERROR + "시도횟수는 최소 1회 이상이어야 합니다."),
+    INVALID_TURN_TYPE(ERROR + "숫자 이외의 값은 입력할 수 없습니다."),
+    CANT_FIND_CAR(ERROR + "차량을 찾을 수 없습니다.");
+
+    private final String message;
+
+    ErrorMessage(String message) {
+        this.message = message;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+}

리뷰: enum 생성자에 대한 이해가 부족했던것 같습니다 ! 좋은 조언 감사합니다 ㅎㅎ
@@ -0,0 +1,63 @@
+package racingcar.domain;
+
+import static racingcar.domain.ErrorMessage.CANT_FIND_CAR;
+import static racingcar.domain.ErrorMessage.DUPLICATED_NAME;
+import static racingcar.domain.ErrorMessage.INVALID_RACING_CARS_SIZE;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class RacingCars {
+    private static final int MIN_SIZE = 2;
+    private final List<RacingCar> racingCars;
+    private RacingCar maxDistanceCar;
+
+    public RacingCars(List<RacingCar> racingCars) {
+        validateSize(racingCars);
+        validateDuplicated(racingCars);
+        this.racingCars = racingCars;
+    }
+
+    private void validateSize(List<RacingCar> racingCars) {
+        if (racingCars.size() < MIN_SIZE) {
+            throw new IllegalArgumentException(INVALID_RACING_CARS_SIZE.getMessage());
+        }
+    }
+
+    private void validateDuplicated(List<RacingCar> racingCars) {
+        if (isDuplicated(racingCars)) {
+            throw new IllegalArgumentException(DUPLICATED_NAME.getMessage());
+        }
+    }
+
+    private boolean isDuplicated(List<RacingCar> racingCars) {
+        int sizeAfterCut = (int) racingCars.stream()
+                .distinct()
+                .count();
+        return racingCars.size() != sizeAfterCut;
+    }
+
+    public void startRace() {
+        racingCars
+                .forEach(racingCar ->
+                        racingCar.moveForward(RandomNumberGenerator.generateRandomNumber()));
+    }
+
+    public Winner selectWinner() {
+        findMaxDistanceCar();
+        List<RacingCar> winner = racingCars.stream()
+                .filter(maxDistanceCar::isWinner)
+                .collect(Collectors.toList());
+        return new Winner(winner);
+    }
+
+    private void findMaxDistanceCar() {
+        maxDistanceCar = racingCars.stream()
+                .max(RacingCar::compareTo)
+                .orElseThrow(() -> new IllegalArgumentException(CANT_FIND_CAR.getMessage()));
+    }
+
+    public List<RacingCar> getRacingCars() {
+        return racingCars;
+    }
+}

리뷰: 제가 3주차 미션을 할때 겪었던 에로사항이 메서드내 모든 코드를 축약을 시키다보니 나중에 제가 그 코드를 보았을때 무슨 코드인지 한눈에 알기가 힘들더군요 ㅜㅜ 그래서 4주차때부터는 어느정도 절제해서 축약을 해보니 가독성이 훨씬 높아졌었던 경험이 있어서 이번에도 이런 방식으로 진행을 해봤습니다!
@@ -0,0 +1,38 @@
+package racingcar.view;
+
+import java.util.stream.Collectors;
+import racingcar.domain.RacingCar;
+import racingcar.domain.RacingCars;
+import racingcar.domain.Winner;
+
+public class OutputView {
+    private static final String RACE_RESULT_MESSAGE = "\n실행 결과";
+    private static final String DISTANCE_UNIT = "-";
+    private static final String RACE_RESULT_FORMAT = "%s : %s%n";
+    private static final String WINNER = "최종 우승자 : ";
+    private static final String DELIMITER = ", ";
+
+    public static void printRaceResultNotice() {
+        System.out.println(RACE_RESULT_MESSAGE);
+    }
+
+    public static void printRaceResult(RacingCars racingCars) {
+        racingCars.getRacingCars()
+                .forEach(racingCar -> System.out.printf(RACE_RESULT_FORMAT
+                        , racingCar.getCarName(), changeDistanceForm(racingCar.getDistance())));
+        System.out.println();
+    }
+
+    private static String changeDistanceForm(int distance) {
+        return DISTANCE_UNIT.repeat(distance);
+    }
+
+    public static void printWinner(Winner winner) {
+        System.out.print(WINNER);
+        String winners = winner.getWinner()
+                .stream()
+                .map(RacingCar::getCarName)
+                .collect(Collectors.joining(DELIMITER));
+        System.out.println(winners);
+    }
+}

리뷰: 받아온 데이터를 어떤식으로 처리할지 결정하는 것은 OutputView의 책임이라고 생각해서 해당 방식으로 진행했습니다~
3주차 공통 피드백에서도 view에서 사용하는 데이터는 getter를 사용하라고 언급이 되어있구요!
<img width="619" alt="image" src="https://github.com/parksangchu/java-racingcar-6/assets/142131857/99c1b2c6-7c6d-49ee-8154-e9d7c306bd0b">
@@ -0,0 +1,17 @@
+package racingcar.domain;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class CarNameTest {
+    @ParameterizedTest
+    @DisplayName("차량이름은 1-5글자이고 특수문자를 포함하지 않는다.")
+    @ValueSource(strings = {"abcdef", "", "$", "&", " ", "!", "(", "$", "%"})
+    void createCarName(String input) {
+        assertThatThrownBy(() -> new CarName(input))
+                .isInstanceOf(IllegalArgumentException.class);
+    }
+}
\ No newline at end of file

리뷰: 처음 보는 메서드였는데 한번 사용해보겠습니다! 감사합니다 ㅎㅎ
@@ -0,0 +1,35 @@
+package racingcar.domain;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
+
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class RacingCarsTest {
+    @ParameterizedTest
+    @DisplayName("경주에는 최소 2대 이상의 차량이 참여해야 하며 중복될 수 없다.")
+    @ValueSource(strings = {"상추", "상추,상추"})
+    void createRacingCars(String input) {
+        List<RacingCar> racingCars = InputConvertor.convertToRacingCars(input);
+        assertThatThrownBy(() -> new RacingCars(racingCars))
+                .isInstanceOf(IllegalArgumentException.class);
+    }
+
+    @Test
+    @DisplayName("우승자는 상추와 배추다.")
+    void selectWinner() {
+        RacingCar sangchu = new RacingCar(new CarName("상추"));
+        RacingCar baechu = new RacingCar(new CarName("배추"));
+        RacingCar moodosa = new RacingCar(new CarName("무도사"));
+        RacingCars racingCars = new RacingCars(List.of(sangchu, baechu, moodosa));
+        sangchu.moveForward(5);
+        baechu.moveForward(6);
+        Winner winner = racingCars.selectWinner();
+        assertThat(winner.getWinner().get(0).getCarName()).isEqualTo("상추");
+        assertThat(winner.getWinner().get(1).getCarName()).isEqualTo("배추");
+    }
+}
\ No newline at end of file

리뷰: 코드를 줄이려고 너무 욕심을 내다보니 그만 ... ㅜ 아무래도 따로 테스트하는게 가독성 측면에서도 좋을거 같습니다
@@ -0,0 +1,17 @@
+package racingcar.domain;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class TurnTest {
+    @ParameterizedTest
+    @DisplayName("1회 미만, 숫자 이외 값 입력시 예외 발생")
+    @ValueSource(strings = {"0", "-1", "d", "#", "", " "})
+    void createTurn(String input) {
+        assertThatThrownBy(() -> new Turn(input))
+                .isInstanceOf(IllegalArgumentException.class);
+    }
+}
\ No newline at end of file

리뷰: 저도 원래는 그런 방식으로 검증을 진행해오다가 이번에 'InputView에서 domain에서 쓰일 데이터 형태를 아는게 맞을까?'하는 의문이 들어 해당 방식으로 진행을 해봤습니다. 그리고 'RacingCar'와 같은 해당 프로그램의 도메인에서 쓰이는 객체를 생성해서 넘기는 것만 아니라면 int와 같은 기본값 형태는 inputView에서 검증해도 괜찮겠다라는 결론을 냈습니다.
@@ -0,0 +1,71 @@
+import { generateBasicToken } from '../utils/auth';
+import convertCartItem from '../utils/convertCartItem';
+
+const API_URL = process.env.VITE_API_URL ?? 'undefined_URL';
+const USER_ID = process.env.VITE_API_USER_ID ?? 'undefined_USER_ID';
+const USER_PASSWORD =
+  process.env.VITE_API_USER_PASSWORD ?? 'undefined_USER_PASSWORD';
+
+export const requestCartItemList = async () => {
+  const token = generateBasicToken(USER_ID, USER_PASSWORD);
+  const response = await fetch(`${API_URL}/cart-items`, {
+    method: 'GET',
+    headers: { Authorization: token, 'Content-Type': 'application/json' },
+  });
+
+  if (!response.ok) {
+    throw new Error('Failed to fetch products:');
+  }
+  const data = await response.json();
+
+  return convertCartItem(data.content);
+};
+
+export const requestSetCartItemQuantity = async (
+  cartItemId: number,
+  quantity: number,
+) => {
+  const token = generateBasicToken(USER_ID, USER_PASSWORD);
+  const response = await fetch(`${API_URL}/cart-items/${cartItemId}`, {
+    method: 'PATCH',
+    headers: { Authorization: token, 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      quantity,
+    }),
+  });
+
+  if (!response.ok) {
+    throw new Error('Failed to setCartItemQuantity');
+  }
+};
+
+export const requestDeleteCartItem = async (cartItemId: number) => {
+  const token = generateBasicToken(USER_ID, USER_PASSWORD);
+  const response = await fetch(`${API_URL}/cart-items/${cartItemId}`, {
+    method: 'DELETE',
+    headers: { Authorization: token, 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      id: cartItemId,
+    }),
+  });
+
+  if (!response.ok) {
+    throw new Error('Failed to deleteCartItem');
+  }
+};
+
+// TEST를 위한 cartItem추가 API 실행
+export const requestAddCartItemForTest = async (cartItemId: number) => {
+  const token = generateBasicToken(USER_ID, USER_PASSWORD);
+  const response = await fetch(`${API_URL}/cart-items`, {
+    method: 'POST',
+    headers: { Authorization: token, 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      productId: cartItemId,
+    }),
+  });
+
+  if (!response.ok) {
+    throw new Error('Failed to addCartItem');
+  }
+};

리뷰: 요고는 각 api마다 선언해서 사용해주시는 걸로 확인됐는데, 상수로 분리해도 좋을 것 같아요! 🐫
@@ -0,0 +1,87 @@
+import * as S from './CartItem.style';
+import Text from '../common/Text/Text';
+import Button from '../common/Button/Button';
+import Divider from '../common/Divider/Divider';
+import Checkbox from '../common/Checkbox/Checkbox';
+import ImageBox from '../common/ImageBox/ImageBox';
+import useCartItemList from '../../hooks/cartItem/useCartItemList';
+import ChangeQuantity from '../common/ChangeQuantity/ChangeQuantity';
+import { useCartItemQuantity } from '../../hooks/cartItem/useCartItemQuantity';
+import { useSelectedCartItemId } from '../../hooks/cartItem/useSelectedCartItemId';
+import useApiErrorState from '../../hooks/error/useApiErrorState';
+
+export type CartItemProps = {
+  type?: 'cart' | 'confirm';
+  cartItem: CartItem;
+};
+
+const CartItem = ({ type = 'cart', cartItem }: CartItemProps) => {
+  const { id, name, price, imageUrl } = cartItem;
+  const { apiError } = useApiErrorState();
+  const { cartItemQuantity, increaseQuantity, decreaseQuantity } =
+    useCartItemQuantity();
+  const { isSelectedId, selectCartItem, unselectCartItem } =
+    useSelectedCartItemId();
+  const { deleteCartItem } = useCartItemList();
+
+  if (
+    apiError?.name === 'FailedDeleteCartItemError' ||
+    apiError?.name === 'FailedSetCartItemQuantityError'
+  ) {
+    throw apiError;
+  }
+
+  return (
+    <S.CartItem>
+      <Divider />
+      {type === 'cart' ? (
+        <S.ItemHeader>
+          <Checkbox
+            state={isSelectedId(id)}
+            handleClick={
+              isSelectedId(id)
+                ? () => unselectCartItem(id)
+                : () => selectCartItem(id)
+            }
+          />
+          <Button size="s" radius="s" onClick={() => deleteCartItem(id)}>
+            삭제
+          </Button>
+        </S.ItemHeader>
+      ) : null}
+      <S.ItemBody>
+        <ImageBox
+          width={112}
+          height={112}
+          radius="m"
+          border="lightGray"
+          src={imageUrl}
+          alt="상품 이미지"
+        />
+        <S.ItemDetail>
+          <S.ItemNameAndCost>
+            <Text size="s" weight="m">
+              {name}
+            </Text>
+            <Text size="l" weight="l">
+              {`${price.toLocaleString('ko-KR')}원`}
+            </Text>
+          </S.ItemNameAndCost>
+          {type === 'cart' ? (
+            <ChangeQuantity
+              quantity={cartItemQuantity(id)}
+              increaseQuantity={() => increaseQuantity(id)}
+              decreaseQuantity={() => decreaseQuantity(id)}
+            />
+          ) : (
+            <Text size="s" weight="m">
+              {`${cartItemQuantity(id)}개`}
+            </Text>
+          )}
+        </S.ItemDetail>
+      </S.ItemBody>
+    </S.CartItem>
+  );
+};
+
+export default CartItem;

리뷰: type을 2개로 분리해서 구현하신 이유가 있을까요?

제가 느끼기에는 컴포넌트를 분리했어도 괜찮지 않나 싶은 생각도 들어서요!
@@ -1,10 +1,41 @@
-import "./App.css";
+import { Suspense } from 'react';
+
+import styled from 'styled-components';
+import { ErrorBoundary } from 'react-error-boundary';
+import { BrowserRouter, Route, Routes } from 'react-router-dom';
+
+import CartPage from './pages/CartPage';
+import ConfirmPurchasePage from './pages/ConfirmPurchasePage';
+import CompletePurchasePage from './pages/CompletePurchasePage';
+import ErrorFallback from './components/ErrorFallback/ErrorFallback';
+import LoadingFallback from './components/LoadingFallback/LoadingFallback';
+
+const AppContainer = styled.div`
+  max-width: 768px;
+  width: 100%;
+`;
 
 function App() {
   return (
-    <>
-      <h1>react-shopping-cart</h1>
-    </>
+    <BrowserRouter>
+      <AppContainer>
+        <ErrorBoundary FallbackComponent={ErrorFallback}>
+          <Suspense fallback={<LoadingFallback />}>
+            <Routes>
+              <Route path="/" element={<CartPage />} />
+              <Route
+                path="/confirm-purchase"
+                element={<ConfirmPurchasePage />}
+              />
+              <Route
+                path="/complete-purchase"
+                element={<CompletePurchasePage />}
+              />
+            </Routes>
+          </Suspense>
+        </ErrorBoundary>
+      </AppContainer>
+    </BrowserRouter>
   );
 }
 

리뷰: 사소하지만 RESTful하게 path를 작성해주셨네요 👏👏👏👏
@@ -1,10 +1,41 @@
-import "./App.css";
+import { Suspense } from 'react';
+
+import styled from 'styled-components';
+import { ErrorBoundary } from 'react-error-boundary';
+import { BrowserRouter, Route, Routes } from 'react-router-dom';
+
+import CartPage from './pages/CartPage';
+import ConfirmPurchasePage from './pages/ConfirmPurchasePage';
+import CompletePurchasePage from './pages/CompletePurchasePage';
+import ErrorFallback from './components/ErrorFallback/ErrorFallback';
+import LoadingFallback from './components/LoadingFallback/LoadingFallback';
+
+const AppContainer = styled.div`
+  max-width: 768px;
+  width: 100%;
+`;
 
 function App() {
   return (
-    <>
-      <h1>react-shopping-cart</h1>
-    </>
+    <BrowserRouter>
+      <AppContainer>
+        <ErrorBoundary FallbackComponent={ErrorFallback}>
+          <Suspense fallback={<LoadingFallback />}>
+            <Routes>
+              <Route path="/" element={<CartPage />} />
+              <Route
+                path="/confirm-purchase"
+                element={<ConfirmPurchasePage />}
+              />
+              <Route
+                path="/complete-purchase"
+                element={<CompletePurchasePage />}
+              />
+            </Routes>
+          </Suspense>
+        </ErrorBoundary>
+      </AppContainer>
+    </BrowserRouter>
   );
 }
 

리뷰: 사소하지만, 다음 미션부터는 [createBrowserRouter](https://reactrouter.com/en/main/routers/create-browser-router)를 사용해봐도 괜찮을 것 같아서 공유드려요!

관련해서 수야와도 리뷰를 한 적이 있어서 첨부합니다!
https://github.com/cys4585/react-shopping-cart/pull/1/files/0636ab3b3126ca9b8873cb7fec59c13b05c81985#r1603187152
@@ -0,0 +1,71 @@
+import { generateBasicToken } from '../utils/auth';
+import convertCartItem from '../utils/convertCartItem';
+
+const API_URL = process.env.VITE_API_URL ?? 'undefined_URL';
+const USER_ID = process.env.VITE_API_USER_ID ?? 'undefined_USER_ID';
+const USER_PASSWORD =
+  process.env.VITE_API_USER_PASSWORD ?? 'undefined_USER_PASSWORD';
+
+export const requestCartItemList = async () => {
+  const token = generateBasicToken(USER_ID, USER_PASSWORD);
+  const response = await fetch(`${API_URL}/cart-items`, {
+    method: 'GET',
+    headers: { Authorization: token, 'Content-Type': 'application/json' },
+  });
+
+  if (!response.ok) {
+    throw new Error('Failed to fetch products:');
+  }
+  const data = await response.json();
+
+  return convertCartItem(data.content);
+};
+
+export const requestSetCartItemQuantity = async (
+  cartItemId: number,
+  quantity: number,
+) => {
+  const token = generateBasicToken(USER_ID, USER_PASSWORD);
+  const response = await fetch(`${API_URL}/cart-items/${cartItemId}`, {
+    method: 'PATCH',
+    headers: { Authorization: token, 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      quantity,
+    }),
+  });
+
+  if (!response.ok) {
+    throw new Error('Failed to setCartItemQuantity');
+  }
+};
+
+export const requestDeleteCartItem = async (cartItemId: number) => {
+  const token = generateBasicToken(USER_ID, USER_PASSWORD);
+  const response = await fetch(`${API_URL}/cart-items/${cartItemId}`, {
+    method: 'DELETE',
+    headers: { Authorization: token, 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      id: cartItemId,
+    }),
+  });
+
+  if (!response.ok) {
+    throw new Error('Failed to deleteCartItem');
+  }
+};
+
+// TEST를 위한 cartItem추가 API 실행
+export const requestAddCartItemForTest = async (cartItemId: number) => {
+  const token = generateBasicToken(USER_ID, USER_PASSWORD);
+  const response = await fetch(`${API_URL}/cart-items`, {
+    method: 'POST',
+    headers: { Authorization: token, 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      productId: cartItemId,
+    }),
+  });
+
+  if (!response.ok) {
+    throw new Error('Failed to addCartItem');
+  }
+};

리뷰: 제가 지난 번에 남겼던 리뷰를 반영해주신것 같아서 기분이 조으네요..ㅎㅎ
@@ -0,0 +1,87 @@
+import * as S from './CartItem.style';
+import Text from '../common/Text/Text';
+import Button from '../common/Button/Button';
+import Divider from '../common/Divider/Divider';
+import Checkbox from '../common/Checkbox/Checkbox';
+import ImageBox from '../common/ImageBox/ImageBox';
+import useCartItemList from '../../hooks/cartItem/useCartItemList';
+import ChangeQuantity from '../common/ChangeQuantity/ChangeQuantity';
+import { useCartItemQuantity } from '../../hooks/cartItem/useCartItemQuantity';
+import { useSelectedCartItemId } from '../../hooks/cartItem/useSelectedCartItemId';
+import useApiErrorState from '../../hooks/error/useApiErrorState';
+
+export type CartItemProps = {
+  type?: 'cart' | 'confirm';
+  cartItem: CartItem;
+};
+
+const CartItem = ({ type = 'cart', cartItem }: CartItemProps) => {
+  const { id, name, price, imageUrl } = cartItem;
+  const { apiError } = useApiErrorState();
+  const { cartItemQuantity, increaseQuantity, decreaseQuantity } =
+    useCartItemQuantity();
+  const { isSelectedId, selectCartItem, unselectCartItem } =
+    useSelectedCartItemId();
+  const { deleteCartItem } = useCartItemList();
+
+  if (
+    apiError?.name === 'FailedDeleteCartItemError' ||
+    apiError?.name === 'FailedSetCartItemQuantityError'
+  ) {
+    throw apiError;
+  }
+
+  return (
+    <S.CartItem>
+      <Divider />
+      {type === 'cart' ? (
+        <S.ItemHeader>
+          <Checkbox
+            state={isSelectedId(id)}
+            handleClick={
+              isSelectedId(id)
+                ? () => unselectCartItem(id)
+                : () => selectCartItem(id)
+            }
+          />
+          <Button size="s" radius="s" onClick={() => deleteCartItem(id)}>
+            삭제
+          </Button>
+        </S.ItemHeader>
+      ) : null}
+      <S.ItemBody>
+        <ImageBox
+          width={112}
+          height={112}
+          radius="m"
+          border="lightGray"
+          src={imageUrl}
+          alt="상품 이미지"
+        />
+        <S.ItemDetail>
+          <S.ItemNameAndCost>
+            <Text size="s" weight="m">
+              {name}
+            </Text>
+            <Text size="l" weight="l">
+              {`${price.toLocaleString('ko-KR')}원`}
+            </Text>
+          </S.ItemNameAndCost>
+          {type === 'cart' ? (
+            <ChangeQuantity
+              quantity={cartItemQuantity(id)}
+              increaseQuantity={() => increaseQuantity(id)}
+              decreaseQuantity={() => decreaseQuantity(id)}
+            />
+          ) : (
+            <Text size="s" weight="m">
+              {`${cartItemQuantity(id)}개`}
+            </Text>
+          )}
+        </S.ItemDetail>
+      </S.ItemBody>
+    </S.CartItem>
+  );
+};
+
+export default CartItem;

리뷰: 저도 비슷하게 생각하는데요. 체크, 삭제버튼, 숫자 버튼 등 지금은 경우의 수가 2**3개 밖에 없어서 타입으로 일일히 지정해줄 수 있지만, 요구사항이 늘어나면 타입으로는 버거울수도 있다는 생각이 들어요..!
@@ -1,7 +1,14 @@
 package christmas;
 
+import christmas.controller.ChristmasController;
+import christmas.exception.ExceptionHandler;
+import christmas.exception.RetryExceptionHandler;
+
 public class Application {
     public static void main(String[] args) {
-        // TODO: 프로그램 구현
+        ExceptionHandler handler = new RetryExceptionHandler();
+
+        ChristmasController controller = new ChristmasController(handler);
+        controller.service();
     }
 }

리뷰: 메서드는 동사로 하는 것이 좋다고 생각하는데,
service() 는 정비하다 라는 의미라고 해요! 물론, 서비스 하다~ 라는 느낌인 건 이해하지만
execute()나 operate() 와 같은 동작을 강조하는 동사를 사용하는 건 어떨까 하는 개인적 의견입니다!
@@ -0,0 +1,25 @@
+package christmas.constant;
+
+public enum Badge {
+    NONE("없음", 0),
+    STAR("별", 5_000),
+    TREE("트리", 10_000),
+    SANTA("산타", 20_000);
+
+
+    private final String badgeName;
+    private final long prize;
+
+    Badge(String badgeName, long prize){
+        this.badgeName = badgeName;
+        this.prize = prize;
+    }
+
+    public String getBadgeName(){
+        return badgeName;
+    }
+
+    public long getPrize(){
+        return prize;
+    }
+}

리뷰: 이 부분 참 고민 많이 헀는데요,
저도 "없음" 배지를 만든 뒤 고민해보니
도메인 쪽에서 출력 로직을 알아야하는 것 같아서
없음 배지를 삭제하고, Dto에 비어있는 badgeName 값을 넣는 식으로 구현한 후 OutputView에서 전달받은 이벤트 배지 값이 비어있는 String이면 없애는 식으로 처리했습니다.
수진님은 어떻게 생각하시나요?
@@ -0,0 +1,41 @@
+package christmas.constant;
+
+public enum Menu {
+    YANGSONGSOUP("양송이수프", 6000, MenuType.APPETIZER),
+    TAPAS("타파스", 5500, MenuType.APPETIZER),
+    CAESAR_SALAD("시저샐러드", 8000, MenuType.APPETIZER),
+
+    T_BONE_STEAK("티본스테이크", 55000, MenuType.MAIN),
+    BBQ_RIB("바비큐립", 54000, MenuType.MAIN),
+    SEAFOOD_PASTA("해산물파스타", 35000, MenuType.MAIN),
+    CHRISTMAS_PASTA("크리스마스파스타", 25000, MenuType.MAIN),
+
+    CHOCO_CAKE("초코케이크", 15000, MenuType.DESSERT),
+    ICE_CREAM("아이스크림", 5000, MenuType.DESSERT),
+
+    ZERO_COLA("제로콜라", 3000, MenuType.BEVERAGE),
+    RED_WINE("레드와인", 60000, MenuType.BEVERAGE),
+    CHAMPAGNE("샴페인", 25000, MenuType.BEVERAGE);
+
+    private final String name;
+    private final int price;
+    private final MenuType menuType;
+
+    Menu(String name, int price, MenuType menuType) {
+        this.name = name;
+        this.price = price;
+        this.menuType = menuType;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public int getPrice() {
+        return price;
+    }
+
+    public MenuType getMenuType() {
+        return menuType;
+    }
+}

리뷰: MenuType을 static import 하면 좀 더 짧게 코드를 작성할 수 있을거 같아요!