Initial commit

This commit is contained in:
2026-10-26 12:28:48 +09:00
commit 18f10cece4
269 changed files with 33654 additions and 0 deletions
+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 }
}