Spring + Mockito 외부 API 클라이언트 단위 테스트 구조 정리

송현진·2025년 5월 20일
0

Spring Boot

목록 보기
18/23

프로젝트에서 외부 쇼핑몰 API(Naver 쇼핑 오픈API)를 연동한 NaverApiClient 클래스를 구현했고 이 클래스에 대한 단위 테스트를 작성했다. @SpringBootTest를 붙여 통합 테스트로 구성하는 대신 Mockito를 활용한 순수 단위 테스트로 작성해 성능과 유지보수 측면에서 최적화된 테스트 구조를 구성하고자 했다.

테스트 구조 및 구성 요소

@Mock

@Mock
private RedisQuotaManager quotaManager;

@Mock
private RestTemplate restTemplate;

@Mock은 테스트 대상 객체가 의존하는 외부 클래스의 가짜(mock) 객체를 만들어 주는 어노테이션이다. RedisQuotaManagerRestTemplate처럼 외부 요청이나 내부 상태가 필요하거나 불안정한 의존성은 진짜 인스턴스를 쓰면 테스트가 불안정해진다. 따라서 동작만 시뮬레이션하는 가짜 객체를 만들어 테스트 흐름을 제어할 수 있다.

@InjectMocks

@InjectMocks
private NaverApiClient naverApiClient;

@InjectMocks는 테스트 대상 객체를 생성하면서 위에서 만든 @Mock 객체들을 해당 클래스의 생성자나 필드에 자동으로 주입해준다. NaverApiClient는 실제로 RedisQuotaManager, RestTemplate에 의존하므로 이 어노테이션을 통해 테스트 대상 객체가 자동으로 완성된다. 즉, 테스트 대상 객체를 수동으로 생성하고 의존성을 주입하지 않아도 되도록 도와주는 어노테이션이다.

MockitoAnnotations.openMocks(this)

@BeforeEach
void setUp() {
    MockitoAnnotations.openMocks(this);
    TestReflection.setField(naverApiClient, "clientId", "test-client-id");
    TestReflection.setField(naverApiClient, "clientSecret", "test-client-secret");
}

이 코드는 @Mock, @InjectMocks 등의 어노테이션을 인식하고 실제 mock 객체들을 초기화한다. JUnit 5 기준에서는 @ExtendWith(MockitoExtension.class)로 대체할 수도 있지만 지금 구조처럼 수동으로 초기화할 때는 반드시 openMocks(this)를 호출해줘야 한다.

@BeforeEach로 설정하여 매 테스트마다 깨끗한 상태로 mock 환경을 리셋하는 것도 안정성을 위한 좋은 습관이다.

TestReflection.setField(...)

TestReflection.setField(naverApiClient, "clientId", "test-client-id");
TestReflection.setField(naverApiClient, "clientSecret", "test-client-secret");

NaverApiClientclientId, clientSecret@Value로 외부 설정 파일에서 주입받는 필드다. 하지만 Mockito 기반 테스트에서는 Spring Context를 띄우지 않기 때문에 @Value가 동작하지 않는다. 이를 해결하기 위해 Java Reflection을 사용해 private 필드에 직접 값을 주입했다.

TestReflection 유틸 클래스 설명

static class TestReflection {
    public static void setField(Object target, String fieldName, Object value) {
        try {
            Field f = target.getClass().getDeclaredField(fieldName);
            f.setAccessible(true);
            f.set(target, value);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

이 유틸 클래스는 Reflection을 사용해 private 필드에 직접 접근하고 값을 변경하는 기능을 제공한다. Spring 없이 테스트할 때 @Value가 동작하지 않으므로 clientId, clientSecret을 강제로 주입하기 위해 사용되었다. 이는 Mockito 기반 테스트에서 꽤 흔히 쓰이는 방식으로 Spring Context를 띄우지 않고도 필요한 설정값만 주입할 수 있어 테스트 속도와 독립성을 높이는 데 효과적이다.

RestTemplate 주입 문제와 해결

문제 상황

처음엔 NaverApiClient 내부에서 아래처럼 RestTemplate을 직접 생성하고 있었다.

private final RestTemplate restTemplate = new RestTemplate();

이렇게 되면 Mockito가 생성한 @Mock RestTemplate 객체가 주입되지 않고 exchange() 호출 시 실제 외부 API 호출이 발생할 수도 있다. 즉, 아래와 같은 stub은 전혀 동작하지 않게 된다.

when(restTemplate.exchange(...)).thenReturn(mockResponse);

해결 방법

아래처럼 RestTemplate을 외부에서 주입받도록 변경했다.

private final RestTemplate restTemplate;

그리고 실제 프로젝트에서 다음과 같이 Bean으로 등록했다.

@Configuration
public class RestTemplateConfig {
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

이제 테스트에서는 @Mock RestTemplate이 정상적으로 동작하며 exchange() 호출을 자유롭게 시뮬레이션할 수 있게 되었다.

테스트 케이스 해석

성공 테스트 - testSearch_success

when(quotaManager.canCall()).thenReturn(true);

quotaManager.canCall() 호출 시 true를 리턴하도록 미리 정의하는 것이다.

when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(JsonNode.class)))
        .thenReturn(responseEntity);

RestTemplate.exchange(...) 메서드 호출 시 우리가 만든 가짜 responseEntity를 리턴하게 설정한다.

any(...) - 인자의 값은 무시하고 해당 타입이면 매칭
eq(value) - 해당 정확한 값일 경우에만 매칭

여기선 URI와 HttpEntity는 아무거나 와도 되고 Method는 반드시 GET, 결과 타입은 JsonNode.class로 설정된 것과 일치해야 한다.

assertThat(result).hasSize(1);
assertThat(result.get(0).title()).isEqualTo("테스트 상품");

응답으로 나온 결과 리스트에 아이템이 정확히 하나 있고 JSON 파싱 결과가 잘 동작했는지를 검증한다.

실패 테스트 - testSearch_quotaExceeded

when(quotaManager.canCall()).thenReturn(false);

Redis quota가 초과된 상황을 가정한다.

assertThatThrownBy(() -> naverApiClient.search("테스트", 1, 10))
        .isInstanceOf(ErrorException.class)
        .hasMessage(ExceptionEnum.QUOTA_EXCEEDED.getMessage());

이 상황에서는 search() 메서드가 예외를 발생시켜야 하므로 해당 예외가 정확히 발생하는지 확인한다.

전체 코드

@ActiveProfiles("test")
class NaverApiClientTest {
    static class TestReflection {
        public static void setField(Object target, String fieldName, Object value) {
            try {
                Field f = target.getClass().getDeclaredField(fieldName);
                f.setAccessible(true);
                f.set(target, value);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }

    @Mock
    private RedisQuotaManager quotaManager;

    @Mock
    private RestTemplate restTemplate;

    @InjectMocks
    private NaverApiClient naverApiClient;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
        TestReflection.setField(naverApiClient, "clientId", "test-client-id");
        TestReflection.setField(naverApiClient, "clientSecret", "test-client-secret");
    }

    @DisplayName("네이버 API에서 정상적으로 상품을 파싱할 수 있다.")
    @Test
    void testSearch_success() throws Exception {
        // given
        when(quotaManager.canCall()).thenReturn(true);

        String json = """
        {
          "items": [
            {
              "title": "테스트 상품",
              "link": "http://example.com",
              "image": "http://example.com/image.jpg",
              "lprice": "12345",
              "mallName": "테스트몰"
            }
          ]
        }
        """;

        JsonNode mockResponse = new ObjectMapper().readTree(json);

        ResponseEntity<JsonNode> responseEntity = new ResponseEntity<>(mockResponse, HttpStatus.OK);
        when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(JsonNode.class)))
                .thenReturn(responseEntity);

        // when
        List<ProductResponseDto> result = naverApiClient.search("테스트", 1, 10);

        // then
        assertThat(result).hasSize(1);
        assertThat(result.get(0).title()).isEqualTo("테스트 상품");
        assertThat(result.get(0).lprice()).isEqualTo(12345);
    }

    @DisplayName("쿼터 초과 시 예외가 발생해야 한다.")
    @Test
    void testSearch_quotaExceeded() {
        // given
        when(quotaManager.canCall()).thenReturn(false);

        // when then
        assertThatThrownBy(() -> naverApiClient.search("테스트", 1, 10))
                .isInstanceOf(ErrorException.class)
                .hasMessage(ExceptionEnum.QUOTA_EXCEEDED.getMessage());
    }
}

회고

이번 테스트를 통해 가장 크게 느낀 점은 테스트의 본질은 빠르고 정확한 피드백을 얻는 것이라는 점이다. @SpringBootTest를 통해 전체 애플리케이션 컨텍스트를 띄우는 방식은 통합 테스트에는 적합하지만 단위 테스트 수준에서는 과도한 비용과 복잡성을 유발할 수 있다. 특히 단순해 보이는 RestTemplate조차 직접 생성했는지 빈으로 주입받았는지에 따라 Mockito의 Mock 객체가 주입되지 않을 수 있다는 점은 중요한 포인트였다. 이번 경험을 통해 Spring의 DI 방식과 Mockito의 Mock 주입 방식이 서로 다르다는 점을 이해하게 되었고 테스트가 실패했을 때 단순히 stub이 잘못된 것이 아니라 Mock 자체가 주입되지 않았을 가능성도 반드시 고려해야 함을 배웠다.

따라서 앞으로는 다음과 같은 원칙을 테스트 설계의 기본 전략으로 삼고자 한다.

  • 테스트 대상 객체의 모든 외부 의존성은 반드시 생성자 주입 방식(DI)으로 설계할 것
  • 외부 설정값(@Value)이 필요한 경우에는 Reflection이나 테스트 전용 설정 객체를 통해 명시적으로 주입할 것
  • Context를 띄우지 않고도 빠르게 검증 가능한 순수 단위 테스트 구조를 최우선으로 고려할 것

이러한 구조는 테스트 속도를 빠르게 유지할 수 있을 뿐 아니라 불필요한 의존성과 환경 설정 문제로부터 테스트를 분리해줌으로써 더 견고하고 유지보수하기 쉬운 테스트 환경을 만드는 데 큰 도움이 된다. 이번 경험은 테스트가 어떻게 설계되어야 하는지를 고민할 수 있었던 의미 있는 과정이었다.

profile
개발자가 되고 싶은 취준생

0개의 댓글