Skip to content

[Feat] 네트워크 재시도 에러 View 구현#85

Merged
taipaise merged 8 commits intodevelopfrom
feat/network-error
Feb 21, 2026
Merged

[Feat] 네트워크 재시도 에러 View 구현#85
taipaise merged 8 commits intodevelopfrom
feat/network-error

Conversation

@taipaise
Copy link
Collaborator

@taipaise taipaise commented Feb 17, 2026

🌁 Background

📱 Screenshot

iPhone SE3 iPhone 13 mini iPhone 16 Pro

👩‍💻 Contents

  • 네트워크 연결 오류 (타임아웃, 네트워크 미연결) 발생 시, 재시도 화면 및 로직 구현
  • 네트워크 재시도 핸들링을 담당하는 NetworkRetryCapable 구현
  • BaseViewController에서 네트워크 재시도 View를 띄울 수 있도록 구현

📝 Review Note

구조 설명

각 링크를 클릭하면 해당 파일로 이동합니다

NetworkRetryCapable(네트워크 재시도 protocol)

  • 해당 프로토콜 내부에는, 네트워크 에러 발생 시, 재시도할 메서드를 방출하는 subject가 있습니다.
          var networkErrorActionSubject: CurrentValueSubject<(() -> Void)?, Never> { get }
  • ViewModel외부 api를 호출하는 메서드 내부에서 네트워크 timeout이나 lost connection 과 같은 Error가 발생하는 경우, handleNetworkError 를 통해 오류가 발생한 viewModel의 메서드를 networkErrorActionSubject로 방출합니다.
  • 방출된 viewModel의 메서드는 네트워크 재시도 View의 button의 action으로 사용됩니다.

네트워크 재시도 View

  • NetworkErrorView는 빛나길 VC가 상속하고 있는 super class인 BaseViewController에 등록되어있습니다.
  • 하위 subclass들은 configureLayoutconfigureAttribute 에서 super인 BaseViewControllerconfigureAttribute와configureLayout을 호출하도록 수정하였습니다.
  • 저장속성으로 가지고 있는 onRetry 클로저에 재시도할 메서드가 들어가게 되고, 사용자가 재시도 버튼을 누르면 해당 메서드를 실행하게 됩니다.

BaseViewController

  • 빛나길 VC가 상속하는 BaseViewController의 핵심 변경 사항은 아래 두가지 메서드가 추가됐다는 점입니다.

      /// 네트워크 재시도 기능이 필요할 경우 BaseViewController의 subclass 내의 bind() 함수 내에서 호출합니다.
      /// 네트워크 재시도 화면의 '다시 시도하기' 버튼의 재시도 action을 설정합니다.
      /// - Parameter publisher: ViewModel의 Output에서 제공하는 네트워크 에러 Publisher
      func bindNetworkError(from publisher: AnyPublisher<(() -> Void)?, Never>) {
          publisher
              .receive(on: DispatchQueue.main)
              .sink { [weak self] retryAction in
                  if let action = retryAction {
                      self?.handleNetworkErrorView(show: true)
                      self?.networkErrorView.onRetry = action
                  } else {
                      self?.handleNetworkErrorView(show: false)
                  }
              }
              .store(in: &baseCancellables)
      }
    
      /// 네트워크 에러 뷰 를 보이거나 숨깁니다.
      private func handleNetworkErrorView(show: Bool) {
          if show {
              view.bringSubviewToFront(networkErrorView)
              networkErrorView.isHidden = false
              tabBarController?.tabBar.isHidden = true
          } else {
              networkErrorView.isHidden = true
              tabBarController?.tabBar.isHidden = false
          }
      }
    • bindNetworkError 가 핵심 함수입니다. 각 VC의 bind 함수 내에서, viewModel에 추후 추가될 networkError 관련 publisher를 넣어주면 됩니다.

사용 방법

  1. 재시도 작업이 필요한 api가 있는 ViewModelOutputnetworkErrorPublisher 추가
  struct Output {
        let reportDetailPublisher: AnyPublisher<ReportDetail?, Never>
        let networkErrorPublisher: AnyPublisher<(() -> Void)?, Never>
  }
  1. ViewModel 내부에 NetworkRetryHandler를 추가

    private let networkRetryHandler: NetworkRetryHandler
    • NetworkRetryCapable를 채택하고 있는 NetworkRetryHandler를 ViewModel 내부에 추가합니다.
  2. 재시도가 필요한 메서드의 catch 문에서, handleNetworkError 를 실행합니다.

    private func fetchReportDetail(reportId: Int) {
            Task {
                do {
                    if let reportEntity = try await reportRepository.fetchReportDetail(reportId: reportId) {
                        let date = Date.convertToDate(from: reportEntity.date ?? "", dateType: .yearMonthDate)
                        let dateString = date?.convertToString(dateType: .yearMonthDateWeek2)
    
                        let reportDetail = ReportDetail(
                            date: dateString ?? "",
                            title: reportEntity.title,
                            status: reportEntity.progress,
                            category: reportEntity.type,
                            description: reportEntity.content ?? "",
                            location: reportEntity.location.address ?? "",
                            photoUrls: reportEntity.photoURLs)
                        reportDetailSubject.send(reportDetail)
                    }
    
                    networkRetryHandler.clearRetryState()
                } catch {
                    reportDetailSubject.send(nil)
    
                    networkRetryHandler.handleNetworkError(error) { [weak self] in
                        self?.fetchReportDetail(reportId: reportId)
                    }
                }
            }
        }
  3. VC에 bindNetworkError() 추가

    • bind() 함수 내부에 viewModelnetworkErrorPublisherbindNetworkError에 넣어 호출합니다.
    override func bind() {
         // 생략..
         viewModel.output.allCompletedRoutineDatePublisher
            .receive(on: DispatchQueue.main)
            .sink { [weak self] allCompletedDates in
                self?.weekView.updateAllCompletedState(allCompletedDates: allCompletedDates)
            }
            .store(in: &cancellables)
         // 생략..
        bindNetworkError(from: viewModel.output.networkErrorPublisher)
    }

개선해야할 점

  • 생각보다 네트워크 오류 관련 핸들링 할 곳이 많아서 ㅜㅜ 우선은 QA 목록에 올라온 것들만 처리해둔 상태입니다.
  • 작업 진행해주시면서 네트워크 연결 관련 오류 처리해줘야하는 화면이 있으시면 그때 그때 수정해주시면 감사하겠습니다!!

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 네트워크 오류 전용 화면(아이콘·메시지·재시도 버튼) 추가로 오류 상황에서 즉시 재시도 가능해졌습니다.
    • 여러 화면에서 네트워크 재시도 알림이 통합되어, 재시도 동작을 일관되게 받을 수 있습니다.
  • 버그 수정

    • 네트워크 실패 시 더 일관된 오류 알림과 재시도 흐름으로 앱의 네트워크 안정성이 향상되었습니다.

@coderabbitai
Copy link

coderabbitai bot commented Feb 17, 2026

Walkthrough

네트워크 오류 분류 및 재시도 인프라를 도입합니다. NetworkService에서 URLError를 매핑하고, 리포지토리에서 NetworkError를 DomainError로 변환하며, Presentation 계층에 NetworkRetryHandler와 NetworkErrorView를 연결해 재시도 흐름을 노출합니다.

Changes

Cohort / File(s) Summary
네트워크 서비스
Projects/DataSource/Sources/NetworkService/NetworkService.swift
URLSession 응답 핸들링을 do-catch로 감싸 URLError를 NetworkError.needRetry 등으로 분류하는 guarded error 처리 추가.
리포지토리 계층
Projects/DataSource/Sources/Repository/*Repository.swift
여러 Repository 파일에서 networkService.request 호출을 do-catch로 감싸 NetworkErrorDomainError(requireRetry, business(String), unknown)로 일괄 변환하도록 변경.
도메인 에러 타입
Projects/Domain/Sources/Entity/Enum/DomainError.swift
새로운 공개 enum DomainError 추가: requireRetry, business(String), unknown.
재시도 인프라
Projects/Presentation/Sources/Common/NetworkRetryHandler.swift, Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift
NetworkRetryCapable 프로토콜과 NetworkRetryHandler 추가로 재시도 액션 발행/관리 로직 도입(현재값 Subject 기반).
UI: 네트워크 오류 뷰 및 에셋
Projects/Presentation/Resources/Images.xcassets/NetworkError/*, Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift, Projects/Presentation/Sources/Common/DesignSystem/BitnagilIcon.swift
네트워크 오류 이미지 에셋 추가, NetworkErrorView 컴포넌트 추가, BitnagilIcon에 networkErrorIcon 등록.
BaseViewController 통합
Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift
base에 NetworkErrorView 추가, Combine 구독용 cancellables와 bindNetworkError(from:) 도입, 탭바 숨김/표시 로직 포함.
ViewModel 통합 (여러 화면)
Projects/Presentation/Sources/*/ViewModel/*ViewModel.swift (예: Home, Report, Routine, RoutineCreation, ResultRecommendedRoutine 등)
각 ViewModel에 NetworkRetryHandler 추가, Output에 networkErrorPublisher 노출, 네트워크 실패 시 handler.handleNetworkError로 재시도 콜백 제공 및 성공 시 clearRetryState 호출.
ViewController 통합 (여러 화면)
Projects/Presentation/Sources/*/View/*ViewController.swift (다수 파일)
configureAttribute()/configureLayout()에서 super 호출 추가 및 view에서 viewModel.output.networkErrorPublisher를 bindNetworkError로 바인딩하도록 변경.
사소한 뷰/구성 변경
여러 파일 (예: Login, Onboarding, Report, RoutineList, Mypage 등)
각종 뷰/컨트롤러에서 super.configureAttribute()/super.configureLayout() 호출 추가(초기화 순서 보장).

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant VC as ViewController
    participant VM as ViewModel
    participant Retry as NetworkRetryHandler
    participant Repo as Repository
    participant Net as NetworkService
    participant API as Remote API

    User->>VC: 사용자 요청(예: 새로고침)
    VC->>VM: ViewModel에 요청 전달
    VM->>Repo: 데이터 요청 호출
    Repo->>Net: networkService.request
    Net->>API: HTTP 요청 전송

    alt 네트워크 실패
        API-->>Net: 오류 응답
        Net-->>Repo: NetworkError로 매핑
        Repo-->>VM: throw DomainError(requireRetry/business/unknown)
        VM->>Retry: handleNetworkError(error, retryAction)
        Retry-->>VM: networkErrorActionSubject에 retryAction 발행
        VM-->>VC: Output.networkErrorPublisher 통해 재시도 콜백 전달
        VC->>VC: BaseViewController가 NetworkErrorView 표시
        User->>VC: 재시도 버튼 탭
        VC->>VM: retryAction 실행 (요청 재시작)
    else 성공
        API-->>Net: 성공 응답
        Net-->>Repo: 데이터 반환
        Repo-->>VM: 성공 결과 반환
        VM->>Retry: clearRetryState()
        VM-->>VC: 정상 데이터로 UI 갱신
        VC->>VC: NetworkErrorView 숨김
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

네트워크가 끊겨도 다시 뛰어볼래 🐰
에러는 버튼으로 껴안고 깡총깡총
리트라이 콜백 손에 쥐고
뷰와 뷰모델이 함께 다시 출발해요
작은 토끼가 축하해요 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.40% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 네트워크 재시도 에러 View 구현이라는 주요 변경 사항을 명확하게 요약하고 있으며, 전체 changeset의 핵심 목표와 완전히 일치합니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/network-error

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@taipaise taipaise changed the title [Fix] 네트워크 재시도 에러 View 구현 [Feat] 네트워크 재시도 에러 View 구현 Feb 17, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift (1)

83-88: ⚠️ Potential issue | 🟠 Major

configureAttribute()configureLayout()이 중복 호출됩니다.

super.viewDidLoad()BaseViewController에서 이미 configureAttribute(), configureLayout(), bind()를 호출합니다. 따라서 Line 85-86에서 다시 호출하면 메서드가 두 번 실행되어, addSubview/addArrangedSubview 등이 중복 수행됩니다.

🐛 중복 호출 제거 제안
 override func viewDidLoad() {
     super.viewDidLoad()
-    configureAttribute()
-    configureLayout()
     viewModel.action(input: .fetchReportDetail(reportId: reportId))
 }

Based on learnings: BaseViewController의 viewDidLoad() 메서드에서 이미 configureAttribute(), configureLayout(), bind()를 호출하므로, 하위 클래스에서 super.viewDidLoad()를 호출하면 이 메서드들이 자동으로 호출된다. 따라서 하위 클래스에서 추가로 호출할 필요가 없다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift`
around lines 83 - 88, BaseViewController.viewDidLoad() already calls
configureAttribute(), configureLayout(), and bind(), so remove the duplicate
calls in ReportCompleteViewController.viewDidLoad(): keep super.viewDidLoad()
and the viewModel.action(input: .fetchReportDetail(reportId: reportId)) line,
but delete the explicit configureAttribute() and configureLayout() invocations
to avoid double addSubview/addArrangedSubview side effects.
Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationViewController.swift (1)

239-317: ⚠️ Potential issue | 🟠 Major

bind() 메서드 마지막에 bindNetworkError 호출 추가 필요

RoutineCreationViewModelnetworkErrorPublisher를 노출하고 있으나, bind() 메서드에서 이를 바인딩하지 않고 있습니다. RoutineListViewController, ReportHistoryViewController, ResultRecommendedRoutineViewController 등 다른 뷰 컨트롤러에서는 일관되게 bindNetworkError(from: viewModel.output.networkErrorPublisher)를 호출하고 있으므로, 동일한 패턴을 적용해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationViewController.swift`
around lines 239 - 317, The bind() method in RoutineCreationViewController is
missing the network error binding — call bindNetworkError(from:
viewModel.output.networkErrorPublisher) at the end of bind() so the controller
subscribes to RoutineCreationViewModel's networkErrorPublisher; update the
RoutineCreationViewController.bind() method to invoke bindNetworkError(from:
viewModel.output.networkErrorPublisher) after the existing publishers are stored
to ensure consistent network-error handling.
Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift (1)

126-156: ⚠️ Potential issue | 🟠 Major

여러 독립 비동기 작업이 하나의 networkRetryHandler를 공유하면 race condition이 발생할 수 있습니다.

HomeViewModel에는 loadNickname, fetchEmotion, fetchRoutines, updateRoutineCompletion, checkVersion 등 여러 독립 비동기 작업이 있으며, 모두 동일한 networkRetryHandler를 사용합니다.

발생 가능한 시나리오:

  1. fetchRoutines 실패 → 에러 뷰 표시 (retry = fetchRoutines)
  2. loadNickname 성공 → clearRetryState() 호출 → 에러 뷰 숨김
  3. 사용자는 fetchRoutines 실패를 재시도할 기회를 잃음

또는 반대로:

  1. loadNickname 실패 → retry = loadNickname
  2. fetchRoutines 실패 → retry가 fetchRoutines로 덮어씌워짐
  3. 사용자가 retry → loadNickname은 재시도되지 않음

viewWillAppear에서 여러 작업이 동시에 트리거되므로, 하나의 작업이 성공하면 다른 실패 작업의 retry 상태가 소실됩니다. 단기적으로는 retry action에서 모든 초기 작업을 다시 실행하는 방식을 고려해 보세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift` around
lines 126 - 156, Multiple concurrent async tasks (loadNickname, fetchEmotion,
fetchRoutines, updateRoutineCompletion, checkVersion) share a single
networkRetryHandler causing races where one task's success clears another's
retry state; fix by giving each independent operation its own retry state or by
making networkRetryHandler support per-operation IDs: either add separate
handlers (e.g., nicknameRetryHandler, emotionRetryHandler) and replace
networkRetryHandler calls in loadNickname/fetchEmotion/etc. with the
corresponding handler, or change
networkRetryHandler.handleNetworkError/clearRetryState to accept an operation
identifier and only clear or retry that specific operation; also update
viewWillAppear to trigger the correct per-operation retries (or a centralized
retry that re-runs all initial tasks) instead of relying on a single global
retry state.
Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift (1)

128-171: ⚠️ Potential issue | 🔴 Critical

fetchRoutinefetchRecommendedRoutine 성공 시 clearRetryState() 호출이 누락되었습니다.

registerRoutine에서는 성공 시 networkRetryHandler.clearRetryState()를 호출하지만, fetchRoutine (Line 128-171)과 fetchRecommendedRoutine (Line 174-191)에서는 호출하지 않습니다. 네트워크 에러 뷰가 표시된 상태에서 재시도가 성공하면 에러 뷰가 사라지지 않을 수 있습니다.

🐛 수정 제안 - fetchRoutine
                 routineId = id

                 updateIsRoutineValid()
+
+                networkRetryHandler.clearRetryState()
             } catch {
🐛 수정 제안 - fetchRecommendedRoutine
                 routineType = routine.type

                 updateIsRoutineValid()
+
+                networkRetryHandler.clearRetryState()
             } catch {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift`
around lines 128 - 171, fetchRoutine and fetchRecommendedRoutine fail to clear
the network retry/error UI on a successful response; after the async fetch
completes successfully (inside fetchRoutine after you set
periodStart/periodEnd/name/subRoutines/repeatType/executionTime/routineId and
before updateIsRoutineValid or returning) call
networkRetryHandler.clearRetryState(), and likewise add the same call in
fetchRecommendedRoutine immediately after successfully processing the fetched
recommended routine (i.e., after updating any subjects/state and before any
return or UI-update call) so a prior network error view is dismissed on success.
🧹 Nitpick comments (7)
Projects/DataSource/Sources/NetworkService/NetworkService.swift (1)

63-75: URLError 처리 로직이 적절합니다.

연결 관련 에러를 needRetry로 매핑하고, 나머지를 unknown으로 처리하는 구조가 깔끔합니다.

한 가지 고려사항: .dataNotAllowed (셀룰러 데이터 비활성화)도 연결 관련 에러로 needRetry에 포함시킬 수 있습니다.

선택적 개선안
-            if [.notConnectedToInternet, .timedOut, .networkConnectionLost].contains(error.code) {
+            if [.notConnectedToInternet, .timedOut, .networkConnectionLost, .dataNotAllowed].contains(error.code) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/DataSource/Sources/NetworkService/NetworkService.swift` around lines
63 - 75, Add .dataNotAllowed to the set of URLError codes treated as transient
so the existing URLError catch maps cellular-data-disabled cases to
NetworkError.needRetry; update the check in the catch-let-URLError block that
currently tests [.notConnectedToInternet, .timedOut, .networkConnectionLost] to
also include .dataNotAllowed (refer to the URLError handling around
URLSession.shared.data(for: request) in NetworkService.swift).
Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift (1)

43-44: 배경색이 .white로 하드코딩되어 있습니다.

다른 뷰에서는 .systemBackgroundBitnagilColor 상수를 사용하는 반면, 여기서는 .white로 하드코딩되어 있습니다. 다크 모드를 지원하게 될 경우 문제가 될 수 있으므로, 프로젝트 컨벤션에 맞게 .systemBackground 또는 적절한 BitnagilColor 사용을 고려해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift` around
lines 43 - 44, In NetworkErrorView replace hardcoded .white assignments
(backgroundColor and contentView.backgroundColor) with the project's dynamic
color choice (e.g. .systemBackground or the appropriate BitnagilColor constant)
so the view follows dark mode and project conventions; update the assignments in
the NetworkErrorView setup/initializer where backgroundColor and
contentView.backgroundColor are set to use the chosen color.
Projects/DataSource/Sources/Repository/ReportRepository.swift (1)

37-50: 에러 매핑 로직의 반복 — 헬퍼 함수 추출을 고려해 주세요.

NetworkErrorDomainError 변환 로직이 이 파일의 세 메서드뿐 아니라 FileRepository, LocationRepository, OnboardingRepository, RecommendedRoutineRepository 등 모든 리포지토리에서 동일하게 반복됩니다. 공통 헬퍼 함수로 추출하면 유지보수성이 향상됩니다.

♻️ 에러 매핑 헬퍼 함수 제안

예를 들어, NetworkError extension 또는 별도 유틸리티로:

extension NetworkError {
    func toDomainError() -> DomainError {
        switch self {
        case .needRetry, .invalidURL, .emptyData:
            return .requireRetry
        default:
            return .business(self.description)
        }
    }
}

각 리포지토리에서는 다음과 같이 사용:

-        } catch let error as NetworkError {
-            switch error {
-            case .needRetry, .invalidURL, .emptyData:
-                throw DomainError.requireRetry
-            default:
-                throw DomainError.business(error.description)
-            }
-        } catch {
-            throw DomainError.unknown
-        }
+        } catch let error as NetworkError {
+            throw error.toDomainError()
+        } catch {
+            throw DomainError.unknown
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/DataSource/Sources/Repository/ReportRepository.swift` around lines
37 - 50, Extract the repeated NetworkError→DomainError mapping into a shared
helper (e.g., add a NetworkError extension method like toDomainError() or a
static util function) and replace the switch in ReportRepository's do-catch (the
catch for "let error as NetworkError") with throw error.toDomainError() (or
throw NetworkErrorMapper.map(error)). Update other repositories (FileRepository,
LocationRepository, OnboardingRepository, RecommendedRoutineRepository) to call
the same helper so all occurrences use the single implementation that returns
.requireRetry for .needRetry/.invalidURL/.emptyData and
.business(error.description) for others.
Projects/Presentation/Sources/Home/View/HomeViewController.swift (1)

263-265: dimmedViewnetworkErrorView 위에 추가되어 네트워크 에러 뷰를 가릴 수 있습니다.

super.configureLayout() (Line 226)에서 networkErrorView가 먼저 추가된 후, Line 263에서 dimmedView, Line 264에서 floatingMenu, Line 265에서 floatingButton이 추가됩니다. handleNetworkErrorView에서 bringSubviewToFront를 호출하므로 표시 시점에는 문제가 없지만, floating 메뉴가 열린 상태에서 네트워크 에러가 발생하면 dimmedView와의 z-order 경쟁이 발생할 수 있습니다. 에러 표시 시 floating 메뉴/dimmedView를 닫는 것을 고려해 보세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Home/View/HomeViewController.swift` around
lines 263 - 265, The dimmedView/floatingMenu/floatingButton are added after
super.configureLayout() which added networkErrorView, causing z-order conflicts
when a network error occurs while the floating menu is open; update
handleNetworkErrorView to first close or hide the floating menu and dimmedView
(e.g., call floatingMenu.dismiss()/close() or set floatingMenu.isHidden = true
and set dimmedView.isHidden = true/removeFromSuperview()) before presenting or
bringing networkErrorView to front, or alternatively ensure you call
view.bringSubviewToFront(networkErrorView) and then hide/remove dimmedView so
the networkErrorView is never obscured.
Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift (1)

47-53: networkErrorView가 서브클래스의 뷰보다 먼저 추가되어 뷰 계층 뒤에 위치합니다.

super.configureLayout()이 먼저 호출되므로 networkErrorView는 서브클래스에서 추가하는 뷰들 아래에 위치하게 됩니다. handleNetworkErrorView에서 bringSubviewToFront를 호출하여 이를 처리하고 있으므로 동작은 정상이지만, 서브클래스에서 super.configureLayout()을 마지막에 호출하면 의도한 대로 networkErrorView가 항상 최상위에 위치할 수 있습니다. 현재 구현도 동작하므로 참고 사항입니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift`
around lines 47 - 53, The networkErrorView is being added in
BaseViewController.configureLayout before subclass views when subclasses call
super.configureLayout() first, causing it to sit behind subclass content; update
the layout flow so networkErrorView is always topmost by either (A) moving the
networkErrorView addition and its constraints out of configureLayout into a
point after subclass layout (e.g., require subclasses to call
super.configureLayout() last) or (B) keep it in configureLayout but immediately
call bringSubviewToFront(networkErrorView) (or call handleNetworkErrorView) at
the end of configureLayout to ensure networkErrorView is brought above any
subclass-added views; reference networkErrorView, configureLayout,
handleNetworkErrorView, bringSubviewToFront and super.configureLayout() when
making the change.
Projects/DataSource/Sources/Repository/RoutineRepository.swift (1)

27-38: 에러 매핑 로직이 7개 메서드에서 동일하게 반복됩니다. 헬퍼로 추출하세요.

모든 메서드에서 동일한 do-catch + switch 패턴이 반복됩니다. 유지보수성과 일관성을 위해 공통 헬퍼를 추출하는 것을 권장합니다.

♻️ 에러 변환 헬퍼 추출 예시
private func mapToDomainError(_ error: Error) -> DomainError {
    if let networkError = error as? NetworkError {
        switch networkError {
        case .needRetry:
            return .requireRetry
        default:
            return .business(networkError.description)
        }
    }
    return .unknown
}

사용 예시:

-        do {
-            _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self)
-        } catch let error as NetworkError {
-            switch error {
-            case .needRetry, .invalidURL, .emptyData:
-                throw DomainError.requireRetry
-            default:
-                throw DomainError.business(error.description)
-            }
-        } catch {
-            throw DomainError.unknown
-        }
+        do {
+            _ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self)
+        } catch {
+            throw mapToDomainError(error)
+        }

Also applies to: 44-57, 63-83, 99-110, 116-127, 133-144, 156-167

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/DataSource/Sources/Repository/RoutineRepository.swift` around lines
27 - 38, Extract a shared helper in RoutineRepository named something like
mapToDomainError(_:) that takes Error and returns DomainError using the same
mapping used in the duplicated do-catch blocks (cast to NetworkError and switch:
.needRetry, .invalidURL, .emptyData -> .requireRetry; default NetworkError ->
.business(networkError.description); non-NetworkError -> .unknown), then replace
the repeated catch logic in each method (the seven methods that currently catch
NetworkError and map to DomainError) to call this helper and rethrow the
resulting DomainError.
Projects/Presentation/Sources/Report/ViewModel/ReportHistoryViewModel.swift (1)

168-174: fetchReports 재시도 로직은 올바르지만, 네트워크 외 에러는 무시됩니다.

handleNetworkErrorDomainError.requireRetry인 경우에만 동작하므로, .business.unknown 에러 발생 시 사용자에게 아무런 피드백이 없습니다. 기존 TODO 주석(Line 171)이 이를 인지하고 있지만, 추후 처리가 필요합니다.

비네트워크 에러에 대한 사용자 피드백(토스트, 알림 등) 처리 구현을 도와드릴까요? 또는 추적을 위한 이슈를 생성해드릴까요?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Report/ViewModel/ReportHistoryViewModel.swift`
around lines 168 - 174, The catch currently only passes errors to
networkRetryHandler.handleNetworkError (which handles DomainError.requireRetry)
so non-network errors (.business, .unknown) are ignored; update the catch in
fetchReports to distinguish DomainError.requireRetry vs other errors by
inspecting the thrown error, call networkRetryHandler.handleNetworkError for
retryable DomainError cases, and for .business/.unknown paths invoke a
user-feedback mechanism (e.g., showToast/showAlert/logError) and keep the
existing TODO comment removed; reference networkRetryHandler.handleNetworkError,
fetchReports, and DomainError.requireRetry/.business/.unknown when adding the
conditional handling and feedback call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Projects/DataSource/Sources/Repository/EmotionRepository.swift`:
- Around line 37-52: In loadEmotion, don’t throw a NetworkError when the
response is nil then immediately catch and map it — throw the appropriate
DomainError directly instead; replace the guard’s throw
NetworkError.unknown(...) with throw DomainError.business("Emotion Response를
받아오지 못했습니다.") (fixing the "Reponse" -> "Response" typo), so the downstream catch
for NetworkError in the same method no longer needs to translate this case.

In `@Projects/DataSource/Sources/Repository/OnboardingRepository.swift`:
- Around line 17-30: The method loadOnboardingResult currently only catches
NetworkError (catch let error as NetworkError) so non-network errors like
UserError.onboardingLoadFailed will escape; to align with other repository
methods (e.g., registerOnboarding, registerRecommendedRoutines) add a general
catch after the NetworkError catch that throws DomainError.unknown, or
alternatively move the throw UserError.onboardingLoadFailed outside the do so
it’s handled consistently; update loadOnboardingResult accordingly to ensure all
non-NetworkError exceptions are mapped to DomainError.unknown.

In `@Projects/DataSource/Sources/Repository/ReportRepository.swift`:
- Around line 82-96: The DTO conversion error from response.toReportEntity() is
being caught as NetworkError and mapped to DomainError.business; separate
network errors from DTO-conversion errors by isolating the network call and the
DTO conversion into two try/catch scopes: call networkService.request(...)
inside its own do/catch that handles NetworkError (switching
needRetry/invalidURL/emptyData -> DomainError.requireRetry, others ->
DomainError.business), then after that, perform response.toReportEntity() in a
separate try and map its failures to the appropriate DomainError (e.g.,
DomainError.unknown or a new DomainError.decode) instead of letting its
NetworkError.decodingError fall into the network catch; reference
networkService.request, response.toReportEntity(), NetworkError, DomainError,
and ReportDTO.swift when making the change.

In `@Projects/DataSource/Sources/Repository/RoutineRepository.swift`:
- Around line 29-38: The catch block in RoutineRepository currently maps
NetworkError.invalidURL and .emptyData to DomainError.requireRetry; change it so
only .needRetry maps to DomainError.requireRetry, while .invalidURL and
.emptyData are mapped to non-retry errors (e.g.,
DomainError.business(error.description) or DomainError.unknown as appropriate).
Update the catch handling for "catch let error as NetworkError" to switch
.needRetry -> DomainError.requireRetry and handle .invalidURL and .emptyData by
throwing a business/unknown domain error, leaving other cases to the default
branch that throws DomainError.business(error.description).

In `@Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift`:
- Around line 60-61: retryButton currently sets both retryButton.tintColor and
retryButton.backgroundColor to BitnagilColor.gray10 which will hide any icon or
system image; inspect the NetworkErrorView (or the retryButton setup) and either
remove the tintColor assignment if you want the default system tint, or change
retryButton.tintColor to a contrasting color (e.g., BitnagilColor.white or a
semantic accent color) so icons remain visible while leaving
retryButton.backgroundColor as BitnagilColor.gray10.
- Line 16: The enum contains a camelCase mismatch: rename the constant
boldtitleLabelTopSpacing to boldTitleLabelTopSpacing so it matches
boldTitleLabelHeight, and update all usages (including the reference near the
other usage around the former Line 98) to the new name; ensure you only change
the identifier (keep the CGFloat value) in the enum and replace every occurrence
of boldtitleLabelTopSpacing with boldTitleLabelTopSpacing in
NetworkErrorView-related code.

In `@Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift`:
- Around line 22-26: The deinit unconditionally unhides the tab bar causing
visual glitches; add a Bool property (e.g. didShowNetworkErrorView or
isNetworkErrorViewVisible) on BaseViewController, set it to true inside
handleNetworkErrorView when you present the network error UI, and in deinit only
perform DispatchQueue.main.async { [weak tabBarController =
self.tabBarController] in if self.didShowNetworkErrorView {
tabBarController?.tabBar.isHidden = false } } so the tab bar is restored only if
the network error view was actually shown (ensure the flag is updated on the
main thread).

In
`@Projects/Presentation/Sources/ResultRecommendedRoutine/ViewModel/ResultRecommendedRoutineViewModel.swift`:
- Around line 169-172: The retry handler is wired to call
fetchResultRecommendedRoutines() on network error but should reattempt the
original registration; update the closure passed to
networkRetryHandler.handleNetworkError(error) so it calls
registerRecommendedRoutine() (not fetchResultRecommendedRoutines()) from the
catch block in registerRecommendedRoutine(), preserving [weak self] capture and
context so the registration is retried correctly.

In
`@Projects/Presentation/Sources/RoutineList/ViewModel/RoutineListViewModel.swift`:
- Around line 139-142: The retry handler is wired to call fetchRoutines() on
failure but should re-attempt the original delete; update the closure passed to
networkRetryHandler.handleNetworkError to call deleteRoutine with the same
identifying parameter(s) used in the original deletion (e.g.
self?.deleteRoutine(id: routineID) or self?.deleteRoutine(routine: routine)),
ensuring you capture any needed routineID/routine into a local constant before
the async call and keep [weak self] in the closure to avoid retain cycles;
replace the current self?.fetchRoutines() invocation with that deleteRoutine
retry call.

---

Outside diff comments:
In `@Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift`:
- Around line 126-156: Multiple concurrent async tasks (loadNickname,
fetchEmotion, fetchRoutines, updateRoutineCompletion, checkVersion) share a
single networkRetryHandler causing races where one task's success clears
another's retry state; fix by giving each independent operation its own retry
state or by making networkRetryHandler support per-operation IDs: either add
separate handlers (e.g., nicknameRetryHandler, emotionRetryHandler) and replace
networkRetryHandler calls in loadNickname/fetchEmotion/etc. with the
corresponding handler, or change
networkRetryHandler.handleNetworkError/clearRetryState to accept an operation
identifier and only clear or retry that specific operation; also update
viewWillAppear to trigger the correct per-operation retries (or a centralized
retry that re-runs all initial tasks) instead of relying on a single global
retry state.

In
`@Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift`:
- Around line 83-88: BaseViewController.viewDidLoad() already calls
configureAttribute(), configureLayout(), and bind(), so remove the duplicate
calls in ReportCompleteViewController.viewDidLoad(): keep super.viewDidLoad()
and the viewModel.action(input: .fetchReportDetail(reportId: reportId)) line,
but delete the explicit configureAttribute() and configureLayout() invocations
to avoid double addSubview/addArrangedSubview side effects.

In
`@Projects/Presentation/Sources/RoutineCreation/View/RoutineCreationViewController.swift`:
- Around line 239-317: The bind() method in RoutineCreationViewController is
missing the network error binding — call bindNetworkError(from:
viewModel.output.networkErrorPublisher) at the end of bind() so the controller
subscribes to RoutineCreationViewModel's networkErrorPublisher; update the
RoutineCreationViewController.bind() method to invoke bindNetworkError(from:
viewModel.output.networkErrorPublisher) after the existing publishers are stored
to ensure consistent network-error handling.

In
`@Projects/Presentation/Sources/RoutineCreation/ViewModel/RoutineCreationViewModel.swift`:
- Around line 128-171: fetchRoutine and fetchRecommendedRoutine fail to clear
the network retry/error UI on a successful response; after the async fetch
completes successfully (inside fetchRoutine after you set
periodStart/periodEnd/name/subRoutines/repeatType/executionTime/routineId and
before updateIsRoutineValid or returning) call
networkRetryHandler.clearRetryState(), and likewise add the same call in
fetchRecommendedRoutine immediately after successfully processing the fetched
recommended routine (i.e., after updating any subjects/state and before any
return or UI-update call) so a prior network error view is dismissed on success.

---

Nitpick comments:
In `@Projects/DataSource/Sources/NetworkService/NetworkService.swift`:
- Around line 63-75: Add .dataNotAllowed to the set of URLError codes treated as
transient so the existing URLError catch maps cellular-data-disabled cases to
NetworkError.needRetry; update the check in the catch-let-URLError block that
currently tests [.notConnectedToInternet, .timedOut, .networkConnectionLost] to
also include .dataNotAllowed (refer to the URLError handling around
URLSession.shared.data(for: request) in NetworkService.swift).

In `@Projects/DataSource/Sources/Repository/ReportRepository.swift`:
- Around line 37-50: Extract the repeated NetworkError→DomainError mapping into
a shared helper (e.g., add a NetworkError extension method like toDomainError()
or a static util function) and replace the switch in ReportRepository's do-catch
(the catch for "let error as NetworkError") with throw error.toDomainError() (or
throw NetworkErrorMapper.map(error)). Update other repositories (FileRepository,
LocationRepository, OnboardingRepository, RecommendedRoutineRepository) to call
the same helper so all occurrences use the single implementation that returns
.requireRetry for .needRetry/.invalidURL/.emptyData and
.business(error.description) for others.

In `@Projects/DataSource/Sources/Repository/RoutineRepository.swift`:
- Around line 27-38: Extract a shared helper in RoutineRepository named
something like mapToDomainError(_:) that takes Error and returns DomainError
using the same mapping used in the duplicated do-catch blocks (cast to
NetworkError and switch: .needRetry, .invalidURL, .emptyData -> .requireRetry;
default NetworkError -> .business(networkError.description); non-NetworkError ->
.unknown), then replace the repeated catch logic in each method (the seven
methods that currently catch NetworkError and map to DomainError) to call this
helper and rethrow the resulting DomainError.

In `@Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift`:
- Around line 43-44: In NetworkErrorView replace hardcoded .white assignments
(backgroundColor and contentView.backgroundColor) with the project's dynamic
color choice (e.g. .systemBackground or the appropriate BitnagilColor constant)
so the view follows dark mode and project conventions; update the assignments in
the NetworkErrorView setup/initializer where backgroundColor and
contentView.backgroundColor are set to use the chosen color.

In `@Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift`:
- Around line 47-53: The networkErrorView is being added in
BaseViewController.configureLayout before subclass views when subclasses call
super.configureLayout() first, causing it to sit behind subclass content; update
the layout flow so networkErrorView is always topmost by either (A) moving the
networkErrorView addition and its constraints out of configureLayout into a
point after subclass layout (e.g., require subclasses to call
super.configureLayout() last) or (B) keep it in configureLayout but immediately
call bringSubviewToFront(networkErrorView) (or call handleNetworkErrorView) at
the end of configureLayout to ensure networkErrorView is brought above any
subclass-added views; reference networkErrorView, configureLayout,
handleNetworkErrorView, bringSubviewToFront and super.configureLayout() when
making the change.

In `@Projects/Presentation/Sources/Home/View/HomeViewController.swift`:
- Around line 263-265: The dimmedView/floatingMenu/floatingButton are added
after super.configureLayout() which added networkErrorView, causing z-order
conflicts when a network error occurs while the floating menu is open; update
handleNetworkErrorView to first close or hide the floating menu and dimmedView
(e.g., call floatingMenu.dismiss()/close() or set floatingMenu.isHidden = true
and set dimmedView.isHidden = true/removeFromSuperview()) before presenting or
bringing networkErrorView to front, or alternatively ensure you call
view.bringSubviewToFront(networkErrorView) and then hide/remove dimmedView so
the networkErrorView is never obscured.

In `@Projects/Presentation/Sources/Report/ViewModel/ReportHistoryViewModel.swift`:
- Around line 168-174: The catch currently only passes errors to
networkRetryHandler.handleNetworkError (which handles DomainError.requireRetry)
so non-network errors (.business, .unknown) are ignored; update the catch in
fetchReports to distinguish DomainError.requireRetry vs other errors by
inspecting the thrown error, call networkRetryHandler.handleNetworkError for
retryable DomainError cases, and for .business/.unknown paths invoke a
user-feedback mechanism (e.g., showToast/showAlert/logError) and keep the
existing TODO comment removed; reference networkRetryHandler.handleNetworkError,
fetchReports, and DomainError.requireRetry/.business/.unknown when adding the
conditional handling and feedback call.

Comment on lines +37 to +52
do {
guard let response = try await networkService.request(endpoint: endpoint, type: EmotionResponseDTO.self)
else { throw NetworkError.unknown(description: "Emotion Reponse를 받아오지 못했습니다.") }

let emotionEntity = response.toEmotionEntity()
return emotionEntity
} catch let error as NetworkError {
switch error {
case .needRetry, .invalidURL, .emptyData:
throw DomainError.requireRetry
default:
throw DomainError.business(error.description)
}
} catch {
throw DomainError.unknown
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

loadEmotion에서 NetworkError를 직접 throw한 후 같은 메서드 내에서 다시 catch하는 것은 우회적입니다.

Line 39에서 response가 nil일 때 NetworkError.unknown을 throw하는데, 이는 바로 아래 catch let error as NetworkError에서 잡혀 DomainError.business로 변환됩니다. 직접 DomainError를 throw하는 것이 더 명확합니다. 또한 "Reponse"는 "Response"의 오타입니다.

🛠️ 수정 제안
         do {
             guard let response = try await networkService.request(endpoint: endpoint, type: EmotionResponseDTO.self)
-            else { throw NetworkError.unknown(description: "Emotion Reponse를 받아오지 못했습니다.") }
+            else { throw DomainError.business("Emotion Response를 받아오지 못했습니다.") }
 
             let emotionEntity = response.toEmotionEntity()
             return emotionEntity
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/DataSource/Sources/Repository/EmotionRepository.swift` around lines
37 - 52, In loadEmotion, don’t throw a NetworkError when the response is nil
then immediately catch and map it — throw the appropriate DomainError directly
instead; replace the guard’s throw NetworkError.unknown(...) with throw
DomainError.business("Emotion Response를 받아오지 못했습니다.") (fixing the "Reponse" ->
"Response" typo), so the downstream catch for NetworkError in the same method no
longer needs to translate this case.

Comment on lines +17 to +30
do {
guard let response = try await networkService.request(endpoint: endpoint, type: OnboardingResponseDTO.self)
else { throw UserError.onboardingLoadFailed }

let onboardingEntity = response.toOnboardingEntity()
return onboardingEntity
} catch let error as NetworkError {
switch error {
case .needRetry, .invalidURL, .emptyData:
throw DomainError.requireRetry
default:
throw DomainError.business(error.description)
}
}
Copy link

@coderabbitai coderabbitai bot Feb 17, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

loadOnboardingResult에 일반 catch 블록이 누락되었습니다.

다른 모든 리포지토리 메서드(registerOnboarding, registerRecommendedRoutines 포함)에는 catch { throw DomainError.unknown } 블록이 있지만, 이 메서드에만 누락되었습니다. Line 19에서 throw UserError.onboardingLoadFailed가 do 블록 안에서 발생하면 catch let error as NetworkError에 매칭되지 않아 UserError가 그대로 전파됩니다. 의도적이라면 괜찮지만, 일관성을 위해 확인이 필요합니다.

🐛 일관성을 위한 수정 제안

UserError.onboardingLoadFailed를 의도적으로 전파하려면 do 블록 밖으로 이동하거나, 일반 catch를 추가하세요:

         } catch let error as NetworkError {
             switch error {
             case .needRetry, .invalidURL, .emptyData:
                 throw DomainError.requireRetry
             default:
                 throw DomainError.business(error.description)
             }
+        } catch let error as UserError {
+            throw error
+        } catch {
+            throw DomainError.unknown
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/DataSource/Sources/Repository/OnboardingRepository.swift` around
lines 17 - 30, The method loadOnboardingResult currently only catches
NetworkError (catch let error as NetworkError) so non-network errors like
UserError.onboardingLoadFailed will escape; to align with other repository
methods (e.g., registerOnboarding, registerRecommendedRoutines) add a general
catch after the NetworkError catch that throws DomainError.unknown, or
alternatively move the throw UserError.onboardingLoadFailed outside the do so
it’s handled consistently; update loadOnboardingResult accordingly to ensure all
non-NetworkError exceptions are mapped to DomainError.unknown.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

UserError.onboardingLoadFailed 를 그대로 throw 하기 위한 의도된 부분입니다

Copy link

Choose a reason for hiding this comment

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

@taipaise 이해했습니다! UserError.onboardingLoadFailedDomainError로 변환하지 않고 그대로 전파하기 위한 의도된 설계라는 것을 확인했습니다. 감사합니다.


✏️ Learnings added
Learnt from: taipaise
Repo: YAPP-Github/Bitnagil-iOS PR: 85
File: Projects/DataSource/Sources/Repository/OnboardingRepository.swift:17-30
Timestamp: 2026-02-17T11:00:01.598Z
Learning: OnboardingRepository의 loadOnboardingResult 메서드에서 UserError.onboardingLoadFailed는 의도적으로 DomainError로 변환하지 않고 그대로 throw 됩니다. 이는 다른 메서드들(registerOnboarding, registerRecommendedRoutines)과 달리 일반 catch 블록이 없는 이유이며, NetworkError만 DomainError로 변환하고 UserError는 원본 그대로 전파하는 것이 의도된 동작입니다.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: choijungp
Repo: YAPP-Github/Bitnagil-iOS PR: 19
File: Projects/Domain/Sources/UseCase/Onboarding/OnboardingUseCase.swift:0-0
Timestamp: 2025-07-16T10:58:30.858Z
Learning: choijungp는 OnboardingUseCase의 convertToDictionary 함수에서 에러 처리 개선을 추후 고민하고 반영할 계획을 가지고 있음.

Learnt from: choijungp
Repo: YAPP-Github/Bitnagil-iOS PR: 19
File: Projects/Presentation/Sources/Onboarding/View/OnboardingRecommendedRoutineView.swift:57-59
Timestamp: 2025-07-16T09:21:15.038Z
Learning: OnboardingRecommendedRoutineView에서 viewWillAppear에 registerOnboarding 호출하는 것이 적절한 이유: 사용자가 이전 페이지에서 온보딩 선택지를 변경한 후 돌아올 때 새로운 선택지로 다시 등록해야 하기 때문. 홈 뷰에서는 이 뷰로 돌아올 수 없어서 중복 호출 문제가 발생하지 않음.

Comment on lines +29 to +38
} catch let error as NetworkError {
switch error {
case .needRetry, .invalidURL, .emptyData:
throw DomainError.requireRetry
default:
throw DomainError.business(error.description)
}
} catch {
throw DomainError.unknown
}
Copy link

@coderabbitai coderabbitai bot Feb 17, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

.invalidURLDomainError.requireRetry로 매핑하는 것은 부적절합니다.

invalidURL은 프로그래밍 오류로 재시도해도 해결되지 않습니다. emptyData도 마찬가지로 재시도로 해결될 가능성이 낮습니다. 사용자에게 "다시 시도" 화면을 보여줘야 하는 케이스는 .needRetry(네트워크 연결 문제)만 해당될 것으로 보입니다.

🛠️ 에러 매핑 수정 제안
         } catch let error as NetworkError {
             switch error {
-            case .needRetry, .invalidURL, .emptyData:
+            case .needRetry:
                 throw DomainError.requireRetry
             default:
                 throw DomainError.business(error.description)
             }
📝 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
} catch let error as NetworkError {
switch error {
case .needRetry, .invalidURL, .emptyData:
throw DomainError.requireRetry
default:
throw DomainError.business(error.description)
}
} catch {
throw DomainError.unknown
}
} catch let error as NetworkError {
switch error {
case .needRetry:
throw DomainError.requireRetry
default:
throw DomainError.business(error.description)
}
} catch {
throw DomainError.unknown
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/DataSource/Sources/Repository/RoutineRepository.swift` around lines
29 - 38, The catch block in RoutineRepository currently maps
NetworkError.invalidURL and .emptyData to DomainError.requireRetry; change it so
only .needRetry maps to DomainError.requireRetry, while .invalidURL and
.emptyData are mapped to non-retry errors (e.g.,
DomainError.business(error.description) or DomainError.unknown as appropriate).
Update the catch handling for "catch let error as NetworkError" to switch
.needRetry -> DomainError.requireRetry and handle .invalidURL and .emptyData by
throwing a business/unknown domain error, leaving other cases to the default
branch that throws DomainError.business(error.description).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

인지하고 있으며, 추후 수정하기 위해 TODO List에 넣어놨습니다

Copy link

Choose a reason for hiding this comment

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

@taipaise 네, 이해했습니다! 나중에 수정하실 계획이시군요.

필요하시다면 이 TODO 항목을 추적하기 위해 GitHub 이슈를 생성해드릴 수 있습니다. 이슈에는 이 PR과 코멘트 링크를 포함하여 나중에 쉽게 찾아볼 수 있도록 하겠습니다. 원하시면 말씀해주세요!


🧠 Learnings used
Learnt from: taipaise
Repo: YAPP-Github/Bitnagil-iOS PR: 85
File: Projects/DataSource/Sources/Repository/OnboardingRepository.swift:17-30
Timestamp: 2026-02-17T11:00:01.598Z
Learning: OnboardingRepository의 loadOnboardingResult 메서드에서 UserError.onboardingLoadFailed는 의도적으로 DomainError로 변환하지 않고 그대로 throw 됩니다. 이는 다른 메서드들(registerOnboarding, registerRecommendedRoutines)과 달리 일반 catch 블록이 없는 이유이며, NetworkError만 DomainError로 변환하고 UserError는 원본 그대로 전파하는 것이 의도된 동작입니다.

Comment on lines +60 to +61
retryButton.tintColor = BitnagilColor.gray10
retryButton.backgroundColor = BitnagilColor.gray10
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

tintColorbackgroundColor가 동일한 값(BitnagilColor.gray10)으로 설정되어 있습니다.

tintColor는 버튼의 이미지나 시스템 아이콘에 영향을 주는데, backgroundColor와 동일하게 설정하면 향후 아이콘 추가 시 보이지 않을 수 있습니다. tintColor 설정이 의도적인지 확인해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift` around
lines 60 - 61, retryButton currently sets both retryButton.tintColor and
retryButton.backgroundColor to BitnagilColor.gray10 which will hide any icon or
system image; inspect the NetworkErrorView (or the retryButton setup) and either
remove the tintColor assignment if you want the default system tint, or change
retryButton.tintColor to a contrasting color (e.g., BitnagilColor.white or a
semantic accent color) so icons remain visible while leaving
retryButton.backgroundColor as BitnagilColor.gray10.

@taipaise taipaise requested a review from choijungp February 17, 2026 10:35
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift (1)

79-91: 탭 바를 직접 숨기는 VC에서 tabBar.isHidden = false 복원이 충돌할 수 있습니다.

handleNetworkErrorView(show: false)deinit에서 tabBar.isHidden = false를 설정하는데, hidesBottomBarWhenPushed = true로 push된 VC에서 네트워크 에러가 발생 후 복구되면 탭 바가 의도치 않게 표시될 수 있습니다.

현재 사용처가 탭 레벨 화면에 한정되어 있다면 문제가 없지만, 향후 확장 시 주의가 필요합니다. 네트워크 에러 표시 전 탭 바의 이전 상태를 저장하고 복원하는 방식을 고려해 보세요.

♻️ 수정 제안
+    private var previousTabBarHiddenState: Bool = false
+
     private func handleNetworkErrorView(show: Bool) {
         isShowingNetworkError = show
 
         if show {
+            previousTabBarHiddenState = tabBarController?.tabBar.isHidden ?? false
             view.bringSubviewToFront(networkErrorView)
             networkErrorView.isHidden = false
             tabBarController?.tabBar.isHidden = true
         } else {
             networkErrorView.isHidden = true
-            tabBarController?.tabBar.isHidden = false
+            tabBarController?.tabBar.isHidden = previousTabBarHiddenState
         }
     }

deinit도 동일하게 적용:

     deinit {
         guard isShowingNetworkError else { return }
         DispatchQueue.main.async { [weak tabBarController = self.tabBarController] in
-            tabBarController?.tabBar.isHidden = false
+            tabBarController?.tabBar.isHidden = self.previousTabBarHiddenState
         }
     }

주의: deinit에서 self는 이미 해제 중이므로, previousTabBarHiddenState 값도 캡처 리스트에 포함해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift`
around lines 79 - 91, handleNetworkErrorView currently forces
tabBarController?.tabBar.isHidden = true/false which can incorrectly show the
tab bar for VCs pushed with hidesBottomBarWhenPushed; save the tab bar's
previous hidden state before hiding (e.g. store previousTabBarHiddenState when
show==true), restore that saved state when show==false, and also restore it in
deinit (capture the saved previousTabBarHiddenState in the deinit
closure/context since self may be deallocating). Update references:
handleNetworkErrorView, isShowingNetworkError, networkErrorView,
tabBarController?.tabBar.isHidden and deinit to use this save-and-restore
approach.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift`:
- Around line 60-61: The retryButton currently sets both tintColor and
backgroundColor to BitnagilColor.gray10 which will hide any templated image or
system icon; update the retryButton configuration (the retryButton instance) to
use a contrasting tintColor (e.g., .white) or remove the tintColor assignment
entirely so icons remain visible while keeping backgroundColor as
BitnagilColor.gray10.

---

Nitpick comments:
In `@Projects/Presentation/Sources/Common/Protocol/BaseViewController.swift`:
- Around line 79-91: handleNetworkErrorView currently forces
tabBarController?.tabBar.isHidden = true/false which can incorrectly show the
tab bar for VCs pushed with hidesBottomBarWhenPushed; save the tab bar's
previous hidden state before hiding (e.g. store previousTabBarHiddenState when
show==true), restore that saved state when show==false, and also restore it in
deinit (capture the saved previousTabBarHiddenState in the deinit
closure/context since self may be deallocating). Update references:
handleNetworkErrorView, isShowingNetworkError, networkErrorView,
tabBarController?.tabBar.isHidden and deinit to use this save-and-restore
approach.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift (1)

83-88: ⚠️ Potential issue | 🟠 Major

configureAttribute()configureLayout()이 두 번 호출됩니다.

BaseViewController.viewDidLoad()에서 이미 configureAttribute(), configureLayout(), bind()를 호출하고 있으므로, Line 85-86에서 다시 명시적으로 호출하면 이 메서드들이 두 번 실행됩니다.

이번 PR에서 super.configureAttribute()super.configureLayout() 호출이 추가되었기 때문에, BaseViewController.configureLayout()이 두 번 실행되면서 networkErrorView에 SnapKit 제약 조건이 중복 추가될 수 있습니다.

Line 85-86의 명시적 호출을 제거하고, viewModel.action 호출만 남기는 것을 권장합니다.

제안된 수정
 override func viewDidLoad() {
     super.viewDidLoad()
-    configureAttribute()
-    configureLayout()
     viewModel.action(input: .fetchReportDetail(reportId: reportId))
 }

Based on learnings: "BaseViewController의 viewDidLoad() 메서드에서 이미 configureAttribute(), configureLayout(), bind()를 호출하므로, 하위 클래스에서 super.viewDidLoad()를 호출하면 이 메서드들이 자동으로 호출된다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift`
around lines 83 - 88, The subclass ReportCompleteViewController currently calls
configureAttribute() and configureLayout() explicitly in viewDidLoad() even
though BaseViewController.viewDidLoad() already invokes
super.configureAttribute(), super.configureLayout(), and bind(); remove the
duplicate calls on Lines 85–86 so viewDidLoad() only calls super.viewDidLoad()
and then the remaining unique work (keep the existing viewModel.action(input:
.fetchReportDetail(reportId: reportId)) call) to avoid double-running
configureAttribute()/configureLayout() and duplicate SnapKit constraints on
networkErrorView.
🧹 Nitpick comments (9)
Projects/Presentation/Sources/RoutineList/View/RoutineDeleteAlertViewController.swift (1)

56-57: super 호출 패턴은 PR 전체와 일관성 있음 — 다만 이 VC에서의 실효성 검토 필요.

이 VC는 루틴 삭제 확인용 소형 모달(alert)인데, super.configureLayout()networkErrorViewview에 full-screen으로 추가합니다. bindNetworkError를 호출하지 않으므로 실제로 보이지는 않지만, 불필요한 뷰가 뷰 계층에 추가됩니다.

현재 동작에 문제가 있는 것은 아니며, PR 전반의 일관된 패턴을 따른 것으로 이해됩니다. 다만 모달/alert 스타일 VC에는 네트워크 에러 뷰가 필요 없으므로, 향후 BaseViewController를 네트워크 에러가 필요한 VC와 그렇지 않은 VC로 분리하거나, opt-in 방식으로 전환하는 것을 고려해 볼 수 있습니다.

Also applies to: 108-109

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Projects/Presentation/Sources/RoutineList/View/RoutineDeleteAlertViewController.swift`
around lines 56 - 57, The controller currently calls super.configureLayout()
which adds the full-screen networkErrorView into the view hierarchy even though
this alert VC doesn't use network errors; in RoutineDeleteAlertViewController
remove that unnecessary view by either not calling super.configureLayout() (keep
super.configureAttribute() if needed) or call super.configureLayout() then
remove networkErrorView from self.view, or introduce an opt-in flag on
BaseViewController so configureLayout only adds networkErrorView when enabled;
reference configureAttribute, configureLayout, networkErrorView,
bindNetworkError, and BaseViewController when making the change.
Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift (1)

24-29: onRetry의 접근 제어 수준을 명시하는 것을 고려해 주세요.

onRetry가 명시적 접근 제어자 없이 선언되어 internal로 동작합니다. 의도에 맞다면 괜찮지만, 나머지 프로퍼티들이 private으로 선언된 것과의 일관성을 위해 명시적으로 표기하면 가독성이 향상됩니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift` around
lines 24 - 29, Explicitly annotate the access level for the onRetry property in
NetworkErrorView to remove the implicit internal and improve consistency with
the other private properties; update the declaration of onRetry to the intended
access level (for example: internal var onRetry: (() -> Void)? if it should
remain module-visible, or public var onRetry: (() -> Void)? if it must be
exposed to clients, or private(set) var onRetry: (() -> Void)? if external
read-only access is desired).
Projects/DataSource/Sources/Repository/RecommendedRoutineRepository.swift (1)

47-56: 에러 매핑 로직이 두 메서드에서 중복됨

NetworkErrorDomainError 변환 로직이 fetchRecommendedRoutinefetchRecommendedRoutines에 동일하게 반복되며, AI 요약에 따르면 다른 Repository 파일들에서도 같은 패턴이 사용됩니다. 헬퍼 함수나 Error extension으로 추출하면 일관성 유지와 향후 매핑 변경 시 수정 포인트를 줄일 수 있습니다.

예시: NetworkError extension 추출
// 예: NetworkError+DomainError.swift
extension NetworkError {
    func toDomainError() -> DomainError {
        switch self {
        case .needRetry:
            return .requireRetry
        default:
            return .business(self.description)
        }
    }
}

그러면 각 catch 블록이 아래처럼 간결해집니다:

-        } catch let error as NetworkError {
-            switch error {
-            case .needRetry, .invalidURL, .emptyData:
-                throw DomainError.requireRetry
-            default:
-                throw DomainError.business(error.description)
-            }
+        } catch let error as NetworkError {
+            throw error.toDomainError()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/DataSource/Sources/Repository/RecommendedRoutineRepository.swift`
around lines 47 - 56, The NetworkError→DomainError mapping is duplicated in
fetchRecommendedRoutine and fetchRecommendedRoutines; extract that logic into a
single helper (e.g., add NetworkError extension with func toDomainError() ->
DomainError or a static mapper) and replace the catch let error as NetworkError
blocks in RecommendedRoutineRepository.fetchRecommendedRoutine and
fetchRecommendedRoutines to throw error.toDomainError() (or the helper result);
update other repositories using the same pattern to call the same helper so
mapping stays consistent.
Projects/DataSource/Sources/Repository/OnboardingRepository.swift (1)

41-56: 중복된 에러 매핑 로직을 헬퍼로 추출하는 것을 권장

NetworkErrorDomainError 변환 switch 문이 세 메서드에 동일하게 반복되고 있습니다. 다른 Repository 파일들에서도 같은 패턴이 사용될 것으로 보이므로, 공통 헬퍼 함수나 NetworkError extension으로 추출하면 유지보수성이 향상됩니다.

♻️ 리팩터링 예시

NetworkError extension 또는 별도 매퍼 함수:

extension NetworkError {
    func toDomainError() -> DomainError {
        switch self {
        case .needRetry, .emptyData:
            return .requireRetry
        default:
            return .business(self.description)
        }
    }
}

사용 시:

         } catch let error as NetworkError {
-            switch error {
-            case .needRetry, .invalidURL, .emptyData:
-                throw DomainError.requireRetry
-            default:
-                throw DomainError.business(error.description)
-            }
+            throw error.toDomainError()
         } catch {

Also applies to: 62-73

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/DataSource/Sources/Repository/OnboardingRepository.swift` around
lines 41 - 56, The NetworkError-to-DomainError switch repeated in
OnboardingRepository's async request handlers should be extracted into a single
helper (e.g., an extension method like NetworkError.toDomainError() or a shared
mapper function); replace the switch in the catch let error as NetworkError
blocks with throw error.toDomainError() (or throw map(error)) and ensure the
mapping covers .needRetry and .emptyData → .requireRetry and all other cases →
.business(error.description), leaving the existing generic catch to throw
.unknown.
Projects/DataSource/Sources/Repository/RoutineRepository.swift (1)

27-38: 반복되는 에러 변환 로직을 헬퍼로 추출하는 것을 권장합니다.

7개 메서드에 동일한 do-catch + NetworkError→DomainError 변환 블록이 복사되어 있습니다. 헬퍼 함수로 추출하면 유지보수성이 크게 향상됩니다.

♻️ 리팩토링 제안
+    private func performNetworkRequest<T: Decodable>(
+        endpoint: Endpoint,
+        type: T.Type
+    ) async throws -> T? {
+        do {
+            return try await networkService.request(endpoint: endpoint, type: type)
+        } catch let error as NetworkError {
+            switch error {
+            case .needRetry:
+                throw DomainError.requireRetry
+            default:
+                throw DomainError.business(error.description)
+            }
+        } catch {
+            throw DomainError.unknown
+        }
+    }

각 메서드에서 사용 예시:

func createRoutine(routine: RoutineCreationEntity) async throws {
    // ... DTO 생성 ...
    let endpoint = RoutineEndpoint.createRoutine(routine: routineCreationDTO)
    _ = try await performNetworkRequest(endpoint: endpoint, type: EmptyResponseDTO.self)
}

Also applies to: 44-57, 63-83, 99-110, 116-127, 133-144, 156-167

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/DataSource/Sources/Repository/RoutineRepository.swift` around lines
27 - 38, Several methods in RoutineRepository duplicate the same do-catch that
calls networkService.request and maps NetworkError cases (.needRetry,
.invalidURL, .emptyData) to DomainError.requireRetry and other errors to
DomainError.business(error.description); extract that logic into a helper (e.g.,
performNetworkRequest(endpoint:type:)) and replace each duplicate block (seen in
methods like createRoutine which currently calls networkService.request with
EmptyResponseDTO.self) with a single call to the helper; ensure the helper
performs the await networkService.request(endpoint:type:), catches NetworkError
and maps the same cases to DomainError.requireRetry and others to
DomainError.business(error.description), and rethrows DomainError.
Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift (1)

12-14: CurrentValueSubject에 클로저를 저장하면 retain cycle 위험이 있습니다.

networkErrorActionSubject(() -> Void)? 타입의 클로저를 보유하고, 호출부에서 [weak self]로 캡처하지 않으면 ViewModel이 해제되지 않을 수 있습니다. 프로토콜 doc comment에 [weak self] 사용을 명시하거나, clearRetryState()가 적시에 호출되는지 확인이 필요합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift`
around lines 12 - 14, The protocol stores a closure in networkErrorActionSubject
which can create retain cycles; update the doc comment on
NetworkRetryCapable.networkErrorActionSubject to explicitly require callers to
capture [weak self] (or otherwise avoid strong self) when assigning closures and
to state that implementers must call clearRetryState() (or nil out the subject)
promptly (e.g., in deinit or after a retry); alternatively note that
implementers may replace the stored closure with a weak-wrapper type if they
prefer to avoid relying on caller discipline.
Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift (3)

126-156: loadNicknamefetchEmotion에서 동시 실패 시 하나의 retry action만 유지됩니다.

HomeViewModel은 초기 로딩 시 loadNickname, fetchEmotion, fetchRoutines, checkVersion 등을 동시에 호출할 가능성이 높습니다. 네트워크가 끊긴 상태에서는 모든 호출이 실패하지만, CurrentValueSubject는 마지막 retry action만 보관합니다.

사용자가 "다시 시도"를 누르면 하나의 호출만 재시도되고, 그 성공 시 clearRetryState()가 에러 뷰를 숨기므로 나머지 실패한 호출들은 재시도되지 않습니다.

권장안: HomeViewModel의 경우, retry action에서 모든 초기 로딩 호출을 다시 수행하는 단일 클로저를 사용하는 것이 더 안정적입니다:

💡 예시
// 초기 로딩 실패 시 모든 호출을 재시도하는 방식
networkRetryHandler.handleNetworkError(error) { [weak self] in
    self?.loadNickname()
    self?.fetchEmotion()
    self?.fetchRoutines()
    self?.checkVersion()
}

또는 초기 로딩을 하나의 메서드로 묶어서 재시도 대상으로 설정할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift` around
lines 126 - 156, The retry handler currently stores only the last retry closure,
so when multiple initial load methods (loadNickname, fetchEmotion,
fetchRoutines, checkVersion) fail concurrently only one will be retried; change
each catch block to register a single retry action that re-runs all initial load
steps (or call a new unified method like performInitialLoad that invokes
loadNickname(), fetchEmotion(), fetchRoutines(), checkVersion()), and keep using
networkRetryHandler.handleNetworkError(error) with that combined closure and
preserve calling networkRetryHandler.clearRetryState() on success.

221-229: fetchRoutines(startDate:endDate:) 재시도 시 파라미터 캡처가 올바릅니다.

startDateendDate가 클로저에 정확히 캡처되어, 재시도 시 원래 요청과 동일한 범위를 조회합니다.

Line 225의 TODO: - 네트워크 에러 제외 오류 처리가 남아 있으니, 향후 비네트워크 에러에 대한 처리도 추가하시면 좋겠습니다.

비네트워크 에러 처리 로직 구현을 도와드릴까요? 이슈를 생성해 드릴 수도 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift` around
lines 221 - 229, The TODO indicates missing handling for non-network errors in
fetchRoutines(startDate:endDate:); implement logic to distinguish network errors
from others inside the catch where networkRetryHandler.handleNetworkError(...)
is called: use the caught error to check/branch (e.g., isNetworkError predicate
or error type), for non-network errors send a meaningful failure state via
fetchRoutineResultSubject, log or report the error (use existing logger), and
surface/localize a user-facing error message or error state so the UI can react;
update the catch block around fetchRoutines to perform this non-network error
branch while keeping the existing networkRetryHandler.handleNetworkError(...)
call for network errors and retaining captured startDate/endDate for retries.

301-324: checkVersion 실패 시 네트워크 재시도가 적용되었습니다.

버전 체크는 앱 시작 시 한 번 호출되는 기능으로, 네트워크 에러 시 재시도 대상으로 포함시킨 것은 합리적입니다. 다만 버전 체크 실패가 네트워크 에러 뷰를 표시하는 것이 사용자 경험상 적절한지 검토해 주세요 — 버전 체크 실패는 사일런트 처리하는 것이 더 적합할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift` around
lines 301 - 324, The current checkVersion() uses
networkRetryHandler.handleNetworkError(...) on any failure which triggers the
network error UI; instead, treat version check failures silently: in the catch
block for checkVersion(), do not call networkRetryHandler.handleNetworkError —
simply log the error (or ignore), send updateVersionSubject.send(nil) to clear
any prompt, and optionally call networkRetryHandler.clearRetryState(); update
references in the method (checkVersion, appConfigRepository.fetchAppVersion,
updateVersionSubject, networkRetryHandler) accordingly so the version check
failure does not surface the network error view to users.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Projects/DataSource/Sources/Repository/LocationRepository.swift`:
- Around line 53-59: The catch mapping in LocationRepository currently treats
.invalidURL and .emptyData as transient and throws DomainError.requireRetry;
change the switch in the NetworkError catch so only .needRetry maps to
DomainError.requireRetry, and map .invalidURL and .emptyData to
DomainError.business with the error description (i.e., treat them as
non-retryable business errors). Update the same pattern in the other
repositories that share this logic (EmotionRepository, FileRepository,
OnboardingRepository, RecommendedRoutineRepository, ReportRepository,
RoutineRepository) so all use .needRetry => DomainError.requireRetry and all
other NetworkError cases => DomainError.business(error.description).

In `@Projects/DataSource/Sources/Repository/OnboardingRepository.swift`:
- Around line 24-29: The switch currently maps `.invalidURL` to
DomainError.requireRetry along with `.needRetry` and `.emptyData`; change the
mapping so only transient errors (`.needRetry` and `.emptyData`) throw
DomainError.requireRetry, while `.invalidURL` should throw
DomainError.business(error.description). Update this logic in the switch blocks
inside OnboardingRepository's three methods (the switch handling `error` that
currently lists `.needRetry, .invalidURL, .emptyData`) and apply the same fix
consistently across other repositories that repeat this pattern (e.g.,
RoutineRepository, FileRepository, ReportRepository) so `.invalidURL` is treated
as a business error while retryable cases remain requireRetry.

In `@Projects/DataSource/Sources/Repository/RecommendedRoutineRepository.swift`:
- Around line 22-27: The switch incorrectly maps .invalidURL and .emptyData to
DomainError.requireRetry (only .needRetry should trigger retries); update the
switch in RecommendedRoutineRepository (and the same pattern in
LocationRepository, RoutineRepository, ReportRepository, OnboardingRepository,
FileRepository, EmotionRepository, etc.) so that case .needRetry throws
DomainError.requireRetry, while .invalidURL and .emptyData (and other
non-transient cases) throw DomainError.business(error.description) (preserving
the existing default behavior) to avoid prompting retries for permanent errors.

In `@Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift`:
- Around line 27-37: handleNetworkError currently returns silently for
non-DomainError.requireRetry errors, causing DomainError.business/unknown to be
ignored; update handleNetworkError to log or forward non-retry errors instead of
silently returning: inside the else branch (where you now comment "// 네트워크 에러
문제가 아닐 경우") call a logger (or send the error to an existing error reporting
subject) and/or expose outcome to callers (e.g., return a Bool handled flag or
add a nonRetryErrorSubject) so ViewModels can surface or handle
DomainError.business/unknown; refer to handleNetworkError,
DomainError.requireRetry, and networkErrorActionSubject when making the change.

In
`@Projects/Presentation/Sources/ResultRecommendedRoutine/ViewModel/ResultRecommendedRoutineViewModel.swift`:
- Around line 166-173: In ResultRecommendedRoutineViewModel, avoid emitting
registerRoutineResultSubject.send(false) for retryable network errors
(DomainError.requireRetry) so the VC doesn't get a duplicate error UI; modify
the catch in registerRecommendedRoutine() to detect DomainError.requireRetry (or
use networkRetryHandler.isRetryable(error)) and only call
registerRoutineResultSubject.send(false) when the error is not retryable,
leaving networkRetryHandler.handleNetworkError(error) {
self?.registerRecommendedRoutine() } to handle retryable cases; reference
registerRoutineResultSubject.send(false),
networkRetryHandler.handleNetworkError(error){...}, DomainError.requireRetry,
and registerRoutineResultPublisher so VC receives a single error signal.

---

Outside diff comments:
In
`@Projects/Presentation/Sources/Report/View/ReportCompleteViewController.swift`:
- Around line 83-88: The subclass ReportCompleteViewController currently calls
configureAttribute() and configureLayout() explicitly in viewDidLoad() even
though BaseViewController.viewDidLoad() already invokes
super.configureAttribute(), super.configureLayout(), and bind(); remove the
duplicate calls on Lines 85–86 so viewDidLoad() only calls super.viewDidLoad()
and then the remaining unique work (keep the existing viewModel.action(input:
.fetchReportDetail(reportId: reportId)) call) to avoid double-running
configureAttribute()/configureLayout() and duplicate SnapKit constraints on
networkErrorView.

---

Duplicate comments:
In `@Projects/DataSource/Sources/Repository/ReportRepository.swift`:
- Around line 37-50: Update the catch mappings in ReportRepository so only
NetworkError.needRetry maps to DomainError.requireRetry: in the catch blocks
that handle "catch let error as NetworkError" (the one shown plus the catch
blocks in fetchReports and fetchReportDetail), remove .invalidURL and .emptyData
from the .needRetry case and instead handle them as general business errors
(e.g., throw DomainError.business(error.description) or the existing default
branch). Locate the catch around the networkService.request calls in the methods
(the shown request and the ones in fetchReports and fetchReportDetail) and
change the switch so case .needRetry -> throw DomainError.requireRetry, and all
other NetworkError cases fall through to the default business error handling.

In `@Projects/DataSource/Sources/Repository/RoutineRepository.swift`:
- Around line 27-38: The switch in the NetworkError catch is incorrectly mapping
.invalidURL and .emptyData to DomainError.requireRetry; change the mapping so
only .needRetry maps to DomainError.requireRetry and all other NetworkError
cases (including .invalidURL and .emptyData) map to
DomainError.business(error.description) (preserving the default catch to
DomainError.unknown), and apply this same fix to the other six methods that
repeat the pattern (look for networkService.request(...) and the NetworkError
catch block).

In `@Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift`:
- Around line 60-61: The retryButton currently sets both tintColor and
backgroundColor to BitnagilColor.gray10 in NetworkErrorView (retryButton),
causing future icon images to be invisible against the same-colored tint; remove
the unnecessary tintColor assignment (retryButton.tintColor =
BitnagilColor.gray10) so the button will inherit a default/contrasting tint or
explicitly set tintColor to a contrasting color when adding an icon.

---

Nitpick comments:
In `@Projects/DataSource/Sources/Repository/OnboardingRepository.swift`:
- Around line 41-56: The NetworkError-to-DomainError switch repeated in
OnboardingRepository's async request handlers should be extracted into a single
helper (e.g., an extension method like NetworkError.toDomainError() or a shared
mapper function); replace the switch in the catch let error as NetworkError
blocks with throw error.toDomainError() (or throw map(error)) and ensure the
mapping covers .needRetry and .emptyData → .requireRetry and all other cases →
.business(error.description), leaving the existing generic catch to throw
.unknown.

In `@Projects/DataSource/Sources/Repository/RecommendedRoutineRepository.swift`:
- Around line 47-56: The NetworkError→DomainError mapping is duplicated in
fetchRecommendedRoutine and fetchRecommendedRoutines; extract that logic into a
single helper (e.g., add NetworkError extension with func toDomainError() ->
DomainError or a static mapper) and replace the catch let error as NetworkError
blocks in RecommendedRoutineRepository.fetchRecommendedRoutine and
fetchRecommendedRoutines to throw error.toDomainError() (or the helper result);
update other repositories using the same pattern to call the same helper so
mapping stays consistent.

In `@Projects/DataSource/Sources/Repository/RoutineRepository.swift`:
- Around line 27-38: Several methods in RoutineRepository duplicate the same
do-catch that calls networkService.request and maps NetworkError cases
(.needRetry, .invalidURL, .emptyData) to DomainError.requireRetry and other
errors to DomainError.business(error.description); extract that logic into a
helper (e.g., performNetworkRequest(endpoint:type:)) and replace each duplicate
block (seen in methods like createRoutine which currently calls
networkService.request with EmptyResponseDTO.self) with a single call to the
helper; ensure the helper performs the await
networkService.request(endpoint:type:), catches NetworkError and maps the same
cases to DomainError.requireRetry and others to
DomainError.business(error.description), and rethrows DomainError.

In `@Projects/Presentation/Sources/Common/Component/NetworkErrorView.swift`:
- Around line 24-29: Explicitly annotate the access level for the onRetry
property in NetworkErrorView to remove the implicit internal and improve
consistency with the other private properties; update the declaration of onRetry
to the intended access level (for example: internal var onRetry: (() -> Void)?
if it should remain module-visible, or public var onRetry: (() -> Void)? if it
must be exposed to clients, or private(set) var onRetry: (() -> Void)? if
external read-only access is desired).

In `@Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift`:
- Around line 12-14: The protocol stores a closure in networkErrorActionSubject
which can create retain cycles; update the doc comment on
NetworkRetryCapable.networkErrorActionSubject to explicitly require callers to
capture [weak self] (or otherwise avoid strong self) when assigning closures and
to state that implementers must call clearRetryState() (or nil out the subject)
promptly (e.g., in deinit or after a retry); alternatively note that
implementers may replace the stored closure with a weak-wrapper type if they
prefer to avoid relying on caller discipline.

In `@Projects/Presentation/Sources/Home/ViewModel/HomeViewModel.swift`:
- Around line 126-156: The retry handler currently stores only the last retry
closure, so when multiple initial load methods (loadNickname, fetchEmotion,
fetchRoutines, checkVersion) fail concurrently only one will be retried; change
each catch block to register a single retry action that re-runs all initial load
steps (or call a new unified method like performInitialLoad that invokes
loadNickname(), fetchEmotion(), fetchRoutines(), checkVersion()), and keep using
networkRetryHandler.handleNetworkError(error) with that combined closure and
preserve calling networkRetryHandler.clearRetryState() on success.
- Around line 221-229: The TODO indicates missing handling for non-network
errors in fetchRoutines(startDate:endDate:); implement logic to distinguish
network errors from others inside the catch where
networkRetryHandler.handleNetworkError(...) is called: use the caught error to
check/branch (e.g., isNetworkError predicate or error type), for non-network
errors send a meaningful failure state via fetchRoutineResultSubject, log or
report the error (use existing logger), and surface/localize a user-facing error
message or error state so the UI can react; update the catch block around
fetchRoutines to perform this non-network error branch while keeping the
existing networkRetryHandler.handleNetworkError(...) call for network errors and
retaining captured startDate/endDate for retries.
- Around line 301-324: The current checkVersion() uses
networkRetryHandler.handleNetworkError(...) on any failure which triggers the
network error UI; instead, treat version check failures silently: in the catch
block for checkVersion(), do not call networkRetryHandler.handleNetworkError —
simply log the error (or ignore), send updateVersionSubject.send(nil) to clear
any prompt, and optionally call networkRetryHandler.clearRetryState(); update
references in the method (checkVersion, appConfigRepository.fetchAppVersion,
updateVersionSubject, networkRetryHandler) accordingly so the version check
failure does not surface the network error view to users.

In
`@Projects/Presentation/Sources/RoutineList/View/RoutineDeleteAlertViewController.swift`:
- Around line 56-57: The controller currently calls super.configureLayout()
which adds the full-screen networkErrorView into the view hierarchy even though
this alert VC doesn't use network errors; in RoutineDeleteAlertViewController
remove that unnecessary view by either not calling super.configureLayout() (keep
super.configureAttribute() if needed) or call super.configureLayout() then
remove networkErrorView from self.view, or introduce an opt-in flag on
BaseViewController so configureLayout only adds networkErrorView when enabled;
reference configureAttribute, configureLayout, networkErrorView,
bindNetworkError, and BaseViewController when making the change.

Comment on lines +53 to +59
} catch let error as NetworkError {
switch error {
case .needRetry, .invalidURL, .emptyData:
throw DomainError.requireRetry
default:
throw DomainError.business(error.description)
}
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

🏁 Script executed:

# LocationRepository.swift 파일의 실제 내용 확인 (53-59줄 포함)
find . -name "LocationRepository.swift" -type f

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 133


🏁 Script executed:

# NetworkError 정의 확인
find . -name "*.swift" -type f | xargs grep -l "enum NetworkError" | head -5

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 131


🏁 Script executed:

# 다른 Repository 파일들에서 동일한 패턴 검색
find . -path "*/Repository/*" -name "*Repository.swift" -type f | head -10

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 728


🏁 Script executed:

# LocationRepository.swift 파일의 lines 47-62 확인
cat -n ./Projects/DataSource/Sources/Repository/LocationRepository.swift | sed -n '40,65p'

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 1238


🏁 Script executed:

# NetworkError 정의 확인
cat -n ./Projects/DataSource/Sources/NetworkService/NetworkError.swift

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 1311


🏁 Script executed:

# 다른 Repository들의 에러 처리 패턴 확인
for file in ./Projects/DataSource/Sources/Repository/{AppConfigRepository,AuthRepository,EmotionRepository,FileRepository,OnboardingRepository,RecommendedRoutineRepository,ReportRepository,RoutineRepository,UserDataRepository}.swift; do
  echo "=== $file ===";
  grep -A 10 "catch let error as NetworkError" "$file" | head -15;
done

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 3602


.invalidURL.emptyDatarequireRetry로 매핑하는 것이 부적절함

.needRetry는 타임아웃/연결 끊김 등 일시적 네트워크 오류이므로 재시도가 의미 있습니다. 그러나 .invalidURL은 URL 구성 오류로 재시도해도 동일하게 실패하며, .emptyData는 서버 응답 자체가 비어있는 경우라 클라이언트 재시도로 해결될 가능성이 낮습니다.

이 문제는 LocationRepository에만 국한된 것이 아니라 EmotionRepository, FileRepository, OnboardingRepository, RecommendedRoutineRepository, ReportRepository, RoutineRepository 등 다수의 Repository에서 동일한 패턴으로 반복되고 있습니다. 이는 체계적인 설계 문제로, 다음과 같이 수정하는 것이 적절합니다:

제안하는 수정
         } catch let error as NetworkError {
             switch error {
-            case .needRetry, .invalidURL, .emptyData:
+            case .needRetry:
                 throw DomainError.requireRetry
+            case .invalidURL, .emptyData:
+                throw DomainError.business(error.description)
             default:
                 throw DomainError.business(error.description)
             }

이 수정은 LocationRepository뿐만 아니라 동일한 패턴을 사용하는 모든 Repository 파일에 적용되어야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/DataSource/Sources/Repository/LocationRepository.swift` around lines
53 - 59, The catch mapping in LocationRepository currently treats .invalidURL
and .emptyData as transient and throws DomainError.requireRetry; change the
switch in the NetworkError catch so only .needRetry maps to
DomainError.requireRetry, and map .invalidURL and .emptyData to
DomainError.business with the error description (i.e., treat them as
non-retryable business errors). Update the same pattern in the other
repositories that share this logic (EmotionRepository, FileRepository,
OnboardingRepository, RecommendedRoutineRepository, ReportRepository,
RoutineRepository) so all use .needRetry => DomainError.requireRetry and all
other NetworkError cases => DomainError.business(error.description).

Comment on lines +24 to +29
switch error {
case .needRetry, .invalidURL, .emptyData:
throw DomainError.requireRetry
default:
throw DomainError.business(error.description)
}
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

🏁 Script executed:

# Find the NetworkError enum definition
find . -name "*.swift" -type f | xargs grep -l "enum NetworkError"

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 131


🏁 Script executed:

# Look at the OnboardingRepository.swift file to see all error handling
cat -n Projects/DataSource/Sources/Repository/OnboardingRepository.swift

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 3369


🏁 Script executed:

# Search for all occurrences of invalidURL case in the codebase
rg "case.*invalidURL" --type swift -B 2 -A 2

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 11831


🏁 Script executed:

# Find where NetworkError is defined and check its cases
rg "enum NetworkError" --type swift -A 20

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 1986


.invalidURLDomainError.requireRetry로 매핑하는 것을 재검토 필요

.invalidURL은 잘못된 URL 구성(개발자 실수)으로 인한 에러이므로 재시도해도 동일하게 실패합니다. .needRetry.emptyData는 일시적인 네트워크 문제일 수 있어 재시도가 의미 있지만, .invalidURLDomainError.business로 매핑하는 것이 더 적절합니다.

이 패턴이 OnboardingRepository의 세 메서드 모두에 적용되어 있으며, 코드베이스의 다른 모든 Repository(RoutineRepository, FileRepository, ReportRepository 등)에서도 동일하게 반복되고 있으므로 함께 수정이 필요합니다.

🔧 수정 제안
         } catch let error as NetworkError {
             switch error {
-            case .needRetry, .invalidURL, .emptyData:
+            case .needRetry, .emptyData:
                 throw DomainError.requireRetry
             default:
                 throw DomainError.business(error.description)
             }
📝 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
switch error {
case .needRetry, .invalidURL, .emptyData:
throw DomainError.requireRetry
default:
throw DomainError.business(error.description)
}
switch error {
case .needRetry, .emptyData:
throw DomainError.requireRetry
default:
throw DomainError.business(error.description)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/DataSource/Sources/Repository/OnboardingRepository.swift` around
lines 24 - 29, The switch currently maps `.invalidURL` to
DomainError.requireRetry along with `.needRetry` and `.emptyData`; change the
mapping so only transient errors (`.needRetry` and `.emptyData`) throw
DomainError.requireRetry, while `.invalidURL` should throw
DomainError.business(error.description). Update this logic in the switch blocks
inside OnboardingRepository's three methods (the switch handling `error` that
currently lists `.needRetry, .invalidURL, .emptyData`) and apply the same fix
consistently across other repositories that repeat this pattern (e.g.,
RoutineRepository, FileRepository, ReportRepository) so `.invalidURL` is treated
as a business error while retryable cases remain requireRetry.

Comment on lines +22 to +27
switch error {
case .needRetry, .invalidURL, .emptyData:
throw DomainError.requireRetry
default:
throw DomainError.business(error.description)
}
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

🏁 Script executed:

cat -n Projects/DataSource/Sources/Repository/RecommendedRoutineRepository.swift

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 2549


🏁 Script executed:

rg -n "\.needRetry.*\.invalidURL.*\.emptyData" --type swift

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 2614


🏁 Script executed:

rg -n "case \.needRetry" --type swift -A 3

Repository: YAPP-Github/Bitnagil-iOS

Length of output: 10040


.invalidURL, .emptyDatarequireRetry로 매핑하지 않기

.needRetry는 타임아웃/연결 끊김 같은 일시적 오류이므로 재시도가 의미 있습니다. 그러나 .invalidURL은 영구적 오류로 재시도해도 항상 실패하고, .emptyData도 일시적 오류가 아닐 가능성이 높습니다. 현재 이 경우들을 DomainError.requireRetry로 매핑하면 사용자에게 오도하는 "재시도" 프롬프트를 표시하게 됩니다.

이 패턴은 RecommendedRoutineRepository 외에도 LocationRepository, RoutineRepository, ReportRepository, OnboardingRepository, FileRepository, EmotionRepository 등 9개 파일의 21개 위치에서 동일하게 사용되고 있어 프로젝트 전반에 걸친 개선이 필요합니다.

제안된 수정
         } catch let error as NetworkError {
             switch error {
-            case .needRetry, .invalidURL, .emptyData:
+            case .needRetry:
                 throw DomainError.requireRetry
             default:
                 throw DomainError.business(error.description)
             }
📝 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
switch error {
case .needRetry, .invalidURL, .emptyData:
throw DomainError.requireRetry
default:
throw DomainError.business(error.description)
}
switch error {
case .needRetry:
throw DomainError.requireRetry
default:
throw DomainError.business(error.description)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/DataSource/Sources/Repository/RecommendedRoutineRepository.swift`
around lines 22 - 27, The switch incorrectly maps .invalidURL and .emptyData to
DomainError.requireRetry (only .needRetry should trigger retries); update the
switch in RecommendedRoutineRepository (and the same pattern in
LocationRepository, RoutineRepository, ReportRepository, OnboardingRepository,
FileRepository, EmotionRepository, etc.) so that case .needRetry throws
DomainError.requireRetry, while .invalidURL and .emptyData (and other
non-transient cases) throw DomainError.business(error.description) (preserving
the existing default behavior) to avoid prompting retries for permanent errors.

Comment on lines +27 to +37
func handleNetworkError(_ error: Error, retryAction: @escaping () -> Void) {
guard
let domainError = error as? DomainError,
domainError == .requireRetry
else {
// 네트워크 에러 문제가 아닐 경우
return
}

networkErrorActionSubject.send(retryAction)
}
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

DomainError.requireRetry가 아닌 에러가 무시됩니다.

handleNetworkErrorDomainError.requireRetry가 아닌 경우 조용히 return합니다. ViewModel의 catch 블록에서 이 메서드에만 의존하면 DomainError.businessDomainError.unknown 같은 에러가 사용자에게 아무런 피드백 없이 무시됩니다.

예시 (ReportHistoryViewModel):

} catch {
    networkRetryHandler.handleNetworkError(error) { [weak self] in
        self?.fetchReports()
    }
}

최소한 로깅을 추가하거나, 호출부에서 비-네트워크 에러에 대한 별도 처리를 보장해야 합니다.

🛡️ 로깅 추가 제안
     func handleNetworkError(_ error: Error, retryAction: `@escaping` () -> Void) {
         guard
             let domainError = error as? DomainError,
             domainError == .requireRetry
         else {
             // 네트워크 에러 문제가 아닐 경우
+            BitnagilLogger.log(logType: .error, message: "Non-retry error occurred: \(error)")
             return
         }
 
         networkErrorActionSubject.send(retryAction)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Projects/Presentation/Sources/Common/Protocol/NetworkRetryCapable.swift`
around lines 27 - 37, handleNetworkError currently returns silently for
non-DomainError.requireRetry errors, causing DomainError.business/unknown to be
ignored; update handleNetworkError to log or forward non-retry errors instead of
silently returning: inside the else branch (where you now comment "// 네트워크 에러
문제가 아닐 경우") call a logger (or send the error to an existing error reporting
subject) and/or expose outcome to callers (e.g., return a Bool handled flag or
add a nonRetryErrorSubject) so ViewModels can surface or handle
DomainError.business/unknown; refer to handleNetworkError,
DomainError.requireRetry, and networkErrorActionSubject when making the change.

Comment on lines 166 to 173
} catch {
BitnagilLogger.log(logType: .error, message: "\(error.localizedDescription)")
registerRoutineResultSubject.send(false)

networkRetryHandler.handleNetworkError(error) { [weak self] in
self?.registerRecommendedRoutine()
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

네트워크 에러 시 registerRoutineResultSubject.send(false)와 네트워크 에러 뷰가 동시에 트리거됩니다.

DomainError.requireRetry인 경우, Line 168에서 registerRoutineResultSubject.send(false)로 실패 신호를 보내고, Line 170에서 네트워크 에러 뷰도 표시합니다. VC에서 registerRoutineResultPublisherfalse 값에 대해 별도 에러 처리(토스트, 얼럿 등)를 하고 있다면 네트워크 에러 뷰와 중복 표시될 수 있습니다.

네트워크 재시도 대상 에러일 경우 send(false)를 건너뛰거나, VC 측에서 중복 처리가 없는지 확인해 주세요.

#!/bin/bash
# VC에서 registerRoutineResultPublisher를 어떻게 처리하는지 확인
fd "ResultRecommendedRoutineViewController" --extension swift --exec cat {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@Projects/Presentation/Sources/ResultRecommendedRoutine/ViewModel/ResultRecommendedRoutineViewModel.swift`
around lines 166 - 173, In ResultRecommendedRoutineViewModel, avoid emitting
registerRoutineResultSubject.send(false) for retryable network errors
(DomainError.requireRetry) so the VC doesn't get a duplicate error UI; modify
the catch in registerRecommendedRoutine() to detect DomainError.requireRetry (or
use networkRetryHandler.isRetryable(error)) and only call
registerRoutineResultSubject.send(false) when the error is not retryable,
leaving networkRetryHandler.handleNetworkError(error) {
self?.registerRecommendedRoutine() } to handle retryable cases; reference
registerRoutineResultSubject.send(false),
networkRetryHandler.handleNetworkError(error){...}, DomainError.requireRetry,
and registerRoutineResultPublisher so VC receives a single error signal.

Copy link
Contributor

@choijungp choijungp left a comment

Choose a reason for hiding this comment

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

그르니까 제가 이해한 바로는

  1. ViewModel ( .. Repository .. Domain)에서 네트워크 유실 에러가 발생
  2. ViewModel의 networkRetryHandler를 통해 해당 메서드를 retryAction으로 send !!
  3. ViewController (BaseViewController) 에서는 bindNetworkError를 통해 해당 네트워크 에러 Subject를 구독하다가
  4. retryAction이 전달되면 networkError 뷰를 show 하고, networkError 뷰의 onRetry 함수를 저장
  5. 요청 성공시 clearRetryState를 통해 etryAction을 nil로 보내서 hide

이것이 마즐까요 ?? ?????


그르티만 ~ .. ViewModel에서 너무 중복 코드가 생기는 것이 고민이다 ...

그렇다면 !!!!
현재 네트워크 요청을 보내는 ViewModel들이 중복적으로 networkRetryHandler를 갖고 있으니까
해당 프로퍼티를 ViewModel protocol에 정의해두고,

extension ViewModel을 통해 중복되는 함수의 행동을 정의하는건 어떤가욤 ??

예를 들어

public extension ViewModel {
    func executeWithRetry(
        action: @escaping () async throws -> Void,
        retryAction: @escaping () -> Void
    ) {
        Task {
            do {
                try await action()
                networkRetryHandler.clearRetryState()
            } catch {
                networkRetryHandler.handleNetworkError(error, retryAction)
            }
        }
    }
}

// HomeViewModel.swift
private func loadNickname() {
    executeWithRetry(
        action: { [weak self] in
            guard let self else { return }
            let nickname = try await self.userDataUseCase.loadNickname()
            self.nicknameSubject.send(nickname)
        },
        retryAction: { [weak self] in
            self?.loadNickname()
        }
    )
}

이렇게 ... ???
다만 수정할 부분이 많을 것 같아 위의 방법이 괜찮다면 ViewModel에 추가 후 각자 수정하는 것도 좋은 방법이라고 생각합니다 !!!!

띵푸루부 완전 척척박사 띵 ~
감사합니다 !!!


public enum DomainError: Error, Equatable {
case requireRetry
case business(String)
Copy link
Contributor

Choose a reason for hiding this comment

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

business 에러는 무슨 상황일 때를 뜻하는지 궁금함니다 !!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

날카롭게 바로 캐치하셨군요!!! 해당 부분 관련해서 잊어버리지 않게 저희 노션에 데스노트로 추가해 두었습니다.

일단 도메인 레이어에 Error를 정의한 이유부터 말씀드려야 할 것 같습니다!
api를 쐈을때 크게 3가지의 에러가 발생할 수 있다고 생각하는데요~

  1. 네트워크 유실 등 연결 문제
  2. 클라이언트의 문제 (400 ~)
  3. 서버 에러 (500~)

그리고 이번에 디자인에 새로 추가된 재시도 화면은 이 중에서 1번 문제만 핸들링한다고 생각했습니다. 2, 3번 에러는 발생 시에 그에 맞는 화면이나 alert를 띄워줘야한다고 생각했기 때문입니다.

그렇다면, presentation 단에서는 위 1, 2, 3에 해당하는 에러 중에 어떤 에러가 발셍했는지 알 수 있어야 합니다. 다만 presentation layer는 datasource layer를 알 수 없기 때문제, domain 단에 error를 정의할 필요가 있었습니다.

Domain 단에서 발생할 수 있는 에러를 좀 더 세분화해서 정의해야 하는데, 사실 이번 pr을 좀 더 빨리 쳐내고 싶은 마음이 크기도 하고,, error 정의와 처리 관련해서는 새로운 PR로 분리하는 것이 좋을 것 같다는 생각도 했습니다.
때문에 우선 현재 필요한 네트워크 연결 문제: 재시도 필요 를 뜻하는 requireRetry 만 정의하고, 발생할 수 있는 나머지 에러들(서버 에러나 도메인 layer에서 발생가능한 기타 비즈니스 로직 에러)은 비즈니스 에러로 일단 퉁!! 쳐서 구현해두었습니다.

@taipaise
Copy link
Collaborator Author

@choijungp
조이가 아주 정확하게 이해하셨고 핵심을 찌르신 것 같아요. 지금 보니 PR의 리뷰 노트에 제가 했던 고민의 과정이 많이 부족했던 것 같습니다.
처음에는 조이가 말씀하신 구조를 고려했었어요! 아래와 같은 흐름으로 생각했습니다.

  1. 모든 ViewModel이 네트워킹을 진행하지 않을 수 있다
  2. 네크워크 유실 시에 직전에 발생한 네트워킹 작업을 저장해뒀다가 재시도 버튼을 누르면 실행할 수 있어야 한다.
  3. 위와 같은 작업과, 직전에 발생한 네트워킹 작업을 저장하는 subject를 가지고 있는 프로토콜을 정의한다. 그리고 네트워킹 재시도 작업을 진행할 수 있는 ViewModel이 해당 프로토콜을 채택하게 하자

해당 구조로 구현하게 되면 반복 코드를 줄일 수 있고 좀 더 직관적이고 간단한 구조로 구현이 가능할 것이라 생각했죠!
NetworkRetryCapable(네트워크 재시도 protocol)
그래서 위 프로토콜이, 처음에는 ViewModel이 직접 채택하는 프로토콜이었습니다.

다만 이렇게 하게 되면, ViewModel를 참조하고 있는 VC에서 해당 프로토콜에 구현되어 있는 메서드를 직접 호출하거나, subject에 값을 send할 수 있다는 점이 마음에 걸렸습니다. 저희는 현재 input - action - output으로 흐름을 단방향으로 강제하고 있기 때문에 해당 구조에 맞지 않기 때문이죠 ㅠ 그래서 NetworkRetryCapable를 채택하는 retryHandler를 따로 구현하고, ViewModel 내에서 해당 핸들러 클래스를 private으로 가지고 있게 구현하는 방식으로 수정하게 되었습니다. (지금 생각하면 핸들러를 struct로 구현하는게 나을 것 같네요!)

구조가 비효율적으로 변한 감이 없지 않아 있는것 같아,, 어떤 방법이 더 괜찮을 지 편하게 말씀주시면 감사하겠습니다!!

@taipaise taipaise merged commit 0973070 into develop Feb 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants