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
132 changes: 132 additions & 0 deletions Data/Sources/DTO/Travel/TravelCacheDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//
// TravelCacheDTO.swift
// Data
//
// Created by 김민희 on 12/15/25.
//

import Foundation
import Domain

public struct TravelCacheDTO: Codable {
let statusRawValue: String
let cachedAt: Date
let travels: [TravelCacheItemDTO]

var status: TravelStatus {
TravelStatus(rawValue: statusRawValue) ?? .unknown
}

var isExpired: Bool {
Date().timeIntervalSince(cachedAt) > TravelCacheConstants.expiration
}
}

public struct TravelCacheItemDTO: Codable {
let id: String
let title: String
let startDate: Date
let endDate: Date
let countryCode: String
let koreanCountryName: String
let baseCurrency: String
let baseExchangeRate: Double
let destinationCurrency: String
let inviteCode: String?
let deepLink: String?
let statusRawValue: String
let role: String?
let createdAt: Date
let ownerName: String
let members: [TravelCacheMemberDTO]
let currencies: [String]?

var status: TravelStatus {
TravelStatus(rawValue: statusRawValue) ?? .unknown
}
}

struct TravelCacheMemberDTO: Codable {
let id: String
let name: String
let role: String
let email: String?
let avatarUrl: String?
}

enum TravelCacheConstants {
static let directoryName = "travels"
static let expiration: TimeInterval = 60 * 60 * 6 // 6 hours
}

extension TravelCacheItemDTO {
func toDomain() -> Travel {
Travel(
id: id,
title: title,
startDate: startDate,
endDate: endDate,
countryCode: countryCode,
koreanCountryName: koreanCountryName,
baseCurrency: baseCurrency,
baseExchangeRate: baseExchangeRate,
destinationCurrency: destinationCurrency,
inviteCode: inviteCode,
deepLink: deepLink,
status: status,
role: role,
createdAt: createdAt,
ownerName: ownerName,
members: members.map { $0.toDomain() },
currencies: currencies
)
}
}

extension TravelCacheMemberDTO {
func toDomain() -> TravelMember {
TravelMember(
id: id,
name: name,
role: MemberRole(value: role),
email: email,
avatarUrl: avatarUrl
)
}
}

extension Travel {
func toCacheItem() -> TravelCacheItemDTO {
TravelCacheItemDTO(
id: id,
title: title,
startDate: startDate,
endDate: endDate,
countryCode: countryCode,
koreanCountryName: koreanCountryName,
baseCurrency: baseCurrency,
baseExchangeRate: baseExchangeRate,
destinationCurrency: destinationCurrency,
inviteCode: inviteCode,
deepLink: deepLink,
statusRawValue: status.rawValue,
role: role,
createdAt: createdAt,
ownerName: ownerName,
members: members.map { $0.toCacheItem() },
currencies: currencies
)
}
}

extension TravelMember {
func toCacheItem() -> TravelCacheMemberDTO {
TravelCacheMemberDTO(
id: id,
name: name,
role: role.rawValue,
email: email,
avatarUrl: avatarUrl
)
}
}
143 changes: 143 additions & 0 deletions Data/Sources/DataSource/Local/TravelLocalDataSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//
// TravelLocalDataSource.swift
// Data
//
// Created by 김민희 on 12/15/25.
//

import Foundation
import Domain

public enum TravelCacheError: Error {
case cacheDirectoryUnavailable
case fileNotFound
case dataCorrupted
}

public protocol TravelLocalDataSourceProtocol: Actor {
func observe(status: TravelStatus) -> AsyncStream<[TravelCacheItemDTO]>
func load(status: TravelStatus) async throws -> TravelCacheDTO?
func save(_ cache: TravelCacheDTO) async throws
func clear(status: TravelStatus)
}

public actor TravelLocalDataSource: TravelLocalDataSourceProtocol {
private let fileManager = FileManager.default
private lazy var cacheDirectory: URL? = {
guard var cachesURL = fileManager.urls(
for: .cachesDirectory,
in: .userDomainMask
).first else {
return nil
}
cachesURL.append(
path: TravelCacheConstants.directoryName,
directoryHint: .isDirectory
)
try? fileManager.createDirectory(
at: cachesURL,
withIntermediateDirectories: true
)
return cachesURL
}()

private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}()

private let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}()

// 여행 상태별로 AsyncStream 보관
private var observers: [TravelStatus: [UUID: AsyncStream<[TravelCacheItemDTO]>.Continuation]] = [:]

public init() {}

public func observe(status: TravelStatus) -> AsyncStream<[TravelCacheItemDTO]> {
// 캐시 파일이 갱신될 때마다 새로운 배열을 내보내며, 가지고 있는 캐시가 있다면 즉시 전달한다.
AsyncStream { continuation in
Task { [weak self] in
guard let self else { return }
let id = UUID()
continuation.onTermination = { [weak self] _ in
Task { await self?.removeObserver(status: status, id: id) }
}
await self.storeObserver(status: status, id: id, continuation: continuation)
if let cache = try? await self.load(status: status) {
continuation.yield(cache.travels)
}
}
}
}

public func load(status: TravelStatus) async throws -> TravelCacheDTO? {
let url = try cacheURL(for: status)
guard fileManager.fileExists(atPath: url.path()) else {
return nil
}

do {
let data = try Data(contentsOf: url)
let cache = try decoder.decode(TravelCacheDTO.self, from: data)
if cache.isExpired {
try? fileManager.removeItem(at: url)
return nil
}
return cache
} catch {
try? fileManager.removeItem(at: url)
throw TravelCacheError.dataCorrupted
}
}

public func save(_ cache: TravelCacheDTO) async throws {
let url = try cacheURL(for: cache.status)
let data = try encoder.encode(cache)
try data.write(to: url, options: [.atomic])
notifyObservers(status: cache.status, travels: cache.travels)
}

public func clear(status: TravelStatus) {
guard let url = try? cacheURL(for: status) else { return }
try? fileManager.removeItem(at: url)
}
}

private extension TravelLocalDataSource {
func cacheURL(for status: TravelStatus) throws -> URL {
guard var directory = cacheDirectory else {
throw TravelCacheError.cacheDirectoryUnavailable
}
directory.append(
path: "travel_\(status.rawValue).json",
directoryHint: .notDirectory
)
return directory
}

func storeObserver(
status: TravelStatus,
id: UUID,
continuation: AsyncStream<[TravelCacheItemDTO]>.Continuation
) {
var continuations = observers[status] ?? [:]
continuations[id] = continuation
observers[status] = continuations
}

func removeObserver(status: TravelStatus, id: UUID) {
var continuations = observers[status] ?? [:]
continuations[id] = nil
observers[status] = continuations.isEmpty ? nil : continuations
}

func notifyObservers(status: TravelStatus, travels: [TravelCacheItemDTO]) {
guard let continuations = observers[status] else { return }
continuations.values.forEach { $0.yield(travels) }
}
}
70 changes: 68 additions & 2 deletions Data/Sources/Repository/Travel/TravelRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,31 @@ import Domain
public final class TravelRepository: TravelRepositoryProtocol {

private let remote: TravelRemoteDataSourceProtocol
private let local: TravelLocalDataSourceProtocol

public init(remote: TravelRemoteDataSourceProtocol) {
public init(
remote: TravelRemoteDataSourceProtocol,
local: TravelLocalDataSourceProtocol = TravelLocalDataSource()
) {
self.remote = remote
self.local = local
}

public func fetchTravels(
input: FetchTravelsInput
) async throws -> [Travel] {
let requestDTO = input.toDTO()
let dtoList = try await remote.fetchTravels(body: requestDTO).items
return dtoList.map { $0.toDomain() }
let travels = dtoList.map { $0.toDomain() }
if let status = input.status {
let appendExisting = input.page > 1
try await persistCache(
travels: travels,
status: status,
appendExisting: appendExisting
)
}
return travels
}

public func createTravel(
Expand Down Expand Up @@ -53,4 +67,56 @@ public final class TravelRepository: TravelRepositoryProtocol {
let responseDTO = try await remote.fetchTravelDetail(id: id)
return responseDTO.toDomain()
}

// 로컬 캐시 파일이 갱신될 때마다 스트림을 통해 최신 Travel 배열을 방출
public func observeCachedTravels(status: TravelStatus) -> AsyncStream<[Travel]> {
AsyncStream { continuation in
let task = Task {
let baseStream = await local.observe(status: status)
for await cacheItems in baseStream {
guard !Task.isCancelled else { break }
let travels = cacheItems.map { $0.toDomain() }
continuation.yield(travels)
}
continuation.finish()
}

continuation.onTermination = { _ in
task.cancel()
}
}
}
}

private extension TravelRepository {
func persistCache(
travels: [Travel],
status: TravelStatus,
appendExisting: Bool
) async throws {
var cacheItems = travels.map { $0.toCacheItem() }

if appendExisting,
let existing = try? await local.load(status: status)?.travels {
cacheItems = existing + cacheItems
}

let deduped = deduplicate(items: cacheItems)
let cache = TravelCacheDTO(
statusRawValue: status.rawValue,
cachedAt: Date(),
travels: deduped
)
try await local.save(cache)
}

// 한 번 캐시된 여행은 다시 저장하지 않도록 ID 기반으로 중복을 제거한다.
func deduplicate(items: [TravelCacheItemDTO]) -> [TravelCacheItemDTO] {
var seen = Set<String>()
return items.filter { item in
guard !seen.contains(item.id) else { return false }
seen.insert(item.id)
return true
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "ChatGPT Image 2025년 12월 12일 오후 02_55_30 1.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "travelList2.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "travelList3.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading