1. 개요
소프트웨어를 개발할 때 한글을 자유롭게 다룰 경우가 종종 있었고, 이에 대한 Utilty 기능을 개발하게 되어 이 포스팅에서 소개하고자 한다.
2. 활용 문법
2.1. extension
3. 구현
3.1. runes
문자열의 각 문자에 대해 기본단위인 유니코드 코드 포인트를 취하여 Iterable<int>
형식으로 반환하는 함수이다. 즉,
'안녕'.runes; // (50504, 45397)
와 같이 '안'
과 '녕'
문자의 각 유니코드 코드 포인트를 알 수 있다.
3.2. 한글 음절의 유니코드 색인
유니코드 값이 가장 작은 한글 음절은 '가'
로 0xAC00
의 값을 가진다.
int get _index => runes.last - 0xAC00;
따라서 위와 같이 나타내면 한글 음절이 유니코드상 몇 번째 위치에 있는지 파악할 수 있다.
3.3. 한글 음절 초⋅중⋅종성의 개수
한글은 19개의 초성, 21개의 중성, 28개의 종성을 가진다.
static const _cho = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
static const _jung = 'ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ';
static const _jong = ' ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ';
3.4. 한글 음절 초⋅중⋅종성 분리
한글 음절 문자는 다음과 같은 방식으로 유니코드 값이 구성된다.
값 =
0xAC00
+ 초성 × 588 + 중성 × 28 + 종성
따라서 음절의 유니코드 값을 통해 초⋅중⋅종성으로 분리하기 위해서는 다음의 작업이 필요하다.
- 초성: ⌊값 / 588⌋
- 중성: ⌊(값 % 588) / 28⌋
- 종성: 값 % 28
다음은 특정 한글 음절에 대하여 각각 초성, 중성, 종성을 반환하는 메소드이다. private
으로 선언되어 패키지 외부에서의 접근이 제외된다.
String get _choseong { assert(length == 1); return _cho[_index ~/ 588]; }
String get _jungseong { assert(length == 1); return _jung[(_index % 588) ~/ 28]; }
String get _jongseong { assert(length == 1); return _jong[_index % 28]; }
실사용하는 한글 문자열은 음절이 아닌, 단어나 문장단위이므로 길이가 1보다 큰 경우가 일반적이다. 따라서 다음의 메소드를 추가한다.
String get choseong => split('').map((e) => e._choseong).join('');
String get jungseong => split('').map((e) => e._jungseong).join('');
String get jongseong => split('').map((e) => e._jongseong).join('');
3.5. 받침 여부
종성(받침) 여부를 판단할 수 있다. 다음의 메소드는 한글 음절에 대해 종성의 존재 여부를 반환한다.
bool get hasBatchim { assert(length == 1 && isEumjeol); return _jongseong != ' '; }
3.6. 풀어쓰기
다음과 같이 모든 자모를 분리하여 작성하는 것을 풀어쓰기라고 한다.
안녕
→ㅇㅏㄴㄴㅕㅇ
기차표
→ㄱㅣㅊㅏㅍㅛ
병원
→ㅂㅕㅇㅇㅜㅓㄴ
다음은 특정 한글 음절의 풀어쓰기를 반환하는 함수이다. private
으로 선언되어 패키지 외부에서의 접근이 제외된다.
String get _puleossugi {
assert(length == 1);
if (isJaeum || isMoeum) return this;
return ([_choseong, _jungseong]..addIf(hasBatchim, _jongseong)).join('');
}
단어와 문장으로 확장한 메소드는 아래와 같다.
String get puleossugi => split('').map((e) => e._puleossugi).join('');
3.7. 모아쓰기
모아쓰기는 풀어쓰기의 반대이다.
ㅇㅏㄴㄴㅕㅇ
→안녕
ㄱㅣㅊㅏㅍㅛ
→기차표
ㅂㅕㅇㅇㅜㅓㄴ
→병원
음절 단위의 모아쓰기는 풀어쓰기의 역순이므로 다음과 같이 구현할 수 있다.
String get _moassugi {
final cho = _cho.indexOf(this[0]);
final jung = _jung.indexOf(this[1]);
final jong = _jong.indexOf(length == 3 ? this[2] : ' ');
if (cho == -1 || jung == -1) return '';
return String.fromCharCode(0xAC00 + cho * 588 + jung * 28 + jong);
}
하지만 단어 및 문장 단위로 확장된 모아쓰기는 상당히 복잡한 과정을 따른다.
아래는 모아쓰기의 원칙을 정리한 것이다.
모아쓰기 원칙
- 기본 구성 원칙
- 초성(자음)은 음절의 시작에 위치하며, 한 음절에는 반드시 하나의 초성이 포함된다.
- 중성(모음)은 초성과 반드시 결합해야 하며, 한 음절에는 반드시 하나의 중성이 포함된다.
- 종성(자음)은 초성과 중성의 조합 이후에 올 수 있고, 존재하지 않을 수 있다.
- 자모의 순서는 자음 → 모음 → 자음의 규칙을 따른다.
- 입력 순서 기반 원칙
- 2.1. 자음 + 모음
- 자음 다음에 모음이 오면 초성과 중성이 결합하여 한 음절을 형성한다.
- 예)
ㄱㅏ
→가
- 예)
ㅎㅜ
→후
- 2.2. 자음 + 자음
- 자음이 연달아 등장하면, 첫 번째 자음은 앞 음절의 종성, 두 번째 자음은 다음 음절의 초성이 된다.
- 예)
(ㅇㅏ)ㄴㄴ
→안ㄴ
- 예)
(ㅁㅓ)ㄹㄹ
→멀ㄹ
- 2.3. 모음 + 모음
- 모음 다음에 모음이 올 경우, 이중 모음으로 취급된다.
- 예)
ㅏㅣ
→ㅐ
- 예)
ㅓㅣ
→ㅔ
- 예)
ㅗㅏ
→ㅘ
- 예)
ㅗㅐ
→ㅙ
- 예)
ㅘㅣ
→ㅙ
- 예)
ㅗㅣ
→ㅚ
- 예)
ㅜㅓ
→ㅝ
- 예)
ㅜㅔ
→ㅞ
- 예)
ㅝㅣ
→ㅞ
- 예)
ㅜㅣ
→ㅟ
- 예)
ㅡㅣ
→ㅢ
- 2.3.1. 모음 + 모음 + 모음
- 세 개의 모음이 연달아 이어진 경우에도 이중 모음으로 취급된다. (아래의 경우에 한 한다.)
- 예)
ㅗㅏㅣ
→ㅙ
- 예)
ㅜㅓㅣ
→ㅞ
- 2.4. 모음 + 자음
- 모음 다음에 자음이 오면 그 뒤를 확인해야 자음의 성격을 판단할 수 있다.
- 2.4.1. 모음 + 자음 + 자음
- 자음이 연달아 등장하므로 2.2. 를 따른다.
- 2.4.2. 모음 + 자음 + 모음
- 자음은 잇달은 모음과 다음 음절의 초성과 중성을 이룬다.
- 예)
(ㅇ)ㅏㄴㅣ
→아니
- 예)
(ㅁ)ㅓㄹㅣ
→머리
- 2.4.3. 모음 + 자음 + (문자열의 마지막)
- 자음 뒤에 더 이상 자모가 없는 경우, 자음은 앞 음절의 종성이 된다.
- 예)
(ㅇ)ㅏㄴ
→안
- 예)
(ㅁ)ㅓㄹㄹ
→멀
이를 구현하면 다음과 같다.
String get moassugi {
String string = this;
string = _ijungmoeum.entries.fold(string, (result, entry) {
String key = entry.key;
List<String> moeums = entry.value;
for (var moeum in moeums) {
if (!result.contains(moeum)) continue;
return result.replaceAll(moeum, key);
}
return result;
});
List<(String, bool)> list = string.split('')
.map<(String, bool)>((e) => (e, e.isJaeum)).toList();
List<String> result = [], char = [];
bool moeumFlag = false;
for (var entry in list.reversed) {
char.insert(0, entry.$1);
if (entry.$2) {
if (moeumFlag) {
result.insert(0, char.join('')._moassugi);
char.clear();
moeumFlag = false;
}
continue;
}
moeumFlag = true;
}
return result.join('');
}
3.8. last
문자열의 가장 마지막 문자를 반환한다.
'안녕'.last; // 녕
3.9. 조사
조사는 기본적으로 바로 이전 음절의 종성 유무에 따라 두 가지 경우 중 하나로 확정된다.
3.9.1. 은/는
이전 음절의 종성 유무 | 기본 | 이름 |
---|---|---|
O | 은 | 이는 |
X | 는 | 는 |
String get eunNeun => last.hasBatchim ? '은' : '는';
String get eunNeunName => last.hasBatchim ? '이는' : '는';
3.9.2. 이/가
이전 음절의 종성 유무 | 기본 | 이름 |
---|---|---|
O | 이 | 이가 |
X | 가 | 가 |
String get iGa => last.hasBatchim ? '이' : '가';
String get iGaName => last.hasBatchim ? '이가' : '가';
3.9.3. 을/를
이전 음절의 종성 유무 | 기본 | 이름 |
---|---|---|
O | 을 | 이를 |
X | 를 | 를 |
String get eulReul => last.hasBatchim ? '을' : '를';
String get eulReulName => last.hasBatchim ? '이를' : '를';
3.9.4. 로/으로
이전 음절의 종성 유무 | 기본 |
---|---|
O | 으로 |
X | 로 |
String get roEuro => last.hasBatchim ? '으로' : '로';
3.10. with
조사
기존 문자열에 조사를 포함하여 반환한다.
String get withEunNeun => this + eunNeun;
String get withEunNeunName => this + eunNeunName;
String get withIGa => this + iGa;
String get withIGaName => this + iGaName;
String get withEulReul => this + eulReul;
String get withEulReulName => this + eulReulName;
String get withRoEuro => this + roEuro;
3.11. is
, has
3.11.1. 자모 여부
isJaeum
문자가 한글 자음인지 여부를 반환한다. 판단할 문자열의 길이는 1로 제한한다.
예)'ㄱ'
, 'ㄴ'
, 'ㅎ'
등
bool get isJaeum { assert(length == 1); return IntRange(12593, 12622).contains(codeUnits[0]); }
isMoeum
문자가 한글 모음인지 여부를 반환한다. 판단할 문자열의 길이는 1로 제한한다.
예)'ㅏ'
, 'ㅑ'
, 'ㅢ'
등
bool get isMoeum { assert(length == 1); return IntRange(12623, 12643).contains(codeUnits[0]); }
hasSeparatedJaeumOrMoeum
문자열 내 독립된 자음 혹은 모음으로 존재하는 문자가 존재하는지 여부를 반환한다.
예)안녕하세요ㅎㅎ
, 죄송합니다ㅠㅠ
등
bool get hasSeparatedJaeumOrMoeum => split('').map((e) => e.isJaeum || e.isMoeum).contains(true);
3.11.2. 음절 여부
문자가 한글 음절인지 여부를 반환한다. 판단할 문자열의 길이는 1로 제한한다.
예)'가'
, '안'
, '밖'
등
bool get isEumjeol { assert(length == 1); return IntRange(44032, 55203).contains(codeUnits[0]); }
3.11.3. 한글 여부
_isHangeul
문자가 한글 자음, 모음, 또는 음절인지 여부를 반환한다. 판단할 문자열의 길이는 1로 제한하며 private
으로 선언되어 패키지 외부에서의 접근이 제외된다.
예)ㄱ
, ㅙ
, 밖
등
bool get _isHangeul { assert(length == 1); return isJaeum || isMoeum || isEumjeol; }
isHangeul
문자열 내 모든 문자가 한글인지 여부를 반환한다.
예)안녕하세요
, 반갑습니다
등
bool get isHangeul => split('').every((e) => e._isHangeul || e == ' ');
hasHangeul
문자열 내 한글이 포함되었는지 여부를 반환한다.
예)안녕 World
등
bool get hasHangeul => split('').any((char) => char.isHangeul);
3.14. 한글 간 포함관계
초성, 중성, 종성으로 이루어진 한글의 독특한 특성 때문에 특정 한글 문자열 간의 포함 관계를 판단하거나 검색할 때 기존의 단순 문자열 비교 방식 (contains
) 으로는 충분하지 않을 수 있다.
가령, '세상'
이라는 단어를 키보드로 입력할 때 다음의 절차를 따른다.
ㅅ
세
셋 or 세ㅅ
세사
세상
이를 기존의 contains
메소드를 사용하여 포함관계를 나타내면 다음과 같다.
'세상'.contains('ㅅ'); // false
'세상'.contains('세'); // true
'세상'.contains('셋'); // false
'세상'.contains('세ㅅ'); // false
'세상'.contains('세사'); // false
'세상'.contains('세상'); // true
containsHangeul
메소드는 '세상'
이란 단어를 치기 위해 거쳐간 문자열 모두를 포함되도록 한다.
bool containsHangeul(String other) => puleossugi.contains(other.puleossugi);
4. 전체 코드
extension KoreanExtension on String {
static const _cho = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
static const _jung = 'ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ';
static const _jong = ' ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ';
static const _ijungmoeum = {
'ㅘ': ['ㅗㅏ'],
'ㅚ': ['ㅗㅣ'],
'ㅟ': ['ㅜㅣ'],
'ㅢ': ['ㅡㅣ'],
'ㅐ': ['ㅏㅣ'],
'ㅒ': ['ㅑㅣ'],
'ㅔ': ['ㅓㅣ'],
'ㅖ': ['ㅕㅣ'],
'ㅝ': ['ㅜㅓ'],
'ㅙ': ['ㅗㅐ', 'ㅘㅣ', 'ㅗㅏㅣ'],
'ㅞ': ['ㅜㅔ', 'ㅝㅣ', 'ㅜㅓㅣ'],
};
int get _index => runes.last - 0xAC00;
String get _choseong { assert(length == 1); return _cho[_index ~/ 588]; }
String get _jungseong { assert(length == 1); return _jung[(_index % 588) ~/ 28]; }
String get _jongseong { assert(length == 1); return _jong[_index % 28]; }
String get choseong => split('').map((e) => e._choseong).join('');
String get jungseong => split('').map((e) => e._jungseong).join('');
String get jongseong => split('').map((e) => e._jongseong).join('');
bool get hasBatchim { assert(length == 1 && isEumjeol); return _jongseong != ' '; }
String get _puleossugi {
assert(length == 1);
if (isJaeum || isMoeum) return this;
return ([_choseong, _jungseong]..addIf(hasBatchim, _jongseong)).join('');
}
String get _moassugi {
final cho = _cho.indexOf(this[0]);
final jung = _jung.indexOf(this[1]);
final jong = _jong.indexOf(length == 3 ? this[2] : ' ');
if (cho == -1 || jung == -1) return '';
return String.fromCharCode(0xAC00 + cho * 588 + jung * 28 + jong);
}
String get puleossugi => split('').map((e) => e._puleossugi).join('');
String get moassugi {
String string = this;
string = _ijungmoeum.entries.fold(string, (result, entry) {
String key = entry.key;
List<String> moeums = entry.value;
for (var moeum in moeums) {
if (!result.contains(moeum)) continue;
return result.replaceAll(moeum, key);
}
return result;
});
List<(String, bool)> list = string.split('')
.map<(String, bool)>((e) => (e, e.isJaeum)).toList();
List<String> result = [], char = [];
bool moeumFlag = false;
for (var entry in list.reversed) {
char.insert(0, entry.$1);
if (entry.$2) {
if (moeumFlag) {
result.insert(0, char.join('')._moassugi);
char.clear();
moeumFlag = false;
}
continue;
}
moeumFlag = true;
}
return result.join('');
}
String get last => this[length - 1];
String get eunNeun => last.hasBatchim ? '은' : '는';
String get eunNeunName => last.hasBatchim ? '이는' : '는';
String get iGa => last.hasBatchim ? '이' : '가';
String get iGaName => last.hasBatchim ? '이가' : '가';
String get eulReul => last.hasBatchim ? '을' : '를';
String get eulReulName => last.hasBatchim ? '이를' : '를';
String get roEuro => last.hasBatchim ? '으로' : '로';
String get withEunNeun => this + eunNeun;
String get withEunNeunName => this + eunNeunName;
String get withIGa => this + iGa;
String get withIGaName => this + iGaName;
String get withEulReul => this + eulReul;
String get withEulReulName => this + eulReulName;
String get withRoEuro => this + roEuro;
bool get isJaeum { assert(length == 1); return IntRange(12593, 12622).contains(codeUnits[0]); }
bool get isMoeum { assert(length == 1); return IntRange(12623, 12643).contains(codeUnits[0]); }
bool get isEumjeol { assert(length == 1); return IntRange(44032, 55203).contains(codeUnits[0]); }
bool get _isHangeul { assert(length == 1); return isJaeum || isMoeum || isEumjeol; }
bool get isHangeul => split('').every((e) => e._isHangeul || e == ' ');
bool get hasHangeul => split('').any((char) => char.isHangeul);
bool get hasSeparatedJaeumOrMoeum => split('').map((e) => e.isJaeum || e.isMoeum).contains(true);
bool containsHangeul(String other) => puleossugi.contains(other.puleossugi);
}
5. 사용법
void main() {
print('고양이'.choseong); // ㄱㅇㅇ
print('고양이'.jungseong); // ㅗㅑㅣ
print('고양이'.jongseong); // ㅇ
print('바'.hasBatchim); // false
print('받'.hasBatchim); // true
print('고양이'.puleossugi); // ㄱㅗㅇㅑㅇㅇㅣ
print('ㄱㅗㅇㅑㅇㅇㅣ'.moassugi); // 고양이
print('김밥'.eunNeun); // 은
print('떡볶이'.eunNeun); // 는
print('민철'.eunNeunName); // 이는
print('김밥'.iGa); // 이
print('떡볶이'.iGa); // 가
print('민철'.iGaName); // 이가
print('김밥'.eulReul); // 을
print('떡볶이'.eulReul); // 를
print('민철'.eulReulName); // 이를
print('학교'.roEuro); // 로
print('병원'.roEuro); // 으로
print('김밥'.withEunNeun); // 김밥은
print('떡볶이'.withEunNeun); // 떡볶이는
print('민철'.withEunNeunName); // 민철이는
print('김밥'.withIGa); // 김밥이
print('떡볶이'.withIGa); // 떡볶이가
print('민철'.withIGaName); // 민철이가
print('김밥'.withEulReul); // 김밥을
print('떡볶이'.withEulReul); // 떡볶이를
print('민철'.withEulReulName); // 민철이를
print('학교'.withRoEuro); // 학교로
print('병원'.withRoEuro); // 병원으로
print('ㄱ'.isJaeum); // true
print('ㅏ'.isJaeum); // false
print('가'.isJaeum); // false
print('ㄱ'.isMoeum); // false
print('ㅏ'.isMoeum); // true
print('가'.isMoeum); // false
print('ㄱ'.isEumjeol); // false
print('ㅏ'.isEumjeol); // false
print('가'.isEumjeol); // true
print('ㄱ'.isHangeul); // true
print('ㅏ'.isHangeul); // true
print('가'.isHangeul); // true
print('강아지'.isHangeul); // true
print('강 아지'.isHangeul); // true
print('Dog'.isHangeul); // false
print('Dog강아지'.isHangeul); // false
print('안녕 World'.hasHangeul); // true
print('Hello World'.hasHangeul); // false
print('안녕ㅎ'.hasSeparatedJaeumOrMoeum); // true
print('안녕'.hasSeparatedJaeumOrMoeum); // false
print('세상'.containsHangeul('ㅅ')); // true
print('세상'.containsHangeul('세')); // true
print('세상'.containsHangeul('셋')); // true
print('세상'.containsHangeul('세ㅅ')); // true
print('세상'.containsHangeul('세사')); // true
print('세상'.containsHangeul('세상')); // true
print('세상'.contains('ㅅ')); // false
print('세상'.contains('세')); // true
print('세상'.contains('셋')); // false
print('세상'.contains('세ㅅ')); // false
print('세상'.contains('세사')); // false
print('세상'.contains('세상')); // true
}
관련 링크
'Develop > Flutter' 카테고리의 다른 글
[Flutter] 문자열 명명 형식 변환기 (String Case Converter) (0) | 2024.11.29 |
---|---|
[Flutter] Duration 값 간결히 나타내기 (0) | 2023.11.01 |
[Flutter] 순환 캐러셀 (Circular Carousel) 위젯 만들기 (0) | 2023.10.16 |
[Flutter] 토스 스타일의 Pressable 커스텀 위젯 만들기 (0) | 2023.09.23 |