Rails 7.2.1.1 업그레이드 후 발생한 SystemStackError: stack level too deep 디버깅

Debugging a Stack Overflow in Rails 7.2.1.1

작성자
발행일
2025년 11월 25일

핵심 요약

  • 1 Rails 7.2.1.1에서 복합 키 쿼리의 `where.not` 사용 시, `PredicateBuilder`가 깊게 중첩된 Arel OR 노드 트리를 생성하여 `SystemStackError`를 유발했습니다.
  • 2 Rails 7.1.5.2의 반복적 Arel 노드 방문 방식과 달리, 7.2.1.1의 재귀적 방문 방식이 대량의 OR 조건에서 스택 오버플로우를 초래했습니다.
  • 3 Async 파이버는 Thread보다 스택 공간이 적어 재귀 깊이 제한에 더 민감했으며, Rails 7.2.2+에서 Arel 노드 구조를 평탄화하는 패치가 적용되어 문제가 해결되었습니다.

도입

NeetoCal은 Rails 7.1.5.2에서 7.2.1.1로 업그레이드한 후 `SystemStackError: stack level too deep`이라는 알 수 없는 프로덕션 크래시를 겪었습니다. 이 문제는 `Async` 젬을 사용하여 여러 캘린더를 동시에 동기화하는 서비스 내에서 발생했으며, 처음에는 `Async` 관련 문제로 오해했습니다. 스택 트레이스는 `visit_Arel_Nodes_Or` 메서드에서 1,000레벨 이상 깊은 재귀를 지목하며, 복합 키 쿼리 처리 방식의 변화가 원인으로 지목되었습니다.

문제의 핵심은 Integrations::Icloud::SyncEventsService 내의 abandoned_events 메서드에서 사용된 where.not([:url, :recurrence_id] => pairs) 구문이었습니다. 프로덕션 환경에서 pairs 변수가 233개의 항목을 포함할 때, Rails는 다음과 같은 SQL을 생성합니다: sql WHERE NOT ( (url = 'url1' AND recurrence_id = 'rec1') OR (url = 'url2' AND recurrence_id = 'rec2') OR -- ... 230 more OR clauses ) 데이터베이스 자체는 이러한 많은 OR 절을 처리할 수 있었지만, 문제는 Rails가 내부적으로 이 쿼리를 표현하는 방식에 있었습니다.

Rails 내부 변경 사항

  • Rails 7.1.5.2: visit_Arel_Nodes_Or 메서드는 수동 스택을 사용하는 반복적인 접근 방식을 채택하여, OR 조건의 중첩 깊이와 관계없이 스택 프레임을 하나만 사용하여 스택 오버플로우에 안전했습니다.

  • Rails 7.2.1.1: 이 메서드는 inject_join을 호출하는 재귀적인 방식으로 변경되었습니다. inject_joinlist.each_with_index 내에서 visit(x, collector)를 재귀적으로 호출하여, OR 노드가 다른 OR 노드를 포함할 때마다 재귀 깊이가 증가했습니다.

트리 구조 문제

Active Record의 `PredicateBuilder

grouping_queries에서 reduce 호출이 문제의 원인이었습니다. 이 reduceOr([Or([Or([Query1, Query2]), Query3]), Query4])와 같이 왼쪽으로 깊게 중첩된 트리 구조를 생성했습니다. 233개의 pairs`는 232단계의 중첩을 만들었고, 각 단계는 약 5개의 스택 프레임을 추가하여 총 1,160개의 스택 프레임을 소비했습니다.

Async와 Thread의 차이

Thread.new는 작동했지만 Async::Barrier는 실패한 이유는 스택 공간의 차이 때문이었습니다.

  • Thread.new의 재귀 한계: 약 11910 호출

  • Async 파이버의 재귀 한계: 약 1482 호출 Async 파이버는 스택 공간이 Thread.new보다 약 8배 적기 때문에, 233개의 pairs가 요구하는 약 1,160개의 스택 프레임은 Async 파이버의 한계에 근접하여 오류를 발생시켰습니다. Thread.new는 더 큰 스택 공간 덕분에 일시적으로 문제를 회피할 수 있었으나, 500개의 pairs에서는 Thread.new조차 실패했습니다.

GitHub를 통한 해결책 발견

이 문제는 Rails 7.2.2+에서 PR #53032를 통해 해결되었습니다. `PredicateBuilder

grouping_queries에서 queries.reduce { … } 부분을 Arel::Nodes::Or.new(queries)로 변경하여, 깊게 중첩된 구조 대신 Or([Query1, Query2, …, Query233])`와 같은 평탄한 구조를 생성하도록 수정되었습니다. 이로써 재귀 깊이가 단 1단계로 줄어들어 스택 오버플로우를 방지했습니다.

임시 해결책

Rails 7.2.1.1을 즉시 업그레이드하기 어려운 상황에서, 팀은 다음과 같은 임시 해결책을 구현했습니다.

  • 복잡한 OR 조건 대신 간단한 IN 절 사용.

  • 메모리에서 Set을 사용하여 정확한 pairs를 빠르게 필터링.

  • id를 기준으로 제외하여 평탄한 목록 유지.

  • 깊게 중첩된 Arel 노드 생성을 회피. 이 방법은 쿼리 횟수를 1회에서 2회로 늘리지만, 쿼리가 더 단순하고 효율적이며, 메모리 필터링 오버헤드는 미미했습니다.

결론

Rails 7.2.1.1 업그레이드 후 발생한 `SystemStackError`는 `Active Record`의 복합 키 쿼리 처리 방식 변화와 `Arel` 노드 방문자의 재귀적 구현이 결합되어 발생한 문제였습니다. 특히 `Async` 파이버와 같이 스택 공간이 제한적인 환경에서 이 문제가 두드러졌습니다. Rails 7.2.2+에서 `PredicateBuilder`의 `Arel` 노드 생성 방식이 평탄화되어 근본적으로 해결되었으므로, Rails 7.2.0부터 7.2.1.x 버전을 사용하는 대규모 데이터셋의 복합 키 쿼리 사용자들은 이 문제를 인지하고 업그레이드하거나 임시 해결책을 적용해야 합니다. 이 사례는 프레임워크 내부 구현 변경이 애플리케이션 성능 및 안정성에 미치는 영향을 명확히 보여줍니다.

댓글 0

로그인이 필요합니다

댓글을 작성하거나 대화에 참여하려면 로그인이 필요합니다.

로그인 하러 가기

아직 댓글이 없습니다

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