Programming-[Backend]/JPA

[spring data JPA]4. 구현체 분석, 기타 기능 : Specification, Query by Example, Projections, Native query

컴퓨터 탐험가 찰리 2021. 11. 21. 21:47
728x90
반응형

 

 

1. 스프링 데이터 JPA 구현체 분석1

이때까지 학습했던 스프링 데이터 JPA의 JpaRepository를 자세히 살펴보자. 스프링 데이터 JPA의 구현체는 JpaRepository의 구현체로, SimpleJpaRepository라는 클래스이다.

 

 

EntityManager를 사용한다.

스프링 데이터 JPA의 구현체들도 결국엔 EntityManager를 사용한다. em으로 축약해서 사용하고 있고, 필요하면 JPQL문법을 사용해서 구현이 된 것을 확인할 수 있다.

 

@Repository를 사용한다.

클래스에 @Repository가 정의된 것을 확인할 수 있다. 이렇게 함으로써 기본적으로 스프링에서 요구하는 sepc을 맞출 수 있다. 또한 Repository에서 발생하는 Exception들을 스프링에 맞게 변환함으로써 예외적인 상황에서도 스프링에서 처리되도록 설정해주고 있다.

 

@Transactional(readOnly=true)

트랜잭션이 기본적으로 적용되어 있다. 사용자가 직접 트랜잭션을 지정해놓은 경우, 그 트랜잭션을 이어받아서 진행되도

록 되어있다. 직접 지정하지 않은 경우라도 기본적으로 트랜잭션이 적용된다. 또한 readOnly=true로 적용되어있는 경우 플러시를 생략해서 dirty checking등을 하지 않으므로 약간의 성능 향상을 얻을 수 있다. 그래서 전체 트랜잭션을 readOnly=true로 지정해두고 save 등의 변경이 필요한 메서드에서는 @Transactional을 따로 오버라이드 하는 방식을 사용한다.

 

 


 

2. 스프링 데이터 JPA 구현체 분석2 ; Save 메서드

 

Save의 경우 새로운 엔티티인지 검사하여 persist(저장) 또는 merge(병합)을 결정한다. 판단의 기준(아래 코드에서 isNew 메서드의 기준)은 다음과 같다.

 

객체인 경우 null,

기본 타입인 경우 0

 

구현체의 메서드 실행부에 break point를 걸고, debug를 해보면 id를 Long으로 지정했을 땐, id=null로 조회하는 것을 확인할 수 있다. 만약 id값을 기본 타입인 long으로 하면, id 값이 없으면 id=0으로 판단한다.

 

 

id값을 직접 설정한다면, isNew를 건너뛰고 기존 엔티티로 판단한다. 그래서 merge가 실행되며, 직접 지정하지 않은 필드값들은 모두 null로 처리한다. 따라서 업데이트 시에 식별자값(보통 id)을 설정해주면 안된다.

 

 

 

Persistable 적용하여 isNew Override 하기

 

만약 식별자가 아닌 다른 로직을 통해서 새로운 엔티티를 구분하고 싶다면, Persistable 인터페이스를 implements 하면 된다. 우선, Persistable<Long>으로, id의 타입을 제네릭 타입으로 설정해준다. 그러면 getter와 isNew 메서드를 Override 할 수 있다. 이 상태에서 isNew에 해당하는 메서드를 작성하면 되는데, 강의에서 추천해주는 로직은 @CreatedDate로 JPA 이벤트를 감지하고, createdDate가 null이면 새로운 객체로 판단하도록 하는 것이다.

(@CreatedDate를 사용하기 위해서 @EntityListeners 옵션을 넣어야 하는 것을 기억하자)

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Entity
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<Long>{
 
  @Id
  @GeneratedValue
  private Long id;
 
  @CreatedDate
  private LocalDateTime createdDate;
 
  public Item(Long id) {
    this.id = id;
  }
 
  @Override
  public Long getId() {
    return id;
  }
 
  @Override
  public boolean isNew() {
    return createdDate == null;
  }
}
cs

 

 


 

3. Specification

 

Specification은 검색 조건 등을 AND, OR 등을 이용해서 쉽게 생성해주는 개념이다. 그런데 사용하기가 복잡하고 가독성이 많이 떨어지며 다른 대안이 있으므로 강의에서는 Specification은 사용하지 말 것을 당부한다.

 

그래도 이런게 있다는거 정도는 알아본다.

 

우선 JpaSpecificationExecutor<>를 Repository interface에 상속받는다. 이 인터페이스에 들어가보면, findAll등의 메서드가 구현되어 있는 것을 확인할 수 있다. 이 메서드들에 Specification 조건을 파라미터로 넣게 된다.

 

1
public interface MemberRepository extends JpaRepository<Member, Long>JpaSpecificationExecutor<Member> {
cs

 

Specification을 구현하기 위한 class를 아래와 같이 작성한다. JPA Criteria 문법이라는데, 따로 공부하지 않으면 알기가 힘든 문법인 것 같다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MemberSpec {
 
  public static Specification<Member> teamName(final String teamName) {
    return (Specification<Member>) (root, query, criteriaBuilder) -> {
 
      if (StringUtils.isEmpty(teamName)) {
        return null;
      }
 
      Join<Member, Team> t = root.join("team", JoinType.INNER);//회원과 조인(JPA Criteria 문법)
      return criteriaBuilder.equal(t.get("name"), teamName);
    };
  }
 
  public static Specification<Member> username(final String username) {
    return (Specification<Member>) (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("username"), username);
  }
}
cs

 

 

테스트는 잘 동작한다. Specification 구현체를 만드는데, and 조건으로 조건을 이어나가는 것을 볼 수 있다. 이런 것이 Specification의 장점이라할 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
  @DisplayName("")
  void specBasic() throws Exception {
    /* GIVEN */
    Team teamA = new Team("teamA");
    em.persist(teamA);
 
    Member m1 = new Member("m1"0, teamA);
    Member m2 = new Member("m2"0, teamA);
    em.persist(m1);
    em.persist(m2);
 
    em.flush();
    em.clear();
 
    /* WHEN */
    Specification<Member> spec = MemberSpec.username("m1").and(MemberSpec.teamName("teamA"));
    List<Member> result = memberRepository.findAll(spec);
 
    /* THEN */
    assertThat(result.size()).isEqualTo(1);
 
  }
cs

 

 


 

4. Query By Example

 

말 그대로 Example을 통해서 쿼리문을 작성하는 기능이다. 여기서 Example은 조회할 엔티티를 의미한다. 엔티티 자체를 조회의 조건으로 넣는 것이다! 일견 좋아보일 수 있으나 장점보다 단점이 커서 실무에서는 사용이 어렵다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Test
  @DisplayName("")
  void queryByExample() throws Exception {
    /* GIVEN */
    Team teamA = new Team("teamA");
    em.persist(teamA);
 
    Member m1 = new Member("m1"0, teamA);
    Member m2 = new Member("m2"0, teamA);
    em.persist(m1);
    em.persist(m2);
 
    em.flush();
    em.clear();
 
    /* WHEN */
    //Probe : 필드에 데이터가 있는 실제 도메인 객체
    Member member = new Member("m1");
    member.setTeam(teamA);
 
    ExampleMatcher matcher = ExampleMatcher.matching()
            .withIgnorePaths("age");
 
    Example<Member> example = Example.of(member, matcher);
 
    List<Member> result = memberRepository.findAll(example);
 
    /* THEN */
    assertThat(result.get(0).getUsername()).isEqualTo("m1");
 
  }
cs

 

"m1" 이라는 이름을 갖는 Member 객체를 만들고 그 객체를 Example.of의 인자로 주어 Example 객체를 만든다. 그리고 memberRepository의 findAll에 이 객체 자체를 넣는다. 이게 가능한 이유는 memberRepository가 상속받는 JpaRepository가 QueryByExampleExecutor를 상속받기 때문이다. 

 

primitive 타입 필드 설정 및 무시

21~22번줄의 ExampleMatcher를 따로 작성해주지 않으면 쿼리문에서 where Member.age = 0 으로 조건문이 추가된다. 이것은 Member 클래스의 age는 int로 primitive 타입으로 작성되어 있기 때문에, null로 반영할 수 없어서 기본값으로 지정되서 나가게 되는 것이다. 그래서 matcher에서 age를 무시하도록 해주었고, 24번줄의 Example 작성 시 두번째 인자로 matcher를 넣어준다.

 

조인

조인이 가능하다. 다만 inner join만 된다고 한다. 19번 줄의 member.setTeam을 통해 team 객체를 설정해주면, 쿼리문에서 inner join으로 team 객체를 불러오는 것을 확인할 수 있다.

 

 

장점과 단점

장점

동적 쿼리를 편하게 처리할 수 있다.

RDB든 NOSQL이든 코드 변경이 필요없도록 추상화 되어있다. 패키지를 보면 spring data jpa의 common 쪽이라서, jpa와 같이 다양한 DB를 지원하는 것이다.

 

단점

inner join만 가능하다.

and or와 같은 중첩조건 적용이 불가하다.

매칭 조건이 매우 단순하다. starts/contains/ends/regex, '='만 지원

 

 


 

5. Projections

 

Projection은 투영이라는 이름처럼, 엔티티의 특정 필드값만을 지정해서 가져오고 싶을 때 사용한다. 크게 Close Projection과 Open projection으로 나뉜다.

 

 

Close Projection

Close Projection은 값을 가져올 때 애초에 쿼리문부터 지정한 값만 select 해오도록 하는 방식이다.

 

우선 가져오고자하는 필드가 포함된 '인터페이스'를 만든다. 그리고 메서드명을 getter 형식으로 작성한다.

 

1
2
3
public interface UsernameOnly {
  String getUsername();
}
cs

 

그리고 UsernameOnly를 반환타입으로 갖는 메서드를 만든다.

 

 

1
2
3
public interface MemberRepository extends JpaRepository<Member, Long>, JpaSpecificationExecutor<Member> {
 
  List<UsernameOnly> findProjectionsByUsername(@Param("username"String username);
cs

 

테스트 코드는 아래와 같이 작성한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
  void projections() throws Exception {
    /* GIVEN */
    Team teamA = new Team("teamA");
    em.persist(teamA);
 
    Member m1 = new Member("m1"0, teamA);
    Member m2 = new Member("m2"0, teamA);
    em.persist(m1);
    em.persist(m2);
 
    em.flush();
    em.clear();
 
    /* WHEN */
    List<UsernameOnly> result = memberRepository.findProjectionsByUsername("m1");
 
    for (UsernameOnly usernameOnly : result) {
      System.out.println("usernameOnly = " + usernameOnly.getUsername());
    }
    /* THEN */
 
  }
cs

 

 

디버깅을 해보면 프록시 객체를 가져오는 것을 확인할 수 있다. 그래서, getUsername()과 같이 직접 필드값을 불러와야만 실제 값을 조회한다.

 

 

 

Open Projection

 

Open Projection은 일단 엔티티의 전체를 가져온 후, 사용자가 원하는 필드값들만 꺼낼 수 있는 방법이다. 이를 위해서는 @Value(springframwork) 어노테이션을 넣어주면 된다.

 

1
2
3
4
5
public interface UsernameOnly {
 
  @Value("#{target.username + ' ' + target.age}")
  String getUsername();
}
cs

 

target이 MemberRepository.findProjectionsByUsername으로 찾아온 엔티티가 된다. 따라서 아래와 같이 출력된다. 쿼리문을 보면 엔티티 전체를 가져오는 것을 확인할 수 있다.

 

 

 

클래스 프로젝션

 

위에서는 interface를 통한 projection 방법으로 close, open projection을 학습했다. 그런데 특정 클래스를 통해 원하는 값만 프로젝션 해올 수 있다.

 

UsernameOnlyDto를 작성한다. 중요한 부분은 생성자의 파라미터인 String username 부분으로, JPA가 이 부분을 인식하여 어떤 쿼리를 작성할지를 판단한다. 따라서 엔티티의 필드명과 파라미터명이 일치하도록 잘 작성해줘야 한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
public class UsernameOnlyDto {
 
  private final String username;
 
  public UsernameOnlyDto(String username) {
    this.username = username;
  }
 
  public String getUsername() {
    return username;
  }
 
}
cs

 

쿼리 결과 요청했던 필드값만 잘 가져오는 것을 확인할 수 있다.

 

중첩 프로젝션

 

인터페이스를 중첩되게 만들어서 조회할 수도 있다. NestedClosedProjections를 만들고 조회해보자.

 

1
2
3
4
5
6
7
8
9
public interface NestedClosedProjections {
 
  String getUsername();
  TeamInfo getTeam();
 
  interface TeamInfo {
    String getName();
  }
}
cs

 

Username을 불러오고, Team 정보를 중첩된 TeamInfo 인터페이스를 통해 불러온다.

 

 

member의 정보는 close projection으로 불러오는데 반해, Team의 정보는 open projection으로 불러온다. 그리고 team은 left join 되는 것을 볼 수 있다. 즉 여러 관련 엔티티의 정보들을 Projection으로 호출하면 기준이 되는 엔티티는 close projection으로 불러올 수 있지만 나머지 엔티티들은 open projection 및 left join 방식이 적용되어 성능 튜닝에서의 한계가 있을 수 있다. 이러한 한계는 추후 querydsl을 적용하여 해결할 것이다.

 

 


 

6. 네이티브 쿼리

 

네이티브 쿼리는 sql문을 직접 작성해주는 방식이다. 일견 편리해보일 수 있으나 반환 타입에 한계가 있고, 원하는 정보의 엔티티를 sql문으로 직접 작성해줘야하며, 동적인 쿼리를 작성하는데 불편하다는 점 등에서 잘 사용하지 않는다.

 

1
2
@Query(value = "select * from member where username = ?"nativeQuery = true)
  Member findByNativeQuery(String username);
cs

 

쿼리문이 그대로 작성되어 나가는 것을 확인할 수 있다.

 

페이징 방식도 지원한다. 이때는 countQuery도 같이 작성해주어야 한다. 그리고 반환 타입은 Projection 방식을 이용해서 각 엔티티들의 특정 필드값들을 네이티브 쿼리로 조회해올 수 있다.

 

1
2
3
4
@Query(value = "select m.member_id as id, m.username, t.name as teamName " +
          "from member m left join team t ",
          countQuery = "select count(*) from member ", nativeQuery = true)
  Page<MemberProjection> findByNativeProjection(Pageable pageable);
cs

 

 

 

1
2
3
4
5
6
public interface MemberProjection {
 
  Long getId();
  String getUsername();
  String teamName();
}
cs

 

 

 


 

참조

 

1. 인프런_실전! 스프링 부트와 JPA 활용1_김영한 님 강의

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%8D%B0%EC%9D%B4%ED%84%B0-JPA-%EC%8B%A4%EC%A0%84/

728x90
반응형