-> 블로그 이전

[Spring] Session

2022. 7. 8. 16:46Language`/Spring

 

[Spring] Cookie

HTTP는 Stateless한 프로토콜이므로 이전에 보낸 Request에 대해서 기억하지 못한다 이러한 Stateless한 특징을 해결하기 위해서 "쿠키"라는 개념을 활용한다 예를 들어서 로그인을 한다고 하자 어떤 사

cs-ssupport.tistory.com

"쿠키"는 HTTP의 Stateless로 인해 탄생한 "유지를 위한 수단"이다

쿠키를 통해서 중요한 정보를 보관할 수 있고 쿠키를 통해서 Client & Server간에 인증을 간편하게 할 수 있었다

하지만 쿠키에는 여러가지 보안 이슈가 존재한다

 

가장 대표적인 보안 이슈는 쿠키에 Client의 중요 정보가 담길 수 있다는 것이다.

따라서 이러한 보안 이슈를 해결하려면 "중요한 정보들은 모두 서버에 저장"해야 하고 Client & Server간에 인증 수단절대로 예측할 수 없는 random한 문자로 설계해야 한다

>> 서버에 중요한 정보를 보관하고 임의의 random한 문자로 연결을 유지하는 방법을 "Session"이라고 한다


Session

1. Client의 서버 접근

Inflearn 김영한: 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술

일단 Client가 Server로 {loginId, password}를 보냄으로써 로그인 요청을 한다

@Service
@RequiredArgsConstructor
public class LoginService {
    private final MemberRepository repository;

    public Member login(String loginId, String password){
        return repository.findByLoginId(loginId)
                .filter(member -> member.getPassword().equals(password))
                .orElse(null);
    }
}

 

2. Server측에서 Request 확인 후, Session Cookie 발급

서버측에서는 먼저 Login에 대한 request 정보가 올바른지 확인한다

그리고 나서 Client의 로그인 request가 올바르고 그에 따른 정보도 DB에 있다면 Server측에서는 "세션"을 생성한다

@Component
public class SessionFactory {

    public static final String MY_CUSTOM_SESSION_COOKIE = "myCustomSessionCookie";
    private final Map<String, Object> sessionStore = new ConcurrentHashMap<>();

    public void createSession(Object value, HttpServletResponse response){
        String sessionId = UUID.randomUUID().toString().replaceAll("-", "");
        // Client & Server간의 인증 수단
        sessionStore.put(sessionId, value);

        Cookie cookie = new Cookie(MY_CUSTOM_SESSION_COOKIE, sessionId);
        response.addCookie(cookie);
    }
}
서버측에서는 세션 저장소를 하나 만들어서 Client별로 세션을 관리한다
먼저 Object value는 Client에 대한 정보이고, Client & Server간의 인증 수단은 절대 예측이 안되고 random한 UUID를 활용하였다
따라서 세션 저장소에는 {UUID / Client Info}를 저장하고 Server는 이를 토대로 쿠키를 생성한다
>> 여기서 쿠키의 value에 UUID를 넣음으로써 Client & Server는 이제부터 이 UUID를 통해서 상호 인증을 한다

 

3. Client의 Request (Session Cookie 발급 후)

Client는 Reqeust할 때마다 Cookie를 request message에 포함시켜서 보내야 하기 때문에 먼저 쿠키 저장소에 해당되는 쿠키가 있나 확인한다

  • 찾은 쿠키는 {"myCustomSessionCookie", <UUID>}로 구성되어 있을 것이다

조회한 쿠키를 Request Message에 포함시켜서 Server로 request message를 보낸다

 

4. Server의 request 수신 & response

Server는 Client와 세션 쿠키를 활용해서 인증을 하고있고, Client는 Server로 쿠키를 보내었는데 이 쿠키 내부에는 random한 UUID가 value로 포함되어 있다

Server는 이 UUID를 통해서 진짜 회원 정보를 세션 저장소에서 찾아낸다

public Object getSessionValue(HttpServletRequest request){
    Cookie findCustomCookie = findCustomCookie(request, MY_CUSTOM_SESSION_COOKIE);

    if(findCustomCookie == null){
        log.info("myCustomSessionCookie라는 name을 가진 쿠키가 없습니다...");
        return null;
    }

    return sessionStore.get(findCustomCookie.getValue());
}

private Cookie findCustomCookie(HttpServletRequest request, String cookieName){
    if(request.getCookies() == null){
        log.info("request에 쿠키가 없습니다...");
        return null;
    }

    return Arrays.stream(request.getCookies())
            .filter(cookie -> cookie.getName().equals(cookieName))
            .findAny()
            .orElse(null);
}

일단 먼저 findCustomCookie를 통해서 이전에 Server에서 보냈던 쿠키가 request에 포함되어서 도착한지 먼저 확인을 한다

 

그리고 나서 해당 쿠키의 value(UUID)를 통해서 세션 저장소에 저장되어 있는 진짜 Client의 정보를 조회한다

@PostMapping("/login")
public String loginCheck(
        @Validated @ModelAttribute(value = "loginDTO") LoginDTO loginDTO,
        BindingResult bindingResult,
        HttpServletResponse response
){
    if(bindingResult.hasErrors()){
        System.out.println(loginDTO);
        return "login/loginForm";
    }

    Member loginMember = loginService.login(loginDTO.getLoginId(), loginDTO.getPassword());
    log.info("login? = {}", loginMember);
    if(loginMember == null){
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 올바르지 않습니다. 다시 확인해주세요");
        return "login/loginForm";
    }

    // 로그인 성공 : sessionFactory를 통해서 세션을 생성하고 서버에서는 세션 저장소에 sessionID(UUID)와 함꼐 회원의 정보를 보관
    // 그리고 Client에게는 response로 {"myCustonSessionCookie", <UUID>}를 쿠키에 담아서 응답
    sessionFactory.createSession(loginMember, response);

    return "redirect:/";
}

Client의 login Request가 들어옴에 따라 Server측에서는 POST 요청이 "/login"으로 들어온것이다

여기서 로그인이 정상 요청인지 확인한 후, 로그인 성공쪽 코드를 살펴보자

 

로그인에 성공했다면 SessionFactory로부터 세션을 생성하고, 이 세션을 쿠키에 담아서 Client에게 response해준다

최종적으로 로그인이 성공한다면 "redirect:/"에 의해서 PRG : Client는 "/" 경로로 다시 GET Request를 보내게 된다

@GetMapping(value = {"/", ""})
public String home(
        HttpServletRequest request,
        Model model
){
    Member member = (Member) sessionFactory.getSessionValue(request);

    if(member == null){
        return "home";
    }

    model.addAttribute("member", member);
    return "loginHome";
}

"/"경로로 GET Request가 들어오면 SessionFactory를 사용해서 Request Message에 올바른 세션 value가 존재하는지 확인한다

 

만약 request에 서버가 보낸 세션이 없다면 해당 사용자는 로그인을 하지 않은 사용자이거나 이전에 로그인에 실패해서 세션을 발급받지 못한 사용자이다

 


HttpSession

위에서는 우리가 직접 Session을 설계해보았다

하지만 이미 Servlet은 HttpSession라는 기능을 공식적으로 제공해주기 때문에 우리가 직접 구현하지 않아도 되고, HttpSession에는 일정시간이 지나면 자동으로 삭제해주는 기능까지 제공해준다

public class SessionConstField {
    public static final String MY_SESSION_NAME = "mySessionName";
}

일단 Client에게 세션을 전달해줄 때 사용할 "세션의 이름"을 클래스 상수로 설계하였다

 

기본적으로 HttpSession은 HttpServletRequest의 "request.getSession()"을 통해서 얻을 수 있다

getSession()

getSession()에는 2가지 종류가 존재한다

 

1. getSession(false)

getSession(false)는 request에 세션이 존재하지 않는다고해도 새로운 세션을 생성하지 않는 것이다

  • 세션이 존재하지 않으면 null을 반환

2. getSession(true)

getSession(true)는 request에 세션이 존재하지 않으면 신규 세션을 생성하는 것이다

>> getSession()은 getSession(true)와 동일한 기능이다

 

"/login" : POST Request

@PostMapping("/login")
public String loginCheck(
        @Validated @ModelAttribute(value = "loginDTO") LoginDTO loginDTO,
        BindingResult bindingResult,
        HttpServletRequest request
){
    if(bindingResult.hasErrors()){
        System.out.println(loginDTO);
        return "login/loginForm";
    }

    Member loginMember = loginService.login(loginDTO.getLoginId(), loginDTO.getPassword());
    log.info("login? = {}", loginMember);
    if(loginMember == null){
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 올바르지 않습니다. 다시 확인해주세요");
        return "login/loginForm";
    }

    HttpSession session = request.getSession();
    session.setAttribute(SessionConstField.MY_SESSION_NAME, loginMember);

    return "redirect:/";
}

HttpServletRequst로부터 "request.getSession()"을 통해서 세션을 얻는다

그리고나서 setAttribute를 통해서 Server는 loginMember의 정보를 "임시 저장소"에 보관해준다

  • setAttribute를 통해서 하나의 세션에 여러 데이터를 저장할 수 있다

 

"/login"경로로부터 온 POST 요청에 대해서 처리를 하고 난 후 "/"로 redirect를 시켰는데 redirect로 인해 Client는 "/"경로로 GET Request를 다시 보낸다

 

"/" : GET Request

이제 "/"경로로 오는 GET Request에 대해서도 Session을 적용해보자

@GetMapping(value = {"/", ""})
public String home(
        HttpServletRequest request,
        Model model
){
    HttpSession session = request.getSession(false);
    if(session == null){
        return "home";
    }

    Member loginMember = (Member) session.getAttribute(SessionConstField.MY_SESSION_NAME);
    if(loginMember == null){
        return "home";
    }

    model.addAttribute("member", loginMember);
    return "loginHome";
}

마찬가지로 request로부터 getSession(false)를 통해서 세션이 존재하는지 확인한다

  • 여기서 false를 설정한 이유는 User의 Request에 session이 존재하지 않는다는 의미자체가 해당 user는 로그인을 하지 않은 유저이기 때문에 이를 구별하기 위해서 false로 설정하였다

 

session이 null이라면 로그인을 하지 않은 user이므로 메인 홈으로 return시켜준다

 

그리고 session이 존재한다면 session으로부터 Server가 보낸 "커스텀 세션"이 존재하는지 확인한다

이것이 없다면 마찬가지로 세션에 회원 데이터가 없다는 의미이므로 메인 홈으로 return시켜준다

 

이 두 과정을 모두 통과한 user라면 로그인에 성공하고 세션에 Server가 담아둔 회원 데이터도 존재하기 때문에 회원 전용 홈으로 return시켜준다

 

"/logout" : POST Request

이제 User가 모든 일을 마치고 페이지에서 로그아웃을 시도하려고 한다

이러면 서버에서는 어떠한 작업을 수행할까

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

"/logout"으로 POST Request가 들어오면 먼저 HttpServletRequest로부터 getSession(false)를 통해서 세션을 얻는다

getSession(false)는 기존에 세션이 존재하지 않는다면 null을 반환해준다

 

이러한 특징을 이용해서 만약 getSession(false)를 통해서 session을 얻었는데 이것이 null이 반환이 되지 않았다는 의미는 해당 User가 세션을 가지고 있다는 의미이고 지금 로그아웃을 원하고 있기 때문에 "invalidate()"를 통해서 세션을 무효화시켜준다

  • 이전에는 쿠키의 만료시간을 지정하고, 세션을 expire하고,,... 이러한 일들을 직접 구현해야 했었는데 HttpSession을 사용하면 invalidate()를 통해서 간편하게 세션을 무효화시킬 수 있다

로그인이 성공한 후 쿠키 저장소에 Session이 등록된 것을 확인할 수 있다

쿠키를 활용할 떄는 쿠키의 value에 Server가 보내었던 User의 DB PK가 그대로 노출된 것을 확인할 수 있다
하지만 세션을 활용하면 세션 내부의 임시 저장소에 회원에 대한 정보를 담아두고, 세션 자체의 value는 random한 값이므로 외부 사용자가 절대로 예측할 수 없다

 


@SessionAttribute

Spring에서는 세션을 더욱더 편리하게 사용할 수 있도록 @SessionAttribute라는 애노테이션을 제공해준다

@GetMapping(value = {"/", ""})
public String home(
        HttpServletRequest request,
        Model model
){
    HttpSession session = request.getSession(false);
    if(session == null){
        return "home";
    }

    Member loginMember = (Member) session.getAttribute(SessionConstField.MY_SESSION_NAME);
    if(loginMember == null){
        return "home";
    }

    model.addAttribute("member", loginMember);
    return "loginHome";
}

이전에 구현했던 것은 HttpServletRequest로부터 getSession(false)을 통해서 session을 얻고 session의 getAttribute를 통해서 Server가 보냈던 세션 value를 확인하는 작업을 수행하였다

>> 이것을 @SessionAttribute를 통해서 변경해보자

@GetMapping(value = {"/", ""})
public String home(
        @SessionAttribute(name = SessionConstField.MY_SESSION_NAME, required = false) Member loginMember,
        Model model
){
//        HttpSession session = request.getSession(false);
//        if(session == null){
//            return "home";
//        }
//
//        Member loginMember = (Member) session.getAttribute(SessionConstField.MY_SESSION_NAME);
    if(loginMember == null){
        return "home";
    }

    model.addAttribute("member", loginMember);
    return "loginHome";
}

Server에서는 이전에 {"MY_SESSION_NAME", <로그인 회원 정보>}를 세션 임시 저장소에 담아놓았었다

이를 @SessionAttribute로 꺼낸 것을 확인할 수 있다

name : Server에서 세션 임시 저장소에 담아두었던 값의 KEY
required : 세션의 필요 여부

required = false는 Client의 request에 세션이 포함되지 않아도 된다는 의미이다

왜냐하면 로그인을 하지 않아서 세션이 없는 User도 일단 메인 홈 페이지에는 접근을 할 수 있게 해야 하기 때문이다

>> @SessionAttribute를 통해서 {세션을 찾고, 세션에 들어있는 데이터를 찾고, ..} 이러한 번거로운 작업들을 한번에 해결할 수 있다


HttpSession의 여러 기능들

getId()

생성된 Session의 고유한 ID

 

isNew()

새로 생성된 Session인지 여부

  • getSession() 또는 getSession(true)를 통해서 session을 받으면 1) 이미 세션이 존재한다면 해당 세션을 반환 & 2) 세션이 없다면 신규 세션 생성

 

getCreationTime() & getLastAccessedTime() & getMaxInactiveInternal()

이 3가지는 서로 연관이 있는 Value이다

>> "마지막 세션 접속 시간 - 세션 생성 시간 > 세션 유효시간"이라면 해당 세션은 invalidate() : 유효하지 않는 세션이 된다

 

세션 타임아웃 설정

일반적으로 사용자가 로그아웃 버튼을 직접 클릭해서 session.invalidate()가 호출되면 세션은 무효화가 된다

하지만 대부분의 사용자는 로그아웃 버튼을 누르지 않고 그냥 웹 브라우저 창을 닫는다

>> 따라서 서버에서는 세션 데이터를 언제 삭제해야 할지 판단하기 어렵다

HttpSession에서는 "기본 세션 타임아웃"을 1800초 = 30분으로 default설정 해놓았다

 

타임아웃 변경 1)

session.setMaxInactiveInterval(<타임아웃 시간>);

여기서 타임아웃 시간은 [초]단위로 작성해야 한다

 

타임아웃 변경 2)

// application.properties 파일
server.servlet.session.timeout=<타임아웃 시간>

 

application.properties에서 설정하는 것은 "글로벌 설정"이고 setMax~~를 통해서 설정하는 것은 특정 세션에게만 해당되는 설정이다