SSD Nodes Learn 🎉 VPS $5.50/월부터
가이드 Matt Connor작성자 Matt Connor

자체 호스팅 API 모킹 및 테스트 환경 구축 방법

WireMock과 Hurl을 사용하여 API 모킹과 테스트를 독립적으로 실행하는 방법을 설명합니다. 외부 서비스 의존성 문제를 해결하고, 보안 데이터 유출 방지를 위해 사설 네트워크 내에서 직접 테스트 러너를 운영하는 실무적인 가이드를 제공합니다.

하나의 저장소를 공유하는 두 가지 작업

자체 호스팅 API 모킹과 테스트는 서로 다른 작업이며, 이를 하나로 취급하면 일주일의 시간을 낭비하게 됩니다. 모의 서버(mock server)는 CI에서 호출할 수 없는 의존성(결제 제공업체, 파트너 API, 속도 제한이 걸린 업스트림, 다른 팀이 아직 출시하지 않은 서비스 등)을 대신합니다. 반면 API 테스트 러너는 고정된 순서로 자신의 엔드포인트를 호출하고 응답을 검증하며, 이전 응답의 값을 다음 요청으로 전달합니다.

이 두 작업은 겹치지 않습니다. 모의 서버는 성공이나 실패를 보고하지 않습니다. 테스트 러너는 카드 결제가 거부되었을 때 결제 제공업체가 무엇을 반환하는지에 대해 관여하지 않습니다. 이미 서버를 운영 중인 대부분의 팀은 동일한 Docker Compose 파일로 시작하고 동일한 pull request에서 검토되는 두 가지 도구를 각각 실행하는 방식으로 마무리합니다.

API 모킹과 테스트를 직접 호스팅하는 이유는 무엇입니까?

테스트 픽스처는 운영 환경의 데이터와 동일한 형태를 갖습니다. API 테스트의 요청 본문은 이름을 변경한 실제 고객 레코드이거나, 아무도 확인하지 않아 이름이 그대로 남은 데이터일 수 있습니다. 기록된 스텁은 더 위험합니다. 프록시 기록 방식은 업스트림이 실제로 반환한 모든 내용을 저장하므로, 기록을 통해 생성된 스텁 디렉터리에는 누군가 모든 파일을 일일이 검토하기 전까지 유효한 토큰과 고객 이메일 주소가 그대로 남아 있게 됩니다. 호스팅된 서비스에서 이러한 데이터가 유출되면 타인의 보안 사고로 이어지며, 이는 곧 귀하의 정보 공개 의무가 됩니다.

두 번째 이유는 도달 가능성입니다. 사설 주소에 바인딩된 서비스는 호스팅된 러너에서 접근할 수 없으므로 테스트를 전혀 실행할 수 없습니다. 모든 우회 방법에는 비용이 따릅니다. 테스트를 위해 API를 인터넷에 공개하는 것은 해당 API를 비공개로 유지했던 이유를 무색하게 만듭니다. 터널링이나 공개 스테이징 복사본을 운영하는 것은 또 다른 유지보수 시스템을 만드는 일이며, 스테이징 복사본은 릴리스가 거듭될수록 운영 환경과 차이가 발생합니다. 동일한 사설 네트워크 내의 러너는 서비스를 직접 호출하므로 이러한 복잡한 과정이 필요 없으며, 이것이 바로 자체 호스팅 GitHub Actions 러너를 사용하는 실질적인 이유입니다.

어떤 셀프 호스팅 모의 서버를 실행해야 할까요?

이 도구들은 모두 사용자가 소유한 서버에서 컨테이너로 실행됩니다. 가장 중요한 질문은 각 도구가 무엇을 진실의 원천(source of truth)으로 삼느냐는 점입니다. 이 기준에 따라 컨테이너를 재구축할 때 아무런 비용이 들지 않을 수도 있고, 오후 내내 시간을 허비해야 할 수도 있습니다.

  • WireMock은 모든 스텁을 mappings/ 디렉터리에 JSON 파일로 저장하며, 대용량 응답 본문은 __files/에 보관합니다. 이미지는 wiremock/wiremock이며 컨테이너 내부의 루트 디렉터리는 /home/wiremock입니다. 또한 레코딩 프록시로도 동작합니다. 디스크에 파일이 존재하므로 다른 코드와 마찬가지로 git을 통해 모의 서버 설정을 관리할 수 있습니다.
  • Mockoon CLI는 전체 모의 API를 하나의 JSON 데이터 파일로 관리합니다. npm install -g @mockoon/cli로 설치하고 mockoon-cli start --data ./data-file.json으로 시작하거나, 해당 파일을 바인드 마운트하여 mockoon/cli 이미지를 실행하십시오. 데스크톱 앱도 동일한 파일을 수정하므로 UI에서 설계하고 결과를 커밋하는 과정이 서로 호환됩니다.
  • MockServermockserver/mockserver 이미지에서 실행되며 1080 포트에서 대기합니다. 기대값(Expectation)은 자체 REST API를 통해 전달되는데, 이는 테스트 코드에서 사용하기에는 편리하지만 배포 환경에서는 위험할 수 있습니다. HTTP 호출로 생성된 기대값은 컨테이너가 재시작되면 사라지기 때문입니다. 영구적으로 유지해야 할 스텁은 JSON 초기화 파일을 사용하십시오.
  • Prism은 별도의 스텁 파일이 아닌 OpenAPI 문서를 기반으로 모의 서버를 구축합니다. npm install -g @stoplight/prism-cli으로 설치한 뒤 prism mock openapi.yaml을 실행하십시오. 컨테이너 내부에서는 -h 0.0.0.0을 추가해야 합니다. Prism은 기본적으로 localhost에 바인딩되므로 그렇지 않으면 컨테이너 외부에서 접근할 수 없기 때문입니다.
  • Microcks는 대규모 옵션입니다. OpenAPI 문서와 Postman 컬렉션을 가져와 모의 서버로 제공하고 계약 테스트를 수행하는 웹 UI를 갖추고 있습니다. 전체 설치를 위해서는 MongoDB와 Keycloak이 필요하며, 비동기 기능을 위해 Kafka도 필요합니다. 올인원 microcks-uber 이미지는 인메모리 MongoDB를 포함하고 있는데, 프로젝트 문서에서는 이를 일회성 용도로 적합하다고 명시하고 있습니다. 따라서 UI에서 생성한 모든 것은 일회용으로 간주하고, 원본 아티팩트는 git에 보관하십시오.

어떤 셀프 호스팅 API 테스트 러너를 사용해야 합니까?

여기서 수행할 작업은 인증, 주문 생성, 주문 조회, 상태 변경 확인으로 이어지는 일련의 과정입니다. 이 과정에서는 한 응답에서 추출한 값을 다음 요청에 사용해야 합니다. 호출 간에 상태를 유지할 수 없는 도구는 API 테스트가 아니라 단순 상태 확인(health check) 도구입니다.

  • Hurl은 단일 바이너리에서 HTTP 요청을 담은 일반 텍스트 파일을 실행합니다. [Captures] 섹션은 응답에서 값을 추출하고, [Asserts] 섹션은 해당 값을 검증하며, --test는 이를 요약 정보와 종료 코드를 제공하는 테스트 러너로 변환합니다. 2026년 8월 기준으로 버전 8.0.1이 최신입니다.
  • Bruno CLI.bru 파일이 담긴 폴더를 실행합니다. npm install -g @usebruno/cli로 설치한 후 bru run folder --env Local --reporter-junit results.xml로 실행합니다. 컬렉션 형식이 디렉터리 내의 텍스트 파일로 설계되어 있어, 코드 리뷰 시 변경 사항을 읽기 쉽습니다.
  • Newman은 Postman 외부에서 Postman 컬렉션을 실행합니다. npm install -g newman으로 설치하고 newman run collection.json -r cli,junit --reporter-junit-export results.xml로 실행합니다. 문제는 형식입니다. 컬렉션이 하나의 내보낸 JSON 블롭(blob) 형태이므로, 편집은 Postman에서 수행해야 하며 git에 저장된 파일은 시간이 지나면 최신 상태를 유지하기 어렵습니다.
  • Schemathesis는 다른 방식의 검사 도구입니다. OpenAPI 스키마를 읽고 스키마상 불가능하다고 정의된 응답을 유도하는 테스트 케이스를 생성합니다: uvx schemathesis run https://your.api/openapi.json. 이 도구는 시스템 충돌이나 계약 위반을 찾아내지만, 비즈니스 규칙은 알지 못하므로 스크립트 기반 테스트 제품군을 대체하기보다는 보조적인 용도로 사용해야 합니다.
  • Hoppscotch 셀프 호스팅 버전은 웹 UI를 제공하며, Postgres 인스턴스가 필요합니다. 설치 전 이 트레이드오프를 이해해야 합니다. 컬렉션이 저장소(repository)가 아닌 데이터베이스에 저장되기 때문입니다.

피해야 할 도구도 있습니다. Step CI는 여전히 도구 요약 정보에 등장하며 YAML 워크플로우 형식이 읽기 편하지만, 해당 저장소의 마지막 커밋은 2024년 8월입니다. CI와 API 사이에서 동작하는 프로그램에 유지보수되지 않는 코드를 사용하는 것은 위험합니다.

모의 서버를 방화벽 뒤에 배치하기

아래 설정은 결제 제공자를 대신하여 WireMock을 실행합니다. Compose 파일 형식이 생소하다면 Docker Compose on a VPS에서 이 섹션이 가정하는 생명주기 명령어를 확인하십시오.

services:
  mock-payments:
    image: wiremock/wiremock:3.13.2
    command: ["--verbose"]
    volumes:
      - ./mocks/payments:/home/wiremock
    ports:
      - "127.0.0.1:8080:8080"
    restart: unless-stopped

포트 앞의 127.0.0.1: 접두사가 핵심입니다. 단순히 8080:8080으로 설정하면 공인 IP를 포함한 모든 인터페이스에 모의 서버가 노출됩니다. ufw에서 해당 포트를 차단하더라도 Docker가 DOCKER iptables 체인에 자체 규칙을 작성하며, 이 규칙이 ufw의 INPUT 규칙보다 먼저 평가되기 때문에 외부에서 접근이 가능합니다. 루프백 주소나 사설 인터페이스 주소에 바인딩하면 커널이 외부 연결을 수락하지 않습니다.

테스트 대상 서비스는 이 모의 서버를 가리키도록 설정합니다. 서비스가 동일한 Compose 프로젝트 내에서 실행될 때 모의 서버의 기본 URL은 http://mock-payments:8080입니다. Compose가 자체 네트워크에서 서비스 이름을 해석하기 때문입니다. 서비스가 호스트에서 실행될 때는 http://127.0.0.1:8080를 사용합니다. 이 값은 코드에 직접 넣지 말고 환경 변수로 설정하십시오. 그렇지 않으면 테스트용 URL이 운영 환경으로 배포됩니다.

스텁은 ./mocks/payments/mappings/ 디렉터리에 JSON 파일 단위로 저장합니다.

{
  "request": {
    "method": "POST",
    "urlPath": "/v1/charges",
    "bodyPatterns": [{ "matchesJsonPath": "$.amount" }]
  },
  "response": {
    "status": 201,
    "headers": { "Content-Type": "application/json" },
    "jsonBody": { "id": "ch_test_001", "status": "succeeded", "amount": 4200 }
  }
}

서버를 시작한 뒤 실제 로드된 내용을 확인합니다.

docker compose up -d --wait mock-payments
curl -fsS http://127.0.0.1:8080/__admin/mappings

--wait은 컨테이너가 정상 상태(healthy)라고 보고할 때까지 대기합니다. WireMock 이미지가 /__admin/health 엔드포인트에 대해 HEALTHCHECK을 수행하도록 설정되어 있어 가능합니다. mappings 호출은 서버가 읽어 들인 모든 스텁을 나열합니다. 작성한 스텁이 목록에 없다면 로드되지 않은 것입니다. 파일이 마운트된 루트가 아닌 mappings/ 아래에 위치하는지, JSON 형식이 올바른지 확인하십시오.

요청이 들어왔으나 일치하는 스텁이 없으면 WireMock은 404을 응답하며, 응답 본문은 Request was not matched로 시작합니다. 그 뒤에는 가장 유사한 스텁과의 차이점(diff)이 표시됩니다. 무엇을 수정하기 전에 이 차이점을 먼저 읽어 보십시오. 차이가 발생하는 정확한 필드명을 알려줍니다. 보통 스텁에는 /v1/charges라고 되어 있는데 실제 요청 경로는 /v1/charge인 경우가 많습니다.

테스트를 호출 간 상태가 유지되는 시퀀스로 작성하기

Hurl 파일은 일반 텍스트입니다. 프로젝트의 releases에서 deb 파일을 설치하십시오.

VERSION=8.0.1
curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl_${VERSION}_amd64.deb
sudo apt update && sudo apt install ./hurl_${VERSION}_amd64.deb

자체 API를 모의 서버(mock)에 대해 검증하는 테스트 스위트는 tests/checkout.hurl에 있습니다.

POST {{base_url}}/orders
Content-Type: application/json
{
  "sku": "ssd-1tb",
  "amount": 4200
}
HTTP 201
[Captures]
order_id: jsonpath "$['id']"

GET {{base_url}}/orders/{{order_id}}
HTTP 200
[Asserts]
jsonpath "$.status" == "paid"
jsonpath "$.charge_id" == "ch_test_001"

[Captures] 블록은 이 테스트를 단순히 연관 없는 두 개의 요청이 아닌 API 테스트로 만드는 핵심입니다. order_id은 첫 번째 응답에서 읽어와 두 번째 요청의 URL에 삽입됩니다. charge_id에 대한 단언(assertion)은 이 전체 과정의 목적입니다. 이는 서비스가 결제 제공자에게 호출을 수행하고 응답을 저장했음을 증명하며, 비교 대상 값은 WireMock 스텁에 작성한 값과 일치합니다. 이제 하나의 파일로 전체 흐름의 양쪽 단계를 모두 다룰 수 있습니다.

hurl --test --variable base_url=http://127.0.0.1:3000 \
  --report-junit reports/junit.xml \
  --report-json reports/json \
  tests/

테스트가 성공하면 파일당 한 줄의 결과와 요약이 출력됩니다.

tests/checkout.hurl: Success (2 request(s) in 61 ms)
Executed files:    1
Executed requests: 2 (30.1/s)
Succeeded files:   1 (100.0%)
Failed files:      0 (0.0%)
Duration:          64 ms

실패 시에는 파일명과 줄 번호가 포함된 error: Assert failure가 출력되며, 실제 값과 기대 값이 비교되어 나타납니다. 또한 hurl은 0이 아닌 종료 코드를 반환하여 CI를 중단시킵니다. 만약 status에서 기대했던 paid 대신 pending가 읽혔다면, 서비스가 모의 서버의 응답을 처리하지 못한 것입니다. 다음으로 확인할 사항은 /__admin/requests의 WireMock 요청 저널이며, 이를 통해 호출이 모의 서버에 도달했는지 여부를 확인할 수 있습니다.

자체 CI 러너에서 테스트 스위트 실행하기

동일한 서버에 러너를 등록하면 워크플로가 간결해집니다. 러너는 호스트에서 실행되는 일반 프로세스이므로, 해당 호스트에 dockerhurl이 설치되어 있어야 합니다. 호스팅된 이미지로부터 상속되는 설정은 없습니다.

name: api-tests
on: [push]
jobs:
  hurl:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4
      - name: Start the mock
        run: docker compose up -d --wait mock-payments
      - name: Run the suite
        run: hurl --test --variable base_url=http://127.0.0.1:3000 --report-junit reports/junit.xml tests/
      - name: Archive the reports
        if: always()
        run: install -d /srv/api-tests/reports/$GITHUB_SHA && cp -r reports/. /srv/api-tests/reports/$GITHUB_SHA/
      - name: Stop the mock
        if: always()
        run: docker compose down

아카이브 단계에서 if: always() 설정은 중요합니다. 이 설정이 없으면 테스트가 실패할 경우 복사 과정이 생략되어, 확인이 필요한 보고서를 잃게 됩니다. 또한 러너는 다음 작업을 시작하기 전에 워크스페이스를 정리하므로, 보고서가 삭제되지 않도록 워크스페이스 외부 경로에 복사해야 합니다.

마지막 실행 결과뿐만 아니라 전체 기록을 유지하십시오

커밋당 하나씩 생성되는 JUnit XML 파일은 테스트 통과 여부라는 단 하나의 질문에만 답합니다. 파일을 열어보지 않으면 특정 엔드포인트의 응답 속도가 언제부터 느려졌는지 알 수 없습니다. 추세를 파악하려면 실행할 때마다 한 행씩 동일한 서버의 소규모 데이터베이스에 추가하십시오. 커밋 SHA, 파일 이름, 통과 횟수, 실패 횟수, 소요 시간을 담은 테이블 하나면 충분하며, VPS 환경의 SQLite는 이를 저장하기에 적합한 장소입니다. 파일 하나로 구성되어 별도의 서버 프로세스가 필요 없으며, 기존에 수행 중인 백업에 전체 기록이 함께 포함되기 때문입니다. JUnit XML보다는 기계 판독이 가능한 형식인 Hurl의 --report-json 출력을 파싱하여 사용하십시오.

컨테이너 재빌드 시 유지해야 할 항목

모의 정의(mock definition)와 테스트 스위트는 소스 코드입니다. 이들은 설명하는 서비스와 함께 저장소에 있어야 하며, 엔드포인트를 변경하는 동일한 풀 리퀘스트에서 함께 수정되어야 합니다. 웹 UI에서 편집한 스텁이나 런타임에 REST API를 통해 MockServer로 푸시한 기대값은 해당 컨테이너의 메모리나 도구의 데이터베이스에만 존재합니다. docker compose down를 실행하면 데이터는 사라지며, 테스트가 잘못된 이유로 통과하기 시작할 때까지 아무도 이를 알아차리지 못합니다. 저장소를 자체 하드웨어에서 운영하는 경우, 자체 호스팅 git 서버를 사용하면 픽스처와 서비스를 하나의 신뢰 경계 내에 유지할 수 있습니다.

다음은 실무 규칙입니다. 이미지 태그를 고정하십시오. latest은 저장소의 변경 없이도 모의 객체가 요청을 일치시키는 방식을 바꿀 수 있으며, 이러한 실패는 원인을 파악하기 매우 어렵습니다. 도구가 스텁 디렉터리에 쓸 필요가 없다면 읽기 전용으로 마운트하십시오. 모의 객체의 스텁을 명명된 Docker 볼륨에 넣지 마십시오. 볼륨이 진실의 원천(source of truth)이 되면 git에 있는 복사본은 조용히 잘못된 상태가 됩니다.

한 가지 더 주의할 점은 많은 사람이 실수하는 부분입니다. 프록시를 통해 실제 트래픽을 기록하여 스텁을 생성하는 경우, 커밋하기 전에 생성된 모든 파일을 읽어보십시오. 기록 파일에는 업스트림이 반환한 내용이 그대로 담겨 있으며, 여기에는 베어러 토큰과 고객 이메일 주소가 포함될 수 있습니다. 이를 커밋하면 git은 삭제된 콘텐츠도 기록에 보관하므로 해당 정보가 저장소에 영구적으로 남게 됩니다.

FAQ

API mock server와 API test runner의 차이점은 무엇입니까?

mock server는 요청에 응답합니다. CI에서 호출할 수 없는 의존성을 대신하며, 성공이나 실패를 보고하지 않습니다. API test runner는 사용자의 서비스에 요청을 보내고, 응답을 검증하며, 이전 호출의 값을 다음 호출로 전달하고, 검증이 실패하면 0이 아닌 종료 코드를 반환합니다. 두 도구는 서로 다른 문제를 해결하며, 일반적인 구성에서는 두 가지를 동시에 실행합니다. 즉, runner는 사용자의 서비스를 호출하고, 사용자의 서비스는 mock을 호출합니다.

호스팅된 CI runner에서 내부 API를 테스트할 수 있습니까?

외부에 노출하지 않으면 불가능합니다. 호스팅된 runner는 네트워크 외부에 위치하므로 사설 주소에 바인딩된 서비스에 접근할 수 없습니다. API를 공개하거나, 터널을 실행하거나, 공개 스테이징 복사본을 유지하는 방법이 있지만, 각 방법은 장애나 정보 유출의 위험이 있는 시스템을 추가하게 됩니다. 동일한 사설 네트워크에 있는 runner는 서비스를 직접 호출할 수 있으며, 이것이 팀이 자체 호스팅을 선택하는 주된 실무적 이유입니다.

mock stub과 API test suite는 어디에 저장해야 합니까?

해당 서비스와 함께 git 저장소에 두어야 합니다. WireMock의 mappings/ 디렉터리, Mockoon의 데이터 파일, Hurl 파일, Bruno의 .bru 폴더와 같이 정의를 파일로 저장하는 도구는 코드 리뷰를 가능하게 하며 컨테이너 재빌드 비용을 발생시키지 않습니다. 데이터베이스나 웹 UI에 정의를 저장하는 도구는 백업 계획과 내보내기 단계가 필요하며, 사람들은 컨테이너가 사라지고 나서야 내보내기를 잊었다는 사실을 깨닫습니다.

stub이 올바른데도 mock이 404를 반환하는 이유는 무엇입니까?

WireMock은 정확히 일치하는 요청에 대해서만 stub을 제공합니다. 일치하지 않는 요청은 Request was not matched로 시작하는 본문과 함께 404 응답을 받으며, 그 뒤에 가장 유사한 stub과의 차이점이 표시됩니다. 이 차이점은 일치하지 않는 필드를 명시합니다. 흔한 원인으로는 경로 끝의 슬래시, stub은 요구하지만 클라이언트가 보내지 않은 Content-Type 헤더, 변수 세그먼트에 urlPathPattern이 필요한데 urlPath을 사용한 경우, 페이로드와 맞지 않는 본문 매처 등이 있습니다. 요청이 mock에 도달했는지 확인하려면 먼저 /__admin/requests을 확인하십시오.

스테이징 환경이 있어도 mock이 여전히 필요합니까?

네, 두 가지 이유 때문입니다. 제어할 수 없는 업스트림의 스테이징 복사본은 여전히 다운될 수 있고 속도 제한을 걸 수 있으므로, 사용자의 코드와 무관한 이유로 테스트가 실패하게 됩니다. 또한 카드 거절이나 게이트웨이 타임아웃과 같이 테스트에 꼭 필요한 응답을 생성할 수 없습니다. mock은 이러한 응답을 로컬 네트워크 속도로 즉시 반환하므로, 샌드박스 환경에서 몇 분씩 걸리던 테스트를 몇 초 만에 끝낼 수 있게 합니다. 릴리스 전 최종 확인을 위해 스테이징 환경을 유지하고, CI에서는 mock을 사용하십시오.