diff --git a/Data/Sources/DataSource/Local/TravelLocalDataSource.swift b/Data/Sources/DataSource/Local/TravelLocalDataSource.swift index cc2f05d6..b4156851 100644 --- a/Data/Sources/DataSource/Local/TravelLocalDataSource.swift +++ b/Data/Sources/DataSource/Local/TravelLocalDataSource.swift @@ -11,13 +11,15 @@ import Domain public protocol TravelLocalDataSourceProtocol: Actor { func load(status: TravelStatus) async throws -> [Travel]? + func load(travelId: String) async throws -> Travel? func save(travels: [Travel], status: TravelStatus) async throws + func upsert(travel: Travel) async throws func clear(status: TravelStatus) async throws } public actor TravelLocalDataSource: TravelLocalDataSourceProtocol { private let container: ModelContainer - + public init(container: ModelContainer? = nil) { if let container { self.container = container @@ -27,69 +29,170 @@ public actor TravelLocalDataSource: TravelLocalDataSourceProtocol { TravelCacheItemEntity.self, TravelCacheMemberEntity.self ]) + do { self.container = try ModelContainer( for: schema, - configurations: ModelConfiguration(isStoredInMemoryOnly: false) + configurations: ModelConfiguration( + isStoredInMemoryOnly: false + ) ) } catch { fatalError("Failed to create Travel cache container: \(error)") } } } - + + // 상태별 여행 리스트 조회 public func load(status: TravelStatus) async throws -> [Travel]? { - let context = ModelContext(container) - guard let cache = try fetchCache(for: status, in: context) else { + let context = makeContext() + + guard let statusCache = try fetchCache( + for: status, + in: context + ) else { return nil } - - // 캐시 시간 만료되면 삭제 - if cache.isExpired { - context.delete(cache) + + if statusCache.isExpired { + context.delete(statusCache) try context.save() return nil } - // api로 받은 리스트랑 순서 같도록 정렬 - let sorted = cache.travels.sorted { $0.orderIndex < $1.orderIndex } - return sorted.map { $0.toDomain() } + + return statusCache.travels + .sorted { $0.orderIndex < $1.orderIndex } + .map { $0.toDomain() } } - + + // travelId로 여행 조회 + public func load(travelId: String) async throws -> Travel? { + let context = makeContext() + + let caches = try fetchAllCaches(in: context) + let validCaches = try purgeExpiredCaches(caches, in: context) + + for cache in validCaches { + if let cachedTravel = cache.travels.first(where: { $0.id == travelId }) { + return cachedTravel.toDomain() + } + } + + return nil + } + + // 여행 리스트 전체 저장 public func save(travels: [Travel], status: TravelStatus) async throws { - let context = ModelContext(container) + let context = makeContext() + if let existing = try fetchCache(for: status, in: context) { context.delete(existing) } - - let cache = TravelCacheEntity(status: status, cachedAt: Date()) - // 순서 유지하기 위해 index 저장 - cache.travels = travels.enumerated().map { index, travel in + + let statusCache = TravelCacheEntity( + status: status, + cachedAt: Date() + ) + + statusCache.travels = travels.enumerated().map { index, travel in travel.toCacheModel(orderIndex: index) } - - context.insert(cache) + + context.insert(statusCache) try context.save() } - + + // 여행 insert or update + public func upsert(travel: Travel) async throws { + let context = makeContext() + let caches = try fetchAllCaches(in: context) + + // 기존 여행 제거 + var preservedOrderIndex: Int? + + for cache in caches { + if let index = cache.travels.firstIndex(where: { $0.id == travel.id }) { + preservedOrderIndex = cache.travels[index].orderIndex + cache.travels.remove(at: index) + } + } + + // 대상 상태 캐시 찾기 or 생성 + let destinationCache = caches.first { + $0.statusRawValue == travel.status.rawValue + } ?? { + let newCache = TravelCacheEntity( + status: travel.status, + cachedAt: Date() + ) + context.insert(newCache) + return newCache + }() + + // orderIndex 유지 또는 append + let orderIndex = preservedOrderIndex ?? destinationCache.travels.count + destinationCache.travels.append( + travel.toCacheModel(orderIndex: orderIndex) + ) + destinationCache.cachedAt = Date() + + try context.save() + } + + // 캐시 전체 삭제 public func clear(status: TravelStatus) async throws { - let context = ModelContext(container) - guard let cache = try fetchCache(for: status, in: context) else { return } - context.delete(cache) + let context = makeContext() + guard let statusCache = try fetchCache(for: status, in: context) else { + return + } + context.delete(statusCache) try context.save() } } private extension TravelLocalDataSource { - func fetchCache( - for status: TravelStatus, + func makeContext() -> ModelContext { + ModelContext(container) + } + + // 모든 캐시 fetch + func fetchAllCaches( in context: ModelContext - ) throws -> TravelCacheEntity? { + ) throws -> [TravelCacheEntity] { var descriptor = FetchDescriptor() - // TravelCacheEntity 호출 시 travels도 같이 호출 descriptor.relationshipKeyPathsForPrefetching = [ \TravelCacheEntity.travels ] - let caches = try context.fetch(descriptor) - return caches.first { $0.statusRawValue == status.rawValue } + return try context.fetch(descriptor) + } + + // 상태별 캐시 fetch + func fetchCache( + for status: TravelStatus, + in context: ModelContext + ) throws -> TravelCacheEntity? { + try fetchAllCaches(in: context) + .first { $0.statusRawValue == status.rawValue } + } + + // 만료 캐시 삭제 + func purgeExpiredCaches( + _ caches: [TravelCacheEntity], + in context: ModelContext + ) throws -> [TravelCacheEntity] { + + let validCaches = caches.filter { cache in + if cache.isExpired { + context.delete(cache) + return false + } + return true + } + + if validCaches.count != caches.count { + try context.save() + } + + return validCaches } } diff --git a/Data/Sources/Repository/Travel/TravelRepository.swift b/Data/Sources/Repository/Travel/TravelRepository.swift index 03ff2cf2..c8ed4a70 100644 --- a/Data/Sources/Repository/Travel/TravelRepository.swift +++ b/Data/Sources/Repository/Travel/TravelRepository.swift @@ -44,6 +44,12 @@ public final class TravelRepository: TravelRepositoryProtocol { try await local.load(status: status) } + public func loadCachedTravel( + id: String + ) async throws -> Travel? { + try await local.load(travelId: id) + } + public func createTravel( input: CreateTravelInput ) async throws -> Travel { @@ -71,7 +77,9 @@ public final class TravelRepository: TravelRepositoryProtocol { id: String ) async throws -> Travel { let responseDTO = try await remote.fetchTravelDetail(id: id) - return responseDTO.toDomain() + let travel = responseDTO.toDomain() + try await local.upsert(travel: travel) + return travel } } diff --git a/Domain/Sources/Repository/Travel/MockTravelRepository.swift b/Domain/Sources/Repository/Travel/MockTravelRepository.swift index e7311b37..8a46e46f 100644 --- a/Domain/Sources/Repository/Travel/MockTravelRepository.swift +++ b/Domain/Sources/Repository/Travel/MockTravelRepository.swift @@ -101,6 +101,10 @@ public final class MockTravelRepository: TravelRepositoryProtocol { let filtered = travels.filter { $0.status == status } return filtered.isEmpty ? nil : filtered } + + public func loadCachedTravel(id: String) async throws -> Travel? { + travels.first { $0.id == id } + } } private extension MockTravelRepository { diff --git a/Domain/Sources/Repository/Travel/TravelRepositoryProtocol.swift b/Domain/Sources/Repository/Travel/TravelRepositoryProtocol.swift index 615c07bb..8a9be017 100644 --- a/Domain/Sources/Repository/Travel/TravelRepositoryProtocol.swift +++ b/Domain/Sources/Repository/Travel/TravelRepositoryProtocol.swift @@ -14,4 +14,5 @@ public protocol TravelRepositoryProtocol { func deleteTravel(id: String) async throws func fetchTravelDetail(id: String) async throws -> Travel func loadCachedTravels(status: TravelStatus) async throws -> [Travel]? + func loadCachedTravel(id: String) async throws -> Travel? } diff --git a/Domain/Sources/UseCase/Travel/LoadTravelDetailCacheUseCase.swift b/Domain/Sources/UseCase/Travel/LoadTravelDetailCacheUseCase.swift new file mode 100644 index 00000000..400c4bd0 --- /dev/null +++ b/Domain/Sources/UseCase/Travel/LoadTravelDetailCacheUseCase.swift @@ -0,0 +1,46 @@ +// +// LoadTravelDetailCacheUseCase.swift +// Domain +// +// Created by 김민희 on 12/16/25. +// + +import Foundation +import Dependencies + +public protocol LoadTravelDetailCacheUseCaseProtocol { + func execute(id: String) async throws -> Travel? +} + +public struct LoadTravelDetailCacheUseCase: LoadTravelDetailCacheUseCaseProtocol { + private let repository: TravelRepositoryProtocol + + public init(repository: TravelRepositoryProtocol) { + self.repository = repository + } + + public func execute(id: String) async throws -> Travel? { + try await repository.loadCachedTravel(id: id) + } +} + +extension LoadTravelDetailCacheUseCase: DependencyKey { + public static var liveValue: any LoadTravelDetailCacheUseCaseProtocol = { + LoadTravelDetailCacheUseCase(repository: MockTravelRepository()) + }() + + public static var previewValue: any LoadTravelDetailCacheUseCaseProtocol = { + LoadTravelDetailCacheUseCase(repository: MockTravelRepository()) + }() + + public static var testValue: any LoadTravelDetailCacheUseCaseProtocol = { + LoadTravelDetailCacheUseCase(repository: MockTravelRepository()) + }() +} + +public extension DependencyValues { + var loadTravelDetailCacheUseCase: any LoadTravelDetailCacheUseCaseProtocol { + get { self[LoadTravelDetailCacheUseCase.self] } + set { self[LoadTravelDetailCacheUseCase.self] = newValue } + } +} diff --git a/Features/Settlement/Sources/SettlementFeature.swift b/Features/Settlement/Sources/SettlementFeature.swift index c66ee663..3929863f 100644 --- a/Features/Settlement/Sources/SettlementFeature.swift +++ b/Features/Settlement/Sources/SettlementFeature.swift @@ -14,6 +14,7 @@ import SettlementResultFeature @Reducer public struct SettlementFeature { @Dependency(\.fetchTravelDetailUseCase) var fetchTravelDetailUseCase + @Dependency(\.loadTravelDetailCacheUseCase) var loadTravelDetailCacheUseCase public init() {} @@ -64,6 +65,7 @@ public struct SettlementFeature { @CasePathable public enum InnerAction { + case cachedTravel(Travel) case travelDetailResponse(Result) } @@ -158,6 +160,12 @@ extension SettlementFeature { // MARK: - Inner Action Handler private func handleInnerAction(state: inout State, action: Action.InnerAction) -> Effect { switch action { + case let .cachedTravel(travel): + state.$travel.withLock { + $0 = travel + } + return .none + case let .travelDetailResponse(.success(travel)): state.$travel.withLock { $0 = travel @@ -178,6 +186,9 @@ extension SettlementFeature { case .fetchTravel: let travelId = state.travelId return .run { send in + if let cached = try? await loadTravelDetailCacheUseCase.execute(id: travelId) { + await send(.inner(.cachedTravel(cached))) + } let result = await Result { try await fetchTravelDetailUseCase.execute(id: travelId) } diff --git a/Features/Travel/Sources/Reducer/TravelSettingFeature.swift b/Features/Travel/Sources/Reducer/TravelSettingFeature.swift index ba00e411..79f0d792 100644 --- a/Features/Travel/Sources/Reducer/TravelSettingFeature.swift +++ b/Features/Travel/Sources/Reducer/TravelSettingFeature.swift @@ -36,6 +36,7 @@ public struct TravelSettingFeature { case onAppear case fetchDetail + case cachedDetailLoaded(Travel) case fetchDetailResponse(Result) case basicInfo(BasicSettingFeature.Action) @@ -64,6 +65,7 @@ public struct TravelSettingFeature { } @Dependency(\.fetchTravelDetailUseCase) var fetchTravelDetailUseCase + @Dependency(\.loadTravelDetailCacheUseCase) var loadTravelDetailCacheUseCase public var body: some ReducerOf { Reduce { state, action in @@ -77,6 +79,9 @@ public struct TravelSettingFeature { case .fetchDetail: state.isLoading = true return .run { [id = state.travelId] send in + if let cached = try? await loadTravelDetailCacheUseCase.execute(id: id) { + await send(.cachedDetailLoaded(cached)) + } do { let travel = try await fetchTravelDetailUseCase.execute(id: id) await send(.fetchDetailResponse(.success(travel))) @@ -85,16 +90,14 @@ public struct TravelSettingFeature { } } - case .fetchDetailResponse(.success(let travel)): + case .cachedDetailLoaded(let travel): state.isLoading = false - state.basicInfo = BasicSettingFeature.State(travel: travel) - state.memberSetting = MemberSettingFeature.State(travel: travel) - state.manage = TravelManageFeature.State( - travelId: travel.id, - isOwner: travel.members.first(where: { $0.role == .owner })?.id - == travel.members.first?.id - ) + state.applyTravel(travel) + return .none + case .fetchDetailResponse(.success(let travel)): + state.isLoading = false + state.applyTravel(travel) return .none case .fetchDetailResponse(.failure(let err)): @@ -175,6 +178,16 @@ public struct TravelSettingFeature { } private extension TravelSettingFeature.State { + mutating func applyTravel(_ travel: Travel) { + basicInfo = BasicSettingFeature.State(travel: travel) + memberSetting = MemberSettingFeature.State(travel: travel) + let ownerId = travel.members.first(where: { $0.role == .owner })?.id + manage = TravelManageFeature.State( + travelId: travel.id, + isOwner: ownerId == travel.members.first?.id + ) + } + func confirmLeaveAlert() -> DSAlertState { DSAlertState( title: "여행을 나가시겠어요?", diff --git a/SseuDamApp/Sources/Application/LiveDependencies.swift b/SseuDamApp/Sources/Application/LiveDependencies.swift index 870639c2..e4d64a72 100644 --- a/SseuDamApp/Sources/Application/LiveDependencies.swift +++ b/SseuDamApp/Sources/Application/LiveDependencies.swift @@ -49,6 +49,7 @@ public enum LiveDependencies { // Travel dependencies.fetchTravelsUseCase = FetchTravelsUseCase(repository: travelRepository) dependencies.loadTravelCacheUseCase = LoadTravelCacheUseCase(repository: travelRepository) + dependencies.loadTravelDetailCacheUseCase = LoadTravelDetailCacheUseCase(repository: travelRepository) dependencies.createTravelUseCase = CreateTravelUseCase(repository: travelRepository) dependencies.fetchTravelDetailUseCase = FetchTravelDetailUseCase(repository: travelRepository) dependencies.updateTravelUseCase = UpdateTravelUseCase(repository: travelRepository)