폼이 21개인데 폼 규칙이 하나도 없다. 그래서 이렇게 되어 있다.
| 개수 | 두 번 누르면 | |
|---|---|---|
| 방어 있음 | 6 | 막힌다 |
| 무방비 · 조회만 하는 폼 | 4 | 조회가 두 번 될 뿐 |
| 무방비 · 서버를 바꾸는 폼 | 7 | 두 개가 생기거나 두 번 지워진다 |
무방비인 일곱은 코드 생성·메뉴 생성·계정 삭제·권한 수정 같은 것들이다. 일부러 뺀 게 아니라 그냥 잊혔다.
먼저: 방어는 「폼을 파일로 뺄 때」만 딸려 왔다
무방비인 곳을 세고 나서 규칙성이 하나 보였다.
방어 있는 6개 → 전부 전용 폼 컴포넌트다 (ReviewForm · AccountForm · CaregiverForm …)
무방비 15개 → 전부 목록·상세 화면 안에 폼이 끼어 있다
누가 폼을 따로 파일로 뺄 때는 처음부터 제대로 만들었고, 목록 화면 한구석에 폼을 끼워 넣을 때는 매번 잊었다.
이게 이 표준의 방향을 정했다. 문제는 사람이 게을러서가 아니라, 방어가 「따로 기억해야 하는 것」이라서다.
먼저: Server Actions 가 없어서 선택지가 하나였다
Next 는 폼 제출을 서버 함수로 넘기는 길(Server Actions)을 준다. 그걸 쓰면 중복 제출 방어가 useActionState 에 딸려 온다.
그런데 이 사이트는 정적 export 라 그 길이 막혀 있다. 문서의 미지원 목록에 있다.
Server Actions 못 씀
Route Handlers (요청 의존) 못 씀
Cookies 못 씀
폼 라이브러리도 안 쓴다. 그래서 「내 손으로 상태를 들고 있는 방법」 하나만 남았고, 이 표준은 그 하나를 어떻게 다룰지만 정한다.
원칙 1. 규칙은 서버를 바꾸는 폼에만 건다
폼 21개를 다 같이 다루지 않는다. 두 번 눌렸을 때 잃는 것이 다르기 때문이다.
검색창에서 [검색] 을 두 번 눌렀다 → 검색이 두 번 됐다. 그뿐이다
계정 삭제에서 [삭제] 를 두 번 눌렀다 → 삭제 요청이 두 번 나갔다
안 A — 모든 폼에 건다. 빠뜨릴 걱정이 없다. 버린 이유는 검색창에까지 상태가 하나 늘기 때문이다. 아무것도 안 지켜주면서 코드만 늘면, 사람들이 「이건 검색이니까」 하고 빼기 시작하고 그 순간 규칙이 지켜지는지 아무도 모르게 된다.
골랐다 — 서버를 바꾸는 폼만. 안 A 가 못 지켜주는 것은 규칙이 계속 지켜지는지 확인할 수 있는 상태다. 대상이 좁으면 「이 폼은 서버를 바꾸나」 한 줄로 검사가 끝난다.
대신 경계에 걸치는 폼을 사람이 판단해야 한다. 검색 결과를 저장하는 폼 같은 것이 나오면 그때 쓰기로 본다.
원칙 2. 중복 제출은 글이 아니라 훅으로 막는다
지켜야 할 코드 모양은 이렇다.
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (submitting) return; // ← 이 줄을 빠뜨리면 끝
setSubmitting(true);
try { await createMenu(form); }
finally { setSubmitting(false); }
};
안 A — 폼은 무조건 전용 컴포넌트로 뺀다. 방어가 있는 6개가 전부 그 모양이니 따라 하자는 것. 버린 이유는 컴포넌트 추출 표준이 「빼는 이유는 둘뿐」이라고 이미 정했기 때문이다. 여기서 「폼이면 뺀다」를 더하면 두 표준이 서로 다른 말을 하고, 다음 사람이 어느 쪽을 따를지 모른다.
안 B — 위 여섯 줄을 규칙으로 적는다. 버린 이유는 여섯 줄을 매번 기억해야 하기 때문이다. 지금 15개가 무방비인 게 그 증거다. 사람이 기억해야만 지켜지는 규칙은 이 저장소가 반복해서 버려온 것이다.
골랐다 — 훅으로 만든다.
const { submitting, run } = useSubmit();
<form onSubmit={run(() => createMenu(form))} noValidate>
<button disabled={submitting}>저장</button>
</form>
안 B 가 못 지켜주는 것은 잊었을 때 티가 나는 것이다. 훅을 안 쓰면 submitting 이라는 값 자체가 없어서 버튼을 잠글 수가 없다. 잊으면 코드가 어색해진다.
대신 훅이 실패를 삼키지 않게 해야 한다. 방어만 훅이 들고, 실패 처리는 부르는 쪽이 든다(원칙 7).
원칙 3. 훅은 앱마다 각자 둔다
앱이 둘(client · admin)이라 훅을 나눠 쓸지를 정해야 했다.
안 A frontend/hooks/useSubmit.js → 생성기가 두 앱에 복사
안 B <앱>/src/hooks/useSubmit.js → 같은 코드가 두 벌
토큰 공유 표준이 정한 대로, 폴더를 같이 보게 할 수는 없다 — Docker 빌드 컨텍스트가 앱 폴더라 밖의 파일은 이미지에 안 들어간다. 나눠 쓰려면 생성기로 복사하는 수밖에 없다.
안 A 를 버린 이유는 이 훅이 10줄짜리라서다. 그거 하나 때문에 생성기가 CSS 말고 JS 까지 관리하게 되고, 고칠 때마다 두 앱을 다시 뽑아야 한다. 드는 것이 얻는 것보다 크다.
그리고 같은 상황이 이미 정해져 있었다.
컴포넌트가 겹쳐 보여도 두 번째에서 합치지 않는다. 세 번째가 나와야 같은 것인지 안다.
골랐다 — 앱마다 각자. 대가는 두 벌이 어긋날 수 있는 것이다. 다만 어긋나도 각 앱 안에서는 일관되고, 세 번째 앱이 생기면 그때 합칠지 다시 본다.
원칙 4. 브라우저 검사를 끄고 손으로 검사하되 required 는 남긴다
지금은 두 갈래로 갈려 있고, 한쪽은 죽어 있다.
InquiryNewClient required 6개 + noValidate → required 가 아무 일도 안 한다
MenuListClient required 3개, noValidate 없음 → 브라우저 말풍선이 뜬다
안 A — 브라우저에 맡긴다. 공짜다. 버린 이유는 서버도 검증하기 때문이다. 서버가 퇴짜 놓은 것은 어차피 내가 그려야 해서, 한 화면에 브라우저 말풍선과 내 빨간 글씨가 둘 다 생긴다. 같은 「잘못 썼다」인데 모양이 둘이 된다.
골랐다 — 손으로 검사한다. noValidate 로 브라우저 검사를 끄면 손 검증 실패와 서버 검증 실패가 같은 자리에 같은 모양으로 뜬다.
단, required 는 지우지 않는다. noValidate 가 끄는 것은 말풍선뿐이고, required 는 남아서 낭독기가 「필수 입력」이라고 읽어준다. 지우면 그게 사라진다.
대신 필수 표시가 두 곳에 살게 된다 — required 속성과 손 검증 코드. 하나만 고치면 어긋나므로 같이 본다.
원칙 5. 실패하면 첫 틀린 칸으로 커서를 옮기고 오류 글씨를 그 칸에 잇는다
오류 글씨에 role="alert" 는 42곳에 붙어 있다 — 나타나는 순간에는 읽힌다.
비어 있는 것은 다른 둘이다. 그 글씨가 어느 칸 얘기인지 잇는 표시가 0곳이고, 커서 이동도 0곳이다.
긴 폼에서 저장을 눌렀다고 하자.
눈으로 보는 사람 → 화면이 그대로라 위로 스크롤해서 틀린 칸을 찾아야 한다
낭독기 쓰는 사람 → "이름" 까지만 읽힌다. 빨간 글씨가 이 칸 것인지 모른다
커서를 옮기면 화면이 저절로 거기로 가고, 낭독기도 그 칸을 읽는다. 오류 글씨를 칸에 묶으면 나중에 그 칸으로 돌아왔을 때도 「이름, 틀림, 이름을 입력해 주세요」까지 읽힌다 — role="alert" 는 뜨는 순간 한 번뿐이라 그때 놓치면 다시 못 듣는다.
<input
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined} />
{errors.name && <p id="name-error" className="field-error">{errors.name}</p>}
안 — 오류 요약을 폼 맨 위에 한 번만 띄운다. 흔한 방식이고 커서를 거기로 보내면 된다. 버리지는 않았지만 기본으로 삼지 않았다 — 요약을 만들려면 오류를 모아 목록으로 그려야 해서 폼마다 코드가 는다. 칸이 20개 넘는 폼이 나오면 그때 얹는다.
만들 것인가 가져다 쓸 것인가 표준이 「커서를 어디에 두는가」를 다시 만들 것 목록에 넣어 뒀다. 이 원칙이 그 목록의 폼 쪽 항목이다.
원칙 6. 서버가 준 칸별 오류를 버리지 않는다
백엔드는 항상 어느 칸이 틀렸는지까지 실어 보낸다.
FieldError.of("phone", "값의 형식이 올바르지 않습니다.")
@Valid 실패, 파라미터 누락, 타입 불일치, ConstraintViolation — 네 경로가 전부 이 모양이다.
그런데 폼 21개 중 2개만 이걸 쓴다. 나머지는 버리고 「실패했습니다」 한 줄만 띄운다.
서버가 보낸 것 "phone 칸이 틀렸습니다"
화면이 보여준 것 "실패했습니다"
사용자는 어느 칸이 문제인지 모른 채 처음부터 다시 훑는다. 서버가 이미 알려준 것을 화면에서 버리는 것이다.
const fieldErrors = error instanceof ApiError ? error.body?.errors : null;
if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
setErrors(Object.fromEntries(fieldErrors.map(({ field, message }) => [field, message])));
return;
}
setSubmitError('저장하지 못했습니다. 잠시 후 다시 시도해 주세요.');
받은 오류는 손 검증 오류와 같은 상태에 넣는다. 그래야 원칙 4·5 가 그대로 적용된다 — 같은 자리에 뜨고, 같은 방식으로 커서가 가고, 같은 방식으로 읽힌다.
칸 이름을 못 알아보면 일반 메시지로 내린다. 서버가 화면에 없는 칸 이름을 줄 수 있는데, 그걸 그냥 버리면 사용자에게 아무 말도 안 하게 된다.
원칙 7. 제출 실패는 핸들러가 직접 잡는다
실패 화면 표준이 예상 못 한 예외를 error.js 로 잡기로 했다. 그런데 제출 실패는 거기 안 걸린다.
error.js 는 React Error Boundary 이고, Next 문서가 가리키는 React 항목이 “catching rendering errors” 다. 제출 핸들러는 렌더가 아니라 이벤트 핸들러다.
화면을 그리다 터짐 → error.js 가 잡는다
[저장] 을 눌러서 터짐 → 아무도 안 잡는다
안 잡히면 무슨 일이 나는지가 중요하다. finally 로 버튼은 풀리므로 화면은 멀쩡해 보이는데 아무 말도 안 뜬다. 사용자는 저장이 됐는지 안 됐는지 모른 채 다시 누른다.
그래서 run() 에 넘기는 함수는 자기 실패를 자기가 잡는다.
run(async () => {
try { await createMenu(form); router.replace('/menus'); }
catch (error) { /* 원칙 6 */ }
})
훅이 대신 잡아주게 하는 안은 버렸다. 훅이 잡으면 실패를 어떻게 보여줄지도 훅이 정해야 하는데, 그건 폼마다 다르다 — 칸별 오류를 뿌릴 폼도 있고 한 줄만 띄울 폼도 있다. 훅이 그것까지 들면 옵션이 붙기 시작하고 결국 폼 라이브러리가 된다.
원칙 8. 성공하면 router.replace 로 옮긴다
지금 두 갈래다.
admin 28곳 router.replace('/accounts') → 목록 화면만 바꿔 끼운다
client 2곳 window.location.href = '/inquiries' → 앱을 처음부터 다시 받는다
아래쪽은 주소창에 직접 친 것과 같아서 JS·CSS·데이터를 전부 다시 내려받는다.
push 가 아니라 replace 인 이유가 따로 있다.
push 로 갔으면 → 뒤로 가기를 누르면 방금 낸 폼이 다시 나온다. 또 낼 수 있다
replace 로 갔으면 → 폼이 기록에서 지워져 안 나온다
원칙 2 가 두 번 못 내게 막았는데, push 를 쓰면 뒤로 가기로 그 방어를 우회한다. 두 규칙은 같이 서 있어야 한다.
window.location 을 금지하지는 않는다. 앱 밖으로 나갈 때는 그게 맞다 — CallConfirmationDialog 가 tel: 로 넘기는 것이 그렇다. 가르는 기준은 「가려는 곳이 이 앱 안인가」다.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 어떤 폼이 규칙 대상인가 | 서버를 바꾸는 폼 | 검색·필터는 뺀다 |
| 중복 제출을 어떻게 막나 | useSubmit 훅 |
글로 적으면 또 잊는다 |
| 훅을 어디 두나 | 앱마다 각자 | 세 번째 앱이 나오면 다시 본다 |
| 검증을 누가 하나 | 내가 한다 | noValidate 를 붙인다 |
required 는 |
남긴다 | 낭독기가 「필수」를 읽는 근거 |
| 검증 실패하면 | 첫 틀린 칸으로 커서 | 스크롤이 따라간다 |
| 오류 글씨는 | aria-invalid + aria-describedby |
낭독기가 같이 읽는다 |
| 서버가 준 칸별 오류는 | 그 칸에 붙인다 | 버리면 「실패했습니다」만 남는다 |
| 제출이 터지면 | 핸들러가 잡는다 | error.js 는 렌더만 잡는다 |
| 성공하면 | router.replace |
push 는 뒤로 가기로 방어를 뚫는다 |
window.location 은 |
앱 밖으로 나갈 때만 | tel: 같은 것 |
이 표준을 정하기까지
프론트에 뭐가 더 남았는지 세다가 폼이 걸렸다. onSubmit 이 26곳인데 폼 규칙이 하나도 없었다. 처음 센 숫자는 「26개 중 20개 무방비」였다.
그 숫자가 틀렸다. ReviewNewClient 를 열어보니 ReviewForm 에 넘기고 있었고, 방어는 그 안에 있었다. 파일 단위로 센 것이 헛세는 것이었다. 폼이 실제로 사는 자리(<form)로 다시 세니 21개 중 15개였고, 그중 조회만 하는 4개를 빼니 진짜 문제는 7개였다.
다시 세면서 규칙성이 하나 나왔다. 방어가 있는 6개는 전부 전용 폼 컴포넌트고, 무방비 15개는 전부 목록 화면 안에 끼어 있는 폼이었다. 문제는 사람이 게을러서가 아니라 방어가 「따로 기억해야 하는 것」이라서였다. 이게 원칙 2 를 「글로 적지 말고 훅으로」로 몰고 갔다.
제약을 먼저 확인한 것이 안을 줄였다. Server Actions 가 정적 export 미지원 목록에 있어서 useActionState 로 푸는 길이 처음부터 막혀 있었다. 폼 라이브러리도 안 쓴다. 선택지가 하나뿐이었고, 그래서 그 하나를 어떻게 다룰지만 정하면 됐다.
검증에서는 「죽어 있는 코드」를 발견했다. required 6개와 noValidate 가 같은 파일에 있었다 — required 가 아무 일도 안 하고 있었다. 처음엔 required 를 지우는 쪽으로 기울었는데, 그러면 낭독기가 「필수 입력」을 못 읽는다는 것을 확인하고 남기기로 바꿨다.
error.js 가 제출 실패를 안 잡는다는 것은 확정 직전에 확인했다. 실패 화면 표준을 바로 앞에 써서 「예상 못 한 예외는 error.js 가 잡는다」가 머리에 있었는데, Next 문서가 가리키는 React 항목이 rendering errors 였다. 이벤트 핸들러는 거기 안 들어간다. 확인 안 하고 넘어갔으면 「터져도 error.js 가 잡아준다」고 잘못 적을 뻔했다.
마지막으로 window.location 을 바꿔도 안전한지 확인했다. 클라이언트 두 곳이 window.location 을 쓰는 이유가 「router.replace 로는 화면이 안 갈려서」일 수 있었다. 목적지를 열어보니 다른 라우트이고 useEffect 에서 데이터를 부르므로 replace 로도 새로 뜬다. 원인을 확인하지 않고 「습관이겠지」로 넘겼으면 안 되는 규칙을 적을 뻔했다.
정리
- 규칙은 서버를 바꾸는 폼에만 건다. 검색창까지 걸면 지켜지는지 확인할 수 없게 된다.
- 중복 제출은 훅으로 막는다. 글로 적으면 또 잊는다 — 15개가 무방비인 게 증거다.
- 훅은 앱마다 각자 둔다. 10줄 때문에 생성기를 얹지 않는다. 세 번째 앱이 나오면 다시 본다.
noValidate로 브라우저 검사를 끄고 손으로 검사하되required는 남긴다. 말풍선만 꺼지고 낭독기는 살아 있다.- 실패하면 첫 틀린 칸으로 커서를 옮기고, 오류 글씨를 그 칸에 잇는다.
- 서버가 준 칸별 오류를 버리지 않는다. 손 검증 오류와 같은 상태에 넣는다.
- 제출 실패는 핸들러가 직접 잡는다.
error.js는 렌더만 잡는다. - 성공하면
router.replace.push는 뒤로 가기로 중복 제출 방어를 뚫는다.
AI 코드 어시스턴트에 바로 적용하기
Claude Code · Codex — .claude/skills/form-submission-standard/SKILL.md · .agents/skills/form-submission-standard/SKILL.md
두 파일은 바이트까지 같아야 한다. .agents 쪽은 tools/agents-skills.rb 가 뽑는 생성물이다.
---
name: form-submission-standard
description: 폼을 제출하는 방법을 정한다. 중복 제출을 막는 훅, 검증을 어디서 하는지, 오류를 어느 칸에 어떻게 붙이는지, 서버가 준 칸별 오류를 받는 법, 성공 후 이동을 담는다. 폼을 만들 때, 저장·삭제 버튼을 붙일 때, 검증을 추가할 때 적용한다. Next.js 정적 export 기준이다.
---
# 폼 제출 표준
**규칙은 서버를 바꾸는 폼에만 건다.**
```
검색 · 필터 폼 두 번 눌려도 조회가 두 번 될 뿐이다 → 대상 아님
저장 · 삭제 폼 두 번 눌리면 두 개가 생기거나 두 번 지워진다 → 대상
```
검색창까지 걸면 아무것도 안 지켜주면서 코드만 늘고, **그러면 「이건 검색이니까」로 빠지기 시작해 지켜지는지 아무도 모르게 된다.**
**Server Actions 는 정적 export 에서 못 쓴다.** `useActionState` 로 푸는 길이 막혀 있어 상태를 직접 든다.
## 중복 제출은 훅으로 막는다
```javascript
const { submitting, run } = useSubmit();
<form onSubmit={run(() => createMenu(form))} noValidate>
<button disabled={submitting}>저장</button>
</form>
```
- **`if (submitting) return;` 여섯 줄을 손으로 쓰지 않는다.** 매번 기억해야 하는 규칙은 잊힌다.
- **훅을 안 쓰면 `submitting` 이 없어 버튼을 잠글 수 없다.** 잊으면 티가 난다.
- **훅은 방어만 든다.** 실패 처리는 부르는 쪽이 든다.
- **훅은 앱마다 각자 둔다** — `<앱>/src/hooks/useSubmit.js`. 생성기로 공유하지 않는다. 10줄 때문에 생성기가 JS 까지 관리하게 된다. 세 번째 앱이 나오면 다시 본다.
## 검증은 손으로 하고 `required` 는 남긴다
- **`<form>` 에 `noValidate` 를 붙인다.** 서버도 검증하므로, 안 끄면 브라우저 말풍선과 내 오류 글씨가 한 화면에 둘 다 생긴다.
- **`required` 는 지우지 않는다.** `noValidate` 가 끄는 것은 말풍선뿐이고, `required` 는 남아서 **낭독기가 「필수 입력」이라고 읽는다.**
- 필수 표시가 `required` 속성과 검증 코드 두 곳에 사니 **같이 고친다.**
## 오류는 그 칸에 붙인다
```jsx
<input
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined} />
{errors.name && <p id="name-error" className="field-error">{errors.name}</p>}
```
- **검증에 실패하면 첫 번째 틀린 칸으로 커서를 옮긴다.** 화면이 저절로 거기로 가고 낭독기도 그 칸을 읽는다.
- **`aria-invalid` 와 `aria-describedby` 를 단다.** `role="alert"` 는 뜨는 순간 한 번만 읽어주므로, 그때 놓치면 그 칸으로 돌아와도 다시 못 듣는다.
- 오류 요약을 폼 맨 위에 띄우는 방식은 기본으로 삼지 않는다. **칸이 20개 넘는 폼이 나오면 그때 얹는다.**
## 서버가 준 칸별 오류를 버리지 않는다
백엔드는 `@Valid` 실패·파라미터 누락·타입 불일치·`ConstraintViolation` 네 경로 모두에서 `FieldError.of(field, message)` 를 싣는다.
```javascript
const fieldErrors = error instanceof ApiError ? error.body?.errors : null;
if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
setErrors(Object.fromEntries(fieldErrors.map(({ field, message }) => [field, message])));
return;
}
setSubmitError('저장하지 못했습니다. 잠시 후 다시 시도해 주세요.');
```
- **받은 오류는 손 검증 오류와 같은 상태에 넣는다.** 그래야 같은 자리에 뜨고 커서 이동과 낭독기 연결이 그대로 적용된다.
- **화면에 없는 칸 이름이 오면 일반 메시지로 내린다.** 그냥 버리면 사용자에게 아무 말도 안 하게 된다.
## 제출 실패는 핸들러가 직접 잡는다
- **`error.js` 는 제출 실패를 안 잡는다.** React Error Boundary 는 **렌더 중** 예외만 잡고, 제출 핸들러는 이벤트 핸들러다.
- 안 잡히면 `finally` 로 버튼만 풀려서 **화면은 멀쩡한데 아무 말도 안 뜬다.** 사용자는 저장됐는지 모른 채 다시 누른다.
- **`run()` 에 넘기는 함수는 자기 실패를 자기가 잡는다.**
- **훅이 대신 잡게 하지 않는다.** 실패를 어떻게 보여줄지는 폼마다 달라서, 훅이 그것까지 들면 옵션이 붙고 결국 폼 라이브러리가 된다.
## 성공하면 `router.replace` 로 옮긴다
- **`window.location` 으로 앱 안을 이동하지 않는다.** 주소창에 직접 친 것과 같아서 JS·CSS·데이터를 전부 다시 받는다.
- **`push` 가 아니라 `replace` 다.** `push` 면 뒤로 가기로 방금 낸 폼이 돌아와 **중복 제출 방어를 우회한다.**
- **앱 밖으로 나갈 때는 `window.location` 이 맞다** — `tel:` 같은 것. 가르는 기준은 **「가려는 곳이 이 앱 안인가」**다.
## 하지 않는 것
- **검색·필터 폼에 중복 제출 방어를 넣지 않는다.**
- **`if (submitting) return;` 을 손으로 쓰지 않는다.**
- **`useSubmit` 을 생성기로 두 앱에 공유하지 않는다.**
- **`required` 를 지우지 않는다.**
- **오류 글씨를 칸과 안 잇고 그리지 않는다.**
- **서버가 준 `FieldError` 를 버리고 「실패했습니다」만 띄우지 않는다.**
- **제출 실패를 `error.js` 가 잡아줄 거라 믿고 안 잡지 않는다.**
- **성공 후 `router.push` 나 `window.location` 으로 앱 안을 이동하지 않는다.**
GitHub Copilot — .github/instructions/form-submission-standard.instructions.md
---
description: 폼을 어떻게 제출하고 실패를 어떻게 보여주는가
applyTo: "**"
---
# 폼 제출
- **규칙은 서버를 바꾸는 폼에만 건다.** 검색·필터 폼은 두 번 눌려도 조회가 두 번 될 뿐이라 대상이 아니다. 전부에 걸면 아무것도 안 지켜주면서 코드만 늘고, **그러면 「이건 검색이니까」로 빠지기 시작해 지켜지는지 아무도 모르게 된다.**
- **Server Actions 는 정적 export 에서 못 쓴다.** `useActionState` 로 푸는 길이 막혀 있어 상태를 직접 든다.
- **중복 제출은 `useSubmit` 훅으로 막는다.** `const { submitting, run } = useSubmit();` 를 쓰고 `<form onSubmit={run(() => createMenu(form))}>`, `<button disabled={submitting}>` 로 잠근다. **`if (submitting) return;` 여섯 줄을 손으로 쓰지 않는다** — 매번 기억해야 하는 규칙은 잊힌다. 훅을 안 쓰면 `submitting` 이 없어 버튼을 잠글 수 없으므로 **잊으면 티가 난다.**
- **훅은 앱마다 각자 둔다** — `<앱>/src/hooks/useSubmit.js`. 10줄 때문에 생성기가 JS 까지 관리하게 되므로 공유하지 않는다. 세 번째 앱이 나오면 다시 본다.
- **`<form>` 에 `noValidate` 를 붙이고 검증은 손으로 한다.** 서버도 검증하므로 안 끄면 브라우저 말풍선과 내 오류 글씨가 한 화면에 둘 다 생긴다. **`required` 는 지우지 않는다** — `noValidate` 가 끄는 것은 말풍선뿐이고 `required` 는 남아서 낭독기가 「필수 입력」을 읽는다.
- **검증에 실패하면 첫 번째 틀린 칸으로 커서를 옮긴다.** 화면이 저절로 거기로 가고 낭독기도 그 칸을 읽는다.
- **오류 글씨를 칸에 잇는다** — `aria-invalid={!!errors.name}` 와 `aria-describedby={errors.name ? 'name-error' : undefined}`, 오류 요소에 `id="name-error"`. `role="alert"` 는 뜨는 순간 한 번만 읽어주므로, 그때 놓치면 그 칸으로 돌아와도 다시 못 듣는다.
- **서버가 준 칸별 오류를 버리지 않는다.** 백엔드는 `@Valid` 실패·파라미터 누락·타입 불일치·`ConstraintViolation` 모두에서 `FieldError.of(field, message)` 를 싣는다. `error.body?.errors` 를 꺼내 **손 검증 오류와 같은 상태에 넣는다.** 화면에 없는 칸 이름이 오면 일반 메시지로 내린다 — 버리면 사용자에게 아무 말도 안 하게 된다.
- **제출 실패는 핸들러가 직접 잡는다.** `error.js`(React Error Boundary)는 **렌더 중** 예외만 잡고 제출은 이벤트 핸들러라 안 걸린다. 안 잡히면 버튼만 풀려서 **화면은 멀쩡한데 아무 말도 안 뜬다.** **훅이 대신 잡게 하지 않는다** — 실패를 어떻게 보여줄지는 폼마다 달라서 옵션이 붙고 결국 폼 라이브러리가 된다.
- **성공하면 `router.replace` 로 옮긴다.** `window.location` 으로 앱 안을 이동하면 JS·CSS·데이터를 전부 다시 받는다. **`push` 가 아니라 `replace` 인 이유는** `push` 면 뒤로 가기로 방금 낸 폼이 돌아와 중복 제출 방어를 우회하기 때문이다. **앱 밖으로 나갈 때(`tel:` 같은 것)는 `window.location` 이 맞다** — 가르는 기준은 「가려는 곳이 이 앱 안인가」다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기