ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • AsyncRestTemplate PATCH 메서드 유효하지 않은 요청
    JAVA 2022. 12. 30. 13:00

    외부 API 연동 중 PATCH 메서드를 사용해서 통신해야 하는 일이 생겼다. 응답 결과가 중요하지 않았기 때문에, 응답을 기다리기 보다는,비동기 호출을 하기로 결정했다. 어떤 API를 활용할까 하다가 우리가 현재 쓰고 있는 버전이 Spring 4.x 버전이다보니, AsyncRestTemplate을 사용하기로 했다.

    참고로, AsyncRestTemplate은 Spring5.0부터 Deprecated 되었다.

     

    일반적인 방식으로 아래와 같이 코딩을 한 뒤, 외부 API를 호출 했다.

    AsyncRestTemplate asyncRestTemplate = new AsyncRestTemplate();
    ListenableFuture<ResponseEntity<String>> future = asyncRestTemplate.exchange(
            createUri(uri, pathParams, null),
            HttpMethod.PATCH,
            requestEntity,
            String.class
    );
    
    future.addCallback(
            successCallback -> log.info(">>>>> success - StatusCode ::: {}", successCallback.getStatusCode()),
            failureCallback -> log.error(">>>>> failure - Message ::: {}", failureCallback.getMessage())
    );

    정상적으로 호출될 줄로만 알았는데, 다음과 같은 Error Message 가 PrintStack에 찍히는 것을 확인할 수 있었다.

    2022-12-30 10:27:54 [async-thread] ERROR o.s.a.i.SimpleAsyncUncaughtExceptionHandler - Unexpected error occurred invoking async method 'public void ...exchange(java.lang.String,java.util.Map,org.springframework.util.MultiValueMap,org.springframework.http.HttpMethod,org.springframework.http.HttpEntity) throws ...Exception'.
    org.springframework.web.client.ResourceAccessException: I/O error on PATCH request for "https://...":Invalid HTTP method: PATCH; nested exception is java.net.ProtocolException: Invalid HTTP method: PATCH

    Error의 내용은 다음과 같다. -> HTTP method PATCH가 유요하지 않다.

    PATCH 메서드가 왜 유효하지 않은 메세지라고 하는 걸까, 오류가 난 부분을 하나하나 따라가 보았다.

    현재 우리는 JAVA 1.8 버전을 사용하고 있는데, 오류가 난 부분을 하나하나 들여다 보니, 문제가 발생한 곳은 Java에서 제공하는 HttpURLConnection 클래스였다. 다음은 HttpURLConnection 클래스의 일부 코드이다.

    ...
    /* valid HTTP methods */
    private static final String[] methods = {
        "GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"
    };
    
    ...
    
    public void setRequestMethod(String method) throws ProtocolException {
        if (connected) {
            throw new ProtocolException("Can't reset method: already connected");
        }
        // This restriction will prevent people from using this class to
        // experiment w/ new HTTP methods using java.  But it should
        // be placed for security - the request String could be
        // arbitrarily long.
    
        for (int i = 0; i < methods.length; i++) {
            if (methods[i].equals(method)) {
                if (method.equals("TRACE")) {
                    SecurityManager s = System.getSecurityManager();
                    if (s != null) {
                        s.checkPermission(new NetPermission("allowHttpTrace"));
                    }
                }
                this.method = method;
                return;
            }
        }
        throw new ProtocolException("Invalid HTTP method: " + method);
    }

    유효한 HTTP Method인지 확인하는 로직이 있는데, 여기서 해당 클래스에 클래스 변수로 초기화한 Method 값만 유효한 메서드로 보고 있었다.

    그러면, 어떤 방법이 있을까?
    구글링을 통해 확인해 보면 대략 3가지 방법을 찾을 수 있다.

     

    어떤 방법이 가장 좋을까를 생각해 봐야 하는데, 우선 두번째 방법을 이야기 하자면, remote 측에서 해당 헤더에 대한 처리를 해두지 않았다면, 허용되지 않을 것이다. 이건, remote 서버의 제약이 따르기 때문에 pass 하기로 했다.

    우선 첫 번째 방법을 사용해 보겠다.

    AsyncRestTemplate을 사용할 때, 기본생성자를 사용해서 AsyncRestTemplate을 사용하는 경우 다음 코드가 실행 된다.

    /**
     * Create a new instance of the {@code AsyncRestTemplate} using default settings.
     * <p>This constructor uses a {@link SimpleClientHttpRequestFactory} in combination
     * with a {@link SimpleAsyncTaskExecutor} for asynchronous execution.
     */
    public AsyncRestTemplate() {
        this(new SimpleAsyncTaskExecutor());
    }
    
    /**
     * Create a new instance of the {@code AsyncRestTemplate} using the given
     * {@link AsyncTaskExecutor}.
     * <p>This constructor uses a {@link SimpleClientHttpRequestFactory} in combination
     * with the given {@code AsyncTaskExecutor} for asynchronous execution.
     */
    public AsyncRestTemplate(AsyncListenableTaskExecutor taskExecutor) {
        Assert.notNull(taskExecutor, "AsyncTaskExecutor must not be null");
        SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
        requestFactory.setTaskExecutor(taskExecutor);
        this.syncTemplate = new RestTemplate(requestFactory);
        setAsyncRequestFactory(requestFactory);
    }

    여기서 보면 SimpleClientHttpRequestFactory 클래스를 사용하는 것을 볼 수 있다.

    AsyncRestTemplate에서 기본으로 제공해주는 SimpleClientHttpRequestFactory 가 아닌, 다른 Factory 클래스를 사용해서 AsyncResetTemplate을 인스턴스화 할 수 있다.

    구글링을 하다보면 가장 쉽게 발견할 수 있는게 HttpComponentsAsyncClientHttpRequestFactory 클래스를 활용하는 것이다.

    그러면 AsyncRestTemplate을 인스턴스화할 때 다음과 같이 코드를 작성할 수 있다.

    AsyncRestTemplate asyncRestTemplate = new AsyncRestTemplate(new HttpComponentsAsyncClientHttpRequestFactory());

    여기서 HttpComponentsAsyncClientHttpRequestFactory 클래스는 Spring에서 제공하는 클래스이긴 한데, HttpComponentsAsyncClientHttpRequestFactory 클래스 안에서 apache libray인 HttpAsyncClients 클래스를 사용하고 있다.

    그래서 다음과 같이 아래 Library를 추가해 줘야 한다.

    <dependency>
       <groupId>org.apache.httpcomponents</groupId>
       <artifactId>httpasyncclient</artifactId>
       <version>4.1.5</version>
    </dependency>

    그리고, 다음 코드를 사용해서 PATCH 메서드 요청을 보내보겠다.

    맨 처음 소개한 코드와 다른 점은 HttpComponentsAsyncClientHttpRequestFactory 클래스를 AsyncRestTemplate을 인스턴스화 하는데 사용했다는 것 뿐이다.

    AsyncRestTemplate asyncRestTemplate = new AsyncRestTemplate(new HttpComponentsAsyncClientHttpRequestFactory());
    asyncRestTemplate.setMessageConverters(createMessageConverters());
    ListenableFuture<ResponseEntity<String>> future = asyncRestTemplate.exchange(
            createUri(uri, pathParams, null),
            HttpMethod.PATCH,
            requestEntity,
            String.class
    );
    
    future.addCallback(
            successCallback -> log.info(">>>>> success - StatusCode ::: {}", successCallback.getStatusCode()),
            failureCallback -> log.error(">>>>> failure - Message ::: {}", failureCallback.getMessage())
    );

     

    그러면, 다음과 같이 정상적으로 실행되는 것을 확인할 수 있다.

    2022-12-30 11:24:42 [I/O dispatcher 1] INFO - >>>>> success - StatusCode ::: 204

    정상적으로 된 것 같으니, 여기서 끝인가?

    아니다. 혹시 모르니, 서버도 한번 내려보도록 하겠다.

    Intellij IDEA를 사용하고 있는데, 서버 중지 버튼을 누르면 원래 종료되던게 바로 종료되지 않는다.

    한참을 기다려도 종료되지 않는다. 

    강제로 종료시키니 그제서야 종료되는 걸 확인할 수 있다.

    무엇이 문제인지 확인하기 위해, 실시간 모니터링을 해보도록 하겠다.

    모니터링은 Visual VM을 사용해서 모니터링 했다.

    1. 처음 서버를 올렸을 때의 Thread 상태는 다음과 같다.

    2. AsyncRestTemplate을 호출하고 나니, 없던 Thread가 생성되는 것을 볼 수 있다.

    I/O dispatcher 라는 이름의 Thread가 다수 생성되서 Running 상태로 실행되는 것을 볼 수 있다. 

    3. 한번 Server를 종료 시키고 해당 Thread가 어떻게 변하는지 확인해 보도록 하겠다.

    기존에 있던 Thread 들이 거의 대부분 사라진 것을 볼 수 있다.

    그렇다. I/O dispatcher 라는 이름의 Thread가 계속해서 Running 상태이기 때문에 Server가 종료되지 않았던 것이다.

    왜 그런것인지 한번 생각해 보면, AsyncRestTemplate을 인스턴스화 할 때 HttpComponentsAsyncClientHttpRequestFactory 클래스를 사용하고 나서 문제가 발생했다.

    HttpComponentsAsyncClientHttpRequestFactory 클래스를 한번 살펴 보자.

    해당 클래스를 보면, 다음과 같은 코드를 확인할 수 있다.

    public class HttpComponentsAsyncClientHttpRequestFactory
    		extends HttpComponentsClientHttpRequestFactory
    		implements AsyncClientHttpRequestFactory, InitializingBean {
    	...
    	@Override
    	public void afterPropertiesSet() {
    		startAsyncClient();
    	}
        
        ...
        
    	@Override
    	public void destroy() throws Exception {
    		try {
    			super.destroy();
    		}
    		finally {
    			getHttpAsyncClient().close();
    		}
    	}
    }

    Spring에 많이 익숙한 개발자라면 느낌이 올 것이다.

    Spring Bean의 LifeCycle에서 Bean 초기화 시와 Bean의 소멸 시에 코드를 실행할 수 있도록 Spring은 Inteface를 제공한다.
    Interface 외에도 다른 방법들이 존재한다. 우선 여기서는 위에 선언되어 있는 메서드만 이야기 하겠다.

    afterPropertiesSet() 메서드는 InitializingBean 인터페이스에 선언된 메서드이다.
    그리고, destroy() 메서드는 상속받은 HttpComponentsClientHttpRequestFactory 클래스에 들어가보면, 해당 클래스가 DisposableBean 인터페이스를 구현한 걸 확인할 수 있다.

    맞다. HttpComponentsClientHttpRequestFactory 클래스는 Spring이 제공하는 클래스인데, Spring 컨테이너가 관리하도록 하는게 맞았던 것이다. 또한, 매번 외부 API 호출 시 마다 메서드 안에서 new 키워드로 인스턴스화 해서 사용하는 것은 매번 GC의 대상으로 만드는 비효율적인 코드를 생산할 뿐이다.

    그러면, Spring 컨테이너가 관리하는 Bean으로 등록해서 사용해 보도록 하겠다.

    @Configuration
    public class HttpComponentConfig {
    	@Bean
    	public HttpComponentsAsyncClientHttpRequestFactory httpComponentsAsyncClientHttpRequestFactory() {
    		return new HttpComponentsAsyncClientHttpRequestFactory();
    	}
    }

    Spring Bean으로 등록 했으면 해당 Bean을 주입받아 사용하도록 하겠다.

    // 생성자 기반 의존성 주입이 권장되지만, 단순화 하기 위해 필드 주입을 사용했다.
    @Autowired
    private HttpComponentsAsyncClientHttpRequestFactory httpComponentsAsyncClientHttpRequestFactory;
    ..
    // 아래는 외부 API를 호출하는 메서드의 일부 코드이다.
    AsyncRestTemplate asyncRestTemplate = new AsyncRestTemplate(httpComponentsAsyncClientHttpRequestFactory);
    asyncRestTemplate.setMessageConverters(createMessageConverters());
    ListenableFuture<ResponseEntity<String>> future = asyncRestTemplate.exchange(
            createUri(uri, pathParams, null),
            HttpMethod.PATCH,
            requestEntity,
            String.class
    );
    
    future.addCallback(
            successCallback -> log.info(">>>>> success - StatusCode ::: {}", successCallback.getStatusCode()),
            failureCallback -> log.error(">>>>> failure - Message ::: {}", failureCallback.getMessage())
    );

    이렇게 코드를 작성하고 서버를 다시 올려보도록 하겠다. 그리고 모니터링을 해보면 다음과 같다.

    서버를 올리자마자 I/O dispatcher 쓰레드가 생성된 것을 볼 수 있다.

    그러면, 여기서 외부 API를 호출해 볼까? 아니다. 우리가 이제 확인하려는 것은, I/O dispatcher 쓰레드가 생성된 후, 서버를 종료했을 때 정상적으로 종료되는지 확인하려 했던 것이다.

    그러면 바로 Thread를 종료 시켜보도록 하겠다.

    그럼 결과는 위와 같다. 아무것도 일어나지 않은 것은 아니고, 위의 그림은, 서버가 종료되서 마지막 쓰레드 상태를 보여준 모습이다.

    이전에는 http-nio-8080-exec 로 시작하는 쓰레드는 Live Threads에서 사라지고, I/O dispatcher 쓰레드가 계속해서 Running 상태로 서버가 죽지 않았는데, 이번에는 아주 정상적으로 Server가 순식간에 종료되었다.

    Spring을 사용하면서는 항상 Bean으로 등록할 것인지, 직접 인스턴스화 할 것인지 고민해서 상황에 맞게 사용하는것이 좋다.

    써드파티 라이브러리르 굳이 사용하고 싶지 않거나, 써드파티 라이브러리를 사용하는데 제약이 있다면, 리플렉션을 사용해야 할 수도 있다.

    아래코드는 게시물 윗부분에 링크해 놓은 코드를 그대로 copy&paste 한 코드이다.

    단지 내가 테스트 하기 위해서 서버가 올라갈 때 한번만 실행하기 위해서 Listener로 등록해서 테스트했다.

    public class WebAppServletContextListener implements ServletContextListener {
    
    	Logger log = LoggerFactory.getLogger(WebAppServletContextListener.class);
    
    	@Override
    	public void contextInitialized(ServletContextEvent sce) {
    		try {
    			Field methodsField = HttpURLConnection.class.getDeclaredField("methods");
    
    			Field modifiersField = Field.class.getDeclaredField("modifiers");
    			modifiersField.setAccessible(true);
    			modifiersField.setInt(methodsField, methodsField.getModifiers() & ~Modifier.FINAL);
    
    			methodsField.setAccessible(true);
    
    			String[] oldMethods = (String[]) methodsField.get(null);
    			Set<String> methodsSet = new LinkedHashSet<>(Arrays.asList(oldMethods));
    			methodsSet.addAll(Arrays.asList(methods));
    			String[] newMethods = methodsSet.toArray(new String[0]);
    
    			methodsField.set(null/*static field*/, newMethods);
    		} catch (NoSuchFieldException | IllegalAccessException e) {
    			throw new IllegalStateException(e);
    		}
    	}
    
    	@Override
    	public void contextDestroyed(ServletContextEvent sce) {
    		// ...
    	}
    }

    위 처럼 Reflection을 사용해서 HttpURLConnection 클래스의 methods 필드에 PATCH를 추가해 주면
    AsyncRestTemplate을 기본 생성자로 만들어서 호출해 주면, 문제없이 호출되는 것을 확인할 수 있다.

    하지만, 정말 코드를 정교하게 짠다고 한다면, AsyncRestTemplate을 new 로 초기화 하지도 않았을 것이고,
    Singletone으로 만들어서 사용하지 않았을까 싶다.

    그리고, 기본생성자로 초기화 하기 보다는, 어떤Factory를 쓰느냐에 따라 쓰레드가 어떻게 만들어지는지에 따라서 선택을 해야 했을거라 생각된다. 

    그리고, 최신 Spring 버전을 사용하고 있다면, AsyncRestTemplate이 아닌 WebClient를 사용해서 전혀 다른 고민을 하지 않았을까도 싶다.

    어떤 방법을 사용할지는 프로젝트의 상황에 따라 선택해야 할 것 같다. 항상 개발에는 정답에 가까운 길들은 있지만, 100프로 확신할 수 있는 정답은 없는 것 같다.

    써드파티 라이브러리르 추가하는데 부담이 있다면, 리플렉션을 사용했을 수도 있고, 리플렉션을 사용하게 되면, 나중에 시간이 지났을 때 이 사실을 모르는 개발자가 왜 코드와 다르게 동작하는지 알 수 없어 유지보수에 어려움을 겪을 수도 있다.

    항상 상황에 맞는 방법이 무엇일지, 훗날도 고려해서 선택하는 것이 가장 옳을 것 같다.

    여기서 좀 더 확인해 보고 싶은 것은 다음과 같다.

    I/O Dispatcher 쓰레드를 어떤 식으로 생성하는지, 왜 갯수가 16개로 생성이 되었을까
    그리고, 왜 계속 Running 상태로 두었을까?

    이 부분에 대해서도 좀 더 확인해 보면 좋지 않을까 생각해 본다.

    댓글

Designed by Tistory.