본문 바로가기
독서/스프링 부트 핵심 가이드

[스프링 부트 핵심 가이드] Chapter 8장. Spring Data JPA 활용

by JayAlex07 2023. 9. 16.

스프링 부트 핵심 가이드

img

 

 

[스프링 부트 핵심 가이드] Chapter 8장. Spring Data JPA 활용

JPQL

 

JPA Query Language의 줄임말로 JPA에서 사용할 수 있는 쿼리를 의미한다

  • JPQL은 엔티티 객체를 대상으로 수행하는 쿼리다

 

 

쿼리 메서드

 

주로 Repository라는 패키지를 만들어서, 그 안에 Repository 인터페이스를 만든다

 

그리고 인터페이스는 JpaRepository 클래스를 상속 받아서 다양한 CRUD 메서드를 사용할 수 있다

 

쿼리 메서드는 크게 동작을 결정하는 주제 (Subject)와 서술어 (Predicate)로 구분한다

  • 'find.... By', 'exists... By'와 같은 키워드로 쿼리 주제를 정하며 'By'는 서술어의 시작을 나타내는 구분자 역할을 한다
// (리턴 타입) + {주제 + 서술어(속성)}
List<ClassExample> findByNameOrAge(String name, int age);

 

조회 키워드

  • find...By | read...By | get...By | query...By | search...By | stream...By
Optional<ClassExample> findByName(String name);
List<ClassExample> findAllByName(String name);
ClassExample findByName(String name);

 

exists... By

  • 특정 데이터가 존재하는지 확인하는 키워드 (리턴 타입으로 boolean 타입을 사용해야 한다)
boolean existsByName(String name);

 

count...By

  • 조회 쿼리를 수행한 후 쿼리 결과로 나온 데이터의 개수를 리턴한다
long countByName(String name);

 

delete...By, remove...By

  • 삭제 쿼리를 수행한다
  • 리턴 타입이 없거나 삭제한 횟수를 리턴한다
void deleteByName(String name);
long removeByName(String name);

 

...First{number}..., ...Top{number}...

  • 조회된 결과값의 개수를 제한한느 키워드다
  • 한 번의 동작으로 여러 건을 조회할 때 사용되며, 단 건으로 조회하기 위해서는 {number}를 생략하면 된다
List<ClassExample> findFirst5ByName(String name);
List<ClassExmple> findTop10ByName(String name);

 

 

쿼리 메서드의 조건자 키워드

 

Is

  • 값의 일치를 조건으로 사용하는 조건자 키워드다
  • 생략되는 경우가 많으며 Equals와 동일한 기능을 수행한다
ClassExample findByNumberIs(Long number);
ClassExample findByNameEquals(String name);

 

(Is)Not

  • 값의 불일치를 조건으로 사용하는 키워드다
  • Is는 생략하고 Not 키워드만 사용할 수 있다
ClassExample findByNameIsNot(String name);
ClassExample findByNumberNot(Long number);

 

(Is)Null, (Is)NotNull

  • 값이 null인지 검사하는 조건자 키워드다
List<ClassExample> findByCountryNull();
List<ClassExample> findByCountryIsNull();
List<ClassExample> findByPetNotNull();
List<ClassExample> findByPetIsNotNull();

 

(Is)True, (Is)False

  • boolean 타입으로 지정된 칼럼값을 확인하는 키워드다
  • 엔티티 안에 boolean 타입을 사용하는 칼럼이 없으면, 사용을 못 한다
ClassExample findByCheckedTrue();
ClassExample findByCheckedIsTrue();
ClassExample findByCheckedFalse();
ClassExample findByCheckedIsFalse();

 

And, Or

  • 여러 조건을 묶을 때 사용한다
ClassExample findByNameAndCountry(String name, String country);
ClassExample findByNameOrCountry(String name, String country);

 

(Is)GreaterThan, (Is)LessThan, (Is)Between

  • 숫자나 datetime 칼럼을 대상으로 비교 연산에 사용할 수 있는 조건자 키워드다
  • GreaterThan, LessThan 키워드는 비교 대상에 대한 초과/미만의 개념이고, 경계값을 포함하려면 Equal 키워드를 추가하면 된다
List<ClassName> findByAgeGreaterThan(Long age);                //초과
List<ClassName> findByAgeIsGreaterThan(Long age);            //초과    
List<ClassName> findByAgeIsGreaterThanEqual(Long age);        //이상
List<ClassName> findByAgeIsLessThan(Long age);                //미만
List<ClassName> findByAgeLessThan(Long age);                //미만
List<ClassName> findByAgeLessThanEqual(Long age);            //이하
List<ClassName> findByAgeIsBetween(Long age1, Long age2);
List<ClassName> findByAgeBetween(Long age1, Long age2);

 

(Is)StartingWith(==StartsWith), (Is)EndingWith(==EndsWith), (Is)Containing(==Contains), (Is)Like

  • 칼럼값에서 일부 일치 여부를 확인하는 조건자 키워드다
  • StartingWith 또는 StartsWith는 문자열 앞
  • EndingWith 또는 EndsWith는 문자열 뒤
  • Containing 또는 Contains는 문자열 안에 포함이 되어 있는지에 대한 여부
  • Like는 코드 수준에서 메서드를 호출하면서 전달하는 값에 %를 명시적으로 입력해야 한다
List<ClassName> findByNameLike(String name);
List<ClassName> findByNameIsLike(String name);
List<ClassName> findByCountryContains(String country);
List<ClassName> findByCountryContaining(String country);
List<ClassName> findByCountryIsContaining(String country);
List<ClassName> findByCompanyStartsWith(String company);
List<ClassName> findByCompanyStartingWith(String company);
List<ClassName> findByCompayIsStartingWith(String company);
List<ClassName> findByUserNameEndsWith(String userName);
List<ClassName> findByUserNameEndingWith(String userName);
List<ClassName> findByUserNameIsEndingWith(String userName);

 

정렬과 페이징 처리

 

정렬을 말 그대로 정렬이고, 페이징은 특히 어플리케이션에 많이 사용이된다

  • 한 페이지에 너무 많은 데이터가 있을 때에, 한 화면에 모든 데이터를 띄우면 어플리케이션이 정돈이 잘 안 된 것 처럼 보일 수 있다
  • 페이징을 통해, 한 페이지 당 몇 개의 데이터를 보일지 정할 수 있다

 

정렬

  • OrderBy를 사용하고, 오름차순이 기본이면 내림차순으로도 바꿀 수 있다
    • ASC : 오름차순
    • DESC : 내림차순
List<ClassName> findByNameOrderByAgeAsc(String name);
List<ClassName> findByNameOrderByAgeDesc(String name);

 

  • 여러 개를 정렬할 수 있다 (And를 꼭 안 붙여도 된다)
List<ClassName> findByNameOrderByAgeAscNumberDesc(String name);

 

  • 가독성을 위해 매개변수를 활요해 정렬할 수 있다
List<ClassName> findByName(String name, Sort.by(Order.asc("age"), Order.desc("number")));

 

 

페이징 처리

  • 데이터베이스의 레코드를 개수로 나눠 페이지를 구분하는 것
  • Page와 Pageable을 사용한다
// PlayerRepository
Page<Player> findByName(String name, Pageable pageable);
// PlayerRepositoryTest
PlayerRepository playerRepository;

Page<Player> playerPage = playerRepository.findByName("Alex", pageRequest.of(0,2));

System.out.println(playerPage.getContent());
  • .getContent() 메서드를 사용하면, 배열 형태로 값이 출력된다

 

of 메서드 매개변수 설명 비고
of(int page, int size) 페이지 번호 (0부터ㅅ 시작), 페이지당 데이터 개수 데이터를 정렬하지 않음
of(int page, int size, Sort) 페이지 번호, 페이지당 데이터 개수, 정렬 Sort에 의해 정렬
of(int page, int size, Direstion, String... properties) 페이지 번호, 페이지당 데이터 개수, 정렬 방향, 속성 (칼럼) Sort.by(direstion, properties)에 의해 정렬

 

 

@Query 어노테이션 활용하기

 

@Query 어노테이션을 활용하여 직접 JPQL을 작성할 수 있다

  • JPQL을 사용하면 JPA 구현체에서 자동으로 쿼리 문장을 해석하고 실행하게 된다
@Query("SELECT * FROM Players WHERE Players.name=?1")
List<Players> findByName(String name);
  • FROM 뒤에서 엔티티 타입을 지정하고 별칭을 생성한다
  • WHERE 문에는 SQL과 마찬가지로 조건을 지정한다
  • 조건문에서 사용한 '?1'은 파라미터를 전달받기 위한 인자에 해당한다
    • 여기서 '1'은 첫 번째 파라미터를 의미한다

 

@Query("SELECT * FROM Players WHERE Players.name= :name")
List<Players> findByNameParam(@Param("name") String name);
  • @Param 어노테이션을 사용할 수도 있다

 

@Query("SELECT p.name, p.age, p.number FROM Playeers AS p WHERE p.name= :name")
List<Object[]> findByNameParam2(@Param("name") String name);
  • 엔티티 타입이 아닌 원하는 칼럼의 값만 추출 할 수 있다
  • SELECT에 가져오고자 하는 칼럼을 지정하면 된다
  • 메서드에서는 Object 배열의 리스트 형태로 리턴 타입을 지정한다

 

 

QueryDSL 적용하기

 

@Query 어노테이션은 직접 문자열을 입력하기 떄문에 컴파일 시점에 에러를 잡지 못하고 런타임 에러가 발생할 수 있다

  • 어플리케이션이 실행된 후 로직이 실행되고 나서야 오류를 발견할 수 있다
  • 이런 문제를 해결하기 위해 사용되는 것이 QueryDSL이다

 

QueryDSL은 문자열이 아니라 코드로 쿼리를 작성할 수 있도록 도와준다

 

QueryDSL은 정적 타입을 이용해 SQL과 같은 쿼리를 생성할 수 있도록 지원하는 프레임워크다

  • QueryDSL이 제공하는 플루언트 API를 활용해 쿼리를 생성할 수 있다

 

장점

  • IDE가 제공하는 코드 자동 완성 기능을 사용할 수 있다
  • 문법적으로 잘못된 쿼리를 허용하지 않는다
  • 고정된 SQL 쿼리를 작성하지 않기 때문에 동적으로 쿼리를 생성할 수 있다
  • 코드로 작성하므로 가독성 및 생산성이 향상된다
  • 도메인 타입과 프로퍼티를 안전하게 참조할 수 있다

 

프로젝트 설정

 

기본적인 QueryDSL 사용하기

  • JPAQuery를 활용한 QueryDSL 테스트 코드
@PersistenceContext
EntityManager entityManger;

@Test
void queryDslTest() {
    JPAQuery<Player> query = new JPAQuery(entityManager);
    QPleyer qPlayer = QPlayer.player;

    List<Player> playerList = query
        .from(qPlayer)
        .where(qPlayer.name.eq("Alex"))
        .orderBy(qPlayer.age.asc())
        .fetch()
}
  • QueryDSL에 의해 생성된 Q도메인 클래스를 활용하는 코드이다
  • QueryDSL을 사용하기 위해서 JPAQuery 객체를 사용한다
    • JPAQuery는 엔티티 매니저 (EntityManager)를 활용해 생성한다
  • 그리고 빌더 형식으로 쿼리를 작성한다
    • List<T> fetch() : 조회 결과를 반환한다
    • T fetchOne : 단 한 건의 조회 결과를 반환한다
    • T fecthFirst() : 여러 건의 조회 결과 중 1건을 반환한다
    • Long fetchCount() : 조회 결과의 개수를 반환한다
    • QueryResult fetchResults : 조회 결과 리스트와 개수를 포함한 QueryResult를 반환한다

 

@PersistenceContext
EntityManager entityManger;

@Test
void queryDslTest() {
    JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(entityManager);
    QPleyer qPlayer = QPlayer.player;

    List<Player> playerList = jpaQueryFactory.selectFrom(qPlayer)
        .where(qPlayer.name.eq("Alex"))
        .orderBy(qPlayer.age.asc())
        .fetch()
}
  • JPAQueryFactory를 활용해서 코드를 작성할 수 있다
  • JPAQueryFactory는 select 절부터 작성이 가능하다
  • 전체 칼럼을 조회하지 않고 일부만 조회하고 싶으면 selectFrom()이 아닌 select()와 from() 메서드를 구분해서 사용하면 된다

 

 

실제 비즈니스 로직에 활용할 수 있게 설정하기

// QueryDSLConfiguration

@Configuration
pubic class QueryDSLConfiguration{
    @PersistenceContext
    EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory(){
        return new JPAQueryFactory(entityManager);
    }
}
  • 위에 테스트에서 매번 JPAQueryFactory를 초기화하지 않고, 스프링 컨테이너에서 가져다 쓸 수 있다

 

// JPAQueryFactory 빈을 활용한 테스트 코드

@Autowired
JPAQueryFactory jpaQueryFactory;

@Test
void queryDslTest() {
    QPlayer qPlayer = QPlayer.player;

    List<String> playerList = jpaQueryFactory
        .select(qPlayer.age)
        .from(qPlayer)
        .where(qPlayer.name.eq("Alex"))
        .orderBy(qPlayer.age.asc())
        .fetch();
}

 

 

QuerdslPredicateExecutor, QuerydslRepositorySupport 활용

 

 

스프링 데이터 JPA에서는 QueryDSL을 더욱 편하게 사용할 수 있게 QuerydslPredicateExecutor 인터페이스와 QuerydslRepositorySupport 클래스를 제공한다

 

 

QuerydslPredicateExecutor 인터페이스

  • JpaRepository와 함께 레포지토리에서 QueryDSL을 사용할 수 있게 인터페이스를 제공한다
public interface QPlayerRepository extends JpaRepository<Player, Long>,
    QuerydslPredicateExecutor<Player> {

}
  • QuerydslPredicateExecutor 인터페이스의 메서드는 대부분 Predicate 타입을 매개변수로 받는다
    • Predicate는 표현식을 작성할 수 있게 QueryDSL에서 제공하는 인터페이스다
  • Predicate는 간단하게 표현식으로 정의하는 쿼리로 생각하면 된다
// 레포지토리 테스트 클래스 생성
@SpringBootTest
public class QPlayerRepositoryTest {
    @Autowired
    QPlayerRepository qPlayerRepository;
}

@Test
public void queryDSLTest(){
    Predicate predicate = QPlayer.player.name.containsIgnoreCase("A")
        .and(QPlayer.player.age.between(10, 20));

    Optional<Player> foundPlayer = qPlayerRepository.findOne(predicate);
}

@Test
public void queryDSLTest2(){
    QPlayer qPlayer = QPlayer.player;

    Iterable<Player> playerList = qPlayerRepository.findAll(
        qPlayer.name.contains("Alex")
        .and(qPlayer.age.between(10, 20))
    );
}
  • QuerdslPredicateExecutor 인터페이스는 join이나 fetch 기능을 사용할 수 없다

 

 

QuerydslRepositorySupport 추상 클래스 사용하기

  • 가장 보편적인 방식은 CustomRepository를 활용해 레포지토리를 구현한다

  • JpaRepository와 QuerydslRepositorySupport는 Spring Date JPA에서 제공하는 인터페이스와 클래스다
  • 나머지 DomainRepository와 DomainRepositoryCustom, DomainRepositoryCustomImpl은 직접 구현을 한다
1. 처음에 사용했던 방식처럼 JpaRepository를 상속받는 DomainRepository를 생성한다
2. 직접 구현한 쿼리를 사용하기 위해서 JpaRepository를 상속받지 않는 레포지토리 인터페이스인 DomainRepositoryCustom을 생성한다. 이 인터페이스에 정의하고자 하는 기능들을 메서드로 정의한다
3. DomainRepositoryCustom에서 정의한 메서드를 사용하기 위해 DomainRepository에서 DomainRepositoryCustom을 상속받는다
4. DomainRepository에서 정의된 메서드 기반으로 실제 쿼리 작성을 하기 위해 구현체인 DomainRepositoryCustomImpl 클래스를 생성한다
5. DomainRepositoryCustomImpl 클래스에서느 다양한 방법으로 쿼리를 구현할 수 있지만 QueryDSL을 사용하기 위해 QueryDslRepositorySupport을 상속 받는다

 

 

  • DomainRepositoryCustom 인터페이스
public interface DomainRepositoryCustom {
    List<Domain> findByName(String name);
}

 

  • DomainRepositoryCustomImpl 클래스
    • QueryDSL을 활용하기 위해서 QuerydslRepositorySupport을 상속 받는다
    • DomainRepositoryCustom 인터페이스를 가지고 온다
@Component
public class DomainRepositoryCustomImpl extends QuerydslRepositorySupport 
    implements DomainRepositoryCustom {

    public DomainRepositoryImpl() {
        super(Domain.class);
    }

    @Override
    public List<Domain> findByName(String name) {
        QDomain domain = QDomain.domain;

        List<Domain> domainList = from(domain)
            .where(domain.name.eq(name))
            .select(domain)
            .fetch();

        return domainList;
    }
}

 

  • DomainRepository 인터페이스
@Repository("domainRepositorySupport")
public interface DomainRepository extends JpaRepository<Domain, Long>,
    DomainRepositoryCustom {

}
  • 기존 레포지토리를 생성하는 것과 동일하게 JpaRepository를 상속받아 구성한다
  • 이미 이전에 DomainRepository라는 이름이 이미 사용되고 있으면 빈 생성 시 출돌을 방지하기 위해 @Repository("domainRepositorySupport")라는 빈 이름을 설정해 준다

 

 

findByName() 메서드 테스트

@Autowired
DomainRepository domainRepository;

@Test
void findByNameTest(){
    List<Domain> domainList = domainRepository.findByName("A");
}

 

 

 

JPA Auditing 적용

 

 

Audit을 사용한다는 것은 '누가', '언제' 데이터를 생성했고 변경했는지 감시한다는 것이다

 

대표적으로 많이 사용되는 필드는

  • 생성 주체
  • 생성 일자
  • 변경 주체
  • 변경 일자
// ProjectApplication.java

@SpringBootApplication
public class ProjectApplication{
    public static void main(String[] args) {
        SpringApplication.run(PApplication.class, args);
    }
}
// Configuration.java

@Configuration
@EnableJpaAuditing
public class ProjectJpaAuditingConfiguration{

}

 

BaseEntity 만들기

  • 중복 코드를 없애기 위해 각 엔티티에 공통으로 들어가게 되는 칼럼(필드)를 하나의 클래스로 빼는 작업을 수행한다
    • 필수는 아니다
// BaseEntity.java

@Getter
@Setter
@ToString
@MappedSuperClass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {
    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}
  • @MappedSuperClass : JPA의 엔티티 클래스가 상속받을 경우 자식 클래스에게 매핑 정보를 전달한다
  • @EntityListeners : 엔티티를 데이터베이스에 적용하기 전후로 콜백을 요청할 수 있게 하는 어노테이션이다
  • AuditingEntityListener : 엔티티의 Auditing 정보를 주입하는 JPA 엔티티 리스너 클래스다
  • @CreatedDate : 데이터 생성 날짜를 자동으로 주입하는 어노테이션이다
  • @LastModifiedDate : 데이터 수정 날짜를 자동으로 주입하는 어노테이션이다
// Domain.java

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@Table(name = "domain")
public class Domain extends BaseEntity {
    @Id
    @GenerateValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Long age;

    @Column(nullable = false)
    private Long number;
}