
지난 시간에 이어 타입스크립트를 활용한 동적 UI 개발을 진행하겠습니다. 이번 시간에는 화면 구성의 바탕이 되는 기본 컴포넌트(Title, Button, Input) 를 작성하고, 공통 레이아웃인 헤더와 푸터를 구현하는 과정을 정리했습니다.
제목을 표시하는 공통 컴포넌트입니다. 컴포넌트 내부의 텍스트는 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' });
});
});
사용자의 클릭 이벤트를 처리하는 버튼 컴포넌트입니다. 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' });
});
});
사용자로부터 텍스트를 입력받는 컴포넌트입니다. 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);
});
});
헤더와 푸터는 웹페이지의 공통적인 레이아웃 요소로, 여러 페이지에서 동일하게 사용해 사이트 전체의 일관성을 유지하는 역할을 합니다.
두 컴포넌트 모두 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>
);
}