직전 포스트에서 ResponseEntity를 소개했다. 이번엔 '인터셉터'라는 놈으로 멋진 일을 하나 해주려고 한다. 바로 컨트롤러로부터 나오는 애들의 형식을 모두 통일해주는 것!
"그냥 컨트롤러에서의 리턴타입을 전부 통일시켜주면 되는거 아닌가요?"
라고 할 수 있다. 컨트롤러의 메서드 하나하나마다 리턴타입을 통일시키고 헤더, 응답코드들을 다 세팅해주고 하면 물론 당연히 응답을 다 통일시켜줄 순 있지만, 개발자는 반복을 싫어한다. 만약 컨트롤러의 메서드가 여러 개 있다면 그들 하나하나마다 리턴타입을 ResponseEntity로 해주고..등의 작업을 해야 하는 것이며, 뭐 헤더를 수정한다거나 하면 이들을 모두 하나하나 다시 건드려줘야 한다.
"이걸 좀 더 편하게 해주자"가 지금 해볼 내용이다.
그럼 이런 일을 어떻게 해줄 수 있는가? 바로 인터셉터를 통해 가능하다.
인터셉터(Interceptor)
스프링이 제공하는 기술로, 컨트롤러의 핸들러(Handler)를 호출하기 전과 후에 요청과 응답을 참조하거나 가공할 수 있게 해주는 놈이다. 좀 더 풀이하자면 클라이언트로부터 들어온 request라는 편지가 컨트롤러라는 마을의 핸들러라는 집에 도착하기 전에 낚아채서(말 그대로 인터셉트) 개발자가 원하는 작업을 해준 후 마저 핸들러의 집에 보낼 수 있게 해주는 녀석이라고 볼 수 있다. 반대로 핸들러로부터 출발한 response라는 편지를 낚아채 개발자가 원하는 작업을 한 후에 클라이언트에게 마저 보내게 할 수도 있는 것.
(※ 핸들러 : 사용자가 요청한 url에 따라 실행되어야 할 메서드)
즉, 특정 컨트롤러의 핸들러가 실행되기 전이나 후에 어떤 작업을 하고 싶을 때 이 인터셉터라는 걸 사용한다. 즉 이를 통해 컨트롤러들이 실행된 후의 객체들에 대해 공통된 작업을 해준다면 응답값 통일을 반복적인 작업없이 간편하게 할 수 있는 거다!
암튼 나만의 인터셉터를 만들고 싶다면, 스프링에서 제공하는 HandlerInterceptor라는 인터페이스를 구현하는 커스텀 클래스를 만들면 된다. HandlerInterceptor는 다음과 같은 3개의 메서드를 가진다.
메서드명 | 리턴 타입 | 설명 | 기본값 |
preHandle | boolean | - 컨트롤러가 호출되기 전에 실행됨 - 컨트롤러의 실행 이전에 처리해야 할 작업이 있을 때 또는 요청정보를 가공할 때 등에 씀 - 실행되어야 할 '핸들러'에 대한 정보를 파라미터로 받음! - true를 리턴하면 preHandle실행 후 핸들러에 접근하나 false를 리턴하면 작업을 중단함 |
true |
postHandle | void | - 핸들러 실행 완료 후 View 생성 전에 실행됨 - ModelAndView 타입의 정보를 파라미터로 받음. 즉 Controller에서 View 정보를 전달하기 위해 작업한 Model의 정보를 참조하거나 조작할 수 있음! |
- |
afterCompletion | void | - View의 렌더링 이후 실행됨 - 요청 처리중에 사용한 리소스를 반환해주기 적당한 메서드 |
- |
여기서 afterCompletion을 활용해 간편한 응답값 통일화 작업이 가능한 거다!
그 방법은 다음과 같다.
우선 다음과 같은 DTO클래스를 만들어준다.
@AllArgsConstructor
@Data
public class SuccessResponseDTO<T> {
private final boolean success;
private final T data;
public SuccessResponseDTO(@Nullable T data) {
this.success = true;
this.data = data;
}
}
클라이언트로 전달되는 모든 응답들은 요 ResonseDTO 형태로 나가도록 통일할 거다.
그리고 전에 만들어뒀던 필터의 doFilterInternal메서드에 코드들을 추가해줬다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 파라미터로 받은 request, response 객체들이 한 번 읽으면 없어지는 애들이라 한 번 감싼 애들을 만들어준다. 일종의 복제
ContentCachingRequestWrapper wrappingRequest = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper wrappingResponse = new ContentCachingResponseWrapper(response);
String token = tokenProvider.getTokenFromHeader(request);
if (token != null && tokenProvider.validateToken(token, jwtSecretKey)) {
Authentication authentication = tokenProvider.getAuthentication(token, jwtSecretKey);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 필터엔 복제한 애들 넘기기. 때문에 인터셉터도 래핑된 객체를 파라미터로 받음
filterChain.doFilter(wrappingRequest, wrappingResponse);
// 마지막에 클라이언트에게 response나갈 때 wrapping한 내용을 써줌(복제본 내용을 원본에 쓴다!)
wrappingResponse.copyBodyToResponse();
}
request, response를 감싼 객체들을 만들고 이 녀석들을 doFilter메서드에 인자로 넘기는 모습이다. (이전에는 request, response를 넘겼었음) 그리고 마지막에 캐싱한 응답값을 원래 응답에 덮어씌우는 모습.
우리가 하고 싶은 건 컨트롤러에서 뱉어진 응답을 인터셉터에서 가로채 afterCompletion() 메서드를 통해 통일된 응답값 형태로 만들고 싶은 것이니, 이때 필연적으로 인터셉터에서 HttpServletResponse의 Stream을 읽어야만 한다. 그런데 슬픈 사실은 HttpServletRequest, HttpServletResponse은 한 번씩만 읽을 수 있도록 구현된 애들이라는 거..(개발하신 분들이 이렇게 만들었다고) 근데 스프링이 Converter를 이용해 response의 내용을 json으로 바꿔주는 과정에서도 HttpServletResponse를 읽게 되니까 총 2번을 읽게 되어 오류가 생긴다는 거다.
이런 말도 안되는 문제를 해결하기 위해 wrapper클래스를 만들어 이 놈들의 내용을 저장해놓은 후 계속해서 쓰는 방식을 이용하는 것.
이 때 response를 wrapping한 객체는 마지막에 copyBodyToResponse()메서드를 꼭 불러줘야 한다. 실제로 클라이언트에게 보낼 response에 자신(wrapping한 객체)의 버퍼에 있는 내용을 덮어씌우는 메서드라고 함.
그리고 다음과 같은 커스텀 인터셉터를 만들어줬다.
@RequiredArgsConstructor
@Component
public class MyInterceptor implements HandlerInterceptor {
private final String SUCCESS_PREFIX = "2";
private final String JSON_CONTENT_TYPE = "application/json";
// response를 object로 매핑해야 됨
private final ObjectMapper objectMapper;
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex
) throws Exception {
// 필터에서 wrapping됐던 response가 산 넘고 강 건너 결국 이 메서드로 넘어올 것임
final ContentCachingResponseWrapper cachingResponse = (ContentCachingResponseWrapper) response;
// 200번대 응답이 아니면 인터셉터를 거치지 않도록
if (!isSuccessStatus(response.getStatus())) {
return;
}
if (cachingResponse.getContentType() != null
&& cachingResponse.getContentType().contains(JSON_CONTENT_TYPE)
&& cachingResponse.getContentAsByteArray().length != 0) {
// String 변환
String body = new String(cachingResponse.getContentAsByteArray());
// Object 형식으로 변환 (Response에 꽂아주기 위함)
Object data = objectMapper.readValue(body, Object.class);
// 컨트롤러가 뱉는 모든 DTO들을 형식에 상관없이 ResponseDTO에 담는 것!
SuccessResponseDTO<Object> objectSuccessResponseDTO = new SuccessResponseDTO<>(data);
// String 변환
String wrappedBody = objectMapper.writeValueAsString(objectSuccessResponseDTO);
// 비우고 (원래는 여기에 SuccessResponseDTO가 아닌 다른 놈, 즉 통일된 형식을 갖추기 전의 무언가가 있었을 거니까)
cachingResponse.resetBuffer();
// 웅답값 교체
cachingResponse.getOutputStream().write(wrappedBody.getBytes(), 0, wrappedBody.getBytes().length);
}
}
private boolean isSuccessStatus(int status) {
return String.valueOf(status).startsWith(SUCCESS_PREFIX);
}
}
※ ObjectMapper : JSON을 Java객체로 역직렬화하거나 Java 객체를 JSON으로 직렬화할 때 자주 쓰이는 클래스(from Jackson 라이브러리). 그 외에도 Java객체의 내용물을 확인하거나 파싱하는데에 쓰임.
생성자가 한개라면 @Autowired 어노테이션을 생략가능한 점 + @RequiredArgsConstructor를 통해서 objectMapper를 주입받는 모습인데, 나는 ObjectMapper를 빈으로 등록한 적이 없다! 근데 왜 되는건 궁금해서 검색해보니까, 스프링 부트가 기본적으로 빈으로 등록한 ObjectMapper가 있다고 한다. JacksonAutoConfiguration을 뜯어보니까 요로코롬 자동으로 등록된 놈이 있는 걸 볼 수 있었음.
@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)
public class JacksonAutoConfiguration {
// 생략
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(Jackson2ObjectMapperBuilder.class)
static class JacksonObjectMapperConfiguration {
@Bean
@Primary
@ConditionalOnMissingBean
ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
return builder.createXmlMapper(false).build();
}
}
// 생략
}
암튼..
이렇게 내가 만든 커스텀 인터셉터를 다음과 같이 등록해줬다.
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final MyInterceptor myInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(myInterceptor)
.addPathPatterns("/**");
}
}
- addInterceptor : 내가 만든 인터셉터를 등록하는 메서드
- addPathPatterns : 이 인터셉터를 호출할 경로 추가
- excludePathPatterns : 이 인터셉터를 호출하지 않을 경로 설정
이제 끝났다.
내가 이해한 대로 동작순서를 정리하면 결국 다음과 같다.
- 클라이언트가 쏜 요청이 디스패처 서블릿에 도착하기 전에 필터를 만나게 됨.
- 필터에서 request, response를 래핑한 객체를 만들어서 얘를 디스패처 서블릿으로 보냄
- 디스패처 서블릿은 얘를 이러쿵저러쿵해서 컨트롤러한테 보냄
- 컨트롤러의 핸들러(본 케이스에서는 login메서드)에 의해 응답(본 케이스에서는 LoginResponseDTO)가 뱉어짐. 즉 얘가 response래핑한 객체에 담김
- 인터셉터에서 얘를 낚아서 SuccessResponseDTO로 형식 맞춰주고 이걸 response래핑한 객체에 갈아끼워넣음
- 필터에서 response래핑한 객체에 있는 내용물을 실제 response로 덮어씌워줌
그래서 한번 요청을 보내본 결과는..
응답값이 SuccessResponseDTO에서 맞춰준대로 통일된 모습이다.
이런식으로 개발한다면, 응답값을 만들어주는 작업을 컨트롤러 단에서 할 필요가 없다. 단순히 DTO만 반환시키면 된다! 반환한 DTO가 인터셉터 단에서 SuccessResponseDTO의 data로 꽂아지고, response를 갈아끼워서 응답을 내보내게 되기 때문. 이렇게해서 응답값이 통일이 된다.
소소한 실험
필터에서 request와 response를 래핑하는데, 사실 request까지 래핑할 필요가 있을까? 란 생각이 들었다. response는 두 번 읽게 되는데 request는 그렇지 않을 것 같았기 때문.
그래서 request는 래핑하지 말고 실험해봤다.
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
ContentCachingResponseWrapper wrappingResponse = new ContentCachingResponseWrapper(response);
String token = tokenProvider.getTokenFromHeader(request);
if (token != null && tokenProvider.validateToken(token, jwtSecretKey)) {
Authentication authentication = tokenProvider.getAuthentication(token, jwtSecretKey);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, wrappingResponse);
wrappingResponse.copyBodyToResponse();
}
결과는 성공이었따..!
참고한 글들입니다.
https://popo015.tistory.com/115
https://kihwan95.tistory.com/7
https://kimvampa.tistory.com/127
'PROJECT > 개발일지' 카테고리의 다른 글
[백엔드 야생 개발일지] 게시글 등록 기능 만들어보기 (2) (0) | 2023.02.27 |
---|---|
[백엔드 야생 개발일지] 게시글 등록 기능 만들어보기 (1) (0) | 2023.02.20 |
[백엔드 야생 도전일기] 인터셉터를 통해 응답값 통일해보기 (1) - ResponseEntity 뜯어보기 (0) | 2023.02.01 |
[백엔드 야생 도전일기] 스프링으로 회원가입/로그인기능 만들어보기 (4) (0) | 2023.01.31 |
[백엔드 야생 도전일기] 스프링으로 회원가입/로그인기능 만들어보기 (3) (0) | 2023.01.29 |