From eadaffd0871c27b25a1eff60f84ddc4859ca327f Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:05:40 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20=EC=95=A1=ED=84=B0=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EB=B0=B1=EA=B7=B8=EB=9D=BC=EC=9A=B4=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=EB=A0=88=EB=93=9C=EC=99=80=20=EC=A7=81=EB=A0=AC?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20=EB=B3=B4=EC=9E=A5=ED=95=98=EB=8A=94=20Dis?= =?UTF-8?q?patchQueue=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Persistence/WebPageImageStoreImpl.swift | 47 +++++++++++++------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/Application/DevLogPersistence/Sources/Persistence/WebPageImageStoreImpl.swift b/Application/DevLogPersistence/Sources/Persistence/WebPageImageStoreImpl.swift index cc86eeb0..8d6adb8e 100644 --- a/Application/DevLogPersistence/Sources/Persistence/WebPageImageStoreImpl.swift +++ b/Application/DevLogPersistence/Sources/Persistence/WebPageImageStoreImpl.swift @@ -9,43 +9,60 @@ import CryptoKit import Foundation import DevLogData -actor WebPageImageStoreImpl: WebPageImageStore { +final class WebPageImageStoreImpl: WebPageImageStore { + private let queue = DispatchQueue( + label: "devlog.web-page-image-store", + qos: .utility + ) + func cachedImageURL(for url: URL) async throws -> URL { - return try await Task.detached(priority: .utility) { - return try Self.cachedImageURL(for: url) - }.value + return try await perform { + try Self.cachedImageURL(for: url) + } } 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 + return try await perform { + try Self.saveImage(data, for: url) + } } func dirSizeInBytes() async -> Int64 { do { - return try await Task.detached(priority: .utility) { - return try Self.dirSizeInBytes() - }.value + return try await perform { + try Self.dirSizeInBytes() + } } catch { return 0 } } func clearDirectory() async throws { - try await Task.detached(priority: .utility) { + try await perform { try Self.clearDirectory() - }.value + } } func removeImage(for url: URL) async throws -> Bool { - return try await Task.detached(priority: .utility) { - return try Self.removeImage(for: url) - }.value + return try await perform { + try Self.removeImage(for: url) + } } } private extension WebPageImageStoreImpl { + func perform(_ operation: @escaping @Sendable () throws -> T) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + continuation.resume(returning: try operation()) + } catch { + continuation.resume(throwing: error) + } + } + } + } + static func hashedFileName(for url: URL) -> String { let hashValue = SHA256.hash(data: Data(url.absoluteString.utf8)) return hashValue.map { String(format: "%02x", $0) }.joined() From bfb23d2d1df18ab367042498b629a25081e6a5ee Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:06:04 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test:=20WebPageImageStoreImpl=EC=9D=98=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WebPageImageStoreImplTests.swift | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 Application/DevLogPersistence/Tests/Persistence/WebPageImageStoreImplTests.swift diff --git a/Application/DevLogPersistence/Tests/Persistence/WebPageImageStoreImplTests.swift b/Application/DevLogPersistence/Tests/Persistence/WebPageImageStoreImplTests.swift new file mode 100644 index 00000000..7ae94531 --- /dev/null +++ b/Application/DevLogPersistence/Tests/Persistence/WebPageImageStoreImplTests.swift @@ -0,0 +1,79 @@ +// +// WebPageImageStoreImplTests.swift +// DevLogPersistenceTests +// +// Created by opfic on 6/3/26. +// + +import Foundation +import Testing +@testable import DevLogPersistence + +@Suite(.serialized) +struct WebPageImageStoreImplTests { + @Test("웹페이지 이미지는 저장되고 삭제된다") + func 웹페이지_이미지는_저장되고_삭제된다() async throws { + let store = WebPageImageStoreImpl() + let fileManager = FileManager.default + try await store.clearDirectory() + let url = try #require(URL(string: "https://example.com/image")) + let data = Data("image-data".utf8) + + let fileURL = try await store.saveImage(data, for: url) + + let savedData = try #require(fileManager.contents(atPath: fileURL.path)) + let directorySize = await store.dirSizeInBytes() + #expect(fileURL.deletingLastPathComponent().lastPathComponent == "webPageImages") + #expect(savedData == data) + #expect(0 < directorySize) + + let removed = try await store.removeImage(for: url) + let removedAgain = try await store.removeImage(for: url) + + #expect(removed) + #expect(!removedAgain) + #expect(!fileManager.fileExists(atPath: fileURL.path)) + + try await store.clearDirectory() + } + + @Test("웹페이지 이미지 디렉터리 삭제는 저장된 이미지를 모두 제거한다") + func 웹페이지_이미지_디렉터리_삭제는_저장된_이미지를_모두_제거한다() async throws { + let store = WebPageImageStoreImpl() + try await store.clearDirectory() + let firstURL = try #require(URL(string: "https://example.com/first")) + let secondURL = try #require(URL(string: "https://example.com/second")) + + _ = try await store.saveImage(Data("first".utf8), for: firstURL) + _ = try await store.saveImage(Data("second".utf8), for: secondURL) + try await store.clearDirectory() + + let directorySize = await store.dirSizeInBytes() + #expect(directorySize == 0) + } + + @Test("동시 저장 요청은 요청 순서대로 같은 파일을 갱신한다") + func 동시_저장_요청은_요청_순서대로_같은_파일을_갱신한다() async throws { + let store = WebPageImageStoreImpl() + try await store.clearDirectory() + let url = try #require(URL(string: "https://example.com/\(UUID().uuidString)")) + let firstData = Data(repeating: 1, count: 64 * 1024 * 1024) + let secondData = Data("latest".utf8) + + let firstSaveTask = Task { + try await store.saveImage(firstData, for: url) + } + try await Task.sleep(nanoseconds: 10_000_000) + let secondSaveTask = Task { + try await store.saveImage(secondData, for: url) + } + + _ = try await firstSaveTask.value + let fileURL = try await secondSaveTask.value + let savedData = try Data(contentsOf: fileURL) + + #expect(savedData == secondData) + + try await store.clearDirectory() + } +} From 8e7d557b9eb59d0da9905d35668d61c603413b62 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:39:40 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test:=20=EC=9B=B9=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WebPageImageStoreImplTests.swift | 72 +++++++++++++++---- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/Application/DevLogPersistence/Tests/Persistence/WebPageImageStoreImplTests.swift b/Application/DevLogPersistence/Tests/Persistence/WebPageImageStoreImplTests.swift index 7ae94531..01f02028 100644 --- a/Application/DevLogPersistence/Tests/Persistence/WebPageImageStoreImplTests.swift +++ b/Application/DevLogPersistence/Tests/Persistence/WebPageImageStoreImplTests.swift @@ -52,28 +52,74 @@ struct WebPageImageStoreImplTests { #expect(directorySize == 0) } - @Test("동시 저장 요청은 요청 순서대로 같은 파일을 갱신한다") - func 동시_저장_요청은_요청_순서대로_같은_파일을_갱신한다() async throws { + @Test("같은 store를 공유하는 객체의 저장은 트랜잭션 단위로 반영된다") + func 같은_store를_공유하는_객체의_저장은_트랜잭션_단위로_반영된다() async throws { let store = WebPageImageStoreImpl() + let firstClient = WebPageImageStoreClient(store: store) + let secondClient = WebPageImageStoreClient(store: store) try await store.clearDirectory() let url = try #require(URL(string: "https://example.com/\(UUID().uuidString)")) - let firstData = Data(repeating: 1, count: 64 * 1024 * 1024) - let secondData = Data("latest".utf8) + let largeData = Data(repeating: 1, count: 64 * 1024 * 1024) + let smallData = Data("latest".utf8) + let startedAt = ContinuousClock.now - let firstSaveTask = Task { - try await store.saveImage(firstData, for: url) + // 동일한 Impl 인스턴스를 공유하는 두 객체가 접근하는 형태의 결과 기반 테스트다. + // 첫 번째 큰 저장 작업이 먼저 큐에 들어갈 시간을 준 뒤 두 번째 작은 저장을 요청하고, + // 최종 파일이 작은 데이터라면 앞 작업 전체가 끝난 뒤 뒤 작업이 반영된 것으로 본다. + // 각 작업의 완료 시점은 호출자 관점에서 saveImage await가 반환된 시점으로 기록한다. + let largeSaveTask = Task { + try await firstClient.saveImage(largeData, for: url, name: "large", since: startedAt) } try await Task.sleep(nanoseconds: 10_000_000) - let secondSaveTask = Task { - try await store.saveImage(secondData, for: url) - } - _ = try await firstSaveTask.value - let fileURL = try await secondSaveTask.value - let savedData = try Data(contentsOf: fileURL) + let smallSaveMeasurement = try await secondClient.saveImage(smallData, for: url, name: "small", since: startedAt) + let largeSaveMeasurement = try await largeSaveTask.value + let savedData = try Data(contentsOf: smallSaveMeasurement.fileURL) + + print(saveSummary(largeSaveMeasurement)) + print(saveSummary(smallSaveMeasurement)) - #expect(savedData == secondData) + #expect(savedData == smallData) try await store.clearDirectory() } } + +private struct WebPageImageStoreClient { + let store: WebPageImageStoreImpl + + func saveImage( + _ data: Data, + for url: URL, + name: String, + since startedAt: ContinuousClock.Instant + ) async throws -> WebPageImageStoreSaveMeasurement { + let requestedAt = startedAt.duration(to: .now) + let fileURL = try await store.saveImage(data, for: url) + let finishedAt = startedAt.duration(to: .now) + + return WebPageImageStoreSaveMeasurement( + name: name, + fileURL: fileURL, + requestedAt: requestedAt, + finishedAt: finishedAt + ) + } +} + +private struct WebPageImageStoreSaveMeasurement { + let name: String + let fileURL: URL + let requestedAt: Duration + let finishedAt: Duration +} + +private func saveSummary(_ measurement: WebPageImageStoreSaveMeasurement) -> String { + "\(measurement.name) save requested: \(millisecondsString(measurement.requestedAt))ms, finished: \(millisecondsString(measurement.finishedAt))ms" +} + +private func millisecondsString(_ duration: Duration) -> String { + let components = duration.components + let milliseconds = Double(components.seconds) * 1_000 + Double(components.attoseconds) / 1_000_000_000_000_000 + return String(format: "%.3f", milliseconds) +} From 5e248dc88c188e0f5e31297ee24d385297cd7619 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:48:29 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=86=A0?= =?UTF-8?q?=EC=BD=9C=EC=97=90=20Sendable=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/DevLogData/Sources/Protocol/WebPageImageStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Application/DevLogData/Sources/Protocol/WebPageImageStore.swift b/Application/DevLogData/Sources/Protocol/WebPageImageStore.swift index 6a2bb78f..198152b6 100644 --- a/Application/DevLogData/Sources/Protocol/WebPageImageStore.swift +++ b/Application/DevLogData/Sources/Protocol/WebPageImageStore.swift @@ -7,7 +7,7 @@ import Foundation -public protocol WebPageImageStore { +public protocol WebPageImageStore: Sendable { func cachedImageURL(for url: URL) async throws -> URL func saveImage(_ data: Data, for url: URL) async throws -> URL func dirSizeInBytes() async -> Int64