지하철 검색 기능에 캐싱 로직 도입하고 테스트로 검증하기
지하철 검색 로직에 캐싱 로직을 도입.
목차
- SearchSubwayUseCase 개선
- SearchSubwayUseCase 테스트를 위한 Spy 객체 만들기
- SearchSubwayUseCaseTests 캐싱 로직 동작 검증 코드
- SearchSubwayUseCaseTests 실패 후 로직 보완
- 보강한 테스트: 실패 응답과 백그라운드 방출
지하철 검색 로직은 사용자가 키보드로 검색어를 입력할 때 throttle을 활용해 약간의 시간을 두고 검색을 실행.
- 여기까지는 일반적으로 사용하는 검색 로직.
같은 검색어에 대한 서버 요청을 줄이고, 동일한 결과를 더 빨리 돌려줄 수 있으므로 이점은 확실.
캐시가 throttle과 겹치는 지점도 있음. throttle이 요청 자체의 빈도를 낮춘다면, 캐시는 이미 한 번 받아온 검색어에 대해 서버를 다시 타지 않고 즉시 반환. 즉 throttle은 "덜 보내기", 캐시는 "받아온 건 다시 안 받기"라 역할이 겹치지 않고 서로 보완됨.
지하철역 데이터는 앱 실행 중에 바뀌지 않는 정적 데이터라 별도의 만료(TTL)나 무효화 로직은 두지 않음. 다만 검색어 종류만큼 딕셔너리가 계속 쌓이는 구조라, 검색어가 매우 다양해지는 상황이라면 상한이나 LRU 같은 정리 정책이 필요해질 수 있음. 지금 규모에서는 필요 없다고 판단해서 넣지 않음.
SearchSubwayUseCase 개선
기존에 Combine으로 처리하던 로직에 캐시를 두어 개선. 캐시에 있으면 Just로 바로 반환하고, 없으면 Repository를 호출한 뒤 handleEvents에서 결과를 캐시에 저장. 딕셔너리는 동시 접근에 안전하지 않으므로 OSAllocatedUnfairLock으로 감싸 읽기/쓰기를 직렬화.
import Entity
import Combine
import Foundation
import os
public protocol SearchSubwayUseCase {
func execute(query: SearchSubwayStationQuery) -> AnyPublisher<[SubwayInfo], SubwayStationRepositoryError>
}
public final class DefaultSearchSubwayUseCase: SearchSubwayUseCase {
private let repository: SearchSubwayStationRepository
typealias Key = SearchSubwayStationQuery
typealias Value = [SubwayInfo]
// 읽기(execute 진입)와 쓰기(handleEvents)가 서로 다른 스레드에서 일어날 수 있어 lock으로 직렬화
private let cacheLock = OSAllocatedUnfairLock(initialState: [Key: Value]())
// 테스트에서 상태 검증용으로 읽는 스냅샷
var cache: [Key: Value] {
cacheLock.withLock { $0 }
}
public init(repository: SearchSubwayStationRepository) {
self.repository = repository
}
public func execute(query: SearchSubwayStationQuery) -> AnyPublisher<[SubwayInfo], SubwayStationRepositoryError> {
// 캐시에 값이 있는지 확인
if let cachedData = cacheLock.withLock({ $0[query] }) {
// 캐시된 값을 바로 반환
return Just(cachedData)
.setFailureType(to: SubwayStationRepositoryError.self)
.eraseToAnyPublisher()
} else {
return repository.searchSubwayStations(query: query)
.receive(on: DispatchQueue.main) // UI 소비를 위해 메인으로 전달
.handleEvents(receiveOutput: { [cacheLock] result in
// 성공적으로 데이터를 가져오면 캐시에 저장
cacheLock.withLock { $0[query] = result }
})
.eraseToAnyPublisher()
}
}
}
SearchSubwayStationQuery를 딕셔너리 Key로, 검색 결과 [SubwayInfo]를 Value로 사용. 핵심은 handleEvents(receiveOutput:)에서 성공 응답을 캐시에 넣는 부분이고, 뒤에서 처음에 이 저장을 빠뜨려 테스트가 실패함.
lock이 필요한 이유. 쓰기(handleEvents)는 방출 경로에서, 읽기(execute 진입부)는 호출한 쪽 스레드에서 일어나므로 서로 다른 스레드가 같은 딕셔너리를 건드릴 수 있음. receive(on:)으로 메인에 모으는 방법은 읽기 쪽까지 막아주지 못해서 접근 자체를 lock으로 직렬화. receive(on:)은 안전장치가 아니라 UI 소비를 위한 메인 전달 역할. 클로저가 [cacheLock]만 캡처하므로 self 순환 참조 걱정도 없음.
SearchSubwayUseCase 테스트를 위한 Spy 객체 만들기
캐싱 로직을 만들었으니 테스트 코드로 검증.
- UseCase 테스트를 위해 Repository 주입이 필요
- 캐싱 로직 검증에는 실제 네트워크가 필요 없으므로 테스트 목적의 Spy 제작
Spy는 결과 값을 임의로 지정할 수 있는 configureResult, 상태를 되돌리는 initialize, 그리고 호출 여부와 횟수를 기록하는 searchCalled / searchCallCount를 가짐. 캐시가 실제로 동작하는지는 이 호출 횟수로 판단.
준비해둔 응답을 돌려주는 configureResult만 보면 Stub이지만, 호출 여부와 횟수를 기록하고 그 기록을 단언에 쓰므로 테스트 더블 분류로는 Spy. 이 테스트의 핵심 검증("Repository가 한 번만 불렸는가")이 바로 이 기록에 의존.
extension SearchSubwayUseCaseTests {
class SpySearchSubwayStationRepository: SearchSubwayStationRepository {
private var success: [SubwayInfo] = []
private var failure: SubwayStationRepositoryError? = nil
var searchCalled: Bool = false
var searchCallCount: Int = 0
/// 실제 네트워크처럼 백그라운드 큐에서 방출하는 경로를 재현
var emitsOnBackgroundQueue: Bool = false
func configureResult(
success: [SubwayInfo],
failure: SubwayStationRepositoryError?
) {
self.success = success
self.failure = failure
}
func initialize() {
success = []
failure = nil
searchCalled = false
searchCallCount = 0
emitsOnBackgroundQueue = false
}
func searchSubwayStations(query: SearchSubwayStationQuery) -> AnyPublisher<[SubwayInfo], SubwayStationRepositoryError> {
searchCalled = true
searchCallCount += 1
let publisher: AnyPublisher<[SubwayInfo], SubwayStationRepositoryError>
if let error = failure {
publisher = Fail(error: error)
.eraseToAnyPublisher()
} else {
publisher = Just(success)
.setFailureType(to: SubwayStationRepositoryError.self)
.eraseToAnyPublisher()
}
if emitsOnBackgroundQueue {
return publisher
.subscribe(on: DispatchQueue.global())
.eraseToAnyPublisher()
}
return publisher
}
}
}
이후 Spy를 주입해 테스트 대상인 sut를 구성. sut는 구체 타입으로 선언해 internal로 열어둔 cache 스냅샷에 다운캐스트 없이 접근.
final class SearchSubwayUseCaseTests: XCTestCase {
let repository = SpySearchSubwayStationRepository()
var sut: DefaultSearchSubwayUseCase!
var cancellables: Set<AnyCancellable> = []
override func setUp() {
super.setUp()
repository.initialize()
sut = DefaultSearchSubwayUseCase(
repository: repository
)
}
override func tearDown() {
super.tearDown()
sut = nil
cancellables.removeAll()
}
}
SearchSubwayUseCaseTests 캐싱 로직 동작 검증 코드
테스트 주안점
- BDD 기반으로 작성할 것.
- 시나리오를 잘 담아낼 것.
- 빈틈 없지만, 너무 복잡하지 않을 것.
고민 포인트
cache프로퍼티의 접근제어자가private이라 테스트 코드에서 접근할 수 없는 부분- 대안 1: 비공식 attribute인
@_spi사용하기 - 대안 2: 모듈 내부(internal)로만 열기
- 선택: 대안 2
- 사유:
@_spi는 외부 모듈에 노출되는 문제가 있고,Feature모듈 내부 테스트가 목적이라 internal 노출을 채택 - lock으로 감싼 뒤에는 저장 프로퍼티 대신 스냅샷을 반환하는 계산 프로퍼티
cache가 그 역할
다만 캐시 내부 상태를 직접 열어보는 방식은 테스트가 구현 세부에 결합되는 약점이 있음. 그래서 이 테스트는 상태 검증(cache[query])뿐 아니라 행위 검증(searchCallCount == 1)도 함께 둠. 캐시가 정말 동작한다면 같은 쿼리를 두 번 호출해도 Repository는 한 번만 불려야 하고, 이 호출 횟수만으로도 캐시 동작을 판별할 수 있음.
func test_캐싱로직이_제대로_동작하는지_검증() {
// given
let query: SearchSubwayStationQuery = .init(searchText: "강남")
let expectedData: [SubwayInfo] = [.init(name: "강남역", lines: ["1"])]
// Spy에 성공 결과 설정
repository.configureResult(success: expectedData, failure: nil)
// when
var firstResult: [SubwayInfo] = []
var secondResult: [SubwayInfo] = []
// 첫 번째 검색 실행 (캐시에 없으므로 repository에서 데이터를 가져옴)
let firstSearchExpectation = expectation(description: "첫번째 검색 성공")
sut.execute(query: query)
.sink(receiveCompletion: { _ in }, receiveValue: { result in
firstResult = result
firstSearchExpectation.fulfill()
})
.store(in: &cancellables)
wait(for: [firstSearchExpectation], timeout: 2.0)
// Spy의 호출 여부를 확인 (repository가 호출되었어야 함)
XCTAssertTrue(repository.searchCalled, "첫 요청에서 Repository가 호출되었음.")
// 캐시 확인 (첫 번째 결과가 캐시에 저장되어야 함)
XCTAssertEqual(sut.cache[query], expectedData, "첫 번째 결과가 캐시에 저장되어야 함.")
// 두 번째 검색 실행 (캐시에 데이터가 있으므로 repository가 호출되지 않음)
let secondSearchExpectation = expectation(description: "두번째 검색 호출")
sut.execute(query: query)
.sink(receiveCompletion: { _ in }, receiveValue: { result in
secondResult = result
secondSearchExpectation.fulfill()
})
.store(in: &cancellables)
wait(for: [secondSearchExpectation], timeout: 1.0)
// repository가 다시 호출되지 않았는지 확인
XCTAssertEqual(repository.searchCallCount, 1, "Repository는 캐싱으로 인해 한 번만 호출 되어야 함.")
// 검증
XCTAssertEqual(firstResult, expectedData, "첫 번째 검색은 예상된 데이터를 반환해야 함.")
XCTAssertEqual(secondResult, expectedData, "두 번째 검색은 캐시된 데이터를 반환해야 함.")
}
SearchSubwayUseCaseTests 실패 후 로직 보완
처음 테스트를 돌렸을 때 실패 확인.
- 사유: 응답 성공 시 캐시 프로퍼티를 업데이트하는 로직 누락.

처음에 잘못 작성한 코드는 아래와 같음(lock 도입 전이라 캐시가 맨 딕셔너리이던 시점). 완성된 코드와 비교하면 else 절에서 handleEvents(receiveOutput:)로 결과를 캐시에 저장하는 부분이 빠져 있음. 그래서 서버 응답이 한 번도 캐시에 들어가지 않음. 첫 검색 직후 cache[query]가 비어 있어 캐시 단언이 먼저 실패하고(스크린샷의 실패 지점), 이어서 두 번째 호출에서도 Repository를 다시 타 searchCallCount가 2가 되어 그 단언도 실패.
public final class DefaultSearchSubwayUseCase: SearchSubwayUseCase {
private let repository: SearchSubwayStationRepository
typealias Key = SearchSubwayStationQuery
typealias Value = [SubwayInfo]
private(set) var cache: [Key: Value] = [:]
public init(repository: SearchSubwayStationRepository) {
self.repository = repository
}
public func execute(query: SearchSubwayStationQuery) -> AnyPublisher<[SubwayInfo], SubwayStationRepositoryError> {
// 캐시에 값이 있는지 확인
if let cachedData = cache[query] {
// 캐시된 값을 바로 반환
return Just(cachedData)
.setFailureType(to: SubwayStationRepositoryError.self)
.eraseToAnyPublisher()
} else {
// 결과를 캐시에 저장하는 handleEvents가 빠져 있음
return repository.searchSubwayStations(query: query)
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
}
보강한 테스트: 실패 응답과 백그라운드 방출
캐싱 검증까지 통과한 뒤 두 가지를 보강.
먼저 실패 경로. handleEvents(receiveOutput:)은 성공 출력에만 반응하므로 실패는 캐시에 남지 않아야 하고, 재시도하면 다시 서버를 타야 함. Spy에 만들어둔 failure 설정을 여기서 사용.
// 성공만 캐시하므로, 실패 응답은 캐시에 남지 않고 재시도 시 다시 서버를 타야 함.
func test_실패_응답은_캐시되지_않고_재시도시_다시_요청하는지_검증() {
// given
let query: SearchSubwayStationQuery = .init(searchText: "강남")
repository.configureResult(success: [], failure: .networkFailure)
// when
var receivedFailure = false
let failedSearchExpectation = expectation(description: "검색 실패")
sut.execute(query: query)
.sink(receiveCompletion: { completion in
if case .failure = completion {
receivedFailure = true
}
failedSearchExpectation.fulfill()
}, receiveValue: { _ in })
.store(in: &cancellables)
wait(for: [failedSearchExpectation], timeout: 2.0)
// then
XCTAssertTrue(receivedFailure, "실패가 그대로 전달되어야 함.")
XCTAssertNil(sut.cache[query], "실패 응답은 캐시에 저장되지 않아야 함.")
// 재시도 (실패는 캐시되지 않았으므로 repository를 다시 타야 함)
let retryExpectation = expectation(description: "재시도")
sut.execute(query: query)
.sink(receiveCompletion: { _ in retryExpectation.fulfill() }, receiveValue: { _ in })
.store(in: &cancellables)
wait(for: [retryExpectation], timeout: 1.0)
XCTAssertEqual(repository.searchCallCount, 2, "실패는 캐시되지 않으므로 재시도 시 Repository가 다시 호출되어야 함.")
}
다음은 백그라운드 방출 경로. 위의 테스트들은 Spy가 Just로 동기 방출해서 모든 코드가 테스트 스레드에서 돎. 실제 네트워크처럼 백그라운드에서 방출하는 경로는 재현되지 않으므로, Spy의 emitsOnBackgroundQueue로 그 경로를 태워 receive(on:)에 의해 결과가 메인으로 전달되고 캐시도 기록되는지 확인.
func test_백그라운드_방출에서도_메인_전달과_캐싱이_동작하는지_검증() {
// given
let query: SearchSubwayStationQuery = .init(searchText: "강남")
let expectedData: [SubwayInfo] = [.init(name: "강남역", lines: ["2"])]
repository.configureResult(success: expectedData, failure: nil)
repository.emitsOnBackgroundQueue = true
// when
var receivedOnMainThread = false
var result: [SubwayInfo] = []
let searchExpectation = expectation(description: "백그라운드 방출 검색")
sut.execute(query: query)
.sink(receiveCompletion: { _ in }, receiveValue: { value in
receivedOnMainThread = Thread.isMainThread
result = value
searchExpectation.fulfill()
})
.store(in: &cancellables)
wait(for: [searchExpectation], timeout: 2.0)
// then
XCTAssertTrue(receivedOnMainThread, "receive(on:)에 의해 결과가 메인 스레드로 전달되어야 함.")
XCTAssertEqual(result, expectedData, "백그라운드 방출에서도 예상된 데이터를 반환해야 함.")
// handleEvents가 receive(on:) 뒤(다운스트림)에 있으므로 sink 시점엔 캐시가 이미 기록됨
XCTAssertEqual(sut.cache[query], expectedData, "백그라운드 방출에서도 결과가 캐시에 저장되어야 함.")
XCTAssertEqual(repository.searchCallCount, 1, "Repository는 한 번만 호출되어야 함.")
}
'Project > Funch(넥스터즈)' 카테고리의 다른 글
| 모듈화 리팩토링 과정에서 고민했던 것들 (2) | 2024.09.24 |
|---|---|
| SwiftUI 화면 dismiss 상황에서 흰 화면 나타나는 문제 (1) | 2024.09.22 |
| Swift Concurrency를 적용하면서 발생한 동시성 문제 (0) | 2024.09.20 |
| [IT 동아리 Nexters] 24기 프로젝트 회고 (0) | 2024.03.03 |
| iOS Memory Debug Graph 분석해 프로젝트 구조 개선 (0) | 2024.03.01 |