클라우드 Mac에서 가상 시간으로 iOS 검색 디바운스 검증하기

클라우드 Mac에서 가상 시간으로 iOS 검색 디바운스 검증하기

클라우드 Mac에서 가상 시간으로 iOS 검색 디바운스 검증하기

검색창에는 300밀리초 디바운스를 적용해 놓고, 테스트는 “입력한 뒤 반 초를 기다린 다음 요청 횟수를 확인한다”는 식으로 작성하는 경우가 있습니다. 클라우드 Mac에서 여러 작업을 병렬로 실행하면 이런 테스트는 느릴 뿐 아니라 스케줄링 지연에도 흔들리기 쉽습니다. 실제로 검증해야 할 것은 마지막 입력을 기준으로 정해지는 만료 시점, 이전 입력의 교체, 검색창을 비운 뒤에는 검색어가 전달되지 않는다는 규칙입니다. 이 규칙을 타이머와 분리하면 테스트에서 실제 시간이 흐르기를 기다릴 필요가 없습니다.

먼저 검색의 시간 계약을 명확히 정의하기

이 글에서는 후행 에지 디바운스를 사용합니다. 비어 있지 않은 입력이 들어올 때마다 만료 시점을 다시 계산하고, 그 시점에 도달하면 최신 검색어만 한 번 전달합니다. 먼저 앞뒤 공백을 제거하며, 공백만 있는 입력과 빈 문자열은 모두 대기 중인 검색어 전달을 취소합니다. 같은 검색어를 다시 입력해도 대기 시간을 처음부터 계산합니다. 이는 명시적인 제품 규칙이지, 모든 구현의 기본 동작은 아닙니다.

시간은 시스템 날짜가 아니라 테스트 시작 시점부터 경과한 정수 밀리초로 표현합니다. 운영 환경에서는 시간 보정으로 값이 갑자기 바뀔 수 있는 벽시계 시간이 아닌, 단조 시계에서 경과 시간을 얻어야 합니다. 300밀리초는 정책의 예시일 뿐, 서비스 성능 지표가 아닙니다.

디바운스는 검색어를 언제 전달할지만 결정합니다. 이미 전송한 네트워크 요청을 취소할지, 이전 요청의 응답이 UI를 갱신해도 되는지는 별개의 두 계약입니다. “타이머를 취소했다”는 사실만으로 이 두 계약의 검증을 대신할 수는 없습니다.

시간 규칙을 독립적인 상태 머신으로 분리하기

클라우드 Mac의 명령줄에서 작은 검증용 패키지를 만듭니다. 현재 선택된 개발 도구 체인에는 Swift가 포함되어 있어야 합니다. 시뮬레이터나 그래픽 세션은 필요하지 않습니다.

mkdir SearchTiming
cd SearchTiming
swift package init --type library --name SearchTiming

Sources/SearchTiming/SearchTiming.swift를 아래 내용으로 교체합니다. 이 코드는 스레드나 타이머를 생성하지 않습니다. 호출하는 쪽에서 이전 값보다 작아지지 않는 순서로 시간을 전달하고, 상태에 직렬로 접근해야 합니다.

import Foundation

struct SearchDebounce {
    let delay: Int64
    private(set) var deadline: Int64?
    private var pending: String?

    init(delay: Int64 = 300) {
        precondition(delay > 0)
        self.delay = delay
    }

    mutating func input(_ text: String, at now: Int64) {
        let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !query.isEmpty else {
            pending = nil
            deadline = nil
            return
        }
        pending = query
        deadline = now + delay
    }

    mutating func takeDue(at now: Int64) -> String? {
        guard let limit = deadline, now >= limit else {
            return nil
        }
        let query = pending
        pending = nil
        deadline = nil
        return query
    }
}

takeDue는 만료 시점을 확인하면서 대기 중인 검색어를 소비합니다. 따라서 콜백이 반복되어도 중복 전달되지 않으며, 콜백이 너무 일찍 실행되어도 검색어가 사라지지 않습니다. 이 예시는 시간 값이 정상적인 실행 범위 안에 있다고 가정합니다. 시계 어댑터가 외부에서 값을 받는다면 음수, 시간 역행, 덧셈 오버플로도 검사해야 합니다.

sleep을 호출하지 않고 가상 시간 진행하기

Tests/SearchTimingTests/SearchTimingTests.swift를 아래 테스트로 교체합니다. 각 테스트는 새로운 상태를 만들어 시계와 대기 중인 입력을 공유하지 않도록 합니다.

import XCTest
@testable import SearchTiming

final class SearchTimingTests: XCTestCase {
    func testDeadlineAndSingleDelivery() {
        var state = SearchDebounce()
        state.input(" swift ", at: 0)
        XCTAssertNil(state.takeDue(at: 299))
        XCTAssertEqual(state.takeDue(at: 300), "swift")
        XCTAssertNil(state.takeDue(at: 301))
    }

    func testLatestInputMovesDeadline() {
        var state = SearchDebounce()
        state.input("s", at: 0)
        state.input("sw", at: 100)
        state.input("swift", at: 220)
        XCTAssertEqual(state.deadline, 520)
        XCTAssertNil(state.takeDue(at: 300))
        XCTAssertNil(state.takeDue(at: 519))
        XCTAssertEqual(state.takeDue(at: 520), "swift")
    }

    func testBlankCancelsPendingInput() {
        var state = SearchDebounce()
        state.input("swift", at: 0)
        state.input(" \n ", at: 100)
        XCTAssertNil(state.deadline)
        XCTAssertNil(state.takeDue(at: 1000))
    }

    func testSameInputRestartsDelay() {
        var state = SearchDebounce()
        state.input("swift", at: 0)
        state.input("swift", at: 200)
        XCTAssertNil(state.takeDue(at: 300))
        XCTAssertEqual(state.takeDue(at: 500), "swift")
    }
}

swift test를 실행합니다. 단언이 실패하면 명령은 반드시 0이 아닌 종료 코드를 반환해야 합니다. 파이프라인이 출력만 보관한 채 다음 단계로 진행해서는 안 됩니다. 이 테스트는 만료 직전, 정확한 만료 시점, 반복 소비를 검증합니다. 만료 시점보다 충분히 늦은 경우도 추가하면 스케줄링 지연 때문에 검색어가 영구적으로 유실되지 않는지 확인할 수 있습니다.

실제 태스크 연결 시 두 가지 취소 오류 방지하기

실제 어댑터에서는 ContinuousClock을 사용해 만료 시점까지 기다릴 수 있습니다. 상태 머신은 규칙을 담당하고, 어댑터는 태스크를 깨우는 역할을 맡습니다. 입력이 들어올 때마다 먼저 상태를 갱신한 다음 기존 대기 태스크를 취소하고, 새 만료 시점에 맞춰 대기 태스크를 만듭니다. 검색창을 비운 뒤에는 새 태스크를 생성하지 않습니다.

첫 번째 오류는 취소 예외를 무시하는 것입니다. try?로 대기한 뒤 곧바로 전달 로직을 호출하면 취소된 경우에도 전달 경로가 계속 실행됩니다. 취소를 잡으면 실행을 종료해야 하며, 취소를 “시간이 다 됐다”는 뜻으로 처리해서는 안 됩니다.

두 번째 오류는 이전 태스크의 콜백이 뒤늦게 실행되는 것입니다. 입력마다 세대 번호를 증가시키고, 대기 태스크는 생성 당시의 세대 번호를 저장하도록 합니다. 태스크가 깨어나면 동일한 직렬 실행기에서 현재 세대 번호와 비교하고, 일치할 때만 상태를 소비하도록 허용합니다. 취소와 깨우기가 동시에 일어날 수 있으므로 태스크의 취소 여부만 확인해서는 충분하지 않습니다.

UI 입력, 세대 번호 갱신, 결과 전달은 모두 메인 실행기에서 처리할 수 있습니다. 백그라운드에서 상태를 읽은 다음 메인 실행기로 비동기 전환해 수정하지 마세요. 그러면 상태 확인과 소비 사이에 틈이 생깁니다. 가상 시간 테스트에서 밀리초를 쓰는 것은 단언을 쉽게 작성하기 위해서입니다. 실제 대기에서는 변환 오차가 누적되지 않도록 시계가 제공하는 만료 시점과 지속 시간 타입을 사용해야 합니다.

소수의 어댑터 통합 테스트로 마무리하기

순수 로직 테스트를 통과했다면 모든 테스트를 긴 실제 대기 방식으로 되돌리는 대신 실제 어댑터를 확인합니다. 테스트 타임아웃은 충분히 여유 있게 설정하되, 검색어가 일찍 전달되지 않고 최종적으로 한 번만 전달되는지는 계속 검증해야 합니다. 타임아웃은 실행이 멈춘 상태를 감지하기 위한 것이지, 정밀한 시간 단언의 기준이 아닙니다.

검증 체크리스트에는 최소한 다음 항목이 있어야 합니다. 빠르게 연속 입력하면 마지막 검색어만 전달되는지, 검색창을 비우면 대기 중인 전달이 즉시 취소되는지, 취소 예외가 전달 경로로 이어지지 않는지, 이전 세대의 콜백이 버려지는지, UI가 해제된 뒤에는 더 이상 갱신되지 않는지, 여러 차례 연속 실행해도 결과가 일관되는지 확인합니다. 서비스에서 같은 검색어의 중복 제거가 필요하다면 별도의 중복 제거 계층을 추가하고, 검색창을 비운 뒤 같은 검색어를 다시 입력했을 때 재검색을 허용할지도 명확히 정해야 합니다.

커밋 버전, 도구 체인 버전, 테스트 종료 코드만 보관하면 됩니다. 사용자가 실제로 입력한 검색어를 기록할 필요는 없습니다. 최종 결과물은 “반 초쯤 기다리면 대체로 통과하는” 스크립트가 아니라, 어떤 시간 규칙이 깨졌는지 정확히 짚어 주는 단언의 모음입니다.

자주 묻는 질문

가상 시간 테스트로 실제 타이머 테스트를 모두 대체할 수 있나요?

아니요. 상태 머신의 규칙은 검증할 수 있지만 단조 시계 사용, 타이머 취소, 지정된 실행기에서의 결과 전달은 별도 통합 테스트로 확인해야 합니다.

검색창을 비우면 이미 시작된 요청도 취소되나요?

아니요. 이 상태 머신은 아직 전달하지 않은 검색어만 취소합니다. 진행 중인 요청은 네트워크 계층에서 취소하고 요청 세대를 비교해 오래된 응답의 반영을 막아야 합니다.

프로젝트를 위한 전용 Mac을 확보하세요

가상 머신이 아닌 Mac mini 물리 노드입니다. 일·주·월·분기 단위로 대여하고, 용도와 사양을 선택한 뒤 콘솔에서 실시간 제공 상태를 확인하세요.

모델 및 대여 기간 선택 전체 요금 보기