language/c

[C언어] 09. 포인터 (Pointer)

렁치 2026. 8. 31. 16:28

포인터는 C언어를 어렵게 만드는 동시에 강력하게 만드는 핵심 개념이다. 나도 여기서 한 번 막혔는데, 사실 한 문장으로 요약된다. 포인터는 값이 아니라 "값이 있는 주소"를 담는 변수다.

지금까지 scanf에서 &를 붙였던 것, 배열 이름이 곧 주소였던 것이 모두 이 포인터로 이어졌다.


1. 포인터란?

모든 변수는 메모리 어딘가에 저장되고, 그 위치를 가리키는 주소를 갖는다.

포인터(Pointer)
다른 변수의 주소를 저장하는 변수다. 일반 변수가 "값"을 담는다면, 포인터는 "그 값이 사는 집 주소"를 담는다. 그래서 어떤 타입을 가리키든 포인터 자체의 크기는 일정하다(주소는 결국 하나의 정수이므로).

두 개의 핵심 연산자가 있다.

  • 주소 연산자 &: 변수의 주소를 구한다. &i는 변수 i의 주소.
  • 간접 참조(역참조) 연산자 *: 포인터가 가리키는 곳의 값을 꺼낸다.

int i = 10;
int* p;       // int를 가리키는 포인터 p 선언
p = &i;       // p에 i의 주소를 저장 (p가 i를 가리킨다)
printf("%d\n", *p);   // *p → p가 가리키는 곳의 값 = 10

 

&로 주소를 담고, *로 그 주소의 값을 꺼낸다. 이 둘이 서로 반대 동작을 한다는 걸 잡으니 포인터의 절반은 이해됐다.

 

초기화 안 된 포인터는 절대 쓰지 말 것
선언만 하고 아무 주소도 넣지 않은 포인터(int *p;)에 *p = 100; 을 하면, 어딘지 모를 쓰레기 주소에 값을 써버려 프로그램이 죽거나 다른 데이터를 망가뜨린다. 가리킬 대상이 아직 없으면 NULL로 초기화하는 습관이 안전하다.

 


2. 포인터와 함수 (Call by Reference)

C는 기본적으로 값을 복사해 넘기므로(call by value), 함수 안에서 매개변수를 바꿔도 원본은 그대로다. 그래서 함수가 원본을 바꾸게 하려면 주소를 넘긴다. 이것이 참조에 의한 호출(call by reference)이다.

void swap(int* a, int* b) {        // 주소를 받아서
    int t = *a; *a = *b; *b = t;   // 가리키는 값을 직접 교환
}
// 호출: swap(&x, &y);  → x, y 원본이 실제로 바뀐다

 

앞서 함수 편에서 swap이 안 됐던 게 값 복사 때문이었는데, 주소를 넘기니 원본이 진짜로 바뀌었다. scanf("%d", &n)에서 &를 붙인 이유도 결국 이것이었다. scanf가 입력값을 n에 직접 써넣으려면 n의 주소를 알아야 하니까. 흩어져 있던 &들이 여기서 하나로 모였다.

지역 변수의 주소를 반환하지 말 것
함수 안에서 선언한 지역 변수는 함수가 끝나면 사라진다. 그 변수의 주소를 return하면, 이미 사라진 메모리를 가리키는 위험한 포인터(dangling pointer)가 된다. 포인터를 반환하려면 함수가 끝나도 살아 있는 메모리(동적 할당 등)를 가리켜야 한다.

 


3. 포인터의 증감 연산

 

포인터를 1 증가시키면 주소가 1바이트 늘어나는 게 아니라, 가리키는 타입의 크기만큼 이동한다. int 포인터는 4씩, char 포인터는 1씩 움직인다. 그래야 다음 요소를 정확히 가리키기 때문이다.

*++가 섞이면 우선순위 때문에 헷갈리기 쉽다.

 

수식 의미
*p++ p가 가리키는 값을 쓴 뒤, p를 증가(다음 칸으로)
(*p)++ p가 가리키는 값을 쓴 뒤, 그 값을 증가
*++p p를 먼저 증가시킨 뒤, 가리키는 값을 사용

괄호로 의도를 분명히 하는 게 안전하다.


4. 포인터와 배열

 

배열과 포인터는 사실상 한 몸이다. 배열 이름은 첫 요소의 주소이기 때문이다.

 

a[i] 와 *(a + i) 는 같다
배열의 인덱스 표기 a[i]는 컴파일러가 내부적으로 *(a + i)로 바꿔 처리한다. "시작 주소 a에서 i칸 이동한 곳의 값"이라는 뜻이다. 그래서 포인터로도 배열을 똑같이 다룰 수 있고, 인덱스 표기를 포인터에 쓸 수도 있다.

 

int a[5] = {10, 20, 30, 40, 50};
int* p = a;                 // 배열 이름 = 첫 요소 주소
printf("%d\n", *(p + 2));   // a[2] = 30
printf("%d\n", p[2]);       // 포인터에도 인덱스 표기 가능 → 30

 

배열 편에서 "배열 이름이 주소"라던 게 여기서 a[i] == *(a+i)로 확인되니, 두 글이 하나로 이어졌다.


5. 포인터는 어디에 쓰는가

포인터가 왜 중요한지는 그것이 열어주는 일들을 보니 분명해졌다.

  • 함수에서 여러 결과 반환: 반환값은 하나지만, 주소를 여러 개 넘기면 함수가 여러 변수를 채워줄 수 있다(결과값 + 오류 코드 등).
  • 동적 메모리 할당: 실행 중에 크기를 정해 메모리를 빌리려면 그 주소를 담을 포인터가 반드시 필요하다.
  • 자료구조: 연결 리스트, 트리처럼 노드들이 서로를 가리키는 구조는 포인터 없이는 만들 수 없다.

즉 포인터는 그 자체가 목적이라기보다, 더 정교한 프로그래밍으로 가는 관문이다.


6. 수업 코드로 보는 배열 주소 전달

call_by_pointer_ex.c는 배열을 함수에 넘겨 입력과 출력을 나누는 예제였다. 배열 이름은 첫 요소의 주소이므로, 함수 안에서 iArray[iIndex]에 값을 넣으면 main의 원본 배열도 채워진다.

 

#define SIZE 10

void getArray(int iArray[], int iArraySize);
void printArray(int iArray[], int iArraySize);

int main(void)
{
    int iArray[SIZE];
    int iArraySize;

    iArraySize = SIZE;

    getArray(iArray, iArraySize);
    printArray(iArray, iArraySize);

    return 0;
}

void getArray(int iArray[], int iArraySize)
{
    int iIndex;

    printf("정수 %d개 입력: ", iArraySize);

    for (iIndex = 0; iIndex < iArraySize; iIndex++)
    {
        scanf("%d", &iArray[iIndex]);
    }
}

void printArray(int iArray[], int iArraySize)
{
    int iIndex;

    printf("{ ");

    for (iIndex = 0; iIndex < iArraySize; iIndex++)
    {
        printf("%d ", iArray[iIndex]);
    }

    printf("}\n");
}

 

여기서 getArray(iArray, iArraySize)는 배열 전체를 복사해서 넘기는 게 아니다. iArray[0]의 주소가 넘어간다. 그래서 함수 안의 scanf("%d", &iArray[iIndex])가 main의 원본 배열 칸에 직접 값을 넣는다.

 

포인터 swap과 비교

void swapValue(int iFirst, int iSecond)
{
    int iTemp;

    iTemp = iFirst;
    iFirst = iSecond;
    iSecond = iTemp;
}

void swapPointer(int* pFirst, int* pSecond)
{
    int iTemp;

    iTemp = *pFirst;
    *pFirst = *pSecond;
    *pSecond = iTemp;
}

 

swapValue(iA, iB)는 복사본만 바꾸고, swapPointer(&iA, &iB)는 원본 주소를 받아 실제 값을 바꾼다. 배열 전달도 이 원리와 연결된다.


오늘 느낀 점

  • "포인터 = 값이 아니라 주소를 담는 변수"라는 한 문장에 &*를 붙이니, 그동안 따로 외우던 scanf의 &, 배열 이름의 정체, call by reference가 전부 한 갈래로 모였다.
  • swap이 값 복사로는 안 되다가 주소로는 되는 걸 직접 보고 나서야, call by value와 call by reference의 차이가 진짜로 와닿았다.
  • 포인터가 어려운 게 "한 단계 간접적"이기 때문이라는 걸 인정하고, 종이에 변수·주소·값을 그려가며 따라가니 그 간접의 층이 눈에 보이기 시작했다.

한 걸음 더

  • 포인터가 가리키는 타입을 선언에 적는 이유(int*, double*)는 두 가지다. 역참조(*p)할 때 몇 바이트를 읽을지, 그리고 증감(p++)할 때 몇 바이트 이동할지를 컴파일러가 알아야 하기 때문이다. 타입을 잘못 맞추면 메모리를 엉뚱하게 해석한다.
  • int* p에서 *를 타입 쪽에 붙이느냐 변수 쪽에 붙이느냐는 취향 차이지만, int* p, q;라고 쓰면 q는 포인터가 아니라 일반 int라는 함정이 있다. 여러 포인터를 한 줄에 선언할 땐 각각 *를 붙여야 한다.
  • 포인터가 어렵게 느껴지는 건 "한 단계 간접적"이기 때문이다. 값을 직접 다루지 않고 "값의 위치를 다룬다"는 추상이 한 겹 더 있는 것이다. 종이에 변수·주소·값을 그려가며 따라가 보면, 이 간접의 층이 눈에 보이기 시작하면서 갑자기 쉬워지는 순간이 온다.

'language > c' 카테고리의 다른 글

[C언어] 08. 문자열 (String)  (0) 2026.08.24
[C언어] 07. 배열 (Array)  (0) 2026.08.17
[C언어] 06. 함수 (Function)  (0) 2026.08.10
[C언어] 05. 반복문  (0) 2026.08.03
[C언어] 04. 조건문  (0) 2026.07.27