Programming Language/C++

Ch07. 포인터 - 04.동적 할당

hustle_D 2025. 4. 10. 17:58
반응형

1. 할당

C++에서 할당(allocation)이란 메모리를 확보해서 어떤 변수나 객체가 사용할 수 있도록 해주는 것을 의미한다

즉 이 말은 프로그램이 실행되는 동안 사용할 수 있는 메모리 공간을 만드는 행위를 말하는 것이다.

 

이 할당의 종류는 자동할당, 정적할당, 동적할당이 존재하는데 간단하게 각각 자동으로 할당을 해주고, 정적으로 할당을 해주고, 동적으로 할당을 해준다는 의미이다.

 

1) 자동할당(Automatic Allocation)

자동할당은 함수 내 지역변수에서만 적용되는 메모리 할당 방식으로 함수가 호출되면 생성되고 함수가 종료되면 자동으로 소멸되는 할당 방식이다.

자동할당을 할때에는 메모리의 stack영역에 공간을 할당하게 된다.

 

자동할당은 변수를 선언할때 자동으로 메모리를 할당하는 방식으로 함수나 블록의 스코프를 기준으로 동작하게 된다.

그리고 함수가 종료되면 자동으로 메모리가 반환되기에 따로 delete나 free같은 함수를 호출해줄 필요도 없다.

int main() {
    int num = 10;  // 자동 할당 (지역 변수 → 스택에 저장)
    
    int num1;      // 이 순간 메모리의 스택공간에 자동으로 할당됨
    num1 = 20;     // 할당된 스택 공간에 10이라는 값을 저장함
}

이 메인 함수 내부에 선언되는 변수의 할당 또한 자동할당에 해당한다.

 

2) 정적할당(Static Allocation) 

정적할당은 프로그램이 시작될 때 메모리를 할당하고 종료될 때 까지 유지되는 메모리 할당 방식으로 실행 중 언제 쓰일지 미리 아는 변수들은 프로그램 시작 시에 딱 한 번 메모리 공간을 잡아 놓고 끝날 때 까지 그 공간을 점유한다.

 

정적할당을 하는 변수의 종류는 여러가지가 있는데 가장 먼저 전역적으로 선언한 모든 변수들은 정적할당 방식을 사용한다 

전역적으로 선언했다는 말은 어떤 함수에도 포함되지 않았다는 의미로 

이런식으로 코드에서 어떤 함수에도 포함되지 않는 변수는 전역변수에 해당하고, 이 변수는 정적할당방식을 사용하게 된다.

 

지역변수의 경우는 static이라는 명령어를 붙인 경우에 정적할당을 하게 된다.

 

추가로 문자열 리터럴의 경우

와 같이 할당됨.

 

여기서 정적할당의 경우는 데이터 영역에 저장되게 되는데 이 데이터 영역은 사실은 Data영역 + BSS영역으로 나뉘어 있고 Data영역의 경우 .data섹션과 .rodata섹션으로 구성되어 있고, BSS영역은 .bss섹션 단일로 이루어져있다.

 

1. .data섹션

.data섹션에는 초기화된 전역 변수와 초기화된 static 변수들이 저장되는 공간이다.

초기화가 되었기 때문에 읽기/쓰기가 가능하다.

int a = 10;          // .data
static int b = 20;   // .data

 

2. rodata영역(Read Olny Data)

읽기 전용 데이터 영역으로 문자열 리터럴, const 전역 상수 등의 데이터가 저장된다.

읽기 전용이기에 수정을 시도하면 에러를 보인다(segmentation fault)

const int e = 100;   // .rodata
const char* msg = "Hello";  // 문자열 리터럴 "Hello" → .rodata

 

3. .bss영역

초기화되지 않은 전역 변수, 초기화되지 않은 static 변수를 저장한다.

실행 시 0으로 초기화된다(하지만 실제 바이너리에는 공간만 예약됨)

int c;               // .bss
static int d;        // .bss

 

정적할당의 경우는 컴파일 시점에 메모리의 공간을 할당할 곳이 정해지기 때문에 코드가 진행됨에 따라 값이 초기화 되더라도 .data , .bss 영역에 선언 되어 있다면 그 위치에 값을 저장하게 된다는 점을 알아두자.

 

그리고 이제 마지막으로 동적 할당이 있다.

2. 동적할당(Dynamic Allocation)

동적할당은 프로그램 실행 중(runtime)에 메모리를 직접 요청해서 확보하는 방식으로 확보한 메모리는 사용자가 직접 해제해줘야만 한다

 

그럼 동적할당을 하는 이유는 무엇일까?

동적할당은 배열의 크기를 컴파일 시점에는 알 수 없을때, 객체를 여러개 만들고 싶으나 숫자를 유동적으로 조절하고 싶을 때, 수명이 함수 밖까지 유지되어야 하는 경우에 필요로 하게 된다.

지정된 메모리 할당 방식 말고 좀 더 유연하게 사용자가 하고 싶은대로 메모리를 사용하고 싶을때 사용한다고 보면 된다.

 

동적할당된 값은 메모리 영역중 힙(Heap)영역에 저장되게 된다.

Heap영역은 스택과 다르게 크기가 크고 프로그램이 직접 관리해야하는 영역으로 한번 할당된 메모리는 해제하지 않으면 프로그램의 종료시점까지 유지가 되게 된다.

 

3. 동적메모리를 사용하기 위한 키워드 new와 delete

동적할당은 new 키워드를 통해서 할 수 있는데 기본적인 사용의 예시는

int* ptr = new int;

이는 new 키워드를 통해서 int 하나의 공간을 메모리의 힙 영역에 할당을 한 후에 그 주소값을 ptr에 저장해둔 것이다.

이를 분리해서 보면 시점을 더 명확하게 알 수 있는데 

int* ptr;        // 택에 포인터(ptr)를 위한 메모리(보통 4 또는 8바이트)가 할당됨
ptr = new int;   // 힙(Heap)에 int 크기만큼(보통 4바이트) 메모리 할당되고 그 주소가 ptr에 저장됨

 

이렇게 동적할당의 경우는 동적으로 공간만 할당해서 그 주소값을 반환하기 때문에 항상 포인터를 사용해서 그 값을 저장해둬야 한다.

 

이렇게 동적 할당된 공간은 항상 delete를 사용해서 해제를 해줘야만 한다.

int* ptr;
ptr = new int;
delete ptr; // 동적 할당 해제

 

이렇게 delete를 안해주는 경우에는 메모리 누수가 발생할 수 있다

 

이렇게 선언되는 경우 루프를 돌때마다 메모리를 할당하고 제거하고 하면서 일정한 메모리의 사용을 보이게 된다.

이는 디버깅 모드를(F5) 돌리면 확인할 수 있는데 

보면 프로세스 메모리가 1016으로 일정하게 사용되는 것을 볼 수 있다.

 

그런데 만약 이게 자동할당(지역변수)가 아니라 동적 할당인 경우에는 

디버깅 모드로 실행시키면

이렇게 시간이 지남에 따라 프로세스 메모리가 점점 증가하는 것을 볼 수 있다.

그러면 저 때 delete를 해준다면 어떻게 될까

이렇게 delete를 해주면 자동할당을 하는 지역변수를 사용했을때와 동일하게 메모리의 누수가 발생하지 않는 것을 볼 수 있다.

이렇게 동적 메모리 할당을 했을때 delete를 해주지 않으면 메모리 누수가 발생할 수도 있게 된다라는 점을 알고 있자.

 

4. 동적할당의 사용

new 키워드를 사용하면 동적으로 메모리를 할당할 수 있다고 했었다.

그러면 값의 저장은 어떻게 해야할까?

int* ptr = new int(값);

과 같이 사용하면 동적으로 메모리 공간을 할당하면서 값을 넣어줄 수 있게 된다.

 

주의해야할점은 이를 해제하면 메모리 공간 자체가 사라지기 때문에 역참조를 했을때 문제가 발생할 수 있다.

이렇게 동적할당된 공간에 123이라는 값을 넣고 그 동적할당된 공간의 주소값을 ptr에 저장해두고 ptr1은 ptr에 저장한 주소값을 받아 같이 저장해둔다고 생각해보자.

이 주소값들을 출력해보면

이렇게 같은 주소값을 바라보는 것을 볼 수 있다.

그런데 중간에 ptr의 할당을 해제 하게 된다면 그 ptr의 주소를 저장했던 ptr1의 경우는 

여전히 같은 주소값을 바라보는 것을 알 수 있다.

그러면 ptr의 경우는 delete이후에 어떤 주소값을 보고 있을까?

보면 ptr은 delete이후엔 다른 주소 값을 가지고 있는 것을 볼 수 있는데 그러면 이를 역참조하면 어떤 값을 확인할 수 있을까?

보면 ptr1의 경우는 쓰레기 값을 가지게 되었고 ptr의 경우는 아예 초기화 되지 않은 상태라고 알려주고 있다.

 

이렇게 사용되는 상황을 조심해야한다.

 

추가로 delete를 두번 연속으로 사용한다면 어떻게 될까?

이 또한 동일한 상황을 보여준다.

 

이 처럼 유효하지 않은 포인터를 뎅글링 포인터(Dangling Pointer)라고 한다.

 

5. 뎅글링 포인터(Dangling Pointer)

이미 소멸되거나 해제된 메모리를 여전히 가리키고 있는 포인터를 말하는 말로 포인터 자체는 주소를 갖고 있으나 그 주소에 있는 실제 메모리는 더 이상 유효하지 않은 상태를 말한다.

 

뎅글링 포인터를 발생시킬 수 있는 시점이 몇가지 존재하는데 

 

1) 지역 변수 주소 반환

#include <iostream>

int main() {
    
    // 포인터 변수 (스택에 생성)
    int* ptr;                   
    
    // 지역변수 생성을 위한 새로운 블럭 스코프 생성
    {
    	// 변수 n은 해당 블럭에서만 살아 있는 블록 지역 변수
        int n = 10;
        
        // ptr에 블럭에서만 살아 있는 지역변수 n의 주소값을 저장한다
        ptr = &n;
    } // 블록 스코프를 벗어나면서 int n 은 자동으로 할당이 해제되면서 메모리 공간이 사라진다

	// 이제 ptr은 사라진 n의 주소를 역참조하게 되면서 뎅글링 포인터가 되게 된다.
    std::cout << *ptr << std::endl; // 이렇게 사용하면 이제 문제가 커지게 됨

}

 

2) 메모리 해제 후 접근(delete, free - free는 아직 배우지 않아 사례로 적진 않음)

#include <iostream>

int main() {
    
    int* ptr = new int(123); // heap공간에 int크기의 공간을 동적으로 할당한 후에 ptr에 주소값 저장

    delete ptr; // heap 공간에 있는 int 공간을 해제(동적 할당 해제)
	// ptr은 이 시점 이후 뎅글링 포인터가 됨
    
    std::cout << *ptr << std::endl;

}

 

 

3) 소멸된 객체에 접근하는 경우(예시는 아직 객체에 대한 정보가 없기에 나중에 보기로 하자)

 

6. 동적 배열

동적 배열은 컴파일 타임이 아닌 런타임에 크기를 결정해서 메모리를 할당하고 사용하는 배열로 동적할당과 비슷하게 힙(heap)메모리 영역을 할당하고 사용하는 배열을 의미한다.

동적 배열의 경우는 new[]와 delete[]라는 키워드를 사용한다.

 

1) 동적 배열의 선언

동적 배열의 경우는 

int* arr = new int[크기];  // 예: int* arr = new int[5];

와 같이 사용한다.

 

그런데 이제 크기는 동적으로 지정할 수 있게 된다.

 

사용 예시를 보자면

#include <iostream>

int main() {
    int size;
    std::cout << "배열 크기 입력: ";
    std::cin >> size;

    int* arr = new int[size];  // 동적 배열 생성

    for (int i = 0; i < size; ++i) {
        arr[i] = i * 2;
    }

    for (int i = 0; i < size; ++i) {
        std::cout << arr[i] << " ";
    }
    std::cout << std::endl;
}

이런식으로 사용이 가능한데 보이는것 처럼 원래는 배열의 경우는 

이렇게 변수로는 넣을 수 없고 상수로만 넣어야하기에

상수는 값을 변경 할 수 없다.

배열을 입력 받을때 그 크기를 확인해서 배열의 크기를 지정할 수 가 없는데 

이렇게 동적배열을 사용하면 크기를 동적으로 지정할 수 있는 배열을 생성할 수 있게 된다.

그리고 이렇게 선언한 동적 배열의 경우는 동일하게 포인터를 통해서 받아줘야만 한다.

또한 쓰레기 값이 들어가지 않게 이 값을 {}를 붙여 

이렇게 선언을 해줘야 한다.

 

포인터를 사용해서 이 동적 배열을 받아주는 이유는 new 키워드를 사용한 동적 배열의 경우는 힙영역에 저장되고 이 배열은 이름이 없기 때문에 바로 접근해서 사용할 수 가 없다.

그렇기에 그 이름의 역할을 할 포인터가 필요한 것이다.

 

그리고 이렇게 선언한 배열의 사용은 

값이 이렇게 초기화되어 있다고 할때 

이렇게 그냥 배열을 사용하듯 사용하면

값을 출력해주게 된다(위에 10은 배열의 크기를 받은 것임)

 

이렇게 선언한 동적 배열의 경우는 동적 할당과 동일하게 new[] 키워드를 사용해서 동적으로 할당을 하고 이 값을 해제 해줘야만 메모리 누수를 막을 수 있다.

 

이를 해제하는 키워드는 delete[] 키워드 이다.

 

7. 구조체의 동적할당

구조체 또한 동적으로 할당이 가능하다.

우선 임의의 구조체를 하나 정의해주고

이 구조체를 동적할당 할때에는 

이렇게 포인터의 타입을 동적할당하는 타입에 맞도록 만들어주면된다.

 

그리고 이제 값을 할당할텐데 이 값의 할당은 구조체 포인터의 경우는 방법이 조금 다르다.

 

1) 구조체와 포인터

이전에 배열의 경우는 

int arr [5] = {1, 2, 3, 4, 5};

int* ptr = arr;

// 셋이 동일함
std::cout << arr[0] << std::endl;
std::cout << ptr[0] << std::endl;
std::cout << *(ptr + 0) << std::endl;

이런 관계로 되어 있다는 사실이 있었다.

 

여기서 구조체 포인터의 경우도 비슷한 접근 방법이 존재하는데 

 

구조체 포인터의 경우는 

struct Sample {
    int num;
    float fnum;
}

이라는 구조체가 정의되었을때 

Sample sample = { 10 , 3.14f};

//or

Sample sample;
sample.num = 10;
sample.fnum = 3.14f;

와 같이 값을 초기화 했었다.

그러면 이렇게 포인터를 넣었을때는 어떻게 달라질까?

보면 . 표기법을 통해서 멤버에 접근할 수 없다는 것을 알 수 있다.

그 이유는 structPtr은 student의 주소값이기 때문에 *을 붙여서 structPtr이 가리키는 구조체로 접근한 다음에 . 표기법을 사용해서 접근해야한다.

 

() 중괄호가 빠지면 

인식을 안하는것 같다.

 

아무튼 이렇게 접근해야하는데 여기서 

(*ptr).멤버

이걸 좀 더 간략하게 사용하는 방법이

ptr->멤버

이 방법이다.

동일한 방법이라고 생각하면 된다.

그래서 

이렇게 접근이 가능함을 알 수 있다.

 

2) 동적할당한 구조체

동적 할당한 구조체의 경우 접근하는 방법은 동일하다.

이렇게 접근을 하면 된다.

 

더보기

* 구조체의 멤버중 char [] 배열 처럼 고정 크기의 문자배열이 존재할 경우

이렇게 값을 넣어줄 수 없다.

그 이유는 

"Kim"

문자열 리터럴의 경우는 주소값을 저장하고 그 주소값을 반환하는 형태이고, 배열의 경우는 생성됨과 함께 배열의 주소값이 고정되기 때문에 = 연산을 통해서 값을 치환할 수 없다.

 

그렇기에 strcpy와 같은 함수를 사용해서 값을 넣어줘야 한다는 점 유의하고 있자.

 

3) 포인터 배열에서의 동적 할당

구조체 포인터 배열을 동적으로 할당해서 배열을 만들어줄 수 도 있다.

이렇게 동일한 형태로 선언해주면 되고 값에 접근하는 방법은 

이렇게 range-based for문을 통해서 포인터 배열을 하나 하나의 포인터로 나누고

이렇게 각각의 포인터 배열에 대해서 접근해서 저장하고 출력하면된다.

 

 

더보기

주의 해야할점은(내가 공부하면서 헷갈린 부분이긴함 ㅋ...) 

위에서는 

    Student* structPtr[2] = {
        new Student{},
        new Student{}
    };

이렇게 만들었었는데 이 형태의 경우는

Stack:

            ┌──────────────┬──────────────┐
structPtr ──│ structPtr[0] │ structPtr[1] │  ← 포인터 배열 (스택)
            └──────┬───────┴────────┬─────┘
                   │                │
                   ▼                ▼
Heap:
          ┌────────────────┐  ┌────────────────┐
          │  Student 객체  │  │  Student 객체   │
          │  id     = 0    │  │  id     = 0    │
          │  height = 0.0f │  │  height = 0.0f │
          │  name[10] = \0 │  │  name[10] = \0 │
          └────────────────┘  └────────────────┘

 

이런 형태로 structPtr[] 배열이 갖고 있는 각각의 요소가 포인터로 존재하기에 각각의 요소가 new Student를 바라볼 수 있고 그렇게 초기화도 가능한 형태이다

 

그럼 아래와 같은 형태는 어떻게 될까

Student* structPtr = new Student[2]{}

 이 경우엔 내부에 저장되는 배열의 요소 하나 하나의 타입은 포인터가 아니라 구조체라는 점이 다르다.

                ┌───────────────┬───────────────┐
 structPtr ---> │   Student[0]  │   Student[1]  │
                └───────────────┴───────────────┘

이렇게 내부에는 구조체 포인터가 아니라 그냥 구조체가 들어있는 형태이기 때문에 

structPtr로 접근할때는 

Student* structPtr = new Student[2]{};

structPtr[0]     // 구조체 배열을 포인터로 갖고 있기에 배열에 접근하듯이 바로 접근이 가능함

structPtr[0].id  // 구조체 배열에서 [idx]로 접근했을땐 주소값이 아니라 *으로 접근한것과 동일하게 
                 // 값에 접근한것이기에 바로 .(점)표기법을 사용해서 접근이 가능함

 

그렇기에 선언과 함께 초기화할때 내부에 new 를 사용해서 구조체를 넣는 것도 불가능하다.

new 를 사용해서 구조체를 동적할당하면 이 구조체의 주소값을 전달하는 것이기에 이걸 받아줄 수 있는 타입은 Student 포인터 타입만 가능하다.

이것처럼 받아주는 타입은 Student* 타입이여야 하는데 위에서 말했던것 처럼 구조체 배열 포인터의 경우는 배열에 포인터를 붙인거지 내부에 있는 구조체에 포인터를 붙인게 아니기 때문에 내부의 값의 타입은 그냥 구조체 타입(Student)이 된다.

 

그렇기에 new를 통해 생성한 구조체의 주소값을 받아줄 수 없기 때문에 내부에 new를 통해서 구조체를 동적 할당하여 초기화하는것은 불가능하다라는 점을 알고 있자.

 

이걸 궂이 쓰고 싶다면(진짜 의미는 없지만..)

이렇게 주소값이 아니라 *연산자를 붙여서 역참조하여 값을 전달해줘야한다.

 

물론 이 타입은 그냥 Student 타입이라는 점을 알고 있어야 한다.

 

반응형