실무에서 포인터 다룰 때 느낀 어려움을 담았습니다
[유의] 기본적인 개념을 상세하게 다루진 않습니다
(포인터를 처음 접한 초급자에게 추천❌)
C/C++의 대명사 "포인터"... 흔히들 컴퓨터공학과 전공생들은 포인터를 만나고 나서 전공에 대해 다시 생각해본다는 말이 나올 정도로 악명이 높다.
포인터란? 주소를 저장하는 변수다
int a = 42;
int *p = &a; // int 포인터 p는 변수 a의 주소를 저장
printf("%p\n", p); // a의 주소 출력
printf("%d\n", *p); // p가 가리키는 값을 출력 (42)
*(dereference): 가리키는 주소의 값(value)
&(address-of): 변수의 주소값
사실 포인터를 다루기 위해서 이 정도가 우리가 알아야 할 개념의 전부다.
이번에 API를 개발할 때 포인터로 인해 고통 받으면서, 포인터를 잘 알고 활용한다는 건 뭘까?라는 근본적인 의문이 생겼다.
👇 최근 나에겐 이랬거든🫠..(현타)

다시 한번 말하자면, 포인터란 "주소를 저장하는 변수"다.
근본으로 돌아가서 한번 질문해보자:
주소를 왜 저장해야 할까?
그리고 C/C++은 포인터라는 개념을 왜 가지고 있는걸까?
✔️ Pass by Value vs. Pass by Reference
주소값을 주고받음으로써, 직접 데이터를 복사하지 않고도 접근 가능하다는 이점이 있다 (👉 Pass by Reference)
✔️ 동적 메모리 관리
데이터 구조의 크기를 컴파일 시점에 알 수 없는 경우, 객체가 생성된 범위를 넘어 존재해야 하는 경우 등 동적 메모리를 사용해야 하는 다양한 시나리오가 있다.
포인터는 동적 메모리를 가리키고 관리하는 데 필수적인 도구다. 따라서 동적 메모리 관리에는 항상 포인터가 수반된다.
❔정적 메모리 vs. 동적 메모리
✔️ 연속된 메모리 블록 다루기 용이
포인터와 배열이 종종 함께 언급된다. 포인터를 사용하면 배열을 효율적으로 다룰 수 있기 때문인데, 이는 연속된 메모리 공간에 데이터가 할당된다는 배열의 특성과 관련이 깊다.
아래와 같이 arr[i]로 접근하는 대신, arr의 포인터를 얻어서 접근할 수 있다.
int arr[5] = {1, 2, 3, 4, 5};
int *p = arr;
for (int i = 0; i < 5; i++) {
printf("%d\n", *(p + i)); // 포인터로 배열 접근
}
최근 느낀 어려움을 기반으로 정리해봤습니다
(지극히 주관주의)
Java와 C/C++ 의 가장 큰 차이점 중 하나는 메모리를 개발자가 직접 관리하느냐의 여부다.
Java는 JVM 위에서 동작하기 때문에 메모리 관리가 자동화되어 있는 반면, C/C++은 기계 친화적인 언어로 더 높은 성능을 제공한다고 알려져 있다. 하지만 여기에는 중요한 전제가 하나 따른다고 본다: 개발자가 메모리를 제대로 관리할 수 있어야 한다는 점이다.
병아리 C/C++ 개발자인 나로서는 여전히 두려운 지점 중 하나기도 하다 😅 나..나는 과연 딴딴한 국밥 개발자인가? 아직 조금 흐물텅한 계란찜 정도 아닐까?..🐣

동적 메모리를 명시적으로 점유하고 해제해야 하기 때문에 어쩔 수 없이 비즈니스 로직과 메모리 점유/해제 로직이 혼재될 수 밖에 없다고 느꼈다.
예시로 회사에서 작업한 API 전체 삭제/일부 삭제를 처리하는 경우의 코드를 가져왔다. (사내 리소스에 맞게 적용한 메모리 malloc/free 코드가 있어 보안상의 문제로 일부 수정 및 pseudocode 표현)
if(전체 삭제)
{
member_count = DB에서 전체 데이터 count;
if (member_count != 0)
{
if ((ptr_xxx_member = 데이터 크기만큼 malloc != NULL_PTR)
{
..삭제 로직..
free (ptr_xxx_member);
}
}
}
else // 일부 삭제
{
if ((ptr_xxx_member = 데이터 크기만큼 malloc != NULL_PTR)
{
// 삭제 실패: fail 케이스 계산
for(i=0,fail_count=0; i<req->member_count; i++)
{
if(삭제 불가능할 때)
{
fail_member 데이터 저장;
fail_count++;
}
else
{
..삭제 로직..
}
free(req->data.member_list[i]);
}
free(req->data.member_list);
free(ptr_xxx_member);
}
}
데이터를 사용하고 난 뒤 메모리는 반드시 해제해야 한다.
하나의 scope 안에서 바로 해제하는 경우 직관적으로 눈에 잘 들어오고 간단하다.
그러나 할당하는 시점과 해제하는 시점이 떨어져 있는 경우 메모리 해제를 놓치기 쉽다.
아까와 같은 delete API 처리를 예시로 들자면,
1. request parsing
2. 비즈니스 로직
3. response making
이렇게 크게 세가지 파트로 이루어져 있다.
이중에서 실제로 delete를 수행하는 del_xxx_group_member_response 함수에선 fail_data 메모리를 할당하고, response를 만들어 보내는 make_del_xxx_group_member_response 함수에서는 response 객체를 만든 후 메모리를 해제한다.
del_xxx_group_member_responseif ((ptr_xxx_member = 데이터 크기만큼 malloc != NULL_PTR)
{
// 삭제 실패: fail 케이스 계산
for(i=0,fail_count=0; i<req->member_count; i++)
{
if(삭제 불가능할 때)
{
fail_member 데이터 저장;
fail_count++;
}
else
{
..삭제 로직..
}
free(req->data.member_list[i]);
}
free(req->data.member_list);
free(ptr_xxx_member);
}
make_del_xxx_group_member_response : 데이터 사용 후 free...
memberp = res->command_data.fail_data.member_list;
if(res->command_data.member_count)
{
for(i=0; i<res->command_data.member_count; i++)
{
member = json_pack("{s:s,s:s}",
"data1", memberp[i]->data1,
"data2", memberp[i]->data2);
json_array_append_new(member_list, member);
free(res->command_data.fail_data.member_list[i]->data1);
free(res->command_data.fail_data.member_list[i]->data2);
free(res->command_data.fail_data.member_list[i]);
}
free(res->command_data.fail_data.member_list);
}
이렇듯 별개의 파일과 함수에서 메모리 할당과 해제가 이루어지는 경우, 메모리 해제하는 걸 놓치기 쉽기 때문에 유의하고 개발해야 한다.
JSON 객체를 다루기 위해 데이터를 담는 struct를 직접 손봐야 할 일이 있었다. 이차원 배열을 다루기 위해 이중 포인터를 활용했다.
struct data {
int** lst;
}
막연하게 해당 데이터로 lst의 크기를 구할 수 있겠지~ 생각하고 초반에는 따로 사이즈 정보를 struct에 저장하지 않았다.
이중 포인터는 포인터의 포인터라, 주소값밖에 들어있지 않기에 이 데이터만 가지고 배열의 사이즈를 구하기란 불가능이다. 한-참 찾아보다가, 사이즈를 따로 저장할 수밖에 없다는 걸 깨닫고 다음과 같이 수정했다.
struct data {
int size; // size 정보 개별적으로 저장
int** lst;
}
왜 나는 막연하게 이중 포인터로 배열 사이즈를 도출할 수 있다고 생각했을까? 생각의 오류 지점을 따져보면, 이중 포인터와 이차원 배열을 혼동해서인 듯하다.
정리하자면 다음과 같은 차이가 있다:
<크기 정보의 접근성>
이차원 배열: 컴파일 시점에 크기가 결정되며, sizeof 연산자를 통해 전체 크기를 알 수 있다
이중 포인터: 동적으로 할당된 메모리를 가리키므로, 크기 정보를 별도로 관리해야 한다
🤔❔ 값이 들어가 있는걸까? 아니면 주소값이 들어가 있는걸까?
아직 명확하게 해결책을 제시하기는 어렵지만, 최근 겪은 어려움이라 간단히 언급하고자 한다.
실제 프로젝트의 코드를 분석하다 보면, 특정 변수나 데이터 구조가 실제 값을 저장하는지, 아니면 메모리 주소를 저장하는지 구분하기 어려운 상황이 의외로 자주 발생한다. 대규모 프로젝트에서 여러 파일과 함수를 오가며 코드를 읽다보면 혼동스러울 수 있다.
따라서 코딩할 때 현재 다루고 있는 데이터가 값을 직접 저장하는 변수인지, 아니면 다른 메모리 위치를 가리키는 포인터인지를 의식하는 것이 중요하다고 느꼈다.
✍️ 미래의 나에게,
효과적인 C/C++ 메모리 관리 전략은 ??
C/C++ 개발을 더 해보면서 경험적인 노하우를 쌓아야 하는 부분..이라는 잠정적 결론을 내린 상태다. 일단 기록만 해두고, 차차 알아보는 걸 목표로 한다.
다른 언어들은 메모리 free 하는 시점을 어떻게 판단하는걸까?
그렇다면 알아서 메모리를 잡고 해제하는 Java나 파이썬은 어떻게 메모리를 관리하고 있는걸까? 궁금하다.. 이 부분도 차차 알아가고 싶다.
최근 포인터와 싸우면서 늪에 허우적거리고 있을 때 회사 아저씨들을 붙잡고 징징댔다 어려움을 토로했다.
2년차: 수석님.. 포인터 어려워요.. 훌쩍
회사 아저씨들: 그 어느날.. 머리 속으로 번뜩!!하고 깨달음이 내려올거야
2년차: ???
과연 이게 언제쯤일지는 모르겠다만, 꾸준히 포인터와 많이 싸워보면서 차곡차곡 쌓인 경험들로부터 말미암아 그 어느날 포인터의 신이 내려오는 날이 오기를 바라며 글을 마쳐본다.
GC는 흔히 mark and sweep 알고리즘을 사용한답니다~
레퍼런스 카운트 기반 메모리 관리에서 약간 더 발전된(?) 방식인데요,
레퍼런스 카운트는 폐쇄형 루프를 이루는 경우는, 실제로는 더 이상 사용되지 않는 데이터들이지만, 참조 카운터가 0이 아니므로 지워지지 않는 문제가 있어,
GC root node 들을 정의하고, 이 노드들부터 시작해서 의존관계의 그래프를 그리고, 이 그래프에 속하지 않는 모든 데이터를 다 삭제하는 방식이 기본으로 알고있어요.
여기에 실제로는 strong, soft, weak reference 등등, 마이너 GC, 메이저 GC, 좀더 추가되는게 많지만,
일단 핵심 아이디어는 위의 설명이였던걸로 알고있슴다.