[JPA]PostgreSQL 사용시, 엔티티에 Enum 매핑 오류
개발 환경SpringBoot 3.3.5Java 17PostgreSQL 14 문제 상황ERROR: column "XXX" is of type XXX but expression is of type character varying  Hint: You will need to rewrite or cast the expression. @Enumerated(EnumType.STRING)@Column(nullable = false, name = "level")private UserLevel level;  엔티티에 ENUM 타입이 있는데 이를 enum이 아닌 문자열로 인식해서 발생하는 문제임을 알았다.XXX(예시) 라는 컬럼이 있는데, PostgreSQL에서는 ENUM으로 만들었지만, JPA에서 이를 문자열로 인식한 상..
2024.11.21
no image
[Query DSL] Spring Data JPA와 Query DSL 통합(+ Query DSL 페이징 최적화)
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.Query DSL을 Spring Data JPA에서 사용하려면 사용자 정의 리포지토리를 만들어야 한다. MemberTeamDto@Datapublic 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) ..
2024.08.20
no image
[Query DSL] 중급 문법
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.테스트 기본 코드@Entity@Getter @Setter@NoArgsConstructor(access = AccessLevel.PROTECTED)@ToString(of = {"id", "username", "age"})public class Member { @Id @GeneratedValue @Column(name = "member_id") private Long id; private String username; private int age; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "team_id") privat..
2024.08.19
no image
[Query DSL] 기본 문법
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.EntityManager 로 JPAQueryFactory 생성Querydsl은 JPQL 빌더JPQL: 문자(실행 시점 오류), Querydsl: 코드(컴파일 시점 오류)JPQL: 파라미터 바인딩 직접, Querydsl: 파라미터 바인딩 자동 처리JPAQueryFactory를 필드로 제공하면 동시성 문제는 JPAQueryFactory를 생성 할 때 제공하는 EntityManager(em)에 달려있다. 스프링 프레임워크는 여러 쓰레드에서 동시에 같은 EntityManager에 접근해도, 트랜잭션 마다 별도의 영속성 컨텍스트를 제공하기 때문에, 동시성 문제는 걱정하지 않아도 된다. 테스트 기본 코드@Entity@Get..
2024.08.18
no image
[Query DSL] Query DSL 초기 세팅
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.필자의 스프링 부트 버전은 3.3.2이다. 의존성 추가build.gradle의 dependencies에 아래의 내용을 추가해준다.implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"이후 gradle을 새로고침 해준다. 환경 설정 검증테스트용 엔티티를 하나 추가해준다./src/main/java/study/querydsl/controller/entity/Hell..
2024.08.17
no image
[Spring Data JPA] Projections 과 Native Query
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.ProjectionsProjections는 Spring Data JPA에서 특정 엔티티의 일부 필드만을 선택적으로 조회하기 위해 사용하는 기능이다. Projections를 통해 전체 엔티티를 조회하지 않고 필요한 필드만을 선택적으로 가져올 수 있으며, 이를 통해 성능을 최적화하고 데이터 전송량을 줄일 수 있다. 인터페이스 기반 프로젝션 (Interface-based Projection)프로젝션을 위해 인터페이스를 정의하고, 필요한 필드만 메서드로 선언한다.JPA는 이 인터페이스를 구현하는 프록시 객체를 생성하여, 쿼리 결과를 이 인터페이스의 구현체로 반환한다.public interface UsernameOnly ..
2024.08.16
no image
[Spring Data JPA] 새로운 엔티티인지 구별하는 방법
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.이 글은 JPA의 변경 감지와 병합에 대한 이해가 필요합니다.2024.08.05 - [Java Category/JPA] - [JPA] 병합(Merge)과 변경 감지(Dirty Checking) [JPA] 병합(Merge)과 변경 감지(Dirty Checking)이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.JPA(Java Persistence API)에서 변경 감지(Dirty Checking)와 병합(Merge)은 엔티티 상태 관리를 위한 중요한 개념이rebugs.tistory.com save() 메서드의 구조org.springframework.data.jpa.rep..
2024.08.15
no image
[Spring Data JPA] 확장 기능
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.사용자 정의 리포지토리사용자 정의 인터페이스public interface MemberRepositoryCustom { List findMemberCustom();} 사용자 정의 인터페이스 구현 클래스@RequiredArgsConstructorpublic class MemberRepositoryCustomImpl implements MemberRepositoryCustom { private final EntityManager em; @Override public List findMemberCustom() { return em.createQuery("select m from Memb..
2024.08.14

개발 환경

  • SpringBoot 3.3.5
  • Java 17
  • PostgreSQL 14

 

문제 상황

ERROR: column "XXX" is of type XXX but expression is of type character varying  Hint: You will need to rewrite or cast the expression.

 

@Enumerated(EnumType.STRING)
@Column(nullable = false, name = "level")
private UserLevel level;

 

 

엔티티에 ENUM 타입이 있는데 이를 enum이 아닌 문자열로 인식해서 발생하는 문제임을 알았다.

XXX(예시) 라는 컬럼이 있는데, PostgreSQL에서는 ENUM으로 만들었지만, JPA에서 이를 문자열로 인식한 상황이다.

MySQL을 사용할 때는 이런일이 전혀 발생하지 않았기 때문에 매우 당황했다.

 

하지만 폭풍 구글링으로 문제를 해결할 수 있었다.

 

문제 해결

@Enumerated(EnumType.STRING)
@Column(nullable = false, name = "level", columnDefinition = "level")
@JdbcTypeCode(SqlTypes.NAMED_ENUM)
private UserLevel level;

@column 애노테이션의 columnDefinition에 컬럼이름을 명시해주고, 이 컬럼이 ENUM 타입이라는 것을 명시해주는 @JdbcTypeCode(SqlTypes.NAMED_ENUM) 애노테이션을 추가해주어서 해결하였다.

 

문제를 정리하자면 자바의 ENUM 타입을 JPA가 자동으로 인지하지 못하기 때문에 오류가 발생한 것이고, 만약 데이터베이스에서 Enum 타입을 직접 정의하고, 그 타입의 이름이 level이라면 @Column의 columnDefinition 속성에 level 타입을 명시해야 한다.
이는 데이터베이스에서 해당 컬럼이 level이라는 사용자 정의 타입을 사용할 수 있도록 명시적으로 설정하는 과정이라고 한다.

@JdbcTypeCode(SqlTypes.NAMED_ENUM)는 Hibernate 6.0부터 도입된 방식으로, 데이터베이스의 사용자 정의 ENUM 타입을 JPA 엔티티와 매핑하기 위해 사용되는 애노테이션이다. 
이 애노테이션은 Hibernate가 데이터베이스의 명명된 Enum 타입(NAMED ENUM)을 인식하도록 설정한다고 한다.

 

오늘도 하나 배웠다~

 

참고 : https://j-ungry.tistory.com/390

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


Query DSL을 Spring Data JPA에서 사용하려면 사용자 정의 리포지토리를 만들어야 한다.

 

MemberTeamDto

@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;
    }
}

 

MemberSearchCondition

@Data
public class MemberSearchCondition {
    //회원명, 팀명, 나이(ageGoe, ageLoe)

    private String username;
    private String teamName;
    private Integer ageGoe;
    private Integer ageLoe;
}

 

 

사용자 정의 리포지토리 생성

사용자 정의 리포지토리 사용법

  1. 사용자 정의 인터페이스 작성
  2. 사용자 정의 인터페이스 구현 클래스 작성
  3. 스프링 데이터 리포지토리에 사용자 정의 인터페이스 상속

사용자 정의 인터페이스 구현 클래스

규칙: 사용자 정의 인터페이스 이름(리포지토리 인터페이스 이름도 가능) + Impl
스프링 데이터 JPA가 인식해서 스프링 빈으로 등록

여기서는 사용자 정의 인터페이스 구현 클래스의 이름을 위의 그림과 다르게 MemberRepositoryCustomImpl로 작성하였다

 

 

사용자 정의 인터페이스 인터페이스 작성

public interface MemberRepositoryCustom {
    List<MemberTeamDto> search(MemberSearchCondition condition);
}

 

사용자 정의 인터페이스 구현 클래스 작성

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    public MemberRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    @Override
    //회원명, 팀명, 나이(ageGoe, ageLoe)
    public List<MemberTeamDto> search(MemberSearchCondition condition) {
        return queryFactory
                .select(new QMemberTeamDto(
                        QMember.member.id,
                        QMember.member.username,
                        QMember.member.age,
                        QTeam.team.id,
                        QTeam.team.name))
                .from(QMember.member)
                .leftJoin(QMember.member.team, QTeam.team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .fetch();
    }

    private BooleanExpression usernameEq(String username) {
        return StringUtils.isEmpty(username) ? null : member.username.eq(username);
    }
    private BooleanExpression teamNameEq(String teamName) {
        return StringUtils.isEmpty(teamName) ? null : team.name.eq(teamName);
    }
    private BooleanExpression ageGoe(Integer ageGoe) {
        return ageGoe == null ? null : member.age.goe(ageGoe);
    }
    private BooleanExpression ageLoe(Integer ageLoe) {
        return ageLoe == null ? null : member.age.loe(ageLoe);
    }
}
  • search 메서드
    -queryFactory를 통해 Member와 Team 엔티티를 leftJoin 하여 조회한다.
    -조회하는 필드는 Member의 id, username, age와 Team의 id, name이다.
    -조회된 데이터를 MemberTeamDto에 매핑하여 반환한다.
    -쿼리에 사용되는 조건들은 where 절에서 각 메서드를 호출하여 동적으로 생성된다. 각 조건 메서드는 null일 경우 해당 조건을 무시-하도록 설계되었다.

  • 조건 메서드
    -각 조건 메서드는 BooleanExpression을 반환하며, null을 반환할 경우 해당 조건은 무시된다.
    -usernameEq: username이 비어 있지 않으면 해당 값을 기준으로 검색한다.
    -teamNameEq: teamName이 비어 있지 않으면 해당 팀 이름을 기준으로 검색한다.
    -ageGoe: ageGoe가 null이 아니면 해당 나이 이상의 조건을 추가한다.
    -ageLoe: ageLoe가 null이 아니면 해당 나이 이하의 조건을 추가한다.

  • 동적 쿼리: usernameEq, teamNameEq, ageGoe, ageLoe 메서드들은 입력 값이 없는 경우(null 또는 빈 값) 조건을 무시하도록 구현되어 있어 동적 쿼리를 구성할 수 있다.
  • BooleanExpression: Querydsl에서 사용하는 조건식 타입으로, 각 조건을 결합하여 where 절에 사용할 수 있다. null을 반환할 경우 해당 조건은 무시되므로, 동적으로 쿼리의 조건을 설정할 수 있다.
  • leftJoin: Member와 Team을 조인할 때 leftJoin을 사용하여 Member가 속한 Team이 없는 경우에도 결과에 포함될 수 있도록 한다.

 

 

스프링 데이터 리포지토리 상속

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
    List<Member> findByUsername(String username);
}

 

 

Query DSL 페이징 연동

전체 카운트를 한 번에 조회하는 단순한 방법

public interface MemberRepositoryCustom {
    //...

    //전체 카운트를 한 번에 조회하는 단순한 방법
    Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable);

    //데이터 내용과 전체 카운트를 별도로 조회하는 방법
    Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable);

    //CountQuery 최적화
    Page<MemberTeamDto> searchPageOptimizationCount(MemberSearchCondition condition, Pageable pageable);
}

 

MemberRepositoryImpl

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    public MemberRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    //...

    /**
     * 단순한 페이징, fetchResults() 사용
     */
    @Override
    public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
        QueryResults<MemberTeamDto> results = queryFactory
                .select(new QMemberTeamDto(
                        QMember.member.id,
                        QMember.member.username,
                        QMember.member.age,
                        QTeam.team.id,
                        QTeam.team.name))
                .from(QMember.member)
                .leftJoin(QMember.member.team, QTeam.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);
    }
}
fetchResults(), fetchCount() Deprecated는 QueryDSL에서 향후 미지원한다고 발표하였다.

따라서 이 글의 Querydsl fetchResults(), fetchCount() Deprecated(향후 미지원) 섹션의 SearchPageSimple() 리펙토링 방식을 사용하는 것을 권장한다.

이 코드는 Querydsl을 사용하여 페이징 처리된 조회 쿼리를 작성한 예시이다.

사용자가 입력한 조건에 맞춰 조회 결과를 페이징하여 반환하는 기능을 제공한다.

주요 메서드는 searchPageSimple으로, 주어진 조건과 Pageable 객체를 기반으로 조회 결과를 처리한 후 Page 객체로 반환한다.

 

  • QueryResults<MemberTeamDto> results
    QueryFactory를 통해 QMember와 QTeam을 기준으로 조회를 수행한다.
    select 절에서 Member와 Team의 필드를 가져와 MemberTeamDto에 매핑한다.
    where 절에서는 동적으로 쿼리 조건을 적용하여 데이터를 필터링한다.
    offset과 limit를 pageable 객체에서 가져와 페이징 처리를 한다.
    fetchResults() 메서드는 결과 목록과 총 개수를 동시에 가져온다.

  • 페이징 처리
    results.getResults()를 통해 조회된 MemberTeamDto 리스트를 가져온다.
    results.getTotal()은 조건에 맞는 전체 데이터의 개수를 반환한다.
    new PageImpl<>(content, pageable, total)을 통해 페이징된 결과를 반환한다. PageImpl은 Spring Data에서 제공하는 페이징 결과 클래스이다.

  • Pageable 객체
    Pageable은 페이징과 정렬 정보를 포함한 객체로, 클라이언트로부터 전달받은 페이지 번호, 페이지 크기, 정렬 등의 정보를 담고 있다.
    offset은 조회를 시작할 데이터의 위치(즉, 몇 번째 데이터부터 조회할지를 의미)이며, limit는 한 페이지당 조회할 데이터의 개수를 설정한다.

 

  • offset(pageable.getOffset())은 조회 시작 위치를 설정
  • limit(pageable.getPageSize())은 한 페이지에 포함될 데이터 개수를 설정

  • fetchResults()로 쿼리 실행 후, 조회된 리스트와 총 개수를 가져온다.
  • PageImpl을 사용하여 최종 결과를 페이징 형식으로 반환한다.

 

데이터 내용과 전체 카운트를 별도로 조회하는 방법

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    public MemberRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

   //...

    /**
     * 복잡한 페이징
     * 데이터 조회 쿼리와, 전체 카운트 쿼리를 분리
     */
    @Override
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
        List<MemberTeamDto> content = queryFactory
                .select(new QMemberTeamDto(
                        QMember.member.id,
                        QMember.member.username,
                        QMember.member.age,
                        QTeam.team.id,
                        QTeam.team.name))
                .from(QMember.member)
                .leftJoin(QMember.member.team, QTeam.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(QMember.member)
                .from(QMember.member)
                .leftJoin(QMember.member.team, QTeam.team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .fetchCount();

        return new PageImpl<>(content, pageable, total);
    }
}
fetchResults(), fetchCount() Deprecated는 QueryDSL에서 향후 미지원한다고 발표하였다.

따라서 이 글의 CountQuery 최적화 섹션의 searchPageOptimizationCount 메서드를 방식을 이용하는 것을 권장한다.

이 코드는 Querydsl을 사용하여 페이징 처리된 조회 쿼리를 구현한 예시이다.

searchPageComplex 메서드는 동적 조건을 기반으로 데이터를 조회하고, 페이징 처리된 결과를 반환한다.

주요 특징은 fetch()와 fetchCount()를 사용해 데이터 조회와 총 개수 계산을 별도로 처리하여 성능을 최적화하려는 접근이다.

 

  • 데이터 조회 부분
    List<MemberTeamDto> content: 실제 데이터를 조회하는 쿼리이다. QueryFactory를 통해 Member와 Team을 조인하여 필요한 데이터를 MemberTeamDto로 매핑한 후 페이징 처리(offset, limit)를 적용해 조회한다.
    fetch()는 조건에 맞는 데이터를 조회해 리스트로 반환한다.

  • 총 데이터 개수 조회 부분
    long total: 총 데이터 개수를 조회하는 쿼리이다. 데이터의 페이징 처리와 별개로, 전체 결과의 총 개수를 계산하기 위해 fetchCount()를 사용한다.
    이 쿼리는 content 쿼리와 동일한 조건을 사용하지만, 실제 데이터를 가져오는 대신 개수만 계산한다.

  • 페이징 결과 반환
    new PageImpl<>(content, pageable, total)을 통해 조회된 데이터를 페이징 형식으로 반환한다. PageImpl 클래스는 스프링 데이터에서 제공하는 Page 인터페이스의 구현체로, 페이징된 데이터 목록과 관련 정보를 담고 있다.

 

  • offset(pageable.getOffset())과 limit(pageable.getPageSize())를 사용하여 페이징 처리한다. offset은 조회를 시작할 위치를, limit은 한 페이지당 조회할 데이터 개수를 설정한다.

  • fetchCount()는 전체 데이터의 개수를 반환하며, 페이징 처리에 사용된다. 이 부분은 데이터의 개수만 계산하기 때문에 select(QMember.member)로 데이터를 조회하지 않고 개수를 세는 최적화된 쿼리다.

  • PageImpl<>(content, pageable, total)을 사용하여 페이징된 데이터를 담은 Page 객체를 반환한다.

 

 

CountQuery 최적화

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    public MemberRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

	//...

    @Override
    public Page<MemberTeamDto> searchPageOptimizationCount(MemberSearchCondition condition, Pageable pageable) {
        List<MemberTeamDto> content = queryFactory
                .select(new QMemberTeamDto(
                        QMember.member.id,
                        QMember.member.username,
                        QMember.member.age,
                        QTeam.team.id,
                        QTeam.team.name))
                .from(QMember.member)
                .leftJoin(QMember.member.team, QTeam.team)
                .where(usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Long> countQuery = queryFactory
                .select(QMember.member.count())
                .from(QMember.member)
                .leftJoin(QMember.member.team, QTeam.team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                );

        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }
}

이 코드는 Querydsl을 사용하여 페이징을 최적화한 쿼리를 작성하는 예시이다.

이 메서드는 MemberSearchCondition을 기반으로 검색 조건을 적용하고, Pageable 객체를 사용하여 페이지네이션을 수행한다. 또한, 페이징 처리에 있어서 성능을 최적화하기 위해 count 쿼리를 분리하여 처리한다.

 

쿼리 실행 과정

  • content 쿼리는 실제로 조회할 데이터 목록을 가져오는 쿼리이다. 이때, Pageable 객체의 offset과 limit을 사용하여 페이징을 처리한다.
  • countQuery는 전체 데이터의 개수를 계산하는 쿼리이다. 이 쿼리는 페이징에 필요한 총 개수를 구하기 위해 사용된다.

 

성능 최적화

  • 페이징 성능을 최적화하기 위해 PageableExecutionUtils.getPage()를 사용한다. 이는 content 쿼리가 실행된 결과와, 필요할 때만 countQuery를 실행하여 전체 페이지 수를 계산하도록 한다. 따라서 불필요한 count 쿼리 실행을 줄여 성능을 높인다.

 

쿼리 조건 설정

  • usernameEq, teamNameEq, ageGoe, ageLoe 같은 메서드들은 조건이 있을 때만 쿼리에 추가하는 방식으로, 동적으로 쿼리 조건을 설정할 수 있다.

 

코드 설명

  • queryFactory: QueryDSL에서 제공하는 쿼리 생성기.
  • QMember.member: QueryDSL로 생성된 QType 클래스.
  • QTeam.team: 마찬가지로 Team 엔티티의 QType.
  • PageableExecutionUtils.getPage(): 페이징 처리를 위해 사용하는 유틸리티 메서드로, 페이징 결과 리스트와 전체 개수를 넘겨준다.

 

 

 

Querydsl fetchResults(), fetchCount() Deprecated(향후 미지원)

Querydsl의 fetchCount() , fetchResult() 는 개발자가 작성한 select 쿼리를 기반으로 count용 쿼리를 내부에서 만들어서 실행한다.

그런데 이 기능은 select 구문을 단순히 count 처리하는 용도로 바꾸는 정도이다.

따라서 단순한 쿼리에서는 잘 동작하지만, 복잡한 쿼리에서는 제대로 동작하지 않는다.

Querydsl은 향후 fetchCount() , fetchResult() 를 지원하지 않기로 결정했다.

 

따라서 count 쿼리가 필요하면 다음과 같이 별도로 작성해야한다.

@Test
 public void count() {
     Long totalCount = queryFactory
             //.select(Wildcard.count) //select count(*)
             .select(member.count()) //select count(member.id)
             .from(member)
             .fetchOne();
     System.out.println("totalCount = " + totalCount);
 }

count(*) 을 사용하고 싶으면 예제의 주석처럼 Wildcard.count 를 사용하면 된다.

member.count() 를 사용하면 count(member.id) 로 처리된다. 

응답 결과는 숫자 하나이므로 fetchOne() 을 사용한다.

 

fetchResults()는 QueryDSL에서 한번에 결과 리스트와 전체 카운트를 가져오는 방식이지만, 성능상의 이슈로 인해 deprecated되었다.

이를 대신하여, 결과 목록을 가져오는 쿼리와 전체 카운트를 가져오는 쿼리를 분리하여 리팩토링할 수 있다.

아래는 fetchResults()를 사용하지 않고 동일한 기능을 수행하도록 리팩토링한 코드이다.

 

SearchPageSimple() 리펙토링

@Override
public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
    // content 쿼리: 결과 리스트를 가져오는 쿼리
    List<MemberTeamDto> content = queryFactory
            .select(new QMemberTeamDto(
                    QMember.member.id,
                    QMember.member.username,
                    QMember.member.age,
                    QTeam.team.id,
                    QTeam.team.name))
            .from(QMember.member)
            .leftJoin(QMember.member.team, QTeam.team)
            .where(usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe()))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    // count 쿼리: 전체 카운트를 가져오는 쿼리
    long total = queryFactory
            .select(QMember.member.count())
            .from(QMember.member)
            .leftJoin(QMember.member.team, QTeam.team)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            )
            .fetchOne();

    return new PageImpl<>(content, pageable, total);
}
  • content는 fetch()를 사용하여 결과 리스트만 가져온다. fetch()는 리스트만 반환하며, fetchResults()처럼 총 개수나 기타 정보는 반환하지 않는다.
  • total은 fetchOne()을 사용하여 전체 개수를 가져온다. count() 쿼리만 실행하여 효율적으로 전체 데이터를 구한다.
  • 최종적으로 PageImpl을 사용하여 결과 리스트(content), 페이지 정보(pageable), 전체 개수(total)를 반환한다.

이렇게 쿼리를 분리하면, 각각의 목적에 맞는 쿼리를 효율적으로 실행할 수 있어 성능 최적화에 도움이 된다.

 

 

컨트롤러 개발

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/v2/members")
    public Page<MemberTeamDto> searchMemberV2(MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.searchPageSimple(condition, pageable);
    }

    @GetMapping("/v3/members")
    public Page<MemberTeamDto> searchMemberV3(MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.searchPageComplex(condition, pageable);
    }

    @GetMapping("/v4/members")
    public Page<MemberTeamDto> searchMemberV4(MemberSearchCondition condition, Pageable pageable) {
        return memberRepository.searchPageOptimizationCount(condition, pageable);
    }
}
  • /v1/members (검색 조건만으로 검색)
    검색 조건인 MemberSearchCondition을 기반으로 데이터를 필터링하여 List<MemberTeamDto>를 반환한다.
    페이징 처리 없이 모든 결과를 반환하는 방식이다.

  • /v2/members (간단한 페이징 처리)
    MemberSearchCondition과 Pageable을 받아 간단한 페이징 처리를 한다.
    memberRepository.searchPageSimple()을 호출하며, 페이징된 데이터를 Page<MemberTeamDto>로 반환한다.
    searchPageSimple()은 데이터 조회와 개수 조회를 동시에 처리하는 간단한 페이징 로직이다.

  • /v3/members (복잡한 페이징 처리)
    MemberSearchCondition과 Pageable을 받아 복잡한 페이징 처리를 한다.
    memberRepository.searchPageComplex()는 데이터 조회와 카운트 쿼리를 분리하여 각각 처리하며, 페이징 성능을 개선한 방식이다.

  • /v4/members (최적화된 카운트 쿼리 적용)
    MemberSearchCondition과 Pageable을 기반으로 데이터를 조회하지만, 카운트 쿼리를 최적화하여 필요할 때만 실행하는 방식이다.
    memberRepository.searchPageOptimizationCount()는 PageableExecutionUtils.getPage()를 사용해 불필요한 카운트 쿼리를 방지하는 방식으로 최적화되어 있다.

 

예시 요청

http://localhost:8080/v4/members?ageGoe=30&ageLoe=44&page=0&size=5

이 요청은 v4 엔드포인트에 대한 호출로, 다음 조건을 포함한다

  • 나이 조건: ageGoe=30, ageLoe=44 — 나이가 30 이상 44 이하인 멤버를 조회한다.
  • 페이징 조건: page=0, size=5 — 0번째 페이지부터 5개씩 조회한다.

이 호출은 searchPageOptimizationCount() 메서드를 사용해 페이징을 처리하며, 최적화된 카운트 쿼리를 적용하여 성능을 높인다.

반환 결과

Page<MemberTeamDto> 형태로 JSON 응답이 반환된다. 여기에는 다음 정보가 포함된다:

  • content: 해당 페이지에 해당하는 MemberTeamDto 리스트.
  • totalElements: 전체 결과의 총 개수.
  • totalPages: 전체 페이지 수.
  • pageable: 현재 페이징 상태.

 

 

 

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


테스트 기본 코드

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
public class Member {
    @Id @GeneratedValue
    @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);
    }

    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);
    }
}

 

@Data
public class MemberDto {
    private String username;
    private int age;

    public MemberDto() {
    }

    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

 

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

    @Id
    @GeneratedValue
    @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;
    }
}

 

@SpringBootTest
@Transactional
public class QuerydslBasicTest {
    @PersistenceContext
    EntityManager em;

    JPAQueryFactory queryFactory;

    @BeforeEach
    public void before() {
        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);
    }
    //...
}

 

 

프로젝션과 결과 반환

프로젝션이란 SELECT 문의 대상 지정을 뜻한다.

 

프로젝션 대상이 하나

  • 프로젝션 대상이 하나면 타입을 명확하게 지정할 수 있음
  • 프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회
@Test
public void simpleProjection(){
    List<String> result = queryFactory
            .select(member.username)
            .from(member)
            .fetch();

    for (String s : result) {
        System.out.println("s = " + s);
    }

    List<Member> result2 = queryFactory
            .select(member)
            .from(member)
            .fetch();

    for (Member s : result2) {
        System.out.println("s = " + s);
    }
}

 

튜플 조회

프로젝션 대상이 둘 이상일 때 사용

@Test
public void tupleProjection(){
    List<Tuple> result = queryFactory
            .select(member.username, member.age)
            .from(member)
            .fetch();
    for (Tuple tuple : result) {
        String username = tuple.get(member.username);
        Integer age = tuple.get(member.age);
        System.out.println("username=" + username);
        System.out.println("age=" + age);
    }
}

 

 

DTO 조회

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);
    }
}

 

field 접근

@Test
public void findDtoByField(){
    List<MemberDto> result = queryFactory
            .select(Projections.fields(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();

    for (MemberDto memberDto : result) {
        System.out.println("memberDto = " + memberDto);
    }
}

DTO가 필드의 접근 제한자가 Private여도 상관 없이 필드에 주입이 된다.

 

 

constructor 접근

@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);
    }
}

 

별칭이 다를 때

프로퍼티나, 필드 접근 생성 방식에서 이름이 다를 때 해결 방안

@Data
public class UserDto {
    private String name;
    private int age;
}

Member 엔티티는 userName이고 UserDto는 name이다.

 

  • ExpressionUtils.as(source,alias) : 필드나, 서브 쿼리에 별칭 적용 
  • username.as("memberName") : 필드에 별칭 적용
@Test
public void findUserDto(){
    QMember memberSub = new QMember("memberSub");
    List<UserDto> fetch = queryFactory
            .select(Projections.fields(UserDto.class,
                    member.username.as("name"),
                    ExpressionUtils.as(JPAExpressions
                                    .select(memberSub.age.max())
                                    .from(memberSub), "age")))
            .from(member)
            .fetch();

    for (UserDto userDto : fetch) {
        System.out.println("userDto = " + userDto);
    }
}

 

@QueryProjection

DTO의 생성자에 @QueryProjection 애노테이션을 붙힌다.

@Data
public class MemberDto {
    private String username;
    private int age;

    public MemberDto() {
    }

    @QueryProjection
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

  • Gradle의 compileJava를 실행한다.
  • QMemberDto 생성 확인

 

@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가 com.querydsl.core.annotations.QueryProjection 에 의존하게된다.

그렇기 때문에 QueryDSL을 사용하지 않으면 이 DTO를 사용하는 모든 계층에 대해 유지보수가 힘들어진다.

 

동적 쿼리

BooleanBuilder

보통 쿼리를 작성할 때, 여러 조건이 동적으로 추가될 수 있을 때가 있다.

예를 들어, 사용자가 입력한 값에 따라 조건이 달라질 때, BooleanBuilder를 사용하여 조건을 점진적으로 추가할 수 있다.

@Test
public void DynamicQuery_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 DynamicQuery_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 값은 무시된다.
  • 메서드를 다른 쿼리에서도 재활용 할 수 있다.
  • 쿼리 자체의 가독성이 높아진다.

 

조합 가능

@Test
public void DynamicQuery_AllEq() throws Exception {
    String usernameParam = "member1";
    Integer ageParam = 10;
    List<Member> result = searchMember2(usernameParam, ageParam);
    Assertions.assertThat(result.size()).isEqualTo(1);
}

private List<Member> searchMember3(String usernameCond, Integer ageCond) {
    return queryFactory
            .selectFrom(member)
            .where(allEq(usernameCond, ageCond))
            .fetch();
}

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

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;
}

이전의 BooleanBuilder 방식과는 조금 다르게, 각 조건을 따로 메서드로 분리하여 동적 쿼리를 작성한 후, 이를 결합하는 방식으로 구성되어 있다.

searchMember3 메서드는 주어진 usernameCond와 ageCond 조건에 따라 동적 쿼리를 실행하는 메서드이다.

where 절에서 allEq() 메서드를 호출하여 usernameCond와 ageCond에 따른 조건을 추가한다.

null 체크는 주의해서 처리해야 한다.

 

수정, 삭제 벌크 연산

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

@Test
public void bulkUpdate(){
    long count = queryFactory
            .update(member)
            .set(member.username, "비회원")
            .where(member.age.lt(28))
            .execute();

    em.flush();
    em.clear();

    List<Member> result = queryFactory
            .selectFrom(member)
            .fetch();
    for (Member member : result) {
        System.out.println("member = " + member);
    }
}

 

@Test
public void bulkAdd(){
    long count = queryFactory
            .update(member)
            .set(member.age, member.age.add(-1))
            .execute();

    em.flush();
    em.clear();

    List<Member> result = queryFactory
            .selectFrom(member)
            .fetch();
    for (Member member : result) {
        System.out.println("member = " + member);
    }
}

 

@Test
public void bulkMultiply(){
    long count = queryFactory
            .update(member)
            .set(member.age, member.age.multiply(2))
            .execute();

    em.flush();
    em.clear();

    List<Member> result = queryFactory
            .selectFrom(member)
            .fetch();
    for (Member member : result) {
        System.out.println("member = " + member);
    }
}

 

@Test
public void bulkDelete(){
    long count = queryFactory
            .delete(member)
            .where(member.age.gt(20))
            .execute();

    em.flush();
    em.clear();

    List<Member> result = queryFactory
            .selectFrom(member)
            .fetch();
    for (Member member : result) {
        System.out.println("member = " + member);
    }
}

 

 

SQL function 호출하기

SQL function은 JPA와 같이 Dialect에 등록된 내용만 호출할 수 있다.

 

member -> M으로 변경하는 replace 함수 사용

@Test
public void sqlFunction(){
    String result = queryFactory
            .select(Expressions.stringTemplate("function('replace', {0}, {1}, {2})",
                    member.username, "member", "M"))
            .from(member)
            .fetchFirst();

    System.out.println("result = " + result);

    List<String> result2 = queryFactory
            .select(Expressions.stringTemplate("function('replace', {0}, {1}, {2})",
                    member.username, "member", "M"))
            .from(member)
            .fetch();
    for (String s : result2) {
        System.out.println("s = " + s);
    }
}

 

소문자로 변경해서 비교

@Test
public void sqlFunction2(){
    List<String> result = queryFactory
            .select(member.username)
            .from(member)
            .where(member.username.eq(Expressions.stringTemplate("function('lower', {0})",
                    member.username)))
            .fetch();

    for (String s : result) {
        System.out.println("s = " + s);
    }
}

 

lower 같은 ansi 표준 함수들은 querydsl이 상당부분 내장하고 있다. 따라서 다음과 같이 처리해도 결과는 같다.

.where(member.username.eq(member.username.lower()))

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


  • EntityManager 로 JPAQueryFactory 생성
  • Querydsl은 JPQL 빌더
  • JPQL: 문자(실행 시점 오류), Querydsl: 코드(컴파일 시점 오류)
  • JPQL: 파라미터 바인딩 직접, Querydsl: 파라미터 바인딩 자동 처리
JPAQueryFactory를 필드로 제공하면 동시성 문제는 JPAQueryFactory를 생성 할 때 제공하는 EntityManager(em)에 달려있다. 스프링 프레임워크는 여러 쓰레드에서 동시에 같은 EntityManager에 접근해도, 트랜잭션 마다 별도의 영속성 컨텍스트를 제공하기 때문에, 동시성 문제는 걱정하지 않아도 된다.

 

테스트 기본 코드

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "username", "age"})
public class Member {
    @Id @GeneratedValue
    @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);
    }

    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);
    }
}
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "name"})
public class Team {

    @Id
    @GeneratedValue
    @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;
    }
}
@SpringBootTest
@Transactional
public class QuerydslBasicTest {
    @PersistenceContext
    EntityManager em;

    JPAQueryFactory queryFactory;

    @BeforeEach
    public void before() {
        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);
    }
    //...
}

 

 

기본 Q-Type 활용

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

QMember qMember = new QMember("m");//별칭 직접 지정 -> Member 테이블의 별칭이 m
QMember qMember = QMember.member; //기본 인스턴스 사용 -> Member 테이블의 별칭이 member

같은 테이블을 조인해야 하는 경우가 아니면 기본 인스턴스를 사용 권장

 

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

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

@Test
 public void startQuerydsl3() {
    //member1을 찾아라.
    Member findMember = queryFactory
                 .select(member)
                 .from(member)
                 .where(member.username.eq("member1"))
                 .fetchOne();
                 
         assertThat(findMember.getUsername()).isEqualTo("member1");
 }

 

 

검색 조건 쿼리

기본 검색 쿼리

@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%’ 검색

 

AND 조건을 파라미터로 처리

@Test
public void searchAndParam() {
    List<Member> result1 = queryFactory
            .selectFrom(member)
            .where(member.username.eq("member1"), member.age.eq(10))
            .fetch();

    assertThat(result1.size()).isEqualTo(1);
}
  • where() 에 파라미터로 검색조건을 추가하면 AND 조건이 추가됨
  • 이 경우 null 값은 무시 -> 메서드 추출을 활용해서 동적 쿼리를 깔끔하게 만들 수 있음

 

결과 조회

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

 

정렬

  • desc() , asc() : 내림차순, 오름차순 정렬
  • nullsLast() , nullsFirst() : null 데이터 순서 부여(last는 마지막에, first는 맨 앞으로 정렬)
/**
 * 회원 정렬 순서
 * 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();
}

 

페이징

조회 건수 제한

@Test
public void paging1() {
    List<Member> result = queryFactory
            .selectFrom(member)
            .orderBy(member.username.desc())
            .offset(1) //0부터 시작(zero index)
            .limit(2) //최대 2건 조회
            .fetch();

    assertThat(result.size()).isEqualTo(2);
}

 

전체 조회수

@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);
}

이 쿼리는 Querydsl을 사용하여 특정 조건에 맞는 Member 엔티티 데이터를 페이징 처리하여 조회하는 것이다.

여기서 fetchResults() 메서드는 페이징된 데이터뿐만 아니라 전체 데이터셋에 대한 정보도 함께 반환한다.

 

구체적으로, 이 메서드는 두 가지 중요한 정보를 제공한다

  • 페이징된 결과: 쿼리의 offset과 limit에 따라 제한된 개수의 데이터를 가져온다.
  • 전체 조회수: 이 쿼리로 제한된 데이터뿐만 아니라 전체 데이터의 개수를 파악할 수 있다.

 

ueryResults.getTotal()

  • 전체 조회수를 반환한다.
  • 이 값은 쿼리에서 페이징 처리가 이루어지지 않은 경우의 총 레코드 수이다. 예를 들어, 데이터베이스에 4개의 Member가 있다면, 전체 조회수는 4가 된다.

 

fetchResults()의 동작 방식

  • Querydsl은 fetchResults()를 호출할 때 두 번의 쿼리를 실행한다. 첫 번째 쿼리는 COUNT 쿼리로, 전체 레코드 수를 계산한다. 두 번째 쿼리는 실제 데이터를 가져오는 쿼리로, offset과 limit에 따라 페이징된 결과를 반환한다.
  • 이 방식으로 전체 데이터셋의 크기와, 페이징된 데이터셋을 동시에 얻을 수 있다.

 

fetchResults()를 통해 쿼리를 실행하고 결과를 가져온다.

fetchResults()는 쿼리 결과와 함께 총 레코드 수, 오프셋, 제한 수 등을 포함한 QueryResults 객체를 반환한다.

 

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);
}

 

GroupBy 사용

/**
 * 팀의 이름과 각 팀의 평균 연령을 구해라. */
@Test
public void group() throws Exception {
    QMember member = QMember.member;
    QTeam team = QTeam.team;

    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);
}
  1. Member와 Team을 조인하여 각 팀의 이름과 팀에 속한 모든 회원들의 평균 나이를 선택한다.
  2. groupBy(team.name)을 사용하여 팀 이름별로 데이터를 그룹화하고, 그룹화된 데이터에 대해 member.age.avg()를 계산하여 각 팀의 평균 연령을 구한다.
  3. 쿼리 결과는 각 팀별 이름과 그 팀의 평균 연령으로 이루어진 Tuple 리스트로 반환된다.

 

groupBy(), having() 예시

//...
     .groupBy(item.price)
     .having(item.price.gt(1000))
//...

 

 

Join

기본 조인

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

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

 

/**
 * 팀 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();

    for (Member m : result) {
        System.out.println("m = " + m + " " + m.getTeam());
    }
}
  • join() , innerJoin() : 내부 조인(inner join)
  • leftJoin() : left 외부 조인(left outer join)
  • rightJoin()` : rigth 외부 조인(rigth outer join)
  • JPQL의 on 과 성능 최적화를 위한 fetch 조인 제공

 

세타 조인

연관관계가 없는 필드로 조인

/**
 * 세타 조인(연관관계가 없는 필드로 조인) * 회원의 이름이 팀 이름과 같은 회원 조회 */
@Test
public void theta_join() throws Exception {
    em.persist(new Member("teamA"));
    em.persist(new Member("teamB"));
    List<Member> result = queryFactory
            .select(member)
            .from(member, team)
            .where(member.username.eq(team.name))
            .fetch();

    assertThat(result)
            .extracting("username")
            .containsExactly("teamA", "teamB");
}
  • from 절에 여러 엔티티를 선택해서 세타 조인
  • 외부 조인 불가능 다음에 설명할 조인 on을 사용하면 외부 조인 가능

 

ON 절

조인 대상 필터링

예) 회원과 팀을 조인하면서, 팀 이름이 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);
    }
}

 

on 절을 활용해 조인 대상을 필터링 할 때, 외부조인이 아니라 내부조인(inner join)을 사용하면, where 절 에서 필터링 하는 것과 기능이 동일하다.

따라서 on 절을 활용한 조인 대상 필터링을 사용할 때, 내부조인이면 익숙한 where 절로 해결하고, 정말 외부조인이 필요한 경우에만 이 기능을 사용하자.

 

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

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

/**
 * 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);
    }
}

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

  • 일반조인: leftJoin(member.team, team)
  • on조인: from(member).leftJoin(team).on(xxx)

 

FETCH 조인

페치 조인은 SQL에서 제공하는 기능은 아니다. SQL조인을 활용해서 연관된 엔티티를 SQL 한번에 조회하는 기능이다. 주로 성능 최적화에 사용하는 방법이다.

 

FETCH 조인 미적용

지연로딩으로 Member, Team SQL 쿼리 각각 실행

@PersistenceUnit
EntityManagerFactory emf;

@Test
public void fetchJoinNo() throws Exception {
    em.flush();
    em.clear();
    Member findMember = queryFactory
            .selectFrom(member)
            .where(member.username.eq("member1"))
            .fetchOne();

    boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
    assertThat(loaded).as("페치 조인 미적용").isFalse();
}

 

FETCH 조인 적용

즉시로딩으로 Member, Team SQL 쿼리 조인으로 한번에 조회

@Test
public void fetchJoinUse() throws Exception {
    em.flush();
    em.clear();
    Member findMember = queryFactory
            .selectFrom(member)
            .join(member.team, team).fetchJoin()
            .where(member.username.eq("member1"))
            .fetchOne();

    boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
    assertThat(loaded).as("페치 조인 적용").isTrue();
}

join(), leftJoin()` 등 조인 기능 뒤에 fetchJoin() 이라고 추가하면 된다.

 

서브 쿼리

com.querydsl.jpa.JPAExpressions 사용

 

WHERE 절에 서브쿼리

서브 쿼리 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);
}

 

서브 쿼리 goe 사용

/**
 * 나이가 평균 나이 이상인 회원 */
@Test
public void subQueryGoe() throws Exception {
    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).extracting("age")
            .containsExactly(30,40);
}

 

SELECT 절에 서브쿼리

/**
 * 서브쿼리 여러 건 처리, in 사용 */
@Test
public void subQueryIn() throws Exception {
    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).extracting("age")
            .containsExactly(20, 30, 40);
}

 

static import 활용

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

List<Member> result = queryFactory
     .selectFrom(member)
     .where(member.age.eq(
             select(memberSub.age.max())
    		.from(memberSub
    )) .fetch();

 

from 절의 서브쿼리 한계

JPA JPQL 서브쿼리의 한계점으로 from 절의 서브쿼리(인라인 뷰)는 지원하지 않는다.
당연히 Querydsl도 지원하지 않는다.
하이버네이트 구현체를 사용하면 select 절의 서브쿼리는 지원한다. Querydsl도 하이버네이트 구현체를 사용 하면 select 절의 서브쿼리를 지원한다.

 

from 절의 서브쿼리 해결방안

1. 서브쿼리를 join으로 변경한다. (가능한 상황도 있고, 불가능한 상황도 있다.)
2. 애플리케이션에서 쿼리를 2번 분리해서 실행한다.
3. nativeSQL을 사용한다.

 

Case 문

select, 조건절(where), order by에서 사용 가능

 

단순한 조건

List<String> result = queryFactory
         .select(member.age
            .when(10).then("열살")
            .when(20).then("스무살")
            .otherwise("기타"))
        .from(member)
        .fetch();

 

복잡한 조건

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();

 

예를 들어서 다음과 같은 임의의 순서로 회원을 출력하고 싶다면?
1. 0~30살이아닌회원을가장먼저출력
2. 0~20살회원출력
3. 21~30살회원출력

 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 절에서 함께 사용할 수 있다.

 

 

상수, 문자 더하기

상수가 필요하면 Expressions.constant(xxx) 사용

Tuple result = queryFactory
         .select(member.username, Expressions.constant("A"))
         .from(member)
         .fetchFirst();
위와 같이 최적화가 가능하면 SQL에 constant 값을 넘기지 않는다. 상수를 더하는 것 처럼 최적화가 어려 우면 SQL에 constant 값을 넘긴다.

 

 

문자 더하기 concat

String result = queryFactory
         .select(member.username.concat("_").concat(member.age.stringValue()))
         .from(member)
         .where(member.username.eq("member1"))
         .fetchOne();

결과: member1_10

 

member.age.stringValue() 부분이 중요한데, 문자가 아닌 다른 타입들은 stringValue()로 문자로 변환할 수 있다.
이 방법은 ENUM을 처리할 때도 자주 사용한다.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


필자의 스프링 부트 버전은 3.3.2이다.

 

의존성 추가

build.gradle의 dependencies에 아래의 내용을 추가해준다.

implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"

이후 gradle을 새로고침 해준다.

 

환경 설정 검증

테스트용 엔티티를 하나 추가해준다.

/src/main/java/study/querydsl/controller/entity/Hello.java

당연히 경로는 마음대로 설정해도 좋다.

 @Entity
 @Getter @Setter
 public class Hello {
     @Id @GeneratedValue
     private Long id;
 }

 

검증용 Q 타입 생성

Gradle -> Tasks -> build -> clean

 

Gradle -> Tasks -> other -> compileJava

 

 

Q 타입 생성 확인

실제 엔티티 경로 : /src/main/java/study/querydsl/controller/entity/Hello.java

Q타입 생성 경로 : /build/classes/java/main/study/querydsl/controller/entity/QHello.class

 

Q타입은 컴파일 시점에 자동 생성되므로 버전관리(GIT)에 포함하지 않는 것이 좋다. 앞서 설정에서 생성 위치를 gradle build 폴더 아래 생성되도록 했기 때문에 이 부분도 자연스럽게 해결된다.

 

 

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


Projections

Projections는 Spring Data JPA에서 특정 엔티티의 일부 필드만을 선택적으로 조회하기 위해 사용하는 기능이다. Projections를 통해 전체 엔티티를 조회하지 않고 필요한 필드만을 선택적으로 가져올 수 있으며, 이를 통해 성능을 최적화하고 데이터 전송량을 줄일 수 있다.

 

인터페이스 기반 프로젝션 (Interface-based Projection)

  • 프로젝션을 위해 인터페이스를 정의하고, 필요한 필드만 메서드로 선언한다.
  • JPA는 이 인터페이스를 구현하는 프록시 객체를 생성하여, 쿼리 결과를 이 인터페이스의 구현체로 반환한다.
public interface UsernameOnly {
	String getUsername();
}

조회할 엔티티의 필드를 getter 형식으로 지정하면 해당 필드만 선택해서 조회(Projection)

 

public interface MemberRepository extends JpaRepository<Member, Long> {
    //...
    List<UsernameOnly> findProjectionsByUsername(String username);
}

메서드 이름은 자유, 반환 타입으로 인지

 

@Test
public void projections() throws Exception {
    //given
    Team teamA = new Team("teamA");
    em.persist(teamA);
    Member m1 = new Member("m1", 0, teamA);
    Member m2 = new Member("m2", 0, teamA);
    em.persist(m1);
    em.persist(m2);
    em.flush();
    em.clear();
    
    //when
    List<UsernameOnly> result = memberRepository.findProjectionsByUsername("m1");
    
    //then
    Assertions.assertThat(result.size()).isEqualTo(1);
 }

실제 SQL에서도 select절에서 username만 조회(Projection)하는 것을 확인할 수 있다.

 

클래스 기반 프로젝션 (Class-based Projection)

  • DTO(Data Transfer Object) 클래스를 정의하고, 생성자를 통해 필요한 필드를 전달받아 프로젝션 결과를 매핑한다.
  • 이 방법은 주로 명시적인 매핑이 필요할 때 사용된다.
public class UsernameOnlyDto {
private final String username;

    public UsernameOnlyDto(String username) {
    	this.username = username;
    }
    
    public String getUsername() {
    	return username;
    }
}
public interface MemberRepository extends JpaRepository<Member, Long> {
    //...
    List<UsernameOnlyDto> findProjectionsByUsername(String username);
}

 

Native Query

가급적 네이티브 쿼리는 사용하지 않는게 좋음, 정말 어쩔 수 없을 때 사용

최근에 나온 궁극의 방법은  스프링 데이터 Projections과 함께 사용하는 것

 

스프링 데이터 JPA 기반 네이티브 쿼리

  • 페이징 지원
  • 반환 타입
    -Object[]
    -Tuple
    -DTO(스프링 데이터 인터페이스 Projections 지원)
  • 제약
    -Sort 파라미터를 통한 정렬이 정상 동작하지 않을 수 있음(믿지 말고 직접 처리)
    -JPQL처럼 애플리케이션 로딩 시점에 문법 확인 불가
    -동적 쿼리 불가

 

네이티브 쿼리

public interface MemberRepository extends JpaRepository<Member, Long> {
     
     @Query(value = "select * from member where username = ?", nativeQuery = true)
     Member findByNativeQuery(String username);
     
 }

 

프로젝션 활용

스프링 데이터 JPA 네이티브 쿼리 + 인터페이스 기반 Projections 활용하여 DTO로 반환

@Query(value = "SELECT m.member_id as id, m.username, t.name as teamName " +
             "FROM member m left join team t ON m.team_id = t.team_id",
             countQuery = "SELECT count(*) from member",
             nativeQuery = true)
Page<MemberProjection> findByNativeProjection(Pageable pageable);

 

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


이 글은 JPA의 변경 감지와 병합에 대한 이해가 필요합니다.

2024.08.05 - [Java Category/JPA] - [JPA] 병합(Merge)과 변경 감지(Dirty Checking)

 

[JPA] 병합(Merge)과 변경 감지(Dirty Checking)

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.JPA(Java Persistence API)에서 변경 감지(Dirty Checking)와 병합(Merge)은 엔티티 상태 관리를 위한 중요한 개념이

rebugs.tistory.com

 


save() 메서드의 구조

org.springframework.data.jpa.repository.support.SimpleJpaRepository

  • 새로운 엔티티면 저장( persist )
  • 새로운 엔티티가 아니면 병합( merge )

 

save()가 새로운 엔티티를 판단하는 기본 전략

  1. 식별자가 객체일 때 null 로 판단
  2. 식별자가 자바 기본 타입일 때 0 으로 판단
  3. Persistable 인터페이스를 구현해서 판단 로직 변경 가능

 

식별자가 객체일 때 null로 판단

엔티티의 기본키가 자바의 기본 타입이 아니라 String 등이고, 식별자(기본키)가 null 이면 영속성 컨텍스트에서 관리하지 않는 새로운 객체라고 판단한다.

이 때, 주의를 해야하는 점이 있다.

데이터베이스에서 자동으로 식별자를 지정해주는 전략이 아니라 애플리케이션에서 자체적으로 식별자를 지정해주는 전략이라면 새로운 엔티티가 생성될 때, persist가 아니라 merge가 작동하게 된다.

따라서 영속성 컨텍스트는 데이터베이스에서 데이터를 조회하게 되고, DB에 데이터가 없다는 것을 뒤 늦게 알고 그제서야 새로운 객체로 판단하게 된다.

 

 JPA 식별자 생성 전략이 @Id 만 사용해서 직접 할당이면 이미 식별자 값이 있는 상태로 save() 를 호출한다.

따라서 이 경우 merge() 가 호출된다. merge() 는 우선 DB를 호출해서 값 을 확인하고, DB에 값이 없으면 새로운 엔티티로 인지하므로 매우 비효율적이다.


따라서 Persistable 를 사용해서 새로운 엔티티 확인 여부를 직접 구현하게는 효과적이다.

 

식별자가 자바 기본 타입일 때 0으로 판단

int 나 long 등 자바의 기본 타입이라면 초기화를 하지 않았다면 기본적으로 0으로 세팅되어있다.

따라서 엔티티의 식별자가 0이면 영속성 컨텍스트에서 관리하지 않는 새로운 객체라고 판단한다.

@Entity
public class Item {

    @Id @GeneratedValue
    private Long id;
}

 

식별자 생성 전략이 @GenerateValue 면 save() 호출 시점에 식별자가 없으므로 새로운 엔티티 로 인식해서 정상 동작

 

 

Persistable 인터페이스를 구현

등록시간( @CreatedDate )을 조합해서 사용하면 이 필드로 새로운 엔티티 여부를 편리하게 확인할 수 있다.

(@CreatedDate에 값이 없으면 새로운 엔티티로 판단)

 

Persistable 인터페이스

package org.springframework.data.domain;
public interface Persistable<ID> {
    ID getId();
    boolean isNew();
}

 

Persistable 구현

@Entity
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item implements Persistable<String> {

    @Id
    private String id;

    @CreatedDate
    private LocalDateTime createdDate;

    public Item(String id) {
        this.id = id;
    }

    @Override
    public String getId() {
        return id;
    }

    @Override
    public boolean isNew() {
        return createdDate == null; //생성 시간이 null이면 새로운 객체로 판단하도록 유도
    }
}

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


사용자 정의 리포지토리

사용자 정의 인터페이스

public interface MemberRepositoryCustom {
    List<Member> findMemberCustom();
}

 

사용자 정의 인터페이스 구현 클래스

@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
    private final EntityManager em;
    @Override
    public List<Member> findMemberCustom() {
        return em.createQuery("select m from Member m", Member.class)
                .getResultList();
    }
}
  • 규칙: 사용자 정의 인터페이스 이름(리포지토리 인터페이스 이름도 가능) + Impl
  • 스프링 데이터 JPA가 인식해서 스프링 빈으로 등록

 

사용자 정의 인터페이스 상속

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

 

사용자 정의 메서드 호출 코드

List<Member> result = memberRepository.findMemberCustom();

 

실무에서는 주로 QueryDSL이나 SpringJdbcTemplate을 함께 사용할 때 사용자 정의 리포지토리 기능 자주 사용

 

항상 사용자 정의 리포지토리가 필요한 것은 아니다.
그냥 임의의 리포지토리를 만들어도 된다. 예를들어 MemberQueryRepository를 인터페이스가 아닌 클래스로 만들고 스프링 빈으로 등록해서 그냥 직접 사용해도 된다.
물론 이 경우 스프링 데이터 JPA와는 아무런 관계 없이 별도로 동작한다.

 

 

Auditing

엔티티를 생성 및 변경할 때 변경한 사람과 시간을 추적하고 싶을 때 쓰는 기능

(등록일, 수정일, 등록자, 수정자 등)

@EnableJpaAuditing //Auditing
@SpringBootApplication
public class DataJpaApplication {

	public static void main(String[] args) {
		SpringApplication.run(DataJpaApplication.class, args);
	}

	//Auditing
	@Bean
	public AuditorAware<String> auditorProvider() {
		return () -> Optional.of(UUID.randomUUID().toString()); //UUID
		//실무에선 세션에서 가져오거나, 스프링 시큐리티에서 가져와야 한다.
	}

}
  • @EnableJpaAuditing
  • auditorProvider() 메서드 등록(등록자와 수정자를 처리해줌)
@EnableJpaAuditing

이 어노테이션은 JPA 엔티티에 대해 자동으로 @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy 같은 어노테이션을 사용해 엔티티의 생성 및 수정 시간을 자동으로 관리할 수 있게 한다.

이를 사용하려면, JpaAuditing 기능을 활성화하기 위해 스프링 부트 애플리케이션 클래스나 설정 클래스에 @EnableJpaAuditing을 추가해야 한다.

 

 

등록일, 수정일만 설정

package study.datajpa.entity;
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity {
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;
    
    @LastModifiedDate
    private LocalDateTime lastModifiedDate;
}

 

등록일, 수정일, 등록자, 수정자 설정

@Getter
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;

    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy
    private String lastModifiedBy;
}

 

 

선택 적용

실무에서 대부분의 엔티티는 등록시간, 수정시간이 필요하지만, 등록자, 수정자는 없을 수도 있다.

그래서 다음과 같이 Base 타입을 분리하고, 원하는 타입을 선택해서 상속하면 된다.

public class BaseTimeEntity {
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;
}

public class BaseEntity extends BaseTimeEntity { //상속
    @CreatedBy
    @Column(updatable = false)
    private String createdBy;
    
    @LastModifiedBy
    private String lastModifiedBy;
}

 

저장시점에 등록일, 등록자는 물론이고, 수정일, 수정자도 같은 데이터가 저장된다.
데이터가 중복 저장되는 것 같지만, 이렇게 해두면 변경 컬럼만 확인해도 마지막에 업데이트한 유저를 확인할 수 있으므로 유지보수 관점 에서 편리하다.
이렇게 하지 않으면 변경 컬럼이 null 일때 등록 컬럼을 또 찾아야 한다.

 

저장시점에 저장데이터만 입력하고 싶으면 @EnableJpaAuditing(modifyOnCreate = false) 옵션을 사용하면 된다.

기본적으로 @EnableJpaAuditing은 엔티티가 처음 생성될 때(@PrePersist 시점)에 @LastModifiedDate 필드도 함께 설정되도록 되어 있다.

그러나 modifyOnCreate = false를 설정하면, 엔티티가 처음 생성될 때는 @LastModifiedDate 필드를 설정하지 않는다.
즉, 생성 시에는 수정 시간이 기록되지 않고, 이후 엔티티가 업데이트될 때만 @LastModifiedDate 필드가 변경된다.

 

 

페이징과 정렬(Web 확장)

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

@GetMapping("/members")
public Page<Member> list(Pageable pageable) {
    Page<Member> page = memberRepository.findAll(pageable);
    return page;
}
  • 이 메서드는 Page<Member> 객체를 반환한다. Page는 Spring Data JPA에서 제공하는 인터페이스로, 페이징된 결과를 포함한다.
  • Page 객체는 현재 페이지의 데이터뿐만 아니라 전체 페이지 수, 현재 페이지 번호, 총 요소 수 등 페이징에 관련된 다양한 메타데이터도 포함하고 있다.
  • Pageable은 페이징 정보(예: 페이지 번호, 페이지 크기, 정렬)를 포함하는 객체이다.
  • 클라이언트가 요청 시 page, size, sort 등의 파라미터를 전달하여 원하는 페이지의 데이터를 요청할 수 있다.
  • 예를 들어, GET /members?page=0&size=10&sort=name,asc는 첫 번째 페이지(0부터 시작)에서 이름을 기준으로 오름차순으로 정렬된 10개의 Member 객체를 반환하게 한다.

 

요청 파라미터

예) /members?page=0&size=3&sort=id,desc&sort=username,desc

  • page: 현재 페이지, (0부터 시작한다.)
  • size: 한 페이지에 노출할 데이터 건수
  • sort: 정렬 조건을 정의한다.
    예) 정렬 속성,정렬 속성...(ASC | DESC), 정렬 방향을 변경하고 싶으면 sort 파라 미터 추가 ( asc 생략 가능)

 

기본 값

-글로벌 설정

application.properties

spring.data.web.pageable.default-page-size=20 /# 기본 페이지 사이즈/
spring.data.web.pageable.max-page-size=2000 /# 최대 페이지 사이즈/

 

-개별설정

@PageableDefault 어노테이션을 사용

@RequestMapping(value = "/members_page", method = RequestMethod.GET)
public String list(
    @PageableDefault(size = 12, sort = "username", direction = Sort.Direction.DESC)
    Pageable pageable) {
    //...
}

 

접두사

페이징 정보가 둘 이상이면 접두사로 구분

@Qualifier 에 접두사명 추가 "{접두사명}_xxx"

예제: /members?member_page=0&order_page=1

public String list(
    @Qualifier("member") Pageable memberPageable,
    @Qualifier("order") Pageable orderPageable, ...

 

 

DTO로 변환

엔티티를 API로 노출하면 다양한 문제가 발생한다. 그래서 엔티티를 꼭 DTO로 변환해서 반환해야 한다.

Page는 map() 을 지원해서 내부 데이터를 다른 것으로 변경할 수 있다.

@Data
public class MemberDto {
    private Long id;
    private String username;
    
    public MemberDto(Member m) {
        this.id = m.getId();
        this.username = m.getUsername();
	}
}

 

@GetMapping("/members")
//public Page<MemberDto> list(Pageable pageable) {
//     Page<Member> page = memberRepository.findAll(pageable);
//     Page<MemberDto> pageDto = page.map(MemberDto::new);
//     return pageDto;
//}

public Page<MemberDto> list(Pageable pageable) {
     return memberRepository.findAll(pageable).map(MemberDto::new);
 }

 

테스트

localhost:8080/members?page=0&size=10