ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA 스프링 데이터 JPA (Spring Data JPA) - 2
    DBMS/JPA 2024. 8. 13. 23:53
    728x90
    반응형

    스프링 데이터 JPA (Spring Data JPA) - 2

     

    앞선 내용들을 기반으로 페이징부터 다양한 기술들을 다뤄보겠다.

     

     

     

    1. 순수 JPA 페이징과 정렬

    다음 조건으로 페이징과 정렬을 사용하는 예제 코드를 보자.

    검색 조건: 나이가 10살

    정렬 조건: 이름으로 내림차순

    페이징 조건: 첫 번째 페이지, 페이지당 보여줄 데이터는 3건

     

     

    JPA 페이징 리포지토리 코드

    public List<Member> findByPage(int age, int offset, int limit) {
        return em.createQuery("select m from Member m where m.age = :age order by 
                m.username desc")
                        .setParameter("age", age)
                        .setFirstResult(offset)
                        .setMaxResults(limit)
                        .getResultList();
    }
    public long totalCount(int age) {
        return em.createQuery("select count(m) from Member m where m.age = :age",
                        Long.class)
                .setParameter("age", age)
                .getSingleResult();
    }

     

     

     

    2. 스프링 데이터 JPA 페이징과 정렬

    페이징과 정렬 파라미터

    org.springframework.data.domain.Sort : 정렬 기능

    org.springframework.data.domain.Pageable : 페이징 기능 (내부에 Sort 포함)

     

    특별한 반환 타입

    org.springframework.data.domain.Page : 추가 count 쿼리 결과를 포함하는 페이징 org.springframework.data.domain.Slice : 추가 count 쿼리 없이 다음 페이지만 확인 가능(내부적으 로 limit + 1조회)

    List (자바 컬렉션): 추가 count 쿼리 없이 결과만 반환

     

    페이징과 정렬 사용 예제

    Page<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용
    Slice<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용 안함
    List<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용 안함
    List<Member> findByUsername(String name, Sort sort);

     

    다음 조건으로 페이징과 정렬을 사용하는 예제 코드를 보자.

    검색 조건: 나이가 10살

    정렬 조건: 이름으로 내림차순

    페이징 조건: 첫 번째 페이지, 페이지당 보여줄 데이터는 3건

     

     

    Page 사용 예제 정의 코드

    public interface MemberRepository extends Repository<Member, Long> {
        Page<Member> findByAge(int age, Pageable pageable);
    }

     

    Page 사용 예제 실행 코드

    //페이징 조건과 정렬 조건 설정
    @Test
    public void page() throws Exception {
        //given
        memberRepository.save(new Member("member1", 10));
        memberRepository.save(new Member("member2", 10));
        memberRepository.save(new Member("member3", 10));
        memberRepository.save(new Member("member4", 10));
        memberRepository.save(new Member("member5", 10));
        //when
        PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC,
                "username"));
        Page<Member> page = memberRepository.findByAge(10, pageRequest);
        //then
        List<Member> content = page.getContent(); //조회된 데이터
        assertThat(content.size()).isEqualTo(3); //조회된 데이터 수
        assertThat(page.getTotalElements()).isEqualTo(5); //전체 데이터 수
        assertThat(page.getNumber()).isEqualTo(0); //페이지 번호
        assertThat(page.getTotalPages()).isEqualTo(2); //전체 페이지 번호
        assertThat(page.isFirst()).isTrue(); //첫번째 항목인가?
        assertThat(page.hasNext()).isTrue(); //다음 페이지가 있는가?
    }

     

    두 번째 파라미터로 받은 Pageable 은 인터페이스다.

    따라서 실제 사용할 때는 해당 인터페이스를 구현한 org.springframework.data.domain.PageRequest 객체를 사용한다.

     

    PageRequest 생성자의 첫 번째 파라미터에는 현재 페이지를, 두 번째 파라미터에는 조회할 데이터 수를 입력 한다.

    여기에 추가로 정렬 정보도 파라미터로 사용할 수 있다.

    참고로 페이지는 0부터 시작한다.

     

    주의

    Page는 1부터 시작이 아니라 0부터 시작이다

     

    Page 인터페이스 

    public interface Page<T> extends Slice<T> {
        int getTotalPages(); //전체 페이지 수
        long getTotalElements(); //전체 데이터 수
        <U> Page<U> map(Function<? super T, ? extends U> converter); //변환기
    }

     

    Slice 인터페이스 

    public interface Slice<T> extends Streamable<T> {
        int getNumber(); //현재 페이지
        int getSize(); //페이지 크기
        int getNumberOfElements(); //현재 페이지에 나올 데이터 수
        List<T> getContent(); //조회된 데이터
        boolean hasContent(); //조회된 데이터 존재 여부
        Sort getSort(); //정렬 정보
        boolean isFirst(); //현재 페이지가 첫 페이지 인지 여부
        boolean isLast(); //현재 페이지가 마지막 페이지 인지 여부
        boolean hasNext(); //다음 페이지 여부
        boolean hasPrevious(); //이전 페이지 여부
        Pageable getPageable(); //페이지 요청 정보
        Pageable nextPageable(); //다음 페이지 객체
        Pageable previousPageable();//이전 페이지 객체
        <U> Slice<U> map(Function<? super T, ? extends U> converter); //변환기
    }

     

    참고

    count 쿼리를 다음과 같이 분리할 수 있음

    @Query(value = "select m from Member m",
            countQuery = "select count(m.username) from Member m")
    Page<Member> findMemberAllCountBy(Pageable pageable);

     

    페이지를 유지하면서 엔티티를 DTO로 변환하기

    Page<Member> page = memberRepository.findByAge(10, pageRequest);
    Page<MemberDto> dtoPage = page.map(m -> new MemberDto());

     

     

     

    3. 벌크성 수정 쿼리

    JPA를 사용한 벌크성 수정 쿼리

    public int bulkAgePlus(int age) {
        int resultCount = em.createQuery(
                        "update Member m set m.age = m.age + 1" +
                                "where m.age >= :age")
                .setParameter("age", age)
                .executeUpdate();
        return resultCount;
    }

     

    스프링 데이터 JPA를 사용한 벌크성 수정 쿼리

    @Modifying
    @Query("update Member m set m.age = m.age + 1 where m.age >= :age")
    int bulkAgePlus(@Param("age") int age);

     

    벌크성 수정, 삭제 쿼리는 @Modifying 어노테이션을 사용

    사용하지 않으면 다음 예외 발생

    org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations

     

    벌크성 쿼리를 실행하고 나서 영속성 컨텍스트 초기화

    @Modifying(clearAutomatically = true) (이 옵션의 기본값은 false )

    이 옵션 없이 회원을 findById 로 다시 조회하면 영속성 컨텍스트에 과거 값이 남아서 문제가 될 수 있다.

    만약 다시 조회해야 하면 꼭 영속성 컨텍스트를 초기화 하자.

     

    참고

    벌크 연산은 영속성 컨텍스트를 무시하고 실행하기 때문에,

    영속성 컨텍스트에 있는 엔티티의 상태와 DB에 엔티티 상태가 달라질 수 있다.

     

    권장하는 방안

    1. 영속성 컨텍스트에 엔티티가 없는 상태에서 벌크 연산을 먼저 실행한다.

    2. 부득이하게 영속성 컨텍스트에 엔티티가 있으면 벌크 연산 직후 영속성 컨텍스트를 초기화 한다.

     

     

     

    4. @EntityGraph

    연관된 엔티티들을 SQL 한번에 조회하는 방법

     

    member의 team은 지연로딩 관계이다.

    따라서 다음과 같이 team의 데이터를 조회할 때 마다 쿼리가 실행된다. (N+1 문제 발생)

    @Test
    public void findMemberLazy() throws Exception {
        //given
        //member1 -> teamA
        //member2 -> teamB
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");
        teamRepository.save(teamA);
        teamRepository.save(teamB);
        memberRepository.save(new Member("member1", 10, teamA));
        memberRepository.save(new Member("member2", 20, teamB));
        em.flush();
        em.clear();
        //when
        List<Member> members = memberRepository.findAll();
        //then
        for (Member member : members) {
            member.getTeam().getName();
        }
    }

    연관된 엔티티를 한번에 조회하려면 페치 조인이 필요하다.

     

    JPQL 페치 조인

    @Query("select m from Member m left join fetch m.team")
    List<Member> findMemberFetchJoin();

    스프링 데이터 JPA는 JPA가 제공하는 엔티티 그래프 기능을 편리하게 사용하게 도와준다.

    이 기능을 사용하면 JPQL 없이 페치 조인을 사용할 수 있다. (JPQL + 엔티티 그래프도 가능)

     

    사용 형태

    //공통 메서드 오버라이드
    @Override
    @EntityGraph(attributePaths = {"team"})
    List<Member> findAll();
    
    //JPQL + 엔티티 그래프
    @EntityGraph(attributePaths = {"team"})
    @Query("select m from Member m")
    List<Member> findMemberEntityGraph();
    
    //메서드 이름으로 쿼리에서 특히 편리하다.
    @EntityGraph(attributePaths = {"team"})
    List<Member> findByUsername(String username);

     

    EntityGraph 정리

    사실상 페치 조인(FETCH JOIN)의 간편 버전 LEFT OUTER JOIN 사용

     

    NamedEntityGraph 사용 방법

    @NamedEntityGraph(name = "Member.all", attributeNodes =
    @NamedAttributeNode("team"))
    @Entity
    public class Member {} 
    
    @EntityGraph("Member.all")
    @Query("select m from Member m")
    List<Member> findMemberEntityGraph();

     

     

     

    5. JPA Hint & Lock

    5 - 1. JPA Hint

    JPA 쿼리 힌트(SQL 힌트가 아니라 JPA 구현체에게 제공하는 힌트)

     

    사용

    @QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
    Member findReadOnlyByUsername(String username);

     

    쿼리 힌트 사용 확인

    @Test
    public void queryHint() throws Exception {
        //given
        memberRepository.save(new Member("member1", 10));
        em.flush();
        em.clear();
        //when
        Member member = memberRepository.findReadOnlyByUsername("member1");
        member.setUsername("member2");
        em.flush(); //Update Query 실행X
    }

     

    쿼리 힌트 Page 추가 예제

    @QueryHints(value = { 
            @QueryHint(name = "org.hibernate.readOnly", value = "true")},
            forCounting = true)
    Page<Member> findByUsername(String name, Pageable pageable);

    org.springframework.data.jpa.repository.QueryHints 어노테이션을 사용

    forCounting

    반환 타입으로 Page 인터페이스를 적용하면 추가로 호출하는 페이징을 위한 count 쿼리도 쿼리 힌트 적용(기본값 true )

     

     

    5 - 2. Lock

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    List<Member> findByUsername(String name);

    org.springframework.data.jpa.repository.Lock 어노테이션을 사용

     

     

     

    6. 사용자 정의 리포지토리 구현

    스프링 데이터 JPA 리포지토리는 인터페이스만 정의하고 구현체는 스프링이 자동 생성

    스프링 데이터 JPA가 제공하는 인터페이스를 직접 구현하면 구현해야 하는 기능이 너무 많음

     

    다양한 이유로 인터페이스의 메서드를 직접 구현하고 싶다면?

    JPA 직접 사용( EntityManager )

    스프링 JDBC Template 사용

    MyBatis 사용

    데이터베이스 커넥션 직접 사용 등등...

    Querydsl 사용

     

    사용자 정의 인터페이스

    public interface MemberRepositoryCustom {
        List<Member> findMemberCustom();
    }

     

    사용자 정의 인터페이스 구현 클래스

    @RequiredArgsConstructor
    public class MemberRepositoryImpl implements MemberRepositoryCustom {
        private final EntityManager em;
        @Override
        public List<Member> findMemberCustom() {
            return em.createQuery("select m from Member m")
                    .getResultList();
        }
    }

     

    사용자 정의 인터페이스 상속

    public interface MemberRepository
            extends JpaRepository<Member, Long>, MemberRepositoryCustom {
    }

     

    사용자 정의 메서드 호출 코드

    List<Member> result = memberRepository.findMemberCustom(); 
    

     

    사용자 정의 구현 클래스 규칙

    리포지토리 인터페이스 이름 + Impl

    스프링 데이터 JPA가 인식해서 스프링 빈으로 등록

     

    Impl 대신 다른 이름으로 변경하고 싶으면?

     

    XML 설정

    <repositories base-package="study.datajpa.repository"
        repository-impl-postfix="Impl" />

     

    JavaConfig 설정

    @EnableJpaRepositories(basePackages = "study.datajpa.repository",
            repositoryImplementationPostfix = "Impl")

    참고

    실무에서는 주로 QueryDSL이나 SpringJdbcTemplate을 함께 사용할 때 사용자 정의 리포지토리 기능 자주 사용

     

    참고

    항상 사용자 정의 리포지토리가 필요한 것은 아니다.

    그냥 임의의 리포지토리를 만들어도 된다.

     

    예를들어 MemberQueryRepository를 인터페이스가 아닌 클래스로 만들고,

    스프링 빈으로 등록해서 그냥 직접 사용해도 된다.

    물론 이 경우 스프링 데이터 JPA와는 아무런 관계 없이 별도로 동작한다.

     

     

    사용자 정의 리포지토리 구현 최신 방식

    (참고: 강의 영상에는 없는 내용입니다.)

     

    스프링 데이터 2.x 부터는 사용자 정의 구현 클래스에 리포지토리 인터페이스 이름 + Impl 을 적용하는 대신에

    사용자 정의 인터페이스 명 + Impl 방식도 지원한다.

     

    예를 들어서 위 예제의 MemberRepositoryImpl 대신에 MemberRepositoryCustomImpl 같이 구현해도 된다.

     

    예제

    @RequiredArgsConstructor
    public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
        private final EntityManager em;
        @Override
        public List<Member> findMemberCustom() {
            return em.createQuery("select m from Member m")
                    .getResultList();
        }
    }

     

     

     

    7. Auditing

    엔티티를 생성, 변경할 때 변경한 사람과 시간을 추적하고 싶으면?

    등록일

    수정일

    등록자

    수정자

     

    7 - 1. 순수 JPA 사용

    우선 등록일, 수정일 적용

    @MappedSuperclass
    @Getter
    public class JpaBaseEntity {
        @Column(updatable = false)
        private LocalDateTime createdDate;
        private LocalDateTime updatedDate;
        @PrePersist
        public void prePersist() {
            LocalDateTime now = LocalDateTime.now();
            createdDate = now;
            updatedDate = now;
        }
        @PreUpdate
        public void preUpdate() {
            updatedDate = LocalDateTime.now();
        }
    }

     

    JPA 주요 이벤트 어노테이션

    @PrePersist

    @PostPersist

    @PreUpdate

    @PostUpdate

     

     

    7 - 2. 스프링 데이터 JPA 사용

    설정

    @EnableJpaAuditing 스프링 부트 설정 클래스에 적용해야함

    @EntityListeners(AuditingEntityListener.class) 엔티티에 적용

     

    사용 어노테이션

    @CreatedDate

    @LastModifiedDate

    @CreatedBy

    @LastModifiedBy

     

    스프링 데이터 Auditing 적용 - 등록일, 수정일, 등록자, 수정자

    @EntityListeners(AuditingEntityListener.class)
    @MappedSuperclass
    public class BaseEntity {
        @CreatedDate
        @Column(updatable = false)
        private LocalDateTime createdDate;
        @LastModifiedDate
        private LocalDateTime lastModifiedDate;
        @CreatedBy
        @Column(updatable = false)
        private String createdBy;
        @LastModifiedBy
        private String lastModifiedBy;
    }

     

    등록자, 수정자를 처리해주는 AuditorAware 스프링 빈 등록

    @EnableJpaAuditing
    @SpringBootApplication
    public class DataJpaApplication {
        public static void main(String[] args) {
            SpringApplication.run(DataJpaApplication.class, args);
        }
        @Bean
        public AuditorAware<String> auditorProvider() {
            return () -> Optional.of(UUID.randomUUID().toString());
        }
    }

    주의

    DataJpaApplication 에 @EnableJpaAuditing 도 함께 등록해야 합니다.

    실무에서는 세션 정보나, 스프링 시큐리티 로그인 정보에서 ID를 받음

     

    실무에서 대부분의 엔티티는 등록시간, 수정시간이 필요하지만, 등록자, 수정자는 없을 수도 있다.

    그래서 다음과 같이 Base 타입을 분리하고, 원하는 타입을 선택해서 상속한다.

     

    public class BaseTimeEntity {
        @CreatedDate
        @Column(updatable = false)
        private LocalDateTime createdDate;
        @LastModifiedDate
        private LocalDateTime lastModifiedDate;
    }
    public class BaseEntity extends BaseTimeEntity {
        @CreatedBy
        @Column(updatable = false)
        private String createdBy;
        @LastModifiedBy
        private String lastModifiedBy;
    }

    저장시점에 등록일, 등록자는 물론이고, 수정일, 수정자도 같은 데이터가 저장된다.

    데이터가 중복 저장되는 것 같지만,

    이렇게 해두면 변경 컬럼만 확인해도 마지막에 업데이트한 유저를 확인 할 수 있으므로 유지보수가 편리하다.

     

    이렇게 하지 않으면 변경 컬럼이 null 일때 등록 컬럼을 또 찾아야 한다.

    참고로 저장시점에 저장데이터만 입력하고 싶으면

    @EnableJpaAuditing(modifyOnCreate = false) 옵션을 사용하면 된다.

     

     

    전체 적용

    @EntityListeners(AuditingEntityListener.class) 를 생략하고

    스프링 데이터 JPA 가 제공하는 이벤트를 엔티티 전체에 적용하려면 orm.xml에 다음과 같이 등록하면 된다.

    <?xml version="1.0" encoding="UTF-8"?>
    <entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm"
                     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
                     xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence/orm
    http://xmlns.jcp.org/xml/ns/persistence/orm_2_2.xsd"
                     version="2.2">
    <persistence-unit-metadata>
      <persistence-unit-defaults>
        <entity-listeners>
          <entity-listener
                  class="org.springframework.data.jpa.domain.support.AuditingEntityListener"/>
        </entity-listeners>
      </persistence-unit-defaults>
    </persistence-unit-metadata>
    
    </entity-mappings>

     

     

     

    7. Web 확장 

    7 - 1. Web 확장 - 도메인 클래스 컨버터

    HTTP 파라미터로 넘어온 엔티티의 아이디로 엔티티 객체를 찾아서 바인딩

     

    도메인 클래스 컨버터 사용 전

    @RestController
    @RequiredArgsConstructor
    public class MemberController {
        private final MemberRepository memberRepository;
        @GetMapping("/members/{id}")
        public String findMember(@PathVariable("id") Long id) {
            Member member = memberRepository.findById(id).get();
            return member.getUsername();
        }
    }

     

    도메인 클래스 컨버터 사용 후

    @RestController
    @RequiredArgsConstructor
    public class MemberController {
        private final MemberRepository memberRepository;
        @GetMapping("/members/{id}")
        public String findMember(@PathVariable("id") Member member) {
            return member.getUsername();
        }
    }

    HTTP 요청은 회원 id 를 받지만 도메인 클래스 컨버터가 중간에 동작해서 회원 엔티티 객체를 반환

    도메인 클래스 컨버터도 리파지토리를 사용해서 엔티티를 찾음

     

    주의

    도메인 클래스 컨버터로 엔티티를 파라미터로 받으면, 이 엔티티는 단순 조회용으로만 사용해야 한다.

    (트랜 잭션이 없는 범위에서 엔티티를 조회했으므로, 엔티티를 변경해도 DB에 반영되지 않는다.)

    *별로 추천하는 방법은 아님*

     

     

     

    7 - 2. Web 확장 - 페이징과 정렬

    스프링 데이터가 제공하는 페이징과 정렬 기능을 스프링 MVC에서 편리하게 사용할 수 있다.

     

    페이징과 정렬 예제

    @GetMapping("/members")
    public Page<Member> list(Pageable pageable) {
        Page<Member> page = memberRepository.findAll(pageable);
        return page;
    }

    파라미터로 Pageable 을 받을 수 있다.

    Pageable 은 인터페이스, 실제는 org.springframework.data.domain.PageRequest 객체 생성

     

    요청 파라미터

    예) /members?page=0&size=3&sort=id,desc&sort=username,desc

    page: 현재 페이지, 0부터 시작한다.

    size: 한 페이지에 노출할 데이터 건수

    sort: 정렬 조건을 정의한다.

    예) 정렬 속성,정렬 속성...(ASC | DESC), 정렬 방향을 변경하고 싶으면 sort 파라미터 추가 ( asc 생략 가능)

     

    기본값

    글로벌 설정: 스프링 부트

    spring.data.web.pageable.default-page-size=20 /# 기본 페이지 사이즈/
    spring.data.web.pageable.max-page-size=2000 /# 최대 페이지 사이즈/

     

    개별 설정

    @RequestMapping(value = "/members_page", method = RequestMethod.GET)
    public String list(@PageableDefault(size = 12, sort = "username",
            direction = Sort.Direction.DESC) Pageable pageable) {
        ...
    }

     

    접두사

    페이징 정보가 둘 이상이면 접두사로 구분

    @Qualifier 에 접두사명 추가 "{접두사명}_xxx"

    예제: /members?member_page=0&order_page=1

    public String list(
            @Qualifier("member") Pageable memberPageable,
            @Qualifier("order") Pageable orderPageable, ...

     

     

    7 - 3. Page 내용을 DTO로 변환하기

    엔티티를 API로 노출하면 다양한 문제가 발생한다.

    그래서 엔티티를 꼭 DTO로 변환해서 반환해야 한다.

    Page는 map() 을 지원해서 내부 데이터를 다른 것으로 변경할 수 있다.

     

    Member DTO

    @Data
    public class MemberDto {
        private Long id;
        private String username;
        public MemberDto(Member m) {
            this.id = m.getId();
            this.username = m.getUsername();
        }
    }

     

    @GetMapping("/members")
    public Page<MemberDto> list(Pageable pageable) {
        Page<Member> page = memberRepository.findAll(pageable);
        Page<MemberDto> pageDto = page.map(MemberDto::new);
        return pageDto;
    }

     

    @GetMapping("/members")
    public Page<MemberDto> list(Pageable pageable) {
        return memberRepository.findAll(pageable).map(MemberDto::new);
    }

     

     

    7 - 4. Page를 1부터 시작하기

    스프링 데이터는 Page를 0부터 시작한다.

    만약 1부터 시작하려면?

     

    1. Pageable, Page를 파리미터와 응답 값으로 사용히지 않고, 직접 클래스를 만들어서 처리한다.

    그리고 직접 PageRequest(Pageable 구현체)를 생성해서 리포지토리에 넘긴다.

    물론 응답값도 Page 대신에 직접 만들어서 제공해야 한다.

     

    2. spring.data.web.pageable.one-indexed-parameters 를 true 로 설정한다.

    그런데 이 방법은 web에서 page 파라미터를 -1 처리 할 뿐이다.

    따라서 응답값인 Page 에 모두 0 페이지 인덱스를 사용하는 한계가 있다.

     

    one-indexed-parameters Page 1요청 ( http://localhost:8080/members?page=1 )

    1로 설정했지만 일부 값들은 page 기본값이 0인 경우로 맞춰진 상태

    {
        "content": [
                ...
        ],
        "pageable": {
            "offset": 0,
                "pageSize": 10,
                "pageNumber": 0 //0 인덱스
        },
        "number": 0, //0 인덱스
        "empty": false
    }

     

     

    핵심 기능들은 모두 정리했다.

    나머지 기능들은 다음 장에서 소개하겠다.

    728x90
    반응형
Designed by Tistory.