c++에 어느 정도 관심이 있어서 중수 이상으로 넘어가려면 반드시 알아야 하는 move semantics에 대해서 알아보자.


0. Copy assignment (복사 대입)


먼저 조금 당연한 사실을 짚고 넘어가자면, c++에서 대입(assignment)은 기본적으로 (깊은) 복사야.

예를 들어서 int a = 42; int b = a; 라는 코드에서 b에는 a에 들어 있던 값과 동일한 42가 들어가지만, a와 b는 서로 다른 메모리 공간을 차지하고 있으니 나중에 아무리 a를 수정해도 b의 값은 그대로 42로 남아 있지? 

또 std::string str1 = "hello"; auto str2 = str1; 이라는 코드에서도 str1과 str2의 내용물은 같지만, 나중에 str1을 고친다고 해서 str2까지 같이 변하는 일은 생기지 않아.


이렇게 대입의 우변에 있던 내용을 (독립된 메모리 공간을 가진) 좌변에 그대로 쓰는 걸 깊은 복사라고 해. 

참고로 이걸 왜 깊다고 하냐면, 언어에 따라선 복사를 했을 때 레퍼런스만 복사해오는 경우도 있기 때문이야. c++로 따지자면 어떤 배열의 포인터만 복사해 가져오는 느낌. 이런 경우는 얕은 복사라고 해.

이렇게 하면 두 변수가 같은 메모리 공간을 가리키고 있으니까 한쪽 변수를 통해서 내용을 수정하면 다른 변수를 통해 메모리에 접근해도 내용이 똑같이 수정돼 있겠지?


아무튼, c++에서 대부분 자료형들은 별다른 언급이 없으면 = 로 대입을 했을 때 우변의 내용을 깊은 복사 해서 좌변에 대입해. 

하지만 깊은 복사는 "Pay only for what you use"에 목숨을 거는 c++ 프로그래머들의 시각에선 용납하기 어려운 면이 있었어. 

예를 들어 우리가 어떤 std::string을 열심히 만들어서 std::map<int, std::string>에 넣는다고 생각해보자. 이 변수를 넣으려면 std::map 내부에 새로 공간을 할당한 다음에 글자를 전부 복사하겠지?

그런데 변수를 대입한 뒤에 그 변수를 다시 수정해 쓸 일이 있었다면 몰라도, 만약 대입한 뒤의 변수를 다시 쓸 일이 없다면 지금처럼 메모리를 새로 할당하고 복사하는 과정은 그야말로 삽질이잖아? 성능의 손해를 보는 삽질은 c++ 프로그래머들에겐 거의 발작 버튼이란 말이지.


문제는 이게 마음에 안 든다고 해서 딱히 다른 방법이 있는 거도 아니었어. 

std::map의 entry에 직접 접근해서 메모리를 미리 할당하고 작업을 지지고볶고 하면 효율성은 해결되겠지만, 이 방법도 문제인 게... 

  • 코드가 더러워져. 이 방식을 사용하려면 string manipulation을 위한 함수도 레퍼런스를 받아 수정하는 형태만 사용 가능하고, 자료구조 내에서 일일이 레퍼런스를 따와야 하니까 모양새가 영 안 좋아지지. 
  • 사실 더 큰 문제는 이 방법을 쓸 수 있다는 보장이 없어. 만약 다른 라이브러리의 API를 통해서 std::string을 받아 넣어야 한다면? 만약 std::map이 아니라 다른 클래스를 쓰는데 멤버 변수가 숨겨져 있어서 직접 수정이 불가능하다면?


c++ 프로그래머들도 바보는 아니라서 이 문제를 디자인 패턴 같은 거로 시도하려 했지만, 무슨 짓을 해도 쓸데없는 복사를 안 일으키는 코드를 깔끔하게 짜는 건 쉬운 일이 아니었어. 

결국 이건 좀 더 근본적인 해결책이 필요하다는 합의에 이르렀고, 그렇게 해서 나온 게 바로 move semantics야!


1. Move semantics란 무엇인가


Move semantics를 요약하자면 "얕은 복사를 활용해 소유권 이전의 개념을 구현한다" 라고 할 수 있어.

얕은 복사는 대개 메타데이터는 그대로 복사하되, 메모리 공간을 복사하는 대신 그 메모리를 가리키는 포인터만 복사하는 식으로 구현해. 메모리 공간이 원래 그리 크지 않았다면 별 차이가 안 나겠지만, 메모리 공간이 아주 크거나 복사가 반복된다면 유의미한 성능 차이가 나겠지?

그리고 c++에 웬 소유권이냐고 생각할 수도 있겠는데, 이게 c++ 문법에 나오는 개념은 아니지만 c++ 프로그래밍을 할 때 정말 중요한 개념이야. GC가 없는 c++ 특성상 메모리 공간의 소유권을 객체의 수명과 연동하는 게 아주 중요하거든. 이게 제대로 연동이 안 되면 객체가 소멸되기 전에 메모리 공간이 먼저 할당 해제돼서 프로그램이 터지거나, 객체가 소멸됐는데도 메모리 공간이 할당 해제되지 않아서 메모리 누수가 일어날 테니까 말야.


2. Rvalue


Move semantics의 중심에는 이름처럼 std::move가... 있지 않아!!!!

사실 std::move 그 자체로는 하는 게 거의 없고, move semantics의 진짜 핵심은 rvalue overload에 있어.


일단 갑자기 튀어나온 rvalue에 대해서 설명을 해야겠지?

간단하게 말하자면 rvalue는 이동 가능한, 좀 거칠게 말하자면 "소유권을 털릴 수 있는" 값이야. 함수에서 반환되는 객체나 std::move() 에서 반환된 객체가 rvalue에 해당돼.

사실 현재 c++ 표준에선 rvalue란 게 조금 엄밀하지 못한 단어 선정이긴 한데, 이번 글에선 크게 중요하지 않아서 패스할게.


Rvalue overload는 함수 중 func(std::string&& str) 처럼 && (rvalue reference)를 받도록 선언된 overload를 의미해.

함수가 rvalue reference를 받도록 선언하면 2가지 효과가 있는데, 프로그래머에게 "이 함수는 받은 객체를 복사해 가는 게 아니라 소유권을 가져갈 것임"을 암시하는 동시에 이 함수가 rvalue만 받을 수 있도록 컴파일 단계에서 강제해.


3. Move constructor와 move assignment operator


그런데 소유권을 가져간다는 게 정확히 뭘까? 그냥 std::move() 로 아무 객체나 rvalue로 바꾼 다음에 rvalue overload에 넣어주면 알잘딱깔센으로 얕은 복사를 시전해주는 걸까?

당연히 그럴 리는 없고, 소유권을 이전하는 방법을 코드로 구현해야겠지? 그러려면 move constructor(이동 생성자)move assignment operator(이동 대입 연산자)를 구현해줘야 해.


우선 이동 생성자는 이렇게 생겼어.

Type(Type&& other);

동일 타입 객체가 가지고 있던 데이터를 전부 가져와 새로운 객체를 생성하는 건데, 사실상 객체를 통째로 가져온다고 생각하면 돼.


그리고 이동 대입 연산자는 이렇게 생겼어.

Type& operator=(Type&& other);

동일 타입 객체가 가지고 있던 데이터를 전부 가져와 원래 있던 다른 객체에게 넘겨주는 거지.


반대로, 만약 이 둘이 구현이 안 돼 있다면 백날 rvalue니 어쩌니 해봤자 기존에 있던 (깊은 복사를 사용하는) copy constructor(복사 생성자)와 copy assignment operator(복사 대입 연산자)를 사용하게 돼. 이러면 얕은 복사가 일어나지 않으니까 성능상의 이득이 하나도 없겠지?

그러니까 새로 만든 객체에서 move semantics를 이용해 성능상의 이득을 보고 싶다면 반드시 이 둘을 구현한 뒤에 써야 해!


4. 얕은 복사의 구현


그런데 이동 생성자이동 대입 연산자를 구현할 때도 문제인 게, 얘들을 어떻게 구현해야 효율적으로 소유권을 이전해줄 수 있을까?

다행히 이걸 구현하는 방법은 딱 2가지 규칙만 따르면 돼.

  • 정수형 타입 (int, char, uint64_t, size_t 등등)이나 포인터 타입은 그냥 그대로 복사한다: 얘들은 원체 크기가 작아서 성능 오버헤드가 그리 크지도 않고, 포인터 하나 크기를 딱히 더 효율적으로 복사할 방법도 없어.
  • 이동 생성자이동 대입 연산자가 이미 구현된 타입은 std::move()를 씌워서 넘겨준다.
  • 그런 게 구현이 안 된 타입은 포기한다: 내가 직접 그 타입에 대해 move를 구현할 수 있으면 몰라도, 제작자가 안 만들어 둔 걸 우리가 창조해서 쓸 수는 없으니까....

표준 라이브러리에 정의된 타입들은 거의 대부분 이동 생성자/이동 대입 연산자 가 잘 구현돼 있으니까 별로 걱정하지 않고 그대로 가져다 쓰면 될 거고, 내가 새로 정의한 타입들은 빠짐없이 다 구현해주면 돼.


사실 정 귀찮고 멤버 타입이 전부 move가 잘 구현돼 있다면 그냥

Type(Type&& other) = default;

Type& operator=(Type&& other) = default;

로 때워버릴 수도 있어. 대부분의 경우는 이것만으로 충분할 거야.


5. Move semantics를 사용할 때 주의할 점


문제는 move semantics도 c++답게(?) 숨은 함정들이 꽤 많이 숨어 있다는 거야. 그리고 그 함정이 쉽게 피할 수 있는 것도 아닌데 이로 인해 발생할 수 있는 결과는 참혹하기 그지없다는 것까지 정말 c++답다 해야 하나. 정말 거지 같은 언어야.


먼저, std::move()된 객체를 다시 참조하면 무슨 일이 생길지 몰라.

표준 라이브러리에 정의된 타입들은 객체의 move된 이후 상태가 valid but unspecified라서 구현체에 달려 있다고 적혀 있는데, 이 상태의 객체를 그대로 읽으면 무슨 값이 튀어나올지 알기 힘들단 뜻이야. 한마디로 쓰지 말라는 거지.

문제는 move된 이후의 객체를 실수로 다시 사용해도 프로그래머가 이걸 눈치채기 힘들단 거야. 이런 걸 컴파일러가 알려주면 좋겠지만 (실제로 Rust 컴파일러는 해주기도 하고), 적어도 내가 아는 c++ 컴파일러들은 저런 상황에서 경고를 띄워주지 않아....


그리고 이동 생성자이동 대입 연산자를 선언하는 순간 복사 생성자와 복사 대입 연산자는 삭제돼버려.

무슨 말이냐면 이 타입에 대해선 더이상 깊은 복사를 아예 사용하지 못하도록 컴파일러가 막아버린다는 뜻이야. 만약 이 상황에서 std::move()를 쓰지 않고 객체를 생성하거나 대입하는 코드를 짜면 존재하지 않는 복사 생성자/복사 대입 연산자를 사용하려 시도했다가 컴파일 오류를 뿜게 되겠지.

대신 이 타입에 대해서 깊은 복사도 허용을 하고 싶다면

Type(const Type& other) = default;

Type& operator=(const Type& other) = default;

처럼 복사 생성자/복사 대입 연산자도 명시적으로 선언해주면 돼.


6. 마무리


글 맨 앞에서도 언급했지만, move semantics는 본격적으로 c++로 프로그래밍을 할 게 아니면 굳이 알 필요가 없는 내용이기도 해. 그런데 내가 필력이 딸려가지고 잘 설명했다면 이해했을 내용을 괜히 어렵게 설명한 걸수도 있어서... 혹시 이게 무슨 말인가 싶으면은 댓글로 질문해줘. 최대한 열심히 답변해볼게 ㅡ.ㅡ;;