Lepisode Tech 로고Lepisode Tech

프로젝트 관리 툴에 실시간 협업을 얹다(Like Notion) — Yjs와 CRDT 적용 회고

박지훈

안녕하세요. AI 기반 프로젝트 관리 플랫폼을 개발하고 있는 풀스택 개발자입니다.

해당 서비스에는 할 일 관리, 데이터베이스 뷰, 리치 텍스트 에디터 등 다양한 기능이 있는데요. 어느 시점부터 "같은 할 일을 두 명이 동시에 수정하면 마지막 저장이 반영" 문제가 반복됐어요. 프로젝트 관리 도구에서 실시간 동기화가 안 되면, 팀원 A가 상태를 "진행 중"으로 바꾸고 팀원 B가 담당자를 변경하는 순간 A의 변경이 사라지는 거죠.

이 글에서는 Yjs(CRDT 기반 실시간 동기화 라이브러리)를 NestJS + Angular 스택에 도입하면서 겪은 설계 결정, 예상 밖의 시행착오, 그리고 돌아보며 배운 것을 공유하려고 합니다.


"마지막 저장이 반영되는" 문제 — 왜 낙관적 업데이트만으론 부족했는가

기존 구조는 단순했어요. 클라이언트에서 폼 값이 변경되면 REST API로 부분 수정 요청을 보내고, 서버는 그대로 DB에 반영하는 방식이었습니다.

클라이언트A: 상태 = 진행중  → PATCH /tasks/:id
클라이언트B: 담당자 = [유저2] → PATCH /tasks/:id

두 요청이 거의 동시에 도착하면, 나중에 도착한 요청이 먼저 도착한 요청의 변경을 덮어씌워요. 필드별 병합도 없고, 충돌 감지도 없는 거죠.

해결 방안은 크게 세 가지였어요.

방안장점단점
필드별 PATCH + 버전 체크구현 간단에디터(리치 텍스트)에는 적용 불가
OT (Operational Transformation)Google Docs가 검증서버 중앙 집중, 구현 복잡도 높음
CRDT (Yjs)서버 없이도 병합 가능, 에디터 지원 성숙바이너리 상태 관리 필요

Tiptap 에디터가 이미 Yjs 기반 Collaboration 확장을 공식 지원하고 있었기 때문에, 에디터와 메타데이터를 하나의 동기화 채널로 통합할 수 있다는 점이 결정적이었습니다. 에디터만 별도 OT로 가고, 나머지 필드는 REST로 가는 이중 구조보다 하나의 Y.Doc으로 묶는 게 훨씬 자연스러웠거든요.


"하나의 Y.Doc에 에디터와 메타데이터를 함께 담는다" — 설계의 핵심 결정

가장 중요한 설계 결정이었어요. 보통 Yjs를 쓰면 리치 텍스트 에디터용 XmlFragment만 Y.Doc에 넣는데요. 저희는 여기에 Y.Map이라는 공유 자료구조를 하나 더 추가해서, 제목·상태·우선순위·담당자 같은 구조화된 필드도 CRDT로 동기화하기로 했습니다.

Y.Doc
├── XmlFragment('default')  ← Tiptap 에디터 (리치 텍스트)
└── Map('metadata')          ← 제목, 상태, 우선순위, 담당자, 날짜 등

이 구조의 핵심 트레이드오프는 이거였어요:

  • 장점: 에디터 WebSocket 연결 하나로 모든 필드가 실시간 동기화됨. 별도 REST 호출 없이 메타데이터 Map에 값을 넣기만 하면 다른 클라이언트에 즉시 반영.
  • 단점: Y.Doc 바이너리에 메타데이터가 포함되므로, 서버에서 저장할 때 바이너리를 디코딩해서 DB 컬럼에 다시 써줘야 함.

"DB에 이중 저장하는 게 맞나?"라는 의문이 있었지만, 목록 조회·필터링·검색은 DB 컬럼을 직접 쿼리하기 때문에, Y.Doc 바이너리만 저장하면 이런 기능이 작동하지 않아요. 결국 "CRDT가 진실 공급원(Source of Truth)이고, DB 컬럼은 읽기 전용 프로젝션"이라는 모델을 선택했습니다.


Hocuspocus — Yjs 서버를 NestJS에 통합하기

Yjs 클라이언트끼리 직접 P2P로 통신할 수도 있지만, 영구 저장과 인증이 필요했기 때문에 Hocuspocus 서버를 선택했어요. Hocuspocus는 Yjs용 WebSocket 서버로, 인증·문서 로드·저장 같은 훅을 통해 인증과 영속화를 깔끔하게 처리할 수 있거든요.

문제는 Hocuspocus가 독립 서버로 설계되어 있어서, NestJS 모듈 시스템과 맞물리지 않는다는 점이었어요. 해결 방법은 NestJS 모듈 초기화 시점에 별도 포트로 Hocuspocus를 띄우는 것이었습니다.

@Module({ imports: [DatabaseModule] })
export class HocuspocusModule implements OnModuleInit, OnApplicationShutdown {
  private readonly port = Number(process.env.HOCUSPOCUS_PORT ?? 3001);

  private server = new HocuspocusServer({
    port: this.port,
    debounce: 500,
    onAuthenticate: async ({ token }) => {
      // JWT 검증 후 사용자 ID 반환
      const { sub } = this.authUtil.verifyToken(token);
      const user = await this.prismaService.user.findUnique({
        where: { id: sub },
      });
      if (!user) throw new Error('Unauthorized');
      return user.id;
    },
    extensions: [
      new Database({
        fetch: async ({ documentName }) => {
          /* DB에서 Y.Doc 로드 */
        },
        store: async ({ documentName, state }) => {
          /* Y.Doc → DB 저장 */
        },
      }),
    ],
  });
}

배포 환경에서는 Nginx가 특정 경로의 WebSocket 요청을 이 별도 포트로 프록시하는 구조예요. 여기서 한 가지 삽질이 있었는데, Hocuspocus는 URL 경로 접두어를 인식하지 않기 때문에 Nginx에서 접두어를 strip해주지 않으면 연결이 실패합니다. proxy_pass 설정에서 끝에 슬래시(/)를 붙여야 하는 거죠.


서버에서 Y.Doc을 "풀어 읽는" 과정 — 예상보다 복잡했던 저장 로직

저장 훅에서 클라이언트가 보낸 Y.Doc 바이너리를 받아 DB에 저장하는 과정이, 처음에는 단순해 보였어요. 그런데 실제로 구현하면서 세 가지 문제를 만났습니다.

1. 기존 에디터 데이터에 메타데이터가 없는 경우 — 마이그레이션 문제

Yjs 도입 전에 이미 Tiptap 에디터로 저장된 할 일들이 있었어요. 이 할 일들의 content 컬럼에는 에디터 데이터(XmlFragment)만 있고 메타데이터 Map은 없는 상태죠. 첫 로딩 시 메타데이터가 비어 있으면 DB의 기존 값으로 메타데이터를 생성해서 Y.Doc에 병합해야 했어요.

private mergeMetadataIntoState(existingState: Uint8Array, task): Uint8Array {
  const ydoc = new Y.Doc();
  Y.applyUpdate(ydoc, existingState);        // 기존 에디터 content 복원

  const metadata = ydoc.getMap('metadata');
  metadata.set('title', task.title || '');    // DB 값으로 메타데이터 초기화
  metadata.set('status', task.status || 'PENDING');
  // ...

  return Y.encodeStateAsUpdate(ydoc);         // 병합된 바이너리 반환
}

이 "에디터 데이터는 있지만 메타데이터는 없는" 과도기 상태를 처리하지 않으면, 기존 사용자들의 에디터 내용이 모두 날아가거든요. 문서 로드 시점에 메타데이터 존재 여부를 확인하고 분기 처리하는 유틸을 만들어서 해결했습니다.

2. Y.Doc에서 텍스트 추출 — XmlFragment 순회의 번거로움

목록에서 미리보기 텍스트를 보여주거나, 검색 기능을 위해 Y.Doc 바이너리에서 순수 텍스트를 뽑아야 했어요. Tiptap은 ProseMirror 기반이라 내부 구조가 XmlElement → XmlText의 트리 구조인데, 이걸 재귀적으로 순회해야 합니다.

private xmlFragmentToText(element: Y.XmlFragment | Y.XmlElement): string {
  const blocks: string[] = [];
  element.forEach((child) => {
    if (child instanceof Y.XmlText) {
      blocks.push(child.toString());
    } else if (child instanceof Y.XmlElement) {
      blocks.push(this.xmlFragmentToText(child));  // 재귀
    }
  });
  return blocks.join('\\n');
}

Yjs 공식 문서에는 이런 텍스트 추출 유틸이 없어서, 직접 만들어야 했어요. Y.Text가 아닌 XmlFragment를 쓰는 에디터에서 텍스트를 뽑아야 할 분들에게 참고가 될 거예요.

3. 관계형 필드(담당자, 프로젝트) 저장 — 배열 ↔ relation 변환의 번거로움

담당자 ID 목록이나 프로젝트 ID 목록처럼 관계 테이블로 연결된 필드는, Y.Map에서 ID 배열을 꺼낸 뒤 ORM의 관계 연결 문법으로 변환해야 했어요.

if (metadata.assigneeIds !== undefined) {
  const assigneeIds = Array.isArray(metadata.assigneeIds)
    ? metadata.assigneeIds.map((id) => String(id))
    : [];
  updateData.assignees = {
    set: assigneeIds.map((assigneeId) => ({ id: assigneeId })),
  };
}

단순 문자열 값은 바로 대입하면 끝나지만, 관계형 필드는 항상 "기존 연결 해제 → 새 연결 설정" 형태로 변환해야 하는 보일러플레이트가 생겨요. 문서 타입이 늘어날수록 이 변환 코드가 커지는데, 아직 깔끔한 추상화를 못 찾았어요.


클라이언트 — Angular Signal과 Y.Map의 양방향 동기화

클라이언트 쪽에서 가장 까다로웠던 부분은 Angular의 Signal(폼 상태)과 Y.Map 사이의 양방향 동기화에서 무한 루프를 방지하는 것이었어요.

구조는 이렇습니다:

사용자 입력 → 폼 상태 업데이트
                 ↓ effect()
           Y.Map에 값 반영 (origin: 'local')
                 ↓ WebSocket 전파
           상대방 클라이언트의 Y.Map observe 콜백 실행
                 ↓
           상대방 폼 상태 업데이트 (isRemoteUpdate = true)
                 ↓ effect() 트리거
           → isRemoteUpdate 플래그로 재전파 차단

핵심은 두 가지 플래그예요:

  • origin 표시 — Y.Map의 observe 콜백에서 내가 보낸 변경인지 판별해, 로컬 트랜잭션은 무시
  • 원격 업데이트 플래그 — effect 내에서 원격 변경으로 인한 폼 업데이트가 Y.Map에 재전파되지 않도록 차단
// 폼 → Y.Map (로컬 변경만)
effect(() => {
  const model = this.formModel();
  if (this.isRemoteUpdate || !this.metadataMap) return;

  doc.transact(() => {
    if (metadata.get('title') !== model.title) {
      metadata.set('title', model.title);
    }
  }, 'local'); // ← 이 트랜잭션은 로컬에서 발생한 것임을 표시
});

// Y.Map → 폼 (원격 변경만)
this.metadataMap.observe((event) => {
  if (event.transaction.origin === 'local') return; // 내가 보낸 변경이면 무시
  this.syncMetadataToForm(); // 상대방이 보낸 변경만 폼에 반영
});

이 패턴을 만드는 데 꽤 시행착오가 있었어요. 처음에는 원격 업데이트 플래그 없이 effect 내부에서 값 비교만으로 중복 전파를 막으려 했는데, 배열(담당자 ID 목록) 비교가 참조 비교로 빠지면서 무한 루프가 발생했습니다. 깊은 비교(JSON.stringify)를 추가하고, 플래그 방어까지 이중으로 걸어야 안정화됐어요.


리스트 뷰에서도 실시간 — 화면에 보이는 항목만 바인딩하기

할 일 상세 페이지뿐 아니라 리스트 뷰에서도 실시간 반영이 필요했어요. 누군가 상태를 바꾸면 리스트에서도 즉시 변경되어야 하니까요.

모든 할 일에 WebSocket 연결을 열면 메모리와 연결 수가 폭발하기 때문에, 현재 화면에 보이는(필터링된) 항목에만 Y.Map 바인딩을 유지하는 전략을 썼어요.

effect(() => {
  const visibleTaskIds = new Set(this.filteredTasks().map((task) => task.id));

  // 화면에서 사라진 항목은 연결 해제
  for (const taskId of this.realtimeBindings.keys()) {
    if (!visibleTaskIds.has(taskId)) {
      this.teardownRealtimeBinding(taskId);
    }
  }

  // 새로 보이는 항목은 연결 수립
  for (const taskId of visibleTaskIds) {
    if (!this.realtimeBindings.has(taskId)) {
      this.setupRealtimeBinding(taskId);
    }
  }
});

Angular의 effect가 필터링된 목록 Signal을 자동 추적하기 때문에, 필터나 정렬이 바뀔 때마다 바인딩이 자동으로 정리/생성돼요. 이 패턴은 React의 useEffect + deps 배열과 비슷하지만, Signal 기반이라 의존성을 명시적으로 적을 필요가 없어서 더 깔끔하더라고요.


데이터베이스 뷰까지 확장 — 동적 컬럼에 Y.Map 적용하기

할 일(Task)은 스키마가 고정되어 있어서 메타데이터 키를 상수로 정의할 수 있었는데요. 데이터베이스 뷰는 사용자가 컬럼을 자유롭게 추가/삭제할 수 있는 동적 스키마 구조예요. 이걸 Y.Map에 어떻게 매핑할지가 두 번째 큰 설계 결정이었습니다.

Y.Doc (데이터베이스 행)
├── Map('columnData')    ← 동적 키-값 쌍 (컬럼ID → 값)
├── Map('metadata')      ← AI 생성 컬럼 추적 등 부가 정보
└── Text('content')      ← 메모 영역

할 일과 달리 Y.Text를 별도로 두고, 컬럼 데이터는 Y.Map에 스키마 ID를 키로 넣었어요. 서버의 저장 훅에서는 columnData Map 전체를 객체로 변환해서 JSON 컬럼에 저장하는 방식이에요.

여기서 한 가지 주의할 점은, Y.Map의 키가 삭제되었을 때(컬럼 삭제)의 동기화예요. 클라이언트 effect에서 "현재 폼 데이터에 없는 키는 Y.Map에서도 삭제"하는 로직을 넣어야 했습니다.

doc.transact(() => {
  // 폼에 없는 키 삭제
  const keysToDelete = Array.from(columnDataMap.keys()).filter(
    (key) => !(key in formData),
  );
  keysToDelete.forEach((key) => columnDataMap.delete(key));

  // 값 동기화
  Object.entries(formData).forEach(([key, value]) => {
    if (JSON.stringify(columnDataMap.get(key)) !== JSON.stringify(value)) {
      columnDataMap.set(key, value);
    }
  });
}, 'local');

돌아보며 — 다시 한다면 무엇을 바꿀까

잘한 것

  1. 에디터와 메타데이터를 하나의 Y.Doc에 통합한 결정. WebSocket 연결 하나로 모든 필드가 동기화되니, "에디터는 실시간인데 제목은 아직 안 바뀜" 같은 어정쩡한 상태가 없어요.
  2. origin 표시 + 원격 업데이트 플래그 이중 방어. Signal이나 Observable 기반 프레임워크에서 CRDT 양방향 바인딩할 때, 한 겹만으로는 부족하더라고요. 방어 코드가 지저분해 보여도, 무한 루프 한 번 겪으면 이중 안전장치의 가치를 깨닫게 됩니다.
  3. 리스트 뷰의 가시 영역 기반 동적 바인딩. 전체 항목에 연결을 열었으면 성능 문제가 심각했을 거예요.

아쉬운 것

  1. 저장 훅의 필드별 변환 보일러플레이트. 문서 타입이 추가될 때마다 "Y.Doc에서 메타데이터 추출 → DB 컬럼 매핑" 코드가 반복돼요. 스키마 정의에서 자동으로 매핑 코드를 생성하는 구조를 만들었어야 했습니다.
  2. Hocuspocus의 별도 포트 운영. NestJS 앱과 같은 HTTP 서버에서 WebSocket을 처리했으면 리버스 프록시 설정이 더 단순했을 거예요. Hocuspocus가 기존 서버에 attach되는 모드도 지원하는데, 초기에 충분히 검토하지 못했어요.
  3. Y.Doc 바이너리 크기 모니터링 부재. 메타데이터가 누적되면서 바이너리 크기가 지속적으로 커질 수 있는데, GC(Garbage Collection) 전략이나 주기적 스냅샷 압축을 아직 적용하지 못했습니다.

마무리 하며

  1. "에디터 + 메타데이터 통합 Y.Doc" 패턴은 프로젝트 관리 도구처럼 구조화된 필드와 리치 텍스트가 공존하는 앱에서 유효합니다. 단, DB에 이중 저장하는 비용을 감수해야 해요.
  2. 양방향 CRDT 바인딩에서는 반드시 "로컬/원격 구분 플래그"를 둬야 합니다. 값 비교만으로 루프를 막으려 하면 배열/객체 참조 비교에서 실패해요.
  3. 리스트 뷰에서 모든 항목에 실시간 연결을 열지 마세요. 가시 영역 기반으로 연결을 동적 관리하는 게 실용적이에요.