C++ 파일 입출력: ifstream·ofstream 기초부터 에러 처리, 버퍼링, 원자적 쓰기까지
C++ 파일 입출력에서 생기는 문제의 대부분은 두 가지 습관으로 막을 수 있습니다. 파일을 연 직후 if (!file)로 열기 결과를 확인하는 것, 그리고 읽기 루프의 조건을 while (std::getline(file, line))처럼 읽기 연산의 결과로 거는 것입니다. 이 글은 이 두 가지에서 출발해 열기 모드, 텍스트·바이너리 읽기와 쓰기, 스트림 상태 플래그, 버퍼링과 flush, 원자적 쓰기, 그리고 Windows 한글 경로 문제까지 다룹니다.
들어가며: 파일이 안 열렸는데 읽기를 계속하는 코드
설정 파일을 읽는 아래 코드는 파일이 있을 때는 잘 동작하지만, 파일이 없거나 권한이 없으면 조용히 잘못된 동작을 합니다.
그림: 실제 실행 결과 — 없는 파일에서 읽으면 fail 상태가 되고 key와 n은 읽기 전 값 그대로다. 3줄짜리 파일을 while(!eof())로 읽으면 4번 돌고 마지막 줄이 빈 문자열이다. clear()와 ignore()로 잘못된 줄 하나를 건너뛰면 합계 60을 얻는다. 쓰기 스트림을 flush하기 전에는 다른 스트림이 빈 내용을 읽고, 쓰기 권한이 없는 폴더는 errno 13이다. 정수가 0이 되는 것은 형식 오류일 때뿐이고, 열리지 않은 스트림은 값을 건드리지 않는다. (환경: macOS 27.0.1 · Apple clang 21.0.0 · macOS)
void loadSettings() {
std::ifstream file("settings.txt");
std::string key, value;
file >> key >> value; // ❌ 파일이 안 열렸는데 읽으려고 함
applySettings(key, value);
}
열기에 실패한 스트림에는 실패 상태 플래그(failbit)가 설정되고, 이후의 읽기 연산은 아무것도 하지 않고 바로 실패합니다. 예외도 던지지 않으므로 key와 value는 빈 문자열 그대로 남고, applySettings는 빈 키와 빈 값을 받습니다. 정수였다면 열리지 않은 스트림은 변수를 건드리지 않으므로, 초기화하지 않은 변수는 쓰레기 값 그대로이고 초기값을 줬다면 그 값이 읽은 값처럼 쓰입니다. C++11부터 추출 실패 시 0이 들어가는 것은 스트림이 정상이다가 형식 오류로 실패할 때뿐입니다(위 실행 화면). 어느 쪽이든 그럴듯한 값으로 프로그램이 계속 돌게 되어 크래시보다 찾기 어려운 버그입니다.
고친 코드는 열기 결과를 확인하고, 읽기 결과를 루프 조건으로 씁니다.
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";
}
}
while (file >> key >> value)는 두 값을 모두 읽었을 때만 본문을 실행하고, 파일 끝이나 형식 오류에서 멈춥니다. 루프가 끝난 뒤 bad()가 참이면 형식 문제가 아니라 읽기 자체가 실패한(장치 오류 등) 경우입니다.
"settings.txt" 같은 상대 경로는 실행 파일의 위치가 아니라 프로세스의 현재 작업 디렉터리를 기준으로 해석된다는 점도 기억해 둘 만합니다. IDE에서 실행할 때와 터미널에서 다른 폴더로 이동해 실행할 때 결과가 달라지는 흔한 원인입니다. 배포하는 프로그램이라면 설정 파일 경로를 명령행 인자나 환경 변수로 받는 편이 안전합니다.
ifstream, ofstream, fstream과 열기 모드
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은 읽기와 쓰기를 모두 할 수 있는 파일 스트림입니다. 세 클래스 모두 내부에 std::filebuf라는 버퍼를 두고, 작은 읽기·쓰기를 모아 운영체제 호출 횟수를 줄입니다.
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: getline() 또는 read()
S->>F: read(버퍼 크기만큼)
F-->>S: 데이터
S-->>P: 데이터
else 쓰기
P->>S: write() 또는 연산자 <<
S->>S: 버퍼에 저장
P->>S: flush()
S->>F: write(버퍼)
end
P->>S: close()
S->>F: close()
바로 실행해 볼 수 있는 예제
파일을 쓰고 다시 읽어서 출력하는 완전한 프로그램입니다. 미리 파일을 만들 필요가 없습니다.
// 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) {
std::cerr << "Cannot create data.txt\n";
return 1;
}
out << "hello 1\nworld 2\n";
} // 여기서 out이 소멸하며 버퍼를 비우고 파일을 닫음
// 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;
}
쓰기를 블록 안에 둔 데는 이유가 있습니다. 블록이 끝나면서 out이 닫혀야 버퍼에 남은 내용이 파일에 반영되고, 그 뒤에 여는 in이 전체 내용을 볼 수 있습니다. 실행하면 hello 1과 world 2가 한 줄씩 출력됩니다.
파일 열기 모드
std::ios::in // 읽기 (ifstream 기본)
std::ios::out // 쓰기 (ofstream 기본)
std::ios::app // 매 쓰기마다 파일 끝에 덧붙임
std::ios::trunc // 열 때 기존 내용 삭제
std::ios::ate // 열자마자 파일 끝으로 이동 (이후 이동 가능)
std::ios::binary // 줄바꿈 변환 없이 바이트 그대로
ofstream을 모드 없이 열면 out인데, 이는 out | trunc와 같아서 기존 파일 내용이 지워집니다. 로그처럼 이어 쓰려면 std::ios::app을 줘야 합니다. fstream은 기본이 in | out이며, 이 조합은 C의 "r+"에 해당해 파일이 이미 있어야만 열립니다. 없는 파일을 만들면서 읽기·쓰기를 하려면 in | out | trunc("w+", 기존 내용 삭제)나 in | out | app("a+", 끝에 추가)을 씁니다.
binary는 Windows에서 의미가 있습니다. 텍스트 모드(기본)에서는 쓸 때 \n이 \r\n으로, 읽을 때 \r\n이 \n으로 바뀌기 때문에 이미지나 압축 파일을 텍스트 모드로 복사하면 0x0A 바이트마다 0x0D가 끼어들어 파일이 깨집니다. Linux와 macOS에서는 두 모드의 동작이 같지만, 바이너리 데이터를 다루는 코드는 플랫폼과 상관없이 binary를 명시하는 편이 안전합니다.
std::ofstream log("app.log", std::ios::app); // 끝에 추가
std::fstream file("data.txt", std::ios::in | std::ios::out); // 기존 파일 읽기+쓰기
std::ifstream img("image.png", std::ios::binary); // 바이너리 읽기
ifstream으로 단어, 줄, 전체, CSV, 바이너리 읽기
단어와 줄 단위 읽기
std::ifstream file("data.txt");
if (!file) return 1;
std::string word;
while (file >> word) { // 공백(스페이스, 탭, 개행)으로 구분된 단어
std::cout << word << "\n";
}
std::ifstream file("log.txt");
std::string line;
while (std::getline(file, line)) { // 개행 전까지 한 줄, 개행 문자는 버림
std::cout << line << "\n";
}
getline은 빈 줄도 빈 문자열로 정상적으로 읽습니다. 파일이 개행으로 끝나면 그 뒤에 빈 줄이 하나 더 있는 것처럼 보이지만, 마지막 개행 다음에는 읽을 문자가 없으므로 추가 반복은 일어나지 않습니다. Windows에서 만든 \r\n 파일을 Linux에서 읽으면 각 줄 끝에 \r이 남아 비교가 어긋나는데, 이때는 if (!line.empty() && line.back() == '\r') line.pop_back();으로 제거합니다.
전체 파일 읽기
#include <sstream>
std::ifstream file("data.txt");
// 방법 1: stringstream 사용
std::stringstream buffer;
buffer << file.rdbuf();
std::string content = buffer.str();
// 방법 2: istreambuf_iterator 사용
std::string content2((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
두 방법 모두 파일 전체를 메모리에 올리므로 수십 MB 정도까지의 파일에 적합합니다. 수 GB짜리 로그 파일에 쓰면 파일 크기만큼 메모리를 할당하다가 메모리 부족으로 프로세스가 종료될 수 있으니, 큰 파일은 줄 단위나 고정 크기 청크 단위로 처리합니다. 방법 2에서 생성자 첫 인자를 괄호로 한 번 더 감싼 것은 함수 선언으로 해석되는 문제(most vexing parse)를 피하기 위해서입니다.
두 방법 모두 file의 상태 플래그를 통해 읽기 오류를 알려 주지 않는다는 점도 알아 둘 만합니다. 스트림 버퍼를 직접 읽기 때문입니다. 읽기 오류까지 확인해야 한다면 아래 청크 읽기 방식을 씁니다.
CSV 파일 파싱
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
struct Person { std::string name; int age = 0; 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::istringstream ss(line);
Person p;
if (!std::getline(ss, p.name, ',')) continue;
if (!(ss >> p.age)) continue; // 나이가 숫자가 아니면 그 줄은 건너뜀
ss.ignore(1); // 나이 뒤의 쉼표
std::getline(ss, p.city);
people.push_back(std::move(p));
}
return people;
}
이 방식은 따옴표로 감싼 필드 안에 쉼표가 들어 있는 경우("Seoul, Korea")를 처리하지 못합니다. 외부에서 받은 CSV를 다룬다면 RFC 4180을 따르는 파서 라이브러리를 쓰는 편이 낫습니다.
바이너리 I/O: read()와 write()
텍스트가 아니라 바이트 단위로 읽고 쓸 때는 read()와 write()를 씁니다.
#include <cstdint>
#include <fstream>
#include <iostream>
struct Record { std::int32_t 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 static_cast<bool>(out);
}
bool readRecord(const std::string& path, std::size_t index, Record& rec) {
std::ifstream in(path, std::ios::binary);
if (!in) return false;
in.seekg(static_cast<std::streamoff>(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;
}
gcount()는 마지막 비형식 입력 연산이 실제로 읽은 바이트 수입니다. 구조체를 메모리 그대로 쓰는 이 방식은 같은 컴파일러·같은 플랫폼에서 쓰고 읽을 때만 안전합니다. 구조체에 포인터나 std::string이 있으면 주소값이 저장될 뿐이고, 플랫폼이 바뀌면 패딩, long의 크기(Windows 4바이트, 64비트 Linux 8바이트), 바이트 순서(엔디안)가 달라질 수 있습니다. 파일 형식이 다른 환경과 공유된다면 필드를 하나씩 고정 크기 정수로, 정해진 바이트 순서로 쓰는 방식이 맞습니다. 자세한 내용은 바이너리 직렬화(#11-2)에서 다룹니다.
청크 단위 읽기와 파일 복사
#include <cerrno>
#include <cstring>
#include <fstream>
#include <iostream>
#include <vector>
bool copyFileBinary(const std::string& src, const std::string& dst) {
const std::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.close();
if (!out) { std::cerr << "Close 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;
}
루프 조건 in.read(...) || in.gcount() > 0이 핵심입니다. 파일 끝에서 마지막 read()는 요청보다 적게 읽고 실패 상태가 되지만, 그 사이에 읽은 바이트가 있으므로 gcount()만큼은 처리해야 합니다. in.read(buf, 1024)가 500바이트만 읽었는데 1024바이트를 처리하면 나머지 524바이트는 이전 반복의 데이터입니다.
짧게 쓰려면 out << in.rdbuf(); 한 줄로도 복사할 수 있습니다. 다만 원본이 빈 파일이면 삽입된 문자가 없다는 이유로 out에 failbit가 설정되어, 위처럼 if (!out)으로 검사하면 정상적인 빈 파일 복사가 실패로 보고됩니다. 실제 파일 복사가 목적이라면 C++17의 std::filesystem::copy_file이 가장 간단합니다.
errno와 strerror로 실패 원인(No such file or directory, Permission denied 등)을 함께 출력하면 디버깅이 훨씬 쉬워집니다. 다만 표준은 파일 스트림이 실패할 때 errno를 설정한다고 보장하지 않습니다. 주요 구현은 내부적으로 fopen이나 open을 호출하므로 실제로는 설정되지만, 이식성이 중요한 코드라면 std::filesystem의 error_code 오버로드로 원인을 확인하는 편이 확실합니다.
ofstream으로 쓰기, 이어 쓰기, 포맷팅
std::ofstream file("output.txt");
if (!file) {
std::cerr << "Cannot create file\n";
return 1;
}
file << "Hello, World!\n";
file << "Number: " << 42 << "\n";
ofstream은 파일이 없으면 만들고, 있으면 내용을 비운 뒤 씁니다. 로그처럼 기존 내용을 유지하며 덧붙이려면 std::ios::app으로 엽니다.
std::ofstream log("app.log", std::ios::app);
log << "[" << getCurrentTime() << "] Application started\n";
포맷팅
#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::setfill(' '); // 채움 문자 원래대로
// 실수
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";
setw는 바로 다음 출력 하나에만 적용되지만, setfill, fixed, setprecision, left는 바꿀 때까지 계속 유지됩니다. 위에서 setfill(' ')로 되돌리지 않으면 아래 표가 Name000000Age처럼 0으로 채워집니다. 스트림을 함수 사이에서 공유한다면 이런 상태가 다른 코드의 출력까지 바꾸므로, 포맷을 바꾼 함수는 끝에서 원래대로 돌려놓는 습관이 필요합니다. C++20의 std::format은 매 호출마다 서식을 지정하므로 이 문제가 없습니다.
fstream으로 설정 파일 읽고 수정하기
#include <fstream>
#include <map>
#include <string>
class ConfigFile {
std::map<std::string, std::string> data;
std::string filename;
public:
explicit ConfigFile(const std::string& file) : filename(file) { load(); }
void load() {
std::ifstream f(filename);
for (std::string line; std::getline(f, line); ) {
std::size_t p = line.find('=');
if (p != std::string::npos) data[line.substr(0, p)] = line.substr(p + 1);
}
}
bool save() const {
std::ofstream f(filename);
for (const auto& [k, v] : data) f << k << "=" << v << "\n";
f.close();
return static_cast<bool>(f);
}
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; }
};
설정을 메모리의 map에 읽어 두고, 바꾼 뒤 save()로 파일 전체를 다시 쓰는 구조입니다. 파일 안에서 한 줄만 고치는 방식은 길이가 바뀌는 순간 뒤의 내용을 덮어쓰므로, 텍스트 설정 파일은 이렇게 통째로 다시 쓰는 편이 단순하고 안전합니다. 다만 save() 도중 프로그램이 죽으면 설정 파일이 반쯤 비어 버리므로, 중요한 파일이라면 아래의 원자적 쓰기 패턴과 결합합니다.
하나의 fstream으로 읽다가 쓰기로 바꿀 때는 그 사이에 seekp()나 seekg()로 위치를 지정해야 합니다. filebuf는 C 표준 입출력의 규칙을 따르는데, 읽기와 쓰기를 위치 지정 없이 연달아 하면 동작이 보장되지 않습니다. 또 읽다가 파일 끝에 도달했다면 eofbit가 설정되어 이후 쓰기가 모두 실패하므로 clear()도 먼저 호출합니다.
std::fstream file("data.txt", std::ios::in | std::ios::out);
std::string line;
while (std::getline(file, line)) { /* ... */ } // 끝까지 읽으면 eofbit, failbit 설정
file.clear(); // 상태 플래그 초기화
file.seekp(0, std::ios::end); // 쓰기 위치를 파일 끝으로
file << "appended\n";
에러 처리와 상태 확인
스트림의 상태는 네 가지 함수로 확인합니다.
| 함수 | 의미 |
|---|---|
good() | 어떤 플래그도 설정되지 않음 |
eof() | 입력 중 파일 끝에 도달함 (eofbit) |
fail() | 마지막 연산이 실패함 (failbit 또는 badbit) |
bad() | 스트림 자체가 손상된 오류, 예를 들어 장치 읽기 실패 (badbit) |
if (!file)과 if (file.fail())은 같은 뜻입니다. 주의할 점은 eof()를 루프 조건으로 쓰면 안 된다는 것입니다. while (!file.eof()) { getline(file, line); ... }은 마지막 줄을 읽은 뒤에도 eofbit가 아직 설정되지 않아 한 번 더 돌고, 실패한 읽기의 결과를 처리하게 됩니다. 루프 조건은 항상 읽기 연산 자체로 걸고, eof()와 bad()는 루프가 끝난 뒤 왜 끝났는지 구분하는 데 씁니다.
clear()로 상태 초기화
#include <limits>
std::ifstream file("numbers.txt");
int value;
while (true) {
if (file >> value) {
process(value);
} else if (file.eof()) {
break;
} else {
file.clear(); // failbit 해제
file.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); // 잘못된 줄 건너뛰기
}
}
숫자가 아닌 문자를 만나면 failbit가 설정되고, 그 뒤의 모든 읽기는 아무것도 하지 않고 실패합니다. clear()로 플래그를 풀고 ignore()로 문제의 입력을 건너뛰어야 다음 줄부터 다시 읽을 수 있습니다. ignore의 개수에 256 같은 임의의 수를 주면 그보다 긴 줄에서 다시 실패하므로 numeric_limits<streamsize>::max()를 씁니다.
예외로 처리하기
예상 가능한 실패(설정 파일이 없음)는 반환값으로, 계속 진행할 수 없는 실패는 예외로 알리는 것이 보통입니다. 예외 방식으로 파일 전체를 읽는 함수는 다음과 같이 쓸 수 있습니다.
#include <fstream>
#include <stdexcept>
#include <string>
std::string readFileOrThrow(const std::string& path) {
std::ifstream file(path, std::ios::binary);
if (!file) throw std::runtime_error("Cannot open: " + path);
std::string content;
char buf[64 * 1024];
while (file.read(buf, sizeof(buf)) || file.gcount() > 0) {
content.append(buf, static_cast<std::size_t>(file.gcount()));
}
if (file.bad()) throw std::runtime_error("Read failed: " + path);
return content;
}
file.exceptions(std::ios::badbit)로 스트림이 직접 예외를 던지게 할 수도 있습니다. 다만 failbit까지 예외로 설정하면 getline이 파일 끝에서 정상적으로 멈추는 것도 예외가 되어 루프를 쓰기 어려워지므로, 보통은 badbit만 켭니다.
스트림은 소멸자에서 자동으로 닫히므로 close()를 따로 부를 필요가 없고, 함수 안의 지역 변수로 두면 예외가 나도 파일 핸들이 남지 않습니다. close()를 명시적으로 부르는 경우는 닫기 결과를 확인하고 싶을 때(쓰기 버퍼를 비우다 디스크가 가득 찬 것을 알아내려면)나, 같은 파일을 곧바로 다시 열어야 할 때입니다.
경로 오류, 권한, 디스크 풀
”파일이 분명히 있는데 열리지 않는다”
가장 흔한 원인은 앞에서 말한 현재 작업 디렉터리입니다. std::filesystem::current_path()는 실행 파일이 있는 폴더가 아니라 작업 디렉터리를 돌려주므로, 실행 파일 기준 경로가 필요하면 Linux의 /proc/self/exe나 Windows의 GetModuleFileNameW 같은 플랫폼 API를 써야 합니다. 경로 구분자는 Windows도 /를 받아들이므로 "config/settings.txt"는 양쪽에서 동작하고, std::filesystem::path의 / 연산자로 조합하면 구분자를 신경 쓸 필요가 없습니다.
#include <filesystem>
#include <fstream>
namespace fs = std::filesystem;
fs::path configPath = fs::path("config") / "settings.txt"; // 작업 디렉터리 기준
std::ifstream file(configPath);
if (!file) {
std::cerr << "Cannot open " << fs::absolute(configPath) << "\n"; // 실제로 찾은 경로 출력
}
에러 메시지에 fs::absolute()로 만든 절대 경로를 찍어 두면 “어디서 찾았는지”가 바로 보여서, 작업 디렉터리 문제인지 금방 판단할 수 있습니다.
권한 거부
읽기 전용 파일을 쓰기 모드로 열거나, 쓰기 권한이 없는 디렉터리에 파일을 만들려 하면 열기에 실패하고 POSIX 환경에서는 errno가 EACCES가 됩니다. Windows에서는 다른 프로세스가 공유를 허용하지 않고 파일을 열어 두고 있을 때도 열기가 실패합니다.
#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";
}
}
디스크 풀과 “쓴 내용이 비어 있음”
file << data는 데이터를 스트림 버퍼에 넣을 뿐이고, 버퍼가 차거나 flush()·close()가 호출될 때 운영체제로 넘어갑니다. 그래서 디스크가 가득 찬 경우 에러는 << 시점이 아니라 flush나 close 시점에 드러나며, 그 뒤에 if (!file)을 확인하지 않으면 실패를 놓칩니다. 같은 이유로, 프로그램이 close 전에 비정상 종료되거나 같은 파일을 다른 스트림으로 너무 일찍 읽으면 내용이 비어 있거나 일부만 보입니다.
같은 파일을 ifstream과 ofstream으로 동시에 열어 두면 각 스트림이 자기 버퍼를 갖기 때문에, 쓰는 쪽이 flush하기 전에는 읽는 쪽에 내용이 보이지 않습니다. 쓰기를 끝내고 닫은 뒤 읽기 스트림을 새로 여는 것이 가장 단순합니다.
직접 확인했습니다(Ubuntu 24.04 컨테이너, g++ 13.3, -std=c++17). 쓰기가 항상 실패하는 /dev/full에 f << "hello\n"을 하면 그 직후에는 good()이 참이었고, flush() 뒤에야 fail()과 bad()가 참이 됐습니다. close()로 닫아도 마찬가지로 fail()이 참이었습니다. 한 스트림에 "data\n"을 쓰고 flush() 전에 같은 파일을 다른 ifstream으로 읽으면 빈 문자열이었고, flush() 뒤에는 data가 읽혔습니다. _exit(0)으로 종료하는 프로그램은 flush()를 하지 않은 쓰기가 파일에 0바이트로 남았고, flush() 한 쪽은 5바이트가 남았습니다. 읽기 전용(444) 파일을 ofstream으로 열면 일반 사용자(nobody)로 실행했을 때 errno가 13(EACCES, Permission denied)이었습니다. 존재하지 않는 config/settings.txt를 열면 fs::absolute()가 "/w/config/settings.txt"처럼 실제로 찾은 절대 경로를 출력해 작업 디렉터리를 바로 알 수 있었습니다.
버퍼링과 flush
std::ofstream log("app.log");
log << "Important message\n"; // 아직 스트림 버퍼에만 있음
// 여기서 프로세스가 강제 종료되면 이 줄은 파일에 남지 않을 수 있음
log.flush(); // 버퍼 내용을 운영체제에 넘김
log << "Line with endl" << std::endl; // '\n' 출력 후 flush
log << "Line without endl\n"; // flush 없음
flush()는 스트림 버퍼를 운영체제로 넘길 뿐, 디스크에 기록되었다는 보장은 아닙니다. 넘어간 데이터는 운영체제의 페이지 캐시에 있다가 나중에 디스크로 내려갑니다. 그래서 flush 이후에 프로세스가 크래시하는 것은 견디지만, 전원이 나가는 것까지 견디려면 fsync(Windows는 FlushFileBuffers)가 필요합니다.
std::endl은 매번 flush하므로 줄 수가 많은 출력에서는 눈에 띄게 느려질 수 있습니다. 일반 출력에는 '\n'을 쓰고, 로그처럼 크래시 직전 내용이 중요한 경우에만 줄마다 또는 일정 간격으로 flush하는 편이 좋습니다.
버퍼 크기 바꾸기
#include <fstream>
#include <vector>
std::vector<char> inbuf(256 * 1024); // 큰 버퍼는 스택이 아닌 힙에
std::ifstream in;
in.rdbuf()->pubsetbuf(inbuf.data(), static_cast<std::streamsize>(inbuf.size())); // open 전에
in.open("large.bin", std::ios::binary);
filebuf의 기본 버퍼 크기는 구현에 따라 몇 KB 수준입니다. pubsetbuf로 더 큰 버퍼를 지정할 수 있지만, 그 효과는 표준상 구현 정의라서 libstdc++와 MSVC는 파일을 열기 전에 호출해야 반영되고 이미 I/O가 시작된 뒤에는 무시될 수 있습니다. 버퍼는 스트림보다 오래 살아야 합니다. 어차피 read()로 64KB 같은 큰 블록을 직접 읽는 코드는 스트림 버퍼를 거치지 않고 바로 읽는 경우가 많아서, 버퍼 크기 조정은 작은 읽기를 많이 하는 경우에만 측정해 보고 적용할 가치가 있습니다.
로그 래퍼와 원자적 파일 쓰기
로그 파일 래퍼
#include <chrono>
#include <ctime>
#include <fstream>
#include <iomanip>
#include <mutex>
#include <string>
class Logger {
std::ofstream file;
std::mutex mtx;
public:
explicit Logger(const std::string& path) : file(path, std::ios::app) {}
void log(const std::string& level, const std::string& msg) {
std::lock_guard<std::mutex> lock(mtx); // 여러 스레드의 줄이 섞이지 않게
if (!file) return;
std::time_t t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
std::tm tm{};
#ifdef _WIN32
localtime_s(&tm, &t);
#else
localtime_r(&t, &tm);
#endif
file << "[" << std::put_time(&tm, "%Y-%m-%d %H:%M:%S") << "] ["
<< level << "] " << msg << "\n";
file.flush(); // 프로세스가 죽어도 이 줄까지는 남도록
}
};
std::localtime은 내부 정적 버퍼를 돌려주므로 여러 스레드에서 동시에 부르면 안전하지 않아, 플랫폼별 재진입 버전(localtime_r, localtime_s)을 썼습니다. 뮤텍스는 한 줄을 쓰는 동안 다른 스레드의 출력이 끼어들지 않게 합니다.
원자적 파일 쓰기 (임시 파일 + rename)
설정 파일을 덮어쓰는 도중 프로그램이 죽으면 파일이 반쯤 쓰인 채로 남습니다. 임시 파일에 다 쓴 뒤 이름을 바꾸면, 읽는 쪽은 항상 이전 파일 전체나 새 파일 전체 중 하나만 보게 됩니다.
#include <filesystem>
#include <fstream>
#include <string>
#include <system_error>
namespace fs = std::filesystem;
bool atomicWrite(const fs::path& path, const std::string& content) {
fs::path tmp = path;
tmp += ".tmp"; // 같은 디렉터리에 임시 파일
{
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
if (!out) return false;
out << content;
out.close();
if (!out) {
std::error_code ec;
fs::remove(tmp, ec);
return false;
}
}
std::error_code ec;
fs::rename(tmp, path, ec); // 대상이 있으면 교체
if (ec) {
fs::remove(tmp, ec);
return false;
}
return true;
}
임시 파일은 반드시 대상과 같은 디렉터리에 만듭니다. POSIX의 rename은 같은 파일 시스템 안에서만 원자적이며, /tmp처럼 다른 파일 시스템(tmpfs 등)으로 넘어가면 실패합니다. fs::rename은 실패하면 예외를 던지는 오버로드와 error_code를 받는 오버로드가 있는데, 여기서는 실패 시 임시 파일을 지우기 위해 후자를 썼습니다.
전원 장애까지 견뎌야 한다면 한 단계가 더 필요합니다. close()가 성공해도 데이터는 아직 페이지 캐시에 있을 수 있어서, rename 직후 전원이 나가면 새 이름의 빈 파일이 남는 경우가 있습니다(ext4 등에서 실제로 보고되어 온 현상입니다). 이를 막으려면 rename 전에 임시 파일에 fsync를 해야 하는데, ofstream에는 파일 디스크립터를 꺼내는 표준 방법이 없어 이 부분만 POSIX open/write/fsync로 작성하는 경우가 많습니다. 대용량 데이터도 같은 원리로, 청크 단위로 임시 파일에 쓴 뒤 rename합니다.
스트림 밖의 선택지: mmap과 io_uring
ifstream이 느리다고 느껴질 때 실제 병목은 대개 파싱 쪽이지만, I/O 방식 자체를 바꿔야 하는 경우도 있습니다.
mmap은 파일을 프로세스의 가상 주소 공간에 매핑해 포인터로 직접 읽게 합니다. read()처럼 커널 버퍼에서 사용자 버퍼로 복사하지 않고 페이지 폴트가 날 때 필요한 부분만 올라오므로, 같은 큰 파일을 여러 번 임의 접근하거나 여러 프로세스가 공유해서 읽을 때 유리합니다. 순차로 훑는다면 madvise(addr, len, MADV_SEQUENTIAL)로 커널에 read-ahead를 유도할 수 있습니다. 주의할 점도 있습니다. 매핑한 파일을 다른 프로세스가 truncate하면 사라진 영역에 접근하는 순간 예외가 아니라 SIGBUS 시그널로 프로세스가 죽습니다. MAP_SHARED로 매핑해 쓴 내용은 msync(addr, len, MS_SYNC)를 호출해야 디스크 반영 시점이 보장됩니다. munmap과 close를 빠뜨리지 않도록 RAII 클래스로 감싸야 하고, 크기가 0인 파일은 mmap이 실패하므로 따로 처리합니다. Windows에서는 CreateFileMapping/MapViewOfFile이 같은 역할을 합니다.
io_uring(Linux 5.1+)은 제출 큐(SQ)에 I/O 요청을 넣고 완료 큐(CQ)에서 결과를 받는 비동기 인터페이스입니다. 여러 파일·블록에 대한 읽기를 io_uring_prep_read로 채운 뒤 io_uring_submit 한 번으로 제출할 수 있어 시스템 콜 횟수가 줄고, 수많은 파일을 동시에 다루는 서버에서 효과가 납니다. 완료 결과 cqe->res는 읽은 바이트 수이고 음수면 -errno이므로 strerror(-res)로 원인을 확인합니다. 오래된 커널이나 일부 컨테이너 환경에서는 Function not implemented로 실패하므로, 기존 경로로 돌아가는 대체 구현을 함께 두는 것이 안전합니다. Windows에서는 IOCP·OVERLAPPED I/O가 대응하는 모델입니다.
어느 쪽이든 이식성과 코드 복잡도를 대가로 치르므로, 같은 입력으로 ifstream 청크 읽기와 비교해 측정한 뒤 바꾸는 것이 원칙입니다.
경로에 한글이 들어가면 파일이 안 열려요 (Windows)
Linux·macOS는 경로를 UTF-8 바이트열로 다루므로 대부분 문제가 없습니다. 문제는 Windows입니다. std::ifstream f("한글.txt");처럼 char 문자열을 넘기면, MSVC는 그 바이트열을 시스템 코드 페이지(한국어 Windows라면 CP949)로 해석합니다. 소스 파일이 UTF-8이고 /utf-8 옵션을 켰다면 문자열 리터럴은 UTF-8 바이트가 되는데, 이걸 CP949로 읽으니 엉뚱한 경로가 되어 열기에 실패합니다. 해결 방법은 경로를 std::filesystem::path로 넘기는 것입니다. Windows에서 path는 내부적으로 UTF-16(wchar_t)을 저장하고, ifstream은 path를 받는 생성자로 wide API를 호출합니다. UTF-8 문자열에서 path를 만들 때는 C++20 기준 fs::path(u8"한글.txt")처럼 char8_t 문자열을 쓰거나, 설정 파일에서 읽은 UTF-8 std::string이라면 char8_t로 변환해서 넘겨야 올바르게 해석됩니다(C++17의 fs::u8path는 C++20에서 deprecated). 파일 내용의 인코딩은 이와 별개 문제로, 스트림은 바이트를 그대로 읽고 쓸 뿐입니다. UTF-8 파일 내용을 CP949 콘솔에 출력하면 깨져 보이는 것은 콘솔 쪽 설정 문제입니다.