Validation Error를 장애로 만들지 않기

@givemethatsewon· June 14, 2026· 4 min read

Editor save failure boundary diagram
Editor save failure boundary diagram

Sentry에는 저장 화면에서 발생한 unresolved issue가 남아 있었다. 저장 중 ApiError: Request validation failed.가 global unhandled rejection으로 잡힌 케이스였다.

처음에는 단순히 "저장 API가 실패했다"처럼 보였다. 하지만 문제의 핵심은 API validation 자체가 아니었다. 사용자가 복구할 수 있는 저장 실패가 UI에서 toast로 처리된 뒤에도 다시 throw되면서, 운영 관측 시스템에는 전역 장애처럼 올라가고 있었다.

요약

  • 문제: editor 저장 중 validation error가 Sentry unhandled rejection으로 기록됐다.
  • 분류: 시스템 장애라기보다 user-recoverable failure가 잘못된 failure boundary를 넘은 문제였다.
  • 수정: auth error만 다시 던지고 일반 저장 실패는 toast에서 끝나도록 경계를 정리했다.
  • 검증: validation failure는 재던지지 않고 auth failure는 기존 auth boundary로 넘기는 테스트를 추가했다.
  • 배운 점: Sentry issue 수보다 먼저 봐야 할 것은 어떤 실패를 운영 장애로 올릴지다.

상황

문제가 된 흐름은 editor의 초안 저장이었다.

user clicks save
  -> build editor payload
  -> POST editor save endpoint
  -> API returns validation failure
  -> UI shows failure toast
  -> error is thrown again
  -> Sentry records unhandled rejection

이 경로에서는 저장 실패 toast가 이미 사용자에게 노출되고 있었다. 사용자는 "저장에 실패했다"는 피드백을 받는다. 그런데 코드가 그 뒤에 같은 error를 다시 throw하면서 브라우저 전역의 unhandled rejection으로 승격됐다.

서비스를 운영할 때는 이 차이를 구분해야 한다. 같은 error도 어디서 멈추느냐에 따라 의미가 달라진다.

실패 유형 적절한 처리 위치 Sentry issue로 볼 성격
인증 만료 auth boundary 필요
저장 validation 실패 editor UI boundary 보통 불필요
서버 장애 / 네트워크 장애 request boundary + Sentry 필요

Request validation failed.는 사용자가 다시 시도하거나 payload를 고쳐야 하는 종류의 실패에 가깝다. 이걸 전역 unhandled rejection으로 올리면 실제 장애와 사용자가 복구할 수 있는 실패가 같은 레벨로 섞인다.

failure boundary로 보기

소프트웨어 공학에서 error handling은 단순히 try/catch를 붙이는 일이 아니다. 실패가 어느 경계에서 멈춰야 하는지를 정하는 일에 가깝다.

이번에는 세 가지 경계가 있었다.

API boundary:
  요청 payload를 검증하고 실패를 명시적으로 반환한다.

Editor UI boundary:
  저장 실패를 사용자에게 보여주고, draft를 dirty 상태로 유지한다.

Global error boundary:
  코드가 예상하지 못한 crash나 시스템성 장애를 잡는다.

기존 구현은 editor UI boundary에서 실패를 처리한 뒤에도 global boundary로 다시 넘겼다. 그래서 관측 도구에는 "사용자에게 안내된 저장 실패"가 "처리되지 않은 예외"처럼 기록됐다.

에러를 숨기자는 말은 아니다. 오히려 에러의 성격에 맞는 관측 위치를 정해야 한다. auth error처럼 세션 상태를 바꿔야 하는 실패는 기존 auth boundary로 넘겨야 한다. 일반 validation failure는 editor boundary에서 멈춰야 한다.

바꾼 것

수정 범위는 작게 잡았다. 저장 실패를 무조건 삼키지 않고, 재던질 error의 기준만 분리했다.

export function shouldRethrowEditorSaveFailure(
  error: unknown,
  handleAuthError: (error: unknown) => boolean
): boolean {
  return handleAuthError(error)
}

그리고 editor page의 저장 흐름에서는 이 기준을 사용했다.

try {
  const res = await api.saveEditorDraft(...)
  setProject(res)
  toast({ title: "Draft saved" })
} catch (error: unknown) {
  if (shouldRethrowEditorSaveFailure(error, handleAuthError)) throw error
  toast({
    title: "Failed to save draft",
    description: error instanceof Error ? error.message : "Unable to save this edit.",
    variant: "destructive",
  })
} finally {
  setSaving(false)
}

핵심은 이 줄이다.

if (shouldRethrowEditorSaveFailure(error, handleAuthError)) throw error

auth error는 기존처럼 상위 auth 처리 흐름으로 넘긴다. 일반 validation failure는 toast로 사용자에게 알리고, editor의 dirty state를 유지한 채 저장 버튼을 다시 누를 수 있게 둔다.

증거와 검증

관측 도구에서 확인한 증거는 다음과 같았다.

항목 내용
error ApiError: Request validation failed.
성격 editor 저장 중 unhandled promise rejection

수정 후에는 두 가지 동작을 테스트로 고정했다.

케이스 기대 동작
validation failure toast 처리 후 재throw하지 않는다
auth failure auth boundary가 처리할 수 있도록 재throw한다

검증은 editor save error handling 테스트와 observability 관련 테스트를 함께 돌렸다. 여기서 확인한 것은 "모든 저장 실패가 사라졌다"가 아니다. 확인한 내용은 복구 가능한 저장 실패가 더 이상 global unhandled rejection으로 승격되지 않는다는 점이다.

왜 이게 운영 품질인가

Issue count를 줄이는 것 자체는 목표가 될 수 없다. 잘못 줄이면 실제 장애를 숨기는 일이 된다.

이번 작업의 목표는 issue count 감소가 아니라 signal quality 개선이었다.

before:
  user-recoverable validation failure
    -> toast
    -> rethrow
    -> global unhandled rejection
    -> Sentry issue

after:
  user-recoverable validation failure
    -> toast
    -> keep editor state
    -> no global incident

운영 중에 봐야 하는 issue는 "사용자가 이미 안내받고 다시 시도할 수 있는 실패"가 아니라 "제품이 예상하지 못한 방식으로 깨졌거나 사용자가 복구할 수 없는 실패"여야 한다.

이 기준이 없으면 alert fatigue가 생긴다. 모든 실패가 같은 레벨로 쌓이면 진짜 장애를 봐야 할 때 노이즈에 묻힌다.

다음에는 이렇게 본다

에러가 Sentry에 올라왔다고 바로 "버그를 고친다"로 들어가지 않는다. 먼저 이렇게 묻는다.

  • 이 실패는 사용자가 복구할 수 있는가.
  • 이미 UI boundary에서 안내되고 있는가.
  • auth, validation, network, server error 중 어디에 속하는가.
  • global error boundary까지 올라가야 하는 실패인가.
  • 관측 도구에 남겨야 한다면 어떤 tag나 message가 있어야 나중에 분류 가능한가.

이번 이슈는 저장 API의 validation이 실패한 사건이기도 했다. 더 정확히는 failure boundary가 잘못 그어진 사건이었다.

운영 시스템에서는 에러를 많이 모으는 것보다 올바른 레벨의 에러를 모으는 쪽이 더 중요하다.

참고

givemethatsewon profile
@givemethatsewon
프로젝트를 만들고 운영하면서 배운 개발, 제품, 디버깅 기록을 남깁니다.