Rust BufWriter의 flush·Drop 오류와 into_parts 복구 경계 이해하기
파일 내보내기 함수가 Ok(())를 반환했는데 파일 끝부분이 비어 있는 문제를 조사한다면, BufWriter를 언제 비웠는지부터 확인해야 합니다. 작은 쓰기는 메모리 버퍼에 받아들여진 뒤 나중에 실제 writer로 전달될 수 있습니다. 이 글은 정상 종료 전에 오류를 관찰하는 경계와 부분 실패 뒤 남은 데이터를 회수하는 경로를 다룹니다.
작성: 소소팁. 작성일: 2026-10-06 한국시간. 공식 문서 확인 기준은 2026-10-05 UTC입니다. 조회한 BufWriter·Write 주 문서는 std 1.99.0으로 표시되었습니다. into_parts는 Rust 1.56부터 제공되는 API입니다. 작업 환경에 rustc와 cargo가 없어 아래 Rust 코드는 컴파일·실행하지 않았습니다. 코드의 결과 설명은 공식 API 및 구현을 대조한 예상 동작이며 실제 실험 결과로 제시하지 않습니다.
1. write_all 성공과 완료 통지를 연결하기 전에
Write::write는 일부 바이트만 소비하고 길이를 반환할 수 있습니다. write_all은 입력을 모두 소비할 때까지 쓰기를 진행하거나 오류를 반환하는 편의 메서드입니다. 버퍼 어댑터에 대한 쓰기 성공은 해당 어댑터가 입력을 받아들였다는 의미로 읽어야 합니다. 마지막 목적지의 내구성까지 한 번에 보장하는 계약으로 넓히면 안 됩니다. Write::write_all의 계약.
BufWriter는 작은 쓰기를 모아 내부 writer에 전달합니다. Drop에서도 버퍼 쓰기를 시도하지만 그 과정의 오류는 무시되므로, 성공 여부를 호출자에게 반환해야 하는 코드에서는 명시적 flush의 결과를 확인해야 합니다. BufWriter의 버퍼링과 Drop 주의사항.

그림 1. 메모리 버퍼에서 내부 writer로 전달하는 단계와 파일 동기화 단계의 경계를 구분한 도식입니다. 직접 제작한 설명 도식이며 사진이 아닙니다. 제작: 소소팁. 이용 조건: CC BY 4.0.
2. 파일 결과를 반환하는 최소 흐름
아래 예제는 새 파일만 생성합니다. 같은 이름이 이미 있으면 create_new(true)가 오류를 반환하므로, 반복 실행할 때 파일을 무심코 덮어쓰는 예제는 피했습니다. 실제 서비스에서는 출력 경로와 파일 권한을 별도로 관리해야 합니다. OpenOptions::create_new.
use std::fs::OpenOptions;
use std::io::{self, BufWriter, Write};
use std::path::Path;
fn write_report(path: &Path) -> io::Result<()> {
let file = OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
let mut out = BufWriter::with_capacity(64 * 1024, file);
out.write_all(b"id,status\n1,ok\n")?;
out.flush()?;
out.get_ref().sync_all()?;
Ok(())
}
fn main() -> io::Result<()> {
write_report(Path::new("report-new.csv"))
}
여기서 성공 경로는 write_all, flush, sync_all을 순서대로 통과합니다. sync_all은 운영체제 내부의 파일 내용과 메타데이터를 파일시스템에 동기화하도록 시도합니다. 메타데이터까지 필요하지 않은 요구에는 sync_data를 검토할 수 있지만, 플랫폼에 따라 차이가 줄어들 수 있습니다. File::sync_all, File::sync_data.
오류가 나면 함수가 Err를 반환하더라도 대상 파일에 앞부분이 이미 남아 있을 수 있습니다. 이 예제에는 실패한 새 파일의 정리, 기존 파일을 원자적으로 교체하는 절차, 디렉터리 동기화 정책이 포함되어 있지 않습니다. 호출자가 성공 응답을 받지 못했다는 사실과 파일에 아무 변화도 없다는 사실은 구분해서 다뤄야 합니다.
3. flush 실패 뒤에 얼마나 남았는지 확인합니다
장애를 검증할 때 실제 디스크를 가득 채우기 전에, 일부 바이트만 받아들이는 작은 writer로 분기부터 시험할 수 있습니다. 아래는 앞의 세 바이트만 받고 이후 write에서 오류를 반환하도록 새로 작성한 예제입니다. 테스트용 writer의 flush는 성공하도록 두어, 버퍼를 비우는 도중의 부분 쓰기 실패에 집중했습니다.
use std::io::{self, BufWriter, Write};
struct Limit {
remaining: usize,
bytes: Vec<u8>,
}
impl Write for Limit {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if buf.is_empty() { return Ok(0); }
if self.remaining == 0 {
return Err(io::Error::new(io::ErrorKind::Other, "quota exhausted"));
}
let n = self.remaining.min(buf.len());
self.bytes.extend_from_slice(&buf[..n]);
self.remaining -= n;
Ok(n)
}
fn flush(&mut self) -> io::Result<()> { Ok(()) }
}
fn main() -> io::Result<()> {
let sink = Limit { remaining: 3, bytes: Vec::new() };
let mut out = BufWriter::with_capacity(16, sink);
out.write_all(b"ABCDE")?;
let error = out.flush().expect_err("three-byte quota");
let (sink, pending) = out.into_parts();
assert_eq!(sink.bytes, b"ABC");
assert_eq!(pending.expect("writer did not panic"), b"DE");
println!("flush error: {:?}", error.kind());
Ok(())
}
공식 구현에 비추어 예상되는 상태는 내부 writer에 ABC가 들어가고 BufWriter에 DE가 남는 것입니다. 첫 write_all의 5바이트는 16바이트 버퍼에 들어갈 수 있습니다. 명시적 flush에서 내부 write가 3바이트를 받은 뒤 다음 write가 실패하면, 이미 전달한 접두부를 제외한 나머지가 보존됩니다. 이 assertion들은 독자가 별도 환경에서 실행해 확인할 검사 항목이며 이 글에서 실행을 마친 결과는 아닙니다. 공식 BufWriter의 부분 쓰기·버퍼 정리 구현.

그림 2. flush 오류 뒤 추가 쓰기 없이 분해하고 남은 버퍼를 확인하는 복구 경로입니다. 직접 제작한 설명 도식이며 사진이 아닙니다. 제작: 소소팁. 이용 조건: CC BY 4.0.
4. into_inner와 into_parts의 소유권을 구분합니다
BufWriter::into_inner는 자기 버퍼를 내부 writer로 쓰고 그 writer를 돌려주려 합니다. 실패하면 IntoInnerError 안에 오류와 BufWriter가 보존됩니다. 이 오류의 into_inner를 호출하면 아직 BufWriter를 받는 것이므로, 바로 파일이나 소켓을 얻었다고 생각하면 타입과 복구 흐름을 오해하게 됩니다. 오류의 into_parts를 쓰면 오류와 복구 가능한 writer 객체를 함께 받을 수 있습니다. IntoInnerError의 복구 API.
BufWriter 자체의 into_parts는 추가 flush 없이 내부 writer와 남은 버퍼를 분리합니다. 반환 튜플 안의 버퍼는 Result이며, 내부 writer가 panic한 경우에는 WriterPanicked로 상태의 불확실성을 알립니다. 단순한 분해가 데이터의 완전한 복구를 인증하는 것은 아닙니다. BufWriter::into_parts.
공식 구현에서 명시적 flush는 자기 버퍼를 비운 뒤 내부 writer의 flush도 호출합니다. into_inner의 경로는 자기 버퍼를 전달하는 데 집중하므로 여러 버퍼 어댑터를 중첩했다면 두 호출을 같은 완료 경계로 취급하지 마십시오. BufWriter의 into_inner·flush 구현.
5. 오류 후 Drop 재시도와 중복 전송을 고려합니다
flush()?에서 오류가 전파되어 함수가 빠져나가면 BufWriter도 Drop될 수 있습니다. 내부 panic 상태가 아니라면 Drop이 남은 버퍼를 다시 쓰려는 경로가 있으므로, 오류 반환을 즉시 쓰기 중단이나 롤백으로 해석하면 안 됩니다. 추가 쓰기를 피하고 복구 정책을 직접 적용하려면 해당 분기에서 into_parts로 분리하는 설계를 검토하십시오. BufWriter의 Drop 구현.
부분 실패 후 전체 원문 ABCDE를 새 연결로 다시 보내면 이미 처리된 ABC가 중복될 수 있습니다. 파일의 현재 위치, 내부 writer의 버퍼 상태, 원격 프로토콜의 확인 응답을 함께 고려해야 합니다. 재시도 단위를 바이트 스트림으로 볼지, 고유 작업 ID를 가진 논리 레코드로 볼지는 응용 프로그램이 결정할 사항입니다. panic 이후에는 어떤 바이트가 실제 처리됐는지 모르는 상태를 성공이나 완전 실패로 단정하지 않는 편이 안전합니다.
6. 벤치마크와 로그도 같은 완료 기준을 씁니다
BufWriter가 작은 쓰기를 묶어 주더라도 한 줄마다 flush하면 묶음의 이점이 줄어들 수 있습니다. 큰 덩어리를 몇 번만 쓰는 작업과 작은 조각을 반복하는 작업을 따로 측정하십시오. 버퍼에 넣는 시간만 재고 flush나 sync_all을 측정 구간 밖에 두면 실제 완료 지연을 과소평가할 수 있습니다. 이 글에서는 성능 수치를 측정하지 않았습니다. BufWriter가 유용한 쓰기 패턴.
디버깅 정보로는 실패한 단계, ErrorKind, 버퍼 길이, 출력 대상의 비민감 식별자를 우선 남기십시오. 회수한 버퍼 전체를 로그로 출력하면 파일에 쓰려던 개인 정보나 비밀 값이 다른 저장소에 복제될 수 있습니다. 복구 버퍼를 보관할 때도 원본 데이터와 같은 접근 통제와 보존 기간을 적용해야 합니다.
7. 대안은 작업의 완료 조건에 맞춥니다
이미 한 덩어리의 바이트를 가진 작은 작업이면 직접 writer에 write_all을 호출하는 편이 단순할 수 있습니다. 작은 쓰기가 반복되는 작업에서는 BufWriter를 두되, flush와 필요한 파일 동기화까지 성공해야 완료로 표시하는 구조를 권합니다. 네트워크의 경우 로컬 flush를 원격 서버의 저장 확인 응답으로 바꾸어 해석하지 마십시오.
기술적 주의: 위 코드는 문서와 구현을 대조한 미실행 예제이며 파일 교체의 원자성, 전원 장애 내구성, 원격 처리의 정확히 한 번 실행을 제공하지 않습니다. 실제 writer의 부분 쓰기·flush 실패·panic 경로를 테스트하고, 성공 응답의 기준을 저장 매체와 프로토콜 수준에서 정의해야 합니다. 이 글에는 오류나 누락이 있을 수 있으며, 버전·기기·실행 환경에 따라 결과가 달라질 수 있습니다. 운영에 적용하기 전 최신 공식 문서를 확인하고 별도 테스트 환경에서 검증해 주세요.


