Rust에서 컴파일 타임 리소스 추적: 런타임 브래킷에서 타입 레벨 안전성으로

발행: (2025년 12월 21일 오전 07:50 GMT+9)
15 min read
원문: Dev.to

Source: Dev.to
Originally published on Entropic Drift

문제: 런타임 리소스 누수

리소스 누수는 교묘합니다. 잊혀진 close(), 누락된 commit(), 정리 작업을 건너뛰는 예외 경로. 코드는 컴파일됩니다. 실행됩니다. 연결 풀이 고갈되거나, 파일 핸들이 부족해지거나, 트랜잭션이 영원히 락을 잡고 있을 때까지는 정상적으로 동작합니다.

전통적인 Rust 접근 방식은 RAII를 사용합니다: Drop에서 정리하는 구조체에 리소스를 감싸는 것이죠. 이는 소유권 기반 패턴에 잘 맞지만, 다음과 같은 경우에는 한계가 있습니다:

  • 리소스가 async 경계를 통해 전달될 때.
  • 효과를 조합하고 체인해야 할 때.
  • 프로토콜을 표현하고 싶을 때 (예: begin → query → commit/rollback).
  • 정리 로직 자체가 실패할 수 있고, 그 처리가 필요할 때.

타입 시스템이 모든 획득된 리소스가 코드를 실행하기 전에 반드시 해제된다는 것을 강제한다면 어떨까요?

The Foundation: Runtime Brackets

Stillwater는 초기 버전부터 bracket 패턴을 통해 런타임 리소스 안전성을 제공해 왔습니다.
bracket 함수는 오류가 발생하더라도 정리 작업이 실행된다는 것을 보장합니다:

use stillwater::effect::prelude::*;

let result = bracket(
    open_connection(),                          // Acquire
    |conn| async move { conn.close().await },   // Release (always runs)
    |conn| fetch_data(conn),                    // Use
)
.run(&env)
.await;

정리 함수는 항상 실행되며, 사용 단계가 성공하든, 실패하든, 패닉이 발생하든 관계없이 동작합니다.

Bracket Variants

VariantDescription
bracket기본 acquire → use → release 흐름이며 정리 작업이 보장됩니다
bracket2, bracket3여러 리소스를 관리합니다; 정리는 LIFO 순서로 수행됩니다
bracket_fullBracketError를 반환하며, 사용 단계와 정리 단계 모두에 대한 명시적인 오류 정보를 포함합니다
acquiring여러 리소스를 함께 구성하기 위한 유창한 빌더입니다

Fluent Builder Example

// Multiple resources with the fluent builder
let result = acquiring(open_conn(), |c| async move { c.close().await })
    .and(open_file(path), |f| async move { f.close().await })
    .with_flat2(|conn, file| process(conn, file))
    .run(&env)
    .await;

코드는 정상적으로 동작하고, 정리 작업이 자동으로 수행됩니다.
하지만 이 안전성은 런타임에 강제되므로, 프로그램을 실행해 보기 전까지는 브래킷이 올바르게 균형을 이루는지 알 수 없습니다.

Stillwater 0.14.0 – 타입‑레벨 리소스 추적

Stillwater 0.14.0은 런타임 브래킷 기반 위에 컴파일‑타임 리소스 추적을 추가합니다. 이제 컴파일러가 코드가 실행되기 전에 리소스가 균형을 이루는지 증명할 수 있습니다.

use stillwater::effect::resource::*;
use stillwater::pure;

// The TYPE says: this acquires a `FileRes`
fn open_file(path: &str) -> impl ResourceEffect<Acquires = FileRes, Releases = Empty> {
    pure::(format!("handle:{}", path)).acquires::()
}

// The TYPE says: this releases a `FileRes`
fn close_file(handle: String) -> impl ResourceEffect {
    pure::(()).releases::()
}

ResourceEffect 트레이트는 Effect를 두 개의 연관 타입으로 확장합니다:

  • Acquires – 이 효과가 생성하는 리소스.
  • Releases – 이 효과가 소비하는 리소스.

이 연관 타입들은 컴파일러가 검증할 수 있는 문서 역할을 합니다.

브래킷 패턴: 보장된 리소스 중립성

실제 힘은 resource_bracket에서 나옵니다. 이는 연산이 리소스를 획득하고, 사용하며, 해제하도록 강제합니다:

fn read_file_safely(path: &str) -> impl ResourceEffect {
    bracket::()
        .acquire(open_file(path))
        .release(|handle| async move { close_file(handle).run(&()).await })
        .use_fn(|handle| read_contents(handle))
}
  • bracket::()는 리소스 타입을 한 번 캡처하고, 이후 체인된 메서드 호출들로부터 나머지를 추론합니다.
  • 반환 타입은 Acquires = Empty, Releases = Empty를 보여주며, 이는 함수가 리소스 중립임을 의미합니다.
  • 브래킷이 맞지 않을 경우(예: 획득이 해제와 일치하지 않을 때), 코드는 컴파일에 실패합니다.

프로토콜 강제: 데이터베이스 트랜잭션

데이터베이스 트랜잭션을 고려해 보세요. 트랜잭션은 열리고, 사용된 뒤에 커밋하거나 롤백해야 합니다. 마지막 단계를 놓치는 것은 버그가 됩니다. 이를 컴파일‑타임 오류로 만들겠습니다:

fn begin_tx() -> impl ResourceEffect {
    pure::("tx_12345".to_string()).acquires::()
}

fn commit(tx: String) -> impl ResourceEffect {
    pure::(()).releases::()
}

fn rollback(tx: String) -> impl ResourceEffect {
    pure::(()).releases::()
}

fn execute_query(tx: &str, query: &str) -> impl ResourceEffect {
    // 쿼리는 리소스에 중립적입니다
    pure::(vec!["row1".to_string()]).neutral()
}

이제 닫히지 않은 트랜잭션 연산은 타입 오류가 됩니다:

// 이 함수 시그니처는 리소스 중립성을 약속합니다
fn transfer_funds() -> impl ResourceEffect {
    bracket::()
        .acquire(begin_tx())
        .release(|tx| async move { commit(tx).run(&()).await })
        .use_fn(|tx| {
            execute_query(tx, "UPDATE accounts SET balance = balance - 100 WHERE id = 1");
            execute_query(tx, "UPDATE accounts SET balance = balance + 100 WHERE id = 2");
            pure::("transferred".to_string())
        })
}

올바른 트랜잭션 종료 강제

타입 시그니처는 트랜잭션이 올바르게 종료되도록 강제합니다. 매칭되는 release 없이 begin_tx()를 반환하려고 하면 코드는 컴파일되지 않습니다.

여러 리소스 추적

실제 시스템은 여러 종류의 리소스를 동시에 다룹니다. 추적은 다음과 같이 구성됩니다:

// Acquire both a file and a database connection
let effect = pure::(42)
    .acquires::()
    .also_acquires::();

// Release both
let cleanup = pure::(())
    .releases::()
    .also_releases::();

타입 시스템은 Has<…>를 타입 수준 집합으로 추적합니다. 합집합 연산은 체인된 이펙트에서 집합을 결합합니다.

Compile‑Time Assertions

For critical code paths, assert resource neutrality explicitly:

fn safe_operation() -> impl ResourceEffect {
    let effect = bracket::()
        .acquire(open_file("data.txt"))
        .release(|h| async move { close_file(h).run(&()).await })
        .use_fn(|h| read_contents(h));

    // This is a compile‑time check, not a runtime assert
    assert_resource_neutral(effect)
}

참고: effect가 실제로 리소스 중립적이지 않다면, 이는 컴파일 타임에 실패합니다.
어설션은 순수히 타입 수준에서 작동하기 때문에 런타임 비용이 전혀 발생하지 않습니다.

사용자 정의 리소스 종류

도메인별 추적을 위해 자체 리소스 마커를 정의하세요:

struct ConnectionPoolRes;

impl ResourceKind for ConnectionPoolRes {
    const NAME: &'static str = "ConnectionPool";
}

fn acquire_connection() -> impl ResourceEffect {
    pure::("conn_42".to_string()).acquires()
}

fn release_connection(conn: String) -> impl ResourceEffect {
    pure::(()).releases()
}

내장 마커(FileRes, DbRes, LockRes, TxRes, SocketRes)는 일반적인 경우를 다루지만, 이에 제한되지 않습니다.

런타임 오버헤드 제로

이것이 핵심 포인트입니다: 모든 추적은 컴파일 타임에 이루어집니다. 구현은 다음을 사용합니다:

  • PhantomData를 타입‑레벨 주석에 사용 (크기 0)
  • 연관 타입을 사용해 리소스‑셋 추적 (컴파일 타임에 계산)

Tracked 래퍼는 내부 이펙트에 직접 위임합니다:

use std::marker::PhantomData;

pub struct Tracked<Eff> {
    inner: Eff,
    _phantom: PhantomData<()>, // Zero bytes
}

impl<Eff> Effect for Tracked<Eff>
where
    Eff: Effect,
{
    type Env = Eff::Env;
    type Output = Eff::Output;
    type Error = Eff::Error;

    async fn run(self, env: &Self::Env) -> Result<Self::Output, Self::Error> {
        self.inner.run(env).await // Just delegates
    }
}

런타임 검사, 할당, 간접 호출이 전혀 없습니다. 추적은 순전히 타입 체커를 위한 것입니다.

비교: RAII vs Bracket vs 타입‑레벨 추적

방법누수 감지비동기 안전프로토콜 강제런타임 비용
RAII (Drop)런타임제한적없음최소
Stillwater bracket런타임없음최소
Stillwater bracket::()컴파일 타임0
  • RAII는 리소스를 직접 소유하고 있을 때 작동합니다.
  • bracket() (런타임)은 정리 코드가 항상 실행되도록 보장합니다—단순한 획득/사용/해제 패턴에 이상적입니다.
  • bracket::() (타입‑레벨)은 더 나아가 획득‑사용‑해제 프로토콜을 타입 시그니처에 인코딩하고 컴파일 타임에 검증합니다.

함께 사용하는 방법

  • 런타임 브래킷을 사용하여 비동기 컨텍스트에서 정리를 보장합니다.
  • 타입‑레벨 추적을 추가하여 더 복잡한 프로토콜을 컴파일 타임에 검증받습니다.

이 조합은 누수 방지와 프로토콜 정확성에 대한 확신을 동시에 제공합니다.

언제 사용해야 할까

타입‑레벨 리소스 추적은 다음과 같은 경우에 빛을 발합니다:

  • 리소스 누수가 고심각도 버그인 경우 (예: 연결 풀, 파일 시스템, 크리티컬 섹션)
  • 프로토콜을 반드시 따라야 할 때 (예: begin → work → commit/rollback)
  • 효과가 함수 경계에서 구성될 때
  • 거짓말할 수 없는 문서를 원한다 – 타입은 항상 최신이다

단순하고 단일 소유자 리소스의 경우, RAII가 여전히 올바른 선택입니다. 리소스 안전이 중요한 복잡한 효과 파이프라인에서는 타입‑레벨 추적이 런타임 검사로는 놓칠 수 있는 버그를 잡아냅니다.

시작하기

Cargo.toml에 Stillwater 0.14.0을 추가합니다:

[dependencies]
stillwater = "0.14"

리소스 추적 모듈을 가져옵니다:

use stillwater::effect::resource::*;
use stillwater::pure;

// Start annotating your effects
fn my_acquire() -> impl ResourceEffect {
    pure::("handle".to_string()).acquires()
}

기존 Effect API는 그대로 동작합니다. 리소스 추적은 순수하게 추가적인 기능이며, 선택적으로 사용할 수 있습니다.

요약

Stillwater의 리소스‑관리 이야기는 이제 두 개의 보완적인 레이어를 가지고 있습니다:

레이어목적
런타임 브라켓 (bracket, bracket2, acquiring)오류나 패닉이 발생해도 정리 작업이 항상 실행되도록 보장합니다
컴파일 타임 추적 (bracket::() builder, ResourceEffect)코드가 실행되기 전에 리소스 프로토콜이 균형을 이루는지 증명합니다

함께 사용하면 다층 방어를 제공합니다:

  • 런타임 브라켓은 프로덕션 환경에서 정리 작업이 이루어지도록 보장합니다.
  • 타입 레벨 추적은 개발 중에 프로토콜 위반을 감지합니다.
  • 빌더 패턴(단일 타입 매개변수)을 통한 인체공학적 API.
  • PhantomData를 사용한 런타임 오버헤드 제로.
  • 효과 체인 및 함수 경계 전반에 걸친 조합 가능.

리소스는 너무 중요해서 런타임 우연에 맡길 수 없습니다. 보장된 정리를 위해 브라켓부터 시작하고, 프로토콜이 중요한 경우 타입 레벨 추적을 추가하세요.

Stillwater 소개

Stillwater는 검증, 효과 조합, 함수형 프로그래밍 패턴을 위한 Rust 라이브러리입니다. 버전 0.14.0은 기존 런타임 브라켓 패턴 위에 타입 레벨 레이어로 컴파일 타임 리소스 추적을 추가합니다.

팔로우 및 탐색

이와 같은 콘텐츠를 더 원하시나요?

  • Dev.to에서 팔로우하세요
  • AI 기반 개발 워크플로, Rust 툴링, 기술 부채 관리에 관한 글을 보려면 Entropic Drift를 구독하세요.

오픈소스 프로젝트:

  • Debtmap – 기술 부채 분석기
  • Prodigy – AI 워크플로 오케스트레이션
Back to Blog

관련 글

더 보기 »

Rust가 'pub'을 잘못 이해했다

!Rust의 표지 이미지가 ‘pub’를 잘못 표시했습니다 https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s...