리액트 파헤치기 한 달 스터디 소감 및 배운 점 정리 1탄

sooki_m·2025년 3월 31일
post-thumbnail

👉 리액트 파헤치기 한 달 과정 repository

숨가쁜 한 달을 보내고 한 달에 한 편 블로그 쓰기 모임에 이번엔 당당히(?!) 불참을 하고 벌금을 내려고 했지만 신에게는 아직 남은 총알이 ...

그래도 약속은 지켜야 하므로 2월 초 ~ 3월 초 한 달 동안 진행했던 "리액트 스터디" 후기 및 새로 배운 점들에 대해 가볍게 복기 하는 시간을 가져보려고 합니다. 😎

일단 과정 자체는 컴팩트합니다. 매주 스터디 해야 하는 큰 주제가 있습니다. 그리고 1-5일까지는 큰 주제에 달린 세부 주제 + 매일의 스터디 과제를 하면 됩니다. (참 쉽죠잉~?)

(사람이 살다보면 매일매일 공부할 수는 없기 때문에 1-5일에 해당하는 공부는 스스로 알아서 일정 조절해서 어제 못했으면 다음날 2배를 하는 그런 식으로 진행하면 됩니다.)

매주 첫날에는 멘토님과 다른 팀원들과 함께 한주간 공부하면서 궁금했던 부분 질의응답 + 멘토님의 알려주시는 이력서 작성법 등과 같은 이직 꿀팁 + 다음주 스터디 주제 발표 등으로 구성된 1시간으로는 부족한 알찬 스터디 시간이 있습니다.

5-6일차에는 한 주 과제를 마무리하고 멘토님과 다른 팀원들에게 PR 리뷰 요청을 보내야 하고 7일 차에는 받은 리뷰를 보고 수정하거나 조금 더 공부를 하는 시간을 가지면 됩니다.

해당 과제를 하면서 가장 좋았던 점은 아래와 같습니다.

1. 리액트 내부 동작을 바닐라 스크립트로 짜보면서 조금 더 이해해 볼 수 있었다는 점
2. 다 모르는 분들과 함께 하기 때문에 포기를 할 수 없어서 마지막까지 완주를 할 수 있었다는 점 (👈 이게 진심 큰 이유...)
3. 멘토님과 함께 1 on 1을 하면서 면접이나 이직 관련 꿀조언을 들을 수 있다는 점🍯

그러면 한 달동안 어떤 내용으로 "리액트 파헤치기" 를 했는지 좀 더 자세하게 알아보겠습니다.

스터디 상세 링크는 여기서 확인! ✨

제가 참여한 스터디는 왓에버에서 진행하는 스터디였는데요, 비용이 조금 들지만 해당 스터디를 선택했던 이유는 일단 제가 외부 스터디를 한 번도 해본 적이 없어서 일종의 보장된(?) 스터디에 참여하고 싶었습니다.
해당 스터디의 경우 스태프가 따로 있고 스터디원을 모아서 멘토와 스터디 그룹을 연계해 주기 때문에 스터디원을 모집하거나 참여하기로 한 스터디원이 갑자기 안 나타나서 모임이 안 되는 등의 그런 문제들을 직접적으로 핸들링 할 필요가 없었습니다. 그래서 정말 찐으로 "스터디"에만 집중할 수 있다는 장점이 있습니다. (거의 지금 돈 받고 하는 ppl 같지만 진짜로 내돈내산 후기임)

또 멘토님과 한 시간 정도 커피챗(1 on 1)을 할 수 있는 시간도 있었는데요. 같은 회사 사람이 아닌 다른 회사를 다니는 개발자와 그렇게 긴 시간 대화를 할 수 있었다는 것도 소중한 기회이자 경험이었습니다.😄

단점이라고 하자면 결국 비용이겠죠. 저같은 직장인이야 미래에 투자한다고 생각하고 충분히 투자할 수 있는 금액이지만 한 푼이 아쉬운 대학생이나 취준생에게는 결코 작은 비용이라고 생각하지 않습니다. 그렇기 때문에 기왕에 참여하기로 했다면 본인이 더 열심히 해서 최대한 아웃풋을 낸다는 각오로 임하면 좋습니다.

커리큘럼은 4주에 걸쳐 리액트 내부 동작을 공부하고 실제로 바닐라 자바스크립트로 로직을 구현해 마지막 주차에 설문조사 폼을 만들어보는 것으로 마무리가 됩니다.

1주차

1주차는 간단하게 프로젝트 세팅을 하고 virtual dom을 리턴하는 createElement(_jsx) 함수를 만들면 됩니다.

번들러(+ 필요하다면 babel도 포함) 및 트랜스파일러가 어떻게 jsx를 Virtual Dom으로 파싱하는지를 스터디 할 수 있습니다. 사실 react+vite 조합으로 프로젝트를 구성하면 아무 생각 없이 react plugin을 설치해서 vite.config.js(ts)에 주입을 해주는데요, 내부 코드를 꼼꼼히 살펴보면서 react plugin 내부에서 babel을 사용하고 있고 트랜스파일러를 사용해 우리가 작성한 jsx 문법이 _jsx 함수로 변환된다는 사실을 알 수 있었습니다. (react 18 버전 이전이라면 createElement, 이후 버전이라면 _jsx로 파싱됩니다. 그렇기 때문에 우리가 JSX문법을 쓰더라도 명시적으로 import React from react를 해줄 필요가 없어진 것이죠!)

react babel 플러그인을 사용하면 node_moduels에 설치된 리액트 디렉토리에서 변환할 함수를 찾겠지만, 우리는 우리만의 jsx 함수로 변환시켜야 하므로 우리 내부 코드로 치환될 수 있도록 config 설정을 바꿔줘야 합니다. 그렇기 때문에 사실 babel을 굳이 사용할 필요 없이 vite config 파일의 esbuild 옵션을 활용해서도 충분히 세팅이 가능합니다.

import { defineConfig } from 'vite';
import viteJsconfigPaths from 'vite-jsconfig-paths';

export default defineConfig({
  plugins: [viteJsconfigPaths()],
  esbuild: {
    jsx: 'transform',
    jsxInject: 'import { h, Fragment } from "@/jsx/jsx-runtime"',
    jsxFactory: 'h',
    jsxFragment: 'Fragment',
  },
});

jsxFragment를 따로 설정해주지 않으면 우리가 자주 사용하는 React.Frament(<><div /> .. </> 을 쓸 수 없으니 이 부분도 빠뜨리지 않고 설정해주는 것이 좋습니다. 😊)

이렇게 설정두고 jsx/jsx-runtime.js 파일에서 제가 만든 createElement 함수를 가져다 쓸 수 있도록 해주면 모든 세팅은 끝이 납니다! (참 쉽죠잉~?)

import { createElement } from '@/utils/createElement';

export const h = (component, props, ...children) =>
  createElement(component, props, children);

export const Fragment = 'Fragment';

2주차

2주차의 주제는 1주차에 제가 만든 virtual dom(가상 돔이 순수 자바스크립트 객체라는 사실을 이 글을 읽는 모든 독자들은 이미 알고 계실테죠..!)을 가지고 실제 화면에 그려보는, render 함수를 작성하게 됩니다.

(1주차에서 2주차, 2주차에서 3주차 ... 한 주가 지날수록 과제의 난이도와 공부해야 할 개념들이 눈덩이처럼 불어나는 것을 느낄 수 있었는데요. 차근차근 계단 식이 아니라 갑자기 난이도가 * 10배씩 되는 거 같은 느낌을 받았습니다. 😅)

render 함수는 일종의 재귀함수로 구현이 됩니다. render 함수가 재귀가 될 수 밖에 없는 이유는 우리가 만든 virtual dom이 다음과 같은 구조로 리턴되기 때문입니다.

createElement(
  'div', 
  { name: '수빈' } , 
  createElement(
    'span', 
    { age: 100 }, 
    null
  )
);

우리가 흔히 알고 있는 render tree가 결국 부모에서부터 자식까지 HTML 노드를 그리는 일이기 때문에 첫 번째 createElement를 호출한 뒤 자식이 있다면 해당 createElement 함수가 종료되지 않고 다시 자식 createElement를 호출하게 됩니다. 만약 자식이 없다면 거기서 함수를 종료하게 됩니다.

  if (isPrimitiveType(firstChild) && children.length === 1) {
    container.appendChild(document.createTextNode(firstChild));
    return container;
  }

  getChildren(children).forEach((child) => {
    container.appendChild(createDOM(child));
  });

diffing 알고리즘이 없다면 virtual dom은 성능이 절대 실제 dom을 조작하는 것보다 좋다고 할 수 없을 것입니다. (제 짧은 식견으로는 그렇습니다.)

diffing 알고리즘이 없이 상태가 변경될 때 모든 dom을 처음부터 다시 그리게 되면 버튼을 눌러서 숫자를 증가시키는 간단한 앱일지라도 10번 정도 클릭한 순간부터 브라우저가 심각하게 느려지는 것을 볼 수 있었습니다.

2주차에서는 useState도 직접 구현을 했어야 했는데요, useState는 클로저로 내부 상태를 저장해서 index로 전체 앱의 상태를 관리하는 방식을 취했습니다. 최근에 안 사실인데 리액트에서 useState() 호출 시 넘기는 initialState도 함수로 넘길 수 있더라고요. (한 번도 콜백 함수로 넘겨본 적 없는 1인...✋)

import { createRoot } from './commit';

export default (function () {
  let index = 0;
  const state = [];

  const forceUpdate = () => {
    index = 0; // index 초기화
    createRoot(); // ⛔️ App()부터 다시 그림.. 성능 최적화가 필요함!
  };

  const useState = (initialSate) => {
    let _index = index; // 현재 useState에서 사용하는 index를 저장
    index++;

    const setState = (newState) => {
      state[_index] = newState;
      forceUpdate();
    };

    if (!!state[_index]) {
      return [state[_index], setState];
    }

    state[_index] = initialSate;

    return [state[_index], setState];
  };

  return { useState };
})();

ChatGpt한테 리액트 초기값 설정 원리에 대해 한 번 질문해봤습니다. (유료 구독 기념 ㅎ)

Q. 리액트에서 useState(initialState) 호출 시 initialState를 setState에 넘겨서 리턴 받은 값을 초기값으로 할당해?

A. React는 useState(initialState)를 호출하면 다음 과정을 거쳐 초기 상태를 설정해.

  1. React Fiber에서 현재 컴포넌트의 Hook 상태를 확인
    • useState는 내부적으로 renderWithHooks 함수를 통해 관리됨.
    • currentlyRenderingFiber.memoizedState에서 이전 상태를 확인함.
    • 초기 렌더링이라면 이전 상태가 없으므로 initialState를 사용해야 함.
    1. initialState가 함수인지 확인
      • typeof initialState === "function"이라면 → initialState() 실행 후 반환 값을 초기 상태로 저장.
      • 일반 값이라면 → 그대로 상태로 저장.
    2. 새로운 hook 객체 생성 및 Fiber 노드에 저장
      • useState는 Hook 객체를 만들고 fiber.memoizedState에 연결함.
      • 즉, React는 initialState를 단순히 setState(initialState)로 실행하는 게 아니라, Fiber 트리에 저장하여 추적함.
function mountState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>] {
  const hook = mountWorkInProgressHook(); // 새로운 Hook 객체 생성

  if (typeof initialState === "function") {
    initialState = initialState(); // 함수라면 실행 후 결과 저장
  }

  hook.memoizedState = initialState; // 초기 상태 저장
  const queue = { pending: null }; // 상태 업데이트 큐 생성
  hook.queue = queue;

  return [hook.memoizedState, dispatchSetState.bind(null, hook, queue)];
}

📌 업데이트 시 (setState 호출 과정)

Fiber 트리를 다시 그리기 위해 업데이트를 스케줄링함.

function dispatchSetState<S>(hook, queue, newState) {
  const update = { action: newState, next: null };

  if (queue.pending === null) {
    update.next = update;
  } else {
    update.next = queue.pending.next;
    queue.pending.next = update;
  }

  queue.pending = update;
  scheduleRender(); // 리렌더링 예약
}

내부적으로 action queue에 pending 값으로 업데이트 순서를 조정하고 있네요.
이 부분은 몰랐는데, 조금 더 학습이 필요할 것 같습니다. 😌

to be continue...

profile
개발 up and down

0개의 댓글