From 8abc70900877359b600b8239636a488267bf8fea Mon Sep 17 00:00:00 2001 From: duthd3 Date: Wed, 6 May 2026 14:26:07 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[FEAT]:=20=EB=84=A4=EC=9D=B4=EB=B2=84=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20SDK=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++ .../Extension+TargetDependencySPM.swift | 1 + .../infoPlist/InfoPlistDictionary.swift | 19 ++++++++- .../infoPlist/Project+InfoPlist.swift | 3 ++ Projects/App/Project.swift | 3 +- .../App/Sources/Application/BangawoApp.swift | 28 +++++++++++++ Projects/App/Sources/Auth/AuthFactory.swift | 3 +- .../Sources/AuthRepositoryImpl.swift | 2 +- Projects/Data/Service/Project.swift | 3 +- .../Sources/Auth/NaverLoginService.swift | 42 +++++++++++++++++++ .../Auth/SignInWithSocialUseCaseImpl.swift | 19 +++++++-- README.md | 30 ------------- Tuist/Package.resolved | 11 ++++- Tuist/Package.swift | 6 ++- 14 files changed, 131 insertions(+), 42 deletions(-) create mode 100644 Projects/Data/Service/Sources/Auth/NaverLoginService.swift delete mode 100644 README.md 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/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 455ea4c..9814a87 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 } @@ -199,4 +202,16 @@ extension InfoPlistDictionary { func setKakaoAppKey(_ value: String) -> 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 } + } } diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/infoPlist/Project+InfoPlist.swift index 7684d5d..64c1d2f 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)") ) static let moduleInfoPlist: Self = .extendingDefault( diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 8136647..a20b1ec 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -18,7 +18,8 @@ let project = Project.makeAppModule( .SPM.kakaoMapsSDK, .SPM.kakaoSDKCommon, .SPM.kakaoSDKAuth, - .SPM.kakaoSDKUser + .SPM.kakaoSDKUser, + .SPM.nidThirdPartyLogin ], sources: ["Sources/**"], resources: ["Resources/**"], diff --git a/Projects/App/Sources/Application/BangawoApp.swift b/Projects/App/Sources/Application/BangawoApp.swift index 4d0ed61..d82ce3f 100644 --- a/Projects/App/Sources/Application/BangawoApp.swift +++ b/Projects/App/Sources/Application/BangawoApp.swift @@ -5,6 +5,7 @@ import Presentation import KakaoSDKCommon import KakaoSDKAuth import KakaoSDKUser +import NidThirdPartyLogin import Utill @preconcurrency import KakaoMapsSDK @@ -21,6 +22,31 @@ struct BangawoApp: App { } else { Log.debug("⚠️ [Kakao] Error: Info.plist에서 'KAKAO_APP_KEY'를 찾을 수 없거나 비어 있습니다.") } + + 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 ?? "" + + if !appName.isEmpty, !naverClientID.isEmpty, !naverClientSecret.isEmpty, !naverURLScheme.isEmpty { + NidOAuth.shared.initialize( + appName: appName, + clientId: naverClientID, + clientSecret: naverClientSecret, + urlScheme: naverURLScheme + ) + Log.debug("👤 [Naver] Login SDK 초기화 완료") + } else { + Log.debug("appName: \(appName)") + Log.debug("clientId: \(naverClientID)") + Log.debug("clientSecret: \(naverClientSecret)") + Log.debug("urlScheme: \(naverURLScheme)") + + Log.debug("⚠️ [Naver] Error: Info.plist에서 네이버 로그인 설정값을 찾을 수 없습니다.") + } } var body: some Scene { @@ -34,6 +60,8 @@ struct BangawoApp: App { ).onOpenURL(perform: { url in if (AuthApi.isKakaoTalkLoginUrl(url)) { // 카카오 로그인 처리를 정상적으로 완료 AuthController.handleOpenUrl(url: url) + } else if NidOAuth.shared.handleURL(url) { + Log.debug("👤 [Naver] Login callback 처리 완료") } }) } 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/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/Projects/Data/UseCase/Sources/Auth/SignInWithSocialUseCaseImpl.swift b/Projects/Data/UseCase/Sources/Auth/SignInWithSocialUseCaseImpl.swift index 7d266fa..94835f0 100644 --- a/Projects/Data/UseCase/Sources/Auth/SignInWithSocialUseCaseImpl.swift +++ b/Projects/Data/UseCase/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/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") ] ) From 1b38d77cba0754730b7c61a7f67038578869d1d5 Mon Sep 17 00:00:00 2001 From: duthd3 Date: Tue, 12 May 2026 20:14:55 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[REFACTOR]:=20=EB=84=A4=EC=9D=B4=EB=B2=84?= =?UTF-8?q?=20SDK=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docs/TCA-Dependency-Convention.md | 612 ++++++++++++++++++ .../InfoPlists/Bangawo-Debug-Info.plist | 9 + .../App/Derived/InfoPlists/Bangawo-Info.plist | 9 + .../InfoPlists/Bangawo-Prod-Info.plist | 9 + .../InfoPlists/Bangawo-Stage-Info.plist | 9 + .../App/Sources/Application/BangawoApp.swift | 45 +- 6 files changed, 670 insertions(+), 23 deletions(-) create mode 100644 Docs/TCA-Dependency-Convention.md 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/Projects/App/Derived/InfoPlists/Bangawo-Debug-Info.plist b/Projects/App/Derived/InfoPlists/Bangawo-Debug-Info.plist index 7119527..d46f65f 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) @@ -38,9 +39,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 7119527..d46f65f 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) @@ -38,9 +39,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 7119527..d46f65f 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) @@ -38,9 +39,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 7119527..d46f65f 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) @@ -38,9 +39,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/Sources/Application/BangawoApp.swift b/Projects/App/Sources/Application/BangawoApp.swift index d82ce3f..8af782e 100644 --- a/Projects/App/Sources/Application/BangawoApp.swift +++ b/Projects/App/Sources/Application/BangawoApp.swift @@ -23,6 +23,28 @@ struct BangawoApp: App { Log.debug("⚠️ [Kakao] Error: Info.plist에서 'KAKAO_APP_KEY'를 찾을 수 없거나 비어 있습니다.") } + initializeNaverLoginSDK() + } + + var body: some Scene { + WindowGroup { + LoginView( // 앱 시작점 임의로 로그인 뷰 + store: Store(initialState: LoginFeature.State()) { // store 주입 + LoginFeature() + } withDependencies: { + $0.socialAuthClient = AuthFactory.makeSocialAuthClient() + } + ).onOpenURL(perform: { 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 @@ -40,30 +62,7 @@ struct BangawoApp: App { ) Log.debug("👤 [Naver] Login SDK 초기화 완료") } else { - Log.debug("appName: \(appName)") - Log.debug("clientId: \(naverClientID)") - Log.debug("clientSecret: \(naverClientSecret)") - Log.debug("urlScheme: \(naverURLScheme)") - Log.debug("⚠️ [Naver] Error: Info.plist에서 네이버 로그인 설정값을 찾을 수 없습니다.") } } - - var body: some Scene { - WindowGroup { - LoginView( // 앱 시작점 임의로 로그인 뷰 - store: Store(initialState: LoginFeature.State()) { // store 주입 - LoginFeature() - } withDependencies: { - $0.socialAuthClient = AuthFactory.makeSocialAuthClient() - } - ).onOpenURL(perform: { url in - if (AuthApi.isKakaoTalkLoginUrl(url)) { // 카카오 로그인 처리를 정상적으로 완료 - AuthController.handleOpenUrl(url: url) - } else if NidOAuth.shared.handleURL(url) { - Log.debug("👤 [Naver] Login callback 처리 완료") - } - }) - } - } } From b1508d2ad34b2cf9841b4ca9b860d2dee397fcc3 Mon Sep 17 00:00:00 2001 From: duthd3 Date: Tue, 12 May 2026 21:43:24 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[REFACTOR]:=20=EB=84=A4=EC=9D=B4=EB=B2=84?= =?UTF-8?q?=20sdk=20=EC=B4=88=EA=B8=B0=ED=99=94=20guard=EB=AC=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Sources/Application/BangawoApp.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Projects/App/Sources/Application/BangawoApp.swift b/Projects/App/Sources/Application/BangawoApp.swift index fe576ae..2ca7841 100644 --- a/Projects/App/Sources/Application/BangawoApp.swift +++ b/Projects/App/Sources/Application/BangawoApp.swift @@ -49,16 +49,16 @@ struct BangawoApp: App { let naverClientSecret = Bundle.main.infoDictionary?["NAVER_CLIENT_SECRET"] as? String ?? "" let naverURLScheme = Bundle.main.infoDictionary?["NAVER_URL_SCHEME"] as? String ?? "" - if !appName.isEmpty, !naverClientID.isEmpty, !naverClientSecret.isEmpty, !naverURLScheme.isEmpty { - NidOAuth.shared.initialize( - appName: appName, - clientId: naverClientID, - clientSecret: naverClientSecret, - urlScheme: naverURLScheme - ) - Log.debug("👤 [Naver] Login SDK 초기화 완료") - } else { - Log.debug("⚠️ [Naver] Error: Info.plist에서 네이버 로그인 설정값을 찾을 수 없습니다.") + 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 초기화 완료") } - } }