Spring에 대한 공부를 하며, Spring에서 제공하는 DI 덕분에 객체지향 설계 원칙인 SOLID 중 지켜지지 않던 O(OCP), D(DIP)를 지키며 개발할 수 있다는 것에 공감할 수 있었다. 문득, DI에 대해 공부해보며 궁금증이 생긴 부분이 있었고 이를 Spring에서 어떻게 해결해주는지에 대하여 알아본 결과를 정리해보았다.
궁금증....만약 인터페이스 1개에 구현체가 2개있는 경우는 의존성 주입이 어떻게 될까??
실제 코드를 통해 알아 보았다. 아래와 같은 Car라는 Interface가 있다.
public interface Car {
int go();
}
해당 인터페이스를 구현하는 구현체 k5, k7이 아래와 같이 작성되있다고 가정해보자. 참고로 @Compenent의 경우 해당 클래스를 Spring Bean으로 등록한다는 것을 말한다.
@Component
public class k5 implements Car{
@Override
public int go() {
return 5;
}
}
@Component
public class k7 implements Car{
@Override
public int go() {
return 7;
}
}
그리고 테스트를 진행해 보았다.
@Autowired
private Car car;
참고로,@Autowired의 경우 Car Interface에 맞는 Bean으로 등록 된 객체를 가져와 Spring에서 자동으로 DI를 해달라고 명시해주는 어노테이션이다. 하지만, car 부분에 빨간줄이 생기며 제대로 DI가 안된다는 것을 알 수 있었다.
원인의 에러는 다음과 같았다.
Could not autowire. There is more than one bean of 'Car' type.
Beans: k5 (k5.java)
k7 (k7.java)
직역해보면 autowire할 수 없다. 'Car' type의 bean이 한개보다 많다. 라고 한다.
결과적으로 제대로 DI를 통해 객체를 주입받지만, SOLID의 원칙인 OCP와 DIP가 위배된 코드를 확인할 수 있다.
2번째 방법을 통해 해결해본 코드이다.
@Qualifier("k5")
@Autowired
private Car car;
결과적으로 제대로 DI를 통해 객체를 주입받고, OCP도 지키며 DIP도 지킨다고 볼 수 있다. Spring에서도@qualifier를 추가하여 문제를 해결하라고 안내하긴 한다. 하지만,,@qualifier("k5")를 사용하다가 후에 k7으로 주입하고 싶으면@qualifier("k5")
@qualifier("k7") 변경 작업이 불가피하기때문에 결과적으로 보면 OCP와 DIP를 위반한다고 볼 수 있다.
3번째 방법을 통해 해결해본 코드이다.
@Component
@Primary
public class k5 implements Car{
@Override
public int go() {
return 5;
}
}
해당@primary의 경우 구현체 중 우선순위를 정해준 것이기 때문에, 같은 interface의 구현체 중 1개만 정해야 하는 단점이 있지만, 테스트 클래스에서 수정하는 것이 아닌 구현체 클래스에서 수정하는 것을 볼 수 있다. 또한, OCP와 DIP도 지켜주는 모습이다.
4번째 방법을 통해 해결하는 방법은 사용하는 구현체 딱 한 개 말고는@component를 삭제하여 Bean에 등록을 안시켜주면 된다. Spring에서 Interface에 의존성 주입을 할 때 1개의 구현체만 있는경우는 문제가 생기지 않기 떄문이다.
결과적으로, 3번째와 4번째 방법이 좋은 해결 방법이라고 볼 수 있다.
가장 좋은 방법은 4번째 방법으로, 사용할 하나의 구현체만 Bean으로 등록하는 것이 좋은 방법인 것 같다.
하지만, 여러개의 구현체를 두고 상황에 맞춰서 각각 다른 구현체를 사용해야 한다면 2번째 방법을 통해 해결하는 것이 최선인 것 같다.
private final Logger log = LoggerFactory.getLogger(getClass());
가 있습니다. 실제로 @Slf4j의 기능은 위의 한줄의 코드를 줄여준다고 보면 됩니다.
"trace log ="+name으로 로그를 찍지 않는 이유
만약 로그 레벨을 info로 설정한 경우 trace와 debug의 로그는 출력이 되지 않을 것 입니다.
하지만 로그를 "info log ={}", name의 방식이 아닌 "trace log ="+name으로 사용하는 경우, java는 "trace log ="+name를 읽고 cpu와 memory를 할당하지만, 실제로는 로그가 찍히지 않기 때문에 불필요한 cpu와 메모리를 낭비하게 됩니다.
반면, "info log = {}", name 방식을 사용하는 경우는 cpu와 메모리를 미리 할당하지 않는다고 합니다. 그렇기때문에 "info log ={}", name과 같은 방식을 사용합시다!
@Override
public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
List<MemberTeamDto> content = queryFactory
.select(new QMemberTeamDto(
member.id.as("memberId"),
member.username,
member.age,
team.id.as("teamId"),
team.name.as("teamName")
))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamnameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe()))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = queryFactory
.selectFrom(member)
.leftJoin(member.team, team) // 어떤 상황에는 join이 필요없는 경우가 있다.
.where(
usernameEq(condition.getUsername()),
teamnameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe()))
.fetchCount();
return new PageImpl<>(content, pageable, total);
}
별도로 조회하는 방법은 성능을 조금 높이기 위함일 수 있습니다. 예를들어, count 쿼리를 조회한 뒤 count가 0인 경우 content 쿼리를 하지 않을 수 있습니다. 아니면 카운트 쿼리를 content 쿼리보다 단순하게 조회할 수 있는 경우 성능상 이점이 생길 수 있습니다.
참고 Querydsl이 향후 fetchCount()를 제공하지 않습니다. fetchCount() => fetchOne() fetchresults() => fetch() 권장
CountQuery 최적화
count 쿼리를 생략할 수 있는 경우는 아래와 같다.
페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
마지막 페이지 일 때 (offset + 컨텐츠 사이즈를 더해서 전체 사이즈 구함)
위의 최적화를 할 수 있는 코드는 count쿼리를 따로 사용할 때 사용할 수 있다.
@Override
public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
List<MemberTeamDto> content = queryFactory
.select(new QMemberTeamDto(
member.id.as("memberId"),
member.username,
member.age,
team.id.as("teamId"),
team.name.as("teamName")
))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamnameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe()))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// countQuery에서는 fetchCount()를 해야 카운트 쿼리가 날라간다.
JPAQuery<Member> countQuery = queryFactory
.selectFrom(member)
.leftJoin(member.team, team) // 어떤 상황에는 join이 필요없는 경우가 있다.
.where(
usernameEq(condition.getUsername()),
teamnameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
);
// PageableExecutionUtils.getPage에서는 content pagealbe의 토탈 사이즈를 보고 count 쿼리를 생략할 수 있는 경우에
// fetchCount() 쿼리 함수를 실행하지 않는다.
return PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetchCount());
//return new PageImpl<>(content, pageable, total);
}
참고 Querydsl이 향후 fetchCount()를 제공하지 않습니다. fetchCount() => fetchOne()
PagealbeExecutionUtils.getPage에서는 content, pageable의 토탈 사이즈를 보고 count 쿼리를 생략할 수 있는 경우에 fetchCount()를 실행하지 않기 때문에 count쿼리가 필요하지 않는 경우 쿼리가 발생하지않는다.
getPage코드는 아래와 같다.
public static <T> Page<T> getPage(List<T> content, Pageable pageable, LongSupplier totalSupplier) {
Assert.notNull(content, "Content must not be null!");
Assert.notNull(pageable, "Pageable must not be null!");
Assert.notNull(totalSupplier, "TotalSupplier must not be null!");
if (pageable.isUnpaged() || pageable.getOffset() == 0) {
if (pageable.isUnpaged() || pageable.getPageSize() > content.size()) {
return new PageImpl<>(content, pageable, content.size());
}
return new PageImpl<>(content, pageable, totalSupplier.getAsLong());
}
if (content.size() != 0 && pageable.getPageSize() > content.size()) {
return new PageImpl<>(content, pageable, pageable.getOffset() + content.size());
}
return new PageImpl<>(content, pageable, totalSupplier.getAsLong());
}
Controller 개발
이제 controller를 개발해보자.
MemberController 코드 추가
public class MemberController {
...
@GetMapping("/v2/members")
public Page<MemberTeamDto> seachMemberV2(MemberSearchCondition condition, Pageable pageable) {
return memberRepository.searchPageSimple(condition, pageable);
}
@GetMapping("/v3/members")
public Page<MemberTeamDto> seachMemberV3(MemberSearchCondition condition, Pageable pageable) {
return memberRepository.searchPageComplex(condition, pageable);
}
}
이제 postman에서 아래와 같이 page, size를 바꿔가며 데이터를 조회해보면 페이지별로 조회가 가능하다.
localhost:8080/v2/members?page=0&size=5
테스트를 해보니 V3의 경우 데이터 < 조회한 데이터 크기 경우 count 쿼리가 나가지 않는 것을 확인할 수 있습니다.
스프링 데이터 정렬
Spring Data JPA는 자신의 정렬(Sort)를 Querydsl의 정렬(OrderSpecifier)로 편리하게 변경하는 기능을 제공한다. 해당 부분은 뒤에 Spring Data JPA가 제공하는 Querydsl 기능에서 살펴본다.
Spring Data Sort를 Querydsl의 OrderSpecifier로 변환
JPAQuery<Member> query = queryFactory
.selectFrom(member);
for (Sort.Order o : pageable.getSort()) {
PathBuilder pathBuilder = new PathBuilder(member.getType(),
member.getMetadata());
query.orderBy(new OrderSpecifier(o.isAscending() ? Order.ASC : Order.DESC,
pathBuilder.get(o.getProperty())));
}
List<Member> result = query.fetch();
참고 정렬은 조건이 조금만 복잡해져도 Pagealbe의 sort기능을 사용하기 어렵다고 한다. 루트 엔티티 범위를 넘어가는 동적 정렬 기능이 필요하면 스프링 데이터 페이징이 제공하는 sort를 사용하기 보다는 파라미터를 받아서 직접 처리하는 것을 권장.
이렇게 각각 기능들을 메소드로 분리함으로써 가독성도 높아지고, 재사용성도 생긴다. 개인적으로 BooleanBuilder보다 where절이 객체지향 프로그래밍에 맞는 코드인 것 같다.
조회 API 컨트롤러 개발
사전 준비
main에 있는 application.yml을 test에도 추가해줍니다. (resources 파일도 추가해야 함)
그리고 설정을 아래와 같이 local부분만 다르게 test로 작성해줍니다. (main도 profiles: active: local 추가)
spring:
profiles:
active: local // main (test 경우에는 local이 아닌 test)
datasource:
url: jdbc:h2:tcp://localhost/~/querydsl
...
그 후 InitMember 클래스 추가 (애플리케이션 로딩 시점에 데이터를 추가하기 위한 클래스 입니다. 테스트 용도)
package study.querydsl.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import study.querydsl.entity.Member;
import study.querydsl.entity.Team;
import javax.annotation.PostConstruct;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
@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 selectTeam = i % 2 == 0? teamA : teamB;
em.persist(new Member("member"+i, i, selectTeam));
}
}
}
}
InitMemberService를 따로 만들어준 이유는 @PostConstruct와 @Transactional을 함께 사용하지 못하기 때문에 분리하기 위해 만들어 주었다. @Profiles("local")은 이전에 application.yml에 설정해주었던 profiles 값이 Local인 경우에만 구동시켜준다.
사전 준비는 끝났다. 게임을 시작해보자.
MemberController 생성
package study.querydsl.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import study.querydsl.dto.MemberSearchCondition;
import study.querydsl.dto.MemberTeamDto;
import study.querydsl.repository.MemberJpaRepository;
import java.util.List;
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberJpaRepository memberJpaRepository;
@GetMapping("/v1/members")
public List<MemberTeamDto> seachMemberV1(MemberSearchCondition condition) {
return memberJpaRepository.search(condition);
}
}
만들고 주소/v1/members로 검색하면 이전에 넣어주었던 데이터들이 나온다. (웹에서 접근하면 보기 힘들기 때문에 postman과 같은 api툴을 사용하기를 바란다!)
위와같이 파라미터 조건을 추가하면 조건에 맞게 조회된 데이터를 확인할 수 있다.
참고로 아무런 파라미터를 설정하지 않는다면 모든데이터가 조회되기 때문에, 사용할 때 각별히 주의하자!!!!
@Test
public void findDtoByJPQL() {
List<MemberDto> result = em.createQuery("select new study.querydsl.dto.MemberDto(m.username, m.age) from Member m", MemberDto.class)
.getResultList();
for (MemberDto memberDto : result) {
System.out.println("MemberDTO = " + memberDto);
}
}
이전에 사용한 바 있는 JPQL을 사용해 DTO로 변환해 조회하는 코드이다. 해당 코드는 new Operation을 사용해야하기 때문에 DTO의 package 명을 다 적어줘야 한다. 이때문에 코드가 지저분해지고, 코드 작성에도 불편함이 있다. 또한, 생성자 방식만 지원하는 불편함도 있다.
하지만, querydsl은 위의 문제를 깔끔하게 해결하는 방법을 제시한다.
Querydsl 빈 생성(Bean population)
결과를 DTO 반환할 때 사용한다. 아래와 같은 3가지 방법을 지원한다.
프로퍼티 접근
필드 직접 접근
생성자 사용
프로퍼티 접근 - Setter
@Test
public void findDtoBySetter() {
List<MemberDto> result = queryFactory
.select(Projections.bean(MemberDto.class,
member.username,
member.age))
.from(member)
.fetch();
for (MemberDto memberDto : result) {
System.out.println("MemberDto = " + memberDto);
}
}
필드 직접 접근
@Test
public void findDtoByField() {
List<MemberDto> result = queryFactory
.select(Projections.fields(MemberDto.class, // getter, setter 상관없이 값을 넣는다.
member.username,
member.age))
.from(member)
.fetch();
for (MemberDto memberDto : result) {
System.out.println("MemberDto = " + memberDto);
}
}
생성자 접근
@Test
public void findDtoByConstructor() {
List<MemberDto> result = queryFactory
.select(Projections.constructor(MemberDto.class, // 파라미터 생성자에 값이 딱딱 들어간다.
member.username,
member.age))
.from(member)
.fetch();
for (MemberDto memberDto : result) {
System.out.println("MemberDto = " + memberDto);
}
}
필드 값이 다른 dto 접근 - 프로퍼티 접근법만 예시 (필드, 생성자 접근 동일)
@Test
public void findUserDtoBySetter() {
List<UserDto> result = queryFactory
.select(Projections.bean(UserDto.class, //setter를 통해 들어간다.
member.username.as("name"),
member.age))
.from(member)
.fetch();
for (UserDto memberDto : result) {
System.out.println("MemberDto = " + memberDto);
}
}
.as("바꾸고싶은 필드명")으로 필드명을 바꿔주면 된다.
서브쿼리 추가 dto 접근 - 필드 접근법만 예시 (프로퍼티, 생성자 접근 동일)
@Test
public void findUserDtoByFieldSubQuery() {
QMember memberSub = new QMember("memberSub");
List<UserDto> result = queryFactory
.select(Projections.fields(UserDto.class, // getter, setter 상관없이 값을 넣는다.
member.username.as("name"),
Expressions.as(JPAExpressions
.select(memberSub.age.max())
.from(memberSub), "age")
))
.from(member)
.fetch();
for (UserDto userDto : result) {
System.out.println("userDto = " + userDto);
}
}
ExpressionUtils.as(source, alias) : 필드나 서브쿼리에 별칭 적용
프로젝션과 결과 반환 - @QueryProjection
@QueryProjection을 사용하기 위해서는 준비 작업이 필요합니다.
@Data
@NoArgsConstructor
public class MemberDto {
private String username;
private int age;
@QueryProjection
public MemberDto(String username, int age) {
this.username = username;
this.age = age;
}
}
사용하고자하는 dto 클래스의 생성자에 @QueryProjection을 추가해줍니다. 실행을 한번 해보거나 이전에 돌렸던 compilequerydsl을 눌러줍니다. 그렇게되면 @MemberDto가 생성됩니다. 이제 사용해봅시다.
생성자 + @QueryProjection
@Test
public void findDtoByQueryProjection() {
List<MemberDto> result = queryFactory
.select(new QMemberDto(member.username, member.age))
.from(member)
.fetch();
for (MemberDto memberDto : result) {
System.out.println("memberDto = " + memberDto);
}
}
생성자 dto 보다의 장점은 컴파일 시점에 오류를 잡아준다는 것 입니다. 생성자 dto는 런타임 시점에 오류를 잡아주지만, 생성자+@QueryProjection은 컴파일 시점에 오류를 잡아줍니다.
위 @QueryProjection은 다 좋지만 2가지 단점이 존재합니다.
QMemberDto와 같이 Q멤버를 만들어야 해야 합니다.
Q멤버로 만드는 경우, dto는 querydsl에 의존성을 가지게 됩니다.
해당 문제는 아키텍쳐 관점에서 문제가 생긴다고 한. 해당 dto를 repository, service, application 계층 모두 사용하는데 querydsl에 의존적인 dto를 모든 계층에서 사용하는데 어떠한 문제가 있을지는 아직 모르겠다,,
distinct
@Test
public void findDtoByQueryProjection() {
List<MemberDto> result = queryFactory
.select(new QMemberDto(member.username, member.age)).distinct()
.from(member)
.fetch();
for (MemberDto memberDto : result) {
System.out.println("memberDto = " + memberDto);
}
}
참고 벌크 연산은 이전에도 말했지만, 조심해야할 것이 있다. 영속성 컨텍스트를 무시하고 DB에 직접 접근해서 update 쿼리문을 날리기 때문이다. 이것이 문제가 되는 것은 영속성 컨텍스트에 update쿼리문에 날라간 데이터가 있고, 벌크 연산 후 데이터를 가져오면 DB가 아닌 영속성 컨텍스트에서 가져오기 때문에 벌크연산된 데이터가 아닌 이전의 데이터를 가져온다. 그렇기 때문에 벌크 연산후에는 em.clear()를 해주자.
쿼리 한번으로 대량 데이터 추가, 곱, 삭제
@Test
public void bulkAdd() {
long count = queryFactory
.update(member)
.set(member.age, member.age.add(1)) //곱하기는 add 대신 multiply
.execute();
}
@Test
public void bulkDelete() {
long count = queryFactory
.delete(member)
.where(member.age.gt(18))
.execute();
}
해당 벌크 연산도 벌크 연산 후 em.clear()를 필수로 해주어야 한다.
SQL function 호출하기
SQL function은 JPQ와 같이 Dialect에 등록된 내용만 호출할 수 있다.
아래 예시는 member string을 m string으로 replace하는 함수를 사용한 예제이다.
@Test
public void slqFunction() {
List<String> result = queryFactory
.select(Expressions.stringTemplate("function('replace', {0}, {1}, {2})",
member.username, "member", "m"))
.from(member)
.fetch();
for (String s : result) {
System.out.println("result = " + s);
}
}
replace말고도 소문자로 변환하는 경우는 아래예시이다.
@Test
public void sqlFunction2() {
List<String> result = queryFactory
.select(member.username)
.from(member)
.where(member.username.eq(Expressions.stringTemplate("function('upper', {0})", member.username)))
//.where(member.username.eq(member.username.upper()))
.fetch();
for (String s : result) {
System.out.println("result = " + s);
}
}
비교할 데이터를 모두 소문자로 비교한 뒤, 비교할 때는 위와같이 소문자로 변경한 뒤 비교할 수 있다.
참고로, lower 같은 ansi 표준 함수들은 querydsl이 상당부분 내장하고 있다. 따라서 아래와 같이 처리해도 결과는 같다.
@Test
public void startJPQL() throws Exception {
Member findByJPQL = em.createQuery("select m from Member m where m.username = :username", Member.class)
.setParameter("username", "member1")
.getSingleResult();
Assertions.assertThat(findByJPQL.getUsername()).isEqualTo("member1");
}
@Test
public void startQuerydsl() {
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember m = QMember.member;
Member findByQuerydsl = queryFactory.select(m)
.from(m)
.where(m.username.eq("member1"))
.fetchOne();
Assertions.assertThat(findByQuerydsl.getUsername()).isEqualTo("member1");
}
JPQL과 Querydsl 테스트하는 코드의 차이이다.
Querydsl 장점
컴파일시점에 오류를 잡아낸다. (JPQL은 메소드가 실행될 때 오류(Runtime Error)를 발견)
자바 코드로 쿼리를 작성할 수 있다.
파라미터 바인딩을 자동으로 해결해 준다.
Code Assistant도 잘되어있다.
기본 Q-Type 활용
Q클래스 인스턴스를 사용하는 2가지 방법
QMember m = new QMember("m");
QMember m = QMember.member;
참고로 QMember.member은 QMember객체에 만들어놓은 객체이다.
@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QMember extends EntityPathBase<Member> {
...
public static final QMember member = new QMember("member1");
}
코드를 더 심플하게 하는 방법
import static study.querydsl.entity.QMember.*;
위와 같이 import를 해주면 아래와 같이 코드를 만들 수 있다.
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
Member findByQuerydsl = queryFactory.select(member)
.from(member)
.where(member.username.eq("member1"))
.fetchOne();
@Test
public void search() {
Member findMember = queryFactory
.selectFrom(member)
.where(member.username.eq("member1")
.and(member.age.eq(10)))
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
}
기본 검색 조건은 .and(), or()를 메서드 체인으로 연결할 수 있다.
select, from을 selectfrom으로 합칠 수 있음.
JPQL이 제공하는 모든 검색 조건 제공
member.username.eq("member1") // username = 'member1'
member.username.ne("member1") //username != 'member1'
member.username.eq("member1").not() // username != 'member1'
member.username.isNotNull() //이름이 is not null
member.age.in(10, 20) // age in (10,20)
member.age.notIn(10, 20) // age not in (10, 20)
member.age.between(10,30) //between 10, 30
member.age.goe(30) // age >= 30
member.age.gt(30) // age > 30
member.age.loe(30) // age <= 30
member.age.lt(30) // age < 30
member.username.like("member%") //like 검색
member.username.contains("member") // like ‘%member%’ 검색
member.username.startsWith("member") //like ‘member%’ 검색
//List
List<Member> fetch = queryFactory
.selectFrom(member)
.fetch();
//단 건
Member findMember1 = queryFactory
.selectFrom(member)
.fetchOne();
//처음 한 건 조회
Member findMember2 = queryFactory
.selectFrom(member)
.fetchFirst();
//페이징에서 사용
QueryResults<Member> results = queryFactory
.selectFrom(member)
.fetchResults();
//count 쿼리로 변경
long count = queryFactory
.selectFrom(member)
.fetchCount();
정렬
/**
* 회원 정렬 순서
* 1. 회원 나이 내림차순(desc)
* 2. 회원 이름 올림차순(asc)
* 단, 2에서 회원 이름이 없으면 마지막에 출력(nulls last)
*/
@Test
public void sort() throws Exception {
//given
em.persist(new Member(null, 100));
em.persist(new Member("member5", 100));
em.persist(new Member("member6", 100));
//when
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(100))
.orderBy(member.age.desc(), member.username.asc().nullsLast())
.fetch();
//then
Member member5 = result.get(0);
Member member6 = result.get(1);
Member memberNull = result.get(2);
Assertions.assertThat(member5.getUsername()).isEqualTo("member5");
Assertions.assertThat(member6.getUsername()).isEqualTo("member6");
Assertions.assertThat(memberNull.getUsername()).isNull();
}
위와 같이 orderBy안에 파라미터로 정렬 할 수 있다.
페이징
조회 건수 제한
@Test
public void paging(){
List<Member> result = queryFactory
.selectFrom(member)
.orderBy(member.username.desc())
.offset(1) // 0부터 시작
.limit(2) // 최대 2건 조회
.fetch();
assertThat(result.size()).isEqualTo(2);
}
전체 조회 수가 필요하다면??
fetch() 대신 fetchResults()로 바꿔주면 된다.
주의 count 쿼리가 실행되니 성능상 주의해야 한다. 성능이 나오지 않는다면, count 쿼리를 분리해야 한다.
집합
집합 함수
@Test
public void aggregation() {
List<Tuple> result = queryFactory
.select(
member.count(), // 개수
member.age.sum(), // 합
member.age.avg(), // 평균
member.age.max(), // 가장 큰 값
member.age.min()) // 가장 작은 값
.from(member)
.fetch();
//Tuple은 querydsl이 제공하는 값
Tuple tuple = result.get(0);
assertThat(tuple.get(member.count())).isEqualTo(4);
assertThat(tuple.get(member.age.sum())).isEqualTo(100);
assertThat(tuple.get(member.age.avg())).isEqualTo(25);
assertThat(tuple.get(member.age.max())).isEqualTo(40);
assertThat(tuple.get(member.age.min())).isEqualTo(10);
}
위와 같이 java 코드처럼 사용가능하다.
GroupBy
@Test
public void group() throws Exception {
//given
List<Tuple> result = queryFactory
.select(team.name, member.age.avg())
.from(member)
.join(member.team, team)
.groupBy(team.name) // 팀 이름으로 그룹
.fetch();
//when
Tuple teamA = result.get(0);
Tuple teamB = result.get(1);
//then
assertThat(teamA.get(team.name)).isEqualTo("teamA");
assertThat(teamA.get(member.age.avg())).isEqualTo(15);
assertThat(teamB.get(team.name)).isEqualTo("teamB");
assertThat(teamB.get(member.age.avg())).isEqualTo(35);
}
groupBy, 그룹화된 결과를 제한하려면 having
GroupBy, having
.groupBy(team.name) // 팀 이름으로 그룹
.having(team.name.eq("teamB"))
위처럼 "teamB"와 같은 team만 가져올 수 있습니다.
조인 - 기본 조인
/**
* 팀 A에 소속된 모든 회원
*/
@Test
public void join() throws Exception {
List<Member> result = queryFactory
.selectFrom(member)
.join(member.team, team)
.where(team.name.eq("teamA"))
.fetch();
assertThat(result).extracting("username").containsExactly("member1", "member2");
}
위의 방식으로 join을 사용할 수 있다. leftjoin, rightjoin 모두 사용 가능하다.
연관관계가 없는 경우 from 절에 모든 데이터를 가져온 뒤, where 문으로 theta join할 수 있다.
조인 - on절
on절을 활용한 조인
조인 대상 필터링
연관관계 없는 엔티티 외부 조인
조인 대상 필터링
/**
* 예) 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회
* jpql : select m, t from Member m left join m.team t on t.name = "teamA"
*/
@Test
public void join_on_filtering() throws Exception {
List<Tuple> result = queryFactory
.select(member, team)
.from(member)
.join(member.team, team)
.on(team.name.eq("teamA"))
.fetch();
for(Tuple tuple : result){
System.out.println("tuple = " + tuple);
}
}
연관관계 없는 엔티티 외부 조인
/**
* 연관관계가 없는 엔티티 외부 조인
* 회원의 이름이 팀 이름과 같은 대상 외부 조인
* jpql : select m, t from Member m LEFT JOIN Team t on m.username = t.name
*/
@Test
public void join_on_no_relation() throws Exception {
em.persist(new Member("teamA"));
em.persist(new Member("teamB"));
em.persist(new Member("teamC"));
List<Tuple> result = queryFactory
.select(member, team)
.from(member)
.leftJoin(team)
.on(member.username.eq(team.name))
.fetch();
for(Tuple tuple : result){
System.out.println("tuple = " + tuple);
}
}
주의해야 할 점은 leftjoin() 부분에 일반 조인과 다르게 엔티티가 하나만 들어갔다.
/**
* 나이가 가장 많은 회원 조회
*/
@Test
public void subQuery() {
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(
JPAExpressions
.select(memberSub.age.max())
.from(memberSub)
))
.fetch();
assertThat(result.get(0).getAge()).isEqualTo(40);
}
같은 QType을 쓰면안되기때문에, 서브쿼리에 넣을 QType은 새롭게 만들어서 넣어줘야 한다.
서브 쿼리 goe 사용
/**
* 나이가 평균 이상인 회원
*/
@Test
public void subQueryGoe() {
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.goe(
JPAExpressions
.select(memberSub.age.avg())
.from(memberSub)
))
.fetch();
assertThat(result.size()).isEqualTo(2);
}
goe는 >= 라고 보면된다.
서브 쿼리 In 사용
/**
* 나이가 10 초과인 회원
*/
@Test
public void subQueryIn() {
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.in(
JPAExpressions
.select(memberSub.age)
.from(memberSub)
.where(memberSub.age.gt(10))
))
.fetch();
assertThat(result.size()).isEqualTo(3);
}
select 절에 subquery
/**
* 나이가 10 초과인 회원
*/
@Test
public void selectSubQuery() {
QMember memberSub = new QMember("memberSub");
List<Tuple> result = queryFactory
.select(member.username,
JPAExpressions
.select(memberSub.age.avg())
.from(memberSub))
.from(member)
.fetch();
for(Tuple tuple : result){
System.out.println("tuple = " + tuple);
}
}
from 절의 서브쿼리 한계
JPA JPQL 서브쿼리의 한계점으로 from 절의 서브쿼리(인라인 뷰)는 지원하지 않는다.
from 절의 서브쿼리 해결방안
서쿼리를 Join으로 변경한다. (가능한 상황도 있고, 불가능한 상황도 있다고 한다.)
애플리케이션에서 쿼리를 2번 분리해서 실행한다.
nativeSQL을 사용한다.
from절의 서브 쿼리를 줄이기 위한 책을 추천해주었다. SQL AntiPattern 나중에 읽을 기회가 된다면 사서 봐보자.
Case 문
@Test
public void basicCase() {
List<String> result = queryFactory
.select(member.age.when(10).then("열살")
.when(20).then("스무살")
.otherwise("서른살 이상"))
.from(member)
.fetch();
for(String s : result){
System.out.println("s = " + s);
}
}
@Test
public void complexCase() {
List<String> result = queryFactory
.select(new CaseBuilder()
.when(member.age.between(0, 20)).then("0~20살")
.when(member.age.between(21, 30)).then("21~30살")
.otherwise("기타"))
.from(member)
.fetch();
for(String s : result){
System.out.println("s = " + s);
}
}
간단한 case문 예제이다.
orderyBy에서 Case문 함께 사용하기
예를들어 아래와 같은 임의의 순서로 회원을 출력하고 싶다면?
0~30살이 아닌 회원을 가장 먼저 출력
0~20살 회원 출력
21~30살 회원 출력
@Test
public void addCase() {
NumberExpression<Integer> rankPath = new CaseBuilder()
.when(member.age.between(0, 20)).then(2)
.when(member.age.between(21, 30)).then(1)
.otherwise(3);
List<Tuple> result = queryFactory
.select(member.username, member.age, rankPath)
.from(member)
.orderBy(rankPath.desc())
.fetch();
for (Tuple tuple : result) {
String username = tuple.get(member.username);
Integer age = tuple.get(member.age);
Integer rank = tuple.get(rankPath);
System.out.println("username = " + username + " age = " + age + " rank = "
+ rank);
}
}
Querydsl은 자바 코드로 작성하기 때문에 rankPath처럼 복잡한 조건을 변수로 선언해서 select절, orderBy절에서 사용할 수 있다.
buildscript {
ext {
queryDslVersion = "5.0.0"
}
}
plugins {
id 'org.springframework.boot' version '2.6.7'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
//querydsl 추가
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
id 'java'
}
group = 'study'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
//querydsl 추가
implementation "com.querydsl:querydsl-jpa:${queryDslVersion}"
implementation "com.querydsl:querydsl-apt:${queryDslVersion}"
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
test {
useJUnitPlatform()
}
//querydsl 추가 시작
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
sourceSets {
main.java.srcDir querydslDir
}
compileQuerydsl{
options.annotationProcessorPath = configurations.querydsl
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
querydsl.extendsFrom compileClasspath
}
//querydsl 추가 끝
검증용 엔티티 생성 (Hello Class)
package study.querydsl.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
@Entity
@Getter
@Setter
public class Hello {
@Id @GeneratedValue
private Long id;
}
후에 오른쪽 gradle 클릭 (없으면 왼쪽 맨아래 네모난 박스 클릭 마우스 이동 후 gradle 클릭) -> querydsl 클릭 -> task 클릭 -> other 클릭 -> compileQuerydsl 더블 클릭
참고 위의 작업없이 build만 해도 되긴함.
후에 build>generated>querydsl>study.qeurydsl.entity를 보면 QHello가 생선된 걸 볼 수 있다.
QHello 코드
package study.querydsl.entity;
import static com.querydsl.core.types.PathMetadataFactory.*;
import com.querydsl.core.types.dsl.*;
import com.querydsl.core.types.PathMetadata;
import javax.annotation.processing.Generated;
import com.querydsl.core.types.Path;
/**
* QHello is a Querydsl query type for Hello
*/
@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QHello extends EntityPathBase<Hello> {
private static final long serialVersionUID = 1910216155L;
public static final QHello hello = new QHello("hello");
public final NumberPath<Long> id = createNumber("id", Long.class);
public QHello(String variable) {
super(Hello.class, forVariable(variable));
}
public QHello(Path<? extends Hello> path) {
super(path.getType(), path.getMetadata());
}
public QHello(PathMetadata metadata) {
super(Hello.class, metadata);
}
}
Hello 엔티티를 보고 querydsl이 QHello란 엔티티를 만들어준 것이다. QHello는 querydsl에서 쿼리와 관련된 작업을 할 때 사용하기위해 만들어지는 엔티티이다.
참고 querydsl이 만들어준 QHello와 같은 엔티티는 git에서 관리하면 안된다. (자동으로 gitignore되는 폴더에 생성되기때문에 상관없긴하다.)