C++ std::function vs 함수 포인터: 성능과 유연성의 트레이드오프
이 글의 핵심
캡처가 있는 람다를 함수 포인터에 담으려다 컴파일 에러를 만나는 데서 출발합니다. 이 글의 측정(MinGW GCC 10.3 -O2)에서 두 방식의 호출 비용 차이는 나노초 이하였고, 의미 있는 차이는 템플릿이 가능하게 하는 인라인과 std::function 생성 시 힙 할당에서 났습니다. 댕글링 참조 캡처, nullptr 체크 누락 같은 실수와 선택 기준까지 정리합니다.
콜백을 저장하는 두 가지 방법
캡처 람다는 함수 포인터에 담기지 않는다
C++에서 콜백 함수를 저장하는 방법은 함수 포인터와 std::function 두 가지가 있습니다. 각각 성능과 유연성에서 차이가 있습니다.
비유로 말씀드리면, 함수 포인터는 정해진 형태의 전화 한 줄로만 걸 수 있는 유선 전화, std::function은 스마트폰 앱처럼 람다·함수 객체까지 담는 통에 가깝습니다. 유연해질수록 내부에서 간접 호출·저장 비용이 늘 수 있습니다.
언제 std::function을, 언제 함수 포인터를 쓰나요?
| 관점 | 함수 포인터 | std::function |
|---|---|---|
| 성능 | 직접 호출에 가깝게 빠름 | 타입 소거·간접 호출로 오버헤드 |
| 사용성 | 캡처 있는 람다 등 저장 불가 | 람다·멤버 바인딩 등 폭넓게 저장 |
| 적용 시나리오 | 핫패스·C API | 콜백 타입을 통일해야 할 때 |
// 함수 포인터: 빠르지만 제한적
void (*callback)(int) = nullptr;
void myFunc(int x) {
std::cout << x << '\n';
}
callback = myFunc; // ✅ OK
callback(42);
// 람다 (캡처 없음)
callback = [](int x) { std::cout << x << '\n'; }; // ✅ OK
// 람다 (캡처 있음)
int multiplier = 2;
// callback = [multiplier](int x) { std::cout << x * multiplier << '\n'; }; // ❌ 컴파일 에러
// std::function: 느리지만 유연
std::function<void(int)> func;
func = myFunc; // ✅ OK
func = [](int x) { std::cout << x << '\n'; }; // ✅ OK
func = [multiplier](int x) { std::cout << x * multiplier << '\n'; }; // ✅ OK (캡처 가능!)
이 글에서 다루는 것:
- std::function vs 함수 포인터 차이
- 성능 비교
- 사용 시나리오
- 실전 선택 가이드
타입 소거 유무로 갈리는 차이
항목별 비교표
| 항목 | 함수 포인터 | std::function |
|---|---|---|
| 저장 가능 | 일반 함수, 캡처 없는 람다 | 모든 호출 가능 객체 |
| 람다 캡처 | ❌ | ✅ |
| 함수 객체 | ❌ | ✅ |
| 멤버 함수 | ❌ (복잡) | ✅ |
| 성능 | 호출은 비슷, 인라인 가능성은 높음 | 호출은 비슷, 인라인 어려움·생성 시 할당 가능 |
| 메모리 | 8바이트 | libstdc++ 32바이트, libc++ 48바이트, MSVC 64바이트 + 힙 할당 가능 |
| 타입 안전성 | 낮음 | 높음 |
| C++ 버전 | 모든 버전 | C++11 이후 |
함수 포인터: 가볍지만 상태를 담을 수 없다
// 함수 포인터 선언
void (*funcPtr)(int) = nullptr;
// 일반 함수
void printInt(int x) {
std::cout << x << '\n';
}
funcPtr = printInt; // ✅ OK
funcPtr(42);
// 캡처 없는 람다
funcPtr = [](int x) { std::cout << x * 2 << '\n'; }; // ✅ OK
// 캡처 있는 람다
int multiplier = 3;
// funcPtr = [multiplier](int x) { std::cout << x * multiplier << '\n'; }; // ❌ 컴파일 에러
// error: cannot convert lambda with captures to function pointer
std::function: 무엇이든 담지만 비용이 있다
// std::function 선언
std::function<void(int)> func;
// 일반 함수
func = printInt; // ✅ OK
func(42);
// 캡처 없는 람다
func = [](int x) { std::cout << x * 2 << '\n'; }; // ✅ OK
// 캡처 있는 람다
int multiplier = 3;
func = [multiplier](int x) { std::cout << x * multiplier << '\n'; }; // ✅ OK
func(42); // 126
// 함수 객체
struct Multiplier {
int factor;
void operator()(int x) const {
std::cout << x * factor << '\n';
}
};
func = Multiplier{5}; // ✅ OK
func(42); // 210
// 멤버 함수
class Calculator {
public:
void add(int x) {
std::cout << "Result: " << x + 10 << '\n';
}
};
Calculator calc;
func = [&calc](int x) { calc.add(x); }; // ✅ OK
func(42); // Result: 52
호출 비용·생성 비용·메모리 크기 측정
벤치마크: 일반 함수 호출
#include <benchmark/benchmark.h>
#include <functional>
// 테스트 함수
int add(int a, int b) {
return a + b;
}
// 함수 포인터
static void BM_FunctionPointer(benchmark::State& state) {
int (*funcPtr)(int, int) = add;
for (auto _ : state) {
int result = funcPtr(10, 20);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(BM_FunctionPointer);
// std::function
static void BM_StdFunction(benchmark::State& state) {
std::function<int(int, int)> func = add;
for (auto _ : state) {
int result = func(10, 20);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(BM_StdFunction);
// 직접 호출 (기준)
static void BM_DirectCall(benchmark::State& state) {
for (auto _ : state) {
int result = add(10, 20);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(BM_DirectCall);
결과: 이전 버전의 “GCC 13 -O3: 0.5 / 1.0 / 3.0 ns”는 출처가 없어, 같은 비교를 std::chrono 루프(1억 회, 결과는 volatile에 저장)로 직접 측정했습니다. MinGW GCC 10.3 -O2, 두 번 실행한 값입니다.
| 호출 방식 | 호출 1회 |
|---|---|
직접 호출 (noinline으로 인라인 금지) | 1.3~1.8ns |
함수 포인터 (volatile 포인터로 상수 전파 방지) | 1.1~1.2ns |
std::function | 1.7ns |
세 방식의 차이는 1ns도 안 되고, 직접 호출이 함수 포인터보다 느리게 나온 회차도 있을 만큼 측정 잡음 수준입니다. std::function 호출은 결국 “내부에 저장된 호출 함수 포인터를 한 번 더 따라가는 간접 호출”이라, 분기 예측이 맞는 반복 호출에서는 함수 포인터와 거의 같습니다. 성능 차이가 실제로 생기는 곳은 두 군데입니다. 첫째, 호출되는 쪽이 보이는 템플릿(template<class F> void run(F f))이나 직접 호출은 컴파일러가 인라인해서 호출 자체를 없앨 수 있지만, 함수 포인터와 std::function은 대부분 그러지 못합니다(위 측정에서 인라인을 일부러 막은 이유). 둘째, std::function을 만들거나 복사할 때의 힙 할당입니다(아래 메모리 절).
벤치마크: 캡처 람다 호출
// 캡처 없는 람다 (함수 포인터)
static void BM_LambdaNoCapturePtr(benchmark::State& state) {
int (*funcPtr)(int, int) = [](int a, int b) { return a + b; };
for (auto _ : state) {
int result = funcPtr(10, 20);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(BM_LambdaNoCapturePtr);
// 캡처 없는 람다 (std::function)
static void BM_LambdaNoCaptureFunc(benchmark::State& state) {
std::function<int(int, int)> func = [](int a, int b) { return a + b; };
for (auto _ : state) {
int result = func(10, 20);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(BM_LambdaNoCaptureFunc);
// 캡처 있는 람다 (std::function만 가능)
static void BM_LambdaWithCapture(benchmark::State& state) {
int multiplier = 2;
std::function<int(int, int)> func = [multiplier](int a, int b) {
return (a + b) * multiplier;
};
for (auto _ : state) {
int result = func(10, 20);
benchmark::DoNotOptimize(result);
}
}
BENCHMARK(BM_LambdaWithCapture);
결과: 이 표도 출처 없는 값이라 뺐습니다. 호출 비용은 위와 같은 이유로 캡처 유무와 무관하게 나노초 수준에서 거의 같고, 캡처가 성능에 영향을 주는 것은 호출이 아니라 std::function에 담는 순간의 할당 여부입니다.
std::function이 함수 포인터보다 느린 근본적인 이유는 타입 소거(type erasure) 방식 때문입니다. 구현마다 방식은 다르지만(libstdc++는 가상 함수 대신 “호출 함수 포인터”와 “관리 함수 포인터”를 객체 안에 저장합니다), 결국 저장된 호출 가능 객체를 한 단계 더 간접적으로 호출하게 되어 함수 포인터의 단순 간접 호출보다 한 단계가 늘어납니다. 게다가 저장된 객체의 크기가 내부 버퍼(SBO, 보통 16바이트 안팎)를 넘으면 힙 할당까지 추가되므로, 큰 캡처를 가진 람다는 할당 비용까지 더해져 격차가 벌어집니다.
호출 오버헤드 분해
// 1. 직접 호출
int add(int a, int b) { return a + b; }
int result = add(1, 2); // 인라인되면 호출 자체가 사라짐
// 2. 함수 포인터
int (*funcPtr)(int, int) = add;
int result = funcPtr(1, 2); // 간접 호출 (측정 약 1.1ns)
// 3. std::function
std::function<int(int, int)> func = add;
int result = func(1, 2); // 타입 소거 + 간접 호출 (측정 약 1.7ns)
크기와 힙 할당 비교표
| 타입 | 크기 | 힙 할당 | 비고 |
|---|---|---|---|
| 함수 포인터 | 8B | 없음 | 포인터만 |
| std::function (작은 캡처) | 32B | 없음 | SBO 적용 |
| std::function (큰 캡처) | 32B | 있음 | 힙 할당 |
// SBO (Small Buffer Optimization)
int x = 42;
std::function<int()> func1 = [x]() { return x; }; // SBO (힙 할당 없음)
std::array<int, 100> bigData;
std::function<int()> func2 = [bigData]() { return 0; }; // 힙 할당
함수 포인터의 크기
void (*funcPtr)(int) = nullptr;
std::cout << sizeof(funcPtr) << '\n'; // 8 (포인터 크기)
std::function의 크기와 SBO
std::function<void(int)> func;
std::cout << sizeof(func) << '\n'; // 32 (구현마다 다름)
// 작은 캡처: 내부 버퍼 사용 (힙 할당 없음)
int x = 42;
func = [x](int y) { std::cout << x + y << '\n'; };
// 큰 캡처: 힙 할당
std::array<int, 100> bigData;
func = [bigData](int y) { /* ... */ }; // 힙 할당 발생
operator new를 가로채 세어 보면(MinGW GCC 10) int 하나를 캡처한 람다는 할당 0회, 32바이트 구조체를 캡처한 람다는 1회, std::string을 캡처한 람다는 2회(람다 저장 1회 + 문자열 복사 1회)였습니다. libstdc++의 std::function은 16바이트 이하이고 trivially copyable인 호출체만 내부 버퍼에 두기 때문에, std::string처럼 작아도 복사 생성자가 있는 타입을 캡처하면 힙으로 갑니다. 이 기준은 표준이 정한 것이 아니라 구현마다 다르므로(MSVC·libc++는 버퍼 크기가 다름), 콜백을 초당 수십만 개 만드는 코드라면 std::function을 재사용하거나 템플릿으로 받는 설계를 검토합니다.
함수 포인터가 맞는 곳과 std::function이 맞는 곳
핫 루프 콜백: 함수 포인터
// ✅ 함수 포인터: 성능 중요한 콜백
class EventLoop {
using Callback = void (*)(int);
std::vector<Callback> callbacks_;
public:
void addCallback(Callback cb) {
callbacks_.push_back(cb);
}
void processEvents() {
for (int i = 0; i < 1000000; ++i) {
for (auto cb : callbacks_) {
cb(i); // 빠른 호출
}
}
}
};
void onEvent(int value) {
// 처리
}
int main() {
EventLoop loop;
loop.addCallback(onEvent);
loop.addCallback([](int value) { /* 처리 */ }); // 캡처 없는 람다
loop.processEvents(); // 빠름!
}
여러 종류의 호출 가능 객체: std::function
// ✅ std::function: 유연한 콜백
class Button {
std::function<void()> onClick_;
public:
void setOnClick(std::function<void()> callback) {
onClick_ = callback;
}
void click() {
if (onClick_) {
onClick_();
}
}
};
int main() {
Button button;
// 람다 캡처
int clickCount = 0;
button.setOnClick([&clickCount]() {
++clickCount;
std::cout << "클릭 횟수: " << clickCount << '\n';
});
button.click(); // 클릭 횟수: 1
button.click(); // 클릭 횟수: 2
// 함수 객체
struct Logger {
void operator()() const {
std::cout << "버튼 클릭됨\n";
}
};
button.setOnClick(Logger{});
button.click(); // 버튼 클릭됨
}
멤버 함수 바인딩: std::function
// ✅ std::function: 멤버 함수 바인딩
class Server {
public:
void handleRequest(const std::string& request) {
std::cout << "요청 처리: " << request << '\n';
}
};
class RequestHandler {
std::function<void(const std::string&)> handler_;
public:
void setHandler(std::function<void(const std::string&)> handler) {
handler_ = handler;
}
void process(const std::string& request) {
if (handler_) {
handler_(request);
}
}
};
int main() {
Server server;
RequestHandler handler;
// 멤버 함수 바인딩
handler.setHandler([&server](const std::string& req) {
server.handleRequest(req);
});
handler.process("GET /api/users"); // 요청 처리: GET /api/users
}
선택 기준 정리
함수 포인터를 고르는 조건
// ✅ 함수 포인터 사용 시나리오
// 1. 성능이 매우 중요
void processData(int* data, size_t size, int (*transform)(int)) {
for (size_t i = 0; i < size; ++i) {
data[i] = transform(data[i]); // 빠른 호출
}
}
// 2. C 라이브러리 인터페이스
extern "C" {
void register_callback(void (*callback)(int));
}
// 3. 단순한 콜백
void sort(int* arr, size_t size, bool (*compare)(int, int)) {
// 정렬
}
std::function을 고르는 조건
// ✅ std::function 사용 시나리오
// 1. 람다 캡처 필요
class Timer {
std::function<void()> callback_;
public:
void setCallback(std::function<void()> cb) {
callback_ = cb;
}
};
int timeout = 1000;
timer.setCallback([timeout]() {
std::cout << "타임아웃: " << timeout << "ms\n";
});
// 2. 다양한 호출 가능 객체
std::vector<std::function<void()>> tasks;
tasks.push_back([]() { /* 작업 1 */ });
tasks.push_back(MyFunctor{});
tasks.push_back([&]() { /* 작업 2 */ });
// 3. 타입 소거 필요
class EventDispatcher {
std::map<std::string, std::function<void(const Event&)>> handlers_;
public:
void on(const std::string& event, std::function<void(const Event&)> handler) {
handlers_[event] = handler;
}
};
이벤트 시스템·스레드 풀·HTTP 서버·작업 큐 구현
문자열 키로 리스너를 등록하는 이벤트 시스템
// std::function: 유연한 이벤트 시스템
class EventSystem {
std::unordered_map<std::string, std::vector<std::function<void(const Event&)>>> listeners_;
public:
void addEventListener(const std::string& eventType,
std::function<void(const Event&)> listener) {
listeners_[eventType].push_back(listener);
}
void dispatchEvent(const std::string& eventType, const Event& event) {
auto it = listeners_.find(eventType);
if (it != listeners_.end()) {
for (auto& listener : it->second) {
listener(event);
}
}
}
};
int main() {
EventSystem events;
// 람다 캡처
int clickCount = 0;
events.addEventListener("click", [&clickCount](const Event& e) {
++clickCount;
std::cout << "클릭 " << clickCount << "회\n";
});
// 함수 객체
struct Logger {
void operator()(const Event& e) const {
std::cout << "이벤트 로그: " << e.type << '\n';
}
};
events.addEventListener("click", Logger{});
Event clickEvent{"click"};
events.dispatchEvent("click", clickEvent);
}
스레드 풀의 작업 큐
// std::function: 작업 큐
class ThreadPool {
std::vector<std::thread> workers_;
std::queue<std::function<void()>> tasks_;
std::mutex mutex_;
std::condition_variable cv_;
bool stop_ = false;
public:
ThreadPool(size_t numThreads) {
for (size_t i = 0; i < numThreads; ++i) {
workers_.emplace_back([this]() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this]() { return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty()) {
return;
}
task = std::move(tasks_.front());
tasks_.pop();
}
task(); // 작업 실행
}
});
}
}
template <typename F>
void enqueue(F&& task) {
{
std::unique_lock<std::mutex> lock(mutex_);
tasks_.emplace(std::forward<F>(task));
}
cv_.notify_one();
}
~ThreadPool() {
{
std::unique_lock<std::mutex> lock(mutex_);
stop_ = true;
}
cv_.notify_all();
for (auto& worker : workers_) {
worker.join();
}
}
};
int main() {
ThreadPool pool(4);
// 다양한 작업 추가
pool.enqueue([]() { std::cout << "작업 1\n"; });
int x = 42;
pool.enqueue([x]() { std::cout << "작업 2: " << x << '\n'; });
pool.enqueue([]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "작업 3 완료\n";
});
}
게임 이벤트 시스템
// ✅ 실무 예시: 게임 이벤트
class GameEventSystem {
// 성능 중요: 프레임마다 호출
using FastCallback = void (*)(int entityId);
std::vector<FastCallback> updateCallbacks_;
// 유연성 중요: 가끔 호출
std::unordered_map<std::string, std::vector<std::function<void(const Event&)>>> eventHandlers_;
public:
// 빠른 업데이트 콜백
void registerUpdate(FastCallback callback) {
updateCallbacks_.push_back(callback);
}
void update() {
// 매 프레임 호출 - 빠름
for (auto callback : updateCallbacks_) {
callback(0);
}
}
// 이벤트 핸들러
void on(const std::string& event, std::function<void(const Event&)> handler) {
eventHandlers_[event].push_back(handler);
}
void emit(const std::string& event, const Event& e) {
// 가끔 호출 - 유연성
auto it = eventHandlers_.find(event);
if (it != eventHandlers_.end()) {
for (auto& handler : it->second) {
handler(e);
}
}
}
};
HTTP 서버 라우트 핸들러
// ✅ 실무 예시: HTTP 라우터
class HttpServer {
using Handler = std::function<void(const Request&, Response&)>;
std::unordered_map<std::string, Handler> routes_;
public:
void get(const std::string& path, Handler handler) {
routes_["GET:" + path] = handler;
}
void post(const std::string& path, Handler handler) {
routes_["POST:" + path] = handler;
}
void handleRequest(const Request& req, Response& res) {
std::string key = req.method + ":" + req.path;
auto it = routes_.find(key);
if (it != routes_.end()) {
it->second(req, res);
} else {
res.status(404).send("Not Found");
}
}
};
// 사용
HttpServer server;
// 람다 캡처 활용
Database db;
server.get("/users", [&db](const Request& req, Response& res) {
auto users = db.query("SELECT * FROM users");
res.json(users);
});
server.post("/users", [&db](const Request& req, Response& res) {
auto user = req.body<User>();
db.insert(user);
res.status(201).send("Created");
});
비동기 작업 큐
// ✅ 실무 예시: 작업 큐
class TaskQueue {
std::queue<std::function<void()>> tasks_;
std::mutex mutex_;
std::condition_variable cv_;
std::vector<std::thread> workers_;
bool stop_ = false;
public:
TaskQueue(size_t numWorkers) {
for (size_t i = 0; i < numWorkers; ++i) {
workers_.emplace_back([this]() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this]() { return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty()) {
return;
}
task = std::move(tasks_.front());
tasks_.pop();
}
task(); // 작업 실행
}
});
}
}
template <typename F>
void enqueue(F&& task) {
{
std::unique_lock<std::mutex> lock(mutex_);
tasks_.emplace(std::forward<F>(task));
}
cv_.notify_one();
}
~TaskQueue() {
{
std::unique_lock<std::mutex> lock(mutex_);
stop_ = true;
}
cv_.notify_all();
for (auto& worker : workers_) {
worker.join();
}
}
};
// 사용
TaskQueue queue(4);
// 다양한 작업 추가
queue.enqueue([]() { std::cout << "작업 1\n"; });
int x = 42;
queue.enqueue([x]() { std::cout << "작업 2: " << x << '\n'; });
queue.enqueue([&db = database]() {
db.cleanup();
});
콜백 인터페이스 설계와 템플릿 대안
콜백 인터페이스 설계
// ✅ 성능 중요: 함수 포인터
class HighPerformanceTimer {
using Callback = void (*)(void* userData);
Callback callback_;
void* userData_;
public:
void setCallback(Callback cb, void* data) {
callback_ = cb;
userData_ = data;
}
void tick() {
if (callback_) {
callback_(userData_); // 빠른 호출
}
}
};
// ✅ 유연성 중요: std::function
class EventEmitter {
std::unordered_map<std::string, std::vector<std::function<void(const Event&)>>> listeners_;
public:
void on(const std::string& event, std::function<void(const Event&)> listener) {
listeners_[event].push_back(listener);
}
};
템플릿으로 오버헤드 없애기
// ✅ 템플릿: 정적 디스패치
template <typename Callback>
class Timer {
Callback callback_;
public:
Timer(Callback cb) : callback_(cb) {}
void tick() {
callback_(); // 인라인 가능
}
};
// 사용
Timer timer([]() { std::cout << "Tick\n"; });
// 컴파일러가 람다를 인라인 전개 가능
코드 리뷰 체크포인트
// 🔍 리뷰 시 확인사항
// 1. 성능 중요한 루프
for (int i = 0; i < 1000000; ++i) {
callback(i); // ⚠️ std::function? 함수 포인터?
}
// 2. 람다 캡처
[&]() { /* ... */ } // ⚠️ 댕글링 참조 가능성?
// 3. 순환 참조
[shared_ptr]() { /* ... */ } // ⚠️ 순환 참조?
// 4. nullptr 체크
callback(); // ⚠️ nullptr 체크?
댕글링 캡처·nullptr 호출·순환 참조
참조 캡처한 지역 변수가 먼저 사라짐
// ❌ 실수: 지역 변수 참조 캡처
std::function<int()> createFunction() {
int x = 42;
return [&x]() { return x; }; // ❌ x는 함수 종료 시 소멸
}
int main() {
auto func = createFunction();
std::cout << func() << '\n'; // ❌ 미정의 동작
}
// ✅ 값 캡처
std::function<int()> createFunction() {
int x = 42;
return [x]() { return x; }; // ✅ x를 복사
}
캡처 람다를 함수 포인터에 대입하려 함
// ❌ 실수: 캡처 람다를 함수 포인터에
int multiplier = 2;
int (*func)(int) = [multiplier](int x) { return x * multiplier; };
// 컴파일 에러: cannot convert lambda with captures to function pointer
// ✅ std::function 사용
std::function<int(int)> func = [multiplier](int x) { return x * multiplier; };
이동 전용 객체를 캡처한 람다
auto buf = std::make_unique<Buffer>();
std::function<void()> task = [b = std::move(buf)] { b->flush(); };
// error: use of deleted function 'Lambda::Lambda(const Lambda&)' 계열의 에러
std::function은 복사 가능한 호출 객체만 담을 수 있습니다. 자신이 복사될 때 안에 든 객체도 복사해야 하기 때문입니다. 그래서 unique_ptr, std::promise, 소켓 핸들처럼 이동만 가능한 값을 캡처한 람다를 넣으면 에러 메시지가 람다의 복사 생성자가 삭제되었다는 식으로 길게 나옵니다. 스레드 풀 작업 큐에서 std::packaged_task를 std::function<void()>에 넣으려다 가장 자주 부딪히는 문제이기도 합니다. C++23부터는 이런 경우를 위해 std::move_only_function이 추가되었고, 그 이전에는 shared_ptr로 감싸 복사 가능하게 만드는 우회가 흔히 쓰였습니다. 반대로 콜백을 저장하지 않고 호출하는 동안에만 쓰는 함수 인자라면 C++26의 std::function_ref(또는 템플릿 매개변수)가 할당도 복사도 없는 선택지입니다.
빈 std::function 호출로 bad_function_call
// ❌ 실수: nullptr 체크 없음
std::function<void()> callback;
callback(); // ❌ std::bad_function_call 예외
// ✅ nullptr 체크
std::function<void()> callback;
if (callback) {
callback();
}
핫 루프에서 std::function 호출이 느릴 때
증상:
// 콜백이 많이 호출되는데 느림
for (int i = 0; i < 1000000; ++i) {
callback(i); // std::function 사용
}
진단:
// 프로파일러에서 볼 것 (숫자는 코드마다 다름)
// 1. 호출 자체보다 std::function 생성·복사 지점에서 operator new가 보이는가
// 2. 루프 본문이 인라인되지 않아 벡터화 등 최적화가 막혔는가
// 3. 콜백 본문 자체가 병목인가
앞의 측정처럼 호출 한 번의 차이는 나노초 이하라서, “std::function으로 바꿨더니 느려졌다”는 경우 대부분의 원인은 호출 비용이 아닙니다. 루프 안에서 std::function을 매번 새로 만들거나 값으로 복사해 넘기는 코드가 힙 할당을 반복하거나, 짧은 콜백이 인라인되지 못해 컴파일러가 루프 전체를 최적화하지 못하는 경우가 흔합니다. 그래서 함수 포인터로 바꾸기 전에 std::function을 const&로 넘기고 있는지, 생성이 루프 밖에 있는지부터 확인하는 것이 순서입니다. 인라인이 정말 중요한 핫 루프라면 앞의 “템플릿으로 오버헤드 없애기”처럼 템플릿으로 콜백 타입을 그대로 받는 것이 함수 포인터보다도 확실한 방법입니다. 함수 포인터 역시 컴파일러가 대상을 알 수 없으면 인라인되지 않기 때문입니다.
해결:
// 1. 함수 포인터로 변경
void (*callback)(int) = myFunc;
// 2. 인라인 함수 사용
inline void callback(int x) { /* ... */ }
// 3. 템플릿으로 정적 디스패치
template <typename Func>
void process(Func callback) {
for (int i = 0; i < 1000000; ++i) {
callback(i);
}
}
콜백이 자신을 소유한 shared_ptr을 캡처해 해제되지 않을 때
증상:
class Widget {
std::function<void()> onClick_;
public:
void setOnClick(std::function<void()> callback) {
onClick_ = callback;
}
};
// 순환 참조
auto widget = std::make_shared<Widget>();
widget->setOnClick([widget]() { // ❌ 순환 참조
widget->doSomething();
});
해결:
// weak_ptr 사용
auto widget = std::make_shared<Widget>();
widget->setOnClick([weak = std::weak_ptr<Widget>(widget)]() {
if (auto ptr = weak.lock()) {
ptr->doSomething();
}
});
C++20 Concepts로 콜백 타입 제약하기
// C++20: Callable concept
template <typename F>
concept Callback = std::invocable<F, int>;
template <Callback F>
void process(F callback) {
callback(42);
}
// 컴파일 타임에 타입 체크
process([](int x) { std::cout << x << '\n'; }); // OK
// process([]() { /* ... */ }); // 컴파일 에러
이 방식은 함수 포인터도 std::function도 아닌 템플릿 기반 정적 디스패치로, 콜백 타입이 호출 지점에서 컴파일 타임에 완전히 결정되어 인라인 확장까지 가능하므로 세 방식 중 가장 빠릅니다. 다만 템플릿이라 헤더에 구현을 노출해야 하고, 콜백 타입이 다양할수록 그만큼 템플릿 인스턴스가 늘어 컴파일 시간이 길어지는 트레이드오프가 있습니다 — 성능이 정말 중요한 소수의 핫패스에만 선택적으로 적용하는 것이 현실적입니다.
함수 포인터와 std::function 선택 요약
상황별 선택표
| 상황 | 사용 |
|---|---|
| 성능 중요 | 함수 포인터 |
| 람다 캡처 | std::function |
| 함수 객체 | std::function |
| 멤버 함수 | std::function |
| C 인터페이스 | 함수 포인터 |
| 타입 소거 | std::function |
| 단순 콜백 | 함수 포인터 |
네 가지 규칙
- 성능 중요 → 함수 포인터
- 유연성 필요 → std::function
- 람다 캡처 → std::function
- 단순 콜백 → 함수 포인터
다음 단계: 캡처 방식(값·참조·초기화 캡처)에 따라 콜백의 수명 문제가 어떻게 달라지는지 람다 문서에서 이어서 확인해 보세요.