이번 시간에는 게시글 기능 구현에 앞서서, JPA의 동작 방식으로 인한 코드의 문제점을 확인하고, 개선하는 시간을 가져보도록 하겠습니다.
문제가 된 상황은 다음과 같습니다.
우리는 지난 시간에 계층형 카테고리를 구현하고, 직접 테스트하는 시간을 가져봤습니다.
하지만 카테고리 삭제를 수행할 때의 쿼리 로그를 확인해보니, 이상한 점이 하나 있었습니다.
Hibernate: select count(*) as col_0_0_ from category category0_ where category0_.category_id=?
Hibernate: select category0_.category_id as category1_0_0_, category0_.name as name2_0_0_, category0_.parent_id as parent_i3_0_0_ from category category0_ where category0_.category_id=?
Hibernate: delete from category where category_id=?
SELECT 쿼리가 두 번 수행되고 있는 것입니다.
두 개의 SELECT 쿼리의 WHERE 문 조건을 보아하니, 유사한 동작을 수행하고 있습니다.
단지, 첫번째 쿼리는 count(*)로 조회하고, 두번째 쿼리는 컬럼 전체를 조회하는 것이었습니다.
CategoryService.delete 에서 카테고리 삭제 코드를 직접 확인해보겠습니다.
// CategoryService.java
@Transactional
public void delete(Long id) {
if(notExistsCategory(id)) throw new CategoryNotFoundException();
categoryRepository.deleteById(id);
}
private boolean notExistsCategory(Long id) {
return !categoryRepository.existsById(id);
}
CategoryRepository.exsitsById를 호출하여 카테고리가 있는지 확인한 뒤에,
CategoryRepository.deleteById를 호출하여 해당 카테고리 id 값을 통해 카테고리 삭제를 수행하는 코드입니다.
SELECT count(*) 쿼리는 existsById에 의해서 호출되는 쿼리일 것입니다.
이 쿼리로 인해 카테고리가 있는지 확인했으니,
그 다음으로는 분명 deleteById에 의해서 단일한 DELETE FROM WHERE category_id = ? 쿼리가 하나 전송될 것이라고 여겼습니다.
하지만 그 사이에서 또 다른 SELECT 쿼리가 일어나고 있던 것입니다.
deleteById가 어떤 쿼리를 생성하는지 로그를 확인해보겠습니다.
@Test
void deleteByIdTest() {
Category category = categoryRepository.save(createCategory());
clear();
System.out.println(" ========================== ");
categoryRepository.deleteById(category.getId());
clear();
}
단순히 저장된 카테고리를 삭제할 뿐입니다.
실행 결과는 다음과 같습니다.
Hibernate: insert into category (category_id, name, parent_id) values (null, ?, ?)
==========================
Hibernate: select category0_.category_id as category1_0_0_, category0_.name as name2_0_0_, category0_.parent_id as parent_i3_0_0_ from category category0_ where category0_.category_id=?
Hibernate: delete from category where category_id=?
deleteById가 SELECT와 DELETE 쿼리 두개를 생성하고 있습니다.
IDE에 힘을 빌려서 deleteById의 코드를 살펴보겠습니다.
@Transactional
@Override
public void deleteById(ID id) {
Assert.notNull(id, ID_MUST_NOT_BE_NULL);
delete(findById(id).orElseThrow(() -> new EmptyResultDataAccessException(
String.format("No %s entity with id %s exists!", entityInformation.getJavaType(), id), 1)));
}
CrudRepository의 구현체인 SimpleJpaRepository 클래스의 deleteById입니다.
deleteById는 내부적으로 findById를 수행한 뒤, delete에 조회된 결과를 인자로 넘겨주고 있었습니다.
단일한 DELETE 쿼리 하나만 생성될 것이라는 기대와는 달리, findById를 이용한 조회 작업이 함께 수행되고 있던 것입니다.
삭제해야할 데이터가 없는 경우 EmptyResultDataAccessException 예외가 발생하는 것은 알았지만, 데이터베이스에서 DELETE 쿼리의 결과로 해당 예외가 발생하는 줄 알았던 것이지, 그 전에 SELECT 쿼리로 미리 확인하는 줄은 몰랐던 것입니다.
findById는 모든 컬럼도 같이 조회하기에, exists로 존재 여부만 가볍게 확인하고자 하였는데, 오히려 의미없는 하나의 쿼리를 더 생성하고 있었습니다.
그렇다면 CategoryService.delete는 다음과 같이 수정될 수 있습니다.
// CategoryService.java
@Transactional
public void delete(Long id) {
Category category = categoryRepository.findById(id).orElseThrow(CategoryNotFoundException::new);
categoryRepository.delete(category);
}
사용되지 않는 CategoryService.nonExistsCategory 메소드는 제거해줍시다.
MemberService.delete도 수정해주겠습니다.
// MemberService.java
@Transactional
public void delete(Long id) {
Member member = memberRepository.findById(id).orElseThrow(MemberNotFoundException::new);
memberRepository.delete(member);
}
코드 변경이 있었으므로, 테스트를 수행하여 변경의 여파를 감지해보겠습니다.
CategoryServiceTest.deleteTest, CategoryServiceTest.deleteExceptionByCategoryNotFoundTest, MemberService.delete, MemberService.deleteExceptionByMemberNotFoundTest
위 네 개의 테스트가 실패하였습니다.
수정된 코드에 알맞게 테스트를 다시 작성해줍니다.
// CategoryServiceTest.java
@Test
void deleteTest() {
// given
given(categoryRepository.findById(anyLong())).willReturn(Optional.of(createCategory()));
// when
categoryService.delete(1L);
// then
verify(categoryRepository).delete(any());
}
@Test
void deleteExceptionByCategoryNotFoundTest() {
// given
given(categoryRepository.findById(anyLong())).willReturn(Optional.ofNullable(null));
// when, then
assertThatThrownBy(() -> categoryService.delete(1L)).isInstanceOf(CategoryNotFoundException.class);
}
// MemeberServiceTest.java
void deleteTest() {
// given
given(memberRepository.findById(anyLong())).willReturn(Optional.of(createMember()));
// when
memberService.delete(1L);
// then
verify(memberRepository).delete(any());
}
@Test
void deleteExceptionByMemberNotFoundTest() {
// given
given(memberRepository.findById(anyLong())).willReturn(Optional.ofNullable(null));
// when, then
assertThatThrownBy(() -> memberService.delete(1L)).isInstanceOf(MemberNotFoundException.class);
}
자세한 설명은 생략하겠습니다.
이제 다시 서버를 구동하여 삭제를 수행해보면,
Hibernate: select category0_.category_id as category1_0_0_, category0_.name as name2_0_0_, category0_.parent_id as parent_i3_0_0_ from category category0_ where category0_.category_id=?
Hibernate: delete from category where category_id=?
SELECT 쿼리는 한 건만 나가는 것을 확인할 수 있습니다.
게시글 기능을 구현하기에 앞서, JPA 동작 방식에 관한 오해를 바로 잡고, 이에 알맞게 코드를 수정해보았습니다.
* 질문 및 피드백은 환영입니다.
* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.
https://github.com/SongHeeJae/kuke-market
'Spring > 게시판 만들기' 카테고리의 다른 글
스프링부트 게시판 API 서버 만들기 (20) - 게시글 - 엔티티 설계 (0) | 2021.12.10 |
---|---|
스프링부트 게시판 API 서버 만들기 (19) - Entity Graph로 LAZY 전략에서 N + 1 문제 해결 (0) | 2021.12.08 |
스프링부트 게시판 API 서버 만들기 (17) - 게시판 - 계층형 카테고리 - 3 (4) | 2021.12.08 |
스프링부트 게시판 API 서버 만들기 (16) - 게시판 - 계층형 카테고리 - 2 (0) | 2021.12.08 |
스프링부트 게시판 API 서버 만들기 (15) - 게시판 - 계층형 카테고리 - 1 (5) | 2021.12.08 |