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")
]
)