목차
- 왜 SPA에서 새로고침이 깨지는가
- ingress-nginx로 해결하는 방법
- 캐싱 정책 — 새로고침 404 다음으로 만나는 함정
- subpath 배포 시 basename 설정
- 헬스체크는 별도 경로로
- 배포 전 5분 점검 체크리스트
- 자주 보는 함정들
- 지금 바로 할 수 있는 액션
2026/05/24 09:14:22 [error] 21#21: *7821 open() "/usr/share/nginx/html/dashboard" failed (2: No such file or directory)
GET /dashboard HTTP/1.1
Host: app.internal.example.com
Status: 404 Not Found
따라서, 이건 Kubernetes Ingress React SPA 라우팅 환경에서 가장 자주 만나는 에러다. /로 접속하면 React 앱이 잘 뜨는데, /dashboard에서 새로고침하면 위 로그가 찍히면서 404가 떨어진다. 신입이 입사 첫 주에 "왜 새로고침만 하면 페이지가 깨지나요"라고 묻는 그 상황.
원인은 단순하다. React Router의 BrowserRouter는 브라우저 History API로 경로를 가짜로 만든다. 서버 입장에서 /dashboard는 실제로 존재하지 않는 파일이다. nginx가 기본 설정대로 정적 파일을 찾으면 당연히 못 찾고 404를 반환한다.
실제로, 해결책도 단순하다. 서버가 모든 경로에 대해 index.html을 돌려주게 만들면 된다. 단 Kubernetes Ingress 환경에서는 nginx 설정 파일을 직접 수정할 수 없다는 게 걸린다. ingress-nginx 컨트롤러가 동적으로 설정을 생성하기 때문이다. 그래서 annotation 또는 컨테이너 내부 nginx 설정으로 우회한다.
왜 SPA에서 새로고침이 깨지는가
이건 React만의 문제가 아니다. Vue Router, Angular Router, Svelte Router 전부 같은 구조다. 클라이언트 사이드 라우팅은 JavaScript가 로드된 뒤에야 동작한다.
또한, 처음 /에 접속할 때의 흐름은 이렇다. 서버가 index.html을 내려준다. 브라우저가 JS 번들을 받아 React가 실행된다. React Router가 현재 URL을 읽고 해당 컴포넌트를 렌더링한다. 여기까지는 문제가 없다.
즉, 문제는 /dashboard에서 새로고침할 때다. 브라우저가 서버에 /dashboard GET 요청을 보낸다. 서버는 /dashboard라는 파일이 없으니 404를 반환한다. JS가 로드되지 않으니 React Router도 동작 못 한다. 사용자 입장에서는 멀쩡히 보던 페이지가 갑자기 nginx 기본 404 페이지로 바뀐다.
결국 서버가 어떤 경로로 들어오든 index.html을 돌려주고, 그 뒤는 React에 맡기는 구조로 만들어야 한다. nginx로 치면 try_files $uri $uri/ /index.html 한 줄이면 끝나는 일이다. 문제는 Kubernetes Ingress에서는 이 한 줄을 어디에 어떻게 넣느냐다.
ingress-nginx로 해결하는 방법
예를 들어, nginx-ingress 컨트롤러가 설치된 클러스터를 기준으로 설명한다. 2026년 5월 기준 v1.10 이상이면 동일하게 동작한다. (출처: kubernetes/ingress-nginx 공식 문서)
방법 1: configuration-snippet 어노테이션
nginx.ingress.kubernetes.io/configuration-snippet 어노테이션으로 nginx location 블록에 직접 지시문을 끼워 넣을 수 있다.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: frontend-ingress
annotations:
# 모든 요청에서 파일 없으면 index.html 반환
nginx.ingress.kubernetes.io/configuration-snippet: |
try_files $uri $uri/ /index.html =404;
spec:
ingressClassName: nginx
rules:
- host: app.internal.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: frontend-svc
port:
number: 80
결국, 이렇게 하면 모든 요청에 대해 nginx가 실제 파일을 찾고, 없으면 /index.html을 반환한다. 가장 단순하고 직관적이다.
다만 ingress-nginx v1.9부터 allow-snippet-annotations가 기본적으로 false가 됐다. (출처: ingress-nginx Release Notes v1.9.0) 보안 이슈 때문이다. 따라서 ConfigMap에서 한 번 켜줘야 한다.
apiVersion: v1
kind: ConfigMap
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
data:
allow-snippet-annotations: "true"
실제로, 회사 보안팀이 snippet 어노테이션을 막아두는 경우가 늘고 있다. 그런 환경이면 방법 2로 가야 한다.
방법 2: 컨테이너 내부 nginx에 try_files 박기
snippet 없이 풀려면 backend Pod 안에서 nginx 설정을 직접 잡고, Ingress는 단순 라우팅만 시킨다. 즉 SPA 서빙용 nginx 이미지에 try_files를 박아 넣는 방식이다.
Dockerfile:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
nginx.conf:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA 라우팅: 모든 경로에서 index.html 반환
location / {
try_files $uri $uri/ /index.html;
}
# 해시 붙은 정적 자산은 1년 캐싱
location ~* \.(?:js|css|woff2|png|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# index.html은 캐싱 금지
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# 헬스체크 경로
location = /healthz {
access_log off;
return 200 "ok\n";
}
}
이 방식이 가장 안정적이다. Ingress가 어떻게 바뀌든 컨테이너 내부에서 라우팅이 처리된다. 운영 환경에서는 이쪽을 권장한다.
방법 3: default-backend로 분기
그런데, 특정 경로 그룹만 SPA로 보내고 싶을 때 쓰는 패턴이다. 여러 마이크로프론트엔드를 동일 도메인에서 운영할 때 유용하다. 구조가 더 복잡하니 이 글에서는 다루지 않는다.
캐싱 정책 — 새로고침 404 다음으로 만나는 함정
라우팅 문제를 풀고 나면 곧바로 만나는 게 캐싱이다. 배포는 했는데 사용자 화면이 안 바뀐다는 컴플레인이 도착한다.
한편, 원인은 두 가지가 자주 겹친다. nginx의 expires 헤더, 그리고 Kubernetes Ingress 앞단 CDN 또는 LB의 캐시. 핵심 원칙은 단순하다.
| 파일 종류 | Cache-Control | 이유 |
|---|---|---|
index.html |
no-store, no-cache |
매번 최신을 받아야 새 번들 해시를 알 수 있다 |
해시 붙은 JS/CSS (main.a1b2c3.js) |
public, max-age=31536000, immutable |
파일명이 바뀌므로 영구 캐싱 안전 |
| 폰트, 이미지 (해시 없음) | public, max-age=3600 |
보수적으로 1시간 |
위 nginx.conf에 이미 반영돼 있다. Vite, Webpack 둘 다 빌드 시 해시를 붙이므로 같은 정책이 적용된다.
CDN을 앞단에 두는 경우 origin 헤더를 존중하는지 한 번 더 확인한다. CloudFront는 기본적으로 origin 헤더를 무시하고 자체 TTL을 쓴다. CloudFront Distribution에서 Cache Policy를 CachingDisabled 또는 Use origin headers로 바꿔야 한다.
subpath 배포 시 basename 설정
루트가 아닌 /admin, /console 같은 서브패스에 SPA를 올릴 때 추가 작업이 필요하다. React Router에 basename을 알려줘야 한다.
import { BrowserRouter } from 'react-router-dom';
function App() {
return (
// Vite의 BASE_URL을 그대로 basename으로 사용
<BrowserRouter basename={import.meta.env.BASE_URL}>
{/* 라우트 정의 */}
</BrowserRouter>
);
}
반면, Vite는 vite.config.ts에 base를 잡으면 빌드 시 모든 자산 경로에 prefix가 붙는다.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
base: '/admin/',
plugins: [react()],
});
Ingress 쪽 path도 맞춰야 한다.
spec:
rules:
- host: app.internal.example.com
http:
paths:
- path: /admin
pathType: Prefix
backend:
service:
name: admin-svc
port:
number: 80
따라서, 여기서 자주 빠지는 함정 하나. nginx.ingress.kubernetes.io/rewrite-target: / 를 무심코 붙이면 /admin/dashboard가 백엔드 nginx에서는 /dashboard로 들어온다. SPA 라우터의 basename과 어긋난다. rewrite-target은 쓰지 말거나, basename을 빈 문자열로 잡거나 둘 중 하나로 통일해야 한다.
반면, (개인적으로는 rewrite-target을 안 쓰고 prefix를 그대로 백엔드로 넘기는 쪽이 디버깅이 편하다.)
헬스체크는 별도 경로로
readiness probe 경로는 /가 아니라 별도 정적 응답으로 잡아두는 게 좋다. 위 nginx.conf의 /healthz 블록이 그 용도다.
readinessProbe:
httpGet:
path: /healthz
port: 80
initialDelaySeconds: 3
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: 80
initialDelaySeconds: 10
periodSeconds: 10
/로 헬스체크를 잡으면 index.html을 매번 내려주게 돼서 access log가 지저분해진다. 별도 경로로 분리하면 깔끔하다.
배포 전 5분 점검 체크리스트
즉, 실서비스 배포 직전에 돌려보는 점검 항목이다. PR 머지 전에 이 순서로 확인하면 누락이 잘 안 생긴다.
- 루트
/접속 — 페이지가 정상으로 뜨는가 /dashboard같은 하위 경로 직접 입력 — 페이지가 뜨는가/dashboard에서 새로고침 — 같은 페이지가 다시 뜨는가 (가장 중요)- 빌드 후 새 번들 배포 — 강력 새로고침 없이 새 화면이 보이는가
- 네트워크 탭에서
index.html응답 헤더 —Cache-Control: no-store가 찍히는가 - 네트워크 탭에서
main.*.js응답 헤더 —Cache-Control: public, immutable이 찍히는가 - 존재하지 않는 정적 파일 (
/static/nonexistent.png) 호출 — 404가 떨어지는가 (이건 떨어져야 정상)
7번이 의외로 자주 빠진다. try_files를 너무 넓게 잡으면 존재하지 않는 이미지 요청에도 index.html이 돌아간다. 그러면 브라우저 콘솔에 "Failed to load image" 대신 "Unexpected token <" 같은 엉뚱한 에러가 뜬다. 디버깅 난이도가 확 올라간다.
위 nginx.conf의 try_files $uri $uri/ /index.html; 마지막에 =404를 붙이거나, 정적 자산 경로만 별도 location으로 분리해 두면 막을 수 있다.
자주 보는 함정들
CORS와 헷갈리는 케이스
/api/... 요청이 404인데 SPA 라우팅 문제로 오인하는 경우가 자주 보인다. API 서버를 별도 Ingress로 분리했거나, 같은 도메인의 다른 path로 잡았는데 Ingress rule이 누락된 케이스다. 브라우저 네트워크 탭에서 응답 헤더의 Server 값을 본다. nginx가 찍혀 있고 응답 본문이 HTML이면 라우팅이 SPA로 흘러간 거고, 다른 게이트웨이면 API 쪽 문제다.
nginx 이미지 태그 고정
try_files 자체는 nginx 0.7부터 있던 지시문이라 버전 영향이 거의 없다. 단 alpine 이미지 태그를 latest로 잡아두면 baseline이 바뀌면서 의도치 않은 동작 변화가 생긴다. 작성 시점(2026년 5월) 기준 nginx:1.27-alpine처럼 명시적으로 고정해서 쓰는 게 안전하다.
Pod 한 개로 운영할 때 502
readiness probe가 너무 빡빡하면 롤링 업데이트 중에 잠깐 502가 떨어진다. Pod 한 개로 운영하는 사이드 프로젝트면 maxUnavailable: 0, maxSurge: 1을 명시해서 새 Pod가 뜬 뒤에 기존 Pod가 죽도록 잡아주는 쪽이 안전하다.
지금 바로 할 수 있는 액션
결국, 이 글을 보고 당장 적용할 수 있는 일은 세 가지다.
예를 들어, 첫째, 현재 운영 중인 React SPA의 임의 경로에서 새로고침해본다. 404가 떨어지면 이 글의 방법 2(컨테이너 내부 nginx)를 그대로 적용한다. 가장 안정적이고 환경 의존이 적다.
둘째, 브라우저 네트워크 탭에서 index.html의 Cache-Control 헤더를 본다. no-store가 아니면 nginx.conf의 location = /index.html 블록을 추가한다. 배포해도 화면이 안 바뀐다는 컴플레인이 사라진다.
셋째, Ingress YAML에 configuration-snippet 어노테이션이 박혀 있다면 ingress-nginx 버전과 allow-snippet-annotations ConfigMap을 확인한다. v1.9 이상인데 이 옵션이 true로 안 켜져 있으면 어노테이션이 무시되고 있는 상태다.
그러나, default-backend나 멀티 마이크로프론트엔드 구성은 아직 회사 환경에서 충분히 굴려보지 못해서 안정성은 더 지켜봐야 한다.
관련 글
- React 앱 Kubernetes 배포 최적화 — 1.2GB 이미지를 28MB로 줄인 기록 – React 앱을 클러스터에 올렸더니 이미지 1.2GB, 롤링 중 502 에러. 멀티스테이지 빌드와 readiness probe로 둘 다 잡…
- 쿠버네티스 Ingress Nginx 설정 완전판: TLS·경로 라우팅·Rate Limit·Canary 실전 – 쿠버네티스 Ingress Nginx 설정을 단순 설치만 하고 끝내면 운영 들어가서 반드시 한 번은 터진다. TLS, 라우팅, Rate Li…
- React Query Retry 재시도 전략: Exponential Backoff와 Jitter 적용기 – React Query 기본 retry로는 thundering herd를 막지 못한다. exponential backoff에 full jit…