반응형

worker 적용한 cavas(좌측)와 미적용된 canvas(우측), 상단의 count 올라가는 속도가 차이가 난다.

 

 

웹 성능 최적화를 위해 브라우저의 메인 스레드(Main Thread) 부담을 줄이는 것은 매우 중요합니다. 이번 글에서는 웹 초보자도 이해하기 쉽게 Web Worker, OffscreenCanvas, Comlink를 활용해 메인 스레드 작업을 분산하고 성능을 향상시키는 방법을 소개합니다.

 

Web Worker란? 왜 메인 스레드 작업을 분산해야 하나?

Web Worker는 브라우저에서 메인 스레드와 별도로 동작하는 백그라운드 스레드입니다. 쉽게 말해 웹 페이지의 UI와 독립적으로 실행되는 자바스크립트 환경입니다. Web Worker를 사용하면 메인 스레드가 해야 할 무거운 작업을 워커로 위임할 수 있어요

 

이로써 메인 스레드는 보다 중요한 UI 렌더링이나 사용자 입력 처리 등에 집중하고, 무거운 연산으로 인한 프리즈(멈춤) 현상을 줄일 수 있습니다.

 

웹 성능이 저하되는 흔한 이유는 메인 스레드가 과부하되어 UI가 응답하지 않는 상태가 되는 것입니다. 예를 들어 복잡한 계산이나 큰 이미지 처리 등을 메인 스레드에서 하면, 그동안 버튼 클릭이나 화면 렌더링이 멈출 수 있죠. Web Worker로 이러한 작업을 옮기면 브라우저가 다중 스레드처럼 작업하여 UI를 매끄럽게 유지할 수 있습니다. 요약하면, Web Worker는 웹 앱에 멀티스레딩 효과를 주어 성능을 높이는 도구입니다.

 

** 성능상 우월한 방법이 아닙니다! 병렬 처리를 위할때 유용합니다. 

 

OffscreenCanvas: 워커에서 캔버스를 그리기 위한 비밀병기

Canvas를 활용한 그래픽 연산은 웹에서 많이 사용되지만, 기존에는 이 <canvas> 요소가 메인 스레드의 DOM과 연결되어 있어 워커에서 직접 조작할 수 없었습니다. OffscreenCanvas(오프스크린 캔버스)는 이런 제약을 해결해 주는 기술입니다. OffscreenCanvas는 화면에 보이지 않는 캔버스를 의미하며, 캔버스를 DOM과 분리하여 화면 밖(off-screen)에서 렌더링할 수 있게 해줍니다. 덕분에 Web Worker 내부에서도 캔버스에 그림을 그릴 수 있죠.

 

 

오프스크린 캔버스를 쓰는 이유는 간단합니다. 메인 스레드의 부담을 줄이기 위해서입니다. 예를 들어 복잡한 애니메이션이나 물리 시뮬레이션을 Canvas로 구현한다면, 메인 스레드에서 매 프레임 그림을 그리느라 다른 작업을 못할 수 있습니다.

 

OffscreenCanvas를 사용하면 이러한 캔버스 그리기 연산을 워커로 넘겨 병렬 처리할 수 있습니다. 메인 스레드는 UI 업데이트이벤트 처리에 집중하고, 워커는 OffscreenCanvas에 그림을 그린 다음 그 결과만 메인 스레드로 보내 화면에 표시하는 식이죠. 이렇게 하면 캔버스 애니메이션도 부드럽게 돌리고, UI도 끊김 없이 반응하도록 만들 수 있습니다.

 

Comlink: Web Worker와의 통신을 쉽게 해주는 라이브러리

Web Worker를 직접 사용하다 보면, postMessage와 onmessage로 메시지를 주고받는 코드가 다소 번거롭습니다.

 

 

Comlink(컴링크)는 이를 간단하게 만들어주는 경량 라이브러리입니다. Comlink를 쓰면 마치 메인 스레드에서 워커 내부 함수나 변수를 직접 호출하는 것처럼 프로그래밍할 수 있어요.

 

웹 워커와 통신 (postMessage) 흐름도

 

내부적으로는 postMessage를 추상화하여 자동으로 메시지를 전달해주기 때문에, 개발자는 복잡한 메시지 핸들러 대신 평범한 함수 호출 형태로 워커와 소통할 수 있습니다

 

예를 들어 Comlink 없이라면:

  • 메인 스레드에서 worker.postMessage(data)로 데이터를 보내고,
  • 워커 내부에서 self.onmessage로 이벤트를 받아 처리한 뒤,
  • 다시 self.postMessage(result)로 결과를 메인에 보내고,
  • 메인에서는 worker.onmessage로 결과를 받는

일련의 과정을 코딩해야 합니다.

 

Comlink를 쓰면 Web Worker와의 통신을 프록시 객체로 감싸서(일종의 RPC), 개발자가 쉽게 비동기 함수 호출처럼 다루도록 도와주는 도구입니다.

 

예제로 보는 Web Worker + OffscreenCanvas 활용 (Matter.js 물리 시뮬레이션)

이제 간단한 예시로 위 개념들을 연결해보겠습니다. Matter.js라는 자바스크립트 2D 물리 엔진을 이용해 공 몇 개가 튕기는 물리 시뮬레이션을 만든다고 가정해봅시다. 이 시뮬레이션은 계산량이 많으니 Web Worker에서 실행하고, 메인 스레드에서는 현재 프레임 수나 객체 개수 등의 count 상태만 화면에 표시하도록 해볼게요.

 

Main Thread

const canvas = document.getElementById('simCanvas');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('physics-worker.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);

// UI에서 1초마다 count 증가 표시 (워커와는 별개로 동작)
let count = 0;
setInterval(() => {
  document.getElementById('counter').innerText = `Count: ${count++}`;
}, 1000);

 

  • 메인 스레드(main.js): 메인 HTML에 <canvas id="simCanvas"></canvas>와 <div id="counter"></div>가 있다고 합시다. 메인에서는 워커를 생성하고 캔버스를 OffscreenCanvas로 넘깁니다.

 

  • 위 코드에서 transferControlToOffscreen()은 DOM 캔버스를 OffscreenCanvas 객체로 변환하여 워커로 소유권 이전(transfer) 하는 역할을 합니다. 이렇게 하면 이후부터는 워커가 해당 캔버스에 직접 그림을 그릴 수 있어요. 또한 메인에서는 1초마다 단순 카운트 숫자를 올려서 #counter 영역에 표시합니다. 이 count 증가 로직은 메인 스레드에서 별도로 돌기 때문에, 물리 시뮬레이션이 돌아가더라도 UI 카운터가 멈추지 않고 계속 업데이트 됩니다.

 

Worker Thread

importScripts('matter.min.js');  // 워커에서 Matter.js 라이브러리 로드
let engine, render;

onmessage = (event) => {
  const offscreenCanvas = event.data.canvas;
  const ctx = offscreenCanvas.getContext('2d');
  // Matter.js 엔진 초기화 (중력, 세계 설정 등)
  engine = Matter.Engine.create();
  // Matter.js에서는 render를 직접 사용하지 않고 OffscreenCanvas의 ctx로 그림
  function update() {
    Matter.Engine.update(engine, 16);            // 물리 세계 한 스텝 진행 (approx 60fps)
    drawScene(ctx, engine.world);               // 사용자 정의: world의 객체들을 ctx로 그리기
    requestAnimationFrame(update);              // 다음 프레임 업데이트 예약
  }
  update(); // 시뮬레이션 시작
};

 

  • 워커 스레드(physics-worker.js): 워커 쪽에서는 메인으로부터 메시지를 받아 OffscreenCanvas 객체를 얻습니다. 그리고 Matter.js 엔진을 초기화하고, 주기적으로 물리 시뮬레이션 업데이트 + 캔버스 렌더링을 합니다.

 

 

  • 위 예시는 단순화를 위해 의사코드 형태로 나타냈지만, 핵심은 워커에서 주기적으로 Matter.js 엔진을 업데이트하고 OffscreenCanvas의 2D 컨텍스트에 결과를 그림입니다.
  • requestAnimationFrame도 워커 환경에서 사용할 수 있는데, 이 경우 OffscreenCanvas에 그리는 것이므로 메인 화면에 바로 반영됩니다. 메인 스레드는 이 과정에 관여하지 않으므로, 물리 계산과 캔버스 렌더링으로 인한 메인 스레드 블로킹이 발생하지 않습니다. 메인은 그저 앞서 설정한 setInterval로 UI 카운트만 올리고 있을 뿐이죠.

worker 적용한 cavas(좌측)와 미적용된 canvas(우측), 상단의 count 올라가는 속도가 차이가 난다.

 

  • 메인에서의 결과: 실제로 실행해보면, <canvas>에는 워커가 그린 Matter.js 물리 시뮬레이션 (예: 공들이 튕기는 애니메이션)이 매끄럽게 표시되고, 동시에 <div id="counter">에는 1, 2, 3... 하는 카운트 숫자가 끊김 없이 증가하는 것을 볼 수 있습니다. 만약 워커를 사용하지 않고 모든 작업을 메인에서 했다면, 물리 연산이나 그리기로 인해 카운트 업데이트가 지연되거나 멈췄을 겁니다. 이처럼 워커+오프스크린 캔버스 구조는 무거운 연산을 백그라운드에서 처리하면서도 메인 UI를 부드럽게 유지시킵니다.

맺으며

정리하면, Web Worker는 무거운 작업을 메인 스레드에서 떼어내어 비동기로 처리할 수 있게 해주고, OffscreenCanvas는 그런 워커에서 그래픽을 그릴 수 있도록 도와주며, Comlink는 메인-워커 간 통신을 개발자 친화적으로 만들어 줍니다. 이 세 가지를 조합하면, 초기 단계의 웹 개발자도 비교적 쉽게 메인 UI의 성능을 최적화하는 구조를 설계할 수 있습니다.

 

** 다시 적지만, 애니메이션이 좀 더 매끄러워지는거 같은 성능상의 이점은 없습니다

다만 "무거운 연산"을 메인 쓰레드에서 분리 가능합니다. 


reference 

 

https://web.dev/articles/offscreen-canvas

 

 

반응형
반응형

개념 설명: 3D → 2D 투영의 기본 원리

 
3D에 있는 한 점을 2D 화면에 찍는 것을 투영이라고 합니다. 투영을 쉽게 이해하려면, 손전등과 그림자 비유를 떠올릴 수 있습니다. 벽을 화면이라고 생각하고, 3D 물체에 빛을 비춰 벽에 드리운 그림자가 바로 그 물체의 투영입니다. 이때 손전등을 물체에 바짝 가까이 대면 그림자가 물체 크기와 다르게 크게 또는 작게 일그러져 보이는데, 이것이 원근 투영과 비슷합니다. 반대로 태양빛처럼 아주 멀리서 평행하게 오는 빛을 생각해보면, 물체의 그림자는 물체의 크기와 동일한 비율로 찍히게 됩니다. 이처럼 빛이 평행하게 온다면 물체까지의 거리에 상관없이 그림자 크기가 변하지 않는데, 이것이 정투영의 원리입니다.
 
컴퓨터 그래픽스에서 정투영 투영은 실제로 아주 간단한 수학적 변환입니다. 3차원 좌표 (X, Y, Z)가 주어지면, 그대로 X, Y 좌표만 가져오고 Z 좌표는 버립니다. 이렇게 X와 Y를 사용해 평면에 찍으면 3D 점을 2D 화면에 옮길 수 있습니다.
(다른 방법으로는 원근 투영이 있습니다)
 
예를 들어, 높이가 다른 두 사람이 있다고 해도 정투영으로 보면 둘 다 같은 키로 보이겠지만, 원근 투영으로 보면 가까이 선 사람은 크게, 멀리 선 사람은 작게 표현되는 차이가 생기는 것입니다.

 정투영이란 무엇인가?

 
정투영(Orthographic Projection, 직교 투영이라고도 합니다)은 3D 공간에 있는 물체를 2D 화면에 그릴 때, 거리와 상관없이 똑같은 비율로 투영하는 방법입니다. 다시 말해, 가까이 있는 물체나 멀리 있는 물체나 크기 변화 없이 동일한 비율로 그려집니다
 
반대로 현실 세계에서 우리가 보는 모습이나 3D 게임의 일반 카메라 시점은 원근 투영(Perspective Projection)이라고 하는데, 이는 가까운 것은 크게, 먼 것은 작게 보이도록 그리는 방식입니다. 원근 투영 덕분에 우리는 깊이감과 거리감을 느낄 수 있지만, 치수를 정확하게 재거나 평면도면을 그릴 때는 오히려 불편할 수 있습니다.
 

정투영과 원근 투영 차이

 
위 그림은 원근 투영정투영의 차이를 간단히 보여줍니다. 왼쪽은 원근 투영으로 본 큐브(정육면체)이고, 오른쪽은 정투영으로 본 모습입니다. 왼쪽 그림에서는 앞쪽 면의 빨간 테두리가 크게 보이고, 뒤쪽 면의 파란 테두리는 멀어지면서 작아져 내부의 작은 사각형처럼 보입니다.
 
회색으로 그린 모서리 선들도 멀어지면서 서로 가까워져, 마치 선들이 한 점으로 모이는 듯한 원근감이 나타납니다. 반면 오른쪽 정투영 그림에서는 앞면(빨간색)과 뒷면(파란색)의 크기가 동일하게 그려져 있습니다. 모든 모서리 선이 서로 평행하게 표시되며, 거리에 따른 크기 왜곡이 없어서 뒷면이 앞면에 정확히 겹쳐 보입니다. 이처럼 정투영은 거리와 무관하게 실제 크기를 그대로 보여주기 때문에, 도면 작업이나 멀리 있는 객체까지 정확한 치수로 표현해야 하는 경우에 유용합니다.
 
 
이제 실제 코드에서 이 단계들이 어떻게 구현되는지 projectOrtho 함수를 통해 알아보겠습니다.
우선, 원리를 간단히 설명해보자면 아래와 같습니다.
 

  1. 피벗 이동: 3D 점을 기준점(pivot)이 중심이 되도록 좌표를 이동합니다. (예: 카메라가 보는 중심으로 좌표계 원점을 맞춤)
  2. 회전 변환: X축, Y축, Z축 순서로 3D 점을 회전시킵니다. (예: 장면을 위아래로 보기 위해 X축 회전, 옆으로 보기 위해 Y축 회전 등)
  3. 투영: 회전된 3D 좌표에서 Z 값을 버리고 X, Y만 남겨 2D 평면에 투영합니다. (정투영이라서 원근 왜곡 없음)
  4. 배율 조정: 3D 단위를 픽셀 크기에 맞게 확대하거나 축소합니다. (예: 1미터를 100픽셀로 보이게 스케일링)
  5. 오프셋 이동: 계산된 2D 좌표에 화면상의 위치 offset을 더해 캔버스 좌표로 변환합니다. (예: 캔버스 중심이나 좌상단 기준으로 옮김)

 
실제 코드에서 이 단계들이 어떻게 구현되는지 projectOrtho 함수를 통해 알아보겠습니다.
 

코드 분석: projectOrtho 함수 단계별 설명

 
이제 provided된 projectOrtho 함수의 내부를 살펴보며, 앞에서 설명한 정투영 변환의 각 단계를 코드로 확인해봅시다. 이 함수는 3D 좌표를 받아 정투영을 적용한 2D 화면 좌표를 계산해주는데, React + TypeScript로 Canvas에 그릴 때 사용할 핵심 로직입니다. 복잡해 보이지만, 우리가 방금 이해한 다섯 단계를 차례로 수행하고 있을 뿐입니다. 코드와 함께 하나씩 살펴볼까요
 

// 3D 점을 정투영하여 2D 화면 좌표로 변환하는 함수
function projectOrtho(
  point: { x: number, y: number, z: number }, 
  pivot: { x: number, y: number, z: number }, 
  rotation: { x: number, y: number, z: number },  // 각도 값(라디안)
  scale: number, 
  offset: { x: number, y: number } 
) {
  // 1. 피벗 기준으로 좌표 이동 (pivot을 원점으로 옮기기)
  let x = point.x - pivot.x;
  let y = point.y - pivot.y;
  let z = point.z - pivot.z;

  // 2. X축 회전: 위아래 방향 회전 (pitch)
  const cosX = Math.cos(rotation.x);
  const sinX = Math.sin(rotation.x);
  let y1 = y * cosX - z * sinX;
  let z1 = y * sinX + z * cosX;
  y = y1;
  z = z1;

  // 3. Y축 회전: 좌우 방향 회전 (yaw)
  const cosY = Math.cos(rotation.y);
  const sinY = Math.sin(rotation.y);
  let x2 = x * cosY + z * sinY;
  let z2 = -x * sinY + z * cosY;
  x = x2;
  z = z2;

  // 4. Z축 회전: 평면 회전 (roll)
  const cosZ = Math.cos(rotation.z);
  const sinZ = Math.sin(rotation.z);
  let x3 = x * cosZ - y * sinZ;
  let y3 = x * sinZ + y * cosZ;
  x = x3;
  y = y3;

  // 이제 정투영이므로 z는 사용하지 않습니다 (Z값 버림)

  // 5. 스케일 조정: 3D 단위를 화면 확대배율에 맞춤
  x = x * scale;
  y = y * scale;

  // 6. 오프셋 적용: 캔버스 좌표계로 이동 (예: 캔버스 중심을 (0,0)→(offset.x, offset.y)로)
  const screenX = x + offset.x;
  const screenY = y + offset.y;

  // 계산된 2D 화면 좌표 반환
  return { x: screenX, y: screenY };
}

 
 
 

  • 피벗 이동 전후: 3D 공간에 좌표축과 점이 있다고 생각해 봅니다. 피벗 이동을 하기 전에는 점이 원점에서 point.x, point.y, point.z만큼 떨어진 곳에 있습니다. 피벗을 원점으로 맞추고 나면, 이제 그 점은 새로운 좌표계에서 (x - pivot.x, y - pivot.y, z - pivot.z) 위치로 보이게 됩니다. 쉽게 말하면, 기준점을 가운데로 옮겼더니 점의 좌표가 바뀌었다고 이해하면 됩니다. 하지만 실제 공간에서 점의 물리적 위치는 변하지 않았고, 우리가 좌표만 옮겨서 바라보고 있는 것입니다.

 

  • 축별 회전 효과: 3차원 회전 행렬을 이용합니다. 
Rotation Transformation

 
X축, Y축, Z축으로 차례로 회전하면, 3D 점은 공간에서 이리저리 자리를 바꿉니다. X축으로 회전하면 점이 위아래로 이동하는 것처럼 보이고, Y축으로 회전하면 좌우로, Z축으로 회전하면 화면 평면상에서 회전합니다. 최종적으로 세 번의 회전을 모두 마치면, 처음의 3D 점은 우리가 정한 각도에서 바라본 위치로 옮겨져 있게 됩니다. 여러 회전이 한꺼번에 적용되었기 때문에, 점의 새 좌표 (x3, y3, z3)는 원래 좌표와 많이 달라졌겠지만, 이 좌표는 우리가 장면을 해당 각도로 본 경우에 그 점이 어디 있는지를 나타낸다고 생각하면 됩니다.
 

  • 투영 및 스케일: 이제 3D 좌표의 z는 버리고 (x3, y3)만 남깁니다. 이 단계에서 이미 2D 투영이 이루어졌다고 볼 수 있습니다. 남은 (x3, y3)는 아직 수치적으로는 작은 값일 수 있는데, 스케일을 곱해서 화면에 보일 크기로 확대합니다. 만약 점들이 서로 가까이 모여 있었다면 스케일을 키워서 벌려주고, 너무 넓게 퍼져 있었다면 스케일을 줄여서 화면에 잘 들어오도록 할 수 있습니다.

 

  • 오프셋 적용: 마지막으로 offset을 더해 캔버스 좌표로 변환하면, 이제 진짜 화면 픽셀 좌표가 나옵니다. 이 좌표를 사용해서 Canvas에 점을 찍으면, 우리가 3D에서 지정했던 그 점이 2D 화면상의 정확한 위치에 표시됩니다.

 
 
요약하면, projectOrtho 함수는 (피벗 이동) → (회전) → (투영) → (스케일) → (오프셋) 순서로 3D 좌표를 변환하여 2D 화면 좌표를 반환합니다. 
 
 
이제 projectOrtho 함수를 이용해서 실제 HTML5 Canvas에 3D 점들을 찍어보는 간단한 React 컴포넌트를 만들어보겠습니다. React와 TypeScript를 사용하므로, 함수형 컴포넌트와 훅(hook)을 활용해볼게요. 예제에서는 정육면체 모서리 8개 점을 3D 좌표로 정의하고, 이를 정투영으로 화면에 표시해보겠습니다.
 

render({ ctx, engine, id }) {
      const cfg = shapePrefabs.box3D as Shape3DConfig;
      const pos = engine.position3DStore.get(id)!; // { x, y, z }
      const rotation = engine.rotationStore.get(id)!; // { x, y, z }

      const perspective = engine.perspective3DManager.getRotation3D();
      const cameraDistance = engine.perspective3DManager.getCameraDistance();

      const camera = engine.perspective3DManager.getCamera();

      const { width, height, depth } = cfg.size as Shape3DConfig["size"];

      // 8개 꼭짓점 정의 (center 기준)
      const verts = [
        { x: -width / 2, y: -height / 2, z: -depth / 2 },
        { x: width / 2, y: -height / 2, z: -depth / 2 },
        { x: width / 2, y: height / 2, z: -depth / 2 },
        { x: -width / 2, y: height / 2, z: -depth / 2 },
        { x: -width / 2, y: -height / 2, z: depth / 2 },
        { x: width / 2, y: -height / 2, z: depth / 2 },
        { x: width / 2, y: height / 2, z: depth / 2 },
        { x: -width / 2, y: height / 2, z: depth / 2 },
      ];


      // 월드 좌표로 이동 및 투영
      const projected = verts.map((v) => {
        const point = { x: v.x + pos.x - camera.x, y: v.y + pos.y - camera.y, z: v.z + pos.z - camera.z };

        return projectOrtho({
          canvas: ctx.canvas,
          point: point,
          pivot: { x: pos.x - camera.x, y: pos.y - camera.y, z: pos.z - camera.z },
          rotation: {
            ...rotation,
            x: rotation.x + perspective.x,
            y: rotation.y + perspective.y,
            z: rotation.z + perspective.z,
          },
          offset: { x: 0, y: 0 },
          cameraDistance: cameraDistance,
        });
      });

      // 연결할 에지(12개)
      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],
      ];

      for (let k = 0; k < edges.length; k++) {
        const [i, j] = edges[k];
        const p = projected[i];
        const q = projected[j];

        // HSL로 무지갯빛 색상 생성
        const hue = (k / edges.length) * 360;
        ctx.strokeStyle = `hsl(${hue}, 100%, 50%)`;

        // 선 하나만 그리기
        ctx.beginPath();
        ctx.moveTo(p.x, p.y);
        ctx.lineTo(q.x, q.y);
        ctx.stroke();
      }

      ctx.stroke();
    },

 
위 코드에 추상화된 클래스 or 함수가 있어서 이해하기 어려울수도 있는데,

핵심적인 부분은 projectOrtho 함수로 3D를 2D로 변환하는 부분이 핵심이고
나머지는 사각형 정보를 담는 헬퍼 함수라고 보시면 됩니다.  (x,y,z 좌표 정보나 카메라 위치, 줌 인 & 아웃 정보 등)
 
 

canvas에 정투영한 3D 정사각형

 
 
 
 
 
reference
 
https://cynthis-programming-life.tistory.com/entry/3%EC%B0%A8%EC%9B%90-%ED%9A%8C%EC%A0%84-%ED%96%89%EB%A0%AC-%EA%B5%AC%ED%95%98%EA%B8%B0-by-%EC%98%A4%EC%9D%BC%EB%9F%AC%EA%B0%81-Input
 

반응형
반응형

AI가 발전되어
이제 canvas나 svg tag를 직접 조작할수 있는 레벨까지 온거 같네요.
 
이 글에서는 svg에 대해 간단히 설명하고 "Figma"에서 svg를 직접 추출해내어 react로 수작업으로 progressbar를 만들며 간단한 인터렉션을 넣어봅시다.
 

SVG 간단 설명

SVG는 Scalable Vector Graphics의 약자로, 크기를 늘리거나 줄여도 화질이 깨지지 않는 벡터 이미지 포맷이에요
 
쉽게 말해, 그림을 수학적인 좌표와 도형으로 표현하는 언어이지요. 그래서 그림을 태그(tag)와 속성으로 작성하게 되는데, HTML이 <div>나 <p> 같은 태그로 문서를 구조화하듯, SVG도 여러 태그를 사용해요. 겁먹을 필요 없어요. 하나씩 알아볼까요?
 

  • <svg>: 모든 SVG 코드의 뿌리, 도화지 역할을 하는 태그예요. 이 태그 안에 우리가 그리고 싶은 도형들을 넣으면 돼요.
  • <g>: 그룹(group)을 만드는 태그예요. 여러 도형을 <g>로 묶으면 한꺼번에 이동하거나 스타일을 같이 적용하기 편해요. 폴더처럼 묶어준다고 생각하면 쉬워요.
  • <rect>: 사각형(rectangle)을 그리는 태그예요. x, y 좌표와 width, height 속성을 주면 해당 위치에 네모를 그려줘요. 색을 채우고 싶다면 fill 속성으로 색상을 지정하면 돼요.
  • <path>: 가장 만능 도형 그리기 태그예요. 직선, 곡선 등 복잡한 모양은 <path>의 d 속성에 특별한 문자열(그림 그리는 명령어들)을 넣어서 표현해요. 마치 점 잇기 놀이로 그림을 그린다고 상상해보세요.
  • <linearGradient>: 예쁘게 색을 섞어주는 그라디언트(gradient) 효과를 정의하는 태그예요. 시작 색부터 끝 색까지 점차 변하는 색깔띠를 만들 수 있어요. 이 태그는 주로 <defs> 안에 넣어서 정의하고, 나중에 도형에서 fill="url(#그라디언트ID)"처럼 불러내 써요.
  • <stop>: 방금 말한 그라디언트에서 색이 바뀌는 지점을 지정하는 태그예요. offset 속성으로 위치(0%~100%)를 정하고 stop-color로 색을 정해요. 여러 개의 <stop>을 넣으면 색이 여러 번 변할 수도 있어요.
  • <clipPath>: 그림을 자르는 액자 같은 역할이에요. <clipPath> 안에 특정 모양을 그려놓고 다른 도형에 적용하면, 그 모양 안쪽 부분만 보이고 바깥 부분은 잘려 보이게 돼요. 꼭 종이를 오려서 겹쳐놓은 것처럼요.
  • <mask>: 마스크는 투명한 필름지처럼 부분 투명하거나 가려주는 효과를 줄 때 써요. 예를 들어, <mask> 안에 검정~흰색 그라디언트를 넣고 어떤 이미지에 씌우면, 천천히 사라지는 페이드 효과를 낼 수 있어요.
  • <filter>: 블러(흐리게)나 그림자 같은 특수 효과를 줄 때 사용해요. 포토샵의 필터를 떠올리시면 돼요. <filter> 안에 종류와 강도를 지정하고 도형에 적용하면, 그림자가 생기거나 반짝거리게 만들 수도 있어요.

 
어때요, 하나하나 보니까 생각보다 할 만하지요? 😀 이 밖에도 <circle>로 원 그리기, <text>로 글자 쓰기 등 많은 태그가 있지만, 위에 소개한 태그들만 알아도 SVG의 핵심은 대부분 배운 셈이에요. 이제 실제로 SVG를 어떻게 활용하는지 알아볼까요?
 

Figma에서 SVG 추출하기

다운받은 svg 파일들

 
Figma로 멋진 이미지를 만들었다고 가정해봐요. 그 이미지를 그대로 우리 React 앱에 가져오려면 SVG 파일로 추출하면 편해요. 방법은 정말 간단해요. Figma에서 원하는 객체를 선택하고 오른쪽 Export 패널에서 포맷을 SVG로 선택 후 Export 버튼을 눌러보세요.
 
또는 더 쉬운 방법으로, 객체를 오른쪽 클릭한 뒤 Copy as SVG를 선택하면 SVG 코드를 바로 복사할 수도 있어요 이렇게 얻은 SVG 코드나 파일을 이제 React에서 사용해보겠습니다.
 

React와 SVG로 세로 프로그레스 바 만들기

 
이제 Figma에서 가져온 SVG를 이용해, 세로로 된 프로그레스 바(슬라이더)를 만들어 볼 거예요.
이 프로그레스 바는 최소값(min)최대값(max) 두 가지 값을 가지며, 둘 다 사용자가 드래그해서 조절할 수 있는 인터랙티브한 컴포넌트예요. React와 TypeScript로 구현하면서, SVG로 도형을 그리고 pointer 이벤트로 드래그 기능까지 넣어볼게요!
 

1. 컴포넌트 상태 정의하기

 
먼저 React 함수 컴포넌트를 만들고, 프로그레스 바의 현재 최소값과 최대값을 상태(state)로 관리하겠습니다. React의 useState 훅을 사용해서 두 개의 숫자 상태 (minVal과 maxVal)를 만들어요. 초기값은 예를 들어 20과 80으로 설정할게요 (전체 범위를 0~100으로 생각). 그리고 드래그 중인 상태를 추적하기 위해 dragging이라는 상태도 하나 만들어요. dragging은 현재 드래그 중인 손잡이가 "min", "max" 또는 없음을 나타내도록 할 것입니다.

 
import React, { useState } from 'react';

function VerticalRangeBar() {
  const [minVal, setMinVal] = useState<number>(20);   // 최소값 (%)
  const [maxVal, setMaxVal] = useState<number>(80);   // 최대값 (%)
  const [activeThumb, setActiveThumb] = useState<'min' | 'max' | null>(null);
  ...
}

 
이렇게 상태를 정의해두면, 나중에 SVG에서 이 값들에 따라 손잡이 위치나 색깔 변화를 줄 수 있어요.

2. SVG로 모양 그리기

이제 컴포넌트의 JSX 반환값으로 SVG 요소를 작성해 볼게요.
우선 progress bar의 "몸통"을 그려볼게요.
 

      <defs>
        {/* Gradient & clip */}
        <linearGradient
          id="rangeGradient"
          gradientUnits="objectBoundingBox"
          x1="0"
          y1="0"
          x2="0"
          y2="1"
        >
          <stop offset="0%" stopColor="var(--ChartColor-ChartColor97, #FFFFE0)" />
          <stop offset="48.56%" stopColor="var(--ChartColor-ChartColor99, #89C0C4)" />
          <stop offset="100%" stopColor="var(--ChartColor-ChartColor100, #579EB9)" />
        </linearGradient>
        <clipPath id="rangeClip">
          <rect x={trackX} y={maxPos} width={trackThickness} height={minPos - maxPos} />
        </clipPath>

        {/* Button filter & clip */}
        <filter
          id="filter0_dd_1314_121150"
          x="0.186035"
          y="0.25"
          width="50"
          height="38"
          filterUnits="userSpaceOnUse"
          colorInterpolationFilters="sRGB"
        >
          <feFlood floodOpacity="0" result="BackgroundImageFix" />
          <feColorMatrix
            in="SourceAlpha"
            type="matrix"
            values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
            result="hardAlpha"
          />
          <feOffset />
          <feGaussianBlur stdDeviation="1.1" />
          <feColorMatrix
            type="matrix"
            values="0 0 0 0 0.0117647 0 0 0 0 0.0156863 0 0 0 0 0.0156863 0 0 0 0.56 0"
          />
          <feBlend
            mode="normal"
            in2="BackgroundImageFix"
            result="effect1_dropShadow_1314_121150"
          />
          <feColorMatrix
            in="SourceAlpha"
            type="matrix"
            values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
            result="hardAlpha"
          />
          <feOffset />
          <feGaussianBlur stdDeviation="0.6" />
          <feColorMatrix
            type="matrix"
            values="0 0 0 0 0.0117647 0 0 0 0 0.0156863 0 0 0 0 0.0156863 0 0 0 0.5 0"
          />
          <feBlend
            mode="normal"
            in2="effect1_dropShadow_1314_121150"
            result="effect2_dropShadow_1314_121150"
          />
          <feBlend
            mode="normal"
            in="SourceGraphic"
            in2="effect2_dropShadow_1314_121150"
            result="shape"
          />
        </filter>
        <clipPath id="clip0_1314_121150">
          <rect x="15.186" y="12.25" width="20" height="14" rx="5" fill="white" />
        </clipPath>
      </defs>

      {/* Track background */}
      <rect
        x={trackX}
        y={margin}
        width={trackThickness}
        height={height}
        fill="#ddd"
       
      />
      {/* Gradient clipped to range */}
      <rect
        x={trackX}
        y={margin}
        width={trackThickness}
        height={height}
        fill="url(#rangeGradient)"
        clipPath="url(#rangeClip)"
 
      />

 
 
위 코드에서 <linearGradient id="rangeGradient">를 정의하고, 트랙용 <rect>의 fill에 url(#"rangeGradient")을 지정했어요.
이렇게 하면 트랙 사각형에 gradient 효과를 지정할 수 있습니다.
def 안에 각종 효과(gradient, filter, clip path 등)을 미리 선언해놓고, 다른 컴포넌트에서 해당 효과를 id를 참조해서 사용이 가능합니다. 
 

track example

 
"track"을 그려놓고 지금 min, max 값만큼 clip해서 그라데이션 효과를 적용합니다.
 
이제 손잡이를 그려볼게요. 손잡이는 min과 max 값을 조절하는 버튼이라고 생각하면 됩니다. 손잡이는 아까 따로 다운로드해두었던 scroll.svg 를 활용합니다. 
 

...(위 코드 생략)

    <g
        transform={`translate(${btnOffsetX}, ${btnOffsetY(minPos)})`}
      >
        <SliderButton
          onMouseDown={e => { e.preventDefault(); setActiveThumb('min'); }}
        />
      </g>

      {/* Max thumb */}
      <g
        transform={`translate(${btnOffsetX}, ${btnOffsetY(maxPos)})`}
      >
        <SliderButton
          onMouseDown={e => { e.preventDefault(); setActiveThumb('max'); }}
        />
      </g>
    </svg>
    
interface SliderButtonProps {
  onMouseDown: (e: React.MouseEvent<SVGRectElement, MouseEvent>) => void;
}

const SliderButton: React.FC<SliderButtonProps> = ({ onMouseDown }) => (
  <g filter="url(#filter0_dd_1314_121150)">
    <path
      d="M12.186 17.25C12.186 14.4886 14.4246 12.25 17.186 12.25H33.186C35.9475 12.25 38.186 14.4886 38.186 17.25V21.25C38.186 24.0114 35.9475 26.25 33.186 26.25H17.186C14.4246 26.25 12.186 24.0114 12.186 21.25V17.25Z"
      fill="#F4F4F4"
    />
    <g clipPath="url(#clip0_1314_121150)">
      <rect
        x="15.186"
        y="12.25"
        width="20"
        height="14"
        rx="5"
        fill="#D9D9FF"
        fillOpacity="0.11"
      />
      <path
        d="M19.8527 23.25H30.5194C30.886 23.25 31.186 22.95 31.186 22.5833C31.186 22.2167 30.886 21.9167 30.5194 21.9167H19.8527C19.486 21.9167 19.186 22.2167 19.186 22.5833C19.186 22.95 19.486 23.25 19.8527 23.25ZM19.8527 19.9167H30.5194C30.886 19.9167 31.186 19.6167 31.186 19.25C31.186 18.8833 30.886 18.5833 30.5194 18.5833H19.8527C19.486 18.5833 19.186 18.8833 19.186 19.25C19.186 19.6167 19.486 19.9167 19.8527 19.9167ZM19.186 15.9167C19.186 16.2833 19.486 16.5833 19.8527 16.5833H30.5194C30.886 16.5833 31.186 16.2833 31.186 15.9167C31.186 15.55 30.886 15.25 30.5194 15.25H19.8527C19.486 15.25 19.186 15.55 19.186 15.9167Z"
        fill="#C6C6C6"
      />
    </g>
    {/* Transparent rect to capture events */}
    <rect
      width="51"
      height="39"
      fill="transparent"
      onMouseDown={onMouseDown}
    />
  </g>
);

 
버튼 좌표 배치 원리는 간단한데, transform 애니메이션을 활용해서 지금 "y 값 좌표" 에 버튼을 위치시킵니다. 
 

최종 프로그래스바 UI

 
 

3. 포인터 이벤트로 드래그 기능 추가하기

이제 가장 재미있는 부분입니다. 손잡이를 드래그해서 위아래로 움직이면 값이 변하도록 만들어볼 거예요.
 
웹에서 드래그 동작을 구현하려면 마우스나 터치 이벤트를 다뤄야 하는데, React에선 Pointer Events를 쓰면 아주 편리합니다. Pointer Event는 마우스, 터치, 펜 등 다양한 입력을 하나의 통일된 방식으로 처리할 수 있는 이벤트예요 예전에는 PC와 모바일을 모두 지원하려면 onMouseDown과 onTouchStart 등을 각각 코딩해야 했지만, 이제는 onPointerDown 하나로 모두 처리할 수 있답니다.
 
우리는 이미 JSX에서 <circle> 손잡이에 onPointerDown을 설정해 두었죠? 사용자가 손잡이를 누르는 순간 어떤 손잡이를 움직이는지 상태를 업데이트하고 (dragging을 "min" 또는 "max"로 set), 이후 움직이는 동안(pointermove)과 놓는 순간(pointerup)을 포착해서 값을 갱신하면 돼요.
 

  // Handles the pointer down event when a thumb is pressed
  // Sets the active thumb ('min' or 'max') and captures the pointer
  const handlePointerDown = (thumb: 'min' | 'max') => (e: React.PointerEvent) => {
     ...
  };
  
  // Handles the pointer move event when the thumb is dragged
  // Updates the value of the active thumb based on the pointer's position
  
  const handlePointerMove = (e: React.PointerEvent) => {
	...
  };
  
  // Handles the pointer up event when the thumb is released
  // Releases the pointer capture and clears the active thumb
  
  const handlePointerUp = (e: React.PointerEvent) => {
   	...
  };

 
 

최종 결과물

 
최종 코드는 아래에 있습니다.
 
https://codesandbox.io/p/devbox/kll755
 

결론: SVG 활용 꿀팁 ✨

이번 예제로 SVG의 힘을 조금 느끼셨나요? 정리하자면, SVG는 화면 크기에 관계없이 선명하고 코드로 그리는 그림이라 자유롭게 수정하거나 상호작용할 수 있다는 게 장점이에요. 마무리로, 실무에서 SVG를 유용하게 활용하는 몇 가지 팁을 알려드릴게요:
 

  • 반응형 디자인: SVG는 벡터 방식이라 화면 크기에 따라 자유롭게 확대/축소해도 깨지지 않아요. 따라서 아이콘이나 일러스트를 SVG로 사용하면 레티나 디스플레이에서도 언제나 또렷하게 보입니다. CSS로 너비나 높이를 %로 주거나 viewBox를 적절히 설정하면 부모 컨테이너 크기에 맞춰 유연하게 대응할 수도 있어요.
  • 아이콘 최적화: Figma나 일러스트레이터에서 SVG를 내보낼 때 불필요한 데이터가 붙거나 좌표가 복잡하게 저장될 수 있어요. 이런 경우 SVG 압축 도구를 사용해보세요. 예를 들어 SVGO라는 오픈 소스 툴을 쓰면 SVG 파일 크기를 많이 줄일 수 있어요복잡한 경로는 단순화하고, 쓰이지 않는 요소는 제거해서 성능을 높이는 거죠. 최적화된 SVG는 파일 크기가 작아져 웹페이지 로딩도 빨라진답니다.

  • 인터랙티브 컴포넌트: SVG 요소들은 DOM 요소이기도 해서, JavaScript나 CSS로 동적으로 제어하기가 쉬워요. 이번에 만든 프로그레스 바처럼 드래그 이벤트를 처리하는 것은 물론이고, <circle>에 마우스오버하면 색을 바꾸거나, 클릭하면 애니메이션을 주는 것도 가능합니다. 예를 들어, SVG에 CSS 클래스를 적용해서 .active일 때 특정 부분의 색깔을 변경하거나, 간단한 트랜지션 효과를 줄 수도 있어요. 또한 D3.js나 GSAP 같은 라이브러리와 결합하면 복잡한 데이터 시각화나 모션 그래픽도 구현할 수 있어요. 상상한 대로 자유롭게 SVG를 가지고 놀아보세요!

SVG를 배우기 시작하면 웹 개발에서 디자인과 상호작용을 다루는 새로운 무기가 생긴 셈이에요. 이번 튜토리얼이 따뜻한 입문 가이드가 되었길 바랍니다. 앞으로도 SVG를 활용해 멋지고 유용한 컴포넌트들을 많이 만들어보세요! 😃
 
P.S) 엄청 복잡한 인터렉션은 가내수공업이 아니고 lottieFiles같은 라이브러리의 힘을 빌리는게 좋을듯..?

반응형
반응형

 

canvas에서 충돌 애니메이션을 구현하는 방법을 간단하게 알아봅시다. 

아래 글을 이해하는데는 벡터 관련 지식이 약간 필요합니다.

출처: https://www.amazon.com/HTML5-Canvas-Native-Interactivity-Animation/dp/1449334989

 

 

핵심 개념은 단 세 가지뿐입니다.

  1. “좌표” 2. “속도(움직임)” 3. “거울에 비치듯 방향을 뒤집기”

1. 좌표 — 종이에 그린 그래프랑 똑같다

 

용어 뜻 예시

 

x 왼쪽 ↔ 오른쪽 거리 x = 0 → 맨 왼쪽, x = 500 → 맨 오른쪽
y 위 ↔ 아래 거리 y = 0 → 맨 위, y = 500 → 맨 아래

 

화면을 가로 500 × 세로 500 칸짜리 눈금종이라 생각하면 됩니다.

빨간 공 하나를 “좌표 (x, y)” 두 숫자로 위치시킵니다.

 

2. 속도 = “한 프레임(0.016초쯤)마다 몇 칸 움직이느냐”

let vx = 3;   // x축으로 한 번에 +3칸
let vy = 2;   // y축으로 한 번에 +2칸
  • 속도 벡터 (vx, vy) : “오른쪽으로 3칸, 아래로 2칸”이라는 화살표
  • 움직이기 : x += vx; y += vy;
  • → 숫자를 더하기만 하면 새 위치가 나옵니다.

👉 ‘벡터’란 단어를 어려워하지 마세요.

그냥 “Δx(+3), Δy(+2)”라는 두 개의 숫자를 한 쌍으로 들고 다닌다는 뜻뿐입니다.

 

3. 충돌 감지 — “부딪쳤다”를 어떻게 알까?

 

3‑1. 벽(사각형)과 부딪힘

  • 왼쪽 벽 : 공의 왼쪽 끝 x - R 이 0보다 작아졌다면 → “벽을 뚫었다”는 뜻
  • 오른쪽 벽 : x + R 이 500보다 크면 뚫음
  • 위·아래도 같은 논리로 구현
if (x - R <= 0 && vx < 0) vx = -vx;   // 왼쪽 벽
  • vx < 0 여야만 뒤집는 이유: 이미 오른쪽(+)으로 가고 있는 상황이라면 굳이 뒤집을 필요 없죠.

3-2. 동글동글 장애물(원)과 부딪힘

  • 두 원 중심 사이의 거리
  • 공 반지름 + 장애물 반지름 보다 작으면 겹쳤다(충돌)
const dx = x - obs.x;
const dy = y - obs.y;
const dist = Math.hypot(dx, dy);   // √(dx² + dy²)
if (dist < R + obs.r) { … }

 

** Math.hypot(a,b) 는 “피타고라스” 공식(√(a²+b²))을 간단히 써 주는 함수


4 튕겨 나가기 — 거울에 비친 화살표처럼 “반사”하기

 

4‑1. 벽은 간단 — 한 축만 부호 반전

  • 왼·오른쪽 벽 → x 방향만 뒤집기 → vx = ‑vx
  • 위·아래 벽 → y 방향만 뒤집기 → vy = ‑vy

결과 : “↘” 로 가던 화살표가 벽에 부딪히면 “↙”로 바뀝니다.

4‑2. 둥근 벽(원) — 법선(normal)이라는 “정면 방향”을 이용

  1. 법선 : 공→장애물로 그린 선을 1칸 길이로 만든 화살표
  2. → (nx, ny) = (dx/dist, dy/dist)
  • 정의: 법선 벡터는 “충돌 지점에서 표면에 수직으로 뻗은 단위 벡터”를 말해요.
  • 충돌 예제에서는 공→장애물 중심을 잇는 방향으로 벡터를 구한 뒤, 이걸 길이 1로 정규화(normalize) 한 것이 법선입니다.

법선 계산하는 방법

 

  1. 반사 공식
    • v·n (v dot n) = vx·nx + vy·ny
      스칼라곱: 그냥 두 숫자씩 곱해서 더한 값입니다

3. 코드

const dot = vx*nx + vy*ny;
vx = vx - 2 * dot * nx;
vy = vy - 2 * dot * ny;

왜 이렇게 하면 “거울 반사”가 될까?

화살표(속도)를 “정면 성분”과 “옆으로 스치는 성분”으로 쪼갠 뒤,

정면 성분만 → 반대 방향으로 두 배 돌려서 빼 버린다고 생각하면 됩니다.

한글 말장난보다, 거울에 비친 모습을 빼서 더한다는 직관이 더 쉬워요!

 

5 끼임 방지 — 살짝 밀어내기

충돌 직후, 공이 이미 장애물 안쪽에 일부 들어가 있을 수 있습니다.

겹친 거리(overlap)만큼 한 발짝 밀어내기

 

✋ 정리

궁금증 아주 쉬운 답

“벡터?” 숫자 두 개를 한 묶음(↗ 화살표)으로 본다
“dot(스칼라곱)?” a₁·b₁ + a₂·b₂ — 곱하고 더하기뿐
“왜 반사가 되죠?” 화살표를 거울에 비춘 뒤 그 방향으로 바꿔 꽂는 것
“무슨 고급 수학 쓰나요?” 피타고라스(√)와 곱셈·뺄셈 끝!

결국 우리가 한 일

① 위치(x, y)를 더하기로 옮겼다 → ② 부딪혔는지 간단한 비교·√로 확인 →

③ 맞으면 화살표(vx, vy)를 뒤집거나 반사 공식으로 바꿨다.

 

 

코드 예시

 

충돌 시뮬레이션 예시 코드(동영상은 20FPS)

 

 

아래 링크에서 확인 가능합니다.

 

https://codesandbox.io/p/devbox/kc8674

 

 

 

reference 

 

HTML5 Canvas: Native Interactivity and Animation for the Web 에서 일부 내용 발췌

반응형
반응형

요즘 애니메이션 구현해보는거에 재미들렸는데,

부드러운 애니메이션을 구현하는 방법에 대해 작성해볼려고 합니다.

 

서론

 

현대 웹과 앱에서는 작은 애니메이션 효과들이 사용자 경험을 크게 향상시킵니다. 눈에 잘 띄지 않는 마이크로 인터랙션(micro-interaction)이라도 인터페이스를 더욱 직관적이고 재미있게 만들어 주며, 사용자에게 피드백을 주어 참여도를 높입니다.

그런데, 애니메이션이 너무 갑작스럽거나 뚝뚝 끊긴다면 오히려 거슬릴 수 있습니다. 특히 모든 움직임이 선형(linear), 즉 처음부터 끝까지 일정한 속도로 진행되면 어딘가 모르게 어색하고 부자연스럽게 느껴지곤 합니다. 현실 세계를 생각해보면, 물체가 움직일 때 처음부터 끝까지 정확히 같은 속도로 움직이는 일은 드물죠.

 

예컨대 공을 던지면 처음에는 속도가 점점 빨라졌다가 나중에는 느려지면서 멈추고, 차가 출발하거나 정지할 때도 서서히 가속하고 감속합니다. 이런 부드러운 속도 변화를 애니메이션에 도입하면 움직임이 훨씬 자연스러워집니다.

 

이번 글에서는 이러한 이징(easing) 함수의 개념을 알아보고, JavaScript로 Canvas에서 부드러운 애니메이션을 구현하는 방법을 함께 살펴보겠습니다.

 

이징 함수란?

애니메이션에서 이징 함수는 움직임의 속도 변화를 결정하는 수학적 함수입니다. 쉽게 말해, 애니메이션이 진행되는 시간에 따른 속도의 변화율을 정의하는 함수라고 할 수 있습니다​.

이징 함수 f(t)는 일반적으로 t=0일 때 시작 값 0을 반환하고 t=1일 때 최종 값 1을 반환합니다. (즉, f(0) = 0이고 f(1) = 1입니다.) 다만 0 < t < 1인 중간 구간에서는 선형적인 증가 대신 특수한 곡선을 따라 값을 증가시키죠​. 이 함수를 통해 애니메이션 속도가 시간이 지남에 따라 어떻게 변할지 결정할 수 있습니다.

 

한마디로 정리하면, 이징 함수는 애니메이션의 진행률을 입력 받아 우리가 원하는 방식으로 조정한 새로운 진행률을 출력해주는 함수입니다. 이렇게 변형된 진행률을 이용하면 객체의 움직임이나 변화에 속도 완급을 줄 수 있습니다. 이징 함수를 사용하면 애니메이션이 한결 부드럽고 자연스러운 느낌을 갖게 되며, 사용자는 변화가 일어나는 방식을 쉽게 예측하고 받아들일 수 있습니다.

 

이징 함수 예제

 

이징 함수의 개념을 이해했으니, 구체적인 예제를 통해 어떤 종류의 이징 함수가 어떤 느낌의 애니메이션을 만드는지 알아보겠습니다. 흔히 쓰이는 이징 패턴에는 ease-in, ease-out, ease-in-out 등이 있는데, 이는 말 그대로 애니메이션의 시작이나 끝 부분의 움직임을 완만하게 만든다는 뜻입니다.

 

예를 들어:

  • ease-in: 처음엔 느리게 시작해서 나중에 빨라지는 패턴 (초반 가속)
  • ease-out: 처음엔 빠르게 시작해서 나중에 느려지는 패턴 (후반 감속)
  • ease-in-out: 처음과 끝은 느리고 중간은 가장 빠른 패턴 (초반 가속 + 후반 감속)
// 선형(linear) 함수: 변화 비율이 일정 (직선 그래프)
function linear(t: number): number {
  return t;
}

// Quadratic ease-in: 천천히 시작해서 가속 (f(t) = t^2)
function easeInQuad(t: number): number {
  return t * t;
}

// Quadratic ease-out: 빨리 시작해서 서서히 감속 (f(t) = 1 - (1-t)^2 와 동일)
function easeOutQuad(t: number): number {
  return 1 - (1 - t) * (1 - t);
}

// Quadratic ease-in-out: 초반과 후반은 완만, 중간은 빠름
function easeInOutQuad(t: number): number {
  return t < 0.5 
    ? 2 * t * t                             // 절반 이전에는 가속 (ease-in)
    : 1 - Math.pow(-2 * t + 2, 2) / 2;      // 절반 이후에는 감속 (ease-out)
}

 

이러한 이징을 적용하지 않은 기본 상태를 linear(선형)라고 부릅니다. Linear는 말 그대로 등속도로 움직이는 것으로, 어떤 순간에도 속도가 변하지 않습니다.

 

Canvas와 애니메이션 루프

 

브라우저에서 애니메이션을 구현할 때는 보통 반복적인 화면 갱신이 필요합니다. HTML5의 Canvas 요소는 자바스크립트를 통해 도형을 그리거나 지울 수 있게 해주는데, 여기에 애니메이션 루프를 적용하면 매 순간 화면을 업데이트하여 움직이는 효과를 만들 수 있습니다. 애니메이션 루프를 돌리는 대표적인 방법이 window.requestAnimationFrame() 함수입니다.

requestAnimationFrame은 브라우저에게 "다음 번 화면을 그리기 전에 이 함수를 실행해줘" 하고 요청하는 메서드입니다​.

한 번 호출하면 브라우저가 다음 리페인트(화면 재렌더링) 시점에 우리가 넘긴 콜백 함수를 호출해주며, 보통 초당 60회 정도 빈도로 불러줍니다. requestAnimationFrame을 이용하면 브라우저의 화면 재생 주기에 맞춰 그리기 때문에, setTimeout이나 setInterval로 임의로 루프를 돌리는 것보다 부드럽고 안정적인 애니메이션이 구현됩니다.

또한, 해당 탭이 비활성화된 경우 자동으로 중지하여 불필요한 연산을 피하는 최적화도 제공하지요.

 

사용법은 간단합니다. 애니메이션하고 싶은 내용을 처리하는 함수를 하나 만든 뒤, 그 함수 내부에서 매번 requestAnimationFrame을 재귀적으로 다시 호출해주면 됩니다. 이렇게 하면 함수가 반복해서 호출되며 루프가 만들어집니다. 이 때 현재 경과 시간을 이용해 애니메이션의 진행률(progress)을 계산하고, 그 진행률에 이징 함수를 적용해 객체의 상태를 업데이트하면 부드러운 속도 변화를 줄 수 있습니다. 아래는 간단한 애니메이션 루프의 예시 코드입니다.

 

const duration = 1000; // 애니메이션 지속 시간 (밀리초)
let startTime: number | undefined;

// 애니메이션 루프 함수
function frame(timestamp: number) {
  if (startTime === undefined) {
    startTime = timestamp;                   // 첫 프레임의 시작 시각 기록
  }
  const elapsed = timestamp - startTime;     // 경과 시간(ms)
  let progress = elapsed / duration;         // 0~1 사이의 진행률 계산
  if (progress > 1) progress = 1;            // 100%를 넘지 않도록 보정

  // 이징 함수를 적용하여 보정된 진행률 계산 (예: easeOutQuad 사용)
  const easedProgress = easeOutQuad(progress);

  // [여기에서 easedProgress에 따라 애니메이션 상태를 계산하고 그리기 작업 수행]
  // 예시: 어떤 요소의 X좌표를 0에서 100으로 이동한다면
  // let currentX = 0 + (100 - 0) * easedProgress;

  if (progress < 1) {
    requestAnimationFrame(frame);            // 아직 끝나지 않았으면 다음 프레임 예약
  }
}

// 애니메이션 시작
requestAnimationFrame(frame);

 

간단히 설명하자면, 지금 값 = 이전 값 + 변화량(Delta) 라고 보면 됩니다. 미분 적분할때 배웠던 변화량을 계속 더해나가면 되는데요, 

  if (startTime === undefined) {
    startTime = timestamp;                   // 첫 프레임의 시작 시각 기록
  }
  const elapsed = timestamp - startTime;     // 경과 시간(ms)

 

첫 프레임에서는 startTime이 비어 있으므로 초기값을 설정하고, 이후에는 elapsed를 통해 경과 시간을 계산합니다.

progress는 elapsed/duration으로 현재 전체 애니메이션에서 얼마만큼 진행됐는지를 0부터 1 사이로 나타낸 값입니다.

 

let progress = elapsed / duration;         // 0~1 사이의 진행률 계산
  if (progress > 1) progress = 1;            // 100%를 넘지 않도록 보정

  // 이징 함수를 적용하여 보정된 진행률 계산 (예: easeOutQuad 사용)
  const easedProgress = easeOutQuad(progress);

 

이 값을 이징 함수 easeOutQuad에 통과시키면 easedProgress를 얻게 됩니다. 이제 easedProgress를 가지고 애니메이션 대상의 현재 상태를 계산합니다.

 

위 예시 주석에서는 0에서 100 사이 이동하는 X좌표를 계산했는데, progress 대신 easedProgress를 사용함으로써 처음에는 빠르게 움직이고 끝에 갈수록 천천히 움직이게 됩니다.

 

마지막으로 진행률이 1(100%)가 아닐 경우 requestAnimationFrame(frame)을 다시 호출하여 다음 프레임을 예약합니다. 이렇게 함수를 자기 자신으로 재호출함으로써 약속된 시간동안 루프가 돌며 애니메이션이 이어집니다.

 

 

부드러운 인터렉션 예시 (비디오 프레임이 낮은데 실제로 보면 더 자연스럽습니다..ㅋㅋ )

 

위 progress bar 코드 예시는 아래 링크에 있습니다.

https://codesandbox.io/p/devbox/yqt69q?embed=1

 

브라우저 easing 함수

브라우저에서는 css에서 이런 이징 함수들을 쉽게 사용 가능하도록 여러가지 옵션을 부여합니다,

아래는 대표적인 CSS 이징 함수들의 개념과 사용 예시입니다. 각 함수를 transition-timing-function 또는 animation-timing-function에 지정해 애니메이션 속도를 조절할 수 있습니다.

/* ease: 시작과 끝에서 느리게, 중간에 빠르게 진행되는 기본 이징 */
animation-timing-function: ease;

/* ease-in: 시작 부분에서 느리게 출발해 점진적으로 가속 */
animation-timing-function: ease-in;

/* ease-out: 처음에는 빠르게 진행하다가 끝 부분에서 느리게 감속 */
animation-timing-function: ease-out;

/* ease-in-out: 시작과 끝은 느리게, 중간 구간은 빠르게 진행 */
animation-timing-function: ease-in-out;

/* linear: 시작부터 끝까지 일정한 속도로 균일하게 진행 */
animation-timing-function: linear;

/* step-start: 즉시 첫 번째 단계로 점프하고, 이후 단계 전환 없이 유지 */
animation-timing-function: step-start;

/* step-end: 마지막 단계 전환은 끝 부분에 이루어지고, 그 전까지는 이전 상태 유지 */
animation-timing-function: step-end;

 

마무리

이 글에서는 이징 함수의 개념과 활용법을 살펴보고, 간단한 애니메이션 예제로 적용해보았습니다. 정리하자면, 이징 함수를 사용하면 애니메이션에서 속도의 완급 조절이 가능해지고, 이를 통해 사용자에게 부드럽고 자연스러운 인터랙션을 제공할 수 있습니다. 선형으로 움직이는 것보다 살짝 가속도나 감속도가 들어간 움직임이 훨씬 현실적이고 매끄럽게 느껴지는 것이죠

 

특히 UI 요소의 등장/퇴장이나 강조 효과 등에 이징을 잘 사용하면, 인터페이스가 한층 세련되고 친절한 느낌을 주게 됩니다.

 

여기서 다룬 Quadratic, Cubic 등의 이징 함수 외에도 매우 다양한 이징 함수들이 존재합니다. 예를 들어 탄성(elastic) 효과로 물체가 용수철처럼 튕기는 움직임이나, 바운스(bounce) 효과로 공이 바닥에 떨어졌다 튕기는 움직임 등도 구현할 수 있습니다​

 

이런 특수한 이징 함수들은 조금 더 복잡한 수식을 사용하지만 원리는 같습니다. 

 

마지막으로, 이징 함수를 잘 활용하는 팁은 어떤 상황에 어떤 느낌을 주고 싶은지 생각하는 것입니다. 부드럽게 시작해야 할까요, 아니면 부드럽게 멈춰야 할까요, 혹은 둘 다일까요? 그런 의도에 따라 적절한 함수를 골라 적용해보고, 눈으로 결과를 확인하면서 감을 익혀보세요.

 

reference 

https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function

 

반응형
반응형

개발하다보면 로직을 재사용하기 위해 Provider를 사용하는 경우가 있는데,
동일한 Provider가 여러번 재사용 되는 경우 (테이블 안에 테이블이 있다던가.. 모달 에서 모달을 연다던가)
상태 핸들링에 어려움을 겪었습니다. 
 
그래서 Radix-Ui에서 사용하는 스코프 개념을 본따 라이브러리도 만들었는데,
홍보 겸 블로그 글을 작성하려고 합니다
 

스코프란?

import { Dialog } from "radix-ui";


// Radix Ui 예시 코드, Root (Provider)를 기반으로 내부에서 상태를 공유해 사용합니다.

export default () => (
	<Dialog.Root>
		<Dialog.Trigger />
		<Dialog.Portal>
			<Dialog.Overlay />
			<Dialog.Content>
				<Dialog.Title />
				<Dialog.Description />
				<Dialog.Close />
			</Dialog.Content>
		</Dialog.Portal>
	</Dialog.Root>
);




Radix UI는 React 기반의 UI 컴포넌트 라이브러리로, Compound Components 패턴을 적극 활용합니다.
Compound Components는 여러 개의 하위 컴포넌트를 조합하여 하나의 큰 구성 요소를 만드는 패턴이에요. 예를 들어 Radix UI의 Dialog 컴포넌트는 Root, Trigger, Content 등 여러 부분으로 쪼개져 있습니다. 이렇게 하면 필요한 부분만 선택해서 조합할 수
있어 유연성이 높아지죠.
 
 
 
하지만 이런 구성 요소들을 여러 겹으로 중첩해서 사용할 때 문제가 생길 수 있습니다. 
예를 들어, Dialog 컴포넌트를 기반으로 만든 AlertDialog가 있다고 상상해봅시다.  AlertDialog 내부에서는 실제로 Dialog의 기능을 활용하지만, AlertDialog만의 추가 기능과 요구사항이 있어요. 
만약 Dialog와 AlertDialog를 한 화면에 함께 쓰거나, 서로 포함하는 구조로 쓴다면 어떻게 될까요? 
잘못하면 두 컴포넌트의 내부 상태나 컨텍스트가 섞여서 엉뚱한 동작이 일어날 수 있습니다.
 

Alert Dialog 코드 예시

 
Radix UI 팀은 이 문제를 해결하기 위해 스코프(scope)라는 개념을 도입했습니다. 
간단히 말해, 컴포넌트 인스턴스마다 독립적인 컨텍스트를 부여하는 것입니다. 
 
앞의 AlertDialog 예에서, AlertDialog는 자체적인 컨텍스트를 만들고 이를 내부에서 사용하는 Dialog에 넘겨줍니다. 
그렇게 하면 Dialog와 AlertDialog 각각 자기만의 상태 공간을 가지므로 충돌이 발생하지 않아요.
 
 실제로 Radix UI 개발자는 "AlertDialog에는 자체 컨텍스트가 있어서, 내부에 렌더링하는 Dialog에 그 컨텍스트를 전달하고 Dialog는 그것을 사용하게 만든다. 이렇게 하지 않으면 Dialog는 자기 자신의 DialogContext를 사용하게 되어 문제가 발생한다고 설명했습니다.
 
즉, 스코프를 이용해 컨텍스트의 범위를 한정함으로써 비슷한 구조의 컴포넌트들이 서로의 상태를 잘못 공유하지 않도록 막아주는 것이죠
 
참조) https://github.com/radix-ui/primitives/discussions/1091
 
쉽게 비유하자면, 여러 개의 라디오 채널이 있다고 생각해봅시다. 각 채널은 자기 주파수가 있어서 다른 채널 신호와 섞이지 않고 독립적으로 송출되죠. Radix의 스코프 개념도 이와 비슷하게, 컴포넌트별로 **자기만의 신호(컨텍스트)**를 주파수처럼 분리해준다고 볼 수 있습니다. 따라서 여러 인스턴스를 동시에 사용하더라도 각자 독립적인 상태를 유지하게 됩니다.
 

React와 Zustand로 Scope 개념 구현하기

 
이제 Radix UI의 스코프 아이디어를 우리만의 코드로 한 번 구현해보겠습니다. React에서는 보통 Context를 사용해서 컴포넌트 트리 전체에 걸쳐 상태를 공유할 수 있는데요. 여기서는 이 Context를 응용해서 컴포넌트마다 개별적인 상태 저장소(Zustand 스토어)를 제공하는 방법을 만들어볼 거예요.
Zustand는 원래 전역 상태를 관리하기 좋지만, 약간의 창의력을 더해 인스턴스별로 분리된 상태 관리도 할 수 있습니다.
전체적인 구현 전략은 다음과 같습니다:
 

  1. createScope 함수 – 새로운 스코프(컨텍스트와 Zustand 스토어)를 생성하는 유틸리티 함수를 만듭니다. 이 함수는 React Context를 만들고, 그 안에 독립적인 Zustand 스토어(상태)를 생성해 줄 거예요.
  2. ScopeProvider 컴포넌트 – createScope 함수가 반환하는 Provider 컴포넌트입니다. 이 컴포넌트를 사용하면 자식 컴포넌트들이 특정 스코프에 접근할 수 있도록 zustand 스토어를 컨텍스트로 공급해줍니다. 이 Provider를 매 인스턴스마다 사용하면 각 인스턴스마다 별도의 상태 저장소를 갖게 되겠죠.
  3. useScope 훅(Hook) – 해당 컨텍스트에서 Zustand 상태를 읽고 조작할 수 있는 커스텀 훅입니다. 이 훅을 사용하면 현재 스코프(컨텍스트)에 연결된 상태 값들과 업데이트 함수를 손쉽게 가져올 수 있어요.

이제 실제 코드로 확인해보겠습니다. 먼저 createScope 함수를 구현해보고, 그를 이용해 컴포넌트별 독립 상태를 가지는 예제를 만들어볼게요.
 
글쓰기에 앞서, zustand에 대한 정보가 필요하면 아래 공식 사이트 링크를 참고하세요.
https://zustand-demo.pmnd.rs/
 
zustand를 몰라도 "전역 상태"를 사용한다"는 개념만 이해하면 이해하기 쉽습니다.
provider를 사용해보기 위해 zustand store를 provider에 넣었는데, 어떻게 넣었는지는 아래 링크를 참고해주세요.
 
https://zustand.docs.pmnd.rs/guides/nextjs
 

// createZustandContextWithScope.ts
"use client";

import React, { createContext, ReactNode, useContext, useRef } from "react";
import type { StoreApi, UseBoundStore } from "zustand";
import { useStore } from "zustand";
import { useShallow } from "zustand/react/shallow";

/**
 * createZustandContextWithScope
 *
 * zustand 스토어를 context Provider와 연계하는 유틸 함수에 스코프 개념을 추가한 버전입니다.
 * Provider와 hook 모두 선택적으로 scope 값을 받을 수 있으며, 동일한 scope를 사용한 컴포넌트끼리 별도의 상태를 공유합니다.
 */
export function createZustandContextWithScope<TStore extends object>(
  createStore: (initialState?: Partial<TStore>) => UseBoundStore<StoreApi<TStore>>
) {
  // scope 값을 key로 하는 React Context들을 저장할 WeakMap (key는 객체여야 함)
  const contexts = new WeakMap<object, React.Context<UseBoundStore<StoreApi<TStore>> | null>>();
  // scope가 없는 경우에 사용될 기본 컨텍스트
  let defaultContext: React.Context<UseBoundStore<StoreApi<TStore>> | null> | undefined;

  // scope 값(객체)이 있으면 해당 scope의 컨텍스트, 없으면 기본 컨텍스트를 반환
  function getContext(scope?: object) {
    if (!scope) {
      if (!defaultContext) {
        defaultContext = createContext<UseBoundStore<StoreApi<TStore>> | null>(null);
      }
      return defaultContext;
    } else {
      if (!contexts.has(scope)) {
        contexts.set(scope, createContext<UseBoundStore<StoreApi<TStore>> | null>(null));
      }

      return contexts.get(scope)!;
    }
  }

  // Provider는 선택적 scope prop(객체)을 받고, 해당 scope에 맞는 컨텍스트 Provider로 감쌉니다.
  const Provider = ({
    children,
    initialState,
    scope,
  }: {
    children: ReactNode;
    initialState?: Partial<TStore>;
    scope?: object;
  }) => {
    const ContextToUse = getContext(scope);
    const storeRef = useRef<UseBoundStore<StoreApi<TStore>>>();
    if (!storeRef.current) {
      storeRef.current = createStore(initialState);
    }
    return <ContextToUse.Provider value={storeRef.current}>{children}</ContextToUse.Provider>;
  };

  // hook도 선택적으로 scope 값을 받고, 동일한 scope에 대응하는 컨텍스트에서 zustand 스토어를 읽어옵니다.
  const useStoreFromContext = <U,>(selector: (state: TStore) => U, scope?: object): U => {
    const ContextToUse = getContext(scope);
    const store = useContext(ContextToUse);
    if (!store) throw new Error(`Zustand store is missing the Provider; required scope: ${scope}`);
    return useStore(store, useShallow(selector));
  };

  return {
    Provider,
    useStore: useStoreFromContext,
  };
}

 
 
코드가 이것저것 많은데요, 핵심 로직만 이해하면 쉽습니다.
 
위 코드에서 createZustandWithScope 함수는 새로운 Context와 Zustand 스토어 훅을 묶어서 제공하고 있습니다.
ScopeProvider가 마운트될 때 한 번만 create 함수가 실행되고, (Provider의 storeRef.current = createStore(initalState) 코드 부분)
 
컴포넌트가 언마운트되기 전까지 동일한 Zustand 스토어 인스턴스를 유지합니다. 이렇게 해야 Provider 컴포넌트가 리렌더되더라도 매번 새로운 스토어를 만들지 않고, 해당 스코프에 안정적인 상태 저장소가 할당되죠.
 
그리고 Scope를 외부에서 할당받아 주입해줍니다. 
createScope함수는 다음과 같습니다.

import { useRef } from "react";

export function useScope<T extends object = {}>(): T {
  const scopeRef = useRef<T>();
  if (!scopeRef.current) {
    scopeRef.current = {} as T;
  }
  return scopeRef.current;
}

 
객체를 사용하는 이유는, WeakMap이 unique한 키 값을 받아 set/get 및 가비지 콜렉팅을 관리하는데
참조하는 scope가 없으면 자동으로 가비지 콜렉팅 되도록 구현하려고 사용했습니다.
 

컴포넌트 예시 

이제 createScope를 활용해서 실제로 컴포넌트별 독립적인 상태가 잘 동작하는지 확인해보겠습니다.
 
예시로 Counter(카운터) 컴포넌트를 만들어 볼게요. 이 Counter는 내부에 자신의 상태로 숫자를 하나 들고 있고, 버튼을 누르면 그 숫자가 증가합니다. 중요한 점은, 이런 Counter 컴포넌트를 여러 개 렌더링해도 서로 다른 숫자를 가지도록 하는 것입니다. 전역적으로 상태를 공유했다면 하나 올릴 때 모두 올라가겠지만, 우리는 각 인스턴스가 분리되어야 하니까요!
 

// App.tsx
"use client";

import React from "react";
import { create, type StoreApi, type UseBoundStore } from "zustand";

import { createZustandContextWithScope, useScope } from "@/shared/libs/zustand";

// 1. 스토어 타입 정의
type CounterStore = {
  count: number;
  increment: () => void;
};

// 2. zustand 스토어 생성 함수 정의
const createCounterStore = (initialState?: Partial<CounterStore>): UseBoundStore<StoreApi<CounterStore>> =>
  create<CounterStore>((set, get) => ({
    count: initialState?.count ?? 0,
    increment: () => set({ count: get().count + 1 }),
  }));

// 3. 스코프를 지원하는 zustand context 생성 (WeakMap 사용)
const { Provider: CounterProvider, useStore: useCounterStore } = createZustandContextWithScope(createCounterStore);

// 4. 카운터 컴포넌트 (선택적 scope prop을 받음)
function Counter({ scope }: { scope?: object }) {
  const { count, increment } = useCounterStore((state) => ({ count: state.count, increment: state.increment }), scope);
  return (
    <div
      style={{
        border: "1px solid #ccc",
        padding: "1rem",
        margin: "1rem",
      }}
    >
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

// 5. App 컴포넌트에서 useScope를 통해 scope 객체를 생성
function App() {
  // 이제 useScope 훅을 통해 scope 객체를 생성하므로, useMemo를 직접 쓸 필요가 없습니다.
  const scope1 = useScope();
  const scope2 = useScope();

  return (
    <div>
      <h1>Scoped Zustand Example (useScope 활용)</h1>

      <CounterProvider scope={scope2} initialState={{ count: 100 }}>
        <CounterProvider scope={scope1} initialState={{ count: 10 }}>
          {/* 첫 번째 인스턴스 - scope1 */}
          <Counter scope={scope1} />

          {/* 두 번째 인스턴스 - scope2 */}
          <Counter scope={scope2} />
        </CounterProvider>
      </CounterProvider>

      {/* scope prop을 전달하지 않은 경우 (기본 컨텍스트 사용) */}
      <CounterProvider initialState={{ count: 0 }}>
        <Counter />
      </CounterProvider>
    </div>
  );
}

export default App;

 
위 예제에서 <CounterScopeProvider>는 각각 독립된 스토어를 만들어서 그 자식인 Counter에게 제공합니다. 따라서 화면에 Counter를 두 개 렌더링했지만, 각각 자기만의 count 상태를 가지게 됩니다. 첫 번째 버튼을 몇 번 눌러도 두 번째 Counter의 숫자에는 영향을 주지 않고, 반대의 경우도 마찬가지예요. 🎉
 

scope 로 상태를 격리한 코드 결과 예시 gif

 
만약 우리가 이렇게 스코프를 분리하지 않고 하나의 전역 Zustand 스토어를 두 Counter가 공유했다면 어떤 일이 벌어질까요? 아마도 하나의 버튼을 클릭할 때 두 Counter 컴포넌트가 모두 같은 상태를 참조하고 있기 때문에 같이 증가했을 거예요. 하지만 createScope로 인해 Provider마다 별도의 스토어 인스턴스를 쓰고 있으니 이러한 충돌이 사라집니다.
 
정리하면, 우리는 Radix UI의 "한 컴포넌트 그룹만을 위한 독립적인 컨텍스트"라는 개념을 React+Zustand 조합으로 구현했습니다. 이렇게 컨텍스트와 상태 저장소를 컴포넌트 단위로 캡슐화하면 재사용성과 상태 격리가 훨씬 쉬워집니다. 실제 사례로도, Zustand를 전역으로 쓰는 대신 각 컴포넌트 subtree별로 컨텍스트를 통해 스토어를 주입하면, 각 컴포넌트가 자신의 상태를 갖게 되어 테스트나 초기화가 간편해집니다. 여러 곳에서 동일한 컴포넌트를 렌더링할 때도 모두 독립적인 동작을 하게 되는 것이죠.
 
(zustand가 아니고 단순한 context api등 다른 전역 상태 라이브러리/방식을 사용해도 동일합니다.)
 

마무리: Scope로 인한 독립성의 가치

Radix UI의 scope 개념과 그것을 활용한 상태 격리 방법을 살펴보았습니다. 핵심 아이디어는 **"컨텍스트를 복제하여 인스턴스별로 분리한다"**라고 요약할 수 있겠네요. 이번에 만든 createScope와 useScope 패턴은 작은 예제이지만, 이러한 원리를 이해하면 더 복잡한 UI를 만들 때도 유사한 접근법을 활용할 수 있어요. 🙂
 
React와 Zustand를 사용한 이 방법으로 컴포넌트를 구성하면, 필요한 곳에만 국한된 상태를 가질 수 있어서 애플리케이션의 유지보수성이 높아집니다. Radix UI가 멋진 점은 이러한 문제들을 미리 고민하고 API에 녹여냈다는 것이고, 우리는 그 아이디어를 배우고 응용해 본 것이죠.
처음엔 조금 헷갈릴 수 있지만, 천천히 코드를 살펴보고 직접 따라 해보세요. 궁금한 점이 생기면 공식 문서나 레퍼런스도 참고하면 큰 도움이 됩니다. 부디 이번 튜토리얼로 스코프 개념에 대한 이해와 독립 상태 관리 기법을 얻어가시길 바랍니다. 🚀
 
 
아 참고로, 저는 이 scope 개념을 활용해 하나의 라이브러리를 만들었습니다.
https://github.com/lodado/react-namespace
 
zustand를 사용하지 않고 직접 전역 상태 관리 방법을 구현한것 이외에는 위 예시 코드와 거의 동일합니다.
그런데 전역 라이브러리를 만들어서 직접 배포해보니 경쟁자(zustand, redux 등)들이 너무 쟁쟁해서 사람들이 별로 사용하진 않는거 같네요.. ㅋㅋ  
 
 
참고 자료: Radix UI 공식 논의에서 컨텍스트 스코프 설명 github.comgithub.com,
Zustand와 React Context 활용에 대한 블로그 포스트 tkdodo.eu.
 
 
직접 구현한 코드 예시)
https://github.com/lodado/OXVoter/tree/main/src/shared/libs/zustand
 
 
 
 

반응형

'Front-end > React' 카테고리의 다른 글

Next 14 tree shaking 관련 조사  (0) 2024.02.27
jotai 내부 구조 훑어보기  (0) 2023.11.25
유연한 컴포넌트 만들기 - 모듈화와 추상화  (0) 2023.06.10
react HOC와 decorator  (0) 2023.05.14
react Dialog tab trap 만들기  (1) 2023.05.01
반응형

블로그에 개인적으로 사용한 "기술"을 요약해놓고 나중 복기하는 용도로 사용했었는데,

 

chatgpt가 나타난 이후로는 위 용도를 완전히 대체해버려서

블로그를 쓰거나 보는것을 계속 미루게 되는것 같습니다.

 

그래도 간단히 요즘 공부한 것을 요약해서 써볼려고 합니다..

 

1. Logical Properties란?

CSS Logical Properties(논리적 속성)은 요소의 좌우(인라인 방향)나 상하(블록 방향)처럼 문서의 쓰기 방향에 따라 바뀌는 속성을 말합니다​

 

기존 CSS 속성들은 left, right, top, bottom처럼 물리적인 방향을 지정했지만, Logical Properties는 이러한 대신

인라인(inline)과 블록(block) 방향으로 속성을 지정합니다. 예를 들어 left와 right는 인라인 방향의

시작(start)과 끝(end)으로, top과 bottom은 블록 방향의 시작과 끝으로 표현됩니다​

 

이렇게 함으로써 요소의 실제 배치 방향(LTR인지 RTL인지 등)에 따라 속성이 자동으로 적용되는 것입니다.

 

새로운 박스 모델

박스 모델 출처 - https://wit.nts-corp.com/2019/08/05/5621

 

  1. Width, Height
  • 영어(LTR) 환경:
    • width는 텍스트의 흐름 방향이므로 inline-size로 대체
    • height는 문장이 쌓이는 방향이므로 block-size로 대체
  • 몽골어와 같이 수직 텍스트 환경:
    • 텍스트 흐름이 수직이므로 inline-size가 height가 되고,
    • 문장이 쌓이는 방향이 수평이 되어 block-size가 width로 대체

2. Position Offsets

  • 기존의 top/left/bottom/right는 논리적 속성으로 변환되며,
    • top → inset-block-start
    • left → inset-inline-start
    • bottom → inset-block-end
    • right → inset-inline-end

요약하자면 아래와 같습니다.

margin-top: margin-block-start;
margin-left: margin-inline-start;
margin-bottom: margin-block-end;
margin-right: margin-inline-end;
border-top: border-block-start;
border-left: border-inline-start;
border-bottom: border-block-end;
border-right: border-inline-end;
padding-top: padding-block-start;
padding-left: padding-inline-start;
padding-bottom: padding-block-end;
padding-right: padding-inline-end;

 

 

이외에도 다양한 속성들이 있는데, 아래 링크에서 잘 설명해둔것 같습니다.

 

https://wit.nts-corp.com/2019/08/05/5621

 

2. 왜 RTL/LTR이 중요할까?

 

tailwind의 ltr, rtl 예시, 출처 - https://v3.tailwindcss.com/docs/hover-focus-and-other-states#rtl-support

 

전세계에는 영어처럼 왼쪽에서 오른쪽으로 쓰는 언어(LTR)뿐만 아니라 아랍어, 히브리어처럼 오른쪽에서 왼쪽으로 쓰는 언어(RTL)도 많이 있습니다​

 

웹 페이지를 다국어로 지원할 때 단순히 left, right 같은 물리적 속성만 사용하면, RTL 언어에서는 레이아웃이 의도와 다르게 표시되는 문제가 발생합니다. 왜냐하면 예를 들어 left는 문서의 방향과 무관하게 항상 왼쪽을 의미하기 때문에, RTL 문서에서도 그대로 왼쪽에 적용되어 “시작 부분”에 적용되지 않기 때문입니다​

이러한 문제 때문에 RTL 지원을 위해 별도의 CSS를 작성하거나, dir="rtl"일 때 클래스를 다르게 적용하는 등의 추가 작업이 필요해지곤 합니다.

 

 

3. Tailwind에서 기존 클래스를 덮어쓰는 방법

Tailwind CSS는 유틸리티 퍼스트(utility-first) 프레임워크로, 기본적으로 물리적 속성 기반의 클래스(.ml-4는 margin-left 등)를 제공합니다. Tailwind 자체적으로는 (과거 버전 기준) 논리적 속성 전용 유틸리티를 제공하지 않았기 때문에, RTL 지원을 위해서는 플러그인이나 커스터마이징이 필요했습니다. 다행히 Tailwind v3부터는 일부 논리적 속성을 다루는 유틸리티가 도입되었는데요. 예를 들어 ms-4는 margin-inline-start: 1rem, me-4는 margin-inline-end: 1rem에 해당하여, 콘텐츠 방향에 따라 좌우 마진을 알아서 적용해줍니다​

 

이미 tailwind를 사용하면서 기본 값들 (w, ml-1 등등..)에 익숙해졌는데 새로운 값을 배우려니 귀찮기도 하고,

혹시 크로스 브라우징 이슈가 발생한다면.. (caniuse에 따르면 ie가 아니면 거의 문제없긴 합니다) 롤백이 쉽도록

 

직접 커스텀 플러그인으로 기존 위치 정보(width, left 등)을 logical properties로 overwrite하는 플러그인을 구현해서 사용해봤습니다. .ml-<값> 클래스가 margin-inline-start를, .mr-<값> 클래스가 margin-inline-end를 지정하도록 유틸리티를 추가하면 됩니다.

 

// tailwind.config.js의 plugins 배열 안
plugins: [
  function ({ addUtilities, theme, variants }) {
    const spacing = theme("spacing");
    const newUtilities = {};
    for (const [key, value] of Object.entries(spacing)) {
      newUtilities[`.ml-${key}`] = { "margin-inline-start": value };
      newUtilities[`.mr-${key}`] = { "margin-inline-end": value };
    }
    addUtilities(newUtilities, variants("margin"));
  }
]

 

이렇게 하면 Tailwind의 기존 .ml-1, .mr-1, .ml-2, .mr-2, ... 클래스들이 모두 물리적 margin-left/right 대신 논리적 margin-inline-start/end로 동작하도록 덮어쓰게 됩니다.

 

https://gist.github.com/lodado/e286cdc452c6362117c2129abfab562a

 

위 링크는 제가 지금 사용하고 있는 커스텀 플러그인 코드 예시입니다. 

 

5. 마무리

CSS Logical Properties를 적극 활용하면 별도의 RTL 전용 스타일시트 없이도 하나의 CSS로 양쪽 방향의 레이아웃을 모두 커버할 수 있습니다. 이는 다국어 웹사이트나 글로벌 서비스에서 유지보수를 훨씬 쉽게 만들어줍니다​

이 글을 쓰면서 알게 된건데, 비슷한 역할을 하는 tailwindcss-vanilla-rtl 같은 플러그인 라이브러리가 이미 있군요..

 

그리고 이걸 왜 공부했냐고요?

다국어 지원이 되는 사이드 프로젝트를 구현하다가, 전 세계 사용 언어 5위권에 속하는 아랍어(rtl)를 지원하기 위해 

logical properties를 사용했습니다..! 

 

아래 링크에서 제 사이드 프로젝트를 확인 가능하고,

 

https://mamapapa.vercel.app/

 

 

덤으로 rtl ltr이 지원되는 swipe(?) 기능 list 도 확인하실 수 있습니다..! 

 

ltr일때 왼쪽으로 스와이프 기능
rtl일때 오른쪽으로 스와이프 하는 기능

 

 

 

참고 자료:

CSS Tricks – CSS Logical Properties and Values​

MDN Web Docs – CSS logical properties and values 모듈​

 

Tailwind CSS 공식 문서​

 

Dev.to – CSS Logical Properties - RTL in a web platform​

 

 

반응형
반응형

로버트 마틴의 클린 아키텍처를 읽고 쓴 글입니다.

 

https://lodado.tistory.com/79

 

유연한 컴포넌트 만들기 - 모듈화와 추상화

소프트웨어는 탄생과 동시에 생명 주기를 갖는다. 소프트웨어 개발자는 소프트웨어가 변경될 가능성에 더 신경써야 한다. 소프트웨어는 시간의 흐름에 따라 언젠가 죽음을 맞이하게 될텐데 초

lodado.tistory.com

 

서론

 

이전에 유연한 컴포넌트라는 글을 썼었는데,

 

이전에 제가 쓴 "유연한 컴포넌트" 글에서는 리액트 컴포넌트를 UI, 비즈니스 로직, 그리고 Data Fetching으로 분리했었습니다. 이렇게 분리함으로써 코드의 재사용성과 유지보수성을 높이고자 했죠.

 

하지만 최근 들어 사용하는 프레임워크 & 라이브러리의 deprecated나 Next.js의 급격한 변화(Next.js 13에서 14, 15로 이어지는)를 보며..; 피로감과 불안감을 느끼게 되었습니다.

 

특히 Next.js처럼 프레임워크 & 라이브러리의 큰 변화는 기존 코드 구조에 큰 영향을 미치고, 때로는 기존에 작성한 로직을 재작성해야 하는 상황을 만들기도 합니다. 이러한 변화는 개발자로서 큰 부담으로 다가올 수밖에 없는데, 이를 어떻게 대처할 수 있을지 고민이 많아졌습니다.

 

그래서 클린 아키텍처를 읽게 되었는데요.

보통 백엔트 아키텍처에서 많이 사용하고, 객체 지향을 사용해서 프론트엔드 분야에서는 좀 낯선 분야였지만 읽고 많은 영감을 얻게 되었습니다.

 

문제를 해결하기 위한 WHAT 과 HOW

요약하자면, 어떤 비즈니스의 문제를 해결할때 추상화를 통하여

"WHAT"(문제를 어떻게 해결할지)만 생각해야지 HOW(어떤 기술을 쓰는지)는 중요하지 않고, 분리해서 생각해야한다는 것이였습니다.

 

이를 클린 아키텍처에서는 layered architecture와 DIP(의존성 역전)으로 해결합니다.

예를 들어, 블로그에서 자신이 쓴 비밀 글을 조회하는 상황을 생각해봅시다.

티스토리에서는 비밀 글을 자신만 조회하거나 비밀번호를 알아야 조회할 수 있습니다. (아마도..? 제가 아는 한은 그렇습니다)

 

그럼 가장 높은 추상화 단계로 생각하자면

유저가 로그인을 했고, 정당한 권한을 가지고 있다. (WHAT)만 판별하면 되는 문제입니다.

 

여기서 로그인 판별 방식을 어떻게 할지는(HOW), 세션 인증을 사용하는지, 아님 JWT를 사용하는지는 부차적인 문제고 나중에 언제든지 바뀔 수 있습니다. 

 

그리고 권한을 판별할때도, 지금은 비밀글을 자신밖에 못본다 하더라도 나중 업데이트를 통하여 

네이버 블로그처럼 서로 이웃인 친구는 비밀글을 볼 수 있도록 나중에 바뀔 수 있겠죠. 자세한 디테일은 언제든지 갈아끼우면 됩니다.

 

이처럼 기술과, 비즈니스 로직을 부품화해서 언제든지 갈아끼울 수 있도록 구현하는 방식을 클린 아키텍처는 소개시켜 줍니다.

 

클린 아키텍처 구조

출처: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

 

 

클린 아키텍처하면 가장 유명한 그림인데요

이 책을 읽기 전에는 이해가 잘 안갔는데 읽고 난 후에는 SOLID의 DIP를 응용한 아키텍처라는것을 알게 되었습니다.

 

각 레이어를 to-do 리스트를 만드는 코드 예시와 간단하게 훑어봅시다.

샘플 코드는 아래 깃허브 주소에 있습니다.

 

https://github.com/lodado/chatgpt-cleanarchitecture-example

 

GitHub - lodado/chatgpt-cleanarchitecture-example

Contribute to lodado/chatgpt-cleanarchitecture-example development by creating an account on GitHub.

github.com

 

 

엔티티 레이어(Entity Layer):

class Task {
  id: string;
  title: string;
  isCompleted: boolean;

  constructor(params: { id: string; title: string; isCompleted?: boolean }) {
    if (!params.id || !params.title) {
      throw new EntityError({ message: "Task must have an id and a title." });
    }

    this.id = params.id;
    this.title = params.title;
    this.isCompleted = params.isCompleted ?? false;
  }

  /**
   * Mark the task as completed
   */
  toggleMark(): void {
    this.isCompleted = !this.isCompleted;
  }

  /**
   * Change the title of the task
   */
  changeTitle(newTitle: string): void {
    if (!newTitle) {
      throw new EntityError({ message: "New title cannot be empty." });
    }
    this.title = newTitle;
  }
}

 

이 레이어는 애플리케이션의 핵심이 되는 비즈니스 엔티티를 포함하고 있습니다. 이러한 엔티티는 비즈니스 규칙을 캡슐화하며 애플리케이션 레이어와는 독립적입니다.

 

집합 개념으로 치면 "연산이 닫혀 있다.(Closure under an operation)" 라는 개념이 생각나는데요.

닫혀있다의 사전적 의미는 특정 연산에 대해 어떤 집합이 닫혀 있다고 말할 때, 이는 그 연산을 집합의 원소들 사이에서 수행한 결과가  항상 동일한 집합의 원소로 남아 있는 성질을 의미합니다.


예를 들어, 엔티티 레이어는 애플리케이션의 다른 레이어와 독립적으로 존재하며, 엔티티 간의 상호작용이나 연산이 일어날 때 그 결과가 항상 엔티티 레이어 내에서 관리되고 유지됩니다. 즉, 엔티티 레이어 내의 비즈니스 규칙이 적용된 연산 결과는 여전히 같은 엔티티 레이어 내에서 처리되며, 외부 레이어로부터의 영향을 받지 않고 독립성을 유지합니다.

이는 수학적 집합에서 특정 연산을 수행한 결과가 항상 그 집합 내에 남아 있는 것과 유사합니다.

 

유스 케이스 레이어(Use Case Layer):

AddTodoListUseCase {
  constructor(private taskRepository: TodoListRepositoryImpl) {}

  async execute(params: { id: string; title: string }): Promise<void> {
    const task = new Task({ id: params.id, title: params.title });
    try {
      return await this.taskRepository.addTask({ task });
    } catch (error) {
      throw mapRepositoryErrorToUseCaseError(error as Error);
    }
  }
}

 

이 레이어는 애플리케이션에 특화된 비즈니스 규칙을 포함하고 있습니다. 이 레이어는 엔티티로부터 데이터를 주고받는 흐름을 조정하고, 필요한 리포지토리를 호출하며 각 유스 케이스에 대한 로직을 관리합니다.

 

비즈니스 로직을 정의하고 있는데요. addTask라는 명령을 수행하는데 해당 코드는 하나의 list를 저장소에 추가하라는 명령을 내리고 있습니다.

그런데 데이터를 "어떻게", "어디에" 저장할지는 주입받은 repository layer에서 정의합니다.

예를 들어 임베디드 시스템에서 저장 명령을 내릴때 "메모리에 저장해"라는 명령어를 내리고 펌웨어에서 "어디"에 저장할지 구체적으로 실행합니다.  

 

 

 

리포지토리 레이어(Repository Layer):

import { Task } from "../../../../entities";
import {
  RepositoryError,
  mapEntityErrorToRepositoryError,
} from "../../../../shared";

import { TodoListRepositoryImpl } from "./interface";

export default class InMemoryTodoListRepository
  implements TodoListRepositoryImpl
{
  private tasks: Map<string, Task> = new Map();

  async getAllTasks() {
    return new Map(this.tasks);
  }

  async addTask(params: { task: Task }): Promise<void> {
    try {
      if (this.tasks.has(params.task.id)) {
        throw new RepositoryError({
          message: "Task with this ID already exists.",
        });
      }
      this.tasks.set(params.task.id, params.task);
    } catch (error) {
      throw mapEntityErrorToRepositoryError(error as Error);
    }
  }

  async getTaskById(params: { id: string }): Promise<Task | null> {
    try {
      return this.tasks.get(params.id) ?? null;
    } catch (error) {
      throw mapEntityErrorToRepositoryError(error as Error);
    }
  }

  async deleteTask(params: { id: string }): Promise<void> {
    try {
      if (!this.tasks.delete(params.id)) {
        throw new RepositoryError({ message: "Task not found for deletion." });
      }
    } catch (error) {
      throw mapEntityErrorToRepositoryError(error as Error);
    }
  }

  async toggleMark(params: { id: string }): Promise<void> {
    try {
      const task = await this.getTaskById({ id: params.id });
      if (!task) {
        throw new RepositoryError({
          message: "Task not found to mark as completed.",
        });
      }
      task.toggleMark();
    } catch (error) {
      throw mapEntityErrorToRepositoryError(error as Error);
    }
  }
}

 

 

이 레이어는 데이터 접근 로직에 대한 추상화를 제공합니다. 데이터베이스나 웹 서비스와 같은 외부 시스템과 상호작용하며, 외부 포맷과 애플리케이션 엔티티 간의 데이터를 변환하는 역할을 담당합니다.

 

구체적으로 "어디에" 저장할건지를 수행합니다. DB일수도, 메모리일수도, 아니면 네트워크 요청일수도 있죠. 레이어 위에서 아래로 클래스를 주입하면서 의존성 역전을 통하여 핵심적인 로직만 전달합니다.

 

 

어댑터 레이어(Adapter Layer):

 

이 레이어는 애플리케이션과 외부 세계(예: 사용자 인터페이스, 외부 API, 데이터베이스 등) 사이의 인터페이스 역할을 합니다.

백엔드면 URL 요청을 받는 controller,  프론트엔드면 hook 부분일수 있습니다. 

 

객체지향을 쓰지 않더라도 이책에서 말하는 핵심적인 가치, 추상화는 유용할것 같습니다. 이책을 읽고 많이 배운것 같네요.

반응형

'Front-end' 카테고리의 다른 글

쓸만해보이는 태그, 속성들 (메모용)  (0) 2022.07.11
jest가 느린이유  (0) 2022.02.18
반응형

개발하다보면 시간이 지나면서 라이브러리가 deprecated되고, 

라이브러리 버전업 및 마이그레이션을 해야하는 경우가 생기는데요.

 

또한, 디자인 시스템을 만들면서 기능 및 인터페이스가 deprecated 되거나 변경되는 케이스가 존재했는데

 

그때를 위한 좋은 라이브러릴 하나 찾아서 공유해봅니다.

 

1. jscodeshift란 무엇인가?

 

jscodeshift는 Facebook(현 Meta)에서 개발한 JavaScript 코드베이스 변환 도구입니다.

 

이 도구는 Abstract Syntax Tree(AST)를 사용하여 코드의 구조를 이해하고, 필요한 변환을 자동으로 수행할 수 있게 해줍니다.

코드의 AST 변환

 

(위 그림을 보시면 좀 무서울수도 있는데  codemod Studio나 chatgpt를 사용하시면 손쉽게 사용 가능합니다...)

 

이 도구는 특히 대규모 코드베이스에서 일관된 코드 스타일 적용, API 변경에 따른 대규모 리팩토링, 혹은 레거시 코드에서 최신 문법으로의 변환 작업 등에 매우 유용합니다.

 

 

jscodeshift를 찾기 전엔 아래 방법을 사용했었는데요,

 

1. vscode의 전체 검색 및 변경으로 대응

2. file I/O 스크립트+정규식을 통하여 변경

 

다음 케이스는 대응이 어려웠습니다. (실제로 업무중 발생한 케이스)

 

1. 함수/클래스 인터페이스가 변경되는 경우

// 변경전
<Dialog Trigger={TriggerComponent}>
	<Dialog.Header />
	<Dialog.Content />
</Dialog>


// 변경 후
<Dialog>
	<Dialog.Trigger asChild>
		<TriggerComponent />
    </Dialog.Trigger>
	<Dialog.Content>
		<Dialog.Header/>
		<Dialog.Body/>
	</Dialog.Content>
</Dialog>

 

예를 들어서 react에서 props로 넘기던 컴포넌트를

children으로 주입해주게 리펙토링 하는 경우 대응이 어려웠습니다.

 

2. import 경로가 바뀌는 경우, 혹은 일부만 아예 다른 경로로 바뀌는 경우

// 이전
import { useLoginSession, anotherHook } from '@/hooks/login'

// 이후
import { useLoginSession } from '@/entities/auth'
import { anotherHook } from '@/hooks/login'

 

 

그외에도 정규식만으로 대응하기는 어려운 케이스가 수십, 수백 케이스 있겠죠. 특히 라이브러리 migration이 골치아팠었습니다.

 

그럼 쓰는 예시를 코드로 한번 봅시다.

 

jscodeShift 코드 예시

 

2번 케이스가 간단하니 코드로 한번 봅시다. 

 

export default function transformer(file, api) {
  const j = api.jscodeshift
  const root = j(file.source)

  root
    .find(j.ImportDeclaration, {
      source: { value: '@/hooks/login' },
    })
    .forEach((path) => {
      const useLoginSessionSpecifier = path.node.specifiers.find(
        (specifier) => specifier.imported && specifier.imported.name === 'useLoginSession',
      )

      if (useLoginSessionSpecifier) {
        // 경로를 '@/entities/auth'로 변경
        path.node.source.value = '@/entities/auth'

        // 다른 import가 있다면 분리
        const otherSpecifiers = path.node.specifiers.filter((specifier) => specifier !== useLoginSessionSpecifier)

        if (otherSpecifiers.length > 0) {
          // 원래 import 구문에서 useLoginSession을 제거
          path.node.specifiers = [useLoginSessionSpecifier]

          // 새로운 import 구문 추가
          const newImport = j.importDeclaration(otherSpecifiers, j.literal('@/hooks/login'))

          j(path).insertAfter(newImport)
        }
      }
    })

  return root.toSource()
}

 

과정을 글로 설명하면 다음과 같습니다.

 

1. code를 AST 트리로 파싱합니다.

 

2.  AST 트리에서 변경하고 싶은 부분을 찾아냅니다.

 

3. 변경을 적용합니다.

 

4. 적용한 코드를 내보냅니다. (마지막 코드 부분)

 

 

1,2,3번이 좀 어려울 수 있는데 codemod(https://codemod.com/studio) 를 사용하거나,

ChatGPT 같은 AI의 도움을 받으면 빠르고 손쉽게 변경 가능합니다.

 

ai를 사용한다면 내가 하고 싶은 과정을 step by step으로 잘 풀어내는 게 중요하겠네요.

 

결론

 

codemod 와 jscodeshift를 사용하면 자동화된 스크립트를 통하여 손쉽게 바꿀 수 있습니다.

저는 요즘 디자인 시스템 개발 & 기존 코드 migration을 하고 있었는데,  여러 팀이 동시에 작업을 하다보니

 

기존 개발 되었던 컴포넌트의 기본 인터페이스가 잘못되었다고 판단되어도

다른 팀의 프로젝트에 이미 쓰고 있으니 변경이 매우 골치아팠었는데 리펙토링 후 migration 스크립트를 같이 제공하는것으로 변경과 확장에 매우 유연해질 수 있었습니다.

 

좋은 라이브러리 찾아서 공유해봅니다 ㅎㅎ

반응형

'Front-end > JavaScript' 카테고리의 다른 글

commonjs와 ESM의 차이  (0) 2023.07.25
pnpm 도입기 (feat: 모노 레포)  (0) 2023.07.15
자바스크립트의 async/await에 대해서  (0) 2022.03.31
JS로 Single Page Application 구현  (0) 2022.03.12
Generator  (0) 2022.02.02
반응형

사내 디자인 시스템을 만들고 있었는데

배포 및 개발 환경 관련해서 여러가지 피드백 및 요청이 왔습니다..!

 

1. 배포 환경을 갖추기 어려운데 자동화 스크립트가 있으면 좋을거 같다.

2. 초심자도 쉽게 배포하고 빌드하면 좋을 것 같다.

 

특히, 각 컴포넌트를 하나의 모노 레포지토리로 관리하고 있고 내부에서 
turbo repo, rollup, jest 등 많은 개발 라이브러리를 사용하고 있는데 시행착오를 많이 겪으시는거 같아서 

boilerplate 생성 스크립트를 하나 작성했고, 블로그에도 공유해 봅니다. 

 

생성 스크립트

 

더보기
const fs = require('fs')
const path = require('path')
const readline = require('readline')
const { exec } = require('child_process')

const validatePackageName = (name) => {
  const regex = /^@TDS\/[a-zA-Z0-9-_]+$/
  if (!regex.test(name)) {
    throw new Error(`Invalid package name format. It should be in the format @TDS/package's name`)
  }

  const hasUppercase = /[A-Z]/.test(name.split('/')[1])
  if (hasUppercase) {
    console.warn('Warning: Package name should not contain uppercase letters.')
  }
}

const generatePackageJson = (packageName, sourcePath) => {
  // 무엇인지 인지한 상태에서 바꿀것
  const [namespace, name] = packageName.split('/')
  
  const packageJson = {
    name: `${namespace}/${name.toLowerCase()}`,
    version: '0.0.0',
    sideEffects: ['./index.scss'],
    license: 'MIT',
    exports: {
      '.': {
        require: sourcePath,
        import: sourcePath,
      },
      './index.scss': './index.scss',
      './package.json': './package.json',
    },
    source: sourcePath,
    main: sourcePath,
    module: sourcePath,
    types: sourcePath,
    files: ['dist/**'],
    scripts: {
      test: 'jest --passWithNoTests',
      build: 'rollup -c && tsc --emitDeclarationOnly --declarationMap false --declaration --declarationDir dist/types',
      lint: `eslint "src/**/*.ts*"`,
      clean: `rm -rf .turbo && rm -rf node_modules && rm -rf dist`,
      'manual-release': 'pnpm run build && pnpm publish --no-git-checks',
    },
    devDependencies: {
      '@types/node': '^20.12.7',
      autoprefixer: '^10.4.19',
      'babel-jest': '29.5.0',
      eslint: '^8.57.0',
      'jest-config': 'workspace:*',
      'rollup-config': 'workspace:*',
      'eslint-config-acme': 'workspace:*',
      postcss: '^8.4.38',
      sass: '^1.75.0',
      'ts-jest': '29.0.5',
      tsconfig: 'workspace:*',
    },
    dependencies: {},
    peerDependencies: {
      '@types/react': '^18.2.37',
      '@types/react-dom': '^18.2.25',
      react: '^16.8 || ^17.0 || ^18.0',
    },
    peerDependenciesMeta: {
      '@types/react': {
        optional: true,
      },
      '@types/react-dom': {
        optional: true,
      },
    },

    publishConfig: {
      access: 'restricted',
      registry: 'http://##addressissecret##/api/v4/projects/311/packages/npm/',
      exports: {
        '.': {
          import: {
            types: `./dist/types/index.d.ts`,
            default: `./dist/es/client/${path.basename(sourcePath).replace('tsx', 'mjs')}`,
          },
          require: {
            types: `./dist/types/index.d.ts`,
            default: `./dist/cjs/client/${path.basename(sourcePath).replace('tsx', 'cjs')}`,
          },
        },
        './index.css': `./dist/es/client/index.css`,
      },
      source: `./src/index.ts`,
      main: `./dist/cjs/client/${path.basename(sourcePath).replace('tsx', 'cjs')}`,
      module: `./dist/es/client/${path.basename(sourcePath).replace('tsx', 'mjs')}`,
      types: `./dist/types/index.d.ts`,
    },
  }

  return JSON.stringify(packageJson, null, 2)
}
const createSrcIndexTsx = (srcDir) => {
  const srcIndexTsxContent = `export {};`
  fs.writeFileSync(path.join(srcDir, 'index.tsx'), srcIndexTsxContent)
}

const createRootIndexTsx = (projectDir) => {
  const rootIndexTsxContent = `import './index.scss';

// export * from './src';
`
  fs.writeFileSync(path.join(projectDir, 'index.tsx'), rootIndexTsxContent)
}

const createEslintConfig = (projectDir) => {
  const rootIndexTsxContent = `module.exports = {
    root: true,
    extends: ["acme"],
  };  
`
  fs.writeFileSync(path.join(projectDir, '.eslintrc.js'), rootIndexTsxContent)
}

const createIndexScss = (projectDir) => {
  const indexScssContent = ` 
`
  fs.writeFileSync(path.join(projectDir, 'index.scss'), indexScssContent)
}

const createJestConfig = (projectDir) => {
  const jestConfigContent = `const jestConfig = require('jest-config/jest.config.js')

const customJestConfig = {
  ...jestConfig,
  // 패키지별 설정을 여기에 추가
}

module.exports = customJestConfig
`
  fs.writeFileSync(path.join(projectDir, 'jest.config.js'), jestConfigContent)
}

const createPostcssConfig = (projectDir) => {
  const postcssConfigContent = `module.exports = {
  plugins: [require('autoprefixer')()],
}
`
  fs.writeFileSync(path.join(projectDir, 'postcss.config.js'), postcssConfigContent)
}

const createRollupConfig = (projectDir) => {
  const rollupConfigContent = `import { defaultConfig } from 'rollup-config/rollup.config.mjs'

const config = defaultConfig()
export default config
`
  fs.writeFileSync(path.join(projectDir, 'rollup.config.mjs'), rollupConfigContent)
}

const createSetupTests = (projectDir) => {
  const setupTestsContent = `import '@testing-library/jest-dom'
`
  fs.writeFileSync(path.join(projectDir, 'setupTests.ts'), setupTestsContent)
}

const createTsconfig = (projectDir) => {
  const tsconfigContent = `{
  "extends": "tsconfig/react-library.json",
  "compilerOptions": {
    "baseUrl": "./",

    "paths": {}
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["dist", "build", "node_modules", "**/*.test.*"]
}
`
  fs.writeFileSync(path.join(projectDir, 'tsconfig.json'), tsconfigContent)
}

const createProjectStructure = (packageName, projectDir, sourcePath) => {
  if (!fs.existsSync(projectDir)) {
    fs.mkdirSync(projectDir, { recursive: true })
  }

  const srcDir = path.join(projectDir, 'src')
  if (!fs.existsSync(srcDir)) {
    fs.mkdirSync(srcDir)
  }

  // Write package.json
  fs.writeFileSync(path.join(projectDir, 'package.json'), generatePackageJson(packageName, sourcePath))

  // Create other files
  createSrcIndexTsx(srcDir)
  createRootIndexTsx(projectDir)
  createIndexScss(projectDir)
  createEslintConfig(projectDir)
  createJestConfig(projectDir)
  createPostcssConfig(projectDir)
  createRollupConfig(projectDir)
  createSetupTests(projectDir)
  createTsconfig(projectDir)

  console.log(`Project structure for ${path.basename(projectDir)} created successfully.`)
}

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
})

const question = (text, defaultValue = '') => {
  return new Promise((resolve) => {
    rl.question(`${text}${defaultValue ? ` (${defaultValue})` : ''}: `, (answer) => resolve(answer || defaultValue))
  })
}

const runPnpmInstall = (projectDir) => {
  return new Promise((resolve, reject) => {
    exec('pnpm install', { cwd: './' }, (error, stdout, stderr) => {
      if (error) {
        console.error(`Error running pnpm install: ${error}`)
        reject(error)
        return
      }
      console.log(stdout)
      console.error(stderr)
      resolve()
    })
  })
}

;(async () => {
  const packageName = await question('Enter the package name (ex- @TDS/button)')
  validatePackageName(packageName)

  const folderPath = path.join('./apps/', packageName.replace(/\//g, '-'))

  console.log(`folders create in ${folderPath}`)
  const sourcePath = './index.tsx'
  const projectDir = path.join(folderPath)

  console.log(`entryPoint is ${sourcePath}`)

  createProjectStructure(packageName, projectDir, sourcePath)

  rl.close()
  console.log(`package.json generated and project folder created at ${projectDir}.`)

  console.log('start installing packages....')

  await runPnpmInstall(projectDir)

  console.log('done.')
})()

 

코드 전체는 아래에서 구경하실 수 있습니다. 


https://gist.github.com/lodado/4beb14f44d304fe65772809778e0fb35

 

generatePacakges.js

GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 

사용 예시 스크린샷)

 

 

기본 사용 방법은

라이브러리의 이름을 입력하면 지정된 위치에 폴더 +  라이브러리 명으로 하나의 레포지토리 및 설정 파일이 생깁니다.

혹시 쓰실 분은 config 관련 함수에 자기가 원하는 코드를 작성하는 식으로 커스텀하면 되실 것 같습니다. 

 

반응형

+ Recent posts