본문 바로가기

DataOutputStream 파일 형식과 버전 관리

DataOutputStream으로 기록한 바이트를 다른 순서로 읽으면 무슨 일이 생기는지 확인하고, 파일 앞에 매직 넘버·버전·개수를 두어 잘못된 파일을 일찍 거르는 법과 형식을 안전하게 바꾸는 순서, 자바 객체 직렬화를 오래 보관할 형식으로 쓰기 어려운 이유를 배웁니다.

앞 글에서는 회원 정보를 텍스트 행으로 저장했습니다. 텍스트는 사람이 열어 볼 수 있지만, 나이 31을 문자 3과 1로 적었다가 읽을 때 Integer.parseInt로 되돌려야 하고 필드를 나눌 구분자 문제도 따로 풀어야 했습니다.

버퍼 스트림을 다룬 글에서 만난 DataOutputStream은 값을 문자로 바꾸지 않고 정해진 바이트로 기록하며, 그 바이트는 짝이 되는 DataInputStream으로 읽습니다. 이 글에서는 두 클래스를 묶어 DataStream이라고 부르겠습니다. writeInt는 int를 언제나 4바이트로 쓰고, writeUTF는 문자열의 바이트 수를 2바이트로 먼저 적은 뒤 본문을 씁니다. 길이를 먼저 적으므로 이름에 쉼표나 줄바꿈이 들어 있어도 문제가 되지 않습니다.

그런데 이런 이진(binary) 파일에는 값의 바이트만 들어가고 필드 이름이나 타입은 들어가지 않습니다. 읽는 쪽이 쓴 쪽과 같은 순서로 같은 메서드를 불러야만 값이 제대로 돌아온다는 뜻이고, 메서드를 부르는 순서 자체가 파일의 스키마(schema), 즉 데이터 구조의 정의가 됩니다. 이 글에서는 순서가 어긋나면 무슨 일이 일어나는지 바이트 단위로 확인하고, 파일 앞에 매직 넘버와 버전, 개수를 적어 잘못된 파일을 일찍 거르는 형식을 만듭니다. 이어서 이미 쓰고 있는 형식을 바꾸는 순서를 정리하고, 객체를 통째로 저장하는 ObjectOutputStream을 오래 보관할 형식으로 권하지 않는 이유를 살펴봅니다. 실행 결과는 JDK 21에서 얻었습니다.

쓴 순서와 다르게 읽으면

다음 프로그램은 이름 Mina를 writeUTF로, 나이 31을 writeInt로 쓴 다음, 읽을 때는 실수로 readInt부터 부릅니다. 바이트를 파일 대신 메모리에 모으는 ByteArrayOutputStream을 썼지만, Files.newOutputStream으로 바꿔도 파일에 같은 바이트가 들어갑니다.

bad/WrongDataOrder.java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;

public final class WrongDataOrder {
    public static void main(String[] args) throws Exception {
        var bytes = new ByteArrayOutputStream();
        try (var output = new DataOutputStream(bytes)) {
            output.writeUTF("Mina");
            output.writeInt(31);
        }
        try (var input = new DataInputStream(
                new ByteArrayInputStream(bytes.toByteArray()))) {
            int wrongAge = input.readInt();
            System.out.println("wrongAge=" + wrongAge);
            System.out.println("remainingName=" + input.readUTF());
        }
    }
}
wrongAge=281961
Exception in thread "main" java.io.EOFException

예외 아래의 스택 트레이스는 생략했습니다. 무슨 일이 일어났는지 보려면 기록된 10바이트를 놓고, 쓸 때 값을 나눈 위치와 잘못 읽을 때 나눈 위치를 나란히 비교해야 합니다.

위치바이트쓸 때의 뜻잘못 읽을 때의 해석
0–300 04 4D 69이름 길이 4와 MireadInt가 281961로 읽음
4–56E 61이름의 나머지 nareadUTF가 본문 길이 28257로 읽음
6–900 00 00 1FwriteInt(31)의 4바이트28257바이트를 채우지 못하고 파일 끝

readInt는 앞의 네 바이트를 큰 자리부터 이어 붙여 int 하나로 만듭니다. 0×256³ + 4×256² + 77×256 + 105를 계산하면 281,961이므로 첫 줄에 wrongAge=281961이 찍혔습니다. 다음 readUTF는 이어지는 두 바이트 6E 61을 길이로 읽어 110×256 + 97 = 28,257바이트를 기다리지만, 남은 것은 나이의 네 바이트뿐입니다. 그래서 읽을 데이터가 끝났다는 뜻의 EOFException이 납니다. 아래 그림이 같은 과정을 읽기 위치와 함께 보여 줍니다.

같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다

UTF 이름과 int 나이를 쓴 10바이트를 잘못된 순서로 읽으면 첫 네 바이트가 281961, 다음 두 바이트가 길이 28257로 해석되어 EOF에 도달합니다. 계산된 바이트 경계의 소비 순서를 보여 줄 수 있습니다.

같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다

WrongDataOrder의 상수 입력을 바이트 위치별로 해석합니다. UTF 이름 뒤 int라는 기록 순서와 int 뒤 UTF라는 판독 순서를 비교합니다.

바이너리 형식 · 10바이트 경계

같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다

UTF 이름을 먼저 쓴 바이트에서 int를 먼저 읽으면, 다음 길이도 28,257로 잘못 해석됩니다.

같은 바이트 축에 다른 읽기 경계를 놓는다

같은 바이트 축에 다른 읽기 경계를 놓는다쓰기 경계는 길이 2 B·Mina 4 B·int 나이 4 B다. 잘못된 읽기는 첫 4 B를 281961로, 다음 2 B를 길이 28257로 해석하며 남은 4 B로 채울 수 없어 EOFException이다.쓰기 경계 · 2 + 4 + 4 B4Mina31읽기 경계 · 4 + 2 + 4 B281961길이4 B잘못 읽은 길이 = 28257 B남은 4 B로 충족 불가EOFException
같은 바이트 축에 다른 읽기 경계를 놓는다쓰기 경계는 길이 2 B·Mina 4 B·int 나이 4 B다. 잘못된 읽기는 첫 4 B를 281961로, 다음 2 B를 길이 28257로 해석하며 남은 4 B로 채울 수 없어 EOFException이다.쓰기 경계 · 2 + 4 + 4 B4Mina31읽기 경계 · 4 + 2 + 4 B281961길이4 B잘못 읽은 길이 = 28257 B남은 4 B로 충족 불가EOFException

쓰기 경계는 길이 2 B·Mina 4 B·int 나이 4 B다. 잘못된 읽기는 첫 4 B를 281961로, 다음 2 B를 길이 28257로 해석하며 남은 4 B로 채울 수 없어 EOFException이다.

상태별 상세 해설
  1. 0–3: 00 04 4D 69

    기록된 원래 의미
    수정 UTF 길이 4와 이름의 Mi
    잘못된 읽기의 해석
    readInt가 281961로 해석
  2. 4–5: 6E 61

    기록된 원래 의미
    이름의 남은 na
    잘못된 읽기의 해석
    readUTF가 본문 길이 28257로 해석
  3. 6–9: 00 00 00 1F

    기록된 원래 의미
    writeInt(31)의 네 바이트
    잘못된 읽기의 해석
    요구한 28257바이트보다 짧아 EOFException

원문 상수에서 계산한 바이트 해석이며 실행 추적이 아닙니다. 타입 이름은 저장되지 않으므로 양쪽의 읽기·쓰기 순서가 맞아야 합니다.

이 예의 조건

  • 원문 10바이트에 따른 계산이며 새 실행 관측이 아니다.
  • 잘못된 readInt/readUTF 순서를 추적한다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
8수정된 UTF의 길이 4와 이름의 Mi
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다 같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다 WrongDataOrder의 상수 입력을 바이트 위치별로 해석합니다. UTF 이름 뒤 int라는 기록 순서와 int 뒤 UTF라는 판독 순서를 비교합니다. 바이너리 형식 · 10바이트 경계 같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다 UTF 이름을 먼저 쓴 바이트에서 int를 먼저 읽으면, 다음 길이도 28,257로 잘못 해석됩니다. 0–3: 00 04 4D 69 기록된 원래 의미 수정된 UTF의 길이 4와 이름의 Mi 잘못된 읽기의 해석 readInt가 281961로 해석 4–5: 6E 61 기록된 원래 의미 이름의 남은 na 잘못된 읽기의 해석 readUTF가 본문 길이 28257로 해석 6–9: 00 00 00 1F 기록된 원래 의미 writeInt(31)의 네 바이트 잘못된 읽기의 해석 요구한 28257바이트보다 짧아 EOFException 원문 상수에서 계산한 바이트 해석이며 실행 추적이 아닙니다. 타입 이름은 저장되지 않으므로 양쪽의 읽기·쓰기 순서가 맞아야 합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
쓰기 결과

UTF Mina와 int 31의 바이트를 한 축에 놓습니다.

  • wire: 00 04 4D 69 6E 61 00 00 00 1F
  • 커서: 0
  • 쓰기 순서는 UTF 길이·이름·int 나이다.
잘못된 첫 읽기

readInt가 이름의 길이와 첫 두 문자를 숫자로 소비합니다.

  • 소비 범위: [0,4)
  • int 결과: 281961
  • 커서: 4
  • 이름 경계에서 읽기 타입이 달라져 네 바이트가 한 int로 묶인다.
불가능한 UTF 길이

readUTF가 나머지 이름 두 바이트를 길이로 읽고 데이터를 기다립니다.

  • UTF 길이: 0x6E61 = 28257
  • 남은 데이터: 4바이트
  • 결과: EOFException
  • 길이가 남은 데이터보다 커서 다음 레코드 결과를 만들지 못한다.

같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다

UTF 이름과 int 나이를 쓴 10바이트를 잘못된 순서로 읽으면 첫 네 바이트가 281961, 다음 두 바이트가 길이 28257로 해석되어 EOF에 도달합니다. 계산된 바이트 경계의 소비 순서를 보여 줄 수 있습니다.

같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다

WrongDataOrder의 상수 입력을 바이트 위치별로 해석합니다. UTF 이름 뒤 int라는 기록 순서와 int 뒤 UTF라는 판독 순서를 비교합니다.

바이너리 형식 · 10바이트 경계

같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다

UTF 이름을 먼저 쓴 바이트에서 int를 먼저 읽으면, 다음 길이도 28,257로 잘못 해석됩니다.

같은 바이트 축에 다른 읽기 경계를 놓는다

같은 바이트 축에 다른 읽기 경계를 놓는다쓰기 경계는 길이 2 B·Mina 4 B·int 나이 4 B다. 잘못된 읽기는 첫 4 B를 281961로, 다음 2 B를 길이 28257로 해석하며 남은 4 B로 채울 수 없어 EOFException이다.쓰기 경계 · 2 + 4 + 4 B4Mina31읽기 경계 · 4 + 2 + 4 B281961길이4 B잘못 읽은 길이 = 28257 B남은 4 B로 충족 불가EOFException
같은 바이트 축에 다른 읽기 경계를 놓는다쓰기 경계는 길이 2 B·Mina 4 B·int 나이 4 B다. 잘못된 읽기는 첫 4 B를 281961로, 다음 2 B를 길이 28257로 해석하며 남은 4 B로 채울 수 없어 EOFException이다.쓰기 경계 · 2 + 4 + 4 B4Mina31읽기 경계 · 4 + 2 + 4 B281961길이4 B잘못 읽은 길이 = 28257 B남은 4 B로 충족 불가EOFException

쓰기 경계는 길이 2 B·Mina 4 B·int 나이 4 B다. 잘못된 읽기는 첫 4 B를 281961로, 다음 2 B를 길이 28257로 해석하며 남은 4 B로 채울 수 없어 EOFException이다.

상태별 상세 해설
  1. 0–3: 00 04 4D 69

    기록된 원래 의미
    수정 UTF 길이 4와 이름의 Mi
    잘못된 읽기의 해석
    readInt가 281961로 해석
  2. 4–5: 6E 61

    기록된 원래 의미
    이름의 남은 na
    잘못된 읽기의 해석
    readUTF가 본문 길이 28257로 해석
  3. 6–9: 00 00 00 1F

    기록된 원래 의미
    writeInt(31)의 네 바이트
    잘못된 읽기의 해석
    요구한 28257바이트보다 짧아 EOFException

원문 상수에서 계산한 바이트 해석이며 실행 추적이 아닙니다. 타입 이름은 저장되지 않으므로 양쪽의 읽기·쓰기 순서가 맞아야 합니다.

이 예의 조건

  • 원문 10바이트에 따른 계산이며 새 실행 관측이 아니다.
  • 잘못된 readInt/readUTF 순서를 추적한다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
8수정된 UTF의 길이 4와 이름의 Mi
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다 같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다 WrongDataOrder의 상수 입력을 바이트 위치별로 해석합니다. UTF 이름 뒤 int라는 기록 순서와 int 뒤 UTF라는 판독 순서를 비교합니다. 바이너리 형식 · 10바이트 경계 같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다 UTF 이름을 먼저 쓴 바이트에서 int를 먼저 읽으면, 다음 길이도 28,257로 잘못 해석됩니다. 0–3: 00 04 4D 69 기록된 원래 의미 수정된 UTF의 길이 4와 이름의 Mi 잘못된 읽기의 해석 readInt가 281961로 해석 4–5: 6E 61 기록된 원래 의미 이름의 남은 na 잘못된 읽기의 해석 readUTF가 본문 길이 28257로 해석 6–9: 00 00 00 1F 기록된 원래 의미 writeInt(31)의 네 바이트 잘못된 읽기의 해석 요구한 28257바이트보다 짧아 EOFException 원문 상수에서 계산한 바이트 해석이며 실행 추적이 아닙니다. 타입 이름은 저장되지 않으므로 양쪽의 읽기·쓰기 순서가 맞아야 합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
쓰기 결과

UTF Mina와 int 31의 바이트를 한 축에 놓습니다.

  • wire: 00 04 4D 69 6E 61 00 00 00 1F
  • 커서: 0
  • 쓰기 순서는 UTF 길이·이름·int 나이다.
잘못된 첫 읽기

readInt가 이름의 길이와 첫 두 문자를 숫자로 소비합니다.

  • 소비 범위: [0,4)
  • int 결과: 281961
  • 커서: 4
  • 이름 경계에서 읽기 타입이 달라져 네 바이트가 한 int로 묶인다.
불가능한 UTF 길이

readUTF가 나머지 이름 두 바이트를 길이로 읽고 데이터를 기다립니다.

  • UTF 길이: 0x6E61 = 28257
  • 남은 데이터: 4바이트
  • 결과: EOFException
  • 길이가 남은 데이터보다 커서 다음 레코드 결과를 만들지 못한다.

이 실행에서 더 눈여겨볼 것은 첫 번째 readInt가 실패하지 않았다는 점입니다. 어떤 4바이트든 int로는 해석할 수 있으므로, 터무니없는 나이 281,961은 아무 경고 없이 만들어졌습니다. 이번에는 다음 읽기가 우연히 채울 수 없는 길이를 만나 예외가 났지만, 바이트 모양에 따라서는 모든 읽기가 '성공'하고 엉뚱한 값만 남을 수도 있습니다. 읽기 코드와 쓰기 코드가 각각 문제없이 컴파일된다고 해서 둘이 서로 맞는다는 뜻은 아닌 것입니다.

그래서 같은 형식을 읽고 쓰는 코드는 한 클래스 안에 나란히 두고, 바이트 배치를 문서에 적어 둡니다. 형식을 바꿀 때는 버전 번호를 올리고, 이전 버전을 읽는 코드를 남기거나 옛 파일을 새 형식으로 바꾸는 변환 도구를 함께 제공합니다. 다음 절에서 만드는 파일 형식이 그 출발점입니다.

매직 넘버·버전·개수로 파일 머리 만들기

파일을 열자마자 내용을 읽기 시작하면, 엉뚱한 파일을 넘겨받았을 때 앞의 예처럼 이상한 값이 만들어진 뒤에야 문제를 알게 됩니다. 그래서 이진 형식은 보통 파일 맨 앞에 정해진 머리(header)를 둡니다. 첫 몇 바이트에는 이 형식의 파일임을 알리는 고정된 값, 매직 넘버(magic number)를 적습니다. PNG 이미지가 89 50 4E 47로, 자바 클래스 파일이 CA FE BA BE로 시작하는 것이 그 예입니다. 매직 넘버 다음에는 뒤의 바이트를 어떻게 해석할지 알려 주는 형식 버전을, 그다음에는 뒤따르는 레코드 수를 적습니다.

레코드 수에는 상한이 필요합니다. 읽는 쪽은 이 수를 보고 목록을 미리 준비하는데, 손상되었거나 누군가 일부러 조작한 파일이 20억 같은 값을 적어 두면 new ArrayList<>(count)가 거대한 배열을 만들려다 메모리를 다 써 버릴 수 있습니다. 파일에서 읽은 수는 믿기 전에 범위부터 확인해야 합니다.

src/VersionedMemberData.java
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public final class VersionedMemberData {
    private static final int MAGIC = 0x4D454D42; // ASCII "MEMB"
    private static final int VERSION = 1;
    private static final int MAX_COUNT = 10_000;
    record Member(String id, String name, int age) {}

    static void write(Path file, List<Member> members) throws IOException {
        try (var output = new DataOutputStream(
                new BufferedOutputStream(Files.newOutputStream(file)))) {
            output.writeInt(MAGIC);
            output.writeInt(VERSION);
            output.writeInt(members.size());
            for (Member member : members) {
                output.writeUTF(member.id());
                output.writeUTF(member.name());
                output.writeInt(member.age());
            }
        }
    }

    static List<Member> read(Path file) throws IOException {
        try (var input = new DataInputStream(
                new BufferedInputStream(Files.newInputStream(file)))) {
            int magic = input.readInt();
            if (magic != MAGIC) throw new IOException("not a member file: magic " + Integer.toHexString(magic));
            int version = input.readInt();
            if (version != VERSION) throw new IOException("unsupported version: " + version);
            int count = input.readInt();
            if (count < 0 || count > MAX_COUNT) throw new IOException("invalid count: " + count);
            List<Member> members = new ArrayList<>(count);
            for (int index = 0; index < count; index++) {
                members.add(new Member(input.readUTF(), input.readUTF(), input.readInt()));
            }
            if (input.read() != -1) throw new IOException("trailing bytes");
            return List.copyOf(members);
        }
    }

    public static void main(String[] args) throws IOException {
        Path file = Files.createTempFile("members-", ".dat");
        try {
            write(file, List.of(new Member("u1", "Mina", 31)));
            System.out.println(read(file));
        } finally {
            Files.deleteIfExists(file);
        }
    }
}
[Member[id=u1, name=Mina, age=31]]

main이 만든 파일은 26바이트이고, 바이트를 16진수로 찍어 보면 다음과 같습니다. 이어지는 표는 이 바이트열을 앞에서부터 잘라 각 조각이 어느 값인지 적은 것이므로, 26바이트가 표의 여섯 조각으로 빠짐없이 나뉘는지 확인해 보세요.

4D 45 4D 42 00 00 00 01 00 00 00 01 00 02 75 31 00 04 4D 69 6E 61 00 00 00 1F
바이트뜻
4D 45 4D 42매직 넘버. ASCII 문자로 읽으면 MEMB
00 00 00 01형식 버전 1
00 00 00 01회원 수 1
00 02 75 31ID: 길이 2와 u1
00 04 4D 69 6E 61이름: 길이 4와 Mina
00 00 00 1F나이 31

매직 넘버 0x4D454D42는 ASCII로 MEMB이므로, 16진수 편집기로 파일을 열었을 때 무슨 파일인지 알아보기 쉽습니다. 머리 12바이트 뒤로는 회원 한 명의 ID, 이름, 나이가 write가 쓴 순서 그대로 이어집니다.

read는 머리부터 차례로 검사합니다. 매직 넘버가 다르면 이 형식의 파일이 아니고, 버전이 1이 아니면 이 코드가 해석할 줄 모르는 형식이므로 바로 거부합니다. 회원 수가 0 이상 10,000 이하인지 확인한 다음에야 목록을 만들고, 회원마다 ID, 이름, 나이를 쓴 순서대로 읽습니다. new Member(input.readUTF(), input.readUTF(), input.readInt())가 ID, 이름, 나이 순서로 읽히는 것은 자바가 메서드 인자를 언제나 왼쪽부터 차례로 계산하기 때문입니다. 마지막으로 input.read()가 파일 끝을 뜻하는 -1인지 확인해서, 정해진 회원 수보다 뒤에 바이트가 남아 있는 파일도 거부합니다.

버전 1 판독기가 확인하는 것과 남는 검증을 구분한다

magic·version과 개수 검증을 통과한 뒤 count만큼 레코드를 읽고 마지막 추가 바이트를 검사합니다. 커서와 누적 목록을 함께 유지하면 각 가드의 역할이 선명합니다.

버전 1 판독기가 확인하는 것과 남는 검증을 구분한다

VersionedMemberData.read의 실제 검사 순서와 레코드 필드 순서를 나타냅니다.

버전 파일 · 판독 검사 순서

버전 1 판독기가 확인하는 것과 남는 검증을 구분한다

헤더와 개수, 필드 순서, 끝 경계를 확인해도 문자열과 나이의 업무 검증은 남습니다.

판독 검사를 통과한 경로만 목록을 반환한다매직검사통과후버전,개수범위,각회원필드,추가바이트없음을순서대로확인한다.개수0이면회원읽기없이끝검사로간다.정상회원은다음인덱스로반복한다.잘못된값이나짧은입출력은예외전파로합류한다.오른쪽바깥공통선은예외합류이며안전한업무값을보장하는검사는아니다.예없음예추가 바이트 있음매직 → 버전 검사count 읽기0..10,000 검사index < count?한 회원 읽기id → name → ageindex 다음 값추가 read == −1?List.copyOf 반환불변 목록예외 전파매직·버전·개수 오류잘린 필드·추가 데이터업무 값 검증은 별도실선: 판독 제어 흐름 · 예외에서 정상 반환으로 가는 간선 없음
판독 검사를 통과한 경로만 목록을 반환한다매직검사통과후버전,개수범위,각회원필드,추가바이트없음을순서대로확인한다.개수0이면회원읽기없이끝검사로간다.정상회원은다음인덱스로반복한다.잘못된값이나짧은입출력은예외전파로합류한다.오른쪽바깥공통선은예외합류이며안전한업무값을보장하는검사는아니다.예예매직 → 버전 검사count 읽기0..10,000 검사index < count?한 회원 읽기id → name → ageindex 다음 값추가 read == −1?List.copyOf 반환불변 목록예외 전파잘못된 헤더·개수잘린 필드·추가 바이트업무 값 검증은 별도왼쪽 끝: 다음 항목 반복왼쪽 안쪽: 항목 없음오른쪽: 모든 예외 합류
  1. MAGIC → VERSION

    원문 판독기의 처리
    0x4D454D42와 1을 확인
    해석 범위
    다른 값이면 거부 · 버전별 분기 없음
  2. count

    원문 판독기의 처리
    0 이상 10000 이하인지 확인 후 목록 준비
    해석 범위
    write에는 같은 레코드 수 검사가 없음
  3. 레코드마다 id → name → age

    원문 판독기의 처리
    readUTF → readUTF → readInt
    해석 범위
    문자열 업무 길이와 나이 범위는 검사하지 않음
  4. 레코드를 모두 읽은 뒤

    원문 판독기의 처리
    추가 한 바이트가 있으면 거부
    해석 범위
    여분 데이터 없이 끝나야 목록 반환

readUTF의 길이는 수정 UTF 인코딩의 바이트 수입니다. 두 바이트 길이 필드의 상한과 업무에서 정한 문자열 상한은 별개입니다. main에는 버전 1의 회원 한 명을 쓰고 읽는 입력만 있습니다.

이 예의 조건

  • 형식 판독 검사는 업무 값 검증을 보장하지 않습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2매직 검사 통과 후 버전, 개수 범위, 각 회원 필드, 추가 바이트 없음을 순서대로 확인한다. 개수 0이면 회원 읽기 없이 끝 검사로 간다. 정상 회원은 다음 인덱스로 반복한다. 잘못된 값이나 짧은 입출력은 예외 전파로 합류한다. 오른쪽 바깥 공통선은 예외 합류이며 안전한 업무 값을 보장하는 검사는 아니다.
원 도표의 설명과 주석
구분내용
설명매직 검사 통과 후 버전, 개수 범위, 각 회원 필드, 추가 바이트 없음을 순서대로 확인한다. 개수 0이면 회원 읽기 없이 끝 검사로 간다. 정상 회원은 다음 인덱스로 반복한다. 잘못된 값이나 짧은 입출력은 예외 전파로 합류한다. 오른쪽 바깥 공통선은 예외 합류이며 안전한 업무 값을 보장하는 검사는 아니다.
전체 읽기 정보버전 1 판독기가 확인하는 것과 남는 검증을 구분한다 버전 1 판독기가 확인하는 것과 남는 검증을 구분한다 VersionedMemberData.read의 실제 검사 순서와 레코드 필드 순서를 나타냅니다. 버전 파일 · 판독 검사 순서 버전 1 판독기가 확인하는 것과 남는 검증을 구분한다 헤더와 개수, 필드 순서, 끝 경계를 확인해도 문자열과 나이의 업무 검증은 남습니다. MAGIC → VERSION 원문 판독기의 처리 0x4D454D42와 1을 확인 해석 범위 다른 값이면 거부 · 버전별 분기 없음 count 원문 판독기의 처리 0 이상 10000 이하인지 확인 후 목록 준비 해석 범위 write에는 같은 레코드 수 검사가 없음 레코드마다 id → name → age 원문 판독기의 처리 readUTF → readUTF → readInt 해석 범위 문자열 업무 길이와 나이 범위는 검사하지 않음 레코드를 모두 읽은 뒤 원문 판독기의 처리 추가 한 바이트가 있으면 거부 해석 범위 여분 데이터 없이 끝나야 목록 반환 readUTF의 길이는 수정된 UTF 인코딩의 바이트 수입니다. 두 바이트 길이 필드의 상한과 업무에서 정한 문자열 상한은 별개입니다. main에는 버전 1의 회원 한 명을 쓰고 읽는 입력만 있습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
헤더 검증

매직·버전과 count 0..10000을 검사합니다.

  • 오류: 예외 전파
  • count0: 회원 읽기 건너뜀
  • 형식 검사를 통과한 경우만 필드 판독을 진행합니다.
회원 반복

각 index에서 id,name,age를 읽고 다음으로 갑니다.

  • 반복 조건: index<count
  • 짧은 입력: 예외 전파
  • 완성되지 않은 회원 목록은 정상 반환하지 않습니다.
끝 검사

추가 read가 −1인지 확인한 뒤 복사 목록을 반환합니다.

  • 추가 바이트: 예외
  • 정상: List.copyOf
  • 마지막 데이터 검사까지 통과해야 불변 목록을 공개합니다.

버전 1 판독기가 확인하는 것과 남는 검증을 구분한다

magic·version과 개수 검증을 통과한 뒤 count만큼 레코드를 읽고 마지막 추가 바이트를 검사합니다. 커서와 누적 목록을 함께 유지하면 각 가드의 역할이 선명합니다.

버전 1 판독기가 확인하는 것과 남는 검증을 구분한다

VersionedMemberData.read의 실제 검사 순서와 레코드 필드 순서를 나타냅니다.

버전 파일 · 판독 검사 순서

버전 1 판독기가 확인하는 것과 남는 검증을 구분한다

헤더와 개수, 필드 순서, 끝 경계를 확인해도 문자열과 나이의 업무 검증은 남습니다.

판독 검사를 통과한 경로만 목록을 반환한다매직검사통과후버전,개수범위,각회원필드,추가바이트없음을순서대로확인한다.개수0이면회원읽기없이끝검사로간다.정상회원은다음인덱스로반복한다.잘못된값이나짧은입출력은예외전파로합류한다.오른쪽바깥공통선은예외합류이며안전한업무값을보장하는검사는아니다.예없음예추가 바이트 있음매직 → 버전 검사count 읽기0..10,000 검사index < count?한 회원 읽기id → name → ageindex 다음 값추가 read == −1?List.copyOf 반환불변 목록예외 전파매직·버전·개수 오류잘린 필드·추가 데이터업무 값 검증은 별도실선: 판독 제어 흐름 · 예외에서 정상 반환으로 가는 간선 없음
판독 검사를 통과한 경로만 목록을 반환한다매직검사통과후버전,개수범위,각회원필드,추가바이트없음을순서대로확인한다.개수0이면회원읽기없이끝검사로간다.정상회원은다음인덱스로반복한다.잘못된값이나짧은입출력은예외전파로합류한다.오른쪽바깥공통선은예외합류이며안전한업무값을보장하는검사는아니다.예예매직 → 버전 검사count 읽기0..10,000 검사index < count?한 회원 읽기id → name → ageindex 다음 값추가 read == −1?List.copyOf 반환불변 목록예외 전파잘못된 헤더·개수잘린 필드·추가 바이트업무 값 검증은 별도왼쪽 끝: 다음 항목 반복왼쪽 안쪽: 항목 없음오른쪽: 모든 예외 합류
  1. MAGIC → VERSION

    원문 판독기의 처리
    0x4D454D42와 1을 확인
    해석 범위
    다른 값이면 거부 · 버전별 분기 없음
  2. count

    원문 판독기의 처리
    0 이상 10000 이하인지 확인 후 목록 준비
    해석 범위
    write에는 같은 레코드 수 검사가 없음
  3. 레코드마다 id → name → age

    원문 판독기의 처리
    readUTF → readUTF → readInt
    해석 범위
    문자열 업무 길이와 나이 범위는 검사하지 않음
  4. 레코드를 모두 읽은 뒤

    원문 판독기의 처리
    추가 한 바이트가 있으면 거부
    해석 범위
    여분 데이터 없이 끝나야 목록 반환

readUTF의 길이는 수정 UTF 인코딩의 바이트 수입니다. 두 바이트 길이 필드의 상한과 업무에서 정한 문자열 상한은 별개입니다. main에는 버전 1의 회원 한 명을 쓰고 읽는 입력만 있습니다.

이 예의 조건

  • 형식 판독 검사는 업무 값 검증을 보장하지 않습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2매직 검사 통과 후 버전, 개수 범위, 각 회원 필드, 추가 바이트 없음을 순서대로 확인한다. 개수 0이면 회원 읽기 없이 끝 검사로 간다. 정상 회원은 다음 인덱스로 반복한다. 잘못된 값이나 짧은 입출력은 예외 전파로 합류한다. 오른쪽 바깥 공통선은 예외 합류이며 안전한 업무 값을 보장하는 검사는 아니다.
원 도표의 설명과 주석
구분내용
설명매직 검사 통과 후 버전, 개수 범위, 각 회원 필드, 추가 바이트 없음을 순서대로 확인한다. 개수 0이면 회원 읽기 없이 끝 검사로 간다. 정상 회원은 다음 인덱스로 반복한다. 잘못된 값이나 짧은 입출력은 예외 전파로 합류한다. 오른쪽 바깥 공통선은 예외 합류이며 안전한 업무 값을 보장하는 검사는 아니다.
전체 읽기 정보버전 1 판독기가 확인하는 것과 남는 검증을 구분한다 버전 1 판독기가 확인하는 것과 남는 검증을 구분한다 VersionedMemberData.read의 실제 검사 순서와 레코드 필드 순서를 나타냅니다. 버전 파일 · 판독 검사 순서 버전 1 판독기가 확인하는 것과 남는 검증을 구분한다 헤더와 개수, 필드 순서, 끝 경계를 확인해도 문자열과 나이의 업무 검증은 남습니다. MAGIC → VERSION 원문 판독기의 처리 0x4D454D42와 1을 확인 해석 범위 다른 값이면 거부 · 버전별 분기 없음 count 원문 판독기의 처리 0 이상 10000 이하인지 확인 후 목록 준비 해석 범위 write에는 같은 레코드 수 검사가 없음 레코드마다 id → name → age 원문 판독기의 처리 readUTF → readUTF → readInt 해석 범위 문자열 업무 길이와 나이 범위는 검사하지 않음 레코드를 모두 읽은 뒤 원문 판독기의 처리 추가 한 바이트가 있으면 거부 해석 범위 여분 데이터 없이 끝나야 목록 반환 readUTF의 길이는 수정된 UTF 인코딩의 바이트 수입니다. 두 바이트 길이 필드의 상한과 업무에서 정한 문자열 상한은 별개입니다. main에는 버전 1의 회원 한 명을 쓰고 읽는 입력만 있습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
헤더 검증

매직·버전과 count 0..10000을 검사합니다.

  • 오류: 예외 전파
  • count0: 회원 읽기 건너뜀
  • 형식 검사를 통과한 경우만 필드 판독을 진행합니다.
회원 반복

각 index에서 id,name,age를 읽고 다음으로 갑니다.

  • 반복 조건: index<count
  • 짧은 입력: 예외 전파
  • 완성되지 않은 회원 목록은 정상 반환하지 않습니다.
끝 검사

추가 read가 −1인지 확인한 뒤 복사 목록을 반환합니다.

  • 추가 바이트: 예외
  • 정상: List.copyOf
  • 마지막 데이터 검사까지 통과해야 불변 목록을 공개합니다.

그림처럼 어느 단계에서 실패하든 예외가 던져지고, 일부만 읽은 목록이 정상 결과처럼 반환되는 경로는 없습니다. 형식 오류를 모두 IOException으로 알리는 것도 같은 이유입니다. 파일이 중간에 잘렸을 때 readInt나 readUTF가 던지는 EOFException도 IOException이므로, 호출하는 쪽은 '이 파일을 읽을 수 없다'는 경우를 IOException 하나로 처리할 수 있습니다. 스트림 사이에 끼운 BufferedOutputStream과 BufferedInputStream은 버퍼 스트림을 다룬 글에서 본 것처럼 값마다 일어나는 작은 쓰기와 읽기를 큰 덩어리로 묶어 줍니다.

이 형식 검사가 해 주지 않는 일도 알아 두어야 합니다. 첫째, write는 회원 수의 상한을 검사하지 않습니다. 10,001명을 넘기면 파일은 문제없이 써지지만 그 파일을 같은 클래스의 read가 거부합니다. 상한은 읽을 때와 쓸 때 똑같이 확인해야 하고, 쓰는 쪽에서는 파일을 열기 전에 확인해야 반쪽짜리 파일도 생기지 않습니다.

둘째, 형식 검사는 바이트가 형식에 맞는지만 볼 뿐 값이 업무 규칙에 맞는지는 보지 않습니다. 빈 이름이나 음수 나이도 형식만 맞으면 그대로 읽히므로, 앞 글의 컴팩트 생성자처럼 값을 검사하는 코드가 따로 있어야 합니다.

망가진 파일로 검사 확인하기

검사가 실제로 동작하는지 보려고, 정상 파일의 바이트를 조금씩 망가뜨려 read에 넘겨 보겠습니다. 다음 프로그램은 VersionedMemberData의 write, read, Member를 쓰므로, 두 파일을 같은 src 폴더에 두고 함께 컴파일합니다.

src/BadMemberFiles.java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;

public final class BadMemberFiles {
    public static void main(String[] args) throws IOException {
        Path file = Files.createTempFile("members-", ".dat");
        try {
            VersionedMemberData.write(file,
                    List.of(new VersionedMemberData.Member("u1", "Mina", 31)));
            byte[] good = Files.readAllBytes(file);
            System.out.println("정상 파일 " + good.length + "바이트");

            byte[] version2 = good.clone();
            version2[7] = 2;
            byte[] hugeCount = good.clone();
            hugeCount[8] = 0x7F;

            check("텍스트 파일", file, "u1,Mina,31\n".getBytes(StandardCharsets.UTF_8));
            check("버전 2", file, version2);
            check("개수 조작", file, hugeCount);
            check("끝 2바이트 잘림", file, Arrays.copyOf(good, good.length - 2));
            check("1바이트 덧붙임", file, Arrays.copyOf(good, good.length + 1));
        } finally {
            Files.deleteIfExists(file);
        }
    }

    static void check(String label, Path file, byte[] content) throws IOException {
        Files.write(file, content);
        try {
            System.out.println(label + " -> " + VersionedMemberData.read(file));
        } catch (IOException e) {
            System.out.println(label + " -> " + e);
        }
    }
}
javac -d out src/VersionedMemberData.java src/BadMemberFiles.java
java -cp out BadMemberFiles
정상 파일 26바이트
텍스트 파일 -> java.io.IOException: not a member file: magic 75312c4d
버전 2 -> java.io.IOException: unsupported version: 2
개수 조작 -> java.io.IOException: invalid count: 2130706433
끝 2바이트 잘림 -> java.io.EOFException
1바이트 덧붙임 -> java.io.IOException: trailing bytes

텍스트 파일은 앞의 네 바이트가 u1,M의 75 31 2C 4D라서 매직 넘버 검사에서 바로 걸립니다. '버전 2'는 버전 필드의 마지막 바이트(7번)를 2로 바꾼 파일로, 이 코드가 모르는 버전이라 거부되었습니다. '개수 조작'은 회원 수의 첫 바이트(8번)를 7F로 바꿔 수를 2,130,706,433으로 만든 파일입니다. 범위 검사가 목록을 만들기 전에 거부했으므로 메모리를 잡지도 않았습니다.

마지막 두 줄은 끝부분이 망가진 경우입니다. 나이의 마지막 두 바이트가 잘린 파일은 readInt가 4바이트를 채우지 못해 EOFException이 났고, 회원 한 명을 다 읽은 뒤 바이트가 하나 더 붙은 파일은 끝 검사에서 걸렸습니다. 어느 경우에도 read는 망가진 내용으로 목록을 만들어 돌려주지 않았습니다.

writeUTF의 길이 한도와 다른 언어와의 호환

writeUTF는 편리하지만, 오래 보관하거나 다른 언어와 주고받을 형식에 쓰기 전에 두 가지를 알아 두어야 합니다. 첫째, 길이를 2바이트에 적으므로 인코딩한 결과가 65,535바이트를 넘는 문자열은 쓸 수 없습니다. 한 글자에 3바이트인 '가'를 21,846번 반복한 문자열은 65,538바이트가 되어 다음 예외가 납니다.

java.io.UTFDataFormatException: encoded string (가가가가가가가가...가가가가가가가가) too long: 65538 bytes

둘째, writeUTF가 쓰는 것은 표준 UTF-8이 아니라 자바가 정의한 modified UTF-8입니다. 한글처럼 흔한 글자는 표준 UTF-8과 같은 바이트가 나오지만, NUL 문자와 이모지 같은 보충 문자는 다르게 인코딩됩니다. 예를 들어 이모지 하나는 표준 UTF-8로 4바이트인데 writeUTF는 6바이트로 쓰고, 이 6바이트를 자바의 표준 UTF-8 디코더로 읽으면 대체 문자 U+FFFD 두 개가 됩니다. 바이트 단위의 차이는 버퍼 스트림을 다룬 글의 '더 깊이' 단락에서 볼 수 있습니다.

그래서 writeUTF는 자바 프로그램끼리만 주고받는 작은 형식에 알맞습니다. 다른 언어의 프로그램도 읽을 파일이라면, int 길이와 getBytes(StandardCharsets.UTF_8)로 만든 표준 UTF-8 바이트를 직접 쓰는 형식을 정합니다. 앞에서 말한 글의 첫 번째 연습 문제가 바로 이 방식입니다. 이때 형식이 허용하는 길이와 업무에서 정한 이름 길이는 서로 다른 한도이므로, '이름은 100자까지' 같은 업무 규칙은 따로 검사해야 합니다.

객체를 통째로 저장하는 ObjectOutputStream

필드를 하나씩 writeInt, writeUTF로 적는 대신 객체를 통째로 저장하는 방법도 있습니다. ObjectOutputStream.writeObject는 Serializable 인터페이스를 구현한 객체와, 그 객체가 참조하는 객체들까지 모두 바이트로 기록합니다. 이를 객체 직렬화(serialization)라고 하고, ObjectInputStream.readObject로 객체를 다시 만드는 것을 역직렬화(deserialization)라고 합니다. 다음 예제는 회원 객체 하나를 직렬화했다가 역직렬화합니다.

src/ObjectStreamBoundary.java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public final class ObjectStreamBoundary {
    static final class Member implements Serializable {
        private static final long serialVersionUID = 1L;
        private final String id;
        private final String name;
        private transient String sessionToken;

        Member(String id, String name, String sessionToken) {
            this.id = id;
            this.name = name;
            this.sessionToken = sessionToken;
        }

        String sessionToken() { return sessionToken; }

        @Override
        public String toString() {
            return "Member[id=" + id + ", name=" + name + "]";
        }
    }

    public static void main(String[] args) throws Exception {
        var bytes = new ByteArrayOutputStream();
        try (var output = new ObjectOutputStream(bytes)) {
            output.writeObject(new Member("u1", "Mina", "secret"));
        }

        try (var input = new ObjectInputStream(
                new ByteArrayInputStream(bytes.toByteArray()))) {
            String filterPattern = String.join(";", "java.base/*", Member.class.getName(), "!*");
            input.setObjectInputFilter(ObjectInputFilter.Config.createFilter(filterPattern));
            Member restored = (Member) input.readObject();
            System.out.println(restored);
            System.out.println("token=" + restored.sessionToken());
        }
    }
}
Member[id=u1, name=Mina]
token=null

첫 줄은 복원한 객체의 toString 결과로, id와 name이 그대로 돌아왔습니다. 둘째 줄의 token=null은 sessionToken에 붙인 transient 때문입니다. transient 필드는 직렬화할 때 기록되지 않으므로 복원한 객체에서는 기본값인 null이 됩니다. 그렇다고 transient가 비밀을 지켜 주는 것은 아닙니다. 다른 필드나 참조하는 객체, 로그에 같은 값이 남을 수 있으므로, 오래 보관해야 하는 비밀에는 암호화와 키 관리가 필요합니다.

serialVersionUID는 클래스의 버전 번호입니다. 직렬화한 바이트에는 클래스 이름과 이 번호가 함께 기록되고, 읽을 때 현재 클래스의 번호와 다르면 InvalidClassException이 납니다. 이 필드를 선언하지 않으면 직렬화 기능이 클래스의 이름, 필드, 메서드 같은 구조로부터 번호를 계산하므로, 메서드 하나를 추가하는 정도의 변경으로도 번호가 바뀌어 예전 데이터를 읽지 못할 수 있습니다. 그래서 직렬화할 클래스에는 번호를 직접 선언합니다.

하지만 번호를 고정해도 필드의 타입을 바꾸는 것처럼 호환되지 않는 변경은 여전히 읽기에 실패하고, 새로 추가한 필드는 옛 데이터를 읽을 때 null이나 0으로 채워집니다. 게다가 역직렬화는 클래스의 생성자를 실행하지 않으므로, 생성자에서 하던 값 검사도 일어나지 않습니다.

역직렬화할 때 setObjectInputFilter로 건 필터는 바이트 안에 등장하는 클래스를 하나씩 검사합니다. 이 예제의 패턴은 java.base/*;ObjectStreamBoundary$Member;!*이고, 세미콜론으로 나뉜 항목을 왼쪽부터 비교해 처음 맞는 항목이 결과를 정합니다. java.base/*는 String처럼 java.base 모듈에 있는 클래스를, 두 번째 항목은 Member를 허용하고, 마지막 !*는 나머지 모든 클래스를 거부합니다. 마지막 항목이 중요한데, 어느 항목에도 맞지 않은 클래스는 '결정하지 않음'으로 남고 그러면 역직렬화가 그대로 계속되기 때문입니다.

필터에는 클래스 목록 말고도 크기 한도를 넣을 수 있습니다. 같은 패턴 앞에 maxdepth=5;maxrefs=100;maxbytes=4096;처럼 객체 그래프의 깊이, 참조 수, 입력 바이트 수의 상한을 붙이면 되고, 배열 길이는 maxarray로 제한합니다. 이 예제의 객체는 작아서 이 한도를 붙여도 그대로 읽히지만, maxbytes를 50으로 줄이면 InvalidClassException: filter status: REJECTED가 나며 거부됩니다. 원래 예제의 필터에는 이런 한도가 없으므로 클래스의 종류만 제한한다는 점을 기억해 두세요.

믿을 수 없는 바이트를 readObject에 넘기지 않는다

역직렬화는 단순히 데이터를 해석하는 일이 아닙니다. 바이트에 적힌 이름의 클래스를 불러오고, 생성자를 거치지 않고 객체를 만들고, 클래스가 정의해 둔 readObject 같은 메서드를 실행합니다. 그래서 공격자가 바이트를 만들 수 있다면 클래스 경로에 있는 클래스들을 엮어 개발자가 의도하지 않은 동작을 일으키거나, 깊은 그래프와 거대한 배열로 메모리를 소진시킬 수 있습니다. 인터넷으로 올라온 파일이나 외부에서 온 메시지의 바이트를 readObject에 바로 넘겨서는 안 되는 이유입니다.

필터는 허용할 클래스와 크기 한도를 정해 위험을 줄여 주지만, 그렇다고 객체 스트림이 오래 보관하거나 외부와 주고받을 형식으로 알맞아지는 것은 아닙니다. 형식이 클래스 이름과 필드 구조, serialVersionUID에 묶여 있어서 리팩터링 하나에도 깨질 수 있고, 자바가 아닌 언어로는 읽기 어렵습니다. 새로 만드는 저장 형식이나 서비스 사이의 데이터에는 JSON이나 Protocol Buffers 같은 데이터 중심 형식과 명시적인 값 검증을 씁니다. 객체 스트림은 이미 있는 내부 파일을 새 형식으로 옮길 때만, 좁은 허용 목록을 건 채 따로 떨어진 변환 단계에서 씁니다. 대안이 되는 형식은 다음 글에서 자세히 다룹니다.

형식을 바꿀 때는 읽는 쪽부터

회원에 이메일을 추가해 형식 버전 2를 만든다고 해 봅시다. 순서 기반인 DataStream 형식에는 필드 이름이 없으므로, 버전 1 판독기는 버전 2 파일을 읽을 방법이 없습니다. 다행히 VersionedMemberData는 버전이 1이 아니면 거부하므로, 앞의 '버전 2' 파일처럼 엉뚱한 값을 만들어 내지 않고 바로 실패합니다. 새 판독기는 버전을 읽은 뒤, 버전마다 따로 만든 레코드 읽기 함수로 갈라져야 합니다.

static Member readRecord(DataInputStream input, int version) throws IOException {
    return switch (version) {
        case 1 -> readVersion1(input); // id, name, age
        case 2 -> readVersion2(input); // id, name, age, email
        default -> throw new IOException("unsupported version: " + version);
    };
}

반복문 곳곳에 if (version == 2)를 흩어 넣기보다 이렇게 버전마다 함수를 나누면, 버전 3에서 필드 순서가 크게 바뀌어도 옛 버전의 읽기 코드는 건드리지 않고 함수만 하나 더하면 됩니다. 완성된 코드는 연습 문제 2에서 만듭니다.

배포 순서도 중요합니다. 서버 여러 대가 같은 파일을 읽고 쓰는 서비스는 새 버전을 한 대씩 차례로 배포하므로(롤링 배포), 한동안 옛 코드와 새 코드가 함께 돌아갑니다. 이때 새 기록기부터 배포하면 아직 옛 코드인 서버가 버전 2 파일을 만나 읽지 못합니다. 그래서 먼저 버전 1과 2를 모두 읽을 수 있지만 여전히 버전 1로 쓰는 코드를 모든 서버에 배포하고, 그 배포가 끝난 것을 확인한 뒤에 기록기를 버전 2로 바꿉니다. 이렇게 하면 두 번째 배포를 되돌리더라도 남아 있는 코드가 버전 2 파일을 읽을 수 있습니다.

필드를 없앨 때도 조심해야 합니다. Protocol Buffers처럼 필드마다 번호를 붙이는 형식에서는 지운 필드의 번호를 '예약됨'으로 표시해 다시 쓰지 않습니다. 옛 데이터에는 그 번호 자리에 예전 의미의 값이 남아 있으므로, 번호를 새 필드에 재사용하면 옛 값을 새 의미로 잘못 읽게 되기 때문입니다. 번호가 없는 순서 기반 형식에서는 필드를 빼거나 순서를 바꾸는 모든 변경이 새 버전 번호와 새 읽기 함수를 뜻합니다.

옛 파일을 새 형식으로 한꺼번에 바꾸는 변환(마이그레이션)을 할 때는 결과의 개수를 맞춰 봅니다. 원본에서 읽은 레코드 수가, 변환에 성공한 수와 문제가 있어 따로 떼어 둔 수를 더한 값과 같아야 변환 도중에 조용히 사라진 레코드가 없다고 말할 수 있습니다.

어떤 형식을 고를까

지금까지 본 형식을 용도별로 정리하면 다음과 같습니다. 표의 '출발점'은 정답이 아니라 검토를 시작할 후보입니다.

목적출발점주의할 점
자바 프로그램 안에서만 쓰는 작은 캐시버전을 적은 DataStream 형식읽기와 쓰기의 순서가 곧 스키마
잠깐 보관하는 객체 스냅샷필터를 건 객체 스트림클래스 구조에 묶이고, 외부 입력에는 위험
다른 서비스나 언어와 주고받는 데이터JSON이나 스키마가 있는 이진 형식받은 값의 검증
사람이 편집하는 파일UTF-8 텍스트(CSV, JSON 등)구분자와 이스케이프 규칙
많은 데이터를 조건으로 찾기데이터베이스트랜잭션과 인덱스

정리

  • DataOutputStream은 값을 정해진 바이트로 기록하지만 필드 이름과 타입은 기록하지 않습니다. 읽는 순서가 쓴 순서와 다르면 엉뚱한 값이 조용히 만들어지거나 EOFException이 납니다.
  • 파일 머리에 매직 넘버, 형식 버전, 레코드 수를 두고, 파일에서 읽은 수는 믿기 전에 범위부터 확인합니다. 끝에 남은 바이트까지 확인하면 잘린 파일과 덧붙은 파일을 모두 거를 수 있습니다.
  • 형식 오류는 IOException으로 알려 잘린 파일의 EOFException과 함께 한 가지 실패로 처리합니다. 형식 검사와 업무 값 검사는 따로 합니다.
  • writeUTF는 길이가 65,535바이트로 제한되고 modified UTF-8을 쓰므로, 자바 프로그램끼리 쓰는 작은 형식에 알맞습니다.
  • 객체 직렬화는 클래스 구조에 묶이고 믿을 수 없는 입력에 위험합니다. 필터로 위험을 줄일 수 있지만, 오래 보관할 형식으로는 데이터 중심 형식이 낫습니다.
  • 형식을 바꿀 때는 새 버전을 읽는 코드를 먼저 모두 배포한 뒤 기록기를 바꿉니다.

연습 문제

  1. VersionedMemberData.read에 다음 26바이트 파일을 넘겼습니다. 정상 파일과 다른 곳은 회원 수의 마지막 바이트(12번째 바이트)뿐입니다. 어떤 검사를 통과하고, 어디서 어떤 예외가 나는지 설명하세요.

    4D 45 4D 42 00 00 00 01 00 00 00 02 00 02 75 31 00 04 4D 69 6E 61 00 00 00 1F
  2. 회원에 이메일(email)을 추가한 형식 버전 2를 만드세요. 기록기는 버전 1과 2 가운데 하나를 골라 쓸 수 있어야 하고, 판독기는 두 버전을 모두 읽어야 합니다. 버전 1 파일의 회원은 이메일을 빈 문자열로 채웁니다.

  3. 서버 세 대가 같은 회원 파일을 읽고 쓰는 서비스에 2번의 변경을 배포하려고 합니다. 새 기록기를 먼저 배포하면 어떤 문제가 생기는지, 어떤 순서로 배포해야 하는지 설명하세요.

해설 보기

1번. 매직 넘버 MEMB와 버전 1은 정상이고, 회원 수 2도 0 이상 10,000 이하라서 범위 검사를 통과합니다. 첫 번째 회원 u1, Mina, 31도 정상적으로 읽힙니다. 두 번째 회원을 읽으려고 readUTF를 부르면 길이를 담은 2바이트부터 읽어야 하는데 파일이 이미 끝났으므로 EOFException이 납니다. 실제로 실행해 보면 스택 트레이스에 readUTF 안에서 길이를 읽는 readUnsignedShort가 보입니다.

범위 검사는 수가 터무니없이 큰지만 볼 뿐, 실제로 들어 있는 레코드 수와 맞는지는 끝까지 읽어 봐야 압니다. 이때도 첫 번째 회원만 담긴 목록이 반환되지 않고 예외가 나므로, 호출한 쪽이 반쪽 결과를 정상 데이터로 착각할 일은 없습니다.

2번. 판독기는 버전을 먼저 확인해 모르는 버전을 회원 수보다 먼저 거부하고, 레코드는 버전별 함수로 읽습니다. 기록기가 버전 1도 쓸 수 있게 한 것은 옛 파일을 만들어 시험하기 위해서입니다. 실제 서비스에서 어느 버전으로 쓸지는 3번의 배포 단계에 따라 정합니다.

exercise/MemberDataV2Solution.java
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public final class MemberDataV2Solution {
    private static final int MAGIC = 0x4D454D42; // ASCII "MEMB"
    private static final int MAX_COUNT = 10_000;
    record Member(String id, String name, int age, String email) {}

    static void write(Path file, int version, List<Member> members) throws IOException {
        if (version != 1 && version != 2) throw new IllegalArgumentException("version " + version);
        if (members.size() > MAX_COUNT) throw new IllegalArgumentException("too many members: " + members.size());
        try (var output = new DataOutputStream(
                new BufferedOutputStream(Files.newOutputStream(file)))) {
            output.writeInt(MAGIC);
            output.writeInt(version);
            output.writeInt(members.size());
            for (Member member : members) {
                output.writeUTF(member.id());
                output.writeUTF(member.name());
                output.writeInt(member.age());
                if (version == 2) output.writeUTF(member.email());
            }
        }
    }

    static List<Member> read(Path file) throws IOException {
        try (var input = new DataInputStream(
                new BufferedInputStream(Files.newInputStream(file)))) {
            int magic = input.readInt();
            if (magic != MAGIC) throw new IOException("not a member file: magic " + Integer.toHexString(magic));
            int version = input.readInt();
            if (version != 1 && version != 2) throw new IOException("unsupported version: " + version);
            int count = input.readInt();
            if (count < 0 || count > MAX_COUNT) throw new IOException("invalid count: " + count);
            List<Member> members = new ArrayList<>(count);
            for (int index = 0; index < count; index++) {
                members.add(version == 1 ? readVersion1(input) : readVersion2(input));
            }
            if (input.read() != -1) throw new IOException("trailing bytes");
            return List.copyOf(members);
        }
    }

    // 버전 1: id, name, age. 이메일이 없던 시절의 파일이므로 빈 문자열로 채운다.
    private static Member readVersion1(DataInputStream input) throws IOException {
        return new Member(input.readUTF(), input.readUTF(), input.readInt(), "");
    }

    // 버전 2: id, name, age, email
    private static Member readVersion2(DataInputStream input) throws IOException {
        return new Member(input.readUTF(), input.readUTF(), input.readInt(), input.readUTF());
    }

    public static void main(String[] args) throws IOException {
        Member mina = new Member("u1", "Mina", 31, "[email protected]");
        Path v1 = Files.createTempFile("members-v1-", ".dat");
        Path v2 = Files.createTempFile("members-v2-", ".dat");
        try {
            write(v1, 1, List.of(mina));
            write(v2, 2, List.of(mina));
            System.out.println("v1 " + Files.size(v1) + "B -> " + read(v1));
            System.out.println("v2 " + Files.size(v2) + "B -> " + read(v2));
        } finally {
            Files.deleteIfExists(v1);
            Files.deleteIfExists(v2);
        }
    }
}
v1 26B -> [Member[id=u1, name=Mina, age=31, email=]]
v2 44B -> [Member[id=u1, name=Mina, age=31, [email protected]]]

버전 1 파일은 본문의 VersionedMemberData가 만든 파일과 똑같은 26바이트이고, 이메일 없이 기록되었으므로 읽은 회원의 이메일은 빈 문자열입니다. 버전 2 파일은 이메일 [email protected]의 길이 2바이트와 본문 16바이트가 더해져 44바이트가 되었습니다. 회원 수 상한은 파일을 열기 전에 검사하므로, 너무 많은 회원을 넘겨도 반쪽짜리 파일이 생기지 않습니다. 버전 1 파일의 빈 이메일처럼, 옛 데이터에 없는 필드를 무엇으로 채울지도 형식을 바꿀 때 함께 정해야 하는 규칙입니다.

3번. 새 기록기를 한 대에 먼저 배포하면, 그 서버가 버전 2 파일을 쓰는 순간 아직 옛 코드인 나머지 두 대는 unsupported version: 2를 내며 파일을 읽지 못합니다. 버전 검사가 없었다면 이메일의 바이트를 다음 회원의 ID로 읽는 식으로 엉뚱한 값이 만들어졌을 것입니다.

올바른 순서는 두 단계입니다. 먼저 두 버전을 모두 읽지만 여전히 버전 1로 쓰는 코드를 세 대 모두에 배포합니다. 세 대가 모두 바뀐 것을 확인한 뒤에 기록기를 버전 2로 바꾸는 두 번째 배포를 합니다. 두 번째 배포에 문제가 생겨 되돌려도 첫 번째 배포의 코드가 버전 2 파일을 읽을 수 있으므로 안전합니다. 다만 첫 번째 배포보다 이전 코드로 되돌리면 다시 버전 2 파일을 읽지 못하므로, 버전 2 파일이 생긴 뒤에는 그보다 오래된 코드로 되돌리지 않습니다.

다음 글

다음 글에서는 객체 스트림 대신 쓸 수 있는 JSON과 XML을 읽을 때 무엇을 검증해야 하는지, 그리고 파일 대신 데이터베이스를 골라야 하는 경우가 언제인지 살펴봅니다.