Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions Projects/DataSource/Sources/DTO/ReportDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// ReportDTO.swift
// DataSource
//
// Created by 최정인 on 11/20/25.
//

import Domain

struct ReportDTO: Decodable {
let reportId: Int?
let reportDate: String?
let reportTitle: String
let reportContent: String?
let reportLocation: String
let reportStatus: String
let reportCategory: String
let reportImageUrl: String?
let reportImageUrls: [String]?
let latitude: Double?
let longitude: Double?

func toReportEntity() throws -> ReportEntity {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엔티티 변환 실패 가능성을 염두에 두셨군요!
추후에 다른 DTO -> Entity 에도 throw를 할 수 있게 하고, 따로 Error를 정의해서 로그를 찍는것도 좋을 것 같습니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

조아유 !!!!!!!!!

guard let reportId else { throw NetworkError.decodingError }
return ReportEntity(
id: reportId,
title: reportTitle,
date: reportDate,
type: ReportType(rawValue: reportCategory) ?? .transportation,
progress: ReportProgress(rawValue: reportStatus) ?? .received,
content: reportContent,
location: LocationEntity(
longitude: longitude,
latitude: latitude,
address: reportLocation),
photoUrls: reportImageUrls ?? [])
}
}
53 changes: 53 additions & 0 deletions Projects/DataSource/Sources/Endpoint/ReportEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// ReportEndpoint.swift
// DataSource
//
// Created by 최정인 on 11/20/25.
//

enum ReportEndpoint {
case fetchReportDetail(reportId: Int)
}

extension ReportEndpoint: Endpoint {
var baseURL: String {
switch self {
case .fetchReportDetail:
return AppProperties.baseURL + "/api/v2/reports"
}
}

var path: String {
switch self {
case .fetchReportDetail(let reportId):
"\(baseURL)/\(reportId)"
}
}

var method: HTTPMethod {
switch self {
case .fetchReportDetail:
.get
}
}

var headers: [String : String] {
let headers: [String: String] = [
"Content-Type": "application/json",
"accept": "*/*"
]
return headers
}

var queryParameters: [String : String] {
return [:]
}

var bodyParameters: [String : Any] {
return [:]
}

var isAuthorized: Bool {
return true
}
}
10 changes: 9 additions & 1 deletion Projects/DataSource/Sources/Repository/ReportRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@
import Domain

final class ReportRepository: ReportRepositoryProtocol {
func report(reportEntity: Domain.ReportEntity) async {
private let networkService = NetworkService.shared

func report(reportEntity: ReportEntity) async {

}

func fetchReportDetail(reportId: Int) async throws -> ReportEntity? {
let endpoint = ReportEndpoint.fetchReportDetail(reportId: reportId)
guard let response = try await networkService.request(endpoint: endpoint, type: ReportDTO.self) else { return nil }
return try response.toReportEntity()
}
}
10 changes: 5 additions & 5 deletions Projects/Domain/Sources/Entity/Enum/ReportProgress.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
// Created by 이동현 on 11/15/25.
//

public enum ReportProgress: CaseIterable {
case entire
case received
case inProgress
case completed
public enum ReportProgress: String, CaseIterable {
case entire = "ENTIRE"
case received = "PENDING"
case inProgress = "IN_PROGRESS"
case completed = "COMPLETED"

public var description: String {
switch self {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@

public protocol ReportRepositoryProtocol {
func report(reportEntity: ReportEntity) async

/// 제보 상세 기록을 조회합니다.
/// - Parameter reportId: 조회할 제보의 ID
/// - Returns: 조회된 제보
func fetchReportDetail(reportId: Int) async throws -> ReportEntity?
}
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,10 @@ public struct PresentationDependencyAssembler: DependencyAssemblerProtocol {
}

DIContainer.shared.register(type: ReportDetailViewModel.self) { container in
return ReportDetailViewModel()
guard let reportRepository = container.resolve(type: ReportRepositoryProtocol.self)
else { fatalError("reportRepository 의존성이 등록되지 않았습니다.") }

return ReportDetailViewModel(reportRepository: reportRepository)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,7 @@ extension HomeViewController: FloatingMenuViewDelegate {
guard let reportDetailViewModel = DIContainer.shared.resolve(type: ReportDetailViewModel.self)
else { fatalError("reportDetailViewModel 의존성이 등록되지 않았습니다.") }

let reportDetailViewController = ReportDetailViewController(viewModel: reportDetailViewModel)
let reportDetailViewController = ReportDetailViewController(viewModel: reportDetailViewModel, reportId: 1)
reportDetailViewController.hidesBottomBarWhenPushed = true

self.navigationController?.pushViewController(reportDetailViewController, animated: true)
Expand Down
2 changes: 2 additions & 0 deletions Projects/Presentation/Sources/Report/Model/ReportDetail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import Domain
struct ReportDetail {
let date: String
let title: String
let status: ReportProgress
let category: ReportType
let description: String
let location: String
let photoUrls: [String]
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Combine
import Kingfisher
import SnapKit
import UIKit

Expand All @@ -20,8 +21,6 @@ class ReportDetailViewController: BaseViewController<ReportDetailViewModel> {
static let contentStackViewTopSpacing: CGFloat = 23
static let contentStackViewBottomSpacing: CGFloat = 40
static let reportStatusViewTopSpacing: CGFloat = 20
static let reportStatusViewWidth: CGFloat = 65
static let reportStatusViewHeight: CGFloat = 26
static let dateLabelTopSpacing: CGFloat = 6
static let reportContentBackgroudViewTopSpacing: CGFloat = 8
static let reportContentDescriptionVerticalMargin: CGFloat = 16
Expand All @@ -42,14 +41,14 @@ class ReportDetailViewController: BaseViewController<ReportDetailViewModel> {
case .description:
return "상세 제보 내용"
case .location:
return " 위치"
return "신고 위치"
}
}
}

private let scrollView = UIScrollView()
private let contentView = UIView()
private let reportStatusView = UIView()
private let reportStatusView = ReportProgressView()
private let dateLabel = UILabel()
private let photoStackView = UIStackView()
private let contentStackView = UIStackView()
Expand All @@ -58,21 +57,27 @@ class ReportDetailViewController: BaseViewController<ReportDetailViewModel> {
private let detailDescriptionLabel = UILabel()
private let locationLabel = UILabel()
private var cancellables: Set<AnyCancellable> = []
private let reportId: Int

init(viewModel: ReportDetailViewModel, reportId: Int) {
self.reportId = reportId
super.init(viewModel: viewModel)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func viewDidLoad() {
super.viewDidLoad()
viewModel.action(input: .fetchReportDetail)
viewModel.action(input: .fetchReportDetail(reportId: reportId))
}

override func configureAttribute() {
view.backgroundColor = .white
configureCustomNavigationBar(navigationBarStyle: .withBackButton(title: "제보하기"))

scrollView.showsVerticalScrollIndicator = false
// TODO: 추후 공통 component로 교체
reportStatusView.layer.masksToBounds = true
reportStatusView.layer.cornerRadius = 6
reportStatusView.backgroundColor = BitnagilColor.green10

dateLabel.font = BitnagilFont(style: .body1, weight: .semiBold).font
dateLabel.textColor = BitnagilColor.gray10
Expand Down Expand Up @@ -110,8 +115,6 @@ class ReportDetailViewController: BaseViewController<ReportDetailViewModel> {

reportStatusView.snp.makeConstraints { make in
make.top.equalTo(contentView).offset(Layout.reportStatusViewTopSpacing)
make.width.equalTo(Layout.reportStatusViewWidth)
make.height.equalTo(Layout.reportStatusViewHeight)
make.leading.equalTo(contentView).offset(Layout.horizontalMargin)
}

Expand Down Expand Up @@ -196,19 +199,26 @@ class ReportDetailViewController: BaseViewController<ReportDetailViewModel> {
private func fillReportContent(reportDetail: ReportDetail?) {
guard let reportDetail else { return }

let photoView = UIView()
photoView.backgroundColor = BitnagilColor.gray30
photoView.layer.masksToBounds = true
photoView.layer.cornerRadius = 9.25
photoView.snp.makeConstraints { make in
make.size.equalTo(Layout.photoSize)
}
photoStackView.addArrangedSubview(photoView)

reportStatusView.configure(with: reportDetail.status)
dateLabel.text = reportDetail.date
titleContentLabel.text = reportDetail.title
categoryLabel.text = reportDetail.category.name
detailDescriptionLabel.attributedText = BitnagilFont(style: .body2, weight: .medium).attributedString(text: reportDetail.description)
detailDescriptionLabel.attributedText = BitnagilFont(style: .body2, weight: .medium)
.attributedString(text: reportDetail.description)
locationLabel.text = reportDetail.location

reportDetail.photoUrls.forEach { photoUrl in
let photoView = UIImageView()

if let url = URL(string: photoUrl) {
photoView.kf.setImage(with: url)
}
photoView.layer.masksToBounds = true
photoView.layer.cornerRadius = 9.25
photoView.snp.makeConstraints { make in
make.size.equalTo(Layout.photoSize)
}
photoStackView.addArrangedSubview(photoView)
}
}
Comment on lines 199 to 223
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

fillReportContent에서 사진 뷰 중복 추가와 SnapKit 적용 순서 개선이 필요합니다

현재 구현에는 두 가지 우려가 있습니다.

  1. 중복 이미지 뷰 누적 가능성 (Line 210-222)
    reportDetailPublisher가 여러 번 값을 방출하면(재시도, 재진입 등) fillReportContent가 반복 호출되는데, 기존 photoStackViewarrangedSubviews를 정리하지 않고 새 UIImageView만 계속 추가하고 있습니다. 이 경우 같은 사진이 여러 번 쌓이거나 메모리 사용이 불필요하게 늘어날 수 있습니다.

  2. SnapKit 제약을 superview에 추가하기 전에 설정 (Line 218-221)
    photoView.snp.makeConstraints를 호출한 뒤에 photoStackView.addArrangedSubview(photoView)를 호출하는 순서인데, 팀 내 SnapKit 가이드/learning에 따르면 뷰를 superview(혹은 stackView)에 먼저 추가한 뒤 제약을 잡는 패턴을 권장하고 있습니다. StackView의 arrangedSubviews도 마찬가지로 추가 후 제약을 잡는 편이 안정적입니다. (Based on learnings)

아래처럼 기존 뷰를 먼저 비우고, addArrangedSubview 이후에 제약을 설정하는 쪽을 추천드립니다.

     private func fillReportContent(reportDetail: ReportDetail?) {
         guard let reportDetail else { return }
@@
         detailDescriptionLabel.attributedText = BitnagilFont(style: .body2, weight: .medium)
             .attributedString(text: reportDetail.description)
         locationLabel.text = reportDetail.location
-
-        reportDetail.photoUrls.forEach { photoUrl in
-            let photoView = UIImageView()
-
-            if let url = URL(string: photoUrl) {
-                photoView.kf.setImage(with: url)
-            }
-            photoView.layer.masksToBounds = true
-            photoView.layer.cornerRadius = 9.25
-            photoView.snp.makeConstraints { make in
-                make.size.equalTo(Layout.photoSize)
-            }
-            photoStackView.addArrangedSubview(photoView)
-        }
+        // 기존 이미지 정리
+        photoStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
+
+        reportDetail.photoUrls.forEach { photoUrl in
+            let photoView = UIImageView()
+            photoView.layer.masksToBounds = true
+            photoView.layer.cornerRadius = 9.25
+            photoView.contentMode = .scaleAspectFill
+
+            if let url = URL(string: photoUrl) {
+                photoView.kf.setImage(with: url)
+            }
+
+            photoStackView.addArrangedSubview(photoView)
+            photoView.snp.makeConstraints { make in
+                make.size.equalTo(Layout.photoSize)
+            }
+        }

이제 파일의 실제 코드를 검증하겠습니다.

실제 코드를 확인하기 위해 코드베이스를 검색하겠습니다.


🏁 Script executed:

#!/bin/bash
# ReportDetailViewController 파일 내용 확인
find . -name "ReportDetailViewController.swift" -type f -exec cat {} \;

Length of output: 8204


이제 UIStackView와 SnapKit의 제약 설정 순서에 대해 기술적으로 검증하겠습니다.


이제 중복 누적 문제를 더 자세히 검증하겠습니다.



이미지 뷰 중복 추가와 SnapKit 제약 순서 개선이 필요합니다

검증 결과, 현재 구현의 두 가지 문제가 확인되었습니다:

  1. 중복 이미지 뷰 누적 가능성
    reportDetailPublisher가 여러 번 값을 방출할 수 있는 Combine 패턴(재시도, 에러 처리 등)을 사용하고 있습니다. 매번 publish할 때마다 fillReportContent가 호출되는데, 기존 photoStackViewarrangedSubviews를 초기화하지 않고 새 UIImageView만 계속 추가하고 있습니다. 같은 사진이 여러 번 쌓이거나 메모리가 낭비될 수 있습니다.

  2. SnapKit 제약을 arrangedSubview 추가 전에 설정
    현재는 photoView.snp.makeConstraints를 호출한 뒤에 photoStackView.addArrangedSubview(photoView)를 호출하고 있습니다. UIStackView가 arrangedSubviews의 Auto Layout 제약을 자동으로 관리하기 때문에, 팀 내 learnings에 따르면 뷰를 먼저 추가한 후 제약을 잡는 패턴이 더 안정적입니다.

아래와 같이 기존 뷰를 먼저 비우고, addArrangedSubview 이후에 제약을 설정하는 것을 추천합니다:

     private func fillReportContent(reportDetail: ReportDetail?) {
         guard let reportDetail else { return }
@@
         detailDescriptionLabel.attributedText = BitnagilFont(style: .body2, weight: .medium)
             .attributedString(text: reportDetail.description)
         locationLabel.text = reportDetail.location
-
-        reportDetail.photoUrls.forEach { photoUrl in
-            let photoView = UIImageView()
-
-            if let url = URL(string: photoUrl) {
-                photoView.kf.setImage(with: url)
-            }
-            photoView.layer.masksToBounds = true
-            photoView.layer.cornerRadius = 9.25
-            photoView.snp.makeConstraints { make in
-                make.size.equalTo(Layout.photoSize)
-            }
-            photoStackView.addArrangedSubview(photoView)
-        }
+        // 기존 이미지 정리
+        photoStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
+
+        reportDetail.photoUrls.forEach { photoUrl in
+            let photoView = UIImageView()
+            photoView.layer.masksToBounds = true
+            photoView.layer.cornerRadius = 9.25
+            photoView.contentMode = .scaleAspectFill
+
+            if let url = URL(string: photoUrl) {
+                photoView.kf.setImage(with: url)
+            }
+
+            photoStackView.addArrangedSubview(photoView)
+            photoView.snp.makeConstraints { make in
+                make.size.equalTo(Layout.photoSize)
+            }
+        }

🏁 Script executed:

#!/bin/bash
# ReportDetailViewController의 viewModel 동작 확인
# bind() 메서드와 publisher 호출 패턴 확인
grep -n "reportDetailPublisher" . -r --include="*.swift" -B2 -A2

Length of output: 1648


🌐 Web query:

UIStackView arrangedSubviews SnapKit constraints order addArrangedSubview

💡 Result:

Short answer

  • Add the view to the stack (addArrangedSubview/insertArrangedSubview) before you create constraints that reference the stack or other arranged subviews. The arranged subview must be in the view hierarchy to make meaningful constraints to its superview/peers. [1][2][3]
  • Prefer setting constraints for the arranged subview’s internal content (its own subviews) — let UIStackView control the arranged subview’s position/size. Only add extra constraints that affect intrinsic sizing (width/height) or alignment when needed. [1][4]
  • If you create constraints that relate two arranged subviews to each other or to the stack, ensure both are added to the stack first; call layoutIfNeeded() (or rely on Auto Layout updates) if you need immediate frames. [1][3]

Why

  • UIStackView manages size/position of its arrangedSubviews; you should not fight it by constraining an arrangedSubview’s frame externally unless you’re changing intrinsic size (height/width) or alignment. [1]
  • SnapKit will disable translatesAutoresizingMaskIntoConstraints when you install constraints, but constraints that reference a superview or sibling assume the views are in the same hierarchy — so add them to the stack before making such constraints. [2][3]

Minimal example (recommended pattern)

  • Add the view to the stack, then constrain its internal contents:

let stack = UIStackView()
stack.axis = .vertical
view.addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}

let card = UIView()
stack.addArrangedSubview(card)

// constrain card's internal content (preferred)
let label = UILabel()
card.addSubview(label)
label.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(8)
}

// if you must constrain card's size, do it after adding:
card.snp.makeConstraints { make in
make.height.equalTo(80) // acceptable: sets intrinsic size the stack will honor
}

Notes / pitfalls

  • Don’t add subviews with addSubview instead of addArrangedSubview — they won’t be arranged by the stack. [4]
  • Avoid constraining an arrangedSubview’s top/leading to the stack’s anchors to “position” it; change stack alignment/distribution or the subview’s intrinsic size instead.
  • If you create constraints between two arranged subviews, add both to the stack first, then create constraints, and call layoutIfNeeded() if you need immediate layout results. [1][3]

Sources

  • Apple Auto Layout Guide — Stack Views. [1]
  • SnapKit docs. [2]
  • Practical examples / community Q&A on using constraints with UIStackView and arrangedSubviews. [3][4]

이미지 뷰 중복 추가와 SnapKit 제약 순서 개선이 필요합니다

검증 결과, 현재 구현의 두 가지 문제가 모두 확인되었습니다:

  1. 중복 이미지 뷰 누적 가능성
    ReportDetailViewModel에서 reportDetailSubject를 사용한 Combine 패턴으로 publisher가 여러 번 값을 방출할 수 있습니다(재시도, 에러 처리 등). 매번 publish할 때마다 fillReportContent가 호출되는데, 기존 photoStackViewarrangedSubviews를 초기화하지 않고 새 UIImageView만 계속 추가합니다. 같은 사진이 여러 번 쌓이거나 메모리가 불필요하게 낭비될 수 있습니다.

  2. SnapKit 제약을 arrangedSubview 추가 전에 설정
    현재는 photoView.snp.makeConstraints를 호출한 뒤에 photoStackView.addArrangedSubview(photoView)를 호출하고 있습니다. Apple Auto Layout Guide와 SnapKit 문서에 따르면, arrangedSubview는 먼저 스택에 추가한 후 크기/높이 제약(intrinsic sizing)을 설정하는 것이 권장됩니다. UIStackView가 arrangedSubview의 위치/크기를 자동으로 관리하기 때문입니다. 팀 내 learnings(PR 46, 68)에서도 뷰를 먼저 추가한 후 제약을 잡는 패턴을 권장하고 있습니다.

아래와 같이 기존 뷰를 먼저 비우고, addArrangedSubview 이후에 제약을 설정하는 것을 추천합니다:

     private func fillReportContent(reportDetail: ReportDetail?) {
         guard let reportDetail else { return }
@@
         detailDescriptionLabel.attributedText = BitnagilFont(style: .body2, weight: .medium)
             .attributedString(text: reportDetail.description)
         locationLabel.text = reportDetail.location
-
-        reportDetail.photoUrls.forEach { photoUrl in
-            let photoView = UIImageView()
-
-            if let url = URL(string: photoUrl) {
-                photoView.kf.setImage(with: url)
-            }
-            photoView.layer.masksToBounds = true
-            photoView.layer.cornerRadius = 9.25
-            photoView.snp.makeConstraints { make in
-                make.size.equalTo(Layout.photoSize)
-            }
-            photoStackView.addArrangedSubview(photoView)
-        }
+        // 기존 이미지 정리
+        photoStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
+
+        reportDetail.photoUrls.forEach { photoUrl in
+            let photoView = UIImageView()
+            photoView.layer.masksToBounds = true
+            photoView.layer.cornerRadius = 9.25
+            photoView.contentMode = .scaleAspectFill
+
+            if let url = URL(string: photoUrl) {
+                photoView.kf.setImage(with: url)
+            }
+
+            photoStackView.addArrangedSubview(photoView)
+            photoView.snp.makeConstraints { make in
+                make.size.equalTo(Layout.photoSize)
+            }
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func fillReportContent(reportDetail: ReportDetail?) {
guard let reportDetail else { return }
let photoView = UIView()
photoView.backgroundColor = BitnagilColor.gray30
photoView.layer.masksToBounds = true
photoView.layer.cornerRadius = 9.25
photoView.snp.makeConstraints { make in
make.size.equalTo(Layout.photoSize)
}
photoStackView.addArrangedSubview(photoView)
reportStatusView.configure(with: reportDetail.status)
dateLabel.text = reportDetail.date
titleContentLabel.text = reportDetail.title
categoryLabel.text = reportDetail.category.name
detailDescriptionLabel.attributedText = BitnagilFont(style: .body2, weight: .medium).attributedString(text: reportDetail.description)
detailDescriptionLabel.attributedText = BitnagilFont(style: .body2, weight: .medium)
.attributedString(text: reportDetail.description)
locationLabel.text = reportDetail.location
reportDetail.photoUrls.forEach { photoUrl in
let photoView = UIImageView()
if let url = URL(string: photoUrl) {
photoView.kf.setImage(with: url)
}
photoView.layer.masksToBounds = true
photoView.layer.cornerRadius = 9.25
photoView.snp.makeConstraints { make in
make.size.equalTo(Layout.photoSize)
}
photoStackView.addArrangedSubview(photoView)
}
}
private func fillReportContent(reportDetail: ReportDetail?) {
guard let reportDetail else { return }
reportStatusView.configure(with: reportDetail.status)
dateLabel.text = reportDetail.date
titleContentLabel.text = reportDetail.title
categoryLabel.text = reportDetail.category.name
detailDescriptionLabel.attributedText = BitnagilFont(style: .body2, weight: .medium)
.attributedString(text: reportDetail.description)
locationLabel.text = reportDetail.location
// 기존 이미지 정리
photoStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
reportDetail.photoUrls.forEach { photoUrl in
let photoView = UIImageView()
photoView.layer.masksToBounds = true
photoView.layer.cornerRadius = 9.25
photoView.contentMode = .scaleAspectFill
if let url = URL(string: photoUrl) {
photoView.kf.setImage(with: url)
}
photoStackView.addArrangedSubview(photoView)
photoView.snp.makeConstraints { make in
make.size.equalTo(Layout.photoSize)
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Domain

final class ReportDetailViewModel: ViewModel {
enum Input {
case fetchReportDetail
case fetchReportDetail(reportId: Int)
}

struct Output {
Expand All @@ -19,28 +19,40 @@ final class ReportDetailViewModel: ViewModel {

private(set) var output: Output
private let reportDetailSubject = CurrentValueSubject<ReportDetail?, Never>(nil)
private let reportRepository: ReportRepositoryProtocol

init() {
init(reportRepository: ReportRepositoryProtocol) {
self.reportRepository = reportRepository
self.output = Output(
reportDetailPublisher: reportDetailSubject.eraseToAnyPublisher()
)
}

func action(input: Input) {
switch input {
case .fetchReportDetail:
fetchReportDetail()
case .fetchReportDetail(let reportId):
fetchReportDetail(reportId: reportId)
}
}

private func fetchReportDetail() {
let report = ReportDetail(
date: "2025.11.03 (금)",
title: "가로등이 깜빡거려요.",
category: .water,
description: "가로등이 깜박거리고 치지직 거려서 곧 터질 것 같아요... 햇살이 유리창 너머로 스며들며 방 안을 부드럽게 채운다. 커피 향이 퍼지고, 어제의 고민이 조금은 멀게 느껴진다. 오늘은 완벽하지 않아도 괜찮다. 천천히 숨을 고르고, 다시 한 걸음 내딛으면 된다. 이게 뭐람.",
location: "서울특별시 강남구 삼성동")

reportDetailSubject.send(report)
private func fetchReportDetail(reportId: Int) {
Task {
do {
if let reportEntity = try await reportRepository.fetchReportDetail(reportId: reportId) {
let reportDetail = ReportDetail(
date: reportEntity.date ?? "",
title: reportEntity.title,
status: reportEntity.progress,
category: reportEntity.type,
description: reportEntity.content ?? "",
location: reportEntity.location.address ?? "",
photoUrls: reportEntity.photoUrls)
reportDetailSubject.send(reportDetail)
}
reportDetailSubject.send(nil)
} catch {
reportDetailSubject.send(nil)
}
}
}
Comment on lines +38 to 57
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

fetchReportDetail(reportId:)가 항상 nil을 발행하는 버그와 Task 캡처 패턴을 함께 정리하는 것이 좋겠습니다

현재 구현에는 두 가지 문제가 있습니다.

  1. 성공 시에도 곧바로 nil을 발행하는 로직 (Line 41-55)

    if let reportEntity = try await reportRepository.fetchReportDetail(reportId: reportId) {
        // ... reportDetail 생성
        reportDetailSubject.send(reportDetail)
    }
    reportDetailSubject.send(nil)

    위 구조에서는 제보 조회에 성공해도 reportDetailSubject가 곧바로 nil을 다시 발행합니다.
    ReportDetailViewController.fillReportContentguard let reportDetail else { return }로 시작하므로, 화면에서 데이터가 잠깐 세팅된 뒤 즉시 지워지는 효과가 나게 됩니다.

  2. Task 클로저에서의 self 캡처 (Line 38-40, 41-55)

    • Task { ... } 내부에서 reportRepository, reportDetailSubject를 사용하고 있으므로, 명시적으로 self를 캡처하는 것이 안전합니다.
    • 화면이 내려간 뒤에도 Task가 살아 있을 수 있으므로 [weak self] + guard let self 패턴을 사용하는 편이 메모리 및 크래시 측면에서 더 안전합니다.

아래처럼 성공/실패/데이터없음 케이스를 명확히 나누고, [weak self]를 적용하는 형태를 권장드립니다.

-    private func fetchReportDetail(reportId: Int) {
-        Task {
-            do {
-                if let reportEntity = try await reportRepository.fetchReportDetail(reportId: reportId) {
-                    let reportDetail = ReportDetail(
-                        date: reportEntity.date ?? "",
-                        title: reportEntity.title,
-                        status: reportEntity.progress,
-                        category: reportEntity.type,
-                        description: reportEntity.content ?? "",
-                        location: reportEntity.location.address ?? "",
-                        photoUrls: reportEntity.photoUrls)
-                    reportDetailSubject.send(reportDetail)
-                }
-                reportDetailSubject.send(nil)
-            } catch {
-                reportDetailSubject.send(nil)
-            }
-        }
-    }
+    private func fetchReportDetail(reportId: Int) {
+        Task { [weak self] in
+            guard let self else { return }
+
+            do {
+                if let reportEntity = try await self.reportRepository.fetchReportDetail(reportId: reportId) {
+                    let reportDetail = ReportDetail(
+                        date: reportEntity.date ?? "",
+                        title: reportEntity.title,
+                        status: reportEntity.progress,
+                        category: reportEntity.type,
+                        description: reportEntity.content ?? "",
+                        location: reportEntity.location.address ?? "",
+                        photoUrls: reportEntity.photoUrls)
+                    self.reportDetailSubject.send(reportDetail)
+                } else {
+                    self.reportDetailSubject.send(nil)
+                }
+            } catch {
+                self.reportDetailSubject.send(nil)
+            }
+        }
+    }

이렇게 하면:

  • 성공 시에는 ReportDetail만 발행되고,
  • 데이터가 없거나 실패한 경우에만 nil이 발행되어 뷰에서 적절히 처리할 수 있습니다.


Based on the verification results, here is my rewritten review comment:

---

**`fetchReportDetail(reportId:)` 메서드의 nil 발행 로직 버그와 메모리 안전성 패턴을 함께 수정해주세요**

현재 구현에는 두 가지 문제가 있습니다.

1. **성공 시에도 곧바로 `nil`을 발행하는 로직 (Line 50)**  
   현재 코드 구조에서는 제보 조회에 성공해도 `reportDetailSubject`가 곧바로 `nil`을 다시 발행합니다. 성공/데이터없음/실패 케이스를 명확히 구분해야 합니다.

2. **Task 클로저에서의 self 캡처 (Line 39-56)**  
   `Task { ... }` 내부에서 `reportRepository`와 `reportDetailSubject`에 접근하므로, [weak self]를 사용하는 것이 필요합니다.

```diff
-    private func fetchReportDetail(reportId: Int) {
-        Task {
-            do {
-                if let reportEntity = try await reportRepository.fetchReportDetail(reportId: reportId) {
-                    let reportDetail = ReportDetail(
-                        date: reportEntity.date ?? "",
-                        title: reportEntity.title,
-                        status: reportEntity.progress,
-                        category: reportEntity.type,
-                        description: reportEntity.content ?? "",
-                        location: reportEntity.location.address ?? "",
-                        photoUrls: reportEntity.photoUrls)
-                    reportDetailSubject.send(reportDetail)
-                }
-                reportDetailSubject.send(nil)
-            } catch {
-                reportDetailSubject.send(nil)
-            }
-        }
-    }
+    private func fetchReportDetail(reportId: Int) {
+        Task { [weak self] in
+            guard let self else { return }
+
+            do {
+                if let reportEntity = try await self.reportRepository.fetchReportDetail(reportId: reportId) {
+                    let reportDetail = ReportDetail(
+                        date: reportEntity.date ?? "",
+                        title: reportEntity.title,
+                        status: reportEntity.progress,
+                        category: reportEntity.type,
+                        description: reportEntity.content ?? "",
+                        location: reportEntity.location.address ?? "",
+                        photoUrls: reportEntity.photoUrls)
+                    self.reportDetailSubject.send(reportDetail)
+                } else {
+                    self.reportDetailSubject.send(nil)
+                }
+            } catch {
+                self.reportDetailSubject.send(nil)
+            }
+        }
+    }

🏁 Script executed:

#!/bin/bash
# Find ReportDetailViewModel.swift and verify the code
find . -name "ReportDetailViewModel.swift" -type f 2>/dev/null | head -5

Length of output: 76


🏁 Script executed:

#!/bin/bash
# Extract and display the fetchReportDetail method around lines 38-57
if [ -f "Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift" ]; then
  sed -n '38,57p' "Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift"
else
  echo "File not found at expected path"
fi

Length of output: 871


🏁 Script executed:

#!/bin/bash
# Search for ReportDetailViewController and fillReportContent method
find . -name "ReportDetailViewController.swift" -type f 2>/dev/null

Length of output: 76


🏁 Script executed:

#!/bin/bash
# Check reportDetailSubject type and usage
rg -n "reportDetailSubject" "Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift" -A 2 -B 2 2>/dev/null || echo "File not found or no matches"

Length of output: 761


🏁 Script executed:

#!/bin/bash
# Look for fillReportContent in ReportDetailViewController
rg -n "fillReportContent\|reportDetailSubject" "Projects/Presentation/Sources/Report/View/Controller/" -A 3 -B 1 2>/dev/null | head -30

Length of output: 0



---

🏁 Script executed:

```shell
#!/bin/bash
# Search for fillReportContent directly in ReportDetailViewController.swift
rg -n "fillReportContent" "Projects/Presentation/Sources/Report/View/ReportDetailViewController.swift" -A 5 -B 2

Length of output: 661


🏁 Script executed:

#!/bin/bash
# Search for reportDetailPublisher subscription in ReportDetailViewController.swift
rg -n "reportDetailPublisher\|\.sink\|\.assign\|\.receive" "Projects/Presentation/Sources/Report/View/ReportDetailViewController.swift" -B 2 -A 3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Get the whole ReportDetailViewController to understand how reportDetailPublisher is used
cat "Projects/Presentation/Sources/Report/View/ReportDetailViewController.swift" | head -100

Length of output: 3298


fetchReportDetail(reportId:)에서 성공 후 항상 nil을 발행하는 버그와 Task 메모리 안전성을 함께 수정해주세요

현재 구현에는 두 가지 문제가 있습니다.

  1. 성공 시에도 곧바로 nil을 발행하는 로직 (Line 52)
    제보 조회 성공 후 reportDetail이 발행되지만, 즉시 line 52의 reportDetailSubject.send(nil)이 조건 없이 실행되어 구독자(fillReportContent)가 nil을 받게 됩니다. 성공/데이터없음/실패 케이스를 명확히 구분해야 합니다.

  2. Task 클로저에서의 self 캡처 (Line 39-56)
    reportRepositoryreportDetailSubject에 접근하므로 [weak self] 패턴을 사용해야 합니다. ReportDetailViewController는 이 패턴을 올바르게 적용하고 있습니다.

-    private func fetchReportDetail(reportId: Int) {
-        Task {
-            do {
-                if let reportEntity = try await reportRepository.fetchReportDetail(reportId: reportId) {
-                    let reportDetail = ReportDetail(
-                        date: reportEntity.date ?? "",
-                        title: reportEntity.title,
-                        status: reportEntity.progress,
-                        category: reportEntity.type,
-                        description: reportEntity.content ?? "",
-                        location: reportEntity.location.address ?? "",
-                        photoUrls: reportEntity.photoUrls)
-                    reportDetailSubject.send(reportDetail)
-                }
-                reportDetailSubject.send(nil)
-            } catch {
-                reportDetailSubject.send(nil)
-            }
-        }
-    }
+    private func fetchReportDetail(reportId: Int) {
+        Task { [weak self] in
+            guard let self else { return }
+
+            do {
+                if let reportEntity = try await self.reportRepository.fetchReportDetail(reportId: reportId) {
+                    let reportDetail = ReportDetail(
+                        date: reportEntity.date ?? "",
+                        title: reportEntity.title,
+                        status: reportEntity.progress,
+                        category: reportEntity.type,
+                        description: reportEntity.content ?? "",
+                        location: reportEntity.location.address ?? "",
+                        photoUrls: reportEntity.photoUrls)
+                    self.reportDetailSubject.send(reportDetail)
+                } else {
+                    self.reportDetailSubject.send(nil)
+                }
+            } catch {
+                self.reportDetailSubject.send(nil)
+            }
+        }
+    }
🤖 Prompt for AI Agents
In Projects/Presentation/Sources/Report/ViewModel/ReportDetailViewModel.swift
around lines 38 to 57, the Task currently always sends nil after a successful
send and captures self strongly; change the Task to capture self weakly (Task {
[weak self] in ... }) and unwrap guard let self = self (or use optional
chaining) before accessing reportRepository/reportDetailSubject, and remove the
unconditional reportDetailSubject.send(nil) after the success branch — instead
send nil only in the else case when fetch returns no entity and inside the catch
block for failures.

}
2 changes: 1 addition & 1 deletion Tuist.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import ProjectDescription

let tuist = Tuist(
project: .tuist(
compatibleXcodeVersions: .upToNextMajor("16.0")
compatibleXcodeVersions: .all
)
)