Tailwind JIT 컴파일러를 피해서 비디오 크기 동적으로 조절하기

드뮴·2025년 4월 1일
5

🪴 개발일지

목록 보기
8/8
post-thumbnail

내가 적은 코드지만 이해할 수 없다

화상회의 페이지 레이아웃을 이전에 개선한 적이 있었다. 이전에는 비디오가 잘리고 일관되지 않은 레이아웃을 보여줬기 때문에 비디오 개수와 현재 화면 사이즈에 따라 동적으로 레이아웃을 조절할 수 있게 계산을 하도록 했다.

코드의 일부를 보면 다음과 같았다.

const getVideoLayoutClass = (count: number) => {
  switch (count) {
    case 1:
      return `w-[calc(min(100%,((100vh-140px)*(4/3))))]`;
    case 2:
      return `w-[calc(min(100%,((100vh-148px)*(2/3))))]
              sm:w-[calc(min(calc(50%-0.5rem),((100vh-140px)*(4/3))))]
             `;
    case 3:
      return `w-[calc(min(100%,((100vh-156px)*(4/9))))]
              md:w-[calc(min(calc(50%-1rem),((100vh-146px)*(2/3))))]
              2xl:w-[calc(min(calc(33.3%-1rem),((100vh-140px)*(4/3))))] 
             `;
    case 4:
      return `w-[calc(min(100%,((100vh-164px)*(1/3))))]
              md:w-[calc(min(calc(50%-0.5rem),((100vh-148px)*(2/3))))]
             `;
    case 5:
      return `w-[calc(min(100%,((100vh-172px)*(4/15))))]
              xs:w-[calc(min(calc(50%-0.5rem),((100vh-156px)*(4/9))))]
              2xl:w-[calc(min(calc(33.3%-1rem),((100vh-148px)*(2/3))))]
             `;
  }
};
  • switch 문으로 비디오 개수에 따라 비디오의 너비를 설정해주었다.
  • 이때 화면에 남는 곳을 최소화하여 꽉찰 수 있게 계산했고, 100vh-140px와 같은 계산식은 현재 높이에서 헤더와 푸터 부분을 제외하고 남는 높이의 길이를 나타내는 것이었다.

자세히 살펴보게 되면 비디오 개수에 따라 계산 로직이 조금씩 달랐다. 그래서 작성할 당시 열심히 화면 사이즈에 맞게 계산해서 넣었기에 레이아웃은 유연하게 보여질 수 있었다.

그러나 리팩토링을 진행하며 헤더와 푸터의 길이를 수정하게 되었고 이에 레이아웃 로직이 완벽하게 동작하지 않았다.

수정하려고 다시 해당 코드를 보니, 이전에 열심히 계산했던 것만 기억나고 로직이 제대로 생각나지 않았다. 어떻게 계산했지?하는 생각이 들었다.

수정하면서 앞으로의 수정에도 쉽게 변경될 수 있게 해당 로직을 수정해야겠다는 생각이 들어 어떻게 수정하는게 앞으로의 수정에도 편하게 사용할 수 있을지 고민해보았다.


계산 로직이 눈에 보이도록 변경하기

interface LayoutConfig {
  breakpoint: string;
  maxVideoPerRow: Record<number, number>;
}

const layoutConfig: LayoutConfig[] = [{
  breakpoint: "default",
  maxVideoPerRow: {
    1: 1, 2: 1, 3: 1, 4: 1, 5: 1
  }
}, {
  breakpoint: "xs",
  maxVideoPerRow: {
    1: 1, 2: 1, 3: 1, 4: 1, 5: 2
  }
}, {
  breakpoint: "sm",
  maxVideoPerRow: {
    1: 1, 2: 2, 3: 1, 4: 1, 5: 2
  }
}, {
  breakpoint: "md",
  maxVideoPerRow: {
    1: 1, 2: 2, 3: 2, 4: 2, 5: 2
  }
}, {
  breakpoint: "2xl",
  maxVideoPerRow: {
    1: 1, 2: 2, 3: 3, 4: 2, 5: 3
  }
}]
  • 이전 코드는 비디오 개수와 화면 사이즈에 따라 width 값을 계산한 값을 넣어서 표현했다. 코드 자체는 간결했지만, 수정하려고 보니 해당 계산이 무엇을 의미하는지 파악하기 어려웠다.
  • 가독성이 떨어지고 수정이 어려웠기에 이를 개선하고자 화면 사이즈에 따라 그리고 비디오 개수에 따라 한 줄에 최대 몇 개의 비디오가 표시되는지를 정의했다.

maxVideoPerRow는 예를 들어 breakpoint가 2xl 즉, 화면이 2xl 이상일 때 5:3을 보면 5개의 비디오가 있고 화면 너비가 2xl일 때 한 줄에 최대 비디오는 3개가 온다는 뜻이다.

이 말의 의미는 비디오가 5개인데 한 줄에 최대 3개가 오므로 3+2로 만들어서 레이아웃이 구성된다는 뜻이다.

그림으로 설계하기

그림으로 계산 로직을 설계해보았다.

  • width를 계산할 때 2가지 케이스를 생각한다.
    • 가로 사이즈에 꽉차게 비디오 크기를 설정하는 경우
    • 세로 사이즈에 맞춰 계산해서 비디오 크기를 설정하는 경우
  • 두 가지 중 작은 값을 선택하면 비디오 사이즈가 화면 사이즈에 따라 동적으로 조절될 수 있다.

화상회의 페이지에 참가자가 5명이 최대라 비디오가 1개일 때부터 5개일 때의 케이스를 그려보았다. 화면 사이즈에 따라 이전 코드는 이렇게 설계되어있었다. 이러한 케이스에 맞게 위에서 layoutConfig에 저장해주었다.

만약 사용자가 5명이 아닌 더 많은 사용자를 수용하게 되면 특정 개수까지 설계하고 넘길 수 있게 구현하면 된다. 또한 layoutConfig를 통해 값을 수정해주기만 하면 되고, 현재는 5개의 비디오라 화면 사이즈에 따라 케이스가 제각각이긴하지만 케이스가 많아지면 어느정도 통일해서 간결하게 나타낼 수도 있다.

화면 사이즈별로 width 계산 합치기

const calculateVideoWidth = (
  videoCount: number,
  maxVideoPerRow: number,
  restHeight: number,
  gap: number
) => {
  const widthPercent = maxVideoPerRow > 1
    ? `${100 / maxVideoPerRow}%-${gap * (maxVideoPerRow - 1)}px`
    : "100%";
  const rowCount = Math.ceil(videoCount / maxVideoPerRow);
  const widthByHeight = `calc(((100vh-${restHeight}px)/${rowCount})*(4/3))-${gap * (maxVideoPerRow - 1)}px`;

  return `min(${widthPercent},${widthByHeight})`;
}

const getVideoLayoutClass = (count: number) => {
  const restHeight = 172;
  const gap = 8;

  const layoutList = layoutConfig.map(config => {
    const maxVideoPerRow = config.maxVideoPerRow[count];
    const width = calculateVideoWidth(count, maxVideoPerRow, restHeight, gap);

    if (config.breakpoint === "default") {
      return `w-[${width}]`;
    }
    return `${config.breakpoint}:w-[${width}]`;
  })

  return layoutList.join(' ');
}
  • 위에서 config로 정의해둔 것을 이용해 현재 화면에서 비디오가 가질 수 있는 최대 너비를 계산할 수 있다.
  • 비디오의 최대 너비는 100%/현재 같은 줄에 있는 비디오 개수(현재 비디오가 차지하는 높이/비디오 줄의 수)*(4/3) 중 작은 값을 선택하면 된다.
    • 비디오가 1개라면 width를 100%로 설정하거나 혹은 현재 높이에 4/3을 곱해주면 된다. 그런데 만약 화면 높이가 너무 낮으면 width를 100%로 설정하면 위 아래가 잘린다. 그러나 높이에 기반해 계산해준다면 비디오는 작게 보이지만 비디오가 잘리는 일은 없다.
    • 위 케이스와 같이 항상 둘 중 작은 값을 선택하면 비디오가 잘리는 일은 없다.

췟 안 되는군..

실제 계산 로직은 비디오 개수, 화면 사이즈에 맞게 계산이 잘되어 class에 적용된 것을 볼 수 있었다.

그러나 실제 이렇게 적용했음에도 비디오의 width가 0이 되었다. 그래서 css의 calc에 띄어쓰기를 제대로 해주지 않은게 문제라 생각했지만 고쳐도 안되었다. 알고보니 tailwind JIT 컴파일러 동작 방식과 관련된 문제였다.


tailwind JIT 컴파일러

tailwind JIT 컴파일러는 개발 과정에서 CSS를 실시간으로 생성하는 방식이다.
Just-In-Time이라는 이름처럼 필요한 순간에 필요한 스타일만 생성한다.

tailwind CSS v3부터 JIT 모드가 기본적으로 활성화되어있다.

JIT 모드의 장점

  • CSS를 미리 생성하는 대신, 실제 사용되는 클래스만 즉시 생성하여 개발 시간을 단축한다.
    • 코드에서 사용하는 스타일 클래스만 생성한다. (개발 중 사용된 클래스만 생성한다.)
  • 사용하지 않는 스타일을 생성하지 않아 최종 CSS 파일 크기가 줄어든다.
  • 변경 사항이 즉시 반영되어 CSS를 다시 빌드할 필요가 없다.
    • 파일을 실시간으로 감시하며, tailwind 클래스를 발견하면 즉시 해당 CSS를 생성한다.

그러나 이런 장점이 있지만 제한 사항도 있었다.

📌 JIT 모드는 동적으로 생성된 클래스 이름이 생성되지 않을 수 있고, 클래스를 문자열 연결로 생성하는 경우 감지하지 못할 수 있다.

빌드타임 vs 런타임

빌드 프로세스와 렌더링 순서

  1. tailwind 빌드 단계
    • 소스 코드를 스캔해서 클래스를 추출한 후 CSS를 생성한다.
    • 하드코딩된 클래스와 정적 템플릿(w-[100px])만 인식한다.
    • 이 과정에서 변수 값이나 함수 결과는 인식이 불가능하다.
  2. 애플리케이션 실행 (브라우저)
    • 자바스크립트 코드를 실행하며, 동적 클래스 문자열을 생성한다.
    • 리액트 컴포넌트가 렌더링되고 DOM에 클래스를 적용한다.
    • 브라우저가 클래스에 맞는 CSS를 찾는다.
    • 빌드 단계에서 생성되지 않은 CSS는 적용되지 않는다.

📌 tailwind는 변수 값이나 함수 결과는 인식이 불가능하다.
변수 값: 런타임에 결정되는 자바스크립트 변수
함수 결과: 실행 시점에 계산되는 함수의 반환 값

동적인 변수 값과 함수 결과는 빌드 시점에 알 수 없는 값이기 때문에 tailwind가 해당 클래스를 미리 생성할 수 없다. (조건부로 된 함수가 동작하는 건 조건에 따라 클래스 문자열이 정의되고 정적 분석으로 tailwind는 모든 클래스를 추출해 CSS를 만들 수 있다.)

이전 코드는 동작했던 이유

const getVideoLayoutClass = (count: number) => {
  switch (count) {
    case 1:
      return `w-[calc(min(100%,((100vh-140px)*(4/3))))]`;
    case 2:
      return `w-[calc(min(100%,((100vh-148px)*(2/3))))]
              sm:w-[calc(min(calc(50%-0.5rem),((100vh-140px)*(4/3))))]
             `;
    ...
  }
};

이전 코드는 문자열을 특정 조건에 따라 생성하는 함수가 아닌 switch 문으로 비디오 개수에 따라 특정 문자열을 반환했다.

tailwind JIT는 getVideoLayoutClass 함수를 class에서 발견하면 이 함수를 분석하여 만들어지는 모든 결과를 추출하여 CSS를 생성한다. 조건부 클래스 반환은 tailwind가 정적 분석을 통해 인식할 수 있다.

수정한 코드는 동작하지 않는 이유

const getVideoLayoutClass = (count: number) => {
  ...
  const layoutList = layoutConfig.map(config => {
    const maxVideoPerRow = config.maxVideoPerRow[count];
    const width = calculateVideoWidth(count, maxVideoPerRow, restHeight, gap);

    if (config.breakpoint === "default") return `w-[${width}]`;
    return `${config.breakpoint}:w-[${width}]`;
  })

  return layoutList.join(' ');
}

수정한 코드는 조건에 따라 문자열을 반환하는 간단한 로직이 아니다. 복잡한 계산을 통해 동적으로 계산된 값을 반환한다. 이렇게 계산하게 되면 tailwind는 빌드 시점에 어떤 값이 들어가는지 정확히 알 수 없다.

자바스크립트가 계산하여 class에 문자열을 만들어 넣었지만, 이에 해당하는 CSS는 빌드타임에 생성되지 않았기 때문에 작동하지 않는다.


어떻게 해결해야할까?

원래 사용했던 대로 하드코딩해서 비디오 개수에 따라 미리 계산된 값을 사용하면 문제 없이 동작한다. 이 방법을 그냥 다시 써야하나 생각했지만, 다음에 또 디자인 일부가 변경되면 재사용하기 어려울거 같다는 생각이 들었다.

  • 비디오 개수, 화면 사이즈에 따라 동적으로 레이아웃을 조정해야한다.
  • 결과가 아닌 계산 로직을 확인해서 언제든지 수정이 가능하게 해야한다.

떠올린 생각

  1. 이전 방식 대로 사용하기
  2. CSS-in-JS 라이브러리를 도입하기
  3. 인라인 스타일 사용하기

이렇게 떠올렸는데 첫번째 방식인 이전 방식대로 사용하는 것은 이후 수정 시 불편해서 해당 수정을 진행하게 되었기에 돌아가지 않기로 했다.

두번째로 CSS-in-JS로 변경할까하는 잠깐의 고민도 있었지만 여태까지 모두 tailwind를 사용한 걸 바꾸는건 엄청난 비용이 들고 그렇다고 이 부분을 위해 CSS-in-JS를 추가하는 것은 복잡성을 증가시킨다 생각해서 택하지 않았다.

세번째 방법은 인라인 스타일은 계산 로직을 이해할 수 있게 작성할 수 있으면서 tailwind 시스템 제한을 우회할 수 있는 방법이라 생각했다.

현재 비디오 레이아웃 계산 로직에서 가장 중요한 것이 코드 가독성이었기에 인라인 스타일을 채택해서 계산 로직을 드러낼 수 있게 하기로 결정했다.

인라인 스타일을 이용하자

인라인 스타일을 이용해서 해결하기로 결정했다. 하드코딩된 값을 사용하면 가독성 측면에서 좋지 않고 다음에 수정하게 된다면 계산을 어떻게 했는지 이해를 시작으로 처음부터 계산을 해줘야했다.

인라인 스타일이란?

인라인 스타일은 리액트가 브라우저의 DOM 요소에 직접 style 속성을 적용한다.
이 과정은 런타임에 이루어지며, tailwind의 빌드 프로세스와 관련이 없다.

이렇게 인라인 스타일을 적용하게 되면 장점이 있었다.

  • 복잡한 동적 레이아웃을 정확하게 계산해서 적용할 수 있다.
  • 다른 스타일 라이브러리를 추가하거나 변환하는 작업 없이 기존 tailwind 시스템에서 사용할 수 있다.

어떻게 적용할까?

설계 로직은 위에서 그림으로 설계했던 계산 로직과 동일하다. 그러나 인라인 스타일을 적용해준다는 점만이 다르다. 또한 자바스크립트에서 CSS 스타일을 변경해주므로, 화면 사이즈에 따라 계산할 수 있도록만 해주면 된다.

const VideoContainer = (...) => {
  const [breakpoint, setBreakpoint] = useState("default");
  const videoCount = 1 + peers.length;

  useEffect(() => {
    const handleResize = () => {
      const width = window.innerWidth;

      if (width >= breakpoints["2xl"]) setBreakpoint("2xl");
      else if (width >= breakpoints["md"]) setBreakpoint("md");
      else if (width >= breakpoints["sm"]) setBreakpoint("sm");
      else if (width >= breakpoints["xs"]) setBreakpoint("xs");
      else setBreakpoint("default");
    };

    handleResize();
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  const getVideoStyle = useMemo(() => {
    const restHeight = 172;
    const gap = 8;
    const config = layoutConfig.find(c => c.breakpoint === breakpoint) || layoutConfig[0];
    const maxVideoPerRow = config.maxVideoPerRow[videoCount];
    const rowCount = Math.ceil(videoCount / maxVideoPerRow);

    const widthPercent = maxVideoPerRow > 1
      ? `calc(${100 / maxVideoPerRow}% - ${gap * (maxVideoPerRow - 1)}px)`
      : "100%";
    const widthByHeight = `calc(((100vh - ${restHeight}px - ${gap * (rowCount - 1)}px) / ${rowCount}) * (4 / 3)) - ${gap * (maxVideoPerRow - 1)}px`;

    return { width: `min(${widthPercent}, ${widthByHeight})`, aspectRatio: '4/3' };
  }, [breakpoint, videoCount]);

  return (
    <div
      className={`relative ${speakingEffect(isSpeaking)} rounded-custom-l`}
      style={getVideoStyle}
    >
      ...
    </div>
  );
};
  • useEffect에서 컴포넌트가 처음 마운트되면 현재 width를 넣어두고, 화면 사이즈가 변경될 때 이벤트 리스너를 등록한다.
  • getVideoStyle 함수는 위에서 작성했던 코드 로직과 동일하다. useMemo를 통해 지정된 화면 너비 범위, 비디오 개수가 변경될 때 함수가 재계산된다.

breakpoint는 특정 너비 범위를 나타내는데 화면의 너비 범위가 md이고 이 범위 내에서 변경될 때와 높이가 변경될 때도 비디오 크기가 동적으로 잘 변경되는 이유가 무엇일까?

useMemo로 getVideoStyle 함수에서 breakpoint, videoCount가 변경될 때만 재계산하도록 했었다. 그렇다면 만약 breakpoint가 md 사이즈로 설정되고 화면 너비가 md 범위 내에서 변경되면 해당 함수는 다시 실행되지 않는데 어떻게 비디오 사이즈가 동적으로 변경이 될까?

이는 반환하는 값이 calc(50% - 8px)와 같은 형태이기 때문이다. 자바스크립트 함수를 재실행하지 않지만 CSS에서 50%는 화면 너비의 50%인데 CSS가 이 50%를 재평가하게 되기 때문에 문제가 없다.

📌 CSS에서 상대 단위 %, vh와 같은 값을 사용하게 되면 브라우저에서 실시간으로 평가된다. 화면 크기가 변경될 때마다 브라우저 렌더링 엔진이 상대 단위를 기반으로한 값을 다시 계산한다.
뷰포트 기반 단위 vh, vw와 % 단위, min, max 등의 함수는 화면 크기가 변경되면 재평가 된다.


CSS-in-JS만 사용하다 tailwind를 이번 프로젝트를 하며 처음 사용해보았는데, 동적 클래스 생성에 한계가 있다는 이야기를 들은 적이 있었지만 와닿지 않았었다. 이번에 수정해보며 무슨 뜻인지 이해할 수 있었다.

전통적인 CSS-in-JS 라이브러리인 styled components, emotion은 자바스크립트가 동적으로 스타일을 생성하고 DOM에 삽입하는 과정에서 오버헤드가 발생한다. 또한 해당 라이브러리 자체가 번들 크기를 증가시키고, SSR에서는 클라이언트 하이드레이션 시 스타일 계산이 다시 일어나 지연이 발생하는 문제가 있었다.

프로젝트에서 tailwind를 도입한 것은 빌드 타임에 스타일을 생성해 런타임 오버헤드가 없다는 장점이 있었기 때문이었다. 이번에 처음으로 tailwind를 사용해보며 동적 클래스 생성에 한계가 있다는 것도 깨달을 수 있었다. 또한 아쉬운 점은 class에 스타일을 정의하다보니 가로로 길어지는 경우가 불편하긴 했던거 같고 스타일 코드랑 조금 분리해도 괜찮을 거 같다는 생각도 들었다. 그러나 익숙해지면 편한 도구인 건 반박할 수는 없는거 같다.

스타일 코드를 완전히 분리하면서도 런타임 오버헤드가 없는 라이브러리로 Zero-Runtime CSS-in-JS가 등장했다고 한다.
대표적으로 PandaCSS, Vanilla Extract 등이 있는데 다음엔 이런 라이브러리도 한 번 사용해보면 좋을거 같다는 생각이 들었다.

profile
안녕하세오

0개의 댓글