당신의 코드를 더욱 근사하게 만들어 줄 모던 C++의 신무기, 스마트 포인터(Smart pointer)와 무브 시맨틱(Move semantics).
C++가 표준화된 1998년으로부터 13년 뒤 처음으로 발표된 첫번째 주요 업데이트는 바로 C++에 야심차게 '모던'을 붙여준 C++11 표준입니다. 그로부터 3년뒤, C++14 이 발표됨에 따라, 13년의 세월동안 C++ 표준화 커미티들이 목표로 해왔던 새로운 C++ 기능 집합 표준화에 종지부를 찍게됩니다.
여러분들이 지금바로 조금만 구글링을 해보아도, 모던 C++이 갖는 다양한 새로운 기능들이 무엇인지 확인해 볼 수 있습니다. 오늘 이 글에서는, 그 중에서도 성능 향상에 큰 패러다임을 가져다온 스마트 포인터(Smart pointer)와 무브 시맨틱(Move semantics)에 대해 알아보고자 합니다.
스마트 포인터
C/C++ 세계에서 가장 중요시되어 온 것 하나를 꼽자면, 그건 바로 성능입니다. 제가 C++을 종종 가르쳐 온 입장에서 볼 때 C++의 언어나 라이브러리의 특정 부분에 대해 "왜 이 부분은..." 로 시작하는 대부분의 질문에 "그건 바로 성능 때문이죠!"라고만 답변해도 90%는 정답이라고 봐도 무방할 정도입니다.
기본적인 C/C++의 포인터는 로우 레벨에서 다뤄지는 만큼, 컴퓨터 입장에서는 매우 빠르게 처리될 수 있는 하나의 기초적인 수단일지라도, 어찌되었든 프로그래머 입장에서는 심심하면 에러만 뱉어내는 친구입니다.
지난 수십년간, 다양한 어플리케이션들이 요구하는 리소스는 날로 커져만 갔고, 하드웨어가 발전하면 할 수록 성능에 대한 갈망도 끊임없이 이어져 왔습니다. 메모리 릭(Memory leaks), 세그폴트(segfaults) 그 외에 고문이나 다름없는 디버깅 창을 하염없이 바라보는 것은, 사실 여러분들의 성능에 대한 갈망을 채워준 악마같은 포인터에게 지불해야 할 댓가일지도 모릅니다.
C/C++ 기본 포인터의 문제점은 '잘못 사용하기 딱 좋다'는 점입니다. 예를 들면, 초기화 하는걸 까먹었다거나, 동적 할당을 해놓고 해제를 안 한다거나, 해제를 하고 또하려하거나… 물론 자신의 부주의함을 탓하며 조금 더 세심하게 바라보면 얼마든지 이런 실수를 줄일 수 있고, 얼마든지 완벽하게 이런 문제들로부터 벗어날 수 있습니다. 물론 그냥 스마트 포인터를 쓰면 바로 해결됩니다. 스마트 포인터는 기존의 포인터를 감싸고 이 녀석을 스마트하게 환골탈태시킨 클래스 템플릿입니다. 사실 C++98 에서도 auto_ptr 템플릿을 제공했었습니다. 스마트 포인터의 일부 기능을 제공했었으나 각종 미묘한 것들까지 언어 차원에서 완벽하게 제공하기엔 역부족이었습니다. C++11을 시작으로 이에 대한 지원이 비로소 시작되었고, C++14에 이르러서는 더이상 기본의 포인터를 쓸 필요 조차 없어졌습니다. 물론, 정말 간혹 new와 delete를 필요에 따라 쓰는 경우도 아직은 있습니다. 동적 메모리 할당을 다루는 코드들의 안전성(예외 처리를 비롯한)은 모던 C++에 이르러 깃털만큼 무거워지고 날개가 달린 셈이 된 정도로 완성도를 갖추게 되었습니다. 아마도 스마트 포인터의 추가야말로 모던 C++에 있어서 패러다임의 전환이라 일컫을 정도로 넓은 범위에서, 강한 인상을 남겼다고 생각합니다.
아래의 단적인 예를 통해 스마트 포인터와 기존 포인터의 근본적인 차이를 감상해보도록 하죠.
// ----------------------------
// 기존의 C/C++ 포인터
// ----------------------------
Widget *getWidget();
void work()
{
Widget *wptr = getWidget();
// 여기서 Exception 발생 혹은 Return하게 될 경우, *wptr 메모리 해제는 어쩌죠?
delete wptr; // 직접 명시적으로 메모리를 해제해야 함.
}
//-----------------------------
// 모던 C++의 unique_ptr(역주 : 스마트 포인터의 일종)
//-----------------------------
unique_ptr<Widget> getWidget();
void work()
{
unique_ptr<Widget> upw = getWidget();
// Exception이 발생하거나 중간에 Return 하더라도 별도로 신경 쓸 필요가 없습니다!
} // 메모리도 알아서 자동으로 해제됩니다.
무브 시맨틱
C++11 이전부터 근본적으로 성능의 발목을 붙잡는 녀석이 있었습니다. 바로 밸류 기반 시맨틱(value-based semantics)이 불필요하게 데이터를 복사함으로써 발생하는, 보이지 않는 숨은 비용들입니다. 예를 들어 C++98에서 아래와 같이 함수를 정의했다고 가정해봅시다.
vector<Widget> makeWidgetVec(parameters);
CPU 싸이클 하나에도 조심스러운 프로그래머들에겐 보기만해도 그저 공포스러울 따름입니다. 왜냐하면 Widgets 오브젝트 한 무더기가 vector로 묶여 return by value(역주: 이는 딱히 번역하기 어려울 것 같습니다. 이쪽 분야에서 통용되는 개념이라 생각합니다)가 발생한다면 엄청난 성능 저하를 야기할 수 있기 때문입니다 (Widget이 리소스에 굶주린 녀석들이라면 문제는 더욱 심각해집니다).
C++98에 따르면, vector는 함수 내에서 생성되어 return시 vector를 복사하도록 동작합니다. 아무튼 최소한 위 경우는 그런 식으로 동작하고 맙니다. 만일 각 Widget을 복사하는데 상당한 연산이 든다면, 전체 vector를 복사하는 작업은 말할 것도 없이 비효율적이겠지요. 역사적으로, 컴파일러 단에서는 RVO(Return Value Optimization)라 불리는 기법을 통해 이러한 vector의 복사 비용을 사전에 차단하려는 노력이 있었습니다. 하지만 이는 단지 Optimization 일 뿐, 모든 경우에 컴파일러가 이를 파악하고 막아주도록 도와주지는 않습니다. 따라서, 이러한 위험은 완벽하게 예측되고 방어되지 못 해왔습니다.
이제 모던 C++에서 등장한 무브 시맨틱을 통해 이러한 불확실성을 한 번에 날릴 수 있게 되었습니다. 아무리 Widget 클래스가 ‘move-enabled’가 아닐지라도, vector 템플릿 자체는 move-enabled이므로 함수로부터 Widget을 위한 임시 컨테이너를 return by value로 넘겨주는 것은 아무래도 이전보다 효율적으로 동작하게 됩니다. 또한, Widget 클래스가 move-enabled(move와 관련한 동작은 플랫폼에 관계없이 일관적으로 동작합니다. 플랫폼 의존적 최적화가 아니기 때문이죠)라면 함수에서 return시 요구되는 오버헤드가 극적으로 감소하게 됩니다. 이와 유사한 예는 할당된 메모리 초과로 인해, 재할당을 필요로 하는 vector의 경우에도 확인할 수 있습니다. 아래 코드에서 Widget 이라는 이름의 클래스를 2가지 버전으로 구현하였습니다. 먼저 “구닥다리" 버전부터 감상해보도록 하죠.
#include <cstring>
class Widget
{
public:
const size_t TEST_SIZE = 10000;
Widget() : ptr(new char[TEST_SIZE]), size(TEST_SIZE)
{}
~Widget() { delete[] ptr; }
// 복사 생성자:
Widget(const Widget &rhs) :
ptr(new char[rhs.size]), size(rhs.size) {
std::memcpy(ptr, rhs.ptr, size);
}
// 복사 할당 오퍼레이터:
Widget& operator=(const Widget &rhs) {
Widget tmp(rhs);
swap(tmp);
return *this;
}
void swap(Widget &rhs) {
std::swap(size, rhs.size);
std::swap(ptr, rhs.ptr);
}
private:
char *ptr;
size_t size;
};
// 위 프로그램의 테스트 결과:
//
// vector<Widget> 의 사이즈 : 500000
// vector의 할당 사이즈를 초과하여 push_back()할 경우 걸리는 시간 : 53.668
무브 시맨틱을 이용한 버전은 아래와 같습니다.
#include <cstring>
#include <utility>
class Widget
{
public:
const size_t TEST_SIZE = 10000;
Widget() : ptr(new char[TEST_SIZE]), size(TEST_SIZE)
{}
~Widget() { delete[] ptr; }
// 복사 생성자:
Widget(const Widget &rhs) :
ptr(new char[rhs.size]), size(rhs.size) {
std::memcpy(ptr, rhs.ptr, size);
}
// 무브 생성자:
Widget(Widget &&rhs) noexcept : ptr(rhs.ptr), size(rhs.size) {
rhs.ptr = nullptr; rhs.size = 0;
}
// 복사 할당 생성자:
Widget& operator=(const Widget &rhs) {
Widget tmp(rhs);
swap(tmp);
return *this;
}
void swap(Widget &rhs) noexcept {
std::swap(size, rhs.size);
std::swap(ptr, rhs.ptr);
}
// 무브 할당 생성자:
Widget &operator=(Widget &&rhs) noexcept {
Widget tmp(std::move(rhs));
swap(tmp);
return *this;
}
private:
char *ptr;
size_t size;
};
// 결과:
//
// vector<Widget> 의 사이즈 : 500000
// vector의 할당 사이즈를 초과하여 push_back()할 경우 걸리는 시간 : 0.032
위에서 사용된 테스트 프로그램은 간단한 timer 클래스를 이용하여 vector<Widget>에 50만개의 인스턴스를 담은 뒤 vector의 메모리 할당 한계에 도달한 상태에서 추가로 1번의 push_back()을 수행하였을 때의 소모된 시간을 측정한 결과입니다. C++98의 경우 push_back() 은 거의 1분을 소모하였고(참고 : 고대 유물이나 다름없는 저의 Dell Latitude E6500 상에서의 결과입니다) 반면 모던 C++에서는 move-enabled 버전으로 Widget 클래스를 새롭게 정의함에 따라, 같은 push_back() 수행에 고작 0.31초 밖에 소요되지 않았습니다. 제가 사용한 ‘간단한' timer 클래스는 아래와 같습니다.
#include <ctime>
class Timer {
public:
Timer(): start(std::clock()) {}
operator double() const
{ return (std::clock() - start) / static_cast<double>(CLOCKS_PER_SEC); }
void reset() { start = std::clock(); }
private:
std::clock_t start;
};
그리고 제가 사용한 테스트 프로그램도 아래와 같이 함께 정리하였습니다.
#include <iostream>
#include <exception>
#include <vector>
#include "Widget2.h"
#include "Timer.h" // 타이머 클래스 헤더파일
int main()
{
using namespace std;
vector<Widget> vw(500000);
while (vw.size() < vw.capacity())
vw.push_back(Widget());
cout << "Size of vw: " << vw.size() << endl;
Timer t; // 현재 시각으로 타이머 클래스를 초기화
vw.push_back(Widget());
cout << "Time for one push_back on full vector: " << t << endl;
}
모던 C++이 제공하는 무브 시맨틱의 효과는 이와 같은 단적인 예에서의 성능 향상으로도 두각을 나타낼 수 있습니다. 하지만 다양한 라이브러리들로 겹겹이 쌓여진 코드 레벨에서는 이를 쉽게 파악하긴 어렵습니다. 때문에 제 생각에 무브 시맨틱은 ‘스텔스' 느낌의 기능이라고 생각합니다. 반면 스마트 포인터는 소스 코드 레벨에서도 쉽게 그 간결성과 위력을 확인할 수 있습니다.
*****
Leor Zolman는 지난 40년간 CP/M, Unix는 물론 Windows계열의 시스템/어플리케이션 소프트웨어 디자인, 구현 및 시스템 관리자를 거쳐 IT 교육계에 몸담아 왔습니다. 1979년 Leor는 BD 소프트웨어 C 컴파일러(“BDS C”)를 만들었으며, 이는 첫 8080/Z80 네이티브 코드 C 컴파일러이자 개인 컴퓨터 상에서, 개인 컴퓨터를 타겟으로 제작되었습니다. Leor는 켄자스 주의 로렌스시에 위치한 R&D Publications, Inc에서 1989년도부터 1992년까지 스탭 멤버로 일했으며 그의 기고문들은 C/C++ User’s Journal, Windows Developers Journal 및 SysAdmin 매거진 등에 수록되어 있습니다. 그의 저서인 “Illustrated C”는 R&D Books 카달로그 첫 장을 장식하고 있기도 합니다. Leor는 STLFlit(C++ 에러 메시지 해독기, 다양한 C++ 컴파일러에서 호환)를 저자이며 현재는 C++에 대한 다양한 글들은 물론, 직접 여러 현장을 다니며 소프트웨어 개발 교육 강좌를 열고 있습니다. 저자에 대한 더 자세한 사항은 웹사이트 www.bdsoft.com 혹은 leor@bdsoft.com로 컨택하실 수 있습니다.
원문 : 2 major reasons why modern C++ is a performance beast
번역 : 김동혁
이전 글 : 아두이노 아니면 라즈베리 파이? 적절한 보드를 선택하는 단순한 방법?
다음 글 : 보안팀의 협업과 생산성 향상
최신 콘텐츠