C++ 파일 입출력 | ifstream·ofstream으로 "파일 열기 실패" 에러 처리까지

C++ 파일 입출력 | ifstream·ofstream으로 "파일 열기 실패" 에러 처리까지

이 글의 핵심

C++ 파일 입출력에 대한 실전 가이드입니다. ifstream·ofstream으로 등을 예제와 함께 상세히 설명합니다.

들어가며: “파일이 안 열려요”

설정 파일을 못 읽어서 프로그램이 죽었다

게임 설정을 저장하는 기능을 만들고 있었습니다. 하지만 파일이 없거나 권한이 없을 때 프로그램이 크래시했습니다.

문제의 코드에서는 std::ifstream file(“settings.txt”)로 파일을 열기만 하고, 열기 성공 여부를 확인하지 않은 채 file >> key >> value로 읽습니다. 파일이 없거나 권한이 없으면 스트림(데이터를 순서대로 읽거나 쓰는 추상적인 흐름. 예를 들면 파일·키보드 입력을 같은 방식으로 다룸)의 fail 비트(스트림이 오류 상태일 때 설정되는 플래그)가 설정된 상태로 남고, keyvalue에는 쓰레기 값이 들어갈 수 있어 applySettings에 잘못된 값이 전달되거나 접근 오류가 날 수 있습니다. 실무에서는 “파일이 없다”는 상황이 자주 발생하므로, is_open() 또는 if (!file)로 한 번 검사한 뒤 읽는 습관이 중요합니다.

void loadSettings() {
    std::ifstream file("settings.txt");

    std::string key, value;
    file >> key >> value;  // ❌ 파일이 안 열렸는데 읽으려고 함

    applySettings(key, value);
}

위 코드 설명: 파일 열기 성공 여부를 검사하지 않고 file >> key >> value를 하면, 파일이 없거나 열기 실패 시 fail 비트가 설정된 상태에서 읽기가 진행되어 key·value에 쓰레기 값이 들어갈 수 있습니다. applySettings에 잘못된 값이 전달되거나 크래시로 이어질 수 있으므로, is_open() 또는 !file로 한 번 확인한 뒤 읽어야 합니다.

원인:

  • 파일 열기 실패 여부를 확인 안 함
  • 파일이 없어도 file >> key는 실행됨 (쓰레기 값)
  • 에러 처리 없음

스트림은 열기 실패 시 fail 비트가 설정되므로, 읽기 전에 is_open() 또는 !file로 한 번 확인하는 습관이 중요합니다. 경로 오타, 권한, 디스크 부족 등은 실무에서 자주 나오므로, 파일 I/O 직후에 검사해 두면 디버깅이 훨씬 수월해집니다.

텍스트 모드 vs 바이너리 모드: Windows에서는 기본이 텍스트 모드라서 \n\r\n으로 변환될 수 있습니다. 바이너리 파일(이미지, 압축 등)을 다룰 때는 std::ios::binary로 열어야 바이트가 바뀌지 않습니다. Linux/macOS에서는 텍스트/바이너리 구분이 없어서 동작이 같지만, 크로스 플랫폼 코드에서는 바이너리일 때 명시적으로 binary 플래그를 쓰는 편이 안전합니다.

경로 참고: "settings.txt"처럼 상대 경로는 실행 시 현재 작업 디렉토리 기준으로 해석됩니다. 다른 폴더에서 실행하면 파일을 못 찾을 수 있으므로, 배포 시에는 설정 파일 경로를 고정하거나 인자/환경 변수로 받는 방식을 고려하면 좋습니다.

해결 후:

void loadSettings() {
    std::ifstream file("settings.txt");

    if (!file.is_open()) {
        std::cerr << "Cannot open settings.txt\n";
        useDefaultSettings();
        return;
    }

    std::string key, value;
    while (file >> key >> value) {
        applySettings(key, value);
    }

    if (file.bad()) {
        std::cerr << "Error reading file\n";
    }
}

위 코드 설명: is_open()으로 열기 실패 시 에러 메시지를 출력하고 기본 설정으로 돌아갑니다. while (file >> key >> value)는 읽기가 성공하는 동안만 루프를 돌고, 파일 끝이나 오류 시 종료됩니다. 읽기 후 file.bad()로 심각한 오류 여부를 확인하는 패턴입니다.

추가 문제 시나리오: 대용량 로그 파일 처리

시나리오: 10GB 로그 파일을 한 번에 메모리로 읽으려다가 메모리 부족으로 프로세스가 종료됩니다.

원인: std::string content((std::istreambuf_iterator<char>(file)), ...)처럼 전체 파일을 string에 담으면, 파일 크기만큼 메모리가 할당됩니다. 10GB 파일은 10GB 메모리를 요구하므로 OOM(Out of Memory)이 발생할 수 있습니다.

해결: 대용량 파일은 한 줄씩 또는 청크 단위로 읽어 처리합니다. getline으로 줄 단위로 처리하거나, read()로 고정 크기 버퍼를 사용해 반복 읽는 방식이 안전합니다.

추가 문제 시나리오: Windows에서 바이너리 파일이 깨짐

시나리오: 이미지 파일을 복사하는데, Windows에서 복사된 파일이 손상됩니다.

원인: std::ifstream in("image.png")처럼 기본적으로 텍스트 모드로 열면, Windows에서 \n(0x0A)이 \r\n(0x0D 0x0A)으로 변환됩니다. 바이너리 파일의 바이트는 그대로 읽어야 하는데 변환이 일어나면 이미지가 깨집니다.

해결: std::ios::binary 플래그로 열어야 합니다.

std::ifstream in("image.png", std::ios::binary);
std::ofstream out("copy.png", std::ios::binary);

추가 문제 시나리오: 디스크 풀·파일 핸들 누수·인코딩

디스크 풀: file << data는 버퍼에만 쓰이고, flush()/close() 시점에 디스크에 반영됩니다. 디스크가 꽉 찼을 때 쓰기 실패가 발생하므로 flush()!file로 검사합니다.

파일 핸들 누수: 스트림은 소멸 시 자동으로 닫히지만, 전역/멤버로 두고 수동 관리할 때 close()를 빼먹으면 누수가 발생합니다. RAII 래퍼나 스코프 내 선언을 사용합니다.

인코딩 불일치: C++ 스트림은 바이트만 다룹니다. UTF-8 파일을 CP949 콘솔에 출력하면 깨져 보입니다. 인코딩을 맞추거나 iconv·ICU로 변환합니다.

이 글을 읽으면:

  • ifstream, ofstream, fstream의 차이를 이해할 수 있습니다.
  • 파일 열기/닫기와 에러 처리를 할 수 있습니다.
  • 텍스트 파일을 안전하게 읽고 쓸 수 있습니다.
  • 실전에서 자주 겪는 파일 I/O 문제를 해결할 수 있습니다.

목차

  1. 파일 스트림 기초
  2. 파일 읽기 (ifstream)
  3. 파일 쓰기 (ofstream)
  4. 파일 읽기/쓰기 (fstream)
  5. 에러 처리와 상태 확인
  6. 자주 발생하는 문제
  7. 버퍼링과 flush
  8. 모범 사례
  9. 성능 최적화 팁
  10. 프로덕션 패턴

1. 파일 스트림 기초

파일 스트림 아키텍처

flowchart TB
    subgraph input["입력 스트림"]
        I1[파일/키보드] --> I2[ifstream]
        I2 --> I3[프로그램]
    end
    subgraph output["출력 스트림"]
        O1[프로그램] --> O2[ofstream]
        O2 --> O3[파일/콘솔]
    end
    subgraph bidirectional["양방향"]
        B1[파일] <--> B2[fstream]
        B2 <--> B3[프로그램]
    end

위 다이어그램 설명: ifstream은 파일에서 프로그램으로 읽기만, ofstream은 프로그램에서 파일로 쓰기만, fstream은 읽기와 쓰기 모두 가능합니다. 스트림은 데이터를 순서대로 흐르게 하는 추상화입니다.

파일 I/O 시퀀스 다이어그램

sequenceDiagram
    participant P as 프로그램
    participant S as 스트림(버퍼)
    participant F as 파일

    P->>S: open(path)
    S->>F: open()
    F-->>S: 성공/실패
    S-->>P: is_open()

    alt 읽기
        P->>S: read() / getline()
        S->>F: read(버퍼)
        F-->>S: 데이터
        S-->>P: 데이터
    else 쓰기
        P->>S: write() / <<
        S->>S: 버퍼에 저장
        P->>S: flush()
        S->>F: write(버퍼)
        F-->>S: 완료
    end

    P->>S: close()
    S->>F: close()

위 다이어그램 설명: 파일 열기 → 읽기/쓰기 → 버퍼 동작 → flush 시 디스크 반영 → close까지의 흐름을 보여줍니다. 쓰기는 버퍼에 먼저 쌓였다가 flush/close 시점에 디스크에 반영됩니다.

한 번에 복사해 실행 가능한 예제

아래는 파일을 쓰고 다시 읽어서 출력하는 완전한 프로그램입니다. data.txt를 미리 만들 필요 없이, 복사해 붙여넣고 빌드·실행하면 됩니다.

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o file_io file_io.cpp && ./file_io
#include <fstream>
#include <iostream>
#include <string>

int main() {
    // 1) 파일에 쓰기
    {
        std::ofstream out("data.txt");
        if (out) {
            out << "hello 1\nworld 2\n";
        }
    }
    // 2) 같은 파일 읽기
    std::ifstream in("data.txt");
    if (!in) {
        std::cerr << "Cannot open data.txt\n";
        return 1;
    }
    std::string line;
    while (std::getline(in, line)) {
        std::cout << line << "\n";
    }
    return 0;
}

위 코드 설명: ofstream으로 data.txt에 “hello 1\nworld 2\n”를 쓰고, 스코프를 벗어나면 스트림이 닫힙니다. 그 다음 ifstream으로 같은 파일을 열어, !in으로 열기 실패를 확인한 뒤 getline으로 한 줄씩 읽어 cout에 출력합니다. 파일 쓰기→읽기 흐름을 한 번에 보여주는 예제입니다.

실행 결과: data.txt 에 쓰인 대로 hello 1world 2 가 각각 한 줄씩 출력됩니다.

세 가지 파일 스트림

#include <fstream>

// 읽기 전용
std::ifstream input("data.txt");

// 쓰기 전용
std::ofstream output("result.txt");

// 읽기/쓰기
std::fstream file("config.txt", std::ios::in | std::ios::out);

위 코드 설명: ifstream은 읽기 전용, ofstream은 쓰기 전용, fstream은 읽기와 쓰기 모두 가능합니다. fstream은 열기 모드를 명시할 때 in | out처럼 비트 OR로 지정합니다. 파일 경로만 주면 ifstream/ofstream은 각각 in/out이 기본입니다.

특징:

  • ifstream: input file stream (읽기)
  • ofstream: output file stream (쓰기)
  • fstream: file stream (읽기/쓰기)

파일 열기 모드

std::ios::in      // 읽기 (기본: ifstream)
std::ios::out     // 쓰기 (기본: ofstream)
std::ios::app     // 추가 (파일 끝에 덧붙임)
std::ios::trunc   // 기존 내용 삭제 (기본: ofstream)
std::ios::binary  // 바이너리 모드

위 코드 설명: in은 읽기, out은 쓰기, app은 파일 끝에 추가(덮어쓰지 않음), trunc는 기존 내용 삭제 후 쓰기, binary는 바이트 변환 없이 그대로 입출력합니다. ofstream 기본은 out|trunc라 기존 파일을 비우고 씁니다.

예제:

// 파일 끝에 추가
std::ofstream log("app.log", std::ios::app);
log << "New log entry\n";

// 읽기+쓰기
std::fstream file("data.txt", std::ios::in | std::ios::out);

// 바이너리 쓰기
std::ofstream bin("data.bin", std::ios::binary);

위 코드 설명: ios::app으로 열면 기존 내용 뒤에 덧붙이고, in|out으로 fstream을 열면 읽기와 쓰기가 모두 가능합니다. binary로 열면 Windows에서도 \n이 \r\n으로 변환되지 않아 이미지·압축 등 바이너리 파일에 적합합니다.


2. 파일 읽기 (ifstream)

기본 읽기

#include <fstream>
#include <iostream>
#include <string>

int main() {
    std::ifstream file("data.txt");

    if (!file.is_open()) {
        std::cerr << "Cannot open file\n";
        return 1;
    }

    std::string word;
    while (file >> word) {
        std::cout << word << "\n";
    }

    file.close();  // 자동으로 닫히지만 명시적으로 닫기 가능
}

위 코드 설명: ifstream으로 파일을 열고 is_open()으로 열기 실패를 확인합니다. file >> word는 공백으로 구분된 단어를 하나씩 읽고, 스트림이 유효한 동안만 루프가 돌아 파일 끝에서 자연스럽게 끝납니다. 소멸 시 스트림이 닫히지만 필요하면 close()를 명시할 수 있습니다.

참고: data.txt가 없으면 “Cannot open file”이 나옵니다. 위 한 번에 복사해 실행 가능한 예제로 먼저 파일을 만든 뒤 실행하면 됩니다.

한 줄씩 읽기

std::ifstream file("log.txt");

std::string line;
while (std::getline(file, line)) {
    std::cout << line << "\n";
}

위 코드 설명: getline(file, line)은 개행 문자까지 한 줄을 읽어 line에 넣고, 개행은 버립니다. while (getline(…))으로 파일 끝까지 한 줄씩 읽을 수 있어, 로그나 설정 파일처럼 줄 단위로 처리할 때 자주 씁니다.

전체 파일 읽기

#include <sstream>

std::ifstream file("data.txt");

// 방법 1: stringstream 사용
std::stringstream buffer;
buffer << file.rdbuf();
std::string content = buffer.str();

// 방법 2: iterator 사용
std::string content((std::istreambuf_iterator<char>(file)),
                     std::istreambuf_iterator<char>());

위 코드 설명: rdbuf()로 스트림 버퍼 전체를 stringstream에 넣으면 content에 한 번에 문자열로 담을 수 있습니다. istreambuf_iterator로 [시작, 끝) 범위를 초기화 리스트처럼 쓰면 같은 내용을 한 번에 string으로 만들 수 있습니다. 주의: 작은 파일에만 사용하세요. 대용량 파일은 메모리 부족을 유발합니다.

CSV(Comma-Separated Values, 쉼표 구분 값) 파일 파싱

#include <fstream>
#include <sstream>
#include <string>
#include <vector>

struct Person { std::string name; int age; std::string city; };

std::vector<Person> loadCSV(const std::string& filename) {
    std::vector<Person> people;
    std::ifstream file(filename);
    if (!file) return people;

    std::string line;
    std::getline(file, line);  // 헤더 스킵
    while (std::getline(file, line)) {
        std::stringstream ss(line);
        Person p;
        std::getline(ss, p.name, ',');
        ss >> p.age;
        ss.ignore();
        std::getline(ss, p.city);
        people.push_back(p);
    }
    return people;
}

위 코드 설명: getline으로 한 줄씩 읽고, stringstream으로 쉼표 구분 파싱. getline(ss, p.name, ’,‘)로 name, ss >> p.age로 숫자, ignore() 후 getline으로 city를 읽습니다.

완전한 파일 복사 예제 (에러 처리 포함)

#include <cerrno>
#include <cstring>
#include <fstream>
#include <iostream>

bool copyFile(const std::string& src, const std::string& dst) {
    std::ifstream in(src, std::ios::binary);
    if (!in) { std::cerr << "Cannot open: " << src << " - " << std::strerror(errno) << "\n"; return false; }
    std::ofstream out(dst, std::ios::binary);
    if (!out) { std::cerr << "Cannot create: " << dst << " - " << std::strerror(errno) << "\n"; return false; }
    out << in.rdbuf();
    if (!out) { std::cerr << "Write failed\n"; return false; }
    return true;
}

int main(int argc, char* argv[]) {
    if (argc != 3) { std::cerr << "Usage: " << argv[0] << " <source> <destination>\n"; return 1; }
    return copyFile(argv[1], argv[2]) ? 0 : 1;
}

위 코드 설명: rdbuf()로 입력 스트림 전체를 출력 스트림으로 복사. binary 모드로 열어 Windows에서도 바이너리 파일이 깨지지 않습니다. errno/strerror로 디버깅 메시지 제공.

바이너리 I/O: read()와 write()

텍스트가 아닌 바이트 단위로 읽고 쓸 때는 read()write()를 사용합니다. 구조체 직렬화, 이미지 처리 등에 쓰입니다.

#include <fstream>
#include <iostream>

struct Record { int id; double value; char name[32]; };

bool writeRecord(const std::string& path, const Record& rec) {
    std::ofstream out(path, std::ios::binary | std::ios::app);
    if (!out) return false;
    out.write(reinterpret_cast<const char*>(&rec), sizeof(rec));
    return out.good();
}

bool readRecord(const std::string& path, size_t index, Record& rec) {
    std::ifstream in(path, std::ios::binary);
    if (!in) return false;
    in.seekg(index * sizeof(Record), std::ios::beg);
    in.read(reinterpret_cast<char*>(&rec), sizeof(Record));
    return in.gcount() == sizeof(Record);
}

int main() {
    Record r = {1, 3.14, "test"};
    if (!writeRecord("data.bin", r)) return 1;
    Record rec;
    if (readRecord("data.bin", 0, rec))
        std::cout << rec.id << " " << rec.value << " " << rec.name << "\n";
    return 0;
}

위 코드 설명: write()는 메모리 블록을 그대로 파일에 쓰고, read()는 파일에서 바이트를 읽어 메모리에 넣습니다. reinterpret_cast로 구조체를 char*로 변환. gcount()는 마지막 read()에서 실제 읽은 바이트 수. seekg()로 인덱스 위치로 이동합니다. 주의: 구조체에 std::string이나 포인터가 있으면 바이너리 직렬화가 안전하지 않습니다. #pragma pack이나 고정 크기 타입 사용을 권장합니다.

청크 단위 바이너리 읽기 (대용량 파일)

std::ifstream in("large.bin", std::ios::binary);
const size_t CHUNK = 64 * 1024;
std::vector<char> buffer(CHUNK);

while (in.read(buffer.data(), CHUNK) || in.gcount() > 0) {
    processChunk(buffer.data(), in.gcount());  // gcount() = 실제 읽은 바이트
}

위 코드 설명: 64KB씩 읽어 처리합니다. EOF에서 read()가 짧게 읽으면 gcount()가 실제 바이트 수를 반환하므로, 이 값을 사용해야 합니다.

완전한 바이너리 I/O 예제 (에러 처리·RAII·버퍼링 포함)

바이너리 파일 복사를 read/write로 청크 단위 처리하고, 에러 처리·RAII·flush 검사를 모두 포함한 예제입니다.

#include <cerrno>
#include <cstring>
#include <fstream>
#include <iostream>
#include <vector>

bool copyFileBinary(const std::string& src, const std::string& dst) {
    const size_t CHUNK = 64 * 1024;
    std::vector<char> buffer(CHUNK);

    std::ifstream in(src, std::ios::binary);
    if (!in) {
        std::cerr << "Cannot open: " << src << " - " << std::strerror(errno) << "\n";
        return false;
    }
    std::ofstream out(dst, std::ios::binary);
    if (!out) {
        std::cerr << "Cannot create: " << dst << " - " << std::strerror(errno) << "\n";
        return false;
    }

    while (in.read(buffer.data(), CHUNK) || in.gcount() > 0) {
        out.write(buffer.data(), in.gcount());
        if (!out) { std::cerr << "Write failed\n"; return false; }
    }
    if (in.bad()) { std::cerr << "Read error\n"; return false; }

    out.flush();
    if (!out) { std::cerr << "Flush failed (disk full?)\n"; return false; }
    return true;
}

int main(int argc, char* argv[]) {
    if (argc != 3) {
        std::cerr << "Usage: " << argv[0] << " <source> <destination>\n";
        return 1;
    }
    return copyFileBinary(argv[1], argv[2]) ? 0 : 1;
}

위 코드 설명: read/write로 64KB 청크 복사, gcount()로 실제 읽은 바이트 사용, flush()!out으로 디스크 풀 검사, errno/strerror로 에러 메시지. RAII로 스트림이 스코프를 벗어나면 자동 close됩니다.


3. 파일 쓰기 (ofstream)

기본 쓰기

std::ofstream file("output.txt");

if (!file.is_open()) {
    std::cerr << "Cannot create file\n";
    return 1;
}

file << "Hello, World!\n";
file << "Number: " << 42 << "\n";

위 코드 설명: ofstream으로 파일을 열고 is_open()으로 생성(열기) 성공 여부를 확인합니다. cout처럼 <<로 문자열과 숫자를 쓸 수 있고, 스트림이 닫힐 때까지 쓴 내용이 파일에 반영됩니다. 파일이 이미 있으면 기본 모드(trunc)에서 내용이 지워진 뒤 덮어씁니다.

파일 끝에 추가

std::ofstream log("app.log", std::ios::app);

log << "[" << getCurrentTime() << "] ";
log << "Application started\n";

위 코드 설명: ios::app으로 열면 기존 파일 내용을 유지한 채 끝에만 덧붙입니다. 로그처럼 계속 추가만 할 때 사용하면 이전 로그가 지워지지 않습니다.

CSV(Comma-Separated Values) 파일 생성

void saveCSV(const std::vector<Person>& people, const std::string& filename) {
    std::ofstream file(filename);

    if (!file.is_open()) {
        std::cerr << "Cannot create file\n";
        return;
    }

    // 헤더
    file << "Name,Age,City\n";

    // 데이터
    for (const auto& p : people) {
        file << p.name << "," << p.age << "," << p.city << "\n";
    }
}

위 코드 설명: 헤더 줄(“Name,Age,City\n”)을 먼저 쓰고, 각 Person을 “이름,나이,도시\n” 형식으로 한 줄씩 씁니다. ofstream은 파일이 없으면 생성하고, 있으면 기본적으로 내용을 비운 뒤 씁니다. CSV를 쓰는 기본 패턴입니다.

포맷팅

#include <iomanip>

std::ofstream file("report.txt");

// 정수 포맷
file << std::setw(10) << 123 << "\n";        // "       123"
file << std::setfill('0') << std::setw(5) << 42 << "\n";  // "00042"

// 실수 포맷
file << std::fixed << std::setprecision(2) << 3.14159 << "\n";  // "3.14"
file << std::scientific << 1234.5 << "\n";   // "1.23e+03"

// 정렬
file << std::left << std::setw(10) << "Name" << "Age\n";
file << std::left << std::setw(10) << "Alice" << 25 << "\n";

위 코드 설명: iomanip의 setw(n)은 최소 너비, setfill(c)는 빈 칸 채울 문자, setprecision(n)은 소수 자리 수입니다. fixed는 고정 소수점, scientific은 지수 표기입니다. left는 왼쪽 정렬이라 보고서·로그 포맷을 맞출 때 유용합니다.


4. 파일 읽기/쓰기 (fstream)

설정 파일 읽고 수정하기

#include <fstream>
#include <map>
#include <string>

class ConfigFile {
    std::map<std::string, std::string> data;
    std::string filename;
public:
    ConfigFile(const std::string& file) : filename(file) { load(); }
    void load() {
        std::ifstream f(filename);
        for (std::string line; std::getline(f, line); ) {
            size_t p = line.find('=');
            if (p != std::string::npos) data[line.substr(0, p)] = line.substr(p + 1);
        }
    }
    void save() {
        std::ofstream f(filename);
        for (const auto& [k, v] : data) f << k << "=" << v << "\n";
    }
    std::string get(const std::string& k) const {
        auto it = data.find(k);
        return it != data.end() ? it->second : "";
    }
    void set(const std::string& k, const std::string& v) { data[k] = v; }
};

위 코드 설명: load()에서 getline으로 key=value를 map에 넣고, save()에서 ofstream으로 파일에 씁니다. get/set으로 메모리상 설정을 읽고 바꾼 뒤 save()로 반영하는 설정 파일 패턴입니다.


5. 에러 처리와 상태 확인

에러 처리 흐름

flowchart TD
    A[파일 열기] --> B{is_open?}
    B -->|No| C[에러 처리/기본값]
    B -->|Yes| D[읽기/쓰기]
    D --> E{good?}
    E -->|No| F{fail?}
    F -->|Yes| G[형식 오류]
    F -->|No| H{bad?}
    H -->|Yes| I[심각한 오류]
    H -->|No| J{eof?}
    J -->|Yes| K[정상 종료]

위 다이어그램 설명: 파일 열기 후 is_open()으로 성공 여부를 확인합니다. 읽기/쓰기 중 good()이 false가 되면 fail(), bad(), eof()로 원인을 구분해 처리합니다.

파일 상태 확인

std::ifstream file("data.txt");

// 파일이 열렸는지
if (!file.is_open()) {
    std::cerr << "Cannot open file\n";
}

// 읽기 가능한지
if (file.good()) {
    std::cout << "File is good\n";
}

// EOF(End Of File, 파일 끝) 도달했는지
if (file.eof()) {
    std::cout << "Reached end of file\n";
}

// 읽기 실패했는지
if (file.fail()) {
    std::cerr << "Read operation failed\n";
}

// 심각한 에러인지
if (file.bad()) {
    std::cerr << "Critical error\n";
}

위 코드 설명: is_open()은 파일이 실제로 열렸는지, good()은 스트림이 정상인지, eof()는 파일 끝에 도달했는지, fail()은 읽기/쓰기 실패(형식 오류 등)인지, bad()는 복구 불가한 오류인지 확인합니다. 읽기 전후로 이 플래그들을 보면 원인 파악에 도움이 됩니다.

상태 플래그

함수의미
good()모든 상태 OK
eof()파일 끝 도달
fail()읽기/쓰기 실패
bad()심각한 에러

clear()로 상태 초기화

std::ifstream file("data.txt");
int value;
file >> value;  // 숫자가 아닌 문자가 있으면 fail 비트 설정

if (file.fail()) {
    file.clear();  // fail 비트 초기화
    file.ignore(256, '\n');  // 잘못된 줄 스킵
    // 다음 줄부터 다시 읽기 가능
}

위 코드 설명: fail()이 설정되면 이후 읽기가 모두 실패합니다. clear()로 상태 플래그를 초기화한 뒤 ignore()로 잘못된 입력을 건너뛰면, 다음 줄부터 다시 읽을 수 있습니다.

안전한 파일 읽기 패턴

bool readFile(const std::string& filename, std::vector<std::string>& lines) {
    std::ifstream file(filename);

    if (!file.is_open()) {
        std::cerr << "Cannot open: " << filename << "\n";
        return false;
    }

    std::string line;
    while (std::getline(file, line)) {
        lines.push_back(line);
    }

    if (file.bad()) {
        std::cerr << "Error reading: " << filename << "\n";
        return false;
    }

    return true;
}

위 코드 설명: 파일을 열고 열기 실패 시 false를 반환합니다. getline으로 한 줄씩 벡터에 넣고, 루프 후 bad()로 읽기 중 심각한 오류가 있었는지 확인해 실패 시 false를 반환합니다. 호출자가 열기·읽기 오류를 구분해 처리할 수 있는 안전한 패턴입니다.

RAII로 자동 닫기

#include <fstream>
#include <stdexcept>
#include <string>

class FileGuard {
    std::ofstream file;

public:
    FileGuard(const std::string& filename) : file(filename) {
        if (!file.is_open()) {
            throw std::runtime_error("Cannot open file");
        }
    }

    ~FileGuard() {
        if (file.is_open()) {
            file.close();
        }
    }

    std::ofstream& get() { return file; }
};

void writeLog(const std::string& message) {
    FileGuard guard("app.log");
    guard.get() << message << "\n";
    // 자동으로 닫힘
}

위 코드 설명: FileGuard는 생성자에서 파일을 열고 실패 시 예외를 던지며, 소멸자에서 is_open()이면 close()를 호출합니다. writeLog에서 guard 스코프를 벗어나면 소멸자가 호출되어 파일이 닫히므로, 예외가 나도 파일이 열린 채로 남는 일을 막을 수 있는 RAII 패턴입니다.


6. 자주 발생하는 문제

문제 1: “파일을 찾을 수 없습니다” (경로 오류)

증상: is_open()이 false를 반환하지만, 파일은 실제로 존재합니다.

원인:

  • 상대 경로는 실행 시 작업 디렉토리 기준입니다. ./myapp을 다른 폴더에서 실행하면 data.txt를 찾지 못합니다.
  • 경로 구분자: Windows는 \, Linux/macOS는 /. C++17 std::filesystem::path를 쓰면 자동 처리됩니다.

해결:

#include <filesystem>
#include <fstream>

namespace fs = std::filesystem;

// 실행 파일 기준 상대 경로
fs::path exeDir = fs::current_path();
fs::path configPath = exeDir / "config" / "settings.txt";

std::ifstream file(configPath);

문제 2: Windows에서 바이너리 파일이 손상됨

증상: 이미지·ZIP 등을 복사했는데 복사본이 깨집니다.

원인: 텍스트 모드(기본)에서 \n\r\n 변환이 발생합니다.

해결:

// ❌ 잘못된 방법
std::ifstream in("image.png");

// ✅ 올바른 방법
std::ifstream in("image.png", std::ios::binary);
std::ofstream out("copy.png", std::ios::binary);

문제 3: getline이 마지막 빈 줄을 읽지 않음

증상: 파일 끝에 빈 줄이 있는데 getline 루프에서 무시됩니다.

원인: while (getline(file, line))은 EOF에 도달하면 false를 반환해 루프가 끝납니다. 마지막 줄이 개행으로 끝나면 그 줄은 읽히지만, 그 다음 getline 호출에서 EOF를 만나 false가 됩니다. 빈 줄 자체는 정상적으로 읽힙니다.

참고: “마지막 빈 줄이 무시된다”고 느끼는 경우는, Windows에서 \r\n으로 끝나는 줄을 읽을 때 \r이 line에 남아 있을 수 있습니다. 이때는 if (!line.empty() && line.back() == '\r') line.pop_back();으로 제거할 수 있습니다.

문제 4: 파일 쓰기 후 내용이 비어 있음

증상: file << "data" 후 파일을 열어보면 비어 있습니다.

원인: 스트림이 버퍼링합니다. close() 전에 프로그램이 비정상 종료되면 버퍼에 남은 데이터가 디스크에 쓰이지 않습니다.

해결:

file << "important data\n";
file.flush();  // 즉시 디스크에 반영 (선택)
// 또는 스코프를 벗어나 close()가 호출되면 자동 flush

문제 5: fstream으로 읽기 후 쓰기 시 위치 오류

증상: fstream으로 읽은 뒤 쓰기를 하면 예상한 위치가 아닙니다.

원인: 읽기/쓰기 후 파일 위치 지정자가 이동합니다. 읽기와 쓰기 모드 전환 시 seekg()/seekp()로 위치를 명시해야 합니다.

해결:

std::fstream file("data.txt", std::ios::in | std::ios::out);
std::string line;
std::getline(file, line);  // 읽기 후 위치가 이동함

// 쓰기 전에 위치 설정
file.seekp(0, std::ios::end);  // 파일 끝으로
file << "appended\n";

문제 6: 권한 거부 (Permission denied)

증상: is_open()이 false이고, errno가 EACCES(13)입니다.

원인: 읽기 전용 파일을 쓰기 모드로 열거나, 디렉토리에 쓰기 권한이 없거나, 파일이 다른 프로세스에 의해 잠겨 있습니다.

해결:

#include <cerrno>
#include <cstring>

std::ofstream file("readonly.txt");
if (!file) {
    if (errno == EACCES) {
        std::cerr << "Permission denied\n";
    } else {
        std::cerr << std::strerror(errno) << "\n";
    }
}

문제 7: read() 후 gcount()를 사용하지 않음

증상: read()로 읽은 뒤 실제 읽은 바이트 수를 모르고 sizeof만 믿어서, EOF에서 짧게 읽은 경우 잘못된 데이터를 처리합니다.

원인: read(buf, 1024)가 EOF에서 500바이트만 읽으면 gcount()는 500을 반환합니다. sizeof나 요청 크기를 그대로 쓰면 나머지 524바이트는 이전 버퍼 내용(쓰레기)입니다.

해결:

std::ifstream in("data.bin", std::ios::binary);
char buf[1024];
while (in.read(buf, sizeof(buf)) || in.gcount() > 0) {
    size_t bytes = in.gcount();  // 실제 읽은 바이트 수
    processChunk(buf, bytes);
}

문제 8: fstream을 in|out으로 열었는데 파일이 없음

증상: std::fstream f("new.txt", std::ios::in | std::ios::out)로 열었는데 is_open()이 false입니다.

원인: in|out만 쓰면 기존 파일이 있어야 열립니다. 새 파일을 만들려면 out을 포함하되, 없을 때 생성하려면 in|out|trunc 또는 out|truncin을 추가하는 식으로 조합해야 합니다. 또는 in|out|app으로 끝에 추가 모드로 열 수 있습니다.

해결:

// 기존 파일이 없으면 생성 후 읽기/쓰기
std::fstream f("data.txt", std::ios::in | std::ios::out | std::ios::trunc);
// 또는 먼저 ofstream으로 생성한 뒤 fstream으로 열기

문제 9: 동일 파일을 읽기·쓰기 스트림으로 동시에 열기

증상: 같은 파일을 ifstreamofstream으로 동시에 열었는데, 한쪽에서 쓴 내용이 다른 쪽에서 바로 보이지 않습니다.

원인: 각 스트림은 자체 버퍼를 갖습니다. 한쪽에서 쓴 뒤 flush()/close()하지 않으면, 다른 쪽은 버퍼된 이전 내용을 읽을 수 있습니다. 또한 플랫폼에 따라 동시 열기 동작이 제한될 수 있습니다.

해결: 쓰기 완료 후 flush()·close()를 하고 읽기 스트림을 새로 열거나, fstream 하나로 seekg/seekp로 위치를 옮겨가며 사용합니다.

문제 10: 디스크 풀·구조체 패딩

디스크 풀: flush()/close() 시점에 실제 디스크 쓰기가 일어나며, 그때 ENOSPC 에러가 발생합니다. flush()!file 검사를 하지 않으면 실패를 놓칩니다.

구조체 패딩: Windows에서 저장한 .bin을 Linux에서 읽으면 필드가 어긋날 수 있습니다. #pragma pack(1)로 패딩 제거하거나, int32_t 등 고정 크기 타입을 사용하세요. 바이너리 직렬화(#11-2) 참고.


7. 버퍼링과 flush

스트림 버퍼 동작

파일 스트림은 버퍼를 사용해 작은 쓰기를 모아서 한 번에 디스크에 반영합니다. 이렇게 하면 시스템 콜 횟수가 줄어 성능이 좋아지지만, 버퍼가 비워지기 전에 프로그램이 종료되면 데이터가 손실될 수 있습니다.

std::ofstream log("app.log");
log << "Important message\n";  // 아직 버퍼에만 있음
// 프로그램 크래시 시 위 내용이 파일에 안 써질 수 있음

flush()와 endl

// 즉시 디스크에 반영
log << "Critical log\n";
log.flush();

// endl = '\n' + flush (매번 flush는 느릴 수 있음)
log << "Line with endl" << std::endl;

// 버퍼만 비우고 개행은 별도 (성능이 더 좋음)
log << "Line without endl\n";
// 로그 파일은 주기적으로 flush() 호출 권장

위 코드 설명: flush()는 버퍼 내용을 디스크에 즉시 반영합니다. std::endl은 개행 문자를 출력한 뒤 flush()를 호출합니다. 로그처럼 중요 데이터는 주기적으로 flush()를 호출해 크래시 시에도 최대한 보존합니다. 단, 매 줄마다 endl을 쓰면 성능이 떨어질 수 있어, 배치 단위로 flush()하는 편이 좋습니다.

버퍼 크기 설정 (pubsetbuf)

#include <fstream>

const size_t BUF_SIZE = 256 * 1024;  // 256KB
char inbuf[BUF_SIZE];
char outbuf[BUF_SIZE];

std::ifstream in;
std::ofstream out;

// open() 전에 pubsetbuf 호출해야 함
in.rdbuf()->pubsetbuf(inbuf, BUF_SIZE);
out.rdbuf()->pubsetbuf(outbuf, BUF_SIZE);

in.open("large.bin", std::ios::binary);
out.open("output.bin", std::ios::binary);

// 이제 읽기/쓰기 시 시스템 콜 횟수가 줄어듦

위 코드 설명: pubsetbuf()는 스트림의 내부 버퍼를 사용자 제공 버퍼로 교체합니다. 주의: open() 호출 전에 설정해야 하므로, 스트림을 기본 생성한 뒤 pubsetbuf를 호출하고 open()합니다. 대용량 순차 I/O에서 버퍼를 키우면 시스템 콜이 줄어 성능이 좋아질 수 있습니다.


8. 모범 사례

파일 I/O 체크리스트

항목내용
열기 검사is_open() 또는 !file로 항상 확인
바이너리이미지·ZIP·직렬화 데이터는 std::ios::binary
경로std::filesystem::path로 크로스 플랫폼 처리
대용량전체 파일을 메모리에 올리지 말고 줄/청크 단위 처리
에러 메시지errno/strerror로 디버깅 정보 제공
RAII스코프 안에서 스트림 선언 또는 래퍼 사용
flush중요 로그·설정은 주기적 flush()

읽기/쓰기 전용 선택

  • 읽기만 필요 → std::ifstream
  • 쓰기만 필요 → std::ofstream
  • 읽기+쓰기 필요 → std::fstream (모드 in|out 명시)

fstream은 읽기/쓰기 전환 시 seekg/seekp 위치 관리가 필요합니다. 단순 읽기나 쓰기만 할 때는 ifstream/ofstream이 더 단순합니다.

예외 vs 에러 코드

예상 가능한 실패(파일 없음)는 bool load(path, out) 형태의 에러 코드, 예상치 못한 실패는 throw std::runtime_error(...)로 처리합니다. 프로젝트 컨벤션에 맞게 선택합니다.

스트림 선택 가이드

용도권장 스트림모드
설정/로그 읽기ifstream기본 또는 binary
로그/데이터 쓰기ofstreamapp(추가) 또는 trunc(덮어쓰기)
설정 파일 읽기+쓰기fstreamin | out
이미지·ZIP·직렬화ifstream/ofstreambinary 필수
대용량 순차 읽기ifstreambinary + 청크 단위 read()

에러 처리 패턴

예상 가능한 실패(파일 없음)는 bool load(path, out) 형태의 에러 코드로, 예상치 못한 실패는 throw std::runtime_error(...)로 처리합니다. 프로젝트 컨벤션에 맞게 선택하세요.


9. 성능 최적화 팁

팁 1: 대용량 파일은 줄 단위 또는 청크로 처리

나쁜 예 (메모리 부족 위험):

std::string content((std::istreambuf_iterator<char>(file)),
                     std::istreambuf_iterator<char>());

좋은 예:

std::string line;
while (std::getline(file, line)) {
    processLine(line);  // 한 줄씩 처리
}

팁 2: 버퍼 크기 조정

기본 버퍼(보통 8KB)보다 큰 블록으로 읽을 때는 rdbuf()->pubsetbuf()로 버퍼를 늘리면 시스템 콜 횟수가 줄어듭니다.

const size_t BUFFER_SIZE = 64 * 1024;  // 64KB
char buffer[BUFFER_SIZE];
std::ifstream file("large.bin", std::ios::binary);
file.rdbuf()->pubsetbuf(buffer, BUFFER_SIZE);

팁 3: 불필요한 seek 피하기

rdbuf()로 스트림 전체를 복사할 때는 순차 읽기가 가장 빠릅니다. 중간에 seekg()를 반복 호출하면 디스크 I/O가 증가합니다.

팁 4: C++17 std::filesystem으로 파일 존재 확인

ifstream으로 열어보기 전에 exists()로 확인하면, 존재하지 않는 파일에 대한 불필요한 시도를 줄일 수 있습니다. 다만 TOCTOU(Time-of-check to time-of-use) 경쟁 조건이 있을 수 있으므로, 최종적으로는 is_open() 검사가 필수입니다.

#include <filesystem>
namespace fs = std::filesystem;

if (fs::exists(path) && fs::is_regular_file(path)) {
    std::ifstream file(path);
    // ...
}

팁 5: 로그 파일은 ios::app으로 열기

trunc 모드로 매번 새로 쓰면 매번 파일을 비우는 오버헤드가 있습니다. 로그처럼 추가만 할 때는 std::ios::app으로 열어 기존 내용을 유지하고 끝에만 덧붙입니다.


10. 프로덕션 패턴

패턴 1: 로그 파일 래퍼 (날짜·레벨 포함)

#include <chrono>
#include <fstream>
#include <iomanip>
#include <sstream>

class Logger {
    std::ofstream file;
public:
    explicit Logger(const std::string& path) : file(path, std::ios::app) {}
    void log(const std::string& level, const std::string& msg) {
        if (file.is_open()) {
            auto t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
            file << "[" << std::put_time(std::localtime(&t), "%Y-%m-%d %H:%M:%S") << "] ["
                 << level << "] " << msg << "\n";
            file.flush();  // 크래시 시에도 최대한 로그 보존
        }
    }
};

패턴 2: 원자적 파일 쓰기 (임시 파일 + rename)

쓰기 중 크래시가 나도 기존 파일이 깨지지 않도록, 임시 파일에 쓰고 성공 시 rename합니다.

#include <filesystem>
#include <fstream>
#include <string>

namespace fs = std::filesystem;

bool atomicWrite(const std::string& path, const std::string& content) {
    fs::path p(path);
    fs::path tmp = p.parent_path() / (p.filename().string() + ".tmp");

    std::ofstream out(tmp, std::ios::binary);
    if (!out) return false;

    out << content;
    out.close();
    if (!out) return false;

    fs::rename(tmp, p);  // 원자적 교체 (같은 파일시스템 내)
    return true;
}

패턴 3: 예외 기반 에러 처리

std::string readFileOrThrow(const std::string& path) {
    std::ifstream file(path);
    if (!file) throw std::runtime_error("Cannot open: " + path);
    std::stringstream buffer;
    buffer << file.rdbuf();
    if (!file) throw std::runtime_error("Read failed: " + path);
    return buffer.str();
}

패턴 4: 파일 존재 및 타입 확인 (C++17)

fs::exists(path)fs::is_regular_file(path)로 디렉토리 등을 제외한 뒤 ifstream으로 열고, rdbuf()로 읽은 뒤 !file로 읽기 실패를 검사합니다.

패턴 5: 스레드 안전 로그 (mutex)

std::mutexlock_guard로 여러 스레드의 동시 쓰기를 직렬화하고, flush()로 크래시 시에도 로그를 보존합니다.

패턴 6: 대용량 파일 원자적 쓰기 (청크 + rename)

대용량 데이터도 임시 파일에 쓰고 성공 시 rename하면, 쓰기 중 크래시가 나도 원본이 깨지지 않습니다. 패턴 2(원자적 파일 쓰기)와 동일한 원리로, 청크 단위 write()로 반복 쓰기 후 flush()·close() 검사 후 rename합니다.

프로덕션 체크리스트

  • 모든 파일 열기 후 is_open() 또는 !file 검사
  • 바이너리 파일은 std::ios::binary 사용
  • 대용량 파일은 줄/청크 단위 처리
  • 로그는 flush() 또는 std::endl로 버퍼 비우기
  • 중요 설정/데이터는 원자적 쓰기(임시 파일 + rename) 고려
  • 경로는 std::filesystem::path로 크로스 플랫폼 처리
  • errno/strerror로 시스템 에러 메시지 로깅

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 바이너리 직렬화 | “게임 세이브 파일 깨졌어요” 엔디안·패딩 문제 해결
  • C++ stringstream | 문자열 파싱·변환·포맷팅
  • C++ LNK2019 | “unresolved external symbol” 링커 에러 원인 5가지와 해결법

이 글에서 다루는 키워드 (관련 검색어)

C++ 파일 입출력, ifstream ofstream, fstream, 파일 읽기 쓰기, 텍스트 파일 바이너리 등으로 검색하시면 이 글이 도움이 됩니다.

정리

항목내용
ifstream읽기 전용
ofstream쓰기 전용
fstream읽기/쓰기
is_open()파일 열림 확인
good()상태 OK
eof()파일 끝
fail()읽기/쓰기 실패
getline()한 줄 읽기

핵심 원칙:

  1. 항상 is_open() 확인
  2. 에러 상태 체크
  3. RAII로 자동 닫기
  4. 예외 처리 고려

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ 파일 입출력(File I/O) 완벽 가이드. ifstream·ofstream·fstream 사용법, 텍스트·바이너리 모드, 파일 열기 실패 에러 처리, getline·read·write, 파일 존재 확인, 실… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

한 줄 요약: ifstream·ofstream으로 파일을 읽고 쓰고, 한 줄씩 읽을 땐 getline을 쓰면 됩니다. 다음으로 바이너리 직렬화(#11-2)를 읽어보면 좋습니다.

이전 글: C++ 실전 가이드 #10-3: STL 알고리즘

다음 글: C++ 실전 가이드 #11-2: 바이너리 파일과 직렬화


관련 글

  • C++ 문자열 기초 완벽 가이드 | std::string·C 문자열·string_view와 실전 패턴
  • C++ 바이너리 직렬화 |
  • C++ 문자열 알고리즘 완벽 가이드 | split·join·trim·replace·정규식 [실전]
  • C++ stringstream | 문자열 파싱·변환·포맷팅
  • C++ 람다 기초 완벽 가이드 | 캡처·mutable·제네릭 람다와 실전 패턴