그순간

포인터

김영원·2026년 10월 29일·2 조회
포인터
포인터

포인터

2026.10.29

포인터에 대해서 알아봅시다.

개념

포인터는 다양한 언어에서 다양하게 해석됩니다. 하지만 이거 하나만큼은 동일하다고 볼 수 있을 것 같습니다.

어떤 대상이 있는 위치를 가리키는 변수

이정도가 포인터의 공통적인 개념이라고 할 수 있을 듯합니다.

C

포인터가 왜 필요한지 알아보기 전에, 이 글에서는 C를 이용한 예제들을 사용하기 때문에 C의 기본적인 포인터 문법을 먼저 알아보겠습니다.

int a = 10; // 일반 변수
int *ptr; // 포인터를 선언함. 'T*' 구조의 타입은 타입 'T'를 가리키는 포인터라는 뜻임.
ptr = &a; // '&' 단항 연산자로 변수의 주소를 얻음.
printf("%d\n", *ptr); // '*' 단항 연산자로 포인터가 가리키는 값을 얻음. '역참조'라고 부름.

필요

포인터가 어떤 대상의 위치를 가리킨다고 했습니다. 하지만 왜 그래야할까요? C를 통해 알아보도록 합시다.

1. 대상의 지정과 접근

int a = 10;
int b = 10;

위 코드에서 a와 b의 값은 10으로 동일합니다. 하지만 둘의 위치, 즉 메모리 주소는 다릅니다. 따라서 값이 같더라도 위치를 이용하면 서로 다른 대상을 구별하고 특정할 수 있습니다.

그러핟면 특정 대상을 구별하고 지정할 수 있다는 것이 왜 유용할까요? 어떤 변수가 짝수일때 2를 나누는 코드가 계속 반복되는 상황이 있다고 가정해봅시다.

int a = 10;
if (a%2 == 0) a /= 2;
int b = 17;
if (b%2 == 0) b /= 2;
int c = 67;
if (c%2 == 0) c /= 2;

아마도 함수로 빼내어 재사용성을 높일 수 있을 것입니다. 이 때, 값의 위치를 함수의 인자로 넘겨 어떤 변수를 바꿀지 특정할 수 있습니다.

void foo(int *a)
{
    if (*a%2 == 0) *a /= 2;
}

int a = 10; foo(&a); int b = 17; foo(&b); int c = 67; foo(&c);

이처럼 메모리 주소를 전달하면 함수는 변수의 이름을 알지 못하더라도 해당 변수에 접근하고 수정할 수 있습니다. 즉, 값의 위치를 이용하면 어떤 대상(혹은 변수)를 다룰 것인지를 외부에서 지정할 수 있는 것입니다.

2. 관계의 표현

struct Person {
char *name;
};

struct Person a = {"길동"}; struct Person b = {"철수"}; struct Person c = {"영희"};

위와 같은 데이터가 있다고 해봅시다. 여기서 Person a의 친구가 b라고 표현하고 싶다면 어떻게 할까요? b의 이름을 저장해야 할까요?

struct Person {
char *name;
char *friend_name;
};

struct Person b = {"철수",NULL}; struct Person a = {"길동",b.name}; struct Person c = {"영희",NULL}; b.name = "나까무라"; printf("%s\n", a.friend_name); // 철수

이 방법은 b의 이름이 바뀌었을때 동기화가 되지 않으며, 이름만으로는 b라는 데이터에 바로 접근할 수 없다는 단점이 있습니다. 이때, Person b의 위치를 저장하여 그 데이터를 가리킬 수 있습니다.

struct Person {
char *name;
struct Person *friend;
};

struct Person b = {"철수",NULL}; struct Person a = {"길동",&b}; struct Person c = {"영희",NULL}; b.name = "나까무라"; printf("%s\n", a.friend->name); // 나까무라

이처럼 포인터를 이용하면 데이터 자체를 직접 참조할 수 있습니다. 이는 단순히 데이터를 저장하는 것을 넘어 데이터와 데이터 사이의 관계를 표현할 수 있게 해줍니다.

다른 방법은 없을까?

앞서 살펴본 문제는 사실 포인터를 사용하지 않고도 해결할 수 있습니다. 대상의 지정과 접근은 함수의 반환값을 다시 변수에 대입하는 식으로, 관계의 표현은 각 데이터에 id를 주는 식으로 말이죠

따라서 포인터는 어떤 문제의 유일한 해결책이 아니라 메모리의 위치라는 특성을 이용하는 문제 해결 방법이라고 볼 수 있습니다.

즉, 포인터가 항상 필수적인 것은 아니지만, 특정 상황에서는 매우 유용한 도구가 될 수 있습니다.

활용

포인터가 특정 상황에서 매우 유용한 도구가 된다고 했습니다. 이번에는 그런 경우들에 대해서 알아봅시다.

1. 복사 비용 절감

struct Image {
unsigned char pixels[1920 * 1080 * 4];
};

void foo(struct Image image); void bar(struct Image *image);

위와 같은 구조의 프로그램이 있다고 했을때 foo 함수의 경우 엄청나게 큰 Image 구조체를 복사해야해 성능에 손실이 발생합니다. 하지만 bar 함수의 경우에는 image의 위치만 전달해주고 있어 복사하는데 발생하는 비용을 절약할 수 있습니다.

2. 동적 할당

만약 컴파일 타임에 길이를 알고있다면 int data[1024 * 1024];와 같은 자료형을 사용할 수 있을 것입니다. 하지만 데이터의 크기를 미리 모른다면 어떻게 할까요? 이때 포인터를 이용한다면 그 데이터가 어디에 있는지만 저장하고, 크기를 런타임에 다룰 수 있게됩니다.

int n;
scanf("%d", &n);
int data = malloc(nsizeof(int));
for (int i = 0; i < n; ++i) {
data[i] = 0;
}
free(data);

위 코드처럼 malloc이 할당하는 데이터의 크기는 함수의 입력값에 따라 변하기 때문에 컴파일타임에 알 수 없습니다. 이때 포인터를 통해 할당된 데이터의 위치만 저장하는 방식을 사용할 수 있습니다. int *data;에 데이터의 위치를 저장하고, int n;에 데이터의 크기를 런타임에 저장한 것을 볼 수 있습니다.

3. 함수 포인터

포인터를 이용하면 함수의 인자로 변수의 값뿐만 아니라 변수의 주소를 넘겨줘 변수를 수정하거나 변수에 접근하게 할 수 있었습니다.

그런데, 포인터는 데이터뿐만 아니라 함수도 가리킬 수 있습니다. 이를 이용하면 실행할 함수를 다른 함수의 인자로 전달하거나 실행중에 어떤 함수를 호출할지 결정할 수 있습니다.

void hello(void) {
puts("Hello!");
}

void goodbye(void) { puts("Goodbye!"); }

void execute(void (*action)(void)) { action(); }

execute(hello); // Hello! execute(goodbye); // Goodbye!

함수 포인터의 기본적인 사용법은 위와 같습니다. 포인터로 함수를 가리킬땐 함수의 반환값과 매개변수 타입을 지정해줘야 합니다. 이를 활용하면 다음과 같이 사용할 수도 있습니다.

struct Person {
char *name;
void (*greet)(void);
};

void greet1(void) { puts("안녕"); }

void greet2(void) { puts("привет"); }

struct Person a = {"철수", greet1}; struct Person b = {"영희", greet2};

a.greet(); // 안녕 b.greet(); // привет

이런식으로 같은 자료형의 객체라도 서로 다른 함수를 가리키게 하여 각기 다른 동작을 수행하게 할 수 있습니다. 이렇게 되면 호출자가 아는것은 반환 타입과 매개변수 타입 뿐, 내부 구현은 런타임에 가변적으로 바뀔 수 있습니다. 이를 이용해 프로그램을 추상화할 수 있습니다.

이를 활용한 예시로는 C에서 쓰이는 qsort의 compare 함수를 들 수 있습니다.

int descending(const void *a, const void *b)
{
int x = *(const int *)a;
int y = *(const int *)b;

return (y > x) - (y < x);

}

int values[] = {5, 2, 8, 1, 9};

qsort(values, 5, sizeof(int), descending);

위와 같이 sort 함수는 두 값을 비교하는 로직을 외부에서 주입받게 됩니다.

위험

이처럼 포인터는 다양한 이점을 제공합니다. 하지만 그만큼 잘못 사용했을때의 위험도 큽니다. 아래 예시처럼 포인터로 잘못된 메모리 주소를 가리키는 포인터를 역참조하면 정의되지 않은 동작이 발생합니다. 이는 단순히 오류 메세지 출력을 뜻하는 것이 아닌, 언어 차원에서 무슨 일이 일어날지 보장하지 않는다는 뜻입니다. 대부분의 환경에서 프로그램이 크래시할 수 있습니다.

int *a = NULL;
printf("%d\n", *a); // Undefined Behavior

또한 포인터를 함수에 넘겨줬을때 해당 포인터를 다른 곳에 저장하게 되면 값의 변화를 예측하기가 어려워지기도 합니다.

int a = 10;
foo(&a); // 어딘가에 포인터를 저장
printf("%d\n", a); // 10
bar(); // 저장된 포인터로 a를 참조하여 값 변경
printf("%d\n", a); // 67

그리고 가리키던 곳의 값이 유효하지 않아졌을때도 문제가 발생할 수 있습니다.

int *ptr;
{
// 스코프 내부
int a = 10;
ptr = &a;
}
printf("%d\n", *ptr);

스코프 내부에서 선언한 a는 스코프가 종료될 시 더이상 유효하지 않게 되며, 이를 가리키는 포인터 또한 유효하지 않은 값을 가리키게 됩니다. C에서는 이러한 수명 문제를 컴파일 시점에 항상 감지하거나 방지할 수 없으므로 주의하지 않으면 발견하기 어려운 버그로 이어질 수 있습니다.

다른 언어의 포인터와 참조

지금까지는 C의 포인터를 기준으로 포인터를 살펴보았습니다. 이번에는 다른 언어들에서의 포인터를 알아봅시다.

1. C++

포인터는 데이터를 가리킵니다. 하지만 아까 보았듯이 해당 데이터가 유효하지 않아질 수 있습니다. C++에서는 포인터가 가리키는 데이터를 안정적으로 관리하는 방법을 제시합니다. 아래 코드를 봅시다.

int *ptr;
{
// 스코프 내부
int a = 10;
ptr = &a;
}
printf("%d\n", *ptr); // UB
std::weak_ptr<int> ptr;
{
// int 크기만큼 메모리를 할당하고, 10이라는 값으로 설정합니다.
// 그리고 그 메모리의 포인터를 반환합니다.
auto a = std::make_shared<int>(10);
ptr = a;
}

// ptr.lock()은 포인터가 유효하면 포인터를 반환하고, 아니라면 빈 포인터를 반환합니다. if (auto p = ptr.lock()) { printf("%d\n", *p); } else { puts("대상이 더 이상 존재하지 않습니다."); }

이처럼 C++은 일반적인 C의 포인터 외에도 더 고급 기능을 가진 포인터 추상화를 제공합니다. 다만 C++의 모든 포인터가 이러한 안정성을 보장하는 것은 아닙니다.

2. Rust

Rust는 포인터 문제에 더 흥미로운 방식으로 접근합니다. 아래 코드를 봅시다.

int *ptr;
{
// 스코프 내부
int a = 10;
ptr = &a;
}
printf("%d\n", *ptr); // UB
let ptr: &i32; // 정수를 가리키는 참조를 선언합니다.
{
let a: i32 = 10;
ptr = &a;
}
println!("{}", *ptr); // 컴파일 오류

이처럼 Rust는 데이터의 수명을 추적하여 유효하지 않은 참조를 컴파일타임에 방지합니다.

하지만 이 때문에 일부 프로그램을 구현하기가 까다로워질 수 있습니다. 이를 해결하기 위해 unsafe를 통해 안정성을 보장하지 않는 저수준 연산을 수행할 수 있습니다만, 이 경우 개발자가 직접 일부 메모리 안정성을 책임져야 합니다.

3. Go

var ptr *int
{
var a int = 10
ptr = &a
}
fmt.Println(*ptr)

Go에서는 위 코드가 완벽하게 작동합니다. Go가 변수의 수명을 분석하여 필요한 경우에 힙에 할당하기 때문입니다.

따라서 Go의 참조는 개발자가 관리하지 않아도 포인터가 가리키는 데이터의 유효성을 보장합니다. 또한 Go에서는 자동으로 힙에 할당된 데이터들을 관리하기 위해 GC가 사용됩니다.

4. Java

Java에서는 C와 달리 일반적인 포인터가 없습니다. 대신, 객체들을 기본적으로 참조 데이터로 처리합니다. 아래 코드를 봅시다.

class Number {
public int value;
Number(int value) {
this.value = value;
}
}

public class main { static void foo(Number num) { num.value = 67; } public static void main(String[] args) { Number num = new Number(10); foo(num); System.out.println(num.value); // 67 } }

위와 같이 자바에서는 기본적으로 객체를 참조하며, 객체가 참조되는 동안은 해당 객체가 회수되지 않도록 관리합니다.

마무리

이렇게 포인터가 뭐고, 왜 쓰고, C에서 어떻게 쓰고, 다른 언어들은 어떻게 다루는지에 대해 알아봤습니다.

포인터는 언어마다 구현 방식과 안정성을 보장하는 방법은 다르지만 특정 대상을 가리키고 접근한다는 공통된 목적을 가지고 있습니다.

이처럼 포인터는 단순한 문법을 넘어, 프로그램이 데이터를 어떻게 다루고 관계를 맺는지 이해하는 데 중요한 개념이라고 생각합니다.

감사합니다.