Developer.

[멋사 백엔드 19기] TIL 43일차 Spring Data Jpa

📂 목차


📚 본문

Spring Data JPA란?

Spring Data JPAJPA(Java Persistence API) 를 기반으로 한 스프링 프레임워크의 데이터 접근 추상화 도구입니다. 복잡한 데이터베이스 접근 코드를 줄이고, 인터페이스 기반의 선언적 방식으로 CRUD 및 쿼리 기능을 쉽게 구현할 수 있도록 도와줍니다. 이를 통해 개발자는 비즈니스 로직에 집중할 수 있습니다.

Spring Data JPA의 동작 구조

Spring Data JPAEntityManager 를 사용해 데이터베이스와 통신한다. Repository 인터페이스를 정의하면 스프링이 런타임에 Proxy 객체를 생성하여 실제 구현체를 제공하고 이 Proxy 는 메서드 호출 시 적절한 JPA 쿼리를 실행한다.

  • EntityManager: JPA의 핵심 인터페이스로, 엔티티의 생명주기 관리 및 쿼리 실행 담당
  • Repository: 개발자가 정의하는 인터페이스
  • Proxy: 스프링이 자동 생성하는 구현체로, 메서드 호출을 실제 DB 쿼리로 변환

Repository 계층의 이해

Spring Data JPA 는 여러 Repository 인터페이스를 제공하는데 Crud 는 우선 Spring Data 에서 공통적으로 제공하는 기능이며 이를 확장하는 Spring Data JPA 의 JpaRepository 가 있다.

JpaRepository

JpaRepository 어노테이션은 CrudRepository 를 확장하며 페이징 기능과 정렬 기능을 기본적으로 제공하게 된다.

Repository
 └─ CrudRepository<T, ID>
     └─ ListCrudRepository<T, ID>

 └─ PagingAndSortingRepository<T, ID>
     └─ ListPagingAndSortingRepository<T, ID>

QueryByExampleExecutor<T> + 
ListPagingAndSortingRepository<T, ID> + 
ListCrudRepository<T, ID>
  └─ JpaRepository<T, ID>

필요한 기능을 쓸 때는 해당 인터페이스 내부의 메서드들을 보면서 사용하는 것이 좋다. 또한 JpaRepository 는 기본적으로 Iterator 보단 List 를 반환하도록 짜여져 있기 때문에 성능적으로 안좋을 수 있다. 이럴때는 JpaRepository 를 사용하기 보다는 직접 BaseRepository 를 만들어서 사용하는 것이 좋다.

쿼리 메서드(Query Method)

쿼리 메서드 자동 쿼리 생성 기능 사용

Spring Data JPA는 메서드 이름으로 쿼리를 자동 생성한다. 메서드 이름을 작성할 때는 다음과 같은 규칙으로 작성한다. 이전에도 다뤘기에 간단히 정리하고 넘어간다.

Prefix

  • find…By: 조회 (가장 많이 사용됨)
  • read…By: 조회 (find와 동일 기능)
  • get…By: 조회 (find와 동일 기능)
  • query…By: 조회 (find와 동일 기능)
  • count…By: 조건에 맞는 개수 조회
  • exists…By: 조건 존재 여부 확인
  • delete…By: 조건에 맞는 데이터 삭제
  • remove…By: delete와 동일 기능

Property Expressions

  • Is, Equals: =findByName(String name)
  • IsNot, Not: !=findByStatusNot(String status)
  • LessThan: <findByAgeLessThan(int age)
  • LessThanEqual: <=findByAgeLessThanEqual(int age)
  • GreaterThan: >findByAgeGreaterThan(int age)
  • GreaterThanEqual: >=findByAgeGreaterThanEqual(int age)
  • Between: BETWEENfindByCreatedAtBetween(Date start, Date end)
  • Like: LIKE (패턴 매칭) — findByNameLike(String name)
  • NotLike: NOT LIKEfindByNameNotLike(String name)
  • StartingWith: LIKE 'abc%'findByNameStartingWith(String prefix)
  • EndingWith: LIKE '%abc’ — findByNameEndingWith(String suffix)
  • Containing: LIKE '%abc%'findByNameContaining(String keyword)
  • In: IN (...)findByIdIn(List<Long> ids)
  • NotIn: NOT IN (...)findByIdNotIn(List<Long> ids)
  • True, False: boolean 조건 — findByActiveTrue()
  • IsNull: IS NULLfindByDeletedAtIsNull()
  • IsNotNull: IS NOT NULLfindByDeletedAtIsNotNull()
  • Before: < (날짜 전) — findByCreatedAtBefore(LocalDate date)
  • After: > (날짜 후) — findByCreatedAtAfter(LocalDate date)

And, Or 을 붙여 속성 표현식 여러개 사용 가능

Limit(Top / First)

조회 개수를 제한할 때 사용하며, 접두사 쪽에 prefix 와 by 사이에 사용한다.

  • Top{n}, First{n}: 상위 n 개 조회

Distinct

마찬가지로 접두사 쪽에 prefix 와 by 사이에 사용

findDistinctByEmail(String email);
findDistinctTop3ByOrderByScoreDesc();

IgnoreCase

속성 표현식 마지막에 추가

findByUsernameIgnoreCase(String username);
findByEmailContainingIgnoreCase(String keyword);

반환 타입

  • Entity: 단일 엔티티 반환 (결과 없으면 null)
  • Optional: 단일 결과를 Optional로 반환
  • Collection: 여러 개 결과 반환
  • Page: 페이징 처리된 결과 반환
  • Slice: 다음 페이지 존재 여부만 있는 슬라이스 반환
  • Stream: Java Stream으로 반환
  • long, int: count, delete 등 숫자 결과 반환
  • boolean: 존재 여부 반환
  • Future, CompletableFuture: 비동기 반환

@Query 애너테이션으로 직접 JPQL 쿼리 작성

JPQL(Java Persistence Query Language) 은 객체 지향 쿼리 언어로, 엔티티 객체를 대상으로 쿼리 작성을 한다.

String jpql = "SELECT u FROM User u WHERE u.age > :age";
List<User> users = em.createQuery(jpql, User.class)
                     .setParameter("age", 18)
                     .getResultList();

이를 내부적으로 Spring Data JPA 가 자동으로 생성시켜주어 다음과 같이 작성만 하면 파라미터와 매핑을 시킬 수 있게 된다.

// 1번째, 2번째 파라미터를 ?1, ?2로 지정
@Query("SELECT u FROM User u WHERE u.age > ?1 AND u.status = ?2")
List<User> findByAgeAndStatus(int age, String status);

// 날짜 범위 조회
@Query("SELECT o FROM Order o WHERE o.createdAt BETWEEN ?1 AND ?2")
List<Order> findOrdersBetweenDates(LocalDate start, LocalDate end);

// :email 이 메서드 파라미터 email과 매핑
@Query("SELECT u FROM User u WHERE u.email = :email")
User findByEmailNamed(@Param("email") String email);

@Query("SELECT u FROM User u WHERE u.age >= :minAge AND u.age <= :maxAge")
List<User> findByAgeRange(@Param("minAge") int minAge, @Param("maxAge") int maxAge);

// 페이징 및 정렬 기능
@Query("SELECT u FROM User u WHERE u.status = :status ORDER BY u.createdAt DESC")
Page<User> findByStatusWithPaging(@Param("status") String status, Pageable pageable);
Path Expression

JPQL 에서는 엔티티의 필드를 속성 경로로 표현할 수 있는데,

자식 엔티티 접근

@Entity
public class Parent {
    @Id private Long id;
    @OneToMany(mappedBy = "parent") private List<Child> children;}
@Entity
public class Child {
    @Id private Long id;
    private String name;
    @ManyToOne private Parent parent;}

위와 같이 있다고 쳤을때, ParentRepository 에서는 다음과 같은 쿼리를 작성할 수 있다.

SELECT c FROM Parent p JOIN p.children c WHERE c.name = 'Tom';

이는 아래와 같음

SELECT c.*
FROM parent p
JOIN child c ON c.parent_id = p.id
WHERE c.name = 'Tom'

따라서 테이블 이름이 아니라 엔티티 필드 이름을 그대로 써서 Java 객체 지향 프로그래밍을 그대로 적용시킬 수 있다. 익숙해지면 다음과 같은 코드도 작성 가능하다.

다단계 경로 탐색

SELECT o FROM Order o WHERE o.customer.address.city = 'Seoul'

이는 Native SQL 을 쓰면 여러 테이블 조인이 필요하지만 JPQL 을 쓰면 JPA 가 알아서 필요한 JOIN 을 생성시켜주기 때문에 신경을 쓰지 않아도 된다.

MySQL 조인 절 생략 어구
LEFT (OUTER) JOIN
RIGHT (OUTER) JOIN
(INNER) JOIN

네이티브 쿼리 사용

위와 똑같지만 nativeQuery = true 로 해주어야 한다.

@Query(value = "SELECT * FROM users WHERE status = ?1", nativeQuery = true)
List<User> findByStatusNative(String status);

이때 네이티브 쿼리를 쓸 때 단점이 있다.

  • 데이터베이스 의존성 증가: 특정 DB에 종속적이 되어 이식성 저하
  • JPA 최적화 미활용: 영속성 컨텍스트의 1차 캐시, 변경 감지 등 미활용

따라서 요약하면 왠만하면 쿼리 메서드를 사용, 그 다음 복잡한 것은 JPQL 사용, 그래도 안되면 SQL 사용

특정 컬럼 조회

단일 컬럼은 그냥 타입 리스트로 반환하면 된다.

List<String> findNameByAgeGreaterThan(int age);

하지만 여러 컬럼이 있을때 이땐 Object[] 로 반환할 수 있는데,

@Query(value = "SELECT name, email FROM user WHERE name LIKE %:name%", nativeQuery = true)
List<Object[]> findUsersByNameNative(@Param("name") String name);

이를 써도 되기는 하지만, 타입 안전성이 떨어지고, 가독성이 낮으며, 영속성 컨텍스트에서 관리가 안된다(마지막 문제는 나머지도 다 똑같다). 타입 안전성을 위해 다음을 보자.

Interface Projection

조회하고 싶은 컬럼 이름과 getter 메서드 이름을 맞춘 인터페이스를 정의하여 반환타입으로 인터페이스를 사용 가능하다.

public interface UserNameOnly { String getName(); }
public interface UserEmailOnly { String getEmail(); }

public interface UserNameAndEmail extends UserNameOnly, UserEmailOnly { }

// Repository query method
List<UserNameAndEmail> findByAgeGreaterThan(int age);

인터페이스로 정의했을 때의 단점은

  • set 을 할 수 없어 영속성 컨텍스트에 반영 X
  • 레퍼런스 형 반환 제한

레퍼런스 형을 반환할 수 없기에 다음 DTO 를 쓴다.

DTO Projection

엔티티 대신 필요한 데이터만 DTO 로 프로젝션하여 조회할 수 있다.

public record UserDTO(
    String name,
    String email
) { }

@Query("SELECT new com.example.UserDTO(u.name, u.email) FROM User u WHERE u.age > :age")
List<UserDTO> findByAge(@Param("age") int age);

DTO로 정의했을 때의 단점은

  • set 을 할 수는 있지만, JPA 가 추적하지 않기 때문에 영속성 컨텍스트에 반영 X

JPQL 에 들어가는 new (패키지명).(클래스명) 때문에 DTO 라는 것은 보통 패키지를 잘 이동하지 않는 쪽에다가 두는게 좋다. 즉, 모든 곳에서 쓰일 수 있는 common 패키지에 두는 것

JPQL 과 Criteria

Criteria API 는 타입 세이프한 동적 쿼리 생성에 유용하다.

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> user = cq.from(User.class);
cq.select(user).where(cb.greaterThan(user.get("age"), 18));
List<User> result = em.createQuery(cq).getResultList();

하지만 이는 너무 장황하다. 써야할 코드도 많으며, 가독성이 안좋고, 필드명을 문자열로 작성해야 해서 컴파일 시점에 체크가 불가하다. 따라서 Querydsl 을 사용하는데 나중에 보자.

Spring Data JPA 실무 활용

Specification (동적 쿼리)

조건에 따라 동적으로 쿼리 생성 가능하다.

public class UserSpecification implements Specification<User> {
    private String username;
    public UserSpecification(String username) { this.username = username; }
    @Override
    public Predicate toPredicate(Root<User> root, CriteriaQuery<?> query, CriteriaBuilder cb) {
        if (username == null) return cb.conjunction();
        return cb.equal(root.get("username"), username);
    }
}

Querydsl 과의 비교

Querydsl 은 타입 안전성과 복잡한 쿼리 작성에 강점이 있으며, Spring Data JPA 와 함께 사용 가능하다. Spring Data JPA 는 빠른 개발과 간결한 코드에 유리하기 때문에 이 두 장점을 합쳐서 혼합하여 사용하는게 좋다.

QUser user = QUser.user;

JPAQuery<User> query = new JPAQuery<>(em);
List<User> users = query
    .from(user)
    .where(user.status.eq("ACTIVE")
           .and(user.age.gt(20)))
    .orderBy(user.createdAt.desc())
    .fetch();

Querydsl 세팅은 검색해서 해보자.