JUINTINATION
TODO API SERVER with 스프링부트 본문
종강을 하고 이런저런 밀린 일을 처리하고 보니 어느새 7월.. 날씨가 견딜 수 없을 만큼 더워지고 이러다가 마지막 방학을 아무것도 안 하고 종일 유튜브만 보면서 보낼 것 같아서 예전에 작성했었던 todo-api-server의 내용을 스프링 복습 겸 작성해 보고자 한다.
지난 학기에 인프런 강의(코드로 배우는 React with 스프링부터 API서버)를 보면서 작성하였으며, 원본 소스코드는 다음 링크에 공개되어 있다.
프로젝트 생성
start.spring.io에서 Lombok, Spring Data JPA, Spring Web, Spring Boot DevTools 의존성을 추가하여 org.zerock.todoapi 프로젝트를 생성한 이후에 Intellij에서 build.gradle 파일을 선택하고 Open as Project로 프로젝트를 실행한다. 이후에 MariaDB 관련 내용과 querydsl 관련 내용을 다음과 같이 build.gradle에 추가한다.
buildscript {
ext {
queryDslVersion = "5.0.0"
}
}
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.4'
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-web'
compileOnly 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta"
annotationProcessor(
"jakarta.persistence:jakarta.persistence-api",
"jakarta.annotation:jakarta.annotation-api",
"com.querydsl:querydsl-apt:${queryDslVersion}:jakarta"
)
}
tasks.named('test') {
useJUnitPlatform()
}
compileJava.dependsOn('clean')
처음에 ${queryDslVersion} 부분에서 빨간 줄이 뜨는데 코끼리 모양 버튼을 눌러 리프레시 해주면 사라진다.
이후에 IntelliJ 우측의 코끼리 아이콘(Gradle)을 누른 뒤에 Tasks/other/compileJava
를 Run하면 build/generated/annotationProcessor/java/main/org/zerock/todoapi/domain
에 QTodo라는 이름의 QDomain이 생긴 것을 볼 수 있다. (내가 옛날에 한 것 처럼 막 srcDirs = \["$projectDir/src/main/java", "$projectDir/build/generated"\]
이런 짓을 할 필요가 없었다!)
추가로 compileJava.dependsOn('clean') 는 위의 compileJava 를 실행하기 전에 전부 초기화하는 세팅인데 지금은 없어도 상관없다.
application.properties의 내용은 다음과 같이 입력하였다.
spring.application.name=todoapi
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://localhost:3306/tododb
spring.datasource.username=malldbuser
spring.datasource.password=malldbuser
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=true
logging.level.com.zaxxer=info
위의 내용을 통해 알 수 있듯이 스프링부트 버전은 3.2.4이며 지난 프로젝트와 달리 mysql이 아닌 mariadb를 사용하였다.
프로젝트 내용
기존의 코드로 배우는 스프링 부트 웹 프로젝트 책으로 공부했을 때와 달리 thymeleaf를 사용하지 않고 화면 구성이 하나도 없는 상태로 작성하였으며, Postman을 이용해서 작성된 코드의 결과를 확인하는 방식으로 구현하였다.
domain/BaseEntity.java
package org.zerock.todoapi.domain;
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;
}
강의 내용과 100% 일치하지 않게 지난 Board Clone 프로젝트에 사용한 내용을 약간 덧붙였다.
BaseEntity에 @Entity
어노테이션을 추가하여 해당 클래스가 엔티티를 위한 클래스이며 해당 클래스의 인스턴스들이 JPA로 관리되는 엔티티 객체라는 것을 명시하고, @MappedSuperClass
어노테이션을 추가하여 해당 어노테이션이 적용된 클래스는 테이블로 생성되지 않고 실제 테이블은 BaseEntity 클래스를 상속한 엔티티의 클래스로 데이터베이스 테이블이 생성되도록 하였다.
JPA 내부에서 엔티티 객체가 생성/변경되는 것을 감지하는 역할은 AuditingEntityListener를 통해 regDate, modDate에 적절한 값이 지정된다. @CreatedDate
는 JPA에서 엔티티의 생성 시간을 처리하고 @LastModifiedDate
는 최종 수정 시간을 자동으로 처리하는 용도로 사용한다. redDate는 updatable를 false로 설정하여 해당 엔티티 객체를 데이터베이스에 반영할 때 변경되지 않도록 하였다.
TodoApiApplication.java
package org.zerock.todoapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@SpringBootApplication
@EnableJpaAuditing
public class TodoApiApplication {
public static void main(String[] args) {
SpringApplication.run(TodoApiApplication.class, args);
}
}
JPA를 이용하면서 AuditingEntityListener를 활성화시키기 위해 프로젝트에 @EnableJpaAuditing
설정을 추가해야 하므로 위과 같이 TodoApiApplication을 수정해야 정상적으로 적용된다.
domain/Todo.java
package org.zerock.todoapi.domain;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDate;
@Entity
@ToString
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Todo extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long tno;
@Column(nullable = false)
private String title;
@Column(length = 500)
private String content;
private boolean complete;
private LocalDate dueDate;
public void changeTitle(String title){
this.title = title;
}
public void changeContent(String content){
this.content = content;
}
public void changeComplete(boolean complete){
this.complete = complete;
}
public void changeDueDate(LocalDate dueDate){
this.dueDate = dueDate;
}
}
강의에서는 Lombok을 추가했기 때문에 엔티티에 @ToString
어노테이션을 추가하는 것이 좋으며, 엔티티는 기본적으로 인뮤터블하게, 불변으로 만들어 주는 것이 좋다고 했다. 그래서 위와 같이 @ToString
어노테이션을 적용하였으며, 다음과 같은 어노테이션을 적용했다.
- @Entity: 클래스가 JPA 엔티티임을 나타냈다.
- @Getter: 모든 필드에 대한 getter 메서드를 자동으로 생성한다.
- @Builder: 빌더 패턴을 구현한다.
- @AllArgsConstructor: 모든 필드를 파라미터로 받는 생성자를 생성한다.
- @NoArgsConstructor: 기본 생성자를 생성한다.
tno 필드는 @Id
어노테이션을 통해 기본키를 나타내고, @GeneratedValue(strategy = GenerationType.IDENTITY)
어노테이션을 통해 기본 키를 데이터베이스에서 자동으로 생성되도록 한다. 나머지 필드의 내용은 다음과 같다.
- title 필드는 말 그대로 todo의 제목을 의미하며,
@Column(nullable = false)
어노테이션을 통해 null 값을 허용하지 않음을 나타낸다. - content 필드는 todo의 내용을 의미하며,
@Column(length = 500)
어노테이션을 통해 최대 길이를 500자로 설정한다. - complete 필드는 todo의 완료 여부를 의미하며, dueDate 필드는 todo의 기한을 의미한다.
- 또한 위에서 만든 BaseEntity를 상속받아 regDate와 modDate 필드가 추가된다.
changeTitle, changeContent, changeComplete, changeDueDate 메서드는 @Setter
어노테이션을 적용하여 대체할 수 있지만 의미를 좀 더 명확하게 명시하기 위해 따로 구현하였다.
repository/TodoRepository.java
package org.zerock.todoapi.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.zerock.todoapi.domain.Todo;
import org.zerock.todoapi.repository.search.TodoSearch;
public interface TodoRepository extends JpaRepository<Todo, Long>, TodoSearch {
}
JpaRepository를 사용할 때는 엔티티의 타입 정보와 @Id의 타입을 지정하게 되는데 이처럼 Spring Data JPA는 인터페이스 선언만으로도 자동으로 스프링 빈(bean)으로 등록된다.
repository/TodoRepositoryTests.java
package org.zerock.todoapi.repository;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.Assertions;
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.todoapi.domain.Todo;
import java.time.LocalDate;
import java.util.Optional;
@SpringBootTest
@Log4j2
public class TodoRepositoryTests {
@Autowired
private TodoRepository todoRepository;
@Test
public void testIsNull() {
Assertions.assertNotNull(todoRepository);
log.info(todoRepository.getClass().getName());
}
@Test
public void testInsert() {
for (int i = 0; i < 100; i++) {
Todo todo = Todo.builder()
.title("Title.." + (i + 1))
.content("Content.." + (i + 1))
.dueDate(LocalDate.of(2024, 3, 12))
.build();
Todo result = todoRepository.save(todo);
log.info(result);
}
}
@Test
public void testRead() {
Long tno = 1L;
Optional<Todo> result = todoRepository.findById(tno);
Todo todo = result.orElseThrow();
Assertions.assertNotNull(todo);
log.info(todo);
}
@Test
public void testUpdate() {
Long tno = 1L;
Optional<Todo> result = todoRepository.findById(tno);
Todo todo = result.orElseThrow();
todo.changeTitle("updated title");
todo.changeContent("updated content");
todo.changeComplete(true);
todo.changeDueDate(LocalDate.of(2023,10,10));
Assertions.assertNotNull(todoRepository.save(todo));
}
@Test
public void testPaging() {
// 페이지 번호는 0부터
Pageable pageable = PageRequest.of(0, 10, Sort.by("tno").descending());
Page<Todo> result = todoRepository.findAll(pageable);
log.info(result.getTotalElements());
log.info(result.getContent());
}
}
- testIsNull 테스트를 통해
@Authwired
를 통해 자동으로 주입받은 todoRepository가 제대로 링크되었는지 여부를 확인할 수 있다. - testInsert 테스트를 통해 100개의 테스트용 더미 데이터를 db에 추가할 수 있다.
- testRead 테스트를 통해 todoRepository의 findById(tno) 메서드를 통해 얻은 데이터를 읽을 수 있다.
- testUpdate 테스트를 통해 Id가 tno인 데이터를 수정할 수 있다.
- DueDate를 수정할 땐 java.time.LocalDate.of(year, month, day)를 통해 수정해야 한다.
- testPaging 테스트를 통해 페이징 처리를 테스트할 수 있다.
- 첫 번째 페이지(0부터 시작)에 10개의 Todo 객체를 가져온다.
- tno 필드 기준으로 내림차순 정렬한다.
- 전체 요소 수와 페이지의 내용을 로그로 출력한다.
dto/TodoDTO.java
package org.zerock.todoapi.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TodoDTO {
private Long tno;
private LocalDateTime regDate;
private LocalDateTime modDate;
private String title;
private String content;
private boolean complete;
private LocalDate dueDate;
}
Entity는 결국 JPA가 관리하는 객체이기 때문에 가능하면 많은 곳을 돌아다니지 않게 처리하기 위해 DTO로 변환하는 과정을 거친다. 결론적으로 DTO는 막 쓰고 버리는 일회용품같은 존재라고 보면 된다.
dto/PageRequestDTO.java
package org.zerock.todoapi.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
public class PageRequestDTO {
@Builder.Default
private int page = 1;
@Builder.Default
private int size = 10;
}
이 클래스는 주로 페이징 요청을 처리하는데 사용되며, 다른 DTO나 서비스 계층에서 쉽게 활용될 수 있다.
- @Data: getter, setter, toString, equals, hashCode 메서드를 자동으로 생성한다.
- @SuperBuilder: Lombok의 @Builder와 비슷하지만, 상속 관계에서 부모 클래스의 필드까지 빌더 패턴을 지원한다.
- 이 클래스는 상속받은 부모 클래스가 없기 때문에 그냥 @Builder로 했어도 큰 문제가 없지 않았을까 조심스럽게 예상해본다.
- @AllArgsConstructor: 모든 필드를 파라미터로 받는 생성자를 생성한다.
- @NoArgsConstructor: 기본 생성자를 생성한다.
@Builder.Default 어노테이션을 통해 빌더 패턴을 사용할 때 기본값을 다음과 같이 설정한다.
- page = 1: 기본 페이지 번호는 1로 설정됩니다.
- size = 10: 기본 페이지 크기는 10으로 설정됩니다.
dto/PageResponse.java
package org.zerock.todoapi.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@Data
public class PageResponseDTO<E> {
private List<E> dtoList;
private List<Integer> pageNumList;
private PageRequestDTO pageRequestDTO;
private boolean prev;
private boolean next;
private int totalCount;
private int prevPage;
private int nextPage;
private int totalPage;
private int current;
@Builder(builderMethodName = "withAll")
public PageResponseDTO(List<E> dtoList, PageRequestDTO pageRequestDTO, long totalCount) {
this.dtoList = dtoList;
this.pageRequestDTO = pageRequestDTO;
this.totalCount = (int) totalCount;
int end = (int) (Math.ceil(pageRequestDTO.getPage() / 10.0)) * 10;
int start = end - 9;
int last = (int) (Math.ceil(totalCount / (double) pageRequestDTO.getSize()));
end = Math.min(end, last);
this.prev = start > 1;
this.next = totalCount > (long) end * pageRequestDTO.getSize();
this.pageNumList = IntStream.rangeClosed(start, end).boxed().collect(Collectors.toList());
if (prev) {
this.prevPage = start - 1;
}
if (next) {
this.nextPage = end + 1;
}
this.totalPage = this.pageNumList.size();
this.current = pageRequestDTO.getPage();
}
}
PageResponseDTO 클래스는 페이징 처리된 결과를 제공하며, 각 필드의 의미는 다음과 같다.
- dtoList: 현재 페이지의 TODO DTO 리스트
- pageNumList: 현재 페이지에서 볼 수 있는 페이지 번호 리스트
- pageRequestDTO: 페이지 요청 DTO
- prev: 이전 페이지 리스트 존재 여부
- next: 다음 페이지 리스트 존재 여부
- totalCount: 전체 TODO 개수
- prevPage: 이전 페이지 리스트의 시작 번호
- nextPage: 다음 페이지 리스트의 시작 번호
- totalPage: 현재 페이지에서 볼 수 있는 페이지 개수
- current: 현재 페이지 번호
생성자에서는 @Builder(builderMethodName = "withAll") 어노테이션을 통해 기본 builder() 메서드 대신 withAll() 메서드를 사용하도록 하였으며, dtoList, pageRequestDTO, totalCount를 매개변수로 받아 객체를 초기화한 후에 다음 과정을 진행한다.
- 페이지 계산을 위해 다음과 같은 순서로 start, end 값을 설정한다.
- 먼저 end 값을 임시로 계산한다.
- 예시로 14 페이지를 요청했다면 14 -> 1.4 -> 2 -> 20 순서로 end가 결정된다.
- 그 다음 start 값을 계산한다.
- 위에서 계산한 end - 9를 진행해준다.
- 이후 last 값을 계산한다.
- last는 정말 맨 마지막 페이지 번호를 의미하며 전체 todo 개수가 171개이고, pageRequestDTO의 size 필드의 값이 10이라면 171 -> 17.1 -> 18 순서로 last가 결정된다.
- 이후 end 값을 최종적으로 계산한다.
- last와 end 중 더 작은 값이 end가 된다.
- 위에서 사용한 예시에서는 20이 아닌 18이 end가 된다.
- 먼저 end 값을 임시로 계산한다.
- 위에서 계산한 start 값이 1보다 큰지 여부에 따라 prev가 정해진다.
- 위에서 계산한 end 값과 pageRequestDTO의 size 필드 값의 곱이 전체 todo의 개수보다 적은지 여부에 따라 next가 정해진다.
- 위의 예시에서는 171 > 180이 false이므로 next는 false가 된다.
- pageNumList는 Java 스트림 API를 사용하여 생성한 현재 페이지 블록의 페이지 번호 리스트이다.
- prev와 next의 여부에 따라 prevPage와 nextPage를 각각 이전 및 다음 페이지 블록의 시작 번호를 설정한다.
- totalPage는 페이지 번호 리스트의 크기이며, current는 현재 페이지 번호이다.
repository/search/TodoSearch.java
package org.zerock.todoapi.repository.search;
import org.springframework.data.domain.Page;
import org.zerock.todoapi.domain.Todo;
import org.zerock.todoapi.dto.PageRequestDTO;
public interface TodoSearch {
Page<Todo> searchByPage(PageRequestDTO pageRequestDTO);
}
TodoSearch는 다른 Repository에서 쿼리 메서드나 @Query 등으로 처리할 수 없는 기능인 페이지 검색 기능을 별도의 인터페이스로 만든 것이다. 아래의 구현 클래스에 인터페이스의 기능을 Q도메인 클래스와 JPQLQuery를 이용하여 구현한다.
repository/search/TodoSearchImpl.java
package org.zerock.todoapi.repository.search;
import com.querydsl.jpa.JPQLQuery;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.domain.*;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import org.zerock.todoapi.domain.QTodo;
import org.zerock.todoapi.domain.Todo;
import org.zerock.todoapi.dto.PageRequestDTO;
import java.util.List;
@Log4j2
public class TodoSearchImpl extends QuerydslRepositorySupport implements TodoSearch {
public TodoSearchImpl() {
super(Todo.class);
}
@Override
public Page<Todo> searchByPage(PageRequestDTO pageRequestDTO) {
log.info("searchByPage............");
QTodo todo = QTodo.todo;
JPQLQuery<Todo> query = from(todo);
Pageable pageable = PageRequest.of(pageRequestDTO.getPage() - 1,
pageRequestDTO.getSize(),
Sort.by("tno").descending());
this.getQuerydsl().applyPagination(pageable, query);
List<Todo> list = query.fetch();
long total = query.fetchCount();
return new PageImpl<>(list, pageable, total);
}
}
다른 글에서도 언급했지만 TodoSearchImpl 클래스에서 가장 중요한 점은 QuerydslRepositorySupport 클래스를 상속한다는 점이다. QuerydslRepositorySupport 클래스는 Spring Data JPA에 포함된 클래스로 Querydsl 라이브러리를 이용하여 직접 무언가를 구현할 때 사용하며 생성자가 존재하기 때문에 클래스 내에서 super() 메서드를 이용하여 호출해야 한다. 이 때 도메인 클래스를 지정하는데 null 값은 넣을 수 없다.
JPQLQuery query = from(todo)를 통해 Todo 엔티티를 기반으로 JPQL 쿼리를 생성한 후에 Pageable 객체를 생성한다. 페이지 번호는 0부터 시작하기 때문에 pageRequestDTO.getPage() - 1로 설정하고, 항목은 tno 필드를 기준으로 내림차순 정렬한다.
this.getQuerydsl().applyPagination(pageable, query)을 통해 페이지네이션과 정렬을 쿼리에 적용한 후에 List list = query.fetch()를 통해 쿼리를 실행하여 결과 리스트를 가져온다.
이후 결과 리스트의 개수와 함께 return new PageImpl<>(list, pageable, total)을 통해 PageImpl 객체를 생성하여 페이지네이션된 결과를 반환한다.
service/TodoService.java
package org.zerock.todoapi.service;
import org.springframework.transaction.annotation.Transactional;
import org.zerock.todoapi.domain.Todo;
import org.zerock.todoapi.dto.PageRequestDTO;
import org.zerock.todoapi.dto.PageResponseDTO;
import org.zerock.todoapi.dto.TodoDTO;
@Transactional
public interface TodoService {
TodoDTO get(Long tno);
Long register(TodoDTO dto);
void modify(TodoDTO dto);
void remove(Long tno);
PageResponseDTO<TodoDTO> getList(PageRequestDTO pageRequestDTO);
default TodoDTO entityToDTO(Todo todo) {
return TodoDTO.builder()
.tno(todo.getTno())
.regDate(todo.getRegDate())
.modDate(todo.getModDate())
.title(todo.getTitle())
.content(todo.getContent())
.complete(todo.isComplete())
.dueDate(todo.getDueDate())
.build();
}
default Todo dtoToEntity(TodoDTO todoDTO) {
return Todo.builder()
.tno(todoDTO.getTno())
.title(todoDTO.getTitle())
.content(todoDTO.getContent())
.complete(todoDTO.isComplete())
.dueDate(todoDTO.getDueDate())
.build();
}
}
TodoService 인터페이스는 차례로 Todo 읽기, 새로운 Todo 등록, Todo 수정 및 삭제, Todo 리스트 불러오기 시나리오를 처리한다.
또한 DTO를 엔티티로 변환하거나 엔티티를 DTO로 변환하는 기능을 처리하는데 ModelMapper 라이브러리나 MapStruct 등을 이용하지 않고 이를 직접 default 메서드로 구현했다.
service/TodoServiceImpl
package org.zerock.todoapi.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
import org.zerock.todoapi.domain.Todo;
import org.zerock.todoapi.dto.PageRequestDTO;
import org.zerock.todoapi.dto.PageResponseDTO;
import org.zerock.todoapi.dto.TodoDTO;
import org.zerock.todoapi.repository.TodoRepository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@Log4j2
@RequiredArgsConstructor
public class TodoServiceImpl implements TodoService {
private final TodoRepository todoRepository;
@Override
public TodoDTO get(Long tno) {
Optional<Todo> result = todoRepository.findById(tno);
Todo todo = result.orElseThrow();
return entityToDTO(todo);
}
@Override
public Long register(TodoDTO dto) {
Todo todo = dtoToEntity(dto);
Todo result = todoRepository.save(todo);
return result.getTno();
}
@Override
public void modify(TodoDTO dto) {
Optional<Todo> result = todoRepository.findById(dto.getTno());
Todo todo = result.orElseThrow();
todo.changeTitle(dto.getTitle());
todo.changeContent(dto.getContent());
todo.changeComplete(dto.isComplete());
todo.changeDueDate(dto.getDueDate());
todoRepository.save(todo);
}
@Override
public void remove(Long tno) {
todoRepository.deleteById(tno);
}
@Override
public PageResponseDTO<TodoDTO> getList(PageRequestDTO pageRequestDTO) {
// JPA
Page<Todo> result = todoRepository.searchByPage(pageRequestDTO);
// from Entity list to DTO list
List<TodoDTO> dtoList = result
.get()
.map(todo -> entityToDTO(todo)).collect(Collectors.toList());
PageResponseDTO<TodoDTO> responseDTO = PageResponseDTO.<TodoDTO>withAll()
.dtoList(dtoList)
.pageRequestDTO(pageRequestDTO)
.totalCount(result.getTotalElements())
.build();
return responseDTO;
}
}
BoardServiceImpl 클래스는 BoardService 인터페이스를 상속받고 스프링에서 빈으로 처리되도록 @Service
어노테이션을 추가한다.
service/TodoServiceTests.java
package org.zerock.todoapi.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;
import org.zerock.todoapi.domain.Todo;
import org.zerock.todoapi.dto.PageRequestDTO;
import org.zerock.todoapi.dto.TodoDTO;
import java.time.LocalDate;
@SpringBootTest
@Log4j2
public class TodoServiceTests {
@Autowired
private TodoService todoService;
@Test
public void testGet() {
Long tno = 50L;
log.info(todoService.get(tno));
}
@Test
public void testRegister() {
TodoDTO todoDTO = TodoDTO.builder()
.title("Title..")
.content("Content..")
.dueDate(LocalDate.of(2024, 3, 12))
.build();
log.info(todoService.register(todoDTO));
}
@Test
public void testGetList() {
PageRequestDTO pageRequestDTO = PageRequestDTO.builder().build();
log.info(todoService.getList(pageRequestDTO));
}
}
- testGet 테스트를 통해 todoService의 get(tno) 메서드를 통해 얻은 데이터를 읽을 수 있다.
- testInsert 테스트를 통해 1개의 테스트용 더미 데이터를 db에 추가할 수 있다.
- testGetList 테스트를 통해 PageRequestDTO의 기본값으로 페이지 객체를 가져올 수 있다.
controller/TodoController.java
package org.zerock.todoapi.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.*;
import org.zerock.todoapi.dto.PageRequestDTO;
import org.zerock.todoapi.dto.PageResponseDTO;
import org.zerock.todoapi.dto.TodoDTO;
import org.zerock.todoapi.service.TodoService;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@Log4j2
@RequestMapping("/api/todo")
public class TodoController {
private final TodoService todoService;
@GetMapping("/{tno}")
public TodoDTO get(@PathVariable("tno") Long tno) {
return todoService.get(tno);
}
@GetMapping("/list")
public PageResponseDTO<TodoDTO> list(PageRequestDTO pageRequestDTO) {
return todoService.getList(pageRequestDTO);
}
@PostMapping("/")
public Map<String, Long> register(@RequestBody TodoDTO dto) {
long tno = todoService.register(dto);
return Map.of("TNO", tno);
}
@PutMapping("/{tno}")
public Map<String, String> modify(@PathVariable("tno") Long tno,
@RequestBody TodoDTO dto) {
dto.setTno(tno);
todoService.modify(dto);
return Map.of("RESULT", "SUCCESS");
}
@DeleteMapping("/{tno}")
public Map<String, String> remove(@PathVariable("tno") Long tno) {
todoService.remove(tno);
return Map.of("RESULT", "SUCCESS");
}
}
RestController의 경우 모든 메서드의 리턴 타입을 기본으로 JSON을 사용한다. 메서드의 반환 타입은 ResponseEntity라는 객체를 이용하는데 이를 이용하면 HTTP의 상태 코드 등을 같이 전달할 수 있다.
controller/advice/CustomControllerAdvice.java
package org.zerock.todoapi.controller.advice;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
import java.util.NoSuchElementException;
@RestControllerAdvice
public class CustomControllerAdvice {
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<?> notExist(NoSuchElementException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("msg", e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<?> handleIllegalArgumentException(MethodArgumentNotValidException e) {
return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).body(Map.of("msg", e.getMessage()));
}
}
RestControllerAdvice는 RestController에 문제가 생겼을 때 예외처리를 하는 것으로 잘못된 값이 들어왔을 때, 예를 들어 페이지 번호는 숫자가 와야하는데 문자가 왔을 때와 같은 공통되는 잘못된 점에 대해 처리를 진행한다.
공통된 작업을 처리하기 때문에 TodoControllerAdvice와 같은 이름을 사용하지 않는 것이 좋다고 한다.
- @ExceptionHandler(NoSuchElementException.class): NoSuchElementException이 발생할 때 noExist 메서드가 호출된다.
- ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("msg", e.getMessage())): 404 Not Found 상태 코드와 함께 예외 메시지를 포함하는 응답을 생성한다.
- @ExceptionHandler(MethodArgumentNotValidException.class): MethodArgumentNotValidException이 발생할 때 이handleIllegalArgumentException 메서드가 호출된다.
- ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).body(Map.of("msg", e.getMessage())): 406 Not Acceptable 상태 코드와 함께 예외 메시지를 포함하는 응답을 생성한다.
controller/formatter/LocalDateFormatter.java
package org.zerock.todoapi.controller.formatter;
import org.springframework.format.Formatter;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
public class LocalDateFormatter implements Formatter<LocalDate> {
@Override
public LocalDate parse(String text, Locale locale) {
return LocalDate.parse(text, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
@Override
public String print(LocalDate object, Locale locale) {
return DateTimeFormatter.ofPattern("yyyy-MM-dd").format(object);
}
}
이 클래스는 스프링의 Formatter 인터페이스를 구현하여 LocalDate 타입을 포맷팅하고 파싱하는 기능을 제공하며, 이를 통해 LocalDate 타입의 데이터를 원하는 형식으로 변환하거나 문자열을 LocalDate 타입으로 변환할 수 있다.
- parse 메서드는 문자열을 LocalDate 타입으로 변환한다.
- print 메서드는 LocalDate 타입의 데이터를 문자열로 변환한다.
controller/config/CustomServletConfig.java
package org.zerock.todoapi.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.zerock.todoapi.controller.formatter.LocalDateFormatter;
@Configuration
public class CustomServletConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addFormatter(new LocalDateFormatter());
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS")
.maxAge(500)
.allowedHeaders("Authorization", "Cache-Control", "Content-Type");
}
}
이 클래스는 WebMvcConfigurer 인터페이스를 구현하여 스프링 MVC의 다양한 설정을 커스터마이징한다.
- addFormatters 메서드를 통해 위의 LocalDateFormatter 포맷터를 등록한다.
- addCorsMappings 메서드를 통해 동일 출처 정책에 대비하여 교차 출처 리소스 공유(CORS) 설정을 추가한다.
- registry.addMapping("/**"): 모든 경로에 대해 CORS를 설정한다.
- allowedOrigins("*"): 모든 오리진(출처)에서 접근을 허용한다.
- allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"): 허용할 HTTP 메서드를 설정한다.
- maxAge(500): 프리플라이(pre-flight) 요청의 유효 기간을 설정한다(초 단위).
- allowedHeaders("Authorization", "Cache-Control", "Content-Type"): 허용할 헤더를 설정한다.
결론
참고 사진이 하나도 없는 이 글이 가독성이 좋을지는 의문이 들긴 한다.. 관련 설정이 데스크탑에 되어 있지만 지금 밖에서 작성하는 중이라 다시 설정하기 귀찮기도 하고, 집에서 다시 관련 내용을 추가를 하거나 하면 더 좋겠지만 다음에 작성할 글에 postman과 같은 내용을 추가하는 것이 나중에 볼 때 더 도움이 될 것 같다는 생각이 들었다. 다음엔 아마도 로그인 관련 api 서버에 대한 내용이나 API 명세에 대한 내용을 작성하지 않을까 싶다. 지금부터라도 다시 정진해보자.
'Java Spring Boot' 카테고리의 다른 글
Spring Security RoleHierarchy로 계층권한 설정하기 (0) | 2024.08.12 |
---|---|
Board Clone 프로젝트에 Spring Security를 활용한 로그인 기능 구현하기 (2) | 2024.08.10 |
코드로 배우는 스프링 부트 웹 프로젝트 Board Clone (1) | 2024.03.10 |
코드로 배우는 스프링 부트 웹 프로젝트 Guestbook Clone (0) | 2024.02.22 |