비즈니스 앱을 위한 Vue 3 폼 아키텍처: 재사용 가능한 패턴
Vue 3 폼 아키텍처(비즈니스 앱용): 재사용 가능한 필드 컴포넌트, 명확한 검증 규칙, 각 입력에 서버 오류를 보여주는 실용적 방법.

실제 비즈니스 앱에서 폼 코드가 망가지는 이유
비즈니스 앱의 폼은 거의 작게 유지되지 않습니다. 처음에는 "몇 개의 입력"으로 시작하지만, 수십 개 필드, 조건부 섹션, 권한, 백엔드 로직과 동기화해야 할 규칙으로 커집니다. 제품 변경이 몇 번 지나면 폼은 동작하지만 코드가 깨지기 쉬워 보입니다.
Vue 3 폼 아키텍처가 중요한 이유는 폼이 "빠른 수정"이 쌓이는 곳이기 때문입니다: 감시자(watcher) 하나, 특수 처리 하나, 복사된 컴포넌트 하나가 추가됩니다. 당장은 동작하지만 신뢰하기 어렵고 변경하기도 어려워집니다.
경고 신호는 익숙합니다: 여러 페이지에서 반복되는 입력 동작(레이블, 형식, 필수 표시, 힌트), 일관성 없는 오류 위치, 컴포넌트 곳곳에 흩어진 검증 규칙, 그리고 백엔드 오류가 사용자가 무엇을 고쳐야 하는지 알려주지 않는 일반적인 토스트로 축소되는 경우입니다.
그런 불일치들은 단순한 코드 스타일 문제가 아닙니다. UX 문제로 바뀝니다: 사용자는 폼을 다시 제출하고, 지원 티켓이 늘어나며, 팀은 숨은 엣지 케이스 때문에 폼을 건드리기를 피합니다.
좋은 설정은 폼을 "지루하게" 만듭니다. 예측 가능한 구조가 있으면 필드를 추가하고 규칙을 변경하며 서버 응답을 처리해도 모든 것을 다시 배선할 필요가 없습니다.
원하는 것은 재사용(한 필드가 어디서나 동일하게 동작), 명확성(규칙과 오류 처리가 검토하기 쉬움), 예측 가능한 동작(touched, dirty, reset, submit), 그리고 더 나은 피드백(서버 측 오류가 주의가 필요한 정확한 입력에 표시됨)을 제공하는 폼 시스템입니다. 아래 패턴은 재사용 가능한 필드 컴포넌트, 읽기 쉬운 검증, 서버 오류를 특정 입력에 매핑하는 방법에 초점을 맞춥니다.
폼 구조에 대한 간단한 정신 모델
오래 버티는 폼은 입력의 더미가 아니라 명확한 부분이 있는 작은 시스템입니다.
UI는 입력을 수집하고, 폼 상태는 그것을 저장하며, 검증은 잘못된 점을 설명하고, API 레이어는 데이터를 로드하고 저장합니다. 이 네 레이어는 한 방향으로 소통한다고 생각하세요.
네 개의 레이어(각 레이어가 담당하는 것)
- 필드 UI 컴포넌트: 입력, 레이블, 힌트, 오류 텍스트를 렌더링합니다. 값 변경을 방출합니다.
- 폼 상태: 값과 오류(그리고 touched와 dirty 플래그)를 보관합니다.
- 검증 규칙: 값들을 읽고 오류 메시지를 반환하는 순수 함수입니다.
- API 호출: 초기 데이터를 로드하고, 변경을 제출하며, 서버 응답을 필드 오류로 번역합니다.
이 분리는 변경을 국지화합니다. 새로운 요구 사항이 생기면 다른 레이어를 깨뜨리지 않고 하나의 레이어만 업데이트하면 됩니다.
필드에 속하는 것 vs 부모 폼에 속하는 것
재사용 가능한 필드 컴포넌트는 단순해야 합니다. API, 데이터 모델, 검증 규칙에 대해 알 필요가 없습니다. 값과 오류만 표시하면 됩니다.
부모 폼이 나머지를 조율합니다: 어떤 필드가 존재하는지, 값이 어디에 저장되는지, 언제 검증하는지, 어떻게 제출하는지 등을요.
간단한 규칙: 로직이 다른 필드에 의존하면(예: "State"는 "Country"가 US일 때만 필수) 그 로직을 필드 컴포넌트 안이 아니라 부모 폼이나 검증 레이어에 두세요.
새 필드를 추가하는 것이 정말로 쉬우면 보통 기본값/스키마, 필드를 배치하는 마크업, 해당 필드의 검증 규칙만 건드립니다. 한 입력을 추가할 때 관련 없는 컴포넌트 전반에 변경을 강요한다면 경계가 모호한 것입니다.
재사용 가능한 필드 컴포넌트: 무엇을 표준화할지
폼이 커지면 가장 빠른 개선은 각 입력을 일회성으로 만들기를 멈추는 것입니다. 필드 컴포넌트는 예측 가능하게 느껴져야 합니다. 그게 빠르게 사용하고 검토하기 쉬운 이유입니다.
실용적인 빌딩 블록 세트:
- BaseField: 레이블, 힌트, 오류 텍스트, 간격, 접근성 속성의 래퍼.
- 입력 컴포넌트: TextInput, SelectInput, DateInput, Checkbox 등. 각 컴포넌트는 제어 자체에 집중합니다.
- FormSection: 관련 필드를 제목, 짧은 도움말, 일관된 간격으로 그룹화합니다.
프롭은 작게 제한하고 어디서나 강제하세요. 40개의 폼에 걸쳐 프롭 이름을 바꾸는 일은 고통스럽습니다.
즉시 성과를 주는 것들:
modelValue와update:modelValue(v-model용)labelrequireddisablederror(단일 메시지 또는 원하면 배열)hint
슬롯은 일관성을 깨지 않으면서 유연성을 허용하는 곳입니다. BaseField 레이아웃은 안정적으로 유지하되, 오른쪽 동작(예: "코드 전송")이나 앞쪽 아이콘 같은 작은 변형은 슬롯으로 허용하세요. 같은 변형이 두 번 이상 나오면 컴포넌트를 포크하는 대신 슬롯으로 만드세요.
렌더 순서를 표준화하세요(레이블, 컨트롤, 힌트, 오류). 사용자는 더 빨리 스캔하고, 테스트가 단순해지며, 각 필드에 오류를 표시할 명확한 장소가 있으므로 서버 오류 매핑도 쉬워집니다.
폼 상태: values, touched, dirty, reset
비즈니스 앱의 대부분 폼 버그는 입력 자체가 아니라 산재한 상태에서 옵니다: 값은 한 곳에, 오류는 다른 곳에, 리셋 버튼은 반만 작동하는 경우 등. 깔끔한 Vue 3 폼 아키텍처는 하나의 일관된 상태 형식에서 시작합니다.
먼저 필드 키 이름 규칙을 정하고 지키세요. 가장 단순한 규칙은: 필드 키는 API 페이로드 키와 같다. 서버가 first_name을 기대하면 폼 키도 first_name으로 하세요. 이 작은 선택이 검증, 저장, 서버 오류 매핑을 훨씬 쉽게 만듭니다.
폼 상태를 한 곳(Composable, Pinia 스토어, 또는 부모 컴포넌트)에 두고 각 필드는 그 상태를 통해 읽고 쓰게 하세요. 대부분의 화면에는 플랫 구조가 잘 작동합니다. API가 진정으로 중첩되어 있을 때만 중첩 구조를 사용하세요.
const state = reactive({
values: { first_name: '', last_name: '', email: '' },
touched: { first_name: false, last_name: false, email: false },
dirty: { first_name: false, last_name: false, email: false },
errors: { first_name: '', last_name: '', email: '' },
defaults: { first_name: '', last_name: '', email: '' }
})
플래그에 대한 실용적 관점:
touched: 사용자가 이 필드와 상호작용했는가?dirty: 값이 기본값(또는 마지막 저장된 값)과 다른가?errors: 현재 사용자에게 어떤 메시지를 보여줘야 하는가?defaults: 무엇으로 리셋해야 하는가?
리셋 동작은 예측 가능해야 합니다. 기존 레코드를 로드할 때 values와 defaults를 동일한 소스에서 설정하세요. 그러면 reset()은 defaults를 values로 복사하고 touched, dirty, errors를 지울 수 있습니다.
예: 고객 프로필 폼이 서버에서 email을 로드합니다. 사용자가 편집하면 dirty.email이 true가 됩니다. 사용자가 Reset을 누르면 이메일은 로드된 값(빈 문자열이 아님)으로 돌아가고 화면은 다시 깔끔해집니다.
읽기 쉬운 검증 규칙
읽기 쉬운 검증은 라이브러리보다는 규칙을 어떻게 표현하느냐에 관한 것입니다. 필드를 한눈에 보고 몇 초 안에 규칙을 이해할 수 있다면 폼 코드는 유지 관리하기 쉬워집니다.
계속 지킬 수 있는 규칙 스타일을 선택하세요
대부분의 팀은 다음 접근 방식 중 하나에 정착합니다:
- 필드별 규칙: 규칙이 필드 사용 근처에 위치. 스캔하기 쉽고 중소형 폼에 적합.
- 스키마 기반 규칙: 규칙이 하나의 객체나 파일에 위치. 여러 화면이 동일 모델을 재사용할 때 유리.
- 하이브리드: 단순 규칙은 필드 근처, 복잡하거나 재사용되는 규칙은 중앙 스키마에.
어떤 것을 선택하든 규칙 이름과 메시지를 예측 가능하게 유지하세요. 몇 가지 공통 규칙(required, length, format, range)이 많은 일회성 헬퍼보다 낫습니다.
규칙을 평서문처럼 작성하세요
좋은 규칙은 문장처럼 읽힙니다: "이메일은 필수이고 이메일 형식이어야 합니다." 의도는 숨기지 마세요.
대부분 비즈니스 폼에서 필드당 한 번에 하나의 메시지(첫 번째 실패)를 반환하면 UI가 차분하고 사용자가 문제를 더 빨리 고칠 수 있습니다.
일반적이고 사용자 친화적인 규칙들:
- Required: 사용자가 정말로 입력해야 할 때만 적용.
- Length: 실제 숫자(예: 2~50자)를 사용.
- Format: 이메일, 전화, 우편번호 등. 현실 입력을 거부하는 과도한 정규식은 피하세요.
- Range: "미래 날짜는 안 됨" 또는 "수량은 1~999" 같은 규칙.
비동기 검사를 명확하게 만드세요
"사용자 이름이 이미 있음" 같은 비동기 검사는 조용히 실행되면 혼란을 줍니다.
블러에서 또는 짧은 대기 후에 검사 트리거, 명확한 "확인 중..." 상태 표시, 사용자가 계속 입력하면 오래된 요청은 취소하거나 무시하세요.
검증 실행 시점을 정하세요
타이밍은 규칙만큼 중요합니다. 사용자 친화적인 설정은:
- 변경 시(on change): 비밀번호 강도처럼 라이브 피드백이 유용한 필드만.
- 블러 시(on blur): 대부분 필드에 대해, 사용자가 계속 입력하는 동안 계속 오류가 뜨지 않도록.
- 제출 시(on submit): 전체 폼을 마지막 안전망으로 검증.
서버 오류를 올바른 입력에 매핑하기
클라이언트 측 검사만으로는 충분하지 않습니다. 비즈니스 앱에서는 중복, 권한 검사, 오래된 데이터(stale), 상태 변경 등 브라우저가 알 수 없는 이유로 서버가 저장을 거부합니다. 좋은 폼 UX는 그 응답을 정확한 입력 옆의 명확한 메시지로 바꾸는 데 달려 있습니다.
오류를 하나의 내부 형태로 정규화하세요
백엔드들은 오류 형식에 대해 합의하지 않는 경우가 많습니다. 어떤 것은 단일 객체, 어떤 것은 리스트, 어떤 것은 필드 이름으로 키된 중첩 맵을 반환합니다. 들어오는 모든 것을 폼이 렌더할 수 있는 하나의 내부 형태로 변환하세요.
// 폼 코드가 소비하는 형태
{
fieldErrors: { "email": ["Already taken"], "address.street": ["Required"] },
formErrors: ["You do not have permission to edit this customer"]
}
몇 가지 규칙을 일관되게 유지하세요:
- 필드 오류는 배열로 저장하세요(메시지가 하나뿐이라도 배열로).
- 서로 다른 경로 스타일을 하나의 스타일로 변환하세요(점 표기법이 잘 작동합니다:
address.street). - 비필드 오류는 별도로
formErrors로 유지하세요. - 로깅을 위해 원시 서버 페이로드는 보관하되 사용자에게 렌더링하지는 마세요.
서버 경로를 필드 키에 매핑하세요
어려운 부분은 서버의 "경로(path)" 개념을 폼의 필드 키와 맞추는 것입니다. 각 필드 컴포넌트의 키를 결정하세요(예: email, profile.phone, contacts.0.type)하고 그것을 고수하세요.
그런 다음 일반적인 케이스를 처리하는 작은 매퍼를 작성하세요:
address.street(점 표기법)address[0].street(배열용 대괄호)/address/street(JSON Pointer 스타일)
정규화 후에는 \u003cField name=\"address.street\" /\u003e 가 fieldErrors["address.street"]를 특별한 케이스 없이 읽을 수 있어야 합니다.
필요하면 별칭(alias)을 지원하세요. 백엔드가 customer_email을 반환하는데 UI는 email을 사용한다면 정규화 과정에서 { customer_email: "email" } 같은 매핑을 유지하세요.
필드 오류, 폼 레벨 오류, 포커싱
모든 오류가 하나의 입력에 속하지는 않습니다. 서버가 "요금제 한도 초과"나 "결제 필요"라고 하면 폼 상단에 폼 레벨 메시지를 표시하세요.
필드 특정 오류는 입력 옆에 표시하고 사용자를 첫 번째 문제로 안내하세요:
- 서버 오류를 설정한 후 렌더된 폼에 존재하는
fieldErrors의 첫 번째 키를 찾습니다. - 해당 필드로 스크롤하고 포커스합니다(필드마다 ref를 가지고
nextTick으로).\n- 사용자가 해당 필드를 다시 편집하면 그 필드의 서버 오류를 지웁니다.
단계별: 아키텍처를 함께 엮기
폼은 폼 상태, UI, 검증, API에 무엇이 속하는지 일찍 결정하고 몇 가지 작은 함수로 연결할 때 차분하게 유지됩니다.
대부분 비즈니스 앱에 잘 작동하는 순서:
- 하나의 폼 모델과 안정적인 필드 키로 시작하세요. 이 키들이 컴포넌트, 검증기, 서버 오류 간의 계약이 됩니다.
- 레이블, 도움말 텍스트, 필수 표시, 오류 표시를 위한 하나의 BaseField 래퍼를 만드세요. 입력 컴포넌트는 작고 일관되게 유지하세요.
- 필드별로 실행할 수 있고 제출 시 전체를 검증할 수 있는 검증 레이어를 추가하세요.
- API에 제출하세요. 실패하면 서버 오류를
{ [fieldKey]: message }형태로 번역해 정확한 입력이 정확한 메시지를 표시하게 하세요. - 성공 처리(리셋, 토스트, 네비게이트)는 분리해 컴포넌트와 검증 로직에 섞이지 않게 하세요.
상태의 간단한 시작점:
const values = reactive({ email: '', name: '', phone: '' })
const touched = reactive({ email: false, name: false, phone: false })
const errors = reactive({}) // { email: '...', name: '...' }
BaseField는 label, error, 그리고 필요하면 touched를 받고 하나의 장소에서 메시지를 렌더링합니다. 각 입력 컴포넌트는 바인딩과 업데이트 방출만 신경 씁니다.
검증은 동일한 키를 사용해 모델 근처에 규칙을 두세요:
const rules = {
email: v => (!v ? 'Email is required' : /@/.test(v) ? '' : 'Enter a valid email'),
name: v => (v.length < 2 ? 'Name is too short' : ''),
}
function validateAll() {
Object.keys(rules).forEach(k => {
const msg = rules[k](values[k])
if (msg) errors[k] = msg
else delete errors[k]
touched[k] = true
})
return Object.keys(errors).length === 0
}
서버가 오류를 응답하면 동일한 키를 사용해 매핑하세요. API가 { "field": "email", "message": "Already taken" }를 반환하면 errors.email = 'Already taken'으로 설정하고 해당 필드를 touched로 표시하세요. 오류가 전역적이라면(예: "permission denied") 폼 상단에 표시하세요.
예시 시나리오: 고객 프로필 편집
지원 상담원이 고객 프로필을 편집하는 내부 관리자 화면을 상상해보세요. 폼에는 name, email, phone, role(Customer, Manager, Admin) 네 필드가 있습니다. 작지만 흔한 문제들을 보여줍니다.
클라이언트 측 규칙은 분명해야 합니다:
- Name: 필수, 최소 길이.
- Email: 필수, 유효한 이메일 형식.
- Phone: 선택 항목이지만 입력했으면 허용 형식과 일치해야 함.
- Role: 필수이고 때때로 조건부(일부 권한이 있어야 Admin을 지정할 수 있음).
일관된 컴포넌트 계약이 도움이 됩니다: 각 필드는 현재 값, 현재 오류 텍스트(있다면), 그리고 touched나 disabled 같은 부울을 받습니다. 레이블, 필수 표시, 간격, 오류 스타일링은 화면마다 다시 발명하지 마세요.
이제 UX 흐름: 상담원이 이메일을 편집하고 탭아웃하면 형식이 잘못되면 이메일 아래에 인라인 메시지가 표시됩니다. 수정하고 저장을 누르면 서버가 응답합니다:
- email already exists: 이메일 아래에 표시하고 해당 필드에 포커스.
- phone invalid: 전화 옆에 표시.
- permission denied: 폼 상단에 폼 레벨 메시지로 표시.
email, phone, role 같은 필드 이름으로 오류를 키하면 매핑이 간단합니다. 필드 오류는 입력 옆에 표시되고 폼 레벨 오류는 전용 메시지 영역에 표시됩니다.
흔한 실수와 피하는 방법
로직을 한 곳에 두세요
검증 규칙을 각 화면에 복사하는 것은 빠르게 느껴지지만 정책이 바뀌면(비밀번호 규칙, 필수 세금 ID, 허용 도메인 등) 문제가 됩니다. 규칙을 중앙화(스키마, 규칙 파일, 공유 함수)하고 폼이 동일한 규칙 세트를 소비하게 하세요.
또한 낮은 수준의 입력이 너무 많은 것을 하게 하지 마세요. \u003cTextField\u003e가 API를 호출하고 실패 시 재시도하며 서버 오류 페이로드를 파싱하면 재사용 가능하지 않습니다. 필드 컴포넌트는 렌더링, 값 방출, 오류 표시만 담당하게 하고 API 호출과 매핑 로직은 폼 컨테이너나 composable에 두세요.
관심사가 섞여 있다는 증상:
- 동일한 검증 메시지가 여러 곳에 쓰여 있음.
- 필드 컴포넌트가 API 클라이언트를 임포트함.
- 한 엔드포인트 변경이 여러 무관한 폼을 깨뜨림.
- 한 입력을 확인하려면 앱의 절반을 마운트해야 테스트가 됨.
UX와 접근성(Accessibility)에서 걸리는 함정
"문제가 발생했습니다(Something went wrong)" 같은 단일 오류 배너는 충분하지 않습니다. 사람들은 어느 필드가 잘못되었고 다음에 무엇을 해야 하는지 알아야 합니다. 네트워크 다운, 권한 거부 같은 전역 실패는 배너로 처리하고, 서버 오류는 특정 입력으로 매핑해 사용자가 빠르게 수정할 수 있게 하세요.
로딩과 이중 제출 문제는 혼란스러운 상태를 만듭니다. 제출 중에는 제출 버튼을 비활성화하고 저장 중 변경하면 안 되는 필드를 비활성화하며 명확한 바쁜 상태를 보여주세요. 리셋과 취소가 폼을 깔끔하게 복원하는지 확인하세요.
커스텀 컴포넌트로 접근성 기본을 건너뛰기 쉽습니다. 몇 가지 선택으로 실질적인 고통을 예방할 수 있습니다:
- 모든 입력은 눈에 보이는 레이블이 있음(플레이스홀더만 사용하지 말 것).
- 오류는 적절한 aria 속성으로 필드에 연결됨.
- 제출 후 첫 번째 유효하지 않은 필드로 포커스 이동.
- 비활성화된 필드는 실제로 인터랙티브하지 않고 적절히 안내됨.
- 키보드 탐색이 끝까지 동작함.
빠른 체크리스트 및 다음 단계
새 폼을 배포하기 전에 빠른 체크리스트를 돌리세요. 나중에 지원 티켓으로 이어질 작은 간극을 잡아냅니다.
- 모든 필드에 페이로드 및 서버 응답과 일치하는 안정적인 키가 있는가(중첩 경로 포함:
billing.address.zip)? - 하나의 일관된 필드 컴포넌트 API(값 입력, 이벤트 출력, 오류와 힌트 입력)로 모든 필드를 렌더할 수 있는가?
- 제출 시 한 번 검증하고, 이중 제출을 차단하며, 사용자가 어디서 시작해야 할지 알 수 있도록 첫 번째 유효하지 않은 필드에 포커스하는가?
- 오류를 올바른 위치에 표시할 수 있는가: 필드별(입력 옆)과 폼 레벨(필요 시 일반 메시지)?
- 성공 후 값, touched, dirty를 올바르게 리셋해 다음 편집이 깔끔하게 시작되는가?
한 항목에 "아니요"라면 먼저 그것을 고치세요. 가장 흔한 폼 고통은 불일치입니다: 필드 이름이 API에서 벗어나거나 서버 오류가 UI가 배치할 수 없는 형식으로 돌아옵니다.
내부 도구를 빠르게 만들고 싶다면 AppMaster (appmaster.io)는 동일한 기본 원칙을 따릅니다: 필드 UI를 일관되게 유지하고, 규칙과 워크플로를 중앙화하며, 서버 응답이 사용자가 조치할 수 있는 위치에 나타나도록 하세요.
자주 묻는 질문
페이지 전체에서 같은 레이블, 힌트, 필수 표시, 간격, 오류 스타일이 반복되는 걸 보이면 표준화를 시작하세요. 한 번의 “작은” 변경을 위해 여러 파일을 수정해야 한다면, 공통 BaseField 래퍼와 몇 가지 일관된 입력 컴포넌트를 만들면 금방 시간을 절약할 수 있습니다.
필드 컴포넌트는 가볍게 유지하세요: 레이블, 컨트롤, 힌트, 오류를 렌더링하고 값 변경을 방출(emit)합니다. 교차 필드 로직, 조건부 규칙, 다른 값에 의존하는 모든 것은 부모 폼이나 검증 레이어에 두어 필드가 재사용 가능하도록 하세요.
기본적으로 API 페이로드와 일치하는 안정적인 키를 사용하세요. 예: first_name, billing.address.zip. 이렇게 하면 검증과 서버 오류 매핑이 간단해집니다—레이어 간에 이름을 계속 번역하지 않아도 됩니다.
간단한 기본 형태는 values, errors, touched, dirty, defaults 를 담는 하나의 상태 객체입니다. 모두가 동일한 구조를 읽고 쓴다면 리셋과 제출 동작이 예측 가능해지고 “절반만 리셋되는” 버그를 피할 수 있습니다.
values와 defaults를 동일한 로드된 데이터로 설정하세요. 그런 다음 reset()은 defaults를 values로 복사하고 touched, dirty, errors를 지워 UI가 깔끔하고 서버에 마지막으로 반환된 값과 일치하게 만듭니다.
폼이 커질수록 규칙을 읽기 쉽게 유지하세요. 동일한 필드 이름(key)을 사용한 간단한 함수들로 시작하고, 한 번에 하나의 명확한 메시지(첫 번째 실패)를 반환하면 UI가 차분하게 유지되고 사용자가 무엇을 고쳐야 할지 알기 쉬워집니다.
대부분의 필드는 blur에서 검증하고 제출 시 전체 검증을 실행하는 것이 좋습니다. 입력 중에 사용자가 오류로 괴롭지 않게 하려면 on-change 검증은 비밀번호 강도 같은 경우에만 사용하세요.
비동기 검사(예: “이메일이 이미 사용 중”)는 블러나 짧은 디바운스 뒤에 실행하고, 명확한 “확인 중(Checking...)” 상태를 보여주세요. 또한 오래된 요청은 취소하거나 무시해 느린 응답이 최신 입력을 덮어쓰지 않도록 하세요.
백엔드 포맷을 { fieldErrors: { key: [messages] }, formErrors: [messages] } 같은 하나의 내부 형태로 정규화하세요. 점 표기(dot notation) 같은 한 가지 경로 스타일을 사용하면 address.street 같은 필드가 항상 fieldErrors['address.street']를 읽을 수 있어 특수 케이스가 줄어듭니다.
폼 레벨 오류는 폼 상단에 표시하고, 필드 오류는 정확한 입력 옆에 표시하세요. 제출 실패 후에는 첫 번째 오류가 있는 필드에 포커스하고 사용자가 해당 필드를 편집하면 그 필드의 서버 오류는 지워지게 하세요.


