Server#BE#Spring Boot#로그인#에러 핸들링

카카오/구글 로그인이 dev에서만 자꾸 깨졌던 이유: OAuth2 콜백 트러블슈팅 3연전

STATELESS 세션과 충돌한 401, GCP Secret 공백 하나 때문에 난 KOE010, 크로스 도메인 쿠키/CSRF까지 — 이틀에 걸쳐 이어진 세 가지 원인을 순서대로 추적한 기록

2026년 7월 21일4분 읽기조회 4
공유

들어가며

소셜 로그인 기능을 배포 환경(dev, Cloud Run)에 올리고 나서, 로그인 콜백이 이틀에 걸쳐 세 번 연달아 깨졌습니다. 겉으로는 전부 "로그인이 안 된다"는 같은 증상이었지만, 원인은 매번 완전히 달랐습니다. 세션 정책 충돌, 시크릿 값 오타, 그리고 크로스 도메인 쿠키 정책까지 — 하나씩 순서대로 추적한 기록입니다.

문제 1 — STATELESS 세션에서 콜백 401

증상

카카오 로그인 콜백(/oauth/callback/kakao) 시점에 401(COMMON_401_UNAUTHORIZED)이 발생했습니다.

원인

SecurityConfig에 SessionCreationPolicy.STATELESS를 설정해뒀는데, Spring Security OAuth2 Login이 기본으로 쓰는 HttpSessionOAuth2AuthorizationRequestRepository는 인가 요청(state 값 포함)을 HTTP 세션에 저장합니다. /oauth/authorization/kakao 요청 시 저장한 state를 콜백 시점에 세션에서 찾지 못해 인증이 실패했고, 기본 실패 핸들러가 /login?error로 리다이렉트했는데 이 경로가 permitAll이 아니라 401로 이어졌습니다. Chrome 네트워크 로그로 code/state가 정상적으로 붙은 콜백 요청 자체가 401을 반환하는 것을 직접 확인했습니다.

해결

세션 대신 쿠키에 OAuth2 인가 요청을 저장하는 HttpCookieOAuth2AuthorizationRequestRepository를 새로 구현해 SecurityConfig의 authorizationEndpoint에 배선했습니다. 여기서 중요한 설계 결정이 하나 있었습니다. 클라이언트가 조작 가능한 쿠키 값을 Java 네이티브 역직렬화(SerializationUtils, Spring 6.0부터 deprecated)로 복원하면 CWE-502(안전하지 않은 역직렬화) 위험이 있습니다. 그래서 필요한 필드만 담은 내부 DTO(record)를 JSON으로 직렬화하는 방식을 채택했습니다.

세션 정책(STATELESS) 자체는 그대로 유지했는데, 더 이상 세션에 의존하지 않으므로 바꿀 이유가 없었습니다. 카카오와 구글이 동일한 oauth2Login 설정을 공유하기 때문에 이 수정으로 두 provider가 함께 해결됐습니다.

수정 후 첫 전체 테스트 실행에서 PlaceControllerTest/TrackControllerTest가 22개 실패했는데, 두 테스트가 SecurityConfig를 직접 import하는 @WebMvcTest라 새로 추가한 빈이 테스트 컨텍스트에 없어서 깨진 것이었습니다. @Import 목록에 추가해서 해결했습니다.

문제 2 — dev 배포 카카오 로그인 KOE010

증상

dev(Cloud Run)에서만 카카오 로그인 시 POST https://kauth.kakao.com/oauth/token에서 401과 함께 {"error":"invalid_client","error_code":"KOE010"}이 돌아왔습니다.

진단 과정

코드, 설정 파일, 빌드된 JAR 실물(application.yml 내용까지), GCP Secret 값, Cloud Run 바인딩, CORS, VPC/프록시까지 하나씩 확인했지만 전부 정상이었습니다. 마지막 수단으로 dev 컨테이너 안에서 Spring이 실제로 인식하는 ClientRegistration 객체를 직접 확인하기 위해, accessTokenResponseClient 확장 지점에 진단 코드(인증 방식과 secret 길이만 로깅하고 값 자체는 찍지 않는)를 추가해 배포하려던 참이었습니다.

원인

GCP Secret Manager에 등록된 KAKAO_REST_API_SECRET 값에 공백이 섞여 있었습니다. 코드 문제가 아니라 순수한 설정값 오류였습니다.

해결

GCP Secret Manager에서 시크릿 값을 공백 없이 재입력해서 해결했습니다. 코드나 PR 없이 설정값만 고쳐서 끝난 케이스입니다.

문제 3 — 로컬 프론트 ↔ dev 배포 백엔드 크로스오리진 쿠키/CSRF

증상

localhost:3000(프론트) ↔ localhost:8080(백엔드)로 로컬끼리 연결하면 로그인 시 쿠키가 정상 삽입되는데, 프론트 로컬 ↔ 백엔드 dev 배포(*.run.app)로 연결하면 로그아웃 등에서 쿠키가 실리지 않았습니다.

원인 1 — SameSite 정책과 CORS 설정의 모순

application-dev.yml에 쿠키 설정이 없어 기본값 cookie.same-site: Lax를 쓰고 있었는데, CorsConfig는 allowCredentials(true)로 크로스오리진 인증을 허용하고 있어 서로 모순된 상태였습니다. SameSite=Lax는 완전한 페이지 이동에만 쿠키를 실어 보내고 fetch() 같은 크로스사이트 API 호출에는 보내지 않습니다. localhost:3000과 localhost:8080은 포트만 다른 same-site라 로컬끼리는 되지만, run.app 도메인은 완전히 다른 사이트라 fetch 호출에 쿠키가 전혀 실리지 않았습니다.

원인 2 — CSRF 토큰은 더 근본적인 문제였다

CSRF 토큰(XSRF-TOKEN)은 프론트 JS가 document.cookie로 직접 읽어서 헤더에 실어 보내는 더블 서브밋 방식인데, 다른 도메인이 설정한 쿠키는 브라우저 정책상 프론트 JS가 애초에 읽을 수 없습니다(SameSite와는 무관한 별개 제약). SameSite를 고쳐도 로그아웃 같은 요청에는 X-XSRF-TOKEN 헤더가 계속 비어 있어 CSRF 검증에서 403으로 막힐 가능성이 남아 있었습니다.

최종 해결 — 설정이 아니라 인프라 구조를 바꾸다

SameSite 설정을 고치거나 CSRF 패턴을 교체하는 대신, 개인 서버에 Traefik 리버스 프록시를 두고 프론트/백엔드를 dev.plimap.kr 하나의 공개 호스트로 통합했습니다. /api, /oauth, /swagger-ui, /v3/api-docs 경로는 Cloud Run으로, 나머지는 프론트 Docker로 경로 기반 라우팅했습니다. 브라우저 입장에서 완전히 같은 오리진이 되면서 크로스도메인 문제 자체가 사라졌습니다. SecurityConfig나 CSRF 필터 등 코드는 전혀 바꾸지 않았고, 기존 더블 서브밋 패턴이 그대로 유효했습니다(같은 오리진이 되면서 document.cookie로 쿠키를 읽을 수 있게 됨).

테스트 과정에서 문제 2와 같은 유형의 문제(GCP Secret Manager의 카카오 키 값 오류)가 다시 한번 발생했는데, 값만 재입력해서 해결했습니다.

정리

같은 "로그인이 안 된다"는 증상 뒤에 세 가지 서로 다른 계층의 문제가 있었습니다.

문제계층해결 방식
STATELESS 세션 충돌애플리케이션 코드쿠키 기반 인가 요청 저장소 신규 구현
KOE010배포 설정값Secret Manager 값 재입력
크로스오리진 쿠키/CSRF인프라 구조리버스 프록시로 오리진 통합

특히 세 번째 문제는 처음엔 SameSite 값을 조정하는 코드 레벨 해결책부터 접근했지만, CSRF 토큰을 다른 도메인에서 읽을 수 없다는 브라우저 제약까지 확인하고 나서야 "애초에 같은 오리진으로 만드는 게 맞다"는 결론에 도달했습니다. 증상이 같다고 원인도 같다고 가정하지 않고, 계층별로 하나씩 확인하는 과정이 결국 가장 빠른 길이었습니다.

관련 포스트

댓글

방명록을 남겨주세요