Skip to content

Appendix A. 사내 실무 환경설정 및 Docker/Nginx 배포 가이드 ​

"로컬에서만 잘 돌아가는 코드는 가치가 반감된다. 개발, 검증, 운영 환경을 안전하게 분기하고, Node 22 LTS 멀티스테이지 Docker 빌드와 Nginx 리버스 프록시로 프로덕션에 무중단 배포하는 엔터프라이즈 실무 표준을 정립한다."


📋 이 부록에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.

개념한 줄 요약설명 위치
environment.ts & fileReplacements빌드 시점에 로컬·검증·운영 환경설정 파일을 자동으로 바꿔 끼우는 Angular 표준A.1
APP_CONFIG InjectionToken환경설정 객체를 컴포넌트와 레포지토리에 DI로 안전하게 주입하는 패턴A.1 [코드 A-2]
Docker 멀티스테이지 빌드Node 22 LTS 빌드용 이미지와 25MB 초경량 Nginx 실행용 이미지를 분리A.2 [코드 A-4]
Nginx try_files, 캐시 헤더, proxy_passSPA 히스토리 라우팅 404 방지, 배포 직후 청크 불일치 방어, API 리버스 프록시A.3 [코드 A-5]
host.docker.internal컨테이너 안에서 호스트 PC의 Spring Boot(8080)를 가리키는 도메인A.3, A.4

A.1 환경설정 분기 전략 (environments & fileReplacements) ​

사내 시스템은 보통 로컬(Local), 개발/검증(Dev/Staging), 운영(Production) 환경으로 나뉩니다. 코드 안에 http://localhost:8080을 하드코딩해서는 안 됩니다. Angular CLI는 angular.json의 fileReplacements 설정을 통해 빌드 타깃에 맞는 환경설정 파일을 컴파일 시점에 자동으로 교체해 줍니다.

[코드 A-1] 로컬 및 운영 환경설정 파일 (src/environments/) ​

typescript
// 1. 로컬 개발 환경: frontend/src/environments/environment.ts
export interface AppEnvironment {
  production: boolean;
  envName: 'local' | 'staging' | 'production';
  apiBaseUrl: string;
}

export const environment: AppEnvironment = {
  production: false,
  envName: 'local',
  // 로컬에서는 proxy.conf.json(/api -> localhost:8080)을 타도록 빈 문자열 지정
  apiBaseUrl: ''
};
typescript
// 2. 사내 운영 환경: frontend/src/environments/environment.prod.ts
import { AppEnvironment } from './environment';

export const environment: AppEnvironment = {
  production: true,
  envName: 'production',
  // 운영에서도 Nginx 동일 출처(Same-Origin) 리버스 프록시(/api/)를 타도록 빈 문자열 유지
  apiBaseUrl: ''
};

[코드 A-2] angular.json의 fileReplacements와 InjectionToken 연결 ​

json
// frontend/angular.json (architect.build.configurations.production 발췌)
"configurations": {
  "production": {
    "fileReplacements": [
      {
        "replace": "src/environments/environment.ts",
        "with": "src/environments/environment.prod.ts"
      }
    ],
    "optimization": true,
    "outputHashing": "all",
    "sourceMap": false
  }
}
typescript
// frontend/src/app/core/app-config.token.ts
import { InjectionToken } from '@angular/core';
import { AppEnvironment, environment } from '../../environments/environment';

// 🌟 전역에서 inject(APP_CONFIG)로 환경설정을 꺼내 쓸 수 있는 토큰 (테스트 시 덮어쓰기 용이!)
export const APP_CONFIG = new InjectionToken<AppEnvironment>('APP_CONFIG', {
  providedIn: 'root',
  factory: () => environment
});

CAUTION

environment.prod.ts의 값도 비밀(Secret)이 아닙니다!
Angular가 빌드할 때 environment.prod.ts의 내용은 최종 JavaScript 번들(main-[hash].js) 안에 평문 문자열 그대로 포함됩니다. 브라우저 개발자 도구로 누구나 열람할 수 있으므로 DB 비밀번호, AWS Secret Key, 내부 관리자 마스터 토큰은 절대 프론트엔드 환경변수에 넣지 마십시오. 민감 정보는 오직 백엔드(Spring Boot 서버 환경변수나 Vault)에만 두어야 합니다.

TIP

왜 운영 환경에서도 동일 출처(Same-Origin) /api 리버스 프록시를 권장할까요?
apiBaseUrl: 'https://api.company.com'처럼 프론트엔드 도메인과 다른 주소로 호출하면 매번 CORS Preflight(OPTIONS 요청) 네트워크 왕복 비용이 발생하고, HttpOnly 보안 쿠키 전송 설정(SameSite, withCredentials)이 까다로워집니다. 아래 nginx.conf처럼 Nginx가 /api/ 요청을 백엔드 서버로 프록시(proxy_pass)하도록 구성하면 CORS 문제를 아예 원천 소멸시킬 수 있습니다!


A.2 프로덕션 배포를 위한 .dockerignore와 멀티스테이지 Dockerfile ​

Angular 22 애플리케이션은 빌드(ng build --configuration production)하고 나면 순수한 정적 파일(dist/frontend/browser/ 폴더 안의 HTML, CSS, JS)만 남습니다.
따라서 운영 컨테이너에 무거운 Node.js 런타임이나 node_modules(수백 MB)를 넣을 필요가 전혀 없습니다. **1단계 빌드 스테이지(node:22-alpine)**에서 정적 번들만 구워내고, **2단계 서빙 스테이지(nginx:alpine)**로 결과물만 복사하면 약 25~30MB의 초경량 보안 컨테이너가 완성됩니다.

[코드 A-3] frontend/.dockerignore ​

호스트 OS(Windows/macOS)의 무거운 node_modules나 빌드 캐시가 Docker 빌드 컨텍스트로 복사되지 않도록 반드시 .dockerignore를 먼저 작성합니다:

txt
node_modules
dist
.angular
.git
.vscode
coverage
*.log

[코드 A-4] 멀티스테이지 빌드 (frontend/Dockerfile) ​

dockerfile
# =========================================================================
# 1단계: 빌드 스테이지 (Angular 22 필수 사양인 Node.js 22 LTS 알파인 이미지)
# =========================================================================
FROM node:22-alpine AS builder
WORKDIR /app

# Docker 레이어 캐시 극대화: 소스 코드보다 package*.json을 먼저 복사해 의존성 설치
COPY package*.json ./
RUN npm ci

# 전체 프론트엔드 소스 복사 및 프로덕션 AOT 빌드 실행
COPY . .
RUN npm run build -- --configuration production

# =========================================================================
# 2단계: 서빙 스테이지 (초경량 Nginx 알파인 이미지)
# =========================================================================
FROM nginx:alpine

# 기본 Nginx 설정 제거 후 엔터프라이즈 SPA 전용 설정 주입
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf

# 🌟 Angular 17~22+ Application Builder의 출력 경로는 dist/<프로젝트명>/browser 입니다!
COPY --from=builder /app/dist/frontend/browser /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

🆕 처음 보는 문법: Angular Application Builder 출력 경로 (dist/frontend/browser)

Angular의 최신 esbuild/Vite 기반 application 빌더는 SSR/프리렌더링 호환을 위해 정적 브라우저 에셋을 dist/<project-name>/browser 폴더 아래에 생성합니다(index.html이 이 안에 있습니다). 만약 예전 버전처럼 /app/dist/frontend만 복사하면 Nginx 루트 바로 아래에 browser/ 하위 폴더가 생겨 403 Forbidden이나 404 Not Found가 발생하므로 경로 끝의 /browser를 꼭 확인하십시오!


A.3 SPA 라우팅과 배포 직후 청크 에러 방지를 위한 nginx.conf ​

Angular Router(provideRouter(routes))로 만든 SPA를 Nginx로 서빙할 때 반드시 챙겨야 할 3대 핵심 설정이 있습니다:

  1. SPA Fallback (try_files $uri $uri/ /index.html): 사용자가 /write나 /admin 경로에서 브라우저 새로고침(F5)을 눌렀을 때, Nginx가 물리 파일을 찾다 404 Not Found를 내지 않고 /index.html을 내려주어야 Angular Router가 화면을 이어받습니다.
  2. index.html 캐시 절대 금지 (no-cache, no-store): 브라우저가 옛날 index.html을 캐싱하고 있으면, 신규 배포 직후 해시 이름이 바뀐 지연 로딩 청크(chunk-XYZ.js)를 요청하다가 ChunkLoadError / Failed to fetch dynamically imported module 에러가 터집니다! 반면 해시가 붙은 .js/.css 파일은 1y immutable로 영구 캐싱합니다.
  3. /api/ 백엔드 리버스 프록시: /api/ 요청을 Spring Boot 서버(8080)로 토스합니다.

[코드 A-5] 엔터프라이즈 frontend/nginx.conf ​

nginx
server {
    listen 80;
    server_name localhost;

    root /usr/share/nginx/html;
    index index.html;

    # Gzip 텍스트 압축 전송 (초기 JS/CSS 번들 크기 70% 이상 절감)
    gzip on;
    gzip_min_length 1024;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;

    # 1. index.html은 절대 캐싱하지 않음 (신규 배포 즉시 새 해시 청크를 바라보도록 강제!)
    location = /index.html {
        add_header Cache-Control "no-cache, no-store, must-revalidate";
        add_header Pragma "no-cache";
        add_header Expires "0";
    }

    # 2. Angular CLI가 파일명에 해시(outputHashing: all)를 붙인 정적 에셋은 1년간 영구 캐싱
    location ~* \.(?:js|css|woff2?|ttf|eot|svg|png|jpe?g|gif|ico)$ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;
    }

    # 3. SPA 히스토리 라우팅 폴백: 정적 파일이 없으면 index.html로 위임
    location / {
        try_files $uri $uri/ /index.html;
        add_header Cache-Control "no-cache";
    }

    # 4. Spring Boot 3 백엔드 API 리버스 프록시 (동일 출처 통신으로 CORS 원천 해결)
    # - 로컬 Docker Desktop 실습 시: host.docker.internal:8080 사용
    # - Docker Compose / Kubernetes 운영 시: http://backend-service:8080/api/ 로 변경
    location /api/ {
        proxy_pass http://host.docker.internal:8080/api/;
        proxy_http_version 1.1;
        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;
        proxy_read_timeout 30s;
    }

    error_page 500 502 503 504 /50x.html;
    location = /50x.html {
        root /usr/share/nginx/html;
    }
}

A.4 컨테이너 빌드, 기동 및 검증 명령어 ​

사전 조건: 호스트 PC의 8080 포트에서 Spring Boot 백엔드가 실행 중이어야 합니다(cd backend && ./mvnw spring-boot:run).

bash
# 1. frontend 폴더로 이동 후 프로덕션 Docker 이미지 빌드
cd frontend
docker build -t angular-board-fe:1.0.0 .

# 2-A. 컨테이너 기동 (Windows / macOS Docker Desktop)
docker run -d -p 8081:80 --name board-fe angular-board-fe:1.0.0

# 2-B. 컨테이너 기동 (Linux Docker Engine: host.docker.internal 게이트웨이 명시 필수!)
docker run -d -p 8081:80 \
  --add-host=host.docker.internal:host-gateway \
  --name board-fe angular-board-fe:1.0.0

# 3. 컨테이너 정상 기동 및 Nginx 로그 확인
docker ps
docker logs board-fe

배포 후 3단계 스모크 테스트 (Smoke Test) ​

  1. 초기 화면 및 API 프록시 확인: 브라우저에서 http://localhost:8081에 접속해 게시글 목록이 정상적으로 로드되는지 확인합니다.
  2. SPA 새로고침(F5) 확인: http://localhost:8081/write로 이동(로그인 전이면 /login?returnUrl=%2Fwrite로 리다이렉트됨)한 상태에서 브라우저 **새로고침(F5)**을 눌렀을 때 Nginx 404 에러 없이 화면이 그대로 뜨는지 확인합니다.
  3. 캐시 헤더 확인: 개발자 도구(F12) → Network 탭에서 index.html의 응답 헤더는 Cache-Control: no-cache, no-store, must-revalidate이고, main-XXXX.js의 응답 헤더는 Cache-Control: public, max-age=31536000, immutable인지 확인합니다.

Moon HomeLab Engineering Knowledge Hub