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 내용을 잘 살펴봐야 한다.) 또한 파일이름에 @ # * % 문자가 포함되어 있는 경우 이스케입을 해줘야 한다.

결론

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

2012년 10월 26일 금요일

버전 관리 시스템의 저장소 관리

요즘 버전 관리 시스템을 사용하지 않는 곳을 찾아 보기 어려울 정도로 버전 관리 시스템은 이제 개발에 필수 요수가 되었다. 이 시스템을 사용하기 전엔 도대체 어떻게 개발을 했나 싶을 정도다.


이제는 사용 요령이나 유용한 팁들이 많아 시스템 사용에 큰 어려움이 없이 잘 쓰지만 정리 차원에서 저장소 관리 가이드라인을 적어본다. 어디까지나 개인적인 프로젝트 경험에 의한 내용이니 적용에는 응용이 필요하다.

저장소와 관련된 가장 기본적인 가이드라인은 다음과 같다.
  • 소스 저장소에 개발 / 빌드에 필요한 모든 것을 넣어둔다.
  • 소스 저장소와 리소스 저장소를 분리한다.
  • 사용 / 관리 정책을 세워야 하고 관심을 기울여 줘야 한다.
좀 더 자세히 살펴보자.

소스 저장소에 개발 / 빌드에 필요한 모든 것을 넣어둔다.

소스 저장소에는 팀에서 개발하는 소스에 더해 빌드에 필요한 라이브러리와 툴이 포함되어 있어야 한다. 만약 개발중인 프로그램이 expat, boost, directx sdk 를 필요로 한다면 이 라이브러리들을 모두 넣어둔다. 이렇게 하면 프로그래머가 일일이 라이브러리를 구해야 하는 수고도 줄고 버전이 불일치 하는 일도 없고 언젠가 있을 라이브러리 업데이트도 자연스럽게 되어 좋다. 

다만 배포되는 라이브러리에 포함되어 있는 모든 파일을 넣지 않도록 조심해야 한다. 라이브러리에 포함되어 있는 튜토리얼, 샘플, 문서, 다른 플랫폼용 파일, 안쓰는 빌드 구성용 lib, pdb 파일 등은 불필요하게 저장소 용량만 차지한다. (예를 들어 boost 1.51 의 경우 빌드 없는 구성으로 총 용량이 334MB 인데 그 중 빌드에 필요한 boost 폴더는 86MB 로 25% 에 불과하다.) 필요한 파일만 등록하고 나중에 해당 라이브러리를 업데이트 할 수 있으니 어디서 구할 수 있는지, 중요 파일을 어떻게 추렸는지 기록해두거나 스크립트로 만들어 저장소에 넣어두면 도움이 된다. 상용 라이브러리라 보관이 꼭 필요하다 싶으면 패키지를 압축해서 올리는 것도 요령이다. 버전 관리 시스템은 많은 파일, 큰 용량 처리에 특화되어 있지 않다.

외부 라이브러리를 svn 의 external link 등으로 연결하는 방법은 효과적인 방법이다. 하지만 내부 프로젝트에서는 추천하지 않는다. 외부 네트워크에서 격리된 곳에서 빌드를 해야할 수도 있고 네트워크 장애로 설치에 어려움을 겪을 수도 있기 때문이다.

외부 라이브러리에 더해 빌드에 관여하는 툴도 저장소에 넣어둔다. 예를 들면 빌드 솔루션, 코드 생성기, 어셈블리 컴파일러 등을 저장소에 같이 넣어둔다. 단 비주얼 스튜디오 등 배보다 배꼽이 커지는 경우도 있으니 득실을 따져보고 넣는다.

소스 저장소와 리소스 저장소를 분리한다.

기본적으로 1개의 프로젝트에 1개의 저장소가 원칙이다. 하지만 리소스가 소스 보다 열 배 이상 용량이 크다면 분리하는 편이 관리와 사용에 유리하다.

소스는 아무리 커져봐야 스냅샷 기준으로 1GB 이상을 넘기가 어렵다. (팀에 코딩 머신이 있어) 매일 10% 분량이 새롭게 수정된다고 해도 저장소 용량이 1TB 까지 가는데 대충 30년이 걸린다. 게다가 텍스트 데이터는 보통 delta 압축이 되어서 실제 용량보다 훨씬 적게 늘어난다.

리소스의 경우 요즘 온라인 게임은 10GB 정도는 기본이고 그 이상도 많다. (역시 팀에 리소스 머신이 있어) 매일 10% 분량이 새롭게 수정된다고 하면 저장소 용량이 1TB 까지 가는데 대충 3년이 걸린다. 3년이 어떤 조직에는 충분히 길 수도 있겠지만 보통 개발 기간 + 라이브 기간 이렇게 합치고 보면 3년은 프로젝트 일생의 유년기 쯤에 해당한다.

문제는 저장소 용량이 커지면 일단 하드도 부족하고 버전 관리 시스템도 느려지고 백업도 어려워지고 이래저래 골치가 아파진다. 이럴 때 보통 최신 스냅샷 + 최근 이력 정보만 남기고 데이터를 지워버리는 저장소 세탁을 하게 된다. 그런데 소스랑 리소스가 같은 저장소에 있으면 소스 이력도 세탁이 되어버린다. 그러면 과거 로그, blame 을 할 수 없게 된다. 특히 몇 년쯤 된 코드의 버그를 추적하다가 로그가 없는 것을 발견하는 것만큼 당황스러운 것도 없다.

백업도 비슷한 이슈가 있다. 백업 공간이 충분하다면 그냥 저장소 내용 전부를 덤프해버리면 되겠지만 백업 용량이 부족하면 스냅샷만 남겨야 한다. 소스의 경우 과거 이력을 잘 보존하는 것이 중요하기 때문에 스냅샷만 남기는 것만으로는 부족하다. 만약 소스와 리소스의 저장소가 분리되어 있다면 '소스는 저장소 전부. 리소스는 스냅샷만.' 과 같이 스토리지 용량에 맞춰 융통성 있게 정책을 세울 수 있다.

사소하지만 권한 설정도 소스, 리소스 저장소가 분리되어 있다면 원천적으로 분리되니 도움이 된다.

소스랑 리소스가 분리되어서 서로 버전 매칭하기가 atomic 하지 않을 수 있는데 일단 대강 시간으로 맞출 수 있고, 좀 더 타이트 하게 하고 싶다면 tag, branch 등의 기능을 활용해 해결할 수 있다.

사용 / 관리 정책을 세워야 하고 관심을 기울여 줘야 한다.

저장소는 쉽게 오염된다. thumbs.db, project.ncb, 누구누구받아.zip 등 용량만 크고 올라갈 필요가 있는 것들이 올라간다. 이런 문제는 커밋 단계에 적당한 후킹 핸들러를 붙여 애초에 이런 파일이 커밋되지 않도록 시스템화 하는 것이 좋다. 비슷하게 커밋 로그를 강제하는 용도로도 사용할 수 있다.

저장 솔루션을 배포 시스템으로 사용하는 것은 추천하지 않는다. 예를 들어 프로그램 실행파일을 팀에서 공유하기 위해 배포 시스템에 올리는 것은 빠르게 저장소 공간을 소모하는 가장 쉬운 방법이다. 예를 들어 빌드로 생성되는 파일 용량이 50MB 라고 하고 매일 10번 정도 빌드가 이루어지면 365 일 열심히 일하고 나면 저장소 공간을 180GB 차지하게 된다. 가끔 있는 작은 크기의 파일이라면 모를까 일반적인 배포는 ftp, 공유 폴더, torrent 등 파일 공유를 위한 솔루션을 쓰는 것이 좋다.

로그를 남기는 방식, 커밋 주기, 단위 등은 팀에서 사용 방식을 결정하고 잘 지켜지도록 보조 시스템을 붙이고 모니터링 해야 한다. 장기적으로 잘 관리된 저장소와 친절한 로그는 개발 단계와 라이브 시기 모두에게 큰 힘이 된다.

저장소 용량 증감, 버전 관리 시스템 건강 상태를 주기 적으로 살펴야 한다. 그리고 불필요한 파일이 올라가는지, 저장소 사용 정책에 위배되는 것이 있는지 살피는 것도 중요하다. 버전 관리 시스템은 개발 시스템중 가장 중요한 시스템임을 잊어서는 안된다.

정리

버전 관리 시스템의 가장 기본인 저장소 관리에 대해한 가이드라인을 정리했다. 이걸 잘 하고 나면 이제 그 위에 폴더 구성, 브랜치 전략을 고민할 수 있다.

2012년 10월 21일 일요일

문자열 키의 map, unordered_map 성능 비교

map 과 unordered_map 은 키, 값을 저장할 수 있는 컨테이너다. map 은 Red-Black Tree 를 사용해 키의 순서를 유지하는 반면 unoredered_map 은 해시 테이블을 사용해 키의 순서를 유지하지 않는다. unordered_map 은 키의 순서를 유지할 필요가 없기 때문에 탐색 속도등에 유리한 점을 가질 수 있다. (아래 그림 좌: Red-Black Tree, 우: Separate Chaining Hash Table)


데이터가 N 개일 때 map 은 O(lgN) 의 탐색 속도를 보이고 unordered_map 은 O(1) 의 탐색 속도를 보인다. 때문에 데이터가 많을 수록 unordered_map 이 속도에 유리한데 문제는 "언제부터 유리한가?" 이다. 특히 키가 문자열일 때는 어떤 차이가 발생하는지가 궁금해 몇 가지 테스트를 해보았다. (문자열에 대한 궁금증은 지난번 Static String Table Lookup using Radix Tree 의 벤치마크 결과 때문이다)

Map, Unordered_map<int, ...>

먼저 다음과 같이 int 를 키로 받는 map, unordered_map 타입의 변수를 준비한다.
std::map<int, int> map;

std::unordered_map<int, int> umap;

struct bypass {
  size_t operator()(int v) const {
    return (size_t)v;
  }};
std::unordered_map<int, int, bypass> umap_raw;
unordered_map 의 경우 두 개를 만들었는데 하나는 hash 함수로 기본 hash<int> 를 사용하는 것과 나머지 하나는 키 값을 그대로 해시 값으로 사용하는 것이다. VC++ 의 unordered_map 은 해시값으로 부터 버킷을 찾아낼 때 해시값의 하위 b 비트를 보는 형태로 구현되어 있어서 키에 대한 hash 값의 하위 b 비트의 분포가 고른지가 중요하다. 때문에 기본 hash<int> 는 입력값의 하위 b 의 분포가 고르지 않더라도 그것을 고르게 만드는 역할을 한다. 하지만 입력 데이터의 키의 하위 b 비트가 애초부터 고르다면 그대로 해시값으로 쓰는 것도 고려해볼만 하다. 이를 확인하기 위해 hash<int> 사용 umap 과 bypass 사용 umap_raw 이렇게 2개를 만들었다.

다음은 데이터 N 개를 컨테이너에 넣고 탐색을 한다. 테스트는 간단히 [0, N) 범위의 정수를 키로 넣고 그 키들을 다시 탐색하는데 얼마나 걸리는지를 측정했다.
Test<T>
{
  T dict;
  for (int i=0; i<N; i++)
    dict.insert(make_pair(i, i));

  // Measure Elapsed Time
  for (int i=0; i<N; i++)
    dict.find(i);
}
그 결과는 다음과 같다. (실행 환경: Intel i7-3550 3.3GHz, Windows 7 SP1, VC++ 10 SP1)
X 축은 데이터 크기 N, Y 축은 탐색 1회에 걸린 시간 µs 이다.


먼저 map은 O(lgN) 의 형태를 보이는 것을 알 수 있다. 다만 128~512 구간에서 급격히 느려지는데 이는 캐시 미스의 영향으로 보인다. unordered_map 은 hash<int> 와 bypass 모두 O(1) 의 형태를 보인다. 특히 bypass 는 hash 단계에 시간을 소모하지 않아 hash<int> 에 비해 2배 빠른 것을 볼 수 있다. 이 그래프로 부터 hash<int> 을 사용했을 때는 N 이 32 부터, bypass 를 사용할 때는 N 이 6 일 때 부터 unordered_map 이 map 보다 탐색에 유리한 것을 알 수 있다. (다만 이 magic number 는 컴파일러, STL, 실행 환경에 종속적이니 참고에 주의해야 한다.)

그렇다면 문자열이 키인 경우에는 어떨까?

Map, Unordered_map<const char*, ...>

키로 넣을 문자열 집합을 정의하자. 먼저 문자열 길이를 M 이라 하고 문자열을 구성하는 문자 집합을 A 라고 한다. 예를 들어 문자열 길이 M=4, 문자 집합 A={0..9, A..F } 에 해당하는 문자열들의 예는 다음과 같다.
  • 0000 1101 ... 9A8E 9A8F ... FFFF
이 집합에서 사전식 순서로 전체 집합에서 균일한 간격을 가지는 데이터 4개를 뽑으면 다음과 같은 문자열을 고를 수 있다.
  • 0000 5555 AAAA FFFF
만약 동일한 집합에서 32 개를 뽑으면 다음과 같은 문자열을 고를 수 있다.
  • 0000 0842 1084 18C6 ... E738 EF7A F7BC FFFF
일반화 시키면 길이가 M 인 문자열 집합에서 N 개의 문자열을 뽑을 때는 사전식 순서로 번호를 매긴 문자열 리스트에서 0, A^M/(N-1), 2*A^M/(N-1) ... A^M-1 번째 문자열을 키로 사용한다고 할 수 있다.

이렇게 고른 키를 가지고 다음과 같은 map 과 umap 에 키, 값을 등록한다.
struct less_str {
  bool operator () (const char* a, const char* b) const {
    return strcmp(a, b) < 0;
  }};
std::map<const char*, int, less_str> map;

struct hash_str {
  size_t operator()(const char* s) const {
    size_t v = 0;
    while (char c = *s++) {
      v = (v << 6) + (v << 16) - v + c;
    return hash<int>()(v);
  }};
struct equal_str {
  bool operator () (const char* a, const char* b) const {
    return strcmp(a, b) == 0;
  }};
std::unordered_map<const char*, int, hash_str, equal_str> umap;
unordered_map 의 hash 함수로 sdbm 해시 함수를 사용했다. 그리고 그 결과를 바로 hash 값으로 사용하지 않고 hash<int> 를 한 번 더 실행해 사용했다. 왜냐하면 sdbm 해시 함수가 하위 b 비트의 균일성을 보장해주지 않기 때문이다. 이제 준비가 되었으니 아래와 같이 탐색!
Test<T>
{
  T dict;
  for (int i=0; i<N; i++)
    dict.insert(make_pair(k[i], i));

  // Measure Elapsed Time
  for (int i=0; i<N; i++)
    dict.find(k[i]);
}
결과는 아래와 같다. M 이 4, 8, 12, 16 의 경우를 테스트했고 X 축은 데이터 크기 N 이고 Y 축은 탐색 1회에 걸린 시간 µs 이다.

 


기본적으로 정수가 키인 경우와 비슷한 결과를 보인다. 하지만 M 의 크기 변화에 따라 재미있는 현상이 발생하는데 map 과 unordered_map 의 성능이 교차하는 점이 M 이 커짐에 따라 같이 커지는 현상이다. 이를 좀 더 잘 보기위해 M 에 따라 map 탐색시간 / unordered_map 탐색시간 비율 그래프를 아래와 같이 그려봤다.


M=4 일때 ratio 가 1이 되는 지점은 N=128~256 지점이고 M=16 일때 ratio 가 1이 되는 지점은 N=512~1024 지점이다. M 이 커질 수록 map 이 유리한 N 의 범위가 커지는 것을 볼 수 있다.  왜그럴까?

시간 복잡도

map 와 unordered_map 의 "문자열 단위" 탐색 작업의 시간 복잡도는 다음과 같다.
  • map: 
    • 노드 마다 문자열 비교: O(lgN)
    • 마지막 노드의 문자열 비교: O(1)
  • unordered_map:
    • 입력 문자열의 hash function 수행: O(1)
    • 입력 문자열과 버킷에 있는 문자열 비교: O(1)
이번에는 문자열 길이를 적용해 "문자 단위" 탐색 작업의 시간 복잡도를 계산해보자.
  • map: 
    • 노드 마다 문자열 비교: ?
    • 마지막 노드의 문자열 비교: O(M)
  • unordered_map: 
    • 입력 문자열의 hash function 수행: O(M)
    • 입력 문자열과 버킷에 있는 문자열 비교: O(M)
map 의 노드 마다 문자열을 비교하는 부분의 시간 복잡도를 구해보자. map 을 구성하는 트리가 완전하게 균형된 트리를 구축했다고 했을 때 M=4 의 트리는 다음과 같은 형태를 가진다.


먼저 키로 받은 데이터 분포 특성에 의해 d=0 노드는 첫 글자 만으로 분기가 가능하다. 이 부분이 중요한데 노드 분기를 위해 문자 M 을 모두 비교할 필요가 없기 때문이다. d=4 노드부터 두번째 문자까지 비교를 해야 한다. 이를 일반화 하면 노드 깊이 d 일 때 분기를 위해 필요한 문자 비교 횟수는 다음과 같다.


첫 노드부터 마지막 노드까지의 비교 횟수의 합은 다음과 같다.


따라서 비교에 필요한 횟수는 길이 M 과 관계 없이 N 에 종속적인 것을 알 수 있다. (이것은 키 문자열 집합의 분포 특성 때문에 그렇다)

map 과 unoredered_map 을 N 과 M 의 시간 복잡도로 표시하면 다음과 같다. (문자 단위)
  • map: O(lgN^2) + O(M)
  • unordered_map: O(M) + O(M)
map 은 M 의 길이에 덜 반응하고 unordered_map 은 M 의 길이에 그대로 반응하기 때문에 M 이 커짐에 따라 map 보다 unordered_map 의 속도 저하가 커질 수 있다.

하지만 위의 분포는 다소 인위적인 분포이며 실제 키 문자열 집합이 이러한 이상적인 분포를 가지는 경우는 드물다. 하지만 대부분의 데이터는 어느정도 이런 형태를 유지하는데 예를 들면 기본 컬러 16 의 이름은 다음과 같다.
  • black, blue, cyan, gray, green, lime, magenta, maroon,
    navy, olive, purple, red, silver, teal, white, yellow
이 집합은 N 이 16 이고 |A|=26 이기 때문에 모든 노드에서 첫번째 문자만으로 분기가 가능해야 한다. 하지만 (black, blue), (gray, green), (magenta, maroon) 의 경우에는 세 번째 문자까지 봐야 구분이 가능하다. 그럼에도 불구하고 특정 문자열 X 가 주어졌을 때 이 X 가 노드 분기에 필요한 문자 비교 횟수는 대부분 1이다. (예를 들어 magenta 가 주어졌을 때 maroon 을 제외하고는 모두 비교 횟수 1이다) 따라서 이 문자열 집합도 map 이 unordered_map 보다 탐색 속도에 우위를 가지게 된다.

하지만 만약 문자열의 형태가 아래와 같이 접두사가 비슷한 경우라면 비교 문자 개수가 크게 늘어나 전혀 다른 양상을 보인다.
  • abnormal abnomality abnomalities abnormalization

결론

몇가지 테스트를 해서 정리를 해보면 이렇다.
  • map, unordered_map 의 탐색 속도는 데이터 크기 N 이 작을 때는 map 이 클 때는 unordered_map 이 유리하다.
  • map 은 데이터 크기 N 이 커짐에 따라 unordered_map 보다 캐시 미스의 영향을 더 빨리 받는다. 이것은 map 이 탐색을 위해 여러 노드를 방문해야 하기 때문으로 보인다.
  • unordered_map 은 해시 함수의 성능이 중요하다. 특히 VC++ 의 경우에는 하위 b 비트의 고른 분포가 중요하다.
  • 문자열 키를 사용하는 경우 정수 키를 사용하는 경우에 비해 map 이 더 큰 N 까지 탐색 성능 우위를 가진다. 이것은 문자열 비교에 적은 비교 횟수를 필요로 하기 때문이다.
  • 따라서 문자열 길이가 길고 데이터 크기가 크지 않은 경우에는 map 이 unordered_map 보다 탐색에 유리하다.
map 도 좋고 unordered_map 도 좋다. 왜 이렇게 unordered 치는데 오타가 나는지는 모르겠지만...

2012년 10월 16일 화요일

Make from VC++ Project

작년에 있었던 팀의 서버 프로그램은 윈도우, 리눅스 양쪽에서 실행 가능했다. 윈도우에서는 Visual C++ 를 리눅스에서는 gcc 를 사용해서 빌드를 했다. 팀내 빌드, 테스트 시나리오는 다음과 같다.
  • 개발 단계에서는 Visual C++ IDE 를 사용해서 개발
  • 개발 테스트는 Visual C++ 혹은 gcc 사용 (개발자 선호에 따라)
  • 내부 테스트 및 실제 서비스는 gcc 로 빌드해 리눅스에서 서비스.
때문에 윈도우 빌드용 vcxproj 와 리눅스 빌드용 makefile 이 개별적으로 유지되고 있었다. 그래서 프로젝트 구성을 변경해야 할 경우 vcxproj 와 makefile 양쪽을 수정해야 하는 번거로움이 있었다. 양쪽을 동일하게 맞추는 것을 놓쳐 빌드가 깨지는 경우도 종종 있었다.

일반적으로 이런 상황을 해결하는 방법은 둘 다 커버할 수 있는 하나의 빌드 시스템을 사용하는 것이다. cmake, scons 가 대표적인 멀티 플랫폼 빌드 시스템이다. (Make Alternatives 에서 잘 다루고 있다.) 두 시스템이 잘 되어 있어보여 적용할지를 고민하다가 최종적으로 쓰지 않는 것으로 결정했다. 이유는 다음과 같다.
  • cmake, scons 는 마스터 빌드 설정 파일을 따로 만들어야 한다.
    즉 Visual C++ 프로젝트도 아니고 makefile 도 아닌 별도의 빌드 설정을 만들어야 한다. 이 마스터 빌드 파일을 가지고 각 플랫폼별 빌드 파일을 만들어 주거나 직접 빌드를 한다. 좋아보이나 마스터 빌드 설정을 하기 위해 모든 프로그래머가 사용법을 익혀야 하는 부담이 있다.
  • Visual C++ IDE 를 사용해 개발하는데 이 IDE 에서 파일 추가/삭제 하던 작업을 마스터 빌드 설정을 통해서 해야 한다. 쉬운 툴 작업이 번거로운 텍스트 편집 작업이 된다
  • cmake, scons 모두 Visual C++ 를 지원하지만 (여러 오픈 소스가 그러하듯) 우선순위가 낮다. 최신 버전 Visual C++ 지원이 엉성하거나 특정 컴파일 옵션을 사용할 수 없거나 할 수 있다.
만약 처음부터 cmake 나 scons 등을 썼다면 상황이 많이 달랐겠지만 중간에 빌드 솔루션을 바꾼다는게 간단하지 않을 뿐더러 이득보다 손해가 많을 상황이라 다른 해결책을 찾기로 했다. 그래서 생각해낸 것은
  • 윈도우 빌드는 원래 하듯 Visual C++ 프로젝트 파일로 빌드 
  • 리눅스 빌드는 make 가 Visual C++ 프로젝트 파일 내용을 읽어서 빌드
이렇게 하면 프로젝트 파일을 하나만 유지할 수 있어서 원래 해결하려 했던 문제가 자연스럽게 해결된다. 또한 새 시스템을 도입하는 것이 아니니 큰 학습 비용 없이 적응해 사용할 수 있다. 자 그럼 어떻게 할까?


Make, the Legacy

Make 는 1977 년에 첫 개발되어서 30 년 넘도록 Unix 계열 OS 에서 널리 사용되고 있다. Rule 의 집합으로 이루어진 파일 makefile 을 바탕으로 빌드 작업을 수행해 주는데 Rule 의 간결하고 강력한 특성으로 아직까지도 광범위 하게 사용되고 있다. 다음은 makefile 의 간단한 예제이다. 
# <rule>
# target: prerequisite
#   recipe

exe: main.o lib.o
  g++ main.o lib.o -o exe

main.o: main.cpp
  g++ -c main.cpp

lib.o: lib.cpp
  g++ -c lib.cpp
기본 개념이 강력한데 반해 (Rule 개념은 거의 모든 빌드 시스템에서 차용하고 있다.) 다만 워낙 옛날에 만들어지고 이러한 형태의 첫 프로그램이다 보니 Rule 외의 기능은 엉성하다. 변수, 제어, 함수 등 언어적 지원은 빈약하고 shell 명령으로 이루어져 다양하게 확장 가능한 recipe 에 비해 prerequisite 은 확장이 어렵다.

make 로 어떻게 하면 vcxproj 빌드를 할 수 있을까? 를 염두하며 Make Manual 을 읽고 나니. 쉽지 않겠구나 라는 생각이 들었다. (make 도 리눅스 개발 환경도 제대로 써본건 이때가 처음이기도 했다)

Import File List

기본 개념은 간단하다. Make 가 실행될 때 프로젝트 파일인 vcxproj 포함되어 있는 cpp 파일 목록을 가져와 makefile 에 넣기. 목록을 제외한 나머지 부분은 (예를 들면 컴파일 옵션) 미리 파일에 적어두고 변경이 많고 플랫폼별 차이가 없는 파일 목록을 끌어오면 되지 않을까?
  • 먼저 VC++ 프로젝트 파일로 부터 포함되어 있는 cpp 파일 목록을 추리는 기능을 만든다. makefile 로는 불가능해보여 (XML 파싱이 필요하므로) python 으로 작성한 스크립트를 만든다. (Import.py)
  • makefile 의 include 기능을 이용해 목록을 가져와 해당 cpp 파일들을 빌드하게 만든다.
Import.py 는 프로젝트 파일로 부터 파일을 읽어 아래와 같은 형태의 결과를 출력한다.
SRCS := main.cpp lib.cpp
OBJS := $(I)main.o $(I)lib.o

$(I)main.o : main.cpp
  g++ $(CXXFLAGS) -c $< -o $@

$(I)lib.o : lib.cpp
  g++ $(CXXFLAGS) -c $< -o $@
그 결과를 받아다가 make 가 처리하도록 makefile 에 아래와 같은 구문을 넣는다.
include $(shell Import.py Program.vcproj > l.tmp && echo l.tmp)

exe : $(OBJS)
  g++ -o $@ $(OBJS)
이제 빌드할 파일 목록을 자동으로 가져올 수 있게 되었다. 이 목록은 컴파일 뿐 아니라 clean 에도 사용된다. 정확히 어떤 파일이 있고 (SRCS) 어떤 중간 생성물을 만드는지 (OBJS) makefile 이 알기 때문에 clean 도 해당 파일들만 깨끗하게 지울 수 있다.

Include Dependency

앞서 만든 빌드 Rule 셋을 보면 사실 불완전한 부분이 있는데 바로 prerequisite 부분이다. C/C++ 은 cpp 파일 뿐 아니라 #include 헤더가 변경되어도 재컴파일을 해야 하는데 이 부분이 빠져있다. (예를 들어 main.cpp 가 lib.h 를 #include 하고 있다면 lib.h 가 변경되어도 main.cpp 가 재컴파일이 되어야 한다. 따라서 저 위 main.o 룰의 prerequisite 은 main.cpp lib.h 가 되어야 한다)

이 부분이 make 의 까다로운 부분인데 make 스스로 할 수 없으므로 (언어 종속적이라) 보통 makedep 과 같은 외부 프로그램을 사용해 makefile 파일 뒷부분에 dependency 룰을 추가하는 방식으로 동작한다. (make dep 이 그 과정을 수행한다) 반면 Visual C++ 의 경우 이 과정이 숨겨져 있어 자연스럽게 프로그래머는 이 부분을 신경쓰지 않아도 된다. 그래서 새 makefile 도 자연스럽게 #include 종속 관계를 얻을 수 있도록 Import.py 에 기능을 넣었다.
  • Import.py 가 실행될 때 Dependency 를 얻어올 수 있는 Rule 파일을 생성함
  • makefile 이 생성된 모든 Dependency Rule 파일을 가져옴
특정 C/C++ 파일이 어떤 파일에 대해 #include 종속을 가지는지는 gcc 를 사용해 쉽게 알아낼 수 있다. gcc 의 -M 과 -MM 옵션을 사용하면 다음과 같은 형태로 종속 룰을 출력한다.
main.o : main.cpp lib.h
이와 같은 gcc 기능을 사용하도록 Import.py 는 프로젝트 파일로 부터 파일을 읽어 아래와 같은 형태의 결과를 출력한다.
DEPS := $(I)main.d $(I)lib.h

$(I)main.o : $(I)main.d
$(I)main.d : main.cpp
  g++ -MM $< | sed 's,\(.*\)\.o[ :]*,$(I)\1.o $(I)\1.d : , ' > $@

$(I)lib.o : $(I)lib.d
$(I)lib.d : lib.cpp
  g++ -MM $< | sed 's,\(.*\)\.o[ :]*,$(I)\1.o $(I)\1.d : , ' > $@
file.d 형태의 dependency 파일을 생성하는 Rule 이다. file.o 보다 먼저 file.d 를 생성하기 위해 prerequisite 설정해준다. 또한 file.o 뿐 아니라 file.d 도 #include dependency 가 걸리도록 sed 로 gcc 결과를 살짝 수정해준다. Recipe 에 의해 생성된 main.d 파일은 다음과 같다.
main.o main.d : main.cpp lib.h
생성된 *.d 파일을 make 가 처리하도록 makefile 에 아래와 같은 구문을 넣는다.
include $(filter $(DEPS),$(wildcard $(I)*.d))
이렇게 해서 Include Dependency 를 해결한다. 별도의 make dep 과정 없이 build 요청에 따라 알아서 재컴파일이 필요한 소스 파일을 make 가 잘 찾아 주게 되었다.

Precompiled Header

Visual C++ 프로젝트는 precompiled header 를 사용하도록 되어있고 gcc 프로젝트는 그렇지 않도록 되어 있었다. Precompiled header 를 사용하면 gcc 에서도 빌드 시간이 단축되므로 gcc make 에서도 사용하도록  Import.py 에 기능을 추가했다.
  • Import.py 가 실행될 때 프로젝트 파일에서 Precompiled header 파일을 찾아서 gch 빌드 명령을 추가한다.
  • gch 가 다른 파일 보다 먼저 빌드가 되어 소스파일의 #include 경로에 존재하면 gcc 가 빌드 때 gch 파일을 우선적으로 사용한다. (VC++ 와 명시적으로 지정하지 않아도 된다.)
Import.py 는 다음과 같은 형태의 명령을 추가한다.
GCHS := $(I)pch.h.gch

$(I)pch.h.gch : pch.h 
  g++ $(CXXFLAGS) -x c++-header $< -o $@

$(OBJS) : $(GCHS)
간단히 gcc 빌드에도 precompiled header 를 사용할 수 있게 되었다.

More

작업을 하다보니 있으면 편한 자잘한 기능이 추가되었다. 추가한 기능을 정리하면
  • 자동 병렬 빌드 기능
    빌드가 되는 CPU 의 코어 개수를 파악해 권장 개수 만큼 make 의 병렬 빌드 기능을 사용한다.(make 의 -j 옵션 사용)
  • vcxproj 파일을 지정하지 않았을 때 실행 폴더에 vcxproj 가 1개만 있는 경우 그 파일을 자동 선택.
    이런 기능은 make 도 있는데 은근히 편리하다.
  • Prebuild, Postbuild 이벤트 지원
    프로그램의 Version 정보를 태깅하거나 symbol 등록을 할 때 사용한다.
  • Google Performance Tools 빌드 옵션 지원
  • 빌드에 필요한 라이브러리 지정 기능
처음에 생각했던 것보다 더 많은 기능이 추가 되었는데 이는 서버 관련 프로젝트가 10개 이상이었고 기능을 추가했을 때 이득을 볼 프로젝트가 많아서 였다. 추가 기능들로 makefile 은 다음과 같은 정보만 가지고 있었다.
  • 종속 라이브러리 목록. 
  • 출력 폴더/파일 이름. 
  • 컴파일, 링크 옵션. 
  • 병렬 빌드 여부, Google Performance Tools 사용 여부 등 기타 옵션.
다음은 위 작업이 적용된 어느 서버의 Makefile 내용이다.
OUTDIR = ./Bin/
OUTPUT = $(OUTDIR)server

DEPLIB = Core Network Data

CXXFLAGS_CUSTOM = $(call lib_I,MySQL) -I./Bin -D_CHECK
LDFLAGS_CUSTOM  = $(call lib_L,MySQL) -lmysql

include MakefileTemplate
MakefileTemplate 은 Import.py 를 사용하며 다음과 같다. (일부만 표시)
.PHONY : BuildEntry
BuildEntry : $(MAKE_BUILD_DEFAULTCONFIG) ;

ifneq ($(CONFIG),)
  ifndef VCXPROJ
    VCXPROJ := $(wildcard *.vcxproj)
  endif
  include $(shell Import.py $(VCXPROJ) > l.tmp && echo l.ret)
  include $(filter $(DEPS),$(wildcard $(I)*.d))
endif

$(OBJS) $(DEPS) $(GCHS) : | $(I)

$(I) :
  mkdir -p $(I)

결론

간단한 파이썬 스크립트와 make script 로 로 원하던 목표를 달성했다. (파이썬 스크립트와 관련 make 스크립트의 라인 수가 200 이 채 되지 않는다)