리플렉션을 이용한 객체 매핑 ( with mybatis )
1. 접근 배경
일반적인 서비스에서는 여러 도메인 객체의 다양한 필드들을 유저나 관리자의 API 호출 등으로 수정하게 된다. 단순히 조회수 등이 추가 되는 것이 아니라 도메인의 메인 프로퍼티들이 변경된다.
이런것들은 보통 Restful 에서 PUT /도메인이름/{id} 같은 API 를 통해 수정이 되게 되는데, 구현 방식에 따라 몇 가지 차이가 있을 수 있다.
- 클라이언트에서 수정요청 보낼 시, 필요한 모든 필드를 채워서 보내는 경우
-> 이 경우에는 해당 요청을 받아낼 Request 용 DTO 등을 만들거나 해서 해당 정보를 받아온 후, 무조건 기존에 있던 데이터의 DTO 의 모든 필드를 다 assign 해버리면 된다. 변경이 되든 안되든 그 값이 다 담겨져 있기 때문. - 반대로 모든 필드가 안 채워서 오는 경우
-> 이 경우에는 바꾸고자 하는 필드인.. 예를 들어 전화번호는 새로운 번호가 담겨왔는데, 유저의 주소는 안담겨져서 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 비교