[C 시리즈 17편] 함수 포인터와 콜백 패턴 이해하기

English version

16편에서 저장 기간과 스코프를 정리했다면, 이제는 함수 그 자체를 값처럼 다루며 더 유연한 코드를 만들어 볼 차례입니다. 함수 포인터는 "이 함수가 메모리 어디에 있는가"를 주소로 저장하는 개념이고, 콜백은 그 주소를 다른 코드에 전달해 나중에 호출하도록 맡기는 패턴입니다.

이번 글에서 새로 나오는 용어

  1. 함수 포인터 (Function Pointer): 함수의 시작 주소를 저장하는 포인터
  2. 콜백 (Callback): 함수를 인자로 넘겨 두었다가 특정 시점에 다시 호출하는 방식
  3. 시그니처 (Signature): 함수 이름을 제외한 반환형과 매개변수 목록
  4. 전략 패턴: 동작을 외부에서 선택해 주입하는 구조

핵심 개념

학습 메모

  • 소요 시간: 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는 터미널에서 직접 컴파일하고 다시 실행하는 흐름이 중요하니, 이번 글의 코드를 파일로 만들고 빌드-실행 사이클을 다시 따라가 보세요.

Live Practice

C Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Universal starterCterminal
  1. starter를 fork한 뒤 hello.c 같은 실습 파일을 만든다
  2. 본문 코드를 붙여 넣고 clang 또는 gcc가 있으면 직접 컴파일한다
  3. 코드를 고친 뒤 다시 빌드하고 실행 결과를 비교한다

C는 브라우저 미리보기보다 터미널 빌드 흐름이 핵심입니다. Universal starter의 컴파일러 구성이 환경에 따라 다를 수 있으니, 먼저 clang이나 gcc 사용 가능 여부부터 확인하세요.

실습

  • 따라 하기: 함수 포인터 배열 예제를 직접 실행하고, 순서를 바꾸면 출력이 어떻게 달라지는지 기록합니다.
  • 확장하기: selection_sort 대신 qsort 스타일 인터페이스를 만들어 배열 길이와 비교 함수만 인자로 받도록 고쳐 봅니다.
  • 디버깅: 콜백 호출 시 잘못된 시그니처를 대입하면 어떤 경고가 나는지 컴파일러 메시지를 정리해 봅니다.
  • 완료 기준: 함수 포인터 선언을 직접 적을 수 있고, 콜백 인자를 받아 호출하는 함수 하나를 스스로 작성하면 됩니다.

마무리

함수 포인터와 콜백을 이해하면 C 코드도 동적으로 행동을 바꿀 수 있습니다. 다음 글에서는 더 아래 층에 있는 비트 연산을 다루어 하드웨어 친화적 사고를 키워 보겠습니다.

💬 댓글

이 글에 대한 의견을 남겨주세요