QueryDsl이란?

  • 오픈소스 프로젝트다.
  • 일반적으로 복잡한 Creteria를 대체하는 JPQL 빌더다.
  • JPA의 표준스펙이 아니므로 약간의 설정이 더 필요하다.
  • 복잡한 쿼리와 동적쿼리를 깔쌈하게 해결해준다.
  • 쿼리를 자바 코드로 작성할 수 있다. 따라서 문법오류를 컴파일단계에서 잡아줄 수 있다.

JPQL vs QueryDSL

JPQL

String username = "gracelove"
String query = "select m from Member m"+
 "where m.username = :username";

List<Member> result = em.createQuery(query, Member.class)
                            .getResult.List();
  • 이 코드에 문제가 있다. 쉽게 발견할 수 있는가?
    • String query에 가독성을 위해 개행을 줬다. 이걸 한 줄로 풀어보자.
    • select m from Member mwhere m.username = :username
    • 한 줄로 풀어보니, "...m where..."가 아니라 "...mwhere..."다.
    • 컴파일러가 잡아주지도 않는다. 런타임때 이 쿼리를 실행시키면 그제서야 에러가 난다.

QueryDSL

String username = "gracelove
List<Member> result = queryFactory
                            .select(member)
                            .from(member)
                            .where(member.username.eq(username))
                            .fetch();
  • 자바 코드기 때문에 ide의 도움을 받을 수 있다. 또 컴파일 단계에서 오류를 발견할 수 있다. 게다가 자바 코드다 보니, 메서드추출도 할 수 있어서 재사용성 또한 높아진다.

maven에서의 설정방법

pom.xml 의존성 추가

    <dependencies>
        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-apt</artifactId>
        </dependency>
        <dependency>
            <groupId>com.querydsl</groupId>
            <artifactId>querydsl-jpa</artifactId>
        </dependency>
    </dependencies>
  • 의존성을 위와 같이 추가한다.

pom.xml 빌드 플러그인 추가

<build>
  <plugins>
        <plugin>
            <groupId>com.mysema.maven</groupId>
            <artifactId>apt-maven-plugin</artifactId>
            <version>1.1.3</version>
            <executions>
                <execution>
                    <goals>
                        <goal>process</goal>
                    </goals>
                    <configuration>
                        <outputDirectory>target/generated-sources/java</outputDirectory>
                        <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>
  • 이렇게 설정한뒤 mvn compile 을 실행시키자. 그럼 @Entity를 붙인 클래스들이 Q타입으로 새롭게 생성된다. ex) Account -> QAccount
    • target 디렉토리 밑에 Q타입의 class가 생성된 걸 볼 수 있다.

사용하기.

  • 먼저 Entity 클래스들을 만들어보자.
  • Member 클래스와 Team 클래스를 만들 것이다.
  • 연관관계는 다 대 일이다. 양방향으로 건다. 연관관계의 주인 즉, fk를 갖고있는 건 '다' 쪽인 Member 클래스다.

Member.class

/**
 * Created by GraceLove
 * Github  : https://github.com/gracelove91
 * Blog    : https://gracelove91.tistory.com
 * Email   : govlmo91@gmail.com
 *
 * @author : Eunmo Hong
 * @since : 2020/06/07
 */

@ToString(of = {"id", "username", "age"})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Getter
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;
    private String username;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;


    public Member(String username) {
        this(username, 0, null);
    }


    public Member(String username, int age) {
        this(username, age, null);
    }

    public Member(String username, 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.class

/**
 * Created by GraceLove
 * Github  : https://github.com/gracelove91
 * Blog    : https://gracelove91.tistory.com
 * Email   : govlmo91@gmail.com
 *
 * @author : Eunmo Hong
 * @since : 2020/06/07
 */

@ToString(of = {"id", "name"})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Getter
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "team_id")
    private Long id;
    private String name;

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

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

사용 코드

  • 먼저 EntityManager와 JPAQueryFactory를 필드로 두고, EntityManager는 빈을 주입받자.

  • 그 다음 JPAQueryFactory의 인스턴스를 생성하는데, 생성자 인자로 EntityManager를 주자.

    • JPAQueryFactory는 스레드 세이프 하기 때문에 빈으로 등록시켜서 싱글톤으로 이용해도 괜찮다.
  • 테스트를 해볼 데이터를 @BeforeEach 로 각 테스트 이전에 넣어준다.

    @SpringBootTest
    @Transactional
    class MemberTest {
    
      @PersistenceContext //@Autowired
      EntityManager em;
      JPAQueryFactory queryFactory;
    
      @BeforeEach
      public void testEntity() {
          queryFactory = new JPAQueryFactory(em);
    
          Team teamA = new Team("teamA");
          Team teamB = new Team("teamB");
          em.persist(teamA);
          em.persist(teamB);
    
          Member member1 = new Member("member1", 10, teamA);
          Member member2 = new Member("member2", 20, teamA);
    
          Member member3 = new Member("member3", 30, teamB);
          Member member4 = new Member("member4", 40, teamB);
          em.persist(member1);
          em.persist(member2);
          em.persist(member3);
          em.persist(member4);
      }
      ...
    }
  • 이렇게 한다면 디비에는 다음과 같이 값이 들어갈 것이다.

전체 소스 : https://github.com/gracelove91/querydsl-practice/blob/master/src/test/java/kr/gracelove/querydsl/entity/MemberTest.java

'JAVA > JPA' 카테고리의 다른 글

[JPA]open-session-in-view 를 알아보자  (1) 2020.07.01
@EntityGraph  (0) 2020.02.11
순수 JPA레포지토리의 페이징과 스프링 데이터 JPA레포지토리의 페이징.  (0) 2020.02.10
연관관계의 주인  (0) 2020.01.27
준영속 상태  (0) 2020.01.16

Open-In-View 또는 Open-Session-In-View 또는 Open-EntityManager-In-View 란?

  • 관례상 OSIV 라고 한다.
  • true일 경우 영속성 컨텍스트가 트랜잭션 범위를 넘어선 레이어까지 살아있다.
    • Api라면 클라이언트에게 응답될 때까지, View라면 View가 렌더링될 때까지 영속성컨텍스트가 살아있다.
  • false일 경우 트랜잭션을 종료할 때 영속성 컨텍스트 또한 닫힌다.
  • 기본값은 true 이다

코드

간단히 코드로 false와 true일 때 어떤 차이가 있는지 살펴보자.

  • Member.class
/**
 * @author : Eunmo Hong
 * @since : 2020/06/30
 */

@ToString(of = "name")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    @Builder
    public Member(String name, Team team) {
        this.name = name;
        this.team = team;
    }


}
  • Team.class
/**
 * @author : Eunmo Hong
 * @since : 2020/06/30
 */

@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "team_id")
    private Long id;
    private String teamName;

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

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

Member와 Team은 다 대 일 관계이며, 서로 양방향 연관관계를 맺었다.
Lazy 로딩이기 때문에 Member를 찾게된다면 Member 안의 Team은 프록시객체가 된다.
Team의 프로퍼티를 건드려야 비로소 Team 객체가 로딩된다.

  • Inin.class
/**
 * @author : Eunmo Hong
 * @since : 2020/07/01
 */

@RequiredArgsConstructor
@Component
public class Init implements ApplicationRunner {
    private final MemberRepository memberRepository;
    private final TeamRepository teamRepository;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        Team teamA = teamRepository.save(new Team("teamA"));
        memberRepository.save(Member.builder()
                .name("member1")
                .team(teamA)
                .build());
    }
}

어플리케이션이 구동되면 실행되는 코드다.
이름이 "teamA" 인 Team 객체를 save 한다.
이름이 "member1" 이며 앞서 save한 Team에 소속된 Member를 Save 한다.
그렇다면 DB 상황은 다음과 같다.

  • MemberController.class
/**
 * @author : Eunmo Hong
 * @since : 2020/06/30
 */

@RequiredArgsConstructor
@RestController
public class MemberController {

    private final MemberService memberService;

    @GetMapping("/members/{name}")
    public MemberDto findMember(@PathVariable String name) {
        Member member = memberService.findByName(name);
        return MemberDto.builder()
                .name(member.getName())
                .teamName(member.getTeam().getTeamName())
                .build();
    }
}

간단한 컨트롤러다.
Query String 으로 들어온 String name 으로 해당하는 Member를 찾고,
MemberDto에 Member의 이름과, Member가 속한 Team의 이름을 Set 해주고 JSON으로 반환한다.
화면엔 이렇게 뜬다.

Controller의 return ...teamName(member.getTeam().getTeamName()) 코드에서
Proxy 객체인 Team의 프로퍼티인 TeamName 을 건드렸기 때문에 Lazy loading 이 됐다. 따라서 결과도 잘 나온다.

하지만 OSIVfalse 로 설정한다면?

  • application.yml
    spring:
    datasource:
      url: jdbc:h2:tcp://localhost/~/jpa-sample
      username: sa
      password:
    jpa:
      open-in-view: false

이와 같이 org.hibernate.LazyInitializationException: could not initialize proxy [kr.gracelove.osivdemo.domain.Team#1] - no Session 이라는 익셉션과 함께 500 에러가 뜨는 걸 볼 수 있다.

이는 영속성컨텍스트가 Transaction 범위 바깥인 Controller에서 Lazy loading 을 시도했기 때문이다.
영속성 컨텍스트가 닫혔다면 Lazy loading 또한 할 수 없다!

정리

  • OSIV가 true
    • 사용자에게 응답 또는 view가 렌더링 될 때까지 영속성컨텍스트를 유지한다.
  • OSIV가 false
    • Transaction이 끝나면 영속성컨텍스트 또한 닫힌다.

너무나도 당연하게 OSIV를 true로 두는 게 좋아보이지만! 단점이 존재한다.
영속성 컨텍스트를 유지한다는 건, DB Connection 또한 계속 가지고 있다는 뜻이다.
실시간 트래픽이 중요한 어플리케이션에서는 DB Connection이 모자를 수 있다. 성능이 중요하다면 OSIV는 false로 설정하자.

전체 코드는 다음 링크에서 볼 수 있다.

https://github.com/gracelove91/jpa-practice/tree/master/osiv-demo

레퍼런스

실전! 스프링 부트와 JPA활용 2편 - 김영한님
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-JPA-API%EA%B0%9C%EB%B0%9C-%EC%84%B1%EB%8A%A5%EC%B5%9C%EC%A0%81%ED%99%94/dashboard

'JAVA > JPA' 카테고리의 다른 글

[QueryDSL] QueryDSL 사용법  (1) 2020.07.07
@EntityGraph  (0) 2020.02.11
순수 JPA레포지토리의 페이징과 스프링 데이터 JPA레포지토리의 페이징.  (0) 2020.02.10
연관관계의 주인  (0) 2020.01.27
준영속 상태  (0) 2020.01.16

@EntityGraph

fetch join

  • fetch가 LAZY로 되어있어도 연관된 객체를 즉시 조회한다.


  • 위와 같이 Member -> Team 관계를 맺고 있을 때 fetch 옵션이 LAZY라면 Member 엔티티를 조회할 때, 연관된 Team 엔티티는 프록시 객체로 조회된다.

    • 이때 셀렉트 쿼리는 Member만을 찾는 셀렉트쿼리 한번이 나가고, Member.getTeam().getName(); 같이 프록시 객체의 실제값을 호출할 때 그제서야 Team에 대한 쿼리도 나간다.

    • 만약 Team이 두 개라면 N + 1문제가 터진다. 1은 Member, N(2)은 Team.

    • 다음과 같이 fetch 조인으로 Member와 Team을 조인시켜 한방쿼리로 해결한다.

      @Query("select m from Member as m left join fetch m.team")
      List<Member> findMemberFetchJoin();
      
      @EntityGraph(attributePaths = {"team"})
      List<Member> findEntityGraphByUsername(@Param("username") String username);

순수 JPA레포지토리의 페이징과 스프링 데이터 JPA레포지토리의 페이징.

순수 JPA레포지토리

MemberJpaRepository.class

public List<Member> findByPage(int age, int offset, int limit) {
    return em.createQuery("select m from Member as m" +
                " where m.age = :age" +
                " order by m.username asc", Member.class)
            .setParameter("age", age)
            .setFirstResult(offset)
            .setMaxResults(limit)
            .getResultList();
}

MemberJpaRepositoryTest.class

@Transactional
@Test
void paging() {
    memberJpaRepository.save(new Member("member1", 10));
    memberJpaRepository.save(new Member("member2", 10));
    memberJpaRepository.save(new Member("member3", 10));
    memberJpaRepository.save(new Member("member4", 10));
    memberJpaRepository.save(new Member("member5", 10));
    memberJpaRepository.save(new Member("member6", 10));

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

    List<Member> members = memberJpaRepository.findByPage(age, offset, limit); //member1, member2, member3
    for (Member member : members) {
        System.out.println("member = " + member);
    }
    long totalCount = memberJpaRepository.totalCountByAge(age);

    assertEquals(3, members.size());
    assertEquals(6, totalCount);
}

스프링 데이터 JPA 레포지토리

MemberRepository.class

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

MemberRepositoryTest.class

  • Sorting과 Pageable로 페이징을 추상화했다.
@Transactional
@Test
void paging() {
    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));
    memberRepository.save(new Member("member6", 10));

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

    PageRequest pageRequest = PageRequest.of(offset, limit, Sort.by(Sort.Direction.ASC, "username"));

    Page<Member> members = memberRepository.findByAge(age, pageRequest); //member1, member2, member3
    Page<MemberDto> map = members.map(member -> new MemberDto(member.getId(), member.getUsername(), null));
    for (MemberDto memberDto : map) {
        System.out.println("memberDto = " + memberDto);
    }

    List<Member> content = members.getContent();
    long totalElements = members.getTotalElements();

    assertEquals(6, totalElements);
    assertEquals("member1", content.get(0).getUsername());
}

Count 같은 통계쿼리 쓸 때 주의할 점

  • Page<T>타입의 경우 count 쿼리 또한 날린다.

  • 연관관계 있다면 Left Outer Join 해서 count 쿼리 날리기 떄문에 무겁다 떄문에 countQuery를 커스텀해주자.

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

'JAVA > JPA' 카테고리의 다른 글

[JPA]open-session-in-view 를 알아보자  (1) 2020.07.01
@EntityGraph  (0) 2020.02.11
연관관계의 주인  (0) 2020.01.27
준영속 상태  (0) 2020.01.16
플러시  (0) 2020.01.16

+ Recent posts