| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 코테
- 리액트
- 항해
- 그리디
- SSE
- 항해플러스
- 리팩토링
- SSE 적용 방법
- 프로그래머스
- jQuery
- 클린코드
- SSE 후기
- react
- 알고리즘
- 백준 19939
- 항해99
- 테스트코드
- 백준
- 자바스크립트
- 코딩테스트
- 백준 반례
- 탐욕알고리즘
- SSE적용방법
- next.js
- 박 터뜨리기
- JavaScript
- greedy
- 프론트엔드
- 2025년회고
- EC2
- Today
- Total
공부 및 일상기록
[React] 무거워진 요청에 SSE를 적용해보자 (프론트엔드편) 본문
지난 포스팅에서 SSE를 적용한 계기와 적용하기 위한 과정, 그리고 서버쪽 코드에 대해 작성했었다.
https://huirin.tistory.com/264
[Express.js] 무거워진 요청에 SSE를 적용해보자 (백엔드편)
SSE를 적용하게된 계기최근 요약의정석이라는 요약 AI 학습 사이드 프로젝트를 진행하고있다.아주 대강 설명하면 내가 어떤 긴 글을 읽고 요약을 하면 요약을 잘 했는지, 뭘 잘 못했는지 피드백
huirin.tistory.com
위 글을 참고하면 알 수 있다.
이번엔 프론트엔드에서 적용하기 위한 과정을 작성해보겠다.
다시 회고하는 SSE를 적용한 계기
요약의 정석이라는 사이드프로젝트에서 기존 기능이였던 "AI 분석 요청" 기능은 약 3~5초정도의 분석시간이 필요했고, 그래서 따로 Falling을 걸거나 SSE를 적용하지 않고 단순히 http요청을 보내고 응답을 대기했다.
대기하는 동안 짧은 시간은 아니므로 로딩 모달을 띄워 progress바를 통해 사용자에게 약간의 대기를 요구했다.
AI분석이라는 뭔가 분석하는 시간이 걸리겠구나 하는 마음이 있으니 기다릴만한 시간이라고 생각했다. (실제로 GPT에게 요청해도 글이 생성되는 시간동안 기다리게 되는데 그 시간과 별 차이가 없으니.. 괜찮다고 느껴졌다.)
그러나 분석 정확도를 위해 경량모델에서 한 단계 위의 기본 모델을 사용했더니 응답시간이 약 5배이상 길어졌다.
이 시간은 사용자가 로딩모달에서 기다려주는 시간이 아니였다.
그래서 우리는 분석을 백그라운드에서 진행하고 완성되면 client에게 알려주자! 하는 방법을 선택했고, Falling, websocket 등 여러가지 방법이 있었지만 SSE를 학습하기 위해 한번 진행해보기로했다.
짧게 다시 보는 기존 방식과 SSE 적용했을 때의 진행 과정
이 부분은 위 링크에 올려둔 백엔드 글에서 작성했던 부분을 그대로 복사해온 것이다.
기존 요청 과정
client에서 분석 요청 -> 백엔드에서 await로 분석시작 -> 분석완료되면 client에 응답 -> client에서 응답을 받아서 결과페이지로 이동
SSE적용 후 요청 과정
client에서 분석 요청 -> 백엔드에서 해당 요청의 jobId를 생성하고 백그라운드에서 분석시작 -> 분석완료를 기다리지 않고 client에 jobId 반환 -> client에서 응답받은 jobId로 백엔드에 SSE연결 요청 -> 백엔드에서 SSE 연결을 받고 백그라운드에서 진행되는 job의 상태를 실시간으로 반환 -> client에서 실시간 상황 반영 및 종료시 결과페이지로 이동
API 요청 → jobId 반환
↓
startSSE(jobId)
↓
EventSource 연결
↓
progress 이벤트 수신 (UI 업데이트)
↓
completed 이벤트 수신 → 결과 페이지 이동 버튼 노출
클라이언트쪽은 UI 개선 부분이 많았다.
SSE적용 이전에 로딩 모달도 사실상 필요 없어졌고 (너무 금방 응답이 와서 로딩 모달이 약간 보이는게 더욱 경험이 안좋아보여서 아예 지웠음),
백엔드에서 실시간으로 응답받을 현재 분석 상황을 위한 플로팅 모달이 필요해졌고,
SSE 연결을 전역적으로 관리하여 플로팅 모달이 한 페이지에 국한되지 않도록 상태관리를 해야했다.
요약 요청에 대한 응답으로 jobId를 받고, jobId를 받으면 바로 SSE연결 요청을 진행하므로 그 부분에 대한 코드는 굳이 설명할 필요가 없다고 생각한다.
중요한 부분은 SSE를 어떻게 했는지가 중요하니 그 부분을 집중적으로 파헤쳐 보려고 한다.
일단 전역상태관리를 위해 ContextAPI를 사용했다.
그 이유는 굳이 다른 라이브러리를 설치하고싶지 않았고, 내부에서 ReactQuery의 useQueryClient나 useNavitate같은것을 사용해야한다고 판단하여 ContextAPI를 사용했다.
SSEContext
import { createContext, useContext, useState, useCallback, useRef, type ReactNode } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { SSEStep, SSEStatus } from '@/types/summary.type';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
interface SSEState {
isProcessing: boolean;
jobId: string | null;
status: SSEStatus | null;
step: SSEStep | null;
progress: number;
message: string;
error: { code: string; message: string } | null;
resultId: number | null; // 완료 시 결과 ID
isMinimized: boolean; // 모달 최소화 상태
}
interface SummarySSEContextValue {
state: SSEState;
startSSE: (jobId: string) => void;
stopSSE: () => void;
clearState: () => void;
toggleMinimize: () => void;
hasActiveState: boolean; // 표시할 상태가 있는지
}
const initialState: SSEState = {
isProcessing: false,
jobId: null,
status: null,
step: null,
progress: 0,
message: '',
error: null,
resultId: null,
isMinimized: false,
};
const SummarySSEContext = createContext<SummarySSEContextValue | undefined>(undefined);
export const SummarySSEProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState<SSEState>(initialState);
const eventSourceRef = useRef<EventSource | null>(null);
const queryClient = useQueryClient();
// 쿼리 무효화 함수
const invalidateQueries = useCallback(() => {//여기에 무효화함수}, [queryClient]);
// SSE 연결 종료
const stopSSE = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}, []);
// 상태 초기화
const clearState = useCallback(() => {
stopSSE();
setState(initialState);
}, [stopSSE]);
// 최소화 토글
const toggleMinimize = useCallback(() => {
setState((prev) => ({ ...prev, isMinimized: !prev.isMinimized }));
}, []);
// 표시할 상태가 있는지 (진행 중, 완료, 에러)
const hasActiveState = state.isProcessing || state.status === 'completed' || state.error !== null;
// SSE 구독 시작
const startSSE = useCallback(
(jobId: string) => {
// 기존 연결 정리
stopSSE();
setState({
isProcessing: true,
jobId,
status: 'pending',
step: 'validation',
progress: 0,
message: 'AI가 요약을 분석하고 있어요...',
error: null,
resultId: null,
isMinimized: false,
});
const eventSource = new EventSource(`${API_BASE_URL}/summary/sse/${jobId}`, {
withCredentials: true,
});
eventSourceRef.current = eventSource;
// 진행 상황 이벤트
eventSource.addEventListener('progress', (event) => {
const data = JSON.parse(event.data);
setState((prev) => ({
...prev,
status: data.status,
step: data.step,
progress: data.progress,
message: data.message,
}));
});
// 완료 이벤트
eventSource.addEventListener('completed', (event) => {
const data = JSON.parse(event.data);
eventSource.close();
eventSourceRef.current = null;
setState((prev) => ({
...prev,
isProcessing: false,
status: 'completed',
step: 'completed',
progress: 100,
message: data.message,
resultId: data.result.resultId,
}));
// 쿼리 무효화
invalidateQueries();
});
// 에러 이벤트 (서버에서 보내는 에러)
eventSource.addEventListener('error', (event) => {
// MessageEvent 타입인 경우 (서버에서 보낸 에러)
if (event instanceof MessageEvent && event.data) {
const data = JSON.parse(event.data);
eventSource.close();
eventSourceRef.current = null;
setState((prev) => ({
...prev,
isProcessing: false,
status: 'failed',
step: 'failed',
progress: 0,
message: data.message,
error: data.error,
}));
}
});
// 연결 에러 처리 (네트워크 등)
eventSource.onerror = () => {
// readyState가 CLOSED(2)인 경우에만 에러 처리
if (eventSource.readyState === EventSource.CLOSED) {
eventSource.close();
eventSourceRef.current = null;
setState((prev) => ({
...prev,
isProcessing: false,
status: 'failed',
step: 'failed',
progress: 0,
message: '연결이 끊어졌습니다.',
error: { code: 'CONNECTION_ERROR', message: '연결이 끊어졌습니다.' },
}));
}
};
},
[stopSSE, invalidateQueries],
);
return (
<SummarySSEContext.Provider value={{ state, startSSE, stopSSE, clearState, toggleMinimize, hasActiveState }}>
{children}
</SummarySSEContext.Provider>
);
};
export const useSummarySSE = () => {
const context = useContext(SummarySSEContext);
if (!context) {
throw new Error('useSummarySSE must be used within SummarySSEProvider');
}
return context;
};
일단 한 사용자는 하나씩만 요청을 보낼 수 있도록 설계했기에 상태에 배열이 아닌 객체형태로 현재 상태를 보관하도록 했다.
일단 여기서도 중요한 부분 몇 가지만 파헤쳐보자면..
EventSource 객체
EventSource는 브라우저가 서버와 단방향 스트리밍 연결을 유지하기 위한 내장 객체이다. 웹소켓처럼 양방향 통신은 아니지만 서버가 데이터를 보내면 브라우저가 계속해서 이벤트를 수신할 수 있다.
eventSourceRef의 사용
EventSource는 렌더링과 무관하게 반드시 1개만 유지되어야한다.
즉 컴포넌트가 리렌더링 되어도, 상태가 변경되어도, 함수가 호출되어도 다시 만들어지면 안되고 유지되어야한다.
이 특성때문에 useRef객체에 보관하여 사용하는 것이다.
쉽게 말해 연결을 계속 유지하고, 또 필요시 연결을 명확하게 끊기 위해 사용하는 것이다.
event등록
서버에서 Event를 일으켜주는것이므로 서버에서 보내줄 이벤트를 반드시 등록해둬야한다.
위 코드에서는 progress와 complete, error 세 개의 이벤트가 등록되어있는것이 보인다.
서버에서 보내는 예시는 다음과 같다.
res.write(`event: progress\n`);
res.write(`data: ${JSON.stringify(jobData)}\n\n`);
위처럼 이벤트를 정의하고, data에 JSON형태로 정보를 보내주기에 이벤트가 백엔드와 반드시 일치해야한다.
결과
결과는 다음과 같다. 아래 스크린샷은 왼쪽 아래에 작게 보이는 플로팅 모달이다.





위처럼 각 단계를 서버로부터 받아서 현재 어떤 단계가 진행중인지 확인가능하고, 최소화 시켜서 다른 글을 읽기 편하게 만들어 주기도 한다. 그리고 분석이 완료되면 결과페이지로 이동 가능하도록 만들었다.
이렇게 프론트엔드쪽 SSE 적용 과정까지 정리해보았다.
이 글만 보고 SSE를 진행하기는 어려울 수 있으나..
"이렇게 활용하는구나" 혹은 "대략 이런 과정이 있구나" 하는 아이디어를 많이 얻어갔으면 좋겠습니다..!!
'개발 > React' 카테고리의 다른 글
| EC2에 배포한 React가 사용자에게 항상 최신이 보이도록 설정하기 (0) | 2025.11.14 |
|---|---|
| [React] 성능 최적화 하기 (0) | 2025.09.15 |
| [WIL] 프론트엔드 테스트코드 - 8주차 과제 (2) | 2025.08.30 |
| [WIL] 프론트엔드에서 테스트코드 (1) | 2025.08.30 |
| [React] FSD 폴더구조 공부 후기 (1) | 2025.08.23 |