동적 배열

골두·2024년 6월 18일

C++의 기묘한 모험

목록 보기
1/4
post-thumbnail

C++에서는 동적 배열을 선언하는 방법과 할당하는 방법이 은근 까다롭다. 그렇기 때문에 다들 JS같은 언어를 좋아하는게 아닐까 싶다...

방법

struct Test {
    int i;
}

Test* Depth1Array = new Test[변수];
Test** Depth2Array = new Test*[변수];

동적 할당의 경우 어찌됬든 배열은 여러개의 연속된 메모리 주소를 기반으로 동작하기 때문에 포인터로 선언 후 사용하는 방식을 거치게 된다. (매우 놀랍게도...)

동적 메모리 할당

C++에서는 메모리를 할당하는 방법으로 3가지가 존재한다.

  • 정적 메모리 할당: 정적 변수와 전역 변수에 대해 발생하는 할당으로 프로그램이 실행될 때 한번 할당되며 프로그램 수명 내내 지속된다. (const를 애용하자)
  • 자동 메모리 할당: 함수 매개변수와 지역 변수에 대해 발생하고, 변수에 대한 메모리는 관련된 블록을 입력할 때 할당되며 블록을 종료할 때 필요에 따라 여러 번 해제된다.
  • 동적 메모리 할당: 현재 알아볼 내용...

다른 두개의 메모리 할당 방법(정적과 자동 메모리 할당)에는 2가지 공통점이 존재한다.

  • 변수/배열의 크기를 컴파일 타임에서 알아야 한다. (즉 변수가 아닌 상수로 선언을 해줘야 한다. 런타임에서 할당되거나 변경되는 변수는 사용이 불가능하다)
  • 메모리 할당 및 해제가 자동으로 수행된다. (변수가 인스턴스화, 제거 되는 경우를 의미)

외부에서 (런타임 환경에서 사용자 or 파일의 개입) 이 입력을 처리할 때 입력의 최대 치(문자열의 경우 문자를 어느정도까지 칠 지 예측이 거의 불가능함.)를 초과하는 경우도 발생할 수 있고, 컴파일 과정에서 이를 처리하려면 임의로 변수의 최대 크기를 추측하고 그 값을 할당하는 방법 뿐이다

char name[25];
Record record[500];
.
.

위 변수 할당은 이슈가 발생할 수 있는데

  1. 변수가 실제로 사용되지 않는 경우의 메모리 낭비가 발생한다.
  2. 고정 배열 등의 일반 변수는 스택 메모리 영역에 할당되는데 Visual Studio의 경우 스택 크기가 기본 1MB가 할당된다. 이 크기가 초과하는 경우는 Stack Overflow가 발생해 운영체제가 해당 프로그램을 종종 종료시키곤 한다.

2번의 경우는 Unreal Engine에서 강제로 큰 메모리 영역을 할당시키면

11>MazeSpawner.gen.cpp(51): Warning C4305 : '초기화 중': 'size_t'에서 'uint16'(으)로 잘립니다.
11>MazeSpawner.gen.cpp(51): Error C4309 : '초기화 중': 상수 값이 잘립니다.

// 적당한 struct 2중 배열의 경우 각 칸이 16정도까지는 문제가 없으나 32, 64, 128 같은 큰 숫자는 반드시 해당 에러가 발생한다.

와 같은 에러가 발생하는데 이와 비슷한 이유로 인해 발생하는 문제가 될 것이다.

이러한 문제를 해결하기 위해 동적 메모리 할당을 이용해 해결한다.

할당 방법

위에 방법이랑 동일한 방식으로 할당한다. new 연산자를 이용해 할당한다.
운영체제에서는 변수에 대해 메모리를 요청하기 때문에 new 연산자를 이용해 메모리를 사용해 객체를 만들고 할당된 메모리의 주소가 포함된 포인터를 반환할 수 있도록 처리한다.

new int;
int ptr* = new int; // 새롭게 동적으로 할당한다.

*ptr = 7; // 할당하고 역포인터를 이용해 해당 변수를 설정해준다.

int ptr2* = new int(5); // 5로 초기에 바로 할당 처리
int ptr3* = new int { 7 } // 위에 할당과 동일하게 직접 바로 할당함

즉 포인터를 통해서 객체 정보를 전달하고 역포인터로 값을 할당하는 방식으로 동적 할당을 사용하고 있다. 물론 이렇게 된다면 할당된 메모리를 유지하는 포인터가 사라지게 된다면 할당된 메모리에 접근할 수 없게 된다.

제거 방법

new 연산자를 이용해 새로 할당했다면 그와 반대로 해제 즉 메모리에서 제거하고 추후 재사용할 수 있도록 C++에 명시적으로 알려야할 필요가 있다.

Garbage Collector가 별도로 없기 때문에 이런 번거로운 작업을 수행해야한다.

단일 변수의 경우는 delete 연산자를 이용해 제거 명시가 가능하다.

여기서 중요한 점은 제거가 아닌 제거 "명시" 라는 것이다.

int ptr* = new int;

delete ptr;
ptr = 0; // 기존의 포인터 주소를 0으로 만든다. C++ 11버전에서는 0으로 하지만 그 이상 버전에서는 nullptr를 사용한다. (nullptr이 더 명시적이기도 하다)

왜 제거 명시인가?

delete 연산자의 경우 실제로 삭제하는 것은 아무것도 없다. 가리키는 메모리를 다시 운영체제로 반환하고 이후 그 운영체제에서 해당 메모리를 다른 응용 프로그램에 다시 할당하는 방식이다.

즉 삭제의 역할보다는 삭제를 명시하면서 메모리 주소 값을 운영체제에 반환해 추후 재사용을 할 수 있도록 하는 방식을 말한다.

다만 반환이기 때문에 동적으로 할당된 이 메모리를 가리키지 않는 포인터를 삭제하면 이슈가 생길 수 있다.

댕글링 포인터 (Dangling pointers)

C++ 계열에서는 가장 유명한 이슈 중 하나로 할당되지 않은 메모리의 내용이나 삭제되는 포인터의 값에 대해서는 보장하지 않는다.

운영 체제에 반환되는 메모리는 반환되기 전의 값과 동일한 값이 포함되며, 포인터의 경우 현재 할당 해제한 메모리를 가리키게 된다.

이렇게 할당이 해제된 메모리를 가리키는 포인터를 댕글링 포인터라고 부르는데 이 댕글링 포인터를 역참조하거나 삭제하면 정의되지 않은 동작이 발생할 수 있다.

// 새로운 메모리 주소 할당.
int* ptr = new int;
*ptr = 7;

// ptr 값에 7이 들어가 있기에 7이 출력된다.
std::cout << *ptr << endl;

// 기존 메모리 주소를 더이상 사용하지 않는다고 반환한다.
delete ptr;

// 값 자체가 뭐가 나올지 모른다.
// 애초부터 해당 주소 값에 무언가를 지웠기 때문이다.
// 하지만 같은 주소를 바라보고는 있다.
std::cout << *ptr;
delete ptr;

위 프로그램에서 이전에 할당된 메모리에 할당된 값인 7 자체는 사라지지 않는다. 하지만 메모리의 주소 값이 변경되었을 수 있으며, 메모리가 다른 프로그램에 할당될 수 있다. 이렇게 되는 경우 그 메모리에 접근하게 될 때 운영 체제가 프로그램을 종료시킨다.

OS 관점에서 고려해봤을 때 메모리 주소라는 것은 프로그램 별로 할당되는 것이 아닌 모든 프로그램에 공통적으로 할당되고 그 메모리 주소가 서로 다른 프로그램 간에 할당 되어버린다면, 끔찍한 일을 초래할 수 있기에 강제 종료가 되지 않나로 추측된다.

그렇기 때문에 이 부분을 방지하려면 대표적으로 2가지 방법이 존재한다. (더 있을 수는 있다)

  1. 여러 포인터가 같은 동적 메모리를 가리키는 것은 피하자. 하나의 동적 메모리는 하나의 포인터가 할당되게 해주는 것이 좋다. 그 포인터 자체를 활용 하는 것 까지만으로 만족하는 것이 좋아 보인다.
  2. 포인터를 삭제할 때 단순히 delete 선언해주는 것 보다 0 혹은 nullptr을 선언해주자.

Operator new can fail

아주 드문 경우에 발생하지만 운영 체제에 해당 메모리가 존재하지 않는 경우가 있다.

기본적으로 new 연산자가 실패하면 bad_alloc 예외 처리가 발생하게 된다. 이 예외가 제대로 처리되지 않으면 프로그램이 오류로 종료된다. (즉 크리티컬 이슈가 발생한다)

예외 처리를 던져 해결하는 것이 바람직하지는 않기에 메모리를 할당하는 것이 불가능한 경우 nullptr를 반환하도록 하는 새로운 new 연산자를 이용하는 것 또한 방법이다.

std::nothrow를 추가해 메모리 할당이 실패한 경우 nullptr이 자동으로 할당되게 된다.

int* value = new (std::nothrow) int;

Null pointers and dynamic memory allocation

Null pointer 자체는 동적 메모리 할당을 처리할 때 유용하다. 동적 메모리 할당과 관련해 null pointer는 기본적으로 이 포인터에 메모리가 할당되지 않았음을 의미한다.

// unreal의 경우 IsValid를 이용해 검증하기도 한다. nullptr는 false 형식으로 반환되기도 한다. (숫자로 0을 의미하기에)
if (!ptr) {
    delete ptr; // null pointer는 삭제되지 않기에 명시를 해줄 필요 또한 있다.
}

자바에서 흔히 말하는 NullPointerException의 경우 인스턴스화 되지 않은 즉 null 자체인 개체에 access할 때 발생하는 런타임 예외이다. 자바는 객체의 함수 (배열이나 객체의 값에 접근)를 사용할 때 null에 접근하기 때문에 관련된 함수가 없다라는 의미에서 사용되지만 C++의 경우는 값이 존재해도 메모리 주소가 할당되지 않은 경우에 발생하게 된다.

Memory Leak

응용 프로그램 (웹 클라, 서버) 개발을 해본다면 종종 발생하는 문제가 C++에서도 참 쉽게 발생할 수 있다.

Memory Leak의 이유는 동일하다. 불필요한 메모리를 수거하지 않고 계속해서 유지하면서 동시에 이 분량이 너무 많아지게 된다면 메모리는 할당할 메모리가 없어지게 되고 이것이 곧 메모리 부족 상태로 이어질 수 있다.

C++에서는 동적으로 할당된 메모리는 범위가 없기에 명시적으로 할당이 해제되거나 프로그램이 종료될 때 까지 할당 상태를 유지하게 된다.

그렇기에 동적으로 할당된 메모리 주소를 유지하는 데 사용하는 포인터는 일반 변수 범위 지정 규칙을 따르는데, 이 것이 문제를 일으키는 원인이 되곤 한다.

void doSomething()
{
    int* ptr = new int;
}

위 함수를 예시로 들어보자면 ptr이라는 지역 변수에 새로운 메모리 주소를 할당하는 작업을 수행하지만 delete 연산자를 이용해 해제하는 작업은 진행하지 않는다. ptr 자체는 일반 변수와 동일한 규칙을 따르기에 원래대로라면 함수가 끝날 때 ptr은 범위에서 벗어나게 된다.

ptr은 동적으로 할당된 정수의 주소를 보유하는 유일한 정수기에 delete 연산자를 통해 반환하지 않게 되면, ptr이 삭제될 때 동적으로 할당 된 메모리를 참조하는 변수가 존재하지 않지만 메모리는 참조하기 때문에 동적으로 할당 된 메모리의 주소를 잃어버리게 된다.

그렇기에 해당 문제는 곧 동적으로 할당된 이 정수를 해제할 수 없게 되고 이것이 누적되게 된다면 할당할 메모리 주소 또한 부족하게 될 것 이다.

이것이 메모리 누수(Memory Leak)으로 이어지게 된다. 메모리 누수는 운영체제 관점에서는 프로그램이 운영 체제로 되돌리기 전에 동적으로 할당된 메모리의 일부 비트 주소를 잃어버리면 발생하게 된다. 잃어버린다라고 말하지만 쉽게 얘기하자면 주민 등록 번호는 특정 사람에게 할당은 되었지만 그 기록이 날라가 동일한 주민 등록 번호를 할당하려하면 문제가 발생하지만 누구에게 할당한지 몰라 문제가 발생하는 것이라고 생각하면 편하다. (물론 실제로는 사람별 고유한 값이 되기에... 이런 경우는 없다. 아마도...?)

메모리 누수가 발생하게 된다면 프로그램이 실행되는 동안 사용 가능한 메모리를 소모하기에 이 프로그램 뿐만 아니라 다른 프로그램에서 사용할 메모리도 줄어들게 되기에 컴퓨터 자체의 속도 감소 및 정지 이슈가 발생할 수 있기에 가장 치명적이다. (다른 프로그램도 같이 돌리고 있다면...)

그리고 메모리 누수는 단순하게 잃어버리는 문제 뿐만 아니라 여러 이유가 있지만 또 다른 대표적인 이유는 동적으로 할당된 메모리 주소를 보유한 포인터에 다른 값을 할당할 때 메모리 누수가 발생할 수 있다.

int value = 5; // 0x000001이 할당 되었다고 가정
int* ptr = new int; // 0x000002가 할당 되었다고 가정
ptr = &value;  // ptr에 0x000001을 주입했기 때문에 0x000002의 경우는 제거 명시를 안했기에 그대로 잃어버리게 된다.

위처럼 메모리 주소를 교체하면서 기존 메모리 주소가 붕뜨게 되는 경우 또한 메모리 주소를 잃어버리는 memory leak 문제가 발생할 수 있는 원인이기에 새롭게 할당한다면 할당 전 반드시 delete 명시를 먼저 해줘야한다.

int* ptr = new int; // 0x000001이 할당 되었다고 가정
ptr = new int; // 새로운 메모리 주소 0x000002가 할당 됨

또한 위 처럼 2중 할당의 경우도 다른 메모리 주소를 할당하지 않았다 뿐이지 새로운 할당이 진행되기에 기존 메모리 주소 또한 잃어버린다라고 할 수 있다. 그렇기에 꼭 delete 명시를 해줘야 한다.

참고자료

https://boycoding.tistory.com/204
https://www.learncpp.com/cpp-tutorial/dynamic-memory-allocation-with-new-and-delete/#google_vignette
https://learn.microsoft.com/ko-kr/cpp/cpp/delete-operator-cpp?view=msvc-170

profile
나 볼려고 만든 블로그 (블로그 이전: https://goldfrosch.tistory.com/)

0개의 댓글