본문 바로가기
Spring/JPA

JPA 8 - Querydsl

by 구너드 2023. 7. 12.

Querydsl

 

    @Test // 기존 JPQL
    public void startJPQL() {
        //member 1 find

        String qlString = "select m from Member m " +
                          "where m.username = :username";
        Member findMember = em.createQuery(qlString, Member.class)
                .setParameter("username", "member1")
                .getSingleResult();

        assertThat(findMember.getUsername()).isEqualTo("member1");
    }

    @Test // Querydsl
    public void startQuerydsl() {
//        QMember m = new QMember("m");
//        QMember m = QMember.member;

        Member findMember = queryFactory
                .select(member)
                .from(member)
                .where(member.username.eq("member1"))
                .fetchOne();

        assertThat(findMember.getUsername()).isEqualTo("member1");

JPQL 빌더. 기존 JPQL은 파라미터 바인딩을 직접 하고 런타임 시점에서 오류를 확인할 수 있었지만, Querydsl은 파라미터 바인딩이 자동으로 처리되고 컴파일 시점에 오류를 확인할 수 있다.


JPAQueryFactory

@SpringBootTest
@Transactional
public class QuerydslBasicTest {

    @Autowired
    EntityManager em;

    JPAQueryFactory queryFactory;

    @BeforeEach
    public void before() {
        queryFactory = new JPAQueryFactory(em);
    }

직접 필드로 JPAQueryFactory를 사용하는 위의 코드

 

@SpringBootApplication
public class QuerydslApplication {

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

    @Bean
    JPAQueryFactory jpaQueryFactory(EntityManager em) {
        return new JPAQueryFactory(em);
    }
}
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom{

    private final JPAQueryFactory queryFactory;
    
}

기존 설정에서 해당 JPAQueryFactory를 애플리케이션 로딩 시점에 빈으로 등록하여 사용할 수도 있다.


Q-Type

Querydsl은 엔티티를 Q엔티티명이라는 클래스로 추가적으로 만들어서 해당 Q-type 인스턴스를 이용한다. 

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

같은 테이블을 조인하거나 서브쿼리로 쓰게 된다면 alias를 다르게 사용해야하므로 별칭 직접 지정 방법을 사용해야하고 그게 아니라면 기본 인스턴스 방법을 사용하여 static import해주는 것이 더 편리하다.


결과 조회

 

1. fetch() - 리스트 조회 

반환 타입 List<Entity or Dto>

 

2. fetchOne() - 단 건 조회 ( 결과가 없으면 null, 결과가 둘 이상이면 NonUniqueResultException)

반환타입 Entity or Dto

 

3. fetchFirst() - limit(1).fetchOne()

반환타입 Entity or Dto

 

Deprecated

4. fetchResults() - 페이징 정보 포함, total count 쿼리 추가 실행

반환타입 QueryResult<Entity or Dto>

 

5. fetchCount() - count 쿼리 

반환타입 Long,long


정렬

    /**
     * 1.회원 나이 내림차순
     * 2.회원 이름 올림차순
     * 3.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();

        assertThat(result.size()).isEqualTo(3);
        assertThat(result.get(1).getUsername()).isEqualTo("member6");
    }

nullsLast(), nullsFirst() - null 데이터에 순서 부여


페이징

조회건수 제한 시,

    @Test
    public void paging() {

        List<Member> result = queryFactory
                .selectFrom(member)
                .orderBy(member.username.desc())
                .offset(1)
                .limit(2)
                .fetch();

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

 

전체 조회건수 필요 시,

    @Test
    public void aggregation() {

        List<Tuple> result = queryFactory
                .select(
                        member.count(),
                        member.age.sum(),
                        member.age.avg(),
                        member.age.max(),
                        member.age.min()
                )
                .from(member)
                .fetch();

        Tuple tuple = result.get(0);
        assertThat(tuple.get(member.count())).isEqualTo(4);
        assertThat(tuple.get(member.age.avg())).isEqualTo(25);

    }

다만 이 때 count 쿼리는 필요없는 left join을 한 채로 실행될 수 있기 때문에 해당 count 쿼리는 별도로 작성하는 것이 성능적인 이점을 보여줄 수 있다.


기본 join

    @Test
    public void join() {
        List<Member> result = queryFactory
                .selectFrom(member)
                .join(member.team, team)
                .where(team.name.eq("teamA"))
                .fetch();

        assertThat(result)
                .extracting("username")
                .containsExactly("member1", "member2");
    }

첫 번째 파라미터에 조인 대상을 지정, 두 번째 파라미터에 별칭으로 사용할 Q 타입 지정

위의 경우 QTeam.team을 static import하여 team이라는 별칭으로 QTeam 타입을 지정하여 조인을 사용하였다.

 

join - on절

    @Test
    public void join_on_filtering() {
        List<Tuple> result = queryFactory
                .select(member, team)
                .from(member)
                .leftJoin(member.team, team)
//                .on(team.name.eq("teamA"))
                .where(team.name.eq("teamA"))
                .fetch();
    }

조인 대상 필터링 - 해당 조건에 해당하는 데이터를 모두 가지고 오고 싶다면 left join을 사용하면 되지만, 단순히 inner join을 이용하여 조인 대상을 필터링하고 싶다면 보다 익숙한 where를 사용하는 게 더 권장된다.

 

    @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);
        }
    }
    
    
    ///결과///
    
        t=[Member(id=3, username=member1, age=10), null]
	t=[Member(id=4, username=member2, age=20), null]
	t=[Member(id=5, username=member3, age=30), null]
	t=[Member(id=6, username=member4, age=40), null]
	t=[Member(id=7, username=teamA, age=0), Team(id=1, name=teamA)]
	t=[Member(id=8, username=teamB, age=0), Team(id=2, name=teamB)]

연관관계 없는 엔티티 외부 조인 - 기본 조인과는 다르게 left join() 부분에 엔티티 하나만 들어간다.

 

기본 조인 - .leftJoin(member.team, team)

on 조인 - .leftJoin(team).on(member.username.eq(team.name))

 

Fetch join

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

join(), left join() 기능 뒤에 fetchJoin()을 추가


서브쿼리

 

JPAExpressions 사용

    /**
     *서브쿼리 eq 사용
     */
    @Test
    public void subQuery() {

        QMember memberSub = new QMember("memberSub");

        List<Member> result = queryFactory
                .selectFrom(member)
                .where(member.age.eq(
                        select(memberSub.age.max())
                                .from(memberSub)
                ))
                .fetch();
    }
    
    /**
     *서브쿼리 goe 사용
     */
    @Test
    public void subQueryGoe() {

        QMember memberSub = new QMember("memberSub");

        List<Member> result = queryFactory
                .selectFrom(member)
                .where(member.age.goe(
                        select(memberSub.age.avg())
                                .from(memberSub)
                ))
                .fetch();
    }
    

    /**
     *select절 서브쿼리, where절 서브쿼리 in 사용
     */
    @Test
    public void subQueryInSelect() {

        QMember memberSub = new QMember("memberSub");

        List<Tuple> result = queryFactory
                .select(member.username,
                        select(memberSub.age.avg())
                                .from(memberSub))
                .from(member)
                .where(member.age.in(
                        select(memberSub.age)
                                .from(memberSub)
                                .where(memberSub.age.gt(10))
                ))
                .fetch();
    }

from 절의 서브쿼리는 지원하지 않음. 이를 위한 해결방안으로는 서브쿼리를 join으로 변경 가능한 상황이면 변경하거나, 애플리케이션 차원에서 쿼리를 두 번 분리하여 실행하거나, nativeSQL을 사용하는 법들이 있다.


엔티티가 아닌 프로젝션 결과 반환(Tuple, Dto)

 

    @Test
    public void simpleProjection() {
        List<String> result = queryFactory
                .select(member.username)
                .from(member)
                .fetch();

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

프로젝션 대상이 하나라면 단순히 타입을 지정해서 반환할 수 있음. 그러나 프로젝션에 여러 타입의 데이터가 들어가게 된다면 타입지정은 불가. 이때 사용할 수 있는 것이 Tuple과 Dto

 

Tuple -  프로그래밍에 사용되는 데이터 구조로 여러 개의 요소를 담을 수 있는 순서가 있는 집합. 변경할 수 없는 시퀀스 형태의 객체로 한 번 생성되면 그 값을 수정하거나 삭제할 수 없음. 괄호() 를 사용하며 각 요소는 쉼표로 구분됨

 

    // Tuple은 쿼리Dsl 패키지의 하부기술이기 때문에 Repository에서 사용은 괜찮지만 바깥 계층에서 해당 Tuple을 사용하는 것은 지양
    @Test
    public void tupleProjection() {
        List<Tuple> result = queryFactory
                .select(member.username, member.age)
                .from(member)
                .fetch();

        for (Tuple tuple : result) {
//            System.out.println("tuple = " + tuple);
            String username = tuple.get(member.username);
            Integer age = tuple.get(member.age);

            System.out.println("username = " + username);
            System.out.println("age = " + age);
        }
    }
    
    // Tuple 사용
----------------------------------------------------------------------------------

	// 결과를 DTO로 반환, 프로퍼티 접근번, 필드 직접 접근, 생성자 사용의 방식 지원

    @Test // 프로퍼티 접근 - setter 사용(Projections.bean(Dto.class, 별칭.필드명...)
    public void findDtoBySetter(){

        List<MemberDto> result = queryFactory
                .select(Projections.bean(MemberDto.class,
                        member.username,
                        member.age))
                .from(member)
                .fetch();

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

    }

    @Test // 필드 직접 접근 - (Projections.fields(Dtol.class, 별칭.필드명...)
    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);
        }
    }

    @Test // 생성자 사용 - (Projections.constructor(Dto.class, 별칭.필드명...)
    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);
        }
    }


    @Test // Dto의 필드명과 Entity의 필드명이 다를 때는 as 사용, 만약 생성자 방식으로 사용한다면 as 사용 불필요
    public void findUserDto(){

        QMember memberSub = new QMember("memberSub");

        List<UserDto> result = 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 : result) {
            System.out.println("userDto = " + userDto);
        }
    }

	// QueryProjection은 DTO 클래소 Q-Type으로 생성 후 사용
    
    List<MemberDto> result = queryFactory
 				.select(new QMemberDto(member.username, member.age))
			 	.from(member)
 				.fetch();
                
    // 매우 편리하고 Dto가 Querydsl에 의존하면서 컴파일 오류를 잡아내는 이점을 가져갈지,
    // Querydsl에 의존하지 않으면서 여러 레이어를 돌아다닐 수 있는 Dto를 순수하게 유지하여 의존성에 대한 이점을 가져갈지 고민

동적 쿼리 Builder

    @Test
    public void dynamicQuery_BooleanBuilder() throws Exception {
        String usernameParam = "member1";
        Integer ageParam = null;

        List<Member> result = searchMember1(usernameParam, ageParam);
        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
                .select(member)
                .from(member)
                .where(builder)
                .fetch();
    }

BooleanBuilder의 사용은 해당 쿼리가 한 눈에 들어오지 않는다는 단점이 존재.

 

동적 쿼리 Where 다중 파라미터

    @Test
    public void dynamicQuery_WhereParams() {
        String usernameParam = "member1";
        Integer ageParam = 10;

        List<Member> result = searchMember2(usernameParam, ageParam);
        assertThat(result.size()).isEqualTo(1);
    }

    private List<Member> searchMember2(String usernameCond, Integer ageCond) {
        return queryFactory
                .select(member)
                .from(member)
//                .where(usernameEq(usernameCond), ageEq(ageCond))
                .where(allEq(usernameCond, 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;
    }

    // 조건 간의 조립이 가능, 재사용성 높음
    private BooleanExpression allEq(String usernameCond, Integer ageCond) {
        return usernameEq(usernameCond).and(ageEq(ageCond));
    }

Where 다중 파라미터는 해당 searchMemberV2에서 실행하고자 하는 쿼리를 한 눈에 알아볼 수 있고 BooleanExpressions로 반환 타입을 설정하고 해당 동적 조건들을 만들어주면 allEq 처럼 각각의 조건들을 조립하여 새로운 조건을 만들어낼 수 있음(추가적인 null 체크 필요) 


벌크 연산

    @Test
    public void bulkUpdate() {
        long count = queryFactory
                .update(member)
                .set(member.username, "청년층")
                .where(member.age.lt(29))
                .execute();

        em.flush();
        em.clear(); // 벌크 연산 후, 영속성 컨텍스트 초기화

        List<Member> members = em.createQuery("select m from Member m", Member.class)
                .getResultList();

        assertThat(members.get(0).getUsername()).isEqualTo("청년층");
    }

    @Test
    public void bulkAdd() {
        queryFactory
                .update(member)
                .set(member.age, member.age.add(1))
                .execute();
    }

    @Test
    public void bulkDelete() {
        queryFactory
                .delete(member)
                .where(member.age.gt(18))
                .execute();
    }

벌크 연산 후 영속성 컨텍스트 초기화는 필수


스프링 데이터 JPA와 Querydsl

 

스프링 데이터 JPA는 쿼리메서드를 이용하여 간단한 정적 쿼리는 쉽게 구현할 수 있음. 반면 동적 쿼리는 구현에 있어 고민이 될 수 있지만 사용자 정의 Repository를 구현하고 이를 해당 interface "Entity"Repository에 상속받게 하는 구조로 Querydsl 전용 Repository를 만들 수 있음

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

JpaRepository를 상속받는 기존 MemberRepsotiroty

 

public interface MemberRepositoryCustom {
    List<MemberTeamDto> search(MemberSearchCondition condition);
    Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable);
    Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable);
}

사용자 정의 Repository interface

 

@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom{

    private final JPAQueryFactory queryFactory;

    @Override  // @QueryProjections 를 이용한 Dto 반환, Where 다중 파라미터를 이용하여 동적쿼리 생성 해당 ~Eq는 밑에 위치
    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();
    }

    @Override
    public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
    }

    @Override
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
    }

    private BooleanExpression usernameEq(String username) {
        return hasText(username) ? member.username.eq(username) : null;
    }
    private BooleanExpression teamNameEq(String teamName) {
        return hasText(teamName) ? team.name.eq(teamName) : null;
    }

    private BooleanExpression ageGoe(Integer ageGoe) {
        return ageGoe != null ? member.age.goe(ageGoe) : null;
    }

    private BooleanExpression ageLoe(Integer ageLoe) {
        return ageLoe != null ? member.age.loe(ageLoe) : null;
    }

}

해당 사용자 정의 Repository interface를 구현하는 구현 Repository

 

searchPageSimple (Content와 Count를 같이 조회)

    @Override
    public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
        QueryResults<MemberTeamDto> results = queryFactory
                .select(new QMemberTeamDto(
                        member.id.as("memberId"),
                        member.username,
                        member.age,
                        team.id.as("teamId"),
                        team.name.as("teamName")
                ))
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe()))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetchResults();

        List<MemberTeamDto> content = results.getResults();
        long total = results.getTotal();

        return new PageImpl<>(content, pageable, total);


    }

.fetchResults()를 이용하면 content와 totalcount를 한 번에 조회할 수 있다.( 검색 쿼리 한 번, 카운트 쿼리 한 번으로 총 쿼리 횟수는 2회). 이 때 fetchResults는 카운트 쿼리 실행 시에 필요없는 부분들(left join, order by 등)은 제외하고 실행된다

 /* select
        count(member1) 
    from
        Member member1   
    left join
        member1.team as team    // 원래 쿼리
        
        
        
 */ select
       count(m1_0.member_id) 
    from
         member m1_0     // 실제 쿼리. 성능저하를 유발할 수 있기 때문에 필요없는 left join 제거

 

searchPageComplex (Content와 Count를 별개로 조회)

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

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

    }

전체 카운트 조회에서 최적화가 필요할 경우(limit, offset 제거, left join의 경우에는 where 다중 파라미터에 teamName을 가져오기 때문에 필요) 하지만 해당 contQuery도 더 최적화가 가능하다. 이를테면

 

1.페이지의 시작이면서 컨텐츠가 페이지 사이즈보다 작아 추가적인 페이지가 필요하지 않을 경우

2.마지막 페이지이면서 해당 페이지의 컨텐츠 사이즈가 페이지 사이즈보다 작을 경우

 

        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 PageableExecutionUtils.getPage(content, pageable, countQuery::fetchCount);

위의 두 경우는 countQuery가 발생하지 않게 메서드 참조 방식으로 함수를 작성해서 countQuery를 생략할 수 있다.

해당 기능을 제공하는 PageableExecutionUtils를 살펴보면,

public abstract class PageableExecutionUtils {

	private PageableExecutionUtils() {}
    
	public static <T> Page<T> getPage(List<T> content, Pageable pageable, LongSupplier totalSupplier) {

		Assert.notNull(content, "Content must not be null");
		Assert.notNull(pageable, "Pageable must not be null");
		Assert.notNull(totalSupplier, "TotalSupplier must not be null");

		if (pageable.isUnpaged() || pageable.getOffset() == 0) {

			if (pageable.isUnpaged() || pageable.getPageSize() > content.size()) {
				return new PageImpl<>(content, pageable, content.size());
			}

			return new PageImpl<>(content, pageable, totalSupplier.getAsLong());
		}

		if (content.size() != 0 && pageable.getPageSize() > content.size()) {
			return new PageImpl<>(content, pageable, pageable.getOffset() + content.size());
		}

		return new PageImpl<>(content, pageable, totalSupplier.getAsLong());
	}
}

위의 두 가지 경우에 대해서 조건식을 설정한 코드들을 살펴볼 수 있다.


해당 코드들을 작성하면서 이상하다고 느낀 점은 .fetchResuls() 와 .fetchCount()가 Deprecated 표시가 뜬다는 점이었다. 해당 부분에 대해서 의문을 가지고 있었는데 마지막 부분에 그 이유가 있었다.

 

Querydsl 5.0 부터는 .fetchResuls() 와 .fetchCount()가 향후 지원되지 않는다. 지금 당장은 아니겠지만 앞으로 Querydsl에서 해당 기능들을 사용하는 것은 자제해야지 싶다.

 

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

기존 카운트 쿼리에서 Wildcard.count, member.count(), count(member.id)로 바꾸고 fetchone()을 이용해 반환 받는다.

 

또, 위의 작성된 serachPageComplex 코드의 카운트 쿼리를 새롭게 바꾸면

JPAQuery<Long> countQuery = queryFactory
 		.select(member.count())
 		.from(member)
 		.leftJoin(member.team, team)
 		.where(
 			usernameEq(condition.getUsername()),
 			teamNameEq(condition.getTeamName()),
 			ageGoe(condition.getAgeGoe()),
 			ageLoe(condition.getAgeLoe())
 		);
 	return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);

반환타입이 Member 에서 Long으로, fetchCount가 아닌 fetchOne으로 바뀌어 있는 모습을 확인할 수 있다.

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

JPA 7 - 스프링 데이터 JPA  (0) 2023.07.10
JPA 6 - 성능 최적화  (0) 2023.07.08
JPA 5 - 패치 조인  (0) 2023.07.03
JPA 4 - 값 타입, JPQL  (1) 2023.07.03
JPA 3 - 영속성 컨텍스트, 연관관계 매핑, 즉시로딩/지연로딩  (0) 2023.07.01