From 3e5021321d75046fb69d9676ff7b160edb2ccdad Mon Sep 17 00:00:00 2001 From: JunYoung Date: Mon, 31 Mar 2025 14:33:51 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat/#92:=20URL=EC=9D=84=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Poppool/Poppool.xcodeproj/project.pbxproj | 12 +++++ .../ImageLoader/ImageLoader.swift | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift diff --git a/Poppool/Poppool.xcodeproj/project.pbxproj b/Poppool/Poppool.xcodeproj/project.pbxproj index a38282f7..10e9c12c 100644 --- a/Poppool/Poppool.xcodeproj/project.pbxproj +++ b/Poppool/Poppool.xcodeproj/project.pbxproj @@ -270,6 +270,7 @@ 0899526E2D0474340022AEF9 /* GetSearchPopUpListResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0899526D2D0474340022AEF9 /* GetSearchPopUpListResponse.swift */; }; 089952732D0475E90022AEF9 /* SearchResultCountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 089952722D0475E90022AEF9 /* SearchResultCountSection.swift */; }; 089952752D0475F20022AEF9 /* SearchResultCountSectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 089952742D0475F20022AEF9 /* SearchResultCountSectionCell.swift */; }; + 089B4FD82D9A57AE00FC0CC3 /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 089B4FD72D9A57AE00FC0CC3 /* ImageLoader.swift */; }; 08A2E46C2D15BC5000102313 /* CommentLikeRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A2E46B2D15BC5000102313 /* CommentLikeRequestDTO.swift */; }; 08A2E4792D1B06A300102313 /* ImageDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A2E4782D1B06A300102313 /* ImageDetailView.swift */; }; 08A2E47B2D1B06AA00102313 /* ImageDetailController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A2E47A2D1B06AA00102313 /* ImageDetailController.swift */; }; @@ -762,6 +763,7 @@ 0899526D2D0474340022AEF9 /* GetSearchPopUpListResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSearchPopUpListResponse.swift; sourceTree = ""; }; 089952722D0475E90022AEF9 /* SearchResultCountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultCountSection.swift; sourceTree = ""; }; 089952742D0475F20022AEF9 /* SearchResultCountSectionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultCountSectionCell.swift; sourceTree = ""; }; + 089B4FD72D9A57AE00FC0CC3 /* ImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; 08A2E46B2D15BC5000102313 /* CommentLikeRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentLikeRequestDTO.swift; sourceTree = ""; }; 08A2E4782D1B06A300102313 /* ImageDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDetailView.swift; sourceTree = ""; }; 08A2E47A2D1B06AA00102313 /* ImageDetailController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDetailController.swift; sourceTree = ""; }; @@ -1494,6 +1496,7 @@ 083A25A02CF3623C0099B58E /* Infrastructure */ = { isa = PBXGroup; children = ( + 089B4FD62D9A576F00FC0CC3 /* ImageLoader */, 0841BA832CF9F61500049E31 /* PreSignedService */, 083A25B12CF362670099B58E /* NetworkLayer */, 08DC61F42CF765B5002A2F44 /* UserDefaultService.swift */, @@ -2311,6 +2314,14 @@ path = View; sourceTree = ""; }; + 089B4FD62D9A576F00FC0CC3 /* ImageLoader */ = { + isa = PBXGroup; + children = ( + 089B4FD72D9A57AE00FC0CC3 /* ImageLoader.swift */, + ); + path = ImageLoader; + sourceTree = ""; + }; 08A2E4772D1B069300102313 /* ImageDetail */ = { isa = PBXGroup; children = ( @@ -3316,6 +3327,7 @@ 4E685EDB2D12CEB6001EF91C /* MapAPIEndpoint.swift in Sources */, 086F89CC2D1E42B000CA4FC9 /* CommentUserBlockController.swift in Sources */, 08DC62032CF8AC06002A2F44 /* HomeView.swift in Sources */, + 089B4FD82D9A57AE00FC0CC3 /* ImageLoader.swift in Sources */, BD9103622CF6149D00BBCCAE /* LoginResponseDTO.swift in Sources */, 083C86642D0EC4A5003F441C /* InstaCommentAddReactor.swift in Sources */, 08DE8A3F2D54DCC40049BCAC /* MyCommentedPopUpGridSection.swift in Sources */, diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift new file mode 100644 index 00000000..798074e6 --- /dev/null +++ b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift @@ -0,0 +1,50 @@ +import UIKit + +//URL을 사용한 이미지 로드 +//메모리 캐싱 +//디스크 캐싱 +//일정 시간 후 캐싱 데이터를 제거 +//이미지 리사이징 모듈 + +enum ImageLoaderError: Error { + case invalidURL + case networkError(description: String?) +} + +class ImageLoader { + + static let shared = ImageLoader() + + private init() {} + + func loadImage(with stringURL: String?, completion: @escaping (Result) -> Void) { + guard let stringURL = stringURL, + let url = URL(string: stringURL) else { + completion(.failure(ImageLoaderError.invalidURL)) + return + } + + fetchImageFrom(url: url) { result in + completion(result) + } + } +} + +private extension ImageLoader { + func fetchImageFrom(url: URL, completion: @escaping (Result) -> Void) { + let task = URLSession.shared.dataTask(with: url) { data, response, error in + if let error = error { + completion(.failure(ImageLoaderError.networkError(description: "Network Error: \(error.localizedDescription)"))) + return + } + + guard let data = data, let image = UIImage(data: data) else { + completion(.failure(ImageLoaderError.networkError(description: "Network Error: Invalid image data"))) + return + } + + completion(.success(image)) + } + task.resume() + } +} From c3ad17a0e1a1dd96ce0110b5f7477de92d163f59 Mon Sep 17 00:00:00 2001 From: JunYoung Date: Mon, 31 Mar 2025 17:47:30 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat/#92:=20NSCache=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ImageLoader/ImageLoader.swift | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift index 798074e6..bed25c13 100644 --- a/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift +++ b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift @@ -14,6 +14,7 @@ enum ImageLoaderError: Error { class ImageLoader { static let shared = ImageLoader() + private static let memoryCache = NSCache() private init() {} @@ -24,8 +25,24 @@ class ImageLoader { return } + let cacheKey = url.absoluteString as NSString + + if let cachedImage = fetchImageFromMemory(forKey: cacheKey) { + completion(.success(cachedImage)) + return + } + fetchImageFrom(url: url) { result in - completion(result) + switch result { + case .success(let image): + if let image = image { + self.storeInMemoryCache(image: image, forKey: cacheKey) + } + completion(.success(image)) + + case .failure(let error): + completion(.failure(error)) + } } } } @@ -47,4 +64,12 @@ private extension ImageLoader { } task.resume() } + + func storeInMemoryCache(image: UIImage, forKey key: NSString) { + ImageLoader.memoryCache.setObject(image, forKey: key) + } + + func fetchImageFromMemory(forKey key: NSString) -> UIImage? { + return ImageLoader.memoryCache.object(forKey: key) + } } From 42d4a9541feee8ed37a7dedbc3d414bca1f97952 Mon Sep 17 00:00:00 2001 From: JunYoung Date: Mon, 31 Mar 2025 19:43:18 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat/#92:=20=EB=A9=94=EB=AA=A8=EB=A6=AC?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=20=EC=8A=A4=ED=86=A0=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=9E=90=EB=8F=99=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Poppool/Poppool.xcodeproj/project.pbxproj | 4 + .../ImageLoader/ImageLoader.swift | 91 ++++++++++-------- .../ImageLoader/MemoryStorage.swift | 92 +++++++++++++++++++ .../Presentation/Extension/UIImageView+.swift | 19 ++-- 4 files changed, 161 insertions(+), 45 deletions(-) create mode 100644 Poppool/Poppool/Infrastructure/ImageLoader/MemoryStorage.swift diff --git a/Poppool/Poppool.xcodeproj/project.pbxproj b/Poppool/Poppool.xcodeproj/project.pbxproj index 87a218d9..c447b668 100644 --- a/Poppool/Poppool.xcodeproj/project.pbxproj +++ b/Poppool/Poppool.xcodeproj/project.pbxproj @@ -271,6 +271,7 @@ 089952732D0475E90022AEF9 /* SearchResultCountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 089952722D0475E90022AEF9 /* SearchResultCountSection.swift */; }; 089952752D0475F20022AEF9 /* SearchResultCountSectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 089952742D0475F20022AEF9 /* SearchResultCountSectionCell.swift */; }; 089B4FD82D9A57AE00FC0CC3 /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 089B4FD72D9A57AE00FC0CC3 /* ImageLoader.swift */; }; + 089B4FDF2D9A8F9A00FC0CC3 /* MemoryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 089B4FDE2D9A8F9A00FC0CC3 /* MemoryStorage.swift */; }; 08A2E46C2D15BC5000102313 /* CommentLikeRequestDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A2E46B2D15BC5000102313 /* CommentLikeRequestDTO.swift */; }; 08A2E4792D1B06A300102313 /* ImageDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A2E4782D1B06A300102313 /* ImageDetailView.swift */; }; 08A2E47B2D1B06AA00102313 /* ImageDetailController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A2E47A2D1B06AA00102313 /* ImageDetailController.swift */; }; @@ -762,6 +763,7 @@ 089952722D0475E90022AEF9 /* SearchResultCountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultCountSection.swift; sourceTree = ""; }; 089952742D0475F20022AEF9 /* SearchResultCountSectionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultCountSectionCell.swift; sourceTree = ""; }; 089B4FD72D9A57AE00FC0CC3 /* ImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; + 089B4FDE2D9A8F9A00FC0CC3 /* MemoryStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryStorage.swift; sourceTree = ""; }; 08A2E46B2D15BC5000102313 /* CommentLikeRequestDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentLikeRequestDTO.swift; sourceTree = ""; }; 08A2E4782D1B06A300102313 /* ImageDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDetailView.swift; sourceTree = ""; }; 08A2E47A2D1B06AA00102313 /* ImageDetailController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDetailController.swift; sourceTree = ""; }; @@ -2314,6 +2316,7 @@ isa = PBXGroup; children = ( 089B4FD72D9A57AE00FC0CC3 /* ImageLoader.swift */, + 089B4FDE2D9A8F9A00FC0CC3 /* MemoryStorage.swift */, ); path = ImageLoader; sourceTree = ""; @@ -3327,6 +3330,7 @@ 08DE8A3F2D54DCC40049BCAC /* MyCommentedPopUpGridSection.swift in Sources */, 081898C52D30AEF40067BF01 /* GetMyProfileResponse.swift in Sources */, BD9103922CF6166800BBCCAE /* SplashView.swift in Sources */, + 089B4FDF2D9A8F9A00FC0CC3 /* MemoryStorage.swift in Sources */, 0899526E2D0474340022AEF9 /* GetSearchPopUpListResponse.swift in Sources */, 08B191392CF366680057BC04 /* UITableViewCell+.swift in Sources */, 08A2E48F2D1BF6E500102313 /* CommentListView.swift in Sources */, diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift index bed25c13..512beafd 100644 --- a/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift +++ b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift @@ -1,75 +1,90 @@ import UIKit -//URL을 사용한 이미지 로드 -//메모리 캐싱 -//디스크 캐싱 -//일정 시간 후 캐싱 데이터를 제거 -//이미지 리사이징 모듈 - enum ImageLoaderError: Error { case invalidURL case networkError(description: String?) + case convertError(description: String?) +} + +/// 이미지 로더 설정 클래스 +/// - `memoryCacheExpiration`: 메모리 캐시 만료 시간 (기본값 300초) +class ImageLoaderConfigure { + var memoryCacheExpiration: TimeInterval = 300 } +/// URL을 통해 이미지를 비동기적으로 로드하는 클래스 class ImageLoader { static let shared = ImageLoader() - private static let memoryCache = NSCache() + + /// 이미지 로더 설정 객체 + let configure = ImageLoaderConfigure() private init() {} + /// URL을 통해 이미지를 로드하고, 실패 시 기본 이미지를 반환하는 메서드 + /// - Parameters: + /// - stringURL: 이미지 URL 문자열 + /// - defaultImage: 로드 실패 시 반환할 기본 이미지 + /// - completion: 로드 완료 후 호출되는 클로저 + func loadImage(with stringURL: String?, defaultImage: UIImage?, completion: @escaping (UIImage?) -> Void) { + loadImage(with: stringURL) { result in + switch result { + case .success(let image): + completion(image) + case .failure: + completion(defaultImage) + } + } + } +} + +private extension ImageLoader { + + /// URL을 통해 이미지를 로드하는 내부 메서드 + /// - Parameters: + /// - stringURL: 이미지 URL 문자열 + /// - completion: 로드 완료 후 호출되는 클로저 func loadImage(with stringURL: String?, completion: @escaping (Result) -> Void) { - guard let stringURL = stringURL, - let url = URL(string: stringURL) else { + guard let stringURL = stringURL, let url = URL(string: stringURL) else { completion(.failure(ImageLoaderError.invalidURL)) return } - - let cacheKey = url.absoluteString as NSString - - if let cachedImage = fetchImageFromMemory(forKey: cacheKey) { + + // 메모리 캐시에서 이미지 조회 + if let cachedImage = MemoryStorage.shared.fetchImage(url: stringURL) { completion(.success(cachedImage)) return } - fetchImageFrom(url: url) { result in + // 네트워크에서 데이터 요청 + fetchDataFrom(url: url) { result in switch result { - case .success(let image): - if let image = image { - self.storeInMemoryCache(image: image, forKey: cacheKey) + case .success(let data): + if let data = data, let image = UIImage(data: data) { + MemoryStorage.shared.store(image: image, url: stringURL) + completion(.success(image)) + } else { + completion(.failure(ImageLoaderError.convertError(description: "Failed to convert data to UIImage"))) } - completion(.success(image)) - case .failure(let error): completion(.failure(error)) } } } -} - -private extension ImageLoader { - func fetchImageFrom(url: URL, completion: @escaping (Result) -> Void) { + + /// URL을 통해 데이터를 요청하는 메서드 + /// - Parameters: + /// - url: 요청할 URL 객체 + /// - completion: 요청 완료 후 호출되는 클로저 + func fetchDataFrom(url: URL, completion: @escaping (Result) -> Void) { let task = URLSession.shared.dataTask(with: url) { data, response, error in if let error = error { completion(.failure(ImageLoaderError.networkError(description: "Network Error: \(error.localizedDescription)"))) return } - - guard let data = data, let image = UIImage(data: data) else { - completion(.failure(ImageLoaderError.networkError(description: "Network Error: Invalid image data"))) - return - } - - completion(.success(image)) + completion(.success(data)) } task.resume() } - - func storeInMemoryCache(image: UIImage, forKey key: NSString) { - ImageLoader.memoryCache.setObject(image, forKey: key) - } - - func fetchImageFromMemory(forKey key: NSString) -> UIImage? { - return ImageLoader.memoryCache.object(forKey: key) - } } diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/MemoryStorage.swift b/Poppool/Poppool/Infrastructure/ImageLoader/MemoryStorage.swift new file mode 100644 index 00000000..b2203678 --- /dev/null +++ b/Poppool/Poppool/Infrastructure/ImageLoader/MemoryStorage.swift @@ -0,0 +1,92 @@ +import UIKit + +/// 캐시할 이미지와 만료 시간을 저장하는 클래스 +class StorageData: NSObject { + let image: UIImage? /// 캐시된 이미지 + let expirationDate: Date /// 캐시 만료 시간 + + /// 초기화 메서드 + /// - Parameters: + /// - image: 저장할 이미지 + /// - expiration: 만료 시간 (초 단위) + init(image: UIImage?, expiration: TimeInterval) { + self.image = image + self.expirationDate = Date().addingTimeInterval(expiration) + } + + /// 캐시가 만료되었는지 확인하는 메서드 + /// - Returns: 만료 여부 (true: 만료됨, false: 유효함) + func isExpired() -> Bool { + return Date() > expirationDate + } +} + +/// 메모리 캐시를 관리하는 클래스 +class MemoryStorage { + + /// 싱글톤 인스턴스 + static let shared = MemoryStorage() + + /// 이미지 캐시 저장소 + private let cache = NSCache() + + /// 현재 캐시에 저장된 키 목록 + private var cachedKeys: Set = [] + + /// 초기화 (자동 캐시 정리 시작) + private init() { + startCacheCleanup() + } + + /// 이미지를 캐시에 저장하는 메서드 + /// - Parameters: + /// - image: 저장할 이미지 + /// - url: 이미지 URL 문자열 + func store(image: UIImage?, url: String) { + let cachedData = StorageData(image: image, expiration: ImageLoader.shared.configure.memoryCacheExpiration) + cache.setObject(cachedData, forKey: url as NSString) + cachedKeys.insert(url) + } + + /// 캐시에서 이미지를 가져오는 메서드 + /// - Parameter url: 이미지 URL 문자열 + /// - Returns: 캐시된 UIImage (없으면 nil) + func fetchImage(url: String) -> UIImage? { + if let cachedData = cache.object(forKey: url as NSString), !cachedData.isExpired() { + return cachedData.image + } else { + removeData(url: url) + return nil + } + } + + /// 특정 URL의 캐시 데이터를 제거하는 메서드 + /// - Parameter url: 제거할 이미지의 URL 문자열 + func removeData(url: String) { + cache.removeObject(forKey: url as NSString) + cachedKeys.remove(url) + } + + /// 모든 캐시 데이터를 삭제하는 메서드 + func clearCache() { + cache.removeAllObjects() + cachedKeys.removeAll() + } + + /// 주기적으로 만료된 캐시를 정리하는 메서드 + private func startCacheCleanup() { + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self else { return } + + Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { _ in + for key in self.cachedKeys { + let nsKey = key as NSString + if let cachedData = self.cache.object(forKey: nsKey), cachedData.isExpired() { + self.cache.removeObject(forKey: nsKey) + self.cachedKeys.remove(key) + } + } + } + } + } +} diff --git a/Poppool/Poppool/Presentation/Extension/UIImageView+.swift b/Poppool/Poppool/Presentation/Extension/UIImageView+.swift index 8040bddb..8b7b398c 100644 --- a/Poppool/Poppool/Presentation/Extension/UIImageView+.swift +++ b/Poppool/Poppool/Presentation/Extension/UIImageView+.swift @@ -17,15 +17,20 @@ extension UIImageView { } let imageURLString = Secrets.popPoolS3BaseURL.rawValue + path if let cenvertimageURL = imageURLString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { - let imageURL = URL(string: cenvertimageURL) - self.kf.setImage(with: imageURL) { result in - switch result { - case .failure(let error): - Logger.log(message: "\(path) image Load Fail: \(error.localizedDescription)", category: .error) - default: - break + ImageLoader.shared.loadImage(with: cenvertimageURL, defaultImage: UIImage(named: "image_default")) { [weak self] image in + DispatchQueue.main.async { + self?.image = image } } +// let imageURL = URL(string: cenvertimageURL) +// self.kf.setImage(with: imageURL) { result in +// switch result { +// case .failure(let error): +// Logger.log(message: "\(path) image Load Fail: \(error.localizedDescription)", category: .error) +// default: +// break +// } +// } } } From 395729f400b6f60af1083a09ebd9fbf8a10e702a Mon Sep 17 00:00:00 2001 From: JunYoung Date: Tue, 1 Apr 2025 19:27:24 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat/#92:=20DiskStorage=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Poppool/Poppool.xcodeproj/project.pbxproj | 4 + .../ImageLoader/DiskStorage.swift | 155 ++++++++++++++++++ .../ImageLoader/ImageLoader.swift | 14 +- .../ImageLoader/MemoryStorage.swift | 7 +- 4 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift diff --git a/Poppool/Poppool.xcodeproj/project.pbxproj b/Poppool/Poppool.xcodeproj/project.pbxproj index c447b668..d4a99af2 100644 --- a/Poppool/Poppool.xcodeproj/project.pbxproj +++ b/Poppool/Poppool.xcodeproj/project.pbxproj @@ -353,6 +353,7 @@ 08CBEA3A2D3FABE100248007 /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CBEA392D3FABE100248007 /* ToastView.swift */; }; 08CBEA3C2D3FABED00248007 /* BookMarkToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CBEA3B2D3FABED00248007 /* BookMarkToastView.swift */; }; 08CBEA3E2D3FF6A100248007 /* PopUpCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CBEA3D2D3FF6A100248007 /* PopUpCardView.swift */; }; + 08CFD3922D9BDE99004CDD50 /* DiskStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CFD3912D9BDE99004CDD50 /* DiskStorage.swift */; }; 08DC61F32CF75037002A2F44 /* KeyChainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DC61F22CF75037002A2F44 /* KeyChainService.swift */; }; 08DC61F52CF765B5002A2F44 /* UserDefaultService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DC61F42CF765B5002A2F44 /* UserDefaultService.swift */; }; 08DC61F82CF76843002A2F44 /* SignUpCompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DC61F72CF76843002A2F44 /* SignUpCompleteView.swift */; }; @@ -845,6 +846,7 @@ 08CBEA392D3FABE100248007 /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; 08CBEA3B2D3FABED00248007 /* BookMarkToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookMarkToastView.swift; sourceTree = ""; }; 08CBEA3D2D3FF6A100248007 /* PopUpCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopUpCardView.swift; sourceTree = ""; }; + 08CFD3912D9BDE99004CDD50 /* DiskStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskStorage.swift; sourceTree = ""; }; 08DC61F22CF75037002A2F44 /* KeyChainService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyChainService.swift; sourceTree = ""; }; 08DC61F42CF765B5002A2F44 /* UserDefaultService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserDefaultService.swift; sourceTree = ""; }; 08DC61F72CF76843002A2F44 /* SignUpCompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpCompleteView.swift; sourceTree = ""; }; @@ -2317,6 +2319,7 @@ children = ( 089B4FD72D9A57AE00FC0CC3 /* ImageLoader.swift */, 089B4FDE2D9A8F9A00FC0CC3 /* MemoryStorage.swift */, + 08CFD3912D9BDE99004CDD50 /* DiskStorage.swift */, ); path = ImageLoader; sourceTree = ""; @@ -3485,6 +3488,7 @@ 086DD8E32CFF356300B97D3B /* HomeCardGridSection.swift in Sources */, 0841BABE2CFB5AA600049E31 /* Date?+.swift in Sources */, 083A258D2CF361F90099B58E /* ConventionCollectionViewCell.swift in Sources */, + 08CFD3922D9BDE99004CDD50 /* DiskStorage.swift in Sources */, 4E685EE12D12CEB6001EF91C /* StoreListReactor.swift in Sources */, 4E685EE52D12CEB6001EF91C /* MapMarker.swift in Sources */, 081898FB2D33D9320067BF01 /* GetBlockUserListResponseDTO.swift in Sources */, diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift b/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift new file mode 100644 index 00000000..92c0f05c --- /dev/null +++ b/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift @@ -0,0 +1,155 @@ +import UIKit +import CryptoKit + +/// 디스크에 이미지를 캐싱하는 클래스 +final class DiskStorage { + + /// 싱글톤 인스턴스 + static let shared = DiskStorage() + + /// 파일 관리 객체 + private let fileManager = FileManager.default + + /// 이미지 캐시 디렉터리 경로 + private let cacheDirectory: URL + + /// 초기화 메서드 (캐시 디렉터리 생성 및 자동 삭제 스케줄 시작) + private init() { + let urls = fileManager.urls(for: .cachesDirectory, in: .userDomainMask) + cacheDirectory = urls[0].appendingPathComponent("ImageCache") + + // 디렉터리가 존재하지 않으면 생성 + if !fileManager.fileExists(atPath: cacheDirectory.path) { + try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true, attributes: nil) + } + startCacheCleanup() + } + + /// URL을 안전한 파일명으로 변환하는 메서드 + /// - Parameter url: 원본 URL 문자열 + /// - Returns: 파일명으로 변환된 문자열 + private func cacheFileName(for url: String) -> String { + let data = Data(url.utf8) + let hashed = SHA256.hash(data: data) + return hashed.compactMap { String(format: "%02x", $0) }.joined() + } + + /// 이미지를 디스크에 저장하는 메서드 + /// - Parameters: + /// - image: 저장할 UIImage 객체 + /// - url: 해당 이미지의 원본 URL 문자열 + func store(image: UIImage, url: String) { + let fileName = cacheFileName(for: url) + let fileURL = cacheDirectory.appendingPathComponent(fileName) + let metadataURL = cacheDirectory.appendingPathComponent("\(fileName).metadata") + + // 이미지 데이터를 JPEG 형식으로 변환하여 저장 + if let data = image.jpegData(compressionQuality: 0.8) { + do { + try data.write(to: fileURL) + } catch { + print("Error writing image data to disk: \(error)") + } + } + + // 만료 시간 기록 + let expirationDate = Date().addingTimeInterval(ImageLoader.shared.configure.diskCacheExpiration) + let metadata = ["expiration": expirationDate.timeIntervalSince1970] + + // 만료 정보를 JSON 형태로 저장 + if let metadataData = try? JSONSerialization.data(withJSONObject: metadata) { + do { + try metadataData.write(to: metadataURL) + } catch { + print("Error writing metadata: \(error)") + } + } + } + + /// 디스크에서 이미지를 불러오는 메서드 (만료된 경우 자동 삭제) + /// - Parameter url: 이미지의 원본 URL 문자열 + /// - Returns: UIImage 객체 (없거나 만료된 경우 nil) + func fetchImage(url: String) -> UIImage? { + let fileName = cacheFileName(for: url) + let fileURL = cacheDirectory.appendingPathComponent(fileName) + let metadataURL = cacheDirectory.appendingPathComponent("\(fileName).metadata") + + // 만료 시간 확인 + if let metadataData = try? Data(contentsOf: metadataURL), + let metadata = try? JSONSerialization.jsonObject(with: metadataData) as? [String: TimeInterval], + let expirationTime = metadata["expiration"] { + + // 만료 시간이 현재 시각을 초과하면 삭제 후 nil 반환 + if Date().timeIntervalSince1970 > expirationTime { + removeImage(url: url) + return nil + } + } + + // 이미지 파일이 존재하면 로드하여 반환 + if let data = try? Data(contentsOf: fileURL) { + return UIImage(data: data) + } + + return nil + } + + /// 특정 URL에 해당하는 이미지를 디스크에서 삭제하는 메서드 + /// - Parameter url: 삭제할 이미지의 원본 URL 문자열 + func removeImage(url: String) { + let fileName = cacheFileName(for: url) + let fileURL = cacheDirectory.appendingPathComponent(fileName) + let metadataURL = cacheDirectory.appendingPathComponent("\(fileName).metadata") + + do { + try fileManager.removeItem(at: fileURL) // 이미지 파일 삭제 + try fileManager.removeItem(at: metadataURL) // 메타데이터 파일 삭제 + } catch { + print("Failed to remove image: \(error)") + } + } + + /// 모든 캐시 데이터를 삭제하는 메서드 + func clearCache() { + do { + try fileManager.removeItem(at: cacheDirectory) + try fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true, attributes: nil) + } catch { + print("Failed to clear cache: \(error)") + } + } + + /// 주기적으로 만료된 캐시를 삭제하는 메서드 + /// - 5분(300초)마다 실행되며, 만료된 이미지와 메타데이터를 정리함. + private func startCacheCleanup() { + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self else { return } + + let cleanTimer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { _ in + let files = (try? self.fileManager.contentsOfDirectory(at: self.cacheDirectory, includingPropertiesForKeys: nil)) ?? [] + + for file in files { + if file.pathExtension == "metadata", + let metadataData = try? Data(contentsOf: file), + let metadata = try? JSONSerialization.jsonObject(with: metadataData) as? [String: TimeInterval], + let expirationTime = metadata["expiration"] { + + // 만료 시간이 지나면 이미지와 메타데이터 삭제 + if Date().timeIntervalSince1970 > expirationTime { + let imageFileURL = file.deletingPathExtension() // 메타데이터와 동일한 이름의 이미지 파일 + do { + try self.fileManager.removeItem(at: imageFileURL) + try self.fileManager.removeItem(at: file) // 메타데이터 삭제 + } catch { + print("Failed to delete expired cache: \(error)") + } + } + } + } + } + // 백그라운드에서 실행되는 타이머를 메인 루프에 추가 + RunLoop.current.add(cleanTimer, forMode: .common) + RunLoop.current.run() // 백그라운드 스레드에서 타이머를 계속 실행하기 위해 RunLoop를 유지 + } + } +} diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift index 512beafd..90df18cc 100644 --- a/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift +++ b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift @@ -10,10 +10,11 @@ enum ImageLoaderError: Error { /// - `memoryCacheExpiration`: 메모리 캐시 만료 시간 (기본값 300초) class ImageLoaderConfigure { var memoryCacheExpiration: TimeInterval = 300 + var diskCacheExpiration: TimeInterval = 86_400 } /// URL을 통해 이미지를 비동기적으로 로드하는 클래스 -class ImageLoader { +final class ImageLoader { static let shared = ImageLoader() @@ -50,19 +51,28 @@ private extension ImageLoader { completion(.failure(ImageLoaderError.invalidURL)) return } - + // 메모리 캐시에서 이미지 조회 if let cachedImage = MemoryStorage.shared.fetchImage(url: stringURL) { completion(.success(cachedImage)) return } + // 디스크 캐시 확인 + if let diskImage = DiskStorage.shared.fetchImage(url: stringURL) { + // 메모리 캐시에 저장 후 반환 + MemoryStorage.shared.store(image: diskImage, url: stringURL) + completion(.success(diskImage)) + return + } + // 네트워크에서 데이터 요청 fetchDataFrom(url: url) { result in switch result { case .success(let data): if let data = data, let image = UIImage(data: data) { MemoryStorage.shared.store(image: image, url: stringURL) + DiskStorage.shared.store(image: image, url: stringURL) completion(.success(image)) } else { completion(.failure(ImageLoaderError.convertError(description: "Failed to convert data to UIImage"))) diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/MemoryStorage.swift b/Poppool/Poppool/Infrastructure/ImageLoader/MemoryStorage.swift index b2203678..517bc9a7 100644 --- a/Poppool/Poppool/Infrastructure/ImageLoader/MemoryStorage.swift +++ b/Poppool/Poppool/Infrastructure/ImageLoader/MemoryStorage.swift @@ -22,7 +22,7 @@ class StorageData: NSObject { } /// 메모리 캐시를 관리하는 클래스 -class MemoryStorage { +final class MemoryStorage { /// 싱글톤 인스턴스 static let shared = MemoryStorage() @@ -78,7 +78,7 @@ class MemoryStorage { DispatchQueue.global(qos: .background).async { [weak self] in guard let self = self else { return } - Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { _ in + let cleanTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { _ in for key in self.cachedKeys { let nsKey = key as NSString if let cachedData = self.cache.object(forKey: nsKey), cachedData.isExpired() { @@ -87,6 +87,9 @@ class MemoryStorage { } } } + // 백그라운드에서 실행되는 타이머를 메인 루프에 추가 + RunLoop.current.add(cleanTimer, forMode: .common) + RunLoop.current.run() // 백그라운드 스레드에서 타이머를 계속 실행하기 위해 RunLoop를 유지 } } } From b529e2424633912701c8311d809729cee3cedd28 Mon Sep 17 00:00:00 2001 From: JunYoung Date: Tue, 1 Apr 2025 20:16:48 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat/#92:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A7=95=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ImageLoader/ImageLoader.swift | 55 ++++++++++++++++++- .../Presentation/Extension/UIImageView+.swift | 2 +- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift index 90df18cc..9e254f47 100644 --- a/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift +++ b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift @@ -6,6 +6,26 @@ enum ImageLoaderError: Error { case convertError(description: String?) } +enum ImageSizeOption { + case low + case middle + case high + case origin + + var size: CGSize { + switch self { + case .low: + return CGSize(width: 100, height: 100) + case .middle: + return CGSize(width: 200, height: 200) + case .high: + return CGSize(width: 400, height: 400) + case .origin: + return CGSize(width: 1000, height: 1000) + } + } +} + /// 이미지 로더 설정 클래스 /// - `memoryCacheExpiration`: 메모리 캐시 만료 시간 (기본값 300초) class ImageLoaderConfigure { @@ -28,11 +48,16 @@ final class ImageLoader { /// - stringURL: 이미지 URL 문자열 /// - defaultImage: 로드 실패 시 반환할 기본 이미지 /// - completion: 로드 완료 후 호출되는 클로저 - func loadImage(with stringURL: String?, defaultImage: UIImage?, completion: @escaping (UIImage?) -> Void) { - loadImage(with: stringURL) { result in + func loadImage( + with stringURL: String?, + defaultImage: UIImage?, + imageQuality: ImageSizeOption = .origin, + completion: @escaping (UIImage?) -> Void + ) { + loadImage(with: stringURL) { [weak self] result in switch result { case .success(let image): - completion(image) + completion(self?.resizeImage(image, defaultImage: defaultImage, with: imageQuality)) case .failure: completion(defaultImage) } @@ -97,4 +122,28 @@ private extension ImageLoader { } task.resume() } + + func resizeImage(_ image: UIImage?, defaultImage: UIImage?, with sizeOption: ImageSizeOption) -> UIImage? { + guard let image else { return defaultImage } + + if sizeOption == .origin { return image } + + let targetSize = sizeOption.size + + // 비율 유지 리사이징 + let aspectRatio = image.size.width / image.size.height + var newSize = targetSize + + if aspectRatio > 1 { // 가로 이미지 + newSize.height = targetSize.width / aspectRatio + } else { // 세로 이미지 + newSize.width = targetSize.height * aspectRatio + } + + let renderer = UIGraphicsImageRenderer(size: newSize) + + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + } } diff --git a/Poppool/Poppool/Presentation/Extension/UIImageView+.swift b/Poppool/Poppool/Presentation/Extension/UIImageView+.swift index 8b7b398c..f2670759 100644 --- a/Poppool/Poppool/Presentation/Extension/UIImageView+.swift +++ b/Poppool/Poppool/Presentation/Extension/UIImageView+.swift @@ -17,7 +17,7 @@ extension UIImageView { } let imageURLString = Secrets.popPoolS3BaseURL.rawValue + path if let cenvertimageURL = imageURLString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { - ImageLoader.shared.loadImage(with: cenvertimageURL, defaultImage: UIImage(named: "image_default")) { [weak self] image in + ImageLoader.shared.loadImage(with: cenvertimageURL, defaultImage: UIImage(named: "image_default"), imageQuality: .origin) { [weak self] image in DispatchQueue.main.async { self?.image = image } From 6db82d35484d6fbec64384195f4adf31f63fa45d Mon Sep 17 00:00:00 2001 From: JunYoung Date: Sun, 6 Apr 2025 18:03:19 +0900 Subject: [PATCH 06/10] =?UTF-8?q?refactor/#92:=20Kingfisher=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0,=20=EB=8C=80=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Poppool/Poppool.xcodeproj/project.pbxproj | 17 ----------- .../xcshareddata/swiftpm/Package.resolved | 11 +------ .../Presentation/Extension/String?+.swift | 9 ------ .../Presentation/Extension/UIImageView+.swift | 29 +++---------------- .../MapPopupCardView/PopupCardCell.swift | 1 - .../HomeCardSection/HomeCardSectionCell.swift | 8 ----- .../HomePopularCardSectionCell.swift | 8 ----- 7 files changed, 5 insertions(+), 78 deletions(-) diff --git a/Poppool/Poppool.xcodeproj/project.pbxproj b/Poppool/Poppool.xcodeproj/project.pbxproj index d4a99af2..70e891ae 100644 --- a/Poppool/Poppool.xcodeproj/project.pbxproj +++ b/Poppool/Poppool.xcodeproj/project.pbxproj @@ -474,7 +474,6 @@ BDCA41E22CF35AC1005EECF6 /* PoppoolUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCA41E12CF35AC1005EECF6 /* PoppoolUITests.swift */; }; BDCA41E42CF35AC1005EECF6 /* PoppoolUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDCA41E32CF35AC1005EECF6 /* PoppoolUITestsLaunchTests.swift */; }; BDCA41F22CF35D0D005EECF6 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = BDCA41F12CF35D0D005EECF6 /* SnapKit */; }; - BDCA41F52CF35D33005EECF6 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = BDCA41F42CF35D33005EECF6 /* Kingfisher */; }; BDCA41F82CF35D9A005EECF6 /* RxSwift in Frameworks */ = {isa = PBXBuildFile; productRef = BDCA41F72CF35D9A005EECF6 /* RxSwift */; }; BDCA41FE2CF35EE7005EECF6 /* ReactorKit in Frameworks */ = {isa = PBXBuildFile; productRef = BDCA41FD2CF35EE7005EECF6 /* ReactorKit */; }; BDCA42012CF35EFE005EECF6 /* RxKeyboard in Frameworks */ = {isa = PBXBuildFile; productRef = BDCA42002CF35EFE005EECF6 /* RxKeyboard */; }; @@ -975,7 +974,6 @@ files = ( BDCA41F82CF35D9A005EECF6 /* RxSwift in Frameworks */, BDCA420D2CF35FD2005EECF6 /* RxGesture in Frameworks */, - BDCA41F52CF35D33005EECF6 /* Kingfisher in Frameworks */, BDCA42072CF35FA6005EECF6 /* Tabman in Frameworks */, BDCA42042CF35F76005EECF6 /* PanModal in Frameworks */, 082197A12D426DCB0054094A /* Then in Frameworks */, @@ -3088,7 +3086,6 @@ name = Poppool; packageProductDependencies = ( BDCA41F12CF35D0D005EECF6 /* SnapKit */, - BDCA41F42CF35D33005EECF6 /* Kingfisher */, BDCA41F72CF35D9A005EECF6 /* RxSwift */, BDCA41FD2CF35EE7005EECF6 /* ReactorKit */, BDCA42002CF35EFE005EECF6 /* RxKeyboard */, @@ -3179,7 +3176,6 @@ mainGroup = BDCA41B42CF35AC0005EECF6; packageReferences = ( BDCA41F02CF35D0D005EECF6 /* XCRemoteSwiftPackageReference "SnapKit" */, - BDCA41F32CF35D33005EECF6 /* XCRemoteSwiftPackageReference "Kingfisher" */, BDCA41F62CF35D9A005EECF6 /* XCRemoteSwiftPackageReference "RxSwift" */, BDCA41FC2CF35EE7005EECF6 /* XCRemoteSwiftPackageReference "ReactorKit" */, BDCA41FF2CF35EFE005EECF6 /* XCRemoteSwiftPackageReference "RxKeyboard" */, @@ -4123,14 +4119,6 @@ minimumVersion = 5.7.1; }; }; - BDCA41F32CF35D33005EECF6 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/onevcat/Kingfisher.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 8.1.1; - }; - }; BDCA41F62CF35D9A005EECF6 /* XCRemoteSwiftPackageReference "RxSwift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ReactiveX/RxSwift.git"; @@ -4238,11 +4226,6 @@ package = BDCA41F02CF35D0D005EECF6 /* XCRemoteSwiftPackageReference "SnapKit" */; productName = SnapKit; }; - BDCA41F42CF35D33005EECF6 /* Kingfisher */ = { - isa = XCSwiftPackageProductDependency; - package = BDCA41F32CF35D33005EECF6 /* XCRemoteSwiftPackageReference "Kingfisher" */; - productName = Kingfisher; - }; BDCA41F72CF35D9A005EECF6 /* RxSwift */ = { isa = XCSwiftPackageProductDependency; package = BDCA41F62CF35D9A005EECF6 /* XCRemoteSwiftPackageReference "RxSwift" */; diff --git a/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5c6fc0d1..78d0f8f5 100644 --- a/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "408890f5035d25ec9b1705255e27f587050890efcc8e3a540c8d9edbae7aeece", + "originHash" : "000b8c18b59df01ac73af8ca8c37167230915d09ad120950c2d361fbb92fe695", "pins" : [ { "identity" : "alamofire", @@ -37,15 +37,6 @@ "version" : "2.24.0" } }, - { - "identity" : "kingfisher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/onevcat/Kingfisher.git", - "state" : { - "revision" : "3db26ab625d194c38e68c1a40e43d1bc12743fe0", - "version" : "8.2.0" - } - }, { "identity" : "lottie-spm", "kind" : "remoteSourceControl", diff --git a/Poppool/Poppool/Presentation/Extension/String?+.swift b/Poppool/Poppool/Presentation/Extension/String?+.swift index c1c5a916..13785af3 100644 --- a/Poppool/Poppool/Presentation/Extension/String?+.swift +++ b/Poppool/Poppool/Presentation/Extension/String?+.swift @@ -1,14 +1,5 @@ -// -// String?+.swift -// Poppool -// -// Created by SeoJunYoung on 11/30/24. -// - import UIKit -import Kingfisher - extension Optional where Wrapped == String { /// ISO 8601 형식의 문자열을 `Date`로 변환하는 메서드 func toDate() -> Date? { diff --git a/Poppool/Poppool/Presentation/Extension/UIImageView+.swift b/Poppool/Poppool/Presentation/Extension/UIImageView+.swift index f2670759..284957d6 100644 --- a/Poppool/Poppool/Presentation/Extension/UIImageView+.swift +++ b/Poppool/Poppool/Presentation/Extension/UIImageView+.swift @@ -1,14 +1,5 @@ -// -// UIImageView+.swift -// Poppool -// -// Created by SeoJunYoung on 12/3/24. -// - import UIKit -import Kingfisher - extension UIImageView { func setPPImage(path: String?) { guard let path = path else { @@ -22,15 +13,6 @@ extension UIImageView { self?.image = image } } -// let imageURL = URL(string: cenvertimageURL) -// self.kf.setImage(with: imageURL) { result in -// switch result { -// case .failure(let error): -// Logger.log(message: "\(path) image Load Fail: \(error.localizedDescription)", category: .error) -// default: -// break -// } -// } } } @@ -43,13 +25,10 @@ extension UIImageView { let imageURLString = Secrets.popPoolS3BaseURL.rawValue + path if let cenvertimageURL = imageURLString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { let imageURL = URL(string: cenvertimageURL) - self.kf.setImage(with: imageURL) { result in - completion() - switch result { - case .failure(let error): - Logger.log(message: "\(path) image Load Fail: \(error.localizedDescription)", category: .error) - default: - break + ImageLoader.shared.loadImage(with: cenvertimageURL, defaultImage: UIImage(named: "image_default"), imageQuality: .origin) { [weak self] image in + DispatchQueue.main.async { + completion() + self?.image = image } } } diff --git a/Poppool/Poppool/Presentation/Map/Common/MapPopupCardView/PopupCardCell.swift b/Poppool/Poppool/Presentation/Map/Common/MapPopupCardView/PopupCardCell.swift index 4d1ee89d..87ad36d5 100644 --- a/Poppool/Poppool/Presentation/Map/Common/MapPopupCardView/PopupCardCell.swift +++ b/Poppool/Poppool/Presentation/Map/Common/MapPopupCardView/PopupCardCell.swift @@ -1,6 +1,5 @@ import UIKit import SnapKit -import Kingfisher final class PopupCardCell: UICollectionViewCell { static let identifier = "PopupCardCell" diff --git a/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomeCardSection/HomeCardSectionCell.swift b/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomeCardSection/HomeCardSectionCell.swift index 5bc534e3..d81a8abd 100644 --- a/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomeCardSection/HomeCardSectionCell.swift +++ b/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomeCardSection/HomeCardSectionCell.swift @@ -1,15 +1,7 @@ -// -// HomeCardSectionCell.swift -// Poppool -// -// Created by SeoJunYoung on 11/30/24. -// - import UIKit import SnapKit import RxSwift -import Kingfisher final class HomeCardSectionCell: UICollectionViewCell { diff --git a/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomePopularCardSection/HomePopularCardSectionCell.swift b/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomePopularCardSection/HomePopularCardSectionCell.swift index aa395113..05bd9715 100644 --- a/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomePopularCardSection/HomePopularCardSectionCell.swift +++ b/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomePopularCardSection/HomePopularCardSectionCell.swift @@ -1,15 +1,7 @@ -// -// HomePopularCardSectionCell.swift -// Poppool -// -// Created by SeoJunYoung on 11/30/24. -// - import UIKit import SnapKit import RxSwift -import Kingfisher final class HomePopularCardSectionCell: UICollectionViewCell { From d80a512d7f4bebe3eca14ed073d12bd82f2aa41d Mon Sep 17 00:00:00 2001 From: JunYoung Date: Sun, 6 Apr 2025 18:08:33 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix/#92:=20DiskStorage=20TImer=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ImageLoader/DiskStorage.swift | 38 +++++++------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift b/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift index 92c0f05c..95d4b70d 100644 --- a/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift +++ b/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift @@ -120,36 +120,26 @@ final class DiskStorage { } /// 주기적으로 만료된 캐시를 삭제하는 메서드 - /// - 5분(300초)마다 실행되며, 만료된 이미지와 메타데이터를 정리함. private func startCacheCleanup() { - DispatchQueue.global(qos: .background).async { [weak self] in - guard let self = self else { return } + let files = (try? self.fileManager.contentsOfDirectory(at: self.cacheDirectory, includingPropertiesForKeys: nil)) ?? [] - let cleanTimer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { _ in - let files = (try? self.fileManager.contentsOfDirectory(at: self.cacheDirectory, includingPropertiesForKeys: nil)) ?? [] + for file in files { + if file.pathExtension == "metadata", + let metadataData = try? Data(contentsOf: file), + let metadata = try? JSONSerialization.jsonObject(with: metadataData) as? [String: TimeInterval], + let expirationTime = metadata["expiration"] { - for file in files { - if file.pathExtension == "metadata", - let metadataData = try? Data(contentsOf: file), - let metadata = try? JSONSerialization.jsonObject(with: metadataData) as? [String: TimeInterval], - let expirationTime = metadata["expiration"] { - - // 만료 시간이 지나면 이미지와 메타데이터 삭제 - if Date().timeIntervalSince1970 > expirationTime { - let imageFileURL = file.deletingPathExtension() // 메타데이터와 동일한 이름의 이미지 파일 - do { - try self.fileManager.removeItem(at: imageFileURL) - try self.fileManager.removeItem(at: file) // 메타데이터 삭제 - } catch { - print("Failed to delete expired cache: \(error)") - } - } + // 만료 시간이 지나면 이미지와 메타데이터 삭제 + if Date().timeIntervalSince1970 > expirationTime { + let imageFileURL = file.deletingPathExtension() // 메타데이터와 동일한 이름의 이미지 파일 + do { + try self.fileManager.removeItem(at: imageFileURL) + try self.fileManager.removeItem(at: file) // 메타데이터 삭제 + } catch { + print("Failed to delete expired cache: \(error)") } } } - // 백그라운드에서 실행되는 타이머를 메인 루프에 추가 - RunLoop.current.add(cleanTimer, forMode: .common) - RunLoop.current.run() // 백그라운드 스레드에서 타이머를 계속 실행하기 위해 RunLoop를 유지 } } } From 72b9de63e56b8c55dd3cd428aae5f203bed8f1bd Mon Sep 17 00:00:00 2001 From: JunYoung Date: Mon, 7 Apr 2025 23:33:48 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor/#92:=20import=20Kingfisher=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=B9=8C=EB=93=9C=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EA=B2=8C=20=EC=BD=94=EB=93=9C=20=EC=9D=BC?= =?UTF-8?q?=EB=B6=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Map/Common/MapPopupCardView/PopupCardCell.swift | 4 ++-- .../Presentation/Scene/Detail/DetailController.swift | 2 +- .../Main/View/HomeCardSection/HomeCardSectionCell.swift | 8 -------- .../HomePopularCardSectionCell.swift | 7 ------- 4 files changed, 3 insertions(+), 18 deletions(-) diff --git a/Poppool/Poppool/Presentation/Map/Common/MapPopupCardView/PopupCardCell.swift b/Poppool/Poppool/Presentation/Map/Common/MapPopupCardView/PopupCardCell.swift index 27350c36..5d2f8f11 100644 --- a/Poppool/Poppool/Presentation/Map/Common/MapPopupCardView/PopupCardCell.swift +++ b/Poppool/Poppool/Presentation/Map/Common/MapPopupCardView/PopupCardCell.swift @@ -1,7 +1,7 @@ -import Kingfisher -import SnapKit import UIKit +import SnapKit + final class PopupCardCell: UICollectionViewCell { static let identifier = "PopupCardCell" diff --git a/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift b/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift index d9030086..ee001146 100644 --- a/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift +++ b/Poppool/Poppool/Presentation/Scene/Detail/DetailController.swift @@ -224,7 +224,7 @@ extension DetailController: UICollectionViewDelegate, UICollectionViewDataSource cell.imageCollectionView.rx.itemSelected .withUnretained(self) .map { (owner, cellIndexPath) in - Reactor.Action.commentImageTapped(controller: owner, cellRow: indexPath.row, ImageRow: cellIndexPath.row) + Reactor.Action.commentImageTapped(controller: owner, cellRow: indexPath.row, imageRow: cellIndexPath.row) } .bind(to: reactor.action) .disposed(by: cell.disposeBag) diff --git a/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomeCardSection/HomeCardSectionCell.swift b/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomeCardSection/HomeCardSectionCell.swift index 14c03267..30bb15e1 100644 --- a/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomeCardSection/HomeCardSectionCell.swift +++ b/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomeCardSection/HomeCardSectionCell.swift @@ -1,13 +1,5 @@ -// -// HomeCardSectionCell.swift -// Poppool -// -// Created by SeoJunYoung on 11/30/24. -// - import UIKit -import Kingfisher import RxSwift import SnapKit diff --git a/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomePopularCardSection/HomePopularCardSectionCell.swift b/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomePopularCardSection/HomePopularCardSectionCell.swift index 9e8f66af..54d97a52 100644 --- a/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomePopularCardSection/HomePopularCardSectionCell.swift +++ b/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomePopularCardSection/HomePopularCardSectionCell.swift @@ -1,10 +1,3 @@ -// -// HomePopularCardSectionCell.swift -// Poppool -// -// Created by SeoJunYoung on 11/30/24. -// - import UIKit import Kingfisher From 8b2674edf15c2de4fa7953f14bd07e7f169e0830 Mon Sep 17 00:00:00 2001 From: JunYoung Date: Mon, 7 Apr 2025 23:58:05 +0900 Subject: [PATCH 09/10] =?UTF-8?q?refactor/#92:=20import=20Kingfisher=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/HomePopularCardSection/HomePopularCardSectionCell.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomePopularCardSection/HomePopularCardSectionCell.swift b/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomePopularCardSection/HomePopularCardSectionCell.swift index 54d97a52..b4bd7862 100644 --- a/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomePopularCardSection/HomePopularCardSectionCell.swift +++ b/Poppool/Poppool/Presentation/Scene/Home/Main/View/HomePopularCardSection/HomePopularCardSectionCell.swift @@ -1,6 +1,5 @@ import UIKit -import Kingfisher import RxSwift import SnapKit From ec1848bc53ad13278bd57a4c436f88c2fb95e1b6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 7 Apr 2025 15:04:14 +0000 Subject: [PATCH 10/10] style/#92: Apply SwiftLint autocorrect --- .../ImageLoader/DiskStorage.swift | 40 +++++++++---------- .../ImageLoader/ImageLoader.swift | 36 ++++++++--------- .../ImageLoader/MemoryStorage.swift | 24 +++++------ 3 files changed, 50 insertions(+), 50 deletions(-) diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift b/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift index 95d4b70d..127639b3 100644 --- a/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift +++ b/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift @@ -1,30 +1,30 @@ -import UIKit import CryptoKit +import UIKit /// 디스크에 이미지를 캐싱하는 클래스 final class DiskStorage { - + /// 싱글톤 인스턴스 static let shared = DiskStorage() - + /// 파일 관리 객체 private let fileManager = FileManager.default - + /// 이미지 캐시 디렉터리 경로 private let cacheDirectory: URL - + /// 초기화 메서드 (캐시 디렉터리 생성 및 자동 삭제 스케줄 시작) private init() { let urls = fileManager.urls(for: .cachesDirectory, in: .userDomainMask) cacheDirectory = urls[0].appendingPathComponent("ImageCache") - + // 디렉터리가 존재하지 않으면 생성 if !fileManager.fileExists(atPath: cacheDirectory.path) { try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true, attributes: nil) } startCacheCleanup() } - + /// URL을 안전한 파일명으로 변환하는 메서드 /// - Parameter url: 원본 URL 문자열 /// - Returns: 파일명으로 변환된 문자열 @@ -33,7 +33,7 @@ final class DiskStorage { let hashed = SHA256.hash(data: data) return hashed.compactMap { String(format: "%02x", $0) }.joined() } - + /// 이미지를 디스크에 저장하는 메서드 /// - Parameters: /// - image: 저장할 UIImage 객체 @@ -42,7 +42,7 @@ final class DiskStorage { let fileName = cacheFileName(for: url) let fileURL = cacheDirectory.appendingPathComponent(fileName) let metadataURL = cacheDirectory.appendingPathComponent("\(fileName).metadata") - + // 이미지 데이터를 JPEG 형식으로 변환하여 저장 if let data = image.jpegData(compressionQuality: 0.8) { do { @@ -51,11 +51,11 @@ final class DiskStorage { print("Error writing image data to disk: \(error)") } } - + // 만료 시간 기록 let expirationDate = Date().addingTimeInterval(ImageLoader.shared.configure.diskCacheExpiration) let metadata = ["expiration": expirationDate.timeIntervalSince1970] - + // 만료 정보를 JSON 형태로 저장 if let metadataData = try? JSONSerialization.data(withJSONObject: metadata) { do { @@ -65,7 +65,7 @@ final class DiskStorage { } } } - + /// 디스크에서 이미지를 불러오는 메서드 (만료된 경우 자동 삭제) /// - Parameter url: 이미지의 원본 URL 문자열 /// - Returns: UIImage 객체 (없거나 만료된 경우 nil) @@ -73,34 +73,34 @@ final class DiskStorage { let fileName = cacheFileName(for: url) let fileURL = cacheDirectory.appendingPathComponent(fileName) let metadataURL = cacheDirectory.appendingPathComponent("\(fileName).metadata") - + // 만료 시간 확인 if let metadataData = try? Data(contentsOf: metadataURL), let metadata = try? JSONSerialization.jsonObject(with: metadataData) as? [String: TimeInterval], let expirationTime = metadata["expiration"] { - + // 만료 시간이 현재 시각을 초과하면 삭제 후 nil 반환 if Date().timeIntervalSince1970 > expirationTime { removeImage(url: url) return nil } } - + // 이미지 파일이 존재하면 로드하여 반환 if let data = try? Data(contentsOf: fileURL) { return UIImage(data: data) } - + return nil } - + /// 특정 URL에 해당하는 이미지를 디스크에서 삭제하는 메서드 /// - Parameter url: 삭제할 이미지의 원본 URL 문자열 func removeImage(url: String) { let fileName = cacheFileName(for: url) let fileURL = cacheDirectory.appendingPathComponent(fileName) let metadataURL = cacheDirectory.appendingPathComponent("\(fileName).metadata") - + do { try fileManager.removeItem(at: fileURL) // 이미지 파일 삭제 try fileManager.removeItem(at: metadataURL) // 메타데이터 파일 삭제 @@ -108,7 +108,7 @@ final class DiskStorage { print("Failed to remove image: \(error)") } } - + /// 모든 캐시 데이터를 삭제하는 메서드 func clearCache() { do { @@ -118,7 +118,7 @@ final class DiskStorage { print("Failed to clear cache: \(error)") } } - + /// 주기적으로 만료된 캐시를 삭제하는 메서드 private func startCacheCleanup() { let files = (try? self.fileManager.contentsOfDirectory(at: self.cacheDirectory, includingPropertiesForKeys: nil)) ?? [] diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift index 9e254f47..efaecd9d 100644 --- a/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift +++ b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift @@ -11,7 +11,7 @@ enum ImageSizeOption { case middle case high case origin - + var size: CGSize { switch self { case .low: @@ -35,14 +35,14 @@ class ImageLoaderConfigure { /// URL을 통해 이미지를 비동기적으로 로드하는 클래스 final class ImageLoader { - + static let shared = ImageLoader() - + /// 이미지 로더 설정 객체 let configure = ImageLoaderConfigure() - + private init() {} - + /// URL을 통해 이미지를 로드하고, 실패 시 기본 이미지를 반환하는 메서드 /// - Parameters: /// - stringURL: 이미지 URL 문자열 @@ -66,7 +66,7 @@ final class ImageLoader { } private extension ImageLoader { - + /// URL을 통해 이미지를 로드하는 내부 메서드 /// - Parameters: /// - stringURL: 이미지 URL 문자열 @@ -76,13 +76,13 @@ private extension ImageLoader { completion(.failure(ImageLoaderError.invalidURL)) return } - + // 메모리 캐시에서 이미지 조회 if let cachedImage = MemoryStorage.shared.fetchImage(url: stringURL) { completion(.success(cachedImage)) return } - + // 디스크 캐시 확인 if let diskImage = DiskStorage.shared.fetchImage(url: stringURL) { // 메모리 캐시에 저장 후 반환 @@ -90,7 +90,7 @@ private extension ImageLoader { completion(.success(diskImage)) return } - + // 네트워크에서 데이터 요청 fetchDataFrom(url: url) { result in switch result { @@ -107,13 +107,13 @@ private extension ImageLoader { } } } - + /// URL을 통해 데이터를 요청하는 메서드 /// - Parameters: /// - url: 요청할 URL 객체 /// - completion: 요청 완료 후 호출되는 클로저 func fetchDataFrom(url: URL, completion: @escaping (Result) -> Void) { - let task = URLSession.shared.dataTask(with: url) { data, response, error in + let task = URLSession.shared.dataTask(with: url) { data, _, error in if let error = error { completion(.failure(ImageLoaderError.networkError(description: "Network Error: \(error.localizedDescription)"))) return @@ -122,26 +122,26 @@ private extension ImageLoader { } task.resume() } - + func resizeImage(_ image: UIImage?, defaultImage: UIImage?, with sizeOption: ImageSizeOption) -> UIImage? { guard let image else { return defaultImage } - + if sizeOption == .origin { return image } - + let targetSize = sizeOption.size - + // 비율 유지 리사이징 let aspectRatio = image.size.width / image.size.height var newSize = targetSize - + if aspectRatio > 1 { // 가로 이미지 newSize.height = targetSize.width / aspectRatio } else { // 세로 이미지 newSize.width = targetSize.height * aspectRatio } - + let renderer = UIGraphicsImageRenderer(size: newSize) - + return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: newSize)) } diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/MemoryStorage.swift b/Poppool/Poppool/Infrastructure/ImageLoader/MemoryStorage.swift index 517bc9a7..2d4b30dc 100644 --- a/Poppool/Poppool/Infrastructure/ImageLoader/MemoryStorage.swift +++ b/Poppool/Poppool/Infrastructure/ImageLoader/MemoryStorage.swift @@ -4,7 +4,7 @@ import UIKit class StorageData: NSObject { let image: UIImage? /// 캐시된 이미지 let expirationDate: Date /// 캐시 만료 시간 - + /// 초기화 메서드 /// - Parameters: /// - image: 저장할 이미지 @@ -13,7 +13,7 @@ class StorageData: NSObject { self.image = image self.expirationDate = Date().addingTimeInterval(expiration) } - + /// 캐시가 만료되었는지 확인하는 메서드 /// - Returns: 만료 여부 (true: 만료됨, false: 유효함) func isExpired() -> Bool { @@ -23,21 +23,21 @@ class StorageData: NSObject { /// 메모리 캐시를 관리하는 클래스 final class MemoryStorage { - + /// 싱글톤 인스턴스 static let shared = MemoryStorage() - + /// 이미지 캐시 저장소 private let cache = NSCache() - + /// 현재 캐시에 저장된 키 목록 private var cachedKeys: Set = [] - + /// 초기화 (자동 캐시 정리 시작) private init() { startCacheCleanup() } - + /// 이미지를 캐시에 저장하는 메서드 /// - Parameters: /// - image: 저장할 이미지 @@ -47,7 +47,7 @@ final class MemoryStorage { cache.setObject(cachedData, forKey: url as NSString) cachedKeys.insert(url) } - + /// 캐시에서 이미지를 가져오는 메서드 /// - Parameter url: 이미지 URL 문자열 /// - Returns: 캐시된 UIImage (없으면 nil) @@ -59,25 +59,25 @@ final class MemoryStorage { return nil } } - + /// 특정 URL의 캐시 데이터를 제거하는 메서드 /// - Parameter url: 제거할 이미지의 URL 문자열 func removeData(url: String) { cache.removeObject(forKey: url as NSString) cachedKeys.remove(url) } - + /// 모든 캐시 데이터를 삭제하는 메서드 func clearCache() { cache.removeAllObjects() cachedKeys.removeAll() } - + /// 주기적으로 만료된 캐시를 정리하는 메서드 private func startCacheCleanup() { DispatchQueue.global(qos: .background).async { [weak self] in guard let self = self else { return } - + let cleanTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { _ in for key in self.cachedKeys { let nsKey = key as NSString