이해하기 좋은 리액트 코드 - 위에서 아래로 리액트 코드 작성하기, 의도를 명확하게 전달하기

Goyoung2·2024년 1월 12일
1
post-thumbnail

회사에서 프론트엔드 잼 시간에 발표했던 내용을 가져왔습니다. 재미있게? 읽어주세요~

좋은 코드?

좋은 코드는 어떤 코드일까요?

우리는 코드를 읽으며 프로그램을 이해합니다. 프로그램을 하나의 책이라고 생각해봅시다. 만약 책의 이야기 순서가 뒤죽박죽 섞여있고 어려운 용어들로 가득하다면 우리는 이 책을 쉽게 읽고 이해할 수 있을까요?

아닐겁니다. 순서가 뒤죽박죽 섞여있기 때문에 내용에 집중하기 어렵고 순서를 이동하다가 놓치는 내용들이 생길겁니다. 또 어려운 용어들을 찾아보기 위해 많은 시간을 써야하고, 일부 용어들은 제대로 이해하지 못한채 넘어가기도 할겁니다.

코드도 이와 같다고 생각합니다. 우리는 코드를 순차적으로 읽습니다. 코드의 순서가 순차적이지 못하면 여기저기 코드를 건너 뛰어다녀야 합니다. 그러다보면 놓치는 코드들이 생기고 코드를 이해하는데 방해가 됩니다. 또 이해하기 어려운 컴포넌트나 함수 이름을 만나곤합니다. 이름을 보고 정확히 어떤 동작을 할지 알 수 없다면 해당 코드를 열어서 한줄 한줄 전부 읽어볼 수 밖에 없습니다. 코드를 읽는데 많은 시간이 걸릴 것이고 원작자의 의도를 100% 이해하지 못할 수도 있습니다. 이렇게 제대로 이해되지 않은 상태에서 코드를 수정한다면 예상치 못한 버그를 발생시킬 확률이 매우 높다고 생각합니다.

제가 생각하는 좋은 코드는 쉽게 읽고 이해할 수 있으며 어떤 동작을 할지 예측 가능한 코드입니다.

리액트는 좋은 코드인가요?

저는 리액트가 훌륭한 UI 라이브러리라고 생각하지만 좋은 코드라고 보기엔 조금 아쉬운 점이 있다고 생각합니다. 이 아쉬운 점들을 고민하면서 코드를 작성한다면 더 좋은 코드로 만들 수 있다고 생각합니다.

  1. 리액트 컴포넌트는 코드 구성이 순차적이지 않습니다. 데이터를 불러와서 컴포넌트를 렌더링하는 순서를 생각해봅시다.

    컴포넌트 렌더링 순서:

    1. 컴포넌트 선언
    2. 데이터 페칭
    3. 데이터 가공
    4. 화면 렌더링
    5. 인터랙션
    6. 화면 업데이트

    아래는 폼을 만들어서 이름을 수정하는 50여줄의 컴포넌트 예제입니다. 렌더링 순서를 살펴봅시다.

import { useEffect, useState } from 'react';
import { getUser, updateUser } from './apis';

// 1. 컴포넌트 선언
export function UserForm() {
// 3. 데이터 가공
const [user, setUser] = useState<{ name: string }>();
const userName = user?.name;
const [newUserName, setNewUserName] = useState('');

function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
 setNewUserName(e.target.value);
}

function handleForm(userName: string) {
 updateUser({ name: userName }).then(() => {
   getUser().then((user) => {
     // 6. 화면 업데이트
     setUser(user);
   });
 });
}

// 2. 데이터 페칭
useEffect(() => {
 getUser().then((user) => {
   setUser(user);
 });
}, []);

// 4. 화면 렌더링
if (!user) {
 return null;
}

return (
 <form
   onSubmit={(e) => {
     // 5. 인터랙션
     e.preventDefault();
     handleForm(newUserName);
   }}>
   <div>{userName}</div>
   <div>
     <input onChange={handleInput} />
   </div>
   <button>이름 변경</button>
 </form>
);
}

위 코드는 1 > 3 > 6 > 2 > 4 > 5 순서로 되어있습니다. 코드를 이해하기 위해선 위아래로 이동하며 코드를 읽어나가야 합니다. 로직이 복잡할 수록 코드 순서는 더욱 복잡해집니다. 이 복잡한 흐름이 코드 이해를 어렵게 만든다고 생각합니다.

  1. 동작을 예측하기 어려운 이름이 많습니다. 리액트 튜토리얼 때문인지 많은 리액트 개발자들이 handleXXX 라는 이름의 함수를 작성합니다. 물론 저도 그랬습니다. 하지만 여기서 handle은 아무 의미가 없는 이름이라고 생각합니다. handleForm, handleInput 보다 updateUserAndRevalidate, setUserName 등이 더 명확하고 예측가능한 좋은 이름이라고 생각합니다.

그래서 어떻게 하면 좋을까요?

  • 코드를 위에서 아래로 읽을 수 있도록 순차적인 흐름을 만들어줍시다.
  • 독자가 쉽고 빠르고 정확하게 이해할 수 있도록 예측 가능한 이름을 지어줍시다.

순차적인 리액트 코드

위에서 나온 1 > 3 > 6 > 2 > 4 > 5 순서에서 2번을 앞으로, 6번을 뒤로 이동시키면 순차적인 흐름을 만들 수 있습니다. 하지만 리액트에서 hook의 제약사항과 return문이 코드의 이동을 방해합니다. 이들을 적절하게 바꿔주면 코드를 이동시킬 수 있습니다.

아래는 위에 작성했던 컴포넌트에 타이머를 추가한 컴포넌트입니다. 순차적인 흐름에 집중하며 코드를 이해해봅시다.

import { ReactNode, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { QUERY_KEY_USER } from './constants';
import { getUser, updateUser } from './apis';
import useCounter, { secondToMinSec } from '../pages/LoginPage/useCounter';
import styled from '@emotion/styled';

// 1. 컴포넌트 선언
export function EditUserNameWithTimer(props: any) {
  // 2. 데이터 페칭
  const { data: user } = useQuery([QUERY_KEY_USER], getUser, { suspense: true });

  // 3. 데이터 가공
  const userName = user?.name;
  const [newUserName, setNewUserName] = useState('');

  const { remainTime: remainingTime, startTime: startTimer, isTimeOver } = useCounter(5);
  const remainingTimeString = secondToMinSec(remainingTime);

  const inputRef = useRef<HTMLInputElement>(null);
  const timeOverFlagRef = useRef(false); // 타임오버 이벤트 트리거하는 용도

  const updatingUserMutation = useMutation([QUERY_KEY_USER], updateUser);
  const isDisabledToChangeName = updatingUserMutation.isLoading || isTimeOver;

  // 4. 화면 렌더링
  function render(): ReactNode {
    if (!userName) {
      return <div>Blocking...</div>;
    }

    return (
      <StyledForm
        onSubmit={(e) => {
          // 5. 인터랙션
          e.preventDefault();
          updateUserAndRevalidate();
        }}>
        <main>
          <div>이름: {userName}</div>
          <div>
            <input
              ref={inputRef}
              value={newUserName}
              disabled={isTimeOver}
              onChange={(e) => {
                setNewUserName(e.target.value);
              }}
            />
          </div>
          <div>남은 시간: {remainingTimeString}</div>

          <div>
            <button disabled={isDisabledToChangeName}>이름 변경</button>{' '}
            {isTimeOver && (
              <button
                type="button"
                onClick={() => {
                  // 5. 인터랙션
                  restartTimerAndFocusInput();
                }}>
                재시작
              </button>
            )}
          </div>
        </main>
      </StyledForm>
    );
  }

  // 5. 인터랙션 함수
  function updateUserAndRevalidate() {
    updatingUserMutation.mutate(
      { name: newUserName },
      {
        // 6. 화면 업데이트
        onSuccess: revalidateUser,
        onError: (error) => console.error(error),
      }
    );
  }

  const queryClient = useQueryClient();
  function revalidateUser() {
    queryClient.invalidateQueries([QUERY_KEY_USER]);
    setNewUserName('');
  }

  function restartTimerAndFocusInput() {
    startTimer(5);
    timeOverFlagRef.current = false;
    setTimeout(() => {
      inputRef.current?.focus();
    });
  }

  // 6. 화면 업데이트
  useEffect(() => {
    if (isTimeOver && !timeOverFlagRef.current) {
      timeOverFlagRef.current = true;
      console.error('Time over! Do Something...');
    }
  });

  return render();
}

const StyledForm = styled.form`
  height: 100vh;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;

  main {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 10px;
    border: 1px solid black;
    border-radius: 16px;
    padding: 20px;
  }

  button {
    padding: 2px 6px;
    :disabled {
      background-color: white;
      color: gray;
      cursor: not-allowed;
    }
  }
`;

코드 순서를 순차적으로 조정해봤습니다. 2번 데이터 페칭을 useQuery를 사용하여 컴포넌트 최상단으로 이동시켰습니다. 6번 화면 업데이트 함수를 컴포넌트 최하단으로 이동시켰습니다.

눈여겨봐야할 부분은 render 함수와 useEffect, useMutation입니다. render 함수를 만들어서 컴포넌트의 마지막에 return render() 형태로 사용합니다. 덕분에 useEffect의 위치가 자유로워져서 원하는 위치에 useEffect 로직을 작성할 수 있습니다. 그리고 useMutation의 로직을 화면이 업데이트 될때 확인할 수 있도록 선언부가 아닌 컴포넌트 하단에 위치시켰습니다.

예측 가능한 코드

이름을 보고 컴포넌트, 변수, 함수, 훅이 어떤 동작을 할지 예측할 수 있어야 합니다. 디테일한 명령은 내부로 숨기고, 명확한 의도를 이름으로 표현함으로써 독자가 더 쉽게 이해하도록 도울 수 있습니다. 좋은 이름 한 줄은 그 뒤에 숨겨져있는 5줄, 10줄의 코드를 예측 가능하게 만든다고 생각합니다. 이렇게하면 코드를 읽고 이해하는데 많은 시간이 절약되고 더불어 코드의 의도를 독자에게 더 정확하게 전달할 수 있습니다.

  • 위의 예제에서 컴포넌트 이름은 EditUserNameWithTimer로 지었습니다. 타이머가 있으면서 유저 이름을 변경하는 컴포넌트라는 의미를 전달하려했습니다.
  • 컴포넌트를 화면에 그리는 함수는 render로 지었습니다.
  • 유저정보를 업데이트한 후 갱신하는 함수는 updateUserAndRevalidate로 지었습니다.
  • 타이머를 재시작하고 인풋에 포커스를 주는 함수는 restartTimerAndFocusInput으로 지었습니다.
  • 사용자 이름이 변경 불가능한지를 나타내는 변수는 isDisabledChangeName 으로 지었습니다.

이름은 문맥과 상황에 따라 언제든 더 좋은 이름으로 변경될 수 있습니다.

개발자는 코드를 통해 의도를 정확하게 전달할 수 있어야합니다. 그래야 다른 개발자가 내 코드를 더 잘 읽고 이해해서 올바른 방향으로 코드를 수정할 수 있을 것입니다.

읽어주셔서 감사합니다.

더 좋은 코드를 작성할 수 있도록 노력해봅시다. ^^

profile
프론트엔드 엔지니어로 일하고 있어요. 제품, 동료, 성장을 중요시해요. 겸손, 존중, 신뢰를 갖춘 동료가 되기 위해 노력해요. 😄

0개의 댓글