HTTP Controller를 REST API로

YGKim·2022년 8월 2일
0

솔루션 연동

목록 보기
3/3

노션에서 원본으로 보기

➡️ 솔루션 코어에서는 XML 설정과 MultiActionFormController ( MultiActionController + SimpleController ) 를 이용해 모든 요청을 처리하고, MethodNameResolver를 통해 적절한 Controller에게 위임하는 방식으로만 구현되어 있습니다. → REST Controller를 구현하기 위해 @Controller, @RequestMapping, @ResponseBody 애너테이션을 적용시키고, Jackson (JSON라이브러리) 을 이용하여 기존처럼 응답하도록 만듭니다.

1. 기존 코드

https://velog.io/@hsw0194/Spring-MVC-HandlerMapping의-동작방식-이해하기-1편

  • Request 시 현재 SimpleController 방식의 순서.
    1. web.xml의 Web 서블릿이 DispatcherServlet으로 매핑한다.
    2. DispatcherServlet.doDispatch()→ SimpleControllerHandlerAdapter.handle() →
      UserExtNoSessionController.handleRequestInternal() → …
      → onSubmit() 에서 →
      MultiActionFormController (extends SimpleFormController).process()를 호출함.
      process() 내에서, 를 사용하여 “method=get…” URL 파싱하여 메서드 호출
    • 기존 메서드 및 파라미터
  • 기존의 URL 매핑 추가 방식
    1. POST /destinyAPI.do?method=getDocumentList¶ms …
    2. /destinyAPI.do=destinyAPIController
      webConfig_SITE.properties 내에서 webContext-controllers.properties로 합쳐짐.
    3. webContext-controllers.xml 에 정의된 method 로 인해, ?method=”메서드명” 으로 매핑하여 호출

2. 코드 변경

2-1. 애너테이션 적용, 기능 정리

  • DispatcherServlet의 HandlerAdapters (코어에서 설정한 XML)
저 **RequestMappingHandlerAdapter**에게 **@RequestMapping**을 인식시켜야 한다.

(applicationContext-web.xml에서 HandlerAdapter bean에 이미 등록되어 있음)

```xml
<!-- default adapter(org/springframework/web/servlet/DispatcherServlet.properties) -->
    <bean id="httpRequestHandlerAdapter" class="org.springframework.web.servlet.mvc.HttpRequestHandlerAdapter" />
    <bean id="simpleControllerHandlerAdapter" class="org.springframework.web.servlet.mvc.SimpleControllerHandlerAdapter" />
    <bean id="annotationMethodHandlerAdapter" class="org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter" />
    <bean id="jacksonMessageConverter" class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter" />
    <bean id="requestMappingHandlerAdapter" class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter">
        <property name="messageConverters">
            <list>
                <ref bean="jacksonMessageConverter"/>
            </list>
        </property>
    </bean>
    <bean id="requestMappingHandlerMapping" class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping">
        <property name="interceptors">
            <list>
                <ref bean="localeChangeInterceptor"/>
            </list>
        </property>
        <property name="order" value="3"/>
    </bean>
```
  • 적용 방법과 그렇게 한 이유
    1. 애너테이션 인식/활성화
      <context:component-scan> 으로 bean 을 생성하여, @Controller, @RequestMapping 등의 MVC @(Annotation) 이 인식되면서 스프링 컨테이너에 등록되도록 한다. ( 다른 방법으로는, annotation-driven, annotation-config 태그 등을 이용하여 MVC 컴포넌트를 활성화 하는 방법도 있다 )

    2. 응답 형식 정형화
      Client에게 주는 응답을 정형화하기 위해 일반적으로 ResponseDTO 등의 Java객체로 생성해 전달하는데, 코어에서 이미 구현되어 있는 ExternalApiResult 가 있으므로 사용 ( Builder 혹은 도메인 객체 치환 메서드 추가로 구현하면 좋을듯 )

    3. @ResponseBody
      3-1. @Responsebody 을 사용하면, return시 ViewResolver 대신에 MessageConverter가 동작 한다. 따라서, View 페이지가 아닌 반환값 그대로 클라이언트한테 return 하고 싶은 경우 사용하며 HTTP body에 자바 객체를 치환한 JSON 형식으로 전달할 수 있다.

      3-2. JSON 치환은 applicationContext-web.xml에서 “requestMappingHandlerAdapter”에 “messageConverters”로 등록된 “jacksonMessageConverter”가 수행해준다. ( MappingJackson2HttpMessageConverter 인데, Spring Boot에서는 기본값이며 솔루션에서도 명시하여 설정되어 있다. )

    4. ExternalApiResult … private T result;
      new ExternalApiResult()
      HashMap형태로 응답 데이터를 작성한다.
      - 제네릭 타입을 String으로 설정한 경우 예시

          **2. 응답 형식 정형화** → 에서 해당 객체를 이용한 응답을 하려고 하는데, “HashMap param”을 String으로 변환하고 싶다면, jackson 라이브러리로 Stringify (문자열로 치환)하여 “result”에 넣어줄 수 있다. **new ExternalApiResult<String>()인 경우, result의 타입도 String**
          jackson 라이브러리의 [ObjectMapper](https://interconnection.tistory.com/137) ,[Jackson라이브러리 이해하기](https://mommoo.tistory.com/83) ,[Jackson ObjectMapper Config](https://kwonnam.pe.kr/wiki/java/jackson)
          요약: Java Object ( Collection ) ↔ JSON 으로 매핑 기능을 제공함.
          
    5. DTO로 응답하기
      응답할 객체를 조작/유효성 검사/불필요(비밀)정보 제외 등의 필요가 있다면, builder 패턴의 DTO 객체를 작성후 사용하면 된다.
      여기서는 HashMap에 필요한 데이터만 담고 있으므로 생략

2-2. URI 설계 변경

Path Variable과 Query Parameter는 언제 사용해야 할까?

요약: resource를 식별하고 싶으면Path Variable을 사용하고, 정렬이나 필터링을 한다면Query Parameter를 사용

/users  # 사용자 목록을 가져온다.
/users?occupation=programer  # 프로그래머인 사용자 목록을 가져온다.
/users/123  # 아이디가 123인 사용자를 가져온다.

기존 URL Mapping 변환 예시

1.      /destinyAPI.do?method=getServerStatus&userCode=007  ->  /api/servers?userCode=007
2.      /destinyAPI.do?method=getFolderList&account=admin&OID=E000  ->  /api/folders/E000?account=admin
3.      /destinyAPI.do?method=getDocumentList&account=admin&OID=1O8W6k020Fc&currentPage=1&pageUnit=10
																									->
				/api/folders/1O8W6k020Fc?account=admin&currentPage=1&pageUnit=10

4.      /destinyAPI.do?method=uploadFile&account=admin&folderOID=1O8W6k020Fc&fileName=보수지침_20190101.pdf
																									->
        

[카카오API 이미지 업로드 가이드 참고](https://developers.kakao.com/docs/latest/ko/kakaostory/rest-api#upload-image-request)

Untitled

2-3. 응답 Data (JSON) 형식

  1. 기존 응답 ( getFolderList() )

    // ModelAndView 객체로부터 생성된 JSON 응답값 - 약 400라인
    {"message": "",
        "status": 200,
        "params": {
    				"status": "open",
            "data": {
    							"OID": "E000",
    	            "fullPathName": "전사 폴더>대상",
    								... 생략
    				},
            "children": [
                {
                    "object": {
                        "groupType": "",
    				...
    }
    • ModelAndView 객체에 담아 전달한 JSON 응답값.
  2. 변경 내용 ( /folders/{XFolderOID 등} )

    // JacksonMessageConverter 로부터 변환된 JSON 응답값 - 약 1300라인
    {
        "success": true,
        "errorCode": "",
        "errorMessage": "",
        "data": {
            "OID": "E000",
            "fullPathName": "전사 폴더>대상",
            "parentOID": "C_ROOT",
            "title": "대상",
            "status": "open",
            "children": [
                {
                    "resultMap": {},
                    "object": {
                        "opCodes": null,
    							... 쭉쭉 ...
    }
    • ModelAndView를 이용해 응답 받은 JSON 데이터와 크기가 차이가 나는 점을 발견했다. ( 1.82KB → 23.9KB, 400Line → 1,300Line )

    • modelAndView.setViewName( “jsonView”); 가 세팅되어 있었는데,
      public class JsonView extends AbstractView implements MessageSourceAware {} 를 사용하여
      null이나 Collections, Class 등을 설정한 옵션에 따라 StringBuffer에 append 하는 식으로 JSON Stringify 하고 있었다.
      → 즉, 세부적인 권한값 등의 ArrayList가 일부분 빠져있는 JSON이었음.

    • 현재 @RequestMapping을 이용하여 구현한 Controller는 requestMappingHandlerAdapter에 property로 설정된 JacksonMessageConverter가 Java → JSON으로 변환해 준다. 추후에 특정 데이터를 숨겨야 하는등의 필요가 발생하면, JsonView 방식을 적용하기, @JsonIgnore 사용하기,
      JacksonMessageConverter를 구현(커스텀) 하는 방법이나, DTO 객체를 생성하여 송/수신 하는 방법(추천) 하여 필요한 정보만 전달하면 되겠다.

2-4. 코드 수정

  • 변경 전 → 변경 후 ( 아래 링크 클릭하여 보기 )
  • 변경된 코드 ( 삼각형 버튼 클릭하여 보기 )
    public ModelAndView getFolderList(HttpServletRequest request, HttpServletResponse response) throws Throwable{
    
    		s_Logger.info("---------- folderList() Begin ----------");
    		ModelAndView mv = new ModelAndView();
    		HashMap<String, Object> paramKeyValue = new HashMap<String, Object>();
    		String OID, account = null;
    		try {
    
    			OID = ServletRequestUtils.getStringParameter(request, WebConstants.PARAM_OID);
    			account = ServletRequestUtils.getStringParameter(request, "account");
    	        if (StringUtil.isEmpty(OID)) {
    	            throw new DestinyDMException(DestinyCMException.OIDIsNull);
    	        }
    
    			s_Logger.info("param[OID] : " + OID);
    
    			String systemCode = checkApiKey(request);
    			createDummySession(request, account);
    			
    			XTreeNode treeNode = null;
    			XFolder folder = null;
    			if( XFolder.CONST_OID_DEPT_FOLDER.equals( OID)) {
    				treeNode = EDMServiceUtil.getFolderService().getTreeNode(OID, true, null, null, null, ObjectConstants.OBJ_TYPE_XDocument, SortObject.SORT_TYPE_NOT, true);
    				folder = EDMServiceUtil.getFolderService().get(OID, null);
    			} else {
    				treeNode = EDMServiceUtil.getFolderService().getTreeNode(OID, true);
    				folder = (XFolder) treeNode.getObject();
    			}
    	        
    	        
    	        XTreeNode[] children = new XTreeNode[0];
    	        if (treeNode.getChildren() != null && treeNode.getChildren().size() > 0) {
    	        	children = (XTreeNode[]) Utils.toArray(treeNode.getChildren());
    	        }
    	        
    	        String status = treeNode.isFlagLeaf() ? null : "closed";
    	        if (ObjectUtil.isNotEmpty( treeNode.getChildren())) {
    	        	status = "open";
    	        }
    
    	        HashMap<String, Object> data = new HashMap<String, Object>();
    	        data.put("title", folder.getName());
    	        data.put("OID", folder.getOID());
    	        data.put("parentOID", folder.getParentOID());
    	        data.put("fullPathIndex", folder.getFullPathIndex());
    	        data.put("fullPathName", folderBLO.getFullPathName(OID));
    	        
    	        HashMap<String, Object> params = new HashMap<String, Object>();
    	        params.put("status", status);
    	        params.put("data", data);
    	        params.put("children", children);
    	        
    	        paramKeyValue.put("params", params);
    	        paramKeyValue.put("status", 200);
    	        paramKeyValue.put("message", "");
    	        mv.addObject( WebConstants.MODEL_RESPONSE_OBJECT, paramKeyValue);
    	        mv.setViewName( RESPONSE_VIEW_NAME);
    		} catch (DestinyCMException e) {
    			e_Logger.error("ErrMessage : " + e.getMessage());
    			e_Logger.error("Cause : " + e.getCause());
    			e_Logger.error("Codes : " + e.getCodes());
    			e.printStackTrace();
    
    			mv = getResponseMsg( request, response, paramKeyValue, e.getErrCode(), e.getMessage(), RESPONSE_VIEW_NAME, null, null);
    			((ResponseMessage) mv.getModel().get(WebConstants.MODEL_RESPONSE_MESSAGE)).setStatus(500);
    			((ResponseMessage) mv.getModel().get(WebConstants.MODEL_RESPONSE_MESSAGE)).setMessage(e.getMessage());
    
    		} catch (DestinyException e) {
    			e_Logger.error("ErrCode : " + e.getErrCode());
    			e_Logger.error("Message : " + e.getMessage());
    			e.printStackTrace();
    
    			mv = getResponseMsg( request, response, paramKeyValue, e.getErrCode(), e.getMessage(), RESPONSE_VIEW_NAME, null, null);
    			((ResponseMessage) mv.getModel().get(WebConstants.MODEL_RESPONSE_MESSAGE)).setStatus(500);
    			((ResponseMessage) mv.getModel().get(WebConstants.MODEL_RESPONSE_MESSAGE)).setMessage(e.getMessage());
    
    		} catch (Exception e) {
    			e_Logger.error("ErrMessage : " + e.getMessage());
    			e_Logger.error("Cause : " + e.getCause());
    			e.printStackTrace();
    
    			mv = getResponseMsg( request, response, paramKeyValue, "", e.getMessage(), RESPONSE_VIEW_NAME, null, null);
    			((ResponseMessage) mv.getModel().get(WebConstants.MODEL_RESPONSE_MESSAGE)).setStatus(500);
    			((ResponseMessage) mv.getModel().get(WebConstants.MODEL_RESPONSE_MESSAGE)).setMessage(e.getMessage());
    
    		}finally{
    			s_Logger.info("---------- folderList() End ----------");
    			logout(request, response);
    		}
    
    		mv.addObject("status", true);
    		return mv;
    	}
    @RequestMapping(value="/folders/{OID}", method=RequestMethod.GET)
    	@ResponseBody
    	public ResponseEntity<?> getFolderList(HttpServletRequest request, HttpServletResponse response, @PathVariable("OID") String OID) {
    
    		s_Logger.info("---------- folderList() Begin ----------");
    		RestResult<HashMap> responseResult = new RestResult<HashMap>();
    		String account = null;
    		try {
    
    			account = ServletRequestUtils.getStringParameter(request, "account");
    	        if (StringUtil.isEmpty(OID)) {
    	            throw new DestinyDMException(DestinyCMException.OIDIsNull);
    	        }
    
    			s_Logger.info("param[OID] : " + OID);
    
    			String systemCode = checkApiKey(request);
    			createDummySession(request, account);
    
    			XTreeNode treeNode = null;
    			XFolder folder = null;
    			if( XFolder.CONST_OID_DEPT_FOLDER.equals( OID)) {
    				treeNode = EDMServiceUtil.getFolderService().getTreeNode(OID, true, null, null, null, ObjectConstants.OBJ_TYPE_XDocument, SortObject.SORT_TYPE_NOT, true);
    				folder = EDMServiceUtil.getFolderService().get(OID, null);
    			} else {
    				treeNode = EDMServiceUtil.getFolderService().getTreeNode(OID, true);
    				folder = (XFolder) treeNode.getObject();
    			}
    
    	        XTreeNode[] children = new XTreeNode[0];
    	        if (treeNode.getChildren() != null && treeNode.getChildren().size() > 0) {
    	        	children = (XTreeNode[]) Utils.toArray(treeNode.getChildren());
    	        }
    
    	        String status = treeNode.isFlagLeaf() ? null : "closed";
    	        if (ObjectUtil.isNotEmpty( treeNode.getChildren())) {
    	        	status = "open";
    	        }
    
    	        HashMap<String, Object> data = new HashMap<String, Object>();
    	        data.put("title", folder.getName());
    	        data.put("OID", folder.getOID());
    	        data.put("parentOID", folder.getParentOID());
    	        data.put("fullPathIndex", folder.getFullPathIndex());
    	        data.put("fullPathName", folderBLO.getFullPathName(OID));
    	        data.put("children", children);
    	        data.put("status", status);
    
    	        responseResult.setData(data);
    	        responseResult.setSuccess(true);
    
    		} catch (Exception e) {
    			return restExceptionHandler(e);
    
    		}finally{
    			logout(request, response);
    		}
    
    		return new ResponseEntity<Object>(responseResult, HttpStatus.OK);
    	}
  • ResponseEntity의 Body 전송용 객체
    package destiny.custom.DAESANG.web.api;
    
    import java.io.Serializable;
    import java.util.HashMap;
    
    public class RestResult <T extends Serializable> implements Serializable {
        private static final long serialVersionUID = 1L;
    
    	private boolean success = true;
        public boolean isSuccess() {
            return success;
        }
        public void setSuccess(boolean success) {
            this.success = success;
        }
    
        private String errorCode;
        public String getErrorCode() {
            return errorCode;
        }
        public void setErrorCode(String errorCode) {
            this.errorCode = errorCode;
        }
    
        private String errorMessage;
        public String getErrorMessage() {
            return errorMessage;
        }
        public void setErrorMessage(String errorMessage) {
            this.errorMessage = errorMessage;
        }
    
        private T data;
        public T getData() {
            return data;
        }
        public void setData(T data) {
            this.data = data;
        }
    
        public RestResult(boolean success) {
        	this(success, "");
        }
    
        public RestResult(boolean success, String errorCode) {
        	this(success, errorCode, "");
        }
    
        public RestResult(boolean success, String errorCode, String errorMessage) {
        	this(success, errorCode, errorMessage, null);
        }
    
        public RestResult(boolean success, String errorCode, String errorMessage, T data) {
    		this.success = success;
    		this.errorCode = errorCode;
    		this.errorMessage = errorMessage;
    		this.data = data;
    	}
    
        public RestResult() {
        	this(true, "", "");
        }
    
    }
💡
  • HTTP 주요 Method인 ( GET , POST , PUT (혹은 PATCH) , DELETE ) 를 이용하여 각각 조회, 등록, 수정, 삭제의 기능에 매핑하는것이 적절하겠으나 GET , POST로만 구현 되어 있습니다. 추후 개선이 필요한 부분입니다.
  • try-catch 구문이 중복으로 모든 메서드마다 붙어 있어서 공통 메서드로 추출하였다. @ExceptionHandler나, @ControllerAdvice로 따로 AOP처럼 분리가 가능한데 코어에 설정된 Error/Exception 처리의 영향으로 인해 메서드로만 추출하기로 하였다. Exception 들아 (@ExceptionHandler)
  • 2-3.1 (기존 응답 JSON)에서 params - { data , children , status } 로 빠져 있던 부분을, 모두 2-3.2 처럼 data 하위로 집어넣음.

3. 고민-공부한 과정들

  • 기존 적용된 SimpleUrlHandlerMapping에 어떻게든 적용해 보려다가 실패한 흔적 web.xml
    <servlet-mapping>
            <servlet-name>Web</servlet-name>
            <url-pattern>/api/*</url-pattern>
    </servlet-mapping>
    
    REST API 에서 사용할 Url도 DispatcherServlet에 매핑
    webConfig_SITE.properties
    해당 파일에,
    webContext-controllers.properties에 append 되도록 
    /api/**=destinyRESTController를 추가한다.
    
    PropertiesFactoryBean의 property로 webContext-controllers.properties가 들어가 결국 
    SimpleUrlHandlerMapping Bean에 등록됨
    여기까지 하면, “/api/*” 로 들어오는 HTTP Request가 DefaultController로 등록되어 있는
    MultiActionFormController (extends SimpleFormController) 로 간다. 하지만, 해당 Controller는 ParameterMethodNameResolver 를 사용해 “method” 라는 식의 메서드를 가진 Controller들 처리만 수행한다. 나의 Controller에서 extends DispatcherServlet을 상속 받을시에도, 동일하게 동작한다. Spring이 제공하는 MVC Controller , DispatcherServlet분석 , Spring Annotation활성화 를 참고해서 @Controller등의 어노테이션이 동작하도록 해서 등록해보자. 공부가 더 필요할 듯..
  • 이게 RESTful URI가 맞나 getDocumentList
    기존:
    http://127.0.0.1:8080/destinyAPI.do?method=getDocumentList
    &account=admin&OID=1O8W6k020Fc¤tPage=1&pageUnit=10 변경 예시:
    http://127.0.0.1:8080/folders/1O8W6k020Fc/documents?account=admin¤tPage=1&pageUnit=10
    http://127.0.0.1:8080/folders/1O8W6k020Fc/users/admin/documents?currentPage=1&pageUnit=10 어떻게 가야할까. 굳이 users로 빼서 admin(혹은 계정명 등) 을 노출해야 좋은건지..
  • Exception 들아 (@ExceptionHandler)
    • @ExceptionHandler를 사용하여, 해당 클래스 내에서 발생하는 Exception을 공통처리 할 수 있다.
      현재는 RestExceptionHandler가 메서드로 구현되어 catch에서 호출되지만, 아래와 같이 변경이 가능하다.
      ```java
      @ExceptionHandler(ServletRequestBindingException.class)
      	@ResponseBody
      	private ResponseEntity<?> RestExceptionHandler(HttpServletRequest request, Exception e) {
      		
      		logout(request, null);
      		HttpStatus httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
      
      		if( e instanceof DestinyException) {
      			httpStatus = HttpStatus.BAD_REQUEST;
      		}
      
      		e_Logger.error("getServerStatus Cause : " + e.getCause());
      		e_Logger.error("getServerStatus Message : " + e.getMessage());
      		RestResult<HashMap> responseResult = new RestResult();
      		responseResult.setSuccess(false);
      		responseResult.setErrorCode("CUSTOM");
      		responseResult.setErrorMessage(e.getMessage());
      		responseResult.setData(new HashMap());
      
      		return new ResponseEntity<Object>(responseResult, httpStatus);
      	}
      /*	------------------------------------------------------------------------   */
      	<bean id="exceptionHandlerExceptionResolver" class="org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver"  autowire="byName" customaction="$[add]"/>
      ```
      
      @ExceptionHandler(ServletRequestBindingException.class) 에서 지정한, “ServletRequestBindingException” 이 발생시에 해당 메서드가 잘 호출되지만, 솔루션 코어내에 등록된 예외 이벤트 관련 부분에서 결국 Exception Page를 보내게 되어, ResponseEntity를 응답 해주지 않는다.
  • 이게 뭔지 알려면, 이거에 쓰인 이게 뭔지부터 알아야 함.
    1. BaseRestApiServerController
      코어에 “외부 연동REST API Controller”에 쓰일 목적으로, BaseRestApiServerController 가 구현되어 있다. Base- 객체는 Interface에 공통/필수적인 기능 등을 1차적으로 구현해 둔 것이라 Service, DAO 같은 레이어별로 BaseService, BaseDAO… 등이 있으며 대부분 상속하고 사용하는게 좋다.

      BaseRestApiServerController는,

    • Header에 RSA암호화 된 Token을 생성,확인,관리하는 Authenticator
    • 일괄적으로 HTTP CharSet을 UTF-8로 설정하는 defaultRequestCharset
    • Jackson 라이브러리 사용을 위한 ObjectMapper
    • 기타 DummySession 관리나 Logging등이 구현되어 있다.
    • (InitializingBean)을 implements하여, Bean 생성시점에 오버라이드 한 afterPropertiesSet 메서드에서 위의 ObjectMapper 에 대한 세팅을 하고 있다.
      ( FAIL_ON_UNKNOWN_PROPERTIES = false , ACCEPT_EMPTY_STRING_AS_NULL_OBJECT = true 등 )
  • PiiExternalController에서 사용중인 Authenticator 구현
    • Pii 기능 제공을 위해 해당 API가 구현되어 있다. POST /externalApi/pii 요청시,
      executeHandler() → new Handler().proceed() return “success” →
      ExternalApiResult externalResult.setSuccess(true) .setResult(result(”success”));
      의 흐름으로 처리가 되는데, 1) 처리가 완료 되는 때까지 대기 후 응답 2) Header에 Basic 인증 토큰 처리 등의 기능이 구현되어 있어서 필요시 참고
      ( 2)의 토큰 인증을 추가하려 했으나, BadPaddingException으로 인해 기능 구현을 보류했음 )
profile
아이디어가 많은,

0개의 댓글