앞서 구조체 배열을 배웠을 때 만약 게임 중에 캐릭터가 추가되거나 삭제되면 고정된 배열 크기는 어떻게 동작하는지 의문이 들었을 수도 있다.
컴퓨터가 우리 프로그램에 메모리를 빌려주는 방식은 크게 두 가지가 있다.
1. 정적 할당(Static Allocation): 고정된 메모리 크기 할당
-> 우리가 지금까지 하던 방식이다. int score; 혹은 char role[20]; 처럼 코드를 짤 때 미리 크기를 정해두는 것이다.
하지만 공간을 설정할 때 사용을 얼마 하지 못하면 메모리가 낭비되고, 설정한 크기보다 메모리를 많이 사용해야 하면 메모리가 부족해서 프로그램이 터져 버린다. 그래서 프로그램이 실행되는 도중에는 이 메모리 공간을 늘리거나 줄일 수 없다.
2. 동적 할당(Dynamic Allocation): 가변형 메모리 크기 할당
-> 프로그램이 실행되는 도중(Runtime), 컴퓨터에게 필요한 메모리 크기를 요청해서 유연하게 가져오는 방식이다.
이를 이해하기 위해서는 컴퓨터의 메모리 공간이 어떻게 구성되어 있는지, 그 특징이 무엇인지 아는 것이 좋다.
최저 주소 (0x00000000)
↓
| 코드(Code) 영역 -읽기 전용 (Read-Only) |
소스코드 (기계어 명령문) |
| 데이터(Data) 영역 |
전역/정적 변수 (프로그램 종료 시 소멸) |
| 힙(Heap) 영역 ↓ |
malloc( )할당 구역 (위에서 아래로 자란다) |
[빈 공간] |
힙과 스택이 서로 나눠쓰는 공간 |
| ↑ 스택(Stack) 영역 |
지역 변수/함수 틀 (아래에서 위로 자란다, 자동소멸) |
↑
최고 주소 (0xFFFFFFFF)
메모리 구조는 코드 영역, 데이터 영역, 힙 영역, 스택 영역으로 구성되어 있다.
- 코드(Code) 영역
-역할: 사용자가 작성한 소스코드가 컴파일되어 컴퓨터가 읽을 수 있는 기계어(명령어) 형태로 저장되는 곳이다.
-특징: 읽기 전용 구역이므로, 프로그램이 실행되는 동안 바뀔 일이 없다. CPU는 여기서 한 줄씩 명령을 가져가서 실행한다. - 데이터(Data) 영역
-역할: 프로그램이 켜져서 꺼질 때까지 계속 존재해야하는 전역 변수(Global)와 정적 변수(Static)가 위치하는 곳이다.
-특징: 프로그램 시작과 동시에 메모리에 자리를 잡고, 프로그램이 완전히 종료되어야 컴퓨터에게 메모리를 반납하고 소멸한다. - 힙(Heap) 영역
-역할: malloc( )함수 등을 통해 프로그래머가 원하는 타이밍에 메모리를 빌려쓰는 공간이다.
-특징: 주소값이 낮은 곳에서 높은 곳으로(위에서 아래로) 쌓이며 자라난다. 메모리를 빌려오는 것도 마음대로, free( )함수를 사용해서 반납하는 것도 마음대로 이다. 프로그래머가 메모리를 반납하기 전까지는 함수가 끝나도 데이터가 절대 사라지지 않는다.
이런 문제로 메모리를 다 쓰고도 메모리 해제를 해주지 않으면 메모리 누수가 날 수도 있다. - 스택(Stack) 영역
-역할: 함수 내부에 선언되는 지역 변수와 함수 호출 시 필요한 매개변수가 잠시 위치하는 공간이다.
-특징: 주소값이 높은 곳에서 낮은 곳으로(아래에서 위로) 거꾸로 쌓이며 자라난다. 함수가 시작되면 변수가 생성되고, 함수가 끝나는 순간 컴파일러가 자동으로 메모리를 정리해 준다. 관리가 편하지만, 함수 밖으로 데이터를 들고 나갈 수 없다는 한계가 있다.
표를 보면 힙(Heap)은 아래로 자라고, 스택(Stack)은 위로 자라며 중간의 빈 공간을 서로 공유한다.
만약 프로그램을 잘못 짜서 Stack이 너무 커져 Heap 구역을 침범하거나(Stack Overflow), 동적 할당을 너무 많이 해서 Heap이 Stack구역을 침범하면(Heap Overflow) 오버플로우로 인해 프로그램이 강제로 터지게 된다.
왜 스택(Stack)과 힙(Heap)은 방향이 반대로 메모리를 사용하게 될까?
-> 만약 스택과 힙이 같은 방향으로 메모리를 사용하게 되면 둘 중 하나가 메모리를 조금만 많이 써도 금방 충돌하기 때문에 프로그램이 터지게 될 것이다. 이를 방지하기 위해 양 끝(메모리 최고 주소와 최저 주소)부터 시작하여 서로 빈 공간을 향해 마주보고 자라도록 설계되어 있다. 이는 메모리 공간을 극한으로 효율적이게 사용하는 방법이다.
메모리 구조에 대해 알아보았으니, 다시 돌아와서 메모리를 동적 할당(Dynamic Allocation) 방법들에 대해 알아보자.
이를 위해 malloc( ), calloc( ), realloc( ), free( ) 함수를 사용해야 한다.
또한 C 언어에서 Heap 영역의 메모리를 사용하고 싶다면 <stdlib.h> 헤더파일을 포함시켜야 한다.
먼저 malloc( )[Memory allocation]함수이다.

malloc( )함수는 내가 원하는 바이트(size)만큼 Heap구역에서 공간을 확보하여, 그 시작주소를 돌려주는 함수이다.
[주소를 돌려받기 때문에 반드시 포인터 변수로 받아서 사용해야 한다]
공간 확보 실패 시 NULL을 반환하고, 초기값은 0이 아닌 쓰레기 값이다.

free( )함수는 malloc( )를 사용해서 확보한 공간을 다시 돌려주는 함수이다.
[메모리를 빌려쓰고 돌려주지 않으면 메모리가 계속 차있다가 컴퓨터가 멈추는 메모리 누수(Memory Leak) 사고가 발생하므로 반드시 malloc( )함수와 free( )함수는 세트로 사용해야 한다]
malloc( )함수로 메모리를 할당, free( ) 함수로 메모리를 해제한다는 것만 기억하면 된다.
그렇다면 전에 배웠던 구조체를 활용해서 '동적 할당'을 사용한 코드 예제 를 한번 살펴보자.

포인터 변수인 player 자체는 함수 안에 있으므로 스택(Stack)영역에 생성된다.
하지만 malloc( )으로 가져온 24byte 메모리 공간은 힙(Heap)영역에 생성된다.
즉, Character* player = (Character*)malloc(sizeof(Character));은 Stack에 있는 player가 Heap에 있는 진짜 메모리 공간의 주소를 가리키고 있는 코드이다.
malloc( )은 원래 주소만 돌려주는 void* 타입이지만, "이제부터 구조체 배열처럼 24byte 단위로 쪼개서 읽겠다"라고 컴퓨터에게 명시적으로 변환(Casting)시킨 것이다.

코드를 실행해보면 정상적으로 메모리가 할당/해제 되고 주소까지 출력되는 것을 확인할 수 있다.
지금까지는 캐릭터 한 개를 malloc으로 만드는 방법을 알아보았다. 하지만 게임에 접속하는 유저의 수가 10명이 될지, 100명이 될지 모르는 상황이기 때문에 구조체 배열 사용해야 하는데, 이를 자체를 동적으로 늘렸다 줄였다 하려면 어떻게 해야 할까?
바로 malloc( )을 활용해서 원하는 크기만큼의 구조체 연속 공간을 할당받고, 이를 일반 배열처럼 index로 접근하면 된다.
구조체 배열 할당 예제를 살펴보자

여기서 몇 가지 헷갈려 하는 포인트들이 있을 것이다.
우리가 단일 구조체 동적 할당을 할 때는 player->hp = 80;처럼 화살표를 사용했다.
하지만 배열로 선언한 뒤 party[i]를 붙이는 순간, 주소에서 구조체 덩어리로 변환된 상태이다. [party[i] = *(party + i)와 동일]
따라서 '일반 구조체 변수' 취급이 되므로 ->가 아닌 .을 사용하는 것이 맞다.
그리고 마지막에 free( )함수로 할당받은 배열을 해제하는 코드를 작성한 곳에서 malloc( )으로 메모리를 할당받을 때에는 party_size를 곱해서 여러 칸을 빌렸는데, 왜 free는 반복문으로 여러 번 해제하지 않고 한번만 작성했는지도 궁금할 수 있다.
이것은 malloc( )이 메모리를 할당할 때 아무리 큰 크기라도 힙 영역에 단 하나의 거대한 연속된 덩어리(Block)의 형태로 빌려준다.
따라서 반납할 때도 그 덩어리의 시작 주소만 free( )에 던져주면 컴퓨터가 통째로 메모리를 해제한다.

코드를 실행하고 생성할 캐릭터 수를 입력하면 그 수만큼 메모리를 가져와서 사용한다
다음은 calloc( )[Clean-Allocation]함수이다.
calloc( )은 malloc( )과 똑같이 힙 영역에 메모리를 빌려오는 함수이지만, "빌려오는 동시에 메모리를 깨끗하게 청소(0으로 초기화)해준다"는 결정적인 차이점이 존재한다.

malloc( )은 필요한 총 바이트 수를 한 번에 넘겼지만, calloc( )은 "몇 개(count)가 필요한지"와 "한 개당 크기(size)가 얼마인지"를 나누어서 인자로 받는다.
malloc( )과 calloc( )은 메모리를 빌려온 직후에 결정적인 차이점이 드러난다.
malloc( )은 메모리 공간만 빠르게 딱 잘라서 주소를 넘겨준다는 점, 새로 할당된 공간에는 이전에 다른 프로글매이 쓰다 버린 메모리 찌꺼기인 쓰레기 값이 그대로 있어 프로그래머가 직접 값을 초기화하고 써야한다는 점이 있다.
calloc( )은 메모리를 할당함과 동시에, 그 공간의 모든 비트를 0으로 깔끔하게 초기화 해준다.
정수형 변수라면 0, 포인터라면 NULL, 문자열 배열이라면 자동으로 빈 문자열(\0) 상태가 되므로 초기화 코드를 따로 짤 필요가 없다.
앞서 작성했던 예제를 calloc( )으로 구현해보자.

malloc(sizeof(character) * 3)은 우리가 연산한 총 바이트 수 72byte를 하나의 덩어리로 전달했었다. 반면 calloc( )은 "24byte짜리 연속된 공간 (sizeof(Character))을 총 3개(party_size)그룹으로 나누어 할당해라" 라고 명시적으로 요청한다.
내부적으로 컴파일러는 두 인자를 곱해 최종적으로 72 byte의 공간을 계산하지만, 관리 단위가 배열 형태라는것을 명확하게 인지한다.
calloc( )또한 성공 시 할당된 힙 영역의 최저 주소(시작 주소)를 void* 타입으로 반환한다. 이를 앞의 (Character*)를 통해 "이 72byte 주소 공간을 앞으로 24byte 단위로 끊어서 읽는 구조체 포인터로 쓰겠다" 고 선언하는 것이다.

코드를 실행해보면 초기화 값이 0이 되어 있는 것을 확인할 수 있다.
그렇다면 언제 malloc( )을 쓰고 언제 calloc( )을 사용하는 것이 좋을까?
1. malloc( )을 사용하는 상황[성능 제일주의]
-> 할당하자마자 어차피 반복문을 돌려서 strcpy나 대입 연산자로 값을 전부 채워 넣을 예정이라면 malloc( )이 좋다.
calloc( )은 메모리를 전부 0으로 바꾸는 내부 연산(청소 과정)이 추가되므로, 덩치가 큰 메모리를 다룰 때는 malloc( )이 미세하게 더 빠르다.
2. calloc( )을 사용하는 상황[안전 제일주의]
-> 동작 배열을 만들어 두고 필요한 칸에만 듬성듬성 값을 넣어야 하거나, 초기 상태가 무조건 0 또는 NULL이어야 안전한 프로그램(예: 은행 잔고 시스템, 플래그 변수 배열 등)을 제작할 때는 코드가 간결해지고 버그를 방지할 수 있는 calloc( )이 압도적으로 유리하다.
다음으로 realloc( )[Re-allocation]함수에 대해 알아보자.
이것은 malloc( )으로 빌려온 메모리의 크기가 마음에 들지 않을 때, 기존 데이터를 안전하게 보존하면서 메모리 공간의 크기를 실시간으로 늘리거나 줄여주는 함수이다.

ptr에는 힙 영역에 할당받은 기존 메모리의 시작 주소를 넣어주고, size에는 새롭게 변경하고 싶은 최종 전체 크기(byte 단위)를 넣어준다.
realloc( )을 실행하면 컴퓨터는 힙 영역의 상황을 보고 내부적으로 다음 3가지 중 하나의 방식으로 작동한다.
1. 연장 방식(그 자리에서 늘리기): 기존 메모리 덩어리 바로 뒤에 붙어있는 공간이 마침 비어있다면, 주소는 바꾸지 않고 뒤쪽으로 영역만 이동한다. [가장 효율적]
2. 이사 방식(새 주소로 복사): 기존 메모리 뒤에 다른 데이터가 이미 자리를 잡고 있어서 늘릴 수 없다면, 힙 영역의 다른 넓은 빈 곳을 찾아 새로운 덩어리를 통째로 새로 빌린다. 그리고 기존 데이터를 새 공간으로 알아서 다 복사해 준 뒤, 기존 메모리는 자동으로 free한다.
3. 실패: 힙 영역 전체에 내가 요구한 크기만큼의 연속된 공간이 없다면 NULL을 반환한다.
그러면 앞서 다룬 가변 구조체 배열 예제에서 파티원을 실시간으로 1명 더 추가하는 예제 코드를 살펴보자.

Character 구조체는 내부에 char role[20] (20byte)와 int hp (4byte)를 가지고 있기 때문에 총 24바이트 이다.
2명 파티이므로 2를 곱해 힙 영역에 연속된 48byte 메모리 공간을 달라고 요청한다.
48byte 공간을 24byte씩 두 칸으로 쪼개서 index 0과 index 1에 각각 데이터(role, hp)를 안전하게 채워 넣는다.
그리고 파티원이 3명으로 늘어났다고 가정했을 때 크기를 또 계산한 이유는 realloc( )함수에는 "기존 크기에서 얼마를 더 늘려줘" 개념이 아니라, "내가 원하는 총 크기는 이만큼이야" 라고 전달해야 하기 때문이다.
이때 party가 아닌 temp(임시 포인터)로 받았는데, 이것은 앞서 설명했듯 컴퓨터 내부 공간이 부족하면 realloc( )은 기존 메모리를 건드리지 못하고 NULL(0)을 반환한다.
만약 party = (Character*)realloc(party, ...);로 사용했다가 실패하면 party 변수 자체가 NULL로 바뀌면서 기존에 존재했던 '전사', '마법사'의 주소까지 영영 잃어버리는 메모리 누수가 일어날 수 있기 때문이다. 따라서 임시 주소록인 temp에 안전하게 먼저 받았다.

예외 처리로 힙 영역에서 요구한 연속된 공간이 없어 NULL을 반환했다면, 기존 party 주소는 temp 덕분에 살아있으므로
기존 데이터라도 안전하게 free(party)로 반납하고 프로그램을 종료할 수 있다.
만약 재할당이 성공하여 party = temp;까지 도달했다면, temp에 들어있는 확장된 72byte짜리 새로운 메모리의 시작주소를 원본 포인터인 party에 대입하여 동적 가변 배열을 확정 짓는다.
[만약 힙 영역에 자리가 부족해 이사를 갔다면(realloc 동작방식 2번) party가 가리키는 주소값 자체가 완전히 새로운 주소로 바뀐다]

성공적으로 3칸(72byte)짜리 배열이 되었으므로, 기존에 접근했을 때 오류가 발생했을 3번째 칸(party[2])에 안전하게 접근하여
"도적" 데이터를 채워 넣었다.
party[0], party[1]은 malloc( )으로 만들었고, party[2]는 realloc으로 만들었으니 free( )를 여러번 해야 하는가?
-> 이미 realloc( )이 성공한 시점부터 이 메모리는 힙 영역에 존재하는 단 하나의 거대한 72byte짜리 연속된 덩어리 이므로, 마지막에 free(party) 한 번만 해주면 컴퓨터가 알아서 72byte 전체를 수거해 간다.
VR게임 개발을 위한 언리얼엔진/C++ 공부한 내용 끄적이기...
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!