Spring Data JPA

Park sang woo·2022년 9월 16일
0

인프런 공부

목록 보기
3/13
post-thumbnail

✠ Spring Data JPA

스프링 프레임워크에서 JPA를 편리하게 사용할 수 있도록 해준다.
데이터 접근 계층을 개발할 때 반복되는 CRUD 문제를 간단한 방법으로 해결 가능하다.
CRUD를 처리하기 위한 공통 인터페이스를 제공하여 레퍼지토리를 개발할 때 인터페이스만 작성하면 구현체 없이 개발 가능하다.


기존에는 각각 save, find 메서드를 만들어서 persist 해야 했다.

@Repository
public class MemberJpaRepository {
    @PersistenceContext //
    private EntityManager em;

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

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

그런데 Spring Data JPA를 사용하면 JpaRepository 인터페이스를 통해 메서드를 정의할 수 있다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    //JpaRepository 안에 무수히 많은 기능들이 있음.
}

public interface TeamRepository extends JpaRepository<Team, Long> {
// Spring Data JPA가 JPARepository 가 있으면 구현체를 만든다.
}



처음에 H2 실행시킬 때 JDBC URL을 jdbc:h2:tcp://localhost/~/datajpa 로 하면 데이터베이스가 없다고 한다. 그래서 jdbc:h2:~/datajpa; 로 해줌으로써 데이터베이스를 원격으로 접근하는 것이 아니라 파일로 접근하게 된다.
한 번만 해주고 그 뒤로는 jdbc:h2:tcp://localhost/~/datajpa로 접근





✠ 항상 build.gradle 제대로 일벽하고 컴파일 했는지 확인 할 것

그렇지 않지 않으면 이런 에러가 발생한다.
Failed to load ApplicationContext for [Web MergedContextConfiguration@402d6012]
(@Entity나 @id 가 import jakarta.~~ 로 import 된다면 의심해 볼 것.)




✪ @NoArgsContructor

기본 생성자를 생성해준다.



Member와 Team간의 일대다 연관관계를 매핑해줌.

✪ Member

@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
public class Member {
    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String username;
    private int age;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;

    public Member(String username) {
        this.username = username;
    }

    public Member(String member, int age, Team team) {
        this.username = username;
        this.age = age;
        if (team != null) {
            changeTeam(team);
        }
    }

    //연관관계 세팅.
    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}



✪ Team

@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "name"})
public class Team {
    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;
    private String name;

    public Team(String name) {
        this.name=name;
    }

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}



S : 엔티티, ID : 엔티티의 식별자 타입, S : 엔티티와 그 자식 타입
-save(S) : 새로운 엔티티는 저장하고, 있는 엔티티는 병합
-delete(T) : 엔티티 하나를 삭제. 내부에서 EntitiManager.remove() 호출
-findById(ID) : 엔티티 하나를 조회, 내부에서 EntitiManager.find() 호출
-getOne(ID) : 엔티티를 프록시로 조회. EntitiManager.getReference() 호출
-findAll(..) : 모든 엔티티 조회. 정렬이나 페이징 조건 제공 가능.







✠ 쿼리 메서드








✠ 메서드 이름으로 쿼리 생성

스프링 데이터 JPA가 아닌 그냥 JPA라면 JPQL을 짜주어야 한다.

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


그런데 스프링 데이터 JPA를 사용하면 위 JPQL로 코드를 쓰지 않아도 테스트 시에 같은 결과를 얻을 수 있다.

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


스프링 데이터 JPA에는 맞는 문법이 있다. (문법에 맞게 사용해야 한다.)
링크텍스트




✪ 문법

조회 : find...By / read...By / query...By / get...By
ex) findHelloBy 처럼 ...에 식별하기 위한 내용이 들어가도 된다.
위처럼 By 뒤에 아무것도 조건을 붙이지 않으면 전체 조회이다.
하지만 findByUsernameAndAgeAnd~~GreaterThan 처럼 And로 너무 길어진다면 QueryDSL을 사용한다. (2개 정도까지만 사용.)


COUNT : count...By 반환타입은 long


EXIST : exists...By 반환타입은 boolean


삭제 : delete...By, remove...By 반환타입은 long


DISTINCT : findDistinct(), findMemberDistinctBy()


LIMIT : findFirst3(), findFirst(), findTop(), findTop3(위에서 3개까지만)



엔티티의 필드명이 변경되면 인터페이스에 정의한 메서드 이름도 꼭 함께 변경해야 한다. 그렇지 않으면 애플리케이션을 시작하는 시점에 오류가 발생한다.








✠ NamedQuery

Member 클래스에 NamedQuery를 써준다.

@NamedQuery(
        name = "Member.findByUsername", //엔티티명.메서드명
        query = "select m from Member m where m.username = :username"
)





✪ JPA의 경우 코드를 구현해야 한다.

public List<Member> findByUsername(String username) {
        return em.createNamedQuery("Member.findByUsername", Member.class)
                .setParameter("username", username)
                .getResultList();
    }



✪ 스프링 데이터 JPA의 경우 구현없이 애노테이션 정의만 해주면 된다.

(name = "엔티티명.메서드명")
그러면 Member.findByUsername 를 찾아서 query의 "select m from Member m where m.username = :username" 를 실행시켜준다.

@Query(name = "Member.findByUsername")
    List<Member> findByUsername(@Param("username") String username);

Member 클래스에서 작성한 @NamedQuery에서 JPQL을 명확하게 작성했을 때 :username 네임드 파라미터가 넘어가야 한다. 이럴 때 @Param이 필요하다.



@Query(name = "Member.findByUsername") 생략 가능하다.
@NamedQuery 에 있는 Member.findByUsername을 찾아버린다.
NamedQuery가 있으면 찾아서 실행하고 없으면 메서드 이름으로 쿼리 생성하는 작업을 실행한다.



✪ 리포지토리 메서드에 쿼리 정의할 수도 있다.

Member 클래스에 @NamedQuery를 만들어 주지 않고 메서드에 쿼리를 만들어 줄 수도 있다.

@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);








✠ DTO로 조회 (username, id, 팀 이름 등 복잡하게 가져올 때)

Dto로 Member 조회 (Dto로 조회할 때는 반드시 new operation 사용)

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







✠ 파라미터 바인딩

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







✠ 반환 타입

스프링 Data JPA는 유연한 반환 타입을 지원한다. 결과가 두 건 이상이면 컬렉션 인터페이스
단건이면 반환 타입을 사용한다.

//반환 타입
    List<Member> findListByUsername(String username); //컬렉션

    Member findMemberByUsername(String username); // 단건

    Optional<Member> findOptionalByUsername(String username); // 단건 Optional


테스트

@Test
    public void returnType() {
        Member m1 = new Member("AAA", 10);
        Member m2 = new Member("BBB", 20);
        memberRepository.save(m1);
        memberRepository.save(m2);

        List<Member> collection = memberRepository.findListByUsername("weg");
        System.out.println("collection = " + collection); //컬렉션 조회

        Member dangun = memberRepository.findMemberByUsername("AAA");
        System.out.println("dangun = " + dangun);  //단건 조회

        Optional<Member> option = memberRepository.findOptionalByUsername("AAA");
        System.out.println("option = " + option); //단건 Optional 조회
    }


✪ 컬렉션 조회일 때

테스트를 해볼 때 AAA나 BBB가 아닌 다른 파라미터 이름으로 조회하여 데이터가 없다면 null이 아닌 Empty Collection(빈 컬렉션)을 반환한다. 즉 size가 0인 컬렉션을 반환한다.
⁂ List는 절대 null을 반환하지 않는다.



✪ 단건 조회일 때

테스트 해볼 때 결과값이 null로 반환된다.



✪ Optional 단건 조회일 때

Optional이면 조회할 때 Optional.empty 로 출력이 된다.



결론 -> DB에서 데이터를 조회했을 때 데이터가 있을 수도 있고 없을 수도 있으면 Optional 사용.
만약 같은 이름의 데이터가 2개 이상이면 예외가 터진다. ("AAA" 가 2개 이상일 때)









✠ 페이징

JPA에서 페이징으로 정렬을 할 수 있다.


✠ 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();
    }



테스트

// 페이징 기법 테스트
    @Test
    void paging() {
        //given
        memberJpaRepository.save(new Member("member1", 10));
        memberJpaRepository.save(new Member("member2", 10));
        memberJpaRepository.save(new Member("member3", 10));
        memberJpaRepository.save(new Member("member4", 40));
        memberJpaRepository.save(new Member("member5", 50));

        int age = 10;
        int offset = 0;
        int limit = 3;


        //when
        //페이징 된 content를 가져옴
        List<Member> members = memberJpaRepository.findByPage(age, offset, limit);
        // 전체 count를 가져옴.
        long total = memberJpaRepository.totalCount(age);

        assertThat(members.size()).isEqualTo(3);
        assertThat(total).isEqualTo(3);
    }


members.size() 는 limit - offset 해서 3, 설정한 나이가 10인 사람의 수는 3.




✠ 스프링 데이터 JPA로 페이징을 할 경우

인터페이스에 findByAge 추가

Page<Member> findByAge(int age, Pageable pageable);

Pageable 구현된 인터페이스를 넘길 때 보통 pageRequest를 많이 씀.



테스트

// 스프링 데이터 JPA 페이징 기법 테스트
    @Test
    void paging() {
        //given
        memberRepository.save(new Member("member1", 10));
        memberRepository.save(new Member("member2", 10));
        memberRepository.save(new Member("member3", 10));
        memberRepository.save(new Member("member4", 40));
        memberRepository.save(new Member("member5", 50));

        int age = 10;
        //0페이지에서 시작해서 3개 가져오고 username으로 정렬
        PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));

        //when
        Page<Member> page = memberRepository.findByAge(age, pageRequest);
        // totalCount 필요 없음. 반환 타입을 Pageable로 받으면 totalCount 쿼리도 같이 날림.

        //then
        List<Member> content = page.getContent(); //내부의 실제 데이터를 꺼냄
        long totalElements = page.getTotalElements(); // totalCount
        
        assertThat(content.size()).isEqualTo(3);
        assertThat(page.getTotalElements()).isEqualTo(3);
        assertThat(page.getNumber()); //페이지 번호
        assertThat(page.getTotalPages()).isEqualTo(1); //전체 페이지 개수
        assertThat(page.isFirst()).isTrue(); //첫 번째 페이지
        assertThat(page.hasNext()).isFalse(); //다음 페이지가 있는지.
    }

스프링 데이터 JPA는 offset(페이징 시작점)을 0부터 시작하므로 offset은 생략함. 그리고 반환 타입이 Page 이므로 페이지를 계산하기 위해 스프링 데이터 JPA가 totalCount 쿼리를 날림.










✠ Slice (Slice는 전체 카운트를 가져오지는 못한다.)

page.getTotalElements(), page.getTotalPages() 사용 불가능

//when
Slice<Member> page = memberRepository.findByAge(age, pageRequest);


assertThat(content.size()).isEqualTo(3);
assertThat(page.getNumber()).isEqualTo(0);

assertThat(page.isFirst()).isTrue(); //첫 번째 페이지.
assertThat(page.hasNext()).isTrue();

위에서 페이징과 달리 추가 count 쿼리 없이 다음 페이지만 확인 가능하다
타입을 List로 한다면 추가 count 쿼리 없이 결과만 반환한다.


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








✠ count 쿼리 분리하는 방법

@Query(value = "select m from Member m left join m.team t", countQuery = "select count(m) from Member m")
    Page<Member> findByAge(int age, Pageable pageable);

그러면 count 쿼리가 심플하게 나온다.

	select
        count(member0_.member_id) as col_0_0_ 
    from
        member member0_
    //countQuery 없는 경우
    left outer join
    	team team1_
        	on member0_.team_id=team1_.team_id

쿼리가 복잡해지면 join을 많이 하게 되어 count 쿼리도 같이 복잡해진다. 그러면 성능이 느려진다. 그래서 최적화를 위해 countQuery를 사용하여 단순하게 멤버만 카운트 한다. join이 없기 때문에 데이터가 많아도 DB에서 심플하게 가져올 수 있다.







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

API에서는 엔티티를 외부로 반환하면 큰일남. -> 무조건 내 애플리케이션 안에 숨겨야 한다.
그래서 DTO로 변환하고 API로 반환 (쉽게 하는 방법)

Page<MemberDto> toMap = page.map(member -> new MemberDto(member.getId(), member.getUsername(), null));
        //map은 내부의 것을 바꿔서 다른 결과를 냄








✠ 벌크성 수정 쿼리

대량의 데이터들을 쿼리를 통해 수정한다.
즉 조건에 만족하면 한 번에 데이터를 업데이트 할 수 있도록 해준다.



✪ JPA의 경우

// 벌크성 수정 쿼리 -> 이 조건에 만족하면 다 age + 1이 된다.
    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(clearAutomatically = true) // 이 어노테이션이 executeUpdate()를 실행해준다.
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);

€ @Modifying 필요함.




테스트

public void bulkUpdate() {
        //given
        memberRepository.save(new Member("member1", 10));
        memberRepository.save(new Member("member2", 19));
        memberRepository.save(new Member("member3", 20));
        memberRepository.save(new Member("member4", 21));
        memberRepository.save(new Member("member5", 40));

        //when
        //20살 이상인 사람들은 다 plus 하겠다는 의미. 대상은 member3,4,5가 됨.
        int resultCount = memberRepository.bulkAgePlus(20);
        //em.flush();
        //em.clear();

        List<Member> result = memberRepository.findByUsername("member5");
        Member member5 = result.get(0);
        System.out.println("member5 = " + member5);
        

        //then
        assertThat(resultCount).isEqualTo(3);
    }

𓇼JPA는 영속성 컨텍스트에서 Entity가 모두 관리가 되는데 벌크성은 그걸 무시하고
DB에 바로 날려버린다. 그래서 DB에는 member5 age가 41로 되어있지만 영속성에는 40으로 되어있다.
그래서 벌크 연산 이후에는 영속성 컨텍스트를 모두 날려야 한다.



✪ 해결 방법

@PersistenceContext
    EntityManager em;

영속성 컨텍스트 만들어줌.




//when
        //20살 이상인 사람들은 모두 age+1을 함.
        int resultCount = memberRepository.bulkAgePlus(20);
        em.flush();
        em.clear();

        // member5의 나이를 보니 DB에는 41살이지만 영속성 컨텍스트에서는 40으로 되어있음.
        List<Member> result = memberRepository.findByUsername("member5");
        Member member5 = result.get(0);
        System.out.println("member5 = " + member5);

em.flush(), em.clear() 를 해주던지 아니면 @Modifying(clearAutomatically = true)


em.clear()로 영속성 컨텍스트 안의 데이터를 모두 날려버린다. 그래서 조회했을 때 깔끔한 상태에서 DB에서 다시 조회를 해온다. 그래서 반드시 벌크 연산 이후에는 영속성 컨텍스트를 날려야 한다.

fetch join: Member를 조회할 때 연관된 Team을 한 방 쿼리로 같이 다 끌고 온다.








✠ Fetch join

fetch join 매우 중요 -> 1 + N 문제


테스트

@Test
    public void findMemberLazy() {
        //given
        //member1 -> teamA를 참조
        //member2 -> teamB를 참조
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");
        teamRepository.save(teamA);
        teamRepository.save(teamB);
        Member member1 = new Member("member1", 10, teamA);
        Member member2 = new Member("member2", 10, teamB);
        memberRepository.save(member1);
        memberRepository.save(member2);

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

        //when
        List<Member> members = memberRepository.findMemberFetchJoin();

        for (Member member : members) {
            System.out.println("member = " + member);
            //멤버에서 팀을 꺼내서 이것의 이름을 출력.
            System.out.println("member = " + member.getTeam().getName());
            System.out.println("member.getTeam().getClass() = " + member.getTeam().getClass());
            System.out.println("member.getTeam().getName() = " + member.getTeam().getName());

        }
    }



그냥 findAll()로 해서 멤버를 조회하면 결과가 Proxy가 생긴다.
member1 을 찍고 member.getTeam().getClass() 했을 때 Proxy가 생성된 다음에 이름을 찾아야 하므로 실제 DB에서 데이터 날려서 team에 대한 데이터를 가져온다.


즉 N+1 문제가 발생한다.

쿼리 한 번 날렸는데(1) 이 쿼리에 대한 결과가 2개(N) 나오는 문제이다. 쿼리 한번 날렸는데(1) 결과가 10개(N) 나오면 결과만큼 쿼리가 10개 더 추가로 나온다는 것이다.



✪ 문제 해결

이 문제를 해결하기 위해 fetch join을 사용한다.

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

그러면 Member를 조회할 때 연관된 Team을 같이 한방 쿼리로 다 끌고온다. 그러면 가짜 프록시가 생기지 않고 진짜가 나온다.







✠ @EntityGraph

fetch join을 JPQL에 쓸 때 굳이 쿼리문(@Query(~~))을 작성하지 않아도 되지 않게 해주는 어노테이션이다.
fetch join을 좀 더 편하게 사용할 수 있다. 3가지 방법이 있음.


@Override
@EntityGraph(attributePaths = {"team"})
//findAll할 때 멤버도 조회하면서 팀도 같이 조회하고 싶을 때 사용.
    
List<Member> findAll();


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


@EntityGraph(attributePaths = {"team"})
    List<Member> findEntityGraphByUsername(@Param("username") String username);

쿼리가 복잡해지면 JPQL에서 fetch join을 사용한다.








✠ JPA Hint

JPA 쿼리 힌트로 DB에 날려주는 SQL 힌트가 아닌 JPA 구현체에게 제공하는 힌트이다.
JPA는 기본적으로 영속성 컨텍스트를 이용해서 기존 데이터를 저장해놓고, set으로 업데이트되는 데이터를 변경 감지로 감지하면서 저장하게 된다. 즉 변경을 하려면 원본이 뭔지를 알아야 한다. 이는 메모리와 성능에 안 좋은 영향을 미칠 수 있다.
그래서 읽기 전용으로만 사용하지만 성능상 좋다면 Hint를 적용시킬 수 있다.
성능상 이득 보기에 어려우므로 단순히 힌트만 적용해서 성능 기준을 간편하게 맞출 수 있는 경우에만 이 기능을 사용한다.


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

@QueryHint에서 하이버네이트가 readOnly, value가 true로 되어있으면 내부적으로 성능 최적화를 다 해준다.
변경이 안 된다고 가정하고 다 무시함.
org.hibernate.readOnly
->엔티티 읽기 전용 처리
변경 감지를 하지 않으므로 성능 최적화를 한다.




테스트

//when
        Member findMember = memberRepository.findReadOnlyByUsername("member1");
        findMember.setUsername("member2");


Test해보면 select 날린 후 변경된 쿼리 자체가 없다.
findReadOnlyByUsername에서 변경 감지 체크를 하지 않은 것이다.
스냅샷(원본)이 없기 때문에 내부에서 읽기 용으로만 쓰는 것으로 최적화를 해버린다.
스냅샷(원본)을 저장하지 않기 때문에 엔티티가 변경되어도 UPDATE 쿼리가 전달되지 않음
성능 테스트를 해보고 정말 중요하고 얻는 이점이 있어야 사용하도록 한다.









✠ Lock

DB에 동시 접근이 발생하여 트랜잭션끼리 충돌이 나는 경우 등을 방지하기 위한 기능이다.
많이 사용하지 않기 때문에 나중에 필요하면 보기










✠ 사용자 정의 레퍼지토리 구현

인터페이스만 정의하고 구현체는 스프링이 자동으로 생성하는데 스프링 데이터 JPA가 제공하는 인터페이스를 직접 구현하면 구현해야 하는 기능이 너무 많다는 문제가 있다.
◎그래서 인터페이스의 메소드 일부만을 직접 구현하고자 할 경우에 사용한다.
◎인터페이스의 메소드를 직접 구현한다.



사용자 정의 인터페이스 생성 (인터페이스 이름은 상관없음.)

public interface MemberRepositoryCustom {
    //스프링 Data JPA가 아닌 내가 직접 구현한 기능을 쓰고 싶음.
    List<Member> findMemberCustom(); //이거에 대한 구현은 MemberRepositoryImpl에서 함
    //그리고 구현할 클래스를 생성.
}

**사용자 정의 구현 클래스 생성 클래스명은 필수로 -> 사용자 정의 인터페이스명 + Impl (MemberRepository와 이름은 맞춰야 한다.)**

@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {

    private final EntityManager em;

    @Override
    public List<Member> findMemberCustom() {
        return em.createQuery("select m from Member m")
                .getResultList();
    }//실제 실행하면 여기 메소드가 실행됨.
}



실무에서 주로 QueryDSL이나 SpringJdbcTemplate을 함께 사용할 때 사용자 정의 리포지토리 기능을 자주 사용한다. 그런데 항상 사용자 정의 리포지토리가 필요한 것은 아니다. 그냥 임의의 리포지토리를 만들어도 된다. 예을 들어 MemberQueryRepository 인터페이스가 아닌 클래스를 만들어서 스프링 빈으로 등록해서 그냥 직접 사용해도 된다. (스프링 데이터 JPA와는 관계없음.)




MemberQueryRepository 직접 생성

private final EntityManager em;

List<Member> findAllMembers(){
        return em.createQuery("select count(m) from Member m")
                .getResultList();
    }


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

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

개발할 때 클래스를 분리할지 말지는 고민해볼 필요가 있다. 애플리케이션이 커질수록 Command와 쿼리를 분리하는 것, 핵심 비즈니스 로직과 핵심이 아닌 것 라이프사이클에 따른 분리 등을 고민해가면서 분리하는 것을 권장.









✠ Auditing -> 엔티티 변경할 때 등록일, 수정일, 등록자, 수정자를 추적 (실무에서 많이 사용)

기본적으로 테이블을 만들 때 등록일, 수정일을 남겨서 운영이 잘 되도록 한다.



✪ 순수 JPA 이벤트를 사용해서 추적

@PrePersist: persist가 호출되기 전에 실행
@PreUpdate: update 되기 전에 실행

public class JpaBaseEntity {
    //createDate 은 변경되지 않도록 update 되지 않도록 설정함.
    @Column(updatable = false)
    private LocalDateTime createDate;

    private LocalDateTime updateDate;

    @PrePersist
    public void prePersist() {
        LocalDateTime now = LocalDateTime.now();
        createDate = now;
        updateDate = now;
    }


    @PreUpdate
    public void preUpdate() {
        updateDate = LocalDateTime.now();
    }
}

그리고 Member 클래스에서 상속받도록 하면 끝. 이렇게 extends로 Team 클래스에도 상속받도록 하면 엔티티가 아무리 많아도 모든 등록일 수정일에 대해서 자동화할 수 있다.


@MappedSuperclass: JPA에서 진짜 상속관계가 있고 속성만 내려서 쓰는 상속관계가 있다. 속성을 내려서 테이블에서 쓸 수 있게 한다.
(예제에서는 createDate 등록일과 updateDate 수정일 속성을 쓸 수 있게 된다.)




✪ 스프링 데이터 JPA를 이용해서 추적.

잊지 말고 적용
@EnableJpaAuditing -> 스프링 부트 설정 클래스에 적용


@EnableJpaAuditing
@SpringBootApplication
public class DataJpaApplication {

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

	@Bean
	public AuditorAware<String> auditorAware() {
		//람다
		return new AuditorAware<String>() {
			@Override
			public Optional<String> getCurrentAuditor() {
				return Optional.of(UUID.randomUUID().toString());
			}
		};
	}
}

@EntityListeners(AuditingEntityListener.class) -> 엔티티에 적용
@CreateDate -> 등록일에 적용
그러면 @PrePersist, @PreUpdate 없이 @CreateDate와 @LastModifiedDate 애노테이션으로만 적용.

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity {
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;
    
    
    //등록자 남기기
    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    //수정자 남기기
    @LastModifiedBy
    private String lastModifiedBy;
}

등록일 수정일은 많이 사용하지만 등록자, 수정자는 때에 따라 다르므로 엔티티를 각각함. (실무에서 주로 사용)
그리고 수정자, 등록자는 값이 들어가도록 할려면 위처럼 스프링 부트 설정 클래스에 빈으로 AuditorAware를 등록해야 한다.



@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseTimeEntity {
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;

}

등록일, 수정일을 따로 BaseTimeEntity 클래스에 넣고 등록자 수정자는 그대로 BaseEntity 클래스에 넣은 다음 BaseTimeEntity를 상속받도록 해서 사용하면 대부분 해결이 됨.







✠ 웹 확장 -> 페이징과 정렬

@GetMapping("/members")
    //Page, Pageable 인터페이스임.
    public Page<Member> list(Pageable pageable) {
        Page<Member> page = memberRepository.findAll(pageable);
        //findAll() 은 끝에 파라미터로 pageable을 넘기기만 하면 끝
        return page;
    }

✪ Pageable 인터페이스 사용

  1. page
    postman에서 ~~8080/members?page=0 하게 되면 (페이지 0부터 시작한다.)
    20페이지씩 출력을 하게 돼서 id는 1부터 20까지 출력이 된다.
    ex)
    page = 0 이면 1 ~ 20
    page = 1 이면 21 ~ 40
    page = 2 이면 41 ~ 60


  1. size
    ~~8080/members?page=0&size=3 하게 되면 한 페이지에 3개씩 불러온다.
    id 개수만큼 id = 1,2,3 이 출력된다.
    ex)
    page=0&size=3 이면
    id = 1,2,3

page=1&size=3 이면
id = 4,5,6



  1. sort
    ex)
    ~~&sort=id,desc 이런 식으로 역순으로도 출력할 수 있다.



✪ 기본적으로 page 하나에 size가 20씩이었는데 default 값으로 10 정해주고 싶다면

  1. yml에서 글로벌로 설정
    data:
    web:
    pageable:
    default-page-size: 10
    max-page-size: 1000
  2. Controller에서 하는 특별한 설정 (글로벌 설정보다 우선적)
public Page<Member> list(@PageableDefault(size=5) Pageable pageable) {}

참고: 반환 타입이 Page이면 totalPages, totalElements 같은 totalCount도 날라간다.



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

@Qualifier 에 접두사명 추가 접두사명_xxx
ex -> ~~/members?member_page=0&order_page=1 (페이지가 member_page, order_page로 2개임.)

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








✠ DTO 로 변환. (map으로 넘기기.)

페이징 내용도 엔티티를 그대로 반환하면 절대 안 된다. 엔티티를 외부 API로 노출하는 것은 내부 설계를 밖으로 노출시키는 것이다. 그래서 Page 내용을 DTO로 변환해야 한다.


public Page<MemberDto> list(@PageableDefault(size=5) Pageable pageable) {
        Page<Member> page = memberRepository.findAll(pageable);
        //Dto로 변환.
        Page<MemberDto> map = page.map(member -> new MemberDto(member.getId(), member.getUsername(), null));

        return map; //Ctrl + Alt + N으로 합치기 가능.
    }

페이지를 1부터 시작하게 할 수 있긴 한데 편리하긴 하지만 그냥 0부터 시작하도록 하는 것이 깔끔함 (상황에 따라 선택). 필요하다면
1. Pageable.Page를 파라미터와 응답 값으로 사용하지 않고 직접 클래스를 만들어서 처리. 그리고 직접 PageRequest(Pageable 구현체)를 생성해서 리포지토리에 넘긴다. 물론 응답값도 Page 대신에 직접 만들어서 제공


  1. spring.data.web.pageable.one-indexed-parameters 를 true로 설정하면 끝. 파라미터 -1 때문에 한계가 있음.








✠ 잠시 복습

✠ @Repository

  1. 스프링 빈의 컴포넌트 스캔 대상이 되도록 한다. 스프링이 읽어 들여서 스프링 컨테이너에 올리게 된다.
  2. 예외가 터지면 영속성 계층에 있는 예외들을 스프링에서 쓸 수 있는 예외로 바꿔준다. 그래서 Controller 나 Service 계층에서 예외를 넘길 때 JDBC/JPA 예외가 올라가는 것이 아니라 스프링 프레임워크가 제공하는 예외로 올라간다.
    이점은 하부 구현 기술을 JDBC에서 JPA로 바꿔도 예외를 처리하는 메커니즘은 동일하다는 것이다.
    (기존 비즈니스 로직에 최대한 영향을 주지 않도록 설계가 된다.)

스프링 데이터 JPA 가 트랜잭션을 걸어줘서 트랜잭션 없이도 데이터 등록, 변경이 가능하다.
(사실은 트랜잭션이 리포지토리 계층에 걸려있는 것)









✠ @Transactinal(readOnly = true)

JPA의 모든 변경은 트랜잭션 안에서 동작한다.
스프링 데이터 JPA는 변경(등록, 수정, 삭제) 메서드를 트랜잭션 처리한다.
트랜잭션이 리포지토리 계층에 걸려있기 때문에 스프링 데이터 JPA를 사용할 때 트랜잭션이 없어도 데이터 변경이 가능하다.
데이터를 단순히 조회만 하고 변경하지 않는 트랜잭션에서는 readOnly = true 옵션을 사용하면 flush를 생략해서 약간의 성능 향상을 얻을 수 있다.


트랜잭션이 끝날 때 엔티티 값만 바꿔놓으면 자동으로 데이터가 바뀌는 변경 감지를 쓰는 것이 정석.
merge를 호출하는 순간 DB에서 데이터를 꺼내서 save로 호출한 파라미터로 넘긴 것들로 다 교체해버린다. 치명적인 단점은 DB로 select를 한다는 것이다. merge는 어떠한 경우로 영속 상태를 벗어났을 때 다시 영속 상태로 만들기 위해서 사용하는 것이다. (데이터 update할 때는 사용 금지.)









✠ 새로운 엔티티 구별하는 방법

  1. 식별자(pk)가 객체일 때 null이면 새로운 엔티티이다.
  2. 식별자가 자바 기본 타입일 때 0이면 새로운 엔티티로 판단한다.


id의 값을 설정하지 않았으면 id는 값이 null이다. @GeneratedValue는 JPA에 Persist를 하면 그 안에서 들어간다. 그 전까지 id는 생기지 않는다.
결국 @GeneratedValue를 쓰지 않았을 때 문제가 발생한다.
@GeneratedValue면 save() 호출 시점에 pk가 없으므로 새로운 엔티티로 인식해서 정상 동작한다.
그런데 JPA 식별자 생성 전략이 @Id만 사용해서 직접 할당이면 이미 식별자가 있는 상태여서 save()를 호출한다. 이 경우 merge가 호출된다.


@Transactional
	@Override
	public <S extends T> S save(S entity) {

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

		if (entityInformation.isNew(entity)) {
			em.persist(entity);
			return entity;
		} else {
			return em.merge(entity);
		}
	}

merge는 DB에 값이 있다는 가정을 하고 동작한다. 우선 DB를 호출해서 값을 확인한다. 그리고 DB에 값이 없으면 새로운 엔티티로 인지하므로 매우 비효율적이다.
실무에서 프로젝트에 데이터가 너무 많아 @GeneratedValue를 못 사용하고 Id를 직접 임의로 생성해야 하는 경우 Persistable 인터페이스를 사용해서 새로운 엔티티 확인 여부를 직접 구현하는 것이 효과적이다.
참고로 등록시간 @CreatedDate 를 조합해서 사용하면 이 필드로 새로운 엔티티 여부를 편리하게 확인할 수 있다. (@CreatedDate에 값이 없으면 새로운 엔티티로 판단.)


@Entity
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<String> {
    @Id
    private String id;

    @CreatedDate //JPA Persist 되기 전에 호출됨.
    private LocalDateTime createDate;

    public Item(String id) {
        this.id = id;
    }

    @Override
    public String getId() {
        return id;
    }

    @Override
    public boolean isNew() {
        //createDate 가 null이면 새로운 객체이다.
        return createDate == null;
    }
}








✠ Projection

엔티티 대신에 DTO를 편리하게 조회할 때 사용하고 전체 엔티티가 아닌 하나의 요소만(회원 이름만) 조회하고 싶을 때 사용한다.

인터페이스 생성

public interface UsernameOnly {
    String getUsername();
}

usernameOnly 타입 반환

//Projection
    //반환 타입에 UsernameOnly로 만든 인터페이스만 넣어주면 끝난다.
    //그러면 usernameOnly에 String getUsername(); 으로 전체 엔티티가 아닌 회원 이름만 조회한다.
    List<UsernameOnly> findProjectionsByUsername(@Param("username") String username);



✪ 오픈 프로젝션

인터페이스에 @Value 추가.

public interface UsernameOnly {
    @Value("#{target.username + ' ' + target.age}")
    String getUsername();
}

그러면 Member의 데이터를 그대로 다 select하고 원하는 데이터 username과 username에 대한 나이를 가져오게 된다.




✪ 인터페이스로가 아닌 클래스로 프로젝션

클래스 생성

public class UsernameOnlyDto {
    private final String username;

    public UsernameOnlyDto(String username) {
        this.username = username;
    }

    public String getUsername() {
        return username;
    }
}

클래스를 생성해서 프록시가 아닌 구체 클래스로 동작하게 할 수 있다. (한 번에 가져와서 최적화하는 방법이다.)









✠ 네이티브 쿼리 (실무에서는 대부분 QueryDSl로 JPQL을 사용해서 해결)

JPQL을 사용할 수 없을 때 JPA는 SQL을 직접 사용할 수 있는 기능을 제공. (사용자가 직접 데이터베이스에 날리는 쿼리를 작성)


@Query(value = "select * from Member where username = ?", nativeQuery = true)
    Member finByNativeQuery(String username);

select from Member where username = ?
select
from Member where username = 'm1';
select * 그대로 나가서 출력됨.


한계)
◎데이터를 엔티티에 맞게 select 절에 다 적어 줘야 한다.
◎네이티브 쿼리로 가져올 때는 member 데이터를 다 찍어줘야 한다.
◎반환 타입 몇 가지가 지원이 되지 않는다.
◎Sort 파라미터를 통한 정렬이 정상 동작하지 않을 수 있고 JPQL처럼 애플리케이션 로딩 시점에 문법 확인이 불가능하다.
◎동적 쿼리 불가





정적 쿼리를 native로 짜야할 때는 Projections 활용하면 해결 가능.(native 문제 조금 심플하게 해결함.)

@Query(value = "select m.member_id as id, m.username, t.name as teamName " +
            "from member m left join team t",
            //페이징 처리까지 완료, countQuery 해주어야 함.
            countQuery = "select count(*) from member",
            nativeQuery = true)
    Page<MemberProjection> findByNativeProjection(Pageable pageable);
    //페이징 처리까지 가능.

동적 네이티브 쿼리는 스프링 JdbcTemplate, myBatis 같은 외부 라이브러리 사용.


⁂ 결론 - 거의 99%는 QueryDSL로 해결이 가능하다. 가급적 사용하지 않는 것이 좋고 정말 어쩔 수 없을 때 사용.

profile
일상의 인연에 감사하라. 기적은 의외로 가까운 곳에 있을지도 모른다.

0개의 댓글