본문으로 건너뛰기

원티드 프리온보딩 과제 1, 2일차

· 약 14분
arch-spatula

1일차

  • github에 이런 저런 설정을 해뒀습니다.

    • 위키 페이지를 작성했습니다.
      • 커밋할 때 게으르게 스니펫을 활용할 것입니다.
      • 브랜치명도 스니펫을 남겼습니다.
  • 다행이 직접 구현과 관련없는 라이브러리는 사용할 수 있었습니다.

    • Jest & RTL을 활용할 수 있을 것으로 보입니다.
    • MSW를 추가로 설치해도 불이익이 없을 것 같습니다.
    • 저는 저의 코드에 불신하는 훌륭한 개발자가 되기 위해 필요했습니다.
  • tailwind CSS 를 설치할 것입니다.

    • 스타일링을 유지보수할 생각이 없습니다.

처음에는 처음에는 ContextAPI로 작업할 것입니다. 나중에는 Jotai 라이브러리를 직접 구현하는 튜토리얼을 활용해서 만들고 리팩토링을 진행할 것입니다. 리이브러리를 활용하지

Jotai를 DIY로 만드는 튜토리얼이 있었습니다. 라이브러리 설치가 아닌 구현으로 우회하는 것이기 때문에 창의적인 문제 해결이라 생각 됩니다. 물론 저의 추측에 불과합니다.

물론 contextAPI로 전역상태관리도 생각하고 있습니다.

git 히스토리도 잘 체크할 것 같습니다. 일단은 git-flow 전략을 활용할 것입니다.

404 페이지도 제공해야 하기 때문에 실제 제출은 1.0.0 이상이 될 것이라 예상합니다.


2일차

Router 컴포넌트로 라우팅 관심사 분리

  • 페이지별로 컴포넌트를 옮겼습니다.
// App.tsx
import { MouseEvent } from 'react';

/**
* @todo 1. 라우트 컴포넌트로 관심사 분리하기
* @todo 2. 버튼 컴포넌트 분리
* @todo 3. 환경변수 설정으로 개발환경, 배포환경 origin 분리하기
*/

function App() {
const handleSignUp = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
window.location.href = '/signup';
};
const handleSignIn = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
window.location.href = '/signin';
};

switch (window.location.href) {
case 'http://localhost:3000/signin':
return <div>signin</div>;
case 'http://localhost:3000/signup':
return <div>signup</div>;
case 'http://localhost:3000/':
return (
<div className="flex h-screen items-center justify-center gap-4">
<button
className="box-border flex w-40 border-collapse justify-center self-center rounded border border-green-500 bg-white py-2 text-green-500"
onClick={(e) => handleSignUp(e)}
>
회원가입
</button>
<button
className="w-40 rounded bg-green-500 px-4 py-2 text-white"
onClick={handleSignIn}
>
로그인
</button>
</div>
);
default:
return <div>404page</div>;
}
}

export default App;

하드 코딩되어 있는 http://localhost:3000이 문제였습니다. 배포하게 되면 origin이 바뀌는데 생각을 너무 짧게 하고 있었습니다.

// Router.tsx
import { Main, NotFound, Signin, Signup } from '../pages';

/**
* @todo 1. 레이아웃을 위한 컴포넌트를 추가합니다. Nav, Header, Footer
*/
function Router() {
switch (window.location.href) {
case window.origin + '/signin':
return <Signin />;
case window.origin + '/signup':
return <Signup />;
case window.origin + '/':
return <Main />;
default:
return <NotFound />;
}
}

export default Router;
import Router from './router/Router';

function App() {
return <Router />;
}

export default App;
  • 앱은 이제 <Router/> 컴포넌트만 호출합니다.

이메일과 비밀번호 유효성 검증

  • 이메일과 비밀번호의 유효성을 검증하는 함수와 테스트코드를 작성했습니다.
  • 이부분은 테스트부터 작성하기 쉬웠을 것 같습니다.
import checkEmail from './checkEmail';

describe('checkEmail', () => {
it("@가 없으면 '이메일에 @이 포함되어야 합니다.'라는 실패 이유를 문자열을 반환합니다.", () => {
const failEmail = 'useremail.com';
expect(checkEmail(failEmail)).toBe('이메일에 @이 포함되어야 합니다.');
});

it("@를 포함하면 ''처럼 비어있는 문자열을 반환합니다.", () => {
const successEmail = 'user@email.com';
expect(checkEmail(successEmail)).toBe('');
});
});
import checkPassword from './checkPassword';

describe('checkPassword', () => {
it("8자리 미만이면 '비밀번호는 8자리 이상이어야 합니다.'라는 실패 이유를 문자열로 반환합니다.", () => {
const failPassword = '1234567';
expect(checkPassword(failPassword)).toBe(
'비밀번호는 8자리 이상이어야 합니다.'
);
});

it("8자리 이상이면 ''처럼 비어있는 문자열로 반환합니다.", () => {
const successPassword = '12345678';
expect(checkPassword(successPassword)).toBe('');
});
});
  • 과제의 요구사항도 단순했습니다. 단순한 만큼 기초적인 확장성을 고민할 수 있는 사람이었는지 측정하고 싶어했을 것 같습니다.
/**
* @param {string} str email인지 검증할 문자열을 대입합니다.
* @returns {string} 실패하는 이유를 설명하는 문자열을 반환합니다.
* switch 문으로 작성하면 검증 조건추가를 확장하기 쉽습니다.
*/
function checkEmail(str: string) {
const regexEmail = /@/;
switch (false) {
case regexEmail.test(str):
return '이메일에 @이 포함되어야 합니다.';
default:
return '';
}
}

export default checkEmail;
/**
* @param {string} str password인지 검증할 문자열을 대입합니다.
* @returns {string} 실패하는 이유를 설명하는 문자열을 반환합니다.
* switch 문으로 작성하면 검증 조건추가를 확장하기 쉽습니다.
*/
function checkPassword(str: string) {
const regexPassword = /^.{8,}$/;
switch (false) {
case regexPassword.test(str):
return '비밀번호는 8자리 이상이어야 합니다.';
default:
return '';
}
}

export default checkPassword;

횡단 관심사

  • 횡단 관심사라는 용어를 알게되었습니다.

    횡단 관심사(Cross-cutting concern)는 핵심 관심에 영향을 주는 프로그램의 영역

  • 고차컴포넌트를 찾아보는 과정에서 발견했습니다.
  • 하지만 가치를 아직 모르겠습니다. 그만큼 지식이 부족합니다.

CustomButton Switch-Case 리팩토링

interface CustomButtonProps {
text: string;
hierarchy: 'primary' | 'secondary';
href?: string;
onClick?: () => void;
}

/**
* @param {CustomButtonProps} Props href가 있으면 onClick을 사용하지 않습니다. onClick이 있으면 href를 사용하지 않습니다.
* @returns {HTMLAnchorElement | HTMLButtonElement}
* <CustomButton text={buttonText} hierarchy="primary" onClick={() => {}} />
* <CustomButton text={buttonText} hierarchy="primary" href="/" />
* @see https://www.builder.io/blog/buttons
* @todo 1. href와 onClick이 호출할 때 상호배타적이도록 타입을 지정합니다.
*/

function CustomButton({ text, href, hierarchy, onClick }: CustomButtonProps) {
let styling = '';
switch (hierarchy) {
case 'primary':
styling =
'w-40 rounded bg-green-500 px-4 py-2 text-white flex justify-center';
break;
case 'secondary':
styling =
'box-border flex w-40 border-collapse justify-center self-center rounded border border-green-500 bg-white py-2 text-green-500';
break;
default:
styling = 'w-40 rounded bg-green-500 px-4 py-2 text-white';
break;
}

if (href) {
return (
<a className={styling} href={href}>
{text}
</a>
);
}

return (
<button className={styling} type="button" onClick={onClick}>
{text}
</button>
);
}

export default CustomButton;

원래 코드였습니다. 하지만 data-testid를 넣어야 할 것을 잊었습니다. 그리고 비활성화도 잊고 있었습니다. 이런 문제 때문에 일단은 switch-case 문으로 작성했었습니다. 커밋도 안 올렸습니다. 깃헙 이슈에 보존만 했습니다.

interface CustomButtonProps {
text: string;
hierarchy: 'primary' | 'secondary';
href?: string;
onClick?: () => void;
testId?: string;
disabled?: boolean;
}

/**
* @param {CustomButtonProps} Props href가 있으면 onClick을 사용하지 않습니다. onClick이 있으면 href를 사용하지 않습니다.
* @returns {HTMLAnchorElement | HTMLButtonElement}
* <CustomButton text={buttonText} hierarchy="primary" onClick={() => {}} />
* <CustomButton text={buttonText} hierarchy="primary" href="/" />
* @see https://www.builder.io/blog/buttons
* @todo 1. href와 onClick이 호출할 때 상호배타적이도록 타입을 지정합니다.
*/

function CustomButton({
text,
href,
hierarchy,
onClick,
testId,
disabled,
}: CustomButtonProps) {
let styling = '';
switch (hierarchy) {
case 'primary':
styling =
'w-40 rounded bg-green-500 px-4 py-2 text-white flex justify-center hover:bg-green-400 focus:bg-green-600';
break;
case 'secondary':
styling =
'box-border flex w-40 border-collapse justify-center self-center rounded border border-green-500 bg-white py-2 text-green-500 hover:bg-green-50 focus:bg-green-100';
break;

default:
styling = 'w-40 rounded bg-green-500 px-4 py-2 text-white';
break;
}

switch (true) {
// <CustomButton text={buttonText} hierarchy="primary" href="/" />
case !!href && !!testId && !disabled:
return (
<a className={styling} href={href} data-testid={testId}>
{text}
</a>
);

// <CustomButton text={buttonText} hierarchy="primary" href="/" disabled/>
case !!href && !!testId && disabled:
return (
<a
className={styling + 'pointer-events-none cursor-default'}
href={href}
data-testid={testId}
>
{text}
</a>
);

case !!href && !testId && !disabled:
return (
<a className={styling} href={href}>
{text}
</a>
);

case !!href && !testId && disabled:
return (
<a
className={styling + 'pointer-events-none cursor-default'}
href={href}
>
{text}
</a>
);

case !href && !!testId && !disabled:
return (
<button
className={styling}
type="button"
onClick={onClick}
data-testid={testId}
>
{text}
</button>
);

case !href && !!testId && disabled:
return (
<button
className={styling}
type="button"
onClick={onClick}
data-testid={testId}
>
{text}
</button>
);

case !href && !testId && !disabled:
return (
<button className={styling} type="button" onClick={onClick}>
{text}
</button>
);

case !href && !testId && disabled:
return (
<button className={styling} type="button" onClick={onClick} disabled>
{text}
</button>
);

default:
return (
<button className={styling} type="button" onClick={onClick}>
{text}
</button>
);
}
}

export default CustomButton;

interface LinkProps {
href: string;
text: string;
styling: string;
testId?: string;
disabled?: boolean;
}

function Link({ href, testId, text, styling, disabled }: LinkProps) {
return (
<a
className={styling + disabled && 'pointer-events-none cursor-default'}
href={href}
data-testid={testId}
>
{text}
</a>
);
}

interface ButtonProps {
text: string;
onClick: () => void;
styling: string;
testId?: string;
disabled?: boolean;
}

function Button({ text, onClick, styling, testId, disabled }: ButtonProps) {
return (
<button className={styling} onClick={onClick} data-testid={testId}>
{text}
</button>
);
}

이렇게 드러운 코드 고치는 방법이 있었습니다. 조건부 props도 존재했습니다. 리액트를 꽤 오랫동안 다루었지만 오늘 처음봤습니다. 아니면 초반에 보고 쓸 일이 많지 않아서 잊고 있던 것일수도 있습니다.

interface CustomButtonProps {
text: string;
hierarchy: 'primary' | 'secondary';
href?: string;
onClick?: () => void;
testId?: string;
disabled?: boolean;
}

/**
* @param {CustomButtonProps} Props href가 있으면 onClick을 사용하지 않습니다. onClick이 있으면 href를 사용하지 않습니다.
* @returns {HTMLAnchorElement | HTMLButtonElement}
* <CustomButton text={buttonText} hierarchy="primary" onClick={() => {}} />
* <CustomButton text={buttonText} hierarchy="secondary" href="/" />
* @see https://www.builder.io/blog/buttons
* @todo 1. href와 onClick이 호출할 때 상호배타적이도록 타입을 지정합니다.
*/

function CustomButton({
text,
href,
hierarchy,
onClick,
testId,
disabled,
}: CustomButtonProps) {
let styling = '';
switch (hierarchy) {
case 'primary':
styling = !disabled
? 'w-40 rounded bg-green-500 px-4 py-2 text-white flex justify-center hover:bg-green-400 active:bg-green-600'
: 'pointer-events-none flex w-40 cursor-default justify-center rounded bg-gray-700 px-4 py-2 text-gray-400';

break;
case 'secondary':
styling = !disabled
? 'box-border flex w-40 border-collapse justify-center self-center rounded border border-green-500 bg-white py-2 text-green-500 hover:bg-green-50 active:bg-green-100'
: 'pointer-events-none box-border flex w-40 border-collapse cursor-default justify-center self-center rounded border border-gray-700 bg-white py-2 text-gray-700';
break;

default:
styling = 'w-40 rounded bg-green-500 px-4 py-2 text-white';
break;
}

if (href) {
return (
<a
className={styling}
href={href}
{...(testId && { 'data-testid': testId })}
>
{text}
</a>
);
}

return (
<button
className={styling}
type="button"
onClick={onClick}
{...(testId && { 'data-testid': testId })}
{...(disabled && { disabled })}
>
{text}
</button>
);
}

export default CustomButton;

{...(props 이름) && {"(속성 이름)": (속성 값)}} 이렇게 작성해서 넣으면 조건부로 props를 넣을 수 있습니다.

custom button 테스트 코드 작성

import { render, screen } from '@testing-library/react';
import CustomButton from './CustomButton';

describe('CustomButton', () => {
it('부여한 속성의 값이 화면에 보여야 합니다.', () => {
const buttonText = '회원가입';
render(
<CustomButton text={buttonText} hierarchy="primary" onClick={() => {}} />
);
const textElement = screen.getByText(buttonText);
expect(textElement).toBeInTheDocument();
});

it('href 속성을 부여하지 않으면 button을 해야 합니다.', () => {
render(
<CustomButton text="회원가입" hierarchy="primary" onClick={() => {}} />
);
const buttonElement = screen.getByRole('button');
expect(buttonElement).toBeInTheDocument();
});

it('href 속성을 부여하면 a태그 역할을 해야 합니다.', () => {
render(<CustomButton text="회원가입" hierarchy="primary" href="/" />);
const anchorElement = screen.getByRole('link');
expect(anchorElement).toBeInTheDocument();
});
});
  • 제가 만든 컴포넌트에 테스트 코드를 작성했습니다. 아무리 허접한 부트캠프 출신이라도 테스트는 기본이지만 뿌듯했습니다.