문제의 핵심은 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_join은list.each_with_index내에서visit(x, collector)를 재귀적으로 호출하여,OR노드가 다른OR노드를 포함할 때마다 재귀 깊이가 증가했습니다.
트리 구조 문제
Active Record의 `PredicateBuilder
grouping_queries에서 reduce 호출이 문제의 원인이었습니다. 이 reduce는 Or([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회로 늘리지만, 쿼리가 더 단순하고 효율적이며, 메모리 필터링 오버헤드는 미미했습니다.