배경
AI 어시스턴스 앱을 개발하면서 채팅 UI를 구현하게 되었습니다. 사용자와 어시스턴트 간의 대화 메시지를 보여주는 Message
컴포넌트를 만들었습니다. 메시지 목록은 부모 컴포넌트인 Chat
컴포넌트가 배열 상태(messages
)로 관리하며, 이 배열을 순회하며 각 Message
컴포넌트를 렌더링합니다.
//Chat.jsx
import { useState } from "React";
import { Mesasge } from "./Message.jsx"
export fucntion Chat(){
const [messages, setMessages] = useState([]);
...
return (
<div>
...
{messages.map((message) => (
<Message
key={message.id}
message={message}
/>
))}
...
</div>
)
}
//Message.jsx
import { AnimatePresence, motion } from "motion/react";
export function Message({ message }) {
...
return (
<AnimatePresence>
<motion.div
...
>
<div
key={message.id}
className={clsx(
"flex flex-col gap-2 rounded-xl px-4 py-2 whitespace-pre-wrap",
{
"bg-primary text-primary-foreground": message.role === "user",
"text-primary dark:bg-primary dark:text-primary-foreground bg-gray-100":
message.role === "assistant",
},
"max-w-full",
)}
>
{message.parts.map((part, i) => {
switch (part.type) {
case "text":
return (
<Markdown key={`${message.id}-${i}`}>{part.text}</Markdown>
);
case "source":
return <p key={i}>{part.source.url}</p>;
case "reasoning":
return <div key={i}>{part.reasoning}</div>;
case "tool-invocation":
...
default:
return null;
}
})}
</div>
<div className="group flex w-full justify-start">
<Button>
...
</Button>
</div>
</motion.div>
</AnimatePresence>
);
}
Chat
컴포넌트에서 messages
상태가 변경될 때마다, Chat
컴포넌트 자체가 리렌더링되고, 그 결과 messages.map()
내부의 모든 Message
컴포넌트들도 리렌더링됩니다.
문제는 Message
컴포넌트가 상당히 무겁다는 점이었습니다. 내부에 framer-motion
을 사용한 애니메이션 로직, clsx
를 이용한 복잡한 조건부 스타일링, 그리고 메시지 part
타입에 따른 다양한 조건부 렌더링(Markdown 렌더링, 소스 표시, 도구 호출 시각화 등)을 포함하고 있었습니다.
여기에 더해, AI 어시스턴트의 응답을 스트리밍(Streaming) 방식으로 처리하면서 문제가 더 심화되었습니다.
**스트리밍이란?**
여기서 스트리밍이란, 서버로부터 데이터를 한 번에 모두 받는 것이 아니라, 생성되는 대로 조금씩 나누어 지속적으로 받아오는 기술을 의미합니다. 예를 들어, 긴 텍스트 응답을 생성하는 AI 모델의 경우, 전체 응답이 완료될 때까지 기다리지 않고 생성되는 단어(토큰)들을 실시간으로 받아 화면에 표시하는 방식입니다.
채팅 UI에서는 스트리밍 응답을 처리하기 위해 마지막 메시지 상태를 매우 짧은 간격으로 계속 업데이트해야 합니다. 이는 Chat
컴포넌트의 messages
상태가 매우 자주 변경됨을 의미하며, 그 결과 모든 Message
컴포넌트들이 빈번하게 리렌더링되는 상황을 초래했습니다.
이렇게 무거운 컴포넌트가 너무 자주 리렌더링되자, 특히 메시지가 많아지거나 스트리밍 중일 때 UI가 약간씩 버벅이는 현상이 발생했습니다. 따라서, 메모이제이션을 통해 불필요한 리렌더링을 막아 성능 최적화할 필요를 느꼈습니다.
메모이제이션이란?
메모이제이션은 컴퓨터 과학의 일반적인 최적화 기법 중 하나입니다. 이전에 수행한 연산의 결과를 저장해두고, 동일한 입력값이 다시 들어왔을 때 저장된 결과를 반환하여 반복적인 계산을 피하는 방식입니다. 동일한 입력에 대해 항상 동일한 출력을 내는 순수함수의 경우에 효과적으로 사용할 수 있습니다.
리액트에서는 메모이제이션을 위해 다양한 useMemo, useCallback 등과 같은 기능을 제공하는데, 이때 컴포넌트 메모이제이션을 위해서는 memo()를 제공합니다.
참고자료 1 : NAVER D2 : 성능 하면 빠질 수 없는 메모이제이션, 네가 궁금해
React.memo(Component, arePropsEqual?)
React.memo
로 컴포넌트를 감싸면, 해당 컴포넌트의 메모이즈된(Memoized) 버전을 얻을 수 있습니다. 이 메모이즈된 컴포넌트는 다음과 같이 동작합니다:
- 부모 컴포넌트가 리렌더링되어 해당 컴포넌트도 리렌더링되어야 할 상황이 됩니다.
- React는 이 컴포넌트가
React.memo
로 감싸져 있다는 것을 인지합니다. - 새로 전달받을
props
와 이전에 렌더링될 때 사용했던props
를 비교합니다. - 비교 결과, 모든
props
가 동일하다면, React는 컴포넌트 함수를 다시 실행하지 않고 이전에 렌더링했던 결과를 재사용합니다. 즉, 리렌더링을 건너뜁니다. props
중 하나라도 변경되었다면, 컴포넌트 함수를 정상적으로 다시 실행하여 리렌더링합니다.
React.memo
의 인자:
Component
(필수): 메모이제이션할 대상 컴포넌트입니다.arePropsEqual?
(선택):props
를 비교하는 로직을 직접 정의하고 싶을 때 사용하는 함수입니다. 이 함수는(prevProps, nextProps)
두 개의 인자를 받으며,nextProps
가prevProps
와 동일하다고 판단되면true
를, 그렇지 않으면false
를 반환해야 합니다.- 인자를 생략할 경우:
React.memo
는 내부적으로 각prop
에 대해 얕은 비교(Shallow Comparison)를 수행합니다. (예:Object.is(prevProps.someProp, nextProps.someProp)
) - 얕은 비교의 한계: 원시 타입(string, number, boolean 등)은 값 자체를 비교하므로 문제가 없지만, 객체나 배열 같은 참조 타입은 참조(메모리 주소)만을 비교합니다. 따라서 객체나 배열의 내용이 동일하더라도 참조가 다르면 (즉, 새로운 객체나 배열이 생성되어 전달되면) 다르다고 판단하여 리렌더링이 발생합니다.
- 인자를 생략할 경우:
주의: React.memo
는 성능 최적화를 위한 힌트일 뿐, 리렌더링을 반드시 막는 것을 보장하지는 않습니다. React는 내부적인 이유로 메모이제이션된 컴포넌트를 리렌더링할 수도 있습니다. 하지만 대부분의 경우 props
가 같다면 리렌더링을 건너뜁니다.
적용하기
Message
컴포넌트에 React.memo
를 적용해 봅시다. 스트리밍 중 마지막 메시지가 업데이트될 때, 다른 이전 메시지들은 props
가 변경되지 않았으므로 리렌더링되지 않도록 하는 것이 목표입니다.
import { memo } from "react";
import equal from "fast-deep-equal";
...
function PureMessage({message}) {
...
return (
...
);
}
export const Message = memo(PureMessage, (prevProps, nextProps) => {
if (prevProps.isLoading !== nextProps.isLoading) return false;
if (prevProps.message.id !== nextProps.message.id) return false;
if (!equal(prevProps.message.parts, nextProps.message.parts)) return false;
return true;
});
- 기존의
Message
컴포넌트를PureMessage
로 이름을 변경합니다. (필수는 아니지만 가독성을 높일 수 있습니다.) - React에서
memo
를 불러,PureMessage
를 첫 번째 인자로 전달합니다.memo
의 반환값이 메모된Message
컴포넌트 입니다. memo()
의 두번재 인자로 커스텀 비교함수를 넣어줍니다. 조건문을 통해 원하는props
의 내용이 변경된 경우에만 리렌더링 되게하여 불필요한 리렌더링을 막습니다.message.parts
는 객체이므로!==
연산자를 통해 비교하게 되면 얕은 비교를 수행하게 됩니다. (참조만을 비교하므로 따라서 항상 다르게 나옵니다.) 실제 값을 비교하려면 깊은 비교를 수행해야합니다. 위 코드에서는fast-deep-equal
라이브러리를 사용해서 깊은 비교를 수행했습니다.
후기
React.memo
를 적용하여 무거운 Message
컴포넌트의 불필요한 리렌더링을 성공적으로 제어했습니다. 덕분에 스트리밍 중에도 눈에 띄게 부드러워진 UI를 사용자에게 제공할 수 있게 되었습니다.
평소 '성능 문제가 확인되지 않은 최적화는 지양한다'는 원칙을 가지고 있었는데, 이번 사례가 바로 필요한 시점에 최적화를 적용하여 효과를 본 좋은 경험이라 만족스럽습니다. 문제를 해결하는 과정에서 실제 성능 개선을 체감하니, 더 깊이 파고드는 동기를 얻을 수 있었어서 만족스럽니다.
이번 경험을 통해 오픈소스 코드를 적극적으로 탐색하는 것이 얼마나 중요한지 다시 한번 느꼈습니다. 잘 설계된 오픈소스를 통해 컴포넌트 네이밍 규칙부터 시작해 배울 점이 정말 많다는 것을 실감했습니다.