스프링을 사용해서 개발을 진행하는 과정에서 객체를 반환했을 뿐인데 JSON으로 자동으로 변환되는 것을 경험하였다. 이를 자바의 기능인 직렬화라고 주워들었는데, 직렬화가 정확히 어떤 것인지 그리고 스프링에서는 어떻게 사용되는지 궁금하여 글을 작성한다.
🧐 직렬화와 역직렬화란?
- 직렬화 (Serialization)은 프로그램 내부에서 사용하는 객체나 데이터를 다른 프로그램에 전달하여 사용할 수 있도록 데이터의 형태를 바이트 (Byte) 형태로 변환하는 것을 의미한다.
- 역직렬화 (Deserialization)은 마찬가지로 바이트 형태로 받은 정보를 프로그램 내에서 다루는 것이 가능한 객체 형태로 다시 변환하는 것을 의미한다.
즉, 직렬화라는 개념은 JSON에 한정되는 것이 아닌 CSV 혹은 XML 등의 문자열 형태로 직렬화할 수도 있다. 중요한 것은 객체를 다른 프로세스로 전송 가능한 형태로 변환한다는 것이다.
🧐 왜 객체 형태는 그대로 전송할 수 없을까?
데이터가 메모리에 저장되는 구조에 대해서 먼저 알아보자.
- 값 형식 (Primitive Type) : int, long, double, float, boolean 등의 원시 타입으로 정의된 데이터이다. 실제 데이터 값을 저장한다.
- 참조 형식 (Reference Type) : 객체를 참조하는 타입으로 메모리 번지 값을 통해 객체를 참조하는 타입이다. Integer, Long, Double 등이나 우리가 생성한 클래스 타입이 포함된다.
스택 메모리에는 값 형식 데이터나 힙 영역에 있는 객체의 주소를 가리키는 참조 형식의 데이터가 저장된다.
힙 메모리에는 메모리가 할당되고 실제 객체 데이터가 저장된다. 그리고 스택 메모리의 참조 형식이 이 힙 메모리의 주소를 참조하고 있다.
우리가 객체를 네트워크 상으로 송수신하고 싶을 때 객체를 직렬화하지 않으면 객체의 참조 형식을 그대로 가져오게 된다. A라는 서버가 객체의 고유한 데이터가 아닌 주솟값을 B라는 서버에 전송하였다면, B 서버는 주솟값으로 A 서버에 있는 힙 영역 메모리 데이터를 불러올 수 있을까? 불가능하다. B 서버는 A 서버의 힙 영역에 임의로 접근할 수 없기 때문이다.
이렇기 때문에 객체를 그대로 내보내는 것이 불가능하다는 것이고, 직렬화를 사용하는 이유가 된다.
🙋♀️ 스프링에서 객체를 보내면 자동으로 직렬화가 되는 이유는?
@PostMapping
public ResponseEntity<ReservationResponse> saveReservation(@RequestBody ReservationRequest reservationRequest) {
ReservationResponse reservationResponse = reservationService.save(reservationRequest);
return ResponseEntity.created(URI.create("/reservations/" + reservationResponse.id()))
.body(reservationResponse);
}
예약 객체를 요청 받아 저장하고 DTO로 저장한 결괏값에 대한 응답을 보내는 간단한 컨트롤러 코드이다.
클라이언트에서 해당 API를 호출하면 아래와 같이 자동으로 JSON 결괏값으로 반환되는 것을 확인할 수 있다.
어떻게 이게 가능한걸까?
implementation 'org.springframework.boot:spring-boot-starter-web'
위 의존성을 build.gradle에 추가하면 Jackson 라이브러리를 함께 가져온다.
스프링은 Jackson 라이브러리 안에 있는 ObjectMapper를 사용해 자바 객체를 JSON으로 직렬화, 혹은 JSON을 자바 객체로 역직렬화한다.
실제로는 Jackson2HttpMessageConverter가 ObjectMapper를 가지고 컨버팅하는 작업을 수행한다.
이번에도 중단점을 잡고 객체의 응답이 나가는 과정을 디버깅해보았다.
HttpMessgaeConverter를 상속한 AbstractJackson2HttpMessageConverter가 내부적으로 ObjectMapper를 세팅하고 변환 작업을 수행하는 것을 확인할 수 있었다.
📝 응답/요청 객체를 어떻게 만들어야 자동 직렬화/역직렬화가 가능할까?
이처럼 Jackson 라이브러리를 사용해 Json으로 클라이언트에게 객체를 직렬화해서 보내주고 싶다면 응답 객체도 변환 가능하게 만들어주어야 한다. 반대로 클라이언트가 보낸 Json을 객체로 역직렬화하기 위해서 요청 객체도 변환 가능하게 만들어주어야 한다.
변환 가능한 응답 객체와 요청 객체의 조건이 뭘까?
☝ 직렬화를 위해 Getter 메서드를 정의하자
응답 객체에서 getter를 제외해보자.
public class ReservationResponse {
private long id;
private String name;
private LocalDate date;
private LocalTime time;
public ReservationResponse(final long id, final String name, final LocalDate date, final LocalTime time) {
this.id = id;
this.name = name;
this.date = date;
this.time = time;
}
}
.
이를 반환하는 API를 호출하면 아래와 같이 406 에러가 뜬다.
406 Not Accpetable은 클라이언트가 전달 받을 수 있는 응답 형식에 맞는 응답을 서버에서 제공해주지 않아서 발생하는 에러이다. 위에서 언급한 JSON 변환 과정에서 객체의 값을 꺼내올 때 Getter 메서드를 사용하는데, 해당 메서드가 없으면 데이터를 꺼내올 수 없고 변환도 이루어지지 않는다.
따라서 Json으로 객체를 반환하고자 할때는 객체에 Getter를 쓰는 것이 필수다.
✌ 역직렬화를 위해 기본 생성자를 정의하자
그럼 반대로 요청을 받아서 Json을 Object로 변환하는 역직렬화 상황을 생각해보자. Json에서 객체로 변환할 때 어떤 생성자를 사용해서 객체를 생성할 수 있을까?
실험을 위해서 ReservationRequest DTO를 아래와 같이 생성하고 POST 요청을 보내보았다.
public class ReservationRequest {
private String name;
private LocalDate date;
private long timeId;
public ReservationRequest(final String name, final LocalDate date, final long timeId) {
this.name = name;
this.date = date;
this.timeId = timeId;
}
}
ReservationRequest의 인스턴스를 생성할 수 없다는 메시지와 함께 POST 요청이 실패된다.
Jackson 라이브러리가 기본 생성자로 객체를 생성하고 클래스의 필드 정보를 Reflection으로 가져와서 필드 값을 할당하기 때문이다. Java Reflection을 사용하면 Setter 메서드가 없이도 객체의 필드에 접근하거나 값을 수정할 수 있기 때문이다.
🤔 저는 기본생성자 없이도 역직렬화가 잘 되는데요?
크루들이 개발하면서 알아낸 신기한 사실이 있다.
Settings > Build, Execution, Deployment > Build Tools > Gradle으로 접근해서 아래와 같이 빌드 환경을 바꿔주면 기본 생성자 없이 역직렬화가 잘 되는 현상이 있었다.
어떻게 이게 가능할 수 있을까?
정답은 Jackson 라이브러리 중 jackson-module-parameter-names가 기본 생성자 없이 역직렬화가 가능하게 한다는 것이다.
저 모듈이 파라미터가 있는 생성자를 찾아 @JsonCreator 어노테이션으로 값을 바인딩해주는 것이다. 하지만 이 경우, 파라미터가 한 개 있는 요청 객체라면 여전히 기본 생성자를 명시해주어야 역직렬화가 잘 동작된다.
Gradle Build와 IntelliJ IDEA Build의 차이점은 증분 빌드에 있다. Gradle과 달리 IntelliJ Build는 변경되지 않은 부분에 대해 건너뛰고 빌드를 실행한다. 즉 Gradle 빌드를 통해야 정의된 라이브러리와 종속된 라이브러리를 모두 가져다가 사용할 수 있는 것이다.
역직렬화에 @JsonCreator가 개입하는 지 여부에는 Jackson 라이브러리의 버전에 따라 다르게 동작하기 때문에 빌드 방식에 따라 이러한 차이가 생기는 것이라고 판단된다.
참고
https://www.baeldung.com/java-stack-heap
https://www.java67.com/2023/04/how-to-accept-and-produce-json-as.html
https://haenny.tistory.com/394
'우아한테크코스 > 레벨2' 카테고리의 다른 글
[회고] 우아한테크코스 6기 백엔드 레벨2 2주차 회고 (5) | 2024.04.28 |
---|---|
[OOP] POJO (Plain Old Java Objects) 란? (feat. 스프링의 등장 배경) (0) | 2024.04.27 |
[OOP] DTO(Data Transfer Object) 란 무엇인가? 어떻게 사용하는가? (0) | 2024.04.26 |
[회고] 우아한테크코스 6기 백엔드 레벨2 1주차 회고 (6) | 2024.04.21 |
[Spring] 문자열 경로를 반환하는 API를 호출하면 웹 페이지가 렌더링되는 이유는 뭘까? (feat. ViewResolver) (7) | 2024.04.18 |