카카오·구글 로그인 붙이기 — OAuth2 클라이언트 실전
"카카오로 로그인" 버튼 하나를 붙이는 일이 겉보기와 다르게 번거로운 이유는, 우리 서버가 사용자의 카카오 비밀번호를 절대 보면 안 되기 때문입니다. OAuth2는 바로 이 문제 — 비밀번호를 우리 서버에 주지 않고도 "이 사람이 카카오 계정의 주인이다"를 증명하는 방법 — 를 표준화한 프로토콜입니다. 우리가 구현할 흐름은 그중 인가 코드 흐름(Authorization Code Grant)입니다.
인가 코드 흐름의 전체 그림
sequenceDiagram participant U as 사용자 브라우저 participant S as 우리 서버 participant K as 카카오 U->>S: "카카오로 로그인" 클릭 S->>U: 카카오 인증 페이지로 리다이렉트 U->>K: 카카오 계정으로 로그인·동의 K->>U: 인가 코드와 함께 우리 서버로 리다이렉트 U->>S: GET /login/oauth2/code/kakao?code=xxx S->>K: 인가 코드로 액세스 토큰 요청 K-->>S: 액세스 토큰 S->>K: 액세스 토큰으로 사용자 정보 요청 K-->>S: 이메일, 닉네임 등 프로필 S->>S: 우리 서비스 회원과 매핑, 없으면 신규 가입 처리 S-->>U: 우리 서비스의 JWT 발급
이 흐름에서 우리 서버가 카카오 비밀번호를 볼 일이 한 번도 없다는 게 핵심입니다. 사용자는 카카오 도메인에서만 비밀번호를 입력합니다. 스프링 시큐리티는 이 흐름 전체(리다이렉트, 콜백 처리, 토큰 교환)를 spring-boot-starter-oauth2-client가 대신 처리해줍니다. 우리가 직접 짤 코드는 마지막 두 단계 — 받아온 프로필로 회원을 매핑하고 우리 서비스의 JWT를 발급하는 부분 — 뿐입니다.
설정: application.yml
spring:
security:
oauth2:
client:
registration:
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
client-authentication-method: client_secret_post
scope: profile_nickname, account_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구글은 표준 OIDC(OpenID Connect)를 따르기 때문에 provider 섹션 없이 registration만으로 끝납니다. 카카오는 표준을 완전히 따르지 않아 provider 섹션에 엔드포인트를 직접 명시해야 합니다. 이 차이를 모르고 구글 설정을 그대로 복사해서 카카오에 붙이면 user-info-uri를 못 찾아 계속 실패하는데, 에러 메시지만 봐서는 원인을 짐작하기 어렵습니다.
| 구글 | 카카오 | |
|---|---|---|
| 표준 | OIDC 완전 준수 | 자체 방식(OIDC 아님) |
provider 섹션 |
불필요 | 필수 (엔드포인트 직접 명시) |
| 사용자 식별자 | sub |
id |
| 이메일 제공 | 기본 포함 | 카카오 비즈 앱 등록 및 동의항목 설정 필요 |
받아온 프로필을 우리 회원과 연결하기
OAuth2UserService를 커스텀해서 프로필을 받은 직후 회원 매핑 로직을 끼워넣습니다.
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest request) {
OAuth2User oAuth2User = super.loadUser(request);
String registrationId = request.getClientRegistration().getRegistrationId(); // "kakao"
Map<String, Object> attributes = oAuth2User.getAttributes();
String providerId = String.valueOf(attributes.get("id"));
User user = userRepository.findByProviderAndProviderId(registrationId, providerId)
.orElseGet(() -> userRepository.save(User.fromOAuth(registrationId, providerId, attributes)));
return new CustomOAuth2User(user, attributes);
}
}여기서 흔히 하는 실수가 이메일만으로 회원을 매핑하는 것입니다. 카카오 이메일로 가입한 계정과 구글 이메일로 가입한 계정이 우연히 같은 이메일 주소를 쓴다면(둘 다 개인 이메일이라면 흔한 일입니다), 이메일만 보고 매핑하는 로직은 서로 다른 사람을 같은 계정으로 합쳐버릴 수 있습니다. provider(카카오/구글)와 providerId(그 서비스 안에서의 고유 ID)를 함께 키로 써야 안전합니다.
소셜 로그인 이후에도 우리 JWT를 쓴다
여기서 짚어야 할 설계 판단이 하나 있습니다. OAuth2 로그인이 성공한 뒤에는 카카오 액세스 토큰을 그대로 우리 API 인증에 쓰지 않습니다. 성공 핸들러에서 우리 회원 정보로 5장에서 만든 것과 같은 우리 서비스의 JWT를 새로 발급합니다.
@Component
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
private final JwtProvider jwtProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();
String accessToken = jwtProvider.createToken(oAuth2User.getUsername(), oAuth2User.getRole());
response.sendRedirect("https://our-frontend.com/oauth/callback?token=" + accessToken);
}
}이렇게 하는 이유는 두 가지입니다. 카카오 액세스 토큰의 만료 정책과 갱신 방식은 우리가 통제할 수 없고, 우리 API의 다른 부분(6장의 리프레시 토큰 전략, 7장의 메소드 권한)이 전부 우리가 발급한 JWT를 전제로 짜여 있기 때문입니다. 소셜 로그인은 "신원을 증명하는 방법" 하나가 늘어난 것일 뿐, 그 이후의 인증·인가 체계는 일반 로그인과 동일하게 유지하는 게 코드를 단순하게 만듭니다.