Skip to content
Code Card

비동기 Scope Guard 기반 결정적 Cleanup 호출과 자원 해제

비동기 프로그램은 연결, 작업 슬롯, 임시 업로드 세션처럼 사용 후 반환해야 하는 자원을 자주 다룬다. 문제는 획득과 해제를 각각 일반 함수 호출로 작성하면 return, 예외, 취소 경로 중 하나에서 해제 호출을 빠뜨리기 쉽다는 점이다. Async Scope Guard는 자원의 생명주기를 렉시컬 스코프(lexical scope)에 결합한다.

Jeong Dongwoo · 연습 메모 · 49 min read · Medium

비동기 프로그램은 연결, 작업 슬롯, 임시 업로드 세션처럼 사용 후 반환해야 하는 자원을 자주 다룬다.
문제는 획득과 해제를 각각 일반 함수 호출로 작성하면 return, 예외, 취소 경로 중 하나에서 해제 호출을 빠뜨리기 쉽다는 점이다.

Async Scope Guard는 자원의 생명주기를 렉시컬 스코프(lexical scope)에 결합한다.
C#의 await using, TypeScript의 await using, Python의 async with는 블록을 벗어날 때 비동기 해제 작업을 기다린다.
C++는 동기 RAII의 원형에 가깝지만 소멸자를 coroutine으로 만들 수 없으므로, 비동기 해제만큼은 coroutine 함수가 acquire -> use -> release 전체를 소유해야 한다. (사실 acquire->use->release 공식만 암기하면 이 문서 전체를 이해했다고 봐도 좋다)
C#의 await usingIAsyncDisposable을 사용하며, TypeScript는 [Symbol.asyncDispose]()를, Python은 __aenter__()__aexit__()를 호출한다.1234

참고로 이 메모 문서에서 결정적이라는 말은 정상적인 제어 흐름에서 cleanup 진입점을 호출하고 그 완료를 기다리는 범위를 뜻한다.
프로세스 강제 종료나 전원 차단을 막지는 않으며, 네트워크를 건너는 원격 release의 성공·정확히 한 번의 적용까지 보장하지는 않는다.

이 패턴의 목적은 try/finally를 없애는 것이 아니지만은, 언어가 반복되는 try/finally 구조를 자원 타입의 프로토콜로 캡슐화하도록 만드는 것이다. 호출자는 “언제 해제해야 하는가”를 매번 다시 구현하지 않고, 자원 구현자는 “어떻게 해제하는가”를 한 곳에서 관리하는 것이다.

비동기 해제가 필요한 이유는 반환 과정 자체가 I/O일 수 있기 때문이며, 원격 작업 슬롯 반환, transaction rollback, buffered stream flush는 완료를 기다려야 다음 작업이 안전하게 진행된다. .NET의 IAsyncDisposable.DisposeAsync()도 비동기 정리를 나타내는 ValueTask를 반환한다.

핵심 암기 공식:

Loading animated diagram...

여기서 한 번은 guard가 브로커(broker)의 클린업 진입점(cleanup entry point)을 중복 호출하지 않는다는 뜻이다. 네트워크 응답이 사라지면 원격 release가 적용됐는지 클라이언트는 알 수 없다.
broker는 동일한 leaseId로 재시도할 수 있는 멱등 release와 lease 만료를 제공해야 한다. exactly once라는 표현을 원격 효과에 그대로 붙이면 보장 범위를 넘는다.5


1. 문제 상황

렌더링 서버가 중앙 coordinator에서 제한된 GPU 작업 슬롯을 빌린다고 하자.

Text
RenderSlotBroker:
- acquireAsync()
- releaseAsync(token)

RenderSlotToken:
- slotId
- leaseId

작업 흐름은 다음과 같다.

Text
1. 렌더링 슬롯 획득
2. 렌더링 실행
3. 슬롯 반환

Loading animated diagram...

렌더링 중 다음 사건이 발생할 수 있다.

Text
- 정상 완료
- 입력 파일 해석 실패
- GPU 작업 실패
- 호출자 취소
- 메서드 중간 return

수동 해제 방식에서는 모든 경로가 다음 코드를 실행해야 한다.

Text
await broker.releaseAsync(token)

Loading animated diagram...

한 경로라도 빠지면 슬롯이 반환되지 않아 이후 작업이 대기하거나, coordinator가 잘못된 활성 슬롯 수를 유지할 수 있다.

원하는 API는 다음과 같다.

Text
await using / async with
→ 블록 안에서는 slotId 사용
→ 블록을 벗어나면 자동으로 releaseAsync 실행

이 카드의 단일 도메인은 비동기 lease의 획득과 해제를 lexical scope에 결합하는 것이다.


2. 핵심 표현

공통 계약:

Text
RenderSlotBroker
- acquire가 정상 반환하기 전에 응답 형식과 token을 검증
- 외부 응답을 언어 내부의 immutable/plain token 값으로 복사
- 정상 반환 시 token 소유권을 호출자에게 이전
- timeout처럼 결과가 불명확한 획득은 request id와 lease TTL로 회수

RenderSlotLease
- acquire: 비동기
- getSlotId: 활성 상태에서만 사용
- dispose/exit: 비동기
- 완료 뒤의 순차 중복 해제: no-op
- 동시 dispose caller: 이 예제의 지원 범위 밖
- 해제 시작 후 재사용: 금지

Loading animated diagram...

중요 지점은 원격 coordinator가 슬롯을 잡은 다음 잘못된 token을 응답했고, lease factory가 그 token을 검증하다 예외를 던지면 scope guard는 아직 만들어지지 않았다는 것이다.
보통 자주 하는 실수 중 하나가 factory가 ValidateToken()만 호출하면 슬롯이 새는데, 더구나 비어 있는 leaseId를 받았다면 클라이언트가 무엇을 release해야 하는지도 알 수가 없다.

따라서 예제의 broker는 정상 반환한 token은 이미 검증됐고 반환 가능한 token이다라는 계약을 가진다.
HTTP/gRPC 응답 파싱, token 검증, 획득 결과가 불명확할 때의 복구는 broker adapter가 맡는다. lease factory는 정상 반환 직후 wrapper를 만들며, 그 사이에 실패할 수 있는 작업을 넣지 않는것이 중요한 지점이다.

C++: RAII가 끝나는 지점

C++에서 파일, mutex, 메모리처럼 동기적으로 정리할 수 있는 자원은 RAII가 가장 자연스럽다. 지역 객체가 scope를 벗어나면 소멸자가 실행되므로 return과 예외 경로를 따로 세지 않아도 된다.

이 문서가 다루는 원격 lease는 사정이 다르다. 슬롯 반환이 네트워크 I/O라면 완료까지 기다리기 위해 co_await가 필요한데, C++ 소멸자는 coroutine이 될 수 없다.46

내가 처음 작성했을때 착각했던 코드고, 많은 C++ 입문자들이 착각했을 지점이다.

C++
class RenderSlotLease {
public:
    ~RenderSlotLease() {
        co_await broker_.release(token_); // 오류: 소멸자는 coroutine이 될 수 없다.
    }
};

소멸자 안에서 event loop를 막아 놓고 future.get()을 호출하는 우회도 좋지 않으며, 같은 thread의 event loop가 release 완료를 진행해야 한다면 교착되고, 교착이 없더라도 소멸자 한 번이 예상하지 못한 긴 지연을 만든다는 것을 인지해야한다.co_spawn(..., detached) 같은 fire-and-forget으로 넘기면 정리 완료 전에 scope가 끝나고 오류도 호출 경로에서 분리된다.

소멸자가 stack unwinding 도중 예외를 내보내면 std::terminate가 호출될 수 있으므로, 실패 가능한 비동기 해제를 소멸자에 숨기는 방향 자체가 맞지 않는다.4

따라서 C++에서는 동기 자원은 RAII로 닫고, 비동기 자원은 coroutine 수준의 bracket 함수가 생명주기 전체를 맡는 편이 분명하게 해야한다.
표준 C++ coroutine은 언어 장치만 정하고 범용 Task<T> runtime을 제공하지 않으므로, 아래 예제는 Boost.Asio의 boost::asio::awaitable<T>를 사용한다. Folly, Seastar, Qt 등의 실제 task type을 쓴다면 반환형과 cancellation API만 그 runtime에 맞춰 바꾸면 된다.67

C++
#include <boost/asio.hpp>

#include <exception>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>

namespace asio = boost::asio;

struct RenderSlotToken {
    std::string slot_id;
    std::string lease_id;
};

struct RenderJob {
    std::string job_id;
    std::string input_key;
};

struct RenderReceipt {
    std::string job_id;
    std::string output_key;
};

class RenderSlotBroker {
public:
    virtual asio::awaitable<RenderSlotToken> acquire() = 0;

    // 호출자 취소와 분리된 짧은 timeout, 멱등 release를 내부에서 적용한다.
    virtual asio::awaitable<void> release_for_cleanup(
        RenderSlotToken token) = 0;

    virtual ~RenderSlotBroker() = default;
};

class Renderer {
public:
    virtual asio::awaitable<RenderReceipt> render(
        RenderJob job,
        std::string slot_id) = 0;

    virtual ~Renderer() = default;
};

class RenderAndCleanupFailed final : public std::runtime_error {
public:
    RenderAndCleanupFailed(
        std::exception_ptr body_error,
        std::exception_ptr cleanup_error)
        : std::runtime_error("render and slot cleanup both failed"),
          body_error_(std::move(body_error)),
          cleanup_error_(std::move(cleanup_error)) {}

    const std::exception_ptr& body_error() const noexcept {
        return body_error_;
    }

    const std::exception_ptr& cleanup_error() const noexcept {
        return cleanup_error_;
    }

private:
    std::exception_ptr body_error_;
    std::exception_ptr cleanup_error_;
};

asio::awaitable<RenderReceipt> execute_render_job(
    RenderJob job,
    RenderSlotBroker& broker,
    Renderer& renderer)
{
    // 정상 반환한 token은 broker가 이미 검증했다.
    RenderSlotToken token = co_await broker.acquire();

    std::optional<RenderReceipt> receipt;
    std::exception_ptr body_error;

    try {
        receipt.emplace(
            co_await renderer.render(
                std::move(job),
                token.slot_id));
    } catch (...) {
        // cleanup 뒤에 원래 오류를 다시 던지기 위해 보관한다.
        body_error = std::current_exception();
    }

    std::exception_ptr cleanup_error;
    bool was_cancelled = false;

    try {
        // Asio는 취소된 coroutine의 다음 co_await에서 예외를 던질 수 있다.
        // cleanup 동안만 자동 throw를 끈다. 이 설정만으로 하위 operation에
        // 전달된 cancellation이 제거되는 것은 아니므로, 아래 broker 계약이
        // 별도 timeout/취소 경계를 제공해야 한다.
        // 이 helper는 진입 시 throw_if_cancelled(true)였다는 일반 정책을 전제한다.
        co_await asio::this_coro::throw_if_cancelled(false);

        co_await broker.release_for_cleanup(std::move(token));
    } catch (...) {
        cleanup_error = std::current_exception();
    }

    // throw_if_cancelled(true)는 이미 발생한 cancellation을 다시 던져 주지
    // 않는다. 따라서 cleanup 뒤 cancellation 상태를 관찰하고, 필요하면
    // 아래에서 명시적으로 취소 결과를 만든다.
    try {
        auto cancellation_state =
            co_await asio::this_coro::cancellation_state;
        was_cancelled = cancellation_state.cancelled()
            != asio::cancellation_type::none;
    } catch (...) {
        if (!cleanup_error) {
            cleanup_error = std::current_exception();
        }
        // 이미 release 실패가 있으면 그 오류를 보존한다.
    }

    if (body_error && cleanup_error) {
        throw RenderAndCleanupFailed(
            std::move(body_error),
            std::move(cleanup_error));
    }

    if (cleanup_error) {
        std::rethrow_exception(cleanup_error);
    }

    if (body_error) {
        std::rethrow_exception(body_error);
    }

    if (was_cancelled) {
        throw asio::system_error(asio::error::operation_aborted);
    }

    co_return std::move(receipt).value();
}

여기서는 release_for_cleanup()의 계약이 중요하다. 호출자 cancellation을 그대로 전달하지 않지만 무기한 기다리라는 뜻도 아니다.
broker가 별도의 짧은 timeout을 적용하고, lease_id를 이용해 같은 반환 요청을 다시 보내도 안전하게 만들어야 한다. Boost.Asio는 취소된 coroutine에서 이어지는 co_await가 기본적으로 예외를 낼 수 있으므로 cleanup 동안만 throw_if_cancelled(false)를 적용하는 것이 중요하다.7

이 설정은 다음 co_await에서 자동으로 예외를 던지는 동작만 끄며, 이미 전달된 cancellation slot이 하위 비동기 operation을 취소하는 것까지 막지 않는데,

따라서 broker가 별도 cleanup 실행 흐름, timeout, 멱등 release를 소유해야 한다. throw_if_cancelled(true)를 cleanup 뒤에 호출하는 것만으로는 이미 설정된 cancellation을 재생하지 않으므로, 예제는 cancellation_state를 관찰한 뒤 취소 결과를 명시적으로 만든다. C++에서는 co_await를 catch handler 안에 둘 수 없으므로8, release와 상태 관찰을 각각 try 블록에서 수집한다.

reset_cancellation_state()는 더 강한 도구인데, 이 상태는 co_spawn이 만든 같은 실행 흐름의 coroutine들이 공유하므로, 하위 helper에서 cancellation state를 초기화한 뒤 그대로 반환하면 호출자의 취소 정책까지 바뀔 수 있다는 점을 인지하여야 한다. 그래서 바깥 bracket 함수는 상태 초기화 대신 자동 throw만 잠시 끈다.

반대로 release_for_cleanup() 내부에서는 범위가 닫혀 있으므로 더 강한 도구를 쓸 수 있다. 실제 async_write, async_connect 같은 operation이 caller cancellation에 끊기지 않아야 한다면,
broker 구현 내부에서 reset_cancellation_state(disable_cancellation())를 적용한 짧은 helper를 만들거나, 새 co_spawn 실행 흐름으로 release를 분리한 뒤 자체 timeout을 붙이는 편이 낫다.
throw_if_cancelled(false)는 다음 co_await에서 자동으로 예외를 던지는 동작만 끄며, 이미 전달된 cancellation slot이 하위 비동기 operation을 취소하는 것까지 막지는 않는다.

즉 이 예제의 핵심은 "caller cancellation을 무시하고 영원히 기다린다"가 아니라, "반환 요청은 caller cancellation과 독립된 broker timeout으로 끝낸다"에 가깝다.(이러한 예제는 그냥 통째로 외우는게 편하다. 나도 보통은 예제 자체를 외우는 식으로 적용한다.)

코루틴의 입력 수명도 따로 봐야 하는데, 값 매개변수는 coroutine frame으로 이동하지만 참조 매개변수는 참조로 남는다. 그래서 execute_render_job(RenderJob{...}, ...)처럼 임시 객체를 넘긴 뒤 coroutine이 suspend되면 const RenderJob&는 dangling reference가 된다.6

예제의 RenderJobslot_id는 값으로 받아 frame이 소유하게 했다. 반면 brokerrenderer는 호출자가 작업 완료까지 살려 둔다는 service lifetime 계약 아래 참조로 남겨 두었다.

또한 C++ 구현은 본문 오류와 cleanup 오류를 std::exception_ptr 두 개로 보존한다. 평범한 RAII 소멸자 하나로는 이 조합을 안전하게 보고하기 어렵다라는 설명이 있다. 실제로도 그렇다.

동기 cleanup이라면 소멸자나 Library Fundamentals TS v3의 std::experimental::scope_exit, 또는 Boost.Scope의 scope guard가 맞을 수 있다. std::scope_exit는 C++23 표준 라이브러리에 들어간 이름이 아니다.9 실패 가능한 비동기 cleanup은 await 가능한 orchestration 경계에서 끝내야 한다. 이 예제는 예외를 오류 채널로 쓰는 정책을 전제로 한다. 예측 가능한 실패를 모두 Result로 다루는 프로젝트라면 같은 네 단계의 결과를 값으로 합성해야 하며, 구체적인 형태는 뒤의 Result-First 아키텍처에서는 scope 문법이 경계가 된다에서 다룬다.

개인적인 메모: C++ 오랜만에 작성하는데, 예전에 저장해두고 사용했던 코드들 깃허브 코드 예제들을 보면서 출처를 찾고 있는데, C++ 문서는 파편화도 심하고 외워야할 지점이 너무 많은 것 같다.

C#

예제는 record struct를 쓰므로 C# 10 이상을 기준으로 한다. 실행 코드는 ArgumentNullException.ThrowIfNull()을 사용하므로 .NET 6 이상을 전제로 한다. await usingIAsyncDisposable 자체는 C# 8/.NET Core 3.0 계열부터 제공된다.1

C#
using System;
using System.Threading;
using System.Threading.Tasks;

public readonly record struct RenderSlotToken(
    string SlotId,
    string LeaseId);

public interface IRenderSlotBroker
{
    ValueTask<RenderSlotToken> AcquireAsync(
        CancellationToken cancellationToken);

    ValueTask ReleaseAsync(
        RenderSlotToken token);
}

public sealed class RenderSlotLease : IAsyncDisposable
{
    private readonly RenderSlotToken token;

    private IRenderSlotBroker? broker;
    private int disposeStarted;

    private RenderSlotLease(
        IRenderSlotBroker broker,
        RenderSlotToken token)
    {
        this.broker = broker;
        this.token = token;
    }

    public static async Task<RenderSlotLease> AcquireAsync(
        IRenderSlotBroker broker,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(broker);

        RenderSlotToken token =
            await broker.AcquireAsync(cancellationToken)
                .ConfigureAwait(false);

        // Broker가 정상 반환한 token은 이미 검증되었고,
        // 호출자가 반환 책임을 넘겨받을 수 있다는 계약이다.
        return new RenderSlotLease(broker, token);
    }

    public string GetSlotId()
    {
        this.EnsureActive();
        return this.token.SlotId;
    }

    public async ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref this.disposeStarted, 1) != 0)
        {
            return;
        }

        IRenderSlotBroker? owner = this.broker;
        this.broker = null;

        if (owner is null)
        {
            return;
        }

        await owner.ReleaseAsync(this.token)
            .ConfigureAwait(false);
    }

    private void EnsureActive()
    {
        if (Volatile.Read(ref this.disposeStarted) != 0)
        {
            throw new ObjectDisposedException(
                nameof(RenderSlotLease));
        }
    }

}

사용 문법:

C#
await using RenderSlotLease lease =
    await RenderSlotLease.AcquireAsync(
        broker,
        cancellationToken);

string slotId = lease.GetSlotId();

await using 블록이나 선언의 범위를 벗어나면 DisposeAsync()가 호출된다. C#의 using 계열은 블록 안에서 예외가 발생하거나 return이 실행되어도 정리 호출이 수행되도록 변환된다.

이 타입은 lease를 한 scope가 단독 소유한다는 전제다. Interlocked.Exchange()는 동시에 들어온 dispose 호출이 원격 release를 중복 시작하지 않게 할 뿐, GetSlotId()DisposeAsync()를 서로 다른 thread에서 동시에 호출해도 안전한 borrow protocol을 제공하지 않는다. lease를 다른 task에 넘겼다면 그 task를 모두 기다린 뒤 scope를 끝내야 한다.

공개 factory는 Task<RenderSlotLease>를 반환한다. ValueTask<T>도 한 번만 await한다면 가능하지만, 일반적으로 여러 번 await하거나 AsTask()와 섞어 쓰지 말아야 하는 소비 규약이 붙는다. 측정된 allocation 병목이 없는 public acquisition API라면 Task<T>가 호출자에게 더 단순하다.10disposeStarted의 첫 Interlocked.Exchange()가 단일 진입을 이미 보장하므로 broker 참조를 비울 때 두 번째 atomic exchange는 쓰지 않았다.

GC.SuppressFinalize()는 이 sealed 예제에 넣지 않았다. finalizer가 없고 unmanaged resource를 직접 소유하지 않기 때문이다. 상속 가능한 타입이나 finalizer가 있는 타입은 Microsoft의 async dispose pattern을 따로 적용한다.1


TypeScript

using/await using 문법을 지원하는 TypeScript 5.2 이상을 기준으로 한다.2

TypeScript
export type RenderSlotToken = Readonly<{
  slotId: string;
  leaseId: string;
}>;

export type RenderSlotBroker = Readonly<{
  acquireAsync: (
    signal?: AbortSignal,
  ) => Promise<RenderSlotToken>;

  releaseAsync: (
    token: RenderSlotToken,
  ) => Promise<void>;
}>;

export class RenderSlotLease implements AsyncDisposable
{
  readonly #token: RenderSlotToken;

  #broker: RenderSlotBroker | undefined;
  #disposeStarted = false;

  private constructor(
    broker: RenderSlotBroker,
    token: RenderSlotToken,
  )
  {
    this.#broker = broker;
    this.#token = token;
  }

  public static async acquireAsync(
    broker: RenderSlotBroker,
    signal?: AbortSignal,
  ): Promise<RenderSlotLease>
  {
    signal?.throwIfAborted();

    const token = await broker.acquireAsync(signal);

    // broker가 정상 반환한 token은 이미 검증됐다는 계약이다.
    return new RenderSlotLease(
      broker,
      token,
    );
  }

  public getSlotId(): string
  {
    this.#ensureActive();
    return this.#token.slotId;
  }

  public async [Symbol.asyncDispose](): Promise<void>
  {
    if (this.#disposeStarted) {
      return;
    }

    this.#disposeStarted = true;

    const broker = this.#broker;
    this.#broker = undefined;

    if (broker === undefined) {
      return;
    }

    await broker.releaseAsync(this.#token);
  }

  #ensureActive(): void
  {
    if (this.#disposeStarted) {
      throw new Error(
        "이미 해제된 RenderSlotLease입니다.",
      );
    }
  }
}

사용 문법:

TypeScript
await using lease =
  await RenderSlotLease.acquireAsync(
    broker,
    signal,
  );

const slotId = lease.getSlotId();

await usingawait는 scope 종료 시 [Symbol.asyncDispose]()를 기다리도록 지정한다. 오른쪽의 await는 별개의 역할로, 획득 함수가 반환한 Promise를 기다려 실제 disposable 객체를 변수에 넣는다. 따라서 획득 함수 자체가 Promise를 반환한다면 오른쪽에도 별도의 await가 필요하다.

컴파일이 된다는 사실만으로 실행 환경이 준비되는 것은 아니다. target runtime이 Symbol.asyncDispose를 제공하는지 확인하고, 없다면 호환 polyfill을 넣어야 한다. SuppressedError도 native Explicit Resource Management를 그대로 실행하거나 전역 생성자를 직접 참조하는 코드라면 별도로 확인한다.

다만 TypeScript가 ES2022 이하로 내린 helper에는 SuppressedError 전역이 없을 때 쓸 fallback이 들어가므로, downlevel된 usingawait using 만을 위해 항상 전역 polyfill이 필요한 것은 아니다. AbortSignal.throwIfAborted()도 별개의 runtime API다. 문법 지원, type library와 실행 환경의 기능을 한 묶음으로 가정하지 않는다.2


Python

아래 코드는 typing.Self를 사용하므로 Python 3.11 이상을 기준으로 한다.

Python
from __future__ import annotations

from collections.abc import AsyncIterator
from contextlib import (
    AbstractAsyncContextManager,
    asynccontextmanager,
)
from dataclasses import dataclass
from types import TracebackType
from typing import Protocol, Self


@dataclass(frozen=True, slots=True)
class RenderSlotToken:
    slot_id: str
    lease_id: str


class RenderSlotBroker(Protocol):
    async def acquire(
        self,
    ) -> RenderSlotToken:
        ...

    async def release(
        self,
        token: RenderSlotToken,
    ) -> None:
        ...


class RenderSlotLease(
    AbstractAsyncContextManager["RenderSlotLease"]
):
    def __init__(
        self,
        broker: RenderSlotBroker,
        token: RenderSlotToken,
    ) -> None:
        self._broker: RenderSlotBroker | None = broker
        self._token = token
        self._dispose_started = False

    @classmethod
    async def acquire(
        cls,
        broker: RenderSlotBroker,
    ) -> Self:
        token = await broker.acquire()
        # broker가 정상 반환한 token은 이미 검증됐다는 계약이다.
        return cls(broker, token)

    async def __aenter__(
        self,
    ) -> Self:
        self._ensure_active()
        return self

    async def __aexit__(
        self,
        exception_type: type[BaseException] | None,
        exception: BaseException | None,
        traceback: TracebackType | None,
    ) -> bool:
        del exception_type
        del exception
        del traceback

        await self.aclose()
        return False

    async def aclose(self) -> None:
        if self._dispose_started:
            return

        self._dispose_started = True

        broker = self._broker
        self._broker = None

        if broker is None:
            return

        await broker.release(self._token)

    def get_slot_id(self) -> str:
        self._ensure_active()
        return self._token.slot_id

    def _ensure_active(self) -> None:
        if self._dispose_started:
            raise RuntimeError(
                "이미 해제된 RenderSlotLease입니다."
            )


@asynccontextmanager
async def render_slot_scope(
    broker: RenderSlotBroker,
) -> AsyncIterator[RenderSlotLease]:
    # 획득과 cleanup 등록을 하나의 __aenter__ 경계 안에서 끝낸다.
    lease = await RenderSlotLease.acquire(broker)

    try:
        yield lease
    finally:
        await lease.aclose()

사용 문법:

Python
async with render_slot_scope(broker) as lease:
    slot_id = lease.get_slot_id()

Python의 async with__aenter__()__aexit__()를 await한다. __aexit__()False를 반환하면 블록에서 발생한 예외를 정상적으로 전파한다.

현재의 단순한 RenderSlotLease.__aenter__()는 내부에서 suspend하지 않으므로, 획득 문장과 async with 사이에 아무 코드도 없다는 이유만으로 곧바로 cancellation leak이 생기지는 않는다. 하지만 획득된 lease가 context 밖에 먼저 노출되고, 나중에 두 문장 사이에 await가 추가되거나 __aenter__()가 비동기 검증을 시작하면 ownership gap이 생긴다. 그래서 이 문서의 권장 진입점은 render_slot_scope()다. 클래스의 __aenter__()·__aexit__() protocol은 이미 획득한 lease를 직접 소유해야 하는 낮은 수준의 사용처를 위해 남겨 둔다. acquire 응답 자체가 유실되는 원격 경계는 이 구조만으로 해결되지 않으며 broker의 lease TTL과 멱등 protocol이 맡는다.

다만 async with__aexit__()를 호출한다는 사실과 cleanup coroutine이 취소 불가능하다는 말은 다르다.

위 코드의 await broker.release(...)도 task에 다시 cancellation이 들어오면 중단될 수 있다. release 완료를 반드시 기다려야 하는 broker라면 별도 cleanup task를 만들고 강한 참조를 유지한 채 asyncio.shield()와 전용 timeout을 조합해야 한다. shield()도 호출자의 await 자체를 취소 불가능하게 만들지는 않으므로, 취소를 받은 뒤 내부 cleanup task의 완료와 실패를 어떻게 회수할지까지 broker 정책에 포함해야 한다.11


3. 호출부

Renderer는 슬롯 반환 방식을 알지 못하며, orchestration service만 lease의 수명을 관리한다.

TypeScript 호출 예제

TypeScript
export type RenderJob = Readonly<{
  jobId: string;
  sourceKey: string;
  outputKey: string;
}>;

export type RenderReceipt = Readonly<{
  jobId: string;
  outputKey: string;
}>;

export type Renderer = Readonly<{
  renderAsync: (
    job: RenderJob,
    slotId: string,
    signal?: AbortSignal,
  ) => Promise<RenderReceipt>;
}>;

export class RenderJobService
{
  readonly #broker: RenderSlotBroker;
  readonly #renderer: Renderer;

  public constructor(
    broker: RenderSlotBroker,
    renderer: Renderer,
  )
  {
    this.#broker = broker;
    this.#renderer = renderer;
  }

  public async executeAsync(
    job: RenderJob,
    signal?: AbortSignal,
  ): Promise<RenderReceipt>
  {
    validateJob(job);

    await using lease =
      await RenderSlotLease.acquireAsync(
        this.#broker,
        signal,
      );

    return await this.#renderer.renderAsync(
      job,
      lease.getSlotId(),
      signal,
    );
  }
}

function validateJob(job: RenderJob): void
{
  if (!isValidIdentifier(job.jobId)) {
    throw new RangeError(
      "jobId 형식이 유효하지 않습니다.",
    );
  }

  if (job.sourceKey.length === 0
    || job.outputKey.length === 0) {
    throw new RangeError(
      "입력과 출력 key는 비어 있을 수 없습니다.",
    );
  }
}

function isValidIdentifier(value: string): boolean
{
  return /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
}

이 호출부의 return await은 취향 문제가 아니다.

return this.#renderer.renderAsync(...)로 Promise만 반환하면 함수가 scope를 빠져나가며 disposal을 시작할 수 있고, renderer의 비동기 작업은 아직 슬롯을 사용 중일 수 있다. Promise를 scope 안에서 먼저 settle시킨 뒤 lease를 반환해야 하므로 여기서는 await가 수명 경계의 일부다.

await using 자체가 오른쪽의 획득 Promise까지 대신 await하지 않는다는 점도 구분해야 한다.2

책임 분리:

Text
API Boundary
= RenderJob 입력 검증

RenderJobService
= lease 획득
→ renderer 호출
→ scope 종료

RenderSlotLease
= 슬롯의 process-local 소유권 표현
= broker cleanup entry point를 중복 시작하지 않음

RenderSlotBroker
= 원격 coordinator I/O
= release timeout과 protocol 처리

Renderer
= 실제 GPU 작업 수행
= 슬롯 반환 책임 없음

Central Policy Handler
= 획득 실패
= 렌더링 실패
= 슬롯 해제 실패
= 취소와 timeout 분류
= 언어별 규칙에 따라 본문 오류와 cleanup 오류를 함께 보존

호출자 취소 token은 슬롯 획득과 렌더링 작업에는 전달한다. 하지만 이미 획득한 슬롯의 해제를 동일한 취소 token으로 즉시 중단하면 자원이 남을 수 있다. 해제 작업의 timeout과 재시도 정책은 broker 내부의 별도 cleanup 정책으로 관리하는 편이 안전하다.

획득 단계에도 scope guard가 닿지 못하는 구간이 있다.

Text
coordinator가 슬롯 할당 완료
→ 응답이 네트워크에서 유실
→ client의 acquire는 timeout
→ lease 객체는 만들어지지 않음

클라이언트에는 해제할 token이 없지만 서버에는 lease가 남아 있다. 이 문제를 factory의 try/finally로 고칠 수는 없다. acquire request id, coordinator의 lease TTL, heartbeat와 orphan reaper가 필요하다. scope guard가 보호하는 범위는 소유권 이전이 성공적으로 관측된 뒤부터다.


4. 읽는 순서

Text
자원을 누가 획득하는가
-> 획득 성공 시점이 명확한가
-> 자원의 소유자가 한 객체로 고정되는가
-> 사용 범위가 lexical block으로 제한되는가
-> 정상 종료·return·예외에서 모두 해제되는가
-> 해제가 비동기라면 완료를 await하는가
-> 중복 해제가 no-op인가
-> 해제 시작 후 자원 접근을 차단하는가
-> 호출자 취소가 cleanup까지 무효화하지 않는가
-> cleanup 실패가 중앙 정책 계층까지 전파되는가

가장 먼저 확인할 것은 close() 메서드의 존재가 아니라 획득한 객체가 반드시 scope protocol 안에서 소비되는가 라는 점이다.


5. 경계와 오해

비동기 scope guard는 다음 자원에 적합하다.

Text
- 비동기 DB connection과 transaction
- 원격 작업 슬롯
- 분산 semaphore permit
- 임시 object-storage upload session
- 비동기 message consumer
- flush가 필요한 buffered writer

일반 메모리 객체에는 dispose protocol이 필요하지 않다. Garbage collector는 객체 메모리를 회수하지만, 외부 연결이나 원격 lease의 적시 반환을 업무 시점에 보장하지 않는다. 비동기 dispose는 메모리 해제가 아니라 명시적인 자원 정리를 위한 계약이다.

Cleanup과 commit을 섞지 않는다

렌더 슬롯은 본문 성공 여부와 상관없이 같은 release를 호출한다. transaction과 upload session은 다르다.

Text
DB transaction
  성공 경로 -> 명시적 CommitAsync
  예외·미완료 scope 종료 -> RollbackAsync 또는 dispose

multipart upload
  성공 경로 -> CompleteAsync
  예외·미완료 scope 종료 -> AbortAsync

DisposeAsync()가 업무 성공을 뜻하는 commit까지 자동으로 수행하면, 본문이 중간에 실패한 경로에서도 부분 결과를 확정할 수 있다. 안전한 기본값은 성공 시 명시적으로 commit하고, commit되지 않은 scope의 cleanup은 rollback/abort가 되게 만드는 것이다.

Python의 __aexit__()는 본문 예외 정보를 인자로 받지만 C#의 DisposeAsync()와 ECMAScript의 [Symbol.asyncDispose]()는 같은 형태의 인자를 받지 않는다. cleanup 동작이 본문 성공 여부에 따라 달라져야 한다면 언어의 dispose hook에 추측을 맡기지 말고 CommitAsync() 같은 상태 전이를 API에 드러낸다.

소유하지 않은 자원은 해제하지 않는다

다음 두 형태는 의미가 다르다는 것을 기억해야한다.

Text
생성자에서 broker를 주입받음
→ broker의 수명은 외부 composition root 소유
→ lease가 broker 자체를 dispose하면 안 됨

broker에서 token을 획득함
→ token의 반환 책임은 lease 소유
→ scope 종료 시 release해야 함

Loading animated diagram...

DI container가 관리하는 singleton client나 connection pool을 개별 작업이 임의로 종료하면 다른 요청까지 실패할 수 있다.

순차 중복 Dispose와 동시 Dispose는 다르다

정상적인 scope 문법에서는 한 번만 호출되지만, 방어적인 자원 구현은 완료 뒤 다시 들어온 dispose를 no-op으로 처리하는 편이 안전하다.

Loading animated diagram...

Text
첫 Dispose
-> 소유권 제거
-> 원격 release

두 번째 Dispose
-> 이미 소유권 없음
-> no-op

중요한 순서는 상태를 해제 완료 후가 아니라 해제 시작 전에 비활성화하는 것이다. 그렇지 않으면 동시 호출 두 개가 모두 release I/O를 수행할 수 있다.

현재 예제는 단일 소유 scope를 전제로 한다. 첫 dispose가 releaseAsync()를 기다리는 동안 두 번째 dispose가 들어오면 두 번째 호출은 즉시 반환한다. 원격 release의 완료나 실패까지 두 호출자가 같이 관측하는 모델은 아니다. concurrent dispose를 공개 API로 지원하려면 최초 cleanup의 Task/Promise/Future를 필드에 저장하고 모든 호출자에게 같은 terminal operation을 반환해야 한다. boolean flag 하나로는 중복 I/O만 막을 뿐 완료 공유까지 해결하지 못한다.

본문의 C#/TypeScript/Python lease 구현은 release를 시작하기 전에 owner reference를 제거한다. 따라서 첫 release가 실패해도 같은 lease 객체에 Dispose를 다시 호출해서 재시도할 수 없다. lease는 사용을 즉시 금지하고, broker가 내부에서 bounded retry와 멱등성을 책임지는 설계다. broker가 그 정책을 제공하지 않는다면 이 구현은 at-most-one client call만 보장하고 원격 반환 성공은 보장하지 못한다.

Cleanup 실패를 삼키지 않는다

원격 release도 실패할 수 있다.

Text
렌더링 성공
→ 슬롯 반환 실패

렌더링 실패
→ 슬롯 반환도 실패

자원 타입이 이를 단순 로그 후 무시하면 coordinator 상태와 실제 상태가 달라진다. Cleanup 실패는 중앙 정책 handler까지 전달해야 한다. 예외 정책을 쓰는 모듈에서는 typed infrastructure exception으로, Result-First 모듈에서는 구조화된 실패 값으로 보낸다. 원래 작업 실패와 cleanup 실패를 자동으로 함께 보존하는 방식도 언어마다 다르다.

언어

본문과 cleanup이 모두 실패했을 때

C++ coroutine helper

예제처럼 본문 오류와 cleanup 오류를 각각 std::exception_ptr로 보관한 뒤 명시적인 합성 오류를 던진다. 소멸자에서 이 작업을 처리하지 않는다.

TypeScript / ECMAScript ERM

SuppressedError가 cleanup 오류를 error, 앞선 본문 오류를 suppressed에 둔다.212

C# await using

dispose가 finally에서 다시 실패하면 cleanup 예외가 바깥으로 나온다. 두 오류를 자동으로 AggregateException에 묶어 주지 않는다. 둘 다 필요하면 명시적인 orchestration helper가 본문 오류를 잡아 두고 disposal 오류와 합쳐야 한다.

Python async with

__aexit__()의 예외가 현재 오류가 되고 본문 오류는 일반적으로 __context__ chain에 남는다. 두 오류를 동등한 묶음으로 다루려면 Python 3.11 이상의 ExceptionGroup/BaseExceptionGroup 같은 명시적 정책이 필요하다.

언어

C++ coroutine helper

본문과 cleanup이 모두 실패했을 때

예제처럼 본문 오류와 cleanup 오류를 각각 std::exception_ptr로 보관한 뒤 명시적인 합성 오류를 던진다. 소멸자에서 이 작업을 처리하지 않는다.

언어

TypeScript / ECMAScript ERM

본문과 cleanup이 모두 실패했을 때

SuppressedError가 cleanup 오류를 error, 앞선 본문 오류를 suppressed에 둔다.212

언어

C# await using

본문과 cleanup이 모두 실패했을 때

dispose가 finally에서 다시 실패하면 cleanup 예외가 바깥으로 나온다. 두 오류를 자동으로 AggregateException에 묶어 주지 않는다. 둘 다 필요하면 명시적인 orchestration helper가 본문 오류를 잡아 두고 disposal 오류와 합쳐야 한다.

언어

Python async with

본문과 cleanup이 모두 실패했을 때

__aexit__()의 예외가 현재 오류가 되고 본문 오류는 일반적으로 __context__ chain에 남는다. 두 오류를 동등한 묶음으로 다루려면 Python 3.11 이상의 ExceptionGroup/BaseExceptionGroup 같은 명시적 정책이 필요하다.

따라서 "scope 문법을 썼으니 두 오류가 모두 중앙 handler에 보인다"고 가정하면 안 된다. 로그와 trace exporter도 aggregate, suppressed, cause/context chain을 실제로 순회하는지 확인해야 한다.

Result-First 아키텍처에서는 scope 문법이 경계가 된다

예측 가능한 실패를 Result<Success, Failure>로 다루는 프로젝트에서는 cleanup의 실패도 값으로 남겨야 한다. 이때 await usingasync with를 application flow에 그대로 노출하면 어색해진다.
이 문법에는 cleanup 함수의 별도 결과를 블록의 반환값과 나란히 돌려줄 자리가 없다. 일반적인 구현에서는 DisposeAsync()의 faulted ValueTask, rejected Promise, __aexit__()의 예외가 바깥으로 전파된다. 그러면 render 실패는 Result인데 release 실패만 예외가 되는 두 개의 오류 채널이 생긴다.

조합해야 할 상태는 네 가지다.

본문

cleanup

최종 결과

성공

성공

Result.Success(receipt)

성공

실패

Result.Failure(CleanupFailed)

실패

성공

Result.Failure(RenderFailed)

실패

실패

Result.Failure(BodyAndCleanupFailed)

본문

성공

cleanup

성공

최종 결과

Result.Success(receipt)

본문

성공

cleanup

실패

최종 결과

Result.Failure(CleanupFailed)

본문

실패

cleanup

성공

최종 결과

Result.Failure(RenderFailed)

본문

실패

cleanup

실패

최종 결과

Result.Failure(BodyAndCleanupFailed)

DisposeAsync()가 cleanup 실패를 내부 로그로만 남기고 정상 완료하면 결과 자체가 사라진다. 반대로 dispose와 별도로 ReleaseAsync()를 한 번 더 호출하면 중복 반환이 생긴다. Result-First 정책을 엄격하게 적용하려면 자원 획득, 본문 실행, 해제를 한 함수가 소유하고 그 함수가 최종 Result를 조립해야 한다.

아래 C# 예제는 application 내부에서 실패 타입을 AppError 하나로 닫는 Result 패턴을 전제로 한다.

Result<T>, AppError.Cancelled(), AppError.Timeout(), AppError.Unexpected()AppError.Combine()의 세부 구현은 프로젝트 타입에 맞추면 된다.

C#
public readonly record struct Unit;

public interface IResultRenderSlotBroker
{
    ValueTask<Result<RenderSlotToken>> AcquireAsync(
        CancellationToken cancellationToken);

    ValueTask<Result<Unit>> ReleaseAsync(
        RenderSlotToken token,
        CancellationToken cancellationToken);
}

public static class LeaseExecution
{
    public static async ValueTask<Result<T>> ExecuteWithLeaseAsync<T>(
        IResultRenderSlotBroker broker,
        Func<string, CancellationToken, ValueTask<Result<T>>> body,
        TimeSpan cleanupTimeout,
        CancellationToken callerToken = default)
    {
        ArgumentNullException.ThrowIfNull(broker);
        ArgumentNullException.ThrowIfNull(body);

        if (cleanupTimeout <= TimeSpan.Zero)
        {
            throw new ArgumentOutOfRangeException(
                nameof(cleanupTimeout),
                "Cleanup timeout must be positive.");
        }

        Result<RenderSlotToken> acquired;

        try
        {
            acquired = await broker.AcquireAsync(callerToken)
                .ConfigureAwait(false);
        }
        catch (OperationCanceledException ex)
            when (callerToken.IsCancellationRequested)
        {
            return Result<T>.Fail(
                AppError.Cancelled("LEASE_ACQUIRE_CANCELLED", ex));
        }
        catch (Exception ex)
        {
            // Adapter가 미처 값으로 바꾸지 못한 runtime/third-party 예외의 최종 경계다.
            return Result<T>.Fail(
                AppError.Unexpected("LEASE_ACQUIRE_THROWN", ex));
        }

        if (acquired.IsFailure)
        {
            return Result<T>.Fail(acquired.Error);
        }

        RenderSlotToken token = acquired.Value;
        Result<T> bodyResult;

        try
        {
            bodyResult = await body(
                    token.SlotId,
                    callerToken)
                .ConfigureAwait(false);
        }
        catch (OperationCanceledException ex)
            when (callerToken.IsCancellationRequested)
        {
            bodyResult = Result<T>.Fail(
                AppError.Cancelled("LEASE_BODY_CANCELLED", ex));
        }
        catch (Exception ex)
        {
            // 업무 실패는 원래 Result로 와야 한다. 여기서 잡는 것은 예상 밖 예외다.
            bodyResult = Result<T>.Fail(
                AppError.Unexpected("LEASE_BODY_THROWN", ex));
        }

        Result<Unit> cleanupResult;

        using (var cleanupCts =
            new CancellationTokenSource(cleanupTimeout))
        {
            try
            {
                cleanupResult = await broker.ReleaseAsync(
                        token,
                        cleanupCts.Token)
                    .ConfigureAwait(false);
            }
            catch (OperationCanceledException ex)
                when (cleanupCts.IsCancellationRequested)
            {
                cleanupResult = Result<Unit>.Fail(
                    AppError.Timeout("LEASE_CLEANUP_TIMEOUT", ex));
            }
            catch (Exception ex)
            {
                cleanupResult = Result<Unit>.Fail(
                    AppError.Unexpected("LEASE_CLEANUP_THROWN", ex));
            }
        }

        if (bodyResult.IsSuccess && cleanupResult.IsSuccess)
        {
            return bodyResult;
        }

        if (bodyResult.IsFailure && cleanupResult.IsSuccess)
        {
            return bodyResult;
        }

        if (bodyResult.IsSuccess && cleanupResult.IsFailure)
        {
            return Result<T>.Fail(cleanupResult.Error);
        }

        return Result<T>.Fail(
            AppError.Combine(
                "LEASE_BODY_AND_CLEANUP_FAILED",
                bodyResult.Error,
                cleanupResult.Error));
    }
}

이 함수 안의 try/catch는 역할이 좁다. 예측 가능한 업무 실패는 처음부터 Result로 받고, 프로젝트의 계약을 모르는 runtime, third-party library와 framework callback의 예외만 가장자리에서 한 번 받아 AppError로 바꾸고, application layer에는 값만 돌려주는 것이 adapter의 일이다. try/catch를 저장소 전체에서 문자 그대로 금지하면 예상 밖 예외가 body를 빠져나가는 순간 cleanup도 건너뛸 수 있다.

취소 예외는 catch 순서와 filter가 의미를 가진다. caller token이 취소된 acquire와 body는 Cancelled로, cleanup 전용 token이 만료된 경우는 Timeout으로 보존했다. 다른 token에서 발생한 OperationCanceledException까지 같은 의미로 분류하면 원인을 숨기므로 뒤의 예상 밖 예외 경계로 넘긴다.

다만 모든 예외를 Unknown Result로 낮춰서는 안 된다. 프로그래머 오류, 깨진 invariant, 프로세스를 계속 신뢰할 수 없는 상태까지 정상적인 업무 실패처럼 복구하면 장애가 숨는다. 어떤 예외를 AppError로 변환하고 어떤 예외는 cleanup 뒤 다시 던질지는 프로젝트의 failure policy가 정해야 한다. 취소도 마찬가지다. 사용자 취소가 정상 상태라면 Cancelled Result로 바꾸고, framework가 cancellation exception을 요구한다면 경계 밖에서 다시 변환한다.

C++도 구조는 같다. 앞의 std::exception_ptr 기반 예제는 예외 정책용 구현이다. C++23을 사용한다면 반환형을 std::expected<RenderReceipt, LeaseExecutionFailure>로 바꾸거나, C++20 프로젝트라면 자체 Result<T, E> 또는 검증된 expected 구현을 쓸 수 있다. TypeScript와 Python도 각각 Promise<Result<T>>, Awaitable[Result[T]]를 반환하는 orchestration 함수를 두면 된다. 어느 구현이든 acquire 이후의 모든 종료 경로를 cleanup 결과와 합친 뒤 호출자에게 돌려준다.13

선택 기준은 다음 정도면 충분하다.

프로젝트 오류 정책

권장 표현

cleanup 실패를 예외로 취급

await using, async with, C++ coroutine bracket

cleanup 실패가 호출자의 분기 대상

ExecuteWithLeaseAsync<T> 같은 Result 합성 함수

transaction처럼 성공 시 commit이 별도

Result 합성 함수 안에서 명시적 commit/rollback 상태 전이

프로젝트 오류 정책

cleanup 실패를 예외로 취급

권장 표현

await using, async with, C++ coroutine bracket

프로젝트 오류 정책

cleanup 실패가 호출자의 분기 대상

권장 표현

ExecuteWithLeaseAsync<T> 같은 Result 합성 함수

프로젝트 오류 정책

transaction처럼 성공 시 commit이 별도

권장 표현

Result 합성 함수 안에서 명시적 commit/rollback 상태 전이

Result는 원격 효과를 더 확실하게 만들어 주지 않는다. release 응답이 유실되면 Result.Failure(Timeout)도 서버에 release가 적용됐는지 말해 주지 못한다. 이 경우에는 앞서 설명한 leaseId, 멱등 release, timeout과 TTL이 여전히 필요하다.

여기서 CancellationTokenSource(cleanupTimeout)의 timeout은 ReleaseAsync()가 해당 토큰을 협력적으로 관찰한다는 전제로 한, 시간 예산이다. 토큰을 무시하는 작업을 강제로 종료하지 않으며, 시간 초과가 곧 원격 release 미적용을 뜻하지도 않는다. 서버 측 TTL과 멱등 요청 키가 최종 복구 경계를 맡아야 한다.

개인적 메모:2020년대 들어 Result를 중심으로 한 명시적 오류 처리가 주류 언어에서도 빠르게 확산되고 있다. 이는 새로 생긴 발상이라기보다, 함수형 언어와 Rust 등에서 오래 사용해 온 합 타입을 오류 처리에 적용한 방식이 대중화된 것이다. 물론 한국에서는 일종의 유행이라고 소비되는 경향이 있긴하다.

Swift 5.0에는 Result<Success, Failure>가 추가됐고, C++23에는 유사한 역할의 std::expected<T, E>가 도입됐다. TypeScript의 discriminated union이나 Kotlin의 sealed class 역시 같은 방향의 오류 모델링에 활용할 수 있으므로 익혀 둘 가치가 있다.

재사용 가능한 라이브러리나 도메인 경계에서는 Result<T, E>로 오류 종류를 명시하는 편이 좋다. 반면 구체적인 애플리케이션 조립 계층에서는 E를 공통 AppError로 고정한 Result<T> 별칭만으로 충분할 수 있다. 다만 오류 타입을 너무 일찍 하나로 평탄화하면 호출자가 처리 가능한 도메인 실패와 시스템 장애를 구분하기 어려워진다.

이건 사실 면접용이고, 그냥 남들 쓰니까 정도로 말하면 된다.

Scope guard는 프로세스 종료를 막지 못한다

다음 상황에서는 해제 코드가 실행되지 않거나, 실행되어도 원격 효과를 확인할 수 없다.

Text
- 프로세스 강제 종료
- 장비 전원 차단
- runtime crash
- 네트워크 단절: cleanup 호출은 실행됐지만 원격 적용 결과를 알 수 없는 경우

원격 lease라면 coordinator에도 만료 시간, heartbeat 또는 orphan 정리 정책이 필요하다. Scope guard는 정상적인 제어 흐름을 안전하게 만들 뿐, 분산 시스템의 장애 복구를 대체하지 않는다.

Python에서 예외를 실수로 억제하지 않는다

__aexit__()가 truthy 값을 반환하면 본문의 예외를 처리한 것으로 간주할 수 있다. 자원 해제만 담당하는 context manager는 일반적으로 False를 반환해 원래 예외가 계속 전파되도록 해야 한다. Python의 async with 의미론도 __aexit__()의 반환값에 따라 예외 재전파 여부를 결정한다.

프로덕션 실패하는 경우의 수:

Text
- 성공 경로에서만 release를 호출함
- cleanup을 await하지 않고 fire-and-forget함
- 호출자 cancellation으로 release까지 즉시 취소함
- dispose 후에도 token을 외부에서 사용할 수 있음
- 중복 dispose가 원격 release를 반복함
- lease가 자신이 소유하지 않은 broker까지 종료함
- cleanup 오류를 로그만 남기고 삼킴
- finalizer나 GC가 적시에 반환할 것으로 기대함
- acquire가 token을 반환한 뒤 factory 검증에서 실패해 guard를 만들지 못함
- remote release에 exactly-once 효과가 있다고 가정함
- lease를 여러 task에 공유한 채 한쪽에서 먼저 dispose함
- Python __aexit__에서 실수로 True를 반환함
- TypeScript runtime의 Symbol.asyncDispose 지원을 확인하지 않음
- C++ 소멸자에서 비동기 cleanup을 기다리려고 event loop를 block함
- C++ 소멸자에서 cleanup coroutine을 detached로 실행하고 완료와 오류를 잃음
- C++ coroutine에 참조 매개변수를 넘겨 임시 객체가 dangling됨
- C++ catch handler 안에서 co_await를 시도해 cleanup 복구가 컴파일되지 않음
- cleanup 상태를 되돌리는 co_await를 오류 수집 구간 밖에 두어 body 오류를 잃음
- Result-First 코드에서 body 실패만 값으로 만들고 cleanup 실패는 예외로 남김
- DisposeAsync가 cleanup 실패를 삼켜 Result 흐름을 지킨 것처럼 보이게 함

6. 잘못된 예제

TypeScript
async function renderJobBadAsync(
  job: RenderJob,
  broker: RenderSlotBroker,
  renderer: Renderer,
  signal?: AbortSignal,
): Promise<RenderReceipt>
{
  const token = await broker.acquireAsync(signal);

  const receipt = await renderer.renderAsync(
    job,
    token.slotId,
    signal,
  );

  await broker.releaseAsync(token);

  return receipt;
}

렌더링이 실패하면 다음 흐름이 된다.

Text
acquire 성공
→ render 실패
→ 함수 즉시 종료
→ release 실행되지 않음

나쁜 이유:

Text
- 해제가 성공 경로에만 존재한다.
- return 위치를 추가할 때마다 해제 누락 위험이 생긴다.
- 호출자가 token의 소유권을 직접 관리해야 한다.
- 중복 해제 방어가 없다.
- 해제 후 token 접근을 막을 수 없다.
- 획득과 사용 범위가 타입이나 문법에 드러나지 않는다.

수동 try/finally로 고칠 수는 있다.

TypeScript
const token = await broker.acquireAsync(signal);

try {
  return await renderer.renderAsync(
    job,
    token.slotId,
    signal,
  );
} finally {
  await broker.releaseAsync(token);
}

하지만 이 구조가 여러 호출부에 반복되면 해제 정책과 중복 방어가 분산된다. 자원 타입 자체가 AsyncDisposable을 구현하고 호출부는 await using만 사용하는 편이 유지보수에 유리하다.


7. 프로덕션 확장

프로세스 내부에서 자동화할 핵심 계약은 다음과 같다.

Text
- 정상 완료 후 broker.release 호출 1회
- 작업 실패 후에도 broker.release 호출 1회
- dispose를 완료한 뒤 순차 재호출해도 broker.release 호출은 총 1회
- release 실패가 호출자에게 관측됨
- acquire 실패 시 guard가 release를 꾸며 내지 않음
- dispose 시작 뒤 lease 사용이 거절됨

이 테스트는 원격 coordinator에 release가 정확히 한 번 적용됐다는 사실을 증명하지 않는다. fake broker 경계까지의 호출 규약만 검증한다. 실제 프로토콜은 중복 request, 응답 유실, timeout 뒤 재시도와 lease 만료를 별도 통합 테스트로 확인해야 한다.

TypeScript 자동화 테스트

TypeScript
import assert from "node:assert/strict";
import test from "node:test";

class FakeRenderSlotBroker implements RenderSlotBroker
{
  readonly #token: RenderSlotToken = {
    slotId: "slot-1",
    leaseId: "lease-1",
  };

  #acquireCount = 0;
  #releaseCount = 0;

  public constructor(
    readonly failRelease = false,
  ) {}

  public async acquireAsync(): Promise<RenderSlotToken>
  {
    this.#acquireCount += 1;
    return this.#token;
  }

  public async releaseAsync(
    token: RenderSlotToken,
  ): Promise<void>
  {
    assert.deepEqual(token, this.#token);
    this.#releaseCount += 1;

    if (this.failRelease) {
      throw new Error("슬롯 반환 실패");
    }
  }

  public getAcquireCount(): number
  {
    return this.#acquireCount;
  }

  public getReleaseCount(): number
  {
    return this.#releaseCount;
  }
}

test("정상 종료 시 슬롯을 한 번 반환한다", async () =>
{
  const broker = new FakeRenderSlotBroker();

  {
    await using lease =
      await RenderSlotLease.acquireAsync(broker);

    assert.equal(
      lease.getSlotId(),
      "slot-1",
    );
  }

  assert.equal(broker.getAcquireCount(), 1);
  assert.equal(broker.getReleaseCount(), 1);
});

test("본문이 실패해도 슬롯을 반환한다", async () =>
{
  const broker = new FakeRenderSlotBroker();

  await assert.rejects(
    async () =>
    {
      await using lease =
        await RenderSlotLease.acquireAsync(broker);

      assert.equal(
        lease.getSlotId(),
        "slot-1",
      );

      throw new Error("렌더링 실패");
    },
    /렌더링 실패/,
  );

  assert.equal(broker.getReleaseCount(), 1);
});

test("완료 뒤 순차 dispose는 원격 반환을 반복하지 않는다", async () =>
{
  const broker = new FakeRenderSlotBroker();

  const lease =
    await RenderSlotLease.acquireAsync(broker);

  await lease[Symbol.asyncDispose]();
  await lease[Symbol.asyncDispose]();

  assert.equal(broker.getReleaseCount(), 1);
});

test("해제 후 슬롯 접근을 거절한다", async () =>
{
  const broker = new FakeRenderSlotBroker();

  const lease =
    await RenderSlotLease.acquireAsync(broker);

  await lease[Symbol.asyncDispose]();

  assert.throws(
    () =>
    {
      lease.getSlotId();
    },
    /이미 해제된/,
  );
});

test("release 실패를 호출자에게 전파한다", async () =>
{
  const broker = new FakeRenderSlotBroker(true);

  await assert.rejects(
    async () =>
    {
      await using lease =
        await RenderSlotLease.acquireAsync(broker);

      assert.equal(lease.getSlotId(), "slot-1");
    },
    /슬롯 반환 실패/,
  );

  assert.equal(broker.getReleaseCount(), 1);
});

TypeScript compiler 설정에는 disposable 타입 library가 필요하다.

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": [
      "ES2022",
      "ESNext.Disposable"
    ],
    "types": [
      "node"
    ],
    "strict": true,
    "noImplicitReturns": true
  }
}

위 설정은 Node.js 예제 기준이라 @types/node가 필요하다. 브라우저 프로젝트라면 AbortSignal 등 Web API 타입을 위해 DOM library를 포함하는 식으로 실행 환경에 맞춘다.

운영 환경에서는 다음 지표를 관찰한다.

Text
render_slot.acquire.count
render_slot.acquire.duration_ms
render_slot.release.count
render_slot.release.duration_ms
render_slot.release.failed.count
render_slot.active.count
render_slot.orphan_expired.count

Raw leaseIdslotId를 metric label로 넣으면 cardinality가 증가한다. 개별 lease 추적은 structured log나 trace field로 제한하고, metric은 집계된 상태만 기록한다.

Cleanup timeout은 호출자 취소와 분리한다.

Text
작업 cancellation
= 렌더링을 중단하라는 요청

cleanup timeout
= 획득한 슬롯을 반환하기 위해 허용할 별도 시간

Broker의 releaseAsync() 구현은 짧은 전용 timeout과 idempotent release protocol을 사용해야 한다. 재시도가 가능한 release endpoint라면 leaseId를 동일하게 유지해 중복 반환이 무해하도록 만든다.


8. C++ / C# / TypeScript / Python 비교 메모

언어

관용적 표현

주요 주의점

C++

동기 RAII + coroutine 수준 async bracket

소멸자는 co_await할 수 없다. block이나 detached cleanup으로 우회하지 않는다.

C#

IAsyncDisposable + await using

dispose 구현에서 소유권을 먼저 원자적으로 제거

TypeScript

AsyncDisposable + await using

획득 Promise에는 별도의 await가 필요

Python

async context manager + async with

__aexit__()의 반환값, cleanup 중 재취소와 exception context를 함께 확인

언어

C++

관용적 표현

동기 RAII + coroutine 수준 async bracket

주요 주의점

소멸자는 co_await할 수 없다. block이나 detached cleanup으로 우회하지 않는다.

언어

C#

관용적 표현

IAsyncDisposable + await using

주요 주의점

dispose 구현에서 소유권을 먼저 원자적으로 제거

언어

TypeScript

관용적 표현

AsyncDisposable + await using

주요 주의점

획득 Promise에는 별도의 await가 필요

언어

Python

관용적 표현

async context manager + async with

주요 주의점

__aexit__()의 반환값, cleanup 중 재취소와 exception context를 함께 확인

표의 표현은 언어가 기본 제공하는 예외 채널을 쓸 때의 선택이다. Result-First application에서는 네 언어 모두 acquire, body와 cleanup을 하나의 orchestration 함수에 넣고 최종 실패를 값으로 합성하는 편이 낫다.

C#의 sealed 자원 타입은 DisposeAsync()에서 직접 정리를 구현할 수 있다. 상속 가능한 타입이라면 Microsoft의 async dispose pattern에 따라 별도의 virtual core 정리 메서드를 검토해야 한다.

TypeScript의 await using은 scope를 벗어날 때 [Symbol.asyncDispose]()를 호출하고 기다린다. 여러 disposable을 선언하면 scope 종료 시 역순으로 정리된다.

Python은 class 기반 __aenter__()·__aexit__()와 generator 기반 asynccontextmanager()를 모두 지원한다. 상태와 중복 해제 방어가 필요한 자원은 class가 명확하고, 단순 acquire/yield/release adapter는 decorator 방식이 간결하다.

여러 자원을 단계적으로 획득해야 한다면 각 언어의 stack형 도구도 검토한다. TypeScript에는 AsyncDisposableStack, Python에는 contextlib.AsyncExitStack이 있다.214두 도구 모두 획득 직후 cleanup을 등록하면 뒤의 획득이 실패해도 이미 등록된 자원을 역순으로 정리할 수 있다. 여러 await using/async with를 손으로 중첩하는 것보다 부분 획득 실패를 다루기 쉽다.

C++의 동기 자원은 소멸자 기반 RAII가 가장 단단하다. 그러나 비동기 cleanup은 표준 문법으로 자동 await되지 않으므로 coroutine combinator나 framework의 async scope가 획득부터 반환까지 소유해야 한다.

C#/TypeScript/Python에 동일한 closeAsync() 이름을 강요할 필요는 없다. 공통 계약은 다음이다.

Text
획득한 자원의 소유권을 scope 객체가 보유하고,
scope 종료가 비동기 해제 완료까지 기다린다.

9. 추가로 생각해보기

  • 호출자 취소가 발생했을 때 cleanup에는 같은 취소 신호를 전달해야 하는가, 별도 timeout을 사용해야 하는가?

  • Resource wrapper가 broker 자체를 소유하는가, broker에서 빌린 token만 소유하는가?

  • 본문 작업과 cleanup이 동시에 실패했을 때 두 오류를 어떤 형태로 관측 계층까지 보존할 것인가?

  • 원격 coordinator가 release 요청을 두 번 받더라도 안전하도록 어떤 idempotency 계약이 필요한가?

  • 프로세스 강제 종료로 dispose가 실행되지 않을 때 lease 만료와 orphan 정리는 누가 담당하는가?

  • 여러 자원을 중첩 획득할 때 교착을 피하기 위한 전역 획득 순서가 필요한가?

  • 서버는 할당했지만 acquire 응답이 유실된 경우 어떤 request id로 orphan을 찾는가?

  • lease가 다른 task로 전달될 수 있다면 dispose와 사용의 경합을 어떤 소유권 규칙으로 막는가?


10. 핵심 암기

  • 비동기 자원은 획득과 해제를 lexical scope에 묶어야 한다.

  • C++의 동기 자원은 RAII로 정리하되, 비동기 해제는 소멸자가 아니라 coroutine 수준의 bracket에서 기다린다.

  • C++ coroutine은 값 매개변수만 frame이 소유하고, co_await는 catch handler 안에 둘 수 없다. cleanup 복구도 별도 try 블록에서 수집한다.

  • C#은 await using, TypeScript는 await using, Python은 async with를 사용한다.

  • 완료 뒤의 순차 Dispose는 no-op으로 만들고, 해제 시작 전에 자원을 비활성화해야 한다.

  • 동시 Dispose를 지원하려면 최초 cleanup의 Task/Promise/Future를 모든 호출자가 공유해야 한다.

  • 호출자 취소가 이미 획득한 자원의 cleanup까지 무조건 중단하게 해서는 안 된다.

  • Cleanup 실패를 삼키지 말고 중앙 정책 handler까지 전달해야 한다.

  • Result-First 정책에서는 body와 cleanup의 네 가지 조합을 orchestration 함수가 하나의 Result로 합쳐야 한다.

  • 예측 가능한 실패는 값으로 다루더라도, 예상 밖 예외를 cleanup 뒤 변환하거나 재전파하는 인프라 경계는 필요하다.

  • Scope guard는 정상적인 제어 흐름을 보호하지만 프로세스 crash와 분산 lease 만료를 대신하지 않는다.

  • 원격 release의 exactly-once 효과는 scope guard가 보장하지 않는다. broker의 멱등성, 재시도와 TTL이 필요하다.

  • broker가 정상 반환하기 전에 token 검증을 끝내야 scope 생성 전 누수를 막을 수 있다.

암기 규칙:

Text
자원을 얻었다면 해제 호출을 기억하지 마라.
해제가 자동으로 따라오는 scope 안에서만 사용하라.


각주

  1. Microsoft Learn, “Implement a DisposeAsync method” 및 IAsyncDisposable API 문서. https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-disposeasync
  2. TypeScript 5.2 Release Notes, “using Declarations and Explicit Resource Management.” https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html
  3. Python Language Reference, “The async with statement.” https://docs.python.org/3/reference/compound_stmts.html#the-async-with-statement
  4. cppreference, “Destructors.” 소멸자는 coroutine이 될 수 없으며 stack unwinding 중 소멸자 예외는 std::terminate로 이어질 수 있다. https://en.cppreference.com/cpp/language/destructor
  5. AWS Well-Architected Framework, “Make all responses idempotent.” https://docs.aws.amazon.com/wellarchitected/2022-03-31/framework/relpreventinteractionfailureidempotent.html
  6. cppreference, “Coroutines (C++20).” C++ coroutine의 언어 변환과 제한. https://en.cppreference.com/cpp/language/coroutines
  7. Boost.Asio, “C++20 Coroutines Support.” awaitable, per-operation cancellation, reset_cancellation_statethrow_if_cancelled. https://www.boost.org/doc/libs/latest/doc/html/boostasio/overview/composition/cpp20coroutines.html
  8. ISO C++ working draft, [expr.await]. await-expression은 function body의 compound-statement 안에, handler 바깥에서만 나타날 수 있다. https://eel.is/c++draft/expr.await
  9. WG21 N4939, “C++ Extensions for Library Fundamentals, Version 3.” scope_exit, scope_fail, scope_success<experimental/scope>의 Library Fundamentals TS v3 기능으로 명시돼 있다. https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/n4939.html
  10. Microsoft Learn, ValueTask<TResult>. 한 인스턴스를 여러 번 await하거나 AsTask()와 중복 소비하지 말아야 하는 사용 제약을 설명한다. https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.valuetask-1
  11. Python Standard Library, asyncio.shield() cancellation semantics. https://docs.python.org/3/library/asyncio-task.html#shielding-from-cancellation
  12. TC39, “ECMAScript Explicit Resource Management,” AsyncDisposableSuppressedError 명세. https://tc39.es/proposal-explicit-resource-management/
  13. cppreference, std::expected (C++23). 성공값 T 또는 오류값 E를 보관하는 표준 vocabulary type. https://en.cppreference.com/cpp/utility/expected
  14. Python Standard Library, contextlib.asynccontextmanagerAsyncExitStack. https://docs.python.org/3/library/contextlib.html