From 52006e00d3f94a047f0afc802cb5589faebc9d15 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 16 Dec 2025 16:01:42 +0900 Subject: [PATCH 1/8] [Release] Version 1.0.4 build 24 with app version display --- Features/Profile/Sources/View/ProfileView.swift | 7 +++++++ .../ProjectDescriptionHelpers/ Project+Settings.swift | 2 +- .../ProjectDescriptionHelpers/Environment.swift | 3 ++- .../ProjectDescriptionHelpers/InfoPlist+Helpers.swift | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Features/Profile/Sources/View/ProfileView.swift b/Features/Profile/Sources/View/ProfileView.swift index c3e3c64..8b789d8 100644 --- a/Features/Profile/Sources/View/ProfileView.swift +++ b/Features/Profile/Sources/View/ProfileView.swift @@ -318,7 +318,14 @@ extension ProfileView { .background(.gray1) .frame(height: 1) + HStack { + Text("v \(store.appVersion ?? "")") + .font(.app(.body, weight: .regular)) + .foregroundStyle(.appBlack) + Spacer() + } + .padding(.vertical, 12) } .padding(.horizontal, 16) diff --git a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/ Project+Settings.swift b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/ Project+Settings.swift index bad802f..267fcf4 100644 --- a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/ Project+Settings.swift +++ b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/ Project+Settings.swift @@ -29,7 +29,7 @@ extension Settings { .setCFBundleDisplayName(Environment.appName) .setMarketingVersion(Environment.mainAppVersion) .setASAuthenticationServicesEnabled() - .setCurrentProjectVersion("22") + .setCurrentProjectVersion(Environment.mainAppBuildVersion) .setCodeSignIdentity() .setCodeSignStyle("Manual") .setSwiftVersion("6.0") diff --git a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Environment.swift b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Environment.swift index c7f5259..ce7f6d1 100644 --- a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Environment.swift +++ b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/Environment.swift @@ -9,7 +9,8 @@ public enum Environment { public static let organizationTeamId = "N94CS4N6VR" // MARK: - Version Management - public static let mainAppVersion = "1.0.3" + public static let mainAppVersion = "1.0.4" + public static let mainAppBuildVersion = "24" public static let demoAppVersion = "0.1.0" // Demo app용 별도 버전 // MARK: - Platform diff --git a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/InfoPlist+Helpers.swift b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/InfoPlist+Helpers.swift index 6c240ab..5588d29 100644 --- a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/InfoPlist+Helpers.swift +++ b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/InfoPlist+Helpers.swift @@ -45,7 +45,7 @@ public extension InfoPlist { "UIUserInterfaceStyle": .string("Light"), "CFBundleShortVersionString": .string(Environment.mainAppVersion), "ITSAppUsesNonExemptEncryption": .boolean(false), - "CFBundleVersion": .string("14"), + "CFBundleVersion": .string(Environment.mainAppBuildVersion), "LSApplicationQueriesSchemes": .array([ .string("kakaokompassauth"), // 카카오톡 로그인 .string("kakaolink"), // 카카오톡 공유 From a1a5ed8ea2345b046b20acb7f47f591633698cdc Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 16 Dec 2025 16:56:17 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[fix]:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=20=EC=84=B8=EC=85=98=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=99=84=EC=A0=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그아웃 성공 시 AppStorage의 sessionId, socialType, userId 정리 - 기존에는 Keychain만 정리되고 AppStorage는 남아있던 문제 해결 - 회원탈퇴와 동일한 수준의 세션 정리 적용 - 로그인 풀리는 현상 방지 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4 --- Features/Profile/Sources/Reducer/ProfileFeature.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Features/Profile/Sources/Reducer/ProfileFeature.swift b/Features/Profile/Sources/Reducer/ProfileFeature.swift index 6306651..1c1ba14 100644 --- a/Features/Profile/Sources/Reducer/ProfileFeature.swift +++ b/Features/Profile/Sources/Reducer/ProfileFeature.swift @@ -424,7 +424,11 @@ extension ProfileFeature { switch result { case .success(let loginData): state.logoutStatus = loginData + // Keychain과 AppStorage 모두 정리 KeychainManager.shared.clearAll() + state.$sessionId.withLock { $0 = nil } + state.$socialType.withLock { $0 = nil } + state.$userId.withLock { $0 = nil } return .send(.delegate(.presentLogin)) case .failure(let error): From 33222c821c465a9a4986fc8d32ebc11094d48f17 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 16 Dec 2025 17:09:55 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[feat]:=20=EC=8A=A4=EC=BC=88=EB=A0=88?= =?UTF-8?q?=ED=86=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/Profile/ProfileSkeletonView.swift | 14 ++++++------- .../Sources/Reducer/ProfileFeature.swift | 3 --- .../Sources/Reducer/SplashFeature.swift | 1 + .../Sources/Application/AppDelegate.swift | 21 ------------------- 4 files changed, 8 insertions(+), 31 deletions(-) diff --git a/DesignSystem/Sources/View/Profile/ProfileSkeletonView.swift b/DesignSystem/Sources/View/Profile/ProfileSkeletonView.swift index b4bb959..33127e7 100644 --- a/DesignSystem/Sources/View/Profile/ProfileSkeletonView.swift +++ b/DesignSystem/Sources/View/Profile/ProfileSkeletonView.swift @@ -49,13 +49,13 @@ public struct ProfileSkeletonView: View { cardPlaceholder() .padding(.horizontal, 16) - sectionPlaceholder() - .padding(.horizontal, 16) - - Spacer().frame(height: 12) - - cardPlaceholder() - .padding(.horizontal, 16) +// sectionPlaceholder() +// .padding(.horizontal, 16) +// +// Spacer().frame(height: 12) +// +// cardPlaceholder() +// .padding(.horizontal, 16) Spacer() } diff --git a/Features/Profile/Sources/Reducer/ProfileFeature.swift b/Features/Profile/Sources/Reducer/ProfileFeature.swift index 1c1ba14..ac5ad02 100644 --- a/Features/Profile/Sources/Reducer/ProfileFeature.swift +++ b/Features/Profile/Sources/Reducer/ProfileFeature.swift @@ -426,9 +426,6 @@ extension ProfileFeature { state.logoutStatus = loginData // Keychain과 AppStorage 모두 정리 KeychainManager.shared.clearAll() - state.$sessionId.withLock { $0 = nil } - state.$socialType.withLock { $0 = nil } - state.$userId.withLock { $0 = nil } return .send(.delegate(.presentLogin)) case .failure(let error): diff --git a/Features/Splash/Sources/Reducer/SplashFeature.swift b/Features/Splash/Sources/Reducer/SplashFeature.swift index 63555b4..5bfd0c5 100644 --- a/Features/Splash/Sources/Reducer/SplashFeature.swift +++ b/Features/Splash/Sources/Reducer/SplashFeature.swift @@ -245,6 +245,7 @@ extension SplashFeature { state.$sessionId.withLock { $0 = sessionData.sessionId } state.$socialType.withLock { $0 = sessionData.provider } return .send(.delegate(.presentLogin)) + case .failure(let error): state.$socialType.withLock { $0 = nil } state.errorMessage = "세션 조회 실패 : \(error.localizedDescription)" diff --git a/SseuDamApp/Sources/Application/AppDelegate.swift b/SseuDamApp/Sources/Application/AppDelegate.swift index 7b0bbfc..2471683 100644 --- a/SseuDamApp/Sources/Application/AppDelegate.swift +++ b/SseuDamApp/Sources/Application/AppDelegate.swift @@ -123,27 +123,6 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @MainActor UNUserNotif completionHandler() } - - /// 여러 가능한 경로에서 딥링크 문자열을 추출 -// nonisolated private static func extractDeepLink(from userInfo: [AnyHashable: Any]) -> String? { -// // 1) 단일 문자열 필드 우선 -// let stringKeys = ["deeplink", "url"] -// for key in stringKeys { -// if let url = userInfo[key] as? String { return url } -// } -// -// // 2) 중첩 객체에서 url 필드 찾기 (호환 키: deeplink, data, custom) -// let containerKeys = ["deeplink", "data", "custom"] -// for key in containerKeys { -// guard let container = userInfo[key] as? [String: Any], -// let url = container["url"] as? String else { continue } -// return url -// } -// -// #logDebug("❌ No deep link found in push notification") -// #logDebug("Available keys: \(userInfo.keys)") -// return nil -// } } extension Notification.Name { From d15b36f05419f62e9796832547e11a8fc3584858 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 16 Dec 2025 19:30:22 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[feat]:=20ImageCacheService=20=EB=B0=8F=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Image/ImageCacheService.swift | 90 +++++++++++++++++++ Domain/Sources/Manager/KeychainManager.swift | 2 +- .../View/Components/EditProfileImage.swift | 66 +++++++++----- 3 files changed, 135 insertions(+), 23 deletions(-) create mode 100644 DesignSystem/Sources/Image/ImageCacheService.swift diff --git a/DesignSystem/Sources/Image/ImageCacheService.swift b/DesignSystem/Sources/Image/ImageCacheService.swift new file mode 100644 index 0000000..3510fdc --- /dev/null +++ b/DesignSystem/Sources/Image/ImageCacheService.swift @@ -0,0 +1,90 @@ +// +// ImageCacheService.swift +// DesignSystem +// +// Created by Wonji Suh on 12/04/25. +// + +import Foundation +import UIKit + +public actor ImageCacheService { + public static let shared = ImageCacheService() + + private let cache = NSCache() + private var inFlightTasks: [URL: Task] = [:] + + private init() { + cache.totalCostLimit = 50 * 1024 * 1024 + } + + public func image( + for url: URL + ) async -> UIImage? { + let key = url.absoluteString as NSString + + if let cached = cache.object(forKey: key) { + return cached + } + + if let task = inFlightTasks[url] { + return await task.value + } + + let task = Task(priority: .userInitiated) { [weak self] () -> UIImage? in + guard let self else { return nil } + return await self.fetchAndCache(url: url, key: key) + } + + inFlightTasks[url] = task + + let image = await task.value + inFlightTasks[url] = nil + return image + } + + public func image( + for urlString: String + ) async -> UIImage? { + guard let url = URL(string: urlString) else { return nil } + return await image(for: url) + } + + public func store( + _ image: UIImage, + for url: URL + ) { + let key = url.absoluteString as NSString + cache.setObject(image, forKey: key) + } + + public func removeImage( + for url: URL + ) { + let key = url.absoluteString as NSString + cache.removeObject(forKey: key) + } + + public func clear() { + cache.removeAllObjects() + } + + private func fetchAndCache( + url: URL, + key: NSString + ) async -> UIImage? { + do { + let (data, response) = try await URLSession.shared.data(from: url) + guard + let httpResponse = response as? HTTPURLResponse, + 200..<300 ~= httpResponse.statusCode + else { return nil } + + guard let image = UIImage(data: data) else { return nil } + cache.setObject(image, forKey: key, cost: data.count) + return image + } catch { + return nil + } + } +} diff --git a/Domain/Sources/Manager/KeychainManager.swift b/Domain/Sources/Manager/KeychainManager.swift index 54d9880..a9eaeea 100644 --- a/Domain/Sources/Manager/KeychainManager.swift +++ b/Domain/Sources/Manager/KeychainManager.swift @@ -90,7 +90,7 @@ public struct KeychainManager { kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key.rawValue, kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly ] let status = SecItemAdd(query as CFDictionary, nil) diff --git a/Features/Profile/Sources/View/Components/EditProfileImage.swift b/Features/Profile/Sources/View/Components/EditProfileImage.swift index 6bb8670..806547e 100644 --- a/Features/Profile/Sources/View/Components/EditProfileImage.swift +++ b/Features/Profile/Sources/View/Components/EditProfileImage.swift @@ -14,6 +14,8 @@ public struct EditProfileImage: View { private let imageURL: String? private let action: (() -> Void)? private let onLoadingStateChanged: ((Bool) -> Void)? + @State private var loadedImage: UIImage? + @State private var isLoading: Bool public init( size: CGFloat = 100, @@ -25,6 +27,7 @@ public struct EditProfileImage: View { self.imageURL = imageURL self.action = action self.onLoadingStateChanged = onLoadingStateChanged + _isLoading = State(initialValue: imageURL != nil) } @ViewBuilder @@ -48,39 +51,34 @@ public struct EditProfileImage: View { .fill(imageURL != nil ? .clear : .gray1) .frame(width: size, height: size) - if let imageURL, let url = URL(string: imageURL) { - AsyncImage(url: url) { phase in - switch phase { - case .empty: - ProgressView() - .task { onLoadingStateChanged?(true) } - case .success(let image): - image - .resizable() - .scaledToFill() - .onAppear { onLoadingStateChanged?(false) } - case .failure: - placeholder(iconSize: iconSize) - .task { onLoadingStateChanged?(false) } - @unknown default: - placeholder(iconSize: iconSize) - .task { onLoadingStateChanged?(false) } - } - } + imageContent(iconSize: iconSize) .frame(width: size, height: size) .clipShape(Circle()) - } else { - placeholder(iconSize: iconSize) - } editBadge() } .frame(width: size, height: size) .clipShape(Circle()) .contentShape(Circle()) + .task(id: imageURL) { + await loadImage() + } } + @ViewBuilder + private func imageContent(iconSize: CGFloat) -> some View { + if let image = loadedImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else if isLoading { + ProgressView() + } else { + placeholder(iconSize: iconSize) + } + } + private func editBadge() -> some View { let badgeHeight: CGFloat = 22 @@ -108,6 +106,30 @@ public struct EditProfileImage: View { .frame(width: 82, height: 90) .foregroundStyle(.primary500) } + + private func loadImage() async { + guard let imageURL, let url = URL(string: imageURL) else { + await MainActor.run { + loadedImage = nil + isLoading = false + } + onLoadingStateChanged?(false) + return + } + + await MainActor.run { + isLoading = true + onLoadingStateChanged?(true) + } + + let image = await ImageCacheService.shared.image(for: url) + + await MainActor.run { + isLoading = false + loadedImage = image + onLoadingStateChanged?(false) + } + } } From 28b61e8059ba29a827f668789bfce16d6d778a86 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 16 Dec 2025 21:12:36 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[feat]:=20=20cacheing=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Image/AsyncImage+Cache.swift | 139 ++++++++++ .../Sources/Image/ImageCacheService.swift | 19 +- .../Image/TransparentImageCaching.swift | 249 ++++++++++++++++++ .../View/Components/EditProfileImage.swift | 68 +++-- 4 files changed, 438 insertions(+), 37 deletions(-) create mode 100644 DesignSystem/Sources/Image/AsyncImage+Cache.swift create mode 100644 DesignSystem/Sources/Image/TransparentImageCaching.swift diff --git a/DesignSystem/Sources/Image/AsyncImage+Cache.swift b/DesignSystem/Sources/Image/AsyncImage+Cache.swift new file mode 100644 index 0000000..330ac31 --- /dev/null +++ b/DesignSystem/Sources/Image/AsyncImage+Cache.swift @@ -0,0 +1,139 @@ +// +// AsyncImage+Cache.swift +// DesignSystem +// +// Created by Wonji Suh on 12/16/25. +// + +import SwiftUI + +// MARK: - Auto-Initialization + +/// DesignSystem import 시 자동으로 ImageCacheService 초기화 +private let _autoSetup: Void = { + Task { + // ImageCacheService 초기화 + _ = await ImageCacheService.shared + print("🚀 Direct image caching enabled via DesignSystem import") + } +}() + +// MARK: - SwiftUI AsyncImage Replacement + +/// 기존 SwiftUI.AsyncImage를 완전히 대체하는 typealias +/// 사용자는 전혀 모르지만 자동으로 캐싱됨 +public typealias AsyncImage = DesignSystemAsyncImage + +/// SwiftUI AsyncImage와 동일한 API를 제공하는 향상된 AsyncImage +public struct DesignSystemAsyncImage: View { + private let url: URL? + private let scale: CGFloat + private let transaction: Transaction + private let content: (AsyncImagePhase) -> Content + + @State private var loadedImage: UIImage? + @State private var isLoading: Bool = false + @State private var loadError: Error? + + // MARK: - Initializers + + public init( + url: URL?, + scale: CGFloat = 1, + transaction: Transaction = Transaction(), + @ViewBuilder content: @escaping (AsyncImagePhase) -> Content + ) { + // ImageCacheService 초기화 확인 + _ = _autoSetup + + self.url = url + self.scale = scale + self.transaction = transaction + self.content = content + } + + public var body: some View { + Group { + if let loadedImage { + content(.success(Image(uiImage: loadedImage))) + } else if isLoading { + content(.empty) + } else if loadError != nil { + content(.failure(loadError!)) + } else { + content(.empty) + } + } + .task(id: url) { + await loadImage() + } + .transaction { t in + t.animation = transaction.animation + t.disablesAnimations = transaction.disablesAnimations + t.isContinuous = transaction.isContinuous + } + } + + private func loadImage() async { + guard let url = url else { + await MainActor.run { + isLoading = false + loadedImage = nil + loadError = nil + } + return + } + + await MainActor.run { + isLoading = true + loadError = nil + } + + // 🚀 직접 ImageCacheService 사용으로 빠른 로딩! + let image = await ImageCacheService.shared.image(for: url) + + await MainActor.run { + isLoading = false + loadedImage = image + + if image == nil { + loadError = URLError(.resourceUnavailable) + } + } + } +} + +// MARK: - Convenience Initializers + +extension DesignSystemAsyncImage where Content == Image { + public init( + url: URL?, + scale: CGFloat = 1 + ) { + self.init( + url: url, + scale: scale, + content: { phase in + phase.image ?? Image(systemName: "photo") + } + ) + } +} + +extension DesignSystemAsyncImage { + public init( + url: URL?, + scale: CGFloat = 1, + transaction: Transaction = Transaction(), + @ViewBuilder content: @escaping (Image) -> I, + @ViewBuilder placeholder: @escaping () -> P + ) where Content == _ConditionalContent { + self.init(url: url, scale: scale, transaction: transaction) { phase in + if case .success(let image) = phase { + content(image) + } else { + placeholder() + } + } + } +} diff --git a/DesignSystem/Sources/Image/ImageCacheService.swift b/DesignSystem/Sources/Image/ImageCacheService.swift index 3510fdc..f61fde6 100644 --- a/DesignSystem/Sources/Image/ImageCacheService.swift +++ b/DesignSystem/Sources/Image/ImageCacheService.swift @@ -13,9 +13,12 @@ public actor ImageCacheService { private let cache = NSCache() private var inFlightTasks: [URL: Task] = [:] - private init() { cache.totalCostLimit = 50 * 1024 * 1024 + cache.countLimit = 200 // 더 많은 이미지를 메모리에 캐시 + cache.evictsObjectsWithDiscardedContent = true // 메모리 압박 시 자동 제거 + + print("🚀 ImageCacheService initialized with direct caching") } public func image( @@ -31,7 +34,9 @@ public actor ImageCacheService { return await task.value } - let task = Task(priority: .userInitiated) { [weak self] () -> UIImage? in + // 프로필 이미지는 높은 우선순위로 처리 + let priority: TaskPriority = isProfileImage(url) ? .high : .userInitiated + let task = Task(priority: priority) { [weak self] () -> UIImage? in guard let self else { return nil } return await self.fetchAndCache(url: url, key: key) } @@ -87,4 +92,14 @@ public actor ImageCacheService { return nil } } + + /// 프로필 이미지인지 확인하여 우선순위 적용 + private func isProfileImage(_ url: URL) -> Bool { + let urlString = url.absoluteString.lowercased() + let profileKeywords = ["profile", "avatar", "user", "member"] + + return profileKeywords.contains { keyword in + urlString.contains(keyword) + } + } } diff --git a/DesignSystem/Sources/Image/TransparentImageCaching.swift b/DesignSystem/Sources/Image/TransparentImageCaching.swift new file mode 100644 index 0000000..8449bcb --- /dev/null +++ b/DesignSystem/Sources/Image/TransparentImageCaching.swift @@ -0,0 +1,249 @@ +// +// TransparentImageCaching.swift +// DesignSystem +// +// Created by Wonji Suh on 12/16/25. +// + +import Foundation +import UIKit + +// MARK: - Actor for Thread-Safe Cache Management + +/// Thread-safe한 캐싱 로직을 담당하는 Actor +private actor CacheManager { + private let imageCache = ImageCacheService.shared + private var processingRequests: Set = [] + + func getCachedImage(for url: URL) async -> UIImage? { + return await imageCache.image(for: url) + } + + func storeImage(_ image: UIImage, for url: URL) async { + await imageCache.store(image, for: url) + processingRequests.remove(url) + } + + func shouldProcessRequest(_ url: URL) -> Bool { + return TransparentImageCaching.isImageRequest(url) + } + + func startProcessing(_ url: URL) -> Bool { + guard !processingRequests.contains(url) else { + return false // 이미 처리 중 + } + processingRequests.insert(url) + return true + } + + func stopProcessing(_ url: URL) { + processingRequests.remove(url) + } + + func isProcessing(_ url: URL) -> Bool { + return processingRequests.contains(url) + } + + func clearCache() async { + await imageCache.clear() + processingRequests.removeAll() + } + + func processingRequestsCount() -> Int { + return processingRequests.count + } +} + +/// 완전히 투명한 이미지 캐싱을 제공하는 URLProtocol +/// 기존 AsyncImage, URLSession 등 모든 이미지 요청이 자동으로 캐싱됨 +public final class TransparentImageCaching: URLProtocol { + private static let handledKey = "TransparentImageCaching_Handled" + + // Actor 인스턴스로 캐싱 로직 위임 + private static let cacheManager = CacheManager() + + // MARK: - URLProtocol Override Methods + + override public class func canInit(with request: URLRequest) -> Bool { + // 이미 처리된 요청은 제외 + guard URLProtocol.property(forKey: handledKey, in: request) == nil else { + return false + } + + // 이미지 요청인지 확인 + guard let url = request.url else { return false } + + return isImageRequest(url) + } + + override public class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override public func startLoading() { + guard let url = request.url else { + finishWithError(URLError(.badURL)) + return + } + + Task { + await handleImageRequest(url: url) + } + } + + override public func stopLoading() { + // 정리 작업 (필요시) + } + + // MARK: - Private Methods + + private func handleImageRequest(url: URL) async { + // 1. Actor를 통해 캐시에서 확인 + if let cachedImage = await Self.cacheManager.getCachedImage(for: url) { + await sendCachedImageResponse(image: cachedImage, url: url) + return + } + + // 2. 중복 요청 방지 체크 + let canStart = await Self.cacheManager.startProcessing(url) + guard canStart else { + // 이미 다른 곳에서 처리 중이면 대기 + await waitForProcessingCompletion(url: url) + return + } + + // 3. 네트워크에서 가져오기 + await fetchImageFromNetwork(url: url) + } + + private func waitForProcessingCompletion(url: URL) async { + // 프로필 이미지는 더 짧은 대기시간으로 최적화 + let waitTime: UInt64 = TransparentImageCaching.isProfileImage(url) ? 50 : 100 + try? await Task.sleep(for: .milliseconds(waitTime)) + + if let cachedImage = await Self.cacheManager.getCachedImage(for: url) { + await sendCachedImageResponse(image: cachedImage, url: url) + } else { + finishWithError(URLError(.resourceUnavailable)) + } + } + + private func fetchImageFromNetwork(url: URL) async { + do { + // 새로운 URLSession으로 실제 요청 (무한루프 방지) + let config = URLSessionConfiguration.default + config.protocolClasses = [] // URLProtocol 제외 + let session = URLSession(configuration: config) + + let (data, response) = try await session.data(from: url) + + // 이미지 데이터 검증 및 Actor를 통한 캐시 저장 + if let image = UIImage(data: data) { + await Self.cacheManager.storeImage(image, for: url) + } + + await MainActor.run { + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + } catch { + // 실패 시 처리 중 상태 해제 + await Self.cacheManager.stopProcessing(url) + + finishWithError(error) + } + } + + @MainActor + private func sendCachedImageResponse(image: UIImage, url: URL) { + guard let data = image.pngData() else { + finishWithError(URLError(.cannotDecodeContentData)) + return + } + + // 가짜 HTTP 응답 생성 + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "image/png", + "Content-Length": "\(data.count)", + "Cache-Control": "max-age=3600" + ] + )! + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + private func finishWithError(_ error: Error) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + client?.urlProtocol(self, didFailWithError: error) + } + } + + // MARK: - Helper Methods + + /// 이미지 요청인지 확인하는 public static 메소드 (Actor에서 호출 가능) + public static func isImageRequest(_ url: URL) -> Bool { + let imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"] + let pathExtension = url.pathExtension.lowercased() + + // 확장자로 확인 + if imageExtensions.contains(pathExtension) { + return true + } + + // URL 경로에 이미지 관련 키워드 포함 확인 + let urlString = url.absoluteString.lowercased() + let imageKeywords = ["image", "photo", "avatar", "profile", "thumbnail", "icon"] + + return imageKeywords.contains { keyword in + urlString.contains(keyword) + } + } + + /// 프로필 이미지인지 확인하여 최적화 적용 + private static func isProfileImage(_ url: URL) -> Bool { + let urlString = url.absoluteString.lowercased() + let profileKeywords = ["profile", "avatar", "user", "member"] + + return profileKeywords.contains { keyword in + urlString.contains(keyword) + } + } + +} + +// MARK: - Public Interface + +extension TransparentImageCaching { + /// 투명한 이미지 캐싱을 수동으로 활성화합니다. + /// 일반적으로는 ImageCacheService 사용 시 자동으로 활성화됩니다. + public static func activate() { + URLProtocol.registerClass(TransparentImageCaching.self) + print("🎭 TransparentImageCaching manually activated - All image requests will be automatically cached") + } + + /// 투명한 이미지 캐싱을 비활성화합니다. + public static func deactivate() { + URLProtocol.unregisterClass(TransparentImageCaching.self) + print("🎭 TransparentImageCaching deactivated") + } + + /// 캐시를 완전히 지웁니다. + public static func clearCache() async { + await cacheManager.clearCache() + print("🗑️ TransparentImageCaching cache cleared") + } + + /// 현재 처리 중인 요청 수를 반환합니다. (디버깅용) + public static func processingRequestCount() async -> Int { + await cacheManager.processingRequestsCount() + } +} diff --git a/Features/Profile/Sources/View/Components/EditProfileImage.swift b/Features/Profile/Sources/View/Components/EditProfileImage.swift index 806547e..9d25035 100644 --- a/Features/Profile/Sources/View/Components/EditProfileImage.swift +++ b/Features/Profile/Sources/View/Components/EditProfileImage.swift @@ -14,8 +14,6 @@ public struct EditProfileImage: View { private let imageURL: String? private let action: (() -> Void)? private let onLoadingStateChanged: ((Bool) -> Void)? - @State private var loadedImage: UIImage? - @State private var isLoading: Bool public init( size: CGFloat = 100, @@ -27,7 +25,6 @@ public struct EditProfileImage: View { self.imageURL = imageURL self.action = action self.onLoadingStateChanged = onLoadingStateChanged - _isLoading = State(initialValue: imageURL != nil) } @ViewBuilder @@ -60,20 +57,44 @@ public struct EditProfileImage: View { .frame(width: size, height: size) .clipShape(Circle()) .contentShape(Circle()) - .task(id: imageURL) { - await loadImage() - } } @ViewBuilder private func imageContent(iconSize: CGFloat) -> some View { - if let image = loadedImage { - Image(uiImage: image) - .resizable() - .scaledToFill() - } else if isLoading { - ProgressView() + if let imageURL, let url = URL(string: imageURL) { + // 🚀 최적화된 DesignSystemAsyncImage - ImageCacheService 직접 사용! + DesignSystemAsyncImage( + url: url, + transaction: Transaction(animation: .easeInOut(duration: 0.25)) + ) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + .onAppear { + onLoadingStateChanged?(false) + } + case .failure(_): + placeholder(iconSize: iconSize) + .onAppear { + onLoadingStateChanged?(false) + } + case .empty: + placeholder(iconSize: iconSize) + .opacity(0.3) + .overlay( + ProgressView() + .scaleEffect(0.8) + ) + .onAppear { + onLoadingStateChanged?(true) + } + @unknown default: + placeholder(iconSize: iconSize) + } + } } else { placeholder(iconSize: iconSize) } @@ -107,29 +128,6 @@ public struct EditProfileImage: View { .foregroundStyle(.primary500) } - private func loadImage() async { - guard let imageURL, let url = URL(string: imageURL) else { - await MainActor.run { - loadedImage = nil - isLoading = false - } - onLoadingStateChanged?(false) - return - } - - await MainActor.run { - isLoading = true - onLoadingStateChanged?(true) - } - - let image = await ImageCacheService.shared.image(for: url) - - await MainActor.run { - isLoading = false - loadedImage = image - onLoadingStateChanged?(false) - } - } } From dff6ac9c60167c24d82e61ecdaa2dc822e750fab Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 16 Dec 2025 21:22:55 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[feat]:=20=20cacheing=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Image/AsyncImage+Cache.swift | 5 ++-- .../Sources/Image/ImageCacheService.swift | 8 +++++-- .../Image/TransparentImageCaching.swift | 24 +++++++++++++++---- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/DesignSystem/Sources/Image/AsyncImage+Cache.swift b/DesignSystem/Sources/Image/AsyncImage+Cache.swift index 330ac31..397feb0 100644 --- a/DesignSystem/Sources/Image/AsyncImage+Cache.swift +++ b/DesignSystem/Sources/Image/AsyncImage+Cache.swift @@ -9,12 +9,11 @@ import SwiftUI // MARK: - Auto-Initialization -/// DesignSystem import 시 자동으로 ImageCacheService 초기화 +/// DesignSystem import 시 자동으로 ImageCacheService 초기화 및 URLProtocol 등록 private let _autoSetup: Void = { Task { - // ImageCacheService 초기화 _ = await ImageCacheService.shared - print("🚀 Direct image caching enabled via DesignSystem import") + TransparentImageCaching.activate() } }() diff --git a/DesignSystem/Sources/Image/ImageCacheService.swift b/DesignSystem/Sources/Image/ImageCacheService.swift index f61fde6..52ce965 100644 --- a/DesignSystem/Sources/Image/ImageCacheService.swift +++ b/DesignSystem/Sources/Image/ImageCacheService.swift @@ -13,12 +13,16 @@ public actor ImageCacheService { private let cache = NSCache() private var inFlightTasks: [URL: Task] = [:] + private let session: URLSession private init() { cache.totalCostLimit = 50 * 1024 * 1024 cache.countLimit = 200 // 더 많은 이미지를 메모리에 캐시 cache.evictsObjectsWithDiscardedContent = true // 메모리 압박 시 자동 제거 - print("🚀 ImageCacheService initialized with direct caching") + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [] // TransparentImageCaching와 중첩되지 않도록 분리 + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + session = URLSession(configuration: configuration) } public func image( @@ -79,7 +83,7 @@ public actor ImageCacheService { key: NSString ) async -> UIImage? { do { - let (data, response) = try await URLSession.shared.data(from: url) + let (data, response) = try await session.data(from: url) guard let httpResponse = response as? HTTPURLResponse, 200..<300 ~= httpResponse.statusCode diff --git a/DesignSystem/Sources/Image/TransparentImageCaching.swift b/DesignSystem/Sources/Image/TransparentImageCaching.swift index 8449bcb..98ae50e 100644 --- a/DesignSystem/Sources/Image/TransparentImageCaching.swift +++ b/DesignSystem/Sources/Image/TransparentImageCaching.swift @@ -58,6 +58,8 @@ private actor CacheManager { /// 기존 AsyncImage, URLSession 등 모든 이미지 요청이 자동으로 캐싱됨 public final class TransparentImageCaching: URLProtocol { private static let handledKey = "TransparentImageCaching_Handled" + private static let registrationLock = NSLock() + private static var isRegistered = false // Actor 인스턴스로 캐싱 로직 위임 private static let cacheManager = CacheManager() @@ -86,8 +88,9 @@ public final class TransparentImageCaching: URLProtocol { return } - Task { - await handleImageRequest(url: url) + Task { [weak self] in + guard let self else { return } + await self.handleImageRequest(url: url) } } @@ -226,20 +229,27 @@ extension TransparentImageCaching { /// 투명한 이미지 캐싱을 수동으로 활성화합니다. /// 일반적으로는 ImageCacheService 사용 시 자동으로 활성화됩니다. public static func activate() { + registrationLock.lock() + defer { registrationLock.unlock() } + + guard !isRegistered else { return } URLProtocol.registerClass(TransparentImageCaching.self) - print("🎭 TransparentImageCaching manually activated - All image requests will be automatically cached") + isRegistered = true } /// 투명한 이미지 캐싱을 비활성화합니다. public static func deactivate() { + registrationLock.lock() + defer { registrationLock.unlock() } + + guard isRegistered else { return } URLProtocol.unregisterClass(TransparentImageCaching.self) - print("🎭 TransparentImageCaching deactivated") + isRegistered = false } /// 캐시를 완전히 지웁니다. public static func clearCache() async { await cacheManager.clearCache() - print("🗑️ TransparentImageCaching cache cleared") } /// 현재 처리 중인 요청 수를 반환합니다. (디버깅용) @@ -247,3 +257,7 @@ extension TransparentImageCaching { await cacheManager.processingRequestsCount() } } + +// MARK: - Sendable Conformance + +extension TransparentImageCaching: @unchecked Sendable {} From f24d024cb3e086f295de833223ca7017c28fcb4f Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 16 Dec 2025 21:49:26 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[feat]:=20=20diskcache=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EB=8F=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Image/ImageCacheService.swift | 79 ++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/DesignSystem/Sources/Image/ImageCacheService.swift b/DesignSystem/Sources/Image/ImageCacheService.swift index 52ce965..3b57b8a 100644 --- a/DesignSystem/Sources/Image/ImageCacheService.swift +++ b/DesignSystem/Sources/Image/ImageCacheService.swift @@ -7,6 +7,7 @@ import Foundation import UIKit +import CryptoKit public actor ImageCacheService { public static let shared = ImageCacheService() @@ -14,6 +15,8 @@ public actor ImageCacheService { private let cache = NSCache() private var inFlightTasks: [URL: Task] = [:] private let session: URLSession + private let urlCache: URLCache + private let diskCacheURL: URL private init() { cache.totalCostLimit = 50 * 1024 * 1024 cache.countLimit = 200 // 더 많은 이미지를 메모리에 캐시 @@ -21,8 +24,26 @@ public actor ImageCacheService { let configuration = URLSessionConfiguration.default configuration.protocolClasses = [] // TransparentImageCaching와 중첩되지 않도록 분리 - configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.requestCachePolicy = .returnCacheDataElseLoad + configuration.timeoutIntervalForRequest = 10 + configuration.timeoutIntervalForResource = 20 + + let cachesDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory + let cacheDir = cachesDir.appendingPathComponent("ImageCacheService", isDirectory: true) + if !FileManager.default.fileExists(atPath: cacheDir.path) { + try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + } + diskCacheURL = cacheDir + + urlCache = URLCache( + memoryCapacity: 30 * 1024 * 1024, + diskCapacity: 120 * 1024 * 1024, + directory: cacheDir + ) + configuration.urlCache = urlCache + session = URLSession(configuration: configuration) + } public func image( @@ -34,6 +55,11 @@ public actor ImageCacheService { return cached } + if let diskImage = loadDiskImage(for: url) { + cache.setObject(diskImage, forKey: key) + return diskImage + } + if let task = inFlightTasks[url] { return await task.value } @@ -76,6 +102,9 @@ public actor ImageCacheService { public func clear() { cache.removeAllObjects() + try? FileManager.default.removeItem(at: diskCacheURL) + try? FileManager.default.createDirectory(at: diskCacheURL, withIntermediateDirectories: true) + urlCache.removeAllCachedResponses() } private func fetchAndCache( @@ -89,8 +118,9 @@ public actor ImageCacheService { 200..<300 ~= httpResponse.statusCode else { return nil } - guard let image = UIImage(data: data) else { return nil } + guard let image = decodedImage(from: data) else { return nil } cache.setObject(image, forKey: key, cost: data.count) + storeToDisk(data: data, for: url) return image } catch { return nil @@ -106,4 +136,49 @@ public actor ImageCacheService { urlString.contains(keyword) } } + + private func storeToDisk(data: Data, for url: URL) { + let fileURL = diskFileURL(for: url) + try? data.write(to: fileURL, options: .atomic) + } + + private func loadDiskImage(for url: URL) -> UIImage? { + let fileURL = diskFileURL(for: url) + guard FileManager.default.fileExists(atPath: fileURL.path), + let data = try? Data(contentsOf: fileURL), + let image = decodedImage(from: data) else { + return nil + } + return image + } + + private func diskFileURL(for url: URL) -> URL { + let hash = SHA256.hash(data: Data(url.absoluteString.utf8)) + .map { String(format: "%02x", $0) } + .joined() + return diskCacheURL.appendingPathComponent(hash).appendingPathExtension("cache") + } + + private func decodedImage(from data: Data) -> UIImage? { + guard let image = UIImage(data: data), let cgImage = image.cgImage else { return nil } + + let size = CGSize(width: cgImage.width, height: cgImage.height) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue + + guard let context = CGContext( + data: nil, + width: Int(size.width), + height: Int(size.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { return image } + + context.draw(cgImage, in: CGRect(origin: .zero, size: size)) + guard let decodedImage = context.makeImage() else { return image } + + return UIImage(cgImage: decodedImage, scale: image.scale, orientation: image.imageOrientation) + } } From 02849ddefd5053ffea1dc3cce2546615823d767d Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 17 Dec 2025 08:46:49 +0900 Subject: [PATCH 8/8] [refactor]: Thread safety improvements for image caching --- .../Sources/Image/AsyncImage+Cache.swift | 27 ++++++------- .../Image/TransparentImageCaching.swift | 39 +++++++++++-------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/DesignSystem/Sources/Image/AsyncImage+Cache.swift b/DesignSystem/Sources/Image/AsyncImage+Cache.swift index 397feb0..b88fc19 100644 --- a/DesignSystem/Sources/Image/AsyncImage+Cache.swift +++ b/DesignSystem/Sources/Image/AsyncImage+Cache.swift @@ -7,16 +7,6 @@ import SwiftUI -// MARK: - Auto-Initialization - -/// DesignSystem import 시 자동으로 ImageCacheService 초기화 및 URLProtocol 등록 -private let _autoSetup: Void = { - Task { - _ = await ImageCacheService.shared - TransparentImageCaching.activate() - } -}() - // MARK: - SwiftUI AsyncImage Replacement /// 기존 SwiftUI.AsyncImage를 완전히 대체하는 typealias @@ -43,7 +33,7 @@ public struct DesignSystemAsyncImage: View { @ViewBuilder content: @escaping (AsyncImagePhase) -> Content ) { // ImageCacheService 초기화 확인 - _ = _autoSetup + _ = autoSetup self.url = url self.scale = scale @@ -66,10 +56,10 @@ public struct DesignSystemAsyncImage: View { .task(id: url) { await loadImage() } - .transaction { t in - t.animation = transaction.animation - t.disablesAnimations = transaction.disablesAnimations - t.isContinuous = transaction.isContinuous + .transaction { transcation in + transcation.animation = transaction.animation + transcation.disablesAnimations = transaction.disablesAnimations + transcation.isContinuous = transaction.isContinuous } } @@ -100,6 +90,13 @@ public struct DesignSystemAsyncImage: View { } } } + + private let autoSetup: Void = { + Task { + _ = ImageCacheService.shared + await TransparentImageCaching.activate() + } + }() } // MARK: - Convenience Initializers diff --git a/DesignSystem/Sources/Image/TransparentImageCaching.swift b/DesignSystem/Sources/Image/TransparentImageCaching.swift index 98ae50e..cb250e9 100644 --- a/DesignSystem/Sources/Image/TransparentImageCaching.swift +++ b/DesignSystem/Sources/Image/TransparentImageCaching.swift @@ -58,8 +58,7 @@ private actor CacheManager { /// 기존 AsyncImage, URLSession 등 모든 이미지 요청이 자동으로 캐싱됨 public final class TransparentImageCaching: URLProtocol { private static let handledKey = "TransparentImageCaching_Handled" - private static let registrationLock = NSLock() - private static var isRegistered = false + private static let registrationManager = RegistrationManager() // Actor 인스턴스로 캐싱 로직 위임 private static let cacheManager = CacheManager() @@ -228,23 +227,13 @@ public final class TransparentImageCaching: URLProtocol { extension TransparentImageCaching { /// 투명한 이미지 캐싱을 수동으로 활성화합니다. /// 일반적으로는 ImageCacheService 사용 시 자동으로 활성화됩니다. - public static func activate() { - registrationLock.lock() - defer { registrationLock.unlock() } - - guard !isRegistered else { return } - URLProtocol.registerClass(TransparentImageCaching.self) - isRegistered = true + public static func activate() async { + await registrationManager.activate() } /// 투명한 이미지 캐싱을 비활성화합니다. - public static func deactivate() { - registrationLock.lock() - defer { registrationLock.unlock() } - - guard isRegistered else { return } - URLProtocol.unregisterClass(TransparentImageCaching.self) - isRegistered = false + public static func deactivate() async { + await registrationManager.deactivate() } /// 캐시를 완전히 지웁니다. @@ -261,3 +250,21 @@ extension TransparentImageCaching { // MARK: - Sendable Conformance extension TransparentImageCaching: @unchecked Sendable {} + +// MARK: - Registration Actor + +private actor RegistrationManager { + private var isRegistered = false + + func activate() { + guard !isRegistered else { return } + URLProtocol.registerClass(TransparentImageCaching.self) + isRegistered = true + } + + func deactivate() { + guard isRegistered else { return } + URLProtocol.unregisterClass(TransparentImageCaching.self) + isRegistered = false + } +}