[React][공식문서] 리액트 프로그래밍 프로세스

Gyuwon Lee·2022년 7월 18일
0
post-thumbnail

React 공식 튜토리얼을 바탕으로, 필요한 개념을 보충하여 학습한 기록입니다.

공식 튜토리얼(주요 개념) 대망의 마지막 챕터다. 원래 제목은 React로 사고하기인데, 목업과 JSON 데이터가 주어진 상황에서 리액트를 사용한 프로그램 설계 방식을 단계별로 잘 설명하고 있다.

0. 설계의 중요성 in React

리액트는 자바스크립트 라이브러리다. 참고한 글 [React 는 왜 프레임워크가 아니라 라이브러리일까?] 에 따르면, 라이브러리란 "소프트웨어를 개발할 때 프로그래밍 사용하는 비휘발성 자원의 모임, 공통으로 사용될 수 있는 특정한 기능들을 모듈화한 것"이다. 즉 자주 또는 중요하게 쓰이는 기능들(책)을 모듈화시켜 모아두고(책꽂이, 혹은 도서관) 사용자가 필요한 시점에 특정 기능을 꺼내어 쓰는 형태로 사용하게 된다.

반면, 프레임워크에서는 요구되는 일정한 형태의 틀(프레임)이 있고, 알맞은 위치에 사용자가 필요한 기능을 입력해서 프로그램이 완성되는 형태다.

따라서 리액트는 라이브러리로서 사용자가 프로그램을 어떤 형태로든 설계할 수 있도록 기능적으로 보조해준다는 점에서 장점을 갖지만, 반대로 토대가 되는 틀이 없기 때문에 설계 과정에서 헤매거나 학습 난이도가 급격히 상승하는 상황을 맞닥뜨리게 되기도 한다.

리액트는 UI를 위한 라이브러리다?

공식문서는 "리액트란 무엇인가요?" 라는 질문에 대해 "사용자 인터페이스를 구축하기 위한 선언적이고 효율적이며 유연한 자바스크립트 라이브러리"라고 설명한다. 물론 그렇다고 해서 figma 같은 UI '디자인' 툴은 아니다. UI 구현을 위한 도구라는 것이 리액트의 본질이다.

웹페이지상의 UI는 정보(HTML)에 디자인(CSS)을 입힌 형태로, 사용자의 조작에 따라 제어 및 조작(JavaScript)될 수 있어야 한다. 리액트는 이 세가지 요소를 컴포넌트라는 통합적 관점에서 바라보는 방식을 제시했다.

따라서 UI의 재사용성을 높이고 필요한 부분만 바꿔 가며(렌더링) 보여줄 수 있다는 장점을 갖지만, UI 단위를 넘어서 '프로그램' 면에서는 다른 프레임워크에 비해 부족한 부분도 있다. 예를 들어 리액트는 페이지 전환 기능을 제공하지 않기 때문에, 리액트를 사용하여 페이지 전환을 해야한다면, react-router와 같은 추가적인 라이브러리를 사용해야 한다.

1단계: UI를 컴포넌트로 나누기

UI를 위한 라이브러리답게, 리액트 프로그래밍의 첫 단계는 UI를 먼저 계획하는 일이다. 이 때 그냥 목업 단계에서 보여지는 구조대로 나누기보다, 컴포넌트로 분리해야 한다는 점이 중요하다.

어떤 것이 컴포넌트가 되어야 할지 어떻게 알 수 있을까? 여기서 필요한 것이 단일 책임 원칙이다. 앞선 글에서도 언급했듯, 리액트에서도 단일 책임 원칙을 지켜야 한다. 따라서 하나의 컴포넌트는 되도록 한 가지 일만 수행해야 한다. 하나의 컴포넌트가 커지게 된다면 이는 보다 작은 하위 컴포넌트로 분리하는 것이 좋다.

컴포넌트 구조와 HTML 구조의 차이

HTML은 각 요소를 태그로 나타낸다. 태그는 해당 요소의 기능을 의미한다. div 태그는 임의의 한 구역을, p 태그는 문단을, input 태그는 사용자의 입력창을 의미하는 식이다. 따라서 HTML로 나타나는 페이지 구조는 각 UI의 기능이 중심이 된다. 다만 태그만 보고 해당 UI의 역할을 파악하기는 어려울 수 있다. div 라는 이름만 보고 정확히 어떤 구역을 나타내는지, 어떤 데이터를 필요로 하는 구역인지 유추하기는 어렵다.

한편 리액트의 컴포넌트는 UI의 역할이 중심이 된다. 하나의 컴포넌트는 한 가지의 역할만 수행한다. 공식 문서에서는 데이터 모델에 기반해서 컴포넌트를 분리하기를 제안한다. "UI와 데이터 모델이 같은 인포메이션 아키텍처(information architecture)를 가지는 경향이 있기 때문에", 각 컴포넌트가 데이터 모델의 한 조각을 나타내도록 분리하는 것이 좋다고 한다.

공식문서의 아래 예제를 보자.

[
  {category: "Sporting Goods", price: "$49.99", stocked: true, name: "Football"},
  {category: "Sporting Goods", price: "$9.99", stocked: true, name: "Baseball"},
  {category: "Sporting Goods", price: "$29.99", stocked: false, name: "Basketball"},
  {category: "Electronics", price: "$99.99", stocked: true, name: "iPod Touch"},
  {category: "Electronics", price: "$399.99", stocked: false, name: "iPhone 5"},
  {category: "Electronics", price: "$199.99", stocked: true, name: "Nexus 7"}
];

위 JSON 예제의 내용에 따라 컴포넌트 별로 필요한 데이터를 구분해 보면 아래와 같다:

  • 제품 정보: price, stocked, name
  • 제품의 범주: category
  • 데이터 콜렉션: 범주에 따라 구분된 제품 정보의 목록
  • 유저의 입력: 데이터 콜렉션에서 필요한 정보만 찾기 위해 유저가 입력한 정보

이렇게 구분된 데이터를 바탕으로 컴포넌트를 설계하면 추후 데이터를 실제로 흐르게 할 때 보다 용이하게 구현할 수 있을 것이다.

2단계: 정적 버전 만들기

본 리액트 프로그래밍 프로세스에서 중요한 것은 처음부터 state를 고려하지 않는 것이다. 상호작용 없는 정적 버전을 만드는 것은 생각은 적게 필요하지만 타이핑은 많이 필요로 하고, 반대로 데이터를 고려한 상호작용을 만드는 것은 생각은 많이 해야 하지만 타이핑은 적게 필요로 한다.

앞선 글에서 언급했듯, 프로그램을 만들며 초반부터 "이 값은 나중에 A, B, C 컴포넌트들이 공유해야 하니까 공통 조상으로 올라가서 state로 만들어 두자" 라는 생각을 하기란 어렵다. 이는 초반 개발 속도를 더뎌지게 하는 주요 원인이 될 수도 있다.

이 단계에서는 데이터 모델을 렌더링하는 앱의 '정적 버전'을 만들기 위해 다른 컴포넌트를 재사용하는 컴포넌트를 만들고 props 를 이용해 데이터를 전달해 준다. 이 때 정적 버전을 만들기 위해 state를 사용하면 안 된다. 말 그대로 '정적 버전' 이므로, 간단히 말해 오직 한 세트의 mock data 만 있는 셈 치고 만들어 보는 것과 같다.

state 말고 props

state는 오직 상호작용을 위해, 즉 시간이 지남에 따라 데이터가 바뀌는 것에 사용한다. 여기서는 데이터가 변경됨에 따라 발생하는 상호작용을 고려하지 않으므로 state를 사용할 일이 없어야 한다.

반면 state는 사용하지 않지만 props는 사용하는 이유는, 데이터 흐름 구조를 만들기 위해서다. 오직 한 세트의 데이터만 갖고 있다면, 변경될 일은 없지만 흐를 일은 생긴다. 각 컴포넌트마다 필요로 하는 데이터가 다르고, 데이터를 필요로 하는 시점이 다를 수도 있다. 따라서 데이터를 어떻게 흐르게 할지 계획하는 것은 아주 중요하다.

리액트는 props를 사용해 데이터를 위에서 아래로 흐르게 하는 하향식, 단방향 데이터 흐름을 지향하므로, 보통 컴포넌트 구조 역시 하향식으로 설계하는 것이 쉽다.

하지만 프로젝트 규모가 커질수록, 하향식으로 설계하다 보면 초반에 놓쳤다가 나중에 덧붙이는 식으로 구현해야 하는 컴포넌트들이 생겨난다. 따라서 원자-분자-유기체-템플릿 순서를 따라 가장 작은 단위부터 구현해나가는 atomic design pattern과 같이 상향식으로 설계하는 디자인패턴이 필요해진다.

설계 중 데이터 흐름을 신중히 고려해야 하는 또 하나의 이유는, 부모 컴포넌트가 업데이트되거나 props를 새로 받아올 때마다 컴포넌트가 재렌더링되기 때문이다. props는 immutable data로 읽기 전용이다. 그런데, props가 업데이트 된다니?

부모 컴포넌트에서 이벤트의 발생 혹은 함수의 실행으로 state가 변경되면 리랜더링이 발생한다. 이때 render() 메서드가 작동하면 부모 컴포넌트에 합성되어 있는 자식 컴포넌트들도 새로 렌더링되므로, 자식 컴포넌트들은 새로 props 값을 받게 된다. 데이터 상으로는 변경된 부분이 없어도 말이다.

따라서 정적 버전 만들기 단계에서 props를 사용해 데이터 흐름 구조를 적절히 설계하는 것은 프로그램 최적화 측면에서도 매우 중요하다.

3. state '최소한으로' 만들기

앞선 단계에서 최초로 데이터가 주어졌을 때 어떤 방향으로 흘러야 하는지 구조를 세웠다. 하지만 데이터가 한 번 흐르고 난 상태로 영구히 멈춰 있는 UI는 거의 없다. UI가 상호작용할 수 있게 만들려면 처음에 흐르면서 각 컴포넌트에 스며든(전달된) 데이터 모델을 변경할 수 있는 방법이 있어야 한다. 이 때 state를 사용한다.

여기서 핵심은 중복 배제 원칙이다. 프로그램이 필요로 하는 가장 최소한의 state를 찾고 이를 통해 나머지 모든 것들이 필요에 따라 그때그때 계산되도록 만들어야 한다.

예를 들어 TODO 리스트를 만든다고 하면, TODO 아이템을 저장하는 배열만 유지하고 TODO 아이템의 개수를 표현하는 state를 별도로 만들 필요는 없다. TODO 갯수를 렌더링해야한다면 TODO 아이템 배열의 길이를 가져오면 되기 때문이다.

따라서 공식문서에 나와 있는 아래 세 가지 질문을 통해 반드시 state로 만들어야만 하는 데이터가 무엇인지 추리고, 최소한으로 유지해야 한다.

  1. 부모로부터 props를 통해 전달되는가? 그러면 확실히 state가 아니다.
  2. 시간이 지나도 변하지 않는가? 그러면 확실히 state가 아니다.
  3. 컴포넌트 안의 다른 state나 props를 가지고 계산 가능한가? 그렇다면 state가 아니다.

4. State의 위치 정하기

이제 어떤 컴포넌트가 state를 변경하거나 소유할지 찾아야 한다.

앞서 말했듯 리액트는 항상 하향식 단방향 데이터 흐름을 따릅니다. 프로젝트를 시작할 때마다 가장 어려운 부분이 어떤 컴포넌트가 어떤 state를 가져야 하는지 결정하는 것이었다. 공식 문서를 보니 아래와 같은 프로세스를 제안하고 있었다:

  1. 애플리케이션이 가지는 각각의 state에 대해서, 해당 state를 기반으로 렌더링하는 모든 컴포넌트를 찾는다.
  2. 공통 소유 컴포넌트 (common owner component)를 찾는다. 공통 혹은 더 상위에 있는 컴포넌트가 state를 가져야 한다.
  3. state를 소유할 적절한 컴포넌트가 없다면, state를 소유하는 컴포넌트를 하나 만들어서 공통 소유 컴포넌트의 상위 계층에 추가한다.

예를 들어, 아래와 같은 의사코드로 표현되는 모달을 만들었다고 생각해 보자:

<Page>
  <ModalButton>
    <Modal>
      <Content />
      <EditButton />
      <DeleteButton />
      <CloseButton />
    </Modal>
  <ModalButton/>
</Page>
  • 이런 구조의 프로그램에서 빈번히 변경될 데이터는 모달의 오픈 상태 일 것이다. [isOpen, setIsOpen] = useState(false) 라는 코드를 어디에 둘지 결정하는 상황이라고 해 보자.
    • ModalButton 을 눌렀을 때 모달이 열려야 하므로 isOpen state가 필요하다.
    • CloseButton 을 눌렀을 때 모달이 닫혀야 하므로 isOpen state가 필요하다.
    • 따라서 둘 중 상위 컴포넌트인 ModalButton 이 해당 state를 갖고, Modal 에 props로 내려준 다음 Modal 이 다시 한번 CloseButton 에게 props로 내려주어야 한다.

5. 역방향 데이터 흐름 추가

앞선 글 리액트 공식문서 - state 끌어올리기에서 다루었듯, 리액트는 단방향 데이터 흐름으로 설계되어야 한다.

읽기 전용 데이터인 props로 데이터 타입의 제한 없이 데이터를 전달할 수 있고, 여러 컴포넌트가 사용하는 state를 공통 조상이 갖도록 끌어올리는 방식이 권장되는 것 등등이 전부 단방향 데이터 흐름을 용이하게 구현하기 위해서다.

위의 예시를 다시 한번 가져와 보자. 이제 모달의 열림/닫힘 상태를 나타내는 isOpen 상태를 ModalButton, Modal, CloseButton 이 모두 갖고 있게 되었다. 그 중 CloseButton은 눌렸을 때 isOpen state를 무조건 false로 바꾸도록 이벤트 핸들러를 갖고 있어야 한다. 하지만 props로 전달받은 상태는 읽기 전용이다. 따라서 setState 핸들러를 함께 내려보내야 한다.

자식 컴포넌트는 부모로부터 내려받은 핸들러(setState)를 통해 부모 컴포넌트가 소유하고 있는 state를 변경시킬 수 있다. 이를 역방향 데이터 흐름이라고 한다.

setState는 비동기적이다

다만 이 때 setState는 비동기적이다. 이로 인해 setState 직후에 새로 업데이트되었을(것이라고 기대하는) 해당 state값을 참조하는 경우 문제가 발생할 수 있다.

state를 사용하는 어떤 컴포넌트가 이미 렌더링되어 화면상에 올라와 있는 상황이라면, setState로 값이 변경되는 것을 감지해 재렌더링되므로 UI상에 반영되지 않을 걱정은 비교적 적다.

문제는 state의 을 참조하여 조건문이나 연산식 내부에 사용하는 경우다.

useEffect(() => {
    if (user.intra_id === "default") {
      axiosMyInfo()
        .then((response) => {
          dispatch(userAll(response.data)); // user 상태를 업데이트하는 액션
          console.log(user.intra_id); // dispatch로 업데이트되기 전의 초기값이 출력됨
          if (user.cabinet_id !== -1) // 마찬가지로 초기값을 기준으로 동작하게 됨
            navigate("/lent");
        })
        .catch((error) => {
          navigate("/");
        });
    }
  }, []);

위 코드의 의도는
1. axios로 API를 호출한 후
2. 응답으로 받은 데이터를 dispatch로 user state에 업데이트하고
3. 업데이트된 user 의 값을 바탕으로 출력 후
4. if 조건문 안에서 사용
하려는 것이다. 하지만 의도대로 작동하지 않고, console.logif 문 안의 user 데이터는 dispatch되지 않은 초기값 그대로인 채로 남아있다.

물론 위의 문제는 userresponse.data 로 바꾸면 쉽게 해결된다. state 변경만 비동기적일 뿐 받아온 데이터는 response.data에 잘 들어있기 때문이다. 하지만 위의 if문처럼 페이지 접근 권한을 결정하는 라우팅 등을 state 값을 바탕으로 구현하는 경우 특정 페이지에 접근하지 못하게 되거나, 접근해선 안 되는 사용자가 접근가능해지는 등 문제를 발생시킬 수 있다.

profile
하루가 모여 역사가 된다

0개의 댓글