서비스 학습
SOURCE · fe/nginx.conf · 2026-08-30

Build된 React 파일을
HTTP 응답으로 바꾼다

Nginx는 모델 추론을 하지 않는다. Browser가 요청한 URL을 해석하고 dist의 HTML·JavaScript·CSS를 돌려주는 production web server다.

Why Nginx

Build 결과를 저장하는 것과 HTTP로 제공하는 것은 다른 일이다

Vite가 만든 dist는 파일 묶음일 뿐 스스로 network port를 열지 않는다. Browser는 URL을 사용해 HTTP 요청을 보내므로, 요청을 받아 적절한 파일과 status code, header를 돌려줄 process가 필요하다. 여기서는 작고 검증된 Nginx가 그 역할을 맡는다.

역할 구분: Vite는 asset을 만드는 build tool이고 Nginx는 만들어진 asset을 전달하는 web server다. Production에서 Vite 개발 서버를 사용하지 않는 이유는 hot reload 같은 개발 기능보다 안정적인 정적 파일 제공과 cache 제어가 중요하기 때문이다.

입력과 출력

Input GET /

Browser 요청

Lookup index.html

web root에서 검색

Output HTTP 200

HTML bytes

Next JS · CSS

추가 asset 요청

HTTP fundamentals

요청 하나에는 URL만 있는 것이 아니다

Browser가 GET /을 보낼 때 method, path, host와 여러 header가 함께 전달된다. Nginx는 어느 server block이 받을지 고르고, 그 안에서 가장 잘 맞는 location을 선택한 다음 response를 만든다.

Request GET /assets/index-a1b2.js HTTP/1.1 Host: localhost:4173 Accept: */* Response HTTP/1.1 200 OK Content-Type: application/javascript Cache-Control: public, immutable [JavaScript bytes]
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만 선택한다. 파일을 읽지 않고 즉시 200ok를 반환하므로 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을 반환하는지 확인했다.

SPA fallback

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로 처리한다”는 설명은 현재 설정과 다르다.

Caching

1년 cache가 가능한 이유는 filename에 내용의 지문이 있기 때문이다

Vite는 보통 내용에서 계산한 hash를 filename에 포함한다. JavaScript 내용이 바뀌면 index-a1b2.jsindex-c3d4.js처럼 새 이름이 되고 새 index.html은 새 파일을 가리킨다. 그래서 기존 asset을 오래 보관해도 새 배포와 섞일 가능성이 낮다.

코드 A → index-a1b2.js → Cache-Control: max-age=1년, immutable 코드 B → index-c3d4.js → URL 자체가 다르므로 browser가 새로 요청
현재 설정의 주의점: public의 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 정책은 별도로 설계해야 한다.

초기 실행에서 관찰한 결과

GET /healthz → 200 · "ok" GET / → 200 · React index.html GET /chat/example → 200 · index.html fallback Docker health → healthy
다음 변경: Browser용 FastAPI 채팅 endpoint가 정해진 뒤 /api/ 요청을 BE로 전달하는 reverse proxy 규칙을 추가할지, React가 BE port를 직접 호출할지 비교하고 결정한다.
Next boundary

FastAPI가 추가되면 정적 파일 요청과 API 요청이 갈라진다

GET /assets/app.js → Nginx → dist/assets/app.js GET /healthz → Nginx → 즉시 200 POST /api/chat → Nginx → FastAPI container → TurboFieldfare streaming response ← Nginx ← FastAPI ← generated tokens

Nginx를 reverse proxy로 사용한다면 Browser는 하나의 origin만 알면 되어 CORS 구성이 단순해진다. 대신 streaming 응답이 중간에서 buffering되지 않도록 timeout과 proxy buffering 설정을 검토해야 한다. 이 규칙은 FastAPI endpoint가 실제로 정해진 뒤 추가하는 것이 안전하다.

← 이전Dockerfile 상세 다음 →FastAPI Backend