발행일

토큰 승인 오클릭이 무서워서 만든 가드 — 위험한 액션 버튼 UX 회고

토큰 승인 오클릭이 무서워서 만든 가드 — 위험한 액션 버튼 UX 회고

토큰 신청을 승인하면 신청자에게 토큰이 발급되고 이메일이 나갑니다. 되돌리기 번거로운, 외부로 새어 나가는 액션이에요. 이런 버튼은 "누르기 쉬움""실수로 누르기 어려움" 을 동시에 만족해야 합니다. 검토 화면의 버튼 레이아웃을 다시 짜면서 그 균형을 맞춘 기록입니다.


1. 문제 — 승인과 거절이 따로 떨어진 두 폼

원래 마크업은 승인 폼과 거절 폼이 위아래로 분리돼 있었습니다. 각자 <form>이고, 승인 버튼은 승인 폼 안에, 거절 버튼은 거절 폼 안에 있었어요.

<!-- 변경 전: 승인이 자기 폼 안에 박혀 있음 -->
<form action="...approve" method="post"
      onsubmit="return confirm('이 신청을 승인하고 토큰을 발급하시겠습니까?');">
  ...
  <button class="primary" type="submit">승인</button>
</form>

<form action="...reject" method="post">
  ...
  <button class="warning" type="submit">거절</button>
</form>

두 가지가 불편했습니다.

  • 버튼이 따로 놀았어요. 승인은 상단 박스 안, 거절은 하단 박스 안. 검토자가 "승인/거절 액션이 여기 모여 있다" 고 한눈에 읽히지 않았습니다.
  • 위험한 시나리오가 있었어요. 거절 사유를 다 적어놓고, 무심코 승인을 눌러버리면? 사유는 버려지고 토큰이 발급됩니다. 사용자 의도(거절하려던 것)와 정반대 결과가 나와요.

두 번째가 진짜 문제였습니다. 첫 번째는 보기 불편한 것이지만, 두 번째는 되돌리기 어려운 부작용이니까요.


2. 마크업 방식 네 가지와 고른 이유

버튼 두 개를 한 그룹으로 묶으면서 각자 다른 요청을 보내야 했습니다. 후보를 늘어놓고 골랐어요.

방법장점포기하는 것판단
① CSS로 버튼 위치만 조정변경 최소제출 대상이 꼬임. 버튼은 자기가 속한 폼을 제출하므로, 시각적으로 모아도 DOM 구조가 안 맞으면 의미가 어긋남
② JS로 클릭 시 제출할 폼 결정자유도 최대JS가 죽으면 검토 화면 전체가 먹통. 승인/거절이 스크립트 의존이 될 이유가 없음
③ 폼 하나로 합치고 formaction으로 분기폼이 하나라 사유 textarea가 양쪽에 공유됨승인 요청에도 거절 사유가 같이 전송됨. 서버가 무시하면 되지만 요청의 의미가 흐려짐✗ (근소)
④ 버튼을 폼 밖에 두고 form 속성으로 연결HTML 표준, JS 없이 그룹화 자유, 요청 의미 명확승인 폼이 화면에 안 보이는 빈 폼으로 남음✓ 채택

③이 마지막까지 남았습니다. HTML만 쓴다는 점은 ④와 같고, 폼이 하나라 구조도 단순해요. 그런데 승인 POST에 거절 사유가 실려 나가는 게 걸렸습니다. 서버 입장에서 "승인 요청인데 거절 사유가 들어 있는" 페이로드를 받게 되고, 나중에 로그를 보는 사람이 이걸 어떻게 읽어야 할지 애매해져요. 요청 본문은 그 요청의 의도만 담는 게 낫다고 봤습니다.

②는 아예 후보가 아니었습니다. 승인·거절은 이 화면의 존재 이유인데, 그걸 스크립트 실행 여부에 걸 이유가 없어요.

④의 form 속성을 쓰면 버튼이 폼 바깥에 있어도 어느 폼을 제출할지 지정할 수 있습니다. 버튼 두 개를 한 그룹으로 묶으면서, 각자 다른 폼을 제출하게 만들 수 있었어요.

<!-- 거절 사유 입력 폼 (버튼은 밖에 둠) -->
<form id="token-reject-form" action="...reject" method="post">
  {{ csrf_input }}
  <label>거절 사유</label>
  <textarea ...></textarea>
</form>

<!-- 승인 폼 (제출 전용 — 보이지 않음) -->
<form id="token-approve-form" class="hidden" action="...approve" method="post"
      onsubmit="var n=document.getElementById('...rejection_note...');
                return (n && n.value.trim())
                  ? confirm('거절 사유가 입력되어 있습니다. 그래도 승인하시겠습니까? (입력한 사유는 저장되지 않습니다)')
                  : confirm('이 신청을 승인하고 토큰을 발급하시겠습니까?');">
  {{ csrf_input }}
</form>

<!-- 버튼 그룹 — 우측 정렬, 각자 form 속성으로 연결 -->
<div class="flex justify-end gap-[.5rem]">
  <button class="primary" type="submit" form="token-approve-form">승인</button>
  <button class="warning" type="submit" form="token-reject-form">거절</button>
</div>

3. 오클릭 가드 — 맥락에 따라 다른 경고

핵심은 승인 버튼의 onsubmit입니다. 거절 사유가 입력돼 있는지를 보고 경고 문구를 바꿔요.

  • 사유가 비어 있으면: "이 신청을 승인하고 토큰을 발급하시겠습니까?" (평범한 확인)
  • 사유가 적혀 있으면: "거절 사유가 입력되어 있습니다. 그래도 승인하시겠습니까? (입력한 사유는 저장되지 않습니다)" (모순 경고)

두 번째 경고가 이 작업의 진짜 목적입니다. 사용자가 방금 한 행동(거절 사유 작성)과 지금 누른 버튼(승인)이 모순 임을 그 순간에 짚어주는 거예요. 단순히 "정말요?"를 한 번 더 묻는 게 아니라, 왜 위험한지를 알려줍니다.

커스텀 모달 대신 네이티브 confirm을 남긴 이유

프로젝트에 확인 모달 컴포넌트가 이미 있어서, 그쪽으로 갈지 고민했습니다. 디자인이 통일되고 문구도 길게 쓸 수 있으니까요. 그래도 confirm()을 남겼습니다.

  • 모달은 비동기라 onsubmit에서 제출을 막고 다시 트리거해야 합니다. 위험한 액션의 제출 경로에 상태 하나를 더 얹는 셈이에요.
  • confirm()은 포커스 트랩·ESC·스크린 리더가 브라우저 기본으로 보장됩니다. 커스텀 모달로 그 수준을 맞추려면 손이 더 갑니다.
  • 이 화면은 관리자용이고 하루에 몇 번 뜨는 창이라, 디자인 통일보다 확실히 막히는 게 우선이었습니다.

대신 이건 명백한 타협이라, 아래 6절에 한계로 적어뒀어요.

이 패턴은 토큰 발급 신청 화면에도 같은 결로 적용했습니다 — 신청/취소 버튼을 우측 정렬 그룹으로 묶고, 신청은 primary, 취소는 warning(빨강)으로 색을 분리했어요. "색만 봐도 어느 게 되돌리기 어려운 액션인지" 읽히게요.


4. 가드를 안 건 쪽

거절 폼에는 일부러 확인 가드를 넣지 않았습니다.

액션되돌리기외부 영향가드
승인어려움 (토큰 발급됨)신청자에게 메일 발송있음 (맥락 분기)
거절쉬움 (다시 검토 가능)없음없음

가드를 다 걸면 사용자는 모든 확인창을 반사적으로 넘기게 됩니다. 그러면 정작 위험한 창도 안 읽혀요. 가드의 효과는 드물게 뜰수록 커집니다.


5. 검증

시나리오기대결과
사유 없이 승인일반 확인창 → 확인 시 발급
사유 입력 후 승인모순 경고창 → 취소 시 아무 일도 안 일어남
사유 입력 후 승인 → 확인발급되고 사유는 저장되지 않음 (경고 문구와 일치)
사유 입력 후 거절확인창 없이 정상 제출
버튼 그룹 정렬화면 폭이 좁아져도 우측에 나란히 유지
CSRF두 폼 각각 토큰 포함, 제출 정상

세 번째 항목을 일부러 넣었습니다. 경고 문구가 "입력한 사유는 저장되지 않습니다" 라고 약속하는데, 실제로 어딘가에 남으면 문구가 거짓말이 되니까요.


6. 회고와 한계

  • 위험한 액션 버튼은 두 조건을 동시에 만족해야 합니다. 누르기 쉽고, 실수로는 누르기 어렵게. 둘은 충돌하는 것 같지만 맥락 가드로 양립합니다.
  • form 속성은 버튼 레이아웃을 폼 구조에서 해방시킵니다. 버튼을 폼 밖으로 빼서 자유롭게 그룹화하면서, 제출 대상은 명확히 유지할 수 있어요. JS 없이 HTML만으로요.
  • 좋은 확인창은 "정말요?"가 아니라 "왜 위험한지"를 말합니다. 사용자의 직전 행동과 모순될 때 그 모순을 짚어주는 경고가, 무지성 확인창보다 훨씬 잘 막아요.
  • 가드는 위험한 쪽에만. 안전한 액션까지 확인창을 달면 가드 전체가 무력해집니다.
  • 탈락시킨 안을 적어두니 나중에 편했습니다. "왜 폼 하나로 안 합쳤냐"는 질문이 나왔을 때 다시 생각할 필요가 없었어요.

한계도 분명합니다. 가드 로직이 템플릿 인라인 onsubmit에 문자열로 들어가 있어요. 지금은 조건이 하나뿐이라 읽히지만, 조건이 늘면 템플릿 안의 JS 문자열이 감당이 안 됩니다. 그리고 이건 UI 가드일 뿐이라 서버는 여전히 무방비예요. 이미 처리된 신청에 승인 요청이 다시 들어오면 서버가 막아야 합니다. 다음에 이 화면을 건드릴 때는 가드를 스크립트 파일로 빼고, 서버 쪽 상태 검사를 같이 넣을 생각입니다.

작은 화면 하나였지만, "실수로 토큰을 발급해버리면 어쩌지" 라는 불안을 코드로 막아낸 작업이라 마음이 편해졌어요.