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 |
|---|---|---|
| 저장 가능 | 일반 함수, 캡처 없는 람다 | 모든 호출 가능 객체 |
| 람다 캡처 | X | O |
| 함수 객체 | X | O |
| 멤버 함수 | 별도 타입(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, 1.46ns였습니다. 함수 포인터 쪽이 약간 빠르지만 차이는 0.3~0.6ns 수준이라 호출이 초당 수백만 번 이하라면 신경 쓸 크기가 아닙니다. 이 값은 분기 예측이 잘 맞는 단순 루프에서의 결과이고, 실제 코드에서는 인라인 여부가 더 큰 차이를 만듭니다.std::function은 1.43
함수 포인터가 맞는 곳과 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을 써도 됩니다.