23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
우리 팀에서는 Prompt Engineering Platform을 만들고 있습니다.
Prompt Engineering Platform은 다양한 외부 Model Provider와 연동을 하고 있는데, 처음에는 Provider 서버를 직접 연동해서 테스트하였습니다.하지만, 이건 녹록지 않더라고요. 실제 연동하는 서버가 동작하지 않기도 했고, 어떤 날은 Azure 쪽에서 이상하게 타임아웃이 나거나 버전 업데이트 때문에 스펙이 바뀌어버리기도 했습니다.🤯
그래서 서버 내 Mock API를 만들어 연동하기도 하였지만, 이것 또한 번거로웠습니다. API에 변경 사항이 있을 때마다 Mock API도 함께 변경해 주며 꾸준히 관리해 주어야 했거든요.
외부 서버의 상태에 따라서 테스트는 종종 실패했고, 실패 원인이 우리 코드 때문인지 외부 서비스 때문인지 알 수가 없었습니다. 이런 상황이 반복되다 보니, 테스트는 신뢰할 수 없는 존재가 되어버렸죠. “테스트가 실패하는데! 이게 내 코드 문제일까, 아니면 또 외부 서버의 문제인 걸까?” 하는 불필요한 검증에 시간을 낭비하는 경우가 많았습니다.
그러던 중 팀원분이 WireMock을 소개해 주셨고, 이를 적용해 빠르고, 독립적이며, 반복 가능한 FIRST Unit Test에 한 발짝 다가갈 결심을 하게 됩니다.
FIRST 테스트를 위하여 선택한 도구는 바로 WireMock, Docker, 그리고 GitLab CI입니다. 외부 API를 Mock하면서 독립적으로 테스트할 수 있는 환경을 만들었고, 이를 자동화된 파이프라인 안에 적용하였습니다.
이 글에서는 WireMock과 Docker, GitLab CI를 어떤 방식으로 활용했는지에 대해 차근차근 이야기하려 합니다.
안 그래도 바쁜데, 불필요한 테스트 실패로 시간을 빼앗기고 있는 개발자분들께 이 글이 작은 도움이 되길 바랍니다 🙏
앞서 언급했듯, 기존에는 외부 서버를 실제로 연동했기 때문에 에러 응답 테스트가 까다로웠고, 외부 서버에 이슈가 발생하면 테스트도 실패하는 문제가 있었습니다.
또한, 테스트 시 DB와 Redis를 직접 로컬에서 실행하는 것도 번거로웠습니다.
아래는 기존의 전체 아키텍처 구성도입니다.
외부 서버 연동을 WireMock으로 대체하고, WireMock, DB, Redis를 모두 Docker로 실행하여 로컬뿐 아니라 GitLab CI 환경에서도 통합 테스트를 수행할 수 있도록 구성하였습니다.
이에 대한 자세한 내용은 아래에서 상세히 다루겠습니다.
결과적으로 프로젝트의 전체 소스 구조는 아래와 같으며, 여기서 Docker + WireMock + GitLab CI 구성을 한눈에 파악할 수 있습니다.
docker 폴더에는 컨테이너로 실행할 WireMock 관련 파일이 위치하며, 프로젝트 루트에는 GitLab CI 설정을 위한 .gitlab-ci.yml파일이 있습니다.
📁 project-root/
├──📁 docker/
│ ├──📁 wiremock/
│ │ ├──📁 __files/
│ │ ├──📁 mappings/
│ │ └──🐳 Dockerfile
│ └──🐳 docker-compose.yml
├──📁 src/
└──🦊 .gitlab-ci.yml또한, 위에 표기하진 않았지만, 통합 테스트를 위해 MySQL과 Redis도 WireMock과 함께 Docker로 실행하고 있습니다.
이와 같이 테스트에 필요한 서비스들을 자유롭게 추가하면 됩니다. 😊
WireMock이란?
API mock testing을 위한 오픈소스 툴로, 안정적인 테스트 및 개발 환경을 만들고, 불안정한 외부(3rd party) 서비스와 독립적으로 테스트할 수 있으며, 아직 구현되지 않은 API를 시뮬레이션하는 데 도움을 줍니다.
WireMock을 이용해 외부 서버의 API를 어떻게 mocking할 수 있는지 살펴보겠습니다.
특정 요청이 들어왔을 때 미리 정해둔 응답을 반환하는 "stub"을 mappings 디렉터리 내에 구성하여 줍니다.
그리고 아래와 같이 Intellij에서 WireMock을 간단하게 실행해볼 수 있습니다.
Docker로 실행하는 방법은 바로 다음 챕터인 "Docker 컨테이너에서 WireMock 구동하기"에서 다루겠습니다.
WireMock을 사용하면, 외부 서버와 직접 연동하지 않고 mappings 하위에 정의된 응답을 내려주어 테스트 환경을 외부 시스템과 완전히 분리하여 독립적으로 유지할 수 있습니다.
예를 들어, 아래 stub은 POST /chat/completions 요청에 대해 JSON 정상 응답을 반환합니다.
{
"request": {
"method": "POST",
"urlPath": "/chat/completions"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"model": "gpt-5",
"text": "오늘은 날씨가 좋아요!"
}
}
}다음은 일정 시간 지연 응답을 테스트할 수 있는 Stub 예시입니다.
/api/v1/delay 경로로 POST 요청이 오면 1000ms(1초) 후에 200 OK 응답을 반환합니다.
{
"request": {
"method": "POST",
"urlPath": "/api/v1/delay"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"fixedDelayMilliseconds": 1000
}
}상황에 따라서는, 같은 URL Path에 대해 서로 다른 응답을 받아야 하는 경우도 있습니다 (e.g., 정상 응답과 에러 응답)
우리 팀은 다양한 에러 상황을 분류하는 테스트가 필요했는데, 이때는 요청 body에 특정 키워드가 포함되어 있는지를 조건으로 stub을 정의했습니다.
아래 예시는 "rate_limit_exceeded"나 "internal_error"가 body에 포함된 경우 각각 429, 500 에러를 반환하도록 설정한 stub입니다:
{
"mappings": [
{
"priority": 1,
"request": {
"method": "POST",
"urlPath": "/chat/completions",
"bodyPatterns": [
{
"contains": "rate_limit_exceeded",
"ignoreExtraElements": true # 추가 요소(필드/값)가 있어도 무시하고 비교(지정한 JSON 부분만 맞으면 매칭으로 인정)
}
]
},
"response": {
"status": 429,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"error": {
"message": "You have exceeded allocated quota and must wait before making new requests",
}
}
}
},
{
"priority": 1,
"request": {
"method": "POST",
"urlPath": "/v1/chat/completions",
"bodyPatterns": [
{
"contains": "internal_error",
"ignoreExtraElements": true # 추가 요소(필드/값)가 있어도 무시하고 비교(지정한 JSON 부분만 맞으면 매칭으로 인정)
}
]
},
"response": {
"status": 500,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"error": {
"message": "Unexpected internal gateway error",
}
}
}
}
]
}#### Tips💡- WireMock 관련 소소한 팁
응답 본문 분리
JSON 매핑 파일 안에 응답 내용을 직접 넣을 수도 있지만, 응답이 길어질 경우
__files/디렉터리에 별도 파일로 저장하는 것을 권장합니다.이렇게 하면 매핑 파일은 요청·응답 구조만 간결하게 유지되고, 응답 본문은 재사용하기 쉬워집니다.
Priority 활용
동일한 URL Path라도 요청 조건에 따라 다른 응답을 줄 수 있습니다.
priority 값이 낮을수록 먼저 매칭되며, 주로 에러 케이스 등 특수 상황에 낮은 값을 부여합니다.
기본(default) 응답은 priority를 높게 설정해 마지막에 매칭되도록 합니다.
Docker 컨테이너에서 WireMock을 구동하는 가장 간단한 방법은 Docker Hub에 공식적으로 배포된 wiremock/wiremock 이미지를 사용하는 것입니다. 이 이미지를 사용하면 별도의 설정 없이 손쉽게 WireMock 서버를 실행할 수 있습니다.
앞서 만들었던 프로젝트 구조 하위의 project-root/docker/wiremock/Dockerfile을 아래 코드와 같이 만들어 주었습니다. 여기서, 도커 이미지에 wiremock 디렉토리의 mappings 파일을 복사해 주었는데, mappings는 앞서 만든 API Stub들 입니다.
ARG VERSION=3.12.1
FROM wiremock/wiremock:${VERSION}
COPY wiremock/mappings /home/wiremock/mappingsARG VERSION=3.12.1 → WireMock 버전을 변수로 정의해, 빌드 시 원하는 버전으로 바꿀 수 있게 합니다.
FROM wiremock/wiremock:${VERSION} → 공식 WireMock 이미지를 지정한 버전으로 가져옵니다.
COPY wiremock/mappings /home/wiremock/mappings → 작성한 stub 매핑 파일들을 컨테이너에 복사해 WireMock이 자동 로딩하도록 합니다.
그리고, 이렇게 만들어준 docker 컨테이너를 한번에 구성하고 실행할 수 있게 만들기 위해 docker-compose.yml 파일도 정의하여 주었습니다.
services:
wiremock:
container_name: wiremock
image: {containerRegistryUrl}/wiremock:3.12.1-25.06.1
ports:
- "8080:8080"
restart: always
healthcheck:
test: curl -f http://localhost:8080/healthcheck || exit 1
interval: 5s
timeout: 3s
retries: 10container_name: wiremock → 컨테이너 이름을 wiremock으로 고정합니다.
image: {containerRegistryUrl}/wiremock:3.12.1-25.06.1 → GitLab Container Registry를 사용하고 있어서 해당 주소를 이용하였습니다.
ports: "8080:8080" → 로컬 8080 포트를 컨테이너 8080 포트와 연결합니다.
restart: always → 컨테이너가 중단되면 자동으로 재시작하는 옵션입니다.
healthcheck → http://localhost:8080/healthcheck 엔드포인트를 주기적으로 확인해 WireMock이 정상 구동 중인지 체크합니다. WireMock 컨테이너가 실행 중이라고 해서 바로 API를 받을 준비가 된 건 아니기 때문에, 실제로 wiremock이 실행이 되고 요청을 처리할 준비가 되었는지 체크하는 단계입니다.
test: 컨테이너 상태를 확인하기 위해 실행하는 명령을 정의
이렇게 만든 docker-compose 파일을 이용하여 실행을 하여 주면 아래와 같이 실행여부를 확인할 수 있습니다. 짜잔... ! 🌟
$ docker-compose up -d
██ ██ ██ ██████ ███████ ███ ███ ██████ ██████ ██ ██
██ ██ ██ ██ ██ ██ ████ ████ ██ ██ ██ ██ ██
██ █ ██ ██ ██████ █████ ██ ████ ██ ██ ██ ██ █████
██ ███ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
███ ███ ██ ██ ██ ███████ ██ ██ ██████ ██████ ██ ██
----------------------------------------------------------------
| Cloud: https://wiremock.io/cloud |
| |
| Slack: https://slack.wiremock.org |
----------------------------------------------------------------
version: 3.12.1
port: 8080API를 호출하여 보면, 앞서 만든 stub의 규칙에 따라서 결과를 반환해주는 것을 확인할 수 있습니다.
앞서 만든 stub에서 body에 model_server_error가 들어오면, 오류를 반환하게 정의하였었는데요. 그 응답도 확인 할 수가 있네요~ !😀
curl --location 'localhost:8080/v1/chat/completions' \
--header 'Content-Type: application/json' \
--data '{
"data":"model_server_error"
}'
{"error":{"message":"Model responded with a server error","type":"internal_errors","code":"model_server_error"}}우리의 목적은 여기서 끝난 게 아니었습니다. 배포 시 수행하는 테스트에서도 WireMock을 이용하여 자동화된 테스트를 안정적으로 실행하여야 했는데요.
GitLab CI 환경에서 테스트함으로써, 개발 플로우는 다음과 같이 개선되었습니다.
gitlab-ci를 통해서 테스트를 실행하기 위해, application-test.yml, .gitlab-ci.yml에 아래와 같이 코드를 추가하여 주었습니다.
test:
stage: test
image: $COMMON_REGISTRY/java:11-glibc-25.04
services:
- name: $COMMON_REGISTRY/wiremock:3.12.1-25.06.1
alias: wiremock # 파이프라인 안에서 어떤 이름(hostname)으로 접근할지 지정
variables:
HEALTHCHECK_TCP_PORT: "8080"
environment:
name: testing
tags:
- test
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'여기서 중요한 부분은 services인데, job에서 함께 실행되는 컨테이너를 정의하는 기능입니다. alias는 호출시 사용할 이름(예: wiremock)으로 지정해줍니다.
다음으로, application-test를 살펴보겠습니다.
# ===============================
# = SERVERS PROPERTIES
# ===============================
servers:
datas:
- name: OPENAI
scheme: http
host: wiremock
port: 8080
- name: AZURE
scheme: http
host: wiremock
port: 8080 여기서 host는 상위의 gitlab-ci에서 지정한 alias인 wiremock을 지정해주어야 합니다.
WireMock + Docker + GitLab CI로 통합 테스트 환경을 만들고 나서, 가장 먼저 달라진 건 테스트에 대한 신뢰감이었습니다. 예전에는 CI가 깨지면 무엇이 문제인지 이유를 찾느라 시간을 허비하곤 했는데, 지금은 외부 환경이 흔들려도 우리가 만든 테스트는 안정적으로 돌아간다는 안정감을 얻을 수 있었습니다.
마이크로 서비스 환경에서는 여러 서버가 함께 동작해야 기능이 정상 작동하는 경우가 종종 있습니다. 하지만 로컬에서 이를 테스트하려면 필요한 서버를 전부 실행해야 하는데, 이 과정이 꽤 번거롭습니다. 이럴 때 WireMock으로 해당 서버들을 Mocking하면, 다른 서버를 띄우지 않고도 독립적으로 테스트를 진행할 수 있습니다.
WireMock을 도입하고 나서는 반복 작업에서 많이 해방됐습니다. 굳이 Mock API를 한 땀 한 땀 만들어주지 않아도, 필요한 응답 패턴만 정의해두면 WireMock이 서버처럼 알아서 응답하기 때문에 테스트 시나리오를 짜는 데 집중할 수 있었습니다.
돌이켜보면, 이 작업은 단순히 테스트 환경을 꾸미는 게 아니라 팀 전체의 개발 사이클을 바꾸는 경험이었습니다. 테스트 실패에 흔들리지 않고, 안정적으로 기능을 개발하고, 빠르게 배포할 수 있는 기반을 마련했기 때문입니다.
앞으로도 새로운 API 프로바이더가 붙거나 요구사항이 바뀌더라도, 이 환경을 계속 확장하고 다듬어가면서 “실제처럼, 빠르게, 안정적으로” 에이닷을 완성도 있게 만드는 목표를 유지해 나가려고 합니다.
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.