Published on
·
reading time
18분

Next.js 앱 라우터와 Tailwind CSS로 적응형 UI 아키텍처 구축하기

Authors

모던 웹 프로젝트에서 반응형(Responsive) 또는 적응형(Adaptive) 디자인의 중요성은 날로 커지고 있다. 하지만 모든 디바이스를 동일한 컴포넌트와 단순한 CSS 미디어 쿼리만으로 대응하기에는, 점점 복잡해지는 UI/UX 요구사항을 만족시키기 어렵다. 특히 모바일과 PC 환경에서 완전히 다른 사용자 경험을 제공해야 할 때, 코드 구조는 금세 복잡해지고 유지보수 비용도 급격히 늘어난다.

복잡해질 수 있는 코드 구조와 어려운 유지보수를 대비하려면, 처음부터 견고하고 유연한 프로젝트 구조를 설계하는 것이 중요하다. 이번 글에서는 내가 실무에서 직접 고민하고 구현한 사례를 바탕으로, Next.js의 앱 라우터(App Router)Tailwind CSS를 조합해 단순한 스타일 분기부터 완전히 다른 컴포넌트 렌더링까지 유연하게 대응할 수 있는 확장 가능한 적응형(Adaptive) 디자인 아키텍처를 소개할 계획이다. 이 구조를 통해 개발자는 직관적으로 디바이스별 UI를 관리할 수 있으며, 생산성과 유지보수성을 모두 크게 향상시킬 수 있을 것으로 기대한다.

이 아키텍처의 핵심 목표:

  • 유연성: 간단한 스타일 변경부터 페이지 전체의 구조 변경까지 모두 커버
  • 직관성: 폴더 구조를 통한 라우팅 우선순위로 디바이스별 코드를 명확하게 분리
  • 개발자 경험(DX): pc:, mobile: 같은 직관적인 Tailwind CSS 커스텀 variant로 스타일링을 간편화
  • 성능: Next.js의 SSG(정적 사이트 생성) 이점을 최대한 유지

1. 폴더 구조를 활용한 파일 시스템 기반 라우팅으로 디바이스별 분기 처리

소개할 아키텍처의 핵심은 Next.js 앱 라우터의 동적 세그먼트를 활용한 폴더 구조에 있다.
app 디렉터리의 아래에 다음과 같이 구성한다.

app/
├── pc/
│   ├── layout.tsx
│   └── page.tsx
├── [device]/
│   ├── layout.tsx
│   └── page.tsx
├── layout.tsx  // Root Layout
└── ...

pc/ - PC 전용 페이지 및 컴포넌트

PC 사용자에게 최적화된 UI와 기능을 담당하는 폴더이다. 사용자가domain.com/pc/ 경로로 접근했을 때 app/pc/ 아래 page.tsx가 존재하면 이 경로의 페이지가 우선적으로 사용된다.

[device]/ — 모바일 퍼스트 공통 UI 및 폴백 라우트

모바일 기반 UI를 중심으로 구성된 라우트 폴더이다. 모바일 퍼스트(Mobile First) 전략에 따라 기본적으로 모바일 환경에 최적화된 화면과 기능을 제공하는 공간이다. 하지만 꼭 모바일만을 위한 라우트만 있는 것은 아니다. 디바이스별로 아주 사소하거나 간단한 스타일·UI 분기가 필요할 때, PC 전용 라우트(pc/)로 완전히 분리하지 않고 이 [device]/ 라우트를 활용할 수 있다.

결과적으로 [device]/는 모바일을 기본으로 하면서도, 디바이스별 간단한 분기와 폴백 역할을 동시에 수행하는 핵심 라우트 폴더이다.

TIP

예를 들어, app/pc/mypage 경로가 없더라도, app/[device]/mypage 경로에 page.tsx가 존재하면 PC 사용자도 app/[device]/mypage의 페이지로 라우팅된다.

app/
├── pc/
│   └── ...
├── [device]/
│   ├── ...
│   └── mypage/
|       └── page.tsx
└── ...

이는 Next.js 앱 라우터의 동적 세그먼트가 폴더 우선순위에 따라 라우트를 매칭하기 때문이며, pc/ 폴더에 대응하는 페이지가 없을 때 [device]/ 동적 경로가 폴백 역할을 수행하는 구조다.

이 구조는 두 가지 시나리오를 매우 효과적으로 처리할 수 있다.

  1. 복잡한 UI 분기: 모바일과 PC의 페이지가 완전히 다르다면, pc/page.tsx[device]/page.tsx에 각각 다른 컴포넌트를 구현하여. 라우팅 레벨에서부터 완벽한 분리가 이루어진다.
  2. 단순한 스타일 분기: 대부분의 구조는 공유하고 스타일만 일부 다르다면, [device]/page.tsx 하나만으로 양쪽 디바이스를 모두 처리한다. 이 경우, 모바일 스타일을 기본으로 작성하고 PC 전용 스타일은 다음에 소개할 Tailwind CSS의 pc: 접두사를 활용해 추가하는 방식을 사용할 수 있다.

이 외에도, 특정 디바이스에 맞춘 별도의 폴더(tablet/ 등)를 추가해 상황에 맞게 세분화하고 구조를 유연하게 확장하는 것도 가능하다.


2. Middleware를 이용한 자동 경로 재작성(Rewrite)

이제 사용자의 디바이스 종류에 따라 pc/ 또는 mobile/ 경로로 보내주는 작업이 필요하다. 이 역할을 바로 미들웨어(middleware)가 수행한다.

TIP

왜 하필 미들웨어일까?
Next.js의 미들웨어는 서버에서 해당 경로가 렌더링되기 전에 실행된다. 즉, 사용자의 요청을 서버 단에서 가로채 경로를 미리 재작성(rewrite)하므로, 클라이언트에서 디바이스를 판별해 리다이렉트할 때 발생하는 화면 깜빡임이나 레이아웃 변경 현상을 완전히 방지할 수 있다.

사용자가 웹사이트에 접속하면, 서버는 먼저 요청(request) 헤더의 user-agent를 통해 사용자가 모바일인지 PC인지 판별한다. 그리고 URL에 디바이스 타입을 추가하여 내부적으로 mobile/ 또는 pc/ 경로로 재작성(rewrite)한다.

결과적으로, 사용자의 브라우저 주소창에는 domain.com/some-page가 표시되지만, Next.js 서버는 실제로는 domain.com/pc/some-page 또는 domain.com/mobile/some-page 경로에 해당하는 콘텐츠를 렌더링하는 방식이다.

middleware.ts 예시 코드:

src/middleware.ts
import { NextRequest, NextResponse, userAgent } from 'next/server'

enum DeviceType {
  PC = 'pc',
  MOBILE = 'mobile',
}

function getDeviceType(request: NextRequest): DeviceType {
  const { device } = userAgent({ headers: request.headers })
  const isMobile = device.type === 'mobile'

  return isMobile ? DeviceType.MOBILE : DeviceType.PC
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  const deviceType = getDeviceType(request)
  const pathPrefix = `/${deviceType}`

  request.nextUrl.pathname = `${pathPrefix}${pathname}`

  return NextResponse.rewrite(request.nextUrl)
}

미들웨어가 URL을 domain.com/pc/some-page로 재작성했을 때, Next.js 라우터는 다음과 같이 동작하게 된다.

  1. app/pc/some-page 경로에 page.tsx가 존재하면, 해당 페이지와 그 상위의 app/pc/layout.tsx가 렌더링된다. 이 경우 [device]폴더는 전혀 사용되지 않는다.
  2. 만약 app/pc/some-page 경로가 없다면, app/[device]/some-page 동적 라우트와 매칭을 시도한다. 매칭에 성공하면 app/[device]/layout.tsxapp/[device]/some-page/page.tsx가 렌더링된다.

바로 이 두 번째 경우에, 재작성된 URL의 pc부분이 동적 세그먼트 [device]의 값으로 인식되어, 레이아웃의 params.device로 전달된다. 우리는 이 값을 이용해 [device]/layout.tsx에서 동적으로 클래스를 주입할 수 있다.


3. pc:mobile: 접두사 활성화를 위한 Tailwind CSS 설정

다음과 같이 간단한 설정을 추가하여, 특정 클래스(pc 또는 mobile) 하위에 있는 엘리먼트에만 적용되는 커스텀 변형(variant)을 만들 수 있다.

Tailwind CSS 4버전 미만 설정 예시:

tailwind.config.ts
import type { Config } from 'tailwindcss'
import type { PluginCreator } from 'tailwindcss/types/config'

const config: Partial<Config> = {
  plugins: [
    function ({ addVariant }) {
      // .pc 클래스가 있는 요소 자신과 자손 모두에 적용되는 variant 추가
      addVariant('pc', '.pc&, .pc &')

      // .mobile 클래스가 있는 요소 자신과 자손 모두에 적용되는 variant 추가
      addVariant('mobile', '.mobile&, .mobile &')
    } satisfies PluginCreator,
  ],
}

export default config

Tailwind CSS 4버전 이상 설정 예시:

tailwind.css
@import 'tailwindcss';

/* .pc 클래스가 있는 요소 자신과 자손 모두에 적용되는 variant 추가 */
@custom-variant pc (&:where(.pc, .pc *));

/* .mobile 클래스가 있는 요소 자신과 자손 모두에 적용되는 variant 추가 */
@custom-variant mobile (&:where(.mobile, .mobile *));

이제 pc/[device]/의 루트 레이아웃 요소에 각각 className을 추가하기만 하면, 다음과 같이 pc:mobile: 접두사를 활용해 조건부 스타일을 매우 손쉽게 작성할 수 있다.

<div className="pc:text-lg mobile:text-sm text-base">
  이 텍스트는 PC에서는 크게, 모바일에서는 작게 보인다.
</div>

4. Layout과 Script를 이용한 클래스 주입

이전에 설정한 Tailwind CSS 접두사가 동작하려면, 상위 DOM 어딘가에 pc 또는 mobile 클래스가 반드시 존재해야 한다. 이 클래스는 각 디바이스별 루트 레이아웃 파일인 layout.tsx에서 주입한다.

app/pc/layout.tsx - 정적 클래스 주입

interface Props {
  children: React.ReactNode
}

function PcLayout(props: Props) {
  const { children } = props

  return <div className="pc">{children}</div>
}

export default PcLayout

app/[device]/layout.tsx - 동적 클래스 주입

interface Props {
  params: { device: string }
}

function DeviceLayout(props: PropsWithChildren<Props>) {
  const { params, children } = props
  const { device } = params

  // params.device 값을 클래스 이름으로 동적 할당 (e.g., 'pc' or 'mobile')
  return <div className={device}>{children}</div>
}

export default DeviceLayout

그런데 여기서 한 가지 까다로운 문제를 마주할 수 있다. 만약 모달, 토스트 팝업처럼 React Portal을 사용해 UI를 <body> 태그 직속으로 렌더링하는 경우이다.

Portal로 생성된 엘리먼트는 우리가 레이아웃에 만든 <div className="pc"> 래퍼의 바깥, 즉 DOM 트리 최상단에 위치하게 된다. 따라서 부모로부터 pcmobile 클래스를 상속받지 못해 디바이스별 스타일이 동작하지 않는 문제가 발생한다.

<body> 와 Portal을 위한 클라이언트 사이드 해법

이 문제를 해결하려면 결국 <body> 태그 자체에 디바이스 클래스를 직접 추가해야 한다. 하지만 <body> 태그는 디바이스별 세그먼트보다 상위인 최상위 RootLayout(app/layout.tsx)에 있어 서버 렌더링 시점에 디바이스 타입을 알기 어렵다.

NOTE

미들웨어에서 판별한 디바이스 타입을 요청 헤더에 담아 전달하는 방법은 어떨까?

RootLayout에서 next/headersheaders() 함수로 이 값을 읽어올 수 있다. 그러나 이 방식은 치명적인 제약으로 이어질 수 있다. headers()와 같은 동적 함수를 사용하는 순간, 해당 경로는 정적 사이트 생성(SSG)이 비활성화되고 모든 요청에 대해 동적으로 렌더링되는 제약이 있어, 적절한 대안으로 보기는 어렵다.

그렇다면 더 나은 대안은 어떤게 있을까? 먼저 이 문제가 어떤 성격을 가진지 살펴보자.
핵심은 Portal이나 document.body.appendChild 같은 방식이 명백한 클라이언트 사이드 작업이라는 점이다.

예를 들어, radix-ui의 Portal 컴포넌트 구현을 보면 useStateuseLayoutEffect 같은 훅을 사용하여 컴포넌트가 브라우저에 마운트된 뒤에만 동작하도록 설계되어 있다. 또한 document 객체는 서버 환경에는 존재하지 않는다. 즉, <body> 바로 아래에 동적으로 추가되는 UI 요소들은 모두 클라이언트에서 렌더링된다고 볼 수 있다.

따라서 서버 렌더링의 이점인 정적 사이트 생성을 해치지 않으면서, 이 문제는 클라이언트에서 해결하는 것이 바람직하다. 여러 가지 방법이 있지만, 그 중 하나는 Next.js의 <Script> 컴포넌트를 활용해 클라이언트에서 <body> 태그에 직접 클래스를 주입하는 방식이다.

이 방법은 서버에서 생성된 정적 HTML에 영향을 주지 않으며, 페이지가 사용자에게 표시된 후 브라우저에서 스크립트를 실행해 클래스를 추가한다. 결과적으로 SSG를 비활성화하는 부작용 없이, 오직 클라이언트에서만 발생하는 스타일링 문제를 정확하고 효율적으로 해결할 수 있다.

app/pc/layout.tsx에 추가:

interface Props {
  children: React.ReactNode
}

function PcLayout(props: Props) {
  const { children } = props

  return (
    <div className="pc">
      {children}
+     <Script
+       id="add-device-type-scope-class-to-body"
+       dangerouslySetInnerHTML={{
+         __html: `document.body.classList.add("pc")`,
+       }}
+     />
    </div>
  )
}

export default PcLayout

app/[device]/layout.tsx에 추가:

import Script from 'next/script'
import type { PropsWithChildren } from 'react'

interface Props {
  params: { device: string }
}

function DeviceLayout(props: PropsWithChildren<Props>) {
  const { params, children } = props
  const { device } = params

  return (
    <div className={device}>
      {children}
+     <Script
+       id="add-device-type-scope-class-to-body"
+       dangerouslySetInnerHTML={{
+         __html: `document.body.classList.add("${device}")`,
+       }}
+     />
    </div>
  )
}

export default DeviceLayout

마무리

이번 글에서는 Next.js 앱 라우터와 Tailwind CSS의 장점 살려 확장 가능하고 유지보수하기 쉬운 적응형 UI 아키텍처를 소개했다.

이 아키텍처는 단순히 디바이스별 화면 분기뿐 아니라, 어떻게 하면 잘 분리하고 함께 관리할 수 있을지 구조를 고민한 결과물이다.

적용 후 복잡한 디바이스별 코드 관리와 분기 작업에 대한 스트레스가 크게 줄었고, 팀원들부터도 좋은 피드백을 받았다. 🙂

혹시 비슷한 고민이 있다면, 이번 글에서 소개한 방법을 한 번 적용해 보는걸 추천한다!