회고

[회고] Spring Boot 배포 환경에서 만난 OAuth 2.0 트러블슈팅

sudo-terry 2025. 11. 11. 03:18

개요

 

간단한 웹 프로젝트 개발을 하던 중, 로그인 기능을 구글 소셜 로그인으로 만들자는 의견이 있어, 제가 그 기능을 맡아 개발하기로 하였습니다. OAuth2.0 인증 흐름에 따라 Spring Boot 서버에 엔드포인트를 구현하였고, 로컬에서 jwt 토큰이 잘 발급되는 것까지 확인한 뒤 자신 있게 배포했습니다. 그러나...

띠용?

 

배포된 서버에서 구글 계정을 고르고 서버 콜백 엔드포인트로 돌아간 뒤에 돌아온 응답은 500에러였습니다.

로컬에서 로그인이 분명히 잘 되었는데 왜 배포하면 로그인이 안되는거지??

오늘은 그날 겪었던 문제를 해결하는 과정, 그리고 배포 환경의 OAuth2.0 인증 흐름에서 고려해야 할 점들을 다루어볼까 합니다.

 

 

 

문제 상황

 

먼저 제가 구현한 OAuth 2.0 인증 흐름은 다음과 같습니다.

  1. [프론트] 사용자가 서버의 소셜 로그인 엔드포인트 요청
  2. [서버] 사용자를 Google 로그인 페이지로 리다이렉트
  3. [사용자] Google 로그인 수행
  4. [Google] 서버의 콜백 엔드포인트로 리다이렉트 (인증 정보와 함께)
  5. [서버] 받은 인증 정보로 Google 리소스 서버에 사용자 정보 요청
  6. [Google] 사용자 정보 응답
  7. [서버] 응답받은 정보로 서비스 자체 JWT 발급
  8. [서버] 사용자에게 인증 결과 반환

로컬에서 로그인을 해보니 여전히 jwt가 잘 발급되는 것을 확인할 수 있었습니다. 하지만 배포된 서버에서만 500에러가 뜨더군요. 먼저 500에러인 만큼, 바로 인스턴스에 붙어 Spring Boot 서버 로그를 먼저 확인하기 시작했습니다.

 

DEBUG ... Stored SecurityContextImpl ... to HttpSession ...
DEBUG ... Redirecting to [https://www.abc.com/auth/google/callback]
...
DEBUG ... Request requested invalid session id ...
WARN  ... OAuth2AuthenticationToken is null at callback

 

invalid session id, 그리고 콜백 시점에 OAuth2AuthenticationToken이 null이라는 로그가 보이더군요.

 

 

삽질의 시작

 

처음 로그를 보고 든 생각은 '갑자기 왜 세션이지?'였습니다.로컬에선 기능이 잘 동작하고 배포된 서버에서만 문제가 재현되고 있기 때문에 배포된 서버에 설정값이 의도와는 다르게 들어간 것을 의심했습니다. 또한, 분명 최종 스펙은 JWT 기반 인증이었기에, Spring Security가 불필요하게 세션을 참조하다가 문제가 생긴 것이라 오판했습니다.

 

단순하게 접근하여 SessionCreationPolicy.STATELESS 설정을 적용해보기도 하고, 중복 필터 문제인가 싶어 필터 로직을 수정해보기도 했습니다. 하지만 결과는 동일했습니다. 구글 로그인 페이지로 넘어가지조차 못하거나, 여전히 세션을 찾지 못해 에러가 발생했습니다. 이에 구체적으로 어느 부분에서 문제가 발생하고 있는지 드릴 다운하기 시작했습니다.

 

결국 저는 OAuth2.0 인증 흐름상, Spring Security에서 리다이렉션 과정에서 인증 상태(state)를 임시로 저장하기 위해 짧은 수명의 세션을 사용하고 있고, 이 부분이 문제가 되고 있다는 점을 파악했습니다. 즉, 사용자가 구글 로그인 페이지로로 떠났다가 돌아올 때 "아, 이 사람이 아까 내가 구글로 보냈던 그 사람이 맞구나"라고 검증하기 위해 JSESSIONID가 필수적이었던 것입니다.

 

 

잃어버린 쿠키를 찾아서

 

세션이 필요하다는 것을 확인한 뒤에는, 브라우저 개발자 도구의 네트워크 탭을 열어 실제 요청 흐름을 분석했습니다. 분석한 결과, 구글에서 서버의 콜백 URL로 리다이렉트될 때, 브라우저가 JSESSIONID 쿠키를 서버로 보내지 않고 있었습니다. 왜였을까요?

 

원인은 OAuth2.0 과정에서 사용하기로 설정한 환경 변수에서 확인할 수 있었습니다.

# local env
GOOGLE_REDIRECT_URI=http://localhost:3000/login/oauth2/code/{registerationId}

# production env
GOOGLE_REDIRECT_URI=www.abc.com/login/oauth2/code/{registerationId}

 

로그인 시작은 abc.com/login에서 했는데, 구글 콘솔과 서버 환경변수에 설정된 리다이렉트 URI는 www.abc.com/login/... 였던 것입니다. 서브 도메인이 달라지니 브라우저는 다른 사이트로 인식하고 쿠키를 주지 않았던 거죠.

 

 

이를 해결하기 위해, 도메인을 통일하기 위해 1차로 Nginx 설정을 통해 www 도메인을 루트 도메인(abc.com)으로 리다이렉트 시켜 도메인을 통일했습니다.

# nginx.conf 예시
server {
    listen 443 ssl;
    server_name www.abc.com;
    # ... SSL 설정 ...
    return 301 https://abc.com$request_uri; # www -> non-www 리다이렉션
}

 

이제 브라우저의 네트워크 탭을 확인했을 때 정상적으로 쿠키를 보내기 시작한 것을 확인할 수 있었습니다. "해결됐다!"라고 생각하며 다시 로그인을 시도했지만... 여전히 서버는 invalid session id를 뱉어냈습니다.

 

 

Spring아, 프록시를 믿어줘

 

브라우저는 분명 쿠키를 보냈는데 서버는 왜 못 받을까? 여기서 배포 환경의 아키텍처를 다시 살펴봐야 했습니다.

 

제 배포 환경은 앞단에 로드밸런서(Nginx)가 있고, 그 뒤에 Spring Boot 컨테이너가 있는 구조였습니다. 보통 이런 구조에서는 SSL 종료(SSL Termination)가 로드밸런서에서 이루어집니다.

 

  • 브라우저 → 로드밸런서: HTTPS 요청 (보안 쿠키 전송 O)
  • 로드밸런서 → Spring Boot: HTTP 요청 (내부망이므로 암호화 해제)

문제는 여기서 발생했습니다. Spring Boot는 HTTP로 요청을 받았기 때문에, 자신이 안전하지 않은(Non-Secure) 환경에 있다고 판단합니다. 하지만 브라우저가 보낸 JSESSIONID 쿠키는 Secure 속성(HTTPS에서만 전송 가능)이 걸려 있었죠. 이렇게 되면 스프링 입장에서는 "HTTP 요청을 받았는데, 이 쿠키는 Secure네? 보안상 위험하니 무시해야지." 라고 생각하게 되는 것이죠.

 

# 브라우저 -> Nginx
Domain=www.abc.com; Path=/; Secure; HttpOnly;

 

# Nginx -> Spring
GET / HTTP/1.1
Host: internal-spring:8080
Cookie: JSESSIONID=12345ABCDE
X-Forwarded-Proto: https

 

문제를 해결하기 위해서는 로드밸런서가 "원래는 HTTPS 요청이었어!"라고 스프링에게 알려주고, 스프링이 이를 신뢰하도록 설정할 필요가 있었습니다. (X-Forwarded-Proto: https)

 

이에 application.yml에 다음 설정을 추가했습니다.

server:
  forward-headers-strategy: framework

 

이 설정은 Spring 프레임워크가 로드밸런서가 추가한 X-Forwarded-* 헤더를 신뢰하고, 이를 바탕으로 요청 URL을 재구성하도록 처리합니다. 이제 Spring은 HTTP로 요청을 받았더라도 X-Forwarded-Proto: https 헤더를 보고 "아, 실제 클라이언트는 HTTPS로 요청했구나"라고 인식하게 되어 Secure 쿠키를 정상적으로 처리할 수 있게 되었습니다.

 

이 부분은 하기 링크의 빈을 통해 동작 방식을 자세히 확인해볼 수 있습니다.

 

ServletWebServerConfiguration.java

 

spring-boot/module/spring-boot-web-server/src/main/java/org/springframework/boot/web/server/autoconfigure/servlet/ServletWebServ

Spring Boot helps you to create Spring-powered, production-grade applications and services with absolute minimum fuss. - spring-projects/spring-boot

github.com

	@Bean
	@ConditionalOnProperty(name = "server.forward-headers-strategy", havingValue = "framework")
	@ConditionalOnMissingFilterBean(ForwardedHeaderFilter.class)
	FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilter(
			ObjectProvider<ForwardedHeaderFilterCustomizer> customizerProvider) {
		
		/* 
			X-Forwarded-* 헤더들을 읽어서
			HttpServletRequest의 정보(getRequestURL(), getScheme(), getServerName() 등)를
			실제 클라이언트 요청 기준으로 덮어씌우는 역할
		*/
		ForwardedHeaderFilter filter = new ForwardedHeaderFilter();
		
		customizerProvider.ifAvailable((customizer) -> customizer.customize(filter));
		FilterRegistrationBean<ForwardedHeaderFilter> registration = new FilterRegistrationBean<>(filter);
		registration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ASYNC, DispatcherType.ERROR);
		registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
		return registration;
	}

 

설정 적용 후, 드디어 배포 환경에서도 정상적으로 200 OK와 함께 토큰이 발급되었습니다!

 

 

마치며

 

지금까지 소규모 프로젝트를 진행하며 겪어왔던 문제들은 비즈니스 로직상으로 허점이 존재하기 때문인 경우가 많았습니다. 특히나 배포 환경에서만 문제가 생긴다면 그 원인은 보통 설정값을 올바르게 적용하지 않았기 때문인 경우가 많았죠. 하지만 이번 케이스는 달라 제게는 조금 인상 깊었던 트러블 슈팅 경험이었던 것 같습니다.

 

Nginx가 구체적으로 트래픽을 분산 처리할 때, 뒷단으로 보내지는 요청들이 어떤 형태인지 자세히 뜯어본 적이 있었는가?

 

OAuth2.0이 구체적으로 브라우저 수준에서 어떻게 그 과정을 구현해내고 있는지 자세하게 고찰하고 이것을 기억하고 있는가?

 

결국, 이러한 기술적 디테일에 대한 이해 부족이 이번 문제의 근본적인 원인이었습니다. 다양한 기술을 습득하고 활용하는 능력도 중요하지만, 장기적으로 뛰어난 엔지니어가 되기 위해서는 기술의 동작 원리를 깊이 있게 이해하는 것이 필수적임을 다시 한번 깨달았습니다.

'회고' 카테고리의 다른 글

[회고] AWS EC2 CPU 사용률 100% 장애 해결  (0) 2025.10.28