Menu

C 포인터: 주소, 역참조, 예제로 배우는 완전 가이드

포인터는 메모리 주소를 담는 변수입니다. 이 페이지는 & 연산자와 * 연산자, 포인터 선언과 역참조, 포인터 타입이 중요한 이유, 그리고 포인터가 존재하는 이유를 보여주는 swap() 함수까지 바닥부터 차근차근 쌓아 올립니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

프로그램의 모든 변수는 메모리 어딘가에 살고 있고, 그 "어딘가"에는 저마다 번호가 있습니다. 바로 주소입니다. 포인터는 그 번호 중 하나를 담아 두는 변수일 뿐입니다. 아이디어는 그게 전부입니다. 이 페이지의 나머지는 모두 문법과 그로부터 따라오는 결과입니다.

C에서 포인터가 그토록 중요한 이유는, C가 모든 함수에 인자의 복사본을 건네주기 때문입니다. 7이라는 숫자를 받은 함수는 자기 복사본을 마음대로 바꿔도 되지만, 호출자의 변수는 꿈쩍도 하지 않습니다. 대신 주소를 건네주면 함수가 원본까지 손을 뻗어 바꿀 수 있습니다. 배열, 문자열, 동적 메모리, 그리고 앞으로 C에서 만들 모든 자료구조가 이 한 가지 능력 위에 서 있습니다.

메모리, 주소, 그리고 왜 모든 것에 주소가 있는가

int score = 42;라고 쓰면 컴파일러는 메모리 몇 바이트를 예약하고 여러분이 쓸 수 있는 이름을 붙여 줍니다. 그 바이트들에는 숫자로 된 주소도 있습니다. 메모리를 아주 긴 거리에 늘어선 번호 붙은 집들로 생각해 보세요. score0x7ffd4c2a번지에 사는 가족입니다. 이름은 여러분을 위한 것이고, 번호는 기계를 위한 것입니다.

& 연산자가 바로 그 번호를 물어봅니다.

직접 실행해 보세요. 세 주소가 서로 다른 이유는 세 변수가 서로 다른 바이트를 차지하기 때문이며, 실행할 때마다 값이 달라집니다. 정상적인 현상이고, 주소를 코드에 직접 박아 넣으면 안 되는 이유이기도 합니다. (void *) 캐스팅에 주목하세요. %pvoid *를 받도록 규정되어 있어서, 다른 포인터 타입을 넘기는 것은 엄밀히 말해 미정의 동작입니다. 이 캐스팅은 습관으로 만들어 두세요.

주소를 예측할 수 없다는 점은 문제가 되지 않습니다. 주소가 무엇인지는 거의 신경 쓸 일이 없기 때문입니다. 중요한 건 그 주소를 손에 쥐고 있다는 사실입니다.

포인터 선언하기

포인터 선언은 무엇을 가리키는지 그 타입을 함께 밝힙니다.

int    *p;      // p는 int의 주소를 담을 수 있다
double *q;      // q는 double의 주소를 담을 수 있다
char   *name;   // name은 char의 주소를 담을 수 있다

int *p;를 "*pint다"라고 읽으세요. p를 역참조하면 int가 나온다는 뜻입니다. 이 읽기 방식은 나중에 C가 던져 줄 온갖 복잡한 선언에서도 그대로 통합니다.

*를 어디에 붙이는지는 컴파일러가 무시하는 스타일 선택입니다. int* p, int *p, int * p는 모두 같은 것을 선언합니다. 대부분의 C 코드는 int *p로 쓰는데, 여기에는 실용적인 이유가 있습니다.

int* a, b;   // a는 int를 가리키는 포인터, b는 그냥 int이지 포인터가 아니다
int *a, *b;  // 둘 다 포인터 - *는 타입이 아니라 이름에 붙는다

*는 타입이 아니라 선언자에 붙습니다. 이름 옆에 써 두면 그 사실이 눈에 보입니다.

&와 *: 두 개의 연산자

두 연산자가 모든 일을 하고, 둘은 정확히 반대입니다.

  • &x - 주소 연산자. x가 놓인 주소를 만들어 냅니다.
  • *p - 역참조 연산자. p에 담긴 주소로 찾아가 그곳에 사는 객체를 건네줍니다.

마지막 두 줄이 포인터의 존재 이유 전부입니다. age에 직접 대입한 것은 아무것도 없는데 age가 바뀌었습니다. *p = 31은 "p가 담은 주소에 31을 저장하라"는 뜻이고, 그 주소가 바로 age의 주소이기 때문입니다.

메모리 안의 그림은 이렇습니다.

      p                        age
 +------------+           +----------+
 | 0x7ffd1c40 | --------> |    31    |
 +------------+           +----------+
 주소 0x7ffd1c38          주소 0x7ffd1c40

 p    -> 주소 0x7ffd1c40
 *p   -> 그곳에 저장된 int, 31
 &age -> 0x7ffd1c40, p가 담고 있는 바로 그 주소
 &p   -> 0x7ffd1c38, p 자신이 사는 곳

마지막 줄을 눈여겨보세요. 포인터 역시 메모리 어딘가에 있는 변수이므로 자기 자신의 주소도 가집니다. 함정 문제가 아니라 그저 일관성이며, 포인터를 가리키는 포인터(int **)가 동작하는 방식이기도 합니다.

&*는 서로를 상쇄하므로 *&age는 그냥 age이고, &*p는 그냥 p입니다.

포인터 타입이 중요한 이유

포인터는 주소를 담고, 주소는 그저 숫자입니다. 그런데 컴파일러는 왜 그것이 int를 가리키는지 double을 가리키는지 굳이 알려고 할까요? 두 가지 이유가 있습니다.

  1. 몇 바이트를 읽을지. int *를 역참조하면 (보통) 4바이트를 읽고, double *를 역참조하면 8바이트를 읽습니다. 타입이 없으면 *p는 아무 의미가 없습니다.
  2. 그 바이트들을 어떻게 해석할지. 똑같은 비트 패턴이라도 int로 보면 어떤 숫자이고 float로 보면 완전히 다른 숫자입니다.

반면 포인터 자체의 크기는 무엇을 가리키는지에 따라 달라지지 않습니다. 주소는 그냥 주소니까요.

64비트 기계에서는 세 포인터가 모두 8바이트인 반면, 그들이 가리키는 대상은 각각 4, 8, 1바이트입니다. *p와 포인터 산술에 의미를 부여하는 것이 바로 이 가리키는 타입입니다.

서로 다른 포인터 타입 사이의 대입은 컴파일러가 불평하는 오류이고, 그럴 만한 이유가 있습니다. pi = &d;는 double의 4바이트만 읽고서 그 결과를 정수라고 부르게 만들 테니까요.

교과서적인 동기: swap()

모든 C 강의가 꺼내 드는 함수가 여기 있습니다. 포인터를 정확히 설명해 주는 방식으로 실패하기 때문입니다.

함수는 둘을 바꿨다고 보고하는데, main은 아무 일도 없었다고 보고합니다. broken_swapxy복사본을 받았습니다. 그 복사본들을 완벽하게 맞바꿨고, 함수가 반환되는 순간 복사본들은 사라졌습니다.

대신 주소를 건네줘 보세요.

이제도 ab는 여전히 복사본입니다. 다만 두 주소의 복사본이고, 주소의 복사본은 원본이 가리키던 바로 그 변수를 가리킵니다. 함수가 어디서 호출되든 *ax입니다.

이것이 호출자의 데이터를 수정하는 모든 C 함수 뒤에 있는 패턴이자, 함수 매개변수 전반의 원리입니다. C에는 참조에 의한 전달이 없으므로, 포인터를 넘기고 그것을 역참조합니다. scanf&가 필요한 이유도 같습니다.

int n;
scanf("%d", &n);   // scanf가 n에 쓸 수 있어야 하므로 n의 주소가 필요하다

값을 하나 이상 돌려주기

C 함수는 값을 하나만 반환합니다. 포인터는 그 제약을 우회하는 방법입니다. 추가 결과를 받을 주소를 넘겨주면 됩니다.

반환값은 상태를 나르고, 포인터는 결과를 나릅니다. 이 분담은 C 표준 라이브러리 자체와 운영체제 API 곳곳에 퍼져 있습니다.

포인터를 가리키는 포인터

포인터도 변수이므로 그 주소를 얻을 수 있고, 그 주소의 타입은 "int를 가리키는 포인터를 가리키는 포인터"입니다.

  pp              p               value
+------+       +------+        +------+
| &p   | ----> | &val | -----> |   8  |
+------+       +------+        +------+

**를 실전에서 만나게 되는 때는 함수가 호출자가 들고 있는 포인터 자체를 바꿔야 할 때입니다. 예를 들어 호출자의 포인터를 새로 할당한 메모리로 설정하는 할당 함수가 그렇고, 명령줄 인자char *argv[]도 그렇습니다.

저지르기 전에 알아 둘 만한 네 가지 실수

아무 데도 가리키게 하지 않은 포인터를 쓰는 것. 초기화하지 않은 포인터는 그 자리에 원래 있던 바이트를 그대로 담고 있습니다. 그것을 역참조하면 무작위 주소를 읽거나 씁니다.

int *p;      // p는 쓰레기 값을 담고 있다
*p = 10;     // 미정의 동작 - 크래시가 날 가능성이 높다

모든 포인터를 실제 주소나 NULL로 초기화하세요. 그에 관한 규율은 널 포인터를 보세요.

*pp를 혼동하는 것. p = 5;는 포인터를 주소 5로 설정합니다(말이 안 되죠). *p = 5;는 가리키는 곳에 5를 저장합니다. 컴파일러가 첫 번째에 경고를 낼 텐데, 경고는 읽어 볼 가치가 있습니다.

scanf에서 &를 빠뜨리는 것. scanf("%d", n)n을 주소로 넘깁니다. 경고와 함께 컴파일되고, 그 숫자가 가리키는 아무 메모리에나 씁니다.

지역 변수의 주소를 반환하는 것. 함수가 반환되는 순간 지역 변수는 사라집니다. 그 주소는 이미 철거된 건물의 영수증입니다.

int *bad(void) {
    int local = 42;
    return &local;   // 호출자는 댕글링 포인터를 받는다
}

호출보다 오래 살아남는 메모리가 필요하다면 직접 할당하세요. 그것이 동적 메모리가 존재하는 이유입니다.

다음으로 볼 것

포인터는 "주소 하나를 담는다"에서 멈추지 않습니다. 포인터에 덧셈을 하면 그 타입의 크기 단위로 메모리를 건너가는데, 이것이 포인터 산술의 주제입니다. 그리고 C에서 배열 이름은 첫 원소를 가리키는 포인터로 붕괴(decay)하는데, 그래서 둘을 모두 알고 나면 포인터와 배열은 사실상 같은 주제가 됩니다. 이 둘을 다음으로 읽고 나면 C의 나머지는 더 이상 마법처럼 보이지 않습니다.

자주 묻는 질문

C에서 포인터란 무엇인가요?

포인터는 값이 메모리 주소인 변수입니다. 즉 메모리 안에서 다른 객체가 놓인 위치를 담습니다. int *p = &age;라고 쓰면 page의 주소를 갖게 되고, 그다음 *p는 그 주소를 통해 age 변수를 읽거나 씁니다.

C에서 *와 &의 차이는 무엇인가요?

&x는 주소 연산자입니다. x가 놓인 주소를 만들어 냅니다. *p는 역참조 연산자입니다. p에 담긴 주소로 찾아가 그곳에 있는 객체를 건네줍니다. 둘은 서로를 상쇄하므로 *&x는 그냥 x입니다.

왜 *가 선언할 때와 포인터를 쓸 때 두 번 나오나요?

같은 기호의 서로 다른 두 용법입니다. int *p;에서 *는 타입의 일부로 "p는 int를 가리키는 포인터다"라고 말합니다. *p = 5;에서 *는 역참조 연산자로 "p가 담은 주소에 5를 저장하라"고 말합니다. 선언을 "*p가 int다"라고 읽으면 둘 다 자연스럽게 이해됩니다.

C에서 포인터가 왜 필요한가요?

C는 모든 인자를 값으로 전달하므로, 주소를 건네받지 않는 한 함수는 호출자의 변수를 절대 바꿀 수 없습니다. 또한 포인터가 있어야 배열을 효율적으로 훑고, 결과를 하나 이상 돌려주고, 연결된 자료구조를 만들고, malloc으로 실행 중에 할당한 메모리를 쓸 수 있습니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기