JPA는 표준 API이고, Spring Data JPA는 그 위에 얹힌 코드 생성 계층이다. 인터페이스만 선언하면 구현을 만들어 주고, 그 구현이 내부에서 EntityManager를 호출한다. 같은 동작을 두 방식으로 작성하면 경계가 드러난다.
JPA
JPA의 중심은 영속성 컨텍스트와 그것을 다루는 EntityManager다. 영속성 컨텍스트는 엔티티를 보관하는 논리적 공간으로 1차 캐시, 변경 감지(Dirty Checking), 지연 로딩, 쓰기 지연을 제공한다. 순수 JPA에서는 EntityManager를 직접 잡고 트랜잭션도 직접 열고 닫는다.
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
User user = new User("홍길동", "[email protected]");
em.persist(user); // 영속 상태로 전환 → INSERT 예약
User found = em.find(User.class, user.getId()); // 1차 캐시에서 반환 (추가 쿼리 없음)
found.setName("김철수"); // 변경 감지 → flush 시 자동 UPDATE
tx.commit(); // 예약된 SQL 실행
em.close();
동작은 하지만, 모든 엔티티의 모든 CRUD마다 createEntityManager, getTransaction, begin, commit, close가 반복된다. Spring Data JPA가 제거하는 것이 이 반복이다.
Spring Data JPA
Spring Data JPA의 핵심은 Repository다. 구현 클래스를 작성하지 않고 인터페이스만 선언하면, Spring이 런타임에 프록시 기반 구현체를 만들고 그 구현체가 내부에서 EntityManager를 호출한다.
public interface UserRepository extends JpaRepository<User, Long> {
// save(), findById(), findAll(), delete() ... 전부 자동 제공
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional
public User createUser(String name, String email) {
User user = new User(name, email);
return userRepository.save(user); // 내부적으로 em.persist() 또는 em.merge()로 위임
}
}
앞의 순수 JPA 코드와 같은 일을 한다. EntityManager를 직접 들지 않고 트랜잭션 경계를 @Transactional로 선언한다는 점만 다르다. CrudRepository가 save()/findById()/findAll()/deleteById()를 주고, JpaRepository는 거기에 flush(), saveAndFlush(), 배치 삭제, 페이징/정렬을 더한다. 어느 쪽이든 호출 끝에서는 EntityManager가 같은 SQL을 만든다.
따라서 Repository를 쓴다고 고유한 성능 오버헤드가 생기지 않는다. 같은 구현체(보통 Hibernate)가 같은 SQL을 만든다. Repository는 보일러플레이트를 없애는 계층이지 다른 엔진이 아니다.
쿼리를 표현하는 세 가지 방법
Repository 위에서 쿼리를 짜는 방법은 세 가지이고, 각자 책임이 다르다.
쿼리 메서드
메서드 이름 규칙으로 JPQL을 자동 생성한다. findBy, countBy, existsBy 같은 접두사에 필드명과 And/Or, Between/LessThan/Like/In/OrderBy 키워드를 조합한다. 메서드 이름이 엔티티 필드와 맞는지 검증되므로 오타나 잘못된 필드명을 일찍 잡는다.
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByName(String name);
Optional<User> findByEmailAndStatus(String email, UserStatus status);
List<User> findByCreatedAtBetweenOrderByNameAsc(LocalDateTime start, LocalDateTime end);
long countByStatus(UserStatus status);
boolean existsByEmail(String email);
}
이름으로 표현되는 단순 조회에 적합하다. 조건이 서너 개를 넘어 findByNameAndStatusAndCreatedAtBetweenAndRoleNot...처럼 길어지면 더는 읽히지 않으므로, 그때는 다음 방법을 쓴다.
@Query
JOIN, 서브쿼리, 집계, CASE처럼 메서드 이름으로 표현하기 어려운 쿼리는 @Query에 JPQL로 직접 쓴다.
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u WHERE u.status = :status AND u.createdAt > :date")
List<User> findActiveUsersAfter(@Param("status") UserStatus status,
@Param("date") LocalDateTime date);
@Query("SELECT u.department, COUNT(u) FROM User u GROUP BY u.department")
List<Object[]> countByDepartment();
@Query(value = "SELECT * FROM users WHERE MATCH(name, bio) AGAINST(?1)",
nativeQuery = true)
List<User> fullTextSearch(String keyword);
}
nativeQuery = true로 DB 특화 SQL도 쓸 수 있지만 이식성을 잃으므로 가능하면 JPQL을 쓴다. @Query는 쿼리가 고정되어, 조건이 들어왔다 빠졌다 하는 동적 쿼리를 표현하기 어렵다.
QueryDSL
검색 화면처럼 어떤 조건이 채워질지 런타임에 결정되면, @Query로는 문자열을 조립해야 해서 지저분해진다. QueryDSL은 타입 세이프하게 조건을 조립하고, 조건이 null이면 자동으로 뺀다. if-else 분기 없이 동적 쿼리를 작성할 수 있다.
@Repository
@RequiredArgsConstructor
public class UserQueryRepository {
private final JPAQueryFactory queryFactory;
public List<User> searchUsers(UserSearchCondition condition) {
return queryFactory
.selectFrom(user)
.where(
nameContains(condition.getName()),
statusEq(condition.getStatus()),
createdAtBetween(condition.getStartDate(), condition.getEndDate())
)
.orderBy(user.createdAt.desc())
.fetch();
}
private BooleanExpression nameContains(String name) {
return StringUtils.hasText(name) ? user.name.contains(name) : null; // null이면 조건에서 제외
}
private BooleanExpression statusEq(UserStatus status) {
return status != null ? user.status.eq(status) : null;
}
private BooleanExpression createdAtBetween(LocalDateTime start, LocalDateTime end) {
if (start == null && end == null) return null;
if (start == null) return user.createdAt.loe(end);
if (end == null) return user.createdAt.goe(start);
return user.createdAt.between(start, end);
}
}
where()에 넘긴 인자 중 null은 무시되므로, 조건마다 if로 분기하던 코드가 메서드 한 줄씩으로 정리된다.
무엇을 언제 쓰나
세 방법은 한 프로젝트에서 섞어 쓴다. 어차피 같은 SQL이 나가므로 기준은 성능이 아니라 표현력과 가독성이다.
- 이름으로 표현되는 단순 조회 → 쿼리 메서드
- JOIN/집계가 있지만 조건이 고정된 정적 쿼리 → @Query (JPQL)
- 조건이 동적으로 변하는 검색 → QueryDSL
대량 처리는 예외다. Repository의 saveAll()은 엔티티마다 개별 INSERT/UPDATE를 날려 대량 데이터에 비효율적이다. 이때는 @Modifying + @Query의 벌크 연산이나 JDBC 배치를 쓴다. 성능 우위가 아니라 변경 감지를 거치는 건당 처리 대신 단일 문장 일괄 처리를 택하는 기능적 선택이다.