두 버그, 하나의 증상
Source: Dev.to
번역을 진행하려면 번역이 필요한 전체 텍스트를 제공해 주세요. 텍스트를 주시면 원본 형식과 마크다운을 그대로 유지하면서 한국어로 번역해 드리겠습니다.
배경
Raku MCP SDK에서 SSE 클라이언트 전송을 구현하면서 겪은 디버깅 전쟁 이야기입니다.
작업은 간단해 보였습니다: 레거시 SSE 전송을 SDK에 추가하는 것. 서버 측은 순조롭게 진행되었습니다—Cro 덕분에 text/event-stream 응답을 쉽게 푸시할 수 있었습니다. 하지만 클라이언트 측은 오후 내내 고생하게 만들었습니다.
Symptom
is-connected가 영원히 False 상태로 남습니다. 오류도, 예외도, 타임아웃 메시지도 없으며—그냥 아무 일도 일어나지 않습니다.
초기 시도
우리는 대략 다음 순서대로 여러 접근 방식을 시도했습니다:
start { await $client.get(...) }– GET가 해결되는 데 5–10 초 걸림$client.get(...).then(-> $p { ... })–.then콜백도 지연됨react { whenever $resp.body-byte-stream }–whenever가 작동하지 않음Supply.tap(...)– tap 콜백이 지연됨RAKUDO_MAX_THREADS=128– 도움이 되지 않음
각 접근 방식은 단독으로는 잘 작동했지만, 같은 프로세스에서 Cro HTTP 서버가 실행 중일 때는 실패했습니다.
Root Cause 1: Thread‑Pool Starvation
Raku의 start 블록, .then 콜백, 그리고 react/whenever는 모두 단일 ThreadPoolScheduler를 공유합니다. Cro도 동일한 프리미티브를 사용합니다. Cro 서버가 오래 지속되는 SSE 스트림(whenever 블록 내부의 Supply 파이프라인)을 열어 두고, 같은 프로세스 내의 Cro 클라이언트가 HTTP 응답 파이프라인을 해결하기 위해 스케줄러 슬롯이 필요할 때, 두자는 동일한 풀을 놓고 경쟁하게 됩니다. 어느 쪽도 잘못한 것이 아니며, 스타베이션은 자연스럽게 발생하는 현상입니다.
Debug output
SSE-CLIENT: before get
connected=False
connected=False
connected=False
SSE-CLIENT: after get, status=200
GET 요청은 해결되지만, 테스트의 폴링 루프가 이미 포기한 뒤인 10초가 지나서 해결됩니다.
Fix: Escape the Shared Pool
Thread.start는 Raku 스케줄러 밖에서 실제 OS 스레드를 생성합니다. 하지만 await은 Thread.start 내부에서는 동작하지 않으며, 조용히 Nil을 반환합니다. 해결책은 스케줄러 밖에서 Promise를 동기적으로 기다리는 .result를 사용하는 것입니다.
method !connect-sse() {
my $self := self;
my $url := $!url;
Thread.start: {
my $client = (require ::('Cro::HTTP::Client')).new;
my $resp = $client.get($url,
headers => [Accept => 'text/event-stream']).result;
react whenever $resp.body-byte-stream -> $chunk {
$self.handle-sse-chunk($chunk);
}
CATCH { default {} }
}
}
이 변경 후에는 연결이 정상적으로 설정되고, 데이터가 흐르며, 청크가 도착합니다.
Root Cause 2: SSE 파서에서 정규식 공백 처리
SSE 파서는 다음과 같은 라인을 받습니다:
event: endpoint
data: http://...
각 라인을 : 로 나누어 필드 "event" 와 값 " endpoint" 를 얻습니다. SSE 사양에 따르면 콜론 뒤에 있는 하나의 선행 공백은 제거되어야 합니다. 이를 위해 코드는 다음과 같이 시도했습니다:
$value = $value.subst(/^ /, '') if $value.defined;
코드 자체는 올바르게 보이지만, Raku 정규식에서는 공백이 기본적으로 의미가 없습니다. 패턴 /^ / 은 실제로 “문자열 시작을 나타내는 앵커(^)” 뒤에 의미 없는 공백이 오는 것을 의미하며, 리터럴 공백을 의미하지 않습니다. 따라서 subst 는 인덱스 0에서 폭이 0인 위치와 매치되어 아무 것도 교체하지 않고 원래 문자열을 그대로 반환합니다. 결과적으로 이벤트 타입은 선행 공백이 포함된 " endpoint" 로 남아 $!sse-event-type eq 'endpoint' 검사에 실패하고 POST 엔드포인트가 설정되지 않습니다.
위치 수정 후 디버그 출력
HANDLE-CHUNK: empty line, event-type=[ endpoint] data=[ http://127.0.0.1:39652/message]
[ endpoint] 에 있는 선행 공백이 바로 문제의 전부였습니다.
해결 방법: 공백을 명시적으로 제거
정규식을 완전히 피합니다:
$value = $value.substr(1) if $value.defined && $value.starts-with(' ');
이제 이벤트 타입이 올바르게 "endpoint" 로 인식됩니다.
두 버그 간의 상호 작용
Bug 1은 데이터가 제때 도착하지 못하게 하여 Bug 2가 보이지 않았습니다. Bug 1이 수정된 후에도 증상(is-connected가 False 상태를 유지함)은 Bug 2 때문에 지속되었습니다. 시스템은 부분적으로 작동하는 상태에 들어가지 않았으며, 관찰 가능한 동작 변화 없이 “이유 A 때문에 고장”에서 “이유 B 때문에 고장”으로 직접 전환되었습니다.
주요 내용
- Raku 정규식에서 공백은 기본적으로 의미가 없습니다.
/^ /는 실제 공백을 매치하지 않고 문자열의 시작을 매치합니다. 이는 앞쪽 공백을 제거할 때 눈에 띄지 않게 버그를 일으킬 수 있습니다. - 스레드 풀 고갈은 서버와 클라이언트가 단일 프로세스에서 동일한
ThreadPoolScheduler를 공유할 때 발생할 수 있습니다..result와 함께Thread.start를 사용하면 이 문제를 회피할 수 있습니다. - 서버와 클라이언트를 같은 프로세스에서 실행하는 것은 실제 운영 환경(별도 프로세스)에서는 괜찮지만, 테스트에서는 새로운 스케줄링 문제를 드러낼 수 있습니다.
- 방어적 코딩(예: 간단한 작업에 정규식 대신 명시적인 문자열 조작)은 미묘한 함정을 피할 수 있습니다.
@lizmat에게 이 글을 쓰게 된 동기를 제공해 준 것에 감사드립니다.