2014년 6월 7일 토요일

자바/안드로이드 초성 검색

몇해전 예기치 않게 안드로이드 앱을 만들게 되었던 때의 일이다. 주소록을 검색할 때 초성으로도 가능하게—빨리—만들어 달라는 고객의 요구가 들어왔다. 예를 들어 “ㅈㅇ”을 치면 “정우성”과 “정엽”이 리스트에 뜨는 식으로 말이다1. 일단 한글 음절에서 초성을 뽑아내기는 쉬울 것 같았다. 마침 이전에 한글 관련 C# 라이브러리를 만들어 둔 게 있었기 때문이다. .cs 파일을 .java로 이름 바꾸고 초성 관련 코드만 남긴 다음 몇군데를 살짝 고쳤더니 기대했던대로 잘 돌아갔다. 아래는 그때 만든 KoreanChar 타입의 전체 소스다2:

package com.mogua.international;

public class KoreanChar {
    private final char _value;
    private final boolean _useCompatibilityJamo;

    static final int CHOSEONG_COUNT = 19;
    static final int JUNGSEONG_COUNT = 21;
    static final int JONGSEONG_COUNT = 28;
    static final int HANGUL_SYLLABLE_COUNT = CHOSEONG_COUNT * JUNGSEONG_COUNT * JONGSEONG_COUNT;
    static final int HANGUL_SYLLABLES_BASE = 0xAC00;
    static final int HANGUL_SYLLABLES_END = HANGUL_SYLLABLES_BASE + HANGUL_SYLLABLE_COUNT;

    static final int[] compatibilityChoseong = new int[] {
        0x3131, 0x3132, 0x3134, 0x3137, 0x3138, 0x3139, 0x3141, 0x3142, 0x3143, 0x3145,
        0x3146, 0x3147, 0x3148, 0x3149, 0x314A, 0x314B, 0x314C, 0x314D, 0x314E
    };

    public KoreanChar(char c, boolean useCompatibilityJamo) {
        _value = c;
        _useCompatibilityJamo = useCompatibilityJamo;
    }

    public static boolean isHangulChoseong(char c) {
        return 0x1100 <= c && c <= 0x1112;
    }

    public static boolean isHangulCompatibilityChoseong(char c) {
        return 0x3131 <= c && c <= 0x314E;
    }

    public static boolean isHangulSyllable(char c) {
        return HANGUL_SYLLABLES_BASE <= c && c < HANGUL_SYLLABLES_END;
    }

    public char getChoseong() {
        final int index = _value - HANGUL_SYLLABLES_BASE;
        final int choseongIndex = index / (JUNGSEONG_COUNT * JONGSEONG_COUNT);
        if (_useCompatibilityJamo)
            return (char)compatibilityChoseong[choseongIndex];
        else
            return (char)(choseongIndex + 0x1100);
    }
}

한글 자모/호환성 자모를 둘 다 지원하느라 생각보다 좀 길다. 유니코드에는 한글 자모 테이블이 몇군데 있는데 일반적으로 사용되는 것은 Hangul Jamo와 Hangul Compatibility Jamo다3.

실제 사용은 다음과 같이 하면 된다:

KoreanChar c = new KoreanChar('한', false);
char choseong = c.getChoseong(); // 'ㅎ'

호환성 자모를 사용하고 싶다면 false 대신 true를 넘겨주면 된다. 그런데—결과 자체는 C# 버전과 똑같은데—이 코드는 사이드 이펙트가 있다. 원래 C# 코드에 struct로 정의되어 있던 밸류 타입을 레퍼런스 타입인 자바 클래스로 바꾸면서 경우에 따라 가비지 컬렉션이 빈번히 발생할 가능성이 생긴 것이다. PC야 메모리가 워낙 크기 때문에 큰 지장은 없지만 당시엔 안드로이드 힙 사이즈가—아마도 지금 기억하기론—앱당 16~24MB 정도 밖에 안되던 시절이었다. 그래서 주소록에 수천명 이상 저장되어 있는 경우 이런 식으로 짜면 안될 것 같았는데, 실제로는 그냥 넘어갔다. 왜냐하면 주소록에 수천명을 저장하면 메모리 부족으로 앱이—실제로는 안드로이드 런타임이—먼저 뻗어 버릴 가능성이 훨씬 높기 때문이다. 당시는 큰 비트맵 3개만 화면에 띄워도 앱이 죽었고, 설치 가능한 앱의 개수가 10개도 채 안되는 안드로이드 폰이 상당수였었다4.

기반 타입을 만들었으니 이제는 초성 검색을 구현할 차례다. KoreanString이란 타입을 만들고 equals()startsWith()를 구현하면 검색단에서 쉽게 쓸 수 있을 것 같았다5. 그런데 여기서 또다른 문제가 발생했는데…그것은 바로 내가 자바와 이클립스를 제대로 쓸 줄 모른다는 사실이었다.

정상 작동 여부를 검증하기 위해 테스트 케이스를 만들어야 하는데, 자바 개발 경력 무에 JUnit을 사용법은커녕 깔아본 적도 없는 상태에서 며칠안에 납품을 완료해야 되는 것이다. 다른 미구현 기능도 아직 여러 개 남아있고…그래서 도저히 시간이 안될 것 같아 일단 C#으로 만들고 NUnit으로 테스트를 거친 다음 자바로 이식하는—괴이하지만 어쩔 수 없는—방법을 썼다. 아래는 그렇게 해서 만든 소스 중 초성 매칭 코드다:

package com.mogua.international;

public class KoreanString {
    private final String _value;

    public KoreanString(String s) {
        _value = s;
    }

    public boolean equals(String other) {
        if (other == null)
            return false;
        if (_value.length() != other.length())
            return false;

        for (int i = 0; i < _value.length(); i++) {
            final char c;
            if (KoreanChar.isHangulChoseong(other.charAt(i))
                && KoreanChar.isHangulSyllable(_value.charAt(i)))
                c = new KoreanChar(_value.charAt(i), false).getChoseong();
            else if (KoreanChar.isHangulCompatibilityChoseong(other.charAt(i))
                     && KoreanChar.isHangulSyllable(_value.charAt(i)))
                c = new KoreanChar(_value.charAt(i), true).getChoseong();
            else
                c = _value.charAt(i);

            if (c != other.charAt(i))
                return false;
        }
        return true;
    }

    public boolean startsWith(String prefix) {
        if (prefix == null)
            return false;
        if (_value.length() < prefix.length())
            return false;

        for (int i = 0; i < prefix.length(); i++) {
            final char c;
            if (KoreanChar.isHangulChoseong(prefix.charAt(i))
                && KoreanChar.isHangulSyllable(_value.charAt(i)))
                c = new KoreanChar(_value.charAt(i), false).getChoseong();
            else if (KoreanChar.isHangulCompatibilityChoseong(prefix.charAt(i))
                     && KoreanChar.isHangulSyllable(_value.charAt(i)))
                c = new KoreanChar(_value.charAt(i), true).getChoseong();
            else
                c = _value.charAt(i);

            if (c != prefix.charAt(i))
                return false;
        }
        return true;
    }
}

실제 사용은 아래처럼 한다:

KoreanString s = new KoreanString("정우성");
boolean success = s.startsWith("ㅈㅇ"); // true

이와 같은 코드를 바탕으로 구현한 초성 검색 기능은 다행히도 잘 동작되었다는 비하인드 스토리6.

그런데 그후로 몇년의 시간이 흘러 안드로이드 앱을 만들어야 할 일이 갑자기 또 생겼다. 여기서도 다시 한번 초성 검색 기능이 필요하게 되었는데, 가비지 컬렉션을 최소화함과 동시에 단순 불리언 매칭 대신 매칭된 문자열의 실제값과 위치까지 제공하는 새 API는 다음편에서 다루도록 하겠다. 위의 소스는 그냥 참고용으로만.


  1. 당시 고객의 요구는 이름을 정확히 매칭하거나 앞부분만 매칭하게 해달라는 것이었다. 따라서 “ㅈㅇ”을 쳐도 “레이디제인”은 매칭하면 안되었다.
  2. 원래의 C# 버전은 중성/종성 관련, 한글 조합, 연산자, 각종 override 메쏘드까지 합쳐져서 이 클래스보다 몇배 이상 크다.
  3. 여기 설명하긴 너무 기므로 나중에 따로 다루도록 하겠다.
  4. 이게 불과 몇년전이라니, 믿거나 말거나.
  5. 사용하긴 쉬웠지만 지나고 보니 이렇게 디자인한 것은 좋은 방법이 아니었다. 자세한 것은 나중 다른 글에.
  6. 앱 자체도 상당히 안정적이었다. 출시후 몇달간 수만명(?)이 썼는데도 발견된 버그가 10개도 안되었고 모든 버그를 24시간 내에 고쳤다.

댓글 없음:

댓글 쓰기

댓글을 입력하세요. 링크를 걸려면 <a href="">..</a> 태그를 쓰면 됩니다. <b>와 <i> 태그도 사용 가능합니다.

게시한지 14일이 지난 글에는 댓글이 등록되지 않습니다. 날짜를 반드시 확인해 주세요.