본문 바로가기

JSON·XML·데이터베이스 중 저장 형식 고르기

자바 객체 직렬화 대신 JSON·XML을 받을 때 값과 구조를 직접 검사하는 방법과, 파일과 데이터베이스를 요구에 따라 고르는 기준을 익힙니다.

프로그램이 다루는 데이터는 그 데이터를 처음 저장한 코드보다 오래 남습니다. 클래스는 리팩터링으로 필드가 바뀌고, 다른 언어로 작성한 서비스가 같은 파일을 읽어야 하며, 몇 달 전 버전으로 저장한 파일을 지금 코드가 다시 복구해야 할 때도 있습니다. 그래서 '무엇으로 저장할 것인가'는 바이트를 가장 짧게 만드는 문제가 아니라, 나중에 그 바이트에서 의미를 안전하게 되살릴 수 있는가 하는 문제입니다.

DataStream과 객체 직렬화를 다룬 글에서 본 ObjectOutputStream과 ObjectInputStream(이 글에서는 둘을 묶어 ObjectStream이라고 부릅니다)은 구현 클래스의 모양을 그대로 바이트로 기록하고 되살립니다. 편리하지만 클래스 이름과 필드 구조에 강하게 묶여 있어 장기 보관이나 외부 공개 형식으로는 불리합니다. 이 글에서는 그 대안인 JSON과 XML을 받을 때 무엇을 따로 검사해야 하는지, 그리고 단순 파일 대신 데이터베이스가 필요해지는 지점이 어디인지를 요구별로 정리합니다.

신뢰할 수 없는 바이트를 ObjectStream으로 읽지 않기

역직렬화는 파일에 적힌 클래스 그래프를 따라 객체를 다시 만듭니다. 이때 바이트에 적힌 타입의 클래스를 찾아 적재하고, 그 클래스가 역직렬화 과정에서 실행하도록 정의한 메서드가 있으면 객체를 만드는 도중에 그 메서드까지 실행합니다. 즉 readObject가 돌려준 값을 여러분 코드가 받아 보는 시점에는 이미 객체 생성이 끝나 있습니다. 그래서 받은 값을 원하는 타입으로 캐스팅해 확인하는 것은 너무 늦습니다. 캐스팅이 ClassCastException으로 실패하더라도, 그 전에 객체를 만들면서 실행된 코드는 되돌릴 수 없기 때문입니다.

bad/UnfilteredObjectInput.java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.ArrayList;

public final class UnfilteredObjectInput {
    public static void main(String[] args) throws Exception {
        var wire = new ByteArrayOutputStream();
        try (var output = new ObjectOutputStream(wire)) {
            output.writeObject(new ArrayList<>(java.util.List.of("untrusted")));
        }
        byte[] uploadedBytes = wire.toByteArray();
        try (var input = new ObjectInputStream(new ByteArrayInputStream(uploadedBytes))) {
            Object value = input.readObject();
            System.out.println(value.getClass() + " " + value);
        }
    }
}
class java.util.ArrayList [untrusted]

출력을 보면 바이트에 적혀 있던 ArrayList가 그대로 되살아났습니다. 이 예제는 직접 만든 안전한 객체를 쓰므로 그 자체로는 위험하지 않습니다. 문제는 API의 모양입니다. readObject는 업로드된 바이트가 어떤 타입을 담고 있어야 하는지 전혀 제약하지 않으므로, 외부에서 들어온 바이트를 그대로 넘기면 호출자가 기대하지 않은 타입과 깊은 객체 그래프, 거대한 배열이 만들어질 수 있습니다. ObjectInputFilter로 허용 클래스와 배열 길이, 그래프 깊이에 상한을 둘 수 있지만, 이것이 ObjectStream을 장기 공개 형식으로 추천하게 만드는 만능 해법은 아닙니다. 외부와 주고받는 데이터에는 필드 이름이 드러나고 다른 언어에서도 읽을 수 있는 JSON 같은 형식과 명시적인 값 검사를 먼저 고려합니다. 이미 쌓여 있는 직렬화 파일을 읽어야 한다면, 좁은 클래스 허용 목록과 크기 제한을 둔 격리된 변환 단계에서만 사용합니다.

더 깊이: 필터는 왜 캐스팅보다 먼저 막을 수 있나

캐스팅은 readObject가 끝난 뒤에 실행되지만, ObjectInputFilter는 ObjectInputStream이 스트림을 읽는 도중에 호출됩니다. 스트림에서 클래스 설명을 만날 때마다 그 클래스의 객체를 만들기 전에 필터를 부르고, 배열 길이와 그래프 깊이, 참조 수, 지금까지 읽은 바이트 수도 함께 넘겨 줍니다. 필터가 거부하면 readObject는 InvalidClassException을 던지고 그 객체는 아예 만들어지지 않습니다. 실제로 String만 허용하는 필터를 건 스트림에 다른 클래스의 객체를 넣어 보면, 그 클래스의 역직렬화 메서드가 한 번도 실행되지 않은 채 filter status: REJECTED라는 메시지와 함께 실패합니다. 필터는 Java 9에서 들어왔고(JEP 290), Java 17부터는 상황마다 다른 필터를 고르는 필터 팩터리도 쓸 수 있습니다(JEP 415).

JSON을 파싱한 뒤 값까지 검사하기

JSON 라이브러리가 문법을 읽어 Map이나 DTO를 만들어 주었다고 해서, 그 안의 값이 업무에서 쓸 수 있는 값이라는 뜻은 아닙니다. 파서는 중괄호와 따옴표가 문법에 맞는지만 봅니다. 필수 필드가 있는지, 타입과 길이와 범위가 맞는지, 모르는 필드가 섞여 있지는 않은지는 데이터를 받는 쪽에서 직접 확인해야 합니다. 그래서 파싱 결과를 바로 업무 객체로 쓰지 않고, 데이터가 들어오는 입구에서 한 번 걸러 record로 만드는 검사 함수를 둡니다.

src/MemberJsonBoundary.java
import java.util.Map;
import java.util.Set;

public final class MemberJsonBoundary {
    record Member(String id, String name, int age) {}

    static Member validate(Map<String, Object> json) {
        Set<String> allowed = Set.of("id", "name", "age");
        if (!allowed.containsAll(json.keySet())) throw new IllegalArgumentException("unknown field");
        if (!(json.get("id") instanceof String id) || id.isBlank() || id.length() > 40) {
            throw new IllegalArgumentException("id");
        }
        if (!(json.get("name") instanceof String name) || name.isBlank() || name.length() > 100) {
            throw new IllegalArgumentException("name");
        }
        if (!(json.get("age") instanceof Number number)) throw new IllegalArgumentException("age type");
        int age = number.intValue();
        if (age < 0 || age > 150) throw new IllegalArgumentException("age range");
        return new Member(id, name, age);
    }

    public static void main(String[] args) {
        Map<String, Object> parsed = Map.of("id", "u1", "name", "Mina", "age", 31);
        System.out.println(validate(parsed));
    }
}
Member[id=u1, name=Mina, age=31]

validate는 허용한 세 키 말고 다른 키가 섞이면 바로 거부하고, id와 name은 문자열인지와 비어 있지 않은지, 길이 상한을 함께 확인합니다. 이 main은 파서를 거치지 않고 Map.of로 입력을 직접 만들어, 검사 로직만 떼어 시험합니다. 실제 서비스라면 JSON 문자열을 파서에 넣어 얻은 Map을 이 함수에 넘기게 됩니다.

나이를 정확한 정수로만 받기

위 함수에는 숫자 처리에 구멍이 있습니다. 나이를 Number로 받은 뒤 intValue()로 int를 꺼내는데, intValue()는 소수 부분이나 int 범위를 넘는 자리를 조용히 버립니다. 예를 들어 BigDecimal("31.5")를 주면 소수점 아래를 떼어 내 31로 통과시키고, BigInteger("4294967327")도 하위 32비트만 남기면 31이 되어 원래의 큰 값을 잃습니다. 변환한 뒤의 31만 범위 검사하면 두 경우 모두 걸러 내지 못합니다.

이 문제를 막으려면 숫자를 int로 바꾸기 전에 원래 값의 타입과 크기를 확인해야 합니다. 아래 함수는 입력 숫자를 Byte·Short·Integer·Long·BigInteger·BigDecimal로 제한하고, 변환에는 intValueExact()를 씁니다. BigDecimal.intValueExact()의 공식 문서는 소수 부분이 0이 아니거나 int 범위를 벗어나면 ArithmeticException을 던진다고 정합니다. 그래서 소수 부분이 0인 31.0은 수학적으로 정수 31이라 허용하고, 31.5는 거부하는 정책이 됩니다. 이진 부동소수를 쓰는 파서라면 먼저 파서의 숫자 표현 방식을 정해야 하므로, 이 함수가 모든 파서에 그대로 들어맞는 것은 아닙니다.

src/ExactAgeBoundarySolution.java
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public final class ExactAgeBoundarySolution {
    static int exactAge(Object value) {
        final BigDecimal decimal;
        if (value instanceof BigDecimal number) decimal = number;
        else if (value instanceof BigInteger number) decimal = new BigDecimal(number);
        else if (value instanceof Byte || value instanceof Short
                || value instanceof Integer || value instanceof Long) {
            decimal = BigDecimal.valueOf(((Number) value).longValue());
        } else throw new IllegalArgumentException("age numeric representation");
        final int age;
        try { age = decimal.intValueExact(); }
        catch (ArithmeticException invalid) {
            throw new IllegalArgumentException("age must be an exact int", invalid);
        }
        if (age < 0 || age > 150) throw new IllegalArgumentException("age range");
        return age;
    }

    static MemberJsonBoundary.Member validateExact(Map<String, Object> json) {
        Map<String, Object> checked = new HashMap<>(json);
        checked.put("age", exactAge(json.get("age")));
        return MemberJsonBoundary.validate(checked);
    }

    public static void main(String[] args) {
        for (Object age : List.of(31, new BigDecimal("31.5"),
                new BigInteger("4294967327"), new BigDecimal("31.0"), 151)) {
            try {
                System.out.println(validateExact(Map.of("id", "u1", "name", "Mina", "age", age)));
            } catch (IllegalArgumentException invalid) {
                System.out.println(invalid.getMessage());
            }
        }
    }
}

이 파일은 앞의 MemberJsonBoundary.java와 함께 컴파일합니다. 입력 Map을 복사해 나이만 정확한 int로 바꾼 뒤, 원래 함수의 ID·이름·모르는 키 검사를 그대로 재사용합니다. 다섯 개의 입력을 차례로 넣으면 다음이 출력됩니다.

Member[id=u1, name=Mina, age=31]
age must be an exact int
age must be an exact int
Member[id=u1, name=Mina, age=31]
age range

첫 줄의 31과 네 번째 줄의 31.0은 정확한 정수 31이라 회원이 만들어집니다. 둘째 줄의 31.5와 셋째 줄의 4294967327은 메시지가 같지만 원인이 다릅니다. 하나는 소수 부분이 0이 아니어서, 다른 하나는 int 범위를 벗어나서 intValueExact()가 예외를 던진 것입니다. 마지막 151은 정수 변환은 통과하지만 0 이상 150 이하라는 업무 범위를 벗어나 age range로 거부됩니다. 다만 JSON 문자열에 중복된 키가 있었다면 파서가 이미 하나만 남기고 버렸을 수 있는데, 이 Map 단계 함수로는 그것까지 되돌릴 수 없습니다. 중복 키 정책은 파서 설정에서 정해야 합니다.

외부에서 들어오는 DTO를 데이터베이스에 저장하는 엔티티와 분리해 두면, 외부 형식에 필드가 하나 늘어도 내부 모델이 덩달아 바뀌지 않습니다. 오류를 돌려줄 때는 어떤 필드가 왜 거부됐는지 안정적인 코드로 알려 주되, 받은 입력 전체를 로그에 남기지는 않습니다. 로그에 민감한 값이 그대로 쌓일 수 있기 때문입니다.

XML 파서의 외부 엔티티 막기

XML은 속성과 네임스페이스, 스키마, 글과 태그가 섞이는 혼합 내용(mixed content)이 필요한 문서 형식에서 여전히 쓸모가 있습니다. 그런데 XML 문서는 루트 요소 앞의 <!DOCTYPE ...> 선언에 문서 형식 정의(DTD, Document Type Definition)를 직접 적거나 바깥 파일로 가리킬 수 있고, DTD 안에서는 이름 붙은 값인 엔티티(entity)를 선언할 수 있습니다. 엔티티에는 본문에서 &name;처럼 불러 쓰는 일반 엔티티와 DTD 안에서 %name;처럼 불러 쓰는 매개변수 엔티티가 있습니다. <!ENTITY name SYSTEM "file:///etc/passwd">처럼 값을 바깥 파일이나 URL에서 가져오도록 선언한 엔티티를 외부 엔티티라고 합니다.

기본 파서 설정은 이런 외부 엔티티를 해석할 수 있어, 파일이나 네트워크 자원을 끌어와 읽는 XXE(XML External Entity) 공격에 노출될 수 있습니다. 그래서 신뢰할 수 없는 XML을 읽을 때는 외부 DTD와 엔티티를 먼저 차단하고, 그다음에 문서 구조와 값을 따로 검사합니다.

src/SecureMemberXml.java
import java.io.StringReader;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import org.xml.sax.InputSource;

public final class SecureMemberXml {
    record Member(String id, String name, int age) {}

    static Member parse(String xml) throws Exception {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
        factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
        factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
        factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
        factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
        var document = factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml)));
        var root = document.getDocumentElement();
        if (!"member".equals(root.getTagName())) throw new IllegalArgumentException("root");
        String id = root.getAttribute("id");
        String name = root.getElementsByTagName("name").item(0).getTextContent();
        int age = Integer.parseInt(root.getElementsByTagName("age").item(0).getTextContent());
        if (id.isBlank() || name.isBlank() || age < 0) throw new IllegalArgumentException("value");
        return new Member(id, name, age);
    }

    public static void main(String[] args) throws Exception {
        System.out.println(parse("<member id=\"u1\"><name>Mina</name><age>31</age></member>"));
    }
}
Member[id=u1, name=Mina, age=31]

disallow-doctype-decl을 true로 두면 문서 안에 DOCTYPE 선언 자체가 있을 때 파싱을 거부하므로, 엔티티를 정의할 입구를 막습니다. 뒤의 두 기능과 ACCESS_EXTERNAL_DTD·ACCESS_EXTERNAL_SCHEMA를 빈 문자열로 두는 것은, 혹시 DOCTYPE을 허용하는 설정이라도 외부 자원은 읽지 못하게 하는 이중 차단입니다. JDK 25의 기본 설정도 호환성 때문에 DTD 처리와 외부 자원 접근을 허용합니다. 그래서 이 설정을 하지 않은 파서는 외부 엔티티가 가리키는 파일 내용을 그대로 문서 값으로 읽어 올 수 있습니다. JDK 23부터 $JAVA_HOME/conf에 더 엄격한 설정 견본(jaxp-strict.properties.template)이 함께 들어 있지만, 직접 적용해야 효과가 있습니다. 코드에서 하는 설정이 실패하면 무시하지 말고 시작 단계에서 멈춰야 하는 이유도 여기에 있습니다.

두 원문이 실제로 검사하는 필드와 구조를 비교한다

MemberJsonBoundary와 SecureMemberXml은 같은 Member를 만들지만 필드 탐색과 숫자 처리 규칙이 다릅니다.

입력 경계 · JSON과 XML 검사

두 원문이 실제로 검사하는 필드와 구조를 비교한다

같은 회원을 만들더라도 필드 구조와 숫자 변환에서 실제로 검사하는 범위는 다릅니다.

JSON 예제의 validate(Map)

필드 구조
id·name·age 외의 키를 거부
id와 name
String·공백 아님·길이 40/100 이하
age
Number.intValue 변환 뒤 0..150 검사
별도로 남는 검사
소수·큰 수의 int 변환 손실과 원래 숫자 범위

XML 예제의 parse(String)

필드 구조
루트 member 확인 · 후손 name·age의 첫 항목 사용
id와 name
문자열 공백 여부만 확인
age
Integer.parseInt 변환 뒤 음수만 거부
별도로 남는 검사
필수 요소 누락·중복·중첩·알 수 없는 필드

JSON 예제의 main은 파서 없이 Map.of로 입력을 만듭니다. XML의 외부 DTD·엔티티 차단도 문서 구조와 업무 값 검증 전체를 대신하지 않습니다.

DOM 방식은 문서 전체를 메모리에 올리므로 큰 XML에는 스트리밍 파서와 요소 수·깊이 상한을 함께 검토합니다. 보안 기능의 지원 여부는 파서 구현마다 다를 수 있고, XML 스키마 검증도 업무 규칙을 전부 대신하지는 못합니다. 구조와 값 검사가 왜 따로 필요한지는 아래 문제로 확인할 수 있습니다.

직접 확인: 중첩과 누락된 요소

위 parse 함수에 <member id="u1"><extra><name>Mina</name></extra><age>151</age></member>를 준다면 지금 검사로 거부될까요? name 요소를 아예 빼면 어느 호출에서 문제가 날까요? 엔티티 차단 설정은 그대로 두고, 필드를 찾는 방식과 값 검사만 읽어 답해 보세요.

풀이

getElementsByTagName은 바로 아래 자식만이 아니라 모든 후손에서 찾으므로, extra 안에 들어간 name도 첫 번째로 집어 옵니다. age는 음수만 거부하므로 151은 그대로 통과합니다. 그래서 이 입력은 이름 Mina, 나이 151로 받아들여집니다. name을 빼면 item(0)이 null을 돌려주고, 그 뒤 getTextContent() 호출에서 NullPointerException이 납니다. 잘못된 문서에 안정적인 오류를 돌려주려면 직접 자식만 세기, 필수 요소 확인, 중복과 모르는 요소 처리, 업무 나이 범위를 따로 검사해야 합니다. 외부 엔티티 차단은 이 구조 검사를 대신하지 않습니다.

단순 파일로 충분할 때와 데이터베이스가 필요할 때

회원 ID 하나로 한 건을 찾으려고 텍스트 파일 전체를 매번 읽는다면, 데이터가 커질수록 조회 시간이 선형으로 늘어납니다. 데이터베이스는 인덱스로 이런 검색 경로를 미리 만들어 두어 전체를 훑지 않고 찾아가며, 여러 요청이 동시에 같은 데이터를 고칠 때는 트랜잭션으로 순서를 조정합니다. 예를 들어 두 프로세스가 같은 ID를 동시에 등록하려 할 때, 고유 제약을 걸어 두면 저장 계층에서 한쪽만 성공하도록 막을 수 있습니다. 트랜잭션은 여러 행을 함께 바꾸는 작업을 모두 반영하거나 모두 취소하게 해 줍니다.

같은 일을 파일로 흉내 내면 빈틈이 생깁니다. 파일 두 개를 차례로 덮어쓰는 구현은 첫 파일만 쓰고 멈추면 절반만 바뀐 상태가 남습니다. 백업과 시점 복구, 권한 관리, 감사 로그까지 파일 코드로 직접 만들면 결국 데이터베이스가 이미 제공하는 저장 시스템을 다시 구현하는 셈이 됩니다. 반대로 이미지나 영상처럼 큰 바이트 덩어리는 객체 스토리지나 파일에 두고, 데이터베이스에는 식별자·경로·해시·소유자·상태만 저장하는 편이 낫습니다. 어느 한쪽이 언제나 정답은 아니며, 접근 패턴과 일관성 기준에 따라 본문과 메타데이터를 나눕니다.

형식을 바꿀 때 구버전을 함께 읽기

저장 형식은 한 번 정하면 끝이 아니라 시간이 지나며 바뀝니다. JSON에 선택 필드를 하나 추가할 때는, 오래된 판독기가 모르는 필드를 무시하도록 미리 정해 두었는지부터 확인합니다. 필드를 필수로 바꾸거나 의미를 바꿀 때는 같은 이름을 재사용하지 말고 새 필드나 새 버전을 둡니다. 기존 데이터에는 그 이름의 옛 의미가 그대로 남아 있기 때문입니다. 기록기와 판독기 중 어느 쪽을 먼저 배포하느냐에 따라 한동안 두 버전이 함께 도는 기간이 생기므로, 배포 순서를 고려한 호환성 표를 만들어 두면 좋습니다.

데이터베이스 마이그레이션도 같은 원칙을 '확장 → 전환 → 정리' 순서로 적용할 수 있습니다. 먼저 새 컬럼을 null 허용이나 기본값과 함께 추가하고, 옛 컬럼과 새 컬럼을 모두 다룰 수 있는 코드를 배포한 뒤, 데이터를 새 컬럼으로 채웁니다. 모든 인스턴스가 새 경로를 쓰는 것을 확인한 다음에야 옛 컬럼을 제거합니다. 이름 변경과 코드 전환을 한 번의 배포에서 동시에 하면, 문제가 생겼을 때 되돌리기가 어려워집니다.

저장 형식을 고르는 기준

지금까지 본 선택지를 요구별로 묶으면 다음과 같습니다. 어느 것도 절대적인 정답이 아니라, 누가 만들고 누가 읽고 어떻게 복구하는지에 따라 출발점이 달라집니다.

요구적합한 출발점이유
사람이 읽고 고치는 설정UTF-8 JSON 또는 YAML편집과 검토가 쉬움
문서 구조와 네임스페이스차단 설정을 한 XML풍부한 문서 모델
서비스 사이의 교환스키마가 있는 JSON·이진 형식언어에 중립적
동시에 바뀌는 업무 데이터관계형 데이터베이스트랜잭션과 제약
큰 불변 본문객체 스토리지와 메타 DB용량과 검색을 분리

연습 문제

레코드 수, 동시 기록기 수, ID 조회 여부, 사람이 편집할 필요, 다른 언어가 읽을 필요를 입력받아 텍스트 파일·데이터베이스·언어 중립 메시지 중 어느 것으로 시작할지 돌려주는 함수를 만들어 보세요. 고른 이유도 결과에 함께 담습니다.

정답과 해설
exercise/StorageChoiceSolution.java
public final class StorageChoiceSolution {
    record Needs(long records, int writers, boolean lookupById,
                 boolean humanEditable, boolean crossLanguage) {}
    record Choice(String technology, String reason) {}

    static Choice choose(Needs needs) {
        if (needs.writers() > 1 || needs.lookupById() || needs.records() > 100_000) {
            return new Choice("database", "동시 변경과 인덱스 조회가 필요합니다");
        }
        if (needs.crossLanguage()) {
            return new Choice("schema data format", "구현 클래스와 분리된 스키마가 필요합니다");
        }
        if (needs.humanEditable()) {
            return new Choice("utf-8 text", "사람이 검토하고 수정해야 합니다");
        }
        return new Choice("versioned file", "작은 단일 writer 데이터입니다");
    }

    public static void main(String[] args) {
        System.out.println(choose(new Needs(500_000, 4, true, false, true)));
        System.out.println(choose(new Needs(50, 1, false, true, false)));
    }
}
Choice[technology=database, reason=동시 변경과 인덱스 조회가 필요합니다]
Choice[technology=utf-8 text, reason=사람이 검토하고 수정해야 합니다]

첫 입력은 기록기가 넷이고 ID 조회가 필요하며 레코드도 많아 데이터베이스를 고릅니다. 둘째 입력은 작고 단일 기록기이며 사람이 편집해야 하므로 UTF-8 텍스트를 고릅니다.

여기 쓴 임계값은 절대 법칙이 아니라 대화를 시작하는 기준입니다. 복구 목표, 보안, 배포 환경, 팀의 운영 능력을 더해 실제 결정을 보완합니다. 코드가 돌려준 기술 이름보다 reason이 요구와 맞는지가 중요합니다. 이 함수의 첫 분기는 세 조건 중 하나만 맞아도 같은 설명을 돌려주므로, 동시 변경과 ID 조회가 항상 둘 다 필요한 것으로 단정하지는 않습니다.

저장 형식을 정하기 전에 적어야 할 것

좋은 저장 형식은 객체를 가장 짧게 기록하는 형식이 아니라, 미래의 판독기가 의미를 안전하게 되살릴 수 있는 형식입니다. 입력을 신뢰할 수 있는지, 버전이 바뀌어도 호환되는지, 동시에 수정되는지, 검색과 복구는 어떤 수준이 필요한지를 먼저 적어 두면, ObjectStream·JSON·XML·데이터베이스 사이의 선택이 취향이 아니라 근거 있는 설계가 됩니다. 파일로 저장하기로 했다면, 다음 글에서 경로를 안전하게 검사하고 파일을 원자적으로 교체하는 방법을 이어서 봅니다.