[C언어 50강] 26강. 포인터 입문: 주소(&), 역참조(*), NULL, 포인터 타입

[C언어 50강] 26강. 포인터 입문: 주소(&), 역참조(*), NULL, 포인터 타입

C언어를 배우다 보면 어느 순간부터 "문법"이 아니라 "메모리 모델"을 이해해야 코드가 읽히기 시작합니다. 그 경계선에 있는 개념이 바로 포인터입니다. 오늘은 포인터를 무작정 외우는 대신, 주소와 값의 관계를 머릿속에 그릴 수 있도록 설명합니다. 핵심은 *&를 기호로 암기하는 것이 아니라, “컴퓨터가 값을 어디에 저장하고, 우리는 그 위치를 어떻게 다루는가”를 이해하는 것입니다.


핵심 개념

  • 포인터는 "값 자체"가 아니라 "값이 저장된 메모리 주소"를 담는 변수다.
  • &는 변수의 주소를 얻는 연산자이고, *는 그 주소가 가리키는 실제 값에 접근(역참조)하는 연산자다.
  • 포인터 타입(int*, double*, char*)은 단순 표기가 아니라 역참조 시 몇 바이트를 어떤 해석으로 읽을지 결정한다.
  • NULL은 "아무 것도 가리키지 않음"을 표현하는 약속된 값이며, 초기화와 방어 코드의 시작점이다.

개념 먼저 이해하기

포인터를 처음 배울 때 가장 흔한 실패 패턴은 “별표(*)가 붙어서 무서운 문법”으로만 받아들이는 것입니다. 그런데 포인터는 새로운 세계가 아니라, 이미 배운 변수 개념의 확장입니다. 우리가 int score = 90;라고 쓰면 컴파일러는 메모리 어딘가에 4바이트 공간을 잡고 90을 저장합니다. 포인터는 그 4바이트 공간의 위치(주소)를 값으로 들고 있는 변수입니다. 즉 값의 값이 아니라, 값의 위치를 다루는 도구입니다.

왜 굳이 위치를 다뤄야 할까요? 첫째, 함수에서 원본 데이터를 바꾸기 위해서입니다. C는 기본적으로 값 전달(call by value)이라서 함수 인자로 값을 넘기면 복사본을 다룹니다. 원본을 바꾸려면 원본이 있는 주소를 넘겨야 합니다. 둘째, 큰 데이터를 복사하지 않고 참조하기 위해서입니다. 구조체, 배열, 버퍼를 매번 복사하면 비용이 큽니다. 주소만 전달하면 빠르고 명확합니다. 셋째, 동적 메모리와 자료구조(연결 리스트, 트리)로 가면 노드끼리 연결할 때 주소 자체가 링크 역할을 합니다.

&*를 헷갈리는 이유는 둘 다 같은 문자 *를 다른 맥락에서 쓰기 때문입니다. 선언에서 int *p;*는 “p는 int를 가리키는 포인터”라는 타입 표기이고, 식(expression)에서 *p는 “p가 가리키는 실제 int 값”이라는 연산입니다. 맥락이 다릅니다. 마찬가지로 &x는 x의 주소를 얻는 연산입니다. 그래서 p = &x;는 자연스럽게 읽으면 “p에 x의 주소를 넣는다”가 됩니다.

포인터 타입이 중요한 이유도 메모리 해석 규칙 때문입니다. 메모리 자체는 바이트의 연속일 뿐인데, int*로 읽을지 char*로 읽을지에 따라 해석이 달라집니다. 예를 들어 같은 주소라도 char*는 1바이트 단위 문자처럼 보고, int*는 보통 4바이트 정수로 봅니다. 즉 포인터 타입은 단순 주석이 아니라 CPU와 컴파일러가 데이터를 읽고 연산하는 방식에 영향을 주는 계약입니다.

NULL은 포인터 안전성의 출발점입니다. 초기화하지 않은 포인터는 쓰레기 주소를 가리키며, 이를 역참조하면 즉시 크래시(세그멘테이션 폴트)로 이어질 수 있습니다. 반대로 int *p = NULL;처럼 시작하면 “아직 유효한 대상을 가리키지 않는다”는 상태를 코드로 표현할 수 있고, if (p != NULL) 검사로 방어 로직을 만들 수 있습니다. 실무에서는 초기화되지 않은 포인터보다 NULL 포인터가 훨씬 디버깅하기 쉽습니다.

마지막으로 꼭 기억할 관점은 이것입니다. 포인터는 위험해서 피해야 할 기능이 아니라, 규칙 없이 쓰면 위험한 기능입니다. 인덱스를 벗어난 배열 접근이 위험하듯 포인터도 경계와 유효성을 지키면 강력합니다. 오늘 단계에서는 “주소를 저장한다”, “역참조 전에 유효성 확인한다”, “타입을 맞춘다” 이 세 가지만 몸에 익히면 충분합니다. 이 감각이 잡히면 이후 포인터 산술, 배열-포인터 관계, 동적 메모리까지 훨씬 자연스럽게 연결됩니다.

기본 사용

예제 1) 주소 얻기와 역참조의 최소 동작

#include <stdio.h>

int main(void) {
    int value = 42;
    int *ptr = &value;

    printf("value = %d\n", value);
    printf("&value = %p\n", (void*)&value);
    printf("ptr = %p\n", (void*)ptr);
    printf("*ptr = %d\n", *ptr);

    *ptr = 100;
    printf("after *ptr=100, value = %d\n", value);

    return 0;
}

설명:

  • ptr에는 value의 주소가 들어가므로 ptr&value는 같은 위치를 가리킨다.
  • *ptr은 그 위치에 저장된 실제 정수값을 읽거나 쓸 수 있다.
  • *ptr = 100은 포인터를 통해 원본 변수 value를 수정한다. 즉 간접 접근이 실제 값 변경으로 이어진다.

예제 2) 함수에서 원본 값 바꾸기

#include <stdio.h>

void swap_int(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main(void) {
    int x = 3;
    int y = 7;

    printf("before: x=%d, y=%d\n", x, y);
    swap_int(&x, &y);
    printf("after : x=%d, y=%d\n", x, y);

    return 0;
}

설명:

  • C 함수 인자는 기본적으로 복사되어 전달된다. 그래서 원본 변경이 필요하면 주소를 넘겨야 한다.
  • swap_int(&x, &y)는 x, y의 메모리 위치를 전달하고, 함수 내부 *a, *b가 원본을 직접 수정한다.
  • 포인터를 쓰는 이유를 가장 직관적으로 보여주는 패턴이다.

예제 3) NULL 방어와 디버깅 포인트

#include <stdio.h>

int read_value_safe(const int *p, int *out) {
    if (p == NULL || out == NULL) {
        return 0;
    }
    *out = *p;
    return 1;
}

int main(void) {
    int n = 55;
    int result = 0;

    if (read_value_safe(&n, &result)) {
        printf("ok: %d\n", result);
    }

    if (!read_value_safe(NULL, &result)) {
        printf("fail: input pointer is NULL\n");
    }

    return 0;
}

설명:

  • 포인터 인자를 받는 함수는 첫 줄에서 NULL 검사를 습관화해야 한다.
  • const int *p는 p를 통해 읽기만 하겠다는 의도를 타입으로 표현한다.
  • 런타임 오류를 “크래시”가 아니라 “반환값 기반 실패 처리”로 바꾸는 것이 실무 품질의 핵심이다.

자주 하는 실수

실수 1) 초기화하지 않은 포인터 역참조

  • 원인: int *p; *p = 10;처럼 p가 어떤 주소를 가리키는지 정하지 않은 상태에서 사용.
  • 해결: 포인터는 선언 즉시 NULL 또는 유효한 주소로 초기화한다. 역참조 전에 항상 유효성 확인.

실수 2) 포인터 타입 불일치 무시

  • 원인: 경고를 무시하고 double*int 주소를 넣는 등 타입 계약을 깨뜨림.
  • 해결: 포인터 타입은 메모리 해석 규칙이다. 경고(-Wall -Wextra)를 오류처럼 다루고, 불필요한 캐스팅을 피한다.

실수 3) *의 의미를 선언/연산에서 혼동

  • 원인: int* p*p를 같은 문법으로 인식해 코드 해석이 꼬임.
  • 해결: 선언에서는 타입 표기, 식에서는 역참조 연산이라고 분리해서 읽는 훈련을 한다.

실무 패턴

  • 포인터 변수는 가능한 좁은 스코프에서 선언하고 즉시 초기화한다.
  • 포인터 인자 함수는 "입력 포인터 유효성 검사 → 본작업" 순서를 고정한다.
  • 읽기 전용 인자는 const T *를 써서 실수로 수정하는 버그를 컴파일 단계에서 차단한다.
  • 주소 출력 디버깅 시 %p(void*) 캐스팅을 사용해 이식성과 경고 억제를 동시에 챙긴다.
  • "누가 메모리를 소유하는가"를 주석/네이밍으로 명시한다. (예: borrowed pointer vs owning pointer)

오늘의 결론

한 줄 요약: 포인터는 어려운 문법이 아니라 "값의 위치를 다루는 변수"이며, 타입·NULL·유효성 검사 3가지를 지키면 가장 강력한 도구가 된다.

연습문제

  1. increment(int *x) 함수를 작성해 전달된 정수를 1 증가시키고, NULL이면 아무 작업 없이 실패 코드를 반환하세요.
  2. 정수 3개를 입력받아 포인터로 최대값을 찾는 max_ptr(const int *a, const int *b, const int *c) 함수를 작성하세요.
  3. swap_int를 확장해 같은 주소가 들어온 경우(예: swap_int(&x, &x))도 안전하게 동작하도록 방어 코드를 추가하세요.

이전 강의 정답

지난 25강(문자열 파싱) 연습문제 예시 정답:

  1. "100,200,300" 파싱 후 합계
  • strtok로 쉼표 분리 → 각 토큰을 strtol로 변환 검증 → 하나라도 실패 시 전체 실패.
  • 정상 케이스 결과: 600.
  1. "name:lee age:31 score:88" 파싱
  • sscanf(line, "name:%31s age:%d score:%d", name, &age, &score) 형태 가능.
  • 반환값이 3인지 확인하고, 아니면 형식 오류 처리.
  1. parse_menu_choice() 구현 포인트
  • fgets로 한 줄 입력 받고 strtol로 변환.
  • endptr 확인으로 찌꺼기 문자 차단.
  • 1~5 범위 검사 후 실패 이유를 호출자에게 전달.

실습 환경/재현 정보

  • 컴파일러: Apple clang 17.x 또는 GCC 13+
  • 컴파일 옵션: -std=c11 -Wall -Wextra -pedantic -O2
  • 실행 환경: macOS (arm64) / Linux (x86_64) 터미널
  • 재현 체크:
    • clang -std=c11 -Wall -Wextra -pedantic -O2 pointer_intro.c -o pointer_intro
    • NULL 전달, 정상 주소 전달, 같은 주소 전달 케이스를 각각 실행해 분기 확인