
최근 Next.js(프론트엔드) + Spring Boot(백엔드) + Nginx(리버스 프록시) 구조에서 Google OAuth2 로그인을 구현하다가 꽤 오랜 시간 삽질을 했다. localhost에서는
멀쩡하게 잘 되던 OAuth 로그인이, HTTPS 환경에 Nginx를 붙이니까 갑자기 동작하지 않았다.
에러 메시지는 항상 같았다:
OAuth2AuthenticationException: [authorization_request_not_found]
이번 글에서는 이 문제를 해결하면서 겪은 시행착오와 최종 해결 방법을 공유하겠다.
먼저 전체 구조를 이해해야 문제의 원인을 파악할 수 있다.
Next.js (HTTPS, localhost:3000)
↓ 프록시
Nginx (HTTPS, api.server.com)
↓ 프록시
Spring Boot (HTTP, localhost:8080)
그리고 Spring Security 설정은 STATELESS 세션 정책을 사용하고 있었다:
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
Google 로그인 버튼을 클릭하면 다음과 같은 흐름이 발생해야 한다:
그런데 3번 단계에서 계속 실패했다. 서버 로그를 보면:
OAuth2AuthenticationException: [authorization_request_not_found]
저장은 분명히 했는데, 콜백 시점에 찾을 수 없다는 것이다.
디버깅 끝에 발견한 문제는 크게 3가지였다.
Spring Security OAuth2는 기본적으로 HttpSessionOAuth2AuthorizationRequestRepository를 사용한다. 이름에서 알 수 있듯이 세션에 authorization request를 저장한다.
그런데 SessionCreationPolicy.STATELESS로 설정하면? 세션이 생성되지 않는다. 저장할 곳이 없으니 당연히 찾을 수도 없다.
쿠키 기반 저장소를 구현해서 문제를 해결하려 했다. 하지만 여전히 쿠키가 전달되지 않았다.
원인은 쿠키 설정이었다:
cookie:
secure: false # ❌ HTTPS 환경에서는 true여야 함
same-site: Lax # ❌ cross-site 요청에서는 None이어야 함
Chrome 80 이후, SameSite=None 쿠키는 반드시 Secure=true와 함께 사용해야 한다. 그렇지 않으면 브라우저가 쿠키 자체를 무시한다.
가장 핵심적인 문제였다. 쿠키 설정을 다 고쳤는데도 여전히 안 됐다.
디버그 로그를 추가해서 확인해보니:
[OAuth2] Loading authorization request
├─ Cookie header present: true
├─ Cookie header length: 430
├─ Has oauth2_auth_request in header: false ← 쿠키가 없다!
Cookie 헤더는 있는데, 우리가 설정한 oauth2_auth_request 쿠키가 없었다.
원인은 프론트엔드에서 백엔드로 요청을 보내는 방식이었다:
// ❌ 기존 방식 (Next.js 프록시 경유)
window.location.href = "/oauth2/authorization/google";
이렇게 하면:
1. 요청이 localhost:3000을 통해 프록시됨
2. 쿠키가 localhost:3000 도메인에 설정됨
3. Google 콜백은 api.server.com로 직접 옴
4. 다른 도메인이므로 쿠키가 전달되지 않음!
STATELESS 환경에서 OAuth2를 사용하려면 세션 대신 쿠키에 저장해야 한다:
@Component
class HttpCookieOAuth2AuthorizationRequestRepository(
private val cookieHelper: CookieHelper,
) : AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
private val logger = LoggerFactory.getLogger(HttpCookieOAuth2AuthorizationRequestRepository::class.java)
companion object {
const val OAUTH2_AUTHORIZATION_REQUEST_COOKIE = "oauth2_auth_request"
const val REDIRECT_URI_COOKIE = "redirect_uri"
private const val COOKIE_EXPIRE_SECONDS = 180
}
override fun loadAuthorizationRequest(request: HttpServletRequest): OAuth2AuthorizationRequest? {
return cookieHelper.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE)
?.let { deserialize(it) }
}
override fun saveAuthorizationRequest(
authorizationRequest: OAuth2AuthorizationRequest?,
request: HttpServletRequest,
response: HttpServletResponse,
) {
if (authorizationRequest == null) {
removeAuthorizationRequestCookies(response)
return
}
cookieHelper.addCookie(
response,
OAUTH2_AUTHORIZATION_REQUEST_COOKIE,
serialize(authorizationRequest),
COOKIE_EXPIRE_SECONDS
)
}
override fun removeAuthorizationRequest(
request: HttpServletRequest,
response: HttpServletResponse,
): OAuth2AuthorizationRequest? {
return loadAuthorizationRequest(request)
}
fun removeAuthorizationRequestCookies(response: HttpServletResponse) {
cookieHelper.deleteCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE)
cookieHelper.deleteCookie(response, REDIRECT_URI_COOKIE)
}
// Base64 URL-safe 인코딩으로 직렬화
private fun serialize(authorizationRequest: OAuth2AuthorizationRequest): String {
ByteArrayOutputStream().use { baos ->
ObjectOutputStream(baos).use { oos ->
oos.writeObject(authorizationRequest)
}
return Base64.getUrlEncoder().encodeToString(baos.toByteArray())
}
}
private fun deserialize(cookie: String): OAuth2AuthorizationRequest? {
return try {
val bytes = Base64.getUrlDecoder().decode(cookie)
ByteArrayInputStream(bytes).use { bais ->
ObjectInputStream(bais).use { ois ->
ois.readObject() as OAuth2AuthorizationRequest
}
}
} catch (e: Exception) {
logger.warn("Failed to deserialize OAuth2 authorization request: {}", e.message)
null
}
}
}
@Configuration
@EnableWebSecurity
class SecurityConfig(
// ... 다른 의존성들
private val cookieOAuth2AuthorizationRequestRepository: HttpCookieOAuth2AuthorizationRequestRepository,
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
// ... 다른 설정들
.oauth2Login { oauth2 ->
oauth2
// ★ 쿠키 기반 저장소 등록
.authorizationEndpoint { endpoint ->
endpoint.authorizationRequestRepository(cookieOAuth2AuthorizationRequestRepository)
}
.userInfoEndpoint { it.userService(customOAuth2UserService) }
.successHandler(oAuth2SuccessHandler)
.failureHandler(oAuth2FailureHandler)
}
.build()
}
}
cookie:
secure: true # ✅ HTTPS 환경
http-only: true
same-site: None # ✅ cross-site 요청 허용
path: /
ResponseCookie를 사용하면 SameSite 속성을 명확하게 설정할 수 있다:
@Component
class CookieHelper(
private val cookieProperties: CookieProperties,
) {
fun addCookie(response: HttpServletResponse, name: String, value: String, maxAge: Int) {
val cookie = ResponseCookie.from(name, value)
.maxAge(Duration.ofSeconds(maxAge.toLong()))
.path(cookieProperties.path)
.httpOnly(cookieProperties.httpOnly)
.secure(cookieProperties.secure)
.sameSite(cookieProperties.sameSite)
.build()
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString())
}
fun deleteCookie(response: HttpServletResponse, name: String) {
val cookie = ResponseCookie.from(name, "")
.maxAge(Duration.ZERO)
.path(cookieProperties.path)
.httpOnly(cookieProperties.httpOnly)
.secure(cookieProperties.secure)
.sameSite(cookieProperties.sameSite)
.build()
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString())
}
fun getCookie(request: HttpServletRequest, name: String): String? {
return request.cookies?.firstOrNull { it.name == name }?.value
}
}
이게 가장 중요한 수정이다:
// ❌ 기존 (프록시 경유)
window.location.href = "/oauth2/authorization/google";
// ✅ 수정 (직접 백엔드로)
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.server.com";
window.location.href = `${apiBaseUrl}/oauth2/authorization/google`;
기존 방식(/oauth2/authorization/google)의 문제를 단계별로 살펴보자:
1단계: 브라우저 → Next.js 서버 (localhost:3000)
브라우저가 "/oauth2/authorization/google"로 요청
→ Next.js 개발 서버(localhost:3000)가 받음
2단계: Next.js 서버 → 백엔드 API (프록시)
Next.js가 next.config.js의 rewrites 설정에 따라 백엔드로 프록시
→ https://api.server.com/oauth2/authorization/google로 전달
3단계: 백엔드가 OAuth Authorization Request 쿠키 설정
백엔드가 Set-Cookie 헤더로 쿠키 설정 시도
→ 하지만 응답은 Next.js 서버를 통해 전달됨
→ 브라우저는 현재 도메인인 localhost:3000에 쿠키를 저장
4단계: Google 로그인 후 콜백 (문제 발생!)
Google이 직접 https://api.server.com/oauth2/callback으로 리다이렉트
→ 브라우저가 api.server.com로 요청
→ 하지만 쿠키는 localhost:3000 도메인에 저장되어 있음
→ 쿠키가 전달되지 않아 Authorization Request를 찾을 수 없음 ❌
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.server.com";
window.location.href = `${apiBaseUrl}/oauth2/authorization/google`;
이렇게 수정하면:
1단계: 브라우저 → 백엔드 (직접 요청)
브라우저가 직접 https://api.server.com/oauth2/authorization/google로 요청
→ Next.js 프록시를 거치지 않음
2단계: 백엔드가 OAuth Authorization Request 쿠키 설정
백엔드가 Set-Cookie 헤더로 쿠키 설정
→ 브라우저는 api.server.com 도메인에 쿠키를 저장 ✅
3단계: Google 로그인 후 콜백
Google이 https://api.server.com/oauth2/callback으로 리다이렉트
→ 브라우저가 api.server.com로 요청
→ 같은 도메인이므로 쿠키가 자동으로 전달됨 ✅
→ Authorization Request를 성공적으로 찾음 ✅
많은 개발자들이 SameSite=None을 설정하면 쿠키가 다른 도메인으로 "이동"한다고 오해한다. 하지만 실제로는 그렇지 않다:
SameSite=None의 실제 의미
✅ 브라우저가 다른 사이트로 요청할 때도 쿠키를 함께 전송함
❌ 쿠키가 다른 도메인으로 저장되거나 이동하지는 않음
구체적인 예시:
시나리오 A: localhost:3000에 쿠키 저장 (문제 상황)
| 도메인 | 저장된 쿠키 |
|---|---|
localhost:3000 | oauth2_auth_request: "abc123" ✅ |
api.server.com | (쿠키 없음) ❌ |
결과:
api.server.com로 요청 시: SameSite=None이어도 쿠키가 전송되지 않음 ❌localhost:3000에만 있고, api.server.com에는 없음시나리오 B: api.server.com에 쿠키 저장 (정상 상황)
| 도메인 | 저장된 쿠키 |
|---|---|
localhost:3000 | (쿠키 없음) |
api.server.com | oauth2_auth_request: "abc123" ✅ |
결과:
api.server.com로 요청 시: SameSite=None이므로 쿠키가 전송됨 ✅SameSite 속성별 비교:
| 속성 | 쿠키 전송 조건 | 쿠키 삭제 여부 |
|---|---|---|
Strict | 같은 사이트에서만 전송 | ❌ 삭제되지 않음, 다만 전송만 안 됨 |
Lax | 같은 사이트 + 안전한 cross-site 요청(GET 등) | ❌ 삭제되지 않음, 조건부 전송 |
None | 모든 cross-site 요청에서 전송 (Secure 필수) | ❌ 삭제되지 않음, 항상 전송 |
핵심 원칙:
Nginx 뒤에서 Spring Boot가 HTTP로 동작하는 경우, 원래 프로토콜(HTTPS)을 인식하려면 이 설정이 필요하다:
server:
- forward-headers-strategy: native
이 설정이 없으면 Spring Boot가 자신을 HTTP로 인식해서 redirect URI가 http://...로 생성되어 문제가 발생할 수 있다.
| 항목 | 설정 |
|---|---|
| 세션 정책 | STATELESS |
| Authorization Request 저장소 | 쿠키 기반 (HttpCookieOAuth2AuthorizationRequestRepository) |
| COOKIE_SECURE | true (HTTPS 환경) |
| COOKIE_SAME_SITE | None (cross-site 허용) |
| forward-headers-strategy | native |
| 프론트엔드 OAuth 요청 | 백엔드 URL로 직접 요청 |
결국 핵심 원인은 도메인 불일치였다. 프론트엔드에서 프록시를 통해 백엔드로 요청을 보내면서 쿠키가 잘못된 도메인에 설정되었고, OAuth 콜백 시 쿠키가 전달되지
않았던 것이다.
디버깅 과정에서 쿠키 설정, Tomcat 쿠키 파서, ResponseCookie 등 여러 가지를 의심했지만, 결국 가장 기본적인 "쿠키는 같은 도메인에서만 전달된다"는 원칙을
놓치고 있었다.
localhost에서는 모든 요청이 같은 도메인이라 문제가 없었지만, 실제 배포 환경에서는 프론트엔드와 백엔드 도메인이 다르기 때문에 발생한 문제였다.
혹시 비슷한 문제를 겪고 있다면, 먼저 OAuth 요청이 어느 도메인으로 가는지, 쿠키가 어느 도메인에 설정되는지 확인해보길 권한다.
좋은글 잘 보고 갑니다