
코드와 검증 기록을 대조하는 장면을 표현한 AI 생성 일러스트입니다. 실제 테스트 화면이 아닙니다.
블로그에 적힌 코드는 짧고 매끄럽게 읽혔다. 그런데 검증 프로젝트로 옮기면 다른 질문이 생겼다.
“생략한 클래스는 어디에 있지?”
“정상 요청 말고 잘못된 요청에도 설명한 대로 동작할까?”
“테스트가 통과한 코드와 실제로 게시된 코드가 같을까?”
9월 13~15일에 기존 기술 글을 정리하면서, 예제의 문법뿐 아니라 어떤 조건을 확인했고 무엇은 아직 확인하지 않았는지를 함께 기록했다. 이번 글은 API 응답 형식을 다시 설명하는 튜토리얼이 아니다. 예제를 검증 가능한 기록으로 바꾸며 정리한 기준이다.
기록의 범위
아래 사례는 당시 수정한 원고, 테스트 소스와 저장된 결과 보고서를 바탕으로 정리했다. 모든 기존 글의 코드를 검증했다는 뜻은 아니다. API 예제의 테스트 결과는 2026년 9월 13일 저장된 보고서이며, 이 글을 작성하면서 전체 테스트를 새로 실행한 결과는 아니다.
| 확인할 질문 | 필요한 근거 | 그것만으로 알 수 없는 것 |
|---|---|---|
| 코드가 실행될 준비가 됐는가? | 버전·의존성·타입을 갖춘 컴파일 | 업무 규칙이 맞는지 |
| 허용하지 않는 입력을 거부하는가? | 정상값·경계값 테스트 | 실제 DB의 제약과 동시성 |
| HTTP 응답이 계약을 지키는가? | 상태·헤더·본문 검증 | 실제 네트워크와 전체 보안 설정 |
| 독자가 같은 코드를 읽는가? | 원고와 저장된 본문 대조 | 실행 환경의 모든 차이 |
컴파일, 테스트, 게시 확인은 같은 검사를 반복하는 일이 아니었다. 각각 다른 종류의 빈틈을 찾는 과정이었다.
기존 API 응답 글에는 DTO의 생성자만 보여 주고 // getters and setters로 끝낸 부분과, 주변 구현을 생략한 서비스 호출이 있었다. 설명용 발췌라면 생략할 수 있다. 다만 독자가 복사해서 실행할 수 있는 예제로 제시하려면 필요한 구현과 의존성이 함께 있어야 한다.
그래서 검증할 때는 실제 DTO·서비스·컨트롤러·예외 처리기를 구성했다. 원고에는 생략한 부분과 적용 조건을 구분해 적었다.
당시 Java 예제 검증 프로젝트의 기준은 Java 17과 Spring Boot 3.3.2 의존성 BOM이었다. 이는 당시의 재현 조건이며, 지금 새 프로젝트에 그 버전을 권장한다는 뜻은 아니다. 코드를 옮긴 환경의 버전을 기록해야 나중에 실패가 생겼을 때 API 변화인지 예제의 누락인지 구분할 수 있다.
작은 코드라도 다음 세 가지는 구별해 두는 편이 좋았다.
금액 계산에서는 BigDecimal.multiply()에 정수 수량을 그대로 넘길 수 없다. 수량을 BigDecimal로 변환하는 것은 타입 문제를 해결한다. 하지만 수량이 0이거나 음수일 때의 정책까지 정해 주지는 않는다.
아래는 당시 검증에 사용한 작은 테스트 클래스다. Java 17과 JUnit Jupiter 의존성을 갖춘 프로젝트에서 src/test/java/OrderAmountTest.java에 놓는 코드다.
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class OrderAmountTest {
static BigDecimal total(BigDecimal price, int quantity) {
if (price == null
|| price.signum() < 0
|| quantity <= 0) {
throw new IllegalArgumentException(
"Invalid order input");
}
return price.multiply(
BigDecimal.valueOf(quantity));
}
@Test
void calculatesAmount() {
BigDecimal result = total(
new BigDecimal("12.50"), 3);
assertEquals(0,
new BigDecimal("37.50").compareTo(result));
}
@Test
void rejectsInvalidQuantity() {
assertThrows(IllegalArgumentException.class,
() -> total(BigDecimal.TEN, 0));
assertThrows(IllegalArgumentException.class,
() -> total(BigDecimal.TEN, -1));
}
@Test
void rejectsInvalidPrice() {
assertThrows(IllegalArgumentException.class,
() -> total(null, 1));
assertThrows(IllegalArgumentException.class,
() -> total(new BigDecimal("-1"), 1));
}
}
여기서는 가격은 0 이상, 수량은 양수라는 예제의 규칙을 선택했다. 환불·부채처럼 음수가 유효한 업무에 그대로 적용할 보편 규칙은 아니다.
결과 비교에도 기준이 있다. BigDecimal.equals()는 값과 스케일을 함께 비교하고, compareTo()는 수치의 대소를 비교한다. 이 테스트의 목적은 금액의 수치 확인이어서 compareTo() == 0을 사용했다. 소수 둘째 자리까지의 표현 자체가 계약이라면 스케일이나 직렬화 결과도 별도로 검사해야 한다. Java 17 BigDecimal 문서
이 작은 테스트는 주문 금액 계산 함수를 확인한다. 재고 차감, 주문 저장, 반올림 정책, 결제와 환불까지 검증하는 주문 시스템 테스트는 아니다. 입력 가격 0은 구현상 허용하지만, 위 테스트에는 그 경계를 직접 확인하는 사례가 빠져 있다는 점도 구분할 수 있다.
공통 응답을 도입하면 본문의 모양에 눈이 가기 쉽다.
{
"status": "FAILURE",
"message": "Request could not be processed",
"data": null
}
하지만 같은 JSON이라도 HTTP 상태를 모두 500으로 바꾸거나 필요한 헤더를 버리면 계약은 달라진다.
예를 들어 GET만 제공하는 경로에 POST를 보냈다면, 해당 예제에서는 405 Method Not Allowed가 나와야 한다. 405 응답에는 지원하는 메서드를 알리는 Allow 헤더가 필요하다. 이는 자체 JSON 형식의 취향이 아니라 HTTP의 요구 사항이다. RFC 9110 §15.5.6
검증 프로젝트에서는 컨트롤러와 예외 처리기를 직접 등록하고, 응답의 상태·헤더·본문을 같이 확인했다. 다음은 테스트 클래스의 관련 메서드 발췌다. DemoUserService·UserController·ApiExceptionHandler는 기존 API 공통 응답 글의 구현을 전제로 한다. 아래 코드만으로 완결된 애플리케이션은 아니다.
ObjectMapper mapper = new ObjectMapper();
MockMvc mvc(DemoUserService service) {
return MockMvcBuilders
.standaloneSetup(new UserController(service))
.setControllerAdvice(new ApiExceptionHandler())
.build();
}
@Test
void methodNotAllowedKeepsHeader() throws Exception {
var response = mvc(new DemoUserService())
.perform(post("/api/v1/users/1"))
.andExpect(status().isMethodNotAllowed())
.andReturn().getResponse();
assertTrue(response.getHeader("Allow")
.contains("GET"));
assertEquals("FAILURE",
mapper.readTree(response.getContentAsString())
.get("status").asText());
}
본문을 공통 형식으로 바꾸더라도 프레임워크가 정한 상태와 헤더를 보존하는지가 핵심이다. 또 이 테스트는 Allow에 GET이 포함됐는지만 확인한다. 허용 메서드 전체 집합이 정확히 같은지까지 단언한 테스트는 아니다.
당시 API 예제에서 확인한 사례는 다음과 같다.
| 요청 또는 상황 | 테스트의 주요 확인 항목 |
|---|---|
| 존재하는 사용자 조회 | 200, SUCCESS, 사용자 ID와 이름 |
| 없는 사용자 조회 | 404, FAILURE |
| ID가 0인 요청 | 400, FAILURE |
| 숫자 대신 문자열 ID | 400, FAILURE |
| 지원하지 않는 POST 요청 | 405, Allow에 GET 포함, FAILURE |
| 서비스에서 예상 밖 예외 발생 | 500, 안전한 오류 메시지, 내부 예외 문자열 비노출 |
저장된 ApiResponseArticleTest 결과는 6개 실행, 실패 0, 오류 0, 건너뜀 0이었다. 보고서의 시각은 2026년 9월 13일 22:12 KST다. “오류가 없다”보다 “이 여섯 조건에서 이 단언을 통과했다”가 결과를 더 정확하게 설명한다.
MockMvc는 실제 서버를 띄우지 않고 Spring MVC의 요청 처리를 시험한다. 컨트롤러 메서드를 직접 호출하는 것과 달리 매핑·타입 변환·메시지 변환·예외 처리 등의 경로를 확인할 수 있다. Spring MockMvc 개요
다만 위에서 선택한 standaloneSetup은 컨트롤러와 필요한 구성을 직접 등록하는 방식이다. 실제 애플리케이션의 Spring 설정 전체를 불러온 것이 아니다. Spring 문서도 standalone 테스트 외에 실제 MVC 설정을 확인하는 통합 테스트가 필요함을 설명한다. MockMvc 설정 방식
따라서 위의 6개 테스트 결과로 다음까지 완료했다고 말할 수는 없다.
이 사례에서 standalone 방식을 선택한 이유는 작게 구성한 API 예제의 상태·헤더·본문을 집중해서 확인하기 위해서다. 실제 애플리케이션에 붙일 때는 그 환경의 설정과 외부 의존성을 포함한 검증을 추가해야 한다.
테스트가 통과해도 원고를 복사하는 과정에서 코드가 빠질 수 있다. 수정 전 코드와 수정 후 코드가 함께 남거나, 긴 본문의 뒷부분이 저장되지 않으면 독자가 읽는 내용은 검증한 예제와 달라진다.
이번 정리에서는 원문과 수정본을 따로 보존하고, 저장된 본문에서 제목·코드 블록·소제목·태그·URL 등을 확인했다. 편집 중 본문이 중복된 글은 복원한 뒤 최종 본문과 소제목의 중복 여부를 다시 대조했다.
이 과정은 다음처럼 연결된다.
원문 보존
↓
실행 조건과 생략 범위 명시
↓
검증 프로젝트의 예제 + 실패·경계값 테스트
↓
결과 보고서와 확인하지 않은 범위 기록
↓
수정 원고 ↔ 실제로 저장된 본문 대조
텍스트 대조와 화면 확인도 다르다. DOM에서 이미지 주소가 있다는 사실만으로 그림의 글자가 읽히거나 모바일 표가 편하게 보인다고 단정할 수는 없다. 스크린샷이나 실제 화면으로 보지 못한 부분은 시각적 검수까지 끝났다고 쓰지 않는 편이 맞다.
예제를 검증하면서 바뀐 것은 “테스트 완료”라는 표시보다 문장의 범위였다.
계산 함수 테스트로 주문 전체가 안전하다고 말하지 않기. MockMvc 테스트로 운영 설정까지 확인했다고 말하지 않기. 원고를 수정했다는 이유로 게시된 내용도 같다고 가정하지 않기.
좋은 예제는 짧게 읽히는 코드이면서, 어디까지 믿어도 되는지 알 수 있는 코드여야 한다. 다음 글에서는 이번에 남겨 둔 경계 중 하나인 “실제 DB를 붙였을 때 테스트의 결론이 어떻게 달라지는가”를 작은 사례로 다뤄 보려 한다.