Skip to main content

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로 초기화하는 습관이 중요합니다.3

  • nullptr를 역참조하기
    아무것도 가리키지 않는 포인터에 *를 붙이면 위험합니다. 포인터가 비어 있을 수 있으면 먼저 검사해야 합니다.3

  • &와 *의 의미를 섞어 쓰기* &x는 주소를 구하는 것이고, *ptr은 주소를 따라가 값을 꺼내는 것입니다. 둘을 반대로 이해하면 코드가 갑자기 외계어처럼 보입니다.

  • 참조자를 다시 다른 변수의 별명으로 바꿀 수 있다고 생각하기
    참조자는 한 번 연결한 뒤에는 "다른 대상에 재연결하는 도구"처럼 쓰지 않습니다. ref = other;는 참조 대상을 바꾸는 것이 아니라, 보통 원래 대상에 other의 값을 대입하는 의미로 읽혀야 합니다.2

  • 지역 변수의 주소나 참조를 바깥으로 던지기
    함수 안에서 만든 지역 변수는 함수가 끝나면 사라집니다. 그 변수의 참조나 주소를 반환하면, 이미 사라진 대상을 가리키는 위험한 상태가 될 수 있습니다. 이런 것을 dangling reference, dangling pointer라고 부릅니다.6

예를 들어 아래 코드는 위험합니다.

int& wrong() {
    int x = 10;
    return x;
}

x는 함수가 끝나면 사라지는데, 그 참조를 돌려주고 있기 때문입니다. 이런 코드는 당장 멀쩡해 보여도 나중에 아주 골치 아픈 버그를 만듭니다.

머릿속 그림으로 정리해 보기

이 장의 감각을 한 번에 정리하면 이렇습니다.

  • 일반 변수는 값을 직접 가진다.

  • 참조자는 기존 변수의 다른 이름이다.

  • 포인터는 변수의 주소를 저장한다.

  • &는 주소를 구하거나 참조자를 선언할 때 보인다.

  • ** 포인터를 선언하거나, 그 포인터가 가리키는 값을 꺼낼 때 보인다.

그리고 함수 인자 전달에서는 이렇게 기억하면 좋습니다.

  • 값 전달: 복사본을 받는다.

  • 참조 전달: 원본을 바로 다룬다.

  • 포인터 전달: 주소를 받아 원본에 접근한다.

이 차이를 이해하면 C++의 많은 문법이 갑자기 하나의 그림으로 이어집니다. 배열이 왜 주소와 가까운지, 함수가 왜 원본을 바꾸기도 하는지, 동적 메모리를 왜 조심해야 하는지도 조금씩 연결되기 시작합니다.

마무리

참조자와 포인터는 단순히 문법 몇 개를 더 외우는 주제가 아닙니다. 값과 메모리의 관계를 보는 눈을 길러 주는 주제입니다. C++가 다른 언어와 다르게 느껴지는 이유도 여기에 있습니다. 값만 다루는 것이 아니라, 그 값이 어디에 있고, 어떤 이름으로 접근하며, 주소를 통해 어떻게 간접 접근할 수 있는지까지 함께 생각하게 만들기 때문입니다.

처음에는 &와 *가 조금 짓궂게 느껴질 수 있습니다. 하지만 한 번 감이 잡히면, 참조자는 "편한 별명", 포인터는 "주소를 담은 메모"처럼 보이기 시작합니다. 그 순간부터 C++는 덜 낯설어집니다.

다음 장에서는 이 감각을 바탕으로 동적 메모리와 안전한 자원 관리를 배웁니다. 즉, 주소를 아는 수준에서 한 걸음 더 나아가, 메모리를 만들고, 쓰고, 안전하게 정리하는 문제로 들어가게 됩니다. 이제 진짜 C++다운 풍경이 펼쳐질 차례입니다.

참고

1포인터 기초와 주소·역참조 설명

2참조자와 포인터의 차이 정리

3포인터 초기화와 nullptr 사용 주의점

4포인터 전달과 참조 전달 비교

5const 참조와 참조 전달 예제

6지역 변수의 참조 반환이 위험한 이유

책 목록

초보자를 위한 C++ 수업: 기초부터 현대 C++까지 한 권으로 끝내기

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기