03. Myblog 학습정리: 기본 UI 컴포넌트 및 레이아웃 시스템

작성일: 2025년 12월 3일 오전 06:29(마지막 수정: 2025년 12월 10일 오전 07:45)
조회수: 86

UXUI.png

Phase 3 학습정리: 기본 UI 컴포넌트 및 레이아웃 시스템

개요

Phase 3에서는 사용자 경험의 기반이 되는 UI 컴포넌트 시스템을 구축했습니다. shadcn/ui와 Tailwind CSS를 활용하여 일관성 있고 접근 가능한 디자인 시스템을 완성했으며, 반응형 레이아웃과 테마 시스템을 통해 현대적인 웹 애플리케이션의 사용자 인터페이스를 구현했습니다.

특히 컴포넌트 기반 아키텍처재사용 가능한 디자인 패턴을 통해 확장성과 유지보수성을 확보했으며, Phase 1-2에서 구축한 기술 스택 위에 완전한 사용자 인터페이스 레이어를 완성했습니다.


핵심 학습 내용

1. 컴포넌트 기반 레이아웃 아키텍처

계층적 레이아웃 시스템 설계

레이아웃 컴포넌트 구조:

typescript
// src/components/layout/Layout.tsx - 메인 레이아웃 컨테이너
import { Header } from './Header';
import { Footer } from './Footer';

interface LayoutProps {
    children: React.ReactNode;
}

export function Layout({ children }: LayoutProps) {
    return (
        <div className="flex min-h-screen flex-col">
            <Header />
            <main className="flex-1">
                {children}
            </main>
            <Footer />
        </div>
    );
}

학습 포인트:

  • Flexbox 레이아웃: flex min-h-screen flex-col로 전체 화면 높이 활용
  • 유연한 메인 영역: flex-1로 헤더/푸터 제외한 모든 공간 활용
  • 컴포넌트 합성: Header, Footer 컴포넌트를 조합한 완전한 레이아웃
  • 타입 안전성: LayoutProps 인터페이스로 children prop 타입 보장

반응형 헤더 컴포넌트 구현

typescript
// src/components/layout/Header.tsx - 반응형 네비게이션
'use client';

import Link from 'next/link';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
    DropdownMenu,
    DropdownMenuContent,
    DropdownMenuItem,
    DropdownMenuSeparator,
    DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';

export function Header() {
    const { user, isAuthenticated, isAdmin, signOut, isLoading } = useAuth();

    return (
        <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
            <div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
                {/* 로고 및 메인 네비게이션 */}
                <div className="flex items-center space-x-6">
                    <Link href="/" className="flex items-center space-x-2">
                        <span className="text-xl font-bold">MyBlog</span>
                    </Link>

                    <nav className="hidden md:flex items-center space-x-6">
                        <Link
                            href="/posts"
                            className="text-sm font-medium transition-colors hover:text-primary"
                        >
                            글 목록
                        </Link>
                        <Link
                            href="/about"
                            className="text-sm font-medium transition-colors hover:text-primary"
                        >
                            소개
                        </Link>
                        {isAdmin && (
                            <Link
                                href="/admin"
                                className="text-sm font-medium text-primary transition-colors hover:text-primary/80"
                            >
                                관리자
                            </Link>
                        )}
                    </nav>
                </div>

                {/* 사용자 액션 영역 */}
                <div className="flex items-center space-x-4">
                    <ThemeToggle />

                    {isLoading ? (
                        <div className="h-8 w-8 animate-pulse rounded-full bg-muted" />
                    ) : isAuthenticated && user ? (
                        <DropdownMenu>
                            <DropdownMenuTrigger asChild>
                                <Button variant="ghost" className="relative h-8 w-8 rounded-full">
                                    <Avatar className="h-8 w-8">
                                        <AvatarImage
                                            src={user.avatar_url || undefined}
                                            alt={user.full_name || user.email}
                                        />
                                        <AvatarFallback>
                                            {user.full_name?.charAt(0) || user.email.charAt(0)}
                                        </AvatarFallback>
                                    </Avatar>
                                </Button>
                            </DropdownMenuTrigger>
                            <DropdownMenuContent className="w-56" align="end" forceMount>
                                <div className="flex items-center justify-start gap-2 p-2">
                                    <div className="flex flex-col space-y-1 leading-none">
                                        {user.full_name && (
                                            <p className="font-medium">{user.full_name}</p>
                                        )}
                                        <p className="w-[200px] truncate text-sm text-muted-foreground">
                                            {user.email}
                                        </p>
                                    </div>
                                </div>
                                <DropdownMenuSeparator />
                                <DropdownMenuItem asChild>
                                    <Link href="/profile">프로필</Link>
                                </DropdownMenuItem>
                                {isAdmin && (
                                    <DropdownMenuItem asChild>
                                        <Link href="/admin/posts">글 관리</Link>
                                    </DropdownMenuItem>
                                )}
                                <DropdownMenuSeparator />
                                <DropdownMenuItem
                                    onClick={() => signOut()}
                                    className="text-red-600 focus:text-red-600"
                                >
                                    로그아웃
                                </DropdownMenuItem>
                            </DropdownMenuContent>
                        </DropdownMenu>
                    ) : (
                        <Button asChild>
                            <Link href="/auth/login">로그인</Link>
                        </Button>
                    )}
                </div>
            </div>
        </header>
    );
}

학습한 핵심 개념:

  • Sticky 헤더: sticky top-0 z-50으로 스크롤 시에도 상단 고정
  • 백드롭 블러: backdrop-blur로 현대적인 반투명 효과
  • 조건부 렌더링: 인증 상태와 권한에 따른 동적 UI 구성
  • 접근성: ARIA 속성과 키보드 네비게이션 지원
  • 반응형 디자인: hidden md:flex로 화면 크기별 메뉴 표시/숨김

푸터 컴포넌트와 소셜 링크

typescript
// src/components/layout/Footer.tsx - 반응형 푸터
import Link from 'next/link';

export function Footer() {
    return (
        <footer className="border-t bg-background">
            <div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
                <div className="flex flex-col items-center justify-between space-y-4 sm:flex-row sm:space-y-0">
                    <div className="text-center text-sm text-muted-foreground sm:text-left">
                        © 2024 MyBlog. All rights reserved.
                    </div>

                    <div className="flex items-center space-x-4">
                        <Link
                            href="https://github.com/yourusername"
                            className="text-muted-foreground transition-colors hover:text-foreground"
                            target="_blank"
                            rel="noopener noreferrer"
                        >
                            <span className="sr-only">GitHub</span>
                            <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
                                <path d="M12 0C5.374 0 0 5.373 0 12 0 17.302 3.438 21.8 8.207 23.387c.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/>
                            </svg>
                        </Link>

                        <Link
                            href="mailto:your.email@example.com"
                            className="text-muted-foreground transition-colors hover:text-foreground"
                        >
                            <span className="sr-only">Email</span>
                            <svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
                            </svg>
                        </Link>
                    </div>
                </div>
            </div>
        </footer>
    );
}

학습 포인트:

  • 반응형 레이아웃: flex-col sm:flex-row로 모바일/데스크탑 레이아웃 분기
  • 접근성: sr-only 클래스로 스크린 리더 지원
  • 외부 링크 보안: rel="noopener noreferrer"로 보안 강화
  • SVG 아이콘: 벡터 기반 아이콘으로 선명한 표시

2. 테마 시스템 구현

next-themes를 활용한 다크/라이트 모드

typescript
// src/components/providers/theme-provider.tsx - 테마 컨텍스트 제공
'use client';

import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes/dist/types';

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
    return (
        <NextThemesProvider
            attribute="class"
            defaultTheme="system"
            enableSystem
            disableTransitionOnChange
            {...props}
        >
            {children}
        </NextThemesProvider>
    );
}
typescript
// src/components/ui/theme-toggle.tsx - 테마 토글 컴포넌트
'use client';

import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import {
    DropdownMenu,
    DropdownMenuContent,
    DropdownMenuItem,
    DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';

export function ThemeToggle() {
    const { setTheme } = useTheme();

    return (
        <DropdownMenu>
            <DropdownMenuTrigger asChild>
                <Button variant="outline" size="icon">
                    <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
                    <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
                    <span className="sr-only">테마 변경</span>
                </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
                <DropdownMenuItem onClick={() => setTheme('light')}>
                    라이트
                </DropdownMenuItem>
                <DropdownMenuItem onClick={() => setTheme('dark')}>
                    다크
                </DropdownMenuItem>
                <DropdownMenuItem onClick={() => setTheme('system')}>
                    시스템
                </DropdownMenuItem>
            </DropdownMenuContent>
        </DropdownMenu>
    );
}

학습한 핵심 개념:

  • CSS 변수 기반 테마: attribute="class"로 CSS 클래스 기반 테마 전환
  • 시스템 테마 감지: enableSystem으로 OS 테마 설정 자동 감지
  • 부드러운 전환: disableTransitionOnChange로 테마 전환 시 깜빡임 방지
  • 아이콘 애니메이션: CSS 트랜지션으로 Sun/Moon 아이콘 부드러운 전환

테마 시스템과 CSS 변수 연동

css
/* src/app/globals.css - 테마별 CSS 변수 정의 */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
    :root {
        /* 라이트 테마 */
        --background: 0 0% 100%;
        --foreground: 222.2 84% 4.9%;
        --card: 0 0% 100%;
        --card-foreground: 222.2 84% 4.9%;
        --popover: 0 0% 100%;
        --popover-foreground: 222.2 84% 4.9%;
        --primary: 222.2 47.4% 11.2%;
        --primary-foreground: 210 40% 98%;
        --secondary: 210 40% 96%;
        --secondary-foreground: 222.2 84% 4.9%;
        --muted: 210 40% 96%;
        --muted-foreground: 215.4 16.3% 46.9%;
        --accent: 210 40% 96%;
        --accent-foreground: 222.2 84% 4.9%;
        --destructive: 0 84.2% 60.2%;
        --destructive-foreground: 210 40% 98%;
        --border: 214.3 31.8% 91.4%;
        --input: 214.3 31.8% 91.4%;
        --ring: 222.2 84% 4.9%;
        --radius: 0.5rem;
    }

    .dark {
        /* 다크 테마 */
        --background: 222.2 84% 4.9%;
        --foreground: 210 40% 98%;
        --card: 222.2 84% 4.9%;
        --card-foreground: 210 40% 98%;
        --popover: 222.2 84% 4.9%;
        --popover-foreground: 210 40% 98%;
        --primary: 210 40% 98%;
        --primary-foreground: 222.2 47.4% 11.2%;
        --secondary: 217.2 32.6% 17.5%;
        --secondary-foreground: 210 40% 98%;
        --muted: 217.2 32.6% 17.5%;
        --muted-foreground: 215 20.2% 65.1%;
        --accent: 217.2 32.6% 17.5%;
        --accent-foreground: 210 40% 98%;
        --destructive: 0 62.8% 30.6%;
        --destructive-foreground: 210 40% 98%;
        --border: 217.2 32.6% 17.5%;
        --input: 217.2 32.6% 17.5%;
        --ring: 212.7 26.8% 83.9%;
    }
}

@layer base {
    * {
        @apply border-border;
    }
    body {
        @apply bg-background text-foreground;
    }
}

학습 포인트:

  • HSL 색상 시스템: hsl(var(--primary))로 색상 값과 투명도 분리
  • 의미론적 색상 명명: primary, secondary, muted 등 용도별 색상 정의
  • 일관된 디자인 토큰: 모든 컴포넌트에서 동일한 색상 변수 사용
  • 자동 테마 적용: .dark 클래스로 다크 모드 자동 전환

3. shadcn/ui 컴포넌트 시스템 확장

커스텀 컴포넌트 개발 패턴

typescript
// src/components/ui/avatar.tsx - 아바타 컴포넌트 확장
'use client';

import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';

const Avatar = React.forwardRef<
    React.ElementRef<typeof AvatarPrimitive.Root>,
    React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
    <AvatarPrimitive.Root
        ref={ref}
        className={cn(
            'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
            className
        )}
        {...props}
    />
));
Avatar.displayName = AvatarPrimitive.Root.displayName;

const AvatarImage = React.forwardRef<
    React.ElementRef<typeof AvatarPrimitive.Image>,
    React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
    <AvatarPrimitive.Image
        ref={ref}
        className={cn('aspect-square h-full w-full', className)}
        {...props}
    />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;

const AvatarFallback = React.forwardRef<
    React.ElementRef<typeof AvatarPrimitive.Fallback>,
    React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
    <AvatarPrimitive.Fallback
        ref={ref}
        className={cn(
            'flex h-full w-full items-center justify-center rounded-full bg-muted',
            className
        )}
        {...props}
    />
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;

export { Avatar, AvatarImage, AvatarFallback };

학습한 핵심 개념:

  • Radix UI 기반: 접근성이 내장된 headless 컴포넌트 활용
  • forwardRef 패턴: ref 전달로 DOM 접근성 확보
  • Compound Components: 관련 컴포넌트들의 논리적 그룹화
  • 타입 안전성: Radix UI 타입을 확장한 완전한 타입 지원

드롭다운 메뉴 컴포넌트 활용

typescript
// 사용자 메뉴에서 드롭다운 활용 예시
<DropdownMenu>
    <DropdownMenuTrigger asChild>
        <Button variant="ghost" className="relative h-8 w-8 rounded-full">
            <Avatar className="h-8 w-8">
                <AvatarImage src={user.avatar_url} alt={user.full_name} />
                <AvatarFallback>{user.full_name?.charAt(0)}</AvatarFallback>
            </Avatar>
        </Button>
    </DropdownMenuTrigger>
    <DropdownMenuContent className="w-56" align="end" forceMount>
        <div className="flex items-center justify-start gap-2 p-2">
            <div className="flex flex-col space-y-1 leading-none">
                <p className="font-medium">{user.full_name}</p>
                <p className="w-[200px] truncate text-sm text-muted-foreground">
                    {user.email}
                </p>
            </div>
        </div>
        <DropdownMenuSeparator />
        <DropdownMenuItem asChild>
            <Link href="/profile">프로필</Link>
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => signOut()}>
            로그아웃
        </DropdownMenuItem>
    </DropdownMenuContent>
</DropdownMenu>

학습 포인트:

  • asChild 패턴: 기존 컴포넌트를 래핑하지 않고 props 전달
  • 정렬 옵션: align="end"로 드롭다운 위치 조정
  • 키보드 네비게이션: 자동으로 화살표 키와 Enter 키 지원
  • 포커스 관리: 메뉴 열기/닫기 시 포커스 자동 관리

4. 반응형 디자인 시스템

모바일 우선 반응형 접근법

typescript
// 반응형 네비게이션 패턴
<nav className="hidden md:flex items-center space-x-6">
    {/* 데스크탑에서만 표시되는 메뉴 */}
    <Link href="/posts">글 목록</Link>
    <Link href="/about">소개</Link>
</nav>

<div className="flex items-center space-x-4">
    {/* 모든 화면 크기에서 표시 */}
    <ThemeToggle />

    {/* 사용자 메뉴 */}
    {isAuthenticated ? <UserMenu /> : <LoginButton />}
</div>

Tailwind CSS 반응형 브레이크포인트:

css
/* 모바일 우선 접근법 */
.container {
    /* 기본 (모바일): 320px+ */
    padding: 1rem;
}

@media (min-width: 640px) {
    /* sm: 640px+ */
    .container {
        padding: 1.5rem;
    }
}

@media (min-width: 768px) {
    /* md: 768px+ */
    .container {
        padding: 2rem;
    }
}

@media (min-width: 1024px) {
    /* lg: 1024px+ */
    .container {
        padding: 2rem 3rem;
    }
}

학습한 핵심 개념:

  • 모바일 우선: 작은 화면부터 시작하여 점진적 향상
  • 브레이크포인트 전략: sm:, md:, lg: 접두사로 화면별 스타일
  • 콘텐츠 우선순위: 중요한 기능부터 표시하고 부가 기능은 큰 화면에서 추가
  • 터치 친화적: 모바일에서 충분한 터치 영역 확보

접근성 고려사항

typescript
// 접근성을 고려한 컴포넌트 구현
<Button variant="outline" size="icon">
    <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
    <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
    <span className="sr-only">테마 변경</span> {/* 스크린 리더 전용 텍스트 */}
</Button>

<Link
    href="https://github.com/yourusername"
    target="_blank"
    rel="noopener noreferrer" // 보안 강화
    className="text-muted-foreground transition-colors hover:text-foreground"
>
    <span className="sr-only">GitHub</span> {/* 스크린 리더 지원 */}
    <GithubIcon className="h-5 w-5" />
</Link>

학습 포인트:

  • 스크린 리더 지원: sr-only 클래스로 시각적으로는 숨기고 스크린 리더에는 제공
  • 의미론적 HTML: <nav>, <main>, <header>, <footer> 태그 활용
  • 키보드 네비게이션: 모든 인터랙티브 요소에 키보드 접근 가능
  • 색상 대비: WCAG 가이드라인 준수하는 색상 조합

고민했던 부분과 해결책

1. 레이아웃 중앙 정렬 vs 전체 너비 활용

문제: Header에서 로고와 메뉴를 양쪽 끝에 배치하면서도 콘텐츠 최대 너비 제한

시도한 방식들:

  1. Container 클래스 사용 (문제 발생):
typescript
// ❌ 중앙 정렬로 인해 양쪽 끝 배치 불가
<div className="container flex items-center justify-between">
    <Logo />
    <UserMenu />
</div>
  1. Max-width와 Margin Auto (선택된 방식):
typescript
// ✅ 양쪽 끝 배치와 최대 너비 제한 동시 달성
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
    <Logo />
    <UserMenu />
</div>
  1. Grid 레이아웃 (복잡함):
typescript
// 🤔 가능하지만 불필요하게 복잡
<div className="grid grid-cols-3 items-center max-w-7xl mx-auto">
    <Logo />
    <div></div> {/* 빈 공간 */}
    <UserMenu />
</div>

학습한 내용:

  • Flexbox vs Grid: 1차원 레이아웃에는 Flexbox가 더 적합
  • 반응형 패딩: px-4 sm:px-6 lg:px-8로 화면 크기별 여백 조정
  • 최대 너비 제한: max-w-7xl로 초대형 화면에서도 가독성 유지

2. 테마 전환 시 깜빡임 현상

문제: 페이지 로드 시 테마가 적용되기 전 잠깐 기본 테마가 보이는 현상

발생 원인:

typescript
// 클라이언트 사이드에서 테마 적용으로 인한 지연
useEffect(() => {
    // 이 시점에서 테마가 적용되어 깜빡임 발생
    setTheme(savedTheme);
}, []);

해결책:

typescript
// next-themes의 suppressHydrationWarning 사용
<ThemeProvider
    attribute="class"
    defaultTheme="system"
    enableSystem
    disableTransitionOnChange // 전환 시 애니메이션 비활성화
    suppressHydrationWarning // 하이드레이션 경고 억제
>
    {children}
</ThemeProvider>

추가 최적화:

typescript
// 테마 스크립트를 head에 인라인으로 삽입 (향후 개선)
const themeScript = `
    (function() {
        const theme = localStorage.getItem('theme') || 'system';
        if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
            document.documentElement.classList.add('dark');
        }
    })();
`;

학습한 내용:

  • 하이드레이션 이슈: 서버와 클라이언트 렌더링 불일치 문제
  • 블로킹 스크립트: 테마 적용을 위한 인라인 스크립트의 필요성
  • 사용자 경험: 깜빡임 없는 부드러운 테마 전환의 중요성

3. 모바일 네비게이션 메뉴 구현

문제: 작은 화면에서 모든 메뉴를 표시할 공간 부족

고려한 옵션들:

  1. 햄버거 메뉴 (향후 구현 예정):
typescript
// 🔮 Phase 4에서 구현 예정
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);

return (
    <>
        <Button
            variant="ghost"
            size="icon"
            className="md:hidden"
            onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
        >
            <Menu className="h-5 w-5" />
        </Button>

        {isMobileMenuOpen && (
            <MobileMenu onClose={() => setIsMobileMenuOpen(false)} />
        )}
    </>
);
  1. 탭 네비게이션 (현재 방식):
typescript
// ✅ 현재: 핵심 메뉴만 표시, 나머지는 숨김
<nav className="hidden md:flex items-center space-x-6">
    <Link href="/posts">글 목록</Link>
    <Link href="/about">소개</Link>
    {isAdmin && <Link href="/admin">관리자</Link>}
</nav>
  1. Bottom Navigation (모바일 앱 스타일):
typescript
// 🤔 블로그에는 부적합
<nav className="fixed bottom-0 left-0 right-0 bg-background border-t md:hidden">
    <div className="flex justify-around py-2">
        <Link href="/"></Link>
        <Link href="/posts"></Link>
        <Link href="/profile">프로필</Link>
    </div>
</nav>

학습한 내용:

  • 점진적 개선: 핵심 기능부터 구현하고 점진적으로 확장
  • 사용자 패턴: 웹 사용자는 모바일 앱과 다른 네비게이션 패턴 선호
  • 우선순위: 모든 기능을 작은 화면에 억지로 넣기보다는 핵심 기능에 집중

4. 컴포넌트 재사용성과 커스터마이징 균형

문제: shadcn/ui 컴포넌트를 프로젝트에 맞게 커스터마이징하면서도 재사용성 유지

접근 방식:

typescript
// 기본 shadcn/ui 컴포넌트 확장
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
    ({ className, variant, size, asChild = false, ...props }, ref) => {
        const Comp = asChild ? Slot : "button";
        return (
            <Comp
                className={cn(buttonVariants({ variant, size, className }))}
                ref={ref}
                {...props}
            />
        );
    }
);

// 프로젝트 특화 컴포넌트 생성
const LoginButton = () => (
    <Button asChild>
        <Link href="/auth/login">로그인</Link>
    </Button>
);

const LogoutButton = ({ onLogout }: { onLogout: () => void }) => (
    <Button
        variant="ghost"
        onClick={onLogout}
        className="text-red-600 hover:text-red-700"
    >
        로그아웃
    </Button>
);

학습한 내용:

  • 컴포지션 패턴: 기본 컴포넌트를 조합하여 특화된 컴포넌트 생성
  • Props 확장: 기본 HTML 속성을 확장하면서 타입 안전성 유지
  • 스타일 오버라이드: cn() 함수로 기본 스타일과 커스텀 스타일 병합

기존 Phase에서 활용한 기술

Phase 1-2 기반 기술의 확장

TypeScript 타입 시스템 활용

  • Phase 1-2: 기본 인터페이스와 데이터베이스 타입 정의
  • Phase 3: 컴포넌트 Props와 이벤트 핸들러 타입 안전성 확보
  • 확장 내용: React 컴포넌트 타입과 이벤트 타입 완전 활용

React Query 인증 상태 연동

  • Phase 2: 인증 상태 관리 시스템 구축
  • Phase 3: UI 컴포넌트에서 인증 상태 기반 조건부 렌더링
  • 확장 내용: useAuth 훅을 통한 실시간 UI 업데이트

Tailwind CSS 디자인 시스템 완성

  • Phase 1: 기본 Tailwind CSS 설정
  • Phase 3: 완전한 디자인 토큰과 컴포넌트 시스템 구축
  • 확장 내용: CSS 변수 기반 테마 시스템과 반응형 디자인

핵심 의사결정과 그 이유

1. shadcn/ui vs 다른 UI 라이브러리

결정: shadcn/ui 지속 사용

이유:

  • Phase 1 연속성: 기존 설정과 학습 곡선 활용
  • 커스터마이징 자유도: 소스 코드 직접 수정 가능
  • 번들 크기: 필요한 컴포넌트만 포함하여 최적화
  • 접근성: Radix UI 기반으로 웹 접근성 자동 보장
  • 타입 안전성: TypeScript와 완벽한 통합

2. 테마 시스템: CSS-in-JS vs CSS 변수

결정: CSS 변수 기반 테마 시스템

이유:

  • 성능: 런타임 스타일 계산 없이 CSS 엔진 레벨에서 처리
  • SSR 호환성: 서버 사이드 렌더링과 완벽 호환
  • 브라우저 지원: 모던 브라우저에서 네이티브 지원
  • 개발자 도구: 브라우저 DevTools에서 쉽게 디버깅 가능
  • 번들 크기: JavaScript 런타임 오버헤드 없음

3. 모바일 네비게이션: 햄버거 메뉴 vs 숨김

결정: 현재는 메뉴 숨김, 향후 햄버거 메뉴 구현

이유:

  • 단계적 구현: 핵심 기능부터 구현하고 점진적 확장
  • 사용자 패턴: 블로그는 콘텐츠 소비가 주목적이므로 복잡한 네비게이션 불필요
  • 성능: 추가 JavaScript 없이 CSS만으로 반응형 구현
  • 접근성: 키보드 네비게이션과 스크린 리더 지원 용이

4. 컴포넌트 구조: Atomic Design vs Feature-based

결정: Atomic Design 원칙 적용

이유:

  • 재사용성: 작은 컴포넌트부터 큰 컴포넌트로 조합
  • 일관성: 디자인 시스템의 일관된 적용
  • 유지보수성: 변경 사항이 전체 시스템에 일관되게 적용
  • 확장성: 새로운 페이지와 기능 추가 시 기존 컴포넌트 재사용

성능 및 접근성 고려사항

성능 최적화

컴포넌트 최적화

typescript
// React.memo로 불필요한 리렌더링 방지
const Header = React.memo(() => {
    const { user, isAuthenticated, isAdmin } = useAuth();

    return (
        <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur">
            {/* 헤더 내용 */}
        </header>
    );
});

// useCallback으로 함수 참조 안정화
const ThemeToggle = () => {
    const { setTheme } = useTheme();

    const handleThemeChange = useCallback((theme: string) => {
        setTheme(theme);
    }, [setTheme]);

    return (
        <DropdownMenu>
            {/* 테마 토글 내용 */}
        </DropdownMenu>
    );
};

CSS 최적화

css
/* 하드웨어 가속 활용 */
.theme-toggle-icon {
    transform: translateZ(0); /* GPU 레이어 생성 */
    transition: transform 0.2s ease-in-out;
}

/* 중요한 스타일 우선 로딩 */
@layer base {
    /* 기본 스타일 */
}

@layer components {
    /* 컴포넌트 스타일 */
}

@layer utilities {
    /* 유틸리티 스타일 */
}

접근성 강화

키보드 네비게이션

typescript
// 키보드 이벤트 처리
const handleKeyDown = (event: React.KeyboardEvent) => {
    if (event.key === 'Enter' || event.key === ' ') {
        event.preventDefault();
        handleClick();
    }
};

// 포커스 관리
const focusableElements =
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement = modal.querySelectorAll(focusableElements)[0];
const lastFocusableElement =
    modal.querySelectorAll(focusableElements)[
        modal.querySelectorAll(focusableElements).length - 1
    ];

ARIA 속성 활용

typescript
<Button
    aria-label="테마 변경"
    aria-expanded={isOpen}
    aria-haspopup="menu"
    onClick={toggleTheme}
>
    <Sun className="h-4 w-4" />
    <span className="sr-only">테마 변경</span>
</Button>

<nav role="navigation" aria-label="메인 네비게이션">
    <ul>
        <li><Link href="/posts" aria-current={pathname === '/posts' ? 'page' : undefined}>글 목록</Link></li>
        <li><Link href="/about" aria-current={pathname === '/about' ? 'page' : undefined}>소개</Link></li>
    </ul>
</nav>

향후 개선 방향

1. 모바일 사용자 경험 향상

햄버거 메뉴 구현

typescript
// 모바일 네비게이션 메뉴
const MobileMenu = ({ isOpen, onClose }: MobileMenuProps) => {
    return (
        <AnimatePresence>
            {isOpen && (
                <motion.div
                    initial={{ opacity: 0, x: '100%' }}
                    animate={{ opacity: 1, x: 0 }}
                    exit={{ opacity: 0, x: '100%' }}
                    className="fixed inset-y-0 right-0 z-50 w-full max-w-sm bg-background shadow-lg md:hidden"
                >
                    <nav className="flex flex-col space-y-4 p-6">
                        <Link href="/posts" onClick={onClose}>글 목록</Link>
                        <Link href="/about" onClick={onClose}>소개</Link>
                        {isAdmin && <Link href="/admin" onClick={onClose}>관리자</Link>}
                    </nav>
                </motion.div>
            )}
        </AnimatePresence>
    );
};

터치 제스처 지원

typescript
// 스와이프 제스처로 메뉴 열기/닫기
const useSwipeGesture = (onSwipeLeft: () => void, onSwipeRight: () => void) => {
    const [touchStart, setTouchStart] = useState<number | null>(null);
    const [touchEnd, setTouchEnd] = useState<number | null>(null);

    const minSwipeDistance = 50;

    const onTouchStart = (e: TouchEvent) => {
        setTouchEnd(null);
        setTouchStart(e.targetTouches[0].clientX);
    };

    const onTouchMove = (e: TouchEvent) => {
        setTouchEnd(e.targetTouches[0].clientX);
    };

    const onTouchEnd = () => {
        if (!touchStart || !touchEnd) return;

        const distance = touchStart - touchEnd;
        const isLeftSwipe = distance > minSwipeDistance;
        const isRightSwipe = distance < -minSwipeDistance;

        if (isLeftSwipe) onSwipeLeft();
        if (isRightSwipe) onSwipeRight();
    };

    return { onTouchStart, onTouchMove, onTouchEnd };
};

2. 애니메이션 시스템 구축

Framer Motion 통합

typescript
// 페이지 전환 애니메이션
const PageTransition = ({ children }: { children: React.ReactNode }) => {
    return (
        <motion.div
            initial={{ opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -20 }}
            transition={{ duration: 0.3, ease: 'easeInOut' }}
        >
            {children}
        </motion.div>
    );
};

// 컴포넌트 등장 애니메이션
const FadeInUp = ({ children, delay = 0 }: FadeInUpProps) => {
    return (
        <motion.div
            initial={{ opacity: 0, y: 30 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.5, delay, ease: 'easeOut' }}
        >
            {children}
        </motion.div>
    );
};

3. 고급 테마 시스템

커스텀 테마 생성기

typescript
// 사용자 정의 테마 색상
interface CustomTheme {
    name: string;
    colors: {
        primary: string;
        secondary: string;
        accent: string;
        background: string;
        foreground: string;
    };
}

const ThemeCustomizer = () => {
    const [customTheme, setCustomTheme] = useState<CustomTheme>();

    const applyCustomTheme = (theme: CustomTheme) => {
        const root = document.documentElement;
        Object.entries(theme.colors).forEach(([key, value]) => {
            root.style.setProperty(`--${key}`, value);
        });
    };

    return (
        <div className="space-y-4">
            <ColorPicker
                label="Primary Color"
                value={customTheme?.colors.primary}
                onChange={(color) => updateThemeColor('primary', color)}
            />
            {/* 다른 색상 선택기들 */}
        </div>
    );
};

4. 컴포넌트 문서화

Storybook 통합

typescript
// Button.stories.tsx
export default {
    title: 'UI/Button',
    component: Button,
    parameters: {
        layout: 'centered',
    },
    tags: ['autodocs'],
} satisfies Meta<typeof Button>;

export const Default: Story = {
    args: {
        children: '기본 버튼',
    },
};

export const Variants: Story = {
    render: () => (
        <div className="flex space-x-2">
            <Button variant="default">Default</Button>
            <Button variant="secondary">Secondary</Button>
            <Button variant="outline">Outline</Button>
            <Button variant="ghost">Ghost</Button>
        </div>
    ),
};

결론

Phase 3 기본 UI 컴포넌트 및 레이아웃 시스템 구축을 통해 현대적이고 접근 가능한 사용자 인터페이스의 견고한 기반을 마련할 수 있었습니다.

특히 shadcn/ui와 Tailwind CSS를 활용한 디자인 시스템을 통해 일관성 있고 확장 가능한 UI 컴포넌트를 구축했으며, next-themes를 활용한 테마 시스템으로 사용자 맞춤형 경험을 제공할 수 있게 되었습니다. 또한 반응형 디자인과 접근성 고려사항을 통해 모든 사용자가 편리하게 사용할 수 있는 인터페이스를 완성했습니다.

컴포넌트 기반 아키텍처Atomic Design 원칙을 적용하여 재사용성과 유지보수성을 극대화했으며, 타입 안전한 컴포넌트 시스템을 통해 개발자 경험과 코드 품질을 동시에 향상시켰습니다.

이러한 경험은 향후 대규모 디자인 시스템 구축사용자 중심의 인터페이스 설계에서도 활용할 수 있는 실무 역량이 될 것입니다.


다음 단계 (Phase 4)

Phase 4에서 구현할 기능들

1. 사용자 인증 및 권한 관리 시스템

  • 로그인/로그아웃 페이지 UI 구현
  • 사용자 프로필 관리 페이지
  • 관리자 권한 확인 및 보호된 라우트

2. 인증 상태 기반 UI 개선

  • 로딩 상태 표시 개선
  • 에러 상태 처리 및 사용자 피드백
  • 인증 실패 시 적절한 리디렉션

3. 사용자 경험 향상

  • 토스트 알림 시스템 구현
  • 폼 검증 및 에러 메시지 표시
  • 접근성 개선 및 키보드 네비게이션

Phase 3에서 구축한 기반이 Phase 4에서 활용되는 방식:

  • 레이아웃 시스템 → 인증 페이지 일관된 디자인 적용
  • 테마 시스템 → 로그인 폼과 프로필 페이지 테마 지원
  • 컴포넌트 시스템 → 폼 컴포넌트와 버튼 재사용
  • 반응형 디자인 → 모바일 친화적 인증 플로우

참고 자료

공식 문서

디자인 시스템 & UX

접근성 & 성능

개발 도구

1개의 댓글
정우진
4개월 전
비밀댓글입니다.