Repository navigation
feat: 명세 불일치 — 실행 상태 전달을 폴링에서 SSE로 전환 - #41
Conversation
기명서 Execution "실행 상태 전달": "Server → Web 실시간 갱신은 SSE를 사용한다. 연결이 끊기면 재연결 후 현재 상태를 다시 조회해 화면을 복구한다." 인데, 지금까지는 600ms 간격 폴링으로 구현돼 있었다(코드에 "SSE 우선 검토, 지금은 폴링"이라는 의도적 보류 코멘트가 남아있었음). - features/execution/lib/executionStream.ts (신규): SSE 구독 함수. 네이티브 EventSource 대신 fetch + ReadableStream 을 직접 읽는다 — 테스트 환경(jsdom)엔 EventSource 가 없어 폴리필 없이는 검증할 수 없고, fetch 기반이라야 MSW로 그대로 테스트할 수 있다. 연결이 끊기면 지수 백오프(500ms~5s)로 재연결한다. - mocks.ts: `GET /api/executions/:id/stream` 추가 — 연결 즉시 현재 상태부터 보내고(재연결 시 "현재 상태를 다시 조회"를 만족), 이후 상태가 실제로 바뀔 때만 새 프레임을 push 한다. 종료 상태에 닿으면 스트림을 닫아 클라이언트가 재연결하지 않게 한다. - hooks.ts: useExecutionState 가 폴링(refetchInterval) 대신 SSE 구독으로 큐 캐시를 갱신한다. 초기 1회 REST 조회는 그대로 유지(마운트 직후· refetch() 즉시 호출 용도). 테스트: executionStream.test.ts 신규 4건(초기 상태 즉시 수신, 상태 전환 push 확인, 완료 시 스트림 종료, 취소 시 프레임 수신). CDP로 실제 브라우저 에서 폴링 없이 큐→배정→진행→완료 전환이 push 로만 이루어짐을 확인. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
리뷰 결과
변경된 5개 파일을 검토했으며, 수정이 필요한 문제 4개를 발견했습니다.
| 심각도 | 개수 |
|---|---|
| Critical | 0 |
| High | 1 |
| Medium | 3 |
| Low | 0 |
주요 내용
- HIGH · REGRESSION — 종료 상태가 아닌 EOF에서도 구독이 영구 종료됩니다
- MEDIUM · RESOURCE_LEAK — 이미 종료된 실행의 mock 스트림이 interval을 영구 누수합니다
- MEDIUM · API_CONTRACT — CRLF를 사용하는 유효한 SSE 프레임을 처리하지 못합니다
- MEDIUM · ERROR_HANDLING — 복구 불가능한 4xx 응답도 무한히 재요청합니다
검토한 head: a2c8e7865576
| while (!stopped) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) return; // 서버가 종료 상태에서 정상 종료 — 재연결하지 않는다 | ||
| buffer += decoder.decode(value, { stream: true }); |
There was a problem hiding this comment.
🔴 HIGH · REGRESSION
종료 상태가 아닌 EOF에서도 구독이 영구 종료됩니다
reader.read()가 done을 반환하면 실행 상태를 확인하지 않고 즉시 connect()를 끝냅니다. 서버·프록시가 keep-alive 제한이나 일시적인 연결 문제로 스트림을 정상 EOF로 닫는 경우에도 재연결되지 않아, 이후 상태 변경이 화면에 반영되지 않습니다.
마지막 수신 상태가 terminal인지 추적하여 terminal일 때만 반환하고, 그 외 EOF는 바깥 재연결 루프로 이어지게 해야 합니다.
| } | ||
| }; | ||
| push(); | ||
| intervalId = setInterval(push, STREAM_CHECK_MS); |
There was a problem hiding this comment.
🟠 MEDIUM · RESOURCE_LEAK
이미 종료된 실행의 mock 스트림이 interval을 영구 누수합니다
초기 push()가 terminal 상태를 만나면 아직 할당되지 않은 intervalId를 정리한 뒤 스트림을 닫습니다. 그 직후 이 줄에서 새 interval을 생성하므로 실제로는 정리되지 않습니다. 이후 tick은 status === lastStatus에서 매번 조기 반환해 interval을 멈출 기회도 없습니다.
interval을 먼저 시작한 뒤 push하거나, 초기 push가 terminal 여부를 반환하도록 하여 terminal이면 interval을 만들지 않아야 합니다.
|
|
||
| let sepIndex: number; | ||
| while ((sepIndex = buffer.indexOf('\n\n')) !== -1) { | ||
| const frame = buffer.slice(0, sepIndex); |
There was a problem hiding this comment.
🟠 MEDIUM · API_CONTRACT
CRLF를 사용하는 유효한 SSE 프레임을 처리하지 못합니다
SSE 이벤트 구분자를 \n\n으로만 찾고 있어 \r\n\r\n으로 전송되는 표준 SSE 응답은 분리되지 않습니다. 이런 서버에서는 데이터가 계속 buffer에 쌓이기만 하고 onMessage가 한 번도 호출되지 않습니다.
CRLF와 LF를 모두 인식하도록 프레임 경계를 파싱해야 합니다.
| signal: controller.signal, | ||
| }); | ||
| if (!response.ok || !response.body) throw new Error(`stream ${response.status}`); | ||
| retryMs = INITIAL_RETRY_MS; // 연결에 성공하면 백오프를 초기화한다 |
There was a problem hiding this comment.
🟠 MEDIUM · ERROR_HANDLING
복구 불가능한 4xx 응답도 무한히 재요청합니다
모든 non-2xx 응답을 동일한 재시도 오류로 변환하므로 존재하지 않는 실행의 404, 인증 실패인 401·403 같은 영구 오류도 구독을 해제할 때까지 5초마다 계속 요청합니다. 일시적 서버·네트워크 오류와 복구 불가능한 응답을 구분해야 합니다.
inryeok-bot HIGH 1건 + MEDIUM 3건 전부 반영:
1. HIGH·REGRESSION — reader.read() 가 done 을 반환하면 종료 상태인지
확인 없이 곧바로 재연결을 멈췄다. keep-alive 제한 등으로 서버·프록시가
스트림을 정상 EOF로 끊어도 영구히 멈춰, 이후 상태 변화가 화면에 반영
안 됐다.
→ 실제로 종료 상태(완료·실패·취소) 프레임을 받았을 때만 멈추고, 그
외 EOF는 재연결 루프로 이어지게 함.
2. MEDIUM·RESOURCE_LEAK — mock 스트림이 이미 완료된 실행에 뒤늦게
연결되면, 초기 push() 의 clearInterval 이 아직 정의 안 된 intervalId
를 지우려다 실패하고 그 직후 새 interval 이 등록돼 영원히 남았다.
→ interval 을 먼저 등록한 뒤 push() 를 호출하도록 순서를 바꿈.
3. MEDIUM·API_CONTRACT — SSE 프레임 구분을 \n\n 으로만 찾아 CRLF(\r\n\r\n)
로 보내는 표준 SSE 응답은 파싱하지 못했다.
→ 청크를 디코드할 때 CRLF·CR 을 LF 로 정규화한 뒤 프레임 경계를 찾음.
4. MEDIUM·ERROR_HANDLING — 404·401·403 같은 복구 불가능한 응답도 5초
백오프로 무한 재시도했다.
→ 4xx 응답은 재시도 없이 곧바로 구독을 끝냄.
테스트 4건 추가(EOF 재연결, CRLF 파싱, 4xx 미재시도, 완료된 실행에
뒤늦게 연결 시 프레임 1개 후 스트림 종료).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
배경
기명서 Execution "실행 상태 전달": "Server → Web 실시간 갱신은 SSE를 사용한다. 연결이 끊기면 재연결 후 현재 상태를 다시 조회해 화면을 복구한다." — 인데 지금까지는 600ms 간격 폴링으로 구현돼 있었다(코드에 "SSE 우선 검토, 지금은 폴링"이라는 의도적 보류 코멘트가 남아있었음).
변경
features/execution/lib/executionStream.ts(신규): SSE 구독 함수. 네이티브EventSource대신fetch+ReadableStream을 직접 읽는다 — 테스트 환경(jsdom)엔EventSource가 없어 폴리필 없이는 검증할 수 없고,fetch기반이라야 MSW로 그대로 테스트할 수 있다. 연결이 끊기면 지수 백오프(500ms~5s)로 재연결한다.mocks.ts:GET /api/executions/:id/stream추가 — 연결 즉시 현재 상태부터 보내고(재연결 시 "현재 상태를 다시 조회"를 만족), 상태가 실제로 바뀔 때만 push. 종료 상태에서 스트림을 닫아 재연결하지 않는다.hooks.ts:useExecutionState가 폴링(refetchInterval) 대신 SSE 구독으로 쿼리 캐시를 갱신. 초기 1회 REST 조회는 유지(마운트 직후·refetch()즉시 호출용).테스트
executionStream.test.ts(신규): 초기 상태 즉시 수신, 상태 전환 push 확인(대기→배정→진행→완료), 완료 시 스트림 종료, 취소 시 프레임 수신typecheck/lint/build/format:check클린🤖 Generated with Claude Code