실패 화면 다시 시도 버튼, 이유별로 나눠 설계하는 법

오류가 났을 때 알림창 대신 결과 자리에 이유를 적고, 다시 시도 버튼은 눌러서 나아지는 실패에만 다는 법이에요. 실패 이유 분류와 재시도 구현을 따라 해 보세요.

셀루모2026.10.25 · 14분 읽기
목차

서버에 무언가를 요청하는 화면을 만들다 보면 반드시 실패하는 순간이 와요. 그때 화면이 빈칸으로 남거나, 잠깐 떴다 사라지는 알림창만 남기면 사용자는 무슨 일이 일어났는지, 다시 눌러도 되는지 알 수 없어요.

이 글은 실패했을 때 화면에 무엇을 보여 줄지 정하는 방법을 순서대로 정리해요. 핵심은 하나예요. 실패를 이유별로 나누고, 눌러서 나아질 수 있는 실패에만 다시 시도 버튼을 다는 것이에요.

혹시 이런 고민 있으신가요?

  • 오류가 나면 "문제가 발생했습니다" 한 줄만 띄우고 있어요.
  • 다시 시도 버튼을 모든 오류에 달았더니 눌러도 소용없는 오류에서 사용자가 같은 버튼을 계속 눌러요.
  • 알림창이 3초 만에 사라져서 사용자가 뭐라고 쓰여 있었는지 못 읽었다고 해요.

이 글을 읽으면

  • 실패를 이유 네 가지로 나누고, 이유마다 화면 문구와 버튼을 정할 수 있어요.
  • 알림창 대신 결과가 나올 자리에 오류를 보여 주는 이유와 방법을 알아요.
  • 같은 요청을 다시 보내는 다시 시도 기능을 상태 하나로 만들고, 테스트하는 법을 알아요.

1단계. 실패를 이유별로 나누어 적어요

먼저 화면이 요청을 보내는 모든 곳에서 실패가 어떤 모양으로 돌아오는지 적어 보세요. 실패 응답에는 숫자로 된 상태 코드가 붙어요. 400대는 보낸 쪽 잘못, 500대는 받는 쪽 잘못, 429는 "너무 자주 보냈으니 기다려라"라는 뜻이에요.

저희는 사진을 올리면 스캔한 것처럼 보정해 주는 화면에서 이 표를 먼저 만들었어요. 서버가 돌려줄 수 있는 실패를 사용자 입장에서 네 가지로 묶었어요.

이유 이름상태 코드사용자에게 일어난 일같은 사진으로 다시 해도 될까
decode400사진 파일을 읽을 수 없어요안 돼요. 다른 사진이 필요해요
too-large413파일이 허용 크기를 넘었어요안 돼요. 더 작은 사진이 필요해요
rate-limited429짧은 시간에 너무 많이 요청했어요지금은 안 돼요. 기다려야 해요
worker그 밖의 모든 경우서버 쪽 처리가 실패했어요돼요. 일시적일 수 있어요

표의 마지막 칸이 이 글 전체의 기준이에요. 이름은 여러분 서비스에 맞게 바꿔도 되지만, "다시 해도 될까"라는 칸은 꼭 넣으세요. 이 칸이 다시 시도 버튼을 달지 말지를 정해 줘요.

이유 이름은 화면 문구뿐 아니라 나중에 실패를 세는 분석에도 같은 이름으로 써요. 한 번 정해 두면 화면, 분석, 대화에서 같은 말을 쓸 수 있어요.

TIP

상태 코드를 화면 곳곳에서 직접 비교하지 마세요. 응답을 이유 이름으로 바꾸는 함수를 한 곳에 두고, 화면은 이유 이름만 읽게 하세요. 서버가 코드를 바꿔도 고칠 곳이 한 군데예요.

2단계. 응답이 없는 경우까지 이유에 넣어요

서버가 답을 주지 않는 실패가 있어요. 와이파이가 끊겼거나, 서버가 응답 전에 죽었거나, 시간이 너무 오래 걸려 연결이 끊긴 경우예요. 이때는 상태 코드 자체가 없어요.

이 경우를 따로 만들면 문구가 하나 더 늘어나고, 사용자는 어차피 할 수 있는 일이 똑같아요. 잠시 뒤에 다시 하는 것뿐이죠. 그래서 저희는 응답이 없는 경우를 worker 로 묶었어요. 아래는 그 변환 함수의 뼈대예요.

TS
type FailureReason = "decode" | "too-large" | "rate-limited" | "worker" type Failure = {  ok: false  reason: FailureReason  retryAfterSeconds?: number} function readFailure(response: Response | undefined): Failure {  // 응답이 없으면 사용자가 할 수 있는 일은 재시도뿐이에요  if (!response) return { ok: false, reason: "worker" }  if (response.status === 429) {    const wait = Number(response.headers.get("retry-after"))    return {      ok: false,      reason: "rate-limited",      retryAfterSeconds: Number.isFinite(wait) && wait > 0 ? wait : undefined,    }  }  if (response.status === 400) return { ok: false, reason: "decode" }  if (response.status === 413) return { ok: false, reason: "too-large" }  return { ok: false, reason: "worker" }}

여기서 "그 밖의 모든 경우는 worker"라는 마지막 줄이 중요해요. 502든 503든 예상 못 한 코드든 일단 서버 쪽 실패로 묶어서 다시 시도할 수 있게 열어 둬요. 반대로 "이건 확실히 사용자 잘못"이라고 아는 코드만 따로 빼요. 목록에 없는 코드를 사용자 잘못으로 취급하면, 서버가 고장 났는데 사용자에게 "다른 사진으로 하세요"라고 말하게 돼요.

429 응답에는 보통 Retry-After 라는 머리글이 같이 와요. 몇 초 뒤에 다시 보내라는 값이에요. 없을 수도 있으니 숫자가 아니거나 0 이하이면 없는 것으로 다뤄요.

참고

서버가 400 으로 돌려주는 경우가 사용자 잘못만은 아닐 수 있어요. 자기 서비스에서 400 이 어떤 상황에서 나오는지 서버 쪽 코드를 한 번 확인한 뒤에 분류하세요. 이 글의 분류는 저희 서버가 400 을 "읽을 수 없는 파일"에만 쓰기 때문에 성립해요.

3단계. 이유마다 문구를 정해요

이유를 나눴으면 문구를 정해요. 좋은 실패 문구는 세 가지를 말해요. 무슨 일이 일어났는지, 사용자 잘못인지 아닌지, 이제 무엇을 하면 되는지예요.

저희가 쓴 문구예요.

이유화면 문구
decode사진을 읽지 못했어요. 다른 사진으로 해 주세요.
too-large사진이 너무 커요. 15MB 보다 작은 사진으로 해 주세요.
rate-limited짧은 시간에 너무 많이 보정했어요. 3분 뒤에 다시 해 주세요. 로그인하면 이 한도가 풀려요.
worker보정하지 못했어요. 잠시 뒤 다시 해 주세요.

문구를 쓸 때 지킨 규칙이 세 가지 있어요.

첫째, 숫자는 구체적으로 적어요. "너무 큰 사진"이 아니라 "15MB 보다 작은 사진"이라고 쓰면 사용자가 바로 다음 행동을 정할 수 있어요.

둘째, 기다려야 하면 얼마나 기다리는지 적어요. 429 의 대기 시간은 Retry-After 값을 사람이 읽는 말로 바꿔요. 45초는 45초, 125초는 3분, 4,801초는 1시간 21분이라고 써요. 올림으로 계산해서 "0분"이 되는 일이 없게 했어요.

TS
function formatWait(seconds: number) {  if (seconds < 60) return `${Math.ceil(seconds)}초`  const minutes = Math.ceil(seconds / 60)  if (minutes < 60) return `${minutes}분`  const hours = Math.floor(minutes / 60)  const rest = minutes % 60  return rest ? `${hours}시간 ${rest}분` : `${hours}시간`}

셋째, 대기 시간을 모르면 "잠시 뒤에 다시 해 주세요"라고만 쓰고 억지로 숫자를 지어내지 않아요.

TIP

사용자를 탓하는 말투는 피하세요. "잘못된 파일입니다"보다 "사진을 읽지 못했어요"가 낫고, 이어서 해결책을 바로 주면 더 좋아요. 사용자 잘못이든 아니든 읽는 사람이 느끼는 감정은 크게 다르지 않아요.

4단계. 오류는 알림창이 아니라 결과가 나올 자리에 보여요

처음에 저희는 오류가 나면 화면 한쪽에 잠깐 떴다 사라지는 알림창을 띄웠어요. 만들기는 쉬운데 문제가 세 가지 있었어요.

  • 몇 초 뒤에 사라지기 때문에, 읽기 전에 없어질 수 있어요.
  • 어느 요청이 실패했는지 화면에서 연결이 안 돼요. 결과 자리는 여전히 비어 있거나 로딩 표시인 채로 있어요.
  • 알림창에 다시 시도 버튼을 달아도, 사라지면 버튼도 같이 사라져요.

그래서 알림창을 빼고, 결과가 나올 자리에 이유와 버튼을 그대로 보여 주기로 했어요. 이 자리는 사용자가 이미 눈을 두고 있는 곳이라 따로 찾을 필요가 없어요. 사라지지도 않아요.

TSX
function RequestFailed({ failure, onRetry }: Props) {  return (    <div role="alert" className="flex flex-col items-center gap-3 text-center">      <p>{failureMessage(failure)}</p>      {failure.reason === "worker" && (        <Button type="button" onClick={onRetry}>          다시 시도하기        </Button>      )}    </div>  )}

두 가지를 눈여겨보세요.

하나는 role="alert" 예요. 화면을 읽어 주는 보조 도구에게 "중요한 안내가 나타났다"고 알려 주는 속성이라, 시각이 불편한 사용자도 실패를 알아차릴 수 있어요. 요약 문구가 눈에 띄지 않는 위치에 있어도 읽어 줘요.

다른 하나는 버튼 조건이에요. 이유가 worker 일 때만 다시 시도 버튼을 보여 줘요. decode 와 too-large 는 같은 사진으로 다시 해도 똑같이 실패하니 버튼 대신 "다른 사진으로" 안내만 줘요. rate-limited 는 지금 누르면 또 429 가 올 테니 기다리는 시간을 적어요.

이 한 줄이 이 글에서 가장 값어치 있는 결정이에요. 눌러도 소용없는 버튼은 사용자를 계속 누르게 만들고, 그러다 한도까지 더 빨리 채워요.

TIP

알림창을 완전히 없애지 않아도 돼요. 화면 어디서나 일어나는 일, 예를 들어 "복사했어요"처럼 결과 자리가 따로 없는 알림에는 알림창이 잘 맞아요. 기준은 "이 실패가 화면의 어느 자리와 이어져 있는가"예요.

5단계. 같은 요청을 다시 보내는 다시 시도를 만들어요

버튼을 달았으면 눌렀을 때 같은 조건으로 요청이 다시 가야 해요. 여기서 흔히 막혀요. 조건이 그대로이면 화면 라이브러리는 "바뀐 게 없다"고 보고 요청을 다시 보내지 않기 때문이에요.

저희는 React 라는 화면 라이브러리를 쓰는데, 값이 바뀌면 그 값에 의존하는 효과를 다시 실행해요. 그래서 시도 횟수를 세는 숫자 상태를 하나 두고, 버튼을 누르면 1 올려요. 요청을 보내는 효과가 이 숫자에 의존하게 해 두면 숫자가 오를 때마다 같은 요청이 다시 가요.

TSX
const [failed, setFailed] = useState<{ key: string; failure: Failure }>()// 재시도가 올리는 숫자예요. 캐시에는 실패를 남기지 않아요const [attempt, setAttempt] = useState(0) useEffect(() => {  // key 는 이번 조건의 이름이에요  void load(key).then((outcome) => {    if (!outcome.ok) {      setFailed({ key, failure: outcome })      return    }    setFailed(undefined)    show(outcome.result)  })}, [key, attempt]) return {  failure: failed?.key === key ? failed.failure : undefined,  retry: () => setAttempt((count) => count + 1),}

짚을 부분이 세 군데 있어요.

첫째, 실패를 캐시에 넣지 않아요. 저희는 같은 조건의 결과를 저장해 두었다가 다시 보여 주는 캐시를 쓰는데, 실패까지 저장하면 다시 시도를 눌러도 저장된 실패가 다시 나와요. 성공만 저장하세요.

둘째, 실패에 조건 이름 key 를 붙여서 저장해요. 사용자가 실패한 뒤 옵션을 바꿔 다른 조건이 되면, 앞 조건의 실패 문구가 새 조건 화면에 남으면 안 돼요. 그래서 지금 조건과 key 가 같을 때만 보여 줘요.

셋째, 성공하면 실패 상태를 지워요. 다시 시도가 통하면 오류 문구가 사라지고 결과가 바로 나와야 해요.

TIP

다시 시도를 눌렀을 때는 반드시 로딩 표시가 먼저 보여야 해요. 아무 변화가 없으면 사용자는 버튼이 안 눌렸다고 생각해서 또 눌러요. 요청이 시작되자마자 오류 문구를 지우고 로딩으로 바꾸세요.

아래 그림은 지금까지의 판단 흐름을 한 장으로 줄인 거예요.

요청이 실패했을 때 이유에 따라 화면이 갈라지는 흐름실패하면 이유를 먼저 보고, 다시 시도 버튼은 서버 쪽 실패에서만 보여요

6단계. 이전 결과가 남아 있을 때 실패를 어디에 보여 줄지 정해요

실제 화면에서는 이 경우가 가장 까다로워요. 사용자가 사진 한 장으로 결과를 이미 한 번 받은 상태에서 옵션을 바꿨는데 이번 요청이 실패한 경우예요. 화면에는 앞 결과가 남아 있고, 새 조건은 실패했어요.

선택지는 둘이에요. 앞 결과를 지우고 오류만 보이거나, 앞 결과를 둔 채 그 아래에 오류를 보이거나요. 저희는 후자를 골랐어요. 앞 결과를 지우면 사용자가 한 번 얻은 것을 잃어요. 그리고 앞 결과 아래에 실패 문구를 두면, 지금 보이는 것은 앞 조건의 결과이고 새 조건은 실패했다는 사실이 같이 전달돼요.

이때 앞 결과 아래에 있던 사용 안내 문구, 곧 "가운데 막대를 끌어 비교하세요" 같은 안내는 실패 문구로 바꿔 끼워요. 둘을 같이 두면 아래가 길어지고, 안내가 지금 상황과 안 맞아요.

테스트로 이 동작을 고정해 두었어요. 서버 쪽 처리 없이 화면 한 조각만 그려서 문구를 확인하는 식이에요.

TSX
test("앞 결과가 남아 있어도 바꾼 조합의 실패를 결과 아래에 알린다", () => {  const html = renderToStaticMarkup(    <ResultView result={RESULT} loading={false} failure={{ ok: false, reason: "worker" }} onRetry={() => {}} />,  )  expect(html).toContain("다시 시도하기")  expect(html).not.toContain("가운데 막대를 끌어 비교하세요")})

테스트는 최소한 이 네 가지를 확인하세요.

  • 서버 쪽 실패이면 이유와 다시 시도 버튼이 보여요.
  • 한도 초과이면 기다릴 시간이 보이고 다시 시도 버튼은 없어요.
  • 앞 결과가 남아 있어도 새 실패가 보이고, 안내 문구는 빠져요.
  • 실패가 없으면 오류 영역 자체가 없어요.

넷째 항목이 의외로 중요해요. 오류 영역이 항상 그려져 있으면 보조 도구가 매번 알림을 읽어 버려요.

7단계. 실패와 다시 시도를 세어서 문구를 고쳐요

화면을 다 만들었으면 실패가 얼마나 일어나는지, 사람들이 다시 시도를 누르는지 알아야 해요. 안 그러면 이유 분류가 맞는지, 문구가 먹히는지 알 길이 없어요.

저희는 두 가지를 서로 다른 도구로 셌어요.

  • 실패 자체는 서비스의 핵심 흐름을 판단하는 데 쓰는 이벤트로 기록하고, 이유 이름을 속성으로 같이 남겨요. 이유별로 몇 번 일어났는지 볼 수 있어요.
  • 다시 시도 버튼 클릭은 웹 방문 분석 도구인 Umami 의 클릭 이벤트로 세요. 버튼에 이벤트 이름 속성 하나만 붙이면 돼요.

둘을 나눈 이유는, 한 가지 숫자를 두 곳에서 세면 어느 쪽이 맞는지 알 수 없어서예요. 실패는 "서비스가 잘 돌아가는가"를 보는 숫자라 정확해야 하고, 버튼 클릭은 "이 문구가 먹히는가"를 보는 참고 숫자라 방문 분석 도구에서 세면 충분해요.

숫자를 볼 때 자주 확인할 질문은 이렇습니다.

  • worker 실패 대비 다시 시도 클릭 비율이 너무 낮으면, 버튼이 눈에 안 띄거나 문구가 불안하게 읽히는 거예요.
  • decode 가 너무 많으면 업로드 전에 파일 형식을 걸러 주는 편이 나아요.
  • rate-limited 가 눈에 띄게 늘면 한도 값이 현실과 안 맞는 거예요.

참고

다시 시도 클릭 뒤에 성공했는지까지 이어서 보려면 성공 이벤트와 연결해야 해요. 클릭 수는 버튼이 눈에 띄는지를 보여 줄 뿐 문제가 풀렸는지는 말해 주지 않으니, 클릭 수와 성공률은 구분해서 해석하세요.

자주 묻는 질문

Q1. 서버 실패에도 자동으로 다시 시도하면 안 되나요?

가능해요. 다만 사용자가 모르는 사이 같은 요청이 여러 번 가면 서버에 부담이 되고, 한도에 더 빨리 닿아요. 자동 재시도를 넣는다면 횟수를 1~2회로 제한하고, 그래도 실패하면 이 글의 수동 버튼 화면으로 넘기세요.

Q2. 다시 시도 버튼을 누르면 몇 번까지 허용해야 하나요?

버튼 횟수 제한을 따로 두지 않아도 돼요. 서버 쪽 요청 한도가 이미 429 로 걸러 주고, 한도에 걸리면 그 순간 이 글의 rate-limited 화면으로 바뀌어 기다릴 시간을 알려 줘요. 화면에서 또 막으면 규칙이 두 곳에 생겨요.

Q3. 이유를 더 잘게 나눠야 하나요?

사용자가 할 수 있는 일이 다르지 않으면 합치세요. 이유를 늘리면 문구와 테스트도 같이 늘어요. 이유 하나를 새로 만들 때는 "이 이유에서는 사용자가 하는 일이 다른가"를 먼저 물어보세요.

정리

  • 실패는 상태 코드가 아니라 사용자가 할 수 있는 일로 나눠요. 같은 사진으로 다시 해도 되는지가 기준이에요.
  • 응답이 없는 경우를 포함해, 목록에 없는 실패는 다시 시도할 수 있는 쪽으로 묶어요.
  • 문구는 무슨 일인지, 누구 탓인지, 이제 무엇을 할지 말해요. 숫자와 대기 시간은 구체적으로 적어요.
  • 오류는 알림창이 아니라 결과가 나올 자리에 보이고, 다시 시도 버튼은 서버 쪽 실패에만 달아요.
  • 다시 시도는 시도 횟수 상태 하나로 만들고, 실패는 캐시에 넣지 않아요.

실패 화면은 잘 안 쓰이는 화면이지만, 쓰이는 순간에는 사용자가 가장 답답한 상태예요. 그 순간에 이유와 다음 행동을 한 곳에서 알려 주면, 같은 서비스인데도 훨씬 믿음직하게 느껴져요.

이어서 읽기 좋은 글