| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- 자바스크립트
- react
- SSE적용방법
- jQuery
- SSE 적용 방법
- 테스트코드
- 알고리즘
- EC2
- 코테
- 백준
- 2025년회고
- 그리디
- 탐욕알고리즘
- 항해
- 박 터뜨리기
- 항해플러스
- 리액트
- SSE 후기
- 프론트엔드
- 프로그래머스
- 백준 반례
- next.js
- 코딩테스트
- SSE
- 백준 19939
- 클린코드
- 항해99
- greedy
- 리팩토링
- JavaScript
- Today
- Total
공부 및 일상기록
SSE 사용 중 새로고침 시 진행상태 유지하기 본문
최근 진행하는 사이드프로젝트에서 서버에서 약 20초나 걸리는 무거운 요청을 SSE를 통해 프론트에서 현 상태를 이벤트로 받아볼 수 있도록 구현해뒀었다.
그런데 팀원분께서 한 가지 문제점을 지적해주셨다.
"새로고침하면 작업중이던 상태를 잃어버려서 진행중인지 아닌지 알 수 없어요!"
사실 저걸 고려해서 만들던게 있었는데.. 메모를 누락하는 바람에 구현을 안했었다. ㅎㅎ;;
그래서 오늘 그 문제를 해결했는데, 내 생각처럼 쉽게 해결되지 않아서 과정을 기록하려고 한다.
해결 방안
새로고침하면 현 상태를 표현해주는 모달 UI가 사라지고 재연결이 안되는 것이다.
그래서 단순히 생각해서 "그럼 다시 재연결을 시도해서 연결시켜두면 되겠다!" 라는 아이디어만 가지고 있었다.
그리하여 localstorage에 구독중인 jobId를 넣고 새로고침 시 jobId를 꺼내서 다시 연결 요청을 하는것이였다.
하지만 그렇게 쉽게 해결되지 않았다.
서버도 수정이 필요할 줄이야..
해결되지 않은 이유는 현재 상태를 즉시 보내주지 않기 때문이였다.
서버에서 다음 progress이벤트가 오기 전까지 UI는 여전히 비어있게 되었다.
당연한것이 연결은 단순히 연결일 뿐이고, 전에 설정해둔 이벤트를 기반으로 UI가 변경되기 때문이다.
즉, "재연결은 성공했지만 상태복원은 되지 않음" 이였다.
스냅샷(snapshot) 개념 도입
그래서 새로 연결한 클라이언트에게는 현재 상태를 한 번은 반드시 보내야한다는것을 알게 되었다.
그래서 서버에 snapshot 개념을 도입했다.
클라이언트가 SSE에 연결되는 순간 현재 job의 상태를 한 번 즉시 전송하고, 그 이후에 기존처럼 progress 이벤트만 전송하도록 하였다.
clients 관리 방법 변경
그렇게 스냅샷을 도입했지만 이상하게 잘 될때도, 잘 되지 않을때도 있었다.
뭔가 타이밍이 문제인가? 싶어서 gpt와 이런저런 대화를 하다보니 역시 타이밍이 문제였다는것을 알게되었다.
private clients: Map<string, SSEClient>
기존 clients는 위 코드처럼 관리되었다.
jobId당 하나의 클라이언트만 관리 가능하였고, 새로고침시 이전 연결은 끊기고 새 연결이 들어오지만 타이밍에 따라 (너무 빠르게 새로고침하거나.. 브라우저에서 다른탭을 다녀오면..?) 상태관리가 꼬일 수 있다는 결론을 내렸다.
그래서 jobId에 여러 클라이언트 연결이 가능하도록 변경했다.
private clients: Map<string, Set<SSEClient>>
이 구조의 장점은 새로고침, 탭 이동, 재연결에 안전했고 서버상태는 클라이언트와 독립적으로 유지된다는 것이였다.
이렇게 수정하고 나니 완전히 수정이 되었다고 생각했다. 하지만 문제는 여전히 존재했다.
너무 빠르게 새로고침하면 여전히 모달 UI가 생기지 않음..
이번엔 더 크리티컬한 문제였다.
너무 빠르게 새로고침하면 아예 재연결 되었던것도 사라지는 문제였다..
알고보니 jobId가 유효한지 검증하는 코드에서 발생했었다.
fetch(`/summary/job/${storedJobId}`)
.then((res) => (res.ok ? res.json() : null))
.then((job) => {
if (!job) {
localStorage.removeItem(STORAGE_KEY);
return;
}
})
이렇게 fetch하는 함수가 useEffect내부에서 페이지가 마운트되면서 실행되었다.
위 코드를 해석해보면 "이 jobId가 서버 기준으로 아직 유효한가?" 를 확인하는 것이다.
서버 기준에서 서버가 재시작되거나, Job이 만료되었거나, 이미 클린업된 Job이라면 SSE 재연결을 하면 안되기 때문에 검증을 먼저 넣었고, 검증에서 job이 없다면 로컬스토리지에서도 삭제시켜줬다.
이 검증이 없다면 재연결 요청을 여러번 보낼 수 있는 문제가 있기 때문에 필수적으로 넣어야 했다.
그럼 왜 이게 문제였을까?
원래 내가 예상한 과정은 다음과 같았을 것이다.
1. 페이지 로드
2. useEffect 실행
3. fetch로 jobId검증
4. 응답 도착
5. 상태 판단 후 startSSE or 기존 연결 유지
근데 아주 빠르게 새로고침을 연타하면?
useEffect로 fetch (#1)을 시작 --- 아직 응답 안옴
새로고침
useEffect로 fetch (#2)를 시작
즉 #1, #2가 동시에 살아있는 것이다.
따라서 race condition이 발생하였고, 그 과정에서 정상적으로 응답했을 서버의 응답이 의미없는 응답이 되면서 잘못된 cleanup(여기서 클린업에는 localstorage.remove가 실행)이 되었을 가능성이 있다.
따라서 AbortController를 도입하여 다음과 같은 원인을 제거해줬다.
catch(err) {
if (err.name === 'AbortError') return; // 의도된 취소
localStorage.removeItem(); // 진짜 실패만
}
즉 도입전에 무조건 실패로 인식되어 클린업되던것을 "의도된 취소"라고 구분지어 의도된 취소에서는 클린업이 발생하지 않도록 한 것이다.
정리
이 문제의 핵심은 SSE 자체가 아니라 "페이지 생명주기 + 비동기 요청이 섞이면서 발생하는 race condition" 이었다.
간략히 글을 요약해본다면..
- 새로고침 시에도 작업 상태를 유지하기 위해 jobId를 localStorage에 저장하고 재연결 시도
- 하지만 재연결 직후 상태가 보이지 않아 서버에서 연결 즉시 snapshot 이벤트를 전송하도록 하여 현 상태 업데이트
- 그럼에도 간혹 발생하는 문제를 해결하기 위해 서버의 clients를 Set구조로 변경하여 다중 연결 가능하도록 변경
- 잘 되는듯 보였으나 아주 빠른 새로고침시 jobId가 사라지는 문제가 발생하여 (race condition 문제였음) AbortController를 도입하여 의도된 취소(너무 빠른 새로고침)와 실제 실패(서버 오류 or jobId없음)를 구분하여 jobId가 클린업되지 않도록 수정
이 정도로 요약할 수 있다.
'개발' 카테고리의 다른 글
| 사이드프로젝트에 CI/CD 적용하기 (0) | 2025.11.18 |
|---|---|
| Lambda + cloudFront + S3 를 이용한 이미지 resize하여 리턴받기 (2) | 2024.10.29 |
| S3 + CloudFront + Route53을 이용한 React프로젝트 배포 (간단설명) (0) | 2023.05.06 |
| gray-matter (마크다운 파싱) (1) | 2023.02.16 |
| [DDC'23] DEV.DESIGN CON 에 다녀오다. (0) | 2023.01.29 |