내일배움캠프 Spring 백엔드 69일차 TIL — Google / Kakao OAuth 2.0 소셜 로그인 도입 트러블슈팅
작성일 2026-03-25 · 분류: 부트캠프, TIL, 팀 프로젝트, OAuth 2.0, Spring Security, Google, Kakao, 트러블슈팅
69일차 개요
오늘 작업 범위
- 결제 시스템 팀 프로젝트에 Google OAuth 2.0 소셜 로그인을 도입했다.
- 이어서 Kakao OAuth 2.0을 추가로 연동했다.
- OAuth 토큰 전달 방식의 보안 불일치 문제를 발견하고 개선했다.
- Google과 Kakao의 사용자 정보 JSON 구조 차이에서 발생하는 파싱 문제를 해결했다.
1. Google Cloud Console 설정
| 문제 | 원인 및 해결 |
|---|---|
| 스코프(scope) 설정 위치를 Google Cloud Console에서 찾으려 했으나 항목이 보이지 않음 | 현재 UI에서는 Console에서 별도로 스코프를 설정할 필요가 없다. application.yml의 scope 설정만으로 충분하다. |
| 리다이렉트 URI를 프론트엔드 주소로 설정하려 함 | OAuth 플로우를 오해한 것이다. 구글이 인증 코드를 보내는 곳은 백엔드 서버이므로 백엔드 주소(/login/oauth2/code/google)로 등록해야 한다. 프론트는 이후 백엔드가 최종 리다이렉트하는 대상이다. |
2. 구글 로그인 화면 → 동의
3. 구글이 백엔드 서버의 Redirect URI(
/login/oauth2/code/google)로 인증 코드 전송4. 백엔드가 인증 코드로 구글 토큰 서버에 Access Token 요청
5. Access Token으로 사용자 정보 조회
6. JWT 발급 후 프론트엔드로 최종 리다이렉트
리다이렉트 URI는 구글이 인증 코드를 보내는 백엔드 주소이며, 프론트 주소가 아니다.
2. Google OAuth 코드 구현
구현 파일 목록
| 파일 | 역할 |
|---|---|
AuthProvider (enum) |
LOCAL, GOOGLE (이후 KAKAO 추가) |
OAuthUserInfo (DTO) |
provider별 사용자 정보 파싱 결과를 담는 DTO |
CustomOAuth2UserService |
OAuth 로그인 시 사용자 조회/생성 처리 |
OAuth2SuccessHandler |
OAuth 인증 성공 후 JWT 발급 및 프론트로 리다이렉트 |
트러블슈팅
| 문제 | 원인 및 해결 |
|---|---|
User 엔티티에 provider 필드 추가 후 DataInitializer에서 NULL not allowed 에러 발생 |
기존 INSERT 쿼리의 컬럼 목록에 provider가 없었다. SQL 컬럼 목록과 VALUES에 provider, "LOCAL"을 추가해서 해결했다. |
위 수정 후 재실행 시 Column count does not match 에러 발생 |
컬럼 목록에는 provider를 추가했지만 VALUES의 ? 개수를 그대로 둬서 불일치가 발생했다. 컬럼 수와 ? 개수를 맞춰서 해결했다. SQL을 수정할 때는 컬럼과 값 자리를 항상 함께 확인해야 한다. |
3. OAuth 토큰 발급 보안 이슈
OAuth 인증 성공 후 프론트엔드로 토큰을 전달하는 방식에서 기존 일반 로그인과의 보안 불일치가 발견됐다.
| 방식 | 초기 구현 | 문제점 |
|---|---|---|
| 일반 로그인 | Access Token → 응답 Body / Refresh Token → HttpOnly 쿠키 | 없음 |
| OAuth 로그인 (초기) | Access Token + Refresh Token 둘 다 URL 파라미터로 전달 | Refresh Token이 브라우저 주소창, 히스토리, 서버 로그에 노출 |
OAuth2SuccessHandler에서 프론트로 리다이렉트할 때는 sendRedirect()를 사용한다.
HTTP 리다이렉트(302)는 응답 Body를 지원하지 않기 때문에 일반 로그인처럼 Body에 토큰을 담는 방식을 쓸 수 없다.
URL 파라미터 외의 방법이 기술적으로 제한된다.
response.addCookie()를 사용하면 리다이렉트 응답에 Set-Cookie 헤더를 추가할 수 있다.Refresh Token → HttpOnly 쿠키(
Set-Cookie 헤더)로 전달 (브라우저가 자동 저장)Access Token → URL 파라미터로 전달 (프론트가 꺼내서 메모리/스토리지에 저장)
이렇게 하면 일반 로그인과 동일한 보안 수준을 유지할 수 있다.
4. Kakao 개발자 콘솔 설정
| 문제 | 원인 및 해결 |
|---|---|
| Redirect URI 등록 위치를 문서와 다른 UI에서 찾지 못함 | 카카오 개발자 콘솔 UI가 개편되었다. 새 UI 기준: 앱 설정 → 플랫폼 키 → REST API 키 → 더보기에서 Redirect URI를 등록해야 한다. |
기존 [플랫폼] → [Web]에서 사이트 도메인을 등록하는 항목이 사라짐 |
UI 개편으로 설정 위치가 변경되었다. JavaScript SDK 도메인은 JavaScript 키 하위, Redirect URI는 REST API 키 하위에서 각각 설정하는 구조로 분리되었다. |
5. Kakao OAuth 코드 구현
Google과 Kakao는 사용자 정보 응답의 JSON 구조가 다르기 때문에 provider별 분기 처리가 필요하다.
Google vs Kakao JSON 구조 비교
| 항목 | Kakao | |
|---|---|---|
| JSON 구조 | flat (단순 1단계) | 중첩 구조 (kakao_account → profile) |
| 고유 ID | "sub" (String) |
"id" (Long) |
| 이메일 위치 | 최상위 레벨 attributes.get("email") |
kakao_account 안에 중첩 |
| nameAttributeKey | "sub" |
"id" |
트러블슈팅
| 문제 | 원인 및 해결 |
|---|---|
| 카카오 중첩 JSON 파싱 실패 | 카카오는 kakao_account → profile의 중첩 구조다. (Map<String, Object>) 캐스팅으로 단계별로 꺼내는 로직을 추가했다. Google은 flat 구조라 별도 처리가 필요 없다. |
카카오 고유 ID 타입 불일치 — id가 Long인데 Google sub은 String |
두 provider의 ID를 통일된 방식으로 처리하려면 String.valueOf()로 변환해서 String으로 통일해야 한다. |
DefaultOAuth2User의 세 번째 인자 nameAttributeKey를 구글과 동일하게 "email"로 하드코딩 → 카카오에서 에러 발생 |
카카오는 최상위 레벨에 "email" 키가 없다. userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()으로 각 provider 설정에서 동적으로 가져오도록 변경했다. Google은 "sub", Kakao는 "id"가 반환된다. |
OAuth2SuccessHandler에서 attributes.get("email")로 카카오 이메일 꺼내기 → null 반환 |
카카오는 이메일이 kakao_account 안에 있어 최상위 레벨 attributes에 없다. OAuth 성공 핸들러에서도 provider를 분기해서 이메일을 꺼내는 로직을 추가했다. |
DefaultOAuth2User의 세 번째 인자 nameAttributeKey는 해당 provider의 응답 JSON에서 반드시 존재하는 최상위 키여야 한다.
Google은 최상위에 "sub"가 있고 Kakao는 최상위에 "id"가 있다."email"처럼 특정 provider에만 있는 키를 하드코딩하면 다른 provider에서 키를 찾지 못해 에러가 발생한다.
getUserNameAttributeName()으로 가져오면 application.yml의 각 provider 설정에서 자동으로 올바른 키를 반환하므로 멀티 provider 환경에서도 안전하게 동작한다.
마무리
OAuth 도입에서 가장 먼저 혼동했던 것은 리다이렉트 URI가 백엔드 주소라는 사실이었다. 구글이 인증 코드를 보내는 곳은 서버이지 브라우저가 아니다. Authorization Code Flow의 전체 흐름을 그림으로 이해하고 나면 당연한 이야기지만, 처음에는 직관적으로 "프론트에 코드를 주는 것 아닌가"라고 오해하기 쉽다.
OAuth 토큰 전달 보안 이슈는 기존 로그인 방식과의 일관성을 점검하다가 발견한 것이다.
HTTP 리다이렉트는 Body를 지원하지 않지만 헤더는 전송된다는 사실을 이용해
Set-Cookie 헤더로 Refresh Token을 전달하는 방식으로 해결했다.
기능을 구현할 때 기존 방식과 동일한 보안 수준을 유지하는지 항상 비교하는 습관이 중요하다.
Google과 Kakao의 JSON 구조 차이는 멀티 provider를 지원할 때 반드시 부딪히는 문제다.
nameAttributeKey를 하드코딩하지 않고 getUserNameAttributeName()으로 동적으로 가져오는 방식이
세 번째 provider를 추가할 때도 동일한 코드를 재사용할 수 있게 만든다.
특정 provider에 종속된 하드코딩은 확장성을 막는다는 것을 이번에 직접 경험했다.