포인터는 C를 배우다 처음으로 벽처럼 느껴지는 주제입니다. 그런데 원리 자체는 단순합니다. 우리 집에 주소가 있어서 택배를 받을 수 있듯, 컴퓨터 안의 모든 변수도 저장된 자리에 붙은 주소를 하나씩 가집니다. 포인터는 바로 그 주소를 값으로 담는 변수입니다. 값을 직접 들고 다니는 대신, 그 값이 어디에 있는지를 가리키는 것이지요.
이 글에서는 주소를 뽑아내는 & 연산자와, 그 주소가 가리키는 값을 꺼내는 * 연산자를 작은 예제로 하나씩 따라가 봅니다. 눈으로만 읽지 말고 직접 쳐서 컴파일하고, 출력이 정말 그렇게 나오는지 확인하며 보면 훨씬 빨리 손에 익습니다.
17.1 변수는 메모리 어딘가에 산다
변수를 하나 만들면 컴퓨터는 메모리 어딘가에 값을 담을 공간을 잡고, 그 공간에는 고유한 번호가 붙습니다. 이 번호가 바로 주소입니다. 변수 이름 앞에 &를 붙이면 그 변수가 놓인 주소를 알 수 있고, 주소는 %p 서식으로 출력합니다.
#include <stdio.h>
int main(void) {
int x = 42;
printf("x의 값: %d\n", x);
printf("x의 주소: %p\n", (void*)&x); // &x = x의 주소
return 0;
}
x의 값: 42
x의 주소: 0x7ffd3c2a4b1c
주소 값은 실행할 때마다 달라지므로 0x로 시작하는 뒷부분은 컴퓨터와 실행 시점마다 다르게 나옵니다. 중요한 건 &x가 "x가 사는 자리"를 뜻한다는 점입니다. 사실 여러분은 이걸 이미 써 봤습니다. scanf("%d", &age)에서 &age가 바로 age의 주소를 넘겨 "그 자리에 입력값을 채워라"라고 시킨 것이었습니다.
17.2 주소를 담는 변수, 포인터
주소를 담는 변수가 포인터입니다. 자료형 뒤에 *를 붙여 선언합니다. int *p는 "정수가 들어 있는 자리의 주소를 담는 변수 p"라는 뜻입니다. 여기에 &x를 넣으면 p는 x를 가리키게 됩니다.
int x = 42;
int *p = &x; // p는 x의 주소를 담는다
printf("%d\n", *p); // *p = p가 가리키는 값
42
가리키는 자리의 값을 꺼낼 때는 다시 *를 붙입니다. *p는 "p가 가리키는 자리의 값", 곧 x의 값 42입니다. 이렇게 주소를 따라가 값을 꺼내는 동작을 역참조라고 부릅니다.
주의. 선언할 때의 *와 값을 꺼낼 때의 *는 생김새만 같을 뿐 역할이 다릅니다. int *p의 *는 "이 변수는 포인터다"라는 선언 표시이고, *p처럼 이미 만든 포인터 앞에 붙는 *는 "가리키는 값을 달라"는 역참조입니다. 처음에는 헷갈리지만 예제를 여러 번 쳐 보면 저절로 구분됩니다.
![[C 17] 포인터 기초, 주소와 역참조](https://img.thenullpage.com/posts/6552/6552_1_49d956.webp)
17.3 포인터로 원본 값 바꾸기
역참조는 값을 읽기만 하는 게 아닙니다. *p에 값을 대입하면 p가 가리키는 원본 변수가 바뀝니다.
int x = 10;
int *p = &x;
*p = 99; // p가 가리키는 곳(x)에 99를 저장
printf("x = %d\n", x);
x = 99
p를 통해 값을 넣었을 뿐인데 x가 바뀌었습니다. p가 x의 주소를 들고 있으니, *p는 사실상 x를 부르는 또 다른 이름인 셈입니다. 이 성질이 포인터를 쓰는 가장 큰 이유로 이어집니다.
17.4 함수에 원본을 넘기기
C에서 함수에 값을 넘기면 그 값의 사본이 전달됩니다. 그래서 함수 안에서 아무리 바꿔도 바깥의 원본은 그대로입니다. 두 변수를 맞바꾸는 함수를 값으로 만들면 이렇게 실패합니다.
void swap(int a, int b) { // 사본만 받는다
int t = a; a = b; b = t;
}
// swap(x, y)를 불러도 바깥 x, y는 그대로
사본 a와 b만 바뀌고, 함수가 끝나면 그 사본은 사라집니다. 원본을 바꾸려면 값이 아니라 주소를 넘겨야 합니다.
void swap(int *a, int *b) {
int t = *a;
*a = *b;
*b = t;
}
int main(void) {
int x = 1, y = 2;
swap(&x, &y); // 값이 아니라 주소를 넘긴다
printf("x=%d, y=%d\n", x, y);
return 0;
}
x=2, y=1
이번에는 x와 y의 주소를 넘겼기 때문에, 함수 안의 *a와 *b가 원본이 사는 자리를 직접 가리켜 값을 맞바꿉니다. 함수가 바깥 변수를 진짜로 바꾸게 하려면 이렇게 주소를 넘긴다고 기억하면 됩니다.
17.5 결과를 두 개 돌려받기
return은 값을 하나만 돌려줍니다. 나눗셈의 몫과 나머지를 한꺼번에 받고 싶을 때는, 결과를 담을 변수의 주소를 넘겨 함수가 그 자리에 답을 채우게 합니다.
void divmod(int a, int b, int *q, int *r) {
*q = a / b; // 몫을 q가 가리키는 자리에
*r = a % b; // 나머지를 r이 가리키는 자리에
}
int main(void) {
int q, r;
divmod(17, 5, &q, &r);
printf("몫 %d, 나머지 %d\n", q, r);
return 0;
}
몫 3, 나머지 2
q와 r은 divmod를 부르기 전에는 빈 상자였지만, 주소를 넘겼으므로 함수가 그 자리에 직접 결과를 써 줍니다. return 하나로는 못 하는 "여러 값 돌려받기"를 포인터가 해결합니다.
17.6 널 포인터와 초기화 함정
포인터도 결국 변수라서, 선언만 하고 초기화하지 않으면 그 안에는 쓰레기 주소가 들어 있습니다. 어디를 가리키는지 아무도 모르는 상태입니다. 그대로 역참조하면 엉뚱한 메모리를 건드리게 됩니다.
int *p; // 초기화 안 됨, 쓰레기 주소가 들어 있음
*p = 5; // 어디인지도 모르는 곳에 값을 쓴다
함정. 위 *p = 5는 정의되지 않은 동작(undefined behavior)입니다. 운이 좋으면 프로그램이 그 자리에서 바로 죽고, 나쁘면 멀쩡히 돌아가다가 한참 뒤 엉뚱한 곳에서 이상하게 망가집니다. 이런 버그는 원인을 되짚기가 가장 괴롭습니다.
가리킬 대상이 아직 없다면 포인터를 NULL로 두어 "아무것도 안 가리킨다"를 분명히 하고, 역참조하기 전에 NULL인지 검사합니다.
int *p = NULL; // 아무것도 안 가리킴을 명시
if (p == NULL) {
printf("아직 가리키는 대상이 없음\n");
} else {
*p = 5; // 대상이 있을 때만 역참조
}
아직 가리키는 대상이 없음
주의. NULL을 역참조하는 것 역시 똑같이 정의되지 않은 동작입니다. NULL은 "여기엔 대상이 없다"는 약속된 표시일 뿐, 값을 써도 되는 자리가 아닙니다. 그래서 위처럼 먼저 검사한 뒤에만 *p에 접근합니다.
17.7 정리하며
포인터는 주소를 담는 변수이고, 딱 두 기호만 확실히 잡으면 됩니다. &는 변수의 주소를 뽑고, *는 그 주소가 가리키는 값을 꺼내거나 바꿉니다. 이 둘 덕분에 함수가 바깥 원본을 직접 고치고, 결과를 여러 개 돌려받을 수 있습니다. 안전하게 쓰는 습관도 단순합니다. 선언과 동시에 유효한 주소나 NULL로 초기화하고, 역참조하기 전에 그 포인터가 진짜 대상을 가리키는지 확인하는 것입니다. 초기화 안 된 포인터와 NULL 역참조, 이 둘만 피해도 포인터가 일으키는 사고의 대부분을 막을 수 있습니다.