From c23347138ce94949a53596a461b3b4a36c065690 Mon Sep 17 00:00:00 2001 From: taipaise Date: Sat, 22 Aug 2026 22:55:55 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20Meta=20SDK=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=91=EA=B3=A0=20=EC=A0=84=ED=99=98=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=A1=9C=EA=B9=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- Projects/App/Project.swift | 3 +- Projects/App/Sources/AppDelegate.swift | 9 +++++ .../App/Sources/DependencyInjection.swift | 5 +++ .../App/Sources/MetaAnalyticsLogger.swift | 35 +++++++++++++++++++ Projects/App/Sources/SceneDelegate.swift | 19 +++++++++- .../Sources/DomainDependencyAssembler.swift | 12 +++++-- .../Sources/Entity/Enum/AnalyticsEvent.swift | 17 +++++++++ .../Protocol/AnalyticsLoggerProtocol.swift | 10 ++++++ .../Sources/UseCase/Auth/LoginUseCase.swift | 5 ++- .../ResultRecommendedRoutineUseCase.swift | 9 ++++- .../UseCase/Routine/RoutineUseCase.swift | 9 ++++- SupportingFiles/Info.plist | 22 ++++++++++++ Tuist/Package.resolved | 9 +++++ Tuist/Package.swift | 3 +- 14 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 Projects/App/Sources/MetaAnalyticsLogger.swift create mode 100644 Projects/Domain/Sources/Entity/Enum/AnalyticsEvent.swift create mode 100644 Projects/Domain/Sources/Protocol/AnalyticsLoggerProtocol.swift diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index c58e3a91..926c3fb6 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -25,7 +25,8 @@ let project = Project( .project(target: "Presentation", path: "../Presentation"), .project(target: "Domain", path: "../Domain"), .project(target: "DataSource", path: "../DataSource"), - .project(target: "Shared", path: "../Shared") + .project(target: "Shared", path: "../Shared"), + .external(name: "FacebookCore") ] ) ] diff --git a/Projects/App/Sources/AppDelegate.swift b/Projects/App/Sources/AppDelegate.swift index 2e69fd1b..0360256d 100644 --- a/Projects/App/Sources/AppDelegate.swift +++ b/Projects/App/Sources/AppDelegate.swift @@ -6,6 +6,7 @@ // import DataSource +import FacebookCore import KakaoSDKCommon import UIKit @@ -14,6 +15,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { KakaoSDK.initSDK(appKey: AppProperties.kakaoNativeKey) + ApplicationDelegate.shared.application(application, didFinishLaunchingWithOptions: launchOptions) + + #if DEBUG + // Meta 이벤트 전송 여부를 Xcode 콘솔에서 확인하기 위한 로깅 (디버그 빌드 전용) + Settings.shared.enableLoggingBehavior(.appEvents) + Settings.shared.enableLoggingBehavior(.networkRequests) + #endif + return true } diff --git a/Projects/App/Sources/DependencyInjection.swift b/Projects/App/Sources/DependencyInjection.swift index f1770f5a..94abb01a 100644 --- a/Projects/App/Sources/DependencyInjection.swift +++ b/Projects/App/Sources/DependencyInjection.swift @@ -13,6 +13,11 @@ import Shared extension DIContainer { func dependencyInjection() { + // Meta SDK 의존성은 App 타겟에만 두고, Domain에는 프로토콜로 주입합니다. + DIContainer.shared.register(type: AnalyticsLoggerProtocol.self) { _ in + return MetaAnalyticsLogger() + } + let dataSourceAssembler = DataSourceDependencyAssembler() let domainAssembler = DomainDependencyAssembler(preAssembler: dataSourceAssembler) let presentationAssembler = PresentationDependencyAssembler(preAssembler: domainAssembler) diff --git a/Projects/App/Sources/MetaAnalyticsLogger.swift b/Projects/App/Sources/MetaAnalyticsLogger.swift new file mode 100644 index 00000000..25f10ff6 --- /dev/null +++ b/Projects/App/Sources/MetaAnalyticsLogger.swift @@ -0,0 +1,35 @@ +// +// MetaAnalyticsLogger.swift +// App +// +// Created by 이동현 on 8/22/25. +// + +import Domain +import FacebookCore +import Foundation + +/// 도메인 이벤트를 Meta(Facebook) 광고 전환 이벤트로 변환해 전송합니다. +/// "첫 루틴 완료만 전송" 같은 트래킹 정책(중복 제거 포함)은 이 어댑터가 책임집니다. +final class MetaAnalyticsLogger: AnalyticsLoggerProtocol { + + private enum StorageKey { + static let didLogFirstRoutineCompletion = "didLogFirstRoutineCompletion" + } + + func log(_ event: AnalyticsEvent) { + switch event { + case .signUpCompleted: + AppEvents.shared.logEvent(.completedRegistration) + + case .onboardingCompleted: + AppEvents.shared.logEvent(.completedTutorial) + + case .routineCompleted: + // 광고 최적화 신호 왜곡을 막기 위해 첫 루틴 완료만 전환 이벤트로 전송합니다. + guard !UserDefaults.standard.bool(forKey: StorageKey.didLogFirstRoutineCompletion) else { return } + UserDefaults.standard.set(true, forKey: StorageKey.didLogFirstRoutineCompletion) + AppEvents.shared.logEvent(.achievedLevel) + } + } +} diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index 2ecd8f2a..bf1e3e9a 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -5,7 +5,9 @@ // Created by 최정인 on 6/15/25. // +import AppTrackingTransparency import Domain +import FacebookCore import KakaoSDKAuth import Presentation import Shared @@ -38,7 +40,20 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidDisconnect(_ scene: UIScene) { } - func sceneDidBecomeActive(_ scene: UIScene) { } + func sceneDidBecomeActive(_ scene: UIScene) { + // 유저가 설정 앱에서 추적 허용 여부를 바꿨을 수 있으므로, 활성화 시마다 최신 상태를 Meta SDK에 반영합니다. + Settings.shared.isAdvertiserTrackingEnabled = (ATTrackingManager.trackingAuthorizationStatus == .authorized) + } + + // 광고 어트리뷰션 정확도를 위해 앱 추적 투명성(ATT) 권한을 요청하고, 결과를 Meta SDK에 반영합니다. + // 스플래시 애니메이션 완료 시점은 앱이 확실히 active 상태이므로 다이얼로그 표시가 보장됩니다. + private func requestTrackingAuthorization() { + guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { return } + + ATTrackingManager.requestTrackingAuthorization { status in + Settings.shared.isAdvertiserTrackingEnabled = (status == .authorized) + } + } func sceneWillResignActive(_ scene: UIScene) { } @@ -51,6 +66,8 @@ extension SceneDelegate: SplashViewDelegate { func splashView(_ sender: Presentation.SplashViewController, isCompletedAnimated: Bool) { guard isCompletedAnimated else { return } + requestTrackingAuthorization() + guard let userDataRepository = DIContainer.shared.resolve(type: UserDataRepositoryProtocol.self) else { fatalError("userDataRepository 의존성이 등록되지 않았습니다.") } diff --git a/Projects/Domain/Sources/DomainDependencyAssembler.swift b/Projects/Domain/Sources/DomainDependencyAssembler.swift index ff4d081d..f786d9de 100644 --- a/Projects/Domain/Sources/DomainDependencyAssembler.swift +++ b/Projects/Domain/Sources/DomainDependencyAssembler.swift @@ -20,8 +20,11 @@ public struct DomainDependencyAssembler: DependencyAssemblerProtocol { guard let authRepository = DIContainer.shared.resolve(type: AuthRepositoryProtocol.self) else { fatalError("authRepository 의존성이 등록되지 않았습니다.") } + guard let analyticsLogger = DIContainer.shared.resolve(type: AnalyticsLoggerProtocol.self) + else { fatalError("analyticsLogger 의존성이 등록되지 않았습니다.") } + DIContainer.shared.register(type: LoginUseCaseProtocol.self) { _ in - return LoginUseCase(authRepository: authRepository) + return LoginUseCase(authRepository: authRepository, analyticsLogger: analyticsLogger) } DIContainer.shared.register(type: LogoutUseCaseProtocol.self) { _ in @@ -46,7 +49,10 @@ public struct DomainDependencyAssembler: DependencyAssemblerProtocol { guard let onboardingRepository = container.resolve(type: OnboardingRepositoryProtocol.self) else { fatalError("onboardingRepository 의존성이 등록되지 않았습니다.") } - return ResultRecommendedRoutineUseCase(onboardingRepository: onboardingRepository, emotionRepository: emotionRepository) + return ResultRecommendedRoutineUseCase( + onboardingRepository: onboardingRepository, + emotionRepository: emotionRepository, + analyticsLogger: analyticsLogger) } DIContainer.shared.register(type: UserDataUseCaseProtocol.self) { container in @@ -60,7 +66,7 @@ public struct DomainDependencyAssembler: DependencyAssemblerProtocol { guard let routineRepository = container.resolve(type: RoutineRepositoryProtocol.self) else { fatalError("routineRepository 의존성이 등록되지 않았습니다.") } - return RoutineUseCase(routineRepository: routineRepository) + return RoutineUseCase(routineRepository: routineRepository, analyticsLogger: analyticsLogger) } DIContainer.shared.register(type: ReportUseCaseProtocol.self) { container in diff --git a/Projects/Domain/Sources/Entity/Enum/AnalyticsEvent.swift b/Projects/Domain/Sources/Entity/Enum/AnalyticsEvent.swift new file mode 100644 index 00000000..0e0f86cc --- /dev/null +++ b/Projects/Domain/Sources/Entity/Enum/AnalyticsEvent.swift @@ -0,0 +1,17 @@ +// +// AnalyticsEvent.swift +// Domain +// +// Created by 이동현 on 8/22/25. +// + +/// 광고 전환 측정 등 분석 도구로 전달되는 도메인 이벤트입니다. +/// 도메인은 "어떤 일이 일어났는지"만 알리고, 전송 여부/횟수 등의 트래킹 정책은 구현체가 결정합니다. +public enum AnalyticsEvent { + /// 회원가입(약관 동의) 완료 + case signUpCompleted + /// 온보딩 완료 + case onboardingCompleted + /// 루틴 완료 + case routineCompleted +} diff --git a/Projects/Domain/Sources/Protocol/AnalyticsLoggerProtocol.swift b/Projects/Domain/Sources/Protocol/AnalyticsLoggerProtocol.swift new file mode 100644 index 00000000..292178f3 --- /dev/null +++ b/Projects/Domain/Sources/Protocol/AnalyticsLoggerProtocol.swift @@ -0,0 +1,10 @@ +// +// AnalyticsLoggerProtocol.swift +// Domain +// +// Created by 이동현 on 8/22/25. +// + +public protocol AnalyticsLoggerProtocol { + func log(_ event: AnalyticsEvent) +} diff --git a/Projects/Domain/Sources/UseCase/Auth/LoginUseCase.swift b/Projects/Domain/Sources/UseCase/Auth/LoginUseCase.swift index 283dfa2b..2032d59a 100644 --- a/Projects/Domain/Sources/UseCase/Auth/LoginUseCase.swift +++ b/Projects/Domain/Sources/UseCase/Auth/LoginUseCase.swift @@ -7,9 +7,11 @@ public final class LoginUseCase: LoginUseCaseProtocol { private let authRepository: AuthRepositoryProtocol + private let analyticsLogger: AnalyticsLoggerProtocol - public init(authRepository: AuthRepositoryProtocol) { + public init(authRepository: AuthRepositoryProtocol, analyticsLogger: AnalyticsLoggerProtocol) { self.authRepository = authRepository + self.analyticsLogger = analyticsLogger } public func kakaoLogin() async throws -> UserState { @@ -24,5 +26,6 @@ public final class LoginUseCase: LoginUseCaseProtocol { public func sumbitAgreement(agreements: [TermsType: Bool]) async throws { try await authRepository.submitAgreement(agreements: agreements) + analyticsLogger.log(.signUpCompleted) } } diff --git a/Projects/Domain/Sources/UseCase/ResultRecommendedRoutine/ResultRecommendedRoutineUseCase.swift b/Projects/Domain/Sources/UseCase/ResultRecommendedRoutine/ResultRecommendedRoutineUseCase.swift index cd8ed6f5..4e4e6193 100644 --- a/Projects/Domain/Sources/UseCase/ResultRecommendedRoutine/ResultRecommendedRoutineUseCase.swift +++ b/Projects/Domain/Sources/UseCase/ResultRecommendedRoutine/ResultRecommendedRoutineUseCase.swift @@ -9,14 +9,21 @@ public final class ResultRecommendedRoutineUseCase: ResultRecommendedRoutineUseCaseProtocol { private let onboardingRepository: OnboardingRepositoryProtocol private let emotionRepository: EmotionRepositoryProtocol + private let analyticsLogger: AnalyticsLoggerProtocol - public init(onboardingRepository: OnboardingRepositoryProtocol, emotionRepository: EmotionRepositoryProtocol) { + public init( + onboardingRepository: OnboardingRepositoryProtocol, + emotionRepository: EmotionRepositoryProtocol, + analyticsLogger: AnalyticsLoggerProtocol + ) { self.onboardingRepository = onboardingRepository self.emotionRepository = emotionRepository + self.analyticsLogger = analyticsLogger } public func fetchResultRecommendedRoutines(onboardingEntity: OnboardingEntity) async throws -> [RecommendedRoutineEntity] { let recommendedRoutines = try await onboardingRepository.registerOnboarding(onboardingEntity: onboardingEntity) + analyticsLogger.log(.onboardingCompleted) return recommendedRoutines } diff --git a/Projects/Domain/Sources/UseCase/Routine/RoutineUseCase.swift b/Projects/Domain/Sources/UseCase/Routine/RoutineUseCase.swift index 4e36cc77..6c713099 100644 --- a/Projects/Domain/Sources/UseCase/Routine/RoutineUseCase.swift +++ b/Projects/Domain/Sources/UseCase/Routine/RoutineUseCase.swift @@ -10,9 +10,11 @@ import Shared public final class RoutineUseCase: RoutineUseCaseProtocol { private let routineRepository: RoutineRepositoryProtocol + private let analyticsLogger: AnalyticsLoggerProtocol - public init(routineRepository: RoutineRepositoryProtocol) { + public init(routineRepository: RoutineRepositoryProtocol, analyticsLogger: AnalyticsLoggerProtocol) { self.routineRepository = routineRepository + self.analyticsLogger = analyticsLogger } public func fetchRoutine(routineId: String) async throws -> RoutineEntity? { @@ -65,5 +67,10 @@ public final class RoutineUseCase: RoutineUseCaseProtocol { public func updateRoutineCompletions(routines: [RoutineEntity]) async throws { try await routineRepository.updateRoutineCompletions(routines: routines) + + // 완료 처리된 루틴이 있을 때만 이벤트를 발행합니다. (완료 해제는 제외) + if routines.contains(where: { $0.routineCompleteYn }) { + analyticsLogger.log(.routineCompleted) + } } } diff --git a/SupportingFiles/Info.plist b/SupportingFiles/Info.plist index 54789fe0..56869077 100644 --- a/SupportingFiles/Info.plist +++ b/SupportingFiles/Info.plist @@ -24,11 +24,18 @@ CFBundleURLSchemes kakao$(KAKAO_NATIVE_KEY) + fb$(FB_APP_ID) CFBundleVersion 1 + FacebookAppID + $(FB_APP_ID) + FacebookClientToken + $(FB_CLIENT_TOKEN) + FacebookDisplayName + 빛나길 KakaoAPIKey $(KAKAO_API_KEY) KakaoNativeKey @@ -39,6 +46,8 @@ kakaokompassauth itms-apps + fbapi + fb-messenger-share-api NSCameraUsageDescription 카메라를 사용해 사진을 촬영합니다. 허용하시겠습니까? @@ -46,6 +55,19 @@ 위치 정보 사용을 허용하시겠습니까? NSPhotoLibraryUsageDescription 앨범에서 사진을 선택합니다. 허용하시겠습니까? + NSUserTrackingUsageDescription + 더 나은 맞춤형 광고 경험을 제공하고 앱 개선에 활용하기 위해 사용됩니다. + SKAdNetworkItems + + + SKAdNetworkIdentifier + v9wttpbfk9.skadnetwork + + + SKAdNetworkIdentifier + n38lu8286q.skadnetwork + + UIAppFonts Pretendard-Bold.otf diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index e5e60718..8679a95b 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -9,6 +9,15 @@ "version" : "5.10.2" } }, + { + "identity" : "facebook-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/facebook/facebook-ios-sdk", + "state" : { + "revision" : "3818dd2ccb6547c6d320ff68a1bd599789c3a44f", + "version" : "18.1.0" + } + }, { "identity" : "fscalendar", "kind" : "remoteSourceControl", diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 2c91fc53..d1ac9ca5 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -8,6 +8,7 @@ let package = Package( .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.23.0"), .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.0.0"), .package(url: "https://github.com/airbnb/lottie-ios", from: "4.0.0"), - .package(url: "https://github.com/WenchaoD/FSCalendar.git", from: "2.8.4") + .package(url: "https://github.com/WenchaoD/FSCalendar.git", from: "2.8.4"), + .package(url: "https://github.com/facebook/facebook-ios-sdk", from: "18.1.0") ] )