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 로 보관하지 않고 네트워크 폴더의 이미지 파일로 보관하니까)