Http Invoke Spring

Background

최근 프로젝트팀으로부터 요청받은 내용중에 Server to Server간의 서비스를 주고받아야 한다는 연락을 받았습니다.

국내 대기업 SI 프로젝트를 진행해 보신 분은 아시겠지만, 일반적으로 외부 인터넷이 불가한 개발환경입니다.

그러다 보니 저희 시스템내의 기능 중 하나인 웹 크롤링 사용이 불가합니다. (일반적으로, 웹 크롤링 기능은 외부 인터넷이 가능한 단독 서버로 진행하지만 이번에는 A시스템의 특정업무와 연계되어 동작해야 했기 때문에 A(시스템) - B(웹크롤링 + 웹서비스(Soap 메시지)) 간의 서비스 요청이 가능해야 했습니다.

Requirements

전달 받은 요구사항을 간략히 정리하면 아래와 같습니다.

  • A서버 : 솔루션(내부망만 가능), B서버 : 외부망 접근 가능 서버
  • B서버 기능 : 웹 크롤링, 인터페이스 연계(Soap with WSDL)
  • A, B서버는 각각 DB 존재. A는 A-DB서버 및 B-DB서버 접근이 가능. 하지만, B는 B-DB서버만 접근 가능.
  • 웹 크롤링 기능과 인터페이스 기능은 A서버에도 존재하고, B서버에도 존재. A서버에서 웹 크롤링 또는 인터페이스 기능이 호출되면 실제 실행은 B서버(외부망 접근이 가능하기 때문)에서 진행되어야 함. A서버는 (B서버에서 진행된) 결과값을 당연히 리턴 받아야 하며, B-DB에 접근하여 데이터를 언제든 가져올 수 있어야 함.
  • A서버의 요청없이도, B서버내에서 자체적인 스케줄러를 통해 '웹 크롤링' 또는 '인터페이스' 기능 사용될 수 있음

Arrangement

위의 요구사항을 정리하면, 아래와 같은 기능 및 가이드를 제공해주면 됩니다.

  • A-솔루션 내의 다중 Datasource 제공.
  • A(Server) to B(Server) 서비스 요청 및 인증. (Rest API)
  • 네트워크 문제로 인한, 실패 처리 보장.
    • B서버에서 정상동작 했음에도 네트워크 문제 발생하여, A서버에 결과값 리턴 실패.

Task

1. 다중 Datasource

현재 MyBatis를 사용하고 있고, SqlSession 빈을 추가로 정의하여 webCrawler 및 Interface Datasource를 추가 정의하면 되니, 문제 없어 보입니다. 하지만, 웹크롤러 및 인터페이스 기능은 모듈 형태(jar)로 제공되다 보니 새로 정의한 SqlSession 빈을 쉽게 추가/변경할 수 있는지는 확인해 보아야 하며, 그렇지 못한 경우 해당 모듈내부를 수정하여 새로운 릴리즈 버전으로 전달해야 할 수도 있습니다.

2. Server to Server 서비스 요청

기존 Server to Server로 서비스를 요청하는 케이스가 존재하였는데, 이 때는 타 시스템과의 커뮤니케이션이기 때문에 Rest API(Spring RestTemplate 사용)를 활용하는 가이드만 제공하였습니다.

Rest API가 꺼려지는 이유?

  1. 다양한 MessageConverter
    현재 2개의 모듈만 대상으로 진행하였지만, 솔루션내에는 수십개의 모듈이 존재하며, 각 모듈별 다루는 (복잡한) 데이터 타입 객체가 다양하고 많음.
    추후에 다른 모듈에 동일한 요구사항이 나왔을 때, 동일한 가이드로 추가 수정없이 빠르게 전달 가능해야 한다는 방향성 같이 고려.

하지만, 동일한 애플리케이션이 서로 다른 WAS에 얹혀지는 것이기 때문에 MessageConverter 필요없이, (파라미터 또는 리턴 시) Java객체 그대로 (Serialize / Deserialize) 사용할 수 있을 것 같아 검색해 보니 다음과 같은 키워드를 찾을 수 있었습니다.

RMI, Http Invoke, Hessian, Burlap

그리고 내용을 보니, 대략 아래와 같은 장/단점이 있는 것을 확인할 수 있었습니다.

  • RMI
    • 방화벽을 넘어 작업하는 환경에서는 한계가 있음(임의 포트 사용)
    • 인트라넷은 상관없지만, 인터넷상에서는 문제(터널링 작업)
    • RMI는 자바기반이기 때문에, 클라이언트 서비스 둘 다 자바 작성
  • Hessian, Burlap
    • Http를 통해, 가벼운 원격서비스 지원.웹서비스 단순화.
    • Http를 기반으로 하므로, 방화벽 문제를 겪지 않음.
    • 복잡한 데이터모델의 경우, 직렬화 모델이 충분치 않을 수 있음
    • Hessian
      • RMI와 유사. 바이너리 메시지를 이용해 통신.
    • Burlap
      • XML기반 리모팅 기술.
  • Http Invoke
    • RMI(자바의 직렬화 사용)와 Hessian/Burlap(방화벽 문제 없음)의 장점을 섞은 스프링의 HTTP Invoke.
    • 방화벽을 가로질러 사용 + 독자적인 객체 직렬화 매커니즘

결론 : Http Invoke 사용하자.

3. 네트워크 문제로 인한, 실패 처리 보장

Real Code

B서버(서비스 제공자)

[web.xml]
Request에 대한 서블릿을 아래와 같이, 분리하여 사용합니다.
init-param의 parameters의 value로 쉼표(,)를 통해 구분하여 등록하면 다중의 인터페이스를 하나의 서블릿에 정의하여 사용할 수 있습니다.
다음과 같은 패턴으로 등록하여 사용하시면 됩니다.
{물리적 인터페이스 풀 패키지 명}:{서비스 빈 ID}

<!-- HTTPInvoker -->
<servlet>
    <servlet-name>DefaultInvokeServlet</servlet-name>
    <servlet-class>jade.web.DefaultInvokeServlet</servlet-class>
    <init-param>
		<param-name>parameters</param-name>
		<param-value>
				jade.web.ExecuteService:executeService, 
				jade.web.ExecutionService:executionService
		</param-value>
	</init-param>
	<load-on-startup>2</load-on-startup>
</servlet>

<servlet-mapping>
    <servlet-name>DefaultInvokeServlet</servlet-name>
    <url-pattern>/http/execute/*</url-pattern>
</servlet-mapping>

[DefaultInvokeServlet.java]
HttpRequestHandlerServlet 클래스를 상속받아, 기존 하나의 서블릿에 하나의 인터페이스만 사용하던 방식을 다중으로 등록하여, 하나의 서블릿에 다중의 인터페이스를 처리할 수 있도록 수정하였습니다.

package jade.web;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.remoting.httpinvoker.HttpInvokerServiceExporter;
import org.springframework.util.StringUtils;
import org.springframework.web.HttpRequestHandler;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.HttpRequestHandlerServlet;
import org.springframework.web.context.support.WebApplicationContextUtils;

public class DefaultInvokeServlet extends HttpRequestHandlerServlet {

	private Map<String, HttpRequestHandler> target = new HashMap();
	
	private final String URL_PREFIX = "/http/execute/";
	
	@Override
	public void init() throws ServletException {
		this.httpInvokerBinder();
	}

	@Override
	protected void service(HttpServletRequest request, HttpServletResponse response)
			throws ServletException, IOException {

		LocaleContextHolder.setLocale(request.getLocale()); 
		try {
			getHttpInvoker(request).handleRequest(request, response);
		}
		catch (HttpRequestMethodNotSupportedException ex) {
			String[] supportedMethods = ex.getSupportedMethods();
			if (supportedMethods != null) {
				response.setHeader("Allow", StringUtils.arrayToDelimitedString(supportedMethods, ", "));
			}
			response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, ex.getMessage());
		}
		finally {
			LocaleContextHolder.resetLocaleContext();
		}
	}
	
	private HttpRequestHandler getHttpInvoker(HttpServletRequest request) {
		// URL_PREFIX
		String requestedUri = request.getRequestURI();
		String interfaceName = requestedUri.replace(URL_PREFIX, "");
		if(this.target.containsKey(interfaceName)) {
			return this.target.get(interfaceName);
		}else {
			return null;
		}
	}
	
	private void generateHttpInvoker(String interfaceName, String beanName) {
		HttpInvokerServiceExporter httpInvoker = new HttpInvokerServiceExporter();
		WebApplicationContext wac = WebApplicationContextUtils.getRequiredWebApplicationContext(getServletContext());
		httpInvoker.setService(wac.getBean(beanName));
		try {
			httpInvoker.setServiceInterface(Class.forName(interfaceName));
		} catch (ClassNotFoundException e) {
			e.printStackTrace();
		}
		httpInvoker.afterPropertiesSet();
		this.target.put(interfaceName, httpInvoker);
	}
	
	private void httpInvokerBinder() {
		String candidateChars = this.getInitParameter("parameters");
		String[] candidates = candidateChars.split(",");
		for(String candidate : candidates) {
			candidate = candidate.trim();
			generateHttpInvoker(candidate.split(":")[0], candidate.split(":")[1]);
		}
	}
}

A서버(클라이언트)

[application-context.xml]
기존 사용하고 있는 스프링 컨텍스트에 아래와 같이 빈 등록.

<bean id="httpInvokerAdvisor" class="jade.web.HttpInvokerAdvisor">
  	<property name="serviceUrl" value="http://192.168.4.52:9090/http/execute/"/>
  	<property name="serviceInterface" value="jade.web.ExecuteService"/>
</bean>

[HttpInvokerAdvisor.java]
어느 곳에서든 쉽게 사용할 수 있도록 AOP를 활용합니다.
추후 변경여지가 있는 포인트만 더 분리할 수 있도록 고민 필요합니다.

package jade.web;

import java.util.Arrays;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.remoting.httpinvoker.HttpInvokerProxyFactoryBean;

import lombok.Setter;
import jade.core.entity.ExecutionDetail;
import jade.core.entity.ExecutionLevel;
import smartsuite.ifproxy.core.entity.normalize.NormalizedElement;

@Aspect
public class HttpInvokerAdvisor {
	
	HttpInvokerProxyFactoryBean httpInvokerProxyFactoryBean;
	
	@Setter
	private String serviceUrl;
	
	@Setter
	private String serviceInterface;

	@Around("execution(* jade.web.ExecuteService.executeByNormalizedData(..))")
	public Object invoke1(ProceedingJoinPoint joinPoint) {
		httpInvokerProxyFactoryBean = new HttpInvokerProxyFactoryBean();
		try {
			httpInvokerProxyFactoryBean.setServiceUrl(serviceUrl);
			httpInvokerProxyFactoryBean.setServiceInterface(Class.forName(serviceInterface));
			httpInvokerProxyFactoryBean.afterPropertiesSet();
			
		} catch (Exception e) {
			e.printStackTrace();
		}
		
		Object[] args = joinPoint.getArgs();
		ExecuteService httpClient = (ExecuteService) httpInvokerProxyFactoryBean.getObject();
		NormalizedElement el = (NormalizedElement)args[1];
		ExecutionDetail detail = httpClient.executeByNormalizedData((String)args[0], el, (ExecutionLevel)args[2]);
		return detail;
	}
	
	@Around("execution(* jade.spring.service.ExecuteServiceSpring.executeByNormalizedData(..))")
	public Object invoke2(ProceedingJoinPoint joinPoint) {
		try {
			httpInvokerProxyFactoryBean.setServiceUrl(serviceUrl);
			httpInvokerProxyFactoryBean.setServiceInterface(Class.forName(serviceInterface));
			httpInvokerProxyFactoryBean.afterPropertiesSet();
		} catch (Exception e) {
			e.printStackTrace();
		}
		
		System.out.println("logAround() is running!");
		System.out.println("hijacked method : " + joinPoint.getSignature().getName());
		System.out.println("hijacked arguments : " + Arrays.toString(joinPoint.getArgs()));
		
		ExecuteService httpClient = (ExecuteService) httpInvokerProxyFactoryBean;
		String returnText = httpClient.execute(null, joinPoint.getArgs(), null);
		
		return returnText;
	}
	
}

좋은 웹페이지 즐겨찾기