1. Service 계층 테스트
학습 목표
- Service 단위 테스트의 명확한 목적 정의
- 의존 관계에 있는 Repository 계층을 완벽히 Mock 처리하여 격리
- 성공 케이스와 실패/예외 케이스를 나누어 비즈니스 로직 검증
- ArgumentCaptor를 활용하여 서비스 내부에서 가공된 데이터 정밀 검증
- private 메서드를 직접 테스트하지 않는 단위 테스트 원칙 이해
1.1 Service 테스트란?
일반적인 웹 애플리케이션은 Controller ➔ Service ➔ Repository ➔ DB 형태의 계층 구조를 가진다. 여기에서 집중적으로 검증하고자 하는 대상은 오직 Service(비즈니스 로직 계층)다. 따라서 상위 계층인 Controller의 HTTP 요청/응답 스펙이나, 하위 계층인 DB 영속성 메커니즘은 철저히 배제한다. 핵심 원칙 Service 테스트에서는 다른 계층의 간섭을 완전히 끊어내고, 오직 Service 내부에 녹아있는 순수한 비즈니스 요구사항과 예외 조건이 정상 동작하는지만 자동으로 검증한다.
예제 도메인: 회원가입 서비스
public class MemberService {
private final MemberRepository repository;
public MemberService(MemberRepository repository) {
this.repository = repository;
}
public void join(Member member) {
validateDuplicateEmail(member); // 비즈니스 로직 1
repository.save(member); // 비즈니스 로직 2
}
private void validateDuplicateEmail(Member member) {
repository.findByEmail(member.getEmail())
.ifPresent(m -> {
throw new IllegalArgumentException("이미 존재하는 이메일입니다.");
});
}
}
- 요구사항: 이메일은 중복될 수 없으며, 중복이 아닐 때만 최종적으로 데이터가 저장되어야 한다.
- 검증 대상 구별하기: 많은 초보 개발자가 `repository.save()`가 DB에 데이터를 밀어 넣는 행위 자체를 서비스 테스트에서 확인하려 한다. 그러나 이는 Repository 테스트의 몫이다. Service 테스트에서는 '중복 검사 필터링이 잘 작동하는지'와 '`save()` 메서드로 명령이 정상적으로 하달되는지'만 확인하면 된다.
테스트 환경 기본 세팅 (BDDMockito 기반)
@ExtendWith(MockitoExtension.class)
class MemberServiceTest {
@Mock
private MemberRepository repository; // 가짜 객체(Mock)로 격리
@InjectMocks
private MemberService service; // Mock이 주입된 실제 서비스 객체
}
1.2 성공 케이스 작성
비즈니스 시나리오가 아무런 걸림돌 없이 일사천리로 통과하는 기본 흐름(Happy Path)을 가장 먼저 작성한다.
@Test
@DisplayName("중복되지 않은 이메일로 가입을 요청하면 회원가입에 성공한다")
void join_success() {
// given
Member member = new Member("kim@test.com");
// Stub 설정: 이메일 조회 시 중복 데이터가 없음을 표현 (Optional.empty)
given(repository.findByEmail(member.getEmail()))
.willReturn(Optional.empty());
// when
service.join(member);
// then
// 서비스의 최종 책임인 save() 메서드가 정상적으로 호출되었는지 검증한다.
then(repository).should().save(member);
}
1.3 실패 및 예외 케이스 작성
실무 소프트웨어의 안정성을 결정하는 핵심 단계다. 실제 장애는 성공 시나리오가 아니라 예외 상황 처리가 미흡할 때 터지기 때문에, 통상적으로 성공 테스트 1개당 실패, 예외 테스트는 3~5개 이상 촘촘하게 도출된다.
1) 중복 이메일 가입 실패 테스트
@Test
@DisplayName("이미 존재하는 이메일로 가입을 요청하면 IllegalArgumentException이 발생한다")
void join_fail_duplicate_email() {
// given
Member member = new Member("kim@test.com");
// Stub 설정: 조회 시 동일한 이메일을 가진 회원이 이미 존재하는 상황을 묘사
given(repository.findByEmail(member.getEmail()))
.willReturn(Optional.of(member));
// when & then
assertThatThrownBy(() -> service.join(member))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("이미 존재하는 이메일입니다.");
// 안전장치 추가: 예외가 터졌으므로 하단의 save()는 절대로 호출되면 안 된다.
then(repository).should(never()).save(any(Member.class));
}
2) 파라미터 경계 및 Null 예외 테스트
만약 서비스에 `email == null`을 제한하는 방어 코드가 추가되었다면 이에 대한 예외 테스트도 독립적으로 분리하여 작성한다.
@Test
@DisplayName("이메일 값이 null이면 가입에 실패한다")
void join_fail_email_null() {
// given
Member member = new Member(null);
// when & then
assertThatThrownBy(() -> service.join(member))
.isInstanceOf(IllegalArgumentException.class);
}
3) 실무 정밀 검증 기법: ArgumentCaptor 활용
단순히 `save()` 메서드가 호출되었다는 사실을 넘어, "서비스 내부 로직에 의해 가공되거나 변경된 데이터가 올바른 상태로 유입되었는가?"를 확인해야 할 때가 있다. 대표적인 예시가 비밀번호 암호화 저장 로직이다.
// 서비스 내부에 비밀번호 암호화 로직이 추가된 상황
public void join(Member member) {
member.encryptPassword(); // 내부에서 평문을 암호문으로 변환
repository.save(member);
}
이때 `ArgumentCaptor`를 사용하여 리포지토리로 흘러 들어간 최종 멤버 객체를 가로채서 정밀 조사를 실시할 수 있다.
@Test
@DisplayName("회원가입 시 비밀번호는 반드시 암호화되어 저장되어야 한다")
void join_password_encryption() {
// given
Member member = new Member("kim@test.com", "plain_password123");
given(repository.findByEmail(member.getEmail())).willReturn(Optional.empty());
ArgumentCaptor<Member> captor = ArgumentCaptor.forClass(Member.class);
// when
service.join(member);
// then
// save()가 호출될 때 들어온 인자를 캡처한다.
then(repository).should().save(captor.capture());
Member savedMember = captor.getValue();
// 원본 평문 비밀번호와 일치하지 않는지(암호화 처리가 완료되었는지) 정밀 검증한다.
assertThat(savedMember.getPassword()).isNotEqualTo("plain_password123");
}
⚠️ Service 테스트 작성 시 자주 하는 실수 (주의점)
- 가짜 객체에 대고 결과 확인하기 (Mock의 성격 유실): `repository는 Mock 객체이므로 아무리 데이터를 저장해도 내부 리스트가 늘어나지 않는다. Mock을 상대로 상태 검증을 수행하는 실수를 범해서는 안 된다.
// 나쁜 예 service.join(member); assertThat(repository.findAll()).hasSize(1); - private 메서드를 어떻게든 꺼내서 테스트하려는 행위: 서비스 내부에 존재하는 `private void validateDuplicateEmail()` 같은 메서드를 테스트하기 위해 접근 제어자를 강제로 열거나 리플렉션을 쓰는 것은 안티 패턴이다. private 메서드는 구현 세부사항일 뿐이므로, 이를 호출하는 public 메서드(`join()`)의 흐름을 통해 자연스럽게 검증받도록 설계해야 한다.
2. Repository 계층 테스트
학습 목표
- @DataJpaTest 애너테이션의 역할과 동작 원리 이해
- H2 인메모리 데이터베이스를 활용한 가벼운 테스트 환경 구축
- 엔티티(Entity)의 저장, 조회, 수정, 삭제(CRUD) 및 연관관계 매핑 검증
- 복잡한 쿼리 메서드(Query Method) 및 JPQL 검증 방법 습득
- 테스트 환경에서의 트랜잭션(@Transactional)과 자동 롤백 메커니즘 이해
- Service 테스트와 Repository 테스트의 관심사(Responsibility) 분리
2.1 Repository 테스트란?
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
}
Repository 테스트는 데이터베이스와 가장 가까운 영속성(Persistence) 계층을 검증하는 과정이다. 지난 정리 내용글에서 다룬 Service 테스트에서는 외부 의존성을 격리하기 위해 리포지토리를 가짜 객체(`@Mock`)로 대체했지만, Repository 테스트에서는 반대로 Mock을 절대 사용하지 않고 실제 데이터베이스와 연동하여 검증한다.
💡 Service 테스트 vs Repository 테스트
- Service 테스트: 관심사가 비즈니스 로직에 있다. 리포지토리는 Mock으로 처리하여 호출 여부(행위)만 검증한다.
- Repository 테스트: 관심사가 JPA 동작 및 SQL 생성, 엔티티 매핑에 있다. 실제 내장 DB를 구동하여 쿼리가 올바르게 나아가고 데이터가 정확히 제어되는지(상태) 검증한다.
2.2 @DataJpaTest를 활용한 환경 구축
스프링 부트는 JPA 테스트를 위해 `@SpringBootTest`보다 훨씬 가볍고 최적화된 슬라이스 테스트(Slice Test) 애너테이션인 `@DataJpaTest`를 제공한다.
@DataJpaTest
class MemberRepositoryTest {
@Autowired
private MemberRepository repository;
// 테스트 로직...
}
`@DataJpaTest`가 자동으로 해주는 일
- 가벼운 컨텍스트 로딩: 컨트롤러나 서비스 등 영속성 계층과 무관한 빈(Bean)들은 제외하고, EntityManager 및 리포지토리 관련 빈들만 컴팩트하게 생성한다.
- 임베디드 DB 연결: 별도의 설정이 없으면 내부적으로 H2 같은 인메모리(In-Memory) 데이터베이스를 자동으로 가동하여 연결해 준다.
- 트랜잭션 관리: 테스트 메서드 단위로 자동 트랜잭션을 걸어주어 프로덕션 DB에 영향을 주지 않는다.
2.3 기본 CRUD 및 쿼리 메서드 검증
1) 저장(Save) 테스트
엔티티가 데이터베이스 영속성 컨텍스트에 정상적으로 편입되고, 식별자(PK) 생성이 올바르게 수행되는지 검증한다.
@Test
@DisplayName("회원 정보를 저장하면 식별자 ID가 자동으로 생성된다")
void save_member() {
// given
Member member = new Member("kim@test.com");
// when
Member saved = repository.save(member);
// then
assertThat(saved.getId()).isNotNull();
}
2) 조회 및 쿼리 메서드 테스트 (실무에서 가장 중요)
스프링 데이터 JPA가 제공하는 쿼리 메서드(`findByEmail()` 등)는 메서드명에 오타가 있거나 문법이 틀리면 애플리케이션 시작 시점 혹은 쿼리 호출 시점에 런타임 에러가 발생한다. 이를 사전에 차단하기 위해 반드시 테스트가 필요하다.
@Test
@DisplayName("이메일로 회원을 정확하게 조회할 수 있다")
void find_by_email() {
// given
Member member = new Member("kim@test.com");
repository.save(member);
// when
Optional<Member> result = repository.findByEmail("kim@test.com");
// then
assertThat(result).isPresent();
assertThat(result.get().getEmail()).isEqualTo("kim@test.com");
}
3) 리스트 다건 조회 및 삭제 테스트
여러 데이터를 한 번에 가져올 때 컬렉션 단언문(assertThat)과 조합하여 검증하고, 삭제 처리가 클리어하게 반영되는지 확인한다.
@Test
@DisplayName("전체 회원 조회 시 저장된 데이터의 수와 이메일 목록을 검증한다")
void find_all_members() {
// given
repository.save(new Member("a@test.com"));
repository.save(new Member("b@test.com"));
// when
List<Member> members = repository.findAll();
// then
assertThat(members).hasSize(2)
.extracting("email")
.containsExactlyInAnyOrder("a@test.com", "b@test.com");
}
@Test
@DisplayName("회원 삭제 요청 시 데이터가 정상적으로 소멸한다")
void delete_member() {
// given
Member member = repository.save(new Member("kim@test.com"));
// when
repository.delete(member);
Optional<Member> result = repository.findById(member.getId());
// then
assertThat(result).isEmpty();
}
2.4 연관관계 및 DB 제약조건 매핑 검증
실무 리포지토리 테스트의 진가는 엔티티 간의 연관관계(`@OneToMany`, `@ManyToOne`)나 DB 제약조건이 설계대로 맞물려 돌아가는지 검증할 때 발휘된다.
1) 제약조건(Nullable) 검증
// 엔티티에 필수값 조건이 걸려있는 경우
@Column(nullable = false)
private String email;
@Test
@DisplayName("이메일 값이 null인 상태로 저장을 시도하면 예외가 발생한다")
void email_is_required() {
// given
Member member = new Member(null);
// when & then
assertThatThrownBy(() -> repository.save(member));
}
2) 연관관계(매핑 및 Cascade) 검증
@Test
@DisplayName("회원에게 주문을 추가한 후 조회하면 연관된 주문 내역이 함께 조회된다")
void member_order_mapping() {
// given
Member member = new Member("kim@test.com");
Order order = new Order("상품A");
member.addOrder(order); // 편의 메서드로 연관관계 세팅
repository.save(member);
// when
Member found = repository.findById(member.getId()).orElseThrow();
// then
assertThat(found.getOrders()).hasSize(1);
assertThat(found.getOrders().get(0).getProductName()).isEqualTo("상품A");
}
2.5 테스트 환경에서의 트랜잭션과 자동 롤백
`@DataJpaTest`가 가진 최고의 장점 중 하나는 철저한 독립성 보장이다.
애너테이션 내부에 기본적으로 `@Transactional`이 내장되어 있다.
- [테스트 메서드 1 실행] ➔ 트랜잭션 시작 ➔ DB 데이터 조작 ➔ 테스트 종료 ➔ 자동 Rollback
- [테스트 메서드 2 실행] ➔ 깨끗하게 비워진 DB 상태에서 새 트랜잭션으로 시작
각 테스트가 끝나는 즉시 DB를 알아서 Rollback 처리하므로, 이전 테스트가 유포한 잔재 데이터 때문에 다음 테스트가 오염되어 깨지는 불상사를 완벽히 예방한다.
⚠️ Repository 테스트 작성 시 자주 하는 실수
- 리포지토리를 `@Mock`으로 만드는 행위: Repository 테스트 클래스 안에서 리포지토리를 목킹하는 것은 테스트의 존재 이유를 부정하는 것이다. 반드시 실제 DB와 연결된 `@Autowired` 인스턴스를 사용해야 한다.
- Service 레이어의 비즈니스 로직을 호출하는 행위:이곳은 영속성 계층만 타깃으로 하는 곳이다. 서비스 객체를 불러와 비즈니스를 수행하는 순간 관심사가 뒤섞이게 된다. 오직 `repository.save()` 처럼 리포지토리 자체 명령만 검증한다.
// 나쁜 예 service.join(member);
3. Controller 계층 테스트
학습 목표
- `@WebMvcTest` 애너테이션을 활용한 가벼운 웹 계층(Slice) 테스트 환경 구축
- MockMvc를 활용한 HTTP 요청 가상화 및 브라우저 없는 API 테스트
- ObjectMapper를 활용한 객체와 JSON 문자열 간의 상호 변환
- `jsonPath()` 문법을 활용한 단일 JSON 객체 및 배열 응답 검증
- `@Valid` 기반의 입력값 유효성(Validation) 및 예외 처리(ExceptionHandler) 검증
- Service 테스트(메서드 직접 호출)와 Controller 테스트(HTTP 요청 흉내)의 근본적인 차이 이해
3.1 Controller 테스트의 본질: HTTP 요청의 가상화
이전 계층의 테스트들과 Controller 테스트의 가장 큰 차이점은 호출 방식에 있다.
- Service 테스트: 테스트 코드가 서비스 객체의 메서드를 직접 호출한다. (`service.join(member)`)
- Controller 테스트: 브라우저나 Postman처럼 HTTP 요청을 흉내 내어 가상으로 호출한다. (POST `/members`)
Controller 테스트의 관심사 (Responsibility)
Controller 계층을 테스트할 때는 다음과 같은 웹 스펙 관련 요소들이 올바르게 작동하는지 검증한다.
- 지정한 URL 경로 매핑 및 HTTP Method(GET, POST, PUT, DELETE)의 일치 여부
- 클라이언트가 보낸 JSON 데이터가 자바 객체(`@RequestBody`)로 정상 변환되는지 여부
- 파라미터 유효성 검증(`@Valid`, `@NotBlank` 등)의 통과 및 필터링 여부
- 로직 실행 후 반환되는 HTTP 상태 코드(200 OK, 400 Bad Request 등) 및 응답 JSON 데이터 구조
따라서 비즈니스 로직이나 데이터베이스 영속성 메커니즘(JPA, DB)은 관심사가 아니며, 하위 계층인 Service는 전량 가짜 객체(`@MockBean`)로 대체하여 격리한다.
3.2 @WebMvcTest와 MockMvc
스프링 부트는 웹 계층만 떼어내어 슬라이스 테스트를 진행할 수 있도록 `@WebMvcTest`를 제공한다.
@WebMvcTest(MemberController.class)
class MemberControllerTest {
@Autowired
private MockMvc mockMvc; // HTTP 요청을 가상으로 주입해 주는 핵심 도구
@MockBean
private MemberService memberService; // 의존성 격리를 위한 Mocking
@Autowired
private ObjectMapper objectMapper; // JSON 변환기
}
- `@WebMvcTest`: `@SpringBootTest`처럼 모든 빈을 올리지 않고 컨트롤러, 필터, `@ControllerAdvice` 등 웹 관련 구성만 로드하므로 매우 가볍고 빠르다.
- MockMvc: 웹 서버를 실제로 구동하지 않고도, 내부적으로 가상의 스프링 MVC 동작을 제어하여 HTTP 요청 및 응답을 검증할 수 있게 해준다.
3.3 GET/POST API 및 JSON 응답 검증
1) GET API와 jsonPath() 검증
조회 성공 시 200 OK 상태 코드와 함께 반환되는 JSON 본문(Body) 내부의 필드를 콕 집어 검증할 때 `jsonPath()`를 사용한다.
문법의 시작인 `$`는 JSON의 루트 객체를 의미한다.
@Test
@DisplayName("회원 단건 조회 시 200 OK와 함께 회원 정보 JSON을 반환한다")
void find_member() throws Exception {
// given
given(memberService.find(1L)).willReturn(new MemberResponse("kim"));
// when & then
mockMvc.perform(get("/members/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("kim")); // JSON 응답 검증
}
2) POST API와 ObjectMapper 활용
POST나 PUT 요청은 바디에 JSON 데이터를 실어 보내야 한다.
자바 객체를 JSON 문자열로 직렬화(Serialization)하기 위해 ObjectMapper를 사용한다.
@Test
@DisplayName("새로운 회원가입 요청을 보내면 200 OK를 반환한다")
void join_success() throws Exception {
// given
MemberRequest request = new MemberRequest("kim@test.com");
String jsonContent = objectMapper.writeValueAsString(request); // 객체 -> JSON 변환
// when & then
mockMvc.perform(post("/members")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonContent)) // 가상 HTTP Request Body 주입
.andExpect(status().isOk());
}
3.4 실무 필수: Validation 및 ExceptionHandler 테스트
실무 API 개발 시 가장 빈번하게 작성되는 패턴으로, 잘못된 요청(Bad Request)을 컨트롤러 전선에서 완벽히 방어하는지 테스트한다.
1) 입력값 유효성(Validation) 테스트
// DTO에 제약 조건 설정
public record MemberRequest(
@NotBlank(message = "이메일은 필수입니다")
String email
) {}
@Test
@DisplayName("회원가입 시 이메일 값이 빈 문자열이면 400 Bad Request가 발생한다")
void join_fail_blank_email() throws Exception {
// given
String invalidJson = """
{
"email": ""
}
""";
// when & then
mockMvc.perform(post("/members")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest()); // 400 코드 검증
}
2) GlobalExceptionHandler 테스트
서비스 계층에서 비즈니스 예외가 터져 컨트롤러 위로 던져졌을 때, `@RestControllerAdvice`가 이를 가로채서 규격화된 에러 JSON 응답으로 치환해 주는지 검증한다.
@Test
@DisplayName("이미 가입된 이메일로 가입을 시도하면 예외 핸들러가 가로채어 400 코드를 반환한다")
void join_fail_duplicate_email_exception() throws Exception {
// given
given(memberService.join(any())).willThrow(new IllegalArgumentException("이미 존재하는 이메일입니다."));
String jsonContent = objectMapper.writeValueAsString(new MemberRequest("duplicate@test.com"));
// when & then
mockMvc.perform(post("/members")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonContent))
.andExpect(status().isBadRequest()) // 핸들러가 400으로 변환했는지 확인
.andExpect(jsonPath("$.message").value("이미 존재하는 이메일입니다."));
}
3.5 JSON 배열(List) 응답 검증법
여러 건의 목록 조회가 발생하여 JSON 배열(`[]`) 형태로 응답이 올 경우, 대괄호 인덱스(`$[0]`, `$[1]`)를 사용하여 각 원소의 필드를 추적할 수 있다.
// 응답 예시: [{"name":"kim"}, {"name":"lee"}]
mockMvc.perform(get("/members"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("kim"))
.andExpect(jsonPath("$[1].name").value("lee"));
⚠️ Controller 테스트 작성 시 자주 하는 실수
- 가짜 서비스에 대고 verify(repository) 호출하기: 컨트롤러 테스트 클래스 내부에는 리포지토리가 빈으로 등록되어 있지 않다. `verify()`나 `then()`은 `@MockBean`으로 선언한 서비스 객체의 행위만 추적해야 한다.
- 무겁게 @SpringBootTest 남발하기: 컨트롤러 단 한 개를 테스트하기 위해 데이터베이스 인프라와 전체 컨텍스트를 구동하는 `@SpringBootTest`를 매번 켜는 것은 테스트 속도를 둔화시키는 원인이다. 웹 계층만 기민하게 검증할 때는 가급적 `@WebMvcTest`를 표준으로 사용한다.
4. Spring Boot 통합 테스트 (Integration Test)
학습 목표
- `@SpringBootTest` 애너테이션을 활용한 전체 스프링 컨텍스트 로딩 기법 습득
- 가짜 객체(@Mock) 없이 실제 스프링 빈(Bean)들을 유기적으로 연결하는 방법 이해
- Controller ➔ Service ➔ Repository ➔ DB로 이어지는 엔드투엔드(E2E) 전체 흐름 검증
- 통합 테스트 환경에서의 @Transactional을 통한 데이터 격리 및 롤백 처리
- @ActiveProfiles를 활용한 실무형 테스트 환경(H2 등) 분리 전략 구축
- 테스트 피라미드 이론에 기반한 단위 테스트와 통합 테스트의 황금 비율 이해
4.1 통합 테스트(Integration Test)의 본질
지금까지 배운 테스트들은 특정 계층의 관심사만 정밀 타격하기 위해 의존성을 인위적으로 끊어내는 '슬라이스/단위 테스트'였다.
- Service 테스트: 리포지토리를 가짜(`@Mock`)로 대체하여 비즈니스 로직만 격리 검증
- Controller 테스트: 서비스를 가짜(`@MockBean`)로 대체하여 HTTP 매핑/JSON 스펙만 격리 검증
그러나 실제 운영 환경의 애플리케이션은 모든 계층이 단단한 사슬처럼 엮여서 동작한다.
왜 통합 테스트가 필요한가?
단위 테스트들이 각각 100점 만점으로 통과했더라도, 그것들을 하나로 조립했을 때 예기치 못한 곳에서 버그가 터질 수 있다.
- 서비스와 리포지토리는 완벽히 작동하지만, 컨트롤러가 클라이언트의 요청을 객체로 변환하는 과정(DTO 역직렬화)에서 데이터 타입이 맞지 않아 에러가 발생하는 경우
- 데이터베이스의 특정 제약조건(외래키, 인덱스 등)이 실제 구동 환경에서 충돌을 일으키는 경우
이처럼 여러 컴포넌트가 실제로 연결되었을 때 전체 시나리오가 올바르게 굴러가는지 최종 검증하는 안전장치가 바로 통합 테스트다.
4.2 @SpringBootTest를 활용한 전체 컨텍스트 로딩
통합 테스트를 수행할 때는 슬라이스 애너테이션 대신 `@SpringBootTest`를 전면에 내세운다.
@SpringBootTest
@Transactional
@ActiveProfiles("test")
class MemberIntegrationTest {
@Autowired
private MemberService memberService; // 실제 빈 주입
@Autowired
private MemberRepository repository; // 실제 빈 주입
}
- 전체 빈 생성: `@SpringBootTest`가 선언되면 스프링 부트는 실제 구동 환경과 똑같이 최상단의 `@SpringBootApplication`을 찾아가 컨트롤러, 서비스, 리포지토리, DB 커넥션 풀을 포함한 모든 스프링 빈을 통째로 생성하고 로딩한다.
- 실제 Bean의 상호작용: 이곳에는 가짜 객체(`@Mock`)가 존재하지 않는다. 실제 서비스 객체가 실제 리포지토리 객체를 호출하고, 실제 인메모리 데이터베이스에 SQL이 무겁게 내리꽂힌다.
4.3 비즈니스 및 API 통합 테스트 작성법
1) 서비스 ➔ 리포지토리 ➔ DB 전 구간 통합 테스트
@Test
@DisplayName("회원가입 비즈니스 로직을 실행하면 실제 DB에 데이터가 적재된다")
void join_integration_success() {
// given
Member member = new Member("kim@test.com");
// when (실제 서비스 호출)
memberService.join(member);
// then (실제 리포지토리로 DB 직접 조회 및 검증)
Optional<Member> found = repository.findByEmail("kim@test.com");
assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("kim@test.com");
}
2) HTTP ➔ Controller ➔ Service ➔ Repository ➔ DB 전체 API 통합 테스트
스프링 컨텍스트를 통째로 올린 상태에서 MockMvc를 결합하면 최전방 API 포트부터 최하단 DB까지 통하는 거대한 파이프라인을 단숨에 테스트할 수 있다.
@SpringBootTest
@AutoConfigureMockMvc // @SpringBootTest 환경에서 MockMvc를 사용하기 위한 설정
@Transactional
class MemberApiIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private MemberRepository repository;
@Test
@DisplayName("회원가입 API 요청을 보내면 전체 계층을 관통하여 DB에 저장되고 200을 반환한다")
void join_api_integration_success() throws Exception {
// given
String jsonRequest = "{\"email\":\"api_test@test.com\"}";
// when (HTTP 요청 발생)
mockMvc.perform(post("/members")
.contentType(MediaType.APPLICATION_JSON)
.content(jsonRequest))
.andExpect(status().isOk());
// then (실제 DB에 완전히 박혔는지 교차 검증)
Optional<Member> savedMember = repository.findByEmail("api_test@test.com");
assertThat(savedMember).isPresent();
}
}
3)실무 필수: 테스트 환경 분리 (@ActiveProfiles)
실무에서 대단히 중요한 규칙이다. 만약 로컬 개발 환경이나 실제 운영 DB(MySQL, Oracle 등)에 통합 테스트가 곧바로 붙어버리면, 테스트 실행 도중 운영 데이터가 영구 삭제되거나 조작되는 끔찍한 금융 사고로 이어진다. 따라서 통합 테스트용 환경 설정 파일(`application-test.yml`)을 별도로 격리 구축하고, 내장 인메모리 DB인 H2 Database를 바라보도록 설정해야 한다.
# src/test/resources/application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
테스트 클래스 상단에 `@ActiveProfiles("test")`를 선언해 주면, 스프링은 무조건 `application-test.yml`을 기반으로 테스트 전용 컨텍스트를 빌드하므로 안전이 완벽히 보장된다.
4.4 외부 제3자(Third-party) API 테스트 전략
통합 테스트라고 해서 모든 것을 100% 다 연결할 수는 없다. 카카오 로그인, 토스페이먼츠 결제 시스템, 알림톡 문자 발송 API 등 우리가 제어할 수 없는 외부 시스템까지 연동하면 다음과 같은 부작용이 발생한다.
- 테스트 속도가 외부 네트워크 상황에 따라 요동친다.
- 테스트를 돌릴 때마다 실제 결제 금액이나 문자 발송 비용이 청구된다.
- 외부 서버가 점검 중이면 우리 인프라가 멀쩡해도 테스트가 깨진다.
따라서 통합 테스트 환경일지라도 외부 인프라와 통신하는 콤포넌트만큼은 `@MockBean`을 활용하여 제한적으로 가짜 객체(Stub) 처리를 가하는 것이 실무 표준 전략이다.
@SpringBootTest
class OrderIntegrationTest {
@MockBean
private KakaoPaymentClient kakaoPaymentClient; // 외부 결제망은 가짜로 대체
@Test
void order_integration() {
// 외부 결제 API는 무조건 성공하는 결과를 반환하도록 고정
given(kakaoPaymentClient.requestPayment(any())).willReturn(new PaymentResponse("SUCCESS"));
// 내부 결제 + 주문 + DB 적재 흐름만 유기적으로 통합 테스트 진행
}
}
1) 실무 테스트 피라미드와 적정 비율
통합 테스트는 가치 있는 테스트 기법이지만 명확한 한계점이 존재한다. 스프링 컨텍스트와 DB를 매번 새로 무겁게 띄워야 하므로 실행 속도가 현저히 느리고, 에러가 터졌을 때 정확히 어느 계층에서 버그가 발생했는지 원인을 한눈에 추적하기 어렵다.
따라서 소프트웨어 공학에서는 위 그림과 같은 테스트 피라미드(Test Pyramid) 구조를 권장한다.
- Unit Test (단위 테스트 - 약 70%): 가볍고 빠르므로 단순 계산, 도메인 제약조건, 핵심 비즈니스 실패/예외 케이스 등을 수백 개 이상 촘촘하게 작성한다.
- Integration Test (통합 테스트 - 약 20%): 회원가입 성공, 주문/결제 프로세스 등 시스템의 핵심 뼈대가 되는 메인 비즈니스 시나리오 위주로 적정 개수만 선별하여 작성한다.
- E2E Test (약 10%): 실제 사용자 브라우저 화면부터 전체 시스템을 관통하는 최소한의 흐름만 검증한다.
5. 동시성 (Concurrency) 테스트
학습 목표
- 경쟁 상태(Race Condition)와 공유 자원(Shared Resource)의 개념 이해
- 자바 전역 락(synchronized)과 원자성 보장 클래스(AtomicInteger)의 특징 비교
- ExecutorService를 활용한 가상 멀티스레드 환경 제어
- CountDownLatch를 활용한 비동기 스레드 작업 완료 및 동시 시작 시점 동기화
- 실무에서 자주 발생하는 재고 감소, 선착순 쿠폰 발급의 동시성 테스트 코드 구현
1) 동시성 문제의 핵심: Race Condition
웹 애플리케이션은 기본적으로 멀티스레드(Multi-Thread) 환경에서 동작한다. 단일 사용자가 요청을 보낼 때는 아무런 문제가 없던 코드도, 수백 명의 사용자가 단 1초 사이에 동시에 하나의 자원을 향해 요청을 쏟아내면 데이터가 꼬이는 치명적인 에러가 발생한다. 이를 동시성 문제라 한다.
경쟁 상태 (Race Condition)
여러 스레드가 동시에 동일한 공유 자원에 접근하여 서로 값을 변경하려고 경쟁하는 현상을 의미한다.

- 가정: 상품 A의 현재 재고가 100인 상황에서 사용자 2명이 동시에 구매 버튼을 클릭한다.
- 현상:
- 스레드 A가 재고 100을 읽어간다.
- 그와 거의 동시에 스레드 B도 데이터가 수정되기 전인 재고 100을 읽어간다.
- 스레드 A가 100 - 1 = 99를 연산하여 저장한다.
- 스레드 B 역시 자기가 읽은 100을 기준으로 100 - 1 = 99를 연산하여 저장한다.
- 결과: 2명이 구매했으므로 최종 재고는 98이 되어야 마땅하나, 실제 데이터베이스에는 99로 박히는 데이터 정합성 오류가 발생한다.
public class Stock {
private int quantity;
// 단일 스레드에서는 안전하지만, 멀티스레드가 동시에 들이닥치면 Race Condition이 발생한다.
public void decrease() {
quantity--;
}
}
동시성 테스트의 주요 타깃 (공유 자원) 재고 수량, 선착순 쿠폰 잔여 개수, 게임 포인트, 은행 계좌 잔액 등 "여러 사용자가 동시에 접근하여 수치를 깎거나 더해야 하는 핵심 자원"들은 무조건 동시성 테스트 대상이 된다.
5.1 코드 레벨의 해결책: synchronized와 Atomic 클래스
1) synchronized (메서드 잠금)
메서드 선언부에 키워드를 붙이면, 해당 메서드는 한 번에 오직 하나의 스레드만 진입할 수 있도록 문을 걸어 잠근다(Lock). 후속 스레드들은 앞선 스레드가 작업을 마치고 나올 때까지 줄을 서서 대기(Blocking)하므로 Race Condition을 원천 차단한다.
public synchronized void decrease() {
quantity--;
}
- 한계점: 자바 프로세스 내부에서만 유효한 락이므로, 인프라가 확장되어 여러 대의 서버(Multi-Server 환경)로 구동될 때는 동시성을 완벽히 방어할 수 없다. (실무에서는 이 한계를 극복하기 위해 DB의 낙관적/비관적 락이나 Redis 분산 락을 결합한다.)
2) AtomicInteger (원자성 보장)
synchronized처럼 무겁게 스레드를 블로킹(Lock)하지 않고, CPU 레벨에서 원자적 연산(CAS - Compare And Swap 알고리즘)을 지원하는 전용 클래스를 사용한다. Lock 없이도 멀티스레드 환경에서 안전함(Thread-Safe)을 보장하여 실무에서 선호된다.
public class AtomicStock {
// 100개의 재고 설정
private final AtomicInteger stock = new AtomicInteger(100);
public void decrease() {
stock.decrementAndGet(); // 원자적 감소 처리 (--quantity)
}
public int getQuantity() {
return stock.get();
}
}
3) 멀티스레드 테스트의 핵심 도구 세트
멀티스레드 코드를 테스트하려면 JUnit에게 "잠시 검증(Assertion)을 멈추고 가상 스레드 100개가 작업을 끝마칠 때까지 기다려라"고 명령할 수 있어야 한다. 이때 사용하는 핵심 도구가 바로 `ExecutorService`와 `CountDownLatch`다.
1) ExecutorService (스레드 풀 유도)
스레드를 수동으로 생성하는 것은 비용이 크다. 비동기 작업을 병렬로 실행할 수 있도록 가상의 스레드 풀(Thread Pool)을 제공한다.
// 32개의 활성화된 스레드를 보유한 스레드 풀 생성
ExecutorService executor = Executors.newFixedThreadPool(32);
// 작업 제출
executor.submit(() -> { /* 비동기 로직 */ });
2) CountDownLatch (스레드 대기선)
지정한 횟수만큼 `countDown()`이 호출될 때까지 메인 스레드의 진행을 바위처럼 멈춰 세우는 동기화 도구다.
- `latch.countDown()`: 대기 숫자를 `1`씩 감소시킨다.
- `latch.await()`: 카운트가`0`이 될 때까지 메인 스레드를 대기 상태로 붙잡아둔다.
5.2 실전 재고 감소 동시성 테스트 코드
가짜 객체 없이, 순수 자바 멀티스레드를 활용하여 재고 100개를 100개의 스레드가 동시에 깎는 완전한 동시성 테스트 명세서다.
@Test
@DisplayName("100개의 재고를 가진 상품을 100명이 동시에 주문하면 최종 재고는 0이 되어야 한다")
void stock_decrease_concurrency_test() throws Exception {
// given
int threadCount = 100;
Stock stock = new Stock(100); // 초기 재고 100개 세팅
// 32개의 스레드 풀 구성
ExecutorService executor = Executors.newFixedThreadPool(32);
// 100개의 하위 비동기 작업이 모두 완료될 때까지 대기하도록 카운트 세팅
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
stock.decrease(); // 재고 감소 로직 실행
} finally {
latch.countDown(); // 작업 완료 신호 (반드시 실행 보장)
}
});
}
latch.await(); // 100개의 스레드가 모두 카운트를 다운할 때까지 메인 스레드 홀딩
// then
// synchronized나 Atomic 처리가 안 되어 있으면 Race Condition으로 인해 0이 아닌 엄뚱한 수치(예: 3, 14)가 나와 테스트가 실패한다.
assertThat(stock.getQuantity()).isEqualTo(0);
}
💡 한 걸음 더: 진짜 '동시' 시작 시점 맞추기 (Double Latch 테크닉)
위 코드는 for문을 돌며 스레드가 순차적으로 작업을 호출하므로 엄밀히 말하면 완벽하게 100개가 쾅 부딪히는 '찰나의 동시성'을 100% 묘사하기 어렵다. 이때 출발 신호용 Latch를 하나 더 두면 완벽한 동시 폭격을 재현할 수 있다.
CountDownLatch startLatch = new CountDownLatch(1); // 출발 신호 총
CountDownLatch executionLatch = new CountDownLatch(100); // 종료 체크
for (int i = 0; i < 100; i++) {
executor.submit(() -> {
try {
startLatch.await(); // 모든 스레드가 여기서 대기선에 멈춰 선다.
stock.decrease();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
executionLatch.countDown();
}
});
}
startLatch.countDown(); // 땅! 소리와 함께 100개 스레드가 일제히 튀어나간다.
executionLatch.await(); // 종료 대기
⚠️ 동시성 테스트 시 자주 하는 실수
- `Thread.sleep()`으로 대기 시간 때우기:컴퓨터 사양이나 젠킨스(CI) 인프라 상황에 따라 3초 내에 작업이 안 끝날 수도 있고, 작업이 0.1초 만에 끝났는데도 3초를 낭비하게 되므로 테스트 빌드가 극도로 느려진다. 반드시 비동기 스레드 완료 대기는 `CountDownLatch`로 정교하게 제어한다.
// 나쁜 예 executor.submit(() -> stock.decrease()); Thread.sleep(3000); // 3초 정도 기다리면 끝나겠지? - 비동기 예외 누락 처리: 비동기 스레드 내부에서 예외가 발생하면 메인 테스트 스레드는 이를 인지하지 못하고 무조건 통과할 수 있다. 반드시 `finally { latch.countDown(); }` 처리를 하여 예외가 터지더라도 카운트다운을 수행시켜 테스트가 영원히 멈추는(Deadlock) 현상을 예방해야 한다.
6. 실무 테스트 전략
학습 목표
- 비즈니스 가치와 위험도를 기준으로 '테스트할 코드'와 '하지 않을 코드' 선별
- private 메서드를 직접 검증하지 않는 객체지향적 단위 테스트 원칙 이해
- 테스트 커버리지 수치 뒤에 숨은 테스트 품질과 맹점 파악
- Fixture 및 구조화를 통해 유지보수하기 좋은 테스트 코드 자산화
- 간헐적으로 실패하는 불안정한 테스트(Flaky Test)의 원인 식별 및 제거
6.1 무엇을 테스트하고, 무엇을 방치할 것인가?
테스트 코드를 작성하는 데는 시간과 비용(유지보수 공수)이 든다.
버그 발생 가능성이 제로에 가깝거나 비즈니스 가치가 없는 코드에까지 기계적으로 테스트를 짜는 것은 자원 낭비다.
1) 반드시 테스트해야 하는 대상 (우선순위)
- 우선순위 1. 비즈니스 규칙(Core Business Logic): 회원가입, 주문 생성, 결제 처리, 선착순 쿠폰 발급, 포인트 차감 등 기업의 매출, 핵심 서비스 정체성, 혹은 금전적 손실과 직결된 도메인 로직은 최우선적으로 촘촘하게 검증한다.
- 우선순위 2. 예외 처리(Exception Handling): 실제 상용 환경에서 발생하는 장애의 80% 이상은 정상 흐름이 아닌 예외 상황을 방어하지 못해 터진다. 파라미터에 `null`이 넘어오거나, 잘못된 값이 주입되는 실패 케이스를 반드시 테스트해야 한다.
- 우선순위 3. 복잡한 분기문(Conditional Logic): if-else if가 다중으로 꼬여있거나 비즈니스 제약 조건이 복잡한 코드일수록 인간이 인지하지 못한 버그가 숨어들기 좋으므로 테스트 집중 포격 대상이 된다.
2) 테스트하지 않아도 되는 대상
- 단순 getter, setter, toString() 메서드
- 롬복(`@Getter`, `@Setter`, `@Builder`)이 자동으로 생성해 주는 코드
- 어떠한 로직도 없이 값만 실어 나르는 데이터 전송 객체(record, 단순 DTO)
이러한 코드들은 구조적으로 버그가 발생할 확률이 극히 낮다. 가치가 낮은 테스트에 시간을 쏟기보다 그 시간에 핵심 비즈니스의 엣지 케이스(Edge Case)를 하나 더 검증하는 것이 이롭다.
6.2 private 메서드 테스트 원칙
Q. 클래스 내부의 private 메서드가 너무 중요하고 복잡한데, 어떻게 테스트해야 하나요?
A. 절대로 직접 테스트하지 않는다. public API(공개 메서드)의 흐름을 통해서 우회 검증하는 것이 원칙이다.
직접 테스트하지 않는 이유
private 메서드는 외부 인터페이스가 아닌 구현의 세부사항(Implementation Details)이다. 만약 private 메서드를 테스트하겠다고 접근 제어자를 강제로 열거나 리플렉션을 쓰는 순간, 테스트 코드가 내부 구현과 강력하게 결합된다. 이는 내부 구조를 조금만 리팩토링해도 테스트가 무더기로 깨지는 주범이 된다.
private 메서드가 너무 비대해서 참을 수 없다면?
만약 private 메서드가 public 메서드의 도움 없이 독자적으로 검증받아야 할 만큼 비대해졌다면, 그것은 해당 클래스가 너무 많은 책임(SRP 위반)을 지고 있다는 강력한 설계적 적신호다. 이때는 억지로 테스트를 짤 것이 아니라, 해당 로직을 독립된 객체(예: ValidationService 등)로 분리(Extract Class)해내야 한다. 별도의 클래스로 떼어내면 자연스럽게 public 메서드가 되므로, 캡슐화를 깨뜨리지 않고 완벽하게 독립적인 단위 테스트를 수행할 수 있다.
6.3 테스트 커버리지의 함정과 올바른 해석
- 테스트 커버리지(Test Coverage): 프로덕션 코드 중 테스트 코드가 최소 한 번 이상 실행한 줄(Line)의 비율을 뜻하며, 자바 진영에서는 JaCoCo, SonarQube 등의 도구로 측정한다.
커버리지 100%의 맹점
실무에서 커버리지 수치 자체에 함몰되는 것은 매우 위험하다. 아무런 검증문(Assertion) 없이 메서드를 단순 호출만 해도 커버리지는 100%로 찍히기 때문이다.
- 가치 낮은 100%: 중요도가 낮은 단순 DTO, Getter, Setter 위주로 무의미하게 라인을 채워 달성한 커버리지 100%
- 가치 높은 60%: 핵심 결제 시스템, 복잡한 정산 할인율 산정 등 핵심 도메인 로직만 완벽하게 단언(Assertion)하여 달성한 커버리지 60%
실무 팀에서는 통상 70~80% 수준을 합리적인 목표치로 잡지만, 숫자 자체보다는 "정말 위험하고 중요한 핵심 영역이 누락 없이 튼튼하게 검증되고 있는가"라는 테스트의 품질(Quality)을 판단 기준으로 삼아야 한다.
6.4 유지보수 가능한 테스트 코드를 위한 지침
테스트 코드도 프로덕션 코드와 동일하게 지속적으로 리팩토링해야 하는 소중한 자산이다.
- 테스트 가독성 극대화: 무의미한 명명(`test1()`, `join()`)을 버리고, `@DisplayName`과 결합하여 비즈니스 의도가 드러나게 작성한다. (`중복된_이메일이면_예외가_발생한다`)
- Given-When-Then 기치 사수: 주석을 통해 단계를 명확히 시각적으로 격리해 두어야 수개월 뒤 코드를 다시 읽을 때 분석 비용이 감소한다.
- Fixture 오브젝트의 적극적 도입: 테스트마다 `new Member("kim", "kim@test.com", "010-...")`를 수십 줄씩 반복 호출하면 생성자 스펙이 바뀔 때 모든 테스트가 깨진다. 테스트용 가짜 데이터를 중앙 집권식으로 생성해 주는 Fixture 팩토리 클래스를 구축하여 중복을 격리한다.
- 단일 책임 검증: 하나의 테스트 메서드 안에서 회원가입, 로그인, 주문, 장바구니 담기를 동시에 검증하지 않는다. 하나의 테스트는 오직 하나의 시나리오 완수에만 집중해야 한다.
6.5 개발자의 주적: Flaky Test (불안정한 테스트) 방지
Flaky Test란 동일한 소스 코드 상태임에도 불구하고 어제는 성공하고, 오늘은 실패하며, 내일은 다시 성공하는 등 결과가 간헐적으로 요동치는 불안정한 테스트를 말한다. Flaky Test가 빌드 파이프라인에 쌓이기 시작하면 개발자는 테스트 자체를 신뢰하지 않게 되어 테스트 시스템 전체가 마비된다.
대표적인 원인과 실무 해결책
- 가상 스레드 대기 제어 미흡 (`Thread.sleep()` 사용 금지)
- 원인: 컴퓨터 인프라가 느려지면 지정한 시간 내에 비동기 작업이 끝나지 않아 깨진다.
- 해결: 무조건 고정 시간을 소모하는 `Thread.sleep()`을 전면 제거하고, 바로 직전에 배운 `CountDownLatch`나 `Awaitility` 같은 정교한 비동기 동기화 도구를 사용한다.
- 예측 불가능한 제어권 (`LocalDateTime.now()` 사용 금지)
- 원인: 마일리지 만료 테스트 시 현재 시간에 의존하면 연도나 달이 바뀌는 시점에 테스트가 실패한다.
- 해결: 서비스 레이어가 자바 시스템 시계에 직접 의존하지 않도록 고정된 시간(`Clock.fixed()`)을 외부에서 주입할 수 있도록 리팩토링한다.
- 난수 처리 (new Random() 사용)
- 원인: 매번 실행 시마다 다른 값이 튀어 나가 단언문이 깨진다.
- 해결: 랜덤 시드(Seed)를 상수로 고정(`new Random(42)`)하여 주입하거나 Mocking을 활용해 예측 가능한 난수를 반환하도록 제어한다.
실무 테스트 피라미드 전략 총정리
실무 테스트 전략의 종착지는 결국 테스트 피라미드의 균형을 맞추는 일이다. 속도가 빠르고 인프라 비용이 들지 않는 단위 테스트를 하단에 가장 많이(약 70%) 배치하고, 계층 간 유기적 결합도를 보는 통합 테스트를 중간에 적당히(약 20%) 섞으며, 시스템 전체의 통신을 보는 E2E 테스트를 최상단에 최소한(약 10%)으로 가져가는 배치가 가장 이상적이고 지속 가능한 구조다.
'🍃SpringBoot' 카테고리의 다른 글
| JPA [2/4] : 엔티티 매핑과 연관관계 (0) | 2026.06.23 |
|---|---|
| JPA [1/4] : JPA 시작과 영속성 컨텍스트 (0) | 2026.06.23 |
| JUnit [1/2]: 테스트 개념과 JUnit, Mockito 사용법 (0) | 2026.06.22 |
| 카카오 로그인 구현(실습): Spring Boot + React 소셜 로그인 구현 및 JWT 인증 연동(Google, Github 포함) (0) | 2026.06.19 |
| Java Time API: JWT와 Audit으로 배우는 실전 시간 정책 (0) | 2026.06.16 |
