본문 바로가기

원티드 프리온보딩 챌린지

원티드 프리온보딩 챌린지 2-1회차

지난 회차들에서 중복되었던 내용도 있었고, 이번 회차가 React Query의 심화 편 같았기 때문에 요약할 만한 키워드가 떠오르질 않는다. 그러므로 이번 글은 지금까지 내가 미뤄왔던 리팩토링과 새롭게 알게 된 부분을 적용한 사례를 쭉 나열해보려고 한다.

Issue와 Pull Request

지난 세션에서 다른 참가자분이 수정사항을 Issue와 Pull Request로 관리하는 모습을 봤다. 괜찮은 방법인 것 같아서 나도 그렇게 해봤다.

https://github.com/2hakjoon/wanted-pre-onboarding-challenge-fe-1/issues/2

 

queryClient 및 queryKey 통합 리팩토링 · Issue #2 · 2hakjoon/wanted-pre-onboarding-challenge-fe-1

https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories 해당 블로그 자료를 참고하여, queryKey와 요청들을 통합하는 방법으로 리팩토링.

github.com

https://github.com/2hakjoon/wanted-pre-onboarding-challenge-fe-1/pull/4

 

Query/2 by 2hakjoon · Pull Request #4 · 2hakjoon/wanted-pre-onboarding-challenge-fe-1

query 관련 리팩토링 진행. #2 하드코딩 되어있던 query키를 변수로 관리하며 export시켜서 여러곳에 사용할 수 있게 하였습니다. Todo를 수정하거나 삭제 할 경우 TodoList전체를 다시 불러와야 하는 반

github.com

수정사항을 Issue로 등록하고 Pull Requst로 하나씩 구현하는 과정에서 문제를 작게 쪼개서 해결하는 것 같은 느낌이 들었다. 물론 자잘 자잘한 부분에 대한 수정을 할 때는 번거로울 것 같기는 하지만, 큰 문제는 Issue에서 해결방법을 충분히 고민하고, 고민한 흔적들이 남게 되고, 빠진 부분은 없는지 점검할 수도 있어서 마음에 든다.

invalideQueries적용 및 Query Key 리팩토링.

invalideQueries 적용

const { refetch: refetchTodos } = useGetTodos();
const { mutate } = useCreateTodo();
const { register, handleSubmit, setValue } = useForm<TodoParams>();

const saveTodoHandler = ({ title, content }: TodoParams) => {
  const onSuccess = () => {
    setValue('content', '');
    setValue('title', '');
    refetchTodos();
  };

  mutate({ title, content }, { onSuccess });
};

전에 작성한 코드이다. useCreateTodo동작을 수행한 다음, useGetTodos의 refetch를 사용해서 목록을 새로 불러왔다. 세션에서 이 부분은 invalideQueries로 해결하는 걸 보고 프로젝트에 적용했다.

function useCreateTodo() {
  const queryClient = useQueryClient();
  return useMutation(apiTodos.createTodo, {
    onSuccess: () => {
      queryClient.invalidateQueries([...queryKeyGetTodos]);
    },
  });
}

useCreateTodo에서 onSuccess과정에 invalidateQueries를 추가해 주었다.

Mastering Mutations in React Query

 

Mastering Mutations in React Query

Learn all about the concept of performing side effects on the server with React Query.

tkdodo.eu

공식 문서에도 나와있는 내용이다. invalidateQueries함수를 실행하면 refetch를 수행한다고 나와있다.

전에는 그냥 복사 붙여 넣기를 했을 텐데, 챌린지를 진행하면서 모든 자료에 대해서 공식 문서와 한번 더 검증하는 습관이 생겨서 좋다.

Query key 적용.

Effective React Query Keys

 

Effective React Query Keys

Learn how to structure React Query Keys effectively as your App grows

tkdodo.eu

공식 문서에 따르면, 데이터 모델별로 CRUD에 해당하는 키를 객체로 만들어서 관리하는 방법이 소개되어있다. 세션에서도 모델별로 CRUD에 해당하는 함수들을 모아서 관리하는 방법을 소개해주었다. 그래서 당연히 모델별로 뭉치는 게 좋다고 생각을 하고 리팩토링을 하려고 했는데, 막상 하려니 편하지가 않아 보였다. 고민한 내용을 한번 정리해보려고 한다.

 

  1. co-location을 좋게 하려면 어떻게 하지?
    우선 키를 모아서 관리하려면 CRUD요청들의 바깥에서 파일을 생성하고 객체를 export 하는 과정이 필요하다고 생각한다. 우리의 요청은 모두 hooks폴더에 있고, 키를 hooks폴더 내에 하나의 파일로 만들어서 관리할 순 없다고 생각한다. hooks에는 hook만 있어야 하기 때문이다. 그래서 외부로 멀리 빼서 react-query나 이런 파일에서 관리하게 되면, 실제로 사용하는 파일과 멀어지게 된다.
  1. 그러면 CRUD함수들을 모아주는 hook을 만들고, hook 내에서 관리하게 되면?
    이 방법이 내가 추구하는 방법이긴 하지만, 실제로 적용하게 되면, 득 보다 실이 더 많을 것 같다. 함수를 호출하여 사용하는 로직에서 복잡도가 늘어날 여지가 있어 보인다. 이 방향으로 수정을 하고 싶은데, 아직은 최선의 방법을 못 찾은 것 같다. 프로젝트의 상황에 따라 이 부분을 적용할 생각은 있다. 하지만 최적화는 대부분 trade-off이므로 이번엔 적용하지 않았다.
export const queryKeyGetTodos = ['Todo', 'getMany'] as const;

function useGetTodos(options?: UseQueryOptions<ApiGetTodosResponse>) {
  return useQuery<ApiGetTodosResponse>([...queryKeyGetTodos], apiTodos.getTodos, options);
}

그래서 그냥 키를 담은 배열을 export 시키고, 받는 쪽에서 구조 분해 할당하여 사용하기로 했다. as const를 사용하면 사용하는 쪽에서 미리보기로 확인도 할 수 있다. queryKey는 첫 번째 모델, 두 번째 요청의 유형, 세 번째는 요청에 영향을 주는 요소(id, fillter조건) 등등 이 오게끔 정했다.

로그인, 회원가입 Form수정(Yup적용 및 리팩토링)

https://github.com/2hakjoon/wanted-pre-onboarding-challenge-fe-1/pull/5

 

#3 by 2hakjoon · Pull Request #5 · 2hakjoon/wanted-pre-onboarding-challenge-fe-1

Form Validation regex -> Yup #3 주요 변경사항. regex를 적용한 validation을 yup으로 교체.(yup이 훨씬 선언적이고, 이해가 쉬움.) useForm함수를 사용하는 custom hook 적용. (useLoginForm, useSignUpForm) useLogin, useSignUp네

github.com

해당 내용 풀 리퀘스트

Yup 적용기.

Get Started

 

Get Started

Performant, flexible and extensible forms with easy-to-use validation.

react-hook-form.com

공식 문서에 나와있는 yup예제.

joi vs yup | npm trends

 

joi vs yup | npm trends

Comparing trends for joi 17.6.0 which has 6,491,830 weekly downloads and 19,036 GitHub stars vs. yup 0.32.11 which has 3,385,967 weekly downloads and 18,051 GitHub stars.

npmtrends.com

joi대신 yup을 선택한 이유. Typescript라이브러리이고, 번들 사이즈가 더 작다.

그동안 input데이터에 대한 validation을 정규표현식으로 해결해 왔다. 정규표현식이 간단한 방법이긴 하다. 패키지를 설치하지 않아도 되고, 구글링을 하면 대부분 원하는 표현식을 찾을 수 있고, 공부를 한다면 직접 만들 수도 있다. 여전히 나쁜 방법은 아니라고 생각하지만, 고민해보면 Yup을 적용할만한 이유가 있다.

// before
export const emailPattern = /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$/i;
// after
 email: yup.string().email('이메일 양식이 올바르지 않습니다.').required(),

Yup은 알아볼 수 없는 기호들로 가득한 정규표현식보다 가독성이 더 좋다. 한 번쯤은 정규표현식이 무슨 내용인지 몰라서 regex사이트에서 해독해본 경험이 있을 것이다. 반면에 Yup은 선언적으로 작성되기 때문에 쉽게 읽을 수 있다. 그리고 invalid 한 양식에 대해서는 에러 메시지 작성도 쉽다. 반면에 regex는 다른 조건문이나 state에 의존해야 한다. react hook form으로 입력 데이터를 관리하는 경우라면 정규표현식이 아닌, Yup을 사용하는 게 가장 궁합이 좋은 것 같다.

에러 메시지 추가

Yup을 적용하고 나니 에러 메시지 핸들링이 쉬워졌다. react hook form에서 regex를 사용하는 경우에는 에러 메시지를 다룰 때 타입도 정하고, 메시지도 정해서 해당 객체에 값을 할당해야만 에러 핸들링을 해줬는데, 이제는 yup에 같이 작성하면 되니 훨씬 쉬워졌다.

Custom Hook으로 리팩토링 시작.

const schema = yup
  .object({
    email: yup.string().email('이메일 양식이 올바르지 않습니다.').required(),
    password: yup.string().min(8, '비밀번호는 8자리 이상입니다.').required(),
  })
  .required();

function useLoginForm() {
  const {
    register,
    trigger,
    getValues,
    watch,
    formState: { errors },
    handleSubmit,
  } = useForm<LoginParams>({
    mode: 'onChange',
    resolver: yupResolver(schema),
  });

  const isFormNotValid = () => {
    return !!errors.email?.message || !!errors.password?.message;
  };

	// 이부분은 여전히 맘에안듬...
	// 개선방안 모색중
  useEffect(() => {
    trigger();
  }, []);

  const emailError = getValues('email') ? errors.email?.message : '';
  const passwordError = watch('password') ? errors.password?.message : '';

  return { register, emailError, passwordError, handleSubmit, isFormNotValid };
}

export default useLoginForm;

Yup을 적용하고 난 후, 관련 로직을 추가했더니 너무 비대해지는 느낌을 받아서 custom hook으로 분리하였다. loginForm전용으로 만들었다. 이번 과제의 로그인 버튼이 조건이 좀 까다로운 것 같다. submit 할 때 validation 하면 로직이 간단해질 것 같은데, 입력값이 유효할 때만 버튼이 활성화돼야 하는 부분 때문에 코드가 길어졌다. 아마도 내 실력이 부족해서겠지?

hooks를 만들어서 저장하려고 보니 useLogin이라는 api커스텀 훅이 있었다. 전에는 문제가 없다고 느꼈지만, hooks폴더에 useLogin, useLoginForm 이렇게 두 가지 hook이 존재하게 되니, useLogin 쪽의 이름이 비교적 구체적인 이름이 아니기 때문에 좀 모호하다는 느낌이 들었다. 그래서 명확하게 useLogin을 useLoginMuation으로 이름을 바꿨다. 예전이었다면 그냥 지나갔을 텐데, 전보다 네이밍에 더 신경을 쓰고 있는 것 같다.

UI 대격변

챌린지도 마무리되어가니, 미뤄왔던 UI 수정도 진행하기로 했다.

Before

After

여전히 예쁜 디자인은 아닌 것 같지만, 전보다는 훨씬 나아졌다. 변경점들을 살펴보자.

 

1. 긴 제목은 … 처리해주기.

width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

세션에도 포함된 내용이다. 고정된 너비와 속성 3개만 추가해주면 되는 간단한 작업이다. 2줄, 3줄로 적용할 경우에 line-hight속성에 따라 글씨가 잘리는 경우가 생기긴 하는데, 1줄은 간단한 것 같다.

 

2. 로딩 스켈레톤 적용.

로딩 시에 보여줄 스켈레톤도 추가했다. 꼼꼼하게 작업하지는 않았지만, “로딩 중…” 글씨만 떠있는 모습보단 보기 좋다.

const skeletonKeyframes = keyframes`
0% {
  background-position: -200px 0;
}
100% {
  background-position: calc(200px + 100%) 0;
}
`;
interface SkeletonSquareProps {
  height: string;
  width: string;
  marginTop?: string;
  marginBottom?: string;
}

const Skeleton = styled.div<SkeletonSquareProps>`
  display: inline-block;
  height: ${(props) => props.height || '14px'};
  width: ${(props) => props.width || '80%'};
  animation: ${skeletonKeyframes} 1300ms ease-in-out infinite;
  background-color: #eee;
  background-image: linear-gradient(90deg, #eee, #f5f5f5, #eee);
  background-size: 200px 100%;
  background-repeat: no-repeat;
  border-radius: 4px;
  margin-bottom: ${(props) => props.marginBottom || '0'};
  margin-top: ${(props) => props.marginTop || '0'};
`;

function SkeletonSquare(props: SkeletonSquareProps) {
  return <Skeleton {...props} />;
}

SkeletonSquare.defaultProps = {
  marginTop: '0',
  marginBottom: '0',
};

export default SkeletonSquare;

네모난 스켈레톤 박스를 만들기 위해 사용된 코드이다. 그냥 인터넷에서 찾아서 사용했다. 이런 상자를 콘텐츠가 들어올 자리에 열심히 배치하면 스켈레톤이 구현된다. 그러면 이제 로딩용 TodoCard 컴포넌트를 만들어보자.

import styled from 'styled-components';
import React from 'react';
import SkeletonSquare from '../../../common/components/skeleton/SkeletonSquare';

const Container = styled.li`
  width: 240px;
  height: 71px;
  margin-bottom: 10px;
  border: 1px solid darkgray;
  border-radius: 5px;
  display: flex;
  flex-direction: column;
  padding: 15px;
`;

function TodoListCardLoading() {
  return (
    <Container>
      <SkeletonSquare width="120px" height="20px" marginTop="0px" />
      <SkeletonSquare width="80px" height="12px" marginTop="8px" />
    </Container>
  );
}

export default TodoListCardLoading;

이렇게 하면 Todo List에 보일 카드가 하나 완성된다. 두 줄짜리의 간단한 스켈레톤이다. 로딩용 템플릿도 만들어보자.

import React from 'react';
import styled from 'styled-components';
import TodoListCardLoading from '../component/TodoListCardLoading';
import { TodoListContainer } from './TodoListTemplate';

const TodoListLoadingContainer = styled(TodoListContainer)`
  display: flex;
  flex-direction: column;
  align-items: center;
`;

function TodoListLoading() {
  return (
    <TodoListLoadingContainer>
      <TodoListCardLoading />
      <TodoListCardLoading />
      <TodoListCardLoading />
      <TodoListCardLoading />
      <TodoListCardLoading />
      <TodoListCardLoading />
    </TodoListLoadingContainer>
  );
}

export default TodoListLoading;

Todo Card를 여러 개 렌더링 시켜서 로딩 중인 카드들의 모습이 완성되었다.

<LoadingAndError loadingFallback={<TodoListLoading />} errorFallback={<TodoListError />}>
  <TodoListTemplate />
</LoadingAndError>

해당 로딩 템플릿을 내가 만들었던 LoadingAndError에 넣어주면 구현이 끝난다. suspense동작이 위임될 경우, 로딩 중일 때 화면에 보이게 될 것이다.

나는 완성된 화면과 로딩 중인 화면을 철저하게 분리시켜서 작업을 했다. 장점으로는 관심사 분리가 잘 되어, 로딩 관련된 코드가 안 보이게 되어, 핵심적인 로직에만 집중해서 유지보수를 할 수 있다. 단점으로는 콘텐츠의 위치에 스켈레톤을 정확히 놓기 힘들다는 점이다.

 

3. 폰트

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap" rel="stylesheet" />

눈치채기 힘들었겠지만, 사실 폰트도 NotoSans로 적용해줬다. 구글 폰트에서 가져왔다. 사실 Typography 같은 컴포넌트를 만들어서 사용을 해야하지만, 수정할 부분도 너무 많고, 한종류밖에 없어서 리팩토링하지는 않았다. 

 

마치며.

  이번 리팩토링을 하면서 가장 중요하게 생각한 부분은, 문제 해결을 위해서 공식문서를 찾아보는 것이었다. 이 전까지는 구글링 하고 스택오버플로우에서 코드를 긁어와서 문제를 해결했다. 그러다 보니 사고의 흐름이 딱 거기서 멈추는 경우가 많았고, 코드에 대한 근거도 부실해졌다. 하지만 이번에는 가장 먼저 공식문서를 들어가서 예제를 찾아보았다. 공식문서를 읽어보려면 시간이 훨씬 오래 걸린다는 단점이 있지만, Best Practice가 어떻게 도출되는지 볼 수 있고, 이렇게 까지 해야만 하는 이유도 볼 수 있었다. 몇몇 문제를 공식문서의 Best Practice로 해결하고 보니, 문서에 있는 Best Practice의 코드 한 줄이 문제를 해결하기 위해 복잡하게 꾸며낸 여러 줄을 대체할 수 도 있다는 사실을 알게 되었다.

  독서는 많은 간접 경험을 할 수 있게 해 주고, 시행착오를 줄여준다. 개발자는 공식문서를 많이 읽음으로써 시행착오를 줄일 수 있다. 이걸 모르는 사람은 없지만, 방대한 양의 공식문서를 읽는 것은 쉽지 않은 일이다. 다행히도 문서를 읽으면 읽을수록 요령을 조금씩 터득하는 것 같고, 문서에 있을만한 내용과 그렇지 않은 내용을 구별해내는 감각이 생긴 것 같다. 아직은 많이 버겁지만, 더 이상 버겁지 않을 때까지 문서와 코드를 뒤져보다 보면, 시행착오를 줄이고 Best Practice만 사용하는 개발자가 될 수 있을 거라 믿는다.