폼 검증 — Form, TextFormField, 커스텀 Validator
폼 검증 — Form, TextFormField, 커스텀 Validator
사용자 입력을 받을 때 검증은 필수입니다. "이메일 형식이 아닙니다", "비밀번호는 8자 이상이어야 합니다" 같은 검증을 Flutter에서 깔끔하게 구현하는 방법을 정리해보겠습니다.
Form 기본 구조
class SignUpForm extends StatefulWidget {
const SignUpForm({super.key});
@override
State<SignUpForm> createState() => _SignUpFormState();
}
class _SignUpFormState extends State<SignUpForm> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState!.validate()) {
// 모든 검증 통과
_formKey.currentState!.save();
print('회원가입 진행');
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
// 실시간 검증 모드
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 이름
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: '이름',
hintText: '이름을 입력하세요',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '이름을 입력하세요';
}
if (value.trim().length < 2) {
return '이름은 2자 이상이어야 합니다';
}
return null;
},
),
const SizedBox(height: 16),
// 이메일
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: '이메일',
hintText: 'email@example.com',
),
validator: Validators.email,
),
const SizedBox(height: 16),
// 비밀번호
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: '비밀번호',
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
},
),
),
validator: Validators.password,
),
const SizedBox(height: 16),
// 비밀번호 확인
TextFormField(
controller: _confirmPasswordController,
obscureText: true,
decoration: const InputDecoration(
labelText: '비밀번호 확인',
),
validator: (value) {
if (value != _passwordController.text) {
return '비밀번호가 일치하지 않습니다';
}
return null;
},
),
const SizedBox(height: 24),
// 제출 버튼
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submit,
child: const Text('회원가입'),
),
),
],
),
),
);
}
}
커스텀 Validator 클래스
재사용 가능한 검증 함수를 모아두면 편합니다.
class Validators {
// 필수 입력
static String? required(String? value) {
if (value == null || value.trim().isEmpty) {
return '필수 입력 항목입니다';
}
return null;
}
// 이메일
static String? email(String? value) {
if (value == null || value.isEmpty) {
return '이메일을 입력하세요';
}
final emailRegex = RegExp(
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
);
if (!emailRegex.hasMatch(value)) {
return '유효한 이메일 주소를 입력하세요';
}
return null;
}
// 비밀번호
static String? password(String? value) {
if (value == null || value.isEmpty) {
return '비밀번호를 입력하세요';
}
if (value.length < 8) {
return '비밀번호는 8자 이상이어야 합니다';
}
if (!value.contains(RegExp(r'[A-Z]'))) {
return '대문자를 1개 이상 포함하세요';
}
if (!value.contains(RegExp(r'[0-9]'))) {
return '숫자를 1개 이상 포함하세요';
}
if (!value.contains(RegExp(r'[!@#$%^&*]'))) {
return '특수문자를 1개 이상 포함하세요';
}
return null;
}
// 전화번호
static String? phone(String? value) {
if (value == null || value.isEmpty) {
return '전화번호를 입력하세요';
}
final phoneRegex = RegExp(r'^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$');
if (!phoneRegex.hasMatch(value.replaceAll('-', ''))) {
return '유효한 전화번호를 입력하세요';
}
return null;
}
// 최소 길이
static String? Function(String?) minLength(int min) {
return (String? value) {
if (value != null && value.length < min) {
return '$min자 이상 입력하세요';
}
return null;
};
}
// 최대 길이
static String? Function(String?) maxLength(int max) {
return (String? value) {
if (value != null && value.length > max) {
return '$max자 이하로 입력하세요';
}
return null;
};
}
// 여러 검증 조합
static String? Function(String?) compose(
List<String? Function(String?)> validators,
) {
return (String? value) {
for (final validator in validators) {
final error = validator(value);
if (error != null) return error;
}
return null;
};
}
}
// 조합해서 사용
TextFormField(
validator: Validators.compose([
Validators.required,
Validators.minLength(2),
Validators.maxLength(20),
]),
)
AutovalidateMode
| 모드 | 동작 |
|---|---|
disabled | validate() 호출 시에만 검증 |
always | 항상 검증 |
onUserInteraction | 사용자 입력 시 검증 (권장) |
비동기 검증
서버에 중복 확인 등 비동기 검증이 필요한 경우입니다.
class _SignUpFormState extends State<SignUpForm> {
String? _emailError;
bool _isCheckingEmail = false;
Future<void> _checkEmailDuplicate(String email) async {
setState(() => _isCheckingEmail = true);
try {
final isDuplicate = await apiService.checkEmail(email);
setState(() {
_emailError = isDuplicate ? '이미 사용 중인 이메일입니다' : null;
});
} catch (e) {
setState(() => _emailError = '확인 중 에러 발생');
} finally {
setState(() => _isCheckingEmail = false);
}
}
@override
Widget build(BuildContext context) {
return TextFormField(
decoration: InputDecoration(
labelText: '이메일',
errorText: _emailError,
suffixIcon: _isCheckingEmail
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
),
onChanged: (value) {
if (Validators.email(value) == null) {
_checkEmailDuplicate(value);
}
},
validator: Validators.email,
);
}
}
DropdownButtonFormField
DropdownButtonFormField<String>(
value: _selectedGender,
decoration: const InputDecoration(labelText: '성별'),
items: const [
DropdownMenuItem(value: 'male', child: Text('남성')),
DropdownMenuItem(value: 'female', child: Text('여성')),
DropdownMenuItem(value: 'other', child: Text('기타')),
],
onChanged: (value) {
setState(() => _selectedGender = value);
},
validator: (value) {
if (value == null) return '성별을 선택하세요';
return null;
},
)
FormField로 커스텀 입력
FormField<bool>(
initialValue: false,
validator: (value) {
if (value != true) return '이용약관에 동의해주세요';
return null;
},
builder: (FormFieldState<bool> state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CheckboxListTile(
title: const Text('이용약관에 동의합니다'),
value: state.value,
onChanged: (value) => state.didChange(value),
),
if (state.hasError)
Padding(
padding: const EdgeInsets.only(left: 16),
child: Text(
state.errorText!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
);
},
)
정리
Form+GlobalKey<FormState>+TextFormField로 폼 검증을 체계적으로 관리합니다validator가 null을 반환하면 통과, String을 반환하면 에러 메시지입니다- 재사용 가능한 Validator 클래스를 만들어 일관성을 유지하세요
AutovalidateMode.onUserInteraction이 가장 좋은 사용자 경험을 제공합니다- 비동기 검증은
onChanged에서, 동기 검증은validator에서 처리합니다 FormField로 체크박스, 라디오 등 커스텀 입력도 폼에 포함시킬 수 있습니다
댓글 로딩 중...