ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -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)
+    }
+}

리뷰: FlowCoordinator 가 다르게 말하면 AppLevel의 Coordinator로 이해되는데요. ProductList의 로직과 의존성이 커서 ProductList  전용 Coordinator 처럼 보입니다. 저라면 ProductListCoordinator 정도를 만들어서 ProductList 로직을 모듈화하고요.  FlowCoordinator의 rootCoordinator를 ProductListCoordinator 로 두어서 FlowCoordinator와 ProductListCoordinator의 관계를 분리할 것 같습니다.
@@ -0,0 +1,46 @@
+import UIKit
+
+class ProductDetailImageCell: UICollectionViewCell {
+    // MARK: - Properties
+    private let productImageView: UIImageView = {
+        let imageView = UIImageView()
+        imageView.translatesAutoresizingMaskIntoConstraints = false
+        imageView.contentMode = .scaleAspectFill
+        imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor).isActive = true
+        imageView.layer.cornerRadius = 6
+        imageView.backgroundColor = .black
+        imageView.clipsToBounds = true
+        return imageView
+    }()
+    
+    // MARK: - Initializers
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        configureUI()
+    }
+        
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    // MARK: - Lifecycle Methods
+    override func prepareForReuse() {
+        super.prepareForReuse()
+        productImageView.image = nil
+    }
+    
+    // MARK: - Methods
+    func apply(with imageURL: String) {
+        productImageView.loadImage(of: imageURL)
+    }
+    
+    private func configureUI() {
+        addSubview(productImageView)
+        NSLayoutConstraint.activate([
+            productImageView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
+            productImageView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
+            productImageView.topAnchor.constraint(equalTo: self.topAnchor),
+            productImageView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
+        ])
+    }
+}

리뷰: 이 경우 init에 param이 없으므로, init coder 살려도 될 것 같아요.
required init?(coder: NSCoder) {
   super.init(coder: coder)
   configureUI()
}
@@ -0,0 +1,57 @@
+import Foundation
+import RxSwift
+import UIKit
+
+class ProductDetailViewModel {
+    // MARK: - Nested Types
+    struct Input {
+        let invokedViewDidLoad: Observable<Void>
+        let cellDidScroll: Observable<IndexPath>
+    }
+    
+    struct Output {
+        let product: Observable<DetailViewProduct>
+        let productImages: Observable<[ProductImage]>
+    }
+    
+    // MARK: - Properties
+    private var productID: Int!
+    private let disposeBag = DisposeBag()
+    
+    // MARK: - Methods
+    func setupProductID(_ productID: Int) {
+        self.productID = productID
+    }
+    
+    func transform(_ input: Input) -> Output {
+        let product = PublishSubject<DetailViewProduct>()
+        let productImages = PublishSubject<[ProductImage]>()
+        
+        configureViewDidLoadObserver(by: input.invokedViewDidLoad, productOutput: product, productImages: productImages)
+        
+        let output = Output(product: product.asObservable(), productImages: productImages.asObservable())
+        
+        return output
+    }
+    
+    private func configureViewDidLoadObserver(by inputObserver: Observable<Void>,
+                                              productOutput: PublishSubject<DetailViewProduct>,
+                                              productImages: PublishSubject<[ProductImage]>) {
+        inputObserver
+            .subscribe(onNext: {[weak self] _ in
+                guard let self = self else { return }
+                _ = self.fetchProduct(with: self.productID).subscribe(onNext: { productDetail in
+                    productOutput.onNext(productDetail)
+                    productImages.onNext(productDetail.images)
+                })
+            })
+            .disposed(by: disposeBag)
+    }
+    
+    private func fetchProduct(with id: Int) -> Observable<DetailViewProduct> {
+        let networkProvider = NetworkProvider()
+        let observable = networkProvider.fetchData(api: ProductDetailAPI(id: id), decodingType: DetailViewProduct.self)
+                                        
+        return observable
+    }
+}

리뷰: inputObserver 와 fetchProduct 를 flatMap으로 엮어서 product observer를 만들 수 있을 것 같네요. 고민해봐주세요. subscribe는 꼭 필요한 위치에만 해주세요.
@@ -0,0 +1,57 @@
+import Foundation
+import RxSwift
+import UIKit
+
+class ProductDetailViewModel {
+    // MARK: - Nested Types
+    struct Input {
+        let invokedViewDidLoad: Observable<Void>
+        let cellDidScroll: Observable<IndexPath>
+    }
+    
+    struct Output {
+        let product: Observable<DetailViewProduct>
+        let productImages: Observable<[ProductImage]>
+    }
+    
+    // MARK: - Properties
+    private var productID: Int!
+    private let disposeBag = DisposeBag()
+    
+    // MARK: - Methods
+    func setupProductID(_ productID: Int) {
+        self.productID = productID
+    }
+    
+    func transform(_ input: Input) -> Output {
+        let product = PublishSubject<DetailViewProduct>()
+        let productImages = PublishSubject<[ProductImage]>()
+        
+        configureViewDidLoadObserver(by: input.invokedViewDidLoad, productOutput: product, productImages: productImages)
+        
+        let output = Output(product: product.asObservable(), productImages: productImages.asObservable())
+        
+        return output
+    }
+    
+    private func configureViewDidLoadObserver(by inputObserver: Observable<Void>,
+                                              productOutput: PublishSubject<DetailViewProduct>,
+                                              productImages: PublishSubject<[ProductImage]>) {
+        inputObserver
+            .subscribe(onNext: {[weak self] _ in
+                guard let self = self else { return }
+                _ = self.fetchProduct(with: self.productID).subscribe(onNext: { productDetail in
+                    productOutput.onNext(productDetail)
+                    productImages.onNext(productDetail.images)
+                })
+            })
+            .disposed(by: disposeBag)
+    }
+    
+    private func fetchProduct(with id: Int) -> Observable<DetailViewProduct> {
+        let networkProvider = NetworkProvider()
+        let observable = networkProvider.fetchData(api: ProductDetailAPI(id: id), decodingType: DetailViewProduct.self)
+                                        
+        return observable
+    }
+}

리뷰: output 파라미터가 많이 보이는데요. 마치 inout 키워드와 비슷한 기능을 하는 것으로 좋은 코드로 보이지 않습니다. output을 하고싶다면 return type을 쓰면 됩니다.
@@ -0,0 +1,53 @@
+import UIKit
+
+final class BannerCell: UICollectionViewCell {
+    // MARK: - Properties
+    private let stackView: UIStackView = {
+        let stackView = UIStackView()
+        stackView.style(axis: .vertical, alignment: .fill, distribution: .fill)
+        return stackView
+    }()
+    private let imageView: UIImageView = {
+        let imageView = UIImageView()
+        imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor).isActive = true
+        imageView.translatesAutoresizingMaskIntoConstraints = false
+        imageView.contentMode = .scaleAspectFill
+        return imageView
+    }()
+    
+    private(set) var productID: Int = 0
+    
+    // MARK: - Initializers
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        configureUI()
+    }
+    
+    @available(*, unavailable)
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    // MARK: - Lifecycle Methods
+    override func prepareForReuse() {
+        super.prepareForReuse()
+        imageView.image = nil
+    }
+    
+    // MARK: - Methods
+    func apply(imageURL: String, productID: Int) {
+        imageView.loadImage(of: imageURL)
+        self.productID = productID
+    }
+    
+    private func configureUI() {
+        addSubview(stackView)
+        stackView.addArrangedSubview(imageView)
+        NSLayoutConstraint.activate([
+            stackView.topAnchor.constraint(equalTo: self.topAnchor),
+            stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
+            stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
+            stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
+        ])
+    }
+}

리뷰: reusable cell은 storable property를 들고있을 이유가 거의 없습니다.
cell은 UI 껍데기 일 뿐이지, 특정 데이터를 대표할  수 없습니다. 

apply쪽에는 UI 세팅을 위한 ViewModel 만 전달 하면 되고요.
이 cell이 어떤 productID로 구성되었는지 궁금하다면, cell의 indexPath를 이용해서 직접 얻어내면 됩니다.
@@ -0,0 +1,53 @@
+import UIKit
+import RxSwift
+import RxCocoa
+
+final class FooterView: UICollectionReusableView {
+    // MARK: - Properties
+    private let bannerPageControl: UIPageControl = {
+        let pageControl = UIPageControl()
+        pageControl.translatesAutoresizingMaskIntoConstraints = false
+        pageControl.pageIndicatorTintColor = .systemGray2
+        pageControl.currentPageIndicatorTintColor = CustomColor.darkGreenColor
+        pageControl.currentPage = 0
+        pageControl.isUserInteractionEnabled = false
+        return pageControl
+    }()
+    
+    private let disposeBag = DisposeBag()
+    
+    // MARK: - Initializers
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        configureUI()
+    }
+    
+    @available(*, unavailable)
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+    
+    // MARK: - Methods
+    func bind(input: Observable<Int>, indexPath: IndexPath, pageNumber: Int) {
+        bannerPageControl.numberOfPages = pageNumber
+        if indexPath.section == 1 {
+            self.isHidden = true
+        } else {
+            input
+                .subscribe(onNext: { [weak self] currentPage in
+                    self?.bannerPageControl.currentPage = currentPage
+                })
+                .disposed(by: disposeBag)
+        }
+    }
+    
+    private func configureUI() {
+        addSubview(bannerPageControl)
+        NSLayoutConstraint.activate([
+            bannerPageControl.topAnchor.constraint(equalTo: topAnchor),
+            bannerPageControl.bottomAnchor.constraint(equalTo: bottomAnchor),
+            bannerPageControl.leadingAnchor.constraint(equalTo: leadingAnchor),
+            bannerPageControl.trailingAnchor.constraint(equalTo: trailingAnchor)
+        ])
+    }
+}

리뷰: bind가 N번 호출되었을 때 subscribe가 N번 될 가능성은 없을지 검토 바랍니다.
@@ -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
+    }
+}

리뷰: 이부분은 프로퍼티를 클로저로 생성하는 부분에서 반복되는 부분이 많아 메서드를 통해 프로퍼티를 set할 수 있도록 했습니다!!
@@ -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
+            }
+        }
+    }
+}

리뷰: 말씀해주신대로 메서드이름만 봐서는 Cache 기능을 내포하는 것을 알 수 없어서 loadCachedImage(of key: String)로 수정했습니다!
@@ -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
+    }
+}

리뷰: 모두 initializer에서 지정하는 프로퍼티라서 let으로 변경했습니다.
@@ -0,0 +1,72 @@
+package com.frame2.server.core.board.api;
+
+import com.frame2.server.core.board.application.ProductReviewService;
+import com.frame2.server.core.board.payload.request.ProductReviewModifyRequest;
+import com.frame2.server.core.board.payload.request.ProductReviewRequest;
+import com.frame2.server.core.board.payload.response.ProductReviewResponse;
+import com.frame2.server.core.support.annotations.Auth;
+import com.frame2.server.core.support.annotations.MemberOnly;
+import com.frame2.server.core.support.request.User;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/products/{saleProductId}/reviews")
+public class ProductReviewApi implements ProductReviewApiSpec {
+
+    private final ProductReviewService productReviewService;
+
+    // create
+    @Override
+    @MemberOnly
+    @PostMapping
+    public void createProductReview(
+            @Valid @RequestBody ProductReviewRequest productReviewRequest,
+            @Auth User user,
+            @PathVariable("saleProductId") Long saleProductId
+    ) {
+        productReviewService.productReviewCreate(productReviewRequest, user.id(), saleProductId);
+    }
+
+    // update
+    @Override
+    @MemberOnly
+    @PatchMapping("/{productReviewId}")
+    public void updateProductReview(
+            @Valid @RequestBody ProductReviewModifyRequest ProductReviewModifyRequest,
+            @Auth User user,
+            @PathVariable("productReviewId") Long productReviewId
+    ) {
+        productReviewService.productReviewModify(ProductReviewModifyRequest, productReviewId);
+    }
+
+    // delete
+    @Override
+    @MemberOnly
+    @DeleteMapping("/{productReviewId}")
+    public void deleteProductReview(@PathVariable("productReviewId") Long id, @Auth User user) {
+        productReviewService.remove(id);
+    }
+
+    // 단건 조회
+    @Override
+    @GetMapping("/{productReviewId}")
+    public ResponseEntity<ProductReviewResponse> getProductReview(@PathVariable("productReviewId") Long productReviewId) {
+        ProductReviewResponse productReviewResponse = productReviewService.getProductReview(productReviewId);
+        return ResponseEntity.ok().body(productReviewResponse);
+    }
+
+    // 한 상품에 대한 전체 리뷰 조회
+    @Override
+    @GetMapping
+    public ResponseEntity<List<ProductReviewResponse>> getProductReviewList(
+            @PathVariable("saleProductId") Long saleProductId) {
+        List<ProductReviewResponse> productReviewList = productReviewService.getProductReviewList(saleProductId);
+        return ResponseEntity.ok().body(productReviewList);
+    }
+}

리뷰: @PatchMapping에 엔드포인트가 정의되어 있는거 같지 않습니다 !
/reviews로 들어가서 어떤 리뷰를 수정하는 걸까요?
@@ -0,0 +1,72 @@
+package com.frame2.server.core.board.api;
+
+import com.frame2.server.core.board.application.ProductReviewService;
+import com.frame2.server.core.board.payload.request.ProductReviewModifyRequest;
+import com.frame2.server.core.board.payload.request.ProductReviewRequest;
+import com.frame2.server.core.board.payload.response.ProductReviewResponse;
+import com.frame2.server.core.support.annotations.Auth;
+import com.frame2.server.core.support.annotations.MemberOnly;
+import com.frame2.server.core.support.request.User;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/products/{saleProductId}/reviews")
+public class ProductReviewApi implements ProductReviewApiSpec {
+
+    private final ProductReviewService productReviewService;
+
+    // create
+    @Override
+    @MemberOnly
+    @PostMapping
+    public void createProductReview(
+            @Valid @RequestBody ProductReviewRequest productReviewRequest,
+            @Auth User user,
+            @PathVariable("saleProductId") Long saleProductId
+    ) {
+        productReviewService.productReviewCreate(productReviewRequest, user.id(), saleProductId);
+    }
+
+    // update
+    @Override
+    @MemberOnly
+    @PatchMapping("/{productReviewId}")
+    public void updateProductReview(
+            @Valid @RequestBody ProductReviewModifyRequest ProductReviewModifyRequest,
+            @Auth User user,
+            @PathVariable("productReviewId") Long productReviewId
+    ) {
+        productReviewService.productReviewModify(ProductReviewModifyRequest, productReviewId);
+    }
+
+    // delete
+    @Override
+    @MemberOnly
+    @DeleteMapping("/{productReviewId}")
+    public void deleteProductReview(@PathVariable("productReviewId") Long id, @Auth User user) {
+        productReviewService.remove(id);
+    }
+
+    // 단건 조회
+    @Override
+    @GetMapping("/{productReviewId}")
+    public ResponseEntity<ProductReviewResponse> getProductReview(@PathVariable("productReviewId") Long productReviewId) {
+        ProductReviewResponse productReviewResponse = productReviewService.getProductReview(productReviewId);
+        return ResponseEntity.ok().body(productReviewResponse);
+    }
+
+    // 한 상품에 대한 전체 리뷰 조회
+    @Override
+    @GetMapping
+    public ResponseEntity<List<ProductReviewResponse>> getProductReviewList(
+            @PathVariable("saleProductId") Long saleProductId) {
+        List<ProductReviewResponse> productReviewList = productReviewService.getProductReviewList(saleProductId);
+        return ResponseEntity.ok().body(productReviewList);
+    }
+}

리뷰: `createProductReview()`, `getProductReview()`와 같이 `update()`와 `delete()`에도 `ProductReview`를 추가하면 좀 더 일관성 있는 메서드 이름이 될 것 같은데 어떠세요~?👀