안동민 개발노트

본문 시작

DataStream과 형식 버전

기본 타입을 순서대로 기록하는 DataStream에 매직·버전·길이 검증을 추가하고 자바 객체 직렬화의 버전·보안·결합 한계를 검토합니다.

DataOutputStream은 int, long, boolean, 수정 UTF 문자열을 정해진 바이트 표현으로 씁니다.

DataInputStream은 같은 메서드 순서로 읽을 때 원래 타입을 복원합니다.

구분자 문자열이 필요 없고 숫자를 고정 바이트로 저장할 수 있지만 필드 이름이 파일에 기록되는 것은 아닙니다.

따라서 호출 순서 자체가 파일 스키마입니다.

쓰는 코드만 바꾸고 읽는 코드를 바꾸지 않으면 데이터가 다른 타입으로 해석되거나 EOF가 발생합니다.

매직 값, 형식 버전, 레코드 수, 길이 상한을 앞에 두어 잘못된 파일을 조기에 거부해야 합니다.


저장 순서와 다른 타입 읽기 실패

아래 파일은 UTF 문자열 뒤에 int를 저장하지만 읽는 쪽은 int부터 기대합니다.

읽기·쓰기 코드가 각각 컴파일된다는 사실은 파일 스키마의 호환성을 보장하지 않습니다.

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());
        }
    }
}
같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다

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

같은 10바이트를 다른 순서로 읽으면 경계가 어긋난다
바이트 위치와 값기록된 원래 의미잘못된 읽기의 해석
0–3: 00 04 4D 69수정 UTF 길이 4와 이름의 MireadInt가 281961로 해석
4–5: 6E 61이름의 남은 nareadUTF가 본문 길이 28257로 해석
6–9: 00 00 00 1FwriteInt(31)의 네 바이트요구한 28257바이트보다 짧아 EOFException
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

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

정상 구현은 읽기와 쓰기가 공유하는 코덱 메서드 또는 명시적 스키마 문서를 둡니다.

변경 시 버전을 올리고 이전 버전 전용 판독기를 유지하거나 마이그레이션 도구를 제공합니다.


매직과 버전이 있는 레코드 파일

매직 값은 전혀 다른 파일을 빠르게 구분하고 버전은 뒤 필드 해석 방법을 선택합니다.

레코드 수와 문자열 길이에는 상한이 필요합니다.

공격자가 파일 앞의 수를 매우 크게 조작하면 배열과 목록 할당만으로 메모리를 소진시킬 수 있습니다.

src/VersionedMemberData.java
import java.io.DataInputStream;
import java.io.DataOutputStream;
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;
    private static final int VERSION = 1;
    record Member(String id, String name, int age) {}

    static void write(Path file, List<Member> members) throws Exception {
        try (var output = new DataOutputStream(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 Exception {
        try (var input = new DataInputStream(Files.newInputStream(file))) {
            if (input.readInt() != MAGIC) throw new IllegalArgumentException("magic");
            if (input.readInt() != VERSION) throw new IllegalArgumentException("version");
            int count = input.readInt();
            if (count < 0 || count > 10_000) throw new IllegalArgumentException("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 IllegalArgumentException("trailing bytes");
            return List.copyOf(members);
        }
    }

    public static void main(String[] args) throws Exception {
        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);
        }
    }
}
버전 1 판독기가 확인하는 것과 남는 검증을 구분한다

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

버전 1 판독기가 확인하는 것과 남는 검증을 구분한다
읽기 구간원문 판독기의 처리해석 범위
MAGIC → VERSION0x4D454D42와 1을 확인다른 값이면 거부 · 버전별 분기 없음
count0 이상 10000 이하인지 확인 후 목록 준비write에는 같은 레코드 수 검사가 없음
레코드마다 id → name → agereadUTF → readUTF → readInt문자열 업무 길이와 나이 범위는 검사하지 않음
레코드를 모두 읽은 뒤추가 한 바이트가 있으면 거부여분 데이터 없이 끝나야 목록 반환
MAGIC → VERSION
원문 판독기의 처리: 0x4D454D42와 1을 확인
해석 범위: 다른 값이면 거부 · 버전별 분기 없음
count
원문 판독기의 처리: 0 이상 10000 이하인지 확인 후 목록 준비
해석 범위: write에는 같은 레코드 수 검사가 없음
레코드마다 id → name → age
원문 판독기의 처리: readUTF → readUTF → readInt
해석 범위: 문자열 업무 길이와 나이 범위는 검사하지 않음
레코드를 모두 읽은 뒤
원문 판독기의 처리: 추가 한 바이트가 있으면 거부
해석 범위: 여분 데이터 없이 끝나야 목록 반환

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

writeUTF는 일반적인 UTF-8 파일 문자열과 동일한 장기 형식이라고 가정하지 않습니다.

길이 제한과 수정 UTF 규칙이 있으므로 다른 언어와 교환할 형식에는 명시적인 UTF-8 바이트 길이와 바이트열을 직접 정의하는 편이 낫습니다.

DataStream은 자바 내부의 작은 바이너리 형식에 적합합니다.


객체 스트림의 저장 범위

ObjectOutputStream.writeObject는 Serializable 객체와 참조하는 그래프를 바이트로 기록합니다.

편리하지만 클래스 이름, 필드 구조, serialVersionUID에 강하게 결합됩니다.

필드나 상속 구조가 바뀌면 이전 데이터 복원이 실패하거나 예상과 다른 기본값을 얻을 수 있습니다.

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());
        }
    }
}

transient 필드는 기록되지 않아 복원 뒤 기본값 null이 됩니다.

비밀을 transient로 표시했다고 파일 전체가 안전해지는 것은 아닙니다.

다른 필드, 참조 객체, 로그에 값이 남을 수 있으며 영속 비밀에는 암호화와 키 관리가 필요합니다.

원문의 필터는 java.base/*와 Member 클래스를 허용합니다. 그래프 깊이·참조 수·배열 길이 상한을 설정한 필터는 아니므로, 아래의 제한 항목을 모두 구현한 것으로 읽지 않습니다.


객체 스트림의 역직렬화 위험

역직렬화는 단순 데이터 파싱보다 강한 동작을 일으킬 수 있습니다.

클래스 로딩과 객체 생성 과정, 사용자 정의 메서드가 실행될 수 있어 허용하지 않은 타입 그래프가 보안 문제가 됩니다.

인터넷 업로드나 외부 메시지에서 받은 바이트를 readObject에 바로 넘기지 않습니다.

필터는 허용 클래스, 배열 길이, 그래프 깊이, 참조 수에 상한을 둘 수 있지만 장기 공개 형식으로 ObjectStream을 추천하게 만드는 만능 해결책은 아닙니다.

JSON, Protocol Buffers 같은 데이터 중심 형식과 명시적 검증을 우선합니다.

기존 내부 파일을 읽어야 할 때만 좁은 허용 목록과 격리된 마이그레이션 단계로 사용합니다.


읽기 측 선행 배포 전략

새 필드를 추가할 때 먼저 구버전과 신버전을 모두 읽는 코드를 배포한 뒤 기록기를 새 버전으로 전환합니다.

반대로 새 기록기부터 배포하면 아직 구 판독기인 인스턴스가 데이터를 읽지 못합니다.

롤링 배포와 롤백이 있는 서비스에서는 두 버전이 동시에 존재하는 기간을 고려해야 합니다.

필드를 제거하더라도 바로 번호를 재사용하지 않습니다.

기존 데이터에 그 위치의 옛 의미가 남아 있기 때문입니다.

필드 ID가 있는 스키마 형식은 예약 처리하고, 순서 기반 DataStream은 버전별 판독기 함수로 완전히 분기합니다.

마이그레이션 결과는 원본 수, 성공 수, 격리 수를 대조합니다.


형식 선택표

목적후보주의점
자바 내부 작은 캐시버전 있는 DataStream순서 스키마
단기 객체 스냅샷제한된 ObjectStream클래스 결합과 필터
서비스 간 교환JSON 또는 스키마 바이너리입력 검증
사람이 편집UTF-8 텍스트escaping 규칙
대량 질의 저장데이터베이스트랜잭션과 인덱스

연습 문제

DataStream의 writeUTF 대신 4바이트 길이와 일반 UTF-8 바이트로 문자열을 기록하고 읽는 함수를 작성하세요.

문자열당 최대 64KiB를 적용하고 잘린 본문과 음수 길이를 거부합니다.

정답과 해설
exercise/Utf8FieldCodecSolution.java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public final class Utf8FieldCodecSolution {
    static void writeString(DataOutputStream output, String value) throws IOException {
        byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
        if (bytes.length > 65_536) throw new IOException("text too large");
        output.writeInt(bytes.length);
        output.write(bytes);
    }

    static String readString(DataInputStream input) throws IOException {
        int length = input.readInt();
        if (length < 0 || length > 65_536) throw new IOException("invalid length");
        byte[] bytes = input.readNBytes(length);
        if (bytes.length != length) throw new EOFException("short text");
        return new String(bytes, StandardCharsets.UTF_8);
    }

    public static void main(String[] args) throws Exception {
        var bytes = new ByteArrayOutputStream();
        try (var output = new DataOutputStream(bytes)) {
            writeString(output, "서울 café");
        }
        try (var input = new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
            System.out.println(readString(input));
        }
    }
}

다른 언어도 4바이트 big-endian 길이와 표준 UTF-8이라는 사양만 알면 같은 데이터를 읽을 수 있습니다.

엄격한 UTF-8 디코더를 추가하면 잘못된 바이트를 대체 문자로 조용히 바꾸는 일도 막을 수 있습니다.


바이너리 형식 호환성의 최종 조건

바이너리 저장의 핵심은 타입 메서드의 편의가 아니라 파일 스키마의 명시성입니다.

매직·버전·길이 상한·읽기 순서·신뢰 구분을 문서와 코드에 넣고 이전 버전 표본을 계속 읽어 보아야 형식 변경이 과거 데이터를 잃지 않습니다.