본문 바로가기

바이너리 파일 입출력

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

텍스트 파일은 값을 글자로 바꾸어 저장하므로 사람이 읽을 수 있지만, 읽을 때마다 글자를 다시 값으로 바꿔야 합니다. 바이너리 파일은 값을 프로그램이 정한 바이트 배열로 저장합니다. 정해진 크기의 레코드를 바로 찾아 읽을 수 있고 형식을 조밀하게 만들 수 있지만, 바이너리라는 이유만으로 언제나 더 빠르거나 작은 것은 아닙니다.

중요한 차이는 사람이 읽는 글자 대신, 쓰는 쪽과 읽는 쪽이 함께 지키는 바이트 형식으로 값을 주고받는다는 점입니다. 이 글은 바이너리 모드가 하는 일, write()와 read()로 바이트를 옮기는 방법, 정수를 정해진 바이트 순서로 저장하는 방법, 고정 크기 레코드를 위치로 찾아 읽는 방법을 다룹니다. 예제는 1바이트가 8비트인 Windows x86-64의 GCC를 기준으로 합니다.

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

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

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

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

명시형식과원시표현은서로다른두경로원desktop두경로각4노드3간선의논리값→인코더→명시파일→검증복원과Record→객체표현복사→ABI결합스냅샷→환경제한을모바일에서도구획분리한다.후자가전자로자동변환되지않는다.객체표현복사의타입조건만으로장기이식가능성을증명하지않는다.명시한 파일 형식논리 값id / score / name인코더폭·순서·길이·범위파일 형식magic·versioncount·fields·payload검증 후 복원헤더·경계·읽은 수제한된 로컬 스냅샷Record 객체trivially copyable객체 표현 쓰기write / sizeof현재 스냅샷padding·layoutendian환경 제한같은 빌드·ABI포인터는 별도 주의
명시적 파일 형식과 로컬 객체 스냅샷 비교명시적 형식은 고정 폭, 바이트 순서, 길이와 버전을 정해 검증 후 복원한다. 원시 객체 표현은 trivially copyable 조건 외에 빌드와 ABI, 포인터 값 등 환경 제한을 확인해야 한다.명시적 파일 형식 · 값을 필드별로 인코딩논리 값id · score · name인코더고정 폭 · 바이트 순서길이 · 값 범위파일 형식magic · versioncount · fieldspayload검증 후 복원헤더 · 길이 경계정확한 바이트 수원시 객체 표현 · 제한된 로컬 스냅샷Record 객체trivially copyable객체 표현 쓰기write() · sizeof현재 바이트 복사스냅샷padding · layoutendian환경 제한같은 빌드 · 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 결합 원시 스냅샷

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

바이너리 모드가 하는 일

텍스트 모드에서는 운영체제에 따라 줄바꿈 같은 글자가 바뀌어 기록될 수 있습니다. Windows에서는 '\n'을 쓰면 파일에 \r\n의 두 바이트가 기록되고, 읽을 때 다시 '\n' 하나로 바뀝니다. std::ios::binary로 열면 이런 변환 없이 바이트를 그대로 주고받으며, Linux처럼 원래 변환이 없는 환경에서는 두 모드가 같습니다.

하지만 위 그림처럼 바이너리 모드는 통로를 정할 뿐, 값을 어떤 바이트로 바꿀지는 정하지 않습니다. 텍스트 파일 쓰기에서 본 것처럼 바이너리 모드에서도 <<는 숫자를 글자로 기록합니다. 값을 바이트로 저장하려면 write()를 쓰고, 다른 프로그램이나 나중의 버전도 읽을 수 있게 하려면 각 값의 바이트 수, 바이트 순서, 길이, 버전을 파일 형식으로 정해야 합니다.

write()와 read()

write(const char* data, std::streamsize n)은 data에서 n바이트를 그대로 출력하고, read(char* data, std::streamsize n)은 n바이트를 읽어 data에 채웁니다. 이 둘은 글자로 바꾸는 형식화를 하지 않는 비형식화 입출력(unformatted I/O)입니다.

read()는 n바이트를 다 채우기 전에 파일 끝에 닿으면 failbit와 eofbit를 설정하고, 그때까지 읽은 만큼만 채웁니다. 실제로 읽은 바이트 수는 gcount()로 알 수 있으므로, read() 뒤에는 스트림 상태와 gcount()를 함께 확인해 일부만 읽은 것을 성공으로 오해하지 않게 합니다. write()는 출력하지 못하면 badbit를 설정합니다.

정수를 정해진 바이트 순서로 저장하기

std::uint32_t처럼 여러 바이트로 이루어진 값은, 메모리에 바이트를 놓는 순서인 바이트 순서(endianness)가 CPU마다 다를 수 있습니다. 낮은 자리 바이트를 먼저 두는 방식을 리틀 엔디언(little-endian)이라고 하며, x86-64가 이 방식입니다. 리틀 엔디언과 빅 엔디언이 같은 값을 주소에 어떻게 놓는지는 비트·바이트·주소에서 표로 비교합니다. 메모리의 바이트를 그대로 쓰면 이 순서에 따라 파일 내용이 달라지므로, 파일 형식에서는 순서를 정하고 시프트로 바이트를 직접 만듭니다.

u32_le.cpp
#include <array>
#include <climits>
#include <cstddef>
#include <cstdint>
#include <istream>
#include <ostream>

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()), 4);
    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()), 4);
    if (in.gcount() != 4 || !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;
}

write_u32_le는 비트 연산자의 시프트와 마스크로 값을 낮은 자리부터 한 바이트씩 나누어, 언제나 같은 순서로 4바이트를 씁니다. std::byte는 C++17에 들어온 바이트 전용 타입이고, reinterpret_cast<const char*>는 바이트 배열을 write()가 받는 char 포인터로 보는 변환입니다. 이 함수는 CPU의 바이트 순서와 상관없이 같은 파일을 만듭니다.

read_u32_le는 같은 규칙을 거꾸로 적용합니다. 읽은 4바이트를 std::to_integer<std::uint32_t>로 하나씩 정수로 바꾼 뒤, 둘째 바이트는 8비트, 셋째는 16비트, 넷째는 24비트만큼 왼쪽으로 밀어 |로 합칩니다. std::byte를 그대로 밀면 결과도 std::byte여서 8비트를 넘는 비트가 사라지므로, 먼저 32비트 정수로 바꾸는 것입니다. 그래서 다른 환경에서도 같은 값을 읽습니다.

C++은 1바이트가 8비트라고 보장하지 않으므로, static_assert로 이 형식의 전제를 컴파일할 때 확인합니다. 실제 파일 형식에는 보통 맨 앞에 형식을 알아보는 고정 값(magic number), 버전, 레코드 수나 길이를 두고, 읽는 쪽은 이를 먼저 검사합니다. 모르는 버전이나 범위를 벗어난 길이는 짐작해서 읽지 않고 거부합니다.

더 깊이: C++20과 C++23의 바이트 순서 도구

C++20의 <bit>에는 지금 플랫폼의 바이트 순서를 알려 주는 std::endian이 있습니다. std::endian::native가 std::endian::little과 같으면 모든 스칼라 타입이 리틀 엔디언이고, big과 같으면 빅 엔디언이며, 둘 다 아니면 두 방식이 섞인 플랫폼입니다. C++23에는 정수의 바이트 순서를 뒤집는 std::byteswap이 더해졌으므로, 리틀 엔디언으로 정한 형식을 빅 엔디언 플랫폼에서 다룰 때 if constexpr로 갈래를 나누어 뒤집을 수 있습니다. 다만 본문처럼 시프트와 마스크로 바이트를 만드는 방법은 플랫폼의 바이트 순서를 묻지 않고도 같은 결과를 내고 C++20 이전의 컴파일러에서도 쓸 수 있으므로, 이 도구들은 그 방법을 대신하기보다 필요할 때 고르는 선택지입니다.

float이나 double을 이런 형식으로 저장할 때는 C++20의 std::bit_cast로 비트를 그대로 같은 크기의 std::uint32_t나 std::uint64_t로 옮긴 뒤 같은 방법으로 씁니다. 이때 std::numeric_limits<float>::is_iec559를 static_assert로 함께 확인해 두면, 부동 소수점 형식이 IEEE 754가 아닌 플랫폼에서는 컴파일 단계에서 알 수 있습니다. 각 도구의 정의는 cppreference의 std::endian과 그 옆의 <bit> 함수 문서에 있습니다.

객체를 통째로 쓰는 방법의 한계

구조체를 통째로 write()하고 싶을 수도 있습니다. C++은 복사해도 안전한 단순한 타입, 즉 trivially copyable 타입의 객체를 바이트 배열로 복사했다가 같은 객체로 되돌리는 것을 허용합니다.

raw_snapshot.cpp
#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_snapshot(std::ostream& out, const Record& record) {
    out.write(reinterpret_cast<const char*>(&record), sizeof record);
    return static_cast<bool>(out);
}

하지만 이것은 같은 빌드의 프로그램이 잠시 저장했다가 다시 읽는 경우에만 쓸 수 있는 방법입니다. 이 Record는 id 4바이트와 value 8바이트 사이에 정렬을 맞추는 패딩(padding) 4바이트가 들어가 Windows x86-64의 GCC에서 sizeof가 16이며, 패딩의 값은 정해져 있지 않습니다. 구조체의 배치, 기본 타입의 크기, 바이트 순서는 컴파일러와 플랫폼에 따라 다를 수 있고, 멤버를 하나 추가하면 옛 파일을 다른 뜻으로 읽게 됩니다.

포인터는 다른 실행에서는 의미가 없는 주소이며, std::string처럼 내부에 포인터와 소유 상태를 가진 객체는 바이트를 그대로 써도 글자가 저장된다는 보장이 없습니다. libstdc++처럼 짧은 문자열의 글자를 객체 안에 두는 구현에서는 글자가 함께 기록되기도 하지만, 같이 기록된 포인터는 다시 읽었을 때 의미가 없으므로 그 바이트로 객체를 되살릴 수 없습니다. 오래 보관하거나 다른 환경과 주고받을 파일은 앞의 write_u32_le처럼 필드마다 정해진 형식으로 저장하고, 레코드 크기도 sizeof(Record)가 아니라 파일 형식이 정한 상수로 둡니다.

위치를 옮겨 읽기: seekg와 tellg

레코드의 크기가 정해져 있으면, 앞의 레코드를 모두 읽지 않고 원하는 레코드의 위치로 바로 옮겨 읽을 수 있습니다. 이것을 임의 접근(random access)이라고 합니다. 읽기 위치는 seekg로 옮기고 tellg로 확인하며, 쓰기 위치는 seekp와 tellp로 다룹니다.

n번째 레코드는 파일 처음에서 n * 레코드 크기바이트 떨어진 곳에서 시작합니다. 하지만 아래 그림처럼 그 위치를 계산하기 전에 파일 크기가 레코드 크기의 배수인지, 인덱스가 레코드 수보다 작은지, 곱셈이 범위를 넘지 않는지를 먼저 확인하고, 위치를 옮긴 뒤와 읽은 뒤에도 각각 결과를 확인해야 합니다. 어느 검사에서든 실패하면 그 자리에서 거부합니다.

고정 폭 레코드 랜덤 접근의 검증 흐름

직렬화 폭 N=8바이트인 레코드의 파일·index·안전 곱셈 검사→seek 확인→read 길이·상태 확인→복원으로 진행하고 각 실패는 즉시 종료한다.

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

C++ · random access · checked flowchart

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

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

고정폭랜덤접근은각검사실패시즉시거부한다desktop과동일3검사각별도실패terminal과최종디코딩총7노드6간선이다.검사통과는실패노드를왼쪽레일로건너뛰어다음검사;실패는자기거부노드로간다.N8/file배수/index/곱셈검증→seekg및tellg→read와gcount/상태→복원이다.실패노드에서다음검사나복원으로화살표가없어실패뒤에도계속진행한다는뜻이없다.형식·범위 검증N=8 · 크기%N=0index<count · 안전곱셈요청 거부잘린 파일·범위 밖산술 범위 초과실패통과위치 이동 확인seekg 뒤 상태tellg = 의도한offset위치 이동 실패조회 실패·failbitoffset 불일치실패통과읽기 길이·상태read N · gcount=Nfail / bad 없는 완료부분 읽기·I/O 오류gcount<Nfail|eof / bad실패통과검증 후 디코딩파일 계약대로Record 값 복원실패 노드에서 다음 검사로 안 감
고정 폭 레코드의 형식·위치·길이 검증본문의 헤더 없는 N=8 파일은 형식과 인덱스, 산술을 검증한 다음 seekg와 tellg를 확인한다. 정확한 N바이트와 정상 상태를 확인한 뒤에만 Record를 디코딩한다. 각 실패는 즉시 거부한다.① 파일 형식·범위 검증본문: N=8 · fileSize % N == 0index < count · offset = index * N곱셈·파일 경계·streamoff 범위 확인요청 거부잘린 파일 · 범위 밖 인덱스산술 범위 초과② seekg 후 위치 확인스트림 상태 확인tellg() != pos_type(-1)본문은 tellg() == offset도 확인위치 이동 실패failbit · 위치 조회 실패의도한 위치와 불일치③ read 후 길이·상태 확인요청한 직렬화 폭 N만큼 readgcount() == Nfailbit·badbit 없이 완료부분 읽기·I/O 오류N보다 작은 gcountfailbit | eofbit 또는 badbit④ 모두 통과한 뒤 디코딩고정 바이트 순서로 Record 값 복원실패실패실패
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

streamoff와 pos_type을 구분

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

partial read

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

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

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

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

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
도표 제목고정 폭 랜덤 접근은 각 검사 실패 시 즉시 거부한다
관계·조건 설명원래 가로 그림과 동일한 3개 검사, 각 검사의 별도 실패 terminal과 최종 디코딩으로 총 7개 노드와 6개 간선이다. 검사 통과는 실패 노드를 왼쪽 레일로 건너뛰어 다음 검사로 향하고, 실패는 해당 거부 노드로 간다. N 8/file 배수/index/곱셈 검증→seekg 및 tellg→read와 gcount/상태→복원이다. 실패 노드에서 다음 검사나 복원으로 화살표가 없으므로 실패 뒤에도 계속 진행한다는 뜻이 아니다.
노드·연결선 라벨index<count · 안전한 곱셈
노드·연결선 라벨tellg = 의도한 offset
관계·조건 설명본문의 헤더 없는 N=8 파일은 형식과 인덱스, 산술을 검증한 다음 seekg와 tellg를 확인한다. 정확한 N 바이트와 정상 상태를 확인한 뒤에만 Record를 디코딩한다. 각 실패는 즉시 거부한다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
레코드 경계 검사

직렬화된 레코드 폭 N=8바이트인 파일에서 index와 곱셈 조건을 확인한다.

  • 직렬화 폭: 8바이트 (CHAR_BIT == 8)
  • 조건: 파일·index·안전한 offset
  • 레코드 개수는 fileSize / N으로 결정된다. 유효한 index와 offset 곱셈의 안전 조건을 통과해야 위치를 이동한다.
seek 뒤 read

seek 성공을 확인한 뒤 정해진 길이를 읽는다.

  • 순서: seek→상태→read
  • 확인: 읽은 길이·stream 상태
  • 위치 이동 실패나 부분 읽기는 복원 실행으로 이어지지 않는다.
복원 또는 종료

전체 조건을 통과한 바이트만 복원한다.

  • 성공: 레코드 복원
  • 실패: 즉시 종료
  • 유효하지 않은 바이트를 정상 레코드로 해석하지 않는다.

고정 폭 레코드 랜덤 접근의 검증 흐름

직렬화 폭 N=8바이트인 레코드의 파일·index·안전 곱셈 검사→seek 확인→read 길이·상태 확인→복원으로 진행하고 각 실패는 즉시 종료한다.

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

C++ · random access · checked flowchart

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

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

고정폭랜덤접근은각검사실패시즉시거부한다desktop과동일3검사각별도실패terminal과최종디코딩총7노드6간선이다.검사통과는실패노드를왼쪽레일로건너뛰어다음검사;실패는자기거부노드로간다.N8/file배수/index/곱셈검증→seekg및tellg→read와gcount/상태→복원이다.실패노드에서다음검사나복원으로화살표가없어실패뒤에도계속진행한다는뜻이없다.형식·범위 검증N=8 · 크기%N=0index<count · 안전곱셈요청 거부잘린 파일·범위 밖산술 범위 초과실패통과위치 이동 확인seekg 뒤 상태tellg = 의도한offset위치 이동 실패조회 실패·failbitoffset 불일치실패통과읽기 길이·상태read N · gcount=Nfail / bad 없는 완료부분 읽기·I/O 오류gcount<Nfail|eof / bad실패통과검증 후 디코딩파일 계약대로Record 값 복원실패 노드에서 다음 검사로 안 감
고정 폭 레코드의 형식·위치·길이 검증본문의 헤더 없는 N=8 파일은 형식과 인덱스, 산술을 검증한 다음 seekg와 tellg를 확인한다. 정확한 N바이트와 정상 상태를 확인한 뒤에만 Record를 디코딩한다. 각 실패는 즉시 거부한다.① 파일 형식·범위 검증본문: N=8 · fileSize % N == 0index < count · offset = index * N곱셈·파일 경계·streamoff 범위 확인요청 거부잘린 파일 · 범위 밖 인덱스산술 범위 초과② seekg 후 위치 확인스트림 상태 확인tellg() != pos_type(-1)본문은 tellg() == offset도 확인위치 이동 실패failbit · 위치 조회 실패의도한 위치와 불일치③ read 후 길이·상태 확인요청한 직렬화 폭 N만큼 readgcount() == Nfailbit·badbit 없이 완료부분 읽기·I/O 오류N보다 작은 gcountfailbit | eofbit 또는 badbit④ 모두 통과한 뒤 디코딩고정 바이트 순서로 Record 값 복원실패실패실패
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

streamoff와 pos_type을 구분

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

partial read

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

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

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

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

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
도표 제목고정 폭 랜덤 접근은 각 검사 실패 시 즉시 거부한다
관계·조건 설명원래 가로 그림과 동일한 3개 검사, 각 검사의 별도 실패 terminal과 최종 디코딩으로 총 7개 노드와 6개 간선이다. 검사 통과는 실패 노드를 왼쪽 레일로 건너뛰어 다음 검사로 향하고, 실패는 해당 거부 노드로 간다. N 8/file 배수/index/곱셈 검증→seekg 및 tellg→read와 gcount/상태→복원이다. 실패 노드에서 다음 검사나 복원으로 화살표가 없으므로 실패 뒤에도 계속 진행한다는 뜻이 아니다.
노드·연결선 라벨index<count · 안전한 곱셈
노드·연결선 라벨tellg = 의도한 offset
관계·조건 설명본문의 헤더 없는 N=8 파일은 형식과 인덱스, 산술을 검증한 다음 seekg와 tellg를 확인한다. 정확한 N 바이트와 정상 상태를 확인한 뒤에만 Record를 디코딩한다. 각 실패는 즉시 거부한다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
레코드 경계 검사

직렬화된 레코드 폭 N=8바이트인 파일에서 index와 곱셈 조건을 확인한다.

  • 직렬화 폭: 8바이트 (CHAR_BIT == 8)
  • 조건: 파일·index·안전한 offset
  • 레코드 개수는 fileSize / N으로 결정된다. 유효한 index와 offset 곱셈의 안전 조건을 통과해야 위치를 이동한다.
seek 뒤 read

seek 성공을 확인한 뒤 정해진 길이를 읽는다.

  • 순서: seek→상태→read
  • 확인: 읽은 길이·stream 상태
  • 위치 이동 실패나 부분 읽기는 복원 실행으로 이어지지 않는다.
복원 또는 종료

전체 조건을 통과한 바이트만 복원한다.

  • 성공: 레코드 복원
  • 실패: 즉시 종료
  • 유효하지 않은 바이트를 정상 레코드로 해석하지 않는다.

다음 프로그램은 ID와 점수를 4바이트씩, 레코드 하나를 8바이트로 정한 파일에서 원하는 레코드를 읽습니다.

read-records.cpp
#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 fileBytes = static_cast<std::uintmax_t>(
        static_cast<std::streamoff>(endPos));

    if (fileBytes % kRecordBytes != 0) {
        return std::nullopt; // 잘린 파일이거나 다른 형식
    }
    if (index >= fileBytes / kRecordBytes) {
        return std::nullopt; // 범위 밖의 인덱스
    }
    const auto maxOffset = static_cast<std::uintmax_t>(
        std::numeric_limits<std::streamoff>::max());
    if (index > maxOffset / kRecordBytes) {
        return std::nullopt; // 위치 계산이 범위를 넘음
    }

    const auto offset = static_cast<std::streamoff>(index * kRecordBytes);
    in.seekg(offset, std::ios::beg);
    if (!in || static_cast<std::streamoff>(in.tellg()) != 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 << "cannot read the requested record\n";
        return 1;
    }
    std::cout << "id=" << record->id << ", score=" << record->score << '\n';
}

std::optional<Record>는 C++17의 타입으로, 레코드를 읽었으면 그 값을, 읽지 못했으면 std::nullopt라는 빈 상태를 돌려줍니다. seekg(0, std::ios::end)로 끝으로 간 뒤 tellg()로 위치를 얻으면 파일 크기가 되고, tellg()가 실패하면 pos_type(-1)을 돌려줍니다. std::streamoff는 파일 안의 위치를 나타내는 부호 있는 정수 타입이어서, 곱셈 결과가 그 최댓값을 넘지 않는지도 미리 확인합니다.

레코드 파일을 만들고 읽기

읽기 프로그램을 실행하려면 같은 형식의 records.bin이 있어야 합니다. 다음 프로그램을 따로 저장해 세 레코드 (101, 80), (102, 95), (103, 70)을 기록합니다.

write-records.cpp
#include <array>
#include <climits>
#include <cstddef>
#include <cstdint>
#include <fstream>
#include <iostream>

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()), 4);
    return static_cast<bool>(out);
}

int main() {
    std::ofstream out("records.bin", std::ios::binary | std::ios::trunc);
    if (!out) {
        std::cerr << "cannot open records.bin\n";
        return 1;
    }

    const std::uint32_t records[][2]{{101, 80}, {102, 95}, {103, 70}};
    for (const auto& record : records) {
        if (!write_u32_le(out, record[0]) || !write_u32_le(out, record[1])) {
            std::cerr << "write failed\n";
            return 1;
        }
    }

    out.close();
    if (!out) {
        std::cerr << "failed to finish writing records.bin\n";
        return 1;
    }
    std::cout << "records.bin created\n";
}

두 프로그램을 같은 폴더에서 빌드하고 차례로 실행합니다. PowerShell의 (Get-Item .\records.bin).Length는 파일 크기를 바이트로 보여 줍니다.

g++ -std=c++20 -Wall -Wextra write-records.cpp -o write-records.exe
g++ -std=c++20 -Wall -Wextra read-records.cpp -o read-records.exe
.\write-records.exe
(Get-Item .\records.bin).Length
.\read-records.exe
records.bin created
24
id=103, score=70

레코드 세 개가 8바이트씩이므로 파일은 24바이트입니다. 인덱스는 0부터 세므로 2는 세 번째 레코드이고, 시작 위치는 2 × 8 = 16바이트입니다. 읽기 프로그램은 그 위치로 옮긴 것을 tellg()로 확인한 뒤 8바이트를 읽어 ID 103과 점수 70을 되돌립니다.

검사가 제대로 동작하는지도 확인합니다. 파일의 마지막 바이트를 지워 23바이트로 만들면, 8의 배수가 아니므로 파일 크기 검사에서 거부되어 cannot read the requested record를 출력하고 종료 상태 1로 끝납니다. 이 경우는 read()까지 가지 않고 거부된 것입니다. 정상 파일로 되돌린 뒤 인덱스를 3으로 바꾸면, 레코드가 세 개뿐이므로 이번에는 인덱스 검사에서 같은 메시지로 거부됩니다.

정리

  • std::ios::binary는 줄바꿈 같은 텍스트 변환을 끌 뿐, 값을 바이트로 바꾸는 방식을 정하지 않으며 <<는 여전히 글자를 씁니다.
  • write()와 read()는 바이트를 그대로 옮기며, read() 뒤에는 상태와 gcount()를 함께 확인합니다.
  • 여러 바이트의 정수는 바이트 순서를 정하고 시프트로 바이트를 직접 만들어 저장하면, 환경과 상관없이 같은 파일이 됩니다.
  • 구조체를 통째로 쓰는 것은 패딩, 배치, 바이트 순서에 묶여 같은 빌드 안의 임시 저장에만 쓸 수 있습니다.
  • 임의 접근은 파일 형식이 정한 레코드 크기로 위치를 계산하고, 파일 크기, 인덱스, 곱셈 범위, 이동, 읽기를 차례로 검사합니다.

연습 문제

1. 다른 레코드 읽기

read-records.cpp에서 인덱스를 1로 바꾸면 읽기 시작 위치와 출력은 무엇인가요? 쓰기 프로그램에 (104, 60) 레코드를 추가해 파일을 다시 만들면, 파일 크기와 인덱스 3, 4의 결과는 어떻게 되나요?

해설 보기

인덱스 1은 둘째 레코드이므로 8바이트 위치에서 8바이트를 읽어 id=102, score=95를 출력합니다. 레코드가 네 개가 되면 파일은 32바이트입니다. 인덱스 3은 24바이트 위치에서 읽어 id=104, score=60을 출력하고, 인덱스 4는 레코드 수 4와 같으므로 범위 검사에서 거부됩니다.

2. 파일에 기록되는 바이트

write_u32_le(out, 258)은 파일에 어떤 네 바이트를 어떤 순서로 기록하나요?

해설 보기

258은 16진수로 0x00000102이므로, 낮은 자리부터 0x02, 0x01, 0x00, 0x00의 순서로 기록됩니다. <<로 썼다면 '2', '5', '8'의 세 글자가 기록되었을 것이므로, 같은 값이라도 형식에 따라 파일 내용이 전혀 다릅니다.

3. 문자열 객체를 통째로 쓰기

std::string name = "Ada";를 out.write(reinterpret_cast<const char*>(&name), sizeof name);으로 저장하면 무엇이 잘못되나요? 어떻게 저장해야 하나요?

해설 보기

std::string의 객체 안에는 글자 대신 내부 저장 공간을 가리키는 포인터와 길이 같은 관리 정보가 들어 있을 수 있어서, 그 바이트를 써도 Ada가 저장된다는 보장이 없습니다. 다시 읽은 포인터는 다른 실행에서 의미 없는 주소이므로 읽는 순간 정의되지 않은 동작이 됩니다. 길이를 write_u32_le로 먼저 쓰고, 그 뒤에 name.data()에서 name.size()바이트를 쓰는 식으로 형식을 정해 저장합니다.

이것으로 8장의 파일 입출력을 마칩니다. 다음 장의 클래스와 객체의 개념에서는 데이터와 그 데이터를 다루는 함수를 하나로 묶는 클래스를 소개하고, 객체 지향 프로그래밍의 기초로 들어갑니다.