리엑트 UI 트리가 뭔지 아세요?

jay·2022년 8월 31일
14

react

목록 보기
11/14
post-thumbnail

안녕하세요, 단테입니다.
오늘은 리엑트의 UI트리에 대해 알아보고 이를 통해 상태 값 보존과 리셋을 할 수 있는 전략에 대해 알아보고자 합니다.

해당 내용은 리엑트 공식 베타버전 문서에 보다 자세히 영문으로 기술되어있습니다.

읽기 귀찮다면?

유튜브 영상으로 보기 :)

UI 트리 그 모호함에 대하여

리엑트를 다루는 저희가 자주 듣지 못하는 단어 중 하나가 UI 트리입니다.

다음 질문에 명확한 답변을 하지 못하신다면 잘 모르시는 걸 수도 있어요 :)

리엑트 개발에 있어서 UI 트리가 어떤 역할을 하나요?

괜찮습니다. 아래 질문은 몇 번 들어보셨죠?

주소창에 입력한 이후에 화면이 렌더링 될때까지 어떤 과정이 이뤄지나요?

네, 프론트엔드 단골 질문인 위 질문에 리엑트의 내용을 추가한 것일 뿐입니다.

우리는 위 질문에 대해 네트워크 적인 요소를 제외하고 브라우저의 측면에서만 답변을 하자면
html parsing, domtree 형성, cssom 형성의 이야기를 합니다.

여기에 리엑트를 사용함으로 추가될 수 있는 과정에 UI tree가 있는데요,

저희가 작성하는 다음과 같은 JSX 코드들을 리엑트가 실제 돔에 업데이트 하기 전에 그리는 트리를
UI 트리라고 합니다.

export default function App() {
  const counter = <Counter />;
  return (
    <div>
      {counter}
      {counter}
    </div>
  );
}

아래 코드는 베타 문서에서 가져온 그림인데요,

우리가 JSX로 작성한 A,B,C의 컴포넌트를 리엑트가 UI 트리 (가운데 부분) 으로 그려주고 있는 것을 알 수 있네요! 그리고 React DOM이 UI tree에 명시된 내용들을 실제 브라우저 돔으로 업데이트 해주는 것입니다.

React DOM 이요?

네, 우리가 항상 작성하지만 한번 작성하고는 잘 신경안쓰는 바로 그 API 말이에요.

const rootElement = document.getElementById('root');
ReactDOM.render(<App />, rootElement);

상태는 어디 소속이지? 컴포넌트 소속?

컴포넌트 내부에 작성하는 상태 코드로 인해 상태는 컴포넌트 고유의 소속이라고 생각할 수 있습니다.
하지만 보다 정확히는 리엑트에 소속되어 있다고 보는게 맞아요.

When you give a component state, you might think the state “lives” inside the component. But the state is actually held inside React.
컴포넌트에 상태를 선언할 때 상태가 컴포넌트 내부에서 존재한다고 생각할 수 있지만 사실 상태는 리엑트 내부에 포함되어 있습니다.

리엑트는 UI 트리에서 명시되어 있는 컴포넌트를 보고 각 상태를 올바른 컴포넌트와 짝짓습니다.

여기 보이는 <Counter /> 컴포넌트는 UI 트리에서 서로 다른 위치에 존재하게 되어요.
카운터는 버튼을 클릭할 때마다 1씩 숫자가 올라가게 내부 상태가 설계되어 있어요.

export default function App() {
  return (
    <div>
      <Counter />
      <Counter />
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  ... 
  return (
    ...
    <h1>{score}</h1>
    ...
  )
  
}

이걸 UI 트리로 나타내면 다음과 같은데요, 앞서 말했듯이 트리에 각기 다른 위치에 포진되어 있어요.
jsx 문법으로만 봤을 때는 그저 한 줄 개행으로 분리해 선언했을 뿐인데 내부적으로 UI 트리에 서로 독립된 공간에 존재한다는게 신기하지 않나요?

물론 우리가 개발을 진행할 때 이렇게 구체적인 사항까지는 신경쓰지 않아도 리엑트가 알아서 렌더링을 잘 해주어요.하지만 수면 밑에서 무슨 일이 일어나는지 잘 아는 것이 개발을 진행함에 있어 가려움 없이 코딩할 수 있게 도와주지 않겠어요?

컴포넌트가 언마운트 될 때

앞서 컴포넌트가 아니라 리엑트가 컴포넌트 내부에 있는 상태를 포함하는 주체라고 했죠?
그렇다면 컴포넌트가 없어졌을 때는 어떻게 될까요?

리엑트는 각 컴포넌트의 상태를 해당 컴포넌트가 존재할 때까지에 한해 가지고 있어요.

다음 예제코드에서는 체크박스를 클릭했을 때에만 두번째 체크박스를 렌더링하고 있어요.
즉,showB 상태에 따라 UI 트리에 있는 Counter 컴포넌트의 존재 유무가 달라진다는 거에요.

export default function App() {
  const [showB, setShowB] = useState(true);
  return (
    <div>
      <Counter />
      {showB && <Counter />} 
      <label>
        <input
          type="checkbox"
          checked={showB}
          onChange={e => {
            setShowB(e.target.checked)
          }}
        />
        Render the second counter
      </label>
    </div>
  );
}

체크박스를 클릭해서 위의 예제 코드를 실제로 동작해보세요.
체크박스 체크 여부에 따라 UI 트리는 아래와 같이 변경되어요.


컴포넌트가 사라지거나, UI 트리의 동일한 위치에 다른 컴포넌트가 렌더링 되었을 때 해당 컴포넌트 내부에 선언해두었던 상태 값 또한 리엑트에서 더 이상 가지고 있지 않게 됩니다.

리엑트는 언제 상태를 유지하게 될까요?

컴포넌트가 UI 트리의 동일한 위치에 계속 존재한다면 상태또한 유지될까요?
다음의 코드에서 Counter 컴포넌트의 카운터를 3까지 올린다음에 체크박스를 누르면 어떻게 될 것 같으세요?

function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? (
        <Counter isFancy={true} /> 
      ) : (
        <Counter isFancy={false} /> 
      )}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        Use fancy styling
      </label>
    </div>
  );

카운터를 올린 다음에 체크박스를 클릭해서 App 컴포넌트에 있는 다른 <Counter/> 를 렌더링 시켜보세요!

3까지 카운터를 올린다음 체크박스를 눌렀더니, 카운터는 그대로 유지되네요?
응? 분명 다른 컴포넌트를 렌더링한 것 같은데 왜 카운터 값이 그대로 유지될까요?

앞서서 리엑트는 UI 트리에서 컴포넌트가 없어질 때 상태 값을 더 이상 가지고 있지 않는다고 했잖아요?
체크 유무와 상관없이 UI 트리 상에 div 엘리먼트의 첫번째 자식으로 항상 Counter가 존재하니 상태 값또한 리엑트에서 없어지지 않았어요.

UI트리의 위치와 JSX 상의 위치가 어떻게 다른가요?

리엑트가 중요하게 여기는 것은 UI 트리 상의 컴포넌트 위치입니다.
이 것은 JSX 마크업 상의 컴포넌트 위치와는 구분되어 이해되어야 합니다.

다음 코드를 볼까요?

아까는 삼항 연산자 (? :)에 따라 동일한 위치에 Counter 가 렌더링 되었어요.이제는 return 문 자체가 다른 위치에 선언되어 있네요.

function App() {
  const [isFancy, setIsFancy] = useState(false);
  if (isFancy) {
    return (
      <div>
        <Counter isFancy={true} />
        <label>
          <input
            type="checkbox"
            checked={isFancy}
            onChange={e => {
              setIsFancy(e.target.checked)
            }}
          />
          Use fancy styling
        </label>
      </div>
    );
  }
  return (
    <div>
      <Counter isFancy={false} />
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        Use fancy styling
      </label>
    </div>
  );
}

아까와 같이 카운터를 올리고 체크박스를 클릭해서 상태가 리셋되는지 확인해보세요!

리엑트는 코드의 위치나 if문 안에 선언되었는지 아닌지에 대해서는 관심이 없어요. 그저 JSX가 반환한 리엑트 트리의 구조만 관심이 있어요. 그래서 카운트 상태 값이 리셋되지 않았어요.

오, 좋아요. 그럼 재밌는 실험을 해볼게요.
지금은 if 문 여부와 관계 없이 아래와 같은 동일한 구조를 리턴하고 있어요.

- div
  - counter
  - label 

그럼 if 문 밖에 있는 jsx 구조를 조금 변경해 볼게요.

if( fancy) {
  ... 
}
return (
...
<div>
  <div>
    <Counter isFancy={false} />
  </div>
  <label>
    ...
)    

UI 트리 구조가 변경되었기 떄문에 상태 값이 리셋되어야 할 것 같은데요,
리셋되는지 볼까요?

오 리셋되네요! 뭔가 어두운 장막이 한 층 걷히는 기분입니다.
리엑트에게 UI 트리는 마치 컴포넌트와 상태의 주소와 같아요.
어떤 엘리먼트의 몇 번째 자식 컴포넌트인지를 살펴보는 것이죠.

다음과 같이 두 조건문이 두 개 연달아 사용된다면?

JSX 구조를 보면 isPlayerA에 따라 항상 div 아래 첫번째 엘리먼트에 Counter가 선언되고 있어요.

부모와 자식 구조가 어떤 상태 조건이든지 관계없이 항상 동일한 구조로 표현되는 것 같은데요,

그렇다면 버튼을 눌렀을 때 상태가 리셋되지 않을까요?

function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {isPlayerA &&
        <Counter person="Taylor" />
      }
      {!isPlayerA &&
        <Counter person="Sarah" />
      }
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        Next player!
      </button>
    </div>
  );
}

그렇지 않아요. 밑에서 코드가 어떻게 동작하는지 실험해보세요.

버튼을 눌렀는데 상태가 리셋되었어요.
위의 jsx 코드에서 리엑트 UI트리는 다음과 같이 표현되어요.
각각의 컴포넌트는 독립된 위치에 표현됩니다. 따라서 상태 값도 리셋되겠죠?

언제 상태가 리셋될까? 동일한 위치지만 다른 컴포넌트가 선언되었을 때

아까 리엑트가 상태를 언제 초기화 하는지에 대해 다음과 같이 말했어요.

컴포넌트가 사라지거나, UI 트리의 동일한 위치에 다른 컴포넌트가 렌더링 되었을 때

앞서 살펴본 예제가 전자이고 이제 후자에 대해 알아볼게요.
여기서 다른 컴포넌트는 무엇일까요?

첫번째는 jsx 코드상으로도 위화감 없이 아예 다른 태그를 렌더링했을 때에요.

export default function App() {
  const [isPaused, setIsPaused] = useState(false);
  return (
    <div>
      {isPaused ? (
        <p>See you later!</p> 
      ) : (
        <Counter /> 
      )}
      ...
      
  )
 }

체크박스의 유무에 따라 아주 다른 타입의 컴포넌트가 렌더링 되었어요.


두번째는 컴포넌트 내부에서 다른 컴포넌트를 선언했을 때에요.

이건 안티패턴이에요. 이렇게 사용하면 안됩니다.

다음 MyTextField 컴포넌트는 버튼이 클릭 되었을 때마다 매번 다시 선언되고 있어요.
이 경우 카운트를 올릴 때마다 MyTextField는 아예 다른 컴포넌트로 만들어져서 UI 트리에 표현됩니다.

따라서 리엑트 공식 문서에서는 컴포넌트 내부에 중첩으로 다른 컴포넌트를 선언하지 말라고 되어있어요.

This leads to bugs and performance problems. To avoid this problem, always declare component functions at the top level, and don’t nest their definitions.

function MyComponent() {
  const [counter, setCounter] = useState(0);

  function MyTextField() {
    const [text, setText] = useState('');

    return (
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
    );
  }

  return (
    <>
      <MyTextField />
      <button onClick={() => {
        setCounter(counter + 1)
      }}>Clicked {counter} times</button>
    </>
  );
}

세번째는 리엑트에게 명시적으로 다른 컴포넌트라고 알려주는 거에요.

우리는 흔히 map 함수를 통해 배열 형식의 자료를 렌더링 할 때 각 리스트에 key 값을 빼먹으면 안된다고 알고 있어요. 이것은 리엑트에게 각 컴포넌트가 다름을 인지하게 해주기 위함이에요.

다른 컴포넌트가 렌더링 되었을 때 상태값이 초기화 되는 것을 응용하여 우리는 상태 값 리셋이 필요할 시 key props 선언을 사용할 수 있어요.

다음 코드에서 테일러와 사라의 점수가 동일하게 표시되면 곤란하겠죠? 이를 막기 위해 명시적으로 key를 다르게 선언해주었어요.

function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {isPlayerA ? (
        <Counter key="Taylor" person="Taylor" />
      ) : (
        <Counter key="Sarah" person="Sarah" />
      )}
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        Next player!
      </button>
    </div>
  );
}

우리는 이 패턴을 잘 기억해두어야 해요.
프론트엔드 개발자에게 폼은 매우 성가신 존재에요.
여러 유저 인터렉션에 따라 독립된 상태 값이 존재하게 해야하기 때문이에요.

위의 key 값을 활용한다면 리셋을 위해 명시적으로 setState를 사용하는 코드를 없앨 수 있어요.

function Chat({ contact }) {
  const [text, setText] = useState('');
  return (
    <section className="chat">
      <textarea
        value={text}
        placeholder={'Chat to ' + contact.name}
        onChange={e => setText(e.target.value)}
      />
      <br />
      <button>Send to {contact.email}</button>
    </section>
  );
}

function ContactList({
  selectedContact,
  contacts,
  onSelect
}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact);
            }}>
              {contact.name}
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}

 function Messenger() {
  const [to, setTo] = useState(contacts[0]);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedContact={to}
        onSelect={contact => setTo(contact)}
      />
      <Chat key={to.id} contact={to} />
    </div>
  )
}

Messenger 컴포넌트에서 자식 컴포넌트인 Chat 컴포넌트의 key 값이 to 상태 값이 변경됨에 따라 유니크한 값으로 전달되는 것을 볼 수 있어요. 우리는 리엑트의 UI트리가 변경됨에 따라 어떻게 리엑트가 상태 값을 가지는지 이해함으로 복잡한 폼 구조도 최소한의 코드로 작성할 수 있게 되었네요!

오늘은 UI트리와 상태 보존, 리셋 관계에 대해 알아보았어요.
setState를 이용해 초기 값으로 선언하는 것은 기존 상태를 개발자가 생각하는 어떤 으로 덮어씌우는 것이에요.
UI트리를 이용해 리엑트의 입장에서 리셋하는 것은 어떠한 원리로 이해할 수 있는지 본 포스팅을 통해 이해하는데 도움이 되었으면 좋겠습니다.

읽어주셔서 감사합니다!

본 포스팅에 쓰인 예제 코드와 트리 그림은 리엑트 베타 문서에서 확인하실 수 있습니다.

https://beta.reactjs.org/learn/preserving-and-resetting-state#the-ui-tree

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글