Java 26일 코스 - Day 26: 미니 프로젝트 Spring Boot Todo API

Day 26: 미니 프로젝트 Spring Boot Todo API

30일간의 여정을 마무리하는 시간입니다. 지금까지 배운 Java 기초, 객체지향, 컬렉션, 스트림, 예외 처리, JDBC, Spring Boot를 모두 활용하여 완전한 Todo REST API를 구축합니다. 실무에서 바로 활용할 수 있는 수준의 프로젝트를 목표로 합니다.

프로젝트 설정과 도메인 모델

프로젝트 뼈대와 핵심 도메인을 설계합니다.

// build.gradle.kts
/*
plugins {
    java
    id("org.springframework.boot") version "3.2.0"
    id("io.spring.dependency-management") version "1.1.4"
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    runtimeOnly("com.h2database:h2")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}
*/

// 도메인 엔티티: JPA 활용
import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "todos")
public class Todo {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String title;

    @Column(length = 500)
    private String description;

    @Column(nullable = false)
    private boolean completed = false;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Priority priority = Priority.MEDIUM;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    @PrePersist
    void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = this.createdAt;
    }

    @PreUpdate
    void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }

    // 생성자, getter, setter 생략
    public enum Priority { HIGH, MEDIUM, LOW }
}

Repository와 Service 계층

Spring Data JPA와 비즈니스 로직을 구현합니다.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

// Repository: Spring Data JPA가 구현체 자동 생성
@Repository
interface TodoRepository extends JpaRepository<Todo, Long> {
    List<Todo> findByCompleted(boolean completed);
    List<Todo> findByPriority(Todo.Priority priority);
    List<Todo> findByTitleContainingIgnoreCase(String keyword);

    @Query("SELECT t FROM Todo t WHERE t.completed = false ORDER BY " +
           "CASE t.priority WHEN 'HIGH' THEN 1 WHEN 'MEDIUM' THEN 2 ELSE 3 END")
    List<Todo> findPendingOrderByPriority();

    long countByCompleted(boolean completed);
}

// DTO
record CreateTodoRequest(
    @jakarta.validation.constraints.NotBlank(message = "제목은 필수입니다")
    @jakarta.validation.constraints.Size(max = 100) String title,
    @jakarta.validation.constraints.Size(max = 500) String description,
    Todo.Priority priority
) {}

record UpdateTodoRequest(
    @jakarta.validation.constraints.NotBlank String title,
    String description,
    Todo.Priority priority,
    Boolean completed
) {}

record TodoResponse(Long id, String title, String description,
                    boolean completed, String priority,
                    String createdAt, String updatedAt) {
    static TodoResponse from(Todo todo) {
        return new TodoResponse(
            todo.getId(), todo.getTitle(), todo.getDescription(),
            todo.isCompleted(), todo.getPriority().name(),
            todo.getCreatedAt().toString(),
            todo.getUpdatedAt() != null ? todo.getUpdatedAt().toString() : null
        );
    }
}

record TodoStats(long total, long completed, long pending,
                 double completionRate) {}

// Service
@Service
@Transactional
class TodoService {
    private final TodoRepository repository;

    TodoService(TodoRepository repository) {
        this.repository = repository;
    }

    TodoResponse create(CreateTodoRequest request) {
        Todo todo = new Todo();
        todo.setTitle(request.title());
        todo.setDescription(request.description());
        todo.setPriority(request.priority() != null ?
                         request.priority() : Todo.Priority.MEDIUM);
        return TodoResponse.from(repository.save(todo));
    }

    @Transactional(readOnly = true)
    List<TodoResponse> findAll(Boolean completed, Todo.Priority priority,
                                String keyword) {
        List<Todo> todos;
        if (keyword != null && !keyword.isBlank()) {
            todos = repository.findByTitleContainingIgnoreCase(keyword);
        } else if (completed != null) {
            todos = repository.findByCompleted(completed);
        } else if (priority != null) {
            todos = repository.findByPriority(priority);
        } else {
            todos = repository.findAll();
        }
        return todos.stream().map(TodoResponse::from).toList();
    }

    @Transactional(readOnly = true)
    TodoResponse findById(Long id) {
        return repository.findById(id)
            .map(TodoResponse::from)
            .orElseThrow(() -> new TodoNotFoundException(id));
    }

    TodoResponse update(Long id, UpdateTodoRequest request) {
        Todo todo = repository.findById(id)
            .orElseThrow(() -> new TodoNotFoundException(id));
        todo.setTitle(request.title());
        if (request.description() != null) todo.setDescription(request.description());
        if (request.priority() != null) todo.setPriority(request.priority());
        if (request.completed() != null) todo.setCompleted(request.completed());
        return TodoResponse.from(repository.save(todo));
    }

    TodoResponse toggleComplete(Long id) {
        Todo todo = repository.findById(id)
            .orElseThrow(() -> new TodoNotFoundException(id));
        todo.setCompleted(!todo.isCompleted());
        return TodoResponse.from(repository.save(todo));
    }

    void delete(Long id) {
        if (!repository.existsById(id)) throw new TodoNotFoundException(id);
        repository.deleteById(id);
    }

    @Transactional(readOnly = true)
    TodoStats getStats() {
        long total = repository.count();
        long completed = repository.countByCompleted(true);
        long pending = total - completed;
        double rate = total > 0 ? (double) completed / total * 100 : 0;
        return new TodoStats(total, completed, pending, Math.round(rate * 10) / 10.0);
    }
}

Controller와 전역 예외 처리

REST 엔드포인트와 통합 에러 핸들링입니다.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import jakarta.validation.Valid;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/todos")
class TodoController {
    private final TodoService service;

    TodoController(TodoService service) { this.service = service; }

    @PostMapping
    ResponseEntity<TodoResponse> create(@Valid @RequestBody CreateTodoRequest req) {
        return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req));
    }

    @GetMapping
    List<TodoResponse> findAll(
            @RequestParam(required = false) Boolean completed,
            @RequestParam(required = false) Todo.Priority priority,
            @RequestParam(required = false) String keyword) {
        return service.findAll(completed, priority, keyword);
    }

    @GetMapping("/{id}")
    TodoResponse findById(@PathVariable Long id) {
        return service.findById(id);
    }

    @PutMapping("/{id}")
    TodoResponse update(@PathVariable Long id,
                        @Valid @RequestBody UpdateTodoRequest req) {
        return service.update(id, req);
    }

    @PatchMapping("/{id}/toggle")
    TodoResponse toggle(@PathVariable Long id) {
        return service.toggleComplete(id);
    }

    @DeleteMapping("/{id}")
    ResponseEntity<Void> delete(@PathVariable Long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/stats")
    TodoStats stats() { return service.getStats(); }
}

// 예외 클래스
class TodoNotFoundException extends RuntimeException {
    TodoNotFoundException(Long id) { super("할일을 찾을 수 없습니다 (ID: " + id + ")"); }
}

// 전역 예외 처리
@RestControllerAdvice
class GlobalExceptionHandler {
    record ErrorBody(int status, String error, String message, String timestamp) {}

    @ExceptionHandler(TodoNotFoundException.class)
    ResponseEntity<ErrorBody> notFound(TodoNotFoundException e) {
        return ResponseEntity.status(404)
            .body(new ErrorBody(404, "Not Found", e.getMessage(),
                                LocalDateTime.now().toString()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<ErrorBody> validation(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
            .map(err -> err.getField() + ": " + err.getDefaultMessage())
            .reduce((a, b) -> a + "; " + b).orElse("검증 실패");
        return ResponseEntity.badRequest()
            .body(new ErrorBody(400, "Bad Request", msg,
                                LocalDateTime.now().toString()));
    }

    @ExceptionHandler(Exception.class)
    ResponseEntity<ErrorBody> general(Exception e) {
        return ResponseEntity.status(500)
            .body(new ErrorBody(500, "Internal Server Error", e.getMessage(),
                                LocalDateTime.now().toString()));
    }
}

테스트 코드

API 통합 테스트를 작성합니다.

import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.hamcrest.Matchers.*;

@SpringBootTest
@AutoConfigureMockMvc
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class TodoApiIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @Order(1)
    @DisplayName("POST /api/todos - 할일 생성")
    void createTodo() throws Exception {
        String json = """
            {"title": "Java 공부하기", "description": "30일 코스 완주", "priority": "HIGH"}
            """;
        mockMvc.perform(post("/api/todos")
                .contentType(MediaType.APPLICATION_JSON)
                .content(json))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.title").value("Java 공부하기"))
            .andExpect(jsonPath("$.completed").value(false))
            .andExpect(jsonPath("$.priority").value("HIGH"));
    }

    @Test
    @Order(2)
    @DisplayName("GET /api/todos - 전체 조회")
    void findAll() throws Exception {
        mockMvc.perform(get("/api/todos"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1))));
    }

    @Test
    @Order(3)
    @DisplayName("GET /api/todos/{id} - 존재하지 않는 ID 조회 시 404")
    void findByIdNotFound() throws Exception {
        mockMvc.perform(get("/api/todos/9999"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.status").value(404));
    }

    @Test
    @Order(4)
    @DisplayName("POST /api/todos - 제목 없이 생성 시 400")
    void createWithoutTitle() throws Exception {
        String json = """
            {"description": "제목 없음"}
            """;
        mockMvc.perform(post("/api/todos")
                .contentType(MediaType.APPLICATION_JSON)
                .content(json))
            .andExpect(status().isBadRequest());
    }

    @Test
    @Order(5)
    @DisplayName("GET /api/todos/stats - 통계 조회")
    void getStats() throws Exception {
        mockMvc.perform(get("/api/todos/stats"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.total").isNumber())
            .andExpect(jsonPath("$.completionRate").isNumber());
    }
}

30일 코스 정리 및 다음 단계

// 30일 동안 배운 내용 정리:
//
// Week 1 (Day 1-7): Java 기초
//   - 환경 설정, 변수/자료형, 연산자, 조건문, 반복문, 배열, 문자열
//
// Week 2 (Day 8-14): 객체지향 프로그래밍
//   - 메서드, 클래스/객체, 캡슐화, 상속, 다형성
//   - 추상 클래스, 인터페이스, 내부/익명 클래스
//
// Week 3 (Day 15-21): 핵심 API와 함수형
//   - 예외 처리, 컬렉션(List, Set, Map), 제네릭스
//   - 람다, 스트림 API, Optional
//
// Week 4 (Day 22-30): 실전 개발
//   - 파일 I/O, 멀티스레드, 동시성
//   - JDBC, 빌드 도구, JUnit 5
//   - Spring Boot, REST API, 미니 프로젝트

// 다음 단계 로드맵:
// 1. Spring Security (인증/인가)
// 2. JPA/Hibernate 심화
// 3. Docker + 배포
// 4. 메시징 (Kafka, RabbitMQ)
// 5. 마이크로서비스 아키텍처

public class CourseComplete {
    public static void main(String[] args) {
        System.out.println("축하합니다! Java 26일 코스를 완주했습니다!");
        System.out.println("이제 실전 프로젝트를 만들어보세요.");
    }
}

오늘의 연습문제

  1. 카테고리 추가: Todo에 카테고리(업무, 개인, 학습 등) 필드를 추가하고, 카테고리별 필터링과 카테고리별 통계를 제공하는 API를 구현하세요.

  2. 마감일 기능: Todo에 dueDate 필드를 추가하세요. 마감일이 지난 항목을 조회하는 /api/todos/overdue 엔드포인트와, 마감일 순으로 정렬하는 기능을 구현하세요.

  3. 전체 프로젝트 빌드: 지금까지 작성한 모든 코드를 하나의 Spring Boot 프로젝트로 통합하세요. ./gradlew build로 빌드하고, ./gradlew test로 모든 테스트를 통과시키고, ./gradlew bootRun으로 실행한 후 curl이나 Postman으로 모든 API를 테스트하세요.

이 글이 도움이 되었나요?