
streamPartialJson에서 해결된 최상위 null 값이 누락된 문제 (stream_struct 0.3.2)
요약
stream_struct 0.3.2 버전에서 streamPartialJson 함수가 최상위 null 값을 정상적으로 처리하지 못하던 버그를 해결했습니다. 기존에는 null 값을 '준비되지 않은 상태'로 오인하여 건너뛰었으나, 이제는 값의 존재 여부를 별도로 확인하여 정확히 방출합니다.
핵심 포인트
- 최상위 null 값이 포함된 JSON 스트림 파싱 오류 수정
- parsePartialJsonResult 레코드 타입을 도입하여 값의 존재 여부(hasValue) 구분
- 중첩된 null은 기존과 동일하게 정상 작동함
- 구조화된 답변에서 null 허용 결과나 선택적 도구 출력을 정확히 처리 가능
stream_struct는 모델로부터 전달받은 텍스트 델타 (deltas) 스트림을 파싱된 JSON 값의 스트림으로 변환합니다. 진입점은 streamPartialJson입니다:
Stream<Object?> streamPartialJson(Stream<String> deltas)
이 함수는 델타 (deltas)를 버퍼 (buffer)에 축적하며, 각 델타가 들어올 때마다 지금까지의 버퍼를 파싱하려고 시도합니다. 버퍼 파싱에 성공하면 해당 값을 방출 (emit)합니다. 버퍼를 파싱 불가능한 상태로 만드는 델타 (deltas) (예: 절반만 작성된 토큰 또는 객체)나, 파싱된 값이 마지막으로 방출된 값과 일치하는 델타 (deltas)는 건너뜁니다. 리스너 (listener)는 델타 (deltas)가 도착함에 따라 객체가 커지는 것을 확인하게 됩니다.
여기까지는 잘 작동했습니다. 버그는 루프 (loop)가 특정 델타 (delta)가 값을 생성했는지 결정하는 방식에 있었습니다.
버그 (The bug)
streamPartialJson은 내보내기(exported)된 헬퍼 (helper) 함수인 parsePartialJson을 기반으로 구축되었습니다:
Object? parsePartialJson(String buffer)
parsePartialJson은 Object?를 반환하며, 다음 두 가지 서로 다른 상황에서 null을 반환합니다:
- 아직 디코딩 (decodable)할 수 있는 것이 없음. 버퍼 (buffer)가 `
0.3.1 버전에서는 첫 번째 델타 (delta)인 "nu"가 파싱되지 않으므로, parsePartialJson이 null을 반환하고 루프 (loop)가 이를 건너뛰는데 이는 올바른 동작입니다. 두 번째 델타가 버퍼 (buffer)를 null로 완성하면, 이는 null로 파싱되며 루프는 이를 "준비되지 않음"으로 읽고 역시 건너뜁니다. 스트림 (stream)은 빈 리스트로 종료됩니다.
이것이 중요한 이유
모델의 구조화된 답변 (structured answer)은 정당하게 최상위 null이 될 수 있습니다. 예를 들어, null 허용 (nullable) 결과, 선택적 도구 출력 (optional tool output), 또는 명시적인 "값 없음" 등이 이에 해당합니다. 해당 답변을 위한 프레임 (frame)을 기다리는 소비자 (consumer)는 영원히 기다리게 되는데, 그 이유는 값을 전달해야 할 단 하나의 프레임이 루프에 의해 건너뛰어졌기 때문입니다.
범위: 오직 최상위 레벨에만 해당됨
객체 (object)나 배열 (array) 내부에 중첩된 (nested) null은 전혀 영향을 받지 않았습니다. 컨테이너 (container)가 null이 아닌 값으로 파싱되므로, 루프는 이를 방출 (emit)하고 중첩된 null은 그 안에 포함되어 함께 전달됩니다.
Stream<String> obj() async* {
yield '{"a":';
yield 'null}';
...
부분적 버퍼 (partial buffer) {"a":는 첫 번째 프레임으로 빈 맵 (map)을 생성합니다. 두 번째 델타가 객체를 {"a": null}로 완성하면, 이는 {a: null}로 파싱되어 두 번째 프레임으로 방출됩니다. 두 프레임 모두 null이 아닌 맵이므로, value == null 체크가 실행되지 않았습니다. 이 버그는 최상위 값 자체가 null이어야만 발생했습니다.
수정 사항
0.3.2 버전에서는 값 (value)과 별개로 존재 여부 (presence)를 전달하는 내부 레코드 (record) 타입을 추가했습니다. parsePartialJsonResult는 레코드 (bool hasValue, Object? value)를 반환합니다. "아직 디코딩 (decodable)할 수 있는 것이 없음"은 hasValue가 false인 경우입니다. "null로 디코딩됨"은 hasValue가 true이고 value가 null인 경우입니다.
lib/src/streaming.dart의 디프 (diff):
// 수정 전
final value = parsePartialJson(buffer.toString());
if (value == null) continue;
...
이제 루프는 델타가 값을 생성했는지 결정하기 위해 hasValue를 확인하고, 값 자체는 result.value를 읽습니다. 디코딩된 null은 hasValue가 true이므로 체크를 통과하고, 변경 감지 (change detection)를 거쳐 yield에 도달합니다.
공개 API (public API)는 변경되지 않았습니다
parsePartialJson은 기존의 Object? 계약을 정확히 유지합니다. 새로운 parsePartialJsonResult와 그 PartialJsonResult 타입은 @internal이며 외부로 노출(export)되지 않습니다. 공개 인터페이스(public surface)에서 변경된 사항이 없으므로, 0.3.2는 0.3.1에서 패치 버전(patch bump)으로 업데이트되었습니다.
출시된 테스트 (Tests that shipped)
0.3.2의 회귀 테스트 (regression tests)는 다음 사항을 포함합니다:
- 두 개의 델타 (deltas)에 걸쳐 스트리밍되는 최상위
null이 단일[null]프레임을 방출하는지 확인, - 하나의 델타에 도착하는 최상위
null또한[null]을 방출하는지 확인, - 일반적인 객체 성장 (object growth) 및 해결되지 않는 버퍼 (never-resolving buffer)가 이전과 동일하게 동작하는지 확인.
근본 원인: null의 이중 역할
null이 동일한 값에 대해 두 가지 의미를 동시에 지니고 있었습니다. parsePartialJson의 반환 값으로서 null은 "아직 준비되지 않음"을 의미했고, 디코딩된 JSON 값으로서 null은 "답이 null임"을 의미했습니다. Object?는 이 둘을 구분할 수 없습니다. 왜냐하면 이들은 동일한 런타임 (runtime) 값이기 때문이며, 따라서 이들을 구분해야 하는 호출자(caller)는 난처한 상황에 처하게 됩니다.
해결책은 존재 여부 (presence)를 위한 별도의 필드를 제공하는 것입니다. (bool hasValue, Object? value) 레코드 (record)는 "값이 아예 존재하는가"를 나타내는 hasValue 필드와 "그 값이 무엇인가"를 나타내는 별도의 value 필드를 가집니다. 이를 통해 "아직 값이 없음"과 "값이 null임"이 하나의 표현을 공유하는 문제를 해결했습니다. 이는 Option 타입이나 두 개의 필드를 가진 모든 결과값과 동일한 형태입니다. 반환하는 센티널 (sentinel) 값이 유효한 값일 수도 있는 경우에는 언제든 이러한 방식을 사용하는 것이 좋습니다. 여기서는 둘 다 null이었기 때문에 충돌이 발생했던 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기