Project/Funch(넥스터즈)

지하철 검색 기능에 캐싱 로직 도입하고 테스트로 검증하기

lgvv 2024. 9. 20. 18:49

지하철 검색 기능에 캐싱 로직 도입하고 테스트로 검증하기

 

지하철 검색 로직에 캐싱 로직을 도입.

 

목차

  • 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는 한 번만 호출되어야 함.")
}