C++ std::function vs 함수 포인터: 성능과 유연성의 트레이드오프

이 글의 핵심

캡처가 있는 람다를 함수 포인터에 담으려다 컴파일 에러를 만나는 데서 출발합니다. 두 방식의 호출 비용 차이는 간접 호출 한 단계 수준이고, 의미 있는 차이는 템플릿이 가능하게 하는 인라인과 std::function 생성 시 힙 할당에서 납니다. 댕글링 참조 캡처, 빈 함수 호출 같은 실수와 선택 기준까지 다룹니다.

콜백을 저장하는 두 가지 방법

캡처 람다는 함수 포인터에 담기지 않는다

C++에서 콜백을 변수나 멤버에 저장할 때는 보통 함수 포인터나 std::function을 씁니다. 함수 포인터는 코드 주소 하나만 담으므로 상태를 가질 수 없고, std::function은 타입 소거로 람다·함수 객체를 상태째 담는 대신 그만큼의 저장·생성 비용을 냅니다.

언제 std::function을, 언제 함수 포인터를 쓰나요?

관점함수 포인터std::function
호출 비용간접 호출 1회간접 호출 1회 + 내부 디스패치
생성 비용없음큰 호출체는 힙 할당
담을 수 있는 것일반 함수, 캡처 없는 람다시그니처가 맞는 모든 호출 가능 객체
주 사용처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
저장 가능일반 함수, 캡처 없는 람다모든 호출 가능 객체
람다 캡처XO
함수 객체XO
멤버 함수별도 타입(void (C::*)(int))과 객체가 필요람다나 std::bind로 객체와 묶어 저장
성능호출은 비슷, 대상이 컴파일 타임에 보이면 인라인 가능호출은 비슷, 인라인은 거의 불가, 생성 시 할당 가능
크기(64비트)8바이트libstdc++ 32바이트, libc++ 32바이트(Apple clang 21에서 측정), 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);

이 벤치마크를 그대로 돌릴 때는 주의할 점이 있습니다. BM_FunctionPointer의 funcPtr는 루프 밖에서 add로 고정되어 있어 컴파일러가 상수 전파로 직접 호출로 바꾸고 인라인까지 할 수 있고, BM_DirectCall은 add가 인라인되어 덧셈 하나만 남습니다. 간접 호출 비용 자체를 보려면 포인터를 benchmark::DoNotOptimize(funcPtr)로 감추거나, add에 [[gnu::noinline]]을 붙여 비교 조건을 맞춰야 합니다.

조건을 맞추면 세 방식의 호출 비용 차이는 대개 측정 잡음과 구별하기 어려울 만큼 작습니다. 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회

// 3. std::function
std::function<int(int, int)> func = add;
int result = func(1, 2);  // 내부 호출 함수 포인터를 거친 간접 호출

크기와 힙 할당 비교표

타입크기힙 할당비고
함수 포인터8B없음포인터만
std::function, libstdc++ (작은 캡처)32B없음내부 버퍼(SBO) 사용
std::function, libstdc++ (큰 캡처)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 bigData[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) { /* ... */ };  // 힙 할당 발생

libstdc++의 std::function은 16바이트 이하이면서 trivially copyable인 호출체만 내부 버퍼에 둡니다. 따라서 int 하나를 캡처한 람다는 할당이 없지만, 32바이트 구조체를 캡처한 람다는 호출체 저장을 위해 한 번 할당합니다. std::string을 값으로 캡처한 람다는 크기와 무관하게 trivially copyable이 아니므로 호출체가 힙으로 가고, 문자열이 SSO 길이(libstdc++는 15자)를 넘으면 문자열 복사에서 할당이 한 번 더 일어납니다. 직접 확인하고 싶다면 전역 operator new를 교체해 호출 횟수를 세어 보면 됩니다. 이 기준은 표준이 정한 것이 아니라 구현마다 다르므로(MSVC·libc++는 버퍼 크기가 다름), 콜백을 초당 수십만 개 만드는 코드라면 std::function을 재사용하거나 템플릿으로 받는 설계를 검토합니다.


직접 측정한 크기·할당·호출 시간

Apple clang 21.0.0(libc++), -std=c++20 -O2, macOS arm64에서 operator new를 가로채 할당 횟수를 세고, sizeof와 호출당 시간을 쟀습니다. libstdc++나 MSVC 결과는 이 환경에서 확인하지 못했으므로 위 표의 해당 값은 문서 기준 값입니다.

항목측정 결과 (libc++)
sizeof(int(*)(int,int)) / sizeof(std::function<int(int,int)>)8 / 32바이트
함수 포인터·캡처 없는 람다·int 1개 캡처 람다를 std::function에 대입힙 할당 0회
16바이트 함수 객체힙 할당 0회
24바이트 함수 객체힙 할당 1회 (32바이트)
32바이트 함수 객체힙 할당 1회 (40바이트)
64바이트 함수 객체힙 할당 1회 (72바이트)
빈 std::function 호출std::bad_function_call 예외

내부 버퍼에 들어가는 한계가 이 환경에서는 16바이트와 24바이트 사이에 있었습니다. 본문의 “16바이트 안팎”이 맞았지만, 구현마다 달라서 “이 크기까지는 할당이 없다”고 가정하면 안 됩니다. 크기를 신경 써야 하는 코드라면 operator new를 가로채는 위 방식으로 자기 환경에서 한 번 세어 보세요.

호출 한 번의 시간은 add를 noinline으로 막고 2억 번 돌린 평균입니다. 두 번 실행했을 때 함수 포인터는 0.891.20ns, std::function은 1.431.46ns였습니다. 함수 포인터 쪽이 약간 빠르지만 차이는 0.3~0.6ns 수준이라 호출이 초당 수백만 번 이하라면 신경 쓸 크기가 아닙니다. 이 값은 분기 예측이 잘 맞는 단순 루프에서의 결과이고, 실제 코드에서는 인라인 여부가 더 큰 차이를 만듭니다.

함수 포인터가 맞는 곳과 std::function이 맞는 곳

핫 루프 콜백: 함수 포인터

#include <vector>
// 함수 포인터: 성능 중요한 콜백
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

#include <iostream>
#include <functional>
// 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

#include <iostream>
#include <string>
#include <functional>
// 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);
}

스레드 풀의 작업 큐

#include <iostream>
#include <vector>
#include <functional>
#include <utility>
#include <thread>
#include <mutex>
#include <chrono>
#include <queue>
// 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 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"; });
// 컴파일러가 람다를 인라인 전개 가능

댕글링 캡처·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

// 실수: 비어 있는지 확인하지 않음
std::function<void()> callback;
callback();  // std::bad_function_call 예외

// operator bool로 확인
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&로 넘기고 있는지, 생성이 루프 밖에 있는지부터 확인하는 것이 순서입니다. 인라인이 정말 중요한 핫 루프라면 앞의 “템플릿으로 오버헤드 없애기”처럼 템플릿으로 콜백 타입을 그대로 받는 것이 함수 포인터보다도 확실한 방법입니다. 함수 포인터 역시 컴파일러가 대상을 알 수 없으면 인라인되지 않기 때문입니다.

// 템플릿으로 콜백 타입을 그대로 받으면 인라인 가능
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();
});

람다가 widget의 shared_ptr을 복사해 들고 있고, 그 람다를 widget이 멤버로 소유하므로 참조 카운트가 0이 되지 않습니다. 콜백 안에서는 weak_ptr로 들고 있다가 호출 시점에 lock()합니다.

// 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 (<concepts>)
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도 아닌 템플릿 기반 정적 디스패치로, 콜백 타입이 호출 지점에서 컴파일 타임에 결정되어 인라인 확장까지 가능하므로 보통 세 방식 중 가장 빠릅니다. Concept을 붙이면 잘못된 콜백을 넘겼을 때 템플릿 내부가 아니라 호출 지점에서 읽기 쉬운 에러가 납니다. 다만 템플릿이라 헤더에 구현을 노출해야 하고, 콜백 타입이 다양할수록 그만큼 템플릿 인스턴스가 늘어 컴파일 시간이 길어지는 트레이드오프가 있습니다 — 성능이 정말 중요한 소수의 핫패스에만 선택적으로 적용하는 것이 현실적입니다.


선택 기준

캡처 없는 콜백을 C API에 넘기거나 상태가 필요 없는 콜백을 저장할 때는 함수 포인터로 충분합니다. 상태를 가진 콜백(캡처 람다, 함수 객체, 객체에 묶인 멤버 함수)을 한 가지 타입으로 저장해야 한다면 std::function을 씁니다. 이동 전용 객체를 캡처해야 하면 C++23 std::move_only_function, 저장하지 않고 호출하는 동안만 쓰는 인자라면 템플릿 매개변수(또는 C++26 std::function_ref)가 맞습니다.

성능이 정말 중요한 핫 루프라면 함수 포인터보다 템플릿으로 콜백을 받는 쪽이 확실합니다. 함수 포인터도 std::function도 컴파일러가 대상을 모르면 인라인되지 않기 때문입니다. 반대로 대부분의 이벤트·UI·작업 큐 코드에서는 호출 비용 차이가 병목이 되는 일이 드물므로, 생성·복사가 루프 안에서 반복되지 않는지만 확인하면 std::function을 써도 됩니다.

참고 자료


같이 보면 좋은 글