✅ 강의를 듣고 팁 + 기본 문법에 대한 간단히 정리 

프로젝트를 하면서 Querydsl을 사용하는 법에 대해서는 정리했지만, 급한대로 정리한거라 좀 더 자세하게 강의 듣고 정리하려고한다. 
인프런 - 김영한 - Querydsl에 대한 정리이다. 

 

 

✅ 순환참조 방지  @ToString 

@ToString(of = {"id", "title", "price"})
public class Product {
    private Long id;
    private String title;
    private int price;
}

 

 

✅ 기본 생성자를 막고 싶은데 JPA 스팩상 열어 두어야 할 때 -> PROTECTED 사용!

접근제어자를 활용해서 다른 곳에서 생성자를 사용하지 못하게한다. 
private안 되는 이유  : 프록시 클래스에서 super (부모 생성자) 호출 못 하므로  Protected로 해야함. 

@NoArgsConstructor(access = AccessLevel.PROTECTED)
class className{
	....
}

 

https://kadosholy.tistory.com/96

https://it-jin-developer.tistory.com/60



✅  JPQL이 별로인 이유 

코드 작성하는 방식이 Querydsl이 편하다. 

JPQL -> 문자열 -> 코드를 실행시점에 오류 발견 

QureyDSL -> 자바코드 -> 컴파일 시점에 오류 발견 

QueryDSL 코드 작성 예시 

@Test
public void startQuerydsl() {

//member1을 찾아라.
JPAQueryFactory queryFactory = new JPAQueryFactory(em); QMember m = new QMember("m");
     Member findMember = queryFactory
             .select(m)
.from(m)
.where(m.username.eq("member1"))//파라미터 바인딩 처리 .fetchOne();
     assertThat(findMember.getUsername()).isEqualTo("member1");
 }

 

  • Querydsl은 자바 코드임 
  • EntityManager로 JPAQueryFactory 생성 
  • Querydsl은 JPQL 빌더 
  • JPQL: 문자(실행 시점 오류), Querydsl: 코드(컴파일 시점 오류) 
  • JPQL: 파라미터 바인딩 직접, Querydsl: 파라미터 바인딩 자동 처리

 

✅  기본 Q-Type 활용 

Q클래스 인스턴스를 활용하는 2가지 방법 

QMember qMember = new QMember("m"); //별칭 직접 지정 
QMember qMember = QMember.member; //기본 인스턴스 사용

 

기본 인스턴스를 static import와 함께 사용

import static study.querydsl.entity.QMember.*;


-> 이렇게하면 그냥 member로 접근 가능 

참고 : 같은 테이블을 조인해야하는 경우가 아니면 기본 인스턴스를 사용하자! (안 그러면 Alias충돌) 

 

 

 

✅  결과 조회 

  • fetch() : 리스트 조회, 데이터 없으면 빈 리스트 반환 
  • fetchOne() : 단 건 조회
    결과가 없으면 : `null`
    결과가 둘 이상이면 : `cohttp://m.querydsl.core.NonUniqueResultException
  • fetchFirst() : `limit(1).fetchOne()`
  • fetchResults() : 페이징 정보 포함, total count 쿼리 추가 실행
  • fetchCount() : count 쿼리로 변경해서 count 수 조회

 

 

✅  정렬

/**
* 회원 정렬 순서
* 1. 회원 나이 내림차순(desc)
* 2. 회원 이름 올림차순(asc)
* 단 2에서 회원 이름이 없으면 마지막에 출력(nulls last) */
 @Test
 public void sort() {
     em.persist(new Member(null, 100));
     em.persist(new Member("member5", 100));
     em.persist(new Member("member6", 100));
     List<Member> result = queryFactory
             .selectFrom(member)
             .where(member.age.eq(100))
             .orderBy(member.age.desc(), member.username.asc().nullsLast())
             .fetch();
     Member member5 = result.get(0);
     Member member6 = result.get(1);
     Member memberNull = result.get(2);
     assertThat(member5.getUsername()).isEqualTo("member5");
     assertThat(member6.getUsername()).isEqualTo("member6");
     assertThat(memberNull.getUsername()).isNull();
}
  • desc() , asc() : 일반 정렬
  • nullsLast() , nullsFirst() : null 데이터 순서 부여

 

 

✅  전체 조회 수가 필요한 페이징

@Test
 public void paging2() {
     QueryResults<Member> queryResults = queryFactory
             .selectFrom(member)
             .orderBy(member.username.desc())
             .offset(1)
             .limit(2)
             .fetchResults();
     assertThat(queryResults.getTotal()).isEqualTo(4);
     assertThat(queryResults.getLimit()).isEqualTo(2);
     assertThat(queryResults.getOffset()).isEqualTo(1);
     assertThat(queryResults.getResults().size()).isEqualTo(2);
}

 

주의 : count 쿼리가 실행되니 성능상 주의!

 

참고: 실무에서 페이징 쿼리를 작성할 때, 데이터를 조회하는 쿼리는 여러 테이블을 조인해야 하지만count 쿼리 는 조인이 필요 없는 경우도 있다. 그런데 이렇게 자동화된 count 쿼리는 원본 쿼리와 같이 모두 조인을 해버리기 때문에 성능이 안나올 수 있다. count 쿼리에 조인이 필요없는 성능 최적화가 필요하다면, count 전용 쿼리를 별 도로 작성해야 한다.

 

 

 

 

✅  집합 함수 

/**
  * JPQL
  * select
* COUNT(m),
  *    SUM(m.age),
  *    AVG(m.age),
  *    MAX(m.age),
  *    MIN(m.age)
  * from Member m
  */
//회원수 //나이 합 //평균 나이 //최대 나이 //최소 나이
@Test
public void aggregation() throws Exception {
    List<Tuple> result = queryFactory
            .select(member.count(),
                    member.age.sum(),
                    member.age.avg(),
                    member.age.max(),
                    member.age.min())
            .from(member)
            .fetch();
    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);
}
  • JPQL이 제공하는 모든 집합 함수를 제공한다. 
  • tuple은 프로덕션과 결과반환에서 설명한다. 

 

 

 

 

✅  GroupBy 사용

/**
* 팀의 이름과 각 팀의 평균 연령을 구해라. */
@Test
public void group() throws Exception {
     List<Tuple> result = queryFactory
             .select(team.name, member.age.avg())
             .from(member)
             .join(member.team, team)
             .groupBy(team.name)
             .fetch();
     Tuple teamA = result.get(0);
     Tuple teamB = result.get(1);
     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(item.price)
     .having(item.price.gt(1000))
...

 

 

 

 

✅  조인 - 기본 조인

기본 조인
조인의 기본 문법은 첫 번째 파라미터에 조인 대상을 지정하고, 두 번째 파라미터에 별칭(alias)으로 사용할 

Q 타입을 지정하면 된다.

join(조인 대상, 별칭으로 사용할 Q타입)

 

기본 조인

연관 관계 필드끼리 자동 매핑(fk = pk) -> 연관관계 없이 조인하려면 세타조인 사용하기 궁금하면 검색

/**
* 팀 A에 소속된 모든 회원 */
@Test
public void join() throws Exception {
    QMember member = QMember.member;
    QTeam team = QTeam.team;
    List<Member> result = queryFactory
            .selectFrom(member)
            .join(member.team, team)
            .where(team.name.eq("teamA"))
            .fetch();
            
            
    assertThat(result)
            .extracting("username")
                .containsExactly("member1", "member2");

}

//이렇게 조인하면 member의 fk teamId랑 team의 teamId랑 조인됨
select ...
from member m
join team t on m.team_id = t.id
where t.name = 'teamA'
  • join() , innerJoin() : 내부 조인(inner join)
  • leftJoin() : left 외부 조인(left outer join) 
  • rightJoin() : rigth 외부 조인(rigth outer join)
  • JPQL의 `on` 과 성능 최적화를 위한 `fetch` 조인 제공 다음 on 절에서 설명

 

 

 

 

✅  조인 - on 절 

ON절을 활용한 조인(JPA 2.1부터 지원)

  • 1. 조인 대상 필터링
  • 2. 연관관계 없는 엔티티 외부 조인

1. 조인 대상 필터링

예) 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회

/**
* 예) 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회
* JPQL: SELECT m, t FROM Member m LEFT JOIN m.team t on t.name = 'teamA'
* SQL: SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.TEAM_ID=t.id and
 t.name='teamA'
  */
 @Test
 public void join_on_filtering() throws Exception {
     List<Tuple> result = queryFactory
             .select(member, team)
             .from(member)
             .leftJoin(member.team, team).on(team.name.eq("teamA"))
             .fetch();
     for (Tuple tuple : result) {
         System.out.println("tuple = " + tuple);
         } 
         
     }
     
     
     // team id가 1인 경우에만 출력   (id = 1의 team.name = "teamA")

참고 : 내부조인이면  where로 해결하자! 

 

2. 연관관계 없는 엔티티 외부 조인 

예)회원의 이름과 팀의 이름이 같은 대상 외부 조인 

/**
* 2. 연관관계 없는 엔티티 외부 조인
* 예) 회원의 이름과 팀의 이름이 같은 대상 외부 조인
* JPQL: SELECT m, t FROM Member m LEFT JOIN Team t on m.username = t.name
* SQL: 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"));
     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("t=" + tuple);
	}
}

// Member는 전부다 반환 Team은 TeamName과 MemberName이 같은 경우 반환

주의! 문법을 잘 봐야 한다.leftJoin() 부분에 일반 조인과 다르게 엔티티 하나만 들어간다.

 

 

✅  서브 쿼리 

com.querydsl.jpa.JPAExpressions 사용

서울 쿼리 eq사용 

/**
* 나이가 가장 많은 회원 조회 */
 @Test
 public void subQuery() throws Exception {
 	 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).extracting("age")
             .containsExactly(40);
}

 

스태틱 Import 사용 가능 

import static com.querydsl.jpa.JPAExpressions.select;

 

from절에는 서브쿼리 불가하다

-> 해결 방안 1. 2번 불리한다. 2. 서브쿼리를 join으로 변경 (가능하다면)

 

 

 

 

✅  프로젝션과 결과 반환  - 기본 

프로젝션: select 대상 지정

결과 반환 값이 1개면 타입을 명확하게 지정가능. 

둘 이상이면 튜플이나 DTO조회 

 

결과를 DTO로 생성할 때  3가지 방법이 있음 v

  • 프로퍼티 접근 - Setter 
  • 필드 직접 접근 
  • 생성자 사용 (@QueryProjection)  @QueryProjection 키워드 생성자 -> @QueryProjection -> QDTO 생성 확인

 

 

 

✅  동적 쿼리 - BooleanBuilder 사용

동적 쿼리를 해결하는 두가지 방식

  • BooleanBuilder
  • Where 다중 파리미터 사용 

BooleanBuilder 사용 

@Test
public void 동적쿼리_BooleanBuilder() throws Exception {
String usernameParam = "member1";

Integer ageParam = 10;
    List<Member> result = searchMember1(usernameParam, ageParam);
    Assertions.assertThat(result.size()).isEqualTo(1);
}

private List<Member> searchMember1(String usernameCond, Integer ageCond) {
    BooleanBuilder builder = new BooleanBuilder();
    if (usernameCond != null) {
        builder.and(member.username.eq(usernameCond));
    }
    if (ageCond != null) {
        builder.and(member.age.eq(ageCond));
    }
    return queryFactory
		.selectFrom(member)
		.where(builder)
		.fetch();
}

 

Where 다중 파라미터 사용 

@Test
public void 동적쿼리_WhereParam() throws Exception {
     String usernameParam = "member1";
     Integer ageParam = 10;
     List<Member> result = searchMember2(usernameParam, ageParam);
     Assertions.assertThat(result.size()).isEqualTo(1);
 }
 private List<Member> searchMember2(String usernameCond, Integer ageCond) {
     return queryFactory
             .selectFrom(member)
             .where(usernameEq(usernameCond), ageEq(ageCond))
             .fetch();
}


private BooleanExpression usernameEq(String usernameCond) {
     return usernameCond != null ? member.username.eq(usernameCond) : null;
}

private BooleanExpression ageEq(Integer ageCond) {
    return ageCond != null ? member.age.eq(ageCond) : null;
}
  • where` 조건에 `null` 값은 무시된다. 
  • 메서드를 다른 쿼리에서도 재활용 할 수 있다. 
  • 쿼리 자체의 가독성이 높아진다.

조합 가능 - null 체크는 주의해서 처리해야함.

private BooleanExpression allEq(String usernameCond, Integer ageCond) {
     return usernameEq(usernameCond).and(ageEq(ageCond));
}

 

 

 

 

 

✅  수정, 삭제 벌크 연산 

JPQL 배치와 마찬가지로 영속성 컨텍스트에 있는 엔티티를 무시하고 실행됨 

-> 배치 쿼리를 실행하고 영속성 컨텍스트 초기화 하자!!! 무조건 필수 !!!  !!!!

 

쿼리 한번으로 대량 데이터 수정 

long count = queryFactory
         .update(member)
	     .set(member.username, "비회원")
         .where(member.age.lt(28))
         .execute();

 

기존 숫자에 1 더하기 

long count = queryFactory
         .update(member)
         .set(member.age, member.age.add(1))
         .execute();

 

쿼리 한번으로 대량 데이터 삭제 

 long count = queryFactory
         .delete(member)
     .where(member.age.gt(18))
     .execute();

 

주의: JPQL 배치와 마찬가지로, 영속성 컨텍스트에 있는 엔티티를 무시하고 실행되기 때문에 배치 쿼리를 실행하고 나면 영속성 컨텍스트를 초기화 하는 것이 안전하다.

 

 

 

 

✅  실제 프로젝트에서 QueryDSL

 

지금까지 그냥 Test코드에서 쿼리 DSL을 사용했지만 실제 프로젝트에서는 어떻게 Qureydsl을 사용할까? 

김영한 querydsl 강의

그림대로 설명해자면 일반적으로 스프링 Data JPA를 사용할 때 리포티지토리 클래스는 

JpaRepository 인터페이스를 extends한다. 

위 처럼 구성된 세팅에서 custom Interface를 추가하고 그를 구현하는 MemberRepositoryImpl에 Querydsl코드를 추가하면 된다. 

단. custom interface의 명칭은  원래 Repository + Custom으로 한다. 

위 그림을 참고하여도 이해는 쉬울 것 같다. 

 

✅  예시 MemberRepositoryImpl

import java.util.List;
import static org.springframework.util.StringUtils.isEmpty;
import static study.querydsl.entity.QMember.member;
import static study.querydsl.entity.QTeam.team;
public class MemberRepositoryImpl implements MemberRepositoryCustom {
    private final JPAQueryFactory queryFactory;
    public MemberRepositoryImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
}


@Override
//회원명, 팀명, 나이(ageGoe, ageLoe)
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 isEmpty(username) ? null : member.username.eq(username);
}
private BooleanExpression teamNameEq(String teamName) {
    return isEmpty(teamName) ? null : team.name.eq(teamName);
}
private BooleanExpression ageGoe(Integer ageGoe) {
    return ageGoe == null ? null : member.age.goe(ageGoe);
import java.util.List;
import static org.springframework.util.StringUtils.isEmpty;
import static study.querydsl.entity.QMember.member;
import static study.querydsl.entity.QTeam.team;
public class MemberRepositoryImpl implements MemberRepositoryCustom {
    private final JPAQueryFactory queryFactory;
    public MemberRepositoryImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
}





@Override
//회원명, 팀명, 나이(ageGoe, ageLoe)
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 isEmpty(username) ? null : member.username.eq(username);
}
private BooleanExpression teamNameEq(String teamName) {
    return isEmpty(teamName) ? null : team.name.eq(teamName);
}
private BooleanExpression ageGoe(Integer ageGoe) {
    return ageGoe == null ? null : member.age.goe(ageGoe);




/**
* 단순한 페이징, fetchResults() 사용 */
 @Override
 public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition,
 Pageable pageable) {
     QueryResults<MemberTeamDto> results = 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()))
             .offset(pageable.getOffset())
             .limit(pageable.getPageSize())
             .fetchResults();
     List<MemberTeamDto> content = results.getResults();
     long total = results.getTotal();
     return new PageImpl<>(content, pageable, total);
 }


* 복잡한 페이징
* 데이터 조회 쿼리와, 전체 카운트 쿼리를 분리 */
@Override
public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition,
Pageable pageable) {
    List<MemberTeamDto> content = 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()))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
    long total = queryFactory
            .select(member)
            .from(member)
            .leftJoin(member.team, team)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe()))
            .fetchCount();
    return new PageImpl<>(content, pageable, total);
}

 

 

✅  CountQuery최적화 

count 쿼리가 생략 가능한 경우 생략해서 처리

  • 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
  • 마지막 페이지 일 때 (offset + 컨텐츠 사이즈를 더해서 전체 사이즈 구함, 더 정확히는 마지막 페이지이면 서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때)
JPAQuery<Member> countQuery = queryFactory
        .select(member)
        .from(member)
        .leftJoin(member.team, team)
        .where(usernameEq(condition.getUsername()),
                teamNameEq(condition.getTeamName()),
                ageGoe(condition.getAgeGoe()),
                ageLoe(condition.getAgeLoe()));
  return new PageImpl<>(content, pageable, total);
        return PageableExecutionUtils.getPage(content, pageable,
countQuery::fetchCount);

 

 

 

✅ 정렬 

정렬을 Pagealble Sort기능을 조금만 복잡해져도 사용하기 힘듦 파라미터로 받아서 하자!

// 정렬 로직 생성
    private OrderSpecifier<?> getSort(QGoodsEntity goods, QBidHistoryEntity bid, String sort) {
        if ("priceAsc".equalsIgnoreCase(sort)) {
            return goods.buyNowPrice.asc();
        } else if ("priceDesc".equalsIgnoreCase(sort)) {
            return goods.buyNowPrice.desc();
        } else if ("currentPriceAsc".equalsIgnoreCase(sort)) {
            return currentBidPriceExpr(goods, bid).asc();
        } else if ("currentPriceDesc".equalsIgnoreCase(sort)) {
            return currentBidPriceExpr(goods, bid).desc();
        } else if ("latest".equalsIgnoreCase(sort)) {
            return goods.createdAt.desc();
        }
        return goods.createdAt.desc();
    }
//정렬 로직 적용 
        OrderSpecifier<?> orderSpecifier = getSort(goods, bid, sort);

        List<org.example.auctify.entity.Goods.GoodsEntity> entityList = queryFactory
                .selectFrom(goods)
                .leftJoin(like).on(
                        like.goods.goodsId.eq(goods.goodsId)
                                .and(like.user.userId.eq(userId))
                ).fetchJoin()
                .where(builder)
                .orderBy(orderSpecifier)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

 

 

 

 

✅ 정리 

!!김영한 강사님의 Querydsl강의를 듣고 정리했습니다.!!
간단하게 자주 쓰일만한 것들을 정리했다.  나중에 프로젝트를 진행하게 된다면 이 글을 참고해서 

Querydsl을 리마인드 하고 진행할 것 같다.

 

✅ 왜 QueryDSL 을  사용할까? 

JPQL로 쿼리를 사용하면 되는데 QueryDSL을 사용하는 이유에 대해서 생각해보자. 

 

문제점

1. JQPL은 문자열로 작성하기 때문에 런타임에서 쿼리가 실행되어야 오류를 발견할 수 있다.

2. 동적쿼리를 구현할 때 JPQL,criteria은 너무 복잡해진다. (Criteria API 가독성 매우 떨어짐)

 

 

QueryDSL을 사용하는 이유

1.QueryDSL은 컴파일 시점에 오류발견할 수 있다. 
2.간편 하게 동적 쿼리를 구현할 수 있다. (Ex 필터 검색 구현하기.)
3. IDE 자동 원성 지원으로 몹시 편하다. 

 

 

✅ QueryDSL 이란?

한마디로, SQL이나 JPQL을 Java 코드로 하게 작성할 수 있도록 도와주는 빌더 기반 오픈소스 프레임워크다.
핵심 장점으로 유형 안전의 결여정적 쿼리 검사의 부재를 해결해준다. 

 

 

✅ QueryDSL 구조 

 

QueryDSL은 빌더 패턴 구조로 이루어져 있다. 

빌더 패턴은 객체의 데이터 세팅에 사용되는 디자인 패턴이다. (우리 개발자들도 개발할 자주 사용한다 잘 생각해보자~)

 

public List<Post> searchPost(PostSearchCondition condition) {
    QPost qPost = QPost.post; // Q타입 클래스 객체 생성

    return queryFactory
            .select(qPost) // select 메소드
            .from(qPost) // from 메소드
            .leftJoin(qPost.author, qMember) // 게시글 작성자와 조인
            .where( // where 메소드
                    titleContains(condition.getTitle()),
                    contentContains(condition.getContent()),
                    authorNameEq(condition.getAuthorName()),
                    createdAfter(condition.getCreatedAfter()),
                    createdBefore(condition.getCreatedBefore())
            )
            .fetch(); // fetch 메소드
}

 

private BooleanExpression titleContains(String title) {
    return title != null ? qPost.title.contains(title) : null;
}

private BooleanExpression contentContains(String content) {
    return content != null ? qPost.content.contains(content) : null;
}

private BooleanExpression authorNameEq(String authorName) {
    return authorName != null ? qPost.author.username.eq(authorName) : null;
}

private BooleanExpression createdAfter(LocalDateTime date) {
    return date != null ? qPost.createdAt.goe(date) : null;
}

private BooleanExpression createdBefore(LocalDateTime date) {
    return date != null ? qPost.createdAt.loe(date) : null;
}

위 코드에는 두 가지 특징을 알아야 한다. 

1. Q타입클래스
2. 메소드 체이닝 방식

 

- Q타입클래스

원하는 엔티티의 정보를 QueryDSL에 넘겨야 하는데, 이때 Q타입 클래스가 사용된다. (컴파일 과정에서 이미 생성된 엔티티를 토대로 생성된다.) 

참고로 QueryDSL을 JPA와 분리된 프레임워크이다. 엔티티를 직접 사용하면 JPA에 종속되므로 Q타입 클래스를 사용함.

QPost qPost = QPost.post; // Q타입 클래스 객체 생성

 

- 메소드 체이닝 방식

메소드 체이닝은 빌더 패턴의 특징이다.  '.' 연산자 로 이어져 있는거 확인할 수 있다. 

더 패턴은 데이터 세팅을 select, from, leftjoin, where 순으로 할 수 있도록 제공해서 직관적이므로 가독성 향상된다. 

 

 

 

 

✅ QueryDSL을 세팅 + 사용  

의존성 추가 -> Config작성 -> CustomRepository, Impl클래스 작성 -> 기존 리포지토리가 CustomRepository 확장 -> 사용 

 


Build.gradle 설정 추가

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.2'
    id 'io.spring.dependency-management' version '1.1.7'
}

// 프로젝트 기본 정보
group = 'org.example'
version = '0.0.1-SNAPSHOT'

// Java 버전 설정
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

ext {
    querydslVersion = "5.0.0"
}

// 프로젝트 의존성 설정
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // Querydsl 설정 (JPA 지원)
    implementation "com.querydsl:querydsl-jpa:${querydslVersion}:jakarta"
    annotationProcessor "com.querydsl:querydsl-apt:${querydslVersion}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api:2.1.1"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api:3.1.0"

    // 데이터 소스 데코레이터 (데이터베이스 연결 모니터링 및 로깅 지원)
    implementation 'com.github.gavlyukovskiy:datasource-decorator-spring-boot-autoconfigure:1.9.0'

    // Lombok (자동 Getter, Setter 등 생성)
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // SQL 쿼리 파라미터를 로깅하기 위한 p6spy 라이브러리
    implementation 'p6spy:p6spy:3.9.1'

    // 개발 편의성 제공 (자동 리로드 등, 필요 시 활성화)
    // developmentOnly 'org.springframework.boot:spring-boot-devtools'

    // OAuth2 클라이언트
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

    // Spring Security
    implementation 'org.springframework.boot:spring-boot-starter-security'

    // JWT 토큰 관련 라이브러리
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'

    // 내장 데이터베이스 H2 (개발 및 테스트용)
    runtimeOnly 'com.h2database:h2'

    // MySQL 데이터베이스 연결 드라이버
    runtimeOnly 'com.mysql:mysql-connector-j'

    // Swagger (API 문서 자동 생성)
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'

    // AWS S3 사용을 위한 라이브러리
    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

    // .env 파일로 환경변수 로드
    implementation 'me.paulschwarz:spring-dotenv:4.0.0'

    // MongoDB 지원
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'

    // 테스트 관련 의존성
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

// Querydsl 설정

def generated = 'src/main/generated'

// Querydsl이 생성하는 QClass 파일 위치 지정
tasks.withType(JavaCompile) {
    options.getGeneratedSourceOutputDirectory().set(file(generated))
}

// Java 소스에 Querydsl이 생성한 디렉토리를 추가
sourceSets {
    main.java.srcDirs += [generated]
}

// gradle clean 시 생성된 QClass 디렉토리 삭제
clean {
    delete file(generated)
}

// 테스트 설정 (JUnit 5 사용)
tasks.named('test') {
    useJUnitPlatform()
}

 

 

QueryDslConfig 클래스 추가 

package org.example.auctify.config.querydsl;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * QueryDSL 설정 클래스
 * QueryDSL을 사용하여 데이터베이스에 타입 세이프한 쿼리를 작성할 수 있도록 도와줍니다.
 */
@Configuration
public class QueryDslConfig {

    /**
     * EntityManager는 엔티티를 관리하고 JPA 작업을 처리합니다.
     * PersistenceContext를 사용해 스프링 컨테이너가 관리하는 EntityManager를 주입받습니다.
     */
    @PersistenceContext
    private EntityManager entityManager;

    /**
     * JPAQueryFactory를 스프링 빈으로 등록하여,
     * 애플리케이션 전반에서 QueryDSL의 타입 세이프 쿼리를 간편하게 생성할 수 있도록 지원합니다.
     *
     * @return JPAQueryFactory 인스턴스
     */
    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

 

Custom Repository Interface + Impl Class 작성 

 

CustomRepository Interface

package org.example.auctify.repository.goods;

import org.example.auctify.dto.Goods.GoodsResponseSummaryDTO;
import org.example.auctify.entity.Goods.GoodsEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface GoodsRepositoryCustom {

    Page<GoodsEntity> searchGoods(
            String category,
            Double priceRangeLow,
            Double priceRangeHigh,
            String goodsStatus,
            String goodsProcessStatus,
            String goodsName,
            String sort,
            Pageable pageable);
}

 

Impl Class 작성 

package org.example.auctify.repository.goods;

import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.CaseBuilder;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.example.auctify.dto.Goods.GoodsCategory;
import org.example.auctify.dto.Goods.GoodsProcessStatus;
import org.example.auctify.dto.Goods.GoodsResponseSummaryDTO;
import org.example.auctify.dto.Goods.GoodsStatus;
import org.example.auctify.entity.Goods.QGoodsEntity;
import org.example.auctify.entity.bidHistory.QBidHistoryEntity;
import org.example.auctify.entity.like.QLikeEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;

import java.util.List;
import java.util.stream.Collectors;

@RequiredArgsConstructor
public class GoodsRepositoryCustomImpl implements GoodsRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<GoodsResponseSummaryDTO> searchGoodsWithLikeStatus(
            Long userId,
            String category,
            Double priceRangeLow,
            Double priceRangeHigh,
            String goodsStatus,
            String goodsProcessStatus,
            String goodsName,
            String sort,
            Pageable pageable
    ) {
        QGoodsEntity goods = QGoodsEntity.goodsEntity;
        QBidHistoryEntity bid = QBidHistoryEntity.bidHistoryEntity;
        QLikeEntity like = QLikeEntity.likeEntity;

        BooleanBuilder builder = new BooleanBuilder();

        if (category != null && !category.isEmpty()) {
            builder.and(goods.category.eq(GoodsCategory.valueOf(category)));
        }
        if (priceRangeLow != null) {
            builder.and(goods.buyNowPrice.goe(priceRangeLow));
        }
        if (priceRangeHigh != null) {
            builder.and(goods.buyNowPrice.loe(priceRangeHigh));
        }
        if (goodsStatus != null && !goodsStatus.isEmpty()) {
            builder.and(goods.goodsStatus.eq(GoodsStatus.valueOf(goodsStatus)));
        }
        if (goodsProcessStatus != null && !goodsProcessStatus.isEmpty()) {
            builder.and(goods.goodsProcessStatus.eq(GoodsProcessStatus.valueOf(goodsProcessStatus)));
        }
        if (goodsName != null && !goodsName.isEmpty()) {
            builder.and(goods.goodsName.containsIgnoreCase(goodsName));
        }

        OrderSpecifier<?> orderSpecifier = getSort(goods, bid, sort);

        List<org.example.auctify.entity.Goods.GoodsEntity> entityList = queryFactory
                .selectFrom(goods)
                .leftJoin(like).on(
                        like.goods.goodsId.eq(goods.goodsId)
                                .and(like.user.userId.eq(userId))
                ).fetchJoin()
                .where(builder)
                .orderBy(orderSpecifier)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        Long total = queryFactory
                .select(goods.count())
                .from(goods)
                .where(builder)
                .fetchOne();

        List<GoodsResponseSummaryDTO> dtoList = entityList.stream().map(g -> GoodsResponseSummaryDTO.builder()
                .goodsId(g.getGoodsId())
                .goodsName(g.getGoodsName())
                .goodsProcessStatus(g.getGoodsProcessStatus())
                .currentBidPrice(g.getCurrentBidPrice())
                .imageUrls(g.getFirstImage())
                .endTime(g.getActionEndTime())
                .category(g.getCategory())
                .goodsStatus(g.getGoodsStatus())
                .currentBidCount((long) g.getBidHistories().size())
                .isLiked(
                        g.getLike().stream().anyMatch(l -> l.getUser().getUserId().equals(userId))
                )
                .build()
        ).collect(Collectors.toList());

        return new PageImpl<>(dtoList, pageable, total);
    }

    private OrderSpecifier<?> getSort(QGoodsEntity goods, QBidHistoryEntity bid, String sort) {
        if ("priceAsc".equalsIgnoreCase(sort)) {
            return goods.buyNowPrice.asc();
        } else if ("priceDesc".equalsIgnoreCase(sort)) {
            return goods.buyNowPrice.desc();
        } else if ("currentPriceAsc".equalsIgnoreCase(sort)) {
            return currentBidPriceExpr(goods, bid).asc();
        } else if ("currentPriceDesc".equalsIgnoreCase(sort)) {
            return currentBidPriceExpr(goods, bid).desc();
        } else if ("latest".equalsIgnoreCase(sort)) {
            return goods.createdAt.desc();
        }
        return goods.createdAt.desc();
    }

    private NumberExpression<Long> currentBidPriceExpr(QGoodsEntity goods, QBidHistoryEntity bid) {
        NumberExpression<Long> maxBidPrice = Expressions.numberTemplate(Long.class,
                "(select coalesce(max(b.bidPrice), 0) from BidHistoryEntity b where b.goods.goodsId = {0})",
                goods.goodsId
        );

        return new CaseBuilder()
                .when(maxBidPrice.gt(goods.minimumBidAmount)).then(maxBidPrice)
                .otherwise(goods.minimumBidAmount);
    }
}

 

 

기존 Repository는 Custom Repository 상속 

package org.example.auctify.repository.goods;

import org.example.auctify.entity.Goods.GoodsEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

public interface GoodsRepository extends JpaRepository<GoodsEntity, Long>,GoodsRepositoryCustom {


    //JPQL에서 JOIN FETCH를 사용할 경우 COUNT(*) 같은
    // 카운트 쿼리를 자동으로 생성할 수 없기 때문에 쿼리 2개 사용하거나 또는
    // EntityGraph 사용 필수
    @EntityGraph(attributePaths = {
            "user"
            ,"image"
            ,"bidHistories"})
    @Query("SELECT g FROM GoodsEntity g WHERE g.user.userId = :userId" +
            " Order BY g.goodsId DESC")
    Page<GoodsEntity> findGoodsByUserId(@Param("userId") Long userId, Pageable pageable);


    @EntityGraph(attributePaths = {
            "image"
            ,"bidHistories"
    })
    @Query("SELECT g FROM GoodsEntity g where g.goodsId = :goodsId")
    Optional<GoodsEntity> findGoodsImageBidHistoryByGoodsId(Long goodsId);



}

 

Service에서 편하게 이제 기존 리포지토리를 통해서 호출 ~!~!~!

 

✅ QueryDSL의 단점과 해결책

아래 글들을 읽어 보는걸 추천한다. 
아래 내용을 정리하지 않는 이유는 이 글의 핵심에서 멀어지기 때문. 

https://velog.io/@cws0718/Spring-JPA-QueryDsl%EC%9D%B4%EB%9E%80

https://velog.io/@yangsijun528/dont-use-querydsl

https://juyoungit.tistory.com/673

 

 

✅ 참고 

https://velog.io/@jmjmjmz732002/SpringBoot-QueryDSL-JPA-1-%EC%82%AC%EC%9A%A9-%EC%9D%B4%EC%9C%A0-%EC%83%9D%EA%B0%81%ED%95%B4%EB%B3%B4%EA%B8%B0

https://lordofkangs.tistory.com/464

https://velog.io/@soyeon207/QueryDSL-%EC%9D%B4%EB%9E%80

 

 

✅ 결론 

핵심 부분에 대해서 정리해봤다. 추가 적인 정보는 찾아가면서 활용하면 될 듯하다. 

 

김영한 "실전! 스프링 데이터 JPA "을 최근에 다시 들었다.
내용을 전부 정리하기 보다는 챕터별로 중요하게 느낀 내용 위주로 간략하게 정리하는게 목표다. 

 

세팅에 대한 설명 

일반 JPA세팅하듯이 일단 진행(jpa의존성와 DB의존성 추가, application.yml에 DB연동 설정,  jpa 동작방식 설정,  p6spy 스프링부트 버전에 맞게 설정)

참고 : 스프링부트를 사용하면 persistence.xml도 없고 LocalContainerEntityManagerFactoryBean도 없다.

 

예제 도메인 모델 

실무에서는 Setter사용하지 말기, 기본 생성자 막고 싶어도 protected로 열어 두어야햠 (롬복으로 생성가능)

 

공통 인터페이스 기능 

순수 JPA로 리포지토리를 만들 경우 -> 반복되는 코드를 계속 작성해줘야한다. 

//순수 JPA의 CRUD리포지토리
 @Repository
 public class MemberJpaRepository {
	@PersistenceContext
     private EntityManager em;
     public Member save(Member member) {
         em.persist(member);
         return member;
}
 	// CRUD ~~~ 쭉 작성 다른 엔티티도 마찬가지로 반복되는 CRUD코드가 존재~~! 
}

 

 

스프링 Data JPA를 활용할 경우 리포지토리가 생성되는 방식

구현클래스를 개발자가 구현하지 않아도 된다. 반복적인 코드를 개발자가 작성하지 않아도 된다.

 

스프링 Data JPA로 리포지토리 인터페이스를 말들 경우 -> 구현클래스는 Spring Data JPA가 생성

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

// extends JpaRepository<Member, Long>를 확장해서 인터페이스 생성 Member = 엔티티클래스 / Long = id(=PK) 타입

// 스프링 JPA가 만들어준 리포지토리 구현클래스 테스트 코드 예시 잘 작동한다. 
@SpringBootTest
@Transactional
public class MemberRepositoryTest {
    @Autowired
    MemberRepository memberRepository;
    @Test
    public void testMember() {
        Member member = new Member("memberA");
        Member savedMember = memberRepository.save(member);
        Member findMember =
memberRepository.findById(savedMember.getId()).get();
        Assertions.assertThat(findMember.getId()).isEqualTo(member.getId());
Assertions.assertThat(findMember.getUsername()).isEqualTo(member.getUsername());
Assertions.assertThat(findMember).isEqualTo(member); //JPA 엔티티 동일성 보장 }
}

 

인터페이스 상속 구조

SPING Data JPA를 활용하니까 CRUD리포지토리를 개발자가 구현하지 않아도 된다. 대부분의 공통 메서드를 제공한다. 

 

참고 : JPA 수정은 변경감지 기능을 이용하도록하자 트랜잭션 안에서 엔티티를 조회한 다음에 데이터를 변경하면, 트랜잭션 종료 시점에 변경감지 기능이 작동해서 변경된 엔티티를 감지하고 UPDATE SQL을 실행한다.

 

쿼리 메서드 기능

 

특정 요소로 조회할 때 이건 공통사항이 아니기 때문에 개발자가 여전히 만들어할 것이다. 이 부분에 대해서도 제공해주는 기능이 있음!


쿼리 메소드 기능 3가지 

  • 메소드 이름으로 쿼리 생성
  • 메소드 이름으로 JPA NamedQuery 호출
  • `@Query` 어노테이션을 사용해서 리파지토리 인터페이스에 쿼리 직접 정의

 

순수 JPA 리포지토리였다면  JPQL로 작성했을 것이다. 하지만 스프링 데이터 JPA라면 

쿼리 메소드 

 public interface MemberRepository extends JpaRepository<Member, Long> {
     List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
//스프링 데이터 JPA는 메소드 이름을 분석해서 JPQL을 생성하고 요청받으면 실행

 

스프링 데이터 JPA가 제공하는 쿼리 메소드 기능

조회: find...By ,read...By ,query...By get...By,
https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.query- methods.query-creation
예:) findHelloBy 처럼 ...에 식별하기 위한 내용(설명)이 들어가도 된다.
COUNT: count...By 반환타입 `long`
EXISTS: exists...By 반환타입 `boolean`
삭제: delete...By, remove...By 반환타입 `long` DISTINCT: findDistinct, findMemberDistinctBy LIMIT: findFirst3, findFirst, findTop, findTop3

 

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

 

 

JPA NamedQuery

@NamedQuery로 엔티티 위에 작성 > JPA를 직접 사용해서 repository에서 Named 쿼리 호출 (나의 개인적으로 별로인듯 )

자동으로 찾아주는 기능 존재 (스프링 데이터 JPA는 선언한 "도메인 클래스 + .(점) + 메서드 이름"으로 Named 쿼리를 찾아서 실행)

 

참고 : 스프링 데이터 JPA를 사용하면 실무에서 Named Query를 직접 등록해서 사용하는 일은 드물다. 대신 @Query를 사용해서 리파지토리 메소드에 쿼리를 직접 정의한다. 

 

@Query 리포지토리 메소드에 쿼리 정의하기

  • `@org.springframework.data.jpa.repository.Query` 어노테이션을 사용 
  • 실행할 메서드에 정적 쿼리를 직접 작성하므로 이름 없는 Named 쿼리라 할 수 있음
  • JPA Named 쿼리처럼 애플리케이션 실행 시점에 문법 오류를 발견할 수 있음(매우 큰 장점!)
public interface MemberRepository extends JpaRepository<Member, Long> {
     // @Query 기본
     @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 바인딩 제공
	//  DTO 경로 다 적어줘서 귀찮지만 해주자. 
    // !! DTO에는 생성자가 맞는 DTO가 필요하다.(JPA와 사용방식이 동일하다.)
	@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) " +
         "from Member m join m.team t")
	List<MemberDto> findMemberDto(); 
    
    //컬렉션 파라미터 바인딩 결과 없으면 참고로 컬랙션은 빈컬랙션 반환 null아니다! 
    //`Collection` 타입으로 in절 지원
    @Query("select m from Member m where m.username in :names")
    List<Member> findByNames(@Param("names") List<String> names);

	//단건 조회는 없으면 null 2건이상 javax.persistence.NonUniqueResultException 발생
}

파라미터 바인딩은 코드 가독성과 유지보수를 위해 이름 기반 파라미터 바인딩을 사용하자 (위치기반은 순서 실수가 바꾸면...)

 

참고: 실무에서는 메소드 이름으로 쿼리 생성 기능은 파라미터가 증가하면 메서드 이름이 매우 지저분해진다. 따 라서 `@Query` 기능을 자주 사용하게 된다.

 

페이징

순수 JPA라면  검색 조건으로 조회하는 JPQL(검색 조건 + 정렬 조건 + 페이징 조건)과 Count를 위한 JPQL코드 작성  

 

스프링 데이터 JPA 페이징과 정렬 

페이징과 정렬 파라미터

org.springframework.data.domain.Sort : 정렬 기능

org.springframework.data.domain.Pageable : 페이징 기능 (내부에 `Sort` 포함)
특별한 반환 타입 지원 
`org.springframework.data.domain.Page` : 추가 count 쿼리 결과를 포함하는 페이징 `org.springframework.data.domain.Slice` : 추가 count 쿼리 없이 다음 페이지만 확인 가능(내부적으
로 limit + 1조회)
`List` (자바 컬렉션): 추가 count 쿼리 없이 결과만 반환

 

메서드 생성 및  카운트 쿼리 사용여부 

// 반환 타입에 따른 Count 쿼리 사용 여부
Page<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용 
Slice<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용 안함 
List<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용 안함 
List<Member> findByUsername(String name, Sort sort);

// Page 사용 예저 정의 코드 Count 쿼리를 사용한다. 
public interface MemberRepository extends Repository<Member, Long> {
     Page<Member> findByAge(int age, Pageable pageable);
}

 

사용하기 

//페이징 조건과 정렬 조건 설정
@Test
public void page() throws Exception {
	//given
     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));
 	 //when
     PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC,
     "username"));
     Page<Member> page = memberRepository.findByAge(10, pageRequest);
     //then
     List<Member> content = page.getContent(); //조회된 데이터 
     assertThat(content.size()).isEqualTo(3); //조회된 데이터 수 
     assertThat(page.getTotalElements()).isEqualTo(5); //전체 데이터 수 
     assertThat(page.getNumber()).isEqualTo(0); //페이지 번호 
     assertThat(page.getTotalPages()).isEqualTo(2); //전체 페이지 번호 
     assertThat(page.isFirst()).isTrue(); //첫번째 항목인가? 
     assertThat(page.hasNext()).isTrue(); //다음 페이지가 있는가?
}

두 번째 파라미터로 받은 `Pageable` 은 인터페이스다. 따라서 실제 사용할 때는 해당 인터페이스를 구현한 `org.springframework.data.domain.PageRequest` 객체를 사용한다.
`PageRequest` 생성자의 첫 번째 파라미터에는 현재 페이지를, 두 번째 파라미터에는 조회할 데이터 수를 입력
한다. 여기에 추가로 정렬 정보도 파라미터로 사용할 수 있다. 참고로 페이지는 0부터 시작한다

참고 Page는 0부터

 

Slice 인터페이스는 다음이 있는지만 단순히 알려준다 궁금하면 찾아보는 걸로 하자!

 

스프링 데이터 JPA를 사용한 벌크성 수정 쿼리

벌크성 쿼리를 사용하고 나서 영속성 컨텍스트를 초기화 하자! 영속성컨텍스트에과거값이남아서문제가될수있다.

@Modifying(clearAutomatically = true) // 벌크성 수정, 삭제 쿼리는 @Modifying 어노테이션 사용 사용하지 않으면 예외발생!!!!
 @Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);

 

 

@EntityGraph

엔티티 그래프는 사실상 패치 조인의 간편 버전. LEFT OUTER JOIN을 사용한다. 

 

배경

지연로딩을 하고 조회해서 다른 엔티티에 접근하면 N + 1문제가 발생 가능하다. (다른 엔티티들은 사용할 때 가져오니까 그래도 지연로딩은 필수로 걸고 필요한 경우 함께 가져오는 방식으로 구현하는게 맞다.)  해결법중에 하나는 fetchJoin이 있다.

 

//공통 메서드 오버라이드
@Override
@EntityGraph(attributePaths = {"team"}) List<Member> findAll();

//JPQL + 엔티티 그래프 
@EntityGraph(attributePaths = {"team"}) 
@Query("select m from Member m") 
List<Member> findMemberEntityGraph();

//메서드 이름으로 쿼리에서 특히 편리하다. 
@EntityGraph(attributePaths = {"team"})
List<Member> findByUsername(String username)

 

 

확장 기능

 

사용자 정의 리포지토리 구현 

스프링 데이터 JPA 리포지토리는 인터페이스만 정의하고 구현체는 스프링이 자동 생성 

스프링 데이터 JPA가 제공하는 인터페이스를 직접 구현하면 구현해야 하는 기능이 너무 많음 

다양한 이유로 인터페이스의 메서드를 직접 구현하고 싶다면?

  • JPA 직접 사용( `EntityManager` ) 
  • 스프링 JDBC Template 사용 
  • MyBatis 사용
    데이터베이스 커넥션 직접 사용 등등... 
  • Querydsl 사용

Auditing

엔티티들에게 공통 필드를 적용하고 싶다면 Auditing 기능을 활용하자. 

  • 등록일
  • 수정일
  • 등록자
  • 수정자

참고 : 수정자는 최초에 등록할 때 등록자와 함께 초기화 추천 

 

Web확장  

도메인 클래스 컨버터

HTTP 파라미터로 넘어온 엔티티의 아이디로 엔티티 객체를 찾아서 바인딩

페이징과 정렬

스프링 데이터가 제공하는 페이징과 정렬 기능을 스프링 MVC에서 편리하게 사용할 수 있다.

글로벌 페이지 사이즈 최대 페이지 사이즈 수정가능 (개별도 가능 )

@GetMapping("/members")
 public Page<Member> list(Pageable pageable) {
     Page<Member> page = memberRepository.findAll(pageable);
     return page;
 }
 
 요청 파라미터**
예) `/members?page=0&size=3&sort=id,desc&sort=username,desc`
page: 현재 페이지, **0부터 시작한다.**
size: 한 페이지에 노출할 데이터 건수
sort: 정렬 조건을 정의한다. 예) 정렬 속성,정렬 속성...(ASC | DESC), 정렬 방향을 변경하고 싶으면 `sort` 파라 미터 추가 ( `asc` 생략 가능)

 

스프링 데이터 JPA 분석 

`@Repository` 적용: JPA 예외를 스프링이 추상화한 예외로 변환 

  • `@Transactional` 트랜잭션 적용
  • JPA의 모든 변경은 트랜잭션 안에서 동작
  • 스프링 데이터 JPA는 변경(등록, 수정, 삭제) 메서드를 트랜잭션 처리
  • 서비스 계층에서 트랜잭션을 시작하지 않으면 리파지토리에서 트랜잭션 시작
  • 서비스 계층에서 트랜잭션을 시작하면 리파지토리는 해당 트랜잭션을 전파 받아서 사용
  • 그래서 스프링 데이터 JPA를 사용할 때 트랜잭션이 없어도 데이터 등록, 변경이 가능했음(사실은 트랜잭션 이 리포지토리 계층에 걸려있는 것임

 

나머지 기능들

Specifications

Query By Example

Projections

네티이브 쿼리

Projections + 네이티브 쿼리 (이것만 한번 찾아서 보든하자)

 

장단점이 존재하지만 한계가 명확해서 QueryDsl을 먼저 배우고 고민하는게 좋을 듯하다. 

 

 


김영한 "실전! 스프링 부트와 JPA 활용1"을 최근에 다시 들었다.
내용을 전부 정리하기 보다는 챕터별로 중요하게 느낀 내용 위주로 간략하게 정리하는게 목표다. 

 

실전! 스프링 부트와 JPA 활용1 강의 내용 정리 

 

프로젝트 세팅 

spring.jpa.hibernate.ddl-auto 옵션

  • none: Hibernate가 DDL을 관리하지 않음. (기본값)
  • create: 애플리케이션 시작 시 기존 테이블을 삭제하고 새로 생성.
  • create-drop: create와 동일하지만 애플리케이션 종료 시 테이블 삭제.
  • update: 기존 테이블 구조를 유지하면서 변경된 부분만 업데이트.
  • validate: 테이블이 엔티티와 일치하는지 확인만 하고 수정하지 않음.

보통 운영 환경에서는 none 또는 validate,
개발 환경에서는 **update 또는 create**를 많이 사용한다.

도메인 분석 설계

다대다 관계일 경우 일대다 다대일로 보통 풀어서 설계한다. 
이유는 어차피 Entity를 다대다로 만들더라도 데이터베이스는 일대다 다대일로 바뀌어서 만들어진다. 따라서 데이터베이스와 구조가 일치하지 않아서 복잡성이 높아질 수 있고, 실무에서는 중간테이블에 또 다른 정보를 넣기도 해서 일대다 다대일로 풀어서 구현하자. 

 

외래 키가 있는 곳을 연관관계의 주인으로 정하자. 

연관관계의 주인은 비즈니스상에 우위에 있다고 정하는 것이 아니라 외래키를 관리하는 쪽이 맡으면 된다.(단 무조건은 아니다.)

mappedBy <- 연관관계 주인이 아닌쪽(양방향일 경우)  JoinColumn <- 연관관계 주인쪽 

 

3. cascade 옵션에 대해서 한번 보자.

CascadeType.RESIST
– 엔티티를 생성하고, 연관 엔티티를 추가하였을 때 persist() 를 수행하면 연관 엔티티도 함께 persist()가 수행된다.  만약 연관 엔티티가 DB에 등록된 키값을 가지고 있다면 detached entity passed to persist Exception이 발생한다.

완전히 보모와 생명주기 동일할 때 사용

CascadeType.MERGE
– 트랜잭션이 종료되고 detach 상태에서 연관 엔티티를 추가하거나 변경된 이후에 부모 엔티티가 merge()를 수행하게 되면 변경사항이 적용된다.(연관 엔티티의 추가 및 수정 모두 반영됨)

CascadeType.REMOVE 

– 삭제 시 연관된 엔티티도 같이 삭제됨

CascadeType.DETACH 

– 부모 엔티티가 detach()를 수행하게 되면, 연관된 엔티티도 detach() 상태가 되어 변경사항이 반영되지 않는다.

CascadeType.ALL 

– 모든 Cascade 적용


getter는 열지만 setter는 닫자

 

~ToOne쪽에 fetch  = FetchType.Lazy로 닫자.(~ToMany는 어차피 자동적용이지만 팀프로젝트시 명시적으로 닫자)
Entity가져올 때 연관된 데이터도 다 가져와주는 좋은 기능이지만 연관관계가 복잡한 경우 전부 가져오므로 그냥 일괄적으로 닫고 
필요할 때 가져오는 Lazy로 설정하자. 필요할 경우 fetch join 같은걸 사용하거나 하면 된다. 

EAGER는 JPA가 자동으로 JOIN을 사용해서 데이터를 한 번에 가져오려고 함 → 성능 이슈 발생 가능

임베디드 타입 (ex Address)

주문에 주소, 회원의 주소처럼 공통적으로 사용되고 실제 엔티티에 핵심은 아니지만 내용이 많은경우 이렇게 빼는 듯하다.

 

컬렉션은 필드에서 초기화 하자. (필드 선언할 때 초기화도 진행)
nul문제에서 안전하다.

 

예제 애플리케이션구조

회원 도메인 개발

Repository
@Repository : 스프링 빈으로 등록, JPA 예외를 스프링 기반 예외로 변환

@PersistenceContext: 엔티티 매니저(EntityManager) 주입

@PersistenceUnit: 엔티티 매니터 팩토리(EntityManagerFactory)주입 (어차피 안 썼었음 직접 EntityManagerFactory 이거 안 쓰니까)

 

Service

@Transactional: 트랜잭션, 영속성 컨텍스트 

읽기 전용일 경우 readOnly=true 설정하기 

회원가입시 검증로직이 있어도 멀티 스레드 환경을 고려해서 회원명 컬럼에 유니크 제약조건을 추가하는 것이 안전하다.(유니크여야할 경우만)

 

스프링 필드 주입 대신에 생성자 주입을 사용하자. 

생성자하나면 @Autowired 생략해도 주입해줌 

 

테스트 관련 

@RunWith(SpringRunner.class): 스프링과 테스트 통합

@SpringBookTest: 스프링 부트 띄우고 테스트 (이게 없으면 @Autowired 다 실패) 

@Transactional : 반복 가능한 테스트 지원, 각각의 테스트를 실행할 때마다 트랜잭션을 시작하고 테스트가

끝나면 트랜잭션을 강제로 롤백

tset/resources/application.yml 에 만들면 test시에는 이거 읽음. 

이렇게 통합적인 테스트 말고 단위테스트를 권장하기는 한다. 

 

주문 도메인 개발

 

동적 쿼리 비교

JPQL  동적으로 처리하기 위해서 상당히 많은 코드 필요 + 문자열로 만들다보니 컴파일 시점에 오류를 알 수 없다.

Criteria 문자열은 아니지만 너무 복잡 보고 해석이 안 된다.

QueryDSL 제일 편하다 이걸 사용하자. 

 

웹  개층 개발

폼 객체 vs 엔티티 직접 사용 

요구사항이 간단할 때는 상관없을 수 있지만 조금만 복잡해지면 엔티티는 화면에 종속적으로 변하고 너저분해 질 수 있다.
결국 유지보수를 어렵게 만든다. 실무에서는 엔티티는 핵심 비즈니스 로직만 가지고 있고, 화면을 위한 로직은 없어야한다. 
화면이나 API에 맞는 폼객체나 DTO를 사용하자.

 

변경 감지와 병합

준영속 엔티티 = 영속성 엔티티가 더는 관리하지 않는 엔티티를 발한다. (특징 식별자를 가지고있다.)DB에 한번 저장됨 
준영속 엔티티를 수정하는 2가지 방법

변강 감지 사용(엔티티 조회 -> 변경 -> 더티체킹 -> DB변경 ), 병합(merge) 사용

 

준영속 엔티티의 식별자값으로 조회함 -> 영속 엔티티의 값을 준영속 엔티티의 값으로 덮어버림 (병합) -> 트랜잭션 커밋 시점 변경감지 update

문제 : 병합을 사용하면 모든 속성이 변경된다. 병합시 값이 없으면 null로 업데이트 할 위험도 있다. 

엔티티를 변경할 때는 변경 감지를 활용하자(병합쓰지 말자.)

 

정리

간략하게 정리가 끝났다. JPA로 프로젝트하기 전에 해당 내용을 상기하고 프로젝트를 진행하면 좋을 것 같다. 

org.hibernate.TransientObjectException: persistent instance references an unsaved transient instance of 'jpabook1.jpashop1.domain.Member' (save the transient instance before flushing)

에러 원인 

JPA를 활용해서 Entity를 만들고 주문로직을 구현하는 중이었다.

회원Entity, 주문상품Entity, 수량 정보를 가지고 주문Entity를 생성할 때 오류가 발생했다. 
TransientObjectException 오류는 Fk값이 없는 MemberEntity(DB에 저장되지 않은)를 OrderEntity에 저장하려고 해서 발생한 에러였다. 

@Entity
@Table(name="orders")
@Getter @Setter
public class Order {
	
	// 생략 ~~~
    
    
	@ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "member_id")
        private Member member;

    
    // 생략 ~~

}

분명 나는 findById 메서드를 통해서 member Entity를 가져 왔는데 뭐가 문제지 하고 계속 헤매다가 

@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)

OrderEntity와 생성과 삭제를 같이하는 cascade 옵션을 추가하기에 이르렀다.
하지만 이도 틀렸다. 같이 생성되는 신규 엔티티 였으면 이게 맞았을 텐데 진짜 원인은


자 여기 연관관계 편의 메서드라고 정의한 나의 코드에 오류가 있었다 

내가 전달한 member를 사용하는게 아니라 계속 신규 

Entity를 생성해서 저장하려고 하니까 에러가 발생한 것이 었다. 

member를 전달하고 나서도 new Member();라니 반성해야겠다. InteliJ 자동 완성이 너무 편해서 저런 실수가 발생했다.
결국에는 코드를 원래대로 고치고 돌리니 잘 동작한다.

정리

1. TransientObjectException 오류는  DB저장되지 않은 Entity를  FK로 활용해서 발생하는 에러다.(Order테이블을 생성하기 위한 Member)

2. 결국 에러의 원인은 내가 작성한 코드 실수 였다. 

3. cascade 옵션에 대해서 한번 보자.

 

CascadeType.RESIST
– 엔티티를 생성하고, 연관 엔티티를 추가하였을 때 persist() 를 수행하면 연관 엔티티도 함께 persist()가 수행된다.  만약 연관 엔티티가 DB에 등록된 키값을 가지고 있다면 detached entity passed to persist Exception이 발생한다.

CascadeType.MERGE
– 트랜잭션이 종료되고 detach 상태에서 연관 엔티티를 추가하거나 변경된 이후에 부모 엔티티가 merge()를 수행하게 되면 변경사항이 적용된다.(연관 엔티티의 추가 및 수정 모두 반영됨)

CascadeType.REMOVE 

– 삭제 시 연관된 엔티티도 같이 삭제됨

CascadeType.DETACH 

– 부모 엔티티가 detach()를 수행하게 되면, 연관된 엔티티도 detach() 상태가 되어 변경사항이 반영되지 않는다.

CascadeType.ALL 

– 모든 Cascade 적용

출처: https://cpdev.tistory.com/85 [하루하나:티스토리]

4. CascadeType.All 로 해결하려고 했는데 이는 완전 잘못된 접근이었다. 앞으로도  오류를 완전히 이해하고 코드를 작성하자. 



요약 

1.  외래키가 있는 쪽이 주인. 
2.  1 : N에서 N이 주인 
3   주인은 JoinColumn,    종속자은  mappedBy

외래키 있는 쪽이 주인으로 만드는 이유
1. 복잡성을 낮추기 위해서. 
2. 성능 이슈가 생길 수 있기 때문에.


JPA에서 양방향 연관관계를 매핑할 때 연관관계의 주인(owner)종속자(inverse, non-owner)의 개념이 중요합니다. 여기서 연관관계의 주인과 종속자는 데이터베이스에서 외래 키(Foreign Key)가 어느 쪽에 위치하는지에 따라 결정됩니다. 이 개념을 이해하면 양방향 연관관계를 올바르게 매핑할 수 있습니다.

 

1. 연관관계의 주인 (Owner)

연관관계의 주인은 데이터베이스에 외래 키가 있는 테이블을 말합니다. 주인은 데이터베이스에 실제로 외래 키를 관리하며, 연관관계를 업데이트하거나 저장할 때 외래 키를 변경할 수 있는 책임을 가집니다. @JoinColumn을 사용하여 외래 키가 매핑된 필드에 지정합니다.

2. 연관관계의 종속자 (Non-owner, Inverse)

연관관계의 종속자는 주인이 아닌 쪽으로, 외래 키를 직접적으로 관리하지 않습니다. 종속자는 단순히 매핑 정보를 가지고 있을 뿐이며, 데이터베이스에 변경 사항을 반영하지 않습니다. 종속자는mappedBy 속성을 사용하여 연관관계의 주인을 지정합니다.

연관관계 판단 방법

외래 키 위치를 기준으로 판단: 데이터베이스에 외래 키가 있는 쪽이 연관관계의 주인이 되며, @JoinColumn을 사용하여 매핑합니다. 반대로, 외래 키가 없는 쪽은 종속자로 mappedBy를 사용하여 주인을 지정합니다.

비즈니스 로직을 고려: 양방향 연관관계를 설정할 때 어느 쪽이 더 자연스럽게 외래 키를 소유하는지가 중요합니다. 예를 들어, Order와 Customer 관계에서 Order가 Customer를 참조하는 것이 더 자연스럽다면 Order가 연관관계의 주인이 되는 것이 바람직합니다.

@Entity
public class Customer {
    @OneToMany(mappedBy = "customer")
    private List<Order> orders =  = new ArrayList<>();
    // 기타 필드 및 메소드
}

 

@Entity
public class Order {
    @ManyToOne
    @JoinColumn(name = "customer_id")
    private Customer customer;
    // 기타 필드 및 메소드
}

위 예시에서 Order 엔티티가 Customer를 참조하는 외래 키를 가지고 있기 때문에 Order가 연관관계의 주인이 되고, Customer는 종속자입니다. 따라서 Order 엔티티에서는 @JoinColumn을 사용하고, Customer 엔티티에서는 mappedBy 속성을 사용하여 Order 쪽에서 매핑된 필드를 지정합니다.

이와 같이 JPA에서 양방향 연관관계를 매핑할 때 외래 키의 위치와 비즈니스 로직을 고려하여 연관관계의 주인과 종속자를 결정하는 것이 중요합니다.

 

3. 연관관계 1 대 N일 경우 사용하는 어노테이션 정리 (참고용)

    @OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    @JsonIgnore
    private List<CartItemEntity> cartItems = new ArrayList<>();

 

1. new ArrayList<>()로 미리 생성하는 이유 > 객체가 null이 되는 걸 방지하려고 Null체크를 안 하고  NullPointerException도 안 터진다. 

2.@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true) 

  • orphanRemoval = true > 부모의 참조를 잃어버리면 알아서 지워짐.
  • mappedBy = "cart" cart필드를 가지고 양방향매핑
  • cascade = CascadeType.ALL 주인 엔티티에 대한 변경(저장, 수정, 삭제)이 자동으로 적용 
  • @Builder.Default  빌더가 기본적으로 초기화 해주지 않아서 "초기화 해라"라고 넣은겨
  • @JsonIgnore 순환참조 무한루프 방지

 

 

 

참고사항

단방향만 으로 테이블과 객체의 연견관계 매핑은 완료.
양방향으로 만들면 반대방향으로 객체 그래프 탐색 가능
양방향 연관관계를 매핑하려면 객체에서 양쪽 방향 모두 관리(테이블은 그대로)

+ Recent posts