본문 바로가기
Spring

[Spring] 쿠키(Cookie) 세션(Session)

by jinjin98 2022. 11. 2.

 쿠키

 

쿠키 없이 로그인하면 이 후 같은 웹사이트에 접근할 떄 로그인이 되어있지 않습니다. 로그인한 사용자인지

아닌지 구분을 하지 못하기 때문입니다.  HTTP 는 무상태 프로토콜이여서 클라이언트와 서버가 서로 요청과 응답을

주고받으면 연결이 끊어지고 클라이언트가 다시 요청을 하면 서버는 기억을 하지 못합니다.

서로 상태를 유지하지 않는 것이죠. 그렇다고 모든 요청에 사용자 정보를 보내면 보안에도 문제가 생기므로 개발이

힘들게 됩니다. 그래서 이 문제를 해결하기 위해 쿠키란 개념이 도입됩니다.

 

 

클라이언트가 웹사이트에서 로그인을 성공하면 서버는 쿠키를 생성해 HTTP 응답에 담고 웹 브라우저에 전달

합니다. 웹 브라우저는 쿠키를 받아 쿠키를 내부에 있는 쿠키 저장소에 저장합니다.

 

 

 

이 후 클라이언트가 해당 웹사이트에 요청을 할 때마다 웹 브라우저는 쿠키 저장소에서 쿠키를 찾아 쿠키 값을

꺼낸 다음 Cookie 라는 HTTP 헤더의 값에 넣어 서버에 전송합니다. 서버는 Cookie 값을 확인해 클라이언트의

로그인 상태를 확인할 수 있습니다. 이렇게 쿠키 덕분에 URL 에 클라이언트 정보를 넣을 필요 없이 로그인한

상태를 인증할 수 있습니다.

 

쿠키의 종류에는 영속 쿠키와 세션 쿠키가 있습니다.

영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지합니다

세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시 까지만 유지합니다.

대부분이 브라우저 종료시 로그아웃이 되길 기대하므로 세션 쿠키를 많이 사용합니다.

 

@Controller
public class LoginController {

    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginForm") LoginForm form) {

        return "login/loginForm";
    }

    @PostMapping("/login")
    public String login(@ModelAttribute LoginForm form, HttpServletResponse response) {
        Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
        response.addCookie(idCookie);

        return "redirect:/";
    }
}

 

예제로 로그인 컨트롤러 만들어 봤습니다. POST 방식으로 /login 요청이 오면 로그인을 진행하는 컨트롤러인데요.

이름은 memberId, 값은 전송된 폼 데이터에서 회원의 아이디로 주어 쿠키 객체를 생성하고 

HttpServletResponse 응답 객체에 addCookie() 메서드로 쿠키를 담습니다. 쿠키 만료 시간을 따로 지정해주지 

않으면 세션 쿠키로 설정되어 브라우저 종료시 종료됩니다. 이렇게 되면 웹 브라우저는 종료 전까지

회원의 아이디를 서버에 계속 보내줍니다.

 

클라이언트에게 쿠키 삭제 요청

 

Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
cookie.setMaxAge(0);
response.addCookie(idCookie);

 

쿠키를 삭제하는 메서드는없지만 setMaxAge() 메서드로 유지시간이 0인 같은 이름의 쿠키를 전송하면 됩니다.

 

 

크롬 웹브라우저에서 로그인을 하기전 F12 버튼을 눌러 개발자 도구를 열고 로그인 버튼을 클릭해 로그인이

성공적으로 처리되면 응답 헤더에 Set-Cookie 가 있는 것을 확인할 수 있습니다.

 

 

 

이 후 같은 웹사이트에 요청을 하면 요청 헤더에 Cookie 가 있는 것을 확인할 수 있습니다.

 

@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {

    private final MemberRepository memberRepository;

    @GetMapping("/")
    public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {

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

        Member loginMember = memberRepository.findById(memberId);
        if (loginMember == null) {
            return "home";
        }
        model.addAttribute("member", loginMember);
        return "loginHome";
    }
}

 

로그인 여부에 따라 홈 화면을 다르게 보여주는 컨트롤러인데요. 요청이 들어오면 @CookieValue 어노테이션을

사용하여 편리하게 쿠키를 조회할 수 있습니다. 로그인 하지 않은 사용자도 홈에 접근할 수 있기 때문에 

required 를 false 로 설정해 쿠키값을 필수로 전송하지 않도록 합니다. 이렇게 하면 로그인 쿠키가 없는 사용자 또는

로그인 쿠키는 있지만 쿠키값에 해당하는 회원의 아이디가 데이터베이스에 없으면 home 으로 보내고 로그인 쿠키가

있고 쿠키값에 해당하는 회원의 아이디가 데이터베이스에 있으면 loginhome 으로 보냅니다.

 

쿠키와 보안 문제

지금까지 쿠키를 사용해서 로그인 아이디를 전달해 로그인을 유지할 수 있는걸 확인했습니다.

그런데 여기에는 심각한 보안 문제가 있는데요. 

쿠키 값은 임의로 변경할 수 있으며 클라이언트가 쿠키를 강제로 변경하게 된다면 다른 사용자가 됩니다.

실제 웹브라우저 개발자모드에서 Application -> Cookie 변경으로 확인할 수 있습니다.

 

 

 

이렇게 되면 쿠키에 보관된 정보를 훔쳐갈 수가 있습니다. 만약 쿠키에 개인정보나, 신용카드 정보가 있다면

심각한 보안 문제를 초래할 수 있습니다. 이 정보가 웹 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서

서버로 전달되기 때문에 쿠키의 정보로 나의 로컬 PC가 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있습니다.

또한 쿠키 값은 변경되지 않기 때문에 해커가 쿠키를 한번 훔쳐가면 평생 사용하여 그 쿠키로 악의적인 요청을

계속 시도할 수 있습니다.

 

대안

쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과

사용자 id를 매핑해서 인식합니다. 그리고 서버에서 토큰을 관리합니다. 토큰은 해커가 임의의 값을 넣어도 찾을 수

없도록 예상 불가능 해야 합니다. 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의

만료시간을 짧게 (예: 30분) 유지하고 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 됩니다.

 

 세션

 

쿠키에 중요한 정보를 보관하는 방법은 여러가지 보안 이슈가 있는걸 확인할수 있었는데요. 이 문제를 해결하려면

결국 중요한 정보를 모두 서버에 저장해야 합니다. 그리고 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로

연결해야 합니다. 이렇게 서버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라 합니다.

 

세션 동작 방식

 

 

클라이언트가 로그인을 하면서 loginId 와 password 정보를 전달하면

서버가 데이터베이스에 정보가  맞는지 확인합니다.

 

 

정보가 일치하면  추정 불가능한 세션 아이디를 생성해서 세션 아아디와 데이터베이스에서 받아온 값을

키와 값으로 서버에 있는 세션 저장소에 저장합니다.

 

 

쿠키를 생성하고 mySessionId 라는 이름으로 세션ID 만 넣습니다.

그리고 쿠키를 HTTP 응답에 담아 웹 브라우저에 전송합니다. 

클라이언트는 쿠키 저장소에 mySessionId 쿠키를 보관합니다.

여기서 핵심은 회원과 관련된 정보는 전혀 클라이언트에 전달하지 않는다는 것입니다.

오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달합니다.

 

 

클라이언트는 요청시 항상 mySessionId 쿠키를 전달합니다.

서버에서는 클라이언트가 전달한 쿠키 정보인 mySessionId 의 값인 세션ID 로

세션 저장소에서 세션을  조회해 로그인시 보관한 세션 정보를 사용합니다.

이렇게 세션을 사용하면 예상 불가능하고 복잡한 세션 아이디를 통해 쿠키 값 변조가 힘들고 쿠키에 

보관하는 정보인 세션ID 가 탈취당해도 여기에는 중요한 정보가 없기 때문에 걱정하지 않아도 됩니다.

만약 해커가 토큰을 가져간다 해도 시간이 지나면 사용할 수 없도록 서버에서 세션의 만료시간을 짧게

유지하고 해킹이 의심되면 해당 세션을 강제로 제거하면 됩니다.

 

서블릿은 세션을 위해 HttpSession 이라는 기능을 제공합니다. 서블릿을 통해 HttpSession 을 생성하면

다음과 같은 쿠키를 생성하는데요. 쿠키 이름 JSESSIONID 이고, 추정 불가능한 랜덤 값입니다.

ex) Cookie: JSESSIONID=4B78E23B513N50164D6FDD4C97B0AD02

위에서 설명한 세션 동작 과정에서 mySessionId 가 JSESSIONID 바뀐 것이라고 보면 됩니다.

 

HttpSession 을 사용하는 예제를 만들어보겠습니다.

 

public abstract class SessionConst {
    public static final String LOGIN_MEMBER = "loginMember";
}

 

HttpSession 에 데이터를 보관하고 조회할 때, 같은 이름이 중복 되어 사용되므로, 상수를 하나 정의했습니다.

 

@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;

    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginForm") LoginForm form) {

        return "login/loginForm";
    }

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

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

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

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

        HttpSession session = request.getSession();

        session.setAttribute(SessionConst.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:/";
    }
}

 

login() 메서드에서 전송된 폼 데이터에 문제가 없고 아이디와 비밀번호가 일치하면 HttpServletRequest

요청 객체에서 getSession() 메서드로 HttpSession 객체를 생성합니다. setAttribute() 메서드로 SessionConst

클래스의 상수 값을 키, 데이터베이스에서 가져온 회원 정보를 값으로 세션을 보관합니다.

이 떄 인자값을 받는 파라미터 타입은 Object 이므로 모든 타입의 값이 들어올 수 있습니다.

세션에 데이터를 보관하는 방법은 request.setAttribute(..) 와 비슷합니다.

하나의 세션에는 여러 값을 저장할 수 있습니다.

 

request.getSession() 메서드를 잠시 설명하자면 세션을 생성하거나 조회할 때 사용하는 메서드입니다.

앞에서 설명한 것처럼, 클라이언트가 보낸 쿠키 정보에서 JSESSIONID 의 값인

세션ID 로 세션 저장소에서 세션을 조회하는 것입니다.

파라미터에 값을 넣어주지 않으면 기본값으로 설정되고 기본값은 true 입니다.

request.getSession(true)

세션이 있으면 기존 세션을 반환합니다. 세션이 없으면 새로운 세션을 생성해서 반환합니다.

request.getSession(false)

세션이 있으면 기존 세션을 반환합니다. 세션이 없으면 새로운 세션을 생성하지 않고 null 을 반환합니다.

 

logout() 메서드에서는 로그아웃을 처리하며 getSession(false) 메서드로 세선을 가져온 후

invalidate() 메서드로 세션을 제거해줍니다

request.getSession() 를 사용하면 기본 값이 true 이므로, 로그인 하지 않을 사용자도 의미없는 세션이 만들어집니다.

세션은 메모리를 쓰기 때문에 꼭 필요할 때만 생성해야 합니다.

따라서 세션을 찾아서 사용하는 시점에는 false 옵션을 사용해서 세션을 생성하지 않아야 합니다.

 

 

 

 

세션을 사용해서 쿠키를 확인해보면 쿠키 이름이 JSESSIONID 이고,

값은 추정 불가능한 랜덤 값인 것을 확인 할 수 있습니다.

 

@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {

    @GetMapping("/")
    public String homeLogin(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model) {
        if (loginMember == null) {
            return "home";
        }

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

 

로그인 여부에 따라 다른 홈 화면을 보여주는 HomeController 도 변경했습니다. 스프링은 세션을 더 편리하게

사용할 수 있도록 @SessionAttribute 어노테이션을 지원하는데요. 이미 로그인 된 사용자를 찾을 때는 다음과 같이

사용하면 됩니다.세션을 찾고, 세션에 들어있는 데이터를 찾는 번거로운 과정을 스프링이 한번에 편리하게 처리할 수

있습니다. 참고로 이 기능은 세션을 생성하지 않습니다.

 

로그인을 처음 시도하면 URL이 다음과 같이 jsessionid 를 포함하고 있는 것을 확인할 수 있습니다.

http://localhost:8080/;jsessionid=4B78E23B513N50164D6FDD4C97B0AD02

이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법입니다.

이 방법을 사용하면 쿠키값을 보내지 않으니 URL에 이 값을 계속 포함해서 전달해야 합니다.

타임리프 같은 템플릿 엔진은 엔진을 통해서 링크를 걸면 jsessionid 를 URL에 자동으로 포함해줍니다.

서버 입장에서 웹 브라우저가 쿠키를 지원하는지 하지 않는지 최초에는 판단하지 못하므로,

쿠키 값도 전달하고, URL에 jsessionid 도 함께 전달합니다.

URL 전달 방식을 끄고 항상 쿠키를 통해서만 세션을 유지하고 싶으면 application.propertie 파일에 다음 옵션을

넣어주면 됩니다. 이렇게 하면 URL에 jsessionid 가 노출되지 않습니다.

server.servlet.session.tracking-modes=cookie

 

@Slf4j
@RestController
public class SessionInfoController {

    @GetMapping("/session-info")
    public String sessionInfo(HttpServletRequest request) {
        HttpSession session = request.getSession(false);

        if (session == null) {
            return "세션이 없습니다.";
        }

        session.getAttributeNames().asIterator()
                .forEachRemaining(name -> log.info("session name={}, value={}", name, session.getAttribute(name)));

        log.info("sessionId={}", session.getId());
        log.info("getMaxInactiveInterval={}", session.getMaxInactiveInterval());
        log.info("creationTime={}", new Date(session.getCreationTime()));
        log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
        log.info("isNew={}", session.isNew());
        
        return "세션 출력";
    }
}

 

 

세션이 제공되는 정보를 확인하기 위한 컨트롤러를 만들어보았습니다.

sessionId: 세션Id, JSESSIONID 의 값입니다. 예) 34B14F008AA3527C9F8ED620EFD7A4E1

maxInactiveInterval: 세션의 유효 시간입니다 예) 1800초, (30분)

creationTime: 세션 생성시간을 반환합니다.

lastAccessedTime: 세션과 연결된 사용자가 최근에 서버에 접근한 시간을 반환하며, 클라이언트에서 서버로

sessionId ( JSESSIONID )를 요청한 경우에 갱신됩니다.

isNew: 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId ( JSESSIONID )를

요청해서 조회된 세션인지 여부를 확인합니다.

 

세션 타임아웃 설정

세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate() 가 호출 되는 경우에 삭제됩니다. 그런데 대부분의

사용자는 로그아웃을 선택하지 않고, 그냥 웹 브라우저를 종료합니다. 문제는 HTTP가 비 연결성(ConnectionLess)

이므로 서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지를 인식할 수 없습니다. 따라서 서버에서

세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵습니다.

이 경우 남아있는 세션을 무한정 보관하면 다음과 같은 문제가 발생할 수 있습니다.

세션과 관련된 쿠키( JSESSIONID )를 탈취 당했을 경우, 오랜 시간이 지나도 해당 쿠키로 악의적인 요청을 할 수

있습니다. 세션은 기본적으로 메모리에 생성됩니다. 메모리의 크기가 무한하지 않기 때문에 꼭 필요한 경우만 생성해서

사용해야 합니다 만약 10만명의 사용자가 로그인하면 10만개의 세션이 생성되는 것입니다. 이렇게 되면 메모리를

초과해 서버 장애로 이어질 수도 있습니다.

 

세션의 종료 시점

가장 단순하게 생각해보면, 세션 생성 시점으로부터 30분 정도로 잡으면 됩니다. 그런데 문제는 30분이 지나면 세션이

삭제되기 때문에, 열심히 사이트를 돌아다니다가 또 로그인을 해서 세션을 생성해야 합니다 그러니까 30분 마다 계속

로그인해야 하는 번거로움이 발생합니다. 더 나은 대안은 세션 생성 시점이 아니라 클라이언트가 서버에 최근에

요청한 시간을 기준으로 30분 정도를 유지해주는 것입니다. 이렇게 하면 사용자가 서비스를 사용하고 있으면,

세션의 생존 시간이 30분으로 계속 늘어나게 됩니다. 따라서 30분 마다 로그인해야 하는 번거로움이 사라집니다.

HttpSession 은 이 방식을 사용합니다.세션 타임아웃 설정은 스프링 부트에서 글로벌 설정으로 

application.properties 파일에서 할 수 있습니다. 글로벌 설정은 모든 세션에 적용이 됩니다.

server.servlet.session.timeout=60 -> 60초

기본적으로 시간은 분 단위로 적어줘야 합니다. ex) 60(1분), 120(2분), ...  

기본값은 1800 으로 30분입니다. 그래서 위에서 세션 정보를 출력할 떄 lastAccessedTime 값이 1800 이었던

것입니다. 특정 세션은 세션 타임아웃을 따로 지정하고 싶다면 setMaxInactiveInterval() 메서드를 사용하면 됩니다.

세션의 타임아웃 시간은 해당 세션과 관련된 JSESSIONID 를 전달하는 HTTP 요청이 있으면 현재 시간으로

다시 초기화 됩니다. 이렇게 초기화 되면 세션 타임아웃으로 설정한 시간동안 세션을 추가로 사용할 수 있습니다.

session.getLastAccessedTime() : 최근 세션 접근 시간을 반환합니다.

LastAccessedTime 이후로 timeout 시간이 지나면, WAS가 내부에서 해당 세션을 제거합니다.

 

주의할 점

세션에는 최소한의 데이터만 보관해야 합니다. 보관한 데이터 용량 * 사용자 수로 세션의 메모리 사용량이 급격하게

늘어나서 장애로 이어질 수 있습니다. 추가로 세션의 시간을 너무 길게 가져가면 메모리 사용이 계속 누적 될 수 있으므로

적당한 시간을 선택하는 것이 필요합니다. 기본이 30 분이라는 것을 고려하여 설정하면 되겠습니다.

댓글