안녕하세요, 뱅크샐러드 프론트엔드 엔지니어 김덕현입니다.
웹뷰 환경에서 게임 엔진 없이 React와 DOM, 그리고 Lottie를 활용해 개발한 “일해라 김뱅샐” 이야기를 해보려고 합니다. 게이미피케이션 서비스를 만들면서 겪은 삽질과 해결 과정을 공유합니다.
뱅크샐러드에 합류하고 얼마 안 됐을 때였습니다. 새로운 프로젝트 미팅에 들어갔는데, 기획안에 게임이 있었습니다.
“내 캐릭터를 키우고, 옷을 갈아입히고, 복권도 긁는 인터랙션이 필요해요.”
당황스러웠습니다. 저는 웹 개발자이지, 게임 개발자가 아니었으니까요. 처음엔 Phaser.js나 Pixi.js 같은 웹 게임 엔진을 검토했지만, 러닝 커브와 일정을 고려했을 때 익숙하지 않은 도구를 도입하는 건 리스크가 컸습니다.
그래서 생각을 바꿨습니다. 게임 엔진은 모르지만, React와 DOM은 충분히 다룰 수 있으니까. 익숙한 도구로 승부해보기로 했습니다.
캐릭터 커스터마이징이 첫 번째 난관이었습니다. 유저가 김뱅샐 캐릭터에 머리, 옷, 안경 등을 자유롭게 조합할 수 있어야 했는데, 조합 수가 상당했고 앞으로도 계속 추가될 예정이었습니다.
모든 조합마다 애니메이션 파일을 제작하는 건 현실적이지 않았습니다. 디자이너분들의 리소스도, 파일 용량도 감당하기 어려웠습니다.
Lottie는 JSON 기반 포맷이기 때문에 런타임에 데이터를 조작할 수 있습니다. 이 점에 착안해서 JSON 구조를 분석해봤습니다. 이미지 에셋이 assets 배열 안에 경로로 정의되어 있었고, 이 경로만 동적으로 교체하면 하나의 Lottie 파일로 다양한 조합을 표현할 수 있겠다는 아이디어가 떠올랐습니다.
const layersToReplace = ['Body', 'Hair', 'Glasses', ...];
layersToReplace.forEach(layerName => {
// 디자이너와 레이어 네이밍 규칙을 맞춰두는 게 중요합니다
const asset = findAssetByLayerName(lottieData, layerName);
const imageUrl = userImages[layerName];
if (imageUrl) {
asset.p = imageUrl;
}
});이 방식으로 Lottie 파일 하나로 수많은 조합을 커버할 수 있게 됐습니다. 디자이너분은 뼈대만 만들고, 개발팀은 JSON만 조작하면 되는 구조가 완성됐습니다.
여기서 끝이 아니었습니다. 캐릭터 렌더링을 구현하고 테스트하는 과정에서, iOS 웹뷰에서 헤어스타일 목록 버튼을 누를 때마다 화면 전체가 리로드되는 현상이 발생했습니다.
처음엔 원인을 파악하기 어려웠습니다. Android에서는 정상 동작했기 때문입니다. 디버깅 결과, absolute inset-0 object-cover로 이미지를 쌓아 올리는 방식이 일부 iOS 디바이스에서 성능 문제를 일으키고 있었습니다.
해결책은 img 태그 대신 background-image를 사용하는 것이었습니다.
기존 방식:
<div className="relative w-[200px] h-[200px]">
<img src={images.BackHair} className="absolute inset-0 object-cover" />
<img src={images.Body} className="absolute inset-0 object-cover" />
<img src={images.FrontHair} className="absolute inset-0 object-cover" />
<img src={images.Glasses} className="absolute inset-0 object-cover" />
</div>개선된 방식:
const layers = [
images.Glasses,
images.FrontHair,
images.Body,
images.BackHair,
];
const backgroundImage = layers
.filter(Boolean)
.map((layer) => `url('${layer}')`)
.join(", ");
return (
<div
style={{
backgroundImage,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
width: "200px",
height: "200px",
}}
/>
);
};background-image는 이미지가 바뀌어도 DOM 트리가 아닌 스타일만 변경되기 때문에 훨씬 안정적이었습니다. 깜빡임과 버벅거림이 모두 해결됐습니다.
모바일 웹뷰에서 애니메이션을 많이 사용하면 60FPS를 유지하는 것만으로는 부족합니다. 사용자 기기의 배터리 소모와 발열까지 고려해야 합니다.
글자가 한 글자씩 나타나는 효과가 필요했습니다. 단순하게 구현하면 이렇게 할 수 있습니다:
const [displayText, setDisplayText] = useState("");
useEffect(() => {
let i = 0;
const timer = setInterval(() => {
setDisplayText((prev) => prev + text[i]);
i++;
}, 100);
return () => clearInterval(timer);
}, [text]);
return <span>{displayText}</span>;하지만 이 방식은 글자가 추가될 때마다 Reflow가 발생합니다. 브라우저가 레이아웃을 매번 다시 계산해야 하기 때문입니다.
접근 방식을 바꿨습니다. 글자를 처음부터 모두 렌더링해두고, opacity만 변경하는 방식입니다:
{text.split("").map((char, index) => (
<span
key={index}
style={{
opacity: isVisible(index) ? 1 : 0,
transition: "opacity 0.2s",
}}
>
{char}
</span>
))}
</Container>이렇게 하면 레이아웃은 최초 한 번만 계산하고, 이후에는 GPU가 처리하는 opacity 변경만 일어납니다. 체감 성능 차이가 확실했습니다.
Framer Motion 같은 라이브러리가 편리하긴 하지만, 가능한 한 CSS 애니메이션을 사용했습니다.
const { controls } = useAnimation();
useEffect(() => {
controls.start({ y: [-10, 10, -10], transition: { repeat: Infinity } });
}, []);/* 브라우저가 알아서 최적화 */
.animate-bounce {
animation: bounce 1s infinite;
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(20px);
}
}CSS 애니메이션은 브라우저의 컴포지터 스레드에서 처리되어 메인 스레드에 영향을 주지 않습니다. 또한 탭이 백그라운드로 전환되면 브라우저가 자동으로 애니메이션을 멈추거나 프레임을 낮춥니다. 배터리 절약에 큰 도움이 됩니다.
움직임이 많은 요소에는 translate3d로 GPU 레이어를 생성했습니다:
.character,
.coin-counter,
.popup {
transform: translate3d(0, 0, 0);
will-change: transform;
}코인 카운터처럼 자주 업데이트되는 값은 별도 컴포넌트로 분리해서, 상태 변경 시 불필요한 리렌더링을 방지했습니다:
// 이렇게 하면 코인 바뀔 때 Header, Character, Shop도 리렌더링됨
const Game = () => {
const [coins, setCoins] = useState(0);
return (
<>
<Header />
<Character />
<Shop />
<CoinDisplay value={coins} />
</>
);
};
// 상태를 분리하면 CoinDisplayWrapper만 리렌더링됨
const Game = () => {
return (
<>
<Header />
<Character />
<Shop />
<CoinDisplayWrapper />
</>
);
};
const CoinDisplayWrapper = () => {
const [coins, setCoins] = useState(0);
return <CoinDisplay value={coins} />;
};처음에는 “게임 엔진 없이 가능할까?” 하는 걱정이 있었지만, 결과적으로 가능했습니다. DOM과 JSON이라는 익숙한 도구를 깊이 파고들면서 오히려 가볍고 유연한 방식을 찾을 수 있었습니다.
물론 복잡한 물리 엔진이 필요하거나 3D가 들어가면 상황이 달라지겠지만, 웹 기술만으로 커버 가능한 영역이 생각보다 넓다는 걸 이번 프로젝트를 통해 경험했습니다.
보다 빠르게 뱅크샐러드에 도달하는 방법 🚀
지원하기