Spring Boot 5. 방명록 프로젝트
Spring Boot 5. 방명록 프로젝트
1. 프로젝트 구조
GuestbookController : 브라우저에서 들어오는 request 처리
GuestbookService 인터페이스 : 필요한 정보(DTO) 인터피에스. GuestBookController에서 GuestbookService 타입을 주고받음
GuestbookServicempl 클래스 : JPA처리를 위한 엔티티 클래스
GuestbookRepository : Spring Data JPA 를 이용해 구성
2. 프로젝트 생성
Dependencies 창에서 Spring Boot DevTools, Lombok, Spring Web, Thymeleaf, Spring Data JPA 선택 후 프로젝트 생성
build.gradle 패이지의 dependencies {} 안에 DB 드라이버 정보 입력 후 코끼리 아이콘 클릭해서 다운로드
implementation group: 'org.mariadb.jdbc', name: 'mariadb-java-client',
version: '3.1.2'
resources -> application.properties 페이지에 DB 관련 설정과 Thymeleaf 관련 설정 입력
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://localhost:3306/[DB 이름]
spring.datasource.username=[유저내임]
spring.datasource.password=[패스워드]
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=true
spring.thymeleaf.cache=false
3. 컨트롤러 & 화면 관련 준비
resources -> templates 폴더에 레이아웃 파일을 담을 layout 폴더와 화면 내용을 담을 guestbook 폴더를 생성
resources -> static 폴더 안에는 외부에서 다운받은 레이아웃 파일 복사 (스타트부트스트랩의 심플 사이드바 이용)
layout 폴더에 layout.html 파일을 생성하고 static 폴더 안의 index.html의 내용 복사 붙여넣고 thymeleaf 설정
<head> 와 <body> 태그를 <th:block> 태그로 감싸고 fragment속성을 지정해서 다른 페이지에서 불러 올 수 있도록 함
맨 아래의 <script> 태그를 <head> 태그 안으로 옮기고 CSS 와 Javascript 링크 속성 앞에 th를 추가하고 @{/ }으로 감쌈
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="setContent(content)">
<head>
<meta charset="utf-8" />
<meta name="viewport"content="width=device-width,initial-scale=1,shrink-to-fit=no"/>
<meta name="description" content="" />
<meta name="author" content="" />
<title>Simple Sidebar - Start Bootstrap Template</title>
<!-- Favicon-->
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" />
<!-- Core theme CSS (includes Bootstrap)-->
<link th:href="@{/css/styles.css}" rel="stylesheet" />
<script th:src="@{/js/scripts.js}"></script>
</head>
<body>
<body> 안의 내용이 표시되는 부분을 지우고 <th:block> 태그를 작성하고 replace속성을 지정해서 파라미터로 전달되는 content가 표시되도록 설정
<div class="container-fluid">
<th:block th:replace="${content}"></th:block>
</div>
프로젝트 패키지 안에 controller 패키지를 추가하고 GuestbookController 클래스 생성
아래 코드 작성
@Controller
@RequestMapping("/guestbook")
@Log4j2
public class GuestbookController {
@GetMapping({"/", "/list"})
public String list() {
log.info("List...");
return "/guestbook/list";
}
}
@Controller : 해당 클래스가 컨트롤러임을 나타냄
@RequestMapping : 서버에서 요청하기위함 ( ) 안에는 요청하는 가상디렉토리명 작성({} 를 이용하면 여러개 작성 가능
@Log4j2 : Lombok의 기능으로 로드를 남기도록 해줌
@GetMapping : 서버에서 연결 시 해당 메소드와 연결
반환값은 리다이렉트할 주소
templates -> guestbook 폴더 안에 list.html 파일 생성
layout.html을 불러와서 표시하고 콘텐츠 부분에 표시할 내용을 파라미터로 넘겨줌
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="~{/layout/layout :: setContent(~{this :: content})}">
<th:block th:fragment="content">
<h1>GuestBook List Page</h1>
</th:block>
</th:block>
<실행결과>
4. 자동으로 처리되는 날짜 시간 설정
데이터의 등록 시간과 수정시간과 같이 자동으로 추가되고 변경되어야 하는 컬럼들을 자동으로 처리할 수 있도록 어노테이션을 이용해서 설정
entity 패키지를 생성하고 엔티티 객체의 등록시간과 최종 수정 시간을 다룰 BaseEntity 추상 클래스 작성
@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;
}
@MappedSuperclass : 엔티티 객체의 테이블을 생성하지 않고 슈퍼 클래스로서만 사용하도록 함
@EntityListeners : 엔티티 객체에 어떤 변화가 일어나는지 감시하는 리스너
AuditingEntityListenser.class : JPA 내부에서 엔티티 객체가 생성, 변경되는 것을 감지
@CreatedDate : JPA에서 엔티티의 생성시간 처리
@Column(updatable) : 해당 컬럼이 업데이트 되지 않도록 함(생성시간은 변경되지 않기 때문)
@LastModifiedDate : 마지막으로 수정된 시간을 자동으로 처리
JPA를 이용하면서 AuditingEntityListener를 활성화시키기 위해 GuestbookApplication 클래스에 @EnableJpaAuditing 추가
@SpringBootApplication
@EnableJpaAuditing
public class GuestbookApplication {
public static void main(String[] args) {
SpringApplication.run(GuestbookApplication.class, args);
}
}
5. 엔티티 클래스
1) 엔티티 클래스 설정
entity 패키지 안에 Guestbook 엔티티 클래스 작성 -> BaseEntity 클래스 상속
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Guestbook extends BaseEntity{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long gno;
@Column(length = 100, nullable = false)
private String title;
@Column(length = 1500, nullable = false)
private String content;
@Column(length = 50, nullable = false)
private String writer;
}
-> 글 번호, 제목, 내용, 작성자 커럼에 대한 필드 작성
repository 패키지를 생성하고 JPA 메소드를 이용하기 위한 GuestbookRepository 작성(JpaRepository 상속)
public interface GuestbookRepository extends JpaRepository<Guestbook, Long> {
}
<엔티티 객체, PK의 타입>
=> 프로젝트 실행
-> 실제로 실행된 쿼리
-> 새로운 테이블(guestbook)이 생성되고 컬럼들이 설정됨
6. 동적 쿼리 처리를 위한 Querydsl 설정
JPA의 쿼리 메소드와 @Query를 통해 많은 기능을 구현할 수는 있지만 선언할 때 고정된 형태의 값을 가짐
복잡한 조합을 이용하는 경우의 수가 많은 상황에서는 동적으로 쿼리를 생성해서 처리해야 함
Querydsl을 이용하면 동적으로 복잡한 검색조건이나 조인, 서브 쿼리등의 기능도 구현 가능
Querydsl을 이용하기 위해서는 엔티티 객체를 그대로 이용하는 것이 아닌 'Q도메인'이라는 것을 이용해야함
Querydsl 라이브러리를 이용해서 엔티티 클래스를 Q 도메인 클래스로 변환하는 방식 이용
-> build.gradle에 설정 추가
- 가장 위에 변수 사용관련 코드 추가
buildscript {
ext {
queryDslVersion = "5.0.0"
}
}
- dependencies 항목에 필요한 라이브러리 추가
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation group: 'org.mariadb.jdbc', name: 'mariadb-java-client',
version: '3.1.2'
// 추가
implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta"
annotationProcessor(
"jakarta.persistence:jakarta.persistence-api",
"jakarta.annotation:jakarta.annotation-api",
"com.querydsl:querydsl-apt:${queryDslVersion}:jakarta")
}
- 맨 아래에 코드 추가
sourceSets {
main {
java {
srcDirs = ["$projectDir/src/main/java", "$projectDir/build/generated"]
}
}
}
compileJava.dependsOn('clean')
build.gradle 파일에 설정을 추개했으면 build.gradle을 코끼리모양 아이콘을 클릭해서 갱신
프로젝트를 실행시키면 프로젝트 내 build 폴더 안에 Q도메인 클래스를 포함한 구조가 생성됨
(Q로 시작하고 엔티티 클래스의 이름과 동일하게 만들어짐)
파일을 보면 내무적으로 선언된 필드 들이 변수 처리 되어 있음
Q로 시작하는 클래스들은 개발자가 직접 건드리지 않음
gradle의 compileQuerydsl과 같은 task를 통해서 자동으로 생성됨
- JpaRepository 인터페이스에 추가 설정
Querydsl을 이용하려면 GuestbookRepository 인터페이스가 추가적으로 QuerydslPredicateExecutor 인터페이스를 상속받아야 함
public interface GuestbookRepository extends JpaRepository<Guestbook, Long>,
QuerydslPredicateExecutor<Guestbook> { // 인터페이스 추가로 상속
}
7. 엔티티 테스트
개발을 진행하기 전 DB와 엔티티 관련해서 테스트를 진행
test -> repository 패키지 생성 -> GuestbookRepositoryTest 클래스 생성
테스트 클래스임을 알려주기 위해 @SpringBootTest 어노테이션 추가
GuestbookRepository 인터배이스를 참조하기 위해 @Autowire 어노테이션과 GuestbookRepository 인터페이스 참조변수 생성
@SpringBootTest
public class GuestbookRepositoryTest {
@Autowired
GuestbookRepository guestbookRepository;
}
1) DB에 300개의 테스트 데이터 넣기
테스트 메소드 작성
@Test
public void insertDummies() {
IntStream.rangeClosed(1, 300).forEach(i -> {
Guestbook guestbook = Guestbook.builder()
.title("Title..." + i)
.content("Content..." + i)
.writer("User" + (i%10))
.build();
System.out.println(guestbookRepository.save(guestbook));
});
}
@Test :테스트 메소드임을 나타냄
IntStream.rangeClosed() : () 안의 두 수를 포함하는 정수형 스트림 생성
-> 1 ~ 300 까지의 Guestbook 객체를 생성해서 JPA의 save() 메소드를 이용해서 DB에 저장
<실행결과>
-> DB에 300개의 테스트 데이터가 추가됨
-> regdate와 moddate 컬럼에도 값이 자동으로 해당 시간으로 채워짐
2) 마지막 수정 시간 테스트
엔티티 클래스는 가능하면 setter 관련 기능을 구현하지 않지만 필요에 따라 수전 기능을 구현하기도 함 -> 최소한의 수정만 가능하게 하는 것을 권장
테스트 목적으로 Guestbook 클래스에 title과 content 필드를 수정하는 메소드 추가
// 테스트용 수정 메소드
public void changeTitle(String title) {
this.title = title;
}
public void changeContent(String content) {
this.content = content;
}
BaseEntity 클래스의 modDate는 최종 수정 시간을 저장하는 필드이므로 특정 엔티티를 수정한 후 save() 메소드를 이용해서 DB에 저장했을 때 의도대로 동작하는 지 확인
GuestbookRepositoryTest.java 에 테스트 메소드 작성
@Test
public void changeEntity(){
// findById() 메소드로 엔티티 객체를 옵셔널로 받아옴
Optional<Guestbook> result = guestbookRepository.findById(300L);
// 옵셔널 객체가 참조하는 객체가 존재하면 실행
if (result.isPresent()){
// 옵셔널 객체에서 엔티티 객체 추출
Guestbook guestbook = result.get();
// title과 content 수정 메소드 호출
guestbook.changeTitle("Changed Title...");
guestbook.changeContent("Changed Content...");
// DB에 저장
guestbookRepository.save(guestbook);
}
}
<실행결과>
-> gno가 300인 컬럼만 moddate 값이 변경되어있음
3) Querydsl 테스트
Querydsl 을 이용하면 Select 문의 처리가 쉬워짐
여러 조건이 필요한 검색작업 수행
- Querydsl 사용방법
Q도메인 객체 생성
BooleanBuilder 객체 생성
WHERE절에 들어가는 조건식을 담는 BooleanExpression 객체를 생성하고 Q도메인 객체를 이용해서 조건식 작성
BooleanBuilder에 BooreanExpression 객체를 추가하고 실행
* 예시 - 타이틀에 7이 포함된 요소 검색
@Test
public void querydslTest1() {
// 패이징 처리를 위한 Pageable 객체
Pageable pageable = PageRequest.of(0, 10, Sort.by("gno").descending());
String keyword = "7";
QGuestbook qGuestbook = QGuestbook.guestbook;
BooleanBuilder builder = new BooleanBuilder();
BooleanExpression expression = qGuestbook.title.contains(keyword);
builder.and(expression);
Page<Guestbook> result = guestbookRepository.findAll(builder, pageable);
result.stream().forEach(guestbook -> {
System.out.println(guestbook);
});
}
QGuestbook.guestbook : 자동으로 생성된 Q도매인 객체의 static 변수
qGuestbook.title.contains() : 타이틀이 () 안의 값 포함 여부 조건을 나타내는 BooleanExpression 객체
builder.and() : WHERE 절의 조건을 나타내는 BooeanExpression 객체를 AND 문으로 WHERE 문에 추가. or() 메소드를 이용하면 OR 문으로 WHERE문에 추가됨
guestbookRepository.findAll() : 기존의 findAll()메소드가 아니라 guestbookRepository가 추가로 상속한 QuerydslPredicateExcutor 인터페이스의 메소드로 매개변수로 BooleanBuilder 객체와 Pageable 객체를 받음
<실행결과>
-> 의도한 대로 where절이 작성되고 실행 결과가 페이징 처리되어 첫번 째(0번째) 페이지의 내용이 출력됨
* 예시 - 다중 항목 검색 (타이틀이 8을 포함하거나 "50"을 포함하고 gno가 30보다 큼)
@Test
public void querydslTest2() {
Pageable pageable = PageRequest.of(0, 10, Sort.by("gno").ascending());
String keyword = "8";
QGuestbook qGuestbook = QGuestbook.guestbook;
BooleanBuilder builder = new BooleanBuilder();
// 타이틀이 8을 포함하거나 "50"을 포함
BooleanExpression exTitle = qGuestbook.title.contains(keyword);
BooleanExpression exContent = qGuestbook.content.contains("50");
BooleanExpression expression = exTitle.or(exContent);
builder.and(expression);
// gno가 30보다 큼 (gt : greater than)
builder.and(qGuestbook.gno.gt(30L));
Page<Guestbook> result = guestbookRepository.findAll(builder, pageable);
for (Guestbook guestbook : result) {
System.out.println(guestbook);
}
}
<실행결과>
8. 서비스 계층과 DTO
실제 프로젝트에서는 엔티티 객체를 DB 밖에서 사용하는 방식 보다는 DTO(Data Transfer Object)를 이용하는 방식 사용
- DTO
순수하게 데이터를 담고 있음
데이터 전달이 목적 -> 읽고 쓰는 것이 모두 허용
일회성으로 사용되는 성격
- 서비스 계층
DTO로 파라미터와 리턴 타입을 처리하도록 구성
DTO 객체와 엔티티 객체 간의 변환을 수행
화면과 데이터를 분리하도록 해줌
엔티티 객체의 범위를 한정지어서 보다 안전한 코드 작성 가능
* 예시
프로젝트 내에 dto, service 패키지 생성
dto패키지 안에는 GuestBookDto 클래스를 생성하고 service 패키지 안에는 GuestbookService 인터패이스와 GuestbookServiceImpl 클래스 생성
GuestbookDTO.java
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class GuestbookDTO {
private Long gno;
private String title;
private String content;
private String writer;
private LocalDateTime regDate, modDate;
}
@Data : DTO 클래스임을 나타냄
엔티티 객체와 동일하게 필드들을 정의
GuestbookService.java
public interface GuestbookService {
// 추상메소드
Long register(GuestbookDTO dto);
}
GuestbookServiceImpl.java (GuestbookService 인터페이스 구현)
@Service
@Log4j2
public class GuestbookServiceImpl implements GuestbookService {
// 추상메소드 임시로 구현
@Override
public Long register(GuestbookDTO dto) {
return null;
}
}
@Service : 서비스계층의 클래스임을 명시. 스프링은 해당 클래스를 자동으로 스캔해서 빈(Bean)으로 등록하고 해당 클래스의 객체를 필요로 하는 다른 클래스에 주입할 수 있도록 함
1) DTO를 엔티티로 변환해서 DB에 등록
서비스계층에서 파라미터를 DTO 타입으로 받기 때문에 이를 JPA로 처리하기 위해 엔티티 객체로 변환하는 작업 필요
GuestbookService 인터페이스에 default 키워드로 변환 메소드를 직접 구현
* default
인터페이스에 실제 작동하는 코드를 작성할 때 사용
추상 클래스를 생략하고 바로 구현 클래스에서 해당 메소드 이용 가능
* 예시
public interface GuestbookService {
// 추상메소드
Long register(GuestbookDTO dto);
// DTO 객체를 엔티티 객체로 변환
default Guestbook dtoToEntity(GuestbookDTO dto) {
Guestbook entity = Guestbook.builder()
.gno(dto.getGno())
.title(dto.getTitle())
.content(dto.getContent())
.writer(dto.getWriter())
.build();
return entity;
}
}
-> GuestbookServiceImpl 클래스에서 해당 메소드를 이용해서 DTO 객체를 엔티티 객체로 변환
=> register() 메소드 수정
@Service
@Log4j2
public class GuestbookServiceImpl implements GuestbookService {
@Override
public Long register(GuestbookDTO dto) {
log.info("DTO_________________");
log.info(dto);
// 인터페이스에 정의한 dtoToEntity() 메소드 호출
Guestbook entity = dtoToEntity(dto);
log.info(entity);
return null;
}
}
-> 테스트 코드를 이용해서 테스트
test 폴더에 service 패키지 추가 -> GuestbookServictTest 클래스 생성
@SpringBootTest
public class GuestbookServiceTest {
// service 인터페이스 참조
@Autowired
private GuestbookService service;
@Test
public void testRegister() {
GuestbookDTO dto = GuestbookDTO.builder()
.title("Sample Title...")
.content("Sample Content...")
.writer("user0")
.build();
// service 인터페이스의 register() 메소드 호출해서 dto 객체를 엔티티 객체로 변환
System.out.println(service.register(dto));
}
}
<실행결과>
-> DTO 객체가 엔티티 객체로 변환됨
=> 메소드가 정상적으로 작동하는 것을 확인했으니 실제로 DB에 반영
-> GuestbookServiceImpl 클래스의 register 메소드 수정
@Service
@Log4j2
@RequiredArgsConstructor // 클래스 선언시 repository 인터페이스 자동 주입 위해 사용
public class GuestbookServiceImpl implements GuestbookService {
private final GuestbookRepository repository; // 반드시 final로 선언
@Override
public Long register(GuestbookDTO dto) {
log.info("DTO_________________");
log.info(dto);
// 인터페이스에 정의한 dtoToEntity() 메소드 호출
Guestbook entity = dtoToEntity(dto);
log.info(entity);
// DB에 엔티티 객체 저장
repository.save(entity);
return entity.getGno();
}
}
-> 테스트 메소드 (testRegister()) 실행해서 DB에 엔티티 객체가 추가되는지 확인
<실행결과>
testRegister() 메소드에서 register() 메소드를 호출하고 그 안에서 DTO객체를 생성한 후 dtoToEntity() 메소드를 호출해서 엔티티 객체로 변환해서 JPA의 save() 메소드를 이용해 DB에 추가
9. 목록 처리
* 목록 처리에 필요한 작업
화면에 필요한 목록 데이터에 대한 DTO 생성(entitiy -> DTO)
DTO를 Pageable 타입으로 전환
Page<Entity>를 화면에서 사용하기 쉬운 List<DTO> 타입등으로 변환
화면에 필요한 페이지 번호 처리
GET 방식으로 파라미터를 전달하면 페이지 처리를 하고 DTO 리스트를 출력
* DTO를 이용하는 이유
엔티티 객체는 DB에 영향을 받기 때문에 DTO를 이용하면 순수한 데이터를 사용하는 것이 가능
엔티티 객체는 불변을 유지해야 하지만 DTO 객체는 변할 수 있음
검증이나 화면에 필요한 추가적인 기능들을 사용 가능
1) 목록 처리를 위한 DTO
- 페이지 요청 처리 DTO (PageRequestDTO)
목록 페이지를 요청할 때 사용하는 데이터를 재사용하기 쉽게 만드는 클래스
페이지 번호, 페이지 내 목록의 개수, 검색 조건등의 파라미터를 DTO로 선언하고 나중에 재사용하는 용도
dto 패키지에 PageRequestDTO 클래스 생성
@Builder
@AllArgsConstructor
@Data
public class PageRequestDTO {
// 페이지 번호와 페이지당 리스트의 개수를 저장하는 필드
private int page;
private int size;
// 기본 생성자
// page와 size에 초기값 지정
public PageRequestDTO() {
this.page = 1;
this.size = 10;
}
// Sort를 매개변수로 전달받아 Pageable 객체를 생성해서 반환하는 메소드
public Pageable getPageable(Sort sort) {
return PageRequest.of(page-1, size, sort);
}
}
@NoArgsConstructor 어노테이션이 없는 이유는 클래스 내부에서 기본 생성자를 정의하기 때문
정렬 방식은 다양한 상황에서 사용하기 위해 파라미터로 받도록 설계
- 페이지 결과 처리 DTO (PageResultDTO)
JpaRepository에서 페이지 처리 결과를 Page<Entity> 타입으로 반환하면 이를 List<DTO> 타입으로 변환하는 클래스
화면 출력에 필요한 페이지 정보들을 구성
dto 패키지 안에 PageResultDTO 클래스 생성
@Data
public class PageResultDTO <DTO, EN>{
private List<DTO> dtoList;
public PageResultDTO(Page<EN> result, Function<EN, DTO> fn) {
// Page<>의 엔티티 객체들을 fn을 이용해서 DTO 태입으로 변황하고 List 타입으로 수집
dtoList = result.stream().map(fn).collect(Collectors.toList());
}
}
다양한 곳에서 사용할 수 있도록 제네릭 타입을 이용 (DTO와 EN은 각각 DTO 객체와 엔티티 객체의 타입을 의미)
Function<EN, DTO> : Java 에서 제공하는 함수형 인터페이스. EN 타입의 인자를 전달받아 DTO 타입으로 변환해서 반환하는 메소드를 가지고 있음 (엔티티 객체를 전달받아서 DTO 타입으로 변환)
어떤 종류의 Page<E> 타입이 생성되더라도 처리할 수 있음
List<DTO> 타입으로 DTO 객체들을 보관
2) 서비스 계층에서 목록 처리
서비스 계층에 PageRequestDTO 타입을 파라미터로 받고 PageResultDTO 타입을 반환하는 getList() 메소드 구현
GuestbookService 인터페이스에 아래 코드 추가
public interface GuestbookService {
// 추상 메소드 추가
// PageRequestDTO타입을 받아서 PageResutlDTO 타입을 반환하는 메소드
PageResultDTO<GuestbookDTO, Guestbook> getList(PageRequestDTO requestDTO);
// 추가
// 엔티티 객체를 DTO 객체로 변환
default GuestbookDTO entityToDto(Guestbook entity) {
GuestbookDTO dto = GuestbookDTO.builder()
.gno(entity.getGno())
.title(entity.getTitle())
.content(entity.getContent())
.writer(entity.getWriter())
.regDate(entity.getRegDate())
.modDate(entity.getModDate())
.build();
return dto;
}
}
GuestbookServiceImpl 클래스에서 추상메소드 getList() 구현
// 인터페이스의 추상메소드 getList() 구현
@Override
public PageResultDTO<GuestbookDTO, Guestbook> getList(PageRequestDTO requestDTO) {
// PageRequestDTO의 getPageable() 메소드를 이용해서
// Sort 정보를 넘겨주고 Pageable 객체 반환받음
Pageable pageable = requestDTO.getPageable(Sort.by("gno").descending());
// JPA의 findAll 메소드에 반환받은 Pageable 객체를 넘겨줘서 패이징 처리된 Page<> 객체를 반환받음
Page<Guestbook> result = repository.findAll(pageable);
// 엔티티 객체를 DTO 객체로 변환하는 Function 객체 생성
Function<Guestbook, GuestbookDTO> fn = (entity -> entityToDto(entity));
// PageResultDTO 객체를 생성해서 반환
// PageResultDTO 객체는 전달받은 Page<>타입에서 엔티티 객체들를
// fn을 이용해서 DTO로 변환하고 List<>로 수집해서 가지고 있음
return new PageResultDTO<>(result, fn);
}
- 목록 처리 테스트
우선 엔티티 객체들이 DTO 객체들로 변환되었는지 확인
GuestbookServiceTest 클래스에 아래 테스트 메소드 작성
@Test
public void testlist() {
// 패이징 정보를 포함한 PageRequestDTO 객체 생성
PageRequestDTO requestDTO = PageRequestDTO.builder()
.page(1)
.size(10
).build();
// GuestbookService 인터페이스의 getList() 메소드를 이용해서 엔티티 객체를 DTO 객체로 변환해서
// List 로 수집한 PageResultDTO 객체 반환
PageResultDTO<GuestbookDTO,Guestbook> resultDTO = service.getList(requestDTO);
// List의 요소를 하나씩 출력
for(GuestbookDTO dto: resultDTO.getDtoList()){
System.out.println(dto);
}
}
<실행결과>
-> Page<Guestbook>이 List<GuestbookDTO>로 정상적으로 변환되어 GuestbookDTO 타입으로 출력
- 목록 데이터 페이지 처리
화면까지 전달되는 데이터는 PageResultDTO 객체
시작 페이지, 끝 페이지등 필요한 모든 정보를 PageResultDTO 객체에 담아서 전달
화면에서는 필요한 내용들만 찾아서 구성이 가능
* 목록 페이지에 필요한 내용
화면에서 시작 페이지 번호
화면에서 끝 페이지 번호
이전 / 다음 이동 링크 표시 여부
현재 페이지 번호
* 예시 - 필요한 정보들을 모두 포함한 PageResultDTO 클래스
@Data
public class PageResultDTO <DTO, EN>{
// 화면에 표시할 DTO 객체 List
private List<DTO> dtoList;
// 추가된 프로퍼티
// 총 페이지 번호
private int totalPage;
// 현재 페이지 번호
private int page;
// 페이지당 표시할 데이터 개수 (목록 사이즈)
private int size;
// 화면에 표시할 시작 페이지 번호, 끝 페이지 번호
private int start, end;
// 이전, 다음 링크 표시 여부
private boolean prev, next;
// 목록에 표시할 페이지 리스트
private List<Integer> pageList;
// 생상지
public PageResultDTO(Page<EN> result, Function<EN, DTO> fn) {
// Page<>의 엔티티 객체들을 fn을 이용해서 DTO 태입으로 변황하고 List 타입으로 수집
dtoList = result.stream().map(fn).collect(Collectors.toList());
// 추가 내용
// 총 페이지 수
this.totalPage = result.getTotalPages();
// Pageable 객체를 받아서 makePageList()메소드에 넘겨줌
makePageList(result.getPageable());
}
// Pageable 객체 관련 정보를 받아오는 메소드
public void makePageList(Pageable pageable) {
// 현제 페이지 번호와 페이지당 표시할 데이터 개수
this.page = pageable.getPageNumber() + 1;
this.size = pageable.getPageSize();
// 임시 끝 페이지 번호
int tmpEnd = (int)(Math.ceil(page / 10.0)) * 10;
// 화면에 표시활 시작 페이지 번호
this.start = tmpEnd - 9;
// 화면에 표시할 마지막 페이지 번호
this.end = totalPage > tmpEnd ? tmpEnd : totalPage;
// 이전으로 이동하는 링크 표시 여부
this.prev = start > 1;
// 다음으로 이동하는 링크 표시 여부
this.next = totalPage > tmpEnd;
// 화면에 표시하는 페이지 번호 리스트
this.pageList = IntStream.rangeClosed(start, end)
.boxed().collect(Collectors.toList());
}
}
화면에 표시할 페이지의 끝 번호는 현제 페이지 번호를 10으로 나눈 후 올림 해서 다시 10을 곱해서 구함 (화면에 한 번에 10개의 페이지 번호만 표시할 경우)
단 마지막에 화면에 표시할 페이지 번호가 10개보다 적은 경우 마지막 페이지 까지만 표시해야해서 먼저 임시로 마지막 페이지 번호를 구하고 totalPage의 값과 비교해서 totalPage보다 임시 끝 페이지번호가 크거나 같으면 totalPage를 화면에 표시할 마지막 페이지 번호로 지정
시작 페이지 번호는 임시 마지막 페이지 번호에서 9를 빼서 구함(한 화면에 10개의 페이지를 표시하는 경우)
* 예시 - PageResultDTO 객체 테스트
GuestbookServiceTest 클래스에 테스트 메소드 작성
@Test
public void testlist() {
// 패이징 정보를 포함한 PageRequestDTO 객체 생성
PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
.page(1)
.size(10)
.build();
// GuestbookService 인터페이스의 getList() 메소드를 이용해서 엔티티 객체를 DTO 객체로 변환해서
// List 로 수집한 PageResultDTO 객체 반환
PageResultDTO<GuestbookDTO, Guestbook> resultDTO=service.getList(pageRequestDTO);
// 이전, 다음 이동 링크 표시 여부 & 총 페이지 수 출력
System.out.println("Prev : " + resultDTO.isPrev());
System.out.println("Next : " + resultDTO.isNext());
System.out.println("Total : " + resultDTO.getTotalPage());
System.out.println("==================================");
// List의 요소의 gno를 하나씩 출력
for(GuestbookDTO dto: resultDTO.getDtoList()){
System.out.println(dto.getGno());
}
System.out.println("===================================");
// 한 화면에 표시할 페이지 번호 리스트 출력
resultDTO.getPageList().forEach(i -> System.out.print(i + " "));
}
<실행결과>
10. 컨트롤러와 화면에서 목록 처리
목록을 처리하기 위해 컨트롤러를 작성
Controller
@RequestMapping("/guestbook")
@Log4j2
// 인터페이스 객체를 자동으로 주입하도록 하는 어노테이션 추가
@RequiredArgsConstructor
public class GuestbookController {
// 인터페이스의 메소드를 이용하기 위한 참조 변수
private final GuestbookService service;
// 루트 주소로 접속하면 list로 리다이렉트
@GetMapping("/")
public String index() {
// 리다이렉트될 경로
return "redirect:/guestbook/list";
}
@GetMapping("list")
public void list(PageRequestDTO pageRequestDTO, Model model) {
log.info("List...");
// Model에 데이터를 전달하기 위해 속성 추가 (이름, 내용)
// PageRequestDTO 객체로 부터 getList() 메소드를 통해 PageResultDTO 객체 반환받음
model.addAttribute("result", service.getList(pageRequestDTO));
}
}
스프링 MVC는 파라미터를 자동으로 수집해주는 기능이 있어서 파라미터로 page나 size 값을 넘겨주면 자동으로 PageRequestDTO 객체로 수집됨
(파라미터로 값을 넘겨주지 않으면 PageRequestDTO객체의 기본 생성자에 의해서 page = 0, size = 10으로 생성됨)
Model 클래스의 객체는 데이터를 화면에 전달하는 용도로 사용되며 속성을 이름과 내용으로 추가해줌
- 화면 구성 코드
부트스트랩의 테이블 구조를 이용해서 리스트 출력
부트스트랩의 템플릿을 이용하기 때문에 list.html의 일부분에 화면에 표시할 내용 작성
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="~{/layout/layout :: setContent(~{this :: content})}">
<th:block th:fragment="content">
<h1>GuestBook List Page</h1>
<table class="table table-striped">
<thead> <!-- 머리글 -->
<tr>
<th scope="col">#</th>
<th scope="col">Title</th>
<th scope="col">Writer</th>
<th scope="col">RegDate</th>
</tr>
</thead>
<tbody> <!-- 내용 -->
<!-- thymeleaf 반복문 -->
<tr th:each="dto : ${result.dtoList}">
<th scope="row">[[${dto.gno]]</th>
<td>[[${dto.title}]]</td>
<td>[[${dto.writer}]]</td>
<!-- 날짜 출력 포멧 -->
<td>[[${#temporals.format(dto.regDate, "yyyy/MM/dd)}]]</td>
</tr>
</tbody>
</table>
</th:block>
</th:block>
<th>태그의 scope 속성은 해당 열이나 행을 대표한다는 의미 (col, colgroup, row, rowgroup으로 지정)
반복문을 이용해서 전달받은 result 파라미터의 dtoList에서 하나씩 테이블에 출력
<실행결과>
- 페이지 번호 & 앞 뒤 이동 링크 처리
list.html 밑에 <ul> 태그를 이용해서 링크 생성
<ul class="pagination h-100 justify-content-center align-items-center">
<!-- 이전 링크 -->
<li class="page-item" th:if="${result.prev}">
<a class="page-link" th:href="@{/guestbook/list(page=${result.start-1})}"
tabindex="-1">Previous</a>
</li>
<!-- 페이지 번호 -->
<li th:class="'page-item ' + ${result.page == page ? 'active' : ''}"
th:each="page: ${result.pageList}">
<a class="page-link" th:href="@{/guestbook/list(page=${page})}"
tabindex="-1">[[${page}]]</a>
</li>
<!-- 다음 링크 -->
<li class="page-item" th:if="${result.next}">
<a class="page-link" th:href="@{/guestbook/list(page=${result.end+1})}">Next</a>
</li>
</ul>
class 속성들은 적욘한 템플릿의 스타일을 적용하기 위함
이전 링크와 다음 링크는 thymeleaf의 조건문을 이용해서 넘겨받은 result 객체의 prev, next에 때라 시여부 결정
이전, 다음 이동 링크는 href 속성에 지정하는데 thymeleaf를 이용해서 링크 뒤에 파라미터를 전달.
result 객채의 start, end 에서 1씩 더하고 빼서 이전, 다음 페이지로 이동하도록 지정
이전 링크의 tabindex 속성은 탭의 순서를 지정하는 속성인데 -1을 줘서 무시하도록 함
페이지 번호는 반복문을 이용해서 result 객체의 pageList의 정수들을 불러와서 표시
삼항 연산자를 이용해서 현재 페이지에 해당하는 경우 class 속성을 다르게 해서 표시되록 지정
11. 등록 페이지와 등록 처리
GuestbookController 클래스에 등록을 위한 @GetMapping, @PostMapping 메소드 추가
@GetMapping("/register")
public void register() {
log.info("register get");
}
@PostMapping("/register")
public String registerPost(GuestbookDTO dto, RedirectAttributes redirectAttributes) {
log.info("dto..." +dto.getGno());
// service의 register() 메소드를 이용해서 dto 객치를 entity 객체로 변환해서 DB에 저장
// 새로 추가된 엔티티 번호 반환
Long gno = service.register(dto);
redirectAttributes.addFlashAttribute("msg", gno);
return "redirect:/gusetbook/list";
}
@GetMapping 메소드는 등록 화면을 표시하기 위한 용도
@PostMapping 메소드는 등록 화면의 <form>에서 post 하면서 전송한 파라미터를 처리
전송된 파라미터들은 자동으로 DTO 객체로 수집됨
DTO 객체는 service.register() 메소드에 의해서 entity 객체로 변환되서 DB에 저장됨
RedirectAttributes는 addFlashAttribute() 메소드를 이용해서 "msg"라는 이름으로 한 번만 사용할 수 있는 값 전송 -> 등록되었음을 확신하는 모달 창에 출력되는 용도
resources -> templates -> guestbook 안에 register.html 생성
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="~{/layout/layout :: setContent(~{this :: content})}">
<th:block th:fragment="content">
<h1 class="mt-4">GuestBook Register Page</h1>
<form th:action="@{/guestbook/register}" th:method="post">
<div class="form-group">
<label>Title</label>
<input type="text" class="form-control" name="title"
placeholder="Enter Title">
</div>
<div class="form-group">
<label>Content</label>
<textarea class="form-control" rows="5" name="content"></textarea>
</div>
<div class="form-group">
<label>Writer</label>
<input type="text" class="form-control" name="writer"
placeholder="Enter Writer">
</div>
<br>
<Button type="submit" class="btn btn-primary">Submit</Button>
</form>
</th:block>
</th:block>
<th:block> 안의 내용을 layout.html의 콘텐츠 부분으로 보내서 템플릿이 적용되도록 함
<form> 의 action과 method를 지정해서 GuestbookController의 @PostMapping 메소드를 호출
입력받는 요소들의 name 속성은 DTO 객체의 필드의 이름과 동일하게 지정해서 자동으로 DTO 객체로 수집되도록 함
등록 페이지를 작성하고 Submit 버튼을 클릭하면 list 페이지로 리다이렉트 되고 입력한 값들은 DB에 추가되어 list 페이지에서 추가된 것을 확인할 수 있음
- 등록 페이지 모달창
등록은 POST 방식이고 자동으로 "/guestbook/list"로 이동
이때 등록후 처리 되었다는 결과를 모달창을 띄워서 유저에게 알림
list.html 파일에 모달창을 작성하고 속성을 tabindex="-1"로 지정해서 화면에 보이지 않도록 설정
등록 처리 후 전송하는 파라미터 msg에 값이 있으면 javascript 코드를 이용해서 모달창을 띄움
$() 형식의 jquery를 사용할 것이기 때문에 라이브러리를 추가해야 함
가장 상단의<html> 태그 아래에 <script>태그를 작성해서 라이브러리 추가
<script src="https://code.jquery.com/jquery-3.7.0.js"></script>
모달창 작성 -> 부트스트랩에서 가져오기
https://getbootstrap.com 에 접속
Docs -> Components -> Modal -> Modal components 의 내용 복사해서 list.html 파일의 </ul> 아래에 붙여넣기
</ul>
<div class="modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Modal title</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Modal body text goes here.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary"
data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary">Save changes</button>
</div>
</div>
</div>
</div>
모달창의 타이틀과 내용 변경 -> 타이틀에는 파라미터 msg로 전달받은 gno값, 내용에는 'Registered Successful' 로 지정
하단의 두 개의 버튼 중 하나만 필요하므로 아래 <button> 태그 삭제
상단의 x 버튼과 하단의 버튼 클릭시 모달창이 사라지도록 처리
-> 버튼의 onclick 속성에 "$('.modal').hide()" 작성 -> class 가 modal인 태그에 .hide() 메소드 실행
<div class="modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">[[${msg}]]</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"
onclick="$('.modal').hide()" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Registered Successful</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="$('.modal').hide()"
data-bs-dismiss="modal">CONFIRM</button>
</div>
</div>
</div>
</div>
아래에 <script> 테그를 추가해서 새로운 들이 등록돼서 파라미터 msg에 값이 있으면 모달창을 보이게 하는 javascript 코드 작성
<script th:inline="javascript">
var msg = [[${msg}]];
console.log(msg);
if(msg){
$(".modal").show();
}
</script>
변수 msg 를 선언해서 파라미터 msg의 값을 받고 콘솔에 로그 출력
msg에 값이 있으면 modal 클래스에 show() 함수를 실행해서 해당 클래스가 보여지도록 설정
<실행결과>
- 리스트에서 등록 페이지로 가는 링크 추가
list.html 파일에 <h1> 태그 안에 <a>태그로 감싼 <button>태그를 추가해서 버튼을 클릭하면 등록 페이지로 이동하도록 설정
<h1 class="mt-4">GuestBook List Page
<a th:href="@{/guestbook/register}">
<button type="button" class="btn btn-outline-primary">REGISTER</button>
</a>
</h1>
<실행결과>
12. 조회 페이지
글의 gno를 클릭하면 해당 글을 조회하는 페이지로 이동
조회 페이지에서 다시 목록 페이지로 돌아가야 하는데 이때 원래 페이지의 번호가 있어야 함
list.html에서 테이블의 각 글의 gno 에 <a> 추가
read.html로 이동하도록 함
파라미터로 표시할 글의 gno와 다시 리스트로 돌아갈 때 표시할 원래 페이지 번호를 전달
<tr th:each="dto : ${result.dtoList}">
<th scope="row">
<a th:href="@{/guestbook/read(gno = ${dto.gno}, page = ${result.page})}">
[[${dto.gno}]]
</a>
</th>
<td>[[${dto.title}]]</td>
<td>[[${dto.writer}]]</td>
<!-- 날짜 출력 포멧 -->
<td>[[${#temporals.format(dto.regDate, "yyyy/MM/dd")}]]</td>
</tr>
-> gno 부분이 링크 처리되어 파랗게 변함
- 조회 처리
서비스 계층에서 gno를 넘겨주면 해당하는 엔티티 객체를 DTO 객체로 변환해서 반환하는 기능 구현
GuestbookService 인터페이스에 추상 메소드 작성
GuestbookDTO read(Long gno);
GuestbookServiceImpl 클래스에서 해당 메소드 구현
@Override
public GuestbookDTO read(Long gno) {
Optional<Guestbook> result = repository.findById(gno);
return result.isPresent() ? entityToDto(result.get()) : null;
}
repository의 findById() 메소드를 이용해서 해당 gno의 엔티티 객체를 Optional 타입으로 받음
반환 받은 결과가 있으면 true를 반환하는 isPresent() 메소드와 삼항 연산자를 이용해서 넘겨받은 gno에 해당하는 엔티티 객체가 있으면 DTO 객체로 변환해서 반환하고 없으면 null을 반환
컨트롤러에 read로 Get 맵핑한 메소드 구현
서비스에서 구현한 read() 메소드를 이용해서 gno를 넘겨주고 DTO 객체를 받아서 모델에 추가해서 전달
매개변수에 @ModelAttribute("파라미터 이름")를 작성하면 해당 매개변수를 특별한 처리 없이 모델에 추가해서 전송할 수 있음
@GetMapping("/read")
public void read(Long gno, @ModelAttribute("requestDTO")
PageRequestDTO requestDTO, Model model) {
GuestbookDTO dto = service.read(gno);
model.addAttribute("dto", dto);
}
-> 모델에는 PageRequestDTO 객체(파라미터로 전달한 page값이 자동으로 PAgeRequestDTO 객체로 수집됨)와 DTO 객체가 담겨서 전달됨
read.html 페이지 작성
전달받은 dto객체를 화면에 표시
register.html과 거의 유사
폼의 설정값을 readonly로 해서 작성할 수는 없고 읽기만 가능하도록 설정
register.html을 그대로 복사해오고 <form> 태그를 삭제 <input> <textarea> 태그의placeholder 속성을 지우고 value 속성에 표시할 값을 설정하고 (<textarea> 태그는 두 태그 사이에 작성) readonly 속성 추가
regDate와 modDate를 표시하는 태그들을 추가하고 value 속성에 날짜 출력 포맷을 지정
아래의 두 버튼을 각각 <a> 태그 사이에 작성하고 수정을 위한 modify.html과 리스트 페이지로 이동하도록 작성
<th:block th:replace="~{/layout/layout :: setContent(~{this :: content})}">
<th:block th:fragment="content">
<h1 class="mt-4">GuestBook Register Page</h1>
<div class="form-group">
<label>Gno</label>
<input type="text" class="form-control" name="gno"
th:value="${dto.gno}" readonly>
</div>
<div class="form-group">
<label>Title</label>
<input type="text" class="form-control" name="title"
th:value="${dto.title}" readonly>
</div>
<div class="form-group">
<label>Content</label>
<textarea class="form-control" rows="5" name="content" readonly>
[[${dto.content}]]
</textarea>
</div>
<div class="form-group">
<label>Writer</label>
<input type="text" class="form-control" name="writer"
th:value="${dto.writer}" readonly>
</div>
<div class="form-group">
<label>RegDate</label>
<input type="text" class="form-control" name="regDate"
th:value="${#temporals.format(dto.regDate, 'yyyy/MM/dd HH:mm:ss')}" readonly>
</div>
<div class="form-group">
<label>ModDate</label>
<input type="text" class="form-control" name="modDate"
th:value="${#temporals.format(dto.modDate, 'yyyy/MM/dd HH:mm:ss')}" readonly>
</div>
<br>
<a th:href="@{/guestbook/modify(gno = ${dto.gno}, page = ${requestDTO.page})}">
<Button type="button" class="btn btn-primary">Modify</Button>
</a>
<a th:href="@{/guestbook/list(page = ${requestDTO.page})}">
<Button type="button" class="btn btn-info">List</Button>
</a>
</th:block>
</th:block>
-> 리스트에서gno를 클릭하면 해당 글의 내용을 보여주는 조회 페이지로 이동
13. 수정 & 삭제 처리
수정 페이지에 3개의 버튼을 만들고 각각 수정, 삭제, 리스트로 돌아가기 기능 구현
수정 페이지에서 수정과 삭제는 POST 방식을 통해서 구현
수정은 타이틀과 컨텐츠만 변경 가능
* 서비스 계층
삭제 : Repository의 deleteById() 메소드를 이용해서 처리
수정 : 엔티티 객체를 조회해서 가져온 후 변경할 부분만 변경해서 수정
* 컨트롤러
삭제 : POST 방식으로 처리 후 목록 화면으로 리다이렉트
수정: POST 방식으로 처리 후 조회 화면으로 리다이렉트
수정하는 페이지에서 필요한 파라미터는 조회 페이지와 동일하므로 조회 페이지에 사용한 컨트롤러의 메소드의 @GetMapping에 "/modify" 추가
@GetMapping({"/read", "/modify"})
public void read(Long gno, @ModelAttribute("requestDTO")
PageRequestDTO requestDTO, Model model) {
GuestbookDTO dto = service.read(gno);
model.addAttribute("dto", dto);
}
- 수정 페이지
modify.html 파일 생성하고 read.html의 내용 복사
<h1> 태그의 내용 부분을 GuestBookModify Page로 변경
<input> 태그들을 <form> 태그로 감싸고 action을 "/guestbook/modify", method를 "post"로 설정
수정할 부분(title, content)의 readonly 속성 삭제
날짜 부분은 JPA에서 자동으로 처리하기 때문에 name 속성 삭제
<form> 태그 아래에 수정, 삭제, 리스트 세가지 버튼 생성
<h1 class="mt-4">GuestBook Modify Page</h1>
<form th:action="@{/guestbook/modify}" method="post">
<div class="form-group">
<label>Gno</label>
<input type="text" class="form-control" name="gno"
th:value="${dto.gno}" readonly>
</div>
<div class="form-group">
<label>Title</label>
<input type="text" class="form-control" name="title" th:value="${dto.title}" >
</div>
<div class="form-group">
<label>Content</label>
<textarea class="form-control" rows="5" name="content" >
[[${dto.content}]]
</textarea>
</div>
<div class="form-group">
<label>Writer</label>
<input type="text" class="form-control" name="writer"
th:value="${dto.writer}" readonly>
</div>
<div class="form-group">
<label>RegDate</label>
<input type="text" class="form-control"
th:value="${#temporals.format(dto.regDate, 'yyyy/MM/dd HH:mm:ss')}" readonly>
</div>
<div class="form-group">
<label>ModDate</label>
<input type="text" class="form-control"
th:value="${#temporals.format(dto.modDate, 'yyyy/MM/dd HH:mm:ss')}" readonly>
</div>
</form><button type="button" class="btn btn-primary">Modify</button>
<button type="button" class="btn btn-info">List</button>
<button type="button" class="btn btn-danger">Remove</button>
-> Title과 Content 부분만 수정 가능하도록 변경됨
- 서비스 계층에서 수정 & 삭제 처리
인터페이스에 수정 & 삭제 추상메소드 추가
void remove(Long gno);
void modify(GuestbookDTO dto);
서비스 클래스에서 메소드 구현
remove() 메소드에서는 repository의 deleteById() 메소드를 이용해서 해당 gno인 엔티티 객체 삭제
modify() 메소드에서는 전달받은 dto객체(수정된 내용을 담고있음)의 gno에 해당하는 엔티티 객체를 받아와서 엔티티 객체에 정의된 changeTitle(), changeContent() 메소드를 이용해서 전달받은 dto객체의 값으로 변경
-> repository의 save() 메소드로 변경 사항을 저장
@Override
public void remove(Long gno) {
repository.deleteById(gno);
}
@Override
public void modify(GuestbookDTO dto) {
Optional<Guestbook> result = repository.findById(dto.getGno());
if (result.isPresent()) {
Guestbook entity = result.get();
entity.changeTitle(dto.getTitle());
entity.changeContent(dto.getContent());
repository.save(entity);
}
}
- 컨트롤러의 삭제 구현
modify와 Post 맵핑한 메소드 구현
서비스 클래스의 remove() 메소드를 호출해서 파라미터로 전달받은 gno에 해당하는 데이터를 DB에서 삭제
RedirectAttribute 객체에 "msg"라는 이름으로 gno를 담아서 리스트 페이지로 리다이렉트
@PostMapping("remove")
public String remove(Long gno, RedirectAttributes redirectAttributes) {
service.remove(gno);
redirectAttributes.addFlashAttribute("msg", gno);
return "redirect:/guestbook/list";
}
- modify.html에서 삭제 구현
javascript 코드를 이용해서 "Remove" 버튼을 클릭하면 Form 태그의 action을 "/guestbook/remove", method를 "post"로 변경 후 submit
jquery를 이용하기 때문에 상단의 <html> 아래에 <script>태그를 이용해서 jquery 라이브러리 추가
<script src="https://code.jquery.com/jquery-3.7.0.js"></script>
<button type="button" class="btn btn-primary modifyBtn">Modify</button>
<button type="button" class="btn btn-info listBtn">List</button>
<button type="button" class="btn btn-danger removeBtn">Remove</button>
<script th:inline="javascript">
var actionForm = $("form") // form 테그 객체
$(".removeBtn").click(function(){
actionForm
.attr("action", "/guestbook/remove")
.attr("method", "post");
actionForm.submit();
});
</script>
jquery를 이용해서 버튼을 클래스별로 구분하기 때문에 각각의 버튼 뒤에 클래스 추가 지정
jquery를 이용해서 클래스가 removeBtn인 버튼이 클릭됐을 때 실행할 함수 작성
from 태그의 객체에 attr() 메소드를 이용해서 속서을 변경하고 submit() 메소드를 이용해서 전송
gno가 308인 글이 삭제됨
- POST 방식으로 수정 처리
수정 후에 다시 리스트로 넘어가려면 패이지 정보가 필요하므로 modify.html의 <form> 태그안에 보이지 않고 패이지 정보만 파라미터로 넘겨주는 <input>태그 작성
<form th:action="@{/guestbook/modify}" method="post">\
<input type="hidden" name="page" th:value="${requestDTO.page}">
컨트롤러에 modify 에 Post 맵핑한 메소드 작성
넘겨받은 dto 객체를 서비스의의 modify() 메소드로 넘겨줘서 해당 gno의 엔티티 객체의 타이틀과 컨텐츠를 넘겨준 dto 객체의 정보로 수정
RedirectAttribute 객체에 page와 gno 파라미터를 추가하고 "/guestbook/read"로 리다이렉트
@PostMapping("/modify")
public String modify(GuestbookDTO dto, @ModelAttribute("requestDTO")PageRequestDTO requestDTO, RedirectAttributes redirectAttributes) {
service.modify(dto);
redirectAttributes.addAttribute("page", requestDTO.getPage());
redirectAttributes.addAttribute("gno", dto.getGno());
return "redirect:/guestbook/read";
}
modify.html에서 수정 처리
Modify 버튼을 클릭했을 때 컨펌 창을 띄우고 <form> 태그의 action을 "/guestbook/modify", method를 "post"로 설정하고 submit
$(".modifyBtn").click(function(){
if (!confirm("수정하시겠습니까?")){
return; // 취소 버튼 클릭하면 함수 종료
}
actionForm
.attr("action", "/guestbook/modify")
.attr("method", "post");
actionForm.submit();
});
-> 수정이 정상적으로 진행됨
- 수정화면에서 리스트 페이지로 이동 처리
목록으로 이동할 때는 page 파라미터 외에는 필요없어서 page 파라미터만 전달
modify.html 에 List 버튼 클릭시 이벤트를 처리하는 javascript 코드 추가
$(".listBtn").click(function (){
var pageInfo = $("input[name='page']");
actionForm.empty();
actionForm.append(pageInfo);
actionForm.attr("action", "/guestbook/list")
.attr("method", "get");
actionForm.submit();
});
page 정보만 따로 변수에 저장하고 empty() 함수로 <form> 태그에 입력된 내용들을 지움
<form> 태그에 page정보를 append() 함수로 추가
attr() 함수로 속성 지정하고 submit() 함수 호출
14. 검색 처리
- 서버측 처리
Repository에서 Querydsl을 이용해서 다양한 상황에 맞는 JPQL 생성 처리
서비스 계층에서 PageRequestDTO 구성
- 컨트롤러 처리
검색 타입(제목, 내용, 글쓴이)과 키워드를 반영하도록 PageRequsetDTO 변경
- 화면 처리
검색 조건을 화면에 표시하도록 수정
검색 버튼 추가
조회 등의 모든 작업에서 검색 조건이 추가될 수 있도록 변경
조회 화면, 수정 화면 등 모든 화면에서 목록으로 갈 때 검색 조건과 키워드가 유지되도록 처리
1) 서버측 검색 처리
PageRequestDTO에 type, keyword 속성 추가
public class PageRequestDTO {
// 페이지 번호와 페이지당 리스트의 개수를 저장하는 필드
private int page;
private int size;
// 검색에 필요한 필드 추가
private String type;
private String keyword;
- 서비스 계층의 검색 구현
Querydsl을 이용해 BooleanBuilder 객체를 생성하고 Repository에서 BooleanBuilder 객체를 findAll() 메소드에서 조건을 처리하는 용도로 이용
GuestbookService 인터페이스에 추상 메소드 추가
BooleanBuilder getSearch(PageRequestDTO pageRequestDTO);
GuestbookServiceImpl 클래스에서 추상 메소드 구현
@Override
public BooleanBuilder getSearch(PageRequestDTO requestDTO) {
String type = requestDTO.getType();
String keyword = requestDTO.getKeyword();
BooleanBuilder booleanBuilder = new BooleanBuilder();
QGuestbook qGuestbook = QGuestbook.guestbook;
BooleanExpression expression = qGuestbook.gno.gt(0L); // gno > 0
booleanBuilder.and(expression);
if (type == null || type.trim().length() == 0) { // 검색조건을 선택하지 않은 경우
return booleanBuilder;
}
// 검색 조건 작성
BooleanBuilder conditionBuilder = new BooleanBuilder();
if (type.contains("t")) {
conditionBuilder.or(qGuestbook.title.contains(keyword));
}
else if (type.contains("w")) {
conditionBuilder.or(qGuestbook.writer.contains(keyword));
}
else if (type.contains("c")) {
conditionBuilder.or(qGuestbook.content.contains(keyword));
}
// 조건 통합
booleanBuilder.and(conditionBuilder);
return booleanBuilder;
}
Querydsl을 이용해서 BooleanBuilder 객체에 조건을 추가
PageRequestDTO를 파라미터로 전달받음 (type, keyword에 해당하는 정보 포함)
검색조건을 선택하지 않았으면 그냥 BooleanBuilder 객체 반환
type에 값이 있으면 해당하는 조건을 추가 (t:제목, c:내용, w:지은이)
키워드가 type에 포함되는지 확인하는 조건 추가
서비스 클래스의 getList 메소드에 BooleanBuilder를 추가
@Override
public PageResultDTO<GuestbookDTO, Guestbook> getList(PageRequestDTO requestDTO) {
// PageRequestDTO의 getPageable() 메소드를 이용해서
// Sort 정보를 넘겨주고 Pageable 객체 반환받음
Pageable pageable = requestDTO.getPageable(Sort.by("gno").descending());
// 추가
// 검색 조건에 대한 내용을 담은 BooleanBuilder 객체 생성
BooleanBuilder booleanBuilder = getSearch(requestDTO);
// 수정
// JPA의 findAll 메소드에 반환받은 Pageable 객체와 BooleanBuilder를 넘겨줘서
// 조건에 해당하는 패이징 처리된 Page<> 객체를 반환받음
Page<Guestbook> result = repository.findAll(booleanBuilder, pageable);
// 엔티티 객체를 DTO 객체로 변환하는 Function 객체 생성
Function<Guestbook, GuestbookDTO> fn = (entity -> entityToDto(entity));
// PageResultDTO 객체를 생성해서 반환
// PageResultDTO 객체는 전달받은 Page<>타입에서 엔티티 객체들를
// fn을 이용해서 DTO로 변환하고 List<>로 수집해서 가지고 있음
return new PageResultDTO<>(result, fn);
}
getSearch() 메소드로 검색 조건을 다은 BooleanBuilder 객체를 생성하고 findAll() 메소드에 BooleanBuilder 객체를 Pageable 객체와 함께 넘겨줌
2) 화면측 검색 처리
화면에서 type와 keyword를 입력하는 UI 작성
list.html 파일에 <table> 태그 위에 <form> 태그 추가
<form action="/guestbook/list" method="get" id="searchForm">
<div class="input-group">
<input type="hidden" name="page" value="1">
<div class="input-group-sm">
<select class="custom-select" name="type">
<option th:selected="${pageRequestDTO.type == null}">------</option>
<option value="t" th:selected="${pageRequestDTO.type == 't'}">
제목</option>
<option value="c" th:selected="${pageRequestDTO.type == 'c'}">
내용</option>
<option value="w" th:selected="${pageRequestDTO.type == 'w'}">
작성자</option>
<option value="tc" th:selected="${pageRequestDTO.type == 'tc'}">
제목 + 내용</option>
<option value="tcw" th:selected="${pageRequestDTO.type == 'tcw'}">
제목 + 내용 + 작성자</option>
</select>
</div>
<input class="form-control-sm" name="keyword"
th:value="${pageRequestDTO.keyword}">
<div class="input-group-sm" id="button-addon4">
<button class="btn btn-outline-secondary btn-search" type="button">
Search</button>
<button class="btn btn-outline-secondary btn-clear" type="button">
Clear</button>
</div>
</div>
</form>
hidden 속성의 <input> 태그를 이용해서 검색을 할때마다 항상 page는 1이 되도록 지정
<select> 안에 <option>태그들 작성. 전달된 type에 해당하는 option이 선택되어 있도록 지정
전달된 keyword값을 keyword를 입력하는 <input>에 표시되도록 지정
Search, Clear 버튼은 JavaScript 코드로 이벤트를 처리해주기 위해 클래스를 각각 btn-search와 btn-clear로 지정
var searchForm = $("#searchForm");
$('.btn-search').click(function(e){
searchForm.submit();
});
$('.btn-clear').click(function(e){
searchForm.empty().submit();
});
<ul class="pagination h-100 justify-content-center align-items-center">
<li class="page-item" th:if="${result.prev}">
<a class="page-link" th:href="@{/guestbook/list(page=${result.start-1},
type=${pageRequestDTO.type}, keyword=${pageRequestDTO.keyword})}"
tabindex="-1">Previous</a>
</li>
<li th:class=" 'page-item ' +${result.page == page ? 'active' : ''} "
th:each="page: ${result.pageList}">
<a class="page-link" th:href="@{/guestbook/list(page=${page},
type=${pageRequestDTO.type}, keyword=${pageRequestDTO.keyword})}">
[[${page}]]
</a>
</li>
<li class="page-item" th:if="${result.next}">
<a class="page-link" th:href="@{/guestbook/list(page=${result.end+1},
type=${pageRequestDTO.type}, keyword=${pageRequestDTO.keyword})}">Next</a>
</li>
</ul>
- 조회 페이지로 이동하는 검색 처리
<table> 태그 안에 글 번호를 클릭해서 이동하는 부분의 href 속성에도 type, keyword 파라미터 추가
<tr th:each="dto : ${result.dtoList}">
<th scope="row">
<a th:href="@{/guestbook/read(gno = ${dto.gno}, page = ${result.page},
type=${pageRequestDTO.type}, keyword=${pageRequestDTO.keyword})}">
[[${dto.gno}]]
</a>
</th>
<td>[[${dto.title}]]</td>
<td>[[${dto.writer}]]</td>
<td>[[${#temporals.format(dto.regDate, 'yyyy/MM/dd')}]]</td>
</tr>
- 조회 페이지 검색 처리
조회 페이지에서 다시 목록으로 돌아갈 때도 검색 조건이 유지되도록 처리
read.html의 <button> 태그를 감싸는 <a> 태그이 href 속성에 type, keyword 파라미터 추가
<a th:href="@{/guestbook/modify(gno = ${dto.gno}, page = ${requestDTO.page},
type=${requestDTO.type}, keyword=${requestDTO.keyword})}">
<button type="button" class="btn btn-primary">Modify</button>
</a>
<a th:href="@{/guestbook/list(page = ${requestDTO.page},
type=${requestDTO.type}, keyword=${requestDTO.keyword})}">
<button type="button" class="btn btn-info">List</button>
</a>
read.html 페이지에서는 PageReqquestDTO 파라미터가 requestDTO라는 이름으로 전달됨 주의
- 수정 작업 후 이동 처리
작업 후 RedirectAttributes를 이용해서 이동하는 경우
등록 처리, 삭제 처리, 수정 처리
수정 처리 후에는 다시 조회 페이지로 이동하기 때문에 검색 조건을 유지해야 함
modify.html 에 hidden 타입의 <input>를 추가해서 type과 keyword에 대한 정보를 넘겨주도록 지정
<form th:action="@{/guestbook/modify}" method="post">
<input type="hidden" name="page" th:value="${requestDTO.page}">
<!-- 추가 -->
<input type="hidden" name="type" th:value="${requestDTO.type}">
<input type="hidden" name="keyword" th:value="${requestDTO.keyword}">
수정 페이지에서 목록으로 이동하는 경우에도 페이지 정보를 유지하도록 이벤트 처리 javascript 코드 수정
$(".listBtn").click(function (){
var pageInfo = $("input[name='page']");
var type = $("input[name='type']");
var keyword = $("input[name='keyword']");
actionForm.empty();
actionForm.append(pageInfo);
actionForm.append(type);
actionForm.append(keyword);
actionForm.attr("action", "/guestbook/list")
.attr("method", "get");
actionForm.submit();
});
pageInfo 뿐만 아니라 type, keyword 정보도 전달하도록 수정
GuestbookController 클래스에서도 리다이렉트할 때 검색 조건을 넘겨주도록 수정
@PostMapping("/modify")
public String modify(GuestbookDTO dto, @ModelAttribute("requestDTO")
PageRequestDTO requestDTO, RedirectAttributes redirectAttributes) {
service.modify(dto);
redirectAttributes.addAttribute("page", requestDTO.getPage());
redirectAttributes.addAttribute("gno", dto.getGno());
// 추가
redirectAttributes.addAttribute("type", requestDTO.getType());
redirectAttributes.addAttribute("keyword", requestDTO.getKeyword());
return "redirect:/guestbook/read";
}
리아디렉트할 파라미터 추가
=> 글을 수정하는 페이지에서 목록 페이지로 이동해도 검색 조건이 남아있음
























댓글
댓글 쓰기