개인 깃허브에서 Riot API를 사용하여 리그오브레전드 게임 관련 어플리케이션을 만들고 있었다. 하지만 API가 어떻게 만들어졌는지 모르겠지만.. 정상적인 API 요청을 보내도 실패할때가 있고 몇 초 뒤에 아무렇지 않게 성공 응답을 반환한다. 😱 뼈져리게 Retry의 필요성을 느꼈다. 해당 기능을 개발하면서 비정상적인 응답이 왔을때 Retry, 예외 처리에 대한 테스트 코드를 작성하기 위해 응답 값 모킹이 필요했고, 이를 위해 WireMock을 사용하게 되었다. 작성한 테스트 및 고려한 사항들에 대해 공유하고자 한다.
본 글은 spring boot, gradle, java를 사용하고 http client는 feign, 테스트 프레임워크는 spock(groovy)를 사용한다. spring, spock 환경 설정 관련한 내용들은 생략되어 있을 수 있으니 세부 내용은 깃허브를 참고하면 좋을 것 같다.
Configuration
dependencies {
implementation platform("org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}")
testImplementation("org.springframework.cloud:spring-cloud-contract-wiremock")
}
gradle의 의존성은 다음과 같이 설정해주었다. springCloudVersion은 spring boot 버전에 따라 상이하게 설정해주면 될 것 같다. implementation platform은 기존의 dependency management 의 imports와 같은 역할을 한다. 다만 다른점은 enforcePlatform을 통해 연관 의존성들을 강제할 수 있는 기능으로 확장할 수 있고, dependencies 블록 내부에서 의존성을 함께 설정할 수 있어 최근엔 platform을 이용하여 의존성 설정을 한다. 참고
@AutoConfigureWireMock(port = 80)
class RiotWebClientSpec extends IntegrationTestSupport {
@Autowired
private WireMockServer mockServer
def setup(){
mockServer.start()
}
def cleanup(){
mockServer.stop()
mockServer.resetAll()
}
}
@AutoConfigureWireMock 어노테이션을 통해 80포트로 mock서버를 빈으로 등록한다. (랜덤 포트 설정은 0) setup을 통해 테스트 함수 시작 전 모킹 서버를 실행시키고, cleanup에서 서버를 중지한다. 여기서 중요한건 resetAll인데, 이 부분을 추가하지 않으면 테스트를 실행할때마다 요청 기록이 쌓이게 된다. 현재 테스트 함수에선 3번 호출했는데, 이전 테스트에서 1번 호출했던 기록이 같이 집계되므로 유닛테스트를 실행할땐 기록을 reset하자.
@FeignClient(name = "RiotWebClient", url = "${riot.uri.kr}", primary = false)
public interface RiotWebClient {
String ACTIVE_GAME_PATH = "/lol/spectator/v4/active-games/by-summoner/";
@GetMapping(value = ACTIVE_GAME_PATH + "{encryptedSummonerId}")
CurrentGameInfoResponse getCurrentGameBySummoner(
final @RequestHeader("X-Riot-Token") String apiKey,
final @PathVariable String encryptedSummonerId);
}
우선 테스트할 FeignClient 정의는 다음과 같다. summoner(소환사) id를 통해 소환사가 현재 플레이 중인 게임을 가져오는 API이다.
성공 테스트
def "클라이언트는 라이엇 API를 통해 소환사 정보를 가져올 수 있다."() {
given:
def apiKey = "MOCK_API_KEY"
def path = urlEqualTo(webClient.ACTIVE_GAME_PATH+MOCK_SUMMONER_ID)
def response = WireMock.aResponse()
.withStatus(HttpStatus.OK.value())
.withHeader(HTTP.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withBody(gson.toJson(FakeCurrentGameInfoResponse.create(MOCK_SUMMONER_ID)))
stubFor(get(path).willReturn(response))
when:
def actual = webClient.getCurrentGameBySummoner(apiKey, MOCK_SUMMONER_ID)
then:
actual != null
actual.participants.summonerId.contains(MOCK_SUMMONER_ID)
and:
verify(getRequestedFor(path))
}
우선 성공 테스트부터 실행해보자.
given을 보면 stubFor을 통해 어떤 요청이 오면( get(path) ) 어떤 응답을 받을지( willReturn(response) ) 설정할 수 있다. body부분은 위의 코드는 gson을 통해 json을 반환했지만, 요 부분을 resource를 통해 파일로 관리할 수도 있다. (나는 추후 재사용할 부분이 있어 코드 관리로 남겨두었다.)
when 블록에선 client를 호출하여 응답 값을 받는다.
then, and 에서 응답 값을 검증한다. verify 함수를 통해 해당 path에 요청이 잘 이루어졌는지 검증한다. 아래 기술한 내용에서 볼 수 있는데, verify를 통해 호출 횟수 또한 검증할 수 있다.
실패 테스트 - Client Error, Server Error
@Configuration
@EnableFeignClients(basePackages = "com.yaini.batch.client.web")
public class FeignConfig {
public static final long RETRY_PERIOD = 100;
public static final long RETRY_MAX_PERIOD = 2000;
public static final int RETRY_MAX_ATTEMPTS = 3;
@Bean
public Retryer retryer() {
return new Retryer.Default(RETRY_PERIOD, RETRY_MAX_PERIOD, RETRY_MAX_ATTEMPTS);
}
@Bean
public ErrorDecoder errorDecoder() {
return new WebClientErrorDecoder();
}
}
Feign은 ErrorDecoder에서 발생한 에러에 대해 어떤 동작을 할지 설정할 수 있다. (자세한 사항은 아래에 코드 참고) 또한 Retryer을 사용하여 retry 설정도 추가해주었다. Feign는 디폴트로 retry가 설정되어 있지 않다. errorDecoder에서 retryException을 throw해도 retryer 설정이 되어있지 않다면 retry 하지 않으니 주의하자. FeignConfig 커스텀 설정은 Feign의 각 Client마다 다른 설정이 가능하다.
public class WebClientErrorDecoder implements ErrorDecoder {
...
@Override
public Exception decode(final String methodKey, final Response response) {
FeignException exception = errorStatus(methodKey, response);
Date retryAfter = this.retryAfter(firstOrNull(response.headers(), RETRY_AFTER));
int httpStatus = response.status();
if (HttpStatus.valueOf(httpStatus).is5xxServerError()) {
return new RetryableException(
response.status(),
exception.getMessage(),
response.request().httpMethod(),
exception,
retryAfter,
response.request());
} else if (HttpStatus.valueOf(httpStatus).is4xxClientError()) {
if (HttpStatus.NOT_FOUND.value() == httpStatus) {
return new ResourceNotFoundException();
}
return new RetryableException(
response.status(),
exception.getMessage(),
response.request().httpMethod(),
exception,
retryAfter,
response.request());
}
return new ErrorDecoder.Default().decode(methodKey, response);
}
...
}
나는 errorDecoder를 통해, 4xx, 5xx의 에러일 경우 retry를 발생시키고, 그 외의 에러는 기존 errorDecoder를 통해 디폴트 설정으로 처리하고 싶었다. 또한 코드를 보면 404만 커스텀하게 설정하였는데, 현재 실행 중인 게임이 없어 404가 반환 될 경우 예외가 아닌 다른 동작으로 처리하려고 했다.(앱 내부에선 동작이 잘못된 경우는 아니므로) 하지만 errorDecoder는 항상 Exception을 반환하도록 되어 있기 때문에 불가능했고, 커스텀 exception을 반환하여 client를 처리하는 쪽에서 try catch로 처리하도록 변경하였다. (아래 코드)
private Game getCurrentGame(final String summonerId) {
try {
CurrentGameInfoResponse response = webClient.getCurrentGameBySummoner(apiKey, summonerId);
return GameConverter.from(response, summonerId);
} catch (ResourceNotFoundException e) {
log.debug("Not Found {}'s Current Game", summonerId);
return null;
}
}
try catch를 통해 client를 호출하는 쪽에서 커스텀 exception만 catch하여 예외가 아닌 null을 반환하도록 변경하였다. Feign 내부의 ㄹFeignClientException을 통해 404인지 구분하는 방법도 있지 않은가? 라고 할 수도 있지만 client 를 사용하는 쪽에선 feign에 대한 정보없이 추상화하여 사용하고 싶었다.
def "클라이언트는 라이엇 API에서 서버 에러가 발생하면 설정된 재시도 회수만큼 다시 호출한다."() {
given:
def apiKey = "MOCK_API_KEY"
def path = urlEqualTo(webClient.ACTIVE_GAME_PATH+MOCK_SUMMONER_ID)
def response = WireMock.aResponse()
.withStatus(HttpStatus.INTERNAL_SERVER_ERROR.value())
.withHeader(HTTP.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
stubFor(get(path).willReturn(response))
when:
webClient.getCurrentGameBySummoner(apiKey, MOCK_SUMMONER_ID)
then:
thrown(FeignException.class)
and:
verify(RETRY_MAX_ATTEMPTS, getRequestedFor(path))
}
Status는 500을 반환하도록 설정하였고, Retry에 설정된 횟수만큼 API를 호출하는지 검증하는 테스트 코드이다. Feign에선 서버에러에 대해 별도의 설정이 없다면 FeignException을 반환한다.
def "클라이언트는 라이엇 API에서 플레이중인 게임이 없다면 NOT_FOUND 예외를 발생시킨다."() {
given:
def apiKey = "MOCK_API_KEY"
def path = urlEqualTo(webClient.ACTIVE_GAME_PATH+MOCK_SUMMONER_ID)
def response = WireMock.aResponse()
.withStatus(HttpStatus.NOT_FOUND.value())
.withHeader(HTTP.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
stubFor(get(path).willReturn(response))
when:
webClient.getCurrentGameBySummoner(apiKey, MOCK_SUMMONER_ID)
then:
thrown(ResourceNotFoundException.class)
and:
verify(getRequestedFor(path))
}
해당 코드는 위에서 설정한 404 에러일 경우 커스텀 Exception을 반환하는 테스트이다. errorDecoder에서 retry로 설정하지 않았기 때문에 retry하지 않고 커스텀 exception을 반환한다.
실패 테스트 - Read Timeout
feign:
client:
config:
default:
connectTimeout: 2000
readTimeout: 5000
feign client에선 다음과 같이 application.yaml 설정을 통해 connection timeout과 read timeout 시간을 정할 수 있다. 해당 부분도 각 feign client interface마다 설정이 가능하다.
def "클라이언트는 라이엇 API의 응답 시간이 초과되면 재시도 후 예외를 발생한다."() {
given:
def apiKey = "MOCK_API_KEY"
def path = urlEqualTo(webClient.ACTIVE_GAME_PATH+MOCK_SUMMONER_ID)
def response = WireMock.aResponse()
.withStatus(HttpStatus.OK.value())
.withHeader(HTTP.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withBody(gson.toJson(FakeCurrentGameInfoResponse.create(MOCK_SUMMONER_ID)))
.withFixedDelay(6000)
stubFor(get(path).willReturn(response))
when:
webClient.getCurrentGameBySummoner(apiKey, MOCK_SUMMONER_ID)
then:
thrown(FeignException.class)
and:
verify(RETRY_MAX_ATTEMPTS, getRequestedFor(path))
}
fixedDelay를 통해 응답 값을 반환할 시간을 설정할 수 있다. 요 테스트의 가장 단점은 테스트를 실행하기 위해 진짜 해당 시간이 걸린다.. timeout 테스트는 시간이 길게 설정되어 있을 경우 CI를 위해 다시 생각해보자..
아쉽게도 wireMock에선 read timeout만 테스트할 수 있다. 이전엔 addRequestProcessingDelay를 통해 connection timeout도 테스트가 가능했던 것으로 보이나 deprecated 되었다. 참고 따라서 connection timeout을 테스트하기 위해선 iptables 와 같이 별도의 툴을 사용해야 한다. 참고
} else if (dismiss404 && response.status() == 404 && !isVoidType(returnType)) {
final Object result = decode(response, returnType);
shouldClose = closeAfterDecode;
resultFuture.complete(result);
} else {
resultFuture.completeExceptionally(errorDecoder.decode(configKey, response));
}
} catch (final IOException e) {
if (logLevel != Level.NONE) {
logger.logIOException(configKey, logLevel, e, elapsedTime);
}
resultFuture.completeExceptionally(errorReading(response.request(), response, e));
} catch (final Exception e) {
resultFuture.completeExceptionally(e);
} finally {
if (shouldClose) {
ensureClosed(response.body());
}
}
feign.AsyncResponseHandler.handleResponse
참고로 timeout과 같은 IOException은 Feign의 ErrorDecoder를 통해 처리되지 않으니 주의하자
'Java > spring' 카테고리의 다른 글
[Spring] Spring Batch에 Hexagonal Architecture를 적용해보자 (0) | 2022.06.06 |
---|---|
[Spring] Log4j2를 이용해 로깅해보자 (0) | 2022.05.29 |
[Spring Security] FormLogin에서 Custom Filter 처리 이슈 (0) | 2022.02.21 |
[Spring Security] LoginSuccessHandler와 FailureHandler 호출 원리 (0) | 2022.02.19 |
[Spring Cloud] FeignClient Logging 방법 정리 (0) | 2022.02.16 |