스마일게이트사의 로스트아크 Open API를 활용하여 토이 프로젝트를 진행하던 중, RestTemplate를 이용한 API 요청이 429 응답 코드로 실패하는 문제가 발생하였습니다.
트러블 슈팅 중 공식 문서를 확인해 보니, 분당 100회 요청 제한이 원인이었습니다.
Lostark OpenAPI Developer Portal
Clients are limited to fire 100 requests per a minute. Exceeding the minute quota results in 429 response until we reset the quota. We automatically renew the quota every minute, so you can expect your application would work after a minute once you hit the limit.
이러한 제한은 아래의 의문을 들게 하였습니다.
이로 인해 제가 하던 테스트는 '단위 테스트'가 아니라 외부 API에 의존하는 '통합 테스트'였다는 것을 깨달았습니다.
이번 포스팅에서는 RestTempalte을 Mocking하여 단위 테스트를 어떻게 구성했는지 공유해 보고자 합니다.
기존의 서비스 코드는 다음과 같았습니다.
@Slf4j
@Service
public class LostArkApiService {
@Value("${lost-ark-api-url}")
private String lostArkApiUrl;
@Value("${lost-ark-api-token}")
private String lostArkApiToken;
private static final String BEARER = "Bearer ";
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
public LostArkApiService(RestTemplate restTemplate, ObjectMapper objectMapper) {
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
}
public ProfileDto getProfiles(String characterName) {
String url = UriComponentsBuilder.fromHttpUrl(this.lostArkApiUrl)
.path(characterName)
.path("/profiles")
.build()
.toUriString();
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, BEARER + this.lostArkApiToken);
HttpEntity<String> httpEntity = new HttpEntity<>(headers);
String body = this.restTemplate.exchange(url, HttpMethod.GET, httpEntity, String.class).getBody();
if (Objects.equals(body, "null")) {
throw new CharacterNotFoundException(characterName + "에 해당하는 캐릭터가 없습니다.");
}
try {
return this.objectMapper.readValue(body, ProfileDto.class);
} catch (JsonProcessingException e) {
log.error("JsonProcessingException", e);
return null;
}
}
}
서비스 코드의 흐름은 다음과 같습니다.
처음 작성했던 위 서비스 코드에 대한 테스트 코드 입니다.
@SpringBootTest
@ActiveProfiles("test")
class LostArkApiServiceTest {
@Autowired
LostArkApiService lostArkApiService;
@DisplayName("프로필 조회에 성공하는 테스트")
@Test
void getProfiles() {
String characterName = "성공하는_아이디";
// given
ProfileDto profileDto = this.lostArkApiService.getProfiles(characterName);
// when
int length = profileDto.getStats().toArray().length;
// then
assertThat(length).isEqualTo(8);
}
@DisplayName("프로필 조회에 실패하는 테스트")
@Test
void getProfilesFail() {
// given
String characterName = "실패하는_아이디";
// when & then
assertThatThrownBy(() -> this.lostArkApiService.getProfiles(characterName))
.isInstanceOf(CharacterNotFoundException.class)
.hasMessage(characterName + "에 해당하는 캐릭터가 없습니다.");
}
}
위 테스트 코드를 작성하고 초록불이 들어오는 것을 확인한 뒤 뿌듯했으나 문제점이 있었습니다.
로 부터 의문이 들기 시작하였고, 결국 위와 같은 테스트 방식은 올바르지 않다는 것을 알게 되었습니다.
이 문제를 해결하기 위해 @MockBean
어노테이션을 사용하여 RestTemplate을 Mocking하는 방법으로 개선하였습니다.
서비스 코드의 흐름 중 외부 서비스에 의존하는
RestTemplate를 이용하여 로스트아크 Open API에 GET 요청을 보낸뒤 응답을 받는다.
부분을 when
과 then
을 이용하여 직접 제어할 수 있게 변경하였습니다.
개선된 코드는 다음과 같습니다.
@SpringBootTest
@ActiveProfiles("test")
class LostArkApiServiceMockTest {
@Autowired
LostArkApiService lostArkApiService;
@MockBean
RestTemplate restTemplate;
String path = "src/test/java/me/minkh/app/";
@DisplayName("프로필 조회에 성공하는 테스트")
@Test
void getProfiles() throws IOException {
// given
String characterName = "성공하는_아이디";
String profile = new String(Files.readAllBytes(Paths.get(path + "profile.json")));
// when
when(restTemplate.exchange(anyString(), any(), any(), eq(String.class)))
.thenReturn(ResponseEntity.ok(profile));
LostArkProfilesResponse lostArkProfilesResponse = this.lostArkApiService.getProfiles(characterName);
// then
assertThat(lostArkProfilesResponse.getStats().size()).isEqualTo(8);
}
핵심은 이 부분입니다.
String profile = new String(Files.readAllBytes(Paths.get(path + "profile.json")));
when(restTemplate.exchange(anyString(), any(), any(), eq(String.class)))
.thenReturn(ResponseEntity.ok(profile));
RestTemplate를 이용하여 exchange
메서드를 호출하면 응답값으로 상태코드 200과 메시지 바디로 profile
를 반환하도록 모킹해 주었습니다.
문제점에서 걱정한, 외부 서버에 의존하는 방식이 아니기 때문에 1만번을 호출해도 상관이 없고 로스트아크 서버가 다운되도 테스트에는 영향이 없습니다.
또한 이렇게 개선된 코드는 로스트아크 Open API의 다양한 상태 코드에도 대응할 수 있게 해줍니다.
@DisplayName("인증 토큰이 올바르지 않을 때, 실패하는 테스트")
@Test
void getProfiles401() {
// given
String characterName = "성공하는_아이디";
// when & then
when(restTemplate.exchange(anyString(), any(), any(), eq(String.class)))
.thenThrow(new HttpClientErrorException(HttpStatus.UNAUTHORIZED));
assertThatThrownBy(() -> this.lostArkApiService.getProfiles(characterName))
.isInstanceOf(HttpClientErrorException.class);
}
이와 같이, 다양한 상태 코드에 대한 테스트를 수행함으로써 전역 예외 처리가 제대로 작동하는지 확인할 수도 있습니다.
이번 경험을 통해 '단위 테스트'와 '통합 테스트'의 차이점을 명확히 이해할 수 있었습니다.
예전에는 사실 모킹이라는 것이 단순히 "내가 A라고 설정하면 B라고 반환되게 해줘"라고 하면 무슨 소용이지? 라는 생각을 가지고 있었는데요.
비즈니스 로직에서 일부 외부 환경에 의존할 수 밖에 없는 부분은 잠시 모킹하고 다른 부분을 검증하기 위해 꼭 필요한 과정이라는 것을 알게된 소중한 경험이었습니다.