diff --git a/BookKitty/BookKitty.xcodeproj/project.pbxproj b/BookKitty/BookKitty.xcodeproj/project.pbxproj index 49a8b45c..1ae32e32 100644 --- a/BookKitty/BookKitty.xcodeproj/project.pbxproj +++ b/BookKitty/BookKitty.xcodeproj/project.pbxproj @@ -18,10 +18,12 @@ 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 */; }; 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 */ @@ -80,6 +82,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E56D6D062D73E8C500B6E6E9 /* LogKit in Frameworks */, 606DA2452D42076100C7FAA3 /* Then in Frameworks */, E93048662D559553008E9467 /* RxCocoa in Frameworks */, E5FC698C2D52414B002875FD /* SnapKit in Frameworks */, @@ -87,6 +90,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 +174,8 @@ 4584C5AE2D685AB300173282 /* FirebaseAnalytics */, 4584C5B02D685AB300173282 /* FirebaseCore */, 4584C5B22D685AB300173282 /* FirebaseCrashlytics */, + E5FFE2EF2D6A3F4200A0F7CF /* NeoImage */, + E56D6D052D73E8C500B6E6E9 /* LogKit */, ); productName = BookKitty; productReference = 60551C652D40E6E800CFC16A /* BookKitty.app */; @@ -346,7 +352,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 +391,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; @@ -695,6 +701,10 @@ isa = XCSwiftPackageProductDependency; productName = BookRecommendationKit; }; + E56D6D052D73E8C500B6E6E9 /* LogKit */ = { + isa = XCSwiftPackageProductDependency; + productName = LogKit; + }; E5DE19632D62A7D3007D37E2 /* BookOCRKit */ = { isa = XCSwiftPackageProductDependency; productName = BookOCRKit; @@ -704,6 +714,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/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/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/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/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/DesignSystem/Package.swift b/BookKitty/BookKitty/DesignSystem/Package.swift index 1d8aae12..a6429d40 100644 --- a/BookKitty/BookKitty/DesignSystem/Package.swift +++ b/BookKitty/BookKitty/DesignSystem/Package.swift @@ -20,7 +20,8 @@ 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"), + .package(path: "../LogKit"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -31,7 +32,8 @@ 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"), + .product(name: "LogKit", package: "LogKit"), ], resources: [ .process("Resource/Fonts"), 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/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/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/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 diff --git a/BookKitty/BookKitty/LogKit/Package.swift b/BookKitty/BookKitty/LogKit/Package.swift new file mode 100644 index 00000000..6330f655 --- /dev/null +++ b/BookKitty/BookKitty/LogKit/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.9 +// 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"] + ) + ], + swiftLanguageVersions: [ + .version("6"), + .v5 + ] +) 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 new file mode 100644 index 00000000..d612fab2 --- /dev/null +++ b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogEngineWrapper.swift @@ -0,0 +1,54 @@ +import Foundation +import OSLog + +/// ๋กœ๊น… ์—”์ง„์˜ ์‹ฑ๊ธ€ํ†ค ๋ž˜ํผ ํด๋ž˜์Šค +/// ์Šค๋ ˆ๋“œ ์•ˆ์ „์„ฑ ๋ฐ FIFO ์ˆœ์„œ๋Œ€๋กœ ์ž‘์—…๋จ์„ ๋ณด์žฅํ•˜๊ธฐ ์œ„ํ•ด ์‹œ๋ฆฌ์–ผ ํ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋กœ๊น… ์ž‘์—…์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. +final class LogEngineWrapper: @unchecked Sendable { + // MARK: - Static Properties + + /// ๊ณต์œ  ์ธ์Šคํ„ด์Šค + public static let shared = LogEngineWrapper() + + // MARK: - Properties + + /// ๋กœ๊น… ์ž‘์—…์„ ์ง๋ ฌํ™”ํ•˜๊ธฐ ์œ„ํ•œ ์‹œ๋ฆฌ์–ผ ํ + private let queue = DispatchQueue(label: "com.bookKitty.logkit.serial", qos: .utility) + + /// ์‹ค์ œ ๋กœ๊น… ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜๋Š” ์—”์ง„ ์ธ์Šคํ„ด์Šค + private let logEngine = LogEngine() + + private init() {} + + // MARK: - Functions + + /// ๋กœ๊ทธ๋ฅผ ๊ธฐ๋กํ•˜๋Š” ๋ฉ”์ธ ๋ฉ”์„œ๋“œ + /// - Parameters: + /// - level: ๋กœ๊ทธ ๋ ˆ๋ฒจ + /// - message: ๋กœ๊ทธ ๋ฉ”์‹œ์ง€ + /// - subSystem: ๋กœ๊ทธ ์„œ๋ธŒ์‹œ์Šคํ…œ (๊ธฐ๋ณธ๊ฐ’: .app) + /// - category: ๋กœ๊ทธ ์นดํ…Œ๊ณ ๋ฆฌ (๊ธฐ๋ณธ๊ฐ’: .general) + /// - file: ๋กœ๊ทธ๊ฐ€ ๋ฐœ์ƒํ•œ ํŒŒ์ผ + /// - function: ๋กœ๊ทธ๊ฐ€ ๋ฐœ์ƒํ•œ ํ•จ์ˆ˜ + /// - line: ๋กœ๊ทธ๊ฐ€ ๋ฐœ์ƒํ•œ ๋ผ์ธ ๋ฒˆํ˜ธ + public func log( + _ level: LogLevel, + message: String, + subSystem: LogSubSystem = .app, + category: LogCategory = .general, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + queue.async { + self.logEngine.log( + level, + message: message, + subSystem: subSystem, + category: category, + file: file, + function: function, + line: line + ) + } + } +} diff --git a/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKit.swift b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKit.swift new file mode 100644 index 00000000..ebdc131e --- /dev/null +++ b/BookKitty/BookKitty/LogKit/Sources/LogKit/LogKit.swift @@ -0,0 +1,73 @@ +public enum LogKit { + public static func debug( + _ message: String, + subSystem: LogSubSystem = .app, + category: LogCategory = .general, + file: String = #file, + function: String = #function, + line _: Int = #line + ) { + LogEngineWrapper.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 + ) { + LogEngineWrapper.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 + ) { + LogEngineWrapper.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 + ) { + 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 new file mode 100644 index 00000000..f44069e1 --- /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, Sendable { + 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 + } + } +} 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..71cfcaaa --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Package.swift @@ -0,0 +1,24 @@ +// 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/Cache/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift new file mode 100644 index 00000000..bd29d284 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift @@ -0,0 +1,303 @@ +import Foundation + +class DiskStorage: @unchecked Sendable { + // MARK: - Properties + + private let config: Config + + private let directoryURL: URL + + private let serialActor = Actor() + private var storageReady = true + + // MARK: - Lifecycle + + /// FileManager๋ฅผ ํ†ตํ•ด ๋””๋ ‰ํ† ๋ฆฌ๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๊ณผ์ •์—์„œ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ ์ž์ฒด์—์„œ throws ํ‚ค์›Œ๋“œ๋ฅผ ๊ธฐ์ž…ํ•ด์ค๋‹ˆ๋‹ค. + init(config: Config) throws { + // ์™ธ๋ถ€์—์„œ ์ฃผ์ž…๋œ ๋””์Šคํฌ ์ €์žฅ์†Œ์— ๋Œ€ํ•œ ์„ค์ •๊ฐ’๊ณผ Creation ๊ตฌ์กฐ์ฒด๋กœ ์ƒ์„ฑ๋œ ๋””๋ ‰ํ† ๋ฆฌ URL์™€ cacheName์„ ์ƒ์„ฑ ๋ฐ self.directoryURL์— + // ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + self.config = config + let creation = Creation(config) + directoryURL = creation.directoryURL + try prepareDirectory() + } + + // 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 ํด๋ž˜์Šค๋กœ ๋Œ€์ฒดํ•˜์˜€์Šต๋‹ˆ๋‹ค. + 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? { + 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 let .expirationTime(storageExpiration): + expirationDate = storageExpiration.estimatedExpirationSinceNow + } + + let attributes: [FileAttributeKey: Any] = [ + .creationDate: Date(), + .modificationDate: expirationDate, + ] + + try FileManager.default.setAttributes(attributes, ofItemAtPath: fileURL.path) + } + + return obj + } + } + + /// ํŠน์ • ํ‚ค์— ํ•ด๋‹นํ•˜๋Š” ํŒŒ์ผ์„ ์‚ญ์ œํ•˜๋Š” ๋ฉ”์„œ๋“œ + 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) + 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๋กœ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. + 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 { + // 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 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? + + /// 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 + 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. + /// - 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 { + // MARK: - Properties + + let directoryURL: URL + let cacheName: String + + // MARK: - Lifecycle + + 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/Cache/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift new file mode 100644 index 00000000..19d6fa63 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift @@ -0,0 +1,124 @@ +import Foundation + +/// ์“ฐ๊ธฐ ์ œ์–ด์™€ ๊ฐ™์€ ๋™์‹œ์„ฑ์ด ํ•„์š”ํ•œ ๋ถ€๋ถ„๋งŒ ์„ ํƒ์ ์œผ๋กœ ์ œ์–ดํ•˜๊ธฐ ์œ„ํ•ด ์ „์ฒด ImageCache๋ฅผ actor๋กœ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š๊ณ , ImageCacheActor ์ƒ์„ฑ +/// actor๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋ชจ๋“  ๋™์ž‘์ด actor์˜ ์‹คํ–‰ํ๋ฅผ ํ†ต๊ณผํ•ด์•ผํ•˜๊ธฐ ๋•Œ๋ฌธ์—, ๋™์‹œ์„ฑ ๋ณดํ˜ธ๊ฐ€ ๋ถˆํ•„์š”ํ•œ read-only ๋™์ž‘๋„ ์ง๋ ฌํ™”๋˜๋ฉฐ ์˜ค๋ฒ„ํ—ค๋“œ๊ฐ€ ๋ฐœ์ƒ +@globalActor +public actor ImageCacheActor { + public static let shared = ImageCacheActor() +} + +public final class ImageCache: @unchecked Sendable { + // 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)์ธ + /// ์‹ฑ๊ธ€ํ†ค ํŒจํ„ด์„ ์‚ฌ์šฉํ•  ๊ฒฝ์šฐ,์œ„ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. + /// ์ด๋Š” ๋ณ„๋„์˜ ๊ฐ€๋ณ€ ํ”„๋กœํผํ‹ฐ๋ฅผ ํด๋ž˜์Šค ๋‚ด๋ถ€์— ์ง€๋‹ˆ๊ณ  ์žˆ์ง€ ์•Š์Œ์—๋„ ๋ฐœ์ƒํ•˜๋Š” ์—๋Ÿฌ์ž…๋‹ˆ๋‹ค + /// ์ด๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„ , 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๋กœ ํ•œ์ •ํ•ฉ๋‹ˆ๋‹ค. + let totalMemory = ProcessInfo.processInfo.physicalMemory + let memoryLimit = totalMemory / 4 + 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 + ) + + // ๋””์Šคํฌ ์บ์‹œ ์ œ์–ด ๊ด€๋ จ ํด๋ž˜์Šค ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ + diskStorage = try DiskStorage(config: diskConfig) + } + + // MARK: - Functions + + /// ๋ฉ”๋ชจ๋ฆฌ์™€ ๋””์Šคํฌ ์บ์‹œ์— ๋ชจ๋‘ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + @ImageCacheActor + public func store( + _ data: Data, + forKey key: String, + 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์ฐจ์ ์œผ๋กœ ์˜ค๋ฒ„ํ—ค๋“œ๊ฐ€ ์ ์€ ๋ฉ”๋ชจ๋ฆฌ๋ฅผ ๋จผ์ € ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + /// ์ดํ›„ ๋ฉ”๋ชจ๋ฆฌ์— ์—†์„ ๊ฒฝ์šฐ, ๋””์Šคํฌ๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + /// ๋””์Šคํฌ์— ์—†์„ ๊ฒฝ์šฐ throwํ•ฉ๋‹ˆ๋‹ค. + /// ๋””์Šคํฌ์— ๋ฐ์ดํ„ฐ๋ฅผ ํ™•์ธํ•  ๊ฒฝ์šฐ, ๋‹ค์Œ ์กฐํšŒ๋ฅผ ์œ„ํ•ด ํ•ด๋‹น ๋ฐ์ดํ„ฐ๋ฅผ ๋ฉ”๋ชจ๋ฆฌ๋กœ ์˜ฌ๋ฆฝ๋‹ˆ๋‹ค. + public func retrieveImage(forKey key: String) async throws -> Data? { + if let memoryData = await memoryStorage.value(forKey: key) { + return memoryData + } + + let diskData = try await diskStorage.value(forKey: key) + + 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) + } +} 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/Constants/CacheError.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift new file mode 100644 index 00000000..0bc2b101 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift @@ -0,0 +1,57 @@ +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) + + // MARK: - Computed Properties + + 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 let .diskStorageError(error): + return "Disk storage error: \(error.localizedDescription)" + case let .memoryStorageError(error): + return "Memory storage error: \(error.localizedDescription)" + case .storageNotReady: + return "The storage is not ready" + case let .fileNotFound(key): + return "File not found for key: \(key)" + case let .cannotCreateDirectory(error): + return "Cannot create directory: \(error.localizedDescription)" + case let .cannotWriteToFile(error): + return "Cannot write to file: \(error.localizedDescription)" + case let .cannotReadFromFile(error): + return "Cannot read from file: \(error.localizedDescription)" + case .invalidCacheKey: + return "The cache key is invalid" + 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 new file mode 100644 index 00000000..27a2b600 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/ExpirationExtending.swift @@ -0,0 +1,42 @@ +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 let .seconds(seconds): + timeInterval = seconds + case let .days(days): + timeInterval = TimeInterval(86400 * days) // 86400 = 24 * 60 * 60 + case .never: + return .distantFuture + } + return Date().addingTimeInterval(timeInterval) + } + + var isExpired: Bool { + estimatedExpirationSinceNow.isPast + } +} + +public enum ExpirationExtending: Equatable, Sendable { + /// ๋งŒ๋ฃŒ ์‹œ๊ฐ„์„ ์—ฐ์žฅํ•˜์ง€ ์•Š์Œ + case none + + /// ํ˜„์žฌ ์บ์‹œ ์„ค์ •์˜ ๋งŒ๋ฃŒ ์‹œ๊ฐ„๋งŒํผ ์—ฐ์žฅ + case cacheTime + + /// ์ง€์ •๋œ ๋งŒ๋ฃŒ ์‹œ๊ฐ„์œผ๋กœ ์—ฐ์žฅ + case expirationTime(StorageExpiration) +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift new file mode 100644 index 00000000..c03f7c51 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift @@ -0,0 +1,70 @@ +// +// NeoImageOptions.swift +// NeoImage +// +// 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, + 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)) +} 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..6fb4806a --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift @@ -0,0 +1,9 @@ +enum TimeConstants { + /// Seconds in a day, a.k.a 86,400s, roughly. + /// also known as + static let secondsInOneDay = 86400 +} + +enum ImageTaskKey { + static let associatedKey = "com.neoimage.UIImageView.ImageTask" +} 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/Extensions/Date+.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift new file mode 100644 index 00000000..5fc8aa34 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Date { + var isPast: Bool { + self < Date() + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift new file mode 100644 index 00000000..5a529724 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift @@ -0,0 +1,253 @@ +import UIKit + +// MARK: - Wrapper & Associated Object Key + +/// UIImageView๊ฐ€ NeoImage์˜ ๊ธฐ๋Šฅ์„ ์ œ๊ณต๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” NeoImageCompatible ํ”„๋กœํ† ์ฝœ์„ ์ฑ„ํƒํ•  ์ˆ˜ ์žˆ์Œ์„ ๋ช…์‹œํ•ฉ๋‹ˆ๋‹ค. +extension UIImageView: NeoImageCompatible {} + +public protocol NeoImageCompatible: AnyObject {} + +extension NeoImageCompatible { + /// neo ๋„ค์ž„์ŠคํŽ˜์ด์Šค๋ฅผ ํ†ตํ•ด NeoImage์˜ ๊ธฐ๋Šฅ์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + public var neo: NeoImageWrapper { + 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ํ•˜๊ฒŒ ํ™•์ธํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + private func setImageAsync( + with url: URL?, + placeholder: UIImage? = nil, + options: NeoImageOptions? = nil + ) async throws -> (ImageLoadingResult, ImageTask?) { + // ์ด๋ฏธ์ง€๋ทฐ๊ฐ€ ์‹ค์ œ๋กœ ํ™”๋ฉด์— ํ‘œ์‹œ๋˜์–ด ์žˆ๋Š”์ง€ ์—ฌ๋ถ€ ํŒŒ์•…, + // ์ด๋Š” Swift 6๋กœ ์˜ค๋ฉด์„œ ๋น„๋™๊ธฐ ์ž‘์—…์œผ๋กœ ๊ฐ„์ฃผ๋˜๊ธฐ ์‹œ์ž‘ํ•จ. + guard await base.window != nil else { + throw CacheError.invalidData + } + + guard let url else { + await MainActor.run { [weak base] in + guard let base else { + return + } + base.image = placeholder + } + + throw CacheError.invalidData + } + + // placeholder ๋จผ์ € ์„ค์ • + if let placeholder { + await MainActor.run { [weak base] in + guard let base else { + return + } + base.image = placeholder + print("\(url): Placeholder ์„ค์ • ์™„๋ฃŒ") + } + } + + // 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) { + try await ImageCache.shared.store(data, forKey: url.absoluteString) + print("\(url): ์ด๋ฏธ์ง€ ์บ์‹ฑ ์™„๋ฃŒ") + } + + // ์ตœ์ข… UI ์—…๋ฐ์ดํŠธ + 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: downloadResult.originalData + ), + 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?, + 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: (@MainActor @Sendable (Result) -> Void)? = nil + ) -> ImageTask? { + let task = ImageTask() + + Task { @MainActor in + do { + let (result, _) = 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 { + 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 + + objc_setAssociatedObject( + base, // ๋Œ€์ƒ ๊ฐ์ฒด (UIImageView) + ImageTaskKey.associatedKey, // ํ‚ค ๊ฐ’ + task, // ์ €์žฅํ•  ๊ฐ’ + .OBJC_ASSOCIATION_RETAIN_NONATOMIC // ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ ์ •์ฑ… + ) + } +} 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..2a9fa54d --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/String+.swift @@ -0,0 +1,17 @@ +import CommonCrypto +import Foundation + +extension String { + var sha256: String { + 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/Image/ImageProcesser.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Image/ImageProcesser.swift new file mode 100644 index 00000000..d31be488 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Image/ImageProcesser.swift @@ -0,0 +1,167 @@ +import UIKit + +public enum FilteringAlgorithm: Sendable { + case none + case linear + case trilinear +} + +/// ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ํ”„๋กœํ† ์ฝœ +public protocol ImageProcessing: Sendable { + /// ์ด๋ฏธ์ง€๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฉ”์„œ๋“œ + func process(_ image: UIImage) async throws -> UIImage + + /// ํ”„๋กœ์„ธ์„œ์˜ ์‹๋ณ„์ž + /// ์บ์‹œ ํ‚ค ์ƒ์„ฑ์— ์‚ฌ์šฉ๋จ + var identifier: String { get } +} + +/// ์ด๋ฏธ์ง€ ๋ฆฌ์‚ฌ์ด์ง• ํ”„๋กœ์„ธ์„œ +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, + filteringAlgorithm: FilteringAlgorithm = .linear + ) { + self.targetSize = targetSize + 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 { _ 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 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 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 { + processedImage = try await processor.process(processedImage) + } + return processedImage + } +} 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/Networking/ImageDownloadManager.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift new file mode 100644 index 00000000..85ac0743 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift @@ -0,0 +1,91 @@ +import Foundation +import UIKit + +/// ์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ ๊ฒฐ๊ณผ ๊ตฌ์กฐ์ฒด +public struct ImageLoadingResult: Sendable { + public let image: UIImage + public let url: URL? + public let originalData: Data +} + +/// ์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ ๊ด€๋ฆฌ ์•กํ„ฐ (๋™์‹œ์„ฑ ์ œ์–ด) +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 { +// 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: - ๋‚ด๋ถ€ ์„ธ์…˜ ๊ด€๋ฆฌ ํ™•์žฅ + +extension ImageDownloadManager { + /// actor์˜ ์ƒํƒœ๋ฅผ ์ง์ ‘ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š๊ณ  ํด๋กœ์ €๋ฅผ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ด๊ธฐ์— nonisolated๋ฅผ ๊ธฐ์ž…ํ•˜์—ฌ, ํ•ด๋‹น ๋ฉ”์„œ๋“œ๊ฐ€ actor์˜ ๊ฒฉ๋ฆฌ๋œ ์ƒํƒœ์— ์ ‘๊ทผํ•˜์ง€ ์•Š์Œ์„ ์•Œ๋ ค์คŒ + private 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) + } + } + + /// ์ธ์ฆ ์ฒ˜๋ฆฌ ํ•ธ๋“ค๋Ÿฌ + private func handleAuthChallenge(_ challenge: URLAuthenticationChallenge) async + -> (URLSession.AuthChallengeDisposition, URLCredential?) { + guard let trust = challenge.protectionSpace.serverTrust else { + return (.cancelAuthenticationChallenge, nil) + } + return (.useCredential, URLCredential(trust: trust)) + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift new file mode 100644 index 00000000..14342ada --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift @@ -0,0 +1,132 @@ +import Foundation + +/// ์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ ์ž‘์—…์˜ ์ƒํƒœ๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” ์—ด๊ฑฐํ˜• +public enum ImageTaskState: Int, Sendable { + /// ๋Œ€๊ธฐ ์ค‘ + case pending = 0 + /// ๋‹ค์šด๋กœ๋“œ ์ค‘ + case downloading + /// ์ทจ์†Œ๋จ + case cancelled + /// ์™„๋ฃŒ๋จ + case completed + /// ์‹คํŒจ + case failed +} + +/// ์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ ์ž‘์—…์„ ๊ด€๋ฆฌํ•˜๋Š” actor +public actor ImageTask: Sendable { + // MARK: - Properties + + /// ํ˜„์žฌ ์ž‘์—…์˜ ์ƒํƒœ + 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 = 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 + } + state = .cancelled + isCancelled = true + endTime = Date() + } + + /// ์ž‘์—… ์‹œ์ž‘ + public func start() { + guard state == .pending else { + return + } + state = .downloading + startTime = Date() + } + + /// ์ž‘์—… ์™„๋ฃŒ + public func complete() { + guard state == .downloading else { + return + } + state = .completed + endTime = Date() + } + + /// ์ž‘์—… ์‹คํŒจ + public func fail() { + 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: - 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/Networking/SessionDelegate.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift new file mode 100644 index 00000000..17aabc77 --- /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 + + /// ํ•„์ˆ˜ ๋ธ๋ฆฌ๊ฒŒ์ดํŠธ ๋ฉ”์„œ๋“œ๋งŒ ๊ตฌํ˜„ + 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 + } + + 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/NeoImage/Sources/NeoImage/Protocols/DataTransformable.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Protocols/DataTransformable.swift new file mode 100644 index 00000000..48e9aa3f --- /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..5ca39737 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageTests.swift @@ -0,0 +1,12 @@ +@testable import NeoImage +import XCTest + +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 + } +} 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", 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/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 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)") - } -} 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/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/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/View/Popup/AddBookConfirmView.swift b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmView.swift index a5a7edb7..20b484f0 100644 --- a/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmView.swift +++ b/BookKitty/BookKitty/Source/Feature/AddBookByTitle/View/Popup/AddBookConfirmView.swift @@ -6,7 +6,7 @@ // import DesignSystem -import Kingfisher +import NeoImage import RxSwift import SnapKit import Then @@ -77,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() { 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 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/MyLibrary/View/MyLibraryCollectionViewCell.swift b/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift index e1408700..dc180fcf 100644 --- a/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift +++ b/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift @@ -6,7 +6,7 @@ // import DesignSystem -import Kingfisher +import NeoImage import SnapKit import UIKit @@ -43,7 +43,7 @@ final class MyLibraryCollectionViewCell: UICollectionViewCell { // TODO: ๊ณ ๋„ํ™” ํ•„์š” func configureCell(imageUrl: URL?) { - cellImageView.kf.setImage(with: imageUrl) + cellImageView.neo.setImage(with: imageUrl) } private func configureHierarchy() { 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 } }