
이제 본격적인 컴포넌트 개발을 진행한다.
포스팅은 Button 하나만 할 생각이지만, 여기서 다룬 생각들을 기반으로 여러 컴포넌트들을 구성할 생각이다.
개발을 진행하다보면 반복적으로 사용되는 컴포넌트들이 많다.
Button - 버튼 제작에 기본이 되는 컴포넌트
InputField - 사용자에게 입력을 받는 컴포넌트
Card - 상품 나열, 프로필 등 이미지, 텍스트로 이루어진 컴포넌트
Modal - 각종 모달 창에서 사용할 컴포넌트
Spinner - 로딩에서 사용할 스피너 컴포넌트
이 외에도 여러 공통 컴포넌트가 있을 것이다.
이를 여러 상황에서 대응하여 사용할 수 있도록 제작하여, 재사용성을 증가시킨다.
여기서, 공통 컴포넌트의 원래 목적인 재사용성과 유지보수성 잊으면 안된다.
위의 공통 컴포넌트 중 Button을 제작해보자.
Button은 여러 attribute를 가지고 있다.
공통으로 사용하기 위해 어떤게 사용될 지 생각하고, 이를 잘 대응하는게 중요하다.
나는 아래와 같은 것들을 고려했다.
1. variant #어떤 스타일을 사용할 것인가?
2. size #크기는 어느정도로 사용할 것인가?
3. disabled #보여지지 않게 할 것 인가?
4. onClick #클릭 이벤트는 무엇인가?
5. children #컴포넌트 내부는 무엇인가?
위와 같은 고려사항을 생각하여 5가지 속성에 대한 타입을 정의했다.
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
}
1. 기본적인 스타일은 3가지를 가진다.
2. 사이즈는 작게, 중간, 크게 총 3가지를 가진다.
3. disabled는 boolean으로 받아 관리한다.
4. 클릭 이벤트는 함수로 사용한다.
5. children은 텍스트뿐만 아니라 아이콘, 다른 컴포넌트 등을 버튼 내부에 배치할 수 있도록 유연하게 설계 했다.
Emotion을 이용하여 스타일을 정의해야한다.
다크모드, 라이트모드 모두 대응할 수 있도록 구성해야하고 반응형 또한 신경써주었다.
작성한 스타일은 10가지이다.
const StyledButton = styled.button<ButtonProps>`
background-color: ${({ theme, variant }) =>
variant === 'primary'
? theme.colors.primary
: variant === 'danger'
? theme.colors.danger
: theme.colors.secondary};
color: ${({ theme }) => theme.colors.text};
padding: ${({ theme, size }) =>
size === 'small'
? theme.spacing.sm
: size === 'large'
? theme.spacing.lg
: theme.spacing.md};
border: none;
border-radius: 8px;
cursor: pointer;
margin-top: ${({ theme }) => theme.spacing.md};
opacity: ${({ disabled }) => (disabled ? 0.6 : 1)};
pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')};
transition: background-color 0.3s;
&:hover {
background-color: ${({ theme, variant }) =>
variant === 'primary'
? theme.colors.secondary
: variant === 'danger'
? '#cc3333'
: '#b0b0b0'};
}
@media (max-width: 768px) {
padding: ${({ size }) =>
size === 'small'
? '6px 10px'
: size === 'large'
? '10px 20px'
: '8px 16px'};
font-size: ${({ size }) =>
size === 'small' ? '12px' : size === 'large' ? '16px' : '14px'};
}
@media (max-width: 480px) {
width: 80%;
padding: ${({ size }) =>
size === 'small'
? '4px 8px'
: size === 'large'
? '8px 16px'
: '6px 12px'};
font-size: ${({ size }) =>
size === 'small' ? '10px' : size === 'large' ? '14px' : '12px'};
}
`;
추 후 필요한 디자인이 있으면 더 추가할 것이다.
위의 타입과 스타일을 바탕으로 컴포넌트를 작성한다.
export const Button = ({
variant = 'primary',
size = 'medium',
disabled = false,
onClick,
children,
}: ButtonProps) => (
<StyledButton
variant={variant}
size={size}
disabled={disabled}
onClick={onClick}>
{children}
</StyledButton>
);
기본적으로 default값을 설정하여 기본적인 버튼 스타일을 가질 수 있도록 하였다.
버튼의 타입을 작성하는 방법으로
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'medium',
disabled = false,
onClick,
children,
}) => (
<StyledButton variant={variant} size={size} disabled={disabled} onClick={onClick}>
{children}
</StyledButton>
);
React.FC
를 사용하는 방법이 있다.
이는 뭘까?
CRA에서는 기본 템플릿에 FC를 빼야한다는 PR이 올라왔었고, 실제 반영되었다.
그 이유를 생각해보자.
children
React.FC에는 기본적으로 children 속성이 포함되어있다.
children을 생각해주지 않아도되어서 편하다고 생각할 수 있지만, 이는 TypeScript의 사용 목적에 모순을 일으킨다.
정확한 타입을 지정해주어 코드에 안정성을 높여주는게 TypeScript인데, 이에 대한 목적성을 잃는 방향이다.
이 경우엔 타입을 정확히 지정하지 않고 사용할 수 있지만, 그만큼 안정성을 잃는다.
물론, children 속성으로 정확히 하나의 타입만 사용하여 이를 주석으로 잘 나타내어 사용하면 편하게 사용할 수 있지만, 현재 컴포넌트에선 이와 같은 사용을 하면 안된다고 판단을 했다.
import styled from '@emotion/styled';
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
}
const StyledButton = styled.button<ButtonProps>`
background-color: ${({ theme, variant }) =>
variant === 'primary'
? theme.colors.primary
: variant === 'danger'
? theme.colors.danger
: theme.colors.secondary};
color: ${({ theme }) => theme.colors.text};
padding: ${({ theme, size }) =>
size === 'small'
? theme.spacing.sm
: size === 'large'
? theme.spacing.lg
: theme.spacing.md};
border: none;
border-radius: 8px;
cursor: pointer;
margin-top: ${({ theme }) => theme.spacing.md};
opacity: ${({ disabled }) => (disabled ? 0.6 : 1)};
pointer-events: ${({ disabled }) => (disabled ? 'none' : 'auto')};
transition: background-color 0.3s;
&:hover {
background-color: ${({ theme, variant }) =>
variant === 'primary'
? theme.colors.secondary
: variant === 'danger'
? '#cc3333'
: '#b0b0b0'};
}
@media (max-width: 768px) {
padding: ${({ size }) =>
size === 'small'
? '6px 10px'
: size === 'large'
? '10px 20px'
: '8px 16px'};
font-size: ${({ size }) =>
size === 'small' ? '12px' : size === 'large' ? '16px' : '14px'};
}
@media (max-width: 480px) {
width: 80%;
padding: ${({ size }) =>
size === 'small'
? '4px 8px'
: size === 'large'
? '8px 16px'
: '6px 12px'};
font-size: ${({ size }) =>
size === 'small' ? '10px' : size === 'large' ? '14px' : '12px'};
}
`;
export const Button = ({
variant = 'primary',
size = 'medium',
disabled = false,
onClick,
children,
}: ButtonProps) => (
<StyledButton
variant={variant}
size={size}
disabled={disabled}
onClick={onClick}>
{children}
</StyledButton>
);
작성한 Button 컴포넌트를 이용해서 테마 변경 버튼을 만들어보자.
적절히 버튼을 import하고, 이벤트만 잘 전달하면 된다.
추가적으로, danger 버튼도 잘 작동하는지 한번 작성해보자.
import { ThemeProvider } from '@emotion/react';
import { lightTheme, darkTheme } from './styles/theme';
import { useState } from 'react';
import styled from '@emotion/styled';
import GlobalStyles from './styles/GlobalStyles';
import ProductList from './components/ProductList';
import { Button } from './components/Button';
const StyledDiv = styled.div`
background-color: ${({ theme }) => theme.colors.background};
color: ${({ theme }) => theme.colors.text};
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
`;
const App = () => {
const [isDarkMode, setIsDarkMode] = useState(() => {
const savedTheme = localStorage.getItem('theme');
return savedTheme ? JSON.parse(savedTheme) : false;
});
const toggleTheme = () => {
setIsDarkMode((prevMode: Boolean) => {
const newMode = !prevMode;
localStorage.setItem('theme', JSON.stringify(newMode));
return newMode;
});
};
return (
<ThemeProvider theme={isDarkMode ? darkTheme : lightTheme}>
<GlobalStyles />
<StyledDiv>
<h1>현재 테마: {isDarkMode ? '다크 모드' : '라이트 모드'}</h1>
<Button onClick={toggleTheme}>Toggle Theme</Button>
<Button
variant='danger'
size='large'
onClick={() => alert('Danger Button')}>
Danger Button
</Button>
<ProductList />
</StyledDiv>
</ThemeProvider>
);
};
export default App;
버튼 컴포넌트를 불러와 toggleTheme 이벤트를 전달해주었다.
이를 통해 테마 변경이 잘 이루어 지는지 확인할 수 있고, danger 버튼도 만들어보았다.

잘 작동하는 것을 확인했다.
공통 컴포넌트는 재사용성이 높고 프로젝트 전반에 걸쳐 자주 사용되기 때문에 테스트를 작성하는 것이 일반적이다.
공통 컴포넌트의 품질 보장은 전체 애플리케이션의 안정성과 유지보수성에 큰 영향을 미치기 때문에 테스트를 작성해보았다.
테스트할 목록은 아래와 같이 생각했다.
- 제대로 렌더링이 이루어지는가?
- variant 속성에 따라 올바른 스타일이 적용되는가?
- size 속성에 따라 올바른 크기가 적용되는가?
- 버튼 클릭 시 onClick 핸들러가 호출되는가?
- disabled 속성이 true일 때 버튼이 비활성화 되는가?
즉, 타입으로 설정해준 유동적인 값에 따라서 버튼이 이를 잘 대응하는가를 테스트 해주었다.
테스트에는 정확한 주석으로 어떤 테스트를 어떻게 했는지 작성하는게 중요하기 때문에 주석 작성도 신경써주었다.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '@emotion/react';
import { Button } from '../Button';
import { lightTheme } from '../../styles/theme';
import { describe, expect, it, vi } from 'vitest';
describe('버튼 컴포넌트 테스트 > ', () => {
it('올바른 텍스트로 버튼이 렌더링된다', () => {
render(
<ThemeProvider theme={lightTheme}>
<Button>클릭하세요</Button>
</ThemeProvider>
);
// 버튼이 렌더링되었는지 확인
expect(
screen.getByRole('button', { name: '클릭하세요' })
).toBeInTheDocument();
});
it('variant 속성에 따라 올바른 스타일이 적용된다', () => {
render(
<ThemeProvider theme={lightTheme}>
<Button variant='danger'>위험 버튼</Button>
</ThemeProvider>
);
// Danger 스타일 확인
const button = screen.getByRole('button', { name: '위험 버튼' });
expect(button).toHaveStyle(`background-color: #cc3333`);
});
it('size 속성에 따라 올바른 크기가 적용된다', () => {
render(
<ThemeProvider theme={lightTheme}>
<Button size='large'>큰 버튼</Button>
</ThemeProvider>
);
// Large 스타일 확인
const button = screen.getByRole('button', { name: '큰 버튼' });
expect(button).toHaveStyle(`padding: ${lightTheme.spacing.lg}`);
});
it('버튼 클릭 시 onClick 핸들러가 호출된다', async () => {
const onClickMock = vi.fn();
render(
<ThemeProvider theme={lightTheme}>
<Button onClick={onClickMock}>클릭하세요</Button>
</ThemeProvider>
);
const button = screen.getByRole('button', { name: '클릭하세요' });
await userEvent.click(button);
// 클릭 이벤트가 호출되었는지 확인
expect(onClickMock).toHaveBeenCalledTimes(1);
});
it('disabled 속성이 true일 때 버튼이 비활성화된다', async () => {
const onClickMock = vi.fn();
render(
<ThemeProvider theme={lightTheme}>
<Button disabled onClick={onClickMock}>
비활성화된 버튼
</Button>
</ThemeProvider>
);
const button = screen.getByRole('button', { name: '비활성화된 버튼' });
// Disabled 상태 확인
expect(button).toBeDisabled();
// 클릭 이벤트가 호출되지 않아야 함
expect(onClickMock).not.toHaveBeenCalled();
});
});
테스트 작성에 주의할 점은 Button 사용지 theme를 사용하기 때문에 ThemeProvider로 감싼 Button을 렌더링하여 테스트를 진행해야한다.
컴포넌트 테스트를 위해 screen.callbackfc()를 적극 활용하였고, 스타일 확인을 위해 toHaveStyle 함수도 적절히 사용해주었다.
클릭이 되었는지/안되었는지 확인하기 위해 vi.fn()을 통해 onClickMock을 생성하여 이벤트를 감지하였다.

테스트가 잘 작동되었다.