2022. 12. 31. 20:50ㆍC언어 복습
1. malloc과 free
이번 글은 강의에서 동적 할당에 대해 다룬 것들 중에서 내가 몰랐던 것들을 위주로 적어보려고 한다. 따라서 malloc, calloc, free함수의 사용법이나 어떤 역할을 하는지에 대한 내용은 기존에 알고 있던 내용이기 때문에 적지 않을 것이다. 대부분이 메모리 관리를 하는 것에 있어서 실수를 예방하는 방법에 대한 내용이고 소수의 어떤 방식으로 메모리가 할당되는지에 대한 내용을 다룰 것이다.
동적 할당을 하는 데 있어서 가장 중요하게 봐야할 것은 동적으로 할당한 메모리를 어떻게 관리를 해야 실수를 최대한 막으면서 관리를 할 수 있을까에 대한 것이다. 아래에 그 원칙들을 나열해보겠다.
- malloc, calloc과 일단 메모리를 할당하는 코드를 짰으면 바로 free함수를 호출해라.
- free를 시켰으면 free를 시킨 포인터에 NULL을 대입해라.
- realloc을 사용할 때, 메모리 누수가 날 수 있으니 조심해라.
"malloc, calloc과 같이 메모리를 할당하는 코드를 짰으면 바로 free함수를 호출해라."
첫 번째 내용은 파일 입출력을 배울 때 비슷한 내용을 본 적이 있을 것이다. 거기서 파일을 여는 fopen함수를 호출하자마자 그 밑에 fclose를 호출시키는 코드를 작성하고 그 후에 fopen과 fclose사이에 코드를 작성하도록 이야기한 적이 있을 것이다. 이 경우에도 마찬가지이다. 메모리를 할당하는 코드를 짰으면 일단 해제를 먼저 시켜놓고 그 다음에 그 사이에 로직을 짜면 메모리를 해제시키지 않는 실수를 줄일 수 있다.
void sample_func(void)
{
int* ptr = malloc(sizeof(int));
/* 어떤 코드가 들어갈지 모르지만
free부터 냅다 박고 보자! */
free(ptr);
}
얼마나 중요하면 malloc을 치는 중에 누가 옆에서 부르더라도 "잠깐만요!"를 외친 후에 free까지 작성하고 볼일을 보러가라고 강의에서 말할 정도이다. 그게 껄끄럽다면 그냥 mall...까지 치다말고 냅다 free를 박은 후에 볼일을 보러가는 방법도 있다. 그럼 mallfree인 상태로 갔다오면 '아, 내가 free를 시켜야 하는구나.' 를 인지할 수 있다는 것이다.
void sample_func(void)
{
int* ptr = mallfree /* mallfree가 뭐지? 아! free시켜야지! */
/* 이후 아주 멋진 코드 완성! */
}
"free를 시켰으면 free를 시킨 포인터에 NULL을 대입해라."
두번째 내용은 이해가 가지 않을 수 있다. 사실 NULL을 넣는 것과 상관없이 메모리 해제 자체는 정상적으로 이루어지기 때문이다. 하지만 누군가가 이미 해제된 메모리를 이미 해제가 된지 모르고 다시 한번 free를 호출시키는 경우를 볼 수 있다. 그런 경우는 결과가 정의되어 있지 않기 때문에 매우 위험하다. 그러나 해제된 메모리에 NULL을 넣은 상태로 아까 같은 경우가 일어나면 그 때는 이야기가 다르다. free에 NULL을 전달하면 그 때는 free가 아무것도 하지 않기 때문이다. 이러면 누군가의 실수에도 불구하고 크래시가 나지않고 그냥 넘어갈 수 있다.
void sample_func(void)
{
int* ptr = malloc(sizeof(int));
/* 아주 멋진 코드 */
free(ptr);
ptr = NULL;
/* 정말 멋진 코드 */
/* 위에서 NULL을 넣어서 아무 문제가 없다. */
free(ptr);
}
"realloc을 사용할 때, 메모리 누수가 날 수 있으니 조심해라."
세 번째 경우는 realloc이라는 함수를 제대로 모르면 의아할 수 있다. realloc이라는 이름에서 alloc이 메모리를 할당한다는 뜻을 가진다는 것은 대부분 알고 있는 사실이며 re가 앞에 붙었다는 것은 메모리를 재할당한다는 것을 어렵지 않게 추측할 수 있다. 실제로 realloc은 제자리에서 메모리를 재할당하며 제자리에서 재할당하기에는 메모리가 모자란 경우, 다른 위치에 메모리를 재할당하는 함수이다. 그리고 메모리 재할당을 위해 아래와 같이 코드를 짜는 경우를 볼 수 있다.
void sample_func(void)
{
void* nums;
nums = malloc(LENGTH);
/* 잘못하면 NULL을 반환한다 */
nums = realloc(nums, LENGTH * 2);
/* 아주 멋진 코드 */
free(nums);
}
하지만 중요한 것은 realloc은 메모리를 재할당에 실패하면 NULL을 반환하는데 이 때, 할당된 메모리를 해제하지 않는다. 즉, 할당된 메모리의 주소를 잃어버려서 메모리 누수가 날 수 있는 것이다. 그래서 엄밀히 말하면 위와 같이 코드를 짜는 것은 메모리 누수의 위험이 있는 코드가 맞다. 그래서 이런 위험을 없애기 위해서 아래와 같이 코드를 짜는 방법이 있다.
void sample_func(void)
{
void* nums;
void* tmp;
nums = malloc(LENGTH);
tmp = nums;
nums = realloc(nums, LENGTH * 2);
if (nums == NULL)
{
nums = tmp;
}
/* 아주 멋진 코드 */
free(nums);
}
단, 이전 글에서 오류 처리에 대한 글을 보면 '정말 이렇게 처리하는 것이 맞나?' 라는 생각을 할 수 있다. 그런 생각이 드는 것이 당연한 것이 사실 이런 경우는 어떻게 처리해야하는 것이 정답이라고 이야기하기 어렵기 때문이다. 어떤 상황에서는 차라리 그냥 NULL이 반환되지 않았다고 가정하고 코드를 짰다가 NULL이 반환되면 크래시가 나게 놔두는 경우도 존재할 수 있다. 여담으로 realloc의 첫 번째 매개 변수로 NULL이 들어가면 malloc과 동일하게 작동한다.
그런데 강의에서는 여기서 내가 인지조차 하지 못했던 궁금증을 제시했다.
"malloc과 calloc은 몇 바이트를 할당할지 매개 변수로 넘겨줬는데 free는 어떻게 그런 데이터도 없이 메모리를 적절하게 해제할 수 있는 거죠?"
이 질문을 보자마자 '어?! 그러고 보니 어떻게 그게 가능하지?' 라는 생각이 들었다. 몰랐던 부분이니 적어보자면 메모리가 할당될 때, sizeof(int)만큼 할당을 받겠다고 매개 변수를 넘겼지만 사실 sizeof(int)보다 더 많은 메모리가 할당된다. 사실 sizeof(int)에 해당하는 메모리 이외에 할당받는 메모리에는 메모리를 삭제시키는 데 필요한 데이터가 들어었이서 free가 메모리를 몇 바이트를 해제시켜야 하는지를 알 수 있게 되는 것이다. 즉, 메모리가 할당될 때 몇 바이트를 할당했는지에 대한 데이터를 같이 저장하는 것이다. 아래와 같이 말이다.
그러고 나서 할당받은 메모리를 사용하기 위해 포인터로 접근을 하려고 하면 아래와 같이 내가 요청한 sizeof(int)의 시작 주소를 반환해서 건내준다. 그래서 32바이트짜리 메모리 앞에 있는 "어떤 데이터"에는 절대 접근하지 못하게 막는 것이다. 아래의 그림과 같이 말이다.
메모리를 사용할만큼 사용했으면 이제 해제를 시켜야 한다. 이러면 사용자가 사용하던 메모리 주소에서 어떤 데이터의 시작 지점으로 이동해서 그 부분부터 메모리 해제를 시작하게 된다. 그리고 앞에 미리 적어놨던 데이터를 근거로 메모리를 해제하는 것이다. 아래의 그림과 같이 말이다.
2. 메모리 함수
void* memset(void* dest, int ch, size_t count);
이 함수는 dest라는 포인터를 받아서 해당 주소로부터 size만큼의 바이트만큼 c의 값으로 복사하는 함수이다. 일반적으로 하나의 값으로 초기화를 시킬 때 많이 쓰는 함수인데 주의할 점이 하나 있다. 구조체의 경우에 멤버로 포인터를 가지는 경우가 있는데 그런 경우에 모르고 구조체 자체를 대상으로 memset을 시켜버리면 주소값을 넣어야 하는 포인터에 매개 변수 c로 들어온 값이 들어오기 때문이다.
특히 C++에서 조심해야 하는 것이 가상 함수를 가진 클래스를 대상으로 memset을 하려고 하는 경우이다. C++에서 어떤 클래스가 가상 함수를 가지게 되면 가상 함수를 제대로 사용하기 위해 virtual table이라는 곳을 가리키는 포인터를 자동으로 만들어주기 때문이다. 만약 여기서 memset을 사용하게 되면 클래스 내에 있는 virtual table을 가리키는 포인터가 어떤 값으로 덮어씌워져서 위험해지는 것이다.
void* memcpy(void* dest, const void* src, size_t count);
memcpy는 전에 문자열 관련 함수에서 봤던 strcpy와 굉장히 유사하게 동작하는 함수이다. dest라는 곳에 src에 있는 데이터를 size의 수만큼 복사하는 것이다. 혹시 strcpy에서 어떤 경우가 위험하다고 했었는지 기억나는가? 바로 dest에 src의 문자열을 복사하려는데 src의 문자열이 더 길어서 dest의 길이를 초과해서 복사하려는 경우에 위험해질 수 있다고 이야기한 적이 있다. 이것도 마찬가지로 count가 너무 커서 dest의 영역 뒤에 복사하려는 경우에 결과가 정의되어 있지 않아서 매우 위험하다. 또한 당연한 얘기지만 src, dest 중 하나가 NULL인 경우도 결과가 정의되어있지 않기 때문에 매우 위험하다.
int memcmp(const void* lhs, const void* rhs, size_t count);
이 함수는 lhs와 rhs에 있는 데이터를 count만큼 비교하는 함수이다. 위와 마찬가지로 lhs, rhs 중 하나가 NULL이거나 count의 수가 너무 커서 lhs, rhs 중 하나의 영역 밖으로 읽기 연산을 하면 어떤 일이 일어날지 정의되어 있지 않다. strcmp와 똑같이 작동하는데 중요한 것은 문자열 비교와 달리 널 문자를 만나도 계속 진행하면서 비교 연산을 한다는 것이다.
그래서 긴 문자열을 담을 수 있는 배열에 같은 문자열이 각각 들어온 상태로 lhs, rhs로 각각 전달이 되면 같은 문자열임에도 널 문자 뒤에 있는 쓰레기 값에 의해서 같지 않다는 결과가 나와버리게 된다. 또한 다음과 같이 구조체에 문자열을 담기 위해 char* 형 변수를 가지고 있는 상태에서 구조체끼리 memcmp연산을 시키는 것도 원하는 결과가 나오지 않는다. 서로 다른 주소를 가리키고 있으면 같은 내용의 문자열이더라도 주소값을 비교하지 문자열의 내용을 비교하지 않기 때문이다.
3. 메모리 관리 기법
C/C++은 기본적으로 언매니지드 언어라서 메모리를 프로그래머가 직접 관리를 해야하며 이를 위해서 메모리를 관리하는 데 있어서 원칙을 정해놔야 한다. 위에서 말한 것처럼 malloc과 free함수를 사용하는 데 있어서 지켜야 하는 규칙들도 다 이런 메모리 관리 기법에 해당한다. 이 부분에서는 위의 1번 내용에 이어서 더 다양한 메모리 관리 기법들을 소개하고자 한다.
- 동적할당을 한 메모리 주소를 저장하는 포인터 변수와 포인터 연산에 사용하는 포인터 변수를 분리해서 사용하라.
- 정적 메모리를 우선으로 사용하고 꼭 필요할 때 동적 메모리를 사용하자.
- 동적 메모리를 할당을 한다면 함수의 이름과 변수에 그 사실을 명시하자.
"동적할당을 한 메모리 주소를 저장하는 포인터 변수와 포인터 연산에 사용하는 포인터 변수를 분리해서 사용하라."
1-3의 내용에 이어서 네 번째 내용은 동적할당을 한 메모리 주소를 가리키는 포인터를 이용해서 포인터 연산을 하다가 자칫 잘못하면 동적할당을 한 메모리 주소를 잃어버리게 될 위험이 있다는 의미이다. 그렇기 때문에 아예 포인터 연산에 사용하는 포인터 변수를 분리해서 사용하라는 것이다. 아래의 예시를 보자.
void sample_func(void)
{
char* ptr = malloc(sizeof(char) * LENGTH);
while (*ptr != '\0')
{
/* 아주 멋진 코드 */
ptr++;
}
free(ptr);
}
이런 식으로 코드를 짠다면 어떻게 되겠는가? ptr이 동적할당을 한 시작 주소를 가지고 있었지만 ptr++; 로 인해서 그 시작 주소를 잃어버리게 되는 모양이 나오게 되었다. 애초에 포인터 연산에 사용하는 변수를 하나 더 만들었다면 이런 일은 발생하지 않았을 것이다.
"정적 메모리를 우선으로 사용하고 꼭 필요할 때 동적 메모리를 사용하자."
함수 내부에서 어떤 코드를 작성할 때 그 함수 내에서 동적 할당을 최대한 피하자는 것이다. 문서를 보지 않으면 그 함수가 내부에서 동적 할당을 하는지 그렇지 않은지 알 수 있는 방법이 없기 때문이다. 그래서 그런 일이 일어날 것 같다면 다음과 같은 방법을 고려할 수 있다. 아래의 예시를 보자.
const char* combine_string(const char* a, const char* b)
{
void* str;
char* p;
/* a와 b의 길이 및 두 길이를 더한 값을 보관한 변수 생략 */
str = malloc(size);
/* a와 b를 str에 복사하는 코드 생략 */
return str;
}
이 함수는 문자열 a, b를 받아서 둘을 결합한 뒤에 반환해주는 함수이다. 그런데 잘 생각해보면 저 함수를 사용하는 사람의 입장에서 직접 내부를 보지 않는 한 저 함수가 내부에서 동적으로 메모리를 할당을 해주는지 그렇지 않은지 알 수 있는 방법이 없다. 만약 저 함수의 내용이 너무 길다면 이는 매우 불편한 상황이 나올 수 있다. 그래서 내부에서 동적으로 메모리를 할당하지 않는 줄 알고 착각하다가 메모리 누수가 일어날 확률이 존재할 수 밖에 없는 것이다.
void combine_string(const char* a, const char* b, char* out_str)
{
/* out_str에 a와 b를 복사 */
}
만약 이렇게 짠다면 확실하게 매개 변수의 이름에서 a, b의 문자열을 합쳐서 out_str에 전달해준다는 것을 명시적으로 표현해줄 수 있고 사용자가 알아서 메모리를 할당해서 out_str로 넘겨주는 것이기 때문에 사용자가 메모리 관리를 잘해주기만 한다면 combine_string함수를 작성하는 사람의 입장이 한결 편해질 것이다.
"동적 메모리를 할당을 한다면 함수의 이름과 변수에 그 사실을 명시하자."
위에서 최대한 정적으로 메모리를 할당하라는 이야기를 했는데 사실 동적으로 메모리를 할당해야 하는 경우를 피할 수 없는 경우도 존재한다. 그런 경우가 어떤 경우인지에 대한 것은 딱히 모두가 동의하는 표준이 없다. 단, 이런 경우라고 판단이 들었을 때 취할 수 있는 방법이 있는데 바로 함수의 이름과 변수의 이름에 동적으로 메모리를 할당한다는 사실을 명시하는 것이다. 아래의 예시를 보자.
const char* combine_string_malloc(const char* str1, const char* str2)
{
void* pa_str;
char* p;
pa_str = malloc(strlen(str1) + strlen(str2));
p = pa_str;
/* 문자열 합치는 코드 생략 */
return pa_str;
}
여기서 함수의 이름이 기존에 combine_string이었던 것에 비해 combine_string_malloc으로 바뀌었다. 즉, 이 함수의 이름에서 대놓고 malloc이라는 함수의 이름을 명시해서 '이 함수의 내부에는 malloc이라는 함수가 사용된다.' 라는 것을 명시한 것이다. 당연히 메모리 동적할당이 일어난다는 것을 알 수 있는 프로그래머는 반환형을 보고 메모리 누수가 일어나지 않게 코드를 짤 것이다. 또한 함수 내부에 있는 pa_str이라는 변수가 있는데 앞에 붙어있는 pa_가 동적으로 할당하는 메모리를 가리킨다는 것을 암시하는 네이밍이다. 이런 방식이 코딩 표준으로 잡혀있으면 메모리 누수에 더 주의를 기울일 수 있다.
'C언어 복습' 카테고리의 다른 글
POCU C언어 정주행 16회차 - inline 함수, inline 주의점, 해결 방법 (1) | 2023.01.07 |
---|---|
POCU C언어 정주행 15회차 - 어서트 재정의, 전처리 명령어와 장단점 (0) | 2023.01.05 |
POCU C언어 정주행 13회차 - 예외 처리, 나쁜 오류 처리, 오류 처리 전략 (0) | 2022.12.30 |
POCU C언어 정주행 12회차 - 가변 인자 함수 (2) | 2022.12.29 |
POCU C언어 정주행 11회차 - 비트 패턴, 공용체 (0) | 2022.12.26 |