Spring에서의 요청 흐름 6 - Filter, Interceptor, AOP 구현

Updated: Categories:

비슷비슷해 보이는 Filter, Interceptor, AOP을 직접 구현해보자




이전 포스팅들에서 Spring에서 요청이 들어왔을 때 어떤 일들이 일어나는지 이론을 살펴보았다.

그 내용들을 그림으로 압축하면 다음과 같다.

엄청나게 복잡해 보이지만, 하나하나 살펴보면 모두 앞에서 살펴본 내용들이다.
이제 위 그림 내용을 똑같이 구현해보고 어떻게 동작하는지 직접 살펴보자!!


기본 설정

실습 환경

  • Spring Boot 2.6.4
  • Gradle 7.4
  • Java 11

의존성

필요한 의존성을 추가해주자

build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-aop'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}
  • Spring MVC를 사용하기 위해 spring-boot-starter-web 모듈 추가
  • Spring AOP를 사용하기 위해 spring-boot-starter-aop 모듈 추가
  • lombok 없으면 코딩 못하니까 추가해주자


기본 계층

그리고 맨 처음 그림에 나온 요소들을 모두 구현할 것이다.
모두 구현한 후 요청을 보내고 각 계층마다 로그를 찍어서 흐름을 눈으로 확인할거임.

따라서 가장 끝 계층인 Service부터 구현해보도록 하자.

Service 클래스
@Service
public class TestService {

    public String testService(){
        return "드디어 찾았다!";
    }
}

리얼 대충 만들어주고 Controller를 만들어주자.

Controller 클래스
@RequiredArgsConstructor
@RestController
public class TestController {

    private final TestService testService;

    @GetMapping("/test")
    public String testController() {
        return testService.testService();
    }
}

GET 요청을 보낼 컨트롤러도 만들었다.
그럼 응답할 준비는 끝났고, 다시 앞으로 가서 Filter부터 살펴보자


Filter

그림을 보면 서블릿 컨테이너의 최전방과 DispatcherServlet 사이에서 동작하는 것을 확인할 수 있다.
(필터 또한 서블릿이기에 생명주기를 갖는 것을 잊어선 안된다!)

그럼 필터를 한번 구현해보자.

Filter 클래스
@Slf4j
@Component
public class TestFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("△ Filter - init : 필터 서블릿 초기화");
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        log.info("△ Filter - doFilter start : 필터 시작");
        chain.doFilter(request, response);
        log.info("△ Filter - doFilter end : 필터 끝");
    }

    @Override
    public void destroy() {
        log.info("△ Filter - destroy : 필터 서블릿 제거");
        Filter.super.destroy();
    }
}

Servlet 객체를 Bean으로 등록하기 위해 @Component를 달아준다.

javax.servlet 모듈에 포함된 Filter 인터페이스를 상속받아서 메소드를 재정의해주고 로그를 찍어주자.

  • init() : 요청이 들어오면 필터를 초기화함
  • doFilter() : 필터의 동작을 구현한다.
    • chain.doFilter에서 필터체인이 실행되고 마지막 필터에서 요청을 DispatcherServlet으로 넘긴다
    • chain.doFilter 위에 구현한 로직은 DispatcherServlet으로 넘기기 이전에 실행되고, 아래는 DispatcherServlet에서 응답이 반환된 후 실행된다
  • destroy() : 필터를 종료시킨다


Interceptor

인터셉터는 DispatcherServletHandlerAdapter와 Controller(Handler) 사이에서 동작한다.
(Controller에 AOP가 걸려있다면 당연히 AOP 사이에서 동작함)

그럼 인터셉터를 구현해보자.

Interceptor 클래스
@Slf4j
public class TestInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("☆ Interceptor - preHandle : 인터셉터 시작");
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("☆ Interceptor - postHandle : 인터셉터 끝");
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        log.info("☆ Interceptor - afterCompletion : view 반환");
    }
}

이번엔 HandlerInterceptor를 상속받아서 메소드를 재정의해주고, 또 싹다 로그를 찍어줬다.

  • preHandle() : 핸들러를 실행하기 전 실행됨. false를 리턴하면 핸들러를 실행하지 않는다.
  • postHandle() : 핸들러가 실행되고 ModelAndView를 반환하면 실행됨
  • afterCompletion() : view가 렌더링되면 실행됨

이렇게 구현한 인터셉터는 따로 등록해줘야 한다.

WebMvcConfig 클래스
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new TestInterceptor());
    }
}

WebMvcConfigurer를 상속받은 Bean에서 만들어 놓은 인터셉터를 추가해주면 끝.


AOP

이제 아까 만들어 놓은 Controller 클래스와 Service 클래스에 AOP를 걸어볼 것이다.

우선 Controller 계층부터 걸어주자.

Controller AOP
@Slf4j
@Aspect
@Component
public class ControllerAspect {

    @Pointcut("execution(* com.example.practiceallflow.controller.*.*(..))")
    private void pointCut() {
    }

    @Before("pointCut()")
    public void before(JoinPoint joinPoint) {
        String methodName = joinPoint.getSignature().getName();
        log.info("□ ControllerAOP - Before : " + methodName + " 메소드가 call 되었습니다");
    }

    @AfterReturning("pointCut()")
    public void afterReturning(JoinPoint joinPoint) {
        String methodName = joinPoint.getSignature().getName();
        log.info("□ ControllerAOP - AfterReturning : " + methodName + " 메소드가 return 되었습니다");
    }

    @After("pointCut()")
    public void after(JoinPoint joinPoint) {
        String methodName = joinPoint.getSignature().getName();
        log.info("□ ControllerAOP - After : " + methodName + " 메소드가 종료 되었습니다");
    }

    @Around("pointCut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().getName();
        log.info("□ ControllerAOP - Around start : " + methodName + " 메소드가 시작되었습니다");
        Object result = joinPoint.proceed();
        log.info("□ ControllerAOP - Around end : " + methodName + " 메소드가 끝났습니다");
        return result;
    }
}

나는 그냥 @PointCut에 controller 패키지 하위의 클래스들한테 다 걸어줬다. 어차피 클래스 한개니까…

@PointCut 범위 정규식 규칙은 다음과 같으니 참고하기 바란다.

"execution([접근지정자] [리턴타입] [패키지].[클래스].[메소드]([인자값타입]))"

그리고 JoinPoint는 다 확인하기 위해서 @AfterThrowing 빼고 모두 설정해줬다.
(이번 실습에서 예외를 던질일은 없어서 냅뒀다.)

또 모든 JoinPoint에서의 메소드명을 출력하도록 로그를 찍어준다.

여기서 @Around만 반환값이 있는데, @Around는 파라미터로 ProceedingJoinPoint를 받는 것을 확인할 수 있다.
@Around는 Controller 클래스 전체를 감싸기 때문에, JoinPoint 실행 전과 실행 후 모두 추가 로직을 구현해줄 수 있기 때문이다.

그 다음은 Service 계층에도 AOP를 걸어준다.

Service AOP
@Slf4j
@Aspect
@Component
public class ServiceAspect {

    @Pointcut("execution(* com.example.practiceallflow.service.*.*(..))")
    private void pointCut() {
    }

    @Before("pointCut()")
    public void before(JoinPoint joinPoint) {
        String methodName = joinPoint.getSignature().getName();
        log.info("○ ServiceAOP - Before : " + methodName + " 메소드가 call 되었습니다");
    }

    @AfterReturning("pointCut()")
    public void afterReturning(JoinPoint joinPoint) {
        String methodName = joinPoint.getSignature().getName();
        log.info("○ ServiceAOP - AfterReturning : " + methodName + " 메소드가 return 되었습니다");
    }

    @After("pointCut()")
    public void after(JoinPoint joinPoint) {
        String methodName = joinPoint.getSignature().getName();
        log.info("○ ServiceAOP - After : " + methodName + " 메소드가 종료 되었습니다");
    }

    @Around("pointCut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().getName();
        log.info("○ ServiceAOP - Around start : " + methodName + " 메소드가 시작되었습니다");
        Object result = joinPoint.proceed();
        log.info("○ ServiceAOP - Around end : " + methodName + " 메소드가 끝났습니다");
        return result;
    }
}

@PointCut 범위와 로그 내용만 빼면 ControllerAspect 클래스와 동일하니 설명은 생략한다.


결과

자 이제 Filter -> Interceptor -> AOP 까지 모든 계층의 구현을 완료했으니 서버를 실행시켜보자!!!

두근두근... 잘 될까?

image

서버가 잘 실행됐다면 아래와 같은 로그를 볼 수 있을 것이다.

image

서버가 실행됨과 동시에 필터의 init() 메소드가 실행되며 필터가 초기화된다.
(일반 서블릿은 요청이 들어와야 초기화된다.)


요청

다음은 포스트맨으로 만들어둔 컨트롤러 URL /test로 요청을 보내보자

요청 ㄱㄱ

응답이 잘 도착했다! 그럼 서버에서는 무슨 일이 일어났는지 로그를 살펴보면…


DispatcherServlet 초기화

우선 첫 요청이 들어왔으니 DispatcherServlet이 초기화된다.
한번 생성된 서블릿은 GC가 동작하기 전까지 싱글톤으로 존재한다.


스레드 풀

그리고 하나의 요청은 모두 동일 스레드에서 처리되는 것을 확인할 수 있다.

서블릿 포스팅에서 공부했듯이, 요청이 들어오면 서블릿 컨테이너가 스레드풀에서 요청에 스레드를 할당한 것이다.


요청 흐름

그 다음은 예상했던대로 흘러간다. 아래 그림과 동일한 순서대로 로그가 찍히는 것을 확인할 수 있다.

필터부터 시작해서 인터셉터 찍고~ AOP 까지 동작한다


응답 흐름

그리고 나서 반전없이 다시 역순으로 응답을 반환한다.
그림과 함께 살펴보면 그다지 어려울 것이 없다!


정리

위 실습코드는 Github에 업로드 되어있다.

이번 실습을 마지막으로 Spring에서의 요청 흐름을 모두 알아보았다.

하얗게 불태웠다..

image

깊은 내용까지 다루지는 못했지만, 전반적인 Spring의 전체적인 흐름을 파악하기에는 부족함이 없을 것이다.

Spring의 동작원리를 이해하고 있는 것과 아닌 것의 차이는 꽤나 크므로 (경험상….) Spring 공부에는 끝이 없는 것 같다.
직접 구현해보면 훨씬 이해가 잘 되니 이론을 공부하고 항상 직접 코드를 짜보기를 추천한다 !!