Querydsl 설정과 검증
build.gradle
//test 롬복 사용
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
//Querydsl 추가
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
Querydsl 환경설정 검증
@Entity
@Getter @Setter
public class Hello {
@Id
@GeneratedValue
private Long id;
}
검증용 Q 타입 생성
Gradle IntelliJ 사용법
Gradle -> Tasks -> build -> clean
Gradle -> Tasks -> other -> compileQuerydsl -> 최신버전에서는 사라졌다.
@SpringBootTest
@Transactional
@RequiredArgsConstructor
class QuerydslApplicationTests {
private final EntityManager em;
@Test
void contextLoads() {
Hello hello = new Hello();
em.persist(hello);
JPAQueryFactory query = new JPAQueryFactory(em);
QHello qHello = new QHello("h");
Hello result = query
.selectFrom(qHello)
.fetchOne();
Assertions.assertThat(result).isEqualTo(hello);
}
}
해당 테스트 실행시 예외가 발생하는 이유
GTP ->
@SpringBootTest 애노테이션은 통합 테스트를 위해 전체 스프링 애플리케이션 컨텍스트를 로드합니다.
이렇게 로드된 컨텍스트 내에서 필요한 빈들을 주입받아 테스트를 수행하게 됩니다.
테스트 클래스 자체는 스프링 컨텍스트에 직접 빈으로 등록되지 않지만, 테스트 클래스 내에서 필요한 빈들을 주입받아
사용할 수 있습니다. 이때 주입 방식에 따라 트랜잭션 관리 등의 기능이 제대로 동작할 수 있습니다.
즉, 테스트 클래스는 스프링빈으로 등록되지않는다. 그래서 생성자 주입 X
하지만 @SpringBootTest로 인해 필요한 스프링빈을 만들어놓기 때문에 @Autowired를 이용해 필드주입은 받을 수 있다.
@SpringBootTest
@Transactional
class QuerydslApplicationTests {
@Autowired
private EntityManager em;
@Test
void contextLoads() {
Hello hello = new Hello();
em.persist(hello);
JPAQueryFactory query = new JPAQueryFactory(em);
QHello qHello = new QHello("h");
Hello result = query
.selectFrom(qHello)
.fetchOne();
Assertions.assertThat(result).isEqualTo(hello);
}
}
이렇게 필드주입을 하면 성공
참고: 스프링 부트에 아무런 설정도 하지 않으면 h2 DB를 메모리 모드로 JVM안에서 실행한다
예제 도메인 모델
@Entity
@Getter @Setter
// JPA가 프록시 기술같은걸 사용할때 기본생성자가 필요하다, private로 해두면 JPA가 사용할수없으므로 Protected로 해둔다.
// [개발자가 실수로 사용하는것을 막기위해 protected로 제한 ]
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of ={"id","username","age"} ) // id, username, age 필드만 포함하여 toString() 메서드를 생성하라는 것, team이 포함되면 무한참조순환이 되므로 뺴준것
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 = username;
}
public Member(String username, int age, Team team) {
this.username = username;
this.age = age;
if (team != null) {
changeTeam(team);
}
}
public Member(String s, int i) {
super();
}
// 연관관계 편의 메소드 [ 양방향 연관관계일때 반대쪽 객체에도 변경사항을 최신화해줘야한다.]
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;
}
}
JPQL vs Querydsl
@SpringBootTest
@Transactional
public class QuerydslBasicTest {
@Autowired
EntityManager em;
JPAQueryFactory queryFactory;
@BeforeEach
public void before() {
queryFactory = new JPAQueryFactory(em); // queryDsl를 사용하려면 JPAQueryFactory가 있어야하고 JPAQueryFactory는 엔티티매니저를 전달해줘야한다.
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);
}
@Test
public void startJPQL() {
//member1을 찾아라.
String qlString = "select m from Member m" +
" where m.username = :username";
Member findMember = em.createQuery(qlString, Member.class)
.setParameter("username", "member1")
.getSingleResult();
Assertions.assertThat(findMember.getUsername()).isEqualTo("member1");
}
@Test
public void startQuerydsl() {
QMember m = new QMember("m"); // 어떤 Qmember인지 구분하는 구분자를 전달해줘야한다. [별칭]
Member findMember = queryFactory
.select(m)
.from(m)
.where(m.username.eq("member1")) // queryDSL은 다음과같이 파라미터를 전달해도 JDBC의 prepareStatement로 바인딩을 자동으로 한다.
.fetchOne();
Assertions.assertThat(findMember.getUsername()).isEqualTo("member1");
}
}
- EntityManager 로 JPAQueryFactory 생성
- Querydsl은 JPQL 빌더
- JPQL: 문자(실행 시점 오류 == 런타임 오류), Querydsl: 코드(컴파일 시점 오류)
- JPQL: 파라미터 바인딩 직접, Querydsl: 파라미터 바인딩 자동 처리
EntityManager 동시성문제? -> GPT
스프링은 EntityManagerFactory를 싱글톤 빈으로 관리합니다.
EntityManagerFactory는 EntityManager 인스턴스를 생성하는 책임을 가집니다.
각 트랜잭션마다 새로운 EntityManager 인스턴스가 생성됩니다.
스프링은 EntityManager를 트랜잭션 범위 내에서 관리합니다.
각 트랜잭션마다 독립적인 EntityManager 인스턴스를 사용하게 되어 동시성 문제가 발생하지 않습니다.
@SpringBootTest와 @Transactional 애노테이션이 적용된 클래스에서
각 테스트 메소드는 별도의 트랜잭션에서 실행됩니다.
따라서 각 테스트 메소드가 실행될 때마다 새로운 EntityManager 인스턴스가 생성됩니다.
이로 인해, 테스트 메소드 간에는 EntityManager 인스턴스가 공유되지 않고,
각 메소드에서 새로운 EntityManager 인스턴스를 사용합니다.
각 테스트 메소드가 실행될 때마다 새로운 EntityManager 인스턴스가 생성되는 것은
Spring의 트랜잭션 관리와 엔티티 매니저 팩토리(EntityManagerFactory)의 협업에 의해 자동으로 이루어집니다.
JPAQueryFactory를 필드로 제공하면 동시성 문제는 어떻게 될까?
동시성 문제는 JPAQueryFactory를 생성 할 때 제공하는 EntityManager(em)에 달려있다.
스프링 프레임워크는 여러 쓰레드에서 동시에 같은 EntityManager에 접근해도,
트랜잭션 마다 별도의 영속성 컨텍스트를 제공하기 때문에, 동시성 문제는 걱정하지 않아도 된다.
즉 스프링은 트랜잭션마다 별도의 EntityManager 인스턴스를 생성하기 때문에 별도의 EntityManager를 이용하여 생성한 JPAQueryFactory는 서로 다른 객체이므로 동시성 문제가없다.
기본 Q-Type 활용
Q클래스 인스턴스를 사용하는 2가지 방법
QMember qMember = new QMember("m"); //별칭 직접 지정
QMember qMember = QMember.member; //기본 인스턴스 사용
방법1
@Test
public void startQuerydsl() {
QMember m = QMember.member; // 기본 인스턴스 사용
Member findMember = queryFactory
.select(m)
.from(m)
.where(m.username.eq("member1")) // queryDSL은 다음과같이 파라미터를 전달해도 JDBC의 prepareStatement로 바인딩을 자동으로 한다.
.fetchOne();
Assertions.assertThat(findMember.getUsername()).isEqualTo("member1");
}
기본 인스턴스를 static import와 함께 사용
import static study.querydsl.entity.QMember.*;
@Test
public void startQuerydsl() {
Member findMember = queryFactory
.select(member)
.from(member)
.where(member.username.eq("member1")) // queryDSL은 다음과같이 파라미터를 전달해도 JDBC의 prepareStatement로 바인딩을 자동으로 한다.
.fetchOne();
Assertions.assertThat(findMember.getUsername()).isEqualTo("member1");
}
application.*
다음 설정을 추가하면 실행되는 JPQL을 볼 수 있다.
spring.jpa.properties.hibernate.use_sql_comments: true
Q클래스 존재이유 - GTP
QClass는 QueryDSL을 사용하여 타입 안전하고 동적 SQL 쿼리를 작성하기 위해 생성되는 클래스입니다. QueryDSL은 메타데이터를 기반으로 이러한 QClass를 생성하며, 이를 통해 개발자는 Java 코드로 SQL 쿼리를 작성할 수 있습니다. 이러한 QClass의 주요 역할과 이점은 다음과 같습니다:
- 타입 안전한 쿼리 작성:
- QClass는 데이터베이스 테이블의 각 컬럼을 필드로 가지는 Java 클래스를 생성합니다. 이를 통해 컴파일 시점에 쿼리의 정확성을 검증할 수 있습니다. 잘못된 필드명을 사용할 경우 컴파일 오류가 발생하므로, 런타임 에러를 줄일 수 있습니다.
- 동적 쿼리 작성:
- QClass를 사용하면 복잡하고 동적인 쿼리를 간편하게 작성할 수 있습니다. 조건부 쿼리, 조인, 서브쿼리 등 다양한 쿼리를 유연하게 생성할 수 있습니다.
- 코드 자동 생성:
- QueryDSL의 코드 생성기(Annotation Processor 또는 Maven/Gradle 플러그인)를 통해 자동으로 생성됩니다. 이로 인해 개발자는 수동으로 코드를 작성할 필요 없이, 엔티티 클래스의 변경 사항이 자동으로 반영된 QClass를 사용할 수 있습니다.
검색 조건 쿼리
@Test
public void search() {
// queryFactory
// .select(member)
// .from(member) selectFrom으로 요약가능
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%’ 검색
...
between도있고 JPQL에서 지원하는건 다 있다.
AND 조건을 파라미터로 처리
and를 사용하지않고 ,(쉼표)를 이용하여 조건을 추가할 수 있다.
@Test
public void searchAndParam() {
Member findMember = queryFactory
.selectFrom(member)
.where(
member.username.eq("member1"),
member.age.eq(10)
)
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
}
where() 에 파라미터로 검색조건을 추가하면 AND 조건이 추가됨
이 경우 null 값은 무시한다.
메서드 추출을 활용해서 동적 쿼리를 깔끔하게 만들 수 있다. 자세한건 뒤에서 설명
둘다 사용해도 되지만 두번째 방법을 선호한다함
결과 조회
fetch() : 리스트 조회, 데이터 없으면 빈 리스트 반환
fetchOne() : 단 건 조회
- 결과가 없으면 : null
- 결과가 둘 이상이면 : com.querydsl.core.NonUniqueResultException
fetchFirst() : limit(1).fetchOne()
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() 을 사용합니다.
정렬
/**
* 회원 정렬 순서
* 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 paging1() {
List<Member> result = queryFactory
.selectFrom(member)
.orderBy(member.username.desc())
.offset(1) // 몇번째부터 보여줄건지 설정, 값은 0부터 가능
.limit(2) // 최대 몇개 보여줄건지
.fetch();
assertThat(result).hasSize(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 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);
}
/*
팀의 이름과 각 팀의 평균 연령을 구하라.
*/
@Test
public void group() {
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);
}
JPQL이 제공하는 모든 집합 함수를 제공한다.
tuple은 프로젝션과 결과반환에서 설명한다
그룹화된 결과를 제한하려면 having
조인 - 기본 조인
조인의 기본 문법은 첫 번째 파라미터에 조인 대상을 지정하고,
두 번째 파라미터에 별칭(alias)으로 사용할 Q 타입을 지정하면 된다
join(조인 대상, 별칭으로 사용할 Q타입)
/*
팀 A에 소속된 모든 회원
*/
@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"); // 해당 순서와 정확히 동일하게 일치하는지 검증
}
join() , innerJoin() : 내부 조인(inner join)
leftJoin() : left 외부 조인(left outer join)
rightJoin() : rigth 외부 조인(rigth outer join)
JPQL의 on 과 성능 최적화를 위한 fetch 조인 제공 다음 on 절에서 설명
세타 조인
연관관계가 없는 필드로 조인
/*
세타조인
회원의 이름이 팀 이름과 같은 회원 조회
*/
@Test
public void theta_join() {
em.persist(new Member("teamA"));
em.persist(new Member("teamB"));
em.persist(new Member("teamC"));
List<Member> result = queryFactory
.select(member)
.from(member, team) // 카테시안 곱
.where(member.username.eq(team.name))
.fetch();
assertThat(result)
.extracting("username")
.containsExactly("teamA","teamB");
}
- from 절에 여러 엔티티를 선택해서 세타 조인
- 외부 조인 불가능[outer join] -> 다음에 설명할 조인 on을 사용하면 외부 조인 가능
조인 - on절
ON절을 활용한 조인(JPA 2.1부터 지원)
1. 조인 대상 필터링
2. 연관관계 없는 엔티티 외부 조인
1. 조인 대상 필터링
예) 회원과 팀을 조인하면서, 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회
/*
JPQL : select m,t from Member m left join m.team on t.name='teamA'
*/
@Test
public void join_on_filtering() {
List<Tuple> result = queryFactory
.select(member, team) // 조회되는 컬럼이 2개 이상이면 tuple 로 반환
.from(member)
.leftJoin(member.team, team).on(team.name.eq("teamA"))
.fetch();
for (Tuple tuple : result) {
System.out.println("tuple = " + tuple);
}
}
left outer join
참고
on 절을 활용해 조인 대상을 필터링 할 때, 외부조인이 아니라 내부조인(inner join)을 사용하면,
where 절에서 필터링 하는 것과 기능이 동일하다.
따라서 on 절을 활용한 조인 대상 필터링을 사용할 때, 내부조인이면 익숙한 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() {
em.persist(new Member("teamA"));
em.persist(new Member("teamB"));
em.persist(new Member("teamC"));
List<Tuple> result = queryFactory
.select(member, team)
.from(member)
.leftJoin(team).on(member.username.eq(team.name))
.fetch();
for (Tuple tuple : result) {
System.out.println("tuple = " + tuple);
}
}
하이버네이트 5.1부터 on 을 사용해서 서로 관계가 없는 필드로 외부 조인하는 기능이 추가되었다.
물론 내부 조인도 가능하다.
주의! 문법을 잘 봐야 한다. leftJoin() 부분에 일반 조인과 다르게 엔티티 하나만 들어간다.
일반조인: leftJoin(member.team, team)
on조인: from(member).leftJoin(team).on(xxx)
조인 - 페치 조인
페치 조인은 SQL에서 제공하는 기능은 아니다.
SQL조인을 활용해서 연관된 엔티티를 SQL 한번에 조회하는 기능이다.
주로 성능 최적화에 사용하는 방법이다.
페치 조인 미적용
지연로딩으로 Member, Team SQL 쿼리 각각 실행
@PersistenceUnit
EntityManagerFactory emf;
@Test
public void fetchJoinNo() {
em.flush();
em.clear();
// fetch join을 하기전에 영속성컨텍스트에서 관리되는 엔티티를 없앤다.
Member findMember = queryFactory
.selectFrom(member)
.where(member.username.eq("member1"))
.fetchOne();
boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());// 해당 엔티티가 로딩이 된 엔티티인지 아닌지 확인할 수있다.
assertThat(loaded).as("페치 조인 미적용").isFalse();
}
페치 조인 적용
즉시로딩으로 Member, Team SQL 쿼리 조인으로 한번에 조회
@Test
public void fetchJoinUse() {
em.flush();
em.clear();
// fetch join을 하기전에 영속성컨텍스트에서 관리되는 엔티티를 없앤다.
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 사용
서브 쿼리 eq 사용
/*
나이가 가장 많은 회원 조회
*/
@Test
public void subQuery() {
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(
JPAExpressions.select(memberSub.age.max())
.from(memberSub)
))
.fetch();
assertThat(result).extracting("age").containsExactly(40);
}
서브 쿼리 goe 사용
/*
나이가 평균 이상인 회원 조회
*/
@Test
public void subQueryGoe() {
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.goe(
JPAExpressions.select(memberSub.age.avg())
.from(memberSub)
))
.fetch();
assertThat(result).extracting("age").containsExactly(30,40);
}
서브쿼리 여러 건 처리 in 사용
@Test
public void subQueryIn() {
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.in(
JPAExpressions.select(memberSub.age)
.from(memberSub)
.where(memberSub.age.gt(10))
))
.fetch();
assertThat(result).extracting("age").containsExactly(30,40);
}
select 절에 subquery
@Test
public void selectSubQuery() {
QMember memberSub = new QMember("memberSub");
List<Tuple> result = queryFactory
.select(member.username,
JPAExpressions
.select(memberSub.age.avg())
.from(memberSub))
.from(member)
.fetch();
for (Tuple tuple : result) {
System.out.println("tuple = " + tuple);
}
}
static import 활용
import static com.querydsl.jpa.JPAExpressions.*;
@Test
public void selectSubQuery() {
QMember memberSub = new QMember("memberSub");
List<Tuple> result = queryFactory
.select(member.username,
select(memberSub.age.avg())
.from(memberSub))
.from(member)
.fetch();
for (Tuple tuple : result) {
System.out.println("tuple = " + tuple);
}
}
from 절의 서브쿼리 한계
JPA JPQL 서브쿼리의 한계점으로 from 절의 서브쿼리(인라인 뷰)는 지원하지 않는다.
당연히 Querydsl도 지원하지 않는다.
하이버네이트 구현체를 사용하면 select 절의 서브쿼리는 지원한다.
Querydsl도 하이버네이트 구현체를 사용하면 select 절의 서브쿼리를 지원한다.
from 절의 서브쿼리 해결방안
1. 서브쿼리를 join으로 변경한다. (가능한 상황도 있고, 불가능한 상황도 있다.)
2. 애플리케이션에서 쿼리를 2번 분리해서 실행한다.
3. nativeSQL을 사용한다.
댓글