- Published on
Rust Tokio Cannot start a runtime 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/CLI를 Rust로 만들다 보면 Tokio를 붙이는 순간 한 번쯤 마주치는 메시지가 있습니다.
> Cannot start a runtime from within a runtime
표현만 보면 “런타임을 시작할 수 없다”인데, 실제 의미는 더 구체적입니다. 이미 Tokio 런타임이 실행 중인 스레드에서 또 다른 런타임을 시작하려고 했다는 뜻이거나, 런타임 컨텍스트 안에서 동기 블로킹 방식으로 런타임을 돌리려 했다는 신호입니다.
이 글에서는 실무에서 자주 나오는 케이스를 중심으로:
- 어떤 상황에서 이 패닉이 발생하는지
- 재현 가능한 최소 코드
- 구조적으로 안전한 해결 패턴
- 라이브러리/테스트/Drop(소멸자)에서의 주의점
을 정리합니다.
에러의 정체: “런타임 중첩”이 핵심
Tokio의 런타임은 이벤트 루프/스케줄러를 관리합니다. 런타임은 스레드 로컬 컨텍스트로 “현재 런타임”이 설정되는데, 이 상태에서 다시 Runtime::new() + block_on() 같은 방식으로 런타임을 시작하면 충돌합니다.
대표 패턴은 아래 둘입니다.
- 비동기 함수 내부에서
block_on호출 - 이미
#[tokio::main]으로 런타임이 있는 상태에서 다른 런타임 생성
이런 문제는 배포 환경에서만 터지기도 합니다. 예를 들어 워커 스레드/콜백/Drop에서 우연히 런타임 컨텍스트를 잡고 있는 상태에서 동기 호출을 하면 “간헐적”으로 보일 수 있습니다.
케이스 1) #[tokio::main] 안에서 또 런타임 만들기
가장 흔한 실수입니다.
재현 코드
use tokio::runtime::Runtime;
#[tokio::main]
async fn main() {
// 이미 Tokio runtime이 시작된 상태
let rt = Runtime::new().unwrap();
rt.block_on(async {
println!("hello");
});
}
해결
- 런타임은 한 번만 만들고,
main에서await로 끝까지 끌고 갑니다.
#[tokio::main]
async fn main() {
println!("hello");
}
만약 “동기 main”이 필요하다면 반대로 #[tokio::main]을 제거하고 Runtime::new().block_on(...)을 최상위에서만 사용하세요.
use tokio::runtime::Runtime;
fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
println!("hello");
});
}
핵심은 진입점(Entry point)에서만 런타임을 구성하는 것입니다.
케이스 2) async 컨텍스트에서 block_on 호출
비동기 함수 내부에서 “동기적으로 기다리고 싶다”는 유혹이 큽니다. 하지만 block_on은 런타임을 돌리기 위해 스레드를 점유합니다. 이미 런타임이 그 스레드를 사용 중이면 중첩 실행이 되어 패닉으로 이어집니다.
재현 코드
use tokio::runtime::Runtime;
async fn do_async_work() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
// ...
});
}
#[tokio::main]
async fn main() {
do_async_work().await;
}
해결 1) 그냥 await로 바꾸기
대부분은 구조를 조금만 바꾸면 됩니다.
async fn do_async_work() {
// block_on 없이 자연스럽게 await
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
#[tokio::main]
async fn main() {
do_async_work().await;
}
해결 2) 정말로 동기 블로킹이 필요하면 spawn_blocking
CPU 바운드 작업이나, 기존 동기 라이브러리 호출처럼 스레드를 블로킹해야 하는 작업은 spawn_blocking으로 격리합니다.
#[tokio::main]
async fn main() {
let handle = tokio::task::spawn_blocking(|| {
// 동기 블로킹 작업
std::thread::sleep(std::time::Duration::from_millis(50));
42
});
let result = handle.await.unwrap();
println!("result = {result}");
}
이 패턴은 운영 환경에서 “갑자기 응답이 멈춤” 같은 증상을 줄이는 데도 중요합니다. (비동기 런타임 스레드를 블로킹하면 처리량이 급감합니다.)
케이스 3) 라이브러리 코드가 런타임을 자체 생성하는 경우
애플리케이션은 #[tokio::main]을 쓰는데, 내부에서 사용하는 라이브러리가 편의상 Runtime::new().block_on(...)을 해버리면 사용자 앱에서 충돌합니다.
나쁜 라이브러리 예시
// library crate
use tokio::runtime::Runtime;
pub fn fetch_sync() -> String {
let rt = Runtime::new().unwrap();
rt.block_on(async { "data".to_string() })
}
권장: async API를 제공하고, 동기 래퍼는 별도 크레이트/feature로
// library crate
pub async fn fetch_async() -> String {
"data".to_string()
}
앱에서:
#[tokio::main]
async fn main() {
let s = fetch_async().await;
println!("{s}");
}
정말 동기 API가 필요하다면, 아래처럼 “이미 런타임이 있으면 그 컨텍스트를 사용하고, 없으면 새로 만든다”는 전략을 쓰기도 합니다. 다만 이 방식은 호출 위치에 따라 동작이 달라져 디버깅이 어려워질 수 있어 신중해야 합니다.
pub fn fetch_sync_best_effort() -> String {
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
// 현재 런타임 위에서 실행 (단, 여기서 block_on은 또 다른 문제를 만들 수 있음)
// 일반적으로는 sync 함수에서 async를 돌리는 것 자체를 피하는 게 최선.
handle.block_on(async { "data".to_string() })
}
Err(_) => {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async { "data".to_string() })
}
}
}
여기서도 결론은 같습니다. 가능하면 “동기에서 async 호출” 자체를 없애는 설계가 가장 안전합니다.
케이스 4) Drop(소멸자)에서 async 정리를 하려다 터지는 경우
실무에서 꽤 까다로운 케이스입니다.
- 커넥션/클라이언트/워커를 Drop할 때
- “종료 시 flush/close를 보장”하려고
- Drop 안에서
block_on(async { ... })같은 정리를 넣음
Drop은 언제 호출될지 예측하기 어렵고, 호출 시점에 이미 런타임 컨텍스트 안일 수 있습니다. 이때 런타임 중첩 문제가 터집니다.
해결: Drop에서 async를 하지 말고, 명시적 shutdown().await 제공
struct MyClient {
// ...
}
impl MyClient {
pub async fn shutdown(self) {
// async 정리 로직
// e.g. background task 종료 신호 보내고 join
}
}
#[tokio::main]
async fn main() {
let client = MyClient {};
// ... 사용
client.shutdown().await; // 명시적으로 종료
}
“종료를 강제”하고 싶다면 Drop에서는 최소한의 동기 정리만 하고, 중요한 비동기 정리는 호출자가 책임지게 만드는 편이 운영 안정성이 높습니다.
운영 환경에서 종료/재기동이 꼬이면 컨테이너가 비정상 종료로 보이기도 합니다. 이런 상황은 종종 CrashLoop로 이어지므로, 애플리케이션 종료 시나리오도 꼭 점검하세요. 관련해서는 K8s CrashLoopBackOff·OOMKilled 원인별 해결 가이드도 함께 참고할 만합니다.
케이스 5) 테스트에서 런타임을 중복 생성
Tokio 테스트는 보통 #[tokio::test]를 씁니다. 그런데 테스트 헬퍼가 런타임을 따로 만들면 충돌할 수 있습니다.
흔한 실수
use tokio::runtime::Runtime;
fn helper() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
// ...
});
}
#[tokio::test]
async fn test_something() {
helper();
}
해결
헬퍼를 async로 만들고 테스트에서 await:
async fn helper() {
// ...
}
#[tokio::test]
async fn test_something() {
helper().await;
}
또는 통합 테스트에서만 “동기 진입점 + 런타임 생성”을 쓰되, 그 안에서 async 테스트를 모아 실행하는 방식도 가능합니다.
실전 체크리스트: 어디서 Runtime::new()를 쓰고 있는가?
문제 해결의 80%는 “런타임 생성 위치”를 찾는 데서 끝납니다.
- 코드베이스에서
Runtime::new,Builder::new_multi_thread,block_on을 전부 검색 #[tokio::main],#[tokio::test]사용 여부 확인- 라이브러리/모듈이 편의상 런타임을 만들고 있지 않은지 확인
- Drop/전역(static)/lazy init에서 async를 억지로 실행하지 않는지 확인
- 동기 I/O, CPU 바운드 처리를 런타임 스레드에서 직접 돌리고 있지 않은지 확인 (
spawn_blocking고려)
특히 외부 시스템(예: DB) 연결/종료 로직을 동기-비동기 혼합으로 구성하면, 런타임 관련 패닉뿐 아니라 리소스 고갈로도 이어질 수 있습니다. 커넥션 관리가 얽혀 있다면 Spring Boot 대규모 트래픽 HikariCP 고갈 진단·튜닝처럼 “풀 고갈을 구조적으로 막는 관점”도 함께 가져가면 좋습니다(언어는 달라도 운영 원리는 유사합니다).
권장 아키텍처 패턴
패턴 A) 바이너리(앱)에서만 런타임을 만들고, 라이브러리는 async만 제공
bin/또는main.rs:#[tokio::main]lib/:pub async fn ...만 제공
이 패턴은 팀 개발에서 특히 안전합니다. 누군가 라이브러리에서 런타임을 만들어버리면, 사용하는 모든 앱에서 폭탄이 됩니다.
패턴 B) 동기 API가 필요하면 “별도 바이너리/별도 feature”로 분리
예:
mycrate는 async만mycrate-sync또는mycrate의syncfeature에서만 런타임 생성
이렇게 하면 런타임 정책이 명확해지고, 의존하는 쪽이 선택할 수 있습니다.
마무리
Tokio의 Cannot start a runtime는 단순한 에러 메시지지만, 실제로는 런타임 생명주기/경계가 설계되지 않았다는 신호인 경우가 많습니다.
- 런타임은 진입점에서 한 번만
- async 내부에서
block_on금지 - 동기 블로킹은
spawn_blocking으로 격리 - Drop에서 async 정리하지 말고
shutdown().await로 명시화 - 라이브러리는 async API 중심으로
위 원칙을 적용하면 해당 패닉은 대부분 사라지고, 코드도 더 예측 가능해집니다.