React, Next, Redux/⚛ React.JS

runtime css-in-js is not free

DarrenKwonDev 2021. 12. 30. 00:32

runtime css-in-js

 

우리는 흔히 SPA 관련 프레임워크를 기반으로 프론트엔드 작업을 할 때 styled-components나 emotion과 같은 css-in-js(CIJ)를 사용하곤 합니다. CIJ는 컴포넌트와 스타일을 번갈아 확인해야함으로써 발생하는 인지적인 비용을 줄일 수 있다는 사소한 장점부터, props에 따른 스타일 변경을 손쉽게 작성할 수 있다는 개발 편의적인 장점까지 많은 장점이 있습니다. 게다가 유명한 런타임 CIJ 라이브러리들은 대부분 자동으로 vendor-prefix를 붙여주기까지 합니다. Typed에서도 이런 장점을 개발에 활용하기 위해 emotion을 사용하고 있습니다.

 

그러나 위에서 언급한 CIJ 관련 라이브러리들은 런타임에 스타일을 동적으로 생성합니다. 따라서 런타임에 너무 많은 연산을 해야하는 경우 성능상 문제가 생길 수 있습니다. 일반적으로는 발생하지 않지만 이용자와의 interaction이 잦은 웹 서비스의 경우에는 종종 이런 경우가 발생하곤 합니다. 그렇다면 구체적으로 런타임에 너무 많은 연산을 하는 예시가 무엇이 있을까요?

 

 

css-in-js와 일반 css간의 연산 차이가 유의미한 순간

 

과거 Typed에서 구현된 DragPreview를 간략하게 재현하자면 다음과 같습니다.

interface AppHomeDocumentDragPreviewProps {
  dragSourceOffset: { x: number; y: number } | null
}

const AppHomeDocumentDragPreview = ({
  dragSourceOffset,
}: AppHomeDocumentDragPreviewProps) => {
  return (
    <DragPreview dragSourceOffset={dragSourceOffset}>
      {numOfMultiSelected} Resources
    </DragPreview>
  )
}

export const DragPreview = styled(InfoToolbarWrapper)<{
  dragSourceOffset: { x: number; y: number } | null
}>`
  display: ${({ dragSourceOffset }) => (dragSourceOffset ? 'block' : 'none')};
  position: fixed;
  top: 0;
  left: 0;
  z-index: 1010;
  pointer-events: none;
  transform: ${({ dragSourceOffset }) =>
    dragSourceOffset &&
    `translate(${dragSourceOffset.x}px, ${dragSourceOffset.y}px)`};
`

 

위 Preview는 다음과 같이 마우스 커서를 느리게 따라가는 것을 확인할 수 있습니다. 이는 마우스 커서가 움질일 때마다 다른 x, y좌표를 반환하고, 이에 해당하는 스타일을 동적으로 생상하는 연산이 유저의 interaction 속도를 따라잡지 못하기 때문에 발생하는 이유입니다.

 

느리다

유저의 interaction이 짧은 시간에 많이 일어나는 대표적인 경우로 스크롤, 키보드 입력 이벤트 등을 들 수 있습니다. 이런 경우에는 throttling이나 debounce를 활용하곤 합니다. 그러나 drag의 경우 throttleTime을 많이 주게 될 경우 유저에게 drag가 뚝뚝 끊겨 보일 수도 있습니다.

 

무엇보다 위의 케이스에서는 props로 넘어오는 x, y 좌표를 받아 runtime 연산을 줄이는 것이 우선적으로 조치되어야 한다고 판단되었습니다. 따라서, 연산을 styled-components 내부에서 진행하는 것이 아닌 외부에서 진행하고 inline-css를 내려주는 방식으로 코드를 수정하였습니다.

// x, y 좌표를 받아 inline-css를 반환하는 함수
const setStyles = (currentOffset: XYCoord | null) => {
  if (!currentOffset) {
    return {
      display: 'none',
    }
  }
  const { x, y } = currentOffset
  return {
    transform: `translate(${x}px, ${y}px)`,
  }
}
const AppHomeDocumentDragPreview = ({
  documentName,
  dragSourceOffset,
  setStyles,
}: AppHomeDocumentDragPreviewProps) => {
  return (
    <DragPreview style={setStyles(dragSourceOffset)}>
      <Title>{documentName}</Title>
    </DragPreview>
  )
}

 

단순히 연산을 styled-component 외부에서 처리해주는 것만으로도 다음과 같은 속도 차이를 확인할 수 있었습니다.

나아졌다

 

 

build-time css-in-js?

 

위와 같이 잦은 연산이 필요한 경우 발생하는 런타임 오버헤드를 줄이기 위해 zero-runtime을 표방하는 라이브러리들이 등장하기 시작했습니다. zero-runtime CIJ는 대부분 빌드 타임 CIJ를 말합니다. 이 분야에서 가장 활성화된 편인 linaria의 경우 빌드 타임에 css를 extract하여 사용하고 props 변화에 따른 동적인 스타일에 대처하기 위하여 css 변수를 활용한다고 합니다. 

 

zero-runtime은 아니지만 near-zero runtime를 표방하는 stitches.js도 인지도를 높여가고 있습니다. 불필요한 런타임에서의 prop interpolations를 줄여 성능을 개선하였다고 합니다. 기존 CIJ와 같게 runtime에서 prop에 따른 변화가 이루어지지만 사전에 정의한 스타일 속성만 사용 가능한 방식을 채택하였습니다.

 

물론 이런 다양한 툴이 등장하면서 피로도가 증가함에 따라 css를 직접 작성하는 것이 낫다는 회의적인 시각 또한 존재합니다.

 

 

 

결론

 

runtime overhead를 걱정할 필요가 없는 서비스라면 기존의 runtime CIJ를 쓰더라도 아무 문제가 없을 것입니다. 그러나 프론트엔드에서의 트렌드가 점차 build-time CIJ로 이동하고 있는 것 같습니다. 이러한 트렌드를 보여주는 사례로 facebook은 빌드 타임에 css를 생성하며 *atomic css를 js적인 방법으로 활용할 수 있는 stylex라는 라이브러리를 개발하였습니다. (stylex는 글을 작성하는 현재 시점에는 오픈소스화되지 않았습니다)

 

Typed에서는 서비스에 적합하다면 이러한 트렌드에 반응하고 적극적으로 도입할 준비가 되어 있습니다. 만약 서비스가 고도화되고 앞서 예시로 든 이슈가 자주 발생하게 된다면 적극적으로 build-tim CIJ를 도입해볼 계획입니다.

 

 

 

 

 

 

 

 

 

 

주석)

* atomic css란 tailwind와 같이 원자적 단위로 css를 작성하고 className을 통해 속성을 조정하는 방법론입니다. 기존의 마크업, 스타일링의 멘탈 모델이 HTML tag 혹은 Component 단위로 css를 작성하는 것이었다면 atomic css는 css를 변수처럼 선언하고, 해당 스타일이 필요한 HTML tag 혹은 Component가 className 등을 통해 스타일을 가져다 쓰는 방식입니다.

 

ref)

 

styled component와 linaria 사이의 벤치마킹에 대한 자세한 글을 확인해보실 수 있습니다.

https://pustelto.com/blog/css-vs-css-in-js-perf/

 

React Finland에서 진행된 stylex 소개 영상입니다.

https://www.youtube.com/watch?v=ur-sGzUWId4&ab_channel=ReactFinland