[김영한 스프링 review] 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 (2)

조갱·2023년 12월 3일
0

스프링 강의

목록 보기
6/16

로그인 처리1 - 쿠키, 세션

일반적인 웹 사이트 구조를 생각해보자.
로그인 하기 이전에는 ID/PW 입력창이 출력되고, 일부 메뉴는 접근이 안된다.
로그인 이후에는 {userName}님, 로그아웃 처럼, 로그인이 됐음을 알 수 있다.

사용자의 계정 정보를 입력받아 검증하고, 로그인을 유지하는 방법에 대해 알아보자.

쿠키 사용

쿠키는 클라이언트에서 보관하는 데이터이다.

클라이언트가 서버로 로그인 요청을 보낸다.
서버에서는 로그인에 성공하면 응답 헤더에 Set-Cookie: memberId=1을 포함하여 응답한다.
클라이언트는 그 쿠키를 저장하고, 앞으로 발생하는 요청에 매번 함께 전달하게된다.

로그인 성공시 쿠키 정보를 함께 응답하기

서버에서 쿠키를 함께 전달하고 싶다면, 파라미터에 HttpServletResponse 객체를 추가하고 addCookie()메소드를 통해 응답값에 쿠키를 추가할 수 있다.

@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
	if (bindingResult.hasErrors()) {
		return "login/loginForm";
	}

	Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
	log.info("login? {}", loginMember);
	if (loginMember == null) {
		bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
		return "login/loginForm";
	}
    
	//로그인 성공 처리
	//쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 모두 종료)
	Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
	response.addCookie(idCookie);
	return "redirect:/";
}

클라이언트에서 전달한 쿠키 값을 참조하기

@CookieValue 어노테이션을 통해, 클라이언트에서 전달한 쿠키를 쉽게 사용할 수 있다.

@Controller
@RequiredArgsConstructor
public class HomeController {
	private final MemberRepository memberRepository;
	@GetMapping("/")
	public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {
		if (memberId == null) {
			return "home";
		}

		//로그인
		Member loginMember = memberRepository.findById(memberId);
	    if (loginMember == null) {
	    	return "home";
		}
		model.addAttribute("member", loginMember);
		return "loginHome";
	}
}

로그아웃 구현하기

Cookie를 즉시 만료시킨다.

@PostMapping("/logout")
public String logout(HttpServletResponse response) {
	expireCookie(response, "memberId");
	return "redirect:/";
}

private void expireCookie(HttpServletResponse response, String cookieName) {
	Cookie cookie = new Cookie(cookieName, null);
	cookie.setMaxAge(0);
	response.addCookie(cookie);
}

쿠키와 보안 문제

쿠키는 클라이언트에서 보관하는 데이터이기 때문에, 보안에 문제가 따른다.

  • 쿠키 값은 임의로 변조가 가능하다.
    • memberId를 변경하여 요청을 보내면 다른 사용자로 로그인이 가능
    • 크롬 개발자도구 > 애플리케이션 탭 > 좌측 저장용량 > 쿠키
      에서 확인할 수 있다. (변조도 해볼 수 있다.)
  • 탈취가 가능하다.
    • 쿠키는 javaScript로도 조회가 가능하다. 다른 악의적인 사이트에서 js코드를 통해 탈취될 수 있다.
    • 네트워크 전송 구간에서 패킷 스니핑을 통해 탈취될 수도 있다.
    • 한번 탈취하면, 평생 사용할 수도 있다.
    • 만약 쿠키에 개인정보나, 신용카드 정보가 있다면?

이러한 문제를 해결하기 위해,

  • 쿠키에 중요한 값을 노출하지 않는다.
  • 사용자별로 1:1 매핑되는 임의의 토큰 (예측불가한 랜덤값)을 발급하고,
    토큰을 쿠키에 전달한다. (사용자 정보 및 토큰은 서버에서 관리한다.)
    • 해커가 임의의 값을 넣어도 찾을 수 없다.
    • 토큰의 만료기간을 짧게 (30분) 유지하여, 해커가 탈취하더라도 일정 시간이 지나면 만료된다.
    • 해킹의 의심되는 경우에는 서버에서 토큰을 즉시 만료/제거할 수 있다.

-> 위 해결 방식이 세션 동작 방식이다.

세션 동작 방식

사용자가 로그인을 시도한다.
로그인에 성공하면 서버에서는 토큰 (추측 불가능한 랜덤값)을 발급하여 서버 메모리에 저장한다.
이후 토큰을 클라이언트의 쿠키에 전달한다.
클라이언트는 앞으로 토큰을 서버에 보내고, 서버는 메모리에 저장된 <토큰:유저정보>에서 매핑된 정보를 찾아서 사용한다.

쿠키 방식과 비교했을 때, 세션은 직접적인 유저 정보를 주고받지 않는다.

세션 직접 만들기

세션 기능을 제공하기 위해서는, 3가지 기능이 필요하다.

  • 세션 생성
    • sessionId 생성 (추측 불가능한 랜덤 값)
    • 세션 저장소에 저장
    • 세션id를 클라이언트에 응답
  • 세션 조회
    • 클라이언트가 요청한 sessionId 값으로, 세션 저장소 조회
  • 세션 만료
    • 클라이언트가 요청한 sessionId값을 세션 저장소에서 만료(삭제)
package hello.login.web.session;
import org.springframework.stereotype.Component;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

/**
* 세션 관리
*/
@Component
public class SessionManager {
	public static final String SESSION_COOKIE_NAME = "mySessionId";
	private Map<String, Object> sessionStore = new ConcurrentHashMap<>();

	/**
	* 세션 생성
	*/
	public void createSession(Object value, HttpServletResponse response) {
		//세션 id를 생성하고, 값을 세션에 저장
		String sessionId = UUID.randomUUID().toString(); 
	    sessionStore.put(sessionId, value);

		//쿠키 생성
		Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(mySessionCookie);
	}

	/**
	* 세션 조회
	*/
	public Object getSession(HttpServletRequest request) {
		Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
		if (sessionCookie == null) {
			return null;
		}
		return sessionStore.get(sessionCookie.getValue());
	}

	/**
	* 세션 만료
	*/
	public void expire(HttpServletRequest request) {
		Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
		if (sessionCookie != null) {
			sessionStore.remove(sessionCookie.getValue());
		}
	}

	private Cookie findCookie(HttpServletRequest request, String cookieName) {
		if (request.getCookies() == null) {
			return null;
        }
        return Arrays.stream(request.getCookies())
                .filter(cookie -> cookie.getName().equals(cookieName))
                .findAny()
                .orElse(null);
	}
}

ConcurrentHashMap : HashMap 은 동시 요청에 안전하지 않다.
동시 요청에 안전한 ConcurrentHashMap 를 사용했다.

직접 만든 세션 적용

private final SessionManager sessionManager;

@PostMapping("/login")
public String loginV2(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
	if (bindingResult.hasErrors()) {
		return "login/loginForm";
	}

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

	//로그인 성공 처리
	//세션 관리자를 통해 세션을 생성하고, 회원 데이터 보관 
    sessionManager.createSession(loginMember, response);
	return "redirect:/";
}

로그아웃

@PostMapping("/logout")
public String logoutV2(HttpServletRequest request) {
	sessionManager.expire(request);
	return "redirect:/";
}

세션은 뭔가 특별한 것이 아니라, 단지 쿠키를 사용하는데 서버에서 데이터를 유지하는 방법일 뿐이다. 그런데 프로젝트마다 세션 개념을 구현하기에는 불편하기 때문에, 서블릿에서도 세션 기능을 제공한다.

서블릿에서 제공하는 (잘 만들어진) 세션 기능을 활용해보자.

서블릿 HTTP 세션1

서블릿에서는 세션 기능을 HttpSession 객체로 제공한다.
쿠키의 key를 JSESSIONID로 제공하며, value는 추측할 수 없는 랜덤값이다.
Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05

로그인

@PostMapping("/login")
public String loginV3(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletRequest request) {
	if (bindingResult.hasErrors()) {
		return "login/loginForm";
	}

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

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

request.getSession(true) (=request.getSession())
세션이 있으면 기존 세션을 반환하고, 없으면 새로운 세션을 생성해서 반환한다.

request.getSession(false)
세션이 있으면 기존 세션을 반환하고, 없으면 새로운 세션을 생성하지 않고 null 을 반환한다.

로그아웃

@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
	//세션을 삭제한다.
	HttpSession session = request.getSession(false); if (session != null) {
		session.invalidate();
	}
	return "redirect:/";
}

session.invalidate() 를 통해 손쉽게 세션을 제거할 수 있다.

서블릿 HTTP 세션2

@SessionAttribute 어노테이션을 통해, 세션에 있는 값을 쉽게 가져올 수 있다.
request.getSession()과는 다르게, 세션이 없다고 새로운 세션을 생성하지 않는다.
즉, request.getSession(false)와 동일하다.

@GetMapping("/")
public String homeLoginV3Spring(
          @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
          Member loginMember,
          Model model) {
	//세션에 회원 데이터가 없으면 home
    if (loginMember == null) {
    	return "home";
	}

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

서블릿의 http 세션 방식을 사용하게되면, url에 jsessionid QueryString이 포함되는것을 볼 수 있다.
http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872
이는 쿠키를 사용하지 않는 브라우저를 지원하기 위한 방식이다.
URL전달 방식이 아닌, 쿠키만을 사용하고 싶다면 아래와 같은 설정을 사용할 수 있다.

application.properties

server.servlet.session.tracking-modes=cookie

세션 정보 조회하기

@GetMapping("/session-info")
public String sessionInfo(HttpServletRequest request) {
	HttpSession session = request.getSession(false);
	if (session == null) {
		return "세션이 없습니다.";
	}

	//세션 데이터 출력
    session.getAttributeNames().asIterator()
		.forEachRemaining(name ->
        	log.info("session name={}, value={}", name, session.getAttribute(name)));
			log.info("sessionId={}", session.getId());
			log.info("maxInactiveInterval={}", session.getMaxInactiveInterval());
			log.info("creationTime={}", new Date(session.getCreationTime()));
			log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
			log.info("isNew={}", session.isNew());
	return "세션 출력";
}
  • sessionId : 세션Id, JSESSIONID 의 값이다.
    예) 34B14F008AA3527C9F8ED620EFD7A4E1
  • maxInactiveInterval : 세션의 유효 시간, 예) 1800초, (30분)
  • creationTime : 세션 생성일시
  • lastAccessedTime :세션과 연결된 사용자가 최근에 서버에 접근한 시간.
    클라이언트에서 서버로 sessionId (JSESSIONID)를 요청한 경우에 갱신된다.
  • isNew : 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId ( JSESSIONID )를 요청해서 조회된 세션인지 여부

세션 타임아웃 설정

세션은 위에서 알아본대로, session.invalidate()를 통해 제거할 수 있다.
하지만, 대부분의 사용자들은 페이지에서 로그아웃 버튼을 누르는 것이 아니라
그냥 웹페이지를 종료한다.

비연결성인 http의 특성상, 세션 정보가 서버에 계속 남아있게되며 보안에 취약해진다.
이를 통해, 일정 시간(대체적으로 30분)이 지나면 세션이 제거되도록 설정할 수 있다.

하지만, 고정된 시간동안만 세션이 유지되면, 그 시간마다 사용자가 로그인을 다시 해야하는 불편함이 생긴다. 이는 마지막으로 Session을 요청한지 n분이 지난 이후에 만료 를 통해 유지할 수 있고, 서블릿에서 지원하는 HttpSession도 이와 같은 방식을 사용한다.

세션 타임아웃을 설정하기 위해서는 2가지 방법이 있다.

  • 스프링 부트 글로벌 설정
    application.properties에
    server.servlet.session.timeout=60 로 설정한다.
    (글로벌 설정은 분 단위로 설정해야 한다. 60(1분), 120(2분), ...)
  • 특정 세션 단위로 시간 설정
    session.setMaxInactiveInterval(1800); //1800초

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

필터와 인터셉터의 개념, 공통점, 차이점

필터와 인터셉터는 이전 포스팅에서도 한번 다뤘지만, 다시 한번 복습하고 간다.

필터와 인터셉터는 컨트롤러로 요청이 들어가기 전에 공통적으로 수행하고자 하는 로직을 반영할 수 있다. 즉, 모든 로직에서의 공통 관심사를 해결할 수 있다.

동일한 역할을 하는것 같지만, 차이점도 존재한다.
우선, 아래에서 동작 흐름의 차이부터 살펴보자.

동작 흐름

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

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

제한 흐름

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

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

체인

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

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

차이점 설명

  • 제공 주체 :
    • 서블릿 필터: 서블릿이 제공하는 기술
    • 스프링 인터셉터: 스프링 MVC가 제공하는 기술
      -> 논블로킹IO를 제공하는 webflux 에서는 사용할 수 없다. (필터 사용)
      -> 관련 스택오버플로우
  • 적용 시점 :
    • 서블릿 필터 : WAS 호출 이후, 서블릿 호출 이전
    • 스프링 인터셉터 : 서블릿 호출 이후, 컨트롤러 호출 직전
  • 제공 기능 :
    스프링이 제공하는 인터셉터의 기능이 더 다양하고 세밀하다.

서블릿 필터

인터페이스 설명

public interface Filter {
	public default void init(FilterConfig filterConfig) throws ServletException {}
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException;
	public default void destroy() {}
}

필터 인터페이스를 구현하고 @Configuration으로 등록하고 나면,
서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고 관리한다.

init(): 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출된다.
doFilter(): 필터의 로직을 구현, 고객의 요청이 올 때 마다 호출된다.
destroy(): 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다.

Request 로깅

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

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
		HttpServletRequest httpRequest = (HttpServletRequest) request;
		String requestURI = httpRequest.getRequestURI();
		String uuid = UUID.randomUUID().toString();
		try {
			log.info("REQUEST  [{}][{}]", uuid, requestURI);
			chain.doFilter(request, response);
		} catch (Exception e) {
			throw e;
		} finally {
			log.info("RESPONSE [{}][{}]", uuid, requestURI);
		}
	}
    
	@Override
	public void destroy() {
		log.info("log filter destroy");
	}
}

doFilter(...)
HTTP 요청이 올 때마다 실행된다.
ServletRequest는 HTTP 요청이 아닌 경우까지 고려하여 만든 인터페이스이다.
HTTP 요청에서 사용하려면, 위 예제와 같이 HttpServletReques로 다운캐스팅 하여 사용할 수 있다.

chain.doFilter(request, response);
이 부분이 가장 중요하다.
다음에 수행할 필터가 존재하면 호출하고, 없으면 서블릿을 호출한다.
doFilter()를 호출하지 않으면, 더 이상 넘어가지 않고 요청이 종료된다.

Configuration으로 Filter 등록하기

@Configuration
public class WebConfig {
	@Bean
	public FilterRegistrationBean logFilter() {
		FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
		filterRegistrationBean.setFilter(new LogFilter());
		filterRegistrationBean.setOrder(1);
		filterRegistrationBean.addUrlPatterns("/*");
		return filterRegistrationBean;
	}
}

setFilter(...) : 등록할 필터를 지정한다.
setOrder(...) : 필터는 체인으로 동작하므로, 순서가 필요하다. 낮을 수록 먼저 동작한다.
addUrlPatterns("/*") : 필터를 적용할 URL 패턴을 지정한다. 한번에 여러 패턴을 지정할 수 있다.

어노테이션으로 Filter 등록하기

@ServletComponentScan
@WebFilter(filterName = "logFilter", urlPatterns = "/*")
@Slf4j
public class LogFilter implements Filter {
	...
}

와 같은 방법으로, @ServletComponentScan, @WebFilter 어노테이션을 사용해서도
필터를 등록할 수 있지만, 순서 (Order) 를 설정할 수 없다.
그냥 FilterRegistrationBean 을 사용하자.

실행 로그

hello.login.web.filter.LogFilter: REQUEST [0a2249f2-cc70-4db4-98d1-492ccf5629dd][/items]
hello.login.web.filter.LogFilter: RESPONSE [0a2249f2-cc70-4db4-98d1-492ccf5629dd][/items]

필터를 등록할 때 urlPattern 을 /* 로 등록했기 때문에 모든 요청에 해당 필터가 적용된다.

인증 체크

@Slf4j
public class LoginCheckFilter implements Filter {
	private static final String[] whitelist = {"/", "/members/add", "/login", "/logout","/css/*"};

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
		HttpServletRequest httpRequest = (HttpServletRequest) request;
		String requestURI = httpRequest.getRequestURI();
		HttpServletResponse httpResponse = (HttpServletResponse) response;
        
        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; //여기가 중요, 미인증 사용자는 다음으로 진행하지 않고 끝!
				}
			}
			chain.doFilter(request, response);
		} catch (Exception e) {
			throw e; //예외 로깅 가능 하지만, 톰캣까지 예외를 보내주어야 함
		} finally {
			log.info("인증 체크 필터 종료 {}", requestURI); }
		}
	}
    
	/**
	* 화이트 리스트의 경우 인증 체크X
	*/
	private boolean isLoginCheckPath(String requestURI) {
		return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
	}
}

isLoginCheckPath(requestURI)
로그인을 하지 않더라도, 접근이 가능한 페이지가 있다. (홈, 로그인, 회원가입, css, 리소스 등)
이러한 경로를 화이트리스트로 관리하여 권한 검사를 하지 않도록 한다.

httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
인증되지 않은 사용자를 로그인 화면으로 보낸다.
이후에, 로그인에 성공하면 마지막으로 보던 페이지로 돌아가도록 지원한다.
(/login API에 별도 구현이 필요하다.)

return;
인증되지 않은 사용자는 서블릿, 컨트롤러까지 로직을 수행하지 않고 먼저 종료시킨다.
바로 윗줄에서 httpResponse.sendRedirect(...)를 통해 페이지를 이동시키고 로직이 종료된다.

Configuration 으로 Filter 추가

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

setOrder(2)
이전에 Request 로깅 필터를 1번 순서로 했으므로,
로깅 이후에 인증 로직이 수행되도록 2번 순서로 지정한다.

addUrlPatterns("/*")
모든 요청에 로그인 필터를 적용한다.
화이트리스트로 관리되는 URL은 LoginCheckFilter 로직 내에서 담당한다.

스프링 인터셉터

인터페이스 설명

스프링의 인터셉터를 사용하려면 HandlerInterceptor 인터페이스를 구현하면 된다.

public interface HandlerInterceptor {
	// 컨트롤러 호출 전
	default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {}
    // 컨트롤러 호출 후
    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 {}
}

서블릿 필터는 doFilter()만 제공하는 반면,
인터셉터는 위와 같이 단계적으로 잘 세분화 되어 있다.

서블릿 필터는 경우 단순히 request, response 만 제공했지만,
인터셉터는 어떤 컨트롤러(handler)가 호출되는지 호출 정보도 받을 수 있다.
그리고 어떤 modelAndView 가 반환되는지 응답 정보도 받을 수 있다.

스프링 인터셉터의 호출 흐름

일반적인 호출 흐름
예외(Exception)이 발생했을 때의 호출 흐름컨트롤러에서 예외가 발생하면,
preHandle은 컨트롤러 호출 전에 수행되기 때문에, 항상 수행된다.
postHandle은 호출되지 않는다.
afterCompletion은 항상 호출된다. (예외가 발생하면 ex정보와 함께 호출됨)

Request 로깅

package hello.login.web.interceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;

@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);
		//@RequestMapping: HandlerMethod
		//정적 리소스: ResourceHttpRequestHandler
        if (handler instanceof HandlerMethod) {
			HandlerMethod hm = (HandlerMethod) handler; //호출할 컨트롤러 메서드의 모든 정보가 포함되어 있다.
		}
        
		log.info("REQUEST  [{}][{}][{}]", uuid, requestURI, handler);
		return true; //false를 반환하면 이후는 진행X
	}

	@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();
		String logId = (String)request.getAttribute(LOG_ID);
		log.info("RESPONSE [{}][{}]", logId, requestURI);
        // 컨트롤러에서 예외가 발생하면 ex변수가 함께 넘어온다.
		if (ex != null) {
			log.error("afterCompletion error!!", ex);
		}
	}
}

String uuid = UUID.randomUUID().toString()
요청 로그를 구분하기 위한 uuid 를 생성한다.

request.setAttribute(LOG_ID, uuid)
서블릿 필터는

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
	...
    String uuid = /*...*/;
	try {
		log.info("REQUEST  [{}][{}]", uuid, requestURI);
		chain.doFilter(request, response);
	} finally {
		log.info("RESPONSE [{}][{}]", uuid, requestURI);
	}
}

와 같이 Request Log -> Controller -> Response Log 가 모두 doFilter() 안에 들어있다. 따라서 uuid를 지역변수로 사용할 수 있다.
하지만 스프링 인터셉터는 컨트롤러 호출 전/후/요청완료 이후 시점이 나뉘어져있고, 싱글톤으로 관리되기 때문에 멤버변수로 사용할 수도 없다.
따라서 uuid를 request객체에 보관하고, 이후에 afterCompletion에서 꺼내쓴다.

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

Configuration 으로 Interceptor 등록

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

스프링 인터셉터는 서블릿 필터(FilterRegistrationBean)와 다르게, 별도 빈을 등록하지 않는다.
대신,WebMvcConfigurer를 상속받고 addInterceptors메소드를 오버라이드 한다.

excludePathPatterns("/css/**", "/*.ico", "/error")
화이트리스트를 doFilter 로직에서 관리하던 서블릿 필터와 달리, 스프링 인터셉터는 등록 시점에 지정할 수 있다. 또한, 보다 정교하게 지정할 수도 있다.
PathPatterns : https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/util/pattern/PathPattern.html

실행 로그

REQUEST [6234a913-f24f-461f-a9e1-85f153b3c8b2][/members/add]
[hello.login.web.member.MemberController#addForm(Member)]
postHandle [ModelAndView [view="members/addMemberForm"; model={member=Member(id=null, loginId=null, name=null, password=null),org.springframework.validation.BindingResult.member=org.springframework.validation.BeanPropertyBindingResult: 0 errors}]]
RESPONSE [6234a913-f24f-461f-a9e1-85f153b3c8b2][/members/add]

인증 체크

package hello.login.web.interceptor;
import hello.login.web.SessionConst;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

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

서블릿 필터와 비교했을 때 코드가 간결해졌다.
인증은 컨트롤러 호출 전에만 수행되면 되기 때문에, preHandle(...) 만 구현하면 된다.

Interceptor 여러개 등록 및 순서 지정

@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");
	}
//...
}

Reference
https://stackoverflow.com/questions/47091717/interceptor-in-spring-5-webflux

profile
A fast learner.

0개의 댓글