Postman으로 확인하는 건 언제까지 가능할까
Spring Security를 붙이고, QueryDSL로 동적 검색을 만들고, N+1 문제를 fetch join으로 해결했다. 기능은 잘 돌아간다. 확인은? Postman을 켜서 API를 하나씩 호출해 본다.
처음에는 괜찮았다. API가 5개일 때는 수동으로 확인해도 10분이면 됐다. 그런데 API가 30개, 50개로 늘어나면서 이야기가 달라졌다.
“이번 배포 전에 기존 API 다 확인했어?”
“다는 못 했고, 수정한 부분만…”
“지난번에 안 건드린 API가 깨졌잖아.”
코드 한 줄 바꿨을 뿐인데, 전혀 관련 없어 보이는 API가 500 에러를 뱉었다. SecurityConfig를 수정했더니 권한 체크가 바뀌어서, 특정 엔드포인트가 403을 반환하고 있었다. Postman으로는 그 엔드포인트까지 확인하지 못했던 것이다.
‘수동 테스트에는 한계가 있다. 자동화된 테스트가 필요하다.’
이건 알고 있었다. 문제는 ‘어디서부터, 어떻게 시작해야 하는가’였다.

어디까지 테스트할까
테스트의 종류를 먼저 짚고 가자. 전부 외울 필요 없고, 이 세 가지만 알면 된다.
| 종류 | 범위 | 속도 | 예시 |
|---|---|---|---|
| 단위 테스트 | 메서드/클래스 하나 | 빠름 (ms) | Service 로직, 유틸 함수 |
| API 테스트 (슬라이스) | Controller + 웹 레이어 | 보통 | MockMvc로 HTTP 요청/응답 검증 |
| 통합 테스트 | 전체 애플리케이션 + DB | 느림 (초) | @SpringBootTest + 실제 DB |
실무에서 자주 하는 실수가 있다. “단위 테스트부터 촘촘하게 짜야 한다”는 생각이다. 이론적으로는 맞지만, 현실에서는 단위 테스트만으로는 버그를 못 잡는 경우가 많다. Service 로직이 완벽해도 Controller에서 JSON 직렬화가 깨지거나, DB 쿼리가 의도와 다르게 실행되는 건 단위 테스트에서 보이지 않는다.
이 글에서는 실무에서 가장 가성비가 좋은 두 가지에 집중한다.
- MockMvc: HTTP 요청을 시뮬레이션해서 API 동작을 검증
- Testcontainers: 실제 PostgreSQL을 띄워서 쿼리와 데이터를 검증
MockMvc로 API 검증하기
MockMvc는 실제 서버를 띄우지 않고 Spring MVC의 웹 레이어를 테스트하는 도구다. HTTP 요청을 보내고, 응답 코드/본문/헤더를 검증할 수 있다.
기본 설정
@WebMvcTest(ProjectController.class) // Controller만 로딩
class ProjectControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean // Service는 가짜 객체로 대체
private ProjectService projectService;
@Test
void 프로젝트_목록_조회_성공() throws Exception {
// given
List<ProjectDto> projects = List.of(
new ProjectDto(1L, "프로젝트A", "ACTIVE"),
new ProjectDto(2L, "프로젝트B", "COMPLETED")
);
given(projectService.search(any())).willReturn(projects);
// when & then
mockMvc.perform(get("/api/projects")
.param("status", "ACTIVE")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].name").value("프로젝트A"))
.andExpect(jsonPath("$[0].status").value("ACTIVE"));
}
}
@WebMvcTest는 Controller와 웹 관련 설정만 로딩한다. Service, Repository, DB는 로딩하지 않으므로 빠르다. Service는 @MockBean으로 가짜 객체를 주입하고, given()으로 반환값을 지정한다.
실패 케이스 검증
성공 케이스만 테스트하면 반쪽이다. 실무에서는 실패 케이스가 더 중요하다.
@Test
void 존재하지_않는_프로젝트_조회시_404() throws Exception {
given(projectService.findById(999L))
.willThrow(new ProjectNotFoundException(999L));
mockMvc.perform(get("/api/projects/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("프로젝트를 찾을 수 없습니다."));
}
@Test
void 권한_없는_사용자_접근시_403() throws Exception {
mockMvc.perform(get("/api/admin/projects")
.with(user("user").roles("USER"))) // USER 권한으로 접근
.andExpect(status().isForbidden());
}
@Test
void 잘못된_요청_파라미터시_400() throws Exception {
mockMvc.perform(post("/api/projects")
.contentType(MediaType.APPLICATION_JSON)
.content("{}")) // 필수 필드 누락
.andExpect(status().isBadRequest());
}
이전 글에서 다뤘던 Spring Security의 401/403 구분도 MockMvc로 검증할 수 있다. SecurityConfig를 수정할 때마다 이 테스트를 돌리면, 의도치 않게 권한이 바뀌는 것을 잡아낼 수 있다.

Testcontainers로 실제 DB 통합 테스트
MockMvc는 HTTP 레이어를 검증하지만, DB 쿼리가 실제로 올바른지는 확인하지 못한다. Service를 @MockBean으로 대체했기 때문이다.
실제 DB 쿼리를 검증하려면 통합 테스트가 필요하다. 보통 H2 인메모리 DB를 쓰는 경우가 많은데, 문제가 있다. H2와 PostgreSQL은 SQL 문법과 동작이 다르다. H2에서 통과한 쿼리가 PostgreSQL에서 실패하는 경우가 실제로 있다.
Testcontainers는 이 문제를 해결한다. 테스트 실행 시 Docker로 실제 PostgreSQL 컨테이너를 띄우고, 테스트가 끝나면 자동으로 정리한다.
Testcontainers를 사용하려면 Docker가 설치되어 있어야 한다. Docker Desktop이나 Docker Engine이 실행 중이어야 테스트가 동작한다.
의존성 추가
// build.gradle testImplementation 'org.testcontainers:testcontainers:1.19.3' testImplementation 'org.testcontainers:junit-jupiter:1.19.3' testImplementation 'org.testcontainers:postgresql:1.19.3'
Testcontainers 2.x부터는 모듈명과 패키지가 변경되었다. Spring Boot 2.x + Java 8 환경에서는 1.x 계열을 사용하는 것이 안정적이다.
기본 구성
@SpringBootTest
@Testcontainers
class ProjectRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:12")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private ProjectRepository projectRepository;
@Autowired
private JPAQueryFactory queryFactory;
}
@Container가 테스트 시작 전에 PostgreSQL Docker 컨테이너를 띄우고, @DynamicPropertySource가 Spring의 DB 연결 정보를 이 컨테이너로 가리킨다. 테스트가 끝나면 컨테이너는 자동으로 제거된다.
QueryDSL 검색 통합 테스트
이전 글에서 만든 QueryDSL 동적 검색이 실제 PostgreSQL에서도 올바르게 동작하는지 검증해 보자.
@Test
void 상태와_지역으로_동적_검색() {
// given: 테스트 데이터 준비
Member manager = memberRepository.save(
Member.builder().name("홍길동").build()
);
projectRepository.save(
Project.builder().name("프로젝트A").status("ACTIVE")
.region("서울").manager(manager).build()
);
projectRepository.save(
Project.builder().name("프로젝트B").status("COMPLETED")
.region("서울").manager(manager).build()
);
projectRepository.save(
Project.builder().name("프로젝트C").status("ACTIVE")
.region("부산").manager(manager).build()
);
// when: 상태=ACTIVE, 지역=서울로 검색
ProjectSearchCondition condition = new ProjectSearchCondition();
condition.setStatus("ACTIVE");
condition.setRegion("서울");
List<Project> result = projectRepository.search(condition);
// then
assertThat(result).hasSize(1);
assertThat(result.get(0).getName()).isEqualTo("프로젝트A");
}
@Test
void 조건_없이_검색하면_전체_조회() {
// given
projectRepository.save(Project.builder().name("A").status("ACTIVE").build());
projectRepository.save(Project.builder().name("B").status("COMPLETED").build());
// when: 빈 조건
ProjectSearchCondition condition = new ProjectSearchCondition();
List<Project> result = projectRepository.search(condition);
// then
assertThat(result).hasSize(2);
}
이 테스트는 H2가 아닌 실제 PostgreSQL에서 실행된다. QueryDSL이 생성한 SQL이 PostgreSQL에서 정상 동작하는지, 동적 조건 조합이 올바른 결과를 반환하는지를 확인할 수 있다.
fetch join 검증: N+1이 정말 해결되었는가
이전 글의 N+1 해결도 테스트로 검증할 수 있다.
@Test
void fetch_join으로_N_plus_1이_발생하지_않는다() {
// given
Member manager1 = memberRepository.save(Member.builder().name("김담당").build());
Member manager2 = memberRepository.save(Member.builder().name("이담당").build());
projectRepository.save(
Project.builder().name("A").manager(manager1).build()
);
projectRepository.save(
Project.builder().name("B").manager(manager2).build()
);
// when: fetch join이 적용된 조회
List<Project> result = projectRepository.findAllWithManager();
// then: 담당자에 접근해도 추가 쿼리 없음
for (Project project : result) {
assertThat(project.getManager().getName()).isNotNull();
}
// show-sql 로그로 쿼리가 1개만 실행되었는지 확인
}

실무에서 시작하는 순서: 처음부터 전부 하지 말 것
테스트를 처음 도입하려는 팀에 가장 중요한 조언은 이것이다. ‘커버리지 목표를 세우지 마라. 가장 아픈 곳부터 테스트를 붙여라.’
1단계: 자주 깨지는 API부터
배포할 때마다 문제가 생기는 엔드포인트가 있다면, 그것부터 MockMvc 테스트를 붙인다. 성공/실패/권한 케이스만 잡아도 수동 확인 시간이 크게 줄어든다.
2단계: 복잡한 검색/쿼리
QueryDSL 동적 검색, fetch join, 페이징 같은 복잡한 쿼리는 코드 변경 시 쉽게 깨진다. Testcontainers로 실제 DB 통합 테스트를 붙이면, 쿼리 변경의 안전망이 생긴다.
3단계: 신규 기능
새로 만드는 기능은 처음부터 테스트와 함께 작성한다. 기존 코드에 테스트를 소급하는 것보다 훨씬 쉽다.
테스트 속도 관리
통합 테스트(Testcontainers)는 느리다. Docker 컨테이너를 띄우는 데만 수 초가 걸린다. 전부 통합 테스트로 만들면 테스트 전체가 느려져서 아무도 안 돌리게 된다.
| 테스트 종류 | 속도 | 비중 | 실행 시점 |
|---|---|---|---|
| MockMvc (슬라이스) | 빠름 | 많이 | 로컬 개발 중 수시로 |
| Testcontainers (통합) | 느림 | 핵심만 | CI/CD 파이프라인에서 |
| 단위 테스트 (Mockito) | 가장 빠름 | 복잡한 로직만 | 로컬에서 수시로 |
빠른 테스트를 많이, 느린 테스트는 핵심만. 이 균형이 중요하다.
H2 vs Testcontainers: 언제 뭘 쓸까
| 상황 | 추천 |
|---|---|
| 간단한 CRUD 검증, 빠른 피드백이 필요 | H2 |
| PostgreSQL 전용 기능(JSONB, 윈도우 함수 등) 사용 | Testcontainers |
| QueryDSL/Native Query가 실제 DB에서 동작하는지 확인 | Testcontainers |
| CI에서 운영 환경과 동일한 검증이 필요 | Testcontainers |
모든 테스트를 Testcontainers로 바꿀 필요는 없다. DB에 의존하지 않는 로직은 단위 테스트로, API 레이어는 MockMvc로, DB 쿼리 검증만 Testcontainers로. 도구를 구분해서 쓰는 원칙은 여기서도 동일하다.
마치며: 테스트는 보험이다
테스트 코드는 기능을 만드는 코드가 아니다. ‘기존 기능이 안 깨졌다’는 것을 확인하는 보험이다. 보험이 없으면 배포할 때마다 불안하고, 리팩토링은 엄두도 못 낸다.
처음부터 완벽할 필요 없다. 가장 아픈 곳부터, MockMvc 하나부터 시작하면 된다.