발행일

서버에서만 안 되는 로딩 — 플래그를 하나 더 만들었고, 사흘 뒤 Promise.allSettled가 진짜 답이었다

서버에서만 안 되는 로딩

이 글은 2026년 9월에 당시 커밋 이력을 다시 보며 정리한 것입니다. 날짜는 작업한 날 기준입니다.

찜 화면은 두 단계로 데이터를 받습니다. 먼저 우리 서버에서 찜 목록(googleMapId와 날짜)을 받고, 그다음 Google Places getDetails를 병원 수만큼 불러 이름·주소·거리를 채웁니다. 3월 6일 밤부터 7일 새벽까지 이 화면 커밋이 다섯 개인데, 그중 둘의 제목이 이렇습니다.

5885c16 03-07 00:10  병원 상세 정보 로딩 및 구글 로딩 추가
5d7329b 03-07 00:26  서버에서만 안되는 로딩 이슈 수정

16분 간격이에요. 로컬에서는 목록이 떴고, Vercel에 올리면 스피너가 돌다가 "찜한 병원이 없네요!"로 떨어졌습니다. 어떤 기기·회선에서 봤는지는 커밋에 없습니다.

00:10 — 플래그 하나로 두 단계를 덮으려 했다

첫 커밋이 isHospitalDetailsLoaded를 만들었습니다.

        if (hospitalFirstData.length > 0 && !isLoading) {
            loadGoogleMapsScript(() => {
                loadPlaceDetails();
                setIsHospitalDetailsLoaded(true);
            });
        }
    }, [hospitalFirstData, isLoading]);

    const isStillLoading = isLoading || !isHospitalDetailsLoaded;

loadPlaceDetails()를 부르고 바로 다음 줄에서 상세가 로드됐다고 표시합니다. 그런데 loadPlaceDetails는 안에서 navigator.geolocation.getCurrentPosition을 부르고, 그 콜백 안에서 병원마다 service.getDetails를 부르고, 또 그 콜백에서 setHospitalInfo에 하나씩 붙이는 함수예요. 돌아오는 게 아무것도 없습니다. 그러니 "상세 로드 완료" 플래그는 상세가 시작되는 순간 true가 됐습니다.

로컬에서는 Places 응답이 빨라서 스피너가 꺼지는 순간과 목록이 채워지는 순간이 거의 겹쳤을 겁니다. 배포 환경에서는 그 사이가 벌어져 빈 화면이 먼저 보였던 걸로 읽혀요. 다만 이건 지금 코드를 보고 하는 추정이고, 그날 그렇게 진단한 기록은 없습니다.

00:26 — 플래그를 셋으로

16분 뒤 커밋은 플래그를 하나 더 만들었습니다.

    const [isHospitalInfoLoaded, setIsHospitalInfoLoaded] = useState(false); // 병원 기본 정보 로딩 상태
    ...
                setHospitalFirstData(response?.data?.data.bookmarkList);
                setIsHospitalInfoLoaded(true); // 병원 기본 정보 로딩 완료
    ...
    const isStillLoading = isLoading || !isHospitalInfoLoaded || !isHospitalDetailsLoaded;

isLoading(서버 요청 중), isHospitalInfoLoaded(서버 목록 받음), isHospitalDetailsLoaded(상세 받음). 세 불리언의 AND로 스피너를 판정합니다. 그런데 셋째 플래그가 여전히 상세 시작 직후에 켜지는 건 그대로였어요. 첫 번째 단계를 더 정확히 표시하게 됐을 뿐, 두 번째 단계가 끝났는지는 여전히 아무도 몰랐습니다.

여기서 "이슈 수정"이라고 적고 커밋했습니다. 로컬에서 확인했겠지만 로컬은 원래 됐던 곳이에요.

이튿날 — 판정에서 빼고 의존성으로

3월 8일 저녁 커밋(a36802e, 제목 "찜한 병원 로딩 테스트")이 방향을 바꿨습니다.

-    }, [hospitalFirstData, isLoading]);
-
-    const isStillLoading = isLoading || !isHospitalInfoLoaded || !isHospitalDetailsLoaded;
+    }, [hospitalFirstData, isHospitalInfoLoaded]);
+
+    const isStillLoading = isLoading || !isHospitalDetailsLoaded;

새로 만든 플래그를 스피너 판정에서 빼고, 상세를 시작하는 useEffect의 의존성으로 옮겼습니다. "서버 목록을 받은 뒤에 상세를 시작한다"는 순서는 이걸로 잡혔어요. 지금 보면 isLoadingfinally에서 꺼지는 값이라 의존성에 두면 요청이 끝나기 전 렌더에도 걸릴 수 있었고, 목록을 실제로 받았다는 플래그가 더 정확한 신호였습니다. 그날 그렇게 적은 기록은 없습니다. 판정 조건은 다시 둘로 돌아갔습니다.

이것도 완전한 답은 아니었습니다. 상세가 끝났는지는 여전히 몰랐으니까요.

사흘 뒤 — 콜백을 모아서 끝을 만들었다

3월 10일 커밋(92bbaee, 제목 "찜하기 목록 업데이트 시도")에서 getDetails 콜백들을 Promise로 감싸고 Promise.allSettled로 모았습니다. 13일에 훅으로 뺀 판(0e89494)은 이렇습니다.

                Promise.allSettled(detailsPromises).then((results) => {
                    const successfulDetails = results
                        .filter((result): result is PromiseFulfilledResult<HospitalDetail | null> => result.status === 'fulfilled')
                        .map((result) => result.value)
                        .filter((detail): detail is HospitalDetail => detail !== null);
                    setHospitalInfo(successfulDetails);
                    setIsHospitalDetailsLoaded(true);
                })

setIsHospitalDetailsLoaded(true)then 안으로 들어갔습니다. 이제야 "상세 로드 완료"가 상세가 끝난 뒤에 켜져요. 7일 새벽의 문제는 플래그가 모자라서가 아니라 끝을 알 수 없는 함수에 끝났다는 표시를 달아 둔 것이었고, 답은 끝을 만들어 주는 것이었습니다. 3일이 걸렸어요.

검증

그날 확인한 것:

  • 로컬에서 목록이 뜨는 것. 배포에서 다시 봤는지는 커밋 제목 "로딩 테스트" 외에 기록이 없습니다.

이 글을 쓰며 확인한 것:

  • 커밋 시각(00:10, 00:26, 이튿날 21:00)은 git log --format='%ci'로 확인했습니다.
  • setIsHospitalDetailsLoaded(true)의 위치가 loadPlaceDetails() 바로 다음 줄에서 allSettled().then 안으로 옮겨진 첫 커밋은 git log -S'allSettled'로 찾았습니다. 3월 10일 92bbaee입니다.
  • "로컬은 빨라서 겹쳐 보였다"는 재현하지 않은 추정입니다.

남은 것 · 한계

  • 왜 배포에서만 안 됐는지 그날 진단한 기록이 없습니다. 커밋 제목이 "서버에서만"이라고 말할 뿐이에요. 회선 속도인지, Places 응답 지연인지, 다른 원인인지 지금도 확정 못 합니다.
  • 플래그 셋 구조는 훅으로 옮겨진 뒤에도 그대로 남았습니다. 3월 17일 찜 훅은 Recoil 아톰으로 isLoading·isHospitalInfoLoaded·isHospitalDetailsLoaded 셋을 다 갖고 있고, 스피너 판정은 다시 세 개의 AND입니다. 끝을 만들어 준 뒤에는 플래그가 둘이면 됐을 텐데 줄이지 않았어요.
  • 초기값이 문제를 하나 더 만듭니다. hospitalFirstData의 기본값이 [{ googleMapId: '', bookmarkDate: '' }]라 길이가 1이고, length > 0 조건이 서버 응답 전에도 참입니다. googleMapId가 빈 문자열이라 안쪽 if에서 걸러지긴 하지만, 목록을 받기 전에 지도 스크립트를 한 번 더 부르는 경로예요. 이 글을 쓰며 봤고 그때는 몰랐습니다.
  • 3월 31일 회고에 이 화면은 "로딩 중 / 데이터 없음 상태에 따른 동적 화면 처리"라고 결과 한 줄로 적혀 있습니다. 이 글이 그 한 줄의 사흘입니다.

관련 글: 직짱건강 — 백엔드를 기다리지 않고 설문 인터페이스를 먼저 완성한 방법 · 무한 스크롤에서는 왜 이슈가 생긴 걸까 — 상태의 주인이 둘일 때 · 배포 첫날 커밋 열 개