[C언어 50강] 45강. 함수 포인터/콜백: qsort 비교함수, 전략 패턴 맛보기

[C언어 50강] 45강. 함수 포인터/콜백: qsort 비교함수, 전략 패턴 맛보기

C언어에서 함수 포인터는 "어려운 문법"이 아니라, 코드를 데이터처럼 다루기 위한 핵심 도구입니다. 특히 콜백(callback)과 함께 쓰면 "무엇을 할지"를 실행 중에 바꿀 수 있어서, 정렬 기준 변경·이벤트 처리·전략 교체 같은 요구를 깔끔하게 해결할 수 있습니다. 이번 강의에서는 문법 암기보다 "왜 함수 주소를 전달하는가"에 초점을 맞춰 이해해보겠습니다.


핵심 개념

  • 함수 포인터는 "함수의 시작 주소"를 저장하는 포인터이며, 시그니처(반환형/매개변수)가 정확히 일치해야 안전하다.
  • 콜백은 "호출 주체"와 "동작 정의"를 분리해서, 공통 흐름은 유지하고 세부 정책만 갈아끼우는 구조다.
  • qsort의 비교 함수 패턴은 C에서 전략 패턴을 구현하는 대표 사례다.

개념 먼저 이해하기

초보자가 함수 포인터를 어렵게 느끼는 이유는 두 가지입니다. 첫째, 선언 문법이 낯설고 괄호가 많아 읽기 순서를 잃기 쉽습니다. 둘째, "함수를 왜 포인터로 넘겨야 하는지" 필요성이 바로 체감되지 않습니다. 그런데 관점을 바꾸면 간단합니다. C에서 함수도 메모리 어딘가에 올라가고, 그 시작 위치(엔트리 포인트)를 가리키는 값이 존재합니다. 함수 포인터는 그 주소를 담는 변수입니다. 즉 "코드의 위치를 값으로 전달"하는 장치입니다.

이게 왜 중요할까요? 우리가 실제로 만드는 프로그램은 대부분 "공통 골격 + 바뀌는 정책" 구조를 가집니다. 예를 들어 정렬을 한다고 할 때, 배열을 분할하고 비교하고 교환하는 절차 자체는 공통입니다. 하지만 오름차순/내림차순, 길이 기준/사전순 같은 "비교 규칙"은 자주 바뀝니다. 규칙이 바뀔 때마다 정렬 함수를 여러 개 만들면 중복이 폭발합니다. 이때 정렬 엔진은 그대로 두고 비교 규칙만 함수 포인터로 주입하면, 하나의 엔진으로 다양한 요구를 처리할 수 있습니다. 이게 콜백의 본질입니다.

콜백을 이해할 때 가장 중요한 포인트는 책임 분리입니다. 콜백을 "받는 쪽"은 언제 호출할지, 어떤 순서로 호출할지를 책임집니다(프레임워크 역할). 콜백을 "제공하는 쪽"은 호출되었을 때 무엇을 할지만 정의합니다(정책 역할). 이 분리를 잘하면 코드가 유연해지고 테스트도 쉬워집니다. 예를 들어 생산 코드에서는 실제 로직 콜백을 넘기고, 테스트에서는 더미 콜백을 넘겨 호출 횟수와 순서를 검증할 수 있습니다.

또 하나 자주 놓치는 부분은 타입 안정성입니다. C는 컴파일러가 많은 걸 잡아주지만, 함수 포인터 타입이 느슨하면 위험합니다. int (*cmp)(const void*, const void*)처럼 정확한 시그니처를 맞춰야 하고, qsort에서 void*를 원래 타입으로 되돌릴 때 const와 정렬 기준을 명확히 지켜야 합니다. 잘못된 캐스팅은 "돌아가는 것처럼 보이다가" 특정 데이터에서만 터지는 버그를 만듭니다.

성능 관점도 짚어야 합니다. 함수 포인터 호출은 직접 호출보다 인라이닝이 어렵고 분기 예측에 불리할 수 있습니다. 그래서 초미세 최적화가 중요한 핫루프에서는 비용이 문제될 수 있습니다. 하지만 실무에서 대부분의 병목은 알고리즘 복잡도/메모리 접근 패턴/불필요한 복사에서 나오므로, 콜백 구조가 주는 설계 이점이 훨씬 큰 경우가 많습니다. 먼저 구조를 올바르게 만들고, 실제 프로파일링으로 병목이 확인되면 그때 특수화된 경로를 추가하는 순서가 안전합니다.

마지막으로, 함수 포인터는 객체지향의 전략 패턴과 매우 닮아 있습니다. C에는 클래스가 없지만, "함수 포인터 + 컨텍스트 포인터(사용자 데이터)" 조합으로 같은 효과를 구현할 수 있습니다. 예를 들어 로그 시스템에서 출력 대상(콘솔/파일/소켓)을 바꾸고 싶다면, 공통 로거는 emit 콜백만 호출하고 실제 출력은 각 전략이 담당하게 만들 수 있습니다. 결국 함수 포인터를 잘 쓴다는 건 문법 실력이 아니라 "변하는 축을 분리하는 설계 감각"을 갖는 것입니다.

기본 사용

예제 1) 함수 포인터 선언과 호출 최소 예제

#include <stdio.h>

int add(int a, int b) {
    return a + b;
}

int sub(int a, int b) {
    return a - b;
}

int apply(int x, int y, int (*op)(int, int)) {
    return op(x, y); // 전달받은 함수 호출
}

int main(void) {
    int (*fp)(int, int) = add; // 함수 이름은 주소로 decay

    printf("fp(add): %d\n", fp(10, 3));
    printf("apply(add): %d\n", apply(10, 3, add));
    printf("apply(sub): %d\n", apply(10, 3, sub));

    return 0;
}

설명:

  • int (*fp)(int, int)는 "int 두 개를 받아 int를 반환하는 함수"를 가리키는 포인터입니다.
  • add처럼 함수 이름은 대부분 문맥에서 함수 주소로 해석됩니다.
  • apply는 계산 흐름(두 값을 받아 연산 후 반환)은 고정하고, 연산 방식(op)은 외부에서 주입받습니다.

예제 2) qsort 콜백으로 정렬 전략 교체

#include <stdio.h>
#include <stdlib.h>

typedef struct {
    const char *name;
    int score;
} Student;

int cmp_score_asc(const void *a, const void *b) {
    const Student *x = (const Student *)a;
    const Student *y = (const Student *)b;
    return (x->score > y->score) - (x->score < y->score);
}

int cmp_score_desc(const void *a, const void *b) {
    return -cmp_score_asc(a, b);
}

static void print_students(const Student *arr, size_t n) {
    for (size_t i = 0; i < n; ++i) {
        printf("%s (%d)\n", arr[i].name, arr[i].score);
    }
    puts("---");
}

int main(void) {
    Student s[] = {
        {"Kim", 82}, {"Lee", 95}, {"Park", 76}, {"Choi", 95}
    };
    size_t n = sizeof(s) / sizeof(s[0]);

    qsort(s, n, sizeof(s[0]), cmp_score_asc);
    print_students(s, n);

    qsort(s, n, sizeof(s[0]), cmp_score_desc);
    print_students(s, n);

    return 0;
}

설명:

  • qsort는 정렬 엔진이고, 비교 기준은 콜백으로 전달합니다.
  • 비교 함수는 "음수/0/양수" 규약을 지켜야 하며, 단순 x->score - y->score는 오버플로 가능성이 있어 지양합니다.
  • 같은 데이터 구조라도 정책 함수만 바꿔 즉시 다른 정렬 결과를 얻습니다.

예제 3) 실무형 패턴: 전략 테이블 + 안전한 NULL 처리

#include <stdio.h>

typedef int (*BinaryOp)(int, int);

typedef struct {
    const char *name;
    BinaryOp fn;
} OpEntry;

int plus(int a, int b) { return a + b; }
int minus(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }

int exec_op(const OpEntry *entry, int a, int b, int *out) {
    if (!entry || !entry->fn || !out) return -1;
    *out = entry->fn(a, b);
    return 0;
}

int main(void) {
    OpEntry table[] = {
        {"add", plus},
        {"sub", minus},
        {"mul", mul}
    };

    int result = 0;
    if (exec_op(&table[2], 6, 7, &result) == 0) {
        printf("%s => %d\n", table[2].name, result);
    }

    if (exec_op(&(OpEntry){"broken", NULL}, 1, 2, &result) != 0) {
        puts("invalid callback detected");
    }

    return 0;
}

설명:

  • 전략(연산 함수)을 테이블로 관리하면 조건문 체인을 줄이고 확장 포인트를 명확히 만들 수 있습니다.
  • 실무에서는 콜백 NULL 가능성을 항상 열어두고 방어 코드(!entry || !entry->fn)를 넣어야 합니다.
  • 함수 포인터 + 메타데이터(name)를 묶으면 로깅/디버깅/옵저버빌리티가 좋아집니다.

자주 하는 실수

실수 1) 함수 포인터 시그니처 불일치

  • 원인: "대충 비슷하면 되겠지"라는 생각으로 매개변수 타입/개수를 다르게 선언.
  • 해결: typedef로 함수 포인터 타입 별칭을 만들고, API 경계에서 동일 타입만 쓰도록 강제.

실수 2) qsort 비교 함수에서 단순 뺄셈 반환

  • 원인: return a - b;가 간단해 보여 습관적으로 사용.
  • 해결: 오버플로를 피하려고 (a>b) - (a<b) 형태 사용 또는 범위를 고려한 안전 비교 작성.

실수 3) void * 캐스팅 후 const 무시

  • 원인: 빠르게 코드를 쓰다 const를 떼어내거나 잘못된 타입으로 캐스팅.
  • 해결: const T *x = (const T *)a;처럼 정확히 맞추고, 비교 함수 안에서는 데이터 수정 금지.

실수 4) NULL 콜백 체크 생략

  • 원인: "항상 등록돼 있을 것"이라는 낙관적 가정.
  • 해결: 콜백 호출 직전 NULL 검사, 실패 코드 반환, 로깅까지 표준화.

실무 패턴

  • 함수 포인터 선언이 반복되면 반드시 typedef를 도입해 가독성을 확보합니다.
  • 콜백 API에는 보통 void *ctx(사용자 컨텍스트)를 함께 전달해, 전역 변수 의존을 줄입니다.
  • 콜백 호출 규약을 문서화합니다.
    • 호출 스레드/시점
    • 에러 반환 규칙
    • 재진입 가능 여부
  • 성능이 민감한 구간은 "콜백 버전"과 "직접 호출 버전"을 분리해 측정 기반으로 선택합니다.
  • 전략 테이블(명령어 문자열 → 함수 포인터) 방식은 CLI, 메뉴형 프로그램, 간단 인터프리터에서 특히 유지보수성이 좋습니다.

오늘의 결론

한 줄 요약: 함수 포인터/콜백은 문법 트릭이 아니라 "공통 흐름과 변경 정책을 분리"해 C 코드를 유연하고 재사용 가능하게 만드는 핵심 설계 도구다.

연습문제

  1. int (*op)(int,int) 타입을 typedef로 선언하고, 사칙연산 함수 4개를 테이블로 등록한 뒤 문자열 명령(add, sub, mul, div)으로 실행해보세요.
  2. qsort를 이용해 구조체 배열을 "점수 오름차순", "이름 사전순" 두 기준으로 각각 정렬하는 비교 함수를 작성하세요.
  3. 콜백을 받는 for_each_int 함수를 만들어 배열 각 원소를 순회하며, 콜백이 0이 아닌 값을 반환하면 순회를 중단하도록 구현해보세요.

이전 강의 정답

44강(비트 연산) 연습문제 핵심 정리:

  • 권한 플래그 기본 패턴
    • 추가: perms |= WRITE;
    • 제거: perms &= ~WRITE;
    • 검사: (perms & WRITE) != 0
  • 16비트 분해 예시(상위 5/중간 3/하위 8)
    • a = (v >> 11) & 0x1F;
    • b = (v >> 8) & 0x07;
    • c = v & 0xFF;
  • 우선순위 버그 수정
    • 잘못: flags & MASK == 0
    • 올바름: (flags & MASK) == 0

실습 환경/재현 정보

  • 컴파일러: Apple clang 17+ 또는 gcc 12+
  • 컴파일 옵션: -std=c11 -Wall -Wextra -Werror -O0
  • 실행 환경: macOS(arm64), Linux(x86_64) 공통
  • 재현 체크:
    • 예제 1: add/sub 결과가 예상값과 일치
    • 예제 2: 오름차순/내림차순 정렬 결과가 달라지는지 확인
    • 예제 3: NULL 콜백 전달 시 에러 처리 경로가 정상 동작하는지 확인