운영 배포한 React/Vite 앱에서 API 호출이 작동을 안 했다.
근데 이상한 점은 응답 status가 200이라는 거. 에러도 안 나는데 데이터만 없었다. axios 인터셉터에서도 catch에 안 걸리고, 그냥 빈 객체나 undefined가 반환됐다.
const res = await axios.get('/api/users');
console.log(res.status); // 200
console.log(res.data); // ??? 뭔가 이상함
뭐가 문제인지 감이 안 잡혀서 일단 Network 탭을 열었다. 그리고 응답 헤더를 보는 순간 이거다 싶었다.
Content-Type: text/html; charset=utf-8
JSON이 와야 하는데 HTML이 오고 있었다. Response 본문을 열어보니 그대로 index.html이었다. 우리 앱의 진입점 HTML 파일이 API 응답으로 오고 있던 것.
status 200 + Content-Type text/html. 이 조합은 거의 100% nginx의 SPA fallback이 범인이다.
React 같은 SPA는 클라이언트 사이드 라우팅을 쓴다. /users/123 같은 경로로 새로고침하면 nginx는 그런 파일을 못 찾는다. 그래서 보통 이렇게 fallback을 걸어둔다.
location / {
try_files $uri $uri/ /index.html;
}
해석하면 "요청한 파일이 없으면 그냥 index.html을 내려줘라"다. 그래야 새로고침해도 React Router가 동작한다.
문제는 이 규칙이 /api/users 같은 백엔드 경로에도 똑같이 적용된다는 것. nginx 입장에서는 /api/users라는 정적 파일이 없으니까 약속대로 index.html을 내려준다. 그래서:
<!DOCTYPE html>... (index.html 그 자체)프론트는 JSON으로 파싱하려다 조용히 실패하거나 이상한 값을 반환한다. 에러도 없고 status도 200이라 디버깅이 더 헷갈린다.
/api 경로는 백엔드로 프록시location /api를 별도로 두고 백엔드로 넘겨주면 된다.
server {
listen 80;
server_name example.com;
# API 요청은 백엔드로 프록시 (이게 먼저!)
location /api {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 그 외 모든 경로는 SPA fallback
location / {
root /var/www/html;
try_files $uri $uri/ /index.html;
}
}
nginx의 location 매칭은 prefix가 더 긴 게 우선이라 /api로 시작하는 요청은 위쪽 블록에서 잡힌다. 나머지는 아래에서 SPA fallback으로 처리된다.
배포 후 reload만 잊지 말자.
sudo nginx -t # 문법 체크
sudo nginx -s reload # 무중단 적용
개발 환경에서는 Vite의 server.proxy 설정이 이 역할을 알아서 해준다. 그래서 로컬에선 멀쩡하다가 운영에서 터진다.
// vite.config.js — 개발 환경에서만 동작
export default {
server: {
proxy: { '/api': 'http://localhost:8080' }
}
}
운영에서는 Vite가 없다. 빌드된 정적 파일을 nginx가 서빙할 뿐이라 프록시는 nginx 설정으로 다시 해줘야 한다. 이 둘을 분리해서 생각하지 못하면 똑같은 함정에 또 빠진다.
status 200인데 데이터가 이상하면, 일단 Network 탭에서 Content-Type부터 확인하자.
API가 HTML을 응답으로 주고 있다면 99% 이 문제다.