순수 JPA 리포지토리와 Querydsl
1. 순수 JPA 리포지토리
📌 MemberJpaRepository
package study.querydsl.repository;
@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {
private final EntityManager em;
public void save(Member member) {
em.persist(member);
}
public Optional<Member> findById(long id) {
Member findMember = em.find(Member.class, id);
return Optional.ofNullable(findMember);
}
public List<Member> findAll() {
return em.createQuery("select m from Member m", Member.class)
.getResultList();
}
public List<Member> findByUsername(String username) {
return em.createQuery("select m from Member m where m.username = :username", Member.class)
.setParameter("username", username)
.getResultList();
}
}
먼저 순수 JPA 리포지토리를 만들었다.
📌 MemberJpaRepositoryTest
package study.querydsl.repository;
@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {
@Autowired
MemberJpaRepository memberJpaRepository;
@Test
void basicTest() {
Member member = new Member("member1", 10);
memberJpaRepository.save(member);
Member findMember = memberJpaRepository.findById(member.getId()).orElse(null);
assertThat(member).isEqualTo(findMember);
List<Member> result1 = memberJpaRepository.findAll();
assertThat(result1).containsExactly(member);
List<Member> result2 = memberJpaRepository.findByUsername("member1");
assertThat(result2).containsExactly(member);
}
}
순수 JPA 리포지토리 기능 테스트.
이제 순수 JPA 리포지토리의 메서드들을 Querydsl로 똑같은 기능을 구현하도록 하겠다. 먼저 JPAQueryFactory를 주입 받기 위해 스프링 빈으로 등록하도록 하자.
📌 QuerydslApplication (루트 클래스)
package study.querydsl;
@SpringBootApplication
public class QuerydslApplication {
public static void main(String[] args) {
SpringApplication.run(QuerydslApplication.class, args);
}
// JPAQueryFactory 빈 등록
@Bean
JPAQueryFactory jpaQueryFactory(EntityManager em) {
return new JPAQueryFactory(em);
}
}
참고로 JPA의 QueryFactory의 동시성 문제는 모두 엔티티 매니저에 의존한다. 스프링이 주입해주는 엔티티 매니저는 실제 동작 시점에 진짜 매니저를 찾아주는 프록시용 가짜 엔티티 매니저다. 이 가짜 엔티티 매니저는 실제 사용 시점에 트랜잭션 단위로 실제 엔티티 매니저(영속성 컨텍스트)를 할당해준다.
JPAQueryFactory를 스프링 빈으로 등록했으니 이제 MemberJpaRepository에서 순수 JPA로 작성했던 코드를 Querydsl로 바꿔보자.
📌 MemberJpaRepository
package study.querydsl.repository;
@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {
private final EntityManager em;
private final JPAQueryFactory queryFactory;
public void save(Member member) {
em.persist(member);
}
public Optional<Member> findById(long id) {
Member findMember = em.find(Member.class, id);
return Optional.ofNullable(findMember);
}
public List<Member> findAll() {
return em.createQuery("select m from Member m", Member.class)
.getResultList();
}
// findAll()을 querydsl로 구현
public List<Member> findAll_Querydsl() {
return queryFactory
.selectFrom(member)
.fetch();
}
public List<Member> findByUsername(String username) {
return em.createQuery("select m from Member m where m.username = :username", Member.class)
.setParameter("username", username)
.getResultList();
}
// findByUsername()을 querydsl로 구현
public List<Member> findByUsername_Querydsl(String username) {
return queryFactory
.selectFrom(member)
.where(member.username.eq(username))
.fetch();
}
}
📌 MemberJpaRepository
package study.querydsl.repository;
@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {
@Autowired
MemberJpaRepository memberJpaRepository;
@Test
void basicQuerydslTest() {
Member member = new Member("member1", 10);
memberJpaRepository.save(member);
Member findMember = memberJpaRepository.findById(member.getId()).orElse(null);
assertThat(member).isEqualTo(findMember);
List<Member> result1 = memberJpaRepository.findAll_Querydsl();
assertThat(result1).containsExactly(member);
List<Member> result2 = memberJpaRepository.findByUsername_Querydsl("member1");
assertThat(result2).containsExactly(member);
}
}
동적 쿼리와 성능 최적화 조회 - Builder 사용
조회 최적화용 DTO를 추가하자.
📌 MemberTeamDto
package study.querydsl.dto;
@Data
public class MemberTeamDto {
private Long memberId;
private String username;
private int age;
private Long teamId;
private String teamName;
@QueryProjection
public MemberTeamDto(Long memberId, String username, int age, Long teamId, String teamName) {
this.memberId = memberId;
this.username = username;
this.age = age;
this.teamId = teamId;
this.teamName = teamName;
}
}
QMemberTeamDto를 생성하기 위해 @QueryProjection을 사용했다.
compileQuerydsl을 실행시켜서 Q파일을 생성할 것.
이제 회원 검색 조건 객체를 정의하도록 하자.
📌 MemberSearchCondition
package study.querydsl.dto;
import lombok.Data;
@Data
public class MemberSearchCondition {
/**
* 검색 조건
* 회원명, 팀명, 나이(ageGoe, ageLoe)
*/
private String username;
private String teamName;
private Integer ageGoe;
private Integer ageLoe;
}
이제 리포지토리에서 Builder를 이용하여 동적쿼리를 생성하도록 하자.
📌 MemberJpaRepository
package study.querydsl.repository;
@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {
private final EntityManager em;
private final JPAQueryFactory queryFactory;
public List<MemberTeamDto> searchByBuilder(MemberSearchCondition condition) {
BooleanBuilder builder = new BooleanBuilder();
if (StringUtils.hasText(condition.getUsername())) {
builder.and(member.username.eq(condition.getUsername()));
}
if (StringUtils.hasText(condition.getTeamName())) {
builder.and(team.name.eq(condition.getTeamName()));
}
if (condition.getAgeGoe()!= null) {
builder.and(member.age.goe(condition.getAgeGoe()));
}
if (condition.getAgeLoe()!= null) {
builder.and(member.age.loe(condition.getAgeLoe()));
}
return queryFactory
.select(new QMemberTeamDto(
member.id,
member.username,
member.age,
team.id,
team.name
))
.from(member)
.leftJoin(member.team, team)
.where(builder)
.fetch();
}
...
}
이제 이 동적 쿼리를 테스트해보자.
📌 MemberJpaRepositoryTest
package study.querydsl.repository;
@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {
@Autowired
MemberJpaRepository memberJpaRepository;
@Autowired
EntityManager em;
/**
* 검색 조건
* - 나이 5 이상
* - 나이 40 이하
* - 팀명이 'teamB'인 경우
*/
@Test
void searchTest() {
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);
em.flush();
em.clear();
MemberSearchCondition condition = new MemberSearchCondition();
condition.setAgeGoe(35);
condition.setAgeLoe(40);
condition.setTeamName("teamB");
List<MemberTeamDto> result = memberJpaRepository.searchByBuilder(condition);
assertThat(result).extracting("username").containsExactly("member4");
}
}
/* select
member1.id,
member1.username,
member1.age,
team.id,
team.name
from
Member member1
left join
member1.team as team
where
team.name = ?1
and member1.age >= ?2
and member1.age <= ?3 */ select
member0_.member_id as col_0_0_,
member0_.username as col_1_0_,
member0_.age as col_2_0_,
team1_.team_id as col_3_0_,
team1_.name as col_4_0_
from
member member0_
left outer join
team team1_
on member0_.team_id=team1_.team_id
where
team1_.name=?
and member0_.age>=?
and member0_.age<=?
검색 조건에 해당하는 동적쿼리가 생성된 것을 알 수 있다.
나중에 여기에 페이징처리를 추가하면 된다.
만약에 검색 조건이 아무것도 없으면 어떻게 될까? 이것도 코드로 확인해보자.
📌 MemberJpaRepositoryTest
package study.querydsl.repository;
@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {
@Autowired
MemberJpaRepository memberJpaRepository;
@Autowired
EntityManager em;
/**
* 검색 조건에 아무런 조건이 없을 경우
*/
@Test
void searchTest2() {
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);
MemberSearchCondition condition = new MemberSearchCondition();
List<MemberTeamDto> result = memberJpaRepository.searchByBuilder(condition);
for (MemberTeamDto memberTeamDto : result) {
System.out.println("memberTeamDto.getUsername() = " + memberTeamDto.getUsername());
}
}
}
/* select
member1.id,
member1.username,
member1.age,
team.id,
team.name
from
Member member1
left join
member1.team as team */ select
member0_.member_id as col_0_0_,
member0_.username as col_1_0_,
member0_.age as col_2_0_,
team1_.team_id as col_3_0_,
team1_.name as col_4_0_
from
member member0_
left outer join
team team1_
on member0_.team_id=team1_.team_id
memberTeamDto.getUsername() = member1
memberTeamDto.getUsername() = member2
memberTeamDto.getUsername() = member3
memberTeamDto.getUsername() = member4
검색 조건이 없을시에는 모든 데이터를 조회하는 것을 알 수 있다.
만약에 데이터 수가 적으면 모든 데이터를 조회해도 상관 없지만 데이터 수가 많을 경우 큰 장애로 이어질 수 있다. 그러므로 검색 조건이 아예 없을 경우를 대비해서 방어 처리 코드를 작성해야 한다.
동적 쿼리와 성능 최적화 조회 - Where절 파라미터 사용
기본의 BooleanBuilder로 동적 쿼리를 처리한 것을 Where절에 다중 파라미터 방식으로 바꿔보도록 하겠다.
📌 MemberJpaRepository
package study.querydsl.repository;
@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {
private final EntityManager em;
private final JPAQueryFactory queryFactory;
/**
* 검색 조건 동적 쿼리
* where 다중 파라미터 사용
*/
public List<MemberTeamDto> search(MemberSearchCondition condition) {
return queryFactory
.select(new QMemberTeamDto(
member.id,
member.username,
member.age,
team.id,
team.name
))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
)
.fetch();
}
private BooleanExpression usernameEq(String username) {
return StringUtils.hasText(username) ? member.username.eq(username) : null;
}
private BooleanExpression teamNameEq(String teamName) {
return StringUtils.hasText(teamName) ? team.name.eq(teamName) : null;
}
private BooleanExpression ageGoe(Integer ageGoe) {
return ageGoe != null ? member.age.goe(ageGoe) : null;
}
private BooleanExpression ageLoe(Integer ageLoe) {
return ageLoe != null ? member.age.loe(ageLoe) : null;
}
...
}
📌 MemberJpaRepositoryTest
package study.querydsl.repository;
@SpringBootTest
@Transactional
class MemberJpaRepositoryTest {
@Autowired
MemberJpaRepository memberJpaRepository;
@Autowired
EntityManager em;
/**
* 검색 조건
* - 나이 5 이상
* - 나이 40 이하
* - 팀명이 'teamB'인 경우
*/
@Test
void searchTest3() {
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);
em.flush();
em.clear();
MemberSearchCondition condition = new MemberSearchCondition();
condition.setAgeGoe(35);
condition.setAgeLoe(40);
condition.setTeamName("teamB");
List<MemberTeamDto> result = memberJpaRepository.search(condition);
assertThat(result).extracting("username").containsExactly("member4");
}
}
조회 API 컨트롤러 개발
조회용 API를 만들기 전에 샘플 데이터를 추가해야한다. 근데 샘플 데이터를 만들기 전에 샘플 데이터가 추가가 테스트 케이스 실행에 영향을 주지 않기 위해서 설정 파일에서 프로파일을 설정해야 한다.
1. 프로파일 설정
📌 src/main/resources/application.yml
spring:
profiles:
active: local
output.ansi.enabled: always
datasource:
url: jdbc:h2:tcp://localhost/~/querydsl
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
# show_sql: true
format_sql: true
use_sql_comments: true
# data:
# web:
# pageable:
# default-page-size: 5
# max-page-size: 2000
# one-indexed-parameters: true
logging.level:
org.hibernate.SQL: debug
# org.hibernate.type: trace
여기서 'spring.profiles.active=[명칭]'으로 지정한 것이 중요하다.
보통 로컬 서버에는 'local', 개발서버에는 'dev', 운영서버에는 'real', 테스트 서버에는 'test'로 지정한다
📌 src/test/resources/application.yml
spring:
profiles:
active: test
output.ansi.enabled: always
datasource:
url: jdbc:h2:tcp://localhost/~/querydsl
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
# show_sql: true
format_sql: true
use_sql_comments: true
logging.level:
org.hibernate.SQL: debug
# org.hibernate.type: trace
테스트 서버에는 profiles의 active 명칭을 test로 구분하여 로컬서버와 테스트 서버를 서로 분리했다.
이렇게 분리하면 main 소스 코드와 테스트 소스 코드 실행시 프로파일을 분리할 수 있다.
2. 샘플 데이터 추가
package study.querydsl.controller;
@Profile("local")
@Component
@RequiredArgsConstructor
public class InitMember {
private final InitMemberService initMemberService;
@PostConstruct
public void init() {
initMemberService.init();
}
@Component
static class InitMemberService {
@PersistenceContext
private EntityManager em;
@Transactional
public void init() {
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
em.persist(teamA);
em.persist(teamB);
for (int i=0; i<100; i++) {
Team selectedTeam = i%2==0 ? teamA : teamB;
em.persist(new Member("member" + i, i, selectedTeam));
}
}
}
}
@Profile 애노테이션의 속성값을 'local'로 설정하여 'local'로 지정된 서버만 실행되도록 설정했다.
그러므로 테스트 코드에서는 해당 코드가 실행되지 않는다.
@Profile 애노테이션에 속성 값으로 들어가는 값이 프로파일의 현재 값과 일치할 시 스프링 빈으로 등록한다.
로컬서버를 실행시키면 저렇게 profiles의 active를 지정된 명칭이 나온다.
만약에 테스트서버를 실행시켰다면 아래의 그림처럼 test로 나온다.
🧷 참고. @PostConstruct 와 @Transactional은 같이 사용할 수 없다.
@PostConstruct는 해당 빈 자체만 생성되었다고 가정하고 호출됩니다. 해당 빈에 관련된 AOP등을 포함한, 전체 스프링 애플리케이션 컨텍스트가 초기화 된 것을 의미하지는 않습니다.
트랜잭션을 처리하는 AOP등은 스프링의 후 처리기(post processer)가 완전히 동작을 끝내서, 스프링 애플리케이션 컨텍스트의 초기화가 완료되어야 적용됩니다.
정리하면 @PostConstruct는 해당빈의 AOP 적용을 보장하지 않습니다.
이런 것을 우회하는 여러가지 방법이있는데요. 제가 보여드린 방법(다른 스프링 빈을 호출해서 사용하는 방법)을 포함해서, AOP를 사용하지 않고 트랜잭션을 직접 코딩하는 방법, 애플리케이션 컨텍스트가 완전히 초기화 된 이벤트를 받아서 호출하는 방법 등이 있습니다.
관련해서 다음 링크를 참고해보시면 더욱 자세한 도움을 받을 수 있습니다^^
https://sorjfkrh5078.tistory.com/311
3. 조회 API 컨트롤러
package study.querydsl.controller;
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberJpaRepository memberJpaRepository;
@GetMapping("/v1/members")
public List<MemberTeamDto> searchMemberV1(MemberSearchCondition condition) {
return memberJpaRepository.search(condition);
}
}
4. 포스트맨 실행
'http://localhost:8080/v1/members?teamName=teamB&ageGoe=31&ageLoe=35'
검색 조건이 잘 나온것을 알 수 있다.
👀 참고 자료
https://www.inflearn.com/course/Querydsl-%EC%8B%A4%EC%A0%84/dashboard
https://sorjfkrh5078.tistory.com/311
'[JPA] > 실전! Querydsl' 카테고리의 다른 글
[Querydsl] 스프링 데이터 JPA가 제공하는 Querydsl 기능 (0) | 2022.04.29 |
---|---|
[Querydsl] 스프링 데이터 JPA 와 Querydsl (0) | 2022.04.28 |
[QueryDsl] 중급 문법 (0) | 2022.04.26 |
[Querydsl] 기본 문법 (0) | 2022.04.24 |
[QueryDSL] 프로젝트 환경 설정 (0) | 2022.04.24 |