얕은 복사와 깊은 복사

구현으로 알아보는 배열 접근과 복사

프로그래밍을 하다보면 복사가 예상한 대로 되지 않을 때가 있습니다. 예를 들어, 아래 파이썬Python 코드처럼 배열을 복사했다고 해봅시다.

original = [[1, 2], [3, 4]]
copy = original[:]

그러면 원본을 건드렸을 때 복사본도 함께 바뀝니다.

original[0][0] = 5 # change original
copy[0][0] == 5 # True (also changed)

이런 현상은 파이썬 뿐만 아니라 일반적으로 다른 언어에서도 나타납니다. 또 다른 예로, 자바스크립트JavaScript도 마찬가지입니다.

const original = [[1, 2], [3, 4]];
const copy = [...original];

original[0][0] = 5; // change original
copy[0][0] === 5; // true (also changed)

이런 복사를 얕은 복사shallow copy라고 부릅니다. 반면 다른 쪽이 영향받지 않게끔 복사본을 만들 수도 있는데, 그런 경우에는 깊은 복사deep copy라고 부릅니다.

얕은 복사와 깊은 복사는 다양한 프로그래밍 언어에서 공통적으로 나타나는 개념입니다. 그런 복사를 어떻게 구현하는 지는 언어마다 다를 수 있지만, 저수준low-level에서는 비슷한 메모리 작업이 필요할 수밖에 없기 때문입니다.

이 글에서는 가상의 프로그래밍 언어에서 복사 연산을 직접 구현해볼 것입니다. 이를 통해 앞으로의 내용으로, 얕은 복사나 깊은 복사가 이루어질 때 저수준에서는 어떤 언어에서든 비슷한 일이 일어난다는 것을 알게될 것입니다. 그리고 그 수준에서 일어나는 일을 이해하기 위해, 포인터pointer가 있는 C 언어를 사용할 것입니다. 복사를 구현하고나면, 포인터를 직접 지원하지 않는 언어 또한 복사 동작이 C 언어와 다를바 없다는 것을 볼 것입니다.

얕은 복사 구현하기

앞서 본 예시처럼, 프로그래밍 언어는 배열을 복사하는 인터페이스를 프로그래머에게 제공합니다. 그런데 만약 거꾸로 그런 언어를 만든다면, 복사는 어떻게 구현할 수 있을까요?

예를 들어, 숫자 배열 [1, 2, 3]을 복사해야 한다고 해봅시다. 배열이란 연속된 메모리 할당contiguous memory allocation이므로, 해야 할 일은 원본을 다른 연속된 공간에 복사하는 것입니다. 그러면 이런 복사 함수를 떠올려볼 수 있을 것입니다.

void copyArray(int* source, int* dest, int size) {
  for (int i = 0; i < size; i++) {
    dest[i] = source[i];
  }
}

이 함수는 source 배열을 dest 배열에 복사합니다. C에서는 배열 또한 포인터로 취급할 수 있다는 점을 이용했습니다.

한편, 중첩된 배열 [[1, 2], [3, 4]]를 복사해야 한다고 해봅시다. 사실 이게 문제의 주인공입니다. 방금처럼 복사 함수를 만들어본다면 다음과 같습니다.

void copyNestedArray(int** source, int** dest, int size) {
  for (int i = 0; i < size; i++) {
    dest[i] = source[i];
  }
}

재밌게도 기존의 copyArray() 함수를 거의 바꾸지 않고 만들 수 있었습니다. 다시 말해 데이터 타입만 다를 뿐 똑같은 코드를 갖다 쓴 것입니다. 이를 통해, 얕은 복사는 데이터 타입에 상관 없이 똑같은 일을 수행한다는 것을 알 수 있습니다.

복사본 만들기

함수를 만들었으니 실제로 써봅시다.

int original[] = {1, 2, 3};
int copy[] = {0, 0, 0};
copyArray(original, copy, 3);

for (int i = 0; i < 3; i++) {
  printf("%d%s", copy[i], (i < 2) ? ", " : "\n");
}
// result: 1, 2, 3

위와 같이 숫자 배열 original을 실제로 copy로 복사했습니다. 여기서 원본을 바꾸더라도 복사본은 바뀌지 않고 남아있게 됩니다.

original[0] = 4;

for (int i = 0; i < 3; i++) {
  printf("%d%s", copy[i], (i < 2) ? ", " : "\n");
}
// result: 1, 2, 3

이번에는 중첩된 배열을 봅시다.

int first[2] = { 1, 2 };
int second[2] = { 3, 4 };
int zeros[2] = { 0, 0 };

int *original[2] = { first, second };
int *copy[2] = { zeros, zeros };
copyNestedArray((int**)original, (int**)copy, 2);

for (int i = 0; i < 2; i++) {
  for (int j = 0; j < 2; j++) {
    printf("%d%s", copy[i][j], (j < 1) ? ", " : "");
  }
  printf("%s", (i < 1) ? ", " : "\n");
}
// result: 1, 2, 3, 4

여기서는 원본을 바꾸면 복사본도 바뀌게 됩니다.

original[0][0] = 5;

for (int i = 0; i < 2; i++) {
  for (int j = 0; j < 2; j++) {
    printf("%d%s", copy[i][j], (j < 1) ? ", " : "");
  }
  printf("%s", (i < 1) ? ", " : "\n");
}
// result: 5, 2, 3, 4

왜 그럴까요? 답은 배열에 접근하는 방식에서 찾아볼 수 있습니다.

주소 값으로서의 배열

arr[2]와 같은 코드는 내부적으로 어떻게 계산될까요?

잠깐 C 언어의 얘기를 하자면, 배열 변수는 시작 주소를 가집니다. 예를 들어 배열 arr이 메모리 상 0x10 번지에서 시작한다고 해봅시다. 그러면 arr0x10을 값으로 갖게 됩니다.

random access in array
그림 1. 메모리 다이어그램. 배열 변수는 시작 주소를 값으로 가지고, 랜덤 엑세스를 가능하게 합니다.

이 사실은 다음과 같이 확인할 수 있습니다. (실제 결과는 매번 다를 수 있습니다.)

  int arr[3] = { 1, 2, 3 };
  printf("%p", arr);
  // result: 0x10 (memory address)

arr[2]의 값이 필요하다면, 시작 주소에서 두 칸을 더한 주소를 계산하게 됩니다. 여기서 한 칸은 데이터 타입의 사이즈를 말합니다. 즉 int가 4 바이트를 차지한다면, 시작 주소에서 8 바이트를 더할 것입니다. 최종적으로 주소는 0x18이 되고, 여기에 있는 값이 곧 arr[2]의 값이 됩니다.

C 언어로 말하자면, arr[2]*(arr+2)와 같은 포인터 연산과 같습니다. 배열 접근 시 우리는 주소 값 0x18이 아니라, 그 주소에 있는 값을 가져오길 기대할텐데요. 이렇게 주소를 통해 값을 가져오는 것을 역참조dereference한다고 부르는데, 이 점이 *(arr+2)에 그대로 표현되어 있습니다. 여기서 +2가 주소 값 덧셈 연산을, *가 역참조를 맡습니다.

재밌는 것은 배열 변수에 붙는 [2]와 같은 접근 연산이 결국 덧셈에 불과해진다는 것입니다. 그러므로 아무리 멀리 떨어진 것이라도 바로 접근할 수 있게 되는데, 어렵게 말하면 랜덤 엑세스random access를 구현하게 된 것입니다.

복사본 다시보기

위에서 만들었던 복사 함수는 랜덤 엑세스를 통해 값을 복사할 뿐입니다. 아까의 예시와 같이 originalcopy가 있다고 해봅시다. 배열을 복사할 때, 각 값은 대응되는 메모리 공간에 담깁니다.

copy number array
그림 2. 숫자 배열을 복사한 경우. 여기서 각 배열의 시작 주소는 임의로 정한 값입니다.

중첩된 배열 [[1, 2], [3, 4]] 또한 이와 다르지 않습니다. 앞서 언급한 대로, 배열은 연속된 메모리 공간의 시작 주소일 뿐이므로, [1, 2][3, 4] 또한 주소 값에 불과합니다. 즉 배열의 배열이란 주소 값의 배열입니다.

copy nested array
그림 3. 배열, 즉 주소 값을 복사한 경우. 여기서 배열 안에 담긴 주소 값은 임의로 정한 값입니다.

복사하는 로직이 똑같았기 때문에 어떻게 보면 당연한 결과입니다. 즉 복사하려는 값이 1인지 0x30인지 신경쓰지 않고 그대로 복사만 수행한 것입니다.

그런데 앞서 원본을 건드렸을 때 복사본 또한 함께 바뀌는 것을 보았습니다. 그 예시 코드를 다시 가져와봅시다.

original[0][0] = 5;

for (int i = 0; i < 2; i++) {
  for (int j = 0; j < 2; j++) {
    printf("%d%s", copy[i][j], (j < 1) ? ", " : "");
  }
  printf("%s", (i < 1) ? ", " : "\n");
}
// result: 5, 2, 3, 4

여기서 첫 원소를 5로 바꾼 것은, 사실 원본 배열 original을 바꾼 것이 아니라 역참조한 값을 바꾼 것입니다. 다시 말해, 0x30 위치의 값을 바꾼 것입니다.

nested array with dereferencing
그림 4. 동일한 메모리 주소를 가리키는 원본과 복사본.

이후 복사본을 통해 이 위치의 원소를 가져오면, 원본 때 역참조 했던 값을 똑같이 가져옵니다. 그래서 복사본 또한 함께 바뀐 것으로 보이게 됩니다.

깊은 복사 만들어보기

깊은 복사란 중첩된 배열을 복사하더라도 한쪽이 다른쪽에 영향받지 않도록 만드는 것입니다.

그렇다면 구현은 어떻게 할 수 있을까요? 배열의 배열을 간단한 예시로 들어봅시다.

void deepCopyNestedArray(int** source, int** dest, int size, int nestedSize) {
  for (int i = 0; i < size; ++i) {
    int* copy = createArray(nestedSize); // assign a different address

    for (int j = 0; j < nestedSize; ++j) {
      copy[j] = source[i][j];
    }

    dest[i] = copy;
  }
}

설명을 간단히 하기 위해 보조 함수 createArray()는 원하는 사이즈를 받아 배열을 만들어낸다고 합시다. 그러면 이 복사 함수는 주소 값을 그대로 복사하는 것이 아니라, 매번 새 배열을 만들어냅니다. 주소 값이 같은 게 원인이기 때문에, 서로 다른 주소 값을 갖도록 만드는 것입니다.

deep copy of nested array
그림 5. 깊은 복사의 결과. 복사본 배열에 담긴 모든 배열은 원본에 있는 것과 다른 시작 주소를 가집니다.

더 깊은 복사

배열의 배열은 해결했습니다. 그러면 배열의 배열의 배열은 어떻게 할까요? 생각해보면 원소들이 항상 같은 중첩 레벨을 가질 이유는 없습니다. ([[1], [[2]]]처럼요.) 그러니 복잡한 경우는 얼마든지 많이 있습니다.

이 질문에서 데이터 타입data type의 구분이 필요하게 됩니다. 얕은 복사는 그런 구분이 필요 없었지만, 깊은 복사는 복사 대상이 배열인지 아닌지 구분해야 하기 때문입니다. 만약 배열이라면, 앞서 구현한 것처럼 새 배열을 만들어서 다른 주소 값을 갖도록 만들어야 합니다.

그렇다면 배열을 단순히 사용할 수는 없습니다. C 언어는 런타임에 데이터 타입을 알아낼 수 있는 그런 기능은 내장되어 있지 않으니까요. 그래서 데이터 타입이 배열임을 알 수 있는 일종의 래퍼wrapper 구조체가 필요해집니다. 여기선 그것까지는 하지 않을 것입니다.

한편, 런타임에 데이터 타입을 알 수 있다면 재귀적으로 깊은 복사를 구현할 수 있습니다. 다음은 파이썬Python으로 만든 예시입니다.

def deepcopy(original):
    copy = []

    for element in original:
        if type(element) is list:
            copy.append(deepcopy(element))
        else:
            copy.append(element)

    return copy

레퍼런스 타입

다른 고수준high-level 프로그래밍 언어는 C 언어의 저수준 메모리 작업을 지원하지 않는 경우가 있습니다. 그 이유는 보통 고의적인데, 그런 작업이 에러를 쉽게 유발하는error-prone 일이기 때문입니다. 메모리에 제대로 된 주소를 넣어두는 것을 까먹거나, 아니면 메모리 해제를 까먹거나, 역참조를 한번만 해야 할 것을 두 번 해버리거나… 이런 식으로 사람은 저수준에서 생각하기 힘들기 때문입니다.

그래서 이런 직접적인 메모리 조작 대신, 언어 자체에서 알아서 역참조를 활용해 값을 가져오도록 만들고, 그 인터페이스를 데이터 타입으로서 마련하는 경우가 있습니다. 보통 레퍼런스 타입reference type이라는 이름 하에 이를 제공하곤 하는데, 물론 언어마다 용어나 구현이 다를 수 있습니다. 예를 들어 어떤 언어에서는 메모리 주소를 읽는 것까지는 허용하는 반면, 또 다른 언어에서는 그것까지 숨길 수도 있습니다.

이런 식으로 저수준 메모리 조작은 내부적으로 숨겨두고, 여기에 가비지 컬렉션garbage collection을 더해 메모리 관리를 프로그래밍 언어에 맡겨버리는 경우가 있습니다. 자바Java, 자바스크립트, 고Go, 파이썬을 비롯한 많은 언어가 그렇게 하고 있습니다.

마치며

여기까지 얕은 복사와 깊은 복사를 알아보았습니다. 간단히 C 언어로 구현했지만, 사실 언어마다 그 구현이 다를 수 있습니다. 하지만 프로그래밍에서 복사는 메모리 동작 원리를 빼놓고 얘기할 수 없는 주제입니다.

C 언어는 메모리 주소 값을 포인터라는 데이터 타입으로서 지원합니다. 그리고 배열 또한 포인터와 같기 때문에, 포인터를 통해 배열도 함께 이해할 수 있습니다. 참고자료가 필요하다면 메모리는 컴퓨터 구조를 다루는 자료에서, 포인터는 C 언어를 다루는 자료에서 쉽게 찾아볼 수 있습니다.