반응형

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 관련 함수에 자기가 원하는 코드를 작성하는 식으로 커스텀하면 되실 것 같습니다. 

 

반응형
반응형
{
   // 예시 코드!
  "exports": {
    ".": {
      "require": "./src/index.tsx",
      "import": "./src/index.tsx"
    },
    "./index.scss": "./src/index.scss",
  },
  "source": "src/index.tsx",
  "main": "dist/index.js",
  "files": [
    "dist/**"
  ],
  "devDependencies": {
...
  },
  "dependencies": {
....
  },
  "publishConfig": {
    "access": "restricted",
    "registry": "http://####/api/v4/projects/311/packages/npm/",
    "exports": {
      ".": {
        "require": "./dist/index.js",
        "import": "./dist/index.mjs",
        "types": "./dist/index.d.ts"
      },
      "./index.css": "./dist/index.css"
    },
    "import": "./dist/index.mjs",
    "main": "./dist/index.js",
    "module": "./dist/index.mjs",
    "types": "./dist/index.d.ts"
  }
}

 

모노레포에서 개발 경험 향상을 위해서 레포지토리의 package.json의 exports에 원본 코드 파일을(./src/index.ts) 명시하고, 배포할때는 publishConfig 옵션의 exports에 빌드 결과물 파일을 명시해서 사용하고 있다.

 

*export에 빌드 결과물(./dist/index.js)을 적으면 테스트코드를 돌리기 위해서 반드시 모든 레포지토리를 빌드해줘야해서 위 방법을 사용했음

 

RootDir 문제점..?

이랬더니 빌드하면 의존성 있는 레포지토리 내용이 번들링 결과에 포함되는 결과물이 생겼었는데..

 

dist (번들링된 결과물)
└── 기타 파일 etc...
└── packages (다른 레포지토리 결과물)
    ├── @다른 모노레포 1 
    │   ├── designFiles
    │   └── src
    │       ├── index.css
    │       └── index.js
    ├── @다른 모노레포 2
    │   └── src
    │       ├── .eslintrc.js
    │       ├── .eslintcache
    │       ├── .gitignore
    │       └── .npmrc

 

빌드 과정에서 tsconfig.jsoncompilerOptions.rootDir 설정이 다른 타입스크립트 파일을 포함하기 위해서 모노레포의 최상위 레포지토리 루트(./)로 자동 확장되고, 빌드 결과물에 다른 레포지토리의 내용이 함께 번들링되는 문제가 발생한걸로 추측됐다. 

 

이 타입 관련 문제는 typescript 자체적인 문제로 tsup, rollup등 다른 번들러를 써도 동일한 결과가 나올것이라 추측되고 실제로 tsup, rollup 동일하게 발생했다.

 

해결 방법  

해결방법은 생각보다 간단했는데.. 

 

1. peer dependencies

 

이미 다른 레포지토리를 배포했다면 해당 라이브러리를 참조해서 사용하면 된다.

그래서, peerDependencies에 다른 모노레포 레포지토리 명을 적어주면 개발할때는 export에 적어놓은 타입스크립트 원본 파일을 참조하고, 빌드할때는 peerdependencies이니까 자동적으로 다른 레포지토리의 내용이 빌드 결과물에 빠지게 된다.

 

참고로 rollup external 등 여러 옵션을 써봐도 다른 레포지토리의 빌드 결과는 자동적으로 빠지지 않았다. 번들러 입장에서 다른 레포지토리가 정말로 배포되어 있어서 내가 빌드 안해도 되는지 알 방법이 없으니 peerDependendicies로 명시해줘야한다.

 

2. 빌드할때 내 폴더의 타입은 tsc로 따로 빌드함

 

다른 사람들이 쓴 rollup 코드들을 보면 타입은 tsc 등으로 따로 빌드하던데 왜 그런가 했더니.. 이 rootDir문제 때문이 아닐까?

typescript({
          tsconfig: './tsconfig.json',
          tsconfigOverride: {},
          useTsconfigDeclarationDir,
        }),

 

나는 rollup과 rolllup-plugin-typescript2를 사용하고 있는데 useTsconfigDeclarationDir로 타입만 빼고 자바스크립트 파일만 따로 빌드하게 만들었다. 

해당 옵션을 빼면 peerDependencies를 사용해도 동일한 문제가 발생했다.

 

  "scripts": {
    "build": "rollup -c ./rollup.config.mjs",
    "build:types": "tsc --emitDeclarationOnly --declarationMap false --declaration --declarationDir dist/types/client",
  }

 

그리고 tsc로 레포지토리의 타입만 따로 추출한다.


해당 방법이 완벽한 정답이 아닐수도 있긴 하지만.. 일단 pnpm, turbo repo, typescript, rollup을 사용하는 환경에서는 잘 동작하는것 같다. 

반응형
반응형

사내에서 프로젝트 별 코드의 파편화를 줄이고 디자인 시스템을 만들려고 하고 있다.

인수인계 겸 정리한 문서를 공개해도 상관 없을꺼 같아서 블로그에 간단 정리해본다.

 

사용 툴

  • 🏎 Turborepo — 모노레포 라이브러리로 여러 패키지나 프로젝트 간의 의존성 관리를 효율화하고 Monorepo 내에서 빌드 프로세스를 간소화
  • 🚀 React — 프론트엔드 사용 라이브러리
  • 🛠 Parcel — 번들러로 설정 없이 간편하게 쓸 수 있어서 사용, 추후 rollup으로 변경될 가능성도 있음
  • 📖 Storybook — UI 컴포넌트 문서화 및 공유 & 협업용
  • TypeScript는 정적 타입 검사를 위한 도구
  • ESLint는 코드 린팅을 위한 도구
  • Prettier는 코드 포맷팅을 위한 도구
  • Changesets는 버전 관리와 변경 로그를 관리하는 도구
  •  

라이브러리를 구현할때는 radix-ui를 사용합니다.

자체 구현은 최대한 지양합니다. (개발자 나가면 유지보수가 안됨 ㅠㅠ)

 

커밋 컨벤션

build: 빌드 시스템이나 외부 의존성에 관련된 변경사항 (예: gulp, broccoli, npm의 패키지 변경 등)
chore: 기타 변경사항 (코드 수정 없이 설정을 변경하는 경우)
ci: 지속적 통합(CI) 설정과 스크립트 변경사항 (예: Travis, Circle, BrowserStack, SauceLabs의 설정 변경 등)
docs: 문서에만 영향을 미치는 변경사항
feat: 새로운 기능에 대한 커밋
fix: 버그 수정에 대한 커밋
perf: 성능을 개선하는 코드 변경사항
refactor: 버그를 수정하거나 기능을 추가하지 않는 코드 변경사항
revert: 이전 커밋을 되돌리는 작업
style: 코드 의미에 영향을 주지 않는 변경사항 (공백, 포맷팅, 누락된 세미콜론 등)
test: 테스트 추가, 리팩토링 테스트 코드, 누락된 테스트 추가 등
echo "foo: some message" # fails
echo "fix: some message" # passes

 

conventional commit을 사용합니다. 허용되지 않은 양식은 husky와 commitlint library가 막습니다.

https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional

 

모노 레포 구성

 

모노레포는 여러 개의 프로젝트나 라이브러리를 단일 저장소에서 관리하는 방식을 말합니다. 이러한 접근 방식은 큰 프로젝트나 여러 팀이 협업할 때 많은 이점을 제공합니다. 주요 이점으로는 코드의 재사용성 증가, 의존성 관리의 단순화, 통합된 버전 관리 등이 있습니다.

turbo repo를 이용한 모노 레포 구조를 가집니다. 각 폴더는 하나의 레포지토리를 구성합니다.

 

- apps
    ㄴ docs -> 문서용 storybook
    ㄴ RadixComponents -> 사용하는 공통 컴포넌트 (현재는 사용하고 있지 않음)
    ㄴ TTTable  -> 테이블 구현체

- packages
    ㄴ shared  -> 공통 변수
    ㄴ @TDS/utils -> 공통 함수
    ㄴ designFiles -> designTokens, icons들 저장소

- configs
    ㄴ eslint -> eslint 설정
    ㄴ tsconifg -> typescript 설정
    ㄴ jestconfig -> jest 설정
  • apps: 실제 사용자에게 제공되는 애플리케이션들이 위치합니다. 여기에는 문서를 위한 Storybook(docs), 공통 컴포넌트(RadixComponents), 테이블 구현체(TTTable) 등이 포함될 수 있습니다.
  • packages: 재사용 가능한 코드, 공통 함수, 디자인 토큰, 아이콘 등을 포함하는 패키지들이 위치합니다. 이러한 패키지들은 apps 내의 프로젝트들 및 배포 후 자사 프로젝트에 의해 사용될 수 있습니다.
  • configs: 프로젝트 전체에 걸쳐 공유되는 설정 파일들이 위치합니다. ESLint, TypeScript, Jest 등의 설정이 포함될 수 있습니다.

script 실행 방법

최상위 package.json에 정의된 스크립트는 모노레포 내의 여러 프로젝트나 패키지에 대한 작업을 조정합니다. 예를 들어, 다음과 같은 스크립트가 있을 수 있습니다:

  • build: 애플리케이션과 패키지를 빌드합니다.
  • test: 애플리케이션과 패키지의 테스트를 실행합니다.
  • lint: 코드 스타일과 문법 검사를 전체 프로젝트에 걸쳐 실행합니다.
  • start: 개발 모드에서 특정 애플리케이션을 실행합니다.

최상위 폴더에서 pnpm run build시 하위 레포지토리의 build 명령어가 있는 레포지토리는 병렬적으로 해당 명령어를 전부 실행합니다.

다른 자세한 기능들은 turbo repo docs를 참고하시기 바랍니다.

레포지토리 배포 프로세스

1. pnpm run changeset
2. 패치 버젼, summary 등 작성
3. pnpm run changeset version (version up)
4. commit
5. PR 및 리뷰
6. master branch에 반영
7. gitlab runner에서 자동 배포

1. pnpm run changeset

  • 이 명령은 변경 사항을 추가하기 위해 Changesets CLI를 실행합니다.

2. 패치 버전, 요약 등 작성

  • 선택한 패키지와 업데이트 유형(예: 패치)에 대한 설명을 작성합니다. 이 설명은 추후 changelog에 반영됩니다.
  • 이 과정은 변경 사항에 대한 명확한 기록을 남기며, 팀원들이나 사용자들이 어떤 변경 사항이 있었는지 쉽게 파악할 수 있도록 합니다.

3. pnpm run changeset version (버전 업)

  • 이 명령은 Changesets가 생성한 변경 사항 파일을 기반으로 패키지의 버전을 실제로 업데이트하고, 각 패키지의 changelog를 업데이트합니다.
  • 변경 사항에 대한 모든 정보는 패키지별로 관리되며, 이 단계에서 자동으로 패키지 버전이 업데이트됩니다.

4. 커밋

  • 버전 업데이트와 changelog 변경 사항을 Git 저장소에 커밋합니다. 이 커밋에는 버전이 업데이트된 패키지와 변경된 changelog 내용이 포함됩니다.

5. PR(풀 리퀘스트) 및 리뷰

  • 변경 사항을 메인 브랜치(예: master)에 병합하기 전에, 풀 리퀘스트를 생성합니다. 이는 코드 리뷰 과정을 통해 변경 사항을 검토하고 팀 내에서 합의를 이루기 위함입니다.
  • 팀원들이 변경 사항을 리뷰하고, 필요한 피드백을 제공합니다. 리뷰 과정을 통해 코드 품질을 유지하고, 오류를 사전에 방지할 수 있습니다.

6. master 브랜치에 반영

  • 리뷰를 통과한 후, 풀 리퀘스트를 메인 브랜치에 병합합니다. 이는 변경 사항이 공식적으로 프로젝트에 반영되었음을 의미합니다.

7. GitLab Runner에서 자동 배포

  • 메인 브랜치에 병합된 후, GitLab CI/CD 파이프라인이 실행됩니다. GitLab Runner는 이 파이프라인을 통해 정의된 배포 작업을 자동으로 수행합니다.
  • 배포 과정은 테스트, 빌드, 배포 단계를 포함할 수 있으며, 이 모든 과정은 자동으로 실행됩니다. 성공적으로 배포가 완료되면, 변경된 사항이 실제 환경에 적용됩니다.

** package.json에 명시한 build 결과물 및 publish config의 exports 파일만 배포하므로 꼼꼼히 확인해주세요.

private(사내망) 배포 방법

 

1. 최상위 .npmrc 에 registry를 설정합니다. (구현되어 있음)

@TDS:registry=http://#######/api/v4/projects/311/packages/npm/

위 설정의 뜻은 @TDS 라는 이름을 앞에 붙인 라이브러리를 사내에(private registry) 배포 및 사용하겠단 뜻입니다.

사용하는 프로젝트의 .npmrc에도 동일하게 작성합니다

 

2. 배포하고 싶은 라이브러리의 package.json을 설정합니다. (중요!)

{
  "name": "@TDS/radix_components",
  "version": "0.0.0",
  "sideEffects": ["./src/index.scss"],
  "license": "MIT",
  "exports": {
    ".": {
      "require": "./src/index.tsx",
      "import": "./src/index.tsx"
    },
    "./index.scss": "./src/index.scss",
    "./package.json": "./package.json"
  },
  "source": "src/index.tsx",
  "main": "dist/index.js",
  "files": [
    "dist/**"
  ],
  "scripts": {
    "test": "jest --passWithNoTests",
    "build": "tsup src/index.tsx --format esm,cjs --dts --external react",
    "parcel:build": "parcel build",
       .... 
  },
  "devDependencies": {
...
  },
  "dependencies": {
....
  },
  "resolutions": {
   ....
  },
  "publishConfig": {
    "access": "restricted",
    "registry": "http://####/api/v4/projects/311/packages/npm/",
    "exports": {
      ".": {
        "require": "./dist/index.js",
        "import": "./dist/index.mjs",
        "types": "./dist/index.d.ts"
      },
      "./package.json": "./package.json",
      "./index.css": "./dist/index.css"
    },
    "import": "./dist/index.mjs",
    "main": "./dist/index.js",
    "module": "./dist/index.mjs",
    "types": "./dist/index.d.ts"
  }
}

 

여기서 중요하게 볼 것은 exports와 publish config, private, sideEffects, name, version 입니다.

name

  • 라이브러리의 고유한 이름을 설정합니다. 여기서는 @TDS/radix_components로, @TDS는 범위(scope)를 나타내며, 같은 범위 내에서 유니크한 이름을 가지게 합니다. npm 등의 패키지 매니저를 통해 이 이름으로 라이브러리를 찾고 설치할 수 있습니다.

version

  • 라이브러리의 현재 버전을 나타냅니다. 세마틱 버저닝(semantic versioning) 원칙에 따라 major.minor.patch 형식으로 관리됩니다. 초기 상태인 0.0.0에서 시작하여, 라이브러리가 업데이트될 때마다 적절한 버전 업을 합니다.

sideEffects

  • 패키지가 부수 효과(side effects)를 가지는지 여부를 웹팩(Webpack) 같은 모듈 번들러에 알립니다. 여기서는 ./src/index.scss 파일이 부수 효과를 가진다고 명시되어 있어, 이 파일을 제외하고 트리 쉐이킹(tree shaking)을 적용할 수 있습니다.

exports

  • 패키지의 내보내기(entry points)를 정의합니다. 이는 패키지를 사용하는 소비자가 접근할 수 있는 모듈의 경로를 명시합니다. 예를 들어, require 또는 import를 통해 메인 모듈을 가져오거나, 추가적인 파일(index.scss)에 대한 접근 방법을 제공합니다.
  • 개발자 경험 향상을 위해서 모노레포 내에서는 코드를 참조합니다. 해당 방식을 사용하지 않으면 merge 및 테스트때마다 강제로 빌드를 한번씩 실행해줘야 합니다.

publishConfig

  • 패키지를 배포할 때 사용할 설정을 정의합니다. access 필드로 접근성을 설정하며, registry 필드는 패키지가 배포될 npm 레지스트리의 URL을 지정합니다. 또한, exports 섹션은 배포된 패키지의 내보내기 설정을 다시 정의하여, 사용자가 패키지의 빌드된(dist) 버전을 사용할 수 있게 합니다.
  • 개발자 경험을 향상하기 위해서 모노레포 빌드시 해당 옵션을 사용하도록 합니다.

private

  • (이 설정은 예시에 명시되어 있지 않지만 중요합니다) 이 필드가 true로 설정되면, 패키지가 비공개로 설정되어 npm 등의 공개 레지스트리에 배포되지 않습니다. 주로 개인 프로젝트나 팀 내부에서 사용할 패키지에 적용됩니다.
  • configs 레포지토리들은 private로 적용합니다.

 

gitlab의 package registry에서 현재 배포된 파일들을 확인 가능합니다

 

사내에 배포시 gitlab의 package registry에서 현재 배포된 파일들을 확인 가능합니다.

라이브러리 사용 방법

  "dependencies": {
    "@DB3/designFiles": "^0.0.0",
    "@DB3/tttable": "1.0.2",
       ...
   }

“내부망에 접속된 컴퓨터”에서 package.json에 명시해서 사용하면 됩니다.

 

CSS 관리법 - Design Variable

Figma Variable은 Figma 디자인 도구에서 사용되는 기능으로, 사용자가 디자인 내에서 텍스트나 색상과

같은 속성을 변수로 설정하여 재사용할 수 있게 해주는 기능입니다.

 

design variable에 대한 간단한 설명

 

개발의 "variable(변수)"처럼 dark mode, theme 등 여러 상황에 알맞게 값을 바꿔주는 기능
color, text 등 여러 값을 유연하게 변경 가능

반영 방법

enterprise 계정에서만 figma web api를 통한 auto import 가능 (우리 회사는 해당 없음)

그래서, figma plugin을 활용해서 design variable은 variable2CSS plugin으로 받아오고

typography 관련은 global style to code plugin으로 수동으로 받아오는 식으로 구현해야합니다.

피그마 기준으로 plugin을 통해 컬러를 뽑아내어 사용합니다.

절대로 임의의 컬러를 추가해서 사용하면 안됩니다.

해당 파일은 packages/designFiles 라이브러리 내부에 구현하고, 공유해서 사용합니다.

ex)

[data-theme="light"] {
  /* colors */
  --adaptive-blanket-layout-split: var(--adaptive-grey-opacity-grey-opacity300, #001d3a2e);
  --adaptive-blanket-modal-background: var(--adaptive-grey-opacity-grey-opacity500, #03183275);
  --adaptive-blue-blue100: #cae8ff;
  --adaptive-blue-blue1000: #004491;
  --adaptive-blue-blue1100: #003571;
  ....(생략)
  }

  [data-theme="dark"] {
  /* colors */
  --adaptive-blanket-layout-split: var(--adaptive-grey-opacity-grey-opacity300, #001d3a2e);
  --adaptive-blanket-modal-background: #13151799;
  --adaptive-blue-blue100: #00326a;
  --adaptive-blue-blue1000: #98cefd;
  --adaptive-blue-blue1100: #b3defe;
  ...(생략)
  }

darkMode, lightMode 두가지 테마를 variable을 토글되게 해서 사용하고, semantic한 네임을 사용하도록 ux연구원분들께 요청합니다.

어떻게 모드를 바꾸는지는 designFiles/setDarkMode.ts 코드를 참고합니다.

다크모드를 적용할 생각이 없어도 해당 방식으로 구현해야 편할껍니다...

*프로젝트에서 실제로 어떻게 사용할껀지는 논의가 필요함.

가장 쉬운 방법은 import ‘designFiles/index.css’ 이다.

테스트(JEST) 사용법

예시 config 파일

const { dirname, join } = require('path')
const path = require('path')

const jestConfig = require('jest-config/jest.config.js')

const customJestConfig = {
  ...jestConfig,

  testEnvironment: 'jsdom',

  moduleNameMapper: {
    ...jestConfig.moduleNameMapper,

    ...{ '@table/(.*)$': `${__dirname}/../@TDS-TTTable/src/$1` },
  },
}

module.exports = customJestConfig

jest-config 레포지토리에서 jest.config.js와 setupTests.ts를 가져옵니다.

describe('Circle class', function() {
  describe('when area is calculated', function() {
    it('it should sets the radius', function() { ... });
    it('it should sets the diameter', function() { ... });
    it('it should sets the circumference', function() { ... });
  });
});

 

테스트는 describe-it pattern 으로 가독성 있게 작성합니다.

https://woonjangahn.gitbook.io/logs/typescript/testing/jest-describe-it

내부는 when then given pattern에 맞게 작성합니다.

https://brunch.co.kr/@springboot/292

프론트엔드 특성상 스타일과 사용 라이브러리가 자주 바뀔 일이 많습니다.

스타일은 테스트 하지 말고 로직(hook)과 컴포넌트 단위로 테스트를 작성합니다.

코드 한줄 한줄을 검증하는 화이트박스가 아니라

블랙박스 테스트, 유저의 동작을 end-to-end로 검증하는 테스트 위주로 작성합니다.

유용한 명령어들

최상위 package.json 참고

  • pnpm build - Storybook 를 포함한 모든 패키지 빌드
  • pnpm dev - 모든 패키지를 로컬에서 실행하고 Storybook으로 미리보기
  • pnpm lint - 모든 패키지에 대해 lint 진행
  • pnpm changeset - changeset 생성
  • pnpm clean - 모든 node_modulesdist 폴더 정리 (각 패키지의 clean 스크립트 실행)
반응형

+ Recent posts