Flutter 튜토리얼 15편: 폼과 입력 검증
요약
핵심 요지
문서가 설명하는 범위
Form위젯과GlobalKey로 폼 상태 관리TextFormField로 입력 검증 구현TextEditingController로 입력값 제어- 포커스 관리와 키보드 제어
읽는 시간: 15분 | 난이도: 중급
참고 자료
- Build a form with validation - 폼 유효성 검사 구현
- Retrieve the value of a text field - 입력값 가져오기
- Handle changes to a text field - 텍스트 변경 감지
- Focus and text fields - 포커스 관리
문제 상황
로그인, 회원가입, 검색 등 사용자 입력을 받는 화면이 필요합니다.
입력값을 검증하고, 오류 메시지를 표시하고, 제출 버튼을 처리해야 합니다.
폼 요구사항
이메일 입력: 형식 검증비밀번호 입력: 최소 길이 검증전화번호 입력: 숫자 형식 검증제출 버튼: 모든 검증 통과 시 활성화문제는 다음과 같습니다.
- 여러 입력 필드의 상태를 관리해야 한다.
- 각 필드에 맞는 유효성 검사가 필요하다.
- 검증 실패 시 사용자에게 피드백을 제공해야 한다.
- 입력 필드 간 포커스 이동을 제어해야 한다.
해결 방법
Flutter는 Form 위젯으로 여러 입력 필드를 그룹화하고, validator 콜백으로 검증을 처리합니다.
챕터 1: 기본 폼 구조
Why
NOTE여러
TextField를 개별적으로 관리하면 복잡합니다.
Form위젯은 폼 필드들을 그룹화하고 일괄 검증을 가능하게 합니다.Form → GlobalKey<FormState> → validate() → 모든 필드 검증
What
NOTE
Form위젯은FormField자식들을 그룹화합니다.
GlobalKey<FormState>를 통해 폼의 상태에 접근하고 검증을 실행합니다.
How
TIP기본 폼 구조
import 'package:flutter/material.dart';class LoginForm extends StatefulWidget {const LoginForm({super.key});@overrideState<LoginForm> createState() => _LoginFormState();}class _LoginFormState extends State<LoginForm> {// 폼 상태에 접근하기 위한 GlobalKeyfinal _formKey = GlobalKey<FormState>();@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('로그인')),body: Padding(padding: const EdgeInsets.all(16.0),child: Form(key: _formKey,child: Column(crossAxisAlignment: CrossAxisAlignment.start,children: [TextFormField(decoration: const InputDecoration(labelText: '이메일',),validator: (value) {if (value == null || value.isEmpty) {return '이메일을 입력해주세요';}return null;},),const SizedBox(height: 16),TextFormField(decoration: const InputDecoration(labelText: '비밀번호',),obscureText: true,validator: (value) {if (value == null || value.isEmpty) {return '비밀번호를 입력해주세요';}return null;},),const SizedBox(height: 24),SizedBox(width: double.infinity,child: ElevatedButton(onPressed: () {// 폼 검증 실행if (_formKey.currentState!.validate()) {ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('로그인 처리 중...')),);}},child: const Text('로그인'),),),],),),),);}}핵심 구성요소
구성요소 역할 Form폼 필드들을 그룹화 GlobalKey<FormState>폼 상태에 접근 TextFormField검증 기능이 있는 텍스트 입력 validator검증 로직 (null 반환 시 통과) validate()모든 필드 검증 실행
Watch out
WARNING
GlobalKey를build()메서드 안에서 생성하면 안 됩니다.
매번 새로운 키가 생성되어 폼 상태가 초기화됩니다.// ❌ 잘못된 방법: build() 안에서 생성@overrideWidget build(BuildContext context) {final formKey = GlobalKey<FormState>(); // 매번 새 키 생성!return Form(key: formKey, ...);}// ✅ 올바른 방법: 상태 변수로 선언class _MyFormState extends State<MyForm> {final _formKey = GlobalKey<FormState>();@overrideWidget build(BuildContext context) {return Form(key: _formKey, ...);}}
결론: Form과 GlobalKey<FormState>로 폼 상태를 관리하고, validator로 검증합니다.
챕터 2: 다양한 유효성 검사
Why
NOTE입력 유형에 따라 다른 검증 규칙이 필요합니다.
이메일은 형식 검증, 비밀번호는 복잡성 검증, 전화번호는 숫자 검증이 필요합니다.이메일: 정규식으로 형식 검증비밀번호: 길이 + 문자 조합 검증전화번호: 숫자만 허용
What
NOTE
validator콜백은 오류 시 문자열을, 성공 시null을 반환합니다.
정규식이나 조건문으로 다양한 검증 규칙을 구현합니다.
How
TIP이메일 검증
TextFormField(decoration: const InputDecoration(labelText: '이메일'),keyboardType: TextInputType.emailAddress,validator: (value) {if (value == null || value.isEmpty) {return '이메일을 입력해주세요';}// 간단한 이메일 형식 검증final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');if (!emailRegex.hasMatch(value)) {return '올바른 이메일 형식이 아닙니다';}return null;},)비밀번호 검증
TextFormField(decoration: const InputDecoration(labelText: '비밀번호'),obscureText: true,validator: (value) {if (value == null || value.isEmpty) {return '비밀번호를 입력해주세요';}if (value.length < 8) {return '비밀번호는 8자 이상이어야 합니다';}// 숫자 포함 확인if (!value.contains(RegExp(r'[0-9]'))) {return '숫자를 포함해야 합니다';}// 특수문자 포함 확인if (!value.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'))) {return '특수문자를 포함해야 합니다';}return null;},)비밀번호 확인 검증
class _SignUpFormState extends State<SignUpForm> {final _formKey = GlobalKey<FormState>();final _passwordController = TextEditingController();@overridevoid dispose() {_passwordController.dispose();super.dispose();}@overrideWidget build(BuildContext context) {return Form(key: _formKey,child: Column(children: [TextFormField(controller: _passwordController,decoration: const InputDecoration(labelText: '비밀번호'),obscureText: true,),TextFormField(decoration: const InputDecoration(labelText: '비밀번호 확인'),obscureText: true,validator: (value) {if (value != _passwordController.text) {return '비밀번호가 일치하지 않습니다';}return null;},),],),);}}전화번호 검증
TextFormField(decoration: const InputDecoration(labelText: '전화번호',hintText: '010-1234-5678',),keyboardType: TextInputType.phone,inputFormatters: [FilteringTextInputFormatter.digitsOnly,],validator: (value) {if (value == null || value.isEmpty) {return '전화번호를 입력해주세요';}if (value.length != 11) {return '올바른 전화번호를 입력해주세요';}return null;},)
Watch out
WARNING
validator는 폼 제출 시에만 실행됩니다.
입력 중 실시간 검증이 필요하면autovalidateMode를 설정합니다.Form(key: _formKey,// 입력 중 자동 검증autovalidateMode: AutovalidateMode.onUserInteraction,child: Column(...),)
AutovalidateMode옵션:
disabled: 자동 검증 안 함 (기본값)always: 항상 검증onUserInteraction: 사용자 입력 후 검증
결론: 입력 유형에 맞는 검증 규칙을 validator에 구현하고, autovalidateMode로 검증 시점을 제어합니다.
챕터 3: TextEditingController로 입력값 제어
Why
NOTE입력값을 프로그래밍 방식으로 읽거나 설정해야 할 때가 있습니다.
검색어 초기화, 기본값 설정, 입력값 변환 등에TextEditingController가 필요합니다.입력값 읽기: controller.text입력값 설정: controller.text = '새 값'입력값 초기화: controller.clear()
What
NOTE
TextEditingController는 텍스트 필드의 현재 값에 접근하고 변경할 수 있게 합니다.
리스너를 등록하여 입력값 변경을 실시간으로 감지할 수도 있습니다.
How
TIP기본 사용법
class SearchPage extends StatefulWidget {const SearchPage({super.key});@overrideState<SearchPage> createState() => _SearchPageState();}class _SearchPageState extends State<SearchPage> {// 컨트롤러 생성final _searchController = TextEditingController();@overridevoid initState() {super.initState();// 입력값 변경 리스너 등록_searchController.addListener(_onSearchChanged);}@overridevoid dispose() {// 리소스 해제 (필수!)_searchController.dispose();super.dispose();}void _onSearchChanged() {print('검색어: ${_searchController.text}');}void _clearSearch() {_searchController.clear();}void _setDefaultSearch() {_searchController.text = 'Flutter';}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('검색')),body: Padding(padding: const EdgeInsets.all(16.0),child: Column(children: [TextField(controller: _searchController,decoration: InputDecoration(labelText: '검색어',suffixIcon: IconButton(icon: const Icon(Icons.clear),onPressed: _clearSearch,),),),const SizedBox(height: 16),ElevatedButton(onPressed: () {// 현재 입력값 가져오기final query = _searchController.text;print('검색 실행: $query');},child: const Text('검색'),),],),),);}}onChanged vs addListener
방식 사용 시점 onChanged간단한 콜백만 필요할 때 addListener여러 곳에서 변경 감지가 필요할 때 // onChanged 방식TextField(onChanged: (text) {print('입력: $text');},)// addListener 방식_controller.addListener(() {print('입력: ${_controller.text}');});
Watch out
WARNING
TextEditingController는 반드시dispose()에서 해제해야 합니다.
해제하지 않으면 메모리 누수가 발생합니다.@overridevoid dispose() {_emailController.dispose();_passwordController.dispose();super.dispose();}여러 컨트롤러가 있다면 모두 해제해야 합니다.
결론: TextEditingController로 입력값을 프로그래밍 방식으로 제어하고, dispose()에서 반드시 해제합니다.
챕터 4: 포커스 관리
Why
NOTE사용자 경험을 위해 포커스를 제어해야 합니다.
화면 진입 시 첫 번째 필드에 포커스, 검증 실패 시 해당 필드로 포커스 이동이 필요합니다.화면 진입 → 첫 번째 필드 자동 포커스검증 실패 → 오류 필드로 포커스 이동Enter 키 → 다음 필드로 포커스 이동
What
NOTE
autofocus속성은 화면 로드 시 자동 포커스를 설정합니다.
FocusNode5는 프로그래밍 방식으로 포커스를 제어합니다.
How
TIP자동 포커스
TextFormField(autofocus: true, // 화면 로드 시 자동 포커스decoration: const InputDecoration(labelText: '이메일'),)FocusNode로 포커스 제어
class MultiFieldForm extends StatefulWidget {const MultiFieldForm({super.key});@overrideState<MultiFieldForm> createState() => _MultiFieldFormState();}class _MultiFieldFormState extends State<MultiFieldForm> {final _formKey = GlobalKey<FormState>();final _emailFocus = FocusNode();final _passwordFocus = FocusNode();final _confirmFocus = FocusNode();@overridevoid dispose() {_emailFocus.dispose();_passwordFocus.dispose();_confirmFocus.dispose();super.dispose();}@overrideWidget build(BuildContext context) {return Form(key: _formKey,child: Column(children: [TextFormField(focusNode: _emailFocus,decoration: const InputDecoration(labelText: '이메일'),textInputAction: TextInputAction.next,onFieldSubmitted: (_) {// Enter 키 누르면 다음 필드로 이동FocusScope.of(context).requestFocus(_passwordFocus);},),TextFormField(focusNode: _passwordFocus,decoration: const InputDecoration(labelText: '비밀번호'),obscureText: true,textInputAction: TextInputAction.next,onFieldSubmitted: (_) {FocusScope.of(context).requestFocus(_confirmFocus);},),TextFormField(focusNode: _confirmFocus,decoration: const InputDecoration(labelText: '비밀번호 확인'),obscureText: true,textInputAction: TextInputAction.done,onFieldSubmitted: (_) {// 폼 제출_submitForm();},),ElevatedButton(onPressed: _submitForm,child: const Text('가입'),),],),);}void _submitForm() {if (_formKey.currentState!.validate()) {// 폼 제출 처리} else {// 검증 실패 시 첫 번째 필드로 포커스_emailFocus.requestFocus();}}}키보드 액션 버튼
TextInputAction 설명 next다음 필드로 이동 done완료 (키보드 숨김) search검색 실행 send전송 go이동
Watch out
WARNING
FocusNode도dispose()에서 해제해야 합니다.
TextEditingController와 마찬가지로 메모리 누수를 방지합니다.@overridevoid dispose() {_emailFocus.dispose();_passwordFocus.dispose();_emailController.dispose();_passwordController.dispose();super.dispose();}
결론: autofocus로 초기 포커스를, FocusNode로 동적 포커스를 제어합니다.
챕터 5: 폼 데이터 저장과 리셋
Why
NOTE검증 통과 후 폼 데이터를 서버로 전송하거나 로컬에 저장해야 합니다.
또한 폼을 초기 상태로 리셋하는 기능도 필요합니다.검증 통과 → save() 호출 → onSaved 실행 → 데이터 저장
What
NOTE
FormState.save()는 모든FormField의onSaved콜백을 호출합니다.
FormState.reset()은 폼을 초기 상태로 되돌립니다.
How
TIP폼 데이터 수집과 제출
class ProfileForm extends StatefulWidget {const ProfileForm({super.key});@overrideState<ProfileForm> createState() => _ProfileFormState();}class _ProfileFormState extends State<ProfileForm> {final _formKey = GlobalKey<FormState>();// 폼 데이터 저장용 변수String _name = '';String _email = '';String _phone = '';@overrideWidget build(BuildContext context) {return Form(key: _formKey,child: Column(children: [TextFormField(decoration: const InputDecoration(labelText: '이름'),validator: (value) {if (value == null || value.isEmpty) {return '이름을 입력해주세요';}return null;},onSaved: (value) {_name = value ?? '';},),TextFormField(decoration: const InputDecoration(labelText: '이메일'),keyboardType: TextInputType.emailAddress,validator: (value) {if (value == null || value.isEmpty) {return '이메일을 입력해주세요';}return null;},onSaved: (value) {_email = value ?? '';},),TextFormField(decoration: const InputDecoration(labelText: '전화번호'),keyboardType: TextInputType.phone,onSaved: (value) {_phone = value ?? '';},),const SizedBox(height: 24),Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly,children: [ElevatedButton(onPressed: _submitForm,child: const Text('저장'),),OutlinedButton(onPressed: _resetForm,child: const Text('초기화'),),],),],),);}void _submitForm() {if (_formKey.currentState!.validate()) {// 모든 onSaved 콜백 실행_formKey.currentState!.save();// 저장된 데이터 사용print('이름: $_name');print('이메일: $_email');print('전화번호: $_phone');// 서버로 전송_sendToServer();}}void _resetForm() {_formKey.currentState!.reset();setState(() {_name = '';_email = '';_phone = '';});}Future<void> _sendToServer() async {// API 호출ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('저장 완료!')),);}}FormState 메서드
메서드 설명 validate()모든 필드 검증, bool 반환 save()모든 필드의 onSaved 실행 reset()폼을 초기 상태로 리셋
Watch out
WARNING
save()는validate()후에 호출해야 합니다.
검증 없이save()만 호출하면 잘못된 데이터가 저장될 수 있습니다.// ❌ 잘못된 방법: 검증 없이 저장void _submitForm() {_formKey.currentState!.save(); // 유효하지 않은 데이터 저장 가능}// ✅ 올바른 방법: 검증 후 저장void _submitForm() {if (_formKey.currentState!.validate()) {_formKey.currentState!.save();// 데이터 처리}}
결론: validate() 후 save()로 데이터를 수집하고, reset()으로 폼을 초기화합니다.
챕터 6: 복합 폼 위젯
Why
NOTE텍스트 외에 체크박스, 라디오 버튼, 드롭다운 등 다양한 입력이 필요합니다.
이들도Form과 통합하여 일괄 검증할 수 있습니다.TextFormField: 텍스트 입력DropdownButtonFormField: 선택 입력FormField: 커스텀 입력
What
NOTE
DropdownButtonFormField는 드롭다운 선택을 폼과 통합합니다.
FormField를 확장하여 커스텀 폼 필드를 만들 수 있습니다.
How
TIP드롭다운이 포함된 폼
class RegistrationForm extends StatefulWidget {const RegistrationForm({super.key});@overrideState<RegistrationForm> createState() => _RegistrationFormState();}class _RegistrationFormState extends State<RegistrationForm> {final _formKey = GlobalKey<FormState>();String? _selectedCountry;bool _agreeToTerms = false;@overrideWidget build(BuildContext context) {return Form(key: _formKey,child: Column(crossAxisAlignment: CrossAxisAlignment.start,children: [TextFormField(decoration: const InputDecoration(labelText: '이름'),validator: (value) {if (value == null || value.isEmpty) {return '이름을 입력해주세요';}return null;},),const SizedBox(height: 16),// 드롭다운 폼 필드DropdownButtonFormField<String>(decoration: const InputDecoration(labelText: '국가'),value: _selectedCountry,items: const [DropdownMenuItem(value: 'KR', child: Text('대한민국')),DropdownMenuItem(value: 'US', child: Text('미국')),DropdownMenuItem(value: 'JP', child: Text('일본')),DropdownMenuItem(value: 'CN', child: Text('중국')),],validator: (value) {if (value == null) {return '국가를 선택해주세요';}return null;},onChanged: (value) {setState(() {_selectedCountry = value;});},),const SizedBox(height: 16),// 커스텀 체크박스 폼 필드FormField<bool>(initialValue: _agreeToTerms,validator: (value) {if (value != true) {return '약관에 동의해주세요';}return null;},builder: (state) {return Column(crossAxisAlignment: CrossAxisAlignment.start,children: [Row(children: [Checkbox(value: state.value,onChanged: (value) {state.didChange(value);setState(() {_agreeToTerms = value ?? false;});},),const Text('이용약관에 동의합니다'),],),if (state.hasError)Text(state.errorText!,style: TextStyle(color: Theme.of(context).colorScheme.error,fontSize: 12,),),],);},),const SizedBox(height: 24),ElevatedButton(onPressed: () {if (_formKey.currentState!.validate()) {ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('가입 완료!')),);}},child: const Text('가입하기'),),],),);}}FormField 속성
속성 설명 initialValue초기값 validator검증 함수 onSaved저장 시 콜백 builderUI 빌더
Watch out
WARNING
FormField의builder에서state.didChange()를 호출해야 폼 상태가 업데이트됩니다.
호출하지 않으면 검증이 제대로 작동하지 않습니다.FormField<bool>(builder: (state) {return Checkbox(value: state.value,onChanged: (value) {state.didChange(value); // 필수!},);},)
결론: DropdownButtonFormField와 FormField로 다양한 입력 유형을 폼과 통합합니다.
한계
폼과 입력 검증에는 몇 가지 한계가 있습니다.
- 비동기 검증: 서버 통신이 필요한 검증(중복 이메일 확인 등)은 별도 처리가 필요합니다.
- 복잡한 검증 규칙: 조건부 검증이나 필드 간 연관 검증은 코드가 복잡해질 수 있습니다.
- 성능: 많은 필드가 있는 폼에서
autovalidateMode.always는 성능에 영향을 줄 수 있습니다. - 상태 관리: 큰 폼에서는 별도의 상태 관리 솔루션이 필요할 수 있습니다.
Footnotes
-
Form(폼): 여러 폼 필드를 그룹화하고 일괄 검증을 가능하게 하는 위젯이다. ↩
-
TextFormField(텍스트 폼 필드): TextField를 감싸서 Form과 통합되는 검증 기능을 제공하는 위젯이다. ↩
-
GlobalKey(글로벌 키): 위젯 트리 전체에서 고유한 식별자로, FormState에 접근할 때 사용한다. ↩
-
TextEditingController(텍스트 편집 컨트롤러): 텍스트 필드의 값을 프로그래밍 방식으로 읽고 쓸 수 있게 해주는 컨트롤러다. ↩
-
FocusNode(포커스 노드): 위젯의 포커스 상태를 프로그래밍 방식으로 제어할 수 있게 해주는 객체다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!