본문 바로가기

Server Programming/Spring Boot Backend Programming

5장-3. Spring Data JPA (+ 리스너, Optional, 쿼리메서드,JPQL, Querydsl 동적쿼리)

반응형

요구사항

  • 게시물 등록/수정/삭제/조회
  • 게시물 목록 화면에서 검색/페이징 처리
  • 각각의 화면에서 기능 수행 후, 조건을 적용한 상태에서 PRG 패턴 적용

JPA의 핵심인 영속계층의 엔티티를 객체지향을 통해 처리하는 JpaRepository

  • 목적 : 데이터를 담은 엔티티 객체를 DB와 연동 관리
  • 영속 컨텍스트데이터베이스를 동기화해서 관리
  • 엔티티 객체 : PK를 가지는 자바의 객체로 엔티티의 생명주기를 관리하는 것이 핵심
  • @Id 어노테이션 : 엔티티 객체를 고유하게 식별하기 위해 구분하고 관리하기 위한 어노테이션
  • @Entity 어노테이션 : 엔티티 클래스를 나타내는 어노테이션
  • Spring Data JPA : 엔티티 객체를 JPA로 관리하기 위한 스프링 관련 라이브러리
  • JpaRepository 인터페이스 : 엔티티 객체 생성, 소멸, 예외처리를 자동으로 관리하기 위한 인터페이스

JPA 개발 순서

  1. 엔티티 객체의 생성을 위한 엔티티 클래스 작성
  2. 엔티티 객체의 생명주기를 관리하는 Repository 작성
  3. 엔티티 객체를 이용해 데이터를 처리하는 Service 작성
  4. JPA를 이용해 개발할 경우에는, 자주 테스트 코드를 작성해 동작을 확인해야 한다.

 

1. domain 패키지의 Board 엔티티

Board 엔티티 클래스 작성

-@Entity : 엔티티 클래스라는 것을 의미

-@Id : 기본키 생성전략을 지정하는 어노테이션

  • 기본키 생성전략
    • IDENTITY : DB에 위임 (MySQL, MariaDB : auto_increment)
    • SEQUENCE : 시퀀스 오브젝트 이용 (oracle : @SequnceGenerator)
    • TABLE : 키 생성용 테이블 이용 (@TableGenerator)
    • AUTO : 생성전략 기본값으로, 방언에 따라 자동 지정
package org.zerock.b01.domain;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bno;
    
    private String title;
    
    private String content;
    
    private String writer;
}

 

2. 공통 속성 처리하는 BaseEntity 추상클래스 엔티티

@MappeedSuperClass 어노테이션 : 데이터 추가된 시간이나 수정된 시간 같은 칼럼을 공통적으로 사용하는 클래스를 나타내는 어노테이션으로 해당 클래스를 상속하는 방식으로 활용한다.

 

(1) domain 패키지의 BaseEntity 추상클래스 엔티티 작성

-abstract : 단독으로 사용할 수 없는 추상클래스로 상속만 가능하다. 

-@MappedSuperclass : 공통 속성을 지정하는 클래스라는 것을 의미하는 어노테이션

-@Getter : 엔티티는 @Setter를 생성하면 안되는 사실상의 VO 클래스이므로 @Getter 어노테이션 적용

-@EntityListener(value = {AuditingEntityListener.class})

: 칼럼 생성 시간, 수정 시간을 지정하기 위해서는 리스너를 이용해 자동으로 시간값을 지정할 수 있도록 하기위한 어노테이션

-@EnableAuditing : AuditingEntityListener를 활성화 하기위해 프로젝트를 실행하는 main() 메서드에 추가해야하는 어노테이션

package org.zerock.b01.domain;

import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@MappedSuperclass
@EntityListeners(value = {AuditingEntityListener.class})
@Getter
abstract class BaseEntity {
    @CreatedDate
    @Column(name="regdate", updatable = false)
    private LocalDateTime regDate;

    @LastModifiedDate
    @Column(name ="moddate")
    private LocalDateTime modDate;

}

 

(2) BaseEntity 클래스 상속하도록 Board 엔티티 변경

-springframework.data.annotation에서 제공하는 어노테이션 : @CreatedDate, @LastModifiedDate

-@CreatedDate : 엔티티 생성시간 칼럼으로, DB 반영할 때 값 변경을 막기위해 updatable=false로 지정

-@LastModifiedDate : 엔티티 최종 수정 시간 칼럼

 

변경 전, Board 클래스

package org.zerock.b01.domain;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bno;
    
    private String title;
    
    private String content;
    
    private String writer;
}

 

변경 후, Board 엔티티 클래스

-javax.persistence 클래스에서 제공하는 어노테이션을 이용해 칼럼의 제한 조건 설정

-@Entity : 엔티티를 나타내는 어노테이션

-VO 클래스와 같은 어노테이션 적용 : @Getter, @Builder, @AllArgsConstructor, @NoArgsConstructor, @ToString

-기본키 설정을 위한 어노테이션 : @Id, @GeneratedValue

package org.zerock.b01.domain;

import lombok.*;

import javax.persistence.*;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Board extends BaseEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bno;

    @Column(length = 500, nullable = false)
    private String title;
    @Column(length = 2000, nullable = false)
    private String content;
    @Column(length = 50, nullable = false)
    private String writer;
}

 

(3) JpaRepository 인터페이스를 상속한 인터페이스 작성

-인터페이스 선언만으로 기본적인 DB 작업을 수행할 수 있도록 미리 작성되어있는 인터페이스

-JpaRepository 인터페이스를 상속하고, 엔티티 타입@Id 타입을 지정한 인터페이스를 작성한다.

package org.zerock.b01.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.zerock.b01.domain.Board;


public interface BoardRepository extends JpaRepository<Board, Long> {
    
}

 

JpaRepository 인터페이스 확장

  • 쿼리 메서드 : 메소드 이름이 쿼리가 되는 메서드를 이용
  • Querydsl : JPA를 이용해 동적 쿼리 작성해 검색 기능을 구현하기 위한 라이브러리

(4) SpringBootApplication에 @EnableJpaAuditing 어노테이션 추가

@SpringBootApplication
@EnableJpaAuditing

public class B01Application {

    public static void main(String[] args) {
        SpringApplication.run(B01Application.class, args);
    }

}

테스트 코드로 CRUD과 페이징 처리 확인

SQL 개발이 거의 없는 Spring Data JPA를 이용할 경우에는, 항상 테스트 코드로 동작 여부를 확인한다.

 

 

CRUD : Insert / select / update / delete

페이징 : Pageable / Page<E>

  1. Insert
  2. select
  3. update
  4. delete

 

CRUD

Insert

@Test
public void testInsert(){
    //닫힌 구간 : rangeClosed()
    //열린 구간 : range()

    //100개의 게시글 작성
    IntStream.rangeClosed(1, 100).forEach(i ->{
        Board board=Board.builder()
                .title("title..."+i)
                .content("content..."+i)
                .writer("user"+i)
                .build();

        //리포지토리에 저장
        //:DB와 동기화된 Board 객체를 반환해 리포지토리에 저장
        Board result = boardRepository.save(board);
        log.info("BNO : "+result.getBno());
    });
}

 

select

@Test
public void testSelect() {
    Long bno = 100L;
    //Optional : orElse, orElseGet, orElseThrow
    //orElse : default 값 지정해서, null값이 들어오면 defuault값으로
    //orElseGet : 값 만드는 supplier를 파라미터로 전달해 값이 없을 때 supplier로 값 생성
    //orElseThrow : overload된 두가지 메소드 중 하나는 빈값 하나는 supplier로 에러 넘겨준 경우
    Optional<Board> result = boardRepository.findById(bno);


    //자바 8의 경우 : 인자값이 필요하다.
    //자바 10부터 : 인자값이 없어도 가능하다.
    Board board = result.orElseThrow(IllegalArgumentException::new);
    //Board board = result.orElseThrow();


    log.info(board);
}

 

update

Update의 경우 등록 시간이 필요하므로 findById()로 가져온 객체를 이용해 수정하는 방법을 이용한다.
엔티티 객체는 최소한의 변경 혹은 변경이 없는 불변하게 설계하는 것이 권장된다.

 

(1) Board 엔티티에 change() 메서드 작성

-수정이 필요한 부분을 메소드로 설계하는 방식으로, title과 content만 변경 가능하도록 작성

public void change(String title, String content){
    this.title=title;
    this.content=content;
}

 

(2) 테스트 코드 작성

@Test
public void testUpdate(){

    Long bno = 100L;

    Optional<Board> result= boardRepository.findById(bno);

    Board board=result.orElseThrow(IllegalArgumentException::new);

    board.change("update..title 100", "update content 100");

    boardRepository.save(board);
}

 

delete

@Id 값을 이용해 deleteById()로 실행

@Test
public void testDelete(){
    Long bno = 1L;
    boardRepository.deleteById(bno);
}

 

update/delete의 경우, 영속 컨텍스트와 데이터베이스의 연동을 위해

1. select를 이용해 @Id 값을 이용해 엔티티 객체를 찾는다.

2. 영속 컨텍스트에 해당 객체를 저장한다.

3. 엔티티를 데이터베이스와 동기화한다.

 

페이징

직접 페이징 처리를 구현해야하는 스프링과 달리

스프링 부트에서는 미리 구현되어있는 Pageable 인터페이스를 이용해 PageRequest.of() 메서드를 통해 구현한다.

 

Pageable 인터페이스에 정의된 메서드

-페이지 번호는 0부터 시작한다.

  • PageRequest.of(페이지 번호, 사이즈)
  • PageRequest.of(페이지 번호, 사이즈, Sort)
  • PageRequest.of(페이지 번호, 사이즈, Sosrt.direction, 속성 ...)

 

 

Page<T>

Pageable 인터페이스를 사용할 때 반환하는 리턴 타입으로, count 처리를 자동으로 실행해준다.

메소드 마지막에 파라미터로 Pageable을 전달하고, 해당 메소드의 리턴 타입을 Page<T>로 설계하는 방식으로 구현한다.

 

: JPA를 이용할 경우, JpaRepository 구현한 리포지토리에서 findAll() 메서드의 파라미터로 Pageable 인스턴스를 전달

 

페이징 처리를 위한 테스트 코드 작성

: 0페이지부터, 10개의 데이터, bno기준 내림차순

@Test
public void testPaging(){
    Pageable pageable = PageRequest.of(0, 10, Sort.by("bno").descending());

    Page<Board> result = boardRepository.findAll(pageable);

    //출력
    log.info("total count :"+result.getTotalElements());
    log.info("total pages :"+result.getTotalPages());
    log.info("page number :"+result.getNumber());
    log.info("page size :"+result.getSize());

    List<Board> todoList = result.getContent();
    todoList.forEach(board -> log.info(board));
}

 


JPA에서 제공하는 쿼리 방식

  1. 쿼리 메서드 작성
  2. @Query 어노테이션과 JPQL 작성
  3. Querydsl을 이용한 동적 쿼리 작성

 

쿼리 메서드 작성

-키워드와 칼럼을 결합하는 쿼리

-'findBy', 'get'으로 시작해 칼럼명과 키워드를 결합한다.

Page<Board> findByTitleContainingOrderByBnoDesc(String keyword, Pageable pageable);

 

@Query 어노테이션과 JPQL 작성

-JPQL : @Query 어노테이션의 속성으로 value로 작성하는 문자열로 JPA에서 사용하는 쿼리 언어

-SQL과 달리 DB 종속적이지 않다.

-SQL과 달리 엔티티 타입을 이용해 엔티티 속성을 이용한다.

@Query("select b from Board b where b.title like concat('%', :keyword, '%')")
Page <Board> findKeyword(String keyword, Pageable pageable);

 

@Query 어노테이션 특징

  • 쿼리메서드와 다르게 복잡한 쿼리 실행 가능
  • 원하는 속성들만 추출해 Object[] / DTO로 처리 가능
  • nativeQuery 속성값을 true로 지정하면, 특정 DB에 종속적인 SQL도 사용 가능
  • 정적으로 고정되므로 검색과 같은 동적 쿼리를 작성하기 어렵다
@Query(value = "select now()", nativeQuery=true)
String getTime();

Querydsl을 이용한 동적 쿼리 작성

-자바 코드를 이용해 타입의 안정성 유지 가능한 상태에서 쿼리 작성 가능

-여러 종류의 속성이 존재하는 복합적인 검색 조건에 대해 처리 가능하다.

-Q도메인 : 기존의 엔티티 클래스를 Querydsl을 사용하기 위해 작성해야 하는 클래스

 

1. 의존성 추가

//queryDsl
implementation 'com.querydsl:querydsl-jpa:5.0.0'

annotationProcessor(
        "javax.persistence:javax.persistence-api:2.2",
        "javax.annotation:javax.annotation-api:1.3.2",
        "com.querydsl:querydsl-apt:5.0.0"
)
//querydsl
sourceSets {
    main{
        java{
            srcDirs=["$projectDir/src/main/java", "$projectDir/build/generated"]
        }
    }
}

 

2. QueryDsl 설정 확인

-gradle-other-compileJava task 실행

-build폴더에 QBoard 클래스 생성 확인

 

3. 기존의 Repository와 Querydsl 연동

(1) Querydsl 이용할 인터페이스 선언

(2) 인터페이스 이름+Impl 이름으로 클래스 선언
: QuerydslRepositorySupport라는 부모 클래스 지정해 인터페이스 구현

(3) 기존의 Repository에 부모 인터페이스로 Querydsl 인터페이스 지정

 

(1) Querydsl 이용할 인터페이스 선언

-repository 패키지에 search 패키지 추가 후, BoardSearch 인터페이스 선언

public interface BoardSearch{
    Page<Board> search1(Pageable pageable);
}

 

(2) 인터페이스 이름+Impl 이름으로 클래스 선언
: QuerydslRepositorySupport 클래스를 상속하고, BoardSearch 인터페이스 구현하는 BoardSearchImpl 클래스 작성

public class BoardSearchImpl extends QuerydslRepositorySupport implements BoardSearch {
    
    public BoardSearchImpl(){
        super(Board.class);
    }
    
    @Override
    public Page<Board> search1(Pageable pageable){
        return null;
    }
}

 

(3) 기존의 Repository에 부모 인터페이스로 Querydsl 인터페이스 지정

-BoardRepository 선언부에 BoardSearch 인터페이스 상속 추가

package org.zerock.b01.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.zerock.b01.domain.Board;
import org.zerock.b01.repository.search.BoardSearch;


public interface BoardRepository extends JpaRepository<Board, Long>, BoardSearch {
    
    @Query(value = "select now()", nativeQuery = true)
    String getTime();

}

 

4. Q도메인을 이용한 쿼리 작성

-Querydsl : 타입 기반의 코드로 JPQL 쿼리 생성

-Q도메인 : JPQL 쿼리를 대신 작성해주는 클래스

 

(1) BoardSearchImpl에서 Q도메인을 이용해 search1() 메서드 작성

@Override
public Page<Board> search1(Pageable pageable){

    // 1. JPQL 작성해주는 객체
    QBoard board=QBoard.board;
    //2. select ~ from board
    JPQLQuery<Board> query = from(board);
    //3. where title like...
    query.where(board.title.contains("1"));
    
    return null;
}

 

(2) where / group by / join와 같은 복잡한 처리 수행

@Override
public Page<Board> search1(Pageable pageable){

    // 1. JPQL 작성해주는 객체
    QBoard board=QBoard.board;
    //2. select ~ from board
    JPQLQuery<Board> query = from(board);
    //3. where title like...
    query.where(board.title.contains("1"));
    //4. 쿼리를 수행한 결과를 반환하는 리스트
    List<Baord> list = query.fetch();
    //5. count() 쿼리 실행해 count 변수에 저장
    long count=query.fetchCount();
    
    return null;
}

 

(3) 테스트 코드 작성

@Test
public void testSearch1(){
    Pageable pageable=PageRequest.of(1, 10, Sort.by("bno").descending());
    
    boardRepository.search1(pageable);
}

 

5. Querydsl로 Pageable 처리

-상속한 QuerydslRepositorySupport 클래스의 기능을 이용

-getQuerydsl() 메서드 : 작성한 쿼리를 반환

-applyPagination() 메서드 : 작성한 쿼리에 페이징을 적용하는 메서드로, 파라미터로 pageable 인스턴스와 쿼리를 전달한다.

 

페이징 적용 전, search1() 메서드

@Override
public Page<Board> search1(Pageable pageable){

    // 1. JPQL 작성해주는 객체
    QBoard board=QBoard.board;
    //2. select ~ from board
    JPQLQuery<Board> query = from(board);
    //3. where title like...
    query.where(board.title.contains("1"));

    return null;
}

 

페이징 적용 후, search1() 메서드

@Override
public Page<Board> search1(Pageable pageable){

    // 1. JPQL 작성해주는 객체
    QBoard board=QBoard.board;
    //2. select ~ from board
    JPQLQuery<Board> query = from(board);
    //3. where title like...
    query.where(board.title.contains("1"));
    //4. paging 적용
    this.getQuerydsl().applyPagination(pageable, query);
    //5. 쿼리 실행해 리스트 객체에 결과 저장
    List<Board> list = query.fetch();
    //6. count 변수에 쿼리 결과에서 count() 메소드 실행해 결과 저장
    long count= query.fetchCount();
    
    return null;
}

 


Querydsl로 검색 조건과 목록 처리

Querydsl로 검색 조건을 구현하고 JPQL 생성

'제목(t), 내용(c), 작성자(w)'의 조합 + 페이징 처리

 

Querydsl로 검색 조건 구현

  • BooleanBuilder : JPQL에 '()'를 작성하기 위한 객체
  • 검색을 위한 메소드 선언과 테스트
    • types [] : 여러 가지 검색 조건
    • keyword : 키워드
  • PageImpl를 이용한 Page<T> 반환
    • PageImpl : 페이징 처리시 Page<T> 타입을 반환하는 불편함을 해소하기 위해 제공하는 클래스로
      PageImpl를 이용하면 3개의 파라미터를 전달해 Page<T>를 생성한다.
      • List<T> : 실제 목록 데이터
      • Pageable : 페이지 관련 정보 객체
      • long : 전체 개수

BooleanBuilder

-'제목이나 내용'에 키워드가 존재하고, bno가 0보다 클 때

-or은 '()'로 묶어서 하나로 만든다.

@Override
public Page<Board> search1(Pageable pageable){

    // 1. JPQL 작성해주는 객체
    QBoard board=QBoard.board;
    //2. select ~ from board
    JPQLQuery<Board> query = from(board);

    BooleanBuilder booleanBuilder = new BooleanBuilder();

    //3. title like...
    booleanBuilder.or(board.title.contains("11"));

    //4. content like...
    booleanBuilder.or(board.content.contains("11"));

    //5. where에 추가
    query.where(booleanBuilder);

    //6. where에 추가
    query.where(board.bno.gt(0L));
    //booleanBuilder을 이용하면
    //where (title like ) or content like가 되어서 or 조건을 하나로 묶어서 처리한다.

    //7. paging 적용
    this.getQuerydsl().applyPagination(pageable, query);
    //8. 쿼리 실행해 리스트 객체에 결과 저장
    List<Board> list = query.fetch();
    //9. count 변수에 쿼리 결과에서 count() 메소드 실행해 결과 저장
    long count= query.fetchCount();

    return null;
}

 

실행된 쿼리문

    select
        board0_.bno as bno1_0_,
        board0_.moddate as moddate2_0_,
        board0_.regdate as regdate3_0_,
        board0_.content as content4_0_,
        board0_.title as title5_0_,
        board0_.writer as writer6_0_ 
    from
        board board0_ 
    where
        (
            board0_.title like ? escape '!' 
            or board0_.content like ? escape '!'
        ) 
        and board0_.bno>? 
    order by
        board0_.bno desc limit ?,
        ?

 

검색을 위한 메소드 선언과 테스트

(1) BoardSearch 인터페이스에 다양한 조건으로 검색을 수행하는 searchAll() 메서드 작성

public interface BoardSearch{
    Page<Board> search1(Pageable pageable);
    Page<Board> searchAll(String[] types, String keyword, Pageable pageable);
}

 

(2) BoardSearchImpl에 searchAll() 메서드 구현

-BooleanBuilder를 이용해 or 조건 하나로 묶기

-PageImpl를 이용한 Page 반환

@Override
public Page<Board> searchAll(String[] types, String keyword, Pageable pageable) {

    QBoard board = QBoard.board;
    JPQLQuery<Board> query = from(board);

    //검색 조건과 키워드가 있다면
    if( (types != null && types.length > 0) && keyword != null ){

        BooleanBuilder booleanBuilder = new BooleanBuilder(); // '('

        //검색 조건 개수만큼 반복
        for(String type: types){

            switch (type){
                case "t":
                    booleanBuilder.or(board.title.contains(keyword));
                    break;
                case "c":
                    booleanBuilder.or(board.content.contains(keyword));
                    break;
                case "w":
                    booleanBuilder.or(board.writer.contains(keyword));
                    break;
            }
        }//end for
        query.where(booleanBuilder);
    }//end if

    //bno > 0
    query.where(board.bno.gt(0L));

    //쿼리문 마지막에 paging 처리한다.
    this.getQuerydsl().applyPagination(pageable, query);

    List<Board> list = query.fetch();

    long count = query.fetchCount();

    //Querydsl에서 페이징 처리를 도와주는 PageImpl 클래스에 3가지 파라미터를 전달해 페이징 처리
    return new PageImpl<>(list, pageable, count);
}

 

(3) 테스트 코드 작성

@Test
public void testSearchAll(){
    String[] types= {"t", "c","w"};
    String keyword="1";


    Pageable pageable=PageRequest.of(0, 10, Sort.by("bno").descending());

    Page<Board> result= boardRepository.searchAll(types, keyword, pageable);

    log.info(result.getTotalPages());

    log.info(result.getSize());

    log.info(result.getNumber());

    log.info(result.hasPrevious() +": "+result.hasNext());

    result.getContent().forEach(board -> log.info(board));
}

 

반응형