AI가 놓치는 상태 설계, 개발자가 직접 쥐어야 할 것

AI가 놓치는 상태 설계, 개발자가 직접 쥐어야 할 것

stale closure 버그와 LLM 에이전트 아키텍처가 동시에 가리키는 것—AI가 코드를 생성해주는 시대일수록 상태의 생명주기를 설계하는 판단은 개발자의 손에 남아야 한다.

stale closure useRef 상태 설계 WebSocket 멀티에이전트 LLM 아키텍처 React 상태 관리 AI 워크플로우
광고

실시간 채팅 앱을 만드는 일은 생각보다 복잡하다. WebSocket 연결은 유지하면서, React의 렌더링 사이클과 자연스럽게 공존시켜야 하기 때문이다. dev.to에 공개된 'The Stale Closure Bug That Haunted My Chat App'은 그 긴장감을 정면으로 보여주는 사례다. 개발자가 직접 Relay라는 채팅 앱을 구축하면서 마주친 버그는 코드의 실수가 아니라, React 상태 모델에 대한 이해의 빈틈에서 비롯됐다.

문제의 구조는 이렇다. useWebSocket 커스텀 훅 안에서 WebSocket 연결을 useEffect로 초기화하고, onMessage 콜백을 외부에서 주입받는 방식이었다. 얼핏 깔끔한 관심사 분리처럼 보였다. 하지만 useEffect의 의존성 배열에 user만 넣어두었기 때문에, 대화 상대를 Alice에서 Bob으로 바꿔도 소켓 연결은 재설정되지 않았다. 그 결과, socket.onmessage 핸들러는 여전히 초기 렌더 시점의 onMessage—Alice가 selectedUser였던 그 순간의 함수—를 붙잡고 있었다. Bob이 메시지를 보내도 Alice의 채팅창에 꽂혔고, Alice의 메시지가 Bob의 창에 조용히 섞여 들어왔다. 전형적인 stale closure 버그다.

의존성 배열에 onMessage를 추가하면 간단히 해결될 것 같지만, 그러면 렌더링마다 소켓이 끊겼다 다시 연결되는 최악의 UX가 발생한다. 진짜 해결책은 useRef였다. ref는 렌더를 트리거하지 않으면서도 렌더 간에 최신 값을 유지할 수 있는 '변이 가능한 상자'다. onMessageRef.current = onMessage를 매 렌더마다 동기화해두고, 소켓 핸들러에서는 onMessageRef.current()를 호출하도록 바꾸면—소켓은 끊기지 않고, 콜백은 항상 최신 상태를 참조한다. 이 패턴은 setInterval, window 이벤트 리스너 등 장수명(long-lived) 이벤트 리스너 전반에 적용되는 핵심 원칙이다.

이 버그가 흥미로운 이유는 따로 있다. AI 코딩 도구에 이 훅 코드를 입력하면 대부분 "의존성 배열이 올바르게 설정되었습니다"라고 넘어간다. 구문적으로 틀린 곳이 없기 때문이다. stale closure 버그는 코드 한 줄의 문제가 아니라, 상태가 어떤 생명주기로 존재하는가에 대한 설계 판단의 문제다. 컴포넌트가 마운트될 때 캡처된 값이 얼마나 오래 살아있는지, 어떤 이벤트 핸들러가 그 값을 붙잡고 있는지—이 흐름을 머릿속에 그릴 수 있어야 버그가 보인다.

그 관점에서 보면, dev.to에 공개된 'Tiny Civilization' 멀티에이전트 시뮬레이션 프로젝트는 전혀 다른 맥락에서 같은 교훈을 던진다. 이 프로젝트에서 가장 먼저 부딪힌 설계 딜레마는 "LLM을 매 틱마다 호출할 것인가, 아니면 순수 유틸리티 AI로만 갈 것인가"였다. 전자는 표현력이 풍부하지만 비용이 폭발하고, 후자는 빠르지만 에이전트가 '살아있다'는 느낌을 주지 못한다. 해결책은 두 레이어를 분리하는 것이었다—LLM은 약 15 시뮬레이션 일마다 전략적 의도를 설정하고, 유틸리티 엔진은 매 틱마다 구체적 행동을 실행한다.

이 구조는 React의 useRef 패턴과 놀랍도록 닮아있다. LLM의 전략 선언은 onMessageRef.current = onMessage처럼 "최신 의도를 기록해두는" 역할이고, 유틸리티 엔진은 onMessageRef.current()를 호출하듯 "매 순간 최신 의도를 참조해 실행"한다. 비싼 연산(LLM 호출 / 소켓 재연결)은 꼭 필요한 순간에만, 저비용 참조(유틸리티 계산 / ref 조회)는 매 사이클마다—이것이 두 시스템이 공유하는 상태 설계 원칙이다.

두 사례가 함께 드러내는 시사점은 명확하다. 상태의 생명주기를 설계하는 일은 AI에게 위임할 수 없다. AI 도구는 코드의 패턴을 학습하지만, '이 값이 언제 캡처되고 언제 무효화되는가'를 시스템 전체 흐름 속에서 판단하는 능력은 아직 개발자의 영역이다. stale closure를 잡아내는 것도, LLM 호출 빈도를 비용-품질 트레이드오프 속에서 결정하는 것도, 결국 "이 상태가 얼마나 오래 살아야 하고, 누가 그것을 참조하는가"를 명확히 정의하는 설계 판단에서 시작한다.

실용적으로 접근하자면, 세 가지 질문을 코드 리뷰와 설계 단계에 습관처럼 끼워 넣는 것으로 시작할 수 있다. 첫째, 이 이벤트 핸들러는 어떤 값을 캡처하고, 그 값은 얼마나 자주 바뀌는가? 장수명 리스너일수록 캡처 시점과 최신 상태 사이의 괴리를 의심해야 한다. 둘째, 비싼 연산과 저비용 참조를 레이어로 분리했는가? 소켓 재연결과 ref 조회, LLM 호출과 유틸리티 계산—각각의 비용과 빈도를 의식적으로 분리해야 한다. 셋째, AI가 생성한 코드에서 상태 생명주기를 직접 추적해봤는가? AI가 만든 훅이라도 의존성 배열과 클로저 캡처 흐름은 반드시 개발자가 눈으로 따라가야 한다.

AI 도구가 프로토타입을 빠르게 생성해주는 시대일수록, 개발자의 진짜 레버리지는 '코드를 얼마나 빨리 쓰는가'에서 '상태가 어떻게 흐르는지를 얼마나 정확히 읽는가'로 이동한다. 채팅창에서 메시지가 엉뚱한 곳에 꽂히는 버그도, 에이전트 사회가 예상치 못한 방식으로 무너지는 현상도—그 근원에는 항상 설계되지 않은 상태의 생명주기가 있었다. 그 설계는, 아직은 개발자의 손에 있어야 한다.

출처

더 많은 AI 트렌드를 Seedora 앱에서 확인하세요