[회고] Spring Custom Request Mapping
* 환경
- spring 4.3.30
어떤 제목을 붙여야되나 엄청 많이 고민되는 글이었다..
정확히 말하자면 기존 RequestMapping을 확장하면 자꾸 RequestMapping이 Override 되길래 Override안되고 추가로 계속 붙게 해주기위해 개발했던 경험을 적은 글! ㅎㅎ
회사 시스템에 struts framework와 spring framework가 함께 사용되고 있다. 대부분은 spring으로 구성되어있지만, 흔히 말하는 MVC구조에서 C인 Controller를 우리는 스프링의 controller대신 스트럿츠의 Action으로 구조가 잡혀있었다.
나는 계속 이부분을 controller를 사용할 수 있도록 구조를 변경하고 싶었는데 마침 외부시스템과 연동을 할 작업이 생겨 이참에 restful api 구조를 잡아보자 라는 목표를 잡고 작업을 시작했다. (이 외부시스템도 이번에 내가 만들어야했던거여서 괜찮은..?.. 시행착오를 겪으며 도입할수있었다 😂)
그동안은 스트럿츠의 Action을 사용했기때문에 Request Mapping을 계층형?으로 만들필요가없었고 (*.do 패턴을 사용함)
패키지 구조는 해당 Action과 매핑되는 화면명의 카멜표기법의 대문자별로 나눈거라 크게 고민거리가 없었다.
Controller를 사용하게됨으로서 Request Mapping을 어떤 룰로 정할지 컨벤션을 고민하게되었다. 패키지는 어떤 룰로 나누며.. 그리고 어떤 것들끼리 한 컨트롤러에 들어가도 되는지.. 등등
내가 이번에 도입하는 방식이 우리팀 모든 개발자가 따라 만들 표준이 되기때문에.. 굉장히 고민이 됐다.. 😥
그 중 이번에 작성할 내용은 api version에 관련된 내용이다.
외부에서 호출할 api를 만들다보니까 '버전이 들어가면 좋겠다'라는 생각이 들었다. 그리고 큰 회사들의 api들을 봐도 url에 v1같은 버전이 들어가는걸보고 더 넣어야겠다고 생각이 들었다 ㅎㅎ
그럼 이제 여기서 고민이 시작되는거다. 🤔
버전을 어떻게하면 깔끔하게 넣어줄수있을까?
물론 controller의 @RequestMapping마다 맨 앞단에 v1을 붙여주면 된다. 그럼 해결이 된다.
하지만 깔끔하지 못하다. 😔
고민이 정말 많았다.. 저렇게 버전을 나누는 회사의 개발자들에게 조언을 구하고싶었다...😥
버전별로 서비스를 나눠서 띄우면 제일 깔끔하고 편할거라고생각이 들었지만 우리 시스템 특성상 v1, v2, v3... 여러버전이 생긴다고 서비스를 따로 띄울수없다. 기껏해야 package정도 분리해서 버전관리를 해야한다.
그럼 v1 패키지에 있는 controller들에 공통적으로 v1을 붙여주려면 어떻게해야할까?
여기까지 생각이 진행될무렵 차장님께서 해당 controller가 지금은 v1이지만 v2로 변경될경우도 고려를 해야한다고 조언을 해주셨다.
그럼 패키지로 접근을 하면 안되겠다ㅠㅠ 라는 생각이 들었고 그래서 시작된 나의 시행착오.. 🎉
고민을 하다하다가 결국 선택한건 단순히 @RequestMapping("/v1")을 가진 class를 상속하자! 였다..ㅎㅎ
분명 더 좋은 방법이 있을것같지만.. 시간도 없고해서 이 방향으로 진행했다. 그렇다고 controller마다 다 v1을 적어줄수는 없으니..
아래와 같은 @RequestMapping을 v1으로 지정해준 Version1 클래스가 있다
package api.webservice.v1;
import org.springframework.web.bind.annotation.RequestMapping;
@RequestMapping("/v1")
public class Version1 {
}
그리고 아래 TestController에서는 Version1을 상속받고있다
package api.webservice.v1.controller;
import javax.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import api.webservice.common.response.dto.WebSerivceResponse;
import api.webservice.v1.dto.RequestLogRequest;
import api.webservice.v1.woreq.Version1;
@RestController()
public class TestController extends Version1 {
@GetMapping("/log")
private ResponseEntity<WebSerivceResponse> log(HttpServletRequest request, RequestLogRequest param) throws Exception {
System.out.println("수키로그");
return ResponseEntity.status(HttpStatus.OK).body(WebSerivceResponse.getInstance());
}
}
🤔 그럼 어떤 경로로 요청을 해야 "수키로그"라는게 찍힐까?
정답은 /v1/log 이다
🤔 그렇다면 아래와같이 TestController에 @RequestMapping("/test")를 추가해주면 경로가 어떻게 바뀔까?
...
@RestController()
@RequestMapping("/test")
public class TestController extends Version1 {
...
}
정답은 /test/log 이다
나는 log메서드가 /v1/test/log라는 경로를 갖기를 원했지만
실제로는 Version1에 들어간 RequestMapping은 상속된 순간 @RequestMapping("/test")가 override하기때문에 /v1/test/log 경로는 나오지않는다
그래서 RequestMappingHandlerMapping을 상속받는 PathRequestMappingHandlerMapping이라는 클래스를 만들어서 메서드의 매핑을 가져오는 getMappingForMethod부분을 재정의했다.
상속받는 클래스의 경로도 차곡차곡모아서 붙여주도록..!
package api.webservice.config;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
public class PathRequestMappingHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
RequestMappingInfo methodMapping = super.getMappingForMethod(method, handlerType);
if (methodMapping == null) {
return null;
}
List<String> superclassUrlPatterns = new ArrayList<String>();
boolean springPath = false;
for (Class<?> clazz = handlerType; clazz != Object.class; clazz = clazz.getSuperclass()) {
if (clazz.isAnnotationPresent(RequestMapping.class)) {
if (springPath) {
superclassUrlPatterns.add(clazz.getAnnotation(RequestMapping.class).value()[0]);
} else {
springPath = true;
}
}
}
if (!superclassUrlPatterns.isEmpty()) {
RequestMappingInfo superclassRequestMappingInfo = new RequestMappingInfo("", new PatternsRequestCondition(String.join("", superclassUrlPatterns)), null, null, null, null, null, null);
return superclassRequestMappingInfo.combine(methodMapping);
} else {
return methodMapping;
}
}
}
그리고 WebConfig에 적용해주었다
package api.webservice.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.web.servlet.config.annotation.DelegatingWebMvcConfiguration;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
@Configuration
public class WebConfig extends DelegatingWebMvcConfiguration {
@Override
protected RequestMappingHandlerMapping createRequestMappingHandlerMapping() {
return new PathRequestMappingHandlerMapping();
}
@Bean
@Primary
@Override
public RequestMappingHandlerMapping requestMappingHandlerMapping() {
return super.requestMappingHandlerMapping();
}
}
이렇게하면 드디어 내가 원하는 /v1/test/log 경로가 만들어진다. ✨✨
그래서 controller마다 extends Version1을 붙여주고있는데, 뭔가 더 좋은 방법이 있을것같아 아쉽다ㅠ
어느날 번뜩이는 생각이 찾아오길... 👀