Flutter 튜토리얼 15편: 폼과 입력 검증

요약#

핵심 요지#

  • 문제 정의: 사용자 입력을 받고 유효성을 검사하는 폼이 필요하다.
  • 핵심 주장: Flutter의 Form1TextFormField2로 입력 검증 로직을 구현한다.
  • 주요 근거: GlobalKey<FormState>3로 폼 상태에 접근하고, validator 콜백으로 검증한다.
  • 실무 기준: TextEditingController4로 입력값을 프로그래밍 방식으로 관리한다.
  • 한계: 복잡한 유효성 검사는 별도 패키지나 비동기 검증이 필요할 수 있다.

문서가 설명하는 범위#

  • Form 위젯과 GlobalKey로 폼 상태 관리
  • TextFormField로 입력 검증 구현
  • TextEditingController로 입력값 제어
  • 포커스 관리와 키보드 제어

읽는 시간: 15분 | 난이도: 중급


참고 자료#


문제 상황#

로그인, 회원가입, 검색 등 사용자 입력을 받는 화면이 필요합니다.
입력값을 검증하고, 오류 메시지를 표시하고, 제출 버튼을 처리해야 합니다.

폼 요구사항#

이메일 입력: 형식 검증
비밀번호 입력: 최소 길이 검증
전화번호 입력: 숫자 형식 검증
제출 버튼: 모든 검증 통과 시 활성화

문제는 다음과 같습니다.

  • 여러 입력 필드의 상태를 관리해야 한다.
  • 각 필드에 맞는 유효성 검사가 필요하다.
  • 검증 실패 시 사용자에게 피드백을 제공해야 한다.
  • 입력 필드 간 포커스 이동을 제어해야 한다.

해결 방법#

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});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
// 폼 상태에 접근하기 위한 GlobalKey
final _formKey = GlobalKey<FormState>();
@override
Widget 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: '이메일',
hintText: '[email protected]',
),
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

GlobalKeybuild() 메서드 안에서 생성하면 안 됩니다.
매번 새로운 키가 생성되어 폼 상태가 초기화됩니다.

// ❌ 잘못된 방법: build() 안에서 생성
@override
Widget build(BuildContext context) {
final formKey = GlobalKey<FormState>(); // 매번 새 키 생성!
return Form(key: formKey, ...);
}
// ✅ 올바른 방법: 상태 변수로 선언
class _MyFormState extends State<MyForm> {
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Form(key: _formKey, ...);
}
}

결론: FormGlobalKey<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();
@override
void dispose() {
_passwordController.dispose();
super.dispose();
}
@override
Widget 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});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
// 컨트롤러 생성
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
// 입력값 변경 리스너 등록
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
// 리소스 해제 (필수!)
_searchController.dispose();
super.dispose();
}
void _onSearchChanged() {
print('검색어: ${_searchController.text}');
}
void _clearSearch() {
_searchController.clear();
}
void _setDefaultSearch() {
_searchController.text = 'Flutter';
}
@override
Widget 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()에서 해제해야 합니다.
해제하지 않으면 메모리 누수가 발생합니다.

@override
void 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});
@override
State<MultiFieldForm> createState() => _MultiFieldFormState();
}
class _MultiFieldFormState extends State<MultiFieldForm> {
final _formKey = GlobalKey<FormState>();
final _emailFocus = FocusNode();
final _passwordFocus = FocusNode();
final _confirmFocus = FocusNode();
@override
void dispose() {
_emailFocus.dispose();
_passwordFocus.dispose();
_confirmFocus.dispose();
super.dispose();
}
@override
Widget 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

FocusNodedispose()에서 해제해야 합니다.
TextEditingController와 마찬가지로 메모리 누수를 방지합니다.

@override
void dispose() {
_emailFocus.dispose();
_passwordFocus.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}

결론: autofocus로 초기 포커스를, FocusNode로 동적 포커스를 제어합니다.


챕터 5: 폼 데이터 저장과 리셋#

Why#

NOTE

검증 통과 후 폼 데이터를 서버로 전송하거나 로컬에 저장해야 합니다.
또한 폼을 초기 상태로 리셋하는 기능도 필요합니다.

검증 통과 → save() 호출 → onSaved 실행 → 데이터 저장

What#

NOTE

FormState.save()는 모든 FormFieldonSaved 콜백을 호출합니다.
FormState.reset()은 폼을 초기 상태로 되돌립니다.

How#

TIP

폼 데이터 수집과 제출

class ProfileForm extends StatefulWidget {
const ProfileForm({super.key});
@override
State<ProfileForm> createState() => _ProfileFormState();
}
class _ProfileFormState extends State<ProfileForm> {
final _formKey = GlobalKey<FormState>();
// 폼 데이터 저장용 변수
String _name = '';
String _email = '';
String _phone = '';
@override
Widget 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});
@override
State<RegistrationForm> createState() => _RegistrationFormState();
}
class _RegistrationFormState extends State<RegistrationForm> {
final _formKey = GlobalKey<FormState>();
String? _selectedCountry;
bool _agreeToTerms = false;
@override
Widget 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

FormFieldbuilder에서 state.didChange()를 호출해야 폼 상태가 업데이트됩니다.
호출하지 않으면 검증이 제대로 작동하지 않습니다.

FormField<bool>(
builder: (state) {
return Checkbox(
value: state.value,
onChanged: (value) {
state.didChange(value); // 필수!
},
);
},
)

결론: DropdownButtonFormFieldFormField로 다양한 입력 유형을 폼과 통합합니다.


한계#

폼과 입력 검증에는 몇 가지 한계가 있습니다.

  • 비동기 검증: 서버 통신이 필요한 검증(중복 이메일 확인 등)은 별도 처리가 필요합니다.
  • 복잡한 검증 규칙: 조건부 검증이나 필드 간 연관 검증은 코드가 복잡해질 수 있습니다.
  • 성능: 많은 필드가 있는 폼에서 autovalidateMode.always는 성능에 영향을 줄 수 있습니다.
  • 상태 관리: 큰 폼에서는 별도의 상태 관리 솔루션이 필요할 수 있습니다.

Footnotes#

  1. Form(폼): 여러 폼 필드를 그룹화하고 일괄 검증을 가능하게 하는 위젯이다.

  2. TextFormField(텍스트 폼 필드): TextField를 감싸서 Form과 통합되는 검증 기능을 제공하는 위젯이다.

  3. GlobalKey(글로벌 키): 위젯 트리 전체에서 고유한 식별자로, FormState에 접근할 때 사용한다.

  4. TextEditingController(텍스트 편집 컨트롤러): 텍스트 필드의 값을 프로그래밍 방식으로 읽고 쓸 수 있게 해주는 컨트롤러다.

  5. FocusNode(포커스 노드): 위젯의 포커스 상태를 프로그래밍 방식으로 제어할 수 있게 해주는 객체다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 15편: 폼과 입력 검증
https://moodturnpost.net/posts/flutter/flutter-forms-validation/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차