[BookShop- FE] React 스타일링 셋업 & 테마 시스템

1. Layout 컴포넌트

페이지마다 Header, Footer를 직접 넣으면 나중에 바꿀 때 전부 수정해야 합니다.

그러나, Layout으로 감싸면 한 곳에서 관리할 수 있습니다. 

  • 프로젝트의 기본적인 화면 구조를 잡을 수 있음
  • 반복적으로 들어가야 하는 헤더, 푸터 등을 매 화면마다 제공
  • 상황과 필요에 따라 레이아웃이 변경될 수 있도록 대비할 수 있음

📄 Layout.tsx

import Footer from "../common/Footer";
import Header from "../common/Header";
 
 
interface LayoutProps {
    children: React.ReactNode;
}
 
function Layout({ children }: LayoutProps) {
    return (
        <>
            <Header />
            <main>{children}</main>
            <Footer />
        </>
    )
}
 
export default Layout;

📄 App.tsx

import Layout from "./components/layout/Layout";
import Home from "./pages/Home";
 
function App() {
  return (
    <Layout>
      <Home />
    </Layout>
  );
}
 
export default App;

children 타입

LayoutProps에서 사용하는 children 타입은 계층 구조를 가집니다.

ReactNode ⊃ ReactElement ⊃ JSX.Element 

  • React.ReactNode: 가장 넓은 범위. null, undefined, 숫자, 문자열, ReactElement 등을 모두 포함합니다. 가장 권장되는 타입입니다.
  • React.ReactElement: 오직 리액트 컴포넌트(요소)만 허용합니다. (문자열 등은 불가)
  • JSX.Element: 리액트 요소의 실행 결과 타입입니다.

🚨 children을 ReactNode로 설정하면 어떤 값이든 유연하게 받을 수 있지만, 특정 컴포넌트만 강제하고 싶을 때는 더 좁은 타입을 고려해야 합니다. 하지만 일반적인 레이아웃에서는 ReactNode가 표준입니다.


2. CSS Reset & Normalize: 평탄화 작업

브라우저마다 기본적으로 적용된 스타일이 다르기 때문에, 이를 초기화하여 동일한 화면을 보장해야합니다. 

 

주요 라이브러리 

Eric Meyer's reset.css 모든 기본 스타일 제거. 처음부터 전부 직접 써야 함
normalize.css 브라우저 차이만 통일. 쓸만한 기본값은 남김
sanitize.css ✅ normalize 기반인데 실용적인 기본값 추가로 들어가 있음
최신 브라우저 경향 반영, :where 셀렉터 활용으로 명시도 문제 해결
💡 :where 
sanitize.css가 사용하는 :where는 명시도(Specificity)가 0입니다. 덕분에 전역 스타일을 적용하면서도, 우리가 나중에 작성할 커스텀 CSS가 아주 쉽게 덮어쓸 수 있도록 도와줍니다.


— sanitize.css

저는 이것들 중 :where형식을 사용해보고 싶어서 sanitize.css 을 선택하였습니다. 이전의 프로젝트에서는 !import를 남발했던 기억이 있어서, 제 상황에서 필요해보였습니다. 

npm install sanitize.css --save
// index.tsx (엔트리 포인트에 한 번만)
import 'sanitize.css';

 

이를 진행하면, 개발자 도구에서 확인하면 :where(h1) 같은 셀렉터가 생긴 걸 볼 수 있습니다.

 

3. 왜 CSS-in-JS (styled-components) 인가?

전통적인 CSS 방식의 한계를 극복하기 위해 등장했습니다. 

  • 전역 충돌 문제
    • 클래스명이 고유한 난수로 생성되어 스타일이 겹치지 않습니다.
  • 상태 공유 어려움
    • JavaScript의 Props를 스타일 코드에서 직접 사용할 수 있습니다.
  • 로딩 순서 / 명시도
    • 라이브러리 CSS가 내 코드보다 나중에 로드되면 현재 내 스타일이 덮어씌어지는 등 React 환경에서는 렌더 순서에 따라 예측 불가능해집니다.
      • → 관심사의 분리 (Separation of Concerns)
        : CSS-in-JS는 컴포넌트 단위로 JS+CSS+HTML을 같은 곳에 모아서 캡슐화합니다.

styled-components 을 적용해보자. 

npm install styled-components --save

📄 Header.tsx

import { styled } from "styled-components";

function Header() {
    return (
        <HeaderStyle>
            <h1>book store</h1>
        </HeaderStyle>
    );
}

const HeaderStyle = styled.header`
    background-color: #333;

    h1 {
        color: white;
    }
`;

export default Header;

 

Styled-components가 제공하는 전역 도구

  • createGlobalStyle
    • 전체 앱에 적용할 기본 스타일을 createGlobalStyle로 만들 수 있습니다. 
  • ThemeProvider (아래 theme 구현에서 사용)
    • Context API 기반의 상위 컴포넌트
    • 데이터 전달: 내부적으로 리액트의 Context를 사용하여 theme 객체를 모든 하위 styled-components에 주입합니다.
    • 동적 테마: theme prop에 전달되는 객체가 바뀌면, 이를 구독하고 있는 모든 스타일드 컴포넌트가 자동으로 리렌더링되어 스타일이 즉시 변경됩니다. (다크모드 구현의 핵심)
    • 중첩 가능: 여러 개의 ThemeProvider를 중첩하여 특정 구역만 다른 테마를 적용할 수도 있습니다.

📄 global.ts

import {createGlobalStyle} from 'styled-components';
// @ts-ignore
import 'sanitize.css';
import { ThemeName } from './theme';

interface Props {
  themeName: ThemeName;
}

export const GlobalStyle = createGlobalStyle<Props>`
  body {
    margin: 0;
    padding: 0;
    background-color : ${(props) => (props.themeName === "light" ? "white" : "black")}
  }

  h1 {
    margin: 0;
  }

  * {
    color: ${(props) => (props.themeName === "light" ?
      "black" : "white")};
  }
`;

3. 테마 스위처 (Theme Switcher) 구현하기

Theme 시스템

색상값을 컴포넌트마다 하드코딩하면 나중에 디자인 바꿀 때 전부 수정해야 하기때문에, theme 객체로 중앙 관리합니다. 

// style/theme.ts
export type ThemeName = "light" | "dark";
type ColorKey = "primary" | "background" | "secondary" | "third";

interface Theme {
    name: ThemeName;
    color: Record<ColorKey, string>;
}

export const light: Theme = {
    name: 'light',
    color: {
        primary: 'brown',
        background: 'lightgray',
        secondary: 'blue',
        third: 'green',
    },
};

export const dark: Theme = {
    name: 'dark',
    color: {
        primary: 'coral',
        background: 'midnightblue',
        secondary: 'darkblue',
        third: 'darkgreen',
    },
};

export const getTheme = (themeName: ThemeName): Theme => {
    switch (themeName) {
        case "light": return light;
        case "dark": return dark;
    }
};

 

styled 컴포넌트에서 theme를 꺼내쓸 수 있습니다. 

const HeaderStyle = styled.header`
    background: ${({ theme }) => theme.color.background};

    h1 {
        color: ${({ theme }) => theme.color.primary};
    }
`;

 

GlobalStyle에 theme 적용 (themeName prop 전달)

// style/global.ts
import { createGlobalStyle } from 'styled-components';
import { ThemeName } from './theme';

interface Props {
    themeName: ThemeName;
}

export const GlobalStyle = createGlobalStyle<Props>`
  body {
    color: ${({ themeName }) => themeName === 'light' ? 'black' : 'white'};
  }
`;

Context API로 전역 테마 관리

왜 Context가 필요한가

App.tsx에서 useState로 테마 관리하면 ThemeSwitcher, Header 등 하위 컴포넌트에서 테마 바꾸려 할 때 props로 계속 내려줘야 합니다. 여기서 Context를 쓰면 Provider 하위 어디서든 바로 꺼내 쓸 수 있습니다.

  • createContext(): 컨텍스트 공간 생성
  • <Context.Provider>: 하위 컴포넌트들에게 값 제공
  • useContext(): 어디서든 값 꺼내 쓰는 훅

BookStoreThemeProvider

styled-components ThemeProvider, GlobalStyle, 커스텀 Context를 하나로 묶었습니다. App.tsx를 깔끔하게 유지할 수 있고 테마 관련 로직이 한 곳에 모일 수 있게 했습니다. 

// context/themeContext.tsx
import { createContext, ReactNode, useEffect, useState } from "react";
import { getTheme, ThemeName } from "../style/theme";
import { ThemeProvider } from "styled-components";
import { GlobalStyle } from "../style/global";

const DEFAULT_THEME_NAME = "light";
const THEME_LOCALSTORAGE_KEY = "book_store_key";

interface State {
    themeName: ThemeName;
    toggleTheme: () => void;
}

export const ThemeContext = createContext<State>({
    themeName: "light" as ThemeName,
    toggleTheme: () => {},
});

export const BookStoreThemeProvider = ({ children }: { children: ReactNode }) => {
    const [themeName, setThemeName] = useState<ThemeName>("light");

	// 초기 로드 시 저장된 테마 불러오기
    useEffect(() => {
        const savedThemeName = localStorage.getItem(THEME_LOCALSTORAGE_KEY) as ThemeName;
        setThemeName(savedThemeName || DEFAULT_THEME_NAME);
    }, []);

	// 테마 토글 및 로컬스토리지 저장
    const toggleTheme = () => {
        const next = themeName === "light" ? "dark" : "light";
        setThemeName(next);
        localStorage.setItem(THEME_LOCALSTORAGE_KEY, next);
    };

    return (
        <ThemeContext.Provider value={{ themeName, toggleTheme }}>
            <ThemeProvider theme={getTheme(themeName)}>
                <GlobalStyle themeName={themeName} />
                {children}
            </ThemeProvider>
        </ThemeContext.Provider>
    );
};

 

ThemeSwitcher

// components/header/ThemeSwitcher.tsx
import { useContext } from "react";
import { ThemeContext } from "../../context/themeContext";

function ThemeSwitcher() {
    const { themeName, toggleTheme } = useContext(ThemeContext);
    return <button onClick={toggleTheme}>{themeName}</button>;
}

export default ThemeSwitcher;

App.tsx 최종

// App.tsx
import Layout from "./components/layout/Layout";
import Home from "./pages/Home";
import ThemeSwitcher from "./components/header/ThemeSwitcher";
import { BookStoreThemeProvider } from "./context/themeContext";

function App() {
  return (
    <BookStoreThemeProvider>
      <ThemeSwitcher />
      <Layout>
        <Home />
      </Layout>
    </BookStoreThemeProvider>
  );
}

export default App;

 

💡 주의: useContext(ThemeContext)는 반드시 BookStoreThemeProvider 안에 있는 컴포넌트에서만 써야 합니다 App.tsx 자체는 Provider 밖이라 거기서 useContext 쓰면 기본값(toggleTheme: ()=>{})만 나옵니다.