바이너리 파일 입출력
바이너리 모드와 바이트 입출력의 역할을 구분하고, 명시적인 파일 형식과 검증된 랜덤 접근을 설계합니다.
앞 절까지는 텍스트 파일 중심의 입출력을 다뤘습니다.
이번 절에서는 바이너리(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로 시작 위치를 계산할 수 있습니다. 하지만 곱셈이 위치 타입의 범위를 넘지 않는지, 요청한 레코드가 파일 안에 완전히 들어오는지, 실제 이동과 읽기가 성공했는지 모두 확인해야 합니다.
고정 폭 레코드 랜덤 접근의 검증 흐름
직렬화 폭 N=8바이트인 레코드의 파일·index·안전 곱셈 검사→seek 확인→read 길이·상태 확인→복원으로 진행하고 각 실패는 즉시 종료한다.
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가 자동으로 안전해진다는 뜻이 아닙니다. 파일 경계와 타입 범위를 먼저 검증하면 오버플로, 범위 밖 이동, 잘린 레코드를 서로 분리해 처리할 수 있습니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 도표 제목 | 고정 폭 랜덤 접근은 각 검사 실패 시 즉시 거부한다 |
| 관계·조건 설명 | 원래 가로 그림과 동일한 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와 곱셈 조건을 확인한다. |
|
|
| seek 뒤 read seek 성공을 확인한 뒤 정해진 길이를 읽는다. |
|
|
| 복원 또는 종료 전체 조건을 통과한 바이트만 복원한다. |
|
|
고정 폭 레코드 랜덤 접근의 검증 흐름
직렬화 폭 N=8바이트인 레코드의 파일·index·안전 곱셈 검사→seek 확인→read 길이·상태 확인→복원으로 진행하고 각 실패는 즉시 종료한다.
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가 자동으로 안전해진다는 뜻이 아닙니다. 파일 경계와 타입 범위를 먼저 검증하면 오버플로, 범위 밖 이동, 잘린 레코드를 서로 분리해 처리할 수 있습니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 도표 제목 | 고정 폭 랜덤 접근은 각 검사 실패 시 즉시 거부한다 |
| 관계·조건 설명 | 원래 가로 그림과 동일한 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와 곱셈 조건을 확인한다. |
|
|
| seek 뒤 read seek 성공을 확인한 뒤 정해진 길이를 읽는다. |
|
|
| 복원 또는 종료 전체 조건을 통과한 바이트만 복원한다. |
|
|
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을 덮어쓰므로 새 실습 폴더에서 실행하세요.
#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.exerecords.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() 호출 자체보다, 미래의 읽기 코드가 같은 바이트 계약을 검증하고 해석할 수 있는지에서 결정됩니다.