나는 이번에 회원가입과 포인트 적립 기능을 개발하면서 TDD 원칙을 도입하였다
단위 테스트(Unit), 통합 테스트(Integration), E2E(End-to-End)와 같이 다양한 테스트들을 작성하다보니 테스트 구조가 복잡해져, 테스트가 터졌을 때 비즈니스 로직이 잘못 된건지, 응답이 잘못된건지 정확한 지점을 찾기 힘들었다.
코드를 작성하는 것보다 테스트하는 것이 중요해진 요즘, 내가 테스트 더블을 통해 그 것들을 해결한 방법을 남겨놓기로 하였다.
꼬리에 꼬리를 무는 컨트롤러 테스트
회원가입 컨트롤러는 아래와 같은 구조였다.
@RestController
@RequestMapping("/user")
public class UserController {
private final UserService userService;
...
}
나는 비즈니스 로직 검증이 아닌 요청과 응답의 흐름을 확인하고 싶었으나, UserController는 UserService에 완전히 의존하고 있었기에 컨트롤러를 단독으로 테스트를 수행할 수 없었다.
나는 이를 해결하기 위해 @MockBean을 사용하여 가짜 UserService를 Spring Context에 주입하였다.
Mock을 활용해보자
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService; // 실제 대신 Mock 주입
@Test
void signUp_returns_200_and_responseBody() throws Exception {
UserResponseDto fakeResponse = new UserResponseDto(
"sangdon", "dori@dori.com", "1998-02-21", "MALE"
);
when(userService.signUp(any())).thenReturn(fakeResponse);
mockMvc.perform(post("/user/signUp")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"id":"sangdon","email":"dori@dori.com","birthDate":"1998-02-21","gender":"MALE"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("sangdon"))
.andExpect(jsonPath("$.email").value("dori@dori.com"));
}
}
이렇게 작성하니 훨씬 간결해졌다.
1. DB나 JPA를 실행하지 않아도 테스트가 가능해졌고, 테스트 속도도 크게 향상되었다.
2. 테스트가 실패하더라도 원인을 명확히 파악할 수 있었다 (DB 문제인지, 컨트롤러 로직 문제인지 헷갈릴 일이 없었다)
3. 또한 서비스에서 발생하는 예외 상황(예: 존재하지 않는 회원)에 대해 컨트롤러가 404를 반환하는지 등 에러 매핑 로직도 손쉽게 검증할 수 있었다.
Spy로 중복 회원 가입 막기
또한 회원가입 기능의 요구사항 중 이미 가입된 ID로는 회원가입을 허용하지 않는다는 조건이 있었다.
이 요구사항을 검증하기 위해 나는 “회원가입이 중복될 때 Repository의 동작이 실제로 어떻게 되는가”에 초점을 맞추었다.
이때 중요한 것은 결과값이 아니라 행동(메서드 호출)이었다.
정상적인 회원가입이라면 userJpaRepository.save()가 한 번만 호출되어야 하고, 중복된 회원가입 시에는 save()가 다시 호출되지 않아야 한다. 이러한 호출 여부와 횟수를 검증하기 위해 Spy를 사용하였다.
Spy는 실제 객체를 프록시로 감싸, 기본 동작은 그대로 수행하면서도 “어떤 메서드가 몇 번 호출되었는가”를 추적할 수 있는 방식이다.
@MockitoSpyBean
private UserJpaRepository userJpaRepository;
@Test
@DisplayName("이미 가입된 ID로 회원가입 시 실패한다.")
void signUp_duplicate_id_fails() {
//given
UserRequestDto requestDto1 = new UserRequestDto(
"sangdon",
"dori@dori.com",
"1998-02-21",
"MALE"
);
User user1 = new UserDtoMapper().toEntity(requestDto1);
UserRequestDto requestDto2 = new UserRequestDto(
"sangdon",
"karina@karina.com",
"2000-02-21",
"FEMALE"
);
User user2 = new UserDtoMapper().toEntity(requestDto2);
//when
userService.saveUser(user1);
//then
assertThrows(CoreException.class, () -> userService.saveUser(user2));
verify(userJpaRepository, times(1)).save(user1);
verify(userJpaRepository, never()).save(user2);
}

이처럼 Spy를 사용하여 실제 비즈니스 흐름을 그대로 수행하면서도 필요한 메서드만 선택적으로 검증할 수 있었다.
이전까지는 크게 체감하지 못했지만, 한 번에 40개가 넘는 테스트 코드를 작성하며
정말 필요한 로직과 응답만 정확히 검증하는 것이 얼마나 중요한지 깨달았다.
불필요한 검증을 줄이고 핵심만 테스트하는 것이 곧 생산성과 직결된다는 사실을 몸으로 느꼈다.
'개발 > 루퍼스(Loopers)' 카테고리의 다른 글
| Kafka, 동작 과정 샅샅이 파해쳐보기 (0) | 2025.12.19 |
|---|---|
| ApplicationEvent 를 활용해 Facade 경량화 하기 (0) | 2025.12.12 |
| 인덱스를 이용하여 50만건의 데이터 빠르게 조회하기 + k6를 이용한 성능 테스트 (0) | 2025.11.28 |
| JPA CountBy 조회 vs 엔티티 필드 저장, DDD에서는 무엇을 선택해야 할까? (0) | 2025.11.21 |
| Facade 패턴으로 레이어 책임 분리하기 (0) | 2025.11.07 |