본문 바로가기
Spring Data

JPA Entity Select에서 Update 쿼리 발생할 경우

by 향로 (기억보단 기록을) 2020. 11. 28.
반응형

JPA Entity를 단순히 조회만 하였는데도, 예상치 못하게 Update 쿼리가 발생하는 경우가 있습니다.
이를테면 다음과 같은 경우인데요.

find로 조회만 하는데,

service

다음과 같이 select와 update 쿼리가 발생 하였습니다.

select-update

신기한 것은 전체 컬럼에 대한 Update 쿼리가 발생한것입니다.
이렇게 트랜잭션 내에서 Update 쿼리가 발생하면 보통은 Dirty Checking이 발생했음을 의심해볼만 한데요.

의심이라고 말씀드리는 이유는 실제로 다른 원인이 있을수도 있기 때문입니다.

자 그럼 왜 이렇게 발생했는지 실제 예제 코드와 함께 보겠습니다.

1. 예제 코드

먼저 위에서 발생한 Entity를 비롯한 서비스 코드는 다음과 같습니다.

Order

@Getter
@NoArgsConstructor
@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String orderNo;

    @Embedded
    private Member member;

    @Convert(converter = PeriodStringConverter.class)
    private Period period;

    @Convert(converter = PayConverter.class)
    private List<Pay> pays = new ArrayList<>();

    public Order(String orderNo, Period period, Member member, Pay pay) {
        this.orderNo = orderNo;
        this.period = period;
        this.member = member;
        this.pays.add(pay);
    }
}

Pay, PayDetail

@Getter
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
public class Pay {
    private String code;
    private Long amount;
    private List<PayDetail> details = new ArrayList<>();

    public Pay(String code, Long amount, PayDetail detail) {
        this.code = code;
        this.amount = amount;
        this.details.add(detail);
    }

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    public static class PayDetail {
        private String salesType;
        private Long amount;
    }
}

Member

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Embeddable
public class Member {

    private String memberNo;
    private String memberName;
}

PayConverter

public class PayConverter implements AttributeConverter<List<Pay>, String> {
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public String convertToDatabaseColumn(List<Pay> attribute) {
        try {
            return objectMapper.writeValueAsString(attribute);
        } catch (JsonProcessingException e) {
            return null;
        }
    }

    @Override
    public List<Pay> convertToEntityAttribute(String dbData) {
        try {
            return objectMapper.readValue(dbData, new TypeReference<List<Pay>>(){});
        } catch (JsonProcessingException e) {
            return new ArrayList<>();
        }
    }
}

PeriodStringConverter

public class PeriodStringConverter implements AttributeConverter<Period, String> {

    @Override
    public String convertToDatabaseColumn(Period attribute) {
        return attribute.toString();
    }

    @Override
    public Period convertToEntityAttribute(String dbData) {
        return Period.parse( dbData );
    }
}

자 그리고 실제로 이를 호출하는 서비스 메소드는 다음과 같습니다.

@Slf4j
@RequiredArgsConstructor
@Service
public class OrderService {
    private final OrderRepository orderRepository;

    @Transactional
    public void showPayDetailAmount (String orderNo) {
        log.info(">>>>>> findAllByOrderNo");
        orderRepository.findAllByOrderNo(orderNo);
    }
}

너무나 단순한 코드이고, Update가 발생할 코드가 없기 때문에 Dirty Checking을 의심해볼만한데요.
그럼 왜 Dirty Checking이 발생하는지 확인해보겠습니다.

2. 문제 해결

먼저 Dirty Checking이 발생하는 경우를 확인해볼텐데요.

JPA의 구현체인 Hibernate를 이용할 경우 트랜잭션 범위에서 Entity를 조회할 경우 아래와 같이 조회시점의 Entity 복사본을 만들어 둡니다.

일반적으로 JPA를 쓰신다고 하면 별도의 설정을 하지 않았다면 Hibernate를 쓴다고 보시면 됩니다.

logic1

그리고 트랜잭션이 끝나는 시점에 여러가지 서비스 로직으로 원본 Entity의 변경이 있다면 조회시점에 복사해둔 복사본과 비교를 해여 다른 점이 있으면 Update쿼리를 발생시키는데요.

기본적으로 비교해서 다른게 있을때 전체 컬럼 Update가 발생합니다.
변경이 있는 컬럼에 한해서만 발생시키고 싶으시면 이전에 작성된 포스팅을 참고해주세요.

logic2

여기서 비교가 중요한데요.

여기서 컬럼 하나 하나 비교에 사용되는 Objects.equals 는 실제로 해당 객체의 equals 메소드를 호출하는데요.

equals

아시다시피 Java에서는 객체의 equals를 override하지 않으면 레퍼런스 비교를 하기 때문에 같은 값을 갖고 있더라도 신규 생성된 객체의 경우 기존 객체 비교시 false가 발생합니다.

자 그럼 설명했다시피 equals를 override 해야하는 객체형 필드는 어디일까요?
바로 Pay와 PayDetail인데요.

order

Pay 클래스를 가보시면 Pay에는 정상적으로 equals가 override 되어 있지만, PayDetail에는 equalsoverride 되어 있지 않는 것을 확인할 수 있습니다.

pay

자 그래서 PayDetail에도 equals가 override (@EqualsAndHashCode) 를 한 뒤에, 다시 테스트를 돌려보면?

select

Dirty Checking이 발생하지 않는 것을 확인할 수 있습니다.

Order 클래스를 주의 깊게 보신분들은 여기서 의아한 점이 하나 있을것입니다.

2-1. 왜 Pay에서만 발생하나?

Order 클래스를 보시면 Pay외에도 객체형 필드들이 여럿 있는데요.

  • @Embedded가 선언된 Member
  • Pay와 마찬가지로 @Convert가 선언된 Period

이들의 코드를 보시면 별도로 @EqualsAndHashCode가 선언되지 않았음을 알 수 있는데요.

member

이들에 대해서는 다음과 같은 이유 때문입니다.

  • @Embedded 로 선언된 객체는 필드가 아닌 Entity의 영역을 확대시킨 것이라, 실제 비교를 @Embedded로 선언된 객체가 아닌 @Embedded 클래스 내부의 필드들이 대상입니다.
    • 즉, Member 클래스의 equals를 사용하지않고, Member 클래스 내부에 있는 memberNo, memberName에 대해 각각 equals를 사용합니다.
  • Period 의 경우 Pay와 마찬가지로 객체형 필드이지만, 불변 객체 (Immutable types)이며, 그에 따라 equals가 이미 구현되어 있습니다.
    • 즉, 복사본이 생겨도 동일한 객체가 생성되기 때문에 이들은 Dirty Checking 대상이 아닙니다.
    • 같은 이유로 LocalDate, LocalTime 등등 역시 Dirty Checking이 발생하지 않습니다.
    • 이에 대해서는 Hibernate 공식 문서를 참고해보시면 더 많은 도움을 얻을 수 있습니다.

@OneToMany, @OneToOne, @ManyToOne등으로 선언된 객체의 경우는 Order와 마찬가지로 Entity이기 때문에 필드 비교 대상이 아닙니다.

3. 마무리

결론은 간단합니다.

  • 가변 객체 (mutable type)을 Entity 필드로 사용해야할 경우 (@Convert) 무분별한 Dirty Checking을 막기 위해 equals를 꼭 Override 해야한다.
  • 그렇지 않을 경우 단순히 조회 로직에서도 Dirty Checking이 발생한다.


반응형