본문 바로가기

포인터(Pointer)와 레퍼런스(Reference)

@iamrain2025. 9. 19. 15:29

1. 포인터 (Pointer)

포인터는 메모리 주소를 값으로 갖는 변수다. 즉, 다른 변수가 저장된 메모리 위치를 가리킨다.

내부 구조 및 구현 방식

  • 실체: 포인터는 그 자체로 변수다. 따라서 메모리 공간을 차지하며(32비트 시스템에서는 4바이트, 64비트 시스템에서는 8바이트), 고유한 메모리 주소를 가진다.
  • 저장 값: 포인터 변수에는 대상 객체의 시작 메모리 주소가 저장된다.
  • 역참조(Dereferencing): 포인터가 가리키는 대상 객체의 값에 접근하려면 역참조 연산자(*)를 명시적으로 사용해야 한다.
  • 주소 연산: 주소 연산자(&)를 사용하여 특정 변수의 메모리 주소를 얻어와 포인터에 저장할 수 있다.

특징

  1. 재할당 가능 (Re-seatable): 포인터는 한 번 초기화된 후에도 다른 변수를 가리키도록 변경할 수 있다.
  2. Null 상태 존재: 아무것도 가리키지 않는 상태를 나타내기 위해 nullptr (또는 NULL)로 초기화할 수 있다. 이는 '값이 없음' 또는 '유효하지 않은 주소'를 명시적으로 표현하는 데 사용된다.
  3. 포인터 연산: 포인터가 가리키는 타입의 크기만큼 주소를 이동하는 포인터 연산(++, --, +, -)이 가능하다. 이는 배열 순회 등에서 매우 유용하다.
  4. 다중 포인터: 포인터를 가리키는 포인터(이중 포인터, int**), 그 포인터를 또 가리키는 포인터(삼중 포인터, int***) 등 다중 간접 참조가 가능하다.

2. 레퍼런스 (Reference)

레퍼런스는 이미 존재하는 변수에 대한 또 다른 이름(별칭, Alias)이다. 레퍼런스는 그 자체가 독립적인 변수라기보다는 원본 변수와 동일한 메모리 공간을 공유하는 새로운 이름이다.

내부 구조 및 구현 방식

  • 실체: 레퍼런스는 선언 시 반드시 기존 변수로 초기화되어야 한다. 컴파일러 수준에서 레퍼런스는 종종 상수 포인터(constant pointer)처럼 구현된다. 즉, 주소값이 한 번 정해지면 절대 바뀌지 않는 포인터와 유사하게 동작한다. 하지만 사용자는 포인터와 달리 역참조 연산 없이 변수처럼 자연스럽게 사용한다.
  • 초기화: 선언과 동시에 반드시 초기화되어야 한다.
  • 자동 역참조: 레퍼런스를 사용하면 컴파일러가 자동으로 역참조를 수행해주므로, 사용자는 일반 변수를 다루는 것과 동일한 문법으로 원본 데이터에 접근할 수 있다.

특징

  1. 재할당 불가 (Non-reseatable): 레퍼런스는 한 번 특정 변수의 별칭으로 선언되면, 다른 변수를 참조하도록 변경할 수 없다. 레퍼런스에 값을 대입하면 원본 변수의 값이 변경된다.
  2. Null 상태 없음: 레퍼런스는 반드시 초기화되어야 하므로 nullptr과 같은 상태를 가질 수 없다. 이는 레퍼런스가 항상 유효한 객체를 참조함을 보장한다.
  3. 연산 제한: 포인터 연산과 같은 메모리 주소 연산이 불가능하다.
  4. 단일 참조: 레퍼런스에 대한 레퍼런스는 만들 수 없다.

3. 동작 계층 구조

포인터와 레퍼런스가 코드에서 어떻게 기계어로 변환되는지 계층별로 살펴보면 그 차이가 명확해진다.

  • 사용자 코드 (User Code)
int x = 10; int* p = &x; // 포인터 p는 x의 주소를 가짐 
int& r = x; // 레퍼런스 r은 x의 별칭이 됨 
*p = 20; // 포인터를 역참조하여 x의 값을 변경 
r = 30; // 레퍼런스를 통해 x의 값을 변경
  • 컴파일러 및 어셈블리 레벨 (Compiler & Assembly Level)
    • 포인터 p: 컴파일러는 p를 위한 메모리 공간(e.g., 8바이트)을 할당하고, 그 공간에 x의 메모리 주소를 저장. *p = 20; 구문은 다음과 같은 2단계의 기계어로 번역.
      1. p의 주소에서 'x의 주소값'을 레지스터로 읽어온다. (Load address from p)
      2. 읽어온 'x의 주소' 위치의 메모리에 값 20을 쓴다. (Store 20 at the loaded address)
    • 레퍼런스 r: 컴파일러는 rx의 별칭임을 인지. r을 위한 별도의 메모리 공간이 할당될 수도 있고, 최적화를 통해 아예 사라질 수도 있다. r = 30; 구문은 x = 30;과 완전히 동일한 기계어로 번역.
      1. x의 주소 위치의 메모리에 값 30을 쓴다. (Store 30 at the address of x)
        이처럼 레퍼런스는 컴파일 타임에 원본 변수로 대체되어, 런타임에서는 포인터와 같은 간접 참조 과정이 하나 줄어드는 효과를 가진다.
  • 메모리/하드웨어 레벨 (Memory/Hardware Level)
    • 포인터 접근은 간접 주소 지정(Indirect Addressing) 방식. CPU는 포인터 변수가 저장된 메모리에 먼저 접근하여 실제 데이터의 주소를 알아낸 다음, 그 주소로 다시 한번 접근해야 한다.
    • 레퍼런스 접근은 컴파일러에 의해 직접 주소 지정(Direct Addressing) 방식으로 변환. CPU는 처음부터 원본 변수의 주소로 바로 접근하여 데이터를 읽거나 쓴다.

4. 비교: 포인터 vs 레퍼런스

특징 포인터 (Pointer) 레퍼런스 (Reference)
정의 메모리 주소를 저장하는 변수 기존 변수의 또 다른 이름 (별칭)
초기화 선언 시 초기화하지 않아도 됨 선언 시 반드시 초기화해야 함
Null 값 nullptr를 가질 수 있음 (가리키는 대상이 없을 수 있음) Null이 될 수 없음 (항상 유효한 대상을 참조)
재할당 다른 대상을 가리키도록 변경 가능 한 번 참조한 대상을 변경할 수 없음
접근 방식 * 연산자로 명시적 역참조 필요 일반 변수처럼 접근 (자동 역참조)
메모리 자신만의 메모리 공간을 차지함 원본 변수와 메모리를 공유 (컴파일러 구현에 따라 다름)
주요 용도 동적 메모리 할당, '없음'을 표현해야 할 때, C-style API 호환 함수 인자 전달(특히 큰 객체), 범위 기반 for문, 연산자 오버로딩

언제 무엇을 사용해야 하는가?

  • 레퍼런스를 사용해야 할 때 (Use References when...)
    1. 유효한 객체가 반드시 필요한 경우: 함수 인자로 레퍼런스를 받으면, 함수 내에서 nullptr 여부를 검사할 필요가 없다. const &는 값을 복사하지 않으면서 원본을 수정하지 않음을 보장하는 가장 일반적인 '읽기 전용' 전달 방식이다.
      void printPlayer(const Player& player); // player는 절대 null이 아니며, 함수 내에서 변경되지 않음
    2. 연산자 오버로딩: 피연산자를 자연스러운 문법으로 다루기 위해 사용된다.
      Matrix operator+(const Matrix& a, const Matrix& b);
    3. 단순 별칭: 복잡한 이름의 변수를 짧은 이름으로 사용하고 싶을 때.
  • 포인터를 사용해야 할 때 (Use Pointers when...)
    1. '가리키는 대상이 없을 수 있음'을 표현할 때: 특정 객체를 가리킬 수도, 아닐 수도 있는 상황(선택적 관계)을 표현할 때 nullptr를 활용할 수 있다. 예를 들어, 트리의 루트 노드는 부모가 없으므로 parent 포인터를 nullptr로 설정할 수 있다.
      struct TreeNode { TreeNode* parent; };
    2. 가리키는 대상을 바꿔야 할 때: 컨테이너를 순회하거나, 특정 조건에 따라 다른 객체를 가리켜야 할 때 사용한다.
      Node* current = head; while(current) { current = current->next; }
    3. 동적 메모리 관리: new, delete를 통해 힙 메모리에 객체를 생성하고 관리할 때. (단, 현대 C++에서는 std::unique_ptr, std::shared_ptr 같은 스마트 포인터를 사용하는 것이 권장됩니다.)

5. 요약

포인터와 레퍼런스의 차이점을 설명하고, 각각 언제 사용하는 것이 좋은지 말해주세요.

포인터와 레퍼런스 둘 다 다른 변수를 간접적으로 가리킨다는 공통점이 있지만, 중요한 차이가 있습니다.

포인터는 '주소값을 저장하는 변수'입니다. 그래서 포인터 자체의 메모리 공간이 있고, nullptr를 저장해 아무것도 가리키지 않는 상태를 표현할 수 있으며, 나중에 다른 변수를 가리키도록 변경할 수도 있습니다. 값에 접근하려면 *로 역참조를 해야 합니다.

반면에 레퍼런스는 '이미 있는 변수에 붙인 별명'입니다. 그래서 선언할 때 반드시 원본 변수로 초기화해야 하고, nullptr이 될 수 없으며, 한번 정해진 별명은 바꿀 수 없습니다. 사용할 때는 일반 변수처럼 쓰면 컴파일러가 알아서 처리해줘서 편리합니다.

따라서, 가리키는 대상이 '없을 수도' 있거나(optional), 나중에 대상을 '바꿔야' 할 때는 포인터를 사용합니다. 예를 들어, 연결 리스트의 노드를 순회하거나, 부모가 없는 루트 노드를 표현할 때 적합합니다. 반면, 함수에 크기가 큰 객체를 넘길 때 값 복사 비용을 피하고 싶지만 nullptr 체크는 하기 싫을 때, 즉 '항상 유효한 객체'를 인자로 받을 때는 레퍼런스(특히 const&)를 사용하는 것이 일반적이고 안전한 방법입니다.

 

6. 실습 코드

#include <iostream>
#include <string>
#include <vector>

// 아이템 구조체
struct Item {
    std::string name;
    int power;
};

// 플레이어 클래스
class Player {
public:
    std::string name;
    int hp;
    Item* equippedWeapon; // 현재 장착한 무기를 가리키는 포인터

    Player(std::string _name, int _hp) 
        : name(std::move(_name)), hp(_hp), equippedWeapon(nullptr) {} // 처음에는 무기를 장착하지 않음 (nullptr)
};

// 플레이어의 상태를 출력 (수정할 필요 없으므로 const reference 사용)
void printPlayerStatus(const Player& player) {
    std::cout << "--- Player Status ---" << std::endl;
    std::cout << "Name: " << player.name << std::endl;
    std::cout << "HP: " << player.hp << std::endl;
    
    // Q3: 아래 if-else 문에서 player.hp = 0; 과 같은 코드를 추가하면 컴파일 오류가 발생하는 이유는 무엇일까요?
    // 이 함수가 const Player&를 인자로 받는 것의 이점은 무엇일까요?
    // 제출: 
    if (player.equippedWeapon) {
        std::cout << "Weapon: " << player.equippedWeapon->name << " (Power: " << player.equippedWeapon->power << ")" << std::endl;
    } else {
        std::cout << "Weapon: (None)" << std::endl;
    }
    std::cout << "---------------------" << std::endl << std::endl;
}

// 플레이어에게 무기를 장착시킴
void equipWeapon(Player* player, Item* weapon) {
    if (!player) return; // 방어 코드: 플레이어 포인터가 유효한지 확인

    player->equippedWeapon = weapon;
    std::cout << player->name << "이(가) " << (weapon ? weapon->name : "맨손") << "을(를) 장착했습니다." << std::endl;
}

// 플레이어에게 데미지를 입힘 (HP를 직접 수정해야 하므로 reference 사용)
void applyDamage(Player& player, int damage) {
    std::cout << player.name << "이(가) " << damage << "의 데미지를 입었습니다." << std::endl;
    player.hp -= damage;
    if (player.hp < 0) {
        player.hp = 0;
    }
}

int main() {
    // --- 기본 설정 ---
    // 플레이어 생성
    Player hero("용사", 100);

    // 아이템 생성 (인벤토리 역할의 vector)
    std::vector<Item> inventory = {{"낡은 검", 5}, {"강철 검", 15}, {"마법 지팡이", 12}};

    printPlayerStatus(hero);

    // --- 포인터 사용 예시 ---
    // Q1: 플레이어의 무기(equippedWeapon)를 포인터 타입으로 선언한 이유는 무엇일까요? 
    // 만약 플레이어가 무기를 장착하지 않은 상태는 어떻게 표현할 수 있을까요?
    // 제출: 
    equipWeapon(&hero, &inventory[1]); // 강철 검 장착
    printPlayerStatus(hero);

    equipWeapon(&hero, nullptr); // 무기 해제
    printPlayerStatus(hero);

    // --- 레퍼런스 사용 예시 ---
    // Q2: applyDamage 함수가 플레이어 정보를 값(Player player)으로 받지 않고 레퍼런스(Player& player)로 받는 이유는 무엇인가요?
    // 만약 값으로 받았다면, 함수 호출 후 hero.hp는 어떤 값을 가질까요?
    // 제출: 
    applyDamage(hero, 20);
    printPlayerStatus(hero);

    // --- 포인터 재할당 예시 ---
    Item* bestItem = nullptr; // 가장 강력한 아이템을 가리킬 포인터
    if (!inventory.empty()) {
        bestItem = &inventory[0]; // 우선 첫 번째 아이템으로 초기화
        for (auto& item : inventory) {
            if (item.power > bestItem->power) {
                bestItem = &item; // 더 강력한 아이템을 만나면 포인터가 가리키는 대상을 변경
            }
        }
    }

    // Q4: 위 'bestItem'을 찾는 로직을 레퍼런스를 사용하여 구현할 수 있을까요?
    // 예를 들어 Item& bestItemRef = inventory[0]; 처럼 시작한 후, 루프 안에서 bestItemRef가 더 좋은 아이템을 참조하도록 바꿀 수 있나요?
    // 그 이유는 무엇일까요?
    // 제출: 
    if (bestItem) {
        std::cout << "인벤토리에서 가장 강력한 아이템은 '" << bestItem->name << "'입니다." << std::endl;
        equipWeapon(&hero, bestItem);
        printPlayerStatus(hero);
    }

    return 0;
}

'C++' 카테고리의 다른 글

std::unordered_map  (0) 2025.09.24
std::vector vs std::list  (0) 2025.09.22
std::sort와 std::list의 sort  (0) 2025.09.18
std::vector의 push_back과 emplace_back  (1) 2025.09.17
vector의 size와 capacity  (0) 2025.09.16
iamrain
@iamrain :: Annals of Unreal

iamrain 님의 블로그 입니다.

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차