Spring Boot 로그인 세션 유지 - Spring Boot logeu-in sesyeon yuji

bindingResult에 TypeMissMatch가 발생했거나 요청으로 넘어온 login id, password로 회원이 조회되지 않으면 "login/loginForm"을 리턴하여 로그인 폼 입력 화면으로 가게 한다.

회원이 조회되지 않는 경우에는 BindingResult#reject로 에러를 담아 아래와 같이 화면에서 적절한 메시지를 노출할 수 있도록 하였다.

Spring Boot 로그인 세션 유지 - Spring Boot logeu-in sesyeon yuji

 

로그인이 성공하면 특정 url로 redirect 하도록 한다.

@RequestParamdefaultValue에 의해 현재는 "/"로 redirect 될것이다.

 

loginForm.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2>로그인</h2>
    </div>

    <form action="item.html" th:action th:object="${loginForm}" method="post">

        <div th:if="${#fields.hasGlobalErrors()}">
            <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
        </div>

        <div>
            <label for="loginId">로그인 ID</label>
            <input type="text" id="loginId" th:field="*{loginId}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{loginId}" />
        </div>
        <div>
            <label for="password">비밀번호</label>
            <input type="password" id="password" th:field="*{password}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{password}" />
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit">로그인</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/}'|"
                        type="button">취소</button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

 

실행

Spring Boot 로그인 세션 유지 - Spring Boot logeu-in sesyeon yuji

실행 후 localhost:8080/login에 접속하면 위와 같은 화면을 볼 수 있다.

앞서 @PostConstruct를 통해 테스트 회원을 만들어두었으므로 test/test!로 로그인을 할 수 있다.

 

Spring Boot 로그인 세션 유지 - Spring Boot logeu-in sesyeon yuji

현재는 로그인을 해도 별다른 기능이 없고 "/"로 redirect 되는데 해당 요청을 처리하는 핸들러가 없으므로 기본 에러페이지가 노출된다.

그리고 로그인은 정상 동작 하나, 쿠키나 세션을 사용하지 않으므로 로그인 상태가 유지되지 않는다.

 

3. HttpSession 적용

HTTP는 기본적으로 stateless한 프로토콜이므로 클라이언트, 서버간의 연결을 유지하기 위해 쿠키 또는 세션을 사용할 수 있다.

그런데 쿠키만으로 로그인을 구현하면 첫째, 쿠키 값은 클라이언트에서 임의로 변경하여 서버에 전송할 수 있고 둘째, 쿠키에 보관된 정보는 중간에 탈취될 수 있으므로 보안에 문제가 발생한다. 따라서 쿠키에는 중요한 정보를 저장하면 안되고 쿠키 값은 추정 불가능한 임의의 값이어야 한다. HttpSession을 사용하여 이 문제를 해결할 수 있다.

 

이제 HttpSession을 통해 로그인을 구현해보자.

 

SessionConstants

package com.atozdevelop.loginexample.web;

public interface SessionConstants {

    String LOGIN_MEMBER = "loginMember";
}

먼저 HttpSession에서 로그인용으로 사용할 세션 id는 여기저기서 사용될 것이기 때문에 상수로 뺀다.

이 때 상수 클래스를 interface 또는 abstract class로 만들면 객체 생성을 막을 수 있다.

 

LoginController

@PostMapping("/login")
public String login(@ModelAttribute @Validated LoginForm loginForm,
                    BindingResult bindingResult,
                    @RequestParam(defaultValue = "/") String redirectURL,
                    HttpServletRequest request) {

    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

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

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

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

    return "redirect:" + redirectURL;
}

@PostMapping("/logout")
public String logout(HttpServletRequest request) {

    HttpSession session = request.getSession(false);
    if (session != null) {
        session.invalidate();   // 세션 날림
    }

    return "redirect:/";
}

아까 작성한 LoginController#loginHttpServletRequest 인자를 추가한다.

그리고 // 로그인 성공 처리 주석 다음에 두 라인을 추가한다.

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

요청에서 넘어온 아이디와 비밀번호로 회원이 정상 조회된다면 HttpServletRequest에서 세션을 가져와 setAttribute()를 통해 위에서 만든 상수값 SessionConstants.LOGIN_MEMBER를 session attribute의 name으로, 로그인 회원 인스턴스를 값으로 보관한다.

 

HttpServletRequest#getSession에는 boolean 타입의 인자를 넘길 수 있는데, true를 넘길 경우 세션이 없으면 신규 세션을 생성하여 반환한다. false를 넘기면 세션이 없으면 그냥 null을 반환한다. 기본값은 true이다.

 

다음으로 logout 핸들러를 추가한다.

HttpSession을 이용한 로그인의 로그아웃은 핸들러에서 HttpServletRequest를 인자로 받아 HttpSession에 접근하여 invalidate()를 호출하여 구현하면 된다.

 

HomeController

package com.atozdevelop.loginexample.web;

import com.atozdevelop.loginexample.domain.member.Member;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.SessionAttribute;

@Controller
public class HomeController {

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

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

        return "loginHome";
    }
}

홈페이지 컨트롤러이다.

홈페이지 접속 시 미로그인이면 홈페이지를 보여주고 로그인이면 로그인한 사용자의 이름을 보여줄 것이다.

이를 위해 컨트롤러에서 로그인 여부를 판단해야 하는데 스프링은 세션을 더 편리하게 사용할 수 있도록 @SessionAttribute를 지원한다.

위와 같이 하면 HttpSession이 존재하면 session attribute에서 name이 SessionConstants.LOGIN_MEMBER인 값을 가져와 loginMember에 바인딩받을 수 있다.

 

그래서 세션에 회원이 없으면 home을, 세션에 회원이 있으면 회원 인스턴스를 model에 담아 loginHome 뷰를 리턴한다.

 

위 코드는 아래와 같다. 다음은 @SessionAttribute를 사용하지 않고 동일한 로직을 수행하는 코드이다.

package com.atozdevelop.loginexample.domain.member;

import lombok.Data;

@Data
public class Member {

    private Long id;

    private String loginId;

    private String name;

    private String password;
}
0

 

home.html

package com.atozdevelop.loginexample.domain.member;

import lombok.Data;

@Data
public class Member {

    private Long id;

    private String loginId;

    private String name;

    private String password;
}
1

 

loginHome.html

package com.atozdevelop.loginexample.domain.member;

import lombok.Data;

@Data
public class Member {

    private Long id;

    private String loginId;

    private String name;

    private String password;
}
2

loginHome 뷰에서는 컨트롤러에서 model에 담은 Member 인스턴스에서 name을 가져와서 로그인한 사용자의 이름을 출력하도록 하였다.

 

여기까지 실행 결과를 보자.

 

실행

Spring Boot 로그인 세션 유지 - Spring Boot logeu-in sesyeon yuji

실행하여 루트로 접속한 화면이다.

 

Spring Boot 로그인 세션 유지 - Spring Boot logeu-in sesyeon yuji

테스트 회원으로 로그인

 

Spring Boot 로그인 세션 유지 - Spring Boot logeu-in sesyeon yuji

url 뒤에 jsessionid 파라미터가 붙고 응답 헤더에 Set-Cookie로 JSSESSIONID라는 이름의 쿠키와 값이 담긴 것을 볼 수 있다.

이 JSESSIONID 쿠키가 클라이언트 - 서버 연결 유지의 핵심이다.

 

url 뒤의 jsessionid는 서버 입장에서 클라이언트의 쿠키를 지원 여부를 판단하지 못하므로 쿠키를 지원하지 않을 경우 대신 URL을 통해 세션을 유지할 수 있도록 붙여주는 것이다. 이 옵션을 끄고 항상 쿠키를 통해서만 세션을 유지하려면 다음 설정을 추가하면 된다.

 

application.yml

package com.atozdevelop.loginexample.domain.member;

import lombok.Data;

@Data
public class Member {

    private Long id;

    private String loginId;

    private String name;

    private String password;
}
3

 

HttpSession은 세션 생성 시 임의의 토큰 값을 생성하여 해당 토큰 값을 JSESSIONID 쿠키로 클라이언트에 전달한다. 

 

Spring Boot 로그인 세션 유지 - Spring Boot logeu-in sesyeon yuji

"/"로 다시 접속해보면 이번에는 요청 Cookie 헤더에 이전에 서버에서 응답으로 받은 JSESSIONID를 담아서 보내는 것을 볼 수 있다.

서버에서는 클라이언트가 전달한 이 JSESSIONID로  서버쪽 세션 저장소를 조회하여 보관된 세션 정보를 사용한다.

즉, HttpSession은 JSESSIONID이라는 이름의 쿠키로 클라이언트와 서버의 연결을 유지하며 쿠키 값은 임의의 토큰 값이고, 실제로 중요한 데이터는 서버 쪽 HttpSession의 세션 저장소에 보관한다.

 

개발자 도구의 Application 탭 > Storage > Cookies에서 쿠키에 대한 자세한 정보를 볼 수 있는데, JSESSIONID는 Expires / Max-Age가 Session으로, 이러한 쿠키를 세션 쿠키라고 한다.

Spring Boot 로그인 세션 유지 - Spring Boot logeu-in sesyeon yuji

세션 쿠키는 브라우저 종료 전 까지만 유지되는 쿠키로, 브라우저를 종료하면 쿠키가 삭제된다.

 

Spring Boot 로그인 세션 유지 - Spring Boot logeu-in sesyeon yuji

로그아웃하면 다시 기본 홈화면이 보여질 것이다.

로그아웃을 해도 요청 헤더에 JSESSIONID 쿠키를 계속 보내지만 서버 측에서는 이미 로그아웃 핸들러에서 HttpSession을 invalidate() 하였으므로 정상적으로 로그아웃이 된 것이다.

JSESSIONID는 브라우저 종료 시 삭제되니 쿠키가 남아있는 것에 신경쓰지 않아도 된다.

 

4. HttpSession 타임아웃 설정

세션은 메모리를 소모하므로 타임아웃을 설정해야 한다.

HttpSession은 LastAccessedTime이라는 상태값을 기준으로 타임아웃이 동작한다.

LastAccessedTime는 클라이언트에서 서버로 session id를 전송해 세션에 접근할 때 마다 새로 초기화된다.

 

스프링부트에서는 다음과 같이 HttpSession의 타임아웃을 설정할 수 있다.

이 설정은 글로벌하게 현재 웹 어플리케이션의 모든 세션에 적용된다.

 

application.yml

package com.atozdevelop.loginexample.domain.member;

import lombok.Data;

@Data
public class Member {

    private Long id;

    private String loginId;

    private String name;

    private String password;
}
4

기본값이 30m(30분)이고 60과 같이 m을 붙이지 않으면 초 단위로 설정된다.

초 단위로 설정할 경우 최소 60(1분) 이상이어야 한다.

 

HttpSession 인스턴스를 통해서도 타임아웃을 설정할 수 있다.

package com.atozdevelop.loginexample.domain.member;

import lombok.Data;

@Data
public class Member {

    private Long id;

    private String loginId;

    private String name;

    private String password;
}
5

 

HttpSession의 LastAccessedTime 이후로 설정된 타임아웃이 지나면 WAS가 내부에서 해당 세션을 제거한다.

 

여기까지 HttpSession으로 로그인 기능을 구현해 보았다.

 

실무에서 주의할 점은 세션은 메모리에 저장되므로 최소한의 데이터만 보관해야 하고 적절한 타임아웃을 설정해야 한다. 세션에 보관할 데이터의 용량 * 사용자 수만큼 메모리를 소요하므로 세션으로 인해 메모리 사용량이 급격하게 늘어나서 장애로 이어질 수 있기 때문이다.