폼
Senco에서 폼을 만들고 구성하는 패턴이다. 송금, 온보딩, 수취인 추가 등 Senco의 플로우는 대부분 폼으로 이루어진다. 그래서 입력, 힌트, 검증의 구조가 얼마나 일관된지가 완료율에 바로 영향을 준다.
이 제품의 폼은 단순한 편의 기능인 경우가 드물다. 계정을 쓰려면 통과해야 하는 KYC 확인이기도 하고, 결제가 처리될지를 정하는 주소이기도 하고, 틀리면 안 되는 금액이기도 하다. 아래 구조는 중간에 그만둔 폼을 다시 이어 갈 수 있게, 잘못 입력한 필드를 바로잡을 수 있게 하려고 만들었다.
다루는 것
- 입력 묶기
- Fieldset
- 힌트
- 검증
- 제출 피드백
입력 묶기
- 관련된 입력은 설명이 담긴 legend와 함께 fieldset으로 묶는다
- 입력은 자연스러운 순서로 배치한다 (이름 → 이메일 → 메시지)
- 복잡한 플로우는 한 페이지에 질문 하나씩, 짧은 폼은 한 페이지에 모아 둔다
한 페이지로 할까, 여러 단계로 나눌까
| Form | Shape | Why |
|---|---|---|
| 로그인 | 한 페이지 | 필드가 두 개뿐이라 단계를 나눌 이유가 없다 |
| 돈 보내기 | 단계 | 각 단계의 결정이 다음 단계에 영향을 준다 |
| 연락처 추가 | 단계 | 어떤 수단을 고르느냐에 따라 세부 입력이 달라진다 |
| KYC | 단계 | 단계마다 따로 실패하고 따로 다시 시도할 수 있다 |
| 설정 | 그룹마다 한 페이지 | 변경이 바로 적용되므로 제출이 없다 |
기준은 길이가 아니라 의존 관계다. 앞의 답에 따라 달라지는 필드는 뒤 단계에 두고, 서로 독립적인 필드는 한 페이지에 함께 둔다. 독립적인 필드를 여러 단계로 쪼개면 더 명확해지지도 않으면서 탭 수만 늘어난다.
필드 순서는 원본 서류를 따른다
은행 카드, 신분증, 결제 전표처럼 사용자가 무언가를 보고 옮겨 적는 폼이라면, 필드 순서는 데이터베이스 스키마가 아니라 그 서류에 맞춘다. 카드 폼이 번호, 만료일, CVV 순서인 것도 카드에 그 순서로 인쇄돼 있기 때문이다.
Fieldset
- 라디오 그룹, 체크박스 그룹, 서로 관련된 입력 묶음에는 fieldset + legend를 쓴다
- legend에는 개별 옵션이 아니라 그룹 전체를 설명하는 텍스트를 쓴다
- fieldset 중첩은 허용하지만 꼭 필요할 때만 쓴다
라디오 그룹에 접근 가능한 이름을 줄 수 있는 건 <legend>뿐이다. legend가 없으면 스크린 리더는 라벨 없는 라디오 버튼 네 개만 읽고, 그 버튼들이 무엇을 묻는지는 끝내 말해 주지 않는다.
힌트
- 힌트 텍스트는 라벨과 입력 사이에 둔다
- 힌트에는 형식, 제약 조건, 예시를 쓴다
- 힌트는 한 문장이나 짧은 구절로 간결하게 쓴다
- aria-describedby로 힌트와 입력을 연결한다
힌트는 필드 아래가 아니라 위에 둔다
입력 아래에 있는 형식 힌트는 사용자가 이미 입력을 마친 뒤에야 눈에 들어온다. 계좌 번호 아래의 "10 digits, no spaces"는 실수한 다음에 읽힌다. 필드 위에 두면 실수하기 전에 읽힌다.
에러만 예외다. 에러는 지금 필드에 입력된 내용을 가리키므로 아래에 표시한다.
| Do | Don't |
|---|---|
| "10 digits, no spaces" above the account field | "Invalid format" below it, after submission |
| "As printed on your ID" | "Please enter a valid name" |
| "We only use this to send your receipt" | "Email address" as the hint for the Email field |
제출
- 주요 제출 버튼은 폼 맨 끝에 둔다
- 처리 중에는 제출 버튼을 비활성화한다 (로딩 표시와 함께)
- 성공하면 알림이나 페이지 이동으로 결과를 확인시킨다
- 실패하면 상단에 에러 요약, 각 필드에 인라인 에러를 보여 주고 포커스를 요약으로 옮긴다
검증을 이유로 제출 버튼을 비활성화하지 않는다
비활성화된 제출 버튼은 사용자에게 아무 단서도 주지 않는다. 메시지도 없고, 포커스를 옮길 곳도 없고, 무엇이 빠졌는지 설명도 없다. Senco의 제출 버튼은 항상 활성 상태로 두고, 잘못된 필드가 있는 상태에서 누르면 사용자가 읽고 찾아갈 수 있는 에러 요약을 보여 준다.
버튼은 제출이 실제로 진행 중일 때만 aria-disabled로 바꾼다. 이때도 로딩 표시만 남기지 말고 라벨을 그대로 둔 채 옆에 스피너를 붙인다.
오프라인과 중단된 제출
Senco 사용자는 폼을 작성하다 연결이 끊기는 일이 잦다. 그래서 다음 세 가지 규칙을 둔다.
- 폼 상태는 입력하는 즉시 기기에 저장한다. 연결이 끊기거나, 앱이 백그라운드로 가거나, 전화가 와도 폼 내용은 지워지지 않는다.
- 서버에 닿지 못한 제출은 바로 실패 처리하지 않고 대기열에 넣는다. UI에도 그렇다고 알린다: "Saved — we'll send this when you're back online."
- 중복 제출은 호출하는 쪽에서 막는다. 버튼을 누르면 요청을 보내기 전에 핸들러가 제출 컨트롤의
loading을 먼저 켜고, 컨트롤은loading이 꺼질 때까지 입력을 무시한다. 느린 연결에서 두 번 누른 사용자가 돈을 두 번 보내면 안 된다.
중간에 그만둔 폼 이어 가기
끝내지 못한 플로우로 돌아오면 입력한 데이터와 단계를 그대로 복원하고, 무슨 일이 있었는지 알려 준다: "You have an unfinished transfer to Maria Santos." 입력한 데이터를 말없이 버리면, 가뜩이나 연결이 불안한 사용자를 가장 빨리 잃는다.
Form card
플로우 안에서 폼 입력을 묶는 스타일 컨테이너다. CSS로 지정할 수 있게 data-slot="form-card"를 쓰고, 카드 모양을 일정하게 유지하려고 전용 토큰을 쓴다.
토큰 체계
| Token | Value | Purpose |
|---|---|---|
--ds-formcard-bg | var(--ds-white) | 카드 배경 |
--ds-formcard-border | 1px solid var(--ds-border) | 카드 테두리 |
--ds-formcard-radius | var(--ds-radius-xl) | 모서리 radius |
--ds-formcard-shadow | var(--ds-shadow-xs) | 높이감을 주는 그림자 |
--ds-formcard-padding | var(--ds-space-4) | 내부 패딩 |
CSS 지정
DS 레이아웃과 프로토타입 범위 모두에서 속성 선택자로 적용한다.
:is(.ds-layout, .senco-prototype-scope) [data-slot="form-card"] {
background: var(--ds-formcard-bg);
border: var(--ds-formcard-border);
border-radius: var(--ds-formcard-radius);
box-shadow: var(--ds-formcard-shadow);
padding: var(--ds-formcard-padding);
}
쓰는 경우
- 단계마다 입력을 시각적으로 묶어야 하는 여러 단계 플로우 (Add Contact, KYC, Send)
- 섹션이 뚜렷하게 나뉘는 설정 페이지
- 카드로 감싸면 더 보기 좋은 폼 섹션
쓰지 않는 경우
- 로그인 폼. 폼이 화면의 유일한 콘텐츠라서, 상자가 이중으로 보이지 않게 평평하게 둔다
- 입력이 하나뿐이라 카드가 괜히 무거워 보이는 페이지
- 다른 카드 안 (카드는 중첩하지 않는다)
카드 안 간격
| Relationship | Token |
|---|---|
| 카드 패딩 | --ds-formcard-padding (16px) |
| 필드와 필드 사이 | --ds-space-4 (16px) |
| 라벨과 도움말 사이 | --ds-space-2 (8px) |
| 도움말과 입력 사이 | --ds-space-2 (8px) |
| 입력과 에러 사이 | --ds-space-2 (8px) |
| 섹션 제목과 첫 필드 사이 | --ds-space-5 (24px) |
모두 Spacing에 정의된 값을 그대로 쓴다. 폼마다 간격을 제각각 정하면, 여러 사람이 따로 만든 제품처럼 보이는 게 가장 먼저 티가 난다.
Contact address
Add Contact와 청구지 주소 플로우에서 쓰는 구조화된 주소 폼 패턴이다. 청구지 주소를 따로 받아야 하는 결제 수단을 위해 청구지 주소 토글이 들어 있다.
주소 필드
| Field | Validation | Min length |
|---|---|---|
| Street | 영문자·숫자와 -#.,/ 허용 | 5자 |
| City | 문자와 공백 | 2자 |
| State/Province | 문자와 공백 | 2자 |
| Postal code | 영문자·숫자와 공백/하이픈 | 3자 |
ContactAddress 타입
interface ContactAddress {
street: string;
city: string;
state: string;
postal: string;
}
청구지 주소 토글
useBillingAddressToggle hook은 결제 수단마다 선택적으로 두는 별도 청구지 주소를 관리한다.
- 기본값: "Same as general address" (체크박스 체크됨)
- 체크를 해제하면 별도 청구지 주소 폼이 나타난다
- 검증은 실제로 사용할 주소(일반 주소 또는 청구지 주소)를 기준으로 한다
- 실제로 제출할 주소인
effectiveAddress를 반환한다
사용
Add Contact의 3단계(Method Details)에서 쓴다. 결제 수단마다 연락처의 일반 주소와 다른 청구지 주소를 따로 둘 수 있다.
해외 주소
필드 네 개짜리 구조는 단순화한 것이고, 이 패턴의 약점으로 알려져 있다. Senco가 서비스하는 시장 중 상당수에는 "State/Province"가 없고, 우편번호가 선택이거나 아예 없는 나라도 여럿이며, 주소를 쓰는 순서도 나라마다 다르다.
현재 방침은 이렇다. 라벨은 범용적인 표현을 쓰고, State/Province를 쓰지 않는 나라에서는 필수로 받지 않는다. 검증은 형식이 아니라 길이만 보기 때문에, 미국식 주소처럼 생기지 않았다는 이유로 유효한 현지 주소가 거부되는 일은 없다. 나라별 주소 스키마는 아직 남은 작업이고, 아래 알려진 공백에 적어 두었다.
접근성
- 체크박스 토글에 명시적인 라벨을 붙인다: "Billing address same as general"
- 주소 필드 묶음은
<legend>가 있는<fieldset>으로 감싼다 - 각 필드에는 보이는 라벨이 있고, 검증 에러는
aria-describedby로 연결한다 - 모든 필드에
autocomplete토큰(address-line1,address-level2,address-level1,postal-code)을 지정해서 자동 완성 한 번으로 주소 전체가 채워지게 한다 - 청구지 폼이 나타나면 포커스를 첫 필드로 옮기고 변화를 알린다
접근성
- 모든 폼 컨트롤에 보이는 라벨을 둔다
- 필수 필드는 라벨에 표시한다
- 에러 메시지에는 필드 이름을 넣는다
- 탭 순서는 화면에 보이는 순서를 따른다
- 키보드만으로 폼을 제출할 수 있어야 한다
모든 폼에 적용하는 규칙
- 라벨은 항상 보이게 필드 위에 둔다. placeholder는 라벨을 대신하지 못한다.
- 검증은 blur와 제출 시점에 하고, 키를 누를 때마다 하지 않는다.
- 제출에 실패하면 포커스를 에러 요약으로 옮긴다.
- 어떤 에러가 나도 사용자가 입력한 내용은 지우지 않는다.
- 자동 완성을 켜 두고
autocomplete토큰을 정확하게 지정한다. - 중간에 끊길 수 있는 폼은 상태를 저장한다.
권장과 금지
| Do | Don't |
|---|---|
| Keep submit enabled and explain what is missing | Disable submit and leave the user guessing |
| Persist form state as the user types | Lose four minutes of typing to a dropped connection |
| Put format hints above the field | Put format hints below, after the mistake |
| Split steps by dependency | Split steps to make the form look shorter |
Set autocomplete on address fields | Disable autofill and make users type their address again |
| Mark the few optional fields | Asterisk every required field with no legend |
알려진 공백
- 주소 패턴은 네 부분으로 된 서구식 주소를 전제로 한다. 나라별 스키마는 없다.
- 기기 저장과 오프라인 대기열은 이 문서에 정의돼 있지만 공용 hook으로 구현되지는 않았다.
- 받는 은행에 계좌 번호를 확인하는 것처럼 비동기로 검증하는 패턴은 문서화되지 않았다.
- 여러 단계의 진행 상태 저장은 Transfer flow에 설명돼 있지만, 단계형 폼 전체로 일반화되지는 않았다.