현재 프로젝트에서 SSE 를 적용하기 위해서 조원분께서 제법 오랫동안 붙잡고 계셨는데 내가 맡은 일이 마무리됨에 따라 몇일전 부터 같이 붙어서 코드를 보게 되었다.
다행히도 이번에는 SSE 구현에 있어서 끝낼 수 있었다 !!!!!!!! (고생하신 조원분들 수고하셨어요 ㅜㅠ)
우선은 완성된 코드를 먼저....
package com.project.trysketch.global.utill.sse;
import com.project.trysketch.service.GameService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
@Slf4j
@RestController
public class SseController {
private final SseEmitters sseEmitters;
@Autowired
private GameService gameService;
public SseController(SseEmitters sseEmitters) {
this.sseEmitters = sseEmitters;
}
// Emitter 생성 및 SSE 최초 연결
@CrossOrigin
@GetMapping(value = "/api/sse/rooms", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<SseEmitter> connect() {
log.info("[SSE] - Controller 시작 / connect() 메서드 시작");
// Emitter 객체 생성. 5분으로 설정
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
sseEmitters.add(emitter);
// SSE 연결 및 데이터 전송
try {
log.info("[SSE] - Controller 의 connect() 메서드 / try 문 안의 emitter 객체 생성");
emitter.send(SseEmitter.event()
.name("connect") // event 의 이름
.data(gameService.getRooms(0))); // event 에 담을 data
log.info("[SSE] - Controller 의 connect() 메서드 / try 문 안의 / 생성된 emitter : {}", emitter);
} catch (IOException e) {
log.info("[SSE] - Controller 의 connect() 메서드 / try 문 안의 예외 처리 터짐 / remove 실행");
log.info("", e);
sseEmitters.remove(emitter);
}
// 타임아웃 발생시 콜백 등록
emitter.onTimeout(() -> {
emitter.complete();
});
// 타임아웃 발생시 브라우저에 재요청 연결 보내는데, 이때 새로운 객체 다시 생성하므로 기존의 Emitter 객체 리스트에서 삭제
emitter.onCompletion(() -> {
sseEmitters.remove(emitter);
});
emitter.onError(throwable -> {
emitter.complete();
});
return ResponseEntity.ok(emitter);
}
}
package com.project.trysketch.global.utill.sse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
@Component
@Slf4j
public class SseEmitters {
private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
// complete()이 실행되면 onCompletion() 이 호출.
// complete의 역할은 SSE 연결을 disconnect 해줌
// SSE emitter 등록 메서드
void add(SseEmitter emitter) {
this.emitters.add(emitter);
log.info("[SSE] - SseEmitters 파일 add 메서드 시작 / emitter list size : {}", emitters.size());
log.info("[SSE] - SseEmitters 파일 add 메서드 시작 / emitter list: {}", emitters);
// Broken Pipe 발생시
emitter.onError(throwable -> {
log.error("[SSE] - ★★★★★★★★SseEmitters 파일 add 메서드 / [ onError ]");
// log.error("", throwable); // 덕분에 Broken Pipe 에러를 찾을 수 있었음
emitter.complete();
});
// 타임아웃 발생시 콜백 등록
emitter.onTimeout(() -> {
log.info("[SSE] - ★★★★★★★★SseEmitters 파일 add 메서드 / [ onTimeout ]");
emitter.complete();
});
// 비동기요청 완료시 emitter 객체 삭제
emitter.onCompletion(() -> {
log.info("[SSE] - ★★★★★★★★SseEmitters 파일 add 메서드 / [ onCompletion ]");
this.emitters.remove(emitter);
});
}
void remove(SseEmitter emitter) {
log.info("[SSE] - ★★★★★★★★SseEmitters 파일 add 메서드 / [ remove ]");
this.emitters.remove(emitter);
}
// 게임 방 생성, 소멸시 SSE 커넥션 연결된 모든 클라이언트에 방리스트 전달
public void changeRoom(Object roomInfo) {
log.info("[SSE] - SseEmitters 파일 changeRoom 메서드 시작");
emitters.forEach(emitter -> {
try {
log.info("[SSE] - SseEmitters 파일 changeRoom 메서드 / try 문 안의 시작");
emitter.send(SseEmitter.event()
.name("changeRoom") // event의 이름 지정
.data(roomInfo)); // event에 담을 data
log.info("[SSE] - SseEmitters 파일 changeRoom 메서드 / try 문 안의 emitter : {}", emitter.toString());
} catch (IOException e) {
log.info("[SSE] - SseEmitters 파일 changeRoom 메서드 / try 문 예외 처리 시작 !!!");
this.emitters.remove(emitter);
log.info("[SSE] - ★★★★★★★★SseEmitters 파일 changeRoom 메서드 / emitter list size : {}", emitters.size());
}
});
}
}
위처럼 작성을 하기 전 까지 몇일을 날려 먹었는지...
일단 나같은 경우에는 이전부터 코드를 짜신 조원분의 코드 위에서 서로가 찾은 정보를 공유하면서 각자가 생각하는 의견을 주고받으면서 계속 코드를 고쳐가며 이것저것 시작해보는 것이 시작이었다.
조원분 께서 이전에 시도했었을 때 발생했던 문제에 대해서 들은거를 간단하게 적자면...
503 Service Unavailable
처음에 SSE 응답을 할 때 아무런 이벤트도 보내지 않으면 재연결 요청을 보낼때나, 아니면 연결 요청 자체에서 오류가 발생한다.
따라서, 첫 SSE 응답을 보낼 시에는 반드시 더미 데이터라도 넣어서 전달해야한다.
JPA 사용시 Connection 고갈 문제
SSE 통신을 하는 동안은 HTTP Connection 이 계속 열려 있다.
만약 SSE 연결 응답 API 에서 JPA 를 사용하고 open-in-view 속성이 true로 되어있다면, HTTP Conneciton이 열려있는 동안 DB Connection도 같이 열려있게 된다.
즉, DB Connection Pool 에서 최대 10개의 Connection 을 사용할 수 있다면, 10명의 클라이언트가 SSE 연결 요청을 하는 순간 DB 커넥션도 고갈되게 된다.
따라서 이 경우 open-in-view 설정을 반드시 false 로 설정해야 한다.
위와 같은 경우 외에도 계속해서 요청이 가는 경우도 있었다고 했는데 그 경우에 대한건 나중에 정리하도록 하고...
지금은 내가 합류한 시점부터 알게된 정보들만을 몇가지 정리 해 보았다.
우리 프로젝트에서는 결국 OSIV(Open-Session-In-View) 를 프로퍼티에 false 로 선언해 주었다.(즉, 기본값은 true)
이를 짧게 설명하자면...
True 일 경우 영속성 컨텍스트가 트랜잭션 범위를 넘어선 레이어까지 살아있다.
- API 라면 클라이언트가 응답될 때 까지, View 라면 View 가 렌더링될 때 까지 영속성 컨텍스트가 살아있다는 의미이다. 즉, View 또는 API 가 반환되어야 커넥션을 반환하고 영속성 컨텍스트를 끝낸다.
반대로 False 라면, 트랜잭션을 종료할 때 영속성 컨텍스트 또한 닫히게 된다.
이에대한 설명및 예제는 아래의 블로그 글을 참고하자.
[JPA]open-session-in-view 를 알아보자
Open-In-View 또는 Open-Session-In-View 또는 Open-EntityManager-In-View 란? 관례상 OSIV 라고 한다. true일 경우 영속성 컨텍스트가 트랜잭션 범위를 넘어선 레이어까지 살아있다. Api라면 클라이언트에게 응답될
gracelove91.tistory.com
기본적으로는 OSIV 를 true 로 두는게 좋아보이지만 영속성 컨텍스트를 유지한다는 건, DB Connection 또한 계속 가지고 있다는 뜻이다.
이렇게 되면 너무 오랫동안 DB 커넥션을 사용할 경우 커넥션이 모자른 경우가 발생할 수 있다.
그래서 우리팀 같은 실시간 트래픽이 중요한 경우 DB Connection 이 모자를 수 있기 때문에, 성능이 중요하다면 OSIV 를 false 로 설정해줘야 한다.
요약
OSIV 를 끄면 트랜잭션을 종료할 때, 영속성 컨텍스트를 닫고, 커넥션도 반환한다.
OSIV 를 끄면 모든 지연 로딩을 트랜잭션 안에서 해결해야 한다.
이에 관해 조원분께서 말씀해주신 Lazy, Eager 등이 있는데 이는 나중에 물어보자...
우선 위처럼 OSIV 를 False 로 변경해주면서 Lazy 관련 에러는 해결이 되었다.
그런데... 한가지 더 문제가 생겼다...
프론트에서 로비로 진입하면 SSE 연결을 하면서 방 정보를 받아와서 뿌려주게 된다.
이후에 방에 입장하게되면 SSE 연결을 끊어주고 이후 대기실에 있는 유저들 전원에게 새롭게 방 정보를 뿌려주게 되어 있다.
하지만, 연결을 끊고 해당 객체를 삭제를 하는 코드가 없으므로 인해서 아무 연결도 되어있지 않은 객체가 살아있으므로 인해 방에 들어올 때 마다 대기실 사람들에게 새롭게 다시 정보를 뿌려주는데, 이미 연결이 끊어진 녀석에게도 똑같은 객체를 전달하려 하니까 자꾸 BrokenPipe 가 뜨는 경우가 발생했다...
처음엔 이 에러가 뜨지도 않아서 원인자체도 몰랐는데...
// Broken Pipe 발생시
emitter.onError(throwable -> {
log.error("[SSE] - ★★★★★★★★SseEmitters 파일 add 메서드 / [ onError ]");
log.error("", throwable); // 덕분에 Broken Pipe 에러를 찾을 수 있었음
emitter.complete();
});
위에 주석으로도 적혀있듯이 log.error("", throwable); 이 녀석 덕분에 그래도 어떤 에러가 뜨는지라도 알 수 있었다...(이를 알게 해준 조원분께 압도적 감사...!!)
java.io.IOException: Broken pipe
위와 같은 BrokenPipe 에러가 뭔지를 몰라서 검색해본 결과...
우리같은 경우에는 아마도 송신 받은 데이터를 제때 처리하지 못하는 상황, 더 세세하게 서버 측에서 작업 결과를 전달할 곳이 없는데도 불구하고 보냄으로 인해서 발생하는 경우라고 추측했다...
왜냐하면 SSE 객체가 살아있는 동안에는 문제가 없었는데 타임아웃 설정으로 인해서 날라가게 되면 그 때부터 계속해서 저 에러가 터지는 것이었다...
- 그래서 SSE 를 생성할 때 유저 정보를 보내고자 했으나 이렇게 되면 SSE 를 쓰기보다는 차라리 웹소켓을 쓰는것이 맞다고 생각해서 PASS
- 이번에는 타임아웃을 엄청 늘려버릴까 했는데 이 또한 커넥션 낭비에다가 매우 비효율적이며 근본적인 해결 방안이 아니기에 PASS
이러지도 저러지도 못하고 있었는데...
이는 결국 단방향으로만 보내는 SSE 의 태생적 한계로 인해 연결이 끊긴 SSE 객체를 언제, 어디서 삭제하냐가 결국 문제였다.
그러던 차에 "SSE 는 연결을 끊어주는 코드가 없나??" 싶어서 찾아보던 중에 눈에 띄게 된...
emitter.complete();
emitter.onCompletion(() -> {
this.emitters.remove(emitter);
});
위의 두 코드 였다.
검색을 통해서 알아본 결과 complete 가 처리를 완료해서 onCompletion 으로 보내주는데, 이 때, onCompletion 안에 해당 역할을 완수한 SSE 객체를 삭제해주는 코드를 추가해줌 으로써 해결을 할 수가 있었다...
또한, 이를 이용해서 onError 에서 에러를 감지하면 즉, 우리같은 경우에는 연결이 끊어진 SSE 객체를 발견해서 에러를 발생하면 그 순간 역할을 완수하고 complete 를 호출해 매나 마찬가지로 SSE 객체를 삭제 해 주었다.
그렇게 했더니 드디어 비로소... SSE 문제를 해결하고 정상 작동하는 모습을 볼 수 있었다...
지금 글을 쓰면서도 좀 제대로 정리가 않되는 느낌이다만... 이정도만 해도 제법 많이 알게 된 것 같다.
이 글을 쓰고 난 이후에 이에 대해서 같이 겪었던 조원분과 대화를 하면서 더 정리 해 봐야겠다.
'일기' 카테고리의 다른 글
2023-02-06 (0) | 2023.02.06 |
---|---|
2023-02-04 (0) | 2023.02.04 |
2023-02-02 (0) | 2023.02.02 |
2023-02-01 (0) | 2023.02.01 |
2023-02-01 (0) | 2023.01.31 |