개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

기초개념 › 보안

세션

세션은 무엇이고, 로그인한 상태는 어디에 기억되는가

세션은 서버 안의 저장소에 적어 두는 기억입니다. 코드 칸의 session은 HttpSession이라는 타입의 인자이고, 서버의 세션 저장소에서 이 요청에 딸린 칸 하나를 가리킵니다. login 메서드의 session.setAttribute("user", "kim") 줄은 그 칸에 "이 세션의 사용자는 kim"이라고 적는 줄이고, me 메서드의 session.getAttribute("user") 줄은 칸에서 그 값을 읽는 줄입니다. 칸마다 서버가 붙인 번호(세션 id)가 있고, 서버는 이 번호만 JSESSIONID라는 이름의 쿠키로 브라우저에 줍니다. 브라우저는 다음 요청마다 그 쿠키를 보내고, 서버는 번호로 저장소의 칸을 찾아 누구인지 압니다. 이 실습은 로그인한 뒤에 /me를 요청하는 방법을 바꿔 가며(쿠키 없이, 다른 사람이, 서버가 칸을 지운 뒤에), 서버가 누구인지 알아보는지를 봅니다. 코드 칸에는 로그인에 성공했을 때 실행되는 부분만 있고, 아이디와 비밀번호를 확인하는 과정은 이 실습에서 다루지 않아 줄였습니다. 쿠키가 무엇인지는 앞의 "쿠키" 실습에서 다룹니다.

실행으로 대조 · 스프링 부트 4.1.1(내장 톰캣 11.0.24)에서 여덟 가지 조합을 실제 컴포넌트 스캔으로 띄우고 요청을 보내 대조(HttpClient가 Cookie 헤더를 직접 적음, 실제 브라우저에서는 실행하지 않음)

이 실습에 나오는 개념

세션
사용자의 상태를 서버 쪽에 저장하고, 클라이언트에는 그 저장소를 가리키는 식별자(세션 ID)만 쿠키로 전달하는 방식이다. 실제 정보가 서버에 있으므로 클라이언트가 내용을 고칠 수 없다(세션 ID를 빼앗기는 것은 다른 문제다). 서버가 여러 대일 때는 어느 서버로 요청이 가도 같은 세션을 찾을 수 있도록 Redis 같은 공용 저장소에 세션을 두는 구성이 일반적이다.
세션 id(JSESSIONID)
세션 id는 서블릿 컨테이너가 세션을 만들 때 그 세션에 붙이는 고유한 문자열로, 서버의 세션 저장소에서 칸 하나를 가리키는 번호다. HttpSession의 getId()로 읽을 수 있고, 어떤 모양의 번호를 쓸지는 컨테이너가 정한다(이 실습의 톰캣 11에서는 영문 대문자와 숫자 32글자였다). 쿠키로 세션을 이어 갈 때 쿠키의 이름은 명세가 JSESSIONID로 정해 두었고, 서버가 Set-Cookie 헤더로 번호를 주면 브라우저가 다음 요청마다 Cookie 헤더에 실어 보낸다. 서버는 이 번호만으로 칸을 찾으므로, 다른 사람이 같은 번호를 보내면 같은 세션으로 다루어진다. 번호가 노출되면 안 되는 이유다.
HttpSession
HttpSession은 한 사용자를 여러 요청에 걸쳐 식별하고, 그 사용자의 정보를 서버에 저장하는 방법을 제공하는 서블릿 인터페이스다. 스프링 컨트롤러 메서드는 HttpSession 타입의 인자를 선언해 받을 수 있고, 요청에 딸린 세션이 없으면 서버가 새로 만든다. setAttribute("user", "kim")으로 이름을 붙여 값을 칸에 적고 getAttribute("user")로 읽으며, 적혀 있지 않은 이름을 읽으면 null이 돌아온다. invalidate()를 부르면 그 세션이 무효가 되고 묶여 있던 값도 풀리며, 이후의 요청은 그 세션을 볼 수 없다. 세션의 값은 서버에 저장되고 브라우저에는 세션 id만 간다.
쿠키
서버가 응답에 실어 보내면 브라우저가 저장해 두었다가 같은 사이트에 요청할 때마다 자동으로 함께 보내는 작은 데이터다. HTTP는 요청 사이의 상태를 기억하지 않으므로, 로그인 상태처럼 이어져야 하는 정보를 유지하는 데 쓰인다. 사용자의 브라우저에 저장되므로 내용을 보거나 바꿀 수 있다는 점을 전제로 사용해야 한다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. 로그인 화면에서 로그인 버튼을 누르면, 브라우저가 서버에 POST /login 요청을 보냅니다. 이 요청에는 Cookie 헤더가 없습니다. 서버의 세션 저장소는 비어 있습니다. 아이디와 비밀번호를 확인하는 과정은 이 실습에서 다루지 않아 줄였습니다.
  2. 스프링이 login 메서드를 부르려면 session 인자를 채워야 하는데, 이 요청에는 세션 번호가 실려 오지 않았으므로 요청에 딸린 세션이 아직 없습니다. 그래서 서버(톰캣)가 세션을 새로 만들고 무작위 번호를 붙입니다(톰캣이 붙인 번호는 영문 대문자와 숫자 32글자였고, 여기서는 앞부분만 적어 3A9F…로 줄여 씁니다). 저장소에 칸 하나가 생기는데 칸은 비어 있습니다. 우리가 쓴 코드에는 세션을 만드는 줄도, 번호를 붙이는 줄도 없습니다.
  3. login 메서드가 session.setAttribute("user", "kim") 줄을 실행합니다. 방금 만든 칸에 user=kim이 적힙니다. 로그인했다는 정보가 적힌 곳은 브라우저가 아니라 서버 안의 세션 저장소입니다.
  4. login 메서드가 "로그인됨"을 돌려주고, 응답에 Set-Cookie: JSESSIONID=3A9F… 줄이 붙습니다. 이 줄을 붙이는 코드를 우리가 쓰지 않았습니다. 톰캣이 붙입니다. 쿠키 값은 번호뿐이고 kim이라는 글자는 들어 있지 않습니다.
  5. 브라우저가 응답을 받고, 쿠키 JSESSIONID=3A9F…를 저장합니다. 브라우저가 들고 있는 것은 번호뿐이고, user=kim은 서버의 저장소에만 있습니다.
  6. 브라우저가 서버에 GET /me 요청을 보냅니다. 저장해 둔 쿠키가 있어서 브라우저가 Cookie: JSESSIONID=3A9F… 헤더를 요청에 붙입니다. 이 헤더를 붙이는 코드를 우리가 쓰지 않았습니다. 브라우저가 합니다.
  7. 서버가 요청의 Cookie 헤더에서 번호 3A9F…를 읽고, 세션 저장소에서 같은 번호의 칸을 찾습니다. 칸이 있으므로 새로 만들지 않고, 그 칸을 me 메서드의 session 인자로 넣어 줍니다.
  8. me 메서드가 session.getAttribute("user") 줄로 칸을 읽습니다. 칸에 user=kim이 적혀 있으므로 "안녕하세요, kim"을 돌려줍니다. 서버가 kim을 아는 것은 쿠키에 kim이 들어 있어서가 아니라, 번호로 찾은 칸에 kim이 적혀 있기 때문입니다.
  9. 브라우저가 200 응답을 받습니다. 본문은 "안녕하세요, kim"입니다. 이 응답에는 Set-Cookie 줄이 붙지 않습니다. 이미 있는 칸을 찾았기 때문입니다.

실행 결과

코드

@PostMapping("/login")
public String login(HttpSession session) {
    // 서버의 세션 저장소에 "이 세션의 사용자는 kim"이라고 적어 둔다
    session.setAttribute("user", "kim");
    return "로그인됨";
}

@GetMapping("/me")
public String me(HttpSession session) {
    // 요청에 실려 온 세션 번호로 저장소의 칸을 찾아 읽는다
    Object user = session.getAttribute("user");
    return user == null ? "누구인지 모름" : "안녕하세요, " + user;
}

이 모형의 전제

스프링 부트 4.1.1(스프링 프레임워크 7.0.9, 내장 톰캣 11.0.24)을 JDK 21에서 실행한 결과입니다. 여덟 가지 조합을 모두, 화면의 코드와 같은 소스(화면에 없는 logout 메서드 하나만 더함)를 패키지 app으로 컴파일하고 시작 클래스(@SpringBootConfiguration, @EnableAutoConfiguration, @ComponentScan)와 함께 자바 프로세스를 새로 띄워 확인했습니다. 컨트롤러를 손으로 등록하지 않고 스프링이 클래스를 훑어 찾게 했으며, 요청은 서버가 뜬 뒤 실제 HTTP로 보냈습니다. 클래스패스에는 스프링 시큐리티, JPA, H2를 넣지 않았습니다(시큐리티가 있으면 요청이 막히고 세션 처리가 달라질 수 있기 때문입니다). 화면의 코드는 package와 import 줄, 클래스 선언, 로그아웃 메서드를 줄였습니다. 로그아웃 메서드는 session.invalidate()를 실행하고 "로그아웃됨"을 돌려주는 메서드이고, 아이디와 비밀번호를 확인하는 과정도 이 실습에서 다루지 않아 줄였습니다. 대조한 것은 login 직후 서버의 세션 저장소(톰캣 세션 관리자의 세션 수와 칸마다 적힌 내용), login 응답의 Set-Cookie, /me 응답입니다. 같은 브라우저는 받은 Set-Cookie의 값을 다음 요청의 Cookie 헤더에 직접 적는 클라이언트 하나이고(쿠키 보관 기능을 쓰지 않았고 실제 브라우저에서는 실행하지 않았습니다), 다른 사람은 별개의 연결(별개의 클라이언트)이 같은 값을 Cookie 헤더에 적어 보내는 것이며, 서버가 세션을 지우는 일은 같은 쿠키로 POST /logout을 먼저 보내는 것으로 재현했습니다. 화면의 번호(3A9F…, B71E…)는 설명을 위해 지어 낸 예시이고, 실제로는 실행할 때마다 다른 영문 대문자와 숫자 32글자였습니다. 문서가 정한 것은 Jakarta Servlet 6.1 명세 7장(7.1.1절: 세션 추적 쿠키의 표준 이름은 JSESSIONID, 7.2절: 세션은 클라이언트가 추적 정보를 서버에 돌려보내 합류하기 전까지 새 세션이고 id는 getId()로 읽는다, 7.5절: 무효화가 시작되면 새 요청은 그 세션을 볼 수 없다)와 HttpSession의 API 문서(세션은 한 사용자를 여러 요청에 걸쳐 식별하고 정보를 저장하는 방법이고, getId의 식별자는 서블릿 컨테이너가 정하며 구현에 따라 다르다)입니다. 다음은 문서가 정한 것이 아니라 이 버전에서 관찰한 동작입니다. 번호가 영문 대문자와 숫자 32글자이고 쿠키 값에 kim이 들어 있지 않다는 것, 쿠키에 Path=/와 HttpOnly 속성이 붙는다는 것, session 인자를 받는 메서드는 setAttribute를 부르지 않아도 세션을 만들고(스프링이 그 인자를 채우려고 요청의 getSession()을 부릅니다. 이 호출은 스프링의 해당 클래스 바이트코드로 확인했습니다) 쿠키가 나간다는 것, 쿠키 없이 /me를 요청하면 서버가 빈 세션을 새로 만들고 새 번호를 준다는 것, 같은 번호를 별개의 연결에서 보내도 서버가 같은 세션으로 다루고 새로 만들지 않는다는 것(두 요청의 연결 포트는 달랐습니다), 서버가 지운 세션의 번호로 요청하면 서버가 새 세션을 만들어 새 번호를 준다는 것, 로그아웃 응답에는 쿠키를 지우라는 Set-Cookie 줄이 없다는 것입니다. 세션 번호를 훔쳐 가는 공격과 방어(세션 고정, HttpOnly 설정 등)는 이 실습에서 다루지 않았고, 공부 쪽 실습("세션과 JWT", "세션 하이재킹과 세션 고정")으로 넘깁니다. 세션의 유효 시간, 여러 서버에서 세션을 나누는 방법, 쿠키를 쓰지 않는 방법(URL에 번호를 싣는 방식 등)도 다루지 않았습니다.

앞 실습: 인증과 인가 · 다음 실습: 토큰

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요