std::thread와 mutex·exception_ptr로 작업을 분리하기

GUI 앱이나 배치 도구에서 이미지 일괄 변환, 대용량 파일 처리처럼 오래 걸리는 작업을 메인 스레드에서 실행하면 화면이 멈춥니다. 이 글에서는 C++ 표준의 std::thread, std::mutex, std::exception_ptr 를 조합해 긴 작업을 백그라운드로 옮기고, 진행률을 안전하게 읽으며, 실패를 메인 스레드에서 다시 던지는 패턴을 정리합니다.

완성할 프로그램

예제는 다음 역할을 나눕니다.

  • BackgroundJob: 실제로 시간이 걸리는 작업(예: 폴더 안 PNG 일괄 리사이즈)

  • JobRunner: 워커 스레드 수명, 진행률·상태 문자열 스냅샷, 예외 캡처

  • 메인 루프: 진행률 표시, 취소 요청, 실패 시 rethrow_exception

읽고 나면 start() / stop() / 진행률 폴링 / 예외 전파까지 갖춘 작은 워커 클래스를 직접 설계할 수 있습니다.

왜 메인 스레드에서 돌리면 안 되는가

이벤트 루프는 보통 초당 수십 번 이상 돌며 입력 처리와 화면 갱신을 담당합니다. 그 안에서 수 초짜리 루프를 돌리면 프레임이 멈추고, 사용자는 앱이 응답하지 않는다고 느낍니다.

관심사와 표준 도구의 대응은 대략 다음과 같습니다.

  1. 응답성 — 별도 std::thread에서 작업 실행

  2. 공유 상태 — std::mutexstd::lock_guard로 짧게 보호

  3. 취소 신호 — std::atomic<bool> 종료 플래그

  4. 예외 전파 — std::exception_ptr로 발생 스레드에서 캡처, 메인에서 재throw

  5. 수명 — 소멸자에서 join()하여 워커가 객체를 앞질러 살지 않게 함

클래스 역할 나누기

먼저 작업 자체와 스레드 관리를 분리합니다. 작업은 “무엇을 할지”, 러너는 “언제·어떻게 스레드로 돌릴지”만 책임집니다.

Cpp

#include <atomic> #include <exception> #include <mutex> #include <string> #include <thread> class BackgroundJob { public: std::atomic<bool> cancel_requested{false}; double progress = 0.0; // 0.0 ~ 1.0 void run(); // 무거운 작업. cancel_requested를 주기적으로 확인 std::string getStatusMessage() const; }; class JobRunner { public: explicit JobRunner(BackgroundJob* job); ~JobRunner(); void start(); void stop(); bool running() const { return is_running_; } int getProgressPercent(); std::string getStatusMessage(); bool failed = false; std::exception_ptr thread_exception; private: void worker(); BackgroundJob* job_; std::mutex state_mutex_; std::thread worker_thread_; bool is_running_ = false; double progress_ = 0.0; std::string status_message_; };

BackgroundJob::run() 안에는 도메인 로직만 둡니다. 스레드 생성, join, mutex, 예외 포인터는 모두 JobRunner에 모읍니다.

초기화와 종료 순서

스레드 객체는 “생성 → 실행 → join 또는 detach” 순서를 지켜야 합니다. 특히 ​이미 돌고 있는 std::thread에 새 스레드를 대입하면 기본 동작이 std::terminate입니다. 그래서 start()는 항상 이전 스레드를 먼저 정리합니다.

Cpp

void JobRunner::start() { stop(); // 이전 워커가 있으면 종료를 기다림 job_->cancel_requested = false; failed = false; thread_exception = nullptr; worker_thread_ = std::thread(&JobRunner::worker, this); is_running_ = true; } void JobRunner::stop() { if (!job_) { return; } job_->cancel_requested = true; if (worker_thread_.joinable()) { worker_thread_.join(); } is_running_ = false; } JobRunner::~JobRunner() { stop(); }

워커 루프와 스냅샷

워커는 작업을 수행한 뒤, 메인에 보여줄 값만 mutex로 보호된 멤버에 복사합니다. job_->run() 자체는 lock 밖에서 호출합니다.

Cpp

void JobRunner::worker() { try { do { job_->run(); const auto msg = job_->getStatusMessage(); { std::lock_guard<std::mutex> lock(state_mutex_); progress_ = job_->progress; status_message_ = msg; } } while (!job_->cancel_requested.load()); is_running_ = false; } catch (...) { std::lock_guard<std::mutex> lock(state_mutex_); thread_exception = std::current_exception(); failed = true; is_running_ = false; } } int JobRunner::getProgressPercent() { std::lock_guard<std::mutex> lock(state_mutex_); return static_cast<int>(progress_ * 100.0); } std::string JobRunner::getStatusMessage() { std::lock_guard<std::mutex> lock(state_mutex_); return status_message_; }

cancel_requested는 UI와 워커가 동시에 접근하므로 std::atomic<bool>로 둡니다. progressstatus_message처럼 복사 단위가 큰 값은 mutex로 보호하는 편이 단순합니다.

메인 루프에서 진행률과 예외 처리

메인(또는 UI) 스레드는 매 프레임 또는 타이머마다 스냅샷만 읽습니다. 워커에서 GUI API를 직접 호출하지 않습니다.

Cpp

BackgroundJob job; JobRunner runner(&job); // 시작 버튼 등 runner.start(); // 매 프레임 / 폴링 if (runner.running()) { drawProgress(runner.getProgressPercent()); drawStatus(runner.getStatusMessage()); } // 취소 버튼 // runner.stop(); if (runner.failed && runner.thread_exception) { try { std::rethrow_exception(runner.thread_exception); } catch (const std::exception& e) { showError(e.what()); } }

예외는 ​발생한 스레드에서 std::current_exception()으로 담고, ​다른 스레드에서 std::rethrow_exception으로 다시 던집니다. 포인터를 읽고 쓰는 동안에도 mutex로 보호하면 data race를 줄일 수 있습니다.

설계 체크리스트

의존성이나 요구사항이 바뀔 때 다음 원칙을 지키면 디버깅이 쉬워집니다.

  1. 작업 로직(BackgroundJob)과 스레드 관리(JobRunner)를 분리합니다.

  2. start() 전에 항상 이전 스레드를 join합니다.

  3. 긴 계산은 lock 밖에서, 공유 스냅샷 복사만 lock 안에서 수행합니다.

  4. 취소 플래그는 atomic, 복합 상태는 mutex로 보호합니다.

  5. GUI·그래픽 API는 메인 스레드에서만 호출합니다.

  6. 소멸자 경로에서도 stop()이 호출되도록 RAII를 유지합니다.

자주 발생하는 문제

std::thread 대입 시 프로그램이 바로 종료됨

이미 joinable()인 스레드에 새 std::thread를 대입하면 std::terminate가 호출됩니다. start() 맨 앞에서 stop()으로 이전 워커를 정리했는지 확인합니다.

진행률이 이상하거나 간헐적으로 깨진 문자열이 보임

progressstatus_message를 mutex 없이 읽으면 data race가 납니다. 메인 쪽 getter와 워커 쪽 갱신을 같은 mutex로 보호합니다.

취소 버튼을 눌러도 작업이 끝나지 않음

cancel_requested를 세워도 run() 안에서 플래그를 검사하지 않으면 소용없습니다. 루프 단위(파일 하나, 청크 하나)마다 atomic 플래그를 확인하도록 작업 코드를 나눕니다.

UI가 여전히 버벅임

워커 안에서 GUI를 호출하고 있거나, mutex를 run() 전체에 걸어 둔 경우를 의심합니다. 또한 메인 루프가 진행률 폴링 외에 무거운 일을 함께 하고 있는지도 확인합니다.

예외를 메인에서 볼 수 없음

워커의 catch (...)에서 std::current_exception()을 저장했는지, 메인이 failedthread_exception을 확인한 뒤 rethrow_exception을 호출하는지 점검합니다. 저장만 하고 메인에서 읽지 않으면 실패가 조용히 사라집니다.

다음 단계

기본 워커가 동작하면 다음으로 확장하기 좋습니다.

  • 작업 큐와 여러 BackgroundJob을 순서대로 처리하기

  • std::condition_variable로 “새 작업 도착”을 대기하기

  • 스레드 풀로 고정 개수 워커를 재사용하기 (이 시리즈의 ThreadPool 글)

  • 진행률을 lock-free 원자 변수로 단순화할 수 있는지 측정하기

표준 문서에서는 std::thread, std::mutex, std::exception_ptr 항목을 함께 읽어 두면 API 세부 동작(특히 joinable과 소멸자 규칙)을 놓치지 않습니다.

// 예제: thread + mutex 스냅샷 + atomic 취소 + exception_ptr
#include <atomic>
#include <chrono>
#include <exception>
#include <iostream>
#include <mutex>
#include <thread>

int main() {
  std::atomic<bool> cancel{false};
  std::mutex mu;
  int progress = 0;
  std::exception_ptr err;

  std::thread worker([&] {
    try {
      for (int i = 1; i <= 10; ++i) {
        if (cancel.load()) break;
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        // if (i == 5) throw std::runtime_error("boom");
        std::lock_guard<std::mutex> lock(mu);
        progress = i * 10;
      }
    } catch (...) {
      std::lock_guard<std::mutex> lock(mu);
      err = std::current_exception();
    }
  });

  while (true) {
    int p = 0;
    std::exception_ptr e;
    {
      std::lock_guard<std::mutex> lock(mu);
      p = progress;
      e = err;
    }
    std::cout << "progress=" << p << "%\n";
    if (p >= 100 || e) {
      if (e) {
        try {
          std::rethrow_exception(e);
        } catch (const std::exception& ex) {
          std::cout << "error: " << ex.what() << '\n';
        }
      }
      break;
    }
    // if (p >= 30) cancel = true;
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
  }

  cancel = true;
  worker.join();
  std::cout << "done\n";
  return 0;
}
On this page