정말 너무 뒷북이지만 큐시즘 31기 기업 프로젝트 회고를 작성하고자 합니다ㅎㅎ 바야흐로 2개월 전 큐시즘 31기에서 한국방송광고진흥공사와 협업하여 기업 프로젝트를 진행했습니다. 큐시즘 30기에서도 프론트엔드 파트로 활동했던지라 벌써 두 번째 기업 프로젝트입니다! 기업에서 실제로 고민하고 있는 문제와 기업의 업무 방식을 간접적으로 체험할 수 있어 큰 도움이 되는 활동이라고 생각합니다. 30기에서는 렛츠커리어라는 회사와 기업 프로젝트를 진행했는데, 이 경우에는 렛츠커리어 PM님 그리고 대표님과 주기적으로 소통하고 멘토링을 받으며 진행해서 큰 도움이 되었습니다. 살짝 아쉬운 점은 결과를 확인할 수 있는 기간이 큐시즘 활동이 끝난 후여서 회사 관계자 분과 소통을 계속 해야 한다는 것입니다. 그래서 전 멘토링 해주셨던 PM님께 계속 연락드리고 유저 데이터를 받고 싶다고 말씀드려 그 데이터를 받을 수 있었습니다. 그리고 회사 측에서 코드를 공개하지 않길 원해서 좀 더 열심히 개발하여 그 과정에서 수치적인 인사이트를 얻어야 한다는 점도 살짝 아쉬웠습니다. 그래도 회사의 입장에서 유저를 생각하고, 문제점을 함께 고민할 수 있는 경험은 정말 값진 경험이었습니다. 그래서 코바코(한국방송광고진흥공사)와 함께하는 기업 프로젝트도 큰 기대를 가지고 시작을 했습니다. 그리고 결론적으로 이야기하면 31기에서의 기업 프로젝트도 값진 경험을 얻을 수 있었습니다. 서론이 길었네요. 어떤 부분을 고민하여 작업을 했고, 어떤걸 구현했고 그 과정에서 무엇을 얻었는지 작성해 보겠습니다!


 

어떤 문제를 해결해야 했는가?

우리는 코바코의 '아이작'이라는 AI 광고 스토리보드 제작 관련 일부 기능을 개선하여 사용자 유입을 높이자라는 목표를 가지고 기업 프로젝트를 진행했습니다.

 

 

AiSAC - 아이작

AiSAC - 아이작,Home, 한국방송광고진흥공사(kobaco)가 운영하는 AI 기반 광고창작 지원 서비스입니다.

aisac.kobaco.co.kr

아이작은 어려워 보이는 광고 제작 과정을 AI에 기반하여 지원해주는 서비스입니다. 사용자들은 아이작의 광고 아카이브 키워드를 통해 광고를 탐색할 수 있고, 소비자 트렌드를 파악할수도 있습니다. 그리고 제일 유용한 기능은 광고 카피 제작 기능입니다. 누구나 손쉽게 만들 수 있는 AI 기반의 광고 카피 제작 서비스를 무료로 제공해 주어서 빠르고 편리하게 광고 카피를 제작할 수 있습니다. 또한 이를 기반으로 AI를 통해 이미지를 만들어 스토리 보드 제작까지 도와주는 서비스인데요.

 

우리는 해당 서비스의 제작 관련 일부 기능을 개선하고 리팩토링하여 사용자 유입을 높이는 것이 목표였습니다.

 

우리는 어떻게 접근했는가

우선 해당 서비스를 이용하는 사용자들이 원하는 부분이 다른데 이에 비해 방대한 기능을 한번에 모두 제공하기 때문에 사용자가 원하는 작업을 하는 과정까지 가기에 어려움이 있다고 생각했습니다. 이 부분을 충족시켜 줄 수 있는 방식을 제안하고자 온보딩 과정에서의 사용자 유형 세분화, AI이미지 조합 방식에 대한 추가적인 기능을 고안하여 기획팀은 기획과 기능명세서 및 와이어프레임을 만들고, 디자인 팀은 그에 맞춘 디자인을 개발 팀은 이를 기반으로 개발을 시작했습니다. 너무 당연한 이야기죠?ㅋㅋㅋㅋ 어쨌든 이 과정에서 AI가 만들어주는 이미지를 받을 캔버스를 구현해야 했고, 이를 구현하기 위해 Fabric.js를 선정하여 개발을 들어갔습니다. 이때까지만 해도 캔버스 구축이 어렵지 않을것이라 생각했죠...

 

쉽지 않은 Fabric.js...

정말 쉽지 않았습니다. 자잘하게 이야기 하기는 양이 너무 많아 크게 겪었던 문제와 어떻게 해결했는지를 작성하겠습니다. 사실 fabric.js는 커스텀을 자유자재로 할 수 있는 장점이 있습니다. 하지만 고려해야 하거나 공부해야 하는 API가 너무 많고, 그에 대한 공식문서의 설명이 구체적이지 않아서 계속해서 여러번 시도해서 그 결과를 확인하며 캔버스를 구현해야 했습니다. 그 과정에서 캔버스 오염이라는 정말 큰 문제를 겪었습니다.

 

캔버스 오염(Tainted Canvas) 문제란?

캔버스를 구현할 때, 외부(다른 도메인)에서 가져온 이미지를 캔버스에 그리면, 브라우저 보안 정책(CORS) 때문에 캔버스가 "오염(tainted)"될 수 있습니다. 오염된 캔버스에서는 toDataURL(), toBlob(), getImageData() 등 픽셀 데이터를 추출하는 메소드 사용 시 아래와 같은 에러가 발생합니다.

Uncaught DOMException: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.46

 

이 현상은 외부 이미지가 적절한 CORS 설정 없이 로드될 때 발생하며, fabric.js를 쓸 때도 동일하게 적용됩니다.

 

이러한 캔버스 오염이 발생하는 이유는 다른 출처(Origin)에서 이미지를 불러올 경우와 서버가 Access-Control-Allow-Origin 헤더를 제공하지 않거나, 클라이언트에서 이미지에 crossOrigin 속성을 지정하지 않은 경우 발생합니다.

 

우리는 AI가 만들어준 이미지가 외부 출처이기에 계속해서 이미지가 들어가지 않는 문제가 생겼고, 이를 

const imgObj = new Image();
imgObj.crossOrigin = 'anonymous';
imgObj.src = imageUrl + '?v=' + new Date().getTime();

이렇게 이미지 객체를 만들어 crossOrigin에 anonymos를 넣고, 캔버스 내에서의 중복을 방지하기 위해 쿼리스트리을 추가했습니다. 이렇게 하여 처음 캔버스 오염 문제를 해결했습니다.

 

하지만..!

 

이후에도 캔버스 오염 문제를 맞닥뜨렸습니다. 그 이유는 AI가 만든 이미지 뒤에 쿼리 스트링이 있기 때문에 쿼리스트링이 있는 상태에서 ?v ~를 붙이며, 캔버스에서 해당 이미지를 받지 않았었습니다. 확장성을 고려하지 못한 제 문제였죠...ㅠㅠ 하지만 시간이 없었기에 프론트 측에서 코드를 바꾸는 것이 아니라 AI에서 생성되는 이미지에 쿼리스트링이 추가되지 않게 설정하여 문제를 해결했습니다.

 

이렇게 AI 이미지를 받아서 편집하고 그 이미지로 또 다른 AI 이미지를 생성할 수 있는 캔버스를 구현할 수 있었습니다.

 

회고

하고 싶은 이야기들이 훨씬 더 많은데, 우선 1차적으로 빠르게 적느라 빠진 이야기들이 많네요..ㅠㅠ 이번 프로젝트에서 아토믹 디자인 패턴과 스토리북을 도입했지만 제대로 사용하지 못한 점도 아쉽고, 개발 과정에서 스케줄링을 잘못해서 개발 일정이 매우 촉박하기도 했었습니다. 그리고 개발 프로젝트를 시작하기 이전에 사용하고자 하는 라이브러리 및 기술에 대해서 깊은 이해를 가지고 시작해야 훨씬 수월하게 원하는 기능을 만들 수 있다는 것을 몸으로 깨달았습니다..ㅎㅎ 어쨌든 이를 기반으로 기업에서 직접 발표를 했고, 유저 유입을 높이기 위해 타겟을 세분화 했다는 것을 코바코 측에서 긍정적으로 받아들였고, 기능에 대해 칭찬도 받아서 정말 뿌듯했습니다. 이후 조금 더 잘 정리해서 업데이트 하겠습니다..ㅠㅠ 기업의 입장에서 문제를 바라보고 함께 해결할 수 있는 메리트가 정말 큰 거 같습니다! 여러분도 기회가 된다면 큐시즘을 해보시는걸 추천드립니다!! 읽어주셔서 감사합니다 :)

 

 

 

 

Custom Hooks

커스텀 훅을 알기 전에 기본적으로 Hook의 사용 규칙에 대해 아는게 좋습니다.

  • 훅은 무조건 use로 시작해야 합니다.
  • 훅은 컴포넌트의 최상단에 위치해야 합니다.
  • 최상위 레벨에서만 훅을 호출해야 합니다.
    • 조건문이나 반복문 내부에서 Hook을 호출하면 안됩니다.
    • 조건부 return문(early return) 이후에 Hook을 호출하면 안됩니다.
    • 이벤트 핸들러에서 Hook을 호출하면 안됩니다.
    • useMemo, useReducer, useEffect에 전달된 함수 내부에서 Hook을 호출하면 안됩니다.
    • try/catch/finally 블록 내부에서 Hook을 호출하면 안됩니다.
  • 오직 React 컴포넌트 또는 커스텀 훅에서만 호출해야 합니다.

훅의 규칙 핵심 원리

React는 훅이 항상 동일한 순서로 호출된다고 가정하고, 각 훅의 상태를 내부적으로 인덱스로 관리합니다. 이런 상황에서 조건문이나 반복문 내부에서 호출한다면 그 순서가 바뀌게 되고, 상태와 이펙트가 꼬이며 에러가 발생할 수 있습니다. 그래서 순서가 보장되도록 조건문이나 반복문 내부에서 훅을 호출하면 에러가 발생하지 않지만, 의미가 없는 코드이기에 고려하지 않아도 됩니다. 

이런 이유로 훅은 컴포넌트의 최상단에 위치해야 하고 최상위 레벨에서 호출해야 하는 것입니다.

 

그리고 커스텀 훅은 state 자체를 공유하는 것이 아니라 state 저장 로직을 공유하도록 해야 합니다.

이는 커스텀 훅 구현 코드를 보면 이해하기 쉬울 거 같습니다.

 

커스텀 훅은 로직 재사용성을 극대화하는 React의 핵심 기능입니다. React 19에서도 해당 기능이 강화되어 깔끔하고 확장 가능한 코드 구조를 구축할 수 있습니다.

이러한 커스텀 훅은 상태를 공유하지 않고 로직만 재사용해야 하며, React 훅의 규칙을 따릅니다.

 

예시 코드를 작성하며 설명드리겠습니다.

<예시 코드 1>

import { useState } from 'react';

function ueFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);
  const handleChange = (e) => {
    setValue(e.target.value);
  }
  
  return {value, onChange: handleChange};
}

export default function From () {
  const nameProps = useFormInput('Join');
  const emailProps = useFormInput('JoinMain@naver.com');
  
  return (
    <form>
      <input {...nameProps} />
      <input {...emailProps} />
    </form>
  );
}

해당 코드는 input에 들어갈 상태와 이벤트 핸들러를 훅으로 묶어서 바로 input 태그에 props로 내려줄 수 있도록 객체를 반환하는 훅입니다.

import { useState } from 'react';

function ueFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);
  const handleChange = (e) => {
    setValue(e.target.value);
  }
  
  return [value, handleChange];
}

export default function From () {
  const [name, handleNameChange] = useFormInput('Join');
  
  return (
    <form>
      <input value={name} onChange={handleNameChange} />
    </form>
  );
}

만약에 값을 return 값을 배열로 보낸다면 그 순서를 기억하고 원하는 변수를 정의해 사용할 수도 있습니다.

 

 

<예시 코드 2>

import { useState } from 'react';

export default fucntion Counter () {
  const [count, setCount] = useState(0);
  
  const handlePlusClick = () => setCount(count => count + 1)
  const handleMinusClick = () => setCount(count => count - 1)
  
  return (
    <div>
      <p>Count : {count}</p>
      <button onClick={handlePlusClick}>+ 버튼</button>
      <button onClick={handleMinusClick}>- 버튼</button>
    </div>
  )
}

그럼 지금까지 애용하던 Counter 컴포넌트에는 어떻게 적용할 수 있을까요?

useState와 이벤트 핸들러 함수를 묶어서 useCounter 훅을 구현해 봅시다.

 

import {useState} from 'react';

function useCounter (initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  increment = () => setCount(prev => prev + 1);
  decrement = () => setCount(prev => prev - 1);

  return {count, increment, decrement};
}

export default function Counter () {
  const { count, increment } = useConter(0);
  
  return (
    <div>
      <p>Count : {count}</p>
      <button onClick={increment}>+ 버튼</button>
    </div>
  );
}

useCounter 훅을 구현해 적용한 코드입니다.

하지만 모든 중복 로직을 커스텀 훅으로 만드는 것은 좋은 방법이 아닙니다. 중복 로직을 커스텀 훅으로 만들었을 때, 데이터 흐름을 명확하게 이해하는 데 도움이 되고, 목적을 전달하는 데 도움이 된다고 판단될 경우 커스텀 훅으로 구현하는 것이 좋습니다.

 

커스텀 Hook으로 로직 재사용하기 – React

The library for web and native user interfaces

ko.react.dev

 

 

실무에서는 어떻게 활용하는게 제일 좋을까요?? 

  • 기능을 직관적으로 표현하여 의도 명확성을 높이자 (ex: useLocalStorage, useDebounde)
  • 단일 책임 원칙을 지키자! 한 훅은 하나의 기능만 수행하도록 설계해야합니다.
  • TypeScript의 제네릭 타입을 활용해 타입을 지원하여 타입 안정성을 확보하자
  • 불필요한 리렌더링 방지를 위해 메모이제이션을 적극 활용하자

핵심적으로 이렇게 4가지를 잘 생각해서 커스텀 훅을 고려해야 합니다. 그 사례들에 대해 알아볼까요?

 

1. 로컬 스토리지를 연동한 useLocalStorage

function useLocalStorage (key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });
  
  useEffect(() => {
    localStorage.setItem(key, JSON.stringfy(value));
  }, [key, value]);
  
  return [value, setValue];
}

 

2. 이전 상태 추적 usePrevious

function usePrevious (value) {
  const ref = useRef();
  
  useEffect(() => {
    ref.current = value;
  });
  
  return ref.current;
}

 

3, 디바운싱 적용 useDebounce

function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value);
  
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    
    return () => clearTimeout(timer);
  }, [value, delay]);
  
  return debouned;
}

 

이렇게 여러 작업 로직들을 커스텀 훅으로 묶어서 편리하게 사용할 수 있습니다.

 

추가적으로 필수적인 TypeScript를 이용해 구현하는 방법도 작성하고자 합니다. 주로 TypeScript 기반으로 개발을 해서 이번 React 포스팅 과정에서 중간중간에 TypeScript를 사용한 코드도 있습니다..ㅋㅋ 다 작성한 이후에 통일할려고 합니다. js 기반인 React 공식문서를 보며 정리해서 그런지 섞이게 되네요ㅠㅠ 어쨌든 타입 안정성을 보장하는 TyprScript를 이용한 방법도 커스텀 훅을 만드는 과정에서 꼭 설명해야겠다는 생각이 들어 작성합니다.

 

1. TypeScript useLocalStorage

import { useState, useEffect } from 'react';

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });
  
  useEffect(() => {
    localStorage.getItem(key, JSON.stringify(value));
  }, [key, value]);
  
  return [value, setValue] as const;
}

// 사용 예시
export default Example () {
  const [name, setName] = useLocalStorage<string>('name', '홍길동');
  return (
    <input value={name} onChange={e => setName(e.target.value)} />
  );
}

제네릭 타입을 이용해 값에 대한 타입을 정의할 수 있도록 했습니다. 그리고 return 값에 as const를 붙여 튜플 타입으로 반환하여 구조분해 할당 측면에서 안전해집니다.

 

2. TypeScript useDebounce

import {useState, useEffect} from 'react';

function useDebounce<T>(value: T, delay: number) {
  const [debounced, setDebounced] = useState<T>(value);
  
  useEffect(() => {
    const handler = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(handler);
  }, [value, delay]);
  
  return debounced;
}

export function Example () {
  const [text, setText] = useState('');
  const debouncedText = useDebounce(text, 500);
  
  return (
    <div>
      <input value={text} onChange={e => setText(e.tartget.value)} />
      <p>디바운스된 값: {debouncedText}</p>
    </div>
  );
}

 

3. TypeScript useToggle

import { useState, useCallback } from 'react';

type UseToggleReturn = [
  boolean,
  () => void,
  () => void,
  () => void
];


function useToggle(initialValue = false): UseToggleReturn {
  const [state, setState] = useState<boolean>(initialValue);
  
  const toggle = useCallback(() => setState(s => !s), []);
  const setTrue = useCallback(() => setState(true), []);
  const setFalse = useCallback(() => setState(false), []);
  
  return [state, toggle, setTrue, setFalse];
}

export default function Example() {
  const [on, toggle, turnOn, turnOff] = useToggle();

  return (
    <div>
      <p>{on ? 'ON' : 'OFF'}</p>
      <button onClick={toggle}>Toggle</button>
      <button onClick={turnOn}>ON</button>
      <button onClick={turnOff}>OFF</button>
    </div>
  );
}

컴포넌트가 리렌더링 될때마다 함수를 새로 생성하는 것을 방지하고자 useCallback을 사용했습니다.

 

이렇게 타입스크립트를 적용한 예시 코드까지 확인해 봤습니다! 커스텀 훅을 구현할 때 도움이 되면 좋겠네요 :)

다음 포스팅부터 React 19에 새로 생긴 훅이나 기능들을 본격적으로 다루지 않을까 싶습니다. 읽어주셔서 감사합니다!

useRef

useRef를 간단하게 이야기 하자면 화면을 바꾸지 않는 데이터라고 할 수 있습니다. useRef는 값이 변경되어도 화면이 리렌더링되지 않습니다. React에서는 document를 이용해 DOM 요소에 접근하는 방식을 비추천하기 때문에 document를 이용하지 않고 DOM 요소에 쉽게 접근을 하기 위해 useRef를 사용합니다.

 

document 객체 사용을 지양하는 이유는 document를 이용하면 DOM을 직접 조작하여 화면을 변경할 수 있는데, 이렇게 되면 화면 변경에 대해서 React를 통해 화면을 바꾸는 것과 document로 DOM을 직접 조작하여 바꾸는 것 이렇게 두 가지로 나뉘어 관리하기가 매우 어려워집니다.

 

하지만 document를 이용하지 않는다면, 원하는 요소를 focus하고 스크롤을 옮기는(scrollIntoView) 등의 편리한 작업을 하기 어렵워집니다. 그래서 React에서 useRef를 제공하는 것입니다. document.getElementById나 document.querySelector 등을 이용해 요소를 가져오는 것을 원하는 요소의 ref 속성에 useRef로 정의한 값을 넣어주고 .current로 그 요소를 바로 가져올 수 있도록 해 줍니다.

 

그런데 useRef를 사용하면 왜 하면이 리렌더링 되지 않을까요? useRef는

const idRef = useRef(null)

이런식으로 사용하는데, 이때 null은 내부 속성인 current에 null을 넣어주는 것입니다. 즉 객체인 것이죠 useRef의 값이 바뀌어도 객체 내부 메소드인 current가 변경되는 것이기 때문에 참조가 바뀌지 않고, 얕은 복사를 이용하는 React 특성상 감지하지 못하는 것입니다.

 

그리고 useRef는 이렇게 요소를 가져오기 위한 방법 뿐만 아니라 컴포넌트가 언마운트되기 전까지 값을 보존하기 때문에 직접 값을 넣어줘서 로깅용으로 사용하거나 UI와 무관한 값을 저장 및 라이브러리를 사용할 때 사용하기도 합니다.

 

사용 예시들을 보면 쉽게 이해하실 수 있습니다.

 

1. DOM 요소 포커싱

import { useRef } from 'react';

function LoginForm () {
	const emailRef = useRef(null);
    
    const handleSubmit = () => {
    	if (!emailRef.current.value.includes('@')) {
        	emailRef.current.focus(); // 유효성 검사 실패 시 포커싱
            return;
        }
        // ...
    }
    
    return (
    	<form>
          <input ref={emailRef} type='email' />
          <button>로그인</button>
        </form>
    )
}

 

2. 애니메이션 / 미디어 제어

function VideoPlayer () {
	const videoRef = useRef(null);
    
    const play = () => videoRef.current.play();
    const pause = () => videoRef.current.pause();
    
    return (
    <div>
      <video ref={videoRef} src="video.mp4" />
      <button onClick={play}>재생</button>
      <button onClick={pause}>일시정지</button>
    </div>
    )
}

 

3. 이전 state 값 추적

fucntion Counter () {
  const [count, setCount] = useState(0);
  const prevCountRef = useRef(0);
  
  useEffect(() => {
    prevCountRef.current = count; // 이전 값 저장
  }, [count]);
  
  return (
    <div>
      현재 값: {count}, 이전 값: {prevCountRef.current}
    </div>
  );
}

 

4. 라이브러리 연동

DOM을 직접 조작하는 외부 라이브러리 (D3.js, Chart.js, Fabric.js 등)와 통합할 때 필수적으로 사용합니다.

function Chart() {
  const chartRef = useRef(null);
  
  useEffect(() => {
    //D3.js로 차트 생성
    const svg = d3.select(chartRef.current);
    svg.append("circle").attr("r", 50);
  },[]);
  
  return <svg ref={chartRef} />
}

 

 

그리고 React 라이프사이클 동안 값을 유지하는 useRef를 이용해 클로저 문제를 해결하기도 합니다.

 

클로저 문제

  • 함수형 컴포넌트에서 비동기 콜백(setTimeout, setInterval 등)을 사용할 때,
  • 콜백이 만들어진 시점의 state, props, 변수 값이 캡처되어 나중에 실행해도 그 값만 참조하게 됩니다.
  • 즉, 최신 값이 아닌 옛날 값을 참조하며, 원하는 작업을 수행할 수 없는 문제가 발생하는 것입니다.
const [count, setCount] = useState(0);

useEffect(() => {
  const timer = setTimeout(() => {
    alert(count)
  }, 1000);
  
  return () => clearTimeout(timer); // 메모리 누수 방지를 위해 언마운트시 clear
},[]);

위와 같이 코드를 작성하면 클로저 문제가 발생합니다.

※ 사실 count를 의존성 배열에 추가하면 클로저 문제가 발생하지 않지만, 사용 방식을 쉽게 보여주고자 해당 코드에서 useRef를 활용해 클로저 문제를 해결하는 방식을 보여드리겠습니다. 이후 실질적으로 useRef를 사용해야 해결할 수 있는 클로저 문제 발생 코드 사례도 작성할 예정입니다.

 

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

// count의 값이 변경될 때마다 countRef에 최신값 반영
useEffect(() => {
  countrRef.current = count;
}, [count]);

useEffect(() => {
  const timer = setTimeout(() => {
    alert(countRef.current); // countRef를 이용해 최신값 불러오기
  }, 1000);
  
   return () => clearTimeout(timer);
},[]);

클로저 문제가 발생했을 경우 위와 같이 countRef를 만들고 최신 값을 계속 갱신하여 setTimer에서 countRef의 current를 불러오면 됩니다. 하지만 해당 코드의 경우 setTimeout이 있는 useEffect의 의존성 배열에 count를 추가하면 해결되는 간단한 문제여서 실질적으로 useRef가 필요한 클로저 문제 발생 코드와 어떻게 해결하면 되는지에 대한 사례도 바로 작성하겠습니다.

 

 

클로저 문제 발생 코드

import { useState, useEffect } from 'react';

function App () {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const handleResize = () => {
      console.log('창 크기 변경된 횟수 :', count); // 클로저 문제 발생 (최초 count만 불러와짐)
    }
    
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

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

해당 코드는 이벤트 핸들러가 최초 count만 기억하고 불러오기 때문에 count가 변화되어도 창 크기 변경 시 항상 0만 출력이 됩니다. 의존성 배열에 count를 넣으면 해결되겠지만, count가 변경될 때마다 이벤트 등록이 계속 발생하며 성능 저하 및 메모리 누수 위험이 있습니다 그렇기에 이런 경우 useRef를 이용합니다.

 

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

export default function App () {
  const [count, setCount] = useState(0);
  const countRef = useRef(count); // 이벤트 핸들러에서 count 값을 불러오기 위해 countRef 정의
  
  useEffect(() => {
    countRef.current = count;
  }, [count]); // count 변경 시마다 업데이트
  
  useEffect(() => {
    const handleResize = () => {
      console.log('창 크기 변경 횟수:', countRef.current); // countRef를 이용해 count 값 호출
    };
    
    window.addEventListener('resize', handleResize);
    
    return () => window.removwEventListener('resize', handleResize);
  }, []);
  
  return (
    <div>
      <p> Count: {count} </p>
      <button onClick={() => setCount(count + 1)}> + </button>
    </div>
  );
}

countRef.current 값은 컴포넌트 생명주기 동안 유지되기에 countRef를  count와 동기화하여 count가 바뀔 때마다 countRef.current 값을 갱신해 줍니다. 그리고 해당 countRef.current를 이벤트 핸들러 내에서 사용하여 클로저 문제를 해결할 수 있습니다.

 

WebSocket 연결 과정에서도 해당 방식을 이용할 수 있습니다.

export deafult function Chat () {
  const [message, setMessage] = useState('');
  const messageRef = useRef('');
  
  useEffect(() => {
    messageRef.current = message;
  }, [message]);
  
  useEffect(() -> {
    const socket = new WebSocket(''wss://...');
    socket.onmessage = (event) => {
      console.log('받은 메시지:', messageRef.current);
    };
    
    return () => socket.close();
  }, []);
  
  ...
}

 

 

ref.current 사용 시 주의점 (feat. side effect)

 

그리고 ref.current는 렌더링 중에 읽으면 안됩니다! React는 컴포넌트가 순수 함수처럼 동작하길 원합니다. 순수 함수란 동일한 입력에 대해 동일한 결과를 반환하는 함수를 말합니다. 즉 컴포넌트에 동일한 입력(props, state, context)가 주어지면 동일한 결과(JSX)를 반환하기를 원하는 것이죠. 하지만 렌더링 중에 ref.current를 읽거나 쓰게 되면 컴포넌트가 동일한 입력에도 매번 다른 결과를 내거나 외부 상태를 바꿀 가능성이 있습니다.

 

그렇기에 ref.current는 이벤트 핸들러 혹은 useEffect 내부에서 사용해야 합니다.

useEffect나 이벤트 핸들러는 렌더링과 별개로 실제 DOM이 만들어진 뒤 실행하기 때문에 side effect를 안전하게 처리할 수 있기 때문입니다.

 

side effect에 대해서도 자세하게 설명하면 좋을거 같습니다.

side effect는 함수가 본래의 목적 이외에 외부 세계에 영향을 주거나, 외부 상태에 의존하거나, 변경하는 모든 행위를 의미합니다. 즉, 함수가 동일한 입력을 받아도 외부 요인에 따라 결과가 달라지거나 함수 실행이 외부에 영향을 미친다면 해당 함수는 side effect가 존재한다고 이야기합니다.

이러한 side effect는 함수형 프로그래밍에서 기본적으로 피하거나 최소화해야 합니다. React에서는 side effect를 컴포넌트의 렌더링 로직과 명확하게 분리해야 하며, useEffect 내부에서 side effect를 다루는 것으로 실현합니다.

 

 

React 19, ref 콜백 클린업 함수

React 19에서는 ref 콜백 함수에서 클린업 함수를 반환할 수 있는 기능이 추가됐습니다.

이를 이용해 DOM의 노드가 마운트될 때 필요한 리소스(이벤트 리스너, 옵저버 등)를 설정하고, 언마운트되거나 노드가 변경될 때 자동으로 정리(cleanup)하는 작업을 더욱 간편하게 처리할 수 있게 되었습니다.

 

기존 ref 콜백은 어떻게 동작했는가?

- 마운트 하는 경우: ref 콜백이 DOM 노드를 인자로 받아 호출합니다.

- 언마운트 하는 경우: ref 콜백이 null을 인자로 받아 한 번 더 호출되기 때문에 이 시점에 클린업 작업을 직접 구현해야 했습니다.

const refCallback = (node) => {
  if (node) {
    // 노드 마운트
  } else {
    // 노드 언마운트 -> 클린업 작업
  }
};

return <div ref={refCallback} />

 

이제 React 19에서는 ref 콜백 클린업 함수를 반환할 수 있기 때문에 null일 때 작업을 하는 것이라니라 return을 이용해 작업을 할 수 있게 됐습니다. 코드로 확인해보면

const refCallback = (node) => {
  if (node) {
    // 노드가 마운트 됨: 리소스 설정
    const handler = () => { //원하는 작업 }
    node.addEventListener('click', handler);
    
    // 클린업 함수 반환
    return () => {
      node.removeEventLisener('click', handler);
      console.log('cleanup', node);
    };
  }
};

return <div ref={refCallback} />

이렇게 else 뒤에 작업을 하는것이 아니라 return으로 클린업 함수를 반환할 수 있습니다.

이를 통해 코드를 명확하고 직관적으로 볼 수 있고 리소스 정리(이벤트 해체, 옵저버 해제)를 자동화할 수 있어 메모리 누수 방지에 효과적입니다.

 

결과적으로 컴포넌트 전체의 생명주기에 대한 작업은 useEffect를 이용하고, 특정 DOM 노드의 생명주기에 대한 작업은 ref 콜백 함수를 이용할 수 있습니다.

 

그런데 위의 예시는 click 이벤트를 다루는 경우입니다. 사실 이런 경우는 그냥 onClick을 사용하면 됩니다. 그렇다면 어떤 상황에서 ref 콜백 함수를 사용할까요?

  • React에서 지원하지 않는 이벤트에 대한 작업이 필요한 경우
  • ResizeObserver, IntersectionObserver, D3.js 등 DOM 노드에 직접 접근해 등록/해제해야 하는 리소스 관리가 필요한 경우
  • 동적으로 여러 개의 ref가 필요한 경우
  • DOM 노드에 대해서 마운트/언마운트될 때의 추가 작업이 필요한 경우
  • 조건부 렌더링 혹은 동적 노드 교체가 자주 일어날 경우

보통 위와 같은 경우에 콜백 ref를 사용하는 것이 일반적입니다.

저는 콜백 ref를 사용한 경험이 없어서 사용 사례들을 찾아보며 이해를 했는데, 그 부분도 정리해 보겠습니다.

 

A. transition 이벤트 관리

const refCallback = (node) => {
  if (node) {
    const handler = () => console.log('transition start');
    node.addEventListener('transitionstart', handler);
    
    return () => {
      node.removeEventListener('trasitionstart', handler);
      console.log('cleanup', node);
    };
  }
};

return <div ref={refCallback}>...</div>

React 19부터 onTransitionStart 및 지원하지 않던 이벤트를 지원해주기 시작했습니다. 하지만 대부분 React 18을 사용중일거라 예상되는데, 위처럼 React에서 지원하지 않는 이벤트에 대해서 처리하고 싶을 때 ret 콜백 함수를 사용하면 됩니다.

 

B. ResizeObserver 등의 리소스 관리

const refCallback = (node) => {
  if (node) {
    const observer = new ResizeObserver(() => { //원하는 작업 });
    observer.observe(node);
    
    return () => observer.disconnect();
  }
}

 

 

C. 동적으로 여러 개의 ref가 필요한 경우

리스트의 각 아이템에 ref를 달아야 하는 경우 ref 객체를 배열이안 Map으로 직접 관리해야 합니다.

이 때 콜백 ref를 쓰면 각 DOM 노드를 원하는 방식으로 저장 및 관리할 수 있습니다.

const itemsRef = useRef(null);
if (itemsRef.current === null) {
  itemsRef.current = new Map();
}
// 위의 코드는 const itemsRef = useRef(new Map());으로 사용할 수 있지만,
// 공식 문서에 따르면 위의 방식은 최초 렌더링 시에만 new Map()을 사용하지만,
// 이후의 렌더링마다 호출 자체는 이루어지기 때문에 성능 저하 가능성이 있습니다.
// 그래서 위의 패턴을 사용합니다. ref.current를 렌더링 중에 쓰거나 읽는 것은 허용되지 않지만,
// 이 경우는 결과가 항상 동일하고 초기화 중에만 조건이 실행되어 충분히 예측이 가능하기에 허용됩니다.

const setItemRef = (id, node) => {
  const map = itemsRef.current;
  if (node) map.set(id, node);
  else map.delete(id);
}

return items.map(item => (
  <div key={item.id} ref={node => setItemRef(item.id, node)}>{item.name}<div>
))

 

 

D. DOM 노드가 마운트/언마운트 될 때 추가 작업이 필요한 경우

이벤트 리스너 등록/해제, 외부 라이브러리 초기화/정리가 DOM 노드의 마운트/언마운트 시점에 필요한 경우 콜백 ref를 사용하여 각 시점에 맞춰 원하는 작업을 할 수 있습니다. React 19 이전에는 마운트 시에는 node가 언마운트 시에는 null이 전달되는 것을 이용해 if문으로 처리했고, React 19부터는 클린업 함수를 함께 이용하면 됩니다.

보통 이벤트 리스너나 외부 라이브러리에 대한 작업은 useEffect를 이용하지만, 동적 DOM 노드 혹은 조건부 렌더링과 같은 특정 상황에서는 ref 콜백 함수를 이용하는 것이 더 적합합니다.

import {useState, useCallback} from 'react';

export default function DynamicComponent() {
  const [isShown, setIsShown] = useState(false);
  
  const refCallback = useCallback((node: HDMLDivElement | null) => {
    if (node) {
      // 노드 마운트
      const handleClick = () => console.log("Clicked !!");
      node.addEventListener("click", handleCLick);
      
      return () => {
        // 노드 언마운트, React 19의 클린업 함수 이용
        node.remveEventListener("click", handleClick);
      }
    }
  }, []);
  
  return (
    <div>
      <button onClick={() => setIsShown(!isShown)}>Toggle</button>
      {isShown && <div ref={refCallback}>동적 노드</div>}
    </div>
  );
}

위의 경우도 click 이벤트는 onClick이 있으니 onClick을 사용하는 것이 더 React스럽고, React에서 사용하지 않는 이벤트나 외부 라이브러리 및 옵저버 등의 등록/해제하는 패턴을 구현해야 할 때 ref 콜백 함수를 사용하는 것이 적합합니다. 

 

E. DOM 노드의 생명주기를 세밀하게 제어애햐 할 경우

const refCallback = (node) => {
  if (node) {
    node.startAnimation();
    return () => node.stopAnimation(); // 애니메이션 정리
  }
};

 

생각보다 정리글이 길어졌네요. 다음 포스팅에 이어서 작성하겠습니다!

useState로 객체를 정의해 사용해보기

import { useState } from 'react';

interface ErrorTypes {
	nameError: string;
    passwordError: string
}

function App () {
    const [userName, setUserName] = useState('');
    const [userPassword, setUserPassword] = useState('');
    const [errors, setErrors] = useState<ErrorTypes>({
    	nameError: '',
        passwordError: '',
    });
    
    const onChangeUserName = (e) => {
    	setUserName(e.target.value);
    }
    
    const onChangePassword = (e) => {
    	setUserPassword(e.target.value);
    }
    
    const handleLoginClick = () => {
    	if(!userName?.trim()) {
        	setErrors({...prev, nameError: '사용자 이름을 입력해 주세요'});
            return;
        }
        if(!userPassword.trim()) {
        	setErrors({...prev, passwordError: '비밀번호를 입력해주세요'});
        }
    }
    
    return (
    	<div>
        	
            <div>
            	<label htmlFor='id'>아이디</label>
                <input value={userName} onChange={onChangeUserName} id='id' />
                {errors.nameError && <p>{errors.nameError}</p>}
            </div>
            <div>
            	<label htmlFor='pw'>비밀번호</label>
            	<input value={userPassword} onChange={onChangeUserPassword} id='pw'/> 
                {errors.passwordError && <p>{errors.passwordError}</p>}
            <div>
            <button onClick={hanleLoginClick}>로그인</button>
        </div>
    )
}

위 코드와 같이 useState로 객체를 다룰 때, setState로 특정 값을 바꾸고 싶다면, ...prev를 입력해 기존 내용을 유지하고, 뒤에 원하는 키에 대한 값을 변경해 주는 방식을 이용하면 됩니다.

 

setState의 비동기적 처리와 일괄 처리(Batching)

비동기 처리

setState는 즉시 상태를 변경하지 않고, React 렌더링 사이클에 맞춰 비동기적으로 처리됩니다. 즉 여러 개의 setState의 호출이 있을 때, React에서는 이를 하나로 묶어서 일괄 처리(Batching)하여 불필요한 렌더링을 줄이고 성능을 최적화합니다.

setState(count + 1);
setState(count + 2);
setState(count + 1);

한 함수나 이벤트 핸들러 내에서 이렇게 setState가 호출된다면 +4가 더해지는 것이 아니라 이벤트가 끝날 때까지 기다렸다가 마지막으로 입력된 값을 적용합니다. 그렇다고 해서 마지막 setState만 실행되는 것은 아닙니다. 각 실행을 큐에 넣어 함수 혹은 이벤트핸들러가 끝날때까지 기다렸다가 순차적으로 실행한다고 이해하면 좋을 것 같습니다.

 

즉, 위와같이 호출되면 count에 4가 더해지는 것이 아니라 최종으로 실행된 setState(count + 1)이 적용되어 count에 1이 더해집니다.

 

 

함수형 업데이트

그렇다면 여러번 연속으로 상태를 변경해야 하거나 이전 상태에 의존하여 새로운 값을 계산 혹은 도출해야 한다면 어떻게 할까요? 함수형 업데이트를 사용하면 됩니다.

setState(prev => prev + 1);

 

처음에 객체에 값을 넣을 때에도 사용한 방식인데요. 위의 방법 뿐만 아니라

setState((prev) => {
  const newPrev = [...prev];
  newPrev.append("내가 원하는 값");
  return newPrev
})

이런식으로 내부에서 원하는 작업을 수행한 후 그 값을 넣는 것도 가능합니다.

 

그렇기에 아래와 같이 한 함수 혹은 이벤트 핸들러 내에서 함수 업데이트를 이용해 여러번 호출하면 그 값이 누적되어 count에 3이 더해집니다.

setState((prev) => prev + 1);
setState((prev) => prev + 1);
setState((prev) => prev + 1);

 

 

 

혼합하여 여러번 호출된다면?

그렇다면 함수형 업데이트와 그냥 값을 넣는 방식이 혼합되어 사용된다면 어떻게 될까요?

처음에 이야기 한 것처럼 혼합하여 사용하는 경우에도 상태 큐 메커니즘에 따라 동작합니다. count의 초기값이 0이라 가정하고 다음 과정들을 따라가보면 이해할 수 있습니다.

setState(count + 1); // 0 + 1
setState((prev) => prev + 1); // 1 + 1 => count -> 2
setState(count + 1); // 0 + 1을 덮어씀 => count -> 1
setState((prev) => prev + 1); // 큐의 마지막 값 1 + 1

count는 2가 됩니다.

setState(count + 1); // 0 + 1
setState((prev) => prev + 1); // 1 + 1
setState((prev) => prev + 1); // 2 + 1
setState(count + 1); // 0(현재 count값을 가져옴) + 1

count는 1이됩니다. 마지막 setCount 호출을 뺀다면 3이 됩니다.

setState(count + 1); // 0 + 1
setState(count + 1); // 0 + 1 덮어씀
setState((prev) => prev + 1); // 1 + 1
setState((prev) => prev + 1); // 2 + 1

count는 3이 됩니다.

 

결국 마지막 값이 prev를 이용한다면 지금까지 큐에 쌓인 값을 업데이트 해주고, 그게 아니라면 초기값에 더한 값을 업데이트 해줍니다! 사실 이렇게 한 함수나 이벤트 핸들러에서 setState를 여러번 호출하는 경우는 거의 없어서 크게 신경쓰지 않아도 될 것 같습니다. 전 호기심에 이것저것 테스트 하면서 알아봤습니다 ㅋㅋ

 

그리고 저렇게 setState를 여러번 호출하는 과정에서 중간에 count를 찍어도 일괄처리 되어 반영되는 것이기 때문에 반영되기 전 count 값이 찍힙니다.

+ Recent posts