직렬화와 역직렬화

Daniel_Yang·2024년 6월 7일
0

위 내용은 스프링 개발자로서의 관점에서 작성하였습니다.

직렬화와 역직렬화란?

서로 다른 비즈니스 서비스 간의 통신은 인터페이스를 통해 구현되어야 합니다. 두 서비스 간에 데이터 객체를 공유하려면 객체를 이진 스트림으로 변환한 후 네트워크를 통해 다른 서비스로 전송하고, 다시 객체로 변환하여 서비스 메서드에서 사용할 수 있어야 합니다. 이 인코딩 및 디코딩 과정을 직렬화(Serialization) 및 역직렬화(Deserialization).

📌 직렬화(Serialization)가 사용되는 경우

객체 데이터를 저장하거나 전송할 때 필요

📌 역직렬화(Deserialization)가 사용되는 경우

저장된 객체 데이터를 다시 사용하거나, 전송받은 데이터를 객체로 변환할 때 필요

사용 사례직렬화역직렬화
캐싱객체 데이터를 Redis에 저장Redis에서 불러와 객체로 변환
네트워크 통신객체 → JSON 변환 (API 응답)JSON → 객체 변환 (API 요청 처리)
파일 저장객체 데이터를 파일에 저장파일에서 객체를 불러와 복구

어떤 직렬화를 써야하는가?

자바 직렬화는 x

  • 간단한 구현, 자바 간 통신에 적합, 클래스 정의 포함
  • But 언어 호환성, 보안 취약, 큰 직렬화 stream 크기, 낮은 직렬화 성능
  • 특징
    • 직렬화된 데이터는 클래스 메타데이터와 객체 상태를 포함 -> 출력 크기가 커질 수 있음.
    • JVM(Java Virtual Machine) 환경에 종속적
    • 직렬화된 데이터는 클래스의 구조에 강하게 의존 => 역직렬화 시 데이터 호환성 문제, serialVersionUID로 귀찮게 관리
  • 보안 문제
    • 역직렬화 공격: 신뢰할 수 없는 데이터를 역직렬화할 때 보안 취약점을 노출
    • 캡슐화 침해: private 필드까지 포함...
  • 그래도 써야한다면? 커스텀 직렬화 사용. 그리고 Serializable 을 구현하도록 클래스에 선언하면 직렬화 가능한 클래스가 된다. 데이터 호환성 문제 때문에 SerialVersionUID 명시하는게 좋다.
  • JSON(Text 포맷) 혹은 Protobuf(구글, binary 포맷) 직렬화 추천

자바 커스텀 직렬화

구현 핵심 원칙
1. transient 필드 선언 : 직렬화 대상에서 제외

public class SecureData implements Serializable {
    private transient String secretKey;  // 직렬화 제외
    private String publicData;
}
  1. writeObject/readObject 메서드
private void writeObject(ObjectOutputStream oos) throws IOException {
    oos.defaultWriteObject();
    oos.writeObject(encrypt(publicData));  // 데이터 암호화
}

private void readObject(ObjectInputStream ois) 
    throws ClassNotFoundException, IOException {
    ois.defaultReadObject();
    publicData = decrypt((String) ois.readObject());
}
  1. 버전 관리 : 직렬 버전 UID를 명시적으로 선언하여 버전 관리 및 호환성을 유지
private static final long serialVersionUID = 20230704L;  // 명시적 버전 지정

JSON 장단점

장점

  • 가독성: 사람이 읽을 수 있는 텍스트 형식으로 디버깅과 문제 해결이 용이
  • 언어 독립성: 여러 플랫폼 및 언어 간 데이터 교환에 적합
  • 웹 친화적

단점

  • 텍스트 기반 형식이라 바이너리 형식보다 직렬화 및 역직렬화 속도가 느릴 수 있다.
  • Protobuf와 비교했을 때, 데이터 크기가 더 클 수 있다.

Protobuf (Protocol Buffers)

장점

  • 고효율: 바이너리 형식으로 직렬화되어 JSON보다 3~5배 빠르고 데이터 크기가 작다.
  • 다중 언어 지원: 플랫폼 간 호환성이 뛰어남.
  • 스키마 기반: .proto 파일을 통해 데이터 구조를 정의하고 버전 관리를 지원합니다.
  • 대규모 데이터 처리에 적합: 대량의 데이터를 효율적으로 처리할 수 있습니다.

단점

  • 읽기 어려움: 바이너리 형식이라 사람이 읽기 어렵고 디버깅이 복잡할 수도.
  • 설정 복잡성: .proto 파일 작성 및 컴파일 단계가 필요하며 초기 설정이 복잡할 수도

싱글턴 방법

ReadSolve vs Enum

readResolve란?

  • 직렬화된 객체를 역직렬화할 때 기존 인스턴스를 반환하게 만드는 메서드
  • 보안 취약점과 복잡성이 존재
    • readResolve가 호출되기 전에 필드가 먼저 역직렬화됨
    • 공격자가 필드를 조작하여 싱글턴을 깨트릴 수 있

=> transient 키워드로 민감한 정보나 참조를 직렬화에서 제외 가능

public class Elvis implements Serializable {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() {}

    private Object readResolve() {
        return INSTANCE; // 싱글턴 보장
    }
}

가장 안전한 싱글턴 방법: enum

  • Java가 자체적으로 싱글턴을 관리
  • readSolve가 필요없으므로 직렬화 안전
  • 간결함
public enum Elvis {
    INSTANCE;
    public void sing() {
        System.out.println("🎤 Hound Dog");
    }
}

직렬화 보안 : 직렬화 Proxy 패턴

추천 사례

  • 보안이나 불변 객체가 중요한 도메인 객체

장점

  • 불변성 보장 : final 필드를 유지한 채 직렬화 가능
  • 직렬화 제어: 원하지 않는 필드 포함 방지 가능
  • 보안 강화 : 직접 역직렬화 방지로 공격 차단

단점

  • 클라이언트 확장 불가
  • 속도: 프록시 통해 객체를 생성하고 변환하기 때문에 방어적 복사보다 상대적 느릴 수 있다.
  • 객체 그래프 순환에는 적용 불가
import java.io.*;  

(원래 직렬화할 객체)
class Person implements Serializable {  
	private final String name;  
	private final int age;  
  
	public Person(String name, int age) {  
		this.name = name;  
		this.age = age;  
	}  

	📌 **이게 핵심! 직렬화를 직접 하지 않고, 대신 이 "프록시" 클래스를 이용함.**
	🔹 `Person` 객체를 **그대로 직렬화하면 보안 취약점**이 생길 수도 있음.  
	🔹 대신 **"프록시"**를 사용하면 안전하게 직렬화 가능!  
	🔹 프록시는 `name`, `age` 필드만 가지고 있음. (`Person` 객체와 동일한 데이터)
	// 직렬화 프록시 클래스  
	private static class SerializationProxy implements Serializable {  
		private static final long serialVersionUID = 1L;  
		private final String name;  
		private final int age;  
	  
		SerializationProxy(Person p) {  
			this.name = p.name;  
			this.age = p.age;  
		}  
	  
		// 역직렬화 시 원래 객체로 변환  
		private Object readResolve() {  
			return new Person(name, age);  
			}  
		}  
	  
	// 직렬화할 때 호출되는데, 프록시를 반환하게 하고 있다.  
	// 이로 인해 바깥 클래스의 직렬화된 인스턴스를 생성할 수 없다.  
	private Object writeReplace() {  
		return new SerializationProxy(this);  
	}  
	  
	// 직접 역직렬화 방지  
	private void readObject(ObjectInputStream stream) throws InvalidObjectException {  
		throw new InvalidObjectException("프록시를 사용해야 합니다.");  
	}  
	  
	@Override  
	public String toString() {  
		return "Person{name='" + name + "', age=" + age + "}";  
	}  
}  
  
public class Main {  
	public static void main(String[] args) throws IOException, ClassNotFoundException {  
		Person person = new Person("Alice", 30);  
		  
		// 직렬화  
		ByteArrayOutputStream bos = new ByteArrayOutputStream();  
		ObjectOutputStream out = new ObjectOutputStream(bos);  
		out.writeObject(person);  
		out.close();  
		  
		// 역직렬화  
		ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());  
		ObjectInputStream in = new ObjectInputStream(bis);  
		Person deserializedPerson = (Person) in.readObject();  
		in.close();  
		  
		System.out.println(deserializedPerson);  
	}  
}

단계실행되는 메서드설명
1. 직렬화 시작writeObject(person)Person 객체를 직렬화하려고 함
2. 직렬화 프록시 반환writeReplace()Person 대신 SerializationProxy를 직렬화함
3. 파일 저장-SerializationProxy 데이터가 파일(person.ser)에 저장됨
4. 역직렬화 시작readObject()SerializationProxy 객체가 파일에서 읽힘
5. 원래 객체로 변환readResolve()SerializationProxyPerson 객체로 변환
6. 복원된 객체 반환-최종적으로 Person 객체가 반환됨

문제가 되는 곳

오늘날 대부분의 백엔드 서비스는 마이크로서비스 아키텍처를 기반으로 구현된다. 서비스는 비즈니스 기능에 따라 분리되어 디커플링을 실현하지만, 이로 인해 새로운 과제가 생긴다

동시 요청이 많은 상황에서는 직렬화가 느리면 요청 응답 시간이 길어질 수 있으며, 직렬화된 데이터 크기가 크면 네트워크 처리량이 감소할 수 있다. 따라서 뛰어난 직렬화 프레임워크는 시스템의 전체 성능을 향상할 수 있다.


주의사항

  • serialVersionUID클래스 버전 확인용 출입증
  • readObject 메서드는 방어적으로 작성
    • readObject는 byte stream을 받아 객체를 생성하는 또 다른 생성자 역할(=숨은 생성자)
    • 역직렬화가 생성자를 우회하여 객체를 생성하며 불변식이 깨질 수 있다. 생성자에서 수행되는 초기화나 유효성 검사가 이루어지지않는다.
      => readObject에서 방어적 복사, 유효성 검사 등 진행해야한다.
public class Person implements Serializable {  
  private final String name; // 불변 필드  

  public Person(String name) {  
    this.name = name;  
    // 생성자에서 초기화 및 유효성 검사  
    if (name == null) {  
   	 throw new NullPointerException("Name cannot be null");  
  }  
}  
  
// 역직렬화 시 생성자가 호출되지 않으므로, name이 null일 수 있음  
}  


private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
    s.defaultReadObject(); // 기본 직렬화 수행

    // ✅ 방어적 복사 (기존 객체를 그대로 사용하지 않음)
    start = new Date(start.getTime());
    end = new Date(end.getTime());

    // ✅ 유효성 검사 (이상한 값이 들어오면 예외 발생)
    if (start.compareTo(end) > 0) {
        throw new InvalidObjectException("시작 날짜가 종료 날짜보다 늦음!");
    }
}


기타

Binary 형식

  • 데이터를 컴퓨터가 바로 처리할 수 있는 형태로 저장하여 다음과 같은 이점이 있다.
  1. 작은 데이터 크기: 바이너리는 데이터를 압축된 형태로 표현합니다
  2. 빠른 파싱 속도: 컴퓨터는 바이너리를 직접 읽고 쓸 수 있으므로, 텍스트를 파싱하고 변환하는 과정이 필요 없어서 처리 속도 빠름
  3. 스키마 기반 설계: .proto 파일에서 데이터 구조(스키마)를 미리 정의
    • 필드 tag 사용
    • 데이터 타입 최적화
  4. 불필요한 공백 및 메타데이터 제거: 가독성 불필요
  5. 데이터를 직렬화할 때 고정된 순서와 최적화된 구조를 사용하여 처리 속도를 높인다.

주요 직렬화 라이브러리 비교표 (AI 추천)

라이브러리주요 특징 및 장점주요 단점/제약 사항
Protobuf- 다중 언어 지원
- 고성능/소형 데이터
- 스키마 기반 구조
- gRPC 표준
- 스키마 사전 정의 필수
- 복잡한 구조 변환 시 번거로움
JSON (Jackson)- 인간 친화적 포맷
- 디버깅 용이
- 웹 API 표준
- 언어 독립성
- 바이너리 대비 속도 느림
- 데이터 크기 큼
- 타입 안전성 낮음
Kryo- JVM 내 최고 성능
- 최소 크기 데이터
- 커스텀 직렬화 지원
- Java 전용
- 스키마/버전 관리 미흡
- 비JVM 환경 비호환
Avro- 빅데이터 시스템 표준
- 스키마 진화 지원
- 분산 시스템 최적화
- 다중 언어
- 복잡한 스키마 관리 필요
- 러닝 커브 존재
- 실시간 처리 비효율적

다중 언어 환경 → Protobuf/Avro
JVM 내 고성능 → Kryo
웹 API/간편성 → JSON
스키마 버전 관리 → Protobuf/Avro
대용량 분산 처리 → Avro

Reference

https://devloo.tistory.com/entry/%EC%9E%90%EB%B0%94-%EC%A7%81%EB%A0%AC%ED%99%94-%EC%82%AC%EC%9A%A9%EC%9D%84-%ED%94%BC%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0
Effective Java 도서 12장

https://youtu.be/qrQZOPZmt0w?si=fu3KnpCbA0lfnklQ
https://youtu.be/Qi2LJ_NfzC0?si=3FR5CY1Rgqv9PsDo
https://youtu.be/M0TtOja18M8?si=iLAOR722UXDoYnSa
https://youtu.be/o5rspNdJ-fE?si=jrW9btKjFkxJvdZH
https://youtu.be/0qYA_vTb7Lw?si=kT4wc1yE0bX59z_z

0개의 댓글