Pointer를 써도 될 때는 언제인가?

C++을 몇년을 했는데 아직도 이런 질문을…?

Object를 다룰줄 안다. std::unique_ptrstd::shared_ptr을 다룰 줄 안다. 기본적인 포인터 연산도 다룰 줄 안다.

함수에 인자로 넘길 때 nullptr로 넘겨서 optional argument로 넘길 줄도 안다.

근데 왜 이런 질문을 아직도 하는가?

 

이전 회사에서는 C++ 프로그래밍을 할 때 포인터를 다룬 경우가 거의 없다. 대부분의 경우 object를 직접 선언해서 사용했고, object에 참조를 할 때는 &를 사용했다.

이번 회사에서는 thread 들 간의 데이터를 다룰 때에는 shared_ptr로 다뤄야한다는 것을 배웠다. 아키텍트의 의견을 따라 모든 객체를 동적할당하여 사용하는 방향으로 개발을 했으며, 이 과정에서 unique_ptr과 shared_ptr을 조금 더 잘 다루게 되었다.

하지만 ‘모든 객체를 동적할당한다’ 라는 개념에 문제가 생겼다. 바로 속도 문제인데, stack에 올리는게 heap보다 빠르다, 라는 점 때문이였다. 모든 것을 heap에 올릴 필요가 있을까? 라는 질문을 하게 되었다. 찾아본 결과 Stack과 heap을 적절히 사용하는게 가장 좋은 성능을 낸다고 한다.

‘Stack과 Heap을 적절히 사용한다’라는건 어느 의미일까? 이에 대해 찾아보던 도중 Stack Overflow에서 이런 문구를 보게 되었다.

Prefer objects, before unique_ptr, before shared_ptr, before raw pointers.

위 논리에 따르면, C++ 프로그래밍을 할 때에는 거의 모든 경우 objects 위주로 프로그래밍을 하는게 맞을 것이다. Raw 포인터는 물론이고, 스마트 포인터도 볼 일이 거의 없을 것이다.

그렇다면 나는 왜 이렇게 많은 포인터 코드를 본 것인가?

왜 우리 아키텍트는 모든 걸 동적할당 해야한다고 한걸까? 왜 우리 아키텍트는 그게 옳은 방법이라고 배운걸까? 왜 ROS의 수많은 코드들도 다 동적할당으로 쏘아올릴까? 왜 Stack Overflow에서도 수많은 스마트 포인터 코드가 있는 것인가? 왜왜왜???

이 질문에 답하기 위해 여러 글을 찾았고, 적절히 조합해서 내린 결론에 대해 글을 적는다.

 


동적 할당 (dynamic allocation)

Object myObject와 같은 방식으로 객체를 정적할당 하면 객체의 수명이 자동적으로 결정된다. 객체는 scope를 벗어나는 순간 자동으로 메모리에서 삭제된다. 이 방식은 아주 유용한데, 우리가 객체 하나하나 수명을 직접 관리해줄 필요가 없기 때문이다. 즉, 잘못 관리해서 메모리를 차지하기만 하고 아무것도 안하는 메모리가 없을 것이라는 것이다.

정적할당의 반대는 동적할당 (dynamic allocation)이다. 그리고 동적할당을 할 때 Object* myObject = new Object; 와 같은 방식으로 포인터를 사용하게 된다. 생성한 후에 객체를 다룰 때는 myObject->foo() 와 같은 형태로 사용한다. Raw pointer로 생성하면 추후에 delete를 직접 해줘야하는데, 이 경우 위 정적할당 부분에서 언급한 것 처럼 ‘해제를 잊어버리면 메모리에 죽은 메모리가 남는다’라는 문제가 생긴다 (i.e. Memory leak). 하지만 이 문제를 파훼하기 위해 std::unique_ptr이나 std::shared_ptr 같은 스마트 포인터라는 개념이 생겼다. std::unique_ptr은 정적할당 object처럼 scope를 벗어나게 되면 메모리 오너십이 사라지며 자동으로 해제되고, std::shared_ptr은 공유 메모리 오너십이 사라지면 자동으로 해제된다. 이렇게 스마트 포인터가 raw pointer보다 안전하기 때문에, 모던 C++에서는 ‘Raw pointer 보다 스마트 포인터를 사용하라‘ 라는 룰이 생기게 되었다.

객체의 수명에 관련해서는 Object로 직접 다루나 스마트 포인터를 사용하나 결국 자동 수명 관리 시스템을 사용하기 때문에 차이가 없다고 볼 수 있다. 그러면 언제 정적할당을 하고 언제 동적할당을 해야하는가?

동적할당이 필요한 경우는 다음과 같다.

  1. 객체가 현재 scope를 벗어나도 살아있어야한다 (복사/이동을 하지 않고 살아있어야한다).
    • 하지만 대부분의 경우 작은 객체들은 복사/이동을 해도 괜찮은 경우가 많다.
  2. 대량의 메모리를 할당해야한다.
    • 예를 들어, 이미지 몇십장을 할당해야한다. 정적할당으로 생기는 메모리를 저장하는 공간인 Stack에는 충분히 공간이 나지 않을 수 있다.

위 두 경우가 아닌 경우에는 무조건 object를 사용해야한다는 것이다.

 

‘가능’하다면 object르 사용하자. ‘필요’하다면 포인터를 사용하자.

그러면 포인터는 어디에서 사용할 수 있는 것인가?

동적할당 외로도 쓸 수 있는 부분이 없지는 않다. 하지만 왠만해서는 좋은 practice가 아닌 경우가 많다.

예시를 들어보자.

첫째, foo(Object* obj)와 같이 함수의 인자로 복사 없이 큰 객체를 넘기고 싶은 경우다. 포인터를 사용하면 객체를 복사하지 않고 넘길 수 있다. 하지만 C++ 에서는 사실 foo(Object& obj)를 더 추천한다.

둘째, foo(Object* obj)를 할 때, obj가 선택적인 인자로 사용되게 하고 싶은 경우다. obj에 nullptr이 들어간다면 선택적으로 인자를 넣을 수 있다. 이건 분명한 장점이지만, C++17 부터는 std::optional을 사용해서 이 문제를 해결할 수 있다.

셋째, 컴파일 시간을 개선시키기 위해 전방선언 (Forward declaration)을 사용해서 포인터만 넘김으로써 compilation unit을 분리시키는 방법이다. 엄청나게 큰 프로젝트를 할 때, 예를 들어서 chromium 브라우저를 전부 빌드한다던지, 이럴 때에는 컴파일 시간이 개선되는건 좋다. Pimpl 같은 개념이 이것에 대해 이야기한다. 하지만 내 경우에는 작고 소중한 SLAM 프로그램 하나만 빌드하는것이며, 나는 런타임 성능이 더욱 소중하다.

넷째, C 라이브러리와 호환을 해야할 때의 경우다. 이 경우에는 C 언어가 스마트 포인터 및 클래스를 지원하지 않으니 어쩔 수 없이 raw pointer를 사용할 수 밖에 없다. 그럼에도 불구하고, 포인터를 다룰 때는 스마트 포인터로 다루다가, get()을 사용해서 raw pointer로만 잠시 바꿔주면 된다.

 


아니 그러면 포인터 왜 이렇게 자주 보이나?

Stack Overflow 질문/답변과 여러 C++ 고수들의 블로그를 뒤져봐도 명확한 이유가 나오지 않았다.

그러다가 Stack Overflow에서 댓글 중에서 가슴에 팍 꽂히는 문장을 보게 되었다.

‘이게 다 Java++ 유저들 때문이다. Java에서 쓰던거처럼 모든 객체를 포인터로 다루는 습관이 있는 사람들이 C++을 더럽히고 있다’

하,,

Java++ 개발자 소리 듣고 싶지 않으니, 왠만하면 포인터 쓰지 말자…