troubleshooting

리플렉션을 이용한 객체 매핑 ( with mybatis )

코딩스토리 2024. 5. 27. 19:45

1. 접근 배경


일반적인 서비스에서는 여러 도메인 객체의 다양한 필드들을 유저나 관리자의 API 호출 등으로 수정하게 된다. 단순히 조회수 등이 추가 되는 것이 아니라 도메인의 메인 프로퍼티들이 변경된다.

 

이런것들은 보통 Restful 에서 PUT /도메인이름/{id} 같은 API 통해 수정이 되게 되는데, 구현 방식에 따라 가지 차이가 있을 있다.

 

  1. 클라이언트에서 수정요청 보낼 시, 필요한 모든 필드를 채워서 보내는 경우
    -> 이 경우에는 해당 요청을 받아낼 Request 용 DTO 등을 만들거나 해서 해당 정보를 받아온 후, 무조건 기존에 있던 데이터의 DTO 의 모든 필드를 다 assign 해버리면 된다. 변경이 되든 안되든 그 값이 다 담겨져 있기 때문.
  2. 반대로 모든 필드가 안 채워서 오는 경우
    -> 이 경우에는 바꾸고자 하는 필드인.. 예를 들어 전화번호는 새로운 번호가 담겨왔는데, 유저의 주소는 안담겨져서 null 이 올 수 있다. 이 때 null 은 기존 데이터에 반영이 되면 안된다.
  • 2번에 더해 현재 legacy 남겨진 도메인들 중에는 애초에 RequestDTO 쓰지 않고도메인 객체 자체를 Body 형태로 주고받고 있는 경우도 있었다. 그러면 어떤 필드가 수정가능한 필드이고, 어떤 필드는 수정불가 필드인지가 코드만 보고 알기 어려워진다.
  • 애초에 DTO 만들어서 도메인 객체와 분리 시켜야겠지만 현재 레거시를 들어내기 힘들다는 가정하에 기존 코드는 매 필드가 추가/수정/삭제 될 때마다 method 를 건드려야 하고 수정할 필드가 많아질수록 코드가 쭉쭉 늘어나고 길어져서 readability 도 좋지 않다.

 

2. 해결 


해결 과정 1


Java 에는 Reflection 이 있기에 class 내의 필드와 메소드 등에 접근해서 활용할 수 있게 해주는 이 Util 을 이용해 자동화 가능

 

고려사항은 2가지

  • 특정 필드만 수정(merge) 대상이어야 한다.
  • 어떤 필드는 예외적으로 null 던지면 null 바뀌어야 한다.

 

클래스의 필드를 잔뜩 가져오는 것은 리플렉션으로 해결한다면, 그 중 특정 필드만 수정 가능하다는 체크는 어떻게 할것인가?

바로 Annotation @  활용Custom annotation  만들어서 해당 필드에 달아주고, 필드들만 대상으로 merge  진행했다.

 

 

[Reflection 을 이용한 필드 접근]

Class  getDeclaredFields  통해 private field 들을 가져올 있다.

 

 

[Custom Annotation 제작 및 적용]

@Target(ElementType.FIELD)

@Retention(RetentionPolicy.RUNTIME)

public @interface Merge {

// null 필드는 무시

boolean ignoreNull() default true;

}

 

 

위와 같이 @interface를 만들면 어노테이션이 생성된다.

안에 있는 필드 ignoreNull 은 추후 어노테이션의 프로퍼티로 사용될 수 있다. null 을 무시하지 않는 케이스를 위해 만들어 두었다.

TestCase 에서  @Merge  넣어보았다.

 

테스트 코드 #1

더보기
@AllArgsConstructor
@Data
class Obj {
	private String a;
	@Merge
	private long b;
	@Merge
	private Boolean c;
	@Merge
	private String d;
}


@Test
public void mergeTest() {
	Obj obj1 = new Obj("abc", 11, false, "123");

	System.out.println(obj1);

	for(Field field : obj1.getClass().getDeclaredFields()) {
		annotation = field.getAnnotation(Merge.class);
	}
}

 

위처럼 짜서 돌려보면 annotation 값에 Merge 라는 어노테이션이 달려있을때만 해당 어노테이션 객체가 들어오게 된다.

저렇게 들어오는 필드일때만 비교해서 넣어주고, 아니면 무시하고! 하면 첫번째 요구사항이 해결된다.

 

 

머지 메소드 #1

더보기
private <T> List<String> getMergeFieldList(T targetObj) {
    List<String> mergeList = new LinkedList<>();

    try {
        for (Field field : targetObj.getClass().getDeclaredFields()) {
            ReflectionUtils.makeAccessible(field);
            Object value = field.get(targetObj);
            boolean canMerge = false;

            final Annotation annotation = field.getAnnotation(Merge.class);
            if (((Merge) annotation).ignoreNull()) {
                if (value != null) {
                    canMerge = true;
                }
            } else {
                canMerge = true;
            }

            if (canMerge) {
                mergeList.add(camelToSnake(field.getName(), false));
            }
        }

        if (mergeList.isEmpty()) {
            for (Field field : targetObj.getClass().getDeclaredFields()) {
                mergeList.add(camelToSnake(field.getName(), false));
            }
        }
    } catch (IllegalAccessException e) {
        throw new IllegalArgumentException(e.getMessage());
        log.error(e.getMessage());
    }

    return mergeList;
}

 

그러고 테이블명, 칼럼, 값들을 가져와야 합니다.

(테이블, 칼럼 -> SnakeCase, 자바 프로퍼티 -> CamelCase 것을 전제)

 

 

칼럼메소드 #1

더보기
public <T> List<String> getColumnList(T targetObj) {
    List<String> columnList = new LinkedList<>();

    for(Field field : targetObj.getClass().getDeclaredFields()) {
        String convertedFieldName = camelToSnake(field.getName(), false);
        columnList.add(convertedFieldName);
    }

    return columnList;
}

 

메소드 #1

더보기
public <T> List<List<Object>> getValueList(T targetObj, List<String> tableFieldList) {
    List<List<Object>> valueList = new ArrayList<>();
    List<Object> objectList = getTargetObjList(targetObj, tableFieldList);
    valueList.add(objectList);

    return valueList;
}


private <T> List<Object> getTargetObjList(T targetObj) {
    List<Object> objectList = new LinkedList<>();

    try {
        for (Field field : targetObj.getClass().getDeclaredFields()) {
            ReflectionUtils.makeAccessible(field);
            Object value = field.get(targetObj);
            if (value == null) {
                objectList.add(null);
            } else {
                objectList.add(field.get(targetObj).toString());
            }
        }
    } catch (IllegalAccessException e) {
        throw new IllegalArgumentException(e.getMessage());
        log.error(e.getMessage());
    }
    
    return objectList;
}

 

하지만, targetObj.getClass().getDeclaredFields()  통해서 필드를 가져오는 경우, super class  필드는 가져오지 못한다는 것을 깨달았고**(private 이기 때문에)**, 결국 merge 메소드를 사용할때 슈퍼 클래스의 필드가 머지되지 않는 오류가 있었습니다.

 

 

 

해결 과정 2


찾아보니 역시 Reflection 안에는 super class 에도 접근할 수 있는 방법이 있다.

 

Field f = b.getClass().getSuperclass().getDeclaredField("superField");

 

그런데 만약 상속이 여러번 중첩되고 한다면... recursive 하게 가져올 수 는 있겠지만 뭔가 점점 복잡해지는 상황이 온다. 

그러던 순간, 새로운 방법을 발견했다. Spring  제공해주는 Util 에서 존재하는 모든 필드를 접근해서 수행하는 메소드가 있다는 사실.

 

ReflectionUtils.doWithFields()

 

또한, 실제 테이블 칼럼을 조회해 리플렉션을 통해 가져온 필드와 비교해 일치하는 필드만 가져온다.

 

 

머지 메소드 #2

더보기
private <T> List<String> getMergeFieldList(T targetObj, List<String> excludeMergeFieldList) {
    List<String> mergeList = new LinkedList<>();

        ReflectionUtils.doWithFields(targetObj.getClass(),
            field -> {
                ReflectionUtils.makeAccessible(field);
                Object value = field.get(targetObj);
                boolean canMerge = false;

                final Annotation annotation = field.getAnnotation(Merge.class);
                if (((Merge) annotation).ignoreNull()) {
                    if (value != null) {
                        canMerge = true;
                    }
                } else {
                    canMerge = true;
                }

                if (canMerge) {
                    mergeList.add(camelToSnake(field.getName(), false));
                }
            },

            field -> {
                if (excludeMergeFieldList != null && excludeMergeFieldList.contains(field.getName())) {
                    return false;
                } else {
                    final Annotation annotation = field.getAnnotation(Merge.class);
                    return annotation != null ;
                }
            }
        );

    if (mergeList.isEmpty()) {
        ReflectionUtils.doWithFields(targetObj.getClass(),
            field -> mergeList.add(camelToSnake(field.getName(), false))
        );
    }

    return mergeList;
}

 

칼럼 메소드 #2

더보기
public <T> List<String> getColumnList(T targetObj, List<String> tableFieldList) {
    List<String> columnList = new LinkedList<>();

    ReflectionUtils.doWithFields(targetObj.getClass(),
        field -> {
            String convertedFieldName = camelToSnake(field.getName(), false);
            if(tableFieldList.contains(convertedFieldName)) {
                columnList.add(convertedFieldName);
            }
        }
    );

    if (columnList.isEmpty()) {
        throw new IllegalArgumentException("실제 테이블 칼럼과 일치하는 필드가 없습니다.");
    }

    return columnList;
}

 

메소드 #2

더보기
private <T> List<Object> getTargetObjList(T targetObj, List<String> tableFieldList) {
    List<Object> objectList = new LinkedList<>();

    ReflectionUtils.doWithFields(targetObj.getClass(),
        field -> {
            String convertedFieldName = camelToSnake(field.getName(), false);
            if(tableFieldList.contains(convertedFieldName)) {
                ReflectionUtils.makeAccessible(field);
                Object value = field.get(targetObj);
                if (value == null) {
                    objectList.add(null);
                } else {
                    objectList.add(field.get(targetObj).toString());
                }
            }
        }
    );

    if (objectList.isEmpty()) {
        throw new IllegalArgumentException("실제 테이블 칼럼과 일치하는 필드가 없습니다.");
    }

    return objectList;
}

 

그리고 기존  테스트 코드 #1 부분에 있는 Test 코드에 이어서 한가지 테스트케이스를 더 추가해봤다.

 

기존 Obj 라는 클래스를 상속받아 쓰는 ChildObj 를 만들고, 해당 클래스의 오브젝트를 2개 만들어서 머지할 때, super class 인 Obj 의 필드들도 합쳐지는지를 보는 테스트다.

사실 위의 Merge 메소드를 새롭게 수정하기 전에 이미 해당 테스트코드를 만들어서 테스트를 돌려보았고, 당연히 상속문제로 이전의 코드에선 실패했다.

 

 

테스트 코드 #2

더보기
class ChildObj extends Obj {
	@Merge
	String e ;

	public String getE() {
		return e;
	}

	public ChildObj(String a, long b, Boolean c, String d, String e) {
		super(a, b, c, d);
	}
}

@Test
public void mergeTest2() {
	ChildObj obj1 = new ChildObj("abc", 11, false, "123", "ffff");
	ChildObj obj2 = new ChildObj("def", 33, true, null, "eeee");

	System.out.println(Util.merge(obj1, obj2));
	assertEquals(obj1.getB(), obj2.getB()); // super class fields
	assertEquals(obj1.getC(), obj2.getC()); // super class fields
	assertEquals(obj1.getE(), obj2.getE()); // 원래 잘 되는 해당 클래스 정의 필드
}

 

이제 해당 Object 필드, 슈퍼 필드, 슈퍼슈퍼 필드 모두가  merge 되는 방식으로 구현이 완료되었다.

 

객체를 DB 에서 조회해 pk 기준 같은 data 가 존재하면 update, 아니면 insert 해주는 쿼리 동적 생성.

 

 

동적 쿼리

더보기
<select id="selectOne" resultType="hashmap">
    select * from ${schema}.${tableName}
    where
    <foreach collection="keyMap" item="value" index="key" separator="and">
      ${key} = #{value}
    </foreach>
</select>

<insert id="insert">
    insert into ${schema}.${tableName}
    (
    <foreach collection="columList" item="item" separator=",">
      ${item}
    </foreach>
    )
    values
    <foreach collection="valueList" item="item" separator=",">
      (
      <foreach collection="item" item="val" separator=",">
        #{val}
      </foreach>
      )
    </foreach>
</insert>

<update id="update">
    update ${schema}.${tableName}
    set
    <foreach collection="updateList" item="value" index="key" separator=",">
      ${key} = #{value}
    </foreach>
    where
    <foreach collection="keyMap" item="value" index="key" separator="and">
      ${key} = #{value}
    </foreach>
</update>

 

조회 쿼리를 공통화하다 보니 hashMap 으로 리턴하고 있어 Object 로 다시 매핑 필요.

 

 

hashMap to Object 매핑

더보기
public <T> T convertHashMapToObject(Map<Object, Object> hashMap, Class<? extends T> clazz) {
        try {
            T obj = clazz.getDeclaredConstructor().newInstance();

            for (Map.Entry<Object, Object> entry : hashMap.entrySet()) {
                String fieldName = entry.getKey().toString();
                Object value = entry.getValue();

                if(value != null) {
                    Field field = getFieldByName(obj, snakeToCamel(fieldName));
                    if(value instanceof BigDecimal bigDecimal) {
                        String fieldType = field.getType().getName();
                        if (fieldType.equals("int") || fieldType.equals("java.lang.Integer")) {
                            value = bigDecimal.intValue();
                        } else if (fieldType.equals("long") || fieldType.equals("java.lang.Long")) {
                            value = bigDecimal.longValue();
                        }
                    } else if (value instanceof LocalDateTime localDateTime) {
                        value = localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
                    }

                    ReflectionUtils.setField(field, obj, value);
                }
            }

            return obj;
        } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e){
            log.error("exception {}", e.getMessage());
            throw new RuntimeException("hashmap to object convert error");
        }

    }

 

 

 

3. 로직 개선


개선 1 - 기능 확장


하지만, 위에서 언급했듯이 RequestDTO를 통해 주고받는 경우 두 개의 object 를 던져주고 source 와 target 에서 같은 필드들의 값을 비교한 뒤, 다른 경우 머지해주는 로직이 필요하다.

 

최초 BeanUtils 활용해 BeanUtils.copyProperties(src, target) 프로퍼티를 머지해줬지만 BeanUtils.copyProperties 쓰기 위해선 target 객체에 setter 필요하고 setter 지양해야할 안티패턴이기에 아래 코드와 같이 리플렉션을 통해 머지시켜 주는 방식으로 변경했다.

 

더보기
private void copyProperties(Object sourceObj, Object targetObj) {
    ReflectionUtils.doWithFields(targetObj.getClass(),
        field -> {
            ReflectionUtils.makeAccessible(field);
            Field targetField = ReflectionUtils.findField(sourceObj.getClass(), field.getName());
            if(targetField != null) {
                ReflectionUtils.makeAccessible(targetField);
                Object newValue = ReflectionUtils.getField(targetField, sourceObj);
                field.set(targetObj, newValue);
            }
        }
    );
}

 

 

개선 2 - 복수 객체 저장 


지금껏 단일 객체만 다뤄왔으니 이제 복수 객체를 저장할 수 있는 기능이 필요하다.

 

도메인 객체를 그대로 저장하는 경우에는 기존과 크게 다를게 없지만, dto 를 통해 주고받는 경우에는 dto 개수만큼 도메인 객체를 새로 생성해줘야 한다.

 

그래서 객체 생성에 대한 책임을 팩토리 클래스를 따로 만들어 위임시켜 줬다.

또한 Domain 인터페이스를 구현한 클래스만 인자로 받을 있게 제한해두었다.

 

더보기
public Domain createDomain(Class<? extends Domain> domain)  {
    try {
        return domain.getDeclaredConstructor().newInstance();
    } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) {
        throw new IllegalArgumentException(e.getMessage());
        log.error(e.getMessage());
    }
}

 

 

개선 3 - 성능 최적화


복수 객체 저장 시, n 번 쿼리 실행 → BulkInsert 적용

insert 대상을 List 형태로 만들어 쿼리에 넘겨줌

 

 

 

 

개선 4 - 객체 비교 로직


기존에는 PK 에 해당 값을 직접 세팅해줬음 → @Id 를 활용한 PK 비교