뭔가 클래스 안의 내가 원하는 특정 필드들만 쭈욱 가지고와서 같은 로직으로 핸들링할 수 있는 방법이 없을까…?

안녕하세요! 강남언니 CTO, Brown 입니다. 오늘은 위 질문을 해결하려고 고생한 과정을 나눠보고자 합니다.

배경

일반적인 서비스에서는 여러 Entity의 다양한 필드들을 유저나 관리자의 API 호출등으로 수정하게 됩니다. 단순히 조회수 등이 추가 되는것이 아니라 도메인의 메인 프로퍼티들이 변경됩니다. 유저의 전화번호나 주소 부터, 병원의 이미지uri 나 설명등 과 같은.

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

  1. 클라이언트에서 수정요청 보낼 시, 필요한 모든 필드를 채워서 보내는 경우 -> 이 경우에는 해당 요청을 받아낼 Request 용 DTO 등을 만들거나 해서 해당 정보를 받아온 후, 무조건 기존에 있던 데이터의 DTO 의 모든 필드를 다 assign 해버리면 됩니다. 변경이 되든 안되든 그 값이 다 담겨져 있을 테니까요
  2. 반대로 모든 필드가 안 채워서 오는 경우 -> 이 경우에는 바꾸고자 하는 필드인.. 예를 들어 전화번호는 새로운 번호가 담겨왔는데, 유저의 주소는 안담겨져서 null 이 올 수 있습니다. 이 때 null 은 기존 데이터에 반영이 되면 안됩니다.
    • 우리의 상황은 2번에 더해 현재 legacy 로 남겨진 도메인들 중에는 애초에 RequestDTO 를 쓰지 않고, Entity 자체를 Body 형태로 주고받고 있는 경우도 있었습니다. 그러면 어떤 필드가 수정가능한 필드이고, 어떤 필드는 수정불가 필드인지가 코드만 보고 알기 어려워집니다.

As-is (issue 발생)

(애초에 DTO 만들어서 Entity 와 분리시켜야 겠지만 현재 레거시를 다 들어내기 힘들다는 가정하에)

아래 코드를 보면 특정 도메인의 필드들을 볼 수 있는데, merge 라는 메소드가 보입니다. 수정 api 를 날리면 merge 메소드를 통해서 새로운 값을 반영하도록 서비스단을 공통화 해둔 상태였습니다.

// 공통 서비스에서 엔티티 업데이트 시 사용하는 메소드 - 엔티티의 merge 메소드를 사용하고 있습니다.
public ResponseResult<T> update(long id, T t) throws CommonException {
	T oldT = getBy(id);

	boolean updated = oldT.merge(t);
	getDao().update(oldT);
	return new ResponseResult<>(updated ? "SUCCESS" : "NOT_AFFECTED", oldT);
}

public class Testdomain extends Basedomain{

	private String targetView;

	private BannerType bannerType;

	@Size(max = 10)
	@Column(length = 10)
	private String colorCode;

	@NotEmpty
	@Size(max = 100)
	@Column(length = 100)
	private String actions;

	@NotEmpty
	private String bannerImage;

	@Transient
	private String title;

	private int viewCount;

	// basedomain 에서부터 상속해서 쓰는 merge 메소드.
	// Service 단에서도 부모 service 를 만들어서 update 시 merge 메소드를 사용하게 공통화해둔 상태
	@Override
	public boolean merge(Object fromObj) throws CommonException {
		boolean updated = super.merge(fromObj);
		if(fromObj instanceof Banner) {
			Banner from = (Banner)fromObj;
			if(Util.canUpdate(this.targetView, from.targetView)) {
				this.targetView = from.targetView;
				updated = true;
			}
			if(Util.canUpdate(this.bannerType, from.bannerType)) {
				this.bannerType = from.bannerType;
				updated = true;
			}
			if(Util.canUpdate(this.colorCode, from.colorCode)) {
				this.colorCode = from.colorCode;
				updated = true;
			}
			if(Util.canUpdate(this.actions, from.actions)) {
				this.actions = from.actions;
				updated = true;
			}
			if(Util.canUpdate(this.bannerImage, from.bannerImage)) {
				this.bannerImage = from.bannerImage;
				updated = true;
			}

		}
		return updated;
	}
}

위 코드는 여러 단점이 있는데

  • 매 필드가 추가+수정+삭제 될때마다 merge method 를 건드려야 한다는 점. 추후에 아무리 클라이언트에서 값을 던져도 반영이 되지 않는 상황들이 몇번 있었는데 이걸 빼먹어서였던 적이 많았ㅠ
  • 수정할 필드가 많아질수록 코드가 쭉쭉 늘어나고 길어져서 readability 도 좋지않다.
  • 무엇보다 뭔가 개발자가 싫어하게 반복적이서.. 고쳐야될것만 같은 막 의무감을 준다.

그럼에도 불구하고, 작업하려면 모든 클래스의 필드에 직접 접근해야 다보니 쉽게 공통코드화 할 수는 없었습니다.

Kai의_코멘트는_언제나_아프지만_옳다

그러던 어느날, 당시 새로 합류한 카이의 코멘트를 보고 다시금 각성! 띠용! 이럴수는 없어! 해결을 해야겠다!!!

해결 방법 고민

하지만 Java 에는 Reflection 이 있습니다. class 내의 필드와 메소드등에 접근해서 활용할 수 있게 해주는 이 util 을 이용해서 자동화를 도전해보고 싶어졌습니다.

요구사항은 2가지

  1. 특정 필드만 수정(merge) 대상이어야 한다.
  2. 어떤 필드는 예외적으로 null 을 던지면 null 로 바뀌어야 한다 (가끔 이런 케이스가 있음)

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

바로 Annotation @ 을 활용! Custom annotation 을 만들어서 해당 필드에 달아주고, 그 필드들만 대상으로 merge 를 진행해보려 합니다.

일단 리플렉션부터 시작해봅니.

Reflection 을 이용한 필드 접근

아래는 리플렉션을 알아보기 위해 짜 본 테스트 코드

@RunWith(MockitoJUnitRunner.class)
public class TestMerge {

	@AllArgsConstructor
	@Data
	class Obj {
		// 어노테이션 가져와지는지 궁금해서 디프리케이티드 붙여봄
		@Deprecated
		private String a;

		private long b;

		private Boolean c;

		private String d;

	}

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

		System.out.println(obj1);
		// private filed 들은 `getDeclaredFields()` 를 써서 가져올 수 있습니다.
		for(Field field : obj1.getClass().getDeclaredFields()) {
			System.out.println("========");
			System.out.println(field.getType().getSimpleName());
			System.out.println(Arrays.toString(field.getAnnotations()));
			System.out.println(field.getDeclaringClass().toString());
			// private 은 accessible 을 true 로 해줘야 리플렉션으로 값 접근이 가능
			field.setAccessible(true);
			System.out.println(field.get(obj1));
		}
	}

}

Class 의 getDeclaredFields 를 통해 private field 들을 가져올 수 있고, 각 필드 메소드를 간단 소개하자면

  • getType() : 필드 타입 클래스
  • getAnnotations() : 필드에 달린 어노테이션의 클래스들
  • getDeclaringClass() : 해당 필드가 어떤 클래스에서 선언되었는지 (부모클래스일수도 있음)
  • setAccessible(Boolean b) : 프라이빗 필드는 보통 접근이 불가. true 로 해줘야 가능
  • get(Object obj) : 특정 옵젝트에서 해당 필드의 value 를 가져오기

수행 결과는 아래와 같습니다.

TestEvery.Obj(a=abc, b=11, c=false, d=123)
========
java.lang.String
[@java.lang.Deprecated()] // 어노테이션도 잘 나옴
class com.healing.beauty.TestEvery$Obj
abc // 값도 잘 나옴
========
long
[]
class com.healing.beauty.TestEvery$Obj
11
========
java.lang.Boolean
[]
class com.healing.beauty.TestEvery$Obj
false
========
java.lang.String
[]
class com.healing.beauty.TestEvery$Obj
123

자 이제 어느정도 이해가 되었으니, 어노테이션을 제작해봅니다.

Custom Annotation 제작 및 적용

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Merge {
	// null 인 필드는 무시 - 추후 자세히 설명
	boolean ignoreNull() default true;
}

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

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

다시 TestCase 로 돌아가서 이번에는 어노테이션에 @Merge 를 넣어보겠습니다.

@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.특정 필드만 수정(merge) 대상이어야 한다.

그럼 이제 두개의 object 를 던져주고 source 와 target 에서 같은 필드들의 값을 비교한 뒤, 다른 경우 머지해주는 로직이 필요합니다.

public static <T> boolean canUpdate(T to, T from) {
	if(from != null && !from.equals(to)) {
		return true;
	}
	return false;
}

두 값을 비교해서 source 값이 null 이 아닌 경우에만 equals 로 동일 체크를 해서 다르면 update 대상임을 알려줍니다. (위) 반대로 source 가 null 이어도 기존 값이 null 이 아니면 반영을 해줘야 하는 요구사항도 있었으니 그 부분을 처리하기 위한 메소드도 추가합니다. (아래)

public static <T> boolean canUpdateAlbeitNull(T to, T from) {
	if(from == null) {
		return to != null;
	}
	return !from.equals(to);
}

이렇게 처리하고 필요할때마다 @Merge(ignoreNull = false) 로 해주면 분기가 가능합니다. 두번째 요구사항도 해결방법이 보입니다.

2.어떤 필드는 예외적으로 null 을 던지면 null 로 바뀌어야 한다 (가끔 이런 케이스가 있음)

위 2가지 메소드와 reflection & annotation 을 이용해 2개의 오브젝트에서 @Merge 붙은 필드들을 찾아내 값을 변경해주는 메소드를 구성합니다.

아래에 소스별로 주석을 달아두었으니 참고해주시길.

Merge 메소드 (code#1)

/****
	* 자체 Util 클래스 안에 머지 메소드 구현
	* 두개의 머지가능한 오브젝트에서 @Merge 어노테이션을 활용해
	* 해당 필드들이 머지 가능한지 체크해서 sourceObj 의 머지가능한 값을 targetObj 로 넣어준다.
	* @param targetObj
	* @param sourceObj
	* @return
	* @throws IllegalAccessException
	*/
public static <T> boolean merge(T targetObj, T sourceObj) {

	if(targetObj.getClass() != sourceObj.getClass()) {
		throw new IllegalArgumentException("The two parameter objects should be the same class");
	}
	boolean updated = false;

	Annotation annotation;
	List<String> mergedList = new LinkedList<>();

	try {
		for(Field field : targetObj.getClass().getDeclaredFields()) {
			// Merge annotation field 체크
			annotation = field.getAnnotation(Merge.class);
			if(annotation == null) continue;

			// 각 object 에서 해당 필드 값 빼오기위해선 해줘야한다
			field.setAccessible(true);
			Object oldValue = field.get(targetObj);
			Object newValue = field.get(sourceObj);
			boolean canMerge = false;

			// Merge annotation 의 ignoreNull 프로퍼티를 이용해서 null 값 처리 분기
			if(((Merge)annotation).ignoreNull()) {
				// 기본형 ( source 필드 null 은 무시하고 두 밸류가 다른지를 체크)
				if(Util.canUpdate(oldValue, newValue)) {
					canMerge = true;
				}
			} else {
				// null 이 올라오면 null 로 변경될 수 있습니다는 가정하에 두 밸류 다른지 여부 체크
				if(Util.canUpdateAlbeitNull(oldValue, newValue)) {
					canMerge = true;
				}
			}

			// 값이 변경되는 케이스
			if(canMerge) {
				field.set(targetObj, newValue);
				updated = true;
				mergedList.add(String.format("%s : %s -> %s", field.getName(), oldValue, newValue));
			}
		}
	} catch(IllegalAccessException e) {
		logger.error("error occurs during Merge fields of "+targetObj.getClass(), e);
	}

	if(updated) {
		logger.info(String.join("\n", mergedList));
	}

	return updated;
}

이렇게 해서 완성!! 아까는 나오지 않았던 field 값을 set 해주는 메소드가 나왔는데, 대상 object 와 value 만 넣어주면 됩니다.

실제로 테스트 케이스에서 다시 시도해봅니다.

(code#2)

@AllArgsConstructor
@Data
class Obj {

	private String a;
	@Merge
	private long b;
	@Merge
	private Boolean c;
	@Merge(ignoreNull = false)
	private String d;

}

@Test
public void mergeTest() {
	Obj obj1 = new Obj("abc", 11, false, "123");
	Obj obj2 = new Obj("def", 33, null, null);
	System.out.println(obj1);
	System.out.println(obj2);

	System.out.println(Util.merge(obj1, obj2));
	System.out.println(obj1);

	assertThat(obj1.getA(), is("abc"));
	assertThat(obj1.getB(), is(33L));
	assertThat(obj1.getC(), is(false));
	assertNull(obj1.getD());
}

아래 3개(b,c,d)만 @Merge가 붙어으니 a 필드는 서로 달라도 안 바뀌어야 하며, c 는 null 무시하고 d 는 null 이 되어야 한다. 그러니 결과는 TestEvery.Obj(a=abc, b=33, c=false, d=null) 이어야 하고, Test 케이스는 모두 통과했다!!! 성공!!

이제 이 메소드를 잘 활용만 하면 끄읏!!! 아까 다시 TestDomain 클래스 로 돌아가서 바꿔준다면?

(code#3)

public class Testdomain extends Basedomain{

	private String targetView;

	@Merge // 머지 등장!!
	private BannerType bannerType;

	@Size(max = 10)
	@Column(length = 10)
	@Merge
	private String colorCode;

	@NotEmpty
	@Size(max = 100)
	@Column(length = 100)
	@Merge
	private String actions;

	@NotEmpty
	@Merge
	private String bannerImage;

	@Transient
	private String title;

	private int viewCount;

	// 아까 30여줄이 넘던 코드가 다 사라지고 이거 하나로 끝.
	// 게다가 이런 도메인 모델이 50여개 있었습니다고 치면... 무려 1500 줄이 줄어들고, 관리도 편해지게 됩니다.
	@Override
	public boolean merge(Object sourceObj) throws CommonException {
		return Util.merge(this, sourceObj);
	}
}

마치며

오래 전 부터 Reflection 으로 어떻게 해볼수 있지 않을까 상상만 하다가 그쳤었는데, Annotation 활용까지 더해서 구현을 마칠 수 있었습니다. 예상보다 막히는 부분이 없어서 빨리 끝날 수 있어서 좋았구요. (테스트 코드부터 단위별로 짜면서 시작해보았는데.. 역시 TDD 인가!?)

예전부터 구상만 하던 거였는데 새로 입사하신 분이 이 레거시 코드의 불편함을 재기해주면서 확 삘 받아서 작업에 들어갈 수 있었습니다.

역시 새로운 사람과 새로운 환경에서의 새로운 자극이, 가끔은 기존에 습관이나 패턴에 익숙해진 우리들을 깨우치고 다시 바꿔나갈 수 있게 해주는것 같아서 매우 좋은 기회였습니다. 마치 3년 묵은 변비를…. 여기까지. fin.

그러나… 충격적인 깨달음

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

해결방법 다시 고민

3번째 요구사항이 생겼습니다.

  1. 상속받은 entity class 라고 하면, 모든 super class 들의 (부모 entity) 필드도 가져와서 Merge 할게 있으면 해줘야 한다

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

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

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

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

역시 스프링 너희들은 다 계획이 있구나..?!

Access to private inherited fields via reflection in Java

Spring has a nice Utility class ReflectionUtils that does just that: it defines a method to loop over all fields of all super classes with a callback: ReflectionUtils.doWithFields()

자세한 건 위의 링크를 보시면 아시겠지만 (스택오버플로우는 사랑입니다) 결국 저 메소드를 사용해서 필터부분과 필드접근부분을 잘 짜면 해결이 가능해 보인다는 사실. 그래서 해보았습니다.

To-be Util.merge method

(code#1 → code#4)

/***
기존에 reflection 으로 짜여졌던 merge 메소드를 아래처럼 변경
**/
public static <T> boolean merge(T targetObj, T sourceObj) {
	if (sourceObj == null) {
		return false;
	}

	if(targetObj.getClass() != sourceObj.getClass()) {
		throw new IllegalArgumentException("The two parameter objects should be the same class");
	}
	//Lambda 안에서 updated 를 접근하기 위해 AtomicReference 를 사용했습니다.
	final AtomicReference<Boolean> updated = new AtomicReference<>(false);

	List<String> mergedList = new LinkedList<>();

	// 바로 이 메소드! package org.springframework.util 에 존재
	ReflectionUtils.doWithFields(targetObj.getClass(),
		field -> {
			// 필드에 어떤 작업을 매번 수행할지 정의하는 부분. 기존 코드에서와 동일하게 적용
			field.setAccessible(true);
			Object oldValue = field.get(targetObj);
			Object newValue = field.get(sourceObj);
			boolean canMerge = false;
			final Annotation annotation = field.getAnnotation(Merge.class);
			if(((Merge)annotation).ignoreNull()) {
				// 기본형 ( source 필드 null 은 무시)
				if(Util.canUpdate(oldValue, newValue)) {
					canMerge = true;
				}
			} else {
				// null 이 올라오면 null 로 변경
				if(Util.canUpdateAlbeitNull(oldValue, newValue)) {
					canMerge = true;
				}
			}

			if(canMerge) {
				field.set(targetObj, newValue);
				updated.set(true);
				mergedList.add(String.format("%s : %s -> %s", field.getName(), oldValue, newValue));
			}

		},
		field -> {
			// 애초에 위 코드를 수행할 필드를 필터해서 가져올수 있게 정의하는 부분. merge 어노테이션 있는 애들만 가져오게 함
			final Annotation annotation = field.getAnnotation(Merge.class);
			return annotation != null ;
		});

	if(updated.get()) {
		logger.info(String.join("\n", mergedList));
	}
	return updated.get();
}

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

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

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

과연 이번에는?!

(#code5 + #code2)

// #code2 부분 생략

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 되는 방식으로 구현이 완료되었습니다. 생각보다 오래 걸리긴 했지만 결국 필요한 요구사항 3가지를 모두 충족시키면서 더 고치기 쉬워지고 쓰기 편해질 수 있었던 AnnotationReflection 의 콤비네이션 스토리였습니다.

이것저것 많은걸 배울 수 있는 시간이었네요. 앞으로도 새로운 시선과 자극을 통해서 기존의 것들을 더 좋게 바꾸는 과정이 계속되는 강남언니팀이 되도록 해야겠습니다.