"한 글자 쳤는데 키보드가 사라졌어요."
Flutter 개발자들이 가장 황당해하는 버그 1위입니다. 검색창에 "Flutter"를 치려고 "F"를 누르는 순간, 키보드가 스르륵 내려가고 입력창의 커서(Focus)가 사라집니다.
다시 입력창을 눌러서 "l"을 칩니다. 또 사라집니다. "u"... 사라짐. "t"... 사라짐. 마치 앱이 저와 "무궁화 꽃이 피었습니다" 놀이를 하는 것 같았습니다.
이 현상은 단순한 버그가 아닙니다. "너는 지금 플러터의 생명주기(Lifecycle)를 거스르고 있다"는 경고입니다.
원리 이해 - 범인은 build() 안에 있다
99%의 확률로, 여러분은 코드를 이렇게 짰을 겁니다.
class SearchPage extends StatelessWidget { // 1. Stateless
@override
Widget build(BuildContext context) {
// 2. 😱 build 안에 컨트롤러 생성
final controller = TextEditingController();
return TextField(
controller: controller,
onChanged: (text) {
// 3. 상태 변경 -> 리빌드 유발 (예: Provider나 GetX 등)
someState.update(text);
},
);
}
}
이 코드가 실행되는 순서를 봅시다.
- 사용자가 "A"를 입력합니다.
onChanged가 실행되고, 어떤 상태(State)가 바뀝니다.- 상태가 바뀌었으니 화면을 갱신(Rebuild)해야 합니다.
build()메서드가 다시 실행됩니다.final controller = TextEditingController();줄이 다시 실행됩니다.- 새로운 컨트롤러 객체가 만들어집니다.
TextField는 "어? 새 컨트롤러네?" 하고 갈아끼웁니다.- 이전 컨트롤러가 가지고 있던 포커스 상태는 날아갑니다.
- 결과: 키보드가 내려갑니다.
해결책 1 - Stateful Widget으로 승격하라
TextEditingController는 상태(State)입니다.
상태는 build() 할 때마다 초기화되면 안 됩니다. 위젯의 수명 동안 계속 살아있어야 합니다.
그래서 반드시 StatefulWidget을 써야 합니다.
class SearchPage extends StatefulWidget {
@override
_SearchPageState createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
// ✅ 1. 클래스 멤버 변수로 선언 (State 안에 저장)
late TextEditingController _controller;
@override
void initState() {
super.initState();
// ✅ 2. 초기화는 딱 한 번만!
_controller = TextEditingController();
}
@override
void dispose() {
// ✅ 3. 메모리 해제 (필수)
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller, // build가 여러 번 실행돼도 이 변수는 그대로임
...
);
}
}
이제 build()가 백 번, 천 번 실행되어도 _controller 객체는 initState()에서 만들어진 그 녀석 그대로 유지됩니다. 포커스도 유지됩니다.
해결책 2 - flutter_hooks 사용 (고수들의 방법)
"매번 Stateful 만들고 init/dispose 짜기 귀찮은데..."
React 개발자라면 익숙할 Hooks 패턴을 Flutter에서도 쓸 수 있습니다.
flutter_hooks 패키지를 쓰면 코드가 마법처럼 줄어듭니다.
class SearchPage extends HookWidget { // HookWidget 상속
@override
Widget build(BuildContext context) {
// ✅ useTextEditingController: 알아서 init/dispose 해줌
final controller = useTextEditingController();
return TextField(
controller: controller,
...
);
}
}
useTextEditingController는 내부적으로 상태를 저장해두고, 리빌드될 때도 기존 컨트롤러를 반환해줍니다. 우아하죠.
Key 문제 (GlobalKey의 오남용) 제대로 이해하기
드물지만, 컨트롤러를 잘 썼는데도 포커스가 풀리는 경우가 있습니다.
바로 위젯의 Key가 바뀔 때입니다.
Flutter는 트리 구조에서 위젯을 식별할 때 runtimeType과 Key를 봅니다.
만약 부모 위젯이 리빌드되면서 TextField의 Key가 바뀌면, Flutter는 "어? 다른 위젯이네?"라고 판단하고 기존 TextField를 버리고 새로 만듭니다. 당연히 포커스도 날아갑니다.
// ❌ 나쁜 예: build 할 때마다 새로운 Key 생성
TextField(
key: GlobalKey(), // 매번 새로운 키! -> 매번 새로운 위젯!
)
GlobalKey는 꼭 필요한 경우(외부에서 상태 접근 등)가 아니면 쓰지 마세요. 쓴다면 반드시 State 안에 변수로 final key = GlobalKey(); 처럼 선언해서 고정시켜야 합니다.
FocusNode의 생명주기 한 걸음 더
TextEditingController만 있는 게 아닙니다. FocusNode도 관리해야 합니다.
"다음 버튼 누르면 비밀번호 입력창으로 포커스 이동" 같은 기능을 만들 때 필수죠.
class _LoginPageState extends State<LoginPage> {
late FocusNode _emailFocus;
late FocusNode _pwFocus;
@override
void initState() {
super.initState();
_emailFocus = FocusNode();
_pwFocus = FocusNode();
}
@override
void dispose() {
// 💀 이거 까먹으면 메모리 누수!
_emailFocus.dispose();
_pwFocus.dispose();
super.dispose();
}
void _nextField() {
_emailFocus.unfocus();
FocusScope.of(context).requestFocus(_pwFocus);
}
}
FocusNode도 상태입니다. build 안에 만들면 입력할 때마다 포커스가 초기화되어(날아가서) 키보드가 내려갑니다.
8. Case Study: 키보드가 올라오면 버튼이 가려져요
로그인 화면 맨 밑에 "로그인" 버튼이 있습니다. 이메일을 입력하려고 키보드를 올리면, 버튼이 키보드 뒤로 숨어버립니다. (Bottom Overflow Error)
해결 1: SingleChildScrollView + Reverse
화면을 스크롤 가능하게 만들면 됩니다.
해결 2: resizeToAvoidBottomInset
Scaffold 속성입니다. 기본값은 true인데, 이걸 false로 하면 키보드가 올라와도 화면이 찌그러지지 않고 키보드 뒤에 깔립니다. (배경 이미지가 있을 때 유용)
해결 3 - 키보드 감지 (Keyboard Visibility)
"키보드가 올라오면 로고를 숨기자!"
// MediaQuery로 키보드 높이 확인
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
return Column(
children: [
if (!isKeyboardVisible) BigLogo(), // 키보드 없을 때만 보여줌
TextField(...),
],
);
공간 절약의 마법입니다.
9. Tip: 화면 아무 데나 터치하면 키보드 내리기
사용자는 "빈 공간 터치 = 완료"라고 생각합니다. 이걸 구현 안 해주면 짜증 냅니다.
GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: Scaffold(...),
)
SystemChannels.textInput.invokeMethod('TextInput.hide') 같은 저수준 API보다, FocusManager를 쓰는 게 훨씬 Flutter스럽고 안전합니다.
요약
"입력창 포커스가 풀린다" = "TextField가 다시 만들어졌다"
TextEditingController를 어디서 선언했는지 확인하라.build()안? -> ❌ 당신이 범인입니다.State클래스 멤버 변수? -> ✅ 정답.
StatefulWidget을 귀찮아하지 마라.- 상태가 있으면 Stateful이 맞다.
- 귀찮으면
flutter_hooks를 써라.
- Key를 함부로 바꾸지 마라.
- 랜덤한 Key는 위젯을 파괴한다.
이 원리만 알면, 다시는 키보드와 숨바꼭질할 일이 없습니다.
13. Troubleshooting: 다이얼로그/바텀시트에서 키보드 문제
showModalBottomSheet 안에 TextField를 넣으면 키보드가 올라올 때 시트가 가려지거나, 포커스가 이상하게 튀는 경우가 있습니다.
해결책: isScrollControlled: true & Padding
showModalBottomSheet(
context: context,
isScrollControlled: true, // 1. 전체 화면 높이 사용 허용
builder: (context) => Padding(
// 2. 키보드 높이만큼 패딩 주기
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom
),
child: MyTextFieldWidget(),
),
);
이렇게 안 하면 OS 키보드가 시트를 덮어버립니다.
14. Refactoring Challenge: StatefulWidget to flutter_hooks
문제 (Problem):
StatefulWidget으로 짠 코드가 50줄이 넘습니다. init, dispose 등 보일러플레이트가 너무 많습니다.
도전 (Challenge):
이걸 flutter_hooks를 써서 15줄로 줄여보세요.
Before:
class Search extends StatefulWidget { ... }
class _SearchState extends State<Search> {
late TextEditingController _c;
@override
void initState() { super.initState(); _c = TextEditingController(); }
@override
void dispose() { _c.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) { return TextField(controller: _c); }
}
After:
class Search extends HookWidget {
@override
Widget build(BuildContext context) {
final c = useTextEditingController();
return TextField(controller: c);
}
}
Hooks의 위력을 직접 체험해보세요. 코드가 아름다워집니다.
15. FAQ: 자주 묻는 질문
Q: autofocus: true를 썼는데 왜 포커스가 안 가나요?
A: 화면 전환 애니메이션이 끝나기 전에 포커스를 요청해서 그렇습니다. WidgetsBinding.instance.addPostFrameCallback 안에 요청하거나, 약간의 delay를 주세요.
Q: 포커스를 잃었을 때(Blur) 저장을 하고 싶어요.
A: FocusNode에 리스너를 다세요.
focusNode.addListener(() {
if (!focusNode.hasFocus) {
saveData();
}
});