ResponseEntity
응답 설계 핵심 도구
우리는 ResponseEntity에 대해 왜 알아야 할까?
이유는 간단하다.
스프링 프레임워크에서 HTTP 응답을 정교하게 제어하기 위한 핵심 도구이기 때문이다.
단순한 "값 반환"이 아니라, HTTP 상태 코드, 헤터, 본문까지 직접 다룰 수 있다.
1. RESTful API에서 '응답 설계'는 감히 절반이라 할 수 있다.
REST는 HTTP 자체를 프로토콜로 삼는다.
응답에 상태 코드, 헤더, 본문을 정밀하게 제어해야 하며, 이걸 직접 제어하는 대표적인 도구가 ResponseEntity다.
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found");
→ 단순한 return "User not found"가 아니다.
→ 404 상태 + 메시지를 명확하게 전달한다.
2. CRUD의 모든 시나리오를 커버한다.
동작 | 응답 예시 | 설명 |
Create (POST) | 201 Created + Location 헤더 | 새 리소스 URI를 알려줌 |
Read (GET) | 200 OK + body | 정상 조회 |
Update (PUT/PATCH) | 204 No Content | 내용 없이 성공 |
Delete (DELETE) | 404 Not Found, 410 Gone, 204 No Content | 상황에 따라 다양 |
3. 헤더 조작이 가능하다.
HttpHeaders headers = new HttpHeaders();
headers.set("X-Custom-Header", "tact");
return new ResponseEntity<>("OK", headers, HttpStatus.OK);
→ 보안, CORS, 토큰, 캐싱 등에서 매우 중요하다.
4. 컨트롤러 테스트에서도 유리하다.
상태 코드까지 반환하기 때문에, 단위 테스트에서 status, headers, body를 명확히 검증할 수 있다.
→ MockMvc로 assert할 때도 편하다.
저번에 했던 것처럼 간단한 페이지 반환 (@Controller) 목적이라면 당연히 String 리턴으로도 충분하다.
@RestController에서도 JSON 응답만 반환하고 상태 코드가 항상 200이라면 생략할 수 있긴 하다.
보일러플레이트 제거
ResponseEntity<T>는 스프링에서 HTTP 응답 전체를 제어할 수 있게 해주는 클래스다.
제네릭 클래스는 타입 매개변수에 타입 인자를 전달해서 사용할 타입을 결정하는데, 여기서 T는 응답 바디에 담길 타입을 뜻한다.
핵심 기능
- HTTP 상태 코드 설정
- 헤더 설정
- 응답 바디 설정
@GetMapping("/hello")
public ResponseEntity<String> hello() {
return ResponseEntity
.status(HttpStatus.OK) // 200 OK
.header("Custom-Header", "value")
.body("Hello, World!"); // String
}
return ResponseEntity.ok("Hello, World!");
ResponseEntity는 응답 객체 전체를 커스터마이징 할 수 있게 해 준다.
응답의 상태코드, 헤더, 바디를 컨트롤할 수 있으며, 일정 부분을 생략해 간단하게 표현하기도 한다. (편리함 + 가독성)
이것이 가능한 이유는 스프링에서 응답 작성의 보일러플레이트를 줄이기 위해 ok(...), badRequest(), notFound() 같은 메서드들을 만들어 놓았기 때문인데, 말은 거창하지만 매번 똑같은 패턴으로 반복해서 써야 하는 코드를 줄이기 위함인 것이다.
→ Lombok의 @Getter, @Setter를 사용하는 이유를 생각해 보면 된다.
→ 당연히 @RestController, @RequestMapping, @Builder, @Autowired도 마찬가지다.
1) 보일러플레이트 코드 예시
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Type", "application/json");
ResponseEntity<String> response = new ResponseEntity<>(
"Hello, World!",
headers,
HttpStatus.OK
);
2) 한 줄로 추상화한 모습
return ResponseEntity.ok("Hello, World!");
빌더 패턴에 대한 짧은 고찰
생성자 방식과 체이닝 방식에는 순서상 차이가 있는 것처럼 느껴진다.
파라미터 순서가 고정되어 있는 생성자 방식 대비 빌더 패턴을 취하는 체이닝 방식은 다소 차이가 있기 때문이다.
1. 생성자 방식
2. 체이닝 방식으로 쓰인 빌더 패턴
생성자 방식의 파라미터 순서와 체이닝 방식의 빌더 패턴 순서가 같았다면 더 좋았을 텐데, 빌더 패턴은 왜 .status() → .headers() → .body()를 따르는 걸까? 여기엔 두 가지 이유가 존재한다.
1) 우리는 보통 HTTP 응답을 생각할 때 아래 순서로 떠올린다.
상태 코드 → 헤더 설정 → 응답 본문
사용자에게 직관적인 흐름을 제공할 수 있다.
(Ex. CREATED 상태로 응답을 만들 건데, 헤더는 이렇게 줄게. 바디는 이거야.)
2) .status()와 .header()는 ResponseEntity.BodyBuilder라는 인터페이스 타입을 반환한다.
(정확히 말하자면, 내부적으로는 이를 구현한 DefaultBuilder 객체가 사용된다.)
.body()는 호출 시 실제 ResponseEntity<T> 객체가 생성된다.
(그 뒤로 더 이상 .header() 같은 체이닝이 불가능하다.)
구조상 .body()를 마지막으로 가도록 설계해야 함 역시 현재의 컨벤션을 만든 중요한 이유 중 하나다.
// .status
public class ResponseEntity<T> extends HttpEntity<T> {
private final HttpStatusCode status;
...
public static BodyBuilder status(HttpStatusCode status) {
Assert.notNull(status, "HttpStatusCode must not be null");
return new DefaultBuilder(status);
}
public static BodyBuilder status(int status) {
return new DefaultBuilder(status);
}
}
// .headers
// .body
private static class DefaultBuilder implements BodyBuilder {
private final HttpStatusCode statusCode;
private final HttpHeaders headers;
...
public BodyBuilder headers(@Nullable HttpHeaders headers) {
if (headers != null) {
this.headers.putAll(headers);
}
return this;
}
public <T> ResponseEntity<T> body(@Nullable T body) {
return new ResponseEntity(body, this.headers, this.statusCode);
}
}
이러한 방식은 "Self-Referencing Generics + Fluent API"이 결합된 형태인데, 많이 쓰이는 디자인 패턴은 아니지만 빌더 패턴에서는 자주 등장하니 이번 기회에 알아두자.
1. 자기 참조 제네릭 (Self-Referencing Generics):
자기 자신을 제네릭 타입 매개변수로 사용하는 패턴
메서드 체이닝 할 때 정확한 타입을 유지하기 위해 사용한다.class Parent<T extends Parent<T>> { public T someMethod() { // ... return (T) this; } }
상속 관계에서도 자식 타입으로 결과를 리턴할 수 있게 돕는다.
class Builder<T extends Builder<T>> { public T withSomething() { System.out.println("Do something"); return (T) this; } }
class MyBuilder extends Builder<MyBuilder> { public MyBuilder withExtra() { System.out.println("Extra step"); return this; } }
// 체이닝 예시 MyBuilder b = new MyBuilder(); b.withSomething().withExtra();
사용하지 않을 경우, 부모 클래스 메서드들이 Builder 타입을 리턴하게 되어 자식에서는 체이닝이 끊기는 문제가 발생할 것이다.
2. Fluent API:
자연스럽고 문장처럼 읽히는 API 디자인 방식
메서드들이 this 또는 자기 타입을 리턴한다.new QueryBuilder() .select("name", "age") .where("age > 18") .orderBy("name") .execute();
메서드 체이닝이 가능하다.
코드가 마치 영어 문장처럼 읽힌다.
비유하자면,
Self-Referencing Generics: 자기 자신의 정확한 타입으로 돌아오게 해주는 템플릿 기능
Fluent API: 메서드를 자연스럽게 연결해서 쓰게 해주는 설계 철학
헬퍼 메서드, 유틸리티 메서드
ResponseEntity.ok() 같은 메서드들을 응답 빌더, 응답 생성기라고 부른다.
조금 더 넓은 개념에서는 헬퍼 메서드, 유틸리티 메서드라고 부르면 될 듯하다.
아래 이미지는 내가 직접 declaration을 캡처한 것이다. 생긴 게 조금 특이해서 미리 설명하자면, IDE나 Javadoc에서 자주 보이는 형태로, 쉽게 말해 "이런 메서드가 있다." 정도를 정의한 선언이다.
시그니처만 보는 버전이기 때문에 구현 내용은 보이지 않는다는 특징이 있다.
실제 구현체는 아마 아래와 같이 되어 있을 것이다.
public static <T> ResponseEntity<T> ok(@Nullable T body) {
return new ResponseEntity<>(body, HttpStatus.OK);
}
'기술블로그 > 시리즈' 카테고리의 다른 글
비즈니스 로직이니까 서비스에 있어야지! (1) | 2025.04.09 |
---|---|
DDD에서는 private을 안 쓴다면서? (0) | 2025.04.08 |
무분별한 Getter & Setter 사용은 OOP 원칙을 위배한다. (0) | 2025.03.27 |