Chapter 13 - MVC 연습(3) : 세션, 인터셉터, 쿠키

이현빈·2024년 3월 27일

1. 프로젝트 준비

이전 포스트에서 연습할 때 사용했던 코드를 재활용하였다. 이번에 pom.xml에 별도로 추가할 모듈은 없다.


2. 로그인 처리를 위한 코드 준비

로그인 처리 기능에는 Session, Interceptor, Cookie 등이 활용된다. 로그인 처리 기능을 구현하기 위해서는 아래와 같은 밑작업이 이루어져야 한다. 새로 추가해야 할 코드의 개수와 분량이 많은 관계로 깃허브 링크를 남긴다.

  1. 로그인 성공 시 Session에 보관할 인증 상태 정보 관련 클래스 만들기
    • src/main/java/spring 패키지 내
      AuthInfo, AuthInfoService 클래스
  2. 입력한 암호의 일치 여부 확인용 메서드 추가하기
    • src/main/java/spring 패키지의 Member 클래스 내 matchPassword() 메서드
  3. 로그인에 사용할 커맨드 객체와 Validator 클래스 만들기
    • src/main/java/controller 패키지 내
      LoginCommand, LoginCommandValidator 클래스
  4. 로그인/로그아웃 처리용 컨트롤러 클래스 만들기
    • src/main/java/controller 패키지 내
      LoginController, LogoutController 클래스
  5. 로그인 Form & 로그인 성공 결과 View를 제공하는 jsp 코드 작성하기
    • src/main/webapp/WEB-INF/view/login 폴더 내 .jsp 파일
  6. 로그인 관련 View용 메시지 프로퍼티 추가하기
    • src/main/resources/message/label_ko.properties 내 로그인 관련 프로퍼티
  7. 로그인 컨트롤러와 서비스에 관한 Bean을 설정 클래스에 등록하기
    • src/main/java/config 패키지 내
      MemberConfig, ControllerConfig 클래스

3. 컨트롤러에서 HttpSession 사용하기

Login에서의 활용

로그인 성공 후 로그인 상태를 유지하는 방법으로는 로그인 컨트롤러 클래스에서 HttpSession을 사용하거나, Cookie를 이용하는 방법이 있다. 이 중 HttpSession을 사용하기 위해서는 다시 다음의 2가지 방법 중 한 가지를 선택하면 된다.

1. 요청 매핑 어노테이션 적용 메서드에 HttpSession 파라미터 추가
이 방법을 사용할 경우, Spring MVC는 컨트롤러의 메서드를 호출할 때 HttpSession 객체를 파라미터로 전달한다. 만약 HttpSession 생성 이전이면 새로운 HttpSession을 만들고, 그렇지 않다면 기존의 HttpSession을 파라미터로 전달한다. 이 방법은 항상 HttpSession을 생성한다.
2. 요청 매핑 어노테이션 적용 메서드에 HttpServletRequest 파라미터 추가 후 이를 통해 getSession() 메서드 사용
이 방법을 사용할 경우, Spring MVC는 컨트롤러의 메서드를 호출할 때 HttpServletRequest 객체를 파라미터로 전달하고, 해당 파라미터를 통해 getSession() 메서드를 호출하는 시점부터 HttpSession을 사용할 수 있다. 이를 통해 1.의 방법과는 달리 세션이 필요한 시점부터 HttpSession을 생성하여 사용할 수 있다.

로그인 요청을 처리할 때는 로그인 Form의 입력값을 통한 로그인 시도가 성공한 이후부터 로그인 상태를 유지하기 위한 인증 정보를 세션에 저장한다. 이는 곧 로그인 처리 과정에서 세션을 생성하는 시점이 정해져 있다는 것을 의미하므로, 실제 코드에서 HttpSession을 사용할 때는 HttpServletRequest를 파라미터로 사용하는 두번째 방법을 사용하였다.

package controller;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;

... 코드 생략

@Controller
@RequestMapping("/login")
public class LoginController {

    ... 코드 생략

	/* HttpServletRequest 파라미터를 이용하여 HttpSession 사용,
       1. @Valid 어노테이션을 커맨드 객체에 적용하여 검증 수행 */
    @PostMapping
    public String submit(@Valid LoginCommand loginCommand, Errors errors, 
    		HttpServletRequest request,
            HttpServletResponse response) {
        
        /* 2-1. 로그인 Form 입력값이 올바르지 않으면 재입력 */
        if (errors.hasErrors()) {
            return "login/loginForm";
        }

        try {
        	/* 2-2 로그인 성공 시 인증 정보 저장용 객체 생성 */
            AuthInfo authInfo = authService.authenticate(
                    loginCommand.getEmail(), loginCommand.getPassword());
            
            /* 3. 로그인 Form 입력값이 올바르면 로그인 세션 생성 후 인증 정보 저장 */
            HttpSession session = request.getSession();
            session.setAttribute("authInfo", authInfo);
            
            /* 4. 로그인 성공 시 출력할 View 이름 리턴 */
            return "login/loginSuccess";
        } catch (WrongIdPasswordException e) {
            errors.reject("idPasswordNotMatching");
            return "login/loginForm";
        }
    }
    
    /* 로그인 컨트롤러 범위 Validator만 적용 */
    @InitBinder
    protected void initBinder(WebDataBinder binder) {
        binder.setValidator(new LoginCommandValidator());
    }
}

Logout에서의 활용

로그아웃 요청을 처리하는 경우, HttpSession을 제거함으로써 로그인 상태를 끝내야 한다.
세션의 해제는 요청 매핑 어노테이션이 적용된 메서드에서 HttpSessioninvalidate() 메서드를 호출하는 방식으로 이루어진다.

package controller;

import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class LogoutController {

    @RequestMapping("/logout")
    public String logout(HttpSession session) {
    	/* 세션을 해제하여 로그인 상태를 종료 */
        session.invalidate();
        return "redirect:/main";
    }
}

4. 비밀번호 변경 기능 구현

비밀번호 변경 기능을 구현하기 위해서는 다음의 코드들이 준비되어야 한다. 자세한 실제 코드는 깃허브 링크를 통해 확인할 수 있다.

  1. 비밀번호 변경에 사용할 커맨드 객체와 Validator 클래스 만들기
    • src/main/java/controller 패키지 내
      ChangePwdCommand, ChangePwdCommandValidator 클래스
  2. 비밀번호 변경 관련 컨트롤러 클래스 만들기
    • src/main/java/controller 패키지 내 ChangePwdController 클래스
  3. 비밀번호 변경 Form & 비밀번호 변경 성공 View에 관한 jsp 코드 작성하기
    • src/main/webapp/WEB-INF/view/edit 폴더 내 .jsp 파일
  4. 로그인 관련 View용 메시지 프로퍼티 추가하기
    • src/main/resources/message/label_ko.properties 내 비밀번호 변경 관련 프로퍼티
  5. 비밀번호 변경에 관한 Bean 설정을 컨트롤러 설정 클래스에 등록하기
    • src/main/java/config 패키지 내 ControllerConfig 클래스

ChangePwdCommand 클래스

회원 등록용 커맨드 객체로 사용된 RegisterRequest에서는 Bean Validator를 이용하여 비밀번호의 최소 길이 제한을 걸어 두었다. 변경한 비밀번호에서도 이러한 길이 제한을 유지해야 했기 때문에, 비밀번호 변경 Form의 입력값을 받을 커맨드 객체에서도 Bean Validator 제공 어노테이션을 사용하였다.

package controller;

import jakarta.validation.constraints.Size;

public class ChangePwdCommand {

    private String currentPassword;

	/* 변경한 비밀번호에 회원 등록과 동일한 비밀번호 길이 제한 적용, 
    	이 값에 관한 검증은 global 범위에서 수행 */
    @Size(min=6)
    private String newPassword;

    public String getCurrentPassword() {
        return currentPassword;
    }

    public void setCurrentPassword(String currentPassword) {
        this.currentPassword = currentPassword;
    }

    public String getNewPassword() {
        return newPassword;
    }

    public void setNewPassword(String newPassword) {
        this.newPassword = newPassword;
    }
}

ChangePwdController 클래스

비밀번호 변경 작업은 현재의 로그인 상태를 유지하는 동안 이루어지므로 기존의 HttpSession을 유지한 상태에서 비밀번호 변경이 이루어져야 한다.
그리고 앞에서 서술한 HttpSession을 사용하는 2가지 방법 중 첫번째인, HttpSession 파라미터를 사용하는 방법에서는 기존에 사용 중인 HttpSession이 있다면 해당 세션을 이 파라미터의 값으로 사용한다. 따라서 기존의 로그인 세션을 유지한 상태에서 비밀번호 변경 요청을 처리하기 위해서는 앞서 언급한 2가지 방법 중 HttpSession을 파라미터로 사용하는 첫번째 방법을 활용한다.

이와 더불어, 비밀번호 변경용 커맨드 객체에 설정해둔 값 검증 규칙을 적용하여 비밀번호 변경용 커맨드 객체의 검증을 수행해야 하므로, 파라미터로 사용된 커맨드 객체에 @Valid 어노테이션을 사용하고, @InitBinder 적용 메서드를 컨트롤러에 추가하여 커맨드 객체의 값에 관한 global 범위 Validator와 Controller 범위 Validator를 모두 사용하도록 구현했다.

package controller;

import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Errors;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import spring.AuthInfo;
import spring.ChangePasswordService;
import spring.WrongIdPasswordException;

@Controller
@RequestMapping("/edit/changePassword")
public class ChangePwdController {

    private ChangePasswordService changePasswordService;

    public void setChangePasswordService(ChangePasswordService changePasswordService) {
        this.changePasswordService = changePasswordService;
    }

    @GetMapping
    public String form(@ModelAttribute("command") ChangePwdCommand pwdCmd) {
        return "edit/changePwdForm";
    }

	/* 비밀번호 변경은 기존의 로그인 세션을 유지한 상태에서 실시 */
    @PostMapping
    public String submit(
    		// 1. 비밀번호 변경 이전 커맨드 객체의 값에 관한 검증 실시
            @Valid @ModelAttribute("command") ChangePwdCommand pwdCmd,
            Errors errors,
            HttpSession session) {
		
        /* 2-1. 비밀번호 변경 Form의 입력값이 올바르지 않으면 재입력 */
        if (errors.hasErrors()) {
            return "edit/changePwdForm";
        }
        
        /* 2-2. 입력값이 올바르면 기존 세션에 저장된 인증 정보를 불러옴  */
        AuthInfo authInfo = (AuthInfo) session.getAttribute("authInfo");
        try {
        	/* 3. 비밀번호 변경 실시 */
            changePasswordService.changePassword(
                    authInfo.getEmail(),
                    pwdCmd.getCurrentPassword(),
                    pwdCmd.getNewPassword());
                    
            /* 4. 비밀번호 변경 성공 시 출력할 View 이름 리턴 */
            return "edit/changedPwd";
        } catch (WrongIdPasswordException e) {
            errors.rejectValue("currentPassword", "notMatching");
            return "edit/changePwdForm";
        }
    }

	/* 비밀번호 변경 컨트롤러 범위 Validator 추가 */
    @InitBinder
    protected void initBinder(WebDataBinder binder) {
        binder.addValidators(new ChangePwdCommandValidator());
    }
}

5. 인터셉터 사용하기

별도의 조치를 취하지 않았다면, 로그인하지 않은 상태에서 비밀번호 변경용 폼을 화면에 출력하는 view의 주소를 입력하면 비밀번호 변경 폼이 출력된다. 비밀번호 변경은 로그인한 상태를 유지하면서 수행해야 하므로, 로그인하지 않은 상태에서 비밀번호 변경 폼을 요청하면 로그인 화면으로 이동하는 것이 더 좋다.

이를 위해서는 HttpSession에 인증 정보를 저장하는 객체가 존재하는지 검사하고, 존재하지 않을 경우에는 로그인 화면으로 리다이렉트하는 세션 확인 코드를 추가하는 것이 해결책이 될 수 있다. 그러나 세션 확인 코드가 필요한 컨트롤러는 비밀번호 변경 컨트롤러에만 국한되지 않는다.

이 때문에 컨트롤러마다 세션 확인 코드를 추가하는 작업은 코드의 중복을 유발한다. 하지만 HandlerInterceptor를 사용한다면 다수의 컨트롤러에게 동일한 기능을 적용할 때마다 나타나는 이러한 번거로움을 해결할 수 있다.

HandlerInterceptor 인터페이스의 구현

org.springframework.web.HandlerInterceptor 인터페이스에서는 다음의 메서드를 활용하여 3가지 시점에 컨트롤러 공통 기능을 추가할 수 있다.
각 메서드는 모두 세부 기능이 구현되지 않은 default 메서드이므로 이 인터페이스를 상속받은 클래스는 아래 메서드 중 필요한 것만 취사선택하는 것이 가능하다.

default boolean preHandle(
	HttpServletRequest request, 
    HttpServletResponse response
    Object handler) throws Exception;

default void postHandle(
	HttpServletRequest request, 
    HttpServletResponse response
    Object handler
    ModelAndView modelAndView) throws Exception;

default void afterCompletion(
	HttpServletRequest request, 
    HttpServletResponse response
    Object handler
    Exception ex) throws Exception;

HandlerInterceptor의 주요 파라미터

  • HttpServletRequest request: 웹 클라이언트의 요청 경로
  • HttpServletResponse response: 웹 클라이언트에게 보낼 응답
  • Object handler
    : 웹 클라이언트의 요청을 처리할 컨트롤러/핸들러 객체
  • ModelAndView modelAndView
    : postHandle() 메서드만 사용하는 파라미터로, 구현한 추가 기능을 전달할 view 이름을 저장하는 객체
  • Exception ex
    : afterCompletion() 메서드만 사용하는 파라미터로, 로그 기록 등의 후처리 작업에 활용할 Exception을 나타낸다.

HandlerInterceptor의 주요 메서드

  • preHandle()
    이 메서드로 handler 실행 이전에 사용할 기능을 구현할 수 있다.
    이를 통해 로그인하지 않은 상태에서의 컨트롤러 실행을 방지하는 작업이나, 컨트롤러 실행 이전에 컨트롤러가 필요로 하는 정보를 생성하는 작업을 수행할 수 있다.
    만약 이 메서드의 반환값이 false이면 컨트롤러나 다음 HandlerInterceptor를 실행하지 않는다.
  • postHandle()
    이 메서드는 handler가 정상 실행된 이후 추가 기능을 구현할 때 사용한다. 만약 이 메서드를 호출하기 전에 handler에서 Exception이 발생하면 이 메서드는 실행하지 않는다.
  • afterCompletion()
    이 메서드는 View가 웹 클라이언트에게 응답을 전송한 뒤에 실행되며, handler 실행 이후 발생한 Exception을 로그로 남기거나 실행시간을 기록하는 등의 후처리에 활용된다. 컨트롤러 실행 과정에서 Exception이 발생하면 이 메서드의 네번째 파라미터인 ex로 전달되고, 그렇지 않으면 해당 파라미터는 null이 된다.

AuthCheckInterceptor

public class AuthCheckInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(
            HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull Object handler) throws Exception {
        HttpSession session = request.getSession(false);
        if (session != null) {
            Object authInfo = session.getAttribute("authInfo");
            if (authInfo != null) {
                return true;
            }
        }
        response.sendRedirect(request.getContextPath() + "/login");
        return false;
    }
}

HandlerInterceptor 설정하기

HandlerIntercepter를 구현했다면 이를 적용할 위치를 설정해야 한다. 인터셉터를 적용할 범위에 관한 설정은 @EnableWebMvc 어노테이션이 적용된 설정 클래스에서 이루어지며, 이를 통해 여러 개의 컨트롤러에게 공통된 기능을 적용할 수 있다.

MvcConfig

@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {

    ... 코드 생략

	/* 사용할 인터셉터 설정 및 인터셉터 적용 경로 지정
    	(2개 이상의 경로 지정 시 addPathPattern()의 각 경로 패턴을 콤마로 구분) */  
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authCheckInterceptor())
                .addPathPatterns("/edit/**");
    }

	/* 인터셉터 생성 */
    @Bean
    public AuthCheckInterceptor authCheckInterceptor() {
        return new AuthCheckInterceptor();
    }
}

Ant 경로 패턴

주요 특수문자의 의미

* : 0개 또는 그 이상의 글자
? : 1개 글자
** : 0개 또는 그 이상의 폴더 경로

Ant 경로 패턴 사용 예시

@RequestMapping("/member/?.info")
: /member/로 시작하고 확장자가 .info로 끝나는 모든 경로
@RequestMapping("/faq/f?00.fq")
: /faq/f로 시작하고, 임의의 1개 글자를 사용한 다음 00.fq로 끝나는 모든 경로
@RequestMapping("/folders/**/files")
: /folders/로 시작하고, 중간에 0개 이상의 중간 경로가 존재하며, /files로 끝나는 모든 경로


6. 컨트롤러에서 쿠키 사용하기

쿠키(Cookie)란 인터넷 사용자가 웹 페이지를 방문했을 때 사용자의 웹 브라우저를 통해 사용자의 컴퓨터나 다른 기기에 설치되는 소용량의 데이터이다.
쿠키는 로그인 시 아이디 자동 완성 기능, 이메일 기억하기 기능처럼 이전에 저장한 데이터를 활용하게 되는 사용자 편의 기능을 구현할 때 활용된다. 하지만 쿠키로 저장가능한 값 중에는 이메일과 같은 민감한 개인 정보도 있기 때문에 실제 서비스에서는 쿠키의 암호화를 통해 보안을 향상시켜야 한다.

쿠키를 이용한 이메일 기억하기 기능 구현

이메일 기억하기 기능 수행 방식

1. 로그인 폼에 이메일 기억하기 옵션 추가
: src/main/webapp/WEB-INF/view/login 폴더 내 loginForm.jsp 파일에 이메일 기억하기 체크박스 추가
2. 로그인 화면에서 이메일 기억하기 옵션 선택 후 로그인 성공 시 쿠키에 이메일을 저장
(쿠키의 유효기간은 충분히 길어야 함에 유의)
: src/main/java/controller 패키지의 LoginController에서 form(), submit() 메서드 수정
3. 이후 로그인 폼 출력 시 쿠키가 존재하면 입력 폼에 이메일 출력

주요 어노테이션

  • @CookieValue(value="쿠키 이름", required="쿠키 사용 필수 여부")
    이 어노테이션은 요청 매핑 어노테이션 적용 메서드의 Cookie 타입 파라미터에 적용되며, 해당 파라미터로 현재 존재하는 쿠키를 전달한다.
    이 어노테이션의 required 속성은 value 속성값을 이름으로 갖는 쿠키가 이 어노테이션이 적용된 요청 처리 메서드를 실행하기 위해 필요한지를 나타낸다. 만약 required 속성의 값이 true이면서 지정한 이름의 쿠키가 없으면 Exception이 발생한다.

LoginController

package controller;

import org.springframework.web.bind.annotation.CookieValue;
import jakarta.servlet.http.HttpServletResponse;

... 코드 생략

@Controller
@RequestMapping("/login")
public class LoginController {

    private AuthService authService;

    public void setAuthService(AuthService authService) {
        this.authService = authService;
    }

	/* form() : 쿠키 사용 메서드 */
    @GetMapping
    public String form(LoginCommand loginCommand,
            @CookieValue(value="REMEMBER", required = false) Cookie rCookie) {
        
        /* "REMEMBER"라는 이름의 쿠키가 존재하면 이메일 기억하기 기능 수행 */
        if (rCookie != null) {
            loginCommand.setEmail(rCookie.getValue());
            loginCommand.setRememberEmail(true);
        }
        return "login/loginForm";
    }


	/* HttpServletResponse : 로그인 성공 시 생성한 쿠키를 저장 */
    @PostMapping
    public String submit(@Valid LoginCommand loginCommand, Errors errors, HttpServletRequest request,
            HttpServletResponse response) {
        if (errors.hasErrors()) {
            return "login/loginForm";
        }

        try {
            AuthInfo authInfo = authService.authenticate(
                    loginCommand.getEmail(), loginCommand.getPassword());
                    
            HttpSession session = request.getSession();
            session.setAttribute("authInfo", authInfo);
            
            /* 로그인 성공 시 생성한 쿠키를 저장 */
            response.addCookie(bakeCookie(loginCommand));
            
            return "login/loginSuccess";
        } catch (WrongIdPasswordException e) {
            errors.reject("idPasswordNotMatching");
            return "login/loginForm";
        }
    }
    
    /* 로그인 폼 입력값에 관한 쿠키 생성용 메서드 (코드 길이로 인해 메서드 분리) */
    private Cookie bakeCookie(LoginCommand loginCommand) {
    	/* 1. 쿠키 생성 후 이름 & 값 설정 */
        Cookie rememberCookie = new Cookie("REMEMBER", loginCommand.getEmail());
        
        /* 2. 생성한 쿠키를 저장할 경로 설정 */
        rememberCookie.setPath("/");
        if (loginCommand.isRememberEmail()) {
        	/* 3-1. 이메일 기억하기 기능 사용 시 쿠키 유효기간은 30일 */
            rememberCookie.setMaxAge(60*60*24*30);
        } else {
        	/* 3-2. 이메일 기억하기 기능 미사용 시 쿠키 즉시 삭제 */
            rememberCookie.setMaxAge(0);
        }
        
        /* 4. 생성한 쿠키 반환 */
        return rememberCookie;
    }

    ... 코드 생략
}

Reference

  • 초보 웹 개발자를 위한 스프링5 프로그래밍 입문(최범균)

0개의 댓글