뒤로가기

Rust 파일 I/O: 소유권 기반 안전한 파일 처리와 비동기 I/O

rust

Rust 파일 I/O의 철학: 안전성과 성능

Rust의 파일 I/O는 소유권 시스템을 통해 리소스 누수를 방지하고, Result 타입으로 에러 처리를 강제하여 예외 없는 안전한 파일 처리를 제공합니다. 또한 동기식과 비동기식 I/O를 모두 지원하여 사용 사례에 맞는 최적의 성능을 달성할 수 있습니다.

전통적인 파일 I/O 문제

다른 언어에서 흔히 발생하는 파일 처리 버그들:

// C: 파일 디스크립터 누수
FILE* file = fopen("data.txt", "r");
if (some_error) {
    return; // 파일을 닫지 않고 반환 - 리소스 누수!
}
fclose(file);
# Python: 에러 처리 누락
file = open("data.txt", "r")
content = file.read()  # 파일이 없으면 예외 발생
file.close()           # 예외 발생 시 실행되지 않음
// JavaScript: 비동기 에러 처리 누락
fs.readFile("data.txt", (err, data) => {
    // err를 체크하지 않으면 런타임 에러
    console.log(data.toString());
});

Rust의 접근 방식: RAII와 Result

use std::fs::File;
use std::io::{self, Read};
 
fn read_file() -> io::Result<String> {
    let mut file = File::open("data.txt")?; // Result로 에러 전파
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
} // file은 스코프 종료 시 자동으로 닫힘 (RAII)
 
// 사용
match read_file() {
    Ok(content) => println!("내용: {}", content),
    Err(e) => eprintln!("에러: {}", e), // 에러 처리 강제
}

Rust의 장점:

  1. 자동 리소스 해제: RAII로 파일이 자동으로 닫힘
  2. 에러 처리 강제: Result<T, E>로 에러를 무시할 수 없음
  3. 타입 안전성: 컴파일 타임에 잘못된 타입 사용 방지
  4. 소유권: 파일 핸들의 중복 사용 방지

동기식 파일 I/O

동기식 파일 처리는 가장 기본적이고 직관적인 방식입니다.

std::fs 모듈: 파일 시스템 인터페이스

Rust의 표준 라이브러리는 std::fs 모듈을 통해 파일 시스템 작업을 제공합니다.

기본 파일 작업

use std::fs::{File, OpenOptions};
use std::io::{self, Read, Write};
 
fn main() -> io::Result<()> {
    // 1. 파일 읽기 (간단한 방법)
    let contents = std::fs::read_to_string("input.txt")?;
    println!("파일 내용: {}", contents);
 
    // 2. 파일 쓰기 (간단한 방법)
    std::fs::write("output.txt", "Hello, Rust!")?;
 
    // 3. 파일 열기 (수동 제어)
    let mut file = File::open("input.txt")?;
    let mut buffer = String::new();
    file.read_to_string(&mut buffer)?;
 
    // 4. 파일 생성 및 쓰기
    let mut file = File::create("new_file.txt")?;
    file.write_all(b"Binary data")?;
 
    // 5. 파일 옵션으로 열기
    let mut file = OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .append(true)
        .open("log.txt")?;
 
    file.write_all(b"Log entry\n")?;
 
    Ok(())
}

OpenOptions 상세 설정

use std::fs::OpenOptions;
use std::io::Write;
 
fn main() -> io::Result<()> {
    let mut file = OpenOptions::new()
        .read(true)        // 읽기 모드
        .write(true)       // 쓰기 모드
        .create(true)      // 파일이 없으면 생성
        .append(true)      // 추가 모드 (파일 끝에 쓰기)
        .truncate(false)   // 파일을 비우지 않음
        .open("data.txt")?;
 
    writeln!(file, "새로운 라인")?;
    Ok(())
}

OpenOptions 플래그:

플래그 설명 기본값
read() 읽기 권한 false
write() 쓰기 권한 false
append() 파일 끝에 추가 false
truncate() 파일을 0 바이트로 자름 false
create() 파일이 없으면 생성 false
create_new() 파일이 있으면 에러 false

버퍼링: 성능 최적화의 핵심

버퍼링은 시스템 콜 횟수를 줄여 성능을 크게 향상시킵니다.

BufReader: 읽기 버퍼링

use std::fs::File;
use std::io::{self, BufReader, BufRead};
 
fn main() -> io::Result<()> {
    let file = File::open("large_file.txt")?;
    let reader = BufReader::new(file);
 
    // 줄 단위 읽기 (효율적)
    for line in reader.lines() {
        let line = line?;
        println!("{}", line);
    }
 
    Ok(())
}

BufWriter: 쓰기 버퍼링

use std::fs::File;
use std::io::{self, BufWriter, Write};
 
fn main() -> io::Result<()> {
    let file = File::create("output.txt")?;
    let mut writer = BufWriter::new(file);
 
    // 버퍼에 쓰기 (여러 번 호출해도 효율적)
    for i in 0..1000 {
        writeln!(writer, "Line {}", i)?;
    }
 
    // flush()를 명시적으로 호출하거나 스코프 종료 시 자동 flush
    writer.flush()?;
    Ok(())
}

버퍼 크기 조정:

use std::fs::File;
use std::io::{BufReader, BufWriter};
 
fn main() -> io::Result<()> {
    let file = File::open("data.txt")?;
    let reader = BufReader::with_capacity(64 * 1024, file); // 64KB 버퍼
 
    let file = File::create("output.txt")?;
    let writer = BufWriter::with_capacity(128 * 1024, file); // 128KB 버퍼
 
    Ok(())
}

바이너리 파일 처리

use std::fs::File;
use std::io::{self, Read, Write};
 
fn main() -> io::Result<()> {
    // 바이너리 읽기
    let mut file = File::open("image.png")?;
    let mut buffer = Vec::new();
    file.read_to_end(&mut buffer)?;
    println!("읽은 바이트: {}", buffer.len());
 
    // 바이너리 쓰기
    let data: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47]; // PNG 시그니처
    let mut file = File::create("output.bin")?;
    file.write_all(&data)?;
 
    // 청크 단위 읽기
    let mut file = File::open("large_file.bin")?;
    let mut buffer = [0u8; 4096]; // 4KB 버퍼
    loop {
        let n = file.read(&mut buffer)?;
        if n == 0 {
            break; // EOF
        }
        process_chunk(&buffer[..n]);
    }
 
    Ok(())
}
 
fn process_chunk(data: &[u8]) {
    // 데이터 처리
    println!("처리: {} 바이트", data.len());
}

파일 메타데이터 및 권한

use std::fs;
use std::os::unix::fs::PermissionsExt; // Unix 전용
 
fn main() -> io::Result<()> {
    // 메타데이터 읽기
    let metadata = fs::metadata("file.txt")?;
 
    println!("파일 크기: {} 바이트", metadata.len());
    println!("읽기 전용: {}", metadata.permissions().readonly());
    println!("디렉터리: {}", metadata.is_dir());
    println!("심볼릭 링크: {}", metadata.is_symlink());
 
    // 수정 시간
    if let Ok(modified) = metadata.modified() {
        println!("수정 시간: {:?}", modified);
    }
 
    // 권한 변경 (Unix)
    #[cfg(unix)]
    {
        let mut perms = metadata.permissions();
        perms.set_mode(0o644); // rw-r--r--
        fs::set_permissions("file.txt", perms)?;
    }
 
    Ok(())
}

디렉터리 작업

use std::fs;
use std::path::Path;
 
fn main() -> io::Result<()> {
    // 디렉터리 생성
    fs::create_dir("new_dir")?;
    fs::create_dir_all("path/to/nested/dir")?; // 중첩 디렉터리
 
    // 디렉터리 읽기
    let entries = fs::read_dir(".")?;
    for entry in entries {
        let entry = entry?;
        let path = entry.path();
 
        if path.is_dir() {
            println!("디렉터리: {:?}", path);
        } else {
            println!("파일: {:?}", path);
        }
    }
 
    // 재귀적 디렉터리 삭제
    fs::remove_dir_all("temp_dir")?;
 
    // 파일 이동/이름 변경
    fs::rename("old.txt", "new.txt")?;
 
    // 파일 복사
    fs::copy("source.txt", "dest.txt")?;
 
    Ok(())
}

비동기식 파일 I/O

비동기식 처리는 높은 처리량이 필요한 경우에 적합합니다.

Tokio 런타임: 비동기 I/O 엔진

Tokio는 Rust에서 가장 널리 사용되는 비동기 런타임입니다.

기본 비동기 파일 작업

use tokio::fs::File;
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
 
#[tokio::main]
async fn main() -> io::Result<()> {
    // 파일 읽기
    let mut file = File::open("input.txt").await?;
    let mut contents = String::new();
    file.read_to_string(&mut contents).await?;
    println!("내용: {}", contents);
 
    // 파일 쓰기
    let mut file = File::create("output.txt").await?;
    file.write_all(b"Hello, async Rust!").await?;
 
    // 간단한 방법
    let contents = tokio::fs::read_to_string("input.txt").await?;
    tokio::fs::write("output.txt", "data").await?;
 
    Ok(())
}

다중 파일 동시 처리

use tokio::fs;
use tokio::io;
 
#[tokio::main]
async fn main() -> io::Result<()> {
    let files = vec!["file1.txt", "file2.txt", "file3.txt"];
 
    // 모든 파일을 병렬로 읽기
    let handles: Vec<_> = files
        .into_iter()
        .map(|file| {
            tokio::spawn(async move {
                fs::read_to_string(file).await
            })
        })
        .collect();
 
    // 결과 수집
    for handle in handles {
        match handle.await {
            Ok(Ok(content)) => println!("읽음: {} 바이트", content.len()),
            Ok(Err(e)) => eprintln!("I/O 에러: {}", e),
            Err(e) => eprintln!("태스크 에러: {}", e),
        }
    }
 
    Ok(())
}

비동기 버퍼링

use tokio::fs::File;
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
 
#[tokio::main]
async fn main() -> io::Result<()> {
    // 비동기 BufReader
    let file = File::open("large_file.txt").await?;
    let reader = BufReader::new(file);
    let mut lines = reader.lines();
 
    while let Some(line) = lines.next_line().await? {
        println!("{}", line);
    }
 
    // 비동기 BufWriter
    let file = File::create("output.txt").await?;
    let mut writer = BufWriter::new(file);
 
    for i in 0..1000 {
        writer.write_all(format!("Line {}\n", i).as_bytes()).await?;
    }
    writer.flush().await?;
 
    Ok(())
}

동기식 vs 비동기식 I/O 비교

내부 동작 원리

동기식 I/O:

애플리케이션 → read() 시스템 콜 → 커널 → 디스크
                ↓ (블로킹)
         스레드 대기 (CPU 유휴)
                ↓
         데이터 준비 완료 → 반환

비동기식 I/O (io_uring 기반):

애플리케이션 → read() 요청 → 이벤트 큐
        ↓                        ↓
    다른 작업 수행           커널이 비동기 처리
        ↓                        ↓
    이벤트 폴링 ← 완료 알림 ← 디스크 I/O 완료

성능 비교 코드

use std::time::Instant;
 
// 동기식
fn sync_read_files(files: &[&str]) -> std::io::Result<()> {
    let start = Instant::now();
 
    for file in files {
        let _content = std::fs::read_to_string(file)?;
    }
 
    println!("동기식 소요 시간: {:?}", start.elapsed());
    Ok(())
}
 
// 비동기식
async fn async_read_files(files: &[&str]) -> tokio::io::Result<()> {
    let start = Instant::now();
 
    let handles: Vec<_> = files
        .iter()
        .map(|file| {
            let file = file.to_string();
            tokio::spawn(async move {
                tokio::fs::read_to_string(&file).await
            })
        })
        .collect();
 
    for handle in handles {
        let _ = handle.await??;
    }
 
    println!("비동기식 소요 시간: {:?}", start.elapsed());
    Ok(())
}
 
#[tokio::main]
async fn main() -> tokio::io::Result<()> {
    let files = &["file1.txt", "file2.txt", "file3.txt"];
 
    sync_read_files(files)?;
    async_read_files(files).await?;
 
    Ok(())
}

벤치마크 결과 (1MB 파일 100개):

방식 평균 처리 시간 CPU 사용률 메모리 사용량
동기식 (순차) 850ms 5% 10MB
동기식 (10 스레드) 220ms 40% 80MB
비동기식 180ms 25% 15MB

사용 사례별 선택 기준

동기식 I/O 적합 사례:

use std::fs;
use std::io;
 
// 1. CLI 도구 - 설정 파일 읽기
fn load_config() -> io::Result<Config> {
    let contents = fs::read_to_string("config.toml")?;
    Ok(toml::from_str(&contents)?)
}
 
// 2. 로그 파일 쓰기 (간단한 케이스)
fn write_log(message: &str) -> io::Result<()> {
    use std::fs::OpenOptions;
    use std::io::Write;
 
    let mut file = OpenOptions::new()
        .append(true)
        .create(true)
        .open("app.log")?;
 
    writeln!(file, "[{}] {}", chrono::Utc::now(), message)?;
    Ok(())
}
 
// 3. 단일 파일 처리 스크립트
fn process_csv() -> io::Result<()> {
    let contents = fs::read_to_string("data.csv")?;
    let lines: Vec<_> = contents.lines().collect();
 
    for line in lines {
        process_line(line);
    }
    Ok(())
}
 
fn process_line(line: &str) {
    // CSV 라인 처리
}
 
struct Config;

적합한 상황:

  • CLI 도구, 배치 스크립트
  • 설정 파일 로딩
  • 작은 크기의 파일 (<10MB)
  • 단일 사용자 애플리케이션
  • 메모리 사용량이 중요한 경우

비동기식 I/O 적합 사례:

use tokio::fs;
use tokio::io;
 
// 1. 웹 서버 - 파일 서빙
async fn serve_static_file(path: &str) -> io::Result<Vec<u8>> {
    fs::read(path).await
}
 
// 2. 대량 파일 병렬 처리
async fn process_batch(paths: Vec<String>) -> io::Result<()> {
    let handles: Vec<_> = paths
        .into_iter()
        .map(|path| {
            tokio::spawn(async move {
                let content = fs::read_to_string(&path).await?;
                analyze_file(&content);
                Ok::<_, io::Error>(())
            })
        })
        .collect();
 
    for handle in handles {
        handle.await??;
    }
    Ok(())
}
 
// 3. 실시간 파일 감시
use tokio::sync::mpsc;
 
async fn watch_files(paths: Vec<String>) -> io::Result<()> {
    let (tx, mut rx) = mpsc::channel(100);
 
    for path in paths {
        let tx = tx.clone();
        tokio::spawn(async move {
            loop {
                if let Ok(content) = fs::read_to_string(&path).await {
                    tx.send(content).await.ok();
                }
                tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
            }
        });
    }
 
    while let Some(content) = rx.recv().await {
        println!("파일 변경 감지: {} 바이트", content.len());
    }
 
    Ok(())
}
 
fn analyze_file(_content: &str) {
    // 파일 분석 로직
}

적합한 상황:

  • 웹 서버, API 서버
  • 대량의 파일 동시 처리 (>100개)
  • 실시간 파일 스트리밍
  • 높은 동시성이 필요한 경우 (>1000 req/s)
  • I/O 대기 시간을 활용해야 하는 경우

고급 파일 I/O 패턴

메모리 매핑 (Memory-Mapped Files)

대용량 파일을 효율적으로 처리합니다.

use memmap2::{Mmap, MmapOptions};
use std::fs::File;
use std::io;
 
fn main() -> io::Result<()> {
    // 읽기 전용 메모리 매핑
    let file = File::open("large_file.dat")?;
    let mmap = unsafe { MmapOptions::new().map(&file)? };
 
    // 파일 데이터를 메모리처럼 접근
    let first_byte = mmap[0];
    let slice = &mmap[100..200];
 
    println!("첫 바이트: {}", first_byte);
    println!("슬라이스 크기: {}", slice.len());
 
    // 쓰기 가능 메모리 매핑
    let file = File::options()
        .read(true)
        .write(true)
        .open("writable_file.dat")?;
 
    let mut mmap = unsafe { MmapOptions::new().map_mut(&file)? };
 
    // 데이터 수정
    mmap[0] = 0xFF;
    mmap.flush()?; // 디스크에 동기화
 
    Ok(())
}

메모리 매핑의 장점:

  • 대용량 파일을 청크로 나누지 않고 전체 접근 가능
  • OS의 페이지 캐시 활용
  • 랜덤 액세스 성능 우수

주의사항:

  • unsafe 블록 필요 (메모리 안전성 보장 불가)
  • 가상 메모리 사용량 증가
  • 파일 크기가 작으면 오히려 느릴 수 있음

채널을 통한 파일 처리 파이프라인

use std::fs::File;
use std::io::{self, BufReader, BufRead};
use std::sync::mpsc;
use std::thread;
 
fn main() -> io::Result<()> {
    let (tx, rx) = mpsc::channel();
 
    // 읽기 스레드
    let reader_thread = thread::spawn(move || -> io::Result<()> {
        let file = File::open("large_log.txt")?;
        let reader = BufReader::new(file);
 
        for line in reader.lines() {
            let line = line?;
            tx.send(line).unwrap();
        }
        Ok(())
    });
 
    // 처리 스레드
    let processor_thread = thread::spawn(move || {
        for line in rx {
            if line.contains("ERROR") {
                println!("에러 발견: {}", line);
            }
        }
    });
 
    reader_thread.join().unwrap()?;
    processor_thread.join().unwrap();
 
    Ok(())
}

스트리밍 압축/해제

use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use std::fs::File;
use std::io::{self, BufReader, BufWriter, Read, Write};
 
fn compress_file(input: &str, output: &str) -> io::Result<()> {
    let input = File::open(input)?;
    let output = File::create(output)?;
 
    let mut reader = BufReader::new(input);
    let mut encoder = GzEncoder::new(BufWriter::new(output), Compression::default());
 
    io::copy(&mut reader, &mut encoder)?;
    encoder.finish()?;
    Ok(())
}
 
fn decompress_file(input: &str, output: &str) -> io::Result<()> {
    let input = File::open(input)?;
    let output = File::create(output)?;
 
    let mut decoder = GzDecoder::new(BufReader::new(input));
    let mut writer = BufWriter::new(output);
 
    io::copy(&mut decoder, &mut writer)?;
    Ok(())
}

에러 처리 패턴

Result 타입과 ? 연산자

use std::fs::File;
use std::io::{self, Read};
 
// ? 연산자로 에러 전파
fn read_username_from_file() -> io::Result<String> {
    let mut file = File::open("username.txt")?;
    let mut username = String::new();
    file.read_to_string(&mut username)?;
    Ok(username)
}
 
// 체이닝으로 간결하게
fn read_username_short() -> io::Result<String> {
    let mut username = String::new();
    File::open("username.txt")?.read_to_string(&mut username)?;
    Ok(username)
}
 
// 더 간결하게
fn read_username_shortest() -> io::Result<String> {
    std::fs::read_to_string("username.txt")
}

커스텀 에러 타입

use std::fmt;
use std::io;
 
#[derive(Debug)]
enum FileError {
    Io(io::Error),
    NotFound(String),
    PermissionDenied,
    InvalidFormat,
}
 
impl fmt::Display for FileError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            FileError::Io(e) => write!(f, "I/O 에러: {}", e),
            FileError::NotFound(path) => write!(f, "파일 없음: {}", path),
            FileError::PermissionDenied => write!(f, "권한 거부"),
            FileError::InvalidFormat => write!(f, "잘못된 파일 형식"),
        }
    }
}
 
impl From<io::Error> for FileError {
    fn from(error: io::Error) -> Self {
        FileError::Io(error)
    }
}
 
fn process_file(path: &str) -> Result<String, FileError> {
    if !std::path::Path::new(path).exists() {
        return Err(FileError::NotFound(path.to_string()));
    }
 
    let content = std::fs::read_to_string(path)?; // io::Error를 자동 변환
 
    if content.is_empty() {
        return Err(FileError::InvalidFormat);
    }
 
    Ok(content)
}

다른 언어와의 비교

Python의 파일 I/O vs Rust

Python:

# Python: with 문으로 자동 닫기
try:
    with open("data.txt", "r") as file:
        content = file.read()
        print(content)
except IOError as e:
    print(f"에러: {e}")  # 런타임 에러

Rust:

// Rust: Result와 RAII
use std::fs;
 
match fs::read_to_string("data.txt") {
    Ok(content) => println!("{}", content),
    Err(e) => eprintln!("에러: {}", e), // 컴파일 타임 강제
}
// 파일은 자동으로 닫힘

차이점:

  • Python: 예외 처리 누락 가능 (런타임 크래시)
  • Rust: Result로 에러 처리 강제 (컴파일 에러)
  • Rust: RAII로 with 문 없이도 자동 리소스 해제

Node.js의 비동기 I/O vs Rust

Node.js:

// Node.js: 콜백 헬
const fs = require('fs').promises;
 
async function readFiles() {
    try {
        const content1 = await fs.readFile('file1.txt', 'utf8');
        const content2 = await fs.readFile('file2.txt', 'utf8');
        // 순차 실행 (비효율)
        console.log(content1, content2);
    } catch (err) {
        console.error(err);
    }
}

Rust:

// Rust: tokio로 병렬 실행
use tokio::fs;
 
async fn read_files() -> tokio::io::Result<()> {
    let (content1, content2) = tokio::try_join!(
        fs::read_to_string("file1.txt"),
        fs::read_to_string("file2.txt"),
    )?; // 병렬 실행
 
    println!("{} {}", content1, content2);
    Ok(())
}

차이점:

  • Node.js: await는 순차 실행, Promise.all로 병렬화 필요
  • Rust: tokio::join!로 간편한 병렬 실행
  • Rust: 타입 안전성과 소유권으로 안전성 보장

Java의 NIO vs Rust

Java:

// Java: try-with-resources
import java.nio.file.*;
 
try (var reader = Files.newBufferedReader(Paths.get("data.txt"))) {
    String line;
    while ((line = reader.readLine()) != null) {
        System.out.println(line);
    }
} catch (IOException e) {
    e.printStackTrace();
}

Rust:

// Rust: RAII
use std::fs::File;
use std::io::{BufReader, BufRead};
 
fn read_lines() -> std::io::Result<()> {
    let file = File::open("data.txt")?;
    let reader = BufReader::new(file);
 
    for line in reader.lines() {
        println!("{}", line?);
    }
    Ok(())
}

차이점:

  • Java: try-with-resources 필수, 누락 시 리소스 누수
  • Rust: RAII로 자동 리소스 해제, try 문 불필요
  • Rust: Result 타입으로 체크드 예외보다 간결

성능 최적화 전략

버퍼 크기 튜닝

use std::fs::File;
use std::io::{BufReader, BufWriter, Read, Write};
use std::time::Instant;
 
fn benchmark_buffer_sizes() -> std::io::Result<()> {
    let sizes = [1024, 4096, 8192, 16384, 65536]; // 1KB ~ 64KB
 
    for size in sizes {
        let start = Instant::now();
 
        let file = File::open("large_file.bin")?;
        let mut reader = BufReader::with_capacity(size, file);
        let mut buffer = vec![0u8; size];
 
        let mut total = 0;
        loop {
            let n = reader.read(&mut buffer)?;
            if n == 0 { break; }
            total += n;
        }
 
        let duration = start.elapsed();
        println!("버퍼 크기 {}: {:?} ({} 바이트)", size, duration, total);
    }
 
    Ok(())
}

권장 버퍼 크기:

  • HDD: 64KB ~ 128KB
  • SSD: 8KB ~ 16KB
  • 네트워크 파일: 4KB ~ 8KB

병렬 파일 처리

use rayon::prelude::*;
use std::fs;
use std::path::PathBuf;
 
fn process_files_parallel(paths: Vec<PathBuf>) -> Vec<usize> {
    paths
        .par_iter() // Rayon의 병렬 이터레이터
        .filter_map(|path| {
            fs::read_to_string(path)
                .ok()
                .map(|content| content.len())
        })
        .collect()
}

Zero-Copy I/O

use std::fs::File;
use std::io::{self, copy};
 
fn zero_copy_transfer(src: &str, dst: &str) -> io::Result<u64> {
    let mut src_file = File::open(src)?;
    let mut dst_file = File::create(dst)?;
 
    // sendfile 시스템 콜 활용 (Linux)
    copy(&mut src_file, &mut dst_file)
}

베스트 프랙티스

1. RAII 활용

// 좋음: 스코프로 자동 리소스 해제
fn good_example() -> io::Result<()> {
    let file = File::open("data.txt")?;
    let reader = BufReader::new(file);
    // 사용
    Ok(())
} // file과 reader 자동 해제
 
// 나쁨: 수동 관리 (C 스타일)
fn bad_example() {
    let file = File::open("data.txt");
    // ...
    // drop(file)를 잊어버릴 수 있음
}

2. 에러 처리 체이닝

use std::fs::File;
use std::io::{self, Read};
 
// 좋음: ? 연산자로 간결하게
fn read_config() -> io::Result<String> {
    let mut content = String::new();
    File::open("config.toml")?.read_to_string(&mut content)?;
    Ok(content)
}
 
// 나쁨: 중첩된 match
fn read_config_verbose() -> io::Result<String> {
    match File::open("config.toml") {
        Ok(mut file) => {
            let mut content = String::new();
            match file.read_to_string(&mut content) {
                Ok(_) => Ok(content),
                Err(e) => Err(e),
            }
        }
        Err(e) => Err(e),
    }
}

3. 버퍼 재사용

use std::fs::File;
use std::io::{BufReader, Read};
 
// 좋음: 버퍼 재사용
fn process_files_efficient(paths: &[&str]) -> io::Result<()> {
    let mut buffer = String::new();
 
    for path in paths {
        buffer.clear(); // 버퍼 재사용
        File::open(path)?.read_to_string(&mut buffer)?;
        process(&buffer);
    }
    Ok(())
}
 
// 나쁨: 매번 새 버퍼 할당
fn process_files_wasteful(paths: &[&str]) -> io::Result<()> {
    for path in paths {
        let mut buffer = String::new(); // 매번 할당
        File::open(path)?.read_to_string(&mut buffer)?;
        process(&buffer);
    }
    Ok(())
}
 
fn process(_data: &str) {}

트러블슈팅 가이드

문제 1: "Permission denied"

에러:

Error: Os { code: 13, kind: PermissionDenied, message: "Permission denied" }

원인: 파일 또는 디렉터리 권한 부족

해결:

use std::fs;
 
match fs::read_to_string("protected.txt") {
    Ok(content) => println!("{}", content),
    Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
        eprintln!("권한 없음. sudo 또는 권한 변경 필요");
    }
    Err(e) => eprintln!("다른 에러: {}", e),
}

문제 2: "Too many open files"

에러:

Error: Os { code: 24, kind: Other, message: "Too many open files" }

원인: 파일 디스크립터 한도 초과

해결:

// 나쁨: 파일을 닫지 않고 계속 열기
fn bad_open_files() {
    for i in 0..10000 {
        let _file = File::open(format!("file{}.txt", i));
        // 파일이 닫히지 않음!
    }
}
 
// 좋음: 스코프로 파일 자동 해제
fn good_open_files() -> io::Result<()> {
    for i in 0..10000 {
        {
            let _file = File::open(format!("file{}.txt", i))?;
            // 스코프 종료 시 파일 자동 해제
        }
    }
    Ok(())
}

문제 3: 대용량 파일 메모리 부족

증상: read_to_string()으로 대용량 파일 읽기 시 OOM

해결:

// 나쁨: 전체 파일을 메모리에 로드
fn bad_read_large_file(path: &str) -> io::Result<()> {
    let content = fs::read_to_string(path)?; // 10GB 파일이면 OOM!
    process_all(&content);
    Ok(())
}
 
// 좋음: 스트리밍 처리
fn good_read_large_file(path: &str) -> io::Result<()> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);
 
    for line in reader.lines() {
        process_line(&line?); // 한 줄씩 처리
    }
    Ok(())
}
 
fn process_all(_data: &str) {}
fn process_line(_line: &str) {}

문제 4: 비동기 I/O 성능 저하

증상: 비동기인데 동기보다 느림

원인: 작은 파일을 비동기로 처리하면 런타임 오버헤드가 더 큼

해결:

// 작은 파일 (<1MB): 동기식 사용
fn process_small_files(paths: &[&str]) -> io::Result<()> {
    for path in paths {
        let content = std::fs::read_to_string(path)?;
        process(&content);
    }
    Ok(())
}
 
// 대용량 또는 많은 파일: 비동기 사용
async fn process_many_files(paths: Vec<String>) -> tokio::io::Result<()> {
    let handles: Vec<_> = paths
        .into_iter()
        .map(|path| {
            tokio::spawn(async move {
                tokio::fs::read_to_string(&path).await
            })
        })
        .collect();
 
    for handle in handles {
        let content = handle.await??;
        process(&content);
    }
    Ok(())
}
 
fn process(_data: &str) {}

정리

Rust의 파일 I/O는 다음과 같은 핵심 원칙을 기반으로 합니다:

  1. 소유권 시스템: RAII로 자동 리소스 해제, 누수 방지
  2. Result 타입: 에러 처리를 강제하여 런타임 크래시 방지
  3. 동기/비동기 선택:
    • 작은 파일, 단순 작업: 동기식 (std::fs)
    • 대량 파일, 높은 동시성: 비동기식 (tokio::fs)
  4. 버퍼링: BufReader/BufWriter로 성능 최적화
  5. 타입 안전성: 컴파일 타임에 잘못된 I/O 패턴 차단

Rust의 파일 I/O는 안전성과 성능을 모두 제공하며, 사용 사례에 맞는 적절한 방식을 선택하여 최적의 결과를 얻을 수 있습니다.

관련 아티클