🚨 도입: 로컬에선 잘 되던 로그인이 배포만 하면 풀린다?
개인 프로젝트인 '가상 자산 거래 플랫폼(Virtual Exchange)'의 인프라를 확장하면서, 기존 GCP(1GB RAM)의 메모리 한계를 극복하고자 Oracle Cloud A1.Flex(24GB RAM) 환경으로 백엔드를 마이그레이션했습니다.
성공적으로 인프라 이전을 마치고 프론트엔드(Vercel)와 연동 테스트를 진행하던 중, 기묘한 버그를 마주했습니다.
"로컬 환경에서는 Access Token이 만료되면 완벽하게 /reissue 로직을 타며 연장되는데, 배포 환경에서는 무조건 재발급이 실패하고 강제 로그아웃이 되어버린다."
프론트엔드와 백엔드의 코드는 단 한 줄도 바뀌지 않았습니다. 그렇다면 문제는 '환경'에 있었습니다.
🕵️♂️ 원인 분석 및 트러블 슈팅
Step 1. 크로스 도메인과 잃어버린 쿠키 (SameSite)
가장 먼저 프론트엔드의 Network 탭을 열어보았습니다. Access Token 만료 후 401 에러를 감지한 Axios Interceptor가 백엔드로 /reissue 요청을 보냈지만, 이 요청 자체도 401 에러로 거절당하고 있었습니다.
원인은 브라우저의 보안 정책이었습니다.
- 로컬 환경: 프론트(localhost:5173)와 백엔드(localhost:8080)가 같은 도메인(Same-Origin)으로 취급되어 Refresh Token 쿠키가 정상적으로 전송됨.
- 배포 환경: 프론트(vercel.app)와 백엔드(kro.kr)의 도메인이 달라 브라우저가 크로스 도메인(Cross-Domain) 간의 쿠키 전송을 보안상 차단해버림.
💡 해결: ResponseCookie와 SameSite=None
이 문제를 해결하려면 서버에서 쿠키를 발급할 때 "이 쿠키는 도메인이 달라도 전송해도 안전해"라는 SameSite=None 속성과, "대신 반드시 HTTPS 통신에서만 전송해"라는 Secure=true 속성을 짝꿍으로 달아주어야 합니다.
기존 jakarta.servlet.http.Cookie 객체로는 해당 속성을 세밀하게 제어하기 어려워, Spring의 ResponseCookie를 도입해 코드를 리팩토링했습니다.
// 개선된 CookieUtil.java
public static void setRefreshCookie(HttpServletResponse response, String refreshToken) {
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true)
.secure(true) // 🔒 HTTPS 환경 필수
.sameSite("None") // 🌍 크로스 도메인 쿠키 전송 허용
.path("/")
.maxAge(7 * 24 * 60 * 60)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
Step 2. Spring Boot의 착각 (The Hidden Boss)
코드를 고치고 배포했으나, 여전히 로그인은 연장되지 않았습니다. 확인해 보니 백엔드(Spring Boot)에서 아예 Set-Cookie 응답 자체를 내려주지 않고 있었습니다.
진짜 흑막은 리버스 프록시(Nginx) 구조에 있었습니다. 현재 백엔드 아키텍처는 다음과 같은 흐름으로 구성되어 있습니다.
클라이언트(HTTPS) ➔ Nginx(SSL 인증서 해독) ➔ Spring Boot(HTTP)
Spring Boot 입장에서는 자신에게 들어온 요청이 HTTP였기 때문에, Secure=true (HTTPS에서만 발급/전송됨) 속성이 붙은 쿠키 생성을 조용히 거부해 버린 것입니다.
💡 해결: X-Forwarded-Proto와 forward-headers-strategy
Nginx가 Spring Boot에게 *"이 요청은 원래 밖에서 HTTPS로 들어왔던 거야!"*라고 알려주고, Spring Boot는 그 말을 신뢰하도록 양쪽의 설정을 맞춰주어야 했습니다.
1. application.yml 수정 (Spring Boot)
server:
forward-headers-strategy: native # Nginx가 보내는 X-Forwarded-* 헤더를 신뢰함
2. Nginx 설정 파일 수정 (/etc/nginx/sites-available/default)
location / {
proxy_pass http://localhost:8080;
# Spring Boot가 원본 요청이 HTTPS였음을 인식하게 함 (핵심!)
proxy_set_header X-Forwarded-Proto $scheme;
# 클라이언트의 실제 IP 전달 (스푸핑 방지)
proxy_set_header X-Forwarded-For $remote_addr;
}
위 설정을 적용하고 Nginx를 재시작하자, 드디어 배포 환경에서도 Access Token 만료 시 조용하고 부드럽게 세션이 연장되는 아름다운 모습을 확인할 수 있었습니다.
🎯 마치며: AI 시대, 백엔드 개발자에게 '아키텍처 지식'이란?
이번 트러블 슈팅을 진행하며 Cursor, Claude Code 등의 AI 코딩 툴을 적극 활용했습니다. AI는 WebConfig와 SecurityConfig 간의 미세한 CORS 충돌 위험이나 헤더 스푸핑 취약점 같은 세부적인 코드 레벨의 문제는 귀신같이 찾아냈습니다.
하지만, *"Vercel에서 출발한 패킷이 Nginx를 거쳐 Spring Boot로 도달하며 프로토콜(HTTPS ➔ HTTP)이 변환되기 때문에 쿠키가 소실된다"*는 전체 아키텍처 관점의 맥락은 AI가 스스로 먼저 파악하고 해결책을 제시해 주지 못했습니다.
결국 시스템 전체의 큰 그림(Architecture)을 그리고, 각 인프라 계층(Layer)이 어떻게 통신하는지 파악하는 것은 오롯이 개발자의 몫이었습니다.
AI라는 강력한 무기를 그저 그런 '자동 완성기'로 쓸 것인지, 백발백중의 '문제 해결 도구'로 쓸 것인지는 결국 개발자가 가진 '컴퓨터 과학의 기초(네트워크, 인프라)와 아키텍처에 대한 이해도'에 달려있음을 뼈저리게 느낀 값진 경험이었습니다.
'개발 공부 > 프로젝트' 카테고리의 다른 글
| Part 3. MongoDB 로그 시스템 구축 (Polyglot Persistence) (0) | 2026.04.30 |
|---|---|
| Part 2. Slack 알림 연동 (0) | 2026.04.30 |
| Part 1. GCP에서 Oracle Cloud로 이전하기 (0) | 2026.04.28 |
| 이상 거래 탐지 트러블슈팅 - StackOverflowError부터 @Transactional 롤백까지 (0) | 2026.04.24 |
| 이상 거래 탐지 구현 - Redis Sliding Window와 이메일 알림 (0) | 2026.04.24 |