블로그 첫 화면이 방문자 체크 응답을 기다리느라 0.6초를 버리고 있었다
방문자 수를 세는 API가 끝나야 화면을 그리게 짜놔서 홈의 글 목록 요청이 그 뒤에 줄을 서고 있었다. 기다리지 않게 바꾸고 모바일 회선을 흉내 내 재보니 홈이 뜨는 시간이 3.98초에서 3.39초가 됐다.
#React #react-query #성능최적화 #프론트엔드
방문자 수 집계를 손보다가 오랜만에 블로그의 App.tsx 를 다시 열었다. 방문자 수를 세는 API를 어디서 부르는지 확인하려던 건데, 그 바로 아래에 있는 한 줄이 눈에 걸렸다. const { isLoading: isCheckLoading } = useQuery(['checkIP'], checkIP, { onSuccess: (res) = { if (res?.id) setUser(res) }, }); if (isCheckLoading) return SplashLoading / ; /checkIP 는 접속한 사람의 IP를 해시로 바꿔서 오늘 방문자로 한 번 세고, 익명 사용자 id를 돌려주는 API다. 그리고 이 응답이 올 때까지 앱 전체를 로딩 화면으로 막고 있었다. 상단 바도, 글 목록도, 아무것도 안 그린다. 이 글에서는 이게 실제로 첫 화면을 얼마나 늦추고 있었는지 재보고, 기다리지 않게 바꾼 과정을 정리해본다. 왜 기다리게 짰을까 처음 만들 때는 이유가 있었다. 여기서 받은 id를 좋아요, 댓글, 방명록에서 쓴다. id가 없는 상태에서 화면이 먼저 뜨면 뭔가 깨질 것 같아서, 아예 받을 때까지 기다리게 해둔 거다. 안전하게 가자는 생각이었다. 근데 그 세 군데를 다시 보니 전부 버튼을 눌렀을 때만 id를 읽는다. // ContentLikes.tsx const handleLike = () = { addLike(bid || '', user.id || '').then(...) } 화면을 그리는 데는 id가 필요 없다. 사람이 글을 읽다가 좋아요를 누를 때쯤이면 응답은 진작 와 있다. 기다릴 이유가 없는 걸 기다리고 있었던 거다. 오히려 기다리게 해둔 덕에 숨어 있던 버그도 하나 있었다. 봇으로 판정되면 서버가 id 없이 응답하는데, 그때 user 는 null 이라 user.id 에서 바로 에러가 난다. 지금까지는 그 상태로 좋아요를 누를 사람이 없어서 안 드러났을 뿐이다. 응답 자체는 빠르다. 문제는 줄을 서는 거다 처음엔 별거 아니라고 생각했다. /checkIP 는 DB에 한 줄 넣는 게 전부라서, 서버 안에서 재면 35ms 정도다. 근데 브라우저 입장에서는 그게 다가 아니다. 순서를 따라가 보면 이렇다. HTML과 JS를 받고, 앱이 시작되면 /checkIP 를 보낸다. 응답이 올 때까지 스플래시만 그린다. 응답이 오면 그제야 홈 컴포넌트가 마운트되고, 홈이 그때 글 목록 API를 출발시킨다 . 그 응답이 와야 카드가 뜬다. 그러니까 왕복 한 번이 통째로 첫 화면 앞에 끼어 있다. 서버 처리 시간이 35ms여도 모바일 회선에서 왕복은 그보다 훨씬 길다. 두 요청이 동시에 갈 수 있는데 하나가 끝나야 다음이 출발하는 구조였다. 재봤다 감으로 말하면 안 될 것 같아서 playwright로 재봤다. 매번 새 브라우저 컨텍스트에 캐시를 끄고 홈과 글 상세를 열어서, 첫 글 카드나 본문 문단이 화면에 생기는 시각을 쟀다. 모바일 회선은 크롬 개발자 도구의 프리셋 값을 그대로 CDP로 걸었다. // Slow 4G { latency: 150, downloadThroughput: 1.6 * 1024 * 1024 / 8, uploadThroughput: 750 * 1024 / 8 } // Fast 3G { latency: 562.5, downloadThroughput: 1.44 * 1024 * 1024 / 8, uploadThroughput: 675 * 1024 / 8 } 시나리오마다 다섯 번씩 돌려서 중앙값을 봤다. 바꾸기 전 홈에서 /checkIP 가 걸린 시간은 데스크톱 8ms, Slow 4G 171ms, Fast 3G 585ms였다. 그리고 그 뒤에 글 목록이 한 번 더 왕복했다. 고친 것 막는 줄을 지우고, 응답은 뒤에서 받게 했다. useQuery(['checkIP'], checkIP, { onSuccess: (res) = { if (res?.id) setUser(res) // 방문자 수가 checkIP 보다 먼저 왔으면 오늘 첫 방문인 내가 빠져 있을 수 있다. // 아직 받는 중인 요청도 서버에선 checkIP 보다 먼저 셌을 수 있어서 취소하고 다시 받는다. queryClient.invalidateQueries('getVisitLog', undefined, { cancelRefetch: true }) }, }); id를 읽는 세 군데는 user?.id 로 바꿨다. 응답 전에 누르거나 봇으로 판정돼 id가 없어도 에러가 안 난다. 하나 더 신경 쓴 게 홈의 방문자 수다. 홈에는 오늘 방문자 수가 나오는데, 이제 /checkIP 와 방문자 수 조회가 동시에 출발한다. 방문자 수가 먼저 처리되면 오늘 처음 들어온 사람은 자기가 빠진 숫자를 보게 된다. 그래서 /checkIP 가 끝나면 방문자 수를 다시 받게 했다. 처음엔 invalidateQueries('getVisitLog') 만 넣었는데, 브라우저에서 확인해보니 다시 받는 요청이 안 나가는 경우가 있었다. 방문자 수 요청이 아직 진행 중이면 react-query는 새로 보내지 않고 진행 중인 걸 기다린다. 그 진행 중인 요청이 서버에서 /checkIP 보다 먼저 처리됐다면 결과는 여전히 나를 빼고 센 숫자다. cancelRefetch: true 를 줘서 진행 중인 건 버리고 새로 받게 했다. 결과 글 목록(본문)이 화면에 뜨기까지, 중앙값 5회 홈 글 상세 데스크톱 251 - 208ms 237 - 181ms 모바일 Slow 4G 2,295 - 2,051ms 3,241 - 2,968ms 모바일 Fast 3G 3,978 - 3,391ms 5,269 - 4,776ms Fast 3G 홈에서 0.59초, Slow 4G에서 0.24초가 줄었다. 줄어든 양이 거의 그 회선의 /checkIP 왕복 시간과 같다. 줄 서던 왕복 한 번이 빠진 거다. 데스크톱은 원래 왕복이 짧아서 40ms 정도 차이다. 첫 페인트 시각도 같이 당겨졌다. Fast 3G에서 2,920ms가 2,256ms가 됐다. 전에는 첫 페인트가 스플래시 배경이었는데, 이제는 상단 바와 홈의 틀이 바로 그려진다. 아직 남은 스플래시 홈에서는 스플래시가 아예 안 보이게 됐는데, 글 상세는 다섯 번 중 네다섯 번 여전히 스플래시가 잠깐 뜬다. 이건 다른 스플래시다. 글 상세 화면은 lazy() 로 따로 불러오고, 그 JS 청크가 올 때까지 Suspense 의 fallback으로 같은 스플래시를 쓴다. 이건 실제로 그릴 게 없어서 기다리는 거라 성격이 다르다. 그래도 화면 전체를 덮는 로딩보다는 상단 바를 살려두고 본문 자리만 비워두는 쪽이 낫겠다 싶어서 다음에 손볼 목록에 넣어뒀다. 남은 생각 로딩 화면은 넣을 때 다들 이유가 있다. 나도 "id 없이 뜨면 깨질까 봐"라는 이유가 있었다. 근데 그 이유가 아직 유효한지는 아무도 다시 확인하지 않는다. 로딩 화면이 뜨는 동안 사용자는 아무것도 못 보는데, 그게 정말 기다려야 하는 건지는 코드 한 줄만 봐서는 알 수 없다. 로딩 화면을 막고 있는 조건이 있다면, 그 조건으로 받는 값을 실제로 어디서 읽는지 한 번 따라가 보길 추천한다. 클릭할 때만 읽는 값이라면 화면은 먼저 그려도 된다.