포스트

"최초 접속 20초 초과" 반려를 두 번 고쳤습니다 — 20초는 로딩이 아니라 화면이 멈추는 시점이었다

우리 PC에서 0.2초 만에 뜨는 앱이 검수에서는 20초를 넘겼다고 반려됐습니다. 첫 번째 원인은 첫 화면에서 바깥으로 나가던 요청이었고, 그걸 고친 뒤에도 또 반려됐습니다. 두 번째 원인은 화면이 뜬 다음에도 몇 초 간격으로 계속 다시 그려지고 있었다는 것이었습니다

"최초 접속 20초 초과" 반려를 두 번 고쳤습니다 — 20초는 로딩이 아니라 화면이 멈추는 시점이었다

Info

Mini apps that paint in 0.2 seconds on our machine were rejected for “cold start over 20 seconds”. The first cause was outbound requests fired during the first paint. After fixing that, they were rejected again — because the first screen kept re-rendering for another ten to twenty seconds as remote data arrived. The lesson: 20 seconds is not when the network finishes, it is when the screen stops changing.

증상

신규 앱 6종을 검수에 넣었는데 하루 동안 아홉 번 반려됐습니다. 사유는 한 줄이었습니다.

미니앱 최초 접속 시간이 20초를 초과

우리 PC에서 재 보면 첫 화면이 0.2초 만에 뜹니다. 폰에서도 그렇습니다. 숫자가 100배 차이 나면 보통 측정 대상이 다른 것입니다.

반려는 요청 후 6~12분에 돌아왔습니다. 사람이 보는 검수가 승인될 때는 14~50분이 걸립니다. 시간대가 다르다는 건 사람이 아니라 자동 검수가 잘라내고 있다는 뜻입니다. 플랫폼 공지에도 시스템 검수 결과가 실제 동작과 다르게 판정될 수 있다는 안내가 있습니다.

지운 가설들

먼저 아닌 것부터 지웠습니다. 다시 파지 않으려고 적어 둡니다.

  • 번들 크기 — 통과하던 앱과 차이가 없었습니다
  • SDK 버전 — 두 버전 모두에서 반려됐습니다
  • 광고 지면 설정, 브릿지 스크립트, 앱정보 승인 여부 — 전부 무관
  • CPU 성능 — 6배 스로틀을 걸어도 가장 긴 작업이 0.6초였습니다
  • 기존 앱이 승인된 이력 — 이건 증거가 아닙니다. 이미 라이브인 앱의 재검수는 5~8초에 통과합니다. 조건이 다릅니다

1차 원인: 첫 화면에서 바깥으로 나간 요청

지우고 나니 하나가 남았습니다. 첫 화면을 그리는 동안 바깥으로 나가는 요청.

검수 환경에서 우리 웹사이트나 외부 CDN이 느리거나 아예 막혀 있다고 가정하면 설명이 됩니다. 타임아웃 없는 요청 하나가 응답을 영영 기다리면 그 페이지의 로딩은 끝나지 않습니다. 앱은 이미 화면에 그려져 있는데도요.

우리 번들에서 첫 로딩 구간에 나가던 것은 세 갈래였습니다.

  1. 크로스 프로모 목록(CSV)과 아이콘 이미지. 앱 하단에 다른 앱을 소개하는 줄입니다. 첫 화면에 7~8줄이 보이는 앱은 아이콘 요청이 8건 나갔고, new Image()에는 타임아웃이라는 개념이 없습니다.
  2. 로또 앱의 회차 데이터 갱신. 호스팅된 CSV를 읽어 오는 코드가 초기화 함수 안에 있어서 부팅과 함께 발사됐습니다.
  3. 광고 SDK. 배너를 붙이는 초기화 호출이 342KB짜리 스크립트를 <script async>로 head에 꽂습니다. async라도 문서의 load 이벤트는 붙잡습니다.

requestIdleCallback은 답이 아니었다

앞선 배포에서 이미 한 번 미뤘었습니다. requestIdleCallback으로 감쌌으니 한가할 때 돌겠지, 하고요.

측정해 보니 idle 콜백이 load보다 먼저 돌고 있었습니다. 150~200ms 지점입니다. 첫 화면이 가벼우니 브라우저 입장에서는 그때가 이미 한가한 시점이었던 겁니다. 요청은 그대로 나갔습니다.

1차 처방

공용 패키지에 함수 하나를 만들고, 바깥으로 나가는 모든 것을 그 뒤로 옮겼습니다.

1
2
3
4
5
6
7
/** window load 이후 extraMs 를 더 기다린 뒤 실행한다.
 *  외부 요청·외부 스크립트 주입은 전부 이 뒤에서. */
export function afterSettled(fn: () => void, extraMs = 5000): void {
  const run = () => setTimeout(fn, extraMs);
  if (document.readyState === 'complete') run();
  else window.addEventListener('load', run, { once: true });
}

광고 부착은 load + 1.5초, 프로모 CSV와 아이콘은 load + 5초(아이콘은 8초 상한을 두고 끊음), 회차 CSV 갱신도 load + 5초로 옮겼습니다. 처방 후 6종 모두 load 140~180ms, 첫 외부 요청은 5.16~5.20초에 나갔습니다.

이걸로 끝인 줄 알았습니다.

그런데 또 반려됐다

네 앱이 같은 사유로 다시 잘렸습니다. 로컬 게이트는 전부 통과한 번들이었습니다.

풀린 건 코드가 아니라 실기기 관찰이었습니다.

"’이런 앱도 있어요’ 영역이 몇 초 간격으로 계속 업데이트돼. 아이콘도 바뀌고 순서도 바뀌고. 20초 넘게도 가.”

화면은 이미 떠 있었습니다. 그런데 계속 움직이고 있었습니다.

2차 원인: 첫 화면이 계속 다시 그려진다

코드를 따라가 보니 이런 흐름이었습니다.

  1. 번들에 들어 있는(또는 캐시된) 목록으로 프로모 영역을 그립니다
  2. load + 5초에 원격 CSV가 도착합니다
  3. 내용이 다르면 목록을 통째로 다시 그립니다 — 순서가 바뀌고, 아이콘이 이모지로 되돌아갑니다
  4. 새로 생긴 행의 아이콘을 받아 또 5초 뒤에 교체합니다

1차 처방으로 요청 시점만 뒤로 밀었기 때문에, 오히려 재렌더가 5초 뒤에 시작되는 모양이 됐습니다. 아이콘 응답이 느린 환경에서는 이 과정이 10~20초 이어집니다.

검수는 사람이든 시스템이든 이걸 ‘아직 로딩 중’으로 봅니다. 그리고 이게 처음에 반려 시간이 6~12분으로 들쭉날쭉했던 이유이기도 합니다. 아이콘이 언제 오느냐에 따라 화면이 멈추는 시점이 달라지니까요.

‘20초’는 네트워크가 끝나는 시점이 아니라 화면이 안정되는 시점입니다.

2차 처방: 한 세션에 한 번만 그린다

  • 프로모 목록은 부팅 때 한 번만 그립니다
  • 원격 CSV와 아이콘은 받아서 localStorage에만 저장합니다. 다음 실행에 반영됩니다
  • 아이콘은 캐시가 있을 때만 첫 렌더에 그리고, 없으면 이모지로 확정합니다. 이번 세션에서는 바꾸지 않습니다
  • 로또 회차 갱신도 같습니다. 받아서 저장만 하고 화면은 건드리지 않습니다

한 줄로 줄이면 이렇습니다. 부팅 뒤에 화면을 갱신하는 코드를 만들지 마세요. 원격 자료는 다음 실행에 반영합니다.

사용자 입장에서 잃는 것은 “오늘 추가된 앱이 오늘 안 보인다” 정도입니다. 반려와 바꿀 만한 값이 아닙니다.

게이트 두 개

고친 것을 코드에만 두면 다음 앱에서 또 합니다. 출시 체크리스트에 기계 검사 두 개를 물렸습니다.

  • load_gate — headless 브라우저로 띄워 첫 로딩 구간에 나간 외부 요청 수를 셉니다. 0이 아니면 실패
  • settle_gate — load + 1.5초 이후 16초 동안 첫 화면 DOM이 바뀌는지 봅니다. 변화 0건이 아니면 실패. 옛 번들은 5~6초 지점에서 프로모 영역이 바뀌며 걸립니다

두 번째 게이트가 필요했던 이유가 이 글의 요점입니다. 첫 번째 게이트는 “언제 요청했나”만 보고, “화면이 멈췄나”는 보지 못합니다.

지금 상태

글을 쓰는 9월 20일 오후 기준입니다. 2차 처방을 넣은 번들을 다시 올려 검수 중이고, 두 게이트는 모두 통과한 상태입니다. 앞서 1차 처방만 들어간 채로 검수를 통과해 넘어간 앱들도 있어서, 같은 조건에서도 판정이 갈리는 정도의 여유는 있는 것으로 보입니다.

정리

  • 우리 네트워크에서 잰 숫자는 검수 환경의 숫자가 아닙니다
  • 첫 화면은 번들 안의 자료만으로 그리세요. 바깥 것은 load 뒤로
  • requestIdleCallback은 “나중”을 보장하지 않습니다. 가벼운 앱에서는 load보다 먼저 돕니다
  • 요청을 미루는 것만으로는 부족합니다. 미룬 응답이 도착해서 화면을 다시 그리면 같은 문제입니다
  • 원격 자료는 받아서 저장만 하고 다음 실행에 반영하세요
  • “로딩이 끝났나”와 “화면이 멈췄나”는 다른 질문입니다. 둘 다 기계가 검사하게 만드세요
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.