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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion Projects/App/Sources/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,22 @@ extension SceneDelegate: SplashViewDelegate {
guard let userDataRepository = DIContainer.shared.resolve(type: UserDataRepositoryProtocol.self)
else { fatalError("userDataRepository 의존성이 등록되지 않았습니다.") }

guard let onboardingRepository = DIContainer.shared.resolve(type: OnboardingRepositoryProtocol.self)
else { fatalError("onboardingRepository 의존성이 등록되지 않았습니다.") }

Task { @MainActor in
let isLogined = await userDataRepository.reissueToken()
if isLogined {
window?.rootViewController = TabBarView()
if onboardingRepository.isOnboardingDone() {
window?.rootViewController = TabBarView()
} else {
guard let onboardingViewModel = DIContainer.shared.resolve(type: OnboardingViewModel.self) else {
fatalError("onboardingViewModel 의존성이 등록되지 않았습니다.")
}
let onboardingView = OnboardingView(viewModel: onboardingViewModel, onboarding: .time)
let navigationController = UINavigationController(rootViewController: onboardingView)
window?.rootViewController = navigationController
}
Comment on lines +63 to +72
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Aug 6, 2025

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

온보딩 분기 처리 로직 검토

로그인 후 온보딩 완료 여부에 따른 분기 처리가 명확하게 구현되었습니다. 온보딩 미완료 시 OnboardingView로, 완료 시 TabBarView로 적절히 분기됩니다.

그런데 PR 목표에서 언급된 "여러 뷰에서 OnboardingRepository 인스턴스가 여러 개 생성되는 문제"를 고려해볼 필요가 있습니다. DI 컨테이너에서 싱글톤으로 관리하는지 확인해보시기 바랍니다.

다음 스크립트로 OnboardingRepository의 생성 패턴을 확인해보겠습니다:


🏁 Script executed:

#!/bin/bash
# OnboardingRepository 사용 패턴 확인
echo "=== OnboardingRepository 의존성 주입 패턴 확인 ==="
rg -A 3 -B 1 "OnboardingRepositoryProtocol\.self"

echo -e "\n=== DIContainer에서 OnboardingRepository 등록 확인 ==="
rg -A 5 -B 2 "OnboardingRepository"

Length of output: 11414


🏁 Script executed:

#!/bin/bash
# DIContainer 구현부 확인
echo "=== DIContainer 클래스 정의 검색 ==="
rg -A 5 -B 5 "class DIContainer" -n .

echo -e "\n=== DIContainer.register 메서드 서명 검색 ==="
rg -n "func register" -n .

Length of output: 2879


OnboardingRepository 싱글톤 등록 필요성 검토

현재 DataSourceDependencyAssembler에서 아래와 같이 factory 방식으로 OnboardingRepository를 등록하고 있어, DIContainer.resolve 호출 시마다 새로운 인스턴스가 생성됩니다.
여러 뷰에서 resolve를 통해 OnboardingRepository가 반복 생성되는 현상을 방지하려면 싱글톤으로 공유되도록 등록 방식을 개선해야 합니다.

  • 수정 위치
    Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift

  • 제안 변경 예시

    - DIContainer.shared.register(type: OnboardingRepositoryProtocol.self) { _ in
    -     return OnboardingRepository()
    - }
    + // 싱글톤 인스턴스 미리 생성
    + private let sharedOnboardingRepository = OnboardingRepository()
    + DIContainer.shared.register(type: OnboardingRepositoryProtocol.self) { _ in
    +     return sharedOnboardingRepository
    + }

또는 DIContainer 자체에 최초 생성된 인스턴스를 캐싱하는 로직을 추가하는 방법도 고려해 보세요.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In Projects/DataSource/Sources/Common/DataSourceDependencyAssembler.swift around
the registration of OnboardingRepository, the current factory registration
causes a new instance to be created on each resolve call, leading to multiple
instances across views. To fix this, change the registration of
OnboardingRepository to a singleton scope so that the same instance is shared
throughout the app. This can be done by registering it once and returning the
cached instance on subsequent resolves, or by adding caching logic inside
DIContainer to store and reuse the first created instance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

이거슨 .. 9월에 DIContainer에 scope라는 개념을 도입할지 상의를 해볼게요 .. !!! ㅜㅜ

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@choijungp 네, 맞습니다! DIContainer에 scope 개념을 도입하는 것은 전체 아키텍처에 영향을 주는 큰 변경사항이니까 팀과 충분히 상의해서 결정하는 게 좋겠어요 👍

지금은 현재 방식대로 사용하시고, 9월에 scope 개념 도입을 검토해보시면 될 것 같습니다. 그때 singleton, transient 등의 lifecycle 관리를 체계적으로 정리하실 수 있을 거예요!

현재 구현으로도 기능상 문제는 없으니 우선 이대로 진행하시면 됩니다 😊


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

Learnt from: choijungp
PR: YAPP-Github/Bitnagil-iOS#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
PR: YAPP-Github/Bitnagil-iOS#30
File: Projects/DataSource/Sources/NetworkService/Plugin/RefreshTokenPlugin.swift:42-43
Timestamp: 2025-07-30T03:56:18.617Z
Learning: choijungp는 현재 테스트 단계에서 RefreshTokenPlugin의 토큰 갱신 로직 디버깅을 위해 액세스 토큰과 리프레시 토큰의 실제 값을 로그에 기록하는 것을 선호함.

Learnt from: choijungp
PR: YAPP-Github/Bitnagil-iOS#19
File: Projects/Presentation/Sources/Common/PresentationDependencyAssembler.swift:0-0
Timestamp: 2025-07-16T10:57:16.559Z
Learning: choijungp는 추후 CustomAlertView를 만들어서 에러 처리를 UI로 해결하도록 수정할 계획을 가지고 있음.

} else {
let introView = IntroView()
let navigationController = UINavigationController(rootViewController: introView)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
enum UserDefaultsKey: String {
case nickname
case socialLoginType
case profileImageUrl
case onboarding
}
18 changes: 9 additions & 9 deletions Projects/DataSource/Sources/Common/Error/UserError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ enum UserError: Error, CustomStringConvertible {
case socialLoginTypeSaveFailed
case socialLoginTypeLoadFailed
case socialLoginTypeRemoveFailed
case profileImageUrlSaveFailed
case profileImageUrlLoadFailed
case profileImageUrlRemoveFailed
case onboardingSaveFailed
case onboardingLoadFailed
case onboardingRemoveFailed
case unknown(error: Error)

var description: String {
Expand All @@ -31,12 +31,12 @@ enum UserError: Error, CustomStringConvertible {
return "소셜 로그인 타입 불러오기에 실패했습니다."
case .socialLoginTypeRemoveFailed:
return "소셜 로그인 타입 삭제 실패했습니다."
case .profileImageUrlSaveFailed:
return "유저 프로필 저장 실패했습니다."
case .profileImageUrlLoadFailed:
return "유저 프로필 불러오기에 실패했습니다."
case .profileImageUrlRemoveFailed:
return "유저 프로필 삭제 실패했습니다."
case .onboardingSaveFailed:
return "온보딩 여부 저장 실패했습니다."
case .onboardingLoadFailed:
return "온보딩 여부 불러오기에 실패했습니다."
case .onboardingRemoveFailed:
return "온보딩 여부를 삭제하는데 실패했습니다."
case .unknown(let error):
return "알 수 없는 에러가 발생했습니다. \(error.localizedDescription)"
}
Expand Down
12 changes: 3 additions & 9 deletions Projects/DataSource/Sources/Repository/AuthRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,6 @@ final class AuthRepository: AuthRepositoryProtocol {
}
}

// UserDefaults에 프로필 이미지를 저장합니다.
private func saveUserProfileImageUrl(profileImageUrl: URL) throws {
guard userDefaultsStorage.save(profileImageUrl.absoluteString, forKey: UserDefaultsKey.profileImageUrl.rawValue) else {
throw UserError.profileImageUrlSaveFailed
}
}

// UserDefaults에 저장된 유저 정보(닉네임, 소셜 로그인 타입, 프로필 이미지)를 삭제합니다.
private func removeUserInfo() throws {
guard userDefaultsStorage.remove(forKey: UserDefaultsKey.nickname.rawValue) else {
Expand All @@ -155,8 +148,9 @@ final class AuthRepository: AuthRepositoryProtocol {
throw UserError.socialLoginTypeRemoveFailed
}

guard userDefaultsStorage.remove(forKey: UserDefaultsKey.profileImageUrl.rawValue) else {
throw UserError.profileImageUrlRemoveFailed
guard userDefaultsStorage.remove(forKey: UserDefaultsKey.onboarding.rawValue) else {
throw UserError.onboardingRemoveFailed
}

}
}
11 changes: 11 additions & 0 deletions Projects/DataSource/Sources/Repository/OnboardingRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,27 @@ import Domain

final class OnboardingRepository: OnboardingRepositoryProtocol {
private let networkService = NetworkService.shared
private let userDefaultsStorage = UserDefaultsStorage.shared

func registerOnboarding(onboardingChoices: [String : String]) async throws -> [RecommendedRoutineEntity] {
let endpoint = OnboardingEndpoint.registerOnboarding(choices: onboardingChoices)
guard let response = try await networkService.request(endpoint: endpoint, type: RecommendedRoutineListResponseDTO.self)
else { return [] }

guard userDefaultsStorage.save(true, forKey: UserDefaultsKey.onboarding.rawValue)
else { throw UserError.onboardingSaveFailed }

let recommendedRoutineEntity = response.recommendedRoutines.compactMap({ $0.toRecommendedRoutineEntity() })
return recommendedRoutineEntity
}

func isOnboardingDone() -> Bool {
guard let isOnboardingDone: Bool = userDefaultsStorage.load(forKey: UserDefaultsKey.onboarding.rawValue)
else { return false }

return isOnboardingDone
}

func registerRecommendedRoutines(selectedRoutines: [Int]) async throws {
let endpoint = OnboardingEndpoint.registerRecommendedRoutine(selectedRoutines: selectedRoutines)
_ = try await networkService.request(endpoint: endpoint, type: EmptyResponseDTO.self)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,8 @@ public protocol OnboardingRepositoryProtocol {
/// 선택한 추천 루틴을 등록합니다.
/// - Parameter selectedRoutines: 선택한 추천 루틴 ID 목록
func registerRecommendedRoutines(selectedRoutines: [Int]) async throws

/// 온보딩 여부를 반환합니다.
/// - Returns: 온보딩 여부
func isOnboardingDone() -> Bool
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ extension UIViewController {
action: #selector(popViewController))
backButton.tintColor = .black
navigationItem.leftBarButtonItem = backButton
changeNavigationBackground(color: .white)
Comment thread
choijungp marked this conversation as resolved.
}

private func configureCustomBackButton() {
Expand All @@ -56,12 +57,14 @@ extension UIViewController {
action: #selector(popTwoViewControllers))
backButton.tintColor = .black
navigationItem.leftBarButtonItem = backButton
changeNavigationBackground(color: .white)
}

private func configureProgressNavigationBar(step: Int, stepCount: Int) {
self.title = ""
let progressView = ProgressBarView(step: step, stepCount: stepCount)
navigationItem.titleView = progressView
changeNavigationBackground(color: BitnagilColor.gray99)
}

@objc private func popViewController() {
Expand All @@ -82,6 +85,17 @@ extension UIViewController {
navigationController.popToViewController(targetViewController, animated: true)
}

private func changeNavigationBackground(color: UIColor?) {
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = color
appearance.shadowColor = .clear

navigationController?.navigationBar.standardAppearance = appearance
navigationController?.navigationBar.scrollEdgeAppearance = appearance
navigationController?.navigationBar.compactAppearance = appearance
}
Comment thread
choijungp marked this conversation as resolved.

// MARK: - BottomSheet
func presentCustomBottomSheet(contentViewController: UIViewController, maxHeight: CGFloat) {
let bottomSheet = CustomBottomSheet(contentViewController: contentViewController, maxHeight: maxHeight)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import UIKit

class BaseViewController<T: ViewModel>: UIViewController {
public class BaseViewController<T: ViewModel>: UIViewController {
let viewModel: T

init(viewModel: T) {
Expand All @@ -19,7 +19,7 @@ class BaseViewController<T: ViewModel>: UIViewController {
fatalError("init(coder:) has not been implemented")
}

override func viewDidLoad() {
public override func viewDidLoad() {
super.viewDidLoad()

configureAttribute()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// Created by 최정인 on 6/26/25.
//

protocol ViewModel {
public protocol ViewModel {
associatedtype Input
associatedtype Output

Expand Down
12 changes: 8 additions & 4 deletions Projects/Presentation/Sources/Login/View/IntroView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Created by 최정인 on 7/6/25.
//

import Domain
import Shared
import SnapKit
import UIKit
Expand Down Expand Up @@ -48,10 +49,13 @@ public final class IntroView: UIViewController {
graphView.image = BitnagilGraphic.introGraphic

startButton.addAction(UIAction { [weak self] _ in
guard let loginViewModel = DIContainer.shared.resolve(type: LoginViewModel.self) else {
fatalError("loginViewModel 의존성이 등록되지 않았습니다.")
}
let loginView = LoginView(viewModel: loginViewModel)
guard let onboardingRepository = DIContainer.shared.resolve(type: OnboardingRepositoryProtocol.self)
else { fatalError("onboardingRepository 의존성이 등록되지 않았습니다.") }

guard let loginViewModel = DIContainer.shared.resolve(type: LoginViewModel.self)
else { fatalError("loginViewModel 의존성이 등록되지 않았습니다.") }

let loginView = LoginView(onboardingRepository: onboardingRepository, viewModel: loginViewModel)
self?.navigationController?.pushViewController(loginView, animated: true)
}, for: .touchUpInside)
}
Expand Down
20 changes: 15 additions & 5 deletions Projects/Presentation/Sources/Login/View/LoginView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import AuthenticationServices
import Combine
import Domain
import Shared
import SnapKit
import UIKit
Expand All @@ -31,8 +32,10 @@ final class LoginView: BaseViewController<LoginViewModel> {
private let kakaoLoginButton = SocialLoginButton(socialType: .kakao)
private let appleLoginButton = SocialLoginButton(socialType: .apple)
private var cancellables: Set<AnyCancellable>
private let onboardingRepository: OnboardingRepositoryProtocol

override init(viewModel: LoginViewModel) {
init(onboardingRepository: OnboardingRepositoryProtocol, viewModel: LoginViewModel) {
self.onboardingRepository = onboardingRepository
cancellables = []
super.init(viewModel: viewModel)
}
Expand Down Expand Up @@ -119,11 +122,18 @@ final class LoginView: BaseViewController<LoginViewModel> {
let agreementView = TermsAgreementView(viewModel: self.viewModel)
self.navigationController?.pushViewController(agreementView, animated: true)
} else {
guard let onboardingViewModel = DIContainer.shared.resolve(type: OnboardingViewModel.self) else {
fatalError("onboardingViewModel 의존성이 등록되지 않았습니다.")
if onboardingRepository.isOnboardingDone() {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first(where: { $0.isKeyWindow }) {
window.rootViewController = TabBarView()
}
} else {
guard let onboardingViewModel = DIContainer.shared.resolve(type: OnboardingViewModel.self) else {
fatalError("onboardingViewModel 의존성이 등록되지 않았습니다.")
}
Comment on lines +131 to +133
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

OnboardingViewModel을 의존성으로 주입받도록 리팩토링 필요

현재 DIContainer에서 직접 resolve하는 방식은 의존성 주입 원칙에 위배됩니다. OnboardingRepositoryProtocol처럼 생성자를 통해 주입받는 것이 더 나은 설계입니다.

다음과 같이 리팩토링하는 것을 제안합니다:

 private let onboardingRepository: OnboardingRepositoryProtocol
+private let onboardingViewModel: OnboardingViewModel

-init(onboardingRepository: OnboardingRepositoryProtocol, viewModel: LoginViewModel) {
+init(onboardingRepository: OnboardingRepositoryProtocol, 
+     onboardingViewModel: OnboardingViewModel,
+     viewModel: LoginViewModel) {
     self.onboardingRepository = onboardingRepository
+    self.onboardingViewModel = onboardingViewModel
     cancellables = []
     super.init(viewModel: viewModel)
 }

그리고 사용 부분을:

-guard let onboardingViewModel = DIContainer.shared.resolve(type: OnboardingViewModel.self) else {
-    fatalError("onboardingViewModel 의존성이 등록되지 않았습니다.")
-}
-let onboardingView = OnboardingView(viewModel: onboardingViewModel, onboarding: .time)
+let onboardingView = OnboardingView(viewModel: self.onboardingViewModel, onboarding: .time)
🤖 Prompt for AI Agents
In Projects/Presentation/Sources/Login/View/LoginView.swift around lines 131 to
133, the OnboardingViewModel is currently obtained by directly resolving it from
DIContainer, which violates dependency injection principles. Refactor the code
to inject OnboardingViewModel via the initializer of LoginView or its ViewModel,
similar to how OnboardingRepositoryProtocol is injected. Remove the direct
DIContainer.resolve call and pass the dependency through constructor injection
to improve testability and design.

let onboardingView = OnboardingView(viewModel: onboardingViewModel, onboarding: .time)
self.navigationController?.pushViewController(onboardingView, animated: true)
}
let onboardingView = OnboardingView(viewModel: onboardingViewModel, onboarding: .time)
self.navigationController?.pushViewController(onboardingView, animated: true)
}
}
.store(in: &cancellables)
Expand Down
12 changes: 12 additions & 0 deletions Projects/Presentation/Sources/MyPage/View/MypageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ final class MypageView: BaseViewController<MypageViewModel> {
fatalError("init(coder:) has not been implemented")
}

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .white
appearance.shadowColor = .clear

navigationController?.navigationBar.standardAppearance = appearance
navigationController?.navigationBar.scrollEdgeAppearance = appearance
navigationController?.navigationBar.compactAppearance = appearance
}

override func configureAttribute() {
view.backgroundColor = .white
navigationItem.rightBarButtonItem = settingButton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Domain
import SnapKit
import UIKit

final class OnboardingView: BaseViewController<OnboardingViewModel> {
public final class OnboardingView: BaseViewController<OnboardingViewModel> {

private enum Layout {
static let horizontalMargin: CGFloat = 20
Expand Down Expand Up @@ -38,7 +38,7 @@ final class OnboardingView: BaseViewController<OnboardingViewModel> {

private let isFromMypage: Bool
private var cancellables: Set<AnyCancellable>
init(
public init(
viewModel: OnboardingViewModel,
onboarding: OnboardingType,
isFromMypage: Bool = false
Expand All @@ -53,11 +53,11 @@ final class OnboardingView: BaseViewController<OnboardingViewModel> {
fatalError("init(coder:) has not been implemented")
}

override func viewDidLoad() {
public override func viewDidLoad() {
super.viewDidLoad()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

override func viewWillAppear(_ animated: Bool) {
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)

let stepCount = OnboardingType.allCases.count + 1
Expand All @@ -66,7 +66,7 @@ final class OnboardingView: BaseViewController<OnboardingViewModel> {
self.viewModel.action(input: .fetchOnboardingChoice(onboarding: onboarding))
}

override func viewDidLayoutSubviews() {
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()

if !isLayoutConfigured {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import Combine
import Domain
import Shared

final class OnboardingViewModel: ViewModel {
enum Input {
public final class OnboardingViewModel: ViewModel {
public enum Input {
case selectOnboardingChoice(selectedChoice: OnboardingChoiceType)
case fetchOnboardingChoice(onboarding: OnboardingType)
case fetchOnboardingChoices
case makeOnboardingResult
}

struct Output {
public struct Output {
let timeOnboardingChoicePublisher: AnyPublisher<OnboardingChoiceType?, Never>
let frequencyOnboardingChoicePublisher: AnyPublisher<OnboardingChoiceType?, Never>
let feelingOnboardingChoicePublisher: AnyPublisher<Set<OnboardingChoiceType>, Never>
Expand Down Expand Up @@ -48,7 +48,7 @@ final class OnboardingViewModel: ViewModel {
)
}

func action(input: Input) {
public func action(input: Input) {
switch input {
case .selectOnboardingChoice(let selectedChoice):
selectChoice(choice: selectedChoice)
Expand Down
Loading