[week12] 프로젝트 : React(TypeScript) 기반의 동적 UI 개발 (3) - 03/31

Kyulee·2026년 3월 31일

TIL 

목록 보기
59/90
post-thumbnail

지난 시간에 이어 타입스크립트를 활용한 동적 UI 개발을 진행하겠습니다. 이번 시간에는 화면 구성의 바탕이 되는 기본 컴포넌트(Title, Button, Input) 를 작성하고, 공통 레이아웃인 헤더와 푸터를 구현하는 과정을 정리했습니다.


1. 기본 컴포넌트 작성 - Title 컴포넌트

제목을 표시하는 공통 컴포넌트입니다. 컴포넌트 내부의 텍스트는 children 을 통해 전달받고, 폰트 크기나 색상 같은 스타일 요소는 props 로 받아 재사용성을 높이는 방식으로 구현합니다.

import { styled } from 'styled-components';
import type { ColorKey, HeadingSize, Theme } from '../../style/theme';

interface Props {
  children: React.ReactNode;
  size: HeadingSize;
  color?: ColorKey;
}

const TitleStyled = styled.h1<Omit<Props, 'children'> & { theme: Theme }>`
  font-size: ${({ theme, size }) => theme.heading[size].fontSize};
  color: ${({ theme, color }) => (color ? theme.color[color] : theme.color.primary)};
`;

function Title({ children, size, color }: Props) {
  return (
    <TitleStyled size={size} color={color}>
      {children}
    </TitleStyled>
  );
}

export default Title;

size props에 따라 theme.heading 에 정의된 폰트 크기가 적용되고, color props가 없을 경우 기본값으로 primary 색상이 사용됩니다. 테마 값을 직접 참조하기 때문에 다크/라이트 모드 전환 시에도 자동으로 색상이 바뀝니다.

테스트 코드

describe('Title 컴포넌트 테스트', () => {
  it('렌더를 확인', () => {
    render(
      <BookStoreThemeProvider>
        <Title size="large">테스트 제목</Title>
      </BookStoreThemeProvider>,
    );
    expect(screen.getByText('테스트 제목')).toBeInTheDocument();
  });

  it('size props 적용', () => {
    const { container } = render(
      <BookStoreThemeProvider>
        <Title size="large">테스트 제목</Title>
      </BookStoreThemeProvider>,
    );
    expect(container.firstChild).toHaveStyle({ fontSize: '2rem' });
  });

  it('color props 적용', () => {
    const { container } = render(
      <BookStoreThemeProvider>
        <Title size="large" color="primary">테스트 제목</Title>
      </BookStoreThemeProvider>,
    );
    expect(container.firstChild).toHaveStyle({ color: 'brown' });
  });
});

2. 기본 컴포넌트 작성 - Button 컴포넌트

사용자의 클릭 이벤트를 처리하는 버튼 컴포넌트입니다. size, scheme, disabled, isLoading 등의 props를 받아 테마 기반으로 스타일을 동적으로 적용합니다.

import { styled } from 'styled-components';
import type { ButtonSize, ButtonScheme, Theme } from '../../style/theme';

interface Props {
  children: React.ReactNode;
  size: ButtonSize;
  scheme: ButtonScheme;
  disabled: boolean;
  isLoading: boolean;
}

function Button({ children, size, scheme, disabled, isLoading }: Props) {
  return (
    <ButtonStyled size={size} scheme={scheme} disabled={disabled} isLoading={isLoading}>
      {children}
    </ButtonStyled>
  );
}

const ButtonStyled = styled.button<Omit<Props, 'children'> & { theme: Theme }>`
  font-size: ${({ theme, size }) => theme.button[size].fontSize};
  padding: ${({ theme, size }) => theme.button[size].padding};
  color: ${({ theme, scheme }) => theme.buttonScheme[scheme].color};
  background-color: ${({ theme, scheme }) => theme.buttonScheme[scheme].backgroundColor};
  border: 0;
  border-radius: ${({ theme }) => theme.borderRadius.default};
  opacity: ${({ disabled }) => (disabled ? 0.5 : 1)};
  pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')};
  cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
`;

export default Button;

disabled 상태일 때는 opacity 를 낮추고 클릭 이벤트를 차단합니다. isLoading 도 props로 받아 추후 로딩 스피너 같은 UI 처리에 활용할 수 있도록 열어뒀습니다.

테스트 코드

describe('Button 컴포넌트 테스트', () => {
  it('렌더를 확인', () => {
    render(
      <BookStoreThemeProvider>
        <Button size="large" scheme="primary" disabled={false} isLoading={false}>
          테스트 버튼
        </Button>
      </BookStoreThemeProvider>,
    );
    expect(screen.getByText('테스트 버튼')).toBeInTheDocument();
  });

  it('size props 적용', () => {
    render(
      <BookStoreThemeProvider>
        <Button size="large" scheme="primary" disabled={false} isLoading={false}>
          테스트 버튼
        </Button>
      </BookStoreThemeProvider>,
    );
    expect(screen.getByRole('button')).toHaveStyle({ fontSize: '1.5rem' });
  });
});

3. 기본 컴포넌트 작성 - Input 컴포넌트

사용자로부터 텍스트를 입력받는 컴포넌트입니다. React.forwardRef 를 사용해 부모 컴포넌트에서 ref 로 직접 input 요소에 접근할 수 있도록 구현했습니다.

import React from 'react';
import styled from 'styled-components';
import type { Theme } from '../../style/theme';

interface Props {
  placeholder?: string;
}

const InputText = React.forwardRef((props: Props, ref: React.Ref<HTMLInputElement>) => {
  return <InputTextStyled placeholder={props.placeholder} ref={ref} />;
});

const InputTextStyled = styled.input.attrs({ type: 'text' })<{ theme: Theme }>`
  padding: 0.25rem 0.75rem;
  border: 1px solid ${({ theme }) => theme.color.border};
  border-radius: ${({ theme }) => theme.borderRadius.default};
  font-size: 1rem;
  line-height: 1.5;
  color: ${({ theme }) => theme.color.text};
`;

export default InputText;

styled.input.attrs({ type: 'text' }) 를 사용해 기본 속성으로 type="text" 를 고정했습니다. forwardRef 덕분에 폼 라이브러리나 포커스 제어가 필요한 상황에서도 유연하게 활용할 수 있습니다.

테스트 코드

describe('Input 컴포넌트 테스트', () => {
  it('렌더를 확인', () => {
    render(
      <BookStoreThemeProvider>
        <Input placeholder="테스트 입력" />
      </BookStoreThemeProvider>,
    );
    expect(screen.getByPlaceholderText('테스트 입력')).toBeInTheDocument();
  });

  it('forwardRef 동작 확인', () => {
    const ref = React.createRef<HTMLInputElement>();
    render(
      <BookStoreThemeProvider>
        <Input ref={ref} placeholder="테스트 입력" />
      </BookStoreThemeProvider>,
    );
    expect(ref.current).toBeInstanceOf(HTMLInputElement);
  });
});

4. 헤더와 푸터

헤더와 푸터는 웹페이지의 공통적인 레이아웃 요소로, 여러 페이지에서 동일하게 사용해 사이트 전체의 일관성을 유지하는 역할을 합니다.

  • 헤더 — 웹페이지 최상단에 위치하며 로고, 카테고리 내비게이션, 로그인/회원가입 링크 등을 포함합니다.
  • 푸터 — 최하단에 위치하며 로고와 저작권 정보를 제공합니다.

두 컴포넌트 모두 styled-components 로 스타일링하며, theme 에서 색상과 레이아웃 너비 값을 가져옵니다.

// Header.tsx
export default function Header() {
  return (
    <HeaderStyle>
      <h1 className="logo">
        <img src={logo} alt="Book Store Logo" width="100" />
      </h1>
      <nav className="category">
        <ul>
          {CATEGORY.map((category) => (
            <li key={category.id}>
              <a href={`/books?category_id=${category.id}`}>{category.name}</a>
            </li>
          ))}
        </ul>
      </nav>
      <nav className="auth">
        <ul>
          <li><a href="/login"><FaSignInAlt /> 로그인</a></li>
          <li><a href="/signup"><FaRegUser /> 회원가입</a></li>
        </ul>
      </nav>
    </HeaderStyle>
  );
}
// Footer.tsx
const Footer: React.FC = () => {
  return (
    <FooterStyle>
      <h1 className="logo">
        <img src={logo} alt="Book Store Logo" />
      </h1>
      <div className="copyright">
        <p>© 2025 Book Store. All rights reserved.</p>
      </div>
    </FooterStyle>
  );
};

이 두 컴포넌트는 Layout 에서 한 번만 선언하면 모든 페이지에서 자동으로 렌더링됩니다.

// Layout.tsx
export default function Layout({ children }: LayoutProps) {
  return (
    <LayoutStyle>
      <Header />
      <main>{children}</main>
      <Footer />
    </LayoutStyle>
  );
}
profile
안녕하세요 매일의 배움을 기록으로 자산화하는 개발자 이규현입니다 😊

0개의 댓글