/ 우리는 이렇게 합니다 / 버튼 담당자의 상태 설계 노트
개발·인터랙션 · 버튼을 맡은 사람

버튼은 하나가 아니라, 다섯 얼굴입니다.

기본·호버·로딩·완료·오류. 같은 버튼이 누른 뒤에 어떤 얼굴을 보여주느냐로 사람은 안심하거나 같은 걸 두 번 누릅니다. 그래서 이 글은 읽는 글이 아니라 상태를 바꿔보는 글입니다. 아래 위젯, 진짜로 다섯 상태가 돕니다.

한 줄 직답

좋은 버튼은 한 모양이 아니라 다섯 상태를 가집니다. 기본은 ‘누를 수 있다’, 호버는 ‘여기다’, 로딩은 ‘지금 처리 중’, 완료는 ‘됐다’, 오류는 ‘이렇게 다시’를 말합니다. Findable은 이 다섯을 빠뜨리지 않게 설계해 중복 제출을 막고 오류에서 사용자를 갇히지 않게 합니다.

요약

  • 버튼은 기본·호버·로딩·완료·오류 다섯 상태로 설계한다 — 한 모양으로 끝내지 않는다.
  • 클릭하는 순간 로딩으로 바꾸고 disabled를 걸어 중복 제출을 막는다.
  • 비활성은 흐리게만 하지 말고 ‘왜 못 누르는지’를 알려준다.
  • 오류는 잠깐 보여준 뒤 ‘다시 시도’가 되는 기본 상태로 반드시 원복한다.

입사 둘째 해, 제가 만든 신청 폼에서 같은 문의가 3분 간격으로 두 번씩 들어온다는 리포트를 받았습니다. 코드는 멀쩡했죠. 문제는 ‘버튼이 아무 말도 안 한 것’이었습니다. 사용자가 신청을 눌렀는데 화면이 그대로니까, 안 눌린 줄 알고 한 번 더 누른 겁니다. 그날 저는 버튼을 ‘모양’이 아니라 ‘상태를 가진 작은 기계’로 다시 봤습니다.

그래서, 다섯 얼굴을 직접 바꿔보세요

설명보다 빠릅니다. 아래 위젯의 단계 버튼을 눌러 기본부터 오류까지 같은 버튼이 어떻게 바뀌는지 보세요.

Live · 버튼 상태 머신

버튼은 다섯 가지 얼굴을 가집니다

상태 버튼을 눌러 기본·호버·로딩·완료·오류를 확인하세요.

로딩 상태는 멋이 아니라 ‘중복 제출 방지 장치’입니다

클릭하는 순간 버튼을 로딩으로 바꾸면 두 가지가 동시에 일어납니다. 사용자에게는 ‘지금 처리 중’이라는 신호가 가고, 버튼에는 disabled가 걸려 같은 요청을 다시 보낼 수 없게 됩니다. 결제·예약·문의처럼 되돌릴 수 없는 행동 앞에서는 이걸 빼면 안 됩니다. 위 위젯에서 ‘로딩’을 눌러보면 버튼이 잠기는 게 보입니다.

비활성 버튼은 ‘왜 못 누르는지’까지 말해야 합니다

흔한 실수는 비활성 버튼을 그냥 흐리게만 만드는 겁니다. 사용자는 색이 옅어진 버튼을 보고도 ‘이게 안 눌리는 건가, 내가 잘못한 건가’를 모릅니다. 저는 비활성일 때 cursornot-allowed로 바꾸고, 가능하면 ‘필수 항목을 채워주세요’ 같은 이유를 옆에 붙입니다. 이유가 없는 비활성은 막다른 길처럼 느껴집니다.

완료 상태는 ‘봤다’와 ‘다음’ 사이의 짧은 1~2초입니다

체크 표시 같은 완료 신호는 사용자가 인지할 만큼만 보여줍니다. 보통 1~2초입니다. 너무 짧으면 못 보고, 너무 오래 멈춰 있으면 다음 행동을 가로막습니다. 완료를 보여준 뒤에는 다음 단계로 넘기거나 기본 상태로 원복해서 사용자가 갇히지 않게 합니다.

오류에서 멈추면 사용자는 갇힙니다 — 회복이 핵심

가장 자주 빠뜨리는 게 오류 회복입니다. 요청이 실패하면 버튼을 오류 상태로 잠깐 바꿔 ‘무엇이 잘못됐다’를 알리되, 반드시 다시 누를 수 있는 기본 상태로 돌아와야 합니다. 오류에서 멈춰버린 버튼은 사용자를 막다른 곳에 가둡니다. ‘다시 시도’가 가능하도록 원복하고, 잘못된 이유를 한 줄로 알려주는 것 — 이게 오류 설계의 전부입니다. 위 위젯의 ‘오류’를 눌러본 뒤 ‘기본’으로 돌아오는 흐름이 그것입니다.

다섯 상태를 코드로 묶는 방법: 상태 머신

저는 버튼 안에 data-state 하나만 둡니다. default → loading → success 또는 loading → error → default처럼 ‘허용된 전이’만 정의하면, 로딩 중에 또 클릭이 들어와도 무시되고 중복이 원천적으로 막힙니다. 위 위젯도 이 방식입니다. 상태가 머릿속이 아니라 코드에 있어야 버그가 줄어듭니다. 제가 실제로 쓰는 전이표는 이렇게 생겼습니다.

JS · 버튼 상태 머신
// 허용된 전이만 표로 선언 — 표에 없는 전이는 무시된다
const NEXT = {
  default: ["loading"],
  loading: ["success", "error"],
  success: ["default"],
  error:   ["default", "loading"], // 오류 → 재시도 허용
};

function setState(btn, to) {
  const from = btn.dataset.state || "default";
  if (!NEXT[from]?.includes(to)) return;   // 로딩 중 재클릭 = 차단
  btn.dataset.state = to;
  btn.disabled = (to === "loading");       // 중복 제출 원천 차단
  btn.setAttribute("aria-busy", to === "loading");
}

이렇게 표로 못 박아두면 ‘오류에서 멈춰버린 버튼’이나 ‘두 번 제출되는 신청’ 같은 사고가 코드 단계에서 사라집니다.

항목상태 없는 버튼상태 있는 버튼
중복 제출로딩 표시 없음 → 두 번 클릭클릭 즉시 로딩·disabled로 차단
신뢰눌러도 반응 없음 → 불안로딩·완료 신호로 ‘되고 있다’ 전달
오류 회복실패하면 멈춤 → 갇힘오류 표시 뒤 기본 상태로 원복·재시도
비활성흐리게만 → 이유 모름not-allowed + 이유 안내

버튼 하나의 상태를 이렇게 보는 사람이, 사이트 전체를 만듭니다

버튼은 사용자가 ‘행동’하는 거의 유일한 지점이고, 그 뒤의 1초가 신뢰를 만듭니다. 그 다섯 상태를 진지하게 다루는 사람이라면 폼도 카피도 로딩 연출도 같은 태도로 다룹니다. Findable에서는 이런 상태 설계가 기본값입니다. 당신의 신청 버튼, 어떤 얼굴로 만들어 드릴까요?

다른 담당자와의 연결

버튼을 누른 직후에 아무 변화도 없으면 왜 문제가 되나요?
사람은 반응이 없으면 다시 누릅니다. 그래서 문의가 두 번 들어오고 결제가 두 번 됩니다. 클릭 직후 버튼이 로딩 상태로 바뀌면 ‘지금 처리 중’이라는 신호가 전달돼 중복 제출이 사라집니다.
로딩 중에 버튼을 비활성화하는 게 정말 필요한가요?
필요합니다. 처리 중에 disabled를 걸지 않으면 사용자가 같은 요청을 여러 번 보낼 수 있습니다. 결제·예약·문의처럼 되돌릴 수 없는 행동에서는 로딩 상태로 진입하는 순간 버튼을 잠가 중복 요청을 막아야 합니다.
비활성(disabled) 버튼은 그냥 흐리게만 하면 되나요?
흐리게만 하면 사용자는 ‘왜 못 누르는지’를 모릅니다. 색만 줄이지 말고 cursor를 not-allowed로 바꾸고, 가능하면 ‘필수 항목을 채워주세요’ 같은 이유를 옆에 보여줘야 합니다. 이유 없는 비활성은 막다른 길처럼 느껴집니다.
오류가 났을 때 버튼은 어떻게 회복해야 하나요?
오류 상태를 잠깐 보여준 뒤 반드시 다시 누를 수 있는 기본 상태로 돌아와야 합니다. 버튼이 오류에서 멈춰버리면 사용자는 갇힙니다. ‘다시 시도’가 가능하도록 원복하고, 무엇이 잘못됐는지 한 줄로 알려주는 게 핵심입니다.
완료 상태는 얼마나 오래 보여줘야 하나요?
체크 표시 같은 완료 신호는 사용자가 인지할 만큼만, 보통 1~2초 보여준 뒤 다음 단계로 넘기거나 기본 상태로 원복합니다. 완료 표시가 너무 짧으면 못 보고, 너무 길게 멈춰 있으면 다음 행동을 막습니다.

이런 상태 설계로 사이트를 만듭니다

버튼의 다섯 얼굴까지 챙기는 팀이 당신의 홈페이지를 만들면 어떨까요. 무료 진단으로 시작하세요.

무료 진단 받기

이 글의 상태 위젯은 이 페이지에서 실제로 동작하는 코드입니다(기본·호버·로딩·완료·오류 다섯 상태, 외부 라이브러리 0). 전환·중복 제출 관련 서술은 일반 원칙이며 특정 성과를 보장하지 않습니다. 날조된 사례·수치는 사용하지 않았습니다.