Spring Boot (Chapter 5)[스프링 데이터 JPA]


4. 스프링 데이터 JPA

5.1 스프링 데이터 JPA 퀵스타트

JPA를 사용하기 위해서는 JPA 구현체인 하이버네이트를 비롯한 의존성을 추가하고, 복잡한 persistence.xml 설정을 위한 코드도 작성해야 한다. 이런 작업들을 JPA를 사용하는 프로젝트를 생성할 때마다 반복하는 것은 매우 비효율적이다. 스프링 부트는 JPA연동에 필요한 라이브러리들과 복잡한 XML 설정을 자동으로 처리하기 위해 JPA 스타터를 제공한다.

5.1.1 스프링 데이터 JPA 사용하기

application.yaml

# Datasource Setting
spring:
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:tcp://localhost/~/test
    username: sa
    password:

# JPA Setting
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true
    generate-ddl: false
    database-platform: org.hibernate.dialect.H2Dialect
    

logging:
  level:
    org.hibernate: info 
@Getter
@Setter
@RequiredArgsConstructor
@ToString
@Entity
@Table(name = "T_BOARD")
public class BoardEntity {

    @Id @GeneratedValue
    private Long seq;
    private String title;
    private String writer;
    private String content;
    @Temporal(TemporalType.TIMESTAMP)
    private Date createDate;
    private Long cnt;
}

어플리케이션을 실행하면 아래와 같이 콘솔에 출력된다.

Hibernate: 
    
    drop table if exists t_board CASCADE 
Hibernate: 
    
    drop sequence if exists hibernate_sequence
Hibernate: create sequence hibernate_sequence start with 1 increment by 1
Hibernate: 
    
    create table t_board (
       seq bigint not null,
        cnt bigint,
        content varchar(255),
        create_date timestamp,
        title varchar(255),
        writer varchar(255),
        primary key (seq)
    )

실행 결과를 통해서 hibernate_sequence와 테이블이 자동으로 생성되는 것을 확인할 수 있다.

엔티티를 작성했으면 CRUD 기능을 처리할 Repository 인터페이스를 작성해야 한다. Repository는 기존의 DAO와 동일한 개념으로 비즈니스 클래스에서는 이 Repository를 이용하여 실질적인 데이터베이스 연동을 처리한다. Repository 인터페이스는 스프링에서 제공하는 Repository중 하나를 상속하여 작성하면 된다.

springboot_5_1

가장 상위에 있는 Repository는 기능이 거의 없으므로 일반적으로는 CrudRepository를 주로 사용한다. CrudRepository 인터페이스는 기본적인 CRUD 기능을 제공한다. 만약 검색 기능이 필요하고 검색 결과 화면에 대해 페이징 처리를 하고자 할 경우에는 PagingAndSortingRepository를 사용하고, 스프링 데이터 JPA에서 추가한 기능을 사용하고 싶으면 JpaRepository를 사용하면 된다.

모든 인터페이스들은 공통적으로 두 개의 제네릭 타입을 지정하도록 되어있다. 예를 들어 CrudRepository는 다음과 같이 T, ID 두 개의 제네릭 타입을 지정해야 한다.

CrudRepository<T, ID>

// T: 엔티티의 클래스 타입
// ID: 식별자 타입(@Id로 매핑한 식별자 변수의 타입)
@Repository
public interface BoardRepository extends JpaRepository<BoardEntity, String> {

}

일반적으로 인터페이스를 정의한다는 것은 인터페이스를 구현한 클래스를 만들어 사용하겠다는 의미다. 인터페이스는 객체로 생성할 수 없고 다른 클래스들의 부모로만 사용되기 때문이다. 하지만 스프링 데이터 JPA를 사용하는 경우는 별도의 구현 클래스를 만들지 않고 인터페이스만 정의함으로써 기능을 사용할 수 있다. 이 말은 스프링 부트가 내부적으로 인터페이스에 대한 구현 객체를 자동으로 생성해준다는 것을 의미한다. 또한 JPA를 단독으로 사용했을 때, JPA를 이용해서 데이터베이스를 연동하기 위해서 사용했었던 EntityManagerFactory, EntityManager, EntityTransaction 같은 객체도 필요 없다. 이 모든 객체들의 생성과 활용이 스프링 데이터 JPA에서는 내부적으로 처리되기 때문이다.

  1. 등록기능테스트
     @ExtendWith(SpringExtension.class)
     @SpringBootTest
     public class BoardServiceTest {
    
         @Autowired
         private BoardRepository boardRepository;
    
         @Test
         public void testInsertBoard(){
             BoardEntity board = new BoardEntity();
    
             board.setTitle("첫 번째 게시글");
             board.setWriter("테스터");
             board.setContent("첫 번째 내용");
             board.setCreateDate(new Date());
             board.setCnt(0L);
    
             boardRepository.save(board);
         }
     }
    

    엔티티를 영속성 컨텍스트에 저장하기 위해서는 원래 JPA의 persist() 메서드를 사용했었다. 하지만 Repository 인터페이스를 사용할 때는 save() 메서드를 이용해서 등록한다.

     Hibernate: 
         call next value for hibernate_sequence
     Hibernate: 
         insert 
         into
             t_board
             (cnt, content, create_date, title, writer, seq) 
         values
             (?, ?, ?, ?, ?, ?)
    

    테스트 케이스를 실행하면 시퀀스로부터 일련번호를 얻어서 게시 글을 등록하는 SQL이 실행된다.

  2. 상세 조회 기능 테스트
     @ExtendWith(SpringExtension.class)
     @SpringBootTest
     public class BoardServiceTest {
    
         @Autowired
         private BoardRepository boardRepository;
    
         @Test
         public void testGetBoard(){
    
             BoardEntity board = boardRepository.findById(1L).get();
             System.out.println(board.toString());
         }
     }
    
     Hibernate: 
         select
             boardentit0_.seq as seq1_0_0_,
             boardentit0_.cnt as cnt2_0_0_,
             boardentit0_.content as content3_0_0_,
             boardentit0_.create_date as create_d4_0_0_,
             boardentit0_.title as title5_0_0_,
             boardentit0_.writer as writer6_0_0_ 
         from
             t_board boardentit0_ 
         where
             boardentit0_.seq=?
     BoardEntity(seq=1, title=Ttile, writer=테스터, content=Content, createDate=2022-04-22 14:19:35.152, cnt=0)
    

    데이터 하나를 조회하기 위해서는 findById()메서드를 이용한다. 그러면 Optional 타입의 객체가 리턴되는데, Optional의 get() 메서드를 이용하면 영속성 컨텍스트에 저장된 Board객체를 받아낼 수 있다.

  3. 수정 기능 테스트
     @ExtendWith(SpringExtension.class)
     @SpringBootTest
     public class BoardServiceTest {
    
         @Autowired
         private BoardRepository boardRepository;
    
         @Test
         public void testUpdateBoard(){
    
             System.out.println("# 조회 #");
             BoardEntity board = boardRepository.findById(1L).get();
    
             System.out.println("# 수정 #");
             board.setWriter("수정된 테스터 입니다.");
             boardRepository.save(board);
         }
     }
    
     # 조회 #
     Hibernate: 
         select
             boardentit0_.seq as seq1_0_0_,
             boardentit0_.cnt as cnt2_0_0_,
             boardentit0_.content as content3_0_0_,
             boardentit0_.create_date as create_d4_0_0_,
             boardentit0_.title as title5_0_0_,
             boardentit0_.writer as writer6_0_0_ 
         from
             t_board boardentit0_ 
         where
             boardentit0_.seq=?
     # 수정 #
     Hibernate: 
         select
             boardentit0_.seq as seq1_0_0_,
             boardentit0_.cnt as cnt2_0_0_,
             boardentit0_.content as content3_0_0_,
             boardentit0_.create_date as create_d4_0_0_,
             boardentit0_.title as title5_0_0_,
             boardentit0_.writer as writer6_0_0_ 
         from
             t_board boardentit0_ 
         where
             boardentit0_.seq=?
     Hibernate: 
         update
             t_board 
         set
             cnt=?,
             content=?,
             create_date=?,
             title=?,
             writer=? 
         where
             seq=?
    

    실행 결과를 보면 수정을 반영하기 직전에 다시 한 번 수정할 엔티티를 메모리에 올리고 수정 작업이 처리되기 때문에 두 번째 SELECT가 실행된 후에 UPDATE가 실행되었다.

  4. 삭제 기능 테스트
     @ExtendWith(SpringExtension.class)
     @SpringBootTest
     public class BoardServiceTest {
    
         @Autowired
         private BoardRepository boardRepository;
    
         @Test
         public void testDeleteBoard(){
    
             System.out.println("# 조회 #");
             BoardEntity board = boardRepository.findById(1L).get();
    
             System.out.println("# 삭제 #");
             boardRepository.delete(board);
    
         }
     }
    
     # 조회 #
     Hibernate: 
         select
             boardentit0_.seq as seq1_0_0_,
             boardentit0_.cnt as cnt2_0_0_,
             boardentit0_.content as content3_0_0_,
             boardentit0_.create_date as create_d4_0_0_,
             boardentit0_.title as title5_0_0_,
             boardentit0_.writer as writer6_0_0_ 
         from
             t_board boardentit0_ 
         where
             boardentit0_.seq=?
     # 삭제 #
     Hibernate: 
         select
             boardentit0_.seq as seq1_0_0_,
             boardentit0_.cnt as cnt2_0_0_,
             boardentit0_.content as content3_0_0_,
             boardentit0_.create_date as create_d4_0_0_,
             boardentit0_.title as title5_0_0_,
             boardentit0_.writer as writer6_0_0_ 
         from
             t_board boardentit0_ 
         where
             boardentit0_.seq=?
     Hibernate: 
         delete 
         from
             t_board 
         where
             seq=?
    

    삭제 과정도 수정 과정과 마찬가지로 삭제하기 전에 삭제할 엔티티를 영속성 컨텍스트에 올리는 SELECT 작업이 먼저 처리된 것을 확인할 수 있다.


5.1.2 쿼리 메서드 사용하기

정상적인 어플리케이션을 개발하기 위해서는 목록 검색과 관련된 기능이 반드시 필요하며, 이를 위해서는 다양한 조건의 쿼리를 사용할 수 있어야 한다. 일반적으로 JPA를 이용해서 목록 기능을 구현할 때는 JPQL(Java Persistence Query Language)을 이용하면 된다.

JPQL은 검색 대상이 테이블이 아닌 엔티티라는 것만 제외하고는 기본 구조와 문법이 기존의 SQL과 유사하다. 스프링 JPA에서는 이런 복잡한 JPQL을 메서드로 대신 처리할 수 있도록 쿼리메서드라는 특별한 기능을 제공한다. 쿼리 메서드는 메서드의 이름으로 필요한 쿼리를 만들어주는 기능으로, 몇 가지 네이밍 룰만 알면 바로 사용할 수 있다.

쿼리 메서드를 이용할 때 가장 많이 사용하는 문법은 검색하려는 엔티티에서 특정 변수의 값만 조회하는 것이다. 이때는 메서드 이름을 find로 시작하면서 조회할 변수들을 적절하게 조합하면 된다. 쿼리 메서드를 작성할 때 엔티티 이름은 생략할 수 있다. 엔티티 이름이 생략되면 현재 사용하는 Repository 인터페이스에 선언된 타입 정보를 기준으로 자동으로 엔티티 이름이 적용된다.

find + 엔티티 이름 + By + 변수 이름
Ex) findBoardEntityByTitle(): BoardEntity에서 title 변수 값만 조회한다. 
    findByTitle(): 엔티티 이름 생략

쿼리 메서드의 리턴 타입은 Page, Slice, List이며, 모두 Collection 타입이다. 이 중에서 가장 많이 사용하는 것은 Page와 List로서, 단순히 목록을 검색하려면 List를 사용하고 페이징 처리를 하려면 Page를 사용하면 된다.


@Repository
public interface BoardRepository extends JpaRepository<Board, Long> {

    List<Board> findByTitle(String searchKeyword);
}



@ExtendWith(SpringExtension.class)
@SpringBootTest
public class QueryMethodTest {

    @Autowired
    private BoardRepository boardRepository;

    @BeforeEach
    public void dataPrepared(){
        for (int i = 0; i < 200; i++) {
            Board board = new Board();
            board.setTitle("제목 " + i);
            board.setWriter("테스터 " + i);
            board.setContent("Content" + i);
            board.setCreateDate(new Date());
            board.setCnt(0L);
            boardRepository.save(board);
        }
    }

    @Test
    public void testFindByTitle(){
        List<Board> boardList = boardRepository.findByTitle("제목 1");
        System.out.println("검색결과");
        for (Board board : boardList){
            System.out.println("Title: " + board.getTitle());
        }
    }
}

테스트 케이스에서는 가장 먼저 BoardRepository를 이용해서 200개의 테스트 데이터를 등록하는 dataPrepared() 메서드를 구현했다.
@BeforeEach가 붙은 dataPrepared() 메서드는 테스트 메서드가 실행되기 전에 동작하여 테스트에서 사용할 데이터를 세팅한다.

쿼리 메서드 유형

키워드 생성되는 SQL
And findByLastnameAndFirstname where x.lastname = ?1 and x.firstname = ?2
Or findByLastnameOrFirstname where x.lastname = ?1 or x.firstname = ?2
Between findByStartDateBetween where x.startDate between ?1 and ?2
LessThan findByAgeLessThan where x.age < ?1
LessThanEqual findByAgeLessThanEqual where x.age <= ?1
GreaterThan findByAgeGreaterThan where x.age > ?1
GreaterThanEqual findByAgeGreaterThanEqual where x.age >= ?1
Before findByStartDateBefore where x.startDate < ?1
After findByStartDateAfter where x.startDate > ?1
IsNull findByAgeIsNull where x.age is null
IsNotNull, NotNull findByAge(Is)NotNull where x.age is not null
Like findByFirstnameLike where x.firstname like ?1
NotLike findByFirstnameNotLike where x.firstname not like ?1
StartingWith findByFirstnameStartingWith where x.firstname like ?1||’%’
EndingWith findByFirstnameEndingWith where x.firstname like ‘%’||?1
Containing findByFirstnameContaining where x.firstname like ‘%’||?1||’%’
OrderBy findByAgeOrderByLastnameDesc where x.age = ?1 order by x.lastname desc
Not findByLastnameNot where x.lastname <> ?1
In findByAgeIn(Collection<Age> ages) where x.age in ?1


페이징과 정렬처리하기

모든 쿼리 메서드는 마지막 파라미터로 페이징 처리를 위한 Pageable 인터페이스와 정렬을 처리하는 Sort 인터페이스를 추가할 수 있다.

  1. 페이징 처리
     @Repository
     public interface BoardRepository extends JpaRepository<Board, Long> {
    
         List<Board> findByTitleContaining(String searchKeyword, Pageable paging);
     }
    
     @ExtendWith(SpringExtension.class)
     @SpringBootTest
     public class QueryMethodTest {
    
         @Autowired
         private BoardRepository boardRepository;
    
         @BeforeEach
         public void datePrepared(){
             System.out.println("#Before Test Data Prepared#");
             for (int i = 0; i < 200; i++) {
                 System.out.println("#Create# " + i);
                 Board board = new Board();
                 board.setTitle("제목 " + i);
                 board.setWriter("테스터 " + i);
                 board.setContent("Content" + i);
                 board.setCreateDate(new Date());
                 board.setCnt(0L);
                 boardRepository.save(board);
             }
         }
         @Test
         public void testFindByTitleContaining(){
             Pageable paging = PageRequest.of(0, 5);
             List<Board> boardList = boardRepository.findByTitleContaining("17", paging);
    
             System.out.println("# 검색 결과 #");
             for (Board board : boardList){
                 System.out.println("---> " + board.toString());
             }
         }
     }
    
     # 검색 결과 #
     ---> Board(seq=18, title=제목 17, writer=테스터 17, content=Content17, createDate=2022-04-26 11:13:28.298, cnt=0)
     ---> Board(seq=118, title=제목 117, writer=테스터 117, content=Content117, createDate=2022-04-26 11:13:28.5, cnt=0)
     ---> Board(seq=171, title=제목 170, writer=테스터 170, content=Content170, createDate=2022-04-26 11:13:28.59, cnt=0)
     ---> Board(seq=172, title=제목 171, writer=테스터 171, content=Content171, createDate=2022-04-26 11:13:28.592, cnt=0)
     ---> Board(seq=173, title=제목 172, writer=테스터 172, content=Content172, createDate=2022-04-26 11:13:28.593, cnt=0)
    

    testFindByTitleContaining 메서드는 title 변수에 17이라는 검색어가 포함된 게시글 목록을 검색하되, 1번부터 다섯 개의 데이터만 조회하도록 했다. Pageable 객체를 생성할 때 사용한 PageRequest.of(0,5) 메서드에서 첫 번째 인자 0은 페이지 번호인데, 0부터 시작하기 때문에 첫 번째 페이지를 보고 싶으면 0이라고 설정한다. 두 번째 인자 5는 검색할 데이터의 개수다.

  2. 정렬 처리
    페이징 처리를 할 때, 데이터를 정렬해서 출력하려면 Sort 클래스를 사용하면 된다.

     Pageable paging = PageRequest.of(1, 5, Sort.Direction.DESC, "seq");
    

    Pageable 객체를 생성할 때, 기존의 두 개의 인자 외에 두 개를 추가로 넘겨준다. 추가된 인자에서 첫 번째는 정렬 방향에 대한 정보이며, 두 번째는 정렬 대상이 되는 변수 이름이다.


Page<T> 타입 사용하기

검색 결과를 List<T> 타입으로 받아도 되지만 스프링 MVC에서 검색 결과를 사용할 목적이라면 List<T>보다는 Page<T>를 사용하는 것이 좋다. Page<T> 객체는 페이징 처리할 때 사용할 수 있는 다양한 정보들을 추가로 제공하기 때문이다.

@Test
public void testFindByTitleContaining(){

    Pageable paging = PageRequest.of(0, 5, Sort.Direction.DESC, "seq");
    Page<Board> pageInfo = boardRepository.findByTitleContaining("17", paging);

    System.out.println("page size: " + pageInfo.getSize());
    System.out.println("total pages: " + pageInfo.getTotalPages());
    System.out.println("total count: " + pageInfo.getTotalElements());
    System.out.println("next: " + pageInfo.nextPageable());

    List<Board> boardList = pageInfo.getContent();
    System.out.println("# 검색 결과 #");
    for (Board board : boardList){
        System.out.println("---> " + board.toString());
    }
}
page size: 5
total pages: 3
total count: 12
next: Page request [number: 1, size 5, sort: seq: DESC]
# 검색 결과 #
---> Board(seq=180, title=제목 179, writer=테스터 179, content=Content179, createDate=2022-04-26 11:40:25.088, cnt=0)
---> Board(seq=179, title=제목 178, writer=테스터 178, content=Content178, createDate=2022-04-26 11:40:25.086, cnt=0)
---> Board(seq=178, title=제목 177, writer=테스터 177, content=Content177, createDate=2022-04-26 11:40:25.085, cnt=0)
---> Board(seq=177, title=제목 176, writer=테스터 176, content=Content176, createDate=2022-04-26 11:40:25.083, cnt=0)
---> Board(seq=176, title=제목 175, writer=테스터 175, content=Content175, createDate=2022-04-26 11:40:25.081, cnt=0)

실행 겨로가를 보면 일반적인 검색 쿼리와 함께 조건에 부합하는 데이터의 총 개수를 조회하기 위한 쿼리가 한 번 더 실행되는 것을 알 수 있다. 그리고 페이징 처리에 필요한 다양한 정보들을 Page 객체를 통해 추출할 수 있다.

Page객체가 제공하는 페이징 관련 메서드

메서드 설명
int GetNumber() 현재 페이지 정보
int GetSize() 한 페이지의 크기
int getTotalPages() 전체 페이지의 수
int getNumberOfElements() 결과 데이터 수
boolean hasPreviousPage() 이전 페이지의 존재 여부
boolean hasNextPage() 다음 페이지의 존재 여부
boolean isLastPage() 마지막 페이지 여부
Pageable nextPageable() 다음 페이지 객체
Pageable previousPageable() 이전 페이지 객체
List getContent() 조회된 데이터 목록
Boolean hasContent() 결과 존재 여부
Sort getSort() 검색 시 사용된 Sort 정보


5.1.3 @Query 어노테이션 사용하기

일반적인 쿼리는 스프링 데이터 JPA의 쿼리메서드만으로도 충분히 처리할 수 있다. 하지만 조금 복잡한 쿼리를 사용한다거나 연관관계에 기반한 조인(JOIN) 검색을 처리하기 위해서는 JPQL(Java Persistence Query Language)을 사용해야 한다. 또는 성능상 어쩔 수 없이 특정 데이터베이스에 종속적인 네이티브 쿼리르 사용해야하는 경우도 있다. 이를 위해서 제공되는 것이 @Query 어노테이션이다.

  1. 위치 기반 파라미터 사용하기

     @Query("SELECT b FROM Board b "
             + "WHERE b.title like %?1% "
             + "ORDER BY b.seq DESC")
     List<Board> queryAnnotationTest1(String searchKeyword);
    
     @ExtendWith(SpringExtension.class)
     @SpringBootTest
     public class QueryMethodTest {
    
         @Autowired
         private BoardRepository boardRepository;
    
         @Test
         public void queryAnnotationTest1(){
    
             List<Board> boardList = boardRepository.queryAnnotationTest1("17");
    
             System.out.println("# 검색 결과 #");
             for (Board board : boardList){
                 System.out.println("==>" + board.toString());
             }
         }
     }
    

    JPQL은 일반적인 SQL과 유사한 문법을 가지고 있지만 검색 대상이 테이블이 아니라 영속성 컨텍스트에 등록된 엔티티다. 따라서 FROM절에 엔티티 이름을 대소문자를 구분하여 정확하게 지정해야 한다. 그리고 칼럼 대신 엔티티가 가지고 있는 변수를 조회하기 때문에 SELECT나 WHERE 절에서 사용하는 변수 이름 역시 대소문자를 구분해야 한다.

    그리고 JPQL에서는 사용자 입력 값을 바인딩할 수 있도록 위치 기반 파라미터와 이름 기반 파라미터 두 가지를 지원한다. 예제에서는 위치 기반 파라미터를 사용했다. ‘?1’ 이라고 하면 첫 번째 파라미터를 의미한다. 따라서 queryAnnotationTest1 메서드 매개변수로 받은 searchKeyword가 첫 번째 파라미터 값으로 바인딩된다.

     Hibernate: 
         select
             board0_.seq as seq1_0_,
             board0_.cnt as cnt2_0_,
             board0_.content as content3_0_,
             board0_.create_date as create_d4_0_,
             board0_.title as title5_0_,
             board0_.writer as writer6_0_ 
         from
             t_board board0_ 
         where
             board0_.title like ? 
         order by
             board0_.seq DESC
     # 검색 결과 #
     ==>Board(seq=180, title=제목 179, writer=테스터 179, content=Content179, createDate=2022-04-26 11:40:25.088, cnt=0)
     ==>Board(seq=179, title=제목 178, writer=테스터 178, content=Content178, createDate=2022-04-26 11:40:25.086, cnt=0)
     ==>Board(seq=178, title=제목 177, writer=테스터 177, content=Content177, createDate=2022-04-26 11:40:25.085, cnt=0)
     ==>Board(seq=177, title=제목 176, writer=테스터 176, content=Content176, createDate=2022-04-26 11:40:25.083, cnt=0)
     ==>Board(seq=176, title=제목 175, writer=테스터 175, content=Content175, createDate=2022-04-26 11:40:25.081, cnt=0)
     ==>Board(seq=175, title=제목 174, writer=테스터 174, content=Content174, createDate=2022-04-26 11:40:25.08, cnt=0)
     ==>Board(seq=174, title=제목 173, writer=테스터 173, content=Content173, createDate=2022-04-26 11:40:25.078, cnt=0)
     ==>Board(seq=173, title=제목 172, writer=테스터 172, content=Content172, createDate=2022-04-26 11:40:25.076, cnt=0)
     ==>Board(seq=172, title=제목 171, writer=테스터 171, content=Content171, createDate=2022-04-26 11:40:25.075, cnt=0)
     ==>Board(seq=171, title=제목 170, writer=테스터 170, content=Content170, createDate=2022-04-26 11:40:25.073, cnt=0)
     ==>Board(seq=118, title=제목 117, writer=테스터 117, content=Content117, createDate=2022-04-26 11:40:24.985, cnt=0)
     ==>Board(seq=18, title=제목 17, writer=테스터 17, content=Content17, createDate=2022-04-26 11:40:24.801, cnt=0)
    
  2. 이름 기반 파라미터 사용하기

     @Repository
     public interface BoardRepository extends JpaRepository<Board, Long> {
    
         @Query("SELECT b FROM Board  b "
                 + "WHERE b.title like %:searchKeyword% "
                 + "ORDER BY b.seq DESC")
         List<Board> queryAnnotationTest2(@Param("searchKeyword") String searchKeyword);
     }
    

    @Query에 설정한 JPQL에는 ‘?1’ 대신 ‘:searchKeyword’로 수정했다. 그리고 ‘:searchKeyword’ 파라미터에 매개변수로 받은 searchKeyword ㄱ밧이 바인딩 되도록 @Param 어노테이션을 추가했다.


특정 변수만 조회하기
@Repository
public interface BoardRepository extends JpaRepository<Board, Long> {

    @Query("SELECT b.seq, b.title, b.writer, b.createDate FROM Board  b "
            + "WHERE b.title like %:searchKeyword% "
            + "ORDER BY b.seq DESC")
    List<Object[]> queryAnnotationTest3(@Param("searchKeyword") String searchKeyword);
}
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class QueryMethodTest {

    @Autowired
    private BoardRepository boardRepository;

    @Test
    public void queryAnnotationTest3(){

        List<Object[]> boardList = boardRepository.queryAnnotationTest3("17");

        System.out.println("# 검색 결과 #");
        for (Object[] board : boardList){
            System.out.println("==>" + Arrays.toString(board));
        }
    }
}
Hibernate: 
    select
        board0_.seq as col_0_0_,
        board0_.title as col_1_0_,
        board0_.writer as col_2_0_,
        board0_.create_date as col_3_0_ 
    from
        t_board board0_ 
    where
        board0_.title like ? 
    order by
        board0_.seq DESC
# 검색 결과 #
==>[180, 제목 179, 테스터 179, 2022-04-26 11:40:25.088]
==>[179, 제목 178, 테스터 178, 2022-04-26 11:40:25.086]
==>[178, 제목 177, 테스터 177, 2022-04-26 11:40:25.085]
==>[177, 제목 176, 테스터 176, 2022-04-26 11:40:25.083]
==>[176, 제목 175, 테스터 175, 2022-04-26 11:40:25.081]
==>[175, 제목 174, 테스터 174, 2022-04-26 11:40:25.08]
==>[174, 제목 173, 테스터 173, 2022-04-26 11:40:25.078]
==>[173, 제목 172, 테스터 172, 2022-04-26 11:40:25.076]
==>[172, 제목 171, 테스터 171, 2022-04-26 11:40:25.075]
==>[171, 제목 170, 테스터 170, 2022-04-26 11:40:25.073]
==>[118, 제목 117, 테스터 117, 2022-04-26 11:40:24.985]
==>[18, 제목 17, 테스터 17, 2022-04-26 11:40:24.801]

특정 변수 값만 조회할 때 중요한 것은 검색 결과로 엔티티 객체가 조회되는 것이 아니라 여러 변수 값들이 조회된다는 것이다. 따라서 리턴 타입을 List<Object[]>로 해야 한다.

@Query를 사용할 때의 주의사항은 @Query로 등록한 SQL은 프로젝트가 로딩되는 시점에 파싱되어 처리된다는 것이다. 따라서 @Query로 등록한 SQL에 오류가 있으면 무조건 예외가 발생되고 프로그램이 실행되지 않는다. 이는 프로그램이 실행되기 전에 사용할 SQL들을 모두 메모리에 올려둠으로서 성능을 향상시킬 수 있기 때문이다.

결국 @Query를 사용할 때는 사용할 쿼리를 한 번에 모두 등록하지 말고, JPQL에 오류가 없는지 하나씩 확인하면서 등록하는 것이 좋다.


네이티브 쿼리 사용하기

@Query를 사용하면 특정 데이터베이스에서만 사용하는 네이티브 쿼리를 실행할 수 있다. 네이티브 쿼리를 사용하면 쿼리가 특정 데이터베이스에 종속되는 문제가 있지만 성능상 특정 데이터베이스에 최적화된 쿼리를 사용해야 하는 경우에는 유용하게 사용할 수 있다.

@Repository
public interface BoardRepository extends JpaRepository<Board, Long> {

    @Query(value = "SELECT seq, title, writer, createDate FROM T_BOARD "
            + "WHERE title like '%' || ?1 || '%' "
            + "ORDER BY seq DESC", nativeQuery = true)
    List<Object[]> queryNativeTest1(String searchKeyword);
}

우선 엔티티가 아닌 정상적인 테이블 이름이 사용되었고, select와 where 절에서도 변수가 아닌 T_BOARD 테이블이 칼럼 이름을 사용했다. 그리고 where 절에서는 LIKE와 문자열 접합 연산자 (‘||’)를 사용했다. 그리고 마지막으로 이 쿼리가 JPQL이 아닌 네이티브 쿼리임을 알려주는 nativeQuery=true 속성을 추가했다.


5.1.4 QueryDSL을 이용한 동적 쿼리 적용하기

QueryDSL이란?

JPA에서는 @Query를 이용해서 어플리케이션에서 사용할 쿼리를 관리한다. 그런데 @Query로 등록한 쿼리는 프로젝트가 로딩되는 시점에 파싱되기 때문에 고정된 SQL만 사용할 수 있다. 따라서 동적으로 쿼리를 처리하려면 QueryDSL(Query Domain Specific Language)을 이용해야 한다. QueryDSL은 오픈소스 프로젝트로서 쿼리를 문자열이 아닌 자바 코드로 작성할 수 있도록 지원하는 일종의 JPQL 빌더라고 보면 된다.


5.2 연관관계 매핑

JPA는 테이블과 엔티티를 매핑하는 기술이다. 따라서 테이블이 관계를 맺듯이 엔티티 역시 다른 엔티티와 관계를 맺고 있으며, 이 관계를 통해 연관된 데이터를 관리할 수 있다. 결국 테이블의 연관관계를 엔티티의 연관관계로 매핑해야 하는데, 중요한 것은 테이블은 PK와 FK를 기반으로 연관관계를 맺지만 객체는 참조 변수를 통해 연관관계를 맺기 때문에 테이블의 연관과 엔티티의 연관이 정확하게 일치하지 않는다는 것이다. 이런 문제를 JPA에서는 어떻게 해결하는지 살펴보자.

5.2.1 단방향 연관관계 설정하기

객체지향 프로그램에서 객체는 참조 변수를 통해 다른 객체와 관계를 맺고, 관계형 데이터베이스는 외래 키(Foreign Key)를 이용하여 다른 테이블과 관계를 맺는다. 따라서 객체지향의 연관과 관계형 데이터베이스의 연관은 근본적인 차이가 존재할 수 밖에 없으며, 이런 차이를 매핑하기 위한 설정 역시 복잡할 수 밖에 없다.

용어 설명
방향 (Direction) 단방향과 양방향이 있다. 예를들어 게시판 객체가 참조 변수를 통해 회원 객체를 참조하면 단방향이다.
만약 회원 객체도 게시판 객체를 참조한다면 양방향이 된다. 중요한 것은 방향은 객체에만 존재하고 테이블은 항상 양방향이라는 것이다.
다중성 (Multiplicity) 다대일(N:1), 일대다(1:N), 다대다(N:M)가 있다. 예를들어 회원은 여러 개의 게시 글을 작성하기 때문에 회원과 게시 글은 일대다 관계다. 반대로 게시 글 입장에서 보면 다대일 관계가 되는 것이다.
연관관계 주인 (Owner) 객체를 양방향 연관관계로 만들면 연관관계의 주인을 정해야 한다. 일반적으로 다대일(N:1)이나 일대다(1:N) 관계에서 연관관계의 주인은 다(N)쪽에 해당하는 개체라고 생각하면 쉽다.


다대일(N:1) 단방향 매핑하기
  1. 다대일 관계의 이해
    데이터 모델링을 하다 보면 다대일 관계가 가장 많이 등장한다.
    • 다대일 연관 매핑 테스트를 위한 조건을 가정한다.
      • 게시판과 회원이 있다.
      • 한 명의 회원은 여러 개의 게시 글을 작성할 수 있다.
      • 게시판과 회원은 다대일 관계이다.
      • 게시 글을 통해서 게시 글을 작성한 회원 정보를 조회할 수 있다.(반대는 안 됨)

    위 조건을 참조하여 작성한 클래스 다이어그램과 ERD이다.

    springboot_5_2

    겍체의 연관관계를 살펴보면 게시판 객체(Board)는 참조 변수 (Board.member)를 통해 회원 회원 객체(Member)와 관계를 맺는다. 그리고 게시판 객체와 회원 객체는 단방향 관계로서 게시판은 Board.member 변수를 통해 회원 정보를 알 수 있지만 반대로 회원은 게시판에 대한 참조 변수를 가지지 않기 때문에 게시판 정보를 알 수 없다.

    테이블의 연관관계를 살펴보면 게시판 테이블(BOARD)은 MEMBER_ID라는 외래 키를 이용하여 회원 테이블(MEMBER)과 연관관계를 맺는다. 그리고 게시판 테이블과 회원 테이블은 이 하나의 관계로 양방향 관계가 성립한다. 테이블은 외래 키 하나로 처음부터 양방향 참조가 가능한 것이다.

  2. 연관관계 매핑하기
    Member
     @Getter
     @Setter
     @RequiredArgsConstructor
     @ToString
     @Entity
     @Table(name = "T_MEMBER")
     public class Member {
    
         @Id
         @Column(name="MEMBER_ID")
         private String id;
         private String password;
         private String name;
         private String role;
     }
    

    Board

     @Getter
     @Setter
     @RequiredArgsConstructor
     @ToString
     @Entity
     @Table(name = "T_BOARD")
     public class Board {
    
         @Id @GeneratedValue
         private Long seq;
         private String title;
     //    private String writer;
         private String content;
         @Temporal(TemporalType.TIMESTAMP)
         private Date createDate;
         private Long cnt;
            
         @ManyToOne
         @JoinColumn(name="MEMBER_ID")
         private Member member;
     }
    

    Member 객체와 연관 매핑을 처리하기 위해서 Member타입의 member 변수를 새롭게 추가했다. 그리고 다대일(N:1) 관계를 설정하기 위해서 member 변수 위에 @ManyToOne 어노테이션을 추가했다.

    @ManyToOne속성

    속성 기능 기본 값
    optional 연관된 엔티티가 반드시 있어야 하는지의 여부를 결정한다. false로 설정되면 항상 있어야 한다는 의미이다. true
    fetch 글로벌 페치 전략을 설정한다. EAGER는 연관 엔티티를 동시에 조회하며, LAZY는 연관 엔티티를 실제 사용할 때 조회한다. @ManyToOne: EAGER
    @OneToMany: LAZY
    cascade 영속성 전이 기능을 설정한다. 연관 엔티티를 같이 저장하거나 삭제할 때 사용한다.  

    연관필드인 member에는 @ManyToOne외에도 외래 키 매핑을 위해 @JoinColumn 어노테이션도 사용했다. @JoinColumn은 name 속성을 통해 참조하는 테이블의 외래 키 칼럼을 매핑하는데 바로 이 @JoinColumn 어노테이션 설정으로 인해 다음 그림과 같은 매핑 관계가 형성된다.

    springboot_5_3


댜대일 연관관계 테스트
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class RelationMappingTest {

    @Autowired
    private BoardRepository boardRepository;

    @Autowired
    private MemberRepository memberRepository;

    @Test
    public void testManyToOneInsert(){

        Member member1 = new Member();
        member1.setId("member1");
        member1.setPassword("member1");
        member1.setName("User1");
        member1.setRole("User");
        memberRepository.save(member1);

        Member member2 = new Member();
        member2.setId("member2");
        member2.setPassword("member2");
        member2.setName("Admin");
        member2.setRole("Admin");
        memberRepository.save(member2);

        for (int i = 0; i < 3; i++) {
            Board board = new Board();
            board.setMember(member1);
            board.setTitle("User1이 등록한 게시글 " + i);
            board.setContent("User1이 등록한 게시글 내용 " + i);
            board.setCreateDate(new Date());
            board.setCnt(0L);
            boardRepository.save(board);
        }

        for (int i = 0; i < 3; i++) {
            Board board = new Board();
            board.setMember(member2);
            board.setTitle("Admin 등록한 게시글 " + i);
            board.setContent("Admin 등록한 게시글 내용 " + i);
            board.setCreateDate(new Date());
            board.setCnt(0L);
            boardRepository.save(board);
        }
    }
}

두 명의 회원 엔티티를 생성하여 저장했다. 그리고 나서 회원 정보가 설정된 게시 글 엔티티 여러 개도 저장했다. 여기에서 중요한 것은 엔티티를 저장할 때 연관관계에 있는 엔티티가 있다면 해당 엔티티도 영속 상태에 있어야 한다는 것이다. 따라서 게시 글과 연관관계에 있는 회원 엔티티를 먼저 영속성 컨텍스트에 젖아하고 이후에 게시 글 엔티티를 저장한 것이다.

@ExtendWith(SpringExtension.class)
@SpringBootTest
public class RelationMappingTest {

    @Autowired
    private BoardRepository boardRepository;

    @Autowired
    private MemberRepository memberRepository;

    @Test
    public void testManyToOneSelect() {
        Board board = boardRepository.findById(5L).get();
        System.out.println(board.toString());
    }
}
Hibernate: 
    select
        board0_.seq as seq1_0_0_,
        board0_.cnt as cnt2_0_0_,
        board0_.content as content3_0_0_,
        board0_.create_date as create_d4_0_0_,
        board0_.member_id as member_i6_0_0_,
        board0_.title as title5_0_0_,
        member1_.member_id as member_i1_1_1_,
        member1_.name as name2_1_1_,
        member1_.password as password3_1_1_,
        member1_.role as role4_1_1_ 
    from
        t_board board0_ 
    left outer join
        t_member member1_ 
            on board0_.member_id=member1_.member_id 
    where
        board0_.seq=?
Board(seq=5, title=Admin 등록한 게시글 1, content=Admin 등록한 게시글 내용 1, createDate=2022-04-28 14:50:07.287, cnt=0, member=Member(id=member2, password=member2, name=Admin, role=Admin))

testManyToOneSelect() 메서드는 5번 게시 글을 상세 조회하여 조회 결과를 출력하도록 했다. 그런데 중요한 것은 @ManyToOne 어노테이션의 fetch 속성의 기본 값이 EAGER라는 것이다. 따라서 수정된 테스트 케이스를 실행하면 조인이 실행되어, 연관관계에 있는 회원 정보까지 같이 조회된다.

그런데 실행 결과를 보면 t_board 테이블과 t_member 테이블이 외부 조인(OuterJoin)으로 연결되어 있는 것이 확인된다. 외부 조인은 성능상 내부 조인보다 좋지 않다. 따라서 반드시 참조 키에 값이 설정된다는 전제가 성립된다면 외부 조인을 내부 조인(InnerJoin)으로 변경하는 것이 좋다. 이때 Member.member 변수와 매핑되는 member_id 컬럼이 항상 참조 값을 가진다는 의미로 @JoinColumn에 nullable 속성을 추가하면 된다.

@Getter
@Setter
@RequiredArgsConstructor
@ToString
@Entity
@Table(name = "T_BOARD")
public class Board {

    @Id @GeneratedValue
    private Long seq;
    private String title;
    private String content;
    @Temporal(TemporalType.TIMESTAMP)
    private Date createDate;
    private Long cnt;

    @ManyToOne
    @JoinColumn(name="MEMBER_ID", nullable = false)
    private Member member;
}
Hibernate: 
    select
        board0_.seq as seq1_0_0_,
        board0_.cnt as cnt2_0_0_,
        board0_.content as content3_0_0_,
        board0_.create_date as create_d4_0_0_,
        board0_.member_id as member_i6_0_0_,
        board0_.title as title5_0_0_,
        member1_.member_id as member_i1_1_1_,
        member1_.name as name2_1_1_,
        member1_.password as password3_1_1_,
        member1_.role as role4_1_1_ 
    from
        t_board board0_ 
    inner join
        t_member member1_ 
            on board0_.member_id=member1_.member_id 
    where
        board0_.seq=?
Board(seq=5, title=Admin 등록한 게시글 1, content=Admin 등록한 게시글 내용 1, createDate=2022-04-28 14:50:07.287, cnt=0, member=Member(id=member2, password=member2, name=Admin, role=Admin))

외부조인이 내부조인으로 변경 된 것을 확인할 수 있다.


5.2.2 양방향 연관관계 매핑하기

5.2.2.1 양방향 매핑 설정하기

양방향으로 매핑하면 게시판에서 회원으로 접근하고 반대 방향인 회원에서도 게시판 정보에 접근할 수 있다.

먼저 객체 연관관계를 보면 게시판과 회원은 다대일 관계다. 반대로 회원은 게피산과 일대다 관계다. 일대다 관계는 하나의 객체가 여러 객체와 연관관계를 맺을 수 있으므로 당연히 List 같은 컬렉션을 사용해야 한다.

반면 테이블의 관계는 외래 키 하나로 조인을 통해 양방향 조회가 가능하다. 따라서 테이블에는 아무 것도 추가할 내용이 없다.

@Getter
@Setter
@RequiredArgsConstructor
@ToString
@Entity
@Table(name = "T_MEMBER")
public class Member {

    @Id
    @Column(name="MEMBER_ID")
    private String id;
    private String password;
    private String name;
    private String role;

    @OneToMany(mappedBy = "member", fetch=FetchType.EAGER)
    private List<Board> boardList = new ArrayList<>();
}

Member 클래스는 Board 엔티티 여러 개를 저장할 수 있도록 List 타입의 boardList 변수를 추가했다. 그리고 boardList 변수에 @OneToMany 어노테이션을 추가했는데, @OneToMany는 일대다 관계를 매핑할 때 사용한다. @OneToMany는 mappedBy 속성과 fetch 속성을 사용할 수 있는데 우선 fetch 속성은 회원 정보를 조회할 때 연관관계에 있는 게시판 정보도 같이 조회할 것인지를 결정할 때 사용한다. @OneToMany의 경우에 fetch 속성의 기본 값은 LAZY다. 하지만 EAGER로 설정했기 때문에 회원 정보를 가져올 때 회원이 등록한 게시 글 목록도 같이 조회될 것이다.

여기에서 가장 중요한 설정은 @OneToMany의 mappedBy 속성이다. mappedBy는 양방향 연관관계에서 연관관계의 주인을 지정할 때 사용한다. 앞에서도 언급했듯이 객체에는 원래부터 양방향이라는 개념이 없고, 서로를 참조하는 단방향 관계 두개가 필요하다. 하지만 테이블의 경우에는 외래 키 하나로 양방향을 조회할 수 있다.

엔티티를 양방향으로 매핑하려면 매핑에 참여하는 참조 변수는 두 개인데 외래 키는 하나기 때문에 둘 사이에 차이가 발생한다. 따라서 둘 중 어떤 관계를 사용해서 외래 키를 관리할지 결정해야 하는데 이것을 연관관계 주인이라고 한다. 연관관계의 주인을 결정한다는 것은 결국 외래 키 관리자를 선택하는 것이다. 그리고 반대로 연관관계의 주인이 아닌 쪽은 자신이 연관관계의 주인이 아님을 알려줘야 하는데 이 때 사용하는 것이 바로 mappedBy 속성이다.

연관관계의 주인은 테이블에 외래 키가 있는 곳으로 정해야 한다. 여기에서는 board 테이블이 외래 키를 가지고 있으므로 Board.member 변수가 주인이 된다. 반대로 주인이 아닌 Member.boardList에는 mappedBy=”member” 속성을 사용해서 주인이 아님을 표시해야 한다. mappedBy 속성 값으로는 연관관계의 주인인 member를 설정하면 된다.

@ExtendWith(SpringExtension.class)
@SpringBootTest
public class RelationMappingTest {

    @Autowired
    private BoardRepository boardRepository;

    @Autowired
    private MemberRepository memberRepository;

    @Test
    public void testTwoWayMapping() {
        Member member = memberRepository.findById("member1").get();


        System.out.println("======================================");
        System.out.println(member.getName() + "가 저장한 게시글 목록");
        System.out.println("======================================");

        List<Board> boardList = member.getBoardList();
        for (Board board : boardList ){
            System.out.println(board.toString());
        }
    }
}
Hibernate: 
    select
        member0_.member_id as member_i1_1_0_,
        member0_.name as name2_1_0_,
        member0_.password as password3_1_0_,
        member0_.role as role4_1_0_,
        boardlist1_.member_id as member_i6_0_1_,
        boardlist1_.seq as seq1_0_1_,
        boardlist1_.seq as seq1_0_2_,
        boardlist1_.cnt as cnt2_0_2_,
        boardlist1_.content as content3_0_2_,
        boardlist1_.create_date as create_d4_0_2_,
        boardlist1_.member_id as member_i6_0_2_,
        boardlist1_.title as title5_0_2_ 
    from
        t_member member0_ 
    left outer join
        t_board boardlist1_ 
            on member0_.member_id=boardlist1_.member_id 
    where
        member0_.member_id=?
======================================
User1가 저장한 게시글 목록
======================================

java.lang.StackOverflowError
	at java.util.Date.getYear(Date.java:651)
	at java.sql.Timestamp.toString(Timestamp.java:279)
	at java.lang.String.valueOf(String.java:2994)
	at java.lang.StringBuilder.append(StringBuilder.java:131)
	at com.bys.sample.domain.Board.toString(Board.java:12)
	at java.lang.String.valueOf(String.java:2994)
	at java.lang.StringBuilder.append(StringBuilder.java:131)
	at java.util.AbstractCollection.toString(AbstractCollection.java:462)
	at org.hibernate.collection.internal.PersistentBag.toString(PersistentBag.java:622)
	at java.lang.String.valueOf(String.java:2994)
	at java.lang.StringBuilder.append(StringBuilder.java:131)
	at com.bys.sample.domain.Member.toString(Member.java:15)

testTwoWayMapping 테스트 메서드에서 StackOverflowError가 발생하는 이유는 롬복에서 제공하는 @ToString이 양방향 참조에서 상호 호출을 했기 때문이다. Board 객체의 toString() 메서드를 호출하면 toString() 메서드 안에서 Member 객체의 toString() 메서드를 호출하고, Member 객체의 toString() 메서드는 또 다시 Board 객체의 toString() 메서드를 호출하는 순환 참조에 빠지게 된다.

따라서 @ToString 어노테이션에 exclude 속성을 추가하여 상호 호출 고리를 끊어야 한다.

 @Getter
@Setter
@RequiredArgsConstructor
@ToString(exclude = "boardList")
@Entity
@Table(name = "T_MEMBER")
public class Member {

    @Id
    @Column(name="MEMBER_ID")
    private String id;
    private String password;
    private String name;
    private String role;

    @OneToMany(mappedBy = "member", fetch=FetchType.EAGER)
    private List<Board> boardList = new ArrayList<>();

}
Hibernate: 
    select
        member0_.member_id as member_i1_1_0_,
        member0_.name as name2_1_0_,
        member0_.password as password3_1_0_,
        member0_.role as role4_1_0_,
        boardlist1_.member_id as member_i6_0_1_,
        boardlist1_.seq as seq1_0_1_,
        boardlist1_.seq as seq1_0_2_,
        boardlist1_.cnt as cnt2_0_2_,
        boardlist1_.content as content3_0_2_,
        boardlist1_.create_date as create_d4_0_2_,
        boardlist1_.member_id as member_i6_0_2_,
        boardlist1_.title as title5_0_2_ 
    from
        t_member member0_ 
    left outer join
        t_board boardlist1_ 
            on member0_.member_id=boardlist1_.member_id 
    where
        member0_.member_id=?
======================================
User1가 저장한 게시글 목록
======================================
Board(seq=1, title=User1이 등록한 게시글 0, content=User1이 등록한 게시글 내용 0, createDate=2022-04-28 14:50:07.258, cnt=0, member=Member(id=member1, password=member1, name=User1, role=User))
Board(seq=2, title=User1이 등록한 게시글 1, content=User1이 등록한 게시글 내용 1, createDate=2022-04-28 14:50:07.275, cnt=0, member=Member(id=member1, password=member1, name=User1, role=User))
Board(seq=3, title=User1이 등록한 게시글 2, content=User1이 등록한 게시글 내용 2, createDate=2022-04-28 14:50:07.278, cnt=0, member=Member(id=member1, password=member1, name=User1, role=User))


5.2.2.2 영속성 전이

특정 엔티티를 영속 상태로 만들거나 삭제 상태로 만들 때 연관된 엔티티도 같이 처리할 경우 영속성 전이를 사용하면 관리가 편하다. JPA는 cascade 속성을 이용하여 부모 엔티티를 저장할 때 자식 엔티티도 같이 저장할 수 있고, 부모 엔티티를 삭제할 때 자식 엔티티도 삭제할 수 있다.

회원과 게시판 관계에서 회원은 PK를 가지고 있는 부모 엔티티며, 게시판은 FK를 가지고 있는 자식 엔티티다. 영속성 전이를 적용하기 위해 부모 엔티티에 해당하는 Member 클래스를 다음과 같이 수정한다.

@Table(name = "T_MEMBER")
public class Member {

    @OneToMany(mappedBy = "member", fetch=FetchType.EAGER, cascade = CascadeType.ALL)
    private List<Board> boardList = new ArrayList<>();
    // ......생략
}

@OneToMany에 cascade 속성을 추가했다. 그리고 cascade속성 값으로 CascadeType.ALL을 지정했다. CascadeType.ALL을 적용하면 회원 객체가 영속화되거나 수정, 또는 삭제될 때 회원과 관련된 게시판도 같이 변경될 것이다.

그리고 게시판 객체에 회원 객체를 설정할 때, 회원이 소유한 게시 글 컬렉션에 자신(게시 글)도 자동으로 저장될 수 있도록 setMember() 메서드를 추가하자. 이렇게 해야 영속 객체가 아닌 단순한 일반 자바 객체 상태에서도 관련된 데이터를 사용할 수 있다.

@Table(name = "T_BOARD")
public class Board {

    @ManyToOne
    @JoinColumn(name="MEMBER_ID", nullable = false)
    private Member member;
    public  void setMember(Member member){
        this.member = member;
        member.getBoardList().add(this);
    }
}
    @Test
    public void testManyToOneInsert(){

        Member member1 = new Member();
        member1.setId("member1");
        member1.setPassword("member1");
        member1.setName("User1");
        member1.setRole("User");
//        memberRepository.save(member1);

        Member member2 = new Member();
        member2.setId("member2");
        member2.setPassword("member2");
        member2.setName("Admin");
        member2.setRole("Admin");
//        memberRepository.save(member2);

        for (int i = 0; i < 3; i++) {
            Board board = new Board();
            board.setMember(member1);
            board.setTitle("User1이 등록한 게시글 " + i);
            board.setContent("User1이 등록한 게시글 내용 " + i);
            board.setCreateDate(new Date());
            board.setCnt(0L);
//            boardRepository.save(board);
        }

        for (int i = 0; i < 3; i++) {
            Board board = new Board();
            board.setMember(member2);
            board.setTitle("Admin 등록한 게시글 " + i);
            board.setContent("Admin 등록한 게시글 내용 " + i);
            board.setCreateDate(new Date());
            board.setCnt(0L);
//            boardRepository.save(board);
        }
        memberRepository.save(member2);
    }

위 소스에서 중요한 것은 앞서 Board 엔티티를 매번 영속화했던 코드가 주석으로 처리되었다는 것이다. 그리고 Member 객체만 영속화시키면 Member가 가진 boardList컬렉션에 저장된 모든 Board 객체도 자동으로 영속화된다.

그리고 앞에서 Board 클래스에 setMember() 메서드를 추가하고, 이를 통해 Board객체에 Member 객체를 셋팅하면 자동으로 Member 객체가 가진 boardList라는 컬렉션에도 Member 객체가 설정되도록 했다.

@Test
public void testCascadeDelete(){
    memberRepository.deleteById("member2");
}
Hibernate: 
    select
        member0_.member_id as member_i1_1_0_,
        member0_.name as name2_1_0_,
        member0_.password as password3_1_0_,
        member0_.role as role4_1_0_,
        boardlist1_.member_id as member_i6_0_1_,
        boardlist1_.seq as seq1_0_1_,
        boardlist1_.seq as seq1_0_2_,
        boardlist1_.cnt as cnt2_0_2_,
        boardlist1_.content as content3_0_2_,
        boardlist1_.create_date as create_d4_0_2_,
        boardlist1_.member_id as member_i6_0_2_,
        boardlist1_.title as title5_0_2_ 
    from
        t_member member0_ 
    left outer join
        t_board boardlist1_ 
            on member0_.member_id=boardlist1_.member_id 
    where
        member0_.member_id=?
Hibernate: 
    delete 
    from
        t_board 
    where
        seq=?
Hibernate: 
    delete 
    from
        t_board 
    where
        seq=?
Hibernate: 
    delete 
    from
        t_board 
    where
        seq=?
Hibernate: 
    delete 
    from
        t_board 
    where
        seq=?
Hibernate: 
    delete 
    from
        t_board 
    where
        seq=?
Hibernate: 
    delete 
    from
        t_board 
    where
        seq=?
Hibernate: 
    delete 
    from
        t_member 
    where
        member_id=?

아이디가 member2인 회원을 삭제하면 member2가 등록한 모든 Board 정보가 먼저 삭제되고 나서 회원 정보가 삭제된 것을 확인할 수 있다.





Reference

  • 스프링 부트 (채규태)

Tag: [ book  programming  spring  framework  springboot  jpa  h2  hibernate  @repository  @query  @beforeeach  querymethod  pageable  sort  querydsl  @manytoone  @onetomany  ]