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:
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user