본문으로 건너뛰기

안동민 개발노트

본문 시작

바이너리 파일 입출력

바이너리 모드와 바이트 입출력의 역할을 구분하고, 명시적인 파일 형식과 검증된 랜덤 접근을 설계합니다.

앞 절까지는 텍스트 파일 중심의 입출력을 다뤘습니다.

이번 절에서는 바이너리(Binary) 파일 입출력을 다룹니다.

바이너리 형식은 문자 변환과 파싱을 줄이거나 더 조밀한 표현을 선택할 수 있지만, 그 자체로 항상 더 빠르거나 더 작다고 보장되지는 않습니다. 중요한 차이는 사람이 읽는 문자 형식 대신 프로그램이 정의한 바이트 형식 계약으로 값을 주고받는다는 점입니다.

논리 값을 고정 폭·바이트 순서·길이와 버전을 정한 파일 형식으로 인코딩하고 검증해 복원하는 경로와, 객체 표현을 그대로 복사해 ABI에 결합되는 로컬 스냅샷 경로를 비교한 데이터 흐름

C++ · binary I/O · byte-format data flow

바이너리 모드는 통로를 정하고, 파일 형식은 의미를 정한다

std::ios::binary는 텍스트 모드 변환을 피하도록 파일을 엽니다. 값의 폭·바이트 순서·길이·버전을 정하고 검증하는 일은 별도의 인코더와 디코더가 책임집니다.

명시적 파일 형식과 원시 객체 스냅샷의 두 데이터 흐름 위쪽 경로는 논리 값을 고정 폭과 바이트 순서로 인코딩해 버전이 있는 파일 형식으로 저장하고 검증해 복원한다. 아래쪽 경로는 trivially copyable 객체의 현재 표현을 그대로 써 같은 빌드와 ABI에서만 해석 가능한 스냅샷이 된다. EXPLICIT FORMAT · PORTABLE CONTRACT OBJECT REPRESENTATION · LOCAL SNAPSHOT ONLY BYTE CONTRACT VALUE 논리 값 id · score · name ENCODE 명시적 인코더 fixed width · endian length · range FORMAT 파일 형식 계약 magic · version · count fields · payload DECODE 검증 후 복원 header · bounds exact bytes OBJECT Record 객체 trivially copyable RAW COPY 객체 표현 쓰기 write(&record, sizeof) SNAPSHOT 현재 객체 표현 padding · layout · endian LIMIT 같은 ABI에서만 same build · ABI
portable path 의미를 보존하려면 표현을 먼저 고정합니다.
  1. 논리 값에서 시작

    id · score · name은 아직 파일 바이트가 아닙니다.

  2. 필드를 명시적으로 인코딩

    고정 폭, 바이트 순서, 길이와 값 범위를 형식 계약으로 정합니다.

  3. 버전이 있는 파일 형식 기록

    magic · version · count · fields가 읽는 쪽의 경계를 만듭니다.

  4. 헤더·길이·읽은 바이트를 검증해 복원

    지원하지 않는 버전과 범위 밖 길이, 부분 읽기는 성공으로 해석하지 않습니다.

local snapshot

객체 표현 복사는 이식 가능한 직렬화가 아니다

trivially copyable은 바이트 복사의 언어 조건일 뿐입니다. 패딩·타입 크기·배치·엔디안·버전이 같은 빌드와 ABI라는 추가 전제가 남습니다.

open mode

std::ios::binary가 하는 일

실행 환경의 텍스트 모드 변환을 피하도록 외부 파일 모드를 고릅니다. operator<<의 서식화나 객체의 직렬화 규칙은 바꾸지 않습니다.

format contract

인코더와 디코더가 하는 일

고정 폭·바이트 순서·길이·지원 버전·범위를 정의하고, write()read()가 옮긴 바이트를 같은 의미로 해석합니다.

  • 명시적 파일 형식 경계
  • ABI 결합 원시 스냅샷

원시 객체 표현은 프로그램 내부 바이트 복사에는 유효할 수 있지만, 장기 저장·교환 형식에는 충분한 계약이 아닙니다. 안정적인 파일은 값을 필드별로 인코딩하고 읽는 쪽이 헤더와 모든 경계를 검증합니다.


텍스트 모드와 바이너리 모드의 차이

텍스트 모드에서는 실행 환경이 줄바꿈 같은 외부 파일 표현을 변환할 수 있습니다. std::ios::binary는 파일을 텍스트 모드가 아닌 바이너리 모드로 열어 그 변환을 피하도록 지정합니다. POSIX 계열처럼 두 모드의 차이가 없는 환경도 있습니다.

다만 binary직렬화 방식이 아닙니다. operator<<로 정수를 쓰면 바이너리 모드에서도 숫자의 문자 표현이 출력됩니다. 객체의 바이트를 쓰려면 write()를, 안정적인 파일 형식을 만들려면 그보다 먼저 필드 폭·바이트 순서·길이·버전을 명시해야 합니다.

  • 텍스트 형식: 값을 문자로 서식화하고 읽을 때 파싱한다.
  • 바이너리 형식: 프로그램이 정한 바이트 배열로 인코딩하고 같은 계약으로 디코딩한다.
  • std::ios::binary: 외부 파일의 텍스트 모드 변환 여부만 선택한다.

현재 C++ 작업 초안은 openmodebinary 효과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::fstreamstd::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로 시작 위치를 계산할 수 있습니다. 하지만 곱셈이 위치 타입의 범위를 넘지 않는지, 요청한 레코드가 파일 안에 완전히 들어오는지, 실제 이동과 읽기가 성공했는지 모두 확인해야 합니다.

직렬화된 레코드 폭과 파일 크기, 인덱스와 오프셋 산술을 먼저 검증한 뒤 seekg와 tellg, read와 gcount 상태를 차례대로 확인해 고정 폭 레코드를 읽는 흐름도

C++ · random access · checked flowchart

오프셋 계산부터 정확한 바이트 수까지 모두 통과해야 한다

랜덤 접근의 기준은 sizeof(Record)가 아니라 파일 형식이 정한 레코드 폭입니다. 범위와 곱셈을 먼저 검증하고, 위치 이동과 읽기의 상태를 각각 판정합니다.

검증된 고정 폭 레코드 랜덤 접근 레코드 폭과 파일 정합성, 인덱스와 오프셋 범위를 확인한다. 통과하면 seekg 뒤 스트림과 tellg 결과를 확인하고, read 뒤 gcount와 상태가 모두 정확할 때만 레코드를 성공으로 반환한다. NO YES NO YES NO YES 형식·인덱스·곱셈이 유효한가? N > 0 · fileSize % N == 0 index < count · offset fits 요청 거부 invalid format · out of range seekg(offset, beg) offset = checked index * N 위치 이동이 성공했는가? stream state is good tellg() != pos_type(-1) 이동 실패 failbit · position unavailable read(buffer, N) request serialized record width 레코드 하나를 온전히 읽었는가? gcount() == N and stream state is good 부분 읽기·I/O 오류로 거부 badbit · I/O failure failbit | eofbit · short gcount 디코딩한 Record 반환 N bytes · valid pos
checked access 위치와 읽기는 서로 다른 완료 조건입니다.
  1. 직렬화된 레코드 폭과 파일 정합성 검증

    N > 0, fileSize % N == 0, index < count를 확인합니다. 곱셈은 streamoff 범위 안에서만 수행합니다.

  2. seekg(offset, beg) 뒤 상태 확인

    실패하면 failbit가 설정될 수 있습니다. 위치를 조회한다면 tellg() == pos_type(-1)도 실패입니다.

  3. read(buffer, N) 뒤 정확한 길이 확인

    gcount() == N과 정상 스트림 상태를 모두 만족해야 레코드 하나를 온전히 읽은 것입니다.

success

모든 게이트를 통과한 뒤에만 디코딩

형식·범위·위치·길이가 확인된 바이트만 파일 계약에 따라 Record 값으로 복원합니다.

position

streamoffpos_type을 구분

상대 오프셋 계산에는 부호 있는 streamoff를 사용하고, tellg()가 반환하는 위치 객체의 실패 값은 pos_type(-1)로 판정합니다.

partial read

파일 끝은 레코드 성공이 아니다

요청한 N바이트 전에 파일 끝을 만나면 read()failbit | eofbit를 설정하고, gcount()는 실제로 읽은 더 작은 수를 보고합니다.

  • 검증·입출력 진행
  • 모든 조건을 통과한 성공
  • 즉시 거부하는 실패

고정 폭 레코드의 장점은 오프셋을 계산할 수 있다는 것이지, 계산과 I/O가 자동으로 안전해진다는 뜻이 아닙니다. 파일 경계와 타입 범위를 먼저 검증하면 오버플로, 범위 밖 이동, 잘린 레코드를 서로 분리해 처리할 수 있습니다.

std::streamoff는 구현이 지원하는 최대 파일 크기를 나타낼 수 있는 부호 있는 정수 타입이고, tellg()pos_type을 반환합니다. tellg()가 실패하면 pos_type(-1)을 반환하며, seekg()의 위치 변경이 실패하면 failbit가 설정됩니다. 출력 위치의 tellp()seekp()도 실패 값과 상태를 확인해야 합니다. 자세한 타입·상태 계약은 streamoffstreamsize, 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() 호출 자체보다, 미래의 읽기 코드가 같은 바이트 계약을 검증하고 해석할 수 있는지에서 결정됩니다.