컨트롤러에서 로그인 여부 확인, 사용자 정보 호출 등의 부가 기능이 섞여 동시에 처리하는 일이 있다. 이는 가독성과 유지 보수성 저하와 계층간의 분리 문제가 있다.
이러한 문제들은 AOP, Filter, Interceptor 등을 이용해 비즈니스 로직과 부가 기능을 분리할 수 있다.
| AOP | Filter | Interceptor | |
|---|---|---|---|
| 주체 | 스프링 | 서블릿 | 스프링 |
| 개념 | 메서드 호출 전-후로 특정 메서드에 부가 기능을 실행(삽입) 시킬 수 있다 | HTTP 요청과 응답을 가로채 필터링 가능 | 디스패처 서블릿과 컨트롤러(핸들러) 사이에서 컨트롤러 호출 직전에 실행된다. |
| 적용 범위 | 특정 비즈니스 로직이나 메서드에 사용 | 전체 애플리케이션, 특정 서블릿에 대해 적용 가능 | 특정 컨트롤러에 대해 적용 |
| 실행 시점 | 메서드 호출 전-후 | HTTP 요청이 서블릿에 도달하기 전 | 컨트롤러 호출 전-후 |
| 사용 목적 | 로깅, 트랜잭션 | 요청 전처리, 인증 및 인가등 HTTP 관련 작업 | 요청-응답 전처리 , 세션 관리 및 인증 |
| 사례 | 트랜잭션 관리, 로깅 | 인증 및 인가, 로깅, 요청 및 응답 변환 | 요청 인증, 권한 체크, 공통 로직 처리 |
→ Interceptor로 선택
SNS 서비스에서는 로그인을 해야만 접근이 가능한 기능들 예를 들면 게시글 작성, 구도(팔로우), 댓글 등이 존재한다.
Interceptor 적용 전에는 로그인 시 생성된 session에서 현재 사용자의 정보를 꺼내와 로그인 유무를 확인한다.
Controller
private final LikeService likeService;
private final SessionLoginService sessionLoginService;
@PostMapping("/{postId}")
public void feedLike(Long userId, @PathVariable Long postId) {
userId = sessionLoginService.getCurrentUserId();
likeService.feedLike(userId, postId);
}
Service
public Long getCurrentUserId() {
Long userId = (Long) httpSession.getAttribute(LOGIN_USER);
if (userId == null) {
throw new GlobalException(ErrorCode.UNAUTHENTICATED_USER);
}
return userId;
}
커스텀 애노테이션 정의
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {
}
커스텀 애노테이션을 정의할때는 해당 애노테이션이 사용될 위치(@Target), 라이프 사이클(@Retention)를 정의해야 한다.
@LoginRequired 는 메서드에 사용되며, 런타임 시 생성되며 런타임 종료시 사라진다.Interceptor 정의
@Component
@RequiredArgsConstructor
public class LoginRequiredInterceptor implements HandlerInterceptor {
private final SessionLoginService loginService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
LoginRequired loginRequired = handlerMethod.getMethodAnnotation(LoginRequired.class);
if (loginRequired == null) {
return true;
}
if (loginService.getCurrentUserId() == null) {
throw new GlobalException(ErrorCode.UNAUTHENTICATED_USER);
}
}
return true;
}
}
LoginRequiredInterceptor는 HandlerInterceptor를 구현한다.
HandlerInterceptor에는 3가지 메서드가 있다.
→ 로그인 유무 확인시에는 컨트롤러 호출 전에만 호출하면 되므로 preHandle()만 구현
HandlerMethod handlerMethod = (HandlerMethod) handler : 호출될 컨트롤러의 메서드를 의미handlerMethod.getMethodAnnotation(LoginRequired.class) : 메서드가 @LoginRequired가 존재하는지 확인loginService.getCurrentUserId() == null : session에 저장된 사용자 정보가 null 이라면 GlobalException(ErrorCode.UNAUTHENTICATED_USER)의 401 예외 발생Interceptor 등록
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final LoginRequiredInterceptor loginRequiredInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginRequiredInterceptor);
}
Interceptor 적용 후
@LoginRequired
@PostMapping("/{postId}")
public void feedLike(Long userId, @PathVariable Long postId) {
userId = sessionLoginService.getCurrentUserId();
likeService.feedLike(userId, postId);
}
public Long getCurrentUserId() {
return (Long) httpSession.getAttribute(LOGIN_USER);
}
→ 현재 로그인한 사용자의 정보를 가져오는 불필요한 Exception 처리가 사라진다.
Interceptor를 통해 Service에서 불필요한 Exception을 제거했지만 로그인 사용자의 정보를 가져오는 로직인
userId = sessionLoginService.getCurrentUserId() 아직 존재한다. 해당 로직은 부가 기능이면서 또한 중복되는 로직이다.
이번에는 위에서 사용한 커스텀 애노테이션과 HandlerMethodArgumentResolver 를 통해 처리가 가능하다.
HandlerMethodArgumentResolver 의 대표적인 예로는 @PathVariable이 존재한다.
HandlerMethodArgumentResolver 구현커스텀 애노테이션 정의
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface CurrentUser {
}
@CurrentUser는 파라미터에 사용되며 런타임 동안 유지된다.HandlerMethodArgumentResolver 구현
@Component
@RequiredArgsConstructor
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
private final SessionLoginService loginService;
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasAnnotation = parameter.hasParameterAnnotation(CurrentUser.class);
boolean typeMatch = Long.class.isAssignableFrom(parameter.getParameterType());
return hasAnnotation && typeMatch;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
return loginService.getCurrentUserId();
}
}
supportsParameter() : 해당 메서드의 반환값이 true면 resolveArgument()를 실행hasAnnotation() : 메서드 파라미터에 @CurrentUser 이 있는지 확인typeMatch() : 파라미터의 타입이 Long 타입인지 확인resolveArgument() : 현재 로그인한 사용자의 정보를 반환HandlerMethodArgumentResolver 등록
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final CurrentUserArgumentResolver currentUserArgumentResolver;
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(currentUserArgumentResolver);
}
private final LikeService likeService;
@PostMapping("/{postId}")
public void feedLike(@CurrentUser Long userId, @PathVariable Long postId) {
likeService.feedLike(userId, postId);
}
→ 컨트롤러에서는 이제 별도의 부가 기능 로직을 처리하지 않으며 동시에 중복 로직도 제거가 된다.