API 테스트와 계약 검증이 필요한 이유
API를 개발할 때 단순히 요청을 보내고 정상적인 응답을 받는 것만으로는 충분하지 않다.
개발자가 의도한 데이터가 반환되더라도 HTTP 상태 코드가 잘못되었을 수 있고, 응답 필드가 누락되었을 수 있으며, API 명세와 실제 구현이 서로 다를 수도 있다.
예를 들어 닉네임 중복 검사 API를 개발한다고 가정하자.
클라이언트는 닉네임이 중복되었을 때 409 Conflict를 기대하지만, 서버는 400 Bad Request를 반환한다. 서버 내부에서는 중복 검사가 정상적으로 동작하더라도 클라이언트는 계약과 다른 응답을 받게 된다.
이러한 문제를 방지하려면 API 테스트와 계약 검증을 수행해야 한다.
API 테스트 및 계약 검증은 다음 세 가지 관점으로 구분할 수 있다.
- API 기능 테스트: 요청을 보냈을 때 기능이 올바르게 동작하는가?
- API 계약 테스트: 요청과 응답이 약속된 형식과 규칙을 준수하는가?
- 명세 일치 검증: OpenAPI 등의 명세와 실제 구현이 일치하는가?
이 세 가지는 서로 연관되어 있지만 검증하는 대상은 다르다.
Postman과 Bruno
API를 개발할 때는 HTTP 요청을 직접 생성하고 응답을 확인할 수 있는 도구가 필요하다. 대표적으로 Postman과 Bruno가 있다.
Postman
Postman은 HTTP API를 테스트하고 요청을 관리할 수 있는 API 개발 도구다.
주요 기능은 다음과 같다.
- GET, POST, PUT, PATCH, DELETE 요청 생성
- HTTP 헤더와 요청 본문 설정
- 환경변수 관리
- 응답 상태 코드와 본문 확인
- JavaScript 기반 테스트 스크립트 작성
- 요청 컬렉션 구성 및 공유
- 자동화된 API 테스트 실행
예를 들어 회원가입 API를 테스트한다면 다음과 같이 요청을 구성할 수 있다.
요청
POST /api/v1/users
Content-Type: application/json
{
"nickname": "player01",
"email": "player01@example.com",
"password": "StrongPassword123!"
}
예상 응답
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": "user-123",
"nickname": "player01",
"email": "player01@example.com"
}
Postman에서는 응답을 확인하는 데 그치지 않고 상태 코드, 데이터 타입, 필수 필드 등을 자동으로 검증할 수 있다.
pm.test("상태 코드는 201이다", () => {
pm.response.to.have.status(201);
});
pm.test("응답에 필수 필드가 존재한다", () => {
const body = pm.response.json();
pm.expect(body).to.have.property("id");
pm.expect(body).to.have.property("nickname");
pm.expect(body).to.have.property("email");
});
pm.test("닉네임이 문자열이다", () => {
const body = pm.response.json();
pm.expect(body.nickname).to.be.a("string");
});
이렇게 작성하면 매번 응답을 눈으로 확인하지 않아도 테스트 결과를 자동으로 판별할 수 있다.
Bruno
Bruno 역시 HTTP API를 테스트하는 도구지만, 요청 데이터를 파일로 관리하는 방식에 초점을 맞춘다.
주요 특징은 다음과 같다.
- API 요청을 컬렉션으로 구성
- 요청 데이터를 파일 형태로 저장
- Git을 이용한 버전 관리
- 환경별 변수 설정
- JavaScript 기반 테스트 작성
- CLI를 통한 자동화 테스트 실행
Bruno는 API 요청과 테스트 정의를 저장소에서 함께 관리하고 싶은 프로젝트에 적합하다.
예를 들어 프로젝트 저장소를 다음과 같이 구성할 수 있다.
project/
├── src/
│ ├── controllers/
│ ├── services/
│ └── repositories/
├── docs/
│ └── openapi.yaml
└── tests/
└── api/
├── collection.bru
├── environments/
│ ├── local.bru
│ ├── staging.bru
│ └── production.bru
└── users/
├── create-user.bru
├── get-user.bru
└── update-user.bru
실제 컬렉션과 환경 파일의 구조는 Bruno의 설정에 따라 달라질 수 있지만, 핵심은 테스트 요청을 코드와 함께 버전 관리할 수 있다는 점이다.
Postman과 Bruno의 선택 기준
| 비교 항목 | Postman | Bruno |
|---|---|---|
| 요청 생성 및 응답 확인 | 지원 | 지원 |
| 자동화 테스트 | 지원 | 지원 |
| 환경변수 | 지원 | 지원 |
| Git 기반 요청 관리 | 지원 | 파일 중심 워크플로에 적합 |
| 협업 | 팀 협업 기능 제공 | 저장소 중심 협업에 적합 |
| CLI 자동화 | 지원 | 지원 |
두 도구 모두 API 테스트에 사용할 수 있다. 팀에서 이미 Postman을 사용한다면 기존 컬렉션과 협업 환경을 활용하면 된다. 요청 정의를 Git 저장소에서 코드와 함께 관리하는 것이 중요하다면 Bruno도 좋은 선택이다.
중요한 것은 특정 도구를 선택하는 것보다 테스트를 반복 실행할 수 있고, 테스트 결과를 자동으로 판별할 수 있는 구조를 만드는 것이다.
환경변수와 테스트 환경
API를 테스트하다 보면 개발 환경, 테스트 환경, 스테이징 환경 등 서로 다른 서버에 요청을 보내야 한다.
이때 서버 주소나 인증 토큰을 요청마다 직접 입력하면 환경이 바뀔 때마다 여러 요청을 수정해야 한다.
환경변수를 사용하면 이러한 문제를 해결할 수 있다.
환경변수란?
환경변수는 실행 환경에 따라 달라지는 값을 별도로 관리하는 방식이다.
예를 들어 다음과 같은 값을 관리할 수 있다.
| 변수 | 용도 |
|---|---|
baseUrl | API 서버의 기본 주소 |
accessToken | 인증에 사용하는 액세스 토큰 |
userId | 테스트 대상 사용자 ID |
testNickname | 테스트에 사용할 닉네임 |
환경에 따라 baseUrl만 변경하면 나머지 요청 정의를 재사용할 수 있다.
개발 환경
baseUrl = http://localhost:3000
테스트 환경
baseUrl = https://staging-api.example.com
요청 예시
GET {{baseUrl}}/api/v1/users/{{userId}}
Authorization: Bearer {{accessToken}}
위 요청은 환경에 따라 다른 서버로 전송되지만 요청 자체는 동일하게 유지된다.
환경변수를 사용하는 이유
환경변수를 사용하는 목적은 단순한 편의성에 그치지 않는다.
첫째, 환경별 설정을 요청 정의와 분리할 수 있다.
둘째, 동일한 테스트를 여러 환경에서 반복 실행할 수 있다.
셋째, 인증 토큰과 테스트 데이터를 중앙에서 관리할 수 있다.
넷째, 환경마다 다른 설정을 사용하더라도 테스트 코드의 중복을 줄일 수 있다.
다만 운영 환경에 실제 데이터를 변경하는 테스트를 무분별하게 실행해서는 안 된다. 테스트용 계정과 데이터를 별도로 준비하고, 파괴적인 요청은 실행 대상을 제한해야 한다.
또한 비밀번호, API 키, 실제 액세스 토큰 등의 민감한 값을 Git 저장소에 커밋해서는 안 된다.
테스트 환경을 분리하는 이유
테스트 환경은 실제 서비스에 영향을 주지 않고 API를 검증하기 위한 공간이다.
예를 들어 회원가입 API를 테스트할 때 운영 데이터베이스에 테스트 계정을 계속 생성한다면 데이터가 오염될 수 있다.
따라서 다음과 같이 환경을 분리하는 것이 좋다.
개발 환경
└── 개발용 API 서버
└── 개발용 데이터베이스
테스트 환경
└── 테스트용 API 서버
└── 테스트용 데이터베이스
스테이징 환경
└── 운영과 유사한 구성의 API 서버
└── 검증용 데이터베이스
운영 환경
└── 실제 서비스 API 서버
└── 운영 데이터베이스
환경을 분리하면 테스트 데이터가 실제 사용자 데이터에 영향을 주는 문제를 줄일 수 있다.
단, 환경을 분리하는 것만으로 안전성이 완성되는 것은 아니다. 접근 권한, 네트워크 설정, 데이터 초기화 정책, 외부 결제나 알림 서비스의 호출 여부도 함께 관리해야 한다.
API 계약 테스트란?
API 계약 테스트(API Contract Testing)는 클라이언트와 서버 사이에 합의된 요청 및 응답 규칙을 검증하는 테스트다.
여기서 계약은 API를 사용하는 측과 제공하는 측이 서로 지켜야 하는 약속을 의미한다.
일반적으로 계약에는 다음과 같은 항목이 포함된다.
- HTTP 메서드와 URL
- 요청 헤더와 인증 방식
- 요청 본문의 필드 및 데이터 타입
- 필수 필드와 선택 필드
- 응답 상태 코드
- 응답 본문의 구조
- 오류 응답 형식
- 필드의 의미와 제약 조건
예를 들어 닉네임 변경 API의 계약이 다음과 같다고 가정하자.
PATCH /api/v1/users/me/nickname
Authorization: Bearer <accessToken>
Content-Type: application/json
요청 본문:
{
"nickname": "new_player"
}
성공 응답:
HTTP/1.1 200 OK
{
"nickname": "new_player"
}
오류 응답:
HTTP/1.1 409 Conflict
{
"code": "NICKNAME_ALREADY_EXISTS",
"message": "이미 사용 중인 닉네임입니다."
}
이 계약을 기준으로 실제 서버가 올바른 상태 코드와 응답 구조를 반환하는지 검증할 수 있다.
기능 테스트와 계약 테스트의 차이
기능 테스트는 비즈니스 로직이 올바르게 동작하는지 확인한다.
예를 들어 닉네임을 변경한 후 데이터베이스에 새로운 닉네임이 저장되었는지 확인하는 것이 기능 테스트다.
반면 계약 테스트는 클라이언트와 서버 사이의 인터페이스가 합의된 규칙을 준수하는지 확인한다.
예를 들어 닉네임 중복 시 409 Conflict를 반환하고, 응답에 code와 message 필드가 존재하는지 확인하는 것이 계약 테스트다.
두 테스트는 서로 대체하는 관계가 아니다. 비즈니스 로직이 정확하더라도 응답 계약이 잘못될 수 있고, 응답 형식이 정확하더라도 데이터 저장 로직이 잘못될 수 있다.
API 계약 테스트 구현하기
Node.js와 TypeScript 기반의 API를 예시로 살펴보자.
닉네임 변경 API가 다음과 같은 규칙을 가진다고 가정한다.
| 상황 | HTTP 상태 코드 | 오류 코드 |
|---|---|---|
| 닉네임 변경 성공 | 200 | 없음 |
| 인증되지 않은 요청 | 401 | UNAUTHORIZED |
| 닉네임 형식 오류 | 400 | INVALID_NICKNAME |
| 닉네임 중복 | 409 | NICKNAME_ALREADY_EXISTS |
이 규칙을 테스트 코드로 작성하면 서버 구현이 계약을 준수하는지 반복적으로 검증할 수 있다.
정상 응답 검증
Postman 테스트 스크립트의 예시다.
pm.test("응답 상태 코드는 200이다", () => {
pm.response.to.have.status(200);
});
pm.test("응답 본문이 계약을 준수한다", () => {
const body = pm.response.json();
pm.expect(body).to.have.property("nickname");
pm.expect(body.nickname).to.be.a("string");
pm.expect(body.nickname).to.eql("new_player");
});
이 테스트는 정상 응답의 상태 코드와 필수 필드, 데이터 타입, 반환값을 검증한다.
단순히 상태 코드만 확인하는 것보다 실제 계약을 더 정확하게 검사할 수 있다.
오류 응답 검증
닉네임이 이미 사용 중일 때의 테스트는 다음과 같이 작성할 수 있다.
pm.test("닉네임 중복 시 409를 반환한다", () => {
pm.response.to.have.status(409);
});
pm.test("오류 응답 형식이 올바르다", () => {
const body = pm.response.json();
pm.expect(body.code)
.to.eql("NICKNAME_ALREADY_EXISTS");
pm.expect(body.message).to.be.a("string");
});
이 테스트를 실행하려면 요청에 사용한 닉네임이 실제로 중복된 상태여야 한다.
따라서 테스트 데이터 준비 단계에서 해당 닉네임을 미리 생성하거나, 테스트 전용 데이터를 초기화하는 절차가 필요하다.
테스트 데이터가 준비되지 않았다면 테스트 자체가 불안정해질 수 있다. 테스트의 성공 여부가 외부 상태나 실행 순서에 따라 달라지지 않도록 설계해야 한다.
입력값 검증 테스트
잘못된 닉네임이 전달되었을 때 서버가 적절한 오류를 반환하는지도 검증해야 한다.
예를 들어 닉네임에 허용되지 않는 문자가 포함된 경우 다음과 같은 테스트를 작성할 수 있다.
pm.test("잘못된 닉네임은 400을 반환한다", () => {
pm.response.to.have.status(400);
});
pm.test("오류 코드가 올바르다", () => {
const body = pm.response.json();
pm.expect(body.code).to.eql("INVALID_NICKNAME");
});
이 테스트는 입력값 검증 규칙이 서버의 외부 인터페이스에 올바르게 반영되었는지 확인한다.
단, 닉네임 중복은 입력 형식 오류와 다른 문제다. 형식이 유효한 닉네임이 이미 존재한다면 409 Conflict를 반환하는 식으로 오류의 의미를 구분하는 것이 좋다.
OpenAPI 명세와 실제 구현의 일치 여부 검증
계약 테스트에서 중요한 부분은 테스트 기준을 어디에서 가져오는가이다.
테스트 코드에 예상 상태 코드와 응답 필드를 직접 작성할 수 있지만, 명세와 테스트 코드에 동일한 내용을 따로 작성하면 두 곳의 정의가 서로 달라질 위험이 있다.
예를 들어 OpenAPI 명세에는 닉네임 중복 시 409를 반환한다고 작성했지만, 테스트 코드에는 400을 기대하도록 작성했다면 테스트 자체가 잘못된 계약을 검증하게 된다.
이를 방지하려면 OpenAPI 명세를 계약의 기준으로 삼고, 테스트에서 명세와 실제 응답을 비교하는 구조를 고려할 수 있다.
OpenAPI 명세 예시
openapi: 3.0.3
info:
title: User API
version: 1.0.0
paths:
/api/v1/users/me/nickname:
patch:
summary: 닉네임 변경
operationId: updateMyNickname
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- nickname
properties:
nickname:
type: string
minLength: 2
maxLength: 16
responses:
"200":
description: 닉네임 변경 성공
content:
application/json:
schema:
$ref: "#/components/schemas/NicknameResponse"
"400":
description: 닉네임 형식 오류
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"401":
description: 인증 실패
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: 닉네임 중복
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
NicknameResponse:
type: object
required:
- nickname
properties:
nickname:
type: string
ErrorResponse:
type: object
required:
- code
- message
properties:
code:
type: string
message:
type: string
이 명세는 API의 요청 형식, 인증 방식, 정상 응답 및 오류 응답을 정의한다.
다만 이 예시는 계약의 기본 구조를 보여주기 위한 것이다. 실제 서비스에서는 닉네임 정규식, 공백 허용 여부, 문자 길이 계산 기준, 오류 코드의 허용 값 등도 필요에 따라 명시해야 한다.
명세와 구현을 검증하는 방법
명세와 실제 구현의 일치 여부를 검증하는 방법은 여러 가지다.
명세 기반 응답 검증
실제 API에 요청을 보내고 응답 상태 코드와 본문이 OpenAPI 명세에 정의된 규칙을 만족하는지 검사한다.
명세 기반 테스트 생성
OpenAPI 명세를 이용해 여러 요청과 입력값을 생성하고, 실제 API의 응답이 계약에 맞는지 확인한다.
클라이언트·서버 계약 검증
클라이언트가 기대하는 인터페이스와 서버가 제공하는 인터페이스가 서로 호환되는지 확인한다. 특히 여러 팀이 독립적으로 개발하는 환경에서 유용하다.
CI 파이프라인 검증
코드가 변경될 때 명세 검증과 API 테스트를 자동 실행해 계약 위반을 조기에 발견한다.
이때 OpenAPI 파일의 문법이 올바른지만 검사해서는 실제 구현과의 일치 여부를 보장할 수 없다. 명세 자체의 유효성 검사와 실제 서버를 대상으로 한 계약 검증은 구분해야 한다.
API 계약 테스트 자동화
API 테스트를 수동으로 실행하는 방식은 초기 개발 단계에서는 유용하지만, API 수가 늘어날수록 모든 요청을 반복해서 확인하기 어려워진다.
CI 환경에 테스트를 연결하면 코드가 변경될 때마다 API 계약을 자동으로 검증할 수 있다.
일반적인 자동화 구조는 다음과 같다.
개발자가 코드 변경
|
v
Git 저장소에 Push
|
v
CI 파이프라인 실행
|
v
OpenAPI 명세 검증
|
v
테스트 서버 및 테스트 데이터 준비
|
v
API 계약 테스트 실행
|
v
테스트 결과 확인
|
+---- 실패 ----> 배포 차단 또는 검토
|
+---- 성공 ----> 다음 배포 단계
예를 들어 테스트 실행 단계에서는 Postman CLI 또는 Bruno CLI와 같은 도구를 사용할 수 있다. 명세 기반 테스트 도구를 별도로 도입해 OpenAPI 스키마와 실제 응답을 비교할 수도 있다.
중요한 점은 테스트가 실패했을 때 그 원인을 확인할 수 있어야 한다는 것이다. 실패한 요청, 실제 응답, 기대한 결과, 사용한 환경을 함께 기록하면 문제를 빠르게 추적할 수 있다.
자동화할 때 고려할 사항
- 테스트 데이터의 생성과 정리를 자동화한다.
- 테스트 환경의 데이터베이스를 운영 환경과 분리한다.
- 인증 토큰과 비밀값을 안전하게 관리한다.
- 테스트 간 데이터 의존성을 최소화한다.
- 실패한 요청과 응답을 추적할 수 있도록 로그를 남긴다.
- 명세 변경이 기존 클라이언트와 호환되는지 확인한다.
- 외부 API, 결제 시스템 등 실제 부작용이 발생하는 호출은 격리하거나 모의 객체로 대체한다.
이러한 조건을 갖추면 API 테스트는 개발자가 필요할 때만 실행하는 도구가 아니라 배포 안정성을 유지하는 자동화된 검증 절차가 된다.
실무에서 권장하는 테스트 전략
모든 API에 동일한 수준의 테스트를 적용할 필요는 없다. API의 중요도와 장애 위험에 따라 테스트를 구성하는 것이 효율적이다.
| 테스트 대상 | 주요 검증 내용 |
|---|---|
| 인증 API | 토큰 유효성, 인증 실패, 권한 부족 |
| 회원 API | 필수 필드, 입력값 검증, 중복 데이터 |
| 게임 재화 API | 재화 변경 규칙, 중복 요청, 데이터 정합성 |
| 결제 API | 중복 결제 방지, 트랜잭션 결과, 오류 처리 |
| 조회 API | 응답 스키마, 페이지네이션, 정렬 |
| 관리자 API | 권한 검증, 접근 제한, 오류 응답 |
특히 게임 서버에서는 재화 지급, 아이템 구매, 보상 수령과 같이 데이터의 정합성이 중요한 API를 주의 깊게 테스트해야 한다.
예를 들어 동일한 보상 요청이 두 번 전송되었을 때 재화가 두 번 지급된다면, 응답 형식이 아무리 정확하더라도 서비스는 정상이라고 볼 수 없다.
이러한 문제를 발견하려면 계약 테스트뿐 아니라 중복 요청 처리, 동시성 제어, 트랜잭션, 멱등성에 대한 기능 테스트도 함께 수행해야 한다.