난이도 ⭐️⭐️⭐️
작성 날짜 2025.07.19
말모는 AI챗봇 모모가 사용자의 애착유형을 바탕으로 연애 고민을 상담해주는 어플리케이션이다.
가장 핵심이라고 할 수 있는 부분이 AI 상담을 위한 채팅 기능인데, 이 기능을 어떻게 구현해야 사용자 친화적일지를 PM님과 함께 고민했다.
우선 개발 경험이 있어서 그나마 친숙하였던 OpenAI API를 사용하기로 하였다.
GPT에게 사용자의 응답을 전송하면, GPT가 응답을 생성해서 받아온 값을 사용자에게 보여주어야 했는데,
이 과정에서 생각보다 많은 지연이 발생해 사용자 관점에서 '느리다'라는 느낌을 받을 수 있었다.
ChatGPT 처럼 GPT의 응답이 모두 완성될 때까지 기다리는 게 아니라, 생성되는 대로 와다다다 나올 수는 없을까?
다행히도 OpenAI API에서는 해당 기능을 제공하는데, 그것이 바로 'Streaming'이다.
https://platform.openai.com/docs/guides/streaming-responses?api-mode=responses
근데 우리... REST API 아닌가?
Spring MVC의 REST 컨트롤러(@RestController)의 동작 방식은 다음과 같다.
클라이언트 → 서버에 요청 (HTTP Request)
서버 → 내부 로직 처리 후 최종 결과를 생성
서버 → 한 번에 Response Body를 내려주고 연결 종료
즉, 한 번 응답을 완료하면 HTTP 연결이 닫히기 때문에 REST 통신 방식만으로는 스트리밍을 구현할 수 없다.
🤔 REST API 통신 방식은 유지하면서, GPT의 스트리밍을 구현할 수는 없을까?
결론부터 이야기 하자면, 우리 팀에서는 SSE를 선택하였다.
우리의 기획은 사용자가 다른 사용자와 채팅을 할 수도 없으면서,
채팅방은 사용자 당 하나이고,
사용자의 요청 → GPT의 응답의 1 대 1 대응이기 때문에
굳이 복잡한 상태관리가 필요한 WebSocket을 선택할 필요가 없었다.
사용자의 채팅은 REST로 받아두고, OpenAI의 응답에 대해서만 서버 → 클라이언트로 응답하면 되는 부분이기 때문에 서버 중심적인 통신 방식을 선택해도 문제가 없는 상황이었다.
그리고 GPT 본인한테 물어보니까
OpenAI API → 서버로의 응답 과정도 SSE 방식이고,
ChatGPT도 SSE 통신 방식을 쓴다고 하니(확실하지 않음)
접근 방식 자체는 좋았던 것 같다.
조금 더 구체적인 동작 방식을 알아보자.
클라이언트에서 서버로 SSE 연결을 요청하면 어떤 일이 벌어지는가?
스프링 서버에서는 SSE Emitter를 생성한다.
외부 네트워크 통신을 위해 소켓을 생성하는데, OS 레벨에서 TCP 소켓은 socket() 시스템 콜로 만들어지고, 이 소켓은 FD(파일 디스크립터) 로 관리된다.
REST와 다른 점은 REST는 이 소켓을 응답과 동시에 닫아버리지만, SSE는 타임아웃의 발생 전까지는 소켓을 유지한다는 점이다.컨트롤러가 SseEmitter를 반환하면, DispatcherServlet은 이 요청을 비동기 모드로 전환한다.
즉, 초기에 연결 API를 통해 받은 요청을 처리하기 위한 스레드는 반납되지만, HTTP 연결은 OS의 네트워크 스택에 의해 유지되는 상태가 된다.
이때 Spring은 SseEmitter에 설정된 타임아웃 시간 후에 실행될 타임아웃 콜백 작업을 스케줄러(Task Scheduler)에 등록한다.
데이터의 전송
생성된 SSE Emitter를 통해 데이터를 전송하면, 소켓을 통해 메시지가 작성된다.
열려있는 HTTP 연결 덕분에 TCP Handshake 과정을 생략하고, 클라이언트에게 메시지가 즉시 전달된다.
타임아웃 발생
예약된 타임아웃 시간이 되면, 스케줄러가 서블릿 컨테이너에 비동기 디스패치(Async Dispatch)를 요청한다.
컨테이너는 이 요청을 받고 스레드 풀에서 스레드를 하나 할당한다.
할당된 스레드가 onTimeout() 콜백을 먼저 실행한다.
onTimeout() 실행 후 onCompletion() 콜백이 실행된다.
이 콜백에서 연결 종료, 리소스(소켓) 정리 등의 후속 작업을 수행한다.
이 과정에서 서버는 클라이언트와의 TCP 연결을 종료하는 신호를 전달한다.onCompletion()까지 실행되어 모든 참조가 해제되면,
SseEmitter 객체는 더 이상 참조되지 않으므로 자바의 가비지 컬렉터(GC)에 의해 메모리에서 해제될 대상이 된다.
이제 직접 적용해보자.
@Slf4j
@RequiredArgsConstructor
@Component
public class SseEmitterAdapter implements SendSseEventPort, ConnectSsePort, ValidateSsePort {
private static final long TIMEOUT = 60 * 1000L; // 1분
private static final int MAX_SIZE = 1000;
public static final long RECONNECT_TIME_MILLIS = 3000L;
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
private final SseMetrics sseMetrics;
@Override
public SseEmitter connect(MemberId memberId) {
if (emitters.size() >= MAX_SIZE) {
log.warn("Cannot connect SSE: Emitter map is full (size: {}).", emitters.size());
throw new SseConnectionException("Maximum number of connections exceeded.");
}
sseMetrics.increment();
Long memberIdValue = memberId.getValue();
SseEmitter newEmitter = new SseEmitter(TIMEOUT);
SseEmitter oldEmitter = emitters.put(memberIdValue, newEmitter);
if (oldEmitter != null) {
oldEmitter.complete();
}
newEmitter.onTimeout(() -> {
newEmitter.complete();
log.info("SSE emitter timed out for member: {}", memberIdValue);
});
newEmitter.onError(e -> {
newEmitter.complete();
log.error("SSE emitter error for member: {}", memberIdValue, e);
});
newEmitter.onCompletion(() -> {
log.info("SSE emitter completed for member: {}", memberIdValue);
emitters.remove(memberIdValue, newEmitter);
sseMetrics.decrement();
});
try {
newEmitter.send(SseEmitter.event()
.id(String.valueOf(memberIdValue))
.name("connected")
.data("SSE connection established.")
.reconnectTime(RECONNECT_TIME_MILLIS));
} catch (IOException e) {
log.error("Failed to send initial SSE connection event for member: {}", memberIdValue, e);
newEmitter.complete();
}
return newEmitter;
}
}
SSE Emitter를 ConcurrentHashMap으로 관리하였다.
사용자 별로 Emitter를 하나만 할당할 수 있다.
너무 많은 연결로 인한 리소스 문제의 발생을 막기 위해 MAX_SIZE를 설정하였고,
RECONNECT_TIME_MILLIS 또한 설정하였는데, 의도치 않은 연결 해제 시 브라우저(클라이언트)에게 자동 재연결을 할 수 있도록 부여하는 시간이다.
타임아웃에 대해 고민이 있었는데,
타임아웃을 길게 설정할 때
장점
클라이언트가 자주 재연결할 필요가 없어 네트워크 트래픽과 서버 부하가 줄어듦
실시간 알림 서비스 등에서 안정적인 연결 유지 가능단점
끊어진 연결(네트워크 단절 등)을 서버가 늦게 감지 → 리소스 낭비 (스레드/메모리 점유)
유휴 연결이 많아지면 서버 자원 관리가 어려워질 수 있음
타임아웃을 짧게 설정할 때
장점
서버가 빠르게 끊어진 연결을 정리 가능 → 리소스 낭비 최소화
클라이언트가 주기적으로 재연결하면서 더 안정적인 연결 상태를 유지단점
클라이언트 재연결 요청이 잦아져 네트워크 부하 증가
이벤트 전달 시 순간적으로 끊김(재연결 지연)이 발생할 수 있음
상대적으로 짧은 타임아웃(1분)을 선택하였다.
사용자가 앱을 이미 떠난 상황임에도 길게 SSE를 점유하고 있어
TCP 소켓(FD)을 정리하지 못하는 상황을 최대한 피하고 싶었다.
SseMetrics는 프로메테우스 매트릭 수집을 위해 추가한 부분이다.
조금 중요하다고 생각하는 부분은 이 부분인데,
newEmitter.onTimeout(() -> {
newEmitter.complete();
log.info("SSE emitter timed out for member: {}", memberIdValue);
});
newEmitter.onError(e -> {
newEmitter.complete();
log.error("SSE emitter error for member: {}", memberIdValue, e);
});
newEmitter.onCompletion(() -> {
log.info("SSE emitter completed for member: {}", memberIdValue);
emitters.remove(memberIdValue, newEmitter);
sseMetrics.decrement();
});
SSE Emitter의 콜백을 관리하기 위한 코드이다.
각각 타임아웃, 전송 중 에러 발생, 완료 처리 시 비동기 후속 처리에 대한 코드이다.
타임아웃과 에러 상황에서도 newEmitter.complete()
을 호출에 주어야 하는데, 그 이유에 대해서는 트러블 슈팅에서 정리해두었다.
complete()이 호출된 경우 onCompletion 속 코드가 실행된다.
HashMap에 저장된 SSE Emitter를 제거하여 자원을 관리한다.
sendToMember에서는 사용자의 ID를 바탕으로 Map에서 조회하여 SSE Emitter로 데이터를 흘려보낸다.
connect() 메서드에서 생성한 SseEmitter를 컨트롤러에서 그대로 반환하면,
HTTP 연결이 열린 상태로 유지되게 된다.
또한, TEXT_EVENT_STREAM_VALUE 헤더를 설정해서 앞으로 SSE 통신이 이루어질 것임을 클라이언트에게 알린다.
이제 SSE Connect를 위한 API를 설정하였으니,
데이터를 흘려보내주어야 한다.
private Map<String, Object> createStreamBody(List<Map<String, String>> messages) {
return Map.of(
"model", GPT_VERSION,
"messages", messages,
"temperature", GPT_TEMPERATURE,
"stream", true
);
}
OpenAI API에 담아줄 Body 부분이다. 모델과 메시지, Temperature를 설정하는데,
가장 중요한 부분은 stream을 true로 설정하여 스트리밍으로 전달받도록 하는 것이다.
@Override
public void streamChat(List<Map<String, String>> messages,
Consumer<String> onData,
Consumer<String> onCompleteFullResponse,
Consumer<String> onError) {
Map<String, Object> body = createStreamBody(messages);
Request request = createStreamRequest(body, onError);
client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(@NotNull Call call, @NotNull IOException e) {
log.error("Failed to connect to OpenAI API", e);
onError.accept("에러가 발생했습니다: 네트워크 연결에 실패했습니다.");
}
@Override
public void onResponse(@NotNull Call call, @NotNull Response response) {
StringBuilder fullResponse = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body().byteStream()))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ")) {
String data = line.substring(6).trim();
if (data.equals("[DONE]")) break;
String content = extractStreamContent(data);
if (!content.isEmpty()) {
fullResponse.append(content);
onData.accept(content);
}
}
}
onCompleteFullResponse.accept(fullResponse.toString());
}
catch (Exception e) {
log.error("Error processing OpenAI API response", e);
onError.accept("에러가 발생했습니다: 응답 처리 중 문제가 발생했습니다.");
}
}
});
}
이 코드는 리팩토링 전의 코드입니다. 추후 포스팅을 통해 코드의 변경 과정을 보여드릴 예정입니다. (WebClient 기반 비동기 논블로킹)
이 코드에서 중심이 되는 내용을 몇 가지 소개하자면
OpenAI에서 우리 서버로 SSE를 통해 chunk 단위로 데이터를 전송한다.
이 데이터를 받아서 그대로 클라이언트에게 전달해야 하는데,
일반적인 코드처럼 API 요청을 하는 것으로 함수를 끝내버리면 response를 반환 받자마자 함수가 끝나버려 연속적인 처리가 불가능하다.
Consumer 인터페이스를 인자로 받으면,
스트리밍으로 데이터가 들어올 때 마다 해당 함수를 실행시켜주는 방식으로 구현할 수 있다.
이러한 방식을 콜백 패턴이라고 하는데,
실제 코드를 함수에 넣는 것과는 달리 실행 시점에 할 일을 외부에서 제어하면서 책임을 분리할 수 있다는 장점이 있다.
OkHttp 라이브러리의 enqueue()
메서드를 사용하였다.
이 메서드는 execute()
와는 달리, HTTP 요청을 비동기로 처리하기 위한 코드이다.
execute()
를 사용하여 동기 처리하는 경우, GPT의 응답 전문을 모두 받을 때까지 호출 스레드를 블로킹한다.
외부 API를 호출하기 때문에 이 시간이 길어질 수 있으며, 스레드 풀의 일시적 고갈로 이어질 수 있다.
이를 방지하기 위해 비동기 처리를 위한 enqueue()
메서드를 사용하였다.
또한, enqueue의 내부적으로도 Callback을 주입해 비동기 코드에서 해야 할 일을 미리 전달하여 데이터가 도착했을 때 어떤 일을 해야하는지 적어두었다.
requestChatApiPort.streamChat(messages,
// 데이터 stream 수신 시 SSE 이벤트 전송
chunk -> {
// ...생략
sendSseMessage(memberId, chunk);
},
// 응답 완료 시 전체 응답 저장
fullAnswer -> {
saveAiMessage(...);
// ...생략
},
// 에러 발생 시 에러 메시지 전송
errorMessage -> sendSseErrorMessage(memberId, errorMessage)
);
streamChat() 메서드의 Caller 부분만 가져왔다.
(관계 없는 내부 로직은 생략하였다.)
이제 chunk가 도착할 때 마다 sendSseMessage 메서드를 통해
SSE를 처리하는 Adapter의 sendToMember로 메시지를 전달하게 된다.
@Override
public void sendToMember(MemberId memberId, NotificationEvent event) {
Long memberIdValue = memberId.getValue();
SseEmitter emitter = emitters.get(memberIdValue);
if (emitter == null) {
log.debug("SSE emitter not found for member: {}", memberIdValue);
return;
}
try {
emitter.send(SseEmitter.event()
.id(memberIdValue + "_" + System.currentTimeMillis()) // 각 이벤트에 고유 ID 부여
.name(event.getEventType().getEventName())
.data(event.getData()));
} catch (IOException | IllegalStateException e) {
log.error("Failed to send SSE event to member: {}. Removing emitter.", memberIdValue, e);
emitter.complete();
}
}
SSE Timeout 시 Access Denied 에러 로그 찍히는 문제 발생!
사용에는 문제 없지만, 너무 길어져 다른 로그를 보는 것에 방해가 된다.
원인은 Spring Security에 ASYNC 디스패처가 보호되어 있어서
AuthorizationFilter가 다시 권한 체크를 하다 AccessDenied를 터뜨리는 것이다.
비동기 요청 완료를 위한 내부 디스패치(Async Dispatch)
SseEmitter
가 타임아웃되면, 스케줄러는 이 비동기 콜백을 처리하기 위해 내부적으로 디스패치(dispatch)를 실행한다.Anonymous
로 만든다.AuthorizationFilter
가 해당 엔드포인트에 대한 권한(예: isAuthenticated()
)을 검사하다가, 익명 사용자의 접근이므로 AccessDeniedException
을 발생시킨다..dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll()
Spring Security에 한 줄 추가해서 해결
org.springframework.web.context.request.async.AsyncRequestTimeoutException: null
at org.springframework.web.context.request.async.TimeoutDeferredResultProcessingInterceptor.handleTimeout(TimeoutDeferredResultProcessingInterceptor.java:42) ~[spring-web-6.1.14.jar:6.1.14]
at org.springframework.web.context.request.async.DeferredResultInterceptorChain.triggerAfterTimeout(DeferredResultInterceptorChain.java:81) ~[spring-web-6.1.14.jar:6.1.14]
at org.springframework.web.context.request.async.WebAsyncManager.lambda$startDeferredResultProcessing$5(WebAsyncManager.java:457) ~[spring-web-6.1.14.jar:6.1.14]
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) ~[na:na]
at org.springframework.web.context.request.async.StandardServletAsyncWebRequest.onTimeout(StandardServletAsyncWebRequest.java:185) ~[spring-web-6.1.14.jar:6.1.14
이런 오류가 또 떴는데 이건
타임아웃에 Complete 처리를 안해서 그렇다.
emitter.complete()
는 단순히 메서드를 하나 더 호출하는 것이 아니라,
비동기 요청 처리의 제어권을 프레임워크로부터 받아와서 내 코드에서 책임지고 완료시키겠다는 의사 표현이기 때문에,
타임아웃 발생 시 Complete을 시키지 않으면 서블릿이 AsyncRequestTimeoutException을 발생시켜 요청이 프레임워크에 의해 강제로 종료됨을 알리는 것이다.
만약 이런 기능이 없으면 통신 객체를 더이상 사용하지 않음에도 소켓이 열려있는 문제가 발생하기 때문에 강제 종료 기능을 넣은 것으로 추측된다.
이건 에러 로그도 없고 특정 시점이 아닌 간헐적으로 발생하는 문제라 디버깅이 어려웠다.
찾아보니 비슷한 문제를 겪고 있는 분들이 계셔서 참고했다.
요약하자면,
SseEmitter의 타임아웃을 1분으로 설정하더라도, 중간 경유지인 공유기, 통신사, 웹 서버(Reverse Proxy) 등의 자체적인 유휴 타임아웃(Idle Timeout) 설정 때문에 메시지 전송이 없는 경우 연결이 먼저 끊어질 수 있다고 한다.
우리 서버(Spring) → 클라이언트(React)로 요청, 응답하는 과정에는 상당히 많은 중간 장치를 거친다.
클라이언트
→ 클라이언트 측 네트워크 장치 (라우터, ISP)
→ DNS 서버
→ AWS 네트워크 → [NACL → Security Group → (EC2 OS 방화벽)]
→ Nginx
→ Spring Boot
우리 인스턴스에서 사용하는 웹 서버인 nginx에는 이미
proxy_read_timeout 1d;
proxy_send_timeout 1d;
이런 식으로 충분히 긴 타임아웃 시간을 설정해두었다.
그러니 nginx의 문제는 아닐 것이다.
그러나 라우터, ISP와 같은 중간 장비들은 SSE 연결이 활성 상태인지, 아니면 단순히 아무 데이터도 전송되지 않는 유휴 상태인지 구분하지 못한다. 따라서 자신들의 규칙에 따라 유휴 상태라고 판단되면 연결을 종료시킨다고 한다.
해결 방법은 바로바로 하트비트 (Heart Beat)
서버 측(SseEmitter)에서 아무런 전송할 이벤트가 없더라도 10초나 30초 등 SseEmitter 타임아웃 및 중간 장비의 타임아웃보다 짧은 간격으로 주석(comment)이나 의미 없는 작은 데이터를 클라이언트로 보내는 방식이다.
이 더미 데이터는 실제 데이터는 아니지만, 네트워크 상에서는 트래픽으로 인식된다. 따라서 중간 경유지들은 이 연결이 계속 활성 상태라고 판단하여 연결을 끊지 않게 된다고 한다.
@Scheduled(fixedRate = 15_000)
public void sendHeartbeat() {
// 현재 연결된 모든 Emitter에 대해 반복
emitters.forEach((memberId, emitter) -> {
try {
emitter.send(SseEmitter.event()
.comment("sse heartbeat"));
} catch (IOException | IllegalStateException e) {
// IO 에러 발생 시, 클라이언트 연결이 끊어진 것으로 간주하고 정리
log.warn("Failed to send heartbeat to member: {}. Removing emitter.", memberId, e);
emitter.complete();
}
});
}
사실 눈에 보이는 해결책은 아니라 실제로 문제를 해결할 수 있을 지에 대한 의문이 있었는데,
이 방식을 적용한 이후로 연결 끊김이 많이 사라졌다는 프론트 팀원 분들의 만족 가득한 후기가 있었다.
좀 뜬금 없는 이야기일 수 있는데, SSE를 구현하고 이런 오류가 발생했다.
좀 자세히 살펴보면,
JDBCConnectionException: Connection is not available, request timed out after 30000ms
이라는 메시지를 확인할 수 있다.
바로 DB와 관련된 API를 요청한 경우, JDBC 트랜잭션이 커넥션 풀에서 커넥션을 가져오려고 하는데 없어서 대기하다가 타임아웃이 발생하는 오류이다.
음? SSE 얘기하고 있는데 갑자기 JDBC?
컨트롤러 코드를 보면, 클라이언트-서버 간 SSE 연결을 해주는 /connect API가 있다.
잘 보면 SseEmitter를 반환하고 있어서 Spring은 Content-Type: text/event-stream 으로 연결을 열어두고 닫지 않는다.
SSE Emitter 생성을 관리하는 서비스 코드이다.
원인은 바로 @CheckValidMember
이놈에 있었다.
CheckValidMember는 커스텀 어노테이션으로, 서비스 코드가 실행되기 전 AOP로 사용자가 실제 멤버인지 DB를 통해 검증하는 역할을 가지고 있다.
그렇다. 이 어노테이션은 DB를 통해 엔티티를 조회한다...
그래서 커넥션을 빌려오고, 요청이 끝날 때까지 이 커넥션을 점유한다.
이런 일이 발생하는 이유는 Spring이 Open-In-View 설정을 true 값으로 자동 설정하기 때문인데, 작동 방식을 보면 알 수 있다.
Open-In-View는 실제로 DB에 접근하는 코드가 실행될 때 커넥션을 가져오며, 일단 가져온 커넥션은 웹 요청이 끝날 때까지 유지한다. Repository를 호출하는 등 DB 접근이 한 번이라도 일어나면 커넥션을 할당하고 계속 붙잡고 있게 된다.
Open-In-View의 핵심은 DB 커넥션의 생명주기를 웹 요청의 생명주기와 일치시키는 것이다.
웹 요청 시작: 클라이언트로부터 HTTP 요청
OpenInViewInterceptor 동작: 인터셉터가 영속성 컨텍스트(EntityManager)를 생성하여 현재 스레드에 바인딩
첫 DB 접근 발생: 서비스나 컨트롤러에서 Repository의 메서드를 호출하는 등 실제로 데이터베이스에 쿼리를 보내야 하는 첫 번째 순간에, 영속성 컨텍스트는 커넥션 풀로부터 물리적인 DB 커넥션을 할당받음.
커넥션 유지: 일단 커넥션이 할당되면, Open-In-View 전략에 따라 이 커넥션은 웹 요청이 완전히 끝날 때까지 반납되지 않는다. (ThreadLocal에 보관)
웹 요청 종료: 클라이언트에게 최종 응답이 전송되고 요청이 마무리되면, OpenInViewInterceptor가 영속성 컨텍스트를 닫으면서 DB 커넥션을 커넥션 풀에 반납
findById(1L)과 같은 코드를 단 한 줄이라도 실행했다면, 그 순간 커넥션이 할당되고 웹 요청(HTTP)이 끝날 때까지 유지된다.
이 경우에는 스레드는 반환되었지만, HTTP 요청은 종료되지 않았기 때문에 스레드의 ThreadLocal에 갇힌 커넥션은 반환되지 않는 것이다.
스프링이 이와 같은 작동 방식에 대한 기본 값을 true로 설정하는 이유는 지연 로딩 때문이다.
Service나 Repository에서 커넥션을 이용해 엔티티를 조회한 경우,
해당 엔티티를 그대로 Caller인 컨트롤러 또는 타임리프와 같은 View 계층에 반환하는 경우가 있다.
이제 실제 JSON으로 변환하기 위해 Getter를 사용하려 하지만, 지연 로딩 설정되어 있는 경우 해당 시점에 DB에서 실제 엔티티를 조회해 와야 한다.
그럼 DB 커넥션이 필요하기 때문에 이 커넥션을 Callee에서 반환해버리지 말고, 편하게 View 단까지 끌어오자~
라는 목적성을 갖고 있다고 보면 된다.
여튼, 이 문제를 해결하기 위해선
spring.jpa.open-in-view의 값을 false로 설정하거나,
DB 커넥션을 사용하는 메서드를 제거하는 방식으로 변경해야 한다.
현재 말모 프로젝트의 엔티티와 도메인은 분리되어 있어서 open-in-view의 값을 false로 설정하더라도 아마도 문제가 없을 것이다.
그러나 나의 경우에는 개발 기간이 빠듯해 이를 검증하지 못할 것 같아 후자를 선택하였다.
어차피 Security의 Access Token에서 검증 과정에서 어느정도 멤버가 검증되기도 하며,
여기서 걸러지지 않은 사용자가 토큰을 재활용하여 Emitter를 생성하더라도, 로직 상 Emitter 생성만 가능하고, 다른 API는 사용하지 못하기 때문에 문제가 없을 것이다.
따라서 @CheckValidMember
을 제거하는 방식으로 문제를 해결하였다. (추후 검증을 통해 전자로 변경 예정)
가급적이면 SSE Emitter를 반환하는 API에는 DB Connection을 사용하지 말자!
결론
새롭게 사용하는 기술이라 여러모로 이슈가 많이 있었다.
기술의 동작 방식을 정리하면서 정확한 문제를 파악할 수 있었다.
기본을 확실히 알고 사용해야 한다는 것을 느꼈다.
다음 포스팅에서 계속...