React에서 Svelte로 앱을 다시 작성하면 기대할 수 있는 것들

Published: 2021-04-08

최근에 중간 규모의 React 앱(~250개의 React 컴포넌트)을 Svelte로 다시 작성했다. 그 결과 더 효율적이고, 유지보수가 쉬우며, 코드 가독성이 훨씬 좋아진 앱을 얻었다. 하지만 그 과정에서 몇 가지 문제와, Svelte 코드로 곧바로 옮기기 어려운 상황들이 있었다. 이 글에서는 그런 상황들 일부를 다룬다.

이 글은 React 개발자를 위한 Svelte 튜토리얼이 아니다. 아직 Svelte를 모른다면, 가장 좋은 출발점은 공식 Svelte 튜토리얼이다. 이 글에서는 Svelte의 원리를 직접 설명하기보다는, Svelte 튜토리얼로 연결되는 링크를 많이 포함하고 있다.

CSS

Svelte는 CSS를 아주 깔끔하고 직관적인 방식으로 직접 지원한다. React 앱에서는 styled components를 사용했다.
React용 CSS 라이브러리는 많지만, 내가 아는 한 모두 CSS-in-JS 패러다임을 사용한다. 반면 Svelte에서는 꽤 깔끔한 순수 CSS를 사용한다.

다음 코드 조각에서는 툴바 버튼의 단순화된 구현을 React(styled components 사용)와 Svelte로 각각 보여준다.

툴바 버튼에는 hover 기능을 비활성화하는 하나의 속성(disabled)이 있다. 또한 테마에서 가져온 몇 가지 변수를 사용한다.

// React component ToolbarButton({disabled, toolBarHeight})

const ToolbarButton = styled.div`
  padding: 5px 15px;
  height: ${props.toolBarHeight}px;

  ${(props) =>
    !props.disabled &&
    `
  &:hover {
    background-color: gray;
  }
  `}
`;
<!-- Svelte component -->

<script>
  export let disabled;
</script>

<div class="button" class:disabled />

<style>
.button {
  padding: 5px 15px;
  height: var(--theme-toolbar-height); /* prop을 직접 쓸 수 없고, CSS 변수를 사용해야 한다 */
}
.button.disabled {
  background-color: gray;
}
</style>

Svelte는 CSS 안에서 어떤 변수도 지원하지 않는다. 따라서 Svelte 컴포넌트에 프로퍼티를 전달하고, 그 값을 CSS에서 직접 사용하는 것은 불가능하다. 이를 처리하는 유일한 방법은 CSS 클래스를 ON/OFF로 전환하는 것이다. disabled가 true로 설정되면 button.disabled 셀렉터가 사용된다.

CSS에서 어떤 값(예: 치수)을 사용하고 싶다면, CSS 변수를 사용할 수 있다.

React hooks, 특히 useEffect

React 훅은 매우 강력한 기능이다. Svelte에는 이와 비슷한 것이 없다. 하지만 React 훅 대부분은 Svelte에서 그리 그립지 않다.

  • useState - 상태는 let 명령으로 정의한다
  • useMemo - 내부 상태 재계산에는 반응형 구문을 대신 사용할 수 있다
  • useCallback - 함수 표현식은 한 번만 평가되므로 의미가 없다
  • useContext - Svelte에는 매우 단순하고 직관적인 컨텍스트 API(getContext(), setContext())가 있다
  • useReducer - store를 사용해 Svelte 버전을 직접 만들어야 한다(이 작업은 매우 간단할 수 있다)
  • useRef - 대신 bind:this를 사용할 수 있다(또는 인스턴스 변수를 위한 let 변수)
  • useEffect - 이건 좀 더 복잡하다…

useEffect - 단순 사용

나는 React에서 useEffect를 자주 사용한다. 하지만 Svelte에서 같은 로직을 구현하려면 어떻게 할지 생각을 좀 해야 한다.

가장 단순한 사용법은 마운트와 언마운트 시점에 코드를 실행하는 것이다.

// React
React.useEffect(() => {
  console.log('MOUNT');
  return () => console.log('UNMOUNT');
}, []);

이 변형은 svelte의 onMount와 onDestroy 메서드로 대응된다.

// Svelte

onMount(() => {
  console.log('MOUNT');
  return () => console.log('UNMOUNT 1');
});
onDestroy(() => {
  console.log('UNMOUNT 2');
});

보는 것처럼, 언마운트 시점에 코드를 실행하는 방법은 두 가지가 있다. onDestroy 함수를 사용하는 방법과, onMount 함수의 반환값을 사용하는 방법이다.

useEffect - 반응형 구문

어떤 표현식이 바뀔 때 코드를 실행하고 싶다면, Svelte의 반응형 구문을 사용할 수 있다.

// React
React.useEffect(() => {
  console.log('HEIGHT changed, new value:', height);
}, [height]);
// Svelte
$: console.log('HEIGHT changed, new value:', height); 

Svelte에서 반응형 구문은, 그 구문에 사용된 의존성 중 하나라도 할당되면 실행된다. 따라서 의존성이 바뀔 때만 무언가를 해야 한다면, 값을 직접 비교해서 체크해야 한다.

React.useEffect의 두 번째 인자처럼 의존성을 명시적으로 나열하고 싶다면, 다음 패턴을 사용할 수 있다.

// Svelte

$: {
  height;
  width;
  handleChanged(); 
}

이 코드는 height 또는 width 변수에 할당이 일어난 뒤에 호출된다. handleChanged 함수 안에서는 다른 변수도 사용할 수 있지만, 그 변수들은 이 반응형 구문을 트리거하지 않는다.

useEffect - 정리(cleanup)가 있는 반응형 구문

React의 useEffect는 프로퍼티 값에 의존하는 리소스를 할당하는 데도 사용할 수 있다. 다음 예제에서 컴포넌트는 SQL 데이터베이스의 테이블 목록을 보여준다. 데이터베이스 구조 변경을 듣기 위해 소켓을 구독한다. connectionId 프로퍼티가 바뀌면, 이전 소켓에서 연결을 끊고 새 소켓에 연결해야 한다.

// React
function SqlTableList({ connectionId }) {
  React.useEffect(() => {
    socket.on(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
    return () => {
      socket.off(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
    };
  }, [connectionId]);
}

Svelte는 이 시나리오를 React만큼 직관적으로 지원하지 않는다. 하지만 간단한 트릭으로 이 동작을 구현할 수 있다.

// Svelte

const useEffect = subscribe => ({ subscribe });

$: effect = useEffect(() => {
  socket.on(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
  return () => {
    socket.off(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
  };
});

$: $effect;

이 트릭이 어떻게 동작하는지 보자. 먼저 store, 특히 커스텀 store에 대해 알아야 한다(튜토리얼).
useEffects 함수는 subscribe 메서드를 가진 커스텀 store를 만든다. store의 subscribe 메서드는 unsubscribe 메서드를 반환해야 하며, 이 메서드는 store 구독이 더 이상 필요 없을 때 호출된다.

마지막 줄($: $effect)은 자동 store 구독을 처리한다(튜토리얼). 따라서 connectionId 값이 바뀌거나(Svelte에서는 단순 재할당만 되어도) React와 비슷한 방식으로 socket.on을 호출하고, 이후 socket.off를 올바르게 호출한다.

Svelte에서 React props가 그리운 이유

물론 Svelte에도 props가 있다. 동작 방식은 React와 비슷하다. 하지만 React에서 컴포넌트의 유일한 외부 인터페이스는 props다. Svelte에서는 컴포넌트의 동작을 제어하는 여러 메커니즘이 있다.

  • props - React와 동일하게 동작
  • events - 특별한 문법을 사용하며, 이벤트는 모든 props를 담고 있는 $$props 객체의 일부가 아니다
  • actions (문법 use:action) - HTML 요소에 바인딩된 로직을 재사용하는 메커니즘
  • slots - 목적은 React의 children 프로퍼티와 같지만, 몇 가지 확장이 있다

이 메커니즘들은 모두 매우 유용하지만, React의 props처럼 통합된 접근 방식을 제공하지는 않는다. 아래에서는 내가 겪었던 몇 가지 문제를 다룬다.

이벤트 전달(Forwarding events)

좀 더 복잡한 컴포넌트 계층을 만들다 보면, 어떤 컴포넌트는 부모 컴포넌트에서 받은 데이터를 그대로 전달만 하기도 한다.

// React - 모든 이벤트를 전달, 이벤트는 props의 일부

function Outer(props) {
  return <Inner {...props} />;
}
 <!-- Svelte - 명시적으로 지정한 click과 keydown 이벤트만 전달 -->

 <Inner {...$$props} on:click on:keydown />;
}

Svelte에서는 모든 이벤트를 한 번에 전달할 수 없고, 나열한 이벤트만 전달할 수 있다. 하지만 모든 이벤트를 전달해야 한다면, on:click 대신 onClick 같은 콜백 함수를 사용하면 된다. 그러면 onClick은 $$props의 일부가 된다.

TabControl 구현

React에서는 TabControl 컴포넌트를 다음과 같이 구현하기가 꽤 쉽다.

// React

<TabControl>
  <TabPage label='Page 1'>
    Page 1 content
  </TabPage>
  <TabPage label='Page 2'>
    Page 2 content
  </TabPage>
</TabControl>

구현에서는 children 배열을 순회하면서, 필요한 정보(페이지 제목과 내용)를 쉽게 추출할 수 있다. 이 접근 방식은 Svelte에서는 동작하지 않는다. Svelte에는 children 같은 것이 없다. 대신 svelte fragment를 사용할 수 있고, 사용법은 다음과 같다.

 <!-- Svelte -->

<TabControl tabs={[
  { label: 'Page 1', slot: 1},
  { label: 'Page 2', slot: 2},
  ]}>
  <svelte:fragment slot='1'>
    Page 1 content
  </svelte:fragment>
  <svelte:fragment slot='2'>
    Page 2 content
  </svelte:fragment>
</TabControl>

탭은 배열에서 정의하고, 탭 레이아웃(children)은 fragment에서 정의한 뒤, TabControl 컴포넌트에 slot으로 전달한다. React만큼 직관적이지는 않지만, 동작은 한다. 다만 TabControl 구현에서 볼 수 있듯이, 꽤 큰 함정이 하나 있다.

// TabControl.svelte

<script>
  export let tabs = []
</script>

<div>
 {#each _.compact(tabs) as tab, index}
   <div class="container" class:isInline class:tabVisible={index == value}>
     {#if tab.slot == 0}<slot name="0" />
     {:else if tab.slot == 1}<slot name="1" />
     {:else if tab.slot == 2}<slot name="2" />
     {:else if tab.slot == 3}<slot name="3" />
     {:else if tab.slot == 4}<slot name="4" />
     {:else if tab.slot == 5}<slot name="5" />
     {:else if tab.slot == 6}<slot name="6" />
     {:else if tab.slot == 7}<slot name="7" />
     {/if}
   </div>
 {/each}
</div>

slot 이름은 정적인 문자열이어야 하므로, 이렇게 다소 우회적인 코드를 작성해야 한다.

동적 컴포넌트 사용

다른 접근 방식으로는 탭 페이지마다 컴포넌트를 하나씩 정의하는 방법이 있다. 하지만 이 경우 Svelte 컴포넌트는 반드시 각자 파일에 정의되어야 하므로, 아주 작은 파일이 많이 생기게 된다.

 <!-- Svelte -->

<TabControl tabs={[
  { label: 'Page 1', component: Tab1},
  { label: 'Page 2', component: Tab2},
  ]}/>

// Tab1.svelte
Page 1 content
// Tab2.svelte
Page 2 content

구현에서는 svelte:component를 사용해 적절한 탭을 인스턴스화한다.

컨텍스트 사용

이 방법이 가장 덜 직관적이다. 하지만 실제로는 React와 거의 같은 문법을 구현할 수 있다.

 <!-- Svelte -->

<TabControl>
  <TabPage label='Page 1'>
    Page 1 content
  </TabPage>
  <TabPage label='Page 2'>
    Page 2 content
  </TabPage>
</TabControl>

TabControl 컴포넌트 안에는 예를 들어 자식 탭들의 배열 같은 “수집 지점”이 정의되어야 한다.

<!-- TabControl.svelte -->
<script>
  const tabs = [];
  setContext('tabs', tabs);
</script>

그리고 탭 페이지에서는 부모 배열에 탭을 등록하기만 하면 된다.

<!-- TabPage.svelte -->
<script>
  export let label;
  const tabs = getContext('tabs');
  tabs.push({ label });
</script>

이 방법의 유일한 함정은, 일부 탭 페이지가 조건부로 렌더링될 경우 정의된 순서를 보존하지 못한다는 점이다.


<TabControl>
  {#if condition_will_be_true_later}
    <TabPage label='Page 1'>
      Page 1 content
    </TabPage>
  {/if}
  <TabPage label='Page 2'>
    Page 2 content
  </TabPage>
</TabControl>

이 코드는 실제로는 Page 2가 먼저, 그다음에 Page 1이 렌더링된다. 아마도 원하는 결과가 아닐 것이다.

bind:clientHeight와 bind:clientWidth 사용 시 주의

치수 바인딩(튜토리얼)은 훌륭한 기능이다. 하지만 사용할 때 주의해야 한다. 이 기능은 숨겨진 iframe으로 구현되어 있다(이 REPL에서 확인할 수 있다). 내 경험상, 더 복잡한 상황에서는 Firefox에서 제대로 동작하지 않는 경우가 있었다. 때로는 ResizeObserver를 사용하는 편이 더 안전하다(이 action 구현을 참고).

에러 바운더리

React에는 에러 바운더리라는 훌륭한 개념이 있다(컴포넌트를 위한 “try-catch”). 함수형 컴포넌트에서는 지원되지 않지만(실제로 내 React 앱에서 ErrorBoundary는 유일한 클래스 컴포넌트였다), 함수형 React 앱에서도 이 클래스 컴포넌트를 사용하는 데 아무 문제가 없다.

// React
<ErrorBoundary>
  {(null).read()}
</ErrorBoundary>

React 앱에서 이 코드가 에러 바운더리 없이 존재하면, 전체 애플리케이션이 죽는다. 에러 바운더리가 있으면, 바운더리 내부만 실패하고, 앱의 다른 부분은 평소처럼 렌더링된다.

안타깝게도 Svelte에는 이런 기능이 없다. svelte-error-boundary라는 NPM 패키지가 있긴 하지만, 실제로는 문제의 일부분만 해결할 뿐이고, Svelte 앱의 대부분의 에러는 여전히 앱 크래시를 유발한다.

Svelte에는 나름의 크래시 방식이 있다. Svelte 컴포넌트가 더 이상 반응형으로 동작하지 않아서, 앱이 멈춘 것처럼 보인다.

할 수 있는 일은 이 상황을 감지하고, 사용자에게 페이지를 다시 로드하라고 안내하는 것뿐이다.

<!-- Svelte -->

<script>
  let counter = 0;
  $: counterCopy = counter;
  const onunhandledrejection = async e => {
    console.log('Unhandler error, checking whether crashed', e);
    const oldCounter = counter;
    counter++;
    window.setTimeout(() => {
      if (counterCopy <= oldCounter) {
        console.log('CRASH DETECTED!!!');
        if (window.confirm('Sorry, App has crashed.\nReload page?')) {
          window.location.reload();
        }
      }
    }, 500);
  };
</script>

<svelte:window on:unhandledrejection={onunhandledrejection} />

여기서는 unhandledrejection 이벤트를 사용해 감지한다. 이 이벤트는 여러 상황에서 발생할 수 있고, 그중 일부는 Svelte 크래시를 일으키지 않는다. 그래서 counter와 counterCopy 변수를 사용한다. 반응형 구문이 동작하지 않는다면, Svelte 전체가 크래시된 것이고, 복구하는 유일한 방법은 페이지를 다시 로드하는 것이다.

전체 코드는 ErrorHandler 컴포넌트에서 볼 수 있다.

결론

이런 문제들이 있음에도 불구하고, Svelte는 훌륭한 프레임워크이고, React에서 Svelte로 앱을 변환한 결과에 매우 만족하고 있다. 지금 Svelte로 구현된 몇몇 기능은 React로는 거의 불가능했을 것이다.

물론 React에만 존재하는 서드파티 라이브러리를 많이 사용하고 있다면, 이는 심각한 문제가 될 수 있다. 하지만 내 경우에는 React에 대한 의존성이 최소였고, 이런 경우라면 Svelte로의 재작성도 꽤 쉽고 빠르게 끝낼 수 있다.