Skip to content

Commit f6b023d

Browse files
authored
[#585] 프로필 사진 수정이 반영되지 않는 문제를 해결한다 (#586)
* feat: 프로필 이미지 데이터 유스케이스 추가 * feat: 프로필 이미지 메모리 캐시 저장소 추가 * feat: Nexa로 프로필 이미지 데이터 요청 * refactor: 프로필 아바타에서 CacheableImage 제거 * chore: 버전 1.2.5로 업 * feat: Nexa 1.1.1 적용
1 parent 6ce0574 commit f6b023d

18 files changed

Lines changed: 404 additions & 123 deletions

File tree

Application/DevLogData/Sources/DataAssembler.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ public final class DataAssembler: Assembler {
9494
UserDataRepositoryImpl(userService: container.resolve(UserService.self))
9595
}
9696

97+
container.register(ProfileImageDataRepository.self) {
98+
ProfileImageDataRepositoryImpl(
99+
service: container.resolve(ProfileImageDataService.self),
100+
store: container.resolve(MemoryCacheStore.self)
101+
)
102+
}
103+
97104
container.register(AnalyticsRepository.self) {
98105
AnalyticsRepositoryImpl(
99106
analyticsService: container.resolve(AnalyticsService.self)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// ProfileImageDataService.swift
3+
// DevLogData
4+
//
5+
// Created by opfic on 6/11/26.
6+
//
7+
8+
import Foundation
9+
10+
public protocol ProfileImageDataService {
11+
func fetchImageData(from url: URL) async throws -> Data
12+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//
2+
// ProfileImageDataRepositoryImpl.swift
3+
// DevLogData
4+
//
5+
// Created by opfic on 6/11/26.
6+
//
7+
8+
import Foundation
9+
import DevLogDomain
10+
11+
public final class ProfileImageDataRepositoryImpl: ProfileImageDataRepository {
12+
private let service: ProfileImageDataService
13+
private let store: MemoryCacheStore
14+
15+
public init(
16+
service: ProfileImageDataService,
17+
store: MemoryCacheStore
18+
) {
19+
self.service = service
20+
self.store = store
21+
}
22+
23+
public func fetchImageData(from url: URL) async throws -> Data {
24+
do {
25+
let data = try await service.fetchImageData(from: url)
26+
store.setValue(data, forKey: Self.cacheKey(for: url))
27+
return data
28+
} catch {
29+
if let data: Data = store.value(forKey: Self.cacheKey(for: url)) {
30+
return data
31+
}
32+
throw error
33+
}
34+
}
35+
36+
private static func cacheKey(for url: URL) -> String {
37+
"profileImageData:\(url.absoluteString)"
38+
}
39+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
//
2+
// ProfileImageDataRepositoryImplTests.swift
3+
// DevLogDataTests
4+
//
5+
// Created by opfic on 6/11/26.
6+
//
7+
8+
import Foundation
9+
import Testing
10+
@testable import DevLogData
11+
12+
struct ProfileImageDataRepositoryImplTests {
13+
@Test("캐시가 있어도 원격 이미지 데이터를 다시 요청하고 성공 데이터를 저장한다")
14+
func 캐시가_있어도_원격_이미지_데이터를_다시_요청하고_성공_데이터를_저장한다() async throws {
15+
let cachedData = Data([1, 2, 3])
16+
let remoteData = Data([4, 5, 6])
17+
let service = ProfileImageDataServiceSpy(data: cachedData)
18+
let store = ProfileImageMemoryCacheStoreSpy()
19+
let repository = ProfileImageDataRepositoryImpl(service: service, store: store)
20+
let url = URL(string: "https://example.com/avatar.png")!
21+
22+
_ = try await repository.fetchImageData(from: url)
23+
service.data = remoteData
24+
let data = try await repository.fetchImageData(from: url)
25+
26+
#expect(data == remoteData)
27+
#expect(service.calledURLs == [url, url])
28+
#expect(store.storedData == remoteData)
29+
}
30+
31+
@Test("원격 이미지 요청 실패 시 메모리 캐시 데이터를 반환한다")
32+
func 원격_이미지_요청_실패_시_메모리_캐시_데이터를_반환한다() async throws {
33+
let cachedData = Data([1, 2, 3])
34+
let service = ProfileImageDataServiceSpy(data: cachedData)
35+
let store = ProfileImageMemoryCacheStoreSpy()
36+
let repository = ProfileImageDataRepositoryImpl(service: service, store: store)
37+
let url = URL(string: "https://example.com/avatar.png")!
38+
39+
_ = try await repository.fetchImageData(from: url)
40+
service.error = ProfileImageDataRepositoryImplTestsError.serviceFailed
41+
let data = try await repository.fetchImageData(from: url)
42+
43+
#expect(data == cachedData)
44+
#expect(service.calledURLs == [url, url])
45+
}
46+
}
47+
48+
private final class ProfileImageDataServiceSpy: ProfileImageDataService {
49+
var data: Data
50+
var error: Error?
51+
private(set) var calledURLs: [URL] = []
52+
53+
init(data: Data) {
54+
self.data = data
55+
}
56+
57+
func fetchImageData(from url: URL) async throws -> Data {
58+
calledURLs.append(url)
59+
60+
if let error {
61+
throw error
62+
}
63+
64+
return data
65+
}
66+
}
67+
68+
private final class ProfileImageMemoryCacheStoreSpy: MemoryCacheStore {
69+
private var values = [String: Any]()
70+
private(set) var storedData: Data?
71+
72+
func value<T: Codable>(forKey key: String) -> T? {
73+
values[key] as? T
74+
}
75+
76+
func setValue<T: Codable>(_ value: T?, forKey key: String) {
77+
guard let value else {
78+
values.removeValue(forKey: key)
79+
return
80+
}
81+
82+
values[key] = value
83+
storedData = value as? Data
84+
}
85+
}
86+
87+
private enum ProfileImageDataRepositoryImplTestsError: Error {
88+
case serviceFailed
89+
}

Application/DevLogDomain/Sources/DomainAssembler.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ private extension DomainAssembler {
116116
FetchUserDataUseCaseImpl(container.resolve(UserDataRepository.self))
117117
}
118118

119+
container.register(FetchProfileImageDataUseCase.self) {
120+
FetchProfileImageDataUseCaseImpl(container.resolve(ProfileImageDataRepository.self))
121+
}
122+
119123
container.register(UpsertStatusMessageUseCase.self) {
120124
UpsertStatusMessageUseCaseImpl(container.resolve(UserDataRepository.self))
121125
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// ProfileImageDataRepository.swift
3+
// DevLogDomain
4+
//
5+
// Created by opfic on 6/11/26.
6+
//
7+
8+
import Foundation
9+
10+
public protocol ProfileImageDataRepository {
11+
func fetchImageData(from url: URL) async throws -> Data
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// FetchProfileImageDataUseCase.swift
3+
// DevLogDomain
4+
//
5+
// Created by opfic on 6/11/26.
6+
//
7+
8+
import Foundation
9+
10+
public protocol FetchProfileImageDataUseCase {
11+
func execute(from url: URL) async throws -> Data
12+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// FetchProfileImageDataUseCaseImpl.swift
3+
// DevLogDomain
4+
//
5+
// Created by opfic on 6/11/26.
6+
//
7+
8+
import Foundation
9+
10+
public final class FetchProfileImageDataUseCaseImpl: FetchProfileImageDataUseCase {
11+
private let repository: ProfileImageDataRepository
12+
13+
public init(_ repository: ProfileImageDataRepository) {
14+
self.repository = repository
15+
}
16+
17+
public func execute(from url: URL) async throws -> Data {
18+
try await repository.fetchImageData(from: url)
19+
}
20+
}

Application/DevLogInfra/Sources/InfraAssembler.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ public final class InfraAssembler: Assembler {
6161
UserServiceImpl()
6262
}
6363

64+
container.register(ProfileImageDataService.self) {
65+
ProfileImageDataServiceImpl()
66+
}
67+
6468
container.register(PushNotificationService.self) {
6569
PushNotificationServiceImpl()
6670
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// ProfileImageDataServiceImpl.swift
3+
// DevLogInfra
4+
//
5+
// Created by opfic on 6/11/26.
6+
//
7+
8+
import Foundation
9+
import Nexa
10+
import DevLogData
11+
12+
final class ProfileImageDataServiceImpl: ProfileImageDataService {
13+
func fetchImageData(from url: URL) async throws -> Data {
14+
try await NXAPIClient(
15+
configuration: NXClientConfiguration(baseURL: url)
16+
)
17+
.get()
18+
.timeout(10)
19+
.intercept(ProfileImageDataCachePolicyInterceptor())
20+
.validate(.successStatusCode)
21+
.raw()
22+
.data
23+
}
24+
}
25+
26+
private struct ProfileImageDataCachePolicyInterceptor: NXHTTPInterceptor {
27+
func intercept(
28+
context: NXRequestExecutionContext,
29+
next: @escaping @Sendable (NXRequestExecutionContext) async throws -> NXRawResponse
30+
) async throws -> NXRawResponse {
31+
var request = context.request
32+
request.cachePolicy = .reloadIgnoringLocalCacheData
33+
return try await next(context.replacingRequest(request))
34+
}
35+
}

0 commit comments

Comments
 (0)