Error Codes (전체 카탈로그)
SPEC #003 §2-9 표준 ErrorResponse. backend GlobalExceptionHandler.
ErrorResponse 구조
{
"success": false,
"error": {
"code": "AUTH_INVALID_CREDENTIALS",
"message": "이메일 또는 비밀번호가 일치하지 않습니다.",
"details": [
{ "field": "email", "code": "INVALID_FORMAT", "message": "..." }
]
}
}details[] 는 validation 실패에서만.
Auth 에러 (status 401)
| Code | 의미 |
|---|---|
AUTH_INVALID_CREDENTIALS | 이메일 / 비밀번호 불일치 |
AUTH_ACCOUNT_NOT_FOUND | 계정 없음 (보안상 INVALID_CREDENTIALS 와 동일 메시지) |
AUTH_ACCOUNT_SUSPENDED | 정지된 계정 (#003 후속) |
AUTH_REFRESH_EXPIRED | refresh token 만료 |
AUTH_REFRESH_REVOKED | refresh token 회수됨 (rotation race) |
AUTH_TOKEN_INVALID | JWT 형식 / 서명 불일치 |
AUTH_TOKEN_EXPIRED | access 만료 (BFF refresh 트리거) |
Impersonation (status 401 / 403)
| Code | 의미 |
|---|---|
IMPERSONATE_INVALID_TARGET | INDEPENDENT / SUSPENDED 본사 대상 |
IMPERSONATE_TOKEN_EXPIRED | exchange token 60s 초과 |
IMPERSONATE_TOKEN_USED | exchange token 재사용 |
IMPERSONATE_FORBIDDEN | OPERATOR 외 호출 |
Validation (status 400)
| Code | 의미 |
|---|---|
VALIDATION_FAILED | request body / param validation 실패. details[] 에 field 별 |
INVALID_LEGAL_VERSION | 약관 version 이 활성 버전과 불일치 |
LEGAL_DOC_INVALID_EFFECTIVE_AT | 게시 effectiveAt 이 과거(>60s) — 발효 시각은 현재 이후여야 함 (#033). FE 게시 다이얼로그가 사전 거부 + 인라인 danger 매핑 |
INVALID_BUSINESS_NUMBER | 사업자번호 형식 오류 (#013) |
INVALID_ENUM_VALUE | enum 값 잘못 |
METADATA_PARSE_FAILED | 음원 업로드/교체 파일이 MP3 가 아니거나 ID3 태그가 손상돼 파싱 실패 (#041, uploadMusic·replaceMusicFile). 서버 메모리 ID3v2.4 재기록 단계에서 거부 |
MUSIC_INVALID_FIELD | 음원 업로드/교체 필드 위반 — title 누락·빈 파일·20MB 초과·musicSource 누락/오값 (#041·#059) |
LIBRARY_TYPE_MISMATCH | 라이브러리에 음원 추가 시 음원 musicSource(AI/TRUST) ≠ 라이브러리 libraryType (#059, addLibraryMusic). 전체 reject — 위반 musicId 를 error.fields.violatingMusicIds(콤마 구분)에 노출 |
RECORDING_UNSUPPORTED_FORMAT | (즉시방송 녹음 슬라이스) 점장 녹음 업로드(recordStoreBroadcast) 파일 MIME 이 허용 3종(audio/webm·audio/mp4·audio/mpeg) 밖. FE 매핑 “지원하지 않는 녹음 형식이에요. 브라우저를 최신으로 업데이트한 뒤 다시 녹음해주세요.” |
RECORDING_INVALID_FIELD | (즉시방송 녹음 슬라이스) 점장 녹음 업로드 필드 위반 — 빈 파일·10MB 초과·durationSeconds 1recordStoreBroadcast). FE 매핑 “녹음이 올바르지 않아요. (빈 파일·10MB 초과·길이 1 |
BROADCAST_TEMPLATE_LIMIT_EXCEEDED | (자주쓰는방송 슬라이스, status 409) 점장 템플릿 생성(createBroadcastTemplate) 시 매장당 활성 템플릿이 상한(20개)에 도달. FE 매핑 “템플릿은 최대 20개까지 저장할 수 있어요. 쓰지 않는 템플릿을 지운 뒤 다시 시도해주세요.” + 목록 total>=20 이면 [+ 새 템플릿] 사전 비활성 |
BROADCAST_TEMPLATE_NOT_FOUND | (자주쓰는방송 슬라이스, status 404) 점장 템플릿 수정(updateBroadcastTemplate)·삭제(deleteBroadcastTemplate) 대상이 본인 매장 활성 템플릿이 아님(미존재·삭제·재삭제·타 매장 — 소유 은닉). FE 매핑 “템플릿을 찾을 수 없어요. 이미 삭제됐을 수 있어요.” |
DISPATCH_INVALID_TARGET | (송출 슬라이스) 안내방송 송출(dispatchHqTtsAnnouncement) target=STORES 인데 storeIds 가 생략(null). 빈 배열은 minItems:1 검증 400(이 코드 아님)·타 본사/미존재 매장 혼입은 404 TTS_ANNOUNCEMENT_NOT_FOUND(전체 은닉). FE MVP 는 target: "ALL" 고정이라 방어적 매핑(“송출 대상이 올바르지 않습니다.”) |
Conflict (status 409)
| Code | 의미 |
|---|---|
DUPLICATE_EMAIL | 같은 이메일 OperatorAccount 존재 (409) |
HQ_INVALID_STATUS_TRANSITION | HqStatus 전이 불가 — 허용 외 출발 상태(이미 같은 상태 등). suspend/reactivate (#018) |
STORE_INVALID_STATUS_TRANSITION | StoreStatus 전이 불가 — 허용 외 출발 상태(같은 상태·역행). suspend(ACTIVE|INACTIVE→SUSPENDED)/reactivate(SUSPENDED→ACTIVE) (#037). FE 매장 상세 다이얼로그 인라인 매핑 |
STORE_ALREADY_CLOSED | 이미 폐점된 매장 재폐점 시도 — closed_at IS NULL 가드 affected 0 (#039). 폐점 매장 suspend/reactivate(WHERE AND closed_at IS NULL)도 동일 409. FE 폐점 다이얼로그 인라인 매핑(“이미 폐점된 매장입니다”) |
DISPATCH_SLOT_OCCUPIED | (점장 예약방송 슬라이스, #109) 점장 예약 송출(sendStoreBroadcast + scheduledAt) 시 같은 매장에 본사가 예약(SCHEDULED, source=HQ)한 동일 시각이 이미 존재 — store_id + status='SCHEDULED' + scheduled_at 일치 + announcement.source='HQ' row 존재면 409. 정확한 시각(Instant) 충돌만 차단(5분 슬롯 그리드·반복 차단은 본사 예약 슬롯 모델 후속 — 20-policy §3-3 풀구현은 별도 대형 SPEC). 점장발 SCHEDULED(자기 예약)·즉시 송출(scheduledAt 없음)은 검사 대상 아님. FE 매핑 “본사 방송이 이미 예약된 시각입니다. 다른 시각을 선택해 주세요.” |
TICKET_INVALID_STATUS_TRANSITION | TicketStatus 전이 불가 — 상태머신 외 전이 시도. PATCH /tickets/{id}/status (#027). FE 클라 가드로 1차 차단 |
MUSIC_TAG_OPTION_DUPLICATE | (#132) 같은 타입(GENRE/MOOD) 내 동일 value 중복 — music_tag_option unique(type,value) 위반. createMusicTagOption·updateMusicTagOption(value 변경 시). FE /settings/music-options 추가/수정 폼 inline 매핑(“이미 같은 값의 옵션이 있습니다”) |
STORE_NAME_DUPLICATE | 같은 본사 산하 동일 매장명 (정책 후속) |
CONSENT_DUPLICATE_VERSION | 같은 version 중복 동의 시도 |
TERMS_ACTIVE_RACE | partial unique race (활성 약관 중복 INSERT) — #033 partial-unique 드롭으로 deprecated, version-unique race 로 대체 |
LEGAL_DOC_DUPLICATE_VERSION | 동일 version 약관/개인정보 중복 게시 (uq_*_version). FE 게시 다이얼로그 인라인 매핑 |
LEGAL_DOC_ACTIVATION_CONFLICT | 동시 게시 race — 재시도 안내 |
LEGAL_DOC_NOT_SCHEDULED | 예약 취소(DELETE /admin/{terms,privacy-policy}/{id}, #038) 대상이 SCHEDULED·미발효(effective_at > now)가 아님 — 이미 발효(ACTIVE)·대체(SUPERSEDED)되어 취소 불가. FE 인라인 danger 매핑(“이미 발효되었거나 대체되어 취소할 수 없습니다”) |
Authorization (status 403)
| Code | 의미 |
|---|---|
FORBIDDEN | role / scope 부족 |
ACCESS_DENIED | Spring Security 의 AccessDeniedException |
INDEPENDENT_HQ_SUSPENSION_FORBIDDEN | INDEPENDENT 가상 본사 정지/복구 시도 — 전이 불가 (#018) |
AUTH_STORE_SUSPENDED | 소속 매장이 SUSPENDED 인 STORE_MANAGER 의 인증 차단 (login·refresh·impersonation exchange). HQ AUTH_HQ_SUSPENDED 미러 (#037) |
AUTH_STORE_CLOSED | 소속 매장이 폐점(closed_at != null)된 STORE_MANAGER 의 인증 영구 차단 (login·refresh). AuthService.verifyStoreNotSuspended 확장 — 세션 revoke 만으론 access TTL 내 잔여 노출이 남아 인증단에서도 차단 (#039) |
PASSWORD_CHANGE_REQUIRED | passwordMustChange=true 인 계정이 임시 비번을 변경하기 전 보호 endpoint 호출 (#022). PasswordChangeEnforcementFilter 가 JWT 인증 직후 차단. allowlist (허용·미차단): POST /api/v1/auth/change-password · GET /api/v1/auth/me · POST /api/v1/auth/logout · POST /api/v1/auth/refresh (+ OPTIONS preflight). access token claim pmc 로 판정(요청당 DB 조회 0). 임퍼소네이션 토큰은 pmc=false 강제 → 무영향 |
Server / Network (status 5xx · 502)
| Code | 의미 |
|---|---|
INTERNAL_ERROR | 처리 안 된 예외 (production 메시지 마스킹) |
BACKEND_ERROR | (BFF only) backend 5xx — session 유지 |
BACKEND_UNREACHABLE | (BFF only) 네트워크 도달 실패 |
TTS_SYNTHESIS_FAILED | (#061, status 502) Typecast TTS 합성 외부 실패(타임아웃·polling 실패·다운로드 실패 등). 외부 장애를 5xx 로 격리하지 않고 502 로 매핑(createHqTtsAnnouncement·점장 previewStoreBroadcast·#134 runTtsSmokeTest). FE 매핑 “합성에 실패했습니다. 잠시 후 다시 시도해주세요.”(smoke-test 는 Typecast 연동 상태 확인 안내) |
TTS_TOKEN_NOT_CONFIGURED | (#061, status 503) TYPECAST_API_TOKEN env 미설정 상태에서 합성 호출. 부팅은 성공(StorageProperties 패턴)하나 합성 시 503. FE 매핑 “TTS 가 설정되지 않았습니다. 관리자에게 문의해주세요.”(#134 smoke-test 는 Render env TYPECAST_API_TOKEN 확인 으로 운영자 안내) |
Not Found (status 404)
| Code | 의미 |
|---|---|
HQ_NOT_FOUND | hqId 존재 X |
STORE_NOT_FOUND | storeId 존재 X |
TERMS_NOT_PUBLISHED | 현재 유효본 TermsDocument 없음 (effective_at <= now 없음) |
PRIVACY_NOT_PUBLISHED | 현재 유효본 PrivacyPolicy 없음 |
LEGAL_DOC_NOT_FOUND | by-id 약관/개인정보 게시본 미존재 (GET·DELETE /admin/{terms,privacy-policy}/{id}, #033·#038). 예약 취소 시 FE 매핑 “이미 삭제된 예약본입니다” |
ACCOUNT_NOT_FOUND | accountId 존재 X |
TICKET_NOT_FOUND | ticketId 존재 X (#027) — FE /tickets/{id} 에서 notFound(). 본사 #086(hq_id 불일치·미존재 은닉, 상세·댓글·상태/우선순위 변경) · 점장 #112(store_id 불일치·미존재 은닉, /store/support/{id} 상세·댓글) 도 동일 코드로 통일(타 본사/타 매장 티켓 id 존재 흘림 차단). 점장 FE 는 “문의를 찾을 수 없습니다.” inline Banner |
MUSIC_NOT_FOUND | 음원 id 존재 X 또는 soft-deleted (#041·#042). 파일 교체(replaceMusicFile)·상세 조회(getMusic)·삭제(deleteMusic) 대상 미존재. #042 는 soft-deleted·미존재·재삭제를 모두 이 코드로 통일(존재 은닉) |
MUSIC_TAG_OPTION_NOT_FOUND | (#132) 음원 태그 옵션 id 미존재. updateMusicTagOption·deleteMusicTagOption 대상 부재. FE /settings/music-options inline 매핑(“대상 옵션을 찾을 수 없습니다. 목록을 새로고침해주세요”) |
TTS_ANNOUNCEMENT_NOT_FOUND | (#061) 안내방송 id 존재 X·soft-deleted·타 본사(hqId 불일치 — 존재 은닉). 상세(getHqTtsAnnouncement)·삭제(deleteHqTtsAnnouncement)·송출(dispatchHqTtsAnnouncement)·송출 이력(#065 listHqTtsAnnouncementDispatches, STORE_BROADCAST 출처도 포함) 대상 미존재. FE 삭제는 404 를 “이미 삭제됨” 으로 흡수, 송출·이력은 “삭제되었거나 존재하지 않는 안내방송입니다.” |
DISPATCH_NOT_FOUND | (송출 슬라이스) 점장 ack(ackStoreAnnouncement) 실패. ack 은 원자 조건부 UPDATE(WHERE status='PENDING')라 이미 PLAYED·중복 ack·본인 매장 아님·미존재 dispatchId 가 모두 이 코드 404(상태·존재 은닉). 첫 ack(PENDING)만 204. 효과는 멱등(상태 불변)이나 HTTP 응답은 404 — “204 멱등”이 아니다. FE player 는 404 를 “이미 소비됨”으로 보고 정상 음악 복귀 |
Rate Limited (status 429)
비용·부하가 큰 endpoint 그룹에 인메모리 토큰버킷(per-instance, principal+그룹 키) rate limit 을 건다. 한도를 넘기면 429 RATE_LIMITED + Retry-After(초) 헤더로 클라이언트에 재시도 대기 시간을 알린다. 버킷은 분당 한도만큼 토큰을 채우는 단순 모델(1분 윈도) — 멀티 인스턴스에서는 인스턴스별 독립 버킷이라 실효 한도가 인스턴스 수배가 될 수 있다(현 단일 인스턴스 전제).
| Code | 의미 |
|---|---|
RATE_LIMITED | 해당 그룹의 분당 한도 초과. Retry-After 헤더(초)만큼 대기 후 재시도. FE 매핑 “요청이 많아 잠시 후 다시 시도해 주세요.” |
그룹·한도:
| 그룹 | 분당 한도 | 대상 | env |
|---|---|---|---|
SYNTHESIS | 10/분 | TTS 합성(안내방송 생성·재합성·점장 미리듣기·smoke-test) — Typecast 외부 비용·쿼터 보호 | — |
DISPATCH | 30/분 | 안내방송 송출(dispatchHqTtsAnnouncement) — fan-out row 생성 부하 보호 | — |
HQ_CONTROL | 30/분 | 본사 제어 액션 — 송출 취소(cancelHqDispatch)·원격 즉시중단(revokeHqDispatch)·반복 예약 생성/취소(createHqDispatchSchedule·cancelHqDispatchSchedule) | APP_RATE_LIMIT_HQ_CONTROL_PER_MINUTE(기본 30) |
env:
APP_RATE_LIMIT_HQ_CONTROL_PER_MINUTE로HQ_CONTROL그룹 분당 한도를 조정한다(미설정 시 기본 30). 운영 부하·악용 패턴에 맞춰 조정 가능.
Frontend 클라이언트 처리
// apps/admin/src/lib/error.ts — extractCode(body: unknown) 는 응답 payload(JSON)를 받는다
const code = extractCode(err.body);
switch (code) {
case "AUTH_INVALID_CREDENTIALS":
toast.error("이메일 또는 비밀번호가 일치하지 않습니다.");
break;
case "DUPLICATE_EMAIL":
setError("manager.email", { message: "이미 사용 중인 이메일" });
break;
case "BACKEND_ERROR":
case "BACKEND_UNREACHABLE":
toast.error("잠시 후 다시 시도해 주세요.");
break;
case "VALIDATION_FAILED":
// details[] 의 field 별 inline
err.body.error.details?.forEach(d => setError(d.field, { message: d.message }));
break;
default:
toast.error(err.body?.error?.message ?? "알 수 없는 오류");
}Roadmap
- Error code 추가 SPEC 별 명시
- i18n message catalog
- error tracking (Sentry)
References
- SPEC #003 §2-9
linkmusic-msa-space-was/.../api/error/GlobalExceptionHandler.ktlinkmusic-msa-space-was/.../api/error/ErrorResponse.ktlinkmusic-msa-space-was/.../domain/exception/