사용 기술
- Spring Data JPA로 영속 계층 처리
- Thymeleaf로 화면 처리
- 스프링 부트로 컨트롤러와 서비스 처리
구현 순서
- 서비스 계층과 DTO 구현
- 목록/검색 처리
- 컨트롤러와 화면 처리
서비스 계층과 DTO 구현
- ModelMapper 설정
- CRUD 작업 처리 : 등록 / 조회 / 수정 / 삭제
HTTP 요청 -> 컨트롤러에서 매핑 -> 서비스 계층에서 리포지토리 메서드 호출-> BoardRepository-> 서비스 계층에서 DTO로 변환
: 모든 DB로 접근은 리포지토리를 거쳐 엔티티에 접근하는 방식으로 엔티티 객체는 영속 컨텍스트에서 관리한다.
ModelMapper 설정
ModelMapper : 엔티티 <-> DTO를 담당한다.
(1) 의존성 추가
implementation 'org.modelmapper:modelmapper:3.1.0'
(2) config 패키지 생성해 RootConfig 클래스 작성
-@Configuration : 스프링 설정 클래스라는 것을 명시하는 어노테이션
-@Bean : ModelMapper를 스프링의 빈으로 등록하기 위한 어노테이션
package org.zerock.b01.config;
import org.modelmapper.ModelMapper;
import org.modelmapper.convention.MatchingStrategies;
import org.modelmapper.spi.MatchingStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.persistence.Column;
@Configuration
public class RootConfig {
@Bean
public ModelMapper getMapper(){
ModelMapper modelMapper = new ModelMapper();
modelMapper.getConfiguration()
.setFieldMatchingEnabled(true)
.setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE)
.setMatchingStrategy(MatchingStrategies.LOOSE);
return modelMapper;
}
}
CRUD 작업 처리
dto 패키지 생성해 BoardDTO 작성
-@Data : @Getter, @Setter, @ToString을 포함하는 어노테이션
-@Builder : 빌더 패턴을 이용해 객체를 생성하는 어노테이션
-@AllArgsConstructor / @NoArgsConstructor : 기본 생성자 생성, 모든 멤버변수가 존재하는 생성자 생성 어노테이션
package org.zerock.b01.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.time.LocalTime;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class BoardDTO {
private Long bno;
private String title;
private String content;
private String writer;
private LocalDateTime regDate;
private LocalDateTime modDate;
}
-> regDate와 modDate의 경우 BaseEntity를 이용해, 공통 속성을 가지는 클래스로 만든다.
1. 등록 처리
(1) BoardService 인터페이스 생성해 register() 메서드 선언
package org.zerock.b01.service;
import org.zerock.b01.dto.BoardDTO;
public interface BoardService {
Long register(BoardDTO boardDTO);
}
(2) BoardServiceImpl에 register() 메서드 구현
-@Service : 서비스라는 것을 알리고, 빈 등록하는 어노테이션
-@Log4j2 : Logging하기 위한 어노테이션
-@RequiredArgsConstructor : final이 붙거나 @NotNull이 붙은 필드의 생성자를 생성해주는 어노테이션
-@Transactional : 해당 객체를 감싸는 별도의 클래스를 생성해 선언적 트랜잭션 처리를 담당하는 어노테이션
(여러 번의 DB 연결이 있을 경우에 사용한다.)
package org.zerock.b01.service;
import com.sun.org.apache.xpath.internal.operations.Mod;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Service;
import org.zerock.b01.domain.Board;
import org.zerock.b01.dto.BoardDTO;
import org.zerock.b01.repository.BoardRepository;
import javax.transaction.Transactional;
@Service
@Log4j2
@RequiredArgsConstructor
@Transactional
public class BoardServiceImpl implements BoardService{
private final ModelMapper modelMapper;
private final BoardRepository boardRepository;
@Override
public Long register(BoardDTO boardDTO) {
//DTO -> Entity 변환
Board board=modelMapper.map(boardDTO, Board.class);
//리파지토리에 저장 (영속 컨텍스트에 저장) -> 데이터베이스와 동기화
Long bno = boardRepository.save(board).getBno();
return bno;
}
}
(3) 테스트 코드 작성
package org.zerock.b01.service;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
@Log4j2
public class ServiceTests {
//필드 주입
@Autowired
private BoardService boardService;
@Test
public void testRegister(){
log.info(boardService.getClass().getName());
//"org.zerock.b01.service.ServiceTests.testRegister"'.
}
}
@Test
public void testRegister(){
log.info(boardService.getClass().getName());
BoardDTO boardDTO= BoardDTO.builder()
.title("Sample Title...")
.content("Sample Content...")
.writer("user00")
.build();
Long bno = boardService.register(boardDTO);
log.info("bno : "+bno);
}
insert into board (moddate, regdate, content, title, writer) values (?, ?, ?, ?, ?) |
2. 조회 처리
-BoardService에 readOne() 메서드 선언, BoardServiceImpl에 readOne() 메서드 구현
@Override
public BoardDTO readOne(Long bno) {
//Null 처리를 위해 Optional 타입을 사용한다.
Optional<Board> result = boardRepository.findById(bno);
Board board=result.orElseThrow(IllegalAccessError::new);
BoardDTO boardDTO = modelMapper.map(board, BoardDTO.class);
return boardDTO;
}
3. 수정 처리
(1) BoardService에 modify() 메서드 선언, BoardServiceImpl에 readOne() 메서드 구현
-select()로 해당 객체가 존재하는지 확인 후, 존재하면 chage() 메서드 호출해 수정
@Override
public void modify(BoardDTO boardDTO) {
Optional<Board> result = boardRepository.findById(boardDTO.getBno());
Board board = result.orElseThrow(IllegalAccessError::new);
board.change(boardDTO.getTitle(), boardDTO.getContent());
boardRepository.save(board);
}
(3) 테스트 코드 작성
@Test
public void testUpdate(){
//변경에 필요한 데이터만으로 객체 생성
BoardDTO boardDTO = BoardDTO.builder()
.bno(10L)
.title("Updated...101")
.content("Updated content 101...")
.build();
boardService.modify(boardDTO);
}
4. 삭제 처리
-BoardService에 remove() 메서드 선언, BoardServiceImpl에 remove() 메서드는 bno를 이용해 삭제 처리
@Override
public void remove(Long bno) {
boardRepository.deleteById(bno);
}
목록/검색 처리
- 페이징(PageRequestDTO, PageResponseDTO)
- 서비스(BoardService, BoardServiceImpl)
페이징
- PageRequestDTO
- PageResponseDTO
- BoardService / BoardServiceImpl
1. PageRequestDTO
-@Data : @Getter, @Setter, @ToString를 포함하는 어노테이션
-@AllArgsConstructor / @NoArgsConstructor :DTO, VO는 파라미터를 모두 갖거나 하나도 없을 때 생성자 생성
-@Builder : Builder 패턴을 이용해 객체 생성
-@Builder.Default : 값이 null일 때 기본값으로 생성하는 어노테이션
(1) PageRequestDTO의 구성 요소
- 페이징 관련 정보
- 검색 조건
- 키워드
(2) PageRequestDTO의 구현
- page : 현재 페이지 번호
- 1페이지 ~ (전체 데이터 수/size)까지
- 기본값 @Builder.default, @Min 최소 1, @Positive 양수만 가능
- size : 한 페이지당 보여주는 데이터 수
- 설정가능한 한 페이지당 보여줄 데이터 개수
- 한 페이지당 10개에서 ~100개까지
- 기본값 @Builder.default, @Min 최소 10 ~ @Max 최대 100, @Positive 양수만 가능
- type : 문자열 하나로 처리해 나중에 각 문자를 분리하도록 검색 조건
- keyword : 키워드
- getTypes() : 리포지토리에서 문자열이 아니라 문자열 배열로 검색조건을 처리하기 때문에 배열로 반환하는 메서드
- getPageable() : 페이징 처리를 위해 Pageable 타입을 반환하는 메서드
- getLink() : 검색 조건과 페이징 조건 등을 문자열로 구성해 링크를 생성하는 메서드
public String[] getTypes(){
//검색 조건이 존재하지 않을 땐 null 리턴
if(type==null || type.isEmpty()){
return null;
}
//검색 조건이 존재하는 경우, 문자로 분리
return type.split("");
}
public Pageable getPageable(String...props){
return PageRequest.of(this.page -1 ,this.size, Sort.by(props).descending());
}
private String link;
public String getLink(){
//초기화 없이 선언만 했으므로 null인 상태
if(link ==null) {
StringBuilder builder = new StringBuilder();
builder.append("page=" + this.page);
builder.append("&size=" + this.size);
//검색 조건이 존재할 경우
if (type!=null && type.length()>0) {
builder.append("&type=" +type);
}
//키워드가 존재할 경우
if (keyword!=null) {
try{
builder.append("&keyword="+ URLEncoder.encode(keyword, "UTF-8"));
}catch (UnsupportedEncodingException e) {}
}
link= builder().toString();
}
return link;
}
2. PageResponseDTO
-직접 DTO를 작성하는 것보다 생성자를 이용해 안전하게 처리하는 것을 목적으로 작성한다.
-@Getter : 생성자를 이용하므로, 직접 Setter를 작성할 필요가 없다.
-@Builder : PageRequestDTO와 List<TodoDTO>, 전체 데이터 개수를 이용한 생성자 작성하는 어노테이션
(1) PageResponseDTO 구성 요소
- TodoDTO의 목록
- 전체 데이터 수
- 페이지 번호의 처리를 위한 데이터들 (시작 페이지 번호 / 끝 페이지 번호)
(2) PageResponseDTO 구현
- page : 현재 페이지 번호
- size : 한 페이지당 보여주는 데이터 수
- total : 전체 데이터 수
- start : 시작 페이지 번호
- end : 마지막 페이지 번호
- prev : 이전 페이지 존재 여부
- next : 이후 페이지 존재 여부
- dtoList : 현재 페이지의 객체들을 담은 리스트
- @Builder 어노테이션을 이용해 작성한 생성자
- page, size는 DTO를 통해, total과 dtolist는 직접 받는다.
- start, end의 경우, 열 개의 페이지 번호를 제공한다고 가정해
먼저 마지막 페이지를 구하고 마지막 페이지를 통해 start 페이지를 찾는다.
-> end : 올림한 (현재 페이지/10.0) * 10
-올림을 하는 이유는 페이지는 데이터가 하나라도 존재한다면 페이지 수가 필요하기 때문이다.
-> start : 마지막 페이지로부터 -9번째 - 중요한건 마지막 페이지가 존재하지 않을 수도 있다는 것이다
-> 페이지 개수가 10개보다 적을 수도 있기 때문에, 직접 마지막 번호를 구해 비교한다.
last : 올림한 (전체 페이지수/한 페이지당 보여주는 데이터 수)
-올림을 하는 이유는 페이지는 데이터가 하나라도 존재한다면 페이지 수가 필요하기 때문이다.
end : last변수를 이용해 마지막 페이지 번호가 last번호보다 크면 last번호를 마지막 번호로 지정한다. - prev : 1페이지보다 크면 이전 페이지가 존재하고,
next : 전체 데이터 수가 마지막 페이지 * 한 페이지당 보여주는 데이터 수 보다 크면 다음 페이지가 존재한다.
package org.zerock.b01.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import java.util.List;
@Getter
@Builder
@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){
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;
}
}
(3) BoardService / BoardServiceImpl
-목록 / 검색을 위한 list() 메서드 작성
-BoardRepository 호출
-Page<Board> -> List<BoardDTO>로 직접 변환
Page<T> -> List<E> 변환 방법
- 직접 변환
- 한번에 DTO로 추출
package org.zerock.b01.service;
import com.sun.org.apache.xpath.internal.operations.Mod;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.modelmapper.ModelMapper;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.zerock.b01.domain.Board;
import org.zerock.b01.dto.BoardDTO;
import org.zerock.b01.dto.PageRequestDTO;
import org.zerock.b01.dto.PageResponseDTO;
import org.zerock.b01.repository.BoardRepository;
import javax.transaction.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@Log4j2
@RequiredArgsConstructor
@Transactional
public class BoardServiceImpl implements BoardService{
private final ModelMapper modelMapper;
private final BoardRepository boardRepository;
@Override
public Long register(BoardDTO boardDTO) {
//DTO -> Entity 변환
Board board=modelMapper.map(boardDTO, Board.class);
//리파지토리에 저장 (영속 컨텍스트에 저장) -> 데이터베이스와 동기화
Long bno = boardRepository.save(board).getBno();
return bno;
}
@Override
public BoardDTO readOne(Long bno) {
//Null 처리를 위해 Optional 타입을 사용한다.
Optional<Board> result = boardRepository.findById(bno);
Board board=result.orElseThrow(IllegalAccessError::new);
BoardDTO boardDTO = modelMapper.map(board, BoardDTO.class);
return boardDTO;
}
@Override
public void modify(BoardDTO boardDTO) {
Optional<Board> result = boardRepository.findById(boardDTO.getBno());
Board board = result.orElseThrow(IllegalAccessError::new);
board.change(boardDTO.getTitle(), boardDTO.getContent());
boardRepository.save(board);
}
@Override
public void remove(Long bno) {
boardRepository.deleteById(bno);
}
@Override
public PageResponseDTO<BoardDTO> list(PageRequestDTO pageRequestDTO) {
//1. 리포지토리 호출
String[] types = pageRequestDTO.getTypes();
String keyword = pageRequestDTO.getKeyword();
Pageable pageable = pageRequestDTO.getPageable("bno");
Page<Board> result = boardRepository.searchAll(types, keyword, pageable);
//2. 스트림으로 Page<E> -> List<T>를 위해 modelMapper를 호출해 변환
List<BoardDTO> dtoList = result.getContent().stream()
.map(board -> modelMapper.map(board, BoardDTO.class))
.collect(Collectors.toList());
//3. PageResponseDTO<BoardDTO>를 생성자를 이용해 생성
//: 생성자를 호출하기위해 PageRequestDTO , dtoList, total가 필요
return PageResponseDTO.<BoardDTO>withAll()
.pageRequestDTO(pageRequestDTO)
.dtoList(dtoList)
.total((int)result.getTotalElements())
.build();
}
}
(4) 테스트 코드 작성
@Test
public void testList(){
PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
.type("tcw")
.keyword("1")
.page(1)
.size(10)
.build();
PageResponseDTO<BoardDTO> pageResponseDTO = boardService.list(pageRequestDTO);
log.info(pageResponseDTO);
}
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 '!' or board0_.writer like ? escape '!' ) and board0_.bno>? order by board0_.bno desc limit ? |
select count(board0_.bno) as col_0_0_ from board board0_ where ( board0_.title like ? escape '!' or board0_.content like ? escape '!' or board0_.writer like ? escape '!' ) and board0_.bno>? |
컨트롤러와 화면 처리
(1) 컨트롤러 작성
package org.zerock.b01.controller;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.zerock.b01.dto.BoardDTO;
import org.zerock.b01.dto.PageRequestDTO;
import org.zerock.b01.dto.PageResponseDTO;
import org.zerock.b01.service.BoardService;
@Controller
@RequestMapping("/board")
@Log4j2
@RequiredArgsConstructor
public class BoardController {
private final BoardService boardService;
}
(2) 화면 레이아웃 구성을 위한 의존성 추가
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
(3) 부트스트랩을 이용한 템플릿 디자인 적용
: simple-sidebar 템플릿을 static폴더에 추가
https://startbootstrap.com/template/simple-sidebar
(4) 부트스트랩 탬플릿을 타임리프 레이아웃으로 변경
-layout 폴더에 index.html 내용을 복사한 basic.html 추가
(5) 레이아웃 적용
-레이아웃, 타임리프 네임스파이스 추가
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:th="http://www.thymeleaf.org">
- <head> 태그에 존재하는 링크를 타임리프 스타일 로 변경 (타임리프에서 링크처리는 th:href="@{/...}"로 표현한다.
변경 전, 링크 처리
<!-- Favicon-->
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" />
<!-- Core theme CSS (includes Bootstrap)-->
<link href="css/styles.css" rel="stylesheet" />
변경 후, 링크 처리
<!-- Favicon-->
<link rel="icon" type="image/x-icon" th:href="@{/assets/favicon.ico}" />
<!-- Core theme CSS (includes Bootstrap)-->
<link th:href="@{/css/styles.css}" rel="stylesheet" />
- 본문에 타임리프 레이아웃 적용
-'Page Content' 부분의 div 속성으로 layout:fragment 적용
변경 전, div
<!-- Page content-->
<div class="container-fluid">
<h1 class="mt-4">Simple Sidebar</h1>
<p>The starting state of the menu will appear collapsed on smaller screens, and will appear non-collapsed on larger screens. When toggled using the button below, the menu will change.</p>
<p>
Make sure to keep all page content within the
<code>#page-content-wrapper</code>
. The top navbar is optional, and just for demonstration. Just create an element with the
<code>#sidebarToggle</code>
ID which will toggle the menu when clicked.
</p>
</div>
변경 후, div
<!-- Page content-->
<div class="container-fluid" layout:fragment="content">
<h1 class="mt-4">Simple Sidebar</h1>
<p>The starting state of the menu will appear collapsed on smaller screens, and will appear non-collapsed on larger screens. When toggled using the button below, the menu will change.</p>
<p>
Make sure to keep all page content within the
<code>#page-content-wrapper</code>
. The top navbar is optional, and just for demonstration. Just create an element with the
<code>#sidebarToggle</code>
ID which will toggle the menu when clicked.
</p>
</div>
-자바스크립트 블록 추가 후, 타임리프 스타일로 링크 수정
변경 전, 자바 스크립트
<!-- Core theme JS-->
<script src="js/scripts.js"></script>
변경 후, 자바 스크립트
<!-- Core theme JS-->
<script th:src="@{/js/scripts.js}"></script>
<th:block layout:fragment="script">
</th:block>
(6) 컨트롤러로 확인
-list() 메서드를 활용해 레이아웃 적용된 화면 구성
-templates/board 폴더에 list.html 추가
<!DOCTYPE html>
<html xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:th="http://www.thymeleaf.org"
layout:decorate="~{layout/basic.html}">
<!-- 타임리프 레이아웃을 적용해 본문만 적용-->
<div layout:fragment="content">
<h1> Board List</h1>
</div>
<!--화면에 나타나지 않고 빌트인 방식으로 실행되는 자바스크립트-->
<script layout:fragment="script" th:inline="javascript">
console.log("script.....")
</script>
'Server Programming > Spring Boot Backend Programming' 카테고리의 다른 글
6장-1. Ajax와 JSON을 이용한 REST 서비스 구현 (+ swagger, BindException, BindingResult, @RestControllerAdvice) (0) | 2022.12.07 |
---|---|
5장-5. 게시물 관리 프로젝트 구현 (2) CRUD (+ #temporals, #number, HistoryAPI) (0) | 2022.12.06 |
5장-3. Spring Data JPA (+ 리스너, Optional, 쿼리메서드,JPQL, Querydsl 동적쿼리) (0) | 2022.12.04 |
5장-2. Thymeleaf (0) | 2022.12.04 |
5장-1. 스프링 부트의 시작 (+Thymeleaf, RESTful, JSON, API Server) (0) | 2022.12.04 |