우리는 Slash를 우리 브랜드의 일부로 리브랜딩했습니다. 시리즈 B 자금 조달그리고 우리는 브랜드와 함께 제품의 시각적 리프레시를 원했습니다. UI 업데이트와 함께, 지난 3년간 극도로 빠르게 확장하면서 쌓인 기술적 부채의 상당 부분을 해결할 수 있는 좋은 기회임을 곧 깨달았습니다.

따라서, 프로젝트 페이스리프트 탄생했습니다 - 확장 중인 비즈니스와 팀을 위한 Slash 프론트엔드의 기반을 마련할 기회였습니다. 우리는 과거의 실수로부터 교훈을 얻어 시간이 흘러도 변함없는 결정을 내리고자 했습니다.

제1부: 기술적 부채 해결

이 섹션에서는 "부채"의 각 항목에 대해 설명하겠습니다. 이후 2부에서는 각 항목에 대한 해결 방안을 상세히 다룰 예정입니다.

감정: CSS in JS 런타임 병목 현상

우리의 기존 디자인 시스템은 위에 구축된 감정 그리고 우리에게 잘 봉사해 주었습니다. CSS-in-JS 라이브러리의 주요 장점은 컴포넌트의 마크업과 스타일이 함께 배치된다는 점이었는데, 이는 반복 작업을 매우 빠르게 만들었습니다. 그러나 고객사가 확장되고 데이터 양이 점점 커지면서 emotion에서 성능 병목 현상이 나타나기 시작했습니다. CSS-in-JS는 런타임 오버헤드를 필요로 하며 메인 이벤트 루프를 느리게 할 수 있습니다. 대규모 리스트 가상화나 빠른 스크롤 시 특히 두드러집니다. 컴포넌트가 빠르게 마운트/언마운트될 때 스타일 동적 계산과 스타일시트 생성이 브라우저에 영향을 주어 60fps 렌더링이 불가능해집니다. 다른 기업들도 비슷한 성능 병목 현상을 발견했습니다 런타임 오버헤드에 의존하는 솔루션에서 벗어나게 되었습니다.

소품 기반 "신 구성 요소"

"의 원래 개념은신은 반대한다이는 객체 지향 프로그래밍(OOP)에서 비롯된 것으로, 객체가 혼자서 너무 많은 책임을 처리하게 되는 현상입니다. 우리는 React 컴포넌트에서도 유사한 현상이 발생함을 발견했고, 이를 '신 컴포넌트(God component)'라 부르기 시작했습니다.

우리 React 오리지널 컴포넌트 대부분은 props 기반 인터페이스로 작성되었습니다. 여기서 개발자는 데이터와 콜백을 제공하고 단일 컴포넌트를 렌더링합니다. 표면적으로는 우아한 인터페이스를 제공합니다: 단순히 데이터와 props를 제공하면 컴포넌트가 모든 마크업과 비즈니스 로직을 처리해 줍니다.

실제 개발 과정에서 코드베이스가 확장됨에 따라, 이러한 컴포넌트들은 원래 고려되거나 의도되지 않았던 용도까지 커버하기 위해 확장됩니다. 엔지니어가 새로운 설정이 필요할 때마다 props를 추가했기 때문에 시간이 지날수록 유지보수와 사용이 점점 더 어려워졌습니다. 결국 이 컴포넌트들은 특정 문제를 해결하기 위한 일회성 props나 컴포넌트 자체에 추가 마크업을 주입/대체하기 위한 "탈출구"(렌더 프로프 패턴과 유사)를 포함하는, 유지보수에 특별한 주의가 필요한 "신급 컴포넌트(God component)"로 변모했습니다. 블로그 후반부에 코드 예시를 보여드리겠습니다.

일관성 없는 양식 관리

기존의 폼 관리 솔루션은 단순히 React 상태 관리에 불과했습니다. 로컬 상태는 사용하기 쉽지만 표준화와 깊은 타입 안전성이 부족하여 코드 품질이 일관되지 못했습니다. 각 엔지니어가 로컬 상태를 구현하는 방식은 조금씩 달랐는데, 어떤 이들은 컨텍스트 API 하나의 큰 공유 양식 상태를 사용하는 반면, 다른 것들은 필드를 개별적인 useState 통화.

이러한 양식을 확장할 때가 되면, 종종 메타데이터 상태와 같은 isFieldTouched 추가될 것이며, 이는 표준화되어야 할 폼 상호작용을 처리하기 위해 명시적으로 정의된 로컬 상태가 많은 복잡한 컴포넌트를 생성하게 될 것입니다.

제2부: 실행

현재 코드베이스의 문제점을 개괄적으로 살펴본 만큼, 이제 각 해결책과 그 배후의 의사결정 과정에 대해 깊이 있게 살펴보겠습니다.

Tailwind v4.0으로 마이그레이션하기

스타일링 솔루션을 평가할 때, 우리는 두 최종 후보를 선정했습니다: Tailwind v4.0과 판다CSS두 방법 모두 런타임 오버헤드가 전혀 없으며 컴포넌트의 마크업과 스타일을 함께 배치할 수 있다는 장점이 있었습니다. PandaCSS의 장점은 타입 안전성과 학습 곡선이 낮다는 점이었는데, 기존 디자인 시스템과 매우 유사했기 때문입니다.

테일윈드의 장점은 성숙한 생태계와 개발자들의 친숙함이었습니다. 많은 엔지니어들이 개인 프로젝트에 테일윈드를 사용해 본 경험이 있었고, 저를 포함해 상당히 선호했습니다. 테일윈드를 내장 지원해주는 훌륭한 도구들도 있습니다. 예를 들어 초신성 그리고 테일윈드 변형체AI 코딩 도구는 기본적으로 테일윈드 클래스명을 작성하는 데도 탁월하여 마이그레이션을 훨씬 수월하게 만들어 줍니다.

런타임 오버헤드가 없는 성능상의 이점을 시각화하기 위해, 동일한 작업(대형 가상화된 테이블 뷰에서 빠른 스크롤링)에 대한 Emotion과 Tailwind의 변경 전후 플레임 차트를 다음과 같이 제시합니다:

감정은 런타임 스타일 주입 오버헤드를 도입합니다
테일윈드는 이러한 병목 현상에 시달리지 않습니다!

피그마에서 코드로의 파이프라인

Tailwind로 마이그레이션할 때, Figma를 진실의 원천으로 삼아 Figma 토큰과 코드베이스를 동기화할 방법을 원했습니다. Figma 내 디자인 시스템은 디자인 에이전시가 관리하고 있습니다. 메타카본디자인 결정을 내리고 의미론적 토큰으로 스타일시트를 생성하는 사람.

저희의 목표는 기본 디자인 시스템의 변경 사항을 최소한의 노력으로 수동 검토 및 구현할 수 있는 파이프라인을 구축하는 것이었습니다. Figma에서 디자인 토큰을 추출하여 코드로 변환하기 위해 Supernova를 선택했는데, 이는 우수한 Tailwind v4.0 코드 추출기를 보유하고 있기 때문입니다.

CSS 출력 작업을 진행하던 중 흥미로운 문제가 발생했습니다: 우리는 의미론적 토큰이 적용되는 속성에 따라 서로 다른 값을 가리키도록 하고 싶었습니다. 다음은 설명을 위한 예시입니다:

/* Raw Supernova output */
@theme {
	--color-bg-neutral-subtle-default: var(--color-light-product-neutral-100);
	--color-border-neutral-subtle-default: var(--color-light-product-neutral-500);
	--color-text-neutral-subtle-default: var(--color-light-product-neutral-1000);
}

우리는 원한다 중립-은은함-기본값 3가지 다른 색조로 해결하기 위해 경제품 중립: 100, 500 그리고 1000 - 그러나 단순히 이것만 사용한다면, 테일윈드 v4.0 --색상 테마 변수 생성될 것이다 bg-bg-중립-은은한-기본값, 경계선 배경 중립적 은은한 기본값, bg-text-중립-은은한-기본값 그리고 의미도 없고 명명상 중복되는 수많은 다른 클래스 이름들.

따라서 우리는 출력을 변환하여 Tailwind v4.0의 보다 구체적인 기능을 활용할 수 있도록 맞춤형 빌드 스크립트를 작성했습니다. 테마 변수 네임스페이스:

/* Build script output */
@theme {
	--background-color-neutral-subtle-default: var(--color-light-product-neutral-100);
  --border-color-neutral-subtle-default: var(--color-light-product-neutral-500);
  --text-color-neutral-subtle-default: var(--color-light-product-neutral-1000);
  
  /* Additional properties that we want to have the same values as other tokens */
  --divide-color-neutral-subtle-default: var(--color-light-product-neutral-500);
}

다음과 같은 classNames로 해결됩니다: bg-중립-은은한-기본값, 경계선 중립적-은은한-기본값 그리고 텍스트 중립적-미묘-기본값 - 이 모든 것이 각기 다른 색조로 귀결된다.

따라서 기본 디자인 시스템이 변경되면, 슈퍼노바 익스포터를 실행하여 출력물을 코드베이스에 반영해 검토하기만 하면 됩니다. 이후 빌드 스크립트가 자동으로 이 원시 출력물을 모든 패키지가 사용하는 빌드된 CSS 파일로 변환합니다.

Good to know

At Slash, we follow a philosophy of maintaining control over our tooling . This allows us to extend functionality quickly and prevent vendor lock in. It also helps us diagnose and fix issues directly. By having our build script, we aren’t locked into Supernova as a vendor, and can extend it - like adding support for divide to be the same color as border, or light vs dark mode themes.

Other examples include our query builder and JSON schema to typescript code generation pipeline.

헤드리스 UI 라이브러리: Base UI와 React Aria

먼저, 스타일링된 솔루션이 아닌 헤드리스 UI 라이브러리를 원했던 이유를 설명하고자 합니다. 이번 리뉴얼의 핵심 목적은 제품 자체를 통해 브랜드 정체성을 명확히 표현하는 데 있습니다. Shadcn이나 MaterialUI 같은 사전 스타일링된 솔루션을 사용하면 컴포넌트에 미리 적용된 스타일이 이 목적을 무색하게 만들 것입니다.

리뉴얼 전에는 Radix UI를 사용했지만, 우리는 그 미래에 대해 확신핵심 유지보수자들이 Base UI로 이동하는 모습을 지켜보았습니다. Base UI는 접근성 준수가 뛰어나며 우리의 요구사항에 매우 잘 부합하는 단순하고 확장 가능한 라이브러리로 자리매김했습니다. 우수한 유지보수자들을 고려할 때, Base UI는 Slash의 요구사항과 함께 성장할 것이라는 확신을 줍니다.

아직 Base UI를 지원하지 않는 특정 컴포넌트(예: 날짜 선택기)의 경우, 헤드리스 UI를 찾던 중 차선책으로 선택한 React Aria를 채택했습니다. React Aria는 실전 검증을 거쳤으며 모든 접근성 표준을 엄격히 준수합니다. 하지만 React Aria의 접근 방식은 상당히 독단적이며 React Aria 생태계에 완전히 의존해야 한다는 점에서, 우리는 정신적 부담이 적은 방식을 선호했기에 결국 Base UI를 기반으로 컴포넌트를 구축했습니다.

복합 구성 요소

앞서 언급했듯이, 프로프 기반 컴포넌트는 초기에는 우아하지만 확장성이 떨어지는 문제가 있습니다. 더 확장 가능한 컴포넌트를 만들기 위한 우리의 해결책은 프로프 기반 접근 방식에 크게 의존하는 것이었습니다. 복합 구성 요소 아키텍처구성 요소의 마크업 제어를 상위 요소에 넘기는 방식입니다. 두 방식의 차이를 설명하는 가장 좋은 방법은 예시를 보여주는 것입니다. 다음은 기존 검색 가능 선택 요소와 새로운 검색 가능 선택 요소를 단순화한 버전입니다:

// Old SearchableSelect implememtation
type Props<T> = {
  selection?: string;
  setSelection?: (val: string) => void;
  
  // Escape hatches added over time
  shouldSetIsChangedFromTextRefAfterTableViewUpdate?: boolean;
  customTextSelectionLogic?: boolean;
  preloadOptions?: boolean;
  hideDropdownOnEmptyOptions?: boolean;
  renderSelectedItem?: (item: T) => ReactNode;
  hideRightIcon?: boolean;
  isClearable?: boolean;
  
  // ... more props
};

export function SearchableSingleSelect<T>(props: Props<T>) {
  const {
    customTextSelectionLogic = false,
    preloadOptions = false,
    hideDropdownOnEmptyOptions = false,
    renderSelectedItem,
    hideRightIcon = false,
    isClearable = true,
    shouldSetIsChangedFromTextRefAfterTableViewUpdate = true,
    // ... more props
  } = props;

  return (
    <FormElementWrapper
      renderInput={(props) => (
        <MenuBase
          setSelection={(value) => {
            setSelection(value);
            
            // Escape hatch for custom behavior
            if (customTextSelectionLogic) {
              return;
            }
            
            // Different logic based on renderSelectedItem prop
            if (value && !renderSelectedItem) {
              setTextInput(findLabel(value));
            } else if (value && renderSelectedItem) {
              setTextInput('');
            }
          }}
        >
          {/* Conditional rendering based on prop */}
          {renderSelectedItem && selectedItem ? (
            <CustomRenderedItem item={selectedItem} />
          ) : null}
          
          <TextInput value={textInput} onChange={...} />
        </MenuBase>
      )}
      {/* Nested ternaries based on multiple props */}
      {...(hideRightIcon ? {} : {
        rightIcon: () => {
          return !isClearable ? (
            <ChevronIcon />
          ) : textInput?.length || (renderSelectedItem && selectedItem) ? (
            <ClearButton onClick={clearSelection} />
          ) : (
            <ChevronIcon onClick={toggleDropdown} />
          );
        }
      })}
    />
  );
}
// Old SearchableSelect usage
<SearchableSingleSelect
  selection={selection}
  setSelection={setSelection}
  tableManager={tableManager}
  isClearable={true}
  customTextSelectionLogic={false}
  hideDropdownOnEmptyOptions={false}
  renderSelectedItem={(item) => <CustomView item={item} />}
  shouldSetIsChangedFromTextRefAfterTableViewUpdate={true}
  // ... more props
/>

그리고 여기 복합 컴포넌트 아키텍처를 사용하는 우리의 새로운 검색 가능한 선택 컴포넌트가 있습니다:

// New SearchableSelect implementation

// Context manages shared state
const SearchableSelectContext = createContext<{
  selectState: SingleSelectState;
  tableManager: TableManagerState;
}>(null);

// Root sets up context
const Root = ({ children, tableManager, type }) => {
  const selectState = useSingleSelectState();
  
  return (
    <SearchableSelectContext.Provider value={{ selectState, tableManager }}>
      <MenuV2.Root>{children}</MenuV2.Root>
    </SearchableSelectContext.Provider>
  );
};

// EachItem exposes items with callbacks
const EachItem = ({ children }) => {
  const { selectState, tableManager } = useSearchableSelectContext();
  
  const items = tableManager.data.map((item, index) => ({
    item,
    callbacks: {
      onClick: (item) => selectState.toggleItem(item.key, index),
      getIsSelected: (item) => selectState.isItemSelected(item.key),
    },
  }));

  return <>{items.map(children)}</>;
};

// SearchBar handles its own state
const SearchBar = () => {
  const { tableManager } = useSearchableSelectContext();
  const [textInput, setTextInput] = useState('');

  useEffect(() => {
    tableManager.updateSearch(textInput);
  }, [textInput]);

  return <InputV2.Field value={textInput} onChange={e => setTextInput(e.target.value)} />;
};

// ... Other components: Content, DefaultTrigger, Item
// New SearchableSelect usage
<SearchableSelect.Root type="single" tableManager={tableManager}>
  <SearchableSelect.DefaultTrigger>
    Select an option...
  </SearchableSelect.DefaultTrigger>
  
  <SearchableSelect.Content>
    <SearchableSelect.SearchBar />
    
    <SearchableSelect.EachItem>
      {({ item, callbacks }) => (
        <MenuV2.Item onClick={() => callbacks.onClick(item)}>
          <CustomView item={item} />
        </MenuV2.Item>
      )}
    </SearchableSelect.EachItem>
  </SearchableSelect.Content>
</SearchableSelect.Root>

첫눈에 복합 컴포넌트는 더 번거로워 보입니다. 개발자는 마크업 패턴을 알고 이를 따라야 합니다. 장점은 마크업을 확장하는 데 있어 유연성이 매우 높다는 점인데, 이는 완전한 통제권이 부모 요소에 있기 때문입니다. 따라서 마크업 해킹은 핵심 컴포넌트에 절대 반영되지 않습니다. 엔지니어가 select에 푸터를 추가해야 한다면? 마크업에 추가하기만 하면 됩니다. 별도의 renderSelectFooter 핵심 컴포넌트에 프로프를 추가하세요! 푸터가 공통 요구사항이 된다면, 생성하세요. <SearchableSelect.Footer /> 구성 요소!

복합 컴포넌트의 또 다른 흔한 함정은 엔지니어들이 핵심 컴포넌트에 포함되지 않은 공통 패턴의 구현을 중복할 수 있다는 점입니다. 따라서 우리 팀은 공통 패턴을 더 잘 인지하고 필요할 때 핵심 컴포넌트에 추가해야 합니다. 단일 라인 컴포넌트의 기반 API로 복합 컴포넌트를 사용할 수도 있지만, 이러한 컴포넌트가 '신 컴포넌트(god component)'로 변질되지 않도록 매우 신중하게 접근합니다. 스토리북을 통한 지식 공유 방안에 대해서는 후반부 섹션에서 더 자세히 다루겠습니다.

테일윈드 변종

실제로 컴포넌트를 스타일링할 때 우리는 크게 의존했습니다. 테일윈드 변형체우리 중 일부는 사용해 왔습니다. 클래스 변동 권한 (CVA)를 사용했으며 스타일링 솔루션에도 그런 변형 논리를 원했습니다. 테일윈드 변형은 우리가 사용하게 된 몇 가지 기능들슬롯 API와 내장된 충돌 해결 기능이 주요 요소입니다.

궁극적으로 우리는 스타일링 변형 코드를 깔끔하게 유지하면서도 복합 컴포넌트 접근법을 충분히 커버할 수 있을 만큼 확장 가능한 솔루션이 필요했습니다. 테일윈드 변형을 사용함으로써, 프로프에 따라 emotion 스타일링된 div에 명시적으로 다른 토큰을 적용하던 기존의 조건부 스타일링 코드를 크게 표준화할 수 있었습니다.

아래는 버튼 구현 및 사용 예시를 보여주는 그림입니다:

import { tv } from 'tailwind-variants';

const buttonVariants = tv({
  // Slots API - different parts of the button component
  slots: {
    base: [
      'inline-flex',
      'items-center',
      'justify-center',
      'gap-1',
      'group',
    ],
    hoverOverlay: [
      'opacity-0',
      'absolute',
      'inset-0',
      'group-hover:group-enabled:opacity-100',
      'pointer-events-none',
    ],
  },

  // Variants - different visual options.
  variants: {
    color: {
      cta: {
        base: [
          'border',
          'border-neutral-boldest-default',
          'bg-button-cta-default',
          'shadow-cta-neutral-default',
          
          'hover:border-neutral-bolder-hover',
          'hover:bg-button-cta-hover',
          'hover:text-neutral-bolder-hover',
        ],
      },
      ghost: {
        base: [
          'bg-transparent',
          'text-neutral-normal-default',
          
          'hover:bg-neutral-subtle-hover',
          'hover:text-neutral-normal-hover',
        ],
      },
    },
    size: {
      sm: {
        base: [
          'h-6',
          'px-1.5',
          'typography-button-small',
          'rounded-border-radius-sm',
        ],
      },
      md: {
        base: [
          'h-8',
          'px-2',
          'typography-button-medium',
          'rounded-border-radius-md',
        ],
      },
    },
  },

  // Compound Variants - styles when multiple variants are combined
  compoundVariants: [
    {
      // Ghost buttons with small size get reduced padding
      color: 'ghost',
      size: 'sm',
      class: {
        base: 'px-1',
      },
    },
    {
      // CTA buttons at large size get enhanced shadows
      color: 'cta',
      size: 'lg',
      class: {
        base: 'shadow-cta-neutral-prominent',
      },
    },
  ],

  // Default Variants - applied when no variant is specified
  defaultVariants: {
    color: 'neutral',
    size: 'md',
    disabled: false,
  },
});

// Extract variant props type from buttonVariants
type ButtonVariantProps = VariantProps<typeof buttonVariants>;

// Button component props
interface ButtonProps extends ButtonVariantProps {
  children?: React.ReactNode;
  onClick?: () => void;
  as?: ElementType;
  className?: string;
}

// Button component implementation
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ 
    children, 
    color, 
    size, 
    disabled, 
    onClick, 
    as: Component = 'button',
    className,
    ...rest 
  }, ref) => {
    // Generate slot classes based on variant props
    const slots = buttonVariants({ color, size, disabled });
    
    return (
      <Component
        ref={ref}
        className={slots.base({ className })}
        onClick={onClick}
        disabled={disabled}
        {...rest}
      >
        {/* Hover overlay slot */}
        <span className={slots.hoverOverlay()} />
        
        {/* Button content */}
        {children}
      </Component>
    );
  }
);
// End usage example
<Button color="cta" size="lg">
  Large CTA Button
</Button>

보시다시피, 버튼 실제로 사용할 필요가 없습니다 버튼 변형 - 프로퍼티를 전달합니다 버튼 구성 옵션으로 사용되며, 이후 호출에 활용됩니다. 버튼 변형 내부적으로. 또한 우리는 확장하다 입력 필드와 선택 필드 같은 유사한 구성 요소 간에 스타일을 공유하는 기능.

의미론적 클래스명 덕분에 스타일을 스캔하는 것이 얼마나 쉬운지 눈치채실 수 있을 것입니다.

테스트 및 문서화: Storybook + Chromatic

프로젝트 전반부에는 혼자 작업하며 테스트와 문서화를 충분히 중시하지 않았습니다. 그러나 기본 컴포넌트 라이브러리를 완성하고 더 많은 엔지니어들이 각자의 제품 개선을 위해 합류하면서, "이 컴포넌트가 있나요?" 또는 "이 컴포넌트를 올바르게 사용하는 방법은?" 같은 아주 간단한 질문에도 답할 수 있는 중앙 집중식 장소가 필요하다는 점이 더욱 분명해졌습니다.

새로 합류한 엔지니어 중 한 명인 샘이 혼자서 스토리북과 크로매틱 테스트 스위트를 구축했습니다. 이 도구들이 없었다면, 특히 복합 컴포넌트의 학습 곡선을 고려할 때 컴포넌트 오용으로 인한 기술적 부채 함정에 빠졌을 것입니다.

스토리북은 모든 컴포넌트의 사용 예시를 중앙 집중화하여 코드 예시를 통해 해당 질문들에 대한 완전한 답변을 가능하게 합니다. 또한 엔지니어가 어떤 컴포넌트를 사용해야 하는지, 그리고 필요한 구성 옵션을 지원하는지 여부가 즉시 명확해집니다.

크로매틱은 시각적 회귀를 포착하여, 기본 구성 요소에 대한 변경 사항이 시각적 요소를 변경할 경우 즉시 표시됩니다. 이는 수동 검토가 이루어질 때까지 병합을 차단합니다.

폼 관리: Zod 대 ArkType

슬래시(Slash)의 코드베이스는 전적으로 타입스크립트(TypeScript)로 작성되었으며, 빌드 시점에 코드베이스 전반에 걸쳐 공유 타입을 생성함으로써 이 점을 적극 활용합니다. 우리는 개발자가 실수로 문제를 일으키기 어렵게 하면서도 우리의 광범위한 타입 안전성을 활용할 수 있는 폼 관리 솔루션을 원했습니다.

우리는 결국 ArkType을 선택했습니다. Typescript와 매우 강력하게 결합되어 있어 어떤 수준의 드리프트도 방지할 수 있었기 때문입니다. 생성된 타입을 가져와서 ArkType 타입과 일대일로 결합할 수 있었습니다. 만족시킨다따라서 기본 유형이 변경되면 코드베이스에서 유형 오류가 발생하게 됩니다.

다음은 설명을 위한 예시입니다:

# models/entity/Address.yaml
title: Address
type: object
properties:
  addressLine:
    type: string
  addressLine2:
    type: string
  addressCity:
    type: string
  addressState:
    type: string
  addressCountry:
    type: string
    minLength: 2
    maxLength: 2
    description: ISO 3166-1 Alpha-2 Country Code (e.g., US, FR, UK, DE, ...).
required:
  - addressLine
  - addressCity
  - addressState
// shemas/entity/Address.ts

// Schema implementation of an address model
import type { entity } from '@slashfi/models';
import { type Type, type } from 'arktype';

// Helper to configure default error messages
export const nonEmptyString = (fieldName: string) =>
  type('string>0').configure({
    message: `${fieldName} is required.`,
  });

export const Address = type({
  addressLine: nonEmptyString('Address Line'),
  addressLine2: 'string | undefined',
  addressCity: nonEmptyString('Address City'),
  addressState: nonEmptyString('Address State'),
  addressCountry: type('string==2 | undefined').configure({
    message: 'Country code must be a 2-letter ISO 3166-1 Alpha-2 code',
  }),
}) satisfies Type<entity.Address>;
// entity.Address is built automatically from our YAML spec

export const defaultAddress: typeof Address.infer = { ... }
// Illustrative form component that submits an address - using Tanstack form
const AddressForm = () => {
  const form = useAppForm({
    defaultValues: defaultAddress,
    // Validate the form input on mount and on change
    validators: {
      onMount: Address,
      onChange: Address,
    },
    onSubmit: ({ value }: { value: Address }) => {
      // Submit the value
      console.log(value);
    },
  });

  return (
    <form.AppForm>
      <form.Field name="addressLine">
        {(field) => (
          <>
            <Input
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            {/* Use built in metadata like isTouched */}
            {field.state.meta.isTouched ? (
              <p>{getMessage(field.state.meta.errors)}</p>
            ) : null}
          </>
        )}
      </form.Field>

      {/* ... more inputs for each field in address */}

      {/* Subscribe for reactive form update */}
      <form.Subscribe
        selector={({ canSubmit, isSubmitting }) => ({
          canSubmit,
          isSubmitting,
        })}
      >
        {({ canSubmit, isSubmitting }) => (
          <Button loading={isSubmitting} disabled={canSubmit}>
            Create vendor
          </Button>
        )}
      </form.Subscribe>
    </form.AppForm>
  );
};

Zod와 React Hook Form도 훌륭한 선택이었지만, 우리는 TypeScript의 장점을 직접 활용할 수 있는 솔루션을 원했고, ArkType이 이 분야에서 확실한 선두주자였습니다. React Hook Form보다 Tanstack Form을 선택한 이유는 TypeScript 준수 측면에서 더 엄격했기 때문입니다.

Tanstack 양식에서 눈에 띄는 점은 엄격하고 독단적인 양식 구조를 강요한다는 것입니다. 이는 학습 곡선을 유발하지만, 코드베이스 전반에 걸쳐 양식 작성 방식을 표준화하는 데는 그만한 가치가 있다고 판단했습니다.

구현: 기능 플래그 및 코드 분기

페이스리프트를 위한 실제 코드 작성에 있어서는, 기존 디자인 시스템의 새 기능들을 페이스리프트와 병행하여 출시 중이었기에 하나의 거대한 PR을 피하고자 했습니다.

우리는 사용했습니다 기능 플래그그리고 단순히 기능 플래그를 활성화했습니다. 우리 계정만이를 통해 우리는 도그푸드 우리만의 변경 사항입니다. 또한 페이스리프트 기능을 켜고 끌 수 있는 간단한 툴바를 구현하여, 페이스리프트를 빠르게 전환함으로써 회귀 현상을 매우 쉽게 발견할 수 있게 했습니다.

회귀 현상을 포착하기 위해 페이스리프트 기능을 빠르게 켜고 끌 때 사용할 토글

실제 코드 분기에 관해서는, 비즈니스 로직 코드를 중복하지 않도록 하는 것을 목표로 삼았으며, 다음과 같은 방식으로 수행하였습니다:

const CardsPage = () => {
  const { isFacelift } = useFaceliftToolbar();
  
	// Existing business logic
	const cards = useCards()
  
  if (isFacelift) {
	  // New, facelifted components
	  return (
		  <CardsTableV2 cards={cards} />
	  )
  }

	
	// Existing UI
	return (
		<CardsTable cards={cards} />
	)
}

위의 예는 이상적인 경우를 보여줍니다. - 기존 코드베이스에 대량의 새 코드를 도입하는 현실은 훨씬 더 복잡합니다. 예를 들어: 만약 당신이 isFacelift 상위 수준(예: 최상위 페이지 수준)에서 스위치를 구현하려면 해당 컴포넌트 아래 모든 분기에 대한 비즈니스 로직을 반드시 복제해야 합니다. 그러나 개별 컴포넌트 수준에서 검사를 수행하면 부모 컴포넌트의 기존 마크업 로직에 종속되게 되며, 이는 어차피 리뉴얼이 필요한 경우가 많습니다.

생산 환경 문제로부터 우리를 보호해준 것은 이 모든 기능을 기능 플래그 뒤에 숨겨서 실제 고객에게는 isFacelift 항상 거짓 준비가 될 때까지. 내부적으로는 계속 켜두어 플랫폼에 반영된 회귀 현상을 즉시 발견할 수 있도록 했습니다.

페이스리프트 기능이 완성됨에 따라, 우리는 긴밀한 관계를 유지해 온 고객사와 협력하여 해당 기능을 활성화했습니다. 고객사는 기능을 끄는 옵션을 선택할 수 있었으며, 기존 워크플로를 차단하지 않은 상태에서 피드백을 요청하고 회귀 현상을 파악할 수 있었습니다.

실행에 관한 참고 사항

위 섹션은 매우 체계적으로 정리되어 있지만, 이는 결코 구현 순서를 반영한 것이 아닙니다. 실제로는 여러 시도를 통해 빠르게 반복하며 결정이 내려졌습니다. 예를 들어,

  • CSS 빌드 스크립트 초안에서 @utilty 우리가 원하는 각 커스텀 클래스명을 생성하는 지시문을 작성한 후, 샘은 이를 기반으로 이 기능을 찾아내어 구축했습니다. GitHub 토론 테일윈드 테마 변수 네임스페이스를 개괄한 문서.
  • 현재의 접근 방식인 BaseUI 컴포넌트를 사용하기 전에, 검색 가능한 단일 선택 컴포넌트를 구현하기 위해 서로 다른 BaseUI 컴포넌트를 활용한 두 번의 반복 작업이 있었습니다. 메뉴 API.

결론적으로 말하자면, 우리는 위에서 설명한 것처럼 우아하게 이러한 결정을 내린 것이 아닙니다. 공통점은 처음부터 완벽해야 한다는 생각에 사로잡혀 마비되지 않으면서도 끊임없이 반복한다는 점입니다.

제3부: 배움

전면적인 프론트엔드 범위를 아우르는 프로젝트를 통해 개인적으로 많은 것을 배웠습니다. 특히 각자의 제품 개선 작업에 더 많은 엔지니어들이 참여하면서 우리 팀 역시 많은 것을 배웠습니다.

"바닥을 높이다"

기술 부채가 쌓인 주요 원인은 공유 지식의 부족 때문이었다. 신생 스타트업에서는 모두가 긴밀히 협력하기 때문에 이는 문제가 되지 않는다. 그러나 팀이 성장하고 제품의 범위가 확장되면, 이전처럼 신속하게 가정과 사용 패턴을 공유하는 것이 불가능해진다.

이번 리팩토링의 철학은 "바닥을 높이는 것"이었습니다. 우리는 나쁜 프론트엔드 코드를 작성하기 어렵게 만들고자 합니다. 이는 좋은 프론트엔드 코드가 어떤 모습인지 문서화하고, 오류가 프로덕션에 반영되기 전에 잡아내는 것을 의미합니다. 스토리북(Storybook)과 크로매틱(Chromatic)은 이를 위해 필수적이며, 설정하는 데 투자할 가치가 충분합니다. 바닥을 높이기 위해 쏟는 모든 노력은 코드베이스 전반의 코드 품질에 복리 효과를 가져올 것입니다. 반대의 경우도 마찬가지입니다.

장기 프로젝트의 현실

전체 리뉴얼 작업은 시작부터 끝까지 약 4개월간 지속되었으며, 마지막 3주간은 전담 '해킹 위크'를 진행했습니다. 이 기간 동안 6명의 다른 엔지니어가 리뉴얼 작업에 참여해 주었습니다(그들에게 큰 감사를 드립니다 <3). 또한 뛰어난 PM(앤디)의 도움을 받아 전체 프로젝트 범위를 설정하고, 메타카본과 소통하며, 새로운 UX 패턴에 대한 피드백을 제공할 수 있었습니다.

페이스리프트 작업은 단계별로 출시할 수 있는 프로젝트가 아니었기에, 오랫동안 고객에게 제품을 출시하는 기쁨을 누리지 못했습니다. 내부적으로 컨텍스트 전환을 하는 것이 좋은 접근법임을 깨달았죠. 때로는 핵심 컴포넌트 라이브러리를 작업하다가, 그 핵심 컴포넌트를 전체 페이지에 적용하는 식으로 두 작업을 번갈아 가며 진행했습니다. 이 접근법은 핵심 컴포넌트 사용 시 발생하는 경계 사례를 빠르게 포착하는 데도 도움이 되며, 더 나은 추상화 경계를 설정하도록 강제합니다.

고객의 관심도 없이 수많은 코드를 리팩토링하는 일은 사기를 떨어뜨릴 수 있습니다. 저를 계속 동기부여하게 한 것은 몇 가지 요인이었습니다:

  1. 슬래시의 동료들 덕분에 출근하는 게 여전히 매일 즐거웠어요!
  2. 내부적으로라도 진행 상황을 자랑할 수 있었어요. 누군가에게 자신의 진척 상황을 보여주는 것에서 오는 일종의 도파민은 동기부여를 유지하는 훌륭한 방법입니다. 특히 자신이 하는 일을 사랑한다면 더욱 그렇죠.
  3. 이 프로젝트는 앞으로 오랫동안 사용될 프론트엔드 코드베이스의 기반을 마련할 예정이었기에, 제대로 구축하는 것이 중요했습니다.
  4. 슬래시는 성장하고 승리하고 있으며, 이는 전체적으로 동기 부여.

제4부: 다음은 무엇인가

페이스리프트 출시가 순조롭게 진행됨에 따라, 우리의 최우선 과제는 특수 사례와 회귀 현상을 포착하는 것입니다. 다행히도, 우리는 이 전환기를 고객과 함께 헤쳐 나가며 문제를 번개처럼 빠르게 발견해내는 세계적 수준의 지원 팀을 보유하고 있습니다.

저희 작업을 확인해 보시려면 저희 웹사이트에서 보실 수 있습니다. 데모 사이트, 여기서 Slash가 제공하는 기능들도 확인해 보실 수 있습니다!

여기까지 오셨다면, 빠르게 성장하는 스타트업에서 실제 고객의 문제를 해결하고자 하는 재능 있는 엔지니어라면 지원하세요. 여기. 여러분의 의견을 기다립니다.

Read more from us