공부해봅시당
[JAVA] 직렬화와 역직렬화 본문
1. 직렬화와 역직렬화란?
직렬화라는 단어부터 알아보자
직렬화라는 단어 자체에서 주는 생소함이 직렬화를 이해하기 어렵게 한다.
따라서 직렬화라는 단어가 어떻게 나오게 되었는지부터 살펴보자.
직렬화는 Serialization이라는 영어를 한국어로 번역한 것이다.
Serialization은 `Serial(연속된 무언가) + ization(~로 만들다)`이다.
그래서 '어떠한 무언가'를 '연속된 무언가'로 바꿔주는 작업이 Serialization이 되는 것이다.
그렇다면 자바의 직렬화, 즉 Serializable에서는 '어떠한 무언가'와 '연속된 무언가'가 무엇일까?
Java에서 사용되는 `Object나 Data`가 `어떠한 무언가`이고,
`바이트 스트림(stream of bytes)`이 `연속된 무언가`이다.
바이트 스트림(stream of bytes)
스트림은 클라이언트나 서버 간에 출발지 목적지로 입출력하기 위한 데이터가 흐르는 통로를 말함
자바는 스트림의 기본 단위를 바이트로 두고 있기 때문에, 데이터베이스로 전송하기 위해 최소 단위인 바이트 스트림으로 변환하여 처리함
직렬화에 대해서 이해했으니, 그 반대의 개념인 역직렬화에 대해서도 편하게 이해할 수 있을 것이다.
역직렬화는 '연속된 무언가'를 'Object나 Data'로 바꿔주는 작업이고,
자바의 역직렬화는 바이트 스트림을 Object나 Data로 변경해 주는 작업이다.
여기서 굳이 자바의 직렬화라고 언급한 이유는 `직렬화`라는 단어 자체가 자바에서만 쓰이는 개념이 아니기 때문이다.
직렬화 해주는 방식에 따라 자바의 직렬화, JSON 직렬화 등 여러 방식의 직렬화가 존재한다.
만약 자바에서 제공하는 Serializable로 직렬화하면, 자바에서 제공하는 직렬화 방식으로 변환하여 바이트 스트림이 만들어진다. 이것은 자바의 직렬화 방식이다.
하지만 자바에서 제공하는 Serializable을 사용하지 않고 JSON으로 변환한 후 바이트 스트림으로 변환하게 되면 JSON 직렬화 방식이 되는 것이다.
위와 같이 직렬화는 자바에서 제공해주는 Serializable 이외에도 여러가지가 있다.
왜 연속된 무언가로 바꾸는 작업이 필요할까?
자바의 직렬화를 다시 정의해보자.
자바의 직렬화는 객체를 바이트 스트림으로 변환하여 파일, 메모리, 또는 네트워크를 통해 다른 JVM(Java Virtual Machine)으로 전송할 수 있게 하는 과정이다.
즉, Java에서 사용되는 Object나 Data를 다른 컴퓨터의 Java 시스템에서도 사용할 수 있도록 하기 위해서 직렬화 과정이 필요한 것이다.
그러니까 객체의 상태를 영속해야 할 필요가 있을 때, 정보를 전달할 필요가 있을 때 사용하게 된다.
영속
영속은 영어로 persistence로 (없어지지 않고 오랫동안) 지속된다는 의미이다.
즉, 데이터를 생성한 프로그램의 실행이 종료되더라고 사라지지 않는 데이터의 특성을 말한다.
이렇게 변환된 바이트 스트림은 다른 JVM에서 역직렬화를 통해 원래의 객체 상태로 복원될 수 있다.
그래서 정리하면
- 자바 직렬화란 자바 시스템 내부에서 사용되는 객체 또는 데이터를 외부의 자바 시스템에서도 사용할 수 있도록 바이트(byte) 형태로 데이터 변환하는 기술과 바이트로 변환된 데이터를 다시 객체로 변환하는 기술(역직렬화)을 아울러서 이야기한다.
- 시스템적으로 이야기하자면 JVM(Java Virtual Machine 이하 JVM)의 메모리에 상주(힙 또는 스택)되어 있는 객체 데이터를 바이트 형태로 변환하는 기술과 직렬화된 바이트 형태의 데이터를 객체로 변환해서 JVM으로 상주시키는 형태를 같이 이야기한다.
2. 자바의 직렬화를 코드로 살펴보자
java.io.Serializable
자바 직렬화 대상에 사용
자바 직렬화를 하기 위해서는 데이터 type이 기본형 타입(primitive type)이거나 `java.io.Serializable` 인터페이스를 상속 받아야 한다.
Serializable 의 선언부를 보면 다음과 같이 비어 있는 것을 볼 수 있는데,
다른 특별한 기능 없이 Serializable 인터페이스를 구현한 객체는 직렬화 가능하다는 걸 알 수 있는 용도로만 사용된다.
package java.io;
public interface Serializable {}
직렬화 테스트를 위해 Serializable 인터페이스를 구현한 User 클래스를 선언해보자.
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User implements Serializable {
private String name;
private int age;
private String email;
@Override
public String toString() {
return String.format("User name: %s, age: %s, email: %s", name, age, email);
}
}
만약에 Serializable 인터페이스를 사용하지 않으면 `NotSerializableException`이 발생한다고 한다.
java.io.ByteArrayOutputStream, java.io.ObjectOutputStream
직렬화 시 사용
직렬화를 하기 위해서는 `java.io.ByteArrayOutputStream, java.io.ObjectOutputStream`을 이용하면 된다.
package net.happykoo.test;
@Slf4j
public class SerializeTest {
@Test
@DisplayName("Serialize Test")
public void SerializeTest() {
User user = User.builder()
.name("Happykoo")
.age(30)
.email("rudals4549@gmail.com")
.build();
String serializedUserBase64;
try(ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
try(ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(user);
//직렬화(byte array)
byte[] serializedUser = baos.toByteArray();
//byte array를 base64로 변환
serializedUserBase64 = Base64.getEncoder().encodeToString(serializedUser);
log.debug("serializedUserBase64 >> {}", serializedUserBase64);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
//결과 serializedUserBase64 >> rO0ABXNyABduZXQuaGFwcHlrb28ubW9kZWwuVXNlcqLhsk0TYPUEAgADSQADYWdlTAAFZW1haWx0ABJMamF2YS9sYW5nL1N0cmluZztMAARuYW1lcQB+AAF4cAAAAB50ABRydWRhbHM0NTQ5QGdtYWlsLmNvbXQACEhhcHB5a29
java.io.ByteArrayInputStream, java.io.ObjectInputStream
역직렬화 시 사용
이번에는 위에서 직렬화하여 Base64 값으로 변환한 값을 다시 원래 객체로 역직렬화 해보자.
역직렬화 시에는 `java.io.ByteArrayInputStream, java.io.ObjectInputStream`을 이용하면 된다.
단, 역직렬화를 하기 위해서는 직렬화 대상이 된 객체의 클래스가 클래스 패스에 존재해야 하며, import 되어 있어야 한다. (직렬화와 역직렬화를 진행하는 시스템이 서로 다를 수 있음)
@Slf4j
public class SerializeTest {
...
@Test
@DisplayName("Deserialize Test")
public void DeserializeTest() {
String serializedUserBase64 = "rO0ABXNyABduZXQuaGFwcHlrb28ubW9kZWwuVXNlcqLhsk0TYPUEAgADSQADYWdlTAAFZW1haWx0ABJMamF2YS9sYW5nL1N0cmluZztMAARuYW1lcQB+AAF4cAAAAB50ABRydWRhbHM0NTQ5QGdtYWlsLmNvbXQACEhhcHB5a29v";
byte[] serializedUser = Base64.getDecoder().decode(serializedUserBase64);
try(ByteArrayInputStream bais = new ByteArrayInputStream(serializedUser)) {
try(ObjectInputStream ois = new ObjectInputStream(bais)) {
//역직렬화(byte array -> object)
Object objectUser = ois.readObject();
User user = (User) objectUser;
log.debug(user.toString());
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
//결과
User name: Happykoo, age: 30, email: rudals4549@gmail.com
제대로 역직렬화 되었음을 확인할 수 있다.
transient
직렬화에서 제외하고 싶은 대상에 붙이는 키워드
비밀번호와 같이 직렬화하는 대상에서 제외하고 싶은 항목이 있을 수 있다.
이 때, transient 키워드를 이용하면, 그 property 는 직렬화 대상에서 제외된다.
public class User implements Serializable {
private String name;
private int age;
//직렬화에서 제외
private transient String email;
...
}
직렬화 후 역직렬화 하게 되면, 해당 제외된 property는 null 값이 들어가게 된다.
직렬화와 역직렬화 흐름
Object가 writeObject()를 통해 직렬화되고, 어딘가에 보관되었다가
readObject를 통해 다시 역직렬화되어 Object로 재생성되게 된다.
3. 그런데 자바의 직렬화, 역직렬화는 잘 사용하지 않는다고 한다. 왜?
자바의 직렬화는 문제가 많다.
- 보안
- 유지보수성
- 테스트
- 그 외 다수(ex. 싱글톤 문제, 역직렬화 폭탄 등)
위 문제점 중 몇 가지만 살펴보자.
보안 문제 - 보이지 않는 생성자, readObject
역직렬화 시 readObject가 불리게 된다.
readObject는 하나의 생성자와 다를게 없다.
그래서 보이지 않는 생성자라고도 부른다.
아래 예시를 살펴보자.
양수값만 가지는 클래스가 필요해서 PositiveNumber라는 클래스에 value 필드를 선언했다.
양수값만 가지게 하기 위해 생성자에 Validation 작업을 진행한다.
public class PositiveNumber implements Serializable {
public final int value;
public PositiveNumber(final int value) {
this.value = value;
if (this.value < 0) {
throw new RuntimeException();
}
}
}
아래 코드에서는 PositiveNumber의 value 값을 1로 정의하고, PositiveNumber 객체를 생성해 직렬화한 것을 역직렬화하고 있다.
@Test
void serializableTest() throws IOException, ClassNotFoundException {
byte[] serializedObject = getSerializedObject(1);
// getSerializedObject(int value): int value의 값으로 PositiveNumber 객체를 생성해 직렬화하는 메서드 따로 정의함
try(ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(serializedObject)) {
try(ObjectInputStream objectInputStream = new ObjectInputStream(byteInputStream)) {
PositiveNumber positiveNumber = (PositiveNumber) objectInputStream.readObject();
assertThat(positiveNumber.value).isEqualTo(-1);
}
}
}
이후 역직렬화한 객체의 value 값이 -1인지 테스트하면 당연히 테스트는 실패하게 된다.
직렬화 이전 1로 정의했기 때문이다.
하지만 아래 코드처럼 직렬화한 이후의 바이트 값을 수정하게 된다면?
@Test
void serializableTest() throws IOException, ClassNotFoundException {
byte[] serializedObject = getSerializedObject(1);
// getSerializedObject(int value): int value의 값으로 PositiveNumber 객체를 생성해 직렬화하는 메서드 따로 정의함
serializedObject[43] = -1;
serializedObject[44] = -1;
serializedObject[45] = -1;
serializedObject[46] = -1;
try(ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(serializedObject)) {
try(ObjectInputStream objectInputStream = new ObjectInputStream(byteInputStream)) {
PositiveNumber positiveNumber = (PositiveNumber) objectInputStream.readObject();
assertThat(positiveNumber.value).isEqualTo(-1);
}
}
}
테스트는 성공하게 된다.
ObjectInputStream의 readObject에 우리가 원하는 validation 로직이 적용되지 않았기 때문에 발생하는 문제이다.
커스텀 직렬화: readObject()로 해결해보기
이러한 문제점을 방어하기 위한 방법 중 하나로 커스텀 직렬화를 사용할 수 있다.
커스텀 직렬화는 커스텀 직렬화 대상 클래스에 readObject 메서드를 새롭게 정의해주면 된다.
public class PositiveNumber implements Serializable {
public final int value;
public PositiveNumber(final int value) {
this.value = value;
checkPositive();
}
private void checkPositive() {
if(this.value < 0) {
throw new RuntimeException();
}
}
private void readObject(ObjectInputStream objectInputStream) throws IOException, ClassNotFoundException {
// 기본적인 역직렬화 먼저 수행
// ObjectInputStream의 readObject와 동일한 작업 수행
objectInputStream.defaultReadObject();
// 양수인지 확인하는 로직
checkPositive();
}
}
위 커스텀 직렬화를 통해 Validation을 수행했기 때문에 다시 에러가 발생하며 Validation이 정상적으로 수행되는 것을 알 수 있다.
보안 문제 - 직렬화 프록시패턴, writeReplace
보이지 않는 생성자 문제를 더 세련된 방식으로 해결해보자
기존에 보이지 않는 생성자 문제인 readObject의 문제를 해결하기 위해 커스텀직렬화를 이용했다.
이렇게 커스텀직렬화를 이용해 객체를 생성함으로써 안정적인 객체 생성이 가능해졌었다.
하지만 같은 validation 로직을 중복해서 작성해야 했기 때문에 개발자가 충분히 실수할 여지가 있다.
아래 코드를 보면, checkPositive() 메서드를 생성자와 readObject()에 모두 포함시켜야 한다.
이런 로직이 1~2개라면 실수하지 않겠지만 그 수가 많아진다면 충분히 실수할 수 있을 것이다.
public class PositiveNumber implements Serializable {
public final int value;
public PositiveNumber(final int value) {
this.value = value;
checkPositive(); // 양수인지 확인하는 로직
}
private void checkPositive() {
if(this.value < 0) {
throw new RuntimeException();
}
}
private void readObject(ObjectInputStream objectInputStream) throws IOException, ClassNotFoundException {
objectInputStream.defaultReadObject();
checkPositive(); // 양수인지 확인하는 로직
}
}
직렬화 Proxy 패턴으로 해결해보기
이 부분을 해결하기 위해 직렬화 프록시 패턴을 이용하는 방법이 있다.
대리 역할을 하는 프록시 객체인 중첩클래스를 사용하는 것이다.
아래 예시코드처럼 PositiveNumber 클래스 내부에 PositiveNumberProxy 클래스를 정의하고, 직렬화와 역직렬화 시 PositiveNumber 클래스 대신에 PositiveNumberProxy 클래스를 사용하는 방법이다.
중첩클래스를 직렬화했기 때문에 역직렬화는 중첩클래스에서 일어나게 된다.
따라서 역직렬화 할 때, 중첩클래스의 readResolve를 사용하도록 해야한다.
우리가 사용하고자 하는 클래스는 중첩클래스가 아닌 외부클래스이므로 외부클래스를 역직렬화 하도록 생성자를 사용해주는 방법이다.
public class PositiveNumber implements Serializable {
public final int value;
public PositiveNumber(final int value) {
this.value = value;
checkPositive(); // 양수인지 확인하는 로직
}
private void checkPositive() {
if(this.value < 0) {
throw new RuntimeException();
}
}
private Object writeReplace() {
return new PositiveNumberProxy(this.value);
}
private static class PositiveNumberProxy implements Serializable {
private final int value;
public PositiveNumberProxy(final int value) {
this.value = value;
}
// 중첩클래스에서 역직렬화를 진행하게 되므로 중첩클래스 내부에서 readResolve() 수행
private Object readResolve() {
return new PositiveNumber(this.value);
}
}
}
readResolve가 역직렬화 과정에 간섭해서 원하는 객체를 역직렬화 대상으로 갈아끼운 것이라면,
writeReplace는 직렬화 과정에 간섭해서 특정 객체를 직렬화 결과로 반환할 수 있다.
이렇게 해주면 readObject에서 2번 사용해야 했던 validation 로직을 외부클래스의 생성자에서만 사용할 수 있게 되면서 관리포인트가 줄어들게 된다.
싱글톤 문제
아래와 같이 싱글톤 패턴으로 클래스를 만들고 직렬화가 가능하도록 Serializable을 붙였다.
public class MySingleton implements Serializable {
private static final MySingleton INSTANCE = new MySingleton();
public static MySingleton getINSTANCE() {
return INSTANCE;
}
private MySingleton() {}
}
기본적으로 readObject를 사용하게 되면 직렬화 할 때의 객체와 역직렬화를 거친 객체는 서로 다른 객체로 생성이 된다.
싱글통 객체를 직렬화 했다가 역직렬화하여 사용한다면 그 싱글톤 객체는 싱글톤의 성격을 잃어버리게 된다는 것이다.
아래 테스트 코드에서는 기존 Singleton 객체와 직렬화와 역직렬화를 거친 Singleton 객체가 같지 않은 객체인지 테스트하고 있다.
따라서 아래 테스트 코드는 성공하면 안되는데 성공하게 된다.
@Test
void singletonTest() throws IOException, ClassNotFoundException {
MySingleton originSingleton = MySingleton.getINSTANCE();
byte[] serializedObject = getSerializedObject(originSingleton);
// getSerializedObject(): MySingleton 객체를 생성해 직렬화하는 메서드 따로 정의함
try(ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(serializedObject)) {
try(ObjectInputStream objectInputStream = new ObjectInputStream(byteInputStream)) {
MySingleton deserializedSingleton = (MySingleton) objectInputStream.readObject();
// 기존 싱글톤 객체와 직렬화, 역직렬화를 거친 객치가 같지 않은 것인지 테스트
assertThat(deserializedSingleton).isNotEqualTo(originSingleton);
}
}
}
readResolve()로 해결해보기
이러한 싱글톤 문제를 해결하기 위해서는 readResolve를 정의해주면 된다.
readObject가 불린 이후에 readResolve가 불리게 되면서 역직렬화하여 생성된 객체는 사용하지 않고 readResolve에서 반환하는 객체를 사용하게 된다.
public class MySingleton implements Serializable {
private static final MySingleton INSTANCE = new MySingleton();
public static MySingleton getINSTANCE() {
return INSTANCE;
}
private MySingleton() {}
private Object readResolve() {
return INSTANCE;
}
}
하지만 이렇게 readResolve()를 사용할 때, 클래스의 인스턴스 변수가 존재하는 경우에는 transient 키워드를 통해 직렬화, 역직렬화 대상이 되지 않도록 해주어야 한다.
그렇지 않으면 참조필드의 영역값을 훔쳐오는 행위가 가능해지기 때문이다.
이를 자세히 알기 위해서는 도둑클래스에 대해 더 알아보아야 한다. 따라서 여기서는 생략하도록 하겠다.
public class MySingleton implements Serializable {
private static final MySingleton INSTANCE = new MySingleton();
private final transient Object serializableObject;
public static MySingleton getINSTANCE() {
return INSTANCE;
}
private MySingleton() {}
}
readResolve() 재정의 방법도 불편해요! -> enum 클래스를 사용해 싱글톤 구현해보자!
모든 인스턴스 변수에 transient 키워드를 붙여주어야 하고, 깜빡할 경우에는 보안 문제까지 발생할 수 있기 때문에 싱글톤과 같이 객체의 갯수를 통제해야 하는 경우에는 readResolve를 이용하는 것이 불편하다고 느껴진다.
따라서 이러한 경우에는 원소를 하나 가지는 열거형 타입인 enum 클래스를 이용해 싱글톤을 구현하면 앞서 언급한 직렬화-역직렬화 이슈들이 모두 해결되는 싱글톤 구현이 가능해진다.
4. 다시 보는 직렬화 - 역직렬화 흐름
위 과정을 통해 다시 정리한 직렬화 - 역직렬화 흐름은 아래 그림과 같다.
맨 오른쪽에 가려져서 안 보이는 부분은 Object이다.
이렇게 사용하기가 위험하고 까다롭기 때문에 자바 직렬화를 잘 사용하지 않게 되는 것이다.
5. 그렇다면 꼭 사용해야 할 때는?
위에서 이야기한 방법들을 제대로 적용하는 방법이 있다.
커스텀 직렬화, 직렬화 프록시, 역직렬화 필터링, 직렬 버전 관리 등등...
위에서 이야기한 방법들 이외에도 다른 방법들이 많다.
하지만 자바 직렬화 이외에도 다른 방법들이 많다.
위에서 설명했던 것처럼 자바 직렬화 뿐만 아니라 `JSON, CSV, XML로의 직렬화도 가능`하다.
특히나 자바 직렬화는 자바 시스템 간의 데이터 교환만 가능하지만 아래 방법들은 모든 시스템에서 가능하다.
따라서 자바 직렬화보다는 아래 방법들이 많이 사용된다.
CSV
CSV는 데이터를 표현하는 가장 많이 사용되는 방법 중 하나로 콤마(,) 기준으로 데이터를 구분하는 방법이다.
실제로, `표 형태의 다량의 데이터를 저장하거나 전송할 때 CSV`를 많이 사용한다.
홍길동,gildong@gmail.com,25
자바에서 사용하는 방법은 아래와 같다.
아래 코드에서는 간단하게 문자열로만 변경했지만
자바에서는 Apache Commons CSV, opencsv 등의 라이브러리를 많이 이용한다.
Member member = new Member("홍길동", "gildong@gmail.com", 25);
// member객체를 csv로 변환
String csv = String.format("%s,%s,%d",member.getName(), member.getEmail(), member.getAge());
System.out.println(csv);
JSON
`네트워크에서 구조적인 데이터를 송수신 할 때(API 시스템) 대부분 JSON` 을 많이 사용한다.
웹개발자로서 주로 JSON 방식을 사용하기 때문에 익숙할 것이다.
{
name: "홍길동",
email: "gildong@gmail.com",
age: 25
}
자바에서 사용하는 방법은 아래와 같다.
마찬가지로 아래 코드에서는 간단하게 문자열로만 변경했지만
JSON 으로 직렬화 시엔 Jackson, GSON 등의 라이브러리를 많이 이용한다.
Member member = new Member("홍길동", "gildong@gmail.com", 25);
// member객체를 json으로 변환
String json = String.format(
"",
member.getName(), member.getEmail(), member.getAge());
System.out.println(json);
참고
☕ 자바 직렬화(Serializable) - 완벽 마스터하기
자바의 직렬화 & 역직렬화 직렬화(serialize)란 자바 언어에서 사용되는 Object 또는 Data를 다른 컴퓨터의 자바 시스템에서도 사용 할수 있도록 바이트 스트림(stream of bytes) 형태로 연속전인(serial) 데
inpa.tistory.com
https://techblog.woowahan.com/2550/
자바 직렬화, 그것이 알고싶다. 훑어보기편 | 우아한형제들 기술블로그
{{item.name}} 자바의 직렬화 기술에 대한 대한 이야기입니다. 간단한 질문과 답변 형태로 자바 직렬화에 대한 간단한 설명과 직접 프로젝트를 진행하면서 겪은 경험에 대해 이야기해보려 합니다.
techblog.woowahan.com
https://www.happykoo.net/@happykoo/posts/257
해피쿠 블로그 - [Java] 직렬화(Serialization)에 대해 알아보자
누구나 손쉽게 운영하는 블로그!
www.happykoo.net
https://www.youtube.com/watch?v=3iypR-1Glm0
'STUDY > JAVA' 카테고리의 다른 글
[JAVA] Generic을 간단하게 알아보자 (1) | 2024.03.06 |
---|---|
[JAVA] 자바의 Reflection (0) | 2024.03.06 |
[JAVA] Java의 Synchronization이란? (0) | 2024.03.06 |
[JAVA] Garbage Collection(가비지 컬렉션)을 톺아보자 (0) | 2024.03.06 |
[쉽게 배우자! JAVA] 컴파일 과정 (0) | 2023.10.31 |