본문 바로가기

나야, 리액트 스터디

[Week7] Effect로 동기화하기

안녕하세요 웹 YB 김다현입니다......

useEffect..... 진짜 항상 궁금했던 챕터라 공식문서가 너무 도움이 많이 됐답니다.....

원래 Effect로 동기화하기를 딥다이브 하려고 했는데... 딥다이브는 무슨 ;; 양이 너무너무너무 많아서 아티클 정리를 다 못 했어요.....

개인적으로 이 챕터는 정말 중요하다고 생각해서 시간 날 때마다 추가할 예정입니다....... ㅎ.ㅎ (진짜로)

 

Effect는 무엇이고 이벤트와는 어떻게 다른가요?

React 컴포넌트 안의 로직은 크게 두 가지로 나뉘어요.

 

1️⃣ 렌더링 로직 - 컴포넌트의 최상단에서 JSX를 반환하는 로직

-> props와 state를 기반으로 UI를 만들어내요.

-> 순수하게 동작해야 합니다. 부수 효과 없이 결과만 계산하는 수학 공식처럼 동작해야 해요.

 

2️⃣ 이벤트 핸들러 - 사용자 상호작용(클릭, 입력 등)에 반응하는 함수

-> ex. 버튼 클릭할 때 데이터 전송, 입력 값 업데이트 등...

-> 이 로직은 사용자 행동으로 인해 발생하는 부수 효과를 포함할 수 있어요.

 

💡 이벤트로 해결할 수 없는 경우가 있다?!

 

공식문서에는 이렇게 ChatRoom 컴포넌트를 예시로 설명해주고 있는데요.

저는 이 설명을 처음엔 추상적으로 이해가 되는 느낌이었어서 쉽게 풀어 설명해보자면!

 

ChatRoom 컴포넌트를 화면에 표시할 때마다 서버에 접속해야 한다는 게 무슨 뜻일까요?

 

채팅방을 만든다고 상상해봅시다. 사용자가 채팅방 페이지를 열면 이 페이지는 서버와 연결돼야 해요. 그래야 새로운 메시지를 실시간으로 받을 수 있겠죠? 하지만 사용자가 버튼을 클릭해서 연결을 시작하는 게 아니라 채팅방 컴포넌트를 화면에 띄우는 것 자체가 서버 연결을 시작하는 조건이에요. 즉, 버튼 클릭 같은 명확한 이벤트가 아니라 그냥 화면에 컴포넌트를 보여줬다는 사실로 인해 동작해야 합니다.

 

💡 왜 이벤트 핸들러로는 안 될까?

 

이벤트 핸들러는 사용자의 특정 행동(ex. 버튼 클릭)으로 작동해요. 하지만 지금 상황에서는 버튼 클릭 같은 사용자 액션이 없어요. 컴포넌트가 렌더링되는 순간 즉 화면에 나타나는 순간 서버 연결이 필요합니다. 이런 렌더링 후에 자동으로 실행되는 작업은 이벤트 핸들러가 아니라 Effect로 처리해야 해요!

 

🌟 Effect의 역할

 

Effect는 렌더링이 끝난 후 실행되는 부수 효과를 정의합니다.

-> ex. ChatRoom 컴포넌트가 화면에 나타날 때 서버 연결 설정

 

Effect는 렌더링 직후 실행되어 React 컴포넌트를 외부 시스템과 동기화해요.

React는 화면 업데이트 후 Effect를 실행하므로 사용자에게 업데이트된 UI를 보여준 뒤 부수 효과를 처리합니다.

 

Effect가 필요 없을지도 모릅니다

Effect는 React 바깥의 외부 시스템과 동기화할 때 사용한다고 했는데요! 그런데 만약 Effect 안에서 하는 일이 React 내부 상태를 조정하는 것뿐이라면? 이건 React의 기본 기능인 state와 props만으로도 충분히 처리할 수 있어요. 굳이 Effect를 사용할 필요가 없어요.

 

🛑 무작정 Effect를 추가하면 안 되는 이유?

Effect는 잘못 사용하면 코드가 복잡해지고 예기치 않은 문제가 생길 수 있습니다.

 

1️⃣ 불필요한 재실행 - Effect가 필요 이상으로 자주 실행돼 성능이 나빠질 수 있음

2️⃣ 디버깅 어려움 - 상태 변경과 Effect가 얽히면 버그를 찾기 힘듦

3️⃣ 더 간단한 방법 존재 - 상태와 props만으로 해결할 수 있는 걸 Effect로 처리하면 코드가 불필요하게 길어짐

 

Effect는 어떻게 작성하나요?

🌟 1단계 Effect 선언하기 (쉽게 이해하기)

React에서 Effect를 선언하려면 useEffect라는 훅을 사용하는데요! 이 훅은 컴포넌트가 렌더링된 뒤에 특정 작업을 수행하도록 도와주는 도구예요. 보통 이 작업은 주로 DOM 조작이나 외부 시스템과 동기화하는 경우에 필요합니다.

 

🛠️ Effect를 작성하는 기본 방법

 

useEffect를 import - 먼저 React에서 useEffect를 가져와야 합니다.

import { useEffect } from 'react';

 

컴포넌트 안에서 호출 - 컴포넌트의 최상위 레벨에서 useEffect를 호출하고 그 안에 실행하고 싶은 코드를 작성합니다.

 
function MyComponent() {
  useEffect(() => {
    // 이곳의 코드는 *모든* 렌더링 후에 실행됩니다
  });
  return <div />;
}

 

💡 Effect는 언제 실행되나요?

React 컴포넌트는 아래처럼 동작하는데!

  1. 렌더링 과정 -> React가 JSX를 계산해 DOM을 생성하고,
  2. Effect 실행 -> DOM이 업데이트된 뒤 useEffect 내부 코드가 실행돼요.

= useEffect는 화면에 변화가 반영된 다음에 실행되기 때문에 안전하게 DOM을 조작하거나 외부 시스템과 동기화할 수 있어요.


그럼 이제 리액트로 비디오 플레이어를 만든다고 생각해볼까요?

 

비디오를 제어하려면 DOM에서 제공하는 play()와 pause() 메서드를 호출해야 하는데, 이 작업은 Effect 없이 렌더링 중에 처리하면 문제가 생길 수 있어요.

 

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null); // 비디오 DOM에 접근하기 위한 ref

  if (isPlaying) {
    ref.current.play(); // DOM 노드가 아직 없을 수 있음
  } else {
    ref.current.pause(); // 렌더링 중 DOM 조작은 허용되지 않음
  }

  return <video ref={ref} src={src} />;
}

 

🚨 왜 문제가 생길까?

1️⃣ DOM 생성이 끝나기 전 -> React가 아직 DOM을 완전히 생성하지 않았을 때 play()나 pause()를 호출하려고 하면 ref.current가 null이어서 오류가 발생해요.

2️⃣ React의 규칙 위반 -> React는 렌더링 과정이 순수한 계산이어야 한다고 가정합니다. 즉, DOM 조작 같은 부수 효과가 렌더링 중에 있으면 안 되는 거죠!

 

✅ Effect를 사용한 올바른 해결 방법

이때 Effect를 사용하면 렌더링이 완료된 뒤에 안전하게 DOM을 조작할 수 있어요.

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null); // 비디오 DOM 접근

  useEffect(() => {
    if (isPlaying) {
      ref.current.play(); // 재생
    } else {
      ref.current.pause(); // 일시 정지
    }
  }, [isPlaying]); // isPlaying이 바뀔 때만 실행

  return <video ref={ref} src={src} loop playsInline />;
}

 

코드 작동 방식

 

- 컴포넌트 렌더링 - 리액트가 비디오 태그를 DOM에 추가합니다.

- Effct 실행 - isPlaying 값에 따라 play() 는 또는 pause()를 호출해 비디오 상태를 제어해요. 이 작업은 렌더링 후에 실행되기 때문에 안전하게 DOM에 접근할 수 있어요.

 

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  }, [isPlaying]); // isPlaying 값이 바뀔 때만 실행

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);

  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? '일시 정지' : '재생'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

 

주의! Effect와 무한 루프
React에서 useEffect는 컴포넌트가 렌더링된 후 실행된다고 했는데요. 이 특성 때문에 무한 루프에 빠질 위험이 있어요. 
const [count, setCount] = useState(0);

useEffect(() => {
  setCount(count + 1); // 상태를 즉시 업데이트
});

 

 

1️⃣ useEffect가 실행돼서 setCount로 상태를 업데이트

2️⃣ 상태가 변경되면 React는 다시 렌더링을 시작

3️⃣ 렌더링이 끝난 뒤 useEffect가 또 실행돼서 다시 setCount를 호출

4️⃣ 이 과정이 끝없이 반복

 

결국 React는 계속 렌더링과 상태 업데이트를 반복하면서 멈추지 않는 무한 루프에 빠지게 됩니다.

 

▶️ Effect는 외부 시스템 동기화할 때 사용하기

 

Effect는 컴포넌트 내부 상태 변경보다는 외부 시스템과의 동기화에 적합해요. 예를 들면, 서버에서 데이터를 가져오거나 브라우저 API와 상호작용을 한다거나 등등의 상황에서 적합합니다. 상태 관리만을 위해 Effect를 사용하면 이렇게 무한 루프 같은 문제가 생길 수 있어요!

 

✅ 무한 루프를 막는 방법

1️⃣ 의존성 배열 사용하기

Effect의 두 번째 인자인 의존성 배열을 활용하면 무한 루프를 방지할 수 있어요. 의존성 배열은 useEffect가 언제 실행될지를 제어하는데, 배열에 들어 있는 값이 변경될 때만 Effect가 실행됩니다.

useEffect(() => {
  console.log("count가 업데이트되었습니다!");
}, [count]); // count가 변경될 때만 실행

 

2️⃣ 상태 변경과 Effect를 분리하기

Effect는 상태를 관리하기보다는 외부 작업에만 사용해야 해요.
아래와 같은 방식으로 상태를 직접 계산하도록 설계하면 Effect가 필요 없답니다!

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

function incrementCount() {
  setCount(prev => prev + 1); // 상태 업데이트를 직접 관리
}

 

🚨 이 코드를 피하기!

useEffect(() => {
  setCount(count + 1); // 무조건 렌더링 후 실행되므로 무한 루프 발생
});

 

🌟 2단계 Effect의 의존성 지정하기

React의 useEffect는 기본적으로 모든 렌더링 후 실행돼요. 하지만! 이 동작이 항상 필요한 건 아닙니다.

의존성을 지정해서 Effect가 정말 필요한 경우에만 실행되도록 만들 수 있어요.

 

🔄 Effect가 모든 렌더링 후 실행되는 문제점

 

1️⃣ 느릴 수 있음

- 매 렌더링마다 외부 시스템과 동기화하면 불필요한 리소스를 낭비할 수 있어요.

- ex. 사용자가 키보드로 입력할 때마다 채팅 서버에 연결하려고 한다면 너무 비효율적이겠죠?

 

2️⃣ 원하지 않는 동작

- 컴포넌트가 처음 나타날 때 실행돼야 할 애니메이션이 매번 실행된다면 어색한 동작이 될 수 있어요.

- ex. 입력할 때마다 화면이 계속 깜빡거린다면 이상하겠죠?!

 

🚨 잘못된 코드

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('video.play() 호출');
      ref.current.play();
    } else {
      console.log('video.pause() 호출');
      ref.current.pause();
    }
  }); // 의존성 배열이 없음

  return <video ref={ref} src={src} loop playsInline />;
}

 

- useEffect는 모든 렌더링 후 실행되므로 입력창에서 텍스트를 입력할 때도 불필요하게 실행돼요.

- 비디오 상태가 변경되지 않았는데도 계속 실행되기 때문에 낭비겠죠?

 

의존성 배열로 문제 해결

useEffect의 두 번째 인자로 의존성 배열을 추가해볼까요?
이 배열은 React에게 Effect를 다시 실행해야 할 조건을 알려줍니다.

useEffect(() => {
  if (isPlaying) {
    console.log('video.play() 호출');
    ref.current.play();
  } else {
    console.log('video.pause() 호출');
    ref.current.pause();
  }
}, [isPlaying]); // isPlaying이 변경될 때만 실행

 

의존성 배열은 어떤 일을 할까?
 

1️⃣ React는 의존성을 추적

- isPlaying 값이 변경되었을 때만 Effect를 다시 실행해요.

- ex. isPlaying이 true에서 false로 바뀌거나 그 반대일 때만 실행.

 

2️⃣ 불필요한 실행 방지

- 입력창에 텍스트를 입력할 때는 isPlaying이 바뀌지 않으므로 Effect가 실행되지 않아요.

 

3️⃣ React의 의존성 검사

- React는 내부적으로 의존성 배열에 있는 값을 추적하고 이전 값과 새 값을 비교해 동일하면 실행을 건너뛰어요.

- 비교는 Object.is 방식을 사용해 정확히 처리돼요.

 

주의! 의존성 꼭 지정하기

1️⃣ 의존성을 명시하지 않으면 린트 에러 발생

useEffect(() => {
  if (isPlaying) {
    ref.current.play();
  }
}, []); // 'isPlaying'이 빠져서 에러 발생

 

에러 메시지

React Hook useEffect has a missing dependency: 'isPlaying'. Either include it or remove the dependency array.

🚨 왜 에러가 날까?

Effect 안에서 isPlaying을 사용하고 있는데, 의존성 배열에 isPlaying을 추가하지 않으면 React가 예상하지 못한 동작을 방지하기 위해 경고를 띄우는 것!

 

2️⃣ 의존성 배열은 React의 신뢰를 얻는 열쇠와 같다

- 모든 Effect 내부에서 사용하는 값은 의존성 배열에 포함돼야 해요.

- 이 규칙을 따르면 코드에서 발생할 수 있는 많은 버그를 예방할 수 있어요.

 

 

🔧 의존성 배열로 효과적으로 관리하기

1️⃣ Effect가 다시 실행되길 원하지 않는 경우

- 의존성 배열을 비워두면 최초 렌더링에서만 실행돼요.

- 하지만 내부에서 사용하는 값이 없도록 코드를 작성해야 해요.

useEffect(() => {
  console.log('컴포넌트가 처음 나타났습니다!');
}, []); // 한 번만 실행

 

2️⃣ 여러 의존성 추적하기

- 의존성 배열에는 여러 개의 값을 포함할 수 있어요. (ex. 여러 상태나 props를 기반으로 Effect를 실행할 때)

useEffect(() => {
  console.log('isPlaying 또는 volume이 변경되었습니다!');
}, [isPlaying, volume]); // 둘 중 하나가 변경될 때 실행

🌟 3단계 필요하다면 클린업을 추가하기

React에서 클린업은 컴포넌트가 화면에서 사라지거나 Effect가 다시 실행되기 전에 정리 작업을 수행할 수 있게 도와주는 중요한 기능이에요. 

 

🚨 잘못된 코드

ChatRoom 컴포넌트를 만들면서 채팅 서버와 연결하는 작업을 추가한다고 가정해볼까요?

서버 연결을 위해 createConnection() API를 사용해 연결을 설정하고 연결 해제는 고려하지 않은 코드가 있다면 아래와 같을 거예요.

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

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
  }, []); // 빈 배열: 컴포넌트가 처음 나타날 때만 실행
  return <h1>채팅에 오신걸 환영합니다!</h1>;
}

 

- ChatRoom이 마운트(화면에 나타남)될 때 연결이 설정돼요.

- 하지만 사용자가 다른 화면으로 이동하면 ChatRoom은 마운트 해제(화면에서 사라짐)가 돼야겠죠?

- 그런데 연결을 끊지 않으면? 이전 연결이 계속 유지돼서 서버에 불필요한 연결이 쌓이게 돼요.

 

React는 개발 모드에서 모든 컴포넌트를 처음 렌더링 후 한 번 더 마운트했다가 해제해요.
이 이유는 클린업이 제대로 동작하는지 확인하기 위해서랍니다!

 
✅ 연결 중...
✅ 연결 중...

 

메시지가 두 번 출력된다면, 클린업이 제대로 설정되지 않았다는 신호예요.

 

✅ 클린업 함수 추가하기

클린업 함수는 Effect 내부에서 반환하는 함수예요. React는 Effect가 다시 실행되기 전에 이전 Effect의 클린업을 실행하고 컴포넌트가 사라질 때 클린업을 호출합니다.

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

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();

    // 클린업 함수: 연결 해제
    return () => {
      connection.disconnect();
    };
  }, []); // 의존성 배열이 비어 있으므로 한 번만 실행

  return <h1>채팅에 오신걸 환영합니다!</h1>;
}

 

React가 클린업을 실행하는 경우

 

- 컴포넌트가 화면에서 사라질 때

ChatRoom이 마운트 해제되면 connection.disconnect()가 실행돼서 서버 연결이 해제돼요.

 

-Effect가 다시 실행되기 전

Effect가 다시 실행되기 전에 이전 연결을 정리해요.

 

결과적으로 어떻게 보일까?

🕹️ 개발 모드에서의 출력

✅ 연결 중...
❌ 연결 해제됨
✅ 연결 중...​
 
(1) 처음 연결 ✅ 연결 중...
 
(2) 마운트 해제 ❌ 연결 해제됨
 
(3) 다시 연결 ✅ 연결 중...
 

React의 개발 모드에서는 이러한 과정이 의도적으로 한 번 더 실행돼요.
React는 컴포넌트를 다시 마운트함으로써 클린업이 올바르게 작동하는지 확인합니다.

 

🚀 배포 모드에서의 동작

 

실제 배포 환경에서는 개발 모드와 달리 컴포넌트가 한 번만 마운트돼요. 즉, 아래처럼 동작합니다.

✅ 연결 중...

 

개발 모드에서 추가로 발생하는 동작은 React의 버그 검출 기능이에요. 이 기능은 코드가 견고하게 작성되었는지 확인해주는 역할을 하니 꺼버리기보다는 켜두는 게 좋아요!

 

StrictMode와 개발 모드

 

StrictMode는 React 개발 모드에서 컴포넌트를 두 번 마운트하는 역할을 해요.

이렇게 하면 Effect가 클린업 없이 실행되는 상황을 찾아낼 수 있답니다.

 

💡 StrictMode 끄기
아래 코드에서 StrictMode를 제거하면 이러한 동작은 발생하지 않아요.

import ReactDOM from 'react-dom';
import App from './App';

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

 

 

하지만 StrictMode를 유지하는 게 좋아요! StrictMode는 앱에서 놓치기 쉬운 버그를 찾는 데 큰 도움이 되기 떄문에,,,

 


궁금한 점
Effect가 렌더링 이후에만 실행된된다고 했는데 렌더링 중에 부수 효과가 필요할 때는 어떻게 해야 할지?
React는 이를 어떻게 처리할 수 있을지가 궁금해지네용