본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
24장 : Path·Files와 파일 영속화

DataStream과 형식 버전

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

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

두 번째 읽기는 파일 위치가 어긋나 예외가 날 수 있습니다. 정상 구현은 읽기와 쓰기가 공유하는 코덱 메서드 또는 명시적 스키마 문서를 둡니다. 변경 시 버전을 올리고 이전 버전 전용 판독기를 유지하거나 마이그레이션 도구를 제공합니다.

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

매직 값은 전혀 다른 파일을 빠르게 구분하고 버전은 뒤 필드 해석 방법을 선택합니다. 레코드 수와 문자열 길이에는 상한이 필요합니다. 공격자가 파일 앞의 수를 매우 크게 조작하면 배열과 목록 할당만으로 메모리를 소진시킬 수 있습니다.

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

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로 표시했다고 파일 전체가 안전해지는 것은 아닙니다. 다른 필드, 참조 객체, 로그에 값이 남을 수 있으며 영속 비밀에는 암호화와 키 관리가 필요합니다.

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

역직렬화는 단순 데이터 파싱보다 강한 동작을 일으킬 수 있습니다. 클래스 로딩과 객체 생성 과정, 사용자 정의 메서드가 실행될 수 있어 허용하지 않은 타입 그래프가 보안 문제가 됩니다. 인터넷 업로드나 외부 메시지에서 받은 바이트를 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 디코더를 추가하면 잘못된 바이트를 대체 문자로 조용히 바꾸는 일도 막을 수 있습니다.

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

바이너리 저장의 핵심은 타입 메서드의 편의가 아니라 파일 스키마의 명시성입니다. 매직·버전·길이 상한·읽기 순서·신뢰 구분을 문서와 코드에 넣고 이전 버전 표본을 계속 읽어 보아야 형식 변경이 과거 데이터를 잃지 않습니다.