From 742aeb854c5983c11fd1f032d5c3647da9cb8b31 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 23 Feb 2025 02:25:06 +0900 Subject: [PATCH 01/25] =?UTF-8?q?[Feature]=20DiskCache=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BookKitty/BookKitty.xcodeproj/project.pbxproj | 11 +- .../contents.xcworkspacedata | 7 + BookKitty/BookKitty/NeoImage/Package.swift | 22 ++ .../NeoImage/Constants/CacheError.swift | 59 +++++ .../Constants/ExpirationExtending.swift | 40 +++ .../NeoImage/Constants/TimeConstants.swift | 5 + .../Sources/NeoImage/DiskStorage.swift | 249 ++++++++++++++++++ .../Sources/NeoImage/Extensions/Date+.swift | 7 + .../Sources/NeoImage/Extensions/String+.swift | 15 ++ .../Sources/NeoImage/ImageCache.swift | 151 +++++++++++ .../NeoImage/Sources/NeoImage/NeoImage.swift | 2 + .../Protocols/DataTransformable.swift | 22 ++ .../Tests/NeoImageTests/NeoImageTests.swift | 12 + 13 files changed, 600 insertions(+), 2 deletions(-) create mode 100644 BookKitty/BookKitty/NeoImage/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 BookKitty/BookKitty/NeoImage/Package.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/ExpirationExtending.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/String+.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImage.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Protocols/DataTransformable.swift create mode 100644 BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageTests.swift diff --git a/BookKitty/BookKitty.xcodeproj/project.pbxproj b/BookKitty/BookKitty.xcodeproj/project.pbxproj index 49a8b45c..23fcdb24 100644 --- a/BookKitty/BookKitty.xcodeproj/project.pbxproj +++ b/BookKitty/BookKitty.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ E5A6B99E2D5F54C300A2E06D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E5A6B99C2D5F54C300A2E06D /* PrivacyInfo.xcprivacy */; }; E5DE19642D62A7D3007D37E2 /* BookOCRKit in Frameworks */ = {isa = PBXBuildFile; productRef = E5DE19632D62A7D3007D37E2 /* BookOCRKit */; }; E5FC698C2D52414B002875FD /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = E5FC698B2D52414B002875FD /* SnapKit */; }; + E5FFE2F02D6A3F4200A0F7CF /* NeoImage in Frameworks */ = {isa = PBXBuildFile; productRef = E5FFE2EF2D6A3F4200A0F7CF /* NeoImage */; }; E93048662D559553008E9467 /* RxCocoa in Frameworks */ = {isa = PBXBuildFile; productRef = E93048652D559553008E9467 /* RxCocoa */; }; E97DC3702D50C161009ADFEA /* DesignSystem in Frameworks */ = {isa = PBXBuildFile; productRef = E97DC36F2D50C161009ADFEA /* DesignSystem */; }; /* End PBXBuildFile section */ @@ -87,6 +88,7 @@ 606DA2402D4206F200C7FAA3 /* RxRelay in Frameworks */, E97DC3702D50C161009ADFEA /* DesignSystem in Frameworks */, 4584C5AF2D685AB300173282 /* FirebaseAnalytics in Frameworks */, + E5FFE2F02D6A3F4200A0F7CF /* NeoImage in Frameworks */, 60A1CC0E2D54A2DB00091568 /* BookRecommendationKit in Frameworks */, 606DA2422D4206F200C7FAA3 /* RxSwift in Frameworks */, 4584C5B32D685AB300173282 /* FirebaseCrashlytics in Frameworks */, @@ -170,6 +172,7 @@ 4584C5AE2D685AB300173282 /* FirebaseAnalytics */, 4584C5B02D685AB300173282 /* FirebaseCore */, 4584C5B22D685AB300173282 /* FirebaseCrashlytics */, + E5FFE2EF2D6A3F4200A0F7CF /* NeoImage */, ); productName = BookKitty; productReference = 60551C652D40E6E800CFC16A /* BookKitty.app */; @@ -346,7 +349,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = SUHZ238M29; + DEVELOPMENT_TEAM = H856SYKNM8; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BookKitty/App/Info.plist; @@ -385,7 +388,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = SUHZ238M29; + DEVELOPMENT_TEAM = H856SYKNM8; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BookKitty/App/Info.plist; @@ -704,6 +707,10 @@ package = E5FC698A2D52414B002875FD /* XCRemoteSwiftPackageReference "SnapKit" */; productName = SnapKit; }; + E5FFE2EF2D6A3F4200A0F7CF /* NeoImage */ = { + isa = XCSwiftPackageProductDependency; + productName = NeoImage; + }; E93048652D559553008E9467 /* RxCocoa */ = { isa = XCSwiftPackageProductDependency; package = 606DA23C2D4206F200C7FAA3 /* XCRemoteSwiftPackageReference "RxSwift" */; diff --git a/BookKitty/BookKitty/NeoImage/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/BookKitty/BookKitty/NeoImage/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/BookKitty/BookKitty/NeoImage/Package.swift b/BookKitty/BookKitty/NeoImage/Package.swift new file mode 100644 index 00000000..ee79c13a --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "NeoImage", + platforms: [.iOS(.v16)], + products: [ + .library( + name: "NeoImage", + targets: ["NeoImage"]), + ], + targets: [ + .target( + name: "NeoImage"), + .testTarget( + name: "NeoImageTests", + dependencies: ["NeoImage"] + ), + ] +) diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift new file mode 100644 index 00000000..c4f7f324 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift @@ -0,0 +1,59 @@ +enum CacheError: Error { + // 데이터 관련 에러 + case invalidData + case invalidImage + case dataToImageConversionFailed + case imageToDataConversionFailed + + // 저장소 관련 에러 + case diskStorageError(Error) + case memoryStorageError(Error) + case storageNotReady + + // 파일 관련 에러 + case fileNotFound(String) // key + case cannotCreateDirectory(Error) + case cannotWriteToFile(Error) + case cannotReadFromFile(Error) + + // 캐시 키 관련 에러 + case invalidCacheKey + + // 기타 + case unknown(Error) + + var localizedDescription: String { + switch self { + case .invalidData: + return "The data is invalid or corrupted" + case .invalidImage: + return "The image data is invalid" + case .dataToImageConversionFailed: + return "Failed to convert data to image" + case .imageToDataConversionFailed: + return "Failed to convert image to data" + + case .diskStorageError(let error): + return "Disk storage error: \(error.localizedDescription)" + case .memoryStorageError(let error): + return "Memory storage error: \(error.localizedDescription)" + case .storageNotReady: + return "The storage is not ready" + + case .fileNotFound(let key): + return "File not found for key: \(key)" + case .cannotCreateDirectory(let error): + return "Cannot create directory: \(error.localizedDescription)" + case .cannotWriteToFile(let error): + return "Cannot write to file: \(error.localizedDescription)" + case .cannotReadFromFile(let error): + return "Cannot read from file: \(error.localizedDescription)" + + case .invalidCacheKey: + return "The cache key is invalid" + + case .unknown(let error): + return "Unknown error: \(error.localizedDescription)" + } + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/ExpirationExtending.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/ExpirationExtending.swift new file mode 100644 index 00000000..73445347 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/ExpirationExtending.swift @@ -0,0 +1,40 @@ +import Foundation + +public enum StorageExpiration: Equatable, Sendable { + /// 초 단위로 만료 시간 지정 + case seconds(TimeInterval) + + /// 일 단위로 만료 시간 지정 + case days(Int) + + /// 영구 저장 (만료되지 않음) + case never + + var estimatedExpirationSinceNow: Date { + let timeInterval: TimeInterval + switch self { + case .seconds(let seconds): + timeInterval = seconds + case .days(let days): + timeInterval = TimeInterval(86400 * days) // 86400 = 24 * 60 * 60 + case .never: + return .distantFuture + } + return Date().addingTimeInterval(timeInterval) + } + + var isExpired: Bool { + return estimatedExpirationSinceNow.isPast + } +} + +public enum ExpirationExtending: Equatable, Sendable { + /// 만료 시간을 연장하지 않음 + case none + + /// 현재 캐시 설정의 만료 시간만큼 연장 + case cacheTime + + /// 지정된 만료 시간으로 연장 + case expirationTime(StorageExpiration) +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift new file mode 100644 index 00000000..9708b120 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift @@ -0,0 +1,5 @@ +struct TimeConstants { + // Seconds in a day, a.k.a 86,400s, roughly. + /// also known as + static let secondsInOneDay = 86_400 +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift new file mode 100644 index 00000000..2eb6001d --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift @@ -0,0 +1,249 @@ +import Foundation + +class DiskStorage: @unchecked Sendable { + private let config: Config + + private let directoryURL: URL + + private let serialActor = Actor() + private var storageReady: Bool = true + + init(config: Config) throws { + /// 외부에서 주입된 디스크 저장소에 대한 설정값과 Creation 구조체로 생성된 디렉토리 URL와 cacheName을 생성 및 self.directoryURL에 저장합니다. + self.config = config + let creation = Creation(config) + self.directoryURL = creation.directoryURL + try prepareDirectory() + } + + func store(value: T, forKey key: String, expiration: StorageExpiration? = nil ) async throws { + guard let data = try? value.toData() else { + throw CacheError.invalidData + } + /// Disk에 대한 접근이 패키지 외부에서 동시에 이루어질 경우, 동일한 위치에 다른 데이터가 덮어씌워지는 data race 상황이 됩니다. 이를 방지하고자, 기존 Kingfisher에서는 DispatchQueue를 통해 직렬화 큐를 구현한 후, store(Write), value(Read)를 직렬화 큐에 전송하여 순차적인 실행이 보장되게 하였습니다. + /// 이를 Swift Concurrency로 변경하고자, 동일한 직렬화 기능을 수행하는 Actor 클래스로 대체하였습니다. + try await serialActor.run { + /// 별도로 메서드를 통해 기한을 전달하지 않으면, 기본값으로 config.expiration인 7일로 정의합니다. + let expiration = expiration ?? self.config.expiration + let fileURL = self.cacheFileURL(forKey: key) + /// Foundation 내부 Data 타입의 내장 메서드입니다. + /// 해당 위치로 data 내부 컨텐츠를 write 합니다. + try data.write(to: fileURL) + + /// FileManager를 통해 파일 작성 시 전달해줄 파일의 속성입니다. + /// 생성된 날짜, 수정된 일자를 실제 수정된 시간이 아닌, 만료 예정 시간을 저장하는 용도로 재활용합니다. + /// 실제로, 파일 시스템의 기본속성을 활용하기에 추가적인 저장공간이 필요 없음 + /// 파일과 만료 정보가 항상 동기화되어 있음 (파일이 삭제되면 만료 정보도 자동으로 삭제) + let attributes: [FileAttributeKey: Any] = [ + .creationDate: Date(), + .modificationDate: expiration.estimatedExpirationSinceNow + ] + + /// 파일의 메타데이터가 업데이트됨 + /// 이는 디스크에 대한 I/O 작업을 수반 + /// 파일의 내용은 변경되지 않고 속성만 변경 + try FileManager.default.setAttributes(attributes, ofItemAtPath: fileURL.path) + } + } + + + func value( + forKey key: String, /// 캐시의 키 + extendingExpiration: ExpirationExtending = .cacheTime // 현재 Confiㅎ + ) async throws -> T? { + return try await serialActor.run { () -> T? in + /// 주어진 키에 대한 캐시 파일 URL을 생성 + let fileURL = cacheFileURL(forKey: key) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return nil + } + + /// 파일에서 데이터를 읽어옴 + let data = try Data(contentsOf: fileURL) + /// DataTransformable 프로토콜의 fromData를 사용해 원본 타입으로 변환 + let obj = try T.fromData(data) + + /// 해당 파일이 조회되었기 때문에, 만료 시간 연장을 처리합니다. + /// "캐시 적중(Cache Hit)"이 발생했을 때 해당 데이터의 생명주기를 연장하는 일반적인 캐시 전략입니다. + /// LRU(Least Recently Used) + if extendingExpiration != .none { + let expirationDate: Date + switch extendingExpiration { + case .none: + return obj + case .cacheTime: + expirationDate = config.expiration.estimatedExpirationSinceNow + ///.expirationTime: 지정된 새로운 만료 시간으로 연장 + case .expirationTime(let storageExpiration): + expirationDate = storageExpiration.estimatedExpirationSinceNow + } + + let attributes: [FileAttributeKey: Any] = [ + .creationDate: Date(), + .modificationDate: expirationDate + ] + + try FileManager.default.setAttributes(attributes, ofItemAtPath: fileURL.path) + } + + return obj + } + } + + /// 캐시 확인 + func isCached(forKey key: String) async -> Bool { + let fileURL = cacheFileURL(forKey: key) + return await serialActor.run { + FileManager.default.fileExists(atPath: fileURL.path) + } + } +} + +extension DiskStorage { + private func cacheFileURL(forKey key: String, forcedExtension: String? = nil) -> URL { + let fileName = cacheFileName(forKey: key, forcedExtension: forcedExtension) + + return directoryURL.appendingPathComponent(fileName, isDirectory: false) + } + + /// 사전에 패키지에서 설정된 Config 구조체를 통해 파일명을 해시화하기로 설정했는지 여부, 임의로 전달된 접미사 단어 유무에 따라 캐시될때 저장될 파일명을 변환하여 반환해줍니다. + private func cacheFileName(forKey key: String, forcedExtension: String? = nil) -> String { + if config.usesHashedFileName { + let hashedKey = key.sha256 + if let ext = forcedExtension ?? config.pathExtension { + return "\(hashedKey).\(ext)" + } + return hashedKey + } else { + + if let ext = forcedExtension ?? config.pathExtension { + return "\(key).\(ext)" + } + /// 해시화 설정을 false로 하고, pathExtension에 별도 조작을 하지 않을 경우, + /// key를 그대로 반환하는 경우도 있습니다. + return key + } + } + + private func prepareDirectory() throws { + /// config에 custom fileManager를 주입할 수 있기 때문에, 여기서 .default를 접근하지 않고 Config 내부 fileManager를 접근합니다. + let fileManager = config.fileManager + let path = directoryURL.path + + /// Creation 구조체를 통해 생성된 url이 FileSystem에 존재하는지 검증 + guard !fileManager.fileExists(atPath: path) else { return } + + do { + /// FileManager를 통해 해당 path에 디렉토리 생성 + try fileManager.createDirectory( + atPath: path, + withIntermediateDirectories: true, + attributes: nil) + } catch { + /// 만일 디렉토리 생성이 실패할경우, storageReady를 false로 변경합니다. + /// 이는 추후 flag로 동작합니다. + self.storageReady = false + throw CacheError.cannotCreateDirectory(error) + } + } +} + +/// 직렬화를 위한 간단한 액터 +/// 에러 처리 여부에 따라 오버로드되어있기에, 에러처리가 필요한지 여부에 따라 선택적으로 try 키워드를 삽입 +actor Actor { + func run(_ operation: @Sendable () throws -> T) throws -> T { + try operation() + } + + func run(_ operation: @Sendable () -> T) -> T { + operation() + } +} + +extension DiskStorage { + /// Represents the configuration used in a ``DiskStorage/Backend``. + public struct Config: @unchecked Sendable { + + /// The file size limit on disk of the storage in bytes. + /// `0` means no limit. + public var sizeLimit: UInt + + /// The `StorageExpiration` used in this disk storage. + /// The default is `.days(7)`, which means that the disk cache will expire in one week if not accessed anymore. + public var expiration: StorageExpiration = .days(7) + + /// The preferred extension of the cache item. It will be appended to the file name as its extension. + /// The default is `nil`, which means that the cache file does not contain a file extension. + public var pathExtension: String? = nil + + /// Whether the cache file name will be hashed before storing. + /// + /// The default is `true`, which means that file name is hashed to protect user information (for example, the + /// original download URL which is used as the cache key). + public var usesHashedFileName = true + + + /// Whether the image extension will be extracted from the original file name and appended to the hashed file + /// name, which will be used as the cache key on disk. + /// + /// The default is `false`. + public var autoExtAfterHashedFileName = false + + /// A closure that takes in the initial directory path and generates the final disk cache path. + /// + /// You can use it to fully customize your cache path. + public var cachePathBlock: (@Sendable (_ directory: URL, _ cacheName: String) -> URL)! = { + (directory, cacheName) in + return directory.appendingPathComponent(cacheName, isDirectory: true) + } + + /// The desired name of the disk cache. + /// + /// This name will be used as a part of the cache folder name by default. + public let name: String + + let fileManager: FileManager + let directory: URL? + + /// Creates a config value based on the given parameters. + /// + /// - Parameters: + /// - name: The name of the cache. It is used as part of the storage folder and to identify the disk storage. + /// Two storages with the same `name` would share the same folder on the disk, and this should be prevented. + /// - sizeLimit: The size limit in bytes for all existing files in the disk storage. + /// - fileManager: The `FileManager` used to manipulate files on the disk. The default is `FileManager.default`. + /// - directory: The URL where the disk storage should reside. The storage will use this as the root folder, + /// and append a path that is constructed by the input `name`. The default is `nil`, indicating that + /// the cache directory under the user domain mask will be used. + public init( + name: String, + sizeLimit: UInt, + fileManager: FileManager = .default, + directory: URL? = nil) + { + self.name = name + self.fileManager = fileManager + self.directory = directory + self.sizeLimit = sizeLimit + } + } +} + +extension DiskStorage { + struct Creation { + let directoryURL: URL + let cacheName: String + + init(_ config: Config) { + let url: URL + if let directory = config.directory { + url = directory + } else { + url = config.fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] + } + + cacheName = "com.neoself.NeoImage.ImageCache.\(config.name)" + directoryURL = config.cachePathBlock(url, cacheName) + } + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift new file mode 100644 index 00000000..a3c4a0ba --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Date { + var isPast: Bool { + return self < Date() + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/String+.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/String+.swift new file mode 100644 index 00000000..73660d92 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/String+.swift @@ -0,0 +1,15 @@ +import Foundation +import CommonCrypto + +extension String { + var sha256: String { + guard let data = self.data(using: .utf8) else { return self } + + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + data.withUnsafeBytes { buffer in + _ = CC_SHA256(buffer.baseAddress, CC_LONG(buffer.count), &hash) + } + + return hash.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift new file mode 100644 index 00000000..99c783c9 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift @@ -0,0 +1,151 @@ +import Foundation + +public final class ImageCache { + + /// ERROR: Static property 'shared' is not concurrency-safe because non-'Sendable' type 'ImageCache' may have shared mutable state + /// + /// ``` + /// public static let shared = ImageCache() + /// ``` + /// Swift 6에서는 동시성 안정성 검사가 더욱 엄격해졌습니다. 이로 인해 여러 스레드에서 동시에 접근할 수 있는 공유 상태 (shared mutable state)인 싱글톤 패턴을 사용할 경우,위 에러가 발생합니다. + /// 이는 별도의 가변 프로퍼티를 클래스 내부에 지니고 있지 않음에도 발생하는 에러입니다 + public static let shared = ImageCache() + + // MARK: - Properties + private let memoryStorage: MemoryStorage + private let diskStorage: DiskStorage + + // MARK: - Initialization + public init(name: String) throws { + guard !name.isEmpty else { + throw CacheError.invalidCacheKey + } + + // Memory cache configuration + let totalMemory = ProcessInfo.processInfo.physicalMemory + let memoryLimit = totalMemory / 4 + let memoryCacheConfig = MemoryStorage.Config( + totalCostLimit: (memoryLimit > Int.max) ? Int.max : Int(memoryLimit) + ) + self.memoryStorage = MemoryStorage(config: memoryCacheConfig) + + // Disk cache configuration + let diskConfig = DiskStorage.Config( + name: name, + sizeLimit: 0, + directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ) + self.diskStorage = try DiskStorage(config: diskConfig) + } + + // MARK: - Public Methods + + /// Stores an image to both memory and disk cache + public func store( + _ data: Data, + forKey key: String, + expiration: StorageExpiration? = nil + ) async throws { + // Store in memory + memoryStorage.store(value: data, forKey: key, expiration: expiration) + + // Store in disk + try await diskStorage.store( + value: data, + forKey: key, + expiration: expiration + ) + } + + /// Retrieves an image from cache (first checks memory, then disk) + public func retrieveImage(forKey key: String) async throws -> Data? { + // Check memory cache first + if let memoryData = memoryStorage.value(forKey: key) { + return memoryData + } + + // If not in memory, check disk + let diskData = try await diskStorage.value(forKey: key) + + // If found in disk, store in memory for next time + if let diskData = diskData { + memoryStorage.store( + value: diskData, + forKey: key, + expiration: .days(7) + ) + } + + return diskData + } + + /// Removes an image from both memory and disk cache + public func removeImage(forKey key: String) async throws { + // Remove from memory + memoryStorage.remove(forKey: key) + + // Remove from disk + try await diskStorage.remove(forKey: key) + } + + /// Clears all cached images from both memory and disk + public func clearCache() async throws { + // Clear memory + memoryStorage.removeAll() + + // Clear disk + try await diskStorage.removeAll() + } + + /// Checks if an image exists in cache (either memory or disk) + public func isCached(forKey key: String) async -> Bool { + if memoryStorage.isCached(forKey: key) { + return true + } + return await diskStorage.isCached(forKey: key) + } +} + +// MARK: - Memory Storage +private final class MemoryStorage { + private let cache = NSCache() + private let lock = NSLock() + + struct Config { + let totalCostLimit: Int + } + + init(config: Config) { + cache.totalCostLimit = config.totalCostLimit + } + + func store(value: Data, forKey key: String, expiration: StorageExpiration?) { + lock.lock() + defer { lock.unlock() } + cache.setObject(value as NSData, forKey: key as NSString) + } + + func value(forKey key: String) -> Data? { + lock.lock() + defer { lock.unlock() } + return cache.object(forKey: key as NSString) as Data? + } + + func remove(forKey key: String) { + lock.lock() + defer { lock.unlock() } + cache.removeObject(forKey: key as NSString) + } + + func removeAll() { + lock.lock() + defer { lock.unlock() } + cache.removeAllObjects() + } + + func isCached(forKey key: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return cache.object(forKey: key as NSString) != nil + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImage.swift new file mode 100644 index 00000000..08b22b80 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImage.swift @@ -0,0 +1,2 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Protocols/DataTransformable.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Protocols/DataTransformable.swift new file mode 100644 index 00000000..766b37b9 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Protocols/DataTransformable.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Sendable 프로토콜을 채택하여 동시성 환경에서 안전하게 사용 가능합니다. +public protocol DataTransformable: Sendable { + + /// Converts the current value to a `Data` representation. + /// - Returns: The data object which can represent the value of the conforming type. + /// - Throws: If any error happens during the conversion. + func toData() throws -> Data + + /// Convert some data to the value. + /// - Parameter data: The data object which should represent the conforming value. + /// - Returns: The converted value of the conforming type. + /// - Throws: If any error happens during the conversion. + static func fromData(_ data: Data) throws -> Self + + /// An empty object of `Self`. + /// + /// > In the cache, when the data is not actually loaded, this value will be returned as a placeholder. + /// > This variable should be returned quickly without any heavy operation inside. + static var empty: Self { get } +} diff --git a/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageTests.swift b/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageTests.swift new file mode 100644 index 00000000..cade6181 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import NeoImage + +final class NeoImageTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} From 3cf7d379286919c5518d44ede9f12e376c691459 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 23 Feb 2025 02:25:27 +0900 Subject: [PATCH 02/25] =?UTF-8?q?[Delete]=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20import=EB=AC=B8?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Feature/AddBookByTitle/View/Popup/AddBookConfirmView.swift | 1 - .../AddBookByTitle/View/Popup/AddBookConfirmViewController.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmView.swift b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmView.swift index a5a7edb7..451c3856 100644 --- a/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmView.swift +++ b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmView.swift @@ -6,7 +6,6 @@ // import DesignSystem -import Kingfisher import RxSwift import SnapKit import Then diff --git a/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmViewController.swift b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmViewController.swift index af42ba54..86028532 100644 --- a/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmViewController.swift +++ b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmViewController.swift @@ -6,7 +6,6 @@ // import DesignSystem -import Kingfisher import RxSwift import SnapKit import Then From cab7e9db3ec3f0649c24320cf5293f7b05a61cde Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 23 Feb 2025 03:05:38 +0900 Subject: [PATCH 03/25] =?UTF-8?q?[Refactor]=20=EB=8F=99=EC=8B=9C=EC=84=B1?= =?UTF-8?q?=20=EC=A0=9C=EC=96=B4=20=EA=B4=80=EB=A0=A8=20=EC=BB=B4=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemoryStorage -> MemoryStorageActor로 변경하여 내부 메서드가 직렬화 실행될 수 있도록 보장 - NSLock 객체 제거 --- .../Sources/NeoImage/Extensions/Data+.swift | 13 ++++ .../Sources/NeoImage/ImageCache.swift | 68 +++++++++---------- 2 files changed, 45 insertions(+), 36 deletions(-) create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Data+.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Data+.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Data+.swift new file mode 100644 index 00000000..799941f9 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Data+.swift @@ -0,0 +1,13 @@ +import Foundation + +extension Data: DataTransformable { + public func toData() throws -> Data { + self + } + + public static func fromData(_ data: Data) throws -> Data { + data + } + + public static let empty = Data() +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift index 99c783c9..f58ace44 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift @@ -1,18 +1,25 @@ import Foundation +/// 쓰기 제어와 같은 동시성이 필요한 부분만 선택적으로 제어하기 위해 전체 ImageCache를 actor로 변경하지 않고, ImageCacheActor 생성 +/// actor를 사용하면 모든 동작이 actor의 실행큐를 통과해야하기 때문에, 동시성 보호가 불필요한 read-only 동작도 직렬화되며 오버헤드가 발생 +@globalActor public actor ImageCacheActor { + public static let shared = ImageCacheActor() +} + public final class ImageCache { /// ERROR: Static property 'shared' is not concurrency-safe because non-'Sendable' type 'ImageCache' may have shared mutable state - /// /// ``` /// public static let shared = ImageCache() /// ``` /// Swift 6에서는 동시성 안정성 검사가 더욱 엄격해졌습니다. 이로 인해 여러 스레드에서 동시에 접근할 수 있는 공유 상태 (shared mutable state)인 싱글톤 패턴을 사용할 경우,위 에러가 발생합니다. /// 이는 별도의 가변 프로퍼티를 클래스 내부에 지니고 있지 않음에도 발생하는 에러입니다 - public static let shared = ImageCache() + /// 이를 해결하기 위해선, Actor를 사용하거나, Serial Queue를 사용해 동기화를 해줘야 합니다. + @ImageCacheActor + public static let shared = try! ImageCache(name: "default") // MARK: - Properties - private let memoryStorage: MemoryStorage + private let memoryStorage: MemoryStorageActor private let diskStorage: DiskStorage // MARK: - Initialization @@ -24,13 +31,11 @@ public final class ImageCache { // Memory cache configuration let totalMemory = ProcessInfo.processInfo.physicalMemory let memoryLimit = totalMemory / 4 - let memoryCacheConfig = MemoryStorage.Config( - totalCostLimit: (memoryLimit > Int.max) ? Int.max : Int(memoryLimit) - ) - self.memoryStorage = MemoryStorage(config: memoryCacheConfig) - + self.memoryStorage = MemoryStorageActor( + totalCostLimit: (memoryLimit > Int.max) ? Int.max : Int(memoryLimit) + ) // Disk cache configuration - let diskConfig = DiskStorage.Config( + let diskConfig = DiskStorage.Config( name: name, sizeLimit: 0, directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first @@ -39,15 +44,15 @@ public final class ImageCache { } // MARK: - Public Methods - /// Stores an image to both memory and disk cache + @ImageCacheActor public func store( _ data: Data, forKey key: String, expiration: StorageExpiration? = nil ) async throws { // Store in memory - memoryStorage.store(value: data, forKey: key, expiration: expiration) + await memoryStorage.store(value: data, forKey: key, expiration: expiration) // Store in disk try await diskStorage.store( @@ -58,9 +63,10 @@ public final class ImageCache { } /// Retrieves an image from cache (first checks memory, then disk) + @ImageCacheActor public func retrieveImage(forKey key: String) async throws -> Data? { // Check memory cache first - if let memoryData = memoryStorage.value(forKey: key) { + if let memoryData = await memoryStorage.value(forKey: key) { return memoryData } @@ -69,7 +75,7 @@ public final class ImageCache { // If found in disk, store in memory for next time if let diskData = diskData { - memoryStorage.store( + await memoryStorage.store( value: diskData, forKey: key, expiration: .days(7) @@ -80,26 +86,29 @@ public final class ImageCache { } /// Removes an image from both memory and disk cache + @ImageCacheActor public func removeImage(forKey key: String) async throws { // Remove from memory - memoryStorage.remove(forKey: key) + await memoryStorage.remove(forKey: key) // Remove from disk - try await diskStorage.remove(forKey: key) +// try await diskStorage.remove(forKey: key) } /// Clears all cached images from both memory and disk + @ImageCacheActor public func clearCache() async throws { // Clear memory - memoryStorage.removeAll() + await memoryStorage.removeAll() // Clear disk - try await diskStorage.removeAll() +// try await diskStorage.removeAll() } /// Checks if an image exists in cache (either memory or disk) + @ImageCacheActor public func isCached(forKey key: String) async -> Bool { - if memoryStorage.isCached(forKey: key) { + if await memoryStorage.isCached(forKey: key) { return true } return await diskStorage.isCached(forKey: key) @@ -107,45 +116,32 @@ public final class ImageCache { } // MARK: - Memory Storage -private final class MemoryStorage { +private actor MemoryStorageActor { private let cache = NSCache() - private let lock = NSLock() - - struct Config { - let totalCostLimit: Int - } + private let totalCostLimit: Int - init(config: Config) { - cache.totalCostLimit = config.totalCostLimit + init(totalCostLimit: Int) { + self.totalCostLimit = totalCostLimit + self.cache.totalCostLimit = totalCostLimit } func store(value: Data, forKey key: String, expiration: StorageExpiration?) { - lock.lock() - defer { lock.unlock() } cache.setObject(value as NSData, forKey: key as NSString) } func value(forKey key: String) -> Data? { - lock.lock() - defer { lock.unlock() } return cache.object(forKey: key as NSString) as Data? } func remove(forKey key: String) { - lock.lock() - defer { lock.unlock() } cache.removeObject(forKey: key as NSString) } func removeAll() { - lock.lock() - defer { lock.unlock() } cache.removeAllObjects() } func isCached(forKey key: String) -> Bool { - lock.lock() - defer { lock.unlock() } return cache.object(forKey: key as NSString) != nil } } From 2d105ab899ba67a937aa8ab61d014b744636d8e0 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 23 Feb 2025 03:30:19 +0900 Subject: [PATCH 04/25] =?UTF-8?q?[Refactor]=20Actor=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EA=B3=B3=20=EC=88=98=EC=A0=95=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/NeoImage/DiskStorage.swift | 23 ++++++++- .../Sources/NeoImage/ImageCache.swift | 49 +++++++++++-------- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift index 2eb6001d..85418e64 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift @@ -8,6 +8,7 @@ class DiskStorage: @unchecked Sendable { private let serialActor = Actor() private var storageReady: Bool = true + /// FileManager를 통해 디렉토리를 생성하는 과정에서 에러가 발생할 수 있기 때문에 인스턴스 생성 자체에서 throws 키워드를 기입해줍니다. init(config: Config) throws { /// 외부에서 주입된 디스크 저장소에 대한 설정값과 Creation 구조체로 생성된 디렉토리 URL와 cacheName을 생성 및 self.directoryURL에 저장합니다. self.config = config @@ -46,7 +47,6 @@ class DiskStorage: @unchecked Sendable { } } - func value( forKey key: String, /// 캐시의 키 extendingExpiration: ExpirationExtending = .cacheTime // 현재 Confiㅎ @@ -90,6 +90,27 @@ class DiskStorage: @unchecked Sendable { } } + /// 특정 키에 해당하는 파일을 삭제하는 메서드 + func remove(forKey key: String) async throws { + try await serialActor.run { + let fileURL = cacheFileURL(forKey: key) + if FileManager.default.fileExists(atPath: fileURL.path) { + try FileManager.default.removeItem(at: fileURL) + } + } + } + + /// 디렉토리 내의 모든 파일을 삭제하는 메서드 + func removeAll() async throws { + try await serialActor.run { + let fileManager = FileManager.default + let contents = try fileManager.contentsOfDirectory(at: directoryURL, includingPropertiesForKeys: nil, options: []) + for fileURL in contents { + try fileManager.removeItem(at: fileURL) + } + } + } + /// 캐시 확인 func isCached(forKey key: String) async -> Bool { let fileURL = cacheFileURL(forKey: key) diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift index f58ace44..82a6c1b7 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift @@ -28,52 +28,52 @@ public final class ImageCache { throw CacheError.invalidCacheKey } - // Memory cache configuration + /// 메모리 캐싱 관련 설정 과정입니다. + /// NSProcessInfo를 통해 총 메모리 크기를 접근한 후, 메모리 상한선을 전체 메모리의 1/4로 한정합니다. let totalMemory = ProcessInfo.processInfo.physicalMemory let memoryLimit = totalMemory / 4 self.memoryStorage = MemoryStorageActor( - totalCostLimit: (memoryLimit > Int.max) ? Int.max : Int(memoryLimit) - ) - // Disk cache configuration + totalCostLimit: min(Int.max, Int(memoryLimit)) + ) + + /// 디스크 캐시에 대한 설정을 여기서 정의해줍니다. let diskConfig = DiskStorage.Config( name: name, sizeLimit: 0, directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first ) + + /// 디스크 캐시 제어 관련 클래스 인스턴스 생성 self.diskStorage = try DiskStorage(config: diskConfig) } - // MARK: - Public Methods - /// Stores an image to both memory and disk cache + /// 메모리와 디스크 캐시에 모두 데이터를 저장합니다. @ImageCacheActor public func store( _ data: Data, forKey key: String, expiration: StorageExpiration? = nil ) async throws { - // Store in memory await memoryStorage.store(value: data, forKey: key, expiration: expiration) - // Store in disk try await diskStorage.store( value: data, forKey: key, expiration: expiration ) } - - /// Retrieves an image from cache (first checks memory, then disk) - @ImageCacheActor + /// 캐시로부터 저장된 이미지를 가져옵니다. + /// 1차적으로 오버헤드가 적은 메모리를 먼저 확인합니다. + /// 이후 메모리에 없을 경우, 디스크를 확인합니다. + /// 디스크에 없을 경우 throw합니다. + /// 디스크에 데이터를 확인할 경우, 다음 조회를 위해 해당 데이터를 메모리로 올립니다. public func retrieveImage(forKey key: String) async throws -> Data? { - // Check memory cache first if let memoryData = await memoryStorage.value(forKey: key) { return memoryData } - // If not in memory, check disk let diskData = try await diskStorage.value(forKey: key) - // If found in disk, store in memory for next time if let diskData = diskData { await memoryStorage.store( value: diskData, @@ -85,24 +85,22 @@ public final class ImageCache { return diskData } - /// Removes an image from both memory and disk cache + /// 메모리와 디스크 모두에서 특정 키에 해당하는 이미지 데이터를 제거합니다. @ImageCacheActor public func removeImage(forKey key: String) async throws { // Remove from memory await memoryStorage.remove(forKey: key) // Remove from disk -// try await diskStorage.remove(forKey: key) + try await diskStorage.remove(forKey: key) } - /// Clears all cached images from both memory and disk + /// 메모리와 디스크 모두에 존재하는 모든 데이터를 제거합니다. @ImageCacheActor public func clearCache() async throws { - // Clear memory await memoryStorage.removeAll() - // Clear disk -// try await diskStorage.removeAll() + try await diskStorage.removeAll() } /// Checks if an image exists in cache (either memory or disk) @@ -111,36 +109,45 @@ public final class ImageCache { if await memoryStorage.isCached(forKey: key) { return true } + return await diskStorage.isCached(forKey: key) } } -// MARK: - Memory Storage +// MARK: - 메모리 영역 제어를 위한 actor입니다. + private actor MemoryStorageActor { + /// 캐시는 NSCache로 접근합니다. private let cache = NSCache() private let totalCostLimit: Int init(totalCostLimit: Int) { + /// 메모리가 사용할 수 있는 공간 상한선 (ImageCache 클래스에서 총 메모리공간의 1/4로 주입하고 있음) 데이터를 아래 private 속성에 주입시킵니다. self.totalCostLimit = totalCostLimit self.cache.totalCostLimit = totalCostLimit } + /// 캐시에 저장 func store(value: Data, forKey key: String, expiration: StorageExpiration?) { cache.setObject(value as NSData, forKey: key as NSString) } + /// 캐시에서 조회 func value(forKey key: String) -> Data? { return cache.object(forKey: key as NSString) as Data? } + /// 캐시에서 제거 func remove(forKey key: String) { cache.removeObject(forKey: key as NSString) } + /// 캐시에서 일괄 제거 func removeAll() { cache.removeAllObjects() } + /// 캐시에서 있는지 여부를 조회 func isCached(forKey key: String) -> Bool { return cache.object(forKey: key as NSString) != nil } From 722a7060b3d1dad4f95f8f47948465a6fd2294ba Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:00:52 +0900 Subject: [PATCH 05/25] =?UTF-8?q?[Feat]=20ImageProcessor=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/NeoImage/ImageCache.swift | 4 +- .../Sources/NeoImage/ImageProcesser.swift | 151 ++++++++++++++++++ .../Sources/NeoImage/ImageTaskState.swift | 136 ++++++++++++++++ 3 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift index 82a6c1b7..71605e36 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift @@ -6,7 +6,7 @@ import Foundation public static let shared = ImageCacheActor() } -public final class ImageCache { +public final class ImageCache: @unchecked Sendable { /// ERROR: Static property 'shared' is not concurrency-safe because non-'Sendable' type 'ImageCache' may have shared mutable state /// ``` @@ -88,10 +88,8 @@ public final class ImageCache { /// 메모리와 디스크 모두에서 특정 키에 해당하는 이미지 데이터를 제거합니다. @ImageCacheActor public func removeImage(forKey key: String) async throws { - // Remove from memory await memoryStorage.remove(forKey: key) - // Remove from disk try await diskStorage.remove(forKey: key) } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift new file mode 100644 index 00000000..7551e671 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift @@ -0,0 +1,151 @@ +// +// ImageProcessing.swift +// NeoImage +// +// Created by Neoself on 2/23/25. +// + + +import UIKit + +/// 이미지 처리를 위한 프로토콜 +public protocol ImageProcessing: Sendable { + /// 이미지를 처리하는 메서드 + func process(_ image: UIImage) async throws -> UIImage + + /// 프로세서의 식별자 + /// 캐시 키 생성에 사용됨 + var identifier: String { get } +} + +/// 이미지 리사이징 프로세서 +public struct ResizingImageProcessor: ImageProcessing { + /// 대상 크기 + public let targetSize: CGSize + + /// 크기 조정 모드 + public let contentMode: UIView.ContentMode + + /// 크기 조정 시 필터링 방식 + public let filteringAlgorithm: FilteringAlgorithm + + public init( + targetSize: CGSize, + contentMode: UIView.ContentMode = .scaleToFill, + filteringAlgorithm: FilteringAlgorithm = .linear + ) { + self.targetSize = targetSize + self.contentMode = contentMode + self.filteringAlgorithm = filteringAlgorithm + } + + public func process(_ image: UIImage) async throws -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = image.scale + + let size = calculateTargetSize(image.size) + let renderer = UIGraphicsImageRenderer(size: size, format: format) + + return renderer.image { context in + image.draw(in: CGRect(origin: .zero, size: size)) + } + } + + private func calculateTargetSize(_ originalSize: CGSize) -> CGSize { + switch contentMode { + case .scaleToFill: + return targetSize + + case .scaleAspectFit: + let widthRatio = targetSize.width / originalSize.width + let heightRatio = targetSize.height / originalSize.height + let ratio = min(widthRatio, heightRatio) + return CGSize( + width: originalSize.width * ratio, + height: originalSize.height * ratio + ) + + case .scaleAspectFill: + let widthRatio = targetSize.width / originalSize.width + let heightRatio = targetSize.height / originalSize.height + let ratio = max(widthRatio, heightRatio) + return CGSize( + width: originalSize.width * ratio, + height: originalSize.height * ratio + ) + + default: + return targetSize + } + } + + public var identifier: String { + let contentModeString: String = { + switch contentMode { + case .scaleToFill: return "ScaleToFill" + case .scaleAspectFit: return "ScaleAspectFit" + case .scaleAspectFill: return "ScaleAspectFill" + default: return "Unknown" + } + }() + + return "com.neoimage.ResizingImageProcessor(\(targetSize),\(contentModeString))" + } + + public enum FilteringAlgorithm { + case none + case linear + case trilinear + } +} + +/// 둥근 모서리 처리를 위한 프로세서 +public struct RoundCornerImageProcessor: ImageProcessing { + /// 모서리 반경 + public let radius: CGFloat + + public init(radius: CGFloat) { + self.radius = radius + } + + public func process(_ image: UIImage) async throws -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = image.scale + + let renderer = UIGraphicsImageRenderer(size: image.size, format: format) + return renderer.image { context in + let rect = CGRect(origin: .zero, size: image.size) + let path = UIBezierPath(roundedRect: rect, cornerRadius: radius) + + context.cgContext.addPath(path.cgPath) + context.cgContext.clip() + + image.draw(in: rect) + } + } + + public var identifier: String { + return "com.neoimage.RoundCornerImageProcessor(\(radius))" + } +} + +/// 여러 프로세서를 순차적으로 적용하는 프로세서 +public struct ChainImageProcessor: ImageProcessing { + let processors: [ImageProcessing] + + public init(_ processors: [ImageProcessing]) { + self.processors = processors + } + + public func process(_ image: UIImage) async throws -> UIImage { + var processedImage = image + for processor in processors { + processedImage = try await processor.process(processedImage) + } + return processedImage + } + + public var identifier: String { + return processors.map { $0.identifier }.joined(separator: "|") + } +} \ No newline at end of file diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift new file mode 100644 index 00000000..a96a09cb --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift @@ -0,0 +1,136 @@ +import Foundation + +/// 이미지 다운로드 작업의 상태를 나타내는 열거형 +public enum ImageTaskState: Int, Sendable { + /// 대기 중 + case pending = 0 + /// 다운로드 중 + case downloading + /// 취소됨 + case cancelled + /// 완료됨 + case completed + /// 실패 + case failed +} + +/// 이미지 다운로드 작업을 관리하는 클래스 +public final class ImageTask: @unchecked Sendable { + + // MARK: - Properties + + /// 현재 작업의 상태 + @ImageCacheActor + public private(set) var state: ImageTaskState = .pending + + /// 다운로드 진행률 + @ImageCacheActor + public private(set) var progress: Float = 0 + + /// 작업 시작 시간 + @ImageCacheActor + public private(set) var startTime: Date? + + /// 작업 완료 시간 + @ImageCacheActor + public private(set) var endTime: Date? + + /// 취소 여부 + @ImageCacheActor + public private(set) var isCancelled: Bool = false + + /// 다운로드된 데이터 크기 + @ImageCacheActor + public private(set) var downloadedDataSize: Int64 = 0 + + /// 전체 데이터 크기 + @ImageCacheActor + public private(set) var totalDataSize: Int64 = 0 + + // MARK: - Initializer + + public init() {} + + // MARK: - Public Methods + + /// 작업 취소 + @ImageCacheActor + public func cancel() { + guard state == .pending || state == .downloading else { return } + state = .cancelled + isCancelled = true + endTime = Date() + } + + /// 작업 시작 + @ImageCacheActor + public func start() { + guard state == .pending else { return } + state = .downloading + startTime = Date() + } + + /// 작업 완료 + @ImageCacheActor + public func complete() { + guard state == .downloading else { return } + state = .completed + endTime = Date() + } + + /// 작업 실패 + @ImageCacheActor + public func fail() { + guard state != .completed && state != .cancelled else { return } + state = .failed + endTime = Date() + } + + /// 진행률 업데이트 + @ImageCacheActor + public func updateProgress(downloaded: Int64, total: Int64) { + downloadedDataSize = downloaded + totalDataSize = total + progress = total > 0 ? Float(downloaded) / Float(total) : 0 + } +} + +// MARK: - CustomStringConvertible + +extension ImageTask: CustomStringConvertible { + public var description: String { +// "ImageTask(state: \(state), progress: \(progress))" + "ImageTask(state, progress: )" + } +} + +// MARK: - Hashable + +extension ImageTask: Hashable { + public static func == (lhs: ImageTask, rhs: ImageTask) -> Bool { + ObjectIdentifier(lhs) == ObjectIdentifier(rhs) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +// MARK: - Convenience Properties + +extension ImageTask { + /// 작업 소요 시간 (밀리초) + @ImageCacheActor + public var duration: TimeInterval? { + guard let start = startTime else { return nil } + let end = endTime ?? Date() + return end.timeIntervalSince(start) + } + + /// 다운로드 속도 (bytes/second) + @ImageCacheActor + public var downloadSpeed: Double? { + guard let duration = duration, duration > 0 else { return nil } + return Double(downloadedDataSize) / duration + } +} From 5e825833074ead9217e111fae69159269ef9e4e2 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:01:13 +0900 Subject: [PATCH 06/25] =?UTF-8?q?[Feat]=20ImageWrapper=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/NeoImage/NeoImageOptions.swift | 67 +++++++ .../Sources/NeoImage/NeoImageWrapper.swift | 169 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageOptions.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageOptions.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageOptions.swift new file mode 100644 index 00000000..9f08ec71 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageOptions.swift @@ -0,0 +1,67 @@ +// +// NeoImageOptions.swift +// NeoImage +// +// Created by Neoself on 2/23/25. +// + + +import UIKit + +/// 이미지 다운로드 및 처리에 관한 옵션을 정의하는 구조체 +public struct NeoImageOptions: Sendable { + /// 이미지 프로세서 + public let processor: ImageProcessing? + + /// 이미지 전환 효과 + public let transition: ImageTransition + + /// 다시 시도 전략 + public let retryStrategy: RetryStrategy + + /// 캐시 만료 정책 + public let cacheExpiration: StorageExpiration + + public init( + processor: ImageProcessing? = nil, + transition: ImageTransition = .none, + retryStrategy: RetryStrategy = .none, + cacheExpiration: StorageExpiration = .days(7) + ) { + self.processor = processor + self.transition = transition + self.retryStrategy = retryStrategy + self.cacheExpiration = cacheExpiration + } +} + +/// 이미지 전환 효과 열거형 +public enum ImageTransition: Sendable { + /// 전환 효과 없음 + case none + /// 페이드 인 효과 + case fade(TimeInterval) + /// 플립 효과 + case flip(TimeInterval) +} + +/// 재시도 전략 열거형 +public enum RetryStrategy: Sendable { + /// 재시도 하지 않음 + case none + /// 지정된 횟수만큼 재시도 + case times(Int) + /// 지정된 횟수와 대기 시간으로 재시도 + case timesWithDelay(times: Int, delay: TimeInterval) +} + +extension NeoImageOptions { + /// 기본 옵션 (프로세서 없음, 전환 효과 없음, 재시도 없음, 7일 캐시) + public static let `default` = NeoImageOptions() + + /// 페이드 인 효과가 있는 옵션 + public static let fade = NeoImageOptions(transition: .fade(0.3)) + + /// 재시도가 있는 옵션 + public static let retry = NeoImageOptions(retryStrategy: .times(3)) +} \ No newline at end of file diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift new file mode 100644 index 00000000..500f1db5 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift @@ -0,0 +1,169 @@ +import UIKit + +// MARK: - Wrapper & Associated Object Key +nonisolated(unsafe) private var associatedImageTaskKey = "com.neoimage.UIImageView.ImageTask" + +/// NeoImage 기능에 접근하기 위한 네임스페이스 역할을 하는 wrapper 구조체 +public struct NeoImageWrapper { + public let base: Base + public init(_ base: Base) { + self.base = base + } +} + +/// NeoImage의 기능을 제공받을 수 있는 타입들이 준수해야 하는 프로토콜 +public protocol NeoImageCompatible: AnyObject { } + +extension NeoImageCompatible { + /// neo 네임스페이스를 통해 NeoImage의 기능에 접근 + public var neo: NeoImageWrapper { + get { return NeoImageWrapper(self) } + set { } + } +} + +extension UIImageView: NeoImageCompatible { } + +// MARK: - UIImageView Extension + +extension NeoImageWrapper where Base: UIImageView { + @discardableResult + public func setImage( + with url: URL?, + placeholder: UIImage? = nil, + options: NeoImageOptions? = nil, + progressBlock: ((Int64, Int64) -> Void)? = nil, + completion: ((Result) -> Void)? = nil + ) async -> ImageTask? { + guard let url = url else { + await MainActor.run { + base.image = placeholder + } + completion?(.failure(CacheError.invalidData)) + return nil + } + + // task 관리를 위한 로컬 변수 + let task = ImageTask() + + return await Task { [weak base] in + guard let base = base else { return nil } + + // 기존 task가 있다면 취소 + await self.cancelDownloadTask() + + // placeholder 설정 + if let placeholder = placeholder { + await MainActor.run { + base.image = placeholder + } + } + + await self.setImageDownloadTask(task) + + do { + let result = try await ImageDownloadManager.shared.downloadImage(with: url) + + try Task.checkCancellation() + + // 이미지 처리 (백그라운드에서 수행) + let processedImage = try await self.processImage(result.image, options: options) + + try Task.checkCancellation() + + // 캐시에 저장 (백그라운드에서 수행) + if let data = processedImage.jpegData(compressionQuality: 0.8) { + try await ImageCache.shared.store(data, forKey: url.absoluteString) + } + + try Task.checkCancellation() + + // UI 업데이트는 메인 스레드에서 + await MainActor.run { + base.image = processedImage + + // transition 효과 적용 + if let transition = options?.transition { + self.applyTransition(transition) + } + } + + let finalResult = ImageLoadingResult( + image: processedImage, + url: url, + originalData: result.originalData + ) + + completion?(.success(finalResult)) + } catch is CancellationError { + completion?(.failure(CacheError.unknown(CancellationError()))) + } catch { + completion?(.failure(error)) + } + + return task + }.value + } + + private func processImage(_ image: UIImage, options: NeoImageOptions?) async throws -> UIImage { + // 이미지 프로세서가 있다면 처리 (백그라운드에서 수행) + if let processor = options?.processor { + return try await processor.process(image) + } + + return image + } + + @MainActor + private func applyTransition(_ transition: ImageTransition) { + switch transition { + case .none: + break + + case .fade(let duration): + UIView.transition( + with: base, + duration: duration, + options: .transitionCrossDissolve, + animations: nil, + completion: nil + ) + + case .flip(let duration): + UIView.transition( + with: base, + duration: duration, + options: .transitionFlipFromLeft, + animations: nil, + completion: nil + ) + } + } + + // MARK: - Task Management + + private func setImageDownloadTask(_ task: ImageTask?) async { + await MainActor.run { + objc_setAssociatedObject( + base, + &associatedImageTaskKey, + task, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private func getImageDownloadTask() async -> ImageTask? { + await MainActor.run { + objc_getAssociatedObject(base, &associatedImageTaskKey) as? ImageTask + } + } + + private func cancelDownloadTask() async { + if let task = await getImageDownloadTask() { + await task.cancel() + await setImageDownloadTask(nil) + } + } +} + From daddb3189cfae67c55f8c6ba00a68b0120cd7a3c Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:01:23 +0900 Subject: [PATCH 07/25] =?UTF-8?q?[Feat]=20ImageDownloader=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Networking/ImageDownloadManager.swift | 108 ++++++++++++++++++ .../NeoImage/Networking/SessionDelegate.swift | 75 ++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift new file mode 100644 index 00000000..fb48b434 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift @@ -0,0 +1,108 @@ +import Foundation +import UIKit + +/// 이미지 다운로드 결과 구조체 (스레드 안전) +public struct ImageLoadingResult: Sendable { + public let image: UIImage + public let url: URL? + public let originalData: Data +} + +/// 이미지 다운로드 관리 액터 (동시성 제어) +public actor ImageDownloadManager { + + // MARK: - 싱글톤 & 초기화 + public static let shared = ImageDownloadManager() + private var session: URLSession + private let sessionDelegate = SessionDelegate() + + private init() { + let config = URLSessionConfiguration.ephemeral + session = URLSession(configuration: config, delegate: sessionDelegate, delegateQueue: nil) + setupDelegates() + } + + // MARK: - 핵심 다운로드 메서드 (kf.setImage에서 사용) + /// 이미지 비동기 다운로드 (async/await) + public func downloadImage(with url: URL) async throws -> ImageLoadingResult { + let request = URLRequest(url: url) + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200..<400).contains(httpResponse.statusCode) else { +// throw CacheError.invalidHTTPStatusCode + throw CacheError.invalidData + } + + guard let image = UIImage(data: data) else { +// throw KingfisherError.imageMappingError + throw CacheError.dataToImageConversionFailed + } + + return ImageLoadingResult(image: image, url: url, originalData: data) + } + + /// URL 기반 다운로드 취소 + public func cancelDownload(for url: URL) { + sessionDelegate.cancelTasks(for: url) + } + + /// 전체 다운로드 취소 + public func cancelAllDownloads() { + sessionDelegate.cancelAllTasks() + } +} + +// MARK: - 내부 세션 관리 확장 +private extension ImageDownloadManager { + /// actor의 상태를 직접 변경하지 않고 클로저를 설정하는 것이기에 nonisolated를 기입하여, 해당 메서드가 actor의 격리된 상태에 접근하지 않음을 알려줌 + nonisolated func setupDelegates() { + sessionDelegate.onReceiveChallenge = { [weak self] challenge in + guard let self else {return (.performDefaultHandling, nil)} + return await handleAuthChallenge(challenge) + } + + sessionDelegate.onValidateStatusCode = { code in + (200..<400).contains(code) + } + } + + /// 인증 처리 핸들러 + func handleAuthChallenge(_ challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + guard let trust = challenge.protectionSpace.serverTrust else { + return (.cancelAuthenticationChallenge, nil) + } + return (.useCredential, URLCredential(trust: trust)) + } +} + +// MARK: - 세션 델리게이트 구현 (간소화 버전) +private class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { + var onReceiveChallenge: ((URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?))? + var onValidateStatusCode: ((Int) -> Bool)? + + private var tasks = [URL: URLSessionTask]() + + func cancelTasks(for url: URL) { + tasks[url]?.cancel() + tasks[url] = nil + } + + func cancelAllTasks() { + tasks.values.forEach { $0.cancel() } + tasks.removeAll() + } + + // 필수 델리게이트 메서드만 구현 + func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await onReceiveChallenge?(challenge) ?? (.performDefaultHandling, nil) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse) async -> URLSession.ResponseDisposition { + guard let httpResponse = response as? HTTPURLResponse, + onValidateStatusCode?(httpResponse.statusCode) == true else { + return .cancel + } + return .allow + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift new file mode 100644 index 00000000..79c02f18 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift @@ -0,0 +1,75 @@ +import Foundation + +/// 다운로드 세션을 위한 델리게이트 클래스 +/// URLSession의 이벤트를 처리하고 다운로드 작업을 관리합니다. +/// URLSessionDataDelegate는 @objc 프로토콜이며, NSObjectProtocol을 채택하는 URLSessionDelegate를 상속합니다. 따라서, 해당 Protocol을 채택하는 가장 간단한 방법은 NSObject를 상속하는 것입니다. +/// 하지만, Actor는 상속이 불가능하기 때문에, actor를 통해 직렬화를 보장받는 대신 NSLock을 사용해 동시성 업데이트 문제를 방지하고 있습니다. +/// 기존 Objective-C/NSObject 기반 시스템과의 호환성도 유지할 수 있는 적절한 선택 +private class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { + + // MARK: - 프로퍼티 + + /// 인증 챌린지 처리를 위한 핸들러 + var onReceiveChallenge: ((URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?))? + + /// HTTP 상태 코드 검증을 위한 핸들러 + var onValidateStatusCode: ((Int) -> Bool)? + + /// 실행 중인 다운로드 작업을 추적하기 위한 딕셔너리 + private var tasks = [URL: URLSessionTask]() + private let lock = NSLock() + + // MARK: - 작업 관리 메서드 + + /// 특정 URL에 대한 다운로드 작업 취소 + func cancelTasks(for url: URL) { + lock.lock() + defer { lock.unlock() } + + tasks[url]?.cancel() + tasks[url] = nil + } + + /// 모든 다운로드 작업 취소 + func cancelAllTasks() { + lock.lock() + defer { lock.unlock() } + + tasks.values.forEach { $0.cancel() } + tasks.removeAll() + } + + // MARK: - URLSessionDataDelegate 메서드 + + /// 서버 인증 챌린지 처리 + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await onReceiveChallenge?(challenge) ?? (.performDefaultHandling, nil) + } + + /// 서버 응답 검증 및 처리 + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse + ) async -> URLSession.ResponseDisposition { + // HTTP 응답 및 상태 코드 검증 + guard let httpResponse = response as? HTTPURLResponse, + onValidateStatusCode?(httpResponse.statusCode) == true else { + return .cancel + } + return .allow + } + + /// 데이터 수신 처리 (필요한 경우 구현) + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + // kf.setImage의 기본 구현에서는 특별한 처리가 필요 없음 + } +} From 852c52cc8d02d3c270c0337edf1dca0dfb8f2c04 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Mon, 24 Feb 2025 18:38:42 +0900 Subject: [PATCH 08/25] =?UTF-8?q?[Fix]=20Task-isolated=20options=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20=EC=9E=84=EC=8B=9C=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NeoImage/Constants/TimeConstants.swift | 4 + .../Sources/NeoImage/ImageProcesser.swift | 32 +-- .../Sources/NeoImage/ImageTaskState.swift | 65 ++---- .../Sources/NeoImage/NeoImageWrapper.swift | 212 +++++++++++------- .../Networking/ImageDownloadManager.swift | 2 +- .../NeoImage/Networking/SessionDelegate.swift | 75 ------- 6 files changed, 170 insertions(+), 220 deletions(-) delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift index 9708b120..5e9381c7 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift @@ -3,3 +3,7 @@ struct TimeConstants { /// also known as static let secondsInOneDay = 86_400 } + +struct ImageTaskKey { + static let associatedKey = "com.neoimage.UIImageView.ImageTask" +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift index 7551e671..ffa319e6 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift @@ -1,13 +1,11 @@ -// -// ImageProcessing.swift -// NeoImage -// -// Created by Neoself on 2/23/25. -// - - import UIKit +public enum FilteringAlgorithm: Sendable { + case none + case linear + case trilinear +} + /// 이미지 처리를 위한 프로토콜 public protocol ImageProcessing: Sendable { /// 이미지를 처리하는 메서드 @@ -21,13 +19,13 @@ public protocol ImageProcessing: Sendable { /// 이미지 리사이징 프로세서 public struct ResizingImageProcessor: ImageProcessing { /// 대상 크기 - public let targetSize: CGSize + private let targetSize: CGSize /// 크기 조정 모드 - public let contentMode: UIView.ContentMode + private let contentMode: UIView.ContentMode /// 크기 조정 시 필터링 방식 - public let filteringAlgorithm: FilteringAlgorithm + private let filteringAlgorithm: FilteringAlgorithm public init( targetSize: CGSize, @@ -91,18 +89,12 @@ public struct ResizingImageProcessor: ImageProcessing { return "com.neoimage.ResizingImageProcessor(\(targetSize),\(contentModeString))" } - - public enum FilteringAlgorithm { - case none - case linear - case trilinear - } } /// 둥근 모서리 처리를 위한 프로세서 public struct RoundCornerImageProcessor: ImageProcessing { /// 모서리 반경 - public let radius: CGFloat + private let radius: CGFloat public init(radius: CGFloat) { self.radius = radius @@ -131,7 +123,7 @@ public struct RoundCornerImageProcessor: ImageProcessing { /// 여러 프로세서를 순차적으로 적용하는 프로세서 public struct ChainImageProcessor: ImageProcessing { - let processors: [ImageProcessing] + private let processors: [ImageProcessing] public init(_ processors: [ImageProcessing]) { self.processors = processors @@ -148,4 +140,4 @@ public struct ChainImageProcessor: ImageProcessing { public var identifier: String { return processors.map { $0.identifier }.joined(separator: "|") } -} \ No newline at end of file +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift index a96a09cb..aaf60af5 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift @@ -14,47 +14,39 @@ public enum ImageTaskState: Int, Sendable { case failed } -/// 이미지 다운로드 작업을 관리하는 클래스 -public final class ImageTask: @unchecked Sendable { +/// 이미지 다운로드 작업을 관리하는 actor +public actor ImageTask: Sendable { // MARK: - Properties /// 현재 작업의 상태 - @ImageCacheActor public private(set) var state: ImageTaskState = .pending /// 다운로드 진행률 - @ImageCacheActor public private(set) var progress: Float = 0 /// 작업 시작 시간 - @ImageCacheActor public private(set) var startTime: Date? /// 작업 완료 시간 - @ImageCacheActor public private(set) var endTime: Date? /// 취소 여부 - @ImageCacheActor public private(set) var isCancelled: Bool = false /// 다운로드된 데이터 크기 - @ImageCacheActor public private(set) var downloadedDataSize: Int64 = 0 /// 전체 데이터 크기 - @ImageCacheActor public private(set) var totalDataSize: Int64 = 0 // MARK: - Initializer public init() {} - // MARK: - Public Methods + // MARK: - Task Management Methods /// 작업 취소 - @ImageCacheActor public func cancel() { guard state == .pending || state == .downloading else { return } state = .cancelled @@ -63,7 +55,6 @@ public final class ImageTask: @unchecked Sendable { } /// 작업 시작 - @ImageCacheActor public func start() { guard state == .pending else { return } state = .downloading @@ -71,7 +62,6 @@ public final class ImageTask: @unchecked Sendable { } /// 작업 완료 - @ImageCacheActor public func complete() { guard state == .downloading else { return } state = .completed @@ -79,7 +69,6 @@ public final class ImageTask: @unchecked Sendable { } /// 작업 실패 - @ImageCacheActor public func fail() { guard state != .completed && state != .cancelled else { return } state = .failed @@ -87,40 +76,15 @@ public final class ImageTask: @unchecked Sendable { } /// 진행률 업데이트 - @ImageCacheActor public func updateProgress(downloaded: Int64, total: Int64) { downloadedDataSize = downloaded totalDataSize = total progress = total > 0 ? Float(downloaded) / Float(total) : 0 } -} - -// MARK: - CustomStringConvertible - -extension ImageTask: CustomStringConvertible { - public var description: String { -// "ImageTask(state: \(state), progress: \(progress))" - "ImageTask(state, progress: )" - } -} - -// MARK: - Hashable - -extension ImageTask: Hashable { - public static func == (lhs: ImageTask, rhs: ImageTask) -> Bool { - ObjectIdentifier(lhs) == ObjectIdentifier(rhs) - } - public func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) - } -} - -// MARK: - Convenience Properties - -extension ImageTask { + // MARK: - Convenience Properties + /// 작업 소요 시간 (밀리초) - @ImageCacheActor public var duration: TimeInterval? { guard let start = startTime else { return nil } let end = endTime ?? Date() @@ -128,9 +92,26 @@ extension ImageTask { } /// 다운로드 속도 (bytes/second) - @ImageCacheActor public var downloadSpeed: Double? { guard let duration = duration, duration > 0 else { return nil } return Double(downloadedDataSize) / duration } + + // MARK: - CustomStringConvertible Implementation + + public nonisolated var description: String { + "ImageTask" // Note: actor의 격리된 상태에 접근하지 않기 위해 간단한 description 사용 + } +} + +// MARK: - Hashable Implementation + +extension ImageTask: Hashable { + public static func == (lhs: ImageTask, rhs: ImageTask) -> Bool { + ObjectIdentifier(lhs) == ObjectIdentifier(rhs) + } + + public nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift index 500f1db5..9c68ef11 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift @@ -1,112 +1,144 @@ import UIKit // MARK: - Wrapper & Associated Object Key -nonisolated(unsafe) private var associatedImageTaskKey = "com.neoimage.UIImageView.ImageTask" -/// NeoImage 기능에 접근하기 위한 네임스페이스 역할을 하는 wrapper 구조체 -public struct NeoImageWrapper { - public let base: Base - public init(_ base: Base) { - self.base = base - } -} +/// UIImageView가 NeoImage의 기능을 제공받을 수 있는 NeoImageCompatible 프로토콜을 채택할 수 있음을 명시합니다. +extension UIImageView: NeoImageCompatible { } -/// NeoImage의 기능을 제공받을 수 있는 타입들이 준수해야 하는 프로토콜 public protocol NeoImageCompatible: AnyObject { } extension NeoImageCompatible { - /// neo 네임스페이스를 통해 NeoImage의 기능에 접근 + /// neo 네임스페이스를 통해 NeoImage의 기능에 접근할 수 있습니다. public var neo: NeoImageWrapper { get { return NeoImageWrapper(self) } set { } } } -extension UIImageView: NeoImageCompatible { } +/// NeoImage 기능에 접근하기 위한 네임스페이스 역할을 하는 wrapper 구조체 +public struct NeoImageWrapper { + public let base: Base + /// 여기서 Base는 이미지 캐시 및 이미지 데이터가 주입되는 UIImageView를 의미합니다. + public init(_ base: Base) { + self.base = base + } +} // MARK: - UIImageView Extension extension NeoImageWrapper where Base: UIImageView { - @discardableResult - public func setImage( + @discardableResult /// Return type을 strict하게 확인하지 않습니다. + private func setImageAsync( with url: URL?, placeholder: UIImage? = nil, - options: NeoImageOptions? = nil, - progressBlock: ((Int64, Int64) -> Void)? = nil, - completion: ((Result) -> Void)? = nil - ) async -> ImageTask? { - guard let url = url else { - await MainActor.run { - base.image = placeholder - } - completion?(.failure(CacheError.invalidData)) - return nil - } + options: NeoImageOptions? = nil + ) async throws -> (ImageLoadingResult, ImageTask?) { + /// 초기에 외부에서 주입받은 UIImageView 컴포넌트입니다. + let baseView = base - // task 관리를 위한 로컬 변수 - let task = ImageTask() + /// Data race 상황이 발생하지 않게끔 현재 컨텍스트를 imageWrapper에 캡처합니다. + let imageWrapper = self - return await Task { [weak base] in - guard let base = base else { return nil } + /// 메인 스레드에 실행 + return try await Task { @MainActor in + guard let url = url else { + baseView.image = placeholder + throw CacheError.invalidData + } - // 기존 task가 있다면 취소 - await self.cancelDownloadTask() + guard baseView.window != nil else { + throw CacheError.invalidData + } - // placeholder 설정 if let placeholder = placeholder { - await MainActor.run { - base.image = placeholder - } + /// 우선 ImageView에 placeholder를 주입합니다. + baseView.image = placeholder } - await self.setImageDownloadTask(task) + /// 현재 컨텍스트에서 발생하고 있는 다운로드 작업을 취소하여 초기화합니다. + await imageWrapper.cancelDownloadTask() - do { - let result = try await ImageDownloadManager.shared.downloadImage(with: url) - - try Task.checkCancellation() - - // 이미지 처리 (백그라운드에서 수행) - let processedImage = try await self.processImage(result.image, options: options) - - try Task.checkCancellation() - - // 캐시에 저장 (백그라운드에서 수행) - if let data = processedImage.jpegData(compressionQuality: 0.8) { - try await ImageCache.shared.store(data, forKey: url.absoluteString) - } - - try Task.checkCancellation() - - // UI 업데이트는 메인 스레드에서 - await MainActor.run { - base.image = processedImage - - // transition 효과 적용 - if let transition = options?.transition { - self.applyTransition(transition) - } - } - - let finalResult = ImageLoadingResult( - image: processedImage, - url: url, - originalData: result.originalData - ) - - completion?(.success(finalResult)) - } catch is CancellationError { - completion?(.failure(CacheError.unknown(CancellationError()))) - } catch { - completion?(.failure(error)) + /// 이미지 다운로드 작업을 관리하는 클래스를 새로 생성합니다. + let task = ImageTask() + + await imageWrapper.setImageDownloadTask(task) + + /// 이미지 다운로드 + let result = try await ImageDownloadManager.shared.downloadImage(with: url) + /// 도중에 Task가 취소된 경우 에러를 throw하도록 합니다. + try Task.checkCancellation() + + /// 이미지 리사이징 + let processedImage = try await imageWrapper.processImage(result.image, options: options) + try Task.checkCancellation() + + /// jpegData가 존재할 경우, 이를 바로 이미지 캐시(메모리 & 디스크)에 저장 및 보관합니다. + if let data = processedImage.jpegData(compressionQuality: 0.8) { + try await ImageCache.shared.store(data, forKey: url.absoluteString) } - return task + try Task.checkCancellation() + + /// 리사이즈를 거친 최종 이미지 데이터를 UIImageView의 image 속성에 주입시켜 이미지를 렌더하도록 합니다. + baseView.image = processedImage + + /// Transition 존재할 경우, 그대로 UIImageView에 적용 + if let transition = options?.transition { + imageWrapper.applyTransition(transition) + } + + let finalResult = ImageLoadingResult( + image: processedImage, + url: url, + originalData: result.originalData + ) + + return (finalResult, task) }.value } + // MARK: - Public Async API + + /// async/await 패턴이 적용된 환경에서 사용가능한 래퍼 메서드입니다. + public func setImage( + with url: URL?, + placeholder: UIImage? = nil, + options: NeoImageOptions? = nil + ) async throws -> ImageLoadingResult { + let (result, _) = try await setImageAsync( + with: url, + placeholder: placeholder, + options: options + ) + + return result + } + + // MARK: - Public Completion Handler API + + @discardableResult + public func setImage( + with url: URL?, + placeholder: UIImage? = nil, + options: NeoImageOptions? = nil, + completion: ((Result) -> Void)? = nil + ) async -> ImageTask? { + do { + let (result, task) = try await setImageAsync( + with: url, + placeholder: placeholder, + options: options + ) + + completion?(.success(result)) + return task + } catch { + completion?(.failure(error)) + return nil + } + } + private func processImage(_ image: UIImage, options: NeoImageOptions?) async throws -> UIImage { - // 이미지 프로세서가 있다면 처리 (백그라운드에서 수행) if let processor = options?.processor { return try await processor.process(image) } @@ -119,7 +151,6 @@ extension NeoImageWrapper where Base: UIImageView { switch transition { case .none: break - case .fade(let duration): UIView.transition( with: base, @@ -128,7 +159,6 @@ extension NeoImageWrapper where Base: UIImageView { animations: nil, completion: nil ) - case .flip(let duration): UIView.transition( with: base, @@ -142,20 +172,38 @@ extension NeoImageWrapper where Base: UIImageView { // MARK: - Task Management + /// UIImageView는 기본적으로 ImageTask를 저장할 프로퍼티가 없습니다. + /// + /// 따라서, Objective-C의 런타임 기능을 사용해 UIImageView 인스턴스에 ImageTask를 동적으로 연결하여 저장합니다, + /// 현재 진행중인 이미지 다운로드 작업 추적에 사용됩니다. private func setImageDownloadTask(_ task: ImageTask?) async { await MainActor.run { + /// 모든 NSObject의 하위 클래스에 대해 사용할 수 있는 메서드이며, SWift에서는 @obj 마킹이 된 클래스도 대상으로 설정이 가능합니다. + /// 순수 Swift 타입인 struct와 enum, class에는 사용이 불가하기 때문에, NSObject를 상속하거나 @objc 속성을 사용해야 합니다. + /// - `UIView` 및 모든 하위 클래스 + /// - UIViewController 및 모든 하위 클래스 + /// - UIApplication + /// - UIGestureRecognizer + /// Foundation 클래스들 + /// - `NSString` + /// - NSArray + /// - NSDictionary + /// - URLSession + objc_setAssociatedObject( - base, - &associatedImageTaskKey, - task, - .OBJC_ASSOCIATION_RETAIN_NONATOMIC + base, // 대상 객체 (UIImageView) + ImageTaskKey.associatedKey, // 키 값 + task, // 저장할 값 + .OBJC_ASSOCIATION_RETAIN_NONATOMIC // 메모리 관리 정책 ) } } + /// UIImageView에 연결된 ImageTask를 가져옵니다 + /// 현재 진행 중인 다운로드 작업이 있는지 확인하는데 사용됩니다 private func getImageDownloadTask() async -> ImageTask? { await MainActor.run { - objc_getAssociatedObject(base, &associatedImageTaskKey) as? ImageTask + objc_getAssociatedObject(base, ImageTaskKey.associatedKey) as? ImageTask } } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift index fb48b434..66f3ed17 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift @@ -1,7 +1,7 @@ import Foundation import UIKit -/// 이미지 다운로드 결과 구조체 (스레드 안전) +/// 이미지 다운로드 결과 구조체 public struct ImageLoadingResult: Sendable { public let image: UIImage public let url: URL? diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift deleted file mode 100644 index 79c02f18..00000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation - -/// 다운로드 세션을 위한 델리게이트 클래스 -/// URLSession의 이벤트를 처리하고 다운로드 작업을 관리합니다. -/// URLSessionDataDelegate는 @objc 프로토콜이며, NSObjectProtocol을 채택하는 URLSessionDelegate를 상속합니다. 따라서, 해당 Protocol을 채택하는 가장 간단한 방법은 NSObject를 상속하는 것입니다. -/// 하지만, Actor는 상속이 불가능하기 때문에, actor를 통해 직렬화를 보장받는 대신 NSLock을 사용해 동시성 업데이트 문제를 방지하고 있습니다. -/// 기존 Objective-C/NSObject 기반 시스템과의 호환성도 유지할 수 있는 적절한 선택 -private class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { - - // MARK: - 프로퍼티 - - /// 인증 챌린지 처리를 위한 핸들러 - var onReceiveChallenge: ((URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?))? - - /// HTTP 상태 코드 검증을 위한 핸들러 - var onValidateStatusCode: ((Int) -> Bool)? - - /// 실행 중인 다운로드 작업을 추적하기 위한 딕셔너리 - private var tasks = [URL: URLSessionTask]() - private let lock = NSLock() - - // MARK: - 작업 관리 메서드 - - /// 특정 URL에 대한 다운로드 작업 취소 - func cancelTasks(for url: URL) { - lock.lock() - defer { lock.unlock() } - - tasks[url]?.cancel() - tasks[url] = nil - } - - /// 모든 다운로드 작업 취소 - func cancelAllTasks() { - lock.lock() - defer { lock.unlock() } - - tasks.values.forEach { $0.cancel() } - tasks.removeAll() - } - - // MARK: - URLSessionDataDelegate 메서드 - - /// 서버 인증 챌린지 처리 - func urlSession( - _ session: URLSession, - task: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge - ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveChallenge?(challenge) ?? (.performDefaultHandling, nil) - } - - /// 서버 응답 검증 및 처리 - func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive response: URLResponse - ) async -> URLSession.ResponseDisposition { - // HTTP 응답 및 상태 코드 검증 - guard let httpResponse = response as? HTTPURLResponse, - onValidateStatusCode?(httpResponse.statusCode) == true else { - return .cancel - } - return .allow - } - - /// 데이터 수신 처리 (필요한 경우 구현) - func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive data: Data - ) { - // kf.setImage의 기본 구현에서는 특별한 처리가 필요 없음 - } -} From 152b503e3fb1eb91c339a3874431b3022dcee228 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Mon, 24 Feb 2025 20:41:10 +0900 Subject: [PATCH 09/25] =?UTF-8?q?[Fix]=20Concurrency=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/NeoImage/ImageCache.swift | 1 + .../Sources/NeoImage/NeoImageWrapper.swift | 207 ++++++++---------- 2 files changed, 98 insertions(+), 110 deletions(-) diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift index 71605e36..b02d4981 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift @@ -62,6 +62,7 @@ public final class ImageCache: @unchecked Sendable { expiration: expiration ) } + /// 캐시로부터 저장된 이미지를 가져옵니다. /// 1차적으로 오버헤드가 적은 메모리를 먼저 확인합니다. /// 이후 메모리에 없을 경우, 디스크를 확인합니다. diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift index 9c68ef11..5eb078ca 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift @@ -24,77 +24,102 @@ public struct NeoImageWrapper { } } + + // MARK: - UIImageView Extension extension NeoImageWrapper where Base: UIImageView { + @discardableResult /// Return type을 strict하게 확인하지 않습니다. private func setImageAsync( with url: URL?, placeholder: UIImage? = nil, options: NeoImageOptions? = nil ) async throws -> (ImageLoadingResult, ImageTask?) { - /// 초기에 외부에서 주입받은 UIImageView 컴포넌트입니다. - let baseView = base - - /// Data race 상황이 발생하지 않게끔 현재 컨텍스트를 imageWrapper에 캡처합니다. - let imageWrapper = self + /// 이미지뷰가 실제로 화면에 표시되어 있는지 여부 파악, + /// 이는 Swift 6로 오면서 비동기 작업으로 간주되기 시작함. + guard await base.window != nil else { + throw CacheError.invalidData + } - /// 메인 스레드에 실행 - return try await Task { @MainActor in - guard let url = url else { - baseView.image = placeholder - throw CacheError.invalidData - } - - guard baseView.window != nil else { - throw CacheError.invalidData + guard let url = url else { + await MainActor.run { [weak base] in + guard let base else { return } + base.image = placeholder } - if let placeholder = placeholder { - /// 우선 ImageView에 placeholder를 주입합니다. - baseView.image = placeholder - } - - /// 현재 컨텍스트에서 발생하고 있는 다운로드 작업을 취소하여 초기화합니다. - await imageWrapper.cancelDownloadTask() - - /// 이미지 다운로드 작업을 관리하는 클래스를 새로 생성합니다. - let task = ImageTask() - - await imageWrapper.setImageDownloadTask(task) - - /// 이미지 다운로드 - let result = try await ImageDownloadManager.shared.downloadImage(with: url) - /// 도중에 Task가 취소된 경우 에러를 throw하도록 합니다. - try Task.checkCancellation() - - /// 이미지 리사이징 - let processedImage = try await imageWrapper.processImage(result.image, options: options) - try Task.checkCancellation() - - /// jpegData가 존재할 경우, 이를 바로 이미지 캐시(메모리 & 디스크)에 저장 및 보관합니다. - if let data = processedImage.jpegData(compressionQuality: 0.8) { - try await ImageCache.shared.store(data, forKey: url.absoluteString) + throw CacheError.invalidData + } + + // placeholder 먼저 설정 + if let placeholder = placeholder { + await MainActor.run { [weak base] in + guard let base else {return} + base.image = placeholder } + } + + /// UIImageView에 연결된 ImageTask를 가져옵니다 + /// 현재 진행 중인 다운로드 작업이 있는지 확인하는데 사용됩니다 + if let task = objc_getAssociatedObject(base, ImageTaskKey.associatedKey) as? ImageTask { + await task.cancel() + await setImageDownloadTask(nil) + } + + let imageTask = ImageTask() + + await setImageDownloadTask(imageTask) + + let downloadResult = try await ImageDownloadManager.shared.downloadImage(with: url) + + try Task.checkCancellation() + + let processedImage = try await processImage(downloadResult.image, options: options) + try Task.checkCancellation() + + /// 캐시 저장 + if let data = processedImage.jpegData(compressionQuality: 0.8){ + try await ImageCache.shared.store(data, forKey: url.absoluteString) + } + + /// 최종 UI 업데이트 + await MainActor.run { [weak base] in + guard let base else { return } - try Task.checkCancellation() - - /// 리사이즈를 거친 최종 이미지 데이터를 UIImageView의 image 속성에 주입시켜 이미지를 렌더하도록 합니다. - baseView.image = processedImage + base.image = processedImage - /// Transition 존재할 경우, 그대로 UIImageView에 적용 if let transition = options?.transition { - imageWrapper.applyTransition(transition) + switch transition { + case .none: + break + case .fade(let duration): + UIView.transition( + with: base, + duration: duration, + options: .transitionCrossDissolve, + animations: nil, + completion: nil + ) + case .flip(let duration): + UIView.transition( + with: base, + duration: duration, + options: .transitionFlipFromLeft, + animations: nil, + completion: nil + ) + } } - - let finalResult = ImageLoadingResult( + } + + return ( + ImageLoadingResult( image: processedImage, url: url, - originalData: result.originalData - ) - - return (finalResult, task) - }.value + originalData: downloadResult.originalData + ), + imageTask + ) } // MARK: - Public Async API @@ -146,29 +171,6 @@ extension NeoImageWrapper where Base: UIImageView { return image } - @MainActor - private func applyTransition(_ transition: ImageTransition) { - switch transition { - case .none: - break - case .fade(let duration): - UIView.transition( - with: base, - duration: duration, - options: .transitionCrossDissolve, - animations: nil, - completion: nil - ) - case .flip(let duration): - UIView.transition( - with: base, - duration: duration, - options: .transitionFlipFromLeft, - animations: nil, - completion: nil - ) - } - } // MARK: - Task Management @@ -177,41 +179,26 @@ extension NeoImageWrapper where Base: UIImageView { /// 따라서, Objective-C의 런타임 기능을 사용해 UIImageView 인스턴스에 ImageTask를 동적으로 연결하여 저장합니다, /// 현재 진행중인 이미지 다운로드 작업 추적에 사용됩니다. private func setImageDownloadTask(_ task: ImageTask?) async { - await MainActor.run { - /// 모든 NSObject의 하위 클래스에 대해 사용할 수 있는 메서드이며, SWift에서는 @obj 마킹이 된 클래스도 대상으로 설정이 가능합니다. - /// 순수 Swift 타입인 struct와 enum, class에는 사용이 불가하기 때문에, NSObject를 상속하거나 @objc 속성을 사용해야 합니다. - /// - `UIView` 및 모든 하위 클래스 - /// - UIViewController 및 모든 하위 클래스 - /// - UIApplication - /// - UIGestureRecognizer - /// Foundation 클래스들 - /// - `NSString` - /// - NSArray - /// - NSDictionary - /// - URLSession - - objc_setAssociatedObject( - base, // 대상 객체 (UIImageView) - ImageTaskKey.associatedKey, // 키 값 - task, // 저장할 값 - .OBJC_ASSOCIATION_RETAIN_NONATOMIC // 메모리 관리 정책 - ) - } - } - - /// UIImageView에 연결된 ImageTask를 가져옵니다 - /// 현재 진행 중인 다운로드 작업이 있는지 확인하는데 사용됩니다 - private func getImageDownloadTask() async -> ImageTask? { - await MainActor.run { - objc_getAssociatedObject(base, ImageTaskKey.associatedKey) as? ImageTask - } - } - - private func cancelDownloadTask() async { - if let task = await getImageDownloadTask() { - await task.cancel() - await setImageDownloadTask(nil) - } + + /// 모든 NSObject의 하위 클래스에 대해 사용할 수 있는 메서드이며, SWift에서는 @obj 마킹이 된 클래스도 대상으로 설정이 가능합니다. + /// 순수 Swift 타입인 struct와 enum, class에는 사용이 불가하기 때문에, NSObject를 상속하거나 @objc 속성을 사용해야 합니다. + /// - `UIView` 및 모든 하위 클래스 + /// - UIViewController 및 모든 하위 클래스 + /// - UIApplication + /// - UIGestureRecognizer + /// Foundation 클래스들 + /// - `NSString` + /// - NSArray + /// - NSDictionary + /// - URLSession + + objc_setAssociatedObject( + base, // 대상 객체 (UIImageView) + ImageTaskKey.associatedKey, // 키 값 + task, // 저장할 값 + .OBJC_ASSOCIATION_RETAIN_NONATOMIC // 메모리 관리 정책 + ) + } } From c1006f635d4c4ce1fb5d4667ad651a61eee7604a Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Mon, 24 Feb 2025 21:34:40 +0900 Subject: [PATCH 10/25] =?UTF-8?q?[Fix]=20NeoImageWrapper=20=EC=9E=90?= =?UTF-8?q?=EC=B2=B4=EB=A5=BC=20Sendable=ED=95=98=EA=B2=8C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=98=EC=97=AC=20=ED=95=B4=EA=B2=B0=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/NeoImage/NeoImageWrapper.swift | 38 ++++++++++--------- .../View/MyLibraryCollectionViewCell.swift | 11 ++++-- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift index 5eb078ca..1f370f45 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift @@ -9,14 +9,14 @@ public protocol NeoImageCompatible: AnyObject { } extension NeoImageCompatible { /// neo 네임스페이스를 통해 NeoImage의 기능에 접근할 수 있습니다. - public var neo: NeoImageWrapper { - get { return NeoImageWrapper(self) } + public var neo: NeoImageWrapper { + get { return NeoImageWrapper(self as! UIImageView) } set { } } } /// NeoImage 기능에 접근하기 위한 네임스페이스 역할을 하는 wrapper 구조체 -public struct NeoImageWrapper { +public struct NeoImageWrapper: Sendable { public let base: Base /// 여기서 Base는 이미지 캐시 및 이미지 데이터가 주입되는 UIImageView를 의미합니다. public init(_ base: Base) { @@ -146,21 +146,25 @@ extension NeoImageWrapper where Base: UIImageView { with url: URL?, placeholder: UIImage? = nil, options: NeoImageOptions? = nil, - completion: ((Result) -> Void)? = nil - ) async -> ImageTask? { - do { - let (result, task) = try await setImageAsync( - with: url, - placeholder: placeholder, - options: options - ) - - completion?(.success(result)) - return task - } catch { - completion?(.failure(error)) - return nil + completion: (@MainActor @Sendable (Result) -> Void)? = nil + ) -> ImageTask? { + let task = ImageTask() + + Task { @MainActor in + do { + let (result, downloadTask) = try await setImageAsync( + with: url, + placeholder: placeholder, + options: options + ) + completion?(.success(result)) + } catch { + await task.fail() + completion?(.failure(error)) + } } + + return task } private func processImage(_ image: UIImage, options: NeoImageOptions?) async throws -> UIImage { diff --git a/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift b/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift index e1408700..d3eabd88 100644 --- a/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift +++ b/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift @@ -6,7 +6,8 @@ // import DesignSystem -import Kingfisher +//import Kingfisher +import NeoImage import SnapKit import UIKit @@ -42,8 +43,12 @@ final class MyLibraryCollectionViewCell: UICollectionViewCell { // MARK: - Functions // TODO: 고도화 필요 - func configureCell(imageUrl: URL?) { - cellImageView.kf.setImage(with: imageUrl) + func configureCell(imageUrl: URL?) async { + do { + try await cellImageView.neo.setImage(with: imageUrl) + } catch { + print("error") + } } private func configureHierarchy() { From ee1273c540a5c841d24cface6a0cbf8ee8d58059 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Mon, 24 Feb 2025 23:01:50 +0900 Subject: [PATCH 11/25] =?UTF-8?q?[Refactor]=20ImageCache=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EA=B5=AC=ED=98=84=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BookKitty/BookKitty/NeoImage/Package.swift | 6 +- .../NeoImage/Constants/CacheError.swift | 36 ++-- .../Constants/ExpirationExtending.swift | 22 +- .../NeoImage/Constants/TimeConstants.swift | 8 +- .../Sources/NeoImage/DiskStorage.swift | 196 ++++++++++-------- .../Sources/NeoImage/Extensions/Date+.swift | 2 +- .../Sources/NeoImage/Extensions/String+.swift | 10 +- .../Sources/NeoImage/ImageCache.swift | 94 +++++---- .../Sources/NeoImage/ImageProcesser.swift | 104 ++++++---- .../Sources/NeoImage/ImageTaskState.swift | 103 +++++---- .../Sources/NeoImage/NeoImageOptions.swift | 19 +- .../Sources/NeoImage/NeoImageWrapper.swift | 3 +- .../Networking/ImageDownloadManager.swift | 80 ++++--- .../Protocols/DataTransformable.swift | 8 +- .../Tests/NeoImageTests/NeoImageTests.swift | 2 +- .../View/MyLibraryCollectionViewCell.swift | 9 +- 16 files changed, 412 insertions(+), 290 deletions(-) diff --git a/BookKitty/BookKitty/NeoImage/Package.swift b/BookKitty/BookKitty/NeoImage/Package.swift index ee79c13a..71cfcaaa 100644 --- a/BookKitty/BookKitty/NeoImage/Package.swift +++ b/BookKitty/BookKitty/NeoImage/Package.swift @@ -9,11 +9,13 @@ let package = Package( products: [ .library( name: "NeoImage", - targets: ["NeoImage"]), + targets: ["NeoImage"] + ), ], targets: [ .target( - name: "NeoImage"), + name: "NeoImage" + ), .testTarget( name: "NeoImageTests", dependencies: ["NeoImage"] diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift index c4f7f324..0bc2b101 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift @@ -4,24 +4,26 @@ enum CacheError: Error { case invalidImage case dataToImageConversionFailed case imageToDataConversionFailed - + // 저장소 관련 에러 case diskStorageError(Error) case memoryStorageError(Error) case storageNotReady - + // 파일 관련 에러 - case fileNotFound(String) // key + case fileNotFound(String) // key case cannotCreateDirectory(Error) case cannotWriteToFile(Error) case cannotReadFromFile(Error) - - // 캐시 키 관련 에러 + + /// 캐시 키 관련 에러 case invalidCacheKey - - // 기타 + + /// 기타 case unknown(Error) - + + // MARK: - Computed Properties + var localizedDescription: String { switch self { case .invalidData: @@ -32,27 +34,23 @@ enum CacheError: Error { return "Failed to convert data to image" case .imageToDataConversionFailed: return "Failed to convert image to data" - - case .diskStorageError(let error): + case let .diskStorageError(error): return "Disk storage error: \(error.localizedDescription)" - case .memoryStorageError(let error): + case let .memoryStorageError(error): return "Memory storage error: \(error.localizedDescription)" case .storageNotReady: return "The storage is not ready" - - case .fileNotFound(let key): + case let .fileNotFound(key): return "File not found for key: \(key)" - case .cannotCreateDirectory(let error): + case let .cannotCreateDirectory(error): return "Cannot create directory: \(error.localizedDescription)" - case .cannotWriteToFile(let error): + case let .cannotWriteToFile(error): return "Cannot write to file: \(error.localizedDescription)" - case .cannotReadFromFile(let error): + case let .cannotReadFromFile(error): return "Cannot read from file: \(error.localizedDescription)" - case .invalidCacheKey: return "The cache key is invalid" - - case .unknown(let error): + case let .unknown(error): return "Unknown error: \(error.localizedDescription)" } } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/ExpirationExtending.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/ExpirationExtending.swift index 73445347..27a2b600 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/ExpirationExtending.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/ExpirationExtending.swift @@ -3,38 +3,40 @@ import Foundation public enum StorageExpiration: Equatable, Sendable { /// 초 단위로 만료 시간 지정 case seconds(TimeInterval) - + /// 일 단위로 만료 시간 지정 case days(Int) - + /// 영구 저장 (만료되지 않음) case never - + + // MARK: - Computed Properties + var estimatedExpirationSinceNow: Date { let timeInterval: TimeInterval switch self { - case .seconds(let seconds): + case let .seconds(seconds): timeInterval = seconds - case .days(let days): - timeInterval = TimeInterval(86400 * days) // 86400 = 24 * 60 * 60 + case let .days(days): + timeInterval = TimeInterval(86400 * days) // 86400 = 24 * 60 * 60 case .never: return .distantFuture } return Date().addingTimeInterval(timeInterval) } - + var isExpired: Bool { - return estimatedExpirationSinceNow.isPast + estimatedExpirationSinceNow.isPast } } public enum ExpirationExtending: Equatable, Sendable { /// 만료 시간을 연장하지 않음 case none - + /// 현재 캐시 설정의 만료 시간만큼 연장 case cacheTime - + /// 지정된 만료 시간으로 연장 case expirationTime(StorageExpiration) } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift index 5e9381c7..6fb4806a 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift @@ -1,9 +1,9 @@ -struct TimeConstants { - // Seconds in a day, a.k.a 86,400s, roughly. +enum TimeConstants { + /// Seconds in a day, a.k.a 86,400s, roughly. /// also known as - static let secondsInOneDay = 86_400 + static let secondsInOneDay = 86400 } -struct ImageTaskKey { +enum ImageTaskKey { static let associatedKey = "com.neoimage.UIImageView.ImageTask" } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift index 85418e64..e75387da 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift @@ -1,71 +1,81 @@ import Foundation class DiskStorage: @unchecked Sendable { + // MARK: - Properties + private let config: Config - + private let directoryURL: URL - + private let serialActor = Actor() - private var storageReady: Bool = true - + private var storageReady = true + + // MARK: - Lifecycle + /// FileManager를 통해 디렉토리를 생성하는 과정에서 에러가 발생할 수 있기 때문에 인스턴스 생성 자체에서 throws 키워드를 기입해줍니다. init(config: Config) throws { - /// 외부에서 주입된 디스크 저장소에 대한 설정값과 Creation 구조체로 생성된 디렉토리 URL와 cacheName을 생성 및 self.directoryURL에 저장합니다. + // 외부에서 주입된 디스크 저장소에 대한 설정값과 Creation 구조체로 생성된 디렉토리 URL와 cacheName을 생성 및 self.directoryURL에 + // 저장합니다. self.config = config let creation = Creation(config) - self.directoryURL = creation.directoryURL + directoryURL = creation.directoryURL try prepareDirectory() } - - func store(value: T, forKey key: String, expiration: StorageExpiration? = nil ) async throws { + + // MARK: - Functions + + func store(value: T, forKey key: String, expiration: StorageExpiration? = nil) async throws { guard let data = try? value.toData() else { throw CacheError.invalidData } - /// Disk에 대한 접근이 패키지 외부에서 동시에 이루어질 경우, 동일한 위치에 다른 데이터가 덮어씌워지는 data race 상황이 됩니다. 이를 방지하고자, 기존 Kingfisher에서는 DispatchQueue를 통해 직렬화 큐를 구현한 후, store(Write), value(Read)를 직렬화 큐에 전송하여 순차적인 실행이 보장되게 하였습니다. - /// 이를 Swift Concurrency로 변경하고자, 동일한 직렬화 기능을 수행하는 Actor 클래스로 대체하였습니다. + // Disk에 대한 접근이 패키지 외부에서 동시에 이루어질 경우, 동일한 위치에 다른 데이터가 덮어씌워지는 data race 상황이 됩니다. 이를 방지하고자, 기존 + // Kingfisher에서는 DispatchQueue를 통해 직렬화 큐를 구현한 후, store(Write), value(Read)를 직렬화 큐에 전송하여 + // 순차적인 + // 실행이 보장되게 하였습니다. + // 이를 Swift Concurrency로 변경하고자, 동일한 직렬화 기능을 수행하는 Actor 클래스로 대체하였습니다. try await serialActor.run { - /// 별도로 메서드를 통해 기한을 전달하지 않으면, 기본값으로 config.expiration인 7일로 정의합니다. + // 별도로 메서드를 통해 기한을 전달하지 않으면, 기본값으로 config.expiration인 7일로 정의합니다. let expiration = expiration ?? self.config.expiration let fileURL = self.cacheFileURL(forKey: key) - /// Foundation 내부 Data 타입의 내장 메서드입니다. - /// 해당 위치로 data 내부 컨텐츠를 write 합니다. + // Foundation 내부 Data 타입의 내장 메서드입니다. + // 해당 위치로 data 내부 컨텐츠를 write 합니다. try data.write(to: fileURL) - - /// FileManager를 통해 파일 작성 시 전달해줄 파일의 속성입니다. - /// 생성된 날짜, 수정된 일자를 실제 수정된 시간이 아닌, 만료 예정 시간을 저장하는 용도로 재활용합니다. - /// 실제로, 파일 시스템의 기본속성을 활용하기에 추가적인 저장공간이 필요 없음 - /// 파일과 만료 정보가 항상 동기화되어 있음 (파일이 삭제되면 만료 정보도 자동으로 삭제) + + // FileManager를 통해 파일 작성 시 전달해줄 파일의 속성입니다. + // 생성된 날짜, 수정된 일자를 실제 수정된 시간이 아닌, 만료 예정 시간을 저장하는 용도로 재활용합니다. + // 실제로, 파일 시스템의 기본속성을 활용하기에 추가적인 저장공간이 필요 없음 + // 파일과 만료 정보가 항상 동기화되어 있음 (파일이 삭제되면 만료 정보도 자동으로 삭제) let attributes: [FileAttributeKey: Any] = [ .creationDate: Date(), - .modificationDate: expiration.estimatedExpirationSinceNow + .modificationDate: expiration.estimatedExpirationSinceNow, ] - - /// 파일의 메타데이터가 업데이트됨 - /// 이는 디스크에 대한 I/O 작업을 수반 - /// 파일의 내용은 변경되지 않고 속성만 변경 + + // 파일의 메타데이터가 업데이트됨 + // 이는 디스크에 대한 I/O 작업을 수반 + // 파일의 내용은 변경되지 않고 속성만 변경 try FileManager.default.setAttributes(attributes, ofItemAtPath: fileURL.path) } } - + func value( - forKey key: String, /// 캐시의 키 + forKey key: String, // 캐시의 키 extendingExpiration: ExpirationExtending = .cacheTime // 현재 Confiㅎ ) async throws -> T? { - return try await serialActor.run { () -> T? in - /// 주어진 키에 대한 캐시 파일 URL을 생성 + try await serialActor.run { () -> T? in + // 주어진 키에 대한 캐시 파일 URL을 생성 let fileURL = cacheFileURL(forKey: key) guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil } - - /// 파일에서 데이터를 읽어옴 + + // 파일에서 데이터를 읽어옴 let data = try Data(contentsOf: fileURL) - /// DataTransformable 프로토콜의 fromData를 사용해 원본 타입으로 변환 + // DataTransformable 프로토콜의 fromData를 사용해 원본 타입으로 변환 let obj = try T.fromData(data) - - /// 해당 파일이 조회되었기 때문에, 만료 시간 연장을 처리합니다. - /// "캐시 적중(Cache Hit)"이 발생했을 때 해당 데이터의 생명주기를 연장하는 일반적인 캐시 전략입니다. - /// LRU(Least Recently Used) + + // 해당 파일이 조회되었기 때문에, 만료 시간 연장을 처리합니다. + // "캐시 적중(Cache Hit)"이 발생했을 때 해당 데이터의 생명주기를 연장하는 일반적인 캐시 전략입니다. + // LRU(Least Recently Used) if extendingExpiration != .none { let expirationDate: Date switch extendingExpiration { @@ -73,23 +83,23 @@ class DiskStorage: @unchecked Sendable { return obj case .cacheTime: expirationDate = config.expiration.estimatedExpirationSinceNow - ///.expirationTime: 지정된 새로운 만료 시간으로 연장 - case .expirationTime(let storageExpiration): + // .expirationTime: 지정된 새로운 만료 시간으로 연장 + case let .expirationTime(storageExpiration): expirationDate = storageExpiration.estimatedExpirationSinceNow } - + let attributes: [FileAttributeKey: Any] = [ .creationDate: Date(), - .modificationDate: expirationDate + .modificationDate: expirationDate, ] - + try FileManager.default.setAttributes(attributes, ofItemAtPath: fileURL.path) } - + return obj } } - + /// 특정 키에 해당하는 파일을 삭제하는 메서드 func remove(forKey key: String) async throws { try await serialActor.run { @@ -99,18 +109,22 @@ class DiskStorage: @unchecked Sendable { } } } - + /// 디렉토리 내의 모든 파일을 삭제하는 메서드 func removeAll() async throws { try await serialActor.run { let fileManager = FileManager.default - let contents = try fileManager.contentsOfDirectory(at: directoryURL, includingPropertiesForKeys: nil, options: []) + let contents = try fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil, + options: [] + ) for fileURL in contents { try fileManager.removeItem(at: fileURL) } } } - + /// 캐시 확인 func isCached(forKey key: String) async -> Bool { let fileURL = cacheFileURL(forKey: key) @@ -123,11 +137,12 @@ class DiskStorage: @unchecked Sendable { extension DiskStorage { private func cacheFileURL(forKey key: String, forcedExtension: String? = nil) -> URL { let fileName = cacheFileName(forKey: key, forcedExtension: forcedExtension) - + return directoryURL.appendingPathComponent(fileName, isDirectory: false) } - - /// 사전에 패키지에서 설정된 Config 구조체를 통해 파일명을 해시화하기로 설정했는지 여부, 임의로 전달된 접미사 단어 유무에 따라 캐시될때 저장될 파일명을 변환하여 반환해줍니다. + + /// 사전에 패키지에서 설정된 Config 구조체를 통해 파일명을 해시화하기로 설정했는지 여부, 임의로 전달된 접미사 단어 유무에 따라 캐시될때 저장될 파일명을 변환하여 + /// 반환해줍니다. private func cacheFileName(forKey key: String, forcedExtension: String? = nil) -> String { if config.usesHashedFileName { let hashedKey = key.sha256 @@ -136,34 +151,37 @@ extension DiskStorage { } return hashedKey } else { - if let ext = forcedExtension ?? config.pathExtension { return "\(key).\(ext)" } - /// 해시화 설정을 false로 하고, pathExtension에 별도 조작을 하지 않을 경우, - /// key를 그대로 반환하는 경우도 있습니다. + // 해시화 설정을 false로 하고, pathExtension에 별도 조작을 하지 않을 경우, + // key를 그대로 반환하는 경우도 있습니다. return key } } - + private func prepareDirectory() throws { - /// config에 custom fileManager를 주입할 수 있기 때문에, 여기서 .default를 접근하지 않고 Config 내부 fileManager를 접근합니다. + // config에 custom fileManager를 주입할 수 있기 때문에, 여기서 .default를 접근하지 않고 Config 내부 fileManager를 + // 접근합니다. let fileManager = config.fileManager let path = directoryURL.path - - /// Creation 구조체를 통해 생성된 url이 FileSystem에 존재하는지 검증 - guard !fileManager.fileExists(atPath: path) else { return } - + + // Creation 구조체를 통해 생성된 url이 FileSystem에 존재하는지 검증 + guard !fileManager.fileExists(atPath: path) else { + return + } + do { - /// FileManager를 통해 해당 path에 디렉토리 생성 + // FileManager를 통해 해당 path에 디렉토리 생성 try fileManager.createDirectory( atPath: path, withIntermediateDirectories: true, - attributes: nil) + attributes: nil + ) } catch { - /// 만일 디렉토리 생성이 실패할경우, storageReady를 false로 변경합니다. - /// 이는 추후 flag로 동작합니다. - self.storageReady = false + // 만일 디렉토리 생성이 실패할경우, storageReady를 false로 변경합니다. + // 이는 추후 flag로 동작합니다. + storageReady = false throw CacheError.cannotCreateDirectory(error) } } @@ -175,7 +193,7 @@ actor Actor { func run(_ operation: @Sendable () throws -> T) throws -> T { try operation() } - + func run(_ operation: @Sendable () -> T) -> T { operation() } @@ -184,64 +202,76 @@ actor Actor { extension DiskStorage { /// Represents the configuration used in a ``DiskStorage/Backend``. public struct Config: @unchecked Sendable { + // MARK: - Properties /// The file size limit on disk of the storage in bytes. /// `0` means no limit. public var sizeLimit: UInt /// The `StorageExpiration` used in this disk storage. - /// The default is `.days(7)`, which means that the disk cache will expire in one week if not accessed anymore. - public var expiration: StorageExpiration = .days(7) + /// The default is `.days(7)`, which means that the disk cache will expire in one week if + /// not accessed anymore. + public var expiration = StorageExpiration.days(7) - /// The preferred extension of the cache item. It will be appended to the file name as its extension. + /// The preferred extension of the cache item. It will be appended to the file name as its + /// extension. /// The default is `nil`, which means that the cache file does not contain a file extension. - public var pathExtension: String? = nil + public var pathExtension: String? /// Whether the cache file name will be hashed before storing. /// - /// The default is `true`, which means that file name is hashed to protect user information (for example, the + /// The default is `true`, which means that file name is hashed to protect user information + /// (for example, the /// original download URL which is used as the cache key). public var usesHashedFileName = true - - /// Whether the image extension will be extracted from the original file name and appended to the hashed file + /// Whether the image extension will be extracted from the original file name and appended + /// to the hashed file /// name, which will be used as the cache key on disk. /// /// The default is `false`. public var autoExtAfterHashedFileName = false - - /// A closure that takes in the initial directory path and generates the final disk cache path. + + /// A closure that takes in the initial directory path and generates the final disk cache + /// path. /// /// You can use it to fully customize your cache path. public var cachePathBlock: (@Sendable (_ directory: URL, _ cacheName: String) -> URL)! = { - (directory, cacheName) in - return directory.appendingPathComponent(cacheName, isDirectory: true) + directory, cacheName in + directory.appendingPathComponent(cacheName, isDirectory: true) } /// The desired name of the disk cache. /// /// This name will be used as a part of the cache folder name by default. public let name: String - + let fileManager: FileManager let directory: URL? + // MARK: - Lifecycle + /// Creates a config value based on the given parameters. /// /// - Parameters: - /// - name: The name of the cache. It is used as part of the storage folder and to identify the disk storage. - /// Two storages with the same `name` would share the same folder on the disk, and this should be prevented. + /// - name: The name of the cache. It is used as part of the storage folder and to + /// identify the disk storage. + /// Two storages with the same `name` would share the same folder on the disk, and this + /// should be prevented. /// - sizeLimit: The size limit in bytes for all existing files in the disk storage. - /// - fileManager: The `FileManager` used to manipulate files on the disk. The default is `FileManager.default`. - /// - directory: The URL where the disk storage should reside. The storage will use this as the root folder, - /// and append a path that is constructed by the input `name`. The default is `nil`, indicating that + /// - fileManager: The `FileManager` used to manipulate files on the disk. The default is + /// `FileManager.default`. + /// - directory: The URL where the disk storage should reside. The storage will use this + /// as the root folder, + /// and append a path that is constructed by the input `name`. The default is `nil`, + /// indicating that /// the cache directory under the user domain mask will be used. public init( name: String, sizeLimit: UInt, fileManager: FileManager = .default, - directory: URL? = nil) - { + directory: URL? = nil + ) { self.name = name self.fileManager = fileManager self.directory = directory @@ -252,9 +282,13 @@ extension DiskStorage { extension DiskStorage { struct Creation { + // MARK: - Properties + let directoryURL: URL let cacheName: String + // MARK: - Lifecycle + init(_ config: Config) { let url: URL if let directory = config.directory { diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift index a3c4a0ba..5fc8aa34 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift @@ -2,6 +2,6 @@ import Foundation extension Date { var isPast: Bool { - return self < Date() + self < Date() } } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/String+.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/String+.swift index 73660d92..2a9fa54d 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/String+.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/String+.swift @@ -1,15 +1,17 @@ -import Foundation import CommonCrypto +import Foundation extension String { var sha256: String { - guard let data = self.data(using: .utf8) else { return self } - + guard let data = data(using: .utf8) else { + return self + } + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) data.withUnsafeBytes { buffer in _ = CC_SHA256(buffer.baseAddress, CC_LONG(buffer.count), &hash) } - + return hash.map { String(format: "%02x", $0) }.joined() } } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift index b02d4981..a759ac93 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift @@ -2,51 +2,61 @@ import Foundation /// 쓰기 제어와 같은 동시성이 필요한 부분만 선택적으로 제어하기 위해 전체 ImageCache를 actor로 변경하지 않고, ImageCacheActor 생성 /// actor를 사용하면 모든 동작이 actor의 실행큐를 통과해야하기 때문에, 동시성 보호가 불필요한 read-only 동작도 직렬화되며 오버헤드가 발생 -@globalActor public actor ImageCacheActor { +@globalActor +public actor ImageCacheActor { public static let shared = ImageCacheActor() } public final class ImageCache: @unchecked Sendable { - - /// ERROR: Static property 'shared' is not concurrency-safe because non-'Sendable' type 'ImageCache' may have shared mutable state + // MARK: - Static Properties + + /// ERROR: Static property 'shared' is not concurrency-safe because non-'Sendable' type + /// 'ImageCache' may have shared mutable state /// ``` /// public static let shared = ImageCache() /// ``` - /// Swift 6에서는 동시성 안정성 검사가 더욱 엄격해졌습니다. 이로 인해 여러 스레드에서 동시에 접근할 수 있는 공유 상태 (shared mutable state)인 싱글톤 패턴을 사용할 경우,위 에러가 발생합니다. + /// Swift 6에서는 동시성 안정성 검사가 더욱 엄격해졌습니다. 이로 인해 여러 스레드에서 동시에 접근할 수 있는 공유 상태 (shared mutable state)인 + /// 싱글톤 패턴을 사용할 경우,위 에러가 발생합니다. /// 이는 별도의 가변 프로퍼티를 클래스 내부에 지니고 있지 않음에도 발생하는 에러입니다 /// 이를 해결하기 위해선, Actor를 사용하거나, Serial Queue를 사용해 동기화를 해줘야 합니다. @ImageCacheActor public static let shared = try! ImageCache(name: "default") - + // MARK: - Properties + private let memoryStorage: MemoryStorageActor private let diskStorage: DiskStorage - + + // MARK: - Lifecycle + // MARK: - Initialization + public init(name: String) throws { guard !name.isEmpty else { throw CacheError.invalidCacheKey } - - /// 메모리 캐싱 관련 설정 과정입니다. - /// NSProcessInfo를 통해 총 메모리 크기를 접근한 후, 메모리 상한선을 전체 메모리의 1/4로 한정합니다. + + // 메모리 캐싱 관련 설정 과정입니다. + // NSProcessInfo를 통해 총 메모리 크기를 접근한 후, 메모리 상한선을 전체 메모리의 1/4로 한정합니다. let totalMemory = ProcessInfo.processInfo.physicalMemory let memoryLimit = totalMemory / 4 - self.memoryStorage = MemoryStorageActor( + memoryStorage = MemoryStorageActor( totalCostLimit: min(Int.max, Int(memoryLimit)) ) - - /// 디스크 캐시에 대한 설정을 여기서 정의해줍니다. + + // 디스크 캐시에 대한 설정을 여기서 정의해줍니다. let diskConfig = DiskStorage.Config( name: name, sizeLimit: 0, directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first ) - - /// 디스크 캐시 제어 관련 클래스 인스턴스 생성 - self.diskStorage = try DiskStorage(config: diskConfig) + + // 디스크 캐시 제어 관련 클래스 인스턴스 생성 + diskStorage = try DiskStorage(config: diskConfig) } - + + // MARK: - Functions + /// 메모리와 디스크 캐시에 모두 데이터를 저장합니다. @ImageCacheActor public func store( @@ -55,14 +65,14 @@ public final class ImageCache: @unchecked Sendable { expiration: StorageExpiration? = nil ) async throws { await memoryStorage.store(value: data, forKey: key, expiration: expiration) - + try await diskStorage.store( value: data, forKey: key, expiration: expiration ) } - + /// 캐시로부터 저장된 이미지를 가져옵니다. /// 1차적으로 오버헤드가 적은 메모리를 먼저 확인합니다. /// 이후 메모리에 없을 경우, 디스크를 확인합니다. @@ -72,43 +82,43 @@ public final class ImageCache: @unchecked Sendable { if let memoryData = await memoryStorage.value(forKey: key) { return memoryData } - + let diskData = try await diskStorage.value(forKey: key) - - if let diskData = diskData { + + if let diskData { await memoryStorage.store( value: diskData, forKey: key, expiration: .days(7) ) } - + return diskData } - + /// 메모리와 디스크 모두에서 특정 키에 해당하는 이미지 데이터를 제거합니다. @ImageCacheActor public func removeImage(forKey key: String) async throws { await memoryStorage.remove(forKey: key) - + try await diskStorage.remove(forKey: key) } - + /// 메모리와 디스크 모두에 존재하는 모든 데이터를 제거합니다. @ImageCacheActor public func clearCache() async throws { await memoryStorage.removeAll() - + try await diskStorage.removeAll() } - + /// Checks if an image exists in cache (either memory or disk) @ImageCacheActor public func isCached(forKey key: String) async -> Bool { if await memoryStorage.isCached(forKey: key) { return true } - + return await diskStorage.isCached(forKey: key) } } @@ -116,38 +126,44 @@ public final class ImageCache: @unchecked Sendable { // MARK: - 메모리 영역 제어를 위한 actor입니다. private actor MemoryStorageActor { + // MARK: - Properties + /// 캐시는 NSCache로 접근합니다. private let cache = NSCache() private let totalCostLimit: Int - + + // MARK: - Lifecycle + init(totalCostLimit: Int) { - /// 메모리가 사용할 수 있는 공간 상한선 (ImageCache 클래스에서 총 메모리공간의 1/4로 주입하고 있음) 데이터를 아래 private 속성에 주입시킵니다. + // 메모리가 사용할 수 있는 공간 상한선 (ImageCache 클래스에서 총 메모리공간의 1/4로 주입하고 있음) 데이터를 아래 private 속성에 주입시킵니다. self.totalCostLimit = totalCostLimit - self.cache.totalCostLimit = totalCostLimit + cache.totalCostLimit = totalCostLimit } - + + // MARK: - Functions + /// 캐시에 저장 - func store(value: Data, forKey key: String, expiration: StorageExpiration?) { + func store(value: Data, forKey key: String, expiration _: StorageExpiration?) { cache.setObject(value as NSData, forKey: key as NSString) } - + /// 캐시에서 조회 func value(forKey key: String) -> Data? { - return cache.object(forKey: key as NSString) as Data? + cache.object(forKey: key as NSString) as Data? } - + /// 캐시에서 제거 func remove(forKey key: String) { cache.removeObject(forKey: key as NSString) } - + /// 캐시에서 일괄 제거 func removeAll() { cache.removeAllObjects() } - + /// 캐시에서 있는지 여부를 조회 func isCached(forKey key: String) -> Bool { - return cache.object(forKey: key as NSString) != nil + cache.object(forKey: key as NSString) != nil } } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift index ffa319e6..d31be488 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift @@ -10,7 +10,7 @@ public enum FilteringAlgorithm: Sendable { public protocol ImageProcessing: Sendable { /// 이미지를 처리하는 메서드 func process(_ image: UIImage) async throws -> UIImage - + /// 프로세서의 식별자 /// 캐시 키 생성에 사용됨 var identifier: String { get } @@ -18,15 +18,34 @@ public protocol ImageProcessing: Sendable { /// 이미지 리사이징 프로세서 public struct ResizingImageProcessor: ImageProcessing { + // MARK: - Properties + /// 대상 크기 private let targetSize: CGSize - + /// 크기 조정 모드 private let contentMode: UIView.ContentMode - + /// 크기 조정 시 필터링 방식 private let filteringAlgorithm: FilteringAlgorithm - + + // MARK: - Computed Properties + + public var identifier: String { + let contentModeString: String = { + switch contentMode { + case .scaleToFill: return "ScaleToFill" + case .scaleAspectFit: return "ScaleAspectFit" + case .scaleAspectFill: return "ScaleAspectFill" + default: return "Unknown" + } + }() + + return "com.neoimage.ResizingImageProcessor(\(targetSize),\(contentModeString))" + } + + // MARK: - Lifecycle + public init( targetSize: CGSize, contentMode: UIView.ContentMode = .scaleToFill, @@ -36,24 +55,26 @@ public struct ResizingImageProcessor: ImageProcessing { self.contentMode = contentMode self.filteringAlgorithm = filteringAlgorithm } - + + // MARK: - Functions + public func process(_ image: UIImage) async throws -> UIImage { let format = UIGraphicsImageRendererFormat() format.scale = image.scale - + let size = calculateTargetSize(image.size) let renderer = UIGraphicsImageRenderer(size: size, format: format) - - return renderer.image { context in + + return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: size)) } } - + private func calculateTargetSize(_ originalSize: CGSize) -> CGSize { switch contentMode { case .scaleToFill: return targetSize - + case .scaleAspectFit: let widthRatio = targetSize.width / originalSize.width let heightRatio = targetSize.height / originalSize.height @@ -62,7 +83,7 @@ public struct ResizingImageProcessor: ImageProcessing { width: originalSize.width * ratio, height: originalSize.height * ratio ) - + case .scaleAspectFill: let widthRatio = targetSize.width / originalSize.width let heightRatio = targetSize.height / originalSize.height @@ -71,64 +92,71 @@ public struct ResizingImageProcessor: ImageProcessing { width: originalSize.width * ratio, height: originalSize.height * ratio ) - + default: return targetSize } } - - public var identifier: String { - let contentModeString: String = { - switch contentMode { - case .scaleToFill: return "ScaleToFill" - case .scaleAspectFit: return "ScaleAspectFit" - case .scaleAspectFill: return "ScaleAspectFill" - default: return "Unknown" - } - }() - - return "com.neoimage.ResizingImageProcessor(\(targetSize),\(contentModeString))" - } } /// 둥근 모서리 처리를 위한 프로세서 public struct RoundCornerImageProcessor: ImageProcessing { + // MARK: - Properties + /// 모서리 반경 private let radius: CGFloat - + + // MARK: - Computed Properties + + public var identifier: String { + "com.neoimage.RoundCornerImageProcessor(\(radius))" + } + + // MARK: - Lifecycle + public init(radius: CGFloat) { self.radius = radius } - + + // MARK: - Functions + public func process(_ image: UIImage) async throws -> UIImage { let format = UIGraphicsImageRendererFormat() format.scale = image.scale - + let renderer = UIGraphicsImageRenderer(size: image.size, format: format) return renderer.image { context in let rect = CGRect(origin: .zero, size: image.size) let path = UIBezierPath(roundedRect: rect, cornerRadius: radius) - + context.cgContext.addPath(path.cgPath) context.cgContext.clip() - + image.draw(in: rect) } } - - public var identifier: String { - return "com.neoimage.RoundCornerImageProcessor(\(radius))" - } } /// 여러 프로세서를 순차적으로 적용하는 프로세서 public struct ChainImageProcessor: ImageProcessing { + // MARK: - Properties + private let processors: [ImageProcessing] - + + // MARK: - Computed Properties + + public var identifier: String { + processors.map(\.identifier).joined(separator: "|") + } + + // MARK: - Lifecycle + public init(_ processors: [ImageProcessing]) { self.processors = processors } - + + // MARK: - Functions + public func process(_ image: UIImage) async throws -> UIImage { var processedImage = image for processor in processors { @@ -136,8 +164,4 @@ public struct ChainImageProcessor: ImageProcessing { } return processedImage } - - public var identifier: String { - return processors.map { $0.identifier }.joined(separator: "|") - } } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift index aaf60af5..14342ada 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift @@ -16,92 +16,107 @@ public enum ImageTaskState: Int, Sendable { /// 이미지 다운로드 작업을 관리하는 actor public actor ImageTask: Sendable { - // MARK: - Properties - + /// 현재 작업의 상태 - public private(set) var state: ImageTaskState = .pending - + public private(set) var state = ImageTaskState.pending + /// 다운로드 진행률 public private(set) var progress: Float = 0 - + /// 작업 시작 시간 public private(set) var startTime: Date? - + /// 작업 완료 시간 public private(set) var endTime: Date? - + /// 취소 여부 - public private(set) var isCancelled: Bool = false - + public private(set) var isCancelled = false + /// 다운로드된 데이터 크기 public private(set) var downloadedDataSize: Int64 = 0 - + /// 전체 데이터 크기 public private(set) var totalDataSize: Int64 = 0 - + + // MARK: - Computed Properties + + /// 작업 소요 시간 (밀리초) + public var duration: TimeInterval? { + guard let start = startTime else { + return nil + } + let end = endTime ?? Date() + return end.timeIntervalSince(start) + } + + /// 다운로드 속도 (bytes/second) + public var downloadSpeed: Double? { + guard let duration, duration > 0 else { + return nil + } + return Double(downloadedDataSize) / duration + } + + // MARK: - CustomStringConvertible Implementation + + public nonisolated var description: String { + "ImageTask" // Note: actor의 격리된 상태에 접근하지 않기 위해 간단한 description 사용 + } + + // MARK: - Lifecycle + // MARK: - Initializer - + public init() {} - + + // MARK: - Functions + // MARK: - Task Management Methods - + /// 작업 취소 public func cancel() { - guard state == .pending || state == .downloading else { return } + guard state == .pending || state == .downloading else { + return + } state = .cancelled isCancelled = true endTime = Date() } - + /// 작업 시작 public func start() { - guard state == .pending else { return } + guard state == .pending else { + return + } state = .downloading startTime = Date() } - + /// 작업 완료 public func complete() { - guard state == .downloading else { return } + guard state == .downloading else { + return + } state = .completed endTime = Date() } - + /// 작업 실패 public func fail() { - guard state != .completed && state != .cancelled else { return } + guard state != .completed, state != .cancelled else { + return + } state = .failed endTime = Date() } - + /// 진행률 업데이트 public func updateProgress(downloaded: Int64, total: Int64) { downloadedDataSize = downloaded totalDataSize = total progress = total > 0 ? Float(downloaded) / Float(total) : 0 } - - // MARK: - Convenience Properties - - /// 작업 소요 시간 (밀리초) - public var duration: TimeInterval? { - guard let start = startTime else { return nil } - let end = endTime ?? Date() - return end.timeIntervalSince(start) - } - - /// 다운로드 속도 (bytes/second) - public var downloadSpeed: Double? { - guard let duration = duration, duration > 0 else { return nil } - return Double(downloadedDataSize) / duration - } - - // MARK: - CustomStringConvertible Implementation - - public nonisolated var description: String { - "ImageTask" // Note: actor의 격리된 상태에 접근하지 않기 위해 간단한 description 사용 - } } // MARK: - Hashable Implementation @@ -110,7 +125,7 @@ extension ImageTask: Hashable { public static func == (lhs: ImageTask, rhs: ImageTask) -> Bool { ObjectIdentifier(lhs) == ObjectIdentifier(rhs) } - + public nonisolated func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageOptions.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageOptions.swift index 9f08ec71..c03f7c51 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageOptions.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageOptions.swift @@ -5,23 +5,26 @@ // Created by Neoself on 2/23/25. // - import UIKit /// 이미지 다운로드 및 처리에 관한 옵션을 정의하는 구조체 public struct NeoImageOptions: Sendable { + // MARK: - Properties + /// 이미지 프로세서 public let processor: ImageProcessing? - + /// 이미지 전환 효과 public let transition: ImageTransition - + /// 다시 시도 전략 public let retryStrategy: RetryStrategy - + /// 캐시 만료 정책 public let cacheExpiration: StorageExpiration - + + // MARK: - Lifecycle + public init( processor: ImageProcessing? = nil, transition: ImageTransition = .none, @@ -58,10 +61,10 @@ public enum RetryStrategy: Sendable { extension NeoImageOptions { /// 기본 옵션 (프로세서 없음, 전환 효과 없음, 재시도 없음, 7일 캐시) public static let `default` = NeoImageOptions() - + /// 페이드 인 효과가 있는 옵션 public static let fade = NeoImageOptions(transition: .fade(0.3)) - + /// 재시도가 있는 옵션 public static let retry = NeoImageOptions(retryStrategy: .times(3)) -} \ No newline at end of file +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift index 1f370f45..743fe484 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift @@ -152,11 +152,12 @@ extension NeoImageWrapper where Base: UIImageView { Task { @MainActor in do { - let (result, downloadTask) = try await setImageAsync( + let (result, _) = try await setImageAsync( with: url, placeholder: placeholder, options: options ) + completion?(.success(result)) } catch { await task.fail() diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift index 66f3ed17..4a2544c2 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift @@ -10,43 +10,53 @@ public struct ImageLoadingResult: Sendable { /// 이미지 다운로드 관리 액터 (동시성 제어) public actor ImageDownloadManager { - + // MARK: - Static Properties + // MARK: - 싱글톤 & 초기화 + public static let shared = ImageDownloadManager() + + // MARK: - Properties + private var session: URLSession private let sessionDelegate = SessionDelegate() - + + // MARK: - Lifecycle + private init() { let config = URLSessionConfiguration.ephemeral session = URLSession(configuration: config, delegate: sessionDelegate, delegateQueue: nil) setupDelegates() } - + + // MARK: - Functions + // MARK: - 핵심 다운로드 메서드 (kf.setImage에서 사용) + /// 이미지 비동기 다운로드 (async/await) public func downloadImage(with url: URL) async throws -> ImageLoadingResult { let request = URLRequest(url: url) let (data, response) = try await session.data(for: request) - + guard let httpResponse = response as? HTTPURLResponse, - (200..<400).contains(httpResponse.statusCode) else { + (200 ..< 400).contains(httpResponse.statusCode) else { // throw CacheError.invalidHTTPStatusCode throw CacheError.invalidData } - + guard let image = UIImage(data: data) else { // throw KingfisherError.imageMappingError throw CacheError.dataToImageConversionFailed } - + return ImageLoadingResult(image: image, url: url, originalData: data) } - + /// URL 기반 다운로드 취소 public func cancelDownload(for url: URL) { sessionDelegate.cancelTasks(for: url) } - + /// 전체 다운로드 취소 public func cancelAllDownloads() { sessionDelegate.cancelAllTasks() @@ -54,21 +64,25 @@ public actor ImageDownloadManager { } // MARK: - 내부 세션 관리 확장 -private extension ImageDownloadManager { + +extension ImageDownloadManager { /// actor의 상태를 직접 변경하지 않고 클로저를 설정하는 것이기에 nonisolated를 기입하여, 해당 메서드가 actor의 격리된 상태에 접근하지 않음을 알려줌 - nonisolated func setupDelegates() { + private nonisolated func setupDelegates() { sessionDelegate.onReceiveChallenge = { [weak self] challenge in - guard let self else {return (.performDefaultHandling, nil)} + guard let self else { + return (.performDefaultHandling, nil) + } return await handleAuthChallenge(challenge) } - + sessionDelegate.onValidateStatusCode = { code in - (200..<400).contains(code) + (200 ..< 400).contains(code) } } - + /// 인증 처리 핸들러 - func handleAuthChallenge(_ challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + private func handleAuthChallenge(_ challenge: URLAuthenticationChallenge) async + -> (URLSession.AuthChallengeDisposition, URLCredential?) { guard let trust = challenge.protectionSpace.serverTrust else { return (.cancelAuthenticationChallenge, nil) } @@ -77,28 +91,44 @@ private extension ImageDownloadManager { } // MARK: - 세션 델리게이트 구현 (간소화 버전) + private class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { - var onReceiveChallenge: ((URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?))? + // MARK: - Properties + + var onReceiveChallenge: ((URLAuthenticationChallenge) async -> ( + URLSession.AuthChallengeDisposition, + URLCredential? + ))? var onValidateStatusCode: ((Int) -> Bool)? - + private var tasks = [URL: URLSessionTask]() - + + // MARK: - Functions + func cancelTasks(for url: URL) { tasks[url]?.cancel() tasks[url] = nil } - + func cancelAllTasks() { tasks.values.forEach { $0.cancel() } tasks.removeAll() } - - // 필수 델리게이트 메서드만 구현 - func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + + /// 필수 델리게이트 메서드만 구현 + func urlSession( + _: URLSession, + task _: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { await onReceiveChallenge?(challenge) ?? (.performDefaultHandling, nil) } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse) async -> URLSession.ResponseDisposition { + + func urlSession( + _: URLSession, + dataTask _: URLSessionDataTask, + didReceive response: URLResponse + ) async -> URLSession.ResponseDisposition { guard let httpResponse = response as? HTTPURLResponse, onValidateStatusCode?(httpResponse.statusCode) == true else { return .cancel diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Protocols/DataTransformable.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Protocols/DataTransformable.swift index 766b37b9..48e9aa3f 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Protocols/DataTransformable.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Protocols/DataTransformable.swift @@ -2,21 +2,21 @@ import Foundation /// Sendable 프로토콜을 채택하여 동시성 환경에서 안전하게 사용 가능합니다. public protocol DataTransformable: Sendable { - /// Converts the current value to a `Data` representation. /// - Returns: The data object which can represent the value of the conforming type. /// - Throws: If any error happens during the conversion. func toData() throws -> Data - + /// Convert some data to the value. /// - Parameter data: The data object which should represent the conforming value. /// - Returns: The converted value of the conforming type. /// - Throws: If any error happens during the conversion. static func fromData(_ data: Data) throws -> Self - + /// An empty object of `Self`. /// - /// > In the cache, when the data is not actually loaded, this value will be returned as a placeholder. + /// > In the cache, when the data is not actually loaded, this value will be returned as a + /// placeholder. /// > This variable should be returned quickly without any heavy operation inside. static var empty: Self { get } } diff --git a/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageTests.swift b/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageTests.swift index cade6181..5ca39737 100644 --- a/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageTests.swift +++ b/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import NeoImage +import XCTest final class NeoImageTests: XCTestCase { func testExample() throws { diff --git a/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift b/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift index d3eabd88..dc180fcf 100644 --- a/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift +++ b/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift @@ -6,7 +6,6 @@ // import DesignSystem -//import Kingfisher import NeoImage import SnapKit import UIKit @@ -43,12 +42,8 @@ final class MyLibraryCollectionViewCell: UICollectionViewCell { // MARK: - Functions // TODO: 고도화 필요 - func configureCell(imageUrl: URL?) async { - do { - try await cellImageView.neo.setImage(with: imageUrl) - } catch { - print("error") - } + func configureCell(imageUrl: URL?) { + cellImageView.neo.setImage(with: imageUrl) } private func configureHierarchy() { From f636f62a4f879aef4c642a4a55989d4b87adc043 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:11:31 +0900 Subject: [PATCH 12/25] =?UTF-8?q?[Refactor]=20=EB=94=94=EB=A0=89=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NeoImage/{ => Cache}/DiskStorage.swift | 0 .../NeoImage/{ => Cache}/ImageCache.swift | 45 ------------------ .../NeoImage/Cache/MemoryStorage.swift | 44 +++++++++++++++++ .../{ => Constants}/NeoImageOptions.swift | 0 .../{ => Extensions}/NeoImageWrapper.swift | 0 .../NeoImage/{ => Image}/ImageProcesser.swift | 0 .../Networking/ImageDownloadManager.swift | 47 ------------------- .../ImageTask.swift} | 0 .../NeoImage/Networking/SessionDelegate.swift | 46 ++++++++++++++++++ 9 files changed, 90 insertions(+), 92 deletions(-) rename BookKitty/BookKitty/NeoImage/Sources/NeoImage/{ => Cache}/DiskStorage.swift (100%) rename BookKitty/BookKitty/NeoImage/Sources/NeoImage/{ => Cache}/ImageCache.swift (77%) create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/MemoryStorage.swift rename BookKitty/BookKitty/NeoImage/Sources/NeoImage/{ => Constants}/NeoImageOptions.swift (100%) rename BookKitty/BookKitty/NeoImage/Sources/NeoImage/{ => Extensions}/NeoImageWrapper.swift (100%) rename BookKitty/BookKitty/NeoImage/Sources/NeoImage/{ => Image}/ImageProcesser.swift (100%) rename BookKitty/BookKitty/NeoImage/Sources/NeoImage/{ImageTaskState.swift => Networking/ImageTask.swift} (100%) create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift similarity index 100% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/DiskStorage.swift rename to BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift similarity index 77% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift rename to BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift index a759ac93..19d6fa63 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageCache.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift @@ -122,48 +122,3 @@ public final class ImageCache: @unchecked Sendable { return await diskStorage.isCached(forKey: key) } } - -// MARK: - 메모리 영역 제어를 위한 actor입니다. - -private actor MemoryStorageActor { - // MARK: - Properties - - /// 캐시는 NSCache로 접근합니다. - private let cache = NSCache() - private let totalCostLimit: Int - - // MARK: - Lifecycle - - init(totalCostLimit: Int) { - // 메모리가 사용할 수 있는 공간 상한선 (ImageCache 클래스에서 총 메모리공간의 1/4로 주입하고 있음) 데이터를 아래 private 속성에 주입시킵니다. - self.totalCostLimit = totalCostLimit - cache.totalCostLimit = totalCostLimit - } - - // MARK: - Functions - - /// 캐시에 저장 - func store(value: Data, forKey key: String, expiration _: StorageExpiration?) { - cache.setObject(value as NSData, forKey: key as NSString) - } - - /// 캐시에서 조회 - func value(forKey key: String) -> Data? { - cache.object(forKey: key as NSString) as Data? - } - - /// 캐시에서 제거 - func remove(forKey key: String) { - cache.removeObject(forKey: key as NSString) - } - - /// 캐시에서 일괄 제거 - func removeAll() { - cache.removeAllObjects() - } - - /// 캐시에서 있는지 여부를 조회 - func isCached(forKey key: String) -> Bool { - cache.object(forKey: key as NSString) != nil - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/MemoryStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/MemoryStorage.swift new file mode 100644 index 00000000..5865cc3d --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/MemoryStorage.swift @@ -0,0 +1,44 @@ +import Foundation + +public actor MemoryStorageActor { + // MARK: - Properties + + /// 캐시는 NSCache로 접근합니다. + private let cache = NSCache() + private let totalCostLimit: Int + + // MARK: - Lifecycle + + init(totalCostLimit: Int) { + // 메모리가 사용할 수 있는 공간 상한선 (ImageCache 클래스에서 총 메모리공간의 1/4로 주입하고 있음) 데이터를 아래 private 속성에 주입시킵니다. + self.totalCostLimit = totalCostLimit + cache.totalCostLimit = totalCostLimit + } + + // MARK: - Functions + + /// 캐시에 저장 + func store(value: Data, forKey key: String, expiration _: StorageExpiration?) { + cache.setObject(value as NSData, forKey: key as NSString) + } + + /// 캐시에서 조회 + func value(forKey key: String) -> Data? { + cache.object(forKey: key as NSString) as Data? + } + + /// 캐시에서 제거 + func remove(forKey key: String) { + cache.removeObject(forKey: key as NSString) + } + + /// 캐시에서 일괄 제거 + func removeAll() { + cache.removeAllObjects() + } + + /// 캐시에서 있는지 여부를 조회 + func isCached(forKey key: String) -> Bool { + cache.object(forKey: key as NSString) != nil + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageOptions.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift similarity index 100% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageOptions.swift rename to BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift similarity index 100% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageWrapper.swift rename to BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Image/ImageProcesser.swift similarity index 100% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageProcesser.swift rename to BookKitty/BookKitty/NeoImage/Sources/NeoImage/Image/ImageProcesser.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift index 4a2544c2..85ac0743 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift @@ -89,50 +89,3 @@ extension ImageDownloadManager { return (.useCredential, URLCredential(trust: trust)) } } - -// MARK: - 세션 델리게이트 구현 (간소화 버전) - -private class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { - // MARK: - Properties - - var onReceiveChallenge: ((URLAuthenticationChallenge) async -> ( - URLSession.AuthChallengeDisposition, - URLCredential? - ))? - var onValidateStatusCode: ((Int) -> Bool)? - - private var tasks = [URL: URLSessionTask]() - - // MARK: - Functions - - func cancelTasks(for url: URL) { - tasks[url]?.cancel() - tasks[url] = nil - } - - func cancelAllTasks() { - tasks.values.forEach { $0.cancel() } - tasks.removeAll() - } - - /// 필수 델리게이트 메서드만 구현 - func urlSession( - _: URLSession, - task _: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge - ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveChallenge?(challenge) ?? (.performDefaultHandling, nil) - } - - func urlSession( - _: URLSession, - dataTask _: URLSessionDataTask, - didReceive response: URLResponse - ) async -> URLSession.ResponseDisposition { - guard let httpResponse = response as? HTTPURLResponse, - onValidateStatusCode?(httpResponse.statusCode) == true else { - return .cancel - } - return .allow - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift similarity index 100% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/ImageTaskState.swift rename to BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift new file mode 100644 index 00000000..cfbe97b4 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift @@ -0,0 +1,46 @@ +import Foundation + +public class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { + // MARK: - Properties + + var onReceiveChallenge: ((URLAuthenticationChallenge) async -> ( + URLSession.AuthChallengeDisposition, + URLCredential? + ))? + var onValidateStatusCode: ((Int) -> Bool)? + + private var tasks = [URL: URLSessionTask]() + + // MARK: - Functions + + func cancelTasks(for url: URL) { + tasks[url]?.cancel() + tasks[url] = nil + } + + func cancelAllTasks() { + tasks.values.forEach { $0.cancel() } + tasks.removeAll() + } + + /// 필수 델리게이트 메서드만 구현 + public func urlSession( + _: URLSession, + task _: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + await onReceiveChallenge?(challenge) ?? (.performDefaultHandling, nil) + } + + public func urlSession( + _: URLSession, + dataTask _: URLSessionDataTask, + didReceive response: URLResponse + ) async -> URLSession.ResponseDisposition { + guard let httpResponse = response as? HTTPURLResponse, + onValidateStatusCode?(httpResponse.statusCode) == true else { + return .cancel + } + return .allow + } +} From 0942e0bbe91ac5fc4d58c156457465d3a5b8eef5 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Thu, 27 Feb 2025 07:12:11 +0900 Subject: [PATCH 13/25] =?UTF-8?q?[Feat]=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EB=94=94=EC=8A=A4=ED=81=AC=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EC=A1=B0=ED=9A=8C=ED=95=98=EC=97=AC=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BookKitty/DesignSystem/Package.swift | 4 +- .../UIImageView/FlexibleImageView.swift | 16 +- .../UIImageView/HeightFixedImageView.swift | 16 +- .../UIImageView/WidthFixedImageView.swift | 15 +- .../Sources/NeoImage/Cache/DiskStorage.swift | 3 +- .../NeoImage/Extensions/NeoImageWrapper.swift | 218 +++++++++++------- .../NeoImage/Networking/SessionDelegate.swift | 20 +- .../View/AddBookByTitleCell.swift | 3 +- .../View/Popup/AddBookConfirmView.swift | 3 +- 9 files changed, 175 insertions(+), 123 deletions(-) diff --git a/BookKitty/BookKitty/DesignSystem/Package.swift b/BookKitty/BookKitty/DesignSystem/Package.swift index 1d8aae12..1babd113 100644 --- a/BookKitty/BookKitty/DesignSystem/Package.swift +++ b/BookKitty/BookKitty/DesignSystem/Package.swift @@ -20,7 +20,7 @@ let package = Package( .package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"), .package(url: "https://github.com/devxoul/Then.git", from: "3.0.0"), .package(url: "https://github.com/airbnb/lottie-spm.git", from: "4.5.1"), - .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.2.0"), + .package(path: "../NeoImage"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -31,7 +31,7 @@ let package = Package( .product(name: "SnapKit", package: "SnapKit"), .product(name: "Then", package: "Then"), .product(name: "Lottie", package: "lottie-spm"), - .product(name: "Kingfisher", package: "Kingfisher"), + .product(name: "NeoImage", package: "NeoImage"), ], resources: [ .process("Resource/Fonts"), diff --git a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/FlexibleImageView.swift b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/FlexibleImageView.swift index 7f1576ad..1cec2088 100644 --- a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/FlexibleImageView.swift +++ b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/FlexibleImageView.swift @@ -5,7 +5,7 @@ // Created by 임성수 on 2/4/25. // -import Kingfisher +import NeoImage import SnapKit import UIKit @@ -95,14 +95,16 @@ extension FlexibleImageView { return } - kf.setImage( + let options = NeoImageOptions( + transition: .fade(0.2), + retryStrategy: .times(3) + ) + + neo.setImage( with: url, placeholder: UIImage(named: "DefaultBookImage", in: Bundle.module, compatibleWith: nil), - options: [ - .transition(.fade(0.2)), // 부드러운 페이드 효과 - .cacheOriginalImage, // 원본 이미지 캐싱 - ], - completionHandler: { [weak self] result in + options: options, + completion: { [weak self] result in guard let self else { return } diff --git a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/HeightFixedImageView.swift b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/HeightFixedImageView.swift index e84596e1..5169bcd5 100644 --- a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/HeightFixedImageView.swift +++ b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/HeightFixedImageView.swift @@ -5,7 +5,7 @@ // Created by 임성수 on 2/4/25. // -import Kingfisher +import NeoImage import SnapKit import UIKit @@ -110,14 +110,16 @@ extension HeightFixedImageView { return } - kf.setImage( + let options = NeoImageOptions( + transition: .fade(0.2), + retryStrategy: .times(3) + ) + + neo.setImage( with: url, placeholder: UIImage(named: "DefaultBookImage", in: Bundle.module, compatibleWith: nil), - options: [ - .transition(.fade(0.2)), // 부드러운 페이드 효과 - .cacheOriginalImage, // 원본 이미지 캐싱 - ], - completionHandler: { [weak self] result in + options: options, + completion: { [weak self] result in guard let self else { return } diff --git a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/WidthFixedImageView.swift b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/WidthFixedImageView.swift index 5b6e78c3..f8e43194 100644 --- a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/WidthFixedImageView.swift +++ b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/WidthFixedImageView.swift @@ -5,6 +5,7 @@ // Created by 임성수 on 2/4/25. // +import NeoImage import SnapKit import UIKit @@ -107,14 +108,16 @@ extension WidthFixedImageView { return } - kf.setImage( + let options = NeoImageOptions( + transition: .fade(0.2), + retryStrategy: .times(3) + ) + + neo.setImage( with: url, placeholder: UIImage(named: "DefaultBookImage", in: Bundle.module, compatibleWith: nil), - options: [ - .transition(.fade(0.2)), // 부드러운 페이드 효과 - .cacheOriginalImage, // 원본 이미지 캐싱 - ], - completionHandler: { [weak self] result in + options: options, + completion: { [weak self] result in guard let self else { return } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift index e75387da..bd29d284 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift @@ -30,8 +30,7 @@ class DiskStorage: @unchecked Sendable { } // Disk에 대한 접근이 패키지 외부에서 동시에 이루어질 경우, 동일한 위치에 다른 데이터가 덮어씌워지는 data race 상황이 됩니다. 이를 방지하고자, 기존 // Kingfisher에서는 DispatchQueue를 통해 직렬화 큐를 구현한 후, store(Write), value(Read)를 직렬화 큐에 전송하여 - // 순차적인 - // 실행이 보장되게 하였습니다. + // 순차적인 실행이 보장되게 하였습니다. // 이를 Swift Concurrency로 변경하고자, 동일한 직렬화 기능을 수행하는 Actor 클래스로 대체하였습니다. try await serialActor.run { // 별도로 메서드를 통해 기한을 전달하지 않으면, 기본값으로 config.expiration인 7일로 정의합니다. diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift index 743fe484..5a529724 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift @@ -3,115 +3,135 @@ import UIKit // MARK: - Wrapper & Associated Object Key /// UIImageView가 NeoImage의 기능을 제공받을 수 있는 NeoImageCompatible 프로토콜을 채택할 수 있음을 명시합니다. -extension UIImageView: NeoImageCompatible { } +extension UIImageView: NeoImageCompatible {} -public protocol NeoImageCompatible: AnyObject { } +public protocol NeoImageCompatible: AnyObject {} extension NeoImageCompatible { /// neo 네임스페이스를 통해 NeoImage의 기능에 접근할 수 있습니다. public var neo: NeoImageWrapper { - get { return NeoImageWrapper(self as! UIImageView) } - set { } + get { NeoImageWrapper(self as! UIImageView) } + set {} } } /// NeoImage 기능에 접근하기 위한 네임스페이스 역할을 하는 wrapper 구조체 public struct NeoImageWrapper: Sendable { + // MARK: - Properties + public let base: Base + + // MARK: - Lifecycle + /// 여기서 Base는 이미지 캐시 및 이미지 데이터가 주입되는 UIImageView를 의미합니다. public init(_ base: Base) { self.base = base } } - - // MARK: - UIImageView Extension extension NeoImageWrapper where Base: UIImageView { - - @discardableResult /// Return type을 strict하게 확인하지 않습니다. + @discardableResult // Return type을 strict하게 확인하지 않습니다. private func setImageAsync( with url: URL?, placeholder: UIImage? = nil, options: NeoImageOptions? = nil ) async throws -> (ImageLoadingResult, ImageTask?) { - /// 이미지뷰가 실제로 화면에 표시되어 있는지 여부 파악, - /// 이는 Swift 6로 오면서 비동기 작업으로 간주되기 시작함. + // 이미지뷰가 실제로 화면에 표시되어 있는지 여부 파악, + // 이는 Swift 6로 오면서 비동기 작업으로 간주되기 시작함. guard await base.window != nil else { throw CacheError.invalidData } - - guard let url = url else { + + guard let url else { await MainActor.run { [weak base] in - guard let base else { return } + guard let base else { + return + } base.image = placeholder } - + throw CacheError.invalidData } - + // placeholder 먼저 설정 - if let placeholder = placeholder { + if let placeholder { await MainActor.run { [weak base] in - guard let base else {return} + guard let base else { + return + } base.image = placeholder + print("\(url): Placeholder 설정 완료") } } - - /// UIImageView에 연결된 ImageTask를 가져옵니다 - /// 현재 진행 중인 다운로드 작업이 있는지 확인하는데 사용됩니다 + + // UIImageView에 연결된 ImageTask를 가져옵니다 + // 현재 진행 중인 다운로드 작업이 있는지 확인하는데 사용됩니다 if let task = objc_getAssociatedObject(base, ImageTaskKey.associatedKey) as? ImageTask { await task.cancel() await setImageDownloadTask(nil) + print("\(url): 기존 Task 존재하여 취소") + } + + let cacheKey = url.absoluteString + + // 메모리 또는 디스크 캐시에서 이미지 데이터 확인 + if let cachedData = try? await ImageCache.shared.retrieveImage(forKey: cacheKey), + let cachedImage = UIImage(data: cachedData) { + print("\(url): 기존 저장소에 이미지 존재 확인") + + // 캐시된 이미지 처리 + let processedImage = try await processImage(cachedImage, options: options) + + await MainActor.run { [weak base] in + guard let base else { + return + } + base.image = processedImage + print("\(url): 메모리에 위치한 이미지로 로드") + + applyTransition(to: base, with: options?.transition) + } + + return ( + ImageLoadingResult( + image: processedImage, + url: url, + originalData: cachedData + ), + nil + ) } - + let imageTask = ImageTask() - + await setImageDownloadTask(imageTask) - + let downloadResult = try await ImageDownloadManager.shared.downloadImage(with: url) - + print("\(url): 이미지 다운로드 완료") try Task.checkCancellation() - + let processedImage = try await processImage(downloadResult.image, options: options) try Task.checkCancellation() - - /// 캐시 저장 - if let data = processedImage.jpegData(compressionQuality: 0.8){ + + // 캐시 저장 + if let data = processedImage.jpegData(compressionQuality: 0.8) { try await ImageCache.shared.store(data, forKey: url.absoluteString) + print("\(url): 이미지 캐싱 완료") } - - /// 최종 UI 업데이트 + + // 최종 UI 업데이트 await MainActor.run { [weak base] in - guard let base else { return } - - base.image = processedImage - - if let transition = options?.transition { - switch transition { - case .none: - break - case .fade(let duration): - UIView.transition( - with: base, - duration: duration, - options: .transitionCrossDissolve, - animations: nil, - completion: nil - ) - case .flip(let duration): - UIView.transition( - with: base, - duration: duration, - options: .transitionFlipFromLeft, - animations: nil, - completion: nil - ) - } + guard let base else { + return } + + base.image = processedImage + print("\(url): 후처리된 이미지 렌더 완료") + applyTransition(to: base, with: options?.transition) } - + return ( ImageLoadingResult( image: processedImage, @@ -121,9 +141,37 @@ extension NeoImageWrapper where Base: UIImageView { imageTask ) } - + + @MainActor + private func applyTransition(to imageView: UIImageView, with transition: ImageTransition?) { + guard let transition else { + return + } + + switch transition { + case .none: + break + case let .fade(duration): + UIView.transition( + with: imageView, + duration: duration, + options: .transitionCrossDissolve, + animations: nil, + completion: nil + ) + case let .flip(duration): + UIView.transition( + with: imageView, + duration: duration, + options: .transitionFlipFromLeft, + animations: nil, + completion: nil + ) + } + } + // MARK: - Public Async API - + /// async/await 패턴이 적용된 환경에서 사용가능한 래퍼 메서드입니다. public func setImage( with url: URL?, @@ -135,12 +183,12 @@ extension NeoImageWrapper where Base: UIImageView { placeholder: placeholder, options: options ) - + return result } - + // MARK: - Public Completion Handler API - + @discardableResult public func setImage( with url: URL?, @@ -149,7 +197,7 @@ extension NeoImageWrapper where Base: UIImageView { completion: (@MainActor @Sendable (Result) -> Void)? = nil ) -> ImageTask? { let task = ImageTask() - + Task { @MainActor in do { let (result, _) = try await setImageAsync( @@ -157,53 +205,49 @@ extension NeoImageWrapper where Base: UIImageView { placeholder: placeholder, options: options ) - + completion?(.success(result)) } catch { await task.fail() completion?(.failure(error)) } } - + return task } - + private func processImage(_ image: UIImage, options: NeoImageOptions?) async throws -> UIImage { if let processor = options?.processor { return try await processor.process(image) } - + return image } - - + // MARK: - Task Management - + /// UIImageView는 기본적으로 ImageTask를 저장할 프로퍼티가 없습니다. /// /// 따라서, Objective-C의 런타임 기능을 사용해 UIImageView 인스턴스에 ImageTask를 동적으로 연결하여 저장합니다, /// 현재 진행중인 이미지 다운로드 작업 추적에 사용됩니다. private func setImageDownloadTask(_ task: ImageTask?) async { - - /// 모든 NSObject의 하위 클래스에 대해 사용할 수 있는 메서드이며, SWift에서는 @obj 마킹이 된 클래스도 대상으로 설정이 가능합니다. - /// 순수 Swift 타입인 struct와 enum, class에는 사용이 불가하기 때문에, NSObject를 상속하거나 @objc 속성을 사용해야 합니다. - /// - `UIView` 및 모든 하위 클래스 - /// - UIViewController 및 모든 하위 클래스 - /// - UIApplication - /// - UIGestureRecognizer - /// Foundation 클래스들 - /// - `NSString` - /// - NSArray - /// - NSDictionary - /// - URLSession - + // 모든 NSObject의 하위 클래스에 대해 사용할 수 있는 메서드이며, SWift에서는 @obj 마킹이 된 클래스도 대상으로 설정이 가능합니다. + // 순수 Swift 타입인 struct와 enum, class에는 사용이 불가하기 때문에, NSObject를 상속하거나 @objc 속성을 사용해야 합니다. + // - `UIView` 및 모든 하위 클래스 + // - UIViewController 및 모든 하위 클래스 + // - UIApplication + // - UIGestureRecognizer + // Foundation 클래스들 + // - `NSString` + // - NSArray + // - NSDictionary + // - URLSession + objc_setAssociatedObject( base, // 대상 객체 (UIImageView) - ImageTaskKey.associatedKey, // 키 값 - task, // 저장할 값 - .OBJC_ASSOCIATION_RETAIN_NONATOMIC // 메모리 관리 정책 + ImageTaskKey.associatedKey, // 키 값 + task, // 저장할 값 + .OBJC_ASSOCIATION_RETAIN_NONATOMIC // 메모리 관리 정책 ) - } } - diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift index cfbe97b4..17aabc77 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift @@ -13,16 +13,6 @@ public class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Senda // MARK: - Functions - func cancelTasks(for url: URL) { - tasks[url]?.cancel() - tasks[url] = nil - } - - func cancelAllTasks() { - tasks.values.forEach { $0.cancel() } - tasks.removeAll() - } - /// 필수 델리게이트 메서드만 구현 public func urlSession( _: URLSession, @@ -43,4 +33,14 @@ public class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Senda } return .allow } + + func cancelTasks(for url: URL) { + tasks[url]?.cancel() + tasks[url] = nil + } + + func cancelAllTasks() { + tasks.values.forEach { $0.cancel() } + tasks.removeAll() + } } diff --git a/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/AddBookByTitleCell.swift b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/AddBookByTitleCell.swift index 1dedbfd7..6fe60ad0 100644 --- a/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/AddBookByTitleCell.swift +++ b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/AddBookByTitleCell.swift @@ -6,6 +6,7 @@ // import DesignSystem +import NeoImage import SnapKit import Then import UIKit @@ -52,7 +53,7 @@ final class AddBookByTitleCell: UICollectionViewCell { // MARK: - Functions func configureCell(imageLink: String, bookTitle: String, author: String) { - imageView.kf.setImage(with: URL(string: imageLink)) + imageView.neo.setImage(with: URL(string: imageLink)) bookTitleLabel.text = bookTitle bookAuthorLabel.text = author bookTitleLabel.lineBreakMode = .byTruncatingTail diff --git a/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmView.swift b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmView.swift index 451c3856..20b484f0 100644 --- a/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmView.swift +++ b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmView.swift @@ -6,6 +6,7 @@ // import DesignSystem +import NeoImage import RxSwift import SnapKit import Then @@ -76,7 +77,7 @@ final class AddBookConfirmView: UIView { func configure(thumbnailUrl: URL?, title: String) { bookTitleLabel.text = title - bookThumbnailImageView.kf.setImage(with: thumbnailUrl) + bookThumbnailImageView.neo.setImage(with: thumbnailUrl) } private func configureBackground() { From 7e72a5c0cfacd9e88611d2ac09579ce5e5898528 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 2 Mar 2025 13:01:26 +0900 Subject: [PATCH 14/25] =?UTF-8?q?[Refactor]=20BookMatchKit=20loggers=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=EA=B3=BC=20=EA=B5=AC=EB=AC=B8=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LogKit/Sources/LogKit/LogKitActor.swift | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitActor.swift diff --git a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitActor.swift b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitActor.swift new file mode 100644 index 00000000..38d89389 --- /dev/null +++ b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitActor.swift @@ -0,0 +1,162 @@ +import Foundation +import OSLog + +struct LoggerKey: Hashable { + let subSystem: LogSubSystem + let category: LogCategory +} + +/// 단순히 전역 액터 속성만 정의 +@globalActor +public actor LogKitActor { + public static let shared = LogKitActor() +} + +public final class _LogKit { + // MARK: - Static Properties + /// @globalActor를 사용해 shared 인스턴스에만 격리 도메인을 지정하는 것은 동시성 격리를 부분적으로 선택적으로 적용 + /// 어떤 부분이 격리되어야 하고 어떤 부분이 일반 동기 코드로 실행되어도 되는지 더 세밀하게 제어 + /// 실제로 모든 코드가 항상 격리될 필요는 없기 때문에 성능상 이점도 존재 + /// 공유 자원에 대한 접근은 조정해야 하지만, 모든 기능이 액터 내부에 있을 필요는 없는 경우 적합 + @LogKitActor + static let shared = _LogKit() + + /// logger 인스턴스를 생성하는 로직을 초기화기 내부에서 호출 하는 것은 actor 내에 actor-isolated 메서드를 동기적으로(synchronous) + /// 호출하는 것입니다. + /// Actor의 초기화 과정에서는 actor의 격리 매커니즘이 완전히 설정되지 않았기 때문에 actor-isolated 메서드를 직접 호출할 수 없습니다. + /// `actor는 공유 가변 상태에 대한 안전한 접근을 보장하기 위한 동시성 타입` + /// 이를 위해 actor 내부 모든 메서드와 프로퍼티는 기본적으로 actor-isolated 되어있기에, actor 외부에선 await 키워드와 함께 호출되어야 하며, + /// 동시에 외부에서 호출되더라도 자동으로 직렬화됩니다. + /// + /// 각 카테고리에 해당한느 로거 인스턴스를 Actor 속성이 아닌, 정적 속성으로 생성 + /// static 프로퍼티 및 메서드는 인스턴스와 무관하게 타입 자체에 속하기에 actor의 격리 메커니즘 밖에 존재하여, actor-isolation 제약을 받지 않게 + /// 됨. + private static let loggers: [LoggerKey: Logger] = { + var map: [LoggerKey: Logger] = [:] + + for subSystem in LogSubSystem.allCases { + for category in LogCategory.allCases { + let logger = Logger(subsystem: subSystem.rawValue, category: category.rawValue) + map[LoggerKey(subSystem: subSystem, category: category)] = logger + } + } + + return map + }() + + private let dateFormatter: DateFormatter + private let fileManager: FileManager + + private let appStartTime: String + private var currentCSVFileID: Int = 1 + private var currentCSVFileURL: URL? + private var currentCSVFileSize: UInt64 = 0 + private let maxCSVFileSize: UInt64 = 6 * 1024 // 6KB size limit + + // MARK: - Lifecycle + + private init() { + dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + + fileManager = FileManager.default + + let startTimeFormatter = DateFormatter() + startTimeFormatter.dateFormat = "yyyyMMdd_HHmm" + appStartTime = startTimeFormatter.string(from: Date()) + + let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + + createNewCSVFile() + } + + // MARK: - Functions + + // MARK: - Public Methods + + func log( + _ level: LogLevel, + message: String, + subSystem: LogSubSystem = .app, + category: LogCategory = .general, + file: String = #file, + function: String = #function, + line: Int = #line + ) async { + let fileName = (file as NSString).lastPathComponent + + let logger = getLogger(subSystem: subSystem, category: category) + logger.log(level: level.osLogType, "[\(fileName):\(line)] \(function) - \(message)") + + let timestamp = dateFormatter.string(from: Date()) + + // CSV 로그 추가 + writeToCSVFile( + timestamp: timestamp, + level: level.rawValue, + fileName: fileName, + line: String(line), + function: function, + message: message, + subSystem: subSystem.rawValue, + category: category.rawValue + ) + } + // MARK: - Private Methods + + private func getLogger(subSystem: LogSubSystem, category: LogCategory) -> Logger { + let key = LoggerKey(subSystem: subSystem, category: category) + + if let logger = _LogKit.loggers[key] { + return logger + } else { + return Logger(subsystem: subSystem.rawValue, category: category.rawValue) + } + } + + private func createNewCSVFile() { + let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + let fileName = "\(appStartTime)-\(currentCSVFileID).csv" + let fileURL = documentsPath.appendingPathComponent(fileName) + + // CSV 헤더 생성 + let headerRow = "Timestamp,Level,FileName,Line,Function,Message,SubSystem,Category\n" + + do { + try headerRow.write(to: fileURL, atomically: true, encoding: .utf8) + currentCSVFileURL = fileURL + currentCSVFileSize = UInt64(headerRow.utf8.count) + print("새 CSV 로그 파일이 생성되었습니다: \(fileName)") + } catch { + print("CSV 로그 파일 생성 실패: \(error)") + } + } + + private func writeToCSVFile(timestamp: String, level: String, fileName: String, line: String, function: String, message: String, subSystem: String, category: String) { + let escapedMessage = message.replacingOccurrences(of: "\"", with: "\"\"") + let escapedFunction = function.replacingOccurrences(of: "\"", with: "\"\"") + + // CSV 행 생성 + let csvRow = "\"\(timestamp)\",\"\(level)\",\"\(fileName)\",\"\(line)\",\"\(escapedFunction)\",\"\(escapedMessage)\",\"\(subSystem)\",\"\(category)\"\n" + + guard let csvData = csvRow.data(using: .utf8) else { return } + let dataSize = UInt64(csvData.count) + + // 현재 파일이 최대 크기를 초과하는지 확인 + if currentCSVFileSize + dataSize > maxCSVFileSize { + currentCSVFileID += 1 + createNewCSVFile() + } + + // CSV 파일에 로그 추가 + guard let fileURL = currentCSVFileURL else { return } + + if let fileHandle = try? FileHandle(forWritingTo: fileURL) { + fileHandle.seekToEndOfFile() + fileHandle.write(csvData) + try? fileHandle.close() + + currentCSVFileSize += dataSize + } + } +} From b2922a3f3f04b50566cfa5d9076419a3e1f3fb6e Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:00:49 +0900 Subject: [PATCH 15/25] =?UTF-8?q?[Feat]=20LogCategory,=20LogSubsystem,=20L?= =?UTF-8?q?ogLevel=20=EC=97=B4=EA=B1=B0=ED=98=95=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LogKit/Sources/LogKit/LogKitType.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitType.swift diff --git a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitType.swift b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitType.swift new file mode 100644 index 00000000..64358385 --- /dev/null +++ b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitType.swift @@ -0,0 +1,34 @@ +import OSLog + +public enum LogSubSystem: String, CaseIterable, Sendable { + case bookOCR = "bookOCR" + case bookRecommendation = "bookRecommendation" + case designSystem = "designSystem" + case database = "database" + case app = "app" +} + +public enum LogCategory: String, CaseIterable, Sendable { + case general = "general" + case network = "network" + case userAction = "userAction" + case lifecycle = "lifecycle" +} + +public enum LogLevel: String { + case debug = "DEBUG" + case info = "INFO" + case log = "LOG" + case error = "ERROR" + + // MARK: - Computed Properties + + var osLogType: OSLogType { + switch self { + case .debug: return .debug + case .info: return .info + case .log: return .default + case .error: return .error + } + } +} From f56862b0d23ca8917488c363945e297db71e86b9 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:01:16 +0900 Subject: [PATCH 16/25] =?UTF-8?q?[Feat]=20LogKitActor=20=EB=B0=8F=20LogKit?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84=EC=B2=B4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LogKit/Sources/LogKit/LogKitActor.swift | 162 +++++++++++------- 1 file changed, 98 insertions(+), 64 deletions(-) diff --git a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitActor.swift b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitActor.swift index 38d89389..8cf025ba 100644 --- a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitActor.swift +++ b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitActor.swift @@ -6,74 +6,80 @@ struct LoggerKey: Hashable { let category: LogCategory } -/// 단순히 전역 액터 속성만 정의 +/// 단순히 전역 액터 속성만 정의하는 것이 아니라, 로깅 인터페이스도 제공 @globalActor public actor LogKitActor { + // MARK: - Static Properties + public static let shared = LogKitActor() + + // MARK: - Properties + + /// _LogKit의 인스턴스를 직접 관리 + private let logKit = _LogKit() + + // MARK: - Functions + + /// _LogKit의 메서드를 액터 내부에서 호출 + public func log( + _ level: LogLevel, + message: String, + subSystem: LogSubSystem = .app, + category: LogCategory = .general, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + // 액터 내부에서는 await 없이 동기적으로 호출 가능 + logKit.log( + level, + message: message, + subSystem: subSystem, + category: category, + file: file, + function: function, + line: line + ) + } } -public final class _LogKit { - // MARK: - Static Properties +final class _LogKit { + // MARK: - Properties + /// @globalActor를 사용해 shared 인스턴스에만 격리 도메인을 지정하는 것은 동시성 격리를 부분적으로 선택적으로 적용 /// 어떤 부분이 격리되어야 하고 어떤 부분이 일반 동기 코드로 실행되어도 되는지 더 세밀하게 제어 /// 실제로 모든 코드가 항상 격리될 필요는 없기 때문에 성능상 이점도 존재 /// 공유 자원에 대한 접근은 조정해야 하지만, 모든 기능이 액터 내부에 있을 필요는 없는 경우 적합 - @LogKitActor - static let shared = _LogKit() - - /// logger 인스턴스를 생성하는 로직을 초기화기 내부에서 호출 하는 것은 actor 내에 actor-isolated 메서드를 동기적으로(synchronous) - /// 호출하는 것입니다. - /// Actor의 초기화 과정에서는 actor의 격리 매커니즘이 완전히 설정되지 않았기 때문에 actor-isolated 메서드를 직접 호출할 수 없습니다. - /// `actor는 공유 가변 상태에 대한 안전한 접근을 보장하기 위한 동시성 타입` - /// 이를 위해 actor 내부 모든 메서드와 프로퍼티는 기본적으로 actor-isolated 되어있기에, actor 외부에선 await 키워드와 함께 호출되어야 하며, - /// 동시에 외부에서 호출되더라도 자동으로 직렬화됩니다. - /// - /// 각 카테고리에 해당한느 로거 인스턴스를 Actor 속성이 아닌, 정적 속성으로 생성 - /// static 프로퍼티 및 메서드는 인스턴스와 무관하게 타입 자체에 속하기에 actor의 격리 메커니즘 밖에 존재하여, actor-isolation 제약을 받지 않게 - /// 됨. - private static let loggers: [LoggerKey: Logger] = { - var map: [LoggerKey: Logger] = [:] - - for subSystem in LogSubSystem.allCases { - for category in LogCategory.allCases { - let logger = Logger(subsystem: subSystem.rawValue, category: category.rawValue) - map[LoggerKey(subSystem: subSystem, category: category)] = logger - } - } - - return map - }() - private let dateFormatter: DateFormatter private let fileManager: FileManager - + private var loggers: [LoggerKey: Logger] = [:] + private let appStartTime: String - private var currentCSVFileID: Int = 1 + private var currentCSVFileID = 1 private var currentCSVFileURL: URL? private var currentCSVFileSize: UInt64 = 0 - private let maxCSVFileSize: UInt64 = 6 * 1024 // 6KB size limit - + private let maxCSVFileSize: UInt64 = 60 * 1024 // 60KB + // MARK: - Lifecycle - - private init() { + + init() { dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" - + fileManager = FileManager.default - + let startTimeFormatter = DateFormatter() startTimeFormatter.dateFormat = "yyyyMMdd_HHmm" appStartTime = startTimeFormatter.string(from: Date()) - - let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] - + + initializeLoggers() createNewCSVFile() } - + // MARK: - Functions - + // MARK: - Public Methods - + func log( _ level: LogLevel, message: String, @@ -82,14 +88,14 @@ public final class _LogKit { file: String = #file, function: String = #function, line: Int = #line - ) async { + ) { let fileName = (file as NSString).lastPathComponent - + let logger = getLogger(subSystem: subSystem, category: category) logger.log(level: level.osLogType, "[\(fileName):\(line)] \(function) - \(message)") - + let timestamp = dateFormatter.string(from: Date()) - + // CSV 로그 추가 writeToCSVFile( timestamp: timestamp, @@ -102,26 +108,40 @@ public final class _LogKit { category: category.rawValue ) } + // MARK: - Private Methods - + + private func initializeLoggers() { + var map: [LoggerKey: Logger] = [:] + + for subSystem in LogSubSystem.allCases { + for category in LogCategory.allCases { + let logger = Logger(subsystem: subSystem.rawValue, category: category.rawValue) + map[LoggerKey(subSystem: subSystem, category: category)] = logger + } + } + + loggers = map + } + private func getLogger(subSystem: LogSubSystem, category: LogCategory) -> Logger { let key = LoggerKey(subSystem: subSystem, category: category) - - if let logger = _LogKit.loggers[key] { + + if let logger = loggers[key] { return logger } else { return Logger(subsystem: subSystem.rawValue, category: category.rawValue) } } - + private func createNewCSVFile() { let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] let fileName = "\(appStartTime)-\(currentCSVFileID).csv" let fileURL = documentsPath.appendingPathComponent(fileName) - + // CSV 헤더 생성 let headerRow = "Timestamp,Level,FileName,Line,Function,Message,SubSystem,Category\n" - + do { try headerRow.write(to: fileURL, atomically: true, encoding: .utf8) currentCSVFileURL = fileURL @@ -131,31 +151,45 @@ public final class _LogKit { print("CSV 로그 파일 생성 실패: \(error)") } } - - private func writeToCSVFile(timestamp: String, level: String, fileName: String, line: String, function: String, message: String, subSystem: String, category: String) { + + private func writeToCSVFile( + timestamp: String, + level: String, + fileName: String, + line: String, + function: String, + message: String, + subSystem: String, + category: String + ) { let escapedMessage = message.replacingOccurrences(of: "\"", with: "\"\"") let escapedFunction = function.replacingOccurrences(of: "\"", with: "\"\"") - + // CSV 행 생성 - let csvRow = "\"\(timestamp)\",\"\(level)\",\"\(fileName)\",\"\(line)\",\"\(escapedFunction)\",\"\(escapedMessage)\",\"\(subSystem)\",\"\(category)\"\n" - - guard let csvData = csvRow.data(using: .utf8) else { return } + let csvRow = + "\"\(timestamp)\",\"\(level)\",\"\(fileName)\",\"\(line)\",\"\(escapedFunction)\",\"\(escapedMessage)\",\"\(subSystem)\",\"\(category)\"\n" + + guard let csvData = csvRow.data(using: .utf8) else { + return + } let dataSize = UInt64(csvData.count) - + // 현재 파일이 최대 크기를 초과하는지 확인 if currentCSVFileSize + dataSize > maxCSVFileSize { currentCSVFileID += 1 createNewCSVFile() } - + // CSV 파일에 로그 추가 - guard let fileURL = currentCSVFileURL else { return } - + guard let fileURL = currentCSVFileURL else { + return + } + if let fileHandle = try? FileHandle(forWritingTo: fileURL) { fileHandle.seekToEndOfFile() fileHandle.write(csvData) try? fileHandle.close() - + currentCSVFileSize += dataSize } } From a50887afedb41af51b433a60ba7ef9852b6730bc Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:02:44 +0900 Subject: [PATCH 17/25] =?UTF-8?q?[Feat]=20LogKit=20Facade=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 복잡한 내부 동작 감추고, 단순한 외부 인터페이스만을 제공 --- .../LogKit/Sources/LogKit/LogKit.swift | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 BookKitty/BookKitty/LogKit/Sources/LogKit/LogKit.swift diff --git a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKit.swift b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKit.swift new file mode 100644 index 00000000..31722fd5 --- /dev/null +++ b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKit.swift @@ -0,0 +1,81 @@ +public enum LogKit { + public static func debug( + _ message: String, + subSystem: LogSubSystem = .app, + category: LogCategory = .general, + file: String = #file, + function: String = #function, + line _: Int = #line + ) { + Task { + await LogKitActor.shared.log( + .debug, + message: message, + subSystem: subSystem, + category: category, + file: file, + function: function + ) + } + } + + public static func log( + _ message: String, + subSystem: LogSubSystem = .app, + category: LogCategory = .general, + file: String = #file, + function: String = #function, + line _: Int = #line + ) { + Task { + await LogKitActor.shared.log( + .log, + message: message, + subSystem: subSystem, + category: category, + file: file, + function: function + ) + } + } + + public static func error( + _ message: String, + subSystem: LogSubSystem = .app, + category: LogCategory = .general, + file: String = #file, + function: String = #function, + line _: Int = #line + ) { + Task { + await LogKitActor.shared.log( + .error, + message: message, + subSystem: subSystem, + category: category, + file: file, + function: function + ) + } + } + + public static func info( + _ message: String, + subSystem: LogSubSystem = .app, + category: LogCategory = .general, + file: String = #file, + function: String = #function, + line _: Int = #line + ) { + Task { + await LogKitActor.shared.log( + .info, + message: message, + subSystem: subSystem, + category: category, + file: file, + function: function + ) + } + } +} From 72831f4d9232ec777f5470e9eef4d926fdf3c229 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:04:44 +0900 Subject: [PATCH 18/25] =?UTF-8?q?[Refactor]=20CSV=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=EB=9D=BC=EC=9D=B8=20=EC=B6=9C=EB=A0=A5=20String?= =?UTF-8?q?=EB=AC=B8=20=EB=8B=A8=EC=9D=BC=EB=9D=BC=EC=9D=B8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/NetworkKit/NetworkManager.swift | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/BookKitty/BookKitty/NetworkKit/Sources/NetworkKit/NetworkManager.swift b/BookKitty/BookKitty/NetworkKit/Sources/NetworkKit/NetworkManager.swift index c1c6d22f..6f190770 100644 --- a/BookKitty/BookKitty/NetworkKit/Sources/NetworkKit/NetworkManager.swift +++ b/BookKitty/BookKitty/NetworkKit/Sources/NetworkKit/NetworkManager.swift @@ -6,6 +6,7 @@ // import Foundation +import LogKit import RxSwift /// 네트워크 기능을 수행하는 객체 @@ -93,7 +94,10 @@ extension NetworkManager { return nil } - NetworkEventLogger.requestDidFinish(request) + LogKit.debug("API 호출 완료 / URL: \(request.url?.absoluteString ?? "")", + subSystem: .app, + category: .network + ) return request } @@ -110,7 +114,11 @@ extension NetworkManager { } if !(200 ... 299).contains(response.statusCode) { - NetworkEventLogger.responseDidFinish(data, response) + LogKit.debug( + "API 응답 완료 / StatusCode: \(response.statusCode) ---> Body: \(JSONtoPrettyString(data) ?? "Empty")", + subSystem: .app, + category: .network + ) } return response @@ -152,6 +160,22 @@ extension NetworkManager { observer(.success(responseData)) } } + + public func JSONtoPrettyString(_ data: Data?) -> String? { + guard let data else { + return nil + } + if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), + let prettyJsonData = try? JSONSerialization.data( + withJSONObject: jsonObject, + options: .prettyPrinted + ), + let prettyPrintedString = String(data: prettyJsonData, encoding: .utf8) { + return prettyPrintedString + } + + return String(data: data, encoding: .utf8) ?? "" + } } // MARK: - Handle Status code From 422e5714faf5dbc038f50ec33452e774473d34eb Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:05:25 +0900 Subject: [PATCH 19/25] =?UTF-8?q?[Delete]=20=EA=B0=81=EC=A2=85=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EA=B3=A0=20=EC=9E=88=EB=8D=98=20Logger=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20LogKit=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BookMatchCore/BookMatchLogger.swift | 92 ------------------- .../DesignSystem/Source/Common/DSLogger.swift | 35 ------- .../Component/NetworkEventLogger.swift | 72 --------------- .../Common/Utility/BookKittyLogger.swift | 35 ------- 4 files changed, 234 deletions(-) delete mode 100644 BookKitty/BookKitty/BookMatchKit/Sources/BookMatchCore/BookMatchLogger.swift delete mode 100644 BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Common/DSLogger.swift delete mode 100644 BookKitty/BookKitty/NetworkKit/Sources/NetworkKit/Component/NetworkEventLogger.swift delete mode 100644 BookKitty/BookKitty/Source/Common/Utility/BookKittyLogger.swift diff --git a/BookKitty/BookKitty/BookMatchKit/Sources/BookMatchCore/BookMatchLogger.swift b/BookKitty/BookKitty/BookMatchKit/Sources/BookMatchCore/BookMatchLogger.swift deleted file mode 100644 index 98f51712..00000000 --- a/BookKitty/BookKitty/BookMatchKit/Sources/BookMatchCore/BookMatchLogger.swift +++ /dev/null @@ -1,92 +0,0 @@ -import Foundation -import OSLog - -public enum BookMatchLogger { - // MARK: - Static Properties - - private static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.BookshelfML.BookKitty", - category: "BookMatchKit" - ) - - // MARK: - Static Functions - - public static func matchingStarted() { - logger.info("📚 도서매칭 시작") - } - - public static func detectorInitializationFailed() { - logger.error("⚠️ CIDetector 초기화 실패") - } - - public static func textSlopeDetectionFailed() { - logger.error("⚠️ 텍스트 기울기 감지 실패") - } - - public static func textsExtracted(_ words: [String]) { - logger.info("📝 최종 OCR 텍스트 추출 완료: \(words.joined(separator: ", "))") - } - - public static func textExtracted(_ words: [String]) { - logger.info("🔍 OCR로 추출된 텍스트: \(words.joined(separator: ", "))") - } - - public static func searchResultsReceived(count: Int) { - logger.info("🔍 추출된 텍스트로 \(count)개의 책 검색됨.") - } - - public static func similarityCalculated(bookTitle: String, score: Double) { - logger.info("📊 '\(bookTitle)'에 대한 이미지 유사도: \(score)") - } - - public static func matchingCompleted(success: Bool, bookTitle: String?) { - if success { - logger.info("✅ 도서 매칭 완료: \(bookTitle ?? "Unknown")") - } else { - logger.error("❌ 도서 매칭 실패") - } - } - - // MARK: - Book Recommendation Logging - - public static func recommendationStarted(question: String?) { - if let question { - logger.info("🎯 도서 추천 시작 - 질문: \(question)") - } else { - logger.info("🎯 보유 도서에 대한 도서 추천 시작") - } - } - - public static func gptResponseReceived(result: String) { - logger.info("🤖 GPT로부터 도서추천반환됨: \(result)") - } - - public static func bookConversionStarted(title: String, author: String) { - logger.info("🔄 도서 매칭 중: \(title) : \(author)") - } - - public static func retryingBookMatch(attempt: Int, currentBook: BookItem) { - logger - .info( - "🔁 GPT에게 도서 재요청 및 재시도: \(attempt)/3 - 현재 도서 = \(currentBook.title) : \(currentBook.author)" - ) - } - - public static func descriptionStarted() { - logger.info("📝 도서 추천이유 작성 중...") - } - - public static func recommendationCompleted(ownedCount: Int, newCount: Int) { - logger.info("✨ 도서추천 완료 - 보유도서 추천 \(ownedCount)개, 미보유도서 추천 \(newCount)개") - } - - // MARK: - Error Logging - - public static func error(_ error: Error, context: String) { - if let error = error as? BookMatchError { - logger.error("❌ Error in \(context): \(error.description)") - } else { - logger.error("❌ Error in \(context): \(error.localizedDescription)") - } - } -} diff --git a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Common/DSLogger.swift b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Common/DSLogger.swift deleted file mode 100644 index 3e863a67..00000000 --- a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Common/DSLogger.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// DSLogger.swift -// DesignSystem -// -// Created by 권승용 on 2/20/25. -// - -import OSLog - -enum DSLogger { - // MARK: - Static Properties - - private static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "DesignSystemPackage", - category: "general" - ) - - // MARK: - Static Functions - - static func log(_ message: String) { - logger.log("\(message)") - } - - static func error(_ message: String) { - logger.error("\(message)") - } - - static func debug(_ message: String) { - logger.debug("\(message)") - } - - static func info(_ message: String) { - logger.info("\(message)") - } -} diff --git a/BookKitty/BookKitty/NetworkKit/Sources/NetworkKit/Component/NetworkEventLogger.swift b/BookKitty/BookKitty/NetworkKit/Sources/NetworkKit/Component/NetworkEventLogger.swift deleted file mode 100644 index f56316af..00000000 --- a/BookKitty/BookKitty/NetworkKit/Sources/NetworkKit/Component/NetworkEventLogger.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// NetworkEventLogger.swift -// NetworkKit -// -// Created by 권승용 on 2/14/25. -// - -import Foundation -import OSLog - -enum NetworkEventLogger { - // MARK: - Static Properties - - // MARK: - Private - - // 로깅에 사용되는 Logger 인스턴스 - - private static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.BookshelfML.BookKitty", - category: "Network" - ) - - // MARK: - Static Functions - - // MARK: - Internal - - /// 네트워크 요청이 완료되었을 때 호출되어 요청 정보를 로깅합니다. - /// - Parameter request: 로깅할 URL 요청 객체 - static func requestDidFinish(_ request: URLRequest) { - logger.debug( - """ - ------------------------------------------------------- - 🤙 API 호출 완료 / URL: \(request.url?.absoluteString ?? "") - ------------------------------------------------------- - """ - ) - } - - /// 네트워크 응답을 받았을 때 호출되어 응답 정보를 로깅합니다. - /// - Parameters: - /// - data: 응답으로 받은 데이터 - /// - response: HTTP 응답 객체 - static func responseDidFinish(_ data: Data?, _ response: HTTPURLResponse) { - logger.debug( - """ - ------------------------------------------------------- - 🛰️ API 응답 완료 / StatusCode: \(response.statusCode) - ------------------------------------------------------- - Body: \(data?.toPrettyString() ?? "Empty") - ------------------------------------------------------- - """ - ) - } -} - -extension Data { - /// Data 객체를 보기 좋은 JSON 문자열로 변환합니다. - /// - Returns: 들여쓰기가 적용된 JSON 문자열. 변환 실패 시 빈 문자열 반환 - func toPrettyString() -> String { - if - let jsonObject = try? JSONSerialization.jsonObject(with: self, options: []), - let prettyJsonData = try? JSONSerialization.data( - withJSONObject: jsonObject, - options: .prettyPrinted - ), - let prettyPrintedString = String(data: prettyJsonData, encoding: .utf8) { - return prettyPrintedString - } - - return String(data: self, encoding: .utf8) ?? "" - } -} diff --git a/BookKitty/BookKitty/Source/Common/Utility/BookKittyLogger.swift b/BookKitty/BookKitty/Source/Common/Utility/BookKittyLogger.swift deleted file mode 100644 index a5e02311..00000000 --- a/BookKitty/BookKitty/Source/Common/Utility/BookKittyLogger.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// BookKittyLogger.swift -// BookKitty -// -// Created by 권승용 on 2/11/25. -// - -import OSLog - -enum BookKittyLogger { - // MARK: - Static Properties - - private static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.bookkitty", - category: "general" - ) - - // MARK: - Static Functions - - static func log(_ message: String) { - logger.log("\(message)") - } - - static func error(_ message: String) { - logger.error("\(message)") - } - - static func debug(_ message: String) { - logger.debug("\(message)") - } - - static func info(_ message: String) { - logger.info("\(message)") - } -} From f3453046cb45b41f28eb6b6b75ae76dd9cee99b3 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:05:59 +0900 Subject: [PATCH 20/25] =?UTF-8?q?[Refactor]=20=EA=B0=81=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EC=97=90=20=EB=8C=80=ED=95=B4=20LogKit=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BookKitty/BookMatchKit/Package.swift | 10 +++++++ .../BookKitty/DesignSystem/Package.swift | 2 ++ BookKitty/BookKitty/LogKit/Package.swift | 28 +++++++++++++++++++ BookKitty/BookKitty/NetworkKit/Package.swift | 6 +++- 4 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 BookKitty/BookKitty/LogKit/Package.swift diff --git a/BookKitty/BookKitty/BookMatchKit/Package.swift b/BookKitty/BookKitty/BookMatchKit/Package.swift index e1392203..ede3e48b 100644 --- a/BookKitty/BookKitty/BookMatchKit/Package.swift +++ b/BookKitty/BookKitty/BookMatchKit/Package.swift @@ -38,6 +38,7 @@ let package = Package( .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.8.0"), .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.55.0"), .package(path: "../NetworkKit"), + .package(path: "../LogKit"), ], targets: [ .target( @@ -45,6 +46,7 @@ let package = Package( dependencies: [ "RxSwift", "SwiftFormat", "BookMatchCore", "BookMatchAPI", "BookMatchStrategy", "BookMatchService", + .product(name: "LogKit", package: "LogKit"), ] ), .target( @@ -52,6 +54,7 @@ let package = Package( dependencies: [ "RxSwift", "SwiftFormat", "BookMatchCore", "BookMatchAPI", "BookMatchService", + .product(name: "LogKit", package: "LogKit"), ] ), .target( @@ -59,12 +62,14 @@ let package = Package( dependencies: [ "RxSwift", "SwiftFormat", "BookMatchCore", + .product(name: "LogKit", package: "LogKit"), ] ), .target( name: "BookMatchCore", dependencies: [ "RxSwift", "SwiftFormat", + .product(name: "LogKit", package: "LogKit"), ] ), .target( @@ -72,6 +77,7 @@ let package = Package( dependencies: [ "RxSwift", "SwiftFormat", "BookMatchCore", "BookMatchAPI", + .product(name: "LogKit", package: "LogKit"), ], resources: [ .process("Resources/MyObjectDetector5_1.mlmodel"), @@ -83,6 +89,7 @@ let package = Package( "RxSwift", "SwiftFormat", "BookMatchCore", .product(name: "NetworkKit", package: "NetworkKit"), + .product(name: "LogKit", package: "LogKit"), ] ), .testTarget( @@ -90,6 +97,9 @@ let package = Package( dependencies: [ "RxSwift", "SwiftFormat", "BookOCRKit", "BookRecommendationKit", "BookMatchCore", + ], + resources: [ + .process("Resources/images"), ] ), ] diff --git a/BookKitty/BookKitty/DesignSystem/Package.swift b/BookKitty/BookKitty/DesignSystem/Package.swift index 1babd113..a6429d40 100644 --- a/BookKitty/BookKitty/DesignSystem/Package.swift +++ b/BookKitty/BookKitty/DesignSystem/Package.swift @@ -21,6 +21,7 @@ let package = Package( .package(url: "https://github.com/devxoul/Then.git", from: "3.0.0"), .package(url: "https://github.com/airbnb/lottie-spm.git", from: "4.5.1"), .package(path: "../NeoImage"), + .package(path: "../LogKit"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -32,6 +33,7 @@ let package = Package( .product(name: "Then", package: "Then"), .product(name: "Lottie", package: "lottie-spm"), .product(name: "NeoImage", package: "NeoImage"), + .product(name: "LogKit", package: "LogKit"), ], resources: [ .process("Resource/Fonts"), diff --git a/BookKitty/BookKitty/LogKit/Package.swift b/BookKitty/BookKitty/LogKit/Package.swift new file mode 100644 index 00000000..a2b1fc41 --- /dev/null +++ b/BookKitty/BookKitty/LogKit/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "LogKit", + platforms: [.iOS(.v16)], + products: [ + // Products define the executables and libraries a package produces, making them visible to + // other packages. + .library( + name: "LogKit", + targets: ["LogKit"] + ), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "LogKit" + ), + .testTarget( + name: "LogKitTests", + dependencies: ["LogKit"] + ), + ] +) diff --git a/BookKitty/BookKitty/NetworkKit/Package.swift b/BookKitty/BookKitty/NetworkKit/Package.swift index 531d8098..fb4ba8e6 100644 --- a/BookKitty/BookKitty/NetworkKit/Package.swift +++ b/BookKitty/BookKitty/NetworkKit/Package.swift @@ -17,13 +17,17 @@ let package = Package( dependencies: [ .package(url: "https://github.com/ReactiveX/RxSwift.git", .upToNextMajor(from: "6.0.0")), .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.55.0"), + .package(path: "../LogKit"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "NetworkKit", - dependencies: ["RxSwift", "SwiftFormat"] + dependencies: [ + "RxSwift", "SwiftFormat", + .product(name: "LogKit", package: "LogKit"), + ] ), .testTarget( name: "NetworkKitTests", From 6d430514f74eca22d34d36af6dffef484a6734ee Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:07:00 +0900 Subject: [PATCH 21/25] =?UTF-8?q?[Refactor]=20Log=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=AA=A8=EB=91=90=20LogKit=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=BC=EA=B4=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BookKitty/BookKitty.xcodeproj/project.pbxproj | 7 +++++ .../Service/BookValidationService.swift | 14 ++++++--- .../Service/TextExtractionService.swift | 27 ++++++++++++----- .../Sources/BookOCRKit/BookOCRKit.swift | 19 ++++++------ .../BookRecommendationKit.swift | 29 ++++++++++--------- .../Source/Extension/UIFont+Extensions.swift | 13 +++++---- .../AddBook/View/AddBookViewController.swift | 7 +++-- .../AddBook/ViewModel/AddBookViewModel.swift | 8 ++--- .../View/AddBookByTitleViewController.swift | 3 +- .../ViewModel/AddBookByTitleViewModel.swift | 9 +++--- .../ViewModel/BookDetailViewModel.swift | 5 ++-- .../Home/View/HomeViewController.swift | 3 +- .../ViewModel/QuestionDetailViewModel.swift | 3 +- .../ViewModel/QuestionResultViewModel.swift | 5 ++-- .../Persistence/BookCoreDataManager.swift | 19 ++++++------ .../BookQALinkCoreDataManager.swift | 9 +++--- .../Source/Persistence/CoreDataStack.swift | 3 +- .../QuestionAnswerCoreDataManager.swift | 17 ++++++----- .../Repository/LocalBookRepository.swift | 23 ++++++++------- .../LocalQuestionHistoryRepository.swift | 5 ++-- 20 files changed, 135 insertions(+), 93 deletions(-) diff --git a/BookKitty/BookKitty.xcodeproj/project.pbxproj b/BookKitty/BookKitty.xcodeproj/project.pbxproj index 23fcdb24..1ae32e32 100644 --- a/BookKitty/BookKitty.xcodeproj/project.pbxproj +++ b/BookKitty/BookKitty.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 606DA2452D42076100C7FAA3 /* Then in Frameworks */ = {isa = PBXBuildFile; productRef = 606DA2442D42076100C7FAA3 /* Then */; }; 606DA2482D42079900C7FAA3 /* Differentiator in Frameworks */ = {isa = PBXBuildFile; productRef = 606DA2472D42079900C7FAA3 /* Differentiator */; }; 60A1CC0E2D54A2DB00091568 /* BookRecommendationKit in Frameworks */ = {isa = PBXBuildFile; productRef = 60A1CC0D2D54A2DB00091568 /* BookRecommendationKit */; }; + E56D6D062D73E8C500B6E6E9 /* LogKit in Frameworks */ = {isa = PBXBuildFile; productRef = E56D6D052D73E8C500B6E6E9 /* LogKit */; }; E5A6B99D2D5F54C300A2E06D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E5A6B99C2D5F54C300A2E06D /* PrivacyInfo.xcprivacy */; }; E5A6B99E2D5F54C300A2E06D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E5A6B99C2D5F54C300A2E06D /* PrivacyInfo.xcprivacy */; }; E5DE19642D62A7D3007D37E2 /* BookOCRKit in Frameworks */ = {isa = PBXBuildFile; productRef = E5DE19632D62A7D3007D37E2 /* BookOCRKit */; }; @@ -81,6 +82,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E56D6D062D73E8C500B6E6E9 /* LogKit in Frameworks */, 606DA2452D42076100C7FAA3 /* Then in Frameworks */, E93048662D559553008E9467 /* RxCocoa in Frameworks */, E5FC698C2D52414B002875FD /* SnapKit in Frameworks */, @@ -173,6 +175,7 @@ 4584C5B02D685AB300173282 /* FirebaseCore */, 4584C5B22D685AB300173282 /* FirebaseCrashlytics */, E5FFE2EF2D6A3F4200A0F7CF /* NeoImage */, + E56D6D052D73E8C500B6E6E9 /* LogKit */, ); productName = BookKitty; productReference = 60551C652D40E6E800CFC16A /* BookKitty.app */; @@ -698,6 +701,10 @@ isa = XCSwiftPackageProductDependency; productName = BookRecommendationKit; }; + E56D6D052D73E8C500B6E6E9 /* LogKit */ = { + isa = XCSwiftPackageProductDependency; + productName = LogKit; + }; E5DE19632D62A7D3007D37E2 /* BookOCRKit */ = { isa = XCSwiftPackageProductDependency; productName = BookOCRKit; diff --git a/BookKitty/BookKitty/BookMatchKit/Sources/BookMatchService/Service/BookValidationService.swift b/BookKitty/BookKitty/BookMatchKit/Sources/BookMatchService/Service/BookValidationService.swift index 6e146381..624f48f2 100644 --- a/BookKitty/BookKitty/BookMatchKit/Sources/BookMatchService/Service/BookValidationService.swift +++ b/BookKitty/BookKitty/BookMatchKit/Sources/BookMatchService/Service/BookValidationService.swift @@ -1,6 +1,7 @@ import BookMatchAPI import BookMatchCore import BookMatchStrategy +import LogKit import RxSwift public final class BookValidationService: BookValidatable { @@ -45,7 +46,11 @@ public final class BookValidationService: BookValidatable { previousBooks: [RawBook], openAiAPI: OpenAIAPI ) -> Single { - BookMatchLogger.bookConversionStarted(title: book.title, author: book.author) + LogKit.info( + "도서 매칭 중: \(book.title) : \(book.author)", + subSystem: .bookRecommendation, + category: .lifecycle + ) var retryCount = 0 var currentBook = book @@ -71,9 +76,10 @@ public final class BookValidationService: BookValidatable { candidates.append((matchedBook, result.similarity)) retryCount += 1 - BookMatchLogger.retryingBookMatch( - attempt: retryCount, - currentBook: matchedBook + LogKit.info( + "GPT에게 도서 재요청 및 재시도: \(retryCount)/3 - 현재 도서 = \(matchedBook.title) : \(matchedBook.author)", + subSystem: .bookRecommendation, + category: .lifecycle ) return openAiAPI.getAdditionalBook( diff --git a/BookKitty/BookKitty/BookMatchKit/Sources/BookMatchService/Service/TextExtractionService.swift b/BookKitty/BookKitty/BookMatchKit/Sources/BookMatchService/Service/TextExtractionService.swift index 8945a537..08e41f81 100644 --- a/BookKitty/BookKitty/BookMatchKit/Sources/BookMatchService/Service/TextExtractionService.swift +++ b/BookKitty/BookKitty/BookMatchKit/Sources/BookMatchService/Service/TextExtractionService.swift @@ -1,5 +1,6 @@ import BookMatchCore import CoreImage +import LogKit import NaturalLanguage import RxSwift import UIKit @@ -61,16 +62,21 @@ public final class TextExtractionService: TextExtractable { return .error(BookMatchError.CoreMLError("No Result from CoreML")) } - BookMatchLogger.textsExtracted(texts) + LogKit.info( + "OCR로 추출된 텍스트: \(texts.joined(separator: ", "))", + subSystem: .bookOCR, + category: .lifecycle + ) + return .just(texts) } } - .catch { [weak self] error in + .catch { [weak self] _ in guard let self else { return .error(BookMatchError.deinitError) } - BookMatchLogger.error(error, context: "extract Text") + LogKit.error("extract Text", subSystem: .bookOCR) return performOCR(on: image) } @@ -151,7 +157,12 @@ public final class TextExtractionService: TextExtractable { $0.topCandidates(1).first?.string } - BookMatchLogger.textExtracted(recognizedText) + LogKit.info( + "OCR로 추출된 텍스트: \(recognizedText.joined(separator: ", "))", + subSystem: .bookOCR, + category: .lifecycle + ) + single(.success(recognizedText)) } @@ -167,8 +178,8 @@ public final class TextExtractionService: TextExtractable { return Disposables.create() } - .catch { error in - BookMatchLogger.error(error, context: "performOCR") + .catch { _ in + LogKit.error("performOCR", subSystem: .bookOCR) return .just([]) } } @@ -217,12 +228,12 @@ public final class TextExtractionService: TextExtractable { context: nil, options: nil ) else { - BookMatchLogger.detectorInitializationFailed() + LogKit.error("CIDetector 초기화 실패", subSystem: .bookOCR) return image } guard let feature = detector.features(in: image).first as? CIRectangleFeature else { - BookMatchLogger.textSlopeDetectionFailed() + LogKit.error("텍스트 기울기 감지 실패", subSystem: .bookOCR) return image } diff --git a/BookKitty/BookKitty/BookMatchKit/Sources/BookOCRKit/BookOCRKit.swift b/BookKitty/BookKitty/BookMatchKit/Sources/BookOCRKit/BookOCRKit.swift index 9db31339..93afdd50 100644 --- a/BookKitty/BookKitty/BookMatchKit/Sources/BookOCRKit/BookOCRKit.swift +++ b/BookKitty/BookKitty/BookMatchKit/Sources/BookOCRKit/BookOCRKit.swift @@ -2,6 +2,7 @@ import BookMatchAPI import BookMatchCore import BookMatchService import BookMatchStrategy +import LogKit import RxSwift import UIKit @@ -46,7 +47,7 @@ public final class BookOCRKit: BookMatchable { /// - Returns: 매칭된 도서 정보 또는 nil /// - Throws: 초기 단어부터 검색된 결과가 나오지 않을 때 public func recognizeBookFromImage(_ image: UIImage) -> Single { - BookMatchLogger.matchingStarted() + LogKit.info("도서매칭 시작", subSystem: .bookOCR, category: .lifecycle) return textExtractionService.extractText(from: image) // `flatMap` - 텍스트 추출 결과를 도서 검색 결과로 변환 @@ -61,15 +62,15 @@ public final class BookOCRKit: BookMatchable { return searchService.searchProgressively(from: textData) .flatMap { results in guard !results.isEmpty else { // 유의미한 책 검색결과가 나오지 않았을 경우, 에러를 반환합니다 - BookMatchLogger.error( - BookMatchError.noMatchFound, - context: "Book Search" + LogKit.error( + "Book Search: \(BookMatchError.noMatchFound.description)", + subSystem: .bookOCR ) return .error(BookMatchError.noMatchFound) } - BookMatchLogger.searchResultsReceived(count: results.count) + LogKit.info("추출된 텍스트로 \(results.count)개의 책 검색됨.", subSystem: .bookOCR) return .just(results) } } @@ -92,9 +93,9 @@ public final class BookOCRKit: BookMatchable { downloadedImage ) - BookMatchLogger.similarityCalculated( - bookTitle: book.title, - score: similarity + LogKit.info( + "'\(book.title)'에 대한 이미지 유사도: \(similarity)", + subSystem: .bookOCR ) return .just((book, similarity)) @@ -113,7 +114,7 @@ public final class BookOCRKit: BookMatchable { throw BookMatchError.noMatchFound } - BookMatchLogger.matchingCompleted(success: true, bookTitle: bestMatchedBook.title) + LogKit.info("도서 매칭 완료: \(bestMatchedBook.title)", subSystem: .bookOCR) return bestMatchedBook } } diff --git a/BookKitty/BookKitty/BookMatchKit/Sources/BookRecommendationKit/BookRecommendationKit.swift b/BookKitty/BookKitty/BookMatchKit/Sources/BookRecommendationKit/BookRecommendationKit.swift index 6e9ff085..fc562e55 100644 --- a/BookKitty/BookKitty/BookMatchKit/Sources/BookRecommendationKit/BookRecommendationKit.swift +++ b/BookKitty/BookKitty/BookMatchKit/Sources/BookRecommendationKit/BookRecommendationKit.swift @@ -2,6 +2,7 @@ import BookMatchAPI import BookMatchCore import BookMatchService import CoreFoundation +import LogKit import RxSwift import UIKit @@ -50,7 +51,7 @@ public final class BookRecommendationKit: BookRecommendable { /// - ownedBooks: 사용자가 보유한 도서 목록 /// - Returns: 추천된 도서 목록 public func recommendBooks(from ownedBooks: [OwnedBook]) -> Single<[BookItem]> { - BookMatchLogger.recommendationStarted(question: nil) + LogKit.info("보유 도서에 대한 도서 추천 시작", subSystem: .bookRecommendation) return openAiAPI.getBookRecommendation(ownedBooks: ownedBooks) // `flatMap` - GPT 추천 결과를 `실제 도서로 변환` @@ -111,7 +112,7 @@ public final class BookRecommendationKit: BookRecommendable { /// - Throws: BookMatchError.questionShort (질문이 4글자 미만인 경우) public func recommendBooks(for question: String, from ownedBooks: [OwnedBook]) -> Single { - BookMatchLogger.recommendationStarted(question: question) + LogKit.info("도서 추천 시작 - 질문: \(question)", subSystem: .bookRecommendation) return openAiAPI.getBookRecommendation(question: question, ownedBooks: ownedBooks) // `do` - 사이드 이펙트 처리하며 스트림을 계속 진행해야하는 상황이므로 선택, Subscribe는 체이닝이 종료되는 시점에 사용 @@ -123,7 +124,7 @@ public final class BookRecommendationKit: BookRecommendable { 미보유 도서 기반 추천 목록: \(result.newBooks.map(\.title)) """ - BookMatchLogger.gptResponseReceived(result: resultString) + LogKit.info("GPT로부터 도서추천반환됨: \(resultString)", subSystem: .bookRecommendation) }) // `flatMap` - 추천 결과를 실제 도서로 변환 // - Note: GPT 추천 결과를 실제 도서 정보로 변환할 때 사용. @@ -133,9 +134,9 @@ public final class BookRecommendationKit: BookRecommendable { books: [BookItem] )> in guard let self else { - BookMatchLogger.error( - BookMatchError.deinitError, - context: "추천 절차" + LogKit.error( + "추천 절차: \(BookMatchError.deinitError.description)", + subSystem: .bookRecommendation ) return .error(BookMatchError.deinitError) @@ -187,9 +188,9 @@ public final class BookRecommendationKit: BookRecommendable { // 3. BookMatchModuleOutput 형식으로 최종 변환 .flatMap { [weak self] result -> Single in guard let self else { - BookMatchLogger.error( - BookMatchError.deinitError, - context: "도서 매칭 절차" + LogKit.error( + "도서 매칭 절차: \(BookMatchError.deinitError.description)", + subSystem: .bookRecommendation ) return .error(BookMatchError.deinitError) @@ -216,7 +217,7 @@ public final class BookRecommendationKit: BookRecommendable { RawBook(title: $0.title, author: $0.author) } - BookMatchLogger.descriptionStarted() + LogKit.info("도서 추천이유 작성 중...", subSystem: .bookRecommendation) return openAiAPI.getDescription( question: question, @@ -231,9 +232,9 @@ public final class BookRecommendationKit: BookRecommendable { .map { description in let newBooks = Array(Set(result.books)) - BookMatchLogger.recommendationCompleted( - ownedCount: filteredOwnedBooks.count, - newCount: newBooks.count + LogKit.info( + "도서추천 완료 - 보유도서 추천 \(filteredOwnedBooks.count)개, 미보유도서 추천 \(newBooks.count)개", + subSystem: .bookRecommendation ) return BookMatchModuleOutput( @@ -244,7 +245,7 @@ public final class BookRecommendationKit: BookRecommendable { } } .catch { error in - BookMatchLogger.error(error, context: "도서 추천") + LogKit.error("도서 추천: \(error.localizedDescription)", subSystem: .bookRecommendation) if let bookMatchError = error as? BookMatchError { switch bookMatchError { diff --git a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Extension/UIFont+Extensions.swift b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Extension/UIFont+Extensions.swift index 05c7a480..40224d52 100644 --- a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Extension/UIFont+Extensions.swift +++ b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Extension/UIFont+Extensions.swift @@ -6,6 +6,7 @@ // import CoreText +import LogKit import UIKit /// 현재 프로젝트에 사용되는 폰트 종류를 선언. @@ -26,24 +27,26 @@ extension UIFont { /// 폰트 등록 메서드 public static func registerFont(name: String, extension ext: String) { guard let fontURL = Bundle.module.url(forResource: name, withExtension: ext) else { - DSLogger.log("폰트 파일을 찾을 수 없음: \(name).\(ext)") + LogKit.error("폰트 파일을 찾을 수 없음: \(name).\(ext)", subSystem: .designSystem) return } guard let fontDataProvider = CGDataProvider(url: fontURL as CFURL), let fontRef = CGFont(fontDataProvider) else { - DSLogger.log("폰트 등록 실패: \(name).\(ext)") + LogKit.error("폰트 등록 실패: \(name).\(ext)", subSystem: .designSystem) return } var error: Unmanaged? if !CTFontManagerRegisterGraphicsFont(fontRef, &error) { - DSLogger - .error("폰트 등록 중 오류 발생: \(name), \(String(describing: error?.takeRetainedValue()))") + LogKit.error( + "폰트 등록 중 오류 발생: \(name), \(String(describing: error?.takeRetainedValue()))", + subSystem: .designSystem + ) } else { - DSLogger.log("폰트 등록 완료: \(name)") + LogKit.log("폰트 등록 완료: \(name)", subSystem: .designSystem) } } diff --git a/BookKitty/BookKitty/Source/Feature/AddBook/View/AddBookViewController.swift b/BookKitty/BookKitty/Source/Feature/AddBook/View/AddBookViewController.swift index 4df6e0dd..a2fbb590 100644 --- a/BookKitty/BookKitty/Source/Feature/AddBook/View/AddBookViewController.swift +++ b/BookKitty/BookKitty/Source/Feature/AddBook/View/AddBookViewController.swift @@ -1,5 +1,6 @@ import AVFoundation import DesignSystem +import LogKit import RxCocoa import RxSwift import SnapKit @@ -292,7 +293,7 @@ extension AddBookViewController: AVCapturePhotoCaptureDelegate { return } - BookKittyLogger.log("📸 이미지 캡처 성공") + LogKit.log("이미지 캡처 성공") capturedImageRelay.accept(image) } @@ -348,7 +349,7 @@ extension AddBookViewController: AVCapturePhotoCaptureDelegate { captureSession.sessionPreset = .photo guard let captureDevice = AVCaptureDevice.default(for: .video) else { - BookKittyLogger.error("🚨 카메라 장치를 찾을 수 없음") + LogKit.error("카메라 장치를 찾을 수 없음") return } @@ -378,7 +379,7 @@ extension AddBookViewController: AVCapturePhotoCaptureDelegate { } } } catch { - BookKittyLogger.error("🚨 카메라 초기화 실패: \(error.localizedDescription)") + LogKit.error("카메라 초기화 실패: \(error.localizedDescription)") } } } diff --git a/BookKitty/BookKitty/Source/Feature/AddBook/ViewModel/AddBookViewModel.swift b/BookKitty/BookKitty/Source/Feature/AddBook/ViewModel/AddBookViewModel.swift index 2e537a1b..50b70300 100644 --- a/BookKitty/BookKitty/Source/Feature/AddBook/ViewModel/AddBookViewModel.swift +++ b/BookKitty/BookKitty/Source/Feature/AddBook/ViewModel/AddBookViewModel.swift @@ -1,6 +1,7 @@ import BookMatchCore import BookOCRKit import Foundation +import LogKit import RxCocoa import RxSwift import UIKit @@ -87,8 +88,7 @@ final class AddBookViewModel: ViewModelType { }, onFailure: { error in - BookKittyLogger - .error("Error: \(error.localizedDescription)") + LogKit.error("Error: \(error.localizedDescription)") switch error { case BookMatchError.networkError: self?.errorRelay.accept(NetworkError.networkUnstable) @@ -115,12 +115,12 @@ final class AddBookViewModel: ViewModelType { if isSaved { owner.navigateBackRelay.accept(()) } else { - BookKittyLogger.log("중복된 책 에러 발생") + LogKit.error("중복된 책 에러 발생") owner.errorRelay.accept(AddBookError.duplicatedBook) } }, onError: { owner, error in guard let error = error as? AlertPresentableError else { - BookKittyLogger.debug("error is not AlertPresentableError") + LogKit.debug("error is not AlertPresentableError") return } owner.errorRelay.accept(error) diff --git a/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/AddBookByTitleViewController.swift b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/AddBookByTitleViewController.swift index 56182f5c..787b0f6b 100644 --- a/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/AddBookByTitleViewController.swift +++ b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/AddBookByTitleViewController.swift @@ -6,6 +6,7 @@ // import DesignSystem +import LogKit import RxCocoa import RxSwift import SnapKit @@ -197,7 +198,7 @@ extension AddBookByTitleViewController: CustomSearchBarDelegate { extension AddBookByTitleViewController: UICollectionViewDelegate { func collectionView(_: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let selectedBook = dataSource.itemIdentifier(for: indexPath) else { - BookKittyLogger.error("선택된 Book 존재하지 않음") + LogKit.error("선택된 Book 존재하지 않음") return } diff --git a/BookKitty/BookKitty/Source/Feature/AddBookByTitle/ViewModel/AddBookByTitleViewModel.swift b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/ViewModel/AddBookByTitleViewModel.swift index 2a1c14bb..537c9e7d 100644 --- a/BookKitty/BookKitty/Source/Feature/AddBookByTitle/ViewModel/AddBookByTitleViewModel.swift +++ b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/ViewModel/AddBookByTitleViewModel.swift @@ -7,6 +7,7 @@ import BookOCRKit import Foundation +import LogKit import RxCocoa import RxSwift @@ -51,12 +52,12 @@ final class AddBookByTitleViewModel: ViewModelType { input.addBookButtonTapped .withUnretained(self) .map { owner, book in - BookKittyLogger.log("책 추가 버튼 탭") + LogKit.log("책 추가 버튼 탭") if !owner.bookRepository.saveBook(book: book) { - BookKittyLogger.error("책 저장 실패") + LogKit.error("책 저장 실패") } if !owner.bookRepository.addBookToShelf(isbn: book.isbn) { - BookKittyLogger.error("책 내 서재 추가 실패") + LogKit.error("책 내 서재 추가 실패") } // TODO: 에러 처리 필요 } @@ -68,7 +69,7 @@ final class AddBookByTitleViewModel: ViewModelType { .flatMapLatest { owner, searchResult in owner.bookOcrKit.searchBookFromText(searchResult) .catch { error in - BookKittyLogger.log("책 검색 실패: \(error.localizedDescription)") + LogKit.error("책 검색 실패: \(error.localizedDescription)") // TODO: 에러 처리 필요 return .just([]) } diff --git a/BookKitty/BookKitty/Source/Feature/BookDetail/ViewModel/BookDetailViewModel.swift b/BookKitty/BookKitty/Source/Feature/BookDetail/ViewModel/BookDetailViewModel.swift index 42307137..79d9ad10 100644 --- a/BookKitty/BookKitty/Source/Feature/BookDetail/ViewModel/BookDetailViewModel.swift +++ b/BookKitty/BookKitty/Source/Feature/BookDetail/ViewModel/BookDetailViewModel.swift @@ -6,6 +6,7 @@ // import Foundation +import LogKit import RxCocoa import RxRelay import RxSwift @@ -65,11 +66,11 @@ final class BookDetailViewModel: ViewModelType { // TODO: 오류 처리 case true: if !owner.bookRepository.exceptBookFromShelf(isbn: bookDetail.isbn) { - BookKittyLogger.error("책장에서 책 제외 실패") + LogKit.error("책장에서 책 제외 실패") } case false: if !owner.bookRepository.addBookToShelf(isbn: bookDetail.isbn) { - BookKittyLogger.error("책장에 책 추가 실패") + LogKit.error("책장에 책 추가 실패") } } }) diff --git a/BookKitty/BookKitty/Source/Feature/Home/View/HomeViewController.swift b/BookKitty/BookKitty/Source/Feature/Home/View/HomeViewController.swift index 59dae0b2..96691518 100644 --- a/BookKitty/BookKitty/Source/Feature/Home/View/HomeViewController.swift +++ b/BookKitty/BookKitty/Source/Feature/Home/View/HomeViewController.swift @@ -7,6 +7,7 @@ import DesignSystem import FirebaseAnalytics +import LogKit import RxCocoa import RxDataSources import RxRelay @@ -160,7 +161,7 @@ class HomeViewController: BaseViewController { output.error .withUnretained(self) .subscribe(onNext: { _, error in - BookKittyLogger.error("에러 발생 : \(error.localizedDescription)") + LogKit.error("에러 발생 : \(error.localizedDescription)") // TODO: 에러 팝업 연결 }) .disposed(by: disposeBag) diff --git a/BookKitty/BookKitty/Source/Feature/QuestionDetail/ViewModel/QuestionDetailViewModel.swift b/BookKitty/BookKitty/Source/Feature/QuestionDetail/ViewModel/QuestionDetailViewModel.swift index 3b40901b..182dd92a 100644 --- a/BookKitty/BookKitty/Source/Feature/QuestionDetail/ViewModel/QuestionDetailViewModel.swift +++ b/BookKitty/BookKitty/Source/Feature/QuestionDetail/ViewModel/QuestionDetailViewModel.swift @@ -6,6 +6,7 @@ // import Foundation +import LogKit import RxCocoa import RxRelay import RxSwift @@ -87,7 +88,7 @@ final class QuestionDetailViewModel: ViewModelType { .subscribe(with: self) { owner, _ in if !owner.questionHistoryRepository .deleteQuestionAnswer(uuid: owner.questionAnswer.id) { - BookKittyLogger.error("질문 내역 삭제 실패!") + LogKit.error("질문 내역 삭제 실패!") } owner.dismissViewController.accept(()) } diff --git a/BookKitty/BookKitty/Source/Feature/QuestionResult/ViewModel/QuestionResultViewModel.swift b/BookKitty/BookKitty/Source/Feature/QuestionResult/ViewModel/QuestionResultViewModel.swift index 222a4833..a84f0b98 100644 --- a/BookKitty/BookKitty/Source/Feature/QuestionResult/ViewModel/QuestionResultViewModel.swift +++ b/BookKitty/BookKitty/Source/Feature/QuestionResult/ViewModel/QuestionResultViewModel.swift @@ -8,6 +8,7 @@ import BookRecommendationKit import FirebaseAnalytics import Foundation +import LogKit import RxCocoa import RxSwift @@ -91,7 +92,7 @@ final class QuestionResultViewModel: ViewModelType { guard let updatedQnA = owner.questionHistoryRepository.fetchQuestion(by: uuid) else { - BookKittyLogger.error("해당하는 uuid의 질의응답이 없습니다.") + LogKit.error("해당하는 uuid의 질의응답이 없습니다.") return [] } @@ -160,7 +161,7 @@ final class QuestionResultViewModel: ViewModelType { return } - BookKittyLogger.error("추천 서비스에서 에러 발생 : \(error.localizedDescription)") + LogKit.error("추천 서비스에서 에러 발생 : \(error.localizedDescription)") switch error { case .networkError: diff --git a/BookKitty/BookKitty/Source/Persistence/BookCoreDataManager.swift b/BookKitty/BookKitty/Source/Persistence/BookCoreDataManager.swift index 1ab5f9bd..aed59348 100644 --- a/BookKitty/BookKitty/Source/Persistence/BookCoreDataManager.swift +++ b/BookKitty/BookKitty/Source/Persistence/BookCoreDataManager.swift @@ -6,6 +6,7 @@ // import CoreData +import LogKit /// Book 엔티티를 관리하는 객체 final class BookCoreDataManager: BookCoreDataManageable { @@ -45,10 +46,10 @@ final class BookCoreDataManager: BookCoreDataManageable { do { _ = modelToEntity(model: model, context: context) try context.save() - BookKittyLogger.log("책 저장 성공") + LogKit.log("책 저장 성공") return true } catch { - BookKittyLogger.log("책 저장 실패: \(error.localizedDescription)") + LogKit.log("책 저장 실패: \(error.localizedDescription)") return false } } @@ -83,13 +84,13 @@ final class BookCoreDataManager: BookCoreDataManageable { do { if let bookEntity = try context.fetch(request).first { if bookEntity.isbn == isbn { - BookKittyLogger.log("책 데이터 가져오기 성공: \(bookEntity)") + LogKit.log("책 데이터 가져오기 성공: \(bookEntity)") return bookEntity } } return nil } catch { - BookKittyLogger.log("책 데이터 가져오기 실패: \(error.localizedDescription)") + LogKit.log("책 데이터 가져오기 실패: \(error.localizedDescription)") return nil } } @@ -120,10 +121,10 @@ final class BookCoreDataManager: BookCoreDataManageable { do { let fetchedEntity = try context.fetch(request) - BookKittyLogger.log("책장 책 가져오기 성공") + LogKit.log("책장 책 가져오기 성공") return fetchedEntity } catch { - BookKittyLogger.log("책장 책 목록 가져오기 실패: \(error.localizedDescription)") + LogKit.log("책장 책 목록 가져오기 실패: \(error.localizedDescription)") return [] } } @@ -142,10 +143,10 @@ final class BookCoreDataManager: BookCoreDataManageable { do { let fetchedEntity = try context.fetch(request) - BookKittyLogger.log("ISBN 목록으로 책 데이터 가져오기 성공") + LogKit.log("ISBN 목록으로 책 데이터 가져오기 성공") return fetchedEntity } catch { - BookKittyLogger.log("ISBN 목록으로 책 데이터 가져오기 실패: \(error.localizedDescription)") + LogKit.log("ISBN 목록으로 책 데이터 가져오기 실패: \(error.localizedDescription)") return [] } } @@ -184,7 +185,7 @@ final class BookCoreDataManager: BookCoreDataManageable { do { return try context.count(for: request) } catch { - BookKittyLogger.log("소유한 책 개수 가져오기 실패: \(error.localizedDescription)") + LogKit.log("소유한 책 개수 가져오기 실패: \(error.localizedDescription)") return 0 } } diff --git a/BookKitty/BookKitty/Source/Persistence/BookQALinkCoreDataManager.swift b/BookKitty/BookKitty/Source/Persistence/BookQALinkCoreDataManager.swift index 1e6f7195..38c64216 100644 --- a/BookKitty/BookKitty/Source/Persistence/BookQALinkCoreDataManager.swift +++ b/BookKitty/BookKitty/Source/Persistence/BookQALinkCoreDataManager.swift @@ -6,6 +6,7 @@ // import CoreData +import LogKit /// BookQuestionAnswerLink 엔티티를 관리하는 객체 final class BookQALinkCoreDataManager: BookQALinkCoreDataManageable { @@ -22,10 +23,10 @@ final class BookQALinkCoreDataManager: BookQALinkCoreDataManageable { do { let fetchresult = try context.fetch(fetchRequest) - BookKittyLogger.log("최근 추천책 조회 성공") + LogKit.log("최근 추천책 조회 성공") return fetchresult } catch { - BookKittyLogger.log("최근 추천책 조회 실패: \(error.localizedDescription)") + LogKit.log("최근 추천책 조회 실패: \(error.localizedDescription)") return [] } } @@ -46,7 +47,7 @@ final class BookQALinkCoreDataManager: BookQALinkCoreDataManageable { linkEntity.questionAnswer = questionAnswerEntity linkEntity.createdAt = Date() - BookKittyLogger.log("BookQuestionAnswerLinkEntity 생성 성공") + LogKit.log("BookQuestionAnswerLinkEntity 생성 성공") return linkEntity } @@ -74,7 +75,7 @@ final class BookQALinkCoreDataManager: BookQALinkCoreDataManageable { // 각 링크 엔티티에서 `book`을 추출 return linkedEntities.compactMap(\.book) } catch { - BookKittyLogger.log("질문 ID에 연결된 책 조회 실패: \(error.localizedDescription)") + LogKit.log("질문 ID에 연결된 책 조회 실패: \(error.localizedDescription)") return [] } } diff --git a/BookKitty/BookKitty/Source/Persistence/CoreDataStack.swift b/BookKitty/BookKitty/Source/Persistence/CoreDataStack.swift index 0f836ace..4a8af10b 100644 --- a/BookKitty/BookKitty/Source/Persistence/CoreDataStack.swift +++ b/BookKitty/BookKitty/Source/Persistence/CoreDataStack.swift @@ -6,6 +6,7 @@ // import CoreData +import LogKit final class CoreDataStack { // MARK: - Static Properties @@ -43,7 +44,7 @@ final class CoreDataStack { do { try context.save() } catch { - BookKittyLogger.log("저장 실패: \(error.localizedDescription)") + LogKit.log("저장 실패: \(error.localizedDescription)") } } } diff --git a/BookKitty/BookKitty/Source/Persistence/QuestionAnswerCoreDataManager.swift b/BookKitty/BookKitty/Source/Persistence/QuestionAnswerCoreDataManager.swift index 1c098724..241b4eb6 100644 --- a/BookKitty/BookKitty/Source/Persistence/QuestionAnswerCoreDataManager.swift +++ b/BookKitty/BookKitty/Source/Persistence/QuestionAnswerCoreDataManager.swift @@ -7,6 +7,7 @@ import CoreData import FirebaseAnalytics +import LogKit /// QuestionAnswer 엔티티를 관리하는 객체 final class QuestionAnswerCoreDataManager: QuestionAnswerCoreDataManageable { @@ -34,10 +35,10 @@ final class QuestionAnswerCoreDataManager: QuestionAnswerCoreDataManageable { do { let fetchResult = try context.fetch(request) - BookKittyLogger.log("질문답변 목록 가져오기 성공") + LogKit.log("질문답변 목록 가져오기 성공") return fetchResult } catch { - BookKittyLogger.log("질문답변 목록 가져오기 실패: \(error.localizedDescription)") + LogKit.log("질문답변 목록 가져오기 실패: \(error.localizedDescription)") return [] } } @@ -54,13 +55,13 @@ final class QuestionAnswerCoreDataManager: QuestionAnswerCoreDataManageable { do { if let entity = try context.fetch(request).first { if entity.id == uuid { - BookKittyLogger.log("질문답변 데이터 가져오기 성공") + LogKit.log("질문답변 데이터 가져오기 성공") return entity } } return nil } catch { - BookKittyLogger.log("질문답변 데이터 가져오기 실패: \(error.localizedDescription)") + LogKit.log("질문답변 데이터 가져오기 실패: \(error.localizedDescription)") return nil } } @@ -77,7 +78,7 @@ final class QuestionAnswerCoreDataManager: QuestionAnswerCoreDataManageable { do { guard let questionEntity = try context.fetch(questionFetchRequest).first else { - BookKittyLogger.log("삭제할 질문답변을 찾을 수 없음") + LogKit.log("삭제할 질문답변을 찾을 수 없음") return false } @@ -93,10 +94,10 @@ final class QuestionAnswerCoreDataManager: QuestionAnswerCoreDataManageable { context.delete(questionEntity) // 질문-답변도 함께 삭제 try context.save() - BookKittyLogger.log("질문답변 삭제 성공") + LogKit.log("질문답변 삭제 성공") return true } catch { - BookKittyLogger.log("질문답변 삭제 실패: \(error.localizedDescription)") + LogKit.log("질문답변 삭제 실패: \(error.localizedDescription)") return false } } @@ -107,7 +108,7 @@ final class QuestionAnswerCoreDataManager: QuestionAnswerCoreDataManageable { do { return try context.count(for: request) } catch { - BookKittyLogger.log("질문 개수 가져오기 실패: \(error.localizedDescription)") + LogKit.log("질문 개수 가져오기 실패: \(error.localizedDescription)") return 0 } } diff --git a/BookKitty/BookKitty/Source/Repository/LocalBookRepository.swift b/BookKitty/BookKitty/Source/Repository/LocalBookRepository.swift index 1fffdf3d..a943829d 100644 --- a/BookKitty/BookKitty/Source/Repository/LocalBookRepository.swift +++ b/BookKitty/BookKitty/Source/Repository/LocalBookRepository.swift @@ -7,6 +7,7 @@ import FirebaseAnalytics import Foundation +import LogKit import RxSwift struct LocalBookRepository: BookRepository { @@ -77,7 +78,7 @@ struct LocalBookRepository: BookRepository { isbnList: isbnList, context: context ) - BookKittyLogger.log("ISBN 배열로부터 책 가져오기 성공") + LogKit.log("ISBN 배열로부터 책 가져오기 성공", category: .lifecycle) return bookEntities.compactMap { bookCoreDataManager.entityToModel(entity: $0) } } @@ -104,7 +105,7 @@ struct LocalBookRepository: BookRepository { } } - BookKittyLogger.log("최근 추천된 책 불러오기 성공") + LogKit.log("최근 추천된 책 불러오기 성공", category: .lifecycle) return books } @@ -117,7 +118,7 @@ struct LocalBookRepository: BookRepository { do { let filteredBooks = data.filter { if bookCoreDataManager.selectBookByIsbn(isbn: $0.isbn, context: context) != nil { - BookKittyLogger.log("\($0.title) 책은 이미 저장되어 있으므로, 저장할 책 목록에서 제외합니다.") + LogKit.log("\($0.title) 책은 이미 저장되어 있으므로, 저장할 책 목록에서 제외합니다.") return false } return true @@ -128,15 +129,15 @@ struct LocalBookRepository: BookRepository { context: context ) guard bookEntities.count == data.count else { - BookKittyLogger.log("반환 전후 갯수 다름;") + LogKit.error("반환 전후 갯수 다름;") return false } try context.save() - BookKittyLogger.log("책 저장 성공") + LogKit.log("책 저장 성공") return true } catch { - BookKittyLogger.log("책 저장 실패: \(error.localizedDescription)") + LogKit.log("책 저장 실패: \(error.localizedDescription)") return false } } @@ -148,7 +149,7 @@ struct LocalBookRepository: BookRepository { /// - Returns: 성공 여부 Bool 반환. func saveBook(book: Book) -> Bool { if bookCoreDataManager.selectBookByIsbn(isbn: book.isbn, context: context) != nil { - BookKittyLogger.log("\(book.title) 책은 이미 저장되어 있습니다.") + LogKit.log("\(book.title) 책은 이미 저장되어 있습니다.") return false } @@ -167,10 +168,10 @@ struct LocalBookRepository: BookRepository { book.updatedAt = Date() } try context.save() - BookKittyLogger.log("책장에 책 등록 성공") + LogKit.log("책장에 책 등록 성공") return true } catch { - BookKittyLogger.log("책장에 책 등록 실패: \(error.localizedDescription)") + LogKit.log("책장에 책 등록 실패: \(error.localizedDescription)") return false } } @@ -187,10 +188,10 @@ struct LocalBookRepository: BookRepository { book.updatedAt = Date() } try context.save() - BookKittyLogger.log("책장에 책 제거 성공") + LogKit.log("책장에 책 제거 성공") return true } catch { - BookKittyLogger.log("책장에 책 제거 실패: \(error.localizedDescription)") + LogKit.log("책장에 책 제거 실패: \(error.localizedDescription)") return false } } diff --git a/BookKitty/BookKitty/Source/Repository/LocalQuestionHistoryRepository.swift b/BookKitty/BookKitty/Source/Repository/LocalQuestionHistoryRepository.swift index 1978d064..eec3777e 100644 --- a/BookKitty/BookKitty/Source/Repository/LocalQuestionHistoryRepository.swift +++ b/BookKitty/BookKitty/Source/Repository/LocalQuestionHistoryRepository.swift @@ -7,6 +7,7 @@ import FirebaseAnalytics import Foundation +import LogKit import RxSwift struct LocalQuestionHistoryRepository: QuestionHistoryRepository { @@ -85,7 +86,7 @@ struct LocalQuestionHistoryRepository: QuestionHistoryRepository { afterCount -= 1 return book } - BookKittyLogger.log("\(beforeCount)만큼 책 가져왔지만, \(afterCount)만큼 저장.") + LogKit.log("\(beforeCount)만큼 책 가져왔지만, \(afterCount)만큼 저장.") return bookCoreDataManager.modelToEntity(model: $0, context: context) } @@ -101,7 +102,7 @@ struct LocalQuestionHistoryRepository: QuestionHistoryRepository { try context.save() return questionEntity.id } catch { - BookKittyLogger.log("저장 실패: \(error.localizedDescription)") + LogKit.log("저장 실패: \(error.localizedDescription)") return nil } } From 610e367d2ac13c22209c01581396d263e4eae992 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:07:58 +0900 Subject: [PATCH 22/25] =?UTF-8?q?[Refactor]=20DesignSystem=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EB=82=B4=EB=B6=80=20Kin?= =?UTF-8?q?gfisher=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?LogKit=20=EB=82=B4=EB=B6=80=20gitignore=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BookKitty/BookKitty/DesignSystem/Package.resolved | 9 --------- BookKitty/BookKitty/LogKit/.gitignore | 8 ++++++++ 2 files changed, 8 insertions(+), 9 deletions(-) create mode 100644 BookKitty/BookKitty/LogKit/.gitignore diff --git a/BookKitty/BookKitty/DesignSystem/Package.resolved b/BookKitty/BookKitty/DesignSystem/Package.resolved index d45a7ec4..7ee4bb7b 100644 --- a/BookKitty/BookKitty/DesignSystem/Package.resolved +++ b/BookKitty/BookKitty/DesignSystem/Package.resolved @@ -1,15 +1,6 @@ { "originHash" : "2047eb6c00d378f8ff34d880679829785a331e8d64a16878fd79495344198d4a", "pins" : [ - { - "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/BookKitty/BookKitty/LogKit/.gitignore b/BookKitty/BookKitty/LogKit/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/BookKitty/BookKitty/LogKit/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc From 4a9ef1fc148ac6360804f273d7ae6bcef082314a Mon Sep 17 00:00:00 2001 From: ericKwon95 Date: Tue, 18 Mar 2025 15:03:25 +0900 Subject: [PATCH 23/25] =?UTF-8?q?[Feat]=20Swift=206=20=EC=A0=90=EC=A7=84?= =?UTF-8?q?=EC=A0=81=20=EB=8C=80=EC=9D=91=20=EC=9C=84=ED=95=9C=20=EC=BB=B4?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=B2=84=EC=A0=84=20=EB=82=AE=EC=B6=94?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BookKitty/BookKitty/LogKit/Package.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/BookKitty/BookKitty/LogKit/Package.swift b/BookKitty/BookKitty/LogKit/Package.swift index a2b1fc41..6330f655 100644 --- a/BookKitty/BookKitty/LogKit/Package.swift +++ b/BookKitty/BookKitty/LogKit/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -23,6 +23,10 @@ let package = Package( .testTarget( name: "LogKitTests", dependencies: ["LogKit"] - ), + ) + ], + swiftLanguageVersions: [ + .version("6"), + .v5 ] ) From 7d1c4bbf5d144c28f697ec9008e2419b041bd0e8 Mon Sep 17 00:00:00 2001 From: ericKwon95 Date: Tue, 18 Mar 2025 15:06:12 +0900 Subject: [PATCH 24/25] =?UTF-8?q?[Feat]=20FIFO=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=EC=9D=98=20=EB=A1=9C=EA=B9=85=20=EC=9C=84=ED=95=9C=20=EC=8B=9C?= =?UTF-8?q?=EB=A6=AC=EC=96=BC=ED=81=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LogEngine으로 이름 변경 - 주석 작성 - 싱글턴 인스턴스가 사용되기 전에는 초기화되지 않기 때문에 앱 시작 시간이라는 보장이 없어 logStartTime으로 네이밍 변경 --- ...gKitActor.swift => LogEngineWrapper.swift} | 79 +++++++++++++------ .../LogKit/Sources/LogKit/LogKit.swift | 78 ++++++++---------- .../LogKit/Sources/LogKit/LogKitType.swift | 2 +- 3 files changed, 89 insertions(+), 70 deletions(-) rename BookKitty/BookKitty/LogKit/Sources/LogKit/{LogKitActor.swift => LogEngineWrapper.swift} (70%) diff --git a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitActor.swift b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogEngineWrapper.swift similarity index 70% rename from BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitActor.swift rename to BookKitty/BookKitty/LogKit/Sources/LogKit/LogEngineWrapper.swift index 8cf025ba..991230cf 100644 --- a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitActor.swift +++ b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogEngineWrapper.swift @@ -1,26 +1,41 @@ import Foundation import OSLog +/// 로거를 식별하기 위한 키 구조체 struct LoggerKey: Hashable { let subSystem: LogSubSystem let category: LogCategory } -/// 단순히 전역 액터 속성만 정의하는 것이 아니라, 로깅 인터페이스도 제공 -@globalActor -public actor LogKitActor { +/// 로깅 엔진의 싱글톤 래퍼 클래스 +/// 스레드 안전성 및 FIFO 순서대로 작업됨을 보장하기 위해 시리얼 큐를 사용하여 로깅 작업을 처리합니다. +final class LogEngineWrapper: @unchecked Sendable { // MARK: - Static Properties - public static let shared = LogKitActor() + /// 공유 인스턴스 + public static let shared = LogEngineWrapper() // MARK: - Properties + + /// 로깅 작업을 직렬화하기 위한 시리얼 큐 + private let queue = DispatchQueue(label: "com.bookKitty.logkit.serial", qos: .utility) - /// _LogKit의 인스턴스를 직접 관리 - private let logKit = _LogKit() + /// 실제 로깅 작업을 수행하는 엔진 인스턴스 + private let logEngine = LogEngine() + + private init() {} // MARK: - Functions - /// _LogKit의 메서드를 액터 내부에서 호출 + /// 로그를 기록하는 메인 메서드 + /// - Parameters: + /// - level: 로그 레벨 + /// - message: 로그 메시지 + /// - subSystem: 로그 서브시스템 (기본값: .app) + /// - category: 로그 카테고리 (기본값: .general) + /// - file: 로그가 발생한 파일 + /// - function: 로그가 발생한 함수 + /// - line: 로그가 발생한 라인 번호 public func log( _ level: LogLevel, message: String, @@ -30,35 +45,47 @@ public actor LogKitActor { function: String = #function, line: Int = #line ) { - // 액터 내부에서는 await 없이 동기적으로 호출 가능 - logKit.log( - level, - message: message, - subSystem: subSystem, - category: category, - file: file, - function: function, - line: line - ) + queue.async { + self.logEngine.log( + level, + message: message, + subSystem: subSystem, + category: category, + file: file, + function: function, + line: line + ) + } } } -final class _LogKit { +/// 실제 로깅 작업을 수행하는 엔진 클래스 +final class LogEngine { // MARK: - Properties - /// @globalActor를 사용해 shared 인스턴스에만 격리 도메인을 지정하는 것은 동시성 격리를 부분적으로 선택적으로 적용 - /// 어떤 부분이 격리되어야 하고 어떤 부분이 일반 동기 코드로 실행되어도 되는지 더 세밀하게 제어 - /// 실제로 모든 코드가 항상 격리될 필요는 없기 때문에 성능상 이점도 존재 - /// 공유 자원에 대한 접근은 조정해야 하지만, 모든 기능이 액터 내부에 있을 필요는 없는 경우 적합 + /// 로그 타임스탬프 포맷팅을 위한 DateFormatter private let dateFormatter: DateFormatter + + /// 파일 연산을 위한 FileManager 인스턴스 private let fileManager: FileManager + + /// 서브시스템과 카테고리별 로거 캐시 private var loggers: [LoggerKey: Logger] = [:] - private let appStartTime: String + /// 로그 엔진 시작 시간 문자열 + private let logEngineStartTime: String + + /// 현재 CSV 파일 ID private var currentCSVFileID = 1 + + /// 현재 CSV 파일 URL private var currentCSVFileURL: URL? + + /// 현재 CSV 파일 크기 private var currentCSVFileSize: UInt64 = 0 - private let maxCSVFileSize: UInt64 = 60 * 1024 // 60KB + + /// 최대 CSV 파일 크기 (60KB) + private let maxCSVFileSize: UInt64 = 60 * 1024 // MARK: - Lifecycle @@ -70,7 +97,7 @@ final class _LogKit { let startTimeFormatter = DateFormatter() startTimeFormatter.dateFormat = "yyyyMMdd_HHmm" - appStartTime = startTimeFormatter.string(from: Date()) + logEngineStartTime = startTimeFormatter.string(from: Date()) initializeLoggers() createNewCSVFile() @@ -136,7 +163,7 @@ final class _LogKit { private func createNewCSVFile() { let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] - let fileName = "\(appStartTime)-\(currentCSVFileID).csv" + let fileName = "\(logEngineStartTime)-\(currentCSVFileID).csv" let fileURL = documentsPath.appendingPathComponent(fileName) // CSV 헤더 생성 diff --git a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKit.swift b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKit.swift index 31722fd5..ebdc131e 100644 --- a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKit.swift +++ b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKit.swift @@ -7,18 +7,16 @@ public enum LogKit { function: String = #function, line _: Int = #line ) { - Task { - await LogKitActor.shared.log( - .debug, - message: message, - subSystem: subSystem, - category: category, - file: file, - function: function - ) - } + LogEngineWrapper.shared.log( + .debug, + message: message, + subSystem: subSystem, + category: category, + file: file, + function: function + ) } - + public static func log( _ message: String, subSystem: LogSubSystem = .app, @@ -27,18 +25,16 @@ public enum LogKit { function: String = #function, line _: Int = #line ) { - Task { - await LogKitActor.shared.log( - .log, - message: message, - subSystem: subSystem, - category: category, - file: file, - function: function - ) - } + LogEngineWrapper.shared.log( + .log, + message: message, + subSystem: subSystem, + category: category, + file: file, + function: function + ) } - + public static func error( _ message: String, subSystem: LogSubSystem = .app, @@ -47,18 +43,16 @@ public enum LogKit { function: String = #function, line _: Int = #line ) { - Task { - await LogKitActor.shared.log( - .error, - message: message, - subSystem: subSystem, - category: category, - file: file, - function: function - ) - } + LogEngineWrapper.shared.log( + .error, + message: message, + subSystem: subSystem, + category: category, + file: file, + function: function + ) } - + public static func info( _ message: String, subSystem: LogSubSystem = .app, @@ -67,15 +61,13 @@ public enum LogKit { function: String = #function, line _: Int = #line ) { - Task { - await LogKitActor.shared.log( - .info, - message: message, - subSystem: subSystem, - category: category, - file: file, - function: function - ) - } + LogEngineWrapper.shared.log( + .info, + message: message, + subSystem: subSystem, + category: category, + file: file, + function: function + ) } } diff --git a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitType.swift b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitType.swift index 64358385..f44069e1 100644 --- a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitType.swift +++ b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKitType.swift @@ -15,7 +15,7 @@ public enum LogCategory: String, CaseIterable, Sendable { case lifecycle = "lifecycle" } -public enum LogLevel: String { +public enum LogLevel: String, Sendable { case debug = "DEBUG" case info = "INFO" case log = "LOG" From 67f33550ccf5550e0c16d23c87e90162013fac44 Mon Sep 17 00:00:00 2001 From: ericKwon95 Date: Tue, 18 Mar 2025 15:41:35 +0900 Subject: [PATCH 25/25] =?UTF-8?q?[Refactor]=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0,=20=EB=A1=9C=EA=B7=B8=20=EC=97=94=EC=A7=84,?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=20=EC=97=94=EC=A7=84=20=EB=9E=98=ED=8D=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=97=AD=ED=95=A0=20=EB=B0=8F=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/LogKit/FileWritingService.swift | 98 ++++++++++ .../LogKit/Sources/LogKit/LogEngine.swift | 96 ++++++++++ .../Sources/LogKit/LogEngineWrapper.swift | 169 ------------------ 3 files changed, 194 insertions(+), 169 deletions(-) create mode 100644 BookKitty/BookKitty/LogKit/Sources/LogKit/FileWritingService.swift create mode 100644 BookKitty/BookKitty/LogKit/Sources/LogKit/LogEngine.swift diff --git a/BookKitty/BookKitty/LogKit/Sources/LogKit/FileWritingService.swift b/BookKitty/BookKitty/LogKit/Sources/LogKit/FileWritingService.swift new file mode 100644 index 00000000..876df090 --- /dev/null +++ b/BookKitty/BookKitty/LogKit/Sources/LogKit/FileWritingService.swift @@ -0,0 +1,98 @@ +// +// FileWritingService.swift +// LogKit +// +// Created by 권승용 on 3/18/25. +// + +import Foundation + +/// CSV 파일로 로그를 기록하는 객체 +/// 앱 실행 시마다 실행 시작 시간을 파일 이름 prefix로 가지는 CSV 파일 생성 +/// 한 파일 당 60KB가 넘어갈 경우 새로운 파일 생성 +final class FileWritingService { + + private let fileManager = FileManager.default + + /// 로그 엔진 시작 시간 문자열 + private let logEngineStartTime: String + + /// 현재 CSV 파일 ID + private var currentCSVFileID = 1 + + /// 현재 CSV 파일 URL + private var currentCSVFileURL: URL? + + /// 현재 CSV 파일 크기 + private var currentCSVFileSize: UInt64 = 0 + + /// 최대 CSV 파일 크기 (60KB) + private let maxCSVFileSize: UInt64 = 60 * 1024 + + init() { + let startTimeFormatter = DateFormatter() + startTimeFormatter.dateFormat = "yyyyMMdd_HHmm" + + logEngineStartTime = startTimeFormatter.string(from: Date()) + } + + func writeToCSVFile( + timestamp: String, + level: String, + fileName: String, + line: String, + function: String, + message: String, + subSystem: String, + category: String + ) { + let escapedMessage = message.replacingOccurrences(of: "\"", with: "\"\"") + let escapedFunction = function.replacingOccurrences(of: "\"", with: "\"\"") + + // CSV 행 생성 + let csvRow = + "\"\(timestamp)\",\"\(level)\",\"\(fileName)\",\"\(line)\",\"\(escapedFunction)\",\"\(escapedMessage)\",\"\(subSystem)\",\"\(category)\"\n" + + guard let csvData = csvRow.data(using: .utf8) else { + return + } + let dataSize = UInt64(csvData.count) + + // 현재 파일이 최대 크기를 초과하는지 확인 + if currentCSVFileSize + dataSize > maxCSVFileSize { + currentCSVFileID += 1 + createNewCSVFile() + } + + // CSV 파일에 로그 추가 + guard let fileURL = currentCSVFileURL else { + return + } + + if let fileHandle = try? FileHandle(forWritingTo: fileURL) { + fileHandle.seekToEndOfFile() + fileHandle.write(csvData) + try? fileHandle.close() + + currentCSVFileSize += dataSize + } + } + + func createNewCSVFile() { + let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] + let fileName = "\(logEngineStartTime)-\(currentCSVFileID).csv" + let fileURL = documentsPath.appendingPathComponent(fileName) + + // CSV 헤더 생성 + let headerRow = "Timestamp,Level,FileName,Line,Function,Message,SubSystem,Category\n" + + do { + try headerRow.write(to: fileURL, atomically: true, encoding: .utf8) + currentCSVFileURL = fileURL + currentCSVFileSize = UInt64(headerRow.utf8.count) + print("새 CSV 로그 파일이 생성되었습니다: \(fileName)") + } catch { + print("CSV 로그 파일 생성 실패: \(error)") + } + } +} diff --git a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogEngine.swift b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogEngine.swift new file mode 100644 index 00000000..6f7b726c --- /dev/null +++ b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogEngine.swift @@ -0,0 +1,96 @@ +// +// LogEngine.swift +// LogKit +// +// Created by 권승용 on 3/18/25. +// + +import OSLog +import Foundation + +/// 로거를 식별하기 위한 키 구조체 +struct LoggerKey: Hashable { + let subSystem: LogSubSystem + let category: LogCategory +} + +/// 실제 로깅 작업을 수행하는 엔진 클래스 +final class LogEngine { + // MARK: - Properties + + /// 서브시스템과 카테고리별 로거 캐시 + private var loggers: [LoggerKey: Logger] = [:] + + /// 로그 타임스탬프 포맷팅을 위한 DateFormatter + private let dateFormatter: DateFormatter + + private let fileWritingService: FileWritingService + + // MARK: - Lifecycle + + init() { + dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + + fileWritingService = FileWritingService() + initializeLoggers() + } + + // MARK: - Functions + + // MARK: - Public Methods + + func log( + _ level: LogLevel, + message: String, + subSystem: LogSubSystem = .app, + category: LogCategory = .general, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + let fileName = (file as NSString).lastPathComponent + + let logger = getLogger(subSystem: subSystem, category: category) + logger.log(level: level.osLogType, "[\(fileName):\(line)] \(function) - \(message)") + + let timestamp = dateFormatter.string(from: Date()) + + // CSV 로그 추가 + fileWritingService.writeToCSVFile( + timestamp: timestamp, + level: level.rawValue, + fileName: fileName, + line: String(line), + function: function, + message: message, + subSystem: subSystem.rawValue, + category: category.rawValue + ) + } + + // MARK: - Private Methods + + private func initializeLoggers() { + var map: [LoggerKey: Logger] = [:] + + for subSystem in LogSubSystem.allCases { + for category in LogCategory.allCases { + let logger = Logger(subsystem: subSystem.rawValue, category: category.rawValue) + map[LoggerKey(subSystem: subSystem, category: category)] = logger + } + } + + loggers = map + } + + private func getLogger(subSystem: LogSubSystem, category: LogCategory) -> Logger { + let key = LoggerKey(subSystem: subSystem, category: category) + + if let logger = loggers[key] { + return logger + } else { + return Logger(subsystem: subSystem.rawValue, category: category.rawValue) + } + } +} diff --git a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogEngineWrapper.swift b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogEngineWrapper.swift index 991230cf..d612fab2 100644 --- a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogEngineWrapper.swift +++ b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogEngineWrapper.swift @@ -1,12 +1,6 @@ import Foundation import OSLog -/// 로거를 식별하기 위한 키 구조체 -struct LoggerKey: Hashable { - let subSystem: LogSubSystem - let category: LogCategory -} - /// 로깅 엔진의 싱글톤 래퍼 클래스 /// 스레드 안전성 및 FIFO 순서대로 작업됨을 보장하기 위해 시리얼 큐를 사용하여 로깅 작업을 처리합니다. final class LogEngineWrapper: @unchecked Sendable { @@ -58,166 +52,3 @@ final class LogEngineWrapper: @unchecked Sendable { } } } - -/// 실제 로깅 작업을 수행하는 엔진 클래스 -final class LogEngine { - // MARK: - Properties - - /// 로그 타임스탬프 포맷팅을 위한 DateFormatter - private let dateFormatter: DateFormatter - - /// 파일 연산을 위한 FileManager 인스턴스 - private let fileManager: FileManager - - /// 서브시스템과 카테고리별 로거 캐시 - private var loggers: [LoggerKey: Logger] = [:] - - /// 로그 엔진 시작 시간 문자열 - private let logEngineStartTime: String - - /// 현재 CSV 파일 ID - private var currentCSVFileID = 1 - - /// 현재 CSV 파일 URL - private var currentCSVFileURL: URL? - - /// 현재 CSV 파일 크기 - private var currentCSVFileSize: UInt64 = 0 - - /// 최대 CSV 파일 크기 (60KB) - private let maxCSVFileSize: UInt64 = 60 * 1024 - - // MARK: - Lifecycle - - init() { - dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" - - fileManager = FileManager.default - - let startTimeFormatter = DateFormatter() - startTimeFormatter.dateFormat = "yyyyMMdd_HHmm" - logEngineStartTime = startTimeFormatter.string(from: Date()) - - initializeLoggers() - createNewCSVFile() - } - - // MARK: - Functions - - // MARK: - Public Methods - - func log( - _ level: LogLevel, - message: String, - subSystem: LogSubSystem = .app, - category: LogCategory = .general, - file: String = #file, - function: String = #function, - line: Int = #line - ) { - let fileName = (file as NSString).lastPathComponent - - let logger = getLogger(subSystem: subSystem, category: category) - logger.log(level: level.osLogType, "[\(fileName):\(line)] \(function) - \(message)") - - let timestamp = dateFormatter.string(from: Date()) - - // CSV 로그 추가 - writeToCSVFile( - timestamp: timestamp, - level: level.rawValue, - fileName: fileName, - line: String(line), - function: function, - message: message, - subSystem: subSystem.rawValue, - category: category.rawValue - ) - } - - // MARK: - Private Methods - - private func initializeLoggers() { - var map: [LoggerKey: Logger] = [:] - - for subSystem in LogSubSystem.allCases { - for category in LogCategory.allCases { - let logger = Logger(subsystem: subSystem.rawValue, category: category.rawValue) - map[LoggerKey(subSystem: subSystem, category: category)] = logger - } - } - - loggers = map - } - - private func getLogger(subSystem: LogSubSystem, category: LogCategory) -> Logger { - let key = LoggerKey(subSystem: subSystem, category: category) - - if let logger = loggers[key] { - return logger - } else { - return Logger(subsystem: subSystem.rawValue, category: category.rawValue) - } - } - - private func createNewCSVFile() { - let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] - let fileName = "\(logEngineStartTime)-\(currentCSVFileID).csv" - let fileURL = documentsPath.appendingPathComponent(fileName) - - // CSV 헤더 생성 - let headerRow = "Timestamp,Level,FileName,Line,Function,Message,SubSystem,Category\n" - - do { - try headerRow.write(to: fileURL, atomically: true, encoding: .utf8) - currentCSVFileURL = fileURL - currentCSVFileSize = UInt64(headerRow.utf8.count) - print("새 CSV 로그 파일이 생성되었습니다: \(fileName)") - } catch { - print("CSV 로그 파일 생성 실패: \(error)") - } - } - - private func writeToCSVFile( - timestamp: String, - level: String, - fileName: String, - line: String, - function: String, - message: String, - subSystem: String, - category: String - ) { - let escapedMessage = message.replacingOccurrences(of: "\"", with: "\"\"") - let escapedFunction = function.replacingOccurrences(of: "\"", with: "\"\"") - - // CSV 행 생성 - let csvRow = - "\"\(timestamp)\",\"\(level)\",\"\(fileName)\",\"\(line)\",\"\(escapedFunction)\",\"\(escapedMessage)\",\"\(subSystem)\",\"\(category)\"\n" - - guard let csvData = csvRow.data(using: .utf8) else { - return - } - let dataSize = UInt64(csvData.count) - - // 현재 파일이 최대 크기를 초과하는지 확인 - if currentCSVFileSize + dataSize > maxCSVFileSize { - currentCSVFileID += 1 - createNewCSVFile() - } - - // CSV 파일에 로그 추가 - guard let fileURL = currentCSVFileURL else { - return - } - - if let fileHandle = try? FileHandle(forWritingTo: fileURL) { - fileHandle.seekToEndOfFile() - fileHandle.write(csvData) - try? fileHandle.close() - - currentCSVFileSize += dataSize - } - } -}