Friday, August 7, 2020

Flutter TextEditingController, 텍스트 입력 UX를 한 단계 끌어올리는 비결

모바일 애플리케이션 개발에서 사용자로부터 텍스트를 입력받는 것은 가장 기본적이면서도 중요한 기능 중 하나입니다. Flutter에서는 TextField 위젯을 통해 이 기능을 손쉽게 구현할 수 있으며, 입력된 텍스트의 상태를 관리하고 제어하기 위해 TextEditingController를 사용합니다. 대부분의 Flutter 개발자는 controller.text를 통해 텍스트를 읽고 쓰는 기본적인 사용법에 익숙할 것입니다. 하지만 TextEditingController의 잠재력은 단순히 텍스트를 저장하는 것을 넘어섭니다. 사용자의 입력 경험(UX)을 극대화하고, 복잡한 입력 시나리오를 우아하게 처리하기 위해서는 그 내부 동작 원리와 고급 활용법을 깊이 이해해야 합니다.

이 글에서는 TextEditingController의 기본적인 사용법을 복습하는 것을 시작으로, 많은 개발자들이 실무에서 마주치는 '동적 텍스트 포맷팅 시 커서 위치 문제'를 심도 있게 파헤쳐 봅니다. 그리고 이 문제를 해결하는 과정에서 TextEditingController의 핵심 구성 요소인 TextEditingValueTextSelection의 개념을 배우고, 이를 통해 어떻게 사용자의 입력 흐름을 방해하지 않으면서 자연스러운 인터페이스를 제공할 수 있는지 구체적인 코드를 통해 살펴보겠습니다. 마지막으로는 이러한 로직을 재사용 가능한 모듈로 만드는 'TextInputFormatter'까지 다루어, 여러분의 Flutter 개발 역량을 한 단계 끌어올리는 데 실질적인 도움을 드리고자 합니다.

1. 모든 것의 시작: TextEditingController 기본기 다지기

고급 기술을 논하기 전에, 기본을 탄탄히 다지는 것이 중요합니다. TextEditingController는 Flutter의 상태 관리 철학을 잘 보여주는 예시입니다. 위젯 트리에서 상태(State)를 분리하여 UI 코드의 복잡도를 낮추고, 상태 변화에 유연하게 대응할 수 있도록 돕습니다.

1.1. 생성과 소멸: 생명주기 관리의 중요성

TextEditingController는 상태를 가지는 객체이므로, 위젯의 생명주기에 맞춰 관리해주어야 합니다. 일반적으로 StatefulWidgetState 객체 내에서 초기화하고, 위젯이 화면에서 사라질 때(dispose) 반드시 컨트롤러의 리소스를 해제해주어야 합니다. 이를 지키지 않으면 메모리 누수(Memory Leak)의 원인이 될 수 있습니다.


import 'package:flutter/material.dart';

class BasicTextFieldScreen extends StatefulWidget {
  const BasicTextFieldScreen({super.key});

  @override
  State<BasicTextFieldScreen> createState() => _BasicTextFieldScreenState();
}

class _BasicTextFieldScreenState extends State<BasicTextFieldScreen> {
  // 1. State 객체 내에서 TextEditingController 인스턴스 생성
  late final TextEditingController _controller;

  @override
  void initState() {
    super.initState();
    // 2. initState에서 컨트롤러 초기화. 초기 텍스트를 부여할 수 있음.
    _controller = TextEditingController(text: '초기 텍스트');
  }

  @override
  void dispose() {
    // 3. dispose 메서드에서 반드시 controller를 dispose하여 리소스 해제
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('TextEditingController 기본'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: TextField(
          // 4. TextField의 controller 속성에 연결
          controller: _controller,
          decoration: const InputDecoration(
            labelText: '텍스트를 입력하세요',
            border: OutlineInputBorder(),
          ),
        ),
      ),
    );
  }
}

위 코드에서 볼 수 있듯이, initState에서 컨트롤러를 생성하고, dispose에서 해제하는 것이 정석적인 패턴입니다. late final 키워드를 사용하면 initState에서 한번만 초기화됨을 명확히 할 수 있어 유용합니다.

1.2. 기본적인 텍스트 제어

컨트롤러가 TextField에 연결되면, 우리는 이 컨트롤러를 통해 텍스트를 읽고, 변경하고, 지울 수 있습니다.

  • 텍스트 읽기: _controller.text는 현재 TextField에 입력된 문자열을 반환합니다.
  • 텍스트 설정: _controller.text = '새로운 텍스트'; 와 같이 값을 할당하면 TextField의 내용이 해당 문자열로 즉시 변경됩니다.
  • 텍스트 지우기: _controller.clear(); 메서드를 호출하면 모든 텍스트가 지워집니다. 이는 _controller.text = ''; 와 동일한 효과를 가집니다.

// ... 위젯의 build 메서드 내부라고 가정 ...

Column(
  children: [
    TextField(
      controller: _controller,
      // ...
    ),
    const SizedBox(height: 20),
    Row(
      mainAxisAlignment: MainAxisAlignment.spaceAround,
      children: [
        ElevatedButton(
          onPressed: () {
            // 현재 텍스트를 스낵바로 보여주기
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('현재 텍스트: ${_controller.text}')),
            );
          },
          child: const Text('텍스트 읽기'),
        ),
        ElevatedButton(
          onPressed: () {
            // 텍스트를 프로그래밍 방식으로 변경하기
            _controller.text = 'Flutter는 대단해! ${DateTime.now()}';
          },
          child: const Text('텍스트 설정'),
        ),
        ElevatedButton(
          onPressed: () {
            // 텍스트 전체 삭제
            _controller.clear();
          },
          child: const Text('지우기'),
        ),
      ],
    ),
  ],
)

여기까지가 TextEditingController의 가장 기본적인 사용법입니다. 하지만 이것만으로는 실시간으로 사용자 입력에 반응하는 동적인 UI를 만들기 어렵습니다.

2. 실시간 반응의 핵심: 리스너(Listener) 활용하기

TextEditingController는 Flutter의 ChangeNotifier를 상속받은 클래스입니다. 이는 컨트롤러의 내부 값이 변경될 때마다 "변경 알림"을 보낼 수 있다는 의미입니다. 우리는 addListener 메서드를 통해 이 알림을 구독하고, 텍스트가 변경될 때마다 특정 로직을 실행할 수 있습니다.


// ... _BasicTextFieldScreenState 클래스 내부 ...

@override
void initState() {
  super.initState();
  _controller = TextEditingController();
  // 리스너 추가: 컨트롤러의 값이 변경될 때마다 호출될 함수를 등록
  _controller.addListener(_printLatestValue);
}

@override
void dispose() {
  // 리스너 제거: dispose 시점에 등록된 리스너를 제거해주어야 함
  _controller.removeListener(_printLatestValue);
  _controller.dispose();
  super.dispose();
}

void _printLatestValue() {
  // 텍스트가 변경될 때마다 콘솔에 현재 값을 출력
  print('두 번째 화면 텍스트 필드: ${_controller.text}');
}

addListener를 사용하면 사용자가 키보드로 한 글자 한 글자 입력하거나, 지우거나, 붙여넣기 하는 모든 변화를 감지할 수 있습니다. 또한, 위에서 본 것처럼 _controller.text = '...' 와 같이 코드로 텍스트를 변경할 때도 리스너는 동일하게 호출됩니다.

참고: `TextField`의 `onChanged` 콜백과의 차이점

TextField 위젯 자체에도 onChanged: (text) { ... } 라는 콜백이 있습니다. 이는 사용자가 직접 입력하여 텍스트가 변경될 때만 호출됩니다. 반면, controller.addListener는 사용자의 입력뿐만 아니라 코드에 의한 텍스트 변경(controller.text = ...)에도 반응한다는 차이가 있습니다. 따라서 어떤 종류의 변경에 반응해야 하는지에 따라 적절한 방법을 선택해야 합니다. 일반적으로 컨트롤러와 관련된 모든 변경에 반응하려면 addListener가 더 강력한 선택지입니다.

3. 실전 문제: 동적 텍스트 포맷팅의 함정

이제 본격적으로 실무에서 마주할 법한 문제 상황으로 들어가 보겠습니다. 금융 앱이나 쇼핑몰 앱에서 흔히 볼 수 있는 '가격 입력 필드'를 만든다고 가정해 봅시다. 사용자가 '1000000'을 입력하면, 가독성을 위해 실시간으로 '1,000,000'처럼 3자리마다 쉼표(,)를 찍어주는 기능을 구현하고 싶습니다.

앞서 배운 addListener를 사용하면 간단히 구현할 수 있을 것 같습니다. 순진한 첫 번째 접근법은 다음과 같을 것입니다.

3.1. 문제의 첫 번째 구현 (Naive Approach)


// ... State 클래스 내부 ...

final _priceController = TextEditingController();

@override
void initState() {
  super.initState();
  _priceController.addListener(_formatPrice);
}

void _formatPrice() {
  // 현재 텍스트 가져오기
  String text = _priceController.text;

  // 쉼표 제거 (숫자만 남기기)
  String plainNumber = text.replaceAll(',', '');
  if (plainNumber.isEmpty) {
    return;
  }

  // 숫자로 변환 후 포맷팅
  try {
    final number = int.parse(plainNumber);
    // NumberFormat을 사용하기 위해 intl 패키지 추가 필요 (pubspec.yaml)
    // dependencies:
    //   intl: ^0.18.0 
    final formatter = NumberFormat('#,###');
    final formattedText = formatter.format(number);

    // 포맷팅된 텍스트를 다시 컨트롤러에 설정
    // **주의: 이 부분이 문제를 일으킵니다!**
    if (_priceController.text != formattedText) {
       _priceController.text = formattedText;
    }
  } catch (e) {
    // 숫자로 파싱 실패 시 처리 (예: 사용자가 문자를 입력했을 때)
  }
}

// ... dispose에서 리스너 제거 및 컨트롤러 dispose 처리 ...

위 코드를 실행하고 텍스트 필드에 '1234567'을 순서대로 입력해보면, 화면에는 '1,234,567'이 잘 표시됩니다. 성공한 것처럼 보입니다. 하지만 결정적인 문제가 있습니다.

3.2. 드러나는 문제점: 멋대로 움직이는 커서

숫자를 계속 입력해보면 이상한 점을 발견하게 됩니다. '123'을 입력하고 '4'를 입력하는 순간, 텍스트는 '1,234'로 바뀌지만 커서(입력 위치를 나타내는 깜빡이는 막대)가 맨 앞으로 이동해버립니다. 그래서 다음 숫자를 입력하면 '51,234'와 같이 원치 않는 결과가 나옵니다. 사용자는 매번 커서를 다시 맨 뒤로 옮겨야 하는 극심한 불편을 겪게 됩니다.

왜 이런 현상이 발생할까요?

원인은 _priceController.text = formattedText; 라인에 있습니다. controller.text에 새로운 문자열을 할당하는 행위는, 단순히 텍스트만 교체하는 것이 아니라 사실상 컨트롤러의 전체 상태를 새로운 값으로 초기화하는 것과 같습니다. 이때, 커서의 위치(selection) 정보는 별도로 지정되지 않았기 때문에 기본값인 '맨 앞(offset: 0)'으로 리셋되어 버리는 것입니다.

이것은 사용자 경험에 치명적입니다. 사용자는 자신이 입력하던 흐름이 끊기고, 앱이 자신의 의도와 다르게 동작한다고 느끼게 됩니다. 이 문제를 해결하는 것이 바로 이 글의 핵심 목표입니다.

4. 문제 해결의 열쇠: `TextEditingValue`와 `TextSelection` 파헤치기

커서 문제를 해결하기 위해서는 TextEditingController를 좀 더 깊이 들여다봐야 합니다. 사실 TextEditingControllerString이 아닌 TextEditingValue라는 객체의 상태를 관리하는 ValueNotifier<TextEditingValue> 입니다. 우리가 controller.text로 접근하는 것은 이 TextEditingValue 객체의 여러 속성 중 하나일 뿐입니다.

TextEditingValue 클래스의 구조를 살펴보면 실마리를 찾을 수 있습니다.


class TextEditingValue {
  const TextEditingValue({
    this.text = '',
    this.selection = const TextSelection.collapsed(offset: -1),
    this.composing = TextRange.empty,
  });

  // 현재 텍스트
  final String text;

  // 현재 커서 위치 또는 선택된 텍스트의 범위
  final TextSelection selection;

  // (주로 CJK 입력기 등에서) 현재 조합 중인 글자의 범위
  final TextRange composing;

  // ... 기타 메서드들 ...
}

보시다시피 TextEditingValue는 텍스트(text) 정보와 함께, 선택/커서 정보(selection)를 가지고 있습니다. 바로 이 selection 속성이 우리가 제어해야 할 대상입니다.

TextSelection 클래스는 커서의 위치나 사용자가 드래그하여 선택한 텍스트의 범위를 나타냅니다. 몇 가지 중요한 생성자가 있습니다.

  • TextSelection.collapsed({required int offset}): 텍스트 블록을 선택하지 않은, 단순히 깜빡이는 커서의 위치를 지정합니다. offset은 0부터 시작하는 문자의 인덱스입니다. 예를 들어 'abc'에서 'c' 다음에 커서를 두려면 offset: 3이 됩니다.
  • TextSelection.fromPosition(TextPosition position): TextPosition 객체를 받아 커서 위치를 지정합니다. TextSelection.collapsed와 거의 동일한 역할을 합니다.
  • TextSelection({required int baseOffset, required int extentOffset}): 텍스트의 특정 범위를 선택할 때 사용합니다. baseOffset은 선택이 시작된 위치, extentOffset은 선택이 끝난 위치입니다.

이제 우리의 전략이 명확해졌습니다.

  1. 리스너 함수 안에서 텍스트를 포맷팅한다.
  2. 포맷팅 후, 커서가 위치해야 할 새로운 위치를 계산한다.
  3. 새로운 텍스트와 계산된 새 커서 위치를 담은 새로운 `TextEditingValue` 객체를 생성한다.
  4. controller.text 대신 controller.value에 이 새로운 `TextEditingValue` 객체를 할당한다.

이렇게 하면 텍스트와 커서 위치를 한 번에, 원자적으로(atomically) 업데이트할 수 있어, 커서가 튀는 현상을 완벽하게 막을 수 있습니다.

5. 완벽한 솔루션: 포맷팅과 커서 위치 동시 제어

위에서 세운 전략을 바탕으로, 가격 포맷팅 코드를 다시 작성해 보겠습니다. 가장 까다로운 부분은 '새로운 커서 위치 계산'입니다.

예를 들어, 사용자가 '123'을 입력한 상태에서(커서는 3 뒤에 있음, offset=3), '4'를 입력했다고 가정해 봅시다.

  1. 변경 전: `text: '123'`, `selection.extentOffset: 3`
  2. 사용자가 '4'를 입력. 리스너가 감지하는 순간의 컨트롤러 값: `text: '1234'`, `selection.extentOffset: 4`
  3. 포맷팅 후: `text`는 '1,234'가 됩니다.
  4. 커서 위치 계산: 원래 텍스트 '1234'의 길이는 4, 포맷팅된 텍스트 '1,234'의 길이는 5입니다. 길이가 1만큼 늘어났습니다. 따라서 원래 커서 위치였던 4에 늘어난 길이 1을 더해 새로운 커서 위치는 5가 되어야 합니다. 즉, '1,234'에서 4 뒤에 커서가 위치하게 됩니다.

이 논리를 일반화하여 코드로 구현해 보겠습니다.


// ... State 클래스 내부 ...

final _priceController = TextEditingController();

@override
void initState() {
  super.initState();
  _priceController.addListener(_formatPriceWithCursor);
}

void _formatPriceWithCursor() {
  // 현재 컨트롤러의 값(텍스트와 커서 위치 포함)을 가져옴
  final text = _priceController.text;
  final selection = _priceController.selection;

  // 쉼표를 제거한 순수 숫자 문자열
  String plainNumber = text.replaceAll(',', '');
  if (plainNumber.isEmpty) {
    return;
  }
  
  try {
    final number = int.parse(plainNumber);
    final formatter = NumberFormat('#,###');
    final newFormattedText = formatter.format(number);

    // 텍스트가 실제로 변경되었을 때만 업데이트
    if (newFormattedText != text) {
      // 새로운 커서 위치 계산
      // 포맷팅으로 인해 텍스트 길이가 얼마나 변했는지 계산
      final newTextLength = newFormattedText.length;
      final oldTextLength = text.length;
      final lengthDifference = newTextLength - oldTextLength;

      // 원래 커서 위치에 길이 변화량을 더해줌
      final newSelectionOffset = selection.baseOffset + lengthDifference;

      // 계산된 커서 위치가 텍스트 길이를 벗어나지 않도록 보정
      final newSelection = TextSelection.collapsed(
        offset: newSelectionOffset > newFormattedText.length 
                ? newFormattedText.length 
                : newSelectionOffset,
      );

      // controller.value를 사용하여 텍스트와 커서 위치를 한 번에 업데이트
      _priceController.value = TextEditingValue(
        text: newFormattedText,
        selection: newSelection,
      );
    }
  } catch (e) {
    // 예외 처리
  }
}

// ... dispose 관련 코드 ...

이제 이 코드를 실행하면, 숫자를 입력할 때마다 쉼표가 자연스럽게 추가되면서도 커서는 항상 입력된 숫자 바로 뒤에 머물러 있게 됩니다. 사용자는 아무런 방해 없이 연속적으로 숫자를 입력할 수 있습니다. 이것이 바로 사용자를 배려한 좋은 UX입니다.

이 기법은 비단 가격 입력뿐만 아니라, 카드 번호 입력(예: '1234-5678-....'), 전화번호 입력(예: '010-1234-....') 등 실시간으로 특정 포맷을 적용해야 하는 모든 경우에 활용할 수 있는 강력한 해결책입니다.

6. 한 걸음 더: `TextInputFormatter`로 로직 재사용하기

지금까지 만든 가격 포맷팅 로직은 아주 훌륭하게 동작합니다. 하지만 이 로직이 특정 화면의 State 클래스 내부에 묶여있다는 단점이 있습니다. 만약 다른 화면에서도 동일한 가격 입력 필드가 필요하다면, 이 코드를 복사-붙여넣기 해야 할까요? 이는 좋은 설계가 아닙니다.

Flutter는 이러한 '입력 텍스트 포맷팅' 로직을 재사용 가능한 클래스로 만들 수 있도록 TextInputFormatter라는 추상 클래스를 제공합니다. 우리는 이 클래스를 상속받아 우리만의 포맷터를 만들 수 있습니다.

TextInputFormatter를 상속받는 클래스는 formatEditUpdate라는 메서드 하나만 구현하면 됩니다. 이 메서드는 이전 TextEditingValue(oldValue)와 사용자의 입력으로 인해 변경된 새로운 TextEditingValue(newValue)를 인자로 받아서, 최종적으로 적용될 `TextEditingValue`를 반환합니다.

앞서 우리가 리스너에 작성했던 로직을 그대로 `TextInputFormatter`로 옮겨보겠습니다.


import 'package:flutter/services.dart';
import 'package:intl/intl.dart';

class NumberWithCommaFormatter extends TextInputFormatter {
  final NumberFormat _formatter = NumberFormat('#,###');

  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    // 변경이 없거나, 텍스트가 삭제되어 비어있을 경우 그대로 반환
    if (newValue.text.isEmpty) {
      return newValue.copyWith(text: '');
    }

    // 텍스트가 삭제되는 경우(backspace) 포맷팅을 다시 적용
    if (newValue.text.length < oldValue.text.length) {
      String plainNumber = newValue.text.replaceAll(',', '');
      if(plainNumber.isEmpty) return const TextEditingValue(text: "");
      
      final String formatted = _formatter.format(int.parse(plainNumber));
      
      return TextEditingValue(
        text: formatted,
        // 커서를 적절히 유지. 단순화를 위해 맨 뒤로 보내는 경우가 많음.
        selection: TextSelection.collapsed(offset: formatted.length),
      );
    }
    
    // 텍스트가 추가되는 경우
    String plainNumber = newValue.text.replaceAll(',', '');
    final String newFormattedText = _formatter.format(int.parse(plainNumber));

    // 커서 위치 계산
    final int selectionIndex = newValue.selection.end;
    final int numCommasOld = oldValue.text.split(',').length - 1;
    final int numCommasNew = newFormattedText.split(',').length - 1;
    final int commaDifference = numCommasNew - numCommasOld;

    int newSelectionIndex = selectionIndex + commaDifference;
    // 커서 위치가 텍스트 길이를 초과하지 않도록 보정
    if (newSelectionIndex > newFormattedText.length) {
      newSelectionIndex = newFormattedText.length;
    }

    return TextEditingValue(
      text: newFormattedText,
      selection: TextSelection.collapsed(offset: newSelectionIndex),
    );
  }
}

이렇게 만들어진 NumberWithCommaFormatter는 이제 어떤 TextField에서든 간단하게 적용할 수 있습니다.


// ... build 메서드 내부 ...

TextField(
  // controller는 여전히 필요합니다.
  controller: _someController, 
  decoration: const InputDecoration(
    labelText: '가격을 입력하세요 (Formatter 사용)',
    border: OutlineInputBorder(),
  ),
  keyboardType: TextInputType.number,
  // inputFormatters 리스트에 우리가 만든 포맷터를 추가합니다.
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly, // 숫자만 입력되도록 강제
    NumberWithCommaFormatter(), // 우리의 커스텀 포맷터
  ],
)

이제 더 이상 State 클래스에 addListener, removeListener, 복잡한 포맷팅 함수를 둘 필요가 없습니다. TextFieldinputFormatters 속성에 포맷터 인스턴스를 넣어주기만 하면 됩니다. 코드가 훨씬 간결해지고, 로직이 캡슐화되어 재사용성이 극대화되었습니다. 이것이 Flutter가 지향하는 선언적 UI와 위젯 조합의 철학을 잘 따르는 방식입니다.

마치며: 디테일이 만드는 사용자 경험의 차이

이 글을 통해 우리는 Flutter의 TextEditingController를 사용하여 텍스트 입력을 관리하는 여정을 함께했습니다. 단순히 텍스트를 읽고 쓰는 수준을 넘어, 리스너를 통해 실시간으로 입력에 반응하고, 가장 까다로운 문제 중 하나인 '동적 포맷팅 시 커서 점프 현상'을 해결하는 방법을 깊이 있게 탐구했습니다. TextEditingValueTextSelection의 내부 구조를 이해하고 이를 직접 제어함으로써, 우리는 사용자의 입력 흐름을 방해하지 않는 매끄럽고 직관적인 UX를 만들어낼 수 있었습니다. 더 나아가 TextInputFormatter를 통해 이 해결책을 재사용 가능한 모범 사례로 승화시키는 방법까지 살펴보았습니다.

훌륭한 애플리케이션은 화려한 기능뿐만 아니라, 사용자가 인지하지 못하는 사소한 부분에서의 세심한 배려가 모여 만들어집니다. 텍스트 입력 필드에서 커서가 제멋대로 움직이지 않도록 잡아주는 것과 같은 디테일이 바로 사용자의 만족도와 앱의 전체적인 품질을 결정짓는 중요한 요소입니다. 오늘 배운 TextEditingController의 고급 활용법을 여러분의 다음 Flutter 프로젝트에 적용하여, 사용자를 감동시키는 한 차원 높은 수준의 앱을 만들어 보시길 바랍니다.


0 개의 댓글:

Post a Comment