Spring Boot 실무 가이드 · Part 5

REST API 구현

요청을 받아 응답을 만들기까지 컨트롤러가 맡아야 할 범위 정하기

작성 기준2026년 7월버전과 지원 현황은 이후 달라질 수 있으니 공식 문서를 함께 확인하세요.

이 파트에서 다루는 내용

요청 매핑과 바인딩DTO 분리요청 값 검증상태 코드 선택
01

@RestController는 반환값을 그대로 응답 본문으로 씁니다

@Controller는 반환한 문자열을 화면 이름으로 해석합니다. @RestController는 여기에 @ResponseBody가 합쳐진 것이라, 반환한 객체를 JSON으로 변환해 응답 본문에 담습니다.

요청을 어떤 메서드가 처리할지는 경로와 HTTP 메서드로 결정합니다. 값을 어디서 꺼내는지에 따라 사용하는 애노테이션이 달라집니다.

@PathVariable
경로에서 꺼내기

/orders/{orderId}처럼 경로에 포함된 식별자를 받습니다. 특정 자원 하나를 지정할 때 씁니다.

@RequestParam
쿼리에서 꺼내기

/orders?status=PAID&page=0처럼 조건과 옵션을 받습니다. 목록 조회의 필터와 페이징에 씁니다.

@RequestBody
본문에서 꺼내기

POST·PUT 요청의 JSON 본문을 객체로 변환합니다. 생성과 수정에 씁니다.

@RequestHeader
헤더에서 꺼내기

인증 토큰이나 추적 아이디처럼 헤더로 오는 값을 받습니다. 다만 인증은 보통 Security 계층에서 처리합니다.

기본 컨트롤러 구조java
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @GetMapping("/{orderId}")
    public OrderResponse getOrder(@PathVariable Long orderId) {
        return orderService.findOne(orderId);
    }

    @GetMapping
    public List<OrderResponse> getOrders(@RequestParam(defaultValue = "ALL") String status) {
        return orderService.findAll(status);
    }

    @PostMapping
    public ResponseEntity<OrderResponse> create(@RequestBody OrderCreateRequest request) {
        OrderResponse created = orderService.create(request);
        return ResponseEntity
                .created(URI.create("/api/orders/" + created.id()))
                .body(created);
    }
}

클래스에 @RequestMapping으로 공통 경로를 두고 메서드에서 나머지를 적습니다. 생성 요청은 201과 Location 헤더를 함께 돌려주는 것이 규약입니다.

02

엔티티를 그대로 주고받지 않습니다

가장 흔한 실수가 JPA 엔티티를 요청·응답 타입으로 그대로 쓰는 것입니다. 코드가 짧아 보이지만 대가가 큽니다.

DTO(Data Transfer Object)를 따로 두면 외부에 노출할 필드와 받을 필드를 명시적으로 통제할 수 있습니다. Java 17 이상에서는 record로 간단히 만들 수 있습니다.

내부 구조 노출
문제

DB 컬럼 이름과 테이블 구조가 그대로 외부에 드러납니다. 스키마를 바꾸면 API 명세가 함께 깨집니다.

의도치 않은 수정
문제

요청 본문으로 엔티티를 받으면 클라이언트가 보낸 필드가 그대로 반영될 수 있습니다. 상태나 권한 필드가 조작될 여지가 생깁니다.

지연 로딩 오류
문제

연관 엔티티를 직렬화하려다 세션이 닫혀 예외가 나거나, 양방향 연관에서 무한 순환이 발생합니다.

DTO 분리
해결

요청용과 응답용을 나눠 정의합니다. 필요한 필드만 노출하고, 화면 요구가 바뀌어도 엔티티를 건드리지 않습니다.

record로 만드는 요청·응답 DTOjava
// 요청: 클라이언트가 보낼 수 있는 값만 정의합니다
public record OrderCreateRequest(
        @NotNull Long memberId,
        @NotEmpty List<OrderLineRequest> lines,
        @Size(max = 200) String memo
) {
}

// 응답: 외부에 보여줄 값만 정의합니다
public record OrderResponse(
        Long id,
        String status,
        int totalAmount,
        LocalDateTime orderedAt
) {
    public static OrderResponse from(Order order) {
        return new OrderResponse(
                order.getId(),
                order.getStatus().name(),
                order.getTotalAmount(),
                order.getOrderedAt()
        );
    }
}

엔티티에서 DTO로 바꾸는 변환 메서드를 DTO 쪽에 두면 엔티티가 표현 계층을 몰라도 됩니다.

03

검증은 컨트롤러 경계에서 끝냅니다

잘못된 값은 서비스 로직까지 들어가기 전에 걸러야 합니다. Bean Validation 애노테이션을 DTO에 붙이고 컨트롤러 파라미터에 @Valid를 달면 자동으로 검사합니다.

검증에 실패하면 MethodArgumentNotValidException이 발생하고 기본적으로 400 응답이 나갑니다. 이 예외를 어떻게 응답으로 바꿀지는 Part 6에서 다룹니다.

자주 쓰는 애노테이션
jakarta.validation
  • @NotNull: null 금지 (빈 문자열은 통과)
  • @NotBlank: 문자열 필수, 공백만 있는 값도 거부
  • @NotEmpty: 컬렉션·문자열이 비어 있으면 거부
  • @Size(min, max): 길이나 크기 범위
  • @Positive, @Min, @Max: 숫자 범위
  • @Email, @Pattern: 형식 검사
주의할 점
실무
  • @Valid를 빠뜨리면 애노테이션을 붙여도 검증되지 않습니다
  • 중첩 객체는 필드에도 @Valid를 붙여야 안쪽까지 검사합니다
  • 형식 검증과 업무 규칙 검증은 구분합니다. '재고가 있는가'는 서비스의 몫입니다
  • 검증 메시지를 그대로 노출하면 내부 필드명이 새어 나갈 수 있습니다
검증 적용java
@PostMapping
public ResponseEntity<OrderResponse> create(
        @Valid @RequestBody OrderCreateRequest request) {
    // 여기 도달했다면 형식 검증은 이미 통과한 상태입니다
    return ResponseEntity.ok(orderService.create(request));
}

// 쿼리 파라미터를 검증하려면 클래스에 @Validated가 필요합니다
@Validated
@RestController
public class OrderController {

    @GetMapping
    public List<OrderResponse> getOrders(
            @RequestParam @Positive int page) {
        return orderService.findAll(page);
    }
}
04

상태 코드는 취향이 아니라 규약입니다

모든 응답을 200으로 내보내고 본문에 성공 여부를 담는 방식은 클라이언트와 중간 장비가 결과를 판단할 수 없게 만듭니다. HTTP가 이미 정해 둔 의미를 그대로 쓰는 편이 낫습니다.

200 OK
조회·수정 성공

요청을 정상 처리했고 응답 본문이 있습니다. 가장 일반적인 성공 응답입니다.

201 Created
생성 성공

새 자원을 만들었습니다. Location 헤더에 생성된 자원의 경로를 담습니다.

204 No Content
성공·본문 없음

삭제처럼 돌려줄 내용이 없는 경우입니다. 본문을 비워서 보냅니다.

400 Bad Request
요청 자체가 잘못됨

필수값 누락, 형식 오류처럼 클라이언트가 고쳐야 하는 문제입니다.

401 / 403
인증·인가

401은 '누구인지 모름', 403은 '알지만 권한 없음'입니다. 둘을 바꿔 쓰면 클라이언트가 재로그인 판단을 못 합니다.

404 / 409
자원 상태

404는 대상이 없음, 409는 중복 등록이나 상태 충돌처럼 현재 상태와 맞지 않는 요청입니다.

500 Internal Server Error
서버 결함

서버가 처리하지 못한 예외입니다. 클라이언트가 고칠 수 없으므로 남발하면 안 됩니다.

판단 기준

클라이언트가 요청을 고치면 해결되는가로 나누면 쉽습니다. 고쳐서 되면 4xx, 고쳐도 안 되면 5xx입니다. 재고 부족처럼 업무상 거절은 400이나 409가 적절하고 500이 아닙니다.

05

설계는 API·Swagger 코스와 이어집니다

이 파트는 이미 정해진 API를 구현하는 방법입니다. 경로 이름, 자원 표현, 버전 정책 같은 설계 기준은 별도 코스에서 다룹니다.

실무 순서는 설계가 먼저입니다. 컨트롤러부터 만들면 나중에 경로와 응답 형식을 뒤집게 되고, 이미 연동한 클라이언트가 함께 깨집니다.

  • 자원 중심 경로와 HTTP 메서드 규약은 API·Swagger 코스 Part 2에서 다룹니다.
  • OpenAPI 문서화는 구현과 함께 갱신해야 계약이 유지됩니다.
  • 응답 형식을 바꿀 때는 클라이언트 영향 범위를 먼저 확인합니다.
버전

3.x와 4.x 차이

본문은 현장에서 가장 많이 쓰는 3.x 기준입니다. 4.x에서 달라진 부분만 아래에 정리합니다.

  • 3.x는 Jackson 2를 사용합니다. 직렬화 설정은 spring.jackson 하위 속성으로 지정합니다.
  • 4.0부터 Jackson 3으로 전환됐습니다. 일부 설정 속성 이름과 기본 동작이 달라져, 업그레이드 시 날짜 형식과 null 처리 결과를 반드시 확인해야 합니다.
  • 4.0은 API 버저닝을 프레임워크 차원에서 지원합니다. 그동안 경로나 헤더로 직접 구현하던 버전 분기를 매핑 단계에서 처리할 수 있습니다.
  • 4.0의 HTTP 서비스 클라이언트를 쓰면 인터페이스 선언만으로 외부 API 호출 코드를 만들 수 있습니다. 3.x에서 RestClient나 WebClient로 직접 작성하던 부분이 줄어듭니다.
체크

이 파트 완료 기준