Study/Pet-Clinic-Project

[Study-6, 7주차] AOP + QueryDSL + (N+1) 문제 및 RequestDTO 수정 + JPA Auditing 사용 (regDate, modDate) - ①

soohykeee 2023. 5. 2. 17:52
728x90

 

[Study-5주차] @Query + 조회 기능 (PostMan) + 연관 관계 추가(Visit-Vet) + 더미 테스트 코드

 

[Study-5주차] @Query + 조회 기능 (PostMan) + 연관 관계 추가(Visit-Vet) + 더미 테스트 코드

[Study-4주차] MapStruct, Exception 적용 + CRUD 수정 + Controller 수정 + ResponseFormat - ② [Study-4주차] MapStruct, Exception 적용 + CRUD 수정 + Controller 수정 + ResponseFormat - ② [Study-4주차] MapStruct, Exception 적용 + CRUD 수

soohykeee.tistory.com

 


 

우리 스터디는, 대학생이 많은 관계로 5주차 이후 6주차는 시험기간으로 인해 스터디를 한주 쉬기로 했다.
그래서 이번 6,7 주차에 구현 및 공부해오기로 한 주제는 다음과 같다.

  • AOP
    • 로깅 기능 공통화
  • QueryDSL
    • 조회 기능 리팩토링
    • N+1 문제 해결
  • 기타사항
    • BaseEntity에 생성, 수정 시간 속성 추가
    • Auditing 이용해서 시간 자동화 기능

 

위의 주제들을 공부 및 구현하기로 했다. 또한 추가적으로 5주차에 각자 코드 설명을 하며, 수정이 필요한 부분이 생겼다. 

앞서, RequestDTO 쪽에서 Owner, Pet, Vet 등을 id 값을 받아와서 처리해주는 것이 아닌, entity 자체를 받아와서 처리해주는 방식으로 해주었다. 해당 방식으로 해보니, 실제 데이터를 생성해주려 할 때 복잡하기도 하고 쓸데없는 정보까지 모두 넘겨줘야 하는 번거로움이 있었다. 그래서 해당 코드를 수정해줄 것이다.

 


 

BaseEntity에 속성 추가

@MappedSuperclass
@Getter
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {

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

    @CreatedDate
    private LocalDateTime regDate;

    @LastModifiedDate
    private LocalDateTime modDate;

}

 

@CreatedDate와 @LastModifiedDate 어노테이션은 해당 필드가 엔티티가 생성될 때 자동으로 값을 할당해주는 역할을 한다. 또한 @CreatedDate와 @LastModifiedDate 어노테이션은 LocalDateTime 타입 필드에만 적용이 가능하기에. 타입을 LocalDateTime으로 해주었다.

@CreationTimestamp 는 하이버네이트 제공, @CreatedDate 는 스프링 프레임워크 제공이다.어떤것을 사용하던 상관은 없지만, 최근에 하이버네이트 애노테이션 자체를 점점 사용하지 않는 추세라고 하여 @CreatedDate를 사용해주었다.
김영한님도 이러한 이유로 @CreatedDate 사용을 더 선호한다고 한다.

@EntityListeners(AuditingEntityListener.class) 어노테이션을 작성해줘야 JPA가 엔티티가 등록, 변경될 때 자동으로 관리하는 기능인 JPA Auditing 기능을 활성화한다. 해당 어노테이션을 붙이지 않으면  null 값으로 들어가게 된다.

또한 아래와 같이 main 쪽에 @EnableJpaAuditing 도 작성해줘야 한다. 해당 어노테이션을 작성해줘야 스프링 부트에서 JPA Auditing 기능이 활성화된다. 

@SpringBootApplication
@EnableJpaAuditing
public class JshPetClinicStudyApplication {

    public static void main(String[] args) {
        SpringApplication.run(JshPetClinicStudyApplication.class, args);
    }

}

 

위와 같이 regDate, modDate를 추가해준 후, DB에 테이블을 재생성한 후에 더미데이터를 넣게 되면 아래와 같이 regDate, modDate가 추가된 것을 확인할 수 있다.

 


 

기존 코드 수정

각 RequestDTO 수정

public class PetRequestDto {

    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class CREATE {

        private String name;

        private LocalDate birthDate;

//        private Owner owner;

        private Long ownerId;

        private String type;
    }

    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class UPDATE {

        private Long petId;

        private String name;

        private LocalDate birthDate;

//        private Owner owner;

        private Long ownerId;

        private String type;
    }
}

 

우선 앞서 PetRequestDTO를 보게되면, 주석처리 해놓은 것이 기존에 사용했던 entity를 받아오는 것이다. 등록 및 수정 테스트를 하기 위해서 PostMan으로 데이터를 생성해주려 하니 id 값만 던져주면 간단하게 데이터를 생성할 수 있는데, 위처럼 Entity를 받아오게 되면 Pet 데이터를 생성해주기 위해서 Owner의 Entity에 있는 모든 정보를 기입해줘야하는 번거로움이 있었다. 그렇기에 id 값만 받아오도록 수정해주었다.

Visit에서도 마찬가지로 수정해주었다. 

public class VisitRequestDto {

    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class CREATE {

        private LocalDate visitDate;

        private String description;

//        private Pet pet;
//
//        private Vet vet;

        private Long petId;

        private Long vetId;
    }

    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class UPDATE {

        private Long visitId;

        private LocalDate visitDate;

        private String description;

//        private Pet pet;
//
//        private Vet vet;

        private Long petId;

        private Long vetId;
    }


}

 

위를 수정하며 생긴 연관 오류들은 간단하니 별도의 코드는 첨부하지 않겠다.

 


 

N+1

[Study] N+1 문제 및 해결 방법

 

[Study] N+1 문제 및 해결 방법

들어가기 앞서.. 스터디를 진행하다, N+1 문제를 직면하게 되었다. Pet과 Visit 쪽에서 조회 쿼리를 날릴 시, 레코드 개수만큼 쿼리문이 더 날라가는 것을 확인했다. 앞서 김영한님의 ORM JPA 강의와

soohykeee.tistory.com

 


 

앞서 PetRepository와 VisitRepository에서 N+1 문제를 해결해주지 않고 사용했다. 이번에는 이러한 문제를 수정해주겠다. 그전에는 아래와 같이 코드를 작성해주었다. 위와같이 작성했을 때 조회시 콘솔창을 확인해보면 N+1 문제가 발생하여 의도치 않은 쿼리가 여러개 나가는 것을 확인할 수 있다.

@Repository
public interface PetRepository extends JpaRepository<Pet, Long> {

    @Query("select p " +
            "from Pet p " +
            "where p.owner.id=:ownerId")
    List<Pet> findPetsByOwnerId(Long ownerId);

    // 테스트 코드 사용 위한 메서드
    Optional<Pet> findPetByName(String name);

}
@Repository
public interface VisitRepository extends JpaRepository<Visit, Long> {

    @Query("select v " +
            "from Visit v " +
            "where v.pet.id=:petId")
    List<Visit> findVisitsByPetId(Long petId);

    @Query("select v " +
            "from Visit v " +
            "where v.pet.owner.id=:ownerId")
    List<Visit> findVisitsByOwnerId(Long ownerId);

    @Query("select v " +
            "from Visit v " +
            "where v.vet.id=:vetId")
    List<Visit> findVisitsByVetId(Long vetId);

}

 

위와 같은 문제를 수정해주기 위해서 fetch join을 사용해주었다. 

@Repository
public interface PetRepository extends JpaRepository<Pet, Long> {

    @Query("select p " +
            "from Pet p " +
            "join fetch p.owner " +
            "where p.owner.id=:ownerId")
    List<Pet> findPetsByOwnerId(Long ownerId);

    // 테스트 코드 사용 위한 메서드
    Optional<Pet> findPetByName(String name);

}
@Repository
public interface VisitRepository extends JpaRepository<Visit, Long> {

    @Query("select v " +
            "from Visit v " +
            "join fetch v.pet " +
            "join fetch v.vet " +
            "where v.pet.id=:petId")
    List<Visit> findVisitsByPetId(Long petId);

    @Query("select v " +
            "from Visit v " +
            "join fetch v.pet.owner " +
            "where v.pet.owner.id=:ownerId")
    List<Visit> findVisitsByOwnerId(Long ownerId);

    @Query("select v " +
            "from Visit v " +
            "join fetch v.vet " +
            "join fetch v.pet " +
            "join fetch v.pet.owner " +
            "where v.vet.id=:vetId")
    List<Visit> findVisitsByVetId(Long vetId);

}

 

Visit의 경우 연관관계가 Pet 과 Vet 두개가 일대다로 연관되어 있다. 그렇기에 Fetch Join으로 두개를 다 작성해주지 않고, 하나만 작성하게 되면 N+1 문제가 여전히 발생하기에 위와 같이 작성해주었다. 

예를 들어 하나만 보면 다음과 같다. findVisitByVetId 를 PostMan 을 통해 실행해보면, 조회 시 쿼리가 기존과 다르게 join 을 통해 하나만 나가는 것을 확인할 수 있다.

 


 

QueryDSL

[Study] QueryDSL 이란?

 

[Study] QueryDSL 이란?

들어가기 앞서.. 앞서 스터디를 진행하며, @Query를 사용하여 Repository에서 쿼리를 작성해주었다. 하지만, @Query를 사용하게 된다면 SQL을 문자열 형태로 작성해주어야한다. 이렇게 되면 구문 오류를

soohykeee.tistory.com

[Study] QueryDSL - 동적 쿼리(Dynamic SQL) 사용

 

[Study] QueryDSL - 동적 쿼리(Dynamic SQL) 사용

들어가기 앞서.. QueryDSL을 학습하며, 동적쿼리에 대해 알게되었다. 또한 스터디 5주차때 다른 스터디원의 발표를 보며 동적쿼리 사용법을 보게되었다. 조회에서 findOne, findAll, findList 등등 이러한

soohykeee.tistory.com

 

 

QueryDSL을 사용해주기 위해서는 build.gradle에 의존성을 추가해줘야한다. 현재 사용하고있는 version은 다음과 같다. 3.0~ 이상부터는 QueryDSL을 추가해줄 때 이제 추가적으로 plugin 을 작성해주지 않고 하단의 의존성만 주입해주어도 실행이 된다고한다. 자세한 사항은 하단의 글을 참고해보면 될것같다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.4'
    id 'io.spring.dependency-management' version '1.1.0'
}
    // Querydsl
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"

QueryDSL 설정 참고 - 인프런 질문 글 (김영한님 답변) (1)

QueryDSL 설정 참고 - 인프런 질문 글 (김영한님 답변) (2)

 

의존성 추가해준 후, 실행해보면 QClass가 생성된 것을 확인할 수 있다.

 

 

QueryDslConfiguration 

package kr.co.jshpetclinicstudy.infra.config;

@Configuration
public class QueryDslConfiguration {

    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

위 설정으로 이 프로젝트에서는 어느 곳에서나 JPAQueryFactory를 주입 받아 Querydsl을 사용할 수 있게 된다. JPAQueryFactory를 사용하려면 EntityManager가 필요하고, 이를 스프링에서 DI(Dependency Injection)을 통해 주입받아야 한다. @PersistenceContext 어노테이션을 이용하여 EntityManager를 주입받고, 이를 사용하여 JPAQueryFactory를 생성하는 것이다.

위의 설정을 작성해주지 않으면 어플리케이션 구동시점에 아래와 같은 에러가 발생하게 된다.

"Parameter 0 of constructor in kr.co.jshpetclinicstudy.persistence.repository.search.OwnerSearchRepository required a bean of type 'com.querydsl.jpa.impl.JPAQueryFactory' that could not be found."

 


 

EntitySearchRepository 생성

'search' 라는 단어를 클래스명에 사용하는 것은 일종의 관례로, 이는 해당 클래스가 검색에 특화되어 있다는 것을 나타내기 위한 것이다.

Spring Data JPA에서 제공하는 기본 Repository에서 제공하는 메소드들은 한 가지 기준으로 검색하는 메소드들이 대부분이다. 예를 들어 findOne, findByName, findByNameAndTelephone, findByAddress, findByCreatedAtBetween 등이 그 예시이다. 하지만 이러한 메소드들만으로는 모든 검색 조건에 대응하기 어려울 수 있다.

따라서 search라는 단어를 클래스명에 사용하여 Spring Data JPA 기본 메소드 이외에도 더 복잡한 검색 기능을 제공하는 Repository 클래스임을 명시하는 것이다. 그러나 이는 규칙이 아니며, 클래스명에 다른 단어를 사용해도 전혀 문제가 되지 않는다. 하지만 많은 사람들이 Search를 사용해주기에 나 역시 해당 명칭으로 작성해주었다.

우선적으로 해당 클래스들을 작성하기 전, 우리는 RequestDto를 통해 검색해야 할 정보를 받아와야 하므로, 각 Entity의 RequestDto 마다 검색을 위한 CONDITION 정적 클래스를 생성해준 후, 검색이 될 파라미터를 넣어줄 것이다.

 


 

EntityRequestDto - CONDITION 추가

public class OwnerRequestDto {

    // ... 생략

    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class CONDITION {

        private List<Long> ownerIds;

        private String firstName;

        private String telephone;

    }
}
public class PetRequestDto {

    // ... 생략

    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class CONDITION {

        private List<Long> petIds;

        private String name;

        private LocalDate birthDate;

    }
}
public class VetRequestDto {

    // ... 생략

    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class CONDITION {

        private List<Long> vetIds;

        private String firstName;

    }
}
public class VisitRequestDto {

    // ... 생략

    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class CONDITION {

        private List<Long> visitIds;

        private LocalDate startDate;

        private LocalDate endDate;

    }

}

각 Entity마다 필요할 것 같은 검색 파라미터를 만들어주었다.

이제 QueryDSL을 사용하여 동적쿼리를 이용해 각 Entity의 SearchRepository를 작성해줄 것이다.

 

@Repository
@RequiredArgsConstructor
public class OwnerSearchRepository {

    private final JPAQueryFactory queryFactory;

    private final QOwner owner = QOwner.owner;

    public List<Owner> find(OwnerRequestDto.CONDITION condition) {
        return queryFactory
                .selectFrom(owner)
                .where(
                        ownerIdIn(condition.getOwnerIds()),
                        ownerFirstNameEq(condition.getFirstName()),
                        ownerTelephoneEq(condition.getTelephone())
                )
                .fetch();
    }

    private BooleanExpression ownerIdIn(List<Long> ownerIds) {
        if (CollectionUtils.isEmpty(ownerIds)) {
            return null;
        }

        return owner.id.in(ownerIds);
    }

    private BooleanExpression ownerFirstNameEq(String firstName) {
        if (!StringUtils.hasText(firstName)) {
            return null;
        }

        return owner.firstName.eq(firstName);
    }

    private BooleanExpression ownerTelephoneEq(String telephone) {
        if (!StringUtils.hasText(telephone)) {
            return null;
        }

        return owner.telephone.eq(telephone);
    }

}

 

JPAQuery를 사용할 수 있지만, JPAQuery를 생성하고 관리하는 클래스인 JPAQueryFactory를 사용할 것이다. JPAQueryFactory 를 사용하게 되면 JPAQuery를 생성하고 재사용하기 쉬워지고, 매번 생성할 때 마다 EntityManager를 전달하지 않아도 되기에 코드의 가독성과 유지보수 측면에서 좋다. 또한 성능 측면에서도 더욱 좋기에 권장된다.

QueryDSL을 사용하여 쿼리를 Build 하기 위해서는 JPAQueryFactory가 필요하다. 해당 클래스를 사용하면 EntityManager를 통해서 쿼리가 처리되고, JPQL을 사용한다.  (SQLQueryFactory를 사용하게 된다면 JDBC를 사용하여 쿼리가 처리되고 SQL을 사용한다.)

CollectionUtils.isEmpty(ownerIds) 를 사용해주지 않고, ownerIds.isEmpty() 를 사용해주게 된다면, 만약 ownerIds 파라미터 값을 적어주지 않고 보낸다면 null 오류가 나게 된다. 그렇기에 CollectionUtils를 사용해서 empty 체크를 해줘야한다.

앞서 위에서 첨부했던 동적쿼리 정리본에서 볼 수 있듯이 BooleanExpression을 사용해줬다. owner의 id, firstName, telephone을 검색 파라미터로 사용할 수 있고, 만약 해당 값이 비어있다면 null을 처리해줬다. 

Pet, Vet, Visit도 위와 마찬가지로 비슷하기에 코드는 따로 첨부하지 않겠다.

 


 

OwnerSearchRepositoryTest

package kr.co.jshpetclinicstudy.persistence.repository.search;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class OwnerSearchRepositoryTest {

    @Autowired
    private OwnerSearchRepository ownerSearchRepository;

    @Test
    public void queryDsl_OwnerSearchIds_Test() {
        //given
        List<Long> ownerIds = new ArrayList<>();
        ownerIds.add(1L);
        ownerIds.add(2L);
        ownerIds.add(3L);

        OwnerRequestDto.CONDITION dto = OwnerRequestDto.CONDITION
                .builder()
                .ownerIds(ownerIds)
                .build();

        //when
        List<Owner> result = ownerSearchRepository.find(dto);

        // then;
        assertEquals(result.size(), 3);
        assertEquals(result.get(0).getFirstName(), "수혁");

    }

    @Test
    public void queryDsl_OwnerSearchTelephone_Test() {
        //given
        OwnerRequestDto.CONDITION dto = OwnerRequestDto.CONDITION
                .builder()
                .telephone("01064564655")
                .build();

        //when
        List<Owner> result = ownerSearchRepository.find(dto);

        // then;
        assertEquals(result.get(0).getFirstName(), "수혁");

    }

    @Test
    public void queryDsl_OwnerSearchFirstNameAndTelephone_Test() {
        //given
        OwnerRequestDto.CONDITION dto = OwnerRequestDto.CONDITION
                .builder()
                .firstName("수혁")
                .telephone("01064564655")
                .build();

        //when
        List<Owner> result = ownerSearchRepository.find(dto);

        // then;
        assertEquals(result.get(0).getLastName(), "장");

    }
}

 

OwnerSearchRepository가 제대로 동작하는지, 쿼리가 원하는대로 생성이 되는지 확인해보기 위해서 간단한 Test 코드를 만들었다.

querydsl_ownerSearchIds_Test 를 보게 되면 List로 owner id 가 1L, 2L, 3L 인 값들을 파라미터로 넘겨주었다. 해당 테스트 실행 시, 파라미터가 어떻게 생성이 되는지 assert 는 만족하는지 확인해보겠다.

우리가 만들어줬던 것처럼 in(?,?,?) 으로 쿼리가 생성된것을 확인할 수 있다. telephone과 firstName 값을 적어주지 않고 넘겨도 null 값 에러가 발생하지 않고 쿼리에 포함시키지 않은것도 확인할 수 있다. 또한 assert도 만족하여 테스트가 성공하였다.

 

querydsl_ownerSearchFirstNameAndTelephone_Test () 를 실행해서 파라미터 값들이 where 절에 and로 firstName, telephone이 넘어가는지 확인해보겠다.

정상적으로 쿼리가 where 절에서 and로 firstName, telephone이 생성되어 넘어가는 것을 확인할 수 있다.

 


 

VisitSearchRepositoryTest

@Repository
@RequiredArgsConstructor
public class VisitSearchRepository {

    private final JPAQueryFactory queryFactory;

    private final QVisit visit = QVisit.visit;

    public List<Visit> find(VisitRequestDto.CONDITION condition) {
        return queryFactory
                .selectFrom(visit)
                .where(
                        visitIdIn(condition.getVisitIds()),
                        visitDateBetween(condition.getStartDate(), condition.getEndDate())
                )
                .fetch();
    }

    private BooleanExpression visitIdIn(List<Long> visitIds) {
        if (CollectionUtils.isEmpty(visitIds)) {
            return null;
        }

        return visit.id.in(visitIds);
    }

    private BooleanExpression visitDateBetween(LocalDate startDate, LocalDate endDate) {
        if (!StringUtils.hasText(startDate.toString()) && !StringUtils.hasText(endDate.toString())) {
            return null;
        }

        return visit.visitDate.between(startDate, endDate);
    }

}
package kr.co.jshpetclinicstudy.persistence.repository.search;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class VisitSearchRepositoryTest {

    @Autowired
    private VisitSearchRepository visitSearchRepository;

    @Test
    public void queryDsl_VisitSearchVisitDate_Test() {
        //given
        VisitRequestDto.CONDITION dto = VisitRequestDto.CONDITION
                .builder()
                .startDate(LocalDate.of(2023, 1, 1))
                .endDate(LocalDate.of(2023,10,1))
                .build();

        //when
        List<Visit> result = visitSearchRepository.find(dto);

        //then
        assertEquals(result.size(), 4);

    }

}

 

 


 

728x90