📂 목차
- URI 설계 원칙
- HTTP 상태 코드의 세분화
- Versioning
- Content Negotiation
- ETag 기반 조건부 요청 (If-Match, If-None-Match)
- Idempotency-Key
- CORS & CSRF
- Spring HATEOAS 심화
- GlobalExceptionHandler
📚 본문
REST 에 대해 더 깊이 살펴본다.
URI 설계 원칙
- 명사 기반 설계 (
/createUserX ,/usersO) - 복수형 사용 (
/users) - 필터링 & 정렬 (
/users?age=20&sort=name,desc) - sub resource (
/users/{id}/orders) - 관계 자원 표현 (
/orders/{id}/items)
HTTP 상태 코드의 세분화
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 로 응답 반환RequestMappingHandlerAdapter가 클라이언트 매개변수, 반환값, 요청/응답 body 직렬화/역직렬화 분석- 이 안엔 여러 객체가 있는데,
HandlerMethodArgumentResolver,HandlerMethodReturnValueHandler,HttpMessageConverter가 있다.
- 이 안엔 여러 객체가 있는데,
ReturnHandler의HandlerMethodReturnValueHandler로Accept헤더 읽어들임- 알맞은
HttpMessageConverter를 선택해 직렬화MappingJackson2HttpMessageConverter->application/jsonJaxb2RootElementHttpMessageConverter->application/xmlStringHttpMessageConverter->text/plainByteArrayHttpMessageConverter->application/octet-stream
- 컨트롤러의 리턴 객체를 그 포맷으로 직렬화
이를 지키기 위해 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 로 리턴 받고 있음을 볼 수 있다:

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가지가 있다.
GET: 리소스를 조회. 서버 상태를 변경하지 않음HEAD: GET 과 동일하지만, 응답 본문은 제외하고 헤더만 반환POST: 리소스를 생성하거나 서버에 데이터를 전송. 상태 변경 가능PUT: 리소스를 전체 갱신. 존재하지 않으면 생성 가능DELETE: 리소스를 삭제CONNECT: 터널링을 위해 사용. 주로 HTTPS 프록시 연결에 쓰임OPTIONS: 서버가 지원하는 메서드, CORS 허용 등 옵션 확인 용TRACE: 요청-응답 경로를 디버깅용으로 그대로 반환
이 중에서 단순 요청은 GET, POST, HEAD 에 해당한다.
단순요청에서는 다음과 같은 Content-Type 제약이 들어간다:
application/x-www-form-urlencodedmultipart/form-datatext/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:8080Access-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
- 브라우저가 요청
- 단순 요청 제약을 만족하도록 요청을 보낸다:
Content-Type:text/plain또는 위의 제약조건 따르도록, 아예 없어도됨- 사용자 정의 헤더 X
- 서버 A 가 응답
- 단순 요청 조건을 만족하므로 Preflilght 없이 바로 서버 요청
- 브라우저가
Access-Control-Allow-Origin을 확인할 수 있도록 응답을 보내게 됨
- 브라우저가 받음
- 받을 때 Origin 이 허용되어 있다면 브라우저는 데이터를 정상적으로 처리하게 되고
- 브라우저 자체에서 허용되지 않는 Origin 이라면 CORS 에러를 발생시키게 된다.
사전 OPTIONS 이 필요한 PUT, DELETE, PATCH
- 브라우저가 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- 서버 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- 브라우저가 실제 요청 전송
- Preflight 응답이 유효하면, 이제 브라우저는 진짜 요청을 보내게 된다.
PUT /api/products/1 HTTP/1.1
Origin: http://localhost:3000
Content-Type: application/json
Authorization: Bearer 123456
{
"name": "새 제품"
}- 서버 A가 실제 응답 전송
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Content-Type: application/json
{
"id": 1,
"name": "새 제품"
}- 브라우저가 응답 처리
- 응답의 Access-Control-Allow-Origin 헤더를 먼저 읽어서 자신의 Origin 과 일치하는지 확인하고 맞다면 데이터 접근을 한다.
CSRF
CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조)는 사용자가 인증된 세션 상태에 있을 때, 공격자가 사용자를 속여 악의적인 요청을 대신 보내게 하는 공격이다. 요청이 정상적인 사용자의 쿠키를 포함하므로 진짜 요청처럼 보이게 된다. 공경당하게 되는 과정을 보자.
- 사용자는
https://bank.com에 로그인 된 상태(세션 쿠키를 얻은 상태)- sessionId = abc123
- 공격자가 만든 사이트는
https://evil.com - 그 상태로 공격자 사이트 우연히 접속(
evil.com) evil.com에 다음 HTML 이 숨어 있다고 가정<img src="https://bank.com/transfer?to=hacker&amount=100000" />
- 브라우저는 이미
bank.com의 쿠키(sessionId=abc123) 을 저장하고 있기 때문에- 자동으로 이 쿠키를 포함한 GET 요청을
https://bank.com으로 보내게 됨
- 자동으로 이 쿠키를 포함한 GET 요청을
- 서버는 정상 로그인된 사용자 요청으로 인식하게 되고, 송금을 수행함
이를 방어하는 방법이 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 Cookie 설정
브라우저 쿠키의 SameSite 속성으로 Cross-Site 요청에서 쿠키 자동 전송을 제한하게 할 수 있다.
Set-Cookie: sessionId=abc123; SameSite=Lax; Secure; HttpOnlyReferer / Origin 검증
서버단에서 할 수 있는 보안이며, 서버가 요청의 Origin 혹은 Referer 헤더를 보고 허용된 도메인에서 온 요청만 처리하도록 검증한다.
Set-Cookie: sessionId=abc123; SameSite=Lax; Secure; HttpOnlySpring 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 컨트롤러 메서드이다. 이때 Memo 와 ResponseEntity 중간에 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 도 있고 이와 유사하다.
동작
- 예외 발생
- 클라이언트가 API 를 요청한 후
- 서비스나 컨트롤러 에서 예외가 발생
- 예외 탐색
- Spring MVC 는
HandlerExceptionResolver체인을 통해 예외를 처리 @ControllerAdvice혹은@RestControllerAdvice가 있는 클래스 중에서@ExceptionHandler로 예외를 처리할 수 있는 메서드가 있는지 탐색
- Spring MVC 는
- 예외 처리 메서드 호출
- 이때 스프링이 자동으로 예외 객체를 인자로 주입시켜준다.
- 대부분의 에러 응답은
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();
}
}