새로고침 버튼을 누른 뒤 앱이 멈추는 문제가 있었다. 네트워크 요청이 오래 걸린 줄 알았는데 로그에는 같은 메서드 이름이 빠르게 반복됐고 마지막에는 호출 스택 관련 오류가 남았다. 직접 자기 자신을 호출하는 코드는 없어서 처음에는 재귀라고 생각하지 못했다.
흐름을 따라가니 목록 새로고침이 선택 값을 바꾸고, 선택 값 변경 이벤트가 다시 목록 새로고침을 호출하고 있었다. 두 메서드가 이벤트를 사이에 두고 서로를 부르는 간접 재귀였다.
호출 관계의 선형화
로그를 많이 추가하기보다 진입 경로를 먼저 그렸다.
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하도록 막았다. 급한 멈춤은 해결했지만 왜 다시 들어왔는지 모르면 필요한 갱신까지 건너뛸 수 있었다. 플래그는 비동기 중복 요청을 막는 데는 유용하지만 잘못된 호출 고리를 고치는 대신 쓰면 흐름이 더 불투명해졌다.
그래서 이벤트의 의미를 분리한 뒤에도 외부에서 새로고침을 여러 번 요청할 수 있는 구간에만 재진입 방어를 남겼다.
스택 오류 이전의 최초 반복
오류가 발생한 마지막 줄은 이미 같은 호출이 수백 번 쌓인 뒤였다. 로그의 끝만 보면 원인을 찾기 어려웠다. 처음 동일한 흐름이 반복되는 지점과 그 직전에 어떤 상태가 바뀌었는지를 보는 편이 빨랐다.
이 일 이후 이벤트가 많은 화면에서 문제가 나면 개별 함수뿐 아니라 누가 누구를 깨우는지 확인한다. 직접 재귀가 없어도 상태 변경과 이벤트가 고리를 만들 수 있었다. 호출 스택 오류는 결과였고, 원인은 서로 다른 책임의 이벤트를 같은 동작으로 처리한 데 있었다.
공식 문서 기반의 추가 정리
나중에 같은 증상을 다시 만나지 않으려고 문서에 적힌 기준으로 구현을 한 번 더 정리했다.
RFC 9110은 HTTP 메서드의 의미와 멱등성을 구분한다. 같은 요청을 다시 보내도 의도한 서버 효과가 한 번과 같아야 멱등하다고 말할 수 있다. 클라이언트에서 버튼을 잠그는 것은 중복 조작을 줄이지만, 타임아웃 뒤 재전송이나 다른 기기 요청까지 막아 주지는 않는다.
또 상태 코드만으로 화면 상태를 모두 결정하기보다 응답이 어떤 요청과 선택 조건에 대한 것인지 확인해야 한다. 네트워크 성공과 현재 화면에 적용 가능한 응답은 다른 판단이다.
다음에 새로고침 재귀와 호출 스택 초과 같은 문제를 보면 아래 순서부터 확인하려고 한다.
- 재귀 호출 대신 명시적 반복과 상한
- 동시에 실행되는 갱신 작업의 단일 소유자
- 이전 세대 응답을 버리는 번호
- 중단 뒤 사용자가 다시 시작하는 경로
재시도는 횟수보다 같은 요청을 식별하는 키와 서버의 중복 처리 규칙이 먼저 정해져야 한다.
공식 참고 자료
- RFC 9110 HTTP Semantics — HTTP 메서드·상태 코드·멱등성 의미