본문 바로가기

바이너리 파일 입출력

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

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

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

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

논리 값을 고정 폭·바이트 순서·길이와 버전을 정한 파일 형식으로 인코딩하고 검증해 복원하는 경로와, 객체 표현을 그대로 복사해 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 결합 원시 스냅샷

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


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

텍스트 모드에서는 실행 환경이 줄바꿈 같은 외부 파일 표현을 변환할 수 있습니다. 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로 시작 위치를 계산할 수 있습니다. 하지만 곱셈이 위치 타입의 범위를 넘지 않는지, 요청한 레코드가 파일 안에 완전히 들어오는지, 실제 이동과 읽기가 성공했는지 모두 확인해야 합니다.

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

직렬화 폭 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 상태
  • 위치 이동 실패나 부분 읽기는 복원 실행으로 이어지지 않는다.
복원 또는 종료

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

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

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 << "요청한 레코드를 온전히 읽지 못했습니다.\n";
        return 1;
    }
    std::cout << "id=" << record->id << ", score=" << record->score << '\n';
}

헤더가 있는 파일이라면 headerBytes도 같은 방식으로 범위를 검사한 뒤 오프셋에 더합니다. 가변 길이 레코드는 index * sizeof(Record)로 찾을 수 없으므로 별도의 오프셋 테이블이나 길이 접두사 인덱스가 필요합니다.

레코드 파일을 만들고 읽기

읽기 코드를 실행하려면 같은 형식의 records.bin이 먼저 있어야 합니다. 앞의 읽기 프로그램을 read-records.cpp로 저장하고, 아래 프로그램은 별도의 write-records.cpp로 저장하세요. 두 프로그램의 main을 한 파일에 합치지 않습니다.

쓰기 프로그램은 앞에서 본 write_u32_le와 같은 방법으로 ID와 점수를 각각 4옥텟씩 기록합니다. 값은 (101, 80), (102, 95), (103, 70)이며, 파일 크기는 3 × 8 = 24바이트입니다. 기존 records.bin을 덮어쓰므로 새 실습 폴더에서 실행하세요.

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 << "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 << "레코드 쓰기 실패\n";
            return 1;
        }
    }

    out.close();
    if (!out) {
        std::cerr << "records.bin 쓰기 또는 닫기 실패\n";
        return 1;
    }
    std::cout << "records.bin 생성 완료\n";
}

Windows PowerShell에서 두 소스 파일을 저장한 폴더로 이동한 뒤 실행합니다.

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 생성 완료
24
id=103, score=70

인덱스는 0부터 시작하므로 2는 세 번째 레코드입니다. 시작 위치는 2 × 8 = 16이고, 읽기 코드는 tellg()로 이 위치에 도달했는지 확인한 뒤 8바이트를 읽습니다. 파일을 찾지 못한다면 두 프로그램을 같은 폴더에서 실행했는지 먼저 확인하세요. 상대 경로는 소스 파일의 위치가 아니라 실행할 때의 작업 폴더를 기준으로 합니다.

잘린 파일과 범위 밖 인덱스 확인하기

정상 값을 읽는 것만으로 검사가 끝나지는 않습니다. 이번에는 PowerShell에서 실습 파일의 마지막 바이트를 빼고 다시 읽어 보세요.

$recordFile = (Resolve-Path .\records.bin).Path
$recordBytes = [System.IO.File]::ReadAllBytes($recordFile)
[System.IO.File]::WriteAllBytes($recordFile, [byte[]]($recordBytes[0..22]))
(Get-Item .\records.bin).Length
.\read-records.exe
$LASTEXITCODE

파일 크기는 23이고, 프로그램은 요청한 레코드를 온전히 읽지 못했습니다.를 출력한 뒤 종료 코드 1을 반환합니다. 이 파일은 8바이트 레코드의 배수가 아니므로 파일 크기 검사에서 거부됩니다. 아직 read()까지 진행하지 않았으며, 이 결과를 gcount()의 부분 읽기 관찰로 해석하지 않습니다.

다음으로 write-records.exe를 다시 실행해 정상 파일을 만듭니다. read-records.cpp의 read_record("records.bin", 2)에서 인덱스만 3으로 바꾸고 다시 빌드하세요.

.\write-records.exe
g++ -std=c++20 -Wall -Wextra read-records.cpp -o read-records.exe
.\read-records.exe
$LASTEXITCODE

레코드는 세 개뿐이므로 인덱스 3은 범위 밖입니다. 이 경우에도 오류 메시지와 종료 코드 1을 확인할 수 있습니다. 인덱스를 다시 2로 바꾸고 빌드하면 정상 값으로 돌아옵니다. 파일 크기 검사와 인덱스 검사는 다른 조건이므로 두 실패를 나누어 확인하세요.

다른 레코드를 혼자 선택하기

정상 세 레코드 파일로 복구한 뒤 읽기 인덱스를 1로 바꾸었습니다. 읽기 시작 오프셋, 요청 바이트 수, ID와 점수를 계산하세요. 이어 쓰기 배열에 (104,60) 레코드를 하나 추가해 새로 만들었다면, 파일 크기와 인덱스 3·4의 허용 여부는 어떻게 달라지나요?

파일 형식 상수로 계산하기

인덱스 1은 둘째 레코드이며 시작 오프셋은 8, 요청한 크기는 8바이트입니다. id=102, score=95입니다. 네 레코드 파일은 32바이트입니다. 인덱스 3은 시작 오프셋 24에서 8바이트를 읽어 104·60을 얻고, 인덱스 4는 recordCount 값 4와 같아서 범위 검사에서 거부됩니다. sizeof(Record)를 측정해 이 8을 대신하지 않습니다. 이 문제는 쓰기 완료 후 파일이 다른 작업에 의해 변경되지 않았고, 위치 이동과 읽기가 성공하는 조건입니다. 동시 수정이나 I/O 실패는 별도의 검사 경로입니다.


바이너리 입출력 점검 요약

  • std::ios::binary는 외부 파일 모드를 고를 뿐, operator<<를 원시 바이트 출력으로 바꾸지 않는다.
  • 파일 형식은 magic, 지원 version, 고정 폭, byte order, length와 검증 규칙을 명시한다.
  • 객체 표현의 원시 복사는 trivially copyable 조건을 만족해도 ABI·패딩·엔디안·버전에 결합된다.
  • 랜덤 접근은 직렬화된 레코드 폭, 안전한 오프셋 산술, 파일 경계, seekg·tellg 결과를 확인한다.
  • read() 뒤에는 스트림 상태와 gcount()를 함께 확인해 부분 읽기를 성공으로 오해하지 않는다.

결국 바이너리 I/O의 안정성은 write() 호출 자체보다, 미래의 읽기 코드가 같은 바이트 계약을 검증하고 해석할 수 있는지에서 결정됩니다.