Java

Jpa 의 변경감지를 통한 업데이트를 Reflection 을 이용해 해보자

hooneats 2022. 10. 9. 15:22
728x90

어느날 친구에게서 Jpa 사용시 업데이트를 어떤 방식으로하는지 질문이 들어왔다.

jpa 를 사용하는 많은곳은 jpa 의 더티체킹 즉 변경감지를 통해 업데이트를 할 것이다.

이때 좋은 업데이트를 위한 여러가지 고려할 사항이 있다.

1. 무분별한 setter 를 사용하지 마라.

- 이는 무분별한 setter 사용으로 인해 안전하게 다루어져야할 데이터가 쉽게 변경될 수 있고, 이러한 변경점을 찾기 힘들게 되어 향후 유지보수를 어렵게 만들기 때문이다.

-> 이러한 해결점으로 entity 내에 상태변경 메서드를 만들 수 있다.

2. entity 의 상태변경이 필요할 때 적절한 메서드를 만든다.

- 이는 entity class 의 크기가 커져, 이또한 유지보수가 힘들어질 수 있다.

 

사실 그동안 이러한 고민을 알고는 있으나 깊게 생각하고 해결책을 찾고자하는 일을 미루었다. 허나, 최근에 필자는 필자의 친구 또한 이러한 고려사항을 고민하고 있기에 함께 나누고 더 나은 최선의 해결책이 무엇이 있을까 생각하는 시간을 가졌다.

 

필자는 java 의 Reflection 을 사용해서 해결해 보고자 한다.

Reflection은 다음과 같은 정보를 가져올 수 있다.

이 정보를 가져와서 객체를 생성하거나 메소드를 호출하거나 변수의 값을 변경할 수 있다.

  • Class
  • Constructor
  • Method
  • Field

참고 블로그

Java Reflection Baeldung

Java Set Field Value Baeldung

 

위와같은 특징을 활용하여 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() 는

 

  1. 첫번째 매개변수로 업데이트를 위해 필요한 즉 업데이트 값을 가진 객체를 받을 것이다. 
    -> 이 객체를 통해 @UpdateColumn 어노테이션에 명시한 name 값을 key 로 필드의 값을 value 로 맵에 담아 두번째로 받을 업데이트 대상이될 객체에 적용시킬 것이다.
  2. 두번째 매개변수로 업데이트가 될 객체를 받는다. 그리고 최종적으로 이 객체의 정보를 변경시켜 반환하게 될 것이다.

 

사용하는것은 아래와 같다.

 

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

 

 

 

728x90