객체 직렬화의 대안
자바 객체 직렬화의 버전·보안·플랫폼 결합을 피하고 JSON과 XML의 명시적 검증, 데이터베이스의 트랜잭션·인덱스·동시성 기능을 요구별로 선택합니다.
저장 형식은 현재 객체를 바이트로 바꾸는 기술보다 오래 살아남습니다. 애플리케이션 클래스는 리팩터링되고 다른 언어의 서비스가 데이터를 읽으며, 과거 버전 파일을 새 코드가 복구해야 합니다. 구현 클래스의 모양을 그대로 기록하는 ObjectStream은 이 변화에 강하게 결합되어 장기 영속성과 공개 API에 불리합니다.
JSON과 XML은 필드 이름이 드러나는 데이터 중심 형식이고 다양한 언어에서 읽을 수 있습니다. 데이터베이스는 형식뿐 아니라 동시 수정, 원자적 커밋, 인덱스 조회, 접근 제어, 백업을 제공합니다. 단순 파일과 데이터베이스의 차이는 문법 취향이 아니라 운영 요구의 차이입니다.
외부 입력의 역직렬화 위험
역직렬화는 파일에 적힌 클래스 그래프를 따라 객체를 만듭니다. 신뢰하지 않은 바이트를 허용하면 예상하지 않은 클래스와 깊은 그래프, 거대한 배열이 생성될 수 있습니다. 단순한 캐스팅 검사만으로는 객체가 만들어지는 과정에서 이미 일어난 동작을 되돌릴 수 없습니다.
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);
}
}
}이 예시는 안전한 자체 객체를 쓰지만 API 모양이 문제입니다. 업로드 바이트가 어떤 타입을 요구할지 제약하지 않습니다. ObjectInputFilter를 추가해도 외부 교환 형식으로는 명시적 데이터 스키마가 더 낫습니다. 이미 존재하는 직렬화 파일은 격리된 마이그레이션 프로세스에서 좁은 클래스 허용 목록과 크기 제한으로 읽습니다.
JSON과 도메인 검증 분리
JSON 라이브러리가 문법을 읽어 Map이나 DTO를 만들었다고 업무 값이 유효한 것은 아닙니다.
필수 필드, 타입, 길이, 범위, 알 수 없는 필드 정책을 접점에서 확인합니다.
파서 옵션으로 중복 키를 허용할지, 큰 숫자를 어떻게 다룰지도 정해야 합니다.
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));
}
}실제 코드에서는 검증 라이브러리와 JSON 파서를 사용하더라도 이 도메인 구분은 남습니다. DTO를 영속 엔티티와 분리하면 외부 필드 추가가 내부 모델을 무심코 바꾸지 않습니다. 오류 응답에는 안정적인 경로와 코드를 제공하고 입력 전체를 로그로 남기지 않습니다.
XML 파서의 입력 제한
XML은 속성, 네임스페이스, 스키마, mixed 내용이 필요한 문서 형식에서 여전히 유용합니다. 그러나 기본 파서 설정이 외부 엔티티를 해석하면 파일이나 네트워크 자원에 접근하는 XXE 위험이 생길 수 있습니다. 외부 DTD와 엔티티를 끄고 기대하는 루트·필드만 읽습니다.
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>"));
}
}DOM은 문서 전체를 메모리에 올리므로 대형 XML에는 스트리밍 파서와 요소 수·깊이 상한을 검토합니다. 보안 옵션 지원은 파서 구현에 따라 다를 수 있어 시작 시 설정 실패를 무시하지 않습니다. XML 스키마 검증도 업무 규칙을 전부 대신하지는 않습니다.
파일과 데이터베이스 선택
회원 ID로 한 건을 찾으려고 텍스트 파일 전체를 매번 읽는다면 데이터가 커질수록 시간이 선형으로 늘어납니다. 데이터베이스 인덱스는 검색 경로를 유지하고 여러 요청의 동시 변경을 트랜잭션으로 조정합니다. 고유 제약은 두 프로세스가 같은 ID를 동시에 등록하는 경쟁을 저장 계층에서 막습니다.
트랜잭션은 여러 행 변경을 모두 반영하거나 모두 취소하게 합니다. 파일 두 개를 순서대로 덮어쓰는 구현은 첫 파일만 성공한 중간 상태가 남을 수 있습니다. 백업과 시점 복구, 권한, 감사 로그도 직접 파일 코드로 만들면 별도의 저장 시스템을 재구현하게 됩니다.
이미지와 영상 같은 큰 바이트 객체는 객체 스토리지나 파일에 두고 데이터베이스에는 식별자, 경로, 해시, 소유자, 상태를 저장할 수 있습니다. 어느 한쪽만 절대적인 답은 아닙니다. 접근 패턴과 일관성 경계에 따라 메타데이터와 본문을 나눕니다.
스키마 진화와 호환성 창 운영
JSON에 선택 필드를 추가할 때 구 판독기가 알 수 없는 필드를 무시하도록 사전에 정했는지 확인합니다. 필수 필드로 바꾸거나 의미를 변경하면 같은 이름을 재사용하지 말고 새 버전 또는 새 필드를 둡니다. 기록기와 판독기 배포 순서를 고려한 호환성 표를 작성합니다.
데이터베이스 마이그레이션도 expand-and-규칙 순서를 사용합니다.
새 컬럼을 null 허용 또는 기본값과 함께 추가하고 양쪽 코드를 배포한 뒤 데이터를 채웁니다.
모든 인스턴스가 새 경로를 쓰는 것을 확인한 후 옛 컬럼을 제거합니다.
한 배포에서 rename과 코드 전환을 동시에 하면 롤백이 어려워집니다.
저장 선택 기준
| 요구 | 적합한 출발점 | 이유 |
|---|---|---|
| 사람이 읽는 설정 | UTF-8 JSON 또는 YAML | 편집과 검토 |
| 문서 구조와 네임스페이스 | 제한된 XML | 풍부한 문서 모델 |
| 서비스 간 규칙 | 스키마가 있는 JSON/바이너리 | 언어 중립성 |
| 동시 업무 데이터 | 관계형 데이터베이스 | 트랜잭션과 제약 |
| 큰 불변 본문 | 객체 스토리지와 메타 DB | 용량과 검색 분리 |
연습 문제
레코드 수, 동시 기록기 수, ID 조회, 사람이 편집할 필요, 다른 언어 소비 여부를 입력받아 텍스트 파일, 데이터베이스, 언어 중립 메시지 중 시작 후보를 반환하세요. 선택 근거를 결과에 함께 포함합니다.
정답과 해설
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)));
}
}임계값은 절대 법칙이 아니라 대화를 시작하는 기준입니다. 복구 목표, 보안, 배포 환경, 팀 운영 능력을 추가해 실제 결정을 보완합니다. 코드가 반환한 기술보다 reason이 요구와 맞는지가 중요합니다.
영속 기술 선택을 확정하는 질문
좋은 영속 형식은 객체를 가장 짧게 저장하는 형식이 아니라 미래 판독기가 의미를 안전하게 복원하는 형식입니다. 신뢰 입력 검증, 버전 호환성, 동시 수정, 검색과 복구 요구를 먼저 적으면 ObjectStream, JSON, XML, 데이터베이스의 선택이 취향이 아닌 근거 있는 설계가 됩니다.