KKflow

BUG · 버그 도감 #12

꾸미기 화면이 엉뚱한 곳에 열렸다: 0.4초짜리 애니메이션이 남긴 transform

스위트 소트 잼에서 화면 전체를 덮어야 할 꾸미기 화면이 휴대폰에서 어긋난 자리에 열렸습니다. 원인은 화면 전환 애니메이션의 fill-mode 'both'가 끝난 뒤에도 남겨 둔 transform 한 줄. transform이 position: fixed의 기준을 바꾸는 원리와 두 겹의 해결을 정리했습니다.

· KKflow · 읽는 시간 약 6분

버그 도감은 KKflow 프로젝트에서 실제로 만난 버그를 한 편에 하나씩 기록하는 시리즈입니다. 모든 내용은 해당 저장소의 커밋 기록과 코드를 근거로 합니다.

프로젝트 스위트 소트 잼 · 시기 2026년 7월 3일 · 관련 기술 CSS 애니메이션, position: fixed, React 포털

증상: 화면을 덮어야 할 창이 어긋나 있었다

스위트 소트 잼은 컵 속 음료를 같은 색끼리 모으는 정렬 퍼즐에, 번 코인으로 카페를 키우는 경영 요소를 얹은 모바일 웹게임입니다. 7월 3일은 이 게임의 모양이 크게 바뀐 날입니다. 낮 12시 51분(한국 시각)에 카페와 퍼즐을 두 화면으로 나누고 화면을 오갈 때 부드럽게 떠오르는 전환 애니메이션을 붙였고, 오후 5시 1분에는 카페를 직접 꾸미는 꾸미기 모드를 넣었습니다. 꾸미기 모드는 화면 전체를 덮고, 아래쪽에서 구매 시트가 올라오는 구조입니다.

30분 뒤인 오후 5시 31분 커밋이 문제를 적었습니다. 실제 휴대폰에서 꾸미기 화면과 아래쪽 구매 시트가 "어긋난 위치에 열려 고장 난 것처럼 보였다." 그리고 같은 원인으로 구매 시트, 순위표, 판 클리어·막힘 안내 같은 다른 오버레이들도 모두 같은 함정에 빠져 있었다고 덧붙였습니다. 이 커밋은 Claude Code가 작성했습니다.

원인 1: 끝난 애니메이션이 남긴 한 줄

화면 전환 애니메이션은 이렇게 정의되어 있었습니다(app/globals.css).

@keyframes view-in {
  0%   { opacity: 0; transform: translateY(18px) scale(0.98); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}
--animate-view-in: view-in 0.4s cubic-bezier(0.34, 1.3, 0.64, 1) both;

살짝 아래에서 투명하게 시작해 0.4초 동안 제자리로 떠오르는 애니메이션입니다. 문제는 맨 끝의 both입니다. 이건 CSS의 animation-fill-mode로, 애니메이션 앞뒤로 키프레임 값을 어떻게 남길지 정합니다.

  • none — 애니메이션이 도는 동안에만 키프레임 값을 쓴다.
  • forwards — 끝난 뒤에도 마지막 키프레임 값을 계속 유지한다.
  • backwards — 시작하기 전(지연 시간 동안)에 첫 키프레임 값을 미리 적용한다.
  • both — forwards와 backwards를 둘 다.
애니메이션 0.4초 시작 끝 그 뒤로 계속 → none forwards backwards both transform이 남음 ✗ 회색·빨강 = 키프레임 값이 애니메이션 밖에서도 적용되는 구간
고치기 전에는 both였습니다. 애니메이션이 끝난 뒤에도 마지막 키프레임의 transform: translateY(0) scale(1)이 계속 남았습니다. backwards로 바꾸면 시작 전의 깜빡임만 막고, 끝난 뒤에는 아무것도 남기지 않습니다.

both였기 때문에, 애니메이션이 끝난 뒤에도 화면 래퍼에는 마지막 키프레임인 transform: translateY(0) scale(1)이 계속 붙어 있었습니다. 눈으로 보면 아무 변화가 없습니다. 0만큼 옮기고 1배로 키운 것이니까요. 하지만 CSS에게는 이게 "transform 없음(none)"과 다른 값입니다.

원인 2: transform은 position: fixed의 기준을 바꾼다

꾸미기 화면 같은 오버레이는 보통 position: fixed; inset: 0으로 만듭니다. fixed는 원래 "휴대폰 화면(뷰포트)을 기준으로 붙어라"라는 뜻이라, 스크롤이 어디에 있든 화면을 꽉 덮습니다.

그런데 CSS 규칙에는 예외가 있습니다. 조상 요소 중 하나에 transform이 none이 아닌 값으로 걸려 있으면, 그 조상이 fixed 요소의 기준(containing block)이 됩니다. 값이 translateY(0) scale(1)처럼 아무것도 안 하는 값이어도 마찬가지입니다. 커밋 설명이 바로 이 점을 짚었습니다. "transform이 걸린 조상은 position: fixed 자손의 containing block이 된다." 일반적으로 filter, perspective, will-change: transform 같은 속성도 같은 효과를 냅니다.

그래서 꾸미기 화면은 휴대폰 화면이 아니라 화면 래퍼를 기준으로 깔렸습니다. 래퍼가 스크롤로 위로 밀려 있거나 크기가 화면과 다르면, 오버레이도 그만큼 어긋납니다. 아래쪽에 붙어야 할 구매 시트가 화면 밖으로 밀려나는 식입니다.

고치기 전 휴대폰 화면(뷰포트) 화면 래퍼(점선) 꾸미기 화면 래퍼 기준으로 깔림 아래가 화면 밖 고친 뒤 휴대폰 화면(뷰포트) 화면 래퍼(점선) 꾸미기 화면 뷰포트를 꽉 채움 (0, 0, 390×844)
개념도입니다. position: fixed는 원래 휴대폰 화면(뷰포트)을 기준으로 붙지만, 조상 요소에 transform이 있으면 그 조상을 기준으로 붙습니다. 래퍼가 스크롤로 올라가 있거나 크기가 다르면 오버레이가 어긋납니다.

이 버그는 전환 애니메이션이 들어간 낮 12시 51분부터 이미 있었을 가능성이 큽니다. 순위표나 판 클리어 안내도 같은 래퍼 안에 있었으니까요. 하지만 커밋에 따르면 증상이 눈에 띈 것은 화면 전체를 덮고 아래 시트까지 있는 꾸미기 모드가 들어온 뒤였습니다. 그 사이 다른 오버레이에서 문제가 보고된 기록은 없습니다.

해결: 두 겹으로

커밋은 두 가지를 고쳤습니다.

1. 끝난 뒤에는 아무것도 남기지 않기

/* "both"가 아니라 "backwards": 남아 있는 transform이 화면 래퍼를
   containing block으로 만들어 안에 있는 fixed 오버레이를 전부 망가뜨린다 */
--animate-view-in: view-in 0.4s cubic-bezier(0.34, 1.3, 0.64, 1) backwards;

backwards는 시작 전에만 첫 키프레임 값(투명, 18px 아래)을 적용하고, 끝난 뒤에는 키프레임 값을 지웁니다. 마지막 키프레임이 원래 모습과 같으니 화면은 똑같아 보이고, transform은 남지 않습니다. 코드에 왜 both가 아닌지 주석을 남겨 둔 것도 눈여겨볼 만합니다. 나중에 누가 "both가 더 안전해 보이는데?" 하고 되돌리는 일을 막는 한 줄입니다.

2. 꾸미기 화면은 아예 body로 옮기기

// 어떤 조상의 transform·filter에도 갇히지 않도록 <body>로 포털
return createPortal(
  <div className="fixed inset-0 z-50 …"> … </div>,
  document.body
);

React의 createPortal은 컴포넌트는 그 자리에 두고, 실제 HTML 요소만 다른 곳(여기서는 <body> 바로 아래)에 그립니다. body 바로 아래에는 transform을 건 조상이 없으니, fixed가 다시 휴대폰 화면을 기준으로 붙습니다. 첫 번째 수정으로 지금의 원인은 사라졌지만, 커밋 설명대로 "앞으로 어떤 조상 스타일도 가두지 못하게" 한 겹 더 막은 것입니다.

검증은 실제 기기 크기로 했습니다. 모바일 터치 환경을 흉내 낸 390×844 화면에서 꾸미기 모드와 순위표의 위치·크기가 정확히 화면 전체(0, 0, 390×844)와 같아진 것을 확인했다고 적혀 있습니다.

교훈

  • fill-mode는 "기본값처럼" 붙이지 않는다. both나 forwards는 애니메이션이 끝난 뒤에도 스타일을 남깁니다. 끝 모습이 원래 모습과 같은 등장 애니메이션이라면 backwards로 충분합니다.
  • "눈에 안 보이는 transform"도 transform이다. translateY(0) scale(1)은 화면에는 아무 영향이 없지만, fixed 자손의 기준을 바꾸고 새 쌓임 맥락도 만듭니다.
  • 화면 전체 오버레이는 body 가까이에 둔다. 모달·시트·전면 화면은 포털로 body 바로 아래에 그리면, 조상의 스타일이 바뀌어도 영향을 받지 않습니다.
  • 실제 휴대폰 크기로 확인한다. 넓은 데스크톱 화면에서는 래퍼와 뷰포트가 거의 겹쳐 차이가 잘 안 보일 수 있습니다. 이번 검증도 390×844 화면에서 숫자로 했습니다.

스위트 소트 잼이 만들어진 과정은 스위트 소트 잼 제작기에 있습니다. 휴대폰에서만 보이는 버그로는 버그 도감 #9: 스틱을 누르고 있는데 한 발짝만 걸었다도 함께 보셔도 좋습니다.

함께 읽으면 좋은 글