Jpa 의 변경감지를 통한 업데이트를 Reflection 을 이용해 해보자
어느날 친구에게서 Jpa 사용시 업데이트를 어떤 방식으로하는지 질문이 들어왔다.
jpa 를 사용하는 많은곳은 jpa 의 더티체킹 즉 변경감지를 통해 업데이트를 할 것이다.
이때 좋은 업데이트를 위한 여러가지 고려할 사항이 있다.
1. 무분별한 setter 를 사용하지 마라.
- 이는 무분별한 setter 사용으로 인해 안전하게 다루어져야할 데이터가 쉽게 변경될 수 있고, 이러한 변경점을 찾기 힘들게 되어 향후 유지보수를 어렵게 만들기 때문이다.
-> 이러한 해결점으로 entity 내에 상태변경 메서드를 만들 수 있다.
2. entity 의 상태변경이 필요할 때 적절한 메서드를 만든다.
- 이는 entity class 의 크기가 커져, 이또한 유지보수가 힘들어질 수 있다.
사실 그동안 이러한 고민을 알고는 있으나 깊게 생각하고 해결책을 찾고자하는 일을 미루었다. 허나, 최근에 필자는 필자의 친구 또한 이러한 고려사항을 고민하고 있기에 함께 나누고 더 나은 최선의 해결책이 무엇이 있을까 생각하는 시간을 가졌다.
필자는 java 의 Reflection 을 사용해서 해결해 보고자 한다.
Reflection은 다음과 같은 정보를 가져올 수 있다.
이 정보를 가져와서 객체를 생성하거나 메소드를 호출하거나 변수의 값을 변경할 수 있다.
- Class
- Constructor
- Method
- Field
위와같은 특징을 활용하여 support 객체를 만들어 사용하고자 한다.
그리고 이 support 객체와 함께 사용할 Column 어노테이션 또한 만들어 사용하자.
UpdateSuport.java
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* @author hooneats
*/
public interface UpdateSupport {
default Optional<?> updateObject(
final Optional<?> resourceObject,
final Optional<?> targetObject
) {
final var updateFieldValueMap = getUpdateMapper(
resourceObject);
readMapAndUpdateObject(targetObject, updateFieldValueMap);
return targetObject;
}
private Map<String, Optional<?>> getUpdateMapper(
final Optional<?> resourceObject) {
final var fields = resourceObject.orElseThrow(
() -> new RuntimeException("Could not update, because resourceObject is null"))
.getClass().getDeclaredFields();
return Arrays.stream(fields)
.collect(
Collectors.toMap(getEntityFieldName(), getResourceFieldValue(resourceObject)));
}
private Function<Field, String> getEntityFieldName() {
return field -> {
final var updateColumn = field.getAnnotation(UpdateColumn.class);
if (Objects.isNull(updateColumn)) {
return "";
}
return updateColumn.name();
};
}
private Function<Field, Optional<?>> getResourceFieldValue(final Optional<?> resourceObject) {
return field -> {
try {
field.setAccessible(true);
return Optional.ofNullable(field.get(resourceObject.get()));
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
};
}
private void readMapAndUpdateObject(final Optional<?> targetObject,
final Map<String, Optional<?>> updateFieldAndValueMap) {
updateFieldAndValueMap.forEach(updateObjectField(targetObject));
}
private BiConsumer<String, Optional<?>> updateObjectField(
final Optional<?> targetObject) {
final var obj =
targetObject.orElseThrow(
() -> new RuntimeException("Could not update, because targetObject is null"));
return (key, value) -> {
if (key.isBlank()) {
return;
}
value.ifPresent(v -> {
try {
final var field = obj.getClass().getDeclaredField(key);
field.setAccessible(true);
field.set(obj, v);
} catch (Exception e) {
throw new RuntimeException(
"Could not update, maybe problem is updateColumn name");
}
});
};
}
}
UpdateColumn.java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author hooneats
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UpdateColumn {
String name() default "";
}
UpdateSuport.java 는 다른 객체에서 사용시 '인터페이스에 의존하라' DIP 원칙을 지키기 위해 인터페이스로 만들었다.
인터페이스로 만들었기에 필요시 default 메서드를 오버라이딩 하여 다른 방식으로 다양하게도 사용할 수 있을 것이다.
그럼 필자가 만들어본 UpdateSuport 객체를 살펴보자
UpdateSupport 는 외부에서 사용할 수 있는 updateObject() 메서드를 가지고 있다.
updateObject() 는
- 첫번째 매개변수로 업데이트를 위해 필요한 즉 업데이트 값을 가진 객체를 받을 것이다.
-> 이 객체를 통해 @UpdateColumn 어노테이션에 명시한 name 값을 key 로 필드의 값을 value 로 맵에 담아 두번째로 받을 업데이트 대상이될 객체에 적용시킬 것이다. - 두번째 매개변수로 업데이트가 될 객체를 받는다. 그리고 최종적으로 이 객체의 정보를 변경시켜 반환하게 될 것이다.
사용하는것은 아래와 같다.
GreenEntity.java
@ToString // ToString 은 테스트의 편의를 위해 사용
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity(name = "green")
public class GreenEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "ranking_seq", nullable = false)
private Long rankingSeq;
@Column(name = "nick_name", length = 50, nullable = false, unique = true)
private String nickName;
@Column(name = "green_count", nullable = false)
private Long greenCount;
}
위와 같이 Entity 하나를 만들었다.
Entity 를 외부에 노출시켜 사용하지 않을 것이기에 외부에 노출시켜 정보를 받아올 Update 객체를 만들자
GreenUpdateDto.java
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GreenUpdateDto {
@UpdateColumn(name = "rankingSeq")
private Long seq;
@UpdateColumn(name = "greenCount")
private Long count;
@UpdateColumn(name = "nickName")
private String nickName;
}
위와같이 @UpdateColumn 을 활용해 외부에 노출시킬 데이터의 필드명과 Entity 필드명을 다르게 작성할 수 있게 했다. 이로써 Entity 에 대한 정보를 보호할 수 있고, 좀 더 다양한 필드명을 가진 Dto 를 만들어 사용할 수 있도록 했다.
이제 UpdateSupport 객체를 활용해서 로직을 작성해 보자.
TestCommit.java
@Component
@RequiredArgsConstructor
public class TestService implements UpdateSupport {
private final GreenRepository greenRepository;
@Transactional
@PostConstruct
public void insert() {
GreenEntity entity = new GreenEntity(1L, "테스트삽입", 4L);
greenRepository.save(entity);
}
@Transactional
public void update() {
GreenEntity entity = greenRepository.findById(1L).get();
GreenUpdateDto dto = new GreenUpdateDto(1L, 999L, "변경된테스트");
Object entity1 = updateObject(
Optional.of(dto), Optional.of(entity)
).get();
System.out.println("entity1 = " + entity1);
System.out.println("getClass = " + entity1.getClass());
}
}
위와 같이 @PostConstruct 를 사용해 테스트 객체를 하나 DB에 넣어주었고, update() 메서드에서 이 객체를 찾아와 update 시켜볼 것이다. 이때 TestService가 UpdateSupport 를 상속받아 updateObject() 메서드를 바로 사용할 수 있게 하였다.
그리고 이제 @Transaction 이 작동해야 Jpa 의 변경감지또한 일어날 수 있기에 update() 메서드를 다른곳에서 호출해보도록 하자.
TestController.java
@Component
@RequiredArgsConstructor
public class TestController {
private final TestService testService;
@PostConstruct
public void test() {
testService.update();
}
}
이제 어플리케이션을 띄워 결과를 확인해보자
어플리케이션이 띄워지고 첫번째로 inset() 메서드가 실행되며 insert 가 이루어진 것을 볼 수 있다.
그러고나서 update() 메서드가 실행되어 select 을 통해 entity를 가지고 왔고,
필자가 만든 updateObject() 를 통해 entity 에 값을 바꾸어 주었다. 그리고 최종적으로 트랜젝션이 끝나면서 마지막으로 변경감지 더티체킹이 발생해 update 쿼리문이 나간것을 확인할 수 있다.
예시 소스코드는 깃헙에 공유하였다.
https://github.com/Hooneats/upadateSupportExample
GitHub - Hooneats/upadateSupport
Contribute to Hooneats/upadateSupport development by creating an account on GitHub.
github.com
UpdateSupport 와 UpdateColumn 만 담은 소스또한 깃헙에 공유하였다.
https://github.com/Hooneats/update-support
GitHub - Hooneats/update-support
Contribute to Hooneats/update-support development by creating an account on GitHub.
github.com