'배경 프레임 시퀀스 애니메이션' 시스템을 만들고, 무료로 오픈소스 공개했다
林政賢 ·
지금 tangyi.mx 홈페이지를 아래로 스크롤하면 배경이 함께 움직인다. 멈추면 그 프레임에서 멈추고, 되돌리면 거꾸로 간다. 그건 영상이 아니다.
홈페이지의 '스크롤 제어 배경'을 오픈소스로 공개했다
그것은 코드가 한 프레임씩 구동하는 프레임 시퀀스다. 이걸 만들기 위해 우리는 scroll-frame-sequence를 썼다. 535줄, 의존성 0(zero-dependency)의 JavaScript 라이브러리다. 외부 패키지에 전혀 기대지 않고, 영상을 프레임 시퀀스로 쪼갠 뒤 스크롤 위치로 정밀하게 제어해, 영상 자동 재생과 스크롤 동기화에 대한 브라우저의 온갖 제한을 완전히 우회한다.
개발한 이유는 명확하다. 복잡한 영상 인터랙션 페이지를 만들 때, 잦은 스크롤 아래에서 네이티브 영상이 버벅거리는 걸 참을 수 없었다. 페이지가 프레임을 떨어뜨리는 순간 사용자 경험은 무너진다. 이 시스템은 소재 처리 도구를 Python 명령 네 개로 묶어, 어떤 해상도에도 맞는 스프라이트시트를 바로 뽑아낸다. 인터랙션이 많은 웹페이지를 만들고 있다면, 먼저 우리의 AI 데모에서 이 구조가 모바일에서 어떻게 부드러움을 유지하는지 보라 — 성능만을 위한 게 아니라, 어떤 기기에서든 애니메이션의 시각 품질을 일관되게 하기 위해서다.
스크롤 애니메이션이 휴대폰 메모리를 터뜨리지 않으려면?
이미지는 그냥 이미지라고 생각하겠지만, 브라우저에게 디코딩된 모든 이미지는 메모리를 두고 다투는 존재다. 이게 우리가 개발 중 빠진 가장 큰 구덩이였다. 휴대폰의 렌더링 한계는 아주 잔인해서, 문턱을 넘는 순간 페이지가 바로 튕기거나 멈춘다.
브라우저가 이미지 한 장을 디코딩하는 데 쓰는 메모리는 고정 공식이다: 가로 × 세로 × 4 bytes. 즉 스프라이트시트(spritesheet)의 원본 파일이 작아도, 디코딩 후 크기는 순식간에 기기를 넘칠 수 있다.
실측: 스프라이트시트 해상도 vs 디코딩 후 메모리
그래서 스프라이트시트를 특정 규격에 묶고, 한 장에 지나치게 높은 해상도를 쓰는 걸 금지했다. 이렇게까지 집요하게 메모리 부하를 계산하는 이유는, 모바일 인터랙티브 페이지에서 이미지가 너무 커서 페이지가 다시 로드되면 이탈률이 곧장 치솟기 때문이다. 페이지가 스크롤 중간에 하얗게 변한다면 먼저 스프라이트시트의 원본 크기를 확인하라 — 이미지 한 장이 휴대폰 RAM의 절반을 먹게 두지 마라. 브라우저의 문제가 아니라 개발자인 우리의 책임이다.
로딩이 너무 느리다? 3층 로딩과 이분 접근법
메모리 크래시를 해결하는 것만으로는 부족하다. 사용자가 스크롤할 때 여전히 긴 로딩 바를 기다려야 한다면 잔존율은 여전히 참담하다. 우리는 리소스를 나눠서 전달하는 '3층 로딩'을 설계했다:
- 아래층(약 2MB): 스프라이트시트를 뼈대로 최우선 로드해, 페이지를 열자마자 애니메이션이 스크롤에 반응하게 한다.
- 중간층(약 5MB): 이어서 풀 해상도 프레임을 로드해 아래층의 흐릿한 프리뷰를 차례로 덮는다.
- 위층: 정지 구간의 루프 처리. 사용자가 거기까지 스크롤할 때만 해당 구간의 루프 소재를 동적으로 로드한다.
사용자를 마냥 기다리게 하지 않기 위해 다운로드 순서는 '이분 접근법'을 쓴다. 첫 프레임부터 마지막까지 순서대로가 아니라 건너뛰며 — 0 → 149 → 75 → 37 → 112, 이런 식이다. 장점은 파일의 약 2할만 도착해도 사용자가 전체 시각 동선을 한 바퀴 볼 수 있다는 것. 중간 몇 프레임이 아직 안 왔어도 리듬은 끊기지 않는다.
이건 페이지를 '더 빨라 보이게' 하는 것만이 아니라, 지연이 큰 모바일 네트워크에서 인터랙션 감각을 유지하는 일이다. AI 데모에서 보는 스크롤 효과의 밑바탕이 바로 이 계층 전략이다 — 한 번에 대용량 파일을 요청하지 않아 서버 부하도, 사용자의 대기 불안도 줄인다.
Python으로 프레임 추출 자동화
프레임 품질과 부드러움을 보장하기 위해 수동 스크린샷을 대체하는 Python 도구 네 개를 썼다. 첫 번째는 프레임 추출 extract_frames.py: 영상이 총 577프레임이면 150프레임 샘플을 권장하고, 시스템은 round(i × 576 / 149)로 정밀 배분한다. 단순히 fps 필터를 거는 것보다 첫 프레임과 마지막 프레임의 정확도를 더 잘 지킨다.
--weighted 파라미터를 더하면 화면 변화가 큰 구간에 더 많은 프레임이 배정되고 정적인 장면은 자동으로 줄어 트래픽을 아낀다. 전체 명령:
python tools/extract_frames.py 원본.mp4 frames/ --count 150 --scale 1920:1080 --quality 72
프레임을 얻은 뒤 build_spritesheet.py로 조각을 합쳐 스프라이트시트를 만든다. 9열(--cols 9)로 설정하면 브라우저가 렌더링 시 이미지 메모리를 효율적으로 처리한다:
python tools/build_spritesheet.py frames/ spritesheet.webp --cols 9
품질에 확신이 없다면 먼저 contact_sheet.py로 프리뷰 한 장을 만들고, --mark로 1, 75, 150 같은 키 프레임 위치를 표시해 샘플링 후 화면 연결이 예상대로인지 확인하라. 이 스크립트들은 예전에 프레임마다 확인하던 시간을 없앴고, 모든 소재가 생산 라인에 들어갈 때 이미 규격화되도록 보장한다.
AI 소재의 처음과 끝이 안 이어진다? Ping-pong 모드와 JSON 좌표표
AI가 생성한 동적 소재에는 치명적 약점이 있다. 첫 프레임과 마지막 프레임이 매끄럽게 이어지지 않아 그대로 루프하면 눈에 띄게 튄다. 실측 결과, 소재 처음과 끝의 차이 배율이 2.3배에서 7.7배 사이면 억지 루프의 결과는 아주 나쁘다. 이럴 땐 처음과 끝이 이어지는 소재를 만드는 데 집착하지 말고 ping-pong 모드로 바꿔라 — 프레임 시퀀스를 '정방향 재생 후 역방향 재생'해서 접합 지점을 물리적으로 피한다.
우리는 이런 요구를 make_loop.py로 처리하며, 루프 프레임 수는 24로 두어 부드러움을 유지한다:
python tools/make_loop.py 루프소재.mp4 hold_mid/ --count 24
루프를 처리해도 모바일 표시는 또 다른 구덩이다. 세로 화면에서 가로 소재를 정확히 보여주기 위해 하드코딩을 버리고 JSON 형식의 '좌표 시간표'로 바꿨다. 각 프레임에 대응하는 frame 번호, 휴대폰에서의 오프셋 cx(화면 너비 비율), fit 모드를 기록한다. 프런트엔드는 스크롤 시 이를 바탕으로 배경의 오프셋과 축척을 동적으로 조정해, 핵심 이미지가 항상 화면 중앙에 오고 화면 크기가 달라도 잘리지 않게 한다. 소재가 휴대폰에서 여백을 보인다면 먼저 JSON의 fit이 cover로 되어 있는지, cx가 화면 중심 오프셋에 맞춰 보정됐는지 확인하라.
가장 은밀한 구덩이 둘: sticky 실패와 데이터베이스 문자열 상한
첫 번째는 position: sticky가 예고 없이 실패하는 것. <html>이나 <body> 레벨에 overflow-x: hidden을 설정하면 브라우저가 새 스크롤 컨테이너(scrolling container)를 강제로 만들어 sticky의 위치 결정 사슬을 바로 끊어 버린다. 해법은 단순하다. body { overflow-x: clip; }으로 바꾸면 넘침은 잘라 내면서 추가 스크롤 컨텍스트를 만들지 않는다.
두 번째는 백엔드에서 났다. 150개의 프레임 URL을 데이터베이스에 저장하려다 단일 텍스트 필드가 길이 제한에 걸린다는 걸 알았다 — 150개 URL을 이어 붙인 문자열은 약 20KB이고, 억지로 넣으면 잘린다. 최종 해법은 '분할 저장':
- 150개 URL을 25개씩 묶어 각각 다른 필드에 저장한다.
- 프런트엔드가 읽을 때 다시 조립하고 결과를
localStorage에 캐시한다. - 캐시 유효 기간은 일주일. 잦은 DB 읽기를 피하고 페이지 로드 시 요청량도 줄인다.
백엔드 통합 시 서로 덮어쓰는 문제를 만나면 데이터 구조에 '시퀀스 식별자'를 넣어라. 이건 우리가 앱 제품을 만들 때 서로 다른 소재 프로젝트를 구분하려고 추가한 핵심 표식으로, 여러 페이지가 같은 업로더를 공유해도 ID 충돌로 데이터가 어긋나지 않게 한다.
화질과 성능의 저울질: WebP 압축과 가중치 설정
극단적인 용량 다이어트를 좇다 보면 '화질을 공간과 맞바꾸는' 한계효용의 함정에 빠지기 쉽다. 실측: JPEG를 전면 WebP로 바꾸면 파일 크기를 약 53% 아낄 수 있다. 가장 효과적인 한 수다. 하지만 더 밀어붙이면 실망한다 — 품질을 q75에서 q55로 낮추면 화면이 눈에 띄게 거칠어지는데 실제로 더 아끼는 건 약 20%뿐. 투자 대비 효과가 극히 낮으니, 대역폭이 극도로 제한된 환경이 아니라면 q72에서 q75를 지키길 권한다. 시각과 용량의 최적 균형점이다.
또 하나 흥미로운 수치: AI 생성 소재의 압축률이 실사 화면보다 좋다. AI 화면은 디테일이 대개 더 매끈해서 실사 촬영보다 약 55% 작다. 하지만 가중치(weight)를 조정할 때 데스크톱 파라미터를 모바일에 그대로 적용하지 마라 — 모바일은 샘플링 프레임 수가 적어서(데스크톱 150프레임, 모바일은 48프레임 권장) 가중치 비율이 따라 조정되지 않으면 스크롤에 심한 덜컥거림이 생긴다. 구현 시 모바일 가중치는 데스크톱의 3분의 1로 유지한다. 감으로 정한 게 아니라, 작은 화면에서 빠르게 스크롤할 때 화면 변화와 스크롤 진행의 동기율을 일정하게 유지하기 위해서다.
구현 가이드와 오픈소스 리소스
이 해법을 scroll-frame-sequence로 정리했다. 535줄, 의존성 0, 정식 오픈소스. 모바일에서 영상 스크롤이 버벅거리거나 브라우저 자동 재생 제한에 시달리고 있다면 바로 개발 목록에 넣어도 된다.
- 소스 저장소: GitHub 저장소, 소재 처리용 Python 스크립트 네 개 포함.
- 패키지: npm 설치로 프런트엔드 프로젝트에 바로 가져오기.
시작하기 전에 데모 페이지에서 실제 스크롤의 부드러움을 먼저 느껴 보길 권한다. 3층 로딩과 이분 접근법을 완전히 시연해 두었다. 데이터베이스 관리가 필요 없다면 manifest.json만 생성하고 스프라이트시트와 프레임 소재를 정적 저장소에 두면, 이 라이브러리로 복잡한 스크롤리텔링 인터랙션을 만들 수 있다. position: sticky 실패나 데이터베이스 문자열 상한 같은 경계 문제를 만나면 문서의 버그 해결 조언을 꼭 참고해서, 프로덕션에서 같은 구덩이를 다시 밟지 마라.
이 글의 숫자에 대하여: 메모리 점유(134MB / 538MB), WebP 압축률(53%), q75→q55의 용량 차(약 20%), AI 소재와 실사의 용량 차(약 55%)는 모두 이 사이트 홈페이지 소재의 실측이며, 소재가 바뀌면 숫자도 달라진다.
저자:林政賢(감독 · Gen AI 크리에이터 겸 엔지니어 · 탕이 스튜디오 창업자)