스코프 기반 리소스 해제
파일, 소켓, DB 커넥션, 락, 임시 디렉터리처럼 "열었으면 반드시 닫아야 하는 것"은 일반 값과 다르게 다뤄야 한다. 리소스를 얻은 스코프가 리소스를 반납해야 한다.
연습 메모 · 23 min read · Hard
스코프 기반 리소스 해제
파일, 소켓, DB 커넥션, 락, 임시 디렉터리처럼 "열었으면 반드시 닫아야 하는 것"은 일반 값과 다르게 다뤄야 한다. 리소스를 얻은 스코프가 리소스를 반납해야 한다.
일반 값은 GC나 소유권 모델에 맡겨도 되지만, 리소스는 운영체제 핸들, 커넥션 풀 슬롯, 파일 락처럼 외부 상태를 점유한다. 해제가 늦어지면 메모리 누수보다 더 직접적인 장애가 난다.
이 패턴의 의도는 리소스 수명과 코드 스코프를 일치시키는 것이다. 리소스를 얻은 위치 근처에서 해제 책임을 보이게 만들고, 중간에 return, 예외, 취소가 발생해도 해제가 빠지지 않게 한다. C#의 using은 블록을 벗어날 때 IDisposable 인스턴스를 dispose하며, 블록 안에서 예외가 발생해도 dispose를 보장한다.1
Python의 with는 try/finally 패턴을 추상화하기 위해 도입되었고, context manager의 __enter__/__exit__가 진입과 종료 시 호출 된다.2
TypeScript/JavaScript에서는 런타임 호환성을 가장 넓게 잡으면 try/finally가 기본 표현이다.
JavaScript의 리소스 관리는 메모리 관리와 다르다. 메모리는 런타임이 자동으로 회수할 수 있지만, 파일 핸들, 네트워크 연결, 스트림 reader lock 같은 외부 리소스는 명시적으로 닫거나 해제해야 한다.3finally 블록은 try...catch...finally 흐름을 벗어나기 전에 실행된다.4
다만, TypeScript 5.2부터는 C#의 using과 의도상 유사한 using / await using 선언이 도입되었다(TC39 Explicit Resource Management, Stage 3). 다만 에러 결합 방식은 다르다. JS/TS 쪽은 dispose 예외와 본문 예외를 함께 보존하는 SuppressedError 의미론을 갖는다. 객체에 [Symbol.dispose] 또는 [Symbol.asyncDispose]를 구현하면 언어 레벨에서 스코프 기반 해제가 지원된다.56
Rust는 RAII에 가깝게 값이 스코프를 벗어날 때 Drop destructor가 실행되는 모델을 쓴다.7
핵심 공식:
Scope Resource = 획득 지점과 해제 지점을 같은 스코프에 둔다
Cleanup Guarantee = 성공 / 실패 / 조기 반환 모두에서 해제를 보장한다
Policy Boundary = 해제는 로컬에서, 실패 해석은 상위 정책에서 처리한다1. 문제 상황
감사 로그를 파일에 append하는 기능을 만든다고 하자. 로그인, 권한 변경, 결제 상태 변경 같은 이벤트는 한 줄씩 audit.log에 기록된다.
이때 파일 핸들을 열고 닫는 코드는 단순해 보이지만, 운영에서는 다음 문제가 자주 생긴다.
메시지가 비어 있을 때 조기 return하면서 파일 핸들을 닫지 않음
append 중 예외가 나면 close가 실행되지 않음
파일 핸들이 누적되어 Windows에서 파일 잠금이 풀리지 않음
close 실패가 원래 write 실패를 덮어씀
리소스 해제와 비즈니스 실패 처리를 한 함수에 섞음
여기서 도메인은 하나다.
감사 로그 파일을 안전하게 열고, 쓰고, 닫는다.
핵심은 열고-쓰고-닫는다 이다.
개인메모:집에 외출할때 가스밸브 잠그는 것처럼,
가스 밸브를 열어서 가스를 쓰고, 끄는 절차라고 생각하면된다.
개인적으로 가스 밸브 비유를 좋아하는게 처음 한 두번은 가스 밸브를 열어도 사고가 안 터질 수 있지만, 사고가 터지면 대형사고가 나는 부분이라는 점에서 공통점이 있다.
예제의 태생적 한계 (반드시 읽을 것): 아래 코드는 리소스의 수명 주기를 보여주기 위한 것이지, 대용량 로깅 아키텍처가 아니다. 감사 로그 한 줄마다 파일을 open -> write -> close하면, 초당 수천 건의 결제/로그인 트래픽에서 OS 파일 핸들과 디스크 IOPS가 고갈되어 시스템이 붕괴한다. 실제 대용량 로그 는 이벤트를 큐(
Channel<T>등)로 메모리에 모은 뒤, 애플리케이션 수명 (Application Lifetime)과 결합된 백그라운드 워커가 스트림을 열어둔 상태로 Batch Write해야 한다.[12] 자세한 백그라운드 워커 수명 관리는 Cache-Aside의 PER/fire-and-forget 절과 같은 원칙을 따른다.
2. 핵심 표현
C#
using System;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
public sealed class AuditLogWriter
{
private readonly string filePath;
public AuditLogWriter(string filePath)
{
this.filePath = filePath ?? throw new ArgumentNullException(nameof(filePath));
}
public string GetFilePath()
{
return this.filePath;
}
public async ValueTask WriteLineAsync(
string message,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(message))
{
throw new ArgumentException("message는 비어 있을 수 없습니다.", nameof(message));
}
await using FileStream stream = new(
this.filePath,
FileMode.Append,
FileAccess.Write,
FileShare.Read,
bufferSize: 16 * 1024,
useAsync: true);
await using StreamWriter writer = new(
stream,
Encoding.UTF8,
bufferSize: 16 * 1024,
leaveOpen: true);
await writer.WriteLineAsync(message.AsMemory(), cancellationToken);
await writer.FlushAsync(cancellationToken);
}
}
FlushAsync는 애플리케이션/스트림 버퍼를 비우는 것에 가깝고, 정전이나 커널 버퍼까지 고려한 내구성(durability) 보장은 아니다. 금융·감사 로그의 정전 안전성이 필요하면 fsync/flush-to-disk 정책(FileStream.Flush(flushToDisk: true)등)을 별도로 둔다.8
TypeScript (가장 보수적 표현: try/finally)
export final class는 유효하지 않다. final은 TypeScript 키워드가 아니다. 아래처럼 export class로 쓴다.
상속 금지는 TypeScript 문법만으로 직접 표현하기 어렵고, 필요하면 린트 규칙, 코드 리뷰 규약, 또는 팩토리 설계로 우회한다. 단 private constructor는 외부 인스턴스 생성까지 막으므로, 일반적인 final class 대체물이 아니라 singleton/static factory 전용에 가깝다.
import { mkdir, open } from "node:fs/promises";
import { dirname } from "node:path";
export class AuditLogWriter {
readonly #filePath: string;
public constructor(filePath: string) {
if (filePath.trim().length === 0) {
throw new RangeError("filePath는 비어 있을 수 없습니다.");
}
this.#filePath = filePath;
}
public getFilePath(): string {
return this.#filePath;
}
public async writeLineAsync(
message: string,
signal?: AbortSignal,
): Promise<void> {
if (message.trim().length === 0) {
throw new RangeError("message는 비어 있을 수 없습니다.");
}
signal?.throwIfAborted();
await mkdir(dirname(this.#filePath), { recursive: true });
const handle = await open(this.#filePath, "a");
try {
signal?.throwIfAborted();
// 시작 전 확인뿐 아니라 쓰기 중 취소까지 원하면 signal을 옵션으로 넘긴다.
await handle.appendFile(`${message}\n`, { encoding: "utf8", signal });
} finally {
await handle.close();
}
}
}TypeScript 5.2+ (using / await using)
TypeScript 5.2부터는 스코프 기반 해제를 언어 레벨로 표현할 수 있다. 스코프를 벗어나면(정상 종료, early return, 예외 모두) [Symbol.asyncDispose]가 자동 호출된다. Symbol.dispose/Symbol.asyncDispose는 Node 18.18+, Deno 1.37+, Safari 18.3+ 등에서 지원되며, 미지원 런타임에서는 심볼 폴리필이 필요하다.910특히 Node의 FileHandle[Symbol.asyncDispose]()는 v20.4.0 / v18.18.0에 추가됐고, v24.2.0부터 non-experimental이 됐다. 보수적인 프로덕션 문서라면 Node 24.2+ 또는 명시적 close()/try-finally를 기준으로 설명하는 편이 안전하다.11
주의할 점은 Symbol.asyncDispose 지원과 using 문법의 native parser 지원이 서로 다른 층이라는 것이다. TypeScript는 문법을 변환할 수 있지만, 실행 런타임 에는 Symbol.dispose/Symbol.asyncDispose(또는 폴리필)가 있어야 실제로 동작한다. 즉 심볼 존재, FileHandle의 asyncDispose 구현, TS 트랜스파일, 런타임 네이티브 문법 지원은 각각 다른 조건이다.
import { mkdir, open } from "node:fs/promises";
import { dirname } from "node:path";
export class AuditLogWriterModern {
readonly #filePath: string;
public constructor(filePath: string) {
if (filePath.trim().length === 0) {
throw new RangeError("filePath는 비어 있을 수 없습니다.");
}
this.#filePath = filePath;
}
public async writeLineAsync(message: string): Promise<void> {
if (message.trim().length === 0) {
throw new RangeError("message는 비어 있을 수 없습니다.");
}
await mkdir(dirname(this.#filePath), { recursive: true });
// Node의 FileHandle은 v20.4.0 / v18.18.0부터 [Symbol.asyncDispose]를 구현한다
// (v24.2.0부터 non-experimental). 스코프를 벗어나면 handle이 자동 close된다.
// (수명 관리 의도는 C#의 await using과 유사하다)
await using handle = await open(this.#filePath, "a");
await handle.appendFile(`${message}\n`, { encoding: "utf8" });
}
}Python
from pathlib import Path
class AuditLogWriter:
def __init__(self, file_path: str) -> None:
if not file_path.strip():
raise ValueError("file_path는 비어 있을 수 없습니다.")
self._file_path = Path(file_path)
def get_file_path(self) -> Path:
return self._file_path
def write_line(self, message: str) -> None:
if not message.strip():
raise ValueError("message는 비어 있을 수 없습니다.")
self._file_path.parent.mkdir(parents=True, exist_ok=True)
with self._file_path.open("a", encoding="utf-8") as file:
file.write(f"{message}\n")Rust
use std::fs::OpenOptions;
use std::io;
use std::io::Write;
use std::path::{Path, PathBuf};
pub struct AuditLogWriter {
file_path: PathBuf,
}
impl AuditLogWriter {
pub fn new(file_path: impl Into<PathBuf>) -> Self {
Self {
file_path: file_path.into(),
}
}
pub fn get_file_path(&self) -> &Path {
&self.file_path
}
pub fn write_line(&self, message: &str) -> io::Result<()> {
if message.trim().is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"message는 비어 있을 수 없습니다.",
));
}
if let Some(parent) = self.file_path.parent() {
// 상대 경로 파일명만 주면 parent가 빈 경로("")일 수 있어 명시적으로 거른다.
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.file_path)?;
writeln!(file, "{message}")?;
file.flush()?;
Ok(())
}
}3. 호출부
호출부는 리소스 해제를 직접 알 필요가 없다.
호출부는 이벤트를 검증하고, 로그 라인으로 변환하고, I/O 실패를 상위 정책으로 넘긴다. 파일 핸들을 언제 닫을지는 AuditLogWriter 내부 스코프가 책임진다.
type AuditEvent = Readonly<{
actorId: string;
action: "login" | "logout" | "permissionChanged";
targetId: string;
occurredAt: Date;
}>;
type AuditIoPolicy = Readonly<{
runAsync: (operation: () => Promise<void>) => Promise<void>;
}>;
function toAuditLine(event: AuditEvent): string {
if (event.actorId.trim().length === 0) {
throw new RangeError("actorId는 비어 있을 수 없습니다.");
}
if (event.targetId.trim().length === 0) {
throw new RangeError("targetId는 비어 있을 수 없습니다.");
}
return [
event.occurredAt.toISOString(),
event.actorId,
event.action,
event.targetId,
].join("\t");
}
export async function appendAuditEventAsync(
event: AuditEvent,
writer: AuditLogWriter,
ioPolicy: AuditIoPolicy,
signal?: AbortSignal,
): Promise<void> {
const line = toAuditLine(event);
await ioPolicy.runAsync(async () => {
await writer.writeLineAsync(line, signal);
});
}책임 분리:
toAuditLine = 도메인 이벤트 검증과 문자열 변환
AuditLogWriter = 파일 열기 / 쓰기 / 닫기
AuditIoPolicy = 재시도, 경보, 장애 처리 정책
appendAuditEventAsync = 도메인 흐름 조립4. 읽는 순서
입력이 비었는가
→ 파일 경로 디렉터리가 존재하는가
→ 리소스를 획득했는가
→ 획득 직후 해제 스코프가 보이는가
→ 쓰기 작업이 성공하든 실패하든 해제가 실행되는가
→ I/O 실패 해석은 상위 정책으로 올라가는가리소스 코드를 읽을 때는 "무엇을 한다"보다 "무엇을 반드시 반납한다"를 먼저 봐야 한다. 도서관 책도 반납일을 따져가며 빌리듯 말이다.
5. 경계와 오해
스코프 기반 해제는 실패 복구 패턴이 아니다. 파일을 닫는 것과 파일 쓰기 실패를 복구하는 것은 다른 책임이다. using, with, finally, Drop은 해제 보장을 담당한다. 재시도, fallback, 경보, 사용자 메시지 변환은 상위 정책 핸들러가 담당해야 한다.
예측 가능한 도메인 실패와 시스템 실패도 분리해야 한다. 감사 이벤트의 actorId가 비어 있는 것은 도메인 입력 오류다. 반면 디스크가 가득 찼거나, 권한이 없거나, 파일 시스템이 read-only인 것은 인프라 실패다. 둘을 같은 false나 null로 뭉개면 운영에서 원인 추적이 어려워진다.
주의할 점은 해제 함수 자체도 실패할 수 있다는 것이다. JavaScript의 finally에서 await handle.close()가 실패하면 원래 appendFile 실패를 덮어쓸 수 있다. C#의 Dispose/DisposeAsync(그리고 await using)도 예외를 낼 수 있어 같은 덮어쓰기 가 생긴다. 중요한 감사 로그나 금융 로그에서는 원래 실패와 close 실패를 둘 다 관측할 수 있는 중앙 정책이 필요하다.
소유권 경계: 생성한 주체가 닫는다 (DI 주의)
리소스를 누가 생성했느냐가 누가 해제하느냐를 정한다. 예제처럼 함수 안에서 직접 new한 리소스는 그 스코프가 닫는다.
그러나 DI(IoC) 컨테이너가 Scoped나 Singleton으로 주입한 리소스를 비즈니스 로직 함수에서 using/await using 으로 임의로 닫으면 안 된다.
그 객체의 수명은 컨테이너가 소유하므로, 다음에 같은 객체를 주입받은 다른 서비스가 이미 dispose된 인스턴스를 받아 ObjectDisposedException으로 연쇄 붕괴한다. 원칙은 "생성한 주체가 해제 책임도 가진다"이다. 주입받은 리소스는 로컬에서 닫지 말고, 수명은 컨테이너의 등록 (scope) 설정에 맡긴다.
개인메모:식당에서 음식 시켰다고 접시까지 집에 가져오지 않듯이, 생성한 주체가 책임진다는 것이다.
예외 덮어쓰기(Double Fault) 방어
원칙은 이렇다. 비즈니스(원래) 예외를 우선 전파하고, close 실패는 로거와 메트릭으로 관측하되 원래 예외를 덮어쓰지 않는다. 본문이 성공했을 때만 close 실패를 표면화한다.
type AuditLogger = Readonly<{ error: (msg: string, meta?: unknown) => void }>;
export async function writeLineSafely(
filePath: string,
message: string,
logger: AuditLogger,
): Promise<void> {
const handle = await open(filePath, "a");
let bodyOk = false;
try {
await handle.appendFile(`${message}\n`, { encoding: "utf8" });
bodyOk = true;
} finally {
try {
await handle.close();
} catch (closeError) {
// close 실패는 항상 관측한다. (metric: audit_log.close.failure.count++)
logger.error("audit close failed", { closeError });
if (bodyOk) {
// 본문은 성공했으니 close 실패를 드러낸다.
throw closeError;
}
// 본문이 이미 실패했다면 원래 예외를 우선한다.
// 여기서 throw하지 않아야 원래 예외가 덮이지 않는다(Double Fault 방지).
}
}
}C#에서도 임계 로그라면 await using의 자동 dispose에만 의존하지 말고, 같은 방식으로 원래 예외를 우선하고 DisposeAsync 실패는 로거/메트릭으로 관측하는 수동 패턴을 쓴다.12
단, 여기서 말한 "덮어쓰기"는 수동 try/finally와 C# using 계열의 일반적 주의점으로 한정해서 읽어야 한다. ECMAScript Explicit Resource Management의 using/await using은 이 문제를 위해, dispose 중 예외와 본문 예외를 SuppressedError로 함께 보존하는 의미론을 갖는다. 즉 using을 쓰면 두 예외가 하나로 뭉개지지 않고 둘 다 접근할 수 있다.13
프로덕션 실패 모드:
open 이후 close 전에 return이 있음
close를 성공 경로 마지막 줄에만 둠
finally 안에서 return하여 원래 예외를 덮어씀
리소스 객체를 필드에 오래 보관해 수명 경계가 흐려짐
dispose/close 실패를 완전히 무시함
해제 책임과 retry 정책을 같은 함수에 섞음
파일 쓰기를 병렬 호출하면서 append ordering을 보장한다고 착각함
append 모드는 파일 오프셋 경쟁을 줄일 수는 있지만, 애플리케이션 레벨의 감사 이벤트 순서, 여러 write의 논리적 직렬화, batch 단위 원자성을 보장하지 않는다. promise 기반 FS 작업은 스레드풀에서 수행되고 같은 파일에 대한 동시 수정은 동기화되지 않으므로, 순서가 중요한 감사 로그라면 단일 writer 큐/Channel로 직렬화해야 한다.11
6. 잘못된 예제
import { open } from "node:fs/promises";
async function appendAuditBadAsync(
filePath: string,
message: string,
): Promise<void> {
const handle = await open(filePath, "a");
if (message.trim().length === 0) {
return;
}
await handle.appendFile(`${message}\n`, { encoding: "utf8" });
await handle.close();
}나쁜 이유:
message가 비어 있으면 close 없이 return한다.
appendFile이 실패하면 close가 실행되지 않는다.
파일 핸들의 수명이 함수 전체에 퍼져 있지만 해제 보장은 마지막 줄 하나에만 의존한다.
호출자는 이 함수가 파일 핸들을 누수할 수 있다는 사실을 알 수 없다.
테스트에서 성공 경로만 보면 문제가 드러나지 않는다.
최소 수정은 try/finally다.
async function appendAuditBetterAsync(
filePath: string,
message: string,
): Promise<void> {
if (message.trim().length === 0) {
throw new RangeError("message는 비어 있을 수 없습니다.");
}
const handle = await open(filePath, "a");
try {
await handle.appendFile(`${message}\n`, { encoding: "utf8" });
} finally {
await handle.close();
}
}TypeScript 5.2+라면 await using으로 더 짧게 쓸 수 있다.
7. 프로덕션 확장
이 패턴의 핵심 테스트는 "성공했을 때 닫힌다"가 아니라 실패해도 닫힌다이다. 실제 파일 시스템을 매번 쓰지 말고, 닫힘 여부를 관측할 수 있는 테스트 더블로 해제 보장을 검증한다.
import assert from "node:assert/strict";
import test from "node:test";
type AsyncCloseable = Readonly<{
closeAsync: () => Promise<void>;
}>;
class DisposableProbe implements AsyncCloseable {
#closed = false;
#closeCalls = 0;
public getClosed(): boolean {
return this.#closed;
}
public getCloseCalls(): number {
return this.#closeCalls;
}
public async closeAsync(): Promise<void> {
this.#closed = true;
this.#closeCalls += 1;
}
}
async function usingResourceAsync<TResource extends AsyncCloseable>(
resource: TResource,
bodyAsync: (resource: TResource) => Promise<void>,
): Promise<void> {
try {
await bodyAsync(resource);
} finally {
await resource.closeAsync();
}
}
test("성공 경로에서도 리소스를 닫는다", async () => {
const probe = new DisposableProbe();
await usingResourceAsync(probe, async () => {
return;
});
assert.equal(probe.getClosed(), true);
assert.equal(probe.getCloseCalls(), 1);
});
test("실패 경로에서도 리소스를 닫는다", async () => {
const probe = new DisposableProbe();
await assert.rejects(async () => {
await usingResourceAsync(probe, async () => {
throw new Error("쓰기 실패");
});
});
assert.equal(probe.getClosed(), true);
assert.equal(probe.getCloseCalls(), 1);
});이 테스트는 파일 쓰기의 correctness가 아니라 수명 정책을 검증한다.
프로덕션 에서는 여기에 metric을 붙인다.
audit_log.write.success.count
audit_log.write.failure.count
audit_log.close.failure.count
audit_log.write.duration.msmetric 이름을 분리하는 이유는 쓰기 실패와 닫기 실패가 다른 장애이기 때문이다. 쓰기 실패는 디스크, 권한, 경로 문제일 수 있고, 닫기 실패는 flush 지연이나 파일 시스템 상태 문제일 수 있다.
개인 메모: 어린왕자를 읽어보면 "어른들은 숫자를 좋아한다."라는 말이 있다. 프로그 운영도 결국 어른의 일이다.
“로그가 가끔 안 써지는 것 같다”는 말보다 audit_log.write.failure.count와 audit_log.close.failure.count가 따로 올라가는 편이 훨씬 낫다. 그런면에서 어른들은 숫자를 좋아한다라는 말은 원인과 결과가 바뀌어있다고 생각한다. 어른이 되면 숫자를 좋아해야만 하는 것이다. 계약 파토나기 싫으면 말이다.
대용량 로그는 매 줄 open/close하지 않는다
앞서 경고했듯,
이 예제의 "한 줄마다 open -> write -> close" 구조는 수명 주기를 보여주기 위한 것이지 처리량 설계가 아니다.
초당 수천 건 트래픽에서는 파일 핸들 생성/파괴와 디스크 IOPS가 병목이 되어 시스템이 무너진다.
실무에서는 대략적으로 이렇게 한다.
이벤트 -> Channel<T> / 큐 (메모리 버퍼)
-> 애플리케이션 수명과 결합된 백그라운드 워커(BackgroundService)
-> 스트림을 열어둔 채 Batch Write + 주기적 Flush
-> 종료 시 그레이스풀하게 남은 버퍼 flush 후 close즉 스코프 기반 해제는 "한 작업 단위"의 수명을 다루는 도구이고, 대용량 처리량은 "애플리케이션 수명"과 결합된 장수명(Long-lived) 리소스 + 배치로 다룬다. 두 수명 축을 구분하는 것이 핵심이다. 백그라운드 워커의 수명/취소 관리는 Cache-Aside의 fire-and-forget 대신 IHostedService/Channel<T>를 쓰라는 원칙과 같다.14
개인 메모: 다만 실무에서는 한 메서드 안에서 파일을 열고, 쓰고, 닫는 코드도 자주 보인다. 대개는 블로그나 예제 코드를 가져와서 “일단 동작하니까” 그대로 납품한 경우다. 나도 그런 코드를 자주 쓴다.
트래픽이 작고, 호출 빈도가 낮고, 장애 영향이 제한적이면 단순한 open-write-close 구조로 써도 된다. 사고만 안터지면 굳이 복잡하게 안해도 된다.
그레이스풀 셧다운(Graceful Shutdown) 순서
장수명 워커를 쓰면 종료(SIGTERM) 시 큐에 남은 이벤트를 유실 없이 flush해야 한다. C# IHostedService/BackgroundService에서는 순서가 핵심이다.
1. StopAsync(hostCancellationToken) 진입 (호스트가 종료 신호를 준다)
2. 새 이벤트 수용 중단: channel.Writer.Complete()로 더 이상 받지 않는다
3. 남은 큐 드레인: await foreach로 Reader의 잔여 항목을 끝까지 처리
- 여기서 hostCancellationToken을 그대로 쓰면 즉시 취소되어 로그가 유실된다.
드레인 전용 유예(grace) 시간을 주는 별도 토큰으로 완료를 기다린다.
4. 마지막 flush: 스트림 FlushAsync (+ 필요 시 flush-to-disk)
5. 스트림/핸들 close요점은 취소 토큰을 두 층으로 나누는 것이다. "새 작업 수락 중단"은 즉시, "남은 작업 드레인"은 유예 시간(예: HostOptions.ShutdownTimeout) 안에서 완료 한다. 그리고 close는 반드시 드레인과 마지막 flush가 끝난 뒤 마지막에 온다. 순서가 뒤집혀 스트림을 먼저 닫으면, 드레인 중인 write가 이미 닫힌 핸들에 부딪혀 오히려 유실과 예외를 만든다.
즉 여기서도 "생성(open)과 해제(close)의 순서"가 곧 정합성이다.
정합성이라고 해서 어렵게 생각할 필요는 없다. 리소스의 상태 전이가 올바른 순서로 일어났는지 보는 것이다. 열기 전에 쓰지 않았는가, 열었으면 닫았는가, 닫은 뒤 다시 쓰지 않았는가, 그리고 닫기 전에 남은 작업을 처리했는가를 확인하면 된다.
그냥 일을 순서에 맞게 제대로 진행했는가가 정합성이다.
8. C# / TypeScript / Python / Rust 비교 메모
언어 | 관용 표현 | 억지로 가져오면 안 되는 것 |
|---|---|---|
C# |
| 모든 실패를 try/catch로 지역 처리하는 습관 |
TypeScript |
| Rust식 RAII를 런타임이 항상 보장한다고 착각하는 것 |
Python |
| 파일 핸들을 필드에 오래 보관하는 Java식 객체 수명 |
Rust | 소유권, 스코프, | GC 언어처럼 "나중에 닫히겠지"라고 생각하는 것; 비동기 I/O를 동기 |
C#은 리소스 타입이 IDisposable/IAsyncDisposable로 수명 계약을 드러낸다. TypeScript는 표준 런타임 환경이 다양하므로 가장 보수적인 표현은 try/finally 이고, 5.2+와 지원 런타임에서는 using/await using으로 같은 수명 관리 의도를 더 선언적으로 표현할 수 있다. Python은 with가 가장 읽기 쉽고, 여러 리소스를 동적으로 쌓아야 할 때는 contextlib 계열이 적합하다.15 Rust는 값의 소유권과 스코프가 리소스 수명과 직접 연결된다.
특히 Rust의 비동기 해제에 주의해야 한다. Drop은 동기 함수라 async fn을 실행하지 못한다(AsyncDrop은 아직 생태계의 미해결 난제다). Tokio 같은 런타임 에서 파일·소켓을 닫을 때, 값이 스코프를 벗어나 Drop이 불려도 비동기 flush나 종료 패킷 전송을 안전하게 await할 수 없다. 그래서 순서가 중요한 종료에서는 소켓·일반 AsyncWrite에는 명시적 shutdown().await, 파일에는 flush().await (내구성이 필요하면 sync_all().await)를 호출하거나, 해제 작업을 백그라운드 태스크로 넘겨야 한다.
실제로 Tokio File도 미완료 I/O가 있으면 drop 시 즉시 닫히지 않을 수 있어, drop 전에 flush를 호출하라고 안내한다.16
동기 Drop은 "빠뜨리지 않는" 안전망이지, "비동기까지 끝까지 기다려주는" 도구가 아니다.
같은 의도를 표현하되 문법을 억지로 통일하지 않는 편이 좋다.
중요한 건 의미다.
리소스를 얻은 코드가 해제 책임도 가진다. 라는 점만 명심하면 된다.
9. 추가로 생각해보기
이 리소스는 함수 내부 스코프에서 끝나야 하는가, 객체 필드로 오래 살아도 되는가?
close/dispose 실패가 원래 작업 실패를 덮어써도 되는가, 아니면 둘 다 기록해야 하는가?
감사 로그 append 순서가 중요한가, 중요하다면 병렬 호출을 어디에서 직렬화할 것인가?
이 실패는 도메인 입력 오류인가, 파일 시스템 인프라 오류인가?
파일을 매번 열고 닫을 것인가, writer를 오래 유지하며 batching할 것인가?
테스트는 성공 경로뿐 아니라 예외 경로와 조기 반환 경로의 해제를 검증하는가?
이렇게 생각하면 어렵지만, 대충 이 리소스의 생명을 어떻게 정리할 것인가다. 중요한 것은 자원의 흐름이다.
자원의 흐름이라는 골격이 완성되면 거기에 더해서 에러 처리,재시도,flush 같은 살을 붙이는 것이다.
10. 요약
파일, 소켓, DB 커넥션, 락은 일반 값이 아니라 명시적 수명 관리 대상이다.
스코프 기반 해제는 성공, 실패, 조기 반환 모두에서 반납을 보장하기 위한 패턴이다. C#은 using, Python은 with, TypeScript는 try/finally(또는 5.2+의 using), Rust는 Drop이 같은 의도를 표현한다.
해제는 로컬 스코프에서 처리하고, 실패 해석과 재시도 정책은 상위 정책 핸들러로 올리는 편이 안전하다. 프로덕션 테스트는 "성공 시 닫힘"보다 "실패해도 닫힘"을 반드시 검증해야 한다.
그리고 대용량 로그는 이 예제처럼 매 줄 open/close하지 말고, 큐 + 장수명 백그라운드 워커 + 배치로 처리한다.
쉽게 암기하기
리소스를 얻은 스코프가 리소스를 반납해야 한다.
각주
- Microsoft Learn. using statement — ensure the correct use of disposable objects (C#) ↩
- Python. PEP 343 – The "with" Statement ↩
- MDN Web Docs. JavaScript resource management guide ↩
- MDN Web Docs. try...catch (finally) ↩
- TypeScript. TypeScript 5.2 release notes — using declarations and Explicit Resource Management ↩
- TC39. Explicit Resource Management proposal (Stage 3) ↩
- Rust Documentation. Drop in std::ops ↩
- Microsoft Learn. FileStream.FlushAsync (스트림 버퍼 flush; 디스크 내구성은 Flush(flushToDisk)) ↩
- MDN Web Docs. Symbol.dispose ↩
- MDN Web Docs. Symbol.asyncDispose ↩
- Node.js. [File system: filehandle.appendFile(signal 옵션), FileHandle[Symbol.asyncDispose](), 동시 수정 주의](https://nodejs.org/api/fs.html) ↩
- Microsoft Learn. IAsyncDisposable / DisposeAsync ↩
- V8. JavaScript's New Superpower: Explicit Resource Management ↩
- Microsoft Learn. System.Threading.Channels (BackgroundService 연계) ↩
- Python. contextlib — Utilities for with-statement contexts ↩
- Tokio. tokio::fs::File (미완료 I/O 시 drop 전 flush 권고, 내구성은 sync_all) ↩