CodeGym /행동 /C# SELF /바이너리 포맷과 그 문제들, 더 깊게

바이너리 포맷과 그 문제들, 더 깊게

C# SELF
레벨 43 , 레슨 3
사용 가능

1. 소개

간단히 말하면: 바이너리 직렬화 포맷은 객체를 그 구조와 값을 최대한 압축된 바이트 시퀀스로 바꿉니다. JSON이나 XML처럼 객체를 글로 설명하는 게 아니라, 메모리에 저장된 것처럼 각 비트를 그대로 적는다고 생각하면 돼요.

텍스트 포맷은 친구에게 쓰는 편지 같아서(각 문자 = 사람이 이해할 수 있음) 읽기 쉽습니다. 바이너리 포맷은 모스 부호에 더 가깝습니다 — 점과 대시만 최대한 짧게 찍혀 있어서 사람이 "수작업"으로 읽을 수 없죠.

도표: 포맷 비교

포맷 사람이 읽을 수 있나 파일 크기 속도 (쓰기/읽기) 호환성
XML/JSON 느림 좋음
바이너리 아니요 작음 아주 빠름 제한적

.NET에서 바이너리 직렬화는 어떻게 동작하나?

.NET 생태계에는 역사적으로 바이너리 직렬화를 위한 대표 도구가 있었는데, 그게 BinaryFormatter예요. 하지만 플랫폼이 발전하면서 이 도구는 안전하지 않다고 판단되어 .NET 9에서 제거되었습니다. 지금은 주로 BinaryWriter/BinaryReader를 사용하거나, 복잡한 객체는 외부 라이브러리(e.g. protobuf-net)를 씁니다.

옛날 이야기(역사적 배경)

BinaryFormatter는 [Serializable]로 표시된 아무 클래스나 바이트로 바꿔주고, 역직렬화 시 객체 구조를 복원해주는 기능이 있었어요. 마법처럼 들리지만, 그 안에는 많은 문제(아래에 설명)가 숨어 있었습니다.

요즘 방법들

원시 타입이나 단순한 구조체는 BinaryWriterBinaryReader를 쓰는 게 편해요. 복잡한 객체나 크로스플랫폼 호환성을 원하면 외부 라이브러리(e.g. protobuf-net, MessagePack-CSharp 등)를 고려하세요.

2. BinaryWriter로 원시 타입 직렬화하기

우리 학습 앱을 계속 개선한다고 가정합시다. 예를 들어 사용자 설정(사용자 이름, 점수, 로그인 시간)을 바이너리 파일에 저장하고 싶다고 합시다.

public class UserProfile
{
    public string Name { get; set; }
    public int Score { get; set; }
    public DateTime LoginTime { get; set; }
}

public static Task SaveUserProfile(UserProfile profile, string filePath)
{
    // 파일을 쓰기용으로 연다
    using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
    using var writer = new BinaryWriter(stream);

    // 데이터를 부분별로 쓴다. 먼저 문자열, 그다음 숫자, 그다음 날짜
    writer.Write(profile.Name ?? string.Empty); // 문자열
    writer.Write(profile.Score);                // 정수
    writer.Write(profile.LoginTime.ToBinary()); // 날짜는 "long"으로 변환해서 저장
}

이제 읽기 예제:

public static Task<UserProfile> LoadUserProfile(string filePath)
{
    using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
    using var reader = new BinaryReader(stream);

    string name = reader.ReadString();
    int score = reader.ReadInt32();
    long dateData = reader.ReadInt64();
    DateTime loginTime = DateTime.FromBinary(dateData);

    return new UserProfile { Name = name, Score = score, LoginTime = loginTime };
}

원시 타입 바이너리 직렬화의 특징

BinaryWriter로 각 속성을 따로 직렬화합니다. 이 방식은 신뢰할 수 있고 예측 가능합니다: 데이터 구조가 바뀌면 코드에서 그 변화를 바로 확인할 수 있어요.

3. 고전적 바이너리 직렬화의 문제들

이제 문제점들을 보죠. 왜 Microsoft가 BinaryFormatter를 강하게 비판하고 사용을 금지했을까요?

포맷의 취약성 (Schema Evolution Hell)

바이너리 데이터는 클래스 구조에 강하게 묶여 있어요. 클래스 이름을 바꾸거나, 필드를 추가/삭제하면 이전 바이너리 파일이 읽히지 않을 수 있습니다. 속성 순서를 바꿔도 문제예요.

예시:

// 어제
public class Profile
{
    public string Name;
    public int Score;
}

// 오늘
public class Profile
{
    public string Name;
    public double Rating; // 새로운 필드가 추가됨
    public int Score;
}

예전 파일을 읽으면 예외가 발생하거나 필드들이 엉뚱한 값으로 읽힐 수 있습니다. JSON이나 XML과 달리 누락된 요소를 건너뛸 수 있는 유연성이 없고, 바이너리 포맷은 콘크리트로 단단히 부어둔 도로 같아서 조금만 어긋나도 큰 사고가 납니다.

역직렬화 취약점

BinaryFormatter의 최대 문제는 잠재적 보안 취약점이에요. 소프트웨어가 네트워크 등 신뢰할 수 없는 소스로부터 받은 바이너리 데이터를 역직렬화하면, 공격자가 악성 객체를 주입해서 원격에서 임의 코드를 실행할 수 있습니다. 과거에 이런 취약점으로 실제 원격 코드 실행이 발생한 사례가 있습니다.

크로스플랫폼과 호환성 문제

바이너리 직렬화는 .NET의 내부 표현, 런타임 버전, 컴파일러, 아키텍처(x64/ARM 등)에 강하게 의존합니다. Windows에서 직렬화하고 Linux에서 역직렬화하면 의외의 문제가 생길 수 있어요. 심지어 .NET 버전 간에도 호환성 문제가 자주 일어납니다.

디버깅의 불편함

텍스트 포맷이면 파일을 열어서 내용을 보고 무슨 일이 일어났는지 유추할 수 있죠. 바이너리 파일은 바이트의 흐름일 뿐이라서 문제를 파악하기가 훨씬 어렵습니다. 바이너리 분석은 취미가 아니면 즐겁지 않을 겁니다.

4. 복잡한 객체의 바이너리 직렬화

참조

BinaryFormatter는 객체 간의 참조(예: 두 속성이 같은 객체를 가리킬 때)를 기억할 수 있었지만, BinaryWriter나 대부분의 외부 라이브러리는 그런 마법을 자동으로 해주지 않습니다. 보통은 객체를 중첩해서 차례대로 쓰는 방식이에요.

순환 참조

순환 참조가 있는 객체(예: 부모의 Child 속성이 있고, 자식의 Parent가 부모를 가리키는 경우)를 직렬화하면 예외가 발생하거나 무한 루프에 빠질 수 있습니다.

예시:

public class Node
{
    public Node? Next { get; set; }
    public Node? Prev { get; set; }
}

이 객체를 단순한 방식으로 직렬화하면 순환 참조 때문에 문제가 생깁니다.

5. 바이너리 직렬화와 이식성

직접 만든 바이너리 포맷은 대체로 "우리만의" 포맷이에요. 다른 프로그램과 데이터를 주고받거나 오래 보관하려면 공개 표준을 선택하세요: JSON, XML 또는 ProtoBuf.

언제 바이너리 직렬화가 적절한가?

  • 데이터가 한 애플리케이션 내에서만 사용되고 "단기간" 저장될 때.
  • 속도와 크기가 중요할 때(예: 대용량 로그나 동일한 생태계 내 서비스 간 통신).
  • 직렬화 쪽과 역직렬화 쪽을 모두 엄격히 통제할 수 있을 때.

대안: protobuf, MessagePack 등

  • protobuf-net: .NET용 Google Protocol Buffers 포트로, 크로스플랫폼 교환과 호환성에 적합합니다.
  • MessagePack-CSharp: .NET용 빠른 MessagePack 구현체입니다.

"날것"의 BinaryWriter와 달리, 이런 라이브러리들은 스키마를 제공하거나 포맷 진화 지원, 크로스플랫폼성, 보안성 등을 갖추고 있습니다. 다른 시스템과의 호환을 생각한다면 이런 걸 쓰세요.

6. "수동" 바이너리 직렬화

그래도 바이너리로 저장해야 하는 상황(예: 퍼포먼스가 진짜 중요할 때)이 있다면 BinaryWriter/BinaryReader를 쓰되, 항상 데이터의 순서와 타입을 명시적으로 코딩하세요.

팁:

  • 항상 데이터를 읽을 순서와 동일한 순서로 쓰세요.
  • 구조 변경 시 버전 번호를 유지하거나 "매직 헤더"(Magic Header)를 써서 포맷을 구분하세요.
  • 문자열/배열은 본문을 쓰기 전에 길이를 먼저 기록하세요.
  • 파일 구조를 문서화하세요: 1년 지나면 본인도 형식을 잊습니다.

예시: 버전 관리

// 파일 맨 앞에 포맷 버전 번호를 저장
writer.Write((byte)1); // 버전 1

writer.Write(profile.Name ?? "");
writer.Write(profile.Score);
writer.Write(profile.LoginTime.ToBinary());

/*
나중에 포맷이 바뀌면 읽기 로직에 조건을 추가할 수 있게 해준다
*/

7. 바이너리 직렬화 작업 시 자주 하는 실수

필드를 한 순서로 인코딩했는데, 읽을 때 순서를 바꾼다. 그러면 값들이 어긋납니다: 문자열을 int로 읽거나, int를 날짜로 읽는 식이 됩니다.

10개의 객체를 썼는데 읽을 때는 11개를 읽으려 한다. 스트림 흐름이 깨져서 파일 끝에 도달했다는 예외가 발생합니다.

클래스 구조를 변경했는데 이전 바이너리 파일을 읽을 수 없다 — 데이터 히스토리를 잃게 됩니다.

중요한 파일을 읽을 때 예외 처리를 빼먹는다 — 디스크 오류나 손상으로 앱이 바로 터집니다(예: EndOfStreamException).

명확한 포맷 없이 서로 다른 언어 간에 바이너리 파일을 교환하려 한다99%의 경우 골치 아픈 일이 생깁니다.

네트워크로부터 온 알 수 없는 사용자 데이터로부터 역직렬화한다 — 보안 취약점의 지름길입니다! 절대 BinaryFormatter를 사용하지 말고, 입력을 검증하며 안전한 포맷을 사용하세요.

2
과제
C# SELF, 레벨 43, 레슨 3
잠금
사용자 데이터를 바이너리 파일에 저장하기
사용자 데이터를 바이너리 파일에 저장하기
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION