C++ call_once | "한 번만 호출" 가이드

C++ call_once | "한 번만 호출" 가이드

이 글의 핵심

std::call_once 는 C++11에서 도입된 함수로, 여러 스레드에서 호출되어도 함수를 정확히 한 번만 실행하도록 보장합니다. std::once_flag와 함께 사용하여 스레드 안전한 초기화를 구현합니다.

call_once란?

std::call_once 는 C++11에서 도입된 함수로, 여러 스레드에서 호출되어도 함수를 정확히 한 번만 실행하도록 보장합니다. std::once_flag와 함께 사용하여 스레드 안전한 초기화를 구현합니다.

#include <mutex>

std::once_flag flag;

void init() {
    std::cout << "초기화" << std::endl;
}

void func() {
    std::call_once(flag, init);  // 한 번만
}

왜 필요한가?:

  • 스레드 안전 초기화: 여러 스레드에서 동시 호출 시에도 안전
  • 성능: 초기화 후 빠른 체크 (double-checked locking 불필요)
  • 예외 안전: 초기화 실패 시 재시도 가능
  • 간결성: 복잡한 동기화 코드 불필요
// ❌ 수동 동기화: 복잡하고 오류 가능
std::mutex mtx;
bool initialized = false;

void init() {
    std::lock_guard<std::mutex> lock(mtx);
    if (!initialized) {
        // 초기화
        initialized = true;
    }
}

// ✅ call_once: 간단하고 안전
std::once_flag flag;

void init() {
    std::call_once(flag,  {
        // 초기화 (한 번만)
    });
}

call_once의 동작 원리:

call_once는 내부적으로 원자적 연산을 사용하여 첫 번째 호출만 함수를 실행하고, 이후 호출은 즉시 반환합니다.

// 개념적 동작
std::once_flag flag;

void call_once(std::once_flag& flag, Callable&& func) {
    // 원자적으로 상태 확인
    if (flag.already_called()) {
        return;  // 이미 호출됨, 빠른 반환
    }
    
    // 첫 호출: 락 획득
    lock();
    if (!flag.already_called()) {
        func();  // 함수 실행
        flag.mark_called();
    }
    unlock();
}

once_flag의 특성:

  • 복사 불가: once_flag는 복사할 수 없음
  • 이동 불가: once_flag는 이동할 수 없음
  • 상태 유지: 한 번 호출되면 영구적으로 “호출됨” 상태 유지
std::once_flag flag1;
// std::once_flag flag2 = flag1;  // 에러: 복사 불가
// std::once_flag flag3 = std::move(flag1);  // 에러: 이동 불가

기본 사용

std::once_flag initFlag;
bool initialized = false;

void initialize() {
    std::cout << "초기화 중..." << std::endl;
    initialized = true;
}

void process() {
    std::call_once(initFlag, initialize);
    // 첫 호출만 initialize 실행
}

실전 예시

예시 1: 싱글톤

class Singleton {
    static std::once_flag initFlag;
    static Singleton* instance;
    
    Singleton() {
        std::cout << "Singleton 생성" << std::endl;
    }
    
public:
    static Singleton& getInstance() {
        std::call_once(initFlag,  {
            instance = new Singleton();
        });
        return *instance;
    }
};

std::once_flag Singleton::initFlag;
Singleton* Singleton::instance = nullptr;

예시 2: 자원 초기화

class Database {
    static std::once_flag connFlag;
    static Connection* conn;
    
public:
    static Connection& getConnection() {
        std::call_once(connFlag,  {
            conn = new Connection("localhost");
            std::cout << "DB 연결" << std::endl;
        });
        return *conn;
    }
};

예시 3: 설정 로드

std::once_flag configFlag;
Config config;

void loadConfig() {
    std::cout << "설정 로드" << std::endl;
    config = Config::load("config.json");
}

Config& getConfig() {
    std::call_once(configFlag, loadConfig);
    return config;
}

예시 4: 람다 사용

std::once_flag flag;
int value = 0;

void func() {
    std::call_once(flag, [&value]() {
        value = expensiveComputation();
        std::cout << "계산 완료: " << value << std::endl;
    });
    
    std::cout << "값: " << value << std::endl;
}

예외 처리

std::once_flag flag;

void init() {
    throw std::runtime_error("초기화 실패");
}

void func() {
    try {
        std::call_once(flag, init);
    } catch (...) {
        // 예외 발생 시 flag 리셋
        // 다음 call_once에서 재시도
    }
}

자주 발생하는 문제

문제 1: 여러 once_flag

std::once_flag flag1, flag2;

void init1() { std::cout << "Init 1" << std::endl; }
void init2() { std::cout << "Init 2" << std::endl; }

void func() {
    std::call_once(flag1, init1);
    std::call_once(flag2, init2);
}

문제 2: 인자 전달

std::once_flag flag;

void init(int x, const std::string& s) {
    std::cout << x << ", " << s << std::endl;
}

void func() {
    std::call_once(flag, init, 42, "Hello");
}

문제 3: 멤버 함수

class MyClass {
    std::once_flag flag;
    
    void init() {
        std::cout << "초기화" << std::endl;
    }
    
public:
    void process() {
        std::call_once(flag, &MyClass::init, this);
    }
};

문제 4: 예외 재시도

std::once_flag flag;
int attempt = 0;

void init() {
    attempt++;
    if (attempt < 3) {
        throw std::runtime_error("재시도");
    }
    std::cout << "성공" << std::endl;
}

void func() {
    try {
        std::call_once(flag, init);
    } catch (...) {
        // 다음 호출에서 재시도
    }
}

정적 지역 변수 대안

// call_once
std::once_flag flag;
Resource* resource = nullptr;

Resource& getResource() {
    std::call_once(flag,  {
        resource = new Resource();
    });
    return *resource;
}

// 정적 지역 변수 (C++11, 더 간단)
Resource& getResource() {
    static Resource resource;  // 스레드 안전
    return resource;
}

실무 패턴

패턴 1: 지연 초기화 래퍼

template<typename T>
class LazyInit {
    std::once_flag flag_;
    std::unique_ptr<T> instance_;
    
public:
    template<typename... Args>
    T& get(Args&&... args) {
        std::call_once(flag_, [this, &args...]() {
            instance_ = std::make_unique<T>(std::forward<Args>(args)...);
        });
        return *instance_;
    }
};

// 사용
LazyInit<Database> db;
db.get("localhost", 5432).query("SELECT * FROM users");

패턴 2: 초기화 체인

class Application {
    std::once_flag configFlag_;
    std::once_flag dbFlag_;
    std::once_flag cacheFlag_;
    
    void initConfig() {
        std::cout << "설정 로드\n";
        // 설정 초기화
    }
    
    void initDatabase() {
        std::call_once(configFlag_, [this]() { initConfig(); });
        std::cout << "DB 연결\n";
        // DB 초기화
    }
    
    void initCache() {
        std::call_once(dbFlag_, [this]() { initDatabase(); });
        std::cout << "캐시 초기화\n";
        // 캐시 초기화
    }
    
public:
    void start() {
        std::call_once(cacheFlag_, [this]() { initCache(); });
        std::cout << "애플리케이션 시작\n";
    }
};

패턴 3: 재시도 가능한 초기화

class RetryableInit {
    std::once_flag flag_;
    int maxRetries_ = 3;
    int attempts_ = 0;
    
    void tryInit() {
        attempts_++;
        if (attempts_ < maxRetries_) {
            throw std::runtime_error("초기화 실패, 재시도");
        }
        std::cout << "초기화 성공\n";
    }
    
public:
    bool initialize() {
        try {
            std::call_once(flag_, [this]() { tryInit(); });
            return true;
        } catch (const std::exception& e) {
            std::cerr << e.what() << '\n';
            return false;
        }
    }
};

// 사용
RetryableInit init;
while (!init.initialize()) {
    std::this_thread::sleep_for(std::chrono::seconds(1));
}

싱글톤 초기화에 call_once를 쓰는 이유

전역·함수 수준에서 한 번만 비용 큰 초기화를 하고 싶을 때, 직접 mutex + bool 플래그를 쓰면 이중 확인 잠금(double-checked locking) 을 손으로 맞추기 어렵고 컴파일러 최적화에 취약했습니다. std::call_once표준이 보장하는 한 번만 실행을 제공하므로, 싱글톤 지연 초기화의 고전적인 구현에 자주 쓰입니다.

다만 C++11 이후에는 매이어스 싱글톤처럼 static 지역 객체를 쓰는 편이 더 단순한 경우가 많습니다(다음 절 참고). call_once여러 단계 초기화 순서, 인자를 넘긴 일회성 호출, 실패 시 재시도처럼 static만으로 애매한 때에 빛납니다.

멀티스레드 안전성이 표준에 어떻게 적용되나

std::call_once(flag, f, args...)모든 스레드에서 동시에 호출해도 f성공적으로 완료된 경우 한 번만 실행됩니다. 한 스레드가 f를 실행하는 동안 다른 스레드는 완료까지 블로킵니다.

초기화 중 예외가 나면 표준에 따라 다음 call_once에서 다시 시도할 수 있습니다(구현은 once_flag를 실패 상태로 되돌리는 방식). 그래서 네트워크·파일처럼 실패할 수 있는 초기화를 감쌀 때 유용합니다.

static 지역 변수와의 비교 (실무 선택)

C++11 이후, 블록 스코프 static 지역 변수의 초기화는 데이터 레이스 없이 한 번만 일어나도록 보장됩니다.

Foo& instance() {
    static Foo f;  // 첫 호출 시 한 번만 초기화(스레드 안전)
    return f;
}
상황추천
타입 T를 그 자리에서 만들 수 있고, 기본/인자 있는 생성만 있으면 됨static 지역 변수가 짧고 읽기 쉬움
초기화에 복잡한 단계, 여러 함수 호출, 예외 후 재시도call_once
동적 라이브러리 로드, 플러그인 등록처럼 “한 번만”이지만 static으로 표현하기 어색함call_once 또는 해당 프레임워크의 초기화 API

실전 패턴 보강

  • 라이브러리 초기화: register_codec(), load_config()를 앱 전역에서 한 번만 호출할 때 once_flag를 네임스페이스 수준에 두고 call_once로 감쌉니다.
  • 지연 로딩된 DLL/so: 핸들 획득이 실패할 수 있으면 예외 처리 루프와 함께 call_once로 재시도 정책을 구현합니다.
  • 테스트: once_flag리셋할 수 없으므로 단위 테스트에서 “매 테스트마다 다시 초기화”가 필요하면 별도 픽스처정적이 아닌 객체로 옮기는 편이 낫습니다.

성능에 대한 현실적인 기대

성공한 뒤의 call_once 호출은 매우 가벼운 원자적 경로로 처리되는 것이 일반적입니다. 정확한 수치는 플랫폼·컴파일러마다 다르지만, 핫 루프 안에서 매 반복 call_once를 호출하는 것은 여전히 피하는 것이 좋습니다. “한 번만”이면 호출 지점을 루프 밖이나 초기화 단계로 옮기세요.

첫 호출 시에만 무거운 작업이 있고 이후에는 동일 플래그로 빠르게 빠져 나오는 구조가 이상적입니다.

FAQ

Q1: call_once는 무엇인가요?

A: 여러 스레드에서 동시에 호출되어도 함수를 정확히 한 번만 실행하도록 보장하는 C++11 함수입니다.

std::once_flag flag;

void init() {
    std::cout << "초기화\n";
}

// 여러 스레드에서 호출해도 init()은 한 번만 실행됨
std::thread t1( { std::call_once(flag, init); });
std::thread t2( { std::call_once(flag, init); });

Q2: 언제 사용해야 하나요?

A:

  • 싱글톤 패턴: 인스턴스를 한 번만 생성
  • 자원 초기화: DB 연결, 파일 열기 등
  • 설정 로드: 설정 파일을 한 번만 읽기
  • 지연 초기화: 필요할 때 한 번만 초기화
// 싱글톤
static Logger& getLogger() {
    static std::once_flag flag;
    static Logger* instance = nullptr;
    std::call_once(flag,  {
        instance = new Logger();
    });
    return *instance;
}

Q3: 예외 처리는 어떻게 되나요?

A: 예외가 발생하면 once_flag리셋되어 다음 호출에서 재시도할 수 있습니다.

std::once_flag flag;
int attempt = 0;

void init() {
    attempt++;
    if (attempt < 3) {
        throw std::runtime_error("재시도");
    }
    std::cout << "성공\n";
}

// 여러 번 호출하면 재시도됨
for (int i = 0; i < 5; ++i) {
    try {
        std::call_once(flag, init);
    } catch (...) {
        std::cout << "실패, 재시도\n";
    }
}

Q4: 정적 지역 변수와 어떤 차이가 있나요?

A: C++11 이후 정적 지역 변수가 스레드 안전하므로, 대부분의 경우 더 간단합니다.

// call_once: 명시적
std::once_flag flag;
Resource* resource = nullptr;

Resource& getResource() {
    std::call_once(flag,  {
        resource = new Resource();
    });
    return *resource;
}

// 정적 지역 변수: 더 간단 (C++11 이후 스레드 안전)
Resource& getResource() {
    static Resource resource;  // 자동으로 한 번만 초기화
    return resource;
}

call_once를 사용하는 경우:

  • 초기화 로직이 복잡할 때
  • 예외 재시도가 필요할 때
  • 초기화 시점을 명시적으로 제어하고 싶을 때

Q5: 성능은 어떤가요?

A: 첫 호출 후 매우 빠릅니다. 내부적으로 원자적 연산을 사용하여 빠른 체크를 수행합니다.

// 첫 호출: 초기화 실행 (느림)
std::call_once(flag, expensiveInit);

// 이후 호출: 원자적 체크만 (매우 빠름, ~1-2ns)
std::call_once(flag, expensiveInit);

Q6: 멤버 함수를 호출할 수 있나요?

A: 가능합니다. 멤버 함수 포인터와 this를 전달하면 됩니다.

class MyClass {
    std::once_flag flag_;
    
    void init() {
        std::cout << "초기화\n";
    }
    
public:
    void process() {
        std::call_once(flag_, &MyClass::init, this);
    }
};

Q7: 인자를 전달할 수 있나요?

A: 가능합니다. call_once는 가변 인자를 지원합니다.

std::once_flag flag;

void init(int x, const std::string& s) {
    std::cout << x << ", " << s << '\n';
}

void func() {
    std::call_once(flag, init, 42, "Hello");
}

Q8: call_once 학습 리소스는?

A:

관련 글: Singleton Pattern, Thread Basics, Mutex.

한 줄 요약: std::call_once는 여러 스레드에서 함수를 정확히 한 번만 실행하도록 보장하는 스레드 안전한 초기화 메커니즘입니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 균일 초기화 | “Uniform Initialization” 가이드
  • C++ Dynamic Initialization | “동적 초기화” 가이드
  • C++ async & launch | “비동기 실행” 가이드

관련 글

  • C++ 균일 초기화 |
  • C++ Aggregate Initialization |
  • C++ Aggregate Initialization 완벽 가이드 | 집합 초기화
  • C++ async & launch |
  • C++ Atomic Operations |