Developer.

[멋사 백엔드 19기] TIL 46일차 Rest API 2

📂 목차


📚 본문

REST 에 대해 더 깊이 살펴본다.

URI 설계 원칙

  • 명사 기반 설계 (/createUser X , /users O)
  • 복수형 사용 (/users)
  • 필터링 & 정렬 (/users?age=20&sort=name,desc)
  • sub resource (/users/{id}/orders)
  • 관계 자원 표현 (/orders/{id}/items)

HTTP 상태 코드의 세분화

HTTP Semantics

RestControllerAdvice

예외처리는 ControllerAdvice 와 별반 다르지 않다. 반환으로는 ErrorResponse 를 내보내게 된다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 비즈니스 예외 처리
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(
            ResourceNotFoundException e) {

        ErrorResponse error = new ErrorResponse(
            HttpStatus.NOT_FOUND.value(),
            e.getMessage(),
            LocalDateTime.now()
        );

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    // 검증 예외 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException e) {

        List<String> errors = e.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(err -> err.getField() + ": " + err.getDefaultMessage())
            .collect(Collectors.toList());

        ErrorResponse error = new ErrorResponse(
            HttpStatus.BAD_REQUEST.value(),
            "Validation failed",
            errors,
            LocalDateTime.now()
        );
        return ResponseEntity.badRequest().body(error);

    }

    // 일반 예외 처리
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneral(Exception e) {
        ErrorResponse error = new ErrorResponse(
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            "Internal server error",
            LocalDateTime.now()
        );

        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(error);
    }
}

@Data
@AllArgsConstructor
public class ErrorResponse {
    private int status;
    private String message;
    private List<String> errors;
    private LocalDateTime timestamp;

    public ErrorResponse(int status, String message, LocalDateTime timestamp) {
        this(status, message, null, timestamp);
    }
}

// 커스텀 예외
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

Versioning

보통 URI 쪽에 버전을 넣는 방식이 표준적으로 많이 사용된다.

  • URI: /api/v1/users
  • Header: Accept: application.vnd.example.v1+json
  • Parameer: /users?version=1

Content Negotiation

REST API 의 핵심 개념 중 하나이며, 이걸 이해하면 원하는 데이터 형식을 서버에게 요청하고 서버가 가장 적절한 표현으로 응답할 수 있게 된다.

콘텐츠 협상이라고 하는 개념은 클라이언트와 서버가 요청/응답의 데이터 형식을 협의하는 과정이다. 다음 두 가지 헤더가 핵심이다.

  • 요청에서의 Accept: 클라이언트가 받고 싶은 응답 형식을 지정한다.
  • 요청에서의 Content-Type: 클라이언트가 보내는 데이터 형식 지정
  • 응답에서의 Content-Type: 서버가 보내는 실제 데이터 형식 명시
### Request
GET /api/users/1 HTTP/1.1
Accept: application/json
- Content-Type: application/json <- 보내는 body 가 있을때만 사용

### Response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 1,
  "name": "홍길동"
}

Spring MVC 에서의 동작

전체 흐름은 다음과 같다.

요청 → DispatcherServlet
        ↓
     HandlerMapping (적절한 Controller 찾기)
        ↓
   HandlerAdapter(RequestMappingHandlerAdapter) (적절한 실행 어댑터 선택)
        ↓
   HandlerMethod(@Controller or @RestController) 실행
        ↓
   HandlerMethodReturnValueHandler 로 HandlerMethod 반환값 처리 
        ↓
   반환 객체를 HttpMessageConverter 로 직렬화
        ↓
   ContentNegotiationManager 가 Accept 헤더 기반으로 MIME 타입 결정
        ↓
   ResponseEntity / ResponseBody 로 응답 반환
  1. RequestMappingHandlerAdapter 가 클라이언트 매개변수, 반환값, 요청/응답 body 직렬화/역직렬화 분석
    • 이 안엔 여러 객체가 있는데, HandlerMethodArgumentResolver, HandlerMethodReturnValueHandler, HttpMessageConverter 가 있다.
  2. ReturnHandlerHandlerMethodReturnValueHandlerAccept 헤더 읽어들임
  3. 알맞은 HttpMessageConverter 를 선택해 직렬화
    • MappingJackson2HttpMessageConverter -> application/json
    • Jaxb2RootElementHttpMessageConverter -> application/xml
    • StringHttpMessageConverter -> text/plain
    • ByteArrayHttpMessageConverter -> application/octet-stream
  4. 컨트롤러의 리턴 객체를 그 포맷으로 직렬화

이를 지키기 위해 Mapping 에는 produces, consumes 를 붙이는 것을 전 포스트에서 보았다. 코드는 생략한다.

ETag 기반 조건부 요청 (If-Match, If-None-Match)

ETag 는 서버가 리소스의 상태를 나타내기 위해 부여하는 고유한 식별자(토큰)이다. 리소스의 내용이 바뀌면, ETag 의 값도 바뀌게 된다.

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "v1-users-12345"

{
  "id": 1,
  "name": "홍길동"
}

만약 전용 스토리지가 있다면, 브라우저나 클라이언트는 이걸 기억해뒀다가 다음 요청 시에 사용한다.

  • 캐싱 최적화
  • 동시성 제어
  • 네트워크 절약
@GetMapping(path = "/{id}",
            produces = "application/json")
public ResponseEntity<ProductResponseDto> getProduct(@PathVariable Long id) {
    if (id == null)
        return ResponseEntity.notFound().build();

    var product = productService.getProduct(id);

    return ResponseEntity.ok()
                         .header("Content-Type", "application/json")
                         .header("ETag", String.valueOf(product.hashCode()))
                         .body(product);
}

이제 client-side 의 storage 자체에서 이를 쓸 수 있다. 위 헤더를 통해서 이제 CRUD 에서 다음 옵션도 사용가능하다.

If-None-Match

GET 방식에서 캐싱 최적화를 위해 사용된다.
클라이언트가 가지고 있는 리소스의 ETag 값이 서버와 같다면, 다시 데이터를 전송하지 않아도 된다.

GET /api/products/1 HTTP/1.1
If-None-Match: "v1-users-12345"

서버 동작:

  • 현재 리소스의 ETag를 계산
  • If-None-Match 값과 비교
    • 같으면 → 304 Not Modified
    • 다르면 → 200 OK + 새로운 ETag + 새 데이터

이를 통해 네트워크 트래픽 절약과 응답 속도 향상을 얻을 수 있다. 기능상 GET 방식에 더 어울린다. 다음 코드를 보자.

@GetMapping(path = "/{id}",
            produces = "application/json")
public ResponseEntity<ProductResponseDto> getProduct(
        @PathVariable Long id,
        @RequestHeader(name = "If-None-Match", required = false) String eTag) {
    if (id == null)
        return ResponseEntity.notFound().build();

    var product = productService.getProduct(id);

    if (eTag != null && eTag.equals(String.valueOf(product.hashCode())))
        return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();

    return ResponseEntity.ok()
                            .header("Content-Type", "application/json")
                            .header("ETag", String.valueOf(product.hashCode()))
                            .body(product);
}

GET 방식으로 HTTP 요청을 다음과 같이 보내면 304 로 리턴 받고 있음을 볼 수 있다:

assets/img/etag-if-none-match.png

HttpStatus.NOT_MODIFIED 는 302 번이다.

If-Match

이제 PUT, PATCH, DELETE 등에 쓸 수 있는 방식이다. If-Match 는 이 버전이 아직 유효할 때만 수정 허용 -> 동시성 제어

@PutMapping(path = "/{id}",
            consumes = "application/json",
            produces = "application/json")
public ResponseEntity<ProductResponseDto> updateProduct(
        @PathVariable Long id,
        @RequestBody ProductResponseDto productRequestDto,
        @RequestHeader(value = "If-Match", required = false) String eTag) {

    if (id == null ||
        productRequestDto.name().isBlank() ||
        productRequestDto.price() < 0)
        return ResponseEntity.badRequest().build();

    var oldProduct = productService.getProduct(id);
    if (eTag != null && eTag.equals(String.valueOf(oldProduct.hashCode())))
        return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();

    var product = productService.updateProduct(id, productRequestDto.name(), productRequestDto.price());

    return ResponseEntity.ok()
                            .header("Content-Type", "application/json")
                            .body(product);
}

@DeleteMapping(path = "/{id}")
public ResponseEntity<Void> deleteProduct(
        @PathVariable Long id,
        @RequestHeader(value = "If-Match", required = false) String eTag) {
    if (id == null)
        ResponseEntity.notFound().build();

    var product = productService.getProduct(id);
    if (eTag != null && !eTag.equals(String.valueOf(product.hashCode())))
        return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();

    productService.deleteProduct(id);

    return ResponseEntity.noContent().build();
}

HttpStatus.PRECONDITION_FAILED 는 412 번이다.

Idempotency-Key

POST 요청은 다른 요청들과는 다르게 멱등성을 보장하지 않는다. 중복 요청 시 상태가 달라지며, 이는 다음 문제들, 결제, 주문 생성, 회원 가입 등 한 번만 수행되어야 하는 요청에 있어 중복 실행을 방지해야 함을 말한다.

Idempotency-Key 는 헤더에 있고, 서버는 같은 키를 가진 요청이 이미 처리됐는지 확인하며, 이미 처리된 요청이면 중복 처리하지 않고 이전 결과를 반환한다.

요청 예시

POST /api/orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000

{
  "productId": 1,
  "quantity": 2
}

CORS & CSRF

CORS

CORS(Cross-Origin Resource Sharing) 는 브라우저에서 다른 출처(Origin) 으로의 요청을 보낼 때 발생하는 보안 정책을 제어하는 기법이다.

이때 Origin = 프로토콜 + 도메인 + 포트 로 정의되며, 다음과 같이 생각하면 된다:

  • https://localhost:8080

Same-Origin Policy

브라우저의 기본 정책은 다른 출처로부터 스크립트가 데이터를 가져오는 것을 막는다. 이는 악성 스크립트가 있을 수도 있고, 사용자의 데이터를 탈취할 수도 있기에 막는게 좋으며 이때 이 안에는 CORS 라는 기술을 통해 서버가 특정 다른 출처를 허용해주어서 브라우저가 요청을 허용할 수 있도록 할 수 있도록 하며 다른 허용되지 않은 웹 응답이나 요청을 막는 기능이 바로 CORS 이다.

서버 -> 브라우저: Access-Control-Allow-Origin 헤더를 통해 허용

CORS 요청에는 두 가지가 있다.

Simple Request

HTTP 메서드에는 8가지가 있다.

  1. GET: 리소스를 조회. 서버 상태를 변경하지 않음
  2. HEAD: GET 과 동일하지만, 응답 본문은 제외하고 헤더만 반환
  3. POST: 리소스를 생성하거나 서버에 데이터를 전송. 상태 변경 가능
  4. PUT: 리소스를 전체 갱신. 존재하지 않으면 생성 가능
  5. DELETE: 리소스를 삭제
  6. CONNECT: 터널링을 위해 사용. 주로 HTTPS 프록시 연결에 쓰임
  7. OPTIONS: 서버가 지원하는 메서드, CORS 허용 등 옵션 확인 용
  8. TRACE: 요청-응답 경로를 디버깅용으로 그대로 반환

이 중에서 단순 요청은 GET, POST, HEAD 에 해당한다.

단순요청에서는 다음과 같은 Content-Type 제약이 들어간다:

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

또한, 헤더에는 사용자 정의 헤더 들어가면 안된다. 이는 아주 기본적인 요청일 때 브라우저가 자동으로 요청을 보내고, 서버에 허용하면 바로 응답이 들어오게 된다.

요청 응답 흐름

GET /api/users HTTP/1.1
Origin: http://localhost:3000

브라우저가 위와 같이 보냈다고 하자. 그러면 서버는 다음과 같이 응답할 수 있다.

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Content-Type: application/json

여기서 Access-Control-Allow-Origin 헤더가 브라우저가 요청한 origin 과 일치해야 요청이 성공적으로 처리된다. 이렇듯 단순 요청은 Preflight 없이 바로 요청이 진행된다.

Preflight Request

Preflight Request(사전 요청) 에는 PUT, DELETE, PATCH, OPTIONS 가 있고, Content-Type 에는 application/json 등이 들어간다.

이 중 OPTIONS 요청Preflight 요청의 핵심이며, 목적은 브라우저가 실제 요청을 보내기 전에 서버가 허용하는 메서드, 헤더, 출처를 미리 확인하는 용도로 사용하게 된다. 항상 OPTIONS 요청이 먼저 일어나고 그 다음 PUT, DELETE, PATCH 요청-응답이 이루어진다.

Preflight Reqeust 흐름

OPTIONS /api/products/1 HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type

위와 같이 요청을 보낸다면,

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 3600

위와 같이 응답을 할 수 있다(GET 은 기본적으로 허용하기 때문에 당연히 명시적으로 들어가는게 맞음).

  • Access-Control-Allow-Origin: http://localhost:8080
  • Access-Control-Allow-Methods: 허용되는 메서드(GET, POST, PUT, DELETE)
  • Access-Control-Allow-Headers: 허용되는 요청 헤더
  • Access-Control-Max-Age: Preflight 캐싱 시간

이제 드디어 GET, POST, PUT, DELETE 를 쓸 수 있게 된다.

PUT /api/products/1 HTTP/1.1
Origin: http://localhost:3000
Content-Type: application/json

{
  "name": "새 제품"
}
전체 흐름

서버 A(REST API 서버), 유저 가 있다고 하자. 이제 전반적인 흐름을 본다.

단순 GET, POST, HEAD

  1. 브라우저가 요청
    • 단순 요청 제약을 만족하도록 요청을 보낸다:
    • Content-Type: text/plain 또는 위의 제약조건 따르도록, 아예 없어도됨
    • 사용자 정의 헤더 X
  2. 서버 A 가 응답
    • 단순 요청 조건을 만족하므로 Preflilght 없이 바로 서버 요청
    • 브라우저가 Access-Control-Allow-Origin 을 확인할 수 있도록 응답을 보내게 됨
  3. 브라우저가 받음
    • 받을 때 Origin 이 허용되어 있다면 브라우저는 데이터를 정상적으로 처리하게 되고
    • 브라우저 자체에서 허용되지 않는 Origin 이라면 CORS 에러를 발생시키게 된다.

사전 OPTIONS 이 필요한 PUT, DELETE, PATCH

  1. 브라우저가 Preflight(OPTIONS) 전송
    • 브라우저는 실제 요청(PUT, DELETE, PATCH) 를 보내기 전에 서버가 이요청을 허용하는지 미리 확인하기 위해 자도으로 OPTIONS 요청을 보냄
OPTIONS /api/products/1 HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization
  1. 서버 A가 OPTIONS 요청에 대한 응답 전송
    • 이때 이 응답이 유효해야만 브라우저가 실제 요청(2단계) 로 넘어감
    • 하나라도 누락되면 CORS 에러 발생
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 3600
  1. 브라우저가 실제 요청 전송
    • Preflight 응답이 유효하면, 이제 브라우저는 진짜 요청을 보내게 된다.
PUT /api/products/1 HTTP/1.1
Origin: http://localhost:3000
Content-Type: application/json
Authorization: Bearer 123456

{
  "name": "새 제품"
}
  1. 서버 A가 실제 응답 전송
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Content-Type: application/json

{
  "id": 1,
  "name": "새 제품"
}
  1. 브라우저가 응답 처리
    • 응답의 Access-Control-Allow-Origin 헤더를 먼저 읽어서 자신의 Origin 과 일치하는지 확인하고 맞다면 데이터 접근을 한다.

CSRF

CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조)는 사용자가 인증된 세션 상태에 있을 때, 공격자가 사용자를 속여 악의적인 요청을 대신 보내게 하는 공격이다. 요청이 정상적인 사용자의 쿠키를 포함하므로 진짜 요청처럼 보이게 된다. 공경당하게 되는 과정을 보자.

  1. 사용자는 https://bank.com 에 로그인 된 상태(세션 쿠키를 얻은 상태)
    • sessionId = abc123
  2. 공격자가 만든 사이트는 https://evil.com
  3. 그 상태로 공격자 사이트 우연히 접속(evil.com)
  4. evil.com 에 다음 HTML 이 숨어 있다고 가정
    • <img src="https://bank.com/transfer?to=hacker&amount=100000" />
  5. 브라우저는 이미 bank.com 의 쿠키(sessionId=abc123) 을 저장하고 있기 때문에
    • 자동으로 이 쿠키를 포함한 GET 요청을 https://bank.com 으로 보내게 됨
  6. 서버는 정상 로그인된 사용자 요청으로 인식하게 되고, 송금을 수행함

이를 방어하는 방법이 CSRF 토큰이며 가장 일반적인 방법이다.

CSRF 토큰
  • 서버가 폼을 렌더링할 때 난수 토큰을 포함시키고
  • 클라이언트가 요청 시 이 토큰을 함께 전송해야만 유효한 요청으로 인정하게 된다.
  • 공격자는 토큰 값을 모르므로 위조가 불가하다

예시

<form action="/transfer" method="POST">
  <input type="hidden" name="_csrf" value="a1b2c3d4e5f6" />
  <input type="text" name="to" />
  <button>송금</button>
</form>

이는 Spring Security 에서 다루자.

브라우저 쿠키의 SameSite 속성으로 Cross-Site 요청에서 쿠키 자동 전송을 제한하게 할 수 있다.

Set-Cookie: sessionId=abc123; SameSite=Lax; Secure; HttpOnly
Referer / Origin 검증

서버단에서 할 수 있는 보안이며, 서버가 요청의 Origin 혹은 Referer 헤더를 보고 허용된 도메인에서 온 요청만 처리하도록 검증한다.

Set-Cookie: sessionId=abc123; SameSite=Lax; Secure; HttpOnly

Spring HATEOAS 심화

HATEOAS (Hypermedia as the Engine of Application State)는 REST의 마지막 단계(“REST Level 3”)로, API 응답 안에 링크 정보를 포함해서 클라이언트가 “다음 행동”을 API 문서 없이도 유도할 수 있게 해주는 개념이다.

기본적인 구조는 다음과 같다.

Mapping Method

@GetMapping("/{id}")
public EntityModel<User> getUser(@PathVariable Long id) {
    var user = userService.findById(id);

    var userModel = EntityModel.of(user);
    userModel.add(linkTo(methodOn(UserController.class).getUser(id)).withSelfRel());
    userModel.add(linkTo(methodOn(UserController.class).getOrdersByUser(id)).withRel("orders"));

    return userModel;
}

응답

{
  "id": 1,
  "name": "홍길동",
  "_links": {
    "self": { "href": "http://localhost:8080/api/users/1" },
    "orders": { "href": "http://localhost:8080/api/users/1/orders" }
  }
}

주요 클래스

  • EntityModel<T>: 단일 리소스에 링크 추가
  • CollectionModel<T>: 여러 리소스(리스트)에 공통 링크 추가
  • PagedModel<T>: 패이징 처리된 리소스 응답
package com.example.resthateoasexam;

public record Memo(
		Long id,
		String content
) { }

위를 토대로 계속 진행해보자.

HATEOAS Representation Model

@PostMapping(consumes = "text/plain", produces = "application/json")
@Transactional
public ResponseEntity<Memo> createMemo(@RequestBody String content) {
    if (content.isBlank())
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
    long idx = memos.size();
    var memo = memos.put(idx, new Memo(idx, content));

    URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                                                .path("/{id}")
                                                .buildAndExpand("id", idx)
                                                .toUri();

    return ResponseEntity.created(location)
                            .body(memo);
} 

위는 전형적인 create 컨트롤러 메서드이다. 이때 MemoResponseEntity 중간에 EntityModel 을 한 번 더 감싸서 처리해주면 된다. 여기서 URI 를 직접 만들고 넣는 것을 볼 수 있는데 이를 다음과 같이 바꿀 수 있다.

@PostMapping(consumes = "text/plain",
             produces = "application/json")
public ResponseEntity<EntityModel<MemoResponseDTO>> createMemo(
        @RequestBody String Memo) {
    if (memo.trim().isBlank()) return ResponseEntity.badRequest().build();

    MemoResponseDTO response = memoService.createMemo(memo);

    URI location = UriComponentsBuilder.fromUriString("/api/memos/")
                                        .path("/{id}")
                                        .buildAndExpand(response.id())
                                        .toUri();

    return ResponseEntity.created(location)
                         .eTag(getETag(response).toString())
                         .body(wrap(response));
}
// ...
private EntityModel<MemoResponseDTO> wrap(MemoResponseDTO dto) {
    return EntityModel.of(dto)
                      .add(linkTo(MEMO_CTRL.createMemo("This is create function"))
                                  .withRel("createMemo"))
                      .add(linkTo(MEMO_CTRL.getMemo(dto.id(), null))
                                  .withRel("getMemo"))
                      .add(linkTo(MEMO_CTRL.getMemos(null))
                                  .withRel("getMemos"))
                      .add(linkTo(MEMO_CTRL.updateMemo(dto.id(), null, null))
                                  .withRel("updateMemo"))
                      .add(linkTo(MEMO_CTRL.deleteMemo(dto.id(), null))
                                  .withRel("deleteMemo"));
}

private ETag getETag(MemoResponseDTO dto) {
    String eTagValue = "\"" + dto.hashCode() + "\"";
    return ETag.create(eTagValue);
}

GlobalExceptionHandler

이제 RestControllerAdvice 어노테이션을 볼 것이다. 이전에 봤다시피 ControllerAdvice 도 있고 이와 유사하다.

동작

  1. 예외 발생
    • 클라이언트가 API 를 요청한 후
    • 서비스나 컨트롤러 에서 예외가 발생
  2. 예외 탐색
    • Spring MVC 는 HandlerExceptionResolver 체인을 통해 예외를 처리
    • @ControllerAdvice 혹은 @RestControllerAdvice 가 있는 클래스 중에서
      • @ExceptionHandler 로 예외를 처리할 수 있는 메서드가 있는지 탐색
  3. 예외 처리 메서드 호출
    • 이때 스프링이 자동으로 예외 객체를 인자로 주입시켜준다.
    • 대부분의 에러 응답은 ErrorResponse 인터페이스 내부의 builder() 로 생성할 수 있다.
ErrorResponse.builder(ex, HttpStatusCode.valueOf(404), ex.getMessage())
             .title("Resource Not Found")
             .property("timestamp", LocalDateTime.now())
             .build();

ErrorResponse 는 Spring 6부터 도입되었다. Spring 표준인 ProblemDetail 기반으로 RFC 9457 규격의 JSON 응답을 만드는 방식이라 웹 규약에 맞아 적절한 에러 처리가 가능하다.

예시

@RestControllerAdvice
public class GlobalExceptionHandler {

	@ExceptionHandler(ResourceNotFoundException.class)
	public ErrorResponse handleNotFound(
			ResourceNotFoundException e) {
		return ErrorResponse.builder(e, HttpStatusCode.valueOf(404), e.getMessage())
				.title("Resource Not Found")
				.property("timestamp", LocalDateTime.now())
				.build();
	}

	@ExceptionHandler(Exception.class)
	public ErrorResponse handleGeneral(Exception ex) {
		return ErrorResponse.builder(ex, HttpStatusCode.valueOf(500), "Internal server error")
		                    .title("Server Error")
		                    .property("timestamp", LocalDateTime.now())
		                    .build();
	}
}