Skip to content

feat: 명세 불일치 — 실행 상태 전달을 폴링에서 SSE로 전환 - #41

Merged
wook10 merged 2 commits into
mainfrom
feat/execution-status-sse
Sep 16, 2026
Merged

wook10 merged 2 commits into
mainfrom
feat/execution-status-sse

Conversation

@wook10

@wook10 wook10 commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

배경

기명서 Execution "실행 상태 전달": "Server → Web 실시간 갱신은 SSE를 사용한다. 연결이 끊기면 재연결 후 현재 상태를 다시 조회해 화면을 복구한다." — 인데 지금까지는 600ms 간격 폴링으로 구현돼 있었다(코드에 "SSE 우선 검토, 지금은 폴링"이라는 의도적 보류 코멘트가 남아있었음).

참고: 이 커밋은 원래 PR #40에 같이 있었는데, PR #40이 이 커밋이 들어가기 전에 먼저 머지돼서 별도 PR로 뺐습니다.

실제 백엔드가 없어 SSE 계약(이벤트 포맷 등)을 알 수 없는 상태에서 진행한 것이라, 나중에 실제 Poppy-Server가 정해지면 프레임 형식을 다시 맞춰야 할 수 있습니다.

변경

  • 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 확인(대기→배정→진행→완료), 완료 시 스트림 종료, 취소 시 프레임 수신
  • 전체 93/93 통과, typecheck/lint/build/format:check 클린
  • CDP로 실제 브라우저에서 폴링 없이 대기→배정→진행(취소 불가)→완료 전환이 SSE push 로만 이루어짐을 확인

🤖 Generated with Claude Code

기명서 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>

@inryeok-bot inryeok-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리뷰 결과

변경된 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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH · REGRESSION

종료 상태가 아닌 EOF에서도 구독이 영구 종료됩니다

reader.read()가 done을 반환하면 실행 상태를 확인하지 않고 즉시 connect()를 끝냅니다. 서버·프록시가 keep-alive 제한이나 일시적인 연결 문제로 스트림을 정상 EOF로 닫는 경우에도 재연결되지 않아, 이후 상태 변경이 화면에 반영되지 않습니다.

마지막 수신 상태가 terminal인지 추적하여 terminal일 때만 반환하고, 그 외 EOF는 바깥 재연결 루프로 이어지게 해야 합니다.

}
};
push();
intervalId = setInterval(push, STREAM_CHECK_MS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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; // 연결에 성공하면 백오프를 초기화한다

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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>
@wook10
wook10 merged commit 6bfb32a into main Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant