Skip to main content

포인터 입문: 주소와 메모리 이해하기

포인터는 C 언어를 배우는 사람에게 가장 신기하면서도 가장 무서운 주제입니다. 처음 보면 *, &, 주소, 메모리 같은 말이 한꺼번에 튀어나와서 "갑자기 컴퓨터 속을 해부하는 느낌인데?" 싶을 수 있습니다.

그런데 포인터를 너무 어렵게 겁먹을 필요는 없습니다. 핵심은 단순합니다. 변수는 값을 담고, 포인터는 그 값이 들어 있는 위치를 담는다는 것입니다.12

이 차이를 이해하는 순간, C 언어가 왜 빠르고 강력한지 보이기 시작합니다. 동시에 왜 조금만 방심해도 프로그램이 이상하게 동작하는지도 함께 보이게 됩니다. 이번 장에서는 포인터를 처음 배우는 사람의 눈높이에서, 주소와 메모리라는 개념부터 차근차근 연결해 보겠습니다.

C Pointers (With Examples)

이미지 출처: C Pointers (With Examples)

변수는 값, 포인터는 주소

지금까지 우리는 이런 코드를 자연스럽게 써 왔습니다.

int age = 20;
printf("%dn", age);

여기서 age는 20이라는 값을 저장하는 변수입니다. 그런데 컴퓨터는 이 값을 공중에 띄워 두지 않습니다. 메모리 어딘가에 저장합니다. 그리고 그 저장 위치에는 주소(address)가 있습니다.1

쉽게 말해 메모리를 아주 큰 사물함 건물이라고 생각해 봅시다. 변수는 사물함 안의 내용물이고, 주소는 그 사물함의 위치 번호입니다. 포인터는 내용물 자체가 아니라 위치 번호를 적어 둔 쪽지에 가깝습니다.

예를 들어 age라는 변수가 있다면, age에는 값 20이 들어 있고, &age는 age가 저장된 메모리 주소를 뜻합니다.12

#include <stdio.h>

int main(void) {
    int age = 20;

    printf("age의 값: %dn", age);
    printf("age의 주소: %pn", (void *)&age);

    return 0;
}

실행할 때 주소는 0x7ffee...처럼 생긴 값으로 보일 수 있습니다. 이 값은 실행할 때마다 달라질 수 있으니, 숫자 자체를 외울 필요는 없습니다. 중요한 것은 변수에는 값이 있고, 그 변수는 메모리 어딘가에 살고 있으며, 우리는 그 주소를 구할 수 있다는 사실입니다.2

주소를 구하는 연산자 &

변수의 주소를 얻을 때는 & 연산자를 사용합니다. 이것을 주소 연산자(address-of operator)라고 부릅니다.15

int score = 95;
printf("%pn", (void *)&score);

score는 값이고, &score는 주소입니다. 둘은 완전히 다른 것입니다.

이 개념은 사실 이미 한 번 만난 적이 있습니다. 바로 scanf입니다.

int num;
scanf("%d", &num);

왜 scanf에 num이 아니라 &num을 넣었을까요? scanf는 "값을 저장할 위치"가 필요하기 때문입니다. 즉, num의 값이 아니라 num이 있는 주소를 받아야 그곳에 사용자가 입력한 값을 써 넣을 수 있습니다.1

포인터를 배우기 시작하면 scanf가 갑자기 더 논리적으로 보입니다. 이제는 "그냥 외우는 문법"이 아니라 "주소를 넘기는 동작"으로 이해할 수 있습니다.

포인터는 어떻게 선언할까

포인터는 다른 변수의 주소를 저장하는 변수입니다. 선언할 때는 자료형 뒤에 *를 붙입니다.25

int *ptr;

이 코드는 ptr이라는 이름의 포인터를 선언한 것입니다. 그리고 ptr은 정수형 변수의 주소를 저장하는 포인터입니다.

여기서 중요한 점이 하나 있습니다. 포인터도 자료형이 있다는 것입니다. int *는 정수형을 가리키는 포인터이고, char *는 문자형을 가리키는 포인터입니다. 가리키는 대상의 자료형과 포인터의 자료형이 맞아야 합니다.5

int num = 10;
int *ptr = &num;

이제 ptr에는 num의 주소가 들어 있습니다.

그림처럼 생각하면 이렇습니다.

  • num에는 10이 들어 있다.

  • ptr에는 num이 있는 주소가 들어 있다.

  • 그래서 ptr은 num을 가리킨다.

초보자가 자주 헷갈리는 부분도 있습니다.

int *p1, p2;

이렇게 쓰면 p1만 포인터이고 p2는 그냥 일반 int 변수입니다.1 그래서 처음에는 아래처럼 쓰는 습관이 더 안전합니다.

int *p1;
int p2;

코드가 조금 길어져도 실수를 줄이는 쪽이 초보자에게는 훨씬 좋습니다.

포인터에 주소 저장하기

포인터는 주소를 담는 변수이므로, 값이 아니라 주소를 넣어야 합니다.

#include <stdio.h>

int main(void) {
    int num = 10;
    int *ptr = &num;

    printf("num의 값: %dn", num);
    printf("num의 주소: %pn", (void *)&num);
    printf("ptr에 저장된 값: %pn", (void *)ptr);

    return 0;
}

여기서 ptr에 저장된 값과 &num은 같은 주소가 됩니다. 즉, 포인터를 출력하면 "포인터가 가리키는 변수의 주소"가 보입니다.2

한편 아래 코드는 잘못된 코드입니다.

int num = 10;
int *ptr;
ptr = num;   // 잘못된 예

왜냐하면 num은 정수 값이고, ptr은 주소를 저장해야 하는 포인터이기 때문입니다. 값과 주소를 섞어 버리면 타입도 맞지 않고, 의미도 맞지 않습니다.1

포인터를 배울 때는 늘 이 질문을 습관처럼 던지면 좋습니다.

"지금 내가 다루는 것은 값인가, 주소인가?"

이 질문 하나만 잘 던져도 포인터 실수가 크게 줄어듭니다.

역참조 *: 주소를 따라가 실제 값에 닿기

포인터가 주소를 들고 있다면, 그 주소에 들어 있는 실제 값을 보고 싶을 때가 있습니다. 이때 사용하는 것이 * 연산자입니다. 이것을 역참조(dereference)라고 부릅니다.26

#include <stdio.h>

int main(void) {
    int num = 10;
    int *ptr = &num;

    printf("ptr이 저장한 주소: %pn", (void *)ptr);
    printf("그 주소에 있는 값: %dn", *ptr);

    return 0;
}

여기서 ptr은 주소이고, *ptr은 그 주소에 있는 실제 값입니다. 즉 *ptr은 결국 num과 같은 값을 뜻합니다.26

많은 사람이 여기서 * 때문에 혼란을 느낍니다. 왜냐하면 *가 두 가지 역할을 하기 때문입니다.

int *ptr;에서는 "이 변수는 포인터다"라는 뜻으로 쓰입니다. 반면 *ptr에서는 "ptr이 가리키는 곳의 값을 가져와라"라는 뜻으로 쓰입니다.26

기호는 같지만 역할은 다릅니다. 선언할 때의 *와 사용할 때의 *를 따로 생각하면 훨씬 덜 헷갈립니다.

포인터로 값을 바꾸기

포인터의 진짜 매력은 단순히 값을 읽는 데서 끝나지 않습니다. 가리키는 대상의 값을 바꿀 수 있다는 점이 핵심입니다.16

#include <stdio.h>

int main(void) {
    int num = 10;
    int *ptr = &num;

    *ptr = 30;

    printf("num의 값: %dn", num);
    printf("*ptr의 값: %dn", *ptr);

    return 0;
}

이 코드에서 *ptr = 30;은 "ptr이 가리키는 주소로 가서 그곳의 값을 30으로 바꿔라"라는 뜻입니다. 결국 num의 값이 30으로 바뀝니다.16

이게 왜 중요할까요? 포인터를 사용하면 다른 함수에 변수의 주소를 넘겨서, 그 함수가 원래 변수 자체를 수정하게 만들 수 있습니다. C 언어에서 함수가 여러 값을 바꾸거나, 큰 데이터를 복사하지 않고 효율적으로 다루는 데 포인터가 많이 쓰이는 이유가 바로 여기에 있습니다.5

값과 주소, 포인터와 역참조를 한 번에 정리하기

포인터를 처음 배울 때는 아래 네 가지를 구분하는 것이 가장 중요합니다.

  • num은 값이다.

  • &num은 num의 주소다.

  • ptr은 주소를 저장하는 포인터 변수다.

  • *ptr은 ptr이 가리키는 곳의 값이다.

같은 예제를 다시 보면 이렇게 읽을 수 있습니다.

int num = 10;
int *ptr = &num;

첫 줄은 정수 변수 num에 10을 넣은 것입니다. 둘째 줄은 num의 주소를 ptr에 저장한 것입니다. 그래서 ptr은 num을 가리키고, *ptr은 num의 값과 같아집니다.

이 감각이 생기면 포인터는 갑자기 훨씬 덜 무서워집니다. 포인터는 마법이 아니라, 주소를 저장하고 그 주소를 따라가 값을 읽거나 바꾸는 도구일 뿐입니다.

포인터를 왜 배워야 할까

"그냥 변수만 써도 되는데 왜 굳이 주소까지 배워야 하지?"라는 생각이 들 수 있습니다. 아주 자연스러운 질문입니다.

포인터를 배우는 이유는 C 언어가 메모리를 직접 가깝게 다루는 언어이기 때문입니다. 포인터를 이해하면 다음과 같은 장면이 자연스럽게 이어집니다.35

  • 함수가 원래 변수의 값을 직접 수정할 수 있다.

  • 배열과 문자열을 더 효율적으로 다룰 수 있다.

  • 나중에 배울 동적 메모리 할당을 이해할 수 있다.

  • 연결 리스트, 트리 같은 자료구조를 만들 수 있다.

즉, 포인터는 C 언어의 선택 과목이 아니라 핵심 과목에 가깝습니다. 포인터를 모르면 C를 겉으로만 쓰게 되고, 포인터를 이해하면 C가 가진 힘이 보이기 시작합니다.

포인터는 조심해야 한다

강력한 도구는 늘 조심해서 써야 합니다. 포인터도 마찬가지입니다. 잘못 쓰면 엉뚱한 메모리를 건드릴 수 있고, 프로그램이 갑자기 종료되거나 이상한 값을 만들어 낼 수 있습니다.3

가장 먼저 조심할 것은 초기화하지 않은 포인터입니다.

int *ptr;
*ptr = 10;   // 매우 위험

이 코드는 ptr이 어디를 가리키는지 정하지도 않았는데, 거기에 값을 쓰려고 합니다. 이런 포인터를 흔히 초기화되지 않은 포인터 또는 와일드 포인터라고 부릅니다.2 어디로 날아갈지 모르는 화살 같은 존재라서 매우 위험합니다.

그래서 아직 가리킬 대상이 없다면 이렇게 시작하는 습관이 좋습니다.

int *ptr = NULL;

NULL은 "아직 아무 것도 가리키지 않는다"는 뜻입니다.2 그리고 NULL인 포인터를 함부로 역참조하면 안 됩니다.

int *ptr = NULL;
printf("%dn", *ptr);   // 잘못된 사용

NULL은 안전한 시작점이지만, 안전하게 쓸 수 있다는 뜻은 아닙니다. 쓸모 없는 주소 대신, 일부러 비어 있다고 표시해 둔 상태라고 이해하면 됩니다.

포인터의 크기와 자료형

포인터는 주소를 저장하므로, 포인터 변수의 크기는 보통 가리키는 자료형보다 컴퓨터 환경의 구조에 영향을 더 많이 받습니다. 같은 시스템에서는 int *, char *, float *의 크기가 대체로 같습니다.2

예를 들어 어떤 환경에서는 모든 포인터가 8바이트일 수 있습니다.

#include <stdio.h>

int main(void) {
    int *ip;
    char *cp;

    printf("int 포인터 크기: %zun", sizeof(ip));
    printf("char 포인터 크기: %zun", sizeof(cp));

    return 0;
}

그런데 "크기가 같다면 자료형은 왜 중요하지?"라는 의문이 생길 수 있습니다. 이유는 포인터가 얼마나 이동해야 하는지, 그리고 그 주소의 값을 어떤 형식으로 읽어야 하는지를 자료형이 결정하기 때문입니다.5

즉, 포인터는 주소만 저장하지만, 그 주소를 어떻게 해석할지는 자료형이 알려 줍니다.

배열과 포인터는 아주 가까운 사이다

이 장의 가장 중요한 주제 중 하나가 바로 이것입니다. C 언어에서 배열과 포인터는 아주 밀접하게 연결되어 있습니다.47

배열 이름은 많은 상황에서 첫 번째 원소의 주소처럼 동작합니다.47

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

이때 다음 두 표현은 같은 위치를 가리킵니다.

arr
&arr[0]

그리고 첫 번째 원소의 값도 이렇게 쓸 수 있습니다.

arr[0]
*arr

즉, arr은 첫 번째 원소를 가리키는 것처럼 동작하고, *arr은 그 첫 번째 원소의 값을 뜻합니다.47

이게 처음엔 꽤 낯설 수 있습니다. 배열은 여러 값을 담는 묶음인데, 왜 포인터처럼 행동할까요? 이유는 C가 배열을 메모리의 연속된 공간으로 다루기 때문입니다. 배열의 시작 위치만 알면, 그 뒤 원소들에도 차례대로 접근할 수 있습니다.

배열 원소를 포인터로 읽기

배열과 포인터의 관계는 예제로 보면 훨씬 잘 보입니다.

#include <stdio.h>

int main(void) {
    int arr[5] = {10, 20, 30, 40, 50};
    int *ptr = arr;

    printf("arr[0] = %dn", arr[0]);
    printf("*ptr = %dn", *ptr);
    printf("*(ptr + 1) = %dn", *(ptr + 1));
    printf("arr[1] = %dn", arr[1]);

    return 0;
}

여기서 ptr = arr;은 ptr = &arr[0];과 거의 같은 뜻입니다.47

그래서 다음이 서로 대응됩니다.

  • arr[0] ↔ *ptr

  • arr[1] ↔ *(ptr + 1)

  • arr[2] ↔ *(ptr + 2)

조금 더 일반화하면 아래 공식처럼 볼 수 있습니다.

arr[i] == *(arr + i)

이 식은 C에서 정말 중요합니다. 배열 인덱스 표기와 포인터 표기가 사실상 연결되어 있다는 뜻이기 때문입니다.48

포인터 연산은 1칸이 아니라 원소 크기만큼 움직인다

ptr + 1을 보면 주소에 1을 더하는 것 같지만, 실제로는 가리키는 자료형의 크기만큼 이동합니다.57

예를 들어 int *ptr이라면 ptr + 1은 보통 4바이트 뒤의 정수 한 칸을 가리키게 됩니다. char *ptr이라면 보통 1바이트 뒤로 갑니다.

이런 성질 때문에 포인터는 배열 순회에 아주 잘 맞습니다.

#include <stdio.h>

int main(void) {
    int arr[5] = {1, 2, 3, 4, 5};
    int *ptr = arr;

    for (int i = 0; i < 5; i++) {
        printf("%d ", *(ptr + i));
    }
    printf("n");

    return 0;
}

이 코드는 배열 인덱스를 직접 쓰지 않고도 배열 전체를 출력합니다. 물론 초보자에게는 아직 arr[i]가 더 읽기 쉬울 수 있습니다. 하지만 포인터 방식도 결국 같은 데이터를 다른 시각으로 보는 것이라는 점을 알아 두면 좋습니다.

함수에 배열을 넘길 때 왜 크기도 같이 넘길까

이전 장에서 배열을 함수에 넘길 때 크기도 함께 넘겨야 한다고 배웠습니다. 이제 그 이유를 조금 더 이해할 수 있습니다.

C에서 배열을 함수에 넘기면, 많은 경우 배열의 첫 번째 원소 주소가 전달되는 형태로 다뤄집니다.4 그래서 함수 안에서는 배열 전체 크기를 자동으로 알기 어렵습니다.

#include <stdio.h>

void print_array(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        printf("%d ", arr[i]);
    }
    printf("n");
}

int main(void) {
    int numbers[] = {3, 6, 9, 12, 15};
    int size = sizeof(numbers) / sizeof(numbers[0]);

    print_array(numbers, size);
    return 0;
}

함수 매개변수 int arr[]는 실질적으로 int *arr처럼 동작합니다.4 그래서 함수 쪽에서는 "몇 개짜리 배열인지" 정보를 따로 받아야 합니다.

이제야 이전 장의 규칙이 포인터와 자연스럽게 연결됩니다. C에서는 배열과 포인터가 아주 가까워서, 배열을 넘긴다는 말은 종종 "배열의 시작 주소를 넘긴다"는 뜻에 가깝습니다.

배열 이름은 포인터와 같을까

여기서 아주 중요한 차이가 하나 있습니다. 배열 이름은 많은 상황에서 포인터처럼 동작하지만, 배열과 포인터는 완전히 같은 것은 아닙니다.48

예를 들어 이런 코드는 안 됩니다.

int arr[3] = {1, 2, 3};
arr++;   // 불가능

배열 이름은 시작 위치처럼 쓰일 수는 있지만, 일반 포인터 변수처럼 다른 곳을 가리키도록 바꿀 수는 없습니다.8

반면 포인터 변수는 가능합니다.

int arr[3] = {1, 2, 3};
int *ptr = arr;
ptr++;   // 가능

즉, 배열은 배열이고 포인터는 포인터입니다. 다만 C가 배열 이름을 자주 첫 원소의 주소처럼 취급하기 때문에 둘이 아주 가깝게 보일 뿐입니다.

이 차이를 이해하면 나중에 더 복잡한 포인터 문법을 배울 때 큰 도움이 됩니다.

초보자가 자주 하는 포인터 실수

포인터는 개념보다 실수에서 더 많이 배웁니다. 하지만 매번 프로그램을 터뜨리며 배울 필요는 없습니다. 자주 나오는 실수를 미리 알아두면 훨씬 편합니다.

초기화하지 않은 포인터를 바로 사용하기

int *ptr;
*ptr = 100;

어디를 가리키는지 정하지도 않고 사용하면 매우 위험합니다.

NULL 포인터를 역참조하기

int *ptr = NULL;
printf("%dn", *ptr);

비어 있는 주소를 따라가려 하면 프로그램이 종료될 수 있습니다.2

값과 주소를 헷갈리기

int num = 5;
int *ptr = &num;

포인터에는 주소를 넣어야 합니다. num과 &num의 차이를 계속 의식하는 습관이 중요합니다.

포인터가 가리키는 범위를 벗어나기

배열과 연결되면 이 실수가 특히 잘 나옵니다.

int arr[3] = {1, 2, 3};
int *ptr = arr;
printf("%dn", *(ptr + 5));   // 잘못된 접근

배열 범위를 넘는 위치를 읽거나 쓰면 정의되지 않은 동작이 발생할 수 있습니다. 겉보기에는 가끔 돌아가 보여도 믿으면 안 됩니다.3

포인터를 이해했다는 신호

포인터를 처음 배우는 단계에서는 아래 문장을 자연스럽게 설명할 수 있으면 아주 잘하고 있는 것입니다.

  • 변수는 값을 저장한다.

  • &변수는 그 변수의 주소다.

  • 포인터는 주소를 저장한다.

  • *포인터는 그 주소에 있는 값을 뜻한다.

  • 배열 이름은 많은 상황에서 첫 번째 원소의 주소처럼 동작한다.

  • arr[i]는 *(arr + i)와 연결된다.78

이 정도만 머릿속에 제대로 들어오면, 포인터의 가장 중요한 문은 이미 연 것입니다.

정리

포인터는 "주소를 저장하는 변수"입니다.12 변수 그 자체보다, 변수가 놓인 위치를 직접 다룰 수 있게 해 줍니다. 그래서 함수, 배열, 문자열, 메모리 관리, 자료구조 같은 C 언어의 핵심 주제들이 모두 포인터와 연결됩니다.

이번 장에서 꼭 기억해야 할 핵심은 세 가지입니다.

  • &는 주소를 구한다.

  • *는 포인터가 가리키는 값을 읽거나 바꾼다.

  • 배열과 포인터는 아주 밀접하게 연결되어 있다.

포인터를 이해하면 C 언어가 왜 강력한지 분명히 보입니다. 하지만 동시에 왜 조심해야 하는지도 함께 보입니다. 이 양면성이 바로 포인터의 매력입니다. 잘 다루면 정밀한 도구이고, 함부로 다루면 프로그램 안에 작은 폭탄이 됩니다.

다음 장에서는 이 포인터 감각을 바탕으로, 여러 값을 한 덩어리로 묶는 구조체와 관련 데이터 표현 방법을 더 넓게 살펴보게 됩니다. 메모리를 바라보는 시야가 조금씩 넓어질수록, C 언어는 점점 더 입체적으로 보이기 시작할 것입니다.

참고

1C Pointers (With Examples)

2Pointers in C - GeeksforGeeks

3C Pointers

4Relationship Between Arrays and Pointers

5Pointers in C

6C 언어 코딩 도장: 34.2 역참조 연산자 사용하기

7Relationship Between Pointer and Array in C - GeeksforGeeks

8Pointers and Arrays

책 목록

초보자를 위한 C 언어 학습: 기초부터 프로젝트까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기