#include <stdio.h>
void change_num(int* input_ptr, int* address) {
input_ptr = address;
}
void change_double(int** input_ptr, int* address) {
*input_ptr = address;
}
int main() {
int* a;
int** double_a;
int b = 8;
a = &b;
double_a = &a;
int c= 15;
change_num(a, &c);
//1번 결과
printf("%d", *a);
printf("\n");
change_double(double_a, &c);
//2번 결과
printf("%d", *a);
return 0;
}
더블 포인터를 왜 사용하는 지 알 수 있는 코드이다.
혹시 1번 결과와 2번 결과가 같다고 생각했다면 아래 글을 읽고
다르다고 생각했다면 컴퓨터 끄고 자도 된다.
우선
1번 결과는 8이 출력되고
2번 결과는 15가 출력된다.
누군가는 그렇게 생각할 수도 있다.
주소를 바꿔줬으니 된 거 아니야?
주소를 바꾼 건 맞다.
누구의 주소를 바꿨는지가 중요하다.
change_num의 함수는 '포인터'와 '주소'를 매개 변수로 받고
'포인터의 값'을 '매개 변수로 가져온 주소'로 바꾼다.
근데 이런 형태, 어디서 많이 봤다.
#include <stdio.h>
void change_num(int a, int b) {
a = b;
}
int main() {
int a = 10;
int b = 8;
change_num(a, b);
//1번 결과
printf("%d", a);
printf("\n");
}
과연 a의 값은 바뀔까?
그렇지 않다.
혹시 이게 이해가 되지 않는다면 포인터보다 지역 변수에 대한 개념부터 이해를 하도록 하자.
지역 변수란 무엇인가? 컴퓨터 구조에 대한 개념부터 이해를 하도록 하자.
일단 메모리에는 이렇게 정리된다.
| 가상 메모리 |
|---|
| 커널 가상 메모리 |
| 유저 스택 |
| ↓ |
| ↑ |
| 공유 라이브러리 메모리 지역 |
| ↑ |
| 런타임 힙 |
| 읽기/쓰기 데이터 |
| 읽기 전용 코드 및 데이터 |
Bryant, R. E., & O’Hallaron, D. R. (2023). Computer systems: A programmer’s perspective (3rd ed.). Pearson.
여기서 런타임 힙에는 유저가 동적 메모리할당을 통해 힙 공간을 요청한 경우에 변수가 저장된다. 사실 이렇게 말하면 나도 무슨 말인지 모른다. 궁금해져서 AI한테 물어보니 perplexity 말로는 언어마다 다르다고 한다.
| 언어/환경 | 힙에 저장되는 데이터의 예시 | 설명 |
|---|---|---|
| C/C++ | malloc(), calloc(), new로 할당된 데이터 | 동적 메모리 할당을 통해 생성된 데이터. 수동으로 해제 필요 (free, delete). |
| Java | 객체(new 키워드로 생성된 데이터) | 모든 객체는 힙에 저장되며, 가비지 컬렉터가 메모리를 관리함. |
| C# | 참조 타입(배열, 클래스 인스턴스 등) | 힙에 저장되며, 가비지 컬렉터가 자동으로 메모리를 해제함. |
| JavaScript | 객체, 배열, 함수 | 비원시(non-primitive) 데이터는 힙에 저장됨. |
| Python | 리스트, 딕셔너리, 클래스 인스턴스 등 | 복잡한 데이터 구조는 모두 힙에 저장되며, 가비지 컬렉터가 관리함. |
| 일반적인 경우 | 큰 데이터 구조(대형 배열, 연결 리스트 등) | 크기가 커서 스택에 적합하지 않은 데이터는 힙에 저장됨. |
그새 정신 차렸는지 이제는 마크다운 표로 잘 보여준다.
이렇게 보니 그렇게 많이 접했던 가비지 컬렉터의 역할을 알 것 같다.
그리고 스택에는 함수에서 사용되는 것들이 저장되는데, 함수를 호출한 주소(반환 주소)나 함수의 기본 정보, 함수에서 사용되는 변수, 매개 변수(매개 변수의 수나 크기에 따라 다르다) 등등이 저장된다.
아래는 유저스택을 확대한 것이다.
| 유저 스택 |
|---|
| 이전 함수 |
| 매개변수 |
| ... |
| 매개변수 |
| 반환 주소 |
| 저장된 레지스터 |
| 지역 변수 |
| ... |
함수가 호출되면 크게 호출한 함수와 실행하는 함수 부분으로 구분할 수 있다.
중요한 건 이 함수가 종료되면 이 스택 부분이 사라진다는 것이다.
예를 들어
#include <stdio.h>
void change_num(int input_a, int input_b) {
input_a = input_b;
}
int main() {
int a = 10;
int b = 8;
change_num(a, b);
//1번 결과
printf("%d", a);
printf("\n");
}
이 코드를 스택으로 표현하면 다음과 같이 된다.
놀랍?게도 메인 함수 역시 스택에 저장된다.
| 유저 스택 |
|---|
| -main 함수- |
| int a = 10 |
| int b = 8 |
| (함수 호출) |
| -change_num 함수- |
| int input_a = 10 |
| int input_b = 8 |
| (함수 호출)의 주소 |
막 함수가 호출됐을 때는 이렇게 되고
input_a = input_b를 실행하게 되면
| 유저 스택 |
|---|
| -main 함수- |
| int a = 10 |
| int b = 8 |
| (change_num 함수 호출) |
| -change_num 함수- |
| int input_a = 8 |
| int input_b = 8 |
| (change_num 함수 호출)의 주소 |
위와 같이 된다.
그리고 함수가 종료되면
| 유저 스택 |
|---|
| -main 함수- |
| int a = 10 |
| int b = 8 |
위와 같이 되며 printf 함수가 실행된다.
| 유저 스택 |
|---|
| -main 함수- |
| int a = 10 |
| int b = 8 |
| (printf 함수 호출) |
| -printf 함수- |
| printf 값 = 10 // 10 출력 |
| (printf 함수 호출)의 주소 |
이렇게 된다. 위에서도 알 수 있겠지만, a의 값은 다른 함수의 스택 프레임에 복사가 될 뿐 a가 전달되는 것은 아니다. 즉, 값으로 전달(call by value)되면 원본은 건드리지 않고 함수 내에서만(지역 변수) 쓰인다.
그렇다면 이 원본을 바꾸기 위해서는 어떻게 해야할까?
바로 참조, 주소, 가르키는 것을 주어야 한다.
어떤 값이 어디에 저장되었는지 알기 위해서는 반드시 메모리 주소를 알아야 한다.
| 메모리 주소 | 유저 스택 |
|---|---|
| 0x1000 | -main 함수- |
| 0x1004 | int a = 10 |
| 0x1008 | int b = 8 |
| 0x100C | (change_num 호출) |
C에서는 해당 메모리 주소를 알기 위해 &(앰퍼샌드)라는 연산자를 사용한다.
예를 들어 &(a)하면 0x1004가 나올 것이고 &(b)하면 0x1008이 나올 것이다.
그럼 이제 살짝 비틀어보자.
얘에 대해서 알아볼 것이다.
#include <stdio.h>
void change_num(int* input_ptr, int input_int) {
*input_ptr = input_int;
}
int main() {
int a = 10;
int b = 8;
int* ptr_a = &a;
change_num(ptr_a, b);
printf("%d", a);
printf("\n");
}
참고로 포인터의 크기는 아키텍처마다 다르고 32bit에서는 8byte를 차지한다.
다만, 여기서는 편의를 위해 4byte로 가정하고 설명을 진행하겠다.
| 메모리 주소 | 유저 스택 |
|---|---|
| 0x1000 | -main 함수- |
| 0x1004 | int a = 10 |
| 0x1008 | int b = 8 |
| 0x100C | int* ptr_a = 0x1004 |
| 0x1010 | change_num(ptr_a, b) 호출 |
| ------------- | ------------------------ |
| 0x1014 | -change_num 함수- |
| 0x1018 | int* input_ptr = 0x1004 |
| 0x101C | int input_b = 8 |
| 0x1020 | 리턴 주소(main으로 복귀) |
여기서 주목할 것은 chnage num 함수의 스택 프레임에 0x1004라는 주소가 있다는 것이다.
그러면 이제 change_num은 자신의 스택 프레임 바깥의 주소에 접근할 수 있다.
따라서 *ptr_a를 통해

이런 형태로 접근해서
| 메모리 주소 | 유저 스택 |
|---|---|
| 0x1000 | -main 함수- |
| 0x1004 | 'int a = 8' |
| 0x1008 | int b = 8 |
| 0x100C | int* ptr_a = 0x1004 |
| 0x1010 | change_num(ptr_a, b) 호출 |
| ------------- | ------------------------ |
| 0x1014 | -change_num 함수- |
| 0x1018 | int* input_ptr = 0x1004 |
| 0x101C | int input_b = 8 |
| 0x1020 | 리턴 주소(main으로 복귀) |
*(0x1004) = 8로 해버린다.
이렇게 주소로 접근하는 것이 call by reference의 기본 개념이다.
call by reference가 왜 중요한가?
우선 객체 지향 언어에서는 일부 객체와 인스턴스는 참조의 형태로 사용된다. 참조형 데이터 역시 참조하는 형태로 사용된다.
그리고 참조한 객체를 수정하는 함수를 쓸 경우 높은 확률로 원본 데이터를 손상시킨다.
예를 들어보자.
형식적인 측면에서 파이썬이 간편하므로 파이썬을 예시코드로 들겠다.
def change_array(input_array):
input_array[0] = 5
array_a = [1, 2, 3]
change_array(array_a)
print(array_a)
위의 코드에서 결과가 어떻게 나올 것 같은가?
#include <stdio.h>
void change_num(int input_a, int input_b) {
input_a = input_b;
}
int main() {
int a = 10;
int b = 8;
change_num(a, b);
//1번 결과
printf("%d", a);
printf("\n");
}
여기서 10이 나왔으니 파이썬 코드에서도 [1, 2, 3]이 나올 것이라고 생각하는가?
정답은 [5, 2, 3]이 나온다.
파이썬이라서 그런 거 아니냐고?
오히려 C로 접근하면 더욱 명확해진다.
C에서는 array_a를 포인터로 취급한다. 포인터는 주소를 가지고 있는 놈이고 따라서 array_a는 주소를 담고 있는 놈이다.
편하게 생각하기 위해 객체들은 힙에 저장된다고 생각하자.
| 메모리 주소 | 힙 |
|---|---|
| 0x2000 | array_a[0] = 1 |
| 0x2004 | array_a[1] = 2 |
| 0x2008 | array_a[2] = 3 |
| 메모리 주소 | 스택 |
|---|---|
| 0x1000 | -main 함수- |
| 0x1004 | array_a = 0x2000 |
| 0x1008 | change_array(array_a) 호출 |
| ------------- | ------------------------------ |
| 0x100C | -change_array 함수- |
| 0x1010 | input_array = 0x2000 |
따라서 change array 함수의 스택 프레임이라도 원본 객체에 접근할 수 있는 것이다.

이런 식으로 말이다.
객체 지향 언어의 객체들도 위와 같다고 생각하면 된다.
따라서 원본 객체가 손상되는 것을 막고 싶으면
from copy import deepcopy
def change_array(input_array):
new_array = deepcopy(input_array)
new_array[0] = 5
array_a = [1, 2, 3]
change_array(array_a)
print(array_a)
OUTPUT
[1, 2, 3]
위와 같이 깊은 복사(deepcopy)를 사용하면 된다.
그러면 다음과 같이 작동할 것이다.
물론 예시처럼 연속으로 할당되지는 않을 것이다.
| 메모리 주소 | 힙 |
|---|---|
| 0x2000 | array_a[0] = 1 |
| 0x2004 | array_a[1] = 2 |
| 0x2008 | array_a[2] = 3 |
| 0x200C | new_array[0] = 1 |
| 0x2010 | new_array[1] = 2 |
| 0x2014 | new_array[2] = 3 |
| 메모리 주소 | 스택 |
|---|---|
| 0x1000 | -main 함수- |
| 0x1004 | array_a = 0x2000 |
| 0x1008 | change_array(array_a) 호출 |
| ------------- | ------------------------------ |
| 0x100C | -change_array 함수- |
| 0x1010 | input_array = 0x2000 |
| 0x1014 | new_array = 0x200C |
그럼 참조에 대한 설명은 여기까지하고, 진짜를 만나러 가자.
를 만나기 전에 우선 *연산자와 자료형*에 대해서 알아보자.
처음에 얘네 만나면 헷갈린다.
많이 헷갈린다.
엄청 헷갈린다.
그 놈이 그 놈 같은데, 사실 다른 것 같고, 또 알고 보면 같은 것 같고...
...
자료형*은 그냥 포인터 선언할 때 쓴다고 생각하면 된다. 그리고 해당 변수는 주소가 들어간다고 생각하면 된다.
자료형*은 변수 선언할 때와 함수의 매개변수로 넣을 때 말고는 볼 일이 없다.
그래도 둘의 느낌이 미묘하게 다르니까 설명하도록 하겠다.
내가 느끼는 느낌이 다르다는 말이지 똑같은 놈이다.
int* a;
인트를 가진 값의 주소를 담는 인트형 포인터 a를 선언한 것이다.
주소를 담기 때문에 보통 &를 단 값을 할당한다.
&는 주소를 반환하는 연산자이기 때문이다.
int b = 0;
int* a = &b;
따라서 위에서는 b의 주소를 반환하고 이를 a에 할당했다고 보면 된다.
그리고 실제로 메모리 내에도 b의 주소가 저장되어 있다.
때문에
printf("%d", a);
를 하면 b의 값인 0이 나오는게 아니라 주소같은 값이 나온다.
주소 같은 값이라고 한 이유는 포인터 변수는 %p와 같은 형태로 출력해야 더 정확한 주소가 나오기 때문이다.
여기서 나오는 주소 같은 값은 당연히 b의 주소이다.
사실 헷갈리는 건 함수다.
void testPtr(int* a) {
//test code
}
위와 같이 있으면 당최 위 함수에 뭘 어떻게 넣어야할지 막막해진다.
int b = 0;
int* a = &b;
testPtr(b) ?
testPtr(a) ?
testPtr(&b) ?
testPtr(*a) ?
넷 중에 무엇이 가능할 거 같은가?
연산자 *의 의미에 대해 후술하겠지만, 간단하게 '~가 가리키는 곳의 값'이라고 나는 해석한다.
앞서 설명했듯 int*면 주소를 담아야 한다.
위의 4개 중 주소에 해당하는 것은
testPtr(a)
testPtr(&b)
이 둘이다.
&b는 주소인 걸 알겠는데 왜 a가 주소냐고?
printf("%d", a);
를 하면 주소가 나오니까
잘 모르겠으면 항상 프린트를 찍어보는 걸 추천한다.
금속활자 같은 걸로 안찍어도 되는데 얼마나 다행인가
그리고 * 앞에 붙는 자료형 따라 int*면 int형 포인터, double*이면 double형 포인터 ... 라고 생각하면 된다.
마지막으로 자료형에 붙은 *을 읽을 때는 '~의 주소를 담은'이라고 생각하면서 읽는다.
int* a는 'int의 주소를 담은' a인 것이다.
선행학습이지만
int** a는 'int의 주소를 담은' 'int*의 주소를 담은' a인 것이다.
우리가 헷갈리는 것은 *가 연산자로써 작용할 때이다.
나는 *를 '~가 가리키는 곳의 값'이라고 읽는다.
int a = 10;
int* ptr_a = &a;
| 메모리 주소 | ? |
|---|---|
| 0x1004 | int a = 10 |
| 0x1008 | int* ptr_a = 0x1004 |
연습해보자
printf("%d", ptr_a)를 하면 무엇이 나올 것 같나?
당연히
0x1004이다.
printf("%d", *ptr_a)를 하면 무엇이 나올 것 같나?
ptr_a'가 가리키는 곳(0x1004)의 값'인 10이 나온다.
사실 여기까지는 할만하다.
변수명을 재밌게 쓰면 프로그래밍이 재밌어진다.

알고리즘을 풀기 위해 유니온 파인드를 쓰다가 문득 real_min_num을 썼던 게 생각이 났다.
어쩔 수 없었다. 루트 노드를 딕셔너리로 구현하려다보니 그렇게 됐다.
진짜 작은 놈은 루트 노드(예정)이다.
각설하고
더블 포인터란 무엇인가?
int**
내 해석을 곁들이면 int의 주소를 담은 int*의 주소를 담은 놈이다.
그렇다면 주소를 담은 놈의 주소를 담는다는 과연 무슨 말일까?
int a = 10;
int* ptr_a = &a;
int** double_ptr_a = &ptr_a;
| 메모리 주소 | ? |
|---|---|
| 0x1004 | int a = 10 |
| 0x1008 | int* ptr_a = 0x1004 |
| 0x100C | int** double_ptr_a = 0x1008 |
보다시피이다.
double_ptr_a는 a의 주소를 담은 ptr_a의 주소를 가지고 있다.
그렇다면 *연산자를 통해 얘네들을 해석해보자.
먼저 *double_ptr_a를 하면 어떤 값이 나올까?
그 방식대로 해석해보면
double_ptr_a가 가리키고 있는 곳의 값
double_ptr_a가 가리키고 있는 곳의 값(0x1008)
즉 0x1008의 값인 0x1004가 나온다.

한 발자국 더 들어가서
**double_ptr_a는 뭐가 나올까?
double_ptr_a가 '가리키고 있는 곳의 값'이 '가리키고 있는 곳의 값'이 나온다.
즉, double_ptr_a가 '가리키고 있는 곳의 값(0x1004)'이 '가리키고 있는 곳의 값(10)'이 나온다.

그럼 이 복잡한 놈을 대체 왜 쓰는 걸까?
그것이 바로 서두에
#include <stdio.h>
void change_num(int* input_ptr, int* address) {
input_ptr = address;
}
void change_double(int** input_ptr, int* address) {
*input_ptr = address;
}
int main() {
int* a;
int** double_a;
int b = 8;
a = &b;
double_a = &a;
int c= 15;
change_num(a, &c);
//1번 결과
printf("%d", *a);
printf("\n");
change_double(double_a, &c);
//2번 결과
printf("%d", *a);
return 0;
}
를 쓴 이유이다. 또한 대표적으로 링크드리스트의 헤드 노드를 변경할 때 쓰기도 한다.
코드를 보면서 무슨 말인지 이해를 해보겠다.
총 3가지 코드 더미를 제시하겠다.
#include <stdio.h>
void change_num(int* input_ptr, int input_int) {
*input_ptr = input_int;
}
int main() {
int a = 10;
int b = 8;
int* ptr_a = &a;
change_num(ptr_a, b);
printf("%d", a);
printf("\n");
}
#include <stdio.h>
void change_ptr(int* input_ptr, int* address) {
input_ptr = address;
}
int main() {
int a = 10;
int b = 8;
int* ptr_a = &a;
change_ptr(ptr_a, &b);
printf("%d", *ptr_a);
}
#include <stdio.h>
void change_double(int** input_ptr, int* address) {
*input_ptr = address;
}
int main() {
int a = 10;
int b = 8;
int* ptr_a;
int** double_a;
ptr_a = &a;
double_a = &ptr_a;
change_double(double_a, &b);
printf("%d", *ptr_a);
return 0;
}
1번에서 a값이 8로 바뀌는 것은 확인했다.
주소에 있는 값을 직접 건드리기 때문이다.
그렇다면 2번은 어떻게 될까?
스택 메모리를 통해서 과정을 살펴보자.
우선 다음과 같이 호출될 것이다.
| 메모리 주소 | 유저 스택 |
|---|---|
| 0x1000 | -main 함수- |
| 0x1004 | int a = 10 |
| 0x1008 | int b = 8 |
| 0x100C | int* ptr_a = 0x1004 |
| 0x1010 | change_ptr(ptr_a, &b) 호출 |
| ------------- | ------------------------ |
| 0x1014 | -change_num 함수- |
| 0x1018 | int* input_ptr = 0x1004 |
| 0x101C | int* address = 0x1008 |
| 0x1020 | 리턴 주소(main으로 복귀) |
여기서 우리가 하는 연산은 input_ptr에 address를 할당하는 연산이다.
따라서 스택 0x1018의 값은 다음처럼 바뀔 것이다.
| 메모리 주소 | 유저 스택 |
|---|---|
| 0x1000 | -main 함수- |
| 0x1004 | int a = 10 |
| 0x1008 | int b = 8 |
| 0x100C | int* ptr_a = 0x1004 |
| 0x1010 | change_ptr(ptr_a, &b) 호출 |
| ------------- | ------------------------ |
| 0x1014 | -change_ptr 함수- |
| 0x1018 | int* input_ptr = '0x1008' |
| 0x101C | int* address = 0x1008 |
| 0x1020 | 리턴 주소(main으로 복귀) |
이 말인 즉슨 원본 데이터는 변하지 않는다는 것이다.
ptr_a처럼 0x1004를 가리키는 포인터가 하나 더 생기고 그 친구가 0x1008을 가리키는 것으로 바꼈을 뿐이다.
따라서 10이 출력된다.
3번은 어떻게 진행될까? 이것도 역시 스택을 살펴보자.
| 메모리 주소 | 유저 스택 |
|---|---|
| 0x1000 | -main 함수- |
| 0x1004 | int a = 10 |
| 0x1008 | int b = 8 |
| 0x100C | int* ptr_a = 0x1004 |
| 0x1010 | int** double_a = 0x100C |
| 0x1014 | change_double(double_a, &b) 호출 |
| ------------- | ------------------------ |
| 0x1018 | -change_double 함수- |
| 0x101C | int** input_ptr = 0x100C |
| 0x1020 | int* address = 0x1008 |
| 0x1024 | 리턴 주소(main으로 복귀) |
여기서 우리가 하는 연산은 *double_a = address 연산이다.
즉 double_a가 가리키고 있는 곳(0x100C)의 값을 0x1008로 바꾸는 것이다.

| 메모리 주소 | 유저 스택 |
|---|---|
| 0x1000 | -main 함수- |
| 0x1004 | int a = 10 |
| 0x1008 | int b = 8 |
| 0x100C | int* ptr_a = '0x1008' |
| 0x1010 | int** double_a = 0x100C |
| 0x1014 | change_double(double_a, &b) 호출 |
| ------------- | ------------------------ |
| 0x1018 | -change_double 함수- |
| 0x101C | int** input_ptr = 0x100C |
| 0x1020 | int* address = 0x1008 |
| 0x1024 | 리턴 주소(main으로 복귀) |
그러면 위와 같이 변하게 된다.
따라서 *ptr_a의 값은 8이 나온다.
1번과 다른 게 없어보일지도 모르지만, 큰 차이가 있다.
바로 원본데이터의 손실 여부이다.
1번에서는

| 메모리 주소 | 유저 스택 |
|---|---|
| 0x1000 | -main 함수- |
| 0x1004 | 'int a = 8' |
| 0x1008 | int b = 8 |
| 0x100C | int* ptr_a = 0x1004 |
| 0x1010 | change_num(ptr_a, b) 호출 |
| ------------- | ------------------------ |
| 0x1014 | -change_num 함수- |
| 0x1018 | int* input_ptr = 0x1004 |
| 0x101C | int input_b = 8 |
| 0x1020 | 리턴 주소(main으로 복귀) |
a의 값이 바뀌었다.
따라서 printf("%d", a)를 하면 8이 출력된다.
그러나
3번에서는

| 메모리 주소 | 유저 스택 |
|---|---|
| 0x1000 | -main 함수- |
| 0x1004 | int a = 10 |
| 0x1008 | int b = 8 |
| 0x100C | int* ptr_a = '0x1008' |
| 0x1010 | int** double_a = 0x100C |
| 0x1014 | change_double(double_a, &b) 호출 |
| ------------- | ------------------------ |
| 0x1018 | -change_double 함수- |
| 0x101C | int** input_ptr = 0x100C |
| 0x1020 | int* address = 0x1008 |
| 0x1024 | 리턴 주소(main으로 복귀) |
가리키는 곳만 바꿨기 때문에 a의 값은 바뀌지 않았다.
따라서 printf("%d", a)를 하면 10이 출력된다.
글 너무 잘봤습니다.