바이너리 파일 입출력
바이트를 그대로 읽고 쓰는 바이너리 모드를 익히고 기본 타입·구조체 저장과 파일 위치 이동의 제약을 이해합니다.
앞 절까지는 텍스트 파일 중심의 입출력을 다뤘습니다.
이번 절에서는 바이너리(Binary) 파일 입출력을 다룹니다.
바이너리 파일은 사람이 직접 읽기 어렵지만, 텍스트보다 빠르고 저장 크기도 작아 성능이 중요한 프로그램에서 자주 사용됩니다.
텍스트 파일은 사람이 읽기 쉬운 문자로 바꾸지만, 바이너리 파일은 정해진 크기의 바이트를 그대로 보존하고 같은 규칙으로 다시 해석합니다.
- 텍스트 모드
값을 문자로 바꿔 저장하므로 읽을 때 다시 파싱해야 합니다. int 2025를 문자로 저장 '2' '0' '2' '5' '\\n'
- 바이너리 모드
메모리의 바이트 수와 순서를 파일 포맷의 일부로 다룹니다. int 2025를 4바이트로 저장, little-endian 예 E9 07 00 00
- 1 모드 지정
모드 지정 ios::binary 줄바꿈 변환 없이 파일을 엽니다.
- 2 크기 고정
크기 고정 sizeof(T) 몇 바이트를 쓸지 코드가 정합니다.
- 3 그대로 저장
그대로 저장 write() 지정한 바이트 수만큼 파일에 보냅니다.
- 4 같은 규칙 복원
같은 규칙 복원 read() 같은 타입, 크기, 순서로 해석합니다.
텍스트 모드와 바이너리 모드의 차이
텍스트 모드는 줄바꿈 변환 같은 플랫폼 의존 처리가 개입될 수 있습니다.
바이너리 모드는 데이터를 바이트 그대로 읽고 씁니다.
- 텍스트 모드: 사람이 읽기 쉬움, 가공/파싱 필요
- 바이너리 모드: 사람이 읽기 어려움, 빠르고 원본 바이트 보존
파일을 바이너리 모드로 열 때는 std::ios::binary 플래그를 지정합니다.
바이너리 파일 열기
#include <fstream>
#include <iostream>
int main() {
std::ofstream out("data.bin", std::ios::binary);
if (!out) {
std::cerr << "data.bin 열기 실패\n";
return 1;
}
std::ifstream in("data.bin", std::ios::binary);
if (!in) {
std::cerr << "data.bin 읽기 열기 실패\n";
return 1;
}
}읽기/쓰기 모두 필요하면 std::fstream에 std::ios::in | std::ios::out | std::ios::binary를 조합해서 사용하면 됩니다.
write() / read()로 기본 타입 저장하기
바이너리 입출력의 핵심은 write()와 read()입니다.
write(const char* buffer, std::streamsize size)read(char* buffer, std::streamsize size)
#include <fstream>
#include <iostream>
int main() {
int number = 2025;
double score = 98.75;
{
std::ofstream out("basic.bin", std::ios::binary);
out.write(reinterpret_cast<const char*>(&number), sizeof(number));
out.write(reinterpret_cast<const char*>(&score), sizeof(score));
}
int loadedNumber = 0;
double loadedScore = 0.0;
{
std::ifstream in("basic.bin", std::ios::binary);
in.read(reinterpret_cast<char*>(&loadedNumber), sizeof(loadedNumber));
in.read(reinterpret_cast<char*>(&loadedScore), sizeof(loadedScore));
}
std::cout << loadedNumber << ", " << loadedScore << "\n";
}구조체 단위로 저장하기 (POD/Trivially Copyable 중심)
단순 구조체는 한 번에 쓰고 읽을 수 있습니다.
#include <fstream>
#include <iostream>
struct Record {
int id;
double value;
};
int main() {
Record r1{1, 12.34};
{
std::ofstream out("record.bin", std::ios::binary);
out.write(reinterpret_cast<const char*>(&r1), sizeof(Record));
}
Record r2{};
{
std::ifstream in("record.bin", std::ios::binary);
in.read(reinterpret_cast<char*>(&r2), sizeof(Record));
}
std::cout << "id=" << r2.id << ", value=" << r2.value << "\n";
}단, 이 방식은 문자열(std::string), 포인터 멤버, 가상 함수가 있는 타입에는 그대로 적용하면 안 됩니다.
파일 포인터 이동: seekg, seekp, tellg, tellp
바이너리 파일 입출력은 열기 모드, 상태 플래그, 읽기·쓰기 단위, 닫기 책임으로 확인합니다.
바이너리 파일에서 랜덤 접근이 빠른 이유는 레코드 크기가 고정되어 있으면 원하는 레코드의 시작 바이트를 계산할 수 있기 때문입니다.
- 3번째 레코드, index 2
offset = 2 * sizeof(Record) 파일 시작 기준으로 계산한 바이트 위치를 seekg 에 넘기면 바로 해당 레코드로 이동합니다.
- seekg
읽기 위치를 계산된 오프셋으로 옮깁니다.
- read
정해진 byte 수만큼 구조체 버퍼에 채웁니다.
- state
읽은 바이트 수와 실패 플래그를 확인합니다.
바이너리 파일은 고정 크기 레코드를 랜덤 접근하기 좋습니다.
#include <fstream>
#include <iostream>
struct Record {
int id;
int score;
};
int main() {
{
std::ofstream out("records.bin", std::ios::binary);
Record arr[3] = {{1, 80}, {2, 90}, {3, 70}};
out.write(reinterpret_cast<const char*>(arr), sizeof(arr));
}
std::ifstream in("records.bin", std::ios::binary);
if (!in) return 1;
// 3번째 레코드(인덱스 2)로 이동
std::streamoff offset = 2 * static_cast<std::streamoff>(sizeof(Record));
in.seekg(offset, std::ios::beg);
Record target{};
in.read(reinterpret_cast<char*>(&target), sizeof(target));
std::cout << "id=" << target.id << ", score=" << target.score << "\n";
}바이너리 입출력 시 주의사항
컴파일러가 멤버 사이에 패딩 바이트를 넣을 수 있어 sizeof(struct)가 기대와 다를 수 있습니다.
다른 플랫폼 간 파일 호환이 필요하면 바이트 순서를 명시적으로 통일해야 합니다.
포인터 값 자체는 메모리 주소이므로 파일에 저장해도 의미가 없습니다.
파일 포맷이 바뀔 수 있으므로 헤더에 버전 필드를 두는 습관이 좋습니다.
바이너리 입출력 점검 요약
바이너리 파일은 빠르지만 바이트 해석 규칙을 코드가 책임지므로, 저장 포맷의 경계를 명시해야 합니다.
바이트를 그대로 읽고 쓰는 순간, 파일을 해석하는 규칙도 코드가 책임집니다. 안전한 파일은 header, payload, 검증 정보를 분리합니다.
- Header
magic version endian length
- Guard
checksum count
- padding
구조체 멤버 사이 정렬 바이트 때문에 파일 크기가 기대와 달라질 수 있습니다. 대응: 필드별로 직접 쓰거나 packed 제약을 문서화
- endian
CPU나 플랫폼에 따라 정수 바이트 순서가 다를 수 있습니다. 대응: 표준 순서를 정하고 읽을 때 변환
- pointer
포인터 값은 실행 중 주소일 뿐이라 파일에 저장해도 다음 실행에서 의미가 없습니다. 대응: 실제 데이터나 길이+내용으로 직렬화
- schema
필드가 추가되면 예전 파일을 새 코드가 다르게 해석할 수 있습니다. 대응: version과 migration 규칙을 둠
아래 보드는 바이너리 저장을 시작하기 전에 포맷을 고정하고, 읽는 쪽이 같은 규칙으로 해석할 수 있는지 확인하는 최소 점검표입니다.
- 바이너리 모드는
std::ios::binary로 연다. write/read는 바이트 단위로 동작한다.- 고정 크기 레코드는
seekg/seekp로 빠르게 랜덤 접근할 수 있다. - 구조체 직렬화는 패딩/엔디안/포인터 멤버를 항상 고려해야 한다.
결국 바이너리 I/O의 안정성은 파일을 여는 코드보다 파일을 해석하는 계약에서 결정됩니다.
헤더와 버전, 읽은 바이트 수, 구조체 저장 한계를 함께 확인하면 대부분의 오류를 초기에 걸러낼 수 있습니다.