기록 이유
팀원들과 Issue Traker 프로젝트를 진행하며 여러 DTO 클래스를 활용해 API 를 개발하고 있다.
값을 바인딩 할 때 사용하는 Request 관련 DTO, 서버에서 Json 데이터를 만들어 응답하는 Response 관련 DTO 로 나눠서 DTO 를
사용하고 있는데, 너무 무분별하게 롬북 라이브러리 어노테이션을 사용한다는 생각이 들었다. 이에 호기심이 생겨 구글링을 하며 궁금했던
부분들을 기록하기 위해 포스트를 작성하게 되었다 😀
기록 시작 ~
Jackson ?
우린 SpringBoot 를 사용하면서 객체를 Json 으로 변환할 때, 그리고 Json 을 객체로 변환할 때 Jackson 라이브러리를 사용하게 된다.
Jackson 은 Json 데이터 구조를 처리할 수 있도록 도와주는데, start.spring.io 에서 프로젝트를 생성한다면 자동으로 설치되기 때문에
바로 사용이 가능하다. 정확하게는 org.springframework.boot:spring-boot-starter 를 통해 Jackson 라이브러리가 자동 설치된다.
나는 거의 start.spring.io 에서 프로젝트 시작을 하기 때문에 알게 모르게 Jackson 을 사용하고 있었다는 것 ..
좀 더 자세하게 말하면, @RestController 를 사용해 개발을 하다보니, Jackson 의 ObjectMapper 이 고맙게도 직렬화와 역직렬화를
알아서 처리해줬다는 것이다 ㅎㅎ,,
Jackson 의 간단한 규칙
1. Jackson 은 기본적으로 Property 로 동작한다.
2. Java 에서는 따로 Property 문법이 존재하지 않는다.
3. Java 에서는 Getter, Setter 명명 규칙(Java Beans Pattern) 으로 Property 를 정해진다.
따라서 롬북 라이브러리를 사용해 편리하게 Jackson 을 다룰 수 있다.
직렬화와 역직렬화
직렬화
Java Object 를 Json 데이터 타입으로 변환하는 것.
역직렬화
Json 데이터 타입을 Java Obejct 로 변환하는 것.
기본 생성자의 역할
@NoArgsConstructor 이 없다면 ?
이 DTO 클래스는 이슈를 생성할 때 사용하는 DTO 클래스이다.
즉, 클라이언트에서 Json 데이터를 받아와 역직렬화를 실시해 데이터를 바인딩할 때 사용하는 클래스란 것.
나는 여기서 @NoArgsConstuctor 어노테이션이 없다면 ObjectMapper 가 잘 동작할까 ? 에 대해 호기심을 가졌다.
일단 @NoArgsConstuctor 어노테이션을 생략하고 실행해보자.
package team20.issuetracker.service.dto.request;
import java.util.List;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
@Getter
public class RequestSaveIssueDto {
@NotEmpty
@Size(max = 50, message = "Issue 의 제목은 50글자를 넘을 수 없습니다.")
private String title;
@NotEmpty
@Size(max = 800, message = "Issue 의 본문은 800글자를 넘을 수 없습니다.")
private String content;
private List<Long> assigneeIds;
private List<Long> labelIds;
private Long milestoneId;
}
짜두었던 테스트 코드는 주석 상태라 Postman 으로 한 번 테스트를 해보자.
아래의 Json 데이터를 클라이언트에서 보낸다고 가정한다.
{
"title" : "이슈 타이틀3",
"content" : "이슈 콘텐츠3",
"assigneeIds" : [
1
],
"labelIds" : [
1
],
"milestionId" : null
}
GET /issues (Issue Read API) 결과
조회했을 때 이슈가 잘 저장된 것을 확인할 수 있다.
{
"id": 7,
"title": "issueTitle5",
"author": "79444040",
"createAt": "2022-08-01",
"issueStatus": "OPEN",
"milestoneTitle": " ",
"labels": [
{
"id": 1,
"title": "test",
"description": "test",
"backgroundColor": "#123123",
"textColor": "#123123"
}
],
"assignees": [
{
"id": 2,
"title": "test2",
"image": "test2"
},
{
"id": 1,
"title": "test",
"image": "test"
}
]
}
왜?
정말 당연한 얘기지만 생성자를 따로 만들지 않으면, 자바의 컴파일러가 자동으로 기본 생성자를 만들어주기 때문이다.
RequestSaveIssueDto 클래스를 다시 보면 따로 만들어준 생성자는 하나도 존재하지 않는다. 그래서 굳이 @NoArgsConstructor 를
사용하지 않아도 파싱 예외가 발생하지 않는 것이다.
좀 더 자세하게 Jackson 의 ObjectMapper 에서 Json 데이터를 객체로 변환하는 것을 세 가지 단계로 나눠서 살펴보자면,
1. 대상 클래스의 기본 생성자로 객체를 생성한다. (기본 생성자가 필요한 이유)
2. 해당 객체의 Setter 메서드를 활용해서 Json 값을 객체에 바인딩한다.
3. 만약 Setter 메서드가 없다면, Java Reflection 패키지를 활용해서 Json 값을 객체에 바인딩한다.
습관적으로 @NoArgsConstructor 어노테이션을 사용했는데 이 경우는 자바의 기본을 잊어버린 바보같은 호기심이 아니었나 싶다 .. 😵💫
그럼 @NoArgsConstructor 은 언제 사용해야 하나?
위에서 적어놨듯 다른 생성자를 하나라도 클래스 내부에 만든 경우에는 자바 컴파일러가 기본 생성자를 자동으로 만들어주지 않기 떄문에
ObjectMapper 에서 역직렬화가 잘 동작하기 위해 @NoArgsConstructor 을 사용해줘야한다.
아래는 프로젝트의 다른 DTO 클래스인데, @AllArgsConstructor 을 사용해 정적 팩토리 메서드를 구현했다.
즉, 생성자가 이미 클래스 내부에 존재하고 있기 때문에 @NoArgsConstuctor 을 사용해 기본 생성자를 만들어줘야 ObjectMapper 가
정상적으로 동작할 수 있다. 참고로 @NoArgsConstructor 에서 엑세스 레벨을 Private 으로 설정해줘도 된다 !
package team20.issuetracker.service.dto.request;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class RequestLabelDto {
@NotEmpty
@Size(max = 20, message = "Label의 제목은 20글자를 넘을 수 없습니다.")
private String title;
@Pattern(regexp = "#[\\dA-Fa-f]{6}", message = "올바른 color 코드가 아닙니다.")
private String backgroundColor;
@Pattern(regexp = "#[\\dA-Fa-f]{6}", message = "올바른 color 코드가 아닙니다.")
private String textColor;
@Size(max = 100, message = "Label의 설명은 100글자를 넘을 수 없습니다.")
private String description;
public static RequestLabelDto of(String title, String backgroundColor, String textColor, String description) {
return new RequestLabelDto(title, backgroundColor, textColor, description);
}
}
@Gettter 의 역할
@Getter 는 왜 사용해야 할까 ?
ObjectMapper 는 일반적으로 직렬화와 역직렬화 과정에서 @Getter 와 @Setter 를 사용한다.
@Setter 를 사용하지 않아도 위에서 언급했듯 Java Reflection 패키지를 이용하기 때문에 굳이 사용할 필요성이 없다면 지양하자.
또한 Getter 를 통해 접근 제어자가 public 이 아닌 필드를 직렬화 / 역직렬화 할 수 있기에 Getter 를 사용한다고 한다.
즉, 필드에 Getter 가 있다면 그 필드는 Property 로 간주돼고, 위에서 언급했듯 Jackson 은 Property 로 동작하기 때문에 @Getter 를
사용하는 것이다.
이 부분에 대해선 여러 번 테스트를 해봤는데 확실하지가 않다 .. @Getter 가 붙어있는 DTO 클래스는 많은 곳에서 Getter 를 통해 필드 값을 사용하고 있고 프로젝트 코드를 함부로 바꾸기 힘들어서 다음에 간단하게 프로젝트를 만들어 테스트 해봐야 할 것 같다.
지금으로썬 역직렬화는 잘 모르겠지만, 직렬화에 @Getter 가 사용되는 것 같긴 하다.
Jackson 을 사용하면서 불변 객체를 생성하는 방법
Jackson 의 ObjectMapper 를 사용해 직렬화와 역직렬화를 한다면 기본 생성자가 꼭 필요하다.
하지만 자바 문법에 의해 모든 필드에 final 이 붙는 불변 객체는 기본 생성자를 추가할 수 없는데, 불변 객체를 생성할 수 있는 방법이
있을 것 같은데,, 호기심이 생겨 찾아보았다.
다행히 Jackson 에서 제공하는 @JsonCretor, @JsonProperty 를 사용하는 방법이 존재한다고 한다. 한 번 알아보도록 하자.
예제 코드
@JsonCreator 를 사용하면 Jackson 의 ObjectMapper 에 해당 생성자를 통해 동작하도록 명시해 줄 수 있다.
클래스 내부에 생성자가 하나라면 @JsonCreator 를 생략해도 무방하다.
그리고 생성자 피라미터 모두에 @JsonProperty 를 붙여줘서 Jackson 에게 접근할 필드명을 알려줄 수 있다.
이렇게 해주면 Jackson 을 사용하면서 해당 객체를 불변 객체로 만들 수 있다.
@Getter
public class car {
private final String name;
private final String owner;
@JsonCreator
public car(
@JsonProperty("name") String name,
@JsonProperty("owner") String owner) {
this.name = name;
this.owner = owner;
}
}
그렇다면 위의 어노테이션을 사용하면 기본 생성자가 필요없지 않을까 ?
공부를 하다보니 @JsonCretor, @JsonProperty 를 사용하면 기본 생성자나 Getter 가 필요없지 않나 라는 생각이 들었다.
아무것도 모르고 사용하던 시절에 어거지로 사용했던 경험도 있고 ? 그래서 한번 테스트를 해봤는데 예외 없이 잘 동작했다 !
어노테이션 이름에서도 추측할 수 있듯, Jackson 은 기본적으로 Property 로 동작하는데 위에서 언급한 두 가지의 어노테이션으로
기본 생성자 없이도 @JsonCretor 를 사용해 ObjectMapper 가 Json 데이터를 잘 바인딩 할 수 있도록 생성자를 명시해주고,
Getter 없이도 @JsonProperty 를 필드에 명시해 해당 필드가 Property 라는 것을 명시해주기 때문인 것 같다.
프로젝트에서 사용하는 DTO 를 하나 가지고 와봤다.
정적 팩토리 메서드를 사용하고 있기 때문에 기본 생성자와 @Getter 를 정의해야 ObjectMapper 에서 예외가 발생하지 않는다.
package team20.issuetracker.service.dto.response;
import java.time.LocalDate;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import team20.issuetracker.domain.milestone.Milestone;
import team20.issuetracker.domain.milestone.MilestoneStatus;
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ResponseMilestoneDto {
private Long id;
private String title;
private LocalDate startDate;
private LocalDate endDate;
private String description;
private MilestoneStatus milestoneStatus;
private ResponseMilestoneDto(Long id,String title,LocalDate startDate,LocalDate endDate,String description,MilestoneStatus milestoneStatus) {
this.id = id;
this.title = title;
this.startDate = startDate;
this.endDate = endDate;
this.description = description;
this.milestoneStatus = milestoneStatus;
}
public static ResponseMilestoneDto of(Milestone milestone) {
return new ResponseMilestoneDto(milestone.getId(), milestone.getTitle(), milestone.getStartDate(), milestone.getEndDate(), milestone.getDescription(), milestone.getMilestoneStatus());
}
}
@JsonCreator, @JsonProperty 어노테이션을 사용한다면 기본 생성자를 생성하지 않아도 예외가 발생하지 않는다.
@Getter 를 굳이 써준 이유는 프로젝트를 진행하며 여러 곳에서 필드 값을 사용하고 있기 때문에 사용했다.
@Getter
public class ResponseMilestoneDto {
private Long id;
private String title;
private LocalDate startDate;
private LocalDate endDate;
private String description;
private MilestoneStatus milestoneStatus;
@JsonCreator
private ResponseMilestoneDto(
@JsonProperty Long id,
@JsonProperty String title,
@JsonProperty LocalDate startDate,
@JsonProperty LocalDate endDate,
@JsonProperty String description,
@JsonProperty MilestoneStatus milestoneStatus) {
this.id = id;
this.title = title;
this.startDate = startDate;
this.endDate = endDate;
this.description = description;
this.milestoneStatus = milestoneStatus;
}
public static ResponseMilestoneDto of(Milestone milestone) {
return new ResponseMilestoneDto(milestone.getId(), milestone.getTitle(), milestone.getStartDate(), milestone.getEndDate(), milestone.getDescription(), milestone.getMilestoneStatus());
}
}
오늘도 삽질 기록 완료 ,, ObjectMapper 내부를 디버깅하며 이것저것 테스트 해보고 싶지만 프로젝트가 너무 급해서 지금은 그럴 수 없을 것 같고 일단은 이렇게 이해하고 사용하기로 했다 😅
기록 끝 !
'프로젝트 기록 > Issue Tracker 삽질 기록' 카테고리의 다른 글
[Issue Tracker Project] Switch - Case Refactoring (0) | 2022.11.06 |
---|---|
[Issue Tracker Project] Issue 전체 조회 시 N + 1 발생과 개선 (0) | 2022.10.21 |
[Issue Tracker Project] 로그인 시 환경변수 이슈 발생 (0) | 2022.09.07 |
[Issue Tracker Project] JPA 연관관계 매핑 관련 이모저모 (0) | 2022.07.15 |