diff --git a/Poppool/Poppool.xcodeproj/project.pbxproj b/Poppool/Poppool.xcodeproj/project.pbxproj index 3e794526..91185e2b 100644 --- a/Poppool/Poppool.xcodeproj/project.pbxproj +++ b/Poppool/Poppool.xcodeproj/project.pbxproj @@ -262,6 +262,8 @@ 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 */; }; + 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 */; }; @@ -343,6 +345,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 */; }; @@ -461,7 +464,6 @@ BDCA41CA2CF35AC1005EECF6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BDCA41C92CF35AC1005EECF6 /* Assets.xcassets */; }; BDCA41CD2CF35AC1005EECF6 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = BDCA41CC2CF35AC1005EECF6 /* Base */; }; 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 */; }; @@ -728,6 +730,8 @@ 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 = ""; }; + 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 = ""; }; @@ -809,6 +813,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 = ""; }; @@ -931,7 +936,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 */, @@ -1427,6 +1431,7 @@ 083A25A02CF3623C0099B58E /* Infrastructure */ = { isa = PBXGroup; children = ( + 089B4FD62D9A576F00FC0CC3 /* ImageLoader */, 0841BA832CF9F61500049E31 /* PreSignedService */, 083A25B12CF362670099B58E /* NetworkLayer */, 08DC61F42CF765B5002A2F44 /* UserDefaultService.swift */, @@ -2244,6 +2249,16 @@ path = View; sourceTree = ""; }; + 089B4FD62D9A576F00FC0CC3 /* ImageLoader */ = { + isa = PBXGroup; + children = ( + 089B4FD72D9A57AE00FC0CC3 /* ImageLoader.swift */, + 089B4FDE2D9A8F9A00FC0CC3 /* MemoryStorage.swift */, + 08CFD3912D9BDE99004CDD50 /* DiskStorage.swift */, + ); + path = ImageLoader; + sourceTree = ""; + }; 08A2E4772D1B069300102313 /* ImageDetail */ = { isa = PBXGroup; children = ( @@ -2988,7 +3003,6 @@ name = Poppool; packageProductDependencies = ( BDCA41F12CF35D0D005EECF6 /* SnapKit */, - BDCA41F42CF35D33005EECF6 /* Kingfisher */, BDCA41F72CF35D9A005EECF6 /* RxSwift */, BDCA41FD2CF35EE7005EECF6 /* ReactorKit */, BDCA42002CF35EFE005EECF6 /* RxKeyboard */, @@ -3037,7 +3051,6 @@ mainGroup = BDCA41B42CF35AC0005EECF6; packageReferences = ( BDCA41F02CF35D0D005EECF6 /* XCRemoteSwiftPackageReference "SnapKit" */, - BDCA41F32CF35D33005EECF6 /* XCRemoteSwiftPackageReference "Kingfisher" */, BDCA41F62CF35D9A005EECF6 /* XCRemoteSwiftPackageReference "RxSwift" */, BDCA41FC2CF35EE7005EECF6 /* XCRemoteSwiftPackageReference "ReactorKit" */, BDCA41FF2CF35EFE005EECF6 /* XCRemoteSwiftPackageReference "RxKeyboard" */, @@ -3190,11 +3203,13 @@ 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 */, 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 */, @@ -3344,6 +3359,8 @@ BD9103652CF6149D00BBCCAE /* HomeAPIEndpoint.swift in Sources */, 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 */, @@ -3866,14 +3883,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"; @@ -3993,11 +4002,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 deleted file mode 100644 index ea30e126..00000000 --- a/Poppool/Poppool.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,177 +0,0 @@ -{ - "originHash" : "90182ae75bddc149e01f3583353785f437a618e30e1e9df09cabd3c7f3605747", - "pins" : [ - { - "identity" : "alamofire", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Alamofire/Alamofire.git", - "state" : { - "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", - "version" : "5.10.2" - } - }, - { - "identity" : "floatingpanel", - "kind" : "remoteSourceControl", - "location" : "https://github.com/scenee/FloatingPanel.git", - "state" : { - "revision" : "b6e8928b1a3ad909e6db6a0278d286c33cfd0dc3", - "version" : "2.8.6" - } - }, - { - "identity" : "kakao-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kakao/kakao-ios-sdk.git", - "state" : { - "revision" : "bfe2fe42f730ccfe59e85f6e9eda2f4578e9a307", - "version" : "2.24.0" - } - }, - { - "identity" : "kingfisher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/onevcat/Kingfisher.git", - "state" : { - "revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3", - "version" : "8.3.1" - } - }, - { - "identity" : "lottie-spm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/airbnb/lottie-spm.git", - "state" : { - "revision" : "8c6edf4f0fa84fe9c058600a4295eb0c01661c69", - "version" : "4.5.1" - } - }, - { - "identity" : "pageboy", - "kind" : "remoteSourceControl", - "location" : "https://github.com/uias/Pageboy.git", - "state" : { - "revision" : "be0c1f6f1964cfb07f9d819b0863f2c3f255f612", - "version" : "4.2.0" - } - }, - { - "identity" : "panmodal", - "kind" : "remoteSourceControl", - "location" : "https://github.com/slackhq/PanModal.git", - "state" : { - "revision" : "b012aecb6b67a8e46369227f893c12544846613f", - "version" : "1.2.7" - } - }, - { - "identity" : "reactorkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ReactorKit/ReactorKit.git", - "state" : { - "revision" : "8fa33f09c6f6621a2aa536d739956d53b84dd139", - "version" : "3.2.0" - } - }, - { - "identity" : "rxdatasources", - "kind" : "remoteSourceControl", - "location" : "https://github.com/RxSwiftCommunity/RxDataSources.git", - "state" : { - "revision" : "90c29b48b628479097fe775ed1966d75ac374518", - "version" : "5.0.2" - } - }, - { - "identity" : "rxgesture", - "kind" : "remoteSourceControl", - "location" : "https://github.com/RxSwiftCommunity/RxGesture.git", - "state" : { - "revision" : "1b137c576b4aaaab949235752278956697c9e4a0", - "version" : "4.0.4" - } - }, - { - "identity" : "rxkeyboard", - "kind" : "remoteSourceControl", - "location" : "https://github.com/RxSwiftCommunity/RxKeyboard.git", - "state" : { - "revision" : "63f6377975c962a1d89f012a6f1e5bebb2c502b7", - "version" : "2.0.1" - } - }, - { - "identity" : "rxswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ReactiveX/RxSwift.git", - "state" : { - "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", - "version" : "6.9.0" - } - }, - { - "identity" : "snapkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SnapKit/SnapKit.git", - "state" : { - "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", - "version" : "5.7.1" - } - }, - { - "identity" : "spm-nmapsgeometry", - "kind" : "remoteSourceControl", - "location" : "https://github.com/navermaps/SPM-NMapsGeometry.git", - "state" : { - "revision" : "436d5e2e684f557faf5ef5862fd6633a42d7af11", - "version" : "1.0.2" - } - }, - { - "identity" : "spm-nmapsmap", - "kind" : "remoteSourceControl", - "location" : "https://github.com/navermaps/SPM-NMapsMap", - "state" : { - "revision" : "ad89e53fdfec3b8d8994280fb0414b5a7b1c3e8e", - "version" : "3.21.0" - } - }, - { - "identity" : "swiftsoup", - "kind" : "remoteSourceControl", - "location" : "https://github.com/scinfu/SwiftSoup", - "state" : { - "revision" : "bba848db50462894e7fc0891d018dfecad4ef11e", - "version" : "2.8.7" - } - }, - { - "identity" : "tabman", - "kind" : "remoteSourceControl", - "location" : "https://github.com/uias/Tabman.git", - "state" : { - "revision" : "3b2213290eb93e55bb50b49d1a179033005c11ab", - "version" : "3.2.0" - } - }, - { - "identity" : "then", - "kind" : "remoteSourceControl", - "location" : "https://github.com/devxoul/Then", - "state" : { - "revision" : "d41ef523faef0f911369f79c0b96815d9dbb6d7a", - "version" : "3.0.0" - } - }, - { - "identity" : "weakmaptable", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ReactorKit/WeakMapTable.git", - "state" : { - "revision" : "cb05d64cef2bbf51e85c53adee937df46540a74e", - "version" : "1.2.1" - } - } - ], - "version" : 3 -} diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift b/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift new file mode 100644 index 00000000..127639b3 --- /dev/null +++ b/Poppool/Poppool/Infrastructure/ImageLoader/DiskStorage.swift @@ -0,0 +1,145 @@ +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: 파일명으로 변환된 문자열 + 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)") + } + } + + /// 주기적으로 만료된 캐시를 삭제하는 메서드 + private func startCacheCleanup() { + 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)") + } + } + } + } + } +} diff --git a/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift new file mode 100644 index 00000000..efaecd9d --- /dev/null +++ b/Poppool/Poppool/Infrastructure/ImageLoader/ImageLoader.swift @@ -0,0 +1,149 @@ +import UIKit + +enum ImageLoaderError: Error { + case invalidURL + case networkError(description: String?) + 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 { + var memoryCacheExpiration: TimeInterval = 300 + var diskCacheExpiration: TimeInterval = 86_400 +} + +/// URL을 통해 이미지를 비동기적으로 로드하는 클래스 +final class ImageLoader { + + static let shared = ImageLoader() + + /// 이미지 로더 설정 객체 + let configure = ImageLoaderConfigure() + + private init() {} + + /// URL을 통해 이미지를 로드하고, 실패 시 기본 이미지를 반환하는 메서드 + /// - Parameters: + /// - stringURL: 이미지 URL 문자열 + /// - defaultImage: 로드 실패 시 반환할 기본 이미지 + /// - completion: 로드 완료 후 호출되는 클로저 + 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(self?.resizeImage(image, defaultImage: defaultImage, with: imageQuality)) + 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 { + 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"))) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + /// URL을 통해 데이터를 요청하는 메서드 + /// - Parameters: + /// - url: 요청할 URL 객체 + /// - completion: 요청 완료 후 호출되는 클로저 + func fetchDataFrom(url: URL, completion: @escaping (Result) -> Void) { + let task = URLSession.shared.dataTask(with: url) { data, _, error in + if let error = error { + completion(.failure(ImageLoaderError.networkError(description: "Network Error: \(error.localizedDescription)"))) + return + } + completion(.success(data)) + } + 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 new file mode 100644 index 00000000..2d4b30dc --- /dev/null +++ b/Poppool/Poppool/Infrastructure/ImageLoader/MemoryStorage.swift @@ -0,0 +1,95 @@ +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 + } +} + +/// 메모리 캐시를 관리하는 클래스 +final 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 } + + 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() { + self.cache.removeObject(forKey: nsKey) + self.cachedKeys.remove(key) + } + } + } + // 백그라운드에서 실행되는 타이머를 메인 루프에 추가 + RunLoop.current.add(cleanTimer, forMode: .common) + RunLoop.current.run() // 백그라운드 스레드에서 타이머를 계속 실행하기 위해 RunLoop를 유지 + } + } +} diff --git a/Poppool/Poppool/Presentation/Extension/String?+.swift b/Poppool/Poppool/Presentation/Extension/String?+.swift index feab8506..5e7c7831 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 761ae557..bd1738d3 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 { @@ -17,13 +8,9 @@ extension UIImageView { } let imageURLString = KeyPath.popPoolS3BaseURL + 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"), imageQuality: .origin) { [weak self] image in + DispatchQueue.main.async { + self?.image = image } } } @@ -38,13 +25,10 @@ extension UIImageView { let imageURLString = KeyPath.popPoolS3BaseURL + 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 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..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,13 +1,5 @@ -// -// HomePopularCardSectionCell.swift -// Poppool -// -// Created by SeoJunYoung on 11/30/24. -// - import UIKit -import Kingfisher import RxSwift import SnapKit