세션 vs JWT vs OAuth2: 스프링 로그인 방식, 무엇을 언제 써야 하나

📖 25min read

“로그인은 되는데, 이게 맞는 건지 모르겠다”

CI/CD 파이프라인을 세우고, 캐시로 응답 속도를 끌어올리고, Nginx로 요청을 분배하고, 로그까지 수집했다. 인프라는 꽤 그럴듯해졌다. 그런데 정작 가장 기본적인 것 하나가 찜찜했다.

‘로그인.’

Spring Security를 붙이긴 했다. 아이디와 비밀번호를 받아서 DB에서 조회하고, 맞으면 통과시키는 정도. 그런데 주변에서 들리는 말이 다 달랐다.

“요즘 세션 쓰는 데가 있어? JWT 써야지.”

“JWT? 토큰 탈취 문제 어떻게 해결할 건데?”

“우리는 OAuth2로 소셜 로그인 붙였는데.”

“OAuth2는 인증이 아니라 인가야. OIDC를 써야…”

세션, JWT, OAuth2. 이 세 단어가 비슷한 듯 다르고, 다른 듯 겹쳐 보였다. 결국 제대로 정리하지 않으면 평생 찜찜할 것 같았다. 그래서 이번에 확실히 잡기로 했다.

‘이 셋은 정확히 뭐가 다르고, 언제 뭘 써야 하는가.’

세션은 서버가 기억하고, JWT는 클라이언트가 들고 다니고, OAuth2는 남의 신분증을 빌린다.

먼저 구분하자: ‘인증’과 ‘인가’

세션, JWT, OAuth2를 비교하기 전에, 두 가지 용어를 확실히 짚고 가야 한다. 이걸 섞어 쓰면 나중에 반드시 헷갈린다.

구분영어질문예시
인증(Authentication)AuthN“너 누구야?”로그인
인가(Authorization)AuthZ“너 이거 할 수 있어?”관리자만 접근 가능한 페이지

카페에 비유하면 이렇다.

  • 인증: 카페 입구에서 회원증을 보여주는 것. “나는 회원이다.”
  • 인가: 회원이라도 직원 전용 공간에는 못 들어가는 것. “회원이지만 직원은 아니다.”

로그인(인증)이 먼저고, 권한 확인(인가)이 그 다음이다. 세션과 JWT는 주로 ‘인증’ 문제를 해결하고, OAuth2는 ‘인가’ 프레임워크지만 인증 용도로도 확장해서 쓴다. 이 차이를 기억하고 읽으면 뒤에서 훨씬 명확해진다.


세션(Session) 기반 인증: 서버가 기억한다

가장 전통적인 방식이다. 웹이 시작된 이래 오랫동안 표준처럼 사용되어 왔다.

동작 원리

  1. 사용자가 아이디/비밀번호를 보낸다.
  2. 서버가 확인 후, ‘세션 ID’라는 고유 번호를 만든다.
  3. 이 세션 ID를 서버 메모리(또는 Redis)에 저장하고, 사용자에게도 쿠키로 보내준다.
  4. 이후 사용자가 요청할 때마다 쿠키에 담긴 세션 ID를 보낸다.
  5. 서버는 세션 ID로 저장소를 조회해서 “아, 이 사람이구나” 확인한다.
[로그인 요청]
클라이언트 → POST /login (id, pw) → 서버

[세션 생성]
서버: 세션 저장소에 { sid: "abc123", user: "홍길동", role: "USER" } 저장
서버 → Set-Cookie: JSESSIONID=abc123 → 클라이언트

[이후 모든 요청]
클라이언트 → GET /api/data (Cookie: JSESSIONID=abc123) → 서버
서버: 세션 저장소에서 "abc123" 조회 → "홍길동이구나" → 응답

핵심은 ‘상태(State)를 서버가 보관한다’는 것이다. 서버가 “이 사용자가 로그인했다”는 사실을 직접 기억하고 있다. 그래서 이 방식을 ‘Stateful(상태 유지)’이라 부른다.

Spring Boot에서의 세션 인증

Spring Security의 기본 설정이 바로 세션 방식이다. 별도 설정 없이도 동작한다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/**").authenticated()
                .anyRequest().permitAll()
            )
            .formLogin(Customizer.withDefaults())  // 기본 로그인 폼
            .sessionManagement(session -> session
                .maximumSessions(1)                // 동시 로그인 1개 제한
                .maxSessionsPreventsLogin(false)    // 새 로그인 시 기존 세션 만료
            );

        return http.build();
    }
}

세션을 서버 메모리 대신 Redis에 저장하면, 여러 서버가 세션을 공유할 수 있다.

# application.yml
spring:
  session:
    store-type: redis
  data:
    redis:
      host: localhost
      port: 6379
// 의존성 추가만 하면 자동 설정
// build.gradle
implementation 'org.springframework.session:spring-session-data-redis'
세션은 호텔 프론트의 서류함이다. 서버가 늘어나면 서류함(Redis)을 공유해야 한다.

JWT(JSON Web Token): 클라이언트가 들고 다닌다

세션의 가장 큰 제약은 ‘서버가 상태를 기억해야 한다’는 점이다. 서버가 1대일 때는 괜찮지만, 여러 대로 늘어나면 세션을 어디에 저장할지가 골칫거리가 된다. Redis로 해결할 수는 있지만, 아예 서버가 아무것도 기억하지 않으면 어떨까?

이 발상에서 나온 것이 JWT다.

동작 원리

  1. 사용자가 아이디/비밀번호를 보낸다.
  2. 서버가 확인 후, 사용자 정보를 담은 ‘토큰’을 만들어서 돌려준다.
  3. 이 토큰 안에 사용자 ID, 권한, 만료 시간 등이 이미 들어 있다.
  4. 서버는 아무것도 저장하지 않는다.
  5. 이후 사용자는 요청마다 이 토큰을 함께 보내고, 서버는 토큰의 서명만 검증한다.
[로그인 요청]
클라이언트 → POST /login (id, pw) → 서버

[토큰 발급]
서버: JWT 생성 { sub: "홍길동", role: "USER", exp: 1720000000 }
     + 비밀 키로 서명
서버 → { "token": "eyJhbGci..." } → 클라이언트

[이후 모든 요청]
클라이언트 → GET /api/data (Authorization: Bearer eyJhbGci...) → 서버
서버: 토큰 서명 검증 → 페이로드에서 사용자 정보 추출 → "홍길동이구나" → 응답

핵심은 ‘서버가 아무것도 저장하지 않는다’는 것이다. 사용자 정보는 토큰 자체에 담겨 있고, 서버는 서명만 확인한다. 그래서 ‘Stateless(무상태)’라 부른다.

JWT의 구조

JWT는 점(.)으로 구분된 세 부분으로 이루어져 있다.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiLtmY3quLjrj5kiLCJyb2xlIjoiVVNFUiJ9.서명값
|______________________|_____________________________________________|________|
       Header                         Payload                       Signature
부분내용암호화 여부
Header알고리즘, 토큰 타입Base64 인코딩 (누구나 읽을 수 있음)
Payload사용자 ID, 권한, 만료 시간Base64 인코딩 (누구나 읽을 수 있음)
SignatureHeader + Payload를 비밀 키로 서명서버만 검증 가능

주의할 점이 있다. Payload는 ‘암호화’가 아니라 ‘인코딩’이다. 누구나 디코딩해서 내용을 볼 수 있다. JWT는 ‘내용을 숨기는 것’이 아니라 ‘내용이 위조되지 않았음을 보장하는 것’이다.

Spring Boot에서의 JWT 인증

// JWT 생성
public String createToken(String username, String role) {
    return Jwts.builder()
        .subject(username)
        .claim("role", role)
        .issuedAt(new Date())
        .expiration(new Date(System.currentTimeMillis() + 3600000)) // 1시간
        .signWith(secretKey)
        .compact();
}

// JWT 검증 필터
public class JwtAuthFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws Exception {

        String header = request.getHeader("Authorization");

        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);

            try {
                Claims claims = Jwts.parser()
                    .verifyWith(secretKey)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();

                String username = claims.getSubject();
                String role = claims.get("role", String.class);

                // SecurityContext에 인증 정보 설정
                UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(
                        username, null,
                        List.of(new SimpleGrantedAuthority("ROLE_" + role))
                    );
                SecurityContextHolder.getContext().setAuthentication(auth);

            } catch (JwtException e) {
                // 토큰 검증 실패 → 인증 없이 진행 (Spring Security가 처리)
            }
        }

        chain.doFilter(request, response);
    }
}
// SecurityConfig에 JWT 필터 등록
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.disable())          // JWT 사용 시 CSRF 비활성화
        .sessionManagement(session -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .addFilterBefore(new JwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/auth/**").permitAll()
            .anyRequest().authenticated()
        );

    return http.build();
}

OAuth2: 남의 신분증을 빌린다

세션과 JWT가 ‘우리 서비스의 아이디/비밀번호로 직접 로그인’하는 방식이라면, OAuth2는 완전히 다른 접근이다.

“로그인? 구글한테 물어볼게.”

네이버 로그인, 카카오 로그인, 구글 로그인. 이런 ‘소셜 로그인’의 뒤에 있는 프로토콜이 바로 OAuth2다.

OAuth2의 본래 목적

중요한 점이 있다. OAuth2는 원래 ‘인증(AuthN)’ 프로토콜이 아니다. ‘인가(AuthZ)’ 프레임워크다.

“이 앱이 내 구글 캘린더를 읽어도 되는가?”

이런 권한 위임 문제를 해결하기 위해 만들어진 것이다. 그런데 실무에서는 ‘이 사용자가 구글 계정을 가지고 있다 = 인증된 사용자’로 확장해서 쓰고 있다. 이 확장을 공식적으로 표준화한 것이 ‘OpenID Connect(OIDC)’다.

프로토콜목적결과물
OAuth2인가 (권한 위임)Access Token (API 접근용)
OIDC인증 (사용자 확인)ID Token (사용자 정보)

실무에서 “OAuth2로 로그인한다”고 말하면, 대부분 ‘OAuth2 + OIDC’를 의미한다.

동작 원리 (Authorization Code 방식)

[1] 사용자 → "구글로 로그인" 클릭 → 우리 서버

[2] 우리 서버 → 구글 인증 페이지로 리다이렉트
    "이 앱이 당신의 이메일과 프로필을 읽으려 합니다. 허용하시겠습니까?"

[3] 사용자 → 구글에서 직접 로그인 + "허용" 클릭

[4] 구글 → 우리 서버에 '인가 코드(Authorization Code)' 전달
    GET /callback?code=abc123

[5] 우리 서버 → 인가 코드 + 클라이언트 시크릿으로 구글에 Access Token 요청
    (서버 간 통신, 사용자 브라우저를 거치지 않음)

[6] 구글 → Access Token (+ ID Token) 발급

[7] 우리 서버 → Access Token으로 구글 API 호출 → 사용자 이메일, 이름 획득
    → 우리 DB에 사용자 등록/조회
    → 우리 서비스의 세션 또는 JWT 발급

핵심은 ‘사용자의 비밀번호를 우리 서버가 전혀 모른다’는 것이다. 인증은 구글이 해주고, 우리는 결과만 받는다. 비밀번호를 직접 관리하지 않으니 유출 위험도 줄어든다.

Spring Boot에서의 OAuth2 로그인

Spring Security는 OAuth2 로그인을 놀라울 정도로 간결하게 지원한다.

# application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: openid, profile, email
          kakao:
            client-id: ${KAKAO_CLIENT_ID}
            client-secret: ${KAKAO_CLIENT_SECRET}
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope: profile_nickname, account_email
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(customOAuth2UserService)  // 사용자 정보 후처리
                )
                .defaultSuccessUrl("/dashboard")
            );

        return http.build();
    }
}
// OAuth2 로그인 성공 후 사용자 정보 처리
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(request);

        String provider = request.getClientRegistration().getRegistrationId(); // "google", "kakao"
        String email = oAuth2User.getAttribute("email");
        String name = oAuth2User.getAttribute("name");

        // 우리 DB에 사용자 등록 또는 조회
        Member member = memberRepository.findByEmail(email)
            .orElseGet(() -> memberRepository.save(
                Member.builder()
                    .email(email)
                    .name(name)
                    .provider(provider)
                    .build()
            ));

        return new DefaultOAuth2User(
            List.of(new SimpleGrantedAuthority("ROLE_USER")),
            oAuth2User.getAttributes(),
            "email"
        );
    }
}
OAuth2는 대사관(Google)에서 비자를 받아오는 것이다. 우리 서버는 비밀번호를 전혀 모른다.

한눈에 비교: 세션 vs JWT vs OAuth2

이제 세 가지를 나란히 놓고 비교해 보자.

항목세션JWTOAuth2
상태 관리Stateful (서버 저장)Stateless (토큰 자체에 정보)외부 인증 서버에 위임
저장 위치서버(메모리/Redis)클라이언트(localStorage, Cookie)인증은 외부, 이후 세션 or JWT
서버 확장Redis 등 세션 공유 필요서버 간 공유 불필요인증 서버만 별도 관리
로그아웃서버에서 세션 삭제하면 즉시 무효화서버가 토큰을 저장하지 않으므로 즉시 무효화 어려움제공자에 따라 다름
보안서버가 통제권을 가짐토큰 탈취 시 만료까지 유효비밀번호를 우리가 관리하지 않음
적합한 환경전통적 웹(SSR), 서버 수 적음API 서버, MSA, 모바일 앱소셜 로그인, 서드파티 연동

실무에서 자주 보는 조합

실제로 세 가지를 하나만 쓰는 경우는 드물다. 대부분 조합해서 쓴다.

시나리오조합이유
사내 어드민세션서버 1~2대, SSR, 단순한 구조
모바일 + API 서버JWT앱에서 쿠키 관리 불편, Stateless 선호
SaaS (B2C)OAuth2 + JWT소셜 로그인으로 가입 허들 낮추고, 이후 JWT로 API 인증
대규모 MSAOAuth2 + JWT + 세션인증 서버(OAuth2)가 JWT 발급, 게이트웨이에서 검증, 일부 서비스는 세션

실무 조언: 각 방식의 함정

세션의 함정

  1. ‘Sticky Session’에 기대지 말 것. 로드밸런서가 같은 사용자를 항상 같은 서버로 보내는 방식인데, 서버 장애 시 세션이 통째로 날아간다. Redis 기반 세션 공유가 정석이다.
  2. CSRF 공격에 주의할 것. 세션은 쿠키 기반이므로, CSRF 토큰을 반드시 함께 써야 한다. Spring Security는 기본으로 활성화되어 있으니 끄지 않는 것이 좋다.

JWT의 함정

  1. 토큰에 민감 정보를 넣지 말 것. Payload는 Base64 디코딩만 하면 누구나 볼 수 있다. 비밀번호, 주민번호 같은 것은 절대 넣으면 안 된다.
  2. 토큰 만료 전에 무효화하기 어렵다. 사용자가 로그아웃하거나 권한이 변경되어도, 이미 발급된 토큰은 만료 시간까지 유효하다. 이 문제를 완화하려면 토큰 수명을 짧게(15분~1시간) 설정하고, Refresh Token을 별도로 관리하는 것이 일반적이다.
  3. 토큰 저장 위치를 신중하게 선택할 것. localStorage는 XSS에 취약하고, 쿠키는 CSRF에 취약하다. HttpOnly + Secure + SameSite 쿠키가 현재 가장 권장되는 방식이다.

OAuth2의 함정

  1. OAuth2는 인증이 아니다. Access Token으로 “이 사용자가 누구인지” 확인하는 것은 OAuth2 스펙에 정의된 동작이 아니다. 사용자 정보가 필요하면 OIDC의 ID Token을 사용하거나, 제공자의 UserInfo 엔드포인트를 호출해야 한다.
  2. 제공자마다 응답 형식이 다르다. 구글은 email 필드를 주지만, 카카오는 kakao_account.email 안에 넣어준다. 제공자별 매핑 로직이 필요하다.

마치며: “뭐가 좋다”가 아니라 “언제 뭘 쓴다”

세 가지 인증 방식은 우열의 문제가 아니다. 각각 해결하려는 문제가 다르다.

  • 서버가 1~2대이고 전통적인 웹이라면, 세션이 가장 단순하고 안전하다.
  • API 서버를 만들고 모바일/SPA와 통신한다면, JWT가 자연스럽다.
  • 소셜 로그인을 제공하거나 외부 서비스와 연동한다면, OAuth2가 정답이다.
  • 그리고 현실에서는 이 셋을 조합해서 쓴다.

회사에서 Spring Security + JWT를 쓰고 있는데, 이제야 왜 세션 대신 JWT를 선택했는지, 그리고 OAuth2가 왜 별도의 레이어인지가 보이기 시작했다. “그냥 JWT 쓰면 되지 않아?”라고 생각했던 과거의 나에게 말해주고 싶다. “그건 도구의 선택이 아니라 구조의 선택이었다”고.

다음 글에서는 이 인증 방식들의 뒤를 받쳐주는 Spring Security의 구조 자체를 뜯어보려 한다. 필터 체인이 어떻게 동작하고, 우리가 작성한 SecurityConfig가 내부적으로 어떤 흐름을 만들어내는지. 블랙박스를 열어볼 시간이다.

“세션 vs JWT vs OAuth2: 스프링 로그인 방식, 무엇을 언제 써야 하나”에 대한 8개의 생각

댓글 남기기