"이펙티브 자바"의 마지막 챕터는 직렬화이다. 요즘은 대부분 JSON이나 Protobuf 같은 언어에 독립적인 데이터 교환 형식을 사용하는데, 왜 책에서는 여전히 직렬화를 중요한 주제로 다룰까?
자바에서 기본적으로 제공하는 직렬화(Serialization)와 역직렬화(Deserialization)는 JVM 위에서 동작하며, 데이터를 파일이나 네트워크로 전달할 수 있는 형식으로 변환하는 강력한 기능입니다. 하지만 이 메커니즘은 클래스 설계이 유연성 저하, 보안 취약점, 이식성 부족등의 큰 한계가 존재한다.
그렇다면 책에서는 왜 직렬화 파트를 크게 다룰까?
여기서는 크게 직렬화를 하게 되면 유의할 점 등 직렬화시 매커니즘과 유의 사항을 알아보자는 취지로 책을 읽어보자!
직렬화의 목적
현재 JVM 메모리 상태를 바이너리로 변환한다,
바이너리가 되면 파일에 해당 객체를 저장하거나 네트워크 통신을 통해 다른 기기에 저장하고 가져오기 위해 변환하는 작업이 가능하다.
아이템 85. 자바 직렬화의 대안을 찾아라
직렬화의 근본적인 문제는 공격범위가 너무 넓고 방어하기 어렵다는 것이다.
package io.member.impl;
import java.io.*;
class MyClass implements Serializable {
private String name;
private int age;
// 기본 생성자
public MyClass(String name, int age) {
this.name = name;
this.age = age;
}
// readObject() 메서드를 재정의하여 객체 복원 시 로직 추가 가능
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject(); // 기본 필드 복원
// 추가적인 로직
System.out.println("readObject() called for: " + name);
this.age = age * 2; // 예시로 age 값을 두 배로 변환
}
@Override
public String toString() {
return "MyClass{name='" + name + "', age=" + age + "}";
}
}
public class DeserializationTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
// 객체 직렬화
MyClass originalObject = new MyClass("Alice", 25);
byte[] serializedData = serialize(originalObject);
// 객체 역직렬화
MyClass deserializedObject = (MyClass) deserialize(serializedData);
// 역직렬화된 객체 출력
System.out.println(deserializedObject); // "MyClass{name='Alice', age=50}"
}
// 직렬화 메서드
private static byte[] serialize(Object obj) throws IOException {
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream)) {
objectOutputStream.writeObject(obj);
return byteArrayOutputStream.toByteArray();
}
}
// 역직렬화 메서드
private static Object deserialize(byte[] data) throws IOException, ClassNotFoundException {
try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data);
ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)) {
return objectInputStream.readObject();
}
}
}
ObjectInputStream의 readObject 메서드를 호출하면 객체 그래프가 역직렬화된다.
바이트스트림을 역직렬화가는 과정에서는 이 메서드는 그 타입들 간의 모든 코드를 수행할 수 있다. 즉 그 타입들의 코드 전체가 공격 범위에 들어간다.
역직렬화 폭탄
static byte[] bomb() {
Set<Object> root = new HashSet<>();
Set<Object> s1 = root;
Set<Object> s2 = new HashSet<>();
for (int i = 0; i < 100; i++) {
Set<Object> t1 = new HashSet<>();
Set<Object> t2 = new HashSet<>();
t1.add("foo"); //t1를 t2와 다르게 만든다.
s1.add(t1); s1.add(t2);
s2.add(t1); s2.add(t2);
s1 = t1;
s2 = t2;
}
return serialize(root);
}
위 코드는 100번의 반복문을 통해 Set 객체가 서로 상호 참조하도록 구성된다.
서버가 해당 직렬화 데이터를 역직렬화할때 그 안에 복잡한 참조관계로 인해 CPU와 메모리를 과도하게 사용하게 된다.
이렇게 직렬화된 데이터를 역직렬화하는 과정에서 서버 자원을 고갈시키는 방식으로 Dos 공격이 발생할 수 있다.
이러한 공격들때문에 신뢰할 수 없는 데이터는 역직렬화하지 말아야 한다.
꼭 해야 한다면 역직렬화 필터링을 사용하자.
또한 직렬화도 대신 json 대안을 사용하자.
아이템 86.serializable을 구현할지 신중히 결정하라
Serializable을 구현하면 릴리스한 뒤에는 수정하기 어렵다
클래스가 Serializable을 구현하면 직렬화된 바이트 스트림 인코딩도 하나의 공개 api 가 된다. 이 클래스가 널리 퍼진다면 그 직렬화 형태도 영원히 지원해야 한다. private 인스턴스 필드드 마저 api로 공개되는 꼴이 된다.
직렬화는 클래스개선을 방해한다.
모든 직렬화된 클래스는 고유식별 번호를 부여받는다.
직렬화 고유식별 번호는 왜 필요할까?
프로토콜의 변경이 있을 경우(클래스 파일이 변경될 경우) 변경을추적하고 하위 호환에 대한 추적을 하기 위한 목적으로 버전이 꼭 있어야 합니다.
static final long serialVersionUID 필드로 이 번호를 명시하지 않으면 시스템이 런타임에 암호를 생성하는 데는 이 번호를 명시하지 않으면 컴파일러가 자동으로 생성한다.
나중에 편의 메서드를 추가하는 식으로 하나라도 수정한다면 직렬 버전 UID 값도 변한다.
즉 자동 생성되는 값에 의존하면 쉽게 호환성이 깨진다. 런타임에 invaliadClassException 이 발생한다.
자바 시리얼
Serializable 구현은 버그와 보안 구멍이 생길 위험이 높다.
보이지 않는 생성자 - readObject
역직렬화 시 readObject를 활용한다. 즉 생성자처럼 객체를 생성하므로 보이지 않는 생성자라 불린다.
ByteArrayInputStream bis = new ByteArrayInputStream(byteData);
ObjectInputStream ois = new ObjectInputStream(bis);
InnerClass deserializedInner = (InnerClass) ois.readObject();
역직렬화를 읽어드려 바이트 배열을 임의 수정할 수 있다.
보안 구멍이 생길 수 있다.
예시: 불완전한 객체 생성
import java.io.*;
public class User implements Serializable {
private String username;
private String password;
public User(String username, String password) {
if (username == null || username.isEmpty()) {
throw new IllegalArgumentException("Username cannot be empty");
}
this.username = username;
this.password = password;
}
// Getter, Setter 생략
}
이 클래스는 username 이 비어있으면 예외를 던진다. 하지만, 직렬화 후 역직렬화 과정에서 생성자가 호출되지 않기 때문에 username이 비어있는 상태로 객체가 복원될 수 있다.
package io.member.impl;
import java.io.*;
public class User implements Serializable {
private String username;
private String password;
public User(String username, String password) {
if (username == null || username.isEmpty()) {
throw new IllegalArgumentException("Username cannot be empty");
}
this.username = username;
this.password = password;
}
// Getter & Setter
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
// 역직렬화 시 'username' 필드를 null로 설정하는 readObject 메서드
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject(); // 기본 역직렬화 수행
// username을 null로 설정하여 불완전한 객체 생성
this.username = null;
}
}
// 직렬화된 파일 경로
FileInputStream fileIn = new FileInputStream("user.ser");
ObjectInputStream in = new ObjectInputStream(fileIn);
// 역직렬화
User user2 = (User) in.readObject();
in.close();
fileIn.close();
// 역직렬화된 객체 출력
System.out.println("Username: " + user2.getUsername());
System.out.println("Password: " + user2.getPassword());
serializavle 구현은 해당 클래스의 신버전을 릴리스할 때 테스트할 것이 늘어난다.
직렬화 가능 클래스가 수정되면 시번전 인스턴스를 직렬화한 후 구버전으로 역직렬화할 수 있는지, 그 반대도 가능한지를 검사해야 한다.
상속용 클래스, 인터페이스도 대부분 serialiazalbe을 확장해서는 안된다.
상속용 클래스가 직렬화를 지원하지 않으면 그 하위 클래스에서 직렬화를 지원하려 할 때 부담이 늘어난다.
serialiazalbe을
내부 클래스는 직렬화를 구현하지 말아야 한다.
익명 클래스와 지역 클래스의 이름을 짓는 규칙이 언어 명세에 나와있지 않듯, 이 필드들이 클래스 정의에 어떻게 추가되는지도 정의되지 않았다.
다시 말해 내부 클래스에 대한 기본 직렬화 형태는 분명하지가 않다.
serializable 구현은 아주 신중히 이뤄줘야 한다. 한 클래스의 여러 버전이 상호작용할 일이 없고 서버가 신뢰할 수 없는 데이터에 노출될 가능성이 없는 등 보호된 환경에서만 이뤄져야 한다.
내부 클래스는 직렬화를 구현하지 말아야 한다.
익명 클래스와 지역 클래스의 이름을 짓는 규칙이 언어 명세에 나와있지 않듯, 이 필드들이 클래스 정의에 어떻게 추가되는지도 정의되지 않았다.
다시 말해 내부 클래스에 대한 기본 직렬화 형태는 분명하지가 않다.
내부 클래스는 직렬화를 구현하지 말라는 이유는 무엇일까?
모든 네트워크 통신은 다 이진수만 가지고 통신을 하는데 직렬화는 어떻게 변환이 되어
결국 애플리케이션 레이어까지 올라오면 어떻게 조작할 수 있는 객체 형태로 메모리에 로드될까?
HTTP1과 HTTP1.1. 은 문자열 기반으로 규칙이 정의된 텍스트 프로토콜이다.
이진 프로토콜과 바이너리 프로토콜도 있다. HTTP 후부터는 바이너리 프로토콜이다.
바이너리 프로토콜은 말 그대로 이진수 기반 규약이다.
예를 들어 1111 0002 이 있을 때 처음부터 3bit 까지는 이름 4-10bit 까지는 주소 나머지는 예비 영역이다 이런 식으로 정해놓는 것이다.
통신 규약 통신 프로토콜이 이런 식으로 정의되는데 직렬화도 사실 프로토콜이다.
메모리에 있는 객체를 어떠한 규칙에 따라서 바이너리로 만들고 어떤 규칙을 따라서 역직렬화할 건지 종우가아더,
큰 바이너리에서 여기서부터 이 부분은 클래스 이름, 여기서부터 이 부분은 클래스 메타데이터, 여기까지는 필드 메타데이터 이런 식으로 룰에 따라서 바이너리 각 부분을 쪼개어 객체화를 하는 것이다.
즉 내부 프로토콜은 어떤 프로토콜에 따라서 역직렬화해야 될지가 명확하지 않다는 뜻이다.
'책 > Effective Java 3E' 카테고리의 다른 글
이펙티브 자바 - 제네릭 (0) | 2025.01.16 |
---|---|
[2장 객체 생성과 파괴] 아이템2. 생성자에 매개변수가 많다면 빌더를 고려하라 (1) | 2024.09.27 |
[2장 객체 생성과 파괴] 아이템1. 생성자 대신 정적 펙터리 메서드를 고려하라. (0) | 2024.09.23 |