채팅에 브랜치 기능을 도입하게 되었는데, 이번 기능의 가장 큰 목적은 사용자에게 있어보이는 기능을 제공하는 것이었다.

그래서 기술적으로 좋은 구조를 떠올리는 것은 당연하고, 보이는 것도 한층 더 세련되고 다르다는 느낌을 줘야 하는 것이 가장 중요했다.

이 글은 아래 글에 이어지는 글이다.
브랜치 채팅 도입 목적과 브랜치 구조 등에 대해 적은 글이니 먼저 보면 좋다.

이번 기능은 프론트엔드가 백엔드에 비해 4배 이상 어려웠던 기능이었는데, 내가 겪은 우여곡절과 어떤 노력이 들어갔는지 적어보려고 한다.


1. 채팅 렌더링 방식의 변화

우선 브랜치가 도입되면서 가장 많이 바뀐 것은 채팅 렌더링 방식의 변화이다.
우리는 채팅 데이터를 Jotai의 Atom으로 관리하는데, 이전에는 그냥 chatDataAtom에 있는 채팅 데이터를 전부 렌더링하면 되었는데, 브랜치가 생기면서 상황이 바뀌었다.

이제는 chatDataAtom에 현재 선택된 브랜치의 채팅만 존재한다는 보장이 없기 때문에, 필터링 과정을 한 번 거쳐야 한다.

그래서 렌더링 전에 isChatGroupRenderedAtom(chatGroup)useMemo로 캐싱해서 이 채팅이 렌더링 되어야 하는가 여부를 결정하는 로직을 추가했다.

ChatContentGroupbranchIds라는 필드가 추가되었는데, 현재 선택된 브랜치가 branchIds에 있는 경우에 해당 ChatGroup이 렌더링 되어야 한다.

따라서 렌더링 방식이

  • 변경 전 : 모든 채팅 데이터를 보여준다.
  • 변경 후 : 현재 선택된 브랜치를 기준으로 이 채팅이 보여질지 말지 결정한다.
    라는 개념으로 바뀌었다.

2. 가상 브랜치 (Temp Branch) 도입

가상 브랜치는 백엔드에는 없는 개념이다.
우리의 설계 상 Regenerated Chat은 branchIds가 존재하지 않는다.

아래 사진에서 빨간 색으로 표시된 Chat Group은 아직 어떤 브랜치에도 속할 수 없으므로 branchIds는 빈 리스트가 된다.

현재 채팅 렌더링 방식이 선택된 브랜치를 기준으로 이 채팅이 보여질지 말지 결정하는 것이기 때문에, Regenerated Chat을 선택한 경우에는 이 Chat Content Group은 어떤 브랜치에도 속하지 않아 채팅을 보여줄 수 없는 상황에 처했다.

그래서 아래와 같이 branchIds가 비어있으면 가상 브랜치를 생성하도록 하였다.

가상 브랜치는 실제 브랜치를 상속받아 보이는 채팅은 상속받은 브랜치의 채팅이 보이도록 하였다.

상속받는 브랜치는 인덱스 상 더 작은 쪽의 Chat Content Group의 메인 브랜치, 그게 없다면 더 큰쪽 중 가장 가까운 Chat Content Group의 메인 브랜치를 따라가도록 하였다.

따라서 어떤 채팅을 보여줄까 결정하는 로직에 가상 브랜치도 포함시켜 그 브랜치의 상속 브랜치의 채팅을 보여주도록 하였다.


3. 어떤 브랜치로 전환할 수 있는지 보여주기 (1차 구현)

branchIds를 기준으로 어떤 브랜치로 갈 수 있는지 보여주는 기능이다.


이 부분이 백엔드와 프론트엔드의 관점이 달라지는 부분이라 꽤나 애를 먹었다.

백엔드의 스키마에 익숙해져 있었는데, 프론트엔드에서 이걸 구현할 때는 다음 채팅을 고려해야 했기 때문이다.

브랜치 구조 상 다음 채팅(자식)에 있는 branchIds의 요소들은 반드시 현재 요소(부모)에 있어야 한다.

그렇다면 현재 채팅만 보고 판단하면 되지 않나?

하지만 그렇게 하면 어려운 이유가 다음 채팅이 마지막 채팅인 경우에는 Temp Branch가 있을 수도 있기 때문이다.

그리고 요구 사항 중 브랜치 Switing 할 때 순서를 Sensitive 하게 관리해야 했기 때문에, 실제 브랜치와 가상 브랜치의 순서도 매우 중요했다.

현재 채팅만 보고 판단하면 가상 브랜치의 위치가 실제 브랜치 사이 중 어느 곳인지 모른다.

따라서 현재 채팅에서 뻗어나갈 수 있는 브랜치를 판단하기 위해서는 반드시 다음 채팅을 보고 결정해야 한다.

그래서 useBranchState(chatGroup) 훅을 만들어서 현재 ChatGroup에서 갈 수 있는 브랜치 목록을 반환하는 형식으로 구현했다.

현재 ChatGroup에서 갈 수 있는 브랜치 목록을 계산하는 과정은 아래와 같다.

1. 현재 채팅과 연결된 다음 채팅을 모두 찾는다.

  • Regeneration으로 생성된 경우와 실제 브랜치를 생성한 경우로 나뉘어지기 때문에 여러 개의 Chat Group이 나올 수 있다.

2. 자식 채팅들을 시간 순으로 나열한다.

  • 기본적인 Chat Group 간 브랜치 순서를 보장하기 위해서이다.

3. 각 Chat Group 내부의 Chat Content Group을 순차적으로 보면서 브랜치를 추가시켜나간다.

  • branchIds의 길이가 1 이상이면 그대로 넣으면 된다.
  • branchIds의 길이가 0이면 실제 브랜치 대신 Temp Branch의 정보를 넣는다.

4. 완성된 브랜치 리스트의 중복을 제거한다.

  • 순서를 보장해야 하므로, 중복되는 요소는 맨 앞 요소만 남긴다.

백엔드에선 다음 채팅에 대한 정보를 주지 않는다

이렇게 보면 구현이 그렇게 어려울 거 같지 않지만 백엔드에서는 기본적으로 다음 채팅에 대한 정보가 없기 때문에 채팅 데이터를 보고 직접 파악해야 한다.

1. 그냥 내 채팅 이후 연속된 채팅을 고르면 안 된다.

채팅 데이터에는 여러 브랜치에 대한 채팅 데이터가 있기 때문에, 그냥 연속된 것을 고르면 같은 브랜치의 채팅이 섞일 수도 있다.
그래서 반드시 브랜치 목록도 함께 고려해야 한다.

현재 Chat Group에서 나올 수 있는 브랜치는 이전부터 계속 뻗어오던 메인 브랜치와 현재 Chat Group에서 분기된 브랜치이다.

그래서 브랜치 목록 중 divergenceChatGroupId가 자신인 브랜치들과 그렇지 않은 메인 브랜치에 대한 채팅 중 현재 채팅보다는 이후 채팅인데, 가장 과거의 채팅을 1개 씩 가져와야 한다.

2. 다른 브랜치 채팅도 어느 정도는 미리 로드 되어있어야 한다.

또 고려해야 할 것이, 채팅 목록 API는 branchId를 받게 되어있는데, 이 API만 호출하면 해당 브랜치 채팅만 가져오기 때문에 브랜치 목록을 계산할 때 다른 브랜치 채팅을 볼 수 없다.

따라서 branchIds를 덜 참조하게 되어 의도하지 않은 결과를 불러올 수 있다.

그래서 채팅을 조회하는 API의 경우, 마지막 채팅의 branchIds를 고려해서 내 브랜치가 아니더라도 아래로 2-3개 정도는 조회해야 한다.


4. 어떤 브랜치로 전환할 수 있는지 보여주기 (2차 구현)

3번 방법은 실제로 논리적으로 통하고 의도한 대로 동작한다.
하지만 한 화면에 채팅이 몇 백 개 렌더링 되어있는 경우에 브랜치 스위칭이나 브랜치가 만들어질 때 몇 백 개 채팅에 대해 브랜치 목록이 다시 계산되는 것이었다.

아까 언급했듯이, 백엔드에서는 기본적으로 다음 채팅에 대한 정보가 없기 때문에 채팅 데이터를 모두 순회해야 한다.

이걸 각 Chat Group에 대해서 연산을 하기 때문에 브랜치의 상태가 바뀌게 되면 매번 O((N + M)^2)의 연산이 일어나게 된다.

  • N은 채팅의 개수이고, M은 브랜치의 개수이다.

채팅이 100개만 있어도 10,000번의 연산이 일어나는 셈이다.

  • Big-O 표기법에 의해 계수가 생략된 것이지, N의 계수도 상당하다.

연산만 일어난다면 다행이지만 채팅 렌더링이 모두 새로 일어나기 때문에 최고 사양 수준인 내 노트북에서도 렉이 걸리기 시작했다.

따라서 위 로직은 그대로 따라가면서 브랜치 계산 로직을 크게 최적화해야했다.

그래서 브랜치 계산을 Global한 연산으로 바꾸기로 하였다.

생각해보면 채팅 간 부모 / 자식 관계는 바뀌지 않으므로 Chat Group이 추가되지 않는 이상 이 그래프는 변할 일이 없었다.
그리고 각 채팅의 부모 / 자식 관계를 한 번의 순회만으로 전체 Topology를 그릴 수 있기 때문에 이 방법이 더 효율적이라고 판단했다.

아래와 같은 Type을 정의하고, 현재 선택된 ChatContentGroup을 기준으로 직접 연결되어 있는 ChatGroupdirectChildrenByBranchId로 가져올 수 있도록 하였다.

TypeScript
export type BranchGraphCompact = {
  byChatContentGroupId: Record<
    string,
    {
      chatGroupId: string
      chatContentGroupId: string
      parentOnSelected: string | null
      childOnSelected: string | null
      isSplitOnSelected: boolean
      directChildrenByBranchId: DirectChildrenMap
      directChildrenUnion: string[]
    }
  >
  rootOnSelected: string | null
  leafOnSelected: string | null
}

채팅 데이터의 길이가 바뀔 때마다 그래프는 다시 계산된다.
브랜치가 바뀌거나 추가되는 동작은 채팅 데이터의 길이를 바꾸진 않으므로 그래프가 다시 계산되지 않아 효율적으로 처리할 수 있었다.

  1. Root부터 탐색을 시작한다.
  2. Chat Group – Chat Content Group 순으로 (이중 반복문) 채팅 데이터를 순회한다.
  3. 이전 데이터들을 함께 관리해서 현재 데이터에서 자식을 판단하는 것이 아닌, 직속 부모를 파악하는 형식으로 그래프를 채워나간다.
  • 앞으로 탐색할 데이터와의 관계보다는 이미 탐색한 데이터와의 관계를 따지는 것이 더 쉽기 때문이다.
  • branchId를 Key로, chatContentGroup을 Value로 해서 브랜치 별 지나온 최신 채팅들을 저장해서 직접 연결된 부모를 간단하게 파악할 수 있었다.

위와 같이 그래프를 만들었으면, 특정 Chat Group에서 분기되는 브랜치도 전역으로 구할 수 있게 된다.
3번에서 설명한 로직을 그래프를 돌면서 각 Chat Group에 설정하는 방식으로 구성했다.

따라서 Global 하게 참조할 수 있는 BranchChatGraph를 만들고, 3번에서 구현했던 useBranchState에서는 아래와 같이 상수 시간에 데이터를 가져올 수 있도록 하였다.

TypeScript
export const chatGroupBranchesSelector = (chatContentGroupId: string) =>
  selectAtom(
    allChatGroupBranchesAtom,
    map => map[chatContentGroupId] ?? [],
    (a, b) => a.length === b.length && a.every((x, i) => x.id === b[i].id),
  )

위와 같이 구현하면 채팅 데이터가 추가되는 경우에는 O(N + M), 브랜치의 상태가 바뀌게 되면 매번 O(1)로 직접 연결된 브랜치 목록을 가져올 수 있게 되었다.


5. 양방향 무한스크롤

다음으로는 특정 브랜치 체크포인트로 가는 기능을 구현해야 했다.
이는 특정 Chat Group으로 이동하는 동작과 같다.

채팅 데이터 페이징 API에 direction이라는 인자가 추가되고, 위, 아래, 그리고 양방향으로 데이터를 가져올 수 있게 되었다.

그래서 특정 Chat Group으로 이동하면 그 위, 아래로 채팅 데이터를 가져오고, 스크롤을 중앙에 위치시키는 로직을 추가해야 했다.

또한, 그렇게 이동한 위치가 상당히 이전 위치일수도 있으므로 아래 방향으로도 무한 스크롤을 추가해야 했다.

그리고 체크포인트로 이동하는 과정이 어색하지 않고 뭔가 진행 중이라는 느낌을 줄 수 있으면 좋겠다는 추가 요구사항이 있었다…

어떻게 Responsive하게 이동시켜야 할지 굉장히 고민이 많았다.

  1. Blur Background를 띄운다.
  2. Chat Data API Call을 한다.
  3. API가 로드되면 채팅 데이터를 이 데이터로 치환한다.
  4. 대상 Chat Group으로 스크롤 시킨다.

위 과정을 통해 구현했는데, 2번-3번, 3번-4번 과정이 Blur Background 아래에서 따로따로 일어나게 했더니 생각보다 뭔가 진행되는 느낌이 들어서 걱정과는 달리 큰 공수 없이 처리할 수 있었다.


6. 복잡한 브랜치 플로우

개발하다보니 브랜치가 만들어질 수 있는 플로우가 매우 많다.

아래와 같이 직접 생성하는 경우를 시작으로 해서,

Regenerate 후 채팅을 보내면 그 채팅은 브랜치가 확정되어 브랜치가 생성되기도 하고, User Chat을 Edit 후 Submit하면 바로 Inference가 날라가면서 브랜치가 생성되었다.

  • ChatGPT에서 내 채팅을 수정하면 바로 수정된 내용으로 답변이 오는 것을 확인 할 수 있다.

이 과정 안에서 상태 관리가 매우 민감하게 되어야 했다.

Regenerate 후 채팅을 보내면 브랜치가 생기면서 프론트엔드에서는 Temp Branch에서 실제 브랜치로 스위칭 되는데, 이때 사용자는 브랜치가 스위칭되었다고 느끼면 안 되고, 그냥 브랜치가 있었던 거처럼 동작해야 한다.

그래프 계산이나 브랜치 목록 계산 등에 Temp Branch와 관련된 Logic이 있었기 때문에 관련 로직을 철저히 검증해야 했다.


7. 외부 페이지에서 버튼 클릭하면 채팅 페이지의 특정 채팅으로 이동할 수 있어야 한다.

아래는 Memory 페이지인데, 주소를 보면 /chat/memory/{chatroomId}이다.

우리 채팅은 /chat/{chatroomId} 이런 형식인데, 메모리 페이지에서 특정 채팅으로 가는 버튼을 누르면, 기존처럼 채팅 페이지의 최신 채팅이 보이는 것이 아닌, 브랜치 Checkpoint로 가듯이 원하는 채팅의 중간으로 이동하도록 해야 했다.

이건 컨벤션을 잘 모르겠는데, localStorage로 구현했다.
그렇게 구현한 이유는 Memory 페이지를 History에 남기면 안 되기 때문이다.

예를 들어, /chat -> /chat/memory 에서 Chat 버튼을 눌러서 router.push('/chat') 형식으로 이동한다면, 히스토리가 /chat -> /chat/memory -> /chat이 되어서 뒤로가기를 누르면 메모리 페이지로 다시 돌아가기 때문이다.

의도한 것은 Chat 버튼을 누르고 뒤로 가기 버튼을 누르면 채팅 페이지 이전으로 가는 것이 자연스럽다고 생각했다.
따라서 Chat 버튼을 누르면 로컬스토리지에 데이터를 설정하고 router.back()으로 이동시켰다.

그러면 채팅 페이지 진입 시 작동하는 useEffect에서 로컬 스토리지를 확인하고 브랜치 체크포인트 이동과 같은 플로우로 chatGroupId 기반 이동이 가능했다.

프론트엔드 개발을 시니어한테 배운 적이 없어서, 이런 히스토리를 관리하는 컨벤션은 아직 잘 모르겠다.


8. 브랜치 뷰 모달

이 브랜치 뷰를 구현하는데 신경을 굉장히 많이 썼다.
데이터를 잘 만져서 그래프 형태로 만들어야 하고, 보여주는 것도 깔끔하게 보여야 했기 때문이다.

그래프 라이브러리는 antv g6를 사용했다.

브랜치 그래프에는 아래 정책들이 있었다.

  1. 리프 노드는 파란색으로 크게 표시되어야 한다.
  2. 체크포인트 노드는 민트색으로 크게 표시되어야 한다.
  3. 체크포인트와 리프 노드 사이는 더미 노드가 회색으로 표시되어야 한다.
  4. 간선은 실선을 기본으로 하되, 현재 선택된 브랜치의 경우에는 움직이는 점선으로 표시되어야 한다.
  5. 선택된 브랜치 라인에는 그 경로에 배경을 넣어 직관적으로 보이게 해야 한다.
  6. 체크포인트에는 요약본이 옆에 표시되어야 한다.
  7. 리프노드는 모두 같은 높이에 위치하되, 메인 스트림 브랜치는 다른 리프노드보다 조금 더 아래에 위치해야 한다.

브랜치 그래프 생성

역시 브랜치 그래프도 브랜치 정보를 가지고 직접 만들어야 했기 때문에, GraphBuilder라는 클래스를 만들고, DFS와 BFS를 사용해서 그래프를 구성했다.

브랜치에 divergenceChatGroupId를 이용해서 브랜치 그래프가 어떤 모양인지 파악할 수 있다.

divergenceChatGroupId의 값이 null인 브랜치는 항상 1개이고, Root Branch이다.
브랜치를 순회하면서 ChatGroupId로 만들어진 노드와 divergenceChatGroupId를 연결시켜서 그래프를 만들었다.

추가로, Checkpoint와 Leaf Node 사이에는 정책에 따라 Dummy Node를 넣는 작업까지 거쳤다.


브랜치 모양 조정

아래 그래프를 보면 깊은 노드부터 왼쪽에 배치되면서 그래프가 겹치지 않고 보기 좋게 배열되는 것을 알 수 있다.

그래서 그래프의 모양을 보기 좋게 하기 위해 DFS를 돌려서 각 노드에 X, Y 좌표 값을 정해주었다.

루트는 (X, Y) = (0, 0)으로 설정하고, 노드의 깊이가 깊어질 수록 Y 좌표를 1씩 증가시켰다.
그리고 X 좌표는 DFS 함수 밖에서 관리해서, 분기점에서 갈라질 때마다 X 좌표를 1씩 증가시켜서 저장했다.

이렇게 하면 X 좌표는 재귀와 상관없이 1씩 증가하게 되면서 자연스럽게 안 겹치게 그래프를 그릴 수 있었다.
DFS 특성 상 깊은 노드부터 탐색을 마치기 때문에 점점 깊이가 옅어지면서 노드는 자연스럽게 오른쪽에 배치된다.

그렇게 해서 모든 노드에 좌표 설정이 완료된 후, 모든 리프 노드의 Y좌표를 그래프 내 Y좌표의 최댓값으로 모두 바꿔주었다.
왜냐하면 모든 리프노드의 Y좌표는 같아야 하기 때문이다.

또한 메인 스트림 브랜치는 조금 더 아래로 내려와 있어야 하므로, 브랜치의 currentLength가 가장 긴 브랜치 1개를 골라 isMainStreamtrue로 설정해주었다.

그래프 모양을 조정했으면 이제 브랜치 경로를 지정하기 위해 BFS와 Back Tracking으로 Root부터 Selected Leaf까지의 경로에 isSelectedtrue로 설정해주었다.


브랜치 그래프 깎기

아래 그래프에서 알 수 있듯이, 축소를 어느 정도 떙기면 글씨가 아예 안 보여야 하고, 확대해도 가독성을 위해 글씨 크기는 유지되어야 하며, 글씨가 짤리는 크기가 되면 Leading(…)으로 처리해야 한다.

사진에는 다 안 담기는데 자연스러움을 위해 그래프 확대 시 노드의 최소 / 최대 크기와 확대 비율도 자연스럽게 조정해야 했다.

오른쪽 아래에 있는 원래 그래프 뷰로 이동하는 버튼을 누르면 현재 선택된 브랜치의 리프노드가 브랜치 뷰 중앙 하단에 적당한 Zoom 상태로 위치하도록 해야 했다.

그리고 리프 노드와 체크 포인트를 클릭했을 때 모달을 띄워야 했으며 동작도 달랐다.

줌인과 함께 노드의 크기가 작아지는데, 히트박스도 작아지는 문제가 생겨서 투명 가상 노드를 도입하고, 이 노드는 덜 민감한 Zoom Ratio를 두어서 노드를 상당히 벗어나게 클릭해도 클릭이 되도록 했다.

줌아웃을 많이 하면 그래프가 많이 작아지면서 노드도 크게 작아지는데, 이때는 히트박스가 겹쳐서 의도하지 않은 동작을 하는 경우도 있었다.

그래서 글씨가 숨겨지는 경우에는 Zoom Ratio을 없애고, 기존 노드보다 조금 더 크게 해서 고정 크기로 배치하여 이상한 노드가 클릭되지 않도록 했다.


모바일 지원

antv g6 라이브러리는 아쉽게도 모바일과 호환성이 그리 좋지 않은데, 특히 브랜치 뷰를 손가락으로 확대/축소할 때 감도가 자연스럽지 않았다.

Pinch 기능을 제공하긴 해서, 계속 직접 테스트하며 최대한 쓰기 덜 불편한 지점을 찾아 튜닝해야 했다.

QA 하면서 모바일 그래프 뷰 줌이 완벽히 마음에 안 드는데 공식적으로 완벽하게 기능을 지원하지 않아 계속 스트레스를 받았던 것 같다.

라이브러리를 직접 만지기는 어려웠기 때문에 지원하는 API로 최대한 합의점을 찾아야 했다.


결과

아래와 같이 몇 십개의 브랜치도 잘 처리할 수 있는 그래프 뷰가 완성되었다.


마무리

브랜치 기획부터 최종 배포까지 2주 정도 걸린 거 같다.

특히 힘들었던 점은 브랜치 정책이 너무 자주 바뀌어서 프론트 코드를 여러 번 갈아 엎어야 했다는 것이다.

해 본 사람은 알겠지만, 정책으로는 한 줄만 바뀌어도 프론트 코드는 몇 백 줄을 바꿔야 할 수도 있다는 프론트엔드 개발의 흔한 고충이다.

채팅의 구조 자체가 바뀌는 큰 업데이트이어서 Database Migration 작업도 상당히 걸려 배포는 40분 정도 소요된 거 같다.

열심히 한 만큼 배포하자마자 많은 사용자들께서 좋은 반응을 해 주셔서 개발자로서의 보람이 생기는 부분이었다.

댓글 남기기

Dalmeng's Footprints에서 더 알아보기

지금 구독하여 계속 읽고 전체 아카이브에 액세스하세요.

계속 읽기