2013년 2월 28일 목요일

Python n-grouper

파이썬 zip 함수 설명을 보면 다음과 같은 문장이 있다.
This makes possible an idiom for clustering a data series into n-length groups using zip(*[iter(s)]*n).
주어진 리스트 s를 순서대로 n개씩 묶어서 반환하는 것을 zip(*[iter(s)]*n) 를 통해 할 수 있다는 의미다. 이는 다음과 같이 사용할 수 있다.
s = [1, 2, 3, 4, 5, 6]
print zip(*[iter(s)]*2)   # [(1, 2), (3, 4), (5, 6)]
이 짧은 n-grouper 코드가 흥미있는 파이썬 문법을 함축적으로 보여줄 수 있어 소개한다.

iter 함수

주어진 객체의 멤버를 순회할 수 있는 iterator 객체를 만들어 반환한다. 만약 리스트가 넘겨졌다면 순서대로 내부 멤버를 반환하는 iterator 객체를 만들어 반환한다. 이런 경우 간단히 iterator 객체는 첫 번째 멤버를 가리키는 index 변수가지고 iterator 객체가 참조할 때마다 현재 index 가 가리키는 값을 반환하며 동시에 index 가 증가한다고 볼 수 있다.
s = [1, 2, 3, 4, 5, 6]
for i in iter(s): 
  print i,   # 1 2 3 4 5 6

[...]*n

주어진 리스트를 n 번 반복해 붙여 반환한다. 만약 [1, 2, 3]*2 라면 [1, 2, 3, 1, 2, 3] 을 반환한다. 단 여기서 주의해야 할 점은 멤버가 반복될 때 얕은 복사가 (shallow copy) 수행된다는 점이다. 따라서 리스트 멤버가 객체 타입의 경우에는 반복되는 객체가 모두 동일한 객체임을 주의해야 한다.
a=[[1], [2]]*2
print a          # [[1], [2], [1], [2]]
a[0].append(3)
print a          # [[1, 3], [2], [1, 3], [2]]
이 경우 a 의 첫번째 값 뿐만 아니라 세번째 값도 변경되었음을 확인할 수 있다. 첫번째와 세번째가 같은 객체를 가리키기 때문이다.

따라서 [iter(s)]*2 로 나타내는 리스트 곱은 아래와 같이 수행된다. 여기서 zip 함수에 동일한 iterator 객체를 가리키는 i 가 두 개 넘겨졌음에 주의해야 한다.
i = iter(s)
print zip(*[i, i])

Unpacking argument *

파이썬의 함수 호출 문법의 재미있는 부분이다. *[...] 형태로 함수 인자를 넘겨주면 리스트 [...] 의 멤버가 펼쳐져 함수 개별 인자로 넘겨진다. zip(*[i, i]) 는 아래와 같이 실행된다.
print zip(i, i)   # == zip(*[i, i])

함수 인자의 평가 순서

여기서는 당장 중요하지 않지만 파이썬의 함수 인자 평가는 왼쪽에서 오른쪽으로 정해져 있다. 따라서 아래와 같은 예제도 결과를 예측할 수 있다.
f=lambda x, y: x.append(y) or x
p=lambda x, y: x + y
a=[]
print p(f(a, 1), f(a, 2))   # [1, 2, 1, 2]
이는 C 언어의 경우 함수 인자의 평가 순서가 정해져 있지 않은 것과 비교된다. 아래의 예제는 컴파일러 혹은 옵션에 따라 서로 다른 결과를 반환하다.
int i=0;
p(i++, i++);

zip 함수의 실행 중 인자 평가 순서

zip 함수가 실행하면서 넘겨 받은 인자를 참조하는데 (iterator 이므로 next() 메소드가 불리는 방식으로) 이 순서를 zip 함수가 보장해 준다. 왼쪽에서 오른쪽으로 평가하는 것으로 되어 있는데 때문에 zip(i, j) 는 아래와 같이 수행됨을 보장한다.
i = iter(s)
j = i
print zip(i, j)
# zip(i, j):
#   r = []
#   while end:
#     a = i.next()
#     b = j.next()
#     r.append((a, b))
#   return r
이렇게 zip의 동작 방식을 정해둔 것은 같은 객체를 여러 인자를 통해 넘겼을 때에도 결과를 일정하게 보장하기 위함으로 보인다. 때문에 zip(i, i) 와 같은 코드의 수행 결과가 항상 일정할 수 있음을 기대할 수 있다.

결론

zip 함수로 구현한 n-grouper 는 파이썬의 여러 문법적 요소를 함축적으로 담고 있어 흥미로운 코드 임에는 틀림없다. 다만 객체가 얕은 복사가 발생하는 것에 주의하면서 동시에 zip 함수의 수행 방식을 숙지하고 있지 않으면 이해가 잘 안되는 코드가 좋은 코드인지는 잘 모르겠다. 개인적으로는 아래와 같이 다소 장황해 보이지만 의도가 명확한 코드가 더 좋지 않을까 싶다.
s = [1, 2, 3, 4, 5, 6]
n = 2
print [s[i-n:i] for i in range(n, len(s)+1, n)]
# [(1, 2), (3, 4), (5, 6)]

2012년 12월 9일 일요일

파일명의 대소문자 구별

프로그램에서 문자열을 다룰 때 (특히 Key 역할을 하는 문자열의 경우) 가능한 대소문자를 구별하도록 하는 편이다. 이유는 다음과 같다.
  • 거의 모든 환경에서 대소문자를 구별하는 것이 문자열의 기본 동작이다. 예를 들어 문자열 타입의 두 변수를 == 로 비교하는 것은 일반적으로 대소문자를 구별한다. 코드가 간결하도록 하기 위해서는 대소문자를 구별하는 쪽이 유리하다.
  • 대소문자를 구별하는 경우와 그렇지 않은 경우가 혼재되어 있는 경우 인코딩 문제와 동일한 문제가 발생한다. (사실 대소문자를 구별/구별하지 않는 것 역시 인코딩이다) 어떤 변수를 사용할 때 이 변수가 대소문자를 구별하는지 아닌지를 사용할 때마다 신경써야 한다. 특히 이 문제는 잘못 사용했다고 문제가 늘 발생하는 것이 아니기 때문에 더 골치아프다.
  • 대소문자만 다른 두 개 이상의 문자열이 없다면 (Apple, apple 등의 경우) 대소문자를 구별하는 시스템이 대소문자를 구별하지 않는 시스템을 사용할 수는 있지만 반대로는 불가능하다.
  • 대소문자를 구별하는 쪽이 최적화에 유리하다. 특히 문자열키가 성능 크리티컬한 상황에서는 lower, upper 처리 혹은 stricmp 의 시간 소모 마저 아쉬울 때가 있다.
이런 이유로 통제가 가능하다면 대소문자를 구별하는 쪽이 시스템의 복잡도를 낮추는데 도움이 된다.

파일명의 대소문자 구별

윈도우의 경우 파일명의 대소문자를 구별하지 않는다. 하지만 대소문자를 보존해준다. (Case Preserving) 따라서 Apple 이라는 파일이 있을 때 apple 로도 파일을 열 수 있고 폴더 내 파일 목록을 얻으면 Apple 으로 원래 생성 때의 대소문자를 보존한 이름을 확인할 수 있다. 하지만 리눅스의 경우는 그렇지 않다. (ext) Apple 과 apple 은 엄밀히 다른 파일이며 두 개의 파일은 동시에 존재할 수 있다.

자 여기서 문제가 있다. 데이터 파일의 대소문자를 구별하고 싶은데 개발 환경이 윈도우라면 대소문자를 구별하는 시스템을 유지하기 어렵다. CreateFile 이 대소문자를 구별하지 않기 때문이다. 때문에 대부분의 윈도우 플랫폼의 프로젝트는 파일명에 대해 대소문자를 구별하지 않도록 작성한다. 또한 시스템의 투명성을 유지하기 위해 패킹된 파일 시스템을 만들더라도 내부적으로 대소문자를 구별하지 않도록 한다.

하지만 프로그램을 간단하게 유지하기 위해 대소문자를 구별하도록 하고 싶다. 어떻게 해결할 수 있을까?

윈도우 플랫폼에서 파일명의 대소문자 구별

파일에 접근할 때 파일명의 대소문자가 일치하는지 확인해서 일치하지 않으면 파일이 없다는 오류를 발생시키자. 이를 위해 아래와 같이 주어진 파일명의 원래 (대소문자가 올바른) 파일명을 반환하는 함수를 작성한다.
TCHAR* GetCaseCorrectedFileName(const TCHAR* directory, 
                                TCHAR* fileName) {
  TCHAR findPath[MAX_PATH];
  _tcscpy(findPath, directory);
  _tcscat(findPath, _T("\\"));
  _tcscat(findPath, fileName);

  WIN32_FIND_DATA fd;
  HANDLE fh = FindFirstFile(findPath, &fd);
  if (fh == INVALID_HANDLE_VALUE) {
    return NULL;
  }
  if (FindNextFile(fh, &fd) == 0 &&
      GetLastError() == ERROR_NO_MORE_FILES &&
      _tcslen(fileName) == _tcslen(fd.cFileName)) {
    _tcscpy(fileName, fd.cFileName);
    FindClose(fh);
    return fileName;
  }
  FindClose(fh);
  return NULL;
}
이 함수는 아래와 같이 사용할 수 있다. 파일명을 넣으면 대소문자가 올바르게 수정된 파일명을 얻을 수 있다. (다음을 실행하면 dbgview.exe -> DbgView.exe 가 출력된다)
void test() {
  TCHAR buf[] = _T("dbgview.exe");
  _tprintf(_T("%s -> "), buf);
  if (GetCaseCorrectedFileName(_T("C:\\SysinternalsSuite"), buf)) {
    _tprintf(_T("%s\n"), buf);
  } else {
    _tprintf(_T("?\n"), buf);
  }
}
간단하다. 이 함수를 파일을 열 때 먼저 실행해서 입력된 파일명과 대소문자가 수정된 파일명과 비교하기만 하면 된다. 간단하다! 이 방식을 사용하면 패킹 시스템에서 대소문자를 구별 하더라도 큰 문제가 없다. 이미 패킹되어 있지 않은 상태에서도 대소문자를 확인하기 때문에 패킹 했다고 추가로 파일명과 관련된 문제가 발생하지 않는다.

또 유저가 파일명을 툴 등에서 직접 입력하는 경우 위 함수를 사용해 파일명을 정리해 주면 입력에 의한 대소문자 불일치 문제를 줄일 수 있다.

XML 등 유저가 직접 파일명을 입력하는 경우 문제가 될 수 있는데 이 문제는 바로바로 파일명 오류로 드러나기 때문에 사실 큰 문제가 되지 않는다. 오타와 동일한 수준의 문제가 되고 파일명을 복사 & 붙여넣기를 권장하면 더더욱 문제가 되기 어렵다.

결론

대소문자는 가능하면 구별하도록 환경을 조성하면 환경의 복잡도를 낮추는데 좋다. 특히 파일명은 위에서 소개한 함수를 잘 사용하면 큰 어려움 없이 적용할 수 있다. 다만 문자열의 사용 패턴을 잘 살펴서 이 것이 적용될 수 없는 경우에도 억지로 넣게 되면 문제가 될 수 있으니 유의해야 한다. (가령 email 주소를 대소문자 구별하도록 하는 것은 메일 주소를 손으로 입력하는 환경에서는 곤란한 것처럼.)

GOLD Parser 로 파싱하기

대부분의 파서 생성기는 코드 생성을 기반으로 동작한다. yacc, bison, antlr 모두 그러한 형태를 기본 방법으로 사용하고 있다. 이 방향은 최적화를 고려하거나 문법 정의와 동작 파일로 부터 바로 결과물을 보고 싶다면 괜찮은 방법이다. 하지만 이 방법은 다음과 같은 단점을 가지고 있다.
  • 언어 문법 정의와 파싱 동작이 분리되어 있지 않아 다소 복잡하다.
  • 언어 문법을 기술하기 위해 정규식, BNF 뿐 아니라 파서 생성기 고유의 사용법에 익숙해져야 한다.
  • 코드 생성을 담당하는 파서 생성기에 빌드 종속이 생긴다.
이런 아쉬움을 pyparsing, boost::spirit 은 호스트 언어를 사용해서 문법을 정의하는 방식으로 풀었다. 때문에 문법 정의와 사용 부분이 언어가 동일하기 때문에 둘 사이의 껄끄러움이 덜하다. 하지만 특정 언어 환경에서만 사용할 수 있는 제약이 생기는 것은 어쩔 수 없는데 pyparsing 은 python 에서만 boost::spirit 은 C++ 에서만 사용할 수 있다. 만약 언어 파싱을 C++, Python 에서 동시에 사용해야 한다면 이는 문제가 된다.

이런 와중에 GOLD Parser 는 파싱하고자 하는 언어의 문법을 파싱할 수 있는 데이터를 익스포트 하고 이를 각자의 환경에서 사용하는 방식을 사용한다. 기본적인 파싱 데이터인 DFA 테이블과 LALR 테이블을 익스포트 하고 이후 파싱 작업은 각 언어 마다 개별적으로 구현한 파싱 엔진에 위임한다. 파싱 엔진은 여러 언어마다 개별 개발자들이 만들어 두었다.

언어 문법과 파싱에서의 사용을 분리했는데 나름 괜찮은 시도인 것 같아 사용해보았다. (이런 접근은 GOLD Parser 말고는 그다지 많이 사용되고 있지 않다. SableCC 가 부분적으로 이 기능을 지원한다.)

GOLD Parser IDE

골드 파서는 언어를 정의할 수 있도록 문법과 이 문법을 사용해 언어 문법을 작성할 수 있는 IDE 를 제공한다. 이 IDE 를 통해 파싱할 언어의 문법을 작성하는데 IDE 라서 Syntax Highlighting 부터 테스트 작성 및 (그래도 친절한) 문법 오류 안내를 지원한다. 보통의 파서 생성기는 이러한 도구가 없거나 부실해 그 환경에 익숙해지지 않으면 사용하기 어렵다.


문법을 작성하면서 작성된 문법을 테스트 할 수 있다. 테스트 하고자 하는 문자열을 입력하고 이 문자열이 올바르게 파싱되는지 확인할 수 있다. 파싱 결과를 파싱 이벤트 혹은 파스 트리를 통해 확인해 볼 수 있다.

문법 만들기

골드 파서로 파싱할 언어의 문법을 작성해보자. 예를 위해 간단한 계산기 파서를 만들어 보자. 이 언어는 정수를 대상으로 +, -, *, / 를 처리할 수 있고 괄호를 인식한다. 이 언어는 아래와 같은 입력을 처리할 수 있다.
-(10+20+35)*4/20
골드 파서는 보통의 파서 생성기와 같이 토큰 정의에 정규식을 문법 정의이 DNF 를 사용한다. 먼저 계산기 토큰을 정의하자. 계산기를 이루는 가장 주요한 토큰은 숫자다. 0 을 포함한 양의 정수를 정의하자. (음의 정수는 1항 연산자 - 로 처리한다.)
{Digi9} = {Digit} - ['0']
Num     = '0' | {Digi9}{Digit}*
토큰을 정의했으면 토큰을 가지고 BNF 를 사용해 문법을 정의하자. 골드 파서는 단순한 토큰은 문법 정의 때 따로 정의 없이 바로 사용할 수 있다. (+, - 등) 아래 문법은 우선순위를 고려한 애매함 없는 계산기 언어의 문법이다.
<E>   ::= <E> '+' <M> 
       |  <E> '-' <M> 
       |  <M> 
<M>   ::= <M> '*' <N> 
       |  <M> '/' <N> 
       |  <N> 
<N>   ::= '-' <V> 
       |  <V> 
<V>   ::= Num
       |  '(' <E> ')'
이렇게 토큰과 문법을 정의하고 나면 이 결과를 operator.egt 파일로 저장한다. 이 파일에는 언어 문법을 구성하는 토큰과 생성 규식이 들어있고 파싱에 중요한 역할을 하는 DFA 테이블과 LALR 테이블이 계산되어 포함된다.

위 계산기 토큰의 DFA 는 아래와 같은 형태를 가진다.


계산기 문법의 LALR 테이블은 아래와 같다. (S:Shift, R:Reduce, G:Goto, A:Accept)


자 이제 문법 데이터를 만들었으니 파싱을 해보자.

파싱

주어진 테이블로 어떻게 파싱하는지는 엔진 마다 다르다. 따라서 엔진은 동작하는 언어 뿐 아니라 문법 데이터를 사용하는 방식까지 결정한다. 여기서는 파이썬 엔진인 PyAuParser 를 사용하기로 하자.

라이브러리를 import 하고 문법 파일인 operator.egt 를 읽어 주어진 식을 파싱한다. 파싱 중간 결과를 그대로 출력하는 예는 다음과 같다.
g = pyauparser.Grammar.load_file("operator.egt")

def callback(ret, arg):
    print "{0}\t{1}".format(ret, arg)
pyauparser.parse_string(g, "-(10+20+35)*4/20", handler=callback)
이를 실행하면 다음과 같이 LALR 파싱 결과가 출력되는 것을 볼 수 있다.
SHIFT   S=1, T=- '-'
SHIFT   S=2, T=( '('
SHIFT   S=3, T=Num '10'
REDUCE  P=8, H=(S=7, P=<V> ::= Num), Hs=[(S=3, T=Num '10')]
REDUCE  P=7, H=(S=6, P=<N> ::= <V>), Hs=[(S=7, P=<V> ::= Num)]
REDUCE  P=5, H=(S=5, P=<M> ::= <N>), Hs=[(S=6, P=<N> ::= <V>)]
REDUCE  P=2, H=(S=9, P=<E> ::= <M>), Hs=[(S=5, P=<M> ::= <N>)]
...
LALR 는 상향식 파서이기 때문에 Reduce 이벤트가 파스 트리 (Parse Tree) 의 말단에서 루트 방향으로 발생한다. -(10+20 까지 파싱되었을 때의 파스 트리를 구축하면 아래와 같다. #번호로 표시된 것이 Reduce 이벤트 발생 순서다. 만약 이러한 post-order 트리 탐색이 충분하다면 이 이벤트를 잡아서 처리할 수 있다.


계산기는 이 순서로 충분히 계산해 낼 수 있으므로 아래와 같이 실제 계산을 하는 핸들러를 넣어 계산기를 구현할 수 있다.
h = pyauparser.ProductionHandler({
    '<E> ::= <E> + <M>': lambda c: c[0] + c[2],
    '<E> ::= <E> - <M>': lambda c: c[0] - c[2],
    '<E> ::= <M>':       lambda c: c[0],
    '<M> ::= <M> * <N>': lambda c: c[0] * c[2],
    '<M> ::= <M> / <N>': lambda c: c[0] / c[2],
    '<M> ::= <N>':       lambda c: c[0],
    '<N> ::= - <V>':     lambda c: -c[1],
    '<N> ::= <V>':       lambda c: c[0],
    '<V> ::= Num':       lambda c: int(c[0].lexeme),
    '<V> ::= ( <E> )':   lambda c: c[1],
}, g)

pyauparser.parse_string(g, "-(10+20+35)*4/20", handler=h)
print "Result = {0}".format(h.result)
이벤트를 사용하는 것은 파스 트리를 구축하는 비용이 없어 좋다. 하지만 XML 의 DOM 과 같이 파싱 결과를 담고 있는 트리를 가지고 작업을 하는 것이 좀 더 편하고 강력하다. (만약 입력식 최적화를 염두하고 있다면 이 방법을 사용해야 한다.) 계산식을 파싱해 파스 트리를 구성하자.
tree = pyauparser.parse_string_to_tree(g, "-(10+20+35)*4/20")
이 계산식은 아래와 같은 파스 트리를 만들어 낸다. 파스 트리를 보면 어떻게 계산을 하면 좋을지 상상 할 수 있다. :)


아래와 같이 구축된 트리를 순회하면서 결과를 계산할 수 있다.
def evaluate(node):
    r = lambda s: g.get_production(s).index
    h = {
        r('<E> ::= <E> + <M>'): lambda c: e(c[0]) + e(c[2]),
        r('<E> ::= <E> - <M>'): lambda c: e(c[0]) - e(c[2]),
        r('<E> ::= <M>'):       lambda c: e(c[0]),
        r('<M> ::= <M> * <N>'): lambda c: e(c[0]) * e(c[2]),
        r('<M> ::= <M> / <N>'): lambda c: e(c[0]) / e(c[2]),
        r('<M> ::= <N>'):       lambda c: e(c[0]),
        r('<N> ::= - <V>'):     lambda c: -e(c[1]),
        r('<N> ::= <V>'):       lambda c: e(c[0]),
        r('<V> ::= Num'):       lambda c: int(c[0].lexeme),
        r('<V> ::= ( <E> )'):   lambda c: e(c[1]),
    }
    def e(node):
        handler = h[node.production.index]
        return handler(node.childs)
    return e(node)

result = evaluate(tree)
print "Result = {0}".format(result)
이 파스 트리로도 계산은 충분히 가능하지만 복잡한 문법의 입력을 처리할 때는 불필요한 노드와 토큰을 제거한 추상 구문 트리 (Abstract Syntax Tree) 를 구축하는 것이 유리하다. PyAuParser 의 SimplifiedTree 기능으로 아래와 같이 AST 를 만들 수 있다.


파싱에는 필요하지만 실제 계산에는 필요하지 않은 토큰이 제거되었고 (+, -, ...)  불필요한 노드도 제거되었다. 뿐만 아니라 10 + 20 + 35 을 구성하는 노드가 리스트 형태로 펼쳐진 것을 볼 수 있다.

결론

골드 파서는 DFA, LALR 를 사용하기 때문에 표현력이 좋다. 따라서 웬만한 언어는 파싱 가능하다. 또한 문법과 엔진을 분리해서 원하는 환경의 엔진을 구할 수 있다면 1개의 문법 파일로 여러 환경에서 파싱을 할 수 있다.

다만 한계가 있는데
  • DFA, LALR 을 벗어나는 언어 처리가 어렵거나 혹은 불가능하다. 사실 대부분의 인기있는 프로그래밍 언어는 문맥 자유 문법에서 벗어난다. 예를 들어 ANSI-C 나 Python 만 해도 이 틀에서 벗아나는데 이를 처리하기 위해 보통의 파서 생성기는 우회 처리 방법을 지원한다. 하지만 골드 파서는 그럴 수가 없다. (애초에 언어의 문법과 처리를 분리했기 때문에)
  • 엔진의 공통 API 가 없다. 레퍼런스로 제공되는 VB.NET 파서 엔진이 제공되긴 하나 이를 강제하지 않고 있다. 따라서 각 언어 환경의 엔진을 만드는 개발자 마다 다른 API 구성이 조금씩 다르다. 때문에 여러 언어를 동시에 지원할 때는 코드 작업이 늘어날 수 있다. 하지만 DFA, LALR 을 사용하는 점은 다르지 않아 Reduce 이벤트에 의존하는 구성은 동일하기 때문에 크게 문제가 되지는 않는다. 하지만 파스 트리 구축이나 AST 변환은 지원 여부부터 사용 방법까지 모두 다르기 때문에 여러 언어 환경에서 일관성 있는 사용은 사실상 어렵다.
하지만 사용하기 좋은 IDE 를 지원하고 기본 기능에만 집중했기 때문에 나름대로 (!) 사용해볼만 하다. (위에서 예로든 PyAuParser 도 골드 파서를 가지고 놀다가 만든 직접 만들어 본 엔진이다.)

2012년 11월 10일 토요일

C++11: Variadic 삼형제

C++11 에 C99 의 Variadic macro 가 포함되고 새롭게 Variadic template 이 추가되어 C++11 의 Variadic 은 전통의 Variadic function 까지 포함해 총 세 가지가 되었다. 각각의 사용법, 특징을 알아보자.

Variadic function (가변 인자 함수)

가변 인자 함수는 아래와 같이 사용한다. va_start 로 가변 인자 커서를 만들고 그 커서를 사용해 va_arg 로 값 참조 및 커서 이동을 수행한다. (일반적으로 가변 인자는 단순한 스택 접근으로 구현되어 있다.)
void write(int count, ...) {
  va_list args;
  va_start(args, count);
  while (count-- > 0)
    puts(va_arg(args, const char *));
  va_end(args);
}
인자 순회가 간단한데 반해 받은 가변 인자를 그대로 다른 가변 인자 함수에게 넘길 수 있는 방법은 없다. 하지만 아래처럼 va_list 타입의 인자는 넘길 수 있다.
int vprintf(const char* fmt, va_list arg);
void error(const char* fmt, ...) {
  puts("ERR:");
  va_list args;
  va_start(args, fmt);
  vprintf(fmt, args); 
  va_end(args);
}
때문에 C 표준 가변 인자 함수들은 printf 와 vprintf 와 같이 ... 를 인자로 하는 함수와 va_list 를 인자로 하는 함수 이렇게 두 벌이 제공된다.

가변 인자는 최소 1개 이상의 고정 인자가 필요하다. va_start 에 마지막 고정 인자를 넘겨야 하기 때문이다. va_start 는 이 고정 인자가 스택의 어느 위치에 있는지를 확인 하고 그 다음 부터 가변 인자가 있다고 판단하기 때문에 고정 인자가 필요하다.

가변 인자를 넘겨 받은 함수에서 인자 개수를 알 방법이 없다. 때문에 위 예제처럼 count 를 넘기거나 printf 처럼 포맷 문자열에서 추정하거나 sentinel 값을 사용한다. 하지만 이 방법 모두 올바르게 사용되지 않았을 때 알아낼 방법이 없다. 때문에 종종 버그의 원인이 된다.
write_a(3, "a", "b", "c");            // 인자 개수를 넘김
write_b("%s %s %s", "a", "b", "c");   // 포맷 문자열로 추정
write_c("a", "b", "c", NULL);         // Sentinel 값 사용
가변 인자 함수는 넘겨 받은 인자의 타입을 알 수 있는 방법이 없다. printf("%s", 1) 과 같이 형식 문자열과 실제 인자가 일치하지 않으면 크래시가 될 수도 있다. 다행히 gcc 는 컴파일 시점에 형식 문자열과 인자가 일치하는지 확인하는 기능이 있다. 컴파일 옵션에 -Wformat 등을 넣으면 이 기능을 사용할 수 있다.
printf("%s", 10);   // warning: format '%s' expects argument of ...
printf("%d");       // warning: format '%d' expects a matching ...
printf("%d", 1, 2); // warning: too many arguments for format ...
C++11 에는 다음과 같이 std::initializer_list를 사용해 가변 인자 함수를 흉내낼 수 있다. 
void write(std::initializer_list<const char*> strs) {
  for (auto s : strs)
    std::cout << s << std::endl;
}
write({"a", "b", "c"});
이 방법은 인자들 모두 같은 타입을 가져야 하는 제약이 있지만 인자 개수, 타입 제한이 가능해 좀 더 안전하고 편리하게 사용할 수 있다.

Variadic macro (가변 인자 매크로)

C99 이전의 C/C++ 에서는 매크로에서 가변 인자를 사용할 수 있는 방법이 없었다. 때문에 가변인자가 필요한 경우에는 인자 개수별로 매크로를 만드는 방법을 사용했다.
#define PRINT_1(fmt,a) printf((fmt), (a))
#define PRINT_2(fmt,a,b) printf((fmt), (a), (b))
#define PRINT_3(fmt,a,b,c) printf((fmt), (a), (b), (c))
작성하기도 사용하기도 불편한 문제를 해결하기 위해 C99 에서 가변 인자 매크로가 추가되었다. (C++11 에도 추가되었다)
#define PRINT(fmt,...) printf(fmt, __VA_ARGS__)
가변 인자 매크로는 보통 받은 가변 인자를 그대로 넘기는 용도로 사용한다.
#define ERROR(fmt,...) \
  puts("ERR:"); printf(fmt, __VA_ARGS__)
가변 인자 함수로는 어려웠던 인자 넘기기가 쉽게 된다. 여기서 C99 에서 가변 인자 매크로를 추가한 목적을 알 수 있다.

매크로의 가변 인자의 개수가 0 이 되는 것은 곤란할 수 있다. 위 ERROR 의 경우 printf 가 전개 될 때 , 가 짝이 안맞기 때문인데 이를 해결하기 위해 gcc 는 문법을 확장해 빈 인자일 때 옆 콤마를 제거하는 ## 를 추가했다. vc++ 는 그냥 빈 인자일 때 옆에 있는 콤마를 무조건 제거한다. 둘 다 표준은 아니다.

가변 인자를 포워딩 하는 것은 간단하나 그 인자를 순회하는 것은 간단하지 않다. 우선 인자 개수는 아래와 같이 계산해 낼 수 있다.
#define VA_NUM_ARGS(...) VA_NUM_ARGS_IMPL_((__VA_ARGS__,5,4,3,2,1))
#define VA_NUM_ARGS_IMPL_(tuple) VA_NUM_ARGS_IMPL tuple
#define VA_NUM_ARGS_IMPL(_1,_2,_3,_4,_5,N,...) N

#define TEST(...) printf("%d\n", VA_NUM_ARGS(__VA_ARGS__));
TEST("a", "b", "c"); // 3
개수 세기부터 간단하지 않으니 그 이상이 필요하다면 boost preprocessor 를 사용하자. 아래는 인자의 개수와 두 번째 인자를 얻어내는 예다.
#define TESTB(...) printf("%d %s\n", \
  BOOST_PP_VARIADIC_SIZE(__VA_ARGS__), \
  BOOST_PP_VARIADIC_ELEM(1, __VA_ARGS__));
TESTB("a", "b", "c"); // 3 b

Variadic template (가변 인자 템플릿)

매크로와 마찬가지로 기존 템플릿도 가변 인자를 받지 못했다. 때문에 가변 인자가 필요한 템플릿의 경우 번거로운 작업이 필요했다.
template <typename T1> 
void print(T1 a) { 
  cout << a << endl;
}
template <typename T1, typename T2> 
void print(T1 a, T2 b) { 
  cout << a << endl;
  cout << b << endl;
}
//...
print(1, "a"); // 1 a
반복적인 코드 작업이 번거롭기 때문에 보통 매크로를 사용해 문제를 우회하는데 복잡한 케이스는 아래와 같이 boost.preprocessor 를 사용해 해결할 수 있다.
#define PRINT_BODY(Z,N,_) \
  cout << s##N << endl;
#define PRINT_FUNC(Z,N,_) \
  template<BOOST_PP_ENUM_PARAMS(N, typename T)> \
  void print(BOOST_PP_ENUM_BINARY_PARAMS(N, T, s)) { \
 BOOST_PP_REPEAT(N, PRINT_BODY, _); \
  }
BOOST_PP_REPEAT_FROM_TO(1, 10, PRINT_FUNC, 0)

print(1, "a"); // 1 a
하지만 이런 코드는 읽기에 썩 좋지 않은데이런 어려움을 해결하기 위해 C++11 는 가변 인자 템플릿을 추가했다. 가변 인자 선언은 아래와 같이 한다. (... 위치에 주의한다)
template<typename... Args>
void print(Args... args) {
  //...
}
위 print 예제를 가변 인자 템플릿으로 구현하면 다음과 같다. 인자 순회를 재귀를 사용해 구현했다. 코드에 있는 args... 는 arg1, arg2, ..., argN 과 같이 확장된다.
void print(const char* s) {
  cout << s << endl;
}
template<typename T, typename... Args>
void print(T s, Args... args) {
  cout << s << endl;
  print(args...);
}
재귀가 아닌 방식으로는 다음과 같이 구현할 수 있다. 단 아래 코드는 출력이 반대로 된다. (pass 에 넘겨지는 인자의 평가 순서가 오른쪽에서 왼쪽이기 때문이다) 만약 순서가 반대로 되어도 관계 없다면 아래와 같은 형식을 사용해도 좋다. 아래 코드에서 f(args)... 는 f(arg1), f(arg2), ..., f(argN) 과 같이 확장된다.
template<typename... Args> inline void pass(Args&&...) {}

template<typename... Args>
void print(Args... args) {
  auto f = [](const char* s) { cout << s << endl; return 1; };
  pass( f(args)... );
}

print("a", "b", "c"); // c b a
가변인자의 개수는 sizeof... 로 간단히 확인할 수 있다.
template<typename... Args>
void count(Args... args) {
  cout << sizeof...(args) << endl;
}
count("a", "b", "c", "d"); // 4

2012년 11월 8일 목요일

C++11: 우측값 참조과 이동 생성자

C++11 과 함께 등장한 많은 기능들은 대부분 간단하거나 직관적인 기능이라 이해하기 쉽다. 그런데 몇몇 기능은 이해하기 까다로운데 그 중 제일을 뽑으라면 우측값 참조와 이동 생성자를 들겠다. 간단한 기능이 복잡한 문법과 이해를 요구하기 때문이다.

시작

15년전 C++ 를 처음 배웠을 때 (신경써서) 처음으로 만든 클래스는 문자열 클래스였다. C 의 번거로운 문자열 제어 작업을 쉽게 해보고 싶기도 했고 복사 생성자, 연산자 오버로딩 등 C++ 의 기능을 충분히 사용해 볼 수 있었기 때문이었다.

다음과 같이 작성된 C 의 문자열 연결 작업은
char* s = (char*)malloc(strlen(a) + strlen(b) + 1);
strcpy(s, a);
strcat(s, b);
C++ 의 문자열 클래스 String 를 사용하면 아래와 같이 산뜻하게 기술할 수 있다.
String s = a + b;
이 String 클래스는 아래와 같이 문자열 길이와 버퍼를 가지는 형태로 구현되었다.
class String {
  size_t len;
  char* buf;
public:
  String() : len(0), buf(nullptr) {}
  ~String() {
    delete [] buf;
  }
  String(const String& s) {               // 복사 생성자
    len = s.len;
    buf = len > 0 ? new char[len] : nullptr;
    memcpy(buf, o.buf, len * sizeof(char));
  }
  String operator = (const String& s) {   // 대입 연산자
    delete [] buf;
    len = s.len;
    buf = len > 0 ? new char[len] : nullptr;
    memcpy(buf, s.buf, len * sizeof(char));
  }
  //...
};
하지만 문자열 클래스를 만들면서 고양된 기분은 operator + 함수를 구현하면서 가라앉았는데 그 operator + 는 아래와 같이 구현되었다.
String operator + (const String& a, const String& b) {
  String r;
  r.len = a.len + b.len;
  r.buf = new char[a.len + b.len];
  memcpy(r.buf, a.buf, a.len);
  memcpy(r.buf + a.len, b.buf, b.len);
  return s;
}
이 operator + 함수는 다음과 같이 사용된다.
String x("head"), y("tail");
String s = x + y;
실행되는 코드를 구체적으로 살펴보면 다음과 같다.
String x("head"), y("tail");
  String r;                       // operator + 함수의 지역 변수
  r.(len, buf) = run_operator +;  // 문자열 연결 실행
String s(r);                      // r 을 가지고 s 생성
  r.~String();                    // r 소멸
위의 r 은 operator + 안에 있는 결과 객체 r 이다. 계산이 완료되어 결과를 담고 있는 r 은 s 에게 넘겨지는데 이 때 변수의 복사생성자 혹은 대입연산자가 불리고 나서 r 은 바로 소멸된다. 그런데 String 클래스는 생성/소멸 때 힙 할당과 해제를 수행한다. 만약 클래스의 생성/소멸이 간단했다면 무시했겠지만 힙이라면 그냥 넘기기 어려운 일이다. 기대했던 군더더기 없는 operator + 실행은 아래 같았다.
String x("head"), y("tail");
String s;
  s.(len, buf) = run_operator +;  // 문자열 연결 실행
함수의 리턴값으로 결과를 반환해야 한다면 이 문제를 피할 수 없었다. 리턴값을 사용할 수 없으면 연산자 오버로딩도 제대로 사용하기 어려우니 폼나는 C++ 형식을 사용하려면 불필요한 값 복사를 감당해야 했다.

그래서 성능이 중요한 클래스는 리턴값으로 결과를 반환하기 보다는 함수의 인자로 참조를 넘겨 결과를 돌려 방법을 사용했다.
void Concat(const String& a, const String& b, String& r) {
  r.clear();
  r.len = a.len + b.len;
  r.buf = new char[a.len + b.len];
  memcpy(r.buf, a.buf, a.len);
  memcpy(r.buf + a.len, b.buf, b.len);
}
불필요한 값 복사와 그에 수반되는 객체 생성/소멸 비용는 C++ 의 아킬레스건이 되었고 이를 해결하려고 하는 시도가 있었다.

시도

프로그래머들은 객체 복사때 발생하는 깊은 복사를 피하기 위해 Copy-on-write 를 사용하는 방법으로 이 문제를 우회하기 시작했다. COW 는 원천적으로 내용이 동등한 경우 실제 내용을 담고 있는 내부 객체를 공유하기 때문에 복사 문제를 해결할 뿐 아니라 메모리를 절약하는 추가적인 장점도 있어 널리 사용되었다. 다음은 COW 로 구현된 문자열의 복사생성자의 모습이다.
CowString(const CowString& s) {
  Data* s_data = s.GetData();    // s 의 데이터를 가져옴
  s_data->IncRef();              // 데이터의 참조 카운트를 올리면서
  this->data = s_data;           // 공유
}
데이터 자체를 공유하기 때문에 객체 복사/소멸 때 추가적인 힙 작업이 없어 효율적이다. 하지만 이 방법은 문제를 해결하기 위해 동작 방식을 수정해야 하고 쓰레드 안전성을 위해 동기화 방법 제공해야 하는 등의 어려움이 있다.

한편 Andrei Alexandrescu 는 이 문제를 해결하기 위해 Mojo (Move of Joint Objects) 라는 패턴을 고안한다. 이 패턴은 임시 객체와 그렇지 않는 객체를 교묘하게 분리해서 처리하는 개념을 사용한다.
class String : public mojo::enabled<string> {
  //...
  String(const String& rhs);            // 복사 생성자
  String(mojo::temporary<string> tmp);  // 임시변수를 가지고 생성
  String(mojo::fnresult<string> res);   // 
}
괜찮은 구현이고 원하던 비효율 제거도 달성했으나 복잡한 구현의 클래스를 써야 한다는게 아쉽다.

컴파일러는 이와 별개로 리턴값 최적화([Named] Return Value Optimization) 을 도입한다. 이 최적화는 함수가 반환하는 객체의 타입과 그걸 받아서 생성되는 객체의 타입이 일치하면 임시 객체를 만들지 않고 바로 받아서 생성될 객체에 직접 작업을 하는 최적화다. 위의 String operator + 의 경우 리턴값 최적화를 사용하면 아래와 같이 로컬 변수 r 없이 바로 실행된다.
String x("head"), y("tail");
String s;                // operator + 의 r 대신 s 를 바로 사용
s.len, buf = run_operator +;
임시 변수 없이 깔끔하게 실행되는 것을 볼 수 있다. 다만 이 리턴값 최적화는 한계가 있는데 결과값으로 생성되는 경우에만 사용할 수 있다는 것과 함수 내에 반환될 수 있는 변수가 2개 이상이면 사용할 수 없다는 것이다.
String s;
s = x + y;               // 대입 연산에는 RVO 를 사용할 수 없다

String function(...) {   // 리턴 가능한 변수가 2개라 RVO 불가
  if (...) {
     String r1 = ...;
     return r1;
  } else {
     String r2 = ...;
     return r2;
  }
}
이 구현은 컴파일러 마다 제각각이었으나 C++11 에서 Copy Elision 으로 표준화 한다. 그리고 C++11 은 복사 문제를 위해 우측값과 이동 생성자를 도입한다.

우측값(Rvalue), 이동 생성자

개념은 간단하다. 넘겨 받은 객체가 곧 소멸될 거라면 그 객체의 내용을 가져다 쓰자는 것이다. 이 방법으로 위 문자열 예제 코드를 아래처럼 구현해볼 수 있다.
String x("head"), y("tail");
String r;                         // operator + 함수의 로컬 변수
r.(len, buf) = run_operator +;
String s;
s.(len, buf) = r.(len, buf);      // r 의 내용을 s 로 가져옴
r.(len, buf) = (0, nullptr);
r.~String();                      // r 소멸
힙 할당까지 해서 어렵게 만든 r 의 버퍼를 s 에 복사하고 버리는 것이아니라 옮겨오고 대신 r 은 빈 버퍼를 넣어준다. 어차피 소멸될 변수이기 때문에 소멸자만 잘 불리는 정도로 마무리 해놓고 내용을 다 들고 온다. 이제 r 을 만들면서 힙에서 할당해 놓은 버퍼를 s 가 그대로 가져갔으니 임시 객체가 한번 생성/소멸 발생하지만 힙의 추가 작업이 없으니 괜찮은 방법이라고 할 수 있다.

이 방법을 C++ 에서 문법적으로 지원해주는 것이 우측값과 이동 생성자이다. 우측값은 곧 소멸될 값의 의미로 사용하고 그 우측값을 사용해서 객체를 생성할 수 있도록 이동 생성자를 만들어 주었다. 복사생성자와 이동생성자는 다음과 같다.
class String {
  String(String&& s) {                    // 이동 생성자
    len = s.len; buf = s.buf;
    s.len = 0;   s.buf = nullptr;
  }
  //...
우측값을 String&& 의 형태로 표현한다. 우측값을 인자로 받는 이동 생성자는 복사 생성자와 달리 인자로 받은 변수의 내용을 훔쳐오는 것을 볼 수 있다. 이동 생성자가 있으니 이동 대입  연산자도 있다. 이동 대입 연산자도 값을 훔쳐오는데 특히 이때는 swap 을 사용하면 편리하다. 이 작업은 (1) this 의 내용을 지우고 (2) 우측값으로 받은 변수의 내용을 가져오고 (3) 우측값이 잘 소멸되도록 빈 값을 넣는 과정으로 이루어져 있는데 이 것을 swap 으로 처리하면 간편하게 해결할 수 있다. 그래서 이동 할 때 swap 을 많이 사용한다.
class String {
  String operator = (String&& s) {        // 이동 대입 연산자
    swap(len, s.len);
    swap(buf, s.buf);
  }
  //...
이제 문자열의 예는 아래 처럼 컴파일러가 경우에 따라 처리 해준다.
String x("head");
String y(x);        // 복사 생성자 호출
String s(x + y);    // s(t = x + y) 이동 생성자 호출
간단하다! 이제 불필요한 복사도 없고 기분이 좋다! 우측값은 간단히 곧 소멸될 임시변수고 우측 생성자/대입연산자는 그런 우측값을 받아서 내용을 옮겨와 낭비를 없엔다 라고 생각하면 된다. 그런데 이게 전부가 아니다.

std::move

문자열을 받아 문자를 모두 소문자로 만들어 반환하는 함수 lower 가 있다. 구현은 아래 코드처럼 되어 있다.
String lower(const String& s) {
   String r(s);
   for (size_t i=0; i<r.len; i++)
     r.buf[i] = tolower(r.buf[i]);
   return r;
}
살펴보니까 lower 에 넘겨온 인자 s 가 우측값이라면 굳이 임시변수 r 을 새로 생성하지 말고 s 를 바로 사용하면 되지 않을까? 라고 생각해서 아래와 같은 함수를 하나 더 만들었다.
String lower(String&& s) {
   String r(s);
   //...
}
자 이제 s 가 우측값으로 r 의 이동생성자에게 잘 넘어가서 오버헤드가 없기를 기대했다. 하지만 기대를 져버리고 복사 생성자가 호출된다. 왜냐하면 우측값이 이름을 가지게 되면 더 이상 우측값이 아니기 때문이다. (이름을 가지면 우측값일 수 없어서) 그렇다면 우측값으로 다시 만들어줘야 하는데 그럴 때 std::move 를 사용한다. 함수의 반환 값은 이름이 없기 때문이다. 다음과 같이 컴파일러에게 우측값임을 다시 일러준다.
String lower(String&& s) {
   String r(std::move(s));
   //...
}
자 이제 원하는대로 r 의 이동생성자가 호출된다. 이런 경우는 멤버 변수의 우측 생성자를 불러 줄 때 흔하게 발생한다. 아래 stack 생성자가 그런 경우에 해당한다. stack 는 deque 를 멤버 변수로 가지고 있고 deque 의 이동생성자의 인자로 받은 s 가 더 이상 우측값이 아니기 때문에 std::move 로 s.c 를 우측값으로 만들어 두고 c 에게 넘겨야 원하는 대로 동작한다.
template<class T>
class stack {
  //...
  stack(stack&& s)
    : c(std::move(s.c)) {  // 멤버변수 c 도 이동 생성자가 불리도록
    }
protected:
  deque<T> c;
};
언제 우측값인지 언제가 아닌지 잘 구분할 필요가 있다. 다음!

std::forward

자 이제 클래스를 하나 만들면 할일이 두 배로 는 것 같이 보인다. 예로 든 lower 함수를 보면 const T& 타입의 인자를 받는 함수와 T&& 타입의 인자를 받는 함수 이렇게 두 벌이 있다. 하지만 내용이 동일하니까 어떻게 한 벌로 만들 수 있지 않을까? 차이라면 아래 코드처럼 임시변수 r 에 move 썼느냐 아니냐의 차이 밖에 없으니까.
String lower(const String& s) {
   Str r(s);
   //...
}

String lower(String&& s) {
   Str r(std:move(s));
   //...
}
이 것을 아래처럼 template 으로 하나로 합친다.
template <typename T>
String lower(T&& s) {
   String r(std::forward<string>(s));
   //...
}
여기서 lower 의 인자 T&& 는 특별한 의미를 가지는데 만약 T 가 T& 타입이라면 T& 로 해석되고 T&& 타입이라면 T&& 로 해석이 된다. 이걸 Reference collapsing rules 이라고 부른다.
자 이제 인자 s 는 넘겨 받은 인자가 String&& 타입이면 String&& 으로 동작하고 그렇지 않은 경우에는 const String& / String& 타입으로 동작한다. 이제 두번째로 필요 한 것은 std::forward 다. 이 함수는 s 가 우측값으로부터 왔다면 move 가 불리고 그렇지 않다면 아무 것도 하지 않는다. 따라서 임시 변수 r 은 s 가 우측값이면 이동 생성자가 불리고 그렇지 않으면 복사 생성자가 불리게 된다. 힘들게 함수 하나로 만들었다.

STL 에서 이 forward 사용하는 코드를 make_shared 에서 찾아볼 수 있다. 이 템플릿 함수는 shared_ptr 을 만들어주는 함수인데 shared_ptr 에게 넘겨줄 인자를 A&& 타입으로 받고 이것을 실제 생성자에게 넘겨준다. 이렇게 해둠으로써 복사 혹은 이동 생성자가 상황에 맞춰 불릴 수 있다.
template<typename T, typename A>
shared_ptr<T> make_shared(A&& arg) {
  return allocate_shared<T>(allocator(), std::forward<A>(arg));
}
이것을 perfect forwarding 이라고 부른다.

결론

자. 시작부터 끝까지 내용이 많은 것 같지만 사실 리턴 값으로 결과 값을 넘기면서 발생하는 불필요한 객체 복사 작업을 없에기 위해서 고생고생 하는 내용이다. 덕분에 C++ 방식으로 깔끔하게 쓰면서도 효율을 유지하는 C++ 의 철학이 어느정도 (이제야) 지킬 수 있게 되었다. 하지만 필요 이상으로 복잡한 내용을 알아야 정확한 동작을 이해할 수 있다는 점에 대해서는 아쉽다. (Rvalue ref. for *this 는 글에서 언급하지도 않았다)

참고로 위에서 예로 들었던 lower 는 사실 아래처럼 써도 (C++11 에서) 효율적으로 동작한다. 함수 lower 가 불릴 때 임시로 생성되는 인자 s 는 우측값을 받았을 때 알아서 이동 생성자로 부터 생성되기 때문이다.
String lower(String s) {
   for (size_t i=0; i<s.len; i++)
     s.buf[i] = tolower(s.buf[i]);
   return s;
}
이번 글에서는 대략적인 흐름만 다뤘을 뿐 실제 문법과 관련된 자세한 내용은 다루지 않았다. 자세한 내용을 읽고 싶다면 아래 링크의 글들을 읽어보는 것을 추천한다.

2012년 11월 3일 토요일

Windows Installer 동작과 SSD 여유 공간

업무 PC 에 80GB SSD 를 장착하고 Windows 7 와 작업에 필요한 소프트웨어를 설치하고 디스크 여유 공간 확보 작업을 시작했다. C 드라이브로 사용하는 80GB SSD 가 OS + 작업 공간으로 쓰기에 빠듯해 몇백 메가라도 줄일 수 있다면 줄일 작정이었다. 먼저 설치한 것들은 다음과 같다.
  • Windows 7 64bit Ultimate (+SP1)
  • Visual Studio 2008 Standard (+SP1)
  • Visual Studio 2010 Premium (+SP1)
  • Office 2010 Plus (+SP1)
  • MS Update 의 모든 Hotfix 설치
설치 하고 나서 SSD 를 위해 다음과 같은 용량 확보 설정을 했다.
  • 가상 메모리 끄기
  • 최대 절전 모드 끄기
  • 시스템 보호 기능 끄기
  • 디스크 정리의 모든 항목 처리
  • 윈도우 서비스팩 백업 삭제 (dism ...)
그리고 며칠 작업을 하고 나서 SpaceSniffer 로 용량 측정을 해봤다. 사용 용량 43GB! SSD 전체 용량의 50% 가 넘는 크기다. 만약 2TB HDD 를 썼다면 신경도 않쓸 2% 의 용량인데 SSD 입장에서는 그렇지 못하다. 이 43GB 를 돈으로 환산하면 HDD 는 2천원인데 반해 SSD 는 6만원이나 된다. (2TB HDD, 80GB SSD 모두 11만원 기준)


누가 이렇게 용량을 차지하나 봤더니 Windows 폴더가 28GB 로 1등이다. 아니 왜 Windows 폴더가 28GB 나 하지? 설치는 DVD 1장으로도 되는데? 라는 생각에 더 살펴보니 Windows 폴더 아래 Installer 폴더가 9.8GB, winsxs 폴더가 7.3GB 로 대부분의 용량을 차지하고 있었다. 그래서 Installer 폴더를 더 열어보면,


Installer 폴더 바로 아래에 있는 msi, msp 확장자의 파일들이 4.6GB 를 차지하고 있고 $PatchCache$ 폴더 아래에 있는 여러 폴더안에 있는 파일들이 5.2GB 를 차지하고 있었다.

Installer 폴더에는 아래 그림처럼 여러 msi, msp 파일이 있다. 탐색기의 컬럼에 "제목" 필드를 추가하면 어떤 내용인지 알수 있는 정보를 볼 수 있다. (모든 파일이 다 나오는 것은 아니다.)


Installer/$PatchCache$ 폴더 아래엔 Managed 폴더가 있고 그 아래 여러 폴더가 있다. 그 중 하나의 폴더를 예로 살펴보면 다음과 같은 파일들을 볼 수 있다.


도대체 Installer 폴더는 어떤 용도의 폴더이길래 이런 파일들이 용량을 차지하고 있나 궁금해서 자료를 살펴보았다.

Windows Installer 동작 방식

Windows Installer 는 윈도우용 프로그램을 설치/패치 해주는 시스템이다. 이 시스템의 동작방식에 대해서 Heath Stewart 가 잘 정리해 두어 자세히 익히지 않아도 대강 어떻게 돌아가는지 파악할 수 있었다. 직접적으로 관련된 블로그는 다음과 같다.
위 블로그와 몇몇 자료를 살펴보고 대략적으로 파악한 Windows Installer 동작 방식은 아래와 같다.
  • Windows Installer 로 설치 프로그램 (msi) 와 패치 프로그램 (msp) 을 만들 수 있다.
  • 설치, 패치 둘 다 기본적인 역할에 더해 복구(Repair), 제거(Uninstall) 기능을 제공한다.
    특히 패치 제거 기능은 Windows Installer 3.0 부터 지원하기 시작했다.
  • 설치, 패치 모두 복구나 구성 변경이 필요하면 설치 디스크를 요구할 수 있다.
  • 하지만 이 과정 중에 사용자에게 설치 디스크를 요구하는 것은 좋은 경험을 제공하는 것이 아니므로 가능한 피하는 것이 좋다라는 것이 Windows Installer 팀의 입장. 때문에 이 과정에 필요할 만한 파일을 하드에 남겨두는 것을 전략적으로 선택.
  • 설치 과정에서 선택적으로 설치 원본 파일을 하드에 남겨둘 수 있다. 이는 구성 변경 때나 패치가 적용될 때 원본 디스크를 요구가 필요 없는 장점이 있다.
  • 패치 데이터는 경우에 따라서 delta encoding 을 사용한다. 따라서 패치를 수행할 때 원본 파일이 하드에 없는 경우 원본 디스크를 요구할 수도 있다.
  • 패치의 경우 제거를 하려면 패치 전 원본 파일이 필요하다. 원본 파일이 백업되어 있다면 그것을 사용하고 아니라면 사용자에게 설치 디스크를 요구해야 한다.
  • 패치의 경우 복구를 하려면 패치 파일이 필요하다. 하지만 패치의 경우 설치 디스크가 없으니 패치 파일 자체를 백업해 둬야 한다.
정리하면 패치를 설치하면 해당 패치의 복구/제거를 위해 패치 과정에 관련된 모든 파일의 패치 전/후 상태를 백업해야 한다는 의미가 된다. 백업은 어떤 형태로 진행되냐하면
  • 패치 전 파일은 Installer/$PatchCache$ 에 백업
    (패치를 복구하거나 제거할 때 패치 전 파일이 필요한 경우 백업된 곳에서 원본 파일을 가져다 사용한다. 하지만 원본 파일이 백업 되어 있지 않다면 설치 디스크를 요구할 수 있다. 따라서 반드시 필요한 백업은 아님)
  • 패치 후 파일은 Installer 아래 msp 파일을 그대로 백업
    (만약 설치된 프로그램을 복구 하는 경우 이미 패치되었다면 패치가 반영된 파일로 복구를 해야 한다. 그런 경우 패치에 관련된 msp 파일이 없다면 복구를 할 수가 없다. 따라서 msp 를 삭제하면 정상적으로 복구를 할 수 없다. 반드시 필요.)
이런 이유로 $PatchCache$ 는 삭제가 가능하고 Installer 아래의 파일은 불가능하다.

설치된 프로그램별 용량

누가 누가 얼마나 차지하는지 간단한 프로그램을 작성해 살펴보았다. (WinstView) 이 프로그램은 단순히 Installer 폴더에 있는 파일/폴더가 어떤 프로그램과 연결되어 있는지 확인해주는 역할을 한다. 연결을 위해 아래의 레지스트리를 탐색한다.
  • HKLM\SOFTWARE\Classes\Installer\Products
  • HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer
이제 실행한 결과를 살펴보면 먼저 Installer 에 있는 msi, msp 파일들이 어떤 프로그램인지 알 수 있다. 상위 6개를 보면 오피스, Visual Studio 의 패치 파일인 것을 알 수 있다.


실행한 결과 두 번째를 보면 Installer/$PatchCache$ 폴더의 하위에 있는 여러 폴더가 어떤 프로그램을 위한 폴더인지 알 수 있다. 상위 6개를 보면 역시 오피스, Visual Studio 를 위한 폴더임을 알 수 있다.


특히 $PatchCache$ 는 원래 프로그램의 최대 2배까지 커질 수 있어 용량이 어마어마하다. (2배인 이유는 RTM 파일, 서비스팩이 적용된 파일을 보관할 수 있기 때문이다)

Windows Installer 의 PatchCache 삭제 및 기능 끄기

$PatchCache$ 폴더는 삭제할 수 있다. 속시원하게 $PatchCache$ 폴더를 지워보자.
rd /s /q %WINDIR%\Installer\$PatchCache$
그리고 Installer 가 이 폴더에 새로 데이터를 넣지 않도록 레지스트리 설정도 하자. (MaxPatchCacheSize 를 0 으로 설정하는 것으로 가능)
reg add HKLM\Software\Policies\Microsoft\Windows\Installer /v MaxPatchCacheSize /t REG_DWORD /d 0 /f
이제 아래 그림처럼 Installer 폴더가 9.8GB 에서 5.2GB 줄어 4.6GB 가 된 것을 볼 수 있다.


하지만 아쉽게도 Installer 폴더 아래에 있는 파일은 지울 수 없다. Windows Update 등에서 원격으로 받아올 수 있는건 필요할 때 마다 받아오거나 직접 사용자가 제공할 수 있는 방법이 있으면 좋겠지만 아직 그런 기능은 없는 것으로 보인다.

WinSxS 폴더

이제 winsxs 폴더가 7.3GB 로 1등이다. 의심스러운 용량이다. DLL Hell 을 해결하기 위한 시스템인 Windows Side by Side 가 사용하는 폴더다. (간략한 설명은 The Secret Of Windows 7 WinSxS Folder 에서 볼 수 있다) 간단히 말해 같은 DLL 의 여러 버전을 winsxs 아래에 보관하고 있다가 프로그램이 원하는 버전의 DLL 을 제공하는 역할을 한다. 만약 A.DLL 파일의 버전이 10개 존재한다면 10개 다 저장해 놓고 있을 수 있다.

게다가 winsxs 에 있는 파일 중 일부는 다른 경로의 파일과 하드 링크로 연결되어 있다. (예를 들면 ntdll.dll 은 system32 에 있는 파일과 winsxs 에 있는 파일이 연결되어 있다.) 따라서 실제 winsxs 폴더에만 있는 파일의 순수 용량은 적다. 그래서 간단한 프로그램으로 winsxs 에 있는 파일이 어떤 파일과 연결되어 있는지 살펴보았다. (ScanWinSxS)

결과를 보면 7.3GB 중 실제 하드링크가 연결되어 있는 파일은 4.5GB (60%) 이고 그렇지 않은 파일은 2.8GB (40%) 였다. 하드링크된 파일의 70% 인 3.3GB 의 파일은 Windows/System32 와 Windows/SysWOW64 에 연결되어 있었다. 따라서 WinSxS 폴더는 하드링크에 의해서 실제보다 부풀려 측정되는 것이라고 볼 수 있다.

여기서 하드링크가 연결되어 있지 않은 2.8GB 중에서 용량 순으로 파일 순위를 보면 다음과 같다. (Size 는 해당 파일의 버전별 크기 총합. Count 는 버전 개수.)


인터넷 익스플로러, 윈도우 시스템, .NET 컴포넌트, MFC 파일등이 업데이트 될 때마다 WinSxS 에 등록해 파일이 8~12 개씩 쌓여 용량을 많이 차지하고 있는 것을 볼 수 있다. (저 리스트에 있는 파일 용량만 합쳐도 686MB 이다) 실제로 저 파일들이 모두 사용되고 있는지 의심스럽지만 winsxs 폴더는 삭제하면 안되기 때문에 그냥 내버려 뒀다.

정리

Windows Installer 의 동작 방식의 의도는 이해가 간다. 지난 20년간 HDD 용량은 꾸준히 늘어 왔고 이에 맞춰 용량 대비 가격은 급격히 떨어졌다. 하드 용량이 많이 남으니까 설치/패치의 안정성과 유저편의성을 하드 용량과 맞바꾼 것은 괜찮은 선택이라고 할 수 있다. 다만 최근에 등장한 SSD 는 이 관계를 틀어놨고 이제는 이 전략이 수정되어야 할 필요가 생겼다. 네트워크 시대에 맞춰 주요 파일을 Windows Update 서버에 넣고 필요할 때 받아가게 하는 장치가 있으면 좋지 않을까? 게다가 설치된 패치를 복구 하거나 제거하는 일의 빈도가 낮다면 (옵션으로) 백업으로 저장하지 말고 원본 디스크를 요청해도 되지 않을까? (요즘 설치 디스크는 DVD 로 보관하지 않고 네트워크 폴더의 이미지 파일로 보관하니까)

2012년 10월 31일 수요일

버전 관리 시스템의 마이그레이션 전략과 방법

최근에 버전 관리 시스템 마이그레이션 작업을 진행하고 있고 작년에는 저장소 재구성 작업을 했었다. 작업을 진행하면서 고민했던 마이그레이션 전략과 방법을 정리했다.

마이그레이션

저장소 마이그레이션은 예전 저장소에서 새 저장소로 데이터를 옮기는 과정이다. 이 과정은 아래의 그림과 같이 예전 저장소에 있는 파일과 로그를 익스포트하는 과정과 여기서 얻은 데이터를 새 저장소로 임포트하는 과정으로 이루어진다.


특히 마이그래이션 작업은 두 저장소가 다른 버전 관리 시스템에 있을 때 까다로워진다. 만약 두 시스템의 리비전 모델이 많이 다른 경우라면 더욱 그렇다. 예를 들어 subversion 의 경우 디렉토리도 파일과 같이 리비전 대상이 되지만 mercurial 의 경우에는 그렇지 않다. 때문에 subversion 의 mkdir 작업은 mercurial 에서는 빠져야 하고 반대로 mercurial 에서 subversion 으로 올 때는 적당한 시점에 mkdir 작업을 임의로 넣어줘야 한다. (다행히 subversion -> mercurial 은 hgsubversion 툴이 있어 간편하게 마이그레이션 할 수 있다)

최대한 예전 저장소의 구조와 데이터를 유지하면서 마이그레이션을 할 수 있다면 좋겠지만 그 만큼 개발 비용이 소모되니 절충안을 잘 선택해야 한다. 이제 여러 전략을 살펴보고 장단점을 따져 최적의 방법을 골라보자.

예제 데이터, 전략의 이점

설명을 위해 아래와 같은 예제 저장소 데이터를 가정하자. 


가로축이 리비전 증가다. 리비전 1 부터 4 까지 총 4개의 리비전 변화가 있었다. 저장소의 파일은 노드로 표시되고 "파일이름 <내용>" 형식으로 표현되어 있다. 위 그림에서 리비전 2 에는 apple 과 banana 파일 있는 것을 알 수 있다. 또 가장 최근 리비전 4 에는 banana 와 orange 파일이 있는 것을 알 수 있다.

리비전이 증가함에 따라 파일이 변하는데 파일의 변화는 노드 사이를 잇는 선으로 표시한다. 변화를 발생시키는 파일 변경 내용은 A(dd, 추가), M(odify, 변경), C(opy, 복사), D(elete, 삭제) 이렇게 네 가지가 있다. 위 그림에서 리비전 2-> 3 과정을 보면 apple 파일은 삭제되고, banana 파일은 내용이 brown 으로 수정되고 orange 는 banana 로부터 복사되었음을 알 수 있다.

각 마이그레이션 전략이 제공하는 이점이 있다. 먼저 어떤 이점이 있는지 살펴보고 각 전략이 그 중 어떤 이점을 제공하는지 다루기로 하자.
  • 현재 스냅샷 유지
    가장 마지막 리비전의 파일의 구성은 최소한 유지되어야 올바른 마이그레이션이다. 과거 데이터가 어떻든 새 저장소에서 현재 파일을 받았을때 예전 저장소와 동일한 파일이 받아져야 한다. 따라서 유지되어야할 필수 조건이다. (예제 데이터의 새 저장소에서 최신 리비전을 받으면 banana, orange 가 나와야 한다.)
  • 과거 시점의 스냅샷의 완결성 유지
    과거 시점의 파일을 받았을 때 그 시점의 파일 구성이 올바르게 받아지는지 여부다. 예를 들서 1년전 저장소 상태의 파일을 받겠다 라고 했을 때 그 때의 구성으로 올바르게 받아지는지 여부다. (예제 데이터의 리비전 2 데이터를 요청했을 때 정확히 apple 과 banana 가 받아져야 한다.)
  • 현재 파일의 변경 내역 유지
    현재 스냅샷에 포함되어 있는 파일들의 변경 이력이 제대로 나오는지 여부다. 현재 파일의 과거 버전을 받을 수도 있고 수정 내역을 통해 blame 을 할 수도 있다. (예제 데이터의 banana 이력을 요청하면 yellow -> brown -> black 으로 변해 왔는지를 알 수 있어야 한다.)
그럼 어떤 전략이 있는지 살펴보자.

전략: 현재 스냅샷

현재 스냅샷 (마지막 리비전에 포함되어 있는 모든 파일) 을 새 저장소에 그대로 추가 하는 방식이다. 과거 이력은 모두 무시하고 현재 파일만 이동하는 전략이다. 새 저장소에 옮겨진 리비전의 모습은 아래 그림과 같으며 과거 리비전에 대한 정보가 모두 사라졌음을 볼 수 있다.


이 방법은 간단하다. 단순히 예전 저장소의 최신파일을 받아 새 저장소에 추가하는 것으로 충분하다. 때문에 마이그레이션 비용이 가장 적고 그래서 많이 사용되는 방법이기도 하다. 만약 과거 리비전 로그를 살리는 것이 중요하지 않다면 좋은 방법이다.

이점: 현재 스냅샷 유지

전략: 주요 스냅샷

중요 포인트가 되는 리비전들을 정하고 그 리비전들의 파일과 그 리비전 사이마다 차이를 남기는 방식이다. 예를 들어 알파 테스트, 베타 테스트, 공개 오픈 이렇게 세 시점이 중요 포인트고 그 시점의 스냅샷과 그 사이의 변화를 남기고 싶다면 이 방법을 사용할 수 있다. 아래 그림은 중요 포인트로 리비전 2와 4를 선택했을 경우의 새 저장소의 리비전 모습이다. 리비전 2, 4 시점의 파일은 정확히 남고 그 사이의 이력은 뭉게지는 것을 볼 수 있다. (banana 의 파일 내용이 yellow 에서 바로 black 이 되는 것이 한 예이다.)


방법도 간단하다. 먼저 중요 포인트의 리비전에 해당하는 파일을 모두 받는다. 이것을 리비전 별로 묶고 아래와 같은 절차를 밟는다.
  • 가장 첫 리비전에 포함되는 파일은 모두 그대로 새 저장소에 추가
  • 그 다음 리비전 r 에 대해서 포함되어 있는 파일에 대해서
    • 새 저장소에 없는 파일이 리비전 r 에 있다면 : Add file
    • 새 저장소에 있던 파일이 리비전 r 에 없다면 : Delete file
    • 새 저장소와 리비전 r 에 모두 있지만 내용이 다르다면 : Modify file
  • 위 내용을 새 저장소에 커밋. 마지막 리비전이 될 때까지 반복
그다지 복잡하지 않다. 다만 새 저장소에 대해 위에서 설명한 리비전 변경 작업을 생성해 내고 수행 하는 스크립트 작업을 해야 한다. 하지만 상대적으로 간단한 기능만으로 가능하기 때문에 수월한 편이다. 또한 중요 포인트를 촘촘히 설정하면 이력이 뭉게지는 범위를 좁힐 수 있다.

이점: 최근 스냅샷 유지 + (주요) 과거 시점의 스냅샷의 완결성 유지

전략: 현재 파일의 변경 로그 유지

과거 스냅샷 보다는 현재 파일이 어떻게 변해왔는지를 유지하는게 중요하다면 현재 파일의 변경 로그 유지 방법을 사용해볼 수 있다. 이 방법은 마지막 리비전에 있는 파일과 이력만 새 저장소로 마이그레이션 하는 방법이다. 예제 데이터의 마지막 리비전 4에는 banana 와 orange 파일이 있는데 이 파일에 대한 이력만 추려서 새 저장소를 구성하면 아래와 같은 그림을 얻을 수 있다. 이 방법은 현재 남아있는 파일의 이력이 남기 때문에 blame 등의 기능을 종전과 같이 그대로 사용할 수 있는 장점이 있다.


구현은 예전 저장소에 있는 모든 파일들의 과거 이력을 얻는 것으로 시작한다. 개별 이력을 얻는 것이 보통의 버전 관리 시스템은 빠르지 않기 때문에 파일이 많은 경우 전체 리비전 정보로 부터 개별 파일 정보를 재구축 하는 것이 효율적이다.
  • 예전 저장소 모든 리비전 r 에 대해 순서대로
    • 리비전 r 에 해당하는 현재 파일의 이력이 있는 경우
      • 그 파일의 첫 로그라면: Add file
      • 그렇지 않은 경우라면: Modify file
    • 새 저장소에 변경 내용 커밋
이 방법 역시 그다지 복잡하지 않다. 예전 저장소에서 개별 파일 이력을 얻는 부분도 일반적으로 대부분의 버전 관리 시스템이 지원하기 때문에 손쉽게 얻을 수 있다. 새 저장소에 파일을 추가하고 변경 내역을 구축하는 것도 Add 와 Modify 기능만으로 가능하기 때문에 간단하다. 특히 소스 저장소의 blame 을 그대로 사용할 수 있어 좋다. 또한 이미 삭제된 파일에 대한 데이터를 옮기지 않아 새 저장소 용량 절감 효과도 있다.

이점: 최근 스냅샷 유지 + 현재 파일의 변경 사항 로그 유지

전략: 변경 리플레이

예전 저장소의 모든 파일과 변경 이력을 최대한 그대로 마이그레이션 하고 싶다면 리비전 로그를 순서대로 새 저장소에서 동등하게 재현하는 변경 리플레이 방법을 사용할 수 있다. 이 방법은 최대한 모든 데이터를 그대로 새 저장소에 넣는 방식이기 때문에 매끄러운 마이그레이션이 가능하지만 그 만큼 비용이 많이 들 수 있다. 특히 서로 다른 버전 관리 시스템 간의 마이그레이션의 경우 제공되는 마이그레이션 솔루션이 없다면 손이 많이 가는 작업이 될 가능성이 높다. 만약 1:1 로 완벽하게 마이그레이션이 가능하다면 아래 그림처럼 예제와 동일한 로그가 새 저장소에 구축된다.


방법은 기본적으로 간단하다. 예전 저장소의 변경 내용을 순서대로 새 저장소에 적용하면 된다.
  • 예전 저장소 모든 리비전 r 에 대해 순서대로
    • 리비전 r 을 구성하는 변경 로그에 대해서
      • 변경 로그가 add 면 새 저장소에 add
      • 변경 로그가 modify 면 새 저장소에 modify
      • 변경 로그가 delete 면 새 저장소에 delete
      • ...
    • 새 저장소에 변경 내용 커밋
서로 다른 저장소의 경우 리비전 변경을 구성하는 집합 (add, modify, ...) 이 달라 변환 과정이 필요한 경우가 많다. 따라서 완전한 구현을 목표로 하기 보다는 가능한 변경 집합을 제한하고 처리할 수 없는 것은 적당히 예외 처리하는 것이 현실적이다. (예를 들어 브랜치는 기본 모델 부터 다른 경우도 있다.) 적당한 선에서 탑협을 해 이 방법을 사용할 수 있다면 가장 완전한 마이그레이션이 될 수 있다.

이점: 현재 스냅샷 파일 유지 + 과거 시점의 스냅샷의 완결성 유지 + 현재 파일의 변경 내역 유지 

전략: 최신 스냅샷 + 현재 파일의 변경 로그 유지

기본적으로 최신 스냅샷을 사용하되 중요 파일에 대해서만 현재 파일의 변경 로그 유지 방법을 사용한다. 아래 그림은 orange 에 대해서는 최신 스냅샷을 banana 에 대해서는 변경 로그 유지를 적용한 예이다.


방법은 현재 파일 변경 로그 유지와 유사하다. 중요 파일은 기존과 같게 동작하도록 하고 그렇지 않은 파일에 대해서 로그를 옮기지 않으면 된다. 이 방법은 새 저장소의 용량을 절약할 수 있는 장점이 있다.

이점: 최근 스냅샷 유지 + (중요) 현재 파일의 변경 사항 로그 유지

전략: 주요 스냅샷 + 현재 파일의 변경 로그 유지

기본적으로 주요 스냅샷을 사용하고 마지막 스냅샷과 직전 스냅샷 사이는 변경 로그 유지 기능을 사용해 최근 변경 로그를 유지 하는 방법이다. 아래는 리비전 2, 4 를 주요 스냅샷으로 남기고 리비전 2 -> 4 사이의 변경 로그를 유지한 예이다.


방법은 마지막 리비전 직전까지는 주요 스냅샷과 동일하고 마지막 리비전 단계에서 현재 파일의 변경 로그 유지 방법과 동일하다. 이 방법은 주요 스냅샷을 재구성할 수 있고 현재 파일들의 최근 변경 사항을 확인할 수 있는데 특히 변경 이력은 과거는 그다지 상세할 필요가 없는데 반해 최근은 상세한 것이 좋기 때문에 적절히 균형을 맞춘 것이라 볼 수 있다. 과거 이력을 상세히 남기지 않기 때문에 저장소의 공간을 절약하는 장점이 있다.

이점: 최근 스냅샷 유지 + (주요) 과거 시점의 스냅샷의 완결성 유지 + 현재 파일의 (최근) 변경 사항 로그 유지

브랜치

브랜치는 앞서 설명한 트렁크에 발생하는 작업 이력에 비해 복잡하다. 특히 버전 관리 시스템 마다 특징히 달라 저장소 리플레이로 커버하기 어려울 수 있다. 이럴 때는 브랜치가 생성되는 시점과 브랜치 마지막 시점을 주요 스냅샷 방법을 사용해 구성해 주는 간단한 방법을 사용하는 것이 좋다. 보통 브랜치 보다는 트렁크의 로그가 중요하기 때문에 브랜치 시점과 현재 모습을 유지하는 쪽이 좋다.

주요 버전 컨트롤 시스템의 명령 예

마이그레이션 작업 때 다뤄봤던 subversion, mercurial, perforce 의 대략의 사용법과 팁을 남겨둔다.

Subversion

익스포트 작업 주요 명령어
svn list path                    # 디렉토리/파일 목록 출력
svn log                          # 저장소 리비전 로그 출력
svn log -v -r rev                # 리비전 작업 내용 출력
svn cat -r rev path > savepath   # 특정 리비전 때의 파일 내용 저장
파일 작업 주요 명령어
svn add filename                 # 추가
svn delete filename              # 삭제
svn move oldpath newpath         # 이동
svn copy srcpath dstpath         # 복사
커밋 명령어
svn commit -m LOG
svn propset --revprop -r HEAD svn:author AUTHOR
svn propset --revprop -r HEAD svn:date 2012-10-27T00:30:15.000000Z
SVN 의 propset 을 사용하기 위해서는 서버의 pre-revprop-change 가 exit 1 등으로 설정되어 있어야 한다. 그리고 svn:data 는 UTC 시간이다. 특히 Subversion 은 commit 을 저장소 루트 디렉토리하는 것이 원칙이나 빠르게 처리 하기 위해 변경이 발생한 디렉토리의 공통 부모 디렉토리에서 하는 것이 좋다. (가끔 update 가 필요하다고 svn 이 에러를 내면 저장소 루트에서 update 를 한 번 수행하면 해결이 된다)

Mercurial

익스포트 작업 주요 명령어
hg log                           # 저장소 리비전 로그 출력
hg log -v -r rev                 # 리비전 작업 내용 출력
hg cat -r rev path > savepath    # 특정 리비전 때의 파일 내용 저장
파일 작업 주요 명령어
hg add filename # 추가
hg remove filename               # 삭제
hg rename oldpath newpath        # 이동
hg copy srcpath dstpath          # 복사
커밋 명령어
hg commit -m LOG -u AUTHOR -d "2012-10-27 09:30:15" 
Subversion 과 비슷하다. 하지만 DVCS 라서 commit 명령어에서 자유롭게 author 와 date 를 지정할 수 있는 것이 특징이다. 또한 저장소가 로컬에 있기 때문에 디렉토리/파일 목록은 dir, ls 등을 사용하면 된다. hg 의 경우 실제 변경사항이 없는데 commit 을 요청하면 에러를 발생하니 이 부분에 대한 예외 처리가 필요하다.

Perforce

익스포트 작업 주요 명령어
p4 [dirs/files] path             # 디렉토리/파일 목록 출력
p4 changes                       # 저장소 리비전 로그 출력
p4 describe rev                  # 리비전 작업 내용 출력
p4 print -o savepath path@rev    # 특정 리비전 때의 파일 내용 저장
파일 작업 주요 명령어
p4 edit filename                 # 편집
p4 add filename                  # 추가
p4 delete filename               # 삭제
p4 rename oldpath newpath        # 이동
p4 copy srcpath dstpath          # 복사
커밋 명령어
p4 submit -d LOG
p4 change -o REV | change "Date:" "User:" | p4 change -i -f
Perforce 는 checkout 방식이므로 편집 전에 수정 요청을 해야한다. 그리고 perforce 의 경우 파일타입 설정에 주의를 기울여야 한다. (잘못 설정하면 파일이 깨지거나 submit 단계에 에러가 나거나 p4merge 에서 이상하게 나올 수 있다. 마이그레이션 작업전에 p4_filetypes 내용을 잘 살펴봐야 한다.) 또한 파일이름에 @ # * % 문자가 포함되어 있는 경우 이스케입을 해줘야 한다.

결론

저장소 마이그레이션의 여러 전략을 살펴보았다. 각 전략의 이점과 비용을 잘 살펴보고 최적의 방법을 결정하는 것이 중요하다. 또 저장소의 데이터는 프로젝트의 중요한 자산이니 무엇보다도 안전하게 누락없이 데이터를 옮기는데 주의를 기울여야 한다.