쿼리 메소드 기능 3가지
- 메소드 이름으로 쿼리 생성
- 메소드 이름으로 JPA NamedQuery 호출
- @Query 어노테이션을 사용해서 리포지토리 인터페이스에 쿼리 직접 정의
메소드 이름으로 쿼리 생성
- 메소드 이름을 분석해서 JPQL 쿼리를 실행한다.
이름과 나이를 기준으로 회원을 조회하려면?
1. 순수 JPA 리포지토리
📌 MemberJpaReposiotry
public List<Member> findByUsernameAndAgeGreaterThan(String username, int age) {
return em.createQuery("select m from Member m where m.username = :username and m.age > :age", Member.class)
.setParameter("username", username)
.setParameter("age", age)
.getResultList();
}
2. 순수 JPA 테스트 코드
📌 MemberJpaRepositoryTest
@Test
void findUsernameAndAgeGreaterThan() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("AAA", 20);
memberJpaRepository.save(m1);
memberJpaRepository.save(m2);
List<Member> result = memberJpaRepository.findByUsernameAndAgeGreaterThan("AAA", 15);
assertThat(result.get(0).getUsername()).isEqualTo("AAA");
assertThat(result.get(0).getAge()).isEqualTo(20);
assertThat(result.size()).isEqualTo(1);
}
3. 스프링 데이터 JPA
📌 MemberRepository
package study.datajpa.repository;
import java.util.List;
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
- 스프링 데이터 JPA는 메소드 이름을 분석해서 JPQL을 생성하고 실행한다
이처럼 이름과 나이를 기준으로 회원을 조회하기 위해서 메서드 이름을 스프링 JPA의 규칙대로 정해야하는데 이렇게 조건이 많아질 수록 메소드 이름도 많이 길어진다.
그러므로 필드 2개 조건일 때만 사용하고 나머지 경우에는 쿼리를 직접 만들거나 다른 방법을 이용할 것.
4. 쿼리 메소드 필터 조건
스프링 데이터 JPA 공식 문서 : https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods.query-creation
5. 스프링 데이터 JPA가 제공하는 쿼리 메소드 기능
- 조회 : find...By / read...By / query...By / get...By
- 공식 문서 : https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.query-methods.query-creation
- ... 에는 아무 명칭이나 들어갈 수 있다. (ex. findHelloBy)
- COUNT : count...By
- 반환 타입 long
- EXISTS : exists...By
- 반환타입 Boolean
- 삭제 : delete...By / remove...By
- 반환타입 long
- DISTINCT : findDistinct / findMemberDistinctBy
- LIMIT : findFirst3 / findFirst / findTop / findTop3
참고로 이 기능은 엔티티의 필드명이 변경되면 리포지토리의 인터페이스에 정의된 메서드 이름도 꼭 함께 변경해야한다. 그렇지 않으면 애플리케이션을 시작하는 시점에 오류가 발생한다.
이렇게 애플리케이션 로딩 시점에 오류를 인지할 수 있는 것이 스프링 데이터 JPA의 매우 큰 장점이다.
JPA NamedQuery
- 결론 : 실무에서 안쓰인다.
- JPA의 NamedQuery를 호출할 수 있다.
1. @NamedQuery 어노테이션으로 Named 쿼리 정의
📌 Member 엔티티
package study.datajpa.entity;
@Entity
@Getter @Setter
@NoArgsConstructor(access = PROTECTED)
@ToString(of = {"id", "username", "age"})
@NamedQuery( // NamedQuery 정의
name = "Member.findByUsername", // 관례상 '엔티티.메소드명' 으로 짓는다.
query = "select m from Member m where m.username = :username"
)
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String username;
private int age;
...
}
엔티티에 @NamedQuery를 정의한다.
NamedQuery를 사용할 때 @NamedQuery 어노테이션의 name속성명 호출해서 사용한다.
일반적으로 name 속성명에는 '엔티티.메소드명'으로 짓는다.
2. 순수 JPA 리포지토리에서 NamedQuery 사용하기
📌 MemberJpaRepository
public List<Member> findByUsername(String username) {
return em.createNamedQuery("Member.findByUsername", Member.class)
.setParameter("username", username)
.getResultList();
}
📌 MemberJpaRepositoryTest 테스트 코드
@Test
void testNamedQuery() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("BBB", 20);
memberJpaRepository.save(m1);
memberJpaRepository.save(m2);
List<Member> result = memberJpaRepository.findByUsername("AAA");
Member findMember = result.get(0);
assertThat(findMember).isEqualTo(m1);
}
3. 스프링 데이터 JPA로 NamedQuery 쿼리 사용하기
📌 MemberRepository
package study.datajpa.repository;
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query(name = "Member.findByUsername")
List<Member> findByUsername(@Param("username") String username);
...
}
@Param은 엔티티에 정의되어 있는 NamedQuery에 파라미터 바인딩이 있을 경우 적어줘야한다.
@Query에 적힌 name 속성명으로 엔티티에 정의되어 있는 NamedQuery를 찾는다.
@Query는 생략할 수 있다. 만약에 생략한다면 리포지토리에 정의되어 있는 메소드 명인 findByUsername을 기준으로 먼저 엔티티 내부를 조회해서 '엔티티.메소드명'을 가진 NamedQuery를 먼저 찾는다.
일치하는 NamedQuery가 없을 경우 메서드 이름으로 쿼리 생성 전략을 사용한다.
📌 MemberRepositoryTest 테스트 코드
@Test
void testNamedQuery() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("BBB", 20);
memberRepository.save(m1);
memberRepository.save(m2);
List<Member> result = memberRepository.findByUsername("AAA");
Member findMember = result.get(0);
assertThat(findMember).isEqualTo(m1);
}
4. 결론
실무에서 안쓰인다.
실무에서는 @Query 애노테이션을 사용하여 리포지토리의 메소드에 쿼리를 직정 정의한다.
🔍NamedQuery 장점
엔티티에 정의되어 있는 NamedQuery의 쿼리문에 문법 오류가 있을 경우 애플리케이션 로딩 시점에서 에러를 발생시켜준다.
🔍NamedQuery 단점
쿼리가 엔티티 내부에 정속되어 있다.
더 좋은 방법인 @Query를 사용하여 리포지토리 메소드에 쿼리를 직접 정의하는 방법이 존재한다
@Query, 리포지토리 메소드에 쿼리 정의하기
1. 리포지토리의 메소드에 JPQL 쿼리 작성하기
📌 MemberRepository
package study.datajpa.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import study.datajpa.entity.Member;
import java.util.List;
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select m from Member m where m.username = :username and m.age = :age")
List<Member> findUser(@Param("username") String username, @Param("age") int age);
...
}
@Query 애노테이션을 사용하여 메소드에 적용할 쿼리를 정의했다.
실행할 메소드에 정적 쿼리를 직접 작성하므로 이름 없는 Named Query라고 불리기도 한다.
@Query에 JPQL 문법이 틀리면 애플리케이션 로딩 시점에 오류를 발생시켜준다.
참고로 실무에서는 필드가 2개 이하인 경우에는 메소드 이름으로 쿼리 생성 기능을 사용한다.
2개 초과일 경우 메소드 이름으로 쿼리 생성 전략 사용시 메서드 이름이 매우 지저분해지기 때문에 @Query 기능으로 리포지토리의 메소드에 쿼리를 직접 작성한다.
@Query를 이용하여 값, DTO 조회하기
1. @Query로 값 조회하기
📌 MemberRepository
package study.datajpa.repository;
import java.util.List;
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select m.username from Member m")
List<String> findUsernameList();
...
}
임베디드 값 타입도 이 방식으로 조회할 수 있다.
2. @Query로 DTO 조회하기
📌 MemberDto
package study.datajpa.dto;
@Data
public class MemberDto {
private Long id;
private String username;
private String teamName;
public MemberDto(Long id, String username, String teamName) {
this.id = id;
this.username = username;
this.teamName = teamName;
}
}
먼저 MemberDto 객체를 정의한다.
📌 MemberRepository
package study.datajpa.repository;
import java.util.List;
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) from Member m join m.team t")
List<MemberDto> findMemberDto();
....
}
그리고나서 MemberRepository에 @Query를 작성한다.
📌 MemberRepositoryTest 테스트 코드
@Autowired MemberRepository memberRepository;
@Autowired TeamRepository teamRepository;
@Test
void findMemberDto() {
Team team = new Team("teamA");
teamRepository.save(team);
Member m1 = new Member("AAA", 10);
m1.changeTeam(team);
memberRepository.save(m1);
List<MemberDto> memberDto = memberRepository.findMemberDto();
for (MemberDto dto : memberDto) {
System.out.println("dto = " + dto); // 실무에서는 Assert를 이용하여 검증할 것
}
select
member0_.member_id as col_0_0_,
member0_.username as col_1_0_,
team1_.name as col_2_0_
from
member member0_
inner join
team team1_
on member0_.team_id=team1_.team_id
dto = MemberDto(id=2, username=AAA, teamName=teamA)
MemberRepositoryTest에서 테스트 코드를 작성하여 검증한다.
QueryDSL로 사용하면 더 편하긴 하다.
파라미터 바인딩
2가지 파라미터 바인딩 방법이 존재한다.
- 위치 기반
- 이름 기반
select m from Member m where m.username = ?0 //위치 기반
select m from Member m where m.username = :name //이름 기반
위치 기반은 안쓰는 것이 좋다. 무조건 이름 기반으로 사용할 것.
위치 기반으로 사용하면 중간에 쿼리를 수정할 때 위치가 다 바뀌게 되어버려 유지보수가 힘들어진다.
1. 컬렉션 파라미터 바인딩
Collection 타입으로 in절을 지원한다.
많이 쓰이는 기능이다.
📌 MemberRepository
package study.datajpa.repository;
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select m from Member m where m.username in :names")
List<Member> findByNames(@Param("names") Collection<String> names);
...
}
in절로 여러 개를 조회하고 싶을 때는 리포지토리의 메서드인 findByNames의 파라미터를 컬렉션 타입으로 정의하면 된다.
📌 MemberRepositoryTest 테스트 코드
@Test
void findByNames() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("BBB", 20);
memberRepository.save(m1);
memberRepository.save(m2);
List<Member> result = memberRepository.findByNames(Arrays.asList("AAA", "BBB"));
for (Member member : result) {
System.out.println("member = " + member);
}
}
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
where
member0_.username in (
? , ?
)
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.username as username3_0_ from member member0_ where member0_.username in (? , ?)
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.username as username3_0_ from member member0_ where member0_.username in ('AAA' , 'BBB');
member = Member(id=1, username=AAA, age=10)
member = Member(id=2, username=BBB, age=20)
반환 타입
스프링 데이터 JPA는 유연하게 반환 타입을 지원한다.
반환타입을 컬렉션 / 단건 / 단건 Optional 로 지정할 수 있다.
📌 MemberRepository
package study.datajpa.repository;
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findListByUsername(String username); // 컬렉션 타입으로 반환
Member findMemberByUsername(String username); // 단건으로 반환
Optional<Member> findOptionalByUsername(String username); // 단건 Optional로 반환
...
}
스프링 데이터 JPA 공식 문서의 반환타입 내용 : https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repository-query-return-types
📌 MemberRepositoryTest - 반환타입이 리스트인 경우 테스트 코드
@Test
void findReturnTypeList() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("BBB", 20);
memberRepository.save(m1);
memberRepository.save(m2);
List<Member> findList = memberRepository.findListByUsername("AAA");
System.out.println("findList = " + findList);
System.out.println("==========================");
// 파라미터에 없는 이름을 넣을 경우
List<Member> result = memberRepository.findListByUsername("sdfsdf"); // 빈 컬렉션으로 반환된다.
System.out.println("result = " + result);
}
findList = [Member(id=1, username=AAA, age=10)]
==========================
result = []
처음에 findListByUsername()의 파라미터에 제대로 된 값을 전달했다. 그래서 리스트에 값이 제대로 들어있다.
두 번째에는 findListByUsername()의 파라미터에 잘못된 값을 전달했다. 그랬더니 빈 리스트를 반환 받았다.
이처럼 반환타입이 리스트인 경우 잘못된 값을 전달해도 null 값으로 나오지 않고 빈 리스트로 반환 받는다.
📌 MemberRepositoryTest - 반환타입이 단건조회인 경우 테스트 코드
@Test
void findReturnTypeMember() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("BBB", 20);
memberRepository.save(m1);
memberRepository.save(m2);
Member findMember = memberRepository.findMemberByUsername("AAA");
System.out.println("findMember = " + findMember);
System.out.println("==========================");
// 파라미터에 없는 이름을 넣을 경우
Member result = memberRepository.findMemberByUsername("sfdsdf"); // null 값으로 반환
System.out.println("result = " + result);
}
findMember = Member(id=1, username=AAA, age=10)
==========================
result = null
JPA에서 단건 조회시 없으면 NullResultExcpetion 예외가 발생한다.
하지만 스프링 데이터 JPA에서는 cry-catch로 감싸서 null 값으로 반환한다.
📌 MemberRepositoryTest - 반환타입이 Optional인 단건조회인 경우 테스트 코드
@Test
void findReturnTypeOptional() {
Member m1 = new Member("AAA", 10);
Member m2 = new Member("BBB", 20);
memberRepository.save(m1);
memberRepository.save(m2);
Optional<Member> findOptional = memberRepository.findOptionalByUsername("AAA");
System.out.println("findOptional = " + findOptional);
System.out.println("==========================");
// 파라미터에 없는 이름을 넣을 경우
Optional<Member> result = memberRepository.findOptionalByUsername("sdsdfsdf"); // 비어있는 Optional로 반환
System.out.println("result = " + result);
}
findOptional = Optional[Member(id=1, username=AAA, age=10)]
==========================
result = Optional.empty
단건 조회시 조회가 안될 경우 빈 Optional로 반환받는다.
단건 조회시 만약에 결과가 2건 이상일 경우에는 NonUniqueResultException 예외가 발생한다.
순수 JPA 페이징과 정렬
다음 조건으로 페이징과 정렬을 사용하는 예제 코드를 보자
- 검색 조건 : 나이 10살
- 정렬 조건 : 이름으로 내림차순
- 페이징 조건 : 첫 번째 페이지, 페이지당 보여줄 데이터는 3건
📌 MemberJpaRepository
package study.datajpa.repository;
import org.springframework.stereotype.Repository;
import study.datajpa.entity.Member;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.List;
import java.util.Optional;
@Repository
public class MemberJpaRepository {
@PersistenceContext
private EntityManager em;
/**
* @param age : 나이를 조건으로 설정
* @param offset : 몇 번째부터 시작하는지
* @param limit : 몇 개를 가져오는지
* @return
*/
public List<Member> findByPage(int age, int offset, int limit) {
return em.createQuery("select m from Member m where m.age = :age order by m.username desc", Member.class)
.setParameter("age", age)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
// 현재 내 페이지가 몇 번쨰 페이지인지
// 단순 카운팅을 하기 위함이므로 성능 최적화를 위해 정렬(sort)은 뺐다.
public long totalCount(int age) {
return em.createQuery("select count(m) from Member m where m.age =:age", Long.class)
.setParameter("age", age)
.getSingleResult();
}
...
}
- setFirstReulst(int startPosition) : 조회 시작 위치 (0부터 시작)
- setMaxResults(int maxResult) : 조회할 데이터 수
📌 MemberJpaRepositoryTest 테스트 코드
@Test
void paging() {
// given
memberJpaRepository.save(new Member("member1", 10));
memberJpaRepository.save(new Member("member2", 10));
memberJpaRepository.save(new Member("member3", 10));
memberJpaRepository.save(new Member("member4", 10));
memberJpaRepository.save(new Member("member5", 10));
int age = 10;
int offset = 0;
int limit = 3;
// when
List<Member> members = memberJpaRepository.findByPage(age, offset, limit);
long totalCount = memberJpaRepository.totalCount(age);
// then
assertThat(members.size()).isEqualTo(3);
assertThat(totalCount).isEqualTo(5);
}
스프링 데이터 JPA 페이징과 정렬
1. 페이징과 정렬 파라미터
- org.springframework.data.domain.Sort : 정렬 기능
- org.springframework.data.domain.Pageable : 페이징 기능 (내부에 Sort 포함)
- 더보기 버튼을 눌러서 계속 내리는 기능
- JPA에 의존하지 않고 스프링에 의존하게 하여 관계형 DB, NoSql 구별 없이 기능을 공통화 시켰다.
2. 특별한 반환타입
- org.springframework.data.domain.Page : 추가 count 쿼리 결과를 포함하는 페이징
- org.springframework.data.domain.Slice : 추가 count 쿼리 없이 다음 페이지만 확인 가능
- 내부적으로 limit + 1 조회 (지정된 limit 개수보다 1개 더 많이 조회한다.)
- 그렇게 하여 다음 페이지가 있는지 없는지 알 수 있다.
- List (자바 컬렉션) : 추가 count 쿼리 없이 결과만 반환
📌 리포지토리 인터페이스 - 사용 예시
Page<Member> findByUsername(String name, Pageable pageable); // count 쿼리 사용
Slice<Member> findByUsername(String name, Pageable pageable); // count 쿼리 사용 안함
List<Member> findByUsername(String name, Pageable pageable); // count 쿼리 사용 안함
List<Member> findByUsername(String name, Sort sort); // count 쿼리 사용 안함
반환 타입에 따라서 페이징 방식과 count 쿼리가 생성될지 안될지 결정된다.
🔍 Page 인터페이스
public interface Page<T> extends Slice<T> {
int getTotalPages(); //전체 페이지 수
long getTotalElements(); //전체 데이터 수
<U> Page<U> map(Function<? super T, ? extends U> converter); //변환기
}
🔍 Slice 인터페이스
public interface Slice<T> extends Streamable<T> {
int getNumber(); // 현재 페이지
int getSize(); // 페이지 크기
int getNumberOfElements(); // 현재 페이지에 나올 데이터 수
List<T> getContent(); // 조회된 데이터
boolean hasContent(); // 조회된 데이터 존재 여부
Sort getSort(); // 정렬 정보
boolean isFirst(); // 현재 페이지가 첫 페이지 인지 여부
boolean isLast(); // 현재 페이지가 마지막 페이지 인지 여부
boolean hasNext(); // 다음 페이지 여부
boolean hasPrevious(); // 이전 페이지 여부
Pageable getPageable(); // 페이지 요청 정보
Pageable nextPageable(); // 다음 페이지 객체
Pageable previousPageable(); // 이전 페이지 객체
<U> Slice<U> map(Function<? super T, ? extends U> converter); // 변환기
}
3. 사용 예제
🔍 페이징과 정렬 예제 코드
다음 조건으로 페이징과 정렬을 사용하는 예제 코드를 보자
- 검색 조건 : 나이 10살
- 정렬 조건 : 이름으로 내림차순
- 페이징 조건 : 첫 번째 페이지, 페이지당 데이터는 3건
📌 MemberRepository
package study.datajpa.repository;
public interface MemberRepository extends JpaRepository<Member, Long> {
Page<Member> findPageByAge(int age, Pageable pageable);
Slice<Member> findSliceByAge(int age, Pageable pageable);
...
}
- 두 번째 파라미터인 Pageable은 인터페이스다. 실제 사용할 때는 해당 인터페이스를 구현한 org.springframework.data.domain.PageRequest 객체를 사용한다.
📌 MemberRepositoryTest - 반환타입이 Page인 경우
@Test
void paging() {
// given
memberRepository.save(new Member("member1", 10));
memberRepository.save(new Member("member2", 10));
memberRepository.save(new Member("member3", 10));
memberRepository.save(new Member("member4", 10));
memberRepository.save(new Member("member5", 10));
int age = 10;
// PageRequest 생성자의 첫 번째 인자로는 현재 페이지를, 두 번째 인자에는 조회할 데이터 수를 입력한다.
// 참고로 정렬 정보도 파라미터로 사용할 수 있다.
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
// when
Page<Member> page = memberRepository.findPageByAge(age, pageRequest);
// then
List<Member> content = page.getContent();
long totalElements = page.getTotalElements();
for (Member member : content) {
System.out.println("member = " + member);
}
System.out.println("totalElements = " + totalElements);
assertThat(content.size()).isEqualTo(3);
assertThat(page.getTotalElements()).isEqualTo(5);
assertThat(page.getNumber()).isEqualTo(0); // 페이지 번호
assertThat(page.getTotalPages()).isEqualTo(2); // 전체 페이지 개수
assertThat(page.isFirst()).isTrue(); // 첫 번쨰 페이지인가
assertThat(page.hasNext()).isTrue(); // 다음 페이지가 존재하는가
}
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
where
member0_.age=?
order by
member0_.username desc limit ?
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.username as username3_0_ from member member0_ where member0_.age=? order by member0_.username desc limit ?
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.username as username3_0_ from member member0_ where member0_.age=10 order by member0_.username desc limit 3
// ----------------------------------------------------
/* 카운트 쿼리 */
select
count(member0_.member_id) as col_0_0_
from
member member0_
where
member0_.age=?
select count(member0_.member_id) as col_0_0_ from member member0_ where member0_.age=?
select count(member0_.member_id) as col_0_0_ from member member0_ where member0_.age=10;
// ----------------------------------------------------
member = Member(id=5, username=member5, age=10)
member = Member(id=4, username=member4, age=10)
member = Member(id=3, username=member3, age=10)
totalElements = 5
- PageRequest는 Pageable 인터페이스의 구현체다.
- PageRequest 생성자의 첫 번째 파라미터에는 현재 페이지를, 두 번째 파라미터에는 조회할 데이터 수를 입력한다. 여기에 추가로 정렬 정보도 파라미터로 사용할 수 있다. 참고로 페이지는 0부터 시작한다.
- 리포지토리의 메소드에서는 인자를 Pageable 타입으로 정의했지만 PageRequest 타입으로 인자를 전달한다.
- 반환타입이 Page이므로 TotalCount 쿼리가 자동으로 생성된다.
📌 MemberRepositoryTest - 반환타입이 Slice인 경우
@Test
void slicing() {
// given
memberRepository.save(new Member("member1", 10));
memberRepository.save(new Member("member2", 10));
memberRepository.save(new Member("member3", 10));
memberRepository.save(new Member("member4", 10));
memberRepository.save(new Member("member5", 10));
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
// when
Slice<Member> page = memberRepository.findSliceByAge(age, pageRequest);
// then
List<Member> content = page.getContent();
for (Member member : content) {
System.out.println("member = " + member);
}
assertThat(content.size()).isEqualTo(3);
assertThat(page.getNumber()).isEqualTo(0); // 페이지 번호
assertThat(page.isFirst()).isTrue(); // 첫 번쨰 페이지인가
assertThat(page.hasNext()).isTrue(); // 다음 페이지가 존재하는가
}
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
where
member0_.age=?
order by
member0_.username desc limit ?
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.username as username3_0_ from member member0_ where member0_.age=? order by member0_.username desc limit ?
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.username as username3_0_ from member member0_ where member0_.age=10 order by member0_.username desc limit 4;
// ----------------------------------------------------
member = Member(id=5, username=member5, age=10)
member = Member(id=4, username=member4, age=10)
member = Member(id=3, username=member3, age=10)
- 반환타입이 slice면 지정된 limit 개수보다 +1 해서 가져온다. 그렇게 하여 다음 페이지가 있는지 없는지 알 수 있다.
- 출력 결과를 보면 limit가 4로 지정되어 있다.
🔍 데이터가 많아질수록 TotalCount 쿼리 성능이 안좋아진다.
📌 MemberRepository
package study.datajpa.repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query(value = "select m from Member m left join m.team t")
Page<Member> findPageCountByAge(int age, Pageable pageable);
...
}
📌 MemberRepositoryTest
@Test
void findPageCountByAge() {
memberRepository.save(new Member("member1", 10));
memberRepository.save(new Member("member2", 10));
memberRepository.save(new Member("member3", 10));
memberRepository.save(new Member("member4", 10));
memberRepository.save(new Member("member5", 10));
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
Page<Member> result = memberRepository.findPageCountByAge(age, pageRequest);
}
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
left outer join
team team1_
on member0_.team_id=team1_.team_id
order by
member0_.username desc limit ?
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.username as username3_0_ from member member0_ left outer join team team1_ on member0_.team_id=team1_.team_id order by member0_.username desc limit ?
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.username as username3_0_ from member member0_ left outer join team team1_ on member0_.team_id=team1_.team_id order by member0_.username desc limit 3;
// --------------------------------------------
/* count 쿼리 */
select
count(member0_.member_id) as col_0_0_
from
member member0_
left outer join
team team1_
on member0_.team_id=team1_.team_id
select count(member0_.member_id) as col_0_0_ from member member0_ left outer join team team1_ on member0_.team_id=team1_.team_id
select count(member0_.member_id) as col_0_0_ from member member0_ left outer join team team1_ on member0_.team_id=team1_.team_id;
조인으로 데이터 양이 많아질수록 TotalCount 쿼리를 생성하는 것이 성능 부하를 일으킬 수 있다.
전체 count 쿼리는 매우 무겁다.
그럴 경우 Slice 방식을 사용하거나 또는 조회 쿼리와 TotalCount 쿼리를 분리하는 것이다.
만약에 Slice 방식을 사용하고 싶다면 리포지토리에 정의되어 있는 메소드의 반환타입을 Slice로 변경하면 된다.
💡 조회 쿼리와 카운트 쿼리 분리
카운트 쿼리를 분리하여 TotalCount 쿼리 성능을 올릴 수 있다.
📌 MemberRepository
package study.datajpa.repository;
public interface MemberRepository extends JpaRepository<Member, Long> {
// 조회쿼리와 카운트 쿼리 분리
@Query(value = "select m from Member m left join m.team t",
countQuery = "select count(m.username) from Member m")
Page<Member> findPageSeparateCountByAge(int age, Pageable pageable);
...
}
- @Query 애노테이션의 속성인 countQuery를 조회 쿼리와 카운트 쿼리를 분리하였다.
📌 MemberRepositoryTest
@Test
void findPageSeparateCountByAge() {
memberRepository.save(new Member("member1", 10));
memberRepository.save(new Member("member2", 10));
memberRepository.save(new Member("member3", 10));
memberRepository.save(new Member("member4", 10));
memberRepository.save(new Member("member5", 10));
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
Page<Member> result = memberRepository.findPageSeparateCountByAge(age, pageRequest);
}
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
left outer join
team team1_
on member0_.team_id=team1_.team_id
order by
member0_.username desc limit ?
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.username as username3_0_ from member member0_ left outer join team team1_ on member0_.team_id=team1_.team_id order by member0_.username desc limit ?
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.username as username3_0_ from member member0_ left outer join team team1_ on member0_.team_id=team1_.team_id order by member0_.username desc limit 3;
// ------------------------------------------------------
/* count 쿼리 */
select
count(member0_.username) as col_0_0_
from
member member0_
select count(member0_.username) as col_0_0_ from member member0_
select count(member0_.username) as col_0_0_ from member member0_;
출력 결과보면 알 수 있듯이 조회 쿼리에서는 join으로 team도 조회했지만 count쿼리에는 join 없이 오직 member만 조회했다.
이렇게 count 쿼리를 분리하여 성능 부하를 막을 수 있다. 실무에서 중요하다.
참고로 PageRequest 생성자에 정렬 조건을 추가할 때, 복잡한 경우 정렬 조건을 못 넣을 수 있다. 그럴 경우 @Query 애노테이션에 Sort 조건(정렬 조건)을 넣으면 된다.
🔍 페이지를 유지하면서 엔티티를 DTO로 변환하기
API로 통신하는 경우 엔티티를 그대로 반환하면 안된다. 먼저 MemberRepository의 페이징 메서드를 보자.
📌 MemberRepository
package study.datajpa.repository;
public interface MemberRepository extends JpaRepository<Member, Long> {
Page<Member> findPageByAge(int age, Pageable pageable);
Slice<Member> findSliceByAge(int age, Pageable pageable);
...
}
해당 메서드를 보면 반환타입이 Page<Member> / Slice<Member> 로 되어있다. 엔티티가 들어있는 상태다.
API의 컨트롤러에서 엔티티를 그대로 반환할 경우 엔티티가 바뀔 경우 API 스펙이 바뀌는 문제가 발생할 수 있다.
그러므로 Page<Member> 를 Page<MemberDto>로, Slice<Member>를 Slice<MemberDto>로 변경해서 Dto로 반환해야 한다.
먼저 MemberDto 클래스를 보자.
📌 MemberDto
package study.datajpa.dto;
import lombok.Data;
@Data
public class MemberDto {
private Long id;
private String username;
private String teamName;
public MemberDto(Long id, String username, String teamName) {
this.id = id;
this.username = username;
this.teamName = teamName;
}
}
📌 MemberRepositoryTest - Page<Member> → Page<MemberDto> 변환 테스트
@Test
void PageEntityDto() {
memberRepository.save(new Member("member1", 10));
memberRepository.save(new Member("member2", 10));
memberRepository.save(new Member("member3", 10));
memberRepository.save(new Member("member4", 10));
memberRepository.save(new Member("member5", 10));
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3);
Page<Member> page = memberRepository.findPageByAge(age, pageRequest);
// Dto로 변환 (api로 반환 가능)
Page<MemberDto> result = page.map(m -> new MemberDto(m.getId(), m.getUsername(), null));
}
- Page<Member>→ Page<MemberDto> 변환 테스트
- 참고로 Page 메서드들은 API로 반환할 때 모두 JSON 으로 변환되어 반환된다.
벌크성 수정 쿼리
변경 감지 기능은 단건 조회에서만 적용된다. 많은 데이터를 일괄적으로 수정하거나 삭제할 경우 변경 감지를 이용한다면 SQL 쿼리문이 너무 많이 생성된다.
그러므로 일괄적인 삭제나 수정이 필요한 경우에는 벌크 연산이 유리하다.
JPQL로 여러 건을 한번에 수정하거나 삭제할 때 사용한다.
벌크 연산은 쿼리가 단 한 번으로 모든 것을 수정하거나 삭제할 수 있다.
실무에서 종종 쓰인다.
1. 순수 JPA로 벌크성 수정 쿼리 생성
📌 MemberJpaRepository
package study.datajpa.repository;
@Repository
public class MemberJpaRepository {
@PersistenceContext
private EntityManager em;
public int bulkAgePlus(int age) {
return em.createQuery(
"update Member m set m.age = m.age +1" +
" where m.age >= :age")
.setParameter("age", age)
.executeUpdate();
}
...
}
- executeUpdate() 메서드를 통해 벌크 연산을 수행한다.
- executeUpdate()의 반환 타입은 int 형이며 해당 벌크 연산이 적용된 엔티티 개수를 반환한다.
📌 MemberJpaRepositoryTest
@Test
void bulkUpdate() {
// given
memberJpaRepository.save(new Member("member1", 10));
memberJpaRepository.save(new Member("member2", 19));
memberJpaRepository.save(new Member("member3", 20));
memberJpaRepository.save(new Member("member4", 21));
memberJpaRepository.save(new Member("member5", 40));
// when
int resultCount = memberJpaRepository.bulkAgePlus(20);
// then
assertThat(resultCount).isEqualTo(3);
}
데이터베이스를 보면 나이가 20살 이상인 사람들의 나이가 1씩 올랐다는 것을 알 수 있다.
2. 스프링 데이터 JPA로 벌크성 수정 쿼리 생성
📌 MemberRepository
package study.datajpa.repository;
public interface MemberRepository extends JpaRepository<Member, Long> {
@Modifying(clearAutomatically = true)
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulAgePlus(@Param("age") int age);
}
- @Modifying 애노테이션이 있어야 JPA에서 executeUpdate() 메서드를 호출해준다.
- @Modifying 애노테이션을 사용하지 않으면 getResultList() 또는 getSingleResult()를 호출하여 예외가 발생한다.
- org.hibernate.hql.internal.QueryExceutionRequestException: Not supported for DML operations 예외가 발생한다.
- @Modifying 애노테이션의 clearAutomatically 속성을 true 값으로 설정하여 벌크 연산 이후 em.clear() 역할을 해준다. 한마디로 영속성 컨텍스트를 비워준다.
- 이 옵션은 기본값은 false다.
- 이 옵션이 false인 경우 회원을 findById로 다시 조회하면 영속성 컨텍스트에 과거 값이 남아서 문제가 될 수 있다. 그러므로 다시 조회해야한다면 영속성 컨텍스트를 초기화시켜야 한다.
- 영속성 컨텍스트를 초기화 시켜주기 위해서는 벌크 연산 이후 em.clear()를 직접 호출하거나 @Modifying의 속성인 clearAutomatically을 true로 설정해야 한다.
- 참고로 벌크 연산은 JPQL로 실행하는 연산이기 때문에 벌크 연산전 자동으로 해당 JPQL의 쿼리와 관련 있는 엔티티를 flush 한다.
📌 MemberRepositoryTest
@Test
void bulkUpdate() {
// given
memberRepository.save(new Member("member1", 10));
memberRepository.save(new Member("member2", 19));
memberRepository.save(new Member("member3", 20));
memberRepository.save(new Member("member4", 21));
memberRepository.save(new Member("member5", 40));
// when
int resultCount = memberRepository.bulAgePlus(20);
// em.clear(); // @Modifying의 clearAutomaticaaly가 true로 되어 있으면 생략 가능
List<Member> result = memberRepository.findByUsername("member5");
Member member5 = result.get(0);
System.out.println("member5 = " + member5);
// then
assertThat(resultCount).isEqualTo(3);
}
member5 = Member(id=5, username=member5, age=41)
3. 벌크 연산시 주의 사항
🔍 벌크 연산시 생길 수 있는 문제점
JPA는 영속성 컨텍스트를 이용해 엔티티를 관리한다. 그러나 벌크 연산은 영속성 컨텍스트를 거치지 않고 DB에 직접 접근하여 쿼리를 날린다. 그렇게 되면 영속성 컨텍스트와 DB 데이터가 서로 불일치 현상이 생긴다.
🔍 해결 방안
💡 벌크 연산을 가장 먼저 실행한다.
벌크 연산을 가장 먼저 실행하면 이미 변경된 내용을 데이터베이스에서 가져온다. 가장 실용적인 해결책이다.
💡 벌크 연산 수행 후 영속성 컨텍스트 초기화
벌크 연산 후에 em.clear()를 직접 호출하거나 @Modifying 애노테이션의 clearAutomatically 속성 값을 true로 하여 영속성 컨텍스트를 초기화 시켜줘야 한다.
@EntityGraph
엔티티 그래프는 연관된 엔티티들을 SQL 한번에 조회하는 방법이다. 엔티티 그래프를 사용하면 JPQL 없이 객체 그래프를 한번에 엮어서 성능 최적화가 된다.
엔티티 그래프는 내부적으로 fetch join을 사용한다. 그래서 엔티티 그래프를 통해 fetch join을 쉽게 사용할 수 있다.
참고로 엔티티 그래프는 JPA에서 제공하는 기능이다. JPA에서 @NamedEntityGraph로 엔티티 기능을 지원하나 거의 안쓰인다.
결론 : 간단한 쿼리문일 때는 @EntityGraph를 사용하고 쿼리가 복잡해지면 JPQL로 fetch join을 사용할 것.
📌 Member 엔티티
package study.datajpa.entity;
@Entity
@Getter @Setter
@NoArgsConstructor(access = 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 = LAZY) // 지연 로딩 관계
@JoinColumn(name = "team_id")
private Team team;
}
Member → Team 관계는 지연 로딩 관계이다. 이런 경우 Member에서 Team을 조회할 때 프록시 N+1 문제가 발생한다. 이 때 fetch join 기능이 필요하다.
📌 MemberRepository
package study.datajpa.repository;
public interface MemberRepository extends JpaRepository<Member, Long> {
// 순수 JPQL
@Query("select m from Member m join fetch m.team")
List<Member> findMemberFetchJoin();
// 순수 EntityGraph, 전체 조회
@Override
@EntityGraph(attributePaths = "team")
List<Member> findAll();
// 순수 EntityGraph, 단건 조회
@EntityGraph(attributePaths = "team")
List<Member>findEntityGraphByUsername(@Param("username") String username);
// EntityGraph + JPQL
@EntityGraph(attributePaths = "team")
@Query("select m from Member m")
List<Member> findMemberEntityGraph();
...
}
JPQL로 fetch join을 써도 되고, @EntityGraph로 사용해도 된다.
🔍 @NamedEnityGraph 사용 방법
EntityGraph 기능은 JPA에서 지원하는 기능이다. JPA 표준에서는 @NamedEntityGraph를 지원하지만, 잘 안쓰인다.
📌 Member 엔티티
package study.datajpa.entity;
import lombok.*;
import javax.persistence.*;
import static javax.persistence.FetchType.LAZY;
import static lombok.AccessLevel.PROTECTED;
@Entity
@Getter @Setter
@NoArgsConstructor(access = PROTECTED)
@ToString(of = {"id", "username", "age"})
@NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode("team"))
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String username;
private int age;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "team_id")
private Team team;
...
}
📌 MemberRepository
package study.datajpa.repository;
public interface MemberRepository extends JpaRepository<Member, Long> {
// @NamedEntityGraph
@EntityGraph("Member.all")
List<Member>findNamedEntityGraphByUsername(@Param("username") String username);
}
🔍 참고) 지연로딩 여부 확인하기
📌 MemberRepositoryTest
package study.datajpa.repository;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Transactional
@Rollback(false)
class MemberRepositoryTest {
@Autowired
MemberRepository memberRepository;
@Autowired
TeamRepository teamRepository;
@PersistenceContext
EntityManager em;
@Test
void LazyLoadingTest() {
Team teamA = new Team("teamA");
teamRepository.save(teamA);
Member member = new Member("member", 10, teamA);
// Hibernate 기능으로 확인하기
memberRepository.save(member);
boolean initialized = Hibernate.isInitialized(member.getTeam());
assertThat(initialized).isTrue();
// JPA 표준 방법으로 확인하기
PersistenceUnitUtil util =
em.getEntityManagerFactory().getPersistenceUnitUtil();
boolean loaded = util.isLoaded(member.getTeam());
assertThat(loaded).isTrue();
}
...
}
지연 로딩을 확인할 수 있는 2가지 방법이 있다. Hibernate 기능으로도 확인할 수 있고, JPA 표준 방법으로도 가능하다.
JPA Hint & Lock
1. JPA Hint
JPA 쿼리 힌트는 SQL 힌트가 아니라 JPA 구현체에게 제공하는 힌트다.
먼저 Hint를 사용하지 않으면 어떻게 되는지 테스트 코드를 통해서 살펴보자.
🔍 조회만 사용하기 (Read Only)
📌 MemberRepositoryTest
@Test
void noQueryHint() {
// givne
Member member1 = new Member("member1", 10);
memberRepository.save(member1);
em.flush();
em.clear();
// when
Member findMember = memberRepository.findByUsername("member1").get(0);
findMember.setUsername("member2");
em.flush();
}
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
where
member0_.username=?
update
member
set
age=?,
team_id=?,
username=?
where
member_id=?
출력 결과를 보면 변경 감지를 통해 update 쿼리가 생성된다. 이렇게 엔티티의 변경이 일어난 경우에는 영속성 컨텍스트가 스스로 변경 감지를 통해서 update 쿼리가 생성한 것이다. 큰 장점이다.
변경 감지 작동 원리를 말하자면, 영속성 컨텍스트는 처음 엔티티가 영속상태로 됐을 때 현재 엔티티 상태를 스냅샷으로 저장을 해서 스냅샷과 비교를 통해 변경 감지가 일어난다.
하지만 이 변경 감지에도 단점이 존재한다. 영속성 컨텍스트는 변경 감지 기능 때문에 항상 스냅샷을 가지고 있다. 즉, 조회만 해도 스냅샷을 가지고 있기에 불필요한 메모리를 잡아먹게 된다.
이 단점을 JPA Hint로 해결할 수 있다.
📌 MemberRepository
package study.datajpa.repository;
public interface MemberRepository extends JpaRepository<Member, Long> {
// Hint
@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUsername(String username);
...
}
@QueryHints 애노테이션의 속성으로 @QueyrHint 애노테이션을 추가할 수 있다.
@QueryHint 애노테이션의 name 속성에는 readOnly 값을 넣고, value 에는 true를 넣었다. 이렇게 JPA에 해당 쿼리가 조회용으로만 쓰인다는 것을 알리면 스냅샷을 만들지 않고 변경감지가 일어나지 않는다.
테스트 코드를 통해 확인해보자.
📌 MemberRepositoryTest
@Test
void queryHint() {
// givne
Member member1 = new Member("member1", 10);
memberRepository.save(member1);
em.flush();
em.clear();
// when
Member findMember = memberRepository.findReadOnlyByUsername("member1");
findMember.setUsername("member2");
em.flush();
}
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
where
member0_.username=?
@QueryHints 애노테이션의 readOnly가 true값이 적용된 findReadOnlyByUsername() 메서드를 통해 멤버 엔티티를 조회한다. 그리고 setUsername() 메서드를 통해 해당 엔티티의 이름을 변경했다.
출력 결과를 보면 select인 조회용 쿼리만 생성되고 update인 변경 쿼리가 생성되지 않았다. 즉, 영속성 컨텍스트가 스냅샷을 만들지 않아서 변경감지도 일어나지 않는 것이다. 그러므로 값이 변경 되어도 update 쿼리가 생성되지 않았다.
이처럼 조회용으로만 쓸경우 기능을 최적화 할 수 있도록 hint를 주도록 한다. Hint 기능은 JPA가 지원하는 기능이 아니라 hibernate에서만 지원하는 기능이다. Hint를 통해 변경 감지 체크를 안하고 읽기용으로 최적화를 했지만 사실 성능 체감이 잘 되진 않는다. 왜냐하면 이미 성능 최적화를 위한 다른 대안들이 존재한다. 예를 들어서 캐싱과 레디스를 사용한다던지..
그리고 Hint를 적용할 때는 진짜 중요한 트래픽이 많은 소수의 API에만 넣어야한다. 모든 API에 다 넣어서 성능 최적화 시키는건 애매하다. Hint를 사용할 때는 무조건 성능 테스트 후 이점이 있을 경우에만 사용할 것.
🧷 참고
Hint는 readOnly 옵션 외에도 forCountig등 여러 옵션 기능을 제공한다.
forCountig : 반환 타입으로 Page 인터페이스를 적용하면 추가로 호출하는 페이징을 위한 count 쿼리도 쿼리 힌트 적용 (기본값 true)
2. Lock
JPA의 기본 동작이 select / update 이기 때문에 어떤 값을 동시에 여러 스레드에서 변경하려고 할 때 그 값의 정합성을 보장하기 어렵다. Lock에는 여러 종류가 있지만 위의 상황에서는 비관적 잠금(Pessimistic Lock : 동일한 데이터를 동시에 수정할 가능성이 높다는 비관적인 전제로 잠금을 거는 방식)을 사용해야 한다. 실시간 트래픽이 많은 서비스에서는 락을 걸면 안된다.
📌 MemberRepository
package study.datajpa.repository;
public interface MemberRepository extends JpaRepository<Member, Long> {
// Lock
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockByUsername(String username);
...
}
@Lock 애노테이션의 속성을 통해 비관적 잠금 모드를 사용하겠다고 명시했다.
📌 MemberRepositoryTest
@Test
void lock() {
// given
Member member1 = new Member("member1", 10);
memberRepository.save(member1);
em.flush();
em.clear();
// when
List<Member> result = memberRepository.findLockByUsername("member1");
}
select
member0_.member_id as member_i1_0_,
member0_.age as age2_0_,
member0_.team_id as team_id4_0_,
member0_.username as username3_0_
from
member member0_
where
member0_.username=? for update
출력 결과를 보면 for update 구문이 중요하다. 이 구문이 있기 때문에 동시 다발적으로 업데이트가 될 때도 정합성이 보장된다.
👀 참고 자료
https://jaime-note.tistory.com/57
'[JPA] > 실전! 스프링 데이터 JPA' 카테고리의 다른 글
[JPA] 나머지 기능들 (0) | 2022.04.22 |
---|---|
스프링 데이터 JPA 분석 (0) | 2022.04.21 |
[JPA] 확장 가능 (0) | 2022.04.20 |
[JPA] 공통 인터페이스 기능 (0) | 2022.04.16 |
[JPA] 예제 도메인 모델 (0) | 2022.04.16 |