[문제 해결 경험] 입력 순서 불일치로 인한 캐럿 동기화 문제

hyonun·2025년 4월 4일

Nocta - CRDT구현

목록 보기
6/9
post-thumbnail

🎨 서론

영어, 숫자입력(non-composing)

  • 유저 1: 1 만 입력
  • 유저 2: a 만 입력

한글(composing)

  • 유저 1: 만 입력
  • 유저 2: 만 입력

현재 동일한 블럭에서 여러 유저가 동시에 입력을 진행할 경우, 캐럿의 위치가 이상하게 움직이는 문제가 있습니다.

한글의 경우 컴포징 처리가 완벽하지 않아 동기화가 깨지는 문제도 있습니다.

원인 추적

clientId가 전부다 3으로 쓰여지고있네?

이처럼, 연결관계가 로컬입력을 통해 깨져버리게 되고, 잘못된(원치않는) 노드와 연결이 되어 입력이 이상해져 버리게 된 것 이였습니다.

원래 이런 현상은 crdt데이터 구조상 해결되는 문제입니다. (client, clock 으로 우선순위 부여) 하지만 배치처리로 인해 일관성을 보강하는 알고리즘이 누락 되어 문제입니다.

🛠 첫번째 원인

모든 유저의 연산이 페이지를 생성한 사람의 clientId로 사용되고 있던 문제

이는 근본적인 참조문제로, 개발 기간이 길어지면서 저희가 놓치고 있었던 문제였습니다.

페이지를 만들거나, 블럭을 만들때 만든이의 clientId를 기반으로 각 CRDT클래스가 생성됩니다.

이후 CRDT클래스 내부의 메서드들이 CRDT클래스를 생성한 사람의 clientId.

즉, 역직렬화 과정을통해 this.client 내부값으로 저장한 상태로 모든 localInsert, remoteInsert등의 연산이 생성한 사람의 번호로 작성되고 있었습니다.

이문제로 CRDT가 제대로 동작하지 않고있던 문제였습니다.

원래는 유저 별로 clientId가 달라야 해서 1유저가 입력하면 clientId=1
2유저가 입력하면 clientId=2 가 되며 입력이 남아야 하는데,
지금은 client=5 로 전부다 입력이 남는 상황이죠.

아래의 코드를 보겠습니다.

operation 으로 오는 연산에 담긴 clientId를 기반으로 새로운 페이지 Id를 만들게 됩니다.

그리고 이 데이터를 기반으로 모든 유저에게 clientId가 송신되며 operation연산이 일어납니다.

이 clientId는 무엇이냐?

정답은 Page만들기 버튼을 누른 유저의clientId입니다.

즉, 7번 유저가 페이지를 만들면, 그 페이지 내부의 모든 연산의 clientId는 7로 이루어지는 것이죠.

그래서 remoteInsertlocalInsert에서 중복된 clientId가 입력되어 보이는 값과 실제 값이 누락, 중복되는 현상이 발생했던 겁니다 !!

🛠 해결

마찬가지로
editorCRDT에 들어가는 clientId를 제대로 만들어도 다시 blockCRDTclient가 잘못 할당되는 문제가 있었습니다.

페이지를 만든 사람으로 ClientId가 할당되었고 블럭 역시 블럭을 만든사람의 clientId로 할당되는 문제가 생겼습니다.

clientId의 상태 관리 플로우

  • 서버 부여 ▶ 페이지 생성 ▶ 블록 생성 ▶ 텍스트 생성
    ㄴ 위 모든 과정을 [서버 부여] 에서 오는 clientId를 가짐

  • 서버 부여 페이지 생성 ▷ 블록 생성 텍스트 생성
    ㄴ (▶) 부분에 관여하여 현재 사용자clientId로 역직렬화, 연산등이 이루어지게 수정

왜 서버부여에서 이미 clientId로 바꿧는데, 블록생성▷텍스트생성에도 관여하나요?

-> 기존의 버튼을 만든사람의 client정보는 남겨져 있어야 하기때문에, 연산에만 필요한 clientId를 변경해야합니다.

  • [페이지를 만든 clientId:7]
  • [블록 생성용 clientId:5] <- 변경
  • [텍스트 생성용 clientId:5] <- 변경

그렇기 때문에 블록내부에서 일어나는 연산도 현재 사용자의 clientId를 기반으로 이루어져야 합니다.

코드로 보면

에디터용 인스턴스를 만들때 client값을 현재 유저의 clientId로 설정해 주었고

localInsert연산이 일어날때 clientId를 매개변수로 넘겨서 새로운 CharId가 생성되도록 수정하였습니다.

결과

매우 빠르게 입력

유저1 : ab[123]
유저2: a[c]b

  • 수정 전

  • 수정 후


👨‍🔧 두번째 원인

한글 컴포징 처리

또한 컴포징중에 글자입력이 들어오면 캐럿 인덱스 처리가 이상해지게 됩니다.

클라이언트1 (한글입력 '가나다')

클라이언트2 (숫자입력 '12345')

컴포징 처리중에 동일 블럭에 다른 입력이 들어오면, 캐럿 처리때문에 컴포징 처리가 풀린 것 처럼 보입니다. (밑줄이 없어짐)

하지만 실제로는 컴포징처리 중인 상태에 이며 다른 글자를 입력하면 가나다가 입력 됩니다.

그리고 컴포징 처리가 풀린 것 처럼 보이는 상태가 여전히 유지되며 한글을 입력한 클라이언트에는 한글 상태가 남아있게 됩니다. (가나다 로 남아있음)


👩‍🍳 세번째 원인

존재하지 않는 index에 커서가 있을 경우 캐럿을 0 으로 초기화

동작 테스트중 캐럿을 어디에 이동시켜야 할지 찾지못 하는 error 가 발생했었습니다.

네트워크 지연, 배치 처리 등을 문제로 index를 이동시키는데 도중입력, 한글 컴포징 등으로 로컬의 관계가 깨질때 이동시켜야할 index가 존재하지 않는 경우가 생깁니다.

특히 삭제의 경우에 생길 확률이 높습니다.

위 같은 상황 뿐만 아니라 다양한 상황에서 적용 가능한 명확한 캐럿 관리 알고리즘이 필요했습니다.


🏃‍♂️ 해결방법

이러한 문제를 보강하기 위한 방법으로

  1. 각 연산을 입력하는 clientId에 따라 입력되게 하기
  2. 컴포징상태를 판단하는 알고리즘 넣기
  3. 문자 Id를 기반으로 입력 처리 하기

3번의 경우는 기존의 index에 기반한 문자 입력처리로는 해결하기 어려웠습니다.
왜냐하면, 정수로 관리하는것은 예외처리가 너무 복잡해졌기 때문입니다.

그래서 다른 클라이언트와 무결성을 유지하기 위해서는 캐럿의 서버동기화가 필요했습니다.

또한 한글의 컴포징 처리역시 컴포징상태를 깨지 않게 remoteInsert 처리가 필요했습니다.

그래서 y.js 에서 사용하는 awareness 시스템을 차용했습니다.
바로 문자 id에 캐럿위치를 지정하는 방법입니다.

기존에는 remoteInsert가 일어났을때 일어난 문자의 위치를 검색하고, 현재 currentCaret의 위치기반으로 캐럿보다 큰 경우 캐럿을 처리해줬습니다.

예) index=3인데 5에 입력이 발생하면 그대로 3 유지.
012|34 -> 012|345
예) index=3인데 2에 입력이 발생하면 +1 처리
012|34 -> 0152|34

이제는 문자ID에 캐럿이 달려 있기 때문에, 문자를 입력하거나, 엔터, 키보드 움직임, 등에 따라 클라이언트의 포커싱 상태를 관리하기 때문에 저런 연산이 필요 없어졌습니다.

😀장점

  • 클라이언트의 캐럿 연산 부하가 줄어든다.
  • 캐럿의 위치를 다른 클라이언트에게 보여줄 수 있다.
  • 서버에서 명확히 캐럿을 관리하기 때문에 캐럿이 이상하게 움직이는 경우를 방지할 수 있다.

😔단점

  • 모든 클라이언트들의 캐럿위치를 서버에 저장하고 관리해야하기때문에 네트워크 부하가 증가할 수 있습니다.

  • 캐럿위치를 동기화 시키지 않고 그냥 remoteInsertlocalInsert의 충돌만 해결하고 더 철저하게 로컬에서 캐럿을 관리시키면 되지 않나? 라는 오버헤드 엔지니어링이 아닌가 라는 우려가 있습니다.


😏 느낀 점

  • y.js 와 같은 기존 라이브러리에서 우리가 겪은 문제를 효율적으로 해결하기 위해 어떻게 고민했나 알 수 있었습니다.
  • 문제가 발생하는 상황을 테스트해보며 원인을 찾을 수 있었습니다.

참고:
https://www.tag1consulting.com/blog/yjs-deep-dive-part-3
https://docs.yjs.dev/getting-started/adding-awareness

profile
비전공자 + 타업계 경력2년의 IT 개발자 도전기~

0개의 댓글