[TypeScript/Keupang] #4 공통 컴포넌트 Button 구현

HoneyCode Lab·2024년 12월 2일

프로젝트

목록 보기
4/8
post-thumbnail

개요


✅Keupang Github

이제 본격적인 컴포넌트 개발을 진행한다.

포스팅은 Button 하나만 할 생각이지만, 여기서 다룬 생각들을 기반으로 여러 컴포넌트들을 구성할 생각이다.

 


공통 컴포넌트?


개발을 진행하다보면 반복적으로 사용되는 컴포넌트들이 많다.

Button - 버튼 제작에 기본이 되는 컴포넌트
InputField - 사용자에게 입력을 받는 컴포넌트
Card - 상품 나열, 프로필 등 이미지, 텍스트로 이루어진 컴포넌트
Modal - 각종 모달 창에서 사용할 컴포넌트
Spinner - 로딩에서 사용할 스피너 컴포넌트

이 외에도 여러 공통 컴포넌트가 있을 것이다.

이를 여러 상황에서 대응하여 사용할 수 있도록 제작하여, 재사용성을 증가시킨다.

여기서, 공통 컴포넌트의 원래 목적인 재사용성유지보수성 잊으면 안된다.

 


Button 컴포넌트 제작


위의 공통 컴포넌트 중 Button을 제작해보자.

 

Type 정의하기


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'};
	}
`;
  1. background-color : variant에 따라 다른 스타일을 가질 수 있도록 삼항 연산자를 활용하였다.
    theme을 이용해 테마에 적응할 수 있도록 하였다.
  2. color : 텍스트의 색깔 또한 테마에 대응할 수 있도록 작성하였다.
  3. padding : 버튼 내부의 padding은 size에 고려하여 설계하였다.
  4. border : 버튼의 기본 스타일은 border에 검은색 선이 존재한다.
    이는 필요없는 효과이기 때문에 없애주었다.
  5. border-radius : 버튼의 끝을 둥글게 해주었다.
  6. cursor : 버튼위에 마우스를 hover하면 클릭할 수 있다는 UX를 제공해주기 위해 설정하였다.
  7. margin-top : 버튼 위의 아이템과 간격이 적당히 벌어지도록 하였다.
  8. opacity : disabled의 값에 따라 흐리게 보이면, 클릭할 수 없는 버튼임을 알 수 있도록 하였다.
  9. pointer-events : 마찬가지로, disabled의 값에 따라 클릭할 수 없도록 설정하였다.
  10. transition : hover시 background-color가 서서히 바뀌도록 하였다.
  11. 사용자의 화면 크기에 따라 반응형으로 작동하도록 미디어쿼리를 작성하였다.

추 후 필요한 디자인이 있으면 더 추가할 것이다.

 

Button 컴포넌트 작성


위의 타입과 스타일을 바탕으로 컴포넌트를 작성한다.

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 속성으로 정확히 하나의 타입만 사용하여 이를 주석으로 잘 나타내어 사용하면 편하게 사용할 수 있지만, 현재 컴포넌트에선 이와 같은 사용을 하면 안된다고 판단을 했다.

 

Button 전체코드

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 버튼도 만들어보았다.

잘 작동하는 것을 확인했다.

 

테스트 작성하기


공통 컴포넌트는 재사용성이 높고 프로젝트 전반에 걸쳐 자주 사용되기 때문에 테스트를 작성하는 것이 일반적이다.

공통 컴포넌트의 품질 보장은 전체 애플리케이션의 안정성과 유지보수성에 큰 영향을 미치기 때문에 테스트를 작성해보았다.

테스트할 목록은 아래와 같이 생각했다.

  1. 제대로 렌더링이 이루어지는가?
  2. variant 속성에 따라 올바른 스타일이 적용되는가?
  3. size 속성에 따라 올바른 크기가 적용되는가?
  4. 버튼 클릭 시 onClick 핸들러가 호출되는가?
  5. 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을 생성하여 이벤트를 감지하였다.

테스트가 잘 작동되었다.

 


Summary


  • 공통 컴포넌트?
  • Button 컴포넌트 제작
  • 프로젝트에 적용
  • 테스트 작성
profile
“왜?”라는 질문을 멈추지 않고 본질에 집중하는 개발자입니다.

0개의 댓글