[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"article-deep-evm-29-semaphore-async-rust-deadlock-ko":3},{"article":4,"author":51},{"id":5,"category_id":6,"title":7,"slug":8,"excerpt":9,"content_md":10,"content_html":11,"locale":12,"author_id":13,"published":14,"published_at":15,"meta_title":16,"meta_description":17,"focus_keyword":18,"og_image":19,"canonical_url":19,"robots_meta":20,"created_at":15,"updated_at":15,"tags":21,"category_name":31,"related_articles":32},"d5000000-0000-0000-0000-000000000301","a0000000-0000-0000-0000-000000000056","Deep EVM #29: Async Rust에서의 세마포어 — 데드락 추적과 Fire-and-Forget 패턴","deep-evm-29-semaphore-async-rust-deadlock-ko","tokio::sync::Semaphore를 활용한 백프레셔 제어, fire-and-forget 쓰기 패턴, tracing과 tokio-console을 통한 데드락 진단, RAII 퍼밋과 acquire 타임아웃을 활용한 프로덕션 강화 솔루션에 대한 심층 분석.","## Async Rust에서 세마포어가 필요한 이유\n\n고처리량 파이프라인을 운영할 때 — 블록당 180,000개의 아비트라지 체인을 처리하는 MEV 봇, 10,000개의 동시 요청을 처리하는 API 서버, 수백만 행을 기록하는 ETL 작업 — 반드시 리소스 한계에 도달합니다. 데이터베이스 커넥션 풀이 고갈되고, RPC 프로바이더가 속도 제한을 적용하며, 50,000개의 tokio 태스크를 생성하여 각각 체인 데이터를 보유하면서 메모리가 급증합니다.\n\n단순한 접근 방식은 무제한 동시성입니다: 모든 작업 단위에 `tokio::spawn`을 사용하고 런타임이 알아서 처리하기를 기대합니다. 하지만 그렇게 되지 않습니다. 프로덕션 환경에서 우리는 270만 개의 생성된 태스크로 인해 15.4GB의 메모리 사용량을 관찰했으며, 각 태스크는 `Vec\u003CHop>`과 시뮬레이션 컨텍스트를 보유하고 있었습니다. 해결책은 세마포어 기반 백프레셔를 통한 배치 동시성이었으며, 이를 통해 메모리를 0.8GB로 줄였습니다.\n\n세마포어는 작업을 완전히 직렬화하지 않으면서 동시 작업 수를 제한해야 할 때 적합한 프리미티브입니다. 뮤텍스(정확히 하나만 허용)와 달리 세마포어는 N개의 동시 접근자를 허용합니다. 이는 다음과 같은 용도에 적합합니다:\n\n- **데이터베이스 쓰기 동시성**: 커넥션 풀 크기로 제한 (예: 20개 동시 쓰기)\n- **RPC 속도 제한**: 프로바이더로부터의 429 응답을 피하기 위해 발신 요청 제한\n- **메모리 백프레셔**: 사용 가능한 퍼밋으로 게이트하여 무제한 태스크 생성 방지\n- **배치 처리**: 공유 노드에 대해 병렬 실행되는 시뮬레이션 배치 수 제어\n\n## tokio::sync::Semaphore 기초\n\n`tokio::sync::Semaphore`는 비동기 코드를 위한 카운팅 세마포어입니다. 사용 가능한 퍼밋의 내부 카운터를 유지합니다. 태스크는 진행하기 전에 퍼밋을 획득하고 완료 시 해제합니다.\n\n```rust\nuse std::sync::Arc;\nuse tokio::sync::Semaphore;\n\n\u002F\u002F 최대 20개의 동시 데이터베이스 쓰기 허용\nlet semaphore = Arc::new(Semaphore::new(20));\n\nfor chain in chains_to_persist {\n    let sem = semaphore.clone();\n    let db_pool = db_pool.clone();\n\n    tokio::spawn(async move {\n        \u002F\u002F 퍼밋 획득 — 20개 모두 사용 중이면 대기\n        let _permit = sem.acquire().await.unwrap();\n\n        \u002F\u002F 쓰기 수행 — 이 스코프 동안 퍼밋 보유\n        sqlx::query(\"UPDATE chains SET profit = $1 WHERE id = $2\")\n            .bind(chain.profit)\n            .bind(chain.id)\n            .execute(&db_pool)\n            .await\n            .ok();\n\n        \u002F\u002F _permit이 여기서 드롭 — 자동으로 해제\n    });\n}\n```\n\n주요 API:\n\n- `Semaphore::new(permits)` — N개의 퍼밋으로 생성\n- `acquire(&self)` — 비동기, 퍼밋 사용 가능할 때까지 대기, `SemaphorePermit` (RAII 가드) 반환\n- `try_acquire(&self)` — 비동기 아님, 퍼밋이 없으면 즉시 `Err(TryAcquireError)` 반환\n- `acquire_owned(self: Arc\u003CSelf>)` — `Arc`를 소유하는 `OwnedSemaphorePermit` 반환, 퍼밋이 빌림보다 오래 지속되어야 할 때 유용\n- `add_permits(&self, n)` — 동적으로 퍼밋 수 증가\n- `close(&self)` — 세마포어 닫기; 모든 대기 중인 `acquire` 호출이 `Err(AcquireError)` 반환\n- `available_permits(&self)` — 현재 카운트 확인 (메트릭에 유용)\n\n핵심 설계 선택: `acquire()`는 RAII 가드를 반환합니다. 가드가 드롭되면 퍼밋이 해제됩니다. 이는 패닉, 조기 반환, `?` 연산자 중단 시에도 가드가 스택에 있는 한 자동 정리를 보장합니다.\n\n## Fire-and-Forget 쓰기 패턴\n\n고처리량 시스템에서는 핫 패스를 차단하지 않고 데이터를 영속화하고 싶을 때가 많습니다. 패턴은 다음과 같습니다: 백그라운드 태스크를 생성하여 세마포어 퍼밋을 획득하고, 쓰기를 수행한 다음 해제합니다. 호출자는 결과를 기다리지 않습니다.\n\n잘 설계된 시스템에서는 내부 스토리지 서비스를 래핑하는 데코레이터로 구현됩니다:\n\n```rust\npub struct AsyncChainStore\u003CS: ChainStore> {\n    inner: Arc\u003CS>,\n    semaphore: Arc\u003CSemaphore>,\n}\n\nimpl\u003CS: ChainStore + Send + Sync + 'static> AsyncChainStore\u003CS> {\n    pub fn save_profits_async(&self, chains: Vec\u003CChainProfit>) {\n        let inner = self.inner.clone();\n        let sem = self.semaphore.clone();\n\n        tokio::spawn(async move {\n            let _permit = match sem.acquire().await {\n                Ok(p) => p,\n                Err(_) => {\n                    tracing::warn!(\"세마포어 닫힘, 쓰기 드롭\");\n                    return;\n                }\n            };\n\n            if let Err(e) = inner.batch_update_profits(&chains).await {\n                tracing::error!(\n                    error = %e,\n                    count = chains.len(),\n                    \"fire-and-forget 쓰기 실패\"\n                );\n            }\n        });\n    }\n}\n```\n\n이 데코레이터 패턴은 내부 `ChainStore`를 순수하게 유지합니다 — 동시성 제어에 대해 전혀 알 필요가 없습니다. 데코레이터가 세마포어 관리, 에러 로깅, 태스크 생성을 처리합니다.\n\n왜 데이터베이스 풀의 내장 커넥션 제한을 사용하지 않을까요? 세마포어가 별도의 조절 장치를 제공하기 때문입니다. 풀에 50개의 커넥션이 있더라도 fire-and-forget 쓰기에는 최대 20개를 사용하고 나머지 30개는 지연 시간에 민감한 읽기를 위해 예약할 수 있습니다.\n\n## 데드락 시나리오\n\n세마포어 데드락은 교묘합니다. 패닉이나 에러가 발생하지 않고 프로그램이 단순히 진행을 멈춥니다. 프로덕션에서 겪은 패턴들을 소개합니다.\n\n### 시나리오 1: 조기 반환 시 퍼밋 미해제\n\nRAII 가드는 대부분의 조기 반환으로부터 보호합니다. 위험한 것은 퍼밋을 예상 수명을 초과하는 구조체로 이동하는 경우입니다 — `HashMap`에 저장, 채널을 통해 전송, `static`에 보유하는 경우.\n\n### 시나리오 2: 중첩된 Acquire (자기 데드락)\n\n```rust\nasync fn simulate_and_persist(\n    sem: &Semaphore,\n    chain: &Chain,\n) -> Result\u003C()> {\n    let _outer = sem.acquire().await?; \u002F\u002F N개 퍼밋 중 1개 사용\n\n    let result = simulate(chain).await?;\n\n    \u002F\u002F 버그: 보유 중인 퍼밋 안에서 동일한 세마포어 획득\n    let _inner = sem.acquire().await?; \u002F\u002F N=1이면 데드락\n    persist(result).await?;\n\n    Ok(())\n}\n```\n\n`N=1`이면 즉시 데드락입니다. `N>1`이면 부하가 증가하여 모든 퍼밋이 내부 acquire를 기다리는 태스크에 의해 보유될 때까지 작동합니다. 해결책: 별도의 관심사에 대해 별도의 세마포어를 사용하거나, 중첩 획득을 피하도록 구조를 변경합니다.\n\n### 시나리오 3: Select 내 await 포인트에서 퍼밋 보유\n\n`select!` 매크로는 패배한 브랜치의 future를 드롭하여 취소하지만, 해당 future 내에서 생성된 태스크는 계속 실행됩니다. 해당 태스크가 퍼밋에 대한 참조를 캡처했다면 퍼밋이 예상대로 해제되지 않습니다.\n\n## 세마포어 데드락 진단\n\n시스템이 진행을 멈출 때, 세마포어 데드락인지 느린 의존성인지 어떻게 식별할까요?\n\n### 트레이싱 기반 진단\n\nacquire\u002Frelease를 구조화된 로깅으로 계측합니다:\n\n```rust\nlet permits_before = semaphore.available_permits();\ntracing::debug!(\n    available = permits_before,\n    \"세마포어 퍼밋 획득 중\"\n);\n\nlet _permit = semaphore.acquire().await?;\n\ntracing::debug!(\n    available = semaphore.available_permits(),\n    \"세마포어 퍼밋 획득 완료\"\n);\n```\n\n로그에 \"획득 중\"은 나오지만 \"획득 완료\"가 나오지 않으면 모든 퍼밋이 어딘가에 보유되어 있는 것입니다. 마지막 N개의 \"획득 완료\" 로그 항목과 상관관계를 분석하여 누가 보유하고 있는지 찾습니다.\n\n### Prometheus 메트릭\n\n사용 가능한 퍼밋에 대한 게이지 메트릭을 노출합니다:\n\n```rust\nuse metrics::gauge;\n\ngauge!(\"semaphore_available_permits\", semaphore.available_permits() as f64);\n```\n\n0으로 떨어져 그대로 유지되는 게이지는 데드락입니다. 0 근처에서 변동하는 게이지는 경합(높은 부하, 더 많은 퍼밋 필요 가능성)입니다.\n\n### tokio-console\n\n`tokio-console`은 실행 중인 tokio 애플리케이션에 연결하여 실시간으로 태스크 상태를 보여주는 진단 도구입니다:\n\n```bash\ncargo add --dev console-subscriber\n```\n\n```rust\n#[cfg(debug_assertions)]\nconsole_subscriber::init();\n```\n\n다른 터미널에서 `tokio-console`을 실행하고 세마포어 acquire에서 \"Idle\" 상태로 멈춘 태스크를 찾습니다.\n\n## 프로덕션 강화 솔루션\n\n### 솔루션 1: OwnedSemaphorePermit과 Arc 사용\n\n태스크를 생성할 때 `acquire()` 대신 `acquire_owned()`를 사용합니다. 소유 변형은 `Arc\u003CSemaphore>`를 받아 세마포어를 빌리지 않는 퍼밋을 반환합니다:\n\n```rust\nlet semaphore = Arc::new(Semaphore::new(20));\n\nfor item in items {\n    let permit = semaphore.clone().acquire_owned().await?;\n\n    tokio::spawn(async move {\n        do_work(item).await;\n        drop(permit); \u002F\u002F 명시적 드롭 또는 스코프가 처리\n    });\n}\n```\n\n### 솔루션 2: 타임아웃 포함 Acquire\n\n프로덕션에서 퍼밋을 무한정 기다리지 마십시오. `tokio::time::timeout`을 사용하여 데드락을 조기에 감지합니다:\n\n```rust\nuse tokio::time::{timeout, Duration};\n\nlet permit = timeout(\n    Duration::from_secs(30),\n    semaphore.acquire(),\n).await\n    .map_err(|_| anyhow!(\"세마포어 acquire 타임아웃 — 데드락 가능성\"))?\n    .map_err(|_| anyhow!(\"세마포어 닫힘\"))?;\n```\n\n타임아웃이 발생하면 사용 가능한 퍼밋 수, 대기 중인 태스크 수, 스택 트레이스를 로깅합니다. 이를 통해 프로세스를 중단하지 않고도 데드락에 대한 즉각적인 가시성을 확보할 수 있습니다.\n\n### 솔루션 3: JoinSet을 활용한 구조화된 동시성\n\n무제한 `tokio::spawn` 대신 `tokio::task::JoinSet`을 사용하여 생성된 태스크의 소유권을 유지하고 세마포어와 결합합니다:\n\n```rust\nuse tokio::task::JoinSet;\n\nlet semaphore = Arc::new(Semaphore::new(20));\nlet mut join_set = JoinSet::new();\n\nfor batch in batches {\n    let permit = semaphore.clone().acquire_owned().await?;\n    let db_pool = db_pool.clone();\n\n    join_set.spawn(async move {\n        let result = process_batch(&db_pool, &batch).await;\n        drop(permit);\n        result\n    });\n}\n\nwhile let Some(result) = join_set.join_next().await {\n    match result {\n        Ok(Ok(())) => {}\n        Ok(Err(e)) => tracing::error!(error = %e, \"배치 처리 실패\"),\n        Err(e) => tracing::error!(error = %e, \"태스크 패닉\"),\n    }\n}\n```\n\n이 패턴은 다음을 보장합니다: (1) 세마포어를 통한 제한된 동시성, (2) 무음 태스크 실패 없음, (3) 부모 태스크가 모든 자식 완료를 인지, (4) 태스크가 패닉해도 퍼밋은 항상 해제.\n\n### 솔루션 4: 관심사별 별도 세마포어\n\n관련 없는 작업 간에 단일 세마포어를 공유하지 마십시오:\n\n```rust\npub struct ConcurrencyLimits {\n    \u002F\u002F\u002F 노드에 대한 동시 RPC 시뮬레이션 호출 제한\n    pub simulation: Arc\u003CSemaphore>,\n    \u002F\u002F\u002F 체인 영속화를 위한 동시 데이터베이스 쓰기 제한\n    pub db_writes: Arc\u003CSemaphore>,\n    \u002F\u002F\u002F 동시 멤풀 구독 핸들러 제한\n    pub mempool: Arc\u003CSemaphore>,\n}\n\nimpl ConcurrencyLimits {\n    pub fn new() -> Self {\n        Self {\n            simulation: Arc::new(Semaphore::new(4)),\n            db_writes: Arc::new(Semaphore::new(20)),\n            mempool: Arc::new(Semaphore::new(100)),\n        }\n    }\n}\n```\n\n이를 통해 중첩된 acquire 데드락을 완전히 제거합니다 — 태스크가 `simulation` 퍼밋을 획득한 후 `db_writes` 퍼밋을 획득하며, 이들은 독립적인 풀입니다.\n\n## 성능: 세마포어 오버헤드\n\n세마포어 자체가 병목이 될까요? 실제로는 아닙니다. `tokio::sync::Semaphore`는 원자적 카운터와 침입형 연결 리스트로 구현됩니다. 비경합 퍼밋 획득은 단일 `fetch_sub` — 나노초입니다. 경합 상태에서도 오버헤드는 웨이커 알림(역시 나노초)이며, 실제 I\u002FO가 걸리는 밀리초와 비교됩니다.\n\n우리 파이프라인에서 세마포어 오버헤드를 벤치마킹했습니다:\n\n| 작업 | 세마포어 없음 | 세마포어 사용 (20 퍼밋) |\n|------|------------|---------------------|\n| 10,000 DB 쓰기 | 1,340ms (전체 동시, 풀 고갈 에러) | 1,580ms (제어됨, 에러 제로) |\n| 500 RPC 시뮬레이션 | 8,200ms (노드 과부하, 타임아웃) | 9,100ms (4개 동시, 타임아웃 제로) |\n| 메모리 (270만 태스크) | 15.4 GB | 0.8 GB (JoinSet 배치) |\n\n10-15%의 벽시계 오버헤드는 에러, 타임아웃, OOM 크래시 제거와 비교하면 무시할 수 있는 수준입니다.\n\n## 결론\n\nAsync Rust에서의 세마포어는 겉보기에 단순합니다 — `acquire`, 작업 수행, 퍼밋 드롭. 복잡성은 프로덕션에서 나타납니다: 장기 보유 구조체에 누출된 퍼밋, 콜 스택 간 중첩된 acquire, `select!` 취소 경계에서 보유된 퍼밋.\n\n방어적 플레이북:\n\n1. 태스크 생성 시 **항상** `acquire_owned()` 사용\n2. acquire에 **항상** 타임아웃 래핑\n3. 동일 세마포어에서 acquire를 **절대** 중첩하지 않기\n4. 별도의 리소스 풀에 대해 세마포어 **분리**\n5. 메트릭과 트레이싱으로 사용 가능한 퍼밋 **계측**\n6. 무제한 `tokio::spawn` 대신 **JoinSet 사용**으로 구조화된 동시성 유지\n\n이 패턴들은 수백만 블록 처리, 수십만 동시 태스크, 그리고 채택 이후 프로덕션에서 데드락 제로를 달성하며 검증되었습니다.","\u003Ch2 id=\"async-rust\">Async Rust에서 세마포어가 필요한 이유\u003C\u002Fh2>\n\u003Cp>고처리량 파이프라인을 운영할 때 — 블록당 180,000개의 아비트라지 체인을 처리하는 MEV 봇, 10,000개의 동시 요청을 처리하는 API 서버, 수백만 행을 기록하는 ETL 작업 — 반드시 리소스 한계에 도달합니다. 데이터베이스 커넥션 풀이 고갈되고, RPC 프로바이더가 속도 제한을 적용하며, 50,000개의 tokio 태스크를 생성하여 각각 체인 데이터를 보유하면서 메모리가 급증합니다.\u003C\u002Fp>\n\u003Cp>단순한 접근 방식은 무제한 동시성입니다: 모든 작업 단위에 \u003Ccode>tokio::spawn\u003C\u002Fcode>을 사용하고 런타임이 알아서 처리하기를 기대합니다. 하지만 그렇게 되지 않습니다. 프로덕션 환경에서 우리는 270만 개의 생성된 태스크로 인해 15.4GB의 메모리 사용량을 관찰했으며, 각 태스크는 \u003Ccode>Vec&lt;Hop&gt;\u003C\u002Fcode>과 시뮬레이션 컨텍스트를 보유하고 있었습니다. 해결책은 세마포어 기반 백프레셔를 통한 배치 동시성이었으며, 이를 통해 메모리를 0.8GB로 줄였습니다.\u003C\u002Fp>\n\u003Cp>세마포어는 작업을 완전히 직렬화하지 않으면서 동시 작업 수를 제한해야 할 때 적합한 프리미티브입니다. 뮤텍스(정확히 하나만 허용)와 달리 세마포어는 N개의 동시 접근자를 허용합니다. 이는 다음과 같은 용도에 적합합니다:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>데이터베이스 쓰기 동시성\u003C\u002Fstrong>: 커넥션 풀 크기로 제한 (예: 20개 동시 쓰기)\u003C\u002Fli>\n\u003Cli>\u003Cstrong>RPC 속도 제한\u003C\u002Fstrong>: 프로바이더로부터의 429 응답을 피하기 위해 발신 요청 제한\u003C\u002Fli>\n\u003Cli>\u003Cstrong>메모리 백프레셔\u003C\u002Fstrong>: 사용 가능한 퍼밋으로 게이트하여 무제한 태스크 생성 방지\u003C\u002Fli>\n\u003Cli>\u003Cstrong>배치 처리\u003C\u002Fstrong>: 공유 노드에 대해 병렬 실행되는 시뮬레이션 배치 수 제어\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 id=\"tokio-sync-semaphore\">tokio::sync::Semaphore 기초\u003C\u002Fh2>\n\u003Cp>\u003Ccode>tokio::sync::Semaphore\u003C\u002Fcode>는 비동기 코드를 위한 카운팅 세마포어입니다. 사용 가능한 퍼밋의 내부 카운터를 유지합니다. 태스크는 진행하기 전에 퍼밋을 획득하고 완료 시 해제합니다.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use std::sync::Arc;\nuse tokio::sync::Semaphore;\n\n\u002F\u002F 최대 20개의 동시 데이터베이스 쓰기 허용\nlet semaphore = Arc::new(Semaphore::new(20));\n\nfor chain in chains_to_persist {\n    let sem = semaphore.clone();\n    let db_pool = db_pool.clone();\n\n    tokio::spawn(async move {\n        \u002F\u002F 퍼밋 획득 — 20개 모두 사용 중이면 대기\n        let _permit = sem.acquire().await.unwrap();\n\n        \u002F\u002F 쓰기 수행 — 이 스코프 동안 퍼밋 보유\n        sqlx::query(\"UPDATE chains SET profit = $1 WHERE id = $2\")\n            .bind(chain.profit)\n            .bind(chain.id)\n            .execute(&amp;db_pool)\n            .await\n            .ok();\n\n        \u002F\u002F _permit이 여기서 드롭 — 자동으로 해제\n    });\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>주요 API:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Ccode>Semaphore::new(permits)\u003C\u002Fcode> — N개의 퍼밋으로 생성\u003C\u002Fli>\n\u003Cli>\u003Ccode>acquire(&amp;self)\u003C\u002Fcode> — 비동기, 퍼밋 사용 가능할 때까지 대기, \u003Ccode>SemaphorePermit\u003C\u002Fcode> (RAII 가드) 반환\u003C\u002Fli>\n\u003Cli>\u003Ccode>try_acquire(&amp;self)\u003C\u002Fcode> — 비동기 아님, 퍼밋이 없으면 즉시 \u003Ccode>Err(TryAcquireError)\u003C\u002Fcode> 반환\u003C\u002Fli>\n\u003Cli>\u003Ccode>acquire_owned(self: Arc&lt;Self&gt;)\u003C\u002Fcode> — \u003Ccode>Arc\u003C\u002Fcode>를 소유하는 \u003Ccode>OwnedSemaphorePermit\u003C\u002Fcode> 반환, 퍼밋이 빌림보다 오래 지속되어야 할 때 유용\u003C\u002Fli>\n\u003Cli>\u003Ccode>add_permits(&amp;self, n)\u003C\u002Fcode> — 동적으로 퍼밋 수 증가\u003C\u002Fli>\n\u003Cli>\u003Ccode>close(&amp;self)\u003C\u002Fcode> — 세마포어 닫기; 모든 대기 중인 \u003Ccode>acquire\u003C\u002Fcode> 호출이 \u003Ccode>Err(AcquireError)\u003C\u002Fcode> 반환\u003C\u002Fli>\n\u003Cli>\u003Ccode>available_permits(&amp;self)\u003C\u002Fcode> — 현재 카운트 확인 (메트릭에 유용)\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>핵심 설계 선택: \u003Ccode>acquire()\u003C\u002Fcode>는 RAII 가드를 반환합니다. 가드가 드롭되면 퍼밋이 해제됩니다. 이는 패닉, 조기 반환, \u003Ccode>?\u003C\u002Fcode> 연산자 중단 시에도 가드가 스택에 있는 한 자동 정리를 보장합니다.\u003C\u002Fp>\n\u003Ch2 id=\"fire-and-forget\">Fire-and-Forget 쓰기 패턴\u003C\u002Fh2>\n\u003Cp>고처리량 시스템에서는 핫 패스를 차단하지 않고 데이터를 영속화하고 싶을 때가 많습니다. 패턴은 다음과 같습니다: 백그라운드 태스크를 생성하여 세마포어 퍼밋을 획득하고, 쓰기를 수행한 다음 해제합니다. 호출자는 결과를 기다리지 않습니다.\u003C\u002Fp>\n\u003Cp>잘 설계된 시스템에서는 내부 스토리지 서비스를 래핑하는 데코레이터로 구현됩니다:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">pub struct AsyncChainStore&lt;S: ChainStore&gt; {\n    inner: Arc&lt;S&gt;,\n    semaphore: Arc&lt;Semaphore&gt;,\n}\n\nimpl&lt;S: ChainStore + Send + Sync + 'static&gt; AsyncChainStore&lt;S&gt; {\n    pub fn save_profits_async(&amp;self, chains: Vec&lt;ChainProfit&gt;) {\n        let inner = self.inner.clone();\n        let sem = self.semaphore.clone();\n\n        tokio::spawn(async move {\n            let _permit = match sem.acquire().await {\n                Ok(p) =&gt; p,\n                Err(_) =&gt; {\n                    tracing::warn!(\"세마포어 닫힘, 쓰기 드롭\");\n                    return;\n                }\n            };\n\n            if let Err(e) = inner.batch_update_profits(&amp;chains).await {\n                tracing::error!(\n                    error = %e,\n                    count = chains.len(),\n                    \"fire-and-forget 쓰기 실패\"\n                );\n            }\n        });\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>이 데코레이터 패턴은 내부 \u003Ccode>ChainStore\u003C\u002Fcode>를 순수하게 유지합니다 — 동시성 제어에 대해 전혀 알 필요가 없습니다. 데코레이터가 세마포어 관리, 에러 로깅, 태스크 생성을 처리합니다.\u003C\u002Fp>\n\u003Cp>왜 데이터베이스 풀의 내장 커넥션 제한을 사용하지 않을까요? 세마포어가 별도의 조절 장치를 제공하기 때문입니다. 풀에 50개의 커넥션이 있더라도 fire-and-forget 쓰기에는 최대 20개를 사용하고 나머지 30개는 지연 시간에 민감한 읽기를 위해 예약할 수 있습니다.\u003C\u002Fp>\n\u003Ch2 id=\"\">데드락 시나리오\u003C\u002Fh2>\n\u003Cp>세마포어 데드락은 교묘합니다. 패닉이나 에러가 발생하지 않고 프로그램이 단순히 진행을 멈춥니다. 프로덕션에서 겪은 패턴들을 소개합니다.\u003C\u002Fp>\n\u003Ch3>시나리오 1: 조기 반환 시 퍼밋 미해제\u003C\u002Fh3>\n\u003Cp>RAII 가드는 대부분의 조기 반환으로부터 보호합니다. 위험한 것은 퍼밋을 예상 수명을 초과하는 구조체로 이동하는 경우입니다 — \u003Ccode>HashMap\u003C\u002Fcode>에 저장, 채널을 통해 전송, \u003Ccode>static\u003C\u002Fcode>에 보유하는 경우.\u003C\u002Fp>\n\u003Ch3>시나리오 2: 중첩된 Acquire (자기 데드락)\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-rust\">async fn simulate_and_persist(\n    sem: &amp;Semaphore,\n    chain: &amp;Chain,\n) -&gt; Result&lt;()&gt; {\n    let _outer = sem.acquire().await?; \u002F\u002F N개 퍼밋 중 1개 사용\n\n    let result = simulate(chain).await?;\n\n    \u002F\u002F 버그: 보유 중인 퍼밋 안에서 동일한 세마포어 획득\n    let _inner = sem.acquire().await?; \u002F\u002F N=1이면 데드락\n    persist(result).await?;\n\n    Ok(())\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>N=1\u003C\u002Fcode>이면 즉시 데드락입니다. \u003Ccode>N&gt;1\u003C\u002Fcode>이면 부하가 증가하여 모든 퍼밋이 내부 acquire를 기다리는 태스크에 의해 보유될 때까지 작동합니다. 해결책: 별도의 관심사에 대해 별도의 세마포어를 사용하거나, 중첩 획득을 피하도록 구조를 변경합니다.\u003C\u002Fp>\n\u003Ch3>시나리오 3: Select 내 await 포인트에서 퍼밋 보유\u003C\u002Fh3>\n\u003Cp>\u003Ccode>select!\u003C\u002Fcode> 매크로는 패배한 브랜치의 future를 드롭하여 취소하지만, 해당 future 내에서 생성된 태스크는 계속 실행됩니다. 해당 태스크가 퍼밋에 대한 참조를 캡처했다면 퍼밋이 예상대로 해제되지 않습니다.\u003C\u002Fp>\n\u003Ch2 id=\"\">세마포어 데드락 진단\u003C\u002Fh2>\n\u003Cp>시스템이 진행을 멈출 때, 세마포어 데드락인지 느린 의존성인지 어떻게 식별할까요?\u003C\u002Fp>\n\u003Ch3>트레이싱 기반 진단\u003C\u002Fh3>\n\u003Cp>acquire\u002Frelease를 구조화된 로깅으로 계측합니다:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">let permits_before = semaphore.available_permits();\ntracing::debug!(\n    available = permits_before,\n    \"세마포어 퍼밋 획득 중\"\n);\n\nlet _permit = semaphore.acquire().await?;\n\ntracing::debug!(\n    available = semaphore.available_permits(),\n    \"세마포어 퍼밋 획득 완료\"\n);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>로그에 “획득 중“은 나오지만 “획득 완료“가 나오지 않으면 모든 퍼밋이 어딘가에 보유되어 있는 것입니다. 마지막 N개의 “획득 완료” 로그 항목과 상관관계를 분석하여 누가 보유하고 있는지 찾습니다.\u003C\u002Fp>\n\u003Ch3>Prometheus 메트릭\u003C\u002Fh3>\n\u003Cp>사용 가능한 퍼밋에 대한 게이지 메트릭을 노출합니다:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use metrics::gauge;\n\ngauge!(\"semaphore_available_permits\", semaphore.available_permits() as f64);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>0으로 떨어져 그대로 유지되는 게이지는 데드락입니다. 0 근처에서 변동하는 게이지는 경합(높은 부하, 더 많은 퍼밋 필요 가능성)입니다.\u003C\u002Fp>\n\u003Ch3>tokio-console\u003C\u002Fh3>\n\u003Cp>\u003Ccode>tokio-console\u003C\u002Fcode>은 실행 중인 tokio 애플리케이션에 연결하여 실시간으로 태스크 상태를 보여주는 진단 도구입니다:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-bash\">cargo add --dev console-subscriber\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cpre>\u003Ccode class=\"language-rust\">#[cfg(debug_assertions)]\nconsole_subscriber::init();\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>다른 터미널에서 \u003Ccode>tokio-console\u003C\u002Fcode>을 실행하고 세마포어 acquire에서 “Idle” 상태로 멈춘 태스크를 찾습니다.\u003C\u002Fp>\n\u003Ch2 id=\"\">프로덕션 강화 솔루션\u003C\u002Fh2>\n\u003Ch3>솔루션 1: OwnedSemaphorePermit과 Arc 사용\u003C\u002Fh3>\n\u003Cp>태스크를 생성할 때 \u003Ccode>acquire()\u003C\u002Fcode> 대신 \u003Ccode>acquire_owned()\u003C\u002Fcode>를 사용합니다. 소유 변형은 \u003Ccode>Arc&lt;Semaphore&gt;\u003C\u002Fcode>를 받아 세마포어를 빌리지 않는 퍼밋을 반환합니다:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">let semaphore = Arc::new(Semaphore::new(20));\n\nfor item in items {\n    let permit = semaphore.clone().acquire_owned().await?;\n\n    tokio::spawn(async move {\n        do_work(item).await;\n        drop(permit); \u002F\u002F 명시적 드롭 또는 스코프가 처리\n    });\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>솔루션 2: 타임아웃 포함 Acquire\u003C\u002Fh3>\n\u003Cp>프로덕션에서 퍼밋을 무한정 기다리지 마십시오. \u003Ccode>tokio::time::timeout\u003C\u002Fcode>을 사용하여 데드락을 조기에 감지합니다:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use tokio::time::{timeout, Duration};\n\nlet permit = timeout(\n    Duration::from_secs(30),\n    semaphore.acquire(),\n).await\n    .map_err(|_| anyhow!(\"세마포어 acquire 타임아웃 — 데드락 가능성\"))?\n    .map_err(|_| anyhow!(\"세마포어 닫힘\"))?;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>타임아웃이 발생하면 사용 가능한 퍼밋 수, 대기 중인 태스크 수, 스택 트레이스를 로깅합니다. 이를 통해 프로세스를 중단하지 않고도 데드락에 대한 즉각적인 가시성을 확보할 수 있습니다.\u003C\u002Fp>\n\u003Ch3>솔루션 3: JoinSet을 활용한 구조화된 동시성\u003C\u002Fh3>\n\u003Cp>무제한 \u003Ccode>tokio::spawn\u003C\u002Fcode> 대신 \u003Ccode>tokio::task::JoinSet\u003C\u002Fcode>을 사용하여 생성된 태스크의 소유권을 유지하고 세마포어와 결합합니다:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use tokio::task::JoinSet;\n\nlet semaphore = Arc::new(Semaphore::new(20));\nlet mut join_set = JoinSet::new();\n\nfor batch in batches {\n    let permit = semaphore.clone().acquire_owned().await?;\n    let db_pool = db_pool.clone();\n\n    join_set.spawn(async move {\n        let result = process_batch(&amp;db_pool, &amp;batch).await;\n        drop(permit);\n        result\n    });\n}\n\nwhile let Some(result) = join_set.join_next().await {\n    match result {\n        Ok(Ok(())) =&gt; {}\n        Ok(Err(e)) =&gt; tracing::error!(error = %e, \"배치 처리 실패\"),\n        Err(e) =&gt; tracing::error!(error = %e, \"태스크 패닉\"),\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>이 패턴은 다음을 보장합니다: (1) 세마포어를 통한 제한된 동시성, (2) 무음 태스크 실패 없음, (3) 부모 태스크가 모든 자식 완료를 인지, (4) 태스크가 패닉해도 퍼밋은 항상 해제.\u003C\u002Fp>\n\u003Ch3>솔루션 4: 관심사별 별도 세마포어\u003C\u002Fh3>\n\u003Cp>관련 없는 작업 간에 단일 세마포어를 공유하지 마십시오:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">pub struct ConcurrencyLimits {\n    \u002F\u002F\u002F 노드에 대한 동시 RPC 시뮬레이션 호출 제한\n    pub simulation: Arc&lt;Semaphore&gt;,\n    \u002F\u002F\u002F 체인 영속화를 위한 동시 데이터베이스 쓰기 제한\n    pub db_writes: Arc&lt;Semaphore&gt;,\n    \u002F\u002F\u002F 동시 멤풀 구독 핸들러 제한\n    pub mempool: Arc&lt;Semaphore&gt;,\n}\n\nimpl ConcurrencyLimits {\n    pub fn new() -&gt; Self {\n        Self {\n            simulation: Arc::new(Semaphore::new(4)),\n            db_writes: Arc::new(Semaphore::new(20)),\n            mempool: Arc::new(Semaphore::new(100)),\n        }\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>이를 통해 중첩된 acquire 데드락을 완전히 제거합니다 — 태스크가 \u003Ccode>simulation\u003C\u002Fcode> 퍼밋을 획득한 후 \u003Ccode>db_writes\u003C\u002Fcode> 퍼밋을 획득하며, 이들은 독립적인 풀입니다.\u003C\u002Fp>\n\u003Ch2 id=\"\">성능: 세마포어 오버헤드\u003C\u002Fh2>\n\u003Cp>세마포어 자체가 병목이 될까요? 실제로는 아닙니다. \u003Ccode>tokio::sync::Semaphore\u003C\u002Fcode>는 원자적 카운터와 침입형 연결 리스트로 구현됩니다. 비경합 퍼밋 획득은 단일 \u003Ccode>fetch_sub\u003C\u002Fcode> — 나노초입니다. 경합 상태에서도 오버헤드는 웨이커 알림(역시 나노초)이며, 실제 I\u002FO가 걸리는 밀리초와 비교됩니다.\u003C\u002Fp>\n\u003Cp>우리 파이프라인에서 세마포어 오버헤드를 벤치마킹했습니다:\u003C\u002Fp>\n\u003Ctable>\u003Cthead>\u003Ctr>\u003Cth>작업\u003C\u002Fth>\u003Cth>세마포어 없음\u003C\u002Fth>\u003Cth>세마포어 사용 (20 퍼밋)\u003C\u002Fth>\u003C\u002Ftr>\u003C\u002Fthead>\u003Ctbody>\n\u003Ctr>\u003Ctd>10,000 DB 쓰기\u003C\u002Ftd>\u003Ctd>1,340ms (전체 동시, 풀 고갈 에러)\u003C\u002Ftd>\u003Ctd>1,580ms (제어됨, 에러 제로)\u003C\u002Ftd>\u003C\u002Ftr>\n\u003Ctr>\u003Ctd>500 RPC 시뮬레이션\u003C\u002Ftd>\u003Ctd>8,200ms (노드 과부하, 타임아웃)\u003C\u002Ftd>\u003Ctd>9,100ms (4개 동시, 타임아웃 제로)\u003C\u002Ftd>\u003C\u002Ftr>\n\u003Ctr>\u003Ctd>메모리 (270만 태스크)\u003C\u002Ftd>\u003Ctd>15.4 GB\u003C\u002Ftd>\u003Ctd>0.8 GB (JoinSet 배치)\u003C\u002Ftd>\u003C\u002Ftr>\n\u003C\u002Ftbody>\u003C\u002Ftable>\n\u003Cp>10-15%의 벽시계 오버헤드는 에러, 타임아웃, OOM 크래시 제거와 비교하면 무시할 수 있는 수준입니다.\u003C\u002Fp>\n\u003Ch2 id=\"\">결론\u003C\u002Fh2>\n\u003Cp>Async Rust에서의 세마포어는 겉보기에 단순합니다 — \u003Ccode>acquire\u003C\u002Fcode>, 작업 수행, 퍼밋 드롭. 복잡성은 프로덕션에서 나타납니다: 장기 보유 구조체에 누출된 퍼밋, 콜 스택 간 중첩된 acquire, \u003Ccode>select!\u003C\u002Fcode> 취소 경계에서 보유된 퍼밋.\u003C\u002Fp>\n\u003Cp>방어적 플레이북:\u003C\u002Fp>\n\u003Col>\n\u003Cli>태스크 생성 시 \u003Cstrong>항상\u003C\u002Fstrong> \u003Ccode>acquire_owned()\u003C\u002Fcode> 사용\u003C\u002Fli>\n\u003Cli>acquire에 \u003Cstrong>항상\u003C\u002Fstrong> 타임아웃 래핑\u003C\u002Fli>\n\u003Cli>동일 세마포어에서 acquire를 \u003Cstrong>절대\u003C\u002Fstrong> 중첩하지 않기\u003C\u002Fli>\n\u003Cli>별도의 리소스 풀에 대해 세마포어 \u003Cstrong>분리\u003C\u002Fstrong>\u003C\u002Fli>\n\u003Cli>메트릭과 트레이싱으로 사용 가능한 퍼밋 \u003Cstrong>계측\u003C\u002Fstrong>\u003C\u002Fli>\n\u003Cli>무제한 \u003Ccode>tokio::spawn\u003C\u002Fcode> 대신 \u003Cstrong>JoinSet 사용\u003C\u002Fstrong>으로 구조화된 동시성 유지\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>이 패턴들은 수백만 블록 처리, 수십만 동시 태스크, 그리고 채택 이후 프로덕션에서 데드락 제로를 달성하며 검증되었습니다.\u003C\u002Fp>\n","ko","b0000000-0000-0000-0000-000000000001",true,"2026-03-28T10:44:28.475234Z","Async Rust에서의 세마포어 — 데드락 추적과 Fire-and-Forget 패턴","tokio::sync::Semaphore를 활용한 백프레셔, fire-and-forget 쓰기, 데드락 진단, RAII 퍼밋과 구조화된 동시성을 통한 프로덕션 솔루션 심층 분석.","rust 세마포어 async",null,"index, follow",[22,27],{"id":23,"name":24,"slug":25,"created_at":26},"c0000000-0000-0000-0000-000000000008","AI","ai","2026-03-28T10:44:21.513630Z",{"id":28,"name":29,"slug":30,"created_at":26},"c0000000-0000-0000-0000-000000000001","Rust","rust","엔지니어링",[33,39,45],{"id":34,"title":35,"slug":36,"excerpt":37,"locale":12,"category_name":31,"published_at":38},"d0000000-0000-0000-0000-000000000674","2026년, Bali가 동남아시아의 임팩트 테크 허브가 되고 있는 이유","bali-2026-dongnamasia-impaekteu-tekeu-heobeu-iyu","Bali는 동남아시아 스타트업 생태계에서 16위를 차지하고 있습니다. Web3 빌더, AI 지속가능성 스타트업, 에코 여행 테크 기업이 집중되면서, 이 섬은 지역 임팩트 테크의 수도로 자리매김하고 있습니다.","2026-03-28T10:44:49.294484Z",{"id":40,"title":41,"slug":42,"excerpt":43,"locale":12,"category_name":31,"published_at":44},"d0000000-0000-0000-0000-000000000673","ASEAN 데이터 보호 패치워크: 개발자를 위한 컴플라이언스 체크리스트","asean-deiteo-boho-paechiwokeu-gaebaljaleul-wihan-keompeullaieonseuchekeuriseuteu","7개 ASEAN 국가가 포괄적인 데이터 보호법을 시행하고 있으며, 각각 다른 동의 모델, 현지화 요건, 벌칙 구조를 가지고 있습니다. 다중 국가 애플리케이션을 구축하는 개발자를 위한 실용적인 컴플라이언스 체크리스트입니다.","2026-03-28T10:44:49.286400Z",{"id":46,"title":47,"slug":48,"excerpt":49,"locale":12,"category_name":31,"published_at":50},"d0000000-0000-0000-0000-000000000672","Indonesia 290억 달러 디지털 전환: 소프트웨어 기업을 위한 기회","indonesia-290eok-dallleo-dijiteol-jeonhwan-sopeuteuweo-gieopui-gihoe","Indonesia IT 서비스 시장은 2026년 290.3억 달러에 달할 것으로 예상되며, 이는 2025년 243.7억 달러에서 증가한 수치입니다. 클라우드 인프라, AI, 전자상거래, 데이터센터가 동남아시아에서 가장 빠른 성장을 주도하고 있습니다.","2026-03-28T10:44:49.265609Z",{"id":13,"name":52,"slug":53,"bio":54,"photo_url":19,"linkedin":19,"role":55,"created_at":56,"updated_at":56},"Open Soft Team","open-soft-team","The engineering team at Open Soft, building premium software solutions from Bali, Indonesia.","Engineering Team","2026-03-28T08:31:22.226811Z"]