📌 HTTTP와 메시지 구조
웹 애플리케이션에서는 데이터 송수신이 자주 일어난다. HTTP는 웹에서 이루어지는 데이터 교환에 적용되는 규약으로, 서버와 클라이언트 간에 통신을 위해서는 정해진 형식의 HTTP 메시지를 교환한다. 일반적으로 서버 응답은 다음과 같은 구조를 가진다.
- start-line: 프로토콜 버전과 요청 수행에 대한 성공 여부를 나타내는 상태 코드
- headers: 메시지 body에 대한 부가적인 설명
- empty line: 요청에 대한 모든 메타 정보가 전송되었음을 알림
- body: 응답과 관련된 문서
📌 Spring의 응답 생성
Spring에서는 HTTP 응답을 만들 때, 상태코드와 Body에 들어갈 내용을 함께 구성해서 객체를 만들어야 하고, 프레임워크 사용 시 다음과 같은 두 가지 응답 방식을 채택할 수 있다.
1. 서버 사이드 렌더링(SSR)
이 방식에서는 서버에서 HTML 문서를 동적으로 생성하여 클라이언트에게 전송한다. 클라이언트는 서버로부터 받은 HTML을 그대로 렌더링하며, 스프링 MVC에서는 @Controller 어노테이션과 Thymleaf와 JSP 같은 뷰 템플릿을 사용하여 SSR을 구현할 수 있다.
2. JSON 또는 기타 데이터 형식 응답
이 방식에서는 클라이언트에게 JSON, XML 등의 데이터 형식으로 응답을 보낸다. 주로 RESTful API를 구현할 때 사용되며, 클라이언트는 받은 데이터를 기반으로 사용자 인터페이스를 동적으로 구성한다.
Spring MVC의 컨트롤러 응답 처리
@Controller
@RequestMapping("/admin")
public class AdminController {
@GetMapping
public String home() {
return "/admin/index";
}
}
일반적으로 Spring MVC에서는 위 코드와 같이 컨트롤러가 view의 이름을 반환하고 ViewResolver를 통해 view 이름에 해당하는 view를 찾아 클라이언트로 응답한다. (SSR)
반면, 요청 처리 후 view 이름이 아닌 HttpEntity를 반환하는 경우에는 ViewResolver가 아닌 HttpMessageConverter가 동작한다. (JSON 형식 응답) HttpMessageConverter는 HTTP 요청과 응답을 객체로 변환하는 전략을 정의하는 인터페이스로, 요청 데이터를 컨트롤러 메서드의 파라미터로 변환하거나, 컨트롤러 메드의 반환 값을 응답 데이터로 변환할 때 중요한 역할을 한다.
public interface HttpMessageConverter<T> {
boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);
boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);
List<MediaType> getSupportedMediaTypes();
default List<MediaType> getSupportedMediaTypes(Class<?> clazz) {
return (canRead(clazz, null) || canWrite(clazz, null) ?
getSupportedMediaTypes() : Collections.emptyList());
}
T read(Class<? extends T> clazz, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException;
void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException;
}
HttpMessageConverter는 위와 같이 정의되어 있는데 canRead는 주로 HTTP 요청을 처리할 때 사용되며, canWrite는 주로 HTTP 응답을 생성할 때 사용된다. 이 두 메드는 HttpMessageConverter가 특정 타입의 객체와 특정 미디어 타입을 처리할 수 있는지 여부를 판단하는 역할을 한다. 단순 문자열을 처리할 때는 StringHttpMessageConverter 구현체를 사용하고, 객체일 때는 MappingJackson2HttpMessageConverter를 사용하는 등, 서버의 컨트롤러 반환 타입 정보를 통해 적합한 HttpMessageConverter를 선택하여 처리한다.
📌 JSON 데이터를 전달하는 방법
@Controller
@RequestMapping("/reservations")
public class ReservationController {
private final ReservationService reservationService;
public ReservationController(final ReservationService reservationService) {
this.reservationService = reservationService;
}
@GetMapping
public List<ReservationResponse> getReservations() {
return reservationService.getReservations();
}
}
위와 같은 코드를 작성하고 '/reservations'에 GET 요청을 보내면 List<ReservationResponse>를 반환할 거라는 예상과 달리 TemplateInputException이 발생한다.
앞서 설명했듯 Spring MVC의 기본 동작은 ViewResolver를 사용하는 것이기 때문에 SSR이 아니라는 것을 명시해야 HttpMessageConverter를 호출할 수 있다.
@ResponseBody
@ResponseBody는 반환된 개체가 자동으로 JSON으로 직렬화되어 HttpResponse 개체로 다시 전달된다는 것을 컨트롤러에 알려준다.
@Controller
@RequestMapping("/reservations")
public class ReservationController {
private final ReservationService reservationService;
public ReservationController(final ReservationService reservationService) {
this.reservationService = reservationService;
}
@GetMapping
@ResponseBody
public List<ReservationResponse> getReservations() {
return reservationService.getReservations();
}
}
JSON 데이터를 반환하는 메서드에 @ResponseBody Annotation을 붙이는 것만으로 간단하게 HTTP 형식에 맞는 메시지를 반환할 수 있다. 오류가 나던 아까 코드와 달리 정상적으로 List<ReservationResponse>가 클라이언트로 전달되는 것을 볼 수 있다.
@ResponseBody는 무척 편리하지만 상태 코드를 지정할 수 없다는 단점이 있다.
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteReservation(final @PathVariable("id") Long id) {
reservationService.deleteReservation(id);
}
때문에 핸들러에 특정 상태코드를 명시하기 위해서는 위와 같이 메서드 외부에 @ResponseStatus를 추가적으로 사용해 주어야 한다.
Spring에서는 편의를 위해 컨트롤러 메서드에 자주 사용되는 @ResponseBody를 클래스 레벨로 적용할 수 있도록 했다. @ResponseBody를 클래스 레벨로 적용할 때는, @Controller와 @ResponseBody를 별도로 적용하지 않고, 두 Annotation을 결합한 @RestController로 대체할 수 있다.
ResponseEntity
ResponseEntity도 @ResponseBody와 마찬가지로 HTTP 응답 객체를 편리하게 만들어 주는 역할을 한다. @ResponseBody와 달리 HTTP 헤더와 상태 코드를 직접 설정할 수 있어 조금 더 유연하다는 장점이 있다.
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteReservation(final @PathVariable("id") Long id) {
reservationService.deleteReservation(id);
return ResponseEntity.noContent().build();
}
@ResponseStatus를 적용하지 않고도 메서드 body에서 직접 no content 상태 코드를 적용할 수 있다.
@GetMapping
public ResponseEntity<List<ReservationResponse>> getReservations() {
final List<ReservationResponse> reservationResponses = reservationService.getReservations();
return ResponseEntity.ok(reservationResponses);
}
이전 예시에서 본 getReservations() 코드에 비해 간결성이 떨어진다는 단점이 있지만, 헤더를 설정하거나 ok가 아닌 특정 상태 코드를 지정해야 하는 경우가 잦다면 확장성 측면에서 장점을 가진다.
📌 @RestController와 @Controller
뷰를 사용하는 상황을 제외하고 보았을 때, 그러니까 @ResponseBody와 ResponseEntity 객체를 반환하는 두 가지 옵션 중에 선택해야 할 때 @RestController와 @Controller에 대한 고민도 자연스럽게 따르게 된다.
@Controller
1. ResponseEntity와 @ResponseBody 혼용
ReseponseEntity를 반환하지 않는 메서드를 포함할 경우에는 해당 메서드들에 직접 @ResponseBody를 붙여 줘야 한다. 그렇기에 코드 일관성과 유지보수 측면에서 @Controller를 사용할 때는 반환 타입을 ResponseEntity로 통일하는 것이 낫다고 생각한다.
2. ResponseEntity만 사용
ResponseEntity를 반환하는 메서드에서는 ViewResolver를 사용하지 않는다는 것을 명확하게 알 수 있다. 그러나 해당 클래스 레벨로 @ResponseBody가 선언되지 않은 상태이기 때문에, 이 컨트롤러가 정말 SSR을 수행하지 않는지 확인하기 위해서는 모든 메서드의 반환값을 일일이 확인해 줘야 해서 코드 예측 가능성이 떨어진다는 단점이 있다.
@RestController
1. ResponeEntity와 @ResponseBody 혼용
getReservations()의 예시에서 볼 수 있듯, 상태 코드를 지정할 필요가 없고, 응답 body 외에 지정할 내용이 없다면 ResponeEntity로 포장하지 않는 것이 더 간결하다. 반대로 말하면, ResponseEntity 타입을 반환하는 메서드에서는 헤더를 지정했거나, 기본 상태 코드(ok)를 사용하지 않는다는 예측이 가능하다는 장점 또한 있다. 그러나 코드 통일성이 조금 떨어진다는 단점이 존재한다.
2. @ResponseBody만 사용
이미 @ResponseBody의 단점으로 언급되었지만 직접적인 HTTP 헤더, 상태 코드 지정이 어렵다. 간결성을 위해 사용했다가 복잡한 API가 요구되었을 때, 다른 설정을 위해 더 지저분한 코드를 작성하게 될 가능성이 존재한다. 그러나 확장성이 중요하지 않고, 요구 사항 변화가 크지 않은 프로젝트라면 가장 나은 선택이라고 생각한다.
3. ResponseEntity만 사용
이번 방탈출 예약 미션에서 내가 선택한 방식이 @RestController와 ResponeEntity를 사용하는 것이다. 물론 다른 방법들도 다 각각의 장단점이 있지만, ResponseEntity의 유연함과 @RestController가 주는 RESTful API라는 명확성 두 가지 장점을 모두 챙길 수 있다는 것이 매력적으로 느껴졌다. ResponseEntity를 반환하는 경우에는 @ResponseBody Annotation이 필요하지 않지만 코드 예측 가능성을 높여 줄 수 있다는 측면에서 사용할 가치가 충분하다고 느꼈다.
📌 결론
이번 미션에서는 확장성이 그렇게 중요한 요소는 아니었지만, 통일성과 코드 예측 가능성을 기준으로 잡아서 결론을 내렸다. API 요구 사항, 프로젝트 기능의 변경 가능성 등에 따라 우선순위는 달라질 수 있기 때문에 현재 상황에서는 어떤 선택이 제일 높은 우선순위를 충족하는지 판단하는 것이 중요할 것 같다.
참고 문서
https://tecoble.techcourse.co.kr/post/2021-05-10-response-entity/
'우아한테크코스 > Level2' 카테고리의 다른 글
기술적 의사 결정은 어떻게 해야 할까? (feat. REST Clients) (6) | 2024.06.02 |
---|---|
[우아한테크코스] JPA에서 감자로 살아남기🥔 (5) | 2024.05.18 |
[우아한테크코스] 로그인에서 감자로 살아남기 🥔 (2) | 2024.05.13 |