[사이드프로젝트] 그저 그런 REST API로 괜찮은가? - 진정한 REST API 구현해보기 - EventController Refactoring 중에 생긴 트러블 슈팅(methodOn)
EventController 리팩토링 진행
강의를 따라가면서 코드를 작성했는데 구현에 초점을 둬서 코드가 깨끗하지 않았다. 따라서 우선 강의를 멈추고 EventController 리팩토링을 진행했다.
리팩토링은 아래와 같이 진행됐다.
리팩토링 항목
- 공통적으로 사용하는 selfAndUpdateLink 만드는 부분 분리하기
- 링크를 추가하는 부분 분리하기(query-event & self Link ...)
- profile Link 부분 분리 후, 상속을 활용해서 EventResource & PageModel 둘 다 사용할 수 있도록 하기
- 공통적으로 사용하는 Event -> EventResource & addLinks 로직 분리하기
- PageModel 내부 Event 모델을 EventResource로 매핑하는 로직 분리하기
기존 코드
@Api(tags = {"Event Controller"})
@RestController()
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE)
public class EventController {
@Autowired
private EventService eventService;
@ApiOperation(value = "Event 객체를 추가하는 메소드")
@PostMapping("")
public ResponseEntity create(@RequestBody @Valid EventDto eventDto) {
Event event = this.eventService.create(eventDto);
EventResource eventResource = createEventResource(event,
"/swagger-ui/index.html#/Event%20Controller/createUsingPOST");
URI uri = linkTo(methodOn(EventController.class)
.create(new EventDto()))
.slash(eventResource.getEvent().getId()).toUri();
return ResponseEntity.created(uri).body(eventResource);
}
@ApiOperation(value = "모든 Event 객체를 읽어오는 메소드")
@GetMapping("")
public ResponseEntity readAll(Pageable pageable, PagedResourcesAssembler pagedResourcesAssembler) {
var result = pagedResourcesAssembler
.toModel(this.eventService.readWithPage(pageable).map(event -> {
EventResource eventResource = createEventResource(event,
"/swagger-ui/index.html#/Event%20Controller/readUsingGET");
return eventResource;
}));
result.add(new Link(getBaseURL() + "/swagger-ui/index.html#/Event%20Controller/readAllUsingGET","profile"));
return ResponseEntity.ok(result);
}
@ApiOperation(value = "Event 단일 객체를 읽어오는 메소드")
@GetMapping("/{id}")
public ResponseEntity read(@PathVariable Integer id){
Event event = this.eventService.read(id);
EventResource eventResource = createEventResource(event,
"/swagger-ui/index.html#/Event%20Controller/readUsingGET");
return ResponseEntity.ok(eventResource);
}
@ApiOperation(value = "이벤트 객체를 수정하는 메소드")
@PutMapping("/{id}")
public ResponseEntity update(@RequestBody @Valid EventDto eventDto, @PathVariable Integer id){
Event event = this.eventService.update(id, eventDto);
EventResource eventResource = createEventResource(event,
"/swagger-ui/index.html#/Event%20Controller/updateUsingPUT");
return ResponseEntity.ok(eventResource);
}
private EventResource createEventResource(Event event, String profileLink){
EventResource eventResource = new EventResource(event);
addLinks(eventResource, profileLink);
return eventResource;
}
private void addLinks(EventResource eventResource, String profileLink){
WebMvcLinkBuilder selfAndUpdateLink = linkTo(methodOn(EventController.class)
.create(new EventDto()))
.slash(eventResource.getEvent().getId());
WebMvcLinkBuilder queryLink = linkTo(methodOn(EventController.class));
eventResource.add(queryLink.withRel("query-events"));
eventResource.add(selfAndUpdateLink.withRel("update-event"));
eventResource.add(selfAndUpdateLink.withSelfRel());
eventResource.add(new Link(getBaseURL() + profileLink,"profile"));
}
private String getBaseURL(){
return ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString();
}
}
공통적으로 사용하는 selfAndUpdateLink 만드는 부분 분리하기
selfAndUpdateLink는 이벤트를 만들고 수정할 수 있는 링크다. 이 링크를 사용하는 부분은 크게 세 가지로 created URI, SelfLink, UpdateLink이다. 따라서 링크 구현 부분을 따로 분리하여 타입 세이프하게 로직을 구현하려고 한다.
private WebMvcLinkBuilder getSelfAndUpdateLink(EventResource eventResource){
return linkTo(methodOn(EventController.class)
.create(new EventDto()))
.slash(eventResource.getEvent().getId());
}
링크를 추가하는 부분 분리하기
지금 링크를 추가하는 부분은 addLinks에서 진행된다.
이때 각 링크를 구현하는 부분이 모두 addLinks에서 구현되어 있다.
따라서 링크 구현하는 부분을 분리하고 addLinks에서는 링크를 연결시켜주는 로직만 담당하도록 한다.
private void addLinks(EventResource eventResource, String profileLink){
addQueryLink(eventResource);
addUpdateLink(eventResource);
addSelfLink(eventResource);
addProfileLink(eventResource, profileLink);
}
private void addUpdateLink(EventResource eventResource){
WebMvcLinkBuilder selfAndUpdateLink = getCreateAndUpdateLink(eventResource);
eventResource.add(selfAndUpdateLink.withRel("update-event"));
}
private void addSelfLink(EventResource eventResource){
WebMvcLinkBuilder selfAndUpdateLink = getCreateAndUpdateLink(eventResource);
eventResource.add(selfAndUpdateLink.withSelfRel());
}
private void addQueryLink(EventResource eventResource){
WebMvcLinkBuilder queryLink = linkTo(methodOn(EventController.class));
eventResource.add(queryLink.withRel("query-events"));
}
private void addProfileLink(EventResource eventResource, String profileLink){
eventResource.add(new Link(getBaseURL() + profileLink,"profile"));
}
profile Link 부분 분리 후, 상속을 활용해서 EventResource & PageModel 둘 다 사용할 수 있도록 하기
현재 profile Link를 사용하는 엔티티는 크게 EventResource와 PageModel이다. 이 두 객체는 공통적으로 RepresentationModel을 상속받는다.
따라서 profile Link를 추가하는 로직을 분리하고 RepresentationModel을 활용해서 둘 다 공통적으로 사용할 수 있도록 한다.
private void addProfileLink(RepresentationModel eventResource, String profileLink){
eventResource.add(new Link(getBaseURL() + profileLink,"profile"));
}
공통적으로 사용하는 Event -> EventResource & addLinks 로직 분리하기
Event model을 EventResource로 변환하는 작업과 addLinks 로직은 여러 곳에서 공통적으로 사용된다. 코드 반복을 방지하기 위해서 이를 분리하기로 했다.
private EventResource createEventResource(Event event, String profileLink){
EventResource eventResource = new EventResource(event);
addLinks(eventResource, profileLink);
return eventResource;
}
PageModel 내부 Event 모델을 EventResource로 매핑하는 로직 분리하기
PageModel 내부에는 EventService ReadAll 메소드를 통해서 Event Model이 담겨있다. 하지만 Self_descritive와 HATEOAS를 만족하기 위해선 Event Model을 EventResource로 변환해줘야 한다.
현재 readAll() 메소드 안에서 PageModel Event -> EventResource 로직과 addProfile 로직이 동시에 구현되어 있다. addProfile 로직은 분리됐으니 이 Event -> EventResource 변환 로직도 분리하기로 했다.
private PagedModel createPagingModel(Pageable pageable, PagedResourcesAssembler pagedResourcesAssembler){
return pagedResourcesAssembler
.toModel(this.eventService.readWithPage(pageable).map(event -> {
EventResource eventResource = createEventResource(event,
"/swagger-ui/index.html#/Event%20Controller/readUsingGET");
return eventResource;
}));
}
1차 리팩토링 코드
@Api(tags = {"Event Controller"})
@RestController()
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE)
public class EventController {
@Autowired
private EventService eventService;
@ApiOperation(value = "Event 객체를 추가하는 메소드")
@PostMapping("")
public ResponseEntity create(@RequestBody @Valid EventDto eventDto) {
Event event = this.eventService.create(eventDto);
EventResource eventResource = createEventResource(event,
"/swagger-ui/index.html#/Event%20Controller/createUsingPOST");
URI uri = getSelfAndUpdateLink(eventResource).toUri();
return ResponseEntity.created(uri).body(eventResource);
}
@ApiOperation(value = "모든 Event 객체를 읽어오는 메소드")
@GetMapping("")
public ResponseEntity readAll(Pageable pageable, PagedResourcesAssembler pagedResourcesAssembler) {
var result = createPagingModel(pageable, pagedResourcesAssembler);
addProfileLink(result,
"/swagger-ui/index.html#/Event%20Controller/readAllUsingGET");
return ResponseEntity.ok(result);
}
@ApiOperation(value = "Event 단일 객체를 읽어오는 메소드")
@GetMapping("/{id}")
public ResponseEntity read(@PathVariable Integer id){
Event event = this.eventService.read(id);
EventResource eventResource = createEventResource(event,
"/swagger-ui/index.html#/Event%20Controller/readUsingGET");
return ResponseEntity.ok(eventResource);
}
@ApiOperation(value = "이벤트 객체를 수정하는 메소드")
@PutMapping("/{id}")
public ResponseEntity update(@RequestBody @Valid EventDto eventDto, @PathVariable Integer id){
Event event = this.eventService.update(id, eventDto);
EventResource eventResource = createEventResource(event,
"/swagger-ui/index.html#/Event%20Controller/updateUsingPUT");
return ResponseEntity.ok(eventResource);
}
private EventResource createEventResource(Event event, String profileLink){
EventResource eventResource = new EventResource(event);
addLinks(eventResource, profileLink);
return eventResource;
}
private PagedModel createPagingModel(Pageable pageable, PagedResourcesAssembler pagedResourcesAssembler){
return pagedResourcesAssembler
.toModel(this.eventService.readWithPage(pageable).map(event -> {
EventResource eventResource = createEventResource(event,
"/swagger-ui/index.html#/Event%20Controller/readUsingGET");
return eventResource;
}));
}
private void addLinks(EventResource eventResource, String profileLink){
addQueryLink(eventResource);
addUpdateLink(eventResource);
addSelfLink(eventResource);
addProfileLink(eventResource, profileLink);
}
private void addUpdateLink(EventResource eventResource){
WebMvcLinkBuilder selfAndUpdateLink = getSelfAndUpdateLink(eventResource);
eventResource.add(selfAndUpdateLink.withRel("update-event"));
}
private void addSelfLink(EventResource eventResource){
WebMvcLinkBuilder selfAndUpdateLink = getSelfAndUpdateLink(eventResource);
eventResource.add(selfAndUpdateLink.withSelfRel());
}
private void addQueryLink(EventResource eventResource){
WebMvcLinkBuilder queryLink = linkTo(methodOn(EventController.class));
eventResource.add(queryLink.withRel("query-events"));
}
private void addProfileLink(RepresentationModel eventResource, String profileLink){
eventResource.add(new Link(getBaseURL() + profileLink,"profile"));
}
private WebMvcLinkBuilder getSelfAndUpdateLink(EventResource eventResource){
return linkTo(methodOn(EventController.class)
.create(new EventDto()))
.slash(eventResource.getEvent().getId());
}
private String getBaseURL(){
return ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString();
}
}
에러 발생 : NullPointerException
리팩토링한 코드를 테스트해보니 다음과 같이 깨졌다.
에러를 확인해보니 다음과 같이 NullPointerException이 떴다.
발생한 코드를 확인해보니 query-link를 붙이는 로직에서 발생했다.
private void addQueryLink(EventResource eventResource){
WebMvcLinkBuilder queryLink = linkTo(methodOn(EventController.class));
eventResource.add(queryLink.withRel("query-events"));
}
정확히는 이 코드의 linkTo 메소드에 null 값이 들어갔다는 얘기고 methodOn() 메소드가 null을 반환했다는 얘기다.
methodOn 메소드를 타고 올라가서 WebMvcLinkBuilder class 내부를 살펴보면서 어떤 값을 리턴하는지 확인해봤다.
//WebMvcLinkBuilder.class
public static <T> T methodOn(Class<T> controller, Object... parameters) {
return DummyInvocationUtils.methodOn(controller, parameters);
}
methodOn 함수는 T, 즉 클래스 타입을, 정확히는 DummyInvocatioin.Utils로 만들어진 클래스 타입을 반환했다.
한번 더 DummyInvocationUtils를 타고 올라가봤다.
public class DummyInvocationUtils {
private static final ThreadLocal<Map<DummyInvocationUtils.CacheKey<?>, Object>> CACHE = ThreadLocal.withInitial(HashMap::new);
public DummyInvocationUtils() {
}
public static <T> T methodOn(Class<T> type, Object... parameters) {
Assert.notNull(type, "Given type must not be null!");
return ((Map)CACHE.get()).computeIfAbsent(DummyInvocationUtils.CacheKey.of(type, parameters), (it) -> {
DummyInvocationUtils.InvocationRecordingMethodInterceptor interceptor = new DummyInvocationUtils.InvocationRecordingMethodInterceptor(it.type, it.arguments);
return getProxyWithInterceptor(it.type, interceptor, type.getClassLoader());
});
}
...
}
DummyInvocationUtils에서 사용하는 methodOn에서는 getProxyWithInterceptor를 반환한다. 여기서 proxy 객체를 사용한다는 감이 왔다.
확실히 하기 위해서 한번 더 타고 올라가봤다.
private static <T> T getProxyWithInterceptor(Class<?> type, DummyInvocationUtils.InvocationRecordingMethodInterceptor interceptor, ClassLoader classLoader) {
if (type.equals(Object.class)) {
return interceptor;
} else {
ProxyFactory factory = new ProxyFactory();
factory.addAdvice(interceptor);
factory.addInterface(LastInvocationAware.class);
if (type.isInterface()) {
factory.addInterface(type);
} else {
factory.setOptimize(true);
factory.setTargetClass(type);
factory.setProxyTargetClass(true);
}
return factory.getProxy(classLoader);
}
}
역시 ProxyFactory를 통해서 프록시 객체로 만들어서 반환한 것이다.
ProxyFactory는 Spring에서 AOP를 구현하기 위해서 사용하는 프록시를 만드는 객체로 methodOn() 메소드를 통해서 만들어진 객체가 프록시 객체라는 것을 알 수 있었다.
따라서 NullPointerException이 발생한 이유는 linkTo 메소드에 프록시 객체가 파라미터로 넘어갔고 실제 객체를 만들기 위해선 invoke가 필요하다. invoke는 프록시 객체에서 메소드를 실행해야만 한다.
Spring Docs에서도 찾아보니 다음과 같은 주의사항이 있었다.
methodOn(…) creates a proxy of the controller class that records the method invocation and exposes it in a proxy created for the return type of the method. This allows the fluent expression of the method for which we want to obtain the mapping. However, there are a few constraints on the methods that can be obtained by using this technique:
- The return type has to be capable of proxying, as we need to expose the method invocation on it.
- The parameters handed into the methods are generally neglected (except the ones referred to through @PathVariable, because they make up the URI).
간단하게 해석하면(정확하진 않을 수 있다.) methodOn은 프록시 객체를 통해서 쉽게 메소드 매핑 정보를 가져올 수 있다. 그러나 몇 가지 제약사항이 있다.
- 리턴 타입은 프록시가 가능해야하기 때문에 메소드를 호출해줘야 한다.
- 파라미터로 넘어오는 객체 값은 무시된다.
첫 번째 제약조건을 보면 프록시로 사용하기 위해서 메소드를 호출해줘야 한다는 부분 때문에 NullPointerException이 발생한 거 같다.
따라서 methodOn()를 통해 불러온 객체에서 메소드를 호출해주거나 methodOn() 메소드를 제거하는 방향으로 코드를 수정해야한다.
private void addQueryLink(EventResource eventResource){
WebMvcLinkBuilder queryLink = linkTo(methodOn(EventController.class).getClass());
eventResource.add(queryLink.withRel("query-events"));
}
또는
private void addQueryLink(EventResource eventResource){
WebMvcLinkBuilder queryLink = linkTo(EventController.class);
eventResource.add(queryLink.withRel("query-events"));
}
개인적으로 여기서 프록시 객체를 쓸 필요가 없을 듯 하여 아래 방법으로 코드를 수정, 테스트해보니 성공적으로 통과했다.
최종 EventController 코드
@Api(tags = {"Event Controller"})
@RestController()
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE)
public class EventController {
@Autowired
private EventService eventService;
@ApiOperation(value = "Event 객체를 추가하는 메소드")
@PostMapping("")
public ResponseEntity create(@RequestBody @Valid EventDto eventDto) {
Event event = this.eventService.create(eventDto);
EventResource eventResource = createEventResource(event,
"/swagger-ui/index.html#/Event%20Controller/createUsingPOST");
URI uri = getCreateAndUpdateLink(eventResource).toUri();
return ResponseEntity.created(uri).body(eventResource);
}
@ApiOperation(value = "모든 Event 객체를 읽어오는 메소드")
@GetMapping("")
public ResponseEntity readAll(Pageable pageable, PagedResourcesAssembler pagedResourcesAssembler) {
var result = createPagingModel(pageable, pagedResourcesAssembler);
addProfileLink(result,
"/swagger-ui/index.html#/Event%20Controller/readAllUsingGET");
return ResponseEntity.ok(result);
}
@ApiOperation(value = "Event 단일 객체를 읽어오는 메소드")
@GetMapping("/{id}")
public ResponseEntity read(@PathVariable Integer id){
Event event = this.eventService.read(id);
EventResource eventResource = createEventResource(event,
"/swagger-ui/index.html#/Event%20Controller/readUsingGET");
return ResponseEntity.ok(eventResource);
}
@ApiOperation(value = "이벤트 객체를 수정하는 메소드")
@PutMapping("/{id}")
public ResponseEntity update(@RequestBody @Valid EventDto eventDto, @PathVariable Integer id){
Event event = this.eventService.update(id, eventDto);
EventResource eventResource = createEventResource(event,
"/swagger-ui/index.html#/Event%20Controller/updateUsingPUT");
return ResponseEntity.ok(eventResource);
}
private EventResource createEventResource(Event event, String profileLink){
EventResource eventResource = new EventResource(event);
addLinks(eventResource, profileLink);
return eventResource;
}
private PagedModel createPagingModel(Pageable pageable, PagedResourcesAssembler pagedResourcesAssembler){
return pagedResourcesAssembler
.toModel(this.eventService.readWithPage(pageable).map(event -> {
EventResource eventResource = createEventResource(event,
"/swagger-ui/index.html#/Event%20Controller/readUsingGET");
return eventResource;
}));
}
private void addLinks(EventResource eventResource, String profileLink){
addQueryLink(eventResource);
addUpdateLink(eventResource);
addSelfLink(eventResource);
addProfileLink(eventResource, profileLink);
}
private void addUpdateLink(EventResource eventResource){
WebMvcLinkBuilder selfAndUpdateLink = getCreateAndUpdateLink(eventResource);
eventResource.add(selfAndUpdateLink.withRel("update-event"));
}
private void addSelfLink(EventResource eventResource){
WebMvcLinkBuilder selfAndUpdateLink = getCreateAndUpdateLink(eventResource);
eventResource.add(selfAndUpdateLink.withSelfRel());
}
private void addQueryLink(EventResource eventResource){
WebMvcLinkBuilder queryLink = linkTo(EventController.class);
eventResource.add(queryLink.withRel("query-events"));
}
private void addProfileLink(RepresentationModel eventResource, String profileLink){
eventResource.add(new Link(getBaseURL() + profileLink,"profile"));
}
private WebMvcLinkBuilder getCreateAndUpdateLink(EventResource eventResource){
return linkTo(methodOn(EventController.class)
.create(new EventDto()))
.slash(eventResource.getEvent().getId());
}
private String getBaseURL(){
return ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString();
}
}
Author And Source
이 문제에 관하여([사이드프로젝트] 그저 그런 REST API로 괜찮은가? - 진정한 REST API 구현해보기 - EventController Refactoring 중에 생긴 트러블 슈팅(methodOn)), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@carrykim/사이드프로젝트-그저-그런-REST-API로-괜찮은가-진정한-REST-API-구현해보기-EventController-Refactoring-중에-생긴-트러블-슈팅methodOn저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)