과제 - 소통 게시판 - 3
Spring Data JPA
프로그램 개발 목표
- 게시물 등록 / 수정 / 삭제 / 조회
- 게시물 페이징 처리 / 게시물 검색
Board Entity와 JpaRepository
- domain package 생성
- Board class 작성
package com.board.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; //작성자
}
- entity 객체를 위한 entitiy 클래스는 반드시 @Entity를 적용해야 하고 @Id가 필요
@MappedSuperClass를 이용한 공통 속성 처리
- domain 패키지에 BaseEntity 클래스 생성
- BaseEntity에서 가장 중요한 부분은 자동으로 Spring Data JPA의 AuditingEntityLis-tener를 지정하는 부분
- AuditingEntityListener를 적용하면 엔티티가 데이터베이스에 추가되거나 변경될 때 자동으로 시간 값을 지정할 수 있다.
- AuditingEntityListener를 활성화 시키기 위해서는 프로젝트의 설정에 @EnableJpaAuditing을 추가해 주어야 한다.
package com.board;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@EnableJpaAuditing
@SpringBootApplication
public class BoardApplication {
public static void main(String[] args) {
SpringApplication.run(BoardApplication.class, args);
}
}
package com.board.domain;
import lombok.*;
import javax.persistence.*;
@Getter
@Entity
@Builder
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class Board extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno; // 게시글 번호 오토인크리먼트
@Column(length = 500, nullable = false) // 칼럼의 길이와 null 허용여부
private String title; // 제목
@Column(length = 2000, nullable = false)
private String content; // 내용
@Column(length = 50, nullable = false)
private String writer; //작성자
}
JpaRepository 인터페이스
테스트 코드를 통한 CRUD / 페이징 처리 확인
- insert 기능 테스트
- 데이버테이스에 insert 를 실행하는 기능은 JpaRepository 의 save()를 통해서 이루어 진다.
- save() 는 엔티티 객체가 없을 때는 insert, 존재할 때는 update를 자동으로 실행
// insert 기능
@Test
public void testInsert(){
IntStream.rangeClosed(1, 100).forEach(i -> {
Board board = Board.builder()
.title("title......" + i)
.content("content 으으" + i)
.writer("작성자는 ------" + (i%10))
.build();
Board result = boardRepository.save(board);
log.info("BNO : " + result.getBno());
});
}
- select 기능 테스트
- 특정 번호의 게시물을 조회하는 기능은 findById를 이용해서 처리
- findById의 리턴 타입은 Optional
- orElseThrows는 Optional의 인자가 null일 경우 예외처리를 한다
- 보통 JPA 사용 시에 가져온 값이 null일 경우 예외를 발생
// select 기능
@Test
public void testSelect(){
Long bno = 100L;
Optional<Board> result = boardRepository.findById(bno);
Board board = result.orElseThrow();
log.info(board);
}
- update 기능 테스트
- insert 와 동일하게 save()를 통해서 처리
- update는 등록 시간이 필요하므로 가능하면 findBy-Id()로 가져온 객체를 이용해서 약간의 수정을 통해서 처리
- Board의 제목 / 내용은 수정이 가능하므로 이에 맞도록 change() 라는 메소드를 다음과 같이 추가
- Board Entity 에서 수정
public void change(String title, String content){ this.title = title; this.content = content; }
// update
@Test
public void testUpdate(){
Long bno = 100L;
Optional<Board> result = boardRepository.findById(bno);
Board board = result.orElseThrow();
board.change("update..title 100", "update content 100");
boardRepository.save(board);
}
- delete 기능
- @Id에 해당하는 값으로 deleteById()를 통해서 실행
- Pageable 과 Page 타입
- 페이징 처리는 Pageable 이라는 타입의 객체를 구성해서 파라미터로 전달하면 된다
- Pageable은 인터페이스로 설계되어 있고, 일반적으로 PageRequest.of()라는 기능을 이용해서 개발이 가능
- PageRequest.of(페이지 번호, 사이즈) : 페이지 번호는 0 부터
- PageRequest.of(페이지 번호, 사이즈 Sort): 정렬 조건 추가
- PageRequest.of(페이즈 번호, 사이즈, Sort.Direction, 속성...) : 정렬 방향과 여러 속성 지정
@Test
public void testPaging(){
//1 page order by bno desc
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));
}
- 쿼리 메소드와 @Query
- 쿼리 메소드는 보통 SQL에서 사용하는 키워드와 칼럼들을 같이 결합해서 구성하면 그 자체가 JPA에서 사용하는 쿼리가 되는 기능
- @Query 어노테이션의 value로 작성하는 문자열을 JPQL이라고 하는데 SQL과 유사하게 JPA에서 사용하는 쿼리 언어라 생각
- 쿼리 메소드는 보통 SQL에서 사용하는 키워드와 칼럼들을 같이 결합해서 구성하면 그 자체가 JPA에서 사용하는 쿼리가 되는 기능
- build.gradle 맨 위에
buildscript {
ext{
queryDslVersion = "5.0.0"
}
}
- 의존성
// Querydsl implementation "com.querydsl:querydsl-jpa:${queryDslVersion}" annotationProcessor( "javax.persistence:javax.persistence-api", "javax.annotation:javax.annotation-api", "com.querydsl:querydsl-apt:${queryDslVersion}:jpa" )
- 마지막 부분
sourceSets{
main{
java{
srcDirs = ["$projectDir/src/main/java", "$projectDir/build/generated"]
}
}
}
#### 기존의 Repository와 Querydsl 연동하기
- Querydsl을 기존 코드에 연동하기 위해 다음과 같은 과정으로 작성
- Querydsl을 이용할 인터페이스 선언
- '인터페이스 이름 + Impl' 이라는 이름으로 클래스를 선언 - 이때 QuerydslRepositorySupport라는 부모 클래스를 지정하고 인터페이스를 구현
- 기존의 Repository에는 부모 인터페이스로 Querydsl을 위한 인터페이스를 지정
- repository 패키지에 search 하위 패키지를 추가 BoardSearch라는 인터페이스를 선언
- BoardSearch에는 단순히 페이지 처리 기능만 선언
package com.board.repository.search;
import com.board.domain.Board;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface BoardSearch {
Page<Board> search1(Pageable pageable);
}
package com.board.repository.search;
import com.board.domain.Board;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
public class BoardSearchImpl extends QuerydslRepositorySupport implements BoardSearch {
public BoardSearchImpl(){
super(Board.class);
}
@Override
public Page<Board> search1(Pageable pageable){
return null;
}
}
- 기존의 BoardRepository의 선언부에 BoardSearch 인터페이스를 추가로 지정
package com.board.repository;
import com.board.domain.Board;
import com.board.repository.search.BoardSearch;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface BoardRepository extends JpaRepository<Board, Long>, BoardSearch {
@Query(value = "select now()", nativeQuery = true)
String getTime();
}
#### Q도메인을 이용한 쿼리 작성 및 테스트
- Querydsl의 목적은 '타입' 기반으로 '코드'를 이용해서 JPQL 쿼리를 생성하고 실행하는 것
- 이때 코드를 만드는 대신 클래스가 Q도메인 클래스
- 작성된 BoardSearchImpl에서 Q도메인을 이용하는 코드를 작성
- JPQLQuery는 @Query로 작성했던 JPQL을 코드를 통해서 생성할 수 있게 한다. 이를 where나 group by 혹은 조인 처리 등이 가능
- JPQLQuery의 실행은 fetch() 라는 기능을 이용하고, fetchCount()를 이용하면 count 쿼리를 실행할 수 있다.
- search1() 코드에 이를 작성
#### 검색을 위한 메소드 선언과 테스트
- BoardSearchImpl 에서 searchAll의 반복문과 제어문을 이용한 처리가 가능
- 검색 조건을 의미하는 types '제목(t), 내용(c), 작성자(w)'
@Override
public Page searchAll(String[] types, String keyword, Pageable pageable){
QBoard board = QBoard.board;
JPQLQuery<Board> query = from(board); //select.. 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();
return null;
}
@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);
//total pages
log.info(result.getTotalPages() + "왜 이게 이거여?");
// page size
log.info(result.getSize());
//pageNumber
log.info(result.getNumber());
//prev next
log.info(result.hasPrevious() + " : " + result.hasNext());
result.getContent().forEach(board -> log.info(board));
}
2023-06-29 09:34:42.514 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : 2왜 이게 이거여?
2023-06-29 09:34:42.516 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : 10
2023-06-29 09:34:42.517 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : 0
2023-06-29 09:34:42.520 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : false : true
2023-06-29 09:34:42.537 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : Board(bno=100, title=title......100, content=content 으으100, writer=작성자는 ------0)
2023-06-29 09:34:42.537 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : Board(bno=91, title=title......91, content=content 으으91, writer=작성자는 ------1)
2023-06-29 09:34:42.538 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : Board(bno=81, title=title......81, content=content 으으81, writer=작성자는 ------1)
2023-06-29 09:34:42.538 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : Board(bno=71, title=title......71, content=content 으으71, writer=작성자는 ------1)
2023-06-29 09:34:42.538 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : Board(bno=61, title=title......61, content=content 으으61, writer=작성자는 ------1)
2023-06-29 09:34:42.539 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : Board(bno=51, title=title......51, content=content 으으51, writer=작성자는 ------1)
2023-06-29 09:34:42.539 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : Board(bno=41, title=title......41, content=content 으으41, writer=작성자는 ------1)
2023-06-29 09:34:42.539 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : Board(bno=31, title=title......31, content=content 으으31, writer=작성자는 ------1)
2023-06-29 09:34:42.539 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : Board(bno=21, title=title......21, content=content 으으21, writer=작성자는 ------1)
2023-06-29 09:34:42.539 INFO 10684 --- [ Test worker] c.board.repository.BoardRepositoryTest : Board(bno=19, title=title......19, content=content 으으19, writer=작성자는 ------9)
2023-06-29 09:34:42.575 INFO 10684 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
- 왜 2 일까 ... 페이지 수가
-------------------------- 23 - 6 - 29 ---------------------
게시물과 관리 완성하기
서비스 계층과 DTO의 구현
- BoardRepository의 모든 메소드는 서비스 계층을 통해서 DTO로 변환되어 처리되도록 구성
- 엔티티 객체는 영속 컨텍스트에서 관리 되므로 가능하면 많은 계층에서 사용 되지 않는 것이 좋다
- ModelMapper 이용
ModelMapper 설정
- DTO와 엔티티간의 변환 처리를 간단히 처리 하기 위해 ModelMapper를 이용할 것이므로 build.gradle 파일에 ModelMapper 라이브러리가 존재하는지 확인
//ModelMapper 를 사용하기 위한 의존성
implementation 'org.modelmapper:modelmapper:3.1.0'
- config 패키지 생성 - > RootConfig 클래스 생성
CRUD 작업처리
등록 작업 처리
- BoardService 인터페이스에는 register()를 선언
Long register(BoardDto boardDto);
- BoardServiceImpl 생성
@Service
@Log4j2
@RequiredArgsConstructor
@Transactional
public class BoardServiceImpl implements BoardService {
private final ModelMapper modelMapper;
private final BoardRepository boardRepository;
@Override
public Long register(BoardDto boardDto){
Board board = modelMapper.map(boardDto, Board.class);
Long bno = boardRepository.save(board).getBno();
return bno;
}
}
- 의존성 주입 외에 @Transactional 어노테이션을 적용
- @Transactional을 적요하면 스프링은 해당 객체를 감싸는 별도의 클래스를 생성해 내는데 간혹 여러번의 데이터베이스 연결이 있을 수도 있으므로 트랜잭션 처리는 기본적으로 적용 해두는 것이 좋다.
- 등록 처리 구현
- 테스트 코드에서 insert 문이 동작하는지 확인하고 최종적으로 DB 확인
@Test
public void register() {
log.info(boardService.getClass().getName());
BoardDto boardDto = BoardDto.builder().title("Sample Title....").content("SampleContent...").writer("user00").build();
Long bno = boardService.register(boardDto);
log.info("bno : " + bno);
}
조회 작업 처리
- 조회는 특정한 게시물의 번호를 이용하므로 readOne 메서드 추가
- BoardServiceImpl
@Override public BoardDto readOne(Long bno){ Optional<Board> result = boardRepository.findById(bno); Board board = result.orElseThrow(); BoardDto boardDto = modelMapper.map(board, BoardDto.class); return boardDto; }
수정 작업 처리
- 수정 작업은 기존 엔티티 객체에서 필요한 부분만 변경하도록 작성
- void modify(BoardDto boardDto);
- BoardServiceImpl 에서 Board chage를 이용해서 필요한 부분만 수정
- 수정 확인 테스트
// update test
@Test
public void testModify(){
// 변경에 필요한 데이터만
BoardDto boardDto = BoardDto.builder()
.bno(101L)
.title("update..........1111")
.content("update content 11111111.")
.build();
boardService.modify(boardDto);
}
목록 / 검색 처리
PageRequestDto
- PageRequestDto는 페이징 관련 정보 (page/size) 외에 검색의 종류 (type)와 키워드 (key-word)를 추가 해서 지정
- 검색의 종류는 문자열 하나로 처리해서 나중에 각 문자를 분리하도록 구성
- 현재 검색 조건들은 BoardRepository에서 String[] 로 처리하기 때문에 type라는 문자열을 배열로 반환해 주는 기능 필요, 페이징 처리를 위해 사용하는 Pageable 타입을 반환하는 기능도 있으면 편리 하므로 메소드로 구현
PageResponseDto
- PageResponseDto는 화면에 DTO 목록과 시작 페이지 / 끝 페이지 등에 대한 처리를 담당
package com.board.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import java.util.List;
@Getter
@ToString
public class PageResponseDto<E> {
private int page;
private int size;
private int total;
// 시작 번호
private int start;
// 끝 페이지 번호
private int end;
// 이전 페이지 존재 여부
private boolean prev;
// 다음 페이지 존재 여부
private boolean next;
private List<E> dtoList;
@Builder(builderMethodName = "withAll")
public PageResponseDto(PageRequestDto pageRequestDto, List<E> dtoList, int total){
if (total <= 0){
return;
}
this.page = pageRequestDto.getPage();
this.size = pageRequestDto.getSize();
this.total = total;
this.dtoList = dtoList;
this.end = (int)(Math.ceil(this.page / 10.0 )) * 10; // 화면에서 마지막 번호
this.start = this.end - 9; // 화면에서 시작 번호
int last = (int)(Math.ceil((total/(double)size))); // 데이터의 개수를 계산한 마지막 페이지 번호
// 마지막 번호가 라스트보다 작으면 라스트가 마지막
this.end = end > last ? last: end;
this.prev = this.start > 1;
this.next = total > this.end * this.size;
}
}
BoardSerivce / BoardServiceImpl 수정
- BoardService 에 list() 메서드 생성 // 목록 / 검색 기능
PageResponseDto<BoardDto> list(PageRequestDto pageRequestDto);
- BoardServiceImpl은 ㅇ선 boardRepository를 호출하는 기능 부터 작성
@Override
public PageResponseDto<BoardDto> list(PageRequestDto pageRequestDto){
String [] types = pageRequestDto.getTypes();
String keyword = pageRequestDto.getKeyword();
Pageable pageable = pageRequestDto.getPageable("bnb");
Page<Board> result = boardRepository.searchAll(types, keyword, pageable);
return null;
- Page<Board>는 List<BoardDto>로 변환될 필요가 있다. 이 처리는 뒤에서 한번에 DTO 추출 하는 기능을 사용하겠지만
- 지금은 직접 변환하는 코드 작성
컨트롤러와 화면 처리
- Board에 대한 컨트롤러는 프로젝트에 controller 패키지를 추가하고 BoardController를 이용해서 처리하도록 구성
- 가장 우선 구현은 목록이므로 list() 메서드 추가 하고 PageRequestDto를 이용해 페이징 처리와 검색에 이용
화면 구성을 위한 준비
- 화면 구성은 Thymeleaf를 이용해서 레이아웃을 적용하도록 준비
- build.gradle 파일에 Thymeleaf의 레이아웃관련 라이브러리의 존재 여부 확인
// thymeleaf
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.1.0'
템플릿 디자인 적용
- 부트스트랩의 무료 디자인 중 Simple Side-bar를 이용
- https://startbootstrap.com/template/simple-sidebar 공식페이지
- 예제에서 사용하는 코드 https://url.kr/zogpxh
- 설치 후 모든 파일을 static 폴더에 넣기
목록화면 개발
페이지 목록의 출력
- <table> 태그가 끝나는 부분과 이어지게 <div>를 구성해서 페이지 번호들을 화면에 출력
- PageResponseDto는 시작 번호 start와 끝번호 end 만 가지고 있으므로 특정 범위의 숫자를 만들기 위해 Thymeleaf의 numbers를 이용
<!-- 특정 범위의 숫자를 만들기 위해 numbers 이용 -->
<div class="float-end">
<ul class="pagination flex-wrap">
<li class="page-item" th:if="${responseDto.prev}">
<a class="page-link" th:data-num="${responseDto.start -1}">Previous</a>
</li>
<th:block th:each="i: ${#numbers.sequence(responseDto.start, responseDto.end)}">
<li th:class="${responseDto.page == i}?'page-item active':'page-item'" >
<a class="page-link" th:data-num="${i}">[[${i}]]</a>
</li>
</th:block>
<li class="page-item" th:if="${responseDto.next}">
<a class="page-link" th:data-num="${responseDto.end + 1}">Next</a>
</li>
</ul>
</div>
- #numbers.sequence() 로 특정한 범위의 연속된 숫자를 만드는 부분과 <a>태ㅡ에 'data-num' 이라는 속성으로 페이지 번호를 처리하는 부분
- 브라우저 아래에 페이지 출력
- list?page=4 로 번호 출력
검색 화면 추가
- list.html 페이지에는 검색이 이루어질 수 있도록 <table> 위에 별도의 card 영역을 만들어서 검색 조건을 선택할 수 있도록 구성
- 검색 조건은 페이지 이동과 함께 처리될 수 있도록 form 태그로 감싸 처리
- http://localhost:9090/board/list?type=t&keyword=2 코드 작성 후 검색
이벤트 처리
- 페이지 번호를 클릭하거나 검색 / 필터링 조건을 눌렀을 대 이벤트를 처리 추가
- 처리 방식
- 페이지 번호를 클릭하면 검색 창에 있는 <form> 태그에 <input type='hidden'>으로 page를 추가한 후 submit
- clear 버튼을 누르면 검색 조건 없이 '/board/list' 호출
등록 처리 화면 개발
- 실제 Post 방식으로 처리할 때 눈 여겨 보야 하는 부분은 @Valid 에서 문제가 발생 했을 때 모든 에러를 errors라는 이름으로 RedirectAtrributes에 추가 해서 전송
register.html 처리
- templates/board 폴더에 register.html 을 추가