[Spring] Interceptor, Resolver 객체 전달을 통한 리팩토링
- Overview
- Interceptor와 Resolver
- 기존 방식
- 리팩토링 진행 후 코드
- 정리
📌 1. Overview
최근 프로젝트를 개발하면서 로그인 인증 처리와 세션 연장 처리 부분을 Spring Security 사용하지 않고 구현해보는 과정을 진행하였습니다. Interceptor
를 사용하였고 인증된 UserSession 객체를 handler parameter에 전달하기 위해 HandlerMethodArgumentResolver
를 사용하였습니다.
문제는 각각의 interceptor와 Resolver 구현체에서 Redis 조회가 이루어져 비용측면에서 비효율적인 부분이 보였습니다. 이를 개선해본 내용들을 블로그에 글로 정리해봤습니다.
📌 2. Interceptor와 Resolver
Interceptor와 Resolver부분에 대한 자세한 내용은 단순 구글링해도 자료가 많이 있기 때문에 대략적으로 언급하겠습니다.
- Interceptor
DispatcherServlet
에서 handler mapping 이후 해당 handler로 접근하기 전에 Request, Response를 가로채는 역할 수행HandlerInterceptor
를 구현해서 적용
- Resolver
- handler에 들어오는 parameter를 조작하거나 객체를 전달하는데 사용
HandlerMethodArgumentResolver
를 구현해서 적용
Interceptor는 들어온 요청이 handler(쉽게 말해 Controller단)로 전달되기 전, 그리고 handler에서 return 함으로써 응답을 전달하려고 할 때 가로채서 원하는 처리를 수행하도록 지시할 수 있습니다. 프로젝트 진행하면서 주로 로그인 관련된 프로세스에 대해 Interceptor를 적용하였습니다.
Resolver는 로그인 이후에 패스워드 변경과 같은 인증된 사용자 정보가 고정적으로 parameter에 전달되어야 하는 상황에서 적용하였습니다.
📌 3. 기존 방식
public class AuthenticationInterceptor implements HandlerInterceptor {
//...
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(!(handler instanceof HandlerMethod)){
return true;
}
Authentication authAnnotation = ((HandlerMethod)handler).getMethodAnnotation(Authentication.class);
Optional<String> bearerValue = Optional.ofNullable(request.getHeader("Authorization"));
if (authAnnotation != null) {
bearerValue.orElseThrow(TokenNotExistedException::new);
Optional<UserSessionDto> userSession = Optional.ofNullable(redisUtils.getSession(bearerValue.get().substring(7)));
userSession.orElseThrow(() -> new AuthenticationException("로그인이 필요합니다."));
isSeller(userSession.get());
}
return true;
}
private void isSeller(UserSessionDto userSession) {
if (userSession.getSeller() == null ||
!userSession.getSeller()) {
throw new AuthenticationException("판매자 권한이 필요합니다.");
}
}
}
로그인 인증여부 확인과 판매자 권한 여부를 확인하는 Interceptor입니다. 여기서 RedisUtils
객체를 통해 Redis에 해당 key값으로 조회합니다.
public class SessionExtensionInterceptor implements HandlerInterceptor {
//...
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Optional<String> bearerValue = Optional.ofNullable(request.getHeader("Authorization"));
// bearerValue 존재하지 않을 때 세션 연장 로직 패스
bearerValue.ifPresent(bearer -> {
UserSessionDto sessionDto = redisUtils.getSession(bearer.substring(7));
// 세션 만료시간 갱신
redisUtils.renewSessionExpire(sessionDto, bearer);
});
return true;
}
}
요청을 할 때마다 로그인 인증된 사용자의 세션 만료시간을 연장처리해주는 Interceptor입니다. 여기서도 RedisUtils
를 통해 Redis에 해당 UserSession 데이터를 조회합니다.
public class SecurityUserResolver implements HandlerMethodArgumentResolver {
//...
@Override
public boolean supportsParameter(MethodParameter methodParameter) {
return methodParameter.hasParameterAnnotation(LoginUser.class);
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
String bearerValue = webRequest.getHeader("Authorization");
if (bearerValue != null) {
return redisUtils.getSession(bearerValue.substring(7));
}
return null;
}
}
Resolver에서도 Redis에서 해당 인증된 사용자 정보 객체를 조회합니다.
여기서 발견할 수 있는 문제점은 다음과 같습니다.
- 매 요청마다 인증된 사용자 정보 데이터라는 같은 객체를 3번이나 불필요하게 Redis에서 가져옵니다.
@LoginUser
를 handler parameter에 적용하기 위해@Authentication
도 해당 handler method에 설정해야 합니다.
이 부분을 하나하나 해결해보겠습니다.
📌 4. 리팩토링 진행 후 코드
불필요한 Redis 조회부분 제거
// SessionExtensionInterceptor.java
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Optional<String> bearerValue = Optional.ofNullable(request.getHeader("Authorization"));
bearerValue.ifPresent(bearer -> {
UserSessionDto userSession = redisAuthUtils.getSession(bearer.substring(7));
// 세션 만료시간 갱신
redisAuthUtils.renewSessionExpire(userSession, bearer.substring(7));
// interceptor 전달을 위한 처리
request.setAttribute("authSession", userSession);
});
return true;
}
세션 만료 연장 처리하는 Interceptor를 첫 시작점으로 설정했습니다. 여기서 Redis 조회를 통해 인증된 사용자 정보를 조회합니다. 그리고 request에 해당 객체를 담아서 다음 Interceptor에 전달하게 됩니다.
// AuthenticationInterceptor.java
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 모든 handler가 method 타입이 아닐수도 있다.
if(!(handler instanceof HandlerMethod)){
return true;
}
Authentication authAnnotation = ((HandlerMethod)handler).getMethodAnnotation(Authentication.class);
Optional<UserSessionDto> userSession
= Optional.ofNullable((UserSessionDto) request.getAttribute("authSession"));
if (authAnnotation != null) {
userSession.orElseThrow(() -> new AuthenticationException("로그인이 필요합니다."));
isSeller(userSession.get());
}
return true;
}
앞선 Interceptor에서 전달해준 사용자 정보를 가져와서 @Authentication
에 대한 로그인 인증 여부, 판매자 권한 여부 처리를 하게 됩니다. 이렇게 함으로써 한 번의 Redis 조회를 통해 모든 처리를 할 수 있게 됐습니다.
@LoginUser
Custom Annotation 하나로 처리하기
// UserController.java
@PatchMapping("/password")
@Authentication
public ResponseDto<?> changePassword(@RequestBody UserPasswordRequestDto resource,
@LoginUser UserSessionDto userSessionDto) {
userService.updatePassword(userSessionDto.getEmail(), resource.getPassword());
return new ResponseDto<>(0, "성공적으로 비밀번호를 변경하였습니다.");
}
기존에는 @LoginUser
를 parameter에 적용하기 위해서 handler method에 @Authentication
을 같이 적용해야 했습니다. @LoginUser
를 처리해주는 Resolver에는 로그인 인증 여부 처리에 대한 로직이 없기 때문에 해당 로직을 처리해주는 @Authentication
이 있어야 제대로 동작할 것입니다.
@LoginUser
하나만으로 인증 여부 처리까지 할 수 있도록 로직을 추가하였습니다.
// SecurityUserResolver.java
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
return Optional.ofNullable(request.getAttribute("authSession"))
.orElseThrow(() -> new AuthenticationException("로그인이 필요합니다."));
}
또한 여기서도 request를 통해 Interceptor에서 정의한 인증된 사용자 정보를 가져옴으로써 불필요한 Redis 조회를 줄일 수 있었습니다.
📌 5. 정리
- Filter, Interceptor, Resolver 등 클라이언트 요청 후 handler에 도달하기 까지의 과정 중에 어떠한 로직 처리가 필요할 때 사용할 수 있는 여러 방법들이 존재
- Filter → DispatcherServlet → Interceptor → Resolver 순으로 진행되기에 로직의 성격에 따라 어디에 적용해야 할지 고민해야함
- DB와 비슷하게 Redis access를 최대한 줄이는 방식으로 로직을 작성하면 효율적인 애플리케이션 운영이 가능
위의 예시 코드는 부족한 부분이 많기 때문에 참고용으로 보시면 될 것 같습니다.
틀린 내용이 있을 수 있습니다. 이에 대한 코멘트 언제나 환영입니다!
'Spring' 카테고리의 다른 글
[Spring] 이벤트 처리를 위한 스케줄러 작업 조정(ShedLock 활용) (0) | 2021.09.22 |
---|---|
MyBatis insert 구문 사용시 mapping 주의 사항 (0) | 2021.09.07 |
[Spring] 인터페이스 특징과 ISP(인터페이스 분리 원칙)에 대한 정리 (0) | 2021.07.21 |
Outbox 패턴을 이용한 메일 발송 구현해보기 (0) | 2021.07.08 |
[OOP] 객체 지향 프로그래밍은 무엇일까 (0) | 2021.06.21 |
댓글
댓글(Github)
다른 글
-
[Spring] 이벤트 처리를 위한 스케줄러 작업 조정(ShedLock 활용)
[Spring] 이벤트 처리를 위한 스케줄러 작업 조정(ShedLock 활용)
2021.09.22 -
MyBatis insert 구문 사용시 mapping 주의 사항
MyBatis insert 구문 사용시 mapping 주의 사항
2021.09.07 -
[Spring] 인터페이스 특징과 ISP(인터페이스 분리 원칙)에 대한 정리
[Spring] 인터페이스 특징과 ISP(인터페이스 분리 원칙)에 대한 정리
2021.07.21 -
Outbox 패턴을 이용한 메일 발송 구현해보기
Outbox 패턴을 이용한 메일 발송 구현해보기
2021.07.08