프로젝트/개인 프로젝트 V1

(6) 쿠키, 세션, 인터셉터를 활용하여 로그인 구현

Thumper 2022. 8. 2. 04:27

이번 시간에는 로그인과 로그아웃을 어떻게 구현했는지 정리해보도록 하겠습니다.

우선 로그인 처리를 위한 보안 요구사항은 다음과 같습니다.

  • 로그인 사용자만 해당 웹페이지를 사용할 수 있다.
  • 로그인 하지 않은 사용자가 웹페이지에 접근하면 로그인 화면으로 이동된다.
  • 쿠키,세션,인터셉터를 사용하여 인증체크를 구현한다.

 

로그인 처리하기 - 서블릿 HTTP 세션

인프런 강의에서 쿠키, 세션, 인터셉터를 활용한 로그인 구현 방법을 배웠고, 이번 프로젝트에서도 해당 방식을 적용했습니다. 

  • 서블릿은 세션을 위해 HttpSession 이라는 기능을 제공한다.
  • 서블릿을 통해 HttpSession 을 생성하면 쿠키를 생성해준다.
  • 쿠키 이름이 JSESSIONID 이고, 값은 추정 불가능한 랜덤 값이다.
@PostMapping("/login")
public String signIn(@Valid @ModelAttribute LoginFormDto form, BindingResult bindingResult, @RequestParam(defaultValue = "/") String redirectURL, HttpServletRequest request) {
        if (bindingResult.hasErrors()) {
            return "login/loginFormV2";
        }

        User loginUser = userService.signIn(form);

        if (loginUser == null) {
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 다릅니다.");
            return "login/loginFormV2";
        }
        HttpSession session = request.getSession();
        session.setAttribute(SessionConst.LOGIN_MEMBER, loginUser);

       // return "redirect:" + redirectURL;
        return "redirect:/home";
}

 

회원가입에 대한 API 요청은, 클라이언트가 잘못된 로그인 정보를 입력하면 로그인 페이지로 리다이렉트되도록 if()문과 BindingResult를 사용했습니다.

올바른 로그인 정보를 입력한 경우에는 세션의 유무에 따라 세션을 생성하고, 생성된 세션에 로그인한 회원의 정보를 보관하도록 코드를 작성했습니다.

 

  • User loginUser = userService.signIn(form)
    • user 엔티티를 생성한다.
  • HttpSession session = request.getSession()
    • 세션이 있으면 있는 세션 반환하고, 없으면 신규 세션 생성한다.
  • session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember)
    • 세션에 로그인을 한 회원의 정보를 보관한다. 

 

 

 

 

스프링 인터셉터

스프링 인터셉터 예외 흐름 처리는 아래 그림과 같습니다.

스프링 인터셉터 예외

 

  • 컨트롤러에서 예외가 발생하면 postHandle은 호출되지 않습니다.
  • preHandle은 컨트롤러 호출 전에만 실행되므로, 예외 처리 전에 수행할 로직을 preHandle에서 구현하면 됩니다.

 

 

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

스프링 인터셉터로 LogInterceptor를 아래와 같이 구현했습니다.

@Slf4j
public class LogInterceptor implements HandlerInterceptor {

    private 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("postHandler [{}]", modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String requestURI = request.getRequestURI();
        Object uuid = request.getAttribute(LOG_ID);

        log.info("RESPONSE [{}][{}][{}]", uuid, requestURI, handler);

        if(ex != null) {
            log.error("afterCompletion error!", ex);
        }
    }
}

 

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

요청 로그를 구분하기 위한 uuid 를 생성한다.

 

request.setAttribute(LOG_ID, uuid);

  • 서블릿 필터의 경우 지역변수로 해결이 가능하지만, 스프링 인터셉터는 호출 시점이 완전히 분리되어 있다.
  • 따라서 preHandle 에서 지정한 값을 postHandle , afterCompletion 에서 함께 사용하려면 어딘가에 담아두어야 한다.
  • LogInterceptor 도 싱글톤 처럼 사용되기 때문에 맴버변수를 사용하면 위험하다.
  • 따라서 request 에 담아두었다.
  • 이 값은 afterCompletion 에서 request.getAttribute(LOG_ID) 로 찾아서 사용한다.

 

return true;

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

 

if (handler instanceof HandlerMethod) {}

  • 핸들러 정보는 어떤 핸들러 매핑을 사용하는가에 따라 달라진다.
  • 스프링을 사용하면 일반적으로 @Controller , @RequestMapping 을 활용한 핸들러 매핑을 사용하는데, 이 경우 핸들러 정보로 HandlerMethod 가 넘어온다.

 

 

 

WebConfig - 인터셉터 등록

WebConfig에 인터셉터를 등록해야 사용할  수 있습니다.

  • WebMvcConfigurer 가 제공하는 addInterceptors() 를 사용해서 인터셉터를 등록한다.
  • 만든 로그인 체크 인터셉터 LoginCheckInterceptor도 등록해둔다.
  • 만든 ArgumentResolver LoginUserArgumentResolver도 등록해둔다.
  • excludePathPatterns("/css/**", "/*.ico", "/error")를 추가하여, 인터셉터에서 제외할 패턴을 지정한다.
@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("/", "/register", "/login", "/logout", "/css**", "/error");

    }


    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginUserArgumentResolver());
    }
}

 

 

 

 

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

로그인 체크 인터셉터 LoginCheckInterceptor를 다음과 같이 구현했습니다.

preHandle 메서드에서 세션을 확인하여 로그인된 사용자가 아니면 로그인 페이지로 리다이렉트하고,

인증된 사용자는 요청을 정상적으로 처리할 수 있도록 합니다.

 

로그인되지 않은 사용자는 요청한 URL이 아닌 로그인 페이지로 리다이렉트되며,

로그인 후에는 원래 요청한 URL로 돌아올 수 있도록 처리됩니다.

@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("미인증 사용자 요청");
            response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        }
        return true;
    }
}

 

 

 

 

 

@Login 애노테이션 

자동으로 세션에서 로그인된 회원 정보를 찾아주고, 세션에 해당 정보가 없다면 null을 반환하는 애노테이션을 만들었습니다. 이 애노테이션을 사용하여 컨트롤러에서 현재 로그인된 회원 정보를 확인했습니다.

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}

 

 

 

 

LoginUserArgumentResolver 

LoginUserArgumentResolver는 컨트롤러 메서드에서 로그인된 사용자 정보를 자동으로 주입하는 역할을 합니다.

supportsParameter 메서드는 해당 파라미터가 @Login 애노테이션을 가지고 있고, 타입이 User일 때만 처리하도록 조건을 설정합니다.

resolveArgument 메서드는 세션에서 로그인된 사용자를 찾아 LOGIN_MEMBER 속성으로 저장된 회원 정보를 반환합니다. 세션이 없거나 로그인된 사용자가 없으면 null을 반환합니다.

 

 

LoginUserArgumentResolver는 다음과 같은 장점이 있습니다.

  • 더 편리하게 로그인 회원 정보를 조회할 수 있다.
  • 회원별 상품등록/구매/조회 구현할 때, 현재 로그인한 회원을 쉽게 찾을 수 있게 해준다.
@Slf4j
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {

        log.info("supportsParameter 실행");

        boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class);
        boolean hasUserType = User.class.isAssignableFrom(parameter.getParameterType());

        return hasLoginAnnotation && hasUserType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        log.info("resolverArgument 실행");

        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
        if (session == null) {
            return null;
        }

        Object user = session.getAttribute(SessionConst.LOGIN_MEMBER);
        return user;
    }
}

supportsParameter()

@Login 애노테이션이 있으면서 User 타입이면 해당 ArgumentResolver가 사용된다.

 

resolveArgument() 

  • 컨트롤러 호출 직전에 호출 되어서 필요한 파라미터 정보를 생성해준다.
  • 여기서는 세션에 있는 로그인 회원 정보인 user 객체를 찾아서 반환해준다.
  • 이후 스프링MVC는 컨트롤러의 메서드를 호출하면서 여기에서 반환된 user 객체를 파라미터에 전달해준다.

 

 

 

 

 

 

로그아웃

로그아웃은 세션을 만료시키는 방식으로 구현하였습니다.

    @PostMapping("/logout")
    public String logoutV3(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }
        return "redirect:/login";
    }

 HttpSession session = request.getSession(false);

  • 세션이 있으면 기존 세션을 반환한다.
  • 세션이 없으면 새로운 세션을 생성하지 않는다.
  • null 을 반환한다.

 

session.invalidate();

세션을 제거한다.