- 발행일
서버에서만 안 되는 로딩 — 플래그를 하나 더 만들었고, 사흘 뒤 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의 의존성으로 옮겼습니다. "서버 목록을 받은 뒤에 상세를 시작한다"는 순서는 이걸로 잡혔어요. 지금 보면 isLoading은 finally에서 꺼지는 값이라 의존성에 두면 요청이 끝나기 전 렌더에도 걸릴 수 있었고, 목록을 실제로 받았다는 플래그가 더 정확한 신호였습니다. 그날 그렇게 적은 기록은 없습니다. 판정 조건은 다시 둘로 돌아갔습니다.
이것도 완전한 답은 아니었습니다. 상세가 끝났는지는 여전히 몰랐으니까요.
사흘 뒤 — 콜백을 모아서 끝을 만들었다
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일 회고에 이 화면은 "로딩 중 / 데이터 없음 상태에 따른 동적 화면 처리"라고 결과 한 줄로 적혀 있습니다. 이 글이 그 한 줄의 사흘입니다.
관련 글: 직짱건강 — 백엔드를 기다리지 않고 설문 인터페이스를 먼저 완성한 방법2024-03-31직짱건강 — 백엔드를 기다리지 않고 설문 인터페이스를 먼저 완성한 방법직장인 대상 건강 설문과 위치 기반 병원 추천을 묶은 헬스케어 서비스입니다. 6인 협업 프로젝트에서 프런트엔드로 참여했고, 백엔드 API가 없는 상태에서 JSON 목업으로 설문 플로우를 먼저 완성해 병렬 작업이 가능하게 만든 게 핵심 기여였어요. · 무한 스크롤에서는 왜 이슈가 생긴 걸까 — 상태의 주인이 둘일 때2024-09-25무한 스크롤에서는 왜 이슈가 생긴 걸까 — 상태의 주인이 둘일 때무한 스크롤을 붙인 화면마다 이슈가 났는데, 돌아보니 라이브러리 문제가 아니라 전부 "누적된 목록의 주인이 누구인가"가 흐릿해서 생긴 문제였습니다. react-query 캐시와 zustand 스토어가 같은 목록을 두고 싸우다 한쪽을 걷어낸 이야기, 페이지 번호를 컴포넌트가 들고 있다가 캐시에 넘긴 이야기를 정리했습니다. · 배포 첫날 커밋 열 개2024-11-28배포 첫날 커밋 열 개 — www 누락, 리라이트 삭제, 그리고 읽히지도 않던 middlewareTripTune을 처음 Netlify에 올린 날, 커밋 열 개가 전부 next.config.js와 middleware.ts를 만지는 것이었습니다. 백엔드 주소에 www가 빠졌고, 리라이트를 지웠다가 되살렸고, 다음날에는 middleware의 matcher를 통째로 뒤집었어요. 지금 커밋을 다시 보니 그 middleware는 위치 때문에 애초에 실행된 적이 없었을 가능성이 큽니다.