JUINTINATION
코드로 배우는 스프링 부트 웹 프로젝트 Guestbook Clone 본문
지난 Mac OS 자바 버전 여러 개 관리하기 글에서 잠깐 언급했듯이 스프링을 쓸 일이 생겨서 jdk 17 버전을 설치했었다. 이후에 ETRI에서 대여한 코드로 배우는 스프링 부트 웹 프로젝트 책과 해당 코드가 적힌 깃허브를 참고하여 방문록 프로젝트를 따라 치면서 스프링 공부를 시작했다. 물론 책의 버전과 지금 버전이 많이 달라져서 오류가 많이 발생했고 관련 내용은 네이버 카페의 QnA 게시판도 참고했다. 이제 그 내용을 차근차근 따라가보자.
프로젝트 생성
start.spring.io에서 다음과 같이 Thymeleaf, Lombok, Spring Data JPA, Spring Web, Spring Boot DevTools 의존성을 추가하여 org.zerock.guestbook 프로젝트를 생성한다.
이후에 Intellij에서 build.gradle 파일을 선택하고 Open as Project로 프로젝트를 실행한다. 이후에 MySQL 관련 내용과 querydsl 관련 내용을 다음과 같이 build.gradle에 추가한다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.2'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'org.zerock'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
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'
runtimeOnly 'mysql:mysql-connector-java:8.0.31'
annotationProcessor 'org.projectlombok:lombok'
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
}
tasks.named('test') {
useJUnitPlatform()
}
sourceSets {
main {
java {
srcDirs = ["$projectDir/src/main/java", "$projectDir/build/generated"]
}
}
}
compileJava.dependsOn('clean')
querydsl 관련 내용도 스프링 부트 버전이 달라지면서 책의 내용과 살짝 다른데 위와 같이 의존성 부분에 관련 내용을 추가하고 코드의 아래 부분에 있는 sourceSets
부분과 compileJava.dependsOn('clean')
부분을 추가해줘야 한다.
이후에 프로젝트를 실행하면 JPA 때문에 오류가 나는데 해결을 위해 다음과 같이 application.properties 파일에 데이터베이스 관련 설정을 적어줘야 한다.
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/bootex
spring.datasource.username=root
spring.datasource.password=admin
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=debug
spring.thymeleaf.cache=false
책에서는 MariaDB를 사용했지만 나는 설치하기 귀찮아서 이미 설치되어있는 MySQL 관련 설정으로 세팅했다. 스키마 이름은 책과 동일하게 bootex로 진행했으며 나머지는 책에 있는 내용을 MySQL에 맞게 바꿔 설정했다.
프로젝트 내용
이 책에서도 부트스트랩에 있는 템플릿을 사용했다. 관련 파일들을 resources/static
폴더에 저장해두고(나는 그냥 깃허브에 있는 폴더 자체를 복붙했다.) resources/templates
폴더에 layout
폴더를 생성하고 깃허브에 있는 layout 폴더를 복붙했다. 나머지 java 코드들 관련 코드는 직접 따라 쳐보자. 책을 그대로 따라가면 글이 너무 쓸데없이 길어질 것 같아서 최종적인 코드만 적어볼까 한다. 커밋 내용과 함께 나의 진행 과정을 따라가고 싶다면 내 깃허브에 올려둔 코드를 참고하면 된다.
또한 resources/templates
폴더의 guestbook
이라는 폴더에 실제로 보여지는 부분의 thymeleaf를 이용한 html 코드가 있는데 이 코드들까지 작성하면.. 글의 양을 감당할 수 없을 것 같아서 thymeleaf 관련 내용은 추후에 더 자세히 공부해 보는 것으로 하고 이 코드들을 보고 싶으면 깃허브로..
controller/GuestbookController.java
package org.zerock.guestbook.controller;
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.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.zerock.guestbook.dto.GuestbookDTO;
import org.zerock.guestbook.dto.PageRequestDTO;
import org.zerock.guestbook.service.GuestbookService;
@Controller
@RequestMapping("/guestbook")
@Log4j2
@RequiredArgsConstructor
public class GuestbookController {
private final GuestbookService service;
@GetMapping("/")
public String index() {
return "redirect:/guestbook/list";
}
@GetMapping("/list")
public void list(PageRequestDTO pageRequestDTO, Model model) {
log.info("list............." + pageRequestDTO);
model.addAttribute("result", service.getList(pageRequestDTO));
}
@GetMapping("/register")
public void register() {
log.info("regiser get...");
}
@PostMapping("/register")
public String registerPost(GuestbookDTO dto, RedirectAttributes redirectAttributes) {
log.info("dto..." + dto);
Long gno = service.register(dto);
redirectAttributes.addFlashAttribute("msg", gno);
return "redirect:/guestbook/list";
}
@GetMapping({"/read", "/modify"})
public void read(long gno, @ModelAttribute("requestDTO") PageRequestDTO requestDTO, Model model) {
log.info("gno: " + gno);
GuestbookDTO dto = service.read(gno);
model.addAttribute("dto", dto);
}
@PostMapping("/modify")
public String modify(GuestbookDTO dto,
@ModelAttribute("requestDTO") PageRequestDTO requestDTO,
RedirectAttributes redirectAttributes){
log.info("post modify.........................................");
log.info("dto: " + dto);
service.modify(dto);
redirectAttributes.addAttribute("page",requestDTO.getPage());
redirectAttributes.addAttribute("type",requestDTO.getType());
redirectAttributes.addAttribute("keyword",requestDTO.getKeyword());
redirectAttributes.addAttribute("gno",dto.getGno());
return "redirect:/guestbook/read";
}
@PostMapping("/remove")
public String remove(long gno, RedirectAttributes redirectAttributes) {
log.info("gno: " + gno);
service.remove(gno);
redirectAttributes.addFlashAttribute("msg", gno);
return "redirect:/guestbook/list";
}
}
이 프로젝트는 로그인 기능을 구현하지 않기 때문에 컨트롤러는 이 코드 하나뿐이다. 각각 방명록 리스트를 볼 수 있는 기능, 방명록 작성 기능, 방명록 읽기 기능, 수정 및 삭제 기능을 수행한다.
entity/BaseEntity.java
package org.zerock.guestbook.entity;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDateTime;
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
abstract class BaseEntity {
@CreatedDate
@Column(name = "regdate", updatable = false)
private LocalDateTime regDate;
@LastModifiedDate
@Column(name = "moddate")
private LocalDateTime modDate;
}
엔티티 클래스는 Spring Data JPA에서는 반드시 @Entity
라는 어노테이션을 추가해야 하며 @Entity
는 해당 클래스가 엔티티를 위한 클래스이며 해당 클래스의 인스턴스들이 JPA로 관리되는 엔티티 객체라는 것을 의미한다. 이러한 엔티티와 관련된 작업을 하다 보면 데이터의 등록 시간 및 수정 시간이 같이 자동으로 추가되고 변경되어야 하는 컬럼들이 있다. 이를 매번 프로그램 안에서 처리하는 일은 번거롭기 때문에 자동으로 처리할 수 있도록 어노테이션을 이용해서 설정한다.
BaseEntity 클래스에 @MappedSuperClass
라는 특별한 어노테이션이 적용되는데 해당 어노테이션이 적용된 클래스는 테이블로 생성되지 않고 실제 테이블은 BaseEntity 클래스를 상속한 엔티티의 클래스로 데이터베이스 테이블이 생성된다.
JPA 내부에서 엔티티 객체가 생성/변경되는 것을 감지하는 역할은 AuditingEntityListener를 통해 regDate, modDate에 적절한 값이 지정된다. @CreatedDate
는 JPA에서 엔티티의 생성 시간을 처리하고 @LastModifiedDate
는 최종 수정 시간을 자동으로 처리하는 용도로 사용한다. redDate는 updatable를 false로 설정하여 해당 엔티티 객체를 데이터베이스에 반영할 때 변경되지 않도록 하였다.
GuestbookApplication.java
package org.zerock.guestbook;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@EnableJpaAuditing
@SpringBootApplication
public class GuestbookApplication {
public static void main(String[] args) {
SpringApplication.run(GuestbookApplication.class, args);
}
}
JPA를 이용하면서 AuditingEntityListener를 활성화시키기 위해 프로젝트에 @EnableJpaAuditing
설정을 추가해야 하므로 위과 같이 GuestbookApplication을 수정해야 정상적으로 적용된다.
entity/Guestbook.java
package org.zerock.guestbook.entity;
import jakarta.persistence.*;
import lombok.*;
@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;
public void changeTitle(String title) {
this.title = title;
}
public void changeContent(String content) {
this.content = content;
}
}
위와 같이 엔티티 클래스 작성 이후에 repository를 구현한다.
repository/GuestbookRepository.java
package org.zerock.guestbook.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.zerock.guestbook.entity.Guestbook;
public interface GuestbookRepository extends JpaRepository<Guestbook, Long>, QuerydslPredicateExecutor<Guestbook> {
}
GuestbookRepository는 JpaRepository와 QuerydslPredicateExecutor를 상속받는 인터페이스다. JpaRepository를 사용할 때는 엔티티의 타입 정보와 @Id의 타입을 지정하게 되는데 이처럼 Spring Data JPA는 인터페이스 선언만으로도 자동으로 스프링 빈(bean)으로 등록된다.
Querydsl
JPA의 쿼리 메서드의 기능과 @Query
를 통해서 많은 기능을 구현할 수는 있지만 선언할 때 고정된 형태의 값을 가진다는 단점이 있다. 이 때문에 단순한 몇 가지의 검색 조건을 만들어야 하는 상황에서는 기본 기능만으로 충분하지만 복잡한 조합을 이용하는 경우의 수가 많은 상황에서는 동적으로 쿼리를 생성해서 처리할 수 있는 기능이 필요한데 이러한 상황을 처리하기 위해 Querydsl
을 사용한다. Querydsl은 작성된 엔티티 클래스를 그대로 이용하는 것이 아닌 'Q도메인'이라는 것을 이용해야 하기 때문에 위에서 언급한 build.gradle
파일에 설정을 완료해야 하며 build.gradle 파일을 갱신하면 다음과 같이 QBaseEntity
와 QGuestbook
파일이 생성되는 것을 확인할 수 있다.
repository/GuestbookRepositoryTests.java
package org.zerock.guestbook.repository;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.BooleanExpression;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.zerock.guestbook.entity.Guestbook;
import org.zerock.guestbook.entity.QGuestbook;
import java.util.Optional;
import java.util.stream.IntStream;
@SpringBootTest
public class GuestbookRepositoryTests {
@Autowired
private GuestbookRepository guestbookRepository;
@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
public void updateTest() {
Optional<Guestbook> result = guestbookRepository.findById(300L);
if (result.isPresent()) {
Guestbook guestbook = result.get();
guestbook.changeTitle("Changed Title....");
guestbook.changeContent("Changed Content...");
guestbookRepository.save(guestbook);
}
}
@Test
public void testQuery1() {
Pageable pageable = PageRequest.of(0, 10, Sort.by("gno").descending());
QGuestbook qGuestbook = QGuestbook.guestbook;
String keyword = "1";
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);
});
}
@Test
public void testQuery2() {
Pageable pageable = PageRequest.of(0, 10, Sort.by("gno").descending());
QGuestbook qGuestbook = QGuestbook.guestbook;
String keyword = "1";
BooleanBuilder builder = new BooleanBuilder();
BooleanExpression exTitle = qGuestbook.title.contains(keyword);
BooleanExpression exContent = qGuestbook.content.contains(keyword);
BooleanExpression exAll = exTitle.or(exContent);
builder.and(exAll);
builder.and(qGuestbook.gno.gt(0L));
Page<Guestbook> result = guestbookRepository.findAll(builder, pageable);
result.stream().forEach(guestbook -> {
System.out.println(guestbook);
});
}
}
- insertDummies 테스트를 진행하면 300개의 더미 데이터를 데이터베이스에 저장하게 된다. 저장된 데이터들을 보면 생성 시간과 수정 시간을 지정하지 않았음에도 자동으로 null이 아닌 값으로 생성되는 것을 확인할 수 있다.
- updateTest 테스트를 진행하면 300번의 id를 갖는 행의 제목 및 내용과 moddate이 업데이트되는 것을 확인할 수 있다.
- 글을 작성하면서 든 생각인데 300번의 방명록을 지우면 이 테스트에서는 오류가 발생할 것이다. 다음에 테스트 코드를 작성할 땐 이런 부분도 고려해서 작성해야겠다.
- testQuery1 테스트를 진행하면 querydsl을 이용하여 제목에 "1"이라는 글자가 포함된 엔티티를 검색한다.
- testQuery2 테스트를 진행하면 querydsl을 이용하여 제목이나 내용에 "1"이라는 글자가 포함된 엔티티를 gno가 높은 순으로 10개를 검색한다.
- 300개의 더미 데이터가 있으니 gno가 291, 281, ..., 218인 엔티티가 출력될 것이다.
dto/GuestbookDTO.java
package org.zerock.guestbook.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class GuestbookDTO {
private Long gno;
private String title;
private String content;
private String writer;
private LocalDateTime regDate, modDate;
}
DTO는 엔티티 객체와 달리 각 계층끼리 주고받는 우편물이나 상자의 개념으로 순수하게 데이터를 담고 있다는 점에서는 엔티티 객체와 유사하지만 목적 자체가 데이터의 전달이므로 읽고 쓰는 것이 모두 허용되며 일회성으로 사용되는 성격이 강하다.
예제에서는 서비스 계층을 생성하고 서비스 계층에서는 DTO 파라미터와 리턴 타입을 처리하도록 구성한다. DTO를 사용하면 엔티티 객체의 범위를 한정 지을 수 있기 때문에 좀 더 안전한 코드를 작성할 수 있고 화면과 데이터를 분리하려는 취지에도 좀 더 부합한다.
DTO를 사용하는 경우 가장 큰 단점은 Entity와 유사한 코드를 중복으로 개발한다는 점과 엔티티 객체를 DTO로 변환하거나 반대로 DTO 객체를 엔티티로 변환하는 과정이 필요하다는 것이다.
작성된 GuestbookDTO는 엔티티 클래스 Guestbook과 거의 동일한 필드를 가지고 있으며 getter/setter
를 통해 자유롭게 값을 변경할 수 있게 구성한다.
dto/PageRequestDTO.java
package org.zerock.guestbook.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
@Builder
@AllArgsConstructor
@Data
public class PageRequestDTO {
private int page, size;
private String type, keyword;
public PageRequestDTO() {
this.page = 1;
this.size = 10;
}
public Pageable getPageable(Sort sort) {
return PageRequest.of(page - 1, size, sort);
}
}
PageRequestDTO는 화면에서 전달되는 page라는 파라미터와 size라는 파라미터를 수정하는 역할을 하나. 다만 이들은 기본값을 가지는 것이 좋기 때문에 각각 1과 10이라는 값을 이용한다.
PageRequestDTO의 진짜 목적은 JPA 쪽에서 사용하는 Pageable 타입의 객체를 생성하는 것이다. 나중에 수정의 여지(페이지 번호에 음수가 들어오는 등)가 있기는 하지만 JPA를 사용하는 경우에는 페이지 번호가 0부터 시작한다는 점을 감안하여 1페이지의 경우 0이 될 수 있도록 page - 1을 하는 형태로 작성한다. 정렬은 나중에 다양한 상황에서 쓰기 위해 별도의 파라미터로 받도록 설계했다.
dto/PageResultDTO.java
package org.zerock.guestbook.dto;
import lombok.Data;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@Data
public class PageResultDTO<DTO, EN> {
private List<DTO> dtoList;
private List<Integer> pageList;
private int totalPage, page, size;
private int start, end;
private boolean prev, next;
public PageResultDTO(Page<EN> result, Function<EN, DTO> fn) {
dtoList = result.stream().map(fn).collect(Collectors.toList());
totalPage = result.getTotalPages();
makePageList(result.getPageable());
}
private void makePageList(Pageable pageable) {
this.page = pageable.getPageNumber() + 1;
this.size = pageable.getPageSize();
int tempEnd = (int) (Math.ceil(page / (double) size)) * 10;
start = tempEnd - (size - 1);
prev = start > 1;
end = totalPage > tempEnd ? tempEnd : totalPage;
next = totalPage > tempEnd;
pageList = IntStream.rangeClosed(start, end).boxed().collect(Collectors.toList());
}
}
JPA를 사용하는 Repository에서는 페이지 처리 결과를 Page 타입으로 반환하게 된다. 따라서 서비스 계층에서 이를 처리하기 위해서 크게 다음과 같은 내용의 별도 클래스를 만들어서 처리해야 한다.
- Page의 엔티티 객체들을 DTO 객체로 변환하여 자료구조로 담아 주어야 한다.
- 화면 출력에 필요한 페이지 정보들을 구성해 주어야 한다.
또한 페이징 처리를 하기 위해 화면에 10개씩 페이지 번호를 출력한다고 가정한다면 예를 들어 사용자가 5페이지를 본다면 화면의 페이지 번호는 1부터 시작하고 19페이지를 본다면 11부터 시작해야 하므로 현재 사용자가 보고 있는 페이지의 정보가 필요하다.
현재 사용자가 보고 있는 페이지의 정보를 pageable.getPageNumber() + 1로 설정하고 pageable.getPageSize()를 이용하여 페이지 사이즈(10)을 가져왔을 때 아래 조건을 통해 시작 페이지(start)와 끝 페이지(end) 번호를 설정하고 pageList를 만든다.
service/GuestbookService.java
package org.zerock.guestbook.service;
import org.zerock.guestbook.dto.GuestbookDTO;
import org.zerock.guestbook.dto.PageRequestDTO;
import org.zerock.guestbook.dto.PageResultDTO;
import org.zerock.guestbook.entity.Guestbook;
public interface GuestbookService {
Long register(GuestbookDTO dto);
PageResultDTO<GuestbookDTO, Guestbook> getList(PageRequestDTO requestDTO);
GuestbookDTO read(Long gno);
void modify(GuestbookDTO dto);
void remove(Long gno);
default Guestbook dtoToEntity(GuestbookDTO dto) {
Guestbook entity = Guestbook.builder()
.gno(dto.getGno())
.title(dto.getTitle())
.content(dto.getContent())
.writer(dto.getWriter())
.build();
return entity;
}
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;
}
}
GuestbookService 인터페이스는 차례로 새로운 방명록 등록, 방명록 리스트 불러오기, 방명록 읽기, 방명록 수정 및 삭제 시나리오를 처리한다. 또한 DTO를 엔티티로 변환하거나 엔티티를 DTO로 변환하는 기능을 처리하는데 ModelMapper
라이브러리나 MapStruct
등을 이용하기도 한다고 한다. 이 책에서는 이를 직접 default 메서드로 구현했다.
service/GuestbookServiceImpl.java
package org.zerock.guestbook.service;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.BooleanExpression;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.zerock.guestbook.dto.GuestbookDTO;
import org.zerock.guestbook.dto.PageRequestDTO;
import org.zerock.guestbook.dto.PageResultDTO;
import org.zerock.guestbook.entity.Guestbook;
import org.zerock.guestbook.entity.QGuestbook;
import org.zerock.guestbook.repository.GuestbookRepository;
import java.util.Optional;
import java.util.function.Function;
@Service
@Log4j2
@RequiredArgsConstructor
public class GuestbookServiceImpl implements GuestbookService {
private final GuestbookRepository repository;
@Override
public Long register(GuestbookDTO dto) {
log.info("DTO------------------------");
log.info(dto);
Guestbook entity = dtoToEntity(dto);
log.info(entity);
repository.save(entity);
return entity.getGno();
}
@Override
public PageResultDTO<GuestbookDTO, Guestbook> getList(PageRequestDTO requestDTO) {
Pageable pageable = requestDTO.getPageable(Sort.by("gno").descending());
BooleanBuilder booleanBuilder = getSearch(requestDTO);
Page<Guestbook> result = repository.findAll(booleanBuilder, pageable);
Function<Guestbook, GuestbookDTO> fn = (entity -> entityToDto((entity)));
return new PageResultDTO<>(result, fn);
}
@Override
public GuestbookDTO read(Long gno) {
Optional<Guestbook> result = repository.findById(gno);
return result.isPresent() ? entityToDto((Guestbook) result.get()) : null;
}
@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);
}
}
@Override
public void remove(Long gno) {
repository.deleteById(gno);
}
private BooleanBuilder getSearch(PageRequestDTO requestDTO) {
String type = requestDTO.getType();
BooleanBuilder booleanBuilder = new BooleanBuilder();
QGuestbook qGuestbook = QGuestbook.guestbook;
BooleanExpression expression = qGuestbook.gno.gt(0L);
booleanBuilder.and(expression);
if (type == null || type.trim().length() == 0) {
return booleanBuilder;
}
BooleanBuilder conditionBuilder = new BooleanBuilder();
String keyword = requestDTO.getKeyword();
if (type.contains("t")) {
conditionBuilder.or(qGuestbook.title.contains(keyword));
}
if (type.contains("c")) {
conditionBuilder.or(qGuestbook.content.contains(keyword));
}
if (type.contains("w")) {
conditionBuilder.or(qGuestbook.writer.contains(keyword));
}
booleanBuilder.and(conditionBuilder);
return booleanBuilder;
}
}
GuestbookServiceImpl 클래스는 GuestbookService 인터페이스를 상속받고 스프링에서 빈으로 처리되도록 @Service
어노테이션을 추가한다. GuestbookService 인터페이스의 기능에 추가적으로 검색 기능을 위한 getSearch 메서드도 구현해두었다.
service/GuestbookServiceTests.java
package org.zerock.guestbook.service;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.zerock.guestbook.dto.GuestbookDTO;
import org.zerock.guestbook.dto.PageRequestDTO;
import org.zerock.guestbook.dto.PageResultDTO;
import org.zerock.guestbook.entity.Guestbook;
@SpringBootTest
public class GuestbookServiceTests {
@Autowired
private GuestbookService service;
@Test
public void testRegister() {
GuestbookDTO guestbookDTO = GuestbookDTO.builder()
.title("Sample Title...")
.content("Sample Content...")
.writer("user0")
.build();
System.out.println(service.register(guestbookDTO));
}
@Test
public void testList() {
PageRequestDTO pageRequestDTO = PageRequestDTO.builder().page(1).size(10).build();
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("-------------------------------------");
for (GuestbookDTO guestbookDTO : resultDTO.getDtoList()) {
System.out.println(guestbookDTO);
}
System.out.println("========================================");
resultDTO.getPageList().forEach(i -> System.out.println(i));
}
@Test
public void testSearch() {
PageRequestDTO pageRequestDTO = PageRequestDTO.builder()
.page(1)
.size(10)
.type("tc") // 검색 조건: t, c, w, tc, tcw..
.keyword("218") // 검색 키워드
.build();
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("-------------------------------------");
for (GuestbookDTO guestbookDTO : resultDTO.getDtoList()) {
System.out.println(guestbookDTO);
}
System.out.println("========================================");
resultDTO.getPageList().forEach(i -> System.out.println(i));
}
}
- testRegister 테스트를 진행하면 Sample 방명록을 DTO를 사용하여 데이터베이스에 저장한다.
- testList 테스트를 진행하면 PageResultDTO를 통해 페이지 번호를 1로, 페이지 크기를 10으로 지정하여 PageResultDTO 객체에서 이전 페이지 여부(isPrev()), 다음 페이지 여부(isNext()), 총 페이지 수(getTotalPage()) 및 PageResultDTO 객체에 포함된 GuestbookDTO 객체 목록을 출력한다.
- 페이지가 1이므로 PREV는 false, NEXT는 true가 출력될 것이며 마지막에 i는 1부터 10까지 순서대로 출력될 것이다.
- testSearch 테스트를 진행하면 PageResultDTO를 통해 페이지 번호를 1로, 페이지 크기를 10으로, 검색 조건을 tc(제목 및 내용), 검색할 내용을 "218"로 지정하여 제목 및 내용에 "218"이 포함된 PageResultDTO 객체에서 이전 페이지 여부(isPrev()), 다음 페이지 여부(isNext()), 총 페이지 수(getTotalPage()) 및 PageResultDTO 객체에 포함된 GuestbookDTO 객체 목록을 출력한다.
- 2월 18일에 작성해서 "218"을 검색하도록 했는데.. "218"을 포함한 방명록은 추가적인 데이터를 넣지 않았다면 1개일 것이다. 그러므로 페이지는 1페이지이며 PREV, NEXT 모두 false가 출력될 것이다.
실행 화면
애플리케이션을 실행한 후에 localhost:8080/guestbook/list에 접속하면 위와 같이 1페이지의 방명록 리스트들이 보인다.
여기서 register 버튼을 누르면 위와 같이 새로운 방명록을 등록할 수 있는 페이지가 나온다. 모든 내용을 작성한 이후에 Submit 버튼을 누르면 해당 방명록이 데이터베이스에 저장되고 아래와 같이 1페이지의 방명록 리스트들이 보이는 페이지로 리디렉션된다.
이후에 방금 작성한 방명록의 gno인 304를 누르면 아래와 같이 해당 방명록을 볼 수 있는 페이지로 이동한다.
이 페이지에서 Modify 버튼을 누르면 아래와 같이 해당 방명록을 수정할 수 있는 페이지로 넘어가게 되며 List 버튼을 누르면 기존의 1페이지의 방명록 리스트들이 보이는 페이지로 리디렉션된다.
방명록은 제목과 내용만 수정할 수 있으며 수정된 이후에는 수정된 게시글의 내용을 다시 보여준다. 또한 이 페이지에서 Remove 버튼을 누르게 되면 데이터베이스에서 해당 방명록이 삭제되고 자동으로 아래와 같이 1페이지의 방명록 리스트들이 보이는 페이지로 리디렉션된다.
사용자가 17페이지를 보고 있을 때 정상적으로 11페이지부터 20페이지까지 보여지는 것을 확인할 수 있다. 또한 1페이지에는 없었던 Previoust 버튼이 생긴 것을 볼 수 있다.
마지막 페이지에는 Next 버튼이 없는 것을 확인할 수 있다.
위와 같이 검색 조건을 설정하고 오늘은 2월 22일이므로 "222"를 제목과 함께 검색하면 아래와 같이 하나의 방명록이 검색되는 것을 확인할 수 있다.
결론
책에 있는 코드를 거의 똑같이 작성하는 것도 힘들다는 생각이 들었다. 그래도 처음 스프링을 공부했을 때보다 지난 스퍼트 프로젝트를 통해서 아는 지식도 많아진 것이 도움이 됐는지 글이 비교적 쉽게 읽혔다. 아직 어려운 부분은 많지만..
ETRI도 곧 있으면 퇴사해야 해서 이 책도 반납해야 하는데.. ETRI 퇴사 전에 시간이 된다면 이 책에서 다루는 다른 프로젝트들 중 게시판 프로젝트와 Spring Security를 이용한 로그인 프로젝트를 따라 쳐보고 만약 그 전에 다 끝내지 못 한다면 개강 후에 이 책을 구매하여 나만의 토이 프로젝트를 진행할 생각이다. 원래 책을 잘 사지 않는데 이 책은 특히 내용이 좋은 것 같아서 다른 사람들에게도 추천해주고 싶다.
'Java Spring Boot' 카테고리의 다른 글
Spring Security RoleHierarchy로 계층권한 설정하기 (0) | 2024.08.12 |
---|---|
Board Clone 프로젝트에 Spring Security를 활용한 로그인 기능 구현하기 (2) | 2024.08.10 |
TODO API SERVER with 스프링부트 (0) | 2024.07.01 |
코드로 배우는 스프링 부트 웹 프로젝트 Board Clone (1) | 2024.03.10 |