연결 리스트의 frontBackSplitLinkedList() 문제를 풀면서 하나의 연결 리스트를 front와 back으로 나누는 함수를 구현했다.
예를 들어,
1 -> 2 -> 3 -> 4 -> 5 를
Front: 1 -> 2 -> 3 ,
Back : 4 -> 5
로 나누는 문제였다.
리스트를 나누는 것 자체는 잘 됐고 출력도 예상대로 나왔다.
그런데 프로그램이 끝나는 순간 갑자기 이런 에러가 발생했다.
free(): double free detected in tcache 2
Aborted (core dumped)
처음에는 리스트를 나누는 로직이 잘못된 줄 알았는데, 확인해보니 문제는 출력이 아니라 메모리를 해제하는 과정에 있었다.원인을 따라가 보니 같은 메모리를 두 번 해제하면서 발생한 Double Free 에러였다.
Double Free는 말 그대로 같은 메모리 주소를 두 번 이상 free()하는 것이다.
예를 들어,
int *p = malloc(sizeof(int));
free(p);
free(p);
첫 번째 free(p)에서 이미 해당 메모리는 해제됐다.
그런데 같은 주소를 다시 free()하면 메모리 관리 구조가 꼬일 수 있다.
단순히 프로그램이 종료되는 정도로 끝날 수도 있지만, 경우에 따라 힙 메모리가 손상되거나 보안 취약점으로 이어질 수도 있다.
처음에는 Double Free라고 해서 이런 경우만 생각했다.
free(p);
free(p);
그런데 서로 다른 포인터가 같은 메모리를 가리키고 있는 경우에도 Double Free가 발생할 수 있다.
예를 들어,
int *p = malloc(sizeof(int));
int *q = p;
라고 하면 p와 q는 서로 다른 포인터 변수지만 같은 메모리를 가리킨다.
p ──┐
↓
memory
↑
q ──┘
이 상태에서
free(p);
free(q);
를 하면 결국 같은 메모리를 두 번 해제하게 된다.
이 내용을 보고 나니, 내가 풀던 연결 리스트 문제에서 왜 같은 에러가 났는지도 조금씩 보이기 시작했다.
frontBackSplitLinkedList()를 구현하면서 이런 코드를 작성했다.
resultFrontList->head = ll->head;
이 코드를 다시 살펴보면, 리스트 내용을 새로 복사하는 것이 아니라
resultFrontList->head에 ll->head가 가지고 있던 노드 주소를 그대로 대입하고 있다.
따라서 실제 구조는
ll->head ────────────────┐
↓
1 -> 2 -> 3
↑
resultFrontList->head ──┘
처럼 된다.
즉 ll과 resultFrontList가 서로 다른 리스트 변수이기는 하지만, 내부적으로는 같은 노드들을 가리키고 있는 상태다.
그래서 리스트를 나눈 뒤에도 원본 ll이 그대로 그 노드들을 가리키고 있으면, 나중에 각 리스트를 해제하는 과정에서 문제가 생길 수 있다.
예를 들어,
removeAllItems(&ll);
removeAllItems(&resultFrontList);
removeAllItems(&resultBackList);
처럼 모두 해제한다고 하면,
먼저 ll을 해제하면서 1, 2, 3 노드의 메모리가 이미 해제된다.
그런데 resultFrontList도 같은 1, 2, 3 노드를 가리키고 있으니까 다시 같은 메모리를 해제하려고 한다.
그 결과,
free(): double free detected
에러가 발생한 것이다.
리스트를 front와 back으로 나눈 뒤, 원본 리스트가 더 이상 기존 노드들을 가리키지 않도록 했다.
ll->head = NULL;
그러면 구조가 이렇게 된다.
ll:
head -> NULL
resultFrontList:
1 -> 2 -> 3
resultBackList:
4 -> 5
이제 실제 노드들은 resultFrontList와 resultBackList가 나눠서 가지고 있고, 원본 ll은 더 이상 같은 노드를 가리키지 않는다.
그래서 각 노드를 한 번씩만 해제할 수 있게 된다.
resultFrontList->head = ll->head;
라고 작성하면 새로운 노드가 만들어지는 것이 아니라, 두 포인터가 같은 노드를 가리키게 된다.
포인터 A ──┐
↓
Node
↑
포인터 B ──┘
즉 포인터 변수가 두 개 있어도 실제 메모리는 하나일 수 있다.
그래서 메모리를 해제할 때는 단순히 포인터의 개수만 보는 게 아니라, 각 포인터가 실제로 어떤 메모리를 가리키고 있는지를 확인해야 한다.
이번 문제를 통해 메모리를 직접 관리하는 C에서는 다음 내용들을 신경 써야 한다는 걸 알게 됐다.
예를 들어,
free(p);
p = NULL;
처럼 처리해두면 같은 포인터를 실수로 다시 사용하는 걸 줄일 수 있다.
포인터가 여러 개여도 실제 메모리는 하나일 수 있다.
그리고 연결 리스트처럼 노드의 주소를 직접 연결해서 사용하는 경우에는 next 연결만 맞추는 데서 끝나는 게 아니라, 지금 이 노드를 어떤 포인터들이 가리키고 있는지, 마지막에 누가 free()해야 하는지도 같이 봐야 한다.