[Spring] 테스트 코드 도입기 - JUnit과 Mockito를 활용한 단위 테스트

2026. 5. 1. 20:30·Backend/Spring

1. 배경 및 목표

1) 배경

저는 스타트업에서 혼자 백엔드를 개발하고 있습니다.

스타트업 특성상 빠른 기능 구현과 잦은 명세 변경에 따른 리팩토링이 반복되는 환경입니다.


안정성을 중요하게 생각하지만, 혼자 개발하는 만큼 놓치는 케이스가 생기지 않을까 불안함이 있었고,

이를 개발 서버에서 배포 전 최대한 많은 테스트를 해보는 것으로 달래곤 했습니다.


최근 Claude Code를 도입하면서 작업속도가 향상되었고, 확보된 시간을 안정성에 투자할 수 있겠다고 판단했습니다.

테스트 코드를 도입하면 다음과 같은 이점을 얻을 수 있습니다.

  • 작성한 코드가 의도한대로 동작하는지 검증 가능
  • 리팩토링 등 코드 수정 후에도 기능이 정상적으로 동작하는지 검증할 수 있음
  • 테스트 코드를 통해 해당 기능이 어떤 목적을 지니고, 어떻게 동작하는지 문서화 할 수 있음

이러한 이유로 테스트 코드를 도입하여 검증 방식을 개선하고자 했습니다.


2) 목표

구체적인 목표는 다음과 같습니다.

  1. 새로운 기능 구현은 TDD 적용 - Red, Green, Refactor 순서
  2. 기존 코드 중 핵심 비즈니스 로직 부터 테스트 코드 도입
  3. CI/CD 파이프라인 연동

기존 코드의 우선순위는 메인 서비스와 Prometheus로 파악한 사용량이 높은 도메인 순서로 정했습니다.

도메인 단위로 테스트 코드가 완료될 때마다 운영 코드에 반영하고, CI/CD 과정 중 테스트를 실행하도록 하였습니다.

테스트가 실패하면 배포 자체가 차단되기 때문에 잘못된 코드가 운영 환경에 반영될 가능성을 줄일 수 있습니다.


 

2. 개념

1) 테스트 종류

테스트는 범위와 목적에 따라 크게 세가지로 나뉩니다.

  • 단위 테스트(Unit Test)
    • 하나의 클래스 또는 메서드를 독립적으로 검증
    • 인터페이스, 외부적 I/O, 자료구조, 오류 처리 경로, 경계조건 등을 검사
    • 외부 의존성은 Mock으로 대체하여 테스트 대상 로직 자체에만 집중
    • 실행 속도가 빠르고 실패 원인을 특정하기 쉬움
  • 통합 테스트(Integration Test)
    • 단위 테스트가 완료된 모듈들을 결합하여 하나의 시스템으로 완성시키는 과정에서의 테스트
    • 실제 DB나 외부 시스템과 연동하여 테스트하기 때문에 단위 테스트보다 실행 속도가 느림
  • E2E 테스트(End To End Test)
    • 사용자 관점에서 전체 흐름을 검증
    • 실제 환경과 가장 유사하지만 구축 비용과 실행 시간이 가장 큼

이번 글에서는 단위 테스트를 중심으로 다루겠습니다.



2) JUnit

JUnit는 테스트를 실행하고 결과를 검증하는 프레임워크로 Java 진영에서 널리 사용됩니다.

spring-boot-starter-test의 의존성에 기본으로 포함되어 있으며, 레퍼런스가 풍부하기에 선택했습니다.


주요 어노테이션은 다음과 같습니다.

 

  • @Test: 테스트 메서드임을 명시
  • @DisplayName: 테스트 이름을 명시적으로 작성 가능
  • @Nested: 관련 테스트를 그룹으로 묶어 계층 구조로 관리
  • @BeforeEach / @AfterEach: 각 테스트 실행 전후에 공통 로직 수행

 

@Nested를 활용하면 하나의 메서드에 대한 테스트를 아래처럼 묶어서 관리할 수 있어 가독성이 높아집니다.

@Nested
@DisplayName("getMyWallet - 본인 지갑 조회")
class GetMyWallet {

    @Test
    void 무료_잔액이_있으면_usable_true() { ... }

    @Test
    void 지갑이_존재하지_않으면_예외를_던진다() { ... }
}



3) Mockito

Mockito는 Java의 Mocking 프레임워크입니다.

JUnit과 마찬가지로 spring-boot-starter-test에 기본 포함되어 있으며, 널리 사용되기에 채택했습니다.


Mock이란 실제 객체를 대신하는 가짜 객체입니다. 테스트에서 실제 DB에 연결된 Repository를 그대로 사용하면 테스트가 외부환경에 의존하게 됩니다. Mock으로 대체하면 서비스 로직 자체만 순수하게 검증할 수 있습니다.


주요 사용방법은 다음과 같습니다.

 

  • @ExtendWith(MockitoExtension.class): Mockito와 JUnit을 연동
  • @Mock: 가짜 객체 생성
  • @InjectMocks: @Mock으로 생성된 객체를 주입받을 테스트 대상 클래스 생성
  • given().willReturn(): Mock 객체의 동작을 정의 (BDDMockito 스타일)
  • verify(): 특정 메서드가 호출되었는지 검증
  • ArgumentCaptor: 메서드에 전달된 인자를 캡처하여 값 검증
@ExtendWith(MockitoExtension.class)
class TicketServiceTest {

    @Mock
    TicketWalletRepository ticketWalletRepository;

    @InjectMocks
    TicketService ticketService;
}

 

 



4) MockedStatic - static 메서드의 Mocking

static 메서드를 Mocking 해야할 일이 생길 수 있습니다.

public class SecurityHelper {
    
    public static Optional<Account> getAccountFromContext() throws CustomAuthenticationException {
        try {
            Account user = (Account) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
            return Optional.of(user);
        } catch (Exception e) {
            return Optional.empty();
        }
    }
    
    public static Account getAccount() {
        return getAccountFromContext().orElseThrow(() -> new UsernameNotFoundException("잘못된 계정정보입니다."));
    }

    public static Long getAccountIdOrNull() {
        return getAccountFromContext()
                .map(Account::getId)
                .orElse(null);
    }

 

저는 SecurityHelper라는 Class에서 static메서드인 getAccount()로 토큰 속 유저정보를 가져오고 있습니다.

이런 static 메서드는 @Mock으로 Mocking이 불가능합니다.

Mock 라이브러리는 런타임에 인터페이스를 구현하거나 해당 클래스를 상속하는 방법, 즉 Proxy를 통해 Mock객체를 생성하는데 static 메서드는 상속, 구현에서 사용할 수 없기 떄문입니다.


이런 상황에서는 Mockito의 mockStatic()을 사용하면 static 메서드도 Mocking할 수 있습니다.

MockedStatic<SecurityHelper> securityHelperMock;

@BeforeEach
void setUp() {
    securityHelperMock = Mockito.mockStatic(SecurityHelper.class);
}

@AfterEach
void tearDown() {
    securityHelperMock.close();
}

 

mockStatic은 내부적으로 바이트 코드를 직접 조작해서 static 메서드를 가로채는 방식이기에 가능합니다.


단, MockedStatic은 반드시 @AfterEach에서 close()를 호출해야 합니다. JVM 레벨에서 동작하기 떄문에 바이트 코드가 변경된 채로 남아있으면 다른 테스트에서도 조작된 상태가 유지될 수 있기 떄문입니다.



3. 구현

이번 글에서 예시로 사용하는 도메인은 Ticket입니다.

티켓은 특정 기능을 호출할 때 소비되며, 각 사용자는 무료/유료 티켓으로 구성된 TicketWallet을 가지고 있습니다.

무료 티켓을 먼저 사용하고, 없으면 유료 티켓에서 차감. 둘 다 없으면 예외를 던집니다.


테스트 메서드의 이름은 한글로 작성했습니다.

여러 IT기업에서 한글 메서드를 사용하고 있는것을 확인하였고, 문서로서의 가독성이 한글이 높다고 판단하여 진행했습니다.


1) 도메인 단위 테스트

테스트 대상은 TicketWallet.consume() 입니다.

// TicketWallet.class

public TicketType consume() {
    if (freeBalance > 0) {
        freeBalance--;
        return TicketType.FREE;
    }
    if (paidBalance > 0) {
        paidBalance--;
        return TicketType.PAID;
    }
    throw new InsufficientTicketException();
}

 

이 메서드는 분기가 3개입니다. 무료 차감, 유료 차감, 예외 발생을 각각 독립적인 테스트로 작성했습니다.

@Test
void 무료_잔액이_있으면_무료부터_차감() {
    TicketWallet wallet = TicketWallet.builder()
            .freeBalance(3)
            .paidBalance(5)
            .ticketPolicy(TicketPolicy.CUSTOMER_FREE)
            .build();

    TicketType used = wallet.consume();

    assertThat(used).isEqualTo(TicketType.FREE);
    assertThat(wallet.getFreeBalance()).isEqualTo(2);
    assertThat(wallet.getPaidBalance()).isEqualTo(5);
}

@Test
void 무료_잔액이_없으면_유료_차감() {
    TicketWallet wallet = TicketWallet.builder()
            .freeBalance(0)
            .paidBalance(5)
            .ticketPolicy(TicketPolicy.CUSTOMER_FREE)
            .build();

    TicketType used = wallet.consume();

    assertThat(used).isEqualTo(TicketType.PAID);
    assertThat(wallet.getFreeBalance()).isEqualTo(0);
    assertThat(wallet.getPaidBalance()).isEqualTo(4);
}

@Test
void 무료_유료_둘다_없으면_예외() {
    TicketWallet wallet = TicketWallet.builder()
            .freeBalance(0)
            .paidBalance(0)
            .ticketPolicy(TicketPolicy.CUSTOMER_FREE)
            .build();

    assertThatThrownBy(wallet::consume)
            .isInstanceOf(InsufficientTicketException.class);
}

 

외부 의존성이 전혀 없기 때문에 Mock 없이 순수하게 도메인 규칙만 검증합니다.

consume() 하나에 대한 테스트지만 분기별로 케이스를 분리하여 어떤 케이스가 실패했는지 즉시 파악할 수 있습니다.


2) 서비스 단위 테스트

테스트 대상은 grantTicket() 메서드 입니다.

관리자가 사용자에게 티켓을 부여하는 메서드 입니다.

@Transactional
public void grantTicket(Long accountId, GrantTicketRequest request) {
    Account account = accountReadService.getAccount(accountId);
    TicketWallet ticketWallet = ticketWalletRepository.findByAccountForUpdate(account)
            .orElseThrow(TicketWalletNotFoundException::new);

    int count = request.getCount();
    TicketType ticketType = request.getTicketType();

    switch (ticketType) {
        case FREE -> ticketWallet.fillFreeTicket(count);
        case PAID -> ticketWallet.fillPaidTicket(count);
    }

    ticketHistoryRepository.save(
            TicketHistory.adminGrant(account, ticketType, count)
    );
}

 

이 메서드에서 검증해야할 것은 두가지입니다.

 

  • 티켓 타입에 따라 잔액이 올바르게 증가했는지
  • TicketHistory가 올바른 값으로 저장되었는지

잔액 검증은 assertThat으로 충분하지만,
ticketHistoryRepository.save()에 전달된 객체의 필드값을 검증하려면 ArgumentCaptor가 필요합니다.

 

@Test
void 무료_티켓_부여시_무료_잔액이_지정한_개수만큼_증가하고_ADMIN_GRANT_history_가_저장된다() {
    Long accountId = 10L;
    given(accountReadService.getAccount(accountId)).willReturn(account);
    GrantTicketRequest request = new GrantTicketRequest(TicketType.FREE, 2);
    TicketWallet wallet = wallet(3, 5, null);
    given(ticketWalletRepository.findByAccountForUpdate(account)).willReturn(Optional.of(wallet));

    ticketService.grantTicket(accountId, request);

    assertThat(wallet.getFreeBalance()).isEqualTo(5);
    assertThat(wallet.getPaidBalance()).isEqualTo(5);

    ArgumentCaptor<TicketHistory> historyCaptor = ArgumentCaptor.forClass(TicketHistory.class);
    verify(ticketHistoryRepository).save(historyCaptor.capture());
    assertThat(historyCaptor.getValue().getEventType()).isEqualTo(TicketEventType.ADMIN_GRANT);
    assertThat(historyCaptor.getValue().getTicketType()).isEqualTo(TicketType.FREE);
    assertThat(historyCaptor.getValue().getAmount()).isEqualTo(2);
}

 

 

verify(ticketHistoryRepository).save(historyCaptor.capture())는 save()가 호출되었는지 검증하는 동시에, 전달된 인자를 캡처합니다. 이후 historyCaptor.getValue()로 저장된 객체의 필드 값을 직접 확인할 수 있습니다.

단순히 verify로 호출 여부만 확인하는 것이 아니라 저장된 객체의 값까지 검증한다는 점에서 신뢰도 높은 테스트가 됩니다.

 

'Backend > Spring' 카테고리의 다른 글

[Spring] PageImpl 역직렬화를 위한 Wrapper 클래스, PageImpl Redis 캐싱  (1) 2026.04.17
[Spring] 1 대 1 실시간 채팅 구현하기 - Stomp, MongoDB, Redis  (0) 2026.01.24
[Spring] Redis Sorted Set, ZSet 을 이용한 매칭 시스템 구현하기  (0) 2025.10.15
[Spring] 로컬, AWS LightSail에서 AWS parameter store로 환경변수 관리하기  (0) 2025.07.19
[Spring] Spring AOP의 동작원리, JDK Dynamic Proxy와 CGLIB  (1) 2025.04.28
'Backend/Spring' 카테고리의 다른 글
  • [Spring] PageImpl 역직렬화를 위한 Wrapper 클래스, PageImpl Redis 캐싱
  • [Spring] 1 대 1 실시간 채팅 구현하기 - Stomp, MongoDB, Redis
  • [Spring] Redis Sorted Set, ZSet 을 이용한 매칭 시스템 구현하기
  • [Spring] 로컬, AWS LightSail에서 AWS parameter store로 환경변수 관리하기
단군왕건영
단군왕건영
널리 세상을 이롭게 하고 싶은 개발자
  • 단군왕건영
    홍익인간 개발자
    단군왕건영
  • 전체
    오늘
    어제
    • 분류 전체보기 (90)
      • TroubleShooting (16)
      • Backend (13)
        • Java (2)
        • Spring (9)
        • JPA (2)
      • DB (1)
      • Algorithm (7)
        • 백준 (4)
      • Infra (3)
      • CS (40)
        • 컴퓨터구조 (25)
        • 네트워크 (12)
        • 운영체제 (3)
      • Git (3)
      • Mac (2)
      • 회고 (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • GitHub
  • 공지사항

  • 인기 글

  • 태그

    spring
    MariaDB
    컴퓨터 구조
    docker
    백준
    컴퓨터구조
    springboot
    java
    네트워크
    Jenkins
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
단군왕건영
[Spring] 테스트 코드 도입기 - JUnit과 Mockito를 활용한 단위 테스트
상단으로

티스토리툴바