1. 서론: 왜 직렬화를 최적화해야 할까?
현대 애플리케이션에서 직렬화는 네트워크 프로토콜부터 분산 캐시, 서비스 간 데이터 교환까지 곳곳에서 사용됩니다.
여기서 직렬화의 속도와 크기는 매우 중요합니다. 직렬화가 느리면 애플리케이션이 데이터를 저장하거나 로드할 때 지연이 발생하고, 네트워크나 디스크는 헛되이 대기하게 됩니다. 객체가 너무 크면 디스크 공간을 많이 차지하고, 네트워크 전송이 오래 걸리며, 메모리와 대역폭에 추가 부담을 줍니다.
전형적인 과제로는 큰 객체 그래프를 파일이나 캐시에 저장하기, 최소 지연으로 네트워크를 통해 객체를 전달하기, 그리고 멀티스레드 시스템에서 데이터를 빠르게 직렬화/역직렬화하기 등이 있습니다.
결론은 간단합니다. 직렬화 최적화는 “프리미엄 기능”이 아니라, 고성능·확장형 애플리케이션을 위한 필수적인 실천입니다.
2. 직렬화된 데이터 크기 최적화
불필요한 데이터 제외: 키워드 transient
기본적으로 객체의 모든 필드는 transient로 표시된 것을 제외하고 직렬화됩니다. 저장할 필요가 없는 필드(예: 캐시, 임시 데이터, 서비스 참조)는 transient로 표시하세요:
public class User implements Serializable {
private String name;
private transient String sessionToken; // 직렬화되지 않음
}
장점:
- 직렬화된 객체의 크기가 줄어듭니다.
- 파일이나 네트워크에 불필요하거나 위험한 데이터가 남지 않습니다.
수동 직렬화: 인터페이스 Externalizable
무엇을 어떻게 직렬화할지 완전한 제어가 필요하다면 인터페이스 Externalizable을 구현하고 직렬화를 명시적으로 기술하세요(writeExternal/readExternal 메서드):
public class Person implements Externalizable {
private String name;
private int age;
private transient String secret;
@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeUTF(name);
out.writeInt(age);
// secret은 직렬화하지 않음
}
@Override
public void readExternal(ObjectInput in) throws IOException {
name = in.readUTF();
age = in.readInt();
}
}
장점:
- 필요한 필드만 직렬화됩니다.
- 호환성을 유지하면서 직렬화 포맷을 변경할 수 있습니다.
압축: 직렬화된 데이터 압축
직렬화된 객체는 특히 반복되는 문자열과 큰 컬렉션을 포함할 때 크기가 커지기 쉽습니다. 압축을 사용해 크기를 줄일 수 있습니다.
GZIPOutputStream 예시:
try (ObjectOutputStream out = new ObjectOutputStream(
new GZIPOutputStream(new FileOutputStream("data.gz")))) {
out.writeObject(bigObject);
}
ZipOutputStream 예시:
try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream("data.zip"))) {
zip.putNextEntry(new ZipEntry("object"));
ObjectOutputStream out = new ObjectOutputStream(zip);
out.writeObject(bigObject);
out.flush();
zip.closeEntry();
}
장점:
- 파일 크기가 몇 배(특히 큰 객체 그래프에서)까지 줄어들 수 있습니다.
- 네트워크 전송 트래픽이 감소합니다.
단점:
- 압축/해제에 추가 CPU 시간이 필요합니다.
3. 직렬화 속도 최적화
버퍼링: 왜 BufferedOutputStream과 BufferedInputStream이 필요한가
문제:
버퍼링이 없으면 write() 또는 read() 호출마다 디스크나 네트워크에 대한 시스템 호출이 발생합니다 — 이는 매우 느립니다!
해결:
버퍼링된 스트림을 사용하세요:
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data.bin")))) {
out.writeObject(bigObject);
}
try (ObjectInputStream in = new ObjectInputStream(
new BufferedInputStream(new FileInputStream("data.bin")))) {
Object obj = in.readObject();
}
장점:
- 큰 객체의 쓰기/읽기를 크게 가속합니다.
- 디스크/네트워크 접근 횟수를 줄입니다.
작동 방식은?
버퍼는 데이터를 메모리에 모아 한 번에 덩어리로 기록하며, 바이트 단위로 쓰지 않습니다.
빠른 복사: FileChannel.transferTo
큰 직렬화 파일을 빠르게 복사해야 한다면 NIO의 transferTo 메서드를 사용하세요:
try (FileChannel src = new FileInputStream("data.bin").getChannel();
FileChannel dest = new FileOutputStream("copy.bin").getChannel()) {
src.transferTo(0, src.size(), dest);
}
장점:
- 복사가 OS 레벨에서 수행되어 Java의 불필요한 버퍼링을 우회 — 큰 파일에서 매우 빠릅니다.
4. 직렬화 프로파일링
간단한 시간 측정: System.nanoTime()
직렬화 성능을 빠르게 가늠하려면 System.nanoTime()을 사용할 수 있습니다:
long start = System.nanoTime();
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data.bin")))) {
out.writeObject(bigObject);
}
long end = System.nanoTime();
System.out.println("직렬화 시간: " + (end - start) / 1_000_000 + " ms");
장점:
- 간단하고 빠릅니다.
- 여러 변형(버퍼 사용/미사용, 압축 유무 등)을 비교할 수 있습니다.
단점:
- GC와 백그라운드 프로세스의 영향으로 결과가 흔들릴 수 있습니다.
- 아주 미세한 차이를 정확히 비교하기에는 적합하지 않습니다.
정밀 프로파일링: JMH (Java Microbenchmark Harness)
더 정밀한 측정을 위해서는 마이크로벤치마크 전용 라이브러리인 JMH를 사용하세요.
간단한 벤치마크 예시:
@Benchmark
public void serializeWithBuffer() throws Exception {
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data.bin")))) {
out.writeObject(bigObject);
}
}
장점:
- JVM 워밍업, GC 영향, OS 노이즈를 고려합니다.
- 신뢰할 수 있고 재현 가능한 결과를 제공합니다.
단점:
- JMH 방법론에 대한 설정과 이해가 필요합니다.
- 대략적인 비교 용도로는 과할 수 있습니다.
5. 실습: 직렬화 시간과 크기 비교
미니 실험을 해봅시다. 큰 객체 그래프(예: 중첩 컬렉션을 포함한 100_000개 객체의 리스트)를 여러 방식으로 직렬화하고, 시간과 파일 크기를 비교합니다.
버퍼링·압축 없이 직렬화
long start = System.nanoTime();
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("data1.bin"))) {
out.writeObject(bigList);
}
long end = System.nanoTime();
System.out.println("버퍼 없음: " + (end - start) / 1_000_000 + " ms, 크기: " +
new File("data1.bin").length() + " 바이트");
버퍼링 사용 직렬화
long start = System.nanoTime();
try (ObjectOutputStream out = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data2.bin")))) {
out.writeObject(bigList);
}
long end = System.nanoTime();
System.out.println("버퍼 사용: " + (end - start) / 1_000_000 + " ms, 크기: " +
new File("data2.bin").length() + " 바이트");
압축(GZIP) 사용 직렬화
long start = System.nanoTime();
try (ObjectOutputStream out = new ObjectOutputStream(
new GZIPOutputStream(new FileOutputStream("data3.gz")))) {
out.writeObject(bigList);
}
long end = System.nanoTime();
System.out.println("압축 사용: " + (end - start) / 1_000_000 + " ms, 크기: " +
new File("data3.gz").length() + " 바이트");
결과 분석
직렬화를 테스트해 보면 버퍼링과 압축의 영향이 매우 크다는 것을 알 수 있습니다. 압축된 파일은 보통 2–10배 더 작아집니다(정확한 비율은 데이터 구조에 따라 달라집니다). 버퍼를 사용하면 직렬화가 눈에 띄게 빨라지고, 압축은 과정을 다소 느리게 만들 수 있지만 공간 절약의 가치가 큰 경우가 많습니다.
결론: 대용량 데이터에는 반드시 버퍼링을 사용하고, 크기가 중요하다면 압축을 추가하세요.
6. 직렬화 최적화 시 흔한 실수
오류 1: 버퍼링을 사용하지 않음 — 큰 객체의 직렬화가 몇 배나 느려집니다.
오류 2: 불필요하거나 민감한 데이터(예: 비밀번호, 임시 토큰)를 직렬화함 — 이러한 필드에는 항상 transient를 사용하세요.
오류 3: 압축이 항상 직렬화를 빠르게 만든다고 기대함 — 실제로 압축은 크기를 줄이지만(특히 성능이 낮은 CPU에서) 처리를 다소 느리게 만들 수 있습니다.
오류 4: JVM 워밍업과 GC 영향을 고려하지 않은 시간 측정 — 정확한 벤치마크에는 JMH를 사용하세요.
오류 5: 시간만 또는 크기만 비교함 — 두 지표를 모두 보고, 과제에 맞는 최적의 균형을 선택하세요.
GO TO FULL VERSION