사용자 친화적인 애플리케이션을 개발할 때, 사소해 보이지만 경험의 질을 크게 좌우하는 요소들이 있습니다. 그중 대표적인 것이 바로 '전화번호 입력'입니다. 사용자가 숫자만 쭉 나열하는 것보다, '010-1234-5678'처럼 익숙한 형태로 자동 변환된다면 훨씬 직관적이고 편리할 것입니다. 또한, 서버로 데이터를 전송하거나 로컬 데이터베이스에 저장할 때 일관된 형식은 데이터 정합성을 유지하는 데 필수적입니다.
이번 글에서는 Flutter/Dart 환경에서 전화번호에 하이픈(-)을 자동으로 추가하는 다양한 방법을 기초부터 심화까지, 실제 프로젝트에 바로 적용할 수 있는 수준으로 상세하게 다룹니다. 단순한 문자열 치환부터 강력한 정규표현식 활용, 그리고 사용자가 입력하는 순간 실시간으로 형식을 바꿔주는 TextInputFormatter
커스터마이징, 마지막으로 검증된 외부 패키지를 사용하는 방법까지 모든 것을 알려드립니다.
1. 가장 기초적인 접근: 조건문과 문자열 자르기
가장 먼저 떠올릴 수 있는 간단한 방법입니다. 전화번호 문자열의 길이를 확인하고, 길이에 따라 적절한 위치에 하이픈을 삽입하는 방식입니다. 프로그래밍에 입문한 분들도 쉽게 이해할 수 있다는 장점이 있습니다.
예를 들어, 한국의 휴대전화번호(11자리)와 일반적인 지역번호가 포함된 유선전화번호(10자리)를 처리하는 함수를 만들어 보겠습니다.
1.1. 기본 구현 코드
String formatPhoneNumberBasic(String phoneNumber) {
// 숫자 외의 모든 문자 제거 (사용자가 하이픈이나 공백을 이미 입력했을 경우 대비)
String digitsOnly = phoneNumber.replaceAll(RegExp(r'\D'), '');
if (digitsOnly.length == 11) {
// 010-1234-5678 형식
return '${digitsOnly.substring(0, 3)}-${digitsOnly.substring(3, 7)}-${digitsOnly.substring(7)}';
} else if (digitsOnly.length == 10) {
// 02-1234-5678 또는 031-123-4567 형식
if (digitsOnly.startsWith('02')) {
// 서울 지역번호
return '${digitsOnly.substring(0, 2)}-${digitsOnly.substring(2, 6)}-${digitsOnly.substring(6)}';
} else {
// 그 외 지역번호
return '${digitsOnly.substring(0, 3)}-${digitsOnly.substring(3, 6)}-${digitsOnly.substring(6)}';
}
} else if (digitsOnly.length == 8) {
// 대표번호 (예: 1588-1234)
return '${digitsOnly.substring(0, 4)}-${digitsOnly.substring(4)}';
}
// 그 외의 경우는 원본 반환 (혹은 예외 처리)
return phoneNumber;
}
// 사용 예시
void main() {
print(formatPhoneNumberBasic("01012345678")); // 출력: 010-1234-5678
print(formatPhoneNumberBasic("0212345678")); // 출력: 02-1234-5678
print(formatPhoneNumberBasic("0311234567")); // 출력: 031-123-4567
print(formatPhoneNumberBasic("15881234")); // 출력: 1588-1234
print(formatPhoneNumberBasic("010 1234 5678")); // 출력: 010-1234-5678
}
1.2. 한계점 명확히 알기
이 방법은 직관적이지만, 몇 가지 명백한 한계를 가지고 있습니다.
- 유연성 부족: 새로운 형식의 전화번호(예: 114 같은 특수번호, 국제전화번호)가 추가될 때마다
if-else
조건문을 계속해서 추가해야 합니다. 코드가 길어지고 유지보수가 어려워집니다. - 복잡성 증가: 위 예시처럼 서울 지역번호(02)와 그 외 지역번호를 구분하는 로직이 들어가기 시작하면 코드가 복잡해집니다.
- 가독성 저하: 조건문이 많아질수록 전체적인 코드의 흐름을 파악하기가 힘들어집니다.
따라서 이 방법은 매우 제한된, 정해진 몇 가지 형식의 전화번호만 처리하는 간단한 애플리케이션이 아니라면 추천하지 않습니다. 하지만 프로그래밍의 기본 원리를 이해하는 데는 좋은 예시가 될 수 있습니다.
2. 강력하고 우아한 해결책: 정규표현식(Regular Expression)
정규표현식(줄여서 '정규식' 또는 'RegExp')은 문자열에서 특정 패턴을 찾거나, 바꾸거나, 검증하는 데 사용되는 매우 강력한 도구입니다. 전화번호처럼 일정한 패턴을 가진 문자열을 다루는 데 이보다 더 적합한 방법은 찾기 어렵습니다. 처음에는 외계어처럼 보일 수 있지만, 핵심 원리 몇 가지만 이해하면 코드의 양을 획기적으로 줄이고 유연성은 극대화할 수 있습니다.
2.1. 핵심 정규표현식 파헤치기: /(\d{3})(\d{3,4})(\d{4})/
한국의 일반적인 전화번호 형식을 처리하는 데 가장 널리 쓰이는 정규식입니다. 이 한 줄의 코드를 원자 단위로 분해해 보겠습니다.
()
(캡처 그룹, Capturing Group): 소괄호로 묶인 부분은 하나의 '그룹'으로 처리됩니다. 패턴에 매칭된 문자열 중에서 이 그룹에 해당하는 부분들을 나중에 따로 꺼내서 사용할 수 있습니다. 위 정규식에는 3개의 캡처 그룹이 있습니다.\d
(Digit): 숫자(0-9) 하나를 의미합니다. 'digit'의 약자입니다.{n}
(Quantifier): 바로 앞의 패턴이 'n번' 반복되는 것을 의미합니다. 예를 들어\d{3}
은 '숫자가 3번 연속'으로 나오는 패턴을 찾습니다.{n,m}
(Quantifier): 바로 앞의 패턴이 '최소 n번, 최대 m번' 반복되는 것을 의미합니다. 예를 들어\d{3,4}
는 '숫자가 3번 또는 4번 연속'으로 나오는 패턴을 찾습니다.
이것들을 조합하여 정규식을 다시 해석해 보면 다음과 같습니다.
(\d{3})
: 첫 번째 그룹. 숫자가 3개 연속으로 나오는 부분을 찾습니다. (예: "010")(\d{3,4})
: 두 번째 그룹. 이어서 숫자가 3개 또는 4개 연속으로 나오는 부분을 찾습니다. (예: "123" 또는 "1234")(\d{4})
: 세 번째 그룹. 마지막으로 숫자가 4개 연속으로 나오는 부분을 찾습니다. (예: "5678")
이 정규식은 총 10자리(3+3+4) 또는 11자리(3+4+4)의 숫자 시퀀스를 정확하게 찾아낼 수 있습니다.
2.2. replaceAllMapped
와의 환상적인 조합
Dart의 String
클래스는 replaceAllMapped
라는 매우 유용한 메서드를 제공합니다. 이 메서드는 정규식에 매칭되는 부분을 찾아서, 그 결과를 바탕으로 새로운 문자열을 동적으로 만들어 교체하는 역할을 합니다.
String formatPhoneNumberWithRegex(String phoneNumber) {
// 1. 숫자 외의 문자는 모두 제거
var digitsOnly = phoneNumber.replaceAll(RegExp(r'\D'), '');
// 2. 정규식 패턴 정의
RegExp exp = RegExp(r'(\d{3})(\d{3,4})(\d{4})');
// 3. replaceAllMapped를 사용하여 매칭된 그룹을 하이픈으로 연결
return digitsOnly.replaceAllMapped(exp, (Match m) => '${m[1]}-${m[2]}-${m[3]}');
}
// 사용 예시
void main() {
print(formatPhoneNumberWithRegex("01012345678")); // 출력: 010-1234-5678
print(formatPhoneNumberWithRegex("010-123-4567")); // 출력: 010-123-4567
print(formatPhoneNumberWithRegex("공일공 일리삼사 오육칠팔")); // 이 경우는 숫자만 추출되어 "01012345678"이 되고, 포맷팅이 적용됨
}
replaceAllMapped
의 두 번째 인자로 전달되는 함수 (Match m) => ...
를 자세히 보겠습니다.
Match m
: 정규식에 매칭된 결과 전체를 담고 있는 객체입니다.m[0]
: 매칭된 전체 문자열입니다. (예: "01012345678")m[1]
: 첫 번째 캡처 그룹(\d{3})
에 매칭된 부분입니다. (예: "010")m[2]
: 두 번째 캡처 그룹(\d{3,4})
에 매칭된 부분입니다. (예: "1234")m[3]
: 세 번째 캡처 그룹(\d{4})
에 매칭된 부분입니다. (예: "5678")
따라서 '${m[1]}-${m[2]}-${m[3]}'
코드는 캡처된 각 부분을 하이픈으로 이어 붙여 '010-1234-5678'이라는 새로운 문자열을 만들어내는 것입니다.
2.3. Dart 확장(Extension)으로 재사용성 높이기
이 유용한 함수를 프로젝트 어디서든 편하게 사용하기 위해 Dart의 확장(Extension) 기능을 활용할 수 있습니다. String
타입에 새로운 메서드를 직접 추가하는 것처럼 만들 수 있습니다.
extension PhoneNumberFormatter on String {
String toPhoneFormatted() {
// 숫자 외의 문자는 모두 제거
var digitsOnly = replaceAll(RegExp(r'\D'), '');
if (digitsOnly.isEmpty) {
return '';
}
// 다양한 케이스를 처리하기 위한 정규식 개선
// 1. 02로 시작하는 9~10자리 번호
if (digitsOnly.startsWith('02') && (digitsOnly.length == 9 || digitsOnly.length == 10)) {
return digitsOnly.replaceAllMapped(
RegExp(r'(\d{2})(\d{3,4})(\d{4})'), (m) => '${m[1]}-${m[2]}-${m[3]}');
}
// 2. 1588, 1600 등 8자리 대표번호
if (digitsOnly.length == 8 && (digitsOnly.startsWith('15') || digitsOnly.startsWith('16') || digitsOnly.startsWith('18'))) {
return digitsOnly.replaceAllMapped(
RegExp(r'(\d{4})(\d{4})'), (m) => '${m[1]}-${m[2]}');
}
// 3. 그 외 일반적인 10~11자리 번호 (휴대폰 포함)
RegExp exp = RegExp(r'(\d{3})(\d{3,4})(\d{4})');
return digitsOnly.replaceAllMapped(exp, (m) => '${m[1]}-${m[2]}-${m[3]}');
}
}
// 사용 예시
void main() {
String phoneNumber1 = "01012345678";
String phoneNumber2 = "02-123-4567";
String phoneNumber3 = "15881234";
print(phoneNumber1.toPhoneFormatted()); // 출력: 010-1234-5678
print(phoneNumber2.toPhoneFormatted()); // 출력: 02-123-4567
print(phoneNumber3.toPhoneFormatted()); // 출력: 1588-1234
}
이제 어떤 문자열 변수 뒤에든 .toPhoneFormatted()
를 붙이는 것만으로 전화번호 포맷팅을 손쉽게 적용할 수 있게 되었습니다. 훨씬 깔끔하고 재사용성이 높은 코드가 완성되었습니다.
3. 실시간 변환의 마법: `TextInputFormatter` 직접 만들기
지금까지의 방법은 이미 존재하는 문자열을 변환하는 데 사용됩니다. 하지만 사용자 경험을 극대화하려면, 사용자가 TextField
에 전화번호를 입력하는 그 순간에 실시간으로 하이픈이 추가되어야 합니다. Flutter는 이를 위해 TextInputFormatter
라는 강력한 클래스를 제공합니다.
우리는 TextInputFormatter
를 상속받아 우리만의 커스텀 포매터를 만들 것입니다. 이 포매터의 핵심 역할은 사용자의 키 입력 이벤트를 가로채서, 텍스트를 원하는 형식으로 바꾼 뒤, 화면에 보여주는 것입니다.
3.1. `PhoneNumberInputFormatter` 클래스 구현하기
가장 중요한 부분은 `formatEditUpdate` 메서드를 오버라이드하는 것입니다. 이 메서드는 사용자가 텍스트를 변경할 때마다 호출됩니다.
import 'package:flutter/services.dart';
class PhoneNumberInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
// 1. 숫자 외의 문자 제거
final String newText = newValue.text.replaceAll(RegExp(r'\D'), '');
final int newTextLength = newText.length;
int selectionIndex = newValue.selection.end;
int usedSubstringIndex = 0;
final StringBuffer buffer = StringBuffer();
// 2. 길이에 따라 하이픈 추가
if (newTextLength >= 4) {
// 010-
buffer.write(newText.substring(usedSubstringIndex, usedSubstringIndex = 3) + '-');
if (newValue.selection.end >= 3) selectionIndex++;
} else {
buffer.write(newText.substring(usedSubstringIndex));
}
if (newTextLength >= 8) {
// 010-1234-
buffer.write(newText.substring(usedSubstringIndex, usedSubstringIndex = 7) + '-');
if (newValue.selection.end >= 7) selectionIndex++;
} else {
buffer.write(newText.substring(usedSubstringIndex));
}
// 나머지 숫자 추가
if (newTextLength > usedSubstringIndex) {
buffer.write(newText.substring(usedSubstringIndex));
}
return TextEditingValue(
text: buffer.toString(),
selection: TextSelection.collapsed(offset: selectionIndex),
);
}
}
위 코드는 정규식 대신 `substring`을 사용해 구현한 버전입니다. 로직은 다음과 같습니다.
- 사용자가 입력한 텍스트(
newValue.text
)에서 숫자만 추출합니다. - 추출된 숫자의 길이를 확인합니다.
- 길이가 4 이상이면(예: '0101'), 세 번째 숫자 뒤에 하이픈을 추가합니다 ('010-1').
- 길이가 8 이상이면(예: '01012341'), 일곱 번째 숫자 뒤에 하이픈을 추가합니다 ('010-1234-1').
- 가장 중요한 부분: 하이픈이 추가되면서 텍스트의 전체 길이가 변하므로, 사용자의 커서(cursor) 위치(
selectionIndex
)도 함께 보정해줘야 합니다. 그렇지 않으면 사용자가 중간에 숫자를 입력하거나 지울 때 커서가 엉뚱한 곳으로 튀는 현상이 발생합니다. - 최종적으로 포맷팅된 텍스트와 보정된 커서 위치를 담은
TextEditingValue
객체를 반환합니다.
3.2. `TextField` 위젯에 적용하기
이렇게 만든 커스텀 포매터는 `TextField` 위젯의 `inputFormatters` 속성에 리스트 형태로 전달하면 됩니다.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// ... (위에 작성한 PhoneNumberInputFormatter 클래스)
class MyFormPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('전화번호 입력'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: TextFormField(
decoration: InputDecoration(
labelText: '전화번호',
hintText: '010-1234-5678',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone, // 숫자 키패드 표시
inputFormatters: [
FilteringTextInputFormatter.digitsOnly, // 숫자만 입력되도록 필터링
LengthLimitingTextInputFormatter(11), // 최대 11자리(하이픈 제외)까지 입력 가능
PhoneNumberInputFormatter(), // 우리가 만든 커스텀 포매터
],
),
),
);
}
}
주목할 점:
FilteringTextInputFormatter.digitsOnly
: 애초에 숫자 외의 문자가 입력되는 것을 막아줍니다. 우리 포매터의 안정성을 높여줍니다.LengthLimitingTextInputFormatter(11)
: 하이픈을 제외한 순수 숫자가 11자리를 넘지 않도록 제한합니다.PhoneNumberInputFormatter()
: 마지막으로 우리가 만든 포매터를 적용하여 실시간으로 하이픈을 추가합니다.
이제 사용자가 키보드에서 숫자를 누를 때마다, 텍스트 필드에는 자동으로 하이픈이 삽입되는 마법 같은 사용자 경험을 제공할 수 있습니다.
4. 가장 편리한 방법: 검증된 외부 패키지 사용하기
직접 포매터를 만드는 것은 Flutter의 동작 원리를 깊이 이해하는 데 큰 도움이 되지만, 때로는 잘 만들어진 외부 패키지를 사용하는 것이 개발 속도와 안정성 면에서 더 효율적일 수 있습니다.
전화번호나 카드번호 등 특정 형식의 마스크(mask)를 입력받는 데 가장 널리 쓰이는 패키지는 `mask_text_input_formatter` 입니다.
4.1. `mask_text_input_formatter` 설치 및 설정
먼저, 프로젝트의 `pubspec.yaml` 파일에 패키지를 추가합니다.
dependencies:
flutter:
sdk: flutter
mask_text_input_formatter: ^2.9.0 # 최신 버전은 pub.dev에서 확인하세요.
그리고 터미널에서 `flutter pub get` 명령어를 실행하여 패키지를 설치합니다.
4.2. 패키지 사용법
사용법은 놀라울 정도로 간단합니다. 포매터 객체를 생성하고, 원하는 마스크 형식을 지정한 뒤 `TextField`에 적용하기만 하면 됩니다.
import 'package:flutter/material.dart';
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
class PackageDemoPage extends StatelessWidget {
// 마스크 포매터 인스턴스 생성
final maskFormatter = MaskTextInputFormatter(
mask: '###-####-####', // 11자리 휴대폰 번호 형식
filter: {"#": RegExp(r'[0-9]')}, // '#' 위치에는 숫자만 허용
type: MaskAutoCompletionType.lazy, // 사용자가 입력할 때만 마스크 적용
);
// 10자리, 11자리 동적 마스킹을 위한 컨트롤러 리스너 활용 예시도 가능
// 이 부분은 심화 내용으로, 공식 문서를 참고하면 좋습니다.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('패키지 사용'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: TextFormField(
decoration: InputDecoration(
labelText: '전화번호',
hintText: '마스크가 적용됩니다',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
inputFormatters: [maskFormatter], // 생성한 포매터를 여기에 추가
),
),
);
}
}
주요 옵션 설명:
mask
: 원하는 입력 형식을 지정합니다. '#'는 `filter`에서 정의한 문자를 의미합니다. '###-####-####'는 11자리 전화번호, '##/##/####'는 날짜 형식 등에 사용할 수 있습니다.filter
: 마스크의 각 특수문자('#', 'A', 'S' 등)에 어떤 종류의 문자가 와야 하는지를 정규식으로 정의합니다.{"#": RegExp(r'[0-9]')}
는 '#' 자리에 0부터 9까지의 숫자만 올 수 있다는 의미입니다.type
: 마스크 자동 완성 방식을 정합니다. `MaskAutoCompletionType.lazy`는 사용자가 입력함에 따라 자연스럽게 마스크를 채워나가고, `MaskAutoCompletionType.eager`는 필드가 포커스를 얻는 순간 마스크 형태를 미리 보여줍니다.
4.3. 패키지 사용의 장단점
장점:
- 압도적인 생산성: 단 몇 줄의 코드로 복잡한 실시간 포맷팅 기능을 구현할 수 있습니다.
- 안정성 및 유지보수: 수많은 개발자가 사용하고 검증한 패키지이므로, 직접 만들었을 때 놓칠 수 있는 다양한 예외 상황(커서 처리, 삭제/붙여넣기 로직 등)이 잘 처리되어 있습니다.
- 유연성: 전화번호뿐만 아니라 주민등록번호, 카드번호, 날짜 등 다양한 형식의 마스크에 쉽게 적용할 수 있습니다.
단점:
- 의존성 추가: 외부 패키지에 대한 의존성이 생깁니다. 패키지 업데이트가 중단되거나, Flutter의 메이저 업데이트와 호환성 문제가 발생할 리스크가 (비록 작지만) 존재합니다.
- 커스터마이징의 한계: 패키지가 제공하는 기능 범위를 넘어서는 매우 특수한 요구사항을 구현하기는 어려울 수 있습니다.
결론: 어떤 방법을 선택해야 할까?
지금까지 Flutter/Dart에서 전화번호를 포맷팅하는 4가지 방법을 단계별로 살펴보았습니다. 각 방법의 특징과 장단점이 명확하므로, 프로젝트의 요구사항과 상황에 맞는 최적의 방법을 선택하는 것이 중요합니다.
상황별 추천 전략 요약
- 단순히 저장된 전화번호를 화면에 보여줄 때:
➡️ 정규식(RegExp)과 Dart 확장(Extension) 조합을 추천합니다. 코드가 간결하고 재사용성이 매우 높습니다. - 실시간 입력 포맷팅이 필요하지만 외부 라이브러리를 쓰고 싶지 않을 때:
➡️ 커스텀 `TextInputFormatter`를 직접 구현하세요. Flutter의 내부 동작을 이해하는 데도 큰 도움이 됩니다. - 빠른 개발 속도와 안정성이 최우선일 때:
➡️ 주저하지 말고 `mask_text_input_formatter` 패키지를 사용하세요. 대부분의 경우 가장 현명한 선택입니다. - 학습 목적이거나 매우 간단한 기능만 필요할 때:
➡️ 기본적인 문자열 처리 방법을 시도해볼 수 있지만, 실제 상용 프로젝트에는 권장하지 않습니다.
전화번호 포맷팅은 작은 기능처럼 보이지만, 사용자의 편의성을 높이고 데이터의 품질을 보장하는 중요한 첫걸음입니다. 오늘 배운 내용들을 여러분의 프로젝트에 적용하여 한 단계 더 완성도 높은 애플리케이션을 만들어 보시길 바랍니다.
0 개의 댓글:
Post a Comment