메모리 주소를 다룬다는 개념 자체는 이해하겠는데, 코딩을 하다 보면 "그래서 이걸 굳이 왜 이렇게 복잡하게 써야 하는가?"라는 근본적인 의문이 항상 따라다녔다.
오늘 C++ 객체지향 프로그래밍 관점에서 포인터와 new 연산자를 배우며, 그 의문이 마침내 해소되었다.
1. 포인터, 도대체 왜 쓰는 걸까?
포인터를 사용하는 가장 강력한 이유는 바로 '컴파일 시간(Compile-time)'이 아닌 '실행 시간(Runtime)'에 어떠한 결정을 내리기 위해서다.
파이썬의 리스트는 데이터를 넣는 대로 무한히 늘어난다. 하지만 C++의 기본 배열은 다르다.
int arr[100]; // 재래적(절차적) 프로그래밍의 배열 선언
이렇게 코드를 짜면, 프로그램이 실행되기도 전(컴파일 단계)에 이미 100칸짜리 공간이 고정되어 버린다. 만약 사용자가 데이터를 101개 입력하면 프로그램은 터져버리고, 반대로 1개만 입력하면 99칸의 메모리는 영원히 낭비된다.
하지만 포인터와 동적 할당을 사용하면 이 굴레에서 벗어날 수 있다. 프로그램이 실제로 돌아가고 있는 도중(실행 시간)에, 사용자에게 "데이터 몇 개 필요해?"라고 물어본 뒤, "필요한 만큼만 메모리 창고(Heap)에서 공간을 뚝 떼어내어 할당받고, 그 창고의 '주소(Pointer)'만 내가 들고 있겠다"는 것이 가능해진다.
즉, 파이썬처럼 유연하고 효율적으로 메모리 크기를 늘리고 줄이기 위한 유일한 통로가 바로 포인터인 것이다. 더불어, 기가바이트(GB) 단위의 거대한 데이터를 함수로 넘길 때 데이터를 무식하게 다 복사하는 대신, "데이터는 창고에 뒀으니 주소만 적어줄게"라며 포인터(주소)만 휙 넘겨주면 프로그램의 속도가 압도적으로 빨라진다.
2. 주소 연산자(&)와 간접 참조 연산자(*)
포인터의 기본 작동 원리를 코드로 다시 짚어본다.
#include <iostream>
using namespace std;
int main() {
int a = 6;
int* b; // 포인터 변수 선언 (주소를 담는 변수)
b = &a; // 변수 a의 메모리 위치(주소)를 b에 대입
// a의 주소와 b가 가진 값이 동일하게 출력된다. (예: 0033FAC0)
cout << "a의 주소: " << &a << endl;
cout << "*b의 주소: " << b << endl;
// 포인터를 이용한 원본 데이터 조작
*b = *b + 1;
cout << "이제 a의 값은: " << a << endl; // 출력 결과: 7
return 0;
}
- & (주소 연산자): 변수 앞에 붙으면 "이 변수가 살고 있는 실제 메모리 주소"를 반환한다.
- * (간접 참조 연산자): 포인터 변수 앞에 붙으면 "이 주소로 직접 찾아가서 그 안에 든 값"을 꺼내오거나 덮어쓴다. *b에 1을 더했더니, 원본인 a의 값이 7로 바뀌는 마법이 일어난다.
3. C++의 꽃, 동적 할당 (new와 delete)
C 언어에서는 동적 할당을 위해 malloc()과 free()라는 복잡한 함수를 썼지만, C++에서는 new와 delete라는 아주 직관적인 연산자를 제공한다.
// 1. 일반적인 변수 선언 (스택 영역, 자동 해제)
int a;
int* b = &a;
// 2. 동적 할당 (힙 영역, 수동 해제 필요)
// "int 크기만큼의 공간을 새로(new) 만들고, 그 주소를 pointer에 넣어라"
int* pointer = new int;
// 사용을 마친 메모리는 반드시 환수(delete)해야 한다.
delete pointer;
new를 통해 만들어진 데이터는 이름이 없다. 오직 포인터(pointer 변수)를 통해서만 접근할 수 있는 순수한 데이터 객체다.
🚨 delete 연산자 절대 규칙: 내가 new로 빌려온 메모리는 다 쓴 후 반드시 delete로 반납해야 한다. (안 그러면 프로그램이 종료될 때까지 메모리에 남아 시스템을 갉아먹는 '메모리 누수'가 발생한다.) 또한, new로 대입하지 않은 일반 변수의 메모리는 delete로 해제할 수 없다.
4. 포인터 연산과 동적 배열
동적 할당은 배열을 만들 때 그 진가를 발휘한다. 그리고 포인터는 C++에서 덧셈 뺄셈이 가능한 특수한 변수다.
int main() {
// double형 데이터 3개를 저장할 수 있는 공간을 동적 할당
double* p3 = new double[3];
// 포인터 p3를 배열 이름처럼 그대로 취급하여 값을 넣을 수 있다.
p3[0] = 0.2;
p3[1] = 0.5;
p3[2] = 0.8;
// 포인터 증가시키기 (주소 이동)
p3 = p3 + 1;
cout << "Now p3[0] is " << p3[0] << endl; // 결과: 0.5 (기존의 p3[1] 값)
cout << "p3[1] is " << p3[1] << endl; // 결과: 0.8 (기존의 p3[2] 값)
// 배열 메모리 해제 전, 이동했던 포인터 위치를 원래대로 되돌려 놓기
p3 = p3 - 1;
// 동적 배열을 해제할 때는 반드시 delete[] 를 사용해야 한다.
delete[] p3;
return 0;
}
💡 포인터의 덧셈 연산 코드에서 p3 = p3 + 1;을 했다고 해서 메모리 주소가 단순하게 숫자 '1'만큼 증가하는 것이 아니다. 컴파일러는 이 포인터가 double형(8바이트)이라는 것을 알고 있기 때문에, "다음 double 데이터가 있는 8바이트 뒤로 점프해라!"라고 똑똑하게 처리한다. 즉, 포인터의 위치 한 칸을 뒤로 미룬 것이다.
🚨 동적 배열의 해제 규칙 new double[3]; 처럼 대괄호 []를 사용해 배열 형태로 동적 할당을 받았다면, 반납할 때도 반드시 delete[ ] p3; 처럼 대괄호를 명시해야 통째로 메모리가 안전하게 해제된다. 대괄호 없이 delete p3;만 적으면 첫 번째 원소 하나만 달랑 해제되는 대참사가 일어날 수 있다.
5. 메모리 낭비 제로: 동적 문자열 할당
문자열(char 배열)을 다룰 때 넉넉하게 char animal[20];처럼 크기를 잡아두면 안 쓰는 메모리가 버려진다. 포인터와 new를 결합하면 딱 '입력된 글자 수'만큼만 메모리를 사용하는 '동적 문자열'을 만들 수 있다.
#include <iostream>
#include <cstring>
using namespace std;
#define SIZE 20
int main() {
char animal[SIZE]; // 입력을 받을 임시 저장소
char* ps; // 동적 메모리 주소를 담을 포인터
cout << "동물 이름을 입력하십시오\n";
cin >> animal; // 예: "Panda" 입력 (5글자)
// 입력받은 실제 글자 수(5) + 널 문자('\0')를 위한 1칸을 더해 딱 6칸만 동적 할당!
ps = new char[strlen(animal) + 1];
// 임시 저장소의 문자열을 새로 만든 동적 메모리로 복사
strcpy(ps, animal);
// 출력 시 주의점: char 포인터는 문자열 전체를 출력해버린다.
// 포인터가 가진 '진짜 메모리 주소값'을 보려면 (int*)로 강제 형변환해야 한다.
cout << "입력하신 동물 이름은 " << animal << " 이고, 주소는 " << (int*)animal << endl;
cout << "복사된 동물 이름은 " << ps << " 이고, 주소는 " << (int*)ps << endl;
delete[] ps; // 다 쓴 후엔 반드시 해제
return 0;
}
여기서 주목할 점은 cout의 독특한 동작 방식이다. cout에 char* 형 포인터를 넘기면 메모리 주소가 아니라 문자열 전체를 출력해 버린다. 따라서 포인터 본연의 16진수 주소값을 눈으로 확인하려면 (int*)animal처럼 컴파일러를 속여 강제 형변환(Casting)을 해주어야 한다.
6. 포인터와 구조체의 만남: 화살표(->) 연산자
포인터는 배열뿐만 아니라 구조체(Struct)와도 환상적인 짝꿍이다. 커다란 구조체 데이터를 함수로 넘길 때 복사본을 통째로 넘기면 시스템이 느려지기 때문에, 구조체를 new로 동적 할당하고 그 포인터(주소)만 휙 넘기는 방식을 실무에서 아주 많이 사용한다.
#include <iostream>
using namespace std;
struct MyStruct {
char name[20];
int age;
};
int main() {
// 1. 구조체 자체를 동적 할당
MyStruct* temp = new MyStruct;
// 2. 포인터를 통한 구조체 멤버 접근 (화살표 연산자 사용)
cout << "이름을 입력하십시오: ";
cin >> temp->name;
// 3. 간접 참조와 점(.) 연산자를 결합한 접근 (잘 안 씀)
cout << "나이를 입력하십시오: ";
cin >> (*temp).age;
cout << "안녕하세요! " << temp->name << "씨!\n";
cout << "당신은 " << temp->age << "살 이군요!\n";
delete temp;
return 0;
}
💡 멤버에 접근하는 두 가지 방법 구조체를 일반 변수로 만들었다면 temp.age처럼 점(.)을 찍어서 접근하지만, 포인터로 만들었을 때는 점(.)을 바로 쓸 수 없다. 대신 (*temp).age처럼 값 자체를 먼저 찾아오거나, C++에서 제공하는 훨씬 깔끔하고 세련된 방식인 화살표 연산자(->)를 사용한다. 코드를 읽기 압도적으로 편하기 때문에, 포인터로 구조체나 클래스 객체를 다룰 때는 무조건 화살표 연산자(temp->name)를 쓴다고 생각하면 된다.
📝 오늘의 요약
- 포인터의 존재 이유: 컴파일 단계가 아닌 실행 중(Runtime)에 유연하게 메모리를 할당하고 통제하기 위함이다.
- new와 delete: 짝꿍을 이루는 동적 할당 연산자이며, 배열은 new [] 와 delete [] 대괄호를 꼭 맞추자.
- 동적 문자열: new char[strlen(문자열) + 1]을 통해 낭비되는 공간 없이 핏(fit)하게 문자열을 메모리에 올릴 수 있다.
- 구조체 포인터 접근: 동적으로 할당된 구조체 포인터 변수는 멤버에 접근할 때 점(.)이 아닌 화살표 연산자를 사용한다.
'C++' 카테고리의 다른 글
| 4. 복합 데이터형: 깐깐한 배열(Array) 규칙부터 구원자 'string'까지 (0) | 2026.02.27 |
|---|---|
| 3. 'const'와 형변환 'static_cast' (0) | 2026.02.26 |
| 2. 크기 제한: 기본 자료형과 climits (0) | 2026.02.26 |
| 1. 왜 C++인가? 기본 구조부터 변수와 메모리의 이해까지 (0) | 2026.02.24 |