[스프링] 스프링5 프로그래밍 입문 - 13 장 : 세션, 인터셉터, 쿠키

June·2021년 6월 17일
1

AuthInfo

public class AuthInfo {

    private Long id;
    private String email;
    private String name;

    public String getEmail() {
        return email;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public AuthInfo(Long id, String email, String name) {
        this.id = id;
        this.email = email;
        this.name = name;
    }
}

로그인 성공 후 인증 상태 정보를 보관할 때 사용할 클래스이다.

Member

public class Member {

    ...

    public boolean matchPassword(String password) {
        return this.password.equals(password)
    }

AuthService

public class AuthService {

    private MemberDao memberDao;

    public void setMemberDao(MemberDao memberDao) {
        this.memberDao = memberDao;
    }

    public AuthInfo authenticate(String email, String password) {
        Member member = memberDao.selectByEmail(email);
        if (member == null) {
            throw new WrongIdPasswordException();
        }
        if (!member.matchPassword(password)) {
            throw new WrongIdPasswordException();
        }
        return new AuthInfo(member.getId(), member.getEmail(), member.getName());
    }
}

이메일과 비밀번호가 일치하는지 확인해서 AuthInfo 객체를 생성하는 클래스이다.

LoginCommand

public class LoginCommand {
    
    private String email;
    private String password;
    private boolean rememberEmail;

    public String getEmail() {
        return email;
    }

    public String getPassword() {
        return password;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public void setRememberEmail(boolean rememberEmail) {
        this.rememberEmail = rememberEmail;
    }
    
    public boolean isRememberEmail() {
        return rememberEmail;
    }
}

폼에 입력한 값을 전달받기 위한 클래스다.

LoginCommandValidator

public class LoginCommandValidator implements Validator {

    @Override
    public boolean supports(Class<?> aClass) {
        return LoginCommand.class.isAssignableFrom(aClass);
    }
    
    @Override
    public void validate(Object o, Errors errors) {
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "email", "required");
        ValidationUtils.rejectIfEmpty(errors, "password", "required");
    }
}

폼에 입력한 값이 올바른지 검사하기 위핸 클래스다.

LoginController

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

    private AuthService authService;

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

    @GetMapping
    public String form(LoginCommand loginCommand) {
        return "login/loginForm";
    }

    @PostMapping
    public String submit(LoginCommand loginCommand, Errors errors) {
        new LoginCommandValidator().validate(loginCommand, errors);
        if (errors.hasErrors()) {
            return "login/loginForm";
        }
        try {
            AuthInfo authInfo = authService.authenticate(loginCommand.getEmail(), loginCommand.getPassword());

            // TODO 세션에 authInfo 저장해야 함
            return "login/loginSuccess";
        } catch (WrongIdPasswordException e) {
            errors.reject("idPasswordNotMatching");
            return "login/loginForm";
        }
    }
}

MemberConfig

@Configuration
@EnableTransactionManagement
public class MemberConfig {

    ...

    @Bean
    public AuthService authService() {
        AuthService authService = new AuthService();
        authService.setMemberDao(memberDao());
        return authService;
    }
}

ControllerConfig

@Configuration
public class ControllerConfig {

    ...
    @Autowired
    private AuthService authService;

    ...
    
    @Bean
    public LoginController loginController() {
        LoginController controller = new LoginController();
        controller.setAuthService(authService);
        return controller;
    }
}

컨트롤러에서 HttpSession 사용하기

로그인 상태를 유지하는 방법은 크게 HttpSession을 이용하는 방법과 쿠키를 이용하는 방법이 있다.

컨트롤러에서 HttpSession을 사용하려면 다음의 두 가지 방법 중 하나를 사용하면 된다.

  1. 요청 매핑 애노테이션 적용 메서드에 HttpSession 파라미터를 추가한다.
  2. 요청 매핑 애노테이션 적용 메서드에 HttpServletRequest 파라미터를 추가하고 HttpServletRequest를 이용해서 HttpSession을 구한다.

다음은 첫 번째 방법을 사용한 코드 예이다.

@PostMapping
public String form(LoginCommand loginCommand, Errors errors, HttpSession session) {
    .. // session을 사용하는 코드
}

요청 매핑 애노테이션 적용 메서드에 HttpSession 파라미터가 존재할 경우 스프링 MVC는 컨트롤러의 메서드를 호출할 때 HttpSession 객체를 파라미터로 전달한다. HttpSession을 생성하기 전이면 새로운 HttpSession을 생성하고 그렇지 않으면 기존에 존재하는 HttpSession을 전달한다.

두 번째 방법은 HttpServletRequest의 getSession() 메서드를 이용하는 것이다.

@PostMapping
public String submit(
    LoginCommand loginCommand, Errors errors, HttpServletRequest req) {
        HttpSession session = req.getSession();
        .. // session을 사용하는 코드
    }

첫 번째 방법은 항상 HttpSession을 생성하지만 -

LoginController

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

    ...

    @PostMapping
    public String submit(LoginCommand loginCommand, Errors errors, HttpSession session) {
        new LoginCommandValidator().validate(loginCommand, errors);
        if (errors.hasErrors()) {
            return "login/loginForm";
        }
        try {
            AuthInfo authInfo = authService.authenticate(loginCommand.getEmail(), loginCommand.getPassword());

            session.setAttribute("authInfo", authInfo);
            return "login/loginSuccess";
        } catch (WrongIdPasswordException e) {
            errors.reject("idPasswordNotMatching");
            return "login/loginForm";
        }
    }
}

HttpSession의 "authInfo" 속성에 인증 정보 객체(authInfo)를 저장하도록 코드를 추가했다.

LogoutController

@Controller
public class LogoutController {

    @RequestMapping("/logout")
    public String logout(HttpSession session) {
        session.invalidate();
        return "redirect:/main";
    }
}

로그아웃을 위한 컨트롤러 클래스는 HttpSession을 제거하면 된다.

@Configuration
public class ControllerConfig {

    ...
    
    @Bean
    public LogoutController logoutController() {
        return new LogoutController();
    }
}

ChangePwdCommand

public class ChangePwdCommand {
    
    private String currentPassword;
    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;
    }
}

ChangePwdCommandValidator

public class ChangePwdCommandValidator implements Validator {

    @Override
    public boolean supports(Class<?> aClass) {
        return ChangePwdCommand.class.isAssignableFrom(aClass);
    }

    @Override
    public void validate(Object o, Errors errors) {
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "currentPassword", "required");
        ValidationUtils.rejectIfEmpty(errors, "newPassword", "required");
    }
}

ChangePwdController

@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(@ModelAttribute("command") ChangePwdCommand pwdCmd, Errors errors, HttpSession session) {
        new ChangePwdCommandValidator().validate(pwdCmd, errors);
        if (errors.hasErrors()) {
            return "edit/changePwdForm";
        }
        AuthInfo authInfo = (AuthInfo) session.getAttribute("authInfo");
        try {
            changePasswordService.changePassword(authInfo.getEmail(), pwdCmd.getCurrentPassword(), pwdCmd.getNewPassword());
            return "edit/changePwd";
        } catch (WrongIdPasswordException e) {
            errors.rejectValue("currentPassword", "notMatching");
            return "edit/changePwdForm";
        }
    }
}

ControllerConfig

@Configuration
public class ControllerConfig {

    ...
    
    @Bean
    public ChangePwdController changePwdController() {
        ChangePwdController controller = new ChangePwdController();
        controller.setChangePasswordService(changePasswordService);
        return controller;
    }
}

인터셉터 사용하기

로그인하지 않았는데 변경 폼이 출력되는 것은 이상하다. 그것보다는 로그인하지 않은 상태에서 비밀번호 변경 폼을 요청하면 로그인 화면으로 이동시키는 것이 더 좋은 방법이다.

이를 위해 HttpSession에 "autoInfo" 객체가 존재하는지 검사하고 존재하지 않으면 로그인 경로로 리다이렉트하도록 ChangePwdController 클래스를 수정할 수 있다.

그런데 실제 웹 어플리케이션에서는 비밀번호 변경 기능 외에 더 많은 기능이 로그인 여부를 확인해야 한다. 각 기능을 구현한 컨트롤러 코드마다 세션 확인 코드를 삽입하는 것은 많은 중복을 일으킨다.

HandlerInterceptor 인터페이스 구현하기

org.springframework.web.HandlerInterceptor 인터페이스를 사용하면 다음의 세 시점에 공통 기능을 넣을 수 있다.

  • 컨트롤러(핸들러) 실행 전
  • 컨트롤러(핸들러) 실행 후, 아직 뷰를 실행하기 전
  • 뷰를 실행한 이후

preHandle() 메서드는 컨트롤러 객체를 실행하기 전에 필요한 기능을 구현할 때 사용한다. handler 파라미터는 웹 요청을 처리할 컨트롤러(핸들러) 객체이다.

  • 로그인하지 않은 경우 컨트롤러를 실행하지 않음
  • 컨트롤러를 실행하기 전에 컨트롤러에서 필요로 하는 정보를 생성

preHandle() 메서드의 리턴 타입은 boolean이다. preHandle() 메서드가 false를 리턴하면 컨트롤러(또는 다음 HandlerInterceptor)를 실행하지 않는다.

postHandle() 메서드는 컨트롤러(핸들러)가 정상적으로 실행된 이후에 추가 기능을 구현할 때 사용한다. 컨트롤러가 익셉션을 발생하면 postHandle() 메서드는 실행하지 않는다.

HandlerInterceptor 인터페이스의 각 메서드는 아무 기능도 구현하지 않은 자바 8의 디폴트 메서드이다. 따라서 HandlerInterceptor 인터페이스의 메서드를 모두 구현할 필요가 없다. 이 인터페이스를 상속받고 필요한 메서드만 재정의하면 된다.

비밀번호 변경 기능에 접근할 때 HandlerInterceptor를 사용하면 로그인 여부에 따라 로그인 폼으로 보내거나 컨트롤러를 실행하도록 구현할 수 있다. 여기서 HandlerInterceptor 구현클래스는 prehandle() 메서드를 사용한다. HttpSession에 "authInfo" 속성이 존재하지 않으면 지정한 경로로 리다이렉트하도록 구현하면 된다.

AuthCheckInterceptor

public class AuthCheckInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, 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;
    }
}

HttpSession에 "authInfo" 속성이 존재하지 않으면 지정한 경로로 리다이렉트하도록 구현한다. true를 리턴하면 컨트롤러를 실행하므로 로그인 상태면 컨트롤러를 실행한다. 반대로 false를 리턴하면 로그인 상태가 아니므로 지정한 경로로 리다이렉트 한다.

MvcConfig

@Configuration
@EnableWebMvc // OptionalValidatorFactoryBean을 글로벌 범위 Validator로 등록
public class MvcConfig implements WebMvcConfigurer {

    ...

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authCheckInterceptor())
                .addPathPatterns("/edit/**");
    }

    @Bean
    public AuthCheckInterceptor authCheckInterceptor() {
        return new AuthCheckInterceptor();
    }
}

addInterceptor() 메서드는 InterceptorRegistration 객체를 리턴하는데 이 객체의 addPathPatterns() 메서드는 인터셉트를 적용할 경로 패턴을 지정한다.

Ant 경로 패턴
1. * : 0개 또는 그 이상의 글자
2. ? : 1개 글자
3. ** : 0개 또는 그 이상의 폴더 경로

  • @RequestMapping("/member/?*.info"):
    /member/로 시작하고 확장자가 .info로 끝나는 모든 경로
  • @ReqeustMapping("/faq/f?OO.fq"):
    /faq/f로 시작하고, 1 글자가 사이에 위치하고 OO.fq로 끝나는 모든 경로
  • @RequestMapping("/folders/**/files"):
    /folders/로 시작하고, 중간에 0개 이상의 중간 경로가 존재하고, /files로 끝나는 모든 경로

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

사용자 편의를 위해 아이디를 기억해 두었다가 다음에 로그인할 때 아이디를 자동으로 넣어주는 사이트가 많다. 이 기능을 구현할 때 쿠키를 사용한다.

  • 로그인 폼에 '이메일 기억하기' 옵션을 추가한다.
  • 로그인 시에 '이메일 기억하기' 옵션을 선택했으면 로그인 성공 후 쿠키에 이메일을 저장한다. 이 때 쿠키는 웹 브라우저를 닫더라도 삭제되지 않도록 유효시간을 길게 설정한다.
  • 이후 로그인 폼을 보여줄 때 이메일을 저장한 쿠키가 존재하면 입력 폼에 이메일을 보여준다.

스프링 MVC에서 쿠키를 사용하는 방법 중 하나는 @CookieValue 애노테이션을 사용하는 것이다. @CookieValue 애노테이션은 요청 매핑 애노테이션 적용 메서드의 Cookie 타입 파라미터에 적용한다. 이를 통해 쉽게 쿠키를 Cookie 파라미터로 전달받을 수 있다.

LoginController

@Controller
@RequestMapping("/login")
public class LoginController {
    ...
    @GetMapping
    public String form(LoginCommand loginCommand, @CookieValue(value = "REMEMBER", required = false) Cookie rCookie) {
        if (rCookie != null) {
            loginCommand.setEmail(rCookie.getValue());
            loginCommand.setRememberEmail(true);
        }
        return "login/loginForm";
    }
    ...
}

@CookieValue 애노테이션의 value 속성은 쿠키의 이름을 지정한다. 이름이 REMEMBER인 쿠키를 Cookie 타입으로 전달받는다. 지정한 이름을 가진 쿠키가 존재하지 않을 수도 있다면 required 속성값을 false로 지정한다.

이 예제에서는 이메일 기억하기를 선택하지 않을 수도 있기 때문에 required 속성값을 false로 지정했다. required 속성의 기본값은 true이다.

REMEMBER 쿠키가 존재하면 쿠키의 값을 읽어와 커맨드 객체의 email 프로퍼티 값을 설정한다. 커맨드 객체를 사용해서 폼을 출력하므로, REMEMBER 쿠키가 존재하면 입력 폼의 email 프로퍼티에 쿠키값이 채워져서 출력된다.

LoginController

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

    ...

    @PostMapping
    public String submit(LoginCommand loginCommand, Errors errors, HttpSession session, HttpServletResponse response) {
        new LoginCommandValidator().validate(loginCommand, errors);
        if (errors.hasErrors()) {
            return "login/loginForm";
        }
        try {
            AuthInfo authInfo = authService
                    .authenticate(loginCommand.getEmail(), loginCommand.getPassword());

            session.setAttribute("authInfo", authInfo);

            Cookie rememberCookie = new Cookie("REMEMBER", loginCommand.getEmail());
            rememberCookie.setPath("/");
            if (loginCommand.isRememberEmail()) {
                rememberCookie.setMaxAge(60 * 60 * 24 * 30);
            } else {
                rememberCookie.setMaxAge(0);
                
            }
            response.addCookie(rememberCookie);
            return "login/loginSuccess";
        } catch (WrongIdPasswordException e) {
            errors.reject("idPasswordNotMatching");
            return "login/loginForm";
        }
    }
}

쿠키를 생성하려면 HttpServletResponse 객체가 필요하므로 submit() 메서드의 파라미터로 HttpServletResponse 타입을 추가한다.

0개의 댓글