Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 133 additions & 30 deletions Data/Sources/DataSource/Local/TravelLocalDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>()
// 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
}
}
10 changes: 9 additions & 1 deletion Data/Sources/Repository/Travel/TravelRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

}
Expand Down
4 changes: 4 additions & 0 deletions Domain/Sources/Repository/Travel/MockTravelRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
46 changes: 46 additions & 0 deletions Domain/Sources/UseCase/Travel/LoadTravelDetailCacheUseCase.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
11 changes: 11 additions & 0 deletions Features/Settlement/Sources/SettlementFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import SettlementResultFeature
@Reducer
public struct SettlementFeature {
@Dependency(\.fetchTravelDetailUseCase) var fetchTravelDetailUseCase
@Dependency(\.loadTravelDetailCacheUseCase) var loadTravelDetailCacheUseCase

public init() {}

Expand Down Expand Up @@ -64,6 +65,7 @@ public struct SettlementFeature {

@CasePathable
public enum InnerAction {
case cachedTravel(Travel)
case travelDetailResponse(Result<Travel, Error>)
}

Expand Down Expand Up @@ -158,6 +160,12 @@ extension SettlementFeature {
// MARK: - Inner Action Handler
private func handleInnerAction(state: inout State, action: Action.InnerAction) -> Effect<Action> {
switch action {
case let .cachedTravel(travel):
state.$travel.withLock {
$0 = travel
}
return .none

case let .travelDetailResponse(.success(travel)):
state.$travel.withLock {
$0 = travel
Expand All @@ -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)
}
Expand Down
Loading
Loading