From aae2637421bb828883de2b578bd7d8b884548d4a Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 14 Apr 2026 18:28:04 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20WebPageImageStore=EC=9D=84=20ac?= =?UTF-8?q?tor=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98=EC=97=AC=20FileManag?= =?UTF-8?q?er=EB=A1=9C=20=EB=94=94=EC=8A=A4=ED=81=AC=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=20=EC=8B=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20race=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Combine 제거 - 데이터는 기존에도 함수의 반환 값을 받아 연동했기 때문에 문제 X --- .../WebPageImageRepositoryImpl.swift | 10 ++-------- .../Repository/WebPageRepositoryImpl.swift | 2 +- .../Infra/Service/WebPageMetadataService.swift | 18 ++++++++++-------- .../Persistence/WebPageImageStore.swift | 8 +------- 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/DevLog/Data/Repository/WebPageImageRepositoryImpl.swift b/DevLog/Data/Repository/WebPageImageRepositoryImpl.swift index df991373..31ebd8cf 100644 --- a/DevLog/Data/Repository/WebPageImageRepositoryImpl.swift +++ b/DevLog/Data/Repository/WebPageImageRepositoryImpl.swift @@ -13,16 +13,10 @@ final class WebPageImageRepositoryImpl: WebPageImageRepository { } func fetchDirSizeInBytes() async -> Int64 { - let store = self.store - return await Task.detached(priority: .utility) { - store.dirSizeInBytes() - }.value + await store.dirSizeInBytes() } func clearDirectory() async throws { - let store = self.store - try await Task.detached(priority: .utility) { - try store.clearDirectory() - }.value + try await store.clearDirectory() } } diff --git a/DevLog/Data/Repository/WebPageRepositoryImpl.swift b/DevLog/Data/Repository/WebPageRepositoryImpl.swift index 8b341707..214e336d 100644 --- a/DevLog/Data/Repository/WebPageRepositoryImpl.swift +++ b/DevLog/Data/Repository/WebPageRepositoryImpl.swift @@ -74,7 +74,7 @@ private extension WebPageRepositoryImpl { let expectedImageURL: URL do { - expectedImageURL = try metadataService.cachedImageURL(for: response.url) + expectedImageURL = try await metadataService.cachedImageURL(for: response.url) } catch { return true } diff --git a/DevLog/Infra/Service/WebPageMetadataService.swift b/DevLog/Infra/Service/WebPageMetadataService.swift index c7dcfa2a..3a10bc6e 100644 --- a/DevLog/Infra/Service/WebPageMetadataService.swift +++ b/DevLog/Infra/Service/WebPageMetadataService.swift @@ -51,7 +51,7 @@ final class WebPageMetadataService { } do { - let removed = try imageStore.removeImage(for: url) + let removed = try await imageStore.removeImage(for: url) if removed { logger.info("Removed cached image for URL: \(urlString)") @@ -61,12 +61,12 @@ final class WebPageMetadataService { } } - func cachedImageURL(for urlString: String) throws -> URL { + func cachedImageURL(for urlString: String) async throws -> URL { guard let url = URL(string: urlString) else { throw URLError(.badURL) } - return try imageStore.cachedImageURL(for: url) + return try await imageStore.cachedImageURL(for: url) } private func extractImageURL(from imageProvider: NSItemProvider?, url: URL) async throws -> URL? { @@ -86,11 +86,13 @@ final class WebPageMetadataService { return } - do { - let fileURL = try imageStore.saveImage(data, for: url) - continuation.resume(returning: fileURL) - } catch { - continuation.resume(throwing: error) + Task { + do { + let fileURL = try await imageStore.saveImage(data, for: url) + continuation.resume(returning: fileURL) + } catch { + continuation.resume(throwing: error) + } } } } diff --git a/DevLog/Storage/Persistence/WebPageImageStore.swift b/DevLog/Storage/Persistence/WebPageImageStore.swift index e89e2d90..792934d9 100644 --- a/DevLog/Storage/Persistence/WebPageImageStore.swift +++ b/DevLog/Storage/Persistence/WebPageImageStore.swift @@ -5,17 +5,14 @@ // Created by opfic on 4/14/26. // -import Combine import CryptoKit import Foundation -final class WebPageImageStore { +actor WebPageImageStore { private let fileManager: FileManager - private let subject = CurrentValueSubject(0) init(fileManager: FileManager = .default) { self.fileManager = fileManager - subject.send(dirSizeInBytes()) } func cachedImageURL(for url: URL) throws -> URL { @@ -30,7 +27,6 @@ final class WebPageImageStore { func saveImage(_ data: Data, for url: URL) throws -> URL { let fileURL = try cachedImageURL(for: url) try data.write(to: fileURL, options: [.atomic]) - subject.send(dirSizeInBytes()) return fileURL } @@ -72,14 +68,12 @@ final class WebPageImageStore { for contentURL in contentURLs { try fileManager.removeItem(at: contentURL) } - subject.send(dirSizeInBytes()) } func removeImage(for url: URL) throws -> Bool { let fileURL = try cachedImageURL(for: url) guard fileManager.fileExists(atPath: fileURL.path) else { return false } try fileManager.removeItem(at: fileURL) - subject.send(dirSizeInBytes()) return true } } From c514c81af08669c5d7c30c41997970932b0d047d Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 14 Apr 2026 20:15:58 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20continuation=EC=9D=98=20?= =?UTF-8?q?=EC=83=9D=EB=AA=85=20=EC=A3=BC=EA=B8=B0=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Delegate/AppleSignInDelegate.swift | 21 ++++++++------- DevLog/Infra/Common/InfraLayerError.swift | 1 + .../AppleAuthenticationService.swift | 26 ++++++++++++++++++- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/DevLog/App/Delegate/AppleSignInDelegate.swift b/DevLog/App/Delegate/AppleSignInDelegate.swift index ef3a4756..cf861f1e 100644 --- a/DevLog/App/Delegate/AppleSignInDelegate.swift +++ b/DevLog/App/Delegate/AppleSignInDelegate.swift @@ -8,24 +8,25 @@ import Foundation import AuthenticationServices -class AppleSignInDelegate: NSObject, - ASAuthorizationControllerDelegate, - ASAuthorizationControllerPresentationContextProviding { - var continuation: CheckedContinuation - - init(continuation: CheckedContinuation) { - self.continuation = continuation +@MainActor +final class AppleSignInDelegate: NSObject, + ASAuthorizationControllerDelegate, + ASAuthorizationControllerPresentationContextProviding { + private let finish: @MainActor (Result) -> Void + + init(finish: @escaping @MainActor (Result) -> Void) { + self.finish = finish } - + func authorizationController( controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization ) { - continuation.resume(returning: authorization) + finish(.success(authorization)) } func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { - continuation.resume(throwing: error) + finish(.failure(error)) } func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { diff --git a/DevLog/Infra/Common/InfraLayerError.swift b/DevLog/Infra/Common/InfraLayerError.swift index a11af19c..2a6af101 100644 --- a/DevLog/Infra/Common/InfraLayerError.swift +++ b/DevLog/Infra/Common/InfraLayerError.swift @@ -45,6 +45,7 @@ enum TokenError: Error { enum SocialLoginError: Error { case invalidOAuthState case failedToStartWebAuthenticationSession + case authenticationAlreadyInProgress } extension Error { diff --git a/DevLog/Infra/Service/SocialLogin/AppleAuthenticationService.swift b/DevLog/Infra/Service/SocialLogin/AppleAuthenticationService.swift index af4dccaa..2292baf6 100644 --- a/DevLog/Infra/Service/SocialLogin/AppleAuthenticationService.swift +++ b/DevLog/Infra/Service/SocialLogin/AppleAuthenticationService.swift @@ -22,6 +22,7 @@ final class AppleAuthenticationService: AuthenticationService { } private var appleSignInDelegate: AppleSignInDelegate? + private var appleSignInContinuation: CheckedContinuation? private let store = Firestore.firestore() private let functions = Functions.functions(region: "asia-northeast3") private let messaging = Messaging.messaging() @@ -191,6 +192,10 @@ final class AppleAuthenticationService: AuthenticationService { // Apple 인증 메서드 @MainActor func authenticateWithAppleAsync() async throws -> AppleAuthResponse { + guard appleSignInDelegate == nil, appleSignInContinuation == nil else { + throw SocialLoginError.authenticationAlreadyInProgress + } + // 자체 nonce 생성 및 해시화 let nonce = UUID().uuidString let hashedNonce = SHA256.hash(data: Data(nonce.utf8)).map { String(format: "%02x", $0) }.joined() @@ -203,7 +208,11 @@ final class AppleAuthenticationService: AuthenticationService { let controller = ASAuthorizationController(authorizationRequests: [request]) let authorization = try await withCheckedThrowingContinuation { continuation in - self.appleSignInDelegate = AppleSignInDelegate(continuation: continuation) + let delegate = AppleSignInDelegate { [weak self] result in + self?.completeAppleSignIn(with: result) + } + self.appleSignInDelegate = delegate + self.appleSignInContinuation = continuation controller.delegate = self.appleSignInDelegate controller.presentationContextProvider = self.appleSignInDelegate controller.performRequests() @@ -224,6 +233,21 @@ final class AppleAuthenticationService: AuthenticationService { idTokenString: idTokenString ) } + + @MainActor + private func completeAppleSignIn(with result: Result) { + guard let continuation = appleSignInContinuation else { return } + + appleSignInContinuation = nil + appleSignInDelegate = nil + + switch result { + case .success(let authorization): + continuation.resume(returning: authorization) + case .failure(let error): + continuation.resume(throwing: error) + } + } // Apple CustomToken 발급 메서드 private func requestAppleCustomToken(idToken: String, authorizationCode: Data) async throws -> String { From 068691848e8448e0e72fff4b50261dc4448aa662 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 14 Apr 2026 20:25:48 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20self.imageStore=EC=9D=84=20?= =?UTF-8?q?=EC=BA=A1=EC=B3=90=ED=95=98=EA=B2=A0=EB=8B=A4=EA=B3=A0=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/Service/WebPageMetadataService.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/DevLog/Infra/Service/WebPageMetadataService.swift b/DevLog/Infra/Service/WebPageMetadataService.swift index 3a10bc6e..8a88e827 100644 --- a/DevLog/Infra/Service/WebPageMetadataService.swift +++ b/DevLog/Infra/Service/WebPageMetadataService.swift @@ -71,10 +71,11 @@ final class WebPageMetadataService { private func extractImageURL(from imageProvider: NSItemProvider?, url: URL) async throws -> URL? { guard let imageProvider else { return nil } - let imageStore = self.imageStore return try await withCheckedThrowingContinuation { continuation in - imageProvider.loadObject(ofClass: UIImage.self) { image, error in + // `[imageStore]`은 배열이 아니고 캡쳐 리스트 + // 명시적으로 imageStore을 캡쳐하겠다고 작성한 것 + imageProvider.loadObject(ofClass: UIImage.self) { [imageStore] image, error in if let error { continuation.resume(throwing: error) return From 7cb7aa0b2937dbaafe4113deb7c250fa25e992b8 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 14 Apr 2026 23:01:44 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20WebPageImageStore=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20I/O=EB=A5=BC=20=EB=B0=B1=EA=B7=B8=EB=9D=BC=EC=9A=B4?= =?UTF-8?q?=EB=93=9C=20=EC=8B=A4=ED=96=89=EC=9C=BC=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Persistence/WebPageImageStore.swift | 106 +++++++++++------- 1 file changed, 67 insertions(+), 39 deletions(-) diff --git a/DevLog/Storage/Persistence/WebPageImageStore.swift b/DevLog/Storage/Persistence/WebPageImageStore.swift index 792934d9..b6367fcc 100644 --- a/DevLog/Storage/Persistence/WebPageImageStore.swift +++ b/DevLog/Storage/Persistence/WebPageImageStore.swift @@ -9,14 +9,50 @@ import CryptoKit import Foundation actor WebPageImageStore { - private let fileManager: FileManager + func cachedImageURL(for url: URL) async throws -> URL { + return try await Task.detached(priority: .utility) { + return try Self.cachedImageURL(for: url) + }.value + } + + func saveImage(_ data: Data, for url: URL) async throws -> URL { + return try await Task.detached(priority: .utility) { + return try Self.saveImage(data, for: url) + }.value + } + + func dirSizeInBytes() async -> Int64 { + do { + return try await Task.detached(priority: .utility) { + return try Self.dirSizeInBytes() + }.value + } catch { + return 0 + } + } + + func clearDirectory() async throws { + try await Task.detached(priority: .utility) { + try Self.clearDirectory() + }.value + } - init(fileManager: FileManager = .default) { - self.fileManager = fileManager + func removeImage(for url: URL) async throws -> Bool { + return try await Task.detached(priority: .utility) { + return try Self.removeImage(for: url) + }.value } +} - func cachedImageURL(for url: URL) throws -> URL { - let imageDirectoryURL = try self.imageDirectoryURL(create: true) +private extension WebPageImageStore { + static func hashedFileName(for url: URL) -> String { + let hashValue = SHA256.hash(data: Data(url.absoluteString.utf8)) + return hashValue.map { String(format: "%02x", $0) }.joined() + } + + static func cachedImageURL(for url: URL) throws -> URL { + let fileManager = FileManager.default + let imageDirectoryURL = try imageDirectoryURL(create: true, fileManager: fileManager) let fileName = hashedFileName(for: url) return imageDirectoryURL @@ -24,41 +60,39 @@ actor WebPageImageStore { .appendingPathExtension("jpeg") } - func saveImage(_ data: Data, for url: URL) throws -> URL { + static func saveImage(_ data: Data, for url: URL) throws -> URL { let fileURL = try cachedImageURL(for: url) try data.write(to: fileURL, options: [.atomic]) return fileURL } - func dirSizeInBytes() -> Int64 { - do { - let imageDirectoryURL = try self.imageDirectoryURL(create: false) - guard fileManager.fileExists(atPath: imageDirectoryURL.path) else { return 0 } - guard let enumerator = fileManager.enumerator( - at: imageDirectoryURL, - includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], - options: [.skipsHiddenFiles] - ) else { - return 0 - } + static func dirSizeInBytes() throws -> Int64 { + let fileManager = FileManager.default + let imageDirectoryURL = try imageDirectoryURL(create: false, fileManager: fileManager) + guard fileManager.fileExists(atPath: imageDirectoryURL.path) else { return 0 } + guard let enumerator = fileManager.enumerator( + at: imageDirectoryURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } - var total: Int64 = 0 - for case let fileURL as URL in enumerator { - guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), - resourceValues.isRegularFile == true, - let fileSize = resourceValues.fileSize else { - continue - } - total += Int64(fileSize) + var total: Int64 = 0 + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + resourceValues.isRegularFile == true, + let fileSize = resourceValues.fileSize else { + continue } - return total - } catch { - return 0 + total += Int64(fileSize) } + return total } - func clearDirectory() throws { - let imageDirectoryURL = try self.imageDirectoryURL(create: false) + static func clearDirectory() throws { + let fileManager = FileManager.default + let imageDirectoryURL = try imageDirectoryURL(create: false, fileManager: fileManager) guard fileManager.fileExists(atPath: imageDirectoryURL.path) else { return } let contentURLs = try fileManager.contentsOfDirectory( at: imageDirectoryURL, @@ -70,21 +104,15 @@ actor WebPageImageStore { } } - func removeImage(for url: URL) throws -> Bool { + static func removeImage(for url: URL) throws -> Bool { + let fileManager = FileManager.default let fileURL = try cachedImageURL(for: url) guard fileManager.fileExists(atPath: fileURL.path) else { return false } try fileManager.removeItem(at: fileURL) return true } -} - -private extension WebPageImageStore { - func hashedFileName(for url: URL) -> String { - let hashValue = SHA256.hash(data: Data(url.absoluteString.utf8)) - return hashValue.map { String(format: "%02x", $0) }.joined() - } - func imageDirectoryURL(create: Bool) throws -> URL { + static func imageDirectoryURL(create: Bool, fileManager: FileManager) throws -> URL { let directory = try fileManager.url( for: .applicationSupportDirectory, in: .userDomainMask,