Dev_Henry

[Java] objectMapper,modelMapper 등 객체 매핑방법과 리플렉션 이해하기 본문

CS/Java

[Java] objectMapper,modelMapper 등 객체 매핑방법과 리플렉션 이해하기

데브헨리 2023. 7. 25. 00:15
728x90

 

이제까지 프로젝트를 하면서 값을 주고 받을 때 다양한 방법으로 객체와 데이터를 매핑시켰다. 그런데 구체적으로 어떻게 매핑이 되는지 이해하지 못한 상태로 사용하다보니 종종 에러가 발생하는 경우가 있었기 때문에, 이에 대해서 공부해보려 한다.

 

우선 objectMapper와 modelMapper를 알아본 뒤에

아래에서 간단한 테스트도 진행해 보겠다.


우선 객체 데이터를 json으로 주고받을때 많이 사용하는 objectMapper. @RequestBody는 내부적으로 objectMapper를 사용한다.

스프링 부트에서는 기본적으로 jackson이 내장되어있고, 여기에 objectMapper클래스가 있다.

 

직렬화를 할때는 getter를 통해 필드 값을 알아내고 값을 내보낸다. 즉 getter가 필요하다.

(맴버변수가 public 이라면 그냥 알아낼수 있다. 하지만 private으로 설정하는것이 일반적이니,,)

또 get~~() 처럼 getter 메서드 네이밍 규약을 통해 필드명을 알아내기 때문에 필드명과 다른 getter를 만들면 의도와는 다르게 동작할 수 있다.

 

역직렬화는 json에서 값을 읽은 후 객체를 생성하고 값을 넣는 방식으로 동작한다.

먼저 값을 읽는 과정에서는 getter혹은 setter를 통해 값을 필드명을 알아내고 값을 읽는다. 직렬화 할때와 마찬가지지만, 직렬화는 필드에 저장된 값을 내보내야 하기때문에 getter가 필요했지만, 역직렬화 과정에서는 필드명만 알아내면 되기때문에 setter만 있어도 가능하다. 물론 필요없는 setter보다는 getter를 사용하는게 좋다.

다음으로 데이터를 객체에 바인딩하는 과정은 리플렉션의 필드 접근 방식을 이용한다,

(리플렉션은 생성자의 인자정보를 가져올 수 없기 때문에 기본 생성자를 사용한다는 내용으로 설명하는 블로그들이 많은데, Java8 이전까지는 그랬지만, Java8부터 가능하다고 합니다. 기본 생성자가 아닌 경우 매핑하는데 어려움이 생길 수 있기 때문에(파라미터 개수,이름, 내부로직 등) 기본생성자를 사용한다고 추측합니다.)

기본생성자를 통해 객체를 생성하고 마찬가지로 리플렉션을 통해 필드값을 주입해준다. 즉 기본생성자가 필요하고, setter는 없어도 괜찮다.

기본 생성자를 만들고 싶지 않다면 @jsonProperty를 사용하여 필드 정보를 명시해주는 방법도 가능하다.

 

+Spring boot 2.x 이상 버전에서 기본적으로 jackson-module-parameter-names 모듈이 포함되는데, 이 녀석은 기본생성자가 없을때 인자가 있는 생성자를 찾아서 역직렬화를 할 수 있도록 해준단다. 그럼 2.x 이상에서 사용할때 기본생성자 없이도 오류가 안떠야하는건데 나는 왜 뜨지,, 일단 중요한건 아니기 때문에 넘어가자.

 


다음으로 dto와 entity를 매핑시킬때 많이 사용하는 modelMapper.

map(source,destination) 메소드를 통해 소스 객체를 목적 객체로 매핑해준다.

내부적인 동작과정은 메서드 접근 방식이 기본방식으로, 소스 객체의 getter를 사용하여 값을 읽어오고, 목적 객체의 기본 생성자로 객체를 만든 뒤에 setter를 사용하여 값을 할당해준다.

즉 소스에는 getter가, 목적에는 기본생성자와 setter가 필요하다.

위의 objectMapper와 비슷한데 setter가 필요한 이유는 내부적인 구현을 그렇게 해뒀기떄문이다. setter 없이 리플렉션을 통해 필드 접근 방식도 사용할 수있다.

이렇게 하고싶을 때는 아래의 코드처럼 mapper의 설정을 바꿔주면 가능하며, 이 때는 setter가 없어도 괜찮다.

modelMapper.getConfiguration()
        .setFieldAccessLevel(Configuration.AccessLevel.PRIVATE)
        .setFieldMatchingEnabled(true) // 이름이 동일한 필드에 접근하고 값을 변환할 수 있음.
 

좀더 정확히 내부 코드를 따라가면서 구현방식을 볼까했지만,,, 너무 오래걸릴것 같아서 일단 테스트를 통해 좀 더 알아보려한다.

테스트 환경은 아래와 같다.

 

먼저 리플렉션에 관한 테스트이다.

package com.example.demo;
public class Dto {
    private String name;
    private int age;

    public Dto(){}
    public Dto(String name,int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }
    public int getAge() {
        return age;
    }
    public void setName(String name) {
        this.name = name;
    }
    public void setAge(int age) {
        this.age = age;
    }
}
 

우선 Dto라는 클래스를 만들어준다.

기본 생성자와, 매개변수 생성자 두개를 만들어 준다.

    @Test
    public void reflectionsTest(){
        Class<Dto> dtoClass = Dto.class;
        Constructor<?>[] constructors = dtoClass.getConstructors();

        for(Constructor constructor:constructors){
            Arrays.stream(constructor.getParameters()).forEach(parameter -> System.out.println("parameter.getName() = " + parameter.getName()));
            System.out.println("---------");
        }
    }
 

 

상황 1. 생성자의 파라미터 가져오기

역시 파라미터가 있는 생성자의 파라미터 값도 잘 가져오는걸 보면 리플렉션이 매개변수를 못찾는다는건 틀린 정보다.

 

public class Dto {
    private String name;
    private int age;
    public Dto(){}
}

    @Test
    public void reflectionsTest2() throws NoSuchMethodException {
        Class<Dto> dtoClass = Dto.class;
        Arrays.stream(dtoClass.getDeclaredFields()).forEach(field -> System.out.println("field = " + field.getName()));

    }
 

상황2. getter,setter삭제

위에서 getter,setter를 이용해서 필드명을 알아온다는 애들이 있었으니 일단 주석처리했다.

리플렉션을 통해 필드명도 잘 가져온다.

 

참고로 getFields()를 사용하면 private을 가져 올 수 없기 때문에 getDeclaredFields() 키워드를 사용해야 한다.

modelMapper의 내부구조를 잘 모르겠지만 .setFieldAccessLevel(Configuration.AccessLevel.PRIVATE)로 권한을 주면 가능한걸 볼때 추측할 수 있는건 이것과 관련된거 아닐까?

아래에서 해볼텐데 리플렉션을 사용해 private필드에 값을 주입할때도 setAccessible(true);을 해줘야하는데 modelMapper의 설정에서 권한을 주면 setAccessible(true) 해준다던지..

 

상황3. getter,setter없이 리플렉션으로 객체를 생성하고, 값을 주입해보자

public class Dto {
    private String name;
    private int age;

    public Dto(){}
}

    @Test
    public void reflectionsTest3() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        Class<Dto> dtoClass = Dto.class;
        Dto dto = dtoClass.getDeclaredConstructor().newInstance();
        Field[] fields = dtoClass.getDeclaredFields();
        Arrays.stream(fields).forEach(field -> {
            field.setAccessible(true);  // getDeclared처럼 private한 필드에 접근해 값을 주기 위한 권한설정
            Type T = field.getType();
            try {
                if (T.getTypeName().equals("java.lang.String")) {
                    field.set(dto, "1");
                }else{
                    field.set(dto,1);
                }
            } catch (IllegalAccessException e) {
                throw new RuntimeException(e);
            }
        });
        System.out.println(dto);
    }
 

필드에 값을 넣는 부분이 좀 이상하긴한데, 필드를 찾아서 타입에 맞는 값을 넣도록 대충 짜봤다..

            Class<?> fieldType = field.getType();
            try {
                Supplier<?> valueSupplier = map.get(fieldType);
                field.set(dto, valueSupplier.get());
            } catch (IllegalAccessException e) {
                throw new RuntimeException(e);
            }
 

실제 매핑과정에서는 들어오는 값은 가지고 있는 상태일테니까,

미리 map에 자료형 : 값 형식으로 지정해서 들어올 값을 만들어두고 위의 코드처럼 타입이 일치하는 곳에 값을 넣어줄 수도 있다.

 

아무튼

정상적으로 객체가 생성되고 값도 주입된다.


다음으로 objectMapper

 

객체 -> json으로 직렬화 하는 과정이다.

    @Test
    public void objectMapperTest() throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        Dto dto = new Dto("test",1);
        String json = objectMapper.writeValueAsString(dto);
        System.out.println(json);
}
 

상황 1번.

public class Dto {
    private String name;
    private int age;
    public Dto(){}
    public Dto(String name,int age) {
        this.name = name;
        this.age = age;
    }
    public String getName() {
        return name;
    }
    public int getAge() {
        return age;
    }
}
 

기본생성자와 setter 모두 있다.

성공.

 

상황 2번.

getter를 주석처리하고 생성자만 존재.

에러가 뜬다.

 

상황 3번.

마찬가지로 getter를 주석처리하고, 대신 필드를 public으로 줘본다.

.

성공.

 

여기서 [직렬화 과정에서는 무조건 getter를 사용한다기 보다는, 값을 가져와야하는데 필드 접근 권한이 막혀있을 경우 getter를 찾는다.] 를 알 수있다. 생각해보니 당연한거 같다.ㅋㅋ

 

 

다음으로 역직렬화 과정이다.

    @Test
    public void objectMapperTest() throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
//        Dto dto = new Dto("test",1);
//        String json = objectMapper.writeValueAsString(dto);
//        System.out.println(json);

        String json = "{\"name\":\"test\",\"age\":1}";
        Dto dto2 = objectMapper.readValue(json,Dto.class);
        System.out.println("dto2.getName() = " + dto2.getName());
        System.out.println("dto2.getAge() = " + dto2.getAge());
    }
 

상황1. 기본 생성자,인자가 있는 생성자, getter,setter 모두 존재

public class Dto {
    private String name;
    private int age;
    public Dto(){}
    public Dto(String name,int age) {
        this.name = name;
        this.age = age;
    }
    public String getName() {
        return name;
    }
    public int getAge() {
        return age;
    }
    public void setName(String name) {
        this.name = name;
    }
    public void setAge(int age) {
        this.age = age;
    }
}
 

당연히 성공

 

상황2.

setter만 주석처리

성공.

 

상황3. 기본생성자만 주석처리

생성할수 없다고 한다.

-> 인자가 있는 생성자를 기본으로 사용할 수는 없다.

 

상황 4.

생성자만 존재. getter,setter 주석

필드를 찾을 수 없다고 한다.

생성은 했지만 직렬화와 마찬가지로 필드에 접근할 방법이 있어야한다.

 

 

 

modelMapper도 테스트 해봤는데 위의 내용들과 동일해서 따로 적진 않겠다.


 

728x90
반응형