Session과 JWT

김소연·2026년 4월 21일

Session

  • 세션

    세션 인증은 서버가 사용자의 로그인 상태를 기억하고 관리하는 방식.
    마치 단골 가게 주인이 우리를 기억하고 "늘 먹던 걸로 드릴까요?"라고 묻는 것과 비슷합니다.

    핵심 특징: Stateful (상태 유지)
    세션 방식의 가장 큰 특징은 Stateful,
    즉 서버가 사용자의 상태(로그인 정보)를 계속해서 저장하고 유지한다는 점입니다.

    1. 로그인을 하면 서버는 무작위의 유니크한 Session ID(=Session Key)를 생성하여 이를 키 값으로 하여 로그인 정보를 저장(Key: Value 형태)하고, Session ID를 클라이언트에게 반환합니다.

    2. 이후, 클라이언트는 요청 시 Session ID를 헤더(or 쿠키)에 넣어 서버에 요청하면, 서버는 Session ID를 키로 하여 로그인 정보를 가져와 유저를 식별합니다.


  • JWT

    인증에 필요한 정보들을 암호화시킨 JSON 객체를 사용하여 인증을 처리하는 방식.
    서버가 사용자를 기억하는 것이 아니라, 사용자가 자신의 신분증을 들고 다니는 것과 같습니다. 모든 정보가 JWT에 들어있기 때문에, 서버는 로그인 상태를 기억하지 않아도 됩니다!

    핵심 특징: Stateless (무상태)

    1. 로그인을 하면 서버는 JWT에 생성하여 이를 클라이언트에게 반환합니다.
    2. 이후, 클라이언트는 요청 시 JWT를 헤더(or 쿠키)에 넣어 서버에 요청하면, 서버는 JWT에 담겨있는 정보로 유저를 식별합니다.

  • JWT의 구성 요소

    aaaa.bbbb.cccc와 같이 점(.)으로 구분된 세 부분으로 구성됩니다.
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  1. 헤더 (Header):JSON

    • 토큰의 유형(typ: JWT)과 서명(Signature)을 생성할 때 사용할 알고리즘(alg: HS256, RS256 등) 정보가 담겨 있습니다.
    • 이 JSON 객체를 Base64Url 방식으로 인코딩한 값입니다.
      {
          "alg": "HS256",
          "typ": "JWT"
      }
  2. 페이로드 (Payload):JSON

    • 토큰에 담을 실제 정보, 즉 클레임(Claim) 이 들어 있습니다. 클레임은 사용자의 정보(이름, 아이디, 권한 등)나 토큰의 속성(만료 시간, 발급자 등)을 나타냅니다.
    • 페이로드 역시 Base64Url 방식으로 인코딩됩니다. 중요한 점은 인코딩일 뿐, 암호화가 아니므로 누구나 디코딩하여 내용을 볼 수 있다는 것입니다. 따라서 비밀번호와 같은 민감한 정보는 절대 넣으면 안 됩니다!
      {
          "sub": "user123", // 토큰 제목 (사용자 ID)
          "name": "Spartan Kim", // 사용자 이름
          "iat": 1516239022, // 토큰 발급 시간
          "exp": 1516242622  // 토큰 만료 시간
      }
  3. 서명 (Signature):

    • JWT의 무결성을 보장하는 가장 중요한 부분입니다.
    • Header와 Payload를 인코딩한 값과 서버만 알고 있는 비밀 키(Secret Key)를 헤더에 지정된 알고리즘(예: HS256)으로 암호화하여 생성합니다.
    • 서버는 클라이언트로부터 받은 토큰의 헤더와 페이로드를 동일한 비밀 키와 알고리즘으로 암호화하여 서명 값을 다시 만듭니다. 이 값이 토큰의 기존 서명 값과 일치하는지 확인하여 토큰의 위변조 여부를 판단합니다.

Refresh Token

JWT를 사용하다 보면 한 가지 딜레마에 빠집니다.

  • 토큰의 유효기간을 짧게 하면?
    → 보안에는 좋지만, 사용자가 로그인을 너무 자주 해야 해서 불편하다…
  • 토큰의 유효기간을 길게 하면?
    → 사용자는 편하지만, 한 번 탈취되면 오랜 시간 동안 악용될 수 있어 위험하다…

이 문제를 해결하기 위해 Access TokenRefresh Token이라는 두 가지 종류의 토큰을 함께 사용하는 전략을 사용합니다.

  • Access Token
    • 이름 그대로 실제 자원에 접근할 때 사용하는 토큰으로, 우리가 지금까지 배운 JWT가 바로 이 Access Token입니다.
    • 유효기간을 짧게 설정합니다. (예: 30분, 1시간)
    • 탈취되더라도 짧은 시간만 유효하기 때문에 피해를 최소화할 수 있습니다.
  • Refresh Token
    • 새로운 Access Token을 발급받기 위해 사용하는 토큰입니다.
    • Access Token보다 유효기간을 길게 설정합니다. (예: 1주, 1달)
    • 평소에는 사용하지 않고, Access Token이 만료되었을 때만 사용됩니다.
    • 보통 데이터베이스와 같은 안전한 곳에 저장하여 관리합니다.

HttpServletRequest

HttpServletRequest 객체는 HTTP 요청 메시지 전체를 담고 있는 그릇이라고 생각하면 쉽습니다. 클라이언트(브라우저)가 서버로 보내는 모든 정보가 이 안에 담겨있습니다. HTTP 요청을 자바에서 활용하기 위해, HTTP 요청이 자바 객체로 변환된 것입니다.
즉, 그렇기 때문에 HTTP 요청에 있는 Start Line, Headers, Message Body 구조의 데이터가 그대로 HttpServletRequest 객체 안에 모두 포함되어있습니다.

@RestController
public class RequestController {

    @RequestMapping("/request-info")
    public String getRequestInfo(HttpServletRequest request) {
        String method = request.getMethod(); // HTTP 메소드
        String uri = request.getRequestURI(); // 요청 URI

        // ...
    }
}

위 코드 예시와 같이 스프링 컨트롤러에서 이 HttpServletRequest 객체를 직접 파라미터로 받아 모든 정보에 접근할 수 있지만, 스프링은 더 편리한 방법들을 제공합니다.
그중 하나가 바로 @RequestHeader입니다.


@RequestHeader

@RequestHeader 애노테이션을 사용하면 HttpServletRequest 객체를 통째로 가져오지 않고도 특정 헤더 값을 파라미터로 직접 받아올 수 있습니다. 코드가 훨씬 간결하고 의도가 명확해집니다.

  • 사용법

    @RequestHeader("헤더이름") String 변수명 형태로 사용합니다.

    @RestController
    public class HeaderController {
    
        @GetMapping("/headers")
        public String getHeaders(
                @RequestHeader("session-id") String sessionId,
                @RequestHeader(value = "User-Agent") String userAgent 
        ) {
                // ...
        }
    }
  • value 또는 name:
    가져올 헤더의 이름을 지정합니다. (필수값, 단순 String만 입력하면 이 String 값이 value가 됩니다)

  • required:
    해당 헤더가 필수인지 여부를 결정합니다.
    기본값은 true이며, true일 때 헤더가 없으면 400 Bad Request 오류가 발생합니다. false로 설정하면 헤더가 없을 경우 null이 주입됩니다.

  • defaultValue:
    requiredfalse일 때, 헤더 값이 없을 경우 대신 주입될 기본값을 설정할 수 있습니다.


@SessionAttribute와 HttpSession을 활용한 세션 관리

“Session ID만 편하게 가져올 수 있는 ArgumentResolver가 있으면 좋겠다!”
그 기능을 하는 도구가 바로 @SessionAttribute입니다.

  • 1. 유저 등록

    @PostMapping("/register")
    public ResponseEntity<RegisterResponse> register(@Valid @RequestBody RegisterRequest request) {
        User user = userService.register(request);
        RegisterResponse response = new RegisterResponse(user.getId());
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
  • 2. 로그인

    // SessionUser DTO
    @Getter
    public class SessionUser {
    
        private final Long id;
        private final String email;
    
        public SessionUser(Long id, String email) {
            this.id = id;
            this.email = email;
        }
    }
    @PostMapping("/login")
    public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request, HttpSession session) {
        User user = userService.login(request);
        SessionUser sessionUser = new SessionUser(user);
        session.setAttribute("loginUser", sessionUser);
    
        LoginResponse response = new LoginResponse(user.getId(), user.getEmail());
        return ResponseEntity.status(HttpStatus.OK).body(response);
    }
    • HttpSession session:
      컨트롤러 메소드의 파라미터로 HttpSession을 선언하면, 스프링이 현재 요청에 해당하는 세션 객체를 자동으로 주입해 줍니다.
      • 만약 기존에 생성된 세션이 없다면, 스프링이 새로운 세션을 생성하고 그 객체를 넣어줍니다.
    • session.setAttribute("loginUser", sessionUser):
      이 코드가 바로 "로그인 상태를 만드는" 핵심 로직입니다.
      • "loginUser" 라는 이름(Key)으로 sessionUser 객체(Value)를 세션 저장소에 저장합니다.
      • 이제부터 이 사용자의 세션에는 loginUser 정보가 계속 남아있게 됩니다.
        💡 Tip!: User 엔티티 전체가 아닌, 세션에 필요한 정보만 담은 
            가벼운 SessionUser DTO를 만들어 저장하는 것이 성능과 보안 면에서 좋은 습관입니다.
    • 동작 원리:
      이 메소드가 성공적으로 응답하면, 서버는 클라이언트에게 Set-Cookie 헤더를 통해 JSESSIONID 를 전달합니다. 브라우저는 이 쿠키를 저장하고, 앞으로 모든 요청에 이 쿠키를 담아 보내 서버가 "아, 이 사용자는 로그인된 사용자구나!"라고 식별할 수 있게 해줍니다.
      JSESSIONID : 스프링에서 HttpSession을 쓸 때, 쿠키의 Session ID Key의 디폴트 이름
      (SESSIONID, session-id 같은 일반적인 이름을 쓰면 겹칠 수도 있기에 버그 발생 위험!)
  • 3. 로그아웃
    세션을 무효화합니다.

    <@PostMapping("/logout")
    public ResponseEntity<Void> logout(@SessionAttribute(name = "loginUser", required = false) SessionUser sessionUser, HttpSession session) {
        if (sessionUser == null) {
            return ResponseEntity.badRequest().build();
        }
    
        session.invalidate();
        return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
    }
    • @SessionAttribute
      • "loginUser"라는 이름으로 저장된 세션 속성을 찾아 sessionUser 파라미터에 주입해줍니다.
    • session.invalidate(): 세션을 무효화하는 메서드입니다.
      • 서버의 세션 저장소에서 현재 세션 객체와 그 안의 모든 데이터를 완전히 삭제합니다.
  • 4. 테스트 진행

    // 로그인 상태(세션이 유효한 상태)에서만 동작하는지 테스트해볼 수 있는 API
    @GetMapping("/test")
    public ResponseEntity<String> test(@SessionAttribute(name = "loginUser", required = false) SessionUser sessionUser) {
        if (sessionUser == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        return ResponseEntity.status(HttpStatus.OK).build();
    }

0개의 댓글