Published 2021. 10. 2. 14:49
반응형

✔️ 직렬화를 공부하게 된 계기

현재 스프링부트를 이용하여 토이 프로젝트를 진행 중입니다. 그런데 진행 중 이런 에러가 발생 했습니다.

java.io.InvalidClassException: com.devlogmoa.config.auth.dto.SessionMember; local class incompatible: stream classdesc serialVersionUID = -8498973270536986012, local class serialVersionUID = -1124684850244601429

풀이하자면, 스트림에 직렬화된 클래스의 UID와 나의 로컬에 있는 클래스 UID가 달라서 역직렬화를
할 수가 없다는 내용입니다.
지금까지 직렬화 역직렬화는 많이 들어봤었는데, 딱히 알아야할 필요성을 못느껴 그냥 넘겼습니다.
그러다가 드디더 부딪혔네요..

✔️ 직렬화와 역직렬화란?

🔍 직렬화(Serialization)

객체를 컴퓨터간 또는 네트워크간에 송수신을 하기 위해서는 객체를 데이터 스트림에 담아주어야 합니다.
이는 객체에 저장된 데이터를 스트림에 담기 위해 연속적인(serial) 데이터로 변환하는 것입니다.
쉽게 이야기 하면 배열형식과 비슷한 구조로 기차처럼 직렬적으로 나열 시켜주는 것입니다.

🔍 역직렬활(Deserialization)

스트림에 있는 데이터를 다시 읽어와서 객체로 만드는 것을 의미합니다.

스트림이란(Stream)?

데이터를 편하게 다루기 위한 것
배열이나 컬렉션, 파일 데이터 등을 매번 다른 방식으로 읽으면 매번 코드가 바뀌어야 합니다.
이게 불편하기 때문에 스트림이 존재합니다. 스트림은 모두 같은 방식으로 데이터를 읽을 수 있습니다.
읽기위한 용도인 만큼 오직 읽기만 가능하고 데이터를 변경할 수 없습니다.

✔️ 직렬화와 역직렬화 예제

먼저 순수 자바로 매우 간단하게 코드를 작성해보겠습니다.
UserInfo.java 클래스를 직렬화 클래스로 사용합니다.

🔍 직렬화 클래스 생성

import java.io.Serializable;

/*
직렬화 가능한 클래스 만들기
Serializable은 빈 인터페이스이지만 직렬화 클래스 판단 기준으로 사용된다.
Serializable을 구현한 클래스를 상속 받으면 구현한 클래스를 상속 받으면 당연히 UserInfo는 직렬화가 가능하다.
 */
public class UserInfo implements Serializable {

    private String name;
    private String password;
    private int age;
    private int err; // 이렇게 코드가 변경되면 java.io.InvalidClassException 발생

    public UserInfo() {
        this("Unknown", "1111", 0);
    }

    public UserInfo(String name, String password, int age) {
        this.name = name;
        this.password = password;
        this.age = age;
    }

    @Override
    public String toString() {
        return "UserInfo{" +
                "name='" + name + '\'' +
                ", password='" + password + '\'' +
                ", age=" + age +
                '}';
    }
}
  • 직렬화 및 역직렬화를 위해서는 Serializable 인터페이스를 구현해야 합니다. 하지만 인터페이스는 아무런 내용도
    없습니다. 단순히 직렬화 클래스라고 명시해주는 용도로 사용됩니다.
  • 각 멤버 변수는 직렬화 및 역직렬화할 데이터가 됩니다.

🔍 직렬화 예제

먼저 직접 직렬화를 구현하는 방법을 간단하게 설명하자면
ObjectInputStream 스트림을 사용하며 OutputStream을 상속 받음.
대략 아래와 같은 형식으로 하면 객체가 파일에 직렬화 되어 저장된다.

FileOutputStream fos = new FileOutputStream("objectfile.ser");
ObjectOutputStream out = new ObjectOutputStream(fos);
out.writeObject(new UserInfo());
import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.util.ArrayList;

public class SerialEx1 {

    public static void main(String[] args) {
        try {
            String fileName = "UserInfo.ser";
            FileOutputStream fos = new FileOutputStream(fileName);
            BufferedOutputStream bos = new BufferedOutputStream(fos);

            ObjectOutputStream out = new ObjectOutputStream(bos);

            UserInfo u1 = new UserInfo("javaMan", "1234", 30);
            UserInfo u2 = new UserInfo("javaWoman", "4321", 29);

            ArrayList<UserInfo> list = new ArrayList<>();
            list.add(u1);
            list.add(u2);

            // 객체 직렬화
            out.writeObject(u1);
            out.writeObject(u2);
            out.writeObject(list);
            out.close();
            System.out.println("직렬화 종료");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

크게 어려운 부분은 없습니다. 지정된 Stream 클래스를 사용하여 UserInfo.ser 파일에 직렬화 데이터를
가공합니다. 즉, 객체를 데이터로 변경하는 것입니다. 그러면 UserInfo.ser 파일이 생성되며,
우리가 알아볼 수 없는 모양으로 데이터가 생성이 됩니다. 여기서 UID가 같이 생성되어 저장됩니다.
UID는 뒤에서 알아보도록 하겠습니다.

🔍 역직렬화 예제

먼저 간단하게 역직렬화 구현 방식을 알아보면,
ObjectInputStream 스트림을 사용하여 저장된 데이터를 읽어 객체로 역직렬화합니다.

FileInputStream fis = new FileInputStream("objectfile.ser");
ObjectInputStream in = new ObjectInputStream(fis);
UserInfo info = (UserInfo) in.readObject();
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.ObjectInputStream;
import java.util.ArrayList;

/*
역직렬화가 매우 간단하다.
 */
public class SerialEx2 {

    public static void main(String[] args) {
        try {
            String fileName = "UserInfo.ser";
            FileInputStream fis = new FileInputStream(fileName);
            BufferedInputStream bis = new BufferedInputStream(fis);

            ObjectInputStream in = new ObjectInputStream(bis);

            // 객체를 읽을 때는 출력한 순서와 일치해야한다.
            // 그렇기 때문에 직렬화할 객체가 많을 때는 각 객체를 개별적으로 직렬화 하는 것 보다는 List같은 컬렉션에 저장해서 직렬화하는 것이 간단한다.
            // 그러면 List 하나만 역직렬화하면 되기 때문에 순서를 고려하지 않아도 되기 때문이다.
            UserInfo u1 = (UserInfo) in.readObject();
            UserInfo u2 = (UserInfo) in.readObject();
            ArrayList list = (ArrayList) in.readObject();

            System.out.println(u1);
            System.out.println(u2);
            System.out.println(list);
            in.close();

            System.out.println("역직렬화 종료");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

직렬화와 거의 비슷하지만 스트림 클래스만 변경되었습니다. 하지만 중요한 점이 두 가지 있습니다.

  • 반드시 직렬화했던 순서와 동일하게 역직렬화를 해야합니다. in.readObject()를 보면,
    직렬화했던 순서와 동일하게 작성이 되어있습니다. 다르면 역직렬화를 할 수 없습니다.
  • 그래서 List로 만들어 직렬화를 하면 역직렬화 시 순서를 고려할 필요가 없습니다.

역직렬화가 완료되면 아래와 같이 데이터를 읽어옵니다.

UserInfo{name='javaMan', password='1234', age=30}
UserInfo{name='javaWoman', password='4321', age=29}
[UserInfo{name='javaMan', password='1234', age=30}, UserInfo{name='javaWoman', password='4321', age=29}]
역직렬화 종료

📌 꿀팁
직렬화 클래스 중 제외하고 싶은 멤버 변수가 있다면 transient를 선언 해주면 제외 됩니다.

public class SuperUserInfo {
    String name;
    transient String password; // 직렬화 대상에서 제외.
}

✔️ UID

UID란 직렬화를 할 때 생성되는 고유 키입니다. 따로 지정을 하지 않는다면 랜덤으로 java에서 생성을 해주게 됩니다.
즉, 클래스의 내용이 바뀌면 UID 값도 변경되게 됩니다. 따로 지정을 하지 않는다면 말이죠.
직렬화된 UID와 직렬화 클래스가 변경되어 직렬화 클래스의 UID가 변경되면, 역직렬화는 불가능하게 되며,
아래와 같은 예외를 던집니다.

java.io.InvalidClassException: UserInfo; local class incompatible: stream classdesc serialVersionUID = 3281374983181807138, local class serialVersionUID = -5473453457323967816

여기서 중요한건 클래스 정보가 변경되면이라는건 오직 인스턴스 변수가 변경되는 것을 의미합니다. 즉, 메서드는 포함되지 않는 것입니다.
왜냐하면, 직렬화를 하기 위해 필요한건 메서드의 정보가 아닌 실제로 직렬화된 값입니다.
역직렬화를 할 때도 직렬화된 데이터를 그대로 인스턴스 변수에 매핑하는 것 뿐이구요.
그렇기 때문에 인스턴스 변수의 변경에만 해당되는 것입니다.

🔍 예제

기존 직렬화된 클래스 정보가 아래와 같고,

public class UserInfo implements Serializable {

    private String name;
    private String password;
    private int age;

역직렬화 할 때 클래스 정보가 변경이 된다면?

public class UserInfo implements Serializable {

    private String name;
    private String password;
    private int age;
    private String err; -- 변경됨.

이런 상황일 때 위와 같은 예외를 발생시켜 역직렬화가 불가능하게 됩니다.

✔️ 토이프로젝트에서 부딪힌 상세 내용

대략 문제가 되었던 환경 및 코드를 설명하겠습니다.

저는 구글 로그인을 하는데 로그인을 할 때 세션에 로그인 정보 중 회원명, 회원메일만 필요한 상황이었습니다.
(그리고 JPA를 사용하고 있습니다). 그래서 회원명, 회원 메일을 HttpSession에 저장하기 위해
SessionMember를 직렬화 시켰습니다. 세션에 객체를 담기 위해서는 직렬화가 필요하다고 하더라구요. 사실 Member라는 회원 엔티티가 있는데 세션전용으로 SessionMember를 만들었습니다.(그 이유는 아래에서 설명)그리고 Session 정보는 스프링시큐리티를 이용해 스프링세션 디비에 저장합니다.
그런데 메일수신여부라는 추가 기능이 필요하여 SessionMember를 변경하게 되었습니다.
여기서 UID가 다르다는 예외가 발생했습니다. 이제 이 이유를 찾으러 출발해봅시다.

📌 세션에 객체 저장 시 직렬화를 하지 않으면??

아래와 같이 직렬화가 필요하다고 예외를 발생 시킵니다.
여러 내용 중 두 가지 에러만 추출 했습니다.

java.lang.IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type
[com.devlogmoa.config.auth.dto.SessionMember]

org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.Object] to type [byte[]] for value 'com.devlogmoa.config.auth.dto.SessionMember@2e986ae8

이제 코드를 살펴보겠습니다.

📌 Member 엔티티를 직접 직렬화 하지 않는 이유는?

엔티티는 다른 엔티티와 관계 변경 및 코드 변경이 자주 일어나는 클래스입니다.
반면 세션 전용으로 SessionMember.java와 같이 Dto를 만들면 변경될 일이 없으므로 UID에 대한 이슈를 방지할 수 있습니다.
Member.java

@Getter
@NoArgsConstructor
@Entity
public class Member extends BaseTimeEntity {
...
}

SessionMember.java

package com.devlogmoa.config.auth.dto;

import com.devlogmoa.domain.member.MailReceiptStatus;
import com.devlogmoa.domain.member.Member;
import com.devlogmoa.domain.member.Role;
import lombok.Getter;

import java.io.Serializable;

@Getter
public class SessionMember implements Serializable {

    private final String name;
    private final String email;
    private final Role role;

    public SessionMember(Member member) {
        this.name = member.getName();
        this.email = member.getEmail();
        this.role = member.getRole();
    }
}

세션 전용 Dto 객체로 사용합니다.

👆🏻 문제 상황 1

이제 메일수신여부 기능 개발을 위해 SessionMember를 변경하게 되었습니다. 아래와 같이 변경했습니다.
그랬더니 UID가 다르다는 예외를 발생시켰습니다.

    private final String name;
    private final String email;
    private final Role role;
    private final MailReceiptStatus mailReceiptStatus;

    public SessionMember(Member member) {
        this.name = member.getName();
        this.email = member.getEmail();
        this.role = member.getRole();
        this.mailReceiptStatus = member.getMailReceiptStatus();
    }

    public void updateMailReceiptStatus(Member findMember) {
        this.mailReceiptStatus = findMember.getMailReceiptStatus();
    }

해결 방법

UID를 설정하지 않으면 자동으로 클래스가 변경될 때 마다 생성하게 되는데 이를 클래스 변수로 고정시켜,
직렬화 클래스가 변경되어도 UID는 변하지 않도록 했습니다.
아래 코드를 추가하였습니다.
그러면 해당 클래스는 항상 UID가 같기 때문에 해결할 수 있습니다.

private static final long serialVersionUID = 5461978348429709517L;

✌️ 문제 상황 2

혹시나 해서 초기 코드로 원복 시켜서 해봤는데 에러가 발생하지 않았습니다.
아무리 하려고 다시 해봐도 에러가 발생하지 않았습니다.
즉, 문제 상황1의 방법으로 해결했다고 생각했는데, 원인이 저게 아닐 수도 있겠다. 라고 생각이 듭니다.

해결 방법

직렬화와 역직렬화를 하기위해 필요한건 인스턴슷 변수 이외에는 필요하지 않습니다.
메서드가 변경된다고 해도 상관없이 UID는 동일하게 생성됩니다.
이유는 위에 UID에서 참고 해주세요.
그런데 저는 클래스의 내용이 바뀌면 UID가 변경되는 것으로 착각했었습니다.
위에 java로 테스트하는 부분을 보면 인스턴스 변수로 테스트를 하긴 했지만 어쩌다가 우연히
인스턴스 변수로 테스트를 했고, 메서드를 변경하지는 않았습니다. 그래서 원인 파악을 하지 못했던 것입니다.

반응형
복사했습니다!