[바닐라 자바스크립트] 함수형 컴포넌트 만들기: 컴포넌트 생성

jaemin·2023년 10월 31일
3
post-thumbnail

바닐라 자바스크립트는 웹 개발의 기본이며, 많은 프론트엔드 개발자들이 처음에 접하게 되는 언어입니다. 이에 따라 많은 초보 개발자들은 바닐라 자바스크립트를 통해 기본적인 웹 어플리케이션을 구축하게 됩니다. 그러나 대부분의 자료와 예제는 전통적인 클래스 기반의 컴포넌트를 사용하여 작성되어 있습니다. 현대의 프론트엔드 트렌드는 리액트와 같은 라이브러리에서 볼 수 있듯이 함수형 컴포넌트를 향하고 있습니다. 이러한 변화의 흐름 속에서, 저는 프로그래머스 데브 코스를 진행하며 바닐라 자바스크립트로 함수형 컴포넌트를 어떻게 효과적으로 구현할 수 있을지 고민하게 되었습니다.

이 글에서는 바닐라 자바스크립트만을 사용해서 간단한 todo App을 만드는 과정을 함께 탐색해보겠습니다.

✅ 함수형 컴포넌트

우리의 목표는 리액트의 함수형 컴포넌트와 유사하게 사용할 수 있도록 바닐라 자바스크립트로 컴포넌트를 구현하는 것이기 때문에 리액트에서 함수형 컴포넌트가 어떤 방식으로 사용되는지 알아보겠습니다.

function MyComponent () {
	return <div>내 컴포넌트입니다.</div>;
}

export default MyComponent;

함수형 컴포넌트는 이름에서도 알 수 있듯이, 일반적인 함수로써 정의됩니다. 함수형 컴포넌트는 JSX (JavaScript XML) 또는 React Element를 반환하는 함수입니다. 함수형 컴포넌트의 장점 중 하나는 간결하고 읽기 쉽다는 것입니다.
이제, 바닐라 자바스크립트에서도 리액트의 함수형 컴포넌트와 유사하게 사용할 수 있도록 구현해보겠습나다.

✅ 컴포넌트 생성하기

리액트의 장점 중 하나는 JSX라는 문법을 사용하여 UI를 간결하게 표현할 수 있다는 것입니다. JSX는 자바스크립트 XML의 약자로, HTML과 유사한 문법을 가진 자바스크립트의 확장 문법입니다. 하지만 이것은 리액트와 Babel과 같은 트랜스파일러를 함께 사용했을 때 가능합니다.

바닐라 자바스크립트에서는 JSX와 같은 특별한 문법을 사용하지 않기 때문에 바로 JSX를 반환하는 것이 불가능합니다. Babel과 같은 트랜스파일러 없이 바닐라 자바스크립트로 컴포넌트를 정의하려면, 기본 자바스크립트 문법을 사용해야 합니다.

이런 한계 때문에 우리의 바닐라 자바스크립트 컴포넌트에서는 문자열 형태의 HTML을 반환하는 방식을 사용하려고 합니다.

function MyComponent () {
	return {
    	element: `<div>내 컴포넌트입니다.</div>`;
    };
}

export default MyComponent;

위와 같이 바닐라 자바스크립트에서 컴포넌트를 정의하면, 리액트의 JSX처럼 직관적이고 선언적인 UI 표현이 조금 더 어려워질 수 있지만, 문자열 형태로 UI를 반환하는 이 방식은 기존의 createElement를 사용한 방식보다는 좀 더 직관적이고 선언적이라고 볼 수 있습니다.

📍 컴포넌트 렌더링 하기

이 방법을 사용해서 컴포넌트를 렌더링 해봅시다.


의도한대로 컴포넌트가 잘 렌더링됩니다. 현재는 테스트를 위해 script에 모든 코드를 작성했는데, 모듈을 사용하여 유지보수하기 편하도록 분할해보도록 하겠습니다.

  • 디렉토리 구조
📦src
 ┣ 📂components	// component들이 속할 디렉토리
 ┃ ┗ 📜Todos.js
 ┗ 📜index.js   // entry point 이자, 렌더 함수 위치하는 곳
  • index.html

  • src/index.js

  • src/components/Todos.js

index.js는 애플리케이션의 엔트리 포인트 역할을 하는데, 현재 구조에서는 컴포넌트를 직접 불러와 렌더링하는 역할까지 담당하고 있습니다. 엔트리 포인트 역할을 하는 파일은 다른 파일과 의존성을 최소화해야 합니다. 현재 index.js는 여러 컴포넌트를 직접 불러와 렌더링하는 책임까지 지니고 있습니다. 이를 해결하기 위해 컴포넌트들의 렌더링 역할을 App.js 파일로 분리하겠습니다.

  • src/App.js
  • src/index.js

이로써, index.js는 애플리케이션의 시작점으로써의 역할에만 집중하게 되었고, 실제 컴포넌트 구성과 렌더링은 App.js가 담당하게 되었습니다. 이런 구조 변경으로 인해 애플리케이션의 모듈화가 향상되고, 각 파일의 역할이 더욱 분명해졌습니다.

파일의 역할은 분명해졌으나, 또 다른 문제점이 있습니다. 파일 분리 전에는 Todos 컴포넌트가 반환하는 내용을 바로 확인할 수 있어 Todos().element의 사용에 큰 문제가 없었습니다. 그러나, 파일을 분리한 후의 render 함수에서Todos().element 방식의 렌더링은 가독성이 떨어집니다. 파일이 분리되면서 각 컴포넌트의 반환값이 무엇인지 직접 확인하기 힘들어졌고, 그 결과 element가 무엇을 나타내는지 파악하기가 더 어려워졌습니다.

이를 개선하기 위해, 컴포넌트를 생성하는 전용 함수를 도입하는 것이 좋을 것 같습니다. 컴포넌트가 이 함수를 통해 생성된다면, 코드는 더 선언적이며 의미 파악도 쉬워질 것입니다.

✅ 컴포넌트 생성 함수 만들기

  • 디렉토리 구조
📦src
 ┣ 📂components
 ┃ ┗ 📜Todos.js
 ┣ 📂core   // 관련된 핵심 로직, 훅, 생명주기 함수들을 보관
 ┃ ┗ 📜component.js
 ┗ 📜index.js

컴포넌트를 생성하는 함수는 컴포넌트를 렌더링할때 반드시 사용될 함수이고, 프로젝트 전반에서 사용될 예정이기 때문에 core 디렉토리에 위치시켰습니다.

  • src/core/component.js

여기서 createComponent는 매우 간단한 작업을 수행합니다. 주어진 컴포넌트를 받아서 호출하고, 그 결과를 바로 반환합니다.

이제 createComponent 함수를 사용하여 컴포넌트를 렌더링 해봅시다.

  • src/index.js
  • src/App.js

위 코드에서는 createComponent 함수를 이용하여 todosComponent를 생성하였습니다. 이후, 이 컴포넌트의 DOM 요소를 참조하여 app에 추가하고 있습니다. createComponent가 단순한 동작만 수행하지만, 그 사용을 통해 코드의 가독성과 의미가 향상된 것을 볼 수 있습니다.

우리의 프로젝트는 발전해나가면서 점점 더 복잡한 요구사항을 반영해야 할 상황을 맞이하게 됩니다. 특히 컴포넌트를 활용해보며 느낀 점은, 모든 상태를 컴포넌트 내부에 고정시키는 것은 제한적이라는 것입니다.

예를 들어 보겠습니다. 현재 Todos 컴포넌트는 내부 상태를 바탕으로 할 일 목록을 표현하고 있습니다. 그렇다면 만약 일상의 할 일과 업무의 할 일을 분리해서 표현하고 싶다면 어떻게 해야 할까요? 이를 위해서는 Todos 컴포넌트가 외부에서 전달받은 데이터, 즉 props를 기반으로 렌더링을 수행해야 합니다. 그러나 현재의 구조에서는 컴포넌트가 props를 전달받을 수 없습니다.

이번 단계에서는 컴포넌트가 props를 받아들일 수 있도록 업데이트하는 작업을 진행해보도록 하겠습니다.

📍 컴포넌트 props 추가하기

먼저, props를 추가하기 전에 리액트의 함수형 컴포넌트는 어떻게 props를 전달하고 받는지 간단히 살펴보겠습니다.

function ParentComponent() {
  return <ChildComponent message="Hello from Parent!" />;
}

function ChildComponent(props) {
  return <h1>{props.message}</h1>;
}

리액트에서, 부모 컴포넌트는 JSX의 속성을 통해 자식 컴포넌트에 props를 전달합니다. 그런 다음 자식 컴포넌트는 이 props를 파라미터로 받아 사용할 수 있습니다. 이처럼 리액트는 JSX의 간결하고 선언적인 문법을 통해 props의 전달과 사용이 자연스럽게 이루어집니다.

그러나, 바닐라 자바스크립트에서는 이와 같은 JSX 문법을 사용할 수 없습니다. 대신, 우리는 함수의 arguments를 활용하여 props를 전달할 수 있습니다.

현재 createComponent 함수는 컴포넌트 생성을 담당하며, 이 함수를 통해 컴포넌트에 props를 전달할 예정입니다. createComponent 함수 내에서 컴포넌트를 호출할 때, props를 함께 넘겨주는 방식으로 구현하면 바닐라 자바스크립트 환경에서도 props를 효율적으로 관리할 수 있을 것입니다.

  • src/core/component.js

아주 간단하죠? 기존 코드에서 props를 추가로 받고 이를 컴포넌트에 주입합니다. 이를 활용하여 Todos 컴포넌트가 props로 받은 할 일 목록을 렌더링 하도록 변경하겠습니다.

  • src/App.js

  • src/components/Todos.js

  • 결과

잘 적용된 것을 볼 수 있습니다.

현재 컴포넌트는 오로지 정적인 정보만을 표시하는 역할에 집중하고 있습니다. 그러나 웹 애플리케이션의 핵심은 사용자와의 상호작용에 있습니다. 이러한 동적인 상호작용을 가능하게 하기 위해서는 이벤트 핸들러의 바인딩이 필수적입니다.

다음 단계에서는 컴포넌트에 이벤트 핸들러를 연결하는 방법에 대해 알아보겠습니다.

✅ 이벤트 처리

📍 이벤트 바인딩

이벤트 처리에 집중하기 위해, 단순한 컴포넌트 구조로 다시 돌아가 보겠습니다. 아래 이미지와 같은 구조를 가진 컴포넌트를 구현해 보겠습니다.

변경할 컴포넌트들은 다음과 같습니다:

  • src/App.js
  • src/components/Todos.js

Todos 컴포넌트가 호출될 때, 내부의 코드는 위에서 아래 순서대로 실행됩니다. 만약 함수 내부에서 DOM을 참조하게 된다면 어떤 문제가 발생할까요?

function Todos({ todos }) {
  // 여기서 버튼 DOM을 참조한다면 어떻게 될까요?

  return {
    element: `
            <ul>
              ${todos.map(todo => `<li>${todo}</li>`).join('')}
            </ul>
            <button class="add-button">할 일 추가</button>
          `,
  };
}

export default Todos;

위와 같이 함수 내부에서 DOM을 참조하려고 시도하면, 원하는 DOM 요소가 아직 생성되지 않았기 때문에 참조에 실패하게 됩니다. 그렇다면 어떻게 Todos 컴포넌트가 완전히 렌더링된 시점을 알 수 있을까요? Todos 컴포넌트를 호출하는 부모 컴포넌트는 Todos의 DOM 요소들이 렌더링된 시점을 정확히 알고 있습니다. 이 점을 활용하면, 부모 컴포넌트에서 Todos 컴포넌트의 DOM이 완전히 렌더링된 후에 이벤트 바인딩을 수행할 수 있습니다.

bindEvents라는 함수를 생성하여 DOM 참조 로직을 그 안에 포함시키고, 이 함수를 반환하도록 해 부모 컴포넌트에서 Todos 컴포넌트의 렌더링이 완료된 이후에 호출하도록 하겠습니다.

  • src/components/Todos.js
  • src/App.js
  • src/index.js

Todos 컴포넌트의 렌더링 시점은 App 컴포넌트가, App 컴포넌트의 렌더링 시점은 최상위인 index.js가 알고 있기 때문에 이벤트 바인딩 함수의 호출을 상위에 위임하고 있습니다. 이러한 구조는 다음과 같은 몇 가지 문제점을 가지고 있습니다.

먼저, 상하위 컴포넌트 간의 강한 종속성이 형성됩니다. 이로 인해 코드의 유지보수가 어려워지며, 하위 컴포넌트를 독립적으로 테스트하기도 힘듭니다. 또한 이런 종속성은 하위 컴포넌트의 재사용성을 저하시킵니다.

이 문제의 원인은 각 컴포넌트가 자신의 렌더링 시점을 알지 못하기 때문입니다. 현재의 구조에서는 변경이 생길 때 전체 app이 리렌더링되는 방식을 사용하고 있어, 개별 컴포넌트 단위의 렌더링 관리가 되지 않습니다. 그 결과, 각 컴포넌트는 자신이 언제 렌더링 되는지 알 방법이 없고, 그 렌더링 시점은 상위 컴포넌트가 알고 있게 됩니다.

이 문제를 해결하기 위해서는 컴포넌트 단위로 렌더링을 관리하는 최적화 방안을 적용할 필요가 있습니다. 각 컴포넌트가 자신의 렌더링 생명주기를 스스로 관리하게 되면, 상위 컴포넌트에 대한 의존성을 줄일 수 있습니다. 이에 대한 구체적인 방법은 추후에 다뤄 보도록 하겠습니다.

📍 이벤트 핸들러 함수 바인딩과 상태 변경

이어서 이벤트 핸들러 함수를 생성하여 할 일 추가 버튼을 눌렀을때 상태가 변경되도록 해보겠습니다. 우선 현재 상태를 변경하는 setState 함수를 구현해보겠습니다.

  • src/App.js

setState 함수로 인해 상태가 재할당 되므로, state 변수의 선언을 const에서 let으로 바꾸었습니다. 리렌더링이 발생할 때마다 App 컴포넌트가 다시 호출되기 때문에, 상태가 초기화되는 것을 방지하기 위해 상태를 함수 외부로 이동시켰습니다. 상태 변경을 Todos 컴포넌트에서도 가능하게 하기 위해, props를 통해 전달하였습니다. 다음으로, Todos 컴포넌트에서 상태를 변경하는 이벤트 핸들러를 구현해보겠습니다.

  • src/components/Todos.js

props를 통해 받은 상태 변경 함수를 이용하여 이벤트 핸들러에서 상태를 업데이트합니다.

하지만, 이러한 방식으로 상태를 관리하면 아래와 같은 다양한 문제점이 발생할 수 있습니다.

외부에서 state 변수를 직접 조작할 수 있는 구조는 예기치 않은 사이드 이펙트를 초래할 수 있습니다. 상태 관리는 원칙적으로 setState 함수를 통해서만 이루어져야 하나, 현재 구조에서는 여러 부분에서 상태를 수정할 가능성이 있습니다. 이렇게 되면 상태가 어디서 변경되었는지 추적하기가 어려워지고, 디버깅 역시 복잡해질 위험이 있습니다. 또한, 컴포넌트 내에서 상태를 활용하고 수정하다보면 렌더링 호출이 여러 곳에서 발생하게 됩니다. 이것은 렌더링의 정확한 시점을 파악하기 어렵게 만듭니다. 더불어, 현재 코드는 선언적이지 않아 setState의 작동 방식을 컴포넌트가 직접 알아야 하는 문제가 있습니다.

이런 문제점들을 극복하기 위해, React의 useState와 유사한 기능을 도입하는 것이 바람직해 보입니다.

바로 다음 포스트에서 useState를 직접 구현해보겠습니다!

profile
프론트엔드 개발자가 되기 위해 공부 중입니다.

2개의 댓글

comment-user-thumbnail
2023년 12월 6일

오호.. 저도 이전 과제에서 함수형으로 컴포넌트를 구현해봤었는데 클래스처럼 프로토타입 상속을 이용해서 했었거든요!! https://github.com/prgrms-fe-devcourse/FEDC5-7_VanillaJS_2/pull/4
구현하면서도 가독성이 떨어지는게 느껴져서 이게 맞나 싶었는데 재민님처럼 상속이 아닌 core 함수로써 호출 하는 방법이 더 함수형이고 선언적이라는 생각이 들어서 좋은 것 같아요!! 제가 너무 클래스 추상화에 갖혀 꾸역꾸역 맞춰넣은 것 같습니다,, 반성하고 가요ㅜ 말랑말랑해질게요 재민센세,,,

1개의 답글