16편에서 저장 기간과 스코프를 정리했다면, 이제는 함수 그 자체를 값처럼 다루며 더 유연한 코드를 만들어 볼 차례입니다. 함수 포인터는 "이 함수가 메모리 어디에 있는가"를 주소로 저장하는 개념이고, 콜백은 그 주소를 다른 코드에 전달해 나중에 호출하도록 맡기는 패턴입니다.
이번 글에서 새로 나오는 용어
- 함수 포인터 (Function Pointer): 함수의 시작 주소를 저장하는 포인터
- 콜백 (Callback): 함수를 인자로 넘겨 두었다가 특정 시점에 다시 호출하는 방식
- 시그니처 (Signature): 함수 이름을 제외한 반환형과 매개변수 목록
- 전략 패턴: 동작을 외부에서 선택해 주입하는 구조
핵심 개념
학습 메모
- 소요 시간: 60~75분
- 준비물: 포인터 기초, 함수 선언과 정의, 배열 반복 경험
- 학습 목표: 함수 포인터 선언·대입·호출 흐름 이해, 콜백 함수 설계
함수 포인터를 보면 가장 먼저 시그니처를 확인해야 합니다. int (*handler)(const char *msg)처럼 괄호 위치가 헷갈릴 수 있지만, "포인터 handler가 있고, 그것이 가리키는 대상은 const char *를 받아 int를 반환하는 함수"라고 읽으면 됩니다. 처음에는 함수 포인터를 "숫자 대신 함수의 위치를 담는 변수"처럼 상상하면 훨씬 이해하기 쉽습니다.
이번 글에서는 아래 단계를 밟습니다.
- 함수 포인터를 선언하고
&함수이름대신 함수 이름 자체를 대입해도 되는 이유 이해하기 - 배열처럼 여러 함수 포인터를 모아 반복 호출하기
- 콜백을 인자로 받아 정렬 전략이나 이벤트 핸들러를 바꾸는 예제 작성하기
void *context같은 추가 데이터를 전달해 콜백이 상황을 해석할 수 있게 만들기
코드로 따라하기
함수 포인터 기초 선언과 호출
#include <stdio.h>
int square(int n) {
return n * n;
}
int cube(int n) {
return n * n * n;
}
int main(void) {
int (*operation)(int);
operation = square;
printf("square(3) = %d\n", operation(3));
operation = cube;
printf("cube(3) = %d\n", operation(3));
return 0;
}
operation = square;처럼 &square를 쓰지 않아도 되는 이유는 함수 이름이 그 함수의 시작 주소로 암묵 변환되기 때문입니다. 반대로 operation을 다시 함수처럼 operation(3)으로 호출하면, 포인터가 가리키는 함수 본문으로 점프합니다.
함수 포인터 배열과 반복
#include <stdio.h>
typedef int (*calc_fn)(int);
int add_one(int n) {
return n + 1;
}
int double_num(int n) {
return n * 2;
}
int negate(int n) {
return -n;
}
int main(void) {
calc_fn steps[3] = {add_one, double_num, negate};
int value = 5;
int i;
for (i = 0; i < 3; i++) {
value = steps[i](value);
printf("[%d] -> %d\n", i, value);
}
return 0;
}
typedef를 활용하면 포인터 선언을 간결하게 만들 수 있습니다. 여기서는 steps 배열에 세 가지 동작을 담아 순서대로 적용하고, 그때그때 같은 시그니처를 공유하는지만 확인하면 됩니다.
콜백으로 정렬 전략 바꾸기
#include <stdio.h>
typedef int (*compare_fn)(int a, int b);
int ascending(int a, int b) {
return (a > b) - (a < b);
}
int descending(int a, int b) {
return (b > a) - (b < a);
}
void selection_sort(int *arr, int len, compare_fn cmp) {
int i, j, min_idx;
for (i = 0; i < len - 1; i++) {
min_idx = i;
for (j = i + 1; j < len; j++) {
if (cmp(arr[j], arr[min_idx]) < 0) {
min_idx = j;
}
}
if (min_idx != i) {
int temp = arr[i];
arr[i] = arr[min_idx];
arr[min_idx] = temp;
}
}
}
int main(void) {
int data[5] = {42, 7, 98, 13, 55};
selection_sort(data, 5, ascending);
printf("오름차순: %d %d %d %d %d\n",
data[0], data[1], data[2], data[3], data[4]);
selection_sort(data, 5, descending);
printf("내림차순: %d %d %d %d %d\n",
data[0], data[1], data[2], data[3], data[4]);
return 0;
}
정렬 함수 내부에서는 cmp 콜백을 통해 순서를 비교합니다. 오름차순, 내림차순처럼 비교 방식만 바꿔도 전체 동작이 달라집니다. 이때 비교 함수는 보통 "작으면 음수, 같으면 0, 크면 양수"를 반환하는 계약을 따릅니다.
콜백에 컨텍스트 전달하기
#include <stdio.h>
typedef void (*log_fn)(const char *msg, void *context);
typedef struct {
int warning_threshold;
} logger_config;
void console_logger(const char *msg, void *context) {
logger_config *cfg = (logger_config *)context;
printf("[warn>= %d] %s\n", cfg->warning_threshold, msg);
}
void monitor_temperature(int readings[], int len, log_fn logger, void *ctx) {
int i;
logger_config *cfg = (logger_config *)ctx;
for (i = 0; i < len; i++) {
if (readings[i] >= cfg->warning_threshold) {
char buffer[64];
snprintf(buffer, sizeof(buffer), "센서 %d: %d도", i, readings[i]);
logger(buffer, ctx);
}
}
}
int main(void) {
int temps[6] = {32, 48, 51, 37, 60, 42};
logger_config cfg = {45};
monitor_temperature(temps, 6, console_logger, &cfg);
return 0;
}
콜백이 단순히 함수 주소만 받으면 상황 정보를 알 수 없습니다. 그래서 void *context로 추가 데이터를 넘기면 포인터를 다시 캐스팅해 사용할 수 있습니다. 다만 void *는 타입 정보를 스스로 설명하지 않으므로, 호출자와 콜백이 "어떤 구조체를 넣을지"를 같은 계약으로 공유해야 합니다. 이 패턴은 GUI 이벤트 처리, 네트워크 라이브러리에서도 널리 쓰입니다.
왜 중요한가
- 함수 포인터는 "코드도 데이터처럼 옮길 수 있다"는 감각을 줍니다.
- 콜백을 쓰면 라이브러리와 사용자 코드가 서로 역할을 나눌 수 있습니다.
- 같은 로직에 다른 동작을 주입하는 전략 패턴을 C 수준에서 구현할 수 있습니다.
- 운영체제 API, 신호 처리, 타이머 라이브러리 등 실제 시스템 프로그래밍에서 필수 패턴입니다.
CodeSandbox로 이어서 실습하기
아래 샌드박스는 CodeSandbox의 Universal starter입니다. C는 터미널에서 직접 컴파일하고 다시 실행하는 흐름이 중요하니, 이번 글의 코드를 파일로 만들고 빌드-실행 사이클을 다시 따라가 보세요.
💬 댓글
이 글에 대한 의견을 남겨주세요