Backend/Java

@RequestBody 모델에 기본생성자, setter/getter가 필요한가?

비비빅B 2021. 1. 21. 12:38

서론

최근에 Entity 모델의 속성의 성격을 잘 생각해보고 불변해야 하는 것들은 final로 선언해 명확하게 하라는 코드리뷰를 받았다. 한번 생성되면 변하면 안되는 것들, 이 속성 없이 생성되면 안되는 것들은 추후에 변경돼 에러를 발생시킬 위험이 있고 규모가 커지면 추적하기 어렵다는 것이 이유였다.

 

오... final! 간단하군! 했는데 여러 문제에 봉착했다. 테스트 코드 작성의 어려움, 객체 변환에 어려움. Builder패턴... 여러가지로 전체적인 코드스타일이 변경되어야 했다. 아무튼, 여러 문제점 중에 여기서 말할 것은 객체 매핑이다.

 

final로 선언된 속성이 있으면 기본생성자는 만들 수 없고 또한 setter 함수도 작성 불가능하다. 그런데 Spring Controller에서 자동으로 객체 매핑시켜주는 라이브러리들은 기본생성자setter함수를 사용하지 않나...?

그래서 JSON과 JAVA 객체를 어떻게 처리하는지 찾아보았다. 

 

사실 여러번 찾아보고 넘어갔는데, 계속 헷갈려 이번에 글로 작성하며 한번 더 정리하려 한다.

내가 생각했던 의문점과 그에 대한 답을 작성하는 식으로 작성하겠다.


본론

1. setter가 없는데 어떻게 역직렬화할까?

spring-boot-starter-web 의 spring-boot-starter-json

 

현재 내 개발환경은 스프링부트 스타터에 포함되어 있는 jackson 라이브러리를 사용하고 있다. 그리고 jackson은 직렬화할 때는 getter를 사용하고, 역직렬화할 때는 기본생성자와 setter를 사용하는 것으로 알고 있었다.

 

@Builder
@Getter
public class User {

	private final String id;
	private String password;
	private Email email;
	private final LocalDate createdAt;
	
	public User(String id, String password, Email email) {
		this(id, password, email, null);
	}
	
	public User(String id, String password, Email email, LocalDate createdAt) {
		checkArgument(isNotEmpty(id), "아이디는 필수 입력값입니다");
		checkArgument(isNotEmpty(password), "패스워드는 필수 입력값입니다");
		checkNotNull(email, "이메일은 필수 입력값입니다");
		
		this.id = id;
		this.password = password;
		this.email = email;
		this.createdAt = ObjectUtils.defaultIfNull(createdAt, LocalDate.now());
	}
 }

간단한 User객체지만 변해서는 안되는 id는 final로 선언함으로써 기본생성자를 생성하지 못한다. 이로써 한번 생성된 회원의 id는 절대 변할 일이 없지만 기본생성자를 생성하지 못해 Jackson 라이브러리를 사용하지 못한다. 어떻게 해야 할까?

 

Controller에서 쓰는 DTO를 만들자.(요청, 응답 따로)

단, 여기서 DTO(Data Transfer Object)를 Service로 넘기는 것은 좋지 않다.

DTO는 말 그대로 데이터를 전달하기 위한 틀에 불과하다. 서비스에서 다른 서비스로 호출될 수 있기 때문에 DTO는 컨트롤러에서만 사용하고 서비스에서는 도메인 객체인 Entity, VO를 사용하는 것이 좋다.

 

@RestController
public class TestController {

	private final TestService testService;
	
	public TestController(TestService testService) {
		this.testService = testService;
	}

	@PostMapping("/signup")
	public UserDTO signup(@RequestBody SignupDTO signupDTO) {
		User user = new User(signupDTO.getId(), signupDTO.getPassword(), signupDTO.getEmail());
		return new UserDTO(testService.signup(user));
	}
}

 

컨트롤러에서 SignupDTO(회원가입 요청 DTO)를 받아서 service에 User객체를 넘겨주고 응답으로는 UserDTO(응답 DTO)를 반환한다.

여기서 jackson은 json데이터를 SignupDTO로 역직렬화, UserDTO를 json데이터로 직렬화 총 2번 사용된다.

 

@Getter
@NoArgsConstructor
public class SignupDTO {

	private String id;
	private String password;
	private Email email;
	
	public SignupDTO(String id, String password, Email email) {
		this.id = id;
		this.password = password;
		this.email = email;
	}
}
@Getter
@NoArgsConstructor
public class UserDTO {
	
	private String id;
	private Email email;
	private LocalDate createdAt;
	
	public UserDTO(User user) {
		this.id = user.getId();
		this.email = user.getEmail();
		this.createdAt = user.getCreatedAt();
	}
	
}

 

 

모두 getter와 기본생성자만 생성하고 테스트해봤다.

 

@WebMvcTest
class TestControllerTest {

	@Autowired
	private MockMvc mvc;
	
	@Autowired
	private ObjectMapper objectMapper;
	
	@MockBean
	private TestService testService;
	
	@Test
	@DisplayName("유효한 회원가입요청시 password 없이 UserDTO받음")
	void test1() throws Exception {
		SignupDTO user = new SignupDTO("test-id", "P4ssword", new Email("test1", "@google.com"));
		given(testService.signup(Mockito.any(User.class))).willReturn(new User(user.getId(), user.getPassword(), user.getEmail()));
		String content = objectMapper.writeValueAsString(user);
		
		mvc
			.perform(post("/signup")
					.content(content)
					.contentType(MediaType.APPLICATION_JSON))
			.andExpect(status().isOk())
			.andExpect(jsonPath("$.id").value("test-id"))
			.andExpect(jsonPath("$.password").doesNotExist())
			.andExpect(jsonPath("$.email.userEmail").value("test1"))
			.andExpect(jsonPath("$.createdAt").exists());
	}
}

 

결과는 성공이었다. 이유를 알아보니 Jackson은 Post요청시 Jackson2HttpMessageConverter이 데이터를 처리하는데 내부적으로 ObjectMapper를 사용한다. objectMapper는 기본생성자 public getter 혹은 setter 혹은 public field를 보고 property명을 찾는다. 그래서 기본생성자와 getter만 있어도 prorperty명을 찾아 값을 주입시켜주는 것이다. 

 

 

그런데 여기서 한가지 더 궁금한 점이 생겼다. property명을 알아내도 어떻게 값을 주입시키는 거지?

setter가 없으면 값을 주입하지 못하지 않나?

property명은 위의 그림처럼 setter, getter 혹은 public field를 보고 찾지만, 값 주입은 java.lang.reflect 패키지를 사용해 직접 주입시킨다. 그래서 굳이 setter가 없더라도 값이 주입되는 것이다.


이 테스트를 하기 전에 깜빡하고 기본생성자를 만들지 않고 테스트를 했고 통과했다. 이때까지의 설명대로라면 기본생성자는 필순데... 어떻게 테스트에 통과했지? 테스트 환경이 문제인가 싶어 서버를 가동해 봐도 문제없이 잘 됐다.

 

 

기본생성자가 필요없나...?

 


 

2. 기본생성자 없이 어떻게 역직렬화할까?

 

ObjectMapper 내부에는 property와 생성자가 위임된 경우 그 정보를 이용해서 직렬화/역직렬화에 사용하는 로직이 있다. 여기서 위임이라는 것은 어노테이션을 사용해 객체를 변환(직렬화/역직렬화)할 때 쓰일 정보를 직접 선언하는 것이다.

 

위임 어노테이션

  • @JsonProperty
  • @JsonAutoDetect
  • @JsonCreator

어노테이션이 작성되면 생성자와 property값이 위임돼 getter, setter, 기본생성자 없이도 jackson이 어노테이션 정보를 참고해 정상적으로 작동한다.

 

자동 위임 모듈

나는 jackson-datatype-jdk8을 임포트해서 사용하고 있었는데, 해당 버전에서 아래의 3가지 유용한 모듈을 지원해준다.

 

Jackson에서 제공하는 추가적인 모듈 3개

이중에서 우리가 봐야할 것은 첫 번째 모듈인 `Parameter names` 모듈이다. 설명을 읽어보면 @JsonProperty로 위임정보를 명시하지 않더라도, 생성자를 자동으로 탐지하는 것을 도와주는 모듈이다. 이 모듈이 자동으로 생성자를 위임시켜줘서, 기본생성자가 없어도 잘 작동했던 것이다.

ObjectMapper에 모듈을 추가적으로 등록할 수 있다.

ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new ParameterNamesModule(JsonCreator.Mode.PROPERTIES));

 

스프링부트 자동설정

개발자가 별도로 ObjectMapper설정을 하지 않는 이상, 스프링부트는 자동으로 ObjectMapper를 설정하고 Bean으로 등록해준다. 아래는 스프링부트에서 위의 모듈을 자동으로 등록하는 코드다. `@Conditional` 어노테이션으로 자동설정하는 것을 볼 수 있다.

 

// SpringBoot JacksonAutoConfiguration.class

  @ConditionalOnClass({ParameterNamesModule.class})
  static class ParameterNamesModuleConfiguration {
    ParameterNamesModuleConfiguration() {
    }

    @Bean
    @ConditionalOnMissingBean
    ParameterNamesModule parameterNamesModule() {
      return new ParameterNamesModule(Mode.DEFAULT);
    }
  }
  
  
  ...

// 아마도 빈으로 등록된 모듈클래스를 objectMapperBuilder에 주입하는 코드
private void configureModules(Jackson2ObjectMapperBuilder builder) {
  Collection<Module> moduleBeans = getBeans(this.applicationContext, Module.class);
  builder.modulesToInstall((Module[])moduleBeans.toArray(new Module[0]));
}

일반적인 ObjectMapper 설정

보통 프로젝트를 진행할 때 ObjectMapper 커스텀이 필요한데, 아래처럼 Override해서 기본생성자, setter, getter 없이 잘 작동하도록 변경하고 진행하는 것이 일반적이라고 한다.

@Configuration
public class ServiceConfiguration {

	  @Bean
	  public Jackson2ObjectMapperBuilder configureObjectMapper() {
		// Java time module
	    JavaTimeModule jtm = new JavaTimeModule();
	    jtm.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ISO_DATE_TIME));
	    Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder() {
	      @Override
	      public void configure(ObjectMapper objectMapper) {
	        super.configure(objectMapper);
	        objectMapper.setVisibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.NONE);
	        objectMapper.setVisibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.NONE);
	        objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
	        objectMapper.registerModule(new ParameterNamesModule(JsonCreator.Mode.PROPERTIES));
	      }
	    };
	    builder.serializationInclusion(JsonInclude.Include.NON_NULL);
	    builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
	    builder.modulesToInstall(jtm);
	    return builder;
	  }
}

 

위의 설정을 하면 setter/getter/기본생성자가 없어도 잘 작동한다.


생성자 인자가 하나일 경우, 위임이 안되는 현상

Note that this mode is currently (2.5) always used for multiple-argument creators; the only ambiguous case is that of a single-argument creator.

생성자 인자가 하나일 경우 @JsonCreater 선언해줘야 한다. 이거 때문에 3시간 동안 삽질했다.

 

public class Email {

	private final String address;

	@JsonCreator
	public Email(String address) {
		checkArgument(isNotEmpty(address), "address must be provided.");
		checkArgument(address.length() >= 4 && address.length() <= 50,
				"addresss length must be between 4 and 50 characters.");
		checkArgument(checkAddress(address), "Invalid email address: " + address);

		this.address = address;
	}
}

 

ObjectMapper는 Property를 어떻게 찾을까 ?

Content

bactoria.github.io

 

@Request Body에서는 Setter가 필요없다?

회사에서 근무하던중 새로오신 신입 개발자분이 저에게 하나의 질문을 했습니다. POST 요청시에 Setter 가 필요없는것 같다고. 여태 제가 알던것과는 달라서 어떻게 된 일인지 궁금했습니다. 정말

jojoldu.tistory.com

 

@RequestBody에 왜 기본 생성자는 필요하고, Setter는 필요 없을까? #3

이전 글에서는 RestController에서 @RequestBody 바인딩을 Jackson 라이브러리의 ObjectMapper가 하는 것을 확인했습니다.그리고 RequestBody를 생성할 때, DTO가 Property기반이 아니거나 Delegate를 한 상태가 아니라

velog.io

 

FasterXML/jackson-modules-java8

Set of support modules for Java 8 datatypes (Optionals, date/time) and features (parameter names) - FasterXML/jackson-modules-java8

github.com

'Backend > Java' 카테고리의 다른 글

JDBC query vs queryForObject  (0) 2021.03.30
Spring Security JWT Token  (0) 2021.03.22
parse, valueOf 차이와 문제점  (0) 2021.01.21
Repository와 DAO 차이?  (0) 2021.01.14
[Spring Boot] 유효성 검사  (0) 2020.12.16