fix: 템플릿 점검 결과 반영 — Node 24·이미지 위생·운영 노출 최소화

템플릿 점검에서 나온 항목을 한 번에 반영한다. 항목별 이유:

- Node 20 → 24 LTS (Dockerfile·CI): Node 20 은 2026-04-30 에 지원이 끝나 보안 패치가 없다
- backend·frontend .dockerignore 추가: `COPY . .` 가 호스트 node_modules(Windows 바이너리)·dist·
  .env*(시크릿)를 이미지에 넣고 있었다
- 업로드 디렉터리 쓰기 검사(fail-fast): Linux 에서 Docker 가 root 소유로 만든 바인드 마운트에
  비-root 앱이 쓰지 못해 첫 업로드에서야 SYS_001 로 터졌다. 기동 시 해결 명령을 안내한다
- DB 커넥션 풀 100 → 10 (DB_POOL_MAX): PostgreSQL 기본 max_connections(100)를 앱 하나가 다 잡았다
- Swagger 운영 기본 비활성 (SWAGGER_ENABLED): API 구조 전체가 공개 도메인에 노출됐다
- 헬스체크 운영 응답에서 오류 상세 제거: 드라이버 오류 문구에 내부 주소·DB 계정명이 담긴다.
  원인은 error 로그로 남긴다
- nginx: location 의 add_header 때문에 정적 자원에서 보안 헤더가 빠지던 문제와,
  index.html 이 캐시돼 배포 직후 빈 화면이 날 수 있던 문제를 고친다
- vite allowedHosts: true 제거 (DEV_ALLOWED_HOSTS): DNS rebinding 보호가 꺼져 있었다
- 전역 설정을 configureApp() 으로 분리해 main.ts 와 e2e 가 공유: e2e 가 설정을 복사해 재현해
  실제 설정과 어긋나도 통과할 수 있었다. PORT 도 빈 값을 방어하는 파서를 거친다
- 쓰이지 않던 WEBSOCKET_PORT 제거: 별도 포트는 같은 도메인 라우팅과 맞지 않는다
- 예제 useUser 가 메시지 대신 에러 코드를 보관하도록 수정, testing.md 표 깨짐 수정,
  settings.json 허용 목록에서 npm install 제거 (Windows lock 파일 규칙과 충돌)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 00:06:02 +09:00
parent 6dcbb45eb9
commit 7897f2de20
41 changed files with 770 additions and 154 deletions
+25
View File
@@ -0,0 +1,25 @@
# ==========================================
# 빌드 컨텍스트에서 제외할 파일
#
# Dockerfile 의 `COPY . .` 가 아래 항목까지 이미지에 넣지 않도록 막는다.
# ==========================================
# 의존성·빌드 산출물 — 컨테이너 안에서 npm ci·빌드를 다시 한다.
# 호스트(Windows) node_modules 가 섞이면 네이티브 바이너리가 리눅스용을 덮어써 빌드가 깨진다.
node_modules
dist
coverage
# 호스트 빌드용 env — 컨테이너 빌드의 값은 루트 .env 가 build arg 로만 넣는다 (SSOT: .claude/rules/env-structure.md)
# 여기서 막지 않으면 호스트에만 둔 로컬 설정이 운영 번들에 섞여 들어간다.
.env
.env.*
# 로그
logs
*.log
# 에디터·OS (.vscode/extensions.json 은 빌드에 쓰이지 않는다)
.vscode
.idea
.DS_Store
+4 -3
View File
@@ -1,8 +1,8 @@
FROM node:20-alpine AS build-stage
FROM node:24-alpine AS build-stage
WORKDIR /app
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
# (전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 고정
# (베이스 이미지의 번들 npm 이 바뀌어도 lock 형식이 어긋나지 않게 한다. 전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
RUN npm install -g npm@11
# 개발 환경(docker-compose.dev.yml)은 이 스테이지에서 vite dev 서버를 구동하므로 비-root 로 전환한다.
@@ -13,6 +13,7 @@ USER node
COPY --chown=node:node package*.json ./
RUN npm ci
# 호스트의 node_modules·dist·.env 는 .dockerignore 로 제외된다
COPY --chown=node:node . .
ARG BUILD_MODE=production
+26 -7
View File
@@ -3,8 +3,15 @@ server {
listen 8080;
server_name localhost;
# 빌드 산출물 위치 — 모든 location 이 공유한다
root /usr/share/nginx/html;
# ----------------------------------------
# 보안 헤더
#
# ⚠️ nginx 는 location 안에 add_header 가 하나라도 있으면 여기(server)의 add_header 를
# **하나도 상속하지 않는다.** 그래서 아래 location 들은 캐시 정책을 add_header 대신
# expires 로 지정한다 — location 에 add_header 를 넣으면 그 응답에서 보안 헤더가 전부 사라진다.
# ----------------------------------------
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
@@ -46,19 +53,31 @@ server {
return 404;
}
# ----------------------------------------
# 캐시 정책 — add_header 대신 expires 를 쓴다 (위 보안 헤더 주의 참조)
# ----------------------------------------
# SPA 진입점 — 매번 재검증한다 (Cache-Control: no-cache).
# 배포 직후 옛 index.html 이 캐시에 남으면 이미 지워진 해시 파일을 요청해 빈 화면이 된다.
# (/ 접속과 SPA 폴백도 내부적으로 /index.html 로 넘어오므로 모두 여기에 걸린다)
location = /index.html {
expires -1;
}
# 빌드 산출물(vite 의 assets 디렉터리) — 파일명에 내용 해시가 붙어 내용이 바뀌면 이름도 바뀌므로 오래 캐시해도 안전하다.
# 없는 파일은 index.html 로 폴백하지 않고 404 — HTML 을 JS 로 받아 생기는 MIME 오류를 막는다.
# (public/ 에서 복사되는 favicon 등은 해시가 없으므로 여기에 넣지 않는다)
location /assets/ {
expires 1y;
try_files $uri =404;
}
# ----------------------------------------
# SPA 라우팅
# ----------------------------------------
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
# 정적 자원 캐싱 (SPA 빌드 산출물은 해시 파일명이라 장기 캐싱 가능)
location ~* \.(?:js|css|woff2?|ttf|svg|png|jpg|jpeg|gif|ico)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
error_page 500 502 503 504 /50x.html;
+14
View File
@@ -56,6 +56,20 @@ function isErrorCode(code: string): code is ErrorCode {
return code in ERROR_CODE_MESSAGES
}
/**
* 인터셉터가 정규화한 ApiError 인지 확인한다.
* 호출부의 catch 에서 code 로 분기하기 전에 쓴다 (API 가 아닌 오류도 catch 로 들어올 수 있다).
*/
export function isApiError(error: unknown): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
typeof error.code === 'string' &&
isErrorCode(error.code)
)
}
/** 에러 코드 → 사용자 노출 문구. 사용자 문구는 이 사전에만 존재한다. */
export function getErrorMessage(code: string): string {
return isErrorCode(code)
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import api, { type ApiError } from './useApi'
import { useUser } from './useUser'
describe('useUser (도메인 composable 패턴)', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('성공하면 언래핑된 데이터를 돌려주고 에러 코드를 비운다', async () => {
const user = { id: 1, email: 'user@example.com', name: '홍길동' }
// 인터셉터가 { success, data } 를 벗긴 뒤의 값을 흉내 낸다 (axios 타입상 AxiosResponse 라 단언이 필요하다)
vi.spyOn(api, 'get').mockResolvedValue(user as never)
const { getUser, errorCode, loading } = useUser()
await expect(getUser(1)).resolves.toEqual(user)
expect(errorCode.value).toBeNull()
expect(loading.value).toBe(false)
})
it('실패하면 메시지가 아니라 에러 코드를 보관한다', async () => {
const error: ApiError = {
code: 'RES_001',
message: '요청하신 정보나 페이지를 찾을 수 없습니다.',
status: 404,
}
vi.spyOn(api, 'get').mockRejectedValue(error)
const { getUser, errorCode } = useUser()
await expect(getUser(999)).resolves.toBeNull()
expect(errorCode.value).toBe('RES_001')
})
it('API 가 아닌 오류는 폴백 코드(SYS_001)로 보관한다', async () => {
vi.spyOn(api, 'get').mockRejectedValue(new Error('예상치 못한 오류'))
const { getUser, errorCode } = useUser()
await expect(getUser(1)).resolves.toBeNull()
expect(errorCode.value).toBe('SYS_001')
})
})
+11 -5
View File
@@ -1,5 +1,9 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { isApiError, useApi } from './useApi'
import {
FALLBACK_ERROR_CODE,
type ErrorCode,
} from '@/shared/constants/error-codes.generated'
// 도메인별 API Composable 샘플 — 실제 도메인이 추가되면 이 패턴을 복제
export interface User {
@@ -11,21 +15,23 @@ export interface User {
export function useUser() {
const api = useApi()
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
// 실패 시 에러 코드만 보관한다 — 화면은 code 로 분기하고, 알림 문구는 인터셉터가 사전에서 이미 띄웠다
const errorCode = ref<ErrorCode | null>(null)
async function getUser(id: number): Promise<User | null> {
loading.value = true
error.value = null
errorCode.value = null
try {
// 인터셉터에서 success/data 언래핑 후 데이터만 반환
return (await api.get<User>(`/users/${id}`)) as unknown as User
} catch (e) {
error.value = (e as Error)?.message ?? 'unknown error'
// 여기서 메시지를 새로 만들지 않는다 (.claude/rules/error-codes.md)
errorCode.value = isApiError(e) ? e.code : FALLBACK_ERROR_CODE
return null
} finally {
loading.value = false
}
}
return { loading, error, getUser }
return { loading, errorCode, getUser }
}
+15 -2
View File
@@ -24,6 +24,16 @@ function parsePositiveInt(value: string | undefined, fallback: number): number {
return parsed
}
/** 콤마로 구분된 목록을 파싱한다 (공백·빈 항목 제거) */
function parseList(value: string | undefined): string[] {
if (value === undefined) return []
return value
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry !== '')
}
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
// 포트·주소의 SSOT 는 루트 `.env.{mode}` 다 (.claude/rules/env-structure.md).
@@ -53,8 +63,11 @@ export default defineConfig(({ mode }) => {
},
},
server: {
// ngrok 등 외부 도메인 접근 허용 (DNS Rebinding 보호 비활성화)
allowedHosts: true,
// dev 서버에 접근을 허용할 호스트. 기본(빈 목록)은 localhost·IP 접근만 허용한다.
// true 로 열면 vite 의 DNS rebinding 보호가 꺼져, 악성 사이트가 개발자의 브라우저를 거쳐
// dev 서버(소스)와 /api 프록시(로컬 백엔드)에 닿을 수 있다.
// ngrok 같은 터널을 쓸 때만 루트 .env 의 DEV_ALLOWED_HOSTS 에 콤마로 적는다 (예: .ngrok-free.app)
allowedHosts: parseList(rootEnv.DEV_ALLOWED_HOSTS),
// 루트 .env 의 FRONTEND_PORT 를 따른다.
// 컨테이너로 띄울 때는 compose 의 `--port`(컨테이너 내부 고정 포트)가 이 값을 덮어쓴다.
port: frontendPort,