2017-10-30

[C++] C++ Core Guidelines - modern C++을 위한 안내서

 C++은 사용하기 어려운 언어이다. C++ 이후에 나온 언어들에 비해 사용자에게 너무 많은 자유도를 주기 때문에 안전하게 사용하기 어렵다. 그래서 구글, 웹킷, LLVM 등 많은 스타일 가이드들이 단순히 문법적인 포맷을 어떻게 작성할지 디자인에 대한 부분도 같이 제안한다.

 오늘 소개할 C++ Core Guidelines도 이런 스타일 가이드 문서 중 하나다. modern C++, 즉, C++ 11 이후 C++을 어떻게 사용할지에 초점을 맞힌 가이드 문서로, C++의 창시자인 Bjarne Stroustrup이 주축이 되어 작성되었다. C++ Core Guidelines는 다른 문서보다 C++ 자체를 어떻게 하면 더 안전하게 쓸 수 있을지에 초점을 두기 때문에 문법적인 포맷에 대한 조언은 거의 하지 않는다. 그보다는 어떻게 하면 로직의 문제를 최대한 빠르게, 가능하면 컴파일 타임에 잡을 수 있는지에 대한 디자인 조언을 많이 한다. 개인적으로는 지금까지 읽었던 modern C++ 코딩에 관한 문서 중 가장 실용적인 문서라고 생각한다.

 C++ Core Guidelines를 읽다 보면 GSL이라는 단어가 자주 나온다. C++ Core Guidelines는 반복되는 몇 가지 사용 패턴을 위해서 몇몇 라이브러리를 사용할 것을 권하고 있다. 이를 Guideline Support Library, 줄여서 GSL이라고 부른다. 정의상으로는 가이드라인에서 제안하는 GSL의 스펙을 만족하는 라이브러리는 모두 GSL이라고 부를 수 있다. 하지만 현존하는 구현체는 Microsoft에서 만든 GSL뿐이기 때문에 사실상 Microsoft의 GSL과 같은 의미라고 생각해도 된다.

 앞으로 시간 나는 대로 C++ Core Guidelines와 GSL의 내용 중에서 좋아하는 항목 몇 개를 소개할 생각이다. 하지만 그와는 별개로 앞으로 계속해서 C++ 프로그래머를 할 생각이라면 한 번쯤은 읽어보기를 추천한다.

2017-10-17

crash-only software - high availability server 만들기

 지난번 글에서 high availability 서버를 만들기 어려운 이유를 설명했었다. 그럼에도 high availability 서버를 만드는 것은 중요하기 때문에 availability를 높이기 위한 여러 가지 방법들이 존재한다. 이번에 설명할 crash-only software도 그중 하나다.

 Crash-only software의 기본 철학은 해결할 수 없는 문제가 발생했을 때, 다른 시도를 하지 않고 바로 종료시키는 것이 오히려 availability를 올린다는 것이다. 이렇게 하면 서버가 떠 있는지만 검사하면 되기 때문에 서버에 문제가 생겼는지 바로 알 수 있고, 문제가 생기면 바로 crash로 끝내기 때문에 빠르게 종료할 수 있다.

 다만 crash-only software는 언제든지 죽을 수 있기 때문에 persistence layer가 프로그램 외부로 빠져야 한다. 즉, 로직 레이어와 데이터 레이어가 구분되는 multitier architecture가 된다.

 자연스럽게 로직 레이어는 스테이트를 가지지 않게 되기 때문에 컴포넌트별로 독립시키기 쉽기 때문에 자연스럽게 마이크로 서비스로 만들게 되고, 서비스 하나의 크기가 줄어들기 때문에 재시작에 걸리는 시간도 줄어들고, 자연스럽게 availability가 증가하게 된다.

 또한, crash-only-software에서 사용하는 공유 자원은 사용권을 소유한다는 개념이 아니라 사용권을 빌린다는 개념으로 접근해야 한다. 사용권을 빌렸기 때문에 명시적으로 사용권을 돌려주지 않았더라도 일정 시간이 지나면 사용권을 잃고 다른 컴포넌트에서 사용할 수 있어야 한다.

2017-10-13

high availability 서버를 만들기 어려운 이유

 서비스에서 availability를 보장해주는 것은 매우 중요하다. three-nine(99.9%)의 availability를 보장하려면 일 년에 아홉 시간 이하의 다운타임만 있어야 하고, four-nine(99.99%)을 보장하려면 약 한 시간 이하, five-nine(99.999%)을 보장하려면 일 년에 다운 타임이 오 분 이하여야 한다.

 보통 서버 장애는 서버가 가장 바쁠 때 발생하기 때문에 가능하면 다운 타임을 줄이는 것이 중요하지만, 현실적으로 이는 쉽지 않다. 상용 서비스 중에서도 three-nine 이상 보장하는 서비스를 찾기 힘들고, 어느 정도 이름 있는 서비스들은 돼야 four-nine, 정말 안정화가 잘 돼 있는 서비스들만이 five-nine 이상의 availability를 보장한다.
 이는 high availability 서버를 만드는 것이 근본적으로 어려운 일이기 때문이다. 다운 타임이 적은 서버를 만들기 위해서는, 일단 버그가 없는 것은 기본이어야 한다. 하지만 서버 다운의 이유가 버그만 있는 게 아니다. 디스크나 네트워크 등 하드웨어 문제로 예상치 못하게 서버를 사용할 수 없게 되는 경우도 있다. 따라서 high availability 서버를 만들기 위해서는 문제가 생겼을 때 빠르게 재시작하는 것이 가장 중요한데 이를 위해서는 서버에 문제가 발생했을 때 이를 감시하고 있던 모니터가 빠르게 감지하여 서버에 문제가 발생하면 서버를 안전하게 죽이고, 새 서버를 띄워야 한다.

  보통 서버에서 일정 시간 간격으로 메시지를 보내고 메시지가 오지 않으면 죽은 것으로 판단하는 heartbeat 방식을 많이 사용한다. 이때 heartbeat 자체는 보통 몇 초에 한 번 보내지만, 실제 서버가 죽지는 않았지만, 네트워크나 다른 문제로 메시지가 오지 않았을 것을 대비하여 몇 번의 메시지가 도착하지 않았을 때 서버가 죽었다고 판단한다. 즉, 실제로 서버에 문제가 발생해도 그를 감지하는 데만 적게는 십수초 많게는 몇십초의 시간이 걸린다.
 그다음 문제는 서버를 빠르고 안전하게 죽이는 것이다. 보통은 프로그램이 죽는 것은 크게 신경 쓰지 않아도 된다. 프로그램이 죽으면 프로그램에서 사용하던 리소스는 OS가 알아서 처리해준다. 하지만 프로그램이 파일 시스템 등의 공유 자원을 사용하고 있었다면, 문제가 복잡해진다. 이 경우 수정한 공유자원을 원래대로 되돌리거나 처리하던 작업을 완료할 때까지 프로그램의 종료를 기다려야 한다.
 마지막 문제는 서버를 빠르게 띄우는 것이다. 단순히 서버를 실행시키는 것은 시간이 오래 걸리지 않는다. 하지만 실행된 서버가 클라이언트의 요청을 정상적으로 처리할 수 있는 상태가 되는데 얼마나 걸릴지는 다른 문제다. 서버에서 사용해야 하는 다른 자원이 있으면 이 자원들을 초기화해야 하고, 서버에서 다른 서비스들을 이용하고 있다면 이 서비스들과의 접속도 다시 확인해야 한다. 게다가 하드웨어의 문제로 서버가 죽은 경우라면 기존에 사용하던 공유자원을 깔끔하게 정리하지 못하고 죽었을 것이기 때문에 이를 정리하는 일까지 해야 한다.

 일반적으로 이상의 작업을 하면 서버에 문제가 발생하여 다시 시작하는 데까지 몇 분의 시간이 소모된다. 즉, 1년에 몇번의 장애만 발생해도 five-nine은 물론이고 four-nine조차 달성하기 어렵다.

2017-10-09

[C++] array to pointer decay

 C++은 그 근본이 C에서 나왔기 때문에 C에서 많은 문제점을 물려받았다. 대표적으로 implicit conversion이 그런데, 그중에서 array가 pointer로 implicit conversion 되는 것을 decay. 혹은 다른 decay들과 구분하기 위해 array to pointer decay라고 부른다. 이는 말 그대로 T[N] 타입으로 선언된 변수의 값이 T* 타입의 변수에 바인딩 될 때 implicit casting이 되는 것을 말한다. 이는 컴파일 타임 정보인 배열의 크기를 잃는다는 문제도 있지만, 그보다 더 큰 문제는 포인터에 대해서 upcasting 또한 implicit conversion이 가능하다는 데서 생긴다.

 위와 같은 코드가 있다고 해보자. A를 상속받은 B 클래스가 있고, B instance의 크기는 A instance의 크기보다 크다(정확히 따지자면 사용하는 alignment의 크기가 B 클래스보다 작은지 따져봐야 하지만, 여기서는 설명을 간단히 하기 위해 int 크기로 align 된다고 가정하고 진행하도록 하겠다). 이때 f 함수가 하는 일은 다음과 같다.

  1. B(3, 4)를 생성한다.
  2. 1에서 생성한 객체의 A 부분만 slice 한 A(3)을 인자로 받은 a 위치에 넣는다.
  3. B(5, 6)를 생성한다.
  4. 3에서 생성한 객체의 A 부분만 slice 한 A(5)를 인자로 받은 a에 1 더한 주소에 저장한다.
 앞에서 말했듯이 위의 코드는 일단 포인터는 실제 배열의 크기를 알지 못하기 때문에 f 함수가 받은 a의 길이가 2 이상인지 알 수 없다는 문제가 있다. 하지만 이보다 더 큰 문제가 있다. 만약 f 함수를 아래와 같이 사용했다고 해보자.  B[4] 타입인 aB* 타입으로 decay 되고, B*는 부모 타입인 A*로 upcasting 되어 f 함수에 넘어간다.

f 함수를 실행한 뒤 사용자가 원했던 모습은 아마 위와 같은 모습일 것이다. 혹은 object slice가 일어난다는 것을 생각하지 않고 아래와 같은 결과를 생각했을 수도 있다.
 하지만 실제 실행한 결과는 다음과 같은 값을 가지게 된다.

 이는 실제 정의된 메모리 레이아웃과 f 함수가 해석한 메모리 레이아웃이 다르기 때문이다.

실제 레이아웃
f가 해석한 레이아웃
 우리는 분명히 B의 배열을 선언하였다. 따라서 실제 메모리에는 B의 인스턴스가 연속으로 존재한다. 하지만 f 함수가 인자로 받은 타입은 A의 포인터 타입이기 때문에 이를 B가 4개 존재하는 메모리가 아닌 A가 8개 존재하는 메모리로 해석하게 된 것이다. 따라서 a[1] = B(5, 6)를 실행하였을 때 2번째 B가 있는 메모리가 아닌 첫 번째 Bint b에 해당하는 영역에 두 번째 A 인스턴스가 존재한다고 생각하고 A(5)를 할당하게 된 것이다.

 이 문제를 해결하는 방법은 크게 2가지 있다. 첫 번째 방법은 함수를 정의할 때 배열을 넘기기 위해서 포인터를 사용하지 않는 것이다. 이는 함수에 넘겼을 때 배열의 크기라는 정보를 잃지 않기 위해서이기도 한데 C++ Core Guidelines에서 추천하는 방법이기도 하다.

 두 번째 방법은 배열을 선언할 때 C 스타일의 []가 아닌 std::array를 사용하는 것이다. std::array는 C++ 11에 추가된 배열을 표현하기 위한 타입으로, C 스타일 배열과 다르게 이터레이터와 begin, end 등이 정의돼있어서 다른 표준 컨테이너들과 같은 코드를 공유할 수 있다는 장점이 있다. 하지만 여기서는 코드를 재사용할 수 있다는 것보다 이는 템플릿 클래스이고 따라서 AB의 부모일 때, std::array<A, N>std::array<B, N>의 부모가 아니라는 특성이 더 중요하다. 따라서 template<int N> void f(std::array<A, N>& a)라는 함수를 호출할 때 인자로 Bstd::array를 넘길 수 없기 때문에 위와 같은 문제가 발생하지 않는다.

[C++] Variadic template

 전통적인 C나 C++에서 잘못 쓰면 위험한 것 중 하나로 stdarg가 있었다. 이를 이용하면 임의의 개수의 인자를 받는 variadic function을 만들 수 있지만, 함수의 시그니쳐를 통한 타입 체크를 완전히 무시하기 때문에, 함수의 호출자가 규약을 지키지 않고 함수를 사용하더라도 컴파일 타임에 문제를 잡을 수 없기 때문에 실행 시에 여러 가지 문제가 발생했다. 이런 문제를 피하고자 stdarg를 사용하지 않고, 예상되는 인자의 최대 개수를 예상해서 받는 인자의 개수가 다른 함수를 오버로딩하여 사용하는 경우가 더 많았다.

 C++11에서는 variadic function을 타입 시스템과 붙여 컴파일 시간에 타입 체크 할 수 있게 하려고 variadic template이라는 문법을 추가하였다. Variadic template을 사용하면 사용된 인자의 타입 별로 instantiation 되기 때문에 바이너리의 크기는 커지지만, 컴파일 타임에 타입 체크를 할 수 있으며 실행 시에 생기는 비용을 줄여준다. Variadic template이 될 템플릿 인자는 ellipsis(...)를 붙여 다음과 같이 사용한다.  Variadic template을 사용하는 패턴은 크게 2가지이다.

 첫 번째는 다른 함수로 인자를 전달하는 것이다. 대표적으로 make_unique가 이런 경우에 속한다. Possible implementation에서 볼 수 있듯이 make_uniquestd::forward(args)...를 이용해 T 타입의 생성자에게 모든 인자를 전달한다. 여기서 std::forward 함수 호출 뒤에 ellipsis가 붙어있다는 것이 중요하다. 이를 pack expansion이라고 하는데 std::forward 함수에 인자를 넘기는 패턴을 variadic arguments인 args에 전부 적용하는 것이다. 따라서 argsN 개의 인자였으면 번의 std::forward 함수가 불리게 된다. 이때 단순히 전달하는 것이 아니라 값을 수정해서 다른 함수로 전달할 수도 있다.  위의 예제가 값을 수정하여 전달한 예제이다. 위의 예제는 variadic arguments인 args의 각각을 mapper에 적용한 뒤 reducer에 전달한다. 따라서 args는 모두 같은 타입이어야 하며, mapperargs중 하나를 인자로 받는 함수여야 하고, reducermapper의 리턴 타입들을 args의 개수만큼 받아 하나의 리턴값으로 만드는 함수여야 한다.

 두 번째 패턴은 base case를 만들고 재귀적으로 호출하는 함수를 정의하는 것이다.  위의 코드가 재귀적으로 정의한 주어진 숫자를 전부 합치는 코드이다. operator+가 정의된 모든 타입이 아닌 숫자만 인자로 받기 위해서 std::enable_if를 이용해서 인자로 받는 타입을 제한했다. 이번에 설명할 내용은 아니니 그것을 제외하고 보면, 아무런 인자를 받지 않는 int sum() 함수가 base case가 되고, 인자가 하나 이상일 때는 T t와 variadic arguments를 인자로 받는 함수를 정의하였다. 그러면 sum의 인자가 없을 때는 base case가 불리고, 인자가 하나 이상일 때는 두 번째 정의된 함수가 불린다. 이때는 첫 번째 인자는 T t에 매칭되고 나머지 인자는 Args... args에 매칭된다.
 아니면 sum 함수를 아래와 같이 정의할 수도 있다.  이 sum 함수는 인자 하나를 받는 함수를 base case로 삼았다. 인자를 넘기지 않고 함수를 부르면 컴파일 에러가 발생하고, 인자를 하나만 사용하여 함수를 부를 때 sum(T t) 함수가 불린다. 따라서 인자를 2개 이상 넘길때만 variadic function이 호출된다.