Java에서 직렬화(Serialization) 는 객체를 저장하거나 네트워크를 통해 전송할 수 있도록 바이트 스트림으로 변환하는 과정이다. 반대로, 역직렬화(Deserialization) 는 바이트 스트림을 다시 객체로 변환하는 과정이다. 직렬화는 파일 저장, 캐시 시스템, 분산 시스템 등에서 널리 사용되지만, 보안 문제도 동반하기 때문에 주의가 필요하다.
이번 포스팅에서는 직렬화의 개념, 사용 방법, 역직렬화 취약점 및 안전한 직렬화 방식을 다뤄보자.
직렬화는 객체의 상태를 유지한 채 저장하거나 전송할 수 있도록 변환하는 과정이다.
Java에서는 Serializable
인터페이스를 구현하여 직렬화를 수행할 수 있다.
import java.io.*;
class Person implements Serializable {
private static final long serialVersionUID = 1L; // 직렬화 버전 ID
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + "}";
}
}
public class SerializationExample {
public static void main(String[] args) {
Person person = new Person("Alice", 25);
// 객체 직렬화 (파일 저장)
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("person.ser"))) {
oos.writeObject(person);
System.out.println("객체가 직렬화되어 저장됨.");
} catch (IOException e) {
e.printStackTrace();
}
// 객체 역직렬화 (파일 읽기)
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("person.ser"))) {
Person deserializedPerson = (Person) ois.readObject();
System.out.println("역직렬화된 객체: " + deserializedPerson);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
}
객체가 직렬화되어 저장됨.
역직렬화된 객체: Person{name='Alice', age=25}
위 코드에서 ObjectOutputStream
을 사용해 객체를 파일(person.ser)에 저장하고, ObjectInputStream
을 사용해 파일에서 객체를 복원했다.
serialVersionUID
는 클래스의 버전 정보를 나타내는 고유한 값으로, 객체가 역직렬화될 때 같은 클래스로 복원되는지 확인하는 역할을 한다.
private static final long serialVersionUID = 1L;
serialVersionUID
가 다르면, 역직렬화 시 InvalidClassException
예외가 발생할 수 있다.
java.io.InvalidClassException: Person; local class incompatible:
stream classdesc serialVersionUID = 1, local class serialVersionUID = 2
이를 방지하려면 클래스 변경 시에도 동일한 serialVersionUID
를 유지하는 것이 좋다.
어떤 필드는 직렬화하지 않고 싶다면, transient
키워드를 사용하면 된다.
class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private transient int age; // 직렬화 제외
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + "}";
}
}
객체가 직렬화되어 저장됨.
역직렬화된 객체: Person{name='Alice', age=0}
age
필드는 transient
키워드로 인해 직렬화되지 않음 → 기본값 0
으로 설정됨.Java 직렬화는 보안 취약점을 가지고 있어, 악의적인 직렬화 데이터를 역직렬화하면 임의의 객체를 생성하거나, JVM에서 위험한 동작을 실행할 수 있다.
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("malicious.ser"));
Object obj = ois.readObject(); // 악성 객체가 실행될 위험
이런 문제를 방지하려면:
역직렬화할 때는 생성자가 실행되지 않으며, 필드 값만 복원된다.
class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
public Person(String name) {
this.name = name;
System.out.println("생성자 호출됨");
}
}
역직렬화하면 "생성자 호출됨"
이 출력되지 않는다.
역직렬화 시 readObject()
를 오버라이딩하여 유효성 검사를 수행하면 보안 취약점을 방지할 수 있다.
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject();
if (age < 0) {
throw new InvalidObjectException("나이는 0 이상이어야 합니다.");
}
}
객체 직렬화에는 JSON 변환 방식이 더 안전하다. Java의 기본 직렬화보다 범용성이 높고, 보안 리스크가 적다.
Jackson을 활용한 JSON 직렬화
import com.fasterxml.jackson.databind.ObjectMapper;
public class JsonSerializationExample {
public static void main(String[] args) throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
Person person = new Person("Alice", 25);
// JSON 직렬화
String jsonString = objectMapper.writeValueAsString(person);
System.out.println("JSON: " + jsonString);
// JSON 역직렬화
Person deserializedPerson = objectMapper.readValue(jsonString, Person.class);
System.out.println("역직렬화된 객체: " + deserializedPerson);
}
}
Externalizable
인터페이스를 구현하면, 어떤 필드를 어떻게 직렬화할지 직접 정의할 수 있다.
import java.io.*;
class CustomPerson implements Externalizable {
private String name;
private int age;
public CustomPerson() {} // 기본 생성자 필수
public CustomPerson(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeUTF(name);
out.writeInt(age);
}
@Override
public void readExternal(ObjectInput in) throws IOException {
name = in.readUTF();
age = in.readInt();
}
}
writeExternal()
과 readExternal()
을 구현해야 하며, 직렬화 과정을 직접 제어 가능하다.Serializable
인터페이스를 사용한다.serialVersionUID
를 지정하여 버전 충돌을 방지하고, transient
키워드를 사용해 특정 필드를 제외할 수 있다.