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::function1.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
단순 콜백함수 포인터

네 가지 규칙

  1. 성능 중요 → 함수 포인터
  2. 유연성 필요 → std::function
  3. 람다 캡처 → std::function
  4. 단순 콜백 → 함수 포인터

다음 단계: 캡처 방식(값·참조·초기화 캡처)에 따라 콜백의 수명 문제가 어떻게 달라지는지 람다 문서에서 이어서 확인해 보세요.


같이 보면 좋은 글