diff --git a/.gitignore b/.gitignore index 5d56357..5374bcf 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,9 @@ Config/ # Secrets .env +# Docs +README.md + # Claude Code CLAUDE.md .claude/ diff --git a/Docs/TCA-Dependency-Convention.md b/Docs/TCA-Dependency-Convention.md new file mode 100644 index 0000000..91757d7 --- /dev/null +++ b/Docs/TCA-Dependency-Convention.md @@ -0,0 +1,612 @@ +# TCA Dependency 컨벤션 + +## 1. 목적 + +우리 프로젝트는 TCA에서 의존성을 struct-based client 패턴으로 통일한다. + +목표: +- Feature가 구현 세부사항을 모르도록 한다 +- 테스트/프리뷰에서 의존성 override를 쉽게 한다 +- Domain UseCase와 TCA Dependency Client의 역할을 분리한다 +- 실제 구현체 조립은 App의 Composition Root에서 수행한다 +- Presentation이 Data 계층 구현체에 직접/간접 의존하지 않도록 한다 + +## 2. 폴더 구조 + +```text +Core/ + Dependencies/ + SocialAuthClient.swift + +Domain/ + Entity/ + AuthToken.swift + SocialAuthProvider.swift + UseCase/ + SignInWithSocialUseCase.swift + DataInterface/ + AuthRepositoryProtocol.swift + DomainInterface/ + SocialAuthClientError.swift + +Data/ + Model/ + AuthDTO.swift + API/ + AuthEndPoint.swift + Service/ + KakaoLoginService.swift + Repository/ + AuthRepositoryImpl.swift + UseCase/ + SignInWithSocialUseCaseImpl.swift + +App/ + Auth/ + AuthAssembly.swift + +Presentation/ + Login/ + LoginFeature.swift + LoginView.swift +``` + +## 3. 레이어별 책임 + +### Presentation / Feature + +- `@Dependency`로만 외부 의존성 사용 +- SDK/API 직접 호출 금지 +- 구현체 직접 생성 금지 +- Data 계층 모듈 import 금지 +- View는 사용자 이벤트를 Feature Action으로 전달 + +### Core/Dependencies + +- TCA struct-based client 정의 +- `DependencyValues` 등록 +- `DependencyKey.liveValue`, `testValue` 기본값 제공 +- Domain UseCase protocol을 호출하는 adapter 제공 +- Data 계층 구현체 import 금지 +- Assembly 또는 구현체 조립 금지 + +### Domain + +- Entity 정의 +- UseCase protocol 정의 +- Repository protocol 정의 +- 외부 SDK/네트워크 타입 금지 +- DTO 노출 금지 + +### Data + +- 실제 구현 담당 +- SDK 호출 +- API 호출 +- DTO 정의 +- RepositoryImpl +- UseCaseImpl +- DTO -> Entity 변환은 Repository 내부 또는 Repository 전용 mapper에서 수행 + +### App + +- Composition Root +- 실제 live dependency 조립 +- `AuthAssembly` 위치 +- 앱 시작 시 TCA dependency live 구현 주입 +- DataUseCase, Repository, Service 등 구현체 의존 가능 + +## 4. 파일 예시 + +### Core/Dependencies/SocialAuthClient.swift + +```swift +import ComposableArchitecture +import DomainInterface +import Entity +import UseCase + +public struct SocialAuthClient: Sendable { + public var signIn: @Sendable (SocialAuthProvider) async throws -> AuthToken + + public init( + signIn: @escaping @Sendable (SocialAuthProvider) async throws -> AuthToken + ) { + self.signIn = signIn + } +} + +public extension SocialAuthClient { + static func live(useCase: SignInWithSocialUseCase) -> Self { + Self { provider in + try await useCase.execute(provider: provider) + } + } +} + +extension SocialAuthClient: DependencyKey { + public static var liveValue: SocialAuthClient { + SocialAuthClient { provider in + throw SocialAuthClientError.notImplemented(provider) + } + } + + public static var testValue: SocialAuthClient { + SocialAuthClient { provider in + throw SocialAuthClientError.notImplemented(provider) + } + } +} + +public extension DependencyValues { + var socialAuthClient: SocialAuthClient { + get { self[SocialAuthClient.self] } + set { self[SocialAuthClient.self] = newValue } + } +} +``` + +역할: +- Feature가 사용하는 TCA dependency client +- Domain UseCase protocol과 Feature 사이의 adapter +- 실제 구현체를 직접 생성하지 않음 +- `liveValue`는 주입 누락을 빠르게 발견하기 위한 기본값 + +### Domain/Entity/SocialAuthProvider.swift + +```swift +public enum SocialAuthProvider: Equatable, Sendable { + case kakao + case apple + case naver +} + +public extension SocialAuthProvider { + var serverValue: String { + switch self { + case .kakao: + return "KAKAO" + case .apple: + return "APPLE" + case .naver: + return "NAVER" + } + } +} +``` + +역할: +- 소셜 로그인 타입 정의 +- SDK 독립적인 도메인 모델 + +### Domain/Entity/AuthToken.swift + +```swift +public struct AuthToken: Equatable, Sendable { + public let accessToken: String + public let refreshToken: String + public let isNewMember: Bool + public let registrationCompleted: Bool + + public init( + accessToken: String, + refreshToken: String, + isNewMember: Bool, + registrationCompleted: Bool + ) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.isNewMember = isNewMember + self.registrationCompleted = registrationCompleted + } +} +``` + +역할: +- 서버 로그인 결과를 표현하는 도메인 모델 +- DTO가 아닌 Feature/Domain에서 사용하는 Entity + +### Domain/UseCase/SignInWithSocialUseCase.swift + +```swift +import Entity + +public protocol SignInWithSocialUseCase: Sendable { + func execute(provider: SocialAuthProvider) async throws -> AuthToken +} +``` + +역할: +- 로그인 유스케이스 인터페이스 +- Presentation과 Data 구현체 사이의 Domain 추상화 + +### Domain/DataInterface/AuthRepositoryProtocol.swift + +```swift +import Entity + +public protocol AuthRepositoryProtocol: Sendable { + func login( + provider: String, + providerToken: String + ) async throws -> AuthToken +} +``` + +역할: +- 서버 인증용 repository protocol +- DTO를 반환하지 않고 Entity를 반환 +- Domain 계층이 Data DTO를 알지 않도록 함 + +### Data/Model/AuthDTO.swift + +```swift +public struct LoginRequestDTO: Encodable, Sendable { + public let provider: String + public let providerToken: String +} + +public struct LoginResponseDTO: Decodable, Sendable { + public let accessToken: String + public let refreshToken: String + public let isNewMember: Bool + public let registrationCompleted: Bool +} +``` + +역할: +- 서버 요청/응답 형태만 표현 +- Entity 변환 로직 포함 금지 +- Domain 타입 import 금지 + +### Data/Repository/AuthRepositoryImpl.swift + +```swift +import API +import DataInterface +import Entity +import Model +import Networking + +public struct AuthRepositoryImpl: AuthRepositoryProtocol { + public init() {} + + public func login( + provider: String, + providerToken: String + ) async throws -> AuthToken { + let requestDTO = LoginRequestDTO( + provider: provider, + providerToken: providerToken + ) + + let response: LoginResponseDTO = try await NetworkManager.shared.request( + AuthEndPoint.login(requestDTO) + ) + + return response.toEntity() + } +} + +private extension LoginResponseDTO { + func toEntity() -> AuthToken { + AuthToken( + accessToken: accessToken, + refreshToken: refreshToken, + isNewMember: isNewMember, + registrationCompleted: registrationCompleted + ) + } +} +``` + +역할: +- `AuthRepositoryProtocol` 구현체 +- API 호출 +- DTO -> Entity 변환을 Repository 내부에서 수행 +- DTO가 Domain을 알지 않도록 보호 + +### Data/Service/KakaoLoginService.swift + +```swift +import DomainInterface +import Entity +import KakaoSDKAuth +import KakaoSDKUser + +public protocol KakaoLoginServiceInterface: Sendable { + @MainActor + func login() async throws -> SocialAuthToken +} + +public final class KakaoLoginService: KakaoLoginServiceInterface { + public init() {} + + @MainActor + public func login() async throws -> SocialAuthToken { + // 실제 Kakao SDK 호출 + } +} +``` + +역할: +- Kakao SDK 직접 호출 +- SDK 응답을 앱 공통 모델로 변환 +- SDK 디테일을 외부로 숨김 + +### Data/UseCase/SignInWithSocialUseCaseImpl.swift + +```swift +import DataInterface +import DomainInterface +import Entity +import Service +import UseCase + +public final class SignInWithSocialUseCaseImpl: SignInWithSocialUseCase { + private let repository: AuthRepositoryProtocol + private let kakaoLoginService: KakaoLoginServiceInterface + + public init( + repository: AuthRepositoryProtocol, + kakaoLoginService: KakaoLoginServiceInterface + ) { + self.repository = repository + self.kakaoLoginService = kakaoLoginService + } + + public func execute(provider: SocialAuthProvider) async throws -> AuthToken { + switch provider { + case .kakao: + let socialToken = try await kakaoLoginService.login() + return try await repository.login( + provider: provider.serverValue, + providerToken: socialToken.accessToken + ) + + case .apple, .naver: + throw SocialAuthClientError.notImplemented(provider) + } + } +} +``` + +역할: +- 소셜 SDK 로그인 + 서버 로그인 흐름 조합 +- Domain UseCase protocol 구현 +- Feature가 알 필요 없는 비즈니스 흐름 수행 + +### App/Auth/AuthAssembly.swift + +```swift +import CoreDependencies +import DataUseCase +import Repository +import Service + +enum AuthAssembly { + static func makeSocialAuthClient() -> SocialAuthClient { + let repository = AuthRepositoryImpl() + let useCase = SignInWithSocialUseCaseImpl( + repository: repository, + kakaoLoginService: KakaoLoginService() + ) + + return .live(useCase: useCase) + } +} +``` + +역할: +- App의 Composition Root +- 실제 live dependency 조립 +- Data 계층 구현체 생성 +- Feature/CoreDependencies가 구현체를 모르도록 연결 + +### App/BangawoApp.swift + +```swift +import ComposableArchitecture +import Presentation + +@main +struct BangawoApp: App { + init() { + prepareDependencies { + $0.socialAuthClient = AuthAssembly.makeSocialAuthClient() + } + } + + var body: some Scene { + WindowGroup { + LoginView( + store: Store(initialState: LoginFeature.State()) { + LoginFeature() + } + ) + } + } +} +``` + +역할: +- 앱 시작 시 live dependency 주입 +- Store 생성부에 반복적인 `withDependencies` 사용을 줄임 + +### Presentation/Login/LoginFeature.swift + +```swift +import ComposableArchitecture +import CoreDependencies +import DomainInterface +import Entity + +@Reducer +public struct LoginFeature { + @Dependency(\.socialAuthClient) private var socialAuthClient + + @ObservableState + public struct State: Equatable { + public var isLoading = false + public var error: String? + } + + public enum Action { + case kakaoLoginTapped + case appleLoginTapped + case naverLoginTapped + case loginResponse(Result) + case delegate(Delegate) + + public enum Delegate: Equatable { + case didLoginSuccess + case needsSignUp(tempToken: String) + } + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .kakaoLoginTapped: + state.isLoading = true + state.error = nil + return signIn(provider: .kakao) + + case .appleLoginTapped: + state.isLoading = true + state.error = nil + return signIn(provider: .apple) + + case .naverLoginTapped: + state.isLoading = true + state.error = nil + return signIn(provider: .naver) + + case let .loginResponse(.success(authToken)): + state.isLoading = false + + if !authToken.registrationCompleted { + return .send(.delegate(.needsSignUp(tempToken: authToken.accessToken))) + } else { + return .send(.delegate(.didLoginSuccess)) + } + + case let .loginResponse(.failure(error)): + state.isLoading = false + state.error = error.localizedDescription + return .none + + case .delegate: + return .none + } + } + } +} + +private extension LoginFeature { + func signIn(provider: SocialAuthProvider) -> Effect { + let client = socialAuthClient + + return .run { send in + do { + let authToken = try await client.signIn(provider) + await send(.loginResponse(.success(authToken))) + } catch let error as SocialAuthClientError { + await send(.loginResponse(.failure(error))) + } catch { + await send(.loginResponse(.failure(.underlying(error.localizedDescription)))) + } + } + } +} +``` + +역할: +- 외부 구현을 모르고 dependency만 사용 +- SDK/API/Repository/UseCaseImpl 직접 생성 금지 +- 로그인 결과에 따른 화면 전환 판단만 수행 + +## 5. Store 생성 예시 + +```swift +let store = Store(initialState: LoginFeature.State()) { + LoginFeature() +} +``` + +설명: +- App 시작 시 `prepareDependencies`로 live 구현을 주입한다 +- Feature Store 생성부에서는 별도 `withDependencies`를 반복하지 않는다 + +## 6. 테스트 override 예시 + +```swift +let store = TestStore(initialState: LoginFeature.State()) { + LoginFeature() +} withDependencies: { + $0.socialAuthClient.signIn = { _ in + AuthToken( + accessToken: "test-access", + refreshToken: "test-refresh", + isNewMember: false, + registrationCompleted: true + ) + } +} +``` + +설명: +- 테스트에서는 필요한 함수만 override +- 실제 SDK/API 호출 없이 Feature 로직만 검증한다 + +## 7. 의존성 규칙 + +허용: + +```text +Presentation -> CoreDependencies +Presentation -> Domain +CoreDependencies -> Domain +Data -> Domain +App -> Presentation +App -> CoreDependencies +App -> Data +``` + +금지: + +```text +Presentation -> Data +CoreDependencies -> Data +Data -> CoreDependencies +Domain -> Data +Domain -> Network +``` + +주의: + +```text +App -> Data +``` + +는 허용된다. App은 Composition Root이므로 실제 구현체 조립을 위해 Data 계층을 알 수 있다. + +## 8. 금지사항 + +- Feature에서 `KakaoLoginService()` 직접 생성 금지 +- Feature에서 `AuthRepositoryImpl()` 직접 생성 금지 +- Feature에서 `URLSession`, `NetworkManager` 직접 호출 금지 +- CoreDependencies에서 DataUseCase/Repository/Service import 금지 +- CoreDependencies의 `liveValue`에서 실제 구현체 조립 금지 +- Data DTO를 Feature/Domain 인터페이스로 노출 금지 +- DTO 내부에 Entity 변환 로직 작성 금지 + +## 9. 한 줄 규칙 + +- Feature는 `@Dependency`로만 사용한다 +- TCA Dependency Client는 CoreDependencies에 둔다 +- UseCase protocol은 Domain에 둔다 +- UseCaseImpl/RepositoryImpl/Service는 Data에 둔다 +- 실제 live 조립은 App의 Assembly에서 한다 +- 앱 실행 시 App에서 dependency를 주입한다 +- 테스트는 `withDependencies`로 override한다 diff --git a/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift b/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift index 336295e..11e54dd 100644 --- a/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift +++ b/Plugins/DependencyPackagePlugin/ProjectDescriptionHelpers/DependencyPackage/Extension+TargetDependencySPM.swift @@ -15,4 +15,5 @@ public extension TargetDependency.SPM { static let kakaoSDKCommon = TargetDependency.external(name: "KakaoSDKCommon", condition: .none) static let kakaoSDKAuth = TargetDependency.external(name: "KakaoSDKAuth", condition: .none) static let kakaoSDKUser = TargetDependency.external(name: "KakaoSDKUser", condition: .none) + static let nidThirdPartyLogin = TargetDependency.external(name: "NidThirdPartyLogin", condition: .none) } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift index 4d48e1c..24532d6 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/InfoPlistDictionary.swift @@ -107,7 +107,8 @@ extension InfoPlistDictionary { .dictionary([ "CFBundleURLSchemes": .array([ .string("$(REVERSED_CLIENT_ID)"), - .string("kakao$(KAKAO_APP_KEY)") + .string("kakao$(KAKAO_APP_KEY)"), + .string("$(NAVER_URL_SCHEME)") ]) ]) ]) @@ -119,7 +120,9 @@ extension InfoPlistDictionary { let dict: [String: Plist.Value] = [ "LSApplicationQueriesSchemes": .array([ .string("kakaokompassauth"), - .string("kakaolink") + .string("kakaolink"), + .string("naversearchapp"), + .string("naversearchthirdlogin") ]) ] return self.merging(dict) { (_, new) in new } @@ -204,6 +207,18 @@ extension InfoPlistDictionary { return self.merging(["KAKAO_APP_KEY": .string(value)]) { (_, new) in new } } + func setNaverClientID(_ value: String) -> InfoPlistDictionary { + return self.merging(["NAVER_CLIENT_ID": .string(value)]) { (_, new) in new } + } + + func setNaverClientSecret(_ value: String) -> InfoPlistDictionary { + return self.merging(["NAVER_CLIENT_SECRET": .string(value)]) { (_, new) in new } + } + + func setNaverURLScheme(_ value: String) -> InfoPlistDictionary { + return self.merging(["NAVER_URL_SCHEME": .string(value)]) { (_, new) in new } + } + func setKakaoRestAPIKey(_ value: String) -> InfoPlistDictionary { return self.merging(["KAKAO_REST_API_KEY": .string(value)]) { (_, new) in new } } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift index bec5ef7..569612d 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift @@ -40,6 +40,9 @@ public extension InfoPlist { ] ]) .setKakaoAppKey("$(KAKAO_APP_KEY)") + .setNaverClientID("$(NAVER_CLIENT_ID)") + .setNaverClientSecret("$(NAVER_CLIENT_SECRET)") + .setNaverURLScheme("$(NAVER_URL_SCHEME)") .setKakaoRestAPIKey("$(KAKAO_REST_API_KEY)") .setNSCameraUsageDescription("프로필 사진 촬영을 위해 카메라 접근이 필요합니다") .setNSPhotoLibraryUsageDescription("프로필 사진 선택을 위해 사진 접근이 필요합니다") diff --git a/Projects/App/Derived/InfoPlists/Bangawo-Debug-Info.plist b/Projects/App/Derived/InfoPlists/Bangawo-Debug-Info.plist index 2d7cc17..9d79f6f 100644 --- a/Projects/App/Derived/InfoPlists/Bangawo-Debug-Info.plist +++ b/Projects/App/Derived/InfoPlists/Bangawo-Debug-Info.plist @@ -25,6 +25,7 @@ $(REVERSED_CLIENT_ID) kakao$(KAKAO_APP_KEY) + $(NAVER_URL_SCHEME) @@ -40,9 +41,17 @@ kakaokompassauth kakaolink + naversearchapp + naversearchthirdlogin LSRequiresIPhoneOS + NAVER_CLIENT_ID + $(NAVER_CLIENT_ID) + NAVER_CLIENT_SECRET + $(NAVER_CLIENT_SECRET) + NAVER_URL_SCHEME + $(NAVER_URL_SCHEME) NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/Projects/App/Derived/InfoPlists/Bangawo-Info.plist b/Projects/App/Derived/InfoPlists/Bangawo-Info.plist index 2d7cc17..9d79f6f 100644 --- a/Projects/App/Derived/InfoPlists/Bangawo-Info.plist +++ b/Projects/App/Derived/InfoPlists/Bangawo-Info.plist @@ -25,6 +25,7 @@ $(REVERSED_CLIENT_ID) kakao$(KAKAO_APP_KEY) + $(NAVER_URL_SCHEME) @@ -40,9 +41,17 @@ kakaokompassauth kakaolink + naversearchapp + naversearchthirdlogin LSRequiresIPhoneOS + NAVER_CLIENT_ID + $(NAVER_CLIENT_ID) + NAVER_CLIENT_SECRET + $(NAVER_CLIENT_SECRET) + NAVER_URL_SCHEME + $(NAVER_URL_SCHEME) NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/Projects/App/Derived/InfoPlists/Bangawo-Prod-Info.plist b/Projects/App/Derived/InfoPlists/Bangawo-Prod-Info.plist index 2d7cc17..9d79f6f 100644 --- a/Projects/App/Derived/InfoPlists/Bangawo-Prod-Info.plist +++ b/Projects/App/Derived/InfoPlists/Bangawo-Prod-Info.plist @@ -25,6 +25,7 @@ $(REVERSED_CLIENT_ID) kakao$(KAKAO_APP_KEY) + $(NAVER_URL_SCHEME) @@ -40,9 +41,17 @@ kakaokompassauth kakaolink + naversearchapp + naversearchthirdlogin LSRequiresIPhoneOS + NAVER_CLIENT_ID + $(NAVER_CLIENT_ID) + NAVER_CLIENT_SECRET + $(NAVER_CLIENT_SECRET) + NAVER_URL_SCHEME + $(NAVER_URL_SCHEME) NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/Projects/App/Derived/InfoPlists/Bangawo-Stage-Info.plist b/Projects/App/Derived/InfoPlists/Bangawo-Stage-Info.plist index 2d7cc17..9d79f6f 100644 --- a/Projects/App/Derived/InfoPlists/Bangawo-Stage-Info.plist +++ b/Projects/App/Derived/InfoPlists/Bangawo-Stage-Info.plist @@ -25,6 +25,7 @@ $(REVERSED_CLIENT_ID) kakao$(KAKAO_APP_KEY) + $(NAVER_URL_SCHEME) @@ -40,9 +41,17 @@ kakaokompassauth kakaolink + naversearchapp + naversearchthirdlogin LSRequiresIPhoneOS + NAVER_CLIENT_ID + $(NAVER_CLIENT_ID) + NAVER_CLIENT_SECRET + $(NAVER_CLIENT_SECRET) + NAVER_URL_SCHEME + $(NAVER_URL_SCHEME) NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index d8109ad..591052f 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -21,6 +21,7 @@ let project = Project.makeAppModule( .SPM.kakaoSDKCommon, .SPM.kakaoSDKAuth, .SPM.kakaoSDKUser, + .SPM.nidThirdPartyLogin, .SPM.composableArchitecture ], sources: ["Sources/**"], diff --git a/Projects/App/Sources/Application/BangawoApp.swift b/Projects/App/Sources/Application/BangawoApp.swift index 0d13193..2ca7841 100644 --- a/Projects/App/Sources/Application/BangawoApp.swift +++ b/Projects/App/Sources/Application/BangawoApp.swift @@ -5,6 +5,7 @@ import Presentation import Utill import KakaoSDKCommon import KakaoSDKAuth +import NidThirdPartyLogin @preconcurrency import KakaoMapsSDK @main @@ -22,6 +23,8 @@ struct BangawoApp: App { $0.searchStationsClient = SearchStationsFactory.makeClient() $0.socialAuthClient = AuthFactory.makeSocialAuthClient() } + + initializeNaverLoginSDK() } var body: some Scene { @@ -30,8 +33,32 @@ struct BangawoApp: App { .onOpenURL { url in if AuthApi.isKakaoTalkLoginUrl(url) { AuthController.handleOpenUrl(url: url) + } else if NidOAuth.shared.handleURL(url) { + Log.debug("👤 [Naver] Login callback 처리 완료") } } } } + + private func initializeNaverLoginSDK() { + let displayName = (Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) ?? "" + let bundleName = (Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String) ?? "" + let appName = displayName.isEmpty ? bundleName : displayName + + let naverClientID = Bundle.main.infoDictionary?["NAVER_CLIENT_ID"] as? String ?? "" + let naverClientSecret = Bundle.main.infoDictionary?["NAVER_CLIENT_SECRET"] as? String ?? "" + let naverURLScheme = Bundle.main.infoDictionary?["NAVER_URL_SCHEME"] as? String ?? "" + + guard !appName.isEmpty, !naverClientID.isEmpty, !naverClientSecret.isEmpty, !naverURLScheme.isEmpty else { + Log.debug("⚠️ [Naver] Error: Info.plist에서 네이버 로그인 설정값을 찾을 수 없습니다.") + return + } + NidOAuth.shared.initialize( + appName: appName, + clientId: naverClientID, + clientSecret: naverClientSecret, + urlScheme: naverURLScheme + ) + Log.debug("👤 [Naver] Login SDK 초기화 완료") + } } diff --git a/Projects/App/Sources/Auth/AuthFactory.swift b/Projects/App/Sources/Auth/AuthFactory.swift index ff626c8..6e8e797 100644 --- a/Projects/App/Sources/Auth/AuthFactory.swift +++ b/Projects/App/Sources/Auth/AuthFactory.swift @@ -15,7 +15,8 @@ enum AuthFactory { let repository = AuthRepositoryImpl() let useCase = SignInWithSocialUseCaseImpl( repository: repository, - kakaoLoginService: KakaoLoginService() + kakaoLoginService: KakaoLoginService(), + naverLoginService: NaverLoginService() ) return .live(useCase: useCase) diff --git a/Projects/Data/DataUseCase/Sources/Auth/SignInWithSocialUseCaseImpl.swift b/Projects/Data/DataUseCase/Sources/Auth/SignInWithSocialUseCaseImpl.swift index 7d266fa..94835f0 100644 --- a/Projects/Data/DataUseCase/Sources/Auth/SignInWithSocialUseCaseImpl.swift +++ b/Projects/Data/DataUseCase/Sources/Auth/SignInWithSocialUseCaseImpl.swift @@ -10,29 +10,42 @@ import DomainInterface import Entity import Service import UseCase +import Utill public final class SignInWithSocialUseCaseImpl: SignInWithSocialUseCase { private let repository: AuthRepositoryProtocol private let kakaoLoginService: KakaoLoginServiceInterface + private let naverLoginService: NaverLoginServiceInterface public init( repository: AuthRepositoryProtocol, - kakaoLoginService: KakaoLoginServiceInterface + kakaoLoginService: KakaoLoginServiceInterface, + naverLoginService: NaverLoginServiceInterface ) { self.repository = repository self.kakaoLoginService = kakaoLoginService + self.naverLoginService = naverLoginService } public func execute(provider: SocialAuthProvider) async throws -> LoginResult { switch provider { case .kakao: let socialToken = try await kakaoLoginService.login() // 소셜인증 후 + Log.debug("🔑 kakaoSocialToken: \(socialToken)") return try await signInWithServer( // 서버 로그인 요청 provider: provider, providerToken: socialToken.accessToken ) - case .apple, .naver: // TODO: 구현 필요 + case .naver: + let socialToken = try await naverLoginService.login() + Log.debug("🔑 naverSocialToken: \(socialToken)") + return try await signInWithServer( + provider: provider, + providerToken: socialToken.accessToken + ) + + case .apple: // TODO: 구현 필요 throw SocialAuthClientError.notImplemented(provider) } } @@ -44,7 +57,7 @@ private extension SignInWithSocialUseCaseImpl { provider: provider.serverValue, providerToken: providerToken ) - + // 직접 키체인에 접근하는 것이 아닌 repository를 통해 접근하도록 repository.saveAuthTokens(loginResult.tokens) return loginResult diff --git a/Projects/Data/Repository/Sources/AuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/AuthRepositoryImpl.swift index 5040591..3e2915d 100644 --- a/Projects/Data/Repository/Sources/AuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/AuthRepositoryImpl.swift @@ -26,7 +26,7 @@ public final class AuthRepositoryImpl: AuthRepositoryProtocol { return response.toEntity() } - + // 로그인과 토큰 저장 로직 분리 public func saveAuthTokens(_ tokens: AuthTokens) { KeyChainManager.addItem(key: KeyChainKey.accessToken, value: tokens.accessToken) KeyChainManager.addItem(key: KeyChainKey.refreshToken, value: tokens.refreshToken) diff --git a/Projects/Data/Service/Project.swift b/Projects/Data/Service/Project.swift index 32e6849..d36c7a1 100644 --- a/Projects/Data/Service/Project.swift +++ b/Projects/Data/Service/Project.swift @@ -14,7 +14,8 @@ let project = Project.makeModule( .Domain(implements: .DomainInterface), .Shared(implements: .Utill), .SPM.kakaoSDKAuth, - .SPM.kakaoSDKUser + .SPM.kakaoSDKUser, + .SPM.nidThirdPartyLogin ], sources: ["Sources/**"], hasTests: false diff --git a/Projects/Data/Service/Sources/Auth/NaverLoginService.swift b/Projects/Data/Service/Sources/Auth/NaverLoginService.swift new file mode 100644 index 0000000..eb4bd91 --- /dev/null +++ b/Projects/Data/Service/Sources/Auth/NaverLoginService.swift @@ -0,0 +1,42 @@ +// +// NaverLoginService.swift +// Service +// +// Created by DDD-iOS2 on 5/6/26. +// + +import DomainInterface +import Entity +import NidThirdPartyLogin +import Utill + +public protocol NaverLoginServiceInterface: Sendable { + @MainActor + func login() async throws -> SocialAuthToken +} + +public final class NaverLoginService: NaverLoginServiceInterface { + public init() {} + + @MainActor + public func login() async throws -> SocialAuthToken { + try await withCheckedThrowingContinuation { continuation in + NidOAuth.shared.requestLogin { result in + switch result { + case let .success(loginResult): + Log.debug("✅ 네이버 로그인 성공") + continuation.resume( + returning: SocialAuthToken( + accessToken: loginResult.accessToken.tokenString, + refreshToken: loginResult.refreshToken.tokenString, + idToken: nil + ) + ) + case let .failure(error): + Log.debug("❌ 네이버 로그인 실패: \(error.localizedDescription)") + continuation.resume(throwing: SocialAuthClientError.underlying(error.localizedDescription)) + } + } + } + } +} diff --git a/README.md b/README.md deleted file mode 100644 index ce60445..0000000 --- a/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# DDD 13기 iOS 2팀 iOS - -## Setup - -### AI 도구 연동 -프로젝트 규칙은 [AGENTS.md](./AGENTS.md)에 정의되어 있습니다. -Claude Code 사용 시 심볼릭 링크를 연결하세요. -```bash -ln -s AGENTS.md CLAUDE.md -``` - -### 빌드 환경 구성 - -`Config/*.xcconfig` 파일은 비공개 repo([DDD-iOS2-iOS-private](https://github.com/khyeji98/DDD-iOS2-iOS-private))에서 관리됩니다. - -1. private repo의 collaborator로 추가받습니다 (소유자에게 GitHub username 전달). -2. private repo README의 안내에 따라 본인 PAT(classic, `repo` scope)을 발급합니다. -3. 다음 명령으로 xcconfig를 받고 프로젝트를 생성합니다. - ```bash - make download-privates # 첫 실행 시 PAT 입력, 이후 .env에 캐시 - make generate - ``` -4. xcconfig 수정분을 private repo에 올릴 때는 업로드 명령을 사용합니다. - ```bash - make upload-privates # 변경된 파일만 1커밋으로 업로드 - ``` - - 커밋 메시지는 `[CHORE]: {파일명} 업데이트` 포맷으로 자동 생성됩니다. - - 변경 없는 파일은 자동 skip. 원격이 앞서 있으면 `make download-privates` 후 재시도하세요. - -자세한 절차/트러블슈팅은 private repo의 README를 참조하세요. diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 9c57751..d0088a8 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b72f867ebd1a1f2214e7a6663469a828b347226e8fac89a91d1e97788002a2d9", + "originHash" : "ab4a666dd97665f07e00c4c992f22e59486757108fafbc1ed8ce167417b35b17", "pins" : [ { "identity" : "alamofire", @@ -64,6 +64,15 @@ "version" : "15.0.3" } }, + { + "identity" : "naveridlogin-sdk-ios-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/naver/naveridlogin-sdk-ios-swift", + "state" : { + "revision" : "ad5d864497fda385f0e4bb5be8957a39391332f3", + "version" : "5.1.0" + } + }, { "identity" : "reactiveswift", "kind" : "remoteSourceControl", diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 3ad0cfb..210b439 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -14,7 +14,8 @@ let packageSettings = PackageSettings( "Clocks": .staticFramework, "ConcurrencyExtras": .staticFramework, "Sharing": .staticFramework, - "KakaoMapsSDK-SPM": .staticFramework + "KakaoMapsSDK-SPM": .staticFramework, + "NidThirdPartyLogin": .staticFramework ], targetSettings: [ "WeaveDICore": ["SWIFT_STRICT_CONCURRENCY": "minimal"], @@ -36,6 +37,7 @@ let package = Package( .package(url: "https://github.com/Roy-wonji/AsyncMoya", from: "1.1.8"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "1.0.0"), .package(url: "https://github.com/kakao-mapsSDK/KakaoMapsSDK-SPM", from: "2.12.0"), - .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.27.3") + .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.27.3"), + .package(url: "https://github.com/naver/naveridlogin-sdk-ios-swift", from: "5.1.0") ] )