본문 바로가기

Develop/Flutter

[Flutter] 한글 Utility 기능

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 + 초성 $\times \ 588$ + 중성 $\times \ 28$ + 종성

따라서 음절의 유니코드 값을 통해 초⋅중⋅종성으로 분리하기 위해서는 다음의 작업이 필요하다.

  • 초성: $\lfloor 값 \ / \ 588 \rfloor$
  • 중성: $\lfloor (값 \ \% \ 588) \ / \ 28 \rfloor$
  • 종성: $값 \ \% \ 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);
}

하지만 단어 및 문장 단위로 확장된 모아쓰기는 상당히 복잡한 과정을 따른다.


아래는 모아쓰기의 원칙을 정리한 것이다.

모아쓰기 원칙

  1. 기본 구성 원칙
    • 초성(자음)은 음절의 시작에 위치하며, 한 음절에는 반드시 하나의 초성이 포함된다.
    • 중성(모음)은 초성과 반드시 결합해야 하며, 한 음절에는 반드시 하나의 중성이 포함된다.
    • 종성(자음)은 초성과 중성의 조합 이후에 올 수 있고, 존재하지 않을 수 있다.
    • 자모의 순서는 자음 → 모음 → 자음의 규칙을 따른다.
  2. 입력 순서 기반 원칙
    • 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
}

관련 링크

728x90