Initial commit

This commit is contained in:
2026-10-28 15:30:05 +09:00
commit 696e34f403
269 changed files with 33654 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# ==========================================
# 프론트엔드 환경변수 (development)
#
# ⚠️ VITE_API_BASE_URL 은 루트 .env.development 의 BACKEND_API_URL 이
# docker-compose build arg 로 주입된다 — 여기에 작성할 필요가 없다.
# (SSOT: 루트 .env — .claude/rules/env-structure.md)
#
# Docker 없이 vite dev 서버를 직접 띄울 때만 아래 값을 사용한다.
# cp .env.development.example .env.development
#
# 모든 키는 VITE_ 접두어가 있어야 클라이언트 번들에 포함된다.
# ⚠️ 번들에 그대로 노출되므로 시크릿을 넣지 말 것.
# ==========================================
# VITE_API_BASE_URL=http://localhost:3000/api
+15
View File
@@ -0,0 +1,15 @@
# ==========================================
# 프론트엔드 환경변수 (production)
#
# ⚠️ VITE_API_BASE_URL 은 루트 .env.production 의 BACKEND_API_URL 이
# docker-compose build arg 로 주입된다 — 여기에 작성할 필요가 없다.
# (SSOT: 루트 .env — .claude/rules/env-structure.md)
#
# Docker 없이 vite dev 서버를 직접 띄울 때만 아래 값을 사용한다.
# cp .env.production.example .env.production
#
# 모든 키는 VITE_ 접두어가 있어야 클라이언트 번들에 포함된다.
# ⚠️ 번들에 그대로 노출되므로 시크릿을 넣지 말 것.
# ==========================================
# VITE_API_BASE_URL=http://localhost:3000/api
+48
View File
@@ -0,0 +1,48 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.development
.env.production
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": true
}
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"oxc.oxc-vscode"
]
}
+36
View File
@@ -0,0 +1,36 @@
FROM node:20-alpine AS build-stage
WORKDIR /app
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
# (전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
RUN npm install -g npm@11
# 개발 환경(docker-compose.dev.yml)은 이 스테이지에서 vite dev 서버를 구동하므로 비-root 로 전환한다.
# /app 소유권을 넘겨야 익명 볼륨(node_modules)이 node 소유로 생성되어 vite 캐시(.vite) 쓰기가 가능하다.
RUN chown node:node /app
USER node
COPY --chown=node:node package*.json ./
RUN npm ci
COPY --chown=node:node . .
ARG BUILD_MODE=production
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build-only -- --mode ${BUILD_MODE}
# 비-root 전용 nginx 이미지 (보안 규칙: 컨테이너 root 실행 금지)
# 1024 미만 포트는 바인드할 수 없으므로 8080 을 사용한다 — nginx.conf 의 listen 과 일치해야 한다.
FROM nginxinc/nginx-unprivileged:stable-alpine AS production-stage
# 보안 헤더 포함된 nginx 설정 적용
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build-stage /app/dist /usr/share/nginx/html
# 이미지 기본 사용자(nginx, uid 101)로 구동 — root 로 되돌리지 말 것
USER nginx
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
+48
View File
@@ -0,0 +1,48 @@
# frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
// Vite 환경변수 타입 정의 — VITE_ prefix 만 클라이언트 번들에 포함됨
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+37
View File
@@ -0,0 +1,37 @@
import pluginVue from 'eslint-plugin-vue'
import vueTsEslintConfig from '@vue/eslint-config-typescript'
// Flat config — Vue 3 + TypeScript 표준 권장 룰
// .claude/skills/vue3/SKILL.md 의 규칙 중 기계적으로 강제 가능한 것을 여기에 담는다.
export default [
...pluginVue.configs['flat/recommended'],
...vueTsEslintConfig(),
{
rules: {
// 컴포넌트 단어 수 강제 해제 (HomePage 등 단일 컴포넌트도 허용)
'vue/multi-word-component-names': 'off',
// Composition API + <script setup> 강제 — Options API 금지
'vue/component-api-style': ['error', ['script-setup']],
// 인라인 스타일 금지 → <style scoped> 사용
'vue/no-static-inline-styles': ['error', { allowBinding: false }],
// 콘솔 출력 금지 (운영 빌드에 디버그 로그가 남지 않도록)
'no-console': 'error',
// 명시적 타입 선언 — any 금지
'@typescript-eslint/no-explicit-any': 'error',
},
},
{
// 생성 파일은 스크립트가 만든 산출물이므로 포맷 규칙 대상에서 제외
files: ['src/**/*.generated.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
},
]
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="프로젝트 베이스 템플릿" />
<title>Project Base</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
server {
# 비-root(nginx-unprivileged) 구동이라 1024 미만 포트를 쓸 수 없다
listen 8080;
server_name localhost;
# ----------------------------------------
# 보안 헤더
# ----------------------------------------
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Content-Security-Policy — XSS 방어의 핵심 헤더
#
# ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명:
# - default-src 'self' : 기본적으로 같은 출처만 허용
# - style-src 'unsafe-inline' : Vue 의 동적 스타일 바인딩(:style)이 인라인 스타일을 생성하므로 필요
# - connect-src 'self' : API 가 다른 도메인이면 그 origin 을 반드시 추가할 것
# (예: connect-src 'self' https://api.example.com)
# - img-src data: blob: : 인라인 아이콘·캔버스/파일 미리보기 대응
# - frame-ancestors 'none' : 클릭재킹 차단 (X-Frame-Options 의 최신 대체재)
#
# 외부 폰트·분석 스크립트·이미지 CDN 을 도입하면 해당 도메인을 명시적으로 추가한다.
# script-src 에 'unsafe-inline'/'unsafe-eval' 을 추가하면 CSP 의 의미가 사라지므로 피할 것.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# ----------------------------------------
# 압축 (Gzip)
# ----------------------------------------
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
# ----------------------------------------
# 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;
location = /50x.html {
root /usr/share/nginx/html;
}
# 숨김 파일 접근 차단
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
+7053
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix",
"format": "oxfmt src/"
},
"dependencies": {
"axios": "^1.14.0",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.1",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/eslint-config-typescript": "^14.2.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.1",
"eslint": "^9.18.0",
"eslint-plugin-vue": "^9.32.0",
"jsdom": "^29.0.1",
"npm-run-all2": "^8.0.4",
"oxfmt": "^0.42.0",
"typescript": "~6.0.0",
"vite": "^8.0.3",
"vite-plugin-vue-devtools": "^8.1.1",
"vitest": "^4.1.2",
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
// 최상위 레이아웃 — 라우터 뷰만 마운트, 도메인 별 레이아웃은 라우트 단위로 분리
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<style scoped></style>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
// 공통 버튼 컴포넌트 — 모든 재사용 컴포넌트의 기준 패턴
// (타입 지정 Props/Emits, scoped 스타일, rem 단위, 기본 접근성 속성)
// 사용 예: <BaseButton label="저장" variant="primary" @click="onSave" />
// Props 정의
interface Props {
// 버튼 라벨 (접근성 aria-label 로도 사용)
label: string
// 시각 변형
variant?: 'primary' | 'secondary'
// 비활성화 여부
disabled?: boolean
// 네이티브 button type
type?: 'button' | 'submit'
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
disabled: false,
type: 'button',
})
// Emits 정의
interface Emits {
// 클릭 이벤트
(e: 'click', event: MouseEvent): void
}
const emit = defineEmits<Emits>()
// 클릭 핸들러 — 비활성화 상태에서는 이벤트를 전파하지 않음
function handleClick(event: MouseEvent) {
if (props.disabled) return
emit('click', event)
}
</script>
<template>
<button
:type="type"
:class="['base-button', `base-button--${variant}`]"
:disabled="disabled"
:aria-label="label"
:aria-disabled="disabled"
@click="handleClick"
>
{{ label }}
</button>
</template>
<style scoped>
/* 모든 치수는 rem 기준 (16px = 1rem) */
.base-button {
padding: 0.5rem 1rem;
font-size: 1rem;
border: none;
border-radius: 0.375rem;
cursor: pointer;
}
.base-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.base-button--primary {
background-color: #4f46e5;
color: #ffffff;
}
.base-button--secondary {
background-color: #e5e7eb;
color: #111827;
}
</style>
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import {
ERROR_CODE_MESSAGES,
FALLBACK_ERROR_CODE,
} from '@/shared/constants/error-codes.generated'
import { getErrorMessage } from './useApi'
describe('에러 코드 사전', () => {
it('등록된 코드는 해당 한국어 메시지를 반환한다', () => {
expect(getErrorMessage('AUTH_001')).toBe(
'로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
)
})
it('미등록 코드는 SYS_001 폴백 메시지를 반환한다', () => {
expect(getErrorMessage('UNKNOWN_999')).toBe(
ERROR_CODE_MESSAGES[FALLBACK_ERROR_CODE],
)
})
it('클라이언트 로직이 참조하는 코드가 사전에 존재한다', () => {
// useApi 의 상태코드 역매핑과 세션 만료 승격이 이 코드들을 직접 사용한다.
// 사전에서 빠지면 폴백 메시지가 나가므로 반드시 존재해야 한다.
//
// ⚠️ 전체 목록을 비교하지 않는 이유: 세 플랫폼의 사전은 backend 레지스트리에서
// 생성되므로 목록 일치는 구조적으로 보장되고, CI 가 드리프트를 검사한다.
// 여기서 전수 비교를 하면 도메인 코드를 추가할 때마다 무의미하게 깨진다.
const usedByClient = [
'SYS_001',
'REQ_001',
'AUTH_001',
'AUTH_002',
'AUTH_003',
'VAL_001',
'RES_001',
'BIZ_001',
]
for (const code of usedByClient) {
expect(ERROR_CODE_MESSAGES).toHaveProperty(code)
}
})
it('모든 코드는 DOMAIN_NNN 네이밍 규약을 따른다', () => {
for (const code of Object.keys(ERROR_CODE_MESSAGES)) {
expect(code).toMatch(/^[A-Z]+_\d{3}$/)
}
})
})
+148
View File
@@ -0,0 +1,148 @@
import axios, {
type AxiosInstance,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios'
import {
ERROR_CODE_MESSAGES,
FALLBACK_ERROR_CODE,
type ErrorCode,
} from '@/shared/constants/error-codes.generated'
import { useAuthStore } from '@/stores/auth.store'
// 표준 응답 포맷 타입 (Global Response Wrapper) — 백엔드 TransformInterceptor 와 동일 구조
export interface StandardResponse<T = unknown> {
success: boolean
data?: T
error?: {
code: string
message: string
/** 서버 요청 추적 ID — 문의 대응 시 로그 검색 키 */
traceId?: string
}
}
/** 인터셉터가 reject 할 때 넘기는 정규화된 에러 — 호출부는 code 로 분기한다. */
export interface ApiError {
code: ErrorCode
message: string
traceId?: string
status?: number
}
const API_TIMEOUT_MS = 10000
/** 동일 코드의 알림이 짧은 시간에 중복 노출되는 것을 막는 간격 */
const DUPLICATE_ALERT_WINDOW_MS = 3000
function isErrorCode(code: string): code is ErrorCode {
return code in ERROR_CODE_MESSAGES
}
/** 에러 코드 → 사용자 노출 문구. 사용자 문구는 이 사전에만 존재한다. */
export function getErrorMessage(code: string): string {
return isErrorCode(code)
? ERROR_CODE_MESSAGES[code]
: ERROR_CODE_MESSAGES[FALLBACK_ERROR_CODE]
}
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수 하나만 교체한다.
let lastAlert: { code: string; at: number } | null = null
const showErrorUI = (code: ErrorCode, message: string) => {
const now = Date.now()
if (lastAlert && lastAlert.code === code && now - lastAlert.at < DUPLICATE_ALERT_WINDOW_MS) {
return
}
lastAlert = { code, at: now }
window.alert(message)
}
// 중앙 Axios 인스턴스
const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: API_TIMEOUT_MS,
// HttpOnly/Secure 쿠키 인증 시 자격 증명을 함께 전송
withCredentials: true,
})
// 요청 인터셉터 — CSRF, 추가 헤더 등 필요 시 확장
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => config,
(error: unknown) => Promise.reject(error),
)
/** HTTP 상태 코드 → 에러 코드 폴백 (서버가 표준 에러 본문을 주지 못한 경우) */
function resolveCodeByStatus(status: number): ErrorCode {
if (status === 401) return 'AUTH_001'
if (status === 403) return 'AUTH_003'
if (status === 400 || status === 422) return 'VAL_001'
if (status === 404) return 'RES_001'
if (status === 429) return 'REQ_001'
return FALLBACK_ERROR_CODE
}
/**
* 인증 세션이 있는 상태에서 401 을 받으면 "세션 만료"로 간주한다.
* AUTH_002 는 서버가 내려주지 않는 클라이언트 전용 코드다.
*/
function toAuthCode(code: ErrorCode): ErrorCode {
if (code !== 'AUTH_001') return code
const authStore = useAuthStore()
if (authStore.isLoggedIn) {
authStore.clearAuth()
return 'AUTH_002'
}
return 'AUTH_001'
}
/** 에러 코드를 확정하고 알림까지 처리한 뒤 정규화된 에러를 만든다. */
function handle(rawCode: string, traceId?: string, status?: number): ApiError {
const code = toAuthCode(isErrorCode(rawCode) ? rawCode : FALLBACK_ERROR_CODE)
const message = getErrorMessage(code)
showErrorUI(code, message)
return { code, message, traceId, status }
}
// 응답 인터셉터 — Global Response Wrapper 언래핑 + 에러코드 → 메시지 매핑
api.interceptors.response.use(
(response: AxiosResponse<StandardResponse>) => {
const payload = response.data
if (payload && typeof payload.success === 'boolean') {
if (payload.success) {
// 성공 시 data 만 언래핑하여 반환
return payload.data as never
}
// HTTP 200 이지만 비즈니스 로직상 실패인 경우 (백엔드는 4xx 로 내리지만 방어적으로 처리)
return Promise.reject(
handle(payload.error?.code ?? 'BIZ_001', payload.error?.traceId, response.status),
)
}
return payload as never
},
(error) => {
if (!error?.response) {
// 네트워크 단절·타임아웃 — 서버 응답 자체가 없는 경우
return Promise.reject(handle(FALLBACK_ERROR_CODE))
}
const status: number = error.response.status
const payload = error.response.data as StandardResponse | undefined
const code = payload?.error?.code ?? resolveCodeByStatus(status)
return Promise.reject(handle(code, payload?.error?.traceId, status))
},
)
/**
* useApi — 컴포넌트에서 axios 인스턴스를 가져올 때 사용하는 composable
* 직접 import 대신 useApi() 사용을 권장 (테스트 시 모킹 용이)
*/
export function useApi() {
return api
}
export default api
+43
View File
@@ -0,0 +1,43 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { useAuthStore } from '@/stores/auth.store'
// 인증 도메인 API Composable 샘플 — 로그인/로그아웃 흐름의 시작점
// 실제 인증 엔드포인트가 추가되면 이 패턴을 그대로 사용/확장한다.
export interface LoginPayload {
email: string
password: string
}
export function useAuth() {
const api = useApi()
const authStore = useAuthStore()
const loading = ref<boolean>(false)
async function login(payload: LoginPayload): Promise<boolean> {
loading.value = true
try {
// 서버는 인증 성공 시 HttpOnly 쿠키로 토큰을 내려준다 (응답 바디에 토큰 없음)
await api.post('/auth/login', payload)
authStore.markAuthenticated()
return true
} catch {
// 에러 메시지/알림은 useApi 인터셉터에서 전역 처리됨
return false
} finally {
loading.value = false
}
}
async function logout(): Promise<void> {
loading.value = true
try {
await api.post('/auth/logout')
} finally {
authStore.clearAuth()
loading.value = false
}
}
return { loading, login, logout }
}
+31
View File
@@ -0,0 +1,31 @@
import { ref } from 'vue'
import { useApi } from './useApi'
// 도메인별 API Composable 샘플 — 실제 도메인이 추가되면 이 패턴을 복제
export interface User {
id: number
email: string
name: string
}
export function useUser() {
const api = useApi()
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
async function getUser(id: number): Promise<User | null> {
loading.value = true
error.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'
return null
} finally {
loading.value = false
}
}
return { loading, error, getUser }
}
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
// 홈 페이지 — 템플릿 기본 진입점
import { ref } from 'vue'
const message = ref<string>('You did it!')
</script>
<template>
<main class="home">
<h1>{{ message }}</h1>
<p>
Visit
<a
href="https://vuejs.org/"
target="_blank"
rel="noopener"
>vuejs.org</a>
to read the documentation
</p>
</main>
</template>
<style scoped>
.home {
padding: 2rem;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
// 404 페이지 — 라우팅 매칭 실패 시 노출
</script>
<template>
<main
class="not-found"
role="alert"
>
<h1>404</h1>
<p>요청하신 정보나 페이지를 찾을 수 없습니다.</p>
<RouterLink to="/">
홈으로 돌아가기
</RouterLink>
</main>
</template>
<style scoped>
.not-found {
padding: 2rem;
text-align: center;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import 처리
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/pages/HomePage.vue'),
meta: { requiresAuth: false },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/pages/NotFoundPage.vue'),
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
// 네비게이션 가드 — 보호 라우트(requiresAuth) 접근 시 인증 여부 검사
router.beforeEach((to) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
// 미인증 시 홈으로 리다이렉트
// (실제 로그인 페이지 추가 시 { name: 'login' } 으로 변경)
return { name: 'home' }
}
return true
})
export default router
@@ -0,0 +1,19 @@
// backend/src/common/errors/error-codes.ts 에서 자동 생성된 파일입니다. 직접 수정하지 마세요.
export const ERROR_CODE_MESSAGES = {
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
REQ_001: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
FILE_001: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
FILE_002: '파일 크기가 허용 범위를 초과했습니다.',
} as const
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES
/** 매핑 실패 시 사용하는 폴백 코드 */
export const FALLBACK_ERROR_CODE: ErrorCode = 'SYS_001'
+25
View File
@@ -0,0 +1,25 @@
// 목록 응답 공유 계약 — 백엔드 PaginatedResult 와 동일 구조
// (백엔드: backend/src/common/dto/pagination.dto.ts)
export interface PageInfo {
/** 다음 페이지 존재 여부 */
hasNext: boolean
/** 다음 요청에 넘길 커서. 마지막 페이지면 null */
nextCursor: string | null
/** 전체 개수. 계산 비용이 큰 경우 null 일 수 있다. */
total: number | null
}
export interface PaginatedResult<T> {
items: T[]
pageInfo: PageInfo
}
/** 커서 기반 목록 조회 쿼리 파라미터 */
export interface CursorPaginationQuery {
cursor?: string
limit?: number
}
/** 목록 API 기본 조회 개수 — 백엔드 PAGINATION_DEFAULT_LIMIT 와 동일 */
export const DEFAULT_PAGE_SIZE = 20
+20
View File
@@ -0,0 +1,20 @@
// 공통 포맷터 — 부수효과 없는 순수 함수만 작성
/**
* 숫자를 한국 통화 표기 (예: 12345 → "12,345원")
*/
export function formatCurrency(value: number): string {
return `${value.toLocaleString('ko-KR')}원`
}
/**
* Date 또는 ISO 문자열을 'YYYY-MM-DD' 형식으로 변환
*/
export function formatDate(input: Date | string): string {
const date = typeof input === 'string' ? new Date(input) : input
if (Number.isNaN(date.getTime())) return ''
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
+24
View File
@@ -0,0 +1,24 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
// 인증 세션 상태 스토어
// 웹은 JWT 를 HttpOnly·Secure 쿠키로 보관하므로(보안 규칙), JS 에서 토큰을 직접
// 저장/조회하지 않는다. 여기서는 "현재 인증된 세션인지" 여부만 관리한다.
// (사용자 프로필 데이터는 user.store.ts 에서 별도 관리)
export const useAuthStore = defineStore('auth', () => {
// 로그인 성공 또는 세션 확인 API 응답으로 갱신되는 인증 여부
const authenticated = ref<boolean>(false)
const isLoggedIn = computed<boolean>(() => authenticated.value)
// 로그인 성공 시 호출 (실제 로그인 API 연동 시 useAuth 에서 사용)
function markAuthenticated() {
authenticated.value = true
}
// 로그아웃/세션 만료 시 호출
function clearAuth() {
authenticated.value = false
}
return { isLoggedIn, markAuthenticated, clearAuth }
})
+19
View File
@@ -0,0 +1,19 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { User } from '@/composables/useUser'
// 사용자 도메인 Pinia 스토어 샘플 — 도메인 단위로 파일을 분리하여 관리
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const isLoggedIn = computed<boolean>(() => user.value !== null)
function setUser(payload: User | null) {
user.value = payload
}
function clear() {
user.value = null
}
return { user, isLoggedIn, setUser, clear }
})
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}
+27
View File
@@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.app.json",
// Override to include only test files and clear exclusions.
// Application code imported in tests is automatically included via module resolution.
"include": ["src/**/__tests__/*", "env.d.ts"],
"exclude": [],
"compilerOptions": {
// Vitest runs in a different environment than the application code.
// Adjust lib and types accordingly.
"lib": [],
"types": ["node", "jsdom"],
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
}
}
+81
View File
@@ -0,0 +1,81 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
/** 루트 .env 에 값이 없을 때만 쓰는 기본 포트 (.env.*.example 과 같은 값) */
const DEFAULT_FRONTEND_PORT = 5173
const DEFAULT_BACKEND_PORT = 3000
/**
* 환경변수를 양의 정수로 파싱한다.
* 값이 없거나(undefined·빈 문자열) 숫자가 아니거나 0 이하면 기본값을 반환한다.
* (compose 는 정의되지 않은 변수를 빈 문자열로 주입한다 — `Number('') === 0` 방어.
* backend 의 `shared/utils/env.ts` 와 같은 규칙)
*/
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (value === undefined || value.trim() === '') return fallback
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) return fallback
return parsed
}
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
// 포트·주소의 SSOT 는 루트 `.env.{mode}` 다 (.claude/rules/env-structure.md).
// 프론트엔드 디렉터리 밖이라 vite 가 자동으로 읽지 않으므로 여기서 직접 로드한다.
// 접두어 ''(전체)로 읽지만, 읽은 값은 **dev 서버 설정에만** 쓰고 클라이언트 번들에는 넣지 않는다
// (VITE_ 접두어가 없는 값을 번들에 넣으면 시크릿이 노출된다 — .claude/rules/security.md).
// 파일이 없으면(클론 직후 등) process.env 만 반영되고 아래 기본값으로 떨어진다.
const rootEnv = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '')
const frontendPort = parsePositiveInt(rootEnv.FRONTEND_PORT, DEFAULT_FRONTEND_PORT)
const backendPort = parsePositiveInt(rootEnv.BACKEND_PORT, DEFAULT_BACKEND_PORT)
// 컨테이너(바인드 마운트) 환경에서만 true — docker-compose.dev.yml 이 주입한다.
// 호스트에서 직접 `npm run dev` 로 띄울 때는 OS 파일 감시 이벤트가 정상 동작하므로
// 폴링을 켜면 CPU 만 쓰고 기동이 느려진다.
const usePolling = rootEnv.CHOKIDAR_USEPOLLING === 'true'
return {
plugins: [
vue(),
vueJsx(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
// ngrok 등 외부 도메인 접근 허용 (DNS Rebinding 보호 비활성화)
allowedHosts: true,
// 루트 .env 의 FRONTEND_PORT 를 따른다.
// 컨테이너로 띄울 때는 compose 의 `--port`(컨테이너 내부 고정 포트)가 이 값을 덮어쓴다.
port: frontendPort,
// 포트가 이미 사용 중이면 조용히 다음 포트로 옮기지 않고 실패한다.
// 루트 .env 의 CORS_ORIGIN 과 compose 포트 매핑이 이 포트에 묶여 있어,
// 말없이 5174 로 옮겨가면 API 요청이 원인 모르게 막힌다 (fail-fast).
strictPort: true,
// 도커 바인드 마운트에서는 inotify 가 호스트 쪽 변경을 잡지 못해 폴링이 필요하다
watch: usePolling ? { usePolling: true } : undefined,
// 개발 서버 API 프록시.
// useApi 의 baseURL 은 '/api'(상대경로)라 프록시가 없으면 vite 가 SPA 폴백으로
// index.html 을 200 으로 돌려준다 — 요청이 조용히 HTML 을 받아 실패한다.
//
// 기본 대상은 호스트에서 띄울 때 기준이고(백엔드 컨테이너가 BACKEND_PORT 를 호스트에 노출한다),
// 프론트까지 docker 로 띄울 때는 compose 가 VITE_DEV_API_PROXY=http://backend:${BACKEND_PORT} 를 주입한다.
proxy: {
'/api': {
target: rootEnv.VITE_DEV_API_PROXY || `http://localhost:${backendPort}`,
changeOrigin: true,
},
},
},
}
})
+17
View File
@@ -0,0 +1,17 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
// vite.config 는 mode 별 설정(포트·프록시)을 계산하는 함수다.
// mergeConfig 는 함수를 병합하지 못하므로 테스트용 ConfigEnv 로 먼저 평가한다.
// (테스트는 dev 서버를 띄우지 않아 포트·프록시 값에 영향받지 않는다)
export default mergeConfig(
viteConfig({ command: 'serve', mode: 'test' }),
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)