Framer Motion whileInView — 일부 카드만 안 뜨던 날

외부 뷰어 리포트의 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.sectionviewport={{ once: true, margin: "-30px", amount: 0.01 }} 세 필드를 표준화. once: true로 첫 교차 콜백을 확정 트리거로 묶고, amount: 0.01교차 임계를 사실상 0으로 낮춰 짧은 카드의 음수 margin 미스 케이스를 닫았다.
  • 부가 가드: 조건부 마운트 카드(InsightCard·EmptyState)에 센티넬 ref를 단 useInView 훅 폴백을 추가. 마운트 시점의 교차 상태를 직접 측정해 한 프레임 안에서 animate 강제 발사. ESLint no-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개로 쌓여 있고, 각 섹션이 공통 variantscascade시킨다. 표면 코드는 이렇다.

// 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 콜백이 한 번도 발사되지 않았다”는 사실교차 상태가 변하지 않았다는 뜻이다. 이미 교차 중인 채로 옵저버가 부착되면 변화가 없으니 콜백이 발사되지 않는다기본값으로는 최초 부착 시점에 한 번 발사돼야 하지만, 여기서는 그 최초 콜백마저 사라졌다. 마이크로 레이스의 신호다.

데이터 도착과 함께 마운트된 정상 섹션과 조건부 분기로 한 박자 늦게 마운트된 멈춘 카드의 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뷰포트이고, 조상 요소의 overflowroot에 영향을 주지 않는다. 다만 카드가 부모 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가 도착하는 순간 hasInsightsfalse → true동기적으로 뒤집힌다. React는 즉시 리렌더. motion.section처음 마운트된다. 마운트 위치가 이미 뷰포트 안이다(스크롤 위치가 0이고, 폴백 카피가 차지하던 공간에 카드가 들어선다).

여기서 마이크로 레이스가 발생한다.

  1. React render — DOM에 <section> 삽입
  2. 같은 microtask에서 layout 계산 — 박스 크기·위치 확정
  3. framer-motion의 useEffect 콜백 — 다음 microtask에서 부착 예약
  4. 그 사이브라우저 paintopacity: 0; transform: translateY(40px) 그대로 그려짐
  5. framer-motion의 IntersectionObserver.observe(section) 부착 — 드디어 옵저버 부착
  6. 옵저버는 부착 시점한 번 콜백을 발사해야 함 — 표준대로면

표준대로면 6번에서 콜백이 들어와야 한다. 그런데 일부 케이스에서 들어오지 않았다. 추적해 보니 viewport.once기본값false인 게 결정적이었다.

once: false이면 framer-motion은 교차 이벤트계속 발사되는 것을 기대한다. 마운트 직후 한 번 부착된 교차 상태가 true인 콜백을 받고 → visible로 전환 → 바로 다음 콜백에서 교차 상태가 false로 떨어지길 기다린다. 그런데 뷰포트가 변하지 않는다면 다음 콜백영영 안 들어온다. 첫 콜백을 한 번은 받았지만, framer-motion의 내부 상태기에서 that 첫 콜백 자체마이크로 레이스로 인해 부착 직전 단계에서 큐잉만 됐다가 부착이 끝난 시점이미 큐가 비워진 케이스가 발생했다.

🔬 핵심 발견: whileInViewviewport.once: false(기본값)는 교차의 변화를 듣는 모델이다. 마운트 직후 첫 부착 시점한 번뿐인 콜백반드시 잡아야 한다. 그 콜백이 부착보다 한 박자 빨리(브라우저 paint 직전 microtask에) 발사되면, 부착 후엔 다음 변화가 없으니 콜백이 영원히 안 들어온다. 이게 조건부 마운트 시점카드의 위치가 이미 뷰포트 안일 때 무작위로 터지는 진짜 범인이다.


🔬 진짜 범인 — 마이크로 레이스 + viewport.once 기본값 false

같은 부모 안의 세 자식 카드가 새로고침마다 무작위로 멈추는 위치가 달라진다는 걸 깨달은 순간 마이크로 레이스라는 단서가 잡혔다
같은 부모 안의 세 자식 카드가 새로고침마다 무작위로 멈추는 위치가 달라진다는 걸 깨달은 순간 마이크로 레이스라는 단서가 잡혔다

세 가설을 종합한 진짜 범인조건부 마운트 × whileInView 첫 콜백 마이크로 레이스 × viewport.once: false 기본값세 변수가 동시에 만족할 때만 터지는 교집합 버그다.

Framer Motion의 공식 문서는 whileInView의 동작을 *“요소가 뷰포트와 교차할 때 트리거”*로 정의한다.

framer.com

내부 구현은 마운트 시점에 IntersectionObserver를 부착하고 콜백을 듣는 모델이다. once: true이면 첫 콜백에서 그대로 visible로 굳히고 옵저버를 해제한다. once: false이면 교차 이벤트의 변화계속 들으면서 visible / hidden을 토글한다.

핵심은 콜백 발사 시점옵저버 부착 시점순서다. 조건부 마운트가 일어나면 React의 render phase에서 동기적으로 DOM이 삽입되고, useEffectcommit 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].isIntersectingtrue즉시 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-syntaxviewport 누락 차단

마지막은 재발 방지다. 팀 개발자가 새 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 체크리스트

핵심을 세 줄로 다시 정리한다.

  1. whileInView뷰포트 진입 이벤트를 듣는 모델이라 이미 안에서 마운트되는 케이스가 사각지대다. 조건부 마운트가 섞이면 마이크로 레이스일부 카드opacity 0으로 멈추는 무작위 버그가 터진다.
  2. viewport={{ once: true, margin, amount }} 세 필드를 명시하고 표준 상수한 곳에서 관리. 기본값 함정처음부터 차단한다.
  3. 조건부 마운트 카드에는 useInView + 센티넬 ref 폴백을 추가. 훅 상태 한 박자가 *마이크로 레이스의 마지막 1%*까지 닫는다.

다음 편(devlog-58)에서는 같은 외부 뷰어 리포트 머지의 바로 뒤를 잇는 BE 머지Prisma include의 N+1 함정이 외부 뷰어 응답에서 4탭 단일 응답10초까지 끌어올린 성능 사고증상·탐색·진짜 범인·해결을 A 톤으로 정리한다.


📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (54편)

  1. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
  25. 25. 멀티테넌트 누수 — tenantId 3계층 강제
  26. 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
  27. 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
  28. 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
  29. 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
  30. 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
  31. 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
  32. 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
  33. 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
  34. 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
  35. 35. Unity ↔ 웹 PostMessage 브릿지 설계기
  36. 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
  37. 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
  38. 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
  39. 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
  40. 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
  41. 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
  42. 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
  43. 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
  44. 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
  45. 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
  46. 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날
  47. 47. 콘텐츠 후보 선택 3차 최적화 — 단일 쿼리로 옮기기
  48. 48. 재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)
  49. 49. 회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
  50. 50. 보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
  51. 51. 외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루
  52. 52. 외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
  53. 53. Framer Motion whileInView — 일부 카드만 안 뜨던 날
  54. 54. 외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로