초록불을 믿지 않는다 — 샌드박스 없는 금융 API를 결정론으로 검증하기

실제 주문 없이 어디까지 검증할 것인가

이 글에서 말하는 ‘샌드박스 없는’ 테스트는 증권사의 모의 주문 환경을 연결하지 않은 프로젝트의 조건이다. 3편에서 정정했듯, 이번 검수로 토스증권의 샌드박스 제공 여부를 확정하지는 못했다. 공식 모의 환경을 사용하지 않더라도 로컬에서 주문 경로를 시험할 방법은 있다.

나는 주문 요청이 만들어지는 과정과 상태 저장을 실제 구현으로 검증하고 싶었다. 엔진 테스트에서 게이트웨이까지 대역으로 바꾸면 규칙 판단은 확인할 수 있지만, 게이트웨이의 응답 변환과 주문 직렬화는 그 테스트를 통과하지 않는다. 이를 보완하려고 실제 게이트웨이까지 연결한 통합 테스트를 추가했다.

대역을 쓰는 것 자체가 잘못은 아니다. 단위 테스트에서는 특정 판단을 격리할 때 유용하다. 중요한 것은 대역이 대신한 부분을 따로 검증했는지, 그 경계에서 주고받는 데이터를 실제 구현으로도 확인했는지다.

실제 구현을 연결하고 환경을 통제한다

주문 통합 테스트에서는 케이지, 게이트웨이, 설정 저장소를 실제 코드로 구성했다. 상태 저장소와 감사 로그는 임시 경로의 SQLite에 쓴다. 매매 주기 통합 테스트에는 규칙 저장소, 엔진, 신호 제공자, 손절 감시기도 연결한다. 여기서 ‘실제’란 운영 계좌나 운영 데이터가 아니라, 운영에 사용하는 구현을 테스트용 입력과 저장 공간으로 실행한다는 뜻이다.

외부 HTTP 통신은 FakeTransport로 대체한다. 하지만 대역이 네트워크와 언어 모델(LLM) 두 곳뿐이라는 원문의 표현은 지나치게 넓었다. 테스트 시각, 재시도 대기 함수, 인증 정보 공급자도 주입하거나 통제한다. 단위 테스트에서는 FakeGateway 같은 대역을 쓰고, 조언자 통합 테스트에서는 뉴스 공급자와 추론기를 대체한다. 실제로 연결하는 구성요소는 테스트 목적에 따라 다르다.

SQLite와 파일 연산도 그 자체로 항상 결정론적인 것은 아니다. 기존 데이터, 실행 순서, 시각, 동시 접근의 영향을 받을 수 있다. 임시 저장소를 만들고 시계·대기 함수를 주입하는 이유는 이런 조건을 통제하기 위해서다. 외부 호출이 없다는 사실만으로 모든 환경에서 같은 결과를 보장하지는 않는다.

도표 크게 보기

응답을 돌려주고 요청을 기록하는 대역

FakeTransport는 미리 준비한 응답이나 예외를 순서대로 돌려주고, 게이트웨이가 만든 요청을 기록한다. 다음은 테스트 코드에서 타입 표기와 보조 메서드를 생략한 예제다.

class FakeTransport:
    def __init__(self, responses):
        self.responses = list(responses)
        self.requests = []

    def send(self, request):
        self.requests.append(request)
        if not self.responses:
            raise AssertionError("준비한 응답보다 요청이 많다")
        response = self.responses.pop(0)
        if isinstance(response, Exception):
            raise response
        return response

타임아웃은 예외 객체를 큐에 넣어 재현한다. 429는 상태 코드와 Retry-After 헤더를 가진 응답 객체로 넣는다. 둘을 모두 예외로 처리하는 것은 아니다. 기록한 요청에서는 HTTP 메서드, 경로, 주문 키, 수량과 가격을 확인할 수 있다. 예상보다 많은 호출이 생기면 빈 응답 큐도 이를 드러낸다.

이름은 FakeTransport지만 역할은 준비된 응답을 주는 스텁(stub)과 호출을 기록하는 스파이(spy)에 가깝다. 대역의 이름보다 어떤 입력을 고정하고 어떤 결과를 관찰하는지가 중요하다. 테스트 대역의 구분

이 대역 아래에서 네트워크 요청은 발생하지 않는다. 그 위에서는 실제 게이트웨이가 응답을 해석하고 주문 요청을 만든다. 다음은 기존 픽스처의 배선을 필요한 인자만 남겨 재구성한 예제다. 클래스 정의와 임시 저장소·가상 인증 정보·한도 생성은 생략했다.

def wire_order_test(state, audit, secrets, transport, limits, sleep):
    gateway = BrokerGateway(
        transport=transport,
        secret_store=secrets,
        sleep=sleep,
        max_retries=2,
    )
    cage = SafetyCage(
        settings=SettingsStore(limits),
        state=state,
        audit=audit,
        gateway=gateway,
    )
    return cage, gateway

주문 제출 뒤에는 반환값만 보지 않는다. 전송 대역에 기록된 POST 횟수와 본문, 상태 저장소의 행, 감사 기록을 함께 확인한다. 같은 키를 두 번 제출한 테스트라면 두 번째 판정이 중복 거부인지, POST는 한 번뿐인지 대조한다. 감사 저장 실패 테스트에서는 임시 감사 테이블을 제거해 실제 저장 오류를 일으키고 주문 호출이 없는지 확인한다.

응답 형태를 닮았다고 계약까지 맞는 것은 아니다

시세·캔들 통합 테스트는 result 안의 lastPrice, closePrice 같은 외부 필드를 사용한다. 그래야 게이트웨이의 변환 코드가 테스트 경로에서 실행된다. 이미 변환된 close 값을 대역에서 반환하는 단위 테스트만으로는 closePrice를 잘못 읽는 결함을 잡을 수 없다.

그렇다고 준비한 응답이 실제 API 계약 전체와 같다는 뜻은 아니다. 3편에서 확인한 캔들 정렬 문제가 그 사례다. 현재 공식 스펙은 최신순이다. 통합 테스트도 최신순 시각을 붙이지만, 배열 앞에서 뒤로 감소하는 종가를 시간순 하락으로 취급했다. 시각의 방향을 확인하지 않은 채 지표 값과 주문 여부를 단언하니, 필드명 변환 테스트가 통과해도 계산기의 순서 오류는 남았다.

따라서 응답의 필드명, 자료형, 정렬, 누락 값, 오류 응답을 각각 확인해야 한다. 테스트 대역도 공식 명세나 확인된 응답과 주기적으로 대조할 대상이다. 실서버에 접속하지 않는 통합 테스트와 제공자 계약 검증은 서로 보완한다. 계약 테스트의 역할

결함을 주입해 어떤 실패를 잡는지 확인한다

변이 테스트는 코드를 일부 바꾼 뒤 테스트가 그 변화를 감지하는지 보는 방법이다. 이번 검수에서는 필요한 소스를 임시 폴더로 복사하고 외부 통신을 막은 상태에서 아래 실험을 재실행했다. 각 실험 뒤에는 파일을 원래 바이트로 복원하고 해당 테스트의 통과를 확인했다.

주입한 변경 관찰한 결과
SHADOW·DRY의 실행 차단 분기 제거 해당 모드의 판정 검사 2건 실패
캔들 변환에서 closePrice 대신 잘못된 키 사용 진입 주문이 만들어지지 않아 주문 횟수 검사 실패
주문 호출 예외를 처리하지 않고 다시 발생시킴 인증 정보 누락·429 재시도 소진 테스트 2건 실패
케이지의 중복 사전 조회만 생략 같은 규칙을 두 주기 실행하는 통합 테스트는 통과
중복 조회·예약·감사 중복 차단·상태 전이 검사를 함께 무력화 주문 POST 시도가 2회가 되어 횟수 검사 실패

원문은 세 번째 실험을 타임아웃 복구 테스트라고 설명했지만, 실제 대상은 인증 정보 누락과 재시도 소진에 따른 예외 처리였다. 타임아웃 복구는 별도 경로다. 또한 마지막 행에서 확인한 것은 대역에 기록된 POST 시도 두 번이며, 증권사에서 두 번 접수되거나 체결됐다는 뜻은 아니다.

중복 사전 조회만 제거했을 때는 뒤의 예약 단계가 같은 키를 막았다. 이는 해당 시나리오에서 다른 방어가 작동했다는 근거다. 네 가지 검사를 함께 무력화한 결과와 합쳐도 모든 장애 조합에서 중복 주문이 없다는 증명이 되지는 않는다.

변이가 살아남았다고 테스트가 무의미한 것도 아니다. 다른 방어가 결과를 유지했을 수도 있고, 바뀐 코드가 실행되지 않았거나 단언이 부족할 수도 있다. 동작을 바꾸지 않는 동등 변이도 있다. 실패한 경우에도 문법 오류 때문인지, 의도한 동작 위반 때문인지 원인을 읽어야 한다. 변이 결과의 구분, 동등 변이

수치가 가리키는 범위

당시 테스트 문서에는 2026-06-26 전체 실행 결과로 447 passed가 남아 있다. 이는 위키·조언자 등 다른 영역도 포함한 수치이며, 447개 모두가 실제 저장소와 게이트웨이를 연결한 통합 테스트라는 뜻은 아니다.

이번 검수에서는 현재 확인 가능한 소스의 주문·신호·엔진·손절 관련 테스트 241개를 실행했다. 모두 통과했고, 위 변이를 복원한 뒤 같은 241개를 다시 통과했다. 최초 발행 당시 커밋을 재현하거나 과거의 447개 전체를 다시 실행한 결과와는 구분한다.

이 검증으로 확인한 것은 준비한 입력에 대한 로컬 코드의 처리다. 실제 인증·접속, 제공자의 요청 제한 정책, 주문 접수와 부분 체결의 시점은 검증하지 않았다. 조회 계약은 읽기 전용 연동으로 확인할 수 있는 부분도 있고, 주문 계약은 주문 가능한 환경에서 별도 검증이 필요하다. 언어 모델의 판단 품질도 고정된 추론 결과를 반환하는 대역으로 입증할 수 없다.

학습

나는 단위 테스트에서 판단을 좁혀 확인하고, 통합 테스트에서 실제 구현 사이의 연결을 확인하는 방식을 택했다. 대역이 많고 적은 것보다 검증하려는 코드가 테스트 경로에 포함되는지가 중요했다. 그 경로에 게이트웨이를 넣자 응답 변환과 주문 요청, 저장된 상태를 함께 대조할 수 있었다.

결함 주입은 그 테스트가 무엇을 감지하는지 보여줬다. 하지만 캔들 순서 문제처럼 대역의 전제 자체가 틀리면 통과가 이어질 수 있다. 테스트 수와 함께 실행한 범위, 대역의 계약, 잡아낸 결함과 남은 한계를 기록해야 결과를 다음 판단의 근거로 쓸 수 있다.

← 전체 글 목록