C++ std::function | 콜백·전략 패턴과 함수 객체

💡 초보자를 위한 한 줄: std::function<R(Args...)>는 “이 시그니처로 호출 가능한 것”을 한 타입에 담습니다. 핫 루프에는 오버헤드가 있을 수 있어, 성능이 빡세면 템플릿으로 callable을 직접 받는 방식도 대안입니다. 13-1 람다 표현식 다음이 읽기 순서에 맞습니다.

들어가며: 함수를 변수에 저장하고 싶다

UI 버튼에 클릭 이벤트 핸들러를 등록하고 싶었습니다. 하지만 함수 포인터는 제한적이었습니다. std::function은 “호출 가능한 것(callable—함수, 람다, operator()를 가진 객체 등)“을 하나의 타입으로 감싸서 저장·전달할 수 있게 해 줍니다. 타입이 달라도 시그니처(함수의 반환 타입과 매개변수 타입 목록)만 맞으면 같은 변수에 넣을 수 있어서, 콜백·전략 패턴·이벤트 핸들러를 구현할 때 실무에서 널리 사용됩니다. 다만 인라인되지 않을 수 있어서, 성능이 중요한 경로에서는 템플릿으로 callable을 그대로 받는 방식도 고려할 만합니다. 문제의 코드:

class Button {
    void (*onClick)();  // ❌ 함수 포인터만 가능
public:
    void setOnClick(void (*callback)()) {
        onClick = callback;
    }
    void click() {
        if (onClick) onClick();
    }
};
// ❌ 람다 캡처 불가
int clickCount = 0;
button.setOnClick([&clickCount]() {  // 에러!
    clickCount++;
});

std::function으로 해결:

// 타입 정의
class Button {
    std::function<void()> onClick;  // ✅ 모든 callable 가능
public:
    void setOnClick(std::function<void()> callback) {
        onClick = callback;
    }
    void click() {
        if (onClick) onClick();
    }
};
// ✅ 람다, 함수, 함수 객체 모두 가능
int clickCount = 0;
button.setOnClick([&clickCount]() {
    clickCount++;
});

std::function이 서로 다른 타입의 callable을 하나의 타입으로 담을 수 있는 비결은 타입 소거(type erasure)입니다. 내부적으로 “어떤 타입이든 호출·복사·파괴하는 방법”을 가상 함수 테이블이나 함수 포인터 묶음으로 기록해 두고, 실제 callable 객체는 내부 버퍼나 힙에 보관합니다. 호출할 때는 이 간접 경로를 한 번 거치므로 컴파일러가 호출 대상을 미리 알 수 없어 인라인이 막힌다는 것이 비용의 본질입니다. 이 글은 그 비용을 언제 감수할 만한지, 그리고 실제로 코드를 쓰다 부딪히는 수명·빈 함수·복사 관련 함정을 함께 다룹니다.

std::function이 필요해지는 세 가지 상황

시나리오 1: 알고리즘 비교 함수를 런타임에 바꾸고 싶다

상황: 사용자가 “오름차순/내림차순”을 UI에서 선택하면, 그에 맞게 정렬해야 합니다. std::sort는 비교 함수를 템플릿 인자로 받아 컴파일 타임에 고정되는데, 런타임에 선택하려면 std::function에 담아야 합니다.

// ❌ 컴파일 타임에 고정됨
std::sort(vec.begin(), vec.end(), std::less<int>{});
// ✅ 런타임에 전략 교체 가능
std::function<bool(int, int)> comparator = std::less<int>{};
if (userWantsDescending) {
    comparator = std::greater<int>{};
}
std::sort(vec.begin(), vec.end(), comparator);

엄밀히 말하면 이 시나리오는 std::function 없이도 풀 수 있습니다. if (userWantsDescending) std::sort(..., std::greater<int>{}); else std::sort(..., std::less<int>{});처럼 분기를 정렬 바깥에 두면 각 호출이 비교자를 인라인할 수 있어서, 원소 n log n번의 비교마다 간접 호출을 거치는 std::function 버전보다 빠릅니다. std::function이 진짜 필요해지는 것은 비교 전략을 변수에 저장해 다른 곳으로 넘기거나, 선택지가 런타임에 등록되어 분기문으로 나열할 수 없을 때입니다.

시나리오 2: 네트워크 요청 완료 시 콜백을 호출하고 싶다

상황: HTTP 요청이 비동기로 완료되면 결과를 처리하는 함수를 호출해야 합니다. 요청을 보낸 시점과 완료 시점이 다르므로, 콜백을 저장해 두었다가 나중에 호출해야 합니다. 함수 포인터는 캡처가 있는 람다를 받을 수 없습니다.

// ❌ 함수 포인터: 캡처 불가
void (*onComplete)(int status);
// ✅ std::function: 람다 캡처 가능
std::function<void(int)> onComplete;
std::string requestId = "req-123";
onComplete = [requestId](int status) {
    std::cout << "Request " << requestId << " completed: " << status << "\n";
};

시나리오 3: 여러 타입의 callable을 하나의 컨테이너에 담고 싶다

상황: 이벤트 리스너 목록에 “일반 함수”, “람다”, “멤버 함수를 bind한 것”을 섞어서 등록하고 싶습니다. 타입이 각각 다르므로 std::vector<void(*)()> 같은 단일 타입 컨테이너에는 넣을 수 없습니다. std::function으로 시그니처를 통일하면 같은 벡터에 담을 수 있습니다.

std::vector<std::function<void()>> listeners;
void freeFunction() { std::cout << "Free\n"; }
listeners.push_back(freeFunction);
listeners.push_back( []() { std::cout << "Lambda\n"; });
struct Handler {
    void handle() { std::cout << "Member\n"; }
} handler;
listeners.push_back(std::bind(&Handler::handle, &handler));
for (auto& fn : listeners) fn();  // Free, Lambda, Member

Callable 타입 관계도

flowchart TB
    subgraph callable["Callable (호출 가능한 것)"]
        A[일반 함수]
        B[함수 포인터]
        C[람다 표현식]
        D["함수 객체<br/>operator()"]
        E["std::bind 결과"]
    end
    subgraph storage[저장/전달 수단]
        F["std::function<br/>(타입 소거, 유연함)"]
        G["템플릿 Func<br/>(인라인, 빠름)"]
    end
    A --> F
    B --> F
    C --> F
    D --> F
    E --> F
    A --> G
    C --> G
    D --> G
    style F fill:#e1f5e1
    style G fill:#e1e5f5

std::function에 람다, 멤버 함수, 함수 객체 담기

기본 사용법

std::function<int(int, int)>는 “int 두 개를 받아 int를 반환하는 호출 가능한 것”을 담는 타입입니다. 일반 함수 add의 이름을 대입하면 그 함수를 저장하며, func(3, 5)처럼 호출할 수 있습니다. 함수 포인터와 달리 시그니처만 맞으면 람다, 함수 객체, std::bind 결과 등 어떤 callable이든 같은 변수에 넣을 수 있어서, 콜백·전략 패턴을 구현할 때 유용합니다.

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o func_basic func_basic.cpp && ./func_basic
#include <functional>
#include <iostream>
int add(int a, int b) { return a + b; }
int main() {
    std::function<int(int, int)> func = add;
    int result = func(3, 5);  // 8
    std::cout << result << "\n";
    return 0;
}

실행 결과: 8 이 한 줄 출력됩니다.

람다 저장

람다는 “이름 없는 함수 객체”이므로 타입이 컴파일마다 다릅니다. 따라서 auto로만 받거나, std::function 에 담아야 나중에 교체·저장이 가능합니다. std::function<int(int, int)>에 캡처 없는 람다를 대입하면, func(3, 5)로 일반 함수처럼 호출할 수 있으며, 캡처가 있는 람다도 같은 시그니처면 같은 std::function 타입에 넣을 수 있습니다.

std::function<int(int, int)> func = [](int a, int b) {
    return a + b;
};
int result = func(3, 5);  // 8

멤버 함수 저장

멤버 함수는 “객체 + 함수”가 함께 있어야 하므로, 첫 번째 인자로 객체(참조)를 넘기거나 std::bind로 객체를 묶어 둡니다. std::function<int(Calculator&, int, int)>는 “Calculator 참조와 int 두 개를 받아 int를 반환”하는 타입이므로, func(calc, 3, 5)처럼 호출합니다. std::bind(&Calculator::add, &calc, _1, _2)는 calc를 고정하고 나머지 두 인자만 받는 callable을 만들어, 나중에 boundFunc(3, 5)만으로 호출할 수 있게 합니다.

class Calculator {
public:
    int add(int a, int b) {
        return a + b;
    }
};
Calculator calc;
// 멤버 함수 포인터
std::function<int(Calculator&, int, int)> func = &Calculator::add;
int result = func(calc, 3, 5);  // 8
// 또는 bind 사용
auto boundFunc = std::bind(&Calculator::add, &calc,
                           std::placeholders::_1,
                           std::placeholders::_2);
result = boundFunc(3, 5);  // 8

std::function<int(Calculator&, int, int)>에 멤버 함수 포인터를 넣을 수 있는 것은 std::function이 내부적으로 std::invoke 규칙으로 호출하기 때문입니다. 첫 번째 인자가 객체(또는 포인터·reference_wrapper)면 (obj.*pmf)(args...) 형태로 바꿔 부릅니다. 실무에서는 std::bind보다 [&calc](int a, int b) { return calc.add(a, b); }처럼 람다로 감싸는 방식이 더 흔합니다. 어느 쪽이든 calc의 주소나 참조를 붙잡는 것이므로, 콜백이 calc보다 오래 살면 댕글링이 된다는 점은 같습니다(뒤의 “오류 4” 참고).

함수 객체 저장

함수 객체(functor)는 operator()를 오버로드한 구조체/클래스로, Adder()처럼 인스턴스를 만들어 std::function에 넣을 수 있습니다. std::function은 내부적으로 호출 가능한 대상을 타입 소거(type erasure)해서 저장하므로, Adder, 람다, 함수 포인터 등 서로 다른 타입이라도 시그니처가 같으면 같은 std::function 변수에 대입하고 나중에 교체할 수 있습니다.

struct Adder {
    int operator()(int a, int b) const {
        return a + b;
    }
};
std::function<int(int, int)> func = Adder();
int result = func(3, 5);  // 8

빈 function 확인

std::function<void()> func;
if (!func) {
    std::cout << "Function is empty\n";
}
func = []() { std::cout << "Hello\n"; };
if (func) {
    func();  // Hello
}

std::function 시그니처 패턴

시그니처의미
std::function<void()>인자 없음, 반환 없음
std::function<int(int, int)>int 두 개 받아 int 반환
std::function<void(const std::string&)>문자열 받아 void 반환
std::function<bool(int, int)>비교자 (정렬 등)

상태를 가진 함수 객체(Functor) 만들기

기본 함수 객체

struct Multiplier {
    int factor;
    Multiplier(int f) : factor(f) {}
    int operator()(int x) const {
        return x * factor;
    }
};
int main() {
    Multiplier times2(2);
    Multiplier times10(10);
    std::cout << times2(5) << "\n";   // 10
    std::cout << times10(5) << "\n";  // 50
}

상태를 가진 함수 객체

class Counter {
    int count = 0;
public:
    int operator()() {
        return ++count;
    }
    int getCount() const { return count; }
};
int main() {
    Counter counter;
    std::cout << counter() << "\n";  // 1
    std::cout << counter() << "\n";  // 2
    std::cout << counter() << "\n";  // 3
    std::cout << "Total: " << counter.getCount() << "\n";  // 3
}

상태를 가진 함수 객체를 STL 알고리즘에 넘길 때는 복사에 주의해야 합니다. 알고리즘은 함수 객체를 값으로 받으므로, std::for_each(v.begin(), v.end(), counter); 뒤에 원래 counter의 getCount()를 보면 0 그대로입니다. 알고리즘 안에서 증가한 것은 복사본이기 때문입니다(for_each는 그 복사본을 반환하므로 반환값을 받으면 결과를 볼 수 있습니다). 더 까다로운 것은 std::remove_if나 std::sort처럼 내부에서 함수 객체를 여러 번 복사할 수 있는 알고리즘으로, 상태가 복사본마다 따로 쌓여 결과가 구현에 따라 달라집니다. 그래서 표준은 조건자(predicate)가 호출 결과에 영향을 주는 상태를 가지지 않을 것을 요구합니다. 상태를 꼭 공유해야 한다면 std::ref(counter)로 참조를 넘기거나, 람다에서 외부 변수를 참조로 캡처합니다.

STL과 함께 사용

struct IsEven {
    bool operator()(int x) const {
        return x % 2 == 0;
    }
};
int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6};
    // 짝수 개수
    int count = std::count_if(numbers.begin(), numbers.end(), IsEven());
    // 짝수 찾기
    auto it = std::find_if(numbers.begin(), numbers.end(), IsEven());
}

완전한 예제: 범위 검증 Functor

실무에서 “입력값이 유효 범위 내인지” 검사하는 함수 객체를 자주 만듭니다. 상태(최소/최대값)를 생성자로 받으며, operator()에서 검증합니다.

struct InRange {
    int minVal, maxVal;
    InRange(int min, int max) : minVal(min), maxVal(max) {}
    bool operator()(int x) const {
        return x >= minVal && x <= maxVal;
    }
};
int main() {
    std::vector<int> data = {5, 15, 25, 35, 45};
    // 10~40 범위 내 개수
    int count = std::count_if(data.begin(), data.end(), InRange(10, 40));
    std::cout << count << "\n";  // 3 (15, 25, 35)
}

완전한 예제: 로깅 래퍼 Functor

기존 함수를 감싸서 호출 전후로 로그를 남기는 함수 객체입니다. std::function으로 원본을 저장하며, operator()에서 로깅 후 위임합니다.

template <typename R, typename... Args>
class LoggingWrapper {
    std::function<R(Args...)> wrapped;
    std::string name;
public:
    LoggingWrapper(std::function<R(Args...)> f, const std::string& n)
        : wrapped(f), name(n) {}
    R operator()(Args... args) {
        std::cout << "[LOG] Calling " << name << "\n";
        R result = wrapped(args...);
        std::cout << "[LOG] " << name << " returned\n";
        return result;
    }
};
int add(int a, int b) { return a + b; }
int main() {
    LoggingWrapper<int, int, int> loggedAdd(add, "add");
    std::cout << loggedAdd(3, 5) << "\n";
    // [LOG] Calling add
    // [LOG] add returned
    // 8
}

완전한 예제: 재시도 Functor

실패 시 N번까지 재시도하는 함수 객체입니다. 네트워크 호출, 파일 I/O 등에 유용합니다.

template <typename Func>
class Retry {
    Func func;
    int maxAttempts;
public:
    Retry(Func f, int max = 3) : func(f), maxAttempts(max) {}
    template <typename... Args>
    auto operator()(Args&&... args) -> decltype(func(std::forward<Args>(args)...)) {
        for (int i = 0; i < maxAttempts; ++i) {
            try {
                return func(std::forward<Args>(args)...);
            } catch (...) {
                if (i == maxAttempts - 1) throw;
                std::cout << "Retry " << (i + 1) << "/" << maxAttempts << "\n";
            }
        }
        throw std::runtime_error("Retry exhausted");
    }
};

LoggingWrapper는 std::function으로 감싸고 Retry는 템플릿 매개변수 Func로 감쌌는데, 두 방식의 차이가 이 글 전체의 주제입니다. LoggingWrapper<int, int, int>는 시그니처만 같으면 어떤 callable이든 담을 수 있어 컨테이너에 넣기 좋지만 호출마다 간접 호출이 붙고, Retry<Func>는 감싼 callable이 인라인될 수 있는 대신 감싼 대상마다 다른 타입이 되어 한 벡터에 섞어 담을 수 없습니다. Retry에서 std::forward를 반복문 안에서 여러 번 쓰는 것은 주의가 필요합니다. 첫 시도에서 인자가 이동(move)되어 버리면 두 번째 시도에는 비어 있는 값이 전달되므로, 재시도 함수에는 rvalue 인자를 넘기지 않거나 forward 대신 그냥 args...로 넘기는 편이 안전합니다. LoggingWrapper도 반환 타입이 void인 함수에는 R result = ...가 컴파일되지 않으므로, 범용으로 쓰려면 if constexpr (std::is_void_v<R>) 분기가 필요합니다.


std::bind와 람다 비교

기본 사용법

int add(int a, int b, int c) {
    return a + b + c;
}
// 첫 번째 인자를 10으로 고정
auto add10 = std::bind(add, 10, std::placeholders::_1, std::placeholders::_2);
int result = add10(5, 3);  // add(10, 5, 3) = 18

인자 순서 변경

int subtract(int a, int b) {
    return a - b;
}
// 인자 순서 바꾸기
auto reverseSub = std::bind(subtract, std::placeholders::_2, std::placeholders::_1);
std::cout << subtract(10, 3) << "\n";      // 7
std::cout << reverseSub(10, 3) << "\n";    // -7 (3 - 10)

멤버 함수 바인딩

class Printer {
public:
    void print(const std::string& msg) {
        std::cout << "Message: " << msg << "\n";
    }
};
int main() {
    Printer printer;
    // 멤버 함수 바인딩
    auto boundPrint = std::bind(&Printer::print, &printer, std::placeholders::_1);
    boundPrint("Hello");  // Message: Hello
}

람다 vs bind

int x = 10;
// bind 사용
auto func1 = std::bind( [](int a, int b) { return a + b; }, x, std::placeholders::_1);
// ✅ 람다가 더 명확 (권장)
auto func2 = [x](int b) { return x + b; };
std::cout << func1(5) << "\n";  // 15
std::cout << func2(5) << "\n";  // 15

람다를 권장하는 이유: std::bind는 _1, _2 같은 자리 표시자가 나열되어 있어 의도가 한눈에 들어오지 않으며, 인자 순서를 바꿀 때 실수하기 쉽습니다. 람다는 “어떤 값을 캡처해서 어떤 인자로 넘길지”가 그대로 드러나서 가독성과 유지보수에 유리합니다. 성능 면에서도 람다는 인라인되기 쉬운 반면, bind로 만든 호출 객체는 추가 간접 호출이 생길 수 있습니다.


이벤트 핸들러와 비동기 콜백

이벤트 핸들러

class EventEmitter {
    std::vector<std::function<void(const std::string&)>> listeners;
public:
    void on(std::function<void(const std::string&)> callback) {
        listeners.push_back(callback);
    }
    void emit(const std::string& event) {
        for (auto& listener : listeners) {
            listener(event);
        }
    }
};
int main() {
    EventEmitter emitter;
    emitter.on( [](const std::string& event) {
        std::cout << "Listener 1: " << event << "\n";
    });
    emitter.on( [](const std::string& event) {
        std::cout << "Listener 2: " << event << "\n";
    });
    emitter.emit("click");
    // Listener 1: click
    // Listener 2: click
}

비동기 콜백

#include <thread>
#include <chrono>
void asyncOperation(std::function<void(int)> callback) {
    std::thread([callback]() {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        callback(42);  // 결과 전달
    }).detach();
}
int main() {
    std::cout << "Starting...\n";
    asyncOperation( [](int result) {
        std::cout << "Result: " << result << "\n";
    });
    std::cout << "Waiting...\n";
    std::this_thread::sleep_for(std::chrono::seconds(2));
}

이 예제의 detach와 sleep_for(2s) 조합은 데모에서만 통하는 방식입니다. main이 1초 대기보다 먼저 끝나면 콜백이 실행되기 전에 프로세스가 종료되고, 반대로 콜백이 캡처한 객체가 먼저 파괴되면 떼어 낸 스레드가 댕글링 참조를 호출합니다. 비동기 콜백에서 가장 흔한 버그가 이것으로, 콜백을 등록한 객체(예: UI 위젯이나 세션)가 이미 사라졌는데 작업이 끝나 this를 캡처한 람다가 호출되는 경우입니다. 이런 경우 this 대신 std::weak_ptr을 캡처해 두고 콜백 안에서 lock()에 성공했을 때만 실행하는 패턴이 표준적인 해결책입니다. 또 콜백이 어느 스레드에서 호출되는지를 API 문서에 명시해야, 받는 쪽이 UI 스레드로 다시 넘겨야 하는지 판단할 수 있습니다.

에러 핸들링

using SuccessCallback = std::function<void(int)>;
using ErrorCallback = std::function<void(const std::string&)>;
void divide(int a, int b, SuccessCallback onSuccess, ErrorCallback onError) {
    if (b == 0) {
        onError("Division by zero");
    } else {
        onSuccess(a / b);
    }
}
int main() {
    divide(10, 2,
         [](int result) {
            std::cout << "Success: " << result << "\n";
        },
         [](const std::string& error) {
            std::cerr << "Error: " << error << "\n";
        });
}

전략, 명령, 메모이제이션 패턴

패턴 1: 전략 패턴

class Sorter {
    std::function<bool(int, int)> comparator;
public:
    void setStrategy(std::function<bool(int, int)> comp) {
        comparator = comp;
    }
    void sort(std::vector<int>& vec) {
        std::sort(vec.begin(), vec.end(), comparator);
    }
};
int main() {
    std::vector<int> numbers = {5, 2, 8, 1, 9};
    Sorter sorter;
    // 오름차순
    sorter.setStrategy( [](int a, int b) { return a < b; });
    sorter.sort(numbers);
    // 내림차순
    sorter.setStrategy( [](int a, int b) { return a > b; });
    sorter.sort(numbers);
}

패턴 2: 명령 패턴

class Command {
public:
    virtual ~Command() = default;
    virtual void execute() = 0;
};
class CommandManager {
    std::vector<std::function<void()>> commands;
public:
    void addCommand(std::function<void()> cmd) {
        commands.push_back(cmd);
    }
    void executeAll() {
        for (auto& cmd : commands) {
            cmd();
        }
    }
};
int main() {
    CommandManager manager;
    manager.addCommand( []() { std::cout << "Command 1\n"; });
    manager.addCommand( []() { std::cout << "Command 2\n"; });
    manager.addCommand( []() { std::cout << "Command 3\n"; });
    manager.executeAll();
}

패턴 3: 체인 패턴

class Pipeline {
    std::vector<std::function<int(int)>> stages;
public:
    Pipeline& addStage(std::function<int(int)> stage) {
        stages.push_back(stage);
        return *this;
    }
    int execute(int input) {
        int result = input;
        for (auto& stage : stages) {
            result = stage(result);
        }
        return result;
    }
};
int main() {
    Pipeline pipeline;
    pipeline
        .addStage( [](int x) { return x * 2; })
        .addStage( [](int x) { return x + 10; })
        .addStage( [](int x) { return x * x; });
    int result = pipeline.execute(5);  // ((5*2)+10)^2 = 400
    std::cout << result << "\n";
}

패턴 4: 메모이제이션

template <typename R, typename... Args>
class Memoized {
    std::function<R(Args...)> func;
    mutable std::map<std::tuple<Args...>, R> cache;
public:
    Memoized(std::function<R(Args...)> f) : func(f) {}
    R operator()(Args... args) const {
        auto key = std::make_tuple(args...);
        auto it = cache.find(key);
        if (it != cache.end()) {
            return it->second;
        }
        R result = func(args...);
        cache[key] = result;
        return result;
    }
};
int fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}
int main() {
    Memoized<int, int> memoFib(fibonacci);
    std::cout << memoFib(40) << "\n";  // 첫 호출은 여전히 느림 (내부 재귀는 캐시를 거치지 않음)
    std::cout << memoFib(40) << "\n";  // 같은 인자 두 번째 호출부터 캐시 적중
}

이 메모이제이션 래퍼에는 흔히 놓치는 한계가 있습니다. fibonacci 내부의 재귀 호출은 memoFib가 아니라 원래의 fibonacci를 직접 부르므로, memoFib(40)의 첫 호출은 캐시 없이 지수 시간이 걸립니다. 캐시는 “같은 인자로 다시 불렀을 때”만 효과가 있습니다. 재귀 내부까지 캐시하려면 함수가 자기 자신 대신 메모이제이션된 버전을 호출하도록, 재귀 호출 대상을 인자로 받는 형태로 바꿔야 합니다. 또 cache가 mutable이지만 동기화가 없으므로 여러 스레드에서 같은 Memoized 객체를 부르면 데이터 레이스가 나고, 캐시가 무한히 커진다는 점도 실제로 쓰기 전에 고려해야 합니다(<map>, <tuple> 헤더도 필요합니다).

패턴 5: 타이머

class Timer {
    std::function<void()> callback;
    std::chrono::milliseconds interval;
    std::thread thread;
    std::atomic<bool> running{false};
public:
    Timer(std::chrono::milliseconds ms, std::function<void()> cb)
        : interval(ms), callback(cb) {}
    void start() {
        running = true;
        thread = std::thread([this]() {
            while (running) {
                std::this_thread::sleep_for(interval);
                if (running) callback();
            }
        });
    }
    void stop() {
        running = false;
        if (thread.joinable()) thread.join();
    }
    ~Timer() {
        stop();
    }
};
int main() {
    int count = 0;
    Timer timer(std::chrono::milliseconds(100), [&count]() {
        std::cout << "Tick " << ++count << "\n";
    });
    timer.start();
    std::this_thread::sleep_for(std::chrono::seconds(1));
    timer.stop();
}

빈 function 호출, 시그니처 불일치, bind 객체 수명

오류 1: 빈 std::function 호출

증상: std::bad_function_call 예외 또는 크래시 원인: std::function에 아무것도 대입하지 않은 상태에서 func()를 호출함

// ❌ 위험
std::function<void()> func;
func();  // std::bad_function_call
// ✅ 호출 전 검사
if (func) {
    func();
}

오류 2: 시그니처 불일치

증상: 컴파일 에러 “conversion from ‘…lambda…’ to non-scalar type ‘std::function<…>’ requested” 또는 “no matching function” 원인: std::function에 넣으려는 callable을 지정한 인자 타입으로 호출할 수 없음

// ❌ 인자 타입 불일치: int로 std::string을 만들 수 없음
std::function<void(int)> f = [](const std::string& s) { std::cout << s; };
// ✅ 시그니처 일치
std::function<void(const std::string&)> g = [](const std::string& s) { std::cout << s; };

// ⚠️ 이건 컴파일됨: 반환 타입이 void면 int 반환값을 그냥 버림
std::function<void(int)> h = [](int x) { return x * 2; };

std::function의 호환성 판단은 “정확히 같은 시그니처인가”가 아니라 “이 인자들로 호출할 수 있고 결과를 반환 타입으로 변환할 수 있는가”입니다. 그래서 int를 double로 받는 람다도 std::function<void(int)>에 들어가고, 반환 타입이 void인 std::function은 어떤 반환값이든 버립니다. 반대로 std::function<int(int)>에 double을 반환하는 람다를 넣으면 경고 없이 소수점이 잘리는 식의 조용한 변환이 일어날 수 있으니, 시그니처를 선언할 때 의도를 분명히 하는 것이 좋습니다.

오류 3: 람다 캡처로 인한 dangling reference

증상: 크래시 또는 undefined behavior 원인: 람다가 참조로 캡처한 지역 변수가 스코프를 벗어난 뒤 콜백이 호출됨

// ❌ 위험
std::function<void()> callback;
{
    int local = 42;
    callback = [&local]() { std::cout << local << "\n"; };
}
callback();  // local은 이미 소멸됨!
// ✅ 값으로 캡처
int local = 42;
callback = [local]() { std::cout << local << "\n"; };

오류 4: std::bind와 객체 수명

증상: 크래시 (use-after-free) 원인: std::bind로 묶은 객체 포인터가 가리키는 객체가 먼저 소멸됨

// ❌ 위험
std::function<void()> bound;
{
    Printer printer;
    bound = std::bind(&Printer::print, &printer, "Hello");
}
bound();  // printer는 이미 소멸됨!
// ✅ 객체 수명이 callback보다 길어야 함
Printer printer;
auto bound = std::bind(&Printer::print, &printer, "Hello");
bound();

오류 5: 재귀 람다와 std::function 캡처

증상: std::bad_function_call 예외 또는 댕글링 참조로 인한 크래시 원인: 재귀 람다가 자기 자신을 담은 std::function을 값으로 캡처하거나, 참조로 캡처한 std::function보다 람다가 오래 살아남음

// ✅ 같은 스코프 안에서만 쓴다면 참조 캡처는 정상 동작
std::function<int(int)> factorial;
factorial = [&factorial](int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
};

// ❌ 값 캡처: 캡처 시점의 factorial2는 비어 있음 → 호출 시 bad_function_call
std::function<int(int)> factorial2;
factorial2 = [factorial2](int n) {
    if (n <= 1) return 1;
    return n * factorial2(n - 1);
};

// ❌ 참조로 캡처한 지역 std::function을 반환: 함수가 끝나면 댕글링
std::function<int(int)> makeFactorial() {
    std::function<int(int)> f;
    f = [&f](int n) { return n <= 1 ? 1 : n * f(n - 1); };
    return f;  // 복사본이 반환되지만, 내부 람다는 사라진 지역 f를 가리킴
}

// ✅ 일반 함수로 분리하면 수명 문제가 없음
int factorialImpl(int n) {
    if (n <= 1) return 1;
    return n * factorialImpl(n - 1);
}
std::function<int(int)> factorial3 = factorialImpl;

C++23에서는 “deducing this”([](this auto self, int n) { ... self(n - 1); })로 std::function 없이 재귀 람다를 만들 수 있어, 이런 수명 문제와 간접 호출 비용을 함께 피할 수 있습니다.


std::function, 템플릿, 함수 포인터의 비용 비교

std::function vs 템플릿 vs 함수 포인터

방식인라인힙 할당타입 소거유연성
std::function❌경우에 따라✅높음
템플릿 template<typename F>✅❌❌높음
함수 포인터❌❌✅낮음

비용 구조 (1억 회 호출 예)

// std::function: 호출마다 간접 호출, 루프 안에서 인라인·벡터화 불가
std::function<int(int)> f = [](int x) { return x * 2; };
for (int i = 0; i < 100000000; ++i) sum += f(i);
// 템플릿: 호출 대상이 컴파일 타임에 확정되어 인라인·벡터화 가능
template <typename F>
void loop(F&& f) {
    for (int i = 0; i < 100000000; ++i) sum += f(i);
}
loop([](int x) { return x * 2; });
// 함수 포인터: std::function보다 약간 빠를 수 있음
int (*fp)(int) = [](int x) { return x * 2; };  // 캡처 없는 람다만

간접 호출 한 번의 비용 자체는 현대 CPU에서 분기 예측이 맞으면 몇 사이클 수준이라 크지 않습니다. 실제 차이를 만드는 것은 인라인이 막히면서 사라지는 최적화입니다. 템플릿 버전에서는 x * 2가 루프 본문에 그대로 들어가 컴파일러가 SIMD로 여러 원소를 한 번에 처리할 수 있지만, std::function 버전은 매 반복마다 불투명한 함수를 불러야 하므로 이런 변환이 불가능합니다. 그래서 차이는 “호출당 몇 ns”보다 “루프 전체가 몇 배”로 나타나며, 그 배수는 루프 본문이 얼마나 단순한지에 따라 크게 달라집니다. 이벤트 핸들러처럼 초당 몇 번 호출되는 곳에서는 이 비용이 무의미하므로, 성능 걱정 때문에 설계의 유연성을 포기할 필요는 없습니다.

선택 가이드

  • 런타임에 콜백을 바꿔야 함 → std::function
  • 컨테이너에 여러 타입의 callable 저장 → std::function
  • 핫 루프, 초당 수백만 호출 → 템플릿으로 callable 직접 받기
  • 캡처 없는 람다만 → 함수 포인터도 가능 (C++11)

작은 객체 최적화 (SBO)

// 작은 람다: 힙 할당 없음 (대부분의 구현에서)
std::function<int(int)> small = [](int x) { return x * 2; };
// 큰 람다: 힙 할당 발생
int data[100];
std::function<int(int)> large = [data](int x) { return x + data[0]; };

SBO가 의미하는 것: std::function은 내부에 작은 버퍼를 갖고 있어서, 저장할 호출 객체가 그 크기 이하면 힙 할당 없이 그 버퍼에 넣습니다. 캡처가 많은 람다처럼 크기가 크면 힙에 할당하고 포인터만 들고 있어서, 호출 시 한 번 더 간접 참조가 들어가고 할당/해제 비용도 생깁니다. 따라서 “캡처를 최소화한 람다”를 넣을수록 std::function 오버헤드가 줄어듭니다.


옵셔널 콜백, 이벤트 큐, 데코레이터 체인

패턴 1: 옵셔널 콜백

콜백이 없을 수 있는 API에서, 호출 전 null 체크를 반복하지 않도록 래퍼를 둡니다.

class OptionalCallback {
    std::function<void(int)> callback;
public:
    void set(std::function<void(int)> cb) { callback = std::move(cb); }
    void invoke(int value) {
        if (callback) callback(value);
    }
};

패턴 2: 스레드 안전 이벤트 큐

작업 스레드에서 이벤트를 큐에 넣으며, 메인 스레드에서 콜백을 실행하는 패턴입니다.

#include <queue>
#include <mutex>
#include <condition_variable>
class EventQueue {
    std::queue<std::function<void()>> queue;
    std::mutex mtx;
    std::condition_variable cv;
public:
    void post(std::function<void()> task) {
        std::lock_guard<std::mutex> lock(mtx);
        queue.push(std::move(task));
        cv.notify_one();
    }
    void processOne() {
        std::unique_lock<std::mutex> lock(mtx);
        cv.wait(lock, [this] { return !queue.empty(); });
        auto task = std::move(queue.front());
        queue.pop();
        lock.unlock();
        task();
    }
};

패턴 3: 데코레이터 체인

여러 콜백을 순서대로 실행하는 체인입니다. 미들웨어, 필터 패턴에 활용됩니다.

using Middleware = std::function<std::function<void()>(std::function<void()>)>;
std::function<void()> applyMiddleware(
    std::function<void()> handler,
    std::vector<Middleware> middlewares)
{
    for (auto it = middlewares.rbegin(); it != middlewares.rend(); ++it) {
        handler = (*it)(std::move(handler));
    }
    return handler;
}
// 사용 예
auto logged = [](std::function<void()> next) {
    return [next]() {
        std::cout << "Before\n";
        next();
        std::cout << "After\n";
    };
};
auto final = applyMiddleware([]() { std::cout << "Handler\n"; }, {logged});
final();  // Before, Handler, After

패턴 4: 타임아웃 래퍼

지정 시간 내에 완료되지 않으면 에러 콜백을 호출하는 패턴입니다.

#include <future>
void withTimeout(
    std::function<void()> task,
    std::chrono::milliseconds timeout,
    std::function<void()> onTimeout)
{
    auto future = std::async(std::launch::async, std::move(task));
    if (future.wait_for(timeout) == std::future_status::timeout) {
        onTimeout();
    } else {
        future.get();
    }
}

이 타임아웃 래퍼에는 이름과 다르게 동작하는 함정이 있습니다. std::async가 돌려준 future는 소멸자에서 작업이 끝날 때까지 블로킹합니다. 그래서 타임아웃이 나 onTimeout()을 호출한 뒤 함수가 반환하려는 순간, future의 소멸자가 여전히 실행 중인 task를 기다리느라 withTimeout 자체가 멈춥니다. “시간이 지나면 포기하고 넘어간다”는 기대와 달리 호출자는 작업이 끝날 때까지 돌아오지 못합니다. C++ 표준에는 실행 중인 스레드를 강제로 멈추는 방법이 없으므로, 진짜 타임아웃이 필요하면 작업 쪽이 협조적으로 취소를 확인하도록 C++20의 std::stop_token(std::jthread)이나 원자적 플래그를 넘기고, 작업이 그 신호를 주기적으로 확인하게 설계해야 합니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 빈 std::function을 호출하면 어떻게 되나요?

A. 아무 callable도 대입되지 않은 std::function을 호출하면 std::bad_function_call 예외가 발생합니다. 콜백 등록이 선택 사항인 구조라면 호출 전에 if (callback)으로 비어 있는지 확인하거나, 처음부터 아무 일도 하지 않는 기본 함수로 초기화해 두는 편이 안전합니다.