로그인 버튼을 눌렀는데 첫 클릭이 가끔 안 먹는다. 다른 탭에 갔다가 돌아와서 누를 때만 그렇다. 버튼이 스켈레톤으로 잠깐 깜빡임이 보이기도 한다. 또는 페이지에 처음 들어올 때만 돌아야 할 환영 애니메이션이, 사용자가 스크롤을 올려 맨 위로 돌아오면 또 돈다. 둘 다 오류는 없다. 코드를 읽어 보면 if (isLoading) return <Skeleton /> 이나 if (scrollY < 100) playIntro() 처럼 멀쩡해 보이는 한 줄이 있다.
상태값은 「처음」을 말하지 않는다
isLoading·status === "loading"·거리 ≤ X 같은 상태값은 「지금」을 말할 뿐 「처음인지」는 말하지 않는다. 한 번만 일어나야 할 일(첫 로딩의 자리표시, 첫 진입 연출, 첫 방문 안내)을 이런 상태값에 걸면, 그 상태가 다시 오는 순간마다 처음이 다시 일어난다. 처음인지는 「이미 지나갔다」를 기억하는 별도의 값으로 가려야 한다.
상태값이 다시 돌아오는 길은 생각보다 많다.
- 재검증: 창에 포커스가 돌아오면 세션·데이터를 다시 확인하는 훅이 많다(
useSession,refetchOnWindowFocus같은 기본값). 그 동안 로딩 플래그가 잠깐 켜진다 - 사용자 조작: 줌·스크롤·뒤로 가기로 사용자가 같은 거리·같은 위치를 다시 만든다
- 다시 그리기: 부모가 다시 렌더되거나 화면이 다시 붙으면(remount) 조건이 처음부터 다시 평가된다

왜 첫 클릭이 사라지나
로딩 플래그로 버튼과 자리표시(스켈레톤)를 갈아끼우는 화면을 생각해 보자. 사용자가 다른 창에서 돌아와 버튼에 손을 대는 바로 그 순간, 포커스 재검증이 플래그를 켠다. 버튼은 화면에서 빠지고 같은 자리에 자리표시가 들어온다. 클릭은 자리표시에 떨어지고, 수백 밀리초 뒤 버튼이 돌아온다. 사용자는 「한 번 눌렀는데 안 됐다」만 겪는다. 오류 로그는 없다.
데이터 가져오기 라이브러리들도 이 둘을 일부러 나눠 둔다. TanStack Query 는 「아직 데이터가 한 번도 없다」(isPending)와 「가져오는 중이다」(isFetching)를 다른 값으로 준다(TanStack Query 문서). SWR 도 첫 로딩(isLoading)과 다시 확인 중(isValidating)을 나눈다. 자리표시는 앞의 것에만 걸어야 한다.
「처음 한 번」은 어떻게 막나
- 한 번만 일어날 일에 이름을 붙인다. 「첫 세션 확인」, 「도입 연출」, 「첫 방문 안내」. 상태 조건 안에 숨은 한 줄로 두지 않는다
- 「지나갔다」를 기억하는 값을 둔다.
hasResolvedOnce,introDone, 단계(phase) 값. 한 번 켜지면 다시 꺼지지 않게 한다 - 상태 조건은 그 값 뒤에 건다.
!introDone && distance <= X,!hasResolvedOnce && isLoading - 다시 돌아오는 경로를 시험으로 박는다. 「도입부가 끝난 뒤에는 어느 거리에서도 연출 세기가 0」, 「첫 확인 뒤 재검증 중에도 버튼이 그대로 있다」처럼, 상태를 다시 만들어 보는 시험을 둔다
// Before: 재검증 때마다 버튼이 사라진다
if (status === "loading") return <Skeleton />;
// After: 자리표시는 첫 확인 전까지만
const [resolvedOnce, setResolvedOnce] = useState(false);
useEffect(() => {
if (status !== "loading") setResolvedOnce(true); // 한 번 켜지면 다시 안 꺼진다
}, [status]);
if (!resolvedOnce) return <Skeleton />;
코드 리뷰나 에이전트 지침에 넣어 둘 문장:
한 번만 일어나야 할 일(첫 로딩 자리표시·첫 진입 연출·첫 안내)을
isLoading·status·거리·스크롤 위치 같은 「지금」 상태값에 직접 걸지 않는다.
「이미 지나갔다」를 기억하는 값(단계·플래그)으로 먼저 막고,
그 상태를 다시 만들었을 때(포커스 복귀·되돌아오기·다시 그리기) 일이 다시 안 일어나는지 시험한다.
우리가 겪은 일
- 로그인 버튼의 첫 클릭 — 한 웹 서비스 헤더에서 세션 훅의 로딩 플래그로 로그인 버튼과 자리표시를 갈아끼웠다. 다른 창에서 돌아와 누른 첫 클릭만 가끔 먹지 않았는데, 창 포커스 때 세션을 다시 확인하며 플래그가 잠깐 켜지는 순간 버튼이 자리표시로 바뀌어 있었다. 자리표시를 첫 확인 전까지만 쓰게 바꿔 닫았다
- 다시 도는 착지 연출 — 3D 장면에서 도입부에만 한 번 도는 착지 연출을 「카메라 거리 ≤ X」로 걸어 두었다. 사용자가 휠로 그 거리까지 줌인하면 카메라가 들렸다 내려오는 느낌이 났다. 앞서 앙각을 고쳐도 증상이 남아서, 같은 자리에 겹친 다른 연출을 찾다가 알았다. 도입 단계 플래그로 막고 「도입부가 아니면 어느 거리에서도 0」을 시험으로 고정했다
둘 다 조건식 자체는 맞았다. 틀린 것은 그 조건이 「처음」을 뜻한다고 믿은 것이었다.
이전 글원문을 줄이는 변환은 가정 밖의 입력에서 오류 없이 내용을 버린다 — 줄이기 전과 후에 지키려는 것을 센다