본문으로 건너뛰기
yceffort
PostsSeriesTagsAbout🧪 Research
EN

Tweaks

theme
accent palette
film grain
minimal mode
BACK TO INDEX
◆ ESSAY
--min
--year
KOoriginal

mailMail icongithubtwitter
yceffort
•
© 2026
•
https://yceffort.kr
BACK TO INDEX
◆ ESSAY

framer-motion 배너에서 프레임드랍 없애기: 두 번의 삽질과 이징 함수

avatar
yceffort
2026-08-15 · 47분
47min
2026year
KOoriginal
framer-motionperformanceanimationcssfrontend

Table of Contents

  • 배너 하나가 열리는 0.6초
  • 왜 구조적으로 끊기는가
  • 삽질 1: "컨테이너가 아래를 민다"
  • 삽질 2: "지정한 ease가 적용된다"
  • 이징 함수 하나로 리플로우를 계단으로
  • 네 가지 구현을 나란히 놓기
  • 선언된 코드와 실행되는 값은 다르다
  • 성능을 결정하는 것은 문법이 아니라 속성이다
  • 값을 복사하지 말고 메커니즘을 보존한다
  • 계측기부터 의심한다
  • "동일함"의 증명 기준을 먼저 세운다
  • 일반화: 레이아웃 애니메이션의 계단화
  • 네이티브 앱이라면 어땠을까
  • framer-motion을 걷어내자는 이야기는 아니다
  • 마치며

배너 하나가 열리는 0.6초

홈 상단에 배너 카드가 하나 열린다. framer-motion의 AnimatePresence와 variants로 만든 평범한 등장 모션이고, 카드가 자리를 만들며 내려오고(0.4초), 그 위로 카드가 떠오른다(0.3~0.6초 구간). 데스크톱에서는 아무 문제가 없다. 그런데 폰에서 보면 이 0.6초 동안 화면 전체가 버벅인다. 배너만이 아니라, 배너 아래에 깔린 목록 전체가 함께 끊긴다. 저사양 폰만의 이야기도 아니었다. 최신 플래그십에서도 끊겼는데, 이게 왜 당연한 결과인지는 뒤에서 비용 모델과 함께 나온다.

프로파일러를 열어 보면 원인은 금방 보인다. 애니메이션이 도는 동안 매 프레임 Layout이 찍혀 있다. 배너는 height: 0 → auto를 애니메이션하고 있었고, height가 바뀔 때마다 그 아래 문서 전체가 다시 배치되고 있었다. 비용이 배너 크기가 아니라 배너 아래에 깔린 문서 크기에 비례하는 구조라서, 컴포넌트만 보면 가벼워 보이는데 실제 화면에서는 무겁다.

여기까지는 흔한 진단이다. 이 글이 기록하고 싶은 것은 그다음이다. "보이는 모션은 원본과 완전히 같게 두고, 프레임드랍만 없앤다"는 목표로 재구현을 시작했는데, 코드를 읽고 세운 가설이 실측 앞에서 두 번 무너졌다. 원본을 만든 사람의 의도와 원본이 실제로 실행하는 값조차 서로 달랐다. 그 과정을 거쳐 도달한 최종 해법은 코드 diff 기준으로 이징 함수 하나였는데, 돌아보면 그 한 줄보다 거기까지 가는 길에서 배운 것들이 더 오래 남을 것 같다. 그래서 글의 앞쪽 절반은 무슨 일이 있었는지의 기록이고, 뒤쪽 절반은 그 일이 남긴 것들을 하나씩 자세히 푸는 부분이다.

이 글의 코드 인용은 framer-motion 12.42.2와 그 내부 엔진인 motion-dom 12.43.0 기준이고, 소스 딥링크는 motion 모노레포의 v12.42.2 태그로 걸었다(인용한 부분의 내용이 같은 것을 확인했다). 측정은 Apple Silicon macOS의 Chromium(트레이스는 CPU 4x 스로틀), 크로스 브라우저 확인은 Playwright 1.62.1의 WebKit·Firefox로 했다. 전체 실험 코드는 yceffort/banner-motion-lab에 있다.

왜 구조적으로 끊기는가

원본 배너의 모션 정의는 대략 이렇다. 값은 실제 코드에서 옮겨온 것이다.

const variants = {
  show: {
    height: 'auto',
    opacity: 1,
    scale: 1,
    transition: {
      duration: 0.3,
      ease: [0.65, 0, 0.35, 1],
      height: {delay: 0.1},
      opacity: {delay: 0.3},
      scale: {delay: 0.3, ease: [0.47, 0, 0.23, 1.38]},
    },
  },
  // hidden(퇴장)도 같은 구조
}

framer-motion이 GPU 가속, 정확히는 WAAPI(Web Animations API)로 넘길 수 있는 값의 목록은 motion-dom의 accelerated-values.ts에 하드코딩되어 있다.

const acceleratedValues = new Set([
  'opacity',
  'clipPath',
  'filter',
  'transform',
  'backgroundColor',
])

height는 이 목록에 없다. 그래서 메인 스레드의 rAF 루프가 매 프레임 인라인 height를 px로 고쳐 쓰는 방식으로 돌아간다. 다만 이것을 framer-motion의 결함이라고 읽으면 원인을 잘못 짚은 것이다. 설령 WAAPI로 돌린다 해도 달라질 것이 없다. height는 레이아웃을 결정하는 속성이라 컴포지터(합성 스레드, 메인 스레드와 별개로 픽셀 합성만 담당하는 스레드)가 단독으로 애니메이션할 수 없고, 어떤 방식으로 값을 바꾸든 매 프레임 문서 리플로우가 따라온다.

실측으로 확인하면 이렇다. 400행짜리 목록을 깔아 둔 페이지에서 등장 애니메이션 1회를 CPU 4x 스로틀로 트레이스한 결과다.

LayoutPaintPrePaint 합
원본 (height 트윈)25회 (매 프레임)53회85.8ms
최종 구현 (계단 + FLIP)10회 (불연속 시점만)8회6.8ms

한 가지 정직하게 적어 두면, 이 데모 머신(M 계열 맥북)에서는 4x 스로틀을 걸어도 애니메이션이 진행되는 동안의 드랍이 없었다. 프레임당 3ms 안팎은 16.7ms 예산 안이기 때문이다. 하지만 이 비용은 문서 크기에 선형으로 비례하고, 예산 쪽도 데스크톱 기준으로 생각하면 안 된다. 실제 서비스에서 이 배너는 최신 플래그십 폰에서도 끊겼는데, 조건을 세어 보면 당연한 일이었다. 실제 홈은 데모보다 훨씬 무거운 수천 노드 문서이고, 배너가 등장하는 시점은 하이드레이션과 데이터 로딩으로 메인 스레드가 가장 바쁜 초기 로딩 직후이고, 플래그십일수록 120Hz라 프레임 예산이 16.7ms가 아니라 8.3ms로 반토막이다. 즉 조건은 "저사양"이 아니라 프레임 예산 대비 비용이고, 원본은 그 비용이 문서 크기에 비례하는 쪽에 서 있다. 최종 구현의 프레임당 비용은 문서 크기와 무관하다.

같은 식을 거꾸로 대입하면 개발하는 동안 PC에서 아무 문제가 없던 이유도 설명된다. 끊김은 프레임당 비용이 프레임 예산을 넘을 때 생기는데, PC는 이 부등식의 양쪽이 모두 유리하다. 예산은 60Hz 기준 16.7ms로 120Hz 폰의 두 배이고, 비용은 DPR이 낮아 다시 그릴 픽셀이 절반 이하인 데다 데스크톱 CPU가 열 제약 없이 몇 배 빠르며, 배너가 뜨는 순간의 메인 스레드 경쟁도 절대 성능으로 흡수된다. 실제로 이 데모 머신은 CPU를 4배 느리게 걸고도 프레임당 3~5ms에 그쳤다. 브라우저가 잘못된 구조를 하드웨어 힘으로 덮어 주고 있는 셈이고, 그 덮개가 폰에서 벗겨지는 것뿐이다.

이 비대칭이 이런 문제의 고약한 점이라고 생각한다. 개발자가 보는 환경에서는 구조적 결함이 증상을 만들지 않으므로, 매 프레임 리플로우는 코드 리뷰도 QA도 통과하고 사용자 폰에서만 나타난다. 다만 증상은 환경을 타도 구조는 어디서 보든 트레이스에 찍힌다. 뒤에서 이 기법을 꺼내는 기준을 "체감"이 아니라 "프로파일러에 매 프레임 Layout이 찍히는가"로 잡는 이유가 이것이다.

삽질 1: "컨테이너가 아래를 민다"

재구현의 첫 버전은 CSS transition과 FLIP으로 만들었다. FLIP(First-Last-Invert-Play)은 레이아웃 변화를 한 번에 확정한 뒤, 변한 거리만큼 transform으로 되돌렸다가 0으로 애니메이션해서 "레이아웃이 부드럽게 변한 것처럼" 보이게 하는 기법이다. 레이아웃은 한 번만 계산되고, 움직임은 컴포지터가 그린다.

그런데 이 재구현을 원본과 겹쳐 보면 미묘하게 달랐다. 아래 콘텐츠가 밀리기 시작하는 시점이 어긋났고, 곡선의 가속 프로파일도 달랐다. 원인을 찾으려면 원본이 실제로 무엇을 하는지부터 다시 봐야 했다.

원본의 구조는 컨테이너와 카드 래퍼의 2중이다. 컨테이너는 initial={{height: 0}} animate={{height: 'auto'}}로 자리를 만들고, 그 안의 카드 래퍼가 위의 variants로 등장한다. 코드만 읽으면 자연스러운 가설이 나온다. 컨테이너의 height: 0 → auto가 아래 콘텐츠를 밀고, 카드 래퍼는 그 안에서 떠오른다는 그림이다. 재구현도 이 가설 위에서 "컨테이너의 곡선"을 옮겼다.

실측 결과는 달랐다. 등장하는 동안 컨테이너와 카드 래퍼의 인라인 스타일을 프레임마다 찍어 보면 이렇게 나온다 (t는 카드 마운트 기준 ms).

t컨테이너 인라인 height카드 래퍼 인라인 height아래 콘텐츠 top
0auto0px148 (+8 점프)
167auto5.96px154
234auto64.6px213
434auto157.6px306
451autoauto314 (+8 점프)

컨테이너의 인라인 height는 처음부터 끝까지 auto다. 애니메이션이 없다. framer-motion은 height: 'auto'라는 목표를 애니메이션 시작 시점에 측정해서 px로 바꾸는데(motion-dom DOMKeyframesResolver의 measureEndState), 그 측정 시점에 자식 카드 래퍼의 인라인 height가 0이다. 그래서 'auto'의 측정값도 0이고, 0에서 0으로 가는 애니메이션은 즉시 끝난 뒤 인라인 height: auto로 복원된다. 이후 컨테이너는 자식을 따라갈 뿐이다.

즉 아래 콘텐츠를 실제로 미는 것은 카드 래퍼의 height 트윈 하나였다. 컨테이너 몫이라고 생각했던 곡선과 시작 시점(delay 없음)은 애초에 존재하지 않았고, 실제 밀림은 카드 래퍼의 delay 0.1초를 따라 0.1초 늦게 시작한다. 재구현이 0.1초 일찍 밀기 시작한 이유가 이것이었다.

표에 있는 ±8px 점프도 처음 보는 사실이었다. 카드의 margin: 8px가 래퍼의 height: 0을 뚫고 나와 마진 컬랩스되다가, 마운트 순간과 트윈 종료(auto 복원) 순간에 애니메이션 없이 8px씩 점프한다. 원본의 일부이므로, "완전히 동일"을 목표로 한다면 이것까지 같은 자리에서 점프해야 한다.

삽질 2: "지정한 ease가 적용된다"

첫 가설을 고치고 다시 겹쳐 봐도 곡선이 미세하게 달랐다. variants에는 분명히 ease: [0.65, 0, 0.35, 1]이라는 대칭 S곡선이 지정되어 있는데, 실측된 원본의 밀림 곡선은 앞쪽으로 치우친 비대칭이었다. 50% 지점을 통과하는 시각이 구간의 절반보다 한참 앞이었다.

답은 motion-dom의 animateMotionValue에 있었다. per-value transition을 해석하는 부분을 그대로 옮긴다.

const valueTransition = getValueTransition(transition, name) || {}
/**
 * Most transition values are currently completely overwritten by value-specific
 * transitions. In the future it'd be nicer to blend these transitions. But for now
 * delay actually does inherit from the root transition if not value-specific.
 */
const delay = valueTransition.delay || transition.delay || 0

주석에 그대로 적혀 있다. 값별 transition은 바깥 transition을 통째로 대체하고, 상속되는 것은 delay뿐이다. 그러면 height: { delay: 0.1 }처럼 delay만 적은 경우 ease는 어디서 올까. 이어지는 코드가 결정한다.

if (!isTransitionDefined(valueTransition)) {
  Object.assign(options, getDefaultTransition(name, options))
}

isTransitionDefined는 delay, repeat 같은 오케스트레이션 키를 제외하고 남는 설정이 있는지를 본다. { delay: 0.1 }은 delay뿐이므로 "transition이 정의되지 않은" 것으로 취급되고, 라이브러리 기본값이 들어간다. 비-transform 값의 기본값은 이것이다.

const ease = {
  type: 'keyframes',
  ease: [0.25, 0.1, 0.35, 1],
  duration: 0.3,
}

실측된 비대칭 곡선과 정확히 일치하는 값이다. 정리하면, 원본이 선언한 타임라인과 실제로 실행되는 타임라인은 이만큼 다르다.

값구간delay실제 easing
등장 height0 → H0.1s[0.25, 0.1, 0.35, 1] (기본값)
등장 opacity0 → 10.3s[0.25, 0.1, 0.35, 1] (기본값)
등장 scale0.96 → 10.3s[0.47, 0, 0.23, 1.38]
퇴장 opacity1 → 0없음[0.65, 0, 0.35, 1]
퇴장 scale1 → 0.96없음[0.47, 0, 0.23, 1.38]
퇴장 heightH → 00.1s[0.25, 0.1, 0.35, 1] (기본값)

지정한 [0.65, 0, 0.35, 1]이 실제로 적용되는 곳은 퇴장의 opacity 하나뿐이다. 퇴장 variants에만 opacity의 per-value 항목이 없어서, 유일하게 바깥 transition을 그대로 타기 때문이다.

이징 함수 하나로 리플로우를 계단으로

두 번의 삽질이 끝나고 나니 문제가 명확해졌다. 카드 래퍼의 height 트윈이 화면에 만드는 효과는 "아래 콘텐츠의 밀림" 하나뿐이다. 카드 자체는 overflow가 visible이라 래퍼 높이와 무관하게 통째로 보이고, 래퍼의 중간 높이값들은 매 프레임 리플로우만 만들 뿐 아무것도 그리지 않는다. 그렇다면 중간값을 버려도 된다.

그래서 variants는 원본 그대로 두고, height의 이징에만 계단 함수를 끼웠다.

/** 시작하자마자 끝값. 트윈의 delay와 종료 시점은 살리고 중간값만 없앤다. */
const stepToEnd = (progress: number) => (progress <= 0 ? 0 : 1)

// 원본:   height: { delay: 0.1 }
// 최종:   height: { delay: 0.1, ease: stepToEnd }

이 한 줄로 height는 원본 트윈의 타임라인(0.1초 시작, 0.4초 종료)을 유지한 채 양 끝값만 밟는다. 등장이라면 0.1초에 0에서 H(px)로 한 번, 0.4초에 framer-motion이 auto를 복원하며 한 번. 매 프레임의 리플로우가 두세 번의 불연속 리플로우로 준다. 계단 사이에서 트윈이 같은 값을 다시 쓰는 프레임은 computed style이 변하지 않아 레이아웃을 만들지 않는다는 것도 트레이스로 확인했다.

없어진 연속 움직임은 FLIP이 대신한다. ResizeObserver가 계단을 받아서, 아래 콘텐츠를 변한 거리만큼 transform으로 되돌린 뒤 원본 트윈과 같은 300ms, 같은 [0.25, 0.1, 0.35, 1]로 0에 보낸다.

const observer = new ResizeObserver(() => {
  const sizeDelta = source.offsetHeight - prevHeight
  const positionDelta = readLayoutTop(target) - prevTop
  prevHeight += sizeDelta
  prevTop += positionDelta

  // 마진 컬랩스로 생기는 이벤트를 거른다: 부호가 다르거나 한쪽이 0이면 점프가 옳다
  if (sizeDelta * positionDelta <= 0) return

  const delta =
    Math.sign(sizeDelta) *
    Math.min(Math.abs(sizeDelta), Math.abs(positionDelta))
  invertAndPlay(target, delta) // transition: none → invert → 강제 flush → 같은 프레임에 play
})

delay가 어디에도 없다는 점이 이 구조의 좋은 성질이다. 계단이 밟히는 순간이 곧 원본 트윈의 delay가 끝난 순간이므로, ResizeObserver가 발화하는 시점 자체가 타이밍이다. 애니메이션할 거리를 offsetHeight 변화량과 실제 레이아웃 변위 중 "같은 부호의 최솟값"으로 잡는 부분은 앞서 본 ±8px 마진 점프를 위한 것이다. 이 규칙 덕분에 원본이 점프하는 지점은 점프로 남고, 원본이 미는 거리만 애니메이션된다.

검증은 눈이 아니라 곡선으로 했다. 아래 콘텐츠의 getBoundingClientRect().top을 매 프레임 기록해 두 구현을 겹쳐 그리고, 밀림 거리의 10%, 50%, 90%를 통과하는 시각으로 형태를 비교했다 (Chromium, 스로틀 없음).

구간원본 p10→p90 폭계단 + FLIP p10→p90 폭p50 차이
등장197ms198ms+13ms
교체165ms156ms+21ms
퇴장198ms197ms+9ms

곡선의 폭, 즉 가속 프로파일은 1~2프레임 노이즈 안에서 같고, 남는 것은 시작 시점의 반 프레임에서 한 프레임 수준 오프셋뿐이다. 마진 점프의 위치와 교체 시 들어오는 카드가 미끄러져 올라오는 곡선까지 확인했고, Playwright의 WebKit과 Firefox에서도 곡선 폭이 일치했다. WebKit의 퇴장 곡선은 p50 차이가 1ms까지 붙었다.

네 가지 구현을 나란히 놓기

이 과정을 거치며 같은 모션의 구현이 네 가지 쌓였다. 문제의 원형, 삽질의 기록, 그리고 최종 해법의 두 형태다.

구현구동height아래 콘텐츠 밀림
원본framer-motion매 프레임 px 트윈height 리플로우의 부수 효과
재구현 (1차)CSS transition즉시 확정FLIP, 그러나 100ms 이르고 곡선 해석이 어긋남
개선framer-motion + 계단 이징0.1s에 한 계단FLIP, 원본 트윈과 같은 곡선
CSS 완성형CSS transition만0.1s에 한 계단FLIP, 동일

개선과 CSS 완성형은 보이는 결과가 같고 구동만 다르다. 개선은 framer-motion을 유지한 채 이징 함수 하나를 주입한 것이라 기존 코드베이스에 diff 한 줄로 후장착할 수 있고, CSS 완성형은 framer-motion 의존을 아예 걷어낸 것이다. CSS 완성형에서는 재생이 전부 CSS transition이고 JS에는 측정과 오케스트레이션만 남는데, 그 오케스트레이션 코드가 곧 framer가 흡수해 주던 일의 목록이기도 하다. 이 이야기는 뒤에서 다시 나온다.

아래 데모에서 네 방식을 직접 재생하고 곡선을 겹쳐 볼 수 있다. 데모의 레이블은 표와 이렇게 대응한다: 재구현1이 1차 재구현, 재구현2가 계단 이징(우리가 지향한 완성본), CSS only가 CSS 완성형이다. 버튼을 누를 때마다 아래 목록의 이동 곡선이 색깔별로 겹쳐 그려진다. 원본(빨강)과 재구현2(초록), CSS only(주황)는 포개지고, 재구현1(파랑)만 100ms가량 왼쪽으로 벗어나는 것이 삽질 1의 흔적이다. 원본 버튼은 framer-motion의 메커니즘(rAF로 매 프레임 height 쓰기)을 그대로 재현한 것이고, 실제 framer-motion으로 구동되는 원본과의 비교는 실험장 저장소에서 할 수 있다.

배너 모션 4종 비교: 곡선은 겹치고 리플로우 횟수만 다르다 · 새 탭에서 열기 ↗

미리 단서를 붙여 두면, 일반적인 PC에서는 네 버튼 모두 드랍 0으로 매끈하게 나올 것이다. 이 데모의 목록은 1200행이고 데스크톱 CPU에서 그 리플로우와 페인트는 프레임당 수 ms 수준이라, 매 프레임 다시 해도 16.7ms 예산 안에 넉넉히 들어가기 때문이다. 원본 방식과 계단 방식의 차이는 "지금 끊기느냐"가 아니라 프레임당 비용이 문서 크기에 비례하느냐 상수냐라는 구조에 있고, 그 구조 차이는 비용이 예산을 넘는 환경(무거운 문서, 바쁜 메인 스레드, 120Hz면 반토막 나는 예산)에서만 드랍으로 나타난다.

데모의 "부하" 옵션은 그 환경을 눈으로 흉내 내는 장치다. 매 프레임 메인 스레드를 태우므로 공은 어느 모드든 덜컹이지만, 계단 방식의 목록은 컴포지터에서 돌기 때문에 그대로 미끄러지고 원본의 목록만 공과 함께 끊긴다. 다만 드랍 "수치"의 차이까지 인위적 부하로 만들려는 시도는 하지 않았다. 실제로 해 봤는데, 상수 비용을 더하는 방식은 16.7ms 예산 경계에 걸려 머신 상태에 따라 같은 설정이 드랍 0이 되기도 8이 되기도 했다. 수치 재현은 DevTools의 CPU 스로틀(4~6x)이 맞는 도구다. 스로틀은 느린 기기가 실제로 겪는 일, 즉 리플로우와 페인트 비용 자체가 몇 배가 되는 상황을 그대로 만들기 때문이다.

부하 없음에서는 계단 방식이 오히려 1~2 드랍으로 나올 수 있다는 것도 적어 둔다. 원본의 비용은 매 프레임에 얇게 퍼지고, 계단 방식의 비용은 transform 레이어 승격 순간에 한 번 몰리기 때문이다. 빠른 기기에서 한 번 재생하는 조건이라면 원본이 이기기도 한다는 뜻이고, 이 우열이 뒤집히는 조건(무거운 문서, 빠듯한 예산)이 곧 이 기법을 꺼낼 조건이다. 부하와 무관하게 곡선 차트는 항상 유효하다. 재구현1의 곡선이 왼쪽으로 벗어나고 나머지 셋이 겹치는 것은 성능이 아니라 모션이 같은가의 문제라서, 기기 성능과 무관하게 그대로 보인다.

사건의 기록은 여기까지다. 이제부터는 이 과정에서 배운 것들을 하나씩 자세히 풀어 본다.

선언된 코드와 실행되는 값은 다르다

이번 작업에서 가장 오래 남을 배움을 하나만 고르라면 이것이다. 가설이 두 번 무너졌는데, 두 번 모두 "코드에 그렇게 적혀 있으니 그렇게 동작할 것"이라는 믿음이 무너진 것이었다.

ease: [0.65, 0, 0.35, 1]은 variants에 분명히 적혀 있었다. 코드 리뷰를 백 번 해도 이 값이 적용되지 않는다는 사실은 보이지 않는다. 리뷰어가 볼 수 있는 것은 선언이고, 선언과 실행 사이에는 라이브러리의 해석 규칙이 끼어 있기 때문이다. framer-motion의 per-value transition 대체 규칙은 공식 문서에 크게 강조되어 있지 않고, 소스 주석에 "In the future it'd be nicer to blend these transitions"라고 적혀 있을 만큼 라이브러리 스스로도 아쉬워하는 동작이다. 원본을 만든 사람도 아마 모든 값이 지정한 곡선으로 움직인다고 생각하며 썼을 것이다. 즉 원본조차 의도대로 동작하고 있지 않았고, "원본과 동일하게"라는 목표는 의도가 아니라 실행 결과를 기준으로 삼아야 했다.

여기서 방법론 하나가 나온다. 처음에 나는 "라이브러리 소스에서 확인했다"는 수준의 검증을 했었는데, 그것으로 부족했다. 소스의 한 부분을 읽고 세운 모델은 그 부분이 실제 실행 경로에 있는지 보장하지 못한다. 실제로 컨테이너의 기본 transition 값을 소스에서 찾아 "이 곡선이 적용된다"고 확신했지만, 그 코드는 맞았고 적용 대상이 틀렸다. 컨테이너의 애니메이션 자체가 no-op이었기 때문이다.

순서를 뒤집으니 풀렸다. 실측을 먼저 하고, 실측이 가리키는 지점의 소스를 읽는다. 이번에 쓴 실측은 거창한 것이 아니고, 애니메이션이 도는 동안 관련 요소들의 인라인 스타일과 위치를 rAF로 매 프레임 기록하는 20줄짜리 스크립트였다. 이 덤프에서 "컨테이너 인라인 height가 전 구간 auto"라는 사실이 나왔고, 그제서야 'auto' 측정 시점이라는 올바른 질문이 생겨서 DOMKeyframesResolver를 읽게 됐다. 비대칭 곡선이 실측에서 나왔고, 그제서야 animateMotionValue의 transition 해석부를 읽게 됐다. 소스 딥다이브는 실측이 질문을 만들어 준 뒤에야 유효했다.

추상화를 쓰지 말자는 이야기가 아니다. 추상화의 비용이 "복잡한 것을 짧게" 만드는 대신 "실행 모델을 불투명하게" 만드는 쪽으로 청구된다는 이야기고, 그 청구서는 성능이나 정밀 재현처럼 실행 모델을 정확히 알아야 하는 작업에서 날아온다. 그리고 그 값은 코드 리뷰가 아니라 프레임 단위 실측으로 치르게 된다.

성능을 결정하는 것은 문법이 아니라 속성이다

이 문제를 처음 만났을 때 가장 먼저 드는 유혹은 라이브러리 교체다. framer-motion이 느리니 가벼운 라이브러리로, 혹은 순수 CSS로 가면 되지 않을까 하는 방향이다. 이번 작업은 그 방향이 원인을 비켜 간다는 것을 여러 각도에서 확인해 줬다.

height가 WAAPI 가속 목록에 없다는 것은 원인이 아니라 결과다. 컴포지터는 레이아웃을 계산할 수 없으므로, 레이아웃 속성은 어떤 엔진에 올려도 매 프레임 메인 스레드의 리플로우로 돌아온다. rAF 루프로 돌리든(framer-motion), WAAPI로 돌리든, CSS transition으로 돌리든 같다. 심지어 최신 CSS의 interpolate-size: allow-keywords로 height: auto를 순수 CSS 문법으로 애니메이션해도, 문법만 선언적이 될 뿐 비용은 원본과 동일한 매 프레임 리플로우다. 문법이 어디에 있느냐가 아니라 어떤 속성이 변하느냐가 비용을 결정한다.

비용 모델을 식으로 적어 두면 판단이 빨라진다. 레이아웃 속성 애니메이션의 총비용은 대략 "문서 크기에 비례하는 리플로우 비용 × 프레임 수"다. 이 식에서 두 가지가 따라 나온다. 하나는 착시의 정체다. 배너 컴포넌트만 떼어 보면 가벼워 보이는 이유는 비용의 대부분이 배너가 아니라 그 아래 문서에서 나오기 때문이고, 그래서 이런 문제는 개발 환경의 가벼운 페이지에서 재현되지 않다가 실제 서비스 홈에서 터진다. 다른 하나는 최적화의 방향이다. 식의 두 인자 중 문서 크기를 줄이는 쪽(contain, content-visibility)과 프레임 수를 줄이는 쪽(이 글의 계단화)이 있고, 라이브러리 교체는 어느 인자도 건드리지 못한다.

거꾸로 말하면, 이 원칙 덕분에 framer-motion을 유지한 채 문제를 풀 수 있었다. 고칠 것은 라이브러리가 아니라 "무엇을 애니메이션하는가"였고, 그것은 이징 함수 하나로 바꿀 수 있는 것이었다.

값을 복사하지 말고 메커니즘을 보존한다

첫 재구현과 최종 구현은 접근 방식이 근본적으로 달랐고, 결과의 차이가 그 접근의 차이를 그대로 반영했다.

첫 재구현은 원본의 값들을 복사했다. duration, delay, easing을 원본에서 읽어 CSS로 옮겨 적는 방식이다. 문제는 읽는 과정에 해석이 끼고, 해석이 틀리면 틀린 값이 조용히 박제된다는 점이다. 실제로 다섯 군데가 어긋나 있었다. 밀림이 0.1초 일찍 출발했고(삽질 1), 퇴장 곡선이 달랐고(삽질 2), 마진 점프가 +8/+8 두 번 대신 +16 한 번에 몰렸고, 카드 교체 시 두 transition이 엇갈려 겹치면서 합성 곡선이 달랐고, offsetHeight 변화량을 실제 변위로 취급하다가 마진이 박스 안팎으로 이동하는 이벤트에서 ±8px를 잘못 애니메이션했다.

최종 구현은 값을 거의 복사하지 않았다. 대신 원본의 구조를 유지한 채 중간값만 제거했다. variants도, AnimatePresence도, 트윈의 타임라인도 원본 그대로이고, 달라진 것은 height가 곡선의 중간을 밟지 않는다는 것뿐이다. 이 접근의 힘은 재현하려고 하지 않은 것들이 저절로 맞아떨어진다는 데서 드러났다.

  • ±8px 마진 점프의 위치가 자동으로 맞았다. 래퍼가 원본과 같은 height 상태(0 → px → auto)를 같은 시점에 거치므로, 마진 컬랩스도 같은 지점에서 같은 방식으로 일어난다.
  • LazyMotion의 chunk 로딩 타이밍 문제가 구조적으로 사라졌다. 첫 재구현은 밀림(ResizeObserver, 즉시 시작)과 카드 모션(framer, chunk 도착 후 시작)의 구동원이 달라 어긋날 수 있었는데, 최종 구현은 밀림의 트리거가 카드 모션의 height 계단 그 자체라서 둘이 어긋날 방법이 없다.
  • 애니메이션 중간에 끊고 닫는 인터럽션도 맞았다. 원본의 퇴장 트윈과 FLIP의 transition이 같은 delay, 같은 duration, 같은 곡선을 타므로, 어느 시점에 끊어도 두 구현이 같은 지점에서 이어 간다.

일반화하면 이렇게 정리할 수 있을 것 같다. 어떤 시스템을 "동일하게" 재현해야 할 때, 관찰된 출력(값)을 복사하는 접근은 관찰이 완벽해야만 성립한다. 출력을 만들어 내는 메커니즘을 보존하고 비용이 드는 부분만 대체하는 접근은, 관찰하지 못한 성질까지 메커니즘이 대신 보증해 준다. 재현 대상이 이번처럼 "만든 사람의 의도와도 다른" 시스템이라면 관찰이 완벽하기는 더욱 어려우므로, 후자의 가치가 더 커진다.

계측기부터 의심한다

이번 작업에서 "구현이 이상하다"고 보였던 순간의 절반은 실제로는 계측이 이상한 것이었다. 세 번 있었고, 세 번 모두 원인이 달랐다.

첫 번째는 실행 순서였다. 곡선을 기록하는 rAF 샘플러가 어느 순간 계단 방식의 곡선에서 거대한 점프를 찍었다. 실물을 보면 부드러운데 데이터만 점프였다. 원인은 브라우저의 프레임 파이프라인 안에 있었다. rAF 콜백은 같은 프레임의 ResizeObserver 콜백보다 먼저 실행되므로, CSS transition이 height 계단을 밟는 프레임에서는 rAF 시점에 레이아웃은 이미 이동했고 FLIP invert는 아직 적용 전이다. 샘플러는 화면에 페인트된 적이 없는 중간 상태를 한 프레임 잡은 것이다. 흥미롭게도 framer-motion이 height를 쓰는 원본에서는 이 현상이 없는데, framer의 쓰기는 rAF 콜백 안에서 일어나서 샘플러보다 뒤 순서이기 때문이다. 같은 계측 코드가 구현 방식에 따라 거짓말을 하기도 안 하기도 한다는 뜻이다. 고립된 한 프레임 스파이크는 median-of-3(연속 세 샘플의 중앙값)으로 걷어냈다.

두 번째는 측정 준비 단계의 오염이었다. 비교 측정을 위해 재생 전에 이전 배너를 정리(reset)하는데, 이 정리가 exit 애니메이션 없이 즉시 언마운트를 일으키자 FLIP 훅이 그 레이아웃 델타를 받아 300ms 슬라이드를 시작했고, 그 슬라이드가 끝나기 전에 다음 측정이 시작되면서 곡선이 겹쳤다. 측정 대상 행동이 아니라 측정 준비 행동이 데이터에 들어간 경우다. 정리 후 안정될 때까지 기다리는 시간을 넣어 해결했다.

세 번째는 가장 시시한 원인이었는데, 이전 실험에서 켜 둔 CPU 4x 스로틀이 꺼지지 않은 채 곡선 타이밍을 재고 있었다. 마운트 커밋이 200ms씩 걸리며 모든 시작 시점이 밀렸고, 두 구현이 다른 정도로 밀리면서 실제로 없는 차이가 있는 것처럼 보였다.

이 세 경험을 한 문장으로 줄이면, 이상한 결과가 나오면 측정 대상보다 측정 도구를 먼저 의심하는 순서가 시간을 아낀다가 된다. 특히 첫 번째 사례는 rAF, ResizeObserver, 스타일 적용, 페인트 사이의 순서라는, 계측 코드를 짜면서 평소에 의식하지 않는 지점에서 왔다. 프레임 단위 계측을 만든다면 자기 샘플러가 파이프라인의 어느 지점을 읽는지 한 번은 따져 볼 필요가 있다.

"동일함"의 증명 기준을 먼저 세운다

"보이는 모션은 완전히 같게"라는 목표에는 함정이 있다. 판정을 눈에 맡기면 아무것도 증명되지 않는다는 점이다. 첫 재구현은 혼자 보면 그럴듯했다. 다섯 군데가 어긋나 있었는데도 그랬다. 사람 눈은 곡선이 통째로 수십 ms 밀리는 것에는 둔하지만, 그렇다고 "달라도 모른다"고 결론 내리면 곤란하다. 가속 프로파일(easing)의 차이는 위화감으로 감지되기 때문이다. 즉 눈은 어떤 차이는 놓치고 어떤 차이는 잡는, 기준이 불명확한 계측기다.

그래서 "같다"의 판정 지표를 명시적으로 정했다. 아래 콘텐츠의 위치를 프레임마다 기록하고, 이동 거리를 0에서 1로 정규화한 뒤, 10%, 25%, 50%, 75%, 90%를 통과하는 시각을 뽑는다. 이러면 곡선 하나가 숫자 다섯 개로 요약되고, 두 구현의 차이가 두 성분으로 분해된다. 가속 프로파일의 차이는 곡선의 폭 으로 나타나고, 시작 시점의 차이는 곡선 전체의 평행이동 으로 나타난다. 이 분해가 유용했던 이유는 두 성분의 의미가 다르기 때문이다. 곡선 폭의 차이는 구현이 틀렸다는 신호였고, 시작 오프셋은 framer의 시작 스케줄링 지연 같은 원본 자체의 변동 요인이었다. 실제로 크로스 브라우저 측정에서 곡선 폭은 세 엔진 모두 일치했고, 오프셋만 엔진별로 달랐다(WebKit은 1~4ms, Firefox는 40~50ms 수준).

이 지표가 있고 나서야 "동일하다"가 주장에서 명제로 바뀌었다. 첫 재구현은 이 지표에서 즉시 탈락했고(시작이 약 250ms 빠르고 곡선 폭이 달랐다), 최종 구현은 통과했다. 같은 지표를 그대로 들고 WebKit과 Firefox로 가져가서 브라우저 의존성이 없다는 것도 확인할 수 있었다. 비슷한 작업을 한다면 구현보다 지표를 먼저 세우는 편이 좋다고 생각한다. 지표가 없으면 "다 된 것 같다"에서 멈추게 되고, 그 상태의 절반쯤은 이번 첫 재구현 같은 상태일 것이다.

일반화: 레이아웃 애니메이션의 계단화

이 작업을 배너에서 떼어내면 재사용 가능한 패턴 하나가 남는다. 이름을 붙이자면 "레이아웃 애니메이션의 계단화(quantization)"쯤 될 것이다.

한 문장으로: 레이아웃 속성은 곡선의 끝값만 불연속으로 밟게 하고, 눈에 보이는 연속 움직임은 그 차분을 transform으로 보간한다.

레시피는 네 단계다.

  1. 분해: 이 애니메이션에서 눈에 보이는 것과 보이지 않는 레이아웃 계산을 가른다. 배너에서는 아래 콘텐츠의 밀림만 보이는 것이었고, 래퍼의 중간 높이값들은 보이지 않는 계산이었다.
  2. 계단화: 레이아웃 커밋을 곡선의 끝점으로 몰아 리플로우를 프레임 수에 비례하는 비용에서 상수 비용으로 줄인다. 기존 라이브러리를 쓰고 있다면 이번처럼 이징 함수 주입으로 후장착할 수 있고, 애니메이션 정의를 다시 쓸 필요가 없다.
  3. 보간: 연속 움직임은 FLIP이 같은 duration, 같은 곡선으로 대체한다.
  4. 검증: 위치 트레이스를 겹쳐 원래 모션과 같은지를 곡선으로 확인한다.

적용 조건은 명확한 편이다. 이 기법은 레이아웃 변화가 눈에 만드는 효과가 요소들이 통째로 밀려나는 것일 때 성립한다. 아코디언, 배너, 리스트 삽입과 삭제, 높이가 변하는 토스트처럼 아래 것들이 밀려나는 패턴이 해당한다. 반대로 성립하지 않는 경우가 둘 있다. overflow: hidden으로 콘텐츠가 잘리며 드러나는 모습 자체가 연출이라면 clip-path(이것도 컴포지터 속성이다) 쪽으로 가야 하고, 폭이 줄며 텍스트 줄바꿈이 매 프레임 변하는 것처럼 리플로우 자체가 연출이라면 가짜로 만들 방법이 없다.

남는 비용도 그대로 적어 둔다. 계단이 밟히는 순간의 리플로우 1회는 여전히 문서 크기에 비례한다. 다만 원본은 같은 비용을 애니메이션 내내 매 프레임 낸다. transform이 걸려 있는 동안 아래 콘텐츠에 containing block이 생겨 하위 position: fixed의 기준이 바뀌므로 끝나면 걷어내야 하고, 아래 콘텐츠에 transform을 거는 통합 지점이 컴포넌트 밖에 필요해진다. 원본은 이것을 height 리플로우의 부수 효과로 공짜로 얻었는데, 그 부수 효과가 바로 비용의 정체였으므로 공짜였던 것이 명시적이 되는 셈이다.

마지막으로, 이 패턴을 언제 꺼내 쓸지다. "모든 height 애니메이션에 기본 적용"하는 규칙으로 만들면 그 순간 과잉 설계가 된다. 가벼운 페이지에서는 원본도 60Hz를 지키고, 실제로 이 실험장에서조차 M 계열 머신은 4x 스로틀에도 애니메이션 도중 드랍이 없었다. 프로파일러에 매 프레임 Layout이 찍히고, 문서가 무겁고, 프레임 예산이 빠듯한(120Hz 모바일이면 8.3ms다), 세 조건이 겹치는 지점이 올바른 트리거라고 생각한다. 마지막 조건 때문에 "저사양에서만의 문제"라고 좁혀 읽으면 안 된다. 실제로 이 배너는 최신 플래그십에서 끊겼다.

네이티브 앱이라면 어땠을까

이쯤에서 자연스러운 질문 하나를 짚고 간다. 같은 배너를 네이티브 앱으로 만들었어도 끊겼을까. 답은 "함정은 같지만, 밟기 어렵게 만들어져 있다"에 가깝다.

레이아웃을 결정하는 값을 매 프레임 바꾸면 매 프레임 레이아웃이 돈다는 원리는 UI 시스템 공통이다. Android에서 ValueAnimator로 매 프레임 LayoutParams의 높이를 바꾸고 requestLayout()을 부르는 코드는 웹의 height 애니메이션과 같은 구조이고, 실제로 안티패턴으로 통한다. 다른 것은 플랫폼의 기본값이다.

첫째, 기본 애니메이션 경로가 처음부터 이 글의 기법이다. iOS의 표준 패턴, 제약(constraint)을 바꾸고 UIView.animate 안에서 layoutIfNeeded()를 호출하는 방식은 레이아웃을 목표 상태로 1회 계산하고 프레임 보간은 Core Animation이 맡는다. 우리가 만든 "계단화 + 보간"이 그대로 내장돼 있는 셈이다. Android의 ChangeBounds 트랜지션도 매 프레임 레이아웃을 다시 도는 대신 계산된 최종 좌표를 향해 뷰의 bounds를 직접 보간한다. 둘째, 렌더링이 상시 합성이다. iOS의 모든 뷰는 CALayer, 즉 처음부터 GPU 레이어이고 Android도 디스플레이 리스트 기반이라, 뷰가 이동할 때 다시 그리는 게 아니라 레이어를 옮긴다. 이번에 확인한 "지배 비용은 페인트였고, 레이어로 승격하니 원본까지 빨라졌다"는 실험을 기억하면, 네이티브는 그 실험의 세계에서 항상 살고 있다. 셋째, iOS는 한 발 더 나가서 애니메이션 보간이 앱 프로세스 밖의 렌더 서버에서 돈다. 앱 메인 스레드가 통째로 멈춰도 진행 중인 애니메이션은 계속 간다. 웹의 컴포지터 스레드 분리보다 강한 격리다.

거꾸로 보면 웹의 조건이 유난히 나쁜 이유도 정리된다. 문서 전역에 영향을 주는 레이아웃 의미론(마진 컬랩스 같은), 손상 영역을 다시 그리는 페인트 모델, 애니메이션과 비즈니스 로직이 한 스레드를 나눠 쓰는 구조, 그리고 어떤 속성이든 경고 없이 애니메이션하게 해 주는 API까지. 함정은 깊은데 가드레일이 없다. 최근 웹 표준의 방향(View Transitions, scroll-driven animations, @starting-style)이 "선언만 하고 실행은 엔진에 맡기는" 네이티브식 모델로 수렴하고 있는 것도 같은 문제의식일 것이다. 요약하면, 네이티브에서 이 글의 기법은 대체로 불필요하다. 플랫폼이 이미 그 기법이기 때문이다. 이번 작업은 UIKit이 오래전부터 기본으로 주던 것을 웹의 배너 하나에 수공업으로 이식한 일이었다고 볼 수도 있다.

이건 추상적인 비교가 아니라 실제로 겪은 일이기도 하다. 같은 인터랙션을 쓰는 다른 앱의 화면이 유독 매끄러워서 의아했는데, 확인해 보니 React Native 화면이었다. React Native의 뷰는 진짜 네이티브 뷰이고, LayoutAnimation 류의 레이아웃 전환은 "다음 레이아웃을 한 번 계산하고 보간은 네이티브에 맡기는" 구조라 이 글의 계단화가 API의 기본 동작이며, 애니메이션은 JS 스레드와 분리된 채 돈다. 같은 연출이라도 어느 렌더 파이프라인 위에서 실행되느냐가 결과를 갈랐던 셈이다.

framer-motion을 걷어내자는 이야기는 아니다

이 글의 결론이 "framer-motion은 위험하다"로 읽히지 않았으면 해서, 균형을 위해 적어 둔다. 최종 구현의 기본형은 framer-motion을 유지했고, 그 선택에는 이유가 있다.

CSS 완성형을 만들면서 체감한 것인데, 그 코드에서 애니메이션 값은 20줄이고 나머지 100줄은 전부 framer가 API로 흡수해 주던 것들이었다. 인터럽션 시 진행 중이던 계산값을 인라인으로 고정하고 갈아타는 처리, 등장 rAF와 정리 타이머의 취소 조율, exit가 끝날 때까지 언마운트를 미루는 수명 관리 같은 것들이다. 상태가 두 개뿐인 배너라 손으로 짤 만했지, 상태와 전이가 늘어나면 이 비용은 빠르게 커진다. framer-motion의 가치는 애니메이션을 가능하게 하는 데 있는 것이 아니라 이 관리 비용을 흡수하는 데 있고, 그 대가가 앞서 본 실행 모델의 불투명성이다.

그래서 판단 기준을 하나로 줄이면 이렇게 된다. 모션이 "상태 두 개 사이의 전이"면 CSS가 이기고, "상태 기계 + 값 그래프"면 framer-motion이 이긴다. 덧붙여 높이가 고정 옵션 몇 개로 열거되는 디자인이라면 측정 JS마저 없앤 순수 CSS(@starting-style, transition-behavior: allow-discrete, :has() 조합)까지 갈 수 있는데, 그 대가는 기술이 아니라 모든 텍스트에 clamp를 강제하는 디자인 계약이다.

마치며

솔직하게 적어 두면, 이 작업은 최근에 했던 일들 중에서 제일 어려웠다. 코드량으로 보면 이상한 말이다. 최종 diff는 이징 함수 하나와 훅 두 개가 전부이니까. 어려움의 정체는 코드가 아니라 다른 데 있었다.

첫째, 아무것도 고장 나 있지 않았다. 에러도, 실패하는 테스트도, 콘솔 경고도 없었다. 어긋남은 전부 100ms 이하의 타이밍과 8px의 점프처럼, 기준을 만들어 재기 전에는 존재하는지도 알 수 없는 것들이었다. 버그를 고치는 일은 "고장"이라는 신호가 방향을 잡아 주는데, 이 일은 매 단계에서 판정 기준부터 직접 세워야 했다.

둘째, 정답지가 없었다. "원본과 동일하게"의 그 원본이 만든 사람의 의도와 다르게 동작하고 있었으므로, 문서도 코드 주석도 정답이 아니었다. 정답은 실행 중인 브라우저 안에만 있었고, 그것을 꺼내는 계측기마저 세 번 거짓말을 했다. 자를 만들고, 자를 검증하고, 그 자로 잰 값으로 소스를 다시 읽는 순환을 몇 바퀴 돌아야 했다.

셋째, 문제가 한 영역에 머물지 않았다. React의 수명주기, framer-motion의 transition 해석 규칙, CSS 마진 컬랩스, 브라우저 프레임 파이프라인의 콜백 순서까지, 평소에는 서로 몰라도 되는 영역들이 전부 동시에 얽혀 있었다. 어느 하나만 알아서는 어긋남 하나도 설명되지 않았다. 고백하자면 그중 나를 가장 오래 붙잡은 것은 CSS였고, 내가 CSS에 약하다는 것도 어려움에 한몫했다. 마진이 height: 0인 박스를 통과해 컬랩스되는 규칙, transform이 containing block을 만들어 하위 position: fixed의 기준을 바꾸는 규칙, transition 목록을 갈아탈 때 시작값이 어디서 오는지 같은 것들은 스펙에 수십 년 있던 내용인데, 프레임워크 위에서 일하는 동안 그 기초가 얼마나 약해져 있었는지를 이번에 확인했다.

그래서 이 글에 남긴 것도 해법 자체보다 그 순환의 기록에 가깝다. 만들기는 하루면 되는데 같음을 증명하는 데 그 몇 배가 들었고, 돌아보면 그 증명이 이 작업의 본체였다고 생각한다.

관련 글

  • #javascript#animation#web-animations-api

    number-flow를 구형 브라우저로 이식하기: 다섯 가지 결정과 두 가지 번복

    number-flow가 애니메이션을 켜는 최소 버전은 Chrome 125, Safari 17.2다. 이 하한을 Chrome 66과 WebKit 16.4까지 내리는 포크를 만들면서 내린 결정들과, 뒤집게 된 판단 두 가지, 그리고 자동 강등을 포기한 Safari 버그 조사의 기록.

    2026-08-11·44분
  • ◆ 프론트엔드 개발자가 알아야 할 쿠버네티스
    #nodejs#kubernetes#v8

    Node.js 파드는 왜 그 크기인가: NODE_OPTIONS부터 파드 수까지 직접 재본 사이징

    같은 워크로드에 GC 튜닝 플래그 한 줄을 붙였더니 peak RSS가 201MB에서 593MB로 뛰었다. 살아있는 데이터는 그대로였다. 왜 그게 당연한 결과인지 V8 New Space를 직접 측정해 역추적하고, 프론트엔드 개발자가 Node.js 파드를 사이징하는 세 축을 정리한다.

    2026-08-03·84분
  • #react#react-server-components#memoization

    React cache() 딥다이브: 소스 코드로 읽는 요청 단위 메모이제이션

    React cache() 함수의 모든 이상한 규칙은 30여 줄짜리 구현에서 직접 따라 나온다. dispatcher, getCacheForType, WeakMap/Map 트리를 소스 레벨로 따라가며 요청 단위 메모이제이션의 동작을 끝까지 본다.

    2026-05-30·35분
  • #frontend#bundle-analysis#performance

    PR diff에서는 보이지 않는 비용: 우리는 사용자가 받는 코드를 리뷰하고 있지 않다

    코드 리뷰가 놓치는 bundle 비용, PR에 어떻게 띄울 것인가.

    2026-05-03·37분

새 글을 놓치고 싶지 않으시다면 RSS로 구독해 주세요.

RSS 구독 →

yceffort — 프론트엔드 엔지니어입니다. 발표·기술 자문·기고 문의는 이곳에서 받고 있습니다.

← Back to the blogIssue on GitHub →