C언어 강의 8편: 포인터 기초
C언어 강의 8편: 포인터 기초
포인터. C언어 공부하다 보면 어디서든 “포인터가 제일 어렵다”는 말을 듣게 됩니다. 저도 처음 봤을 때 *이랑 &가 섞여 나오니까 코드가 아니라 수학 기호 같았습니다. 근데 며칠 동안 이것저것 찍어보면서 한 가지 깨달은 게 있는데, 포인터는 결국 “이 변수에 값이 들어 있나, 주소가 들어 있나”를 구분하는 문제였습니다. 그 구분만 되면 나머지는 따라옵니다.
변수에는 주소가 있다
당연한 얘기인데 처음엔 잘 와닿지 않았습니다. 변수를 선언하면 메모리 어딘가에 공간이 잡히고, 그 공간에는 고유한 주소가 있습니다. & 연산자를 붙이면 그 주소를 볼 수 있어요.
int num = 10;
printf("값: %d\n", num); // 10
printf("주소: %p\n", &num); // 메모리 주소 (예: 0x7fff5fbff6ac)
처음에 %p로 주소를 찍어봤을 때 16진수가 나와서 뭔가 대단한 걸 본 기분이었는데, 사실 그냥 메모리 위치를 숫자로 표현한 것뿐입니다.
포인터 선언 — 여기서 헷갈리기 시작
포인터 변수를 만드는 문법은 이렇습니다.
데이터타입 *포인터변수명;
실제로 쓰면:
int num = 10;
int *ptr; // int형 포인터 선언
ptr = # // num의 주소를 ptr에 저장
printf("num의 값: %d\n", num); // 10
printf("num의 주소: %p\n", &num); // 주소
printf("ptr이 가리키는 값: %d\n", *ptr); // 10
printf("ptr에 저장된 주소: %p\n", ptr); // &num과 동일
여기서 *가 두 가지 역할을 하는 게 헷갈림의 원인이었습니다. 선언할 때 int *ptr의 *는 “이 변수는 포인터입니다”라는 표시이고, 사용할 때 *ptr의 *는 “이 포인터가 가리키는 곳의 값을 가져와”라는 뜻입니다. 같은 기호인데 문맥에 따라 의미가 달라지니까 처음엔 정말 혼란스러웠어요.
저한테 도움이 됐던 건, 코드를 읽을 때 “ptr에는 주소가 들어 있다, *ptr은 그 주소에 가서 값을 꺼내는 것이다” 하고 한 줄씩 말로 풀어보는 방식이었습니다.
역참조로 값 바꾸기
포인터가 가리키는 곳의 값을 읽기만 하는 게 아니라 바꿀 수도 있습니다.
int num = 10;
int *ptr = #
*ptr = 20; // ptr이 가리키는 곳(num)의 값을 20으로 변경
printf("%d\n", num); // 20
num을 직접 건드리지 않았는데 값이 바뀝니다. 처음에 이걸 보고 “이러면 코드 추적하기 어렵지 않나?” 하고 생각했는데, 이게 바로 포인터가 강력한 이유이기도 합니다. 함수 쪽에서 이걸 왜 쓰는지 보면 납득이 됩니다.
포인터 기본 사용 전체 코드
위 내용을 하나로 묶으면 이렇습니다.
#include <stdio.h>
int main() {
int num = 10;
int *ptr = #
printf("=== 포인터 기본 ===\n");
printf("num의 값: %d\n", num);
printf("num의 주소: %p\n", &num);
printf("ptr의 값(주소): %p\n", ptr);
printf("ptr이 가리키는 값: %d\n", *ptr);
// 포인터를 통한 값 변경
*ptr = 20;
printf("\n값 변경 후:\n");
printf("num의 값: %d\n", num);
printf("ptr이 가리키는 값: %d\n", *ptr);
return 0;
}
이 코드를 직접 실행해보면서 ptr과 *ptr의 차이를 손으로 익히는 게 중요합니다. 눈으로만 보면 알 것 같은데 막상 코드를 짜보면 또 헷갈립니다.
포인터가 진짜 필요한 순간: 함수에서 값 바꾸기
포인터가 왜 필요한지 가장 확실하게 보여주는 예제가 swap입니다.
먼저 포인터 없이 해보면:
void swap(int a, int b) {
int temp = a;
a = b;
b = temp;
// 함수 내에서만 값이 바뀜
}
int main() {
int x = 10, y = 20;
swap(x, y);
printf("%d %d\n", x, y); // 10 20 (변하지 않음)
return 0;
}
x, y가 그대로입니다. C에서 함수에 값을 넘기면 복사본이 전달되기 때문에, 함수 안에서 아무리 바꿔도 원본은 안 바뀝니다. 처음에 이 결과를 보고 “분명 swap했는데 왜 안 바뀌지?” 하고 잠시 멍했습니다.
포인터를 쓰면 해결됩니다.
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int main() {
int x = 10, y = 20;
swap(&x, &y);
printf("%d %d\n", x, y); // 20 10 (값이 바뀜)
return 0;
}
주소를 넘겼기 때문에 함수 안에서 원본을 직접 건드릴 수 있는 겁니다. 이걸 보고 나서 “아, 포인터가 이래서 필요하구나” 싶었습니다. 값 복사 vs 주소 전달의 차이를 swap 예제로 보니까 확 와닿았어요.
배열과 포인터의 관계
C에서 배열 이름은 사실 첫 번째 요소의 주소입니다. 이거 처음 들었을 때 좀 신기했습니다.
int arr[5] = {1, 2, 3, 4, 5};
int *ptr = arr; // arr은 &arr[0]과 동일
printf("%d\n", *ptr); // 1 (arr[0])
printf("%d\n", *(ptr + 1)); // 2 (arr[1])
printf("%d\n", *(ptr + 2)); // 3 (arr[2])
// 배열 접근 방법들
printf("%d\n", arr[0]); // 1
printf("%d\n", *(arr + 0)); // 1 (동일)
printf("%d\n", ptr[0]); // 1 (동일)
arr[i]라고 쓰는 게 사실은 *(arr + i)의 편의 표현이라는 걸 여기서 알게 됐습니다. 그래서 포인터에도 ptr[0] 같은 배열 표기를 쓸 수 있는 거고요.
포인터 산술 연산
포인터에 1을 더하면 “메모리에서 1바이트 뒤”가 아니라 “다음 요소”로 이동합니다. int 포인터면 4바이트만큼 움직이는 거죠.
int arr[5] = {10, 20, 30, 40, 50};
int *ptr = arr;
printf("%d\n", *ptr); // 10
printf("%d\n", *(ptr + 1)); // 20
printf("%d\n", *(ptr + 2)); // 30
ptr++; // 다음 요소로 이동
printf("%d\n", *ptr); // 20
이건 처음에 직관적이지 않았습니다. ptr + 1이면 주소값에 1이 더해지는 거 아닌가 싶었는데, 실제로는 자료형 크기만큼 더해집니다. 컴파일러가 포인터의 타입을 알고 있으니까 알아서 계산해주는 겁니다.
NULL 포인터
포인터를 선언만 하고 초기화 안 하면 쓰레기 값이 들어가 있어서 위험합니다. 아직 가리킬 곳이 없을 때는 NULL을 넣어둡니다.
int *ptr = NULL;
if (ptr == NULL) {
printf("포인터가 NULL입니다.\n");
}
// NULL 포인터 역참조는 위험!
// *ptr = 10; // 오류 발생!
NULL 포인터를 역참조하면 프로그램이 터집니다. 그래서 포인터를 쓰기 전에 NULL인지 체크하는 습관을 들이는 게 좋습니다.
이중 포인터
포인터의 주소를 또 다른 포인터에 담을 수 있습니다. 말로 하면 복잡한데 코드를 보면 패턴은 같습니다.
int num = 10;
int *ptr = #
int **pptr = &ptr;
printf("num: %d\n", num); // 10
printf("*ptr: %d\n", *ptr); // 10
printf("**pptr: %d\n", **pptr); // 10
pptr은 ptr의 주소를 갖고 있고, ptr은 num의 주소를 갖고 있으니까, **pptr로 두 단계를 거쳐 num의 값에 도달하는 겁니다. 지금 당장 이걸 어디 쓰나 싶을 수 있는데, 나중에 동적 할당이나 함수에서 포인터 자체를 바꿔야 할 때 등장합니다.
동적 메모리 할당
배열 크기를 미리 정하지 않고 실행 중에 메모리를 잡을 수 있습니다. malloc으로 할당하고 free로 해제합니다.
#include <stdlib.h>
int main() {
int *ptr;
int n = 5;
// 동적 메모리 할당
ptr = (int*)malloc(n * sizeof(int));
if (ptr == NULL) {
printf("메모리 할당 실패\n");
return 1;
}
// 사용
for (int i = 0; i < n; i++) {
ptr[i] = i * 10;
}
// 출력
for (int i = 0; i < n; i++) {
printf("%d ", ptr[i]);
}
// 메모리 해제 (중요!)
free(ptr);
ptr = NULL;
return 0;
}
free 후에 ptr = NULL을 넣는 이유는, 해제된 메모리를 실수로 다시 접근하는 걸 막기 위해서입니다. free만 하고 ptr을 그대로 두면 나중에 그 포인터를 써버릴 위험이 있습니다. 이런 걸 dangling pointer라고 하는데, 버그 잡기가 정말 까다롭다고 합니다.
예제: 배열에서 최대값 찾기
포인터를 함수 인자로 넘기는 연습입니다.
#include <stdio.h>
int findMax(int *arr, int size) {
int max = *arr; // arr[0]
for (int i = 1; i < size; i++) {
if (*(arr + i) > max) {
max = *(arr + i);
}
}
return max;
}
int main() {
int numbers[5] = {5, 2, 8, 1, 9};
int max = findMax(numbers, 5);
printf("최대값: %d\n", max);
return 0;
}
findMax에 배열을 넘기면 사실 첫 번째 요소의 주소가 넘어갑니다. 그래서 함수 매개변수 타입이 int *arr인 거고요. int arr[]로 써도 같은 의미입니다.
예제: 문자열 길이 직접 구하기
strlen 없이 포인터로 문자열 끝을 찾아가는 방식입니다.
#include <stdio.h>
int stringLength(char *str) {
int length = 0;
while (*str != '\0') {
length++;
str++;
}
return length;
}
int main() {
char str[] = "Hello";
printf("길이: %d\n", stringLength(str));
return 0;
}
C에서 문자열은 끝에 \0(널 문자)이 붙어 있어서, 포인터를 하나씩 옮기면서 \0을 만날 때까지 세면 길이가 나옵니다. 이 코드를 짜보면서 “아, C에서 문자열이 이런 식으로 작동하는구나” 하고 조금 감이 잡혔습니다.
포인터 쓸 때 주의할 것들
며칠간 포인터 가지고 놀면서 실수한 것들을 정리하면:
- 포인터를 선언하면 반드시 초기화할 것. 초기화 안 하면 쓰레기 주소가 들어 있어서 뭐가 일어날지 모릅니다.
- 역참조하기 전에 NULL인지 확인할 것.
- 배열 범위 밖을 접근하지 않도록 주의할 것. 컴파일러가 잡아주지 않아서 런타임에 터집니다.
malloc으로 잡은 메모리는 꼭free할 것. 안 하면 메모리 누수가 생깁니다.
여기까지 정리
포인터를 처음 봤을 때는 *이랑 &가 뒤죽박죽으로 느껴졌는데, “이 변수에 값이 들어 있나, 주소가 들어 있나”만 따지면 생각보다 단순한 구조입니다. 저는 코드를 볼 때 ptr에는 주소, *ptr에는 값, 이렇게 한 줄씩 적어가면서 추적하는 게 많이 도움이 됐습니다. 문법을 외우려고 하면 금방 잊어버리는데, 값의 흐름을 따라가다 보면 자연스럽게 익혀지는 것 같습니다.