500줄로 만드는 REST API — Spring Boot 실전 패턴

REST API를 만든다고 하자. 컨트롤러, DTO, 서비스, 예외 처리, 응답 표준화 — 각각을 따로 배우면 연결이 안 된다. 이 글은 하나의 흐름으로 처음부터 끝까지 REST API를 구축하는 패턴을 풀어간다: 요청 수신 → DTO 바인딩 → 서비스 호출 → 표준 응답 반환 → 예외 처리.

(Spring Boot Reference - Spring MVC)

DTO 계층 — 요청과 응답의 분리

// Spring Boot 4 / Java 25 — DTO (record)
import jakarta.validation.*;

// 요청 DTO (입력 검증 포함)
public record CreateUserRequest(
    @NotBlank String name,
    @Email @NotBlank String email,
    @Min(0) @Max(150) Integer age
) {}

// 응답 DTO (민감 정보 제외)
public record UserResponse(
    Long id,
    String name,
    String email,
    Integer age,
    java.time.LocalDateTime createdAt
) {}

// 수정 요청 DTO
public record UpdateUserRequest(
    String name,
    @Email String email   // null 허용 (부분 수정)
) {}

DTO를 record로 만들면 불변 + 간결 + 자동 equals/hashCode/toString. 요청 DTO와 응답 DTO를 분리하여 — 응답에 비밀번호, 내부 ID 등 민감 정보가 노출되지 않게 한다.

왜 DTO를 분리하는가? 엔티티(User)를 그대로 API 응답으로 반환하면 세 가지 문제가 발생한다: (1) 비밀번호 같은 민감 필드가 노출된다, (2) 연관 관계(@OneToMany orders)가 무한 직렬화(순환 참조)를 일킬 수 있다, (3) DB 스키마 변경이 곧 API 스펙 변경이 되어 버전 관리가 불가능해진다. DTO는 API 계약(contract)과 DB 구조를 분리하는 방어벽이다.

비유: 은행에서 계좌 개설을 할 때 "이름, 이메일, 나이"만 적는다(요청 DTO). 하지만 계좌가 만들어지면 "잔액, 계좌번호, 비밀번호" 등은 응답에 포함되지 않는다(응답 DTO). 은행 내부 데이터베이스(엔티티)에는 더 많은 정보가 있지만, 고객에게 보여주는 것은 필요한 것만.

컨트롤러 — REST API의 진입점

// Spring Boot 4 / Java 25 — REST 컨트롤러
import org.springframework.web.bind.annotation.*;
import org.springframework.http.*;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService service;

    public UserController(UserService service) { this.service = service; }

    // 목록 조회 (페이지네이션)
    @GetMapping
    public List<UserResponse> list(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        return service.findAll(page, size);
    }

    // 단건 조회
    @GetMapping("/{id}")
    public UserResponse get(@PathVariable Long id) {
        return service.findById(id);
    }

    // 생성
    @PostMapping
    public ResponseEntity<UserResponse> create(@Valid @RequestBody CreateUserRequest request) {
        UserResponse created = service.create(request);
        return ResponseEntity
            .created(URI.create("/api/users/" + created.id()))
            .body(created);
    }

    // 전체 수정
    @PutMapping("/{id}")
    public UserResponse update(@PathVariable Long id,
                                @Valid @RequestBody UpdateUserRequest request) {
        return service.update(id, request);
    }

    // 삭제
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        service.delete(id);
    }
}

서비스 계층 — 비즈니스 로직

// Spring Boot 4 / Java 25 — 서비스
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
public class UserService {

    private final UserRepository repo;

    public UserService(UserRepository repo) { this.repo = repo; }

    public List<UserResponse> findAll(int page, int size) {
        return repo.findAll(PageRequest.of(page, size))
            .map(this::toResponse)
            .toList();
    }

    public UserResponse findById(Long id) {
        return repo.findById(id)
            .map(this::toResponse)
            .orElseThrow(() -> new NoSuchElementException("사용자 없음: " + id));
    }

    @Transactional
    public UserResponse create(CreateUserRequest request) {
        if (repo.existsByEmail(request.email())) {
            throw new IllegalStateException("이미 존재하는 이메일: " + request.email());
        }
        User user = new User(request.name(), request.email(), request.age());
        User saved = repo.save(user);
        return toResponse(saved);
    }

    @Transactional
    public UserResponse update(Long id, UpdateUserRequest request) {
        User user = repo.findById(id)
            .orElseThrow(() -> new NoSuchElementException("사용자 없음: " + id));
        if (request.name() != null) user.updateName(request.name());
        if (request.email() != null) user.updateEmail(request.email());
        return toResponse(user);   // @Transactional이 dirty check로 자동 저장
    }

    @Transactional
    public void delete(Long id) {
        if (!repo.existsById(id)) {
            throw new NoSuchElementException("사용자 없음: " + id);
        }
        repo.deleteById(id);
    }

    private UserResponse toResponse(User user) {
        return new UserResponse(user.getId(), user.getName(),
            user.getEmail(), user.getAge(), user.getCreatedAt());
    }
}

전역 예외 처리 — 일관된 에러 응답

// Spring Boot 4 / Java 25 — 전역 예외 처리
import org.springframework.http.*;
import org.springframework.web.*;
import org.springframework.web.bind.*;

@RestControllerAdvice
public class GlobalExceptionHandler {

    public record ErrorResponse(String code, String message, List<String> details) {}

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .toList();
        return ResponseEntity.badRequest()
            .body(new ErrorResponse("VALIDATION_FAILED", "입력값 검증 실패", errors));
    }

    @ExceptionHandler(NoSuchElementException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(NoSuchElementException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("NOT_FOUND", ex.getMessage(), null));
    }

    @ExceptionHandler(IllegalStateException.class)
    public ResponseEntity<ErrorResponse> handleConflict(IllegalStateException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(new ErrorResponse("CONFLICT", ex.getMessage(), null));
    }
}

실제 API 응답 예

# 생성
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"alice@test.com","age":30}'
{
  "id": 1,
  "name": "Alice",
  "email": "alice@test.com",
  "age": 30,
  "createdAt": "2026-07-10T14:30:00"
}
# 검증 실패
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"","email":"bad","age":-5}'
{
  "code": "VALIDATION_FAILED",
  "message": "입력값 검증 실패",
  "details": [
    "name: 공백일 수 없습니다",
    "email: 올바른 형식의 이메일 주소여야 합니다",
    "age: 0 이상이어야 합니다"
  ]
}

RestClient로 외부 API 호출

// Spring Boot 4 / Java 25 — RestClient (Spring 6.1+/7)
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

@Service
public class PaymentClient {
    private final RestClient client;

    public PaymentClient(RestClient.Builder builder) {
        this.client = builder.baseUrl("https://api.payment.com").build();
    }

    public PaymentResult charge(String orderId, long amount) {
        return client.post()
            .uri("/charge")
            .header("Authorization", "Bearer sk_test_12345")
            .body(new ChargeRequest(orderId, amount))
            .retrieve()
            .onStatus(status -> status.is4xxClientError(),
                      (req, res) -> { throw new PaymentFailedException("결제 실패"); })
            .body(PaymentResult.class);
    }

    public record ChargeRequest(String orderId, long amount) {}
    public record PaymentResult(String transactionId, String status) {}
}

RestClient(Spring 6.1+ 도입, Spring 7 강화)는 RestTemplate의 현대적 대안이다. 빌더 패턴, fluent API, 타입 안전 바인딩, 선언적 에러 처리를 제공한다. (Spring Boot Reference - HTTP Clients)

API 설계 원칙 — RESTful 패턴

원칙 비고
URL은 명사 /api/users, /api/orders/{id} 동사 금지 (/api/getUsers X)
HTTP method로 동작 표현 GET=조회, POST=생성, PUT=수정, DELETE=삭제
상태 코드 의미 사용 201 Created, 204 No Content, 400 Bad Request, 404 Not Found
표준 응답 구조 { "code", "message", "data" } 일관성
페이지네이션 표준화 ?page=0&size=20&sort=name,asc Spring Data Page

Spring Boot 4.0 — 구조화된 로깅

Spring Boot 4.0은 구조화된 로깅(structured logging)을 지원한다:

# application.yml
logging:
  structured:
    format:
      console: ecs   # Elastic Common Schema JSON 형식
// 구조화된 로그 출력 예
{
  "@timestamp": "2026-07-10T14:30:00.123Z",
  "log.level": "INFO",
  "service.name": "my-service",
  "message": "사용자 생성: alice@test.com",
  "process.thread.name": "http-nio-8080-exec-1"
}

구조화된 로깅은 ECS(Elastic Common Schema), GELF(Graylog) 형식을 지원한다. JSON 기반으로 로그 검색/필터링이 용이하여 ELK, Datadog 등 로그 분석 시스템과 결합이 쉽다. (Spring Boot 4.0 - Structured Logging)

요약 — 이 글의 결론

  • DTO를 record로 정의하여 요청/응답을 분리한다. 요청 DTO에는 @Valid 검증, 응답 DTO에는 민감 정보 제외.
  • 컨트롤러는 HTTP 매핑만, 서비스는 비즈니스 로직만. 컨트롤러에 비즈니스 로직을 넣지 않는다.
  • @RestControllerAdvice로 전역 예외 처리. 일관된 ErrorResponse 구조로 모든 에러 응답을 표준화.
  • RestClient로 외부 API를 호출한다. RestTemplate(레거시) 대신 빌더 패턴 기반의 현대적 클라이언트.
  • Spring Boot 4.0은 구조화된 로깅(ECS, GELF)을 지원한다. JSON 기반 로그로 ELK/Datadog과 원활한 통합.

생각해 볼 문제

  1. @RequestBody@ModelAttribute의 차이는? REST API에서 어느 것을 써야 하는가?
  2. PUT(전체 수정)과 PATCH(부분 수정)를 Spring Boot에서 어떻게 구현하는가?
  3. ResponseEntity.created(URI)가 반환하는 상태 코드는? Location 헤더는 자동 생성되는가?
  4. RestClient.onStatus()로 4xx/5xx 에러를 처리하지 않으면 어떤 일이 일어나는가?
  5. Spring Boot 4.0의 구조화된 로깅에서 커스텀 필드를 추가하려면 어떻게 하는가?

참고

'Develop Artifacts > Spring Boot' 카테고리의 다른 글

SpringBoot - 06. testing  (0) 2026.07.12
SpringBoot - 05. spring-data-jpa  (0) 2026.07.12
SpringBoot - 03. embedded-server  (0) 2026.07.12
SpringBoot - 02. starter-config  (0) 2026.07.12
SpringBoot - 01. auto configuration  (0) 2026.07.12