๐Ÿ“Œ TODO ๋ฐฑ์—”๋“œ API ๊ฐœ๋ฐœํ•˜๊ธฐ

์ตœ์ธํ˜ธยท2025๋…„ 4์›” 3์ผ

API ๋ฌธ์„œ

1. ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ

src/main/java
  โ”œโ”€โ”€ com.nhnacademy.todo
      โ”œโ”€โ”€ controller       // REST API ์—”๋“œํฌ์ธํŠธ ์ •์˜
      โ”œโ”€โ”€ domain           // JPA ์—”ํ‹ฐํ‹ฐ ํด๋ž˜์Šค
      โ”œโ”€โ”€ dto              // ๋ฐ์ดํ„ฐ ์ „์†ก ๊ฐ์ฒด (DTO)
      โ”œโ”€โ”€ repository       // JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ์ธํ„ฐํŽ˜์ด์Šค
      โ”œโ”€โ”€ service          // ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๊ตฌํ˜„
      โ”œโ”€โ”€ exception        // ์‚ฌ์šฉ์ž ์ •์˜ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ
      โ””โ”€โ”€ config           // ์„ค์ • ํŒŒ์ผ (์˜ˆ: CORS, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋“ฑ)
src/main/resources
  โ”œโ”€โ”€ application.properties // ํ™˜๊ฒฝ ์„ค์ • ํŒŒ์ผ

2. ์—”ํ‹ฐํ‹ฐ

package inho.domain;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.time.LocalDateTime;

@Entity
@NoArgsConstructor
@Data
@Table(name = "todos")
@Slf4j
public class Todo {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "subject", nullable = false)
    private String subject;

    @Column(name = "event_at", nullable = false)
    private String eventAt;

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

    @JsonCreator
    public Todo(
            @JsonProperty("subject") String subject,
            @JsonProperty("eventAt") String eventAt) {
        this.subject = subject;
        this.eventAt = eventAt;
        log.debug("eventAt ํ…Œ์ŠคํŠธ {}", eventAt);
        this.createdAt = LocalDateTime.now(); // createdAt์„ ํ•ญ์ƒ ์„ค์ •
    }

    @PrePersist
    protected void onCreate() {
        if (this.eventAt == null || this.eventAt.isEmpty()) {
            throw new IllegalArgumentException("eventAt ํ•„๋“œ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค");
        }
        if (this.createdAt == null) {
            this.createdAt = LocalDateTime.now();
        }
    }
}

subject: ํ•  ์ผ ์ œ๋ชฉ

eventAt: ํ•  ์ผ ๋‚ ์งœ

createdAt: ์ƒ์„ฑ ๋‚ ์งœ (์ž๋™ ์„ค์ •)

@PrePersist: ๋ฐ์ดํ„ฐ ์ €์žฅ ์ „ ํ•„์ˆ˜ ๊ฐ’ ๊ฒ€์ฆ

3. ๋ ˆํฌ์ง€ํ† ๋ฆฌ

package inho.repository;

import inho.domain.Todo;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface TodoRepository extends JpaRepository<Todo, Long> {

    List<Todo> findByEventAt(String eventAt);

    long countByEventAt(String eventAt);

    boolean existsById(Long id);

    @Query("SELECT t from Todo t where t.eventAt LIKE :eventMonth%")
    List<Todo> findEventAtStartingWith(String eventMonth);
}
  • ํŠน์ • ๋‚ ์งœ ๋˜๋Š” ์›”๋ณ„ ์กฐํšŒ ๊ธฐ๋Šฅ ์ œ๊ณต
  • @Query๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์›”๋ณ„ ๋ฐ์ดํ„ฐ ์กฐํšŒ

4. ์„œ๋น„์Šค

์„œ๋น„์Šค ๊ตฌํ˜„ (Service Implementation)

package inho.service.impl;

import inho.domain.Todo;
import inho.exception.MaxTodoLimitExceededException;
import inho.exception.TodoNotFoundException;
import inho.repository.TodoRepository;
import inho.service.TodoService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional
public class TodoServiceImpl implements TodoService {
    private final TodoRepository todoRepository;
    private static final int DAILY_MAX_TODO_COUNT = 8;

    /**
     * ์ƒˆ๋กœ์šด TODO๋ฅผ ์ €์žฅ
     */
    public Todo saveTodo(String subject, String eventAt) {
        long count = todoRepository.countByEventAt(eventAt);
        if (count >= DAILY_MAX_TODO_COUNT) {
            throw new MaxTodoLimitExceededException("ํ•˜๋ฃจ ์ตœ๋Œ€ 8๊ฐœ์˜ TODO๋งŒ ๋“ฑ๋ก ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.");
        }
        Todo todo = new Todo(subject, eventAt);
        return todoRepository.save(todo);
    }

    /**
     * ํŠน์ • ๋‚ ์งœ์˜ ๋ชจ๋“  TODO ์‚ญ์ œ
     */
    public void deleteTodosByDate(String eventAt) {
        List<Todo> todos = todoRepository.findByEventAt(eventAt);
        if (todos.isEmpty()) {
            throw new TodoNotFoundException("ํ•ด๋‹น ๋‚ ์งœ(" + eventAt + ")์— ๋“ฑ๋ก๋œ TODO๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.");
        }
        todoRepository.deleteAll(todos);
    }

    /**
     * ํŠน์ • ID์˜ TODO ์‚ญ์ œ
     */
    public void deleteTodoById(Long id) {
        if (!todoRepository.existsById(id)) {
            throw new TodoNotFoundException("ID " + id + "์— ํ•ด๋‹นํ•˜๋Š” TODO๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.");
        }
        todoRepository.deleteById(id);
    }

    /**
     * ํŠน์ • ๋‚ ์งœ์˜ TODO ๋ฆฌ์ŠคํŠธ ์กฐํšŒ
     */
    public List<Todo> getTodosByDate(String eventAt) {
       return findTodos(eventAt, false);
    }

    /**
     * ํŠน์ • ์›”์˜ TODO ๋ฆฌ์ŠคํŠธ ์กฐํšŒ
     */
    @Transactional(readOnly = true)
    public List<Todo> getTodosByMonth(String eventMonth) {
        return findTodos(eventMonth, true);
    }

    /**
     * ํŠน์ • ๋‚ ์งœ์˜ TODO ๊ฐœ์ˆ˜ ๋ฐ˜ํ™˜
     */
    public long countTodosByDate(String date) {
        return todoRepository.countByEventAt(date);
    }

    /**
     * ํŠน์ • ID์˜ TODO ์กฐํšŒ
     */
    public Todo getTodoById(Long id) {
        return todoRepository.findById(id)
                .orElseThrow(() -> new TodoNotFoundException("ID " + id + "์— ํ•ด๋‹นํ•˜๋Š” TODO๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."));
    }

    /**
     * ํŠน์ • ๋‚ ์งœ ๋˜๋Š” ์›”์˜ TODO ๋ฆฌ์ŠคํŠธ ์กฐํšŒ (๊ณตํ†ต ๋กœ์ง)
     */
    private List<Todo> findTodos(String queryParam, boolean isMonthly){
        return isMonthly ? todoRepository.findEventAtStartingWith(queryParam) : todoRepository.findByEventAt(queryParam);
    }
}

์„œ๋น„์Šค ์ธํ„ฐํŽ˜์ด์Šค

package inho.service;

import inho.domain.Todo;
import java.util.List;

public interface TodoService {
    Todo saveTodo(String subject, String eventAt);
    void deleteTodosByDate(String eventAt);
    void deleteTodoById(Long id);
    List<Todo> getTodosByDate(String eventAt);
    List<Todo> getTodosByMonth(String eventMonth);
    long countTodosByDate(String date);
    Todo getTodoById(Long id);
}

๐Ÿ”น ํ•ต์‹ฌ ์ •๋ฆฌ

  • ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ TodoServiceImpl์—์„œ ๊ด€๋ฆฌ
  • ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ํ†ตํ•ด ์„œ๋น„์Šค์˜ ๊ณ„์•ฝ์„ ์ •์˜
  • ํŠธ๋žœ์žญ์…˜์„ ๊ด€๋ฆฌํ•˜์—ฌ ๋ฐ์ดํ„ฐ ์ผ๊ด€์„ฑ ์œ ์ง€

5. ์ปจํŠธ๋กค๋Ÿฌ

์ปจํŠธ๋กค๋Ÿฌ ๊ณ„์ธต์€ ํด๋ผ์ด์–ธํŠธ๋กœ๋ถ€ํ„ฐ HTTP ์š”์ฒญ์„ ๋ฐ›์•„ ์„œ๋น„์Šค ๊ณ„์ธต์„ ํ˜ธ์ถœํ•˜๊ณ , ๊ทธ ๊ฒฐ๊ณผ๋ฅผ ์ ์ ˆํ•œ HTTP ์‘๋‹ต์œผ๋กœ ๋ณ€ํ™˜ํ•˜๋Š” ์—ญํ• ์„ ํ•œ๋‹ค.

์ฃผ์š” ๊ธฐ๋Šฅ

  • TODO ์ƒ์„ฑ: @PostMapping("/events")๋ฅผ ํ†ตํ•ด ์ƒˆ๋กœ์šด ํ•  ์ผ์„ ์ƒ์„ฑํ•œ๋‹ค.
  • TODO ์‚ญ์ œ: ํŠน์ • ID ๋˜๋Š” ๋‚ ์งœ์˜ TODO๋ฅผ ์‚ญ์ œํ•œ๋‹ค.
  • TODO ์กฐํšŒ: ํŠน์ • ๋‚ ์งœ ๋˜๋Š” ์›”์˜ TODO ๋ฆฌ์ŠคํŠธ๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค.
  • TODO ๊ฐœ์ˆ˜ ํ™•์ธ: ํŠน์ • ๋‚ ์งœ์˜ TODO ๊ฐœ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
package inho.controller;

import inho.domain.Todo;
import inho.service.TodoService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/calendar")
public class TodoController {

    private final TodoService todoService;

    /**
     * ์ƒˆ๋กœ์šด TODO ์ƒ์„ฑ
     */
    @PostMapping("/events")
    public ResponseEntity<Todo> createTodo(@RequestBody Todo todo) {
        Todo savedTodo = todoService.saveTodo(todo.getSubject(), todo.getEventAt());
        return ResponseEntity.status(201).body(savedTodo);
    }

    /**
     * ํŠน์ • ID์˜ TODO ์‚ญ์ œ
     */
    @DeleteMapping("/events/{id}")
    public ResponseEntity<Void> deleteTodoById(@PathVariable Long id) {
        todoService.deleteTodoById(id);
        return ResponseEntity.noContent().build();
    }

    /**
     * ํŠน์ • ๋‚ ์งœ์˜ ๋ชจ๋“  TODO ์‚ญ์ œ
     */
    @DeleteMapping("/events/daily/{eventAt}")
    public ResponseEntity<Void> deleteTodosByDate(@PathVariable String eventAt) {
        todoService.deleteTodosByDate(eventAt);
        return ResponseEntity.noContent().build();
    }

    /**
     * ํŠน์ • ๋‚ ์งœ์˜ TODO ๋ฆฌ์ŠคํŠธ ์กฐํšŒ
     */
    @GetMapping(value = "/events/", params = {"year", "month", "day"})
    public ResponseEntity<List<Todo>> getTodosByDate(
            @RequestParam int year,
            @RequestParam int month,
            @RequestParam int day
    ){
        String eventAt = String.format("%04d-%02d-%02d", year, month, day);
        List<Todo> todos = todoService.getTodosByDate(eventAt);
        return ResponseEntity.ok(todos);
    }

    /**
     * ํŠน์ • ์›”์˜ TODO ๋ฆฌ์ŠคํŠธ ์กฐํšŒ
     */
    @GetMapping(value = "/events/", params = {"year","month"})
    public ResponseEntity<List<Todo>> getTodosByMonth(
            @RequestParam int year,
            @RequestParam int month
    ){
        String eventMonth = String.format("%04d-%02d", year, month);
        List<Todo> todos = todoService.getTodosByMonth(eventMonth);
        return ResponseEntity.ok(todos);
    }
}

๐Ÿ”น ํ•ต์‹ฌ ์ •๋ฆฌ

  • ์ปจํŠธ๋กค๋Ÿฌ ๊ณ„์ธต์€ ํด๋ผ์ด์–ธํŠธ ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•˜๊ณ  ์‘๋‹ต ๋ฐ˜ํ™˜
  • @RequestParam์„ ํ™œ์šฉํ•˜์—ฌ ๋‚ ์งœ ๋ฐ ์›”๋ณ„ ์กฐํšŒ ์ง€์›

์ „๋ฐ˜์ ์ธ ํ๋ฆ„

1. ๋ฐฑ์—”๋“œ ๊ณ„์ธต ๊ตฌ์กฐ

๐Ÿ”น ์ฃผ์š” ๊ณ„์ธต

  • ์ปจํŠธ๋กค๋Ÿฌ (Controller): ํด๋ผ์ด์–ธํŠธ ์š”์ฒญ์„ ๋ฐ›์•„ ์ฒ˜๋ฆฌํ•˜๊ณ  ์‘๋‹ต์„ ๋ฐ˜ํ™˜
  • ์„œ๋น„์Šค (Service): ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ์ˆ˜ํ–‰
  • ๋ฆฌํฌ์ง€ํ† ๋ฆฌ (Repository): ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์™€ ์ง์ ‘์ ์œผ๋กœ ์ƒํ˜ธ์ž‘์šฉ
  • ๋„๋ฉ”์ธ (Domain/Entity): ๋ฐ์ดํ„ฐ ๋ชจ๋ธ ์ •์˜
  • ์˜ˆ์™ธ ์ฒ˜๋ฆฌ (Exception Handling): ๋ฐœ์ƒํ•˜๋Š” ์˜ˆ์™ธ๋ฅผ ๊ด€๋ฆฌํ•˜๊ณ  ์ ์ ˆํ•œ ์—๋Ÿฌ ์‘๋‹ต์„ ๋ฐ˜ํ™˜

2. ์ฃผ์š” ๊ธฐ๋Šฅ ํ๋ฆ„

๐Ÿ”น TODO ์ƒ์„ฑ

ํด๋ผ์ด์–ธํŠธ๊ฐ€ POST /api/calendar/events ์š”์ฒญ์„ ๋ณด๋ƒ„

TodoController์—์„œ @RequestBody๋กœ ์š”์ฒญ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์Œ

TodoService์—์„œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์ˆ˜ํ–‰ (์ตœ๋Œ€ 8๊ฐœ ์ œํ•œ ์ฒดํฌ ๋“ฑ)

TodoRepository๋ฅผ ํ†ตํ•ด ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ

์ €์žฅ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ํด๋ผ์ด์–ธํŠธ์— ๋ฐ˜ํ™˜ (201 Created ์‘๋‹ต)

๐Ÿ”น TODO ์กฐํšŒ

ํŠน์ • ๋‚ ์งœ ์กฐํšŒ

ํด๋ผ์ด์–ธํŠธ๊ฐ€ GET /api/calendar/events/?year=2024&month=04&day=01 ์š”์ฒญ

TodoController์—์„œ @RequestParam์„ ํ™œ์šฉํ•ด ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋ฐ›์Œ

TodoService์—์„œ findTodos(eventAt, false) ํ˜ธ์ถœ

TodoRepository์—์„œ ํ•ด๋‹น ๋‚ ์งœ์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒ ํ›„ ๋ฐ˜ํ™˜

ํด๋ผ์ด์–ธํŠธ์— 200 OK ์‘๋‹ต๊ณผ ํ•จ๊ป˜ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜

ํŠน์ • ์›” ์กฐํšŒ

ํด๋ผ์ด์–ธํŠธ๊ฐ€ GET /api/calendar/events/?year=2024&month=04 ์š”์ฒญ

TodoService์—์„œ findTodos(eventMonth, true) ํ˜ธ์ถœ

TodoRepository์—์„œ ํ•ด๋‹น ์›”์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒ ํ›„ ๋ฐ˜ํ™˜

ํด๋ผ์ด์–ธํŠธ์— 200 OK ์‘๋‹ต๊ณผ ํ•จ๊ป˜ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜

๐Ÿ”น TODO ์‚ญ์ œ

ํŠน์ • ID ์‚ญ์ œ

ํด๋ผ์ด์–ธํŠธ๊ฐ€ DELETE /api/calendar/events/{id} ์š”์ฒญ

TodoController์—์„œ @PathVariable Long id๋กœ ID ๋ฐ›์Œ

TodoService์—์„œ deleteTodoById(id) ์‹คํ–‰

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ํ•ด๋‹น ID ์‚ญ์ œ ํ›„ 204 No Content ๋ฐ˜ํ™˜

ํŠน์ • ๋‚ ์งœ์˜ ๋ชจ๋“  TODO ์‚ญ์ œ

ํด๋ผ์ด์–ธํŠธ๊ฐ€ DELETE /api/calendar/events/daily/{eventAt} ์š”์ฒญ

TodoService์—์„œ deleteTodosByDate(eventAt) ์‹คํ–‰

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ํ•ด๋‹น ๋‚ ์งœ์˜ TODO ์‚ญ์ œ ํ›„ 204 No Content ๋ฐ˜ํ™˜

3. ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ํ๋ฆ„

๐Ÿ”น ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ ์ฒ˜๋ฆฌ ๊ณผ์ •

MaxTodoLimitExceededException: ํ•˜๋ฃจ ์ตœ๋Œ€ 8๊ฐœ ์ œํ•œ ์ดˆ๊ณผ ์‹œ ๋ฐœ์ƒ

TodoNotFoundException: ์‚ญ์ œ ๋˜๋Š” ์กฐํšŒ ์‹œ ํ•ด๋‹น ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์„ ๊ฒฝ์šฐ ๋ฐœ์ƒ

์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ @ControllerAdvice๋ฅผ ํ†ตํ•ด ๊ธ€๋กœ๋ฒŒ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ

์ ์ ˆํ•œ HTTP ์ƒํƒœ ์ฝ”๋“œ์™€ ๋ฉ”์‹œ์ง€๋ฅผ ํฌํ•จํ•œ JSON ์‘๋‹ต ๋ฐ˜ํ™˜

4. ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๋™

๐Ÿ”น TodoRepository

findByEventAt(String eventAt): ํŠน์ • ๋‚ ์งœ์˜ TODO ์กฐํšŒ

findEventAtStartingWith(String eventMonth): ํŠน์ • ์›”์˜ TODO ์กฐํšŒ

countByEventAt(String eventAt): ํŠน์ • ๋‚ ์งœ์˜ TODO ๊ฐœ์ˆ˜ ํ™•์ธ

existsById(Long id): ํŠน์ • ID ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ

deleteById(Long id): ํŠน์ • ID์˜ TODO ์‚ญ์ œ

5. ์ „์ฒด์ ์ธ ์š”์ฒญ & ์‘๋‹ต ํ๋ฆ„

ํด๋ผ์ด์–ธํŠธ โ†’ ์ปจํŠธ๋กค๋Ÿฌ โ†’ ์„œ๋น„์Šค โ†’ ๋ฆฌํฌ์ง€ํ† ๋ฆฌ โ†’ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค
           โ† ์‘๋‹ต ๋ฐ˜ํ™˜ โ†

Spring Boot์˜ ํ•ต์‹ฌ ๊ณ„์ธต์„ ํ™œ์šฉํ•˜์—ฌ ๋ชจ๋“ˆํ™”๋œ ๊ตฌ์กฐ๋กœ ๊ฐœ๋ฐœ๋˜์—ˆ์œผ๋ฉฐ, ํด๋ฆฐ ์•„ํ‚คํ…์ฒ˜๋ฅผ ์œ ์ง€ํ•˜๋ฉด์„œ ์œ ์ง€๋ณด์ˆ˜์„ฑ์ด ๋†’์€ ๊ตฌ์กฐ๋ฅผ ๊ฐ€์ง„๋‹ค!

๋ฐ์ดํ„ฐ ๋„ฃ๊ธฐ

Front

DB

๋ฐ์ดํ„ฐ ์‚ญ์ œ

Front

DB

๋‚ ์งœ๋ณ„ ์‚ญ์ œ

0๊ฐœ์˜ ๋Œ“๊ธ€