Skip to content

Latest commit

 

History

History
210 lines (174 loc) · 23.4 KB

File metadata and controls

210 lines (174 loc) · 23.4 KB

API 오류 코드 및 재시도 정책

목적과 적용 범위

이 문서는 Pingdom Backend가 2026-08-10 기준으로 실제 반환하거나 기록하는 오류 코드와 재시도 동작을 구분해 설명한다. 클라이언트, 운영자, 외부 연동 handler가 오류를 같은 의미로 해석하고, 구현보다 강한 전달 보장이나 재시도 약속을 문서로 선언하지 않는 것이 목적이다.

관리자 Outbox 운영 API는 최종 실패 event의 조회·재처리만 제공한다. 비내구성 Spring 이벤트의 일반 재생이나 외부 부수효과의 exactly-once 처리를 보장하지 않는다.

관련 문서:

용어와 책임 경계

구분 책임 식별자 예시 재시도 의미
HTTP API 오류 코드 Controller, Security, Controller Advice의 클라이언트 응답 분류 INVALID_TOKEN, RATE_LIMIT_EXCEEDED 클라이언트가 요청을 다시 보낼지 판단하는 근거다. 서버의 자동 재시도를 뜻하지 않는다.
Outbox 상태 외부 부수효과 작업의 worker 처리 상태 PENDING, RETRY, FAILED worker가 동일 Outbox event를 다시 처리할지 나타낸다.
Notification delivery 오류 코드 이메일·FCM 발송 결과의 내부 기록 분류 EMAIL_SEND_FAILED, FCM_INVALID_TOKEN 발송 결과 조회·운영 분석용이며 Outbox 재시도를 직접 제어하지 않는다.
Provider 오류 코드 Firebase·Postmark 등이 반환한 원본 코드 provider별 문자열 내부 코드와 별도로 기록하며 API 계약 코드로 노출하지 않는다.

HTTP API 오류와 비동기 delivery 결과는 같은 요청에서 함께 관찰될 수 있어도 서로 다른 계약이다. 좋아요 요청이 성공한 뒤 FCM 발송이 실패하면 HTTP 성공 여부와 notification delivery 실패 기록을 분리해 판단한다.

HTTP 오류 응답 계약

현재 응답 형태

발생 경로 현재 응답 본문 비고
도메인 예외, rate-limit 예외, JWT 인증·인가 실패 {"message":"...","code":"..."} code는 클라이언트 분기용 문자열이다.
Bean Validation 실패 {"message":"입력값을 확인해주세요.","errors":{"field":"..."}} 공통 code는 없다.
ConstraintViolation, 일반 DataIntegrityViolation, ResponseStatusException {"message":"..."} 일부 제약 조건은 409 코드로 변환되지만, 그 외에는 code가 없다.

따라서 모든 실패 응답에 code가 있다고 가정해서는 안 된다. OpenAPI의 ErrorResponse schema도 일부 Controller 문서에만 사용되므로, 현재 전체 API의 단일 오류 schema를 보장하지 않는다.

API 코드 카탈로그

아래 상태 코드는 각 enum 또는 Security handler가 현재 설정한 값이다. 메시지는 사용자 표시 문구이므로 제어 흐름은 메시지가 아닌 상태 코드와 code으로 판단한다.

영역·정의 위치 HTTP 상태 코드
인증·계정 AuthErrorCode 401 INVALID_CREDENTIALS, INVALID_TOKEN, EXPIRED_TOKEN, OAUTH_LINK_TOKEN_INVALID
인증·계정 AuthErrorCode 403 ADMIN_ACCESS_REQUIRED, USER_BANNED, USER_WITHDRAWN
인증·계정 AuthErrorCode 400 INVALID_PASSWORD_RESET_TOKEN, EXPIRED_PASSWORD_RESET_TOKEN, PASSWORD_MISMATCH, INVALID_EMAIL_VERIFICATION_CODE, EXPIRED_EMAIL_VERIFICATION_CODE
인증·계정 AuthErrorCode 409 EMAIL_ALREADY_VERIFIED, DUPLICATE_USERNAME, DUPLICATE_EMAIL, OAUTH_EMAIL_CONFLICT, OAUTH_EMAIL_MISMATCH, OAUTH_ACCOUNT_ALREADY_LINKED, OAUTH_PASSWORD_CONFIRMATION_REQUIRED, OAUTH_LOCAL_PASSWORD_REQUIRED
인증·계정 AuthErrorCode 404 OAUTH_ACCOUNT_NOT_LINKED, USER_NOT_FOUND
JWT Security handler 401 INVALID_TOKEN, EXPIRED_TOKEN
JWT Security handler 403 ACCESS_DENIED
사용자 UsersErrorCode 400 PASSWORD_MISMATCH, INVALID_TRAVEL_SCHEDULE_PERIOD
사용자 UsersErrorCode 404 USER_NOT_FOUND, TRAVEL_SCHEDULE_NOT_FOUND
사용자 UsersErrorCode 409 USERNAME_ALREADY_EXISTS, TRAVEL_SCHEDULE_NOT_EDITABLE, TRAVEL_SCHEDULE_CONCURRENT_MODIFICATION
Merchant Owner MerchantOwnerErrorCode 400 ADMIN_ACCOUNT_NOT_ALLOWED, INVALID_ONBOARDING_METRIC, INVALID_OPERATIONAL_QUALITY_METRIC, INVALID_PLACE_INFORMATION
Merchant Owner MerchantOwnerErrorCode 403 USER_ACCOUNT_NOT_ELIGIBLE, ACTIVE_OWNER_REQUIRED, PLACE_CLAIMANT_NOT_ELIGIBLE
Merchant Owner MerchantOwnerErrorCode 404 PROFILE_NOT_FOUND, PLACE_NOT_FOUND, OWNER_PLACE_NOT_FOUND, VERIFICATION_NOT_FOUND, PLACE_CLAIM_NOT_FOUND, PLACE_INFORMATION_NOT_FOUND
Merchant Owner MerchantOwnerErrorCode 409 PROFILE_ALREADY_EXISTS, INVALID_PROFILE_STATE, PLACE_ALREADY_ASSIGNED, PLACE_ALREADY_ASSIGNED_TO_REQUESTER, PLACE_OWNERSHIP_CHANGED, VERIFICATION_ALREADY_EXISTS, INVALID_VERIFICATION_STATE, VERIFICATION_REQUIRED, PLACE_CLAIM_ALREADY_PENDING, INVALID_PLACE_CLAIM_STATE
지도·게시글 MapErrorCode 400 PLACE_ID_REQUIRED, PLACE_SEARCH_CONDITION_INVALID, UNSUPPORTED_PLACE_SEARCH_SORT, IMAGE_FILE_EMPTY, IMAGE_FILE_TOO_LARGE, UNSUPPORTED_IMAGE_TYPE, INVALID_IMAGE_FILE, IMAGE_RESOLUTION_TOO_LARGE, ALREADY_LIKED, NOT_LIKED, ALREADY_POSTED
지도·게시글 MapErrorCode 403 REPORTER_RESTRICTED, REPORT_APPEAL_NOT_ALLOWED, OTHERS_NOT_DELETED, OTHERS_NOT_UPDATE, OTHERS_PLACE_NOT_DELETED, OTHERS_PLACE_MEDIA_NOT_MANAGED, PLACE_INFORMATION_REPORT_FORBIDDEN, PLACE_INFORMATION_DISPUTE_FORBIDDEN
지도·게시글 MapErrorCode 404 IMAGE_NOT_FOUND, REPORT_NOT_FOUND, PLACE_NOT_FOUND, PLACE_MEDIA_NOT_FOUND, PLACE_EVENT_NOT_FOUND, PLACE_INFORMATION_REPORT_NOT_FOUND, PLACE_INFORMATION_DISPUTE_NOT_FOUND, RECOMMENDATION_EXPLANATION_NOT_FOUND, BOOKMARK_NOT_FOUND
지도·게시글 MapErrorCode 409 ALREADY_REPORTED_IMAGE, REPORT_APPEAL_ALREADY_EXISTS, PLACE_ALREADY_EXISTS, PLACE_INFORMATION_REPORT_ALREADY_SUBMITTED, FAVORITE_ALREADY_EXISTS, BOOKMARK_ALREADY_EXISTS
지도·게시글 MapErrorCode 500 DELETE_ERROR, S3_NOT_CONFIGURED, S3_CONNECTION_ERROR, UPLOAD_ERROR
알림 NotificationsErrorCode 400 CANNOT_SEND_NOTIFICATION_TO_SELF, INVALID_FCM_TOKEN, INVALID_NOTIFICATION_TIMEZONE, INVALID_QUIET_HOURS
알림 NotificationsErrorCode 404 FCM_TOKEN_NOT_FOUND, NOTIFICATION_NOT_FOUND
알림 NotificationsErrorCode 500 NOTIFICATION_SEND_FAILED
Offer OfferErrorCode 400 INVALID_OFFER_PERIOD, INVALID_OFFER_INPUT
Offer OfferErrorCode 403 PLACE_NOT_OWNED, TOURIST_ELIGIBILITY_REQUIRED
Offer OfferErrorCode 404 OFFER_NOT_FOUND, COUPON_NOT_FOUND
Offer OfferErrorCode 409 INVALID_OFFER_STATE, OFFER_NOT_AVAILABLE, OFFER_SOLD_OUT, COUPON_ALREADY_ISSUED, COUPON_NOT_REDEEMABLE
예약 가능 시간 AvailabilityErrorCode 400 INVALID_AVAILABILITY_INPUT
예약 가능 시간 AvailabilityErrorCode 403 PLACE_NOT_OWNED
예약 가능 시간 AvailabilityErrorCode 404 AVAILABILITY_NOT_FOUND
예약 가능 시간 AvailabilityErrorCode 409 AVAILABILITY_ALREADY_EXISTS, INVALID_AVAILABILITY_STATE, AVAILABILITY_CAPACITY_EXCEEDED
예약 ReservationErrorCode 400 INVALID_RESERVATION_INPUT
예약 ReservationErrorCode 403 RESERVATION_FORBIDDEN, TOURIST_ACCOUNT_REQUIRED
예약 ReservationErrorCode 404 RESERVATION_NOT_FOUND
예약 ReservationErrorCode 409 INVALID_RESERVATION_STATE, IDEMPOTENCY_KEY_REUSED
방문자 검증 VisitorVerificationErrorCode 400 LOCATION_OBSERVATION_EXPIRED, LOCATION_TOO_INACCURATE, VISIT_EVIDENCE_FILE_EMPTY, VISIT_EVIDENCE_FILE_INVALID, INVALID_REPORT_DETAILS, INVALID_REVIEW, INVALID_CORRECTION_DETAILS, INVALID_CORRECTION_REVIEW, INVALID_SCOUT_PROFILE_DETAILS, INVALID_SCOUT_ACTIVITY_ELIGIBILITY_PERIOD
방문자 검증 VisitorVerificationErrorCode 403 REPORT_FORBIDDEN, ADMIN_ACCOUNT_REQUIRED, TOURIST_ACCOUNT_REQUIRED, CORRECTION_FORBIDDEN, SCOUT_PROFILE_ACCOUNT_REQUIRED, SCOUT_PROFILE_FORBIDDEN, SCOUT_ACTIVITY_NOT_ELIGIBLE
방문자 검증 VisitorVerificationErrorCode 404 REPORT_NOT_FOUND, PLACE_NOT_FOUND, CHECK_IN_NOT_FOUND, VISIT_EVIDENCE_NOT_FOUND, CORRECTION_NOT_FOUND, SCOUT_PROFILE_NOT_FOUND, SCOUT_ACTIVITY_ELIGIBILITY_NOT_FOUND
방문자 검증 VisitorVerificationErrorCode 409 DAILY_CHECK_IN_ALREADY_EXISTS, VISIT_EVIDENCE_ALREADY_EXISTS, ACTIVE_REPORT_ALREADY_EXISTS, INVALID_REPORT_STATE, CORRECTION_NOT_ALLOWED, ACTIVE_CORRECTION_ALREADY_EXISTS, SCOUT_PROFILE_ALREADY_EXISTS, INVALID_SCOUT_PROFILE_STATE, SCOUT_ACTIVITY_PROFILE_REQUIRED, INVALID_SCOUT_ACTIVITY_ELIGIBILITY_STATE
방문자 검증 VisitorVerificationErrorCode 413 VISIT_EVIDENCE_FILE_TOO_LARGE
방문자 검증 VisitorVerificationErrorCode 422 OUTSIDE_CHECK_IN_RADIUS
방문자 검증 VisitorVerificationErrorCode 503 VISIT_EVIDENCE_STORAGE_UNAVAILABLE
관리자 AdminErrorCode 400 ADMIN_ROLE_ASSIGNMENT_INVALID, PLACE_MERGE_INVALID_REQUEST, PLACE_OPERATING_STATUS_INVALID_REQUEST, PLACE_DISCOVERY_STATUS_INVALID_REQUEST, PLACE_INFORMATION_VERIFICATION_INVALID_REQUEST, PLACE_INFORMATION_REPORT_INVALID_REQUEST, PLACE_INFORMATION_DISPUTE_INVALID_REQUEST, PLACE_OPERATING_SCHEDULE_INVALID_REQUEST, PLACE_EVENT_INVALID_PERIOD, RECOMMENDATION_TRAFFIC_POLICY_INVALID_REQUEST, RECOMMENDATION_TRAFFIC_POLICY_TOTAL_INVALID, RECOMMENDATION_METRIC_QUERY_TOO_LARGE, TRUST_SCORE_INTERVENTION_RULE_INVALID_REQUEST, AD_INVALID_PERIOD, UNSUPPORTED_PLACE_SORT_PARAM, INVALID_SANCTION_PERIOD, INVALID_SANCTION_FILTER_PERIOD, INVALID_AUDIT_LOG_FILTER_PERIOD, INVALID_NOTIFICATION_DELIVERY_FILTER_PERIOD
관리자 AdminErrorCode 403 ADMIN_PERMISSION_REQUIRED
관리자 AdminErrorCode 404 ADMIN_ROLE_ASSIGNMENT_NOT_FOUND, ADMIN_TARGET_USER_NOT_FOUND, POST_NOT_FOUND, PLACE_NOT_FOUND, PLACE_DUPLICATE_NOT_FOUND, PLACE_INFORMATION_EVIDENCE_NOT_FOUND, PLACE_INFORMATION_REPORT_NOT_FOUND, PLACE_INFORMATION_DISPUTE_NOT_FOUND, PLACE_EVENT_NOT_FOUND, PLACE_MERGE_HISTORY_NOT_FOUND, RECOMMENDATION_EXPLANATION_NOT_FOUND, RECOMMENDATION_TRAFFIC_POLICY_VERSION_NOT_FOUND, TRUST_SCORE_ANOMALY_NOT_FOUND, TRUST_SCORE_REPORTER_POLICY_NOT_FOUND, TRUST_SCORE_INTERVENTION_RULE_NOT_FOUND, AD_NOT_FOUND, REPORT_NOT_FOUND, APPEAL_NOT_FOUND
관리자 AdminErrorCode 409 ADMIN_ROLE_ASSIGNMENT_CONFLICT, PLACE_INFORMATION_REPORT_ALREADY_SUBMITTED, PLACE_EVENT_UPDATE_NOT_ALLOWED, PLACE_EVENT_PUBLISH_NOT_ALLOWED, PLACE_EVENT_CANCEL_NOT_ALLOWED, PLACE_EVENT_CONNECTED, PLACE_CHECK_IN_CONNECTED, PLACE_MERGE_NOT_ALLOWED, PLACE_MERGE_ALREADY_RESTORED, PLACE_MERGE_RESTORE_NOT_ALLOWED, PLACE_KAKAO_PLACE_ID_CONFLICT, TRUST_SCORE_INTERVENTION_RULE_DUPLICATED, REPORT_ALREADY_PROCESSED, APPEAL_ALREADY_PROCESSED, USER_NOT_BANNED, PENDING_REPORT_NOT_FOUND
관리자 AdminErrorCode 500 AUDIT_LOG_WRITE_FAILED, RECOMMENDATION_POLICY_HISTORY_WRITE_FAILED, POST_DELETE_FAILED, S3_NOT_CONFIGURED, S3_CONNECTION_ERROR, S3_REPORT_FAILED
요청 제한 429 RATE_LIMIT_EXCEEDED
요청 제한 저장소 장애 503 RATE_LIMIT_UNAVAILABLE

위 enum 기반 예외는 GlobalExceptionHandler의 전용 handler를 통해 {"message":"...","code":"..."} 형태로 반환된다. 새 도메인 예외를 추가할 때는 enum, exception, Controller Advice handler, OpenAPI 오류 문서가 함께 갱신됐는지 확인한다. 전체 오류 응답 표준화는 Controller Advice와 OpenAPI baseline에 영향을 주므로 별도 구현 이슈에서 결정한다.

DataIntegrityViolation 중 사용자명 중복, OAuth 계정 연결 중복, 지도 북마크 중복은 409와 대응 코드로 변환된다. 장소별 게시글 중복은 ALREADY_POSTED의 선언 상태인 400과 대응 코드로 변환된다. 그 외의 무결성 오류는 500과 메시지만 반환한다.

HTTP 클라이언트 재시도 기준

서버는 일반 HTTP 요청의 재시도 횟수, backoff, 멱등성 키를 제공하지 않는다. 아래 표는 현재 응답을 받은 클라이언트의 안전한 기본 판단이며, 새로운 자동 재시도 계약을 추가하는 정책은 아니다.

응답 기본 처리 재시도 판단
400, 404, 409, 413, 422 또는 validation 오류 요청 값·현재 리소스 상태를 수정한다. 자동 재시도하지 않는다.
401 INVALID_TOKEN, EXPIRED_TOKEN 인증 정보를 갱신하거나 로그인 흐름을 수행한다. 인증 복구 후에도 원래 요청이 조회 또는 멱등한 요청일 때만 다시 보낸다.
403 권한·사용자 상태를 확인한다. 자동 재시도하지 않는다.
429 RATE_LIMIT_EXCEEDED 요청 빈도를 낮춘다. 현재 Retry-After 헤더가 없으므로 서버가 대기 시간을 약속하지 않는다. 조회 또는 멱등한 요청에 한해 제품별 제한된 backoff를 적용할 수 있다.
503 RATE_LIMIT_UNAVAILABLE 또는 저장소 일시 장애 제한 저장소·외부 저장소·의존성 장애로 본다. 조회 또는 멱등한 요청에 한해 횟수가 제한된 backoff 재시도를 검토한다. 상태 변경 요청은 중복 효과를 먼저 검토한다.
500 서버·외부 의존성 오류로 분류하고 요청 ID와 오류 코드를 보존한다. 일반적인 자동 재시도 계약이 아니다. 같은 상태 변경 요청을 즉시 반복하지 않는다.

Outbox 및 notification delivery 재시도

Outbox 상태 전이와 일정

Outbox worker는 5초 주기로 준비된 event를 선점한다. 설정은 outbox.max-attempts=5, base-backoff=PT10S, max-backoff=PT10M, processing-timeout=PT5M이다.

PENDING 또는 RETRY
        │ worker가 선점
        ▼
   PROCESSING ── 성공 ──► SUCCEEDED
        │
        ├─ 실패 1~4회: 10초, 20초, 40초, 80초 지연 후 RETRY
        └─ 실패 5회: FAILED
  • 5분을 넘긴 PROCESSING event는 stale recovery 과정에서 같은 실패·backoff 규칙을 적용한다.
  • FAILED event는 OUTBOX_RECOVERY 권한을 가진 관리자가 POST /admin/outbox-events/{eventId}/retry로 시도 횟수를 초기화해 RETRY로 전환할 수 있다. 장애 원인과 중복 외부 효과 안전성을 먼저 확인하고 사유를 입력해야 하며, 동일 event의 반복 요청은 409로 거절된다.
  • deduplication_key는 중복 event 저장을 막을 뿐 외부 공급자 호출의 exactly-once 처리를 보장하지 않는다.

이메일과 FCM의 현재 차이

처리 실패 시 동작 delivery 기록과 Outbox의 관계
이메일 인증·비밀번호 재설정 handler가 예외를 기록한 뒤 다시 던진다. worker가 Outbox를 RETRY 또는 FAILED로 전이한다. RETRY_SCHEDULED delivery 기록은 최대 시도 횟수에서 FINAL_FAILED가 될 수 있다.
좋아요 FCM의 무효 토큰 토큰을 삭제하고 실패를 기록한다. handler 예외를 전파하지 않으므로 해당 Outbox event는 성공 처리될 수 있다.
좋아요 FCM의 일시 실패 retryable=true으로 실패를 기록한다. 현재 FCM service가 예외를 잡아 전파하지 않으므로 retryable=true은 Outbox 재시도 예약을 뜻하지 않는다.
payload 역직렬화 실패 handler가 예외를 던진다. event는 일반 Outbox 실패 정책을 따른다.

notification delivery의 retryable, attempt_count, RETRY_SCHEDULED, FINAL_FAILED는 발송 결과 관찰용 모델이다. 이 값은 Outbox worker의 상태 전이를 직접 제어하지 않는다. FCM 일시 실패도 실제 Outbox 재시도로 처리해야 한다면 중복 전송, 트랜잭션 경계, notification 생성 중복을 검토하는 별도 구현 이슈가 필요하다.

API·Flyway·OpenAPI·운영 영향

대상 현재 기준 #809 반영 내용
HTTP API·OpenAPI 오류 본문과 Security 응답은 기존 구현을 따른다. GET /admin/outbox-events 조회와 POST /admin/outbox-events/{eventId}/retry 명령을 OpenAPI baseline에 추가한다.
Flyway V5__create_outbox_event.sql이 Outbox 상태·시도 정보를 저장한다. V90에서 상태·시도 횟수 제약과 관리자 조회 인덱스를 추가하며 기존 row 값은 변경하지 않는다.
운영 Outbox metric, handler 로그, 관리자 감사 이력이 실패 분석의 근거다. 수동 재처리 결과를 pingdom.outbox.manual_retry로 기록하고 payload는 API·감사 로그에서 제외한다.

구현 대조 결과

점검 항목 대조 위치 결과
enum 기반 도메인 예외 응답 GlobalExceptionHandler, 각 *ErrorCode enum 전용 handler가 있는 도메인은 message, code를 반환한다.
Validation, ConstraintViolation, ResponseStatusException GlobalExceptionHandler 일부 실패 응답에는 code가 없으므로 클라이언트는 상태 코드와 본문 형태를 함께 처리해야 한다.
요청 제한 오류 RateLimitException, RateLimitUnavailableException 429와 503은 코드가 있지만 현재 Retry-After 헤더 계약은 없다.
Outbox 재시도 설정 application.yaml, OutboxEvent, OutboxEventWorker 5초 선점 주기, 최대 5회, 10초 기반 backoff, 10분 최대 backoff, 5분 stale recovery 기준을 따른다.
Outbox 운영 복구 AdminOutboxEventController, AdminOutboxEventRecoveryService FAILED만 행 잠금 후 RETRY로 전환하며 권한·사유·감사 이력을 요구한다.
notification delivery 기록 NotificationDeliveryRecorder, NotificationDeliveryRecordWriter, AdminNotificationDeliveryController delivery의 retryable과 상태는 운영 관찰용이며 Outbox 상태 전이를 직접 제어하지 않는다.
DB migration 영향 V5__create_outbox_event.sql, V90__add_outbox_recovery_operations.sql 기존 Outbox row를 보존하면서 허용 상태·시도 횟수 제약과 운영 조회 인덱스를 적용한다.

운영 확인과 장애 대응

  1. HTTP 오류는 상태 코드, code이 있으면 해당 코드, X-Request-Id를 함께 보존한다. validation 오류는 errors 필드를 함께 기록한다.
  2. Outbox 실패는 event ID, event type, aggregate, attempt count, last error를 보존한다.
  3. pingdom.outbox.max_attempts_exceeded 증가 또는 pingdom.outbox.events{status="FAILED"}가 0보다 큰 경우 즉시 원인을 확인한다.
  4. 이메일·FCM 문제는 provider 오류 코드, 내부 delivery 오류 코드, delivery 상태를 Outbox 상태와 혼동하지 않고 대조한다.
  5. 원인이 해결돼도 동일 event의 중복 외부 효과가 안전한지 확인하기 전에는 재처리하거나 같은 상태 변경 요청을 다시 보내지 않는다.

변경 이력

일자 이슈 내용 상태
2026-07-10 #839, #840, #841 HTTP 오류 코드, Outbox·delivery 재시도 책임, API·Flyway·운영 연결 기준을 문서화 완료
2026-07-21 #841 구현 대조 결과, 누락된 도메인 오류 코드, 관련 운영 문서 링크 기준을 갱신 완료
2026-08-10 #809, #810, #811 권한·감사·관측성을 갖춘 Outbox 조회 및 수동 재처리 운영 계약 추가 완료