feat: 에러 코드 SSOT 체계 도입
사용자에게 노출되는 실패가 에러 코드 없이 만들어질 수 없도록 구조를 바꾼다. 기존에는 예외 필터가 HTTP 상태로 코드를 추론해 도메인별 에러를 표현할 수 없었고, 세 플랫폼의 에러 사전을 손으로 맞춰야 해서 어긋나기 쉬웠다. - 코드↔메시지↔HTTP 상태를 error-codes.ts 레지스트리 한 곳으로 통합 - BusinessException 은 에러 코드를 생성자 필수 인자로 받아 코드 누락을 타입으로 차단 - sync:error-codes 스크립트가 프론트·플러터 사전을 자동 생성 (수동 복사 제거) - 429 전용 코드 REQ_001 추가 (기존에는 "시스템 오류"로 잘못 안내) - AUTH_002(세션 만료)를 클라이언트에서 실제로 발행하도록 구현 - 응답에 traceId 를 실어 로그와 대조 가능하게 함 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
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('세 플랫폼 공유 에러 코드가 모두 등록되어 있다', () => {
|
||||
// backend error-codes.ts 레지스트리와 동일한 목록이어야 한다.
|
||||
const expected = [
|
||||
'SYS_001',
|
||||
'REQ_001',
|
||||
'AUTH_001',
|
||||
'AUTH_002',
|
||||
'AUTH_003',
|
||||
'VAL_001',
|
||||
'RES_001',
|
||||
'BIZ_001',
|
||||
]
|
||||
|
||||
expect(Object.keys(ERROR_CODE_MESSAGES).sort()).toEqual(expected.sort())
|
||||
})
|
||||
|
||||
it('모든 코드는 DOMAIN_NNN 네이밍 규약을 따른다', () => {
|
||||
for (const code of Object.keys(ERROR_CODE_MESSAGES)) {
|
||||
expect(code).toMatch(/^[A-Z]+_\d{3}$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,37 +4,63 @@ import axios, {
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
|
||||
// 표준 응답 포맷 타입 (Global Response Wrapper) — 백엔드 TransformInterceptor와 동일 구조
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 에러 코드 사전 — CLAUDE.md / 백엔드 HttpExceptionFilter 와 1:1 매핑
|
||||
// SYS_001 은 폴백 메시지로 항상 존재함을 타입으로 보장 (noUncheckedIndexedAccess 대응)
|
||||
const ErrorCodeLexicon: Record<string, string> & { SYS_001: string } = {
|
||||
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
/** 인터셉터가 reject 할 때 넘기는 정규화된 에러 — 호출부는 code 로 분기한다. */
|
||||
export interface ApiError {
|
||||
code: ErrorCode
|
||||
message: string
|
||||
traceId?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수만 교체하면 됨
|
||||
const showErrorUI = (message: string) => {
|
||||
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: 10000,
|
||||
timeout: API_TIMEOUT_MS,
|
||||
// HttpOnly/Secure 쿠키 인증 시 자격 증명을 함께 전송
|
||||
withCredentials: true,
|
||||
})
|
||||
@@ -45,6 +71,41 @@ api.interceptors.request.use(
|
||||
(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>) => {
|
||||
@@ -55,30 +116,24 @@ api.interceptors.response.use(
|
||||
// 성공 시 data 만 언래핑하여 반환
|
||||
return payload.data as never
|
||||
}
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우
|
||||
const errCode = payload.error?.code || 'BIZ_001'
|
||||
showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
|
||||
return Promise.reject(payload.error)
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우 (백엔드는 4xx 로 내리지만 방어적으로 처리)
|
||||
return Promise.reject(
|
||||
handle(payload.error?.code ?? 'BIZ_001', payload.error?.traceId, response.status),
|
||||
)
|
||||
}
|
||||
return payload as never
|
||||
},
|
||||
(error) => {
|
||||
let errCode = 'SYS_001'
|
||||
|
||||
if (error?.response) {
|
||||
const status: number = error.response.status
|
||||
const payload = error.response.data as StandardResponse | undefined
|
||||
|
||||
if (payload?.error?.code) {
|
||||
errCode = payload.error.code
|
||||
} else if (status === 401) errCode = 'AUTH_001'
|
||||
else if (status === 403) errCode = 'AUTH_003'
|
||||
else if (status === 400 || status === 422) errCode = 'VAL_001'
|
||||
else if (status === 404) errCode = 'RES_001'
|
||||
if (!error?.response) {
|
||||
// 네트워크 단절·타임아웃 — 서버 응답 자체가 없는 경우
|
||||
return Promise.reject(handle(FALLBACK_ERROR_CODE))
|
||||
}
|
||||
|
||||
showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
|
||||
return Promise.reject(error)
|
||||
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))
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// 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: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
} as const
|
||||
|
||||
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES
|
||||
|
||||
/** 매핑 실패 시 사용하는 폴백 코드 */
|
||||
export const FALLBACK_ERROR_CODE: ErrorCode = 'SYS_001'
|
||||
Reference in New Issue
Block a user