로그인 처리2 - 필터, 인터셉터

2025. 9. 18. 17:37·Spring

서블릿 필터

공통 관심 사항

요구사항엔 로그인 한 회원만 상품 관리 페이지에 들어갈 수 있어야하는데, 현재는 로그인 하지 않은 사용자가 URL을 직접 호출하면 상품 관리 화면에 등록해서 상품 등록 수정을 할 수 있다.

 

이를 해결하기 위해선 컨트롤러 로직에 공통으로 로그인 여부를 확인해야 한다. 향후 로그인 관련 로직이 변경된다면 모든 로직을 다 수정해야하는 일이 생긴다.

 

이렇게 애플리케이션 여러 로직에서 공통으로 관심이 있는 것을 공통 관심사 (cross-cutting concern)라고 한다.

여기서는 등록, 수정, 삭제, 조회 등등 공통으로 인증에 대해서 관심을 가지고 있다.

 

스프링 AOP로도 해결할 수 있지만, 웹과 관련된 공통 관심사는 서블릿 필터 또는 스프링 인터셉터를 사용하는것이 좋다.

웹과 관련된 공통 관심사를 처리할 때는 HTTP의 헤더나, URL 정보들이 필요한데, 서블릿 필터나 스프링 인터셉터는 HttpServletRequest를 제공한다.

 

서블릿 필터 소개

필터는 서블릿이 지원하는 수문장이다. 특성은 다음과 같다.

 

필터 흐름

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러

필터를 적용하면 필터가 호출 된 다음에 서블릿이 호출된다.

스프링을 사용하면 여기서 말하는 서블릿은 디스패처 서블릿과 같다고 생각하자

 

필터 제한

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 // 로그인 사용자

HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단, 서블릿 호출 X) // 비로그인 사용자

필터에서 적절하지 않은 요청이라고 판단하면 거기에서 끝을 낼 수 있다.

 

필터 체인

HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러

필터는 체인으로 구성된다. 중간에 필터를 자유롭게 추가할 수 있다. 예를들어 로그를 남기는 필터 -> 로그인 여부 체크 필터 가능

 

public interface Filter {
    default void init(FilterConfig filterConfig) throws ServletException {
    }

    void doFilter(ServletRequest var1, ServletResponse var2, FilterChain var3) throws IOException, ServletException;

    default void destroy() {
    }
}

 

- init() : 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출

- doFilter() : 고객의 요청이 올때마다 호출. 필터의 로직을 구현하면 된다.

- destroy() : 필터 종료 메서드, 서블릿 컨테이너 종료시 호출

 

서블릿 필터 - 요청 로그

필터가 정말 제 역할을 하는지 로그를 남기는 필터를 개발해보자

@Slf4j
public class LogFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("log filter init");
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        log.info("log filter doFilter");

        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        String requestURI = httpRequest.getRequestURI();

        String uuid = UUID.randomUUID().toString();

        try {
            log.info("REQUEST [{}][{}]", uuid, requestURI);
            filterChain.doFilter(servletRequest, servletResponse);
        } catch (Exception e) {
            throw e;
        } finally {
            log.info("RESPONSE [{}][{}]", uuid, requestURI);
        }

    }

    @Override
    public void destroy() {
        log.info("log filter destroy");
    }
}

 

implements Filter : 필터를 사용하려면 Filter 인터페이스를 구현해야한다. 

doFilter() : HTTP 요청이 오면 doFilter() 호출. ServletRequest는 HTTP 요청이 아닌 경우까지 고려해서 만든 인터페이스이므로 HTTP를 사용하면 HttpServletRequest로 다운캐스팅 해줘야 한다.

String uuid : HTTP 요청일 구분하기 위해 uuid 생성

log info("REQUEST [{}][{}]",uuid, requestURI) : uuid와 requestURI 출력

 

filterChain.doFilter(servletRequest, servletResponse) : 다음 필터가 있으면 필터 호출, 없으면 서블릿 호출

 

package hello.login;

import hello.login.web.filter.LogFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Filter;

@Configuration
public class WebConfig {

    @Bean
    public FilterRegistrationBean logFilter() {
        FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
        filterFilterRegistrationBean.setFilter(new LogFilter());
        filterFilterRegistrationBean.setOrder(1);
        filterFilterRegistrationBean.addUrlPatterns("/*");

        return filterFilterRegistrationBean;
    }
}

 

스프링 부트를 사용한다면 FilterRegistrationBean을 사용해서 등록하면 된다.

- setFilter() : 등록할 필터 지정

- setOrder() : 필터는 체인으로 동작한다. 순서가 필요한데 낮을수록 먼저 동작

- addUrlPatterns("/*") : 필터를 적용할 URL 패턴을 지정. 한번에 여러 패턴 지정 가능

 

서블릿 필터 - 인증 체크

로그인 되지 않은 사용자는 상품 관리 뿐만 아니라 미래에 개발될 페이지에도 접근하지 못하도록 막아보자

@Slf4j
public class LoginCheckFilter implements Filter {

    private static final String[] whitelist = {"/", "/members/add", "/login", "/logout", "/css/*"};

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        String requestURI = httpRequest.getRequestURI();

        HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;

        try {
            log.info("인증 체크 필터 시작{}", requestURI);

            if (isLoginCheckPath(requestURI)) {
                log.info("인증 체크 로직 실행 {}", requestURI);
                HttpSession session = httpRequest.getSession(false);
                if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
                    log.info("미인증 사용자 요청 {}", requestURI);
                    // 로그인 페이지로 redirect
                    httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
                    return; // 중요. 미인증 사용자는 다음으로 진행하지 않고 끝
                }
            }
            filterChain.doFilter(servletRequest, servletResponse);
        } catch (Exception e) {
            throw e; //예외 로깅 가능
        } finally {
            log.info("인증 체크 필터 종료 {} ", requestURI);
        }
    }

    // 화이트 리스트의 경우 인증 체크X
    private boolean isLoginCheckPath(String requestURI) {
        return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
    }
}

 

- whitelist : 인증 필터를 적용해도 홈, 회원가입, 로그인 화면, css같은 리소스에는 접근할 수 있어야한다. 화이트 리스트 경로는 인증과 무관하게 항상 허용한다. 화이트 리스트를 제외한 나머지 모든 경로에는 인증 체크 로직을 적용한다.

- isLoginCheckPath() : 화이트 리스트를 제외한 모든 경우에 인증 체크 로직을 적용한다.

- httpResponse.sendRedirect() : 미인증 사용자는 로그인 폼으로 이동됐다. 그 후에 로그인에 성공하면 다시 /items로 이동해야 하는데, 사용자 입장에선 불편하다. 이러한 기능을 위해 현재 요청한 경로인 requestURI를 /login에 쿼리 파라미터로 함께 전달한다. 물론 이 기능은 추가로 개발해야한다.

- return; : 필터를 더 진행하지 않는다. 이후 필터, 서블릿, 컨트롤러가 호출되지 않는다. 앞서 redirect를 사용했기 때문에 redirect가 응답으로 적용되고 요청은 종료된다.

 

@Bean
public FilterRegistrationBean loginCheckFilter() {
    FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
    filterFilterRegistrationBean.setFilter(new LoginCheckFilter());
    filterFilterRegistrationBean.setOrder(2);
    filterFilterRegistrationBean.addUrlPatterns("/*");

    return filterFilterRegistrationBean;
}

 

LoginCheckFilter()를 필터로 등록하고, 순서는 2번으로 잡았다. 로그 필터 이후에 인증 필터가 적용된다.

@PostMapping("/login")
public String loginV4(@Validated @ModelAttribute LoginForm form,
                      BindingResult bindingResult,
                      @RequestParam(defaultValue = "/") String redirectURL,
                      HttpServletRequest request) {
    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

    if (loginMember == null) {
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    // 로그인 성공 처리
    // 세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
    HttpSession session = request.getSession();
    // 세션에 로그인 회원 정보 보관
    session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);

    return "redirect:" + redirectURL;
}

 

로그인 후 처음 요청한 URL로 리다이렉트 하는 기능을 추가했다.

매개변수에 @RequestParam을 넣고, 로그인 체크 필터에서 미인증 사용자는 요청 경로를 포함해서 /login에 redirectURL 요청 파라미터를 추가해서 요청했다. 이 값을 사용해서 로그인 성공시 해당 경로로 redirect 한다.

(LoginCheckFilter의 httpResponse.sendRedirect()의 값이 쿼리파라미터로 넘어가는거다)

 

스프링 인터셉터

스프링 인터셉터도 서블릿 필터와 같이 웹과 관련된 공통 관심사항을 효과적으로 해결할 수 있는 기술이다.

스프링 MVC가 제공하는 기술이며 둘 다 웹과 관련된 공통 관심 사항을 처리하지만, 적용되는 순서와 범위, 사용방법이 다르다.

 

스프링 인터셉터 흐름

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러

- 스프링 인터셉터는 디스패처 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출된다.

- 스프링 MVC가 제공하는 기술이므로 디스패처 서블릿 이후에 등장하게 된다. 스프링MVC의 시작점이 디스패처 서블릿이다.

- 스프링 인터셉터에도 URL 패턴을 적용할 수 있는데, 서블릿과 다르고, 더 정밀하게 설정할 수 있다.

 

스프링 인터셉터 제한

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 // 로그인 사용자

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 (잘못된 요청으로 판단 시 컨트롤러 호출 X) // 비 로그인 사용자

 

스프링 인터셉터 체인

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러

 

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.web.servlet;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;

public interface HandlerInterceptor {
    default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        return true;
    }

    default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
    }

    default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
    }
}

 

HandlerIntercepter 구조

인터셉터를 사용하려면 HandlerIntercepter를 구현하면 된다.

 

서블릿과 다르게 컨트롤러 호출 전 (preHandle), 호출 후 (postHandle), 요청 완료 이후 (afterCompletion)으로 세분화 되어있다.

서블릿은 request, response만 제공했지만, 인터셉터는 어떤 handler가 호출되는지, 어떤 modelAndVidw가 반환되는지 응답 정보도 확인할 수 있다.

- prehandle: 컨트롤러 호출 전에 호출. 응답값이 true면 다음 진행, false면 종료 (핸들러 어댑터, 핸들러 호춢X)

- postHandle : 컨트롤러 호출 후에 호출 (정확히 핸들러 어댑터 호출 이후)

- afterCompletion : 뷰 렌더링 이후 호출

 

예외 발생시

- prehandle : 컨트롤러 호출 전에 호출

- postHandler : 컨트롤러에서 예외 발생시 호출X

- afterCompletion : 항상 호출됨. 예외를 파라미터로 받아서 어떤 예외가 발생했는지 로그 출력 가능

 

스프링 인터셉터 - 요청 로그

@Slf4j
public class LogInterceptor implements HandlerInterceptor {

    public static final String LOG_ID = "logId";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        String requestURI = request.getRequestURI();
        String uuid = UUID.randomUUID().toString();

        request.setAttribute(LOG_ID, uuid);

        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;// 호출할 컨트롤러 메서드의 모든 정보가 포함
        }

        log.info("REQUEST [{}][{}][{}])", uuid, requestURI, handler);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("postHandle [{}]", modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String requestURI = request.getRequestURI();
        Object logId = (String) request.getAttribute(LOG_ID);
        log.info("REQUEST [{}][{}][{}])", logId, requestURI, handler);
        if (ex != null) {
            log.error("afterCompletion error!!", ex);
        }

    }
}

 

요청 로그를 구분하기 위해 uuid를 생성했는데, 서블릿 필터의 경우 지역변수로 해결이 가능하지만. 스프링 인터셉터는 호출 시점이 완전히 분리되어 있기 때문에, 세가지 메서드에서 함께 사용하려면 어딘가에 담아두어야 한다.

LogInterceptor도 싱글톤처럼 사용되기 때문에 멤버변수를 사용하면 다른곳에서 값이 변경될 위험이 있으므로 request에 담아두었다.

 

return true면 다음 인터셉터나 컨트롤러가 호출된다.

 

@Controller, @RequestMapping을 활용한 핸들로 매핑을 사용할 경우 핸들러 정보로 HandlerMethod가 넘어온다.

그게 아닌 resources/static과 같은 정적 리소스가 호출되는 경우 ResourceHttpRequestHandler가 핸들러 정보로 넘어오기 때문에 타입 처리가 필요하다.

 

종료 로그를 afterCompletion에서 실행한 이유는 예외가 발생한 경우 postHandle이 호출되지 않기 때문이다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");
    }

 

WebMvcConfigurer가 제공하는 addInterceptors()를 사용해서 인터셉터를 등록할 수 있다.

registry : 인터셉터 등록

order : 순서 등록

addPathPAtterns() : 인터셉터를 적용할 URL 패턴 지정

excludePathPatterns() : 인터셉터에서 제외할 패턴 지정

 

스프링 인터셉터 - 인증 체크

@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();

        log.info("인증 체크 인터셉터 실행 {}", requestURI);
        HttpSession session = request.getSession();

        if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
            log.info("미인증 사용자 요청");
            // 로그인으로 redirect
            response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        }

        return true;
    }
}

 

서블릿 필터와 비교하면 코드가 매우 간결하다. 인증은 컨트롤러 호출 전에만 호출되면 된다. 따라서 preHandle만 구현했다.

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");

        registry.addInterceptor(new LoginCheckInterceptor())
                .order(2)
                .addPathPatterns("/**")
                .excludePathPatterns("/", "/members/add", "/login", "/logout", "/css/**", "*.ico", "/error");
    }
    }

 

인터셉터를 적용하거나 적용하지 않을 부분은 addPathPatterns, excludePathPatterns에 작성하면 된다.

서블릿 필터와 비교하면 편리하게 코드 작성이 가능하다.

 

서블릿 필터에선 whitelist로 관리했지만 인터셉터는 메서드 하나에서 모두 관리할 수 있다. (편리)

이렇게 해서 공통 관심사를 편리하게 해결할 수 있었다.

 

ArgumentResolver 활용

argumentResolver를 사용해서 로그인 회원을 조금 더 편리하게 찾아보자

@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {

    // 세션에 회원 데이터 값이 없으면 home
    if (loginMember == null) {
        return "home";
    }

    // 세션이 유지되면 로그인으로 이동
    model.addAttribute("member", loginMember);
    return "loginHome";
}

 

@Login Member loginMember를 커스텀 어노테이션을 파라미터로 받는다.

 

package hello.login.web.argumentresolver;

import hello.login.domain.member.Member;
import hello.login.web.SessionConst;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter methodParameter) {
        log.info("supportsParameter 실행");

        boolean hasLoginAnnotation = methodParameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType = Member.class.isAssignableFrom(methodParameter.getParameterType());
        
        return hasLoginAnnotation && hasMemberType;
    }

    @Override
    public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {

        log.info("resolverArgument 실행");
        HttpServletRequest nativeRequest = (HttpServletRequest) nativeWebRequest.getNativeRequest();
        HttpSession session = nativeRequest.getSession(false);
        if (session == null) {
            return null;
        }

        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}

 

supportsParameter()로 받을 수 있는 타입인지 검증 후 resolveArgument()로 요청값에 맞는 세션을 조회해서 키에 맞는 value를 꺼내서 컨트롤러 파라미터에 주입한다.

'Spring' 카테고리의 다른 글

API 예외처리  (0) 2025.09.20
예외처리와 오류 페이지  (0) 2025.09.19
로그인 처리1 - 쿠키, 세션  (1) 2025.09.17
Bean Validation  (0) 2025.09.12
오류 코드와 메세지처리, Validator 분리  (0) 2025.09.11
'Spring' 카테고리의 다른 글
  • API 예외처리
  • 예외처리와 오류 페이지
  • 로그인 처리1 - 쿠키, 세션
  • Bean Validation
공부처음하는사람
공부처음하는사람
  • 공부처음하는사람
    lazzzykim
    공부처음하는사람
  • 전체
    오늘
    어제
    • 분류 전체보기 (159)
      • Kotlin (31)
      • Java (56)
      • Spring (44)
      • JPA (6)
      • Algorithm (3)
      • TroubleShooting (1)
      • 내일배움캠프 프로젝트 (14)
      • Setting (2)
      • ... (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 글쓰기
  • 링크

  • 인기 글

  • 태그

    배열
    spring
    kotlin
    중첩클래스
    java
    싱글톤
    다형성
    김영한의 실전 자바
    jpa
    예외처리
    김영한의 실전자바
    캡슐화
    제네릭
    Di
    내일배움캠프
    언체크예외
    OCP
    빈 생명주기
    김영한
    트랜잭션
  • hELLO· Designed By정상우.v4.10.3
공부처음하는사람
로그인 처리2 - 필터, 인터셉터
상단으로

티스토리툴바