2014년 6월 24일 화요일

Java ArrayList<Integer> 대 C# List<int> 정렬 성능 비교

인터넷을 둘러 보다가 C++와 C#, Java, 그리고 Node.js 정렬 성능 비교란 흥미로운 글을 발견했다. 글에서는 크기가 1백만인 int[] 배열로 벤치마크를 하고 있었는데, 소스를 약간 고쳐서 자바 ArrayList<Integer>와 C# List<int> 간에는 얼마 만큼의 성능차가 나는지 알아 보았다.

자바

static void quicksort(ArrayList<Integer> a, int start, int end) {
    if (end - start < 2) {
      return;
    }
    int p = a.get(start + (end - start) / 2);
    int l = start;
    int r = end - 1;
    while (l <= r) {
      if (a.get(l) < p) {
        ++l;
        continue;
      }
      if (a.get(r) > p) {
        --r;
        continue;
      }
      int t = a.get(l);
      a.set(l, a.get(r));
      a.set(r, t);
      ++l;
      --r;
    }
    quicksort(a, start, r + 1);
    quicksort(a, r + 1, end);
}

quicksortThreeway()도 수정한 방법은 거의 같으므로 생략. 자바는 인덱서([])를 제공하지 않아서 단지 int[]ArrayList<Integer>로 바꿨을 뿐인데도 소스를 거의 절반은 고쳐야 했다.

C#

static void Quicksort(List<int> a, int start, int end)
{
    if (end - start < 2)
    {
        return;
    }
    var p = a[start + (end - start) / 2];
    var l = start;
    var r = end - 1;
    while (l <= r)
    {
        if (a[l] < p)
        {
            ++l;
            continue;
        }
        if (a[r] > p)
        {
            --r;
            continue;
        }
        var t = a[l];
        a[l] = a[r];
        a[r] = t;
        ++l;
        --r;
    }
    Quicksort(a, start, r + 1);
    Quicksort(a, r + 1, end);
}

C#은 한 줄만 고치면 되었다.

벤치마크 결과 및 분석

int 타입은 개당 4바이트라 백만개 짜리 리스트라고 해도 전체가 4MB 밖에 안된다. 캐시 영향을 최소화하기 위해 이 테스트에서는 원소 개수를 천만개로 늘렸다.

결과가 꽤 흥미로운데, 일단 “Built-in”이라고 되어 있는 int[] 벤치마크 항목에선 자바가 C#보다 25% 빠르다. 그 이유는 아마도 자바의 Timsort가 .NET의 Introsort보다 효율적이어서인 것 같다. .NET도 자바 버전을 그대로 이식한 C# 버전이 있으니 다음번엔 그걸로 비교해 보는 것도 좋겠다.

반면 자바 ArrayList<Integer>와 C# List<int>를 대상으로 한 나머지 두 테스트에서는 C# 쪽이 2-웨이는 170%, 3-웨이는 282% 빨랐다. 자바에서는 int 같은 타입의 원소를 리스트에 넣으려면 꼭 박싱(프리미티브 타입을 레퍼런스 타입으로 포장하는 것)을 거쳐야 한다. 그 결과 성능이 엄청나게 희생되는 걸 볼 수 있다. 그렇지만 C#과 자바 양쪽 모두 배열을 쓴 쪽이 압도적으로 빠른 것도 눈여겨 볼 점이다. 대량의 데이터 정렬은 리스트 대신 배열로 하는 게 유리하다는 결론.

2014년 6월 23일 월요일

자바/안드로이드 초성 검색: API 디자인

자바/안드로이드 초성 검색
자바/안드로이드 초성 검색 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 체크를 한번 했는데도 캐스팅 연산 중에 같은 체크를 한번 더 한다는 단점이 있다1. 따라서

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");

라고 해놓고 successtrue가 되길 기대하는 것과 유사한 상황이다. 알파벳 비교시 대소문자 무시 옵션이 있는 것처럼 한글 초성 비교시에도 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 타입의 파라미터는 코드 읽기를 어렵게 만든다. 특히 자바에서는 더욱 더 그렇다.
  • 클래스를 설계할 때 가능한한 이뮤터블 타입으로 만들 것.

  1. 똑똑한 JVM이라면 경우에 따라 중복 체크를 제거하는 최적화도 가능할 것 같은데, 확인 못해봄.

2014년 6월 21일 토요일

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

지난번 자바/안드로이드 초성 검색에서

가비지 컬렉션을 최소화함과 동시에 단순 불리언 매칭 대신 매칭된 문자열의 실제값과 위치까지 제공하는 새 API는 다음편에서 다루도록 하겠다.

고 했었는데, 다음편이 생각보다 너무 늘어져 버렸다. ^^ 일단 요즘 대세에 발맞춰 GitHub에 저장소를 만들었다. 소스는 필요없고 JAR 파일만 라이브러리로 쓸 분들은 여기를 눌러 받으면 된다. 아래는 안드로이드에서 초성 검색후 찾은 검색어만 하일라이트해주는 예:

KoreanTextMatcher

KoreanTextMatcher는 자바/안드로이드에서 한글 초성 매칭 검색을 가능하게 해주는 라이브러리다. 주요 기능과 특징은 다음과 같다:

  • 오픈 소스: 수정, 배포, 2차 저작 등 어떤 프로그램에도 자유롭게 사용 가능한 BSD 라이선스. 상용 프로그램에도 물론 아무 제약없이 사용할 수 있다(저작권 문구는 절대 지우지 마세요!).
  • 사용하기 쉬운 API
  • 100개 이상의 유닛 테스트 케이스, 99% 이상의 코드 커버리지 등을 통한 품질 검증
  • 가비지 컬렉션을 최소화하도록 세심하게 작성된 코드
  • 완전 쓰레드 세이프: 모든 타입이 이뮤터블
  • Unicode Hangul Jamo와 Unicode Hangul Compatibility Jamo 모두 지원
  • 단순 문자열 검색과 초성 매칭 검색 모두 지원
  • 한 문자열 내에서 여러 개의 패턴 검색
  • 정규식 앵커 ^$를 이용하여 패턴의 위치를 검색 대상 문자열의 시작과 끝으로 한정할 수 있음

사용법

KoreanTextMatcher는 KoreanChar, KoreanTextMatcher, KoreanTextMatch 세 클래스로 이루어져 있다.

KoreanChar

KoreanChar는 한글 문자에서 초성을 추출하는 데 쓰는 클래스다. isSyllable()은 입력 문자가 한글 음절인지, isCompatibilityChoseong()/isChoseong()은 입력 문자가 초성인지를 알려준다. getCompatibilityChoseong()/getChoseong()은 한글 음절에서 초성을 추출한다. 사용예는 다음과 같다:

char c = '한';
boolean isHangulChar = KoreanChar.isSyllable(c);
char compatChoseong = KoreanChar.getCompatibilityChoseong(c);
char choseong = KoreanChar.getChoseong(c);
boolean isCompatChoseong = KoreanChar.isCompatibilityChoseong(compatChoseong);
boolean isChoseong = KoreanChar.isChoseong(choseong);

getCompatibilityChoseong()/getChoseong()은 입력이 한글 음절이 아니면 '\0'을 리턴한다.

자음/모음으로만 구분되어 있는 Hangul Compatibility Jamo와 별개로 초성/중성/종성으로 구분되는 Hangul Jamo가 있지만 최소한 Windows와 Android에서는 후자를 쓰는 경우가 거의 없는 것 같다. 따라서 대부분은 xxxCompatibilityChoseong() API를 쓰면 될 것이다.

KoreanTextMatcher/KoreanTextMatch

한글 초성 매칭 검색을 가능케 해주는 클래스다. KoreanTextMatcher에 매칭 API가 들어 있고 매칭 결과가 KoreanTextMatch 인스턴스로 리턴된다. 기본적인 용법은 다음과 같다:

KoreanTextMatcher matcher = new KoreanTextMatcher(pattern);
KoreanTextMatch match = matcher.match(text);
if (match.success()) {
    System.out.format("%s: %s[%d]에서 시작, 길이 %d\n",
        match.value(), text, match.index(), match.length());
}

1회성으로 쓸 때에는 static 버전의 match()를 쓰는 쪽이 간편하다:

KoreanTextMatch match = KoreanTextMatcher.match(text, pattern);
if (match.success()) {
    System.out.format("%s: %s[%d]에서 시작, 길이 %d\n",
        match.value(), text, match.index(), match.length());
}

패턴이 문자열의 시작 또는 끝에 위치하는 경우만 매칭하려면 정규식에서 널리 쓰이는 ^$를 각각 쓰면 된다. 단순히 매칭 성공 여부만을 알려주는 isMatch()와 함께 쓰면 다음과 같다:

KoreanTextMatcher.isMatch("하늘", "^ㅎㄴ");  // true
KoreanTextMatcher.isMatch(" 하늘", "^ㅎㄴ"); // false

^$는 각각 String.startsWith()String.endsWith()를 대체하는 목적으로 쓸 수 있다. 둘을 한꺼번에 쓰면 String.equals()를 쓴 것과 동일한 효과를 얻을 수 있다.

KoreanTextMatcher.matches()는 문자열 내에서 매칭되는 패턴을 모두 찾아 List<KoreanTextMatch>로 돌려준다. 검색결과에서 검색어만 하일라이트할 필요가 있을 때 쓰면 편리하다. 아래는 맨 위에 소개한 안드로이드 데모 스크린샷의 실제 코드다:

String text = "바닥에 남은 차가운 껍질에 뜨거운 눈물을 부어";

TextView tv = new TextView(getBaseContext());
tv.setTextSize(25F);
tv.setText(text, TextView.BufferType.SPANNABLE);

Spannable spannedText = (Spannable) tv.getText();

KoreanTextMatcher matcher = new KoreanTextMatcher("ㄱㅇ");
for (KoreanTextMatch match : matcher.matches(text)) {
    spannedText.setSpan(new ForegroundColorSpan(Color.RED),
        match.index(), match.index() + match.length(),
        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}

matches() 역시 match()처럼 인스턴스 버전과 static 버전이 있으므로 필요에 따라 골라 쓰면 된다. 대개는 static 쪽이 쓰기 편하다.

다음편에서는…

이 API들도 지난번 버전과 마찬가지로 C#을 자바로 변환하여 만든 것이다. API 디자인과 작성 및 테스트, C#을 자바로 변환하는 과정 등에 관한 이야기는 다음편에 계속.

2014년 6월 14일 토요일

학실하게 읽어주세요

The Daily WTF이라고 종종 가는 사이트가 있는데, 웃음이 절로 나오는 코드, 개발 일화, 짤방들이 많다. 주말 기념으로 몇가지를 골라 보았다.



현재 작업을 취소할까요? "OK", "취소", "도움말"
(취소 누르면 되는 거죠?)



만료일자: 2014년 2월 30일
(옛날에 12월 32일이란 노래를 불렀던 별이란 가수가 생각난다.)



"CUPS 서버 에러: 성공"
...?!



배송 가능 지역: 아메리카 대륙, 유럽, 아시아, 호주
이 제품은 호주로 배송이 불가능합니다.



본 스티커 훼손시 A/S 불가



알 수 없는 에러.
이 정보가 도움이 되었습니까?



종료하시겠습니까?



로딩이 완료되었습니다. 진행하려면 취소를 누르세요.



UI 체계도를 가져 오는 동안 예기치 않은 오류 발생
이유: UI 체계도를 가져 오는 동안 오류가 발생해서



백업 실패 성공



라이선스 조항을 주의깊게 읽어 주세요.



잉크 카트리지를 교체하세요.
이 창 인쇄하기



오늘의 단어: 모름
뜻: 모름
사용예: 모름

보너스 트랙

이건 다른데서 가져왔다.



2014년 6월 13일 금요일

10억 달러 짜리 해결책

10억 달러 짜리 실수편에 이어 이번 글에서는 10억 달러 짜리 실수의 해결책에 관해 알아보기로 한다.

지난번 글에서는 비슷한 프로젝트를 C#과 F#으로 짰었는데 F#으로 짠 쪽은 null 체크를 27번 했던 반면 C#으로 짠 쪽은 3036번(오타 아님) 했었다는 사례를 소개했었다. 사실 F#에서 null 체크가 별로 필요치 않은 이유는…간단하다:

F# 타입의 변수/상수에는 null을 대입할 수 없다.

null 키워드가 있기는 있는데, C# 같은 다른 .NET 언어로 만든 타입을 쓸 때만 필요할 뿐 순수하게 F#으로 만든 타입에는 대입하지 못한다. null을 대입하지 못하니까 당연히 null 체크도 필요없는 것이다1. 어찌 보면 좀 컬럼버스의 달걀 같은 얘긴데…

처음 F#을 알았을 때는 어떻게 null 없이 프로그램을 짤 수 있다는 건지 이해가 가질 않았다. 왜냐하면 자바/C#으로 프로그램을 짤 땐 일단 인스턴스를 만들고 프로퍼티를 초기화하고, 또 다른 인스턴스를 만들고 프로퍼티를 초기화하고, … 처럼 하는 방식이 일반적이기 때문이다. 그 과정 중간중간에 인스턴스의 일부 상태가 null로 머물러 있는 것은 당연하고 불가피하다. 예를 들어 아래처럼 말이다:

class Pet {
    public string Name { get; set; }
}

class Person {
    public string Name { get; set; }
    public Pet Pet { get; set; }
}

Pet myCat = new Pet();     // 1
myCat.Name = "야옹이";     // 2
Person me = new Person();  // 3
me.Name = "준영";          // 4
me.Pet = myCat;            // 5

1에서 Pet 인스턴스를 생성하긴 했지만 Name 프로퍼티는 아직 null이다. 3에서 Person 인스턴스를 생성하긴 했지만 NamePet 프로퍼티는 아직 둘 다 null이다. 5까지 거치고 나야 비로소 모든 프로퍼티가 정상적인 값으로 초기화된다. 이상할 것 없어 보이는 이 코드를 아래처럼 F#으로 옮기면,

type Pet() =
    member val Name: string with get, set

type Person() =
    member val Name: string with get, set
    member val Pet: Pet with get, set

let myCat = Pet()
myCat.Name <- "야옹이"
let me = Person()
me.Name <- "준영"
me.Pet <- myCat

거의 똑같이(?) 생겼는데도 컴파일 에러가 뜬다! F#에서는 인스턴스를 생성할 때 프로퍼티가 초기화되지 않은 상태로 남아 있는 것을 허용하지 않기 때문이다. 이를 컴파일이 되게 고치면 아래와 같다:

type Pet() =
    member val Name: string = null with get, set

type Person() =
    member val Name: string = null with get, set
    member val Pet: Pet = Pet() with get, set

let myCat = Pet()
myCat.Name <- "야옹이"
let me = Person()
me.Name <- "준영"
me.Pet <- myCat

프로퍼티를 선언할 때 = 뒤에 디폴트값을 대입해 줌으로써 인스턴스가 어떤 경우든 완전히 초기화된 상태로 생성되는 것을 보장했다. 그런데 컴파일만 될 뿐이지 이 코드는 이전 C# 코드와 다를 것이 없다. 여전히 Name 프로퍼티에 null을 대입하고 있기 때문이다. F# 타입에는 null 대입이 불가능하다고 했는데 컴파일이 되는 이유는 string 타입이 실제로는 .NET의 System.String 타입이기 때문이다. 반면 순수 F# 타입인 Petnull 대입이 불가능하므로 member val Pet: Pet = null with get, set처럼 초기화하면 컴파일 에러가 발생한다.

그렇다면 string 타입에서도 null 대입을 없애려면? 디폴트값으로 ""같은 걸 대입하면 된다:

type Pet() =
    member val Name: string = "" with get, set

type Person() =
    member val Name: string = "" with get, set
    member val Pet: Pet = Pet() with get, set

let myCat = Pet()
myCat.Name <- "야옹이"
let me = Person()
me.Name <- "준영"
me.Pet <- myCat

그런데 이 방법도 꼼수이기는 매한가지다. 사용중에 NullReferenceException만 발생하지 않는다 뿐이지 ""Pet()같은 디폴트값은 실제론 쓸모가 없기 때문이다(이름이 ""인 사람이 이름이 ""인 동물을 가지고 있다니!?). 그래서 어차피 제대로 된 값으로 프로퍼티를 재초기화해야 하는데, 이렇게 하면 이전의 디폴트값 초기화는 헛수고가 된다.

null 대입도 없애고 중복 초기화도 막는 제대로 된 방법은 인스턴스를 생성할 때 프로퍼티를 함께 초기화하는 것이다:

type Pet(name) =
    member val Name: string = name with get, set

type Person(name, pet) =
    member val Name: string = name with get, set
    member val Pet: Pet = pet with get, set

let myCat = Pet("야옹이")
let me = Person("준영", myCat)

인스턴스의 생성과 초기화를 하나로 묶는 것은 프로그램이 비정상 상태에 놓이는 순간을 줄이는 아주 중요한 테크닉이다2.

null이 필요없지 않은 이유

사실 null을 모든 경우에 절대 쓰지 말아야 하는 것은 아니다. null이 문제가 되는 본질적인 이유는 변수가 초기화되지 않았음을 나타내는 값과 정확한 값을 결정할 수 없는 값이라는 중의적 의미로 쓰이기 때문이다. 이중 null이 불필요하고 위험한 경우는 전자, 즉 지난번 글에서 언급한 것처럼 아직 초기화되지 않은 변수를 메쏘드에 파라미터로 넘겨줄 때 등이다. 반면 메쏘드 실행 결과로 리턴하는 null은 후자에 속하며, 유용한 경우가 많이 있다.

예를 들어 영어 단어를 입력으로 받아 마지막 글자의 종성이 발음되는지 여부를 bool값으로 돌려주는 메쏘드를 만든다고 해보자. “computer” 같은 단어는 false를 리턴하면 되고, “pencil” 같은 단어는 true를 리턴하면 된다. 그런데 “net” 같은 단어는 “네트”로 읽으면 마지막 종성이 없고, “넷”으로 읽으면 종성이 있기 때문에 판단이 불가능하다. 이런 단어는 truefalse 대신 알 수 없음이란 값을 리턴하는 게 의미상으로 맞을 것이다. 이처럼 연산의 결과를 결정할 수 없는 상태를 리턴할 때는 null이 적합하다.

물론 연산의 결과를 결정할 수 없는 상태를 호출자에게 알려주는 표준화된 방법은 예외를 던지는 것이다. 그렇지만 그런 상황이 너무 자주 발생할 땐 예외가 성능을 떨어뜨리는 주원인이 되기도 한다. 유닛 테스트를 하면서 테스트 케이스를 수천개 이상 한번에 돌려본 분들은 아마 경험해 봤을 것이다. 성공율이 높을 땐 테스트가 빠르게 끝나는데 비해 실패율이 일정 이상 높아질 땐 어느 순간부터 컴퓨터가 엄청나게 버벅거리기 시작한다. 예외 처리에 드는 비용이 null 체크와는 비교도 안되게 크기 때문이다.

F#의 경우 null을 대체하는 목적으로 option 타입이라는 게 존재한다. 이 타입은 다른 F# 타입의 래퍼로 쓰이는데, 래핑한 인스턴스가 유효한 값을 가지고 있으면 Some이 되고, 그렇지 않으면 null에 상응하는 None이 된다.

재미있는 것은 애플의 최신 언어 Swift에서도 F#처럼 변수/상수에 nilnull의 또 다른 이름—을 대입하는 것을 제한해 놓았다는 사실이다. F#처럼 덜 초기화된 인스턴스의 생성도 금지되어 있고, F# option 타입과 같은 목적으로 이름도 비슷한 optional 타입이 존재한다. 차이점이라면 None 대신 nil을 그대로 쓰는 것과 문법이 C# nullable 타입과 유사하다는 정도.

그런데 여기까지 읽은 분들중에는 “저번 글에서는 메쏘드 리턴값으로 null을 리턴하면 안된다고 해놓고, 지금은 유용하다고 하고, 앞뒤가 안맞는 것 아니냐?”고 할 수도 있을 것 같다. 바로 그 점이 언어 차원에서 non-nullable/nullable 타입을 구별할 수 있는 구문을 제공해야 하는 이유다. 예를 들어 Swift에서는

let possibleNumber = "123"
let convertedNumber = possibleNumber.toInt()

처럼 문자열을 정수로 바꾸려고 할 때 toInt() 메쏘드가 optional 타입을 리턴한다. 따라서 이런 메쏘드들만 리턴값에 대해 nil 체크를 해주면 된다. optional 타입이 아닌 메쏘드는 nil을 절대 리턴하지 않기 때문에 당연히 리턴값을 nil 체크할 필요도 없다. 반면 자바나 C#은 많은 경우 호출한 메쏘드가 null을 리턴하는지 여부를 알 수 없다. 따라서 null 체크를 훨씬 빈번하게 해주어야 한다.

기존 언어에서의 해결책

언어 차원에서 null 대입을 금지시킬 방법이 없는 언어로는 딱히 해결책이 없는 것 같다. 굳이 꼽는다면 null 체크를 좀 더 편하게 하는 방법 정도가 있겠는데, 이것은 다음에 좀 더 자세히 소개하도록 하겠다.

실제로 C#에서는 “non-nullable” 레퍼런스 타입을 도입하려고 꽤 오랫동안 연구가 이루어졌었다. 한 예로 C# 계열의 실험적인 언어였던 Spec#에서는 변수를 선언할 때 타입명 뒤에 !를 붙여서 해당 인스턴스에 null을 대입할 수 없음을 명시하도록 했었다. 언뜻 괜찮은 아이디어 같은데…C#에는 채택되지 않았다. 아마도 !의 남용으로 소스가 지저분해지는 게 한가지 이유가 아닐까 싶다. 이미 기본 레퍼런스 타입을 “nullable”로 정한 다음에는 “non-nullable” 타입을 도입하기가 만만치 않다.

한가지 생각해 본 것은 변수를 선언할 때마다 일일히 붙일 것이 아니라 아예 타입을 만들 때

class Foo! {
    ...
}

Foo foo = null; // 컴파일 에러

처럼 이름 뒤에 !를 붙여서 null 대입을 금지시키는 것이다. null을 대입할 필요가 있는 일부 경우에만 기존 C# nullable 타입처럼 타입명 뒤에 ?를 붙인다:

class Foo! {
    ...
}

Foo? foo = null; // OK

이렇게 하고 나면 Swift와도 꽤 유사해진다(어차피 Swift도 C# 문법을 일부 차용한 것이니까). 따라서 나머지 용법은 Swift의 것을 그대로 가져오면 될 것 같다. ^^

그런데 이 타입을 도입할 경우 또 다른 문제가 생기는데—타입 파라미터의 한정어로 non-nullable 레퍼런스 타입을 표현할 방법이 현재로선 없다든지—얘기가 너무 길어지므로 여기선 생략.


  1. 사실은 배열을 만들 때처럼 메모리 공간이 null로 채워지는 경우가 있어서 F# 타입이라도 null 체크를 완전히 없앨 순 없다.
  2. 거꾸로 말하면 C/C++/C#/자바처럼 인스턴스의 비정상 상태를 허용하는 언어는 그만큼 프로그램이 비정상적으로 될 여지를 많이 남기게 된다고 할 수 있다.

2014년 6월 10일 화요일

10억 달러 짜리 실수

만약 여러분이 C/C++/C#/자바 프로그래머라면 이 질문의 답을 한번 구해 보자: “null이 필요한 이유가 뭐지?” 너무 당연한 질문인가? 정답은 아래 있으나 일단 30초간 생각할 시간.

.
.
.
.
.
.
.
.
.
.

null이 필요한 첫번째 이유는 변수가 아직 초기화되지 않았음을 표시하기 위해서다. 예를 들어

Object obj;

라는 문장이 있을 때 우리는 obj가 아직 초기화되지 않았다는 사실을 바로 알 수 있다. 그런데 이 문장 만으로는 우리가 일부러 초기화를 안하기로 한 건지 아니면 실수로 빼먹은 건지 헷갈릴 경우가 있다. 그래서

Object obj = null;

처럼 null을 대입해 주면 일부러 초기화를 안했다는 점을 확실히 할 수 있다.

그리고 null을 쓰면 얻을 수 있는 또 다른 장점은

Object obj;
DoSomethingWith(obj);

처럼 컴파일 에러가 나는 코드를 아래와 같이 고쳐서 컴파일되게 만들 수 있다는 점이다:

Object obj = null; 
DoSomethingWith(obj);

그래서 이 두가지를 종합해 보면 오 이거 꽤 쓸모가 있구나 싶은데…

…사실은 정반대다. 왜냐 하면 초기화되지 않은 변수는 의도적으로 그랬건 실수로 그랬건 어디에도 쓸모가 없기 때문이다. 쓸모 없는 상태의 변수를 쓸모 없다고 표시하는 것 조차 쓸모없는 행동이기는 마찬가지다. 두번째는—이게 치명적인 문제인데—컴파일이 되지 말아야 할 엉터리 코드를 억지로 컴파일되게 만들 수 있다! 덕분에 우리는 레퍼런스를 파라미터로 받는 모든 메쏘드에 대해 아래처럼 일일히 null 체크를 해주어야만 하게 되었다:

public TextMatch Match(string text, string pattern) {
    if (text == null)
        throw new ArgumentNullException("text");
    if (pattern == null)
        throw new ArgumentNullException("pattern");

또는 내부 메쏘드의 경우:

private TextRange TryGetTextRange(string text, int hintLength, ...) {
    Debug.Assert(text != null);

실제로 C/C++/C#/자바로 프로그램을 만들면 이런 단순 null 체크를 적게는 수십에서 많게는 수천개씩 도배해 주어야 한다1.


F# 언어의 창시자인 Don Syme이 소개한 사례에 의하면

one C# project had 3036 explicit null checks, where a functionally similar F# project had 27, a reduction of 112x in the total number of null checks.

기능적으로 비슷한 프로젝트였는데 C#으로 짠 쪽이 null 체크가 무려 112배나 많았다고 한다. 반면 버그는 F#으로 짠 쪽이 압도적으로 적었다.


이번에는 null을 리턴하는 메쏘드에 관해 생각해 보자:

Object GetObject() {
    ...
    return null;
}

완벽하게 컴파일되는 위의 메쏘드가 null을 리턴했는지 안했는지를 확인하기 위해 우리는 GetObject()를 호출하고 나서 반드시 리턴값을 체크해주어야 한다:

Object obj = GetObject();
if (obj == null) {
    // 에러 처리
}
...

여기서 null 체크를 깜빡 잊으면—실제로 아주 흔한 실수다—프로그램은 어느 순간 NullReferenceException을 토해 내면서 사망하고 말 것이다.

이러한 난처한 상황을 두고 1965년 null의 개념을 최초로 만들어 낸 Tony Hoare 할아버지는 이렇게 말했다:

I call it my billion-dollar mistake.

나는 그걸 나의 십억 달러 짜리 실수라고 부른다.

왜냐하면

This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.

이것은 셀 수 없는 에러, 취약점, 시스템 크래시로 이어졌고, 그 결과 지난 사십 년간 십억 달러에 달하는 고통과 손해를 초래했다.

그럼 애시당초 왜 이런 쓸데없고 위험한 기능을 만들었냐 하면

simply because it was so easy to implement.

그냥 구현하기가 너무 쉬워서.

...네?!

이 10억 달러 짜리 실수를 만회할 해결책은 다음편에 계속…


  1. null 체크를 해서 ArgumentNullException을 던지는 것이나 체크 안해서 NullReferenceException이 저절로 발생하는 것이나 같은 예외인데 결과적으로 무슨 차이냐고 하실 분이 있을 것 같다. 왜 반드시 이렇게 짜야 하는지는 다른 글에서 설명하겠다.

2014년 6월 9일 월요일

C# using vs. Java try-with-resources

C/C++1와 달리 C#이나 자바는 가비지 컬렉션이 있어서 사용한 메모리를 일일히 수동으로 반환해야 하는—지옥에 가까운—번거로움 없이도 프로그램을 쉽게 짤 수 있다2. 그렇지만 파일이나 네트워크 같은 외부 자원은 가비지 컬렉션 대상이 아니기 때문에 여전히 사용후 수동 반환 과정이 꼭 필요하다. 예를 들어 아래와 같이 말이다:

var fs = new FileStream("SomeFile", FileMode.Open, FileAccess.Read);
// ...
fs.Close();

그런데 이 코드는 문제가 있다. 파일을 열거나 입출력하는 과정에서 예외가 발생할 수 있기 때문이다. 그래서 대부분의 경우 아래처럼 try 블럭으로 전체를 감싸 주어야 한다:

FileStream fs = null;
try {
    fs = new FileStream("README", FileMode.Open, FileAccess.Read);
    // ...
} catch (...) {
    // ...
} finally {
    if (fs != null)
        fs.Close();
}

전체를 try-catch-finally로 감싸 놓으니 예외 처리와 자원 반환이 확실히 되어 좋긴 한데, 위에선 null을 대입하고 아래에선 다시 null 체크를 하는 등 너저분한 느낌이 든다. 그냥

try {
    var fs = new FileStream("README", FileMode.Open, FileAccess.Read);
    // ...
} catch (...) {
    // ...
} finally {
    fs.Close();
}

처럼 짜면 깔끔할 텐데 말이다. 그렇지만 실제로 이렇게 짜면 컴파일 에러가 난다. fs 변수가 try 블럭 안에서만 유효하기 때문이다. 그래서 try 블럭을 쓸 때는 항상 이전의 너저분한 코드처럼 짤 수 밖에 없다.

C# using

C#에서는 using문을 이용해서 이런 종류의 코드를 간단히 짤 수 있게 해준다. 위의 코드를 using을 써서 다시 짜면

try {
    using (var fs = new FileStream("README", FileMode.Open, FileAccess.Read)) {
        // ...
    }
} catch (...) {
    // ...
}

처럼 되는데, 이것을 풀어 쓰면

try {
    FileStream fs = new FileStream("README", FileMode.Open, FileAccess.Read);
    try {
        // ...
    } finally {
        fs.Dispose();
    }
} catch (...) {
    // ...
}

처럼 된다. C# 컴파일러는 IDisposable을 구현하는 타입에 대해 finally 절에서 Dispose()를 호출하는 코드를 자동 생성한다. 그래서 실행 흐름이 using 블럭을 벗어나는 순간 fs.Dispose()가 자동 호출되고, 이 Dispose() 안에서 fs.Close()가 호출되어 파일 스트림이 닫히는 원리다. 아울러 finally 절에서 null 체크를 할 필요도 없는 것도 눈여겨 볼 점이다. 내부 try 블럭 밖에서 실행되는 new FileStream(...)에서 예외가 발생하면 finally 절이 아예 실행되지 않기 때문이다.

Java try-with-resources

한편 자바 7에서는 C# using과 매우 비슷한 try-with-resources란 구문이 새로 생겼다. 이것을 이용하면

OutputStreamWriter writer = null;
try {
    writer = new OutputStreamWriter(new FileOutputStream(fileName), "utf-8");
    // ...
} catch (UnsupportedEncodingException ex) {
    // ...
} finally {
    if (writer != null) {
        try {
            writer.close();
        } catch (...) {
        }
    }
}

처럼 지저분한 코드가

try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(fileName), "utf-8")) {
    // ...
} catch (UnsupportedEncodingException ex) {
    // ...
}

처럼 깔끔해진다. 이 경우 자바 컴파일러는 AutoCloseable 또는 Closeable 인터페이스를 구현한 타입에 대해 finally 절에서 close()를 호출하는 코드를 생성한다.

그리고 C# using과 달리 자바의 try-with-resources는 catch 블럭과도 함께 쓰일 수 있다. 그 결과 자바 쪽이 더 깔끔하게 되었다.

미묘한 차이

그런데 문제는 굉장히 흡사해 보이는 이 두 구문이 실제로는 미묘하게 다르다는 점이다. 아래의 두 코드를 비교해 보자:

자바 버전:

public static void main(String[] args) {
    try (Foo f = new Foo()) {
        throw new Exception("try");
    } catch (Exception ex) {
        System.err.println(ex.getMessage());
    }
}

static class Foo implements AutoCloseable {
    @Override
    public void close() throws Exception {
        throw new Exception("Foo");
    }
}

C# 버전:

static void Main() {
    try {
        using (var foo = new Foo()) {
            throw new Exception("try");
        }
    } catch (Exception ex) {
        Console.WriteLine(ex.Message);
    }
}

class Foo : IDisposable {
    public void Dispose() {
        throw new Exception("Foo");
    }
}

두 언어간 사소한 문법적 차이 몇개를 제외하면 거의 동일한 코드다. 그래서 결과도 같을 것 같은데…실제로 실행해 보면 자바는

try

C#은

Foo

가 된다.

이렇게 차이가 나는 이유는 C#의 경우 항상 마지막에 발생한 예외를 밖의 catch 절에서 잡는 반면 자바는 내부 try 블럭과 생략된 finally 절에서 모두 예외가 발생할 경우 먼저 발생한 전자를 밖의 catch 절에서 잡기 때문이다. 버려진(?) 후자의 예외를 특별히 suppressed exception이라고 하고, Throwable.getSuppressed()를 호출해서 알아낼 수 있다. 그래서 위의 자바 코드는 옛날 스타일로 풀어 쓰면

public static void main(String[] args) {
    Throwable[] suppressedExceptions = null;
    try {
        Foo f = new Foo();
        try {
            throw new Exception("try block");
        } finally {
            try {
                f.close();
            } catch (Exception ex) {
                suppressedExceptions = new Throwable[1];
                suppressedExceptions[0] = ex;
            }
        }
    } catch (Exception ex) {
        if (suppressedExceptions != null)
            ex.setSuppressed(suppressedExceptions);
        System.err.println(ex.getMessage());
    }
}

static class Foo implements AutoCloseable {
    @Override
    public void close() throws Exception {
        throw new Exception("Foo");
    }
}

처럼 된다3. try-with-resources가 중첩된 상태에서 예외가 여러 개 발생할 경우 코드가 한층 더 복잡해질 것이다. 물론 이 모든 것은 컴파일러가 알아서 만들어 주므로 사람이 신경 쓸 필요는 없다.


  1. 현대적인 C++의 메모리 관리 기법은 C와는 사뭇 달라 메모리 릭 가능성이 크게 줄었지만 여전히 문제는 남아 있다.
  2. C#/자바에서도 메모리 릭이 발생할 수 있지만 일반적인 경우가 아니므로 여기서는 논외로 한다.
  3. 개념상으로 이렇다는 얘기. 실제로 setSuppressed() 메쏘드는 없다.