[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 코드를 유연하고 재사용 가능하게 만드는 핵심 설계 도구다.
연습문제
int (*op)(int,int)타입을typedef로 선언하고, 사칙연산 함수 4개를 테이블로 등록한 뒤 문자열 명령(add,sub,mul,div)으로 실행해보세요.qsort를 이용해 구조체 배열을 "점수 오름차순", "이름 사전순" 두 기준으로 각각 정렬하는 비교 함수를 작성하세요.- 콜백을 받는
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 콜백 전달 시 에러 처리 경로가 정상 동작하는지 확인
- 예제 1: