C++ 콜백 구현 4가지 비교: 함수 포인터, 펑터, std::function, 람다와 멤버 함수 콜백
이 글의 핵심
C++ 콜백의 4가지 구현 방법(함수 포인터, 펑터, std::function, 람다)을 비교하고, 멤버 함수를 콜백으로 넘기는 법, 비동기 작업과 이벤트 시스템에서의 활용을 정리합니다.
들어가며: “나중에 호출될 함수를 어떻게 전달하나요?”
콜백(callback)은 나중에 호출될 함수를 미리 등록하는 패턴입니다. 동기적인 코드 흐름에서는 함수를 직접 호출하지만, 비동기 작업이나 이벤트 처리에서는 “작업이 완료되면 이 함수를 호출해줘”라고 미리 알려줘야 합니다.
// ❌ 동기적 방식 - 파일 읽기가 끝날 때까지 대기
std::string content = readFile("data.txt"); // 블로킹
processContent(content);
// ✅ 비동기 방식 - 콜백으로 완료 시점에 처리
readFileAsync("data.txt", [](const std::string& content) {
processContent(content); // 나중에 호출됨
});
// 다른 작업 계속 진행 가능
doOtherWork();
이 글은 콜백을 구현하는 네 가지 방법(함수 포인터, 펑터, std::function, 람다)을 같은 예제로 비교하고, 멤버 함수를 콜백으로 넘길 때의 수명 문제, 비동기 작업과 Observer 패턴에 적용하는 방법, 각 방식의 호출 비용 차이까지 다룹니다.
콜백 설계에서 자주 부딪히는 문제
이벤트 시스템처럼 콜백을 많이 다루는 코드에서는 비슷한 문제가 반복해서 나타납니다. 함수 포인터만으로 시작하면 콜백에 상태를 붙일 방법이 없어 전역 변수가 늘어납니다. 반대로 모든 곳에 std::function을 쓰면 캡처가 큰 콜백을 만들 때마다 힙 할당이 숨어들고, 매 프레임처럼 자주 도는 경로에서는 간접 호출 때문에 인라이닝이 막힙니다. 비동기 콜백에서 지역 변수나 this를 참조로 캡처하면 콜백이 실행될 때 대상이 이미 사라져 있는 댕글링 참조가 생기고, 비동기 작업을 콜백 안에서 또 이어 붙이다 보면 중첩이 깊어져 흐름을 읽기 어려워집니다.
그래서 이 글은 호출 빈도와 수명을 기준으로 방식을 고르는 관점을 취합니다. 자주 호출되는 경로에는 템플릿으로 받는 콜백, 서로 다른 콜백을 한 컨테이너에 담아야 하는 이벤트 등록에는 std::function, 호출 시점이 불확실한 비동기 콜백에는 값 캡처나 shared_from_this()를 쓰는 식입니다.
콜백이란?
기본 개념
콜백은 함수 A에 “함수 B를 나중에 호출해줘”라고 전달하는 패턴입니다.
// ✅ 콜백의 기본 형태
#include <iostream>
// 콜백 타입 정의
using Callback = void(*)();
// 콜백을 받는 함수
void doWork(Callback callback) {
std::cout << "작업 시작\n";
// 작업 수행...
std::cout << "작업 완료\n";
// 콜백 호출
callback();
}
// 콜백으로 사용될 함수
void onComplete() {
std::cout << "완료 알림 받음!\n";
}
int main() {
doWork(onComplete); // 함수 포인터 전달
}
출력:
작업 시작
작업 완료
완료 알림 받음!
왜 필요한가?
1. 비동기 작업
// 동기: 완료될 때까지 대기
std::string result = httpGet("https://api.example.com");
// 비동기: 콜백으로 완료 시점에 처리
httpGetAsync("https://api.example.com", [](const std::string& result) {
// 응답 받은 후 실행
processResponse(result);
});
2. 이벤트 처리
// 버튼 클릭 시 실행될 코드 등록
button.onClick([]() {
std::cout << "버튼 클릭됨!\n";
});
3. 커스터마이징
// 정렬 방식을 콜백으로 커스터마이징
std::sort(vec.begin(), vec.end(), [](int a, int b) {
return a > b; // 내림차순
});
방법 1: 함수 포인터 콜백
기본 사용법
가장 전통적인 방법이며, C API와 호환되는 유일한 방식이기도 합니다. 호출은 포인터에 담긴 주소로 점프하는 간접 호출 한 번이라 비용이 작지만, 컴파일러가 포인터 값을 컴파일 타임에 추적할 수 없으면 인라이닝은 되지 않습니다.
#include <iostream>
// 콜백 함수 타입 정의
using ResultCallback = void(*)(int result);
// 비동기 작업 시뮬레이션
void asyncAdd(int a, int b, ResultCallback callback) {
std::cout << "계산 중...\n";
int result = a + b;
callback(result); // 콜백 호출
}
// 콜백 함수
void onResult(int result) {
std::cout << "결과: " << result << '\n';
}
int main() {
asyncAdd(3, 5, onResult);
}
출력:
계산 중...
결과: 8
여러 콜백 등록
#include <iostream>
#include <vector>
using EventCallback = void(*)();
class Button {
private:
std::vector<EventCallback> callbacks_;
public:
void onClick(EventCallback callback) {
callbacks_.push_back(callback);
}
void click() {
std::cout << "버튼 클릭!\n";
for (auto callback : callbacks_) {
callback();
}
}
};
void handler1() { std::cout << "핸들러 1 실행\n"; }
void handler2() { std::cout << "핸들러 2 실행\n"; }
int main() {
Button btn;
btn.onClick(handler1);
btn.onClick(handler2);
btn.click();
}
출력:
버튼 클릭!
핸들러 1 실행
핸들러 2 실행
단점
함수 포인터의 근본적인 제약은 타입 자체에서 나옵니다 — void(*)()라는 타입은 “이 시그니처를 가진 코드 주소”만 담을 수 있을 뿐, 그 함수가 실행될 때 참조할 추가 데이터(상태)를 함께 담을 자리가 없습니다. 그래서 increment()처럼 카운터를 증가시키는 콜백을 만들려면 전역 변수나 정적 변수에 의존할 수밖에 없고, 이는 같은 콜백을 서로 다른 상태로 여러 개 만들 수 없다는 뜻이기도 합니다(전역 변수 하나를 모든 호출이 공유하므로). 오버로딩·템플릿 함수가 모호해지는 문제도 같은 근본 원인에서 나옵니다 — 함수 포인터 타입은 정확히 하나의 시그니처만 가리킬 수 있으므로, 오버로드 집합 중 어느 것을 가리키는지 문맥만으로 결정할 수 없는 경우 컴파일러가 모호성 에러를 냅니다.
// ❌ 상태를 저장할 수 없음
int counter = 0;
void increment() {
counter++; // 전역 변수에 의존
}
// ❌ 템플릿이나 오버로딩된 함수는 모호함
void process(int x) { }
void process(double x) { }
// using Callback = void(*)(???); // 어느 process?
방법 2: 펑터 (함수 객체) 콜백
펑터란?
operator()를 정의한 클래스로, 상태를 저장할 수 있습니다. 함수 포인터가 풀지 못했던 문제(호출 시 참조할 데이터를 담을 자리가 없다는 것)를, 펑터는 그 데이터를 클래스의 멤버 변수로 만들어 해결합니다 — Counter의 count_처럼, 객체 자체가 상태를 소유하고 operator() 호출마다 그 상태를 갱신하거나 참조할 수 있습니다. 함수 포인터와 또 다른 차이는 타입입니다 — 함수 포인터는 런타임에 어떤 함수를 가리키는지가 정해지는 하나의 타입이지만, 펑터는 클래스마다 서로 다른 고유 타입을 가지므로 템플릿 함수(예: std::sort)에 펑터를 넘기면 컴파일러가 그 구체 타입을 그대로 알고 있어 호출을 인라인할 여지가 생깁니다 — 이것이 뒤의 “장점” 절에서 다루는 성능 이점의 근거입니다.
#include <iostream>
// ✅ 펑터: 상태를 저장하는 함수 객체
class Counter {
private:
int count_ = 0;
public:
void operator()() {
count_++;
std::cout << "호출 횟수: " << count_ << '\n';
}
int getCount() const { return count_; }
};
int main() {
Counter counter;
counter(); // operator() 호출
counter();
counter();
std::cout << "총 " << counter.getCount() << "번 호출됨\n";
}
출력:
호출 횟수: 1
호출 횟수: 2
호출 횟수: 3
총 3번 호출됨
템플릿 콜백으로 사용
GreaterThan이 생성자로 threshold_를 받아 저장해 두는 것이 이 패턴의 핵심입니다 — 함수 포인터로는 “5보다 큰지”와 “10보다 큰지”를 검사하는 두 콜백을 별도의 함수 두 개로 작성해야 하지만, 펑터는 같은 클래스를 서로 다른 생성자 인자로 인스턴스화해 원하는 임계값을 가진 콜백을 얼마든지 만들어낼 수 있습니다. std::find_if는 이 펑터를 템플릿 매개변수로 받으므로, 컴파일러는 GreaterThan::operator()의 구체적인 구현을 그 호출 지점에서 직접 알고 있어 함수 호출 오버헤드 없이 인라인할 수 있습니다 — 이는 이후 다룰 std::function이 타입 소거 때문에 포기해야 하는 최적화입니다.
#include <iostream>
#include <algorithm>
#include <vector>
// ✅ 비교 펑터
class GreaterThan {
private:
int threshold_;
public:
explicit GreaterThan(int threshold) : threshold_(threshold) {}
bool operator()(int value) const {
return value > threshold_;
}
};
int main() {
std::vector<int> numbers = {1, 5, 3, 8, 2, 9, 4};
// 5보다 큰 숫자 찾기
auto it = std::find_if(numbers.begin(), numbers.end(), GreaterThan(5));
if (it != numbers.end()) {
std::cout << "첫 번째로 5보다 큰 숫자: " << *it << '\n';
}
}
출력:
첫 번째로 5보다 큰 숫자: 8
장점
펑터는 상태를 멤버로 가질 수 있고, 타입이 고유하므로 템플릿으로 전달하면 인라인 최적화가 가능하며, 일반 객체처럼 복사·이동할 수 있습니다. 단점은 콜백 하나를 위해 클래스 선언이 필요해 코드가 장황하다는 점이고, 람다는 이 펑터를 컴파일러가 대신 만들어 주는 문법이라고 보면 됩니다.
방법 3: std::function 콜백
std::function이란?
타입 소거(type erasure)로 모든 호출 가능 객체를 담을 수 있는 범용 래퍼입니다. 함수 포인터, 펑터, 람다는 서로 완전히 다른 타입이므로(펑터·람다는 클래스마다 고유한 타입을 가짐), 이 셋을 하나의 컨테이너(예: std::vector<이벤트콜백>)에 균일하게 담을 방법이 원래는 없습니다. std::function<void()>는 내부적으로 어떤 구체 타입이 들어오든 “인자 없이 호출하면 void를 반환하는 무언가”라는 하나의 공통 인터페이스로 감싸(타입을 지워) 저장하므로, 함수 포인터든 펑터든 캡처하는 람다든 상관없이 같은 타입의 변수에 담고 같은 방식으로 호출할 수 있게 됩니다. 이 유연성의 대가는 뒤에 나올 “성능 주의사항”에서 다루는 간접 호출과 힙 할당 비용입니다.
#include <iostream>
#include <functional>
// ✅ std::function: 모든 콜백을 담을 수 있음
void regularFunction() {
std::cout << "일반 함수\n";
}
class Functor {
public:
void operator()() const {
std::cout << "펑터\n";
}
};
int main() {
// 함수 포인터
std::function<void()> callback1 = regularFunction;
callback1();
// 펑터
std::function<void()> callback2 = Functor();
callback2();
// 람다
std::function<void()> callback3 = []() {
std::cout << "람다\n";
};
callback3();
}
출력:
일반 함수
펑터
람다
이벤트 시스템 구현
이 예제가 std::function이 실무에서 가장 많이 쓰이는 이유를 보여줍니다 — subscribe로 등록되는 콜백들은 캡처 없는 람다, counter를 참조 캡처하는 람다처럼 서로 완전히 다른 내부 구조를 가지지만, std::vector<EventCallback>이라는 단일 컨테이너에 아무 문제 없이 함께 저장됩니다. 함수 포인터만 썼다면 counter를 캡처하는 두 번째 리스너 자체가 애초에 표현 불가능했을 것입니다 — 캡처가 있는 람다는 함수 포인터로 변환할 수 없기 때문입니다(캡처된 데이터를 저장할 자리가 함수 포인터 타입에는 없으므로). std::function이 그 캡처 데이터까지 함께 지워서 담아주기 때문에 이런 이질적인 콜백들의 혼합이 가능해집니다.
#include <iostream>
#include <functional>
#include <vector>
#include <string>
class EventSystem {
private:
using EventCallback = std::function<void(const std::string&)>;
std::vector<EventCallback> callbacks_;
public:
void subscribe(EventCallback callback) {
callbacks_.push_back(callback);
}
void emit(const std::string& message) {
for (auto& callback : callbacks_) {
callback(message);
}
}
};
int main() {
EventSystem events;
// 다양한 콜백 등록
events.subscribe([](const std::string& msg) {
std::cout << "리스너 1: " << msg << '\n';
});
int counter = 0;
events.subscribe([&counter](const std::string& msg) {
counter++;
std::cout << "리스너 2 (호출 " << counter << "회): " << msg << '\n';
});
events.emit("이벤트 발생!");
events.emit("또 다른 이벤트!");
}
출력:
리스너 1: 이벤트 발생!
리스너 2 (호출 1회): 이벤트 발생!
리스너 1: 또 다른 이벤트!
리스너 2 (호출 2회): 또 다른 이벤트!
성능 주의사항
std::function이 치르는 대가는 타입 소거 그 자체에서 나옵니다. 캡처한 데이터가 작으면 std::function은 작은 버퍼 최적화(SBO)로 내부 고정 크기 저장 공간에 담아 힙 할당을 피하지만, 캡처가 그 버퍼보다 크면(버퍼 크기는 표준이 정하지 않으며 표준 라이브러리 구현마다 다릅니다) 힙에 별도로 할당해야 하므로 콜백을 만들 때마다 new/delete가 숨어서 발생할 수 있습니다. 호출 비용은 함수 포인터 호출과 같은 간접 호출 한 번에 가깝지만(std::function::operator()는 내부에 저장된 호출 가능 객체를 구현별 함수 포인터나 vtable 비슷한 구조를 통해 호출합니다), 결정적인 차이는 컴파일러가 그 안의 코드를 인라인할 기회가 거의 사라진다는 점입니다. 호출 한 번의 비용 자체는 작아도, 짧은 콜백이 루프 안에서 매우 자주 호출되는 경로라면 인라이닝이 막혀 벡터화 같은 후속 최적화까지 사라지는 것이 더 큰 손해입니다. 이런 핫패스에는 아래처럼 템플릿 매개변수로 콜백 타입 자체를 받아 컴파일 타임에 구체화하는 편이 낫고, 실제로 병목인지는 프로파일러로 확인한 뒤 바꾸는 것이 순서입니다.
// ❌ std::function의 오버헤드
// 1. 힙 할당 가능 (큰 캡처 시)
// 2. 가상 함수 호출 수준의 간접 호출
// 3. 타입 소거 비용
// ✅ 템플릿 콜백 (인라인 가능)
template <typename Callback>
void execute(Callback callback) {
callback(); // 직접 호출, 인라인 가능
}
방법 4: 람다 콜백 (가장 현대적)
람다 기본
C++11부터는 람다 표현식이 가장 편리한 콜백 방법입니다.
#include <iostream>
#include <functional>
#include <thread>
#include <chrono>
// ✅ 람다를 콜백으로 받기
void asyncTask(std::function<void(int)> callback) {
std::cout << "작업 시작...\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
int result = 42;
callback(result);
}
int main() {
asyncTask([](int result) {
std::cout << "작업 완료! 결과: " << result << '\n';
});
}
캡처로 상태 전달
람다가 함수 포인터·펑터보다 편리한 이유가 이 예제에 압축되어 있습니다 — 별도의 클래스를 선언하고 생성자로 상태를 받는 펑터 방식(GreaterThan처럼) 없이도, [&totalResult]라는 캡처 절 하나로 호출 지점의 지역 변수를 콜백 내부 상태처럼 사용할 수 있습니다. totalResult를 참조로 캡처했으므로 processAsync를 세 번 호출하는 동안 callback 내부에서 누적된 값이 매번 살아남아 다음 호출에 반영되는데, 이것이 가능한 이유는 세 호출 모두가 main이 아직 살아있는 동안(totalResult가 스코프를 벗어나기 전에) 동기적으로 실행되기 때문입니다 — 만약 이 콜백이 비동기로 나중에 실행된다면, 다음 절에서 다루는 댕글링 참조 문제가 그대로 발생합니다.
#include <iostream>
#include <functional>
class DataProcessor {
private:
int processedCount_ = 0;
public:
void processAsync(int data, std::function<void(int)> callback) {
// 처리 작업
int result = data * 2;
processedCount_++;
callback(result);
}
int getProcessedCount() const { return processedCount_; }
};
int main() {
DataProcessor processor;
int totalResult = 0;
// ✅ 참조 캡처로 호출 지점의 변수에 누적 (동기 호출이라 안전)
auto callback = [&totalResult](int result) {
totalResult += result;
std::cout << "결과: " << result << ", 누적: " << totalResult << '\n';
};
processor.processAsync(5, callback);
processor.processAsync(10, callback);
processor.processAsync(15, callback);
std::cout << "총 처리: " << processor.getProcessedCount() << "건\n";
}
출력:
결과: 10, 누적: 10
결과: 20, 누적: 30
결과: 30, 누적: 60
총 처리: 3건
주의: 댕글링 참조
바로 앞 예제와 이 예제를 나란히 비교하면 왜 위험한지 명확해집니다 — 앞의 [&totalResult]가 안전했던 이유는 콜백이 캡처 대상이 살아있는 동안 동기적으로 실행되었기 때문이며, 이 예제의 [&value]는 그 전제가 깨진 경우입니다. std::thread(...).detach()로 스레드를 분리하면 그 스레드는 dangerousAsync() 함수 자체보다 오래 살아남을 수 있는데, value는 dangerousAsync의 지역 변수이므로 함수가 반환되는 순간 소멸됩니다 — 스레드가 1초 뒤 깨어나 value에 접근하려 할 때는 이미 존재하지 않는 스택 메모리를 참조하는 것입니다. [value](값 캡처)는 캡처 시점에 값을 복사해 람다 객체 내부에 별도로 저장하므로, 원본 value가 소멸되어도 람다 자신의 사본은 영향받지 않습니다. 일반 규칙은 “콜백이 원래 스코프보다 더 오래 살아남을 가능성이 있다면(비동기, 스레드, 이벤트 등록 등) 항상 값 캡처를 우선하라”는 것입니다.
// ❌ 위험: 참조 캡처
void dangerousAsync() {
int value = 42;
std::thread([&value]() { // ❌ 참조 캡처
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << value << '\n'; // ❌ value가 이미 소멸됨!
}).detach();
} // value 소멸
// ✅ 안전: 값 캡처
void safeAsync() {
int value = 42;
std::thread([value]() { // ✅ 값 캡처
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << value << '\n'; // ✅ 안전!
}).detach();
}
멤버 함수를 콜백으로 사용하기
std::bind 사용 (C++11)
멤버 함수는 일반 함수와 달리 암묵적인 this 매개변수를 가지므로, &Logger::log 하나만으로는 어떤 Logger 인스턴스에 대해 호출할지 알 수 없어 그대로 콜백으로 넘길 수 없습니다. std::bind(&Logger::log, &logger, std::placeholders::_1)는 이 문제를 해결합니다 — 멤버 함수 포인터와 그것을 호출할 구체적인 객체(&logger)를 하나로 묶고, _1은 “나중에 호출될 때 전달받을 첫 번째 인자를 그 자리에 그대로 전달하라”는 placeholder입니다. 결과물은 std::function<void(const std::string&)>에 담을 수 있는 완전한 호출 가능 객체가 되어, 마치 자유 함수처럼 callback("작업 완료") 형태로 호출할 수 있습니다.
#include <iostream>
#include <functional>
#include <string>
class Logger {
public:
void log(const std::string& message) {
std::cout << "[LOG] " << message << '\n';
}
};
void executeWithCallback(std::function<void(const std::string&)> callback) {
callback("작업 완료");
}
int main() {
Logger logger;
// ✅ std::bind로 멤버 함수와 this 바인딩
auto callback = std::bind(&Logger::log, &logger, std::placeholders::_1);
executeWithCallback(callback);
}
람다 사용 (더 직관적)
같은 결과를 람다로는 [&counter]() { counter.increment(); }처럼 자연스러운 함수 호출 문법으로 표현할 수 있어, std::bind의 &Logger::log, &logger, std::placeholders::_1 같은 다소 낯선 문법을 기억할 필요가 없습니다. 이것이 C++11 이후 실무에서 std::bind보다 람다가 멤버 함수 콜백의 사실상 표준으로 자리 잡은 이유입니다 — 표현하는 바가 동일하다면, 읽는 사람이 한눈에 이해할 수 있는 문법을 선택하는 편이 유지보수에 유리합니다.
#include <iostream>
#include <functional>
class Counter {
private:
int count_ = 0;
public:
void increment() {
count_++;
std::cout << "카운트: " << count_ << '\n';
}
void registerCallback(std::function<void()> callback) {
callback();
callback();
}
};
int main() {
Counter counter;
// ✅ 람다로 멤버 함수 캡처 (더 직관적)
counter.registerCallback([&counter]() {
counter.increment();
});
}
출력:
카운트: 1
카운트: 2
shared_from_this로 안전하게
this를 직접 캡처하는 대신 shared_from_this()로 얻은 shared_ptr을 캡처하는 이유는, 방금 다룬 댕글링 참조 문제의 객체 버전이기 때문입니다 — [this]로 캡처하면 worker가 스코프를 벗어나 소멸된 뒤에도 스레드는 이미 사라진 객체의 this 포인터를 들고 있게 되지만, [self]로 캡처하면 self가 참조 카운트를 하나 쥐고 있는 한 AsyncWorker 객체는 스레드가 self->onComplete()를 호출할 때까지 계속 살아있음이 보장됩니다. main에서 worker가 중괄호 스코프를 벗어나도 “Worker 1 완료” 메시지가 정상적으로 출력되는 것이 바로 이 보장 덕분입니다.
#include <iostream>
#include <memory>
#include <functional>
#include <thread>
#include <chrono>
class AsyncWorker : public std::enable_shared_from_this<AsyncWorker> {
private:
int id_;
public:
explicit AsyncWorker(int id) : id_(id) {}
void startWork() {
// ✅ shared_from_this()로 객체 수명 보장
auto self = shared_from_this();
std::thread([self]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
self->onComplete();
}).detach();
}
private:
void onComplete() {
std::cout << "Worker " << id_ << " 완료\n";
}
};
int main() {
{
auto worker = std::make_shared<AsyncWorker>(1);
worker->startWork();
} // worker 스코프 벗어남 - 하지만 스레드가 보유 중
std::this_thread::sleep_for(std::chrono::seconds(2));
}
실전 패턴: 비동기 작업
HTTP 클라이언트
성공과 실패를 하나의 콜백이 아니라 onSuccess/onError 두 개로 분리한 설계가 눈여겨볼 부분입니다 — 만약 하나의 콜백만 두고 성공 여부를 매개변수(bool success)나 예외로 판단하게 했다면, 호출하는 쪽이 매번 그 분기 처리를 반복해야 합니다. 두 콜백으로 나누면 각 콜백은 정확히 하나의 시나리오만 처리하면 되므로 호출부 코드(main의 람다 두 개)가 각각 단순해지고, 이는 Node.js의 에러 우선 콜백이나 Promise의 then/catch 분리와 같은 발상입니다. 세 값(url, onSuccess, onError) 모두를 스레드 람다가 값으로 캡처하는 것도 의도적입니다 — 앞서 다룬 댕글링 참조 규칙을 그대로 따라, 비동기로 실행될 코드는 참조가 아니라 값으로 필요한 것을 들고 다니게 한 것입니다.
#include <iostream>
#include <functional>
#include <string>
#include <thread>
#include <chrono>
class HttpClient {
public:
using ResponseCallback = std::function<void(int statusCode, const std::string& body)>;
using ErrorCallback = std::function<void(const std::string& error)>;
void getAsync(
const std::string& url,
ResponseCallback onSuccess,
ErrorCallback onError
) {
std::thread([url, onSuccess, onError]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
// 네트워크 요청 시뮬레이션
if (url.find("https://") == 0) {
onSuccess(200, "Response from " + url);
} else {
onError("Invalid URL");
}
}).detach();
}
};
int main() {
HttpClient client;
client.getAsync(
"https://api.example.com/users",
[](int status, const std::string& body) {
std::cout << "성공! 상태: " << status << ", 응답: " << body << '\n';
},
[](const std::string& error) {
std::cout << "에러: " << error << '\n';
}
);
std::this_thread::sleep_for(std::chrono::seconds(2));
}
Promise/Future 패턴 (콜백 지옥 회피)
“콜백 지옥”은 비동기 작업 A의 콜백 안에서 비동기 작업 B를 호출하고, 그 콜백 안에서 다시 C를 호출하는 식으로 콜백이 계속 중첩되어 코드가 오른쪽으로 계속 들여쓰기되며 읽기 어려워지는 현상을 말합니다. std::future는 이 중첩을 없애는 대신, 비동기 작업의 결과를 “나중에 값을 받을 수 있는 손잡이”(future)로 표현합니다 — future1.get()은 결과가 준비될 때까지 블로킹하지만, 코드 자체는 콜백 중첩 없이 순차적인 흐름(result1을 구해 asyncOperation에 다시 넘기고, 그 결과로 또 다음 단계를 진행)처럼 읽힙니다. 다만 여기서 .get()을 연달아 호출하는 것은 사실상 각 단계를 순차적으로 기다리는 것이므로 진짜 비동기 이점(다른 작업과의 병렬 실행)은 없다는 점은 짚어둘 만합니다 — 이 예제의 진짜 가치는 코드 가독성이지 병렬성 자체가 아니며, 여러 비동기 작업을 실제로 동시에 실행하고 싶다면 future들을 먼저 모두 발사한 뒤 나중에 한꺼번에 get()하는 구조가 필요합니다.
#include <iostream>
#include <future>
#include <thread>
#include <chrono>
// ✅ Future로 콜백 체인 단순화
std::future<int> asyncOperation(int value) {
return std::async(std::launch::async, [value]() {
std::this_thread::sleep_for(std::chrono::milliseconds(500));
return value * 2;
});
}
int main() {
// 비동기 작업 체인
auto future1 = asyncOperation(5);
int result1 = future1.get(); // 10
auto future2 = asyncOperation(result1);
int result2 = future2.get(); // 20
auto future3 = asyncOperation(result2);
int result3 = future3.get(); // 40
std::cout << "최종 결과: " << result3 << '\n';
}
Observer 패턴
기본 구현
Observer 패턴을 직접 구현하려면 보통 추상 Observer 인터페이스와 그것을 상속하는 구체 클래스들이 필요하지만, 이 Signal<Args...> 템플릿은 상속 계층 없이도 같은 효과를 냅니다 — 가변 인자 템플릿(Args...)을 이용해 인자 개수와 타입이 서로 다른 이벤트(Signal<>는 인자 없음, Signal<int,int>는 좌표 두 개)를 하나의 재사용 가능한 컴포넌트로 표현하고, 리스너는 그 시그니처에 맞는 아무 호출 가능 객체(주로 람다)나 connect로 등록하면 됩니다. Button이 clicked_와 moved_라는 서로 다른 Signal 인스턴스를 멤버로 두는 방식은 Qt의 Signal/Slot이나 C# 이벤트와 본질적으로 같은 발상이며, 표준 라이브러리만으로 타입 안전한 이벤트 시스템을 구축할 수 있음을 보여줍니다.
#include <iostream>
#include <vector>
#include <functional>
#include <algorithm>
template <typename... Args>
class Signal {
private:
using Slot = std::function<void(Args...)>;
std::vector<Slot> slots_;
public:
void connect(Slot slot) {
slots_.push_back(slot);
}
void emit(Args... args) {
for (auto& slot : slots_) {
slot(args...);
}
}
};
class Button {
private:
Signal<> clicked_;
Signal<int, int> moved_;
public:
Signal<>& onClick() { return clicked_; }
Signal<int, int>& onMove() { return moved_; }
void click() {
std::cout << "버튼 클릭!\n";
clicked_.emit();
}
void move(int x, int y) {
std::cout << "버튼 이동: (" << x << ", " << y << ")\n";
moved_.emit(x, y);
}
};
int main() {
Button btn;
// 클릭 이벤트 구독
btn.onClick().connect([]() {
std::cout << "리스너 1: 버튼 클릭됨\n";
});
btn.onClick().connect([]() {
std::cout << "리스너 2: 클릭 처리\n";
});
// 이동 이벤트 구독
btn.onMove().connect([](int x, int y) {
std::cout << "리스너: 위치 = (" << x << ", " << y << ")\n";
});
btn.click();
btn.move(100, 200);
}
출력:
버튼 클릭!
리스너 1: 버튼 클릭됨
리스너 2: 클릭 처리
버튼 이동: (100, 200)
리스너: 위치 = (100, 200)
성능 비교
호출 비용은 “컴파일 타임에 무엇을 알 수 있는가”로 갈린다
네 가지 방법의 성능 차이는 결국 호출 지점에서 컴파일러가 호출 대상을 얼마나 알고 있는지로 귀결됩니다. 펑터와 람다를 템플릿 매개변수로 받으면 호출 대상의 구체 타입이 인스턴스화 시점에 확정되므로 컴파일러가 operator() 본문을 그 자리에 인라인할 수 있습니다. 함수 포인터는 변수에 담긴 주소로 점프하는 간접 호출이라, 컴파일러가 그 값을 상수로 추적할 수 있는 경우(예: 같은 번역 단위에서 상수 인자로 넘긴 뒤 호출 함수까지 인라인된 경우)가 아니면 인라인되지 않습니다. std::function은 타입 소거 때문에 저장된 대상을 한 단계 더 거쳐 호출하고, 큰 캡처라면 생성 시 힙 할당까지 더해집니다.
// 함수 포인터: 포인터에 담긴 주소로 간접 호출
void (*fp)() = &myFunction;
fp();
// std::function: 타입 소거된 저장소를 거친 간접 호출
std::function<void()> fn = myFunction;
fn();
// 템플릿 콜백: 구체 타입이 알려져 있어 인라인 가능
template <typename F>
void run(F f) { f(); }
| 방법 | 호출 방식 | 인라인 가능성 | 상태 저장 | 서로 다른 콜백을 한 컨테이너에 |
|---|---|---|---|---|
| 함수 포인터 | 간접 호출 | 낮음 (포인터 값이 상수로 보일 때만) | 불가능 | 가능 (같은 시그니처) |
| 펑터 (템플릿으로 전달) | 직접 호출 | 높음 | 가능 | 불가능 (타입이 모두 다름) |
| 람다 (템플릿으로 전달) | 직접 호출 | 높음 | 가능 | 불가능 (타입이 모두 다름) |
std::function | 간접 호출 + 타입 소거 | 낮음 | 가능 | 가능 |
표에서 보듯 “가장 빠른 것”과 “가장 유연한 것”이 서로 다른 열을 차지합니다. 버튼 클릭처럼 드물게 호출되는 콜백이라면 간접 호출 비용은 의미가 없으므로 std::function의 유연성을 택하는 편이 합리적이고, 알고리즘 내부 비교 함수처럼 한 번의 작업에서 수백만 번 호출될 수 있는 콜백이라면 템플릿으로 받는 쪽이 유리합니다.
핫패스와 일반 경로를 나누기
// 성능이 중요한 경로: 템플릿 콜백
template <typename Callback>
void processHotPath(Callback callback) {
// 컴파일 타임에 타입 결정, 인라인 가능
callback();
}
// 일반 경로: std::function
void processNormalPath(std::function<void()> callback) {
// 타입 소거, 저장·교체가 자유로움
callback();
}
템플릿 콜백의 대가는 콜백 타입마다 함수가 따로 인스턴스화되어 코드 크기가 늘 수 있다는 점과, 헤더에 구현을 두어야 한다는 점입니다. 콜백을 멤버 변수로 저장해 나중에 호출해야 한다면 저장할 타입을 하나로 정해야 하므로 결국 std::function(또는 C++23의 std::move_only_function)이 필요해집니다. C API에 넘겨야 하는 경우에는 함수 포인터만 가능하며, 캡처 없는 람다는 함수 포인터로 암시적으로 변환되므로 이때도 람다 문법을 쓸 수 있습니다.