스프링 데이터 JPA - 1

김강현·2023년 4월 19일
0

스프링 데이터 JPA

목록 보기
2/4

JPA -> 스프링 데이터 JPA 방식으로 진행!

세팅을 원래는 해줬어야 하나, Spring 부트가 알아서 해줌!

Repository

<MemberJpaRepository.java>

public class MemberJpaRepository {
    private final EntityManager em;

    public Member save(Member member){
        em.persist(member);
        return member;
    }

    public Member find(Long id){
        return em.find(Member.class, id);
    }

    public void delete(Member member){
        em.remove(member);
    }

    public List<Member> findAll(){
        return em.createQuery("select m from Member m", Member.class).getResultList();
    }

    public Optional<Member> findById(Long id){
        Member member = em.find(Member.class, id);
        return Optional.ofNullable(member);
    }

    public long count(){
        return em.createQuery("select count(m) from Member m", Long.class).getSingleResult();
    }
}

<MemberJpaRepositoryTest.java>

@SpringBootTest
@Transactional
@Rollback(false)
class MemberJpaRepositoryTest {

    @Autowired MemberJpaRepository memberJpaRepository;

    @Test
    public void testMember() throws Exception {
        // given
        Member member = new Member("memberA", 10);
        Member saveMember = memberJpaRepository.save(member);

        // when
        Member findMember = memberJpaRepository.find(saveMember.getId());

        // then
        assertThat(findMember.getId()).isEqualTo(member.getId());
        assertThat(findMember.getUsername()).isEqualTo(member.getUsername());
        assertThat(findMember).isEqualTo(member);
    }

    @Test
    public void basicCRUD(){
        Member memberA = new Member("memberA", 10);
        Member memberB = new Member("memberB", 12);
        memberJpaRepository.save(memberA);
        memberJpaRepository.save(memberB);

        Member findMember1 = memberJpaRepository.findById(memberA.getId()).get();
        Member findMember2 = memberJpaRepository.findById(memberB.getId()).get();

        assertThat(findMember1).isEqualTo(memberA);
        assertThat(findMember2).isEqualTo(memberB);

        List<Member> all = memberJpaRepository.findAll();
        assertThat(all.size()).isEqualTo(2);

        long count = memberJpaRepository.count();
        assertThat(count).isEqualTo(2);

        memberJpaRepository.delete(memberA);
        memberJpaRepository.delete(memberB);

        long afterCount = memberJpaRepository.count();
        assertThat(afterCount).isEqualTo(0);
    }
}

interface 로 Repository

<MemberRepository.java>

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

Test 쪽 코드에서, memberRepository
MemberJpaRepository => MemberRepository 로 바꿔주면 똑같이 작동함!

interface 만 있고, 구현한 적이 없는데 대체 어떻게?


Spring Data JPA가 로딩 시점에 알아서 프록시 객체로 구현을 해줌!

TeamRepository

<TeamJpaRepository.java>

@Repository
@RequiredArgsConstructor
public class TeamJpaRepository {
    private final EntityManager em;

    public Team save(Team team){
        em.persist(team);
        return team;
    }

    public Team find(Long id){
        return em.find(Team.class, id);
    }

    public void delete(Team team){
        em.remove(team);
    }

    public List<Team> findAll(){
        return em.createQuery("select t from Team t", Team.class).getResultList();
    }

    public Optional<Team> findById(Long id){
        Team team = em.find(Team.class, id);
        return Optional.ofNullable(team);
    }

    public long count(){
        return em.createQuery("select count(t) from Team t", Long.class).getSingleResult();
    }
}

<TeamRepository.java>

public interface TeamRepository extends JpaRepository<Team, Long> {
}

Team 의 경우도 바꿔줄 수 있다!

JpaRepository<___, ___>
Type 이 중요하다! 그것에 따라 내부 동작 메소드들이 결정되니깐!

기본적으로 사용할 것 같은 메소드들은 다 구현되어 있으니, CRUD 코드 노가다를 엄청 줄일 수 있음!! 최고!!

save, delete, findById, getOne, findAll, paging, sort 등의 메소드를 제공해준다!!
getOne의 경우 getReference 프록시 객체로 받음!!

개발자로서 상상할 수 있는 공통기능은 전부다 제공해줌!!!

공통기능 외에 다른 기능이 필요하다면!!

ex. field 값에 따른 find
List<Member> findByUsername(String username)

  • 이런걸 구현하려고 하면, 구현체에서 interface 모든 메소드를 override 해야하는데... 크흠...

이걸 해결해주기 위해!! query method 를 제공해줌!

Query Method

  • method 이름으로 Query 작성 가능!

순수하게 했다고 치면

   public List<Member> findByUsernameAndAgeGreaterThen(String username, int age){
        return em.createQuery("select m from Member m" +
                        " where m.username = :username" +
                        " and m.age > :age", Member.class
                ).setParameter("username", username)
                .setParameter("age", age)
                .getResultList();
    }

뭐 그닥 어렵지 않기는 한데, 이런거 좀 알아서 해주면 안되나?

정말 웃기게도, Spring Data JPA 가 알아서 메소드를 연결해준다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    public List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}


(IntelliJ 유료 버전에서는 이렇게 추천도 해줌!)

메소드 이름으로 쿼리를 생성!!!
https://docs.spring.io/spring-data/jpa/docs/2.7.11/reference/html/#jpa.query-methods.query-creation

엔티티 필드명이 변경된다거나, 많은 양의 쿼리 조건이 들어간다면!!
아무래도 메소드 명도 길어지고, 뭔가 유지보수가 애매해진다!!
그래서 다른 방안들이 여러 개 존재한다!!

김영한 개발자님은 한두개 정도 필드의 쿼리는 메소드 쿼리로 생성한다고 하심!
나머지는 다른 방법으로!!

덧. 오류의 순위

  • 가장 좋은 거 : 컴파일 단에서
  • 그 다음 : Tdd 돌려서 알 수 있는
  • 그 다음 : 실행을 하면서 세팅단에서 알 수 있는
  • 최악 : 고객이 버튼을 클릭해야 나오는

JPA Named Query

...
@NamedQuery(
        name="Member.findByUsername",
        query="select m from Member m where m.username = :username"
)
public class Member {
...}
    public List<Member> findByUsername(String username) {
        return em.createNamedQuery("Member.findByUsername", Member.class).setParameter("username", "aaa").getResultList();
    }

createNamedQuery 를 활용해서 사용 !!

이 방식을 Spring Data JPA 는 어떻게 도와준다는 걸까

public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
    
    @Query(name = "Member.findByUsername")
    List<Member> findByUsername(@Param("username") String username);
}
  • @Query 를 활용하여 매칭해주고, @Param 으로 매개변수 지정!!
  • 메소드 이름이 같을 필요 x

웃긴건, @Query 어노테이션을 지워도 동작을 하게 해줌! (Spring boot)
이런 관례가 있다고함!
메소드 쿼리를 생성할때,
JpaRepository 에서 Type으로 받은 MemberMember.____ 이름의 NamedQuery 가 있는지 먼저 탐색!
없으면 메소드 쿼리로 생성!

  • 장점이 있음!!
  • 보통 jpql 상에서 오류가 뜨면, 해당 쿼리를 실행할 때 오류가 발생함!!
  • 그런데 NamedQuery 안에 있는 jpql 문은 어플리케이션 로딩 시점에 에러를 체크함!!~

    좋아 보이지만... 사실 실무에서 많이 사용되지는 않음!!
    뭔가 엔티티에 쿼리가 들어가 있는게 좀 어색...? 위의 장점을 가진 다른 기능!!!

Repository에 Query 직접 지정

public interface MemberRepository extends JpaRepository<Member, Long> {
    ...

    @Query("select m from Member m where m.username = :username and m.age = :age")
    List<Member> findUser(@Param("username") String username, @Param("age") int age);
    
    @Query("select m.username from Member m")
    List<String> findUsernameList();
}

기본 CRUD 말고 필요한 쿼리들은 @Query 를 바로 적용하여 메소드 생성!

쿼리문도 똑같이 들어가는 것을 알 수 있다!!
위의 NamedQuery 처럼 어플리케이션 로딩 시점에 에러를 체크함!!
미리 에러를 발견하고 넘길 수 있다!

DTO 직접 조회

	@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t")
    List<MemberDto> findMemberDto();

test code

	@Test
    public void findMemberDto() throws Exception {
        // given
        Member m1 = new Member("aaa", 10);
        Member m2 = new Member("bbb", 20);
        memberRepository.save(m1);
        memberRepository.save(m2);

        Team team = new Team("teamA");
        teamRepository.save(team);

        m1.changeTeam(team);

        em.flush();
        em.clear();

        // when
        List<MemberDto> memberDtoList = memberRepository.findMemberDto();

        // then
        assertThat(memberDtoList.get(0).getTeamName()).isEqualTo("teamA");
        assertThat(memberDtoList.get(0).getUsername()).isEqualTo("aaa");
        assertThat(memberDtoList.size()).isEqualTo(1);
    }

동적 Query 의 경우는? QueryDSL 로 해야한다!!

파라미터 바인딩

  • 위치기반 (사용하지 말것)
  • 이름기반

컬렉션 파라미터 바인딩

	@Query("select m from Member m where m.username in :names")
    List<Member> findByNames(@Param("names") List<String> names);

반환타입

컬렉션, 단건, 단건 Optional

public interface MemberRepository extends JpaRepository<Member, Long> {
	...
	List<Member> findListByUsername(String username);
	Member findMemberByUsername(String username);
	Optional<Member> findOptionalByUsername(String username);
}

find...ByUsername 형태의 모든 naming이 가능함!
위 형태는 반환값만 다르게 지정한 것임!

JPA 의 경우 SingleResult 메소드를 사용하면, 값이 없거나 2개 이상일때는 Exception 을 띄움
Spring Data JPA 의 경우 findMemberByUsername 의 경우

  • 없으면 null
  • 2개 이상일때는 Exception
    @Test
    public void returnType() throws Exception {
        // given
        Member m1 = new Member("aaa", 10);
        Member m2 = new Member("aaa", 20);
        memberRepository.save(m1);
        memberRepository.save(m2);

        em.flush();
        em.clear();

        // when

        List<Member> memberList = memberRepository.findListByUsername("aaa");
        Member oneMember = memberRepository.findMemberByUsername("bbb");
        Optional<Member> optionalMember = memberRepository.findOptionalByUsername("bbb");

        // then

        assertThat(memberList.size()).isEqualTo(2);
        assertThat(oneMember).isEqualTo(null);
        assertThat(optionalMember.isPresent()).isEqualTo(false);
    }

다양한 반환 타입을 지원한다!

순수 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", Member.class)
                .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();
    }

위와 같이

  • offset, limit 를 받아서 특정 요소들만 가져오고
  • 전체 페이지를 알기위해 totalCount 를 불러온다 (이때 sorting은 필요 없음)

스프링 데이터 페이징, 정렬

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

반환타입을 Page 로 지정해주고!
input 값으로 Pageable 을 받는다!!

주의 사항 : 기존에 offset, limit 를 넣었던 과는 다르게
pageNum, pageSize 를 넣는 것임!!

find, sorting, paging 쿼리를 날리고, 추가적으로 count 쿼리도 알아서 날림!
Page에 맞게 쿼리를 날려줌!!
test code

    @Test
    public void pagingTest() throws Exception {
        // given
        Member m1 = new Member("aaa", 10);
        Member m2 = new Member("bbb", 20);
        Member m3 = new Member("ccc", 10);
        Member m4 = new Member("ddd", 10);
        Member m5 = new Member("eee", 10);
        Member m6 = new Member("fff", 10);
        Member m7 = new Member("ggg", 10);
        Member m8 = new Member("hhh", 10);
        
        memberRepository.save(m1);
        ...
        memberRepository.save(m8);

        int age = 10;
        int pageOffset = 0;
        int onePageContent = 3;
        PageRequest pageRequest = PageRequest.of(
                pageOffset,
                onePageContent,
                Sort.by(Sort.Direction.DESC, "username"));

        // when
        Page<Member> memberPage = memberRepository.findByAge(age, pageRequest);
        for (Member member : memberPage.getContent()) {
            System.out.println("member = " + member);
        }

        // then
        assertThat(memberPage.getContent().get(0).getUsername()).isEqualTo("hhh");
        assertThat(memberPage.getContent().get(1).getUsername()).isEqualTo("ggg");
        assertThat(memberPage.getContent().get(2).getUsername()).isEqualTo("fff");
        assertThat(memberPage.getTotalElements()).isEqualTo(7);
        assertThat(memberPage.getNumber()).isEqualTo(pageOffset);
        assertThat(memberPage.getTotalPages()).isEqualTo(3);
        assertThat(memberPage.isFirst()).isTrue();
        assertThat(memberPage.hasNext()).isTrue();
    }

Page 가 가지고 있는 기능들 중에, totalPage 나 totalElements 이런 것들은 좀 필요 없는데 좀 더 가벼운 같은 기능은 없나? (count 쿼리를 매번 호출하는게 약간 불만일때)

Page 가 상속받고 있는 Slice 를 활용하면 됨!

Page -> Slice

  • Page 경우

  • Slice 경우

Slice 의 경우 limit 보다 하나 더 들고와서, hasNext 에 대한 대응을 한다!!

  • Page 의 경우 3개를 불러오고, Slice 는 4개를 불러온다

Slice -> List

  • 근데 Slice 까지도 필요 없어. 그냥 데이터 몇개 끊어서 가져와!! 하는 경우에는 이것도 가능

기존의 여러 메소드들을 전혀 사용할 수 없음!!

total count 의 경우 복잡한 연관관계에 맞춰 성능이 저하될 수 있음.(join 등)
사실 테이블의 한 필드값만 접근해서 개수만 세어도 되는 건데!!
해당 쿼리를 따로 간단하게 제한하는 방법이 존재!!

Team 값을 함께 가져오는 join query 를 만들었다고 하자.

잘 보면, count를 하는데도 left join 이 들어간다!
counter query 가 table join 을 할 필요는 없음!

그래서, 이런식으로 추가해서 해줄 수 있다!

countQuery 항목에 새로 넣어서, count에 복잡한 쿼리가 나가지 않도록 할 수 있음!!

덧1


외부 API 로 반환할때는 이런식으로 mapping 해서 DTO 로 보낼 수 있도록!

벌크성 수정 쿼리

순수 jpa

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


이런 쿼리를 날림!

스프링 데이터 JPA

    @Modifying // 이게 있어야 executeUpdate() 를 실행함! 필수임!
    @Query("update Member m set m.age = m.age + 1 where m.age >= :age")
    int bulkAgePlus(@Param("age") int age);
  • @Modifying 필수!!

테스트 코드

    @Test
    public void bulkUpdate() throws Exception {
        // given
        Member m1 = new Member("aaa", 10);
        Member m2 = new Member("bbb", 20);
        Member m3 = new Member("ccc", 10);
        Member m4 = new Member("ddd", 10);
        Member m5 = new Member("eee", 20);
        Member m6 = new Member("fff", 10);
        Member m7 = new Member("ggg", 10);
        Member m8 = new Member("hhh", 20);
        
        memberRepository.save(m1);
        ...
        memberRepository.save(m8);

        // when
        int resultCount = memberRepository.bulkAgePlus(15);
        System.out.println("resultCount : " + resultCount);

        // then
        assertThat(resultCount).isEqualTo(3);
    }
  • 주의해야할 사항이 있음!
  • update 벌크 연산의 경우, 영속성 컨텍스트를 거치지 않고 진행됨!
        ...
        Member m = new Member("hhh", 20);
        ...
        memberRepository.save(m);

        // when
        int resultCount = memberRepository.bulkAgePlus(15);
        System.out.println("resultCount : " + resultCount);

        Member memberH1 = memberRepository.findMemberByUsername("hhh");
        System.out.println("memberH1 : " + memberH1); // Member(id=8, username=hhh, age=20)

        em.clear();

        Member memberH2 = memberRepository.findMemberByUsername("hhh");
        System.out.println("memberH2 : " + memberH2); // Member(id=8, username=hhh, age=21)

영속성 컨텍스트를 거치치 않고 DB 업데이트 쿼리를 날리기 때문에,
업데이트 된 값을 사용하고 싶으면, clear 를 해주어야함

  • 가능하면 벌크연산 후에 EntityManager 를 flush, clear 해버려라!!
    업데이트 함수에서 먼저 flush 를 실행함!!
  • 이걸 생략하고 싶으면, @Modifying(clearAutomatically = true) 추가
    @Modifying(clearAutomatically = true)
    @Query("update Member m set m.age = m.age + 1 where m.age >= :age")
    int bulkAgePlus(@Param("age") int age);

// em.clear();

@EntityGraph

평범하게 findAll 쿼리메소드를 활용하면, lazy 같은 것들은 어떻게 해결 할까?
여기서 fetch 조인은 어떻게 해야할까!!

  • 무조건 @xToOne 관계에 있는 요소들을 N+1 문제가 있다고, fetch 조인 하는 것은 아님
  • 상황에 따라 fetch 조인 하지 않는 경우도 있음!!
	@Query("select m from Member m left join fetch m.team")
    List<Member> findMemberFetchJoin();

Query 어노테이션을 활용해서, 이와 같이 하면 된다!

그런데 이것마저 귀찮다!!!!!
@EntityGraph 로 해결!!!

public interface MemberRepository extends JpaRepository<Member, Long> {
    ...
	@Override
    @EntityGraph(attributePaths = {"team"})
    List<Member> findAll();
}

findAll 자체에 fetch join 을 @Override 하면서, @EntityGraph 설정!!

	@Query("select m from Member m left join m.team")
    @EntityGraph(attributePaths = {"team"})
    List<Member> findMemberEntityGraph();

이렇게 쓰는 것도 가능하다!!

쿼리 메소드의 경우도 @EntityGraph 를 추가하면서, fetch join 기능이 가능하도록 해줌!!

JPA Hint & Lock

  • find 하고 나서, 영속성 컨텍스트 관리 안해도 됨!!
  • 수정할 일도 없고, 삭제할 일도 없어!! 그 데이터 그대로 사용만 할거야!
  • 변경감지 이런거 필요 없어!!
    (기존)

Transaction 이 끝나는 (flush 가 일어남) 시점에 update 쿼리가 실행됨.

하지만, @QueryHints@QueryHint 를 통해 read only 라는 설정을 주게 되면

public interface MemberRepository extends JpaRepository<Member, Long> {
    ...
    @QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
    Member findReadOnlyByUsername(String username);
}

분명 setUsername을 하여, findMember 에 변경이 일어났는데 이를 체크하지 않고 업데이트 쿼리를 실행하지 않음!!
(각 엔티티마다 스냅샷을 지정해주고, 변경감지 등의 리소스를 사용하는 행위들을 하지 않도록 하여 성능을 향상 시킨다!!)

우리 서버 트래픽이 너무너무 많아서 최적화 시키는게 아닌 이상,
이 Read-only 기능은 다른 최적화들에 비해 미미하다!!
페치 조인, 페이징 등등이 훨씬 더 중요!

Lock 이라는 기능도 있는데, 동시 접근하여 수정을 하게 되면 안되므로, lock을 걸어 권한 있는 쪽만 수정하도록 하는 것!!
실무에서 꽤나 중요해 보임!!
https://velog.io/@backtony/JPA-Lock
꼭 따로 공부해야함!

profile
this too shall pass

0개의 댓글