본문 바로가기

React

React 앱에서 Atomic Design 패턴 적용하기

Atomic Design은 컴포넌트를 원자부터 페이지까지 계층적으로 구성해 재사용성과 일관성을 높이는 설계 방법입니다. 실무에서는 디자인 시스템 구축, 대규모 UI 유지보수, 팀 협업에서 특히 효과적입니다. 이 글은 React 앱에 Atomic Design을 적용하는 실전 가이드를 제공합니다.

1. Atomic Design 5단계 요약

Atoms: 버튼, 입력, 아이콘처럼 더 쪼갤 수 없는 UI 단위입니다. 스타일 토큰과 접근성 속성을 포함하되 비즈니스 로직은 없습니다.

Molecules: 여러 Atom을 조합한 기능 단위입니다. 예: 라벨+인풋+에러 메시지 필드, 버튼 그룹 등입니다.

Organisms: 헤더, 카드 리스트, 검색 헤더처럼 의미 있는 영역을 구성합니다. 상태는 UI 영역 내부 동작 정도로 제한합니다.

Templates: 레이아웃과 배치만 정의합니다. 실제 데이터 없이 슬롯(children) 중심으로 구성합니다.

Pages: 라우팅, 데이터 로딩, 의존성 주입(서비스 호출) 등 화면 완성 단계입니다.

2. 폴더 구조 예시

src/
  components/
    atoms/
      Button/
        Button.jsx
        Button.css
        index.js
      Input/
        Input.jsx
        Input.css
        index.js
    molecules/
      LoginForm/
        LoginForm.jsx
        index.js
    organisms/
      Header/
        Header.jsx
        index.js
    templates/
      AuthTemplate/
        AuthTemplate.jsx
        index.js
  pages/
    LoginPage.jsx
  app/
    router.jsx
  index.jsx

레이어 간 의존성은 아래 방향(높은 추상화 → 낮은 추상화)으로만 허용합니다: Pages → Templates → Organisms → Molecules → Atoms.

3. Atom: Button 구현

// src/components/atoms/Button/Button.jsx
import React from 'react';

const VARIANTS = { primary: 'btn btn--primary', secondary: 'btn btn--secondary', text: 'btn btn--text' };
const SIZES = { sm: 'btn--sm', md: 'btn--md', lg: 'btn--lg' };

export default function Button({ type = 'button', variant = 'primary', size = 'md', disabled = false, onClick, children, ...rest }) {
  const className = [VARIANTS[variant], SIZES[size]].filter(Boolean).join(' ');
  return (
    <button type={type} disabled={disabled} onClick={onClick} className={className} {...rest}>
      {children}
    </button>
  );
}
// src/components/atoms/Button/index.js
export { default } from './Button';

가이드: Atom은 단일 책임, 접근성(aria-*), 디자인 토큰(variant/size)만 처리합니다. API 호출, 상태 관리 등 비즈니스 로직은 금지합니다.

4. Atom: Input 구현

// src/components/atoms/Input/Input.jsx
import React from 'react';

export default function Input({ id, label, error, ...rest }) {
  return (
    <div className='field'>
      {label && <label htmlFor={id}>{label}</label>}
      <input id={id} aria-invalid={!!error} aria-describedby={error ? id + '-error' : undefined} {...rest} />
      {error && (
        <span id={id + '-error'} role='alert' className='field__error'>{error}</span>
      )}
    </div>
  );
}

5. Molecule: LoginForm 구현

// src/components/molecules/LoginForm/LoginForm.jsx
import React, { useState } from 'react';
import Input from '@atoms/Input';
import Button from '@atoms/Button';

export default function LoginForm({ onSubmit, loading = false }) {
  const [values, setValues] = useState({ email: '', password: '' });

  function handleChange(e) {
    setValues(v => ({ ...v, [e.target.name]: e.target.value }));
  }

  function handleSubmit(e) {
    e.preventDefault();
    onSubmit?.(values);
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <Input id='email' name='email' type='email' label='이메일' onChange={handleChange} value={values.email} required />
      <Input id='password' name='password' type='password' label='비밀번호' onChange={handleChange} value={values.password} required />
      <Button type='submit' disabled={loading}>{loading ? '로그인 중...' : '로그인'}</Button>
    </form>
  );
}

6. Organism & Template

// src/components/organisms/Header/Header.jsx
import React from 'react';

export default function Header({ rightSlot }) {
  return (
    <header className='site-header'>
      <a href='/' className='logo'>MyApp</a>
      <nav className='nav'>
        <a href='/features'>기능</a>
        <a href='/pricing'>요금</a>
      </nav>
      <div className='header-right'>{rightSlot}</div>
    </header>
  );
}
// src/components/templates/AuthTemplate/AuthTemplate.jsx
import React from 'react';

export default function AuthTemplate({ title, children }) {
  return (
    <main className='auth'>
      <section className='auth__panel'>
        <h1>{title}</h1>
        {children}
      </section>
      <aside className='auth__aside'>{/* 비주얼/카피 */}</aside>
    </main>
  );
}

7. Page: 데이터와 라우팅

// src/pages/LoginPage.jsx
import React, { useState } from 'react';
import AuthTemplate from '@templates/AuthTemplate';
import LoginForm from '@molecules/LoginForm';
import Header from '@organisms/Header';

async function fakeApiLogin(values) {
  return new Promise(res => setTimeout(res, 600));
}

export default function LoginPage() {
  const [loading, setLoading] = useState(false);

  async function handleLogin(values) {
    try {
      setLoading(true);
      await fakeApiLogin(values);
      // 라우팅, 토스트 등 처리
    } finally {
      setLoading(false);
    }
  }

  return (
    <>
      <Header rightSlot={<a href='/signup'>회원가입</a>} />
      <AuthTemplate title='로그인'>
        <LoginForm onSubmit={handleLogin} loading={loading} />
      </AuthTemplate>
    </>
  );
}

8. 경로 별칭과 배럴(Barrel) 내보내기

// jsconfig.json
{
  "compilerOptions": {
    "baseUrl": "src",
    "paths": {
      "@atoms/*": ["components/atoms/*"],
      "@molecules/*": ["components/molecules/*"],
      "@organisms/*": ["components/organisms/*"],
      "@templates/*": ["components/templates/*"]
    }
  }
}

각 컴포넌트 폴더에 index.js를 두어 기본 내보내기를 제공하면 import가 간결해집니다. 레이어 루트에 index.js를 만들어 대량 재노출도 가능하나, 불필요한 의존성 확장을 막기 위해 폴더 단위 import를 추천합니다.

9. Storybook으로 문서화

// src/components/atoms/Button/Button.stories.jsx
import Button from './Button';

export default {
  title: 'Atoms/Button',
  component: Button,
  argTypes: {
    variant: { control: 'select', options: ['primary', 'secondary', 'text'] },
    size: { control: 'select', options: ['sm', 'md', 'lg'] }
  }
};

const Template = (args) => <Button {...args}>Button</Button>;
export const Primary = Template.bind({});
Primary.args = { variant: 'primary', size: 'md' };

원자와 분자 단계부터 문서화/시각테스트를 시작하면 UI 변동 비용을 크게 줄일 수 있습니다.

10. 테스트 전략 (Jest + RTL)

// src/components/atoms/Button/Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

test('클릭 핸들러 호출', () => {
  const fn = jest.fn();
  render(<Button onClick={fn}>OK</Button>);
  fireEvent.click(screen.getByRole('button', { name: 'OK' }));
  expect(fn).toHaveBeenCalled();
});

원자에서 상호작용과 접근성 롤(role)을 검증하고, 분자/유기체에서 조합 동작을 테스트합니다. 페이지는 데이터 흐름과 라우팅을 통합 테스트합니다.

11. 의존성 경계 ESLint 규칙

// .eslintrc.js (요약)
module.exports = {
  rules: {
    'import/no-restricted-paths': [2, {
      zones: [
        // atoms가 상위 레이어를 import 금지
        { target: './src/components/molecules', from: './src/components/atoms' },
        { target: './src/components/organisms', from: './src/components/atoms' },
        { target: './src/components/templates', from: './src/components/atoms' },
        // molecules가 organisms/templates import 금지
        { target: './src/components/organisms', from: './src/components/molecules' },
        { target: './src/components/templates', from: './src/components/molecules' }
      ]
    }]
  }
};

원자 → 분자 방향으로만 의존하도록 자동 검사합니다. 사이클이 생기지 않도록 import/no-cycle 규칙도 함께 권장합니다.

12. 스타일 가이드

디자인 토큰(색상, 간격, 그림자)은 CSS 변수나 Tailwind/Styled System으로 통일합니다. Atom에는 토큰 기반 variant/size만 두고, 레이아웃/배치는 상위(Template/Organism)에서 책임지게 합니다. 클래스 이름은 레이어에 독립적이어야 하며, 공용 유틸 클래스(예: .visually-hidden)는 atoms에서 바로 사용합니다.

13. 성능과 접근성 체크

성능: 반복 렌더가 잦은 Organism에는 React.memo, list에는 key 안정성, 핸들러는 useCallback으로 메모이제이션합니다. 과도한 memo는 피하고, 프로파일링으로 병목을 확인합니다.

접근성: Input/Label 연결, 포커스 트랩(모달), 키보드 탐색, role/aria-속성, 대비 비율을 원자 단계에서 보장하고 Storybook에서 a11y 애드온으로 확인합니다.

14. 점진적 도입 전략

신규 컴포넌트부터 Atomic 구조로 작성합니다. 공통 버튼/입력부터 추출해 변형을 variant/size로 통합합니다. 기존 화면은 페이지 레벨에서 새 Molecule/Organism으로 교체하고, 사용처가 3회 이상 반복되는 UI부터 승격(분자→유기체)합니다.

15. 흔한 함정과 회피 팁

레이어 과분화: 꼭 5단계를 강요하지 말고, 프로젝트 규모에 따라 Atoms/Molecules/Organisms 3단계만 우선 적용해도 충분합니다.

비즈니스 로직 누수: 폼 검증, API 호출은 Page/컨테이너에서 수행하고 하위에는 콜백만 전달합니다.

Variant 폭증: variant 조합이 6가지를 넘기면 스타일 토큰/조합을 재검토하고 Storybook에서 사용성 점검합니다.

16. 적용 체크리스트

1) Atoms는 순수 UI이며 접근성 보장됨 2) 상향 의존성 금지 린트 적용 3) 경로 별칭과 배럴 내보내기 정리 4) Storybook/테스트로 회귀 방지 5) Templates는 레이아웃만, Pages는 데이터/라우팅만 담당합니다.

Atomic Design을 React에 적용하면 UI의 일관성과 재사용이 극대화됩니다. 위의 구조/규칙/예제를 팀 컨벤션으로 명문화하고 Storybook과 ESLint로 자동화하면 유지보수 효율이 크게 향상됩니다.