Three.js 컴포넌트를 React로 구현하고 구조화해보기

2025. 8. 6. 19:23·React

안녕하세요,

오늘 글에서는 React와 Three.js, react-three/fiber 세가지를 이용한 3D 애니메이션을 웹 페이지에 넣는 것과,

작성된 코드의 공통부분들을 뽑아 hook과 util 함수들로 공통화하는 작업들을 기록했습니다.
즐겁게 봐주세요...

 


0. 요구사항

포트폴리오 페이지 작업 중, 핵심 역량 파트에 눈에 띌만한 요소를 넣고 싶어졌음,

 

핵심 역량은 내 능력 중 강조하고 싶은 세가지를 적어놓은 파트다.

세개가 연속하는 섹션이다보니 통일감이 있어야 하는데,
주제와 통일감, 퀄리티 세가지 모두 높은 이미지를 구하는 게 쉽지 않았다.

 

아이콘? 막상 넣으니 너무 허전해보였다.
사진? 적당한 게 없다.
일러스트같은 걸 넣으면 딱일 것 같은데...

 

AI 친구들에게 우선 부탁해봄.

요즘 이미지 생성으로 가장 애용하고 있는 구글 제미나이 친구

음... 그냥 직접 만들자...!


1. 초기 기획

사실 포트폴리오 초안을 잡을 때부터 꾸준히 시도해보고 싶었던 요소가 있었다.

그것은 바로... 3D와 픽셀의 조합!

이번 2025 FrontEnd 컨퍼런스 페이지가 너무 예뻤기도 하고,
그냥 픽셀과 3D가 조합된 레트로한 이미지들이 내 취향이기도 하다.

 

다만 지금 만든 포트폴리오 페이지는 이미 모던, 깔끔으로 방향을 잡았기에,
레트로한 느낌은 빼고, 작은 점들로 기하학적인 이미지를 넣으면 꽤 잘어울리지 않을까 싶었다.

초기 레퍼런스 사진. (출처: Gemini)

위 레퍼런스를 참고로, 정육면체를 컨셉으로 잡았다.

 

내가 핵심역량으로 뽑은 세 개는 초기 구축, 리팩토링, 협업 이었다.


위 세가지를 정육면체로만 표현... 표현....

 

몇 차례 뒤엎다가 컨셉을 정했다.

 

초기 구축: 텅 빈 공간에서 정육면체가 생성되는 이미지

리팩토링: 흐릿한 정육면체가 선명해지는 이미지

협업: 여러개의 정육면체가 합쳐져 하나가 되는 이미지

 

아래는 미리 보는 완성작.

 

계속 움직이는 이미지는 너무 정신 사나울까봐

일부러 명도 대비도 낮추고 세 요소의 통일감도 신경썼다.

(정육면체와 점의 크기, 애니메이션 이후 회전, 잠깐 멈추기 등을 전부 통일함)


2. 애니메이션 개발

구현에 관련된 세부적인 코드 설명은 건너뛰겠음.

const VERTICES = [
  [-1, -1, -1],
  [1, -1, -1],
  [1, 1, -1],
  [-1, 1, -1],
  [-1, -1, 1],
  [1, -1, 1],
  [1, 1, 1],
  [-1, 1, 1],
];

const EDGES = [
  [0, 1],
  [1, 2],
  [2, 3],
  [3, 0],
  [4, 5],
  [5, 6],
  [6, 7],
  [7, 4],
  [0, 4],
  [1, 5],
  [2, 6],
  [3, 7],
];

const EDGE_DIVISIONS = 20;

export default function DotsBuildCube() {
  const pointsRef = useRef<THREE.Points>(null);
  const groupRef = useRef<THREE.Group>(null);
  const [phase, setPhase] = useState<string>('build');
  const clock = useRef(0);
  const rotateClock = useRef(0);

  const { mergedPositions, currentPositions } = useMemo(() => {
      const points = [];
    for (const [startIdx, endIdx] of EDGES) {
      const start = new THREE.Vector3(...VERTICES[startIdx]);
      const end = new THREE.Vector3(...VERTICES[endIdx]);

      for (let i = 0; i < EDGE_DIVISIONS; i++) {
        const t = i / (EDGE_DIVISIONS - 1);
        const point = new THREE.Vector3().lerpVectors(start, end, t);
        points.push(point.x, point.y, point.z);

      }
    }
    return {
      mergedPositions: new Float32Array(points),
      currentPositions: new Float32Array(
        Array.from({ length: points.length }, () => 0),
      ),
    };
  }, []);

  const resetPosition = () => {
    if (!groupRef.current) return;
    groupRef.current.visible = false;
    for (let i = 0; i < getPointsCount() * 3; i++)
      currentPositions[i] = 0;

    // 위치, 회전 초기화
    groupRef.current.position.set(0, 0, 0);
    groupRef.current.rotation.set(0, 0, 0);
  }

  useFrame((_, delta) => {
    if (!pointsRef.current || !groupRef.current) return;

    const posAttr = pointsRef.current.geometry.attributes.position;
    clock.current += delta;

    switch (phase) {
      case 'build': {
        groupRef.current.visible = true;
        const totalPoints = getPointsCount();
        const progress = Math.min(clock.current / 2, 1);
        const visiblePoints = Math.floor(totalPoints * progress);

        if (!posAttr) break;

        for (let i = 0; i < visiblePoints * 3; i++)
          currentPositions[i] = mergedPositions[i];

        for (let i = visiblePoints * 3; i < totalPoints * 3; i++)
          currentPositions[i] = 0; // 안보이게 0 위치 처리

        posAttr.needsUpdate = true;

        if (progress >= 1) {
          clock.current = 0;
          setPhase('rotate');
        }
        break;
      }
      case 'rotate': {
        rotateClock.current += delta;
        const t = Math.min(rotateClock.current / DURATION.rotate, 1);
        const eased = easeInOut(t);
        groupRef.current.rotation.y = eased * Math.PI * 2;
        if (t >= 1) {
          rotateClock.current = 0;
          groupRef.current.rotation.y = 0;

          clock.current = 0;
          setPhase('conveyor');
        }
        break;
      }

      case 'conveyor': {
        const t = Math.min(clock.current / DURATION.conveyor, 1);
        const distance = 20; // 오른쪽으로 이동할 거리

        const eased = easeInOut(t); // 컨베이어 이동 easing
        groupRef.current.position.x = eased * distance;
        groupRef.current.position.z = -eased * distance;

        if (t >= 1) {
          resetPosition();
          clock.current = 0;
          setPhase('build');
        }
        break;
      }
    }
  });

  return (
    <group
      ref={groupRef}
      scale={[2.5, 2.5, 2.5]}
    >
      <points ref={pointsRef}>
        <bufferGeometry>
          <bufferAttribute
            args={[currentPositions, 3]}
            attach="attributes-position"
          />
        </bufferGeometry>
        <pointsMaterial
          color="#66ccff"
          depthWrite={false}
          size={0.1}
          sizeAttenuation={true}
        />
      </points>
    </group>
  );
}

 

대략적인 개요만 설명하자면,


<points> 태그가 실제로 그려지는 점의 집합이고,
<bufferGeometry>와 <bufferAttribute>가 점의 위치와 관련된 정보를,
<pointsMaterial>이 점의 색상과 크기와 같은 재질 정보를 정의하는 태그다.


<group>는 말 그대로 이를 하나로 묶어주는 태그다,

 

처음 로드가 될 때 점 위치들의 초기 위치와 목표 위치를 설정한 뒤에,
useFrame을 이용해 애니메이션 프레임마다의 움직임을 설정한다.

 

이때, 애니메이션이 동작 -> 회전 -> 정지 세가지의 상태 변화를 하기 때문에,
phase 라는 변수를 state로 관리하였고,
현재 진행 시간을 측정하는 clock 변수를 ref로 선언해
매 시간 변화마다 기록하고, 일정 시간 도달시 단계를 넘길 수 있도록 했다.

 

지금까지 작성된 코드도 충분히 원하는대로 잘 동작하고, 깔끔한 코드지만,
문제는 위와 거의 비슷한 코드가 세개 있다는 점이다...

 

동작 구현은 끝났으니 코드를 깔끔하게 정리해보자.

(그렇다, 이 글의 주 목적은 코드 구현 설명이 아닌 리팩토링 설명이었다.)


3. 리팩토링

위에도 말했듯, 이 코드에는 중복된 부분이 많다.

하나씩 찾아내어 공통화해보자.

 

1) 정육면체 생성 로직

우선 각각의 애니메이션을 보며 공통되는 부분을 추측해보자.

 

세개 다 정육면체를 기반으로 그려지는 애니메이션이니,
정육면체를 생성해내는 쪽의 코드가 비슷할 것 같다.

 

정육면체를 그리기 위해서는 정육면체 각 꼭짓점과 꼭짓점을 잇는 모서리들의 정보값이 필요하다.
이 정보들이 배열로 저장되어 하드코딩 되어있는데,
이 배열들도 세 가지 파일에 각각 나눠져있었다.

정육면체를 그리기 위한 정보들이 한 곳에 모이게 되면 좋을 것 같다.

 

정육면체를 그리는 곳의 코드를 살펴보자.

(...)
const points = [];
for (const [startIdx, endIdx] of EDGES) {
  const start = new THREE.Vector3(...VERTICES[startIdx]);
  const end = new THREE.Vector3(...VERTICES[endIdx]);

  for (let i = 0; i < EDGE_DIVISIONS; i++) {
    const t = i / (EDGE_DIVISIONS - 1);
    const point = new THREE.Vector3().lerpVectors(start, end, t);
    points.push(point.x, point.y, point.z);

  }
}
return {
  mergedPositions: new Float32Array(points),
  currentPositions: new Float32Array(
    Array.from({ length: points.length }, () => 0),
  ),
};
(...)

 

위에서 하드코딩해둔 모서리 배열(시작점과 종료지점의 인덱스값)을 반복하면서,

현재 그릴 선의 시작점과 끝점의 좌표값을 구한 뒤에, 

이후 설정해둔 모서리 당 점 수만큼 반복하며 해당하는 곳의 좌표를 기록한다.

 

기록된 좌표를 Float32Array에 담아 반환하면, 해당 값이 points의 좌표로 쓰이게 된다.

 

로직을 간소화하면 다음과 같다.

  1. 이중 반복문을 순회한다. (이때 나온 좌표값을 모으면 기본적인 정육면체가 된다.)
  2. 원하는 형태로 좌표를 수정한다.
  3. 해당 좌표 목록을 반환한다.

이 중 1번과 3번은 모든 정육면체에 공통으로 사용되는 로직이고, 

2번만이 각 애니메이션별로 차이가 발생하는 로직이다.

 

반복문을 순회하고 특정 로직을 취한 뒤 배열을 반환하는 로직은 자바스크립트의 map과 유사하다.

해당 부분을 map과 유사한 유틸 함수로 뽑아내 공통화해보자.

 

import { Vector3 } from 'three';

const VERTICES = [
  [-1, -1, -1],
  [1, -1, -1],
  [1, 1, -1],
  [-1, 1, -1],
  [-1, -1, 1],
  [1, -1, 1],
  [1, 1, 1],
  [-1, 1, 1],
];

const EDGES = [
  [0, 1],
  [1, 2],
  [2, 3],
  [3, 0],
  [4, 5],
  [5, 6],
  [6, 7],
  [7, 4],
  [0, 4],
  [1, 5],
  [2, 6],
  [3, 7],
];

const EDGE_DIVISIONS = 20;

export const mapCubePoints = <T>(
  callback: (params: {
    point: Vector3;
    start: Vector3;
    end: Vector3;
    t: number;
    edgeIndex: number;
    pointIndex: number;
  }) => T,
): T[] => {
  const result: T[] = [];
  EDGES.forEach(([startIdx, endIdx], edgeIndex) => {
    const start = new Vector3(...VERTICES[startIdx]);
    const end = new Vector3(...VERTICES[endIdx]);

    for (let pointIndex = 0; pointIndex < EDGE_DIVISIONS; pointIndex++) {
      const t = pointIndex / (EDGE_DIVISIONS - 1);
      const point = new Vector3().lerpVectors(start, end, t);
      result.push(
        callback({
          point,
          start,
          end,
          t,
          edgeIndex,
          pointIndex,
        }),
      );
    }
  });
  return result;
};

export const getPointsCount = () => EDGES.length * EDGE_DIVISIONS;

 

우선, 모든 정육면체가 동일하게 가지는 정육면체 생성을 위한 상수값들을 한 파일에 몰아놨다.

공통 함수의 핵심 로직인 mapCubePoints는

callback 함수를 인자로 받고, callback함수에 반복문 중 사용되는 값들을 넘겨서, 그 결과물을 결과 배열에 추가한다.

 

위 함수가 어떤식으로 쓰일 수 있는지 예시 코드와 함께 살펴보자!

const { mergedPositions, currentPositions } = useMemo(() => {
    const points = mapCubePoints(({ point }) => {
      return [point.x, point.y, point.z];
    }).flat();

    return {
      mergedPositions: new Float32Array(points),
      currentPositions: new Float32Array(
        Array.from({ length: points.length }, () => 0),
      ),
    };
  }, []);

 

위 코드는 빈 화면에서 정육면체가 그려지는 애니메이션의 일부다.

필요한 좌표값은 초기값 (빈 화면), 완성값 (정육면체의 온전한 좌표) 이렇게 두가지이다.

따라서 정육면체 좌표를 순회하는 mapCubePoints에서

point의 좌표값을 온전히 반환해 mergedPositions라는 배열로 반환하고,

초기값은 같은 길이의 0으로 초기화된 배열을 반환해서 사용한다.

 

위 코드는 간단한 예시니 조금 더 복잡한 예시도 함께 살펴보자.

 

const {
    mergedPositions,
    fallStartPositions,
    fallEndPositions,
    currentPositions,
  } = useMemo(() => {
    const result = mapCubePoints(({ point }) => ({
      merged: [point.x, point.y, point.z],
      fallStart: [
        point.x + jitter(),
        point.y + jitter() + 3,
        point.z + jitter(),
      ],
      fallEnd: [point.x + jitter(), point.y + jitter(), point.z + jitter()],
    }));

    return {
      mergedPositions: new Float32Array(result.flatMap(r => r.merged)),
      fallStartPositions: new Float32Array(result.flatMap(r => r.fallStart)),
      fallEndPositions: new Float32Array(result.flatMap(r => r.fallEnd)),
      currentPositions: new Float32Array(result.flatMap(r => r.fallStart)),
    };
  }, []);

 

위 코드는 위에서부터 흐릿한 형태의 정육면체가 떨어져, 바닥에 닿은 뒤 점점 선명한 형태가 되는 정육면체 애니메이션의 일부다.

애니메이션의 단계에 따라 총 네가지의 좌표값 배열이 필요하다.

  1. 흐릿한채로 떨어지기 시작할 때의 좌표값
  2. 흐릿한채로 떨어진 후의 좌표값
  3. 선명한 형태가 된 후의 좌표값 (기본적인 정육면체 좌표와 동일하다.)
  4. 현재 상태를 저장하는 좌표값
    (지금 다시보니 위 좌표는 다른 목표치 좌표값과는 차이가 있어 아예 다른 코드로 분리되는게 나을수도 있겠단 생각이 든다...)
(잠깐 설명) 위 코드 중 jitter 함수는 랜덤한 소수값을 반환하는 커스텀 함수다.
실제 정육면체 위치에서 어긋나게 해 흩어져보이게 하기 위해 추가했다.

이때 동일한 반복문을 여러번 반복하게 하지 않기 위해, 
동일 함수에서 각각의 좌표값을 모두 생성한 후에 객체에 담아 반환하게끔 했다.

 

최종 반환 부분에서는 result에서 특정 키만 모아 반환하도록 flatMap 함수를 사용했다.

 

2) 애니메이션 전환 로직

현재의 애니메이션은 모두 세가지의 전환 단계를 가진다.

  1. 애니메이션 (정육면체 생성, 결합 등...)
  2. 회전
  3. 유지

이 중 1번을 제외한 나머지는 모두 동일하니 통합할 수 있을 것 같다.

 

우선 애니메이션 전환 로직이 어떤식으로 동작하고 있는지 확인해보자.

case 'animate':
  const t = Math.min(clock.current / 2, 1); // 진행시간부터 목표시간(2)까지 얼마나 지났는지 확인하기 위한 변수
  (...)
  if (t >= 1) { 	// 만약 목표 시간에 도달했다면
    clock.current = 0;	// 현재 시간을 초기화
    setPhase('rotate'); // 다음 단계의 애니메이션으로 단계 조정
  }
  break;

Three.js를 리액트화한 @react-three/fiber에서는

애니메이션을 useFrame이라는 Hook을 통해 관리할 수 있게 되어있다.

이때 useFrame의 콜백에 매개변수로 전달되는 delta값은 이전 프레임에서 현재까지 경과한 시간을 나타낸다.

이 delta값을 매 프레임마다 clock 이라는 ref 객체에 더해서 애니메이션의 진행 시간을 추적할 수 있다.

 

현재는 clock값을 목표시간값으로 나눠서 현재 애니메이션 도중 진행률을 파악하고,

이 진행률이 1에 도달하면 다음 애니메이션으로 넘어가게 된다.

 

위 로직은 모든 애니메이션마다 동일하게 적용되어있어서

각 파일별로 3번씩, 세 개의 파일을 합치면 총 9번의 동일 코드가 중복되게 된다.

 

그리고 애니메이션 단계 전환인 setPhase에서 리터럴 문자열을 매개변수로 넘기고 있는 점도 재사용성이 떨어진다.

 

해당 부분을 수정해보자.

type Phase = 'animate' | 'rotate' | 'hold';
const PHASE_ORDER: Phase[] = ['animate', 'rotate', 'hold'];
const DURATION: Record<Phase, number> = {
  animate: 2,
  rotate: 1,
  hold: 1,
};

 

 

우선 애니메이션 단계를 Phase라는 타입으로 선언한 뒤,

PHASE_ORDER라는 상수 배열을 선언해 각 단계의 순서를 계산할 수 있게 했다.

또  Phase 타입을 키로, 숫자를 값으로 가지는 DURATION이라는 Record 객체를 선언해 각 단계별 지속시간을 할당했다.

const goNextPhase = () => {
    clock.current = 0;
    const currentIndex = PHASE_ORDER.findIndex(p => p === phase);
    const nextIndex = (currentIndex + 1) % PHASE_ORDER.length;
    setPhase(PHASE_ORDER[nextIndex]);
  };

 

위에서 만든 타입과 값들을 기반으로 다음 단계 애니메이션으로 전환하는 부분을 공통 함수로 만들었다.

 

여기까지 공통화한 내용을 적용한 전체 코드를 살펴보자.

'use client';
import { useRef, useMemo, useState } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { easeInOut } from 'motion';
import { getPointsCount, mapCubePoints } from '@/utils/three-utils';

type Phase = 'animate' | 'rotate' | 'conveyor';
const PHASE_ORDER: Phase[] = ['animate', 'rotate', 'conveyor'];
const DURATION = {
  animate: 2,
  rotate: 1,
  conveyor: 1.5,
};

export default function DotsBuildCube() {
  const pointsRef = useRef<THREE.Points>(null);
  const groupRef = useRef<THREE.Group>(null);
  const [phase, setPhase] = useState<Phase>('animate');
  const clock = useRef(0);
  const rotateClock = useRef(0);

  // 각 점별 랜덤 초기 X,Z 위치와 낙하 속도 생성
  const { mergedPositions, currentPositions } = useMemo(() => {
    const points = mapCubePoints(({ point }) => {
      return [point.x, point.y, point.z];
    }).flat();

    return {
      mergedPositions: new Float32Array(points),
      currentPositions: new Float32Array(
        Array.from({ length: points.length }, () => 0),
      ),
    };
  }, []);

  const goNextPhase = () => {
    clock.current = 0;
    const currentIndex = PHASE_ORDER.findIndex(p => p === phase);
    const nextIndex = (currentIndex + 1) % PHASE_ORDER.length;
    setPhase(PHASE_ORDER[nextIndex]);
  };

  const resetPosition = () => {
    if (!groupRef.current) return;
    groupRef.current.visible = false;
    for (let i = 0; i < getPointsCount() * 3; i++) currentPositions[i] = 0;

    // 위치, 회전 초기화
    groupRef.current.position.set(0, 0, 0);
    groupRef.current.rotation.set(0, 0, 0);
  };

  useFrame((_, delta) => {
    if (!pointsRef.current || !groupRef.current) return;

    const posAttr = pointsRef.current.geometry.attributes.position;
    clock.current += delta;

    switch (phase) {
      case 'animate': {
        groupRef.current.visible = true;
        const totalPoints = getPointsCount();
        const progress = Math.min(clock.current / 2, 1);
        const visiblePoints = Math.floor(totalPoints * progress);

        if (!posAttr) break;

        for (let i = 0; i < visiblePoints * 3; i++)
          currentPositions[i] = mergedPositions[i];

        for (let i = visiblePoints * 3; i < totalPoints * 3; i++)
          currentPositions[i] = 0; // 안보이게 0 위치 처리

        posAttr.needsUpdate = true;

        if (progress >= 1) goNextPhase();
        break;
      }
      case 'rotate': {
        rotateClock.current += delta;
        const t = Math.min(rotateClock.current / DURATION.rotate, 1);
        const eased = easeInOut(t);
        groupRef.current.rotation.y = eased * Math.PI * 2;
        if (t >= 1) {
          rotateClock.current = 0;
          groupRef.current.rotation.y = 0;

          goNextPhase();
        }
        break;
      }

      case 'conveyor': {
        const t = Math.min(clock.current / DURATION.conveyor, 1);
        const distance = 20; // 오른쪽으로 이동할 거리

        const eased = easeInOut(t); // 컨베이어 이동 easing
        groupRef.current.position.x = eased * distance;
        groupRef.current.position.z = -eased * distance;

        if (t >= 1) {
          resetPosition();
          goNextPhase();
        }
        break;
      }
    }
  });

  return (
    <group
      ref={groupRef}
      scale={[2.5, 2.5, 2.5]}
    >
      <points ref={pointsRef}>
        <bufferGeometry>
          <bufferAttribute
            args={[currentPositions, 3]}
            attach="attributes-position"
          />
        </bufferGeometry>
        <pointsMaterial
          color="#66ccff"
          depthWrite={false}
          size={0.1}
          sizeAttenuation={true}
        />
      </points>
    </group>
  );
}

(이때 당시엔 애니메이션이 움직임 -> 회전 -> 대기 가 아니고 움직임 -> 회전 -> 옆으로 빠지기였음..)

 

하드코딩도 많이 없어지고 겹치는 부분도 상당 부분 공통함수화 되었다.

 

하지만 여전히 많은 양의 코드가 중복되고 있다.예를 들어 회전하는 애니메이션과 대기하는 애니메이션은 모든 부분에서 공통으로 사용되고 있다.

 

다만 해당 로직 내부에는 ref객체가 사용되고 있어 일반적인 유틸 함수로는 분리가 불가능하다.

 

이제부턴 React의 hook을 사용해 공통화해보도록 하자.

 

3) 공통 애니메이션 추출

React의 customHook은,

컴포넌트 외부에서 UI를 제외한 로직 부분을 재사용가능하게 만든 함수형 추상화라고 볼 수 있다.

import { RefObject } from 'react';
import { easeInOut } from 'motion';
import { Group, Object3DEventMap } from 'three';

export default function useDotsAnimation({
  clock,
  groupRef,
  onAnimateEnd,
  duration = {
    rotate: 1,
    hold: 1,
  },
}: {
  clock: RefObject<number>;
  groupRef: RefObject<Group<Object3DEventMap> | null>;
  onAnimateEnd: () => void;
  duration?: Record<string, number>;
}) {
  const rotate = () => {
    if (!groupRef.current) return;
    const t = Math.min(clock.current / duration.rotate, 1);
    const eased = easeInOut(t);
    groupRef.current.rotation.y = eased * Math.PI * 2;
    if (t >= 1) {
      groupRef.current.rotation.y = 0;
      onAnimateEnd();
    }
  };

  const hold = () => {
    const t = Math.min(clock.current / duration.hold, 1);
    if (t >= 1) onAnimateEnd();
  };

  return {
    rotate,
    hold,
  };
}

 

 

위의 코드에서 useDotsAnimation hook은 함수 첫 생성 시

현재 진행 상황을 알려주는 clock 객체,

animation을 적용할 groupRef,

애니메이션 종료시 동작할 onAnimateEnd,

동작 시간을 뜻하는 duration을 매개변수로 받는다.

반환값으로는 rotate와 hold 함수를 호출한다.

 

반환된 함수들이 호출될때마다 처음 매개변수로 받은 값들의 현재값을 기반으로 계산해 로직이 발생된다.

 

사실 이 글을 쓰기 전까지만해도

위 코드 내부에 rotateClock 이라는 별도의 ref 객체가 있어서

해당 객체값을 기반으로 rotate 애니메이션이 동작하고 있었는데,

글을 쓰면서 코드를 다시 확인하니

어차피 각 애니메이션은 배타적으로 동작해 굳이 별도의 시간 측정용 ref 객체를 둘 필요없겠다는 결론이 났다.

 

따라서 위 hook은 hook 빠진 custom hook...

홍철없는 홍철팀...이 되어버림.

 

리액트 hook을 아예 사용하지 않는다면 사실상 유틸함수라고 볼 수 있다.

customHook의 형태를 버리고 아예 유틸함수로 리팩토링하자.

다만 groupRef, onAnimationEnd, duration 등은 처음 함수를 불러올때 쓰인 뒤에 변경되지 않는 값이니,

매번 매개변수로 넘길 필요가 없도록 클로저를 활용한 형태는 유지하도록 하자.

export default function getBaseDotsAnimation({
  groupRef,
  onAnimateEnd,
  duration = {
    rotate: 1,
    hold: 1,
  },
}: {
  groupRef: RefObject<Group<Object3DEventMap> | null>;
  onAnimateEnd: () => void;
  duration?: Record<string, number>;
}) {
  const getProgressedTime = (time: number, duration: number) =>
    Math.min(time / duration, 1);

  const rotate = (time: number) => {
    if (!groupRef.current) return;
    const t = getProgressedTime(time, duration.rotate);
    const eased = easeInOut(t);
    groupRef.current.rotation.y = eased * Math.PI * 2;
    if (t >= 1) {
      groupRef.current.rotation.y = 0;
      onAnimateEnd();
    }
  };

  const hold = (time: number) => {
    const t = getProgressedTime(time, duration.hold);
    if (t >= 1) onAnimateEnd();
  };

  return {
    rotate,
    hold,
  };
}

 

customHook이 아니게 되었으니, 이름의 use로 시작하는 네이밍 규칙도 제거했다.

사용하는 쪽에서는 현재 시간도 clock이라는 ref 객체로 관리하고 있어서

첫 함수 호출시에 clock 객체도 넘겨 rotate와 hold에 아무런 매개변수도 넘기지 않게도 할 수 있는데,

의미상 매 호출때마다 시간을 넘기는게 더 맞다고 느껴져서 위 형태를 유지했다.

 

4) HTML 분리 및 context 추가

지금까지는 js의 코드를 봤다면, 이제는 UI, 즉 HTML 태그들의 공통화도 진행해보자.

<group
  ref={groupRef}
  scale={[5, 5, 5]}
>
  <points ref={pointsRef}>
    <bufferGeometry>
      <bufferAttribute
        args={[currentPositions, 3]}
        attach="attributes-position"
      />
    </bufferGeometry>
    <pointsMaterial
      color={'#000'}
      depthWrite={false}
      size={0.1}
      sizeAttenuation={true}
    />
  </points>
</group>

 

세개의 애니메이션의 HTML태그들은 모두 위와 유사한 형태를 띠고 있다.

이 중 두개는 위와 완전히 같은 구성으로 되어있고,

세개의 정육면체가 합쳐져 하나가 되는 애니메이션만 points 태그가 세번 반복되어 그려지는 형태로 되어있다.

 

points 태그가 반복될 수도 있고 단일로만 쓰일 수도 있다면,

points 태그를 가진 컴포넌트를 만들어서 group 태그의 자식으로 넘겨주는 형태로 설계하면 되겠다.

 

또, 각 애니메이션별 차이점은 첫 정육면체 생성과 메인 애니메이션 동작 두개뿐인데, 

마침 두개 다 points 태그와 연관된 로직이라 깔끔하게 분리가 되겠다는 생각이 들었다.

 

그럼 다시한번 각자의 역할을 정리해보자.

  • group 컴포넌트
    • 모든 points를 한데 묶어놓은 역할
    • 스케일 조정과 회전 애니메이션, 대기 애니메이션을 담당한다.
  • points 컴포넌트
    • 정육면체 하나
    • 정육면체의 초기 모양, 변경 모양, 최종 모양과 그에 따른 애니메이션을 담당한다.

전체적인 애니메이션 흐름과 단계 할당은 group 컴포넌트에서 전부 처리하고

특화된 애니메이션만 points에서 처리하도록 하면 된다.

 

이때 애니메이션이 변경된다해도 points 태그의 HTML은 동일하기 때문에,

좌표값 배열만 prop으로 넘겨 받도록 설정하자.

// DotsPoint.tsx
'use client';

import { RefObject } from 'react';
import { Points } from 'three';

export default function DotsPoints({
  currentPositions,
  pointsRef,
}: {
  currentPositions: Float32Array<ArrayBuffer>;
  pointsRef: RefObject<Points | null>;
}) {
  return (
    <points ref={pointsRef}>
      <bufferGeometry>
        <bufferAttribute
          args={[currentPositions, 3]}
          attach="attributes-position"
        />
      </bufferGeometry>
      <pointsMaterial
        color="#000"
        size={0.1}
      />
    </points>
  );
}
// DotsGroup.tsx
'use client';

import { ReactNode, useRef, useState } from 'react';
import { Group } from 'three';
import { useFrame } from '@react-three/fiber';
import { DotsContext } from '@/component/dots-animation/DotContext';
import useDotsAnimation from '@/component/dots-animation/useDotsAnimation';

type Phase = 'animate' | 'rotate' | 'hold';

export default function DotsGroup({ children }: { children: ReactNode }) {
  const PHASE_ORDER: Phase[] = ['animate', 'rotate', 'hold'];
  const DURATION: Record<Phase, number> = {
    animate: 2,
    rotate: 1,
    hold: 1,
  };
  const groupRef = useRef<Group>(null);
  const [phase, setPhase] = useState<Phase>('animate');
  const clock = useRef(0);

  const goNextPhase = () => {
    clock.current = 0;
    const currentIndex = PHASE_ORDER.findIndex(p => p === phase);
    const nextIndex = (currentIndex + 1) % PHASE_ORDER.length;
    setPhase(PHASE_ORDER[nextIndex]);
  };

  const { rotate, hold } = useDotsAnimation({
    clock,
    groupRef,
    onAnimateEnd: goNextPhase,
  });

  useFrame((_, delta) => {
    if (!groupRef.current) return;
    clock.current += delta;
    switch (phase) {
      case 'rotate':
        rotate(delta, DURATION.rotate);
        break;
      case 'hold':
        hold(DURATION.hold);
        break;
    }
  });
  return (
    <DotsContext
      value={{
        groupRef,
        phase,
        setPhase,
        phaseOrder: PHASE_ORDER,
        duration: DURATION,
        clock,
        goNextPhase,
      }}
    >
      <group
        ref={groupRef}
        scale={[5, 5, 5]}
      >
        {children}
      </group>
    </DotsContext>
  );
}

 

애니메이션 단계 관련 로직들은 전부 DotsGroup 컴포넌트에 넣은 뒤에,

자식 컴포넌트에서도 쓸 수도 있을 변수, 함수들은 context로 넘기기로 했다.

 

위의 DotsGroup과 DotsPoints를 사용해 구현한 최종 코드는 아래와 같다.

'use client';

import { useContext, useMemo, useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { Points } from 'three';
import { getPointsCount, mapCubePoints } from '@/utils/three-utils';
import DotsGroup from '@/component/dots-animation/DotsGroup';
import { DotsContext } from '@/component/dots-animation/DotContext';
import DotsPoints from '@/component/dots-animation/DotsPoints';

function DotsBuildingPoints() {
  const pointsRef = useRef<Points>(null);
  const context = useContext(DotsContext);

  if (!context) throw new Error();

  const { mergedPositions, currentPositions } = useMemo(() => {
    const points = mapCubePoints(({ point }) => {
      return [point.x, point.y, point.z];
    }).flat();

    return {
      mergedPositions: new Float32Array(points),
      currentPositions: new Float32Array(
        Array.from({ length: points.length }, () => 0),
      ),
    };
  }, []);

  useFrame(() => {
    if (!pointsRef.current || !context.groupRef.current) return;

    const posAttr = pointsRef.current.geometry.attributes.position;

    if (context.phase === 'animate') {
      const totalPoints = getPointsCount();
      const progress = Math.min(
        context.clock.current / context.duration.animate,
        1,
      );
      const visiblePoints = Math.floor(totalPoints * progress);

      for (let i = 0; i < visiblePoints * 3; i++)
        currentPositions[i] = mergedPositions[i];

      pointsRef.current.geometry.setDrawRange(0, visiblePoints);

      posAttr.needsUpdate = true;
      if (progress >= 1) context.goNextPhase();
    }
  });

  return (
    <DotsPoints
      currentPositions={currentPositions}
      pointsRef={pointsRef}
    />
  );
}

export default function DotsBuilding() {
  return (
    <DotsGroup>
      <DotsBuildingPoints />
    </DotsGroup>
  );
}

 


4. 마무리하며

포트폴리오에 사용되는 컴포넌트다보니 실제 프로젝트나 업무에 비하면 엉성한 수준으로 컴포넌트를 제작했다.

여러모로 아쉽고 좀 더 만져서 고도화하고 싶은 부분도 있지만,

단순 정적 페이지인 포트폴리오 사이트인 맥락을 고려하면 이쯤에서 마무리하는 게 적절하단 생각이 든다.

(시간도 없다...)

(저거 말고도 부족한 부분 이미 많다...)

 

아쉬운 점은 있지만,

이런저런 구조를 고민하면서 컴포넌트를 쪼갰다가 합쳤다가 분리했다가 하는 과정은 개인적으로 참 재밌었다.

엉성한 코드지만 나름의 흐름으로 리팩토링한 과정을 기록하고 싶어서 글을 쓴다.

 

언제든 더 나은 방향에 대한 제안이나 피드백은 환영합니다.

감사합니다.

 

'React' 카테고리의 다른 글

리액트 바텀시트 컴포넌트 제작기 (feat. framer-motion)  (1) 2025.08.20
'React' 카테고리의 다른 글
  • 리액트 바텀시트 컴포넌트 제작기 (feat. framer-motion)
저니재
저니재
프론트엔드 위주의 개발글들을 기록합니다.
  • 저니재
    Bit by Bit
    저니재
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • React
      • JavaScript
      • CS
      • 트러블슈팅
      • 학점은행제
      • 번역글
      • etc N
  • 인기 글

  • 최근 글

  • 태그

    휴리스틱 오라클
    리액트
    학점은행제
    tsconfig
    TypeScript
    bdd
    테스트 모듈
    독학학위제
    HOC
    Render Props
    앰비언트 선언
    컴퓨터공학과
    테스트 케이스
    독학사 1단계
    코딩폰트
    행동 주도 개발
    네트워크관리사 2급
    테스트 시나리오
    일관성 검사 오라클
    개발용 폰트
    독학사
    명세 기반 오라클
    샘플링 오라클
    코딩용 폰트
    독학사 교양
    ViTE
    개발폰트
    네트워크관리사
    참 오라클
    테스트 오라클
  • hELLO· Designed By정상우.v4.10.3
저니재
Three.js 컴포넌트를 React로 구현하고 구조화해보기
상단으로

티스토리툴바