LIGHTLOG
article thumbnail

 

Content

1. 간단한 소감
2. 수업 요약 및 연계된 코드 리팩토링
3. 새롭게 알게 된 내용 feat. 리뷰리뷰스터디


1. 간단한 소감

https://react-payments-git-step3-kangyeongmin.vercel.app/

 

React App

 

react-payments-git-step3-kangyeongmin.vercel.app

이번 글은 레벨 2의 두번째 미션 '페이먼츠'에 대한 회고이다. 

카드 정보를 간편하게 입력하는 것이 핵심이었고, storybook을 활용한 CDD를 처음 도입해본 미션이었다. 

 

리뷰어와도 디엠으로도 많은 이야기를 나누며 리팩토링을 진행했고
그를 통해 많은 것을 깨닫고 성장한 미션이어서

왠지 모르게 애틋한 미션이다.

 

어서 빨리 망각하기 전에 기록하자!!


2. 수업 요약 및 연계된 코드 리팩토링

CDD란?

Component Driven Development의 약자이다. 컴포넌트에 대한 재사용성과 유지보수성을 높이고 협업을 용이하게 한다는 장점을 가진다. 이 CDD를 잘하면 말 그대로 '컴포넌트가 춤을 춘다~✨💃🏻'란다. (아직 활발히 춤을 추는 컴포넌트를 만들지는 못한 것 같지만, 춤을 춘다라는 느낌이 무엇인지는 대충 알 것 같다.) 

 

Storybook을 활용하면 컴포넌트 별로 테스트 및 구조 파악이 가능하고, 독립적인 컴포넌트를 만드는 의식적인 연습이 가능하다고 한다. 

요약하자면, 

독립적인 컴포넌트 단위로 개발하고, 배포하고, 테스트하고, 피드백받기 위함이다.

 

결국 CDD를 잘하기 위해서는 다시 TDD로 돌아가야 한다!


TDD 역자가 정의한 TDD cycle 7단계 

1. 전체 문제가 해결되었을 때, 어떤 상태일지 상상하기. 결국 내가 뭘하려는 걸까?

2. 적당한 난이도로 문제를 쪼개거나 변형하기. 단 핵심을 포함하도록.

-> 이 부분에서 주니어와 시니어 개발자의 차이가 드러난다고 한다. 잘하는 개발자는 요구사항을 세분화하는 능력이 남다르다는 점.

3. 핵심과 가까우면서 쉽게 할 수 있는 적절한 것을 하나 선택한다. 순차적으로 뭐부터 하는 것이 수월하고 유리한지?

4. 결과치가 뭔지 구체화하고 최대한 진짜처럼 시뮬레이션. 현실상황, 세계에서 일어날 수 있는 일 들 떠올리기!
5. 동작가능한 가장 작은 버전의 솔루션을 만들고 테스트가 통과하는지 확인한다.

6. 중복을 줄이거나 의도가 드러나게 리팩토링한다.

7. 다시 1번이나 2번으로 돌아간다.

 

2번에서 핵심을 파악하는 기준은 '결국 사용자가 무엇을 원하는 지, 사용자에게 뭐가 좋을지'

 

수업시간에 신선했던 부분은 '테스트 코드를 작성해야지만 TDD가 아니라는 점'이다.

테스트코드가 있으면 유용하지만, TDD는 결정과 피드백 사이의 갭에 대해 조절하는 테크닉이자 사고방식이기 때문이다.

 

전문가일수록 불확실한 상황에서 요구사항을 작은 단위로 쪼갠다.

5~10분마다 구현 및 동작을 테스트할 수 있는 수준으로!


제어 컴포넌트 vs 비제어 컴포넌트

상태가 줄어들면 다음과 같은 점이 좋다.

  • 성능이 향상된다.
  • 에러/버그가 줄어든다.
  • 코드의 복잡도나 양이 줄어든다.
  • 재사용성이 증가한다.
  • 관심사가 줄어들고 테스트가 쉬워진다.

어떤 컴포넌트를 선택할지에 대한 가이드는 다음과 같다.

  • 입력값에 대한 실시간 검증이 필요한가?
  • 사용자의 입력값에 따라 컴포넌트의 동작이 달라져야 하는가?
  • 입력값이 여러 컴포넌트에서 공유되거나 전달되어야 하는가?

storybook

스토리북은 실제 현업에서 디자이너와 소통하기 위한 툴로써 쓰인다.

testing이 필요한 컴포넌트는 사용빈도가 높거나 기능이 복잡하거나 중요한 역할을 하는 컴포넌트이다.

 

나는 이번 미션에서 `Header`, `Card`, `CardInputForm` 등 내가 작성한 컴포넌트에 대한 스토리들을 작성했다. 

 

step2의 코드 리뷰 중 story에 공통되는 속성들이 나열되어 있는 점에 대해 지적을 받았다. 

import { StoryObj } from "@storybook/react";
import Card from "../components/Card";

const meta = {
  title: "Card",
  component: Card,
};

export default meta;
type Story = StoryObj<typeof meta>;

export const EmptyCard: Story = {
  args: {
    cardNumber: "1111-2222-3333-4444",
    ownerName: "light",
    expiredDate: "12 / 24",
    cardCompany: "카드사선택필요",
    name: "빈카드",
  },
};

export const BcCard: Story = {
  args: {
    cardNumber: "1111-2222-3333-4444",
    ownerName: "light",
    expiredDate: "12 / 24",
    cardCompany: "BC카드",
    name: "엄카",
  },
};

 

하지만 아래처럼 Template.bind()를 사용하면, 중복되는 속성들을 미리 템플릿으로 선언하고 편리하게 스토리들을 작성할 수 있었다. 

import { StoryFn, Meta } from "@storybook/react";
import Card from "../components/Card";
import type { CardType } from "../types";

export default {
  title: "Card",
  component: Card,
} as Meta<CardType>;

const Template: StoryFn<CardType> = (props) => (
  <Card
    {...props}
    cardNumber="1111 2222 3333 4444"
    ownerName="LIGHT"
    expiredDate="12 / 24"
    name="카드"
    cvc="123"
    password="12"
  />
);

export const BcCard = Template.bind({});
BcCard.args = {
  cardCompany: "BC카드",
};

export const ShinhanCard = Template.bind({});
ShinhanCard.args = {
  cardCompany: "신한카드",
};

 

카드 인풋 폼 인터랙션 테스트도 하나 작성했었다!

작성하면서 내가 검사하고자 한 유효성 구멍에 적절한 안내메세지가 나오는지 다시 한번 스스로 피드백받을 수 있었고,

유효성 검사가 제대로 동작하지 않는 부분도 하나 발견할 수 있어서 뿌듯했다. 😎

import { StoryFn, Meta } from "@storybook/react";
import { CardInputForm } from "../components";
import { CardInputFormType } from "../components/CardInputForm";
import { useCard } from "../hooks";
import { within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect } from "@storybook/jest";

export default {
  title: "CardInputForm",
  component: CardInputForm,
} as Meta<CardInputFormType>;

const Template: StoryFn<CardInputFormType> = (props) => {
  const [card, isValidCard, setNewCard] = useCard();

  return (
    <CardInputForm
      card={card}
      isValidCard={isValidCard}
      setNewCard={setNewCard}
      onSubmit={() => {}}
    />
  );
};

export const CardForm = Template.bind({});

CardForm.play = async ({ canvasElement }) => {
  const canvas = within(canvasElement);

  const cardNumber = canvas.getByLabelText("카드 번호 *");
  const expiredDate = canvas.getByLabelText("만료일 *");
  const ownerName = canvas.getByLabelText("카드 소유자 이름 (선택)");
  const cvc = canvas.getByLabelText("보안 코드(CVC/CVV) *");
  const password = canvas.getByLabelText("카드 비밀번호 *");

  await userEvent.type(cardNumber, "abcdefgh", { delay: 200 });
  const numberErrorMessage = await canvas.findByText("숫자만 입력해 주세요.");
  expect(numberErrorMessage).toBeInTheDocument();
  await userEvent.clear(cardNumber);

  await userEvent.type(cardNumber, "12345678", { delay: 200 });
  const lengthErrorMessage = await canvas.findByText(
    "카드번호 16자리를 모두 입력해 주세요."
  );
  expect(lengthErrorMessage).toBeInTheDocument();
  await userEvent.type(cardNumber, "12345678", { delay: 150 });
  expect(expiredDate).toHaveFocus();

  await userEvent.type(expiredDate, "gggg", { delay: 500 });
  expect(numberErrorMessage).toBeInTheDocument();
  await userEvent.clear(expiredDate);

  await userEvent.type(expiredDate, "13", { delay: 500 });
  const monthErrorMessage = await canvas.findByText(
    "유효한 달(월)을 입력해 주세요."
  );
  expect(monthErrorMessage).toBeInTheDocument();
  await userEvent.clear(expiredDate);

  await userEvent.type(expiredDate, "1230", { delay: 500 });
  const yearErrorMessage = await canvas.findByText(
    "유효한 년(해)을 입력해 주세요."
  );
  expect(yearErrorMessage).toBeInTheDocument();
  await userEvent.clear(expiredDate);

  await userEvent.type(expiredDate, "1222", { delay: 500 });
  const expiredErrorMessage = await canvas.findByText(
    "만료일이 지난 카드입니다."
  );
  expect(expiredErrorMessage).toBeInTheDocument();
  await userEvent.clear(expiredDate);
  await userEvent.type(expiredDate, "0425", { delay: 500 });
  expect(ownerName).toHaveFocus();

  await userEvent.type(ownerName, "yeongmin", { delay: 150 });
  await userEvent.type(cvc, "12", { delay: 500 });
  const cvcLengthErrorMessage = await canvas.findByText(
    "cvc는 카드 뒤 3자리를 입력해 주세요."
  );
  expect(cvcLengthErrorMessage).toBeInTheDocument();
  await userEvent.type(cvc, "3", { delay: 500 });
  expect(password).toHaveFocus();

  await userEvent.type(password, "11", { delay: 500 });

  const submitButton = canvas.getByText("다음");
  expect(submitButton).toBeVisible();
};

UX

FE가 Front Engineering의 약자인데, Front가 등한시되고 Engineering만 생각되는 경우가 있다고 한다. 

 

'페이먼츠' 미션에서 집중해야 할 사용자 경험은

  • 카드를 편하게 등록하기
  • 에러메세지, 가이드메세지의 좋은 디자인

좋은 UX라이팅은 가성비가 개쩔기 때문에 적극 활용해야 한다.

항상 토스의 서비스를 이용하며 UX에 감탄하고는 한다.

토스에는 UX 라이터라는 직무도 존재하는데, 수업시간에 아래의 글을 공유해주셔서 재미있게 읽었다.

https://toss.tech/article/8-writing-principles-of-toss

 

토스의 8가지 라이팅 원칙들

토스의 문구는 8가지 라이팅 원칙을 고려하면서 쓰고 있어요. 사람이 말하는 것 같은 문장을 지향하면서요.

toss.tech

사실 UX는 내가 이번 미션에서 가장 많이 신경쓴 부분이 아닐까싶다.

내가 만든 서비스는 사용자에게 노출되어 편리하게 목적을 달성하게 하기 위함이고, 사용자는 내 코드를 전혀 궁금해하지 않는다...

 

1. 카드 목록 페이지

  • 헤더 고정, 하단 그림자 추가
  • 카드 hover시 애니메이션 효과
  • 카드 추가 페이지에 진입 시에 카드사 선택 모달이 바로 열림

2. 카드 추가 페이지

  • 유효한 입력이면 다음 input으로 focusing
  • 필수 입력값 * 추가
  • 사용자의 입력 상태마다 가이드 메세지 노출
  • 방향키로 input 간의 상하좌우 이동

3. 카드 별칭 + 로딩 페이지

  • 페이지 진입 시에 input autoFocus
  • 'Enter'키로 제출 가능

Context API

미션 step2에서는 Context API를 사용하는 것이 요구사항으로 존재했다.

이것의 핵심은 꼭 전역으로 사용해야 하는 것이 아니라는 점. 

Context: (어떤 일의) 맥락, 전후 사정

 

맥락을 만들고 그 맥락에 맞는 consumer가 있는 것이다. 지금은 앱의 규모가 작아서 전역인 것 처럼 보인다고 하셨다.

 

`react-router`, `redux`, `styled-component` 모두 react에서 제공하는 Context API를 사용중이다. 

다시 말해서, 같은 맥락에서 props가 아닌 효율적으로 데이터를 전달하기 위함인데

역시나 남용해서도 안된다.

context Provider에서 값이 바뀌면 provider 하위 컴포넌트들도 리렌더링 되기 때문이다.

 

상태 전달은 다음과 같은 순으로 해결책이 될 수 있다.

props -> context -> 컴포넌트 합성


Custom Hook

커스텀 훅은 처음부터 만들려고 하지 말고, CDD사이클에 초점을 맞추어 반복되는 로직을 찾아 그것을 발전시켜 나가는 것이 답이다.

 

커스텀 훅과 일반 함수로 관심사를 분리하는 것의 차이는

리액트 컴포넌트(useState, useEffect)와 가까운 관계인지 살펴봐야 한다.

 

내가 이번 미션에서 만든 커스텀 훅은 2가지이다. 

`CardInputForm` 컴포넌트에서 일일히 처리하고 있던

복잡한 set로직과 focus 이동 로직을 분리하는 경험은 너무나 인상깊었다. 🤩

/**
 * useCard Hook은 카드 객체 property를 효과적으로 수정하여 저장하는 훅입니다.
 *
 * @returns card: 현재 카드 정보를 담은 객체를 나타냅니다.
 * @returns isValidCard: 현재 저장된 카드 정보 입력값이 유효한지 boolean값을 리턴합니다.
 * @returns setNewCard: 카드 정보(카드번호, 만료일, 이름, cvc, 비밀번호)들을 각각의 상태값으로 관리하지 않고 객체로서 관리하기 때문에
 * 'key'값을 인자로 받아 수정된 객체를 set합니다. 각각의 input마다 필요한 구분자나 변환까지 처리하여 set할 수 있습니다.
 */
 
export const useCard = () => {
  const [isValidCard, setIsValidCard] = useState(false);
  const [card, setCard] = useState<CardType>(getEmptyCard());

  useEffect(() => {
    setIsValidCard(validateForm(card));
  }, [card]);

  const setNewCard = (key: keyof CardType, value: string) => {
    switch (key) {
      case "cardNumber":
        setCard({
          ...card,
          [key]: getSeperatedCardNumber(getSubCardNumber(value)),
        });
        break;
      case "expiredDate":
        setCard({
          ...card,
          [key]: getSeperatedExpiredDate(getSubExpiredDate(value)),
        });
        break;
      case "ownerName":
        setCard({ ...card, [key]: value.toLocaleUpperCase() });
        break;
      default:
        setCard({ ...card, [key]: value });
    }
  };

  return [card, isValidCard, setNewCard] as const;
};

/**
 * useCardInputRefs Hook은 input들의 Ref배열을 참조해 효과적인 focus이동을 처리하는 훅입니다.
 *
 * @returns inputRefs: 현재 input들의 Ref배열을 나타냅니다.
 * @returns moveFocus: 현재 input의 key를 인자로 받아 다음 input으로 focusing합니다.
 */
 
export const useCardInputRefs = () => {
  const inputRefs = Array.from({ length: 6 }).map(createRef<HTMLInputElement>);

  const moveFocus = (key: string) => {
    if (
      document.activeElement === inputRefs[CARD_INPUT_REFS_INDEX[key]].current
    ) {
      inputRefs[CARD_INPUT_REFS_INDEX[key] + 1].current?.focus();
    }
  };

  return [inputRefs, moveFocus] as const;
};

 


3. 새롭게 알게된 내용

styled-components에서 hover 주기

const AnswerBoxWrapper = styled.div`
  ${CvcInputWrapper} > img:hover + & {
    display: flex;
  }
  display: none;
  width: 180px;
  height: 50px;

  position: absolute;
  top: -20px;
  left: 130px;

  padding: 10px;

  background: #ecebf1;
  border-radius: 8px;

  & > p {
    align-self: center;
    font-size: 13px;
    color: #636c72;
  }
`;

styled-component에서는 `${CvcInputWrapper}`이런 식으로 해당 스타일드 컴포넌트를 선택하여 효과적으로 원하는 태그에 hover 효과를 줄 수 있었다.


dependencies, devDependencies, peerDependencies의 차이

  • deps는 배포/실행 시 사용하는 패키지
  • devDeps는 개발/테스트 단계에서만 사용하는 패키지
  • peerDeps는 반드시 특정 버전이 필요할 때 - lock

setState를 직접 prop으로 주입해서 컴포넌트에도 사용할 수 있게 했을 때 어떤 문제가 있을까?

setState 로직이 퍼져있다면 state의 변경을 추적하지 못하는 어려움이 생긴다.

또한, useState에 대한 강결합도 발생하고, 부수적인 로직을 처리하려면 일을 두 번 해줘야 한다고 한다.


CRA는 어떤 기능을 제공해주고 있고, 어떤 단점들이 있을까?

새로운 리액트 공식문서에는 CRA에 대한 설명이 사라졌다고 한다.

https://react.dev/learn/start-a-new-react-project


storybook에 보여지는 prop 조절하는 법

argTypes: {
    // foo is the property we want to remove from the UI
    foo: {
      table: {
        disable: true,
      },
    },
  },

이런식으로 table안의 disable에 true를 넣어주면 된다!


props에 $ prefix

CSS-in-JS 진영에서, props로 내리는 스타일드 속성은 앞에 $를 붙이는 것을 컨벤션으로 권장하고 있다고 한다. 이 속성이 네이티브 DOM 속성인지, 스타일드 속성인지 알기 힘들기 때문이다.

 

예제는 내가 만든 Button 컴포넌트.

import React from "react";
import styled from "styled-components";

export interface ButtonType
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  isShown: boolean;
}

const Button = (props: ButtonType) => {
  return (
    <ButtonWrapper {...props} $isShown={props.isShown}>
      {props.children}
    </ButtonWrapper>
  );
};

const ButtonWrapper = styled.button<{ $isShown: boolean }>`
  visibility: ${(props) => (props.$isShown ? "visible" : "hidden")};
  align-self: flex-end;
  width: 30px;

  font-weight: 700;
  font-size: 14px;
  color: black;
  text-decoration: none;

  background: transparent;
  border: none;

  :active {
    opacity: 50%;
    transform: scale(0.98);
  }
`;

export default React.memo(Button);

<Outlet />

https://reactrouter.com/en/main/components/outlet

 

Outlet v6.11.1

Type declarationinterface OutletProps { context?: unknown; } declare function Outlet( props: OutletProps ): React.ReactElement | null; An should be used in parent route elements to render their child route elements. This allows nested UI to show up when ch

reactrouter.com

react-router에서 제공해주는 건데, Route 이동시에 효과적으로 렌더링하기 위해서 도움을 주는 태그이다.

(ex. 페이지마다 공통적으로 존재하는 Header)


switch-case문에 관한 조언

default문이 어느 한 case와 중복될 경우에 관한 것이다.

case "/card-list":
  setHeadProps({ text: "보유카드", backButton: false });
  break;
case "/card-register":
	setHeadProps({ text: "카드 추가", backButton: true });
  break;
default:
	setHeadProps({ text: "보유카드", backButton: false });
	break;
// 보다는

case "/card-register":
  setHeadProps({ text: "카드 추가", backButton: true });
  break;
case "/card-list":
default:
	setHeadProps({ text: "보유카드", backButton: false });
  break;
// 이렇게!

crypto.randomUUID()

react-uuid를 사용하여 key값을 부여했었는데, 더 이상 랜덤key값을 위한 별도의 라이브러리를 설치해주지 않아도 된다는 리뷰를 받았다.

crypto.randomUUID()를 사용해보자!


`first-child`와 `first-of-type`의 차이점

너무 너무 헷갈렸던 부분이다.......💢

모든 경우의 수를 작성해보며 간신히 이해할 수 있었다.

모두 한번씩 각각 어떤 태그가 선택될지 퀴즈 풀어보고 가세요..

<TestDiv id="1">
    <a id="2">
    	<p id="2.5"/>
    </a>
    <p id="3"/>
    <p id="4"/>
    <span id="5"/>
</TestDiv>
<TestDiv id="6">
    <p id="7"/>
    <a id="8"/>
    <p id="9"/>
    <span id="10"/>
</TestDiv>


const TestDiv = styled.div`
    &:first-child {
    // 1
    }
    &:first-of-type {
    // 1
    }
    :first-child {
    // 1
    }
    :first-of-type {
    // 1
    }
    *:first-child {
    // 2, 2.5, 7
    }
    *:first-of-type {
    // 2, 2.5, 3, 5, 7, 8, 10
    }
    & :first-child {
    // 2, 2.5, 7
    }
    & :first-of-type {
    // 2, 2.5, 3, 5, 7, 8, 10
    }
    & > :first-child {
    // 2, 7
    }
    & > :first-of-type {
    // 2, 3, 5, 7, 8, 10
    }
    & > *:first-child {
    // 2, 7
    }
    & > *:first-of-type {
    // 2, 3, 5, 7, 8, 10
    }
 `

Children 속성이 필수 혹은 옵셔널?!

무조건 children이 존재해야 하는 컴포넌트에서도 옵셔널로 허용하는 것이 좋다.

 

왜??

BottomSheet 컴포넌트를 구현할 때, Children이 필수로 존재해야 하는 컴포넌트라고 생각해서 속성을 필수로 선언했다.

 

그런데, 다음과 같은 리뷰를 받았다.

라잇의 말대로 BottomSheet는 children이 필수로 들어와야 하므로 옵셔널을 허용하면 안되는 것이 좋네요. 그럼에도 불구하고 여러 컴포넌트에서 children을 옵셔널하게 가져가는 것을 습관화한다면 좋겠어요. 라잇이 만든 컴포넌트를 가져다 사용하는 입장에서 큰 오류를 맞닥뜨리지 않고 명세를 유연하게 이해할 수 있거든요.

가져다 사용하는 입장에서! BottomSheet을 열어봐야할 때, 임시로 확인하는 차원에서 유용한 점을 이해해버렸다.

따라서 앞으로는 children이 옵셔널로 존재하는 PropsWithChildren을 사용할 것이다.


styled-components에서 Theming 하기

https://styled-components.com/docs/advanced

 

styled-components: Advanced Usage

Theming, refs, Security, Existing CSS, Tagged Template Literals, Server-Side Rendering and Style Objects

styled-components.com


context에서 state와 Dispatch 분리하기

export const Context = createContext({
  isModalOpen: false,
  toggleModal: (): void => {},
});

이렇게 원래 state와 Dispatch 부분을 따로 만들면...

이렇게 생성된 Context는 state가 변경될 때마다 하위 컴포넌트 트리를 전체 렌더링 합니다.
이를 이펙티브하게 사용하려면, 컨텍스트를 state와 dispatch로 분리해서 활용할 수 있어요.

이러한 리뷰를 받고 아래처럼 분리했다!

import React, { createContext, useState } from "react";

export const ModalStateContext = createContext({
  isModalOpen: false,
});

export const ModalDispatchContext = createContext({
  toggleModal: (): void => {},
});

export const ModalContextProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const [isModalOpen, setIsModalOpen] = useState(false);

  const toggleModal = () => {
    setIsModalOpen(!isModalOpen);
  };

  return (
    <ModalStateContext.Provider value={{ isModalOpen }}>
      <ModalDispatchContext.Provider value={{ toggleModal }}>
        {children}
      </ModalDispatchContext.Provider>
    </ModalStateContext.Provider>
  );
};

모든 props가 옵셔널인 경우

type UnderlinedInputProps = {
  width?: string;
  name?: string;
  placeholder?: string;
};
const UnderlinedInput = ({ width, name, placeholder }: UnderlinedInputProps) => {
// 보다는

type UnderlinedInputProps = {
  width: string;
  name: string;
  placeholder: string;
};
const UnderlinedInput = ({ width, name, placeholder }: Partial<UnderlinedInputProps>) => {
// 이렇게

StructuredClone()

객체의 깊은 복사시에 아주 편리한 기능이 나왔다.

https://developer.mozilla.org/en-US/docs/Web/API/structuredClone

 

structuredClone() global function - Web APIs | MDN

The global structuredClone() method creates a deep clone of a given value using the structured clone algorithm.

developer.mozilla.org

더 이상 JSON.parse(JSON.stringfy(요런짓))안해도 됨..


 

profile

LIGHTLOG

@lightOnCoding

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!