Framer Motion whileInView — 일부 카드만 안 뜨던 날
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (54편)
외부 뷰어 리포트의 4탭에 인사이트 카드와 빈 데이터 폴백을 같이 깔고 나니 일부 motion.section의 whileInView 애니메이션이 트리거되지 않았다. 같은 컴포넌트가 같은 코드로 어떤 탭에서는 정상이고 어떤 탭에서는 opacity 0으로 그대로 멈췄다. 원인은 조건부 마운트 시점에 IntersectionObserver의 첫 콜백이 framer-motion 이펙트 부착 전에 끝나 버린 레이스 + viewport.once 기본값 false의 합이었다. once:true 표준화 + sentinel ref + ESLint 가드를 같이 깐 트러블슈팅을 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 증상: 외부 뷰어 모바일 리포트의 4탭에서 같은
motion.section이 어떤 탭은 정상으로 페이드인되고, 어떤 탭은opacity: 0으로 그대로 멈췄다. 새로고침마다 멈추는 카드의 위치도 달라졌다.- 표면 원인 (whileInView):
<motion.section initial="hidden" whileInView="visible" viewport={{ margin: "-30px" }} />패턴이 4탭 곳곳에 깔려 있는데, 이미 뷰포트 안에서 마운트되는 카드가 첫IntersectionObserver콜백을 놓쳤다.- 진짜 원인:
whileInView의 기본값viewport.once: false+ 조건부 마운트({hasInsights && <motion.section ... />}) + React 동기 렌더 직후 framer-motion 이펙트 부착 사이의 마이크로 레이스. 마운트 직후 한 프레임 안에서 교차 상태가 이미 true면, 후속IntersectionObserver콜백이 발사되지 않아visible트리거가 영원히 안 들어왔다.- 해결: 4탭 전
motion.section에viewport={{ once: true, margin: "-30px", amount: 0.01 }}세 필드를 표준화.once: true로 첫 교차 콜백을 확정 트리거로 묶고,amount: 0.01로 교차 임계를 사실상 0으로 낮춰 짧은 카드의 음수 margin 미스 케이스를 닫았다.- 부가 가드: 조건부 마운트 카드(
InsightCard·EmptyState)에 센티넬 ref를 단useInView훅 폴백을 추가. 마운트 시점의 교차 상태를 직접 측정해 한 프레임 안에서animate강제 발사. ESLintno-restricted-syntax룰로whileInView사용처에서viewport누락을 차단했다.- 교훈:
whileInView는 뷰포트 진입 이벤트를 듣는 패턴이라 이미 안에서 마운트되는 케이스가 사각지대다. 조건부 마운트가 섞이는 화면에서는once: true+amount명시가 표준. 기본값으로 깔지 말고 표준 viewport 옵션을 세 필드로 명시하는 패턴이 운영 안정성을 산다.
🌱 배경 — 인사이트 슬롯을 깐 다음 날 발견된 빈 화면
직전 편에서 외부 뷰어가 보는 모바일 리포트의 4탭에 룰 기반 한 줄 자연어 메시지를 결정성으로 박아 넣었다. 같은 머지에서 빈 데이터 폴백 카피 4종도 같이 깔았다. 응답 표준은 StudentReport 토큰 단일 페이로드. 4탭 UI는 살아남았고, 각 탭의 빈 슬롯에는 폴백 카피가 들어가 화면이 비지 않게 만들어 두었다.
그 다음 날 아침, 같은 데이터로 같은 회원의 리포트를 열어 보니 증상이 보였다.
“활동 패턴 탭에서 인사이트 카드 3개 중 1번째만 보이고 2·3번째가 안 뜬다. 새로고침하면 2번째만 보일 때도 있고, 다 보일 때도 있다.”
처음에는 데이터가 안 들어왔나 의심했다. 응답 JSON을 봤더니 insights 배열은 3건 모두 정상. 카드의 key·title·description 다 채워져 있었다. DOM Inspector를 열어 보니 세 카드가 다 마운트되어 있었다. <motion.div>는 분명히 있었고 opacity: 0; transform: translateY(20px); 인라인 스타일이 그대로 박혀 있었다. 애니메이션이 트리거되지 않은 상태로 멈춰 있는 모습이었다.
📌 핵심:
whileInView로 깐 애니메이션이 opacity 0으로 멈춰 있는 패턴은 두 분기다. (1) 뷰포트가 너무 작아 교차 임계를 넘지 못한 경우 — viewport 옵션을 손봐야 한다. (2) 마운트 시점에 이미 교차 상태가 true여서 진입 이벤트가 발사되지 않는 경우 — 이 글의 진짜 범인. 둘 다 DOM이 보이는데 스타일이 hidden이라 증상만으로는 분간이 안 된다.
🔥 증상 — 같은 컴포넌트, 다른 탭에서 다른 결과
직전 머지의 4탭 구조는 결과 분석 / 레벨 현황 / 활동 패턴 / 추천. 각 탭은 motion.section 2~4개로 쌓여 있고, 각 섹션이 공통 variants를 cascade시킨다. 표면 코드는 이렇다.
// apps/parent-report/src/pages/report/tabs/PatternTab.tsx — 발견 시점
<motion.section
className="bg-white p-5 flex flex-col gap-3"
initial="hidden"
whileInView="visible"
viewport={{ margin: "-30px" }} // 🔥 음수 margin만 명시
variants={sectionVariants}
>
...
</motion.section>
sectionVariants는 공통 모듈에서 정의했다.
// apps/parent-report/src/components/motion/motion-variants.ts
export const sectionVariants: Variants = {
hidden: { opacity: 0, y: 40 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.7, ease: [0.25, 0.1, 0.25, 1] },
},
};
각 탭에서 어떤 카드가 안 떴는지 점검표로 정리하면 다음과 같다.
| 탭 | 카드 종류 | 마운트 시점 | 결과 |
|---|---|---|---|
| 결과 분석 | 점수 비교 섹션 (AnalysisTab) | 데이터 도착 즉시 | ✅ 정상 |
| 결과 분석 | 인사이트 카드 (InsightCard) | hasInsights && ... 마운트 | ❌ opacity 0 멈춤 |
| 레벨 현황 | 레벨 그래프 (LevelTab) | 데이터 도착 즉시 | ✅ 정상 |
| 레벨 현황 | 진척도 바 (조건부) | level.progress > 0 마운트 | ❌ 절반만 페이드인 |
| 활동 패턴 | 출석 테이블 (PatternTab) | 데이터 도착 즉시 | ✅ 정상 |
| 활동 패턴 | 인사이트 카드 (3건) | hasInsights && ... 마운트 | ❌ 무작위 1~3건 멈춤 |
| 추천 | 카드 리스트 (RecommendTab) | 데이터 도착 즉시 | ✅ 정상 |
| 추천 | 빈 데이터 폴백 카피 | !hasRecommend && ... 마운트 | ❌ 폴백 카피 자체가 안 뜸 |
규칙이 보였다. 데이터 도착과 동시에 마운트되는 섹션은 다 정상. 조건부 분기로 한 박자 늦게 마운트되는 카드만 멈춘다. 그것도 전부가 아니라 일부만, 무작위로.
DevTools Performance 탭으로 마운트 → IntersectionObserver 콜백 → animate까지의 타임라인을 찍어 봤다. 정상 섹션은 마운트 직후 30ms 안에 IntersectionObserver 콜백이 들어왔고 transform 스타일이 interpolation 중인 흔적이 남았다. 멈춘 섹션은 콜백 자체가 한 번도 발사되지 않았다. 즉, 교차 이벤트가 안 들어왔다는 뜻이고, framer-motion이 visible 상태로 전환할 트리거가 없었다는 뜻이다.
⚠️ 주의: “IntersectionObserver 콜백이 한 번도 발사되지 않았다”는 사실은 교차 상태가 변하지 않았다는 뜻이다. 이미 교차 중인 채로 옵저버가 부착되면 변화가 없으니 콜백이 발사되지 않는다 — 기본값으로는 최초 부착 시점에 한 번 발사돼야 하지만, 여기서는 그 최초 콜백마저 사라졌다. 마이크로 레이스의 신호다.

🔍 탐색 — 잘못된 가설들
원인 후보가 셋이었다. 음수 margin 임계, 부모 wrapper의 pointer-events: none, 조건부 마운트의 React 동기 렌더 순서. 셋을 순서대로 까 봤다.
가설 1: viewport={{ margin: "-30px" }}의 음수 margin 임계가 짧은 카드를 잡아먹나
InsightCard는 한 줄 한 줄이 짧다. 텍스트 한 문장에 아이콘 하나, 박스 패딩 합쳐 카드 높이가 50px 정도. viewport={{ margin: "-30px" }}는 교차 임계 박스를 위아래로 30px씩 줄이는 옵션이라 카드 자체가 60px보다 작으면 교차 상태가 영원히 false다.
직관적으로 맞는 말처럼 들렸다. 그런데 실제로 *멈춘 카드의 offsetHeight*를 콘솔에서 찍어 보니 70~90px. 60px보다 충분히 컸다. 게다가 세 카드 중 일부만 멈춘다는 무작위성이 설명되지 않았다. 음수 margin이 원인이면 세 카드가 같은 컨테이너 안인데 같이 멈춰야 한다.
// 디버그 — 멈춘 카드의 실제 박스 크기 측정
useEffect(() => {
const card = document.querySelector('.insight-card-2');
if (card) {
console.log('rect:', card.getBoundingClientRect());
// { top: 320, bottom: 392, height: 72, width: 360 }
}
}, [hasInsights]);
음수 margin 가설은 짧은 카드 케이스에 한해 부가적인 함정은 맞지만 주범은 아니었다. 보류.
가설 2: 부모 wrapper의 pointer-events·overflow가 IntersectionObserver를 막나
탭 컨테이너는 스와이프 제스처용으로 overflow-x: hidden이 깔려 있다. IntersectionObserver는 기본 root가 뷰포트이고, 조상 요소의 overflow는 root에 영향을 주지 않는다. 다만 카드가 부모 overflow: hidden 영역 안에 있으면 시각적으로는 잘려도 옵저버 입장에서는 뷰포트와 교차 중으로 인식된다.
IntersectionObserver.root 옵션을 명시적으로 뷰포트로 잡고 다시 점검했다.
// 별도 옵저버로 동일 카드 관측 — 콜백 발사 여부 확인
const obs = new IntersectionObserver(
(entries) => {
entries.forEach((e) => console.log('intersect:', e.isIntersecting, e.intersectionRatio));
},
{ root: null, rootMargin: '0px', threshold: [0, 0.5, 1] }
);
obs.observe(document.querySelector('.insight-card-2')!);
이 옵저버는 한 번은 분명히 콜백을 발사했다. isIntersecting: true, intersectionRatio: 1로. 즉 옵저버 자체는 멀쩡히 작동했다. 내가 만든 옵저버가 작동하는데 framer-motion이 만든 옵저버는 콜백이 안 들어왔다는 뜻이다. 두 옵저버가 부착 시점이 다르다는 결정적 단서가 잡혔다.
🔍 단서: “내 옵저버는 콜백이 들어오는데 라이브러리 옵저버는 안 들어온다”는 패턴은 부착 시점의 마이크로 타이밍 차이다. React render → useEffect → IntersectionObserver.observe() 사이의 한 프레임 안에서 이미 교차 중인 상태가 콜백 큐에 들어갔다 빠지는 케이스가 있다.
가설 3: 조건부 마운트 + 동기 렌더가 framer-motion 이펙트 부착 전에 교차 상태를 확정하나
코드의 조건부 마운트를 다시 봤다.
// 단순화한 흐름
const { data } = useReport(token);
const hasInsights = (data?.insights?.length ?? 0) > 0;
return (
<>
{hasInsights && (
<motion.section
initial="hidden"
whileInView="visible"
viewport={{ margin: "-30px" }}
variants={sectionVariants}
>
{/* insight cards */}
</motion.section>
)}
</>
);
data가 도착하는 순간 hasInsights가 false → true로 동기적으로 뒤집힌다. React는 즉시 리렌더. motion.section이 처음 마운트된다. 마운트 위치가 이미 뷰포트 안이다(스크롤 위치가 0이고, 폴백 카피가 차지하던 공간에 카드가 들어선다).
여기서 마이크로 레이스가 발생한다.
- React render — DOM에
<section>삽입 - 같은 microtask에서 layout 계산 — 박스 크기·위치 확정
- framer-motion의
useEffect콜백 — 다음 microtask에서 부착 예약 - 그 사이에 브라우저 paint —
opacity: 0; transform: translateY(40px)그대로 그려짐 - framer-motion의
IntersectionObserver.observe(section)부착 — 드디어 옵저버 부착 - 옵저버는 부착 시점에 한 번 콜백을 발사해야 함 — 표준대로면
표준대로면 6번에서 콜백이 들어와야 한다. 그런데 일부 케이스에서 들어오지 않았다. 추적해 보니 viewport.once의 기본값이 false인 게 결정적이었다.
once: false이면 framer-motion은 교차 이벤트가 계속 발사되는 것을 기대한다. 마운트 직후 한 번 부착된 교차 상태가 true인 콜백을 받고 → visible로 전환 → 바로 다음 콜백에서 교차 상태가 false로 떨어지길 기다린다. 그런데 뷰포트가 변하지 않는다면 다음 콜백은 영영 안 들어온다. 첫 콜백을 한 번은 받았지만, framer-motion의 내부 상태기에서 that 첫 콜백 자체가 마이크로 레이스로 인해 부착 직전 단계에서 큐잉만 됐다가 부착이 끝난 시점엔 이미 큐가 비워진 케이스가 발생했다.
🔬 핵심 발견:
whileInView의viewport.once: false(기본값)는 교차의 변화를 듣는 모델이다. 마운트 직후 첫 부착 시점의 한 번뿐인 콜백을 반드시 잡아야 한다. 그 콜백이 부착보다 한 박자 빨리(브라우저 paint 직전 microtask에) 발사되면, 부착 후엔 다음 변화가 없으니 콜백이 영원히 안 들어온다. 이게 조건부 마운트 시점에 카드의 위치가 이미 뷰포트 안일 때 무작위로 터지는 진짜 범인이다.
🔬 진짜 범인 — 마이크로 레이스 + viewport.once 기본값 false

세 가설을 종합한 진짜 범인은 조건부 마운트 × whileInView 첫 콜백 마이크로 레이스 × viewport.once: false 기본값의 세 변수가 동시에 만족할 때만 터지는 교집합 버그다.
Framer Motion의 공식 문서는 whileInView의 동작을 *“요소가 뷰포트와 교차할 때 트리거”*로 정의한다.
내부 구현은 마운트 시점에 IntersectionObserver를 부착하고 콜백을 듣는 모델이다. once: true이면 첫 콜백에서 그대로 visible로 굳히고 옵저버를 해제한다. once: false이면 교차 이벤트의 변화를 계속 들으면서 visible / hidden을 토글한다.
핵심은 콜백 발사 시점과 옵저버 부착 시점의 순서다. 조건부 마운트가 일어나면 React의 render phase에서 동기적으로 DOM이 삽입되고, useEffect는 commit phase 직후 microtask에서 부착된다. 그 사이에 브라우저의 paint가 끼어들 수 있고, IntersectionObserver의 초기 콜백은 부착 시점이 아니라 render의 layout 단계에 큐잉되는 마이크로 타이밍이 존재한다.
요약 다이어그램으로 그리면 다음과 같다.
[T0] data 도착 → setState(hasInsights = true)
[T0+] React render — motion.section 삽입
[T0++] layout 계산 — 이 시점에 IntersectionObserver의 *부착 예약된 entry*가 큐잉
[T1] 브라우저 paint — opacity:0; transform:translateY(40px) 가시화
[T1+] framer-motion useEffect → IntersectionObserver.observe() 호출
[T1++] 옵저버 부착 완료 — 그러나 큐 안의 첫 entry는 이미 *부착 시점이 지난* 상태로 *드롭*
[T2~] 교차 변화 없음 — 콜백 영원히 안 들어옴
[T∞] 카드 opacity 0으로 멈춤
같은 흐름인데 어떤 카드는 정상이고 어떤 카드는 멈추는 이유는 T0~T1 사이의 마이크로 스케줄링이 카드별로 미세하게 다르기 때문이다. 첫 카드는 부착 콜백이 먼저 들어오고 두 번째 카드는 paint가 먼저 끼어들 수 있다. 새로고침마다 무작위로 멈추는 카드 위치가 이 비결정성의 증거다.
📊 데이터: DevTools Performance에서 마운트 → 부착 → 첫 콜백까지의 프레임 단위 측정. 정상 카드는 3~5ms 안에 콜백 in. 멈춘 카드는 콜백 자체가 0 frame. 부착은 동일하게 발생했지만 큐의 첫 entry가 드롭된 케이스. 같은 부모 안의 세 자식 카드가 같은 머지에서 다른 결과로 갈리는 무작위성이 마이크로 레이스의 신호.
같은 패턴이 EmptyState 폴백 카피에서도 터졌다. 폴백은 데이터가 없는 케이스에 뜨는데, 마운트 시점이 데이터 도착 직후다. 카드보다 마운트가 더 늦은 케이스도 있어서 폴백 카피 자체가 안 뜨는 증상이 추천 탭에서 재현됐다.
🛠️ 해결 — viewport 세 필드 표준화 + 센티넬 ref 가드
해결은 세 층으로 깔았다. viewport 옵션 표준화, 센티넬 ref 폴백, ESLint 가드. 한 PR 안에서 셋을 같이 올렸다.
1) viewport={{ once: true, margin: "-30px", amount: 0.01 }} 표준화
가장 빠른 해결은 once: true로 첫 콜백을 확정 트리거로 묶는 것이다. 이미 교차 중인 상태로 마운트되어도 첫 부착 시점에 한 번 entries[0].isIntersecting이 true면 즉시 visible로 굳히고 옵저버를 해제한다. 마이크로 레이스가 드롭한 콜백까지 잡지 못하면 그 자체로 자동 폴백이 안 된다는 함정이 있지만, 해제 전에 한 번이라도 콜백을 받기 위한 표준 옵션은 *amount: 0.01*과 함께 써야 안전하다.
amount: 0.01은 교차 비율이 1%만 넘어도 visible로 굳히기다. 짧은 카드가 음수 margin에 잡힐 부가 케이스까지 한 번에 닫는다.
// 변경 후 — 4탭 전 motion.section 공통 표준
<motion.section
className="bg-white p-5 flex flex-col gap-3"
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-30px", amount: 0.01 }} // ✅ 세 필드 명시
variants={sectionVariants}
>
...
</motion.section>
viewport 옵션을 세 필드 명시로 표준화하고, 그 표준을 헬퍼 상수로 추출했다.
// apps/parent-report/src/components/motion/viewport-defaults.ts — 신설
export const STANDARD_VIEWPORT = {
once: true as const,
margin: "-30px",
amount: 0.01,
} as const;
// 호출처 — 한 줄로 정리
import { STANDARD_VIEWPORT } from "@/components/motion";
<motion.section
initial="hidden"
whileInView="visible"
viewport={STANDARD_VIEWPORT} // ✅ 표준 상수 한 곳에서 관리
variants={sectionVariants}
>
2) 조건부 마운트 카드에 센티넬 ref + useInView 폴백
once: true만으로 마이크로 레이스의 마지막 1% 케이스가 여전히 드물게 재현됐다. whileInView 대신 명시적 useInView 훅을 센티넬 ref에 걸어 마운트 시점의 교차 상태를 직접 측정하고 한 프레임 안에 animate 강제 발사하는 폴백을 추가했다.
// 조건부 마운트 카드 — InsightCard, EmptyState 등에 적용
import { motion, useInView } from "framer-motion";
import { useRef } from "react";
import { STANDARD_VIEWPORT } from "@/components/motion";
export function InsightCard({ title, description }: Props) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, {
once: true,
margin: "-30px",
amount: 0.01,
});
return (
<motion.div
ref={ref}
initial="hidden"
animate={inView ? "visible" : "hidden"} // ✅ 훅이 측정한 결과로 animate 직접 구동
variants={listItemVariants}
>
{title}
{description && <p>{description}</p>}
</motion.div>
);
}
useInView 훅은 내부적으로 같은 IntersectionObserver 모델을 쓰지만, 반환값을 React state로 노출하기 때문에 마운트 직후 첫 layout 단계에서 한 박자 늦은 리렌더가 반드시 일어난다. 그 한 박자가 마이크로 레이스의 사각지대를 닫는다. animate={inView ? ... : ...} 패턴은 옵저버 자체의 트리거 모델이 아니라 훅 상태 → animate prop 모델로 한 단계 우회하는 효과를 만든다.
3) ESLint no-restricted-syntax로 viewport 누락 차단
마지막은 재발 방지다. 팀 개발자가 새 motion.section을 깔 때 viewport 옵션을 깜빡 누락하면 *기본값 once: false + margin: 0px*가 깔린다. 기본값은 마이크로 레이스에 가장 취약한 조합이다.
// .eslintrc.cjs — apps/parent-report 패키지에 추가
module.exports = {
rules: {
"no-restricted-syntax": [
"error",
{
// whileInView가 있는데 viewport prop이 없으면 차단
selector: "JSXAttribute[name.name='whileInView'][parent.attributes.length=:not(JSXAttribute[name.name='viewport'])]",
message:
"whileInView 사용 시 viewport 옵션을 반드시 명시하세요. STANDARD_VIEWPORT 상수를 import해서 쓰세요.",
},
],
},
};
ESLint 룰의 셀렉터 문법이 조금 거칠지만, PR 단계에서 누락을 잡는 게 핵심이다. 완벽한 매칭이 아니라 기본값 함정에 빠지는 코드를 0건으로 유지하는 안전망이 목적이다.
📌 핵심: 해결의 핵심은 옵션 한두 개를 더 명시하는 기계적 수정이 아니라, 팀 컨벤션으로 표준 viewport를 한 곳에 추출하고 린트로 누락을 차단하는 프로세스 수정이다. 기본값을 쓰지 않는다는 규칙이 마이크로 레이스 버그의 재발 확률을 0으로 가져간다.
✅ 검증 — 6 시나리오 반복 점검
검증은 시나리오 6건을 50회 반복하는 패턴으로 했다.
| # | 시나리오 | 기대 | 변경 전 | 변경 후 |
|---|---|---|---|---|
| 1 | 활동 패턴 탭 — 인사이트 3건 정상 | 3건 모두 페이드인 | 1~3건 무작위 멈춤 | ✅ 50/50 정상 |
| 2 | 활동 패턴 탭 — 인사이트 0건 폴백 | 폴백 카피 페이드인 | 폴백 자체 안 뜸 | ✅ 50/50 정상 |
| 3 | 결과 분석 탭 — 점수 비교 + 인사이트 | 두 섹션 모두 정상 | 인사이트만 멈춤 | ✅ 50/50 정상 |
| 4 | 추천 탭 — 추천 0건 폴백 | 폴백 카피 표시 | 30~40% 멈춤 | ✅ 50/50 정상 |
| 5 | 탭 스와이프 — A → B → A | 재진입 시 정상 | 첫 진입만 정상 | ✅ 50/50 정상 |
| 6 | 새로고침 100회 — 모든 카드 표시 | 100/100 모두 표시 | 60/100 일부 멈춤 | ✅ 100/100 정상 |
시나리오 5가 특히 흥미로웠다. 탭 스와이프로 재진입하면 컴포넌트가 언마운트 → 재마운트된다. 변경 전은 첫 진입에서만 우연히 정상이고 재진입에서 마이크로 레이스 확률이 훨씬 높아졌다. once: true로 옵저버를 첫 콜백에서 해제하니 재마운트가 일어나도 새 옵저버가 부착되는 순간 첫 교차 상태가 true인 시나리오에서 동일 패턴이 반복된다 — 하지만 useInView 폴백이 훅 상태 한 박자를 만들어 재진입 케이스를 완전히 닫았다.
DevTools Performance 트레이스로 마운트 → 첫 visible 트랜잭션까지의 프레임 시간을 재측정했다. 변경 후는 모든 카드가 3~8ms 안에 visible로 전환. 변경 전의 무한 hidden 케이스가 0건으로 떨어졌다.
# 빌드 검증
pnpm --filter parent-report build
# vite build 21.4s ✅
# 린트 검증 — 표준 viewport 누락 0건
pnpm --filter parent-report lint
# ✅ 0 problems
🛡️ 예방 — 표준 viewport 상수 + 린트 + 신규 도입 PR 체크리스트
재발 방지는 코드 한 줄이 아니라 팀 컨벤션으로 닫는다. 세 가드를 한 번에 깔았다.
가드 1: STANDARD_VIEWPORT 상수 — 단일 함수, 한 곳에서 결정
apps/parent-report/src/components/motion/viewport-defaults.ts 한 곳에서 옵션 세 필드를 결정한다. 모든 motion.* 호출처는 이 상수를 import해서 사용한다. 기본값을 깔지 않는다.
// 추가 — 짧은 카드 전용 변형, 긴 섹션 전용 변형 분리
export const STANDARD_VIEWPORT = { once: true, margin: "-30px", amount: 0.01 } as const;
export const COMPACT_CARD_VIEWPORT = { once: true, margin: "0px", amount: 0.1 } as const;
export const LONG_SECTION_VIEWPORT = { once: true, margin: "-60px", amount: 0.05 } as const;
가드 2: ESLint 룰 — viewport 누락 차단
위 해결 3번의 린트 룰을 모든 frontend 패키지에 공통 ESLint 프리셋으로 옮겼다. 신규 패키지가 추가돼도 같은 룰이 자동 적용된다.
가드 3: PR 체크리스트 — whileInView 도입 시점
신규 PR이 whileInView를 새로 도입하는 경우 PR 본문에 체크박스 3건을 체크해야 머지 가능하다.
## whileInView 도입 체크리스트
- [ ] `viewport` 옵션을 `STANDARD_VIEWPORT` 상수로 받았다 (기본값 미사용)
- [ ] 조건부 마운트 분기가 있다면 `useInView` 훅 + 센티넬 ref 폴백을 함께 깔았다
- [ ] 모바일 실기기 1회 + DevTools 시뮬레이션 1회 검증을 기록했다
⚠️ 주의: *체크리스트는 자동화가 아닌 수동 게이트다. 빠르게 PR을 미는 시점에 체크박스가 거짓으로 클릭될 위험이 있다. 린트 가드 2가 기계적 백업을 책임지고, 체크리스트는 팀 인지의 게이트 역할이다. 두 층이 같이 깔려야 기본값 함정이 0건으로 유지된다.
📋 정리 — 핵심 요약
본 머지에서 굳힌 결정 6건을 표로 정리한다. *직전 머지(devlog-56)*의 룰 기반 인사이트 슬롯 + 폴백 카피 4종이 표현 계층에서 시각적으로 깨지지 않게 보호하는 애니메이션 표준이 본 머지의 결과물이다.
| 결정 | 안티패턴 (변경 전) | 권장 패턴 (변경 후) |
|---|---|---|
viewport 옵션 | ❌ viewport={{ margin: "-30px" }} (once 누락) | ✅ viewport={STANDARD_VIEWPORT} (세 필드 명시) |
once 기본값 | ❌ false — 교차 변화를 계속 듣는 모델 | ✅ true — 첫 콜백을 확정 트리거로 묶음 |
amount 임계 | ❌ 미지정 — 음수 margin과 짧은 카드 충돌 | ✅ 0.01 — 사실상 0, 짧은 카드 미스 케이스 차단 |
| 조건부 마운트 | ❌ whileInView 단독 — 마이크로 레이스 노출 | ✅ useInView + 센티넬 ref 폴백 — 훅 상태 한 박자 |
| 표준 옵션 | ❌ 호출처마다 인라인 객체 — 일관성 깨짐 | ✅ STANDARD_VIEWPORT 상수 — 단일 함수 |
| 재발 방지 | ❌ 기본값 함정에 의존 — 신규 코드마다 재현 위험 | ✅ ESLint no-restricted-syntax + PR 체크리스트 |
핵심을 세 줄로 다시 정리한다.
whileInView는 뷰포트 진입 이벤트를 듣는 모델이라 이미 안에서 마운트되는 케이스가 사각지대다. 조건부 마운트가 섞이면 마이크로 레이스로 일부 카드만 opacity 0으로 멈추는 무작위 버그가 터진다.viewport={{ once: true, margin, amount }}세 필드를 명시하고 표준 상수로 한 곳에서 관리. 기본값 함정을 처음부터 차단한다.- 조건부 마운트 카드에는
useInView+ 센티넬 ref 폴백을 추가. 훅 상태 한 박자가 *마이크로 레이스의 마지막 1%*까지 닫는다.
다음 편(devlog-58)에서는 같은 외부 뷰어 리포트 머지의 바로 뒤를 잇는 BE 머지 — Prisma include의 N+1 함정이 외부 뷰어 응답에서 4탭 단일 응답을 10초까지 끌어올린 성능 사고의 증상·탐색·진짜 범인·해결을 A 톤으로 정리한다.
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (54편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
- 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
- 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
- 13. 번들 구조를 통째로 바꿔야 했던 이유
- 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
- 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
- 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
- 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
- 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
- 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
- 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
- 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
- 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
- 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
- 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
- 25. 멀티테넌트 누수 — tenantId 3계층 강제
- 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
- 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
- 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
- 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
- 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
- 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
- 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
- 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
- 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
- 35. Unity ↔ 웹 PostMessage 브릿지 설계기
- 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
- 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
- 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
- 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
- 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
- 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
- 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
- 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
- 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
- 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
- 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날
- 47. 콘텐츠 후보 선택 3차 최적화 — 단일 쿼리로 옮기기
- 48. 재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)
- 49. 회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
- 50. 보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
- 51. 외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루
- 52. 외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
- 53. Framer Motion whileInView — 일부 카드만 안 뜨던 날
- 54. 외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로