Skip to content

Commit 1ca458a

Browse files
authored
[#528] actor에서 Task.detached를 사용하지 않고 백그라운드 스레드로 작업을 할 수 있도록 개선한다 (#531)
* refactor: 액터 대신 백그라운드 스레드와 직렬성을 보장하는 DispatchQueue로 개선 * test: WebPageImageStoreImpl의 로직 테스트 추가 * test: 웹페이지 썸네일 저장 트랜잭션 테스트 추가 * refactor: 프로토콜에 Sendable 추가
1 parent d3197b6 commit 1ca458a

3 files changed

Lines changed: 158 additions & 16 deletions

File tree

Application/DevLogData/Sources/Protocol/WebPageImageStore.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import Foundation
99

10-
public protocol WebPageImageStore {
10+
public protocol WebPageImageStore: Sendable {
1111
func cachedImageURL(for url: URL) async throws -> URL
1212
func saveImage(_ data: Data, for url: URL) async throws -> URL
1313
func dirSizeInBytes() async -> Int64

Application/DevLogPersistence/Sources/Persistence/WebPageImageStoreImpl.swift

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,43 +9,60 @@ import CryptoKit
99
import Foundation
1010
import DevLogData
1111

12-
actor WebPageImageStoreImpl: WebPageImageStore {
12+
final class WebPageImageStoreImpl: WebPageImageStore {
13+
private let queue = DispatchQueue(
14+
label: "devlog.web-page-image-store",
15+
qos: .utility
16+
)
17+
1318
func cachedImageURL(for url: URL) async throws -> URL {
14-
return try await Task.detached(priority: .utility) {
15-
return try Self.cachedImageURL(for: url)
16-
}.value
19+
return try await perform {
20+
try Self.cachedImageURL(for: url)
21+
}
1722
}
1823

1924
func saveImage(_ data: Data, for url: URL) async throws -> URL {
20-
return try await Task.detached(priority: .utility) {
21-
return try Self.saveImage(data, for: url)
22-
}.value
25+
return try await perform {
26+
try Self.saveImage(data, for: url)
27+
}
2328
}
2429

2530
func dirSizeInBytes() async -> Int64 {
2631
do {
27-
return try await Task.detached(priority: .utility) {
28-
return try Self.dirSizeInBytes()
29-
}.value
32+
return try await perform {
33+
try Self.dirSizeInBytes()
34+
}
3035
} catch {
3136
return 0
3237
}
3338
}
3439

3540
func clearDirectory() async throws {
36-
try await Task.detached(priority: .utility) {
41+
try await perform {
3742
try Self.clearDirectory()
38-
}.value
43+
}
3944
}
4045

4146
func removeImage(for url: URL) async throws -> Bool {
42-
return try await Task.detached(priority: .utility) {
43-
return try Self.removeImage(for: url)
44-
}.value
47+
return try await perform {
48+
try Self.removeImage(for: url)
49+
}
4550
}
4651
}
4752

4853
private extension WebPageImageStoreImpl {
54+
func perform<T: Sendable>(_ operation: @escaping @Sendable () throws -> T) async throws -> T {
55+
try await withCheckedThrowingContinuation { continuation in
56+
queue.async {
57+
do {
58+
continuation.resume(returning: try operation())
59+
} catch {
60+
continuation.resume(throwing: error)
61+
}
62+
}
63+
}
64+
}
65+
4966
static func hashedFileName(for url: URL) -> String {
5067
let hashValue = SHA256.hash(data: Data(url.absoluteString.utf8))
5168
return hashValue.map { String(format: "%02x", $0) }.joined()
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//
2+
// WebPageImageStoreImplTests.swift
3+
// DevLogPersistenceTests
4+
//
5+
// Created by opfic on 6/3/26.
6+
//
7+
8+
import Foundation
9+
import Testing
10+
@testable import DevLogPersistence
11+
12+
@Suite(.serialized)
13+
struct WebPageImageStoreImplTests {
14+
@Test("웹페이지 이미지는 저장되고 삭제된다")
15+
func 웹페이지_이미지는_저장되고_삭제된다() async throws {
16+
let store = WebPageImageStoreImpl()
17+
let fileManager = FileManager.default
18+
try await store.clearDirectory()
19+
let url = try #require(URL(string: "https://example.com/image"))
20+
let data = Data("image-data".utf8)
21+
22+
let fileURL = try await store.saveImage(data, for: url)
23+
24+
let savedData = try #require(fileManager.contents(atPath: fileURL.path))
25+
let directorySize = await store.dirSizeInBytes()
26+
#expect(fileURL.deletingLastPathComponent().lastPathComponent == "webPageImages")
27+
#expect(savedData == data)
28+
#expect(0 < directorySize)
29+
30+
let removed = try await store.removeImage(for: url)
31+
let removedAgain = try await store.removeImage(for: url)
32+
33+
#expect(removed)
34+
#expect(!removedAgain)
35+
#expect(!fileManager.fileExists(atPath: fileURL.path))
36+
37+
try await store.clearDirectory()
38+
}
39+
40+
@Test("웹페이지 이미지 디렉터리 삭제는 저장된 이미지를 모두 제거한다")
41+
func 웹페이지_이미지_디렉터리_삭제는_저장된_이미지를_모두_제거한다() async throws {
42+
let store = WebPageImageStoreImpl()
43+
try await store.clearDirectory()
44+
let firstURL = try #require(URL(string: "https://example.com/first"))
45+
let secondURL = try #require(URL(string: "https://example.com/second"))
46+
47+
_ = try await store.saveImage(Data("first".utf8), for: firstURL)
48+
_ = try await store.saveImage(Data("second".utf8), for: secondURL)
49+
try await store.clearDirectory()
50+
51+
let directorySize = await store.dirSizeInBytes()
52+
#expect(directorySize == 0)
53+
}
54+
55+
@Test("같은 store를 공유하는 객체의 저장은 트랜잭션 단위로 반영된다")
56+
func 같은_store를_공유하는_객체의_저장은_트랜잭션_단위로_반영된다() async throws {
57+
let store = WebPageImageStoreImpl()
58+
let firstClient = WebPageImageStoreClient(store: store)
59+
let secondClient = WebPageImageStoreClient(store: store)
60+
try await store.clearDirectory()
61+
let url = try #require(URL(string: "https://example.com/\(UUID().uuidString)"))
62+
let largeData = Data(repeating: 1, count: 64 * 1024 * 1024)
63+
let smallData = Data("latest".utf8)
64+
let startedAt = ContinuousClock.now
65+
66+
// 동일한 Impl 인스턴스를 공유하는 두 객체가 접근하는 형태의 결과 기반 테스트다.
67+
// 첫 번째 큰 저장 작업이 먼저 큐에 들어갈 시간을 준 뒤 두 번째 작은 저장을 요청하고,
68+
// 최종 파일이 작은 데이터라면 앞 작업 전체가 끝난 뒤 뒤 작업이 반영된 것으로 본다.
69+
// 각 작업의 완료 시점은 호출자 관점에서 saveImage await가 반환된 시점으로 기록한다.
70+
let largeSaveTask = Task {
71+
try await firstClient.saveImage(largeData, for: url, name: "large", since: startedAt)
72+
}
73+
try await Task.sleep(nanoseconds: 10_000_000)
74+
75+
let smallSaveMeasurement = try await secondClient.saveImage(smallData, for: url, name: "small", since: startedAt)
76+
let largeSaveMeasurement = try await largeSaveTask.value
77+
let savedData = try Data(contentsOf: smallSaveMeasurement.fileURL)
78+
79+
print(saveSummary(largeSaveMeasurement))
80+
print(saveSummary(smallSaveMeasurement))
81+
82+
#expect(savedData == smallData)
83+
84+
try await store.clearDirectory()
85+
}
86+
}
87+
88+
private struct WebPageImageStoreClient {
89+
let store: WebPageImageStoreImpl
90+
91+
func saveImage(
92+
_ data: Data,
93+
for url: URL,
94+
name: String,
95+
since startedAt: ContinuousClock.Instant
96+
) async throws -> WebPageImageStoreSaveMeasurement {
97+
let requestedAt = startedAt.duration(to: .now)
98+
let fileURL = try await store.saveImage(data, for: url)
99+
let finishedAt = startedAt.duration(to: .now)
100+
101+
return WebPageImageStoreSaveMeasurement(
102+
name: name,
103+
fileURL: fileURL,
104+
requestedAt: requestedAt,
105+
finishedAt: finishedAt
106+
)
107+
}
108+
}
109+
110+
private struct WebPageImageStoreSaveMeasurement {
111+
let name: String
112+
let fileURL: URL
113+
let requestedAt: Duration
114+
let finishedAt: Duration
115+
}
116+
117+
private func saveSummary(_ measurement: WebPageImageStoreSaveMeasurement) -> String {
118+
"\(measurement.name) save requested: \(millisecondsString(measurement.requestedAt))ms, finished: \(millisecondsString(measurement.finishedAt))ms"
119+
}
120+
121+
private func millisecondsString(_ duration: Duration) -> String {
122+
let components = duration.components
123+
let milliseconds = Double(components.seconds) * 1_000 + Double(components.attoseconds) / 1_000_000_000_000_000
124+
return String(format: "%.3f", milliseconds)
125+
}

0 commit comments

Comments
 (0)