Search
🔄

@Setter 와 @Builder 그리고 Jackson 에러 Feat.Editor

Person
김시은
김시은
태그
데이터바인딩
목차

문제의 시작

@Setter 를 지우고 빌더 패턴을 적용하면서 아래와 같은 에러들이 발생하였습니다.
org.springframework.messaging.converter.MessageConversionException: Could not read JSON: Cannot construct instance of travelplanner.project.demo.planner.dto.request.CalendarCreateRequest (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator) at [Source: (byte[])"{"dateTitle":"2023-09-04T15:04:16.920Z"}"; line: 1, column: 2]
Plain Text
복사
오늘도 머리아픈 에러를 만나버린 김시은. @Setter 를 왜 지양하는지부터 차근차근 짚어보기로 했습니다.

@Setter 를 왜 지양하는 걸까?

@Setter 를 사용하면 해당 코드가 업데이트인지 추가인지 알기 어렵습니다.
변경하면 안되는 중요한 값임에도 불구하고 변경 가능한 값으로 착각할 수 있습니다. 즉, 안정성을 보장할 수 없습니다.
OCP(Open-Closed Principle) 의 원리를 지킬 수 업습니다.
OCP 는 확장에 대해서는 열려있어야하고, 수정에 대해서는 닫혀있어야 한다는 원리입니다.
그래서 프로젝트를 진행하던중 @Setter 를 제거하고 빌더패턴을 적용하기로 하였습니다. 어떻게 변화 하였는지 코드로 한번 알아보겠습니다.

객체 생성과 수정시에 setter와 builder의 차이점

실무에서는 Setter를 사용하지 않는다!

JAVA를 활용하여 Api를 개발하는 것을 경험해본 사람이라면 Setter를 사용하면 안된다는 말을 한 번쯤은 들어봤을 것입니다. 개발 초기에 Setter를 활용하여 값을 바인딩하였지만 이를 해결하기 위해 공부해봤습니다.
Setter를 사용하여 개발을 하게되면, 다른 개발자가 어떠한 객체의 필드 값을 수정할 수 있습니다.
OCP의 원리를 지킬 수 없습니다.
그리하여 Setter를 제거하고 builder 패턴을 적용하기 위해 팀원들에게 builder를 사용하는 이유를 설명하고, setter를 제거하는 과정을 거쳤으며 새로운 오류 또한 만날 수 있게 되었습니다.
@setter를 사용하여 객체를 생성하는 것과 @Builder를 사용하여 객체를 생성하는 것은 크게 다르지 않습니다.

@setter 사용하여 객체 생성

Planner createPlanner = new Planner(); createPlanner.setPlanTitle(plannerCreateRequest.getPlanTitle()); createPlanner.setIsPrivate(plannerCreateRequest.getIsPrivate()); createPlanner.setMember(authUtil.getCurrentMember(request)); plannerRepository.save(createPlanner);
Plain Text
복사

@Builder를 사용하여 객체 생성

Planner createPlanner = Planner.builder() .planTitle(plannerCreateRequest.getPlanTitle()) .isPrivate(plannerCreateRequest.getIsPrivate()) .member(authUtil.getCurrentMember(request)) .build(); plannerRepository.save(createPlanner);
Plain Text
복사
@Builder를 사용하는 가장 큰 이유는 .build() 를 할 경우 객체의 특정 필드를 수정할 수 없습니다.
다른 개발자가 혹시나 필드를 수정할 경우를 미리 방지하는 것입니다.
객체를 생성하는 것은 둘의 차이가 크지 않고 이해하기 쉽습니다.
그렇다면 수정은 어떻게 할 수 있을까요?
기존에 setter를 사용하여 특정 필드만 수정 후 저장하면, JPA가 변경감지를 통해 알아서 저장해줍니다.
수정은 특정 필드만을 수정해야하는 것이기에 이작업이 까다롭습니다.
코드로 한 번 살펴봅시다.
공통로직
Long plannerId = plannerEditRequest.getPlannerId(); // 조회했을 때 플래너가 존재하지 않을 경우 Planner planner = plannerRepository.findById(plannerId) .orElseThrow(() -> new ApiException(ErrorType.PAGE_NOT_FOUND)); // 플래너를 생성한 사람이 아닐 경우 if (!planner.getMember().getId().equals(currentMember.getId())) { throw new ApiException(ErrorType.USER_NOT_AUTHORIZED); }
Plain Text
복사
DB에 없는 상황과 권한 설정을 확인 후에 작업을 시작합니다.

@setter를 사용하여 수정

// 플래너 정보 수정 planner.setPlanTitle(plannerEditRequest.getNewPlanTitle()); planner.setIsPrivate(plannerEditRequest.getNewIsPrivate());
Plain Text
복사
setter를 사용하여 특정 필드만을 수정한다면 매우 간단합니다.
이제 @Builder를 사용하여 수정을 진행해보겠습니다.
가장 먼저 해야할 작업은 Editor를 만드는 작업입니다. 값 필드를 어떻게 넣어줄지 정하는 클래스입니다.
이 Editor는 @Builder 대신, 직접 Builder 패턴대로 생성을 해주어야합니다.

Builder 패턴으로 수정

@Getter public class PlannerEditor { private String planTitle; private Boolean isPrivate; public PlannerEditor(String planTitle, Boolean isPrivate) { this.planTitle = planTitle; this.isPrivate = isPrivate; } public static PlannerEditorBuilder builder() { return new PlannerEditorBuilder(); } public static class PlannerEditorBuilder { private String planTitle; private Boolean isPrivate; PlannerEditorBuilder() { } public PlannerEditorBuilder planTitle(final String planTitle) { if (planTitle != null && !planTitle.isEmpty()) { this.planTitle = planTitle; } return this; } public PlannerEditorBuilder isPrivate(final Boolean isPrivate) { if (isPrivate != null) { this.isPrivate = isPrivate; } return this; } public PlannerEditor build() { return new PlannerEditor(planTitle, isPrivate); } } }
Plain Text
복사
그 후, 엔티티에서 수정할 수 있는 메서드와 editor로 갈 수 있는 메서드를 생성해줍니다.
public PlannerEditor.PlannerEditorBuilder toEditor() { return PlannerEditor.builder() .planTitle(planTitle) .isPrivate(isPrivate); } public void edit(PlannerEditor plannerEditor){ planTitle = plannerEditor.getPlanTitle(); isPrivate = plannerEditor.getIsPrivate(); }
Plain Text
복사
이제 수정하는 Service에서 이들을 활용합니다.
PlannerEditor.PlannerEditorBuilder editorBuilder = planner.toEditor(); PlannerEditor plannerEditor = editorBuilder .planTitle(plannerEditRequest.getPlanTitle()) .isPrivate(plannerEditRequest.getIsPrivate()) .build(); planner.edit(plannerEditor);
Plain Text
복사
수정할 수 있는 메서드를, 즉 PlannerEditorBuilder를 새로 생성하여 editorBuilder 변수에 담습니다.
그 후, PlannerEditorBuilder형태의 editorBuilder에다가 수정하고자 하는 값들을 넣고 생성하여 plannerEditor를 생성합니다.
그 후, Planner 엔티티 클래스에 있는 수정 메서드를 통해 값을 수정합니다.
여기서 알아둬야할 점은 PlannerEditor.PlannerEditorBuilder는 정적메서드이자 .build() 를 하지 않은 상태이기 때문에, 아직까진 수정할 수 있다는 점입니다.
이러한 Editor를 사용하면 setter를 제거하고 값을 수정, 삭제할 수 있습니다.

빌더패턴으로의 변화

아래는 @Data 를 제거하고 빌더패턴으로 작성한 날짜 생성/수정 코드입니다.

날짜 생성

public CalendarResponse createDate(Long plannerId, CalendarCreateRequest createRequest, String accessToken) { Planner planner = validatingService.validatePlannerAndUserAccessForWebSocket(accessToken, plannerId); Calendar buildRequest = Calendar.builder() .dateTitle(createRequest.getDateTitle()) .planner(planner) .createdAt(LocalDateTime.now()) .build(); calendarRepository.save(buildRequest); List<ToDoResponse> scheduleItemList = toDoService.getScheduleItemList(buildRequest.getId()); return CalendarResponse.builder() .dateId(buildRequest.getId()) .createAt(buildRequest.getCreatedAt()) .dateTitle(buildRequest.getDateTitle()) .plannerId(plannerId) .scheduleItemList(scheduleItemList) .build(); }
Java
복사

날짜 수정

public CalendarResponse updateDate(Long plannerId, Long updateId, CalendarEditRequest updateRequest, String accessToken) { Planner planner = validatingService.validatePlannerAndUserAccessForWebSocket(accessToken, plannerId); Calendar calendar = validatingService.validateCalendarAccess(planner, updateId); CalendarEditor.CalendarEditorBuilder editorBuilder = calendar.toEditor(); CalendarEditor calendarEditor = editorBuilder .dateTitle(updateRequest.getDateTitle()) .build(); calendar.edit(calendarEditor); calendarRepository.updatedateTitle(updateId, updateRequest.getDateTitle()); List<ToDoResponse> scheduleItemList = toDoService.getScheduleItemList(calendar.getId()); return CalendarResponse.builder() .dateId(calendar.getId()) .createAt(calendar.getCreatedAt()) .dateTitle(calendar.getDateTitle()) .plannerId(plannerId) .scheduleItemList(scheduleItemList) .build(); }
Java
복사
이렇게 코드를 작성하므로써 OCP(Open-Closed Principle) 의 원리를 지킬 수 있습니다. 또한 수정에관한 dto 를 따로 생성하여 생성과 수정을 코드만 보고 분리할 수 있습니다.

@Builder 에 대한 이해 높이기

@Builder

@Builder 는 기본적으로 빌더 어노테이션이 적용된 전체 필드에 대한 값을 요구합니다. 때문에 생성자가 반드시 필요합니다. 이때의 생성자는 매개변수가 있는 생성자 이며 매개변수가 있는 생성자가 없다면 자동으로 만들어 줍니다. 이때 @AllArgsConstructor 를 사용하여 직접 지정할 수도 있습니다.

@Entity

@Entity의 경우 매개변수가 없는 기본 생성자를 필요로 하며, 없다면 자동으로 만들어 줍니다. 하지만 빌더와 마찬가지로 @NoArgsConstructor 를 사용하여 직접 지정할 수 있습니다.
만약 @Entity 와 @Builder 만 사용한다면 어떻게 될까요? @Entity 는 @Builder 에서 자동으로 만든 생성자 때문에 생성자가 이미 만들어졌다고 판단하고 @Builder 는 @Entity 에서 만든 기본 생성자 때문에 생성자가 이미 만들어졌다고 판단해 충돌이 발생하게 됩니다. 이때문에 @NoArgsConstructor 와 @AllArgsConstructor 까지 적어 각각의 생성자를 직접 지정해줘야 합니다.

Jackson 라이브러리

Jackson은 Java에서 객체를 JSON 문자열을 변환(역직렬화)하거나 JSON 문자열을 객체로 변환(직렬화)하는 기능을 제공하는 라이브러리 입니다. 즉, @RequestBody 로 프론트에서 값을 받아오면 dto 와 바인딩을 해주는 역할을 합니다. 이 라이브러리는 JSON 을 어떻게 객체로 변환하는 걸까요?
1.
ObjectMapper 와 기본생성자를 사용하여 객체를 생성합니다.
2.
Setter 혹은 Getter 를 이용하여 필드를 인식합니다.
3.
객체의 필드에 해당하는 값을 넣습니다.
때문에 기본생성자가 없다면 초장부터 길을 잃어버리고 맙니다.

문제 다시 뜯어보기

org.springframework.messaging.converter.MessageConversionException: Could not read JSON: Cannot construct instance of travelplanner.project.demo.planner.dto.request.CalendarCreateRequest (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator) at [Source: (byte[])"{"dateTitle":"2023-09-04T15:04:16.920Z"}"; line: 1, column: 2]
Plain Text
복사
다시 천천히 살펴보면 문제는 명확했습니다. 기본생성자가 없어서 직렬화를 할 수 없다고 합니다.
해당 리퀘스트를 찾아가보니 아래와 같이 어노테이션이 달려있었습니다.
@Getter @Builder @AllArgsConstructor public class PlannerDeleteRequest {
Java
복사
이를 아래와 같이 수정해주니 잘 해결되었습니다.
@Getter @Builder @AllArgsConstructor @NoArgsconstructor public class PlannerDeleteRequest {
Java
복사

결론

Controller에서 응답을 전달하는 DTO 에 기본생성자가 없었기에 나타난 문제였습니다. 때문에 이는 간단히 기본생성자를 추가함으로써 해결할 수 있습니다. 에러로그가 상세하게 나오기 때문에 쉽게 수정방법은 알 수 있었으나, 과정을 알아야 하므로 어노테이션을 정리해보는 시간을 가져봤습니다.

도움이 되었던 블로그

버튼을 눌러 궁금점이나 의견을 남겨주세요.

@김시은
제가 만든 기능의경우 저는 원래 dto 와 entity 에 @NoArgsConstructor, @AllArgsConstructor, @RequiredArgsConstructor 는 붙이고 작업을 시작합니다. 혹은 @Data 를 붙이거나요. @Data 의 경우 @Setter 의 기능이 포함되어 있기 때문에 위의 에러가 나타나지 않았던 것 같습니다.