화면을 만들다 보면 같은 컴포넌트를 서로 다른 상황에서 재사용해야 하는 경우가 있다. 예를 들어 어떤 결과 카드는 실시간 응답으로 새로 만들어질 수도 있고, 사용자가 이전 화면에 다시 들어왔을 때 과거 기록으로 다시 보여질 수도 있다.
UI 자체는 같기 때문에 같은 컴포넌트를 사용하는 것이 자연스럽지만, 새로운 응답에서만 필요한 동작까지 조건없이 함께 실행하고 있다면 문제가 생길 수 있다.
과거 기록을 불러올 때도 같은 컴포넌트가 마운트되면서, 원하지 않는 Side Effect가 다시 실행될 수 있기 때문이다.
이런 경우에는 단순히 컴포넌트가 마운트됐는지를 보는 것이 아니라, 해당 컴포넌트가 어떤 상황에서 만들어졌는지를 구분할 필요가 있다.
이 글에서는 채팅 화면에서 과거 대화를 다시 열 때 작업 패널까지 함께 열리던 문제를, 컴포넌트가 처음 만들어질 때의 상태를 보존하는 방식으로 해결한 과정을 정리해보려고 한다.
문제: 재사용된 컴포넌트에서 Side Effect가 다시 실행된다..!
아래와 같은 상황에서 사용자가 특정 작업을 요청하면 AI가 답변을 생성하고, 동시에 오른쪽 작업 패널을 열어 필요한 값을 자동으로 채워줘야 했다.

채팅 안에서 일반 텍스트가 아닌 결과 화면은 UI Block이라는 형태로 관리하고 있었는데,
이는 서버 응답을 바로 컴포넌트와 연결하는 대신, 어떤 UI를 보여줄지 나타내는 데이터로 한 번 변환하는 방식이다.
dispatch.block.addUIBlock({ // block 추가 id: messageId, kind: "action-card", props: [{ name: "data", value: data }], });
이 UI Block은 두 상황에서 만들어졌는데, 실시간 응답에서 서버의 작업 완료 이벤트를 받아 UI Block을 채팅에 추가했고, 사용자가 이전 채팅방에 다시 들어왔을 때는 저장된 메시지를 같은 형태의 UI Block으로 보여주었다.
결과적으로 실시간 응답과 과거 대화 모두 같은 ActionCard 컴포넌트를 사용하게 된다.
문제는 이 ActionCard에서 우측 패널을 제어해야 하는 기능을 붙이면서 일어났다.
ActionCard를 받으면 우측 패널이 제어가 되어야 했기 때문에 해당 컴포넌트 안에 useEffect를 넣고 패널이 열리는 코드를 넣어주었다.
function ActionCard({ data }) { useEffect(() => { openPanel(); }, []); return <Card />; }
새로 채팅을 날려보니 새 결과가 도착하면 ActionCard가 마운트되고, effect가 실행되면서 작업 패널이 정상적으로 열렸다.
하지만 다른 채팅방을 열어보니 추가로 채팅을 보내지 않았음에도 아님에도 불구하고 갑자기 패널이 열리고 패널의 폼이 채워지는 문제가 발생해버렸다.

ActionCard 입장에서는 이 카드가 실시간 응답으로 만들어졌는지, 과거 기록을 복원하면서 만들어졌는지 알 수 없기 때문이다... 두 경우 모두 새로 마운트된 같은 컴포넌트일 뿐이고 서로 패널을 열고 싶어했던 것이다..!
이 문제를 나는 컴포넌트를 실시간용과 과거 기록용으로 두개를 두는 대신, 하나의 컴포넌트에서 화면을 보여주는 조건과 Side Effect를 실행하는 조건을 따로 관리하여 실시간 응답에서만 처리될 수 있도록 하는 과정으로 처리하고 싶었다.
해결: 생성 시점의 맥락으로 Side Effect 실행 조건 분리하기
- 원하는것 : 실시간 상태로 만들어진 ActionCard라면 우측 패널을 연다.
이 문제를 해결하려면 ActionCard가 실시간 응답으로 만들어진 것인지, 과거 대화를 복원하면서 만들어진 것인지 구분할 수 있어야 했다. 우선 애플리케이션에는 실시간 응답이 진행 중인지 나타내는 isStreaming 값을 저장하고 있어 아래처럼 작성을 해 보았다.
const isStreaming = useIsStreaming(); useEffect(() => { if (!isStreaming) return; openPanel(); }, [isStreaming]);
하지만 isStreaming은 현재 실시간 응답이 진행 중인지만 알려주는 전역 값이었기 때문에
채팅을 시작해서 isStreaming이 true가 되면 이전 대화에 있던 블락에서 또 실행되어 패널이 열려버렸다ㅋㅋㅋ..
내가 필요했던 것은 현재 스트리밍 상태가 아니라 이 ActionCard가 어떤 상황에서 만들어졌는지에 대한 정보였다. 그래서 컴포넌트가 처음 만들어질 때의 isStreaming 값을 컴포넌트별로 별도로 보존하기로 했다.
const [isFromStream] = useState(isStreaming); useEffect(() => { if (!isFromStream) return; openPanel(); }, [isFromStream]);
실시간 응답 중 생성된 ActionCard라면 isFromStream은 true로 유지되고, 과거 대화를 복원하면서 생성된 경우에는 false가 된다.
이제 패널을 열 때 단순히 컴포넌트가 마운트됐는지만 보는 것이 아니라, 실시간 응답으로 생성된 ActionCard인지까지 확인한 뒤 Side Effect를 실행할 수 있게 됐다.
실제 구현에서는 실시간 응답에서 생성됐다는 조건만으로 바로 패널을 열 수 있는 것은 아니었다. 현재 화면에서 해당 패널을 열 수 있는 상태인지 확인하는 조건도 필요했고, 이를 canOpenPanel로 관리하고 있었다.
예를 들어 컴포넌트가 처음 렌더링됐을 때는 패널을 여는 데 필요한 정보가 아직 준비되지 않아 canOpenPanel이 false일 수 있다. 이후 준비가 끝나 true로 바뀌면 effect가 다시 실행된다.
이처럼 의존하는 값이 변경되면 effect는 여러 번 실행될 수 있는데, 만약 별도의 제어가 없다면 패널이 이미 열린 뒤에도 openPanel()이 다시 호출될 수 있다.
하지만 패널을 여는 동작은 하나의 Block에서 한 번만 실행해야 했기 때문에, Side Effect가 이미 실행됐는지를 기억하기 위해 triggeredRef를 추가했다. 패널을 한 번 연 뒤에는 triggeredRef.current를 true로 변경하고, 이후 effect가 다시 실행되더라도 바로 종료하도록 했다.
const triggeredRef = useRef(false); useEffect(() => { if (!isFromStream) return; if (!canOpenPanel) return; if (triggeredRef.current) return; triggeredRef.current = true; openPanel(); }, [isFromStream, canOpenPanel]);
여기서 useState가 아니라 useRef를 사용한 이유는 실행 여부가 화면을 다시 그리는 데 필요한 값은 아니기 때문이다. 패널을 이미 열었는지만 기억하면 되고, 값이 변경될 때 리렌더링까지 발생할 필요는 없었다.
마지막으로 패널을 자동으로 여는 Block이 여러 종류로 늘어나면서 이 실행 조건을 공통 훅으로 분리했다.
각 컴포넌트에서 같은 조건을 반복해서 작성하기보다, 언제 패널을 열어야 하는지에 대한 기준을 한 곳에서 관리하기 위해서였다.
useAutoOpenPanelOnStream();
마치며
지금까지의 문제를 정리해보자면,, ActionCard가 마운트될 때마다 패널을 열도록 구현했기 때문에, 과거 메시지를 다시 불러올 때도 같은 컴포넌트가 마운트되면서 원치 않게 패널이 함께 열리는 문제가 발생했다.
기존 대화와 새로 추가되는 대화는 같은 ActionCard라는 컴포넌트를 사용하고 있었지만, 패널 열기나 값 자동 입력 같은 동작은 새로 들어온 대화에서만 실행되어야 했다. 이를 구분하기 위해 각 ActionCard가 처음 마운트될 때의 스트리밍 상태를 해당 컴포넌트 내부에 별도로 보존하고, 해당 값을 기준으로 Side Effect의 실행 여부를 결정하는 식으로 해결했다.
이 과정에서 깨달은 점은 같은 컴포넌트를 재사용하더라도 각 컴포넌트가 어떤 상황에서 만들어졌는지를 구분하고, 그에 따라 필요한 동작만 실행하도록 나눌 필요가 있다는 것이다..!