JPA 관련 메서드
📌 개요
JPA와 관련된 메서드에 대해 자세히 알아보자.
📌 메서드명 기반 쿼리
findBy
와 같이 By
로 끝나는 메서드는 반드시 뒤에 추가 키워드가 붙어야 한다. 예를 들어 findByName
과 같이 속성 이름 또는 조건이 붙어야 하며, 이러한 방법을 Query Derivation
라고 한다.
예를 들어 findByLastnameAndFirstname
는 SELECT u FROM User u WHERE u.lastname = ?1 AND u.firstname = ?2
로 변환된다.
메서드명 기반 쿼리가 실제 SQL 쿼리로 변환되는 과정은 다음과 같다.
- 메서드를 작성하게 되면
Spring Data
레포지토리 팩토리가 이를 식별하여 애플리케이션 컨텍스트를 초기화하는 시점에 해당 레포지토리 인터페이스의 프록시 구현체를 생성한다. PartTree
컴포넌트가By
를 기준으로 메서드명을 분리하여 트리 구조로 변환한다.find
,exists
와 같이 메서드 목적을 나타내는 키워드는subject
,And
,Or
, 속성 이름과 같은 키워드는predicate
로 구분된 후 트리 구조로 변환된다.PartTreeJpaQuery
가 파싱된PartTree
를 기반으로 쿼리를 생성하고 실행한다.JpaQueryCreator
가CriteriaBuilder
와ParameterMetadataProvider
를 통해 파싱된 식별자와 조건에 맞는CriteriaQuery
객체를 만든다.AbstractJpaQuery
의doCreateQuery()
가 최종Query
객체를 생성한다.- 최종
Query
객체는 JPA 구현체에 의해 SQL로 변환되어 DB에 전송된다.
findBy, readBy, getBy
findBy
는 조건에 맞는 엔티티를 조회하는 메서드이다. 일반적으로 조건에 따라 List
객체를 리턴할 수도 있고, Optional
객체를 리턴할 수도 있다.
findBy
, readBy
, getBy
는 기능적으로 유사하나, findById
와 getById
는 큰 차이점이 존재한다. findById
는 실제 엔티티를 DB에서 조회하지만, getById
는 엔티티를 바로 조회하는 것이 아니라 프록시 객체를 리턴한다. 따라서 프록시 객체 필드에 실제로 접근할 때 쿼리가 발생하게 된다.
단, 과거 getOne
으로 불린 getById
는 deprecated되었고, getReferenceById
를 사용해야 한다. 만약 조회된 엔티티를 바로 사용하는 경우거나 엔티티의 존재가 불확실할 때 findById
를, 다른 엔티티 간 연관 관계만 설정하고 싶은 경우거나 불필요한 SELECT
쿼리를 생략하고 싶은 경우 getReferenceById
를 사용하는 것이 좋다.
existsBy
existsBy
는 특정 조건을 만족하는 엔티티가 존재하는지 여부를 boolean
타입으로 리턴하는 메서드이다. 성능 최적화를 위해 조건을 만족하는 데이터를 하나라도 찾으면 즉시 검색을 종료한다. 이는 existsBy
가 EXISTS
쿼리나 LIMIT 1
쿼리로 변환되기 때문이다. 다른 메서드를 통해 엔티티의 존재 유무를 확인할 수도 있지만, existsBy
를 사용하는 것이 성능적으로 이득이다.
countBy
countBy
는 특정 조건을 만족하는 엔티티의 개수를 리턴하는 메서드이다. 즉, COUNT(*)
쿼리를 생성하게 된다. 리턴 타입은 반드시 long
또는 int
여야 한다.
deleteBy, removeBy
deleteBy
는 특정 조건을 만족하는 엔티티를 삭제하는 메서드이다. 먼저 조건에 맞는 엔티티를 SELECT
쿼리로 조회한 후 조회한 엔티티들을 순회하며 각각의 엔티티에 대해 delete
메서드를 호출한다. 따라서 대량의 데이터를 삭제해야 하는 경우 @Query
와 @Modifying
을 사용한 벌크 연산이 더 효율적이다.
delete
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
@Transactional
@SuppressWarnings("unchecked")
public void delete(T entity) {
Assert.notNull(entity, "Entity must not be null");
if (entityInformation.isNew(entity)) {
return;
}
Class<?> type = ProxyUtils.getUserClass(entity);
T existing = (T) entityManager.find(type, entityInformation.getId(entity));
// if the entity to be deleted doesn't exist, delete is a NOOP
if (existing == null) {
return;
}
entityManager.remove(entityManager.contains(entity) ? entity : entityManager.merge(entity));
}
delete
메서드의 동작을 자세히 살펴보자.
1
Assert.notNull(entity, "Entity must not be null");
delete
메서드는 먼저 삭제하려는 엔티티가 null
인지 확인한다.
1
2
3
if (entityInformation.isNew(entity)) {
return;
}
만약 엔티티가 비영속 상태라면 DB에 삭제할 대상이 없는 것이므로 아무런 동작을 수행하지 않고 메서드를 종료한다.
1
2
3
4
5
6
7
Class<?> type = ProxyUtils.getUserClass(entity);
T existing = (T) entityManager.find(type, entityInformation.getId(entity));
if (existing == null) {
return;
}
엔티티의 ID를 사용하여 find
메서드를 호출하여 엔티티가 DB에 실제로 존재하는지 확인한다. 이 과정에서 SELECT
쿼리가 발생하게 된다. 만약 리턴값이 null
이라면 DB에 해당 엔티티가 존재하지 않는 것이므로 아무 작업도 수행하지 않고 종료한다.
1
2
entityManager.remove(entityManager.contains(entity) ? entity : entityManager.merge(entity));
contains
메서드는 엔티티 객체가 영속성 컨텍스트에 의해 관리되는지 여부를 리턴한다.
만약 contains
메서드의 리턴값이 true
즉, 현재 엔티티가 영속 상태라면 remove
메서드를 통해 엔티티를 영속성 컨텍스트에서 제거한다.
contains
메서드의 리턴값이 false
즉, 현재 엔티티가 준영속 상태라면 merge
메서드를 통해 엔티티를 영속성 컨텍스트로 다시 가져와 영속 상태로 만든다. 그리고 영속 상태의 엔티티를 remove
메서드에 전달하여 삭제를 진행한다. remove
메서드는 영속 상태의 엔티티만 인자로 받을 수 있기 때문이다.
주의해야 할 점은 실제 DELETE
쿼리는 remove
메서드를 호출할 때 발생하는 것이 아닌, 트랜잭션이 커밋될 때 DB에 날라가게 된다.
📌 findAll
findAll
은 테이블에 있는 모든 엔티티를 조회하는 메서드이다. 리턴 타입은 List
이다.
📌 deleteAll
1
2
3
4
5
6
7
8
@Override
@Transactional
public void deleteAll() {
for (T element : findAll()) {
delete(element);
}
}
deleteAll
은 여러 엔티티를 삭제하는 메서드이다. 먼저 findAll
메서드를 수행하여 엔티티들을 순회하고(이 과정에서 엔티티들이 영속성 컨텍스트에 로드되게 됨), 조회된 엔티티에 대해 개별적으로 DELETE
쿼리를 수행한다. 즉, 삭제 쿼리가 N번 발생하게 된다. 따라서 데이터의 수가 많을수록 비효율적이다.
📌 deleteAllInBatch
1
2
3
4
5
6
7
8
9
10
@Override
@Transactional
public void deleteAllInBatch() {
Query query = entityManager.createQuery(getDeleteAllQueryString());
applyQueryHints(query);
query.executeUpdate();
}
deleteAll
의 성능적 문제를 해결하기 위한 방법은 deleteAllInBatch
메서드이다. 이 메서드는 단 한 번의 DELETE
쿼리로 모든 엔티티를 삭제한다(벌크 연산). 삭제 대상 엔티티들이 영속성 컨텍스트를 거치지 않고 바로 삭제된다는 특징이 있다.
그렇다면 deleteAllInBatch
와 @Query
, @Modifying
을 사용한 JPQL은 어떤 차이점이 있을까? 둘 다 한 번의 쿼리로 여러 엔티티를 삭제하는 벌크 연산이라는 점은 동일하다. deleteAllInBatch
는 조건이 OR
절로 연결되는 반면, @Query
를 사용하면 IN
절로 조건을 명시할 수 있다는 특징이 있다. 따라서 조건이 많아진다면 @Query
를 통해 JPQL 쿼리를 작성하는 것이 더 효율적이다. 또한 @Modifying
의 clearAutomatically
옵션을 통해 영속성 컨텍스트 동기화를 수행할 수 있어 동기화할 수 있다.
벌크 연산을 수행하면 DB에 바로 쿼리를 날리기 때문에 영속성 컨텍스트와 DB 간 데이터 불일치가 발생할 수 있다.
📌 save
1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (entityInformation.isNew(entity)) {
entityManager.persist(entity);
return entity;
} else {
return entityManager.merge(entity);
}
}
save
는 엔티티를 DB에 저장하거나 업데이트하는 메서드이다.
1
2
Assert.notNull(entity, "Entity must not be null");
전달된 엔티티가 null
인지 확인한다.
1
2
3
4
5
6
if (entityInformation.isNew(entity)) {
entityManager.persist(entity);
return entity;
} else {
return entityManager.merge(entity);
}
isNew
메서드를 통해 엔티티가 새로운 엔티티인지 확인한다. 엔티티가 비영속 상태라면 persist
메서드를 통해 엔티티를 영속성 컨텍스트에 추가한다. 엔티티가 영속 또는 준영속 상태라면 merge
메서드를 통해 엔티티를 다시 영속 상태로 만든다. 이 과정에서 동일한 ID를 가진 엔티티가 존재하는지 확인하기 위해 SELECT
쿼리가 발생하게 된다.
따라서 이미 영속 상태인 엔티티에 save
메서드를 호출하게 되면 불필요한 쿼리가 발생하게 되며, 성능 저하가 생길 수 있다. 따라서 같은 트랜잭션 내라면 dirty checking
을 사용하는 것을 권장한다.
📌 saveAll
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Transactional
@Override
public <S extends T> List<S> saveAll(Iterable<S> entities) {
Assert.notNull(entities, "Entities must not be null");
List<S> result = new ArrayList<>();
for (S entity : entities) {
result.add(save(entity));
}
return result;
}
saveAll
은 여러 엔티티를 한 번에 저장 및 업데이트할 때 사용되는 메서드이다.
@Transaction
어노테이션이 메서드 레벨에 적용되어 있어 saveAll
을 호출하게 되면 하나의 트랜잭션이 생성된다. 즉, 내부에서 호출하는 save
메서드에 대해 별도의 트랜잭션을 생성하지 않는다.
바로 이 점이 여러 번의 save
메서드 호출보다 한 번의 saveAll
메서드 호출이 더 좋은 성능을 보이는 이유이다. 단일 트랜잭션으로 처리함으로써 트랜잭션 오버헤드를 줄일 수 있기 때문이다.
DB 커넥션을 얻고 트랜잭션을 시작하고 커밋하는 작업의 비용은 크다.