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과 원활한 통합.
생각해 볼 문제
@RequestBody와@ModelAttribute의 차이는? REST API에서 어느 것을 써야 하는가?- PUT(전체 수정)과 PATCH(부분 수정)를 Spring Boot에서 어떻게 구현하는가?
ResponseEntity.created(URI)가 반환하는 상태 코드는? Location 헤더는 자동 생성되는가?RestClient.onStatus()로 4xx/5xx 에러를 처리하지 않으면 어떤 일이 일어나는가?- Spring Boot 4.0의 구조화된 로깅에서 커스텀 필드를 추가하려면 어떻게 하는가?
참고
- Spring Boot 4.0 Reference - Spring MVC - 접근 2026-07-10
- Spring Boot 4.0 Reference - HTTP Clients - 접근 2026-07-10
- Spring Boot 4.0 Reference - Structured Logging - 접근 2026-07-10
- Spring Framework 7 - RestClient - 접근 2026-07-10
'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 |
