- Published on
- reading time
- 18분
Next.js 앱 라우터와 Tailwind CSS로 적응형 UI 아키텍처 구축하기
- Authors
- Name
- Heesu Choi
- dqdq4197@gmail.com
- Github
- dqdq4197
모던 웹 프로젝트에서 반응형(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]/
동적 경로가 폴백 역할을 수행하는 구조다.
이 구조는 두 가지 시나리오를 매우 효과적으로 처리할 수 있다.
- 복잡한 UI 분기: 모바일과 PC의 페이지가 완전히 다르다면,
pc/page.tsx
와[device]/page.tsx
에 각각 다른 컴포넌트를 구현하여. 라우팅 레벨에서부터 완벽한 분리가 이루어진다. - 단순한 스타일 분기: 대부분의 구조는 공유하고 스타일만 일부 다르다면,
[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
예시 코드:
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 라우터는 다음과 같이 동작하게 된다.
app/pc/some-page
경로에page.tsx
가 존재하면, 해당 페이지와 그 상위의app/pc/layout.tsx
가 렌더링된다. 이 경우[device]
폴더는 전혀 사용되지 않는다.- 만약
app/pc/some-page
경로가 없다면,app/[device]/some-page
동적 라우트와 매칭을 시도한다. 매칭에 성공하면app/[device]/layout.tsx
와app/[device]/some-page/page.tsx
가 렌더링된다.
바로 이 두 번째 경우에, 재작성된 URL의 pc
부분이 동적 세그먼트 [device]
의 값으로 인식되어, 레이아웃의 params.device
로 전달된다. 우리는 이 값을 이용해 [device]/layout.tsx
에서 동적으로 클래스를 주입할 수 있다.
pc:
와 mobile:
접두사 활성화를 위한 Tailwind CSS 설정
3. 다음과 같이 간단한 설정을 추가하여, 특정 클래스(pc 또는 mobile) 하위에 있는 엘리먼트에만 적용되는 커스텀 변형(variant)을 만들 수 있다.
Tailwind CSS 4버전 미만 설정 예시:
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버전 이상 설정 예시:
@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 트리 최상단에 위치하게 된다. 따라서 부모로부터 pc
나 mobile
클래스를 상속받지 못해 디바이스별 스타일이 동작하지 않는 문제가 발생한다.
<body>
와 Portal을 위한 클라이언트 사이드 해법
이 문제를 해결하려면 결국 <body>
태그 자체에 디바이스 클래스를 직접 추가해야 한다. 하지만 <body>
태그는 디바이스별 세그먼트보다 상위인 최상위 RootLayout(app/layout.tsx)
에 있어 서버 렌더링 시점에 디바이스 타입을 알기 어렵다.
NOTE
미들웨어에서 판별한 디바이스 타입을 요청 헤더에 담아 전달하는 방법은 어떨까?
RootLayout
에서 next/headers
의 headers()
함수로 이 값을 읽어올 수 있다. 그러나 이 방식은 치명적인 제약으로 이어질 수 있다. headers()
와 같은 동적 함수를 사용하는 순간, 해당 경로는 정적 사이트 생성(SSG)이 비활성화되고 모든 요청에 대해 동적으로 렌더링되는 제약이 있어, 적절한 대안으로 보기는 어렵다.
그렇다면 더 나은 대안은 어떤게 있을까? 먼저 이 문제가 어떤 성격을 가진지 살펴보자.
핵심은 Portal
이나 document.body.appendChild
같은 방식이 명백한 클라이언트 사이드 작업이라는 점이다.
예를 들어, radix-ui의 Portal 컴포넌트 구현을 보면 useState
나 useLayoutEffect
같은 훅을 사용하여 컴포넌트가 브라우저에 마운트된 뒤에만 동작하도록 설계되어 있다. 또한 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 아키텍처를 소개했다.
이 아키텍처는 단순히 디바이스별 화면 분기뿐 아니라, 어떻게 하면 잘 분리하고 함께 관리할 수 있을지 구조를 고민한 결과물이다.
적용 후 복잡한 디바이스별 코드 관리와 분기 작업에 대한 스트레스가 크게 줄었고, 팀원들부터도 좋은 피드백을 받았다. 🙂
혹시 비슷한 고민이 있다면, 이번 글에서 소개한 방법을 한 번 적용해 보는걸 추천한다!