[Spring Boot] @RequestBody가 선언된 객체의 final field 이슈

이홍준·2023년 8월 17일
0

Trouble Shooting

목록 보기
2/3

프로젝트를 개발하면서 Validation과 핵심적인 비즈니스 로직이 들어있는 도메인 엔티티클래스를 외부에 숨기고자 입력받을 객체와 도메인 객체의 역할을 분리하였다. 그런데 객체의 역직렬화하는데 있어서 발생한 문제에 대해서 알아보도록 한다.

문제 발생

변수들의 불변성 보장하고자 lombok 의 @Value를 선언하였다. 그리고 Validation는 상위 클래스인 SelfValidating 클래스를 통해 직접 유효성 검사를 하기로 했다.

import com.example.demo.common.SelfValidating;
import lombok.*;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
// Reqeust Dto
@Value
@EqualsAndHashCode(callSuper = false)
public class LoginRequest extends SelfValidating<LoginRequest> {
    @Email
    private String email;
    @NotBlank
    private String password;
}
// Controller
@PostMapping("/login")
    public SuccessApiResponse login(@RequestBody LoginRequest loginRequest){

요청 객체를 보낸 결과 대충 기본 생성자가 없어서 생기는 Jackson에서 나오는 에러였다.

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.example.demo.user.adapter.in.web.request.LoginRequest` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 2]
	at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:67) ~[jackson-databind-2.13.5.jar:2.13.5]
	at com.fasterxml.jackson.databind.DeserializationContext.reportBadDefinition(DeserializationContext.java:1904) ~[jackson-databind-2.13.5.jar:2.13.5]
	at com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:400) ~[jackson-databind-2.13.5.jar:2.13.5]
	at com.fasterxml.jackson.databind.DeserializationContext.handleMissingInstantiator(DeserializationContext.java:1349) ~[jackson-databind-2.13.5.jar:2.13.5]

컨트롤러에서 매개변수에 @RequestBody를 달아주면 그 객체는 내장된 Jackson라이브러리를 통해 json을 자바 객체로 변환해준다고 한다. (ObjectMapper → readValue()) 구글링을 한 결과 이부분은 역직렬화를 할 때 default 생성자가 있어야 한다고 뜨는 에러라고 한다.

해결과정

GPT에게도 물어보고 구글링을 해보니 잘 설명한 글들이 많았다. (Reference 주소들 참고)

  1. 첫번째 해결: 빌드 주체 바꾸기

이렇게 빌드하는 주체를 Intellij → Gradle로 바꿔주면 해결되는 일이다.

하지만 이 방법은 근본적인 해결책이 아니라고 느꼈고, 구조를 한번 뜯어볼 필요가 있었다.

  1. 두번째 해결: 그냥 디폴트 생성자넣기
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest extends SelfValidating<LoginRequest> {
    @Email
    private String email;
    @NotBlank
    private String password;
}

이 문제로 우선 급한 불은 껐다. 하지만 Request 객체로 들어오는 값 들의 불변성이 보장되지 않고, SelfValidation부분도 적용되지 않는 문제가 있었기 때문에 다른 방법을 찾아보았다.

  1. @JsonCreator + @JsonProperty 사용

이런식으로 @JsonCreator를 통해 넣어주면 잘 작동하게 된다.

@Value
@EqualsAndHashCode(callSuper = false)
public class LoginRequest extends SelfValidating<LoginRequest> {
    @Email
    private String email;
    @NotBlank
    private String password;
    @JsonCreator
    public LoginRequest(@JsonProperty("email")String email, @JsonProperty("password") String password) {
        this.email = email;
        this.password = password;
        this.validateSelf();
    }
}

작동 원리

이렇게 그냥 끝낼 거면 이 글을 작성하지 않았을 것이다. 작동 원리에 대해서 알아보자

우선 알아야할 개념이 ObjectMapper클래스와 Java의 Reflection API이라는 개념이다. ObjectMapper는 말그대로 json와 객체사이에 매핑역할을 해주는 클래스이다. 그리고 Reflection은 Person.class, forName(), Constructor 등등 런타임중에 클래스, 메서드, 필드 등의 정보를 검사하고 조작할 수 있는 기능을 제공하는 것이다.

// Reflection API
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class ReflectionExample {
    public static void main(String[] args) throws Exception {
        // 클래스 이름으로 Class 객체 얻기
        Class<?> clazz = Class.forName("com.example.User");

        // 클래스의 생성자 정보 가져오기
        Constructor<?> constructor = clazz.getConstructor();
        Object obj = constructor.newInstance();

        // 클래스의 필드 정보 가져오기
        Field field = clazz.getDeclaredField("fieldName");
        field.setAccessible(true);
        field.set(obj, "New Value");

        // 클래스의 메서드 정보 가져오기
        Method method = clazz.getDeclaredMethod("methodName", String.class);
        method.invoke(obj, "Argument");

        System.out.println(obj);
    }
}
}

JDK 8 미만 에서는 리플렉션으로 메소드를 확인할수 없었다고 하는데 컴파일시 -parameters 옵션을 줘야 사용할 수 있다고 한다. Gradle 로 실행할때 컴파일 옵션이 자동으로 들어가는데 IntelliJ와 같은 IDE로 빌드할 때 어느정도 생략되는 부분이 있어서 따로 설정해야 한다고 한다.

@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)
public class JacksonAutoConfiguration {
@Configuration(proxyBeanMethods = false)
	@ConditionalOnClass(ParameterNamesModule.class)
	static class ParameterNamesModuleConfiguration {
// ...
		@Bean
		@ConditionalOnMissingBean
		ParameterNamesModule parameterNamesModule() {
			return new ParameterNamesModule(JsonCreator.Mode.DEFAULT);
		}

	}
 }

결론

단순히 왜 기본생성자를 사용해야하지? 에서 시작된 궁금증에서 부터 리플렉션의 개념과 ObjectMapper란게 무엇인지 어느정도 알게 되는 큰 계기가 되었다. 개발을 하다보면 그냥 당연히 된다는 것에 부터 너무 익숙한 나머지 디테일한 동작 구조를 간과하게 되는 것 같다. 이번 경험을 계기로 단순히 해결하는 것에 이어서 관련된 정보와 원리까지 알아냄으로써 비슷한 문제에 있어서 해결하는데 많은 도움이 될 것이라고 생각된다.


References

profile
I'm a web developer.

0개의 댓글