FeignClient

공병주(Chris)·2023년 2월 14일
0

2023년 글로벌미디어학부 졸업 작품 단디 에서 Apple OAuth를 사용하기로 결정했습니다.

Apple OAuth 인증 과정에서 Apple의 public auth keys이 필요했습니다. 고정된 값이 아닌, apple에서 해당 값들을 실시간으로 변경하기 때문에, API 요청을 통해 받아와야 했습니다.

RestTemplate vs FeignClient vs WebClient

3가지 기술의 특징을 간단히 알아보겠습니다.

RestTemplate

RestTemplate은 maintenance mode가 되었습니다. 또한 작성해야 하는 코드의 양이 상대적으로 많기 때문에 고려 대상에서 제외했습니다.

WebClient

deprecated될 RestTemplate을 대체하기 위해 등장한 Spring webflux의 라이브러리입니다. 가장 큰 특징은 non-blocking 방식으로 동작할 수 있습니다. async + non-blocking을 사용한다면 대용량 트래픽 환경에서 상대적으로 성능적으로 좋습니다.

FeignClient

Netflix에서 간단하게 HTTP Client를 사용하기 위해 개발한 기술입니다. 조금 더 자세히 말하면, OkHttpClient, Apache HttpClient와 같은 HTTP Client를 손쉽게 쓸 수 있도록 하는 Client Builder 입니다. interface와 annotation 선언만하면 자동으로 구현체를 끼워주기 때문에 간단하게 HTTP 요청을 보낼 수 있습니다.

현재 개발 초기 단계이기 때문에 대규모 트래픽을 기대하기 어렵습니다. 따라서, WebClient의 non-blocking 장점을 극대화할 수 없다는 생각이 들었습니다. 또한, FeignClient은 간단하게 구현할 수 있기 때문에 FeignClient를 채택했습니다.

FeignClient 의존성

gradle을 사용하신다면 아래와 같이 의존성을 추가해주시면됩니다.

dependencies {
	implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
}

ext {
	set('springCloudVersion', "2021.0.5")
}

dependencyManagement {
	imports {
		mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
	}
}

springCloudVersion은 SpringBoot 버전과 호환되게 설정해주어야 하는데요.

https://github.com/spring-cloud/spring-cloud-release/wiki/Supported-Versions

위 링크에 SpringBoot에 따른 SpringCloudVersion이 나와있습니다.

FeignClient 사용방법

@EnableFeignClients

먼저, 아래와 같이 configuration을 해줘야합니다.

@EnableFeignClients 어노테이션이 있다면, basePackageClasses를 기준으로 @FeignClient 어노테이션을 통해 FeignClinet로 등록된 인터페이스가 있는지 스캔합니다.

@Configuration
@EnableFeignClients(basePackageClasses = DandiApplication.class)
public class FeignConfiguration {
}

@FeignClient를 통해 api 호출을 담당할 인터페이스 선언

@FeignClient(name = "${feign.client.apple.name}", url = "${feign.client.apple.url}")
public interface AppleApiCaller {

    @GetMapping
    ApplePublicKeys getPublicKeys();
}

name은 FeignClient의 이름입니다. url엔 요청을 보려고하는 url을 작성하시면 됩니다.

@SpringBootTest
class AppleApiCallerTest {

    @Autowired
    private AppleApiCaller appleApiCaller;

    @DisplayName("oauth public keys를 요청 후 응답 받는다.")
    @Test
    void getApplePublicKeys() {
        ApplePublicKeys applePublicKeys = appleApiCaller.getPublicKeys();

        List<ApplePublicKey> keys = applePublicKeys.getKeys();
        boolean isRequestedKeysNonNull = keys.stream()
                .allMatch(this::isAllNotNull);
        assertThat(isRequestedKeysNonNull).isTrue();
    }

    private boolean isAllNotNull(ApplePublicKey applePublicKey) {
        return Objects.nonNull(applePublicKey.getKty()) && Objects.nonNull(applePublicKey.getKid()) &&
                Objects.nonNull(applePublicKey.getUse()) && Objects.nonNull(applePublicKey.getAlg()) &&
                 Objects.nonNull(applePublicKey.getN()) && Objects.nonNull(applePublicKey.getE());
    }
}

위와 같은 테스트 코드를 실행하면 api 호출이 정상적으로 되는 것을 확인할 수 있습니다.

FeignClient interface의 구현체

FeignAutoConfiguration 에서 Bean으로 등록되는 과정을 볼 수 있습니다.

구현체는 크게 Feign의 Default, ApacheHttpClient, OkHttpClient가 있습니다.

ApacheHttpClient와 OkHttpClient는 추가적인 의존성을 주입해줘야 사용할 수 있고, feign.Client.Default는 Feign에서 기본으로 제공하기 때문에 다른 의존성 주입 없이 사용할 수 있습니다.

Default는 내부적으로 java.net의 HttpUrlConnection을 사용하는데요. 가볍고 빠르다는 장점이 있습니다.

ApacheHttpClient와 OkHttpClient는 Default 구현체보다 설정할 수 있는 값들이 더 많고, 더 편리한 api들을 제공합니다.

아래와 같은 이유로 OkHttpClient를 추천하는 글이 있습니다.

OkHttp has HTTP/2, a built-in response cache, web sockets, and a simpler API.
It’s got better defaults and is easier to use efficiently. It’s got a better URL model, a better cookie model, a better headers model and a better call model.
OkHttp makes canceling calls easy. OkHttp has carefully managed TLS defaults that are secure and widely compatible.
Okhttp works with Retrofit, which is a brilliant API for REST. It also works with Okio, which is a great library for data streams.
OkHttp is a small library with one small dependency (Okio) and is less code to learn. OkHttp is more widely deployed, with a billion Android 4.4+ devices using it internally.

https://github.com/square/okhttp/issues/3472

지금 당장 OkHttpClient 혹은 ApacheHttpClient 를 도입하기 보다는, 추후에 API Call 핸들링을 더 구체적으로 설정하고 관련 문제가 있을 때, 둘 중 하나를 도입할 예정입니다.

Timeout 설정

FeignClient에 Connection-Timeout과 Read-Timeout을 설정하는 방법에 대해 알아보겠습니다.

feign:
  client:
    config:
      default:
        connectTimeout: ${connectionTimeout 시간}
        readTimeout: ${readTimeout 시간}

위처럼 feign.client.config.default 를 통해서는 어플리케이션의 모든 FeignClient의 설정을 다룰 수 있습니다.

FeignClient 마다 timeout 설정을 달리하고 있다면 아래처럼 default가 아닌 FeignClient의 name을 지정해주면 됩니다.

feign:
  client:
    config:
      ${FeignClient의 name 혹은 value}:
        connectTimeout: ${connectionTimeout 시간}
        readTimeout: ${readTimeout 시간}

위에서 @FeignClient의 name 혹은 value 값과 동일하게 적어줘야 timeout 설정할 수 있습니다.

timeout에 대한 설정을 진행하면서 timeout을 어떻게 도출해야할지에 대해서도 알아보았습니다.

ConnectionTimeout과 ReadTimeout은 어떤 값이 적절할까?

하지만, 지금 당장 적절한 Timeout 값들을 설정하는 것은 불가능하다고 생각합니다. 추후에 실제로 실제 서버에서 테스트를 한 후에, 로그들을 확인하면서 적절한 Timeout을 설정하려합니다.

을 읽고, 임시로 Connection-Timeout은 3초, Read-Timeout은 1초로 설정해두었습니다.

Retryer로 재시도 하기

Feign Client을 통한 요청이 ConnectionTimeout 혹은 ReadTimeout이 발생한다면 RetryableException 이 발생합니다.

Retyer을 설정하지 않는다면?

Retryer을 따로 Configuration을 해주지 않는다면, Retyer은 NEVER.RETRY로 지정되기 때문에 재시도를 하지 않습니다. 아래 코드를 보면 확인할 수 있습니다.

@Configuration(proxyBeanMethods = false)
public class FeignClientsConfiguration {  

    // ...

	  @Bean
		@ConditionalOnMissingBean
		public Retryer feignRetryer() {
			return Retryer.NEVER_RETRY;
		}
}
public interface Retryer extends Cloneable {
    Retryer NEVER_RETRY = new Retryer() {

    @Override
    public void continueOrPropagate(RetryableException e) {
      throw e;
    }

    @Override
    public Retryer clone() {
      return this;
    }
  };
}

Retryer 객체 알아보기

public interface Retryer extends Cloneable {

  void continueOrPropagate(RetryableException e);

  Retryer clone();

  class Default implements Retryer {

    private final int maxAttempts;
    private final long period;
    private final long maxPeriod;
    int attempt;
    long sleptForMillis;
    
    public Default() {
      this(100, SECONDS.toMillis(1), 5);
    }

    public Default(long period, long maxPeriod, int maxAttempts) {
      this.period = period;
      this.maxPeriod = maxPeriod;
      this.maxAttempts = maxAttempts;
      this.attempt = 1;
    }

    public void continueOrPropagate(RetryableException e) {
      if (attempt++ >= maxAttempts) {
        throw e;
      }

      long interval;
      if (e.retryAfter() != null) {
        interval = e.retryAfter().getTime() - currentTimeMillis();
        if (interval > maxPeriod) {
          interval = maxPeriod;
        }
        if (interval < 0) {
          return;
        }
      } else {
        interval = nextMaxInterval();
      }
      try {
        Thread.sleep(interval);
      } catch (InterruptedException ignored) {
        Thread.currentThread().interrupt();
        throw e;
      }
      sleptForMillis += interval;
    }

    long nextMaxInterval() {
      long interval = (long) (period * Math.pow(1.5, attempt - 1));
      return interval > maxPeriod ? maxPeriod : interval;
    }
    // ...
}

period

첫 RetryableException이 발생했을 때, 재시도를 위해 대기할 시간

max-period

재시도를 위한 대기 시간의 최대값

maxAttempts

최대 재시도 횟수

위 3가지 값으로 retryer을 위한 대기시간이 period를 시작으로 1.5을 곱하면서 증가(nextMaxInterval 메서드 참고)하면서 최대 재시도 횟수 만큼 재시도 하게 됩니다. 증가한 대기 시간이 maxPeriod보다 크다면 maxPeriod만큼 대기하게 됩니다.

그렇게 재시도를 하다가 시도횟수가 maxAttempts를 넘는다면 RetryableException을 throw 합니다. continueOrPropagate 의 첫번째 if문을 참고하시면 됩니다.

Retryer 설정하기

서버에서 Apple Login 요청 시간을 최대 7초로 지정했습니다.

@Configuration
public class FeignRetryConfig {

    @Bean
    public Retryer retryer() {
        return new Retryer.Default(1000, 1500, 1);
    }
}

timeout 설정

  • Connection Time-out : 3초
  • Read Time-out : 1초

현재 connection timeout이 3초로 설정되어 있습니다. 따라서 connection timeout이 두번할 생할 때 retry를 두번 한다면 7초를 처리시간이 7초를 넘기기 때문에 재시도 횟수를 1로 설정해두었습니다.

반면 read timeout은 1초이기때문에 retry를 2번할 수 있는데요. Retryer를 구현하는 객체를 직접 만들어서 continueOrPropagate를 오버라이드하면 connection timeout과 read timeout에 대한 재시도 횟수를 다르게 처리할 수 있을 것 같습니다. continueOrPropagate의 파라미터로 들어오는 RetryableException에서 connection timeout인지 read timeout인지에 대한 정보를 추출하면 가능할 것이라고 생각합니다.

이렇게 FeignClient를 설정해보았습니다. 설정에 그치는 것이 아니라, 다음엔 Timeout과 재시도 로그들을 확인하면서 Timeout과 Retryer 직접 구현하거나 값들을 조정하는 글로 돌아오겠습니다.

참고자료

https://techblog.woowahan.com/2630/

https://techblog.woowahan.com/2657/

https://digitalbourgeois.tistory.com/56

0개의 댓글