[Frontend] 상태 기반 인터랙션 구현기

YuminPark·2026년 1월 25일

Frontend

목록 보기
9/17
post-thumbnail

UMC 9기 데모데이를 위해 사용자에게 최적의 기기 조합을 제공하는 웹 서비스 Device Life 를 개발 중이다. 이 서비스에는 사용자가 직접 조합 이름을 만들고 기기 검색 단계로 넘어가는 ‘조합 생성’이라는 핵심 플로우가 있다. 단순히 하나의 기능이라기보다는, 사용자가 이 서비스와 처음으로 본격적인 상호작용을 시작하는 지점에 가깝다.
Figma를 보아하니 버튼을 누르면 바로 페이지가 바뀌는 방식이 아니라, 이 페이지 내에서 지금 어떤 일이 일어나고 있는지를 사용자가 자연스럽게 따라갈 수 있는 인터랙션 방식으로 구현되어 있었다.

아래 GIF는 Figma 프로토타입을 그대로 구현하여 조합 이름을 입력하고 생성 버튼을 눌렀을 때 실제로 동작하는 인터랙션 모습이다.이 글에서는 이 인터랙션을 구현하면서 어떤 지점을 고민했고, 그 고민을 어떤 상태 설계와 코드 구조로 풀어냈는지를 정리해보려고 한다.


1. 이 인터랙션을 어떻게 바라봐야 하나?

처음 Figma에서 이 인터랙션을 봤을 때, 단순히 “애니메이션이 들어간다”는 인상은 아니었다.
사용자 입장에서 보면 입력창에 적은 조합명이 갑자기 사라지는 것이 아니라, ‘내가 만든 결과물’이 되어 중앙으로 이동하고, 그 결과물이 점점 정리되면서 다음 단계로 넘어가도 된다는 신호를 받게 된다.

여기서 내가 중요하다고 느낀 포인트는 두 가지였다.
1. 이 흐름이 끊기지 않아야 한다는 점
2. 사용자가 굳이 생각하지 않아도 '지금 조합이 만들어졌구나'를 느낄 수 있어야 한다는 점이었다.

그래서 이 인터랙션을 “움직임을 어떻게 예쁘게 만들까?”보다는, 상태가 어떻게 변하고 그 변화가 사용자에게 어떻게 전달되는가라는 관점에서 바라보게 됐다. 이 관점이 이후 모든 구현 선택의 기준이 됐다.


2. 인터랙션 흐름을 '상태'로 분해

상태 기반으로 구현하기로 마음먹고 나서 가장 먼저 한 일은, 이 인터랙션을 단계적으로 분해하는 것이었다. 프로토타입을 여러 번 반복해서 보면서, 실제로 어떤 장면들이 순서대로 이어지는지 하나씩 정리했다.

내가 정리해본 흐름은 다음과 같았다.여기서 고민했던 건 이걸 애니메이션 단계로 나눌 것인가, 상태로 나눌 것인가였다.
애니메이션 단계로 나누면 구현은 빠를 수 있지만, 나중에 흐름을 수정하거나 단계가 늘어났을 때 감당하기 어려워진다. 그래서 결과 UI가 지금 어떤 단계에 있는지를 하나의 상태로 표현하는 쪽을 선택했다.

type ResultPhase = 'idle' | 'shrink' | 'stack' | 'done';

이 phase는 애니메이션의 이름이 아니라, UI의 의미적인 상태에 가깝다.

  • shrink : 정리되는 중
  • stack : 완성 단계로 넘어가는 중
  • done : 다음 행동을 할 수 있는 상태

정도로 정의해두었고, 이렇게 정의해두니 이후 구현에서 판단 기준이 훨씬 명확해졌다.

여기서 짚고 넘어가야할 점이 idle 이라는 상태인데, idle은 말 그대로 아무 인터랙션도 일어나지 않는 상태라기보다는 결과 UI가 “등장하기 직전”에 머무르는 준비 상태에 가깝다.
이 인터랙션에서는 입력창의 텍스트가 중앙으로 이동하는 동안에는 아직 결과 카드가 화면에 나타나면 안 된다. 하지만 이동이 끝나는 순간, 결과 UI는 이미 중앙에 자리 잡은 상태로 등장해야 하고, 곧바로 shrink 애니메이션이 이어져야 한다.

이때 만약 idle 상태가 없으면 구현이 꽤 애매해진다... 🥹

  • 결과 UI를 아예 렌더링하지 않거나
  • 등장과 shrink를 동시에 처리하거나
  • 여러 boolean으로 보이는지 / 줄어드는지를 나눠야 한다

이런 선택지들이 생기는데, 어느 쪽이든 흐름이 깔끔하지 않다.

그래서 이 구현에서는 결과 UI를 먼저 렌더링해 두되, 아직 아무 변화도 일어나지 않은 상태를 idle로 정의했다.

setMode('result');
setResultOn(true);
setPhase('idle');

이렇게 하면 결과 UI는 이미 중앙에 준비된 상태로 존재하고, 다음 프레임에서 shrink로 넘어가면서 자연스럽게 첫 애니메이션이 시작된다!

requestAnimationFrame(() => {
  setPhase('shrink');
});

즉, idle은 사용자에게는 거의 인지되지 않지만, 렌더링 타이밍과 애니메이션 시작 시점을 분리하기 위한 중요한 완충 상태다. 이 상태를 명시적으로 두었기 때문에 결과 UI의 첫 등장 프레임이 튀지 않고 shrink 애니메이션이 안정적으로 적용되면서 이후 단계들도 예측 가능한 흐름으로 이어질 수 있었다.


3. 애니메이션 값 상수 분리

구현을 시작하고 나서 가장 많이 바뀐 건 로직이 아니라 숫자였다. 줄어드는 속도가 너무 빠르거나, 떠오르는 거리가 과하거나, 다음 단계로 넘어가는 타이밍이 어색한 경우가 계속 생겼다. 이때마다 컴포넌트 안에서 숫자를 직접 고치고 있는 건 너무 비효율적이었다.

그래서 아예 처음부터 '이 인터랙션에서 쓰이는 모든 수치는 설정값이다'라고 생각하고, 전부 COMBO_MOTION으로 분리했다.

export const COMBO_MOTION = {
  HEADER_H: 80,
  INNER_W: 600,
  INNER_H: 72,
  SHRINK_W: 558,
  SHRINK_H: 66,
  OUTER_W: 638,
  OUTER_H: 111,
  T_SHRINK: 420,
  T_STACK: 520,
  DROP_DURATION: 1500,
  DROP_EASING: 'cubic-bezier(0.12, 0.95, 0.18, 1)',
  LIFT_DISTANCE: 90,
  LIFT_DURATION: 1500,
  LIFT_DELAY: 180,
  LIFT_EASING: 'cubic-bezier(0.12, 0.9, 0.18, 1)',
  DOUBLE_DELAY: 160,
  EXTRAS_AT_LIFT_PROGRESS: 0.01,
} as const;

이렇게 분리해두니, 디자이너님 피드백이나 직접 느낀 어색함을 바로 수치로 조정할 수 있었고, 왜 이 값이 여기 들어가 있지? 같은 고민도 줄어들었다. 결과적으로 구현과 튜닝의 경계가 명확해졌다.
상수로 분리하는 습관이 좋다는 건 알고 있었지만, 이 인터랙션을 구현하면서 그 필요성을 정말 뼈저리게 느꼈다...🚶🏻


4. 입력창 텍스트 이동은 React 밖에서 처리

이 인터랙션에서 가장 눈에 띄는 장면은 입력창에 있던 조합명이 중앙으로 이동하는 부분이다. 하지만 구현을 고민하면서 가장 조심했던 것도 바로 이 지점이었다.
입력창은 이미 포커스 처리, 입력 유효성 검증, 상태 관리 등 여러 책임을 가지고 있는 컴포넌트였고, 여기에 이동 애니메이션까지 얹는 순간 구조가 급격히 복잡해질 게 뻔했다.

그래서 입력창을 직접 움직이는 대신, 연출만을 담당하는 DOM 요소를 하나 만들어서 이동시키는 방식을 선택했다.

const floating = document.createElement('div');
floating.textContent = text;
floating.style.position = 'fixed';
floating.style.pointerEvents = 'none';
document.body.appendChild(floating);

이 floating 요소는 화면에 보이는 역할만 하고, 사용자 입력이나 React 상태에는 전혀 관여하지 않는다. 애니메이션이 끝나면 바로 제거되기 때문에, 흐름을 따라가면서도 기존 컴포넌트 구조를 건드리지 않을 수 있었다.

이 모든 인터랙션은 조합 생성 버튼 클릭 시 한 번의 start() 호출로 시작되며, 입력값 검증과 동시에 배경을 서서히 흐리게 처리해, 이후 이어질 결과 인터랙션에 시선이 집중되도록 설계했다.


5. floating이 실제 UI처럼 보이게 만들기

연출용 DOM을 따로 만들기로 결정했을 때 가장 먼저 떠올랐던 문제는, 중앙으로 날아가는 동안 텍스트의 스타일이 실제 UI와 달라질 수 있다는 점이었다.
floating 요소는 React 컴포넌트가 아니라 document.createElement로 만든 DOM이기 때문에, 아무 설정도 하지 않으면 기본 브라우저 스타일을 그대로 사용하게 된다. 이렇게 되면 중앙으로 이동하는 순간 폰트, 크기, 굵기, 배경, 그림자 등이 입력창 UI와 미묘하게 달라지고, 사용자는 그 차이를 바로 어색하게 느끼게 된다.
그래서 floating의 스타일을 하드코딩하는 방식은 처음부터 배제하고, 대신 실제 UI와 동일한 스타일을 그대로 가져와 적용하는 방법을 선택했다. 이를 위해 화면 밖에 스타일 복사용 컴포넌트를 하나 두었다.

<div
  ref={styleProbeRef}
  className="fixed -left-10000 -top-10000 w-600 h-72 rounded-button bg-white border-shadow-blue"
/>

이 컴포넌트는 화면에는 보이지 않지만, 실제 결과 UI와 동일한 클래스와 디자인 토큰을 가지고 있다.

여기서 새롭게 알게된 것이 바로 getComputedStyle이다!

getComputedStyle은 특정 요소에 실제로 적용된 최종 스타일 값을 가져오는 Web API로, class나 CSS 파일로 정의된 스타일까지 모두 포함한 결과를 반환한다.

floating DOM을 만들 때 이 요소의 getComputedStyle 값을 읽어 그대로 복사하면, 중앙으로 이동하는 동안에도 폰트, 크기, 색상, 그림자 등이 실제 UI와 완전히 동일하게 유지된다.
이 방식 덕분에 디자인이 바뀌더라도 floating 연출 코드를 수정할 필요가 없고, 중앙으로 날아가는 텍스트가 연출용 요소가 아니라 원래 UI가 그대로 이동하는 것처럼 보이게 만들 수 있었다!


6. 애니메이션 이후 상태 전환 설계

텍스트가 중앙으로 이동하는 애니메이션 자체는 비교적 단순하게 구현할 수 있었다. 문제는 그 다음이었다. 이동이 끝나는 순간부터는 floating DOM이 아니라, React가 관리하는 결과 UI가 자연스럽게 이어받아야 했다. 이 연결이 어색하면 사용자는 “날아간 다음에 갑자기 화면이 바뀐다”는 느낌을 받게 된다.

그래서 여기서 가장 신경 쓴 부분은 애니메이션의 종료 시점과 상태 전환 시점을 명확히 분리하는 것이었다. 이동 애니메이션이 끝나기 전에는 결과 UI가 전면에 나오지 않도록 하고, 이동이 끝난 직후에만 결과 UI가 등장하도록 타이밍을 맞췄다.

el.animate(
  [
    { transform: 'translate3d(0,0,0)' },
    { transform: `translate3d(${dx}px, ${dy}px, 0)` },
  ],
  { duration: M.DROP_DURATION, fill: 'forwards' }
);

이 애니메이션이 끝나는 시점에 맞춰 floating을 제거하고, 결과 UI를 렌더링한 뒤 phase를 idle 상태로 두었다. 그리고 다음 프레임에서 shrink로 전환하면서 본격적인 결과 인터랙션이 시작되도록 했다.

setPhase('idle');
requestAnimationFrame(() => {
  setPhase('shrink');
});

이후에는 shrinkstackdone 순서로 상태를 변경하면서, 애니메이션을 직접 제어하지 않고 상태 변화에 따라 UI가 자연스럽게 반응하도록 구성했다. 이 방식 덕분에 흐름을 수정하거나 타이밍을 조정할 때도 상태 전환만 신경 쓰면 됐다.

인터랙션 중단을 고려한 상태 정리

이 인터랙션은 여러 setTimeout과 애니메이션이 연속으로 이어지는 구조다 보니, 중간에 페이지를 벗어나거나 다시 트리거될 가능성도 함께 고려해야 했다.

그래서 실제 구현에서는 모든 timeout과 애니메이션을 추적해 두고, 사용자가 인터랙션 도중 다른 페이지로 이동하거나 컴포넌트가 unmount 되는 경우 한 번에 정리하도록 구성했다.
이 덕분에 아래와 같이 인터랙션 도중 화면이 바뀌어도 상태가 꼬이거나 메모리가 남는 문제를 피할 수 있었다.

또 하나 신경 쓴 부분은 floating DOM을 단순히 즉시 제거하지 않는 것이었다.
텍스트가 중앙으로 이동하는 DROP 애니메이션이 끝난 직후 바로 사라지면 화면이 순간적으로 튀는 느낌을 줄 수 있어서, opacity를 이용해 아주 짧게 fade-out 시킨 뒤 제거하도록 처리했다. 이를 통해 floating에서 결과 UI로 넘어가는 전환이 훨씬 부드럽게 느껴졌다.


7. 결과 UI 역시 상태만 보고 그리도록 하기

CombinationResultOverlay 컴포넌트는 이 인터랙션이 어떤 이벤트로 시작됐는지를 전혀 알지 못한다. 입력창에서 시작됐는지, floating이 날아왔는지, 어떤 애니메이션을 거쳤는지는 이 컴포넌트의 관심사가 아니다. 오직 현재 결과 UI가 어떤 상태에 있어야 하는지만 알고 있으면 된다.

그래서 이 컴포넌트는 phase와 몇 가지 플래그 값만 받아서 UI를 결정하도록 만들었다.
예를 들어 shrink 단계에서는 카드의 크기가 줄어들고, stack 단계에서는 바깥 한 겹이 더 쌓인 형태가 되며, done 단계에서는 카드가 위로 살짝 떠오르고 다음 행동을 유도하는 UI가 노출된다.

const innerSize =
  phase === 'shrink'
    ? { w: M.SHRINK_W, h: M.SHRINK_H }
    : { w: M.INNER_W, h: M.INNER_H };
const liftActive = phase === 'done';

이렇게 구현하면 이벤트 흐름이 아무리 복잡해져도, 결과 UI 컴포넌트는 항상 단순한 구조를 유지할 수 있다. 나중에 단계가 하나 더 추가되거나, 특정 단계의 표현만 바뀌더라도 상태 조건만 수정하면 되기 때문에 수정 범위도 명확해진다.
결과적으로 무슨 일이 있었는지와 지금 어떤 모습이어야 하는지를 분리한 것이 이 인터랙션 구조의 핵심이었다!

UI 노출 시점을 상태로 제어

추가로, 결과 UI 내부에서도 모든 요소를 동시에 노출하지 않고 showDouble, showExtras 같은 플래그로 노출 시점을 분리했다.
이를 통해 카드의 형태가 완성된 뒤에만 안내 문구와 버튼이 등장하고, 그 전까지는 pointer-events를 막아 사용자가 조작할 수 없도록 제어했다.
이 역시 “지금 이 단계에서는 무엇을 할 수 있는지”를 상태로 표현한 예시다.


마무리

이 인터랙션을 구현하면서 가장 크게 느낀 점은, 인터랙션의 완성도를 좌우하는 건 애니메이션 기법 자체가 아니라 상태를 어떻게 나누고 연결하느냐라는 점이었다. 애니메이션을 직접 제어하려고 할수록 코드가 복잡해지고, 흐름을 수정하기도 어려워졌다. 반대로 상태를 기준으로 생각하니, 애니메이션은 자연스럽게 따라왔다.

특히 floating DOM, idle 상태, phase 기반 UI 구조 같은 선택들은 처음에는 다소 어렵고 번거롭게 느껴졌지만, 구현이 진행될수록 이래서 이렇게 나누는게 낫구나 하는 확신이 들었다. 이후 타이밍을 조정하거나 디자인이 바뀌었을 때도, 전체 구조를 흔들지 않고 필요한 부분만 손볼 수 있게 되었다!

이 글이 단순히 한 인터랙션을 구현한 기록을 넘어서, 복잡한 인터랙션을 상태 기반으로 정리해나가는 하나의 사고 방식을 공유하는 글이 되었으면 한다. 👀

0개의 댓글