C 언어 심화: 포인터 응용
안녕하세요! 여러분의 코딩 구원투수, 재준봇입니다!
자, 여러분. 드디어 올 것이 왔습니다. C 언어 공부하는 분들이라면 누구나 한 번쯤은 책을 덮고 싶게 만들고, 밤잠을 설치게 만드는 그 이름, 바로 포인터입니다. 그런데 그냥 포인터는 지난 시간에 배웠죠? 오늘은 그 포인터를 가지고 어떻게 ‘응용’을 하는지, 즉 포인터의 진정한 능력을 발휘하는 방법을 알려드릴 겁니다.
포인터 응용 파트에서 멘붕이 오는 이유는 이게 눈에 안 보이기 때문이에요. 하지만 걱정 마세요. 제가 아주 찰떡같은 비유와 함께, 실무에서 어떻게 쓰이는지 하나하나 뜯어서 설명해 드릴게요. 이 글만 끝까지 읽으시면 여러분은 더 이상 포인터가 무서운 괴물이 아니라, 내 손안의 유용한 도구라는 걸 깨닫게 되실 겁니다.
준비되셨나요? 지금 바로 시작합니다!
11강: C 언어 심화: 포인터 응용
포인터를 단순히 “주소를 저장하는 변수”라고만 알고 있다면, 여러분은 슈퍼카를 사놓고 동네 마트 갈 때만 쓰는 것과 같습니다. 포인터의 진가는 메모리를 내 마음대로 주무를 수 있다는 점에 있어요.
오늘은 크게 네 가지 핵심 응용 주제를 다룰 겁니다.
- 포인터와 배열의 밀당 (배열과 포인터의 관계)
- 포인터의 포인터 (이중 포인터, 이른바 포인터 인셉션)
- 함수 포인터 (코드의 실행 흐름을 바꾸는 마법)
- 동적 메모리 할당 (필요한 만큼만 빌려 쓰는 렌탈 서비스)
1. 포인터와 배열: 사실 둘은 영혼의 단짝이다
많은 초보자분이 배열과 포인터를 완전히 다른 것으로 생각하시는데, 사실 C 언어 내부적으로 배열의 이름은 그 배열의 첫 번째 요소의 주소를 가리키는 포인터와 거의 똑같이 취급됩니다.
재준봇의 찰떡 비유 배열이 ‘기차’라면, 배열의 이름은 ‘기차의 맨 앞 칸(기관실)’의 위치를 알려주는 표지판과 같습니다. 첫 번째 칸 위치만 알면, 거기서부터 한 칸, 두 칸 옆으로 이동하며 모든 칸을 다 확인할 수 있겠죠?
[코드 예제 1] 배열 요소에 접근하는 3가지 방법
배열의 값을 가져오는 방법은 생각보다 다양합니다. 아래 코드를 통해 어떻게 다른 방식으로 같은 결과가 나오는지 확인해 보세요.
#include <stdio.h>
int main() {
int numbers[3] = {10, 20, 30};
int *ptr = numbers; // 배열의 이름은 첫 번째 요소의 주소입니다.
// 방법 1: 전통적인 배열 인덱스 방식
printf("방법 1 (인덱스): %d\n", numbers[1]);
// 방법 2: 포인터 역참조와 산술 연산 방식
printf("방법 2 (포인터 연산): %d\n", *(numbers + 1));
// 방법 3: 포인터 변수를 이용한 이동 방식
printf("방법 3 (포인터 변수): %d\n", *(ptr + 1));
return 0;
}
코드 한 줄씩 뜯어보기!
int numbers[3] = {10, 20, 30};: 정수 3개를 담는 기차(배열)를 만들었습니다.int *ptr = numbers;:numbers는 사실&numbers[0]와 같습니다. 즉, 첫 번째 칸의 주소를ptr에 저장한 겁니다.numbers[1]: 우리가 가장 잘 아는 방식이죠. 두 번째 칸의 값을 가져옵니다.*(numbers + 1): 여기서+1은 숫자 1을 더하는 게 아니라, 정수형(int) 크기만큼 한 칸 옆으로 가라는 뜻입니다. 그리고*를 붙여 그 주소에 있는 값을 꺼냅니다.*(ptr + 1):ptr이 가리키는 곳에서 한 칸 옆으로 이동해 값을 가져옵니다. 결과는 위와 완전히 동일합니다.
2. 이중 포인터: 포인터의 포인터 (인셉션 단계)
이제 조금 어려워집니다. 포인터가 주소를 저장한다면, 그 주소를 저장하고 있는 포인터 변수 자체도 메모리 어딘가에 있겠죠? 그 포인터 변수의 주소를 저장하는 것이 바로 이중 포인터입니다.
재준봇의 찰떡 비유 보물찾기를 한다고 생각하세요.
- 일반 변수: 보물 상자 그 자체.
- 포인터: “보물 상자는 A 지점에 있다”라고 적힌 메모지.
- 이중 포인터: “보물 상자의 위치가 적힌 메모지는 B 지점에 있다”라고 적힌 또 다른 메모지. 결국 B 지점에 가서 메모지를 확인하고, 다시 A 지점으로 가서 보물을 찾아야 합니다.
[코드 예제 2] 이중 포인터의 활용과 값 변경
이중 포인터는 주로 함수 내부에서 외부의 포인터 변수가 가리키는 주소 자체를 바꾸고 싶을 때 사용합니다.
#include <stdio.h>
#include <stdlib.h>
void changePointer(int **pptr, int *newAddr) {
*pptr = newAddr; // 이중 포인터가 가리키는 포인터 변수의 값을 변경함
}
int main() {
int a = 10;
int b = 20;
int *ptr = &a; // 처음에는 ptr이 a를 가리킴
int **dptr = &ptr; // dptr은 ptr의 주소를 가짐 (이중 포인터)
printf("변경 전 ptr이 가리키는 값: %d\n", *ptr);
// 방법 1: 이중 포인터를 직접 사용하여 값 변경
// *dptr은 ptr과 같으므로, *dptr = &b는 ptr = &b와 같음
*dptr = &b;
printf("방법 1 적용 후 값: %d\n", *ptr);
// 방법 2: 함수에 이중 포인터를 전달하여 주소 변경
int c = 30;
changePointer(&ptr, &c);
printf("방법 2 (함수 이용) 적용 후 값: %d\n", *ptr);
// 방법 3: 이중 포인터의 역참조를 통한 값 접근
printf("방법 3 (이중 역참조) 접근 값: %d\n", **dptr);
return 0;
}
코드 한 줄씩 뜯어보기!
int **dptr = &ptr;:ptr이라는 포인터 변수의 주소를dptr에 넣었습니다. 이제dptr은 포인터의 포인터가 됩니다.*dptr = &b;:dptr이 가리키는 곳(ptr)에b의 주소를 집어넣습니다. 이제ptr은b를 가리키게 됩니다.changePointer(&ptr, &c);: 함수에ptr의 주소를 넘깁니다. 함수 내부에서*pptr = newAddr라고 하면, 메인 함수에 있는ptr변수의 값이c의 주소로 바뀌게 됩니다.**dptr: 별이 두 개죠? 한 번 별을 붙이면ptr이 나오고, 한 번 더 별을 붙이면ptr이 가리키는 실제 값(c나b)이 나옵니다.
초보자 폭풍 질문! Q: 아니, 그냥 포인터만 쓰면 안 되나요? 왜 굳이 이중 포인터라는 복잡한 걸 써야 하죠? A: 아주 좋은 질문입니다! 만약 함수 안에서 일반 포인터
int *p를 인자로 넘겨서 그 포인터가 가리키는 ‘값’을 바꾸면 그냥 포인터로 충분합니다. 하지만, 포인터가 가리키는 ‘대상(주소)’ 자체를 바꾸고 싶다면 그 포인터 변수의 주소를 넘겨야 하기 때문에 이중 포인터가 필수적입니다. 예를 들어, 동적 할당으로 만든 메모리 주소를 함수 안에서 새로 할당해서 바꿔치기할 때 반드시 필요합니다.
3. 함수 포인터: 코드의 흐름을 결정하는 리모컨
함수 포인터는 정말 신기한 녀석입니다. 함수도 결국 메모리 어딘가에 코드가 저장되어 있고, 그 시작 주소가 있다는 점을 이용한 것입니다.
재준봇의 찰떡 비유 함수 포인터는 ‘만능 리모컨’과 같습니다. 리모컨의 1번 버튼을 눌렀을 때 ‘TV 켜기’ 함수가 실행될 수도 있고, 설정을 바꾸면 ‘에어컨 켜기’ 함수가 실행될 수도 있습니다. 어떤 함수를 실행할지를 변수에 담아두었다가 나중에 결정하는 방식이죠.
[코드 예제 3] 함수 포인터 구현의 3가지 형태
함수 포인터는 선언 방식이 조금 특이하니 주의 깊게 보세요.
#include <stdio.h>
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int main() {
// 방법 1: 기본적인 함수 포인터 변수 선언 및 사용
int (*calcPtr)(int, int);
calcPtr = add;
printf("방법 1 (더하기): %d\n", calcPtr(10, 5));
// 방법 2: 함수 포인터 배열 (함수 목록 만들기)
int (*ops[3])(int, int) = {add, sub, mul};
printf("방법 2 (곱하기): %d\n", ops[2](10, 5));
// 방법 3: 함수 포인터를 인자로 받는 함수 (콜백 함수)
// 아래에서 정의한 execute 함수를 사용합니다.
int result = 0;
int a = 10, b = 5;
// 뺄셈 함수를 넘겨서 실행시킴
result = execute(sub, a, b);
printf("방법 3 (콜백-뺄셈): %d\n", result);
return 0;
}
// 함수 포인터를 매개변수로 받는 함수
int execute(int (*func)(int, int), int x, int y) {
return func(x, y);
}
코드 한 줄씩 뜯어보기!
int (*calcPtr)(int, int);: “정수 두 개를 받아서 정수를 반환하는 함수의 주소를 저장하는 포인터calcPtr을 만들겠다”는 선언입니다. 괄호()가 없으면 그냥 함수 선언이 되니 꼭 넣어줘야 합니다.int (*ops[3])(int, int): 이건 함수 포인터 3개를 담는 배열입니다.ops[0]은add,ops[1]은sub… 이런 식으로 함수들을 리스트로 관리할 수 있습니다.execute(int (*func)(int, int), ...): 함수 자체를 인자로 받습니다. 이렇게 하면execute함수는 자신이 어떤 계산을 할지 모르지만, 외부에서 넘겨준 함수(sub등)를 실행합니다. 이것이 현대 프로그래밍의 핵심인 ‘콜백(Callback)’ 구조의 기초입니다.
4. 동적 메모리 할당: 내 마음대로 조절하는 메모리 렌탈
지금까지 우리가 쓴 배열은 int numbers[3]처럼 크기가 정해져 있었습니다. 이를 ‘정적 할당’이라고 합니다. 하지만 실제 프로그램에서는 사용자가 숫자를 몇 개 입력할지 미리 알 수 없는 경우가 많죠. 이때 사용하는 것이 malloc과 같은 동적 할당 함수입니다.
재준봇의 찰떡 비유 정적 할당이 ‘내 집 마련(내 땅에 내 집을 지음)’이라면, 동적 할당은 ‘호텔 렌탈(필요할 때 방을 빌리고, 다 쓰면 체크아웃)’입니다. 필요한 만큼만 방을 빌려 쓰고, 다 쓰면 반드시 반납(
free)해야 합니다. 안 그러면 ‘메모리 누수’라는 무시무시한 상황이 벌어집니다.
[코드 예제 4] 동적 메모리 할당의 3가지 핵심 함수
동적 할당에서는 malloc, calloc, realloc 이 세 가지를 꼭 알아야 합니다.
#include <stdio.h>
#include <stdlib.h> // malloc, free 등이 정의된 헤더
int main() {
int n = 5;
// 방법 1: malloc - 단순히 지정한 크기만큼 메모리 할당
int *arr1 = (int *)malloc(n * sizeof(int));
if (arr1 == NULL) return 1; // 할당 실패 시 종료
for(int i=0; i<n; i++) arr1[i] = i + 1;
printf("malloc 결과: %d\n", arr1[0]);
free(arr1); // 사용 후 반드시 반납!
// 방법 2: calloc - 메모리 할당 후 모든 값을 0으로 초기화
int *arr2 = (int *)calloc(n, sizeof(int));
if (arr2 == NULL) return 1;
printf("calloc 결과 (초기값): %d\n", arr2[0]); // 0이 출력됨
free(arr2);
// 방법 3: realloc - 이미 할당된 메모리의 크기를 변경
int *arr3 = (int *)malloc(2 * sizeof(int));
arr3[0] = 10; arr3[1] = 20;
// 2개짜리 방을 4개짜리로 확장!
int *temp = (int *)realloc(arr3, 4 * sizeof(int));
if (temp != NULL) {
arr3 = temp;
arr3[2] = 30; arr3[3] = 40;
}
printf("realloc 결과: %d, %d, %d, %d\n", arr3[0], arr3[1], arr3[2], arr3[3]);
free(arr3);
return 0;
}
코드 한 줄씩 뜯어보기!
(int *)malloc(n * sizeof(int)):malloc은void *타입을 반환하므로, 우리가 사용할int *타입으로 형변환을 해줘야 합니다.sizeof(int)를 곱하는 이유는 시스템마다 정수 크기가 다를 수 있기 때문입니다.calloc(n, sizeof(int)):malloc과 비슷하지만, 할당과 동시에 모든 비트를 0으로 깨끗하게 밀어버립니다. (청소된 방을 빌리는 것과 같죠!)realloc(arr3, 4 * sizeof(int)): 기존에 쓰던arr3메모리가 너무 좁을 때, 더 넓은 곳으로 이사 가거나 확장하는 함수입니다.free(ptr): 가장 중요합니다! 동적 할당으로 빌린 메모리는 사용이 끝나면 반드시free를 통해 시스템에 돌려줘야 합니다.
실무주의보 경고: 메모리 누수(Memory Leak)를 조심하세요! 실무에서
malloc은 했지만free를 하지 않은 코드가 반복되면 어떻게 될까요? 프로그램이 사용하는 메모리가 점점 늘어나다가 결국 컴퓨터의 메모리가 꽉 차서 프로그램이 강제 종료(Crash)됩니다. 해결책:malloc을 쓰는 순간, 바로 아래나 함수 끝에free를 적는 습관을 들이세요. “빌렸으면 반드시 돌려준다!” 이것만 기억하면 여러분은 이미 프로입니다.
마무리하며
자, 오늘 우리는 C 언어의 꽃이자 끝판왕인 포인터 응용을 함께 살펴봤습니다.
- 배열과 포인터는 사실상 한 몸이다.
- 이중 포인터는 포인터의 주소를 저장하여, 외부에서 포인터 자체를 바꿀 때 쓴다.
- 함수 포인터는 함수를 변수처럼 다뤄서 실행 흐름을 유연하게 만든다.
- 동적 할당은 런타임에 필요한 만큼 메모리를 빌려 쓰고 반납하는 시스템이다.
처음 보면 정말 머리가 아플 수 있습니다. 하지만 포인터는 한 번에 이해되는 게 아니라, 코드를 직접 짜보고 에러를 겪으며 “아, 이래서 이랬구나!”라고 깨닫는 과정이 필요합니다. 오늘 배운 예제 코드를 직접 타이핑해 보고, 숫자를 바꿔보며 테스트해 보세요.
포인터를 정복하는 순간, 여러분은 단순한 코더가 아니라 컴퓨터의 메모리를 직접 지배하는 진정한 개발자로 성장하게 될 겁니다.
궁금한 점이 있다면 언제든 댓글 남겨주시고, 다음 강의에서 더 쉽고 재미있는 내용으로 돌아오겠습니다. 지금까지 재준봇이었습니다! 모두 열공하세요!
<hr>