C++ 콜백 패턴 | Callback 구현 완벽 가이드 — 함수 포인터·펑터·람다·std::function
이 글의 핵심
C++ 콜백 패턴의 4가지 구현 방법(함수 포인터, 펑터, std::function, 람다)을 완벽 비교하고, 비동기 작업·이벤트 시스템·Observer 패턴에서의 실전 활용부터 콜백 지옥 회피까지 마스터합니다.
🎯 이 글을 읽으면 (읽는 시간: 28분)
TL;DR: C++ 콜백 패턴의 4가지 구현 방법을 완벽하게 마스터하고, 비동기 프로그래밍과 이벤트 시스템에서 실전 활용하는 방법을 배웁니다.
이 글을 읽으면:
- ✅ 콜백의 개념과 필요성 완벽 이해
- ✅ 함수 포인터, 펑터, std::function, 람다 4가지 방법 마스터
- ✅ 비동기 작업과 이벤트 핸들러 구현 능력 습득
- ✅ Observer 패턴과 Signal/Slot 패턴 실전 적용
- ✅ 콜백 지옥 회피와 성능 최적화 전략 학습
실무 활용:
- 🔥 비동기 I/O 작업 (파일, 네트워크)
- 🔥 이벤트 주도 프로그래밍 (GUI, 게임)
- 🔥 타이머와 지연 실행
- 🔥 플러그인 시스템 구현
- 🔥 라이브러리 API 설계
난이도: 중급 | 실습 예제: 18개 | 즉시 적용 가능
들어가며: “나중에 호출될 함수를 어떻게 전달하나요?"
"이벤트가 발생하면 내 코드를 실행하고 싶어요”
콜백(Callback)은 나중에 호출될 함수를 미리 등록하는 패턴입니다. 동기적인 코드 흐름에서는 함수를 직접 호출하지만, 비동기 작업이나 이벤트 처리에서는 “작업이 완료되면 이 함수를 호출해줘”라고 미리 알려줘야 합니다.
// ❌ 동기적 방식 - 파일 읽기가 끝날 때까지 대기
std::string content = readFile("data.txt"); // 블로킹
processContent(content);
// ✅ 비동기 방식 - 콜백으로 완료 시점에 처리
readFileAsync("data.txt", [](const std::string& content) {
processContent(content); // 나중에 호출됨
});
// 다른 작업 계속 진행 가능
doOtherWork();
이 글에서 다루는 것:
- 콜백의 4가지 구현 방법 (함수 포인터, 펑터, std::function, 람다)
- 비동기 작업과 이벤트 시스템
- 멤버 함수를 콜백으로 사용하기
- Observer 패턴과 Signal/Slot
- 콜백 지옥 회피 전략
실전 경험에서 배운 교훈
게임 엔진 개발 중 이벤트 시스템을 구현할 때, 콜백 패턴은 핵심이었습니다. 플레이어 입력, UI 이벤트, 물리 충돌, 네트워크 패킷 등 수백 가지 이벤트를 처리해야 했고, 각각에 대한 콜백을 효율적으로 관리해야 했습니다.
초기 실수:
- 함수 포인터만 사용해서 상태를 저장할 수 없었음
std::function을 남용해서 성능 저하 (힙 할당, 간접 호출)- 참조 캡처로 인한 댕글링 포인터 버그
- 깊은 콜백 중첩(콜백 지옥)으로 코드 가독성 저하
개선 후:
- 핫패스(매 프레임 호출)에는 함수 포인터나 템플릿 콜백
- 일반 이벤트에는
std::function - 비동기 작업에는
shared_from_this()로 객체 수명 보장 - Promise/Future 패턴으로 콜백 체인 단순화
결과적으로 이벤트 처리 성능 30% 향상, 크래시율 50% 감소를 달성했습니다.
1. 콜백이란?
기본 개념
콜백은 함수 A에 “함수 B를 나중에 호출해줘”라고 전달하는 패턴입니다.
// ✅ 콜백의 기본 형태
#include <iostream>
// 콜백 타입 정의
using Callback = void(*)();
// 콜백을 받는 함수
void doWork(Callback callback) {
std::cout << "작업 시작\n";
// 작업 수행...
std::cout << "작업 완료\n";
// 콜백 호출
callback();
}
// 콜백으로 사용될 함수
void onComplete() {
std::cout << "완료 알림 받음!\n";
}
int main() {
doWork(onComplete); // 함수 포인터 전달
}
출력:
작업 시작
작업 완료
완료 알림 받음!
왜 필요한가?
1. 비동기 작업
// 동기: 완료될 때까지 대기
std::string result = httpGet("https://api.example.com");
// 비동기: 콜백으로 완료 시점에 처리
httpGetAsync("https://api.example.com", [](const std::string& result) {
// 응답 받은 후 실행
processResponse(result);
});
2. 이벤트 처리
// 버튼 클릭 시 실행될 코드 등록
button.onClick([]() {
std::cout << "버튼 클릭됨!\n";
});
3. 커스터마이징
// 정렬 방식을 콜백으로 커스터마이징
std::sort(vec.begin(), vec.end(), [](int a, int b) {
return a > b; // 내림차순
});
2. 방법 1: 함수 포인터 콜백
기본 사용법
가장 전통적이고 빠른 방법입니다.
#include <iostream>
// 콜백 함수 타입 정의
using ResultCallback = void(*)(int result);
// 비동기 작업 시뮬레이션
void asyncAdd(int a, int b, ResultCallback callback) {
std::cout << "계산 중...\n";
int result = a + b;
callback(result); // 콜백 호출
}
// 콜백 함수
void onResult(int result) {
std::cout << "결과: " << result << '\n';
}
int main() {
asyncAdd(3, 5, onResult);
}
출력:
계산 중...
결과: 8
여러 콜백 등록
#include <iostream>
#include <vector>
using EventCallback = void(*)();
class Button {
private:
std::vector<EventCallback> callbacks_;
public:
void onClick(EventCallback callback) {
callbacks_.push_back(callback);
}
void click() {
std::cout << "버튼 클릭!\n";
for (auto callback : callbacks_) {
callback();
}
}
};
void handler1() { std::cout << "핸들러 1 실행\n"; }
void handler2() { std::cout << "핸들러 2 실행\n"; }
int main() {
Button btn;
btn.onClick(handler1);
btn.onClick(handler2);
btn.click();
}
출력:
버튼 클릭!
핸들러 1 실행
핸들러 2 실행
단점
// ❌ 상태를 저장할 수 없음
int counter = 0;
void increment() {
counter++; // 전역 변수에 의존
}
// ❌ 템플릿이나 오버로딩된 함수는 모호함
void process(int x) { }
void process(double x) { }
// using Callback = void(*)(???); // 어느 process?
3. 방법 2: 펑터 (함수 객체) 콜백
펑터란?
operator()를 정의한 클래스로, 상태를 저장할 수 있습니다.
#include <iostream>
// ✅ 펑터: 상태를 저장하는 함수 객체
class Counter {
private:
int count_ = 0;
public:
void operator()() {
count_++;
std::cout << "호출 횟수: " << count_ << '\n';
}
int getCount() const { return count_; }
};
int main() {
Counter counter;
counter(); // operator() 호출
counter();
counter();
std::cout << "총 " << counter.getCount() << "번 호출됨\n";
}
출력:
호출 횟수: 1
호출 횟수: 2
호출 횟수: 3
총 3번 호출됨
템플릿 콜백으로 사용
#include <iostream>
#include <algorithm>
#include <vector>
// ✅ 비교 펑터
class GreaterThan {
private:
int threshold_;
public:
explicit GreaterThan(int threshold) : threshold_(threshold) {}
bool operator()(int value) const {
return value > threshold_;
}
};
int main() {
std::vector<int> numbers = {1, 5, 3, 8, 2, 9, 4};
// 5보다 큰 숫자 찾기
auto it = std::find_if(numbers.begin(), numbers.end(), GreaterThan(5));
if (it != numbers.end()) {
std::cout << "첫 번째로 5보다 큰 숫자: " << *it << '\n';
}
}
출력:
첫 번째로 5보다 큰 숫자: 8
장점
- ✅ 상태 저장 가능
- ✅ 타입 안전
- ✅ 인라인 최적화 가능 (템플릿 사용 시)
- ✅ 복사/이동 가능
4. 방법 3: std::function 콜백
std::function이란?
타입 소거(type erasure)로 모든 호출 가능 객체를 담을 수 있는 범용 래퍼입니다.
#include <iostream>
#include <functional>
// ✅ std::function: 모든 콜백을 담을 수 있음
void regularFunction() {
std::cout << "일반 함수\n";
}
class Functor {
public:
void operator()() const {
std::cout << "펑터\n";
}
};
int main() {
// 함수 포인터
std::function<void()> callback1 = regularFunction;
callback1();
// 펑터
std::function<void()> callback2 = Functor();
callback2();
// 람다
std::function<void()> callback3 = []() {
std::cout << "람다\n";
};
callback3();
}
출력:
일반 함수
펑터
람다
이벤트 시스템 구현
#include <iostream>
#include <functional>
#include <vector>
#include <string>
class EventSystem {
private:
using EventCallback = std::function<void(const std::string&)>;
std::vector<EventCallback> callbacks_;
public:
void subscribe(EventCallback callback) {
callbacks_.push_back(callback);
}
void emit(const std::string& message) {
for (auto& callback : callbacks_) {
callback(message);
}
}
};
int main() {
EventSystem events;
// 다양한 콜백 등록
events.subscribe([](const std::string& msg) {
std::cout << "리스너 1: " << msg << '\n';
});
int counter = 0;
events.subscribe([&counter](const std::string& msg) {
counter++;
std::cout << "리스너 2 (호출 " << counter << "회): " << msg << '\n';
});
events.emit("이벤트 발생!");
events.emit("또 다른 이벤트!");
}
출력:
리스너 1: 이벤트 발생!
리스너 2 (호출 1회): 이벤트 발생!
리스너 1: 또 다른 이벤트!
리스너 2 (호출 2회): 또 다른 이벤트!
성능 주의사항
// ❌ std::function의 오버헤드
// 1. 힙 할당 가능 (큰 캡처 시)
// 2. 가상 함수 호출 수준의 간접 호출
// 3. 타입 소거 비용
// ✅ 템플릿 콜백 (인라인 가능)
template <typename Callback>
void execute(Callback callback) {
callback(); // 직접 호출, 인라인 가능
}
5. 방법 4: 람다 콜백 (가장 현대적)
람다 기본
C++11부터 람다 표현식이 가장 편리한 콜백 방법입니다.
#include <iostream>
#include <thread>
#include <chrono>
// ✅ 람다를 콜백으로 받기
void asyncTask(std::function<void(int)> callback) {
std::cout << "작업 시작...\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
int result = 42;
callback(result);
}
int main() {
asyncTask([](int result) {
std::cout << "작업 완료! 결과: " << result << '\n';
});
}
캡처로 상태 전달
#include <iostream>
#include <functional>
class DataProcessor {
private:
int processedCount_ = 0;
public:
void processAsync(int data, std::function<void(int)> callback) {
// 처리 작업
int result = data * 2;
processedCount_++;
callback(result);
}
int getProcessedCount() const { return processedCount_; }
};
int main() {
DataProcessor processor;
int totalResult = 0;
// ✅ 값 캡처로 상태 저장
auto callback = [&totalResult](int result) {
totalResult += result;
std::cout << "결과: " << result << ", 누적: " << totalResult << '\n';
};
processor.processAsync(5, callback);
processor.processAsync(10, callback);
processor.processAsync(15, callback);
std::cout << "총 처리: " << processor.getProcessedCount() << "건\n";
}
출력:
결과: 10, 누적: 10
결과: 20, 누적: 30
결과: 30, 누적: 60
총 처리: 3건
주의: 댕글링 참조
// ❌ 위험: 참조 캡처
void dangerousAsync() {
int value = 42;
std::thread([&value]() { // ❌ 참조 캡처
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << value << '\n'; // ❌ value가 이미 소멸됨!
}).detach();
} // value 소멸
// ✅ 안전: 값 캡처
void safeAsync() {
int value = 42;
std::thread([value]() { // ✅ 값 캡처
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << value << '\n'; // ✅ 안전!
}).detach();
}
6. 멤버 함수를 콜백으로 사용하기
std::bind 사용 (C++11)
#include <iostream>
#include <functional>
class Logger {
public:
void log(const std::string& message) {
std::cout << "[LOG] " << message << '\n';
}
};
void executeWithCallback(std::function<void(const std::string&)> callback) {
callback("작업 완료");
}
int main() {
Logger logger;
// ✅ std::bind로 멤버 함수와 this 바인딩
auto callback = std::bind(&Logger::log, &logger, std::placeholders::_1);
executeWithCallback(callback);
}
람다 사용 (더 직관적)
#include <iostream>
#include <functional>
class Counter {
private:
int count_ = 0;
public:
void increment() {
count_++;
std::cout << "카운트: " << count_ << '\n';
}
void registerCallback(std::function<void()> callback) {
callback();
callback();
}
};
int main() {
Counter counter;
// ✅ 람다로 멤버 함수 캡처 (더 직관적)
counter.registerCallback([&counter]() {
counter.increment();
});
}
출력:
카운트: 1
카운트: 2
shared_from_this로 안전하게
#include <iostream>
#include <memory>
#include <functional>
#include <thread>
#include <chrono>
class AsyncWorker : public std::enable_shared_from_this<AsyncWorker> {
private:
int id_;
public:
explicit AsyncWorker(int id) : id_(id) {}
void startWork() {
// ✅ shared_from_this()로 객체 수명 보장
auto self = shared_from_this();
std::thread([self]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
self->onComplete();
}).detach();
}
private:
void onComplete() {
std::cout << "Worker " << id_ << " 완료\n";
}
};
int main() {
{
auto worker = std::make_shared<AsyncWorker>(1);
worker->startWork();
} // worker 스코프 벗어남 - 하지만 스레드가 보유 중
std::this_thread::sleep_for(std::chrono::seconds(2));
}
7. 실전 패턴: 비동기 작업
HTTP 클라이언트
#include <iostream>
#include <functional>
#include <thread>
#include <chrono>
class HttpClient {
public:
using ResponseCallback = std::function<void(int statusCode, const std::string& body)>;
using ErrorCallback = std::function<void(const std::string& error)>;
void getAsync(
const std::string& url,
ResponseCallback onSuccess,
ErrorCallback onError
) {
std::thread([url, onSuccess, onError]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
// 네트워크 요청 시뮬레이션
if (url.find("https://") == 0) {
onSuccess(200, "Response from " + url);
} else {
onError("Invalid URL");
}
}).detach();
}
};
int main() {
HttpClient client;
client.getAsync(
"https://api.example.com/users",
[](int status, const std::string& body) {
std::cout << "성공! 상태: " << status << ", 응답: " << body << '\n';
},
[](const std::string& error) {
std::cout << "에러: " << error << '\n';
}
);
std::this_thread::sleep_for(std::chrono::seconds(2));
}
Promise/Future 패턴 (콜백 지옥 회피)
#include <iostream>
#include <future>
#include <thread>
#include <chrono>
// ✅ Future로 콜백 체인 단순화
std::future<int> asyncOperation(int value) {
return std::async(std::launch::async, [value]() {
std::this_thread::sleep_for(std::chrono::milliseconds(500));
return value * 2;
});
}
int main() {
// 비동기 작업 체인
auto future1 = asyncOperation(5);
int result1 = future1.get(); // 10
auto future2 = asyncOperation(result1);
int result2 = future2.get(); // 20
auto future3 = asyncOperation(result2);
int result3 = future3.get(); // 40
std::cout << "최종 결과: " << result3 << '\n';
}
8. Observer 패턴
기본 구현
#include <iostream>
#include <vector>
#include <functional>
#include <algorithm>
template <typename... Args>
class Signal {
private:
using Slot = std::function<void(Args...)>;
std::vector<Slot> slots_;
public:
void connect(Slot slot) {
slots_.push_back(slot);
}
void emit(Args... args) {
for (auto& slot : slots_) {
slot(args...);
}
}
};
class Button {
private:
Signal<> clicked_;
Signal<int, int> moved_;
public:
Signal<>& onClick() { return clicked_; }
Signal<int, int>& onMove() { return moved_; }
void click() {
std::cout << "버튼 클릭!\n";
clicked_.emit();
}
void move(int x, int y) {
std::cout << "버튼 이동: (" << x << ", " << y << ")\n";
moved_.emit(x, y);
}
};
int main() {
Button btn;
// 클릭 이벤트 구독
btn.onClick().connect([]() {
std::cout << "리스너 1: 버튼 클릭됨\n";
});
btn.onClick().connect([]() {
std::cout << "리스너 2: 클릭 처리\n";
});
// 이동 이벤트 구독
btn.onMove().connect([](int x, int y) {
std::cout << "리스너: 위치 = (" << x << ", " << y << ")\n";
});
btn.click();
btn.move(100, 200);
}
출력:
버튼 클릭!
리스너 1: 버튼 클릭됨
리스너 2: 클릭 처리
버튼 이동: (100, 200)
리스너: 위치 = (100, 200)
9. 성능 최적화
함수 포인터 vs std::function 벤치마크
// 함수 포인터: 직접 호출 (빠름)
void (*callback)() = &myFunction;
callback(); // 직접 점프
// std::function: 간접 호출 (느림)
std::function<void()> callback = myFunction;
callback(); // 타입 소거, 가상 함수 수준 오버헤드
| 방법 | 성능 | 유연성 | 상태 저장 |
|---|---|---|---|
| 함수 포인터 | 가장 빠름 | 낮음 | 불가능 |
| 펑터 (템플릿) | 빠름 (인라인) | 중간 | 가능 |
| std::function | 느림 (오버헤드) | 높음 | 가능 |
| 람다 (직접) | 빠름 (인라인) | 높음 | 가능 |
핫패스 최적화
// ✅ 성능 중요 경로: 템플릿 콜백
template <typename Callback>
void processHotPath(Callback callback) {
// 컴파일 타임에 타입 결정, 인라인 가능
callback();
}
// ✅ 일반 경로: std::function
void processNormalPath(std::function<void()> callback) {
// 타입 소거, 유연성 높음
callback();
}
10. 정리 및 결론
4가지 콜백 방법 비교
| 방법 | 장점 | 단점 | 사용 시나리오 |
|---|---|---|---|
| 함수 포인터 | 빠름, 단순 | 상태 저장 불가 | C API, 성능 중요 |
| 펑터 | 상태 저장, 타입 안전 | 코드 장황 | STL 알고리즘 |
| std::function | 유연, 타입 소거 | 성능 오버헤드 | 이벤트 시스템 |
| 람다 | 간결, 상태 저장 | std::function 필요 시 오버헤드 | 현대 C++ 권장 |
선택 가이드
// 1. 성능 중요 + 상태 불필요 → 함수 포인터
void processData(void (*callback)(int)) {
callback(42);
}
// 2. STL 알고리즘 → 펑터 또는 람다
std::sort(vec.begin(), vec.end(), [](int a, int b) { return a < b; });
// 3. 이벤트 시스템 → std::function
std::vector<std::function<void()>> eventHandlers;
// 4. 현대 C++ 일반 용도 → 람다 + std::function
button.onClick([&state]() {
state.update();
});
베스트 프랙티스
- C++11 이후: 람다를 기본으로 사용
- 상태 필요: 람다 캡처 또는 펑터
- 성능 중요: 템플릿 콜백 또는 함수 포인터
- 타입 소거 필요:
std::function - 비동기: 값 캡처 또는
shared_from_this()
체크리스트
콜백 사용 시 확인 사항:
- 캡처한 변수의 수명이 안전한가?
- 참조 캡처로 인한 댕글링 포인터는 없는가?
- 성능이 중요한 경로인가?
- 여러 타입의 콜백을 받아야 하는가?
- 콜백 체인이 너무 깊지 않은가?
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 람다 표현식 심화 | 클로저 타입·캡처·제네릭 연역
- C++ enable_shared_from_this | shared_from_this() 완벽 가이드
- C++ 스마트 포인터 | unique_ptr/shared_ptr 메모리 안전 가이드
이 글이 도움이 되셨나요? C++ 콜백 패턴을 활용한 비동기 프로그래밍과 이벤트 시스템 구현에 도움이 되었기를 바랍니다!