Liveblocks 완벽 가이드 — 실시간 협업 플랫폼·CRDT·React·Next.js

Liveblocks 완벽 가이드 — 실시간 협업 플랫폼·CRDT·React·Next.js

이 글의 핵심

Liveblocks는 문서·디자인·화이트보드 등 여러 사용자가 동시에 편집하는 경험을 손쉽게 붙일 수 있는 실시간 협업 백엔드입니다. Presence, CRDT 기반 Storage, 댓글·알림, React·Next.js 연동까지 한 번에 다룹니다.

이 글에서 다루는 내용

Liveblocks는 Google Docs나 Figma처럼 여러 사람이 동시에 작업하는 UI를 웹 앱에 붙이기 위한 실시간 협업 인프라입니다. WebSocket 연결, 방(Room) 단위 격리, 인증, CRDT 기반 공유 상태, 커서·선택 영역 같은 프레즌스(presence), 스레드 댓글, 알림까지 한 스택으로 제공합니다.

이 글은 다음을 순서대로 설명합니다.

  • Liveblocks의 핵심 개념(클라이언트, 방, 인증)
  • PresenceAwareness(다른 사람의 커서·상태 인지)
  • StorageCRDT(LiveObject, LiveList, LiveMap)
  • CommentsNotifications
  • ReactNext.js 통합 패턴
  • 실전 협업 화이트보드를 만드는 흐름

문자 그대로 “완벽”을 목표로, 왜 이런 추상화가 존재하는지와 운영 시 주의점까지 포함했습니다.


Liveblocks가 해결하는 문제

협업 편집을 직접 구현하면 다음이 반복됩니다.

  1. 연결 관리: 재연결, 백오프, 중복 이벤트
  2. 상태 합치기: 두 사용자가 동시에 수정했을 때 충돌 해결
  3. 프레즌스: “누가 온라인인지, 어디를 보고 있는지”를 낮은 지연으로 브로드캐스트
  4. 보안: 방마다 접근 권한, 토큰 만료

Liveblocks는 이를 관리형 서비스 + 클라이언트 SDK로 묶어, 프론트엔드 팀이 제품 로직에 집중하도록 합니다. 백엔드는 “토큰만 잘 발급하면 된다”는 수준으로 줄어드는 경우가 많습니다.


핵심 개념

클라이언트와 공개 키

앱은 @liveblocks/clientcreateClient로 클라이언트를 만들고, 공개 API 키 또는 인증 엔드포인트를 연결합니다. 공개 키만으로는 개발·데모에 적합하고, 사용자별 방 접근이 필요하면 서버에서 JWT 등 토큰을 내려주는 패턴이 표준입니다.

import { createClient } from "@liveblocks/client";

export const liveblocks = createClient({
  publicApiKey: "pk_dev_xxxxxxxx",
  // 프로덕션에서는 authEndpoint 또는 커스텀 인증으로 교체
});

위 코드는 “이 브라우저 세션이 Liveblocks 클라우드와 어떻게 말할지”를 정의합니다. publicApiKey는 저장소에 커밋하지 말고, 환경 변수(NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY 등)로 주입하는 것이 안전합니다.

방(Room)

Room은 협업의 경계입니다. 같은 roomId에 연결된 클라이언트만 Storage와 Presence를 공유합니다. 문서 한 편, 캔버스 한 장, 디자인 파일 하나에 대응시키는 경우가 많습니다. roomId는 URL 파라미터나 DB의 문서 ID와 1:1로 매핑하면 추적이 쉽습니다.

인증 모델

  • 공개 키: 빠른 시작. 누구나 roomId를 알면 입장할 수 있게 설계된 데모에 적합합니다.
  • 토큰 기반: 사용자 ID, 방 ID, 권한(읽기 전용 등)을 서버에서 서명한 토큰을 내려줍니다. Next.js에서는 Route Handler에서 발급하는 패턴이 흔합니다.

인증을 붙이면 “링크만 알면 모든 문서에 접근”하는 사고를 막을 수 있습니다. 엔터프라이즈 제품에서는 거의 항상 토큰 방식이 필요합니다.


Presence와 Awareness

Presence란?

Presence는 Storage에 저장되지 않는 일시적(ephemeral) 상태입니다. 예: 마우스 커서 좌표, 선택 중인 도구, 현재 보고 있는 슬라이드 번호. 연결이 끊기면 사라져도 되는 정보에 적합합니다.

반면 Storage는 CRDT로 복제되는 영속 협업 상태입니다. 둘을 섞지 않는 것이 유지보수에 유리합니다. “커서는 Presence, 도형 목록은 Storage”처럼 역할을 나누세요.

Awareness

문서에서는 Awareness라는 말로 “다른 참가자의 Presence를 인지하는 메커니즘”을 가리키기도 합니다. Liveblocks에서는 useOthers, useOthersMapped 등으로 다른 사용자의 프레즌스를 구독합니다. “누가 타이핑 중인지” 같은 UI는 Presence 필드만 추가하면 됩니다.

// 개념 예시: 내 커서 위치를 Presence로 broadcast
import { useUpdateMyPresence, useOthers } from "@liveblocks/react";

function Cursors() {
  const updateMyPresence = useUpdateMyPresence();
  const others = useOthers();

  function onPointerMove(e: React.PointerEvent) {
    updateMyPresence({ cursor: { x: e.clientX, y: e.clientY } });
  }

  return (
    <div onPointerMove={onPointerMove} className="h-screen w-full">
      {others.map((other) =>
        other.presence.cursor ? (
          <div
            key={other.connectionId}
            style={{
              position: "fixed",
              left: other.presence.cursor.x,
              top: other.presence.cursor.y,
            }}
          >
            {other.info?.name ?? "익명"}
          </div>
        ) : null
      )}
    </div>
  );
}

위 예시에서 updateMyPresence내 Presence만 갱신하고, useOthers다른 연결의 스냅샷을 돌려줍니다. connectionId는 세션 단위 식별자로, 사용자 ID와 다를 수 있으므로 표시 이름은 other.info에 매핑해 두는 것이 좋습니다.

설계 팁

  • Presence 업데이트는 너무 자주 보내면 네트워크 비용이 커집니다. requestAnimationFrame 스로틀, 또는 16~33ms 간격으로 샘플링하는 것이 일반적입니다.
  • 민감한 정보(이메일 전체 등)는 Presence에 넣지 말고, 서버에서 내려준 표시용 이름·아바타 URL만 넣습니다.

Storage와 CRDT

왜 CRDT인가?

여러 클라이언트가 동시에 같은 JSON을 수정하면 마지막 쓰기 승리(LWW)로는 필연적으로 데이터가 날아갑니다. CRDT(Conflict-free Replicated Data Type)는 연산의 순서가 달라도 수렴하는 자료구조로, 텍스트·목록·트리 협업에 널리 쓰입니다.

Liveblocks Storage는 CRDT 기반의 LiveObject, LiveList, LiveMap으로 트리를 구성합니다. 루트는 보통 LiveObject 하나로 두고, 그 아래에 도형 배열 LiveList, 사용자별 설정 LiveMap 등을 매달습니다.

LiveObject

필드 이름으로 중첩 객체를 표현합니다. 한 필드 단위로 병합 가능한 속성(예: x, y, fill)을 넣기 좋습니다.

LiveList

순서가 있는 컬렉션입니다. 화이트보드의 도형 배열, TODO 리스트의 항목 순서 등에 사용합니다. 배열 인덱스에 의미를 두면 충돌 시 흔들릴 수 있으므로, 도형에는 고유 id를 두고 렌더링 시 정렬하는 패턴도 자주 씁니다.

LiveMap

키-값 맵입니다. userId → 커서 색처럼 엔티티를 키로 빠르게 찾을 때 유리합니다.

읽기: useStorage

useStorage불변 스냅샷을 선택자(selector)로 구독합니다. 선택자가 좁을수록 리렌더가 줄어듭니다.

import { useStorage } from "@liveblocks/react";

function ShapeCount() {
  const count = useStorage((root) => root.shapes.length);
  if (count == null) return null;
  return <p>도형 개수: {count}</p>;
}

로딩 직후에는 null일 수 있어 분기 처리가 필요합니다. Suspense 변형(@liveblocks/react/suspense)을 쓰면 로딩 경계를 명시적으로 나눌 수 있습니다.

쓰기: useMutation

Storage는 반드시 mutation 안에서만 바꿉니다. React의 setState와 비슷하게, 동시성 안전한 업데이트 경로를 강제합니다.

import { useMutation } from "@liveblocks/react";
import { LiveObject } from "@liveblocks/client";

const addRect = useMutation(({ storage }, x: number, y: number) => {
  const shapes = storage.get("shapes");
  shapes.push(
    new LiveObject({
      id: crypto.randomUUID(),
      type: "rect",
      x,
      y,
      width: 120,
      height: 80,
      fill: "#3b82f6",
    })
  );
}, []);

useMutation의 콜백은 동기적으로 storage를 수정합니다. 비동기 I/O를 이 안에서 기다리기보다, 서버 응답 후 mutation을 한 번 더 호출하는 편이 예측 가능합니다.

초기 Storage

RoomProviderinitialStorage에서 루트 구조를 선언합니다. 처음 방이 생성될 때만 쓰이며, 이미 존재하는 방은 서버 상태를 따릅니다.

import { RoomProvider } from "@liveblocks/react";
import { LiveList, LiveObject } from "@liveblocks/client";

<RoomProvider
  id={roomId}
  initialPresence={{ cursor: null, tool: "select" }}
  initialStorage={{
    shapes: new LiveList([]),
    viewport: new LiveObject({ x: 0, y: 0, zoom: 1 }),
  }}
>
  {children}
</RoomProvider>

Yjs와의 관계

문서 편집기처럼 리치 텍스트가 필요하면 Liveblocks의 Yjs 통합을 검토합니다. 같은 CRDT 계열이지만, ProseMirror·TipTap·Monaco 등과 붙일 때 Yjs 생태계가 성숙해 있습니다. Liveblocks는 Yjs 문서를 방에 바인딩하는 흐름을 문서화하고 있으므로, 에디터 제품은 해당 가이드를 따르는 것이 안전합니다.


Comments와 Notifications

Comments(댓글)

협업 도구에서 “이 요소에 달린 스레드 댓글”은 제품 신뢰도에 큰 영향을 줍니다. Liveblocks는 @liveblocks/react-comments 등 댓글 UI 컴포넌트와 데이터 모델을 제공하며, 스레드·해결(resolved) 상태·멘션 같은 패턴을 기본으로 다룹니다.

실무에서는 다음을 결정해야 합니다.

  • 댓글이 어떤 대상에 붙는지(도형 id, 텍스트 앵커, 픽셀 좌표 등)
  • 권한: 댓글 작성·삭제·해결을 누구에게 허용할지
  • 감사: 법적 요구가 있으면 서버에 로그를 남길지

댓글 데이터는 Liveblocks가 보관하고, 앱의 사용자 디렉터리와 userId를 맞추는 설계가 중요합니다.

Notifications(알림)

멘션·배정·초대 등 이벤트가 발생했을 때 이메일·인앱 알림으로 확장하려면, Liveblocks의 알림·웹훅(Webhook) 기능과 자체 알림 파이프라인을 연결하는 경우가 많습니다. 정확한 엔드포인트와 페이로드는 제품 업데이트에 따라 변하므로, 공식 문서의 Notifications / Webhooks 섹션을 기준으로 구현하세요.

일반적인 아키텍처는 다음과 같습니다.

  1. Liveblocks(또는 앱 서버)에서 이벤트 발생
  2. 큐(SQS, Cloud Tasks 등)에 넣어 재시도 가능하게 처리
  3. 이메일·Slack·인앱 알림 DB에 기록

React 통합

Provider 구성

LiveblocksProvider로 클라이언트를 주입하고, 화면 단위로 RoomProvider를 둡니다. 앱 전체를 한 방으로 묶지 말고, 문서/캔버스 페이지에서만 Room을 여는 것이 메모리·연결 수에 유리합니다.

import { LiveblocksProvider, RoomProvider } from "@liveblocks/react";
import { LiveList } from "@liveblocks/client";
import { liveblocks } from "./liveblocks";

export function AppRoot({ children }: { children: React.ReactNode }) {
  return <LiveblocksProvider client={liveblocks}>{children}</LiveblocksProvider>;
}

export function DocumentRoom({
  roomId,
  children,
}: {
  roomId: string;
  children: React.ReactNode;
}) {
  return (
    <RoomProvider
      id={roomId}
      initialPresence={{ cursor: null }}
      initialStorage={{
        items: new LiveList([]),
      }}
    >
      {children}
    </RoomProvider>
  );
}

실제 프로젝트에서는 LiveList를 파일 상단에서 import하고, initialStorage를 공용 팩토리 함수로 빼면 테스트가 쉬워집니다.

자주 쓰는 훅

용도
useStorageStorage 읽기(불변 스냅샷)
useMutationStorage 쓰기
useOthers, useOthersMapped다른 사용자 Presence
useSelf나의 Presence·connection 정보
useUpdateMyPresencePresence 갱신
useRoom저수준 Room API(구독 해제 등)

Next.js 통합

App Router와 'use client'

Next.js 13+ App Router에서는 Liveblocks 트리를 클라이언트 컴포넌트로 격리합니다. 서버 컴포넌트에서 RoomProvider를 직접 쓰지 마세요. 대신 페이지에서 roomId만 서버에서 결정하고, 자식으로 클라이언트 래퍼를 넘깁니다.

// app/board/[roomId]/BoardClient.tsx
"use client";

import { RoomProvider } from "@liveblocks/react";
import { LiveList } from "@liveblocks/client";
import { Whiteboard } from "@/components/Whiteboard";

export function BoardClient({ roomId }: { roomId: string }) {
  return (
    <RoomProvider
      id={roomId}
      initialPresence={{ cursor: null, tool: "pen" }}
      initialStorage={{
        shapes: new LiveList([]),
      }}
    >
      <Whiteboard />
    </RoomProvider>
  );
}

인증: Route Handler에서 토큰 발급

// app/api/liveblocks-auth/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const session = await getSession(req); // 자체 세션
  if (!session) {
    return new NextResponse("Unauthorized", { status: 401 });
  }

  const { room } = await req.json();

  const canAccess = await userCanAccessRoom(session.userId, room);
  if (!canAccess) {
    return new NextResponse("Forbidden", { status: 403 });
  }

  const token = await generateLiveblocksToken({
    userId: session.userId,
    userInfo: { name: session.name },
    roomId: room,
  });

  return NextResponse.json({ token });
}

generateLiveblocksToken은 예시 이름입니다. 실제로는 Liveblocks 대시보드에서 안내하는 서버 SDK 또는 JWT 규격에 맞춰 토큰을 만듭니다. 클라이언트에서는 createClient({ authEndpoint: "/api/liveblocks-auth" })처럼 연결합니다.

동적 임포트

SSR 시 WebSocket 관련 코드가 서버에서 실행되지 않도록, 필요하면 next/dynamic으로 보드 컴포넌트를 로드합니다.


실전: 협업 화이트보드 앱 구축

요구사항을 나누기

  1. 도형 목록: LiveList<LiveObject<Shape>>
  2. 뷰포트(팬/줌): 모든 사용자에게 공유할지, 로컬만 할지 제품 정책 결정
    • 협업 리뷰: 보통 로컬 뷰포트 + Presence에 “보고 있는 영역”만 표시
    • 단일 캔버스 퍼실리테이션: Storage에 뷰포트를 넣기도 함
  3. 도구 모드: Presence의 tool 필드
  4. Undo/Redo: Liveblocks의 히스토리 API(제품 버전에 따라 제공) 또는 앱 레벨 커맨드 패턴

화이트보드 컴포넌트 골격

"use client";

import { useStorage, useMutation, useOthers, useUpdateMyPresence } from "@liveblocks/react";
import { LiveObject } from "@liveblocks/client";

type Shape = {
  id: string;
  type: "rect";
  x: number;
  y: number;
  width: number;
  height: number;
  fill: string;
};

function useWhiteboard() {
  const shapes = useStorage((root) => root.shapes);
  const others = useOthers();
  const updateMyPresence = useUpdateMyPresence();

  const addShape = useMutation(({ storage }) => {
    const list = storage.get("shapes");
    list.push(
      new LiveObject<Shape>({
        id: crypto.randomUUID(),
        type: "rect",
        x: 40,
        y: 40,
        width: 160,
        height: 100,
        fill: "#22c55e",
      })
    );
  }, []);

  const moveShape = useMutation(({ storage }, id: string, dx: number, dy: number) => {
    const list = storage.get("shapes");
    for (let i = 0; i < list.length; i++) {
      const item = list.get(i);
      if (item.get("id") === id) {
        item.set("x", item.get("x") + dx);
        item.set("y", item.get("y") + dy);
        break;
      }
    }
  }, []);

  return { shapes, others, updateMyPresence, addShape, moveShape };
}

export function WhiteboardCanvas() {
  const { shapes, others, updateMyPresence, addShape, moveShape } = useWhiteboard();

  if (shapes == null) {
    return <div className="p-4 text-sm text-neutral-500">동기화 중…</div>;
  }

  return (
    <div
      className="relative h-[600px] w-full overflow-hidden border border-neutral-200 bg-neutral-50"
      onPointerMove={(e) =>
        updateMyPresence({
          cursor: { x: e.clientX, y: e.clientY },
        })
      }
    >
      <div className="absolute left-2 top-2 flex gap-2">
        <button
          type="button"
          className="rounded bg-black px-3 py-1 text-sm text-white"
          onClick={() => addShape()}
        >
          사각형 추가
        </button>
      </div>

      {shapes.map((shape) =>
        shape.type === "rect" ? (
          <button
            key={shape.id}
            type="button"
            className="absolute box-border border border-neutral-400"
            style={{
              left: shape.x,
              top: shape.y,
              width: shape.width,
              height: shape.height,
              backgroundColor: shape.fill,
            }}
            onPointerDown={(e) => {
              e.currentTarget.setPointerCapture(e.pointerId);
            }}
            onPointerMove={(e) => {
              if (e.buttons !== 1) return;
              moveShape(shape.id, e.movementX, e.movementY);
            }}
          />
        ) : null
      )}

      {others.map((o) =>
        o.presence.cursor ? (
          <div
            key={o.connectionId}
            className="pointer-events-none absolute h-4 w-4 rounded-full border-2 border-red-500"
            style={{ left: o.presence.cursor.x, top: o.presence.cursor.y }}
          />
        ) : null
      )}
    </div>
  );
}

위 예시는 교육용 최소 구현입니다. 실서비스에서는 히트 테스트, 선택(selection), 키보드 이동, 터치 제스처, 성능(가상화), 접근성(키보드 포커스)을 확장해야 합니다.

좌표계 주의

clientX/clientY뷰포트 기준입니다. 캔버스에 줌·팬이 있으면 좌표를 캔버스 로컬로 변환한 뒤 Storage에 기록해야 합니다. 변환을 빼먹으면 다른 사용자 화면에서 도형 위치가 어긋납니다.


모범 사례와 함정

Storage 스키마 버전업

도형에 필드를 추가할 때, 기존 방에 기본값이 없는 필드가 생기면 렌더 오류가 날 수 있습니다. 읽기 시 마이그레이션을 넣거나, 선택적 필드를 명시적으로 처리하세요.

Presence 크기

Presence는 자주 바뀌므로 작게 유지합니다. 큰 JSON을 Presence에 넣는 순간 네트워크와 렌더 비용이 함께 증가합니다.

테스트

  • 단위 테스트: 순수 함수(좌표 변환, id 생성)를 분리
  • 통합 테스트: Liveblocks 테스트 환경 또는 목(mock) 클라이언트로 Room 시나리오 검증

개인정보

userInfo에 실명·이메일을 넣을지, 이니셜만 넣을지 프라이버시 정책에 맞춰 결정합니다.


트러블슈팅

useStorage가 끝없이 null

Room이 아직 연결되지 않았거나, RoomProvider 밖에서 훅을 호출한 경우입니다. 트리 상위에 Provider가 있는지, roomId가 유효한지 확인합니다.

다른 사람의 변경이 보이지 않음

roomId가 다른지, 다른 프로젝트의 API 키를 쓰고 있지 않은지 확인합니다. 개발·스테이징 키를 분리하면 이런 실수가 줄어듭니다.

성능 저하

  • useStorage 선택자가 루트 전체를 구독하고 있지 않은지 확인합니다.
  • Presence 업데이트를 스로틀합니다.
  • 도형이 매우 많으면 뷰포트 컬링·단순화된 원격 표시를 고려합니다.

정리

Liveblocks는 실시간 협업 제품을 만들 때 반복되는 인프라 문제를 크게 줄여 줍니다. Presence로 “함께 있는 느낌”을 만들고, CRDT Storage로 편집 충돌을 관리하며, Comments·Notifications로 의사결정 흐름을 완성할 수 있습니다. React·Next.js에서는 클라이언트 경계토큰 발급 API만 정리하면 프로덕션 구조로 확장할 수 있습니다.

처음에는 공개 키로 프로토타입을 만든 뒤, 사용자 단위 권한이 필요해지는 시점에 인증 엔드포인트를 도입하는 로드맵을 추천합니다. 공식 문서의 업그레이드 가이드는 메이저 버전 전환 시 반드시 확인하세요.

배포 전에는 git add, git commit, git pushnpm run deploy를 실행하는 워크플로를 유지하면 Cloudflare Pages와 저장소 상태가 일치합니다.