← Posts

Session · Cookie · Authentication

쿠키와 세션 - 서버가 기억한다

로그인 결과를 서버가 들고 있고 브라우저는 번호표만 든다. 쿠키·세션 ID·저장소·만료를 장면으로 따라간다.

목차
  1. 새로고침하면 로그아웃된다
  2. HTTP는 방금 누가 왔는지 모른다
  3. 쿠키는 서버가 브라우저에 붙여두는 쪽지다
  4. 쪽지에 이름을 적으면 위조된다
  5. 세션은 서버의 기억이고 쿠키는 번호표다
  6. 쿠키에 붙이는 안전장치
  7. 서버가 기억한다는 말의 무게
  8. 서버가 기억하니 서버가 지울 수 있다
  9. 세션이 남기는 불편
  10. 정리

로그인은 분명히 성공했는데, 새로고침하면 다시 로그인 화면이 뜬다.

새로고침하면 로그아웃된다

로그인 API를 갓 만든 상태를 상상해 보자. 서버 코드는 이렇게 생겼다.

java
@PostMapping("/login")
public String login(@RequestBody LoginRequest req) {
    User user = userService.verify(req.getEmail(), req.getPassword());
    return "환영합니다 " + user.getName();
}

비밀번호를 확인했고, 200을 줬다. 로그인은 됐다.

그런데 바로 다음 요청에서 서버는 이 사람이 누군지 모른다.

http
GET /orders HTTP/1.1
Host: shop.example.com

HTTP/1.1 401 Unauthorized

방금 로그인한 사람이 보낸 요청인데도 401이다. 서버 입장에서는 이 요청과 조금 전 로그인 요청을 이어줄 단서가 하나도 없기 때문이다.

HTTP는 방금 누가 왔는지 모른다

참고

HTTP가 왜 이렇게 만들어졌고 그 대가가 무엇인지는 상태를 안 갖는다는 것이 따로 다룬다. 여기서는 그래서 로그인 상태를 어떻게 이어붙이나만 본다.

이유는 HTTP의 성격에 있다. HTTP는 요청 하나하나를 서로 독립적으로 처리한다. 요청이 끝나면 그 요청에 대한 기억을 남기지 않는다. 다음 요청이 오면 그건 그냥 새 요청이다.

diagramdiagram

이 성질 덕분에 서버를 여러 대로 늘리기도 쉽고 중간에 캐시를 끼우기도 쉽다. 얻는 게 많은 설계다. 대신 “방금 로그인한 사람”이라는 사실은 아무도 안 들고 있다.

그러면 방법은 하나다. 요청마다 “나 아까 그 사람이야”를 직접 들고 오게 만들어야 한다. 매번 비밀번호를 다시 보낼 수는 없으니, 로그인에 성공했을 때 서버가 무언가를 쥐여주고 그걸 다음 요청에 붙여 오게 한다.

그 쥐여주는 수단이 쿠키다.

쿠키는 서버가 브라우저에 붙여두는 쪽지다

쿠키는 서버가 응답에 담아 보내는 작은 문자열이고, 브라우저가 그걸 저장해뒀다가 같은 사이트로 가는 요청마다 자동으로 다시 붙여 보낸다.

서버가 응답 헤더로 준다.

http
HTTP/1.1 200 OK
Set-Cookie: greeting=hello; Path=/

브라우저는 저장해두고, 그 다음 요청부터 알아서 붙인다.

http
GET /orders HTTP/1.1
Cookie: greeting=hello

봐야 할 것은 “자동으로” 다. 개발자가 요청마다 붙이는 코드를 쓰지 않아도 브라우저가 붙인다. 그래서 쿠키는 “브라우저에 붙여둔 쪽지”에 가깝다. 서버가 한 번 붙여두면 그 사이트로 가는 요청에 계속 따라온다.

이제 로그인 결과를 여기에 담으면 될 것 같다. 이렇게.

http
Set-Cookie: user=kim; role=admin

이러면 안 된다.

쪽지에 이름을 적으면 위조된다

쿠키는 브라우저에 저장된다. 브라우저는 사용자의 것이다. 사용자는 개발자 도구를 열어 쿠키 값을 직접 고칠 수 있다.

diagramdiagram

role=userrole=admin으로 바꾸는 데 특별한 기술이 필요하지 않다. 값을 고치고 새로고침하면 끝이다. 서버가 그 값을 믿는 순간 관리자가 된다.

경고

클라이언트가 보낸 값은 클라이언트가 정한 값이다. 쿠키든 헤더든 요청 본문이든 마찬가지다. 사용자 신원이나 권한처럼 위조되면 곧바로 피해가 나는 값을 그대로 실어 보내고 그걸 믿으면 안 된다. 이건 쿠키가 부실해서가 아니라, 저장 위치가 사용자 쪽이기 때문에 생기는 구조적 성질이다.

그러면 쿠키를 못 쓰나. 아니다. 쿠키에 담는 내용을 바꾸면 된다.

이름과 역할을 담지 말고, 아무 의미 없는 임의의 번호만 담는다. 그리고 그 번호가 누구를 가리키는지는 서버가 따로 기억한다.

세션은 서버의 기억이고 쿠키는 번호표다

이 방식에 이름이 붙어 있다. 세션(session) 이다.

식당의 번호표를 떠올리면 그대로다. 주문한 내용은 주방이 들고 있고, 손님은 47이라고만 적힌 번호표를 든다. 번호표를 48로 고쳐도 소용이 없다. 48번 주문이 없으면 아무것도 안 나오고, 있더라도 그건 그 사람 주문이 아니다.

로그인에 이걸 그대로 적용한다.

diagramdiagram

브라우저가 들고 있는 것은 a3f9...라는 뜻 없는 문자열 하나다. 사용자 번호도 역할도 거기 없다. 그 문자열이 누구를 가리키는지는 서버만 안다.

이제 값을 고쳐도 소용이 없다. a3f9...b4c0...으로 바꾸면 서버는 그런 세션이 없다고 답한다. 남의 세션 ID를 찍어 맞히는 것은 사실상 불가능한데, 세션 ID를 충분히 길고 예측 불가능한 난수로 만들기 때문이다. 그래서 세션 ID는 직접 만들지 말고 프레임워크가 주는 것을 쓴다.

Spring에서 세션에 값을 넣고 꺼내는 코드는 이렇게 생겼다.

java
@PostMapping("/login")
public String login(@RequestBody LoginRequest req, HttpSession session) {
    User user = userService.verify(req.getEmail(), req.getPassword());
    session.setAttribute("userId", user.getId());   // 서버가 기억한다
    return "환영합니다 " + user.getName();
}

@GetMapping("/orders")
public List<Order> orders(HttpSession session) {
    Long userId = (Long) session.getAttribute("userId");   // 번호표로 꺼낸다
    if (userId == null) throw new UnauthorizedException();
    return orderService.findByUser(userId);
}

Set-Cookie를 직접 쓰는 코드가 없다. 서블릿 컨테이너가 세션을 만들면서 JSESSIONID 쿠키를 알아서 내려준다.

쿠키에 붙이는 안전장치

세션 ID는 뜻이 없지만, 훔치면 그 사람 행세를 할 수 있다. 번호표를 뺏기면 남의 주문을 받아가는 것과 같다. 그래서 쿠키에는 이 쪽지를 함부로 못 만지게 하는 속성이 몇 개 붙는다.

http
Set-Cookie: JSESSIONID=a3f9...; HttpOnly; Secure; Path=/; Max-Age=1800
속성
HttpOnly자바스크립트로 이 쿠키를 읽을 수 없다. 브라우저가 요청에 붙일 때만 쓴다
SecureHTTPS 연결에서만 보낸다. 평문 통신에 실려 나가지 않는다
Path이 경로 아래 요청에만 붙인다
Max-Age / Expires이 시간이 지나면 브라우저가 쿠키를 버린다

HttpOnly가 특히 중요하다. 세션 ID는 애플리케이션 코드가 읽을 일이 없는 값이다. 브라우저가 자동으로 붙여주니까. 읽을 일이 없으면 읽을 수 있게 둘 이유도 없다.

주의

다만 HttpOnly가 스크립트 공격을 막아주는 건 아니다. 쿠키 값을 못 읽게 할 뿐이고, 페이지에서 도는 스크립트는 그 쿠키가 자동으로 실리는 요청을 얼마든지 보낼 수 있다. 무엇을 막고 무엇을 못 막는지는 XSS 글이 다룬다.

참고

쿠키에는 SameSite라는 속성도 있는데, 이건 “다른 사이트에서 시작된 요청에 쿠키를 붙일 것인가”를 정한다. 브라우저가 출처를 어떻게 다루는지에 걸린 이야기라 여기서는 이름만 짚고 넘어간다. 그 이야기는 CSRF 글이 소유한다.

서버가 기억한다는 말의 무게

지금까지가 세션의 원리다. 그런데 “서버가 기억한다”는 이 한 문장이 운영에 들어가면 값을 요구한다.

기본 설정에서 세션은 그 서버의 메모리에 저장된다. 서버가 한 대일 때는 아무 문제가 없다.

서버를 두 대로 늘리는 순간 문제가 나타난다.

diagramdiagram

로그인 요청은 서버 A로 갔고 세션은 A의 메모리에 생겼다. 다음 요청이 로드 밸런서를 거쳐 서버 B로 가면, B는 a3f9...라는 세션을 모른다. 로그인이 풀린 것처럼 보인다. 게다가 요청이 어느 서버로 가느냐에 따라 됐다 안 됐다 하니, 재현도 잘 안 된다.

서버를 재시작해도 같은 일이 벌어진다. 메모리에 있던 세션이 통째로 사라지니 배포할 때마다 전원이 로그아웃된다.

해결은 세션을 어디에 둘지 옮기는 것이다.

저장 위치성질
서버 메모리가장 빠르다. 서버를 늘리거나 재시작하면 깨진다
공용 저장소(Redis 등)서버가 몇 대든 같은 세션을 본다. 저장소가 하나 더 늘어난다
데이터베이스별도 인프라가 필요 없다. 요청마다 DB를 읽는다

Spring Session을 쓰면 이 전환이 설정 수준에서 끝난다.

java
@EnableRedisHttpSession   // HttpSession의 저장 위치만 Redis로 바뀐다
public class SessionConfig { }

컨트롤러 코드는 그대로 HttpSession을 쓴다. 바뀌는 것은 “서버가 어디에 기억하느냐”뿐이다. 다만 공짜는 아니다. 요청마다 세션을 읽는 네트워크 왕복이 붙고, 그 저장소가 죽으면 전원이 로그아웃된다. 관리할 것이 하나 늘어난 것이다.

서버가 기억하니 서버가 지울 수 있다

여기에 세션 방식의 가장 강력한 성질이 있다. 판단의 근거가 서버 안에 있으므로, 서버가 지우면 그 순간 끝난다.

java
@PostMapping("/logout")
public void logout(HttpSession session) {
    session.invalidate();   // 이 줄 이후 그 세션 ID는 아무것도 아니다
}

이 한 줄이 실행되면 브라우저에 남아 있는 a3f9...는 즉시 의미를 잃는다. 다음 요청은 401이다.

지울 수 있다는 것은 로그아웃보다 넓게 쓰인다.

  • 강제 로그아웃 - 계정이 도용된 것 같으면 그 사용자의 세션을 전부 지운다
  • 권한 변경 즉시 반영 - 관리자 권한을 회수하면 다음 요청부터 바로 막힌다
  • 동시 접속 제한 - 이미 세션이 있으면 예전 것을 끊는다
  • 유휴 만료 - 일정 시간 요청이 없으면 서버가 알아서 정리한다

전부 “판단 근거를 서버가 들고 있다”에서 나온다. 이 성질은 다음 글에서 반대편을 볼 때 비교할 축이 된다.

로그인에 성공하면 세션 ID를 새로 발급하는 것이 정석이다. 로그인 전에 쓰던 세션 ID를 그대로 유지하면, 공격자가 미리 심어둔 세션 ID가 로그인 후에도 유효해진다. Spring Security는 이걸 기본으로 해주지만, 세션을 손으로 다룬다면 session.invalidate() 후 새 세션을 만드는 절차를 직접 넣어야 한다.

세션이 남기는 불편

세션은 오래된 방식이고, 지금도 웹 애플리케이션의 기본값에 가깝다. 브라우저가 쿠키를 자동으로 붙여주고, 서버가 즉시 무효화할 수 있고, 브라우저에 나가는 값에는 아무 정보도 없다.

대신 갚아야 할 것이 남는다.

  • 요청마다 저장소를 조회한다. 세션 ID만으로는 아무것도 모르니, 누구인지 알려면 매번 찾아봐야 한다.
  • 서버가 상태를 갖는다. 서버를 늘리려면 세션 저장소를 공유해야 하고, 그 저장소가 새로운 단일 장애점이 된다.
  • 쿠키가 안 맞는 자리가 있다. 모바일 앱이나 서버 대 서버 호출처럼 브라우저가 없는 곳에서는 “자동으로 붙는다”는 장점이 사라진다.

여기서 반대 발상이 나온다. 서버가 기억하지 않아도 되게 만들면 어떨까. 브라우저가 들고 다니는 값 자체에 “누구인지”를 담되, 아무도 그 값을 위조할 수 없게 만드는 것이다. 앞에서 “쪽지에 이름을 적으면 위조된다”고 했던 그 문제를 정면으로 푸는 셈이다.

그 답이 다음 글의 JWT다.

정리

문제HTTP는 요청 사이를 스스로 이어주지 않는다
쿠키서버가 붙여두면 브라우저가 요청마다 자동으로 붙여 보내는 값
왜 세션인가쿠키에 신원을 직접 담으면 사용자가 고칠 수 있다
세션서버가 로그인 정보를 기억하고, 브라우저엔 뜻 없는 세션 ID만 준다
쿠키 속성HttpOnly·Secure로 쪽지를 함부로 못 만지게 한다
저장 위치메모리는 서버가 늘면 깨진다. 공용 저장소로 옮긴다
가장 큰 장점서버가 지우면 즉시 끝난다
대가요청마다 조회 · 서버가 상태를 갖는다

서버가 기억하는 대신, 서버가 통제한다. 그게 세션의 성격이다.

관련 글