개발/ToDo List using JPA

[ToDo List using JPA] 2. Todo List 구현

용꿀 2022. 11. 22. 14:47

1. 기본 설정

ToDo List 프로젝트의 기본 설정을 위한 작업들이다.

// build.gradle
dependencies {
   testImplementation 'org.springframework.boot:spring-boot-starter-test'
   implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
   implementation 'org.springframework.boot:spring-boot-starter-web'
   implementation 'com.h2database:h2'
   compileOnly 'org.projectlombok:lombok:1.18.24'
   runtimeOnly 'com.h2database:h2'
   annotationProcessor 'org.projectlombok:lombok:1.18.24'
}

 

JPA, h2 DB, lombok 등을 사용하기 위해 dependencies들을 추가해주었다.

// application.config
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect

spring.jpa.open-in-view=false

spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.hibernate.ddl-auto=create

 

h2 DB, JPA, Hibernate 등과 관련된 여러 설정들을 진행하였다.

 

2. TodoEntity 구현

Todo 관련 내용을 담을 객체를 구현하기 위한 코드이다. JPA를 통해 자동으로 해당 테이블을 생성할 것이다.

//TodoEntity.java
@Builder // 자동으로 빌더를 생성해줌
@NoArgsConstructor // 파라미터가 없는 기본 생성자를 생성해줌
@AllArgsConstructor // 모든 필드 값을 파라미터로 받는 생성자를 생성해줌
@Data // getter, setter, toString을 자동 생성해줌
@Entity // DB에서의 Entity로써 테이블의 스키마
@Table(name = "Todo") // Table 이름을 지정, 미지정 시에는 클래스 이름과 동일
public class TodoEntity {

    @Id //Primary Key로 설정
    @GenericGenerator(name="uuid2", strategy="org.hibernate.id.UUIDGenerator") // 자동 임의값 지정
    @GeneratedValue(generator="uuid2")
    private String id;

    private String userId; // 차후 로그인 지원시 user의 구분을 위함
    private String title; // Todo의 내용
    private LocalDate date; // 날짜
    private boolean done; // Todo 완료 여부
}

 

3. CORS - 교차 출처 리소스 공유를 위한 설정

CORS란 추가 HTTP 헤더를 사용하여 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제다.

백엔드에서는 프론트 쪽에서의 요청들을 처리해야 하므로 이 과정이 필요하다.

//WebMVCConfig.java
@Configuration
public class WebMVCConfig implements WebMvcConfigurer {
    private final long MAX_AGE_SECS = 3600;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 모든 경로에 대해
        registry.addMapping("/**") // CORS를 적용할 URL 패턴을 정의 (여기서는 와일드카드를 사용)
                .allowedOrigins("http://localhost:3000") // Origin(http:localhost:3000)에 대해
                // GET, POST, PUT, PATCH, DELETE, OPTIONS 메서드를 허용한다.
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
                .allowedHeaders("*") // 모든 헤더 허용
                // MAX_AGE_SECS만큼 pre-flight 리퀘스트(사전에 서버에서 어떤 origin과 어떤 method를 허용하는지 브라우저에게 알려주는 역할)를 캐싱
                .maxAge(MAX_AGE_SECS);
    }
}

 

4. DTO (Data Transfer Object) 구현

DTO는 컨트롤러, 서비스, 리포지토리 계층간의 데이터 교환을 위한 객체이다.

프로젝트를 위해서 Todo와 Response를 위한 DTO 객체를 생성하였다.

// ResponseDTO.java
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class ResponseDTO<T> {
    private String error; // 에러가 발생하면 에러 정보를 저장
    private List<T> data; // 넘겨줄 데이터를 저장
}

 

// TodoDTO.java
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class TodoDTO {
    private String id;
    private String title;
    private LocalDate date;
    private boolean done;

    public TodoDTO(TodoEntity todo) { // 주입되는 todo의 정보에 맞춰 TodoDTO를 생성
        this.id = todo.getId();
        this.title = todo.getTitle();
        this.date = todo.getDate();
        this.done = todo.isDone();
    }

    public static TodoEntity toTodo(final TodoDTO todoDTO) { // 주입되는 todoDTO의 정보에 맞춰 todo를 생성한 후 반환
        return TodoEntity.builder()
                .id(todoDTO.getId())
                .title(todoDTO.getTitle())
                .date(todoDTO.getDate())
                .done(todoDTO.isDone())
                .build();
    }

}

 

5.  컨트롤러 구현

유저가 요청하는 다양한 기능들을 처리해줄 컨트롤러이다.

//TodoController.java
@RestController
@RequestMapping("todo") // 아래 메서드들을 localhost:8080/todo로 Mapping
@RequiredArgsConstructor
public class TodoController {

    private final TodoService service; // 서비스 계층의 기능을 사용하기 위함

    @GetMapping("/test") // localhost:8080/todo/test로 Mapping
    public ResponseEntity<?> testTodo() {
        String str = service.testService();
        List<String> list = new ArrayList<>();
        list.add(str);
        ResponseDTO<String> response = ResponseDTO.<String>builder().data(list).build();
        return ResponseEntity.ok().body(response);
    }

    @PostMapping("/create") // localhost:8080/todo/create로 Mapping
    public ResponseEntity<?> createTodo(@RequestBody TodoDTO todoDTO){
        try{
            String tempUserId = "temp-user"; // User ID
            LocalDate date = LocalDate.now();
            TodoEntity todo = TodoDTO.toTodo(todoDTO); // DB로 넘길 todo를 만들기
            todo.setId(null); // 명시
            todo.setUserId(tempUserId);
            todo.setDate(date); // 날짜 저장
            // todo가 올바른 상태인지 확인하고 올바르다면 저장 후에 그대로 반환
            List<TodoEntity> todos = service.create(todo);
            // todos에 들어가 있는 Todo들을 하나씩 순회하면서 TodoDTO로 변경 후에 모두 toDoDTOs에 리스트 형태로 저장
            List<TodoDTO> todoDTOs = todos.stream().map(TodoDTO::new).collect(Collectors.toList());
            // todoDTOs을 가지는 ResponseDTO<TodoDTO>인 response 생성
            ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(todoDTOs).build();
            return ResponseEntity.ok().body(response); // response를 응답 메시지에 포함하여 전달함으로써 todo 생성 완료
        } catch (Exception e){
            String error = e.getMessage();
            // 에러 정보를 담은 ResponseDTO인 response를 생성
            ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().error(error).build();
            return ResponseEntity.badRequest().body(response); // 에러 정보를 응답 메시지에 포함하여 badRequest로 전달
        }
    }

    @GetMapping("/find") // localhost:8080/todo/find로 Mapping
    public ResponseEntity<?> findTodoList(){
        try{
            String tempUserId = "temp-user"; // User ID

            List<TodoEntity> todos = service.retrieve(tempUserId);

            List<TodoDTO> todoDTOs = todos.stream().map(TodoDTO::new).collect(Collectors.toList());
            ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(todoDTOs).build();
            return ResponseEntity.ok().body(response);
        }catch (Exception e){
            String error = e.getMessage();
            return ResponseEntity.badRequest().body(error);
        }
    }

    @PutMapping("/update") // localhost:8080/todo/update로 Mapping
    public ResponseEntity<?> updateTodo(@RequestBody TodoDTO todoDTO){
        try {
            String tempUserId = "temp-user";

            TodoEntity todo = todoDTO.toTodo(todoDTO);
            todo.setUserId(tempUserId);

            List<TodoEntity> todos = service.update(todo);
            List<TodoDTO> todoDTOs = todos.stream().map(TodoDTO::new).collect(Collectors.toList());
            ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(todoDTOs).build();
            return ResponseEntity.ok().body(response);

        } catch (Exception e){
            String error = e.getMessage();
            return ResponseEntity.badRequest().body(error);
        }
    }

    @DeleteMapping("/delete") //localhost:8080/todo/delete로 Mapping
    public ResponseEntity<?> deleteTodo(@RequestBody TodoDTO todoDTO){
        try {
            String tempUserId = "temp-user";
            TodoEntity todo = todoDTO.toTodo(todoDTO);
            todo.setUserId(tempUserId);
            List<TodoEntity> todos = service.delete(todo);
            List<TodoDTO> todoDTOs = todos.stream().map(TodoDTO::new).collect(Collectors.toList());
            ResponseDTO<TodoDTO> response = ResponseDTO.<TodoDTO>builder().data(todoDTOs).build();
            return ResponseEntity.ok().body(response);
        }catch (Exception e){
            String error = e.getMessage();
            return ResponseEntity.badRequest().body(error);
        }
    }
}

 

6. 서비스 계층 구현

서비스 계층은 컨트롤러와 데이터 엑세스 계층(Repository)을 연결해주는 역할을 한다.

CRUD 기능뿐만 아니라 Transaction 관리나 Todo의 적합성 검증(validation)도 수행할 것이다.

@Slf4j // 다양한 로깅 프레임 워크에 대한 추상화(인터페이스) 역할을 하는 라이브러리
@Service
@RequiredArgsConstructor
public class TodoService {

    private final TodoRepository todoRepository;

    public String testService() {
        // TodoEntity 생성 (Create)
        TodoEntity todo = TodoEntity.builder().title("My first todo item").build();
        // TodoEntity 저장 (Create)
        todoRepository.save(todo);

        // TodoEntity 검색 (Read)
        // TODO: Optional 값을 어떻게 처리해주는 것이 좋은 것인가?
        Optional<TodoEntity> optionalTodoEntity = todoRepository.findById(todo.getId());
        if (optionalTodoEntity.isPresent()) {
            TodoEntity savedEntity = optionalTodoEntity.get();
            return savedEntity.getTitle();
        }
        return "";
    }


    @Transactional
    public List<TodoEntity> create(TodoEntity todo) { // todo 검증 후 저장을 한 후에 로그 작성하고 저장한 todo를 반환
        validate(todo);
        todoRepository.save(todo);
        log.info("Todo Id : {} is saved", todo.getId());
        return retrieve(todo.getUserId());
    }

    public List<TodoEntity> retrieve(String userId) { // 해당 uid로 작성된 todo들 반환
        return todoRepository.findByUserId(userId);
    }

    private void validate(TodoEntity todo) { // 올바른 todo인지 검증
        if(todo == null) {
            log.warn("Entity can not be NULL.");
            throw new RuntimeException("Entity can not be NULL.");
        }
        if(todo.getUserId() == null) {
            log.warn("Unknown User.");
            throw new RuntimeException("Unknown User.");
        }
    }

    @Transactional
    public List<TodoEntity> update(TodoEntity todo) {
        validate(todo);
        final Optional<TodoEntity> optionalTodo = todoRepository.findById(todo.getId());
        optionalTodo.ifPresent((offeredTodo) -> {
            // spring JPA의 더티체킹 기능 덕에 todoRepository.save(entity)를 해주지 않아도 된다.
            offeredTodo.setTitle(todo.getTitle()); // todo 내용 수정 후 저장
            offeredTodo.setDone(todo.isDone()); // todo 완료 여부 수정 후 저장
//            offeredTodo.setDate(backupDate); // todo 날짜 저장
//            todoRepository.save(offeredTodo);
        });
        return retrieve(todo.getUserId());
    }

    @Transactional
    public List<TodoEntity> delete(TodoEntity todo) {
        validate(todo);
        try{
            todoRepository.deleteById(todo.getId());
        }catch (Exception e){
            log.error("error deleting entity {} not exists!", todo.getId());
            throw new RuntimeException("error deleting entity " + todo.getId());
        }
        return retrieve(todo.getUserId());
    }
}

 

7. 리포지토리 계층 구현

JpaRepository를 사용하여, DB에 CRUD 명령들을 실행할 수 있게 하는 계층이다. 복잡한 쿼리문을 쉽게 실행할 수 있게 해 준다.

@Repository
public interface TodoRepository extends JpaRepository<TodoEntity, String> {
    List<TodoEntity> findByUserId(String userId); // 해당 uid를 가지는 todo들을 리스트 형태로 반환
}

 

8. 실행 결과

(1) ToDo 테이블 생성

 

JPA를 사용하여서 자동적으로 테이블을 생성하게 하였는데, 의도대로 잘 실행되었음을 콘솔 창에서 확인할 수 있다.

 

 

H2에서 show문을 사용해 Todo 테이블의 column들을 확인하였을 때, 역시 의도한 대로 잘 생성됐음을 확인할 수 있다.

 

(2)  ToDo 입력

이제 웹 화면에 ToDo를 직접 입력을 해보자.

 

위와 같이 ToDo 입력 창에 해야 할 일을 입력한다.

 

(3) ToDo 생성

 

엔터키를 입력하거나 + 버튼을 클릭하게 되면 위와 같이 ToDo가 생성되어 화면에 나타남을 확인할 수 있다.

 

 

콘솔 창에도 마찬가지로 자동으로 SQL문이 실행되어서 데이터가 DB에 입력되었음을 확인할 수 있다.

 

 

H2에서 select문을 사용해 입력한 ToDo가 DB에 저장되었는지 확인할 수 있다.

 

(4) ToDo 수정

생성된 ToDo의 내용을 마우스로 클릭하면 내용을 수정할 수 있게 된다.

위처럼 수정을 한 후에 엔터키를 눌러 수정을 완료한다.

 

 

콘솔 창에서 자동으로 SQL문이 실행되어서 데이터가  수정되어 DB에 입력되었음을 확인할 수 있다.

 

 

H2에서 select문을 사용해 Todo 테이블의 모든 데이터들을 확인하였을 때, 잘 삭제되었음을 확인할 수 있다.

 

(5) ToDo 삭제

생성된 ToDo의 우측에 위치한 휴지통 버튼을 클릭하면 그 ToDo는 삭제가 된다.

 

 

콘솔창에서 자동으로 SQL문이 실행되어서 데이터가 DB에서 삭제되었음을 확인할 수 있다.

 

H2에서 select문을 사용해 Todo 테이블의 모든 데이터들을 확인하였을 때, 잘 삭제되었음을 확인할 수 있다.

 

9. 다음으로

다음에는 ToDo List를 사용하기 위한 회원가입을 구현해보도록 하겠다.

 

 

※ 참고

프론트엔드 구현은 같이 스프링 스터디를 진행했던 팀원(leejh7)이 제공해준 프론트엔드 코드를 사용하였다.

https://github.com/leejh7/Co_TodoList