Trawling
발견 출처 dev.to/발행

I Throttled My App to Slow 3G. Here's What My Tests Never Caught

앱을 3G로 스로틀링해 봤더니 — 테스트가 잡지 못한 버그들이 드러났다

저자가 Chrome DevTools로 네트워크를 3G로 스로틀링해 평소 Wi-Fi에서는 문제없던 앱을 검증한 결과, 테스트를 모두 통과한 코드에서 정확성 버그 두 개를 발견했습니다. 검색 요청의 레이스 컨디션과 자동저장의 클라이언트 타임아웃 재시도 문제로, 둘 다 빠른 네트워크에서는 드러나지 않는 전형적인 순서·타이밍 가정 오류입니다.

본문

저자는 성능 문제를 찾으려던 것이 아니라, 네트워크가 충분히 느려졌을 때 평소에는 눈치채지 못하던 가정들이 드러나는지 확인하고 싶었다고 합니다. Chrome DevTools의 스로틀링을 3G로 설정하고(참고로 Chrome의 네트워크 스로틀링 프리셋은 별도의 'Slow 3G'를 없애고 Slow 4G와 Offline 사이에 '3G'만 둡니다) 평소 Wi-Fi에서는 한 번도 오작동하지 않던 앱을 클릭해 봤더니, '느리다'는 불만이 아니라 정확성 버그 두 개가 깨졌다고 합니다. 둘 다 작성한 모든 테스트를 통과한 코드에 숨어 있었습니다.

검색 요청의 레이스 컨디션

앱에는 검색창이 있고, 쿼리를 입력하면 fetch로 결과를 받아 화면에 표시하는 단순한 구조입니다. 문제는 응답이 도착하는 순서를 아무도 보장하지 않는다는 점입니다. 빠른 Wi-Fi에서는 요청과 응답 시간이 충분히 가까워서 순서가 뒤집히는 일이 드물지만, 그것은 보장이 아니라 낮고 일정한 지연이 대부분의 경우 보여주는 모습일 뿐이라고 저자는 지적합니다. 연결을 느리게 만들면 이 가정이 무너집니다. 'apple'을 입력하고 잠시 후 'banana'로 바꾸면 두 요청이 동시에 in-flight 상태가 되고, 더 오래 걸린 쪽이 나중에 도착해 사용자가 이미 보고 있지 않은 쿼리의 결과로 화면을 덮어씁니다.

재현을 위해 저자는 서버 쪽에 인위적인 지연을 넣어 결정적으로 만들었습니다. apple은 3초, banana는 200ms로 설정하고 스로틀링 없이 테스트해 로그 수치가 서버 지연만 반영되도록 했습니다. 로그를 보면 banana 응답이 210ms에 먼저 도착하고 apple 응답이 3,001ms에 나중에 도착해, 화면에는 결국 사용자가 마지막으로 관심 있던 것이 아니라 마지막에 도착한 apple의 결과가 남습니다.

수정은 도착 순서를 신뢰하지 않고 어떤 요청이 실제로 최신인지 추적하는 것입니다. latestQueryId 카운터를 두고 각 요청에 증가하는 ID를 부여한 뒤, 응답이 돌아왔을 때 자신의 ID가 최신이 아니면 결과를 무시합니다. 같은 요청 순서에서도 늦게 도착한 apple(id 1)의 응답은 'a newer request has been sent'로 무시됩니다. 다른 방법으로 AbortController로 이전 fetch를 중단하는 것도 가능하지만, 저자는 이것이 같은 메커니즘의 다른 이름이 아니라 다른 트레이드오프라고 구분합니다. abort는 클라이언트가 그 응답을 기다리는 것을 멈출 뿐, 서버가 이미 시작한 작업까지 멈추지는 않습니다. 검색 엔드포인트가 저렴하고 서버 쪽 취소가 필요 없어서 저자는 늦은 응답이 이기지 못하게만 하는 요청 ID 방식을 택했다고 합니다.

자동저장의 타임아웃 재시도 버그

두 번째 버그는 왕복이 항상 빠르다고 가정한 클라이언트 타임아웃에서 나옵니다. 자동저장은 1.2초 안에 응답이 없으면 실패로 간주하고 재시도했는데, 저장이 보통 수백 밀리초면 합리적인 숫자입니다. 그러나 스로틀링된 연결에서는 아무 이상 없이도 저장이 2~3초 걸릴 수 있습니다. 클라이언트는 '느림'과 '실패'를 구분하지 못하고 타이머가 끝나면 최악을 가정해, 첫 번째 저장이 in-flight 상태로 곧 성공할 참인데 두 번째 저장을 쏩니다. 로그에는 save #1 타임아웃 후 save #2 전송, 이후 #1과 #2가 모두 confirmed된 것이 찍힙니다. 저장 엔드포인트가 엄격하게 멱등(idempotent)하지 않다면 이것은 무해한 중복이 아니라, 그 사이에 변경된 내용에 따라 다른 결과를 낼 수 있는 두 번의 쓰기가 됩니다.

첫 번째 수정은 in-flight 가드로, 저장이 진행 중이면 새 저장을 건너뛰는 방식이었습니다. 중복은 막지만 다른 버그를 만듭니다. 저장 진행 중에 다시 편집하면 그 편집이 조용히 드롭되어 영원히 전송되지 않습니다. 'hello' 저장 중 'hello world'로 수정하면 'hello world' 편집이 사라지고 'hello'만 저장됩니다.

원하는 동작은 최신 편집을 건너뛰는 것이 아니라 큐에 넣어 현재 저장이 끝난 직후 보내는 것입니다. saveInFlight 플래그와 pendingContent 변수를 두고, 저장 중이면 최신 내용을 pendingContent에 저장하고 반환하며, finally 블록에서 플래그를 해제한 뒤 대기 중인 내용이 있으면 다시 save를 호출합니다. 여기서 try/finally는 스타일이 아니라 필수라고 저자는 강조합니다. postSave가 reject되면 saveInFlight가 영원히 true로 남아 이후 모든 편집이 조용히 큐에만 쌓이고 전송되지 않는, 시작했던 버그보다 더 나쁜 조용한 실패가 되기 때문입니다. 큐에 대기 중 다시 편집하면 pendingContent를 덮어쓰는데, 이것이 의도된 병합(coalescing)으로 가장 최근 편집 하나만 줄을 서게 됩니다. 다만 이 코드는 '느림'은 처리하지만 '실패'는 처리하지 않으므로, 프로덕션 자동저장에는 실제 실패 시 정책도 별도로 필요하다고 덧붙입니다.

교훈

실제 교훈은 '중복 저장을 막아라'가 아니라, 클라이언트 타임아웃은 응답이 아직 도착하지 않았다는 사실만 알려줄 뿐 서버에서 무슨 일이 일어났는지는 알려주지 않는다는 것입니다. 부수 효과가 있는 작업을 재시도하려면 이미 진행 중인 작업을 어떻게 할지 명시적 전략이 필요합니다. 저자는 순서·타이밍 버그 두 개를 찾으려던 것이 아니라, 자기 네트워크가 다른 사람의 네트워크를 대표한다고 가정하는 것을 멈추면 무엇이 드러나는지를 찾았다고 말합니다. 응답은 보통 보낸 순서대로 도착하지만 안 그럴 때가 있고, 몇 초면 보통 저장이 끝나지만 아닐 때도 있습니다. 두 테스트 스위트가 이를 잡지 못한 이유는 둘 다 느린 연결에서 실행된 적이 없기 때문입니다. 개발하던 것과 같은 빠르고 관대한 네트워크에서 실행됐고, 그것이 바로 이 버그들을 숨기는 네트워크입니다. 이 버그들은 예외를 던지지도, 시끄럽게 실패하지도 않고, 정확히 트리거되는 조건을 파고들 때까지 조용히 잘못된 일을 합니다. 응답이 요청 순서대로 온다고 가정하거나 클라이언트 타임아웃을 실패의 증거로 취급하는 코드가 있다면, 아직 오작동을 본 적이 없더라도 이미 신뢰하는 코드 안에 같은 종류의 버그가 있을 가능성이 높다는 것이 저자의 결론입니다.

원문: dev.to / 번역·요약: Trawling