Intro

‘수상한 녀석들’은 광고 공모전 탈락작을 학습 자산으로 전환하는 플랫폼입니다.
  • 공모전에 출품했지만, 상을 받지 못한 작품은 그저 묻혀버림
  • 탈락 후, 정보 부족으로 인해 회고 및 분석이 이루어지지 않아 학습의 기회를 놓침.
이러한 문제의식에서 출발한 프로젝트였고, 저는 이 프로젝트에서 프론트엔드 개발을 맡았습니다.
그런데, 화면을 구현하던 중 예상치 못한 문제가 발생했습니다
<div className="font-B03-R sm:font-T04-SB">
  수상한 녀석들
</div>
Tailwind로 sm: 이하 조건에 맞춰 폰트를 변경하려 했는데, 아무리 해도 적용이 되지 않았습니다.
font-B03-R은 보이고, sm:font-T04-SB는 꿈쩍도 하지 않아요.
처음엔 단순히 Tailwind 설정 문제인가?, 빌드가 덜 된 건가? 싶었지만, 디버깅을 거듭할수록 이상하게도 브라우저 너비는 충분한데, 반응형 클래스는 적용되지 않는 현상이 반복됐습니다.
디자인 시안대로 구현했는데 뭔가 안 맞고, sm: md:가 안 먹을 때의 그 찝찝함…
이번 글에서는 그 반응형 지옥을 어떻게 해결했는지, 그리고 그 과정에서 만든 useIsMobile 커스텀 훅의 개발기를 공유하려고 합니다.

문제 상황: 반응형 Tailwind 클래스가 안 먹는다

1. 문제 상황: 반응형 Tailwind 클래스가 안 먹는다

문제는 아주 단순한 코드에서 시작됐습니다.
<div className="font-B03-R sm:font-T04-SB">
  수상한 녀석들
</div>
의도:
  • 기본(모바일 기준)에는 font-B03-R이 적용
  • sm 이상일 때는 font-T04-SB가 적용되어야 함
현상:
  • 실제 화면에선 아무리 브라우저 너비를 키워도 sm: 클래스가 적용되지 않음
  • 콘솔에 찍힌 className을 봐도 sm: 조건의 스타일이 적용되지 않은 상태
처음엔 “Tailwind 설정이 잘못됐나?” 싶었지만, 설정은 문제 없었습니다.
브라우저 크기도 분명 640px 이상이었고, 심지어 Chrome의 devtools에서도 해당 클래스가 표시되지 않았죠.

원인 추적

간단한 예시 상황을 만들어 왜 적용이 되지 않는지 확인을 해보았습니다.
function App() {
  return (
    <div className="flex flex-col gap-12 sm:gap-4">
      <div className="bg-amber-100 font-T01-B sm:font-B01-R">howu</div>
      <div className="bg-amber-200 font-T01-B sm:font-B01-R">howu</div>
      <div className="bg-amber-300 font-T01-B sm:font-B01-R">howu</div>
    </div>
  );
}

export default App;
@import "tailwindcss";

@layer utilities {
  .font-default {
    font-family: "Pretendard";
  }

  .font-T01-B {
    font-family: "Pretendard";
    font-weight: 700;
    font-size: 26px;
    line-height: 1.4;
  }

  .font-B01-R {
    font-family: "Pretendard";
    font-weight: 400;
    font-size: 18px;
    line-height: 1.5;
  }
}
이런 코드를 짰을때 브라우저에서 개발자 도구 소스탭에 들어가서 생성된 CSS 파일을 확인해보면
코드 예시
media 쿼리를 달고 생성된 style은 sm:gap-4 하나이다.
이는 Tailwind가 @layer 안에 직접 정의한 커스텀 유틸리티 클래스에 대해 자동으로 반응형 variant (sm: 등)를 생성하지 않는다는 것을 알수있었습니다.

여러가지 해결책

1. 직접 미디어 쿼리 작성

@media (min-width: 640px) {
  .sm\:font-B01-R {
    font-family: "Pretendard";
    font-weight: 400;
    font-size: 18px;
    line-height: 1.5;
  }
}
장점:
  • 빠르게 테스트를 해볼수 있습니다.
  • 원하는 스타일을 수동으로라도 만들수 있습니다.
한계:
  • 유지보수성이 떨어집니다.
  • Tailwind에 의해서 제거되어 스타일이 만들어지지 않을 수 있습니다.

2. 근본적 해결 – Tailwind 유틸리티 조합 사용

<div className="text-[16px] font-normal sm:text-[20px] sm:font-semibold">
  howu
</div>
장점:
  • Tailwind가 의도한 방식이며, 정적 분석에 완벽하게 대응됩니다.
  • 유지보수성과 예측 가능성이 높습니다.
  • JIT 컴파일과 purge에도 안전합니다.
한계:
  • 기존에 커스텀한 font-T04-SB, font-B03-R 같은 스타일을 재사용할 수 없습니다.
  • 모든 스타일을 수동으로 조합해야 하므로, 스타일 일관성 관리가 어려울 수 있습니다

3. 조건부 렌더링 – useIsMobile

import { useIsMobile } from './hooks/useIsMobile';

export default function App() {
  const isMobile = useIsMobile();

  return isMobile ? (
    <div className="font-B01-R">모바일 UI</div>
  ) : (
    <div className="font-T01-B">데스크탑 UI</div>
  );
}
장점:
  • 완전히 다른 Ui를 분기할 수 있습니다.(ex: 모바일 전용 메뉴, 모달, 바텀 시트)
  • CSS만으로 불가능한 로직 처리가 가능합니다.
단점
  • JS 기반이라 Next의 SSR 환경에서의 주의가 필요합니다
  • Tailwind의 장점인 클래스 조합만으로 빠른 개발 속도를 일부 포기해야합니다.

선택

저희 프론트엔드 팀은 여러 접근 방식을 검토한 끝에,
useIsMobile 커스텀 훅을 제작하여 사용하는 방향을 선택했습니다.
그 이유는 다음과 같습니다:
  • 디자인적으로 모바일과 데스크탑의 레이아웃 구조가 크게 달랐고, 단순한 텍스트 크기 변경 수준을 넘어서는 분기가 필요했습니다.
  • Tailwind의 유틸리티 클래스 조합만으로는 복잡한 구조를 커버하기 어려웠고,
  • 조건부 렌더링으로 UX 흐름을 명확하게 분리할 수 있어 성능과 유지보수 측면에서도 유리했습니다.

useIsMobile 훅의 발전기

처음엔 단순히 window.innerWidth만 확인하면 충분할 거라 생각했습니다. 하지만 반응형 UI에서 이 방식은 곧 한계에 부딪혔고, 실제 사용자 경험을 고려해 훅을 발전시켜야 했습니다.
아래는 실제 useIsMobile 훅이 발전해온 4단계의 흐름입니다.

틀만 존재한 초기 버전

export const useIsMobile = () => {
  const [isMobile, setIsMobile] = useState(false);
  return isMobile;
};
  • 단순히 boolean 값만 반환하는 상태 관리 훅
  • 아무 로직 없이 false만 반환 → 쓸모없음

환경 감지 로직 추가

useEffect(() => {
  const userAgent = navigator.userAgent;
  const isMobile = /Android|iPhone|iPad|.../i.test(userAgent) ||
                   ("ontouchstart" in window && window.innerWidth <= 1024);
  setIsMobile(isMobile);
}, []);
  • 브라우저 UA(User-Agent) + 터치 디바이스 여부 + 화면 너비를 함께 고려
  • 한 번만 실행 → 창 크기 바뀌면 적용 안 됨
    • 데스크탑에서 창 크기를 줄이면 반응형이 적용이 안된다는 단점이 있었습니다.

resize 이벤트로 동적 대응

useEffect(() => {
  const checkIsMobile = () => {
    const isMobile = ...;
    setIsMobile(isMobile);
  };

  checkIsMobile();
  window.addEventListener("resize", checkIsMobile);
  return () => window.removeEventListener("resize", checkIsMobile);
}, []);
  • 창 크기 변화 대응 가능
  • 문제는 창을 늘이고 줄일때 resize 이벤트가 너무 자주 발생해 성능 저하 우려되었습니다.

디바운스 + useCallback으로 최적화

import { useEffect, useState, useCallback } from "react";

const MOBILE_BREAKPOINT = 1024;
const DEBOUNCE_DELAY = 150;

export const useIsMobile = () => {
  const [isMobile, setIsMobile] = useState(false);

  const checkIsMobile = useCallback(() => {
    if (typeof window === "undefined") return false;

    const userAgent = navigator.userAgent;
    const isMobileUserAgent =
      /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
    const isTouchDevice = "ontouchstart" in window;
    const isSmallScreen = window.innerWidth <= MOBILE_BREAKPOINT;

    return isMobileUserAgent || (isTouchDevice && isSmallScreen);
  }, []);

  useEffect(() => {
    setIsMobile(checkIsMobile());

    let timeoutId: NodeJS.Timeout;
    const handleResize = () => {
      clearTimeout(timeoutId);
      timeoutId = setTimeout(() => {
        setIsMobile(checkIsMobile());
      }, DEBOUNCE_DELAY);
    };

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

  return isMobile;
};
수상한 녀석들 프로젝트에서는 다음 훅을 사용하여 반응형을 처리하였습니다.

Outro..

처음엔 단순히 글꼴 하나가 안 바뀌는 문제처럼 보였지만, 그 뒤에는 Tailwind의 설계 철학이 있었습니다.
그 문제를 해결하는 과정에서 Tailwind의 컨셉에 대해서 다시 한 번 생각하게 되었습니다.
수상한 녀석들은 단순히 조건문 하나를 넘는 프론트엔드 분기 전략을 고민했고, 결국 useIsMobile 이라는 훅으로 해결하였습니다.
다음 글에서는 이메일 템플릿을 어떻게 구현했는지 공유해보겠습니다. 감사합니다.