C++ 포인터 처음 배우기: &와 * 연산자, swap·배열 순회, 초보자가 하는 첫 실수
이 글의 핵심
포인터가 어렵게 느껴지는 이유는 변수의 값과 변수의 위치라는 두 가지를 동시에 생각해야 하기 때문입니다. 선언할 때의 *와 사용할 때의 *가 다른 뜻이라는 점을 먼저 정리하고, 함수에서 원본 값을 바꾸거나 큰 데이터를 복사 없이 넘길 때 포인터가 왜 필요한지, 참조와 비교해 언제 무엇을 쓰면 되는지까지 초보자 눈높이로 설명합니다.
포인터를 집 주소로 이해하기
포인터는 “집 주소”에 비유할 수 있습니다.
변수 = 집 (실제 데이터가 있는 곳)
포인터 = 집 주소 (집이 어디 있는지 알려주는 정보)
이 비유에서 중요한 점은 주소를 적은 쪽지도 어딘가에 보관되어 있다는 것입니다. 포인터 역시 변수이므로 자기만의 메모리 공간을 차지하고(64비트 환경에서 보통 8바이트), 그 공간에 “다른 변수의 주소”라는 숫자를 담습니다. 포인터를 처음 배울 때 헷갈리는 이유는 대부분 “포인터 변수 자체의 값(주소)“과 “그 주소에 있는 값”을 한 번에 생각해야 하기 때문입니다. 아래 예제에서 이 두 가지를 계속 구분해 보겠습니다.
또 하나, 쪽지에 적힌 주소가 여전히 유효한 집인지는 쪽지가 알려 주지 않습니다. 집이 철거되어도 쪽지의 주소는 그대로 남아 있습니다. 포인터 관련 버그의 대부분은 이 한 문장으로 설명됩니다.
기본 개념
변수와 주소
int age = 25; // 변수: 값을 저장
int* ptr = &age; // 포인터: 주소를 저장
// &age: age 변수의 주소를 가져옴
// ptr: age의 주소를 저장하는 포인터
메모리 안의 모습을 그려 보면 다음과 같습니다.
메모리 주소 변수명 값
0x1000 age 25
0x2000 ptr 0x1000 (age의 주소)
그림의 주소는 설명용 숫자이고, 64비트 리눅스에서 지역 변수의 주소를 출력해 보면 0x7ffd5e3c8a4c처럼 훨씬 큰 값이 나옵니다. 실행할 때마다 주소가 바뀌는 것도 정상입니다. 운영체제가 보안을 위해 스택과 힙의 시작 위치를 무작위로 배치(ASLR)하기 때문입니다. 그래서 “주소 값 자체”를 파일에 저장했다가 다음 실행에서 다시 쓰는 것은 의미가 없습니다.
int* ptr의 int는 “이 주소에 int가 있다”는 정보입니다. 주소는 숫자일 뿐이라서, 그 위치에 무엇이 있는지 알아야 몇 바이트를 어떻게 읽을지 정할 수 있습니다. 그래서 double 변수의 주소를 int*에 담으려고 하면 cannot convert 'double*' to 'int*' 컴파일 에러가 납니다.
포인터 선언
int* ptr; // int를 가리키는 포인터
double* ptr2; // double을 가리키는 포인터
char* ptr3; // char를 가리키는 포인터
// * 위치는 상관없음 (스타일 차이)
int *ptr; // 같은 의미
int * ptr; // 같은 의미
*의 위치가 문법적으로는 상관없지만, 한 줄에 여러 변수를 선언할 때는 결과가 달라집니다. int* a, b;는 a와 b가 모두 포인터처럼 보이지만, 실제로 *는 바로 뒤의 변수 하나에만 붙기 때문에 a는 int*, b는 그냥 int입니다. 이 때문에 C 계열에서는 int *a, *b;처럼 *를 변수 이름에 붙여 쓰는 스타일도 많습니다. 가장 안전한 방법은 포인터는 한 줄에 하나씩 선언하는 것입니다.
그리고 *가 선언문에서는 “이 변수는 포인터다”라는 뜻이고, 식에서는 “이 주소가 가리키는 값”(역참조)이라는 뜻이라는 점을 꼭 구분해야 합니다. 같은 기호가 두 가지 역할을 하는 것이 포인터를 처음 배울 때 가장 큰 혼란의 원인입니다. &도 마찬가지로 식에서는 주소 연산자, 선언문에서는 참조(int& ref)를 뜻합니다.
주소 연산자 (&)
int x = 10;
int* ptr = &x; // x의 주소를 ptr에 저장
std::cout << x; // 10 (값)
std::cout << &x; // 0x7fff... (주소)
std::cout << ptr; // 0x7fff... (주소, &x와 같음)
역참조 연산자 (*)
int x = 10;
int* ptr = &x;
std::cout << *ptr; // 10 (ptr이 가리키는 값)
*ptr = 20; // ptr이 가리키는 곳의 값을 20으로 변경
std::cout << x; // 20 (x가 변경됨!)
이 예제가 포인터의 핵심입니다. *ptr = 20은 ptr을 바꾸는 것이 아니라 ptr에 적힌 주소로 찾아가서 그곳의 값을 바꿉니다. 그곳이 바로 x이므로 x를 직접 건드리지 않았는데도 x가 20이 됩니다. 같은 메모리를 가리키는 이름이 두 개(x와 *ptr) 생긴 셈입니다. 이 성질 덕분에 함수가 호출한 쪽의 변수를 바꿀 수 있고, 반대로 이 성질 때문에 “누가 이 값을 바꿨는지” 추적하기 어려워지기도 합니다.
실전 예제
예제 1: 값 교환 (Swap)
#include <iostream>
// ❌ 잘못된 방법 (값 복사)
void swap_wrong(int a, int b) {
int temp = a;
a = b;
b = temp;
// 함수 끝나면 a, b는 원래대로!
}
// ✅ 올바른 방법 (포인터 사용)
void swap(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}
int main() {
int x = 10, y = 20;
swap_wrong(x, y);
std::cout << x << ", " << y; // 10, 20 (변경 안 됨)
swap(&x, &y);
std::cout << x << ", " << y; // 20, 10 (변경됨!)
}
swap_wrong이 실패하는 이유는 C++의 함수 인자가 기본적으로 값으로 복사되어 전달되기 때문입니다. a와 b는 x, y의 복사본이라서 함수 안에서 아무리 바꿔도 원본과는 관계가 없고, 함수가 끝나면 복사본은 사라집니다. swap은 값 대신 주소를 복사해서 넘깁니다. 주소가 복사되어도 가리키는 곳은 같으므로, *a와 *b로 원본을 직접 바꿀 수 있습니다.
실무 C++에서는 같은 일을 참조로 void swap(int& a, int& b)처럼 쓰고, 호출도 swap(x, y)로 더 깔끔합니다. 표준 라이브러리의 std::swap도 참조 방식입니다. 그런데 포인터 버전을 먼저 이해해 두면 참조가 “자동으로 역참조되는 포인터”처럼 동작한다는 점이 자연스럽게 이해됩니다. 참고로 함수 이름을 swap으로 짓고 using namespace std;를 쓰면 std::swap과 이름이 겹쳐 의도와 다른 함수가 호출되거나 모호성 에러가 날 수 있으니, 연습용 함수는 my_swap처럼 이름을 다르게 두는 편이 안전합니다.
예제 2: 배열과 포인터
int arr[5] = {1, 2, 3, 4, 5};
int* ptr = arr; // 배열 이름은 첫 번째 요소의 주소
std::cout << *ptr; // 1 (첫 번째 요소)
std::cout << *(ptr+1); // 2 (두 번째 요소)
std::cout << ptr[2]; // 3 (세 번째 요소)
정확히 말하면 배열 이름이 곧 주소인 것은 아니고, 배열이 대부분의 식에서 첫 번째 요소의 주소로 자동 변환(decay)됩니다. 차이가 드러나는 대표적인 곳이 sizeof입니다. sizeof(arr)는 배열 전체 크기인 20(4바이트 × 5)이지만, sizeof(ptr)는 포인터 크기인 8입니다. 배열을 함수 매개변수로 넘기면 포인터로 바뀌기 때문에, 함수 안에서 sizeof(arr) / sizeof(arr[0])로 개수를 구하려 하면 엉뚱한 값(8 / 4 = 2)이 나옵니다. 초보자가 가장 많이 겪는 배열 버그 중 하나이며, 그래서 C++에서는 std::array나 std::vector, 또는 C++20의 std::span을 쓰는 것이 권장됩니다.
ptr[2]가 *(ptr + 2)와 같다는 것도 알아 두면 좋습니다. 대괄호 연산은 사실 포인터 산술의 줄임말입니다. 그리고 포인터는 배열이 몇 칸인지 모르기 때문에 ptr[10]처럼 범위를 벗어나도 컴파일러가 막아 주지 않습니다. 이 경우는 미정의 동작으로, 옆 변수의 값을 읽거나 덮어쓰는 조용한 버그가 되기 쉽습니다.
자주 하는 실수
실수 1: 초기화하지 않은 포인터
// ❌ 위험한 코드
int* ptr;
*ptr = 10; // 어디를 가리키는지 모름! (미정의 동작: 크래시할 수도, 조용히 다른 메모리를 덮어쓸 수도)
// ✅ 올바른 코드
int* ptr = nullptr; // 아무것도 가리키지 않음
// 또는
int x;
int* ptr = &x; // x를 가리킴
함수 안의 지역 변수는 초기화하지 않으면 그 메모리에 원래 남아 있던 값을 그대로 갖습니다. 포인터라면 그 값이 임의의 주소가 되는데, 운이 좋으면 접근할 수 없는 주소라 즉시 Segmentation fault로 죽고, 운이 나쁘면 프로그램의 다른 변수를 가리켜서 조용히 데이터를 망가뜨립니다. 두 번째 경우가 훨씬 위험합니다. 크래시는 원인 근처에서 나지만, 데이터 오염은 한참 뒤 전혀 다른 곳에서 이상한 값으로 드러나기 때문입니다.
nullptr로 초기화하는 이유는 “잘못 쓰면 확실하게 죽게” 만들기 위해서입니다. 널 포인터를 역참조하면 대부분의 환경에서 즉시 크래시가 나므로 디버거로 원인을 바로 찾을 수 있습니다. 컴파일러 경고도 켜 두세요. GCC/Clang의 -Wall -Wextra는 초기화하지 않은 변수 사용을 warning: 'ptr' is used uninitialized 같은 경고로 알려 줍니다.
실수 2: 포인터와 값 혼동
int x = 10;
int* ptr = &x;
// ❌ 잘못된 코드
ptr = 20; // 컴파일 에러! (주소에 값을 대입)
// ✅ 올바른 코드
*ptr = 20; // ptr이 가리키는 값을 20으로 변경
ptr = 20;은 GCC 기준 error: invalid conversion from 'int' to 'int*' 에러가 납니다. 정수를 주소로 바로 쓸 수 없게 막아 주는 것이니 오히려 고마운 에러입니다. 반대 방향 실수는 에러 없이 통과해서 더 까다롭습니다. 예를 들어 두 포인터를 비교하면서 if (p1 == p2)라고 쓰면 가리키는 값이 같은지가 아니라 같은 주소를 가리키는지를 비교합니다. 값을 비교하려면 if (*p1 == *p2)여야 합니다. C 스타일 문자열(const char*)을 ==로 비교했는데 내용이 같아도 false가 나오는 것도 같은 이유입니다.
실수 3: 댕글링 포인터
// ❌ 위험한 코드
int* ptr;
{
int x = 10;
ptr = &x;
} // x가 사라짐
*ptr = 20; // 사라진 변수 접근! (미정의 동작)
// ✅ 올바른 코드
int* ptr = new int(10); // 동적 할당
*ptr = 20;
delete ptr; // 사용 후 해제
댕글링(dangling) 포인터는 “이미 사라진 집의 주소가 적힌 쪽지”입니다. 이 코드가 특히 까다로운 이유는 대개 크래시가 나지 않는다는 점입니다. x가 있던 스택 메모리는 해제되어도 운영체제에 반환되는 것이 아니라 다음 함수 호출이 재사용할 뿐이라서, *ptr = 20은 대부분 아무 문제 없이 실행되는 것처럼 보입니다. 그러다 나중에 다른 함수가 그 자리를 쓰면서 값이 바뀌거나, 반대로 이 대입이 다른 함수의 지역 변수를 덮어씁니다.
“올바른 코드”의 핵심은 new 자체가 아니라 값이 필요한 동안 살아 있는 곳에 두는 것입니다. 이 예제라면 x를 블록 밖에 선언하는 것이 가장 간단한 해결책입니다. new로 할당하면 수명이 스코프와 무관해지지만, 대신 delete를 직접 책임져야 한다는 새 문제가 생깁니다. 댕글링 포인터가 실무에서 가장 흔하게 나오는 형태는 함수가 지역 변수의 주소를 반환하는 경우(int* f() { int x = 1; return &x; })입니다. 컴파일러가 warning: address of local variable 'x' returned로 경고해 주므로 경고를 절대 무시하지 않는 것이 좋습니다.
초보자가 오래 헤매기 쉬운 변형도 있습니다. std::vector에 원소를 넣고 그 원소의 주소를 포인터로 저장해 둔 뒤 벡터에 원소를 더 추가하면, 저장한 포인터가 엉뚱한 값을 가리키기 시작할 수 있습니다. 벡터는 공간이 부족해지면 더 큰 메모리로 원소를 통째로 옮기고 옛 공간을 해제하므로, 옛 주소가 무효가 되기 때문입니다. 지역 변수가 사라지는 경우만 조심하면 된다고 생각하기 쉽지만, 컨테이너 안의 원소도 언제든 “철거”될 수 있다는 점을 기억해야 합니다.
포인터 vs 참조자
// 포인터
int x = 10;
int* ptr = &x;
*ptr = 20; // 역참조 필요
// 참조자 (더 쉬움)
int& ref = x;
ref = 20; // 역참조 불필요
함수에서 원본을 바꾸는 정도의 용도라면 참조가 더 간단하므로, 실제 코드를 쓸 때는 참조를 먼저 고려하는 것이 좋습니다.
참조는 “이미 있는 변수의 또 다른 이름”입니다. 포인터와 비교하면 세 가지가 다릅니다. 참조는 선언할 때 반드시 초기화해야 하고(int& ref;는 컴파일 에러), 한 번 정해지면 다른 변수를 가리키도록 바꿀 수 없으며, 정상적인 코드에서는 널이 될 수 없습니다. 이 제약 덕분에 참조를 받는 함수는 “값이 없을 수도 있다”를 걱정하지 않아도 됩니다.
반대로 포인터가 필요한 경우는 이 제약이 방해가 될 때입니다. “없음”을 표현해야 할 때(nullptr), 가리키는 대상을 도중에 바꿔야 할 때(연결 리스트의 next), 배열을 순회하며 주소를 옮겨 가야 할 때입니다. 함수 매개변수라면 “반드시 있는 값”은 참조, “없을 수도 있는 값”은 포인터로 받는다는 규칙이 실무에서도 널리 쓰입니다.
동적 메모리 할당
new와 delete
// 단일 변수 할당
int* ptr = new int; // 메모리 할당
*ptr = 10; // 값 저장
delete ptr; // 메모리 해제
// 초기값과 함께 할당
int* ptr2 = new int(25);
delete ptr2;
// 배열 할당
int* arr = new int[100]; // 100개 정수 배열
arr[0] = 1;
delete[] arr; // 배열 해제 ([] 필수!)
지역 변수는 스택에 만들어져 함수가 끝나면 자동으로 사라지지만, new로 만든 값은 힙에 만들어져 delete를 호출할 때까지 남아 있습니다. 함수가 끝난 뒤에도 값이 살아 있어야 하거나, 실행 중에야 크기를 알 수 있을 때 힙을 씁니다.
new와 delete, new[]와 delete[]는 반드시 짝을 맞춰야 합니다. new int[100]으로 만든 배열을 delete arr로 지우면 미정의 동작입니다. int처럼 단순한 타입에서는 겉보기에 문제가 없을 수 있지만, 소멸자가 있는 클래스 배열이라면 첫 번째 원소의 소멸자만 호출되거나 힙이 손상되어 나중에 free(): invalid pointer 같은 에러로 죽습니다. new int;처럼 괄호 없이 할당하면 값이 초기화되지 않는다는 점도 주의해야 합니다. new int()나 new int{}는 0으로 초기화됩니다.
메모리 누수 주의
// ❌ 메모리 누수
void leak() {
int* ptr = new int(10);
// delete 안 함!
} // ptr 사라지지만 메모리는 남음
// ✅ 올바른 코드
void no_leak() {
int* ptr = new int(10);
// ... 사용 ...
delete ptr; // 반드시 해제
}
no_leak도 사실 완벽하지 않습니다. // ... 사용 ... 부분에서 return으로 일찍 빠져나가거나 예외가 발생하면 delete까지 도달하지 못해 똑같이 누수가 생깁니다. 함수가 길어지고 분기가 많아질수록 모든 경로에 delete를 챙기기는 사실상 불가능합니다. 그래서 모던 C++에서는 new/delete를 직접 쓰지 않고 std::unique_ptr나 std::vector처럼 스코프를 벗어나면 자동으로 해제하는 객체에 맡깁니다. 이 원칙을 RAII라고 부릅니다.
#include <memory>
void no_leak_raii() {
auto ptr = std::make_unique<int>(10);
// ... 사용 ... (중간에 return하거나 예외가 나도)
} // 여기서 자동으로 delete됨
누수는 짧게 실행되고 끝나는 프로그램에서는 운영체제가 종료 시 메모리를 회수하므로 티가 나지 않습니다. 문제가 되는 것은 서버나 게임처럼 오래 도는 프로그램인데, 반복문 안에서 조금씩 새는 메모리가 쌓이다 결국 메모리 부족으로 종료됩니다. 리눅스에서 GCC·Clang으로 컴파일할 때 -fsanitize=address를 붙이면 프로그램 종료 시 LeakSanitizer가 누수된 할당 위치를 알려 줍니다.
포인터 산술 연산
그림: ptr + 1은 주소에 1을 더하는 것이 아니라 가리키는 타입 한 칸만큼 이동한다. 이 맥의 clang++로 확인하면 int는 4바이트, double는 8바이트, char는 1바이트씩 움직이고, 포인터 변수 자체의 크기는 8바이트다.*
int arr[5] = {10, 20, 30, 40, 50};
int* ptr = arr;
std::cout << *ptr; // 10
std::cout << *(ptr+1); // 20
std::cout << *(ptr+2); // 30
// 포인터 증가
ptr++;
std::cout << *ptr; // 20
// 배열처럼 사용
ptr[0]; // 20
ptr[1]; // 30
ptr + 1은 주소에 1을 더하는 것이 아니라 가리키는 타입 한 칸만큼 이동합니다. int가 4바이트라면 주소는 실제로 4만큼 커집니다. 포인터에 타입이 붙어 있는 또 하나의 이유가 이것입니다. double*이라면 8바이트씩, char*라면 1바이트씩 움직입니다.
포인터 산술은 같은 배열 안에서만 의미가 있습니다. 배열의 끝 바로 다음 위치(arr + 5)까지는 가리켜도 되지만 역참조하면 안 되고, 그보다 더 나가거나 배열 앞으로 벗어나면 가리키는 것만으로도 미정의 동작입니다. 위 코드에서 ptr++ 이후 ptr[0]이 20인 것처럼, 포인터를 옮기면 인덱스의 기준도 함께 옮겨진다는 점도 헷갈리기 쉬운 부분입니다. 실무에서는 포인터를 직접 증가시키기보다 인덱스나 범위 기반 for를 쓰는 편이 실수가 적습니다.
포인터를 쓰는 이유
함수에서 값 변경
#include <iostream>
void increment(int* num) {
(*num)++;
}
int main() {
int x = 10;
increment(&x);
std::cout << x; // 11 (변경됨)
}
대용량 데이터 전달
// ❌ 비효율적 (복사)
void process(std::vector<int> data) { // 전체 복사!
// ...
}
// ✅ 효율적 (포인터 또는 참조)
void process(std::vector<int>* data) { // 주소만 전달
// ...
}
// ✅ 더 좋은 방법 (참조)
void process(const std::vector<int>& data) { // 참조 (권장)
// ...
}
원소 100만 개짜리 벡터를 값으로 넘기면 호출할 때마다 약 4MB를 새로 할당하고 복사합니다. 포인터나 참조는 주소 하나(8바이트)만 넘기므로 크기와 관계없이 비용이 같습니다. 참조 버전을 권장하는 이유는 const를 붙여 “읽기만 하겠다”는 약속을 타입에 표시할 수 있고, 널 체크가 필요 없으며, 호출할 때 process(v)처럼 &를 붙이지 않아도 되기 때문입니다. 포인터 버전은 nullptr이 들어올 수 있으므로 함수 안에서 매번 확인해야 합니다. 참고로 int, double 같은 작은 기본 타입은 값으로 넘기는 것이 오히려 빠르거나 같으므로 참조로 바꿀 필요가 없습니다.
동적 크기 배열
int n;
std::cin >> n;
// 컴파일 타임에 크기를 모름
int* arr = new int[n]; // 런타임에 크기 결정
// ... 사용 ...
delete[] arr;
C++ 표준에서 int arr[n];처럼 실행 중에 정해지는 크기로 배열을 선언하는 것(VLA)은 허용되지 않습니다. GCC는 확장 기능으로 컴파일해 주지만 MSVC에서는 error C2131: expression did not evaluate to a constant가 나고, 스택에 큰 배열을 만들다 스택 오버플로를 일으키기도 쉽습니다. 이 예제가 new int[n]을 쓰는 이유입니다. 다만 실제 코드에서는 std::vector<int> arr(n); 한 줄이 같은 일을 하면서 delete[]도, 크기 추적도 알아서 해 주므로 이것을 쓰는 것이 정답입니다.
포인터를 가리키는 포인터
포인터도 변수이므로 그 주소를 다시 포인터에 담을 수 있습니다. int**는 “int*가 있는 곳의 주소”입니다.
int x = 10;
int* ptr = &x; // x의 주소
int** ptr2 = &ptr; // ptr의 주소
std::cout << **ptr2; // 10 (ptr2 → ptr → x)
C에서는 함수가 호출한 쪽의 포인터 자체를 바꿔야 할 때(예: 할당한 메모리 주소를 돌려줄 때) 이중 포인터를 매개변수로 받습니다. C++에서는 같은 일을 아래 연결 리스트 예제의 Node*& head처럼 포인터에 대한 참조로 하는 경우가 많습니다. 반면 OpenGL, SDL 같은 C 라이브러리나 운영체제 API를 쓸 때는 지금도 원시 포인터와 이중 포인터를 직접 다루게 됩니다.
심화: const 포인터 vs 포인터를 통한 const
int x = 1;
const int* p1 = &x; // 가리키는 int가 const (p1로 값 변경 불가)
int* const p2 = &x; // 포인터 자체가 const (다른 주소로 못 바꿈)
const int* const p3 = &x; // 둘 다 const
선언에서 const가 *의 앞에 있으면 “가리키는 값”이, 뒤에 있으면 “포인터 자체”가 상수라고 읽으면 됩니다. const int*와 int const*는 같은 뜻입니다. 함수가 데이터를 읽기만 한다면 매개변수를 const int*로 받아 그 약속을 타입에 드러내는 것이 좋습니다.
심화: void*·정렬·uintptr_t
C API와 맞닿을 때 void*가 나옵니다. C++에서는 역참조 전에 원래 타입으로 static_cast하며, 주소를 정수로 다뤄야 할 때는 <cstdint>의 std::uintptr_t를 씁니다.
#include <cstddef> // std::byte (C++17)
#include <new> // placement new
alignas(int) std::byte buffer[64]; // int 정렬을 보장해야 함
auto* p = new (buffer) int(42); // placement new: 이미 있는 메모리에 객체 생성
// 잘못된 예: 임의 주소를 int*로만 캐스팅하면 정렬·alias 규칙 위반 가능
std::byte 배열은 정렬이 1바이트라서 alignas(int) 없이 int를 생성하면 정렬되지 않은 주소에 객체가 놓일 수 있습니다. x86에서는 대개 느려질 뿐이지만, 일부 ARM 환경에서는 정렬 오류로 프로그램이 종료됩니다. void*를 받는 C API 콜백(qsort의 비교 함수, 스레드 시작 함수의 void* arg)에서도 같은 원칙이 적용됩니다. 넘길 때의 원래 타입으로 정확히 되돌려서 써야 하며, 다른 타입으로 해석하면 컴파일러가 막아 주지 않습니다.
성능 면에서 역참조의 비용은 가리키는 데이터가 어디 있느냐에 달려 있습니다. L1 캐시에 있으면 몇 사이클이면 끝나지만, 캐시에 없어 메인 메모리까지 가야 하면 수백 사이클이 걸립니다. 그래서 “포인터가 느리다”기보다, 연결 리스트처럼 포인터를 따라 메모리 여기저기를 건너뛰는 접근 패턴이 느린 것입니다.
심화: 디버깅 도구 (GDB·Sanitizer)
| 상황 | 도구 |
|---|---|
| 범위 밖 접근, 해제 후 사용, 이중 해제 | AddressSanitizer (-fsanitize=address) |
| 초기화하지 않은 메모리 읽기 | MemorySanitizer(Clang, -fsanitize=memory), Valgrind |
| 누수 | LeakSanitizer(리눅스에서 ASan에 포함), Valgrind |
| 값이 언제 바뀌는지 추적 | GDB watch *ptr, 크래시 시점의 호출 스택은 bt |
// GDB: 포인터가 유효한 객체를 가리키는지 의심될 때
// print ptr
// x/4wx ptr (메모리 덤프. 접근할 수 없는 주소면 "Cannot access memory at address" 출력)
심화: 흔한 실수 패턴 (추가)
| 패턴 | 왜 위험한가 | 대안 |
|---|---|---|
이중 delete | 미정의 동작 | delete 후 nullptr 대입 또는 스마트 포인터 |
vector 재할당 후 옛 반복자 | 무효화 | 인덱스·reserve로 범위 고정 |
reinterpret_cast 남용 | strict aliasing 위반 | std::bit_cast(C++20) 또는 설계 변경 |
| C 스타일 가변 인자 + 포인터 | 타입 불일치 | 모던 API로 교체 |
실행해서 확인한 결과
이 글의 설명을 2026-11-01에 Ubuntu 24.04 aarch64 컨테이너(g++ 13.3, clang++ 18.1.3)에서 작은 프로그램으로 확인했습니다. 앞의 두 예제(swap, increment)에는 #include <iostream>이 빠져 있어 단독으로는 컴파일되지 않았고 헤더를 보강했습니다.
- 크기와 산술:
int arr[5]의sizeof(arr)는 20,int* ptr = arr의sizeof(ptr)는 8이었습니다.ptr + 1은int*에서 4바이트,double*로 바꿔 계산하면 8바이트,char*로는 1바이트 이동했습니다.ptr[2],*(ptr + 2),2[ptr]는 모두 30이었습니다. - 타입 불일치:
double d; int* p = &d;는 g++이cannot convert 'double*' to 'int*' in initialization, clang++이cannot initialize a variable of type 'int *' with an rvalue of type 'double *'로 거부했습니다. const포인터:const int* p1으로 값을 바꾸면assignment of read-only location '* p1',int* const p2에 다른 주소를 넣으면assignment of read-only variable 'p2', 둘 다const인p3도read-only variable오류였습니다.- 초기화하지 않은 포인터:
int* q;를 읽으면g++ -Wall -Wextra -O1은'q' is used uninitialized [-Wuninitialized], clang++은variable 'q' is uninitialized when used here와 조건에 따라 초기화되지 않는 경우를 알리는-Wsometimes-uninitialized경고를 냈습니다. 글의'ptr' is used uninitialized문구는 g++ 기준입니다. - 널 포인터 역참조:
int* p = nullptr; return *p;는Segmentation fault로 종료했고 종료 코드는 139였습니다. - 이중
delete: AddressSanitizer가attempting double-free로 잡았습니다. - 범위 밖 접근:
new int[5]의a[10]을 읽으면 AddressSanitizer가heap-buffer-overflow와READ of size 4를 보고했지만, 일반 빌드에서는 오류 없이 값(쓰레기)이 읽혔고 프로그램은 종료 코드 0으로 끝났습니다. 조용히 틀린 값이 나오는 미정의 동작이라는 설명이 그대로 확인됩니다.
심화: 실전 미니 예제 — 연결 리스트 노드 (교육용)
struct Node {
int value{};
Node* next{nullptr};
};
void push_front(Node*& head, int v) {
auto* n = new Node{v, head};
head = n;
}
void free_list(Node* head) {
while (head) {
Node* next = head->next;
delete head;
head = next;
}
}
push_front는 head 자체를 바꿔야 하므로 포인터를 참조(Node*&)로 받습니다. 그냥 Node*로 받으면 함수 안의 복사본만 바뀌어, 앞의 swap_wrong과 같은 실수가 됩니다. 실무에서는 next를 std::unique_ptr<Node>로 바꿔 소유권을 타입으로 드러내는 것이 기본입니다. 위 예제는 포인터가 소유권까지 의미할 때 free_list 같은 정리 규칙을 사람이 직접 지켜야 한다는 점을 보여 주기 위한 것입니다. 다만 unique_ptr로 만든 아주 긴 리스트는 소멸자가 재귀적으로 이어져 스택이 넘칠 수 있으므로, 이 경우에도 소멸자에서 반복문으로 노드를 하나씩 떼어 내는 처리가 필요합니다.