자바/안드로이드 초성 검색
자바/안드로이드 초성 검색 2.0
에 이어 이번 글은 API를 디자인하면서 얻은 교훈에 관한 것이다.
버전 1
KoreanChar
최초의 버전에서는 KoreanChar
를 아래와 같이 쓰도록 했었다:
public class KoreanChar {
public KoreanChar(char c, boolean useCompatibilityJamo) {
_value = c;
_useCompatibilityJamo = useCompatibilityJamo;
}
}
...
KoreanChar c = new KoreanChar('한', false);
char choseong = c.getChoseong(); // 'ㅎ'
C# 버전을 비슷하게 옮기긴 했는데—작동도 잘 되는듯 보였지만—불행히도 이 짧은 코드에는 크고 작은 문제가 여러 개 있다. 첫번째는 KoreanChar()
에서 파라미터 c
가 한글 음절인지 여부를 체크하지 않는다는 것이다. 그 결과
KoreanChar c = new KoreanChar('A', false);
처럼 한글이 아닌 문자의 대입도 허용된다. 이 상태에서 getChoseong()
을 호출하면 프로그램이 뻗는다. 아…
KoreanChar
를 이렇게 만든 원래 목적은
Base b = ...;
if (b instanceof Derived) {
Derived d = (Derived) b;
...
처럼 자바에서 흔히 쓰는 용법을 흉내내기 위해서였다. 그렇지만 이것은 밖에서 instanceof
체크를 한번 했는데도 캐스팅 연산 중에 같은 체크를 한번 더 한다는 단점이 있다. 따라서
char c = ...;
if (KoreanChar.isHangulSyllable(c)) {
KoreanChar k = new KoreanChar(c, false);
} ...
처럼 용법은 흉내내되 KoreanChar()
속에서 불필요한 isHangulSyllable()
체크를 생략하면 그 만큼 속도가 빨라지지 않을까하는 것이었는데…결과적으론 절대 따라하지 마세요! 코드가 되고 말았다. 요즘 CPU들은 워낙 빨라서 이런 체크 한번 더 한다고 속도가 별로 느려지지도 않는다. 미미한 성능 향상보다 프로그램의 안정성이 훨씬 중요하다.
그렇지만 특이한 상황—타입 캐스팅을 1초에 몇백만번 이상 한다든지—에선 성능을 짜낼 수 있는 만큼 짜내야 할 수도 있다. 그럴 때를 위해 자바에서는 중복 체크를 제거하려면
try {
Derived d = (Derived) b;
...
} catch (ClassCastException ex) {
...
}
처럼 하면 되겠다. 그렇지만 이 방법은 코드가 너저분해지고 어떨 땐 예외 처리 비용이 훨씬 더 들 수도 있는 등 좋은 해결책이 아니다. C#에서는 as
캐스트 연산자가 따로 있어서 이러한 단점들을 커버해 준다:
Derived d = b as Derived;
if (d != null) {
...
}
일단 캐스팅을 시도하고, 실패하면 예외를 던지는 대신 null
을 리턴하는 방식이다. 중복 체크가 없어져 속도가 빨라지고, 코드도 깔끔해질 뿐 아니라 비싼 예외 처리 비용을 고려하지 않아도 된다.
두번째 단점은 KoreanChar
처럼 크기가 작고 빈번하게 쓰는 타입은 자바 가비지 컬렉터에게 상당한 부담이 될 수 있다는 것이다. 이는 C#과 달리 밸류 타입이 존재하지 않는 자바의 구조적 한계에 따른 결과다. 주소록 초성 검색의 경우 주소록 자체가 대개 크지 않고 이름도 길어봐야 6자 이내다. 그래서 한글 음절마다 KoreanChar
인스턴스를 하나씩 만들어도 대개는 문제가 없다. 그렇지만 대량의 텍스트 검색시에도 같은 방법을 적용하면 사용자가 초성을 입력하는 순간마다 매번 KoreanChar
인스턴스를 수천에서 수십만개 이상 만들어야 하는 사태가 일어날 수 있다. 이렇게 되면 가비지 컬렉션 오버헤드 뿐 아니라 메모리 낭비도 엄청날 것이다. 예를 들어 자바에서 int
는 프리미티브 타입이라 4바이트밖에 안들지만 Integer
타입은 32비트 플랫폼에서 16바이트나 차지한다. KoreanChar
인스턴스의 경우 3바이트 짜리 타입을 16바이트 공간에 할당해야 되니 전체 공간의 81.25%가 낭비되는 것이다.
이러한 메모리 문제의 해결책은 인스턴스 생성을 원천 금지하고 모든 연산을 static
메쏘드로만 처리하게 하거나, 또는 문자를 처리하는 클래스와 문자열을 처리하는 클래스를 하나의 클래스로 합치는 것 등이 있겠다. 그렇지만 후자의 방법은 재사용성이 떨어지고 복잡도가 높아지는 등 좋은 대안이 아니다. 버전 2에서는 static
방식으로 전환했다.
세번째는 파라미터 타입으로 boolean
을 썼을 때 생기는 가독성 문제다.
KoreanChar c = new KoreanChar('한', false);
이 라인만 보고 false
가 무엇을 뜻하는지 알 수 있겠는가? C#이라면
KoreanChar c = new KoreanChar('한', useCompatibilityJamo: false);
처럼 파라미터 이름의 명시가 가능하지만 불행히도 자바에는 이런 기능이 없다. 굳이 쓴다면
KoreanChar c = new KoreanChar('한', /*useCompatibilityJamo*/ false);
처럼 해도 되겠지만 너무 지저분해 보인다. 또 다른 방법은
enum HangulJamoType {
Default,
CompatibilityJamo
}
을 따로 만들고
KoreanChar c = new KoreanChar('한', HangulJamoType.CompatibilityJamo);
처럼 쓰는 것이다. boolean
파라미터 버전보다는 읽기 쉽지만 문제는 enum
타입을 이렇게 쓰는 것 자체가 꽤나 번거롭다는 것.
다행히도 이 세번째 문제는 KoreanChar
를 인스턴스 생성 불가 클래스로 전환하면서 저절로 없어졌다.
KoreanString
버전 1의 실제 검색은 KoreanString
클래스에서 담당했었다:
KoreanString s = new KoreanString("정우성");
assertTrue(s.equals("ㅈㅇㅅ"));
assertTrue(s.startsWith("ㅈㅇ"));
원래 이 코드는 C#에서
static class KoreanStringExtensions {
static bool StartsWith(this string s, string value, KoreanStringComparison options) {
...
}
}
...
"정우성".StartsWith("ㅈㅇ", KoreanStringComparison.MatchChoseongOnly);
처럼 확장 메쏘드로 만든 것을 자바로 옮긴 것이다. 확장 메쏘드 덕분에 C#에서는 string.StartsWith()
를 사용한 기존 코드에 KoreanStringComparison.MatchChoseongOnly
파라미터를 덧붙이기만 하면 간단히 초성 검색으로 전환된다. 그에 비해 확장 메쏘드가 없는 자바에선 별도의 인스턴스 클래스로 KoreanString
을 만들어야 했다. 그 결과 작동이 잘 되긴 했는데, 이번에는 또 다른 가독성 문제가 불거졌다.
s.startsWith("ㅈㅇ");
라는 문장 하나만 놓고 보면 s = "정우성"
일 때 결과가 true
가 되는 것이 뭔가 어색하기 때문이다. 이것은 마치
String s = "ABC";
boolean success = s.startsWith("ab");
라고 해놓고 success
가 true
가 되길 기대하는 것과 유사한 상황이다. 알파벳 비교시 대소문자 무시 옵션이 있는 것처럼 한글 초성 비교시에도 C# 버전처럼 별도의 옵션 파라미터가 있어야 코드 읽기가 쉽다. 그럴 경우
KoreanString s = new KoreanString("정우성");
boolean success = s.startsWith("ㅈㅇ", KoreanStringComparison.MatchChoseongOnly); // true
처럼 하면 될 것이다. 그렇지만 이 아이디어도 곧 폐기되었는데, 이유는 KoreanString
클래스 자체에 있다. 이 클래스는 단지 이름과 기능 몇 개만 비슷하게 구현했을 뿐 자바 String
클래스를 대체할 수 없기 때문이다. 예를 들어 자바에선 equals()
를 오버라이드했을 때 hashCode()
도 함께 오버라이드하도록 강력히 권고되고 있지만 KoreanString
은 그렇지 못하다든지, charAt()
도 없고 compareTo()
도 없고, 기타 이것도 안되고 저것도 안되고 등등.
버전 2
KoreanChar
버전 2에서는 아래처럼 인스턴스 생성을 원천 금지해 놓았다:
private KoreanChar() {
// Can never be instantiated.
}
그 결과 모든 종류의 메모리 문제에서 해방될 수 있게 되었다(!). 단점은 클래스의 사용 방법에 큰 제약이 생겼다는 것. 거의 비슷하게 작성된 C# 버전의 KoreanChar
는 훨씬 다양한 기능을 제공하면서 사용하기는 더 쉬운 것과 비교된다. 반면 자바 덕분에 예기치 않은 수확도 있었는데, 자바에서의 변경 사항을 C# 버전에 반영했더니 크기는 작아지고 속도는 빨라지면서 사용하기는 더 편해졌다. 이건 나중에 다시 다루도록 하겠다.
KoreanTextMatcher/KoreanTextMatch
기존의 잘못 만든 KoreanString
은 폐기하고 그 자리를 KoreanTextMatcher
/KoreanTextMatch
가 대신한다. 이 API는 특별히 .NET Regex 클래스와 용법이 유사하도록 설계되었다. 예를 들어 C# 버전에서의 유닛 테스트는
foreach (var type in new[] { typeof(KoreanTextMatcher), typeof(Regex) }) {
dynamic matcher = Activator.CreateInstance(type, pattern);
dynamic match = matcher.Match(text, startIndex);
Assert.That(match.Success, Is.EqualTo(expectedSuccess));
if (match.Success) {
Assert.That(match.Index, Is.EqualTo(expectedIndex));
Assert.That(match.Length, Is.EqualTo(expectedLength));
Assert.That(match.Length, Is.EqualTo(match.Value.Length));
Assert.That(text, Is.StringContaining(match.Value));
}
}
처럼 기본 용법에 대해 두 타입의 결과가 같음을 검증한다.
자바 버전은 메쏘드 시작 문자를 소문자로 바꾸고 Success
, Index
, Value
, Length
프로퍼티를 각각 success()
, index()
, value()
, length()
로 대체했다. 처음에는 getSuccess()
, getIndex()
, …처럼 지었다가 앞의 get
을 떼어버렸더니 보기가 훨씬 좋다. get
을 떼어도 상관없는 것은 KoreanTextMatch
클래스가 이뮤터블 타입이라 set
이 필요없기 때문이다. 이뮤터블 타입이라 멀티 쓰레드 프로그램에 사용할 때 동기화가 필요없는 것도 또 다른 장점.
결론
C#으로 만든 간단한 초성 검색 API를 자바로 재작성하는 동안 자바에 대해 많은 것을 배울 수 있었다. 거기다 더 수확인 것은 그 동안 잘 알고 있는 줄 알았던 C#에 대해서도 많은 것을 배울 수 있었다는 점이다. 여러분에게도 새로운 언어를 배울 때 다른 언어로 작성되어 있는 적당히 작은 크기의 프로그램을 재구현해볼 것을 추천한다. KoreanTextMatcher
는 작은 크기, 실용적인 쓸모, 면밀하게 작성된 코드, 다양한 방법을 통해 검증된 품질, 누구나 자유롭게 쓸 수 있는 라이선스 조건 등 여러 면에서 추천할 만하다.
교훈
- 어떠한 경우에도 파라미터 체크는 절대 생략하면 안된다.
- 미숙한 최적화는 만악의 근원이다.
- 자바에서 프리미티브 타입을 클래스로 정의하는 것은 가비지 컬렉션 부담을 가중시키고 상당한 양의 메모리 낭비도 초래할 가능성이 있다.
boolean
타입의 파라미터는 코드 읽기를 어렵게 만든다. 특히 자바에서는 더욱 더 그렇다.
- 클래스를 설계할 때 가능한한 이뮤터블 타입으로 만들 것.