웹 애플리케이션에서 사용자에게 맞춤형 콘텐츠를 제공하는 것은 필수적입니다.
예를 들어, 특정 사용자에게만 접근을 허용하는 관리자 페이지나, 로그인한 사용자에게만 보여주는 정보 수정 페이지 등이 있습니다.
이러한 페이지에 대한 접근 제어가 제대로 이루어지지 않으면, 일반 사용자가 관리자 페이지에 접근하거나 로그인하지 않은 사용자가 제한된 페이지에 들어갈 수 있는 심각한 보안 문제가 발생할 수 있습니다.
이러한 문제를 방지하고 웹 애플리케이션의 보안을 강화하기 위해, Spring MVC Filter를 활용할 수 있습니다.
필터(FIlter)란❓
필터는 Java Servlet API의 Filter 인터페이스를 구현한 객체로, 요청과 응답이 웹 애플리케이션의 컨트롤러에 도달하기 전에 또는 컨트롤러로부터 응답을 반환하기 전에 처리할 수 있는 기능을 제공합니다.
이를 통해 요청과 응답의 정보를 조작하거나 추가적인 작업을 수행할 수 있습니다.
필터의 주요 목적
- 보안
- 인증 및 인가를 수행하여 민감한 정보에 접근을 제한합니다.
- 로깅
- 요청 및 응답의 세부 사항을 기록하여 문제를 추적하거나 성능을 분석합니다.
- 데이터 변환
- 요청 데이터를 변환하거나 응답 데이터를 가공하여 최종 사용자에게 전달합니다.
- 응답 캐싱
- 성능을 개선하기 위해 응답을 캐싱합니다.
Spring MVC Filter Life Cycle
Spring MVC의 요청 생명주기(Request Life Cycle)는 클라이언트가 서버에 요청을 보내는 순간부터, 서버가 응답을 클라이언트에게 반환하는 순간까지의 전 과정을 설명합니다.
Spring MVC는 이 생명주기 동안 다양한 컴포넌트와 단계가 관여하여 요청을 처리합니다.
1. 요청 수신 (Request Received)
- 클라이언트가 웹 애플리케이션에 요청을 보내면, 이 요청은 서버의 DispatcherServlet에 도달합니다.
- DispatcherServlet은 Spring MVC의 중앙 서블릿으로, 요청을 처리하기 위해 다양한 컴포넌트와 상호작용합니다.
2. 핸들러 매핑 (Handler Mapping)
- DispatcherServlet은 요청을 적절한 핸들러(컨트롤러)로 전달하기 위해 HandlerMapping을 사용합니다.
- HandlerMapping은 "요청 URL"을 기반으로 요청을 처리할 핸들러(컨트롤러)를 찾습니다.
- 이 단계에서 요청에 매핑된 컨트롤러 메서드가 결정됩니다.
3. 핸들러 어댑터 (Handler Adapter)
- DispatcherServlet은 요청을 처리할 핸들러(컨트롤러)를 찾으면, HandlerAdapter를 사용하여 해당 핸들러를 호출합니다.
- HandlerAdapter는 핸들러가 요청을 처리할 수 있도록 준비하는 역할을 합니다.
- 이 단계에서 핸들러 메서드가 호출되어 실제 요청 처리가 이루어집니다.
4. 핸들러 실행 (Handler Execution)
- 컨트롤러 메서드가 실행되어 요청을 처리합니다.
- 이 단계에서 비즈니스 로직이 수행되고, 필요한 경우 서비스 계층과 데이터베이스와 상호작용하여 결과를 생성합니다.
- 핸들러 메서드는 일반적으로 ModelAndView 객체를 반환합니다.
5. 뷰 리졸버 (View Resolver)
- DispatcherServlet은 HandlerAdapter가 반환한 ModelAndView 객체를 사용하여 적절한 뷰를 결정합니다.
- ViewResolver는 뷰 이름을 실제 뷰 구현(예: JSP 파일)으로 변환합니다.
- 이 단계에서 뷰 이름이 실제 뷰 경로로 매핑됩니다.
6. 뷰 렌더링 (View Rendering)
- ViewResolver가 반환한 뷰를 사용하여 실제 응답이 렌더링 됩니다.
- 이 단계에서는 모델 데이터를 뷰에 전달하고, 최종 HTML 응답을 생성하여 클라이언트에게 전달합니다.
- 뷰는 JSP, Thymeleaf, Freemarker 등 다양한 템플릿 엔진을 사용할 수 있습니다.
7. 응답 반환 (Response Returned)
- 렌더링 된 응답은 DispatcherServlet을 통해 클라이언트로 반환됩니다.
- 이 단계에서 클라이언트는 최종 HTML 페이지 또는 JSON/XML 데이터와 같은 응답을 받게 됩니다.
8. 후처리 (Post-Processing)
- 요청이 처리된 후, 필요한 후처리 작업이 수행될 수 있습니다.
- 예를 들어, 필터를 통해 추가적인 처리가 이루어질 수 있습니다.
- 이 단계는 필터를 사용하여 요청 또는 응답을 가로채고 추가적인 로깅, 보안 처리 등을 수행하는 과정입니다.
필터의 구성 요소
Spring MVC에서 필터는 javax.servlet.Filter 인터페이스를 구현하여 다음과 같은 메서드를 정의합니다.
- init(FilterConfig filterConfig)
- 필터 초기화 시 호출되며, 필터가 생성될 때 필요한 초기화 작업을 수행합니다.
- doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
- 요청과 응답을 가로채어 처리하며, 필터 체인의 다음 필터로 요청을 전달합니다.
- destroy()
- 필터가 더 이상
필요하지 않을 때 호출되며, 자원 정리 작업을 수행합니다.
- 필터가 더 이상
@Slf4j란❓
@Slf4j는 Java에서 로깅을 간편하게 사용할 수 있게 해주는 어노테이션입니다. 이 어노테이션은 Lombok 라이브러리의 일부로, 로깅을 위해 org.slf4j.Logger를 자동으로 생성해 줍니다.
따라서 @Slf4j 어노테이션을 붙이면, 다음과 같이 로거를 쉽게 사용할 수 있습니다.
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class MyClass {
public void myMethod() {
log.info("This is an info message");
}
}
Slf4j는 log라는 이름의 SLF4 J Logger 객체를 자동으로 생성해 주므로, log.info, log.error 등의 메서드를 바로 사용할 수 있습니다.
@Order란❓
@Order는 Spring Framework에서 빈(bean)들의 순서를 지정할 때 사용하는 어노테이션입니다. 주로 Spring의 다양한 컴포넌트, 특히 @Component, @Service, @Configuration, @Bean 등에 적용됩니다.
- 작동 방식
- @Order 어노테이션은 정수 값을 받으며, 숫자가 낮을수록 높은 우선순위를 가지게 됩니다.
- Spring은 이 값을 사용하여 빈이나 컴포넌트의 초기화 및 사용 순서를 결정합니다.
Component 순서 지정
@Order를 사용하여 Spring이 컴포넌트를 특정 순서로 처리하도록 지시할 수 있습니다. 주로 필터, 인터셉터, 어드바이스 등에 사용됩니다.
@Component
@Order(1)
public class MyFirstComponent {
// Component logic
}
@Component
@Order(2)
public class MySecondComponent {
// Component logic
}
위의 코드에서는 MyFirstComponent가 MySecondComponent 보다 먼저 처리됩니다.
Configuration 클래스 순서 지정
여러 @Configuration 클래스를 사용할 때, 이 어노테이션을 통해 설정 클래스의 로딩 순서를 지정할 수 있습니다.
@Configuration
@Order(1)
public class FirstConfiguration {
// Configuration logic
}
@Configuration
@Order(2)
public class SecondConfiguration {
// Configuration logic
}
이 경우 FirstConfiguration이 SecondConfiguration보다 먼저 로드됩니다.
Bean 메서드의 순서 지정
@Configuration
public class AppConfig {
@Bean
@Order(1)
public MyBean firstBean() {
return new MyBean();
}
@Bean
@Order(2)
public MyBean secondBean() {
return new MyBean();
}
}
이 경우 firstBean이 secondBean보다 먼저 생성됩니다.
Filter Chain란❓
필터 체인은 여러 필터가 연결된 순서를 의미합니다. 요청이 들어오면, 필터 체인은 필터의 순서에 따라 각 필터를 실행하고, 각 필터는 다음 필터로 요청을 전달합니다.
필터 체인은 요청이 처리되기 전에 요청을 변형하거나, 응답을 처리한 후 응답을 변형하는 작업을 수행할 수 있습니다.
즉, Filter는 한 개만 존재하는 것이 아니라 이렇게 여러 개가 Chain 형식으로 묶여서 처리될 수 있습니다.
Filter의 전처리, 후처리 확인해 보기
@Slf4j(topic = "LoggingFilter")
@Component
@Order(1)
public class LoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 전처리
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
log.info("Request URL: {}", url);
chain.doFilter(request, response); // 다음 필터 또는 서블릿으로 이동
// 후처리
log.info("Business logic completed");
}
}
@Slf4j(topic = "AuthFilter")
@Component
@Order(2)
public class AuthFilter implements Filter {
// 필드와 생성자 정의
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
if (StringUtils.hasText(url) && (url.startsWith("/api/user") || url.startsWith("/css") || url.startsWith("/js"))) {
chain.doFilter(request, response); // 인증 필요 없는 URL
} else {
// 인증 필요 URL 처리
String tokenValue = jwtUtil.getTokenFromRequest(httpServletRequest);
if (StringUtils.hasText(tokenValue)) {
String token = jwtUtil.substringToken(tokenValue);
if (!jwtUtil.validateToken(token)) {
throw new IllegalArgumentException("Token Error");
}
Claims info = jwtUtil.getUserInfoFromToken(token);
User user = userRepository.findByUsername(info.getSubject()).orElseThrow(() ->
new NullPointerException("Not Found User")
);
request.setAttribute("user", user);
chain.doFilter(request, response); // 다음 필터 또는 서블릿으로 이동
} else {
throw new IllegalArgumentException("Not Found Token");
}
}
}
}
LoggingFilter
- @Slf4j
- Lombok을 사용하여 로깅 기능을 제공합니다.
- @Component
- Spring의 컴포넌트 스캔(Component Scan)에 의해 자동으로 빈으로 등록됩니다.
- @Order(1)
- 필터의 실행 순서를 지정합니다. 숫자가 낮을수록 먼저 실행됩니다.
- chain.doFilter(request, response);
- 다음 필터 또는 서블릿으로 요청을 전달합니다.
- log.info("Business logic completed");
- 비즈니스 로직이 완료된 후 로그를 기록합니다.
"/api/login-page"실행 결과
AuthFilter에서 /api/login과 같은 URL은 인증 처리를 건너뛰고 chain.doFilter();를 호출하여 필터 체인을 계속 진행합니다.
이후, DispatcherServlet이 해당 URL에 매핑된 컨트롤러를 호출하여 비즈니스 로직을 처리한 후, 필터 체인의 후처리 단계로 돌아옵니다. 이 과정에서 후처리 로그 "비즈니스 로직 완료"가 출력됩니다.
Filter에서 쿠키 값 처리하기
// HttpServletRequest 에서 Cookie Value : JWT 가져오기
public String getTokenFromRequest(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();
if(cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
try {
return URLDecoder.decode(cookie.getValue(), "UTF-8"); // Encode 되어 넘어간 Value 다시 Decode
} catch (UnsupportedEncodingException e) {
return null;
}
}
}
}
return null;
}
필터가 없을 땐 손쉽게 Request객체에 들어있는, Cookie에 들어있는 Value값을 가져올 수 있었습니다.
그러나 필터는 웹 애플리케이션에서 클라이언트의 요청이 서블릿이나 컨트롤러로 전달되기 전에 처리되는 컴포넌트입니다.
따라서 요청이 서블릿이나 DispatcherServlet에 도달하기 전에 필터가 먼저 실행되므로, 필터에서는 @CookieValue와 같은 Spring MVC의 어노테이션을 사용할 수 없습니다. 대신, 필터 내에서 직접 HttpServletRequest 객체를 통해 쿠키 값을 가져와야 합니다.
전체 Filter 적용해 보기
Request URL Logging
package com.sparta.springauth.controller;
import com.sparta.springauth.entity.User;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/api")
public class ProductController {
@GetMapping("/products")
public String getProducts(HttpServletRequest req) {
System.out.println("ProductController.getProducts : 인증 완료");
User user = (User) req.getAttribute("user");
System.out.println("user.getUsername() = " + user.getUsername());
return "redirect:/";
}
}
Authentication Filter
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String url = httpServletRequest.getRequestURI();
if (StringUtils.hasText(url) && (url.startsWith("/api/user") || url.startsWith("/css") || url.startsWith("/js"))) {
log.info("인증 처리를 하지 않는 URL " + url);
chain.doFilter(request, response); // 인증이 필요 없는 URL은 필터를 계속 진행
} else {
// 인증 처리
String tokenValue = jwtUtil.getTokenFromRequest(httpServletRequest);
if (StringUtils.hasText(tokenValue)) {
String token = jwtUtil.substringToken(tokenValue);
if (!jwtUtil.validateToken(token)) {
throw new IllegalArgumentException("Token Error");
}
Claims info = jwtUtil.getUserInfoFromToken(token);
User user = userRepository.findByUsername(info.getSubject()).orElseThrow(() -> new NullPointerException("Not Found User"));
request.setAttribute("user", user);
chain.doFilter(request, response); // 다음 필터로 이동
} else {
throw new IllegalArgumentException("Not Found Token");
}
}
}
Controller Handling
검증이 되는지 확인하기 위해서 경로가 다른 컨트롤러(@GetMapping("/products"))를 하나 만듭니다.
@Controller
@RequestMapping("/api")
public class ProductController {
@GetMapping("/products")
public String getProducts(HttpServletRequest req) {
System.out.println("ProductController.getProducts : 인증 완료");
User user = (User) req.getAttribute("user");
System.out.println("user.getUsername() = " + user.getUsername());
return "redirect:/";
}
}
Token이 유효한 사용자 로그인 시
Token이 유효한 사용자가 로그인 시 마찬가지로 인증이 필요 없기 때문에 "인증 처리를 하지 않는 URL"이 출력되고, DB에서 데이터가 있는지 SELECT 쿼리를 보내 확인을 합니다.
그리고 확인이 되었다면 LoggingFilter가 "비즈니스 로직이 완료"되었다고 로그를 남깁니다.
/api/products 접속 실행 결과 로그 확인
- 요청 URL 로그
- LoggingFilter가 /api/products 요청을 로깅합니다.
- 인증 및 데이터베이스 쿼리
- AuthFilter가 요청에 대해 인증을 수행하며, 사용자 정보를 확인하기 위해 데이터베이스 쿼리를 실행합니다.
- 비즈니스 로직 완료 로그
- LoggingFilter가 "비즈니스 로직의 완료"를 로그에 남깁니다.
- 인증 완료 로그
- AuthFilter에서 "인증 처리를 하는 조건문"을 타기 때문에 request에 사용자의 이름을 넣어주고, doFilter() 메서드로 다음 필터로 이동하고 최종적으로 컨트롤러에 도착하여 ProductController.getProducts() 메서드가 인증 완료 후 호출되었음을 나타냅니다.
- 사용자 정보 로그
- user.getUsername() 메서드로 현재 인증된 사용자의 이름을 출력하여, ZINU라는 사용자가 요청을 했다는 것을 확인할 수 있습니다.
후처리:
- LoggingFilter에서 "비즈니스 로직 완료 로그"를 찍습니다.
만료된 Token 사용 시 Throw
필터를 통한 인증 과정은 사용자의 요청을 적절히 처리하고, 인증된 사용자에게만 접근을 허용하는데 중요한 역할을 합니다.
필터 체인의 구성과 각 필터의 순서, 그리고 필터의 전처리 및 후처리 단계는 개발자가 웹 애플리케이션의 동작을 세밀하게 제어할 수 있는 강력한 도구가 됩니다.
이와 같은 필터를 적절히 활용하면, 웹 애플리케이션의 보안과 기능성을 극대화할 수 있으며, 개발자는 보다 안전하고 효율적인 애플리케이션을 구축할 수 있습니다.