|

C언어 void 포인터 쉽게 이해하기

C언어 void 포인터 대표 이미지
void 포인터는 어떤 주소든 담을 수 있지만 타입 정보 없이 바로 읽을 수는 없다

C언어 void 포인터는 흔히 만능 포인터라고 불립니다. 하지만 더 정확한 표현은 어떤 객체의 주소든 담을 수 있지만, 타입 정보가 없어서 바로 역참조할 수 없는 포인터입니다.

이번 글에서는 void 포인터가 왜 필요한지, 왜 형변환 없이 바로 쓰면 안 되는지, 그리고 malloc과 범용 함수 인터페이스에서 왜 자주 등장하는지 단계적으로 정리하겠습니다. 포인터 기본기가 아직 불안하다면 C언어 이중 포인터란 무엇인가, 함수 포인터 글과 함께 보면 흐름이 더 잘 이어집니다.

void 포인터 핵심 요약 카드
void 포인터를 이해할 때 먼저 잡아야 할 핵심 정리

C언어 void 포인터는 정확히 무엇일까

void 포인터는 void * 형태로 선언합니다. 여기서 핵심은 void가 값의 실제 타입을 모른다는 뜻이라는 점입니다. 즉 이 포인터는 주소 자체는 가지고 있지만, 그 주소에 무엇이 들어 있는지에 대한 구체적인 타입 정보는 없습니다.

예를 들어 int *는 이 주소를 int로 읽어야 한다는 정보를 함께 가집니다. 반면 void *는 그런 힌트가 없습니다. 그래서 저장은 범용적으로 할 수 있지만, 읽을 때는 다시 구체적인 타입이 필요합니다.


왜 어떤 주소든 담을 수 있을까

C에서는 객체 포인터끼리 주소 표현을 맞춰서 다룰 수 있도록 설계되어 있습니다. 그래서 int *, double *, char * 같은 객체 포인터를 void *에 넣는 일이 가능합니다. 이 점 때문에 void 포인터가 범용 포인터처럼 보입니다.

예를 들어 아래 코드는 각각 다른 타입의 주소를 하나의 void 포인터 변수에 담습니다.

int number = 10;
double pi = 3.14;
char ch = 'A';

void *ptr;

ptr = &number;
ptr = π
ptr = &ch;

코드는 허용되지만, 이 상태에서 ptr이 지금 무엇을 가리키는지 사람과 컴파일러가 함께 정확히 알고 있어야 합니다. 주소를 담는 것과, 그 주소를 올바른 타입으로 해석하는 것은 다른 문제이기 때문입니다.

void 포인터와 타입 해석 흐름도
주소는 같아도 어떤 타입으로 해석하느냐에 따라 읽는 방식이 달라진다

왜 바로 역참조하면 안 될까

포인터를 역참조한다는 것은, 그 주소에 있는 바이트를 어떤 타입 규칙으로 읽겠다는 뜻입니다. int *라면 4바이트 정수를 읽고, double *라면 8바이트 부동소수점을 읽는 식입니다. 그런데 void *는 그 규칙이 없습니다.

그래서 아래처럼 바로 역참조하면 안 됩니다.

void *ptr;
int number = 10;
ptr = &number;

// 잘못된 예시
// printf("%d\n", *ptr);

이유는 간단합니다. 컴파일러가 *ptr을 어떤 타입으로 읽어야 할지 모릅니다. 따라서 먼저 구체적인 타입으로 바꿔야 합니다.

void *ptr;
int number = 10;
ptr = &number;

printf("%d\n", *(int *)ptr);

이 코드는 ptr이 가리키는 주소를 int *로 다시 해석한 뒤 값을 읽습니다. 즉 void 포인터는 주소 보관용으로는 범용이지만, 사용 시점에는 반드시 타입이 다시 필요합니다.


malloc에서 void 포인터가 자주 나오는 이유

malloc은 할당한 메모리의 시작 주소를 반환합니다. 그런데 그 메모리에 앞으로 int를 넣을지, 구조체를 넣을지, 배열을 넣을지는 malloc 자신이 모릅니다. 그래서 반환형이 void *입니다.

int *arr = malloc(5 * sizeof(int));
if (arr == NULL) {
    return 1;
}

C에서는 void *가 다른 객체 포인터로 자동 변환될 수 있어서 위 코드처럼 바로 대입하는 경우가 많습니다. 반면 C++에서는 malloc 반환값을 명시적으로 캐스팅해야 하므로, 두 언어의 예제가 섞이면 초보자가 더 헷갈릴 수 있습니다.

메모리 할당 자체의 위험성과 free 사용법은 cppreference malloc 문서를 참고할 수 있고, 개념 정리는 기존 malloc/free 관련 글과 함께 봐도 좋습니다.


범용 함수에서 왜 유용할까

이 주제를 더 헷갈리게 만드는 요소 중 하나는 “void 포인터면 아무 연산이나 할 수 있나”라는 오해입니다. C 표준 감각에서는 void 포인터 자체에 대해 임의의 타입 크기를 가정한 산술을 바로 기대하면 안 됩니다. 결국 어떤 단위로 이동할지 알려면 다시 구체적인 타입 맥락이 필요합니다.

그래서 범용 정렬이나 콜백 인터페이스에서 void 포인터가 많이 쓰인다고 해도, 실제 구현에서는 “주소를 범용적으로 받되, 해석은 약속된 타입으로 정확히 한다”는 원칙이 항상 따라붙습니다.

void 포인터는 하나의 함수가 여러 타입의 데이터를 받아야 할 때 특히 유용합니다. 대표적인 예가 라이브러리의 범용 정렬, 범용 콜백, 사용자 데이터 전달 방식입니다. 함수 입장에서는 주소만 받아 두고, 실제 타입 해석은 호출 규약에 맞게 나중에 하게 됩니다.

void print_int(void *data) {
    printf("%d\n", *(int *)data);
}

void print_double(void *data) {
    printf("%.2f\n", *(double *)data);
}

이 패턴의 장점은 인터페이스를 일반화할 수 있다는 점입니다. 하지만 단점도 분명합니다. 호출하는 쪽과 사용하는 쪽이 이 포인터가 실제로 어떤 타입인지 약속을 정확히 맞춰야 합니다.


자주 하는 실수

  • void 포인터를 만능 포인터라고만 외우고 바로 역참조하려 한다
  • 실제 타입과 다른 타입으로 캐스팅해서 읽는다
  • malloc 예제를 C와 C++ 문법이 섞인 상태로 이해한다
  • 주소는 맞게 들고 있지만 데이터 크기와 정렬을 무시한다

이런 실수는 대부분 “주소를 담는 일”과 “그 주소를 해석하는 일”을 분리해서 생각하지 않았을 때 생깁니다. void 포인터는 앞쪽만 범용적이고, 뒤쪽은 여전히 타입에 엄격합니다.


void 포인터를 안전하게 이해하는 한 문장

void 포인터는 어떤 객체의 주소든 보관할 수 있지만, 사용할 때는 반드시 원래 타입 정보를 되찾아야 한다고 기억하면 됩니다.

이 문장만 정확히 잡아도 왜 캐스팅이 필요한지, 왜 malloc이 void 포인터를 반환하는지, 왜 잘못된 타입으로 읽으면 위험한지까지 자연스럽게 연결됩니다.

void 포인터 사용 체크리스트 카드
void 포인터를 다룰 때 확인해야 할 체크리스트

마무리

C언어 void 포인터는 어떤 타입의 주소든 담을 수 있다는 점에서 유용합니다. 하지만 타입 정보가 비어 있기 때문에, 바로 읽거나 연산할 수 있는 포인터는 아닙니다.

즉 void 포인터를 이해하는 핵심은 만능이라는 말보다, 타입을 잠시 보류한 주소라고 보는 데 있습니다. 이 관점이 잡히면 캐스팅, malloc, 범용 인터페이스가 훨씬 덜 헷갈립니다.

함께보면 좋은 글