바이너리 파일 입출력
바이너리 모드와 바이트 입출력의 역할을 구분하고, 명시적인 파일 형식과 검증된 랜덤 접근을 설계합니다.
앞 절까지는 텍스트 파일 중심의 입출력을 다뤘습니다.
이번 절에서는 바이너리(Binary) 파일 입출력을 다룹니다.
바이너리 형식은 문자 변환과 파싱을 줄이거나 더 조밀한 표현을 선택할 수 있지만, 그 자체로 항상 더 빠르거나 더 작다고 보장되지는 않습니다. 중요한 차이는 사람이 읽는 문자 형식 대신 프로그램이 정의한 바이트 형식 계약으로 값을 주고받는다는 점입니다.
C++ · binary I/O · byte-format data flow
바이너리 모드는 통로를 정하고, 파일 형식은 의미를 정한다
std::ios::binary는 텍스트 모드 변환을 피하도록 파일을 엽니다. 값의 폭·바이트 순서·길이·버전을 정하고 검증하는 일은 별도의 인코더와 디코더가 책임집니다.
-
논리 값에서 시작
id · score · name은 아직 파일 바이트가 아닙니다. -
필드를 명시적으로 인코딩
고정 폭, 바이트 순서, 길이와 값 범위를 형식 계약으로 정합니다.
-
버전이 있는 파일 형식 기록
magic · version · count · fields가 읽는 쪽의 경계를 만듭니다. -
헤더·길이·읽은 바이트를 검증해 복원
지원하지 않는 버전과 범위 밖 길이, 부분 읽기는 성공으로 해석하지 않습니다.
객체 표현 복사는 이식 가능한 직렬화가 아니다
trivially copyable은 바이트 복사의 언어 조건일 뿐입니다. 패딩·타입 크기·배치·엔디안·버전이 같은 빌드와 ABI라는 추가 전제가 남습니다.
std::ios::binary가 하는 일
실행 환경의 텍스트 모드 변환을 피하도록 외부 파일 모드를 고릅니다. operator<<의 서식화나 객체의 직렬화 규칙은 바꾸지 않습니다.
인코더와 디코더가 하는 일
고정 폭·바이트 순서·길이·지원 버전·범위를 정의하고, write()와 read()가 옮긴 바이트를 같은 의미로 해석합니다.
- 명시적 파일 형식 경계
- ABI 결합 원시 스냅샷
원시 객체 표현은 프로그램 내부 바이트 복사에는 유효할 수 있지만, 장기 저장·교환 형식에는 충분한 계약이 아닙니다. 안정적인 파일은 값을 필드별로 인코딩하고 읽는 쪽이 헤더와 모든 경계를 검증합니다.
텍스트 모드와 바이너리 모드의 차이
텍스트 모드에서는 실행 환경이 줄바꿈 같은 외부 파일 표현을 변환할 수 있습니다. std::ios::binary는 파일을 텍스트 모드가 아닌 바이너리 모드로 열어 그 변환을 피하도록 지정합니다. POSIX 계열처럼 두 모드의 차이가 없는 환경도 있습니다.
다만 binary는 직렬화 방식이 아닙니다. operator<<로 정수를 쓰면 바이너리 모드에서도 숫자의 문자 표현이 출력됩니다. 객체의 바이트를 쓰려면 write()를, 안정적인 파일 형식을 만들려면 그보다 먼저 필드 폭·바이트 순서·길이·버전을 명시해야 합니다.
- 텍스트 형식: 값을 문자로 서식화하고 읽을 때 파싱한다.
- 바이너리 형식: 프로그램이 정한 바이트 배열로 인코딩하고 같은 계약으로 디코딩한다.
std::ios::binary: 외부 파일의 텍스트 모드 변환 여부만 선택한다.
현재 C++ 작업 초안은 openmode의 binary 효과와 basic_filebuf가 여는 C 파일 모드를 별도로 규정합니다.
바이너리 파일 열기
쓰기와 읽기는 파일 수명을 분리하고, 열기뿐 아니라 쓰기와 닫기 뒤의 상태도 확인합니다.
#include <fstream>
#include <iostream>
int main() {
{
std::ofstream out("data.bin", std::ios::binary | std::ios::trunc);
if (!out) {
std::cerr << "data.bin 쓰기 열기 실패\n";
return 1;
}
// 이 범위에서 write() 등으로 파일 형식에 맞는 바이트를 기록합니다.
out.close();
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;
}
// 이 범위에서 read() 뒤 상태와 읽은 바이트 수를 확인합니다.
}
}읽기와 쓰기가 모두 필요하면 std::fstream에 std::ios::in | std::ios::out | std::ios::binary를 조합할 수 있습니다. 같은 스트림에서 읽기와 쓰기를 번갈아 수행할 때는 각 작업 사이의 위치·버퍼 규칙도 별도로 관리해야 합니다.
write()와 read()는 바이트 블록을 옮긴다
write(const char* buffer, std::streamsize size)와 read(char* buffer, std::streamsize size)는 각각 비서식(unformatted) 출력·입력 함수입니다.
write()는 요청한 수만큼 문자를 출력하고, 출력 시퀀스에 넣지 못하면badbit를 설정합니다.read()는 요청한 수만큼 문자를 채우려 하며, 그 전에 파일 끝을 만나면failbit | eofbit를 설정합니다.- 입력 장치나 하위 버퍼에서 오류가 발생하면
read()에badbit가 설정될 수도 있습니다. gcount()는 마지막 비서식 입력 함수가 실제로 추출한 문자 수를 반환합니다.
따라서 read() 뒤에는 스트림 상태와 gcount()를 함께 확인해야 합니다. 세부 동작은 C++ 작업 초안의 write() 규정과 read()·gcount() 규정에서 확인할 수 있습니다.
아래 예제는 CHAR_BIT == 8인 구현을 대상으로 std::uint32_t를 파일 형식에서 4옥텟 리틀 엔디안으로 고정합니다. C++의 1바이트가 항상 8비트인 것은 아니므로 이 전제를 컴파일 시 확인합니다. 메모리 배치를 복사하지 않고 각 옥텟을 명시적으로 인코딩하므로 읽는 쪽도 같은 규칙을 독립적으로 구현할 수 있습니다.
#include <array>
#include <climits>
#include <cstddef>
#include <cstdint>
#include <istream>
#include <ostream>
constexpr std::streamsize kU32Bytes = 4;
static_assert(CHAR_BIT == 8, "file format requires 8-bit bytes");
bool write_u32_le(std::ostream& out, std::uint32_t value) {
const std::array<std::byte, 4> bytes{
static_cast<std::byte>(value & 0xffu),
static_cast<std::byte>((value >> 8) & 0xffu),
static_cast<std::byte>((value >> 16) & 0xffu),
static_cast<std::byte>((value >> 24) & 0xffu),
};
out.write(reinterpret_cast<const char*>(bytes.data()), kU32Bytes);
return static_cast<bool>(out);
}
bool read_u32_le(std::istream& in, std::uint32_t& value) {
std::array<std::byte, 4> bytes{};
in.read(reinterpret_cast<char*>(bytes.data()), kU32Bytes);
if (in.gcount() != kU32Bytes || !in) {
return false;
}
value = std::to_integer<std::uint32_t>(bytes[0])
| (std::to_integer<std::uint32_t>(bytes[1]) << 8)
| (std::to_integer<std::uint32_t>(bytes[2]) << 16)
| (std::to_integer<std::uint32_t>(bytes[3]) << 24);
return true;
}실제 형식에는 보통 고정된 매직 값, 지원 버전, 레코드 수나 페이로드 길이를 먼저 두고, 읽는 쪽이 이를 검증한 뒤 필드를 디코딩합니다. 알 수 없는 버전이나 범위를 벗어난 길이는 추측해서 읽지 말고 거부합니다.
객체 표현을 그대로 저장할 수 있는 범위
C++은 trivially copyable 타입의 객체를 이루는 바이트를 char, unsigned char, std::byte 배열로 복사했다가 같은 객체 표현으로 되돌릴 수 있도록 보장합니다. 이 언어 규칙은 trivially copyable 타입과 객체 표현에 정의되어 있습니다.
#include <cstdint>
#include <ostream>
#include <type_traits>
struct Record {
std::uint32_t id;
double value;
};
static_assert(std::is_trivially_copyable_v<Record>);
bool write_local_snapshot(std::ostream& out, const Record& record) {
out.write(
reinterpret_cast<const char*>(&record),
static_cast<std::streamsize>(sizeof record)
);
return static_cast<bool>(out);
}그러나 trivially copyable은 이식 가능한 파일 형식이라는 뜻이 아닙니다. 위 방식은 같은 프로그램 빌드와 같은 ABI에서 쓰는 일시적 스냅샷처럼 환경을 엄격히 제한한 경우에만 고려합니다.
- 객체 표현에는 값에 참여하지 않는 패딩 비트·바이트가 있을 수 있습니다.
- 기본 타입의 크기·정렬, 구조체 배치, 엔디안은 구현과 ABI에 의존할 수 있습니다.
- 포인터 값은 다른 실행이나 프로세스에서 유효한 식별자가 아닙니다. 포인터 타입 자체가 trivially copyable인지와 영속화 가능성은 별개의 문제입니다.
std::string처럼 내부 저장소와 소유 상태를 가진 일반적인 라이브러리 객체는 그 내부 표현을 파일 형식으로 사용하면 안 됩니다.- 구조체나 컴파일 옵션이 바뀌면 같은 바이트를 다른 의미로 해석할 수 있습니다.
오래 보관하거나 다른 빌드·플랫폼과 교환할 파일은 고정 폭 정수, 정해진 바이트 순서, 길이 접두사, 지원 버전과 범위 검사를 사용해 필드별로 직렬화합니다. 패딩을 포함한 sizeof(Record) 대신 직렬화된 레코드 폭을 파일 형식의 상수로 둡니다.
파일 위치 이동: seekg, seekp, tellg, tellp
고정 폭 레코드라면 headerBytes + index * recordBytes로 시작 위치를 계산할 수 있습니다. 하지만 곱셈이 위치 타입의 범위를 넘지 않는지, 요청한 레코드가 파일 안에 완전히 들어오는지, 실제 이동과 읽기가 성공했는지 모두 확인해야 합니다.
C++ · random access · checked flowchart
오프셋 계산부터 정확한 바이트 수까지 모두 통과해야 한다
랜덤 접근의 기준은 sizeof(Record)가 아니라 파일 형식이 정한 레코드 폭입니다. 범위와 곱셈을 먼저 검증하고, 위치 이동과 읽기의 상태를 각각 판정합니다.
-
직렬화된 레코드 폭과 파일 정합성 검증
N > 0,fileSize % N == 0,index < count를 확인합니다. 곱셈은streamoff범위 안에서만 수행합니다. -
seekg(offset, beg)뒤 상태 확인실패하면
failbit가 설정될 수 있습니다. 위치를 조회한다면tellg() == pos_type(-1)도 실패입니다. -
read(buffer, N)뒤 정확한 길이 확인gcount() == N과 정상 스트림 상태를 모두 만족해야 레코드 하나를 온전히 읽은 것입니다.
모든 게이트를 통과한 뒤에만 디코딩
형식·범위·위치·길이가 확인된 바이트만 파일 계약에 따라 Record 값으로 복원합니다.
streamoff와 pos_type을 구분
상대 오프셋 계산에는 부호 있는 streamoff를 사용하고, tellg()가 반환하는 위치 객체의 실패 값은 pos_type(-1)로 판정합니다.
파일 끝은 레코드 성공이 아니다
요청한 N바이트 전에 파일 끝을 만나면 read()는 failbit | eofbit를 설정하고, gcount()는 실제로 읽은 더 작은 수를 보고합니다.
- 검증·입출력 진행
- 모든 조건을 통과한 성공
- 즉시 거부하는 실패
고정 폭 레코드의 장점은 오프셋을 계산할 수 있다는 것이지, 계산과 I/O가 자동으로 안전해진다는 뜻이 아닙니다. 파일 경계와 타입 범위를 먼저 검증하면 오버플로, 범위 밖 이동, 잘린 레코드를 서로 분리해 처리할 수 있습니다.
std::streamoff는 구현이 지원하는 최대 파일 크기를 나타낼 수 있는 부호 있는 정수 타입이고, tellg()는 pos_type을 반환합니다. tellg()가 실패하면 pos_type(-1)을 반환하며, seekg()의 위치 변경이 실패하면 failbit가 설정됩니다. 출력 위치의 tellp()와 seekp()도 실패 값과 상태를 확인해야 합니다. 자세한 타입·상태 계약은 streamoff와 streamsize, tellg()와 seekg(), tellp()와 seekp()를 참고하세요.
다음 예제는 CHAR_BIT == 8인 구현에서 헤더가 없는 8옥텟 레코드 파일을 가정합니다. 파일 크기가 레코드 폭의 배수인지 먼저 확인하고, 인덱스·곱셈·파일 경계·이동·부분 읽기를 순서대로 검증합니다.
#include <array>
#include <climits>
#include <cstddef>
#include <cstdint>
#include <fstream>
#include <iostream>
#include <limits>
#include <optional>
struct Record {
std::uint32_t id;
std::uint32_t score;
};
constexpr std::uintmax_t kRecordBytes = 8;
static_assert(CHAR_BIT == 8, "file format requires 8-bit bytes");
std::uint32_t decode_u32_le(const std::byte* bytes) {
return std::to_integer<std::uint32_t>(bytes[0])
| (std::to_integer<std::uint32_t>(bytes[1]) << 8)
| (std::to_integer<std::uint32_t>(bytes[2]) << 16)
| (std::to_integer<std::uint32_t>(bytes[3]) << 24);
}
std::optional<Record> read_record(const char* path, std::uintmax_t index) {
std::ifstream in(path, std::ios::binary);
if (!in) {
return std::nullopt;
}
in.seekg(0, std::ios::end);
const auto endPos = in.tellg();
if (endPos == std::ifstream::pos_type(-1)) {
return std::nullopt;
}
const auto endOffset = static_cast<std::streamoff>(endPos);
if (endOffset < 0) {
return std::nullopt;
}
const auto fileBytes = static_cast<std::uintmax_t>(endOffset);
if (fileBytes % kRecordBytes != 0) {
return std::nullopt; // 잘린 레코드 또는 다른 형식
}
const auto recordCount = fileBytes / kRecordBytes;
if (index >= recordCount) {
return std::nullopt;
}
const auto maxOffset = static_cast<std::uintmax_t>(
std::numeric_limits<std::streamoff>::max()
);
if (kRecordBytes > maxOffset || index > maxOffset / kRecordBytes) {
return std::nullopt;
}
const auto offsetBytes = index * kRecordBytes;
if (offsetBytes > fileBytes - kRecordBytes) {
return std::nullopt;
}
const auto offset = static_cast<std::streamoff>(offsetBytes);
in.seekg(offset, std::ios::beg);
if (!in) {
return std::nullopt;
}
const auto actualPos = in.tellg();
if (actualPos == std::ifstream::pos_type(-1)
|| static_cast<std::streamoff>(actualPos) != offset) {
return std::nullopt;
}
std::array<std::byte, kRecordBytes> bytes{};
const auto request = static_cast<std::streamsize>(bytes.size());
in.read(reinterpret_cast<char*>(bytes.data()), request);
if (in.gcount() != request || !in) {
return std::nullopt;
}
return Record{
decode_u32_le(bytes.data()),
decode_u32_le(bytes.data() + 4),
};
}
int main() {
const auto record = read_record("records.bin", 2);
if (!record) {
std::cerr << "3번째 레코드를 온전히 읽지 못했습니다.\n";
return 1;
}
std::cout << "id=" << record->id << ", score=" << record->score << '\n';
}헤더가 있는 파일이라면 headerBytes도 같은 방식으로 범위를 검사한 뒤 오프셋에 더합니다. 가변 길이 레코드는 index * sizeof(Record)로 찾을 수 없으므로 별도의 오프셋 테이블이나 길이 접두사 인덱스가 필요합니다.
바이너리 입출력 점검 요약
std::ios::binary는 외부 파일 모드를 고를 뿐,operator<<를 원시 바이트 출력으로 바꾸지 않는다.- 파일 형식은 magic, 지원 version, 고정 폭, byte order, length와 검증 규칙을 명시한다.
- 객체 표현의 원시 복사는 trivially copyable 조건을 만족해도 ABI·패딩·엔디안·버전에 결합된다.
- 랜덤 접근은 직렬화된 레코드 폭, 안전한 오프셋 산술, 파일 경계,
seekg·tellg결과를 확인한다. read()뒤에는 스트림 상태와gcount()를 함께 확인해 부분 읽기를 성공으로 오해하지 않는다.
결국 바이너리 I/O의 안정성은 write() 호출 자체보다, 미래의 읽기 코드가 같은 바이트 계약을 검증하고 해석할 수 있는지에서 결정됩니다.