서비스에서 응답을 여러 청크chunk(덩이)로 나누어 전송하기
ResponseBodyEmitter(또는 SseEmitter)로 응답을 청크로 나눠 보내기
스프링에서는 HttpMessageConverter 인프라를 이용해 어떤 객체를 평범한 일반 객체로 출력 가능
클라이언트는 청크된(또는 스트리밍된) 리스트를 받음
결과를 객체 대신 이벤트 형태로 보내는 방법, 서버 전송 이벤트
스프링 MVC의 ResponseBodyEmitter 클래스는 (뷰 이름 또는 ModelAndView 등) 하나의 결과 대신 여러 객체를 클라이언트에 반환할 때 유용
반환할 객체는 HttpMessageConverter를 이용해 결과로 변환한 다음 전송하며 핸들러 메서드는 ResponseBodyEmitter를 반드시 반환
ReservationQueryController의 find() 메서드에서 조회 결과를 하나씩 클라이언트에 보내고 마지막에 ResponseBodyEmitter를 반환
@Controller
@RequestMapping("/reservationQuery")
public class ReservationQueryController {
private final ReservationService reservationService;
private final TaskExecutor taskExecutor;
...
@GetMapping(params = "courtName")
public ResponseBodyEmitter find(@RequestParam("courtName") String courtName) {
final ResponseBodyEmitter emitter = new ResponseBodyEmitter();
taskExecutor.execute(() -> {
Collection<Reservation> reservations = reservationService.query(courtName);
try {
for (Reservation reservation : reservations) {
emitter.send(reservation);
}
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
});
return emitter;
}
}
find() 메서드는 처음에 생성한 ResponseBodyEmitter 객체를 마지막에 반환
reservationService.query() 메서드로 예약 리스트를 조회하고 그 결과 레코드를 하나씩 ResponseBodyEmitter.send() 메서드로 반환
전부 다 반환하면 결과 전송을 담당한 스레드가 처리를 마친 다음 응답을 처리할 수 있게끔 complete() 메서드를 호출해서 메모리에서 해제
이 과정에서 발생한 예외를 유저에 알리고 싶은 경우 completeWithError() 메서드를 호출하면 스프링 MVC 예외 처리 장치를 통과
HTTPie나 curl 같은 툴을 이용해 테스트하면 청크로 나누어져있고 응답 코드는 200(OK)인 결과 출력
상태 코드를 바꾸거나 커스텀 헤더를 추가하고 싶을 때엔 ResponseEntity 안에 ResponseBodyEmitter를 감쌈
@GetMapping(params = "courtName")
public ResponseEntity<ResponseBodyEmitter> find(@RequestParam("courtName") String courtName) {
final ResponseBodyEmitter emitter = new ResponseBodyEmitter();
taskExecutor.execute(() -> {
Collection<Reservation> reservations = reservationService.query(courtName);
try {
for (Reservation reservation : reservations) {
emitter.send(reservation);
}
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
});
return ResponseEntity.status(HttpStatus.I_AM_A_TEAPOT)
.header("Custom-Header", "Custom-Value")
.body(emitter);
}
}
SseEmitter는 서버 전송 이벤트를 이용해 서버 -> 클래이언트 방향으로 이벤트를 전송
서버 전송 이벤트는 서버가 클라이언트에 보내는 메시지로 test/event-stream이라는 콘텐트 타입 헤더가 들어 있음
아주 가벼운 편이라 정의할 필드는 4개
| 필드 | 설명 |
|---|---|
| id | 이벤트 ID |
| event | 이벤트 타입 |
| data | 이벤트 데이터 |
| retry | 이벤트 스트림에 재접속하는 시간 |
핸들러 메서드에서 이벤트를 전송하려면 SseEmitter 인스턴스를 만들어 마지막 줄에서 반환
데이터 각 항목은 send() 메서드로 클라이언트에 보냄
@GetMapping(params = "courtName")
public SseEmitter find(@RequestParam("courtName") String courtName) {
final SseEmitter emitter = new SseEmitter();
taskExecutor.execute(() -> {
Collection<Reservation> reservations = reservationService.query(courtName);
try {
for (Reservation reservation : reservations) {
Delayer.delay(75);
emitter.send(reservation);
}
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
});
return emitter;
}
Context-Type 헤더값이 text/event-stream인 걸로 보아 이벤트 스트림을 받았음을 알 수 있음
스트림을 열어둔 상태로 계속 이벤트 알림을 수신
출력된 객체는 제작기 JSON으로 변환
평범한 ResponseBodyEmitter처럼 HttpMessageConverter로 변환
각 객체는 data 태그 내부에 이벤트 데이터로 씌어짐
이벤트에 더 많은 정보를 추가하고 싶으면 SseEventBuilder를 사용
SseEventBuilder 인스턴스는 SseEmitter의 팩토리 메서드 event()를 호출하여 획득
@GetMapping(params = "courtName")
public SseEmitter find(@RequestParam("courtName") String courtName) {
final SseEmitter emitter = new SseEmitter();
taskExecutor.execute(() -> {
Collection<Reservation> reservations = reservationService.query(courtName);
try {
for (Reservation reservation : reservations) {
Delayer.delay(120);
emitter.send(SseEmitter.event().id(String.valueOf(reservation.hashCode())).data(reservation));
}
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
});
return emitter;
}