logo

AI Sales Copilot 개발 기록 - (3) Delta-Driven UI

작성 2025년 2월 12일

업데이트 2025년 2월 12일

이전 글 AI Sales Copilot 개발 기록 - (2) Streaming Queue

먼지 쌓인 백로그

AI Sales Copilot의 빌딩 블록들이 하나씩 늘어나면서 스트리밍은 Yess 웹앱의 핵심 UX로 자리잡게 되었다. 그러다 보니 '일단 출시부터 하고 나중에 개선하자'는 식으로 의사결정을 하고 넘어갔던 부분들이 기술 부채로 돌아오는 것을 느끼기 시작했다. 어느 순간 스트리밍 요구사항이 있을 때마다 정상 동작을 보장하기 어렵다는 우려의 이야기가 많아지고, 이미 구현했던 부분에서조차 버그가 빈번하게 발생하면서 백로그에 이슈가 쌓여갔다.

가장 심각한 것은 문제가 커지고 있다는 걸 알면서도 섣부르게 해결을 시도하지 못한다는 데 있었다. 깨진 유리창 이론이라는 게 있듯이 문제를 방치하다 보면 그 사이에 다른 문제들이 알게 모르게 겹치면서 더이상 무엇이 근본적인 원인인지, 어디부터 해결해야 할지도 파악하기 어려워지는 법이다. 이런 상황을 타개하고 싶었다. AI Sales Copilot을 메인 피처로 내세우는 제품에서 스트리밍 UX가 엉망진창이라면 우리가 고객 집착미친듯이 훌륭한 퀄리티를 추구하는 팀이고 이 제품이 우리의 대답이라고 어떻게 말할 수 있을까?

이때만큼은 문제를 빠르게 해결하기보다 제대로 해결하는 것을 목표로 삼고자 했다. 그래야 앞으로 더 많은 빌딩 블록을 견고하게 쌓아나갈 수 있기 때문이다. 실제로 문제를 파악하는 데만도 많은 시간이 걸렸는데, 자세히 살펴본 결과 두 가지 큰 문제와 그 문제에 대한 원인이 서로 복잡하게 얽혀 있다는 것을 알 수 있었다.

1. 일관성 없는 동작

첫째로 새로운 문서 유형이 추가될 때마다 타이핑 애니메이션이 오동작하는 현상이 빈번하게 일어났다. AI Meeting Cheat-sheet에서는 동작하던 것이 AI Proposal에서는 동작하지 않거나, 반대로 AI Proposal에서 동작하게끔 구현해 놓으니 AI Meeting Cheat-sheet에서 갑자기 동작하지 않게 되는 현상이 그 예이다.

원인은 처음 타이핑 애니메이션을 검토할 때 우려했던 부분과 이어진다. 타이핑 애니메이션은 현재 스트리밍 중인 위치에 커서처럼 생긴 UI를 표시하는 기능인데, 커서를 표시하기 위해서는 위치 계산 로직을 구현해야 한다. 문제는 위치를 계산하는 방식이 데이터 형식별로 다르게 되어 있다는 점이다. quill·tiptap 데이터, 레거시·최신 테이블 데이터 등 특수한 형식을 비롯해서 일반 객체 형식이나 데이터 내용에 따라 구조가 천차만별로 이루어져 있는 경우도 대응해야 한다. 여기서 한 단계 더 복잡해지면 일반 객체 구조의 내부에 quill·tiptap 등 특수 형식이 포함되는 경우도 존재한다. 이러한 경우의 수는 기능이 추가될 때마다 더 늘어난다.

Server-Driven UI

첫 번째로 생각했던 해결 방식은 Server-Driven UI이다. 프론트엔드에서 응답 받은 데이터로부터 위치를 계산하는 것이 아니라, 역으로 서버에서 데이터를 구성하는 시점에 위치 정보까지 담아서 보내주는 것이다.

Server-Driven UI는 서버가 UI에 관여하는 것이 바람직하지 않다는 일반적인 관점에 국한되지 않고, 필요한 경우에는 서버가 적극적으로 UI 구성 요소를 직접 데이터에 반영하고 내려주는 방법이다. 이 방법을 가장 흔하게 사용하는 분야는 모바일 네이티브 앱이다. 모바일 네이티브 앱은 클라이언트 사이드의 배포 과정이 길고 불편하기 때문에 서버 배포만으로 변경 사항을 반영할 수 있다면 얻는 장점이 크다. 서버 개발자는 익숙하지 않은 UI 관점을 고려해야 하고, 모바일 네이티브 개발자는 고도화된 설계로 디자인 시스템을 구현해야 하기 때문에 협업 및 개발 난이도가 높은 편이지만 많은 팀에서 이 방식을 시도하고 있다.

만약 배포 과정의 복잡도를 상쇄하기 위한 목적이 아니라면 서버에서 UI에 관여하는 것은 적절하지 않은 일일까? 최근의 프론트엔드 기술 트렌드는 SSR 또는 Server Component 등을 활용하여 필요에 따라 적재적소에 서버와 상호작용할 수 있는 환경을 지향하고 있다. 이전의 트렌드로부터 굳어진 프론트엔드와 백엔드의 철저한 구분으로 인해서 새로운 기술이 성숙하게 자리잡는 데는 시간이 걸리는 것 같다는 느낌이 들지만, 개인적으로는 새로운 트렌드에 더 기대를 가지고 있는 편이다. 각자 전문성을 쌓는 분야가 나뉠 수는 있어도 고객 경험과 비즈니스의 성공을 위해서 공동의 목표를 합의하고 가장 경제적인 방법으로 UI를 핸들링하는 것이 이상적이라고 생각한다.

AI 스트리밍 문제를 해결하기 위해 Server-Driven 방식을 떠올리게 된 것은 팀원들과 참가한 해커톤에서 Vercel의 AI SDK 템플릿을 사용해 본 경험이 계기가 되었다. 타이핑 에니메이션을 처리할 때 AI와 통신하는 서버 로직 내에서 스트리밍 상태와 서버 컴포넌트를 바로 연결하여 렌더링하는 방식은 굉장히 직관적이고 간결하게 느껴졌다. Yess 웹앱에 AI SDK RSC와 같은 최신 기술을 바로 적용하기는 어렵지만 서버에서 커서 UI의 위치 정보를 함께 보내주는 것 정도는 시도해볼 만하다고 생각하여 팀에 제안하였다.

Delta-Driven UI

제안한 내용에 대해 백엔드 개발자와 함께 논의해 보았다. 그러나 기대와 달리 커서 UI의 위치 정보를 서버에서 내려주고자 하더라도 데이터 형식별로 복잡한 계산 로직을 구현해야 하는 것은 마찬가지라는 결론에 이르렀다. AI가 Yess 웹앱의 데이터 구조에 대한 지식을 내재화하고 있지 않기 때문이다. 따라서 파트만 바꾸어 중복 작업을 하기보다는 로직의 가독성과 확장성을 높이는 방향으로 개선해 보기로 했다. 그렇다면 어떤 데이터 형식이든 상관 없이 적용할 수 있는 코어 로직을 설계해야 한다. 특정 데이터 구조에 한해서 대응할 수밖에 없는 부분을 제외한 나머지를 모아서 공통점을 도출한다면 무엇이 코어인지 알 수 있을 것이다.

AI 스트리밍에서 코어 로직의 흐름은 Delta 엔진 내부에 존재한다. Delta 엔진은 Yess에서 실시간 동기화 데이터를 다루기 위해 자체적으로 개발했다. 아마도 최초에는 quill의 데이터 구조인 TextDelta에서 차용하지 않았을까 추측되는 이름이다. Delta 엔진은 OT 기반으로, 서버가 특정 타이밍에 들어온 실시간 데이터들로부터 하나의 신뢰할 수 있는 스냅샷을 형성하여 동기화 버전을 쌓아 올리는 방식을 채택하고 있다. 서버가 AI로부터 받은 응답을 후처리하여 보내면 Delta 엔진 내부에서는 스냅샷을 형성하고 클라이언트에 WebSocket으로 전달하는 일련의 과정을 거치는데, 요약하면 서버와 클라이언트 사이에 동기화를 담당하는 중개 서버가 하나 더 있는 형태이다. 스냅샷을 형성하는 시점은 현재 스트리밍 중인 데이터 변경분과 최종 데이터를 모두 알 수 있는 유일한 시점이기 때문에, 커서의 위치를 계산하는 역할을 담당하기에 적절하다. Delta 엔진에서는 이 시점을 핸들링할 수 있는 이벤트를 하나 열어주고, 이벤트 핸들링에 필요한 API를 구현해 두면 서버나 클라이언트는 커서 위치를 계산하는 데 필요한 구체적인 로직은 신경쓰지 않고 필요할 때 API만 가져다 쓸 수 있을 것이다.Delta-Driven UI를 적용하기 위해 필요한 로직의 흐름은 다음과 같다.

  1. AI 스트리밍을 하고자 하는 문서 Delta에 이벤트를 핸들링할 수 있는 클래스 모듈 적용
  2. 문서 UI 렌더링시 Delta를 구독하는 시점에 스트리밍 모드를 켜는 함수 실행
  3. 데이터 형식별로 스트리밍 이벤트 핸들러에 콜백을 등록하여 커서 렌더링 (이벤트 핸들러의 인자로 커서 위치가 전달됨)
  4. AI 스트리밍이 완료되면 Delta의 상태가 변경되며, 이 변경을 감지하여 스트리밍 모드를 끄는 함수 실행

완성되고 나서의 감상이지만, Delta-Driven UI 로직은 정해진 흐름대로 사용해야만 해서 프레임워크의 성격이 강하다. 심지어 꽤나 장황한 문서를 보면서 그대로 적용해야 할 정도로 자유도가 거의 없는 방식이라서 확장성이라기보다는 재사용성에 초점을 맞추었다고 해야 할 것이다.

추후에는 근본적인 구조 변경이 불가피할 수도 있다. 참고로 AI 스트리밍을 처음 도입할 때 Delta 엔진의 기존 로직을 그대로 활용하는 것이 적절한지에 대해서 논의하기도 했었다. 다수의 사용자가 하나의 문서를 동시에 편집하는 유즈케이스와 AI 혼자서 문서를 편집하고 사용자는 바라보고만 있는 유즈케이스가 근본적으로 다르기 때문에, AI 스트리밍에 맞게 간소화된 동기화 로직을 구현할 필요성도 제기되었다. 하지만 편집의 대상이 되는 문서 데이터가 동일하기 때문에 기존 로직을 그대로 활용하는 것이 시간을 절약하는 방법이라고 판단하여 구현하지 않았다. 이때 다르게 판단했다면 Delta-Driven UI의 설계 역시 훨씬 간소화되었을 수도 있다.

어쨌든 프레임워크 수준으로 정형화된 로직이 마련됨으로 인해서 적어도 일관적인 동작을 보장할 수 있게 된 것은 도움이 된다. 만약 이 방법으로도 더 이상 일관적인 동작이 보장되지 않는 아주 특이한 케이스가 생기거나, 앞서 이야기한 것처럼 근본적인 구조의 변화가 필요한 상황이 오면 그때는 새로운 패러다임을 구상해야 할 것이다.

2. 심각한 성능 저하

둘째로 AI 스트리밍을 UI에서 렌더링할 때 심각한 성능 저하가 발생하고 이것이 커서 UI의 오동작으로 이어지는 문제가 있었다. 커서의 위치가 느리게 업데이트되거나 아예 사라져 버리는 현상이었다. 이 문제는 첫 번째 문제에 비해 발견이 늦었지만 먼저 해결해야 했다. 첫 번째 문제의 해결책이 코어 로직을 도출하여 코드를 패턴화하는 것이었다 보니, 코어 로직과 상관 없는 문제를 먼저 제거할 필요가 있었기 때문이다.

Delta의 중복 구독 방지하기

성능 저하의 원인을 확인해 본 결과 같은 문서에 대해 Delta를 여러 번 구독할 경우 급격하게 처리 속도가 느려졌다. Delta 엔진을 최초 구현할 당시에는 한 페이지에 같은 문서가 여러 개 렌더링되리라는 예상이 전혀 없었기 때문에, 코드에서 중복 구독을 막지 않더라도 문서의 구독 상태는 당연히 하나만 존재할 것임을 전제로 하고 있었다. 그런데 생성형 AI 도입 이후에는 문서를 반복해서 개선하는 것이 일반적인 UX가 되어버렸기 때문에 하나의 채팅 스레드에서 같은 문서의 프리뷰가 여러 개 표시되는 것 역시 일반적인 UI 패턴이 되었다. 이 문제를 해결하기 위해서 구독을 처리하는 로직 내부에서 이미 구독되었는지 여부를 참조하여 중복 구독을 막도록 조치해 두었다.

그런데 더욱 근본적으로는, 최초에 전제했던 바와 같이 한 페이지 내에서 Delta를 중복으로 구독하지 않는 것이 기획에서 의도했던 요구사항과 일치하는지 확인이 필요했다. 특정 시점에 Delta의 스냅샷은 반드시 최신 동기화 상태임이 보장된 데이터이다. 아무리 페이지 내에 프리뷰가 여러 개 뜨더라도 Delta 구독 후 받는 데이터는 최신 데이터로 동일하기 때문에, 오히려 해당 프리뷰가 생성된 시점의 스냅샷을 알아야 하고 편집 화면에서도 해당 스냅샷으로부터 편집을 이어나갈 수 있는 것이 요구사항을 제대로 해석한 것이라는 생각이 들었다. 확인 결과 요구사항의 의도는 내가 생각했던 방향이 맞았으나, 문서 편집 버전이 여러 갈래로 나뉘어지는 것은 기존의 전제를 뒤엎고 대대적인 설계가 필요한 기능이다. 그에 비해 우선순위가 높지 않다는 팀의 결정을 존중하여 중복 구독을 막는 것으로 마무리하기로 했다.

남은 과제

당면한 문제를 해결하긴 했지만 남은 과제가 있다. 무엇보다 AI-native 서비스로 발전하기 위해서는 훨씬 면밀한 UI/UX 검토가 필요하다는 생각이 들었다. 타이핑 애니메이션을 설계 및 구현하고, 버그를 해결하는 데까지 굉장히 많은 시간을 할애했는데 그에 비해서 얻은 고객 가치가 얼마나 큰가에 대해 검증하지 못한 점이 아쉽다. 비교적 사소한 UI 요소에 대해서는 PMF가 검증된 후에 고민해도 늦지 않을 것이라는 판단, 그리고 그에 따른 선택과 집중도 중요할 것이다. 최근 들어 다양한 AI 서비스를 접하게 되면서 나 또한 사용자로서 느끼는 점에 기반하여 추측하자면 타이핑 애니메이션의 중요도는 그리 높지 않을 수도 있다. 사용자들이 AI 서비스에 요구하는 것은 어떤 가치를 얻을 수 있는가에 대한 대답이지 얼마나 보기 좋게 만들었는가는 그 다음 문제다. 나에게 도움이 되는 서비스라면 당장 버그가 많고 유려하지 않더라도 계속 쓰게 되기 때문이다.

또한 고객 검증을 통해 빠르게 제품 개선 이터레이션을 돌리기 위해서는 레거시로 인한 제약을 벗을 필요가 있다. Delta의 최초 설계 당시 전제로 인하여 프리뷰 기능이 유명무실해진 상황을 다시 생각해 보면, 이는 레거시 코드를 유지하기 위해 사용성을 타협한 사례일 수 있다. 사용자는 오히려 생성 당시의 버전으로부터 여러 갈래로 편집본을 관리하는 것을 원할 가능성도 있다. 이 가설이 맞다는 전제 하에 제품을 개선하려면 지금까지 쌓아온 시스템의 근본적인 변화가 불가피하다. 아니면 관점을 바꿔서 반드시 레거시 위에 새로운 기능을 얹어서 검증하는 것이 아니라, 새로운 기능만 독립적으로 선보이는 것이 더 효율적일 수도 있다. PMF를 찾아가고 있는 단계에서 레거시를 고민하는 상황은 어떻게 보면 역설적으로 느껴지기도 한다. 빠르게 성장 방정식을 풀어내는 길을 찾기 위해서는 냉정하게 상황을 판단하고 과감하게 시도할 수 있는 용기가 필요한 시점이다.