cheoly's language study blog

C 언어 포인터 완벽 정리 – 이 글 하나로 끝내기

프로그래밍/C언어
반응형
SMALL

파이썬만 쓰다가 다시 C 언어를 보면 제일 먼저 막히는 게 바로 포인터(pointer)다.
개념만 애매하게 잡고 넘어가면, 나중에 구조체·동적 메모리·파일 입출력·함수 포인터에서 계속 발목을 잡는다.

이 글에서는

  • 포인터가 메모리에서 어떻게 동작하는지
  • *, &, 배열, malloc, NULL과의 관계
  • 자주 터지는 실수와 디버깅 포인트

까지 한 번에 정리해본다.

C 언어 포인터를 설명하는 일러스트로, 큰 C 로고와 코드 창에 'int p' 문구가 표시되어 있으며 포인터 구조를 나타내는 다이어그램이 포함된 그래픽


1. 포인터란 무엇인가?

포인터 = “값” 대신 “주소”를 저장하는 변수

일반 변수는 값(value) 을 저장하고,
포인터 변수는 값이 저장된 메모리 주소(address) 를 저장한다.

int x = 10;      // 값 10을 저장하는 int 변수
int *p = &x;     // x가 저장된 "주소"를 저장하는 포인터 변수
  • &x : 변수 x주소
  • p : 그 주소를 저장하는 포인터 변수
  • *p : 포인터 p가 가리키는 주소에 있는

즉,

  • x → 값: 10
  • &x → 주소: 예) 0x1000
  • p → 주소: 0x1000
  • *p → 값: 10

2. 포인터 선언과 기본 문법

2-1. 기본 선언

int *p;      // int 를 가리키는 포인터
char *cp;    // char 를 가리키는 포인터
double *dp;  // double 을 가리키는 포인터
  • *“포인터 타입이다” 라는 의미
  • int *p, q; 처럼 쓰면 p만 포인터고, q는 그냥 int 이다 → 조심!
int* p, q;   // 혼동의 시작: p는 int*, q는 int
int *p, *q;  // 둘 다 포인터

2-2. 포인터에 주소 대입

int x = 42;
int *p = &x;   // x의 주소를 p에 저장
  • 항상 같은 타입끼리 맞춰야 한다.
  • int* 포인터에는 int 변수의 주소만 넣는다.

3. &* 정확히 이해하기

3-1. & 연산자: 주소 연산자

int x = 10;
printf("%p\n", (void*)&x);  // x가 저장된 주소 출력
  • &x : 변수 x메모리에 있는 위치

3-2. * 연산자: 간접 참조(역참조)

int x = 10;
int *p = &x;

printf("%d\n", *p);  // 10
*p = 20;             // x의 값을 20으로 변경
printf("%d\n", x);   // 20
  • p는 “주소”를 저장
  • *p는 그 주소에 있는 “값”에 접근

✔ 포인터를 사용할 때 항상 “지금 이게 주소인지, 인지”를 구분해서 생각하면 헷갈림이 많이 줄어든다.


4. 포인터와 배열의 관계

C에서 배열 이름은 상수 포인터 비슷하게 동작한다.

int arr[3] = {10, 20, 30};

printf("%p\n", (void*)arr);     // arr[0]의 주소
printf("%p\n", (void*)&arr[0]); // 동일한 주소

4-1. 배열 → 포인터 변환

int *p = arr;    // 사실은 &arr[0] 와 같은 의미
printf("%d\n", p[1]);    // 20
printf("%d\n", *(p + 1)); // 20
  • arr[i]*(arr + i)
  • p[i]*(p + i)

4-2. 하지만 배열과 포인터는 완전히 같지는 않다

int arr[3] = {1, 2, 3};
int *p = arr;

sizeof(arr); // 3 * sizeof(int) => 배열 전체 크기
sizeof(p);   // 포인터 변수 크기 (보통 4 또는 8바이트)
  • 배열: 메모리에 연속된 공간 + 크기가 컴파일 시에 결정
  • 포인터: 그 공간을 가리키는 주소만 저장

5. 포인터 연산(pointer arithmetic)

포인터에 +1을 하면 주소가 1 증가하는 게 아니라
타입 크기만큼 이동한다.

int arr[3] = {10, 20, 30};
int *p = arr;

printf("%d\n", *p);       // 10
printf("%d\n", *(p + 1)); // 20
printf("%d\n", *(p + 2)); // 30
  • 만약 sizeof(int) == 4라면,
    p + 1 은 실제 주소로 +4 만큼 이동한 것이다.

✔ 그래서 포인터 연산은 배열 순회에 매우 유용하다.


6. NULL 포인터와 초기화

포인터는 사용 전에 반드시 초기화해야 한다.
초기화되지 않은 포인터는 “쓰레기 주소”를 가리키며,
접근 시 바로 세그멘테이션 폴트(segmentation fault) 로 이어질 수 있다.

int *p = NULL;   // 초기화
if (p == NULL) {
    // 아직 유효한 주소가 없음
}
  • NULL 은 “아무 것도 가리키지 않는다”는 의미의 특별한 주소 값
  • 동적 할당 실패 시에도 보통 NULL을 반환한다.

7. 동적 메모리와 포인터 (malloc, free)

힙(heap) 영역에 메모리를 할당할 때 포인터가 필수로 등장한다.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof(int) * 3);  // int 3개 공간 할당

    if (p == NULL) {
        // 메모리 할당 실패
        return 1;
    }

    p[0] = 10;
    p[1] = 20;
    p[2] = 30;

    printf("%d %d %d\n", p[0], p[1], p[2]);

    free(p);   // 반드시 해제!
    return 0;
}
  • malloc : 메모리를 할당하고 그 첫 번째 바이트의 주소를 반환
  • free : 더 이상 사용하지 않을 때 반드시 해제해야 함
  • malloc 으로 받은 주소를 잃어버리면 → 메모리 누수(memory leak)

8. 이중 포인터(double pointer) 맛보기

이중 포인터는 “포인터를 가리키는 포인터”다.

int x = 10;
int *p = &x;   // x를 가리키는 포인터
int **pp = &p; // p를 가리키는 포인터

printf("%d\n", **pp);   // 10

언제 쓰냐?

  • 포인터를 함수 인자로 넘겨서 그 포인터 자체를 변경하고 싶을 때
  • 2차원 배열처럼 데이터를 관리할 때
  • 동적 할당된 배열의 배열을 다룰 때

이건 글이 길어지니, 이중 포인터만 따로 글 하나를 파도 될 정도다.


9. 함수 인자와 포인터: call by value vs call by reference 흉내

C는 무조건 call by value지만, 포인터를 사용해서
마치 “참조 호출”처럼 동작하게 만들 수 있다.

#include <stdio.h>

void add_one(int *p) {
    (*p)++;
}

int main(void) {
    int x = 10;
    add_one(&x);    // x의 주소를 넘김
    printf("%d\n", x);  // 11
    return 0;
}
  • 함수는 p라는 포인터의 복사본을 받지만
  • 그 포인터가 가리키는 원래 변수 x를 직접 수정할 수 있다.

파이썬의 “mutable 객체를 함수에서 수정하는 느낌”과 비슷하다고 보면 이해가 편하다.


10. 포인터에서 자주 하는 실수들

10-1. 초기화 안 된 포인터 사용

int *p;       // 초기화 안 됨 (쓰레기 값)
*p = 10;      // 세그폴트 가능성 100%

→ 반드시 NULL 또는 유효한 주소로 초기화.


10-2. 해제 후 포인터를 계속 사용 (use-after-free)

int *p = malloc(sizeof(int));
*p = 10;
free(p);
printf("%d\n", *p);   // 정의되지 않은 동작(UB)

free(p); p = NULL; 패턴을 습관처럼 넣는 게 좋다.


10-3. 배열 범위 밖 접근

int arr[3] = {1, 2, 3};
int *p = arr;

printf("%d\n", p[3]);  // 쓰지 말 것! 범위 밖 접근

→ C는 범위 체크를 안 해준다.
디버깅 어려운 버그의 주요 원인.


10-4. 잘못된 캐스팅

int x = 10;
double *dp = (double*)&x;  // 타입이 전혀 다른 포인터

이런 코드는 가능하면 쓰지 않는 게 좋다.
메모리 레이아웃을 완전히 이해하고 쓸 필요가 있을 때만, 조심해서 사용.


11. 포인터를 진짜 이해했는지 스스로 체크해보기

아래 질문들에 스스로 답해볼 수 있다면,
포인터는 “완벽까지는 아니어도 꽤 잘 이해한 상태”다.

  1. int *pint (*p)[3] 의 차이를 설명할 수 있는가?
  2. arr, &arr[0], &arr 의 차이를 말할 수 있는가?
  3. malloc으로 2차원 배열을 만드는 코드를 직접 짤 수 있는가?
  4. NULL 포인터와 초기화되지 않은 포인터의 차이를 구분하는가?
  5. 함수 인자로 포인터를 넘겨서, 원래 변수 값을 바꾸는 코드를 직접 짤 수 있는가?

이 글에서 다룬 것은 그 중에서도 가장 기초이자 필수적인 부분이다.
다음 글에서는 이 내용을 바탕으로

  • 이중 포인터로 2차원 배열 다루기
  • 함수 포인터와 콜백 구조

를 이어서 정리해볼 예정이다.

 

 

반응형
LIST