진돌.dev
React반응형클린코드2026.07.05 · 16분 읽기

PC/MOBILE 반응형 웹 개발 어떻게 해야되는걸까?(진짜 모름)

여진석

들어가며

서비스를 빠르게 출시해야 했다. 페이지를 나눠 맡고 동시에 개발했고, "하나의 URL로 PC와 모바일을 모두 대응하는 반응형"이라는 요구사항은 분명했지만 어떻게 구현할지에 대한 컨벤션이 부족했다.

누군가는 CSS 미디어쿼리로, 누군가는 모바일·PC 두 벌을 다 그려놓고 한쪽을 숨기는 방식으로, 누군가는 컴포넌트를 아예 두 벌로 나누어서, 누군가는 JS 훅으로 분기해서 작업했다.

유지보수 국면에 들어서자 같은 "반응형 수정"인데 파일마다 접근법이 달라 매번 구조부터 파악해야 했다. 리뷰는 파일 사이를 오가느라 흔들렸고, 디자인은 둘인데 코드는 하나라 수정 하나가 반대쪽을 깨뜨리지 않았는지 매번 확인해야 했다. 개발하면서 "이게 맞나?" 싶은 순간이 점점 잦아졌다.

우리 팀은 반응형을 어떻게 구현하고 있나

코드베이스를 전수 조사해보니, 네 가지 길로 갈라져 있었다.

  1. 인라인 미디어쿼리. 가장 많은 선택. 모바일 스타일을 기본값으로 두고 데스크톱에서 오버라이드한다. 높이 평가할 부분도 있다 — 이 방식을 쓴 파일의 98% 이상이 중앙 미디어쿼리 토큰을 참조하고 mobile-first도 잘 지켜진다. 규율은 있었다:
// SomeButton.css.ts
export const button = style({
  padding: '10px 20px',
  ...tokens.typography['16px'],
  '@media': {
    [tokens.mediaQuery.desktop]: {
      padding: '8px 18px',
      ...tokens.typography['14px'],
    },
  },
});
  1. display:none 이중 렌더. 모바일·PC 두 컴포넌트를 나란히 다 렌더하고 CSS로 한쪽을 숨긴다. DOM에는 항상 두 벌이 살아 있다:
// ItemList.tsx
<div className={styles.itemList}>
  <ItemListMobile items={items} />   {/* PC에서 display:none */}
  <ItemListDesktop items={items} />  {/* 모바일에서 display:none */}
</div>
// ItemList.css.ts — 숨김 규칙
export const mobileWrapper = style({
  display: 'flex', flexDirection: 'column', gap: 16,
  '@media': { [tokens.mediaQuery.desktop]: { display: 'none' } },
});
export const desktopWrapper = style({
  display: 'none',
  '@media': { [tokens.mediaQuery.desktop]: { display: 'block' } },
});

마이페이지 계열이 이 패턴의 밀집 지대다(래퍼 사용 40여 파일). 같은 데이터를 캐러셀(모바일)과 테이블(PC) 두 벌로 마운트하는 최근 주문 섹션부터, 심지어 타이포 크기 하나(16px vs 18px) 때문에 제목이 두 벌인 화면도 있다:

// 프로필 설정 화면의 섹션 제목 — 폰트 크기 하나 때문에 두 벌
<MobileView>
  <Box pt={12} pb={8}>
    <Typography as="p" variant="16px" weight="bold">{section.title}</Typography>
  </Box>
</MobileView>
<DesktopView>
  <Typography as="p" variant="18px" weight="bold">{section.title}</Typography>
</DesktopView>
  1. 컴포넌트 완전 분리. 헤더처럼 구조가 아예 다른 곳은 DesktopHeader/MobileHeader 식으로 파일부터 나누었다. 각 파일은 @media 없는 단일 레이아웃이라 한쪽 디바이스만 생각하며 읽으면 된다:
// DesktopHeader.tsx — @media 없는 단일 레이아웃
export function DesktopHeader({ leftSlot, centerSlot, rightSlot }: DesktopHeaderProps) {
  return (
    <header role="banner">
      <nav className={styles.container} aria-label="Desktop header navigation">
        <div className={styles.logoSlot}>{leftSlot ?? <Logo height={24} />}</div>
        <div className={styles.centerSlot}>{centerSlot}</div>
        <div className={styles.actionsSlot}>{rightSlot}</div>
      </nav>
    </header>
  );
}
// MobileHeader.tsx는 햄버거 버튼 + 검색 아이콘 구조로 완전히 다른 파일

다만 둘 중 하나를 고르는 분기점은 결국 B(이중 렌더)였다 — 파일 분리는 깨끗했지만 "분기점을 뭐로 할 것인가"의 기준은 없었다:

// 분기점 — 두 벌을 모두 렌더하고 CSS로 토글 (C와 B의 조합)
<MobileView><MobileHeader /></MobileView>
<DesktopView><DesktopHeader /></DesktopView>

같은 완전 분리를 하고도 분기점 선택이 다른 곳도 있다. 멤버십 페이지의 8개 섹션은 전부 Desktop/Mobile 함수 쌍인데, 선택을 CSS가 아니라 JS가 한다:

// 멤버십 등급 배너 — 약 160줄 (Desktop 함수 + Mobile 함수)
function TierBannerDesktop() {
  const { data } = useSuspenseQuery(membershipQueries.summary());
  // …등급 계산, 진행률, 실적 모달 핸들러…
  return <Box className={styles.desktopBanner}>{/* 가로 배너 + 우측 카드 */}</Box>;
}
function TierBannerMobile() {
  const { data } = useSuspenseQuery(membershipQueries.summary());
  // …완전히 같은 로직이 한 번 더…
  return <VStack px={16}>{/* 세로 스택 + 말풍선 카드 */}</VStack>;
}
export function TierBanner() {
  const platform = usePlatform(); // SSR 초기값 'PC' 고정 + matchMedia 판정
  return platform === 'PC' ? <TierBannerDesktop /> : <TierBannerMobile />;
}

두 함수는 데이터 훅·계산 로직이 완전히 동일하고 JSX만 다르다(약 30줄 중복). 분리는 했는데 공유 로직을 추출하지 않아 중복이 남았고, 분기가 JS라 SSR 비용도 그대로다.

  1. JS 훅 prop 분기. 한 컴포넌트 안에서 값·문구·간격이 전부 삼항 분기된다:
// OrderInfoRow.tsx
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
return (
  <div>
    {isDesktop && <span className={styles.divider}>|</span>}
    <span>{isMobile && 'No. '}{orderNo}</span>
    ...
    <Space size={isMobile ? 16 : 24} />
    <Space size={isMobile ? 32 : 56} />
  </div>
);

네 가지 모두 "동작하는" 방법이다. 문제는 이게 한 코드베이스 안에 동시에 있고, 심지어 한 화면 안에서 섞인다는 것이다.

네 가지 패턴은 파일마다 다를 뿐 아니라, 한 페이지 안에서도 섞인다 — 마이페이지는 B의 밀집 지대이고, 멤버십은 C로 나누고 D로 고른다. 각 패턴 설명에 실사례를 같이 담았다.

개발하면서 실제로 겪은 불편

위 상황은 현황이고, 아래는 내가 느낀 불편한 부분들이다.

1. CSS 복잡도 — 한 파일이 두 레이아웃을 책임진다

진짜 문제는 폰트 크기 같은 값 차이가 아니다. 레이아웃 구조 자체가 다른 두 UI를 한 파일이 동시에 책임질 때다. 리뷰 상세 팝업 스타일 파일은 500여 줄에 @media 블록 26개 — 모바일에서는 세로 스택, PC에서는 좌우 2단이라 거의 모든 셀렉터에 반전 분기가 달린다:

// ReviewPopup.css.ts (500여 줄 중) — 세로 스택 ↔ 좌우 2단 전환
export const container = style({
  display: 'flex',
  flexDirection: 'column',   // 모바일: 세로 스택
  height: '100%',
  '@media': {
    [tokens.mediaQuery.desktop]: {
      flexDirection: 'row',  // PC: 좌우 배치로 전환
      height: '640px',
      gap: '32px',
    },
  },
});
export const contentPane = style({
  width: '100%',             // 모바일: 전체 폭
  paddingTop: '24px',
  '@media': {
    [tokens.mediaQuery.desktop]: {
      width: '50%',          // PC: 절반 폭
      padding: 0,
    },
  },
});

공용 오버레이는 한 술 더 떠서, 한 파일이 모바일 바텀시트와 PC 중앙 모달이라는 서로 다른 컴포넌트 두 개를 책임진다:

// OverlayLayer.css.ts — 바텀시트 ↔ 모달 전환
const dimmed = style({
  position: 'fixed',
  inset: 0,
  display: 'flex',
  justifyContent: 'center',
  alignItems: 'end',          // 모바일: 하단 정렬 (바텀시트)
  '@media': {
    [tokens.mediaQuery.desktop]: {
      alignItems: 'center',   // PC: 중앙 정렬 (모달)
    },
  },
});
const panel = style({
  width: '100%',
  borderRadius: '16px 16px 0 0',      // 모바일: 위쪽만 둥글게
  transform: 'translate(0, 100%)',    // 모바일: 하단 오프스크린에서 올라옴
  '@media': {
    [tokens.mediaQuery.desktop]: {
      borderRadius: 0,                // PC: 모서리 없음
      width: '480px',
    },
  },
});

결제 페이지의 CTA 버튼은 모바일에서 화면 하단 고정바, PC에서는 본문 인라인이다 — position: fixed ↔ static이 한 스타일 블록 안에서 뒤집힌다. 여기에 14px→16px 류 타이포 쌍 반복까지 수십 개 파일에 쌓인다. 이 파일들은 사실 "반응형인 한 파일"이 아니라, 서로 다른 두 UI가 한 파일에 압축된 것이다.

2. 리뷰·수정할 때 tsx ↔ css.ts를 계속 왕복한다

코드 리뷰에서 레이아웃 변경 하나를 이해하려면 .tsx를 읽다가 .css.ts로 가서 클래스를 찾고, 다시 돌아와서 조건을 확인해야 한다. 더 나쁜 건 한 컴포넌트가 CSS 분기와 JS 분기를 동시에 쓸 때다 — 상품 상세는 .css.ts에서 @media로 flex-order를 바꾸면서 .tsx에서는 isMobile ? 16 : 0으로 마진을 분기한다. 한 레이아웃을 이해하는 데 두 파일 · 두 메커니즘을 오가야 한다.

3. 디자인은 두 벌로 오는데 코드는 한 벌이다

  • 디자인 시안도, 기획 요청도 모바일/데스크톱 두 벌로 온다. 그런데 코드는 한 파일에 합쳐져 있다.
  • QA·테스트도 항상 두 벌 — 한쪽을 고치면 반대쪽이 깨졌는지 매번 확인해야 한다.
  • AI로 작업할 때 치명적이다: "모바일 시안 반영"과 "PC 시안 반영"이 같은 파일을 수정하므로 병렬 작업이 불가능하다. 파일이 분리되어 있었다면 두 에이전트(또는 두 사람)가 동시에 작업할 수 있다.

왜 이렇게 개발이 불편하고 힘들게 느껴질까

불편이 단순히 익숙함의 문제인지, 원칙으로 설명되는 구조적 문제인지 확인하고 싶었다.

토스 frontend-fundamentals는 좋은 코드의 기준을 가독성 · 예측 가능성 · 응집도 · 결합도 4가지로 정의한다. 대표 문서 4편의 예시를 그대로 가져와, 같은 형식에 우리 반응형 코드를 대입해보았다.

  1. 같이 실행되지 않는 코드 분리하기 — 가독성

원문은 권한에 따라 다르게 동작하는 버튼을 예로 든다:

// 토스 원문 — Before
function SubmitButton() {
  const isViewer = useRole() === "viewer";
  useEffect(() => {
    if (isViewer) return;
    showButtonAnimation();
  }, [isViewer]);
  return isViewer ? (
    <TextButton disabled>Submit</TextButton>
  ) : (
    <Button type="submit">Submit</Button>
  );
}

동시에 실행되지 않는 코드가 교차되어서 나타나서 코드를 이해할 때 부담을 줘요.

이제 isViewerisMobile로 바꿔 보면, 1장의 D 패턴이 정확히 원문의 Before와 같은 모양임을 알 수 있다. 우리 코드로 직접 보면:

// 우리 코드 — Before: 모바일에서 절대 실행되지 않는 PC 코드가 같은 JSX에 교차
function OrderInfoRow({ orderNo }: Props) {
  const isMobile = useIsMobile();
  const isDesktop = useIsDesktop();
  return (
    <div>
      {isDesktop && <span className={styles.divider}>|</span>}
      <span>{isMobile && 'No. '}{orderNo}</span>
      <Space size={isMobile ? 16 : 24} />
      <Space size={isMobile ? 32 : 56} />
    </div>
  );
}

이런 교차가 코드베이스에 370여 곳이다.

원문의 개선은 권한별 컴포넌트로 완전히 나누는 것이다:

// 토스 원문 — After
function SubmitButton() {
  const isViewer = useRole() === "viewer";
  return isViewer ? <ViewerSubmitButton /> : <AdminSubmitButton />;
}
function ViewerSubmitButton() {
  return <TextButton disabled>Submit</TextButton>;
}
function AdminSubmitButton() {
  useEffect(() => {
    showButtonAnimation();
  }, []);
  return <Button type="submit">Submit</Button>;
}

같은 방식으로 우리 코드를 개선하면, 각 벌은 분기 없는 직선 코드가 된다:

// 우리 코드 — After: 분기는 한 곳, 각 벌은 조건 0개
function OrderInfoRow(props: Props) {
  const isMobile = useIsMobile();
  return isMobile ? <OrderInfoRowMobile {...props} /> : <OrderInfoRowDesktop {...props} />;
}
function OrderInfoRowMobile({ orderNo }: Props) {
  return (
    <div>
      <span>{'No. '}{orderNo}</span>
      <Space size={16} />
    </div>
  );
}
function OrderInfoRowDesktop({ orderNo }: Props) {
  return (
    <div>
      <span className={styles.divider}>|</span>
      <span>{orderNo}</span>
      <Space size={24} />
    </div>
  );
}

2. 시점 이동 줄이기 — 가독성

원문은 버튼 비활성화 이유 하나를 이해하려면 세 번 점프해야 하는 코드를 보여준다:

// 토스 원문 — Before: "Invite 버튼이 왜 비활성화지?" 하나에 세 번 점프
<Button disabled={!policy.canInvite}>Invite</Button>      // ① policy.canInvite는 어디서?

const policy = getPolicyByRole(user.role);                 // ② getPolicyByRole은 뭐를 하지?

function getPolicyByRole(role) {
  return { canInvite: POLICY_SET[role].includes("invite") }; // ③ POLICY_SET까지 와서야 답
}
const POLICY_SET = { admin: ["invite", "view"], viewer: ["view"] };

우리 코드의 실제 사례로 보면 더 극적이다. 행사 헤더 컴포넌트에서 공유 버튼 하나가 언제 보이는지 이해하는 경로:

// EventHeader.tsx — ① JS가 isMobile로 플로팅 버튼의 '상태'를 계산
const getFloatingPosition = (isMobile, isAtTop, isScrollingDown) => {
  if (!isMobile) return 'hidden';        // ② JS 분기: PC면 숨김…이긴 한데
  if (!isAtTop && isScrollingDown) return 'bottom';
  if (isAtTop && !isScrollingDown) return 'stackOne';
  return 'stackTwo';
};
// …
<div className={styles.staticShareArea}>{shareButton}</div>              {/* ③ 클래스 확인 */}
<div className={styles.floatingShareArea({ position })}>{shareButton}</div>
// EventHeader.css.ts — ④ 같은 버튼들의 가시성이 @media로 '또' 제어된다
export const staticShareArea = style({
  display: 'none',                       // 모바일: 정적 버튼 숨김
  '@media': { [tokens.mediaQuery.desktop]: { display: 'flex' } },
});
export const floatingShareArea = recipe({
  base: {
    position: 'fixed',
    '@media': { [tokens.mediaQuery.desktop]: { display: 'none' } },  // PC: 플로팅 통째로 숨김
  },
  variants: { position: { bottom: { bottom: 32 }, stackOne: { bottom: 64 }, stackTwo: { bottom: 112 }, hidden: { opacity: 0 } } },
});

같은 공유 버튼의 가시성이 JS(****isMobile → position 계산)와 CSS(@media display:none) 두 메커니즘으로 이중 제어된다. "PC에서 이 버튼 왜 안 보여요?"에 답하려면 단순한 질문인데도 여러코드를 왔다갔다하며 살펴보아야 한다.

3. 중복 코드 허용하기 — 결합도

원문은 여러 페이지에서 반복된다는 이유로 공통화된 훅을 보여주며 경고한다:

// 토스 원문 — 공통화된 점검 바텀시트 훅
export const useOpenMaintenanceBottomSheet = () => {
  const maintenanceBottomSheet = useMaintenanceBottomSheet();
  const logger = useLogger();
  return async (maintainingInfo: TelecomMaintenanceInfo) => {
    logger.log("점검 바텀시트 열림");
    const result = await maintenanceBottomSheet.open(maintainingInfo);
    if (result) {
      logger.log("점검 바텀시트 알림받기 클릭");
    }
    closeView();
  };
};

페이지마다 동작이 달라질 여지가 있다면, 공통화 없이 중복 코드를 허용하는 것이 더 좋은 선택이에요.

우리는 이 원칙을 반대 방향으로 어겼다. "PC와 모바일은 같은 화면"이라는 이유로 두 레이아웃을 한 파일에 공통화했는데, 실제 요청사항은 분리돼서 오고 있다.

// 우리 코드 — Before: OrderSkeleton.tsx, isMobile 삼항이 37번
export const OrderSkeleton = () => {
  const isMobile = useIsMobile();
  return (
    <>
      <Skeleton width="100%" height={isMobile ? 46 : 62} borderRadius={isMobile ? 0 : 4} />
      <Space size={isMobile ? 0 : 40} />
      <Skeleton width={isMobile ? 144 : 130} height={isMobile ? 32 : 24} />
      {!isMobile && <div className={styles.line} />}
      {isMobile ? <div className={styles.divider} /> : <Space size={48} />}
      <Skeleton width={160} height={isMobile ? 18 : 22} />
      <Space size={isMobile ? 2 : 4} />
      {/* …이런 삼항이 파일 전체에 37번 */}
    </>
  );
};

두 개의 완전히 다른 스켈레톤 레이아웃을 "중복 없이" 한 함수에 담으려다 보니 생긴 결과다. 중복을 허용하면 각 파일은 조건 없는 직선 코드가 된다:

// 우리 코드 — After: 레이아웃 중복을 허용하고 두 벌로
export const OrderSkeleton = () => (
  <>
    <MobileView><OrderSkeletonMobile /></MobileView>
    <DesktopView><OrderSkeletonDesktop /></DesktopView>
  </>
);
function OrderSkeletonMobile() {
  return (
    <>
      <Skeleton width="100%" height={46} borderRadius={0} />
      <Skeleton width={144} height={32} />
      <div className={styles.divider} />
      {/* 조건 분기 0개 — 위에서 아래로 그냥 읽힌다 */}
    </>
  );
}
// OrderSkeletonDesktop도 동일 — 37개의 삼항이 사라지고 단순한 목록 둘이 남는다

2장의 500여 줄·@media 26개 파일도 같은 이야기다. 모바일 시안 수정이 PC를 깨뜨릴 수 있는 구조, 원문이 말한 "공통 코드를 수정할 때마다 의존하는 코드를 일일이 테스트해야 하는" 상태다.

4. 숨은 로직 드러내기 — 예측 가능성

원문은 잔액 조회 함수 안에 몰래 들어간 로깅을 예로 든다:

// 토스 원문 — Before
async function fetchBalance(): Promise<number> {
  const balance = await http.get<number>("...");
  logging.log("balance_fetched");   // 이름·반환 타입으로 예측할 수 없는 숨은 동작
  return balance;
}

함수의 이름과 파라미터, 반환 타입으로 예측할 수 있는 로직만 구현 부분에 남기세요.

우리 코드에서 이 냄새는 컴포넌트 안에 숨은 디바이스 로직으로 나타난다. 이름과 시그니처만 봐서는 알 수 없는데, 내부에서 스스로를 숨기거나 사라진다:

// 우리 코드 — Before ①: 이름만 보면 항상 렌더될 것 같은 섹션
export const RecentOrdersSection = () => {
  const isMobile = useIsMobile();
  const { data, isLoading } = useQuery(ordersQuery);
  const orders = data?.orders ?? [];
  // 모바일 + 주문 없음 → 섹션이 통째로 사라진다. 호출부는 알 길이 없다
  if (isLoading || (isMobile && !orders.length)) return null;
  return <section></section>;
};
// 우리 코드 — Before ②: JSX에는 항상 있는데, 모바일에서는 CSS가 몰래 숨긴다
// SectionTitle.css.ts
export const title = style({
  display: 'none', // 기본(모바일): 숨김 — tsx만 읽는 사람은 알 수 없다
  '@media': { [tokens.mediaQuery.desktop]: { display: 'block' } },
});

"모바일에서 이 제목 왜 안 보여요?"라는 질문에 tsx는 답을 주지 못한다 — 숨은 로직이 CSS에 있다. 개선은 원문과 같다. 숨김을 구현 안쪽이 아니라 이름과 호출부에 드러낸다:

// 우리 코드 — After: 조건은 호출부로, 디바이스 전용임은 래퍼/이름으로
{(!isMobile || orders.length > 0) && <RecentOrdersSection orders={orders} />}

<DesktopView>
  <SectionTitle>Order Details</SectionTitle>  {/* CSS의 몰래 display:none 대신 */}
</DesktopView>

정리

클린코드는 여전히 AI가 짜준 코드를 사람이 리뷰해야된다는 점에서 중요한 부분이라고 생각한다. 우리가 하나의 방향성을 설립하기 위한 아주 중요한 기준점이 되어줄 것이다.

기준 원칙 우리 증상
가독성 같이 실행되지 않는 코드 분리 isMobile ? A : B 교차 370여 곳 — 한 함수에 두 디바이스의 코드가 섞임
가독성 시점 이동 줄이기 tsx ↔ css.ts ↔ 토큰, 파일 경계를 넘는 왕복이 기본 독해 코스
결합도 중복 코드 허용하기 디자인부터 두 벌인 레이아웃을 한 파일에 결합 → 500여 줄/@media 26개
예측 가능성 숨은 로직 드러내기 컴포넌트가 내부에서 디바이스 조건으로 스스로 숨음

다른 유명 이커머스들은 어떻게 하고 있을까

위에서는 클린코드라는 기준점으로 우리 코드에 문제점을 살펴보았고,

다른 곳에서는 어떤식으로 구현하고 있을지 힌트를 얻기위해 fable에게 유명 이커머스 사이트들을 조사해보라고 했다.

1. 상품 리스트 실측

사이트 스택 구현 결정적 근거
29CM React + Tailwind 🟢 미디어쿼리 반응형 (단일 클래스) grid grid-cols-3 desktop:grid-cols-5 — 동일 DOM·동일 클래스가 3열↔5열
오늘의집 홈 React SSR + Emotion 🟢 CSS 미디어쿼리 + 국소 이중렌더 media rule 171개(768·1024·1256px + hover:hover 30회). stats-mobile/stats-pc 쌍만 토글
오늘의집 스토어 Next.js + Emotion 🟠 JS(matchMedia) 분기 랭킹 그리드: 375px iframe엔 해당 rule이 아예 없음, @media 0개인데 뷰포트 반응 = 다른 트리 렌더
컬리 Next.js + Emotion (사실상 CSR) 🔴 user agent adaptive — PC 고정폭 375/1280 iframe 모두 3열·카드 249px 동일. main 1050px 고정. "하나의 URL"의 실체는 user agent 분기
무신사 Next.js + styled-components 🔴 user agent adaptive (fluid) 모바일 단일 레이아웃의 유동 확장(min 744/max 1440). 2024년 PC 웹 종료와 일관

2. 홈 화면 실측 — 구조가 크게 갈라지는 화면

상품 리스트가 "열 수만 바뀌는" 가벼운 케이스라면, 홈은 헤더·GNB·하단탭바·섹션 구성까지 통째로 갈라진다.

사이트 분기 메커니즘 페이지 레이아웃 헤더·GNB 하단 탭바 / 부분 UI SSR 상태
29CM 🟢 뷰포트 + CSS 토글 매거진형 3열(PC) ↔ 단일 히어로(MO) PC GNB·모바일 탭바 둘 다 DOM에 존재 — hidden/desktop:flex 대칭 토글 하단 탭바는 desktop:hidden, 앱 웹뷰는 webview:hidden으로 또 숨김 두 벌 모두 서버 렌더
오늘의집 홈 🟢 CSS 미디어쿼리 (단일 DOM) 단일 DOM 재배치 — main 섹션 38개 동일 같은 헤더 트리가 두 폭에서 재배치 가로 스크롤 섹션 모바일 4 vs PC 1 — 캐러셀화도 CSS로 SSR 322KB, 디바이스 마커 0
컬리 🔴 user agent adaptive 387px 뷰포트에서도 PC(1080px+)가 그대로 잘려 렌더 PC 한 벌만 존재 해당 없음 SSR 쉘 15KB (사실상 CSR)
무신사 🔴 user agent adaptive (fluid) DOM 사실상 동일(2,876 vs 2,880 노드), 폭만 유동(744~1440) GNB 동일 — 트래킹 메타데이터부터 pc_gnb 가로 스크롤 캐러셀 5개 동일 SSR에 device 마커 존재

조사 결과

조사 결과를 한 줄로 줄이면: 모두가 다르게 하고 있다. 순수 미디어쿼리 반응형(쿠팡·29CM)부터 JS 분기(오늘의집 스토어), user agent adaptive(컴리·무신사), 도메인 분리(올리브영 국내)까지 전부 현역에서 돌아간다.

모두가 다른 선택을 한 이유는 아래와 같은 각각의 트레이드 오프가 있기에 그럴 것 같다.

방식 장점 단점 채택 사례
① CSS 미디어쿼리 (단일 DOM) SSR-safe (서버는 디바이스를 몰라도 됨) · zero-runtime · 리사이즈 즉시 대응 DOM 구조가 크게 다르면 표현 한계 · 셀렉터마다 분기 누적 시 복잡도 폭발 쿠팡 · 29CM · 오늘의집 홈 · 우리 상품 리스트
② display:none 이중 렌더 구조가 다른 UI도 CSS만으로 분기 · 구현 단순 DOM·번들 이중 전송 · SEO 중복 콘텐츠 · 한 화면치 이상이면 낭비 큼 (접근성은 display:none이면 안전 — 5-4) 29CM GNB · 오늘의집 국소 조각 · 우리 헤더/주문리스트
③ JS 분기 (matchMedia 훅) DOM 한 벌만 렌더 · 로직으로 명확히 표현 SSR 초기값 문제 → hydration mismatch 또는 2-pass 렌더(느림) · 판정 전 깜빡임/CLS 오늘의집 스토어 · 우리 useIsMobile 374회
④ 서버 User Agent 감지 첫 렌더부터 정확 · 반대편 코드 미전송(번들 -38.7% 실측 사례) · hydration 안전 뷰포트 리사이즈 미대응 · 태블릿/신형 user agent 판정 리스크 마켓컬리 · 무신사
⑤ 컴포넌트/라우트 분리 파일=시안 1:1 · 병렬 작업 가능 · 각 파일은 @media 없는 단일 레이아웃 분기점 설계 필요(SSR이면 ④와 결합해야 완전) · 공통 로직/카피 이중 수정 리스크 우리 DesktopHeader/MobileHeader · velog 라우트 분리 실측

반응형 구현에서 반드시 고려해야 할 것들

어떤 방식으로 개발할지 접근하려면, 뭐를 고르든 반드시 부딪히는 문제들부터 알아야 판단이 설 것 같았다. 그래서 최소한 이것들은 먼저 조사해봤다.

1. 번들 사이즈

미디어쿼리·이중렌더 방식은 모바일 유저도 PC 코드(그 역도)를 전부 다운로드한다.

여기서 중요한 구분 하나. 서버 user agent "감지"만으로는 번들이 줄지 않는다. 우리 앱도 user agent를 이미 쓰고 있지만(앱 웹뷰 판별, 봇 판별로 SSR 스트리밍 전략 선택) 어느 것도 번들과 연결되어 있지 않다. 번들 구성은 빌드 타임의 정적 import 그래프로 결정되므로, 서버가 디바이스를 알아내도 "어떤 JS를 보낼지"가 갈라지지 않으면 그대로다. 감지 + 서빙 분리(라우트 분리 또는 dynamic import)가 결합돼야 줄어든다.

2. SEO

  • 구글은 모바일 우선 인덱싱 — 모바일 DOM이 콘텐츠의 완전판이어야 한다.
  • display:none 이중 렌더는 중복 콘텐츠를 만든다. 숨긴 콘텐츠도 크롤러는 읽지만, 두 벌 관리가 어긋나면 어느 쪽이 정본인지 모호해진다.
  • user agent는 구글봇 user agent(모바일/데스크톱 두 종)에 서로 다른 HTML을 주게 되므로 클로킹으로 오해받지 않게 캐싱 관리가 필요하다.

3. Hydration 불일치

SSR에서 JS 분기(③)를 쓰면 반드시 부딪힌다. 서버는 디바이스를 모르므로 초기값을 고정하는데(우리는 desktop), 클라이언트 판정과 다르면 React는 mismatch를 낸다. 안전하게 우회하는 2-pass 렌더(isHydrated 게이트)는 그 대신 깜빡임과 CLS를 산다. 우리 푸터가 정확히 이 비용을 지불하고 있다:

// 푸터 앱 다운로드 섹션 — hydration 후에야 등장 → 늦은 팝인, CLS
{isHydrated && platform !== 'APP' && (
  <>
    <div className={styles.appBannerDesktop}>…QR코드…</div>
    <div className={styles.appBannerMobile}>…스토어 배지…</div>
  </>
)}

앞으로의 계획

단순한 반응형 개발이 아닌 pc/mobile 두가지 버전을 같은 레포로 개발해보는 것은 처음이라, 입사했을 시점부터 고민이 많았다. 평소에 해오던 클린코드 방식과는 거리가 멀긴한데, 경험이 없어서 정답은 모르겠어서 쉽게 주장하기 어려웠다.

그래서 이번 글에서는 클린코드 관점과 다른 곳에서는 어떻게 하고 있나를 분석해서 기준점을 만들어보았다.

오픈하고 조금 여유가 생긴 우리 팀에서 꼭 해결하고 갔으면해서 팀원들에게 안건을 열심히 제출 중이다. 결론이 나면 또 업데이트를 해보겠다..!

# React# 반응형# 클린코드
여진석

커머스 도메인에서 제품과 팀의 개발 방식을 함께 개선하는 프론트엔드 개발자입니다.