9. 참조자와 포인터: C++다운 메모리 감각 익히기
지금까지 우리는 변수에 값을 넣고, 연산하고, 함수에 전달하면서 프로그램을 만들어 왔습니다. 그런데 C++를 제대로 배우려면 여기서 한 걸음 더 들어가야 합니다. 값은 그냥 공중에 떠다니는 것이 아니라, 메모리 어딘가에 놓여 있다는 사실을 몸으로 느끼는 단계입니다. 그 감각을 열어 주는 대표 선수 둘이 바로 참조자(reference) 와 포인터(pointer) 입니다.
처음 보면 둘 다 비슷해 보입니다. 둘 다 "원래 값을 간접적으로 다룬다"는 점에서는 닮았기 때문입니다. 하지만 쓰임새와 문법, 안전성은 분명히 다릅니다. 이 장에서는 복잡한 이론보다 먼저, "값과 주소는 어떻게 다르고", "왜 *와 &가 자꾸 나오며", "함수에서 원본을 바꾸려면 무엇을 써야 하는지"를 자연스럽게 익혀 보겠습니다.1
값은 집이 있다: 주소라는 개념
변수는 이름표가 붙은 저장 공간입니다. 예를 들어 아래 코드에서 number는 정수값 42를 저장합니다.
#include <iostream>
using namespace std;
int main() {
int number = 42;
cout << number << 'n'; // 값
cout << &number << 'n'; // 주소
return 0;
}첫 번째 줄은 42를 출력합니다. 두 번째 줄은 number가 저장된 메모리 주소를 출력합니다. 보통은 0x7ff...처럼 16진수 모양으로 보입니다. 주소값은 실행 환경에 따라 달라질 수 있으니, 책과 똑같은 숫자가 안 나와도 전혀 이상하지 않습니다.1
여기서 중요한 건 딱 하나입니다.
값과 주소는 다릅니다.
number는 값이고, &number는 그 값이 사는 집 주소입니다. C++는 이 주소를 직접 다룰 수 있게 해 줍니다. 그래서 다른 언어보다 메모리와 훨씬 가까운 감각을 익히게 됩니다.
참조자: 변수에 붙이는 두 번째 이름
참조자는 어렵게 말하면 reference지만, 초보자 입장에서는 기존 변수의 별명이라고 이해하는 것이 가장 쉽습니다. 이미 있는 변수를 다른 이름으로 부르는 것입니다.2
#include <iostream>
using namespace std;
int main() {
int number = 42;
int& ref = number;
cout << number << 'n';
cout << ref << 'n';
ref = 100;
cout << number << 'n';
return 0;
}이 코드에서 ref는 number의 복사본이 아닙니다. number를 다른 이름으로 부르는 것에 가깝습니다. 그래서 ref = 100;을 하면 ref만 바뀌는 것이 아니라 원래 변수 number도 함께 100이 됩니다.
참조자의 특징은 꽤 분명합니다.
선언할 때 바로 무엇을 참조할지 정해야 합니다.
한 번 연결한 뒤에는 다른 변수로 갈아타는 용도로 쓰지 않습니다.
사용할 때 문법이 일반 변수처럼 보여서 읽기 편합니다.
예를 들어 아래 코드는 올바르지 않습니다.
int& ref; // 오류참조자는 "누구의 별명인지" 없이 혼자 존재할 수 없기 때문입니다.
또 하나 재미있는 점이 있습니다.
int number = 42;
int& ref = number;
cout << &number << 'n';
cout << &ref << 'n';이 둘은 같은 객체를 가리키므로 같은 주소처럼 보입니다. 즉, 참조자는 새 상자를 하나 더 만든다기보다, 기존 상자에 이름표를 하나 더 붙이는 느낌으로 받아들이면 이해가 쉽습니다.2
포인터: 주소를 저장하는 변수
포인터는 참조자와 닮았지만 더 직접적입니다. 참조자가 "별명"이라면, 포인터는 주소를 저장하는 변수입니다. 즉, 값 자체가 아니라 값의 위치를 들고 다닙니다.1
#include <iostream>
using namespace std;
int main() {
int number = 42;
int* ptr = &number;
cout << ptr << 'n'; // 주소
cout << *ptr << 'n'; // 그 주소에 있는 값
*ptr = 100;
cout << number << 'n';
return 0;
}여기서 천천히 읽어 봅시다.
int* ptr = &number;
이 문장은 "ptr은 int를 가리키는 포인터이고, 그 안에는 number의 주소를 넣는다"는 뜻입니다.
그리고
*ptr
은 "ptr이 가리키는 주소에 들어 있는 실제 값"을 뜻합니다. 이걸 역참조(dereference) 라고 부릅니다. 이름은 조금 무섭지만, 사실은 "주소를 따라가서 그 안의 값을 꺼낸다"는 뜻일 뿐입니다.1
그래서 *ptr = 100;은 포인터가 가리키는 원래 값을 바꾸는 코드입니다. 결과적으로 number도 100이 됩니다.
&와 *는 왜 이렇게 헷갈릴까
C++ 초보자가 가장 많이 헷갈리는 문자가 바로 &와 *입니다. 이유는 간단합니다. 같은 기호가 상황에 따라 다른 역할을 하기 때문입니다.
먼저 &부터 보겠습니다.
int number = 10;
int& ref = number;
cout << &number << 'n';첫 줄의 int& ref에서 &는 참조자 선언입니다.
세 번째 줄의 &number에서 &는 주소를 구하는 연산자입니다.
*도 비슷합니다.
int number = 10;
int* ptr = &number;
cout << *ptr << 'n';int* ptr에서 *는 포인터 선언이고,
*ptr에서 *는 역참조 연산자입니다.
기호는 같아도 자리가 다르면 의미가 다릅니다. 처음엔 헷갈리지만, 이렇게 읽으면 편합니다.
&변수→ 변수의 주소*포인터→ 포인터가 가리키는 값타입& 이름→ 참조자 선언타입* 이름→ 포인터 선언
한마디로 정리하면, &는 "주소 쪽", *는 "따라가서 값 보기 쪽"이라고 기억해 두면 좋습니다.
참조자와 포인터, 닮았지만 다르다
참조자와 포인터는 둘 다 원본에 간접 접근하게 해 주지만, 느낌은 꽤 다릅니다.2
| 항목 | 참조자 | 포인터 |
|---|---|---|
| 본질 | 기존 변수의 별명 | 주소를 저장하는 변수 |
| 초기화 | 선언과 동시에 해야 함 | 나중에 넣을 수도 있음 |
| 빈 상태 | 없음 | nullptr 가능 |
| 대상 변경 | 다른 대상을 가리키도록 쓰지 않음 | 다른 주소로 바꿀 수 있음 |
| 값 접근 | 그냥 변수처럼 사용 | *로 역참조 필요 |
참조자는 문법이 단순하고 읽기 편합니다. "이 값은 반드시 있어야 한다"는 뜻도 자연스럽게 전달합니다. 반면 포인터는 더 유연합니다. 아무것도 가리키지 않는 상태도 표현할 수 있고, 가리키는 대상을 바꿀 수도 있습니다. 대신 그만큼 조심할 것도 많습니다.2
실무 감각으로 말하면 이렇습니다.
참조자는 "반드시 존재하는 손님"이고, 포인터는 "올 수도 있고 안 올 수도 있는 손님"에 가깝습니다.
nullptr: 아무것도 가리키지 않는 포인터
포인터의 중요한 특징 하나는 아무것도 가리키지 않는 상태를 표현할 수 있다는 점입니다. 현대 C++에서는 이런 상태를 nullptr로 나타냅니다.3
int* ptr = nullptr;이 말은 "ptr은 지금 유효한 어떤 정수도 가리키지 않는다"는 뜻입니다.
이 상태에서 아래 코드는 매우 위험합니다.
cout << *ptr << 'n';가리키는 대상이 없는데 값을 꺼내려고 하기 때문입니다. 이런 코드는 프로그램을 비정상적으로 멈추게 하거나, 더 나쁘게는 이상하게만 돌아가게 만들 수도 있습니다. 그래서 포인터가 비어 있을 가능성이 있다면 먼저 확인하는 습관이 중요합니다.3
if (ptr != nullptr) {
cout << *ptr << 'n';
}참조자는 이런 "빈 상태"를 정상적인 용도로 표현하지 않습니다. 그래서 참조자가 더 단순하고, 포인터가 더 유연하다고 말하는 것입니다.
함수 인자 전달: 값, 참조, 포인터의 차이
7장에서 함수는 기본적으로 값 전달(pass by value) 이라고 배웠습니다. 즉, 함수는 원본이 아니라 복사본을 받습니다. 이번에는 그 차이를 더 선명하게 비교해 보겠습니다.
#include <iostream>
using namespace std;
void byValue(int x) {
x += 10;
}
void byReference(int& x) {
x += 10;
}
void byPointer(int* x) {
if (x != nullptr) {
*x += 10;
}
}
int main() {
int a = 5;
int b = 5;
int c = 5;
byValue(a);
byReference(b);
byPointer(&c);
cout << "a: " << a << 'n';
cout << "b: " << b << 'n';
cout << "c: " << c << 'n';
return 0;
}결과는 이렇게 됩니다.
a는 5 그대로입니다.b는 15가 됩니다.c는 15가 됩니다.
왜 그럴까요?
byValue(a)는 a의 복사본을 받았습니다. 함수 안에서 바뀐 것은 복사본뿐입니다.
byReference(b)는 b의 참조자를 받았습니다. 함수 안의 x가 곧 바깥의 b처럼 동작합니다.
byPointer(&c)는 c의 주소를 넘겼습니다. 함수 안에서는 그 주소를 따라가 *x로 원본을 바꿉니다.4
즉, 원본을 바꾸는 방식은 둘 다 가능하지만 문법이 다릅니다.
참조 전달은 호출할 때 평범해 보입니다.
byReference(b);포인터 전달은 주소를 넘긴다는 사실이 코드에 드러납니다.
byPointer(&c);이 차이는 꽤 중요합니다. 참조 전달은 깔끔하고 읽기 좋지만, 처음 보는 사람은 "어? 그냥 값 전달처럼 보이는데 왜 원본이 바뀌지?" 하고 놀랄 수 있습니다. 포인터 전달은 문법이 조금 더 번거롭지만, 주소를 넘긴다는 점이 눈에 더 잘 들어옵니다.4
같은 목표인데 언제 참조자를 쓰고, 언제 포인터를 쓸까
초보자 기준으로는 이렇게 정리하면 실전에서 꽤 잘 맞습니다.24
함수에 넘기는 대상이 반드시 있어야 하고, 코드도 깔끔하게 쓰고 싶다면 참조자를 우선 생각합니다.
없을 수도 있는 값을 표현해야 하거나,
nullptr자체가 의미가 있다면 포인터가 더 자연스럽습니다.가리키는 대상을 도중에 바꿔야 하는 상황이라면 포인터가 필요합니다.
예를 들어 두 정수를 바꾸는 swap 같은 함수는 참조자로 쓰면 아주 읽기 좋습니다.
void swapValue(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}반면 "대상이 없을 수도 있음"을 표현해야 하는 함수라면 포인터가 더 어울립니다.
void printNumber(const int* ptr) {
if (ptr != nullptr) {
cout << *ptr << 'n';
}
}둘 중 무엇이 절대적으로 더 우월한 것은 아닙니다. 다만 초보자 관점에서는 참조자는 더 읽기 쉽고, 포인터는 더 강력하지만 더 조심해야 한다고 기억해 두면 좋습니다.
const 참조: 읽기만 할 때 자주 쓰는 형태
참조자는 "원본을 바꾸는 도구"로만 생각하기 쉽지만, 사실 실무와 학습에서 아주 자주 보이는 형태는 const 참조입니다.5
#include <iostream>
#include <string>
using namespace std;
void printName(const string& name) {
cout << name << 'n';
}이 함수는 문자열을 복사하지 않고 받아오면서도, 함수 안에서 바꾸지 못하게 막습니다.
왜 좋을까요?
문자열이나 큰 객체를 값으로 받으면 복사가 일어날 수 있습니다. 그런데 const 참조로 받으면 불필요한 복사를 줄이면서도 "읽기 전용"이라는 뜻을 분명하게 전달할 수 있습니다.5
정리하면 이렇습니다.
int x: 복사해서 받음int& x: 원본을 직접 다룸, 수정 가능const int& x: 원본을 직접 보되, 수정은 금지
지금은 작은 예제에서는 차이가 크게 안 느껴질 수 있습니다. 하지만 나중에 문자열, 클래스, STL 컨테이너를 다룰수록 const 참조는 아주 자주 만나게 됩니다.
한 번 더 짚는 핵심: 참조 전달과 포인터 전달의 진짜 차이
함수 안에서 원본을 바꾸는 결과만 보면 참조 전달과 포인터 전달은 비슷합니다. 그래서 "그럼 그냥 아무거나 쓰면 되는 것 아닌가요?"라는 생각이 들 수 있습니다.
하지만 둘은 함수가 요구하는 약속이 다릅니다.
참조 전달은 이렇게 말합니다.
"이 함수에 넘기는 대상은 반드시 유효해야 해."
포인터 전달은 이렇게 말합니다.
"주소를 받겠어. 그런데 없을 수도 있으니 그 가능성도 생각해야 해."
이 차이는 함수 설계에서 꽤 큽니다. 참조를 보면 독자는 "이 값은 있어야 하는구나"라고 이해하고, 포인터를 보면 "비어 있을 수도 있겠네"라고 자연스럽게 생각하게 됩니다.24
초보자가 특히 많이 하는 실수
참조자와 포인터는 강력하지만, 그만큼 실수도 잘 납니다. 특히 아래 실수는 꼭 피해야 합니다.
초기화하지 않은 포인터 사용
포인터를 선언만 하고 바로 쓰면 어디를 가리키는지 알 수 없습니다. 처음부터 유효한 주소를 넣거나nullptr로 초기화하는 습관이 중요합니다.3nullptr를 역참조하기
아무것도 가리키지 않는 포인터에*를 붙이면 위험합니다. 포인터가 비어 있을 수 있으면 먼저 검사해야 합니다.3&와*의 의미를 섞어 쓰기*&x는 주소를 구하는 것이고,*ptr은 주소를 따라가 값을 꺼내는 것입니다. 둘을 반대로 이해하면 코드가 갑자기 외계어처럼 보입니다.참조자를 다시 다른 변수의 별명으로 바꿀 수 있다고 생각하기
참조자는 한 번 연결한 뒤에는 "다른 대상에 재연결하는 도구"처럼 쓰지 않습니다.ref = other;는 참조 대상을 바꾸는 것이 아니라, 보통 원래 대상에other의 값을 대입하는 의미로 읽혀야 합니다.2지역 변수의 주소나 참조를 바깥으로 던지기
함수 안에서 만든 지역 변수는 함수가 끝나면 사라집니다. 그 변수의 참조나 주소를 반환하면, 이미 사라진 대상을 가리키는 위험한 상태가 될 수 있습니다. 이런 것을 dangling reference, dangling pointer라고 부릅니다.6
예를 들어 아래 코드는 위험합니다.
int& wrong() {
int x = 10;
return x;
}x는 함수가 끝나면 사라지는데, 그 참조를 돌려주고 있기 때문입니다. 이런 코드는 당장 멀쩡해 보여도 나중에 아주 골치 아픈 버그를 만듭니다.
머릿속 그림으로 정리해 보기
이 장의 감각을 한 번에 정리하면 이렇습니다.
일반 변수는 값을 직접 가진다.
참조자는 기존 변수의 다른 이름이다.
포인터는 변수의 주소를 저장한다.
&는 주소를 구하거나 참조자를 선언할 때 보인다.** 포인터를 선언하거나, 그 포인터가 가리키는 값을 꺼낼 때 보인다.
그리고 함수 인자 전달에서는 이렇게 기억하면 좋습니다.
값 전달: 복사본을 받는다.
참조 전달: 원본을 바로 다룬다.
포인터 전달: 주소를 받아 원본에 접근한다.
이 차이를 이해하면 C++의 많은 문법이 갑자기 하나의 그림으로 이어집니다. 배열이 왜 주소와 가까운지, 함수가 왜 원본을 바꾸기도 하는지, 동적 메모리를 왜 조심해야 하는지도 조금씩 연결되기 시작합니다.
마무리
참조자와 포인터는 단순히 문법 몇 개를 더 외우는 주제가 아닙니다. 값과 메모리의 관계를 보는 눈을 길러 주는 주제입니다. C++가 다른 언어와 다르게 느껴지는 이유도 여기에 있습니다. 값만 다루는 것이 아니라, 그 값이 어디에 있고, 어떤 이름으로 접근하며, 주소를 통해 어떻게 간접 접근할 수 있는지까지 함께 생각하게 만들기 때문입니다.
처음에는 &와 *가 조금 짓궂게 느껴질 수 있습니다. 하지만 한 번 감이 잡히면, 참조자는 "편한 별명", 포인터는 "주소를 담은 메모"처럼 보이기 시작합니다. 그 순간부터 C++는 덜 낯설어집니다.
다음 장에서는 이 감각을 바탕으로 동적 메모리와 안전한 자원 관리를 배웁니다. 즉, 주소를 아는 수준에서 한 걸음 더 나아가, 메모리를 만들고, 쓰고, 안전하게 정리하는 문제로 들어가게 됩니다. 이제 진짜 C++다운 풍경이 펼쳐질 차례입니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.