Cheondi
개발 · 알고리즘·CS

새로고침을 눌렀는데 호출 스택이 넘쳤다

상태 변경 이벤트가 서로를 다시 호출하며 생긴 재귀 문제를 호출 흐름으로 추적한 기록입니다.

  • #recursion
  • #event
  • #debugging

새로고침 버튼을 누른 뒤 앱이 멈추는 문제가 있었다. 네트워크 요청이 오래 걸린 줄 알았는데 로그에는 같은 메서드 이름이 빠르게 반복됐고 마지막에는 호출 스택 관련 오류가 남았다. 직접 자기 자신을 호출하는 코드는 없어서 처음에는 재귀라고 생각하지 못했다.

흐름을 따라가니 목록 새로고침이 선택 값을 바꾸고, 선택 값 변경 이벤트가 다시 목록 새로고침을 호출하고 있었다. 두 메서드가 이벤트를 사이에 두고 서로를 부르는 간접 재귀였다.

호출 관계를 한 줄씩 적었다

로그를 많이 추가하기보다 진입 경로를 먼저 그렸다.

RefreshList()
 -> SelectFirstItem()
   -> onSelectionChanged
     -> RefreshDetail()
       -> RefreshList()

각 함수만 보면 자연스러운 행동이었다. 연결했을 때 원래 위치로 돌아오는 고리가 생겼다. 이벤트 기반 코드도 결국 호출 그래프로 볼 수 있다는 걸 알았다.

값 변경과 알림을 분리했다

목록을 다시 그리며 첫 항목을 내부적으로 선택하는 것과, 사용자가 선택을 바꾼 것은 의미가 달랐다. 내부 초기화에서는 알림을 발생시키지 않고 마지막에 필요한 갱신을 한 번만 호출했다.

void SelectWithoutNotify(Item item)
{
    selectedItem = item;
    selectionView.SetSelected(item);
}

void RefreshList()
{
    RenderItems();
    SelectWithoutNotify(items.FirstOrDefault());
    RefreshDetail();
}

UI 컴포넌트가 제공하는 SetValueWithoutNotify 같은 기능도 같은 목적이었다.

방어 플래그는 원인을 숨길 수 있었다

처음에는 isRefreshing을 두고 재진입하면 return하도록 막았다. 급한 멈춤은 해결했지만 왜 다시 들어왔는지 모르면 필요한 갱신까지 건너뛸 수 있었다. 플래그는 비동기 중복 요청을 막는 데는 유용하지만 잘못된 호출 고리를 고치는 대신 쓰면 흐름이 더 불투명해졌다.

그래서 이벤트의 의미를 분리한 뒤에도 외부에서 새로고침을 여러 번 요청할 수 있는 구간에만 재진입 방어를 남겼다.

스택 오류보다 앞의 첫 반복을 봤다

오류가 발생한 마지막 줄은 이미 같은 호출이 수백 번 쌓인 뒤였다. 로그의 끝만 보면 원인을 찾기 어려웠다. 처음 동일한 흐름이 반복되는 지점과 그 직전에 어떤 상태가 바뀌었는지를 보는 편이 빨랐다.

이 일 이후 이벤트가 많은 화면에서 문제가 나면 개별 함수뿐 아니라 누가 누구를 깨우는지 확인한다. 직접 재귀가 없어도 상태 변경과 이벤트가 고리를 만들 수 있었다. 호출 스택 오류는 결과였고, 원인은 서로 다른 책임의 이벤트를 같은 동작으로 처리한 데 있었다.