OAuth 2.0 완전 정복 — 구성요소부터 OIDC·Spring Boot 실습까지

이번 포스팅에서는 OAuth 2.0을 처음 적용하는 백엔드 개발자를 위해 그 개념과 실습을 함께 정리하고자 한다. OAuth 2.0은 “비밀번호를 넘기지 않고도 제3자 앱에 특정 권한만 위임하는” 표준 인가(authorization) 프로토콜이다. 구글 로그인 버튼 하나에도 이 프로토콜이 통째로 돌아가는데, 막상 직접 붙이려 하면 등장인물이 많고 토큰 종류도 헷갈린다. 그래서 구성요소와 전체 흐름을 먼저 그림처럼 정리하고, access·refresh·id token의 차이와 OIDC와의 관계를 짚은 뒤, Spring Boot로 구글 로그인을 실제로 구현해 실행까지 확인한다.

OAuth 2.0이 정확히 무엇을 푸는 문제인가

OAuth 2.0은 사용자의 비밀번호를 제3자 앱에 알려주지 않고, 필요한 권한만 위임하기 위한 프로토콜이다. 무게중심이 “인증(누구인가)”이 아니라 “인가(무엇을 할 수 있는가)”에 있다는 점을 먼저 기억해 두면 좋다. 예를 들어 어떤 앱이 내 구글 캘린더를 읽고 싶을 때, 내 구글 비밀번호를 그 앱에 주는 대신 “캘린더 읽기 권한만” 담긴 토큰을 발급받아 넘기는 방식이다.

이 구분이 중요하다. 비밀번호를 넘기면 앱은 내 계정으로 무엇이든 할 수 있지만, OAuth 2.0에서는 범위(scope)로 권한을 좁히고, 문제가 생기면 토큰만 폐기하면 된다. 비밀번호는 그대로 안전하다. 표준 명세는 RFC 6749에 정의돼 있고, 오늘날 구글·깃허브·카카오 로그인이 모두 이 프레임워크 위에서 동작한다.

다만 한 가지 자주 하는 오해가 있다. OAuth 2.0 자체는 “권한 위임”을 위한 것이지 “이 사용자가 누구인지”를 표준화해 알려주지는 않는다. 그 역할은 뒤에서 다룰 OIDC가 맡는다. 먼저 OAuth 2.0의 등장인물부터 정리한다.

OAuth 2.0의 네 가지 구성요소 — 등장인물부터 잡자

OAuth 2.0에는 **네 개의 역할(role)**이 있고, 흐름을 이해하려면 이 넷을 먼저 구분해야 한다. Resource Owner(사용자), Client(권한을 위임받으려는 앱), Authorization Server(토큰을 발급하는 서버), Resource Server(보호된 자원을 가진 API)다. 구글 로그인으로 치환하면 한눈에 들어온다.

역할정체구글 로그인 예시
Resource Owner자원의 주인(사용자)로그인하는 나
Client권한을 위임받는 앱내가 만든 웹 서비스
Authorization Server인증·동의·토큰 발급Google 계정 서버
Resource Server보호된 APIGoogle UserInfo·Calendar API

여기에 두 가지 개념이 더 붙는다. Authorization Grant는 클라이언트가 토큰을 받기 위해 제시하는 “권한 증명”이고, 웹 앱에서 가장 널리 쓰이는 방식이 authorization code(인가 코드)다. Scope는 위임 범위를 뜻하며 email, profile, openid 같은 값으로 표현한다. 이 구성요소들이 어떤 순서로 상호작용하는지가 다음 절의 전체 흐름이다.

Client가 준비해야 하는 것 — 무엇을 손에 쥐고 시작하나

OAuth 2.0 Client가 인가 흐름을 시작하려면 네 가지가 반드시 필요하다. client-id, client-secret, redirect URI, 그리고 요청할 scope다. 앞의 두 개는 벤더(구글 등)에 앱을 등록하면 발급되고, redirect URI는 인가가 끝난 뒤 사용자가 돌아올 우리 서버의 주소다.

  • client-id: 클라이언트를 식별하는 공개 값. 인가 요청 URL에 그대로 실린다.
  • client-secret: 클라이언트를 인증하는 비밀 값. 토큰 교환 때 쓰이며 절대 프런트엔드에 노출하면 안 된다(백엔드에만 둔다).
  • redirect URI(콜백 주소): 인가 코드를 받아올 우리 서버의 엔드포인트. 벤더에 등록한 값과 정확히 일치해야 한다.
  • scope: 요청할 권한 범위. 로그인만 필요하면 openid, profile, email 정도면 충분하다.

초보자가 가장 많이 막히는 지점이 redirect URI다. 구글은 등록된 값과 “스킴(http/https), 대소문자, 끝의 슬래시(/)까지 모두 일치“하지 않으면 redirect_uri_mismatch 에러를 던진다(구글 공식 문서). Spring Security를 쓰면 이 콜백 주소가 {baseUrl}/login/oauth2/code/{registrationId} 패턴으로 자동 생성되는데, 이 값을 그대로 벤더에 등록해야 한다. 뒤 실습에서 이 부분을 다시 짚는다.

Vendor(구글) 쪽 설정 — Authorization Server에는 뭘 등록하나

벤더 쪽에서는 OAuth 클라이언트를 생성하고 네 가지를 설정한다. OAuth 클라이언트 ID/secret 발급, 승인된 redirect URI 등록, OAuth 동의 화면(consent screen) 구성, 그리고 사용할 API 활성화다. 구글은 이를 Google Cloud Console에서 처리한다.

구글 기준 실제 절차는 이렇다. 먼저 API Console에서 프로젝트를 만들고, “OAuth 2.0 클라이언트 ID”를 웹 애플리케이션 타입으로 생성한다. 이때 승인된 리다이렉션 URI에 우리 서버의 콜백 주소(http://localhost:8080/login/oauth2/code/google 등)를 등록한다. 이어 OAuth 동의 화면에 앱 이름과 요청 권한을 설정하면, 사용자가 로그인할 때 “이 앱이 email·profile에 접근하려 합니다”라는 동의 창이 뜬다.

구글의 실제 엔드포인트는 인가용 https://accounts.google.com/o/oauth2/v2/auth, 토큰 교환용 https://oauth2.googleapis.com/token이다. 다행히 Spring Security는 구글·깃허브 같은 주요 벤더의 엔드포인트를 CommonOAuth2Provider에 미리 내장하고 있어, 우리가 이 URL들을 일일이 적을 필요가 없다. client-id와 client-secret만 넣으면 나머지는 자동으로 채워진다. 이 편의성 덕분에 실습 설정이 몇 줄로 끝난다.

OAuth 2.0 전체 흐름 — Authorization Code Grant 한 바퀴

가장 널리 쓰이는 authorization code grant는 여섯 단계로 한 바퀴 돈다. 사용자가 로그인 버튼을 누르면 인가 서버로 리다이렉트되고, 사용자가 동의하면 인가 코드가 우리 서버로 돌아오며, 그 코드를 백엔드에서 토큰으로 교환한 뒤, 토큰으로 사용자 정보나 API를 호출한다. 순서대로 짚으면 이렇다.

  1. 인가 요청: 클라이언트가 사용자를 인가 서버로 보낸다. URL에 response_type=code, client_id, redirect_uri, scope, state를 담는다.
  2. 사용자 인증·동의: 사용자가 인가 서버(구글)에서 로그인하고 권한 위임에 동의한다.
  3. 인가 코드 발급: 인가 서버가 redirect URI로 사용자를 되돌리며 임시 code를 붙여 준다.
  4. 토큰 교환: 클라이언트 백엔드가 그 code와 client-secret을 토큰 엔드포인트로 보낸다. 이 단계는 secret이 오가므로 반드시 서버 쪽에서 일어난다.
  5. 토큰 발급: 인가 서버가 검증 후 access token(과 필요 시 id token, refresh token)을 반환한다.
  6. 자원 접근: 클라이언트가 access token으로 Resource Server의 API를 호출한다.

이 대목에서 왜 코드를 한 번 더 교환하는가라는 의문이 자연스럽게 든다. access token을 바로 주지 않고 code를 거치는 이유는, 토큰이 브라우저 주소창(프런트)에 노출되지 않게 하기 위해서다. code는 짧게 살고 1회용이며, 실제 토큰 교환은 client-secret을 아는 백엔드에서만 가능하다. 그래서 토큰이 안전하게 서버에만 머문다. 이 흐름의 각 단계는 RFC 6749의 Authorization Code Grant에 명세돼 있다.

access token, refresh token, id token — 세 토큰의 용도

토큰이 헷갈리는 이유는 셋의 목적이 완전히 다른데 이름이 비슷해서다. 한 줄로 못박으면 **access token은 “무엇을 할 수 있는가”, id token은 “누구인가”, refresh token은 “다시 발급받기 위한 열쇠”**다. 각각을 뜯어본다.

토큰정의 위치용도받는 주체수명
access tokenOAuth 2.0API 호출 권한 증명Resource Server(API)짧음(분~시간)
refresh tokenOAuth 2.0access token 재발급Authorization Server김(일~주)
id tokenOIDC사용자 신원 증명(JWT)Client(앱)짧음

access token은 API를 부를 때 쓴다. Authorization: Bearer {token} 헤더에 실어 보내고, Resource Server가 이 토큰을 읽어 “이 요청이 허용된 범위 안인가”를 판단한다. 즉 API가 읽고 검증하는 토큰이다.

refresh token은 access token이 만료됐을 때 사용자를 다시 로그인시키지 않고 새 access token을 받기 위한 토큰이다. 수명이 길고 민감하므로 Resource Server로는 절대 보내지 않고 Authorization Server하고만 주고받는다(Auth0 설명). access token은 짧게, refresh token은 길게 두는 이 구조가 “탈취돼도 피해를 최소화”하는 핵심 설계다.

id token은 OAuth가 아니라 OIDC에서 정의된 토큰으로, “이 사용자가 누구인지”를 담은 JWT다. access token이 API를 위한 것이라면 id token은 클라이언트 앱을 위한 것이라, API로 보내는 게 아니라 앱이 직접 열어 사용자 정보를 확인한다. 이 셋의 차이를 oauth.net은 “id token은 클라이언트에게, access token은 API에게 보내는 것”이라고 요약한다. id token이 나온 김에, 그 실체인 JWT부터 뜯어본다.

JWT 뜯어보기 — id token 안에는 뭐가 들었나

JWT(JSON Web Token)는 id token이 담기는 형식으로, 점(.)으로 나뉜 세 부분 — header, payload, signature로 구성된다. 각 부분은 Base64URL로 인코딩돼 있어 겉보기엔 알 수 없는 문자열이지만, 디코드하면 그냥 JSON이다. 실제 id token 하나를 펼치면 아래 모양이다.

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9   ← header
.
eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJzdWIiOiIxMDkwLi4uKOq1rOq4gCBzdWIsIDIx7J6Q66asIOyIq-yekCkiLCJhdWQiOiJteS1hcHAtY2xpZW50LWlkIiwiZXhwIjoxNzM1MDAwMDAwfQ   ← payload
.
NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-RU8VNbftc4TSMb4bXP3l3YlNWACw...(생략)   ← signature
Plaintext

세 덩어리가 각각 점으로 이어져 있다. 이 문자열을 점 기준으로 잘라 앞 두 부분을 Base64URL 디코드하면 아래처럼 사람이 읽을 수 있는 JSON이 나온다.

// header (Base64URL 디코드)
{ "alg": "RS256", "typ": "JWT" }

// payload (Base64URL 디코드) — 클레임 (값은 형식을 보여주는 예시)
{
  "iss": "https://accounts.google.com",  // 발급자
  "sub": "1090...(구글 sub, 21자리 숫자)",  // 사용자 고유 ID (핵심)
  "aud": "my-app-client-id",              // 대상 (이 토큰을 받을 클라이언트)
  "exp": 1735000000,                      // 만료 시각 (Unix time)
  "iat": 1734996400,                      // 발급 시각
  "email": "user@example.com"
}
JSON

header는 토큰의 메타데이터다. 서명 알고리즘 alg(구글은 RS256, 즉 RSA 공개키 서명)와 타입 typ(항상 JWT)이 들어간다. payload는 앞서 본 클레임의 집합으로, sub(사용자 ID)·iss(발급자)·aud(대상)·exp(만료)·iat(발급 시각) 같은 표준 클레임과 email 같은 추가 클레임이 담긴다. 세 번째 signature는 header와 payload를 발급자의 키로 서명한 값으로, “이 토큰이 위조되지 않았음”을 증명한다.

여기서 반드시 기억할 함정이 하나 있다. payload는 인코딩된 것이지 암호화된 게 아니다. Base64URL은 누구나 디코드해 읽을 수 있으므로, 비밀번호나 민감 정보를 JWT payload에 넣으면 안 된다(jwt.io). 그럼 왜 안전한가 하면, 내용을 감추는 게 목적이 아니라 변조를 막는 것이 목적이기 때문이다. 누가 payload를 고치면 signature 검증이 깨져 바로 들통난다.

검증은 서명으로 한다. RS256의 경우 발급자(구글)가 개인키로 서명하고, 클라이언트는 발급자가 공개한 공개키(JWKS)로 서명을 검증한다. Spring Security는 id token을 받으면 이 공개키로 서명을 확인하고 exp(만료)·aud(대상)·iss(발급자)까지 검증한 뒤에야 OidcUser로 만들어 준다. 즉 우리가 user.getSubject()sub를 읽을 수 있다는 건, 그 JWT가 이미 위조·만료 검증을 통과했다는 뜻이다.

여기서 자연스러운 의문이 든다. 그 공개키는 어디서 얻는가? 바로 jwk-set-uri다. 이건 발급자의 공개키 목록(JWKS)을 받아올 주소로, 구글은 https://www.googleapis.com/oauth2/v3/certs다. 앞의 최소 설정(application.yml)에는 이 값을 직접 적지 않았는데, 구글의 경우 CommonOAuth2Provider.GOOGLE이 authorization-uri·token-uri와 함께 이 jwk-set-uri까지 내장하고 있어 자동으로 채워지기 때문이다. 그래서 client-id·client-secret만 적어도 서명 검증이 동작한 것이다.

중요한 건, Spring Security가 공개키를 코드 안에 미리 갖고 있는 게 아니라는 점이다. 검증이 필요할 때 이 jwk-set-uri로 요청해 공개키를 받아온 뒤 캐시해 재사용한다. 왜 미리 안 갖고 있느냐면, 구글이 서명 키를 주기적으로 교체(rotation)하기 때문이다. 키를 하드코딩해 두면 구글이 키를 바꾸는 순간 검증이 전부 깨지지만, “키를 받아올 주소”만 두면 구글이 키를 갈아도 다음 조회 때 새 키를 자동으로 받아와 우리 앱은 코드 수정 없이 계속 검증할 수 있다. 매번 네트워크 요청을 하는 것도 아니라서, 받아온 키는 캐시하고 모르는 키가 등장할 때만 다시 조회한다.

참고로 API를 호출할 때 쓰는 access token도 벤더에 따라 JWT 형식일 수 있다(구글은 불투명 문자열, 일부 벤더는 JWT). JWT든 아니든 Authorization: Bearer {token} 헤더에 실어 보내는 사용법은 같다. JWT의 표준 구조와 클레임은 RFC 7519에 정의돼 있다. 이제 이 id token을 발급하는 주체인 OIDC로 넘어간다.

OIDC란 무엇인가 — OAuth 위에 얹은 “신원” 계층

OIDC(OpenID Connect)는 OAuth 2.0 위에 “인증(신원 확인)”을 얹은 프로토콜이다. OAuth 2.0은 “이 앱이 무엇을 할 수 있는가(인가)”만 표준화했을 뿐, “지금 로그인한 사용자가 누구인가”는 정의하지 않았다. 그 빈칸을 채우는 게 OIDC다. 그래서 “구글로 로그인” 같은 소셜 로그인은 사실 OAuth가 아니라 OIDC를 쓰는 경우가 대부분이다.

OIDC가 OAuth와 다른 점은 세 가지로 압축된다. 첫째, 요청 scope에 openid를 넣으면 인가 서버가 OIDC 모드로 동작해 **id token(JWT)**을 함께 발급한다. 둘째, 이 id token 안에는 사용자를 식별하는 표준 클레임이 들어 있다. 셋째, 더 많은 프로필 정보가 필요하면 UserInfo 엔드포인트를 호출해 받는다. 즉 OIDC = OAuth 2.0(인가) + id token(신원) + UserInfo(프로필)인 셈이다.

id token에 담기는 표준 클레임은 이름이 정해져 있다. 실무에서 자주 읽는 것만 추리면 다음과 같다.

클레임의미
sub사용자 고유 식별자(subject) — 가장 중요, 사용자 PK로 쓴다
iss토큰 발급자(issuer) — 예: https://accounts.google.com
aud대상(audience) — 이 토큰을 받을 클라이언트 id
exp / iat만료 시각 / 발급 시각
email사용자 이메일(scope에 email 포함 시)

한 문장으로 압축하면, 단순히 API 권한만 필요하면 OAuth 2.0으로 충분하고, “로그인한 사용자가 누구인지” 알아야 하면 OIDC를 쓴다. 소셜 로그인을 붙인다면 사실상 OIDC를 쓰는 것이다. OIDC 흐름의 표준 설명은 OpenID 공식 문서에 정리돼 있다. 이제 이 개념을 Spring Boot로 실제 구현한다.

Spring Boot로 구글 로그인 구현하기 — 의존성과 설정

Spring Boot에서 OAuth 2.0 로그인은 의존성 하나와 설정 몇 줄이면 동작한다. OAuth2 클라이언트 스타터를 추가하고, application.yml에 client-id·client-secret만 넣으면 나머지 엔드포인트는 CommonOAuth2Provider가 자동으로 채운다. 아래는 Spring Boot 4.1 기준 실제 구성이다.

// build.gradle (Spring Boot 4.1)
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    // OAuth 2.0 / OIDC 클라이언트 기능
    implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-client'
}
Groovy

여기서 스타터 이름을 눈여겨봐야 한다. Spring Boot 4.x부터 스타터 이름이 바뀌었다. 3.x에서 쓰던 spring-boot-starter-webspring-boot-starter-webmvc로, spring-boot-starter-oauth2-clientspring-boot-starter-security-oauth2-client로 이름이 변경됐다(위 build.gradle은 start.spring.io에서 실제로 생성해 확인한 4.1.1 기준이다). 이 OAuth2 클라이언트 스타터 하나가 OAuth 2.0 Client와 OIDC 로그인 기능을 모두 가져온다. 다음은 벤더 등록 정보를 담는 설정이다.

# application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          google:                          # registrationId (콜백 URI에 들어감)
            client-id: ${GOOGLE_CLIENT_ID}       # 환경변수로 주입 (하드코딩 금지)
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope:                          # openid를 넣어 OIDC 활성화
              - openid
              - profile
              - email
YAML

눈여겨볼 대목은 scopeopenid를 넣은 것이다. 이 한 줄로 구글이 id token까지 발급하는 OIDC 모드로 동작한다. provider(google)의 authorization-uri·token-uri 같은 엔드포인트는 적지 않았는데, CommonOAuth2Provider.GOOGLE이 이 값들을 내장하고 있어 생략 가능하다(Spring Security 공식 문서). client-id·client-secret은 코드에 직접 쓰지 않고 환경변수로 주입한다.

SecurityConfig와 사용자 정보 조회 — 실행 가능한 전체 코드

이제 보안 설정과 로그인한 사용자 정보를 확인하는 컨트롤러를 붙이면 끝이다. 아래 두 클래스는 그대로 합치면 컴파일·실행되는 완결 코드다. oauth2Login()을 켜면 Spring Security가 앞서 설명한 authorization code 흐름 전체를 자동으로 처리한다.

package com.example.oauthdemo;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // "/", "/login"은 누구나, 나머지는 인증 필요
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/error").permitAll()
                .anyRequest().authenticated()
            )
            // 이 한 줄이 OAuth2/OIDC 로그인 전체 흐름을 활성화한다
            .oauth2Login(login -> login.defaultSuccessUrl("/me", true));
        return http.build();
    }
}
Java

oauth2Login()을 호출하면 Spring Security가 /oauth2/authorization/google 진입점과 /login/oauth2/code/google 콜백을 자동으로 만든다. 후자가 바로 앞에서 구글에 등록해야 한다고 강조한 redirect URI다. 로그인이 끝나면 /me로 보내도록 defaultSuccessUrl을 지정했다. 다음은 로그인한 사용자 정보를 꺼내는 컨트롤러다.

package com.example.oauthdemo;

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
public class MeController {

    // OIDC 로그인 결과는 OidcUser로 주입된다 (id token 클레임 포함)
    @GetMapping("/me")
    public Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
        return Map.of(
            "sub", user.getSubject(),          // id token의 sub 클레임 (사용자 고유 ID)
            "email", user.getEmail(),          // email 클레임
            "name", user.getFullName(),        // 프로필 이름
            "issuer", user.getIssuer().toString()  // iss 클레임 (발급자)
        );
    }
}
Java

여기서 봐야 할 건 @AuthenticationPrincipal OidcUser다. openid scope로 로그인했기 때문에 principal이 OidcUser 타입으로 들어오고, id token의 클레임(sub, email, iss 등)을 바로 읽을 수 있다. 만약 openid 없이 순수 OAuth2로만 로그인했다면 OAuth2User 타입이 되고 id token은 없다. 이 차이가 앞서 설명한 OAuth와 OIDC의 실물 차이다.

실제로 위 코드를 그대로 Spring Boot 4.1 프로젝트에 넣고 띄워, OAuth 흐름이 동작하는지 확인해 봤다. /me에 인증 없이 접근하면 로그인으로 리다이렉트되고(302), 로그인 진입점은 Spring Security가 조립한 구글 인가 요청 URL로 다시 리다이렉트된다. 그 URL에 본문에서 설명한 response_type=code, scope=openid profile email, redirect_uri가 그대로 담겨 나오는 것을 볼 수 있다.

Spring Boot OAuth 2.0 실행 캡처 — 앱 부팅 후 /me 미인증 접근이 로그인으로 리다이렉트되고 구글 인가 요청 URL에 response_type=code·scope·redirect_uri가 담긴 결과
Spring Boot OAuth 2.0 실행 캡처 — 앱 부팅 후 /me 미인증 접근이 로그인으로 리다이렉트되고 구글 인가 요청 URL에 response_type=code·scope·redirect_uri가 담긴 결과

캡처의 두 번째 curl 결과가 이 글의 흐름도 1~3단계를 그대로 증명한다. Spring Security가 redirect_uri{baseUrl}/login/oauth2/code/google 패턴으로 자동 생성했고(구글에 등록해야 하는 바로 그 값), state·nonce·code_challenge까지 붙여 CSRF와 코드 가로채기를 방어한다. 여기까지 확인되면 남은 건 구글에서 실제 client-id·client-secret을 발급받아 넣는 것뿐이고, 그러면 로그인 후 /me가 id token의 클레임(sub, email 등)을 JSON으로 돌려준다.

실무에서 자주 막히는 지점 — redirect URI와 secret 관리

마지막으로 실습에서 십중팔구 부딪히는 두 가지를 짚는다. 첫째는 redirect_uri_mismatch다. 앞서 강조했듯 구글에 등록한 승인된 리다이렉션 URI와 Spring이 실제로 쓰는 콜백 주소(http://localhost:8080/login/oauth2/code/google)가 스킴·대소문자·슬래시까지 완전히 같아야 한다. 로컬은 http, 배포 환경은 https라 도메인이 바뀌면 두 URI를 모두 등록해야 한다.

둘째는 client-secret 노출이다. secret은 토큰 교환에 쓰이는 비밀 값이라 절대 프런트엔드 코드나 git 저장소에 커밋하면 안 된다. 위 설정에서 ${GOOGLE_CLIENT_SECRET}처럼 환경변수로 주입한 이유가 이것이다. 또 하나, authorization code 흐름을 백엔드에서 처리하는 이유도 결국 secret을 서버 밖으로 내보내지 않기 위해서라는 점을 기억하면, 왜 프런트에서 토큰을 직접 받으면 안 되는지가 자연스럽게 이해된다.

정리하면 OAuth 2.0/OIDC를 붙일 때 사고의 대부분은 “URI 불일치”와 “secret 관리”에서 난다. 이 둘만 원칙대로 지키면 Spring Security가 나머지 복잡한 흐름을 알아서 처리해 준다.

OAuth로 안 되는 것 — SAML은 언제 쓰나

여기까지 OAuth 2.0과 OIDC를 다뤘지만, 인증 세계에는 다른 표준도 있다. 대표적인 것이 SAML(Security Assertion Markup Language)이다. 결론부터 말하면, API·모바일·SPA를 다룬다면 OAuth 2.0/OIDC, 사내 SSO나 레거시 엔터프라이즈 IdP 연동이라면 SAML 쪽이다. 셋의 성격을 한눈에 비교하면 이렇다.

항목OAuth 2.0OIDCSAML 2.0
주 목적인가(권한 위임)인증(신원)인증·SSO
포맷JSON / JWTJSON / JWTXML
등장201220142005
강한 영역API 권한 위임모바일·SPA 로그인엔터프라이즈 웹 SSO
대표 사용처제3자 API 접근소셜 로그인사내 SSO·SaaS 연동
OAuth 2.0·OIDC·SAML 성격 비교 — 셋은 대체재가 아니라 쓰임이 다른 표준이다

핵심 갈림길은 “누가 쓰느냐”다. SAML은 2005년에 나온 XML 기반 표준으로, 지금도 엔터프라이즈 SSO에서 가장 널리 배포된 프로토콜이다. 회사에 로그인 한 번 하면 사내 여러 SaaS에 자동 로그인되는 그 구조가 대부분 SAML이다. 반면 OAuth/OIDC는 JSON·JWT 기반이라 모바일 앱, SPA, API 접근처럼 가볍고 현대적인 환경에 강하다(SAML vs OAuth 비교).

실무에서는 셋 중 하나만 고르는 게 아니라 함께 쓰는 경우가 많다. 사내 웹 앱 SSO는 SAML, API 레벨 접근은 OAuth, 모바일·소셜 로그인은 OIDC를 각각 붙이는 식이다([관련 논의](https://ssojet.com/blog/saml-vs-oidc-vs-oauth-2-0-12-differences-every-b2b-engineering-team-should-know)). 그래서 “OAuth vs SAML 중 뭐가 이기나”라는 질문보다, “지금 붙이려는 게 API 위임인가, 소셜 로그인인가, 사내 SSO인가”를 먼저 따지는 편이 정확하다. SAML의 구체적인 연동 방법(Spring Security SAML2, IdP 메타데이터, XML 서명 검증 등)은 이 글의 범위를 넘어서므로, 별도 글에서 따로 다룬다.

FAQ

OAuth 2.0과 OIDC는 뭐가 다른가요?

OAuth 2.0은 “무엇을 할 수 있는가(인가)”를, OIDC는 “누구인가(인증)”를 다룬다. OIDC는 OAuth 2.0 위에 얹은 신원 계층으로, scope에 openid를 넣으면 활성화되며 사용자 신원을 담은 id token(JWT)을 함께 발급한다. 소셜 로그인처럼 “로그인한 사용자가 누구인지” 알아야 하면 OIDC를, 단순히 API 권한만 필요하면 OAuth 2.0을 쓴다.

access token과 id token은 어떻게 다른가요?

access token은 API를 호출할 권한을 증명하는 토큰으로 Resource Server(API)가 읽고 검증한다. id token은 사용자가 누구인지 증명하는 JWT로 클라이언트 앱이 열어서 사용한다. 한마디로 access token은 API에게, id token은 앱에게 보내는 것이다. access token은 OAuth 2.0에서, id token은 OIDC에서 정의된다.

refresh token은 왜 필요한가요?

access token은 탈취 피해를 줄이려고 수명을 짧게 둔다. 그러면 만료 때마다 사용자를 다시 로그인시켜야 하는데, 이를 피하려고 쓰는 게 refresh token이다. refresh token으로 새 access token을 받아오면 사용자는 재로그인할 필요가 없다. 다만 refresh token은 민감하므로 Authorization Server하고만 주고받고 API 서버로는 보내지 않는다.

redirect URI가 왜 정확히 일치해야 하나요?

인가 코드가 엉뚱한 곳으로 새는 것을 막기 위한 보안 장치다. 구글은 등록된 승인 리다이렉션 URI와 요청의 redirect_uri가 스킴(http/https)·대소문자·끝 슬래시까지 모두 같아야 하고, 하나라도 다르면 redirect_uri_mismatch 에러를 낸다. Spring Security는 {baseUrl}/login/oauth2/code/{registrationId} 패턴을 쓰므로 이 값을 그대로 벤더에 등록해야 한다.

Spring Boot에서 OAuth 로그인에 최소로 필요한 설정은 무엇인가요?

OAuth2 클라이언트 스타터(Spring Boot 4.x 기준 spring-boot-starter-security-oauth2-client) 의존성 하나와 application.yml의 client-id·client-secret 두 값이면 된다. 구글·깃허브 같은 주요 벤더는 엔드포인트가 CommonOAuth2Provider에 내장돼 있어 authorization-uri 같은 값을 직접 적지 않아도 된다. scopeopenid를 넣으면 OIDC 로그인이 되어 id token까지 받는다.

SAML과 OAuth 2.0은 뭐가 다르고 언제 쓰나요?

SAML은 2005년에 나온 XML 기반 표준으로 엔터프라이즈 SSO(사내 로그인 한 번으로 여러 앱 접근)에 강하고, OAuth 2.0/OIDC는 JSON·JWT 기반이라 API 권한 위임과 모바일·SPA·소셜 로그인에 강하다. API를 다루면 OAuth, 소셜 로그인이면 OIDC, 사내 SSO나 레거시 IdP 연동이면 SAML을 쓴다. 셋은 대체재가 아니라 쓰임이 다르며, 실무에서는 함께 쓰는 경우가 많다.

마치며

지금까지 OAuth 2.0의 구성요소부터 전체 흐름, 세 토큰의 용도, OIDC와의 관계, 그리고 Spring Boot 실습까지 정리해 보았다. 개인적으로 OAuth를 처음 붙일 때 가장 헷갈렸던 건 “왜 코드를 한 번 더 교환하지?”와 “id token이랑 access token이 왜 둘 다 오지?”였는데, 결국 둘 다 “secret과 토큰을 서버 밖으로 내보내지 않으려는” 설계에서 나온 장치라는 걸 알고 나니 흐름이 한 번에 꿰였다. 실습에서는 redirect URI 하나 때문에 한참을 헤맸던 기억이 있어서, 이 글에서는 그 지점을 특히 힘주어 적었다. 처음이라면 구글 로그인부터 붙여 보고, id token 안에 뭐가 들었는지 /me로 직접 찍어 보길 권한다. 토큰의 실체를 눈으로 보면 개념이 훨씬 빠르게 잡힌다.