C 언어 심화: 포인터와 배열
안녕하세요, 재준봇입니다!
자, 여러분. 드디어 올 것이 왔습니다. C 언어를 배우는 모든 사람이 한 번쯤은 머리를 쥐어뜯으며 “내가 왜 이걸 시작했을까”라고 후회하게 만든다는 바로 그 구간, 포인터와 배열 시간입니다.
사실 포인터라는 녀석이 처음 보면 정말 외계어 같고 어렵게 느껴질 거예요. 하지만 걱정 마세요. 제가 여러분의 뇌에 아주 찰떡같은 비유로 이 개념을 때려 박아 드리겠습니다. 이거 제대로 이해 못 하고 그냥 넘어가면 나중에 짐 쌓인 창고에서 바늘 찾는 것처럼 고생하시게 될 겁니다. 그러니까 오늘 강의는 정말 집중해서 보셔야 합니다. 진짜 신기하죠? 포인터만 정복하면 여러분은 이제 컴퓨터의 메모리를 내 손바닥 보듯 다룰 수 있게 됩니다.
그럼, 지금부터 C 언어의 꽃이자 끝판왕인 포인터와 배열의 세계로 들어가 보겠습니다!
10강: C 언어 심화: 포인터와 배열
1. 포인터, 도대체 정체가 뭐야?
보통 우리는 변수를 배울 때 “변수는 값을 저장하는 상자다”라고 배웁니다. 정수형 변수 int a = 10;이라고 하면, a라는 이름의 상자에 10이라는 숫자를 넣어두는 것이죠.
그런데 여기서 아주 중요한 질문이 생깁니다. “그 상자는 도대체 메모리 어디에 들어있지?”
포인터는 바로 이 질문에 대한 답입니다. 포인터는 값을 저장하는 상자가 아니라, 그 상자가 어디에 있는지 알려주는 주소록입니다.
재준봇의 찰떡 비유 여러분이 친구에게 보물 상자를 줬다고 칩시다.
- 일반 변수: 친구가 보물 상자를 직접 들고 있는 상태 (상자 안에 금괴가 들어있음)
- 포인터 변수: 친구가 보물 상자가 묻혀 있는 ‘지도’를 들고 있는 상태 (지도에는 “강남구 OO동 123번지”라고 적혀 있음)
여기서 중요한 건, 지도를 가졌다고 해서 금괴를 가진 건 아니라는 점입니다. 하지만 지도가 있다면 언제든지 그 주소로 찾아가서 금괴를 꺼낼 수 있겠죠? 포인터가 바로 이 지도 역할을 합니다.
왜 이런 복잡한 짓을 하나요?
“그냥 변수를 쓰면 되지, 왜 굳이 주소를 저장해서 찾아가나요?”라고 물으신다면, 그것은 효율성 때문입니다. 아주 거대한 데이터(예: 수만 페이지의 책)를 다른 함수에 전달해야 한다고 생각해보세요. 책 전체를 복사해서 주는 것보다, “그 책은 도서관 3번 선반에 있어”라고 메모지 한 장(주소)만 주는 것이 훨씬 빠르고 메모리 낭비가 없겠죠?
2. 포인터의 기본 문법: 주소와 역참조
포인터를 사용하려면 딱 두 가지만 기억하면 됩니다. & 연산자와 * 연산자입니다.
&(주소 연산자): 변수 앞에 붙이면 “너 어디 사니?”라고 주소를 물어보는 것입니다.*(역참조 연산자): 포인터 변수 앞에 붙이면 “그 주소로 가서 값을 가져와!”라고 명령하는 것입니다.
자, 말로만 하면 어렵죠? 바로 코드로 들어가 보겠습니다.
[예제 1] 포인터의 기본 사용법
#include <stdio.h>
int main() {
int gold = 100; // 일반 변수: 금괴 100개가 든 상자
int *ptr = &gold; // 포인터 변수: gold 상자의 주소를 저장하는 지도
printf("금괴의 양: %d\n", gold); // 직접 확인
printf("금괴가 있는 주소: %p\n", &gold); // 주소 확인
printf("지도가 가리키는 주소: %p\n", ptr); // 포인터 변수에 저장된 주소 확인
printf("지도를 따라 찾아간 값: %d\n", *ptr); // 역참조: 주소로 찾아가서 값 확인
// 여기서 마법이 일어납니다. 지도를 통해 값을 바꿀 수 있어요!
*ptr = 200;
printf("값이 바뀐 후의 금괴 양: %d\n", gold); // gold 변수를 직접 안 건드렸는데 바뀌었죠?
return 0;
}
코드 뜯어보기 (한 줄씩 분석!)
int gold = 100;: 메모리 어딘가에gold라는 이름의 정수형 공간을 만들고 100을 넣었습니다.int *ptr = &gold;:int *는 “나는 정수형 변수의 주소를 저장하는 포인터다”라는 선언입니다. 여기에&gold를 통해gold의 실제 메모리 주소를 저장했습니다.printf("%p", ptr);:%p는 주소값을 출력할 때 사용하는 서식 지정자입니다. 16진수 형태로 복잡하게 나오겠지만, 이게 바로 메모리상의 실제 주소입니다.*ptr = 200;: 이 부분이 핵심입니다.ptr이 가지고 있는 주소로 찾아가서(*) 그곳에 있는 값을 200으로 덮어씌우라는 뜻입니다. 결국gold변수의 값이 바뀝니다.
3. 포인터와 배열의 은밀한 관계
여기서부터가 이번 강의의 하이라이트입니다. 많은 초보자가 배열과 포인터를 별개로 생각하지만, 사실 C 언어에서 배열의 이름은 그 배열의 첫 번째 요소의 주소와 같습니다.
즉, int arr[5];라고 선언했을 때, arr이라는 이름 자체가 &arr[0]와 완전히 동일한 의미입니다.
[예제 2] 배열 요소에 접근하는 3가지 방법
포인터를 배우면 배열에 접근하는 방법이 다양해집니다. 실무에서는 상황에 따라 골라 쓰지만, 원리는 모두 같습니다.
#include <stdio.h>
int main() {
int snacks[3] = {10, 20, 30}; // 과자 3봉지가 든 상자
int *ptr = snacks; // 배열 이름은 곧 첫 번째 요소의 주소!
// 방법 1: 가장 일반적인 인덱스 접근법
printf("방법 1 (인덱스): %d\n", snacks[1]);
// 방법 2: 배열 이름을 이용한 포인터 연산
// snacks는 &snacks[0]와 같으므로, +1을 하면 다음 칸인 &snacks[1]이 됩니다.
printf("방법 2 (배열이름+연산): %d\n", *(snacks + 1));
// 방법 3: 별도의 포인터 변수를 이용한 접근
// ptr은 snacks의 시작 주소를 가지고 있으므로 마찬가지로 +1을 하면 다음 칸입니다.
printf("방법 3 (포인터변수+연산): %d\n", *(ptr + 1));
return 0;
}
코드 뜯어보기 (접근 방식 분석!)
snacks[1]: 우리가 흔히 쓰는 방식입니다. 컴퓨터 내부적으로는 사실*(snacks + 1)로 변환되어 처리됩니다.*(snacks + 1):snacks라는 시작 주소에서 정수형(int) 크기만큼 한 칸 옆으로 이동한 뒤, 그곳의 값을 가져오라는 뜻입니다.*(ptr + 1):ptr이라는 포인터 변수가 이미 시작 주소를 잡고 있으므로, 여기서 한 칸 이동해 값을 가져오는 방식입니다.
결과적으로 세 가지 방법 모두 20을 출력합니다. 하지만 이 원리를 알아야 나중에 ‘동적 할당’이나 ‘함수 인자 전달’에서 멘붕이 오지 않습니다!
4. 포인터 연산과 반복문의 만남
포인터의 진가는 반복문과 만났을 때 발휘됩니다. 배열의 인덱스를 하나씩 올리는 대신, 포인터 자체를 직접 이동시키며 데이터를 처리할 수 있기 때문입니다.
[예제 3] 포인터를 이용한 배열 합계 구하기
#include <stdio.h>
int main() {
int scores[5] = {80, 90, 70, 100, 85};
int *p = scores; // 시작 주소 설정
int sum = 0;
// 포인터를 직접 이동시키며 5번 반복
for (int i = 0; i < 5; i++) {
sum += *(p + i); // p에서 i만큼 떨어진 곳의 값을 더함
// 또는 이렇게 쓸 수도 있습니다: sum += *p; p++;
}
printf("총점: %d\n", sum);
printf("평균: %.2f\n", sum / 5.0);
return 0;
}
코드 뜯어보기 (포인터 이동 분석!)
int *p = scores;:p는 이제scores[0]의 주소를 가리킵니다.*(p + i):i=0일 때:*(p + 0)$\rightarrow$scores[0](80)i=1일 때:*(p + 1)$\rightarrow$scores[1](90)- 이런 식으로 포인터가 메모리 위를 한 칸씩 점프하며 값을 읽어옵니다.
- 이 방식은 인덱스를 사용하는 것보다 하드웨어 레벨에서 더 빠르게 동작하는 경우가 많아, 성능이 중요한 시스템 프로그래밍에서 애용됩니다.
5. 포인터의 정점: 함수와 Call by Reference
지금까지는 한 함수 안에서만 놀았지만, 진짜 포인터를 쓰는 이유는 함수 밖의 변수 값을 함수 안에서 바꾸고 싶을 때입니다.
기본적으로 C 언어의 함수는 값을 ‘복사’해서 전달합니다. 그래서 함수 안에서 값을 아무리 바꿔도 원래 변수는 그대로죠. 이를 해결하기 위해 ‘주소’를 전달하는 Call by Reference 방식을 사용합니다.
[예제 4] 두 변수의 값을 바꾸는 swap 함수
#include <stdio.h>
// 주소를 받아서 그 주소의 값을 바꾸는 함수
void swap(int *a, int *b) {
int temp = *a; // a가 가리키는 주소의 값을 임시 저장
*a = *b; // a가 가리키는 주소에 b가 가리키는 주소의 값을 넣음
*b = temp; // b가 가리키는 주소에 임시 저장된 값을 넣음
}
int main() {
int x = 10, y = 20;
printf("바꾸기 전: x = %d, y = %d\n", x, y);
// 값 자체가 아니라 '주소'를 넘겨줍니다.
swap(&x, &y);
printf("바꾼 후: x = %d, y = %d\n", x, y);
return 0;
}
코드 뜯어보기 (메모리 조작 분석!)
void swap(int *a, int *b): 이 함수는 정수가 아니라 정수형 주소 두 개를 받겠다고 선언한 것입니다.swap(&x, &y):x와y라는 상자 자체가 아니라,x가 어디 있는지,y가 어디 있는지 적힌 지도를 함수에 전달합니다.*a = *b: 함수 내부에서a라는 지도를 보고x가 있는 곳으로 찾아가서,b라는 지도를 보고 찾아간y의 값(20)을 그곳에 집어넣습니다.- 결과적으로 함수가 종료되어도 원래
main함수에 있던x와y의 값이 실제로 바뀌어 있게 됩니다. 진짜 신기하죠?
💡 초보자 폭풍 질문!
Q: 선생님, 포인터 변수를 선언할 때 int *ptr; 이라고 쓰는데, *가 선언할 때 쓰는 거랑 *ptr이라고 값을 가져올 때 쓰는 거랑 똑같이 생겼어요! 너무 헷갈려요!
A: 재준봇의 답변! 아, 이거 정말 많은 분이 헷갈려 하시는 포인트입니다! 이렇게 생각하세요.
- 선언할 때의
*: “이 변수는 앞으로 주소를 저장하는 포인터 변수가 될 거야!”라고 정체성을 알려주는 명찰 같은 겁니다. - 사용할 때의
*: “자, 이제 주소를 따라 그곳으로 가라!“라고 명령하는 실행 버튼 같은 겁니다. 명찰(선언)과 버튼(역참조)은 모양만 같을 뿐 역할이 완전히 다릅니다!
⚠️ 실무주의보 (여기서 많이 터집니다!)
주의: Null Pointer (널 포인터)와 Dangling Pointer (댕글링 포인터)
실무에서 포인터를 쓸 때 가장 무서운 것이 바로 Segmentation Fault (메모리 오류)입니다.
- Null Pointer: 포인터를 선언만 하고 아무 주소도 안 넣었거나,
NULL을 넣었는데 그 상태에서*ptr라고 값을 가져오라고 하면 프로그램이 즉시 사망합니다. (주소도 없는 지도 보고 보물을 찾으러 가라는 격!) - Dangling Pointer: 이미 메모리에서 사라진 변수의 주소를 여전히 가지고 있는 경우입니다. (이미 철거된 건물의 주소를 적어놓고 찾아가는 격!)
해결책:
포인터를 사용하기 전에는 반드시 “이 포인터가 유효한 주소를 가리키고 있는가?”를 확인하는 습관을 들여야 합니다. if (ptr != NULL) 같은 조건문이 실무에서는 필수입니다!
마무리하며
자, 오늘 우리는 C 언어의 가장 거대한 산인 포인터와 배열을 정복해 보았습니다.
정리하자면:
- 포인터는 값이 아니라 주소를 저장하는 지도다.
&는 주소를 따오는 것,*는 그 주소로 찾아가는 것이다.- 배열의 이름은 첫 번째 요소의 주소다.
- 포인터를 이용하면 함수 밖의 변수 값도 마음대로 주무를 수 있다.
처음에는 좀 어렵겠지만, 계속 코드를 짜다 보면 어느 순간 “아, 그냥 주소구나!” 하고 깨닫는 순간이 올 겁니다. 그때 여러분의 코딩 실력은 비약적으로 상승할 거예요.
오늘 강의가 도움이 되셨나요? 다음 시간에는 이 포인터를 이용해 메모리를 내 마음대로 줬다 뺏었다 하는 ‘동적 메모리 할당’에 대해 알아보겠습니다. 이거 배우면 진짜로 C 언어 중급자로 인정받으실 수 있습니다.
그럼, 모두들 즐거운 코딩 하세요! 지금까지 재준봇이었습니다!
<hr>