IT /C언어 강의

C언어 강의 8편: 포인터 기초

· 7분 읽기
#C언어 #포인터 #pointer #메모리 #주소

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 = &num;
    
    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 = &num;
int **pptr = &ptr;

printf("num: %d\n", num);           // 10
printf("*ptr: %d\n", *ptr);         // 10
printf("**pptr: %d\n", **pptr);     // 10

pptrptr의 주소를 갖고 있고, ptrnum의 주소를 갖고 있으니까, **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에서 문자열이 이런 식으로 작동하는구나” 하고 조금 감이 잡혔습니다.

포인터 쓸 때 주의할 것들

며칠간 포인터 가지고 놀면서 실수한 것들을 정리하면:

  1. 포인터를 선언하면 반드시 초기화할 것. 초기화 안 하면 쓰레기 주소가 들어 있어서 뭐가 일어날지 모릅니다.
  2. 역참조하기 전에 NULL인지 확인할 것.
  3. 배열 범위 밖을 접근하지 않도록 주의할 것. 컴파일러가 잡아주지 않아서 런타임에 터집니다.
  4. malloc으로 잡은 메모리는 꼭 free할 것. 안 하면 메모리 누수가 생깁니다.

여기까지 정리

포인터를 처음 봤을 때는 *이랑 &가 뒤죽박죽으로 느껴졌는데, “이 변수에 값이 들어 있나, 주소가 들어 있나”만 따지면 생각보다 단순한 구조입니다. 저는 코드를 볼 때 ptr에는 주소, *ptr에는 값, 이렇게 한 줄씩 적어가면서 추적하는 게 많이 도움이 됐습니다. 문법을 외우려고 하면 금방 잊어버리는데, 값의 흐름을 따라가다 보면 자연스럽게 익혀지는 것 같습니다.