C++ 파일 입출력 | ifstream·ofstream으로 "파일 열기 실패" 에러 처리까지
이 글의 핵심
C++ 파일 입출력에 대한 실전 가이드입니다. ifstream·ofstream으로 등을 예제와 함께 상세히 설명합니다.
들어가며: “파일이 안 열려요”
설정 파일을 못 읽어서 프로그램이 죽었다
게임 설정을 저장하는 기능을 만들고 있었습니다. 하지만 파일이 없거나 권한이 없을 때 프로그램이 크래시했습니다.
문제의 코드에서는 std::ifstream file(“settings.txt”)로 파일을 열기만 하고, 열기 성공 여부를 확인하지 않은 채 file >> key >> value로 읽습니다. 파일이 없거나 권한이 없으면 스트림(데이터를 순서대로 읽거나 쓰는 추상적인 흐름. 예를 들면 파일·키보드 입력을 같은 방식으로 다룸)의 fail 비트(스트림이 오류 상태일 때 설정되는 플래그)가 설정된 상태로 남고, key와 value에는 쓰레기 값이 들어갈 수 있어 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 문제를 해결할 수 있습니다.
목차
- 파일 스트림 기초
- 파일 읽기 (ifstream)
- 파일 쓰기 (ofstream)
- 파일 읽기/쓰기 (fstream)
- 에러 처리와 상태 확인
- 자주 발생하는 문제
- 버퍼링과 flush
- 모범 사례
- 성능 최적화 팁
- 프로덕션 패턴
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 1 과 world 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++17std::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|trunc 후 in을 추가하는 식으로 조합해야 합니다. 또는 in|out|app으로 끝에 추가 모드로 열 수 있습니다.
해결:
// 기존 파일이 없으면 생성 후 읽기/쓰기
std::fstream f("data.txt", std::ios::in | std::ios::out | std::ios::trunc);
// 또는 먼저 ofstream으로 생성한 뒤 fstream으로 열기
문제 9: 동일 파일을 읽기·쓰기 스트림으로 동시에 열기
증상: 같은 파일을 ifstream과 ofstream으로 동시에 열었는데, 한쪽에서 쓴 내용이 다른 쪽에서 바로 보이지 않습니다.
원인: 각 스트림은 자체 버퍼를 갖습니다. 한쪽에서 쓴 뒤 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 |
| 로그/데이터 쓰기 | ofstream | app(추가) 또는 trunc(덮어쓰기) |
| 설정 파일 읽기+쓰기 | fstream | in | out |
| 이미지·ZIP·직렬화 | ifstream/ofstream | binary 필수 |
| 대용량 순차 읽기 | ifstream | binary + 청크 단위 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::mutex와 lock_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() | 한 줄 읽기 |
핵심 원칙:
- 항상
is_open()확인 - 에러 상태 체크
- RAII로 자동 닫기
- 예외 처리 고려
자주 묻는 질문 (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·제네릭 람다와 실전 패턴