이 글을 적는 이유는 다른 사람들이 혹시나 SSE에 대해 처음 도입을 할때, 내가 했던 삽질을 안했으면 좋겠는 마음에 적는다.!🤯
내가 이전부터 해오던 프로젝트는 대게 클라이언트가 요청을 보내면, 서버에서 응답을 전달하는 형태로 통신이 진행됐었다. 따라서 개발을 하는데 있어서 큰 문제가 없었다.
그런데,,,,, 새로 진행한 프로젝트에서는 프론트엔드에서 요청을 보내지 않아도 백엔드에서 특정 작업이 끝나면 알림을 줘야됐다. 그래야 클라이언트 측에서 특정 작업이 완료된것을 알 수 있기 때문이다.
찾아보니까 SSE라는 통신기법이 있었다.
이번에 진행한 프로젝트는 '사용자 INPUT 이미지 기반 유사 이미지를 원하는 수량 만큼 생성해주는 데이터 유통 플랫폼'이다.
따라서 구조가 클라이언트 - 스프링 서버 - AI 서버
와 같은 구조이다.
프로젝트 시나리오에 대해 간략하게 말해보겠다.
처음에 생각한 플로우이다. 여기서 든 생각이 사용자가 이미지 생성이 완료된지에 대한 여부를 모르면, 계속 마이페이지에서 확인을 해야되는 번거로움이 있기 때문에 SSE를 도입하기로 하였다.!
Postman은 api 테스팅을 해보는데 매우 유용한 툴이다. 하지만 실제 프로덕션 환경에서 가상 서버를 띄우고, 통신하는 과정에서는 생각하지 못한 장애물이 존재한다.
아래는 내가 SSE 구독에 대해 백엔드 단만 먼저 개발하여 테스팅했던 코드 및 결과이다.
@GetMapping(value = "/v1/subscribe", produces = MediaType.ALL_VALUE)
public SseEmitter subscribe() {
log.info("sub");
return notificationService.subscribe();
}
위와 같이 토큰에 userId가 있기 때문에 컨트롤러는 그냥 요청만하면 된다.
public SseEmitter subscribe() {
Long userId = userService.getCurrentUserId();
log.info(userId.toString());
SseEmitter emitter = createEmitter(userId);
sendToClient(userId, "EventStream Created. [userId=" + userId + "]");
log.info("finish");
return emitter;
}
private SseEmitter createEmitter(Long id) {
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
emitterRepository.save(id, emitter);
log.info(emitter.toString());
// Emitter가 완료될 때(모든 데이터가 성공적으로 전송된 상태) Emitter를 삭제한다.
emitter.onCompletion(() -> emitterRepository.deleteById(id));
// Emitter가 타임아웃 되었을 때(지정된 시간동안 어떠한 이벤트도 전송되지 않았을 때) Emitter를 삭제한다.
emitter.onTimeout(() -> emitterRepository.deleteById(id));
return emitter;
}
private void sendToClient(Long id, Object data) {
SseEmitter emitter = emitterRepository.get(id);
if (emitter != null) {
try {
emitter.send(SseEmitter.event().id(String.valueOf(id)).name("sse").data(data));
emitter.send("success");
} catch (IOException exception) {
emitterRepository.deleteById(id);
emitter.completeWithError(exception);
}
}
}
그럼 Postman에서 위의 정의한대로 실행해보면?
알맞게 connect 된 모습을 볼 수 있다.
그렇다. 로컬에서는 잘 작동하는 것 같아 보이지만 프로덕션 환경에서는 여러가지 스택이 추가되면서 원하는 대로 작동되지 않을 수 있다.
이제 아래에 트러블에 대해 적어보겠다.
로그인한 유저는 모든 요청을 보낼때 토큰을 헤더에 담아서 보냈었다. 그러나 SSE에 대해서는 아래와 같이 토큰이 없다고 떴다.
해당 요청만 토큰이 제대로 전달되지 않은것을 의심하고 폭풍 구글링을 해봤다.
const sse = new EventSource("http://localhost:8080/v1/subscribe");
위의 코드가 SSE 설정하는 일부 클라이언트 코드이다.
기본적으로 구독시에 클라이언트에서는 EventSource 인터페이스를 이용한다.
이는 기본적으로 헤더 전달을 지원하지 않는다.
따라서 토큰이 전달되지 않고, 유효한 토큰이 없다는 로그가 뜬것이다.
event-source-polyfill
을 사용하여 헤더를 함께 보낸다.@RequestParam
으로 받아서 사용한다.나는 2번 해결법을 사용했다. 왜냐면 1번은 클라이언트 측의 설정이라 내가 할 수 없었기 때문에 백엔드에서 추가적인 처리인 2번을 채택했다.
크게 어려운것은 없다.
기존 컨트롤러에서 @RequestParam으로 토큰을 받아서 subscribe 함수의 파라미터로 전달하고, 해당 토큰으로 userId를 찾으면 된다.
아래는 수정된 코드이다.
@GetMapping(value = "/v1/subscribe", produces = MediaType.ALL_VALUE)
public SseEmitter subscribe(@RequestParam String token) {
log.info("sub");
return notificationService.subscribe(token);
}
public SseEmitter subscribe(String token) {
Long userId = tokenProvider.getUserId(token);
log.info(userId.toString());
SseEmitter emitter = createEmitter(userId);
sendToClient(userId, "EventStream Created. [userId=" + userId + "]");
log.info("finish");
return emitter;
}
위와 같이 수정하게 된다면, 이제 SSE 구독이 제대로 된다.
다만, 문제점은 토큰 자체를 통신으로 주고 받기 때문에, 보안상의 위험이 발생할 수 있다. 따라서 위의 방식보다는 1번 방식으로 처리해야 안전할 것이라고 생각한다.
위에서 SseEmitter에 첫 구독시 더미 응답을 넣어서 보낸다.(왜 이래야 되는지는 아래에서 후술하겠다.)
라고 적었었다. 그 이유는 처음 SSE 응답으로 아무런 이벤트도 보내지 않으면 재연결 요청, 연결 요청 자체에서 오류가 발생한다.
따라서 첫 SSE 응답으로 더미 데이터라도 넣어서 응답 한다.
프로젝트에서는 위와 같은 아키텍쳐를 사용중이었다. Nginx를 리버스 프록시로 사용하고 있었다. 그런데, 백엔드 로그를 확인해보면, SSE 연결이 계속 닫히는 문제가 발생했다.
문제의 원인은 Nginx는 Upstream으로 요청 보낼때, HTTP/1.0을 사용한다. 그러므로 Nginx에서 백엔드 WAS로 요청을 보낼 때 Connection: close 헤더를 사용해서 계속 커넥션이 닫히게 되는 것이다.
이를 해결하기 위해 nginx.conf 파일을 아래와 같이 수정해줬다.
location / {
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Host $http_Host;
proxy_http_version 1.1; # 이 줄 추가
proxy_set_header Connection ""; # 이 줄 추가
proxy_pass $service_url;
}
proxy_http_version 1.1;
, proxy_set_header Connection "";
이 두줄을 추가하게 되면, HTTP/1.1을 사용해서 지속연결을 기본적으로 지원한다.
큰 문제는 아닌데 스프링 자체 톰캣의 파일 용량은 늘려놨었다. 하지만 Nginx의 경우 파일 용량이 기본적으로 1MB이므로 프로젝트 특성상 추가 설정을 해줘야 했다.
client_max-body_size
를 늘려주면 해결된다.!
이렇게 프로젝트를 진행하면서 겪음 문제를 정리해봤다.!
역시 프로덕션, 개발 환경은 로컬과 많이 다르고 문제도 많이 생겼다.
해당 프로젝트에 대핸 글도 조만간 써봐야겠다..
coming soon..