Java 26일 코스 - Day 25: REST API 구현

Day 25: REST API 구현

REST(Representational State Transfer) API는 웹에서 데이터를 주고받는 표준 방식입니다. HTTP 메서드(GET, POST, PUT, DELETE)로 자원(Resource)을 조작합니다. 오늘은 Spring Boot로 계층화된 실전 REST API를 구축합니다.

프로젝트 계층 구조

Controller - Service - Repository 3계층 아키텍처로 설계합니다.

// 프로젝트 구조:
// src/main/java/com/example/todoapi/
// ├── TodoApiApplication.java       (메인)
// ├── controller/
// │   └── TodoController.java       (HTTP 요청 처리)
// ├── service/
// │   └── TodoService.java          (비즈니스 로직)
// ├── repository/
// │   └── TodoRepository.java       (데이터 접근)
// ├── dto/
// │   ├── TodoRequest.java          (요청 DTO)
// │   └── TodoResponse.java         (응답 DTO)
// ├── domain/
// │   └── Todo.java                 (도메인 엔티티)
// └── exception/
//     └── GlobalExceptionHandler.java (전역 예외 처리)

// 도메인 엔티티
public class Todo {
    private Long id;
    private String title;
    private String description;
    private boolean completed;
    private String priority; // HIGH, MEDIUM, LOW
    private java.time.LocalDateTime createdAt;
    private java.time.LocalDateTime updatedAt;

    // 생성자, getter, setter 등
    public Todo(Long id, String title, String description, String priority) {
        this.id = id;
        this.title = title;
        this.description = description;
        this.priority = priority;
        this.completed = false;
        this.createdAt = java.time.LocalDateTime.now();
        this.updatedAt = this.createdAt;
    }

    // getter/setter 생략 (실제로는 필요)
}

DTO와 요청/응답 분리

클라이언트와 주고받는 데이터 형태를 정의합니다.

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;

// 요청 DTO (클라이언트 -> 서버)
record TodoRequest(
    @NotBlank(message = "제목은 필수입니다")
    @Size(max = 100, message = "제목은 100자 이내여야 합니다")
    String title,

    @Size(max = 500, message = "설명은 500자 이내여야 합니다")
    String description,

    String priority  // HIGH, MEDIUM, LOW
) {
    // 기본값 설정
    public TodoRequest {
        if (priority == null || priority.isBlank()) {
            priority = "MEDIUM";
        }
    }
}

// 응답 DTO (서버 -> 클라이언트)
record TodoResponse(
    Long id,
    String title,
    String description,
    boolean completed,
    String priority,
    LocalDateTime createdAt,
    LocalDateTime updatedAt
) {
    // 엔티티 -> 응답 DTO 변환
    static TodoResponse from(Todo todo) {
        return new TodoResponse(
            todo.getId(), todo.getTitle(), todo.getDescription(),
            todo.isCompleted(), todo.getPriority(),
            todo.getCreatedAt(), todo.getUpdatedAt()
        );
    }
}

// 에러 응답 DTO
record ErrorResponse(
    int status,
    String message,
    String detail,
    LocalDateTime timestamp
) {
    static ErrorResponse of(int status, String message, String detail) {
        return new ErrorResponse(status, message, detail, LocalDateTime.now());
    }
}

// 페이지네이션 응답
record PageResponse<T>(
    java.util.List<T> content,
    int page,
    int size,
    long totalElements,
    int totalPages
) {}

Service와 Repository 계층

비즈니스 로직과 데이터 접근을 분리합니다.

import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

// Repository: 데이터 저장소 (인메모리)
@Repository
class TodoRepository {
    private final Map<Long, Todo> store = new ConcurrentHashMap<>();
    private final AtomicLong idGenerator = new AtomicLong(1);

    Todo save(Todo todo) {
        if (todo.getId() == null) {
            todo.setId(idGenerator.getAndIncrement());
        }
        store.put(todo.getId(), todo);
        return todo;
    }

    Optional<Todo> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    List<Todo> findAll() {
        return new ArrayList<>(store.values());
    }

    void deleteById(Long id) {
        store.remove(id);
    }

    boolean existsById(Long id) {
        return store.containsKey(id);
    }
}

// Service: 비즈니스 로직
@Service
class TodoService {
    private final TodoRepository repository;

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

    TodoResponse create(TodoRequest request) {
        Todo todo = new Todo(null, request.title(),
                             request.description(), request.priority());
        Todo saved = repository.save(todo);
        return TodoResponse.from(saved);
    }

    TodoResponse findById(Long id) {
        Todo todo = repository.findById(id)
            .orElseThrow(() -> new TodoNotFoundException("할일을 찾을 수 없습니다: " + id));
        return TodoResponse.from(todo);
    }

    List<TodoResponse> findAll(String priority, Boolean completed) {
        return repository.findAll().stream()
            .filter(t -> priority == null || t.getPriority().equals(priority))
            .filter(t -> completed == null || t.isCompleted() == completed)
            .map(TodoResponse::from)
            .toList();
    }

    TodoResponse update(Long id, TodoRequest request) {
        Todo todo = repository.findById(id)
            .orElseThrow(() -> new TodoNotFoundException("할일을 찾을 수 없습니다: " + id));
        todo.setTitle(request.title());
        todo.setDescription(request.description());
        todo.setPriority(request.priority());
        todo.setUpdatedAt(java.time.LocalDateTime.now());
        repository.save(todo);
        return TodoResponse.from(todo);
    }

    TodoResponse toggleComplete(Long id) {
        Todo todo = repository.findById(id)
            .orElseThrow(() -> new TodoNotFoundException("할일을 찾을 수 없습니다: " + id));
        todo.setCompleted(!todo.isCompleted());
        todo.setUpdatedAt(java.time.LocalDateTime.now());
        repository.save(todo);
        return TodoResponse.from(todo);
    }

    void delete(Long id) {
        if (!repository.existsById(id)) {
            throw new TodoNotFoundException("할일을 찾을 수 없습니다: " + id);
        }
        repository.deleteById(id);
    }
}

// 사용자 정의 예외
class TodoNotFoundException extends RuntimeException {
    TodoNotFoundException(String message) { super(message); }
}

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.util.List;

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

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

    // POST /api/todos
    @PostMapping
    ResponseEntity<TodoResponse> create(@Valid @RequestBody TodoRequest request) {
        TodoResponse response = todoService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    // GET /api/todos
    @GetMapping
    List<TodoResponse> findAll(
            @RequestParam(required = false) String priority,
            @RequestParam(required = false) Boolean completed) {
        return todoService.findAll(priority, completed);
    }

    // GET /api/todos/{id}
    @GetMapping("/{id}")
    TodoResponse findById(@PathVariable Long id) {
        return todoService.findById(id);
    }

    // PUT /api/todos/{id}
    @PutMapping("/{id}")
    TodoResponse update(@PathVariable Long id,
                        @Valid @RequestBody TodoRequest request) {
        return todoService.update(id, request);
    }

    // PATCH /api/todos/{id}/toggle
    @PatchMapping("/{id}/toggle")
    TodoResponse toggleComplete(@PathVariable Long id) {
        return todoService.toggleComplete(id);
    }

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

// 전역 예외 처리
@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(TodoNotFoundException.class)
    ResponseEntity<ErrorResponse> handleNotFound(TodoNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse.of(404, "Not Found", e.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        String detail = e.getBindingResult().getFieldErrors().stream()
            .map(err -> err.getField() + ": " + err.getDefaultMessage())
            .reduce((a, b) -> a + "; " + b)
            .orElse("유효성 검증 실패");
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(ErrorResponse.of(400, "Validation Error", detail));
    }

    @ExceptionHandler(Exception.class)
    ResponseEntity<ErrorResponse> handleGeneral(Exception e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of(500, "Internal Server Error", e.getMessage()));
    }
}

오늘의 연습문제

  1. 검색과 페이지네이션: 할일 목록에 제목 검색(?keyword=xxx)과 페이지네이션(?page=1&size=10) 기능을 추가하세요. PageResponse DTO를 활용하세요.

  2. API 문서화: 각 엔드포인트의 요청/응답 예시를 포함하는 API 명세를 작성하세요. curl 명령어로 각 API를 테스트하는 스크립트도 만드세요.

  3. 통합 테스트: @SpringBootTestMockMvc를 사용하여 TodoController의 모든 엔드포인트를 테스트하세요. 정상 케이스와 에러 케이스(404, 400)를 모두 포함하세요.

이 글이 도움이 되었나요?