[C++] 배열과 포인터(메모리 주소) 그리고 할당(new){stack과 heap에 대해서}


Study/C , C++ , MFC  2020. 3. 14. 02:30

안녕하세요. 명월입니다.


이 글은 C++에서 사용되는 배열과 포인터(메모리 주소) 그리고 할당(new){stack과 heap에 대해서}에 대한 글입니다.


여기서 부터 Java,C#과 c++의 차이점이 나타나는데 개인적으로 여기서부터는 c++이 조금 재미있습니다.

배열부터는 배열, 포인터(메모리 주소), 할당 (new)이 세개가 서로 연관되어 있습니다.


즉, 배열을 이해하려면 포인터를 알아야하고 포인터를 이해하려면 할당 (new)를 알아야 합니다.

초보분들이 배열까지야 쉽게 이해하는 데 포인터에서 많이 헤매다가 포기하는 분이 많습니다. 포인터가 다들 어렵다 어렵다하니깐 어렵게 느끼는 거지 원리를 할면 엄청 쉬운 것입니다.

그리고 많은 분이 포인터를 이해하기 어려워 해서 포인터의 개념이 없는 Java나 C#으로 넘어가시는 분도 있습니다.(참고로 Java나 C#도 포인터 개념을 모르면 깊이 들어갈 수가 없습니다.)

배열, 포인터, 할당은 정말 별거 아닌 개념이니 알고가면 개발하시는데 여러모로 편해집니다.


배열 선언은 데이터 타입 변수명[배열 길이]로 선언합니다.

#include <stdio.h>
#include <iostream>
using namespace std;
// 실행 함수
int main()
{
  // int 타입의 변수 a를 선언, 배열은 10개.
  int a[10];
  // 배열 길이, 변수 a의 사이즈 / int 타입 크기
  int length = sizeof(a) / sizeof(int);
  // 반복문으로 배열이 값을 넣는다.
  for (int i = 0; i < length; i++)
  {
    // 제곱의 값을 넣는다.
    a[i] = i * i;
  }
  // a[0]의 메모리 주소값
  // 변수명 앞에 「*」가 붙으면 메모리 주소값의 뜻이다.
  int *point = &a[0];
  for (int i = 0; i < length; i++)
  {
    // a[0] 메모리 주소보다 하나씩 올리면 배열의 값이 나온다.
    // 즉, a[0] 메모리 주소 + 1하면 a[1]이다.
    int *now = point + i;
    // 콘솔 출력
    wcout << " i [" << i << "] > 메모리 주소: "<< now << " 배열의 값 : " << *now << endl;
  }
  return 0;
}

다른 언어와 다르게 변수 뒤에 배열이 붙습니다. 이게 의미가 있는지는 잘 모르겠습니다.

다른 언어에서도 배열의 순서에 따라 메모리 주소가 연결되어서 할당되는 지는 모르겠으나, C++에서는 배열이 순서대로 붙습니다.

int가 4byte에 배열이 10개이니 총 40byte가 할당됩니다. 즉 sizeof(a)에서 40의 값이 나오고 size(int)로 4가 나오기 때문에 배열의 개수 10이 나오는 것입니다.

여기서 메모리 주소라는 뜻이 나왔는데 이게 포인터입니다.

포인터는 값이 아니기 때문에 변수앞에 *를 표시합니다.(이 *은 연산자 *과 헤갈릴 수 있습니다. 「변수*변수」나 「숫자*변수」를 하면 연산이 되기 떄문에 표시를 주의합니다.)

a[0]의 메모리 주소를 알기 위해서는 변수 앞에 (&)를 표시하고 그 값은 「*」이 변수 앞에 붙어있는 포인터로 들어갑니다.

C++에서는 메모리 주소를 직접 수정이 가능합니다.(Java나 C#에서 hashcode를 수정할 수 없습니다.)

그래서 차례로 메모리 주소를 증가시켜서 배열을 찾는 것입니다.


여기서 그럼 궁금점이 생기네요. a[0]의 메모리 주소값은 *point라고 하면, a[9]는 *point+9가 되겠네요.

그럼 *point+10은 무슨 값이 나올까요? int a의 할당 범위를 넘어가기 때문에, 할당되지 않는 쓰레기 값(garbage value)이 나옵니다.


위 배열에서는 10이라는 정수를 a의 변수에 넣어서 정적 할당을 했습니다. 그럼 10대신에 변수를 넣어서 동적으로 배열을 할당할 수 있을까? 불가능합니다.

배열을 동적으로 할당하기 위해서는 stack과 heap의 차이를 알아야 합니다.


인터넷을 찾아보면 stack과 heap에 대해서 stack이 할당이 빠르나 용량이 적고, heap은 할당은 느리고 용량이 많다 등등의 많은 설명이 쓰여있습니다.

중요한 내용입니다만, 설명이 애매 모호합니다.

쉽게 설명하면 제가 지금까지 설정했던 값이 전부 다 statkc할당입니다.

즉, int a, char b이런 값이 statck 할당입니다.

stack 할당은 할당량이 정해져 있고, 기본 스택 할당 크기는 1MB입니다.

heap의 경우는 new라는 키워드로 메모리를 할당하는 것입니다. (Java나 C#에서 사용하는 그 new와 같습니다.)

#include <stdio.h>
#include <iostream>
using namespace std;
// 실행 함수
int main()
{
  // 1MB를 넘게 stack에 할당하면 statck overflow가 발생합니다.
  //int a[100000000];
  // 배열 길이
  int length = 10;
  // 배열 동적 할당이 된다.
  int *a = new int[length];
  // 반복문으로 배열이 값을 넣는다.
  for (int i = 0; i < length; i++)
  {
    // a[0]는 *a이다. a[1]은 *a+1이다.
    int *now = a + i;
    // 제곱의 값을 넣는다.
    *now = i * i;
  }
  // 반복문으로 배열의 값을 출력한다.
  for (int i = 0; i < length; i++)
  {
    // a[0]는 *a이다. a[1]은 *a+1이다.
    int *now = a + i;
    // 콘솔 출력
    wcout << " i [" << i << "] > 메모리 주소: " << now << " 배열의 값 : " << *now << endl;
  }
  return 0;
}

여기서는 배열을 사용하는데 new를 사용해서 int[]를 할당했습니다.

여기서 new를 사용했기 때문에 이 배열은 heap이란 메모리 영역에 선언된 것입니다. heap의 영역을 찾아가기 위해서는 반드시 메모리 주소가 필요한데, 이 값을 statck 메모리 변수 *a에 저장한 것입니다.

이런 형태로 할당이 된 것입니다. 지금은 배열 뿐이지만, Class도 이런 형태로 할당이 됩니다.


여기까지는 포인터와 할당이 크게 어렵지 않습니다. 이제부터 머리 터지는 부분입니다만, 바로 메모리 해제입니다.

Java와 C#은 포인터가 없는 것 같지만, 사실 statck에 값을 넣을 수가 없습니다. (원시 데이터(Primitive type)은 예외)

// C#의 경우
Int32 a = 10;
// Java의 경우
Integer b = 10;
// 내부적으로는 아래와 같다.
// C#의 경우
Int32 *a = new Int32;
*a = 10;
// Java의 경우
Integer b = new Integer
*b = 10;
// 즉, 변수의 값은 무조건 stack메모리 값이고 값은 heap으로 설정되는 구조
// 참고로 원시 데이터는 C++과 같고 스택 영역을 벗어날 수 없다. 메모리 참조는 불가능하고 무조건 값참조로 넘어간다라는 조건이 생깁니다.

Java와 C#를 비교하는 이유는 이 Gabage Collection(GC)때문입니다.

statck은 statck 영역을 벗어나면 자동으로 해제가 되고 heap은 사용자가 직접 메모리를 해제해야 합니다. 즉, 이 메모리 해제를 Java와 C#은 Gabage Collection(GC)이 처리해 줍니다.

C++은 이 Gabage Collection(GC)이 없기 때문에 직접 다 해제를 해야하는 데, 이게 어느 시점해 해야 하는지 결정해야 하기 때문에 어려워 지는 것입니다.

#include <stdio.h>
#include <iostream>
using namespace std;
// 함수로부터 stack으로 선언된 배열을 받는다.
int* getStatckArray()
{
  // 배열 선언
  int a[10];
  // 반복문으로 배열이 값을 넣는다.
  for (int i = 0; i < 10; i++)
  {
    // 제곱의 값을 넣는다.
    a[i] = i * i;
  }
  return a;
}
// 함수로부터 heap으로 선언된 배열을 받는다.
int* getAllocArray()
{
  // 배열 선언
  int *a = new int[10];
  // 반복문으로 배열이 값을 넣는다.
  for (int i = 0; i < 10; i++)
  {
    int* now = a + i;
    // 제곱의 값을 넣는다.
    *now = i * i;;
  }
  return a;
}
// 실행 함수
int main()
{
  // statck으로 할당된 배열 출력
  int *a = getStatckArray();
  cout << " Statck Array - a[9] :";
  cout << *(a + 9) << endl;
  
  // heap으로 할당된 배열 출력
  a = getAllocArray();
  cout << " Alloc Array - a[9] :";
  cout << *(a + 9) << endl;

  return 0;
}

결과를 보시면 stack으로 할당된 배열은 81이 아닌 쓰레기 값이 나왔습니다. 왜냐하면 getStatckArray함수 안에서 배열을 stack 메모리에 선언하고, statck영역, 즉, getStatckArray(){}의 영역이 끝났기 때문에 배열이 자동으로 해제가 되어 버린 것입니다.

반대로 getAllocArray에서 나온 값은 해제가 되지 않았습니다. 이것을 해제해야 하는데 c++에서는 delete 키워드를 사용합니다.

#include <stdio.h>
#include <iostream>
using namespace std;
// 함수로부터 heap으로 선언된 배열을 받는다.
int* getAllocArray()
{
  // 배열 선언
  int length = 10;
  int *a = new int[length];
  // 반복문으로 배열이 값을 넣는다.
  for (int i = 0; i < length; i++)
  {
    int* now = a + i;
    // 제곱의 값을 넣는다.
    *now = i * i;;
  }
  return a;
}

int main()
{
  // heap으로 할당된 배열 출력
  int *a = getAllocArray();
  // 배열이기 때문에 delete[]로 해제합니다.
  // 일반 할당이면 delete입니다.
  delete[] a;

  return 0;
}

일반 할당이면 delete로 해결되지만 배열은 delete[]로 사용합니다.

c++의 가장 큰 난제가 이거입니다. 할당된 데이터가 어디까지 사용할 지도 모르고, 배열인지 일반 할당인지도 소스상으로는 확인할 길이 없습니다.(사양(설계)상으로 선언을 배열로 했으니 delete[], 일반 할당이니 delete로 해야합니다.)


참고로 메모리를 해제하지 않으면 어떻게 되냐? memory leak, 메모리 누수가 발생합니다.

이게 우습게 볼 수 없는게, 일반 클라이언트는 프로그램이 종료될 때, 메모리 반환이 발생하기 때문에 조금은 낫습니다.


그러나 서버의 경우는 서버를 시도때도 끄는 것이 아니라 계속 실행시켜 놓는 프로그램이기 때문에, 메모리 해제를 하지 않으면 메모리 사용이 계속 할당될 것입니다.

즉 메모리 해제를 하지 않으면 메모리는 계속 잡아먹고 메모리(ram)은 유한 자원이기 때문에 언젠가는 한계에 도달할 것입니다. 그럼... server down!!!


여기까지 배열과 포인터(메모리 주소) 그리고 할당(new){stack과 heap에 대해서}에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.