ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -9,19 +9,24 @@ public LottoGame() {
         statistics = new TreeMap<>();
     }
 
-    public int inputMoney(int money) {
+    public int calculateLottoCount(int money) {
         return lottoCount = money / Lotto.LOTTO_PRICE;
     }
 
-    public LottoTicket generateAutoLottoTicket() {
+    public LottoTicket generateLottoTicket(int manualLottoCount) {
         List<Lotto> lottos = new ArrayList<>();
-        for(int i = 0; i < lottoCount; i++) {
+
+        for (int i = 0; i < manualLottoCount; i++) {
+            lottos.add(ManualLottoGenerator.generate(i, manualLottoCount));
+        }
+
+        for(int i = manualLottoCount; i < lottoCount; i++) {
             lottos.add(AutoLottoGenerator.generate());
         }
-        return generateLottoTicket(lottos);
+        return makeLottoTicket(lottos);
     }
 
-    private LottoTicket generateLottoTicket(List<Lotto> lottos) {
+    private LottoTicket makeLottoTicket(List<Lotto> lottos) {
         return lottoTicket = new LottoTicket(lottos);
     }
 

리뷰: 수동 로또와 자동 로또를 함께 생성하고 있는데 이를 분리시켜보면 어떨까요? 
```
List<Lotto> lottos = InputView.manualLotto(lottoCount);
OutputView.printLottoTicket(game.generateLottoTicket(lottos));
```
코드 자체로 generateLottoTicket에서 자동로또까지 생성해주는 것인지 혹은 의도한대로 수동 로또와 자동 로또를 함께 생성하는 것인지 메서드명만 보고는 의미가 조금 모호하다고 생각됩니다. (개인적 생각으로) 
그리고 나중에 요구사항이 변경되어 `수동 로또 생성만` 혹은 `자동 로또 생성만` 그리고 지금과 같이 `수동 로또와 자동 로또를 섞어서` 할 수 있도록 하는 형태라면 메서드 자체로 조금 더 분명한 의미를 가지고 메서드를 재활용할 수 있을 것 같아서요 ~
@@ -0,0 +1,12 @@
+public class BonusNumber {
+    private LottoNumber bonusNumber;
+
+    public BonusNumber(LottoNumber bonusNumber) {
+        this.bonusNumber = bonusNumber;
+    }
+
+    public LottoNumber getBonusNumber() {
+        return bonusNumber;
+    }
+}
+

리뷰: LottoNumber 자체도 원시값을 포장한 형태로 LottoNumber 타입으로 bonusNumber를 사용할 수 있는데 이를 한 번 더 포장하여 사용하신 이유가 있으실까요? BonusNumber에서는 어떠한 행위를 하지 않고 포장된 값을 한 번 더 Wrapping 하는 형태로만 사용되고 있는데 불필요해 보여서요~
@@ -43,6 +43,10 @@ public int match(WinningLotto winningLotto) {
         return matchCount;
     }
 
+    public boolean isBonusMatch(WinningLotto winningLotto) {
+        return lotto.contains(winningLotto.getBonusNumber());
+    }
+
     public boolean contains(LottoNumber lottoNumber) {
         return lotto.contains(lottoNumber);
     }

리뷰: lotto에 보너스 숫자가 포함되어있는지 판별하는 역할을 하는 메소드로 보이네요. 단순히 판별 여부라면 boolean 타입으로 변경해주면 어떨까요? 만약 보너스 숫자가 1개 이상이라는 요구사항 변경을 염두한 구현이라면 1, 0을 리턴하는 것보다 구체적으로 매칭된 갯수를 리턴해주는 식으로 변경해보면 확장에 유리한 구현이 될 것 같아요. 메소드명도 의미전달을 조금 더 명확하게 하면 완벽할 것 같습니다b
@@ -6,6 +6,8 @@ public class OutputView {
     private static final String RESULT_STATISTICS_MESSAGE = "당첨 통계";
     private static final String BOUNDARY_LINE = "---------";
     private static final String TOTAL_EARNING_RATIO_MESSAGE = "총 수익률은 %s 입니다.";
+    private static final int SECOND_RANK_COUNT = 5;
+    private static final int MINIMUM_PRIZE_COUNT = 3;
 
     public static void printLottoCount(int lottoCount) {
         System.out.printf((LOTTO_PURCHASE_COMPLETE_MESSAGE) + "%n", lottoCount);
@@ -22,12 +24,29 @@ public static void printStatistics(Map<Rank, Integer> statistics) {
         System.out.println(BOUNDARY_LINE);
 
         for (Rank rank : statistics.keySet()) {
-            System.out.print(String.format("%d 개 일치 (%d원)", rank.getMatchCount(), rank.getReward()));
-            System.out.println(" - " + statistics.get(rank) + "개");
+            printResult(rank, statistics);
         }
     }
 
     public static void printEarningRate(String earningRate) {
         System.out.printf((TOTAL_EARNING_RATIO_MESSAGE) + "%n", earningRate);
     }
+
+    private static void printResult(Rank rank, Map<Rank, Integer> statistics) {
+        if (validateMatchCountIs5(rank)) {
+            System.out.print(String.format("%d 개 일치, 보너스 볼 일치 (%d원)", rank.getMatchCount(), rank.getReward()));
+            System.out.println(" - " + statistics.get(rank) + "개");
+        } else if (validateMatchCountOver3(rank)) {
+            System.out.print(String.format("%d 개 일치 (%d원)", rank.getMatchCount(), rank.getReward()));
+            System.out.println(" - " + statistics.get(rank) + "개");
+        }
+    }
+
+    private static boolean validateMatchCountIs5(Rank rank) {
+        return rank.getMatchCount() == SECOND_RANK_COUNT && rank.isBonusMatch();
+    }
+
+    private static boolean validateMatchCountOver3(Rank rank) {
+        return rank.getMatchCount() >= MINIMUM_PRIZE_COUNT;
+    }
 }
\ No newline at end of file

리뷰: 제약사항 요구사항을 반영할 때 `rank.getMatch() == 5 && rank.getBonusMatchCount() == 1` 와 같은 코드를 메소드로 따로 분리해서 `validateMatchCount(rank)`와 같이 작성한다면 코드를 읽는 다른 개발자가 제약사항을 파악하는데 더 쉬울 것 같습니다. 메소드명만 보고 "이 지점에서 validate 체크를 하는 구나"라고 한눈에 파악이 되고, 구체적인 제약사항이 궁금할 땐 해당 메소드 내부 구현만 찾아보면 되게 될것 같습니다. 추가로 숫자들도 상수 처리해주면 더 좋을 것 같습니다.
@@ -1,10 +1,33 @@
+import exception.*;
+import java.util.List;
+
 public class Application {
     public static void main(String[] args) {
         LottoGame game = new LottoGame();
-        OutputView.printLottoCount(game.inputMoney(new Money(InputView.inputMoney()).getMoney()));
-        OutputView.printLottoTicket(game.generateAutoLottoTicket());
-        WinningLotto winningLotto = new WinningLotto(StringParsingUtils.parseToLottoNumber(InputView.inputWinningLottoNumber()));
-        OutputView.printStatistics(game.calculateRankStatistics(winningLotto));
-        OutputView.printEarningRate(game.calculateEarningRatio());
+        while (true) {
+            try {
+                OutputView.printLottoCount(game.calculateLottoCount(new Money(InputView.inputMoney()).getMoney()));
+                int manualLottoCount = InputView.manualLottoCount();
+
+                OutputView.printLottoTicket(game.generateLottoTicket(manualLottoCount));
+                List<LottoNumber> lottoNumbers = StringParsingUtils.parseToLottoNumber(InputView.inputWinningLottoNumber());
+                BonusNumber bonusNumber = InputView.inputBonusNumber();
+                WinningLotto winningLotto = new WinningLotto(lottoNumbers, bonusNumber);
+
+                OutputView.printStatistics(game.calculateRankStatistics(winningLotto));
+                OutputView.printEarningRate(game.calculateEarningRatio());
+                break;
+            } catch (MoneyUnderMinMoneyException moneyUnderMinMoneyException) {
+                System.out.println(moneyUnderMinMoneyException.getMessage());
+            } catch (LottoSizeMismatchException lottoSizeMismatchException) {
+                System.out.println(lottoSizeMismatchException.getMessage());
+            } catch (LottoIsNotUniqueException lottoIsNotUniqueException) {
+                System.out.println(lottoIsNotUniqueException.getMessage());
+            } catch (LottoNumberOutOfRangeException lottoNumberOutOfRangeException) {
+                System.out.println(lottoNumberOutOfRangeException.getMessage());
+            } catch (BonusNumberDuplicatedException bonusNumberDuplicatedException) {
+                System.out.println(bonusNumberDuplicatedException.getMessage());
+            }
+        }
     }
 }
\ No newline at end of file

리뷰: 큰 요소는 아니겠지만 관련성에 따라서 개행을 추가해 구분 지어주는 것도 가독성을 높이는 요소라고 생각합니다!
@@ -1,9 +1,14 @@
+import java.util.List;
 import java.util.Scanner;
 
 public class InputView {
 
     private static final String INPUT_MONEY_MESSAGE = "구입금액을 입력해 주세요.";
     private static final String INPUT_WINNING_LOTTO_MESSAGE = "지난 주 당첨 번호를 입력해 주세요.";
+    private static final String MANUAL_LOTTO_COUNT_MESSAGE = "수동으로 구매할 로또 수를 입력해 주세요.";
+    private static final String MANUAL_LOTTO_NUMBER_MESSAGE = "수동으로 구매할 번호를 입력해 주세요.";
+
+    private static final String INPUT_BONUS_NUMBER_MESSAGE = "보너스 볼을 입력해 주세요.";
 
     private static Scanner scanner = new Scanner(System.in);
 
@@ -12,6 +17,30 @@ public static int inputMoney() {
         return Integer.parseInt(scanner.nextLine());
     }
 
+    public static int manualLottoCount() {
+        System.out.println(MANUAL_LOTTO_COUNT_MESSAGE);
+        return Integer.parseInt(scanner.nextLine());
+    }
+
+    public static List<LottoNumber> manualLotto(int i, int manualLottoCount) {
+        if (i == 0) {
+            System.out.println(MANUAL_LOTTO_NUMBER_MESSAGE);
+        }
+
+        String inputs = scanner.nextLine();
+        if (i != manualLottoCount - 1) {
+            scanner.nextLine();
+        }
+
+        return StringParsingUtils.parseToLottoNumber(inputs);
+    }
+
+    public static BonusNumber inputBonusNumber() {
+        System.out.println(INPUT_BONUS_NUMBER_MESSAGE);
+        int bonusNumber = Integer.parseInt(scanner.nextLine());
+        return new BonusNumber(new LottoNumber(bonusNumber));
+    }
+
     public static String inputWinningLottoNumber() {
         System.out.println(INPUT_WINNING_LOTTO_MESSAGE);
         return scanner.nextLine();

리뷰: InputView에서 수동 로또에 대해 번호를 입력 받는 역할에 더해 직접 로또를 생성하는 역할까지 하고 있네요. 역할을 분리해볼 수 있을까요? 분리했을 때 어떤 이점이 생길까요?
@@ -1,9 +1,14 @@
+import java.util.List;
 import java.util.Scanner;
 
 public class InputView {
 
     private static final String INPUT_MONEY_MESSAGE = "구입금액을 입력해 주세요.";
     private static final String INPUT_WINNING_LOTTO_MESSAGE = "지난 주 당첨 번호를 입력해 주세요.";
+    private static final String MANUAL_LOTTO_COUNT_MESSAGE = "수동으로 구매할 로또 수를 입력해 주세요.";
+    private static final String MANUAL_LOTTO_NUMBER_MESSAGE = "수동으로 구매할 번호를 입력해 주세요.";
+
+    private static final String INPUT_BONUS_NUMBER_MESSAGE = "보너스 볼을 입력해 주세요.";
 
     private static Scanner scanner = new Scanner(System.in);
 
@@ -12,6 +17,30 @@ public static int inputMoney() {
         return Integer.parseInt(scanner.nextLine());
     }
 
+    public static int manualLottoCount() {
+        System.out.println(MANUAL_LOTTO_COUNT_MESSAGE);
+        return Integer.parseInt(scanner.nextLine());
+    }
+
+    public static List<LottoNumber> manualLotto(int i, int manualLottoCount) {
+        if (i == 0) {
+            System.out.println(MANUAL_LOTTO_NUMBER_MESSAGE);
+        }
+
+        String inputs = scanner.nextLine();
+        if (i != manualLottoCount - 1) {
+            scanner.nextLine();
+        }
+
+        return StringParsingUtils.parseToLottoNumber(inputs);
+    }
+
+    public static BonusNumber inputBonusNumber() {
+        System.out.println(INPUT_BONUS_NUMBER_MESSAGE);
+        int bonusNumber = Integer.parseInt(scanner.nextLine());
+        return new BonusNumber(new LottoNumber(bonusNumber));
+    }
+
     public static String inputWinningLottoNumber() {
         System.out.println(INPUT_WINNING_LOTTO_MESSAGE);
         return scanner.nextLine();

리뷰: 자동이 아닌 수동으로 입력할 갯수만큼 입력을 받도록 요구사항이 기재되어 있기 때문에, 만약 `수동으로 구매할 로또 수를 입력해 주세요.` 에서 2개를 입력했다면, 2줄의 입력 값을 넣어주어야 합니다!
@@ -1,9 +1,14 @@
+import java.util.List;
 import java.util.Scanner;
 
 public class InputView {
 
     private static final String INPUT_MONEY_MESSAGE = "구입금액을 입력해 주세요.";
     private static final String INPUT_WINNING_LOTTO_MESSAGE = "지난 주 당첨 번호를 입력해 주세요.";
+    private static final String MANUAL_LOTTO_COUNT_MESSAGE = "수동으로 구매할 로또 수를 입력해 주세요.";
+    private static final String MANUAL_LOTTO_NUMBER_MESSAGE = "수동으로 구매할 번호를 입력해 주세요.";
+
+    private static final String INPUT_BONUS_NUMBER_MESSAGE = "보너스 볼을 입력해 주세요.";
 
     private static Scanner scanner = new Scanner(System.in);
 
@@ -12,6 +17,30 @@ public static int inputMoney() {
         return Integer.parseInt(scanner.nextLine());
     }
 
+    public static int manualLottoCount() {
+        System.out.println(MANUAL_LOTTO_COUNT_MESSAGE);
+        return Integer.parseInt(scanner.nextLine());
+    }
+
+    public static List<LottoNumber> manualLotto(int i, int manualLottoCount) {
+        if (i == 0) {
+            System.out.println(MANUAL_LOTTO_NUMBER_MESSAGE);
+        }
+
+        String inputs = scanner.nextLine();
+        if (i != manualLottoCount - 1) {
+            scanner.nextLine();
+        }
+
+        return StringParsingUtils.parseToLottoNumber(inputs);
+    }
+
+    public static BonusNumber inputBonusNumber() {
+        System.out.println(INPUT_BONUS_NUMBER_MESSAGE);
+        int bonusNumber = Integer.parseInt(scanner.nextLine());
+        return new BonusNumber(new LottoNumber(bonusNumber));
+    }
+
     public static String inputWinningLottoNumber() {
         System.out.println(INPUT_WINNING_LOTTO_MESSAGE);
         return scanner.nextLine();

리뷰: <img width="370" alt="스크린샷 2022-07-04 오후 7 14 35" src="https://user-images.githubusercontent.com/62830487/177134400-0365bef0-a31c-4a57-9020-e294daa65100.png">
로직에 문제가 있어보이네요. 수동 로또 2장을 구매해서 로또 2장의 번호를 입력했는데 입력을 한 번 더 기다리고 있네요. 확인 부탁드려요.
@@ -9,19 +9,24 @@ public LottoGame() {
         statistics = new TreeMap<>();
     }
 
-    public int inputMoney(int money) {
+    public int calculateLottoCount(int money) {
         return lottoCount = money / Lotto.LOTTO_PRICE;
     }
 
-    public LottoTicket generateAutoLottoTicket() {
+    public LottoTicket generateLottoTicket(int manualLottoCount) {
         List<Lotto> lottos = new ArrayList<>();
-        for(int i = 0; i < lottoCount; i++) {
+
+        for (int i = 0; i < manualLottoCount; i++) {
+            lottos.add(ManualLottoGenerator.generate(i, manualLottoCount));
+        }
+
+        for(int i = manualLottoCount; i < lottoCount; i++) {
             lottos.add(AutoLottoGenerator.generate());
         }
-        return generateLottoTicket(lottos);
+        return makeLottoTicket(lottos);
     }
 
-    private LottoTicket generateLottoTicket(List<Lotto> lottos) {
+    private LottoTicket makeLottoTicket(List<Lotto> lottos) {
         return lottoTicket = new LottoTicket(lottos);
     }
 

리뷰: 결국은 수동 로또와 자동 로또 모두 하나의 로또 티켓으로 합쳐지는 부분이 필요하기 때문에, 현재 `generateLottoTicket()` 내의 수동과 자동 로또 생성 각각의 반복문을 `generateManualLottoTicket()`과 `generateAutoLottoTicket()` 으로 메서드를 분리해보도록 하겠습니다
@@ -0,0 +1,12 @@
+public class BonusNumber {
+    private LottoNumber bonusNumber;
+
+    public BonusNumber(LottoNumber bonusNumber) {
+        this.bonusNumber = bonusNumber;
+    }
+
+    public LottoNumber getBonusNumber() {
+        return bonusNumber;
+    }
+}
+

리뷰: @sunghyuki 기존에 Lotto 객체로만 구성된 WinningLotto에 BonusNumber를 추가하여, 로또 당첨 번호 중 5개가 일치하는 경우 BonusNumber 일치 여부 확인을 분명하게 하기 위해 LottoNumber 타입으로 구성된 BonusNumber를 생성했습니다~
@@ -1,9 +1,14 @@
+import java.util.List;
 import java.util.Scanner;
 
 public class InputView {
 
     private static final String INPUT_MONEY_MESSAGE = "구입금액을 입력해 주세요.";
     private static final String INPUT_WINNING_LOTTO_MESSAGE = "지난 주 당첨 번호를 입력해 주세요.";
+    private static final String MANUAL_LOTTO_COUNT_MESSAGE = "수동으로 구매할 로또 수를 입력해 주세요.";
+    private static final String MANUAL_LOTTO_NUMBER_MESSAGE = "수동으로 구매할 번호를 입력해 주세요.";
+
+    private static final String INPUT_BONUS_NUMBER_MESSAGE = "보너스 볼을 입력해 주세요.";
 
     private static Scanner scanner = new Scanner(System.in);
 
@@ -12,6 +17,30 @@ public static int inputMoney() {
         return Integer.parseInt(scanner.nextLine());
     }
 
+    public static int manualLottoCount() {
+        System.out.println(MANUAL_LOTTO_COUNT_MESSAGE);
+        return Integer.parseInt(scanner.nextLine());
+    }
+
+    public static List<LottoNumber> manualLotto(int i, int manualLottoCount) {
+        if (i == 0) {
+            System.out.println(MANUAL_LOTTO_NUMBER_MESSAGE);
+        }
+
+        String inputs = scanner.nextLine();
+        if (i != manualLottoCount - 1) {
+            scanner.nextLine();
+        }
+
+        return StringParsingUtils.parseToLottoNumber(inputs);
+    }
+
+    public static BonusNumber inputBonusNumber() {
+        System.out.println(INPUT_BONUS_NUMBER_MESSAGE);
+        int bonusNumber = Integer.parseInt(scanner.nextLine());
+        return new BonusNumber(new LottoNumber(bonusNumber));
+    }
+
     public static String inputWinningLottoNumber() {
         System.out.println(INPUT_WINNING_LOTTO_MESSAGE);
         return scanner.nextLine();

리뷰: @Bellroute 기존에 저희가 작성한 코드는 지적해주신 것처럼 Lottoame 책임을 InputView에서 부여하고 있습니다. 이 경우 변경사항이 생길 때, InputView와 Lotto를 생성하는 LottoGame을 모두 수정해야합니다. 
확인해보니 LottoGame에 기존에 작성해둔 Lotto 생성 메서드를 활용해서 리펙토링하였습니다.
@@ -0,0 +1,24 @@
+import UIKit
+
+extension UIImageView {
+    func loadImage(of key: String) {
+        let cacheKey = NSString(string: key)
+        if let cachedImage = ImageCacheManager.shared.object(forKey: cacheKey) {
+            self.image = cachedImage
+            return
+        }
+        
+        DispatchQueue.global().async {
+            guard let imageURL = URL(string: key),
+                  let imageData = try? Data(contentsOf: imageURL),
+                  let loadedImage = UIImage(data: imageData) else {
+                return
+            }
+            ImageCacheManager.shared.setObject(loadedImage, forKey: cacheKey)
+            
+            DispatchQueue.main.async {
+                self.image = loadedImage
+            }
+        }
+    }
+}

리뷰: `func loadImage(of key: String)` 만 보고 key가 무엇인지, key가 Image와 어떤 연관이 있는지 알기 어렵네요.
loadCachedImage(of key: String) 정도 제안드립니다.
@@ -0,0 +1,22 @@
+import UIKit
+
+extension UIStackView {
+    func style(axis: NSLayoutConstraint.Axis,
+               alignment: UIStackView.Alignment,
+               distribution: UIStackView.Distribution,
+               spacing: CGFloat = .zero) {
+        self.translatesAutoresizingMaskIntoConstraints = false
+        self.axis = axis
+        self.alignment = alignment
+        self.distribution = distribution
+        self.spacing = spacing
+    }
+    
+    func setupMargins(verticalInset: CGFloat = .zero, horizontalInset: CGFloat = .zero) {
+        self.layoutMargins = UIEdgeInsets(top: verticalInset,
+                                          left: horizontalInset,
+                                          bottom: verticalInset,
+                                          right: horizontalInset)
+        self.isLayoutMarginsRelativeArrangement = true
+    }
+}

리뷰: method를 통해 property를 set하는 이유를 추측하기 어렵네요.
@@ -0,0 +1,18 @@
+import Foundation
+
+extension URLRequest {
+    init?(api: APIProtocol) {
+        guard let url = api.url else {
+            return nil
+        }
+        
+        self.init(url: url)
+        self.httpMethod = "\(api.method)"
+        
+        if let postableAPI = api as? Postable {
+            self.addValue(postableAPI.identifier, forHTTPHeaderField: "identifier")
+            self.addValue(postableAPI.contentType, forHTTPHeaderField: "Content-Type")
+            self.httpBody = postableAPI.body
+        }
+    }
+}

리뷰: 추상화했을 때, Specific한 Type으로 casting이 필요하다는 건 추상화가 잘못되었다는 반증이 됩니다.
Header set하는 코드와 httpBody set하는 부분을 APIProtocol Level로 올려서 구한다면 Postable로의 Casting이 필요없을 것 같습니다. 이 방향으로 고민해주세요.
@@ -0,0 +1,52 @@
+import Foundation
+
+struct OpenMarketBaseURL: BaseURLProtocol {
+    var baseURL = "https://market-training.yagom-academy.kr/"
+}
+
+struct HealthCheckerAPI: Gettable {
+    var url: URL?
+    var method: HttpMethod = .get
+    
+    init(baseURL: BaseURLProtocol = OpenMarketBaseURL()) {
+        self.url = URL(string: "\(baseURL.baseURL)healthChecker")
+    }
+}
+
+struct ProductDetailAPI: Gettable {
+    var url: URL?
+    var method: HttpMethod = .get
+    
+    init(id: Int, baseURL: BaseURLProtocol = OpenMarketBaseURL()) {
+        self.url = URL(string: "\(baseURL.baseURL)api/products/\(id)")
+    }
+}
+
+struct ProductPageAPI: Gettable {
+    var url: URL?
+    var method: HttpMethod = .get
+    
+    init(pageNumber: Int, itemsPerPage: Int, baseURL: BaseURLProtocol = OpenMarketBaseURL()) {
+        var urlComponents = URLComponents(string: "\(baseURL.baseURL)api/products?")
+        let pageNumberQuery = URLQueryItem(name: "page_no", value: "\(pageNumber)")
+        let itemsPerPageQuery = URLQueryItem(name: "items_per_page", value: "\(itemsPerPage)")
+        urlComponents?.queryItems?.append(pageNumberQuery)
+        urlComponents?.queryItems?.append(itemsPerPageQuery)
+        
+        self.url = urlComponents?.url
+    }
+}
+
+struct ProductRegisterAPI: Postable {
+    var url: URL?
+    var method: HttpMethod = .post
+    var identifier: String = "1061fe9a-7215-11ec-abfa-951effcf9a75"
+    var contentType: String
+    var body: Data?
+    
+    init(boundary: String, body: Data, baseURL: BaseURLProtocol = OpenMarketBaseURL()) {
+        self.url = URL(string: "\(baseURL.baseURL)api/products")
+        self.contentType = "multipart/form-data; boundary=\(boundary)"
+        self.body = body
+    }
+}

리뷰: var / let 확인 바랍니다.
@@ -0,0 +1,37 @@
+import UIKit
+
+struct FlowCoordinator {
+    // MARK: - Properties
+    weak private var navigationController: UINavigationController?
+    private var productListViewController: ProductListViewController!
+    private var productListViewModel: ProductListViewModel!
+    private var menuSegmentedControl: MenuSegmentedControl!
+    private var menuSegmentedControlViewModel: MenuSegmentedControlViewModel!
+    
+    // MARK: - Initializers
+    init(navigationController: UINavigationController) {
+        self.navigationController = navigationController
+    }
+    
+    // MARK: - Methods
+    mutating func start() {
+        let actions = ProductListViewModelAction(showProductDetail: showProductDetail)
+        
+        productListViewModel = ProductListViewModel(actions: actions)
+        menuSegmentedControlViewModel = MenuSegmentedControlViewModel()
+        menuSegmentedControl = MenuSegmentedControl(viewModel: menuSegmentedControlViewModel)
+        productListViewController = ProductListViewController(viewModel: productListViewModel,
+                                                              menuSegmentedControl: menuSegmentedControl)
+        menuSegmentedControlViewModel.delegate = productListViewController
+
+        navigationController?.pushViewController(productListViewController, animated: false)
+    }
+    
+    func showProductDetail(with productID: Int) {
+        let productDetailViewModel = ProductDetailViewModel()
+        let productDetailViewController = ProductDetailViewController(viewModel: productDetailViewModel)
+        productDetailViewModel.setupProductID(productID)
+        
+        navigationController?.pushViewController(productDetailViewController, animated: true)
+    }
+}

리뷰: class type을 property로 들고있으니, struct보다는 class가 어울려보입니다.
@@ -0,0 +1,37 @@
+import UIKit
+
+struct FlowCoordinator {
+    // MARK: - Properties
+    weak private var navigationController: UINavigationController?
+    private var productListViewController: ProductListViewController!
+    private var productListViewModel: ProductListViewModel!
+    private var menuSegmentedControl: MenuSegmentedControl!
+    private var menuSegmentedControlViewModel: MenuSegmentedControlViewModel!
+    
+    // MARK: - Initializers
+    init(navigationController: UINavigationController) {
+        self.navigationController = navigationController
+    }
+    
+    // MARK: - Methods
+    mutating func start() {
+        let actions = ProductListViewModelAction(showProductDetail: showProductDetail)
+        
+        productListViewModel = ProductListViewModel(actions: actions)
+        menuSegmentedControlViewModel = MenuSegmentedControlViewModel()
+        menuSegmentedControl = MenuSegmentedControl(viewModel: menuSegmentedControlViewModel)
+        productListViewController = ProductListViewController(viewModel: productListViewModel,
+                                                              menuSegmentedControl: menuSegmentedControl)
+        menuSegmentedControlViewModel.delegate = productListViewController
+
+        navigationController?.pushViewController(productListViewController, animated: false)
+    }
+    
+    func showProductDetail(with productID: Int) {
+        let productDetailViewModel = ProductDetailViewModel()
+        let productDetailViewController = ProductDetailViewController(viewModel: productDetailViewModel)
+        productDetailViewModel.setupProductID(productID)
+        
+        navigationController?.pushViewController(productDetailViewController, animated: true)
+    }
+}

리뷰: !는 쓰지 않는걸로 해요.