React.js로 Coverflow 만들기: 문제해결과 접근방식

Mar 15, 2025

iOS6의 iPod 앱에서는 음악 앨범을 Coverflow로 볼 수 있었다. Coverflow는 앨범 아트를 가로로 나열한 뒤, 중앙에 크게 표시하고, 좌우로 슬라이드하면 다음 앨범을 볼 수 있는 UI이다.

Human Interface Guidelines의 엄격한 규칙에 따라 개발되었으며, 실제와 같은 물리법칙을 통해 사용자에게 직관적인 경험을 제공한다.

이 글에서는 React.js로 바닥부터 Coverflow를 만드는 과정을 소개한다.

문제 쪼개기

하나의 문제를 해결하는 가장 쉬운 방법은 그 문제를 작은 문제로 쪼개는 것이다. Coverflow를 만들기 위해서는 다음과 같은 작업이 필요하다.

  1. 이미지를 가로방향으로, 입체적으로 나열한다.
  2. 이미지를 드래그했을때, 이미지가 물리법칙에 따라 움직이도록 한다.
  3. 이미지를 클릭했을 때, 해당 이미지가 중앙으로 오도록 한다.
  4. 재사용할 수 있도록, 컴포넌트 외부에서 값을 수정할 수 있도록 한다.

아직은 각 문제가 크다. 이를 더 작은 문제로 쪼개보자.

  1. 이미지를 가로방향으로, 입체적으로 나열한다.
    1. 이미지를 가로방향으로 나열
    2. 이미지를 가로방향으로 나열할 때, 이미지 간의 간격을 조절
    3. 이미지를 가로방향으로 나열할 때, 이미지의 각도를 조절
    4. 이미지를 가로방향으로 나열할 때, 이미지의 크기를 조절
  2. 이미지를 드래그했을때, 이미지가 물리법칙에 따라 움직이도록 한다.
    1. 이미지를 드래그
    2. 이미지를 드래그했을 때, 이미지가 물리법칙에 따라 움직이기
    3. 여러개의 이미지를 함께 움직이기
  3. 이미지를 클릭했을 때, 해당 이미지가 중앙으로 오도록 한다.
    1. 이미지가 선택되면, 해당 이미지가 중앙으로 오도록 하기
    2. 이미지를 클릭했을 때, 선택되도록 하기
  4. 재사용할 수 있도록, 컴포넌트 외부에서 값을 수정할 수 있도록 한다.
    1. 이미지의 크기를 조절하기
    2. 이미지의 크기가 조절되었을 때, 1번 문제에 영향을 주지 않도록 하기

이제 문제가 파악하기 쉽고, 해결가능한 문제가 되었다. 각 문제를 해결하기 위한 합리적인 방법을 찾아서 구현하면 된다.

정의한 문제 해결하기

1. 이미지를 가로방향으로, 입체적으로 나열한다.

html 요소를 가로방향으로 나열하는 방법은 여러가지가 있다. 가장 쉬운 방법은 display: flex를 사용하는 것이다. display: flex를 사용하면, 자식 요소를 가로방향으로 나열할 수 있다. 하지만 Coverflow에서는 요소간 간격이 일정하지 않고, 요소의 순서와 위치에 따라 x값이 변해야 한다.

여기에서는 위치를 완전히 제어하기 위해 position: absolute를 사용한다.

html 요소의 위치를 이동시킬 때, left 와 같은 속성이 아닌, translate를 사용해야한다. 브라우저 렌더링 엔진의 동작을 이해한다고 가정한다.

우선은 중앙을 기준으로 오른쪽의 요소들을 구현한다. 좌측의 요소들은 나중에 구현할 수 있다.

coverflow를 유심히 살펴보면, 중앙에 있는 요소와 두번째 요소의 간격이 제일 크고, 그 다음은 조금씩 작아지다가 유지되는 것을 알 수 있다. 이를 구현하기 위해, 각 요소의 x값을 계산해야 한다. 일단은 각 요소의 크기를 400*400 으로 설정하고, 디자인을 보면서 흉내낸다. 각도와 크기도 같은 방법으로 임의의 값을 구할 수 있다.

위 사진은 표로 정리하고 그래프로 그린 모습이다. 이렇게 시각화하면 직관적으로 애니메이션을 이해할 수 있다.

위의 그래프를 코드로 구현하면 다음과 같다. score 0 부터 시작하는 각 요소의 index이다.

const FIRST_GAP = 160;
const GAP = 80;
const getX = (score: number) => {
  if (score < 1) {
    return score * FIRST_GAP;
  }
  return FIRST_GAP + GAP * score;
};

const getRotateY = (score: number) => {
  if (score < 1) {
    return score * -40;
  }
  return -40;
};

const getScale = (score: number) => {
  if (score < 1) {
    return 1 - 0.2 * score;
  }
  if (score < 2) {
    return 0.8 - 0.05 * (score - 1);
  }
  return 0.75;
};

이제 각 요소의 x값, 각도, 크기를 계산할 수 있다. 이를 이용해 각 요소를 배치하면 다음과 같다.

rotateY를 적용했을 때, 요소가 입체적으로 회전하는 것으로 보이기 위해서는 perspective 속성을 부모 요소에 적용해야한다. 잘 위치하는 것을 확인했으니, 0을 기준으로 x 축에 대칭인 그래프를 그린다고 생각하고, 위의 함수들을 수정한다.

// x
const FIRST_GAP = 220;
const GAP = 80;

// scale
const FISRST_SCALE = -0.2;
const SCALE = -0.05;

const getX = (score: number) => {
  if (score < -1) {
    return -FIRST_GAP + GAP * (score + 1);
  }
  if (score < 1) {
    return score * FIRST_GAP;
  }
  return FIRST_GAP + GAP * (score - 1);
};

const getRotateY = (score: number) => {
  if (score < -1) {
    return 40;
  }
  if (score < 1) {
    return score * -40;
  }
  return -40;
};

const getScale = (score: number) => {
  if (score < -2) {
    return 1 + FISRST_SCALE + SCALE;
  }
  if (score < -1) {
    return 1 + FISRST_SCALE + SCALE * (score + 1);
  }
  if (score < 0) {
    return 1 - FISRST_SCALE * score;
  }
  if (score < 1) {
    return 1 + FISRST_SCALE * score;
  }
  if (score < 2) {
    return 1 + FISRST_SCALE + SCALE * (score - 1);
  }
  return 1 + FISRST_SCALE + SCALE;
};

score를 -1부터 9 까지 설정했을 때, 기대한 모습이 나오는 것을 확인할 수 있다.

2. 이미지를 드래그했을때, 이미지가 물리법칙에 따라 움직이도록 한다.

react-use-gesture를 사용하면, 드래그 이벤트를 쉽게 사용할 수 있다. 그리고 같은 pmndrs의 react에서 spring을 고수준으로 추상화한 react-spring을 사용하면, 간단하게 물리법칙을 적용할 수 있다.

index와 index 사이에서 요소의 위치를 파악하기 위해, score를 구하는 로직이 필요하다. score는 0에서 시작하며, 가운데 위치에서 오른쪽 요소의 위치로 갈때마다 1씩 증가한다. 가장 첫 요소를 x만큼 이동한다고 했을때, 이 x의 변화량을 역산하여 score를 구할 수 있다. getX의 역함수인 getScore를 정의한다.

const getScore = (x: number) => {
  if (x < -FIRST_GAP) {
    return (x + FIRST_GAP) / GAP - 1;
  }
  if (x < FIRST_GAP) {
    return x / FIRST_GAP;
  }
  return (x - FIRST_GAP) / GAP + 1;
};

그리고 기준 요소의 변화량을 이용해 score를 구하는 로직을 구현한다.

const getTransform = (score: number) => {
  return {
    scale: getScale(score),
    x: getX(score),
    rotateY: `${getRotateY(score)}deg`,
  };
};
const [baseX, setBaseX] = useState(0); // 기준 요소(가운데 요소)의 상대적 x 위치
const [covers, coversApi] = useSprings(10, (index) => {
  const score = getScore(baseX) + index;
  return getTransform(score);
});

const handler: Handler<"drag" | "wheel"> = ({ movement: [x] }) => {
  return coversApi.start((index) => {
    const score = getScore(baseX + x) + index; // 위의 설명과 같이, 기준 요소의 위치를 기준으로 x를 이동했을 때의 score를 구한다. 그리고, 각 요소의 index를 더해서 각 요소의 score를 구한다.
  
    return getTransform(score);
  });
};

const bind = useGesture({ onDrag: handler, onWheel: handler });
<div
  {...bind()}
  style={{
    touchAction: "none",
    position: "relative",

    height: COVER_SIZE,

    perspective: "600px",
  }}
>
  {covers.map((props, index) => (
    <animated.div
      key={index}
      style={{
        position: "absolute",
        left: 0,
        top: 0,
        ...props,
      }}
    >
      <Cover />
    </animated.div>
  ))}
</div>

위와 같이 구현하면, 드래그를 통해 모든 요소를 이동할 수 있다.

movement가 아닌 offset을 사용하면, 더욱 직관적으로 구현할 수 있지만, 요소들의 너비의 총합이 고정되어있지 않고, 이후 구현에서 api를 사용해 직접 요소들을 이동해야하기 때문에 액션 별 이동거리를 의미하는 movement를 사용했다.

3. 이미지를 클릭했을 때, 해당 이미지가 중앙으로 오도록 한다.

아직은 요소를 선택할 수 없고, 드래그된 마지막 위치에서 요소가 유지된다. 그리고, 맨 처음과 끝 요소를 넘어서 스크롤해도 멈추지 않고 지속된다. 이 시점에서 필요한 요구사항을 아래와 같이 정리할 수 있다.

  1. 드래그가 멈추면, 가장 가까운 요소를 선택하기
  2. 요소를 클릭했을 때 선택하기
  3. 선택된 요소가 중앙으로 오도록 하기
  4. 맨 처음과 끝 요소를 넘어서면, 가장 가까운 요소를 선택하기

드래그가 멈추었을 때 가장 가까운 요소를 선택하려면, score의 절대값이 0.5보다 작은 요소를 선택하면 된다.

if (Math.abs(score) <= 0.5) {
  setCurrnet(index);
}

handler의 active가 false이면 드래그가 끝난 것이므로, 이때 baseX를 업데이트하고 선택된 요소로 이동하는 로직을 넣을 수 있다.

const handler: Handler<"drag" | "wheel"> = ({ movement: [x], active }) => {
  if (active) {
    return coversApi.start((index) => {
      const score = getScore(baseX + x) + index;

      if (Math.abs(score) <= 0.5) { // 0.5보다 작으면 가장 가깝다고 판단
        setCurrnet(index);
      }

      return getTransform(score);
    });
  }

  setBaseX(-getX(current)); // 기준 요소의 x 위치를 업데이트
  return coversApi.start((index) => { // 선택된 요소로 이동
    return getTransform(index - current);
  });
};

요소를 클릭했을 때 선택되게 하기 위해서는 onClick이벤트를 사용하면 될 것 같다. 하지만, 드래그를 하는 시나리오에서는 클릭이 되면 안될 것이다. 그래서, mouseDown 이벤트와 mouseUp 이벤트를 사용해서, mouseDown시 좌표와 mouseUp시 좌표가 같으면 클릭이라고 판단하고, 클릭한 요소를 선택하고 이동하도록 구현했다. 이 로직은 단순하므로 코드는 생략한다.

맨 끝 요소를 넘어서 드래그했을때 더이상 드래그를 어렵게 하기 위해서는 x의 상대적 값을 계산해서, 가능한 범위를 벗어났는지 판단해야 한다.

const getBoundedX = (baseX: number, x: number, size: number) => {
  const offset = baseX + x;
  const x0 = -getX(0);
  const xMax = -getX(size - 1);
  if (offset > x0) {
    return offset * RUBBER;
  }
  if (offset < xMax) {
    return xMax + (offset - xMax) * RUBBER;
  }
  return offset;
};

그러면 score를 구하는 로직을 아래와 같이 수정할 수 있다.

const boundedX = getBoundedX(baseX, x, covers.length);
const score = getScore(boundedX) + index;

가장 오른쪽 값이 score가 0일때 x 보다 커지지 못하도록 했고, 가장 왼쪽 값이 score가 size-1일때 x 보다 작아지지 못하도록 했다. 불가능한 상황에서 드래그를 어렵게하기 위해 초과된 편차에 RUBBER를 곱해서, 약간의 관성은 가능하도록 구현했다.

위의 코드를 보면, 모든 요소에 대해 getScore를 호출하는 것을 알 수 있다. 요소가 많아졌을때 getScore는 부하를 유발할 수 있으므로, 이를 최적화할 필요가 있다. memo를 사용해서 중복된 연산을 줄이도록 구현한다.

const handler: Handler<"drag" | "wheel"> = ({ movement: [x], active }) => {
  if (active) {
    return coversApi.start((index) => {
      if (index === 0) {
        const boundedX = getBoundedX(baseX, x, covers.length);
        const baseScore = getScore(boundedX);
        memo.current.baseScore = baseScore; // 첫번째 요소에서 baseScore를 저장
      }

      if (Math.abs(memo.current.baseScore + index) <= 0.5) { // baseScore를 사용해서 score를 구한다.
        setCurrnet(index);
        memo.current.current = index; // batch 업데이트로 인해 값이 정상적으로 업데이트되지 않을 수 있으므로 memo를 사용해서 저장
      }

      return getTransform(memo.current.baseScore + index);
    });
  }

  const current = memo.current.current; // 최신의 current를 사용하도록 보장한다.
  setCurrnet(current);
  setBaseX(-getX(current));
  return coversApi.start((index) => {
    return getTransform(index - current);
  });
};

렌더링 하는 쪽에서, 가운데 요소의 z-index를 가장 크게해야 뒤의 요소가 앞의 요소를 가리는 것 처럼 보이지 않는다. 지금은 선택된 요소의 인덱스를 상태로 가지고 있으므로, 쉽게 구현할 수 있다.

<animated.div
  key={index}
  style={{
    position: "absolute",
    left: 0,
    top: 0,
    zIndex: covers.length - Math.abs(current - index),
    ...props,
  }}
>

4. 재사용할 수 있도록, 컴포넌트 외부에서 값을 수정할 수 있도록 한다.

이제 컴포넌트 외부에서 값을 수정할 수 있도록 해야한다. 너비를 처음에는 하드코딩된 400*400으로 설정했지만, 이를 외부에서 수정할 수 있도록 해야한다. 이를 위해 Coverflow 컴포넌트에 prop을 추가하고, 각 요소의 크기와 위치를 계산할 때 이를 사용하도록 수정한다.

props.size가 바뀔때마다 getX, getRotateY, getScale을 다시 계산하도록 해야하므로 Util클래스를 만들어 생성자에서 size를 주입받도록 했다. 이제 아래와 같이 사용할 수 있다.


export class Util {
  private firstGap: number;
  private gap: number;
  private firstScale: number;
  private scale: number;
  private rubber: number;

  constructor(size: number) {
    this.firstGap = (size * 220) / 400;
    this.gap = (size * 80) / 400;
    this.firstScale = -0.2;
    this.scale = -0.05;
    this.rubber = 0.15;
  }
  /*구현*/
}

const util = useMemo(() => new Util(size), [size]);
const score = util.getScore(baseX) + index;
return util.getTransform(score);

위와같이 구현하고 약간의 css 추가로 완성할 수 있다.

결과 보기

마우스로 드래그를 하거나, 요소를 선택해서 상호작용할 수 있다. 전체 화면으로 보기

마치며

이 글에서는 React.js로 Coverflow를 만드는 과정을 소개했다. 문제를 쪼개어 해결하는 방법을 설명하고, 각 문제를 해결하는 방법을 소개했다. 처음에는 어려워보였던 문제도, 작은 문제로 쪼개고 하나씩 해결하니 쉽게 해결할 수 있었다. 더 자세한 문제해결 과정이 궁금하다면 github에서 커밋 내역과 코드를 확인해보자. 이 글이 단순히 Coverflow의 구현이 아닌, 새로운 문제를 마주했을 때, 어떻게 접근해야 하는지에 대한 가이드가 되었으면 좋겠다.