[리팩터링 계기]
맡은 프로젝트에서 요청 데이터들은 암호화해서 들어오고, Header에 특정 값을 통해 복호화를 해서 비즈니스 로직을 실행한 다음 다시 암호화를 해서 응답한다.
따라서 암호화된 데이터이기 때문에 컨트롤러에서 @RequestBody를 사용하지 않고 HttpServeltRequest를 파라미터로 전달받아서 진행하는데, 파라미터에 대한 가독성이 떨어지고, 암/복호화에 쓰이는 코드들이 보일러 플레이트화 되어가고 있었다. 따라서 아래의 목표를 달성하기 위한 리팩토링을 진행하였다.
- 운영 중인 서비스이기 때문에 기존의 코드를 최대한 고치지 않는다.
- 컨트롤러 메서드에서 HttpServletRequest를 받고 Javadoc으로 파라미터를 작성하는 방법이 아닌 @RequestBody로 받는다.
- Header에 있는 특정 값으로 DB에서 키를 찾아야 하기 때문에 스프링 빈으로 등록이 되어야 한다.
- Header에 있는 특정 값이 오류가 있을 경우 예외 처리를 한다.
- 로그 처리.
운영 중인 서비스라 기존 코드를 고치지 않고 새로운 코드에 적용하려면 어노테이션 기반으로 적용해야 할 것 같아서
인터셉터, 필터, AOP 등을 찾아보았으나 Body값을 소모하면 다시 사용하지 못하여 Wrapper클래스를 사용해야 하거나 @ResponseBody 어노테이션이 달린 컨트롤러는 응답 메시지를 꺼내서 수정하지 못한다는 문제가 있어서 고민한 결과 ExceptionHandler를 사용하는 ControllerAdvice 쪽에서 처리하면 어떨까?라는 생각으로 진행하게 되었다.
[@ControllerAdvice]
@ControllerAdvice는 Spring이 제공하는 AOP(Aspect Oriented Programming, 관점 지향 프로그래밍)의 기능 중 하나이며, 컨트롤러에 공통적으로 사용되는 것이 있을 때 적용시켜 주는 annotation이다.
(AOP를 모르시면 여기로 가셔서 글 읽어주세요)
흔히 사용하는 @ControllerAdvice의 방법은 대표적으로 Global ExceptionHandler를 만드는 방식으로 많이 쓰이고, 많은 블로그에서 예제를 흔히 찾아볼 수 있으며 Exception 처리만 도와주는 annotation이라고 많은 블로그에서 소개되고 있으나 사실과는 다르다.
해외 포럼에서는 @ControllerAdvice는 Global ExceptionHandler 방식이 아닌 다른 방식으로도 쓰이고 있었고, 아래와 같은 기능들을 갖고 있었다.
[@ControllerAdvice의 기능]
- @ModelAttribute
- @ExceptionHandler
- @InitBinder
- RequestBodyAdvice, ResponseBodyAdvice
[RequestBodyAdvice, ResponseBodyAdvice]
RequestBodyAdvice와 ResponseBodyAdvice는 Spring MVC에서 제공하는 인터페이스로, HTTP 요청 본문과 응답 본문을 처리하는 과정에 추가적인 로직을 적용할 수 있게 해 준다.
RequestBodyAdvice는 HTTP 요청 본문이 객체로 변환되는 과정에 추가적인 로직을 적용하는 데 사용된다.
public interface RequestBodyAdvice {
boolean supports(MethodParameter methodParameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType);
HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException;
Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType);
@Nullable
Object handleEmptyBody(@Nullable Object body, HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType);
}
- supports
- 컨트롤러 메서드의 반환 타입과 선택된 HttpMessageConverter의 타입을 받아와서 이 인터셉터가 적용될지 여부를 반환한다.
- beforeBodyRead
- 요청 본문이 객체로 변환되기 전에 호출.
- 원본 HttpInputMessage를 받아와서 필요한 처리를 수행한 후 새로운 HttpInputMessage를 반환한다.
- afterBodyRead
- 요청 본문이 객체로 변환된 후에 호출.
- 변환된 객체와 원본 HttpInputMessage를 받아와서 필요한 처리를 수행한 후, 최종적으로 컨트롤러 메서드에 전달될 객체를 반환한다.
- handleEmptyBody
- 요청 본문이 비어있을 경우에 호출
ResponseBodyAdvice는 HTTP 응답 본문이 생성되는 과정에 추가적인 로직을 적용하는 데 사용된다.
public interface ResponseBodyAdvice<T> {
boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
@Nullable
T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response);
}
- supports
- 어드바이스가 적용될지 여부를 결정
- beforeBodyWrite
- 응답 본문이 작성되기 전에 호출
- 원본 객체와 여러 가지 매개변수를 받아와서 필요한 처리를 수행한 후 응답 본문으로 작성될 객체를 반환
[DecryptRequestBodyAdvice]
@ControllerAdvice
@RequiredArgsConstructor
@Slf4j
@Order(100)
public class DecryptRequestBodyAdvice extends RequestBodyAdviceAdapter {
private final HttpServletRequest request;
private final AuthService authService;
@Override
public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return methodParameter.hasMethodAnnotation(DecryptBody.class);
}
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
logging(); // 로그
String apiKey = request.getHeader("apiKey");
String privateKey = this.authService.getPrivateKey(apiKey);
if(privateKey.isEmpty()){
throw new AuthKeyEmptyException(); // 에러 처리
}
request.setAttribute("privateKey",privateKey);
return new DecryptHttpInputMessage(inputMessage, privateKey);
}
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
log.debug("reqBody : " + body); // 로그
return super.afterBodyRead(body, inputMessage, parameter, targetType, converterType);
}
private void logging(){
log.debug("로그");
}
}
public class DecryptHttpInputMessage implements HttpInputMessage {
private HttpHeaders headers;
private InputStream body;
public DecryptHttpInputMessage(HttpInputMessage inputMessage, String privateKey) throws IOException{
this.headers = inputMessage.getHeaders();
String encryptedBody = StreamUtils.copyToString(inputMessage.getBody(), Charset.defaultCharset());
String decryptedBody = // 암호화 로직
this.body = new ByteArrayInputStream(decryptedBody.getBytes());
}
@Override
public InputStream getBody() throws IOException {
return body;
}
@Override
public HttpHeaders getHeaders() {
return headers;
}
}
RequestBodyAdvice를 직접 구현하지 않고 RequestBodyAdviceAdapter이라는 추상 클래스를 상속받아 구현했다. RequestBodyAdviceAdapter는 RequestBodyAdvice를 구현하는 디폴트 메서드를 가지고 있다.
한 가지 문제점은 복호화할 때의 값도 헤더에서 가져온 apiKey를 통해 privateKey를 가져와서 진행해야 하는데, ResponseBodyAdvice에서도 DB에서 꺼내오면 문제가 있기 때문에 HttpServletRequest의 생명주기가 요청이 끝나면 사라진다는 것을 알고 setAttribute에 값을 넣어 전달했다.
[EncryptResponseBodyAdvice]
@RestControllerAdvice
@RequiredArgsConstructor
public class EncryptResponseBodyAdvice implements ResponseBodyAdvice<Object> {
private final HttpServletRequest httpRequest;
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return returnType.hasMethodAnnotation(EncryptBody.class);
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
String privateKey = httpRequest.getAttribute("privateKey");
String encryptedBody = // 복호화 로직
return encryptedBody;
}
}
[컨트롤러의 변경]
- 기존 컨트롤러
@PostMapping(value = "/before")
public String beforeControllerMethod(HttpServletRequest req, HttpServletResponse res) {
String encString = null;
log.debug("로그");
String apiKey = req.getHeader("apiKey");
String privateKey = authService.getPrivateKey(apiKey);
if (privateKey.isEmpty()) {
throw new AuthKeyEmptyException();
}
String reqBody = // HttpServletRequest 에서 가져온 바디로 암호화 로직 진행
// reqBody로 객체에 매핑
// 비즈니스 로직 실행
}
- 리팩토링 후 컨트롤러
@EncryptBody
@DecryptBody
@PostMapping(value = "/after")
public String afterControllerMethod(@RequestBody SomeClass someClass) {
// 비즈니스 로직 진행
}
[결론]
깔끔하고 가독성이 좋은 컨트롤러 메서드를 작성할 수 있게 되어서 좋다. 또한 ControllerAdvice가 예외 핸들링만 하는 것이 아닌 인터셉터처럼의 일을 할 수 있는 것을 알게 되어서 앞으로 비슷한 상황이 오면 자주 사용할 것 같다.
[Spring] Spring AOP란?
Spring AOP AOP(Aspect Oriented Programming) AOP는 관점 지향 프로그래밍이라고 불린다. 관점 지향은 객체 지향, 절차 지향 과는 또 다른 패러다임으로 특정 로직을 기준으로 핵심적인 관점, 부가적인 관점
dev-density.tistory.com
'Spring' 카테고리의 다른 글
[Spring] Spring AOP란? (1) | 2023.10.29 |
---|---|
[Spring] Spring IoC / DI 란? (1) | 2023.10.08 |
[Spring] Spring이란? (1) | 2023.10.03 |