공연 예매 서비스 Ticket Run의 회원 기능을 만들면서, 자체 회원가입 없이 카카오와 구글 로그인만 제공하기로 했다. (Spring Boot 4.1, Spring Security 7.1 기준)
처음 헷갈렸던 지점은 두 가지였다. 카카오와 구글이 같은 "소셜 로그인"인데 왜 처리 방식이 다른가, 그리고 소셜 로그인을 했는데 왜 토큰을 또 발급하는가였다.
카카오는 OAuth 2.0으로, 구글은 그 위에 신원 확인을 얹은 OpenID Connect(OIDC)로 연동한다. 하지만 핵심 차이는 제공자별 방식이 아니라, 소셜 로그인이 "누구인지 확인"까지만 맡고 API 접근 자격은 우리 서버가 발급한 JWT로 따로 증명한다는 점이다.
이 글에서는 카카오, 구글 앱 등록과 키 발급을 마쳤다는 가정하에, 제공자 연동부터 토큰 재발급과 권한 설정까지 실제 코드를 기준으로 정리한다.
간단히 정리하면
- oauth2Login : 인가 코드 흐름(리다이렉트, 콜백, 코드 교환) 처리
- CustomOAuth2UserService : 카카오(OAuth 2.0) 사용자 정보로 회원 조회, 가입
- CustomOidcUserService : 구글(OIDC) 사용자 정보로 회원 조회, 가입
- OAuth2LoginSuccessHandler : Refresh 토큰만 HttpOnly 쿠키로 심고 프론트로 리다이렉트
- JwtProvider + Redis : Access/Refresh 토큰 발급, 토큰 회전(Refresh Token Rotation, RTR)
- oauth2ResourceServer : 이후 API 요청의 Access 토큰 검증
제공자가 Spring에 내장되어 있다 → registration만 작성 (google)
내장되어 있지 않다 → registration + provider 작성 (kakao)
OIDC로 연동한다 → oidcUserService에 커스텀 서비스 등록 (google)
OAuth 2.0으로 연동한다 → userService에 커스텀 서비스 등록 (kakao)
로그인 한 번의 흐름은 다음 순서로 진행된다.
- 진입: 프론트가
/oauth2/authorization/kakao로 이동한다. - 인가: 사용자가 카카오에서 로그인, 동의하면
/login/oauth2/code/kakao로 인가 코드가 돌아온다. - 토큰 교환과 사용자 조회: Spring Security가 코드를 토큰으로 바꾸고 사용자 정보를 받아 온다.
- 회원 처리: 제공자별 서비스가 회원을 찾거나 새로 가입시킨다.
- 쿠키 발급:
OAuth2LoginSuccessHandler가 Refresh 토큰 쿠키를 심고 프론트로 리다이렉트한다. - Access 토큰 발급: 프론트가 곧바로
POST /api/auth/reissue를 호출해 Access 토큰을 받는다.
제공자 등록 설정
spring.security.oauth2.client는 어떤 제공자와 어떤 방식으로 통신할지 선언할 때 사용한다.
1~3단계 중 리다이렉트, 콜백 수신, 코드 교환은 Spring Security가 처리한다. 콜백 주소가 /login/oauth2/code/{registrationId}이므로 개발자 콘솔에도 이 주소를 등록해 둔다.
설정은 다음과 같이 작성할 수 있다.
spring:
security:
oauth2:
client:
# client-id, client-secret 은 application-secret.yml 에서 주입한다.
registration:
kakao:
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope:
- profile_nickname
- profile_image
- account_email
google:
# openid 가 있으면 OIDC 흐름이 되어 CustomOidcUserService 가 사용자 정보를 처리한다.
scope:
- openid
- profile
- email
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
구글은 짧고 카카오는 긴 이유는 Spring Security에 내장된 제공자(CommonOAuth2Provider) 목록 차이 때문이다. 목록에는 Google, GitHub, Facebook, X, Okta만 있어서, 카카오는 provider 아래에 인가, 토큰, 사용자 정보 주소를 직접 적는다.
카카오 쪽 설정에서 짚을 부분은 두 가지다.
client_secret_post: 카카오는 Client Secret을 사용하면 토큰 요청에client_secret을 포함해야 한다. 그래서 요청 본문 파라미터로 보내도록 지정했다.scope: 동의항목 ID인profile_nickname,profile_image,account_email을 그대로 적는다. 이 중 이메일 동의항목은 비즈 앱 또는 테스트 앱에서 설정할 수 있다.
키 값은 공통 설정에 쓰지 않는다. .gitignore에 등록된 application-secret.yml에만 두고, 팀원이 복사해 쓸 수 있도록 값을 비운 example 파일을 다음과 같이 커밋했다.
spring:
security:
oauth2:
client:
registration:
kakao:
client-id: NEED_TO_INPUT # 카카오 REST API 키
client-secret: NEED_TO_INPUT
google:
client-id: NEED_TO_INPUT
client-secret: NEED_TO_INPUT
실제 application-secret.yml에는 이 자리에 발급받은 값을 채운다. spring.profiles.include: secret으로 함께 로드되어, 공통 yml의 registration 설정과 합쳐진다.
즉, 흐름 자체는 Spring Security에 맡기고 우리는 "누구와, 어떤 정보를" 주고받을지만 선언한다.
사용자 정보 정규화와 회원 가입
그렇다면 카카오와 구글의 서로 다른 응답은 어디서 맞출까?
OAuth2UserInfo는 제공자마다 다른 응답을 회원 가입에 필요한 값으로 통일할 때 사용한다. 예를 들어 카카오는 식별자를 id에, 프로필을 kakao_account.profile 아래에 담고, 구글은 OIDC 클레임 sub, name, picture에 담는다.
public record OAuth2UserInfo(SocialProvider provider, String providerId, String email,
String nickname, String profileImageUrl) {
public static OAuth2UserInfo of(String registrationId, Map<String, Object> attributes) {
return switch (registrationId) {
case "kakao" -> ofKakao(attributes);
case "google" -> ofGoogle(attributes);
default -> throw new OAuth2AuthenticationException(new OAuth2Error(UNSUPPORTED_PROVIDER));
};
}
// { "id": 123, "kakao_account": { "email": "...", "profile": { "nickname": "...", "profile_image_url": "..." } } }
private static OAuth2UserInfo ofKakao(Map<String, Object> attributes) {
Map<String, Object> account = (Map<String, Object>) attributes.getOrDefault("kakao_account", Map.of());
Map<String, Object> profile = (Map<String, Object>) account.getOrDefault("profile", Map.of());
return new OAuth2UserInfo(SocialProvider.KAKAO, String.valueOf(attributes.get("id")),
(String) account.get("email"), (String) profile.get("nickname"),
(String) profile.get("profile_image_url"));
}
// { "sub": "...", "email": "...", "name": "...", "picture": "..." }
private static OAuth2UserInfo ofGoogle(Map<String, Object> attributes) {
return new OAuth2UserInfo(SocialProvider.GOOGLE, (String) attributes.get("sub"),
(String) attributes.get("email"), (String) attributes.get("name"),
(String) attributes.get("picture"));
}
}
registrationId(yml의 kakao, google)로 분기해 다섯 개의 값만 꺼낸다. 응답에서 빠질 수 있는 항목은 getOrDefault로 NullPointerException을 막고, 닉네임이 비면 compact constructor에서 "회원"으로 보정한다.
식별자는 이메일이 아니라 providerId를 쓴다. 구글 문서도 sub를 "모든 구글 계정에서 유일하고 재사용되지 않는 식별자"로 정의하고, 카카오 이메일은 사용자가 동의하지 않으면 응답에 포함되지 않기 때문이다.
이 값을 받는 서비스는 제공자 방식에 맞춰 두 개를 둔다. 카카오는 DefaultOAuth2UserService를, 구글은 OidcUserService를 상속한다.
// 구글(OIDC). 카카오용 CustomOAuth2UserService 도 같은 구조로 DefaultOAuth2UserService 를 상속한다.
@Override
public OidcUser loadUser(OidcUserRequest userRequest) {
OidcUser oidcUser = super.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2UserInfo userInfo = OAuth2UserInfo.of(registrationId, oidcUser.getAttributes());
MemberResponse member = memberService.findOrRegister(userInfo);
return OAuth2LoginMember.ofOidc(member.memberId(), member.role(), oidcUser);
}
super.loadUser가 제공자 사용자 정보를 가져오고, findOrRegister가 (provider, providerId)로 회원을 조회해 없으면 관객(AUDIENCE)으로 저장한다. 외부 HTTP 호출은 트랜잭션 밖에서 하고 가입 처리만 MemberService 트랜잭션에서 하도록 나눴다.
중복 가입은 애플리케이션 조회만으로 막지 않는다. Member에 (provider, provider_id) @UniqueConstraint를 걸어 DB에서도 보장한다.
다만, 구글의 ID 토큰 검증은 이 서비스가 하지 않는다. Spring Security 소스(main 브랜치) 기준으로 서명과 iss, aud, exp 검증은 OidcIdTokenDecoderFactory가 만든 디코더가, nonce 검증은 OidcAuthorizationCodeAuthenticationProvider가 맡는다.
OidcUserService는 그 뒤에 사용자 정보를 조회해 OidcUser를 만드는 역할이다.
두 제공자를 하나의 principal로
두 서비스가 반환하는 타입이 다르면 성공 핸들러도 두 갈래가 된다. 그래서 반환 타입을 OAuth2LoginMember 하나로 맞췄다.
// OIDC(구글)도 같은 성공 핸들러를 타도록 OidcUser 를 구현하며, OAuth2(카카오)에서는 idToken, userInfo 가 null 이다.
public record OAuth2LoginMember(Long memberId, Role role, Map<String, Object> attributes,
OidcIdToken idToken, OidcUserInfo userInfo) implements OidcUser {
public static OAuth2LoginMember ofOAuth2(Long memberId, Role role, Map<String, Object> attributes) {
return new OAuth2LoginMember(memberId, role, attributes, null, null);
}
public static OAuth2LoginMember ofOidc(Long memberId, Role role, OidcUser oidcUser) {
return new OAuth2LoginMember(memberId, role, oidcUser.getAttributes(), oidcUser.getIdToken(),
oidcUser.getUserInfo());
}
// getClaims, getAttributes, getAuthorities 등 OidcUser 메서드 구현 생략
@Override
public String getName() {
return String.valueOf(memberId);
}
}
OidcUser를 구현하면 OIDC 쪽 반환 타입을 만족하면서, 카카오 쪽에서는 ID 토큰 자리를 null로 둔 채 같은 타입으로 쓸 수 있다. 핵심 필드는 memberId 하나이고, 나머지는 인터페이스를 만족시키기 위한 값이다.
즉, 제공자가 늘어나도 OAuth2UserInfo에 분기 하나만 추가하면 회원 도메인과 성공 핸들러는 바뀌지 않는다.
로그인 성공 후 토큰 전달
AuthenticationSuccessHandler는 로그인 성공 직후의 응답을 직접 만들 때 사용한다.
가장 간단한 방법은 ?accessToken=...처럼 리다이렉트 URL에 토큰을 실어 보내는 것이다. 하지만 URL은 브라우저 기록이나 Referer 헤더로 새어 나갈 수 있고, OAuth 보안 권고(RFC 9700)도 Access 토큰을 URI 쿼리에 싣지 말라고 한다.
그래서 OAuth2LoginSuccessHandler는 Refresh 토큰만 쿠키로 심고 리다이렉트한다.
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2LoginMember loginMember = (OAuth2LoginMember) authentication.getPrincipal();
String refreshToken = authService.login(loginMember.memberId());
response.addHeader(HttpHeaders.SET_COOKIE, refreshTokenCookieProvider.create(refreshToken).toString());
response.sendRedirect(redirectUri);
}
principal이 항상 OAuth2LoginMember이므로 카카오, 구글 구분 없이 캐스팅 한 번으로 회원 ID를 꺼낸다. Access 토큰은 프론트 콜백 페이지(/oauth/callback)가 6단계의 재발급 요청으로 응답 본문에서 받는다.
실패 핸들러는 같은 주소로 ?error=login_failed만 붙여 보내, 브라우저가 백엔드 에러 화면에 머물지 않게 한다.
쿠키 속성은 RefreshTokenCookieProvider 한 곳에서 정한다.
private ResponseCookie.ResponseCookieBuilder baseCookie(String value) {
return ResponseCookie.from(COOKIE_NAME, value)
.httpOnly(true) // JS에서 읽을 수 없다.
.secure(true) // HTTPS로만 전송된다.
.sameSite("Lax") // 다른 사이트에서 시작된 POST 요청에는 실리지 않는다.
.path("/api/auth"); // 재발급, 로그아웃 요청에만 실린다.
}
성공 핸들러, 재발급, 로그아웃이 모두 같은 속성의 쿠키를 써야 덮어쓰기와 삭제가 정확히 동작하므로 생성 규칙을 한 클래스로 모았다. 만료 시간도 Refresh 토큰 만료(14일)와 맞췄다.
즉, URL에는 어떤 토큰도 싣지 않는다.
다만, Path는 쿠키가 실리는 범위를 줄일 뿐 보안 수단은 아니다. 탈취 방어는 HttpOnly와 아래의 토큰 회전이 맡는다.
JWT 발급과 token_type 검증
JwtProvider는 Spring Security의 NimbusJwtEncoder, NimbusJwtDecoder로 토큰을 발급, 검증할 때 사용한다. 코드에서 MacAlgorithm.HS256을 명시하고, HS256은 규격상 256비트 이상 키가 필요하므로 secret은 Base64로 인코딩한 32바이트 이상 값으로 둔다.
Access 토큰(30분)에는 회원 ID(sub)와 권한(role, 예: ROLE_AUDIENCE)을, Refresh 토큰(14일)에는 회원 ID와 jti(UUID)를 담는다. 두 토큰은 같은 키로 서명하므로 token_type 클레임으로 구분하고, 디코더마다 다음과 같이 검증기를 따로 붙였다.
private NimbusJwtDecoder createDecoder(SecretKey secretKey, String tokenType) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(secretKey)
.macAlgorithm(MacAlgorithm.HS256)
.build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefault(),
new JwtClaimValidator<String>(TOKEN_TYPE_CLAIM, tokenType::equals)));
return decoder;
}
기본 검증(만료 등)에 token_type 검증을 더해, Access 디코더는 access만, Refresh 디코더는 refresh만 통과시킨다.
이 검증이 없으면 Refresh 토큰을 Authorization: Bearer 헤더에 넣어도 서명이 같아 디코더를 통과한다. 지금은 Refresh 토큰에 role 클레임이 없어 인증 변환에서 실패하지만, 클레임 구성이 바뀌는 순간 14일짜리 토큰으로 API를 호출할 수 있게 된다.
즉, 키를 하나만 쓰더라도 토큰의 용도는 클레임으로 강제한다.
토큰 회전(RTR)
그렇다면 Refresh 토큰 자체가 탈취되면 어떻게 막을까?
RTR은 재발급할 때마다 Refresh 토큰도 새로 바꿔, 이전 토큰의 재사용을 탐지할 때 사용한다. Redis에는 회원당 현재 유효한 토큰 하나만 member:{id}:refresh-token 키로, Refresh 토큰 만료와 같은 TTL을 걸어 저장한다.
public AuthTokens reissue(String refreshToken) {
// ... 쿠키 없음(REFRESH_TOKEN_NOT_FOUND), 서명/만료 오류(INVALID_REFRESH_TOKEN) 검사 생략
String savedToken = refreshTokenRepository.findByMemberId(memberId)
.orElseThrow(() -> new MemberException(INVALID_REFRESH_TOKEN));
// 서명은 맞지만 저장값과 다르면 이미 회전된 토큰이 다시 쓰인 것이다.
// 탈취 가능성이 있으므로 현재 토큰까지 지워 정상 사용자도 다시 로그인하게 한다.
if (!savedToken.equals(refreshToken)) {
refreshTokenRepository.deleteByMemberId(memberId);
throw new MemberException(INVALID_REFRESH_TOKEN);
}
MemberResponse member = memberService.getMember(memberId);
String accessToken = jwtProvider.createAccessToken(memberId, member.role().getAuthority());
return new AuthTokens(accessToken, issueRefreshToken(memberId));
}
저장값과 같으면 새 Access 토큰과 새 Refresh 토큰을 발급하고, Redis 값도 새 토큰으로 덮어쓴다. 상황을 가정하면 다음과 같다.
공격자가 토큰 A 탈취 → 정상 사용자가 A로 재발급 → Redis: B
공격자가 A로 재발급 시도 → 저장값(B)과 불일치 → B까지 삭제, 공격자 실패, 정상 사용자 재로그인
RFC 9700도 무효화된 Refresh 토큰이 다시 제시되면 이를 탈취 신호로 보고, 현재 유효한 Refresh 토큰을 폐기하는 방식을 설명한다. 이 구현은 같은 원리를 서버 측 Redis 저장값 비교로 적용한 것이다.
jti를 넣은 이유도 여기에 있다. 같은 초에 두 번 재발급해도 토큰 값이 달라져야 "저장값과 다르다"는 비교가 의미를 갖는다.
즉, Redis를 화이트리스트로 써서 "가장 최근에 발급한 토큰만 유효하다"는 규칙을 서버가 강제한다.
다만, 로그아웃은 예외를 던지지 않는다. 토큰이 이미 만료됐어도 쿠키는 지워야 하므로, 저장값과 일치할 때만 Redis에서 삭제하고 쿠키는 항상 maxAge=0으로 덮어쓴다.
Security 설정 연결
SecurityFilterChain은 요청마다 어떤 인증 방식을 적용할지 연결할 때 사용한다. 여기서 소셜 로그인, JWT 검증, 역할별 권한을 하나의 체인에 묶는다.
http
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/error", "/oauth2/**", "/login/oauth2/**", "/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/organizer/**").hasRole("ORGANIZER")
.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
.oidcUserService(customOidcUserService))
.successHandler(oAuth2LoginSuccessHandler)
.failureHandler(oAuth2LoginFailureHandler))
.oauth2ResourceServer(resourceServer -> resourceServer
.bearerTokenResolver(bearerTokenResolver())
.jwt(jwt -> jwt
.decoder(jwtProvider::decodeAccessToken)
.jwtAuthenticationConverter(jwtProvider::toAuthentication)));
userService와 oidcUserService를 각각 등록해야 카카오와 구글이 각자의 커스텀 서비스를 거친다. 또한 toAuthentication이 principal을 회원 ID(Long)로 만들기 때문에 컨트롤러는 @AuthenticationPrincipal Long memberId 한 가지 방식으로 로그인 사용자를 받는다.
요청은 다음처럼 들어온다.
GET /api/members/me
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
권한은 RoleHierarchy로 ADMIN > ORGANIZER > AUDIENCE 계층을 두어, 관리자가 주최자 API를, 주최자가 관객 API를 함께 쓸 수 있게 했다. Access 토큰의 role 클레임 값이 ROLE_ 접두사를 포함하므로 hasRole("ADMIN") 비교와 그대로 맞는다.
설정에서 놓치기 쉬운 부분은 세 가지였다.
bearerTokenResolver: 만료된 Access 토큰이 헤더에 함께 오면/api/auth/reissue가permitAll이어도 401로 막힌다. 그래서/api/auth/요청에서는 Bearer 헤더를 읽지 않게 했다.- CORS: 쿠키를 주고받으려면
allowCredentials(true)가 필요하고, 이때는 origin을*가 아닌 프론트 주소로 명시해야 한다. - H2 콘솔: dev 프로필에서만
/h2-console/**전용 체인을 우선순위 가장 높게 따로 두어, 메인 체인의 JWT, frame 차단 규칙을 건드리지 않는다.
즉, 로그인은 oauth2Login이, 이후 요청은 oauth2ResourceServer가 맡는다.
중요한 점은 로그인이 끝난 뒤에는 세션에 인증 상태를 두지 않는다는 것이다. 인가 요청 정보(state, 구글은 nonce 포함)는 기본 저장소인 HttpSessionOAuth2AuthorizationRequestRepository가 로그인 과정 동안만 세션에 보관한다.
남은 고민
회원당 Refresh 토큰을 하나만 두기 때문에, PC와 모바일에 동시에 로그인하면 먼저 로그인한 기기가 재발급에서 밀려난다. 다중 기기 로그인이 필요해지면 키를 기기 단위로 나누는 쪽이 맞을지는 고민이 남는다.
또한 재발급은 "조회 후 저장" 순서라서, 같은 Refresh 토큰으로 동시에 두 번 요청하면 둘 다 성공할 수 있다. Redis 스크립트처럼 비교와 교체를 원자적으로 처리할지는 트래픽을 보며 판단할 부분이다.
정리
모든 단계는 "제공자에게 받은 신원을 우리 서버의 토큰으로 바꾼다"는 같은 목표를 향한다. 차이는 각 단계가 신원 확인, 회원 처리, 토큰 전달, 토큰 검증 중 무엇을 맡느냐에 있다.
| 단계 | 담당 | 핵심 결정 |
| 인가 코드 흐름 | oauth2Login |
카카오는 provider 직접 등록, 구글은 내장 설정 + openid |
| 회원 처리 | CustomOAuth2UserService, CustomOidcUserService |
OAuth2UserInfo로 응답 통일, providerId로 식별 |
| 토큰 전달 | OAuth2LoginSuccessHandler |
OAuth2LoginMember 하나로 받고, Refresh 토큰만 쿠키로 |
| 재발급 | AuthService.reissue |
RTR, 재사용 감지 시 저장 토큰 삭제 |
| API 인증 | oauth2ResourceServer |
token_type 검증, principal은 Long memberId |
토큰을 어디에 둘까 → Access: 응답 본문(메모리), Refresh: HttpOnly 쿠키 + Redis
Refresh 토큰 탈취 대비 → 회전 + 서버 저장값 비교
제공자가 늘어나면 → 방식(OAuth 2.0/OIDC)에 맞는 서비스 + OAuth2UserInfo 분기 추가
정답을 따라가기보다, 우리 서비스에서 토큰이 새어 나갈 수 있는 경로를 하나씩 막는다는 기준으로 골랐다.