본문 바로가기
비난 스터디

[Spring]직렬화를 하는 이유 - Serializable과 JSON 직렬화

by 정말반가워요 2023. 7. 20.

오늘의 주제는 '직렬화' 입니다.

글의 목차는

  1. 웹브라우저와 자바 서버 간의 직렬화 (JSON)
  2. 자바 프로그램 간의 직렬화 (Serializable)
  3. Serializable 실습 - 세션 클러스터링

이 되겠습니다.

 

글의 제목은 Spring이며, 자바 스프링 부트 환경에서의 이야기가 주가 되지만

사실 직렬화는 어떤 언어나 프레임워크에 종속적인 부분이 아니고,

어떤 언어나 프레임워크가 직렬화를 나름의 방법으로 구현했을 뿐입니다.

 

1. 웹브라우저와 자바 서버 간의 직렬화 (JSON)

직렬화를 왜 쓰는지를 한 마디로 정의하자면,

"서로 다른 프로그램 간에 데이터를 공유하기 위해서" 이다.

 

예시를 들기 위해,

"웹 브라우저에서 자바 백엔드 서버로 댓글작성 요청을 보내는 시나리오" 를 가정해 보자.

 

웹 브라우저가 할 일

  1. 사용자가 입력한 댓글 데이터를 준비한다.
  2. fetch()함수를 실행하여 자바 서버로 댓글 데이터 등록 요청을 보낸다

댓글 데이터는 아래와 같이 자바스크립트의 객체 형태이다.

{
	"writerId": "1",
	"content": "안아줘요"
}

자바 서버가 할 일

  1. 요청을 받아서 댓글 데이터를 자바 객체로 변환한다.
  2. 변환한 자바 객체를 데이터베이스에 저장한다.

 

어찌 보면 간단해 보이는 요구사항이지만, 해결해야 할 문제가 남아 있다.

요청값인 자바스크립트 객체를 어떻게 자바 객체로 만들 것인가 하는 문제이다.

 

JS는 자바의 데이터 체계를 모르고,

자바 또한 JS의 데이터 체계를 모른다.

JS 데이터가 단순히 문자로 표현 가능한 타입이라면 몰라도, 

위의 요구사항에서의 JS 데이터는 객체이다. 즉 주소값을 가진 참조형 데이터라는 것이다.

이는 자바 프로그램이 JS 데이터를 읽기 더욱 힘들게 만든다.

 

이렇게 서로 다른 프로그램 간에 데이터를 주고받으려면, 서로 합의하에 "표준화된 데이터 규격"을 바라봐야 한다.

그 표준화된 데이터 규격 중 하나가 우리가 많이 쓰는 JSON이다.

 

(JSON은 자바스크립트 Object 타입의 형식을 따라 만든 데이터 포맷이다.

JSON과 자바스크립트의 Object는 다르다. JSON은 데이터 포맷이고, 문자열 형태이다. JS뿐만 아니라 어떤 환경에서든 사용 가능하다.
JS Object는 자바스크립트의 참조형 데이터이며, JS 프로그램 안에서만 가치가 있다.)

 

그래서 웹브라우저와 자바 서버가 해야 할 일을 좀 더 자세히 설명하자면,

 

웹 브라우저가 할 일 v2

  1. 사용자가 입력한 댓글 데이터를 준비한다.
  2. 댓글 데이터 객체를 JSON 타입의 문자열 데이터 포맷으로 변경한다. (직렬화)
    (참조형 데이터는 주소를 담고 있으므로 그 웹브라우저에서만 해석 가능하며, 가치가 있다.
    다른 프로그램으로 해당 데이터를 보내려면 그 주소가 가리키는 데이터를 문자열로 만들어서 보내야 한다.)

    독자는 아래와 같은 JS 코드를 본 적이 있을 수도 있다.
    const obj = {};
    const serializedString = JSON.stringify(obj); // 직렬화. JS 객체를 JSON 문자열로 변환한다.
    // 요청 보내기

    // 응답을 받은 직후
    response.json() // 역직렬화. Http Response Body의 JSON문자열을 JS 객체로 변환한다.
  3. fetch() 함수를 실행하여 자바 서버로 JSON 문자열을 보낸다.

자바 서버가 할 일 v2


1. 요청을 받아서 댓글 데이터를 자바 객체로 변환한다. (역직렬화)
(내 데이터를 네가 읽기 편하게 포맷팅할게 - 직렬화
네가 준 데이터 내 방식에 맞게 해석할게 - 역직렬화)

근데 여기서 어떻게 변환해야 할까?

 

{
	"writerId": "1",
	"content": "안아줘요"
}

 

도착한 데이터는 위와 같다.
데이터의 시작과 끝은 중괄호이며,

key와 value는 따옴표로 묶어져 있고,

그 사이는 콜론으로 구분되어 있으며,

그다음 key-value 쌍은 comma로 구분된다.

그렇다는 것은 뭐냐? 문자열 데이터의 규격이 일정해 해석이 가능해진다는 것이다.

 

물론 해석이 '가능'하다는 것이지, 타입 문제도 있고 아주 간단히 구현하기는 힘들다.

그래서 우리는 직렬화 라이브러리를 사용한다.

spring 사용자라면 Jackson이라는 직렬화/역직렬화 라이브러리를 사용한 적이 있을 것이다.

Gson과 같은 라이브러리도 종종 사용되는 것으로 보인다.

 

직렬화 라이브러리 사용 예시는 다음과 같다.

소스코드 로직을 먼저 설명하자면

1. Human 클래스 객체를 생성한다.
2. JSON 포맷의 문자열로 만든다.
3. JSON 데이터를 토대로 다시 새로운 객체를 만든다.

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

public class Test {
    public static void main(String[] args) throws JsonProcessingException {
        Human human = new Human("name", 15);
        ObjectMapper objectMapper = new ObjectMapper();

	// 참조형 데이터 human을 통신하기에 알맞게 JSON 문자열로 바꾼다.
        String serializedString = objectMapper.writeValueAsString(human); 
	// 출력: serializedString = {"name":"name","age":15}
        System.out.println("serializedString = " + serializedString);
        
	// JSON 문자열을 이용해서 새 객체를 생성한다. name 필드에는 "name"이, age 필드에는 15가 들어있다는 것을 해석하고 객체를 생성한다.
        Human human2 = objectMapper.readValue(serializedString, Human.class);
	// 출력: human2 = Human(name=name, age=15)
        System.out.println("human2 = " + human2);
    }
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
class Human{
    private String name;
    private int age;
}

위의 예시는 실제로 요청을 받는 예시는 아니고,

직렬화와 역직렬화를 이해하기 위한 자바 코드일 뿐이다.

스프링 프레임워크에서는 위의 예시보다 조금 더 간단하게 할 수 있다.

 

@RequestBody 애노테이션을 붙이는 것으로 역직렬화를 자동으로 할 수 있고,

@ResponseBody 애노테이션이 붙은 메서드의 반환값은 JSON으로 직렬화되어

자바 객체를 쉽게 직렬화할 수 있다.

프레임워크 단에서의 사용예시는 본 포스팅의 주제가 아니므로 생략했다.

 

2. 자바 프로그램 간의 직렬화 (Serializable)

여러분 미안해요!

사실 1번 "웹브라우저와 자바 서버 간의 직렬화 (JSON)" 설명의 일부는 수정이 필요하다.

JS는 자바의 데이터 체계를 모르고,
자바 또한 JS의 데이터 체계를 모른다.

위 문장은 사실 핵심을 놓친 설명이다.

JS냐 JAVA냐는 중요하지 않다.

 

참조형 데이터가 가진 주소값은 해당 프로그램에서만 해석할 수 있기에,

"서로 다른 프로그램 간에는 참조형 데이터를 바로 공유할 수 없기에 직렬화를 한다"가 핵심이지,

"그 프로그램이 JS냐 JAVA냐"는 크게 관계가 없다.

무슨 말이냐면, 자바 프로그램 두 대가 서로 클라이언트와 서버의 역할을 한다고 해도

데이터의 공유를 위해 직렬화와 역직렬화가 필요하다는 것이다.

 

참조형 데이터의 실체는 힙 메모리에 저장되며, 우리는 주소값으로 해당 메모리를 참조하게 되는데

A 자바 프로그램에서 객체를 생성해놓고 그 주소값을

B 자바 프로그램에 넘긴다 한들, 주소값을 받아든 B 자바 프로그램은 "이게뭐여" 밖에 할 수가 없다. 

자기는 그런 데이터를 생성한 적이 없기 때문이다.

 

결국 A 자바 프로그램은 자기만 알고 있는 정보(힙 메모리의 정보들)을 끌고 와서,

적당한 포맷으로 가공해서 B 자바 프로그램으로 넘겨야 한다는 것이다.

 

다시 말하자면, 두 개의 자바 프로그램 간에 데이터를 주고받을 때도 직렬화를 해야 한다.

직렬화 방법은 1번의 예시와 같이 JSON을 쓰든지, XML과 같은 다른 데이터 포맷을 쓰든지 해도 되지만

자바 프로그램끼리의 직렬화는 "Serializable 인터페이스 구현"이라는 자바만의 방법을 사용한다.

 

Serializable은 몸통이 비어있는 마커 인터페이스이다.

직렬화를 위해 추상 메서드를 구현할 필요가 없으며,

대상 클래스가 Serializable을 '구현한다는 선언'만 하면 된다(implements Serializable).

 

Serializable 객체의 직렬화 방식은 JSON과 같은 특정 형식을 가진 문자열이 아닌

바이트 스트림을 통해 변환한다고 한다.

내가 잘 아는 분야도 아니고,

"직렬화 왜 하냐"가 포스팅의 중심이므로, 


그런데 Serializable을 찾아보다가 궁금한 것이 생겼다.

"스프링 부트와 AWS로 혼자 구현하는 웹 서비스" 라는 책을 볼 때,

 

HttpSession에 저장할 객체에 Serializable을 구현하게 하는 부분이 있었다.

그 때 당시에는 내용이 어려워서 그냥 넘어갔는데, 

"세션 클러스터링 환경에서는 Serializable을 구현해야 한다..."는 내용을 어디선가 본 적이 있어

 

chatGPT한테 질문을 해 보았다.

 

질문: Spring Boot 개발환경에서 HttpSession에 저장하려는 객체는 반드시 'Serializable' 인터페이스를 구현해야 하나요?

답변:

Spring Boot 개발 환경에서 HttpSession에 저장하려는 객체는 반드시 'Serializable' 인터페이스를 구현할 필요가 없습니다.

그러나 명심해야 할 몇 가지 중요한 고려 사항이 있습니다.

  1. 직렬화할 수 없는 객체: 직렬화할 수 없는 HttpSession에 개체를 저장하는 경우 메모리 내 세션 저장소 또는 직렬화가 필요하지 않은 세션 저장소를 사용할 때(예: 제공된 기본 메모리 내 세션 저장소 사용) 대부분의 경우 여전히 작동할 수 있습니다.
  2. 세션 복제 및 직렬화: HttpSession 데이터가 응용 프로그램의 여러 인스턴스에서 공유되는 분산 세션 저장소 또는 세션 복제(예: 클러스터 환경)를 사용하려는 경우 세션에 저장하는 개체는 직렬화 가능해야 합니다. 이 경우 직렬화할 수 없는 개체로 인해 직렬화 오류가 발생합니다.
  3. 개체 직렬화: HttpSession에 개체를 저장하고 해당 개체에 다른 개체 또는 복잡한 데이터 구조에 대한 참조가 포함되어 있는 경우 참조된 개체도 직렬화 가능해야 합니다. 그렇지 않으면 직렬화 문제가 발생할 수 있습니다.

일반적으로 HttpSession에 저장하려는 개체를 직렬화할 수 있도록 만드는 것이 좋습니다. 특히 분산 세션 저장소를 사용할 것으로 예상하거나 응용 프로그램의 세션 저장 전략에 대한 향후 변경 사항과의 호환성을 보장하려는 경우에 그렇습니다.

 

요약하자면,

1. 단일 서버 내에서 기본적으로 제공하는 메모리 내 세션 저장소를 사용한다면, Serializable을 구현하지 않아도 OK

2. 서버가 여러 대라서 세션을 공유해야 할 때는 Serializable을 구현해야 한다.

3. 그래서 세션에 저장하려면 웬만하면 Serializable을 구현해라.

 

서버가 여러 대라서 세션을 공유해야 할 때는 Serializable을 구현해야 하는 이유는 무엇일까?

위에서 짚었듯이 서로 다른 자바 애플리케이션 간에는 참조형 데이터를 직접 주고받을 수 없어,

별도의 직렬화 전략을 채용해야 하기 때문일 거이다.

 

궁금하니까 한번 세션 공유 환경을 로컬에서 돌려보고, 테스트해보기로 했다.

3. Serializable 실습 - 세션 클러스터링

spring boot 내장 Tomcat 서버를 이용해서

로컬 8080, 9090 포트 두 군데에 서버를 열고 테스트를 해 보았다.

테스트 내용은 다음과 같다.

1. 세션 공유 환경에서 한쪽에서 Serializable을 구현하지 않은 객체를 저장하고,

반대편에서 그 객체를 호출하려고 하면 어떻게 될까?

2. 문제가 발생한다면 Serializable을 구현할 경우 해결이 될까?

 

톰캣 세션 공유 환경은 아래를 참고했다.

https://happy-jjang-a.tistory.com/155

 

SpringBoot Embedded Tomcat 세션 클러스터링

SpringBoot를 내장 톰캣으로 실행하고 만약 세션을 사용한다면 세션 클러스터링 설정이 필요하다. 토큰이나 Redis를 사용하는 경우에는 불필요하겠지만 내장 톰캣의 세션을 그대로 이용한다면 세션

happy-jjang-a.tistory.com

세션 클러스터링 환경 구축에 대한 지식이 없으므로 테스트를 위해 위 소스코드를 복붙해서 환경을 설정했다.


일단 세션 클러스터링 환경설정을 적용한 후,
Serializable을 구현하지 않은 객체를 등록했다.

 

대충 이런 식이다.

@GetMapping("/test")
public String test(HttpServletRequest request) {
    // serializable하지 않은 객체를 생성한다.
    Resident resident = residentRepository.findById(1L).get(); 

    HttpSession session = request.getSession();
    // 세션 등록 
    session.setAttribute("resident", resident);
    return resident.toString() + resident.getName(); // toString()과 name 출력
}

그랬더니 세션 속성에 객체를 등록하자마자 예외가 발생했다.
serialize할 수가 없다는 것이다.

단일 서버 환경에서는 모든 객체를 등록할 수 있었는데, 세션을 공유해야 하는 상황에서는 등록조차 안되는 것이다.

세션 공유 환경에 대한 자세한 원리는 내가 모르는 것이 아쉽지만,

지금은 "프로그램끼리 참조형 데이터를 공유하려면 Serializable해야 한다" 라는 핵심만 알고 있어도 좋을 듯하다.

그러면 serializable을 구현한 클래스를 등록해보자.

Resident클래스가 이제는 serializable을 구현하도록 했다.


String 또한 serializable을 구현했기 때문에, 테스트를 한다면 굳이 직접 만든 클래스가 아닌 String과 같은

내장 클래스를 사용해서 테스트해도 된다.

이제 정상적으로 세션 속성이 등록되었다.

 

8080 포트에서 세션을 등록했으니, 이제 9090 포트에서 세션을 확인해보도록 하겠다.

세션 확인은 아래와 같은 로직을 거친다.

@GetMapping("/test-get")
public String testGet(HttpServletRequest request) {
    HttpSession session = request.getSession();
    Resident resident = (Resident) session.getAttribute("resident"); // Resident 객체 가져오기

    return resident.toString() + resident.getName();
}

 

9090 포트 요청 결과, toString() + getName(); 이 제대로 반환되었다.

 

@5490e7f6이라는 부분이 있다.

Object.toString()을 실행하면 {@ + 16진수 숫자} 식의 문자가 나오는데,

이는 객체의 hashCode 값이다.

 

8080 포트의 앱과 9090 포트의 앱은 서로 다른 hashCode() 실행결과를 가지고 있다.

재정의하지 않은 hashCode()는 메모리 주소를 기반으로 한 값을 반환한다.

이는 각 서버가 Serializable 객체 두 개를 각각 따로 저장하고 있으나, Serializable 하기 때문에

따로 저장된 객체들을 서로 같은 객체로 취급하면서 공유할 수 있지 않았나 하는 추측이 든다.

정확한 사실은 아니고 추측이다.

 

이로써,
"
세션 클러스터링 환경은 여러 대의 서버(즉 여러 개의 자바 프로그램)이

서로 객체를 공유해야 하는 상황이기 때문에

세션에 저장할 객체는 Serializable을 구현해야 하고,

단 한 개의 자바 프로그램이 돌아갈 경우는 세션에 저장할 객체가 반드시

Serializable을 구현하지 않아도 괜찮으나 확장성을 고려한다면 구현하는게 좋은 선택일 수 있다.
"
가 이번 모험의 결론이 되겠다.


후기

나는 기존에 직렬화를 1번 설명과 같이 이해하고 있었다.
서로 다른 체계를 가진 프로그램 간에 데이터를 공유하기 위함이라는 것이다.

그러다가 serializable 인터페이스에 대해서 탐구하던 중, 직렬화의 핵심적인 이유는

'다른 체계의 프로그램(C, Java, JS)'이 아닌,
'(같은 체계더라도)다른 프로그램에서는 주소값만으로 데이터를 알 수가 없어서, 주소값을 보내지 않고

주소값에 해당하는 데이터를 적절한 형태로 가공해서 전달해야 하기 때문'

가 본질임을 알게 되어, 해당 내용을 정리하고자 글을 작성했다.

이 글에 어떤 구현이나 활용에 대한 정보는 별로 없지만,

어떤 작업을 왜 하는지, 함으로써 얻는 이득이 무엇인지 파악한다면 활용하기 훨씬 쉬워질 것이라 생각한다.

이번 기회를 통해 어떤 기술이나 해결법이 존재하는 근본적인 이유에 대해 한걸음 더 다가간 듯하다.

 

연관 글: equals와 hashCode란? 왜 둘을 함께 재정의해야 할까?

https://moolzumdao.tistory.com/entry/Java-equals%EC%99%80-hashCode%EB%9E%80-%EC%99%9C-%EB%91%98%EC%9D%84-%ED%95%A8%EA%BB%98-%EC%9E%AC%EC%A0%95%EC%9D%98%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C

 

Java - equals와 hashCode란? 왜 둘을 함께 재정의해야 할까?

사전지식 - HashTable, 동등성과 동일성 동일성 - Book 객체들의 메모리 주소가 같은가? (즉, 완벽하게 일치하며 메모리상에 하나만 존재하는가?) 동등성 - Book 객체들이 '사실상' 같은가? (여기서 '사

moolzumdao.tistory.com