C++ std::call_once와 once_flag: 스레드 안전한 1회 초기화와 예외 시 재시도

이 글의 핵심

지연 초기화를 직접 락으로 구현하면 이중 검사 잠금 같은 함정에 빠지기 쉽습니다. call_once가 예외 발생 시 플래그를 세우지 않는 점, once_flag를 여러 개 섞어 쓸 때의 실수를 짚고, C++11 이후 스레드 안전해진 static 지역 변수와 비교해 어느 쪽을 고를지까지 다룹니다.

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, [] {
        // 초기화 (한 번만)
    });
}

위의 “수동 동기화” 버전도 사실 틀린 코드는 아닙니다. 매번 뮤텍스를 잡으니 정확하지만, 초기화가 끝난 뒤에도 모든 호출이 락을 경쟁하므로 자주 불리는 함수라면 병목이 됩니다. 이를 피하려고 락 바깥에서 initialized를 먼저 확인하는 것이 이중 검사 잠금(DCLP)인데, bool을 일반 변수로 두면 한 스레드가 initialized = true를 쓰는 것과 초기화된 데이터를 쓰는 순서가 다른 스레드에게 뒤바뀌어 보일 수 있어 “초기화 안 된 객체를 읽는” 버그가 생깁니다. 이 문제는 std::atomic<bool>과 acquire/release 순서로만 올바르게 고칠 수 있고, call_once는 이 과정을 라이브러리가 정확하게 구현해 둔 것이라고 보면 됩니다.

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;

이 싱글톤은 new로 만든 객체를 일부러 해제하지 않습니다. 프로그램 종료 시 정적 객체들이 생성 역순으로 파괴될 때, 다른 정적 객체의 소멸자가 이미 파괴된 싱글톤을 쓰는 “정적 소멸 순서 문제”를 피하기 위한 흔한 선택입니다. 대신 Valgrind나 LeakSanitizer가 종료 시점에 “still reachable” 누수로 보고할 수 있고, 소멸자에서 파일을 닫거나 버퍼를 flush해야 하는 객체라면 그 정리가 실행되지 않는다는 대가가 있습니다.

예시 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;
}

getConfig()는 초기화만 스레드 안전하게 보장할 뿐, 반환된 Config&로 여러 스레드가 값을 수정하면 그것은 별개의 데이터 레이스입니다. 설정처럼 한 번 읽고 나면 바뀌지 않는 데이터라면 const Config&를 반환해 수정 자체를 막는 편이 의도에 맞습니다. call_once가 끝난 뒤에 초기화 결과를 읽는 것은 안전한데, 표준이 “성공한 call_once의 완료는 같은 플래그에 대한 이후 call_once 반환보다 먼저 일어난다(synchronizes-with)“고 보장해 주기 때문입니다.

예시 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에서 재시도
    }
}

예외 규칙은 표준에 명확합니다. 함수가 예외로 끝나면 그 호출은 예외적 실행(exceptional execution)으로 취급되어 예외가 호출자에게 전파되고, 플래그는 완료되지 않은 상태로 남아 다른 스레드나 다음 호출이 함수를 다시 실행합니다. 이때 다른 스레드가 같은 플래그로 대기 중이었다면 그중 하나가 이어서 실행을 시도합니다. 다만 과거 GCC의 libstdc++ 구현은 pthread_once를 이용했기 때문에 예외가 나면 다음 호출이 영원히 멈추는 결함이 보고된 적이 있어(GCC Bugzilla 66146), 오래된 툴체인에서 “실패하면 재시도” 동작에 의존한다면 해당 버전에서 직접 테스트해 보는 것이 안전합니다.

자주 발생하는 문제

문제 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");
}

인자는 std::thread나 std::bind와 같은 규칙으로 전달됩니다. "Hello"는 const char*로 넘어간 뒤 init 호출 시 std::string으로 변환되고, 참조로 넘기고 싶은 변수는 std::ref(x)로 감싸야 합니다. 첫 호출에서만 실제로 쓰이므로, 두 번째 호출부터 넘긴 인자는 조용히 무시된다는 점도 기억해 두세요.

문제 3: 멤버 함수

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

once_flag를 멤버로 두면 객체마다 한 번씩 초기화가 일어나 객체별 지연 초기화에 쓸 수 있습니다. 대신 once_flag는 복사·이동이 모두 삭제되어 있어 이 클래스도 기본 복사 생성자와 이동 생성자를 잃습니다. std::vector<MyClass>에 넣으려다 use of deleted function 'std::once_flag::once_flag(const std::once_flag&)' 에러를 만나는 것이 전형적인 증상이고, 복사가 필요하면 복사 생성자에서 새 once_flag를 기본 생성하도록 직접 작성해야 합니다. 또 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;
}

정적 지역 변수의 스레드 안전 초기화(“magic statics”)는 C++11 표준이 보장하고, 컴파일러는 보통 가드 변수와 __cxa_guard_acquire 같은 런타임 함수로 구현합니다. 원리적으로 call_once와 같은 일을 하며, 생성자가 예외를 던지면 다음 호출에서 초기화를 다시 시도한다는 점도 같습니다. 주의할 점은 오래된 MSVC(Visual Studio 2013 이전)가 이 보장을 지원하지 않았고, 이후 버전에서도 /Zc:threadSafeInit-로 끌 수 있다는 것입니다. 임베디드 툴체인에서는 -fno-threadsafe-statics로 가드를 끄는 경우도 있어, 그런 환경에서는 명시적인 call_once가 더 확실합니다.

실무 패턴

패턴 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");

이 래퍼는 편리하지만 계약이 미묘합니다. 첫 번째 get() 호출의 인자로만 객체가 만들어지고, 이후 db.get("other-host", 3306)처럼 다른 인자를 넘겨도 아무 경고 없이 무시됩니다. 여러 곳에서 서로 다른 인자로 부를 수 있는 코드라면 이 동작이 버그를 숨기므로, 생성 인자를 생성자에서 한 번 받아 저장해 두고 get()은 인자 없이 두는 편이 안전합니다. 람다가 args를 참조로 캡처하는 것은 call_once가 반환하기 전에 람다를 실행하므로 문제없습니다.

패턴 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";
    }
};

초기화 체인에서 절대 하면 안 되는 것은 같은 once_flag에 대한 재귀 호출입니다. 예를 들어 initConfig() 안에서 다시 std::call_once(configFlag_, ...)를 부르면, 첫 호출이 아직 끝나지 않은 플래그를 같은 스레드가 다시 기다리게 되어 교착 상태(구현에 따라 미정의 동작)가 됩니다. 두 초기화 함수가 서로의 플래그를 부르는 순환 의존도 같은 결과를 낳으므로, 의존 관계는 이 예제처럼 한 방향으로만 흐르게 설계해야 합니다.

패턴 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 지역 변수도 예외 시 재시도)
동적 라이브러리 로드, 플러그인 등록처럼 “한 번만”이지만 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);

// 이후 호출: 원자적 로드 한 번 수준의 빠른 경로 (정확한 비용은 구현·플랫폼마다 다름)
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는 여러 스레드에서 함수를 정확히 한 번만 실행하도록 보장하는 스레드 안전한 초기화 메커니즘입니다.


같이 보면 좋은 글