폼 검증 — Form, TextFormField, 커스텀 Validator

사용자 입력을 받을 때 검증은 필수입니다. "이메일 형식이 아닙니다", "비밀번호는 8자 이상이어야 합니다" 같은 검증을 Flutter에서 깔끔하게 구현하는 방법을 정리해보겠습니다.


Form 기본 구조

DART
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 클래스

재사용 가능한 검증 함수를 모아두면 편합니다.

DART
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

모드동작
disabledvalidate() 호출 시에만 검증
always항상 검증
onUserInteraction사용자 입력 시 검증 (권장)

비동기 검증

서버에 중복 확인 등 비동기 검증이 필요한 경우입니다.

DART
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

DART
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로 커스텀 입력

DART
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로 체크박스, 라디오 등 커스텀 입력도 폼에 포함시킬 수 있습니다
댓글 로딩 중...