diff --git a/MLS/MLS.xcworkspace/contents.xcworkspacedata b/MLS/MLS.xcworkspace/contents.xcworkspacedata
index da356cdc..4309165a 100644
--- a/MLS/MLS.xcworkspace/contents.xcworkspacedata
+++ b/MLS/MLS.xcworkspace/contents.xcworkspacedata
@@ -35,6 +35,9 @@
+
+
diff --git a/MLS/MLSAppFeature/.claude/settings.local.json b/MLS/MLSAppFeature/.claude/settings.local.json
new file mode 100644
index 00000000..070af57c
--- /dev/null
+++ b/MLS/MLSAppFeature/.claude/settings.local.json
@@ -0,0 +1,11 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(swift build:*)",
+ "Bash(rm:*)",
+ "Bash(mkdir:*)",
+ "Bash(swift test:*)",
+ "Bash(find:*)"
+ ]
+ }
+}
diff --git a/MLS/MLSAppFeature/.gitignore b/MLS/MLSAppFeature/.gitignore
new file mode 100644
index 00000000..0023a534
--- /dev/null
+++ b/MLS/MLSAppFeature/.gitignore
@@ -0,0 +1,8 @@
+.DS_Store
+/.build
+/Packages
+xcuserdata/
+DerivedData/
+.swiftpm/configuration/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc
diff --git a/MLS/MLSAppFeature/Package.swift b/MLS/MLSAppFeature/Package.swift
new file mode 100644
index 00000000..895561cb
--- /dev/null
+++ b/MLS/MLSAppFeature/Package.swift
@@ -0,0 +1,52 @@
+// swift-tools-version: 6.2
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "MLSAppFeature",
+ platforms: [.iOS(.v15)],
+ products: [
+ // Interface: 외부 인터페이스와 모델을 제공하는 모듈
+ .library(
+ name: "MLSAppFeatureInterface",
+ targets: ["MLSAppFeatureInterface"]
+ ),
+ // Feature: 실제 기능이 구현된 모듈
+ .library(
+ name: "MLSAppFeature",
+ targets: ["MLSAppFeature"]
+ ),
+ // Testing: 단위 테스트나 Example 앱에서 사용될 Mock 데이터를 제공하는 모듈
+ .library(
+ name: "MLSAppFeatureTesting",
+ targets: ["MLSAppFeatureTesting"]
+ )
+ ],
+ targets: [
+ // Interface 모듈 (도메인 모델 및 프로토콜)
+ .target(
+ name: "MLSAppFeatureInterface",
+ dependencies: []
+ ),
+ // Feature 모듈 (실제 구현)
+ .target(
+ name: "MLSAppFeature",
+ dependencies: ["MLSAppFeatureInterface"]
+ ),
+ // Testing 모듈 (Mock 객체)
+ .target(
+ name: "MLSAppFeatureTesting",
+ dependencies: ["MLSAppFeatureInterface"]
+ ),
+ // Tests 모듈
+ .testTarget(
+ name: "MLSAppFeatureTests",
+ dependencies: [
+ "MLSAppFeature",
+ "MLSAppFeatureInterface",
+ "MLSAppFeatureTesting"
+ ]
+ )
+ ]
+)
diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Local/UserDefaultsDataSource.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Local/UserDefaultsDataSource.swift
new file mode 100644
index 00000000..1f314b3d
--- /dev/null
+++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Local/UserDefaultsDataSource.swift
@@ -0,0 +1,41 @@
+import Foundation
+
+import MLSAppFeatureInterface
+
+/// UserDefaults를 사용하는 Local Data Source
+final class UserDefaultsDataSource {
+ nonisolated(unsafe) private let userDefaults: UserDefaults
+ private let versionKey = "com.mls.updateChecker.skippedVersion"
+ private let dateKey = "com.mls.updateChecker.skippedDate"
+
+ init(userDefaults: UserDefaults = .standard) {
+ self.userDefaults = userDefaults
+ }
+
+ /// 스킵 버전을 저장합니다
+ func saveSkipVersion(_ version: Version, skippedAt date: Date) {
+ userDefaults.set(version.versionString, forKey: versionKey)
+ userDefaults.set(date.timeIntervalSince1970, forKey: dateKey)
+ }
+
+ /// 저장된 스킵 버전을 조회합니다
+ func getSkippedVersion() -> Version? {
+ guard let versionString = userDefaults.string(forKey: versionKey) else {
+ return nil
+ }
+ return Version(versionString: versionString)
+ }
+
+ /// 스킵 날짜를 조회합니다
+ func getSkippedDate() -> Date? {
+ let timestamp = userDefaults.double(forKey: dateKey)
+ guard timestamp > 0 else { return nil }
+ return Date(timeIntervalSince1970: timestamp)
+ }
+
+ /// 스킵 정보를 삭제합니다
+ func clearSkipInfo() {
+ userDefaults.removeObject(forKey: versionKey)
+ userDefaults.removeObject(forKey: dateKey)
+ }
+}
diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Remote/AppStoreService.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Remote/AppStoreService.swift
new file mode 100644
index 00000000..b3ffb71a
--- /dev/null
+++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/DataSources/Remote/AppStoreService.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+import MLSAppFeatureInterface
+
+/// iTunes Search API를 통해 앱스토어 버전 정보를 조회하는 Remote Data Source
+final class AppStoreService {
+ private let urlSession: URLSession
+
+ init(urlSession: URLSession = .shared) {
+ self.urlSession = urlSession
+ }
+
+ /// 앱스토어에서 최신 버전을 조회합니다
+ /// - Parameter appID: 앱스토어 앱 ID
+ /// - Returns: 최신 버전 정보
+ /// - Throws: AppStoreError
+ func fetchLatestVersion(appID: String) async throws -> Version {
+ guard let url = URL(string: "https://itunes.apple.com/lookup?id=\(appID)") else {
+ throw AppStoreError.invalidURL
+ }
+
+ let (data, response) = try await urlSession.data(from: url)
+
+ guard let httpResponse = response as? HTTPURLResponse,
+ (200...299).contains(httpResponse.statusCode) else {
+ throw AppStoreError.invalidResponse
+ }
+
+ guard let lookupResponse = try? JSONDecoder().decode(AppStoreLookupResponse.self, from: data) else {
+ throw AppStoreError.parsingError
+ }
+
+ guard let result = lookupResponse.results.first,
+ let version = Version(versionString: result.version) else {
+ throw AppStoreError.versionNotFound
+ }
+
+ return version
+ }
+}
+
+// MARK: - Response Models
+
+private struct AppStoreLookupResponse: Decodable {
+ let results: [AppStoreResult]
+}
+
+private struct AppStoreResult: Decodable {
+ let version: String
+}
diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/AppStoreRepository.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/AppStoreRepository.swift
new file mode 100644
index 00000000..4f3a6508
--- /dev/null
+++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/AppStoreRepository.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+import MLSAppFeatureInterface
+
+/// 앱스토어 정보를 조회하는 Repository 구현체
+public final class AppStoreRepository: AppStoreRepositoryProtocol, @unchecked Sendable {
+ private let remoteDataSource: AppStoreService
+
+ public init() {
+ self.remoteDataSource = AppStoreService()
+ }
+
+ /// 테스트용 초기화자
+ init(remoteDataSource: AppStoreService) {
+ self.remoteDataSource = remoteDataSource
+ }
+
+ public func fetchLatestVersion(appID: String) async throws -> Version {
+ return try await remoteDataSource.fetchLatestVersion(appID: appID)
+ }
+}
diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/UpdateSkipRepository.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/UpdateSkipRepository.swift
new file mode 100644
index 00000000..0b5692f5
--- /dev/null
+++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Data/Repositories/UpdateSkipRepository.swift
@@ -0,0 +1,45 @@
+import Foundation
+
+import MLSAppFeatureInterface
+
+/// 업데이트 스킵 정보를 관리하는 Repository 구현체
+public final class UpdateSkipRepository: UpdateSkipRepositoryProtocol, @unchecked Sendable {
+ private let localDataSource: UserDefaultsDataSource
+ private let skipDuration: TimeInterval
+
+ /// UpdateSkipRepository 초기화
+ /// - Parameter skipDuration: 스킵 유효 기간 (기본값: 7일)
+ public init(skipDuration: TimeInterval = 7 * 24 * 60 * 60) {
+ self.localDataSource = UserDefaultsDataSource()
+ self.skipDuration = skipDuration
+ }
+
+ /// 테스트용 초기화자
+ /// - Parameters:
+ /// - localDataSource: 로컬 데이터 소스
+ /// - skipDuration: 스킵 유효 기간
+ init(localDataSource: UserDefaultsDataSource, skipDuration: TimeInterval = 7 * 24 * 60 * 60) {
+ self.localDataSource = localDataSource
+ self.skipDuration = skipDuration
+ }
+
+ public func saveSkipVersion(_ version: Version, skippedAt date: Date) {
+ localDataSource.saveSkipVersion(version, skippedAt: date)
+ }
+
+ public func isSkipValid(for version: Version) -> Bool {
+ guard let skippedVersion = localDataSource.getSkippedVersion(),
+ let skippedDate = localDataSource.getSkippedDate(),
+ skippedVersion == version else {
+ return false
+ }
+
+ // 스킵 기간이 지났는지 확인
+ let elapsed = Date().timeIntervalSince(skippedDate)
+ return elapsed >= 0 && elapsed < skipDuration
+ }
+
+ public func clearSkipInfo() {
+ localDataSource.clearSkipInfo()
+ }
+}
diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeature/Domain/UseCases/UpdateCheckerUseCase.swift b/MLS/MLSAppFeature/Sources/MLSAppFeature/Domain/UseCases/UpdateCheckerUseCase.swift
new file mode 100644
index 00000000..b3c20b1b
--- /dev/null
+++ b/MLS/MLSAppFeature/Sources/MLSAppFeature/Domain/UseCases/UpdateCheckerUseCase.swift
@@ -0,0 +1,55 @@
+import Foundation
+
+import MLSAppFeatureInterface
+
+/// 앱 업데이트 체크 Use Case 구현체
+public final class UpdateCheckerUseCase: UpdateCheckerUseCaseProtocol {
+ private let appStoreRepository: AppStoreRepositoryProtocol
+ private let skipRepository: UpdateSkipRepositoryProtocol
+ private let appID: String
+
+ /// UpdateCheckerUseCase 초기화
+ /// - Parameters:
+ /// - appID: 앱스토어 앱 ID
+ /// - appStoreRepository: 앱스토어 정보 조회 Repository
+ /// - skipRepository: 스킵 정보 관리 Repository
+ public init(
+ appID: String,
+ appStoreRepository: AppStoreRepositoryProtocol,
+ skipRepository: UpdateSkipRepositoryProtocol
+ ) {
+ self.appID = appID
+ self.appStoreRepository = appStoreRepository
+ self.skipRepository = skipRepository
+ }
+
+ public func checkUpdate(currentVersion: Version) async throws -> UpdateStatus {
+ let latestVersion = try await appStoreRepository.fetchLatestVersion(appID: appID)
+
+ // 최신 버전이 현재 버전보다 낮거나 같으면 업데이트 불필요
+ guard latestVersion > currentVersion else {
+ return .none
+ }
+
+ // major 버전이 다르면 강제 업데이트
+ if latestVersion.major != currentVersion.major {
+ return .force(latestVersion: latestVersion)
+ }
+
+ // minor 또는 patch 차이면 선택 업데이트
+ // 단, 사용자가 이전에 스킵했고 7일이 지나지 않았으면 none 반환
+ if skipRepository.isSkipValid(for: latestVersion) {
+ return .none
+ }
+
+ return .optional(latestVersion: latestVersion)
+ }
+
+ public func skipUpdate(version: Version) {
+ skipRepository.saveSkipVersion(version, skippedAt: Date())
+ }
+
+ public func clearSkipInfo() {
+ skipRepository.clearSkipInfo()
+ }
+}
diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/UpdateStatus.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/UpdateStatus.swift
new file mode 100644
index 00000000..e9707969
--- /dev/null
+++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/UpdateStatus.swift
@@ -0,0 +1,13 @@
+import Foundation
+
+/// 앱 업데이트 상태를 나타내는 열거형
+public enum UpdateStatus: Equatable, Sendable {
+ /// 강제 업데이트 필요 (major 버전 차이)
+ case force(latestVersion: Version)
+
+ /// 선택적 업데이트 가능 (minor 또는 patch 차이)
+ case optional(latestVersion: Version)
+
+ /// 업데이트 불필요
+ case none
+}
diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/Version.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/Version.swift
new file mode 100644
index 00000000..ae647174
--- /dev/null
+++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Entities/Version.swift
@@ -0,0 +1,38 @@
+import Foundation
+
+/// 앱 버전을 나타내는 도메인 엔티티
+/// major.minor.patch 형식의 시맨틱 버저닝을 지원합니다
+public struct Version: Equatable, Comparable, Sendable {
+ public let major: Int
+ public let minor: Int
+ public let patch: Int
+
+ public init(major: Int, minor: Int, patch: Int) {
+ self.major = major
+ self.minor = minor
+ self.patch = patch
+ }
+
+ /// 버전 문자열로부터 Version 객체를 생성합니다
+ /// - Parameter versionString: "1.2.3" 형식의 버전 문자열
+ /// - Returns: 파싱에 성공하면 Version 객체, 실패하면 nil
+ public init?(versionString: String) {
+ let components = versionString.split(separator: ".").compactMap { Int($0) }
+ guard components.count >= 2 else { return nil }
+
+ self.major = components[0]
+ self.minor = components[1]
+ self.patch = components.count > 2 ? components[2] : 0
+ }
+
+ /// 버전을 문자열로 반환합니다
+ public var versionString: String {
+ "\(major).\(minor).\(patch)"
+ }
+
+ public static func < (lhs: Version, rhs: Version) -> Bool {
+ if lhs.major != rhs.major { return lhs.major < rhs.major }
+ if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
+ return lhs.patch < rhs.patch
+ }
+}
diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/AppStoreRepositoryProtocol.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/AppStoreRepositoryProtocol.swift
new file mode 100644
index 00000000..dea53755
--- /dev/null
+++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/AppStoreRepositoryProtocol.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+/// 앱스토어 정보를 조회하는 Repository 프로토콜
+public protocol AppStoreRepositoryProtocol: Sendable {
+ /// 앱스토어에서 최신 버전을 조회합니다
+ /// - Parameter appID: 앱스토어 앱 ID
+ /// - Returns: 최신 버전 정보
+ /// - Throws: 조회 실패 시 AppStoreError
+ func fetchLatestVersion(appID: String) async throws -> Version
+}
+
+/// 앱스토어 조회 시 발생할 수 있는 에러
+public enum AppStoreError: Error, Equatable, Sendable {
+ case invalidURL
+ case networkFailure
+ case invalidResponse
+ case versionNotFound
+ case parsingError
+}
diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/UpdateSkipRepositoryProtocol.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/UpdateSkipRepositoryProtocol.swift
new file mode 100644
index 00000000..c2f840a3
--- /dev/null
+++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/Repositories/UpdateSkipRepositoryProtocol.swift
@@ -0,0 +1,18 @@
+import Foundation
+
+/// 업데이트 스킵 정보를 관리하는 Repository 프로토콜
+public protocol UpdateSkipRepositoryProtocol: Sendable {
+ /// 특정 버전에 대한 스킵 정보를 저장합니다
+ /// - Parameters:
+ /// - version: 스킵할 버전
+ /// - date: 스킵 날짜
+ func saveSkipVersion(_ version: Version, skippedAt date: Date)
+
+ /// 특정 버전에 대한 스킵 정보가 유효한지 확인합니다
+ /// - Parameter version: 확인할 버전
+ /// - Returns: 스킵 정보가 유효하면 true (7일 이내)
+ func isSkipValid(for version: Version) -> Bool
+
+ /// 저장된 스킵 정보를 삭제합니다
+ func clearSkipInfo()
+}
diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/UseCases/UpdateCheckerUseCaseProtocol.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/UseCases/UpdateCheckerUseCaseProtocol.swift
new file mode 100644
index 00000000..12583c07
--- /dev/null
+++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureInterface/Domain/UseCases/UpdateCheckerUseCaseProtocol.swift
@@ -0,0 +1,17 @@
+import Foundation
+
+/// 앱 업데이트 체크를 담당하는 Use Case 프로토콜
+public protocol UpdateCheckerUseCaseProtocol: Sendable {
+ /// 현재 버전과 앱스토어의 최신 버전을 비교하여 업데이트 상태를 반환합니다
+ /// - Parameter currentVersion: 현재 앱 버전
+ /// - Returns: 업데이트 상태 (force, optional, none)
+ /// - Throws: 앱스토어 조회 실패 시 에러
+ func checkUpdate(currentVersion: Version) async throws -> UpdateStatus
+
+ /// 사용자가 선택 업데이트를 스킵했을 때 호출합니다
+ /// - Parameter version: 스킵할 버전
+ func skipUpdate(version: Version)
+
+ /// 스킵 정보를 초기화합니다
+ func clearSkipInfo()
+}
diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockAppStoreRepository.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockAppStoreRepository.swift
new file mode 100644
index 00000000..29044c2b
--- /dev/null
+++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockAppStoreRepository.swift
@@ -0,0 +1,32 @@
+import Foundation
+import MLSAppFeatureInterface
+
+/// 테스트용 MockAppStoreRepository
+public final class MockAppStoreRepository: AppStoreRepositoryProtocol, @unchecked Sendable {
+ public var mockVersion: Version?
+ public var mockError: Error?
+ public var fetchCallCount = 0
+
+ public init() {}
+
+ public func fetchLatestVersion(appID: String) async throws -> Version {
+ fetchCallCount += 1
+
+ if let error = mockError {
+ throw error
+ }
+
+ guard let version = mockVersion else {
+ throw AppStoreError.versionNotFound
+ }
+
+ return version
+ }
+
+ /// Mock 데이터를 초기화합니다
+ public func reset() {
+ mockVersion = nil
+ mockError = nil
+ fetchCallCount = 0
+ }
+}
diff --git a/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockUpdateSkipRepository.swift b/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockUpdateSkipRepository.swift
new file mode 100644
index 00000000..5beebb5d
--- /dev/null
+++ b/MLS/MLSAppFeature/Sources/MLSAppFeatureTesting/Domain/MockUpdateSkipRepository.swift
@@ -0,0 +1,62 @@
+import Foundation
+import MLSAppFeatureInterface
+
+/// 테스트용 MockUpdateSkipRepository
+public final class MockUpdateSkipRepository: UpdateSkipRepositoryProtocol, @unchecked Sendable {
+ private var skippedVersion: Version?
+ private var skippedDate: Date?
+ public var skipDuration: TimeInterval = 7 * 24 * 60 * 60
+
+ public var saveCallCount = 0
+ public var clearCallCount = 0
+
+ public init() {}
+
+ public func saveSkipVersion(_ version: Version, skippedAt date: Date) {
+ saveCallCount += 1
+ skippedVersion = version
+ skippedDate = date
+ }
+
+ public func isSkipValid(for version: Version) -> Bool {
+ guard let skippedVersion = skippedVersion,
+ let skippedDate = skippedDate else {
+ return false
+ }
+
+ guard skippedVersion == version else {
+ return false
+ }
+
+ let currentDate = Date()
+ let elapsedTime = currentDate.timeIntervalSince(skippedDate)
+
+ return elapsedTime < skipDuration
+ }
+
+ public func clearSkipInfo() {
+ clearCallCount += 1
+ skippedVersion = nil
+ skippedDate = nil
+ }
+
+ /// Mock 데이터를 초기화합니다
+ public func reset() {
+ skippedVersion = nil
+ skippedDate = nil
+ saveCallCount = 0
+ clearCallCount = 0
+ }
+
+ // MARK: - Test Helpers
+
+ /// 테스트용 헬퍼 메서드
+ public func getSkippedVersion() -> Version? {
+ return skippedVersion
+ }
+
+ /// 테스트용 헬퍼 메서드
+ public func getSkippedDate() -> Date? {
+ return skippedDate
+ }
+}
diff --git a/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Data/UpdateSkipRepositoryTests.swift b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Data/UpdateSkipRepositoryTests.swift
new file mode 100644
index 00000000..c8aa5079
--- /dev/null
+++ b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Data/UpdateSkipRepositoryTests.swift
@@ -0,0 +1,163 @@
+import Foundation
+import Testing
+
+@testable import MLSAppFeature
+@testable import MLSAppFeatureInterface
+
+/// UpdateSkipRepository의 스킵 정보 저장, 조회, 유효성 검증 기능을 테스트합니다
+@Suite("UpdateSkipRepository 테스트")
+struct UpdateSkipRepositoryTests {
+
+ /// 테스트용 UserDefaults와 Repository를 생성합니다
+ func createRepository(suiteName: String = "com.mls.test.skip") -> (UpdateSkipRepository, UserDefaults) {
+ let userDefaults = UserDefaults(suiteName: suiteName)!
+ userDefaults.removePersistentDomain(forName: suiteName)
+ userDefaults.synchronize()
+
+ let dataSource = UserDefaultsDataSource(userDefaults: userDefaults)
+ let repository = UpdateSkipRepository(localDataSource: dataSource)
+
+ return (repository, userDefaults)
+ }
+
+ // MARK: - 저장 및 조회 테스트
+
+ /// 스킵 정보를 저장하고 유효성 검증이 올바른지 확인합니다
+ @Test("스킵 정보 저장 및 유효성 검증")
+ func saveAndValidateSkip() {
+ // Given
+ let suiteName = "com.mls.test.skip.save"
+ let (repository, userDefaults) = createRepository(suiteName: suiteName)
+ defer { userDefaults.removePersistentDomain(forName: suiteName) }
+
+ let version = Version(major: 1, minor: 2, patch: 0)
+
+ // When: 버전 스킵 저장
+ repository.saveSkipVersion(version, skippedAt: Date())
+
+ // Then: 스킵 정보가 유효함
+ #expect(repository.isSkipValid(for: version))
+ }
+
+ // MARK: - 7일 정책 테스트
+
+ /// 스킵한 지 3일 이내인 경우 유효한지 확인합니다
+ @Test("스킵 유효: 7일 이내 (3일 전)")
+ func skipValidWithinDuration() {
+ // Given: 3일 전에 스킵함
+ let suiteName = "com.mls.test.skip.valid"
+ let (repository, userDefaults) = createRepository(suiteName: suiteName)
+ defer { userDefaults.removePersistentDomain(forName: suiteName) }
+
+ let version = Version(major: 1, minor: 2, patch: 0)
+ let threeDaysAgo = Date().addingTimeInterval(-3 * 24 * 60 * 60)
+
+ // When
+ repository.saveSkipVersion(version, skippedAt: threeDaysAgo)
+
+ // Then: 7일 이내이므로 유효
+ #expect(repository.isSkipValid(for: version))
+ }
+
+ /// 스킵한 지 8일이 지난 경우 무효한지 확인합니다
+ @Test("스킵 무효: 7일 초과 (8일 전)")
+ func skipInvalidAfterDuration() {
+ // Given: 8일 전에 스킵함
+ let suiteName = "com.mls.test.skip.expired"
+ let (repository, userDefaults) = createRepository(suiteName: suiteName)
+ defer { userDefaults.removePersistentDomain(forName: suiteName) }
+
+ let version = Version(major: 1, minor: 2, patch: 0)
+ let eightDaysAgo = Date().addingTimeInterval(-8 * 24 * 60 * 60)
+
+ // When
+ repository.saveSkipVersion(version, skippedAt: eightDaysAgo)
+
+ // Then: 7일을 초과했으므로 무효
+ #expect(!repository.isSkipValid(for: version))
+ }
+
+ // MARK: - 버전 불일치 테스트
+
+ /// 스킵한 버전과 다른 버전을 체크하면 무효한지 확인합니다
+ @Test("스킵 무효: 버전 불일치")
+ func skipInvalidForDifferentVersion() {
+ // Given: v1.2.0을 스킵함
+ let suiteName = "com.mls.test.skip.diff"
+ let (repository, userDefaults) = createRepository(suiteName: suiteName)
+ defer { userDefaults.removePersistentDomain(forName: suiteName) }
+
+ let v1 = Version(major: 1, minor: 2, patch: 0)
+ let v2 = Version(major: 1, minor: 3, patch: 0)
+ repository.saveSkipVersion(v1, skippedAt: Date())
+
+ // When/Then: v1.3.0은 스킵되지 않았으므로 무효
+ #expect(!repository.isSkipValid(for: v2))
+ }
+
+ // MARK: - 스킵 정보 삭제 테스트
+
+ /// clearSkipInfo 호출 시 스킵 정보가 삭제되는지 확인합니다
+ @Test("스킵 정보 삭제")
+ func clearSkipInfo() {
+ // Given: 스킵 정보 저장
+ let suiteName = "com.mls.test.skip.clear"
+ let (repository, userDefaults) = createRepository(suiteName: suiteName)
+ defer { userDefaults.removePersistentDomain(forName: suiteName) }
+
+ let version = Version(major: 1, minor: 2, patch: 0)
+ repository.saveSkipVersion(version, skippedAt: Date())
+
+ // When: 스킵 정보 삭제
+ repository.clearSkipInfo()
+
+ // Then: 스킵 정보가 무효화됨
+ #expect(!repository.isSkipValid(for: version))
+ }
+
+ // MARK: - 영속성 테스트
+
+ /// UserDefaults에 저장된 데이터가 다른 Repository 인스턴스에서도 조회되는지 확인합니다
+ @Test("UserDefaults 영속성")
+ func persistence() {
+ // Given: Repository1에서 스킵 정보 저장
+ let suiteName = "com.mls.test.skip.persistence"
+ let (repository, userDefaults) = createRepository(suiteName: suiteName)
+ defer { userDefaults.removePersistentDomain(forName: suiteName) }
+
+ let version = Version(major: 2, minor: 5, patch: 1)
+ repository.saveSkipVersion(version, skippedAt: Date())
+
+ // When: 새로운 Repository2 생성 (같은 UserDefaults 사용)
+ let dataSource2 = UserDefaultsDataSource(userDefaults: userDefaults)
+ let repository2 = UpdateSkipRepository(localDataSource: dataSource2)
+
+ // Then: Repository2에서도 스킵 정보 조회 가능
+ #expect(repository2.isSkipValid(for: version))
+ }
+
+ // MARK: - 커스텀 기간 테스트
+
+ /// 스킵 기간을 커스터마이징할 수 있는지 확인합니다
+ @Test("커스텀 스킵 기간: 1일")
+ func customDuration() {
+ // Given: 스킵 기간을 1일로 설정
+ let suiteName = "com.mls.test.skip.custom"
+ let userDefaults = UserDefaults(suiteName: suiteName)!
+ userDefaults.removePersistentDomain(forName: suiteName)
+ userDefaults.synchronize()
+ defer { userDefaults.removePersistentDomain(forName: suiteName) }
+
+ let dataSource = UserDefaultsDataSource(userDefaults: userDefaults)
+ let customRepo = UpdateSkipRepository(localDataSource: dataSource, skipDuration: 1 * 24 * 60 * 60)
+
+ let version = Version(major: 1, minor: 2, patch: 0)
+ let twoDaysAgo = Date().addingTimeInterval(-2 * 24 * 60 * 60)
+
+ // When: 2일 전에 스킵함
+ customRepo.saveSkipVersion(version, skippedAt: twoDaysAgo)
+
+ // Then: 1일 기준으로는 무효
+ #expect(!customRepo.isSkipValid(for: version))
+ }
+}
diff --git a/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/UpdateCheckerUseCaseTests.swift b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/UpdateCheckerUseCaseTests.swift
new file mode 100644
index 00000000..9a0e875e
--- /dev/null
+++ b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/UpdateCheckerUseCaseTests.swift
@@ -0,0 +1,177 @@
+import Foundation
+import Testing
+
+@testable import MLSAppFeature
+@testable import MLSAppFeatureInterface
+@testable import MLSAppFeatureTesting
+
+/// UpdateChecker Use Case의 업데이트 판단 로직과 스킵 기능을 테스트합니다
+@Suite("UpdateChecker Use Case 테스트")
+struct UpdateCheckerUseCaseTests {
+ let mockAppStoreRepository: MockAppStoreRepository
+ let mockSkipRepository: MockUpdateSkipRepository
+ let useCase: UpdateCheckerUseCase
+
+ /// 각 테스트마다 새로운 Mock 객체와 UseCase를 생성합니다
+ init() {
+ mockAppStoreRepository = MockAppStoreRepository()
+ mockSkipRepository = MockUpdateSkipRepository()
+ useCase = UpdateCheckerUseCase(
+ appID: "123456789",
+ appStoreRepository: mockAppStoreRepository,
+ skipRepository: mockSkipRepository
+ )
+ }
+
+ // MARK: - 강제 업데이트 테스트
+
+ /// major 버전이 다를 때 강제 업데이트가 반환되는지 확인합니다
+ @Test("강제 업데이트: major 버전 차이")
+ func forceUpdate() async throws {
+ // Given: 앱스토어 버전이 2.0.0, 현재 버전이 1.0.0
+ mockAppStoreRepository.mockVersion = Version(major: 2, minor: 0, patch: 0)
+ let currentVersion = Version(major: 1, minor: 0, patch: 0)
+
+ // When: 업데이트 체크
+ let status = try await useCase.checkUpdate(currentVersion: currentVersion)
+
+ // Then: 강제 업데이트 상태
+ guard case .force(let latest) = status else {
+ Issue.record("Expected force update status")
+ return
+ }
+ #expect(latest == Version(major: 2, minor: 0, patch: 0))
+ }
+
+ // MARK: - 선택 업데이트 테스트
+
+ /// minor 버전이 다를 때 선택 업데이트가 반환되는지 확인합니다
+ @Test("선택 업데이트: minor 버전 차이")
+ func optionalUpdate() async throws {
+ // Given: 앱스토어 버전이 1.2.0, 현재 버전이 1.1.0
+ mockAppStoreRepository.mockVersion = Version(major: 1, minor: 2, patch: 0)
+ let currentVersion = Version(major: 1, minor: 1, patch: 0)
+
+ // When: 업데이트 체크
+ let status = try await useCase.checkUpdate(currentVersion: currentVersion)
+
+ // Then: 선택 업데이트 상태
+ guard case .optional(let latest) = status else {
+ Issue.record("Expected optional update status")
+ return
+ }
+ #expect(latest == Version(major: 1, minor: 2, patch: 0))
+ }
+
+ // MARK: - 업데이트 불필요 테스트
+
+ /// 현재 버전과 최신 버전이 같을 때 none이 반환되는지 확인합니다
+ @Test("업데이트 불필요: 동일한 버전")
+ func noneUpdate() async throws {
+ // Given: 앱스토어 버전과 현재 버전이 모두 1.0.0
+ mockAppStoreRepository.mockVersion = Version(major: 1, minor: 0, patch: 0)
+ let currentVersion = Version(major: 1, minor: 0, patch: 0)
+
+ // When: 업데이트 체크
+ let status = try await useCase.checkUpdate(currentVersion: currentVersion)
+
+ // Then: 업데이트 불필요
+ #expect(status == .none)
+ }
+
+ // MARK: - 스킵 로직 테스트
+
+ /// 사용자가 스킵한 버전이 7일 이내인 경우 none이 반환되는지 확인합니다
+ @Test("스킵 로직: 유효한 스킵 (7일 이내)")
+ func skipLogicValid() async throws {
+ // Given: 최신 버전 1.2.0을 오늘 스킵함
+ let latest = Version(major: 1, minor: 2, patch: 0)
+ mockAppStoreRepository.mockVersion = latest
+ mockSkipRepository.saveSkipVersion(latest, skippedAt: Date())
+
+ // When: 업데이트 체크
+ let currentVersion = Version(major: 1, minor: 1, patch: 0)
+ let status = try await useCase.checkUpdate(currentVersion: currentVersion)
+
+ // Then: 스킵이 유효하므로 none 반환
+ #expect(status == .none)
+ }
+
+ /// 스킵한 지 7일이 지난 경우 다시 optional이 반환되는지 확인합니다
+ @Test("스킵 로직: 만료된 스킵 (7일 초과)")
+ func skipLogicExpired() async throws {
+ // Given: 최신 버전 1.2.0을 8일 전에 스킵함
+ let latest = Version(major: 1, minor: 2, patch: 0)
+ mockAppStoreRepository.mockVersion = latest
+ let eightDaysAgo = Date().addingTimeInterval(-8 * 24 * 60 * 60)
+ mockSkipRepository.saveSkipVersion(latest, skippedAt: eightDaysAgo)
+
+ // When: 업데이트 체크
+ let currentVersion = Version(major: 1, minor: 1, patch: 0)
+ let status = try await useCase.checkUpdate(currentVersion: currentVersion)
+
+ // Then: 스킵이 만료되어 optional 반환
+ guard case .optional = status else {
+ Issue.record("Expected optional update status")
+ return
+ }
+ }
+
+ /// 강제 업데이트는 스킵 정보를 무시하고 항상 force를 반환하는지 확인합니다
+ @Test("스킵 로직: 강제 업데이트는 스킵 무시")
+ func forceUpdateIgnoresSkip() async throws {
+ // Given: 최신 버전 2.0.0을 스킵했지만 major 버전 차이
+ let latest = Version(major: 2, minor: 0, patch: 0)
+ mockAppStoreRepository.mockVersion = latest
+ mockSkipRepository.saveSkipVersion(latest, skippedAt: Date())
+
+ // When: 업데이트 체크
+ let currentVersion = Version(major: 1, minor: 0, patch: 0)
+ let status = try await useCase.checkUpdate(currentVersion: currentVersion)
+
+ // Then: 스킵을 무시하고 강제 업데이트 반환
+ guard case .force = status else {
+ Issue.record("Expected force update status")
+ return
+ }
+ }
+
+ // MARK: - 스킵 관리 테스트
+
+ /// skipUpdate 호출 시 Repository에 저장이 요청되는지 확인합니다
+ @Test("스킵 저장 기능")
+ func skipUpdate() {
+ // Given
+ let version = Version(major: 1, minor: 2, patch: 0)
+
+ // When: 버전 스킵
+ useCase.skipUpdate(version: version)
+
+ // Then: Repository의 save가 호출됨
+ #expect(mockSkipRepository.saveCallCount == 1)
+ }
+
+ /// clearSkipInfo 호출 시 Repository의 clear가 호출되는지 확인합니다
+ @Test("스킵 정보 초기화")
+ func clearSkipInfo() {
+ // When: 스킵 정보 초기화
+ useCase.clearSkipInfo()
+
+ // Then: Repository의 clear가 호출됨
+ #expect(mockSkipRepository.clearCallCount == 1)
+ }
+
+ // MARK: - 에러 처리 테스트
+
+ /// 앱스토어 조회 실패 시 에러가 전파되는지 확인합니다
+ @Test("에러 처리: 앱스토어 조회 실패")
+ func errorHandling() async throws {
+ // Given: 앱스토어 조회 시 에러 발생
+ mockAppStoreRepository.mockError = AppStoreError.invalidResponse
+
+ // When/Then: 에러가 전파됨
+ await #expect(throws: AppStoreError.self) {
+ try await useCase.checkUpdate(currentVersion: Version(major: 1, minor: 0, patch: 0))
+ }
+ }
+}
diff --git a/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/VersionTests.swift b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/VersionTests.swift
new file mode 100644
index 00000000..220217d8
--- /dev/null
+++ b/MLS/MLSAppFeature/Tests/MLSAppFeatureTests/Domain/VersionTests.swift
@@ -0,0 +1,58 @@
+import Testing
+
+@testable import MLSAppFeatureInterface
+
+/// Version 엔티티의 생성, 비교, 파싱 기능을 테스트합니다
+@Suite("Version 엔티티 테스트")
+struct VersionTests {
+
+ // MARK: - 초기화 테스트
+
+ /// 버전을 major, minor, patch로 초기화하고 속성이 올바른지 확인합니다
+ @Test("버전 초기화 및 문자열 변환")
+ func versionInitialization() {
+ let version = Version(major: 1, minor: 2, patch: 3)
+
+ #expect(version.major == 1)
+ #expect(version.minor == 2)
+ #expect(version.patch == 3)
+ #expect(version.versionString == "1.2.3")
+ }
+
+ /// 버전 문자열로부터 Version 객체를 생성하고 파싱이 올바른지 확인합니다
+ @Test("버전 문자열 파싱")
+ func versionFromString() {
+ // 정상 케이스: "1.2.3"
+ #expect(Version(versionString: "1.2.3") == Version(major: 1, minor: 2, patch: 3))
+
+ // patch가 없는 경우: "1.2" → patch는 0으로 처리
+ #expect(Version(versionString: "1.2") == Version(major: 1, minor: 2, patch: 0))
+
+ // 잘못된 형식
+ #expect(Version(versionString: "invalid") == nil)
+ #expect(Version(versionString: "1") == nil)
+ }
+
+ // MARK: - 비교 테스트
+
+ /// 버전 간 비교 연산자가 올바르게 동작하는지 확인합니다
+ @Test("버전 비교 연산")
+ func versionComparison() {
+ let v1 = Version(major: 1, minor: 0, patch: 0)
+ let v2 = Version(major: 2, minor: 0, patch: 0)
+ let v3 = Version(major: 1, minor: 2, patch: 0)
+ let v4 = Version(major: 1, minor: 2, patch: 3)
+
+ // major 버전 비교
+ #expect(v1 < v2)
+
+ // minor 버전 비교
+ #expect(v1 < v3)
+
+ // patch 버전 비교
+ #expect(v3 < v4)
+
+ // 동등 비교
+ #expect(v1 == Version(major: 1, minor: 0, patch: 0))
+ }
+}