Post

C++ std::string을 memcpy로 복사하면 안 되는 이유

C++에서 std::string을 포함한 객체는 push_back으로 안전하게 복사되지만 memcpy로 복사하면 문제가 발생하는 이유를 설명한 글입니다.

C++ std::string을 memcpy로 복사하면 안 되는 이유


std::string은 그냥 메모리를 복사하면 안 된다


C/C++에서 구조체나 배열의 데이터를 복사하다 보면 memcpy를 사용하는 경우가 많다.
예를 들어 아래와 같이 단순한 데이터 타입으로만 구성된 구조체는 memcpy를 사용해서 복사할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <cstring>

struct Data
{
    int a;
    double b;
};

int main()
{
    Data src{10, 20.0};
    Data dst;

    std::memcpy(&dst, &src, sizeof(Data));

    return 0;
}

하지만 구조체나 클래스 내부에 std::string이 들어가면 이야기가 달라진다.

1
2
3
4
5
struct Data
{
    int id;
    std::string name;
};

위와 같은 객체를 아래처럼 memcpy로 복사하면 안 된다.

1
2
3
4
5
6
7
Data src;
src.id = 1;
src.name = "hello";

Data dst;

std::memcpy(&dst, &src, sizeof(Data));

겉으로 보기에는 src의 메모리를 dst로 그대로 복사했으니 문제가 없어 보인다.
하지만 std::string단순히 문자 데이터를 객체 안에 가지고 있는 타입이 아니다.


std::string 객체에는 문자열 외의 정보도 들어있다


std::string의 정확한 내부 구현은 표준에서 정해져 있지 않다.
하지만 개념적으로 생각하면 다음과 비슷한 정보를 관리한다고 볼 수 있다.

1
2
3
4
5
6
class string
{
    char* data;
    std::size_t size;
    std::size_t capacity;
};

실제 구현은 이것보다 복잡하며, 작은 문자열의 경우 별도의 동적 메모리를 사용하지 않는 Small String Optimization(SSO) 기법을 사용하는 경우도 많다.
중요한 것은 std::string이 단순한 문자 배열이 아니라 자신이 소유하고 있는 메모리를 관리하는 객체라는 점이다.
예를 들어 긴 문자열이 내부적으로 아래와 같은 상태라고 가정해 보자.

1
2
3
4
5
6
7
8
9
10
std::string str
+----------+
| data ----|--------+
| size     |        |
| capacity |        |
+----------+        |
                    v
             +-------------+
             | hello.....  |
             +-------------+

std::string 객체 자체와 실제 문자 데이터가 저장되어 있는 메모리 영역이 서로 다를 수 있다.


memcpy는 객체를 복사하는 것이 아니라 바이트를 복사한다


memcpy는 객체가 어떤 타입인지 알지 못한다.
단순히 지정된 크기만큼 메모리의 바이트를 그대로 복사한다.

1
std::memcpy(&dst, &src, sizeof(Data));

따라서 std::string 내부에 포인터와 같은 정보가 있다면 그 값 역시 그대로 복사된다.
개념적으로 다음과 같은 상황이 만들어질 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
src.name
+----------+
| data ----|---------+
| size     |         |
+----------+         |
                     |
                     v
                +---------+
                | "hello" |
                +---------+
                     ^
                     |
dst.name             |
+----------+         |
| data ----|---------+
| size     |
+----------+

src.namedst.name이 각각 독립적인 문자열을 가지고 있는 것이 아니라 내부 관리 정보가 그대로 복사되어 버린 것이다.
이런 상태에서 객체가 소멸하거나 문자열을 수정하면 문제가 발생할 수 있다.
예를 들어 두 객체가 같은 동적 메모리를 자신의 것이라고 생각한다면 객체가 소멸할 때 같은 메모리를 두 번 해제하는 double free 문제가 발생할 수 있다.
또한 실제 std::string 구현에서는 Small String Optimization 등을 사용하기 때문에 단순히 “같은 포인터가 복사된다” 정도보다 더 복잡한 문제가 발생할 수 있다.
결국 std::string 객체 자체를 memcpy 하는 것은 정의되지 않은 동작(Undefined Behavior) 을 발생시킬 수 있다.


std::string은 복사 생성자를 통해 복사해야 한다


std::string을 정상적으로 복사하면 memcpy처럼 내부 메모리를 그대로 복사하지 않는다.

1
2
std::string str1 = "hello";
std::string str2 = str1;

위 코드는 std::string의 복사 생성자를 사용한다.
개념적으로는 다음과 같은 상태가 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
str1
+----------+
| data ----|--------+
+----------+        |
                    v
              +---------+
              | "hello" |
              +---------+
str2
+----------+
| data ----|--------+
+----------+        |
                    v
              +---------+
              | "hello" |
              +---------+

str1str2는 각각 정상적인 std::string 객체가 된다.
따라서 한쪽 문자열을 변경해도 다른 문자열에는 영향을 주지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <string>

int main()
{
    std::string str1 = "hello";
    std::string str2 = str1;

    str2[0] = 'H';

    std::cout << str1 << '\n';
    std::cout << str2 << '\n';

    return 0;
}
1
2
hello
Hello

이처럼 C++ 객체를 복사할 때는 단순히 메모리를 복사하는 것이 아니라 해당 객체가 제공하는 복사 생성자(Copy Constructor) 또는 복사 대입 연산자(Copy Assignment Operator) 를 사용하는 것이 중요하다.


std::string이 포함된 클래스는 어떻게 복사될까?


그렇다면 std::string이 멤버로 들어있는 클래스를 복사하면 어떻게 될까?

1
2
3
4
5
6
7
8
#include <string>

class Data
{
public:
    int id;
    std::string name;
};

아래와 같이 객체를 복사해도 문제없이 동작한다.

1
2
3
4
5
Data data1;
data1.id = 1;
data1.name = "hello";

Data data2 = data1;

사용자가 별도의 복사 생성자를 만들지 않았다면 컴파일러가 기본 복사 생성자를 만들어준다.
개념적으로는 다음과 같은 코드와 비슷하다.

1
2
3
4
5
Data(const Data& other)
    : id(other.id),
      name(other.name)
{
}

intid는 값이 복사되고,

1
id(other.id)

std::stringnamestd::string의 복사 생성자가 호출된다.

1
name(other.name)

따라서 Data 객체 전체를 memcpy 하는 것과 달리 각각의 멤버가 자신의 타입에 맞는 방식으로 정상적으로 복사된다.


std::vector에 push_back 하는 것은 왜 괜찮을까?


std::string이 포함된 객체를 std::vector에 넣는 경우도 마찬가지이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <string>
#include <vector>

class Data
{
public:
    int id;
    std::string name;
};

int main()
{
    std::vector<Data> vec;

    Data data;
    data.id = 1;
    data.name = "hello";

    vec.push_back(data);

    return 0;
}

push_back()은 객체의 메모리를 memcpy 해서 저장하는 것이 아니다.
위 코드에서는 data가 lvalue이므로 기본적으로 Data의 복사 생성자를 이용해서 새로운 객체를 vector 내부에 생성한다.

1
vec.push_back(data);

개념적으로는 다음과 같은 복사가 이루어진다고 생각할 수 있다.

1
2
3
4
5
Data(const Data& other)
    : id(other.id),
      name(other.name)
{
}

따라서 std::string 역시 자신의 복사 생성자를 통해 정상적으로 복사된다.


이동할 수도 있다


C++11 이후에는 객체를 복사하지 않고 이동(Move) 시킬 수도 있다.

1
2
3
4
5
Data data;
data.id = 1;
data.name = "hello";

vec.push_back(std::move(data));

이 경우 가능하다면 Data의 이동 생성자가 호출된다.
Data의 기본 이동 생성자를 개념적으로 표현하면 다음과 비슷하다.

1
2
3
4
5
Data(Data&& other)
    : id(std::move(other.id)),
      name(std::move(other.name))
{
}

특히 std::string은 이동 생성자를 지원하기 때문에 문자열 데이터를 새로 복사하는 대신 기존에 가지고 있던 내부 자원을 새로운 객체로 넘길 수 있다.
따라서 긴 문자열을 가지고 있는 객체에서는 복사보다 효율적일 수 있다.
다만 std::move() 이후의 원본 객체는 유효하지만 값이 특정되지 않은 상태(valid but unspecified state) 가 되므로 기존 문자열 값이 그대로 남아있을 것이라고 가정해서는 안 된다.


vector의 크기가 커져도 괜찮을까?


std::vector는 내부 공간이 부족해지면 더 큰 메모리를 할당하고 기존 객체들을 새로운 메모리 영역으로 옮긴다. 예를 들어 처음에 다음과 같은 상태였다면,

1
2
3
4
vector
+-------+-------+-------+
| Data1 | Data2 | Data3 |
+-------+-------+-------+

공간이 부족해졌을 때 더 큰 영역을 만든다.

1
2
3
4
새로운 vector 영역
+-------+-------+-------+-------+-------+-------+
| Data1 | Data2 | Data3 |       |       |       |
+-------+-------+-------+-------+-------+-------+

이 과정에서도 std::vector가 단순히 memcpy로 객체를 복사한다고 생각하면 안 된다.
객체의 특성에 따라 이동 생성자 또는 복사 생성자를 사용하여 새로운 메모리 영역에 객체를 생성한다.
따라서 std::string과 같이 내부 자원을 관리하는 객체도 정상적으로 이동 또는 복사될 수 있다.
다만 이러한 재할당이 발생하면 기존 vector 요소를 가리키고 있던 포인터, 참조, iterator가 무효화될 수 있다는 점은 별개의 문제이므로 주의해야 한다.


어떤 타입이라면 memcpy로 복사할 수 있을까?


C++에서는 객체가 단순한 바이트 복사로 안전하게 복사될 수 있는지를 판단할 때 Trivially Copyable이라는 개념을 사용한다. 예를 들어 다음과 같은 구조체는 일반적으로 trivially copyable 타입이다.

1
2
3
4
5
struct Data
{
    int id;
    double value;
};

확인이 필요한 경우 <type_traits>std::is_trivially_copyable을 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <string>
#include <type_traits>

struct Data1
{
    int id;
    double value;
};

struct Data2
{
    int id;
    std::string name;
};

int main()
{
    std::cout
        << std::is_trivially_copyable_v<Data1>
        << '\n';

    std::cout
        << std::is_trivially_copyable_v<Data2>
        << '\n';

    return 0;
}

일반적인 환경에서는 다음과 같은 결과가 나온다.

1
2
1
0

std::string은 trivially copyable 타입이 아니기 때문에 std::string을 포함한 Data2 역시 단순한 memcpy 대상으로 사용하면 안 된다.
따라서 memcpy를 사용하기 전에 단순히 이 객체의 크기만큼 복사하면 되겠지라고 생각하기보다는 이 타입을 바이트 단위로 복사해도 되는 타입인가?를 확인하는 것이 중요하다.


정리


memcpy와 C++의 객체 복사는 완전히 다른 개념이다.
memcpy는 객체의 의미를 이해하지 못하고 단순히 메모리의 바이트를 복사한다.

반면

1
Data data2 = data1;

또는

1
vec.push_back(data);

같은 C++의 객체 복사는 해당 타입의 복사 생성자를 사용한다.
따라서 std::string처럼 내부적으로 메모리를 관리하는 객체도 정상적으로 복사될 수 있다.
정리하면 아래와 같다.

1
2
3
4
5
6
7
std::string str2 = str1;        // OK
Data data2 = data1;             // OK
vec.push_back(data1);           // OK

std::memcpy(&str2,
            &str1,
            sizeof(std::string)); // X

std::string, std::vector, std::unique_ptr처럼 자신의 자원을 관리하거나 특별한 복사/이동 동작을 가지는 C++ 객체는 일반적으로 memcpy로 복사해서는 안 된다.
C++ 객체를 복사할 때는 객체가 제공하는 복사 생성자, 이동 생성자, 복사 대입 연산자, 이동 대입 연산자를 통해 복사하는 것이 기본이다.

This post is licensed under CC BY 4.0 by the author.