JPA 변경 감지 사용시, 고려해 볼 만한 개선 방법

공병주(Chris)·2022년 10월 2일
1
post-thumbnail

우아한테크코스 팀 프로젝트에 알림 기능이 있었습니다. 새로운 알림 감지를 위해, 사용자가 알림을 읽으면 알림의 조회 여부를 true로 변경해주어야 했습니다.

@Service
@Transactional(readOnly = true)
public class NotificationService {

    private final NotificationRepository notificationRepository;

    //...
    
    public NotificationsResponse findNotifications(AuthInfo authInfo, Pageable pageable) {
        Slice<Notification> foundNotifications = notificationRepository
                .findNotificationsByMemberId(authInfo.getId(), pageable);
        List<Notification> notifications = foundNotifications.getContent();
        inquireNotification(notifications);

        List<NotificationResponse> notificationResponses = notifications
                .stream()
                .map(NotificationResponse::of)
                .collect(Collectors.toUnmodifiableList());
        return new NotificationsResponse(notificationResponses, foundNotifications.isLast());
    }

    private void inquireNotification(List<Notification> notifications) {
        for (Notification notification : notifications) {
            inquire(notification);
        }
    }

    private void inquire(Notification notification) {
        if (!notification.isInquired()) {
            notification.inquire();
        }
    }
    // ...
}

따라서, 아래와 같이 사용자에게 응답되는 알림에 대해 Notification 엔티티의 inquire 필드를 true로 변경하고 JPA의 변경 감지를 통해서 DB를 업데이트 해주었습니다.

@Entity
@Getter
@EntityListeners(AuditingEntityListener.class)
public class Notification {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "notification_id")
    private Long id;

    private NotificationType notificationType;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "comment_id")
    private Comment comment;

    private boolean inquired;

    // ...

    public void inquire() {
        inquired = true;
    }
}

위의 로직에 대해 아래와 같은 테스트 코드를 실행해보았습니다.

@DataJpaTest
class NotificationRepositoryTest {

    @DisplayName("알림들의 id를 받아 조회 여부를 true로 변경한다.")
    @Test
    void inquireNotificationByIds() {
        // ... notification1, notification2, notification3 선언
        // ... notification의 초기 inquire 값은 false; 
        notificationRepository.save(notification1);
        notificationRepository.save(notification2);
        notificationRepository.save(notification3);

        notificationRepository.inquireNotificationByIds(
                List.of(notification.getId(), notification2.getId(), notification3.getId()));

        List<Notification> notifications = notificationRepository
                .findNotificationsByMemberId(member1.getId(), Pageable.ofSize(3))
                .getContent();
        boolean actual = notifications.stream()
                .allMatch(Notification::isInquired);
        assertThat(actual).isTrue();
    }
}

실행 후, 쿼리문을 살펴보니 3개의 update 쿼리가 실행이 되었습니다. 변경 감지가 된 Notification 엔티티가 3개이고, 각각의 쿼리문이 실행된 것입니다.

Hibernate: 
    update
        notification 
    set
        comment_id=?,
        created_at=?,
        inquired=?,
        member_id=?,
        notification_type=?,
        post_id=? 
    where
        notification_id=?

Hibernate: 
    update
        notification 
    set
        comment_id=?,
        created_at=?,
        inquired=?,
        member_id=?,
        notification_type=?,
        post_id=? 
    where
        notification_id=?

Hibernate: 
    update
        notification 
    set
        comment_id=?,
        created_at=?,
        inquired=?,
        member_id=?,
        notification_type=?,
        post_id=? 
    where
        notification_id=?

문제점

물론 페이지네이션을 통해 알림들을 조회해오기 때문에 엄청나게 많은 양의 쿼리문이 실행되지는 않겠지만,
조회되는 알림의 개수만큼의 쿼리문이 실행이 됩니다.

JPQL 사용

아래와 같이 where 조건문에 조회된 Notification들의 id를 대입하여 한번에 update를 처리하도록 구현해보았습니다.

public interface NotificationRepository extends JpaRepository<Notification, Long> {

    // ...

    @Modifying
    @Query(value = "UPDATE Notification n SET n.inquired = true WHERE n.id in :ids")
    void inquireNotificationByIds(List<Long> ids);
}
@Service
@Transactional(readOnly = true)
public class NotificationService {

    private final NotificationRepository notificationRepository;

    //...
    @Transactional
    public NotificationsResponse findNotifications(AuthInfo authInfo, Pageable pageable) {
        Slice<Notification> foundNotifications = notificationRepository
                .findNotificationsByMemberId(authInfo.getId(), pageable);
        List<Notification> notifications = foundNotifications.getContent();
        if (foundNotifications.hasContent()) {
            inquireNotifications(notifications);
        }
        return generateNotifications(notifications, foundNotifications.isLast());
    }

    private void inquireNotifications(List<Notification> notifications) {
        List<Long> inquiredNotificationIds = notifications.stream()
                .map(Notification::getId)
                .collect(Collectors.toUnmodifiableList());
        notificationRepository.inquireNotificationByIds(inquiredNotificationIds);
    }

    private NotificationsResponse generateNotifications(List<Notification> notifications, boolean isLastPage) {
        List<NotificationResponse> notificationResponses = notifications
                .stream()
                .map(NotificationResponse::of)
                .collect(Collectors.toUnmodifiableList());
        return new NotificationsResponse(notificationResponses, isLastPage);
    }
}
@DataJpaTest
class NotificationRepositoryTest {

    @PersistentContext
    private EntityManager em;

    @DisplayName("알림들의 id를 받아 조회 여부를 true로 변경한다.")
    @Test
    void inquireNotificationByIds() {
        // ... notification1, notification2, notification3 선언
        // ... notification의 초기 inquire 값은 false; 
        notificationRepository.save(notification1);
        notificationRepository.save(notification2);
        notificationRepository.save(notification3);
        em.clear();
        

        notificationRepository.inquireNotificationByIds(
                List.of(notification.getId(), notification2.getId(), notification3.getId()));

        List<Notification> notifications = notificationRepository
                .findNotificationsByMemberId(member1.getId(), Pageable.ofSize(3))
                .getContent();
        boolean actual = notifications.stream()
                .allMatch(Notification::isInquired);
        assertThat(actual).isTrue();
    }
}

결론

돌아와서, 실행된 쿼리를 확인해보면 하나의 쿼리로 3개의 Notification을 update 하는 것을 확인할 수 있습니다.

Hibernate: 
    update
        notification 
    set
        inquired=true 
    where
        notification_id in (
            ? , ? , ?
        )

변경 감지라는 JPA의 기능은 Entity의 값을 변경만 해주면 별다른 작업을 해주지 않아도 DB가 업데이트 되지만, 변경된 엔티티의 개수만큼의 쿼리문이 실행된다는 점을 고려해야합니다. 따라서, where의 in을 사용해서 하나의 쿼리로 엔티티들의 값을 변경해줄 수 있다면 위와 같은 방식으로 처리하는 것이 좋아보입니다.

하지만, 이가 가능했던 이유는 항상 모든 알림들의 조회 여부를 true로 바꿔주는 로직이기 때문에 가능한 것입니다. 만약, 여러 엔티티의 필드 값이 규칙없이 중구난방으로 변경된다면 위와 같은 방법을 적용할 수 없는 것으로 보입니다. 이와 같을 때는, 지금의 제 생각으로는 하나의 엔티티에 하나의 쿼리문 실행해 변경해야 할 수 밖에 없다고 생각하기 때문에 JPA의 변경 감지의 편리성을 택하는 것이 좋아보입니다.

끗!

profile
self-motivation

2개의 댓글

comment-user-thumbnail
2022년 10월 2일

크리스 좋은 주제의 글 잘 봤습니다! 벌크 업데이트시에 영속성 컨텍스트를 초기화 해주어야 하는 이유를 보여주는 좋은 글이었어요 :) 그런데 글 내용에 작은 오류가 있는 것 같아서 댓글 남깁니다!

"JPQL은 1차 캐시를 조회하지 않는다", "findNotificationsByMemberId는 SpringDataJpa에서 생성해주는 메서드이기 때문에 1차 캐시를 조회합니다" 라고 써주셨는데요, findNotificationsByMemberId는 쿼리 메서드 기능을 이용해 생성하는 메서드이기 때문에 1차 캐시를 조회하는 것이 아닌 것 같아요!

JpaRepository가 쿼리 메서드로 생성하는 메서드들은 자동으로 적절한 JPQL을 만들어서 실행을 하게 되고, 1차 캐시에 값이 있는 지 없는지의 여부는 신경쓰지 않고 조회 쿼리가 날아갑니다! 다만 조회해 온 결과의 식별자를 확인해서 1차 캐시(영속성 컨텍스트)에 있으면 동일성 보장을 위해 1차 캐시의 데이터를 반환하게 되어 1차 캐시에서 조회하는 것과 비슷한 효과가 나게 됩니다! 그리고 이건 쿼리 메서드 기능 뿐 아니라 JPQL로 조회하는 모든 경우에 마찬가지입니다.

때문에 같은 트랜잭션 내에서 다시 해당 엔티티를 조회할 일이 있다면 "JQPL로 변경된 값을 조회할 때는 1차 캐시를 비우거나 다이렉트 DB 조회를 해야합니다."가 아니라 반드시 1차 캐시를 비워줘야 하는 상황이 더 맞는 것 같습니다 :)

1개의 답글