Build된 React 파일을
HTTP 응답으로 바꾼다
Nginx는 모델 추론을 하지 않는다. Browser가 요청한 URL을 해석하고 dist의 HTML·JavaScript·CSS를 돌려주는 production web server다.
Build 결과를 저장하는 것과 HTTP로 제공하는 것은 다른 일이다
Vite가 만든 dist는 파일 묶음일 뿐 스스로 network port를 열지 않는다. Browser는 URL을 사용해 HTTP 요청을 보내므로, 요청을 받아 적절한 파일과 status code, header를 돌려줄 process가 필요하다. 여기서는 작고 검증된 Nginx가 그 역할을 맡는다.
입력과 출력
Browser 요청
web root에서 검색
HTML bytes
추가 asset 요청
요청 하나에는 URL만 있는 것이 아니다
Browser가 GET /을 보낼 때 method, path, host와 여러 header가 함께 전달된다. Nginx는 어느 server block이 받을지 고르고, 그 안에서 가장 잘 맞는 location을 선택한 다음 response를 만든다.
Status code와 header는 왜 필요한가?
Status code는 요청 결과를 숫자로 요약한다. 200은 성공, 404는 대상 없음이다. Header는 본문을 어떻게 해석하고 얼마나 저장할지 같은 부가 규칙이다. 본문이 같은 byte라도 Content-Type이 잘못되면 Browser는 JavaScript나 CSS로 실행하지 않을 수 있다.
현재 전체 코드
server {
listen 80;
server_name _;
server_tokens off;
root /usr/share/nginx/html;
index index.html;
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:css|js|svg|png|jpg|jpeg|gif|webp|ico|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}
설정이 실행되는 순서
listen, server_name, root의 관계
listen 80은 container 내부의 TCP port 80에서 연결을 기다리라는 뜻이다. server_name _의 밑줄은 “모든 domain”이라는 특별한 wildcard가 아니라 일반 domain과 겹치지 않게 고른 이름이다. 이 구성처럼 해당 listen 주소·port에 server block이 하나면 그 block이 기본 요청을 받는다. 여러 block이 있다면 listen ... default_server 또는 첫 block 선택 규칙이 기본 server를 정한다. Nginx 공식 server_name 설명에서도 이 차이를 확인할 수 있다. root /usr/share/nginx/html은 URL path를 실제 파일에서 찾을 때 기준이 되는 directory다. 예를 들어 /assets/app.js는 기본적으로 /usr/share/nginx/html/assets/app.js와 대응한다.
GET /healthz
location =는 정확히 같은 URL만 선택한다. 파일을 읽지 않고 즉시 200과 ok를 반환하므로 Docker가 web server 생존 여부를 가볍게 확인한다.
GET /assets/index-BArpG2f7.js
확장자 정규식 location이 선택된다. 파일이 있으면 1년 cache header와 함께 반환한다. 이 예처럼 Vite가 생성한 hash 포함 JS는 내용이 바뀌면 URL도 바뀌므로 장기 캐시와 잘 맞는다. 그러나 아래 location은 hash 존재 여부가 아니라 확장자로만 선택하므로 모든 대상 파일이 안전하게 버전 관리된다는 뜻은 아니다. 파일이 없으면 404다.
GET /chat/example
실제 파일이 없으므로 try_files의 마지막 후보인 /index.html을 반환한다. 이후 React router가 browser URL을 해석할 수 있다. 검증에서는 아직 router가 없어도 Nginx fallback이 HTTP 200을 반환하는지 확인했다.
try_files는 없는 파일을 무조건 성공시키는 규칙이 아니다
try_files $uri $uri/ /index.html은 요청 path와 같은 파일, 같은 directory, 마지막으로 index.html을 순서대로 시도한다. React의 client-side router는 최초 HTML과 JavaScript가 실행된 뒤 /chat/example 같은 경로를 화면으로 해석하므로 새로고침 요청에도 먼저 index.html을 보내야 한다.
반면 asset location은 try_files $uri =404를 사용한다. 존재하지 않는 JavaScript 요청에 index.html을 돌려주면 status는 200이어도 Browser가 HTML을 JavaScript로 해석하려다 오류가 난다. 현재는 정규식에 나열한 css, js, svg, png, jpg, jpeg, gif, webp, ico, woff, woff2에 해당하는 경로만 없는 파일이면 404를 반환한다. /missing.json처럼 정규식에 없는 확장자는 location /로 들어가 index.html fallback이 될 수 있다. “확장자가 있는 모든 asset을 404로 처리한다”는 설명은 현재 설정과 다르다.
1년 cache가 가능한 이유는 filename에 내용의 지문이 있기 때문이다
Vite는 보통 내용에서 계산한 hash를 filename에 포함한다. JavaScript 내용이 바뀌면 index-a1b2.js가 index-c3d4.js처럼 새 이름이 되고 새 index.html은 새 파일을 가리킨다. 그래서 기존 asset을 오래 보관해도 새 배포와 섞일 가능성이 낮다.
favicon.svg·icons.svg처럼 이름에 hash가 없는 파일도 SVG 확장자 규칙에 걸려 1년 캐시 대상이 된다. 같은 URL의 내용을 바꾸면 오래된 파일이 남을 수 있다. 배포 단계에서 URL 버전 관리 또는 hash asset과 public asset의 캐시 정책 분리가 필요하며, 이번에는 nginx.conf 자체를 변경하지 않았다.expires 1y는 Expires와 max-age를 설정하고 add_header는 public, immutable을 추가하므로 실제 응답에는 Cache-Control이 여러 줄로 표시될 수 있다. 위 header 예시는 설명용 일부이며 실제 전체 응답을 그대로 복사한 기록은 아니다.
immutable은 저장 기간 동안 파일이 바뀌지 않을 것이라고 Browser에 알린다. 이 정책을 hash가 없는 index.html에 똑같이 적용하면 이전 HTML이 새 asset 이름을 알지 못할 수 있으므로 HTML cache 정책은 별도로 설계해야 한다.
초기 실행에서 관찰한 결과
/api/ 요청을 BE로 전달하는 reverse proxy 규칙을 추가할지, React가 BE port를 직접 호출할지 비교하고 결정한다.FastAPI가 추가되면 정적 파일 요청과 API 요청이 갈라진다
Nginx를 reverse proxy로 사용한다면 Browser는 하나의 origin만 알면 되어 CORS 구성이 단순해진다. 대신 streaming 응답이 중간에서 buffering되지 않도록 timeout과 proxy buffering 설정을 검토해야 한다. 이 규칙은 FastAPI endpoint가 실제로 정해진 뒤 추가하는 것이 안전하다.