Rack::Attack 스로틀이 StringIO를 되감지 않고 읽어 발생한 Ruby JSON::ParserError 디버깅 및 해결

Why Rack:Request's Body Returns an Empty String (and How to Fix It)

작성자
HackerNews
발행일
2025년 09월 08일

핵심 요약

  • 1 Rack::Attack 스로틀에서 `req.body`를 `StringIO` 객체로 처리할 때, 커서 위치를 재설정하지 않아 `JSON::ParserError`가 발생하는 문제를 해결했습니다.
  • 2 `req.body`는 `StringIO` 객체이므로, 다른 미들웨어 또는 애플리케이션 코드에서 이미 읽었을 경우 커서가 이동하여 후속 읽기 시 빈 문자열을 반환할 수 있습니다.
  • 3 `StringIO`의 `rewind` 메서드를 사용하여 읽기 전후에 커서를 0으로 재설정하는 `safe_read` 헬퍼를 도입하여 일관성 없는 `IO` 상태 관리 문제를 해결했습니다.

도입

Jortt 애플리케이션에서 `JSON::ParserError`가 무작위로 발생하는 문제가 발견되었습니다. 이 오류는 `Rack::Attack` 스로틀에서 `req.body`를 파싱하는 과정에서 발생했으며, 스택 트레이스는 `JSON::ParserError: unexpected token at ''`를 가리켰습니다. 문제의 근원은 `Rack::Request`의 `body`가 `StringIO` 객체이며, 이 객체의 내부 커서가 예상치 못한 위치에 있어 후속 읽기 시 빈 문자열을 반환하게 되는 것이었습니다. 본 글에서는 이 문제를 디버깅하고 해결한 과정을 설명합니다.

문제의 발단: Rack::Attack 스로틀과 StringIO

  • Rack::Attack 스로틀은 요청을 분류하고 제한하는 데 사용되며, req.body.read를 통해 요청 본문을 읽어 JSON.parse를 시도합니다.

  • 초기 코드에서는 JSON.parse(req.body.read) 이후에 req.body.rewind가 있었으나, JSON.parse 이전에 커서 위치를 확인한 결과, 커서가 0이 아닌 3281과 같은 위치에 있음을 확인했습니다.

  • 이는 req.body.read가 빈 문자열을 반환하여 JSON::ParserError를 유발했습니다.

StringIO의 특성과 커서 관리

  • req.bodyStringIO 객체로, 내부적으로 커서를 사용하여 버퍼를 읽습니다. 이 커서는 수동으로 rewind 메서드를 호출하여 초기 위치(0)로 되돌려야 합니다.

  • StringIO의 가변 커서(movable cursor)는 스트리밍 및 청크 전송, 메모리 효율성, 점진적 파싱 등 특정 시나리오에서 유용하지만, 본 애플리케이션에서는 IO 상태 캡슐화 부족으로 인해 문제를 야기했습니다.

커서 이동의 원인

  • jortt-app 코드베이스를 분석한 결과, 여러 컨트롤러에서 req.body.read를 사용하고 있었으며, 일부는 rewind를 사용했지만 다른 곳에서는 전혀 사용하지 않아 StringIO 커서가 중간에 멈춰 있는 경우가 발생했습니다.

  • 이러한 일관성 없는 IO 접근 방식이 Rack::Attack 스로틀과 같은 다운스트림 소비자에게 영향을 미쳐 JSON::ParserError를 유발했습니다.

해결책: BufferReader 헬퍼 모듈 도입

  • StringIO 객체의 안전한 읽기를 보장하기 위해 Jortt::BufferReader 모듈에 safe_read 헬퍼 메서드를 추가했습니다.

  • safe_readreadable 객체가 rewind 메서드를 가지고 있는지 확인하고, 읽기 전과 후에 rewind를 호출하여 커서 위치를 항상 0으로 재설정합니다.

  • 이 헬퍼를 req.body를 직접 읽는 모든 곳에 적용하여 일관된 IO 상태 관리를 확립했습니다.

결론

무작위로 발생하던 `JSON::ParserError`는 `req.body`를 불변(immutable) 문자열이 아닌 가변(mutable) `StringIO` 객체로 제대로 인지하지 못해 발생한 문제였습니다. 여러 컴포넌트가 일관된 `rewind` 전략 없이 동일한 스트림을 읽으면서 `StringIO` 커서가 중간에 남게 되었고, 이는 `Rack::Attack` 스로틀과 같은 후속 소비자가 빈 문자열을 파싱하게 만들었습니다. `BufferReader` 헬퍼를 도입하여 읽기 전후에 항상 `rewind`를 수행함으로써, 불필요한 `JSON` 파싱 오류를 제거하고 안전한 본문 접근을 위한 통일된 인터페이스를 제공하여 유사한 버그 발생 위험을 줄였습니다.

댓글 0

댓글 작성

0/1000
정중하고 건설적인 댓글을 작성해 주세요.

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!