
OAuth2 JWT 구조에서 가장 헷갈리는 부분은 소셜 로그인 성공 뒤에도 왜 우리 서비스 token을 다시 만들냐는 점입니다. 구글이나 카카오가 이미 로그인해 줬는데 또 JWT를 발급하는 것이 중복처럼 보일 수 있습니다.
하지만 둘의 책임은 다릅니다. 소셜 로그인은 사용자가 누구인지 확인하는 입구이고, 자체 JWT는 우리 서비스 API를 어떤 권한으로 쓸 수 있는지 표현하는 토큰입니다.

OAuth2 JWT 구조를 나눠서 보기
OAuth2 소셜 로그인은 외부 provider가 사용자의 신원을 확인해 주는 흐름입니다. 사용자는 구글이나 카카오 화면에서 인증하고, 우리 서비스는 그 결과로 provider user info를 받습니다.
그다음 우리 서비스는 그 사용자를 내부 member로 매핑해야 합니다. 이미 가입한 사용자라면 기존 계정을 찾고, 처음 들어온 사용자라면 회원 레코드를 만들거나 추가 동의를 받아야 합니다.
Provider token을 그대로 쓰면 안 되는 이유
외부 provider가 발급한 token은 그 provider와의 관계에서 의미가 있습니다. 우리 서비스의 게시글 작성, 주문 조회, 관리자 권한 같은 도메인 권한을 직접 표현하지 않습니다.
- provider token은 구글/카카오 API 접근 권한과 관련된다
- 우리 서비스 권한은 내부 member, role, policy 기준으로 정해야 한다
- provider token 만료와 우리 서비스 세션 정책은 다를 수 있다
- 로그아웃, 기기 관리, refresh token 회전 정책도 서비스가 별도로 가져가야 한다
로그인 성공 후 흐름
1. 사용자가 Google/Kakao 로그인 버튼을 누른다
2. provider 인증 화면을 거친다
3. Spring Security OAuth2 Login이 사용자 정보를 받는다
4. 우리 서비스 member와 매핑한다
5. 자체 access token과 refresh token을 발급한다
6. 이후 API 요청은 자체 JWT로 인증한다SuccessHandler에는 무엇을 둘까
Spring Security OAuth2 Login에서는 로그인 성공 후 후처리 지점이 필요합니다. 이때 principal에서 provider 정보와 이메일, provider id 같은 식별자를 꺼내 내부 member와 연결합니다.
다만 SuccessHandler에 모든 비즈니스 로직을 몰아넣으면 유지보수가 어렵습니다. SuccessHandler는 흐름을 조립하고, member 조회/생성, token 발급, refresh token 저장은 별도 서비스로 나누는 편이 좋습니다.
@Component
public class OAuth2LoginSuccessHandler
extends SimpleUrlAuthenticationSuccessHandler {
private final MemberService memberService;
private final TokenService tokenService;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication
) throws IOException {
OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal();
Member member = memberService.findOrCreate(oauth2User);
TokenPair tokens = tokenService.issue(member.id());
response.sendRedirect("/login/success?accessToken=" + tokens.accessToken());
}
}Access token과 Refresh token 책임
Access token은 API 요청마다 인증 정보를 확인하는 짧은 토큰입니다. Refresh token은 access token이 만료됐을 때 재발급을 허용할지 판단하는 더 긴 토큰입니다.
- access token: 짧게 유지하고 요청 인증에 사용한다
- refresh token: DB나 Redis에 저장해 폐기와 회전을 관리한다
- 로그아웃: 저장된 refresh token을 폐기한다
- 탈취 대응: 재사용 감지, 기기별 token, 회전 전략을 검토한다
실무에서 조심할 지점
- email만으로 계정을 합치기 전에 provider와 verified 상태를 확인한다
- 카카오처럼 email 제공 동의가 없을 수 있는 상황을 처리한다
- redirect URL에 token을 직접 싣는 방식의 노출 위험을 검토한다
- 프론트와 쿠키, header, storage 정책을 미리 합의한다
- OAuth2 login 세션과 stateless API 인증 경계를 분명히 나눈다
핵심 기준
OAuth2 로그인 후 JWT를 따로 만드는 이유는 간단합니다. provider는 사용자의 신원을 확인하고, 우리 서비스 JWT는 우리 서비스의 권한을 표현하기 때문입니다.
구글 로그인을 통과했다고 해서 자동으로 우리 서비스의 관리자, 작성자, 유료 사용자 권한이 생기는 것은 아닙니다. 그 권한은 내부 member와 role 기준으로 다시 판단해야 합니다.

provider token과 서비스 token의 차이
provider token은 구글이나 카카오 API를 호출할 때 의미가 있습니다. 예를 들어 사용자 프로필을 가져오거나, 동의한 scope 안에서 외부 API를 호출할 때 쓰입니다.
반면 서비스 token은 우리 서버가 발급합니다. 이 token에는 우리 서비스 사용자 id, role, token 만료 시간, 발급자 같은 정보를 담을 수 있습니다. API 서버는 이 token을 보고 접근 가능 여부를 판단합니다.
- provider token: 외부 provider와의 관계에서 의미가 있다
- service access token: 우리 API 요청 인증에 쓴다
- service refresh token: 우리 서비스의 재발급 정책에 쓴다
- member mapping: provider 사용자와 내부 사용자를 연결한다
처음 로그인한 사용자 처리
소셜 로그인 성공 뒤 바로 JWT를 발급해도 되는지는 내부 사용자 상태에 따라 달라집니다. 처음 들어온 사용자라면 약관 동의, 닉네임 설정, 추가 정보 입력이 필요할 수 있습니다.
OAuth2 login success
-> provider user info
-> find member by provider + providerUserId
-> if not exists: signup-required
-> if exists: issue service tokensemail만 믿고 계정을 합치면 위험하다
email은 편리하지만, 모든 provider에서 항상 제공되거나 항상 검증된 값이라고 단정하면 안 됩니다. provider id와 provider name을 함께 저장하는 편이 안전합니다.
- provider: google, kakao, naver 같은 출처
- providerUserId: provider 안에서의 사용자 식별자
- email: 보조 식별자 또는 연락 수단
- emailVerified: provider가 검증 여부를 제공할 때만 사용
Refresh token은 발급보다 운영이 중요하다
Refresh token은 길게 살아남기 때문에 저장과 폐기 정책이 핵심입니다. 단순히 긴 JWT를 하나 더 만드는 것으로 끝내면 로그아웃, 탈취 대응, 기기별 세션 관리가 어려워집니다.

- 로그인 성공 시 refresh token을 저장한다
- 재발급 요청 때 저장된 token과 요청 token을 비교한다
- 재발급 성공 시 refresh token 회전 여부를 결정한다
- 로그아웃 시 저장된 refresh token을 폐기한다
- 비정상 재사용이 감지되면 전체 세션 폐기를 검토한다
프론트와 합의해야 할 것
백엔드 구조만 정해도 구현은 끝나지 않습니다. 프론트가 access token을 어디에 둘지, refresh token을 쿠키로 둘지, 로그인 성공 후 redirect를 어떻게 처리할지 같이 정해야 합니다.
- access token을 memory에 둘지 storage에 둘지
- refresh token을 HttpOnly cookie로 둘지
- CORS와 SameSite 정책을 어떻게 잡을지
- 로그인 성공 redirect에 token을 직접 노출할지
- 모바일 앱에서는 secure storage를 어떻게 쓸지
웹과 모바일은 저장 전략이 다르다
웹 SPA, 서버 렌더링 웹, 모바일 앱은 token 저장 방식이 다릅니다. 같은 백엔드라도 클라이언트 유형에 따라 전달 방식과 재발급 흐름을 다르게 설계해야 합니다.
- 웹 SPA: access token 노출 위험과 refresh cookie 정책을 함께 본다
- 서버 렌더링 웹: 서버 세션을 유지할지 JWT를 쓸지 먼저 결정한다
- 모바일 앱: secure storage와 refresh token 폐기 흐름을 검토한다
- 관리자 페이지: 일반 사용자 API보다 짧은 만료와 추가 인증을 검토한다
로그인 완료 응답 형태
소셜 로그인 성공 후 응답을 어떻게 돌려줄지도 중요합니다. redirect URL query에 access token을 직접 싣는 방식은 구현은 쉽지만 노출 면에서 조심해야 합니다.
선택지 A: redirect + one-time code
/login/success?code=abc
-> 프론트가 code를 서버에 전달
-> 서버가 token pair 발급
선택지 B: HttpOnly refresh cookie + short access token
Set-Cookie: refresh_token=...
body: { accessToken: "..." }
선택지 C: 모바일 deep link
app://login/success?code=abc
-> 앱이 code 교환 요청토큰 발급 서비스의 입력값
TokenService는 OAuth2User 전체를 그대로 받기보다, 내부 member id와 role처럼 발급에 필요한 값만 받는 편이 좋습니다. 그래야 provider별 속성 구조가 token 발급 로직으로 새지 않습니다.
public TokenPair issue(Member member) {
Instant now = clock.instant();
String accessToken = jwtSigner.sign(
Claims.of(member.id(), member.role(), now.plusMinutes(15))
);
String refreshToken = refreshTokenStore.create(member.id(), now.plusDays(14));
return new TokenPair(accessToken, refreshToken);
}실패 응답도 설계한다
소셜 로그인은 외부 provider와 네트워크를 거칩니다. 성공 흐름만 만들면 장애 때 사용자가 어디에서 막혔는지 알기 어렵습니다.
- provider 인증 실패: 다시 로그인 안내
- email 미제공: 추가 정보 입력 화면
- 이미 다른 provider로 가입됨: 계정 연결 안내
- refresh token 저장 실패: 로그인 실패 처리와 재시도
- redirect URI 불일치: 서버 설정 오류로 별도 로깅
테스트 케이스
- 기존 사용자가 구글로 로그인하면 기존 member에 매핑된다
- 신규 사용자는 signup-required 상태로 이동한다
- provider id가 같고 email이 바뀌어도 같은 사용자로 처리된다
- refresh token 재사용이 감지되면 기존 token을 폐기한다
- 로그아웃 후 refresh token으로 재발급할 수 없다
정리
소셜 로그인 뒤 자체 JWT를 발급하는 이유는 같은 로그인을 두 번 하기 위해서가 아닙니다. 외부 provider가 확인한 신원을 우리 서비스의 사용자와 권한 체계로 옮기는 과정입니다.
관련해서 내부 글은 Spring Boot JWT 인증 구현, Spring Security JWT와 OAuth2 Resource Server는 어떻게 다를까, FastAPI JWT 인증은 어디서 나눠야 할까와 함께 보면 좋습니다.
외부 기준은 Spring Security Reference – OAuth2 Login, Spring Security Reference – OAuth2 Resource Server JWT, RFC 7519 – JSON Web Token를 기준으로 확인했습니다.