[러닝하이] JPA 복합 키 ID 값을 VO로 사용하는 이유 / Delete 시에 주의할 점

수박이삼촌·2024년 3월 21일
2

러닝하이

목록 보기
5/11
post-thumbnail

📋 복합 키 사용

현재 저희 러닝하이에서는 기획 당시에 즐겨찾기 테이블을 다음과 같이 설계하였습니다.

저희는 ORM을 사용하므로 JPA 연관 관계로 설명하겠습니다.
한 명의 회원은 여러 게시글을 즐겨찾기 할 수 있습니다. -> Member와 Bookmark는 1:N 단방향 연관 관계를 가집니다.
하나의 게시글은 여러 사람이 즐겨찾기 할 수 있습니다. -> Post와 Bookmark는 1:N 단방향 연관 관계를 가집니다.

🍉 Q1. 즐겨찾기 엔티티에서 별도의 key를 생성하지 않고 복합키를 사용한 이유가 무엇인가요?

즐겨찾기는 삭제 상태 칼럼을 두지 않고 자유롭게 생성/삭제가 빈번하게 발생할 것이라 예상했습니다.
별도의 key 값을 가진다면, Auto Increment로 인해 id 값의 컬럼이 별도로 지속적으로 추가되기 때문에 막대한 숫자의 불필요한 ID 값이 증가할 것이라 예상되었습니다.


📌 Bookmark

다음과 같이 엔티티를 설계했습니다. 연관 관계를 가지는 엔티티의 Id값으로 Bookmark 엔티티의 id 값을 만들고 각 객체의 연관 관계를 형성해줍니다.

🎈 Entity

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "TBL_BOOKMARK")
public class Bookmark {

    @EmbeddedId
    private BookmarkId bookmarkId;

    @MapsId(value = "memberNo")
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "MEMBER_NO")
    private Member member;


    @MapsId(value = "postNo")
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "POST_NO")
    private Post post;

    private Bookmark (BookmarkBuilder builder) {
        this.bookmarkId = builder.bookmarkId;
        this.member = builder.member;
        this.post = builder.post;
    }

    public static BookmarkBuilder builder() {
        return new BookmarkBuilder();
    }

    public static class BookmarkBuilder {
        private BookmarkId bookmarkId;
        private Member member;
        private Post post;

        public BookmarkBuilder bookmarkId(BookmarkId bookmarkId) {
            this.bookmarkId = bookmarkId;
            return this;
        }

        public BookmarkBuilder member(Member member) {
            this.member = member;
            return this;
        }

        public BookmarkBuilder post(Post post) {
            this.post = post;
            return this;
        }

        public Bookmark build() {
            return new Bookmark(this);
        }

    }

🎈 Entity ID

두 객체의 ID 값으로 동등성/동일성을 판단해야 하기 때문에 @EqualsAndHashCode를 이용해 자동 생성해줍니다. @Comment이용 시에 ID 클래스에 적어주어야 실제 DB 쿼리에 포함됩니다.

또한, Serializable 인터페이스를 상속받는 JPA 표준 스펙에 모든 엔티티는 Serializable을 구현해야 한다는 글을 보고 나서 상속 받았습니다. 자세한 이유는 다음 글에서 정리하겠습니다.

@Getter
@EqualsAndHashCode
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class   BookmarkId implements Serializable {

    @Comment("멤버 번호")
    private Long memberNo;

    @Comment("게시물 번호")
    private Long postNo;

    public static BookmarkId of (Long memberNo, Long postNo) {
        return new BookmarkId(memberNo, postNo);
    }
}

📌 복합 키 ID 값을 이용한 조회

제가 북마크 도메인 테스트 코드를 작성하던 중에 발견한 일입니다.
이전의 프로젝트들에서는 JPA 연관 관계가 아닌. 테이블 Id 값을 엔티티에 포함 시킨 도메인 간 의존성을 배제한 형식의 JPA를 사용했었는데요.. 그렇다보니 습관적으로 엔티티 삭제를 할 때 id값으로 엔티티를 조회해 온 후에 해당 엔티티를 삭제하는 방식으로 기능을 구현했었습니다.

🎈 기존 Delete Method

BookmarkService

    @Transactional
    public void deleteBookmark(Long memberNo, Long postNo) {

        Bookmark bookmark =
                bookmarkRepository.findBookmarkByBookmarkId_MemberNoAndBookmarkId_PostNo(memberNo, postNo);
        bookmarkRepository.deleteById(bookmark.getBookmarkId());
    }

BookmarkRepository

public interface BookmarkRepository extends JpaRepository<Bookmark, BookmarkId> {

    Bookmark findBookmarkByBookmarkId_MemberNoAndBookmarkId_PostNo(Long memberNo, Long postNo);
}

결과는 잘 나오고 Optional 설정을 하면 예외 처리도 가능할겁니다. 하지만, 객체 의존적으로 조회하는 것이 아닌 테이블에 의존적인 조회라고 생각됩니다.

Hibernate: 
    select
        b1_0.MEMBER_NO,
        b1_0.POST_NO,
        b1_0.member_no,
        b1_0.post_no 
    from
        tbl_bookmark b1_0 
    where
        b1_0.MEMBER_NO=? 
        and b1_0.POST_NO=?
Hibernate: 
    delete 
    from
        tbl_bookmark 
    where
        MEMBER_NO=? 
        and POST_NO=?

이번 프로젝트에서는 JPA 연관 관계를 사용하므로 복합 키 ID 내에 @EqualsAndHashCode를 통해 값을 이용하면 복합 키를 이용해 조회가 가능해집니다.

🎈 변경된 Delete Method

BookmarkService

    @Transactional
    public void deleteBookmark(Long memberNo, Long postNo) {
        Bookmark bookmark = bookmarkRepository.findById(BookmarkId.of(memberNo,postNo))
                        .orElseThrow(EntityNotFoundException::new);
        bookmarkRepository.delete(bookmark);
    }

BookmarkRepository

public interface BookmarkRepository extends JpaRepository<Bookmark, BookmarkId> {

}

findById(), delete()JpaRepository-SimpleJpaRepository에서 상속받아 사용하기 때문에 따로 구현해줄 필요가 없습니다.

Hibernate: 
    select
        b1_0.MEMBER_NO,
        b1_0.POST_NO,
        b1_0.member_no,
        b1_0.post_no 
    from
        tbl_bookmark b1_0 
    where
        b1_0.MEMBER_NO=? 
        and b1_0.POST_NO=?
Hibernate: 
    delete 
    from
        tbl_bookmark 
    where
        MEMBER_NO=? 
        and POST_NO=?

Hibernate가 작성해준 쿼리는 동일하게 나갑니다. 하지만, VO로 빼둔 복합 키의 ID는 VO의 @EqualsAndHashCode를 이용하여 기존 엔티티의 ID 객체와 동등성/동일성을 판단하여 같은 객체인 것을 판단해줍니다. 덕분에 ID 객체로 일치하는 엔티티를 조회할 수 있는 것입니다.

주의할 점은,
복합 키로 들어갈 memberNo, postNo에 null 값을 넣어주더라도 BookmarkId는 null값의 필드를 가진 객체가 생성되므로 null 체킹을 통과할 수 있게 됩니다.

📌 Delete / DeleteBy

여기서, deleteById 안에 findByIddelete가 실행되니 deleteById를 이용하면 더욱 간결해지는 것 아니냐고 의문이 드실텐데요..!!

결론부터 말씀드리면, 예외 처리 때문에 findByIDdelete를 따로 사용하였습니다. deletedeleteById 메소드는 매개 변수가 null예외를 발생시키지 않습니다.

🎈deleteById(SimpleJpaRepository)

	@Transactional
	@Override
	public void deleteById(ID id) {

		Assert.notNull(id, ID_MUST_NOT_BE_NULL);

		findById(id).ifPresent(this::delete);
	}

위처럼 deleteById에서 예외가 발생할 요소는

  • Assert.NotNull, id 값이 null이면 IllegalArgumentException 발생

    Assert.NotNull

    	public static void notNull(@Nullable Object object, String message) {
    		if (object == null) {
    			throw new IllegalArgumentException(message);
    		}
    	}

    => 복합 키 혹은 관련 없는 id 값이 들어가 있다면 가볍게 통과합니다!

  • Optional.ifPresent에 의한 NPE
    => this::delete란 action을 전달해주기 때문에 예외 발생x

입니다. findById는 왜 예외를 발생시키지 않는 지 살펴보겠습니다.

findById(SimpleJpaRepository)

	@Override
	public Optional<T> findById(ID id) {

		Assert.notNull(id, ID_MUST_NOT_BE_NULL); // id가 null이었다면, deleteById에서 미리 NPE 발생합니다.

		Class<T> domainType = getDomainClass();

		if (metadata == null) {
			return Optional.ofNullable(entityManager.find(domainType, id));
		}

		LockModeType type = metadata.getLockModeType();
		Map<String, Object> hints = getHints();

		return Optional.ofNullable(type == null ? entityManager.find(domainType, id, hints) : entityManager.find(domainType, id, type, hints));
	}

findById에서는 Optional.ofNullable을 통해서 entityManager.find 값이 null이 나온다면 NPE 대신 Optional[](Optional.empty)을 반환해줍니다.

Optional.ifPresent

    /**
     * If a value is present, performs the given action with the value,
     * otherwise does nothing.
     *
     * @param action the action to be performed, if a value is present
     * @throws NullPointerException if value is present and the given action is
     *         {@code null}
     * = value가 있는데 action이 비었다? => NPE 발생
     */
    public void ifPresent(Consumer<? super T> action) {
        if (value != null) {
            action.accept(value);
        }
    }

findById 값이 Optional로 반환되고, ifPresent로 넘어가게 됩니다. 해당 메소드는 NPE를 발생하는 조건은 유일합니다.

🚨 "value값이 존재하지만(not null) action을 넘겨주지 않았다면 NullPointException을 발생"

그 외엔,

  • value 값이 비었다면, action을 실행시키지 않고 메소드 종료.
  • value 값이 존재한다면, action을 실행.

입니다. 그렇다보니 findById에서 빈 Optional 객체를 반환해준다면 아무 일도 일어나지 않고 메소드가 종료됩니다.

🍉 따라서!!
deleteById존재하지 않는 id을 사용한다면!!
delete를 실행하지도 못하고, 예외도 발생하지 않죠.

deleteById가 예외를 발생하려면 매개변수인 id값이 null이어야 합니다.
하지만, 위처럼 복합 키 id가 모두 null이어도 ID 객체 자체는 null이 아니므로 예외가 발생하지 않습니다.

그러면 delete에선 왜 예외가 발생하지 않을까요?

🎈delete

	@Override
	@Transactional
	@SuppressWarnings("unchecked")
	public void delete(T entity) {

		Assert.notNull(entity, "Entity must not be null");

		if (entityInformation.isNew(entity)) {
			return;	// 새로운 객체야? 그냥 삭제 = db에 존재하지 않으므로
		}

		Class<?> type = ProxyUtils.getUserClass(entity);

		T existing = (T) entityManager.find(type, entityInformation.getId(entity));

		// if the entity to be deleted doesn't exist, delete is a NOOP
		if (existing == null) {
			return; // db에 존재하지 않다구? 그럼 삭제된거지.
		}

		entityManager.remove(entityManager.contains(entity) ? entity : entityManager.merge(entity));
	}
    
    /* entityInformation.isNew */
    /**
	 * Returns whether the given entity is considered to be new.
	 *
	 * @param entity must never be {@literal null}
	 * @return
	 */
	boolean isNew(T entity);
    

실질적인 삭제가 일어나는 delete메소드에선, entity의 메타데이터를 이용해 이것이 새로운 엔티티인지 / null 값은 아닌 지 체크해줍니다.

그리고 entityManager.find를 통해 다시 한번 현재 DB 상에 존재하는 Entity인 지 확인해줍니다. null일 시 NPE 발생이 아니라 메소드를 종료시킵니다.


📌 실제 테스트

deleteByIddelete의 실행이 어떻게 되는 지 자세히 알아보며 예외 발생
여기서 문제가 되는 게 복합 키를 사용한다면 memberNopostNo가 null값이나 존재하지 않는 값이라도 ID 객체는 생성되게 됩니다. 이 상태에서 deleteById 메소드에 들어가게 된다면 findById 메소드에서 반환된 Optional.empty 값으로 ifPresent에서 값이 존재하지 않아 다음 액션인 delete를 실행하지 않는 것입니다.

🎈deleteById 테스트

 	@ParameterizedTest
    @DisplayName("북마크 삭제 테스트 : 존재하지 않는 멤버나 게시글 값일 시 Hibernate에서 select만 실행시키는 지 확인")
    @CsvSource(value = {"0, 0", "null, null"}, nullValues = "null")
    void testDeleteBookmarkHibernate(Long memberNo, Long postNo) {

        bookmarkRepository.deleteById(BookmarkId.of(memberNo, postNo));
	}

결과


🎈 delete 테스트

 	@ParameterizedTest
    @DisplayName("북마크 삭제 테스트 : 존재하지 않는 멤버나 게시글 값일 시 Hibernate에서 select만 실행시키는 지 확인")
    @CsvSource(value = {"0, 0", "null, null"}, nullValues = "null")
    void testDeleteBookmarkHibernate(Long memberNo, Long postNo) {
    
        Bookmark bookmark = Bookmark.builder()
                    .bookmarkId(BookmarkId.of(memberNo, postNo))
                    .post(null)
                    .member(null)
                    .build();

        bookmarkRepository.delete(bookmark);
  }

결과

결과에서처럼 Hibernate는 select만 실행시키고 메소드를 마무리합니다.

✨ 해결 방안

deleteByIddelete의 실행이 어떻게 되는 지 자세히 알아보았습니다.

복합 키 사용 시 null 체킹을 못하기 때문에 VO 생성 당시에 필드 null 값을 체크가 필요할 것 같습니다.
또한, 제가 구현한 대로 findByIddelete를 나눠서 실행하면 예외 핸들링도 가능하기 때문에 큰 어려움 없이 해결이 가능합니다!

profile
Today I Learned

2개의 댓글

comment-user-thumbnail
2024년 3월 24일

어렵네요ㅜㅜ 잘 정리해주셔서 감사합니다....

1개의 답글