바닐라 부트캠프

[WIL] 바닐라코딩 부트캠프 6주차 후기 - Hook, 내뜻대로 안되네..

feel2 2025. 3. 23. 17:20
반응형

 

이번주는 Hook을 제대로 다루는걸 위주로 과제를 진행하였다.

Hook이란 React 16.8에서 도입되어 함수형 컴포넌트에서 상태와 사이드 이펙트를 관리하는 방식을 혁신적으로 변경을 했다고 한다.

React를 하면서 Hook을 제대로 이해를 못한다면, react로 프로젝트를 진행하는 것이 어려울만큼 아주 중요하다고 생각한다.

그럼 시작해보자!

 

이번주 과제


✅ 과제 주제

이번주 과제는 React Hook을 활용하여 모의 유튜브 사이트를 만들어 보는 걸 진행하였다.

실제로 유투브 API를 이용하기 위해서 API key를 발급받아 유튜브의 비디오 리스트를 API를 통해서 받고, 화면을 구성하는 것을 이번주에 진행하였다.

 

✅ 사전에 알아야 하는 개념

 

이번주는 Hook을 중점적으로 알아보고 시작하였다.

 

Hook?

 

React Hooks는 React 16.8에서 도입되어 함수형 컴포넌트에서 상태와 사이드 이펙트를 관리하는 방식을 혁신적으로 변경하였다.

 

Hooks 이전에는 상태나 생명주기 메서드를 사용하려면 함수형 컴포넌트를 클래스 컴포넌트로 변환해서 사용해야 했다.

 

Hooks은 함수형 컴포넌트 내에서 이러한 기능을 직접적으로 사용할 수 있는 방법을 제공하여 더 깨끗하고 가독성이 높은 코드를 작성할 수 있게 도와준다고 한다.

 

정리해보면 다음과 같이 컴포넌트의 사용이 진화해 왔다는걸 알 수 있다.

 

함수형 컴포넌트 → 클래스 컴포넌트 → Hooks(함수형 컴포넌트 + state)

 

 

여기서 클래스형 컴포넌트와 함수형 컴포넌트라는 용어가 나오는데, 두 컴포넌트의 차이를 아는 것도 중요하니 짚고 넘어가 보자.

 

선언 방식

 

클래스형 컴포넌트

 

import React from "react";

class MyButton extends React.Component {
  render() {
    return (
      <div>
        <button className='myButton'>Click me</button>
      </div>
    );
  }
}

export default MyButton;

 

 

클래스형 컴포넌트의 경우에는 키워드 class를 통해서 선언을 해야하며, React의 Component를 상속받아야 한다. 또한 랜더링을 위해서 꼭 render 라는 메서드가 필요하다.

 

함수형 컴포넌트

 

function MyButton() {
    return (
      <div>
        <button className='myButton'>Click me</button>
      </div>
    );
}

export default MyButton;

 

 

클래스형 컴포넌트와 비교하면 훨씬 코드가 간결해진 걸 알 수 있다. 함수 자체가 랜더 함수이기 때문에 render 메서드가 필요하지 않으며, React의 Component를 상속받지 않아도 된다.

 

상태 관리

 

클래스형 컴포넌트

import React from "react";

class MyButton extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Increase
        </button>
      </div>
    );
  }
}

export default MyButton;

// constructor 없이 state 설정
class MyButton extends React.Component {

 state = { count: 0 };

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Increase
        </button>
      </div>
    );
  }
}

export default MyButton;

 

 

constructor 안에서 this.state를 통해 초기값 설정이 가능하며, constructor 없이도 state 설정이 가능하다.

 

함수형 컴포넌트

 

import { useState } from "react";

function MyButton() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increase</button>
    </div>
  );
}

export default MyButton;

 

함수형 컴포넌트는 useState를 사용하여 state를 관리한다. useState를 사용하면 배열이 반환이 되는데, 첫번째 요소는 state, 두번째 요소는 setState의 역할을 한다.

 

state는 현재 설정된 값을 의미하며, setState를 통해서 state값을 업데이트 할 수 있다.

 

생명 주기

 

모든 리엑트 컴포넌트는 생명주기를 가지고 있다. 컴포넌트는 항상 생성(mount) → 업데이트(update) → 제거(unmount)의 생명주기를 갖는다.

 

각 생명주기에서 어떤 작업을 처리해야 하는지 적절하게 지정해줘야 불필요한 업데이트를 방지할 수 있다.

클래스형 컴포넌트는 LifeCycle API를 사용하며, 함수형 컴포넌트는 Hook을 사용하여 생명주기를 관리한다.

 

클래스형 컴포넌트

 

https://velog.io/@hans1997/React-클래스형-컴포넌트-함수형-컴포넌트

 

 

import React from 'react';
import { createConnection } from './chat.js';

class ChatRoom extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      serverUrl: 'https://localhost:1234',
    };
    this.connection = null;
  }

  setupConnection() {
      if (this.connection) {
        this.connection.disconnect();
      }
      this.connection = createConnection(this.state.serverUrl, this.props.roomId);
      this.connection.connect();
  }

  componentDidMount() {
    this.setupConnection();
  }

  componentDidUpdate(prevProps, prevState) {
    if (prevProps.roomId !== this.props.roomId || prevState.serverUrl !== this.state.serverUrl) {
      this.setupConnection();
    }
  }

  componentWillUnmount() {
    if (this.connection) {
      this.connection.disconnect();
    }
  }

  render() {
    return (
      <div>
        <h1>Chat Room {this.props.roomId}</h1>
        <p>Connected to: {this.state.serverUrl}</p>
      </div>
    );
  }
}

export default ChatRoom;

 

한가지 예시로 채팅방 컴포넌트를 만들어 보았다.

 

 

생명주기 관점에서 어떻게 흘러가는지 살펴보면

  1. constructor 가 실행이 되면서 초기화가 진행되고, componentDidMount 가 실행된다.
  2. 만약 변경사항이 있다면 componentDidUpdate 가 실행된다.
  3. ChatRoom 컴포넌트가 언마운트(화면에서 제거) 된다면 커넥션을 끊는다.

 

명시적으로 생명주기를 관리해주는 건 좋지만, 사용하는 입장에서는 모두 명시적으로 적어주어야 하기 때문에 귀찮을 수가 있다.

 

함수형 컴포넌트

 

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [serverUrl, roomId]);

  return (
    <div>
      <h1>Chat Room {this.props.roomId}</h1>
      <p>Connected to: {this.state.serverUrl}</p>
    </div>
  );

}

 

함수형 컴포넌트로 전환했을 뿐인데, 코드 가독성이 훨씬 좋아진다.

 

명시적으로 생명주기를 지정해주어야 했던 클래스형 컴포넌트와는 달리, 함수형 컴포넌트에서는 useEffect를 활용하여, 컴포넌트의 모든 생명주기를 다룰 수 있다.

 

this 바인딩

 

클래스형 컴포넌트

 

this 바인딩 문제의 경우, strict 모드일반 모드의 차이가 발생한다.

 

class MyComponent extends React.Component {
  constructor() {
    this.value = 10;
  }

  showValue() {
    console.log(this.value);
  }
}

const obj = new MyComponent();
const ref = obj.showValue; // 함수만 할당 (this 바인딩 안됨)
ref(); // 스트릭트 모드: TypeError (this가 undefined)
       // 비스트릭트 모드: undefined 출력 (this가 window를 참조)

 

따라서 명시적으로 this를 바인딩 해주어야 한다.

 

const ref = obj.showValue.bind(obj); // this를 명시적으로 바인딩
ref(); // ✅ 10 출력

 

또한 이벤트 핸들러의 경우에도 this를 바인딩 해주어야 한다.

 

class Button extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.handleClick = this.handleClick.bind(this); // 바인딩 필요
  }

  handleClick() {
    console.log(this.state.count);
  }

  render() {
    return <button onClick={this.handleClick}>Click me</button>;
  }
}

export default Button;

 

만약 화살표 함수를 이용한다면 따로 this를 바인딩 해주지 않아도 된다.

 

class Button extends React.Component {
  state = { count: 0 };

  handleClick = () => {
    console.log(this.state.count);
  };

  render() {
    return <button onClick={this.handleClick}>Click me</button>;
  }
}

export default Button;

 

함수형 컴포넌트

 

function Button() {
  const handleClick = () => {
    console.log("Button clicked!");
  };

  return <button onClick={handleClick}>Click me</button>;
}

export default Button;

 

함수형 컴포넌트의 경우에는 this 바인딩을 하지 않기 때문에 this가 필요 없다.

정리해보면 다음과 같은 차이가 있다.

 

 

특징 함수형 컴포넌트(Function Component) 클래스형 컴포넌트(Class Component)
선언 방식 함수(function)로 선언 클래스(class)로 선언 (extend Component)
상태(State) useState 사용 (React Hook) this.state 사용
생명주기(Lifecycle) useEffect 사용 componentDidMount, componentDidUpdate, componentWillUnmount 등 사용
this 바인딩 this 없음 this 바인딩 필요 (constructor에서 this.method = this.method.bind(this) 해야 함)
성능 가볍고 최적화 쉬움 상대적으로 무거움
코드 간결함 짧고 간결함 코드가 길어질 수 있음

 

✅ 도전이 되었던 부분

 

이번 과제를 하면서 도전이 되었던 부분은 무한 스크롤 구현이었다.

 

무한 스크롤 구현 방식에는 크게 2가지 방식이 있는데, 스크롤을 이용하는 방식과 IntersectionObserver API 를 이용하는 방식이 있다.

 

스크롤 이벤트를 사용해서 무한 스크롤을 구현하는 경우에는 구현이 쉽고, 모든 브라우저에서 동작을 하지만, 스크롤 할때마다 이벤트가 발생하기 때문에 과도한 랜더링이 발생이 가능하다.

 

또한 빠르게 스크롤을 하면 여러 번 이벤트가 발생하여 생각했던 것 보다 더 데이터 패칭이 일어날 수도 있다.

 

그래서 나는 IntersectionObserver API 를 이용하여 구현하기로 하였다.

 

 

IntersectionObserver API를 통해서 구현하는 도중 여러 문제가 발생하였는데, 그 중 하나가 observer 대상 선정이었다.

구현을 위해서 관찰 대상을 선정해야 하는데, 처음에 나는 마지막 요소를 대상으로 선정하였다.

 

<Wrapper data-test="video-list">
  {videos.map((video, idx) => {
    const isLastElement = idx === videos.length - 1; // 마지막 요소 찾기
    return (
      <Link to={video.id.videoId} state={{ video }} key={video.id.videoId + idx}>
        <VideoListEntry
          video={video}
          measureRef={isLastElement ? measureRef : null} // 마지막 요소에만 ref 추가
        />
      </Link>
    );
  })}
</Wrapper>

 

처음에 이렇게 대상을 선정했을 때는 잘 작동을 하는 것처럼 보였다.

그런데 화면이 동적으로 바꾸면, 요소들이 밀려나면서 의도치 않게 무한스크롤이 되는 위치가 동적으로 변경이 되었다. 이러면 내가 완전히 통제를 할 수 없다고 생각이 들었다.

 

그래서 일단 css를 통해서 한 줄 당 나오는 요소의 수를 고정하면 문제는 해결되었다.

 

const GridWrapper = styled.div`
  display: grid;
  padding: 2em 0 0;
  width: 100%;
  grid-template-columns: repeat(5, minmax(200px, 1fr)); <= 이부분!!
  row-gap: 20px;
  column-gap: 20px;
`;

 

그런데 이렇게 하면 문제가, 요소의 크기가 달라지면, 감지 영역도 달라진다는 것이다.

 

물론 요소의 크기가 처음에 정해지면 달라질 일이 별로 없겠지만, 최대한 다른 변수에 기능이 영향을 받지 않으면 좋겠다는 생각이 들었다.

그래서 마지막에 리스트를 순회를 하고, 새로운 감지 영역을 넣어두기로 했다.

 

<GridWrapper data-test="video-list">
   {videos.length && videos.map((video, idx) =>
    <Link to={video.id.videoId} state={{ video }} key={video.id.videoId}>
      <VideoListEntry video={video} />
    </Link>)}
</GridWrapper>
<div ref={measureRef}></div>

 

이렇게 하니 요소의 크기가 바뀌더라도, 감지 영역 자체는 똑같기 때문에, 내가 예상한 타이밍에 감지가 일어나 무한스크롤이 동작을 하게 되는걸 볼 수 있었다.

 

멘토링 시간


✅ 멘토와 나눈 대화

 

멘토링 시간에 지금 하고 있는 진도가 어떤지, React를 학습하는데 문제가 없는지를 위주로 얘기를 나누었던 것 같다.

나는 이미 이전 직장에서 Vue를 해보았기 때문에, 학습하는데 있어 큰 어려움은 없었다.

 

다만, React이 추구하는 방향을 이해하고, 적용하는데 어려움이 있었던 것 같다.

특히 Hook을 통해서 상태를 관리하는데, 내가 원하는 타이밍에 Hook이 제대로 동작을 안하거나, 예상치 못한 타이밍에 Hook이 동작하는 사례가 있었다.

 

아직 React에 적응하는 시간이라 생각하며 더 열심히 학습을 진행해야겠다.

 

✅ 멘토링이 학습에 준 영향

 

작업이 마무리 될 무렵, 한가지 문제에 봉착하였다.

 

strict 모드에서는 함수형 컴포넌트가 순수성을 유지하는지 확인을 위해 2번 랜더링이 된다.

즉, 몇 번이 실행되던 항상 같은 값이 나와야 하는데, 나는 strict 모드랑 strict 모드가 아닐때랑 결과가 다른 것이다!

 

 

문제 상황의 코드는 다음과 같다.

async function fetchData(query) {
    const items = await getVideoList(query);

    setVideos((prevVideos) => isPressEnter ? items : [...prevVideos, ...items]);
    setHasMore(items.length >= 5);
    setIsPressEnter(false);
  }

  useEffect(() => {
    debugger
    // url에서 searchParam으로 접근한 경우
    const query = searchParams.get("q");
    if (query && !text) {
        fetchData(query);
        return;
    }

    if (!isPressEnter && page === 0 && videos.length > 0) {
      return;
    }

    if (isPressEnter) {
      window.scrollTo(0, 0);
    }

    fetchData(text ? text : query);

  }, [isPressEnter, page]);

 

멘토님께 질문을 드려 어떻게 해결하면 좋을지를 여쭤보았다.

 

멘토님께서는 하나의 fetchData로 모든 걸 해결하려고 해서 로직이 꼬여서 그런것 같다는 말씀을 해주셨다.

로직을 각각 분리하면, 문제를 해결할 수도 있다고 하셔서 한번 분리해보기로 하였다.

 

 

그래서 하나하나 데이터 패치를 하는 경우 디펜던시를 분리하여 로직을 구성해보았다.

 

     // 처음 데이터 패치 + searchParam 있을 때 패치
  useEffect(() => {
    async function fetchData() {
      const searchString = searchParams.get("q");
      if (searchString) {
        setSearchParam(searchString);
      }
      const items = await getVideoList(searchString);

      setVideos(items);
    }

    fetchData();
    setIsInitFetchData(true);

  }, [])

  // loadMore 패치
  useEffect(() => {

    if (!isInitFetchData || page <= 0) {
      return;
    }

    async function fetchData() {
      const queryString = text ? text : searchParam;
      const items = await getVideoList(queryString);
      setVideos((prevVideos) => [...prevVideos, ...items]);
      setHasMore(items.length >= 5);
    }

    fetchData();

  }, [page])

  // 검색어 Enter 패치
  useEffect (() => {
    if (!isInitFetchData) {
      return;
    }

    if (isPressEnter) {
      window.scrollTo(0, 0);
    }

    async function fetchData() {
      const items = await getVideoList(text);
      setVideos(items);
      setHasMore(items.length >= 5);
      setIsPressEnter(false);
    }

    fetchData();

  }, [isPressEnter])

 

이렇게 바꾸니, 처음 패치데이터도 2번 일어나도, 컴포넌트의 순수성을 지켜서 원하는데로 동작하게 되었다.

그리고 이렇게 하나의 부모 컴포넌트에서 상태를 관리하는 것이 좋은지를 멘토님께 또 여쭤보았다.

 

내가 생각했을 때는 데이터 패칭을 관리하는 걸 하나의 컴포넌트에서 관리를 하면 더 유지보수성이 좋아질 것이라고 생각이 들어 상태를 하나의 부모로 끌어올려서 관리를 했다.

 

하지만 이렇게 하면 APP이라는 부모 컴포넌트가 무거워지는 건 아닐까 생각이 들어서 여쭤보았다.

멘토님께서는 이렇게 하나의 컴포넌트에서 데이터 패칭을 관리하는 건 오히려 자식 컴포넌트가 더 가벼워지니, 보는 관점에 따라 다르겠지만 크게 상관은 없을 것 같다고 얘기해주셨다.

 

다만, 부모가 랜더링 될때마다 자식 컴포넌트가 무조건 랜더링이 일어나니, 성능 측면에서는 조금 불이익이 발생할 수도 있을 것 같다고 말씀하셨다.

 

어떤 코드는 다 트레이드 오프가 있으니, 그걸 잘 조율하는 것이 그 개발자의 역량인 것 같다.

 

한줄 후기


Hook, 내 뜻대로 움직여줘!

참조


반응형