
2026년 새해가 밝았다.
오늘도 난 퇴근 후 공부를 하고 난뒤, 업무를 하면서 겪었던 내용을 정리해 포스팅을 한다😳
누구에겐 오늘이 뜻깊고 한해를 마무리하는 날 이지만, 나에겐 그리 특별하지 않다. 그냥 매일 똑같은 하루 같은 느낌이랄까.
그래도 이 글을 읽는 분들은 행복한 날이 되길 바래본다❤️
다들 새해 복 많이 받으세요🫶
⟡ 인트로
신입으로 입사해 처음 맡은 고도화 프로젝트에서 가장 큰 불안 요소는 테스트 코드가 전혀 없는 레거시 시스템이었다.
코드를 수정할 때마다 전체 애플리케이션을 실행해서 수동으로 확인해야 했고, 사이드 이펙트를 발견하는 건 운에 맡겨야 했다.
단위 테스트를 도입하려고 했지만 처음에는 방법을 몰라서 여러 번 실패했다.
전체 Spring 컨텍스트를 로딩하는 느린 테스트, 복잡한 Mock 설정으로 점철된 읽기 어려운 테스트,
테스트하기 어렵게 설계된 레거시 코드와 씨름하면서 점진적으로 테스트 전략을 찾아갔다.
이 글은 그 과정에서 실패하고 배운 경험을 기록한 것이다.

⟡ 문제 상황
고도화 프로젝트를 진행하면서 가장 불안했던 순간은 기존 코드를 수정할 때였다.
레거시 시스템에는 테스트 코드가 전혀 없었고, 코드 한 줄을 수정할 때마다 애플리케이션 전체를 실행해서 수동으로 확인해야 했다.
사용자 등록 로직을 수정했는데 알고 보니 결제 모듈에 영향을 주는 경우가 있었고
이런 사이드 이펙트는 실제 운영 환경에 배포하기 전까지 발견하기 어려웠다.
특히 문제가 심각했던 부분은 리팩토링을 시도할 때였다.
코드 구조를 개선하고 싶어도 . . .
"내가 수정한 부분이 다른 곳에 영향을 주지는 않을까?🙀"라는 불안감 때문에 손을 대기가 두려웠다.
결국 기술 부채는 쌓여만 갔고, 새로운 기능을 추가할 때마다 기존 코드를 건드리지 않고 우회하는 방식으로 개발하게 되었다.
이 상황을 개선하기 위해 단위 테스트 도입을 시도했다.
⟡ 최종 해결 모델 요약
최종적으로 안착한 테스트 전략은 계층별로 다른 테스트 방식을 적용하는 것이었다.
Controller 계층은 @WebMvcTest로 웹 레이어만 로딩하여 HTTP 요청/응답을 검증했고
Repository 계층은 @DataJpaTest로 JPA 관련 설정만 로딩하여 쿼리 동작을 확인했다.
Service 계층은 Mockito를 활용한 순수 단위 테스트로 비즈니스 로직만 검증했다.
이 방식은 테스트 실행 속도를 빠르게 유지하면서도 각 계층의 책임을 명확히 검증할 수 있었다.
⟡ 첫 번째 시도: JUnit만 추가하고 @SpringBootTest로 시작
테스트를 처음 작성해본다는 막연한 두려움 때문에 일단 가장 간단해 보이는 방법을 선택했다.
build.gradle에 JUnit 의존성을 추가하고, Controller를 테스트하는 코드를 작성했다.
먼저 필요한 의존성은 다음과 같이 추가했다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// 테스트 의존성
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
그리고 다음과 같이 테스트 코드를 작성했다.
package com.example.demo.controller;
import com.example.demo.dto.UserRegisterRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.junit.jupiter.api.Assertions.assertEquals;
// 전체 Spring 컨텍스트를 로딩하는 통합 테스트
@SpringBootTest
class UserControllerTest {
@Autowired
private UserController userController;
@Test
void 사용자_등록_테스트() {
// given: 테스트용 요청 데이터 준비
UserRegisterRequest request = new UserRegisterRequest(
"test@example.com",
"password123"
);
// when: Controller 메서드 직접 호출
ResponseEntity<?> response = userController.registerUser(request);
// then: 응답 상태 코드 확인
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}

문제점: 이 방식은 작동은 했지만 치명적인 문제가 있었다🤯. .
@SpringBootTest는 전체 Spring 컨텍스트를 로딩한다.
즉, 모든 Bean을 생성하고 DB 연결을 수립하고 외부 API 클라이언트까지 초기화했다.
테스트 하나를 실행하는 데 20초 이상 걸렸고, 테스트가 5개만 넘어가도 1분 이상 소요되었다.
이 정도 속도라면 TDD(테스트 주도 개발)는 불가능했고, 심지어 수동 테스트보다 느렸다.
더 큰 문제는 테스트 격리가 안 된다는 점이었다.
DB에 실제 데이터가 쌓였고, 테스트 순서에 따라 결과가 달라졌다.
테스트 A에서 생성한 사용자 데이터가 테스트 B에 영향을 주는 식이었다.
@Transactional을 붙여서 롤백을 시도했지만, 일부 비동기 작업이나 외부 API 호출은 롤백할 수 없었다.
배운 점: @SpringBootTest는 통합 테스트용이지 단위 테스트용이 아니었다.
전체 컨텍스트 로딩은 개발 피드백 루프를 너무 느리게 만들었다.
빠른 테스트 실행이 단위 테스트의 핵심 가치라는 걸 깨달았다.
⟡ 두 번째 시도: Service 계층 단위 테스트
Controller 테스트가 느리다는 걸 알게 되었으니, Service 계층을 먼저 테스트해보기로 했다.
Service는 순수 비즈니스 로직이니까 Spring 컨텍스트 없이 테스트할 수 있을 것 같았다.
먼저 테스트 대상인 Service 클래스는 다음과 같았다.
package com.example.demo.service;
import com.example.demo.domain.User;
import com.example.demo.dto.UserRegisterRequest;
import com.example.demo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final EmailService emailService;
@Transactional
public User registerUser(UserRegisterRequest request) {
// 중복 이메일 확인
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException("이미 사용 중인 이메일입니다.");
}
// 비밀번호 암호화
String encodedPassword = passwordEncoder.encode(request.getPassword());
// 사용자 생성 및 저장
User user = new User(request.getEmail(), encodedPassword);
User savedUser = userRepository.save(user);
// 환영 이메일 발송
emailService.sendWelcomeEmail(savedUser.getEmail());
return savedUser;
}
}
처음에는 의존성 없이 직접 생성을 시도했다.
package com.example.demo.service;
import com.example.demo.domain.User;
import com.example.demo.dto.UserRegisterRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
// Spring 컨텍스트 없이 순수 Java로만 테스트 시도
class UserServiceTest {
private UserService userService;
@BeforeEach
void setUp() {
// 의존성을 어떻게 전달해야 할까? - 실패
userService = new UserService(); // 컴파일 에러: 생성자 파라미터 필요
}
@Test
void 사용자_등록_테스트() {
UserRegisterRequest request = new UserRegisterRequest(
"test@example.com",
"password123"
);
User user = userService.registerUser(request);
assertNotNull(user);
}
}

문제점: 컴파일조차 되지 않았다.
UserService는 UserRepository, PasswordEncoder, EmailService 등
여러 의존성을 생성자로 주입받고 있었는데
이걸 직접 생성하려니 순환 참조처럼 계속 다른 의존성이 필요했다.
그래서 Mockito를 처음 접하게 되었다.
의존성을 Mock 객체로 만들어서 주입하면 된다는 걸 배웠다.
package com.example.demo.service;
import com.example.demo.domain.User;
import com.example.demo.dto.UserRegisterRequest;
import com.example.demo.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
// Mockito를 사용한 단위 테스트
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
// Mock 객체로 만들 의존성들
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private EmailService emailService;
// 테스트 대상 (위의 Mock들이 자동으로 주입됨)
@InjectMocks
private UserService userService;
@Test
void 사용자_등록_테스트() {
// given: Mock 객체의 동작을 정의
UserRegisterRequest request = new UserRegisterRequest(
"test@example.com",
"password123"
);
// existsByEmail 호출 시 false 반환하도록 설정
when(userRepository.existsByEmail(anyString())).thenReturn(false);
// encode 호출 시 암호화된 문자열 반환하도록 설정
when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword");
// save 호출 시 저장된 사용자 객체 반환하도록 설정
when(userRepository.save(any(User.class))).thenReturn(
new User("test@example.com", "encodedPassword")
);
// when: 실제 테스트 대상 메서드 호출
User user = userService.registerUser(request);
// then: 결과 검증 및 호출 여부 확인
assertNotNull(user);
verify(emailService).sendWelcomeEmail(anyString()); // 이메일 발송 메서드가 호출되었는지 확인
}
}

문제점: 코드는 작동했지만 Mocking 설정이 너무 복잡했다.
의존성이 5개만 넘어가도 when().thenReturn() 구문을 10줄 이상 작성해야 했고
이게 실제로 올바른 테스트인지 확신이 서지 않았다.
특히 Mock 객체의 동작을 일일이 정의하다 보니
"내가 구현 코드를 테스트하는 건가 아니면 Mock 설정을 테스트하는 건가?🤔" 하는 의문이 들었다.
더 큰 문제는 Service 로직이 테스트하기 어렵게 설계되어 있다는 점이었다.
하나의 메서드가 여러 책임을 가지고 있었고, 의존성이 많았고, 일부는 static 메서드를 직접 호출하고 있었다.
테스트를 작성하려니 기존 코드를 리팩토링해야 하는데, 리팩토링을 하려니 테스트가 필요한 순환 구조였다.
배운 점: 테스트 가능한 코드 설계가 먼저였다.
단일 책임 원칙, 의존성 주입, 인터페이스 분리 같은 원칙들이 "이론상 좋은 것"이 아니라
"테스트를 위해 필수적인 것"이라는 걸 체감했다.
또한 모든 의존성을 Mock으로 만들면 테스트가 깨지기 쉽다는 것도 배웠다.
구현을 조금만 바꿔도 Mock 설정을 전부 다시 해야 했다.

⟡ 세 번째 시도: @WebMvcTest로 Controller 테스트
Service 테스트가 복잡하니, 다시 Controller로 돌아왔다.
하지만 이번에는 @SpringBootTest 대신 @WebMvcTest를 사용했다.
이 어노테이션은 웹 계층만 로딩하고 나머지 Bean은 생성하지 않는다는 걸 알게 되었다.
먼저 테스트 대상인 Controller는 다음과 같았다.
package com.example.demo.controller;
import com.example.demo.domain.User;
import com.example.demo.dto.UserRegisterRequest;
import com.example.demo.dto.UserResponse;
import com.example.demo.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/register")
public ResponseEntity<UserResponse> registerUser(@Valid @RequestBody UserRegisterRequest request) {
User user = userService.registerUser(request);
return ResponseEntity.ok(new UserResponse(user.getEmail()));
}
}
그리고 @WebMvcTest를 사용한 테스트 코드는 다음과 같이 작성했다.
package com.example.demo.controller;
import com.example.demo.domain.User;
import com.example.demo.dto.UserRegisterRequest;
import com.example.demo.service.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
// 웹 계층만 로딩하는 슬라이스 테스트
@WebMvcTest(UserController.class)
class UserControllerTest {
// MockMvc: HTTP 요청/응답을 테스트하기 위한 도구
@Autowired
private MockMvc mockMvc;
// ObjectMapper: Java 객체를 JSON으로 변환하기 위한 도구
@Autowired
private ObjectMapper objectMapper;
// Service는 Mock으로 대체 (실제 로직은 실행되지 않음)
@MockBean
private UserService userService;
@Test
void 사용자_등록_API_테스트() throws Exception {
// given: 테스트 데이터 준비
UserRegisterRequest request = new UserRegisterRequest(
"test@example.com",
"password123"
);
User mockUser = new User("test@example.com", "encodedPassword");
// Service의 registerUser 메서드가 호출되면 mockUser를 반환하도록 설정
when(userService.registerUser(any(UserRegisterRequest.class)))
.thenReturn(mockUser);
// when & then: HTTP POST 요청을 보내고 응답 검증
mockMvc.perform(post("/api/users/register")
.contentType(MediaType.APPLICATION_JSON) // Content-Type 헤더 설정
.content(objectMapper.writeValueAsString(request))) // 요청 본문을 JSON으로 변환
.andExpect(status().isOk()) // 상태 코드 200 확인
.andExpect(jsonPath("$.email").value("test@example.com")); // 응답 JSON의 email 필드 확인
}
@Test
void 사용자_등록시_이메일_형식이_잘못되면_400_반환() throws Exception {
// given: 잘못된 이메일 형식
String invalidRequestJson = "{\"email\":\"invalid\",\"password\":\"pwd123\"}";
// when & then: 400 Bad Request 응답 확인
mockMvc.perform(post("/api/users/register")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidRequestJson))
.andExpect(status().isBadRequest()); // @Valid 검증 실패로 400 반환
}
}
결과: 이 방식은 효과적이었다.
테스트 실행 속도가 5초 이내로 줄어들었고
HTTP 요청/응답 형식, 상태 코드, JSON 직렬화/역직렬화까지 검증할 수 있었다.
Controller의 책임인 "요청을 받아서 적절한 Service를 호출하고 응답을 반환하는 것"에만 집중할 수 있었다.
Service는 @MockBean으로 대체했기 때문에 Service의 실제 로직은 실행되지 않았다.
이게 처음에는 불안했는데 . .😬
"Controller는 Service가 올바르게 작동한다고 가정하고 자신의 역할만 잘하면 된다"는
단위 테스트의 철학을 이해하게 되면서 받아들일 수 있었다.
배운 점: 계층별로 다른 테스트 전략이 필요했다.
Controller는 웹 레이어의 책임만 테스트하고
Service의 실제 로직은 Service 자체의 테스트에서 다루면 되었다.
@WebMvcTest는 빠르고 집중된 테스트를 작성하는 데 적합했다.

⟡ 네 번째 시도: @DataJpaTest로 Repository 테스트
Repository 계층도 테스트해야 한다는 걸 깨달았다.
복잡한 쿼리 메서드나 @Query 어노테이션으로 작성한 JPQL이 실제로 작동하는지 확인이 필요했다.
먼저 테스트 대상인 Repository는 다음과 같았다.
package com.example.demo.repository;
import com.example.demo.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// 이메일로 사용자 조회
Optional<User> findByEmail(String email);
// 이메일 중복 확인
boolean existsByEmail(String email);
}
Entity 클래스는 다음과 같았다.
package com.example.demo.domain;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
public User(String email, String password) {
this.email = email;
this.password = password;
}
}
그리고 @DataJpaTest를 사용한 테스트 코드는 다음과 같이 작성했다.
package com.example.demo.repository;
import com.example.demo.domain.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
// JPA 관련 Bean만 로딩하는 슬라이스 테스트
@DataJpaTest
class UserRepositoryTest {
// 실제 Repository 구현체 (Spring Data JPA가 자동 생성)
@Autowired
private UserRepository userRepository;
// 테스트용 EntityManager (데이터 준비 및 flush/clear 제어용)
@Autowired
private TestEntityManager entityManager;
@Test
void 이메일로_사용자_조회() {
// given: 테스트용 사용자 데이터 준비
User user = new User("test@example.com", "password123");
entityManager.persist(user); // 영속성 컨텍스트에 저장
entityManager.flush(); // DB에 즉시 반영
// when: Repository 메서드 호출
Optional<User> found = userRepository.findByEmail("test@example.com");
// then: 결과 검증
assertTrue(found.isPresent());
assertEquals("test@example.com", found.get().getEmail());
}
@Test
void 이메일_중복_확인_메서드_동작() {
// given: 기존 사용자 데이터 저장
entityManager.persist(new User("existing@example.com", "password"));
entityManager.flush();
// when & then: 존재하는 이메일과 존재하지 않는 이메일 확인
assertTrue(userRepository.existsByEmail("existing@example.com"));
assertFalse(userRepository.existsByEmail("new@example.com"));
}
@Test
void 사용자_저장_후_ID_자동_생성_확인() {
// given
User user = new User("test@example.com", "password123");
// when: save 메서드로 저장
User savedUser = userRepository.save(user);
// then: ID가 자동으로 생성되었는지 확인
assertNotNull(savedUser.getId());
// 실제로 DB에서 조회되는지 확인
Optional<User> found = userRepository.findById(savedUser.getId());
assertTrue(found.isPresent());
assertEquals("test@example.com", found.get().getEmail());
}
}
결과: @DataJpaTest는 JPA 관련 Bean만 로딩했다.
H2 같은 인메모리 DB를 자동으로 설정해주었고 각 테스트마다 트랜잭션을 자동으로 롤백해주었다.
테스트 실행 속도는 3~5초 정도였고, 실제 DB 연동 없이 JPA 쿼리를 검증할 수 있었다.
특히 유용했던 점은 N+1 문제나 불필요한 쿼리 발생 여부를 확인할 수 있다는 것이었다.
@Query 어노테이션으로 작성한 복잡한 JPQL이 실제로 의도한 대로 작동하는지 로그를 보면서 확인할 수 있었다.
배운 점: Repository 테스트는 데이터 접근 로직 자체를 검증하는 것이지 비즈니스 로직을 검증하는 게 아니었다.
"이 메서드가 DB에서 올바른 데이터를 가져오는가"에만 집중하면 되었다.

⟡ 최최종 해결 : ✅ 계층별 테스트 전략 수립
결국 각 계층마다 적합한 테스트 방식이 다르다는 걸 깨달았다.
최종적으로 정착한 테스트 전략은 다음과 같았다.
Controller 테스트: @WebMvcTest 사용.
HTTP 요청/응답 검증, 입력 유효성 검증, 상태 코드 확인.
Service는 Mock으로 대체. 실행 속도 5초 이내.
Service 테스트: Mockito로 의존성 Mocking.
비즈니스 로직 검증, 예외 처리 검증.
단, 복잡한 Mock 설정이 필요하면 코드 구조를 먼저 개선.
실행 속도 1~2초.
Repository 테스트: @DataJpaTest 사용.
쿼리 메서드 검증, JPQL 문법 확인, N+1 문제 확인.
실행 속도 3~5초.
통합 테스트: @SpringBootTest 사용하되 최소한으로만 작성.
주로 중요한 비즈니스 플로우의 end-to-end 검증용.
실행 속도 20초 이상이지만 개수를 제한.
⟡ 구체적인 적용 사례
실제로 사용자 등록 기능을 예로 들면, 다음과 같이 3개 계층으로 나눠서 테스트를 작성했다.
각 테스트는 자신의 계층에만 집중하고 다른 계층은 Mock이나 실제 구현에 의존한다.
// ===== 1. Controller 테스트: HTTP 요청/응답만 검증 =====
package com.example.demo.controller;
import com.example.demo.domain.User;
import com.example.demo.dto.UserRegisterRequest;
import com.example.demo.service.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(UserController.class)
class UserControllerWebTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private UserService userService;
@Test
@DisplayName("올바른 요청이 들어오면 200 OK와 사용자 정보를 반환한다")
void 사용자_등록_성공() throws Exception {
// given
UserRegisterRequest request = new UserRegisterRequest(
"test@example.com",
"password123"
);
User mockUser = new User("test@example.com", "encodedPassword");
when(userService.registerUser(any(UserRegisterRequest.class)))
.thenReturn(mockUser);
// when & then
mockMvc.perform(post("/api/users/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("test@example.com"));
}
@Test
@DisplayName("이메일 형식이 잘못되면 400 Bad Request를 반환한다")
void 사용자_등록시_이메일_형식_검증() throws Exception {
// given
String invalidRequestJson = "{\"email\":\"invalid\",\"password\":\"pwd123\"}";
// when & then
mockMvc.perform(post("/api/users/register")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidRequestJson))
.andExpect(status().isBadRequest());
}
}
// ===== 2. Service 테스트: 비즈니스 로직 검증 =====
package com.example.demo.service;
import com.example.demo.domain.User;
import com.example.demo.dto.UserRegisterRequest;
import com.example.demo.exception.DuplicateEmailException;
import com.example.demo.repository.UserRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceMockTest {
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
@Test
@DisplayName("중복된 이메일로 가입 시도 시 예외를 발생시킨다")
void 중복_이메일_예외_발생() {
// given
when(userRepository.existsByEmail(anyString())).thenReturn(true);
UserRegisterRequest request = new UserRegisterRequest(
"existing@example.com",
"password123"
);
// when & then
assertThrows(DuplicateEmailException.class, () -> {
userService.registerUser(request);
});
// 예외 발생 후 save나 이메일 발송은 호출되지 않아야 함
verify(userRepository, never()).save(any(User.class));
verify(emailService, never()).sendWelcomeEmail(anyString());
}
@Test
@DisplayName("정상적인 가입 요청 시 사용자를 저장하고 환영 이메일을 발송한다")
void 사용자_등록_성공() {
// given
UserRegisterRequest request = new UserRegisterRequest(
"new@example.com",
"password123"
);
when(userRepository.existsByEmail(anyString())).thenReturn(false);
when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword");
when(userRepository.save(any(User.class))).thenReturn(
new User("new@example.com", "encodedPassword")
);
// when
User result = userService.registerUser(request);
// then
assertNotNull(result);
assertEquals("new@example.com", result.getEmail());
// 각 메서드가 정확히 한 번씩 호출되었는지 확인
verify(userRepository, times(1)).existsByEmail("new@example.com");
verify(passwordEncoder, times(1)).encode("password123");
verify(userRepository, times(1)).save(any(User.class));
verify(emailService, times(1)).sendWelcomeEmail("new@example.com");
}
}
// ===== 3. Repository 테스트: 데이터 접근 검증 =====
package com.example.demo.repository;
import com.example.demo.domain.User;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
class UserRepositoryJpaTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
@DisplayName("이메일로 사용자를 조회할 수 있다")
void 이메일로_사용자_조회() {
// given
User user = new User("test@example.com", "password123");
entityManager.persist(user);
entityManager.flush();
// when
Optional<User> found = userRepository.findByEmail("test@example.com");
// then
assertTrue(found.isPresent());
assertEquals("test@example.com", found.get().getEmail());
}
@Test
@DisplayName("존재하는 이메일은 true를, 존재하지 않는 이메일은 false를 반환한다")
void 이메일_중복_확인() {
// given
entityManager.persist(new User("existing@example.com", "password"));
entityManager.flush();
// when & then
assertTrue(userRepository.existsByEmail("existing@example.com"));
assertFalse(userRepository.existsByEmail("new@example.com"));
}
@Test
@DisplayName("사용자 저장 시 ID가 자동으로 생성된다")
void 사용자_저장_ID_자동생성() {
// given
User user = new User("test@example.com", "password123");
// when
User savedUser = userRepository.save(user);
entityManager.flush();
entityManager.clear(); // 영속성 컨텍스트 초기화하여 실제 DB 조회 강제
// then
assertNotNull(savedUser.getId());
Optional<User> found = userRepository.findById(savedUser.getId());
assertTrue(found.isPresent());
assertEquals("test@example.com", found.get().getEmail());
}
}
⟡ DTO 클래스들
테스트 코드에서 사용하는 DTO 클래스들도 실제로 작성해야 컴파일이 된다.
// UserRegisterRequest.java
package com.example.demo.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserRegisterRequest {
@Email(message = "올바른 이메일 형식이 아닙니다")
@NotBlank(message = "이메일은 필수입니다")
private String email;
@NotBlank(message = "비밀번호는 필수입니다")
private String password;
}
// UserResponse.java
package com.example.demo.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class UserResponse {
private String email;
}
// DuplicateEmailException.java
package com.example.demo.exception;
public class DuplicateEmailException extends RuntimeException {
public DuplicateEmailException(String message) {
super(message);
}
}
// EmailService.java
package com.example.demo.service;
import org.springframework.stereotype.Service;
@Service
public class EmailService {
public void sendWelcomeEmail(String email) {
// 실제 이메일 발송 로직
System.out.println("환영 이메일 발송: " + email);
}
}
⟡ 점진적 도입 전략
레거시 프로젝트에 테스트를 한 번에 다 작성할 수는 없었다.
다음과 같은 우선순위로 점진적으로 도입했다.
첫째, 자주 수정하는 코드부터 테스트를 작성했다.
버그가 반복적으로 발생하는 부분이나 새로운 기능을 추가할 예정인 영역을 먼저 대상으로 삼았다.
둘째, 새로 작성하는 코드는 무조건 테스트와 함께 작성했다.
이미 작성된 레거시 코드에 테스트를 추가하는 것보다
새 코드를 처음부터 테스트 가능하게 작성하는 게 훨씬 쉬웠다.
셋째, 리팩토링 전에 테스트를 먼저 작성했다.
코드 구조를 바꾸기 전에 현재 동작을 검증하는 테스트를 작성해두면
리팩토링 후에도 기능이 깨지지 않았다는 걸 확인할 수 있었다.

⟡ 배운 점
테스트는 처음부터 완벽할 수 없다:
처음에는 모든 케이스를 다 커버하려고 했는데, 이게 오히려 진입 장벽이 되었다.
중요한 부분부터 하나씩 추가하는 게 현실적이었다.
계층별로 다른 테스트 전략이 필요하다:
Controller, Service, Repository는 각각 책임이 다르고, 테스트 방식도 달라야 했다.
하나의 방식으로 모든 계층을 테스트하려는 시도는 실패했다.
테스트 가능한 코드 설계의 중요성:
의존성이 많고 책임이 불명확한 코드는 테스트 작성 자체가 불가능했다.
테스트를 작성하면서 자연스럽게 더 나은 코드 구조를 고민하게 되었다.
빠른 피드백이 핵심이다:
20초 걸리는 테스트는 실행하기 싫어지지만, 2초 걸리는 테스트는 자주 실행하게 된다.
테스트 속도가 개발 생산성에 직접적인 영향을 주었다.
Mock은 최소한으로:
모든 의존성을 Mock으로 대체하면 테스트가 구현에 강하게 결합되고
리팩토링할 때마다 테스트도 함께 수정해야 했다.
진짜 검증하고 싶은 부분의 의존성만 Mock으로 만드는 게 유지보수에 유리했다.
통합 테스트와 단위 테스트의 균형:
단위 테스트만으로는 부족한 부분이 있었다.
핵심 비즈니스 플로우는 통합 테스트로 한 번 더 검증하되, 개수를 제한해서 실행 속도를 관리했다.
⟡ 참고 문서 및 레퍼런스
JUnit 5 공식 문서 :
JUnit 5의 기본 어노테이션(@Test, @BeforeEach, @DisplayName 등)과 Assertion 메서드 사용법을 다룬다.
Overview :: JUnit User Guide
The goal of this document is to provide comprehensive reference documentation for programmers writing tests, extension authors, and engine authors as well as build tool and IDE vendors.
docs.junit.org
Spring Boot 테스트 공식 문서 : Spring Boot Testing 공식 가이드
Spring Boot에서 제공하는 테스트 어노테이션(@SpringBootTest, @WebMvcTest, @DataJpaTest 등)의
상세한 설명과 사용법을 다룬다.
Testing :: Spring Boot
If you have tests that use JUnit 4, JUnit 6’s vintage engine can be used to run them. To use the vintage engine, add a dependency on junit-vintage-engine, as shown in the following example: org.junit.vintage junit-vintage-engine test org.hamcrest hamcres
docs.spring.io
우아한형제들 기술 블로그 : 테스트 관련 글
실무에서 테스트 코드를 도입하고 개선한 경험을 공유하는 다양한 글들이 있다.
특히 "단위 테스트 작성의 고민"이나 "통합 테스트 환경 구축" 같은 글이 유용하다.
우아한형제들 기술블로그
우아한형제들의 기술, 서비스, 비전, 가치를 들려 드립니다.
techblog.woowahan.com
의존성 주입과 테스트 가능한 설계 : Spring Framework - Dependency Injection
생성자 주입이 테스트에 유리한 이유와 Spring의 의존성 주입 방식을 설명한다.
Dependency Injection :: Spring Framework
Constructor-based DI is accomplished by the container invoking a constructor with a number of arguments, each representing a dependency. Calling a static factory method with specific arguments to construct the bean is nearly equivalent, and this discussion
docs.spring.io
https://www.baeldung.com/spring-boot-testing
Baeldung : Spring Boot Testing 튜토리얼
@WebMvcTest, @DataJpaTest 등의 실전 예제와 함께 각 테스트 방식의 장단점을 설명한다.