Liveblocks로 실시간 협업 기능 붙이기: Presence, CRDT Storage, Comments, 화이트보드 예제

이 글의 핵심

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

Liveblocks는 Google Docs나 Figma처럼 여러 사람이 동시에 작업하는 UI를 웹 앱에 붙이기 위한 실시간 협업 인프라입니다. WebSocket 연결, 방(Room) 단위 격리, 인증, CRDT 기반 공유 상태, 커서·선택 영역 같은 프레즌스(presence), 스레드 댓글, 알림을 한 스택으로 제공합니다. 이 글은 핵심 개념부터 Presence, Storage, Comments, React·Next.js 통합, 간단한 협업 화이트보드 구현 순서로 설명합니다. 코드 예시는 Liveblocks 2.x의 @liveblocks/react 기준입니다.


Liveblocks가 해결하는 문제

협업 편집을 직접 구현하면 같은 문제를 매번 다시 풀게 됩니다. 연결이 끊겼을 때 백오프하며 재연결하고 중복 이벤트를 걸러야 하고, 두 사용자가 같은 데이터를 동시에 고쳤을 때 충돌을 해결해야 합니다. “누가 온라인이고 어디를 보고 있는지”를 낮은 지연으로 브로드캐스트해야 하며, 방마다 접근 권한과 토큰 만료도 관리해야 합니다.

Liveblocks는 이를 관리형 서비스와 클라이언트 SDK로 묶어 프론트엔드 팀이 제품 로직에 집중하도록 합니다. 백엔드가 맡는 일은 대부분 “이 사용자가 이 방에 들어갈 수 있는가”를 판단해 토큰을 발급하는 정도로 줄어듭니다.


핵심 개념

클라이언트와 공개 키

React를 쓰지 않는 앱은 @liveblocks/client의 createClient로 클라이언트를 만들고, 공개 API 키 또는 인증 엔드포인트를 연결합니다. 공개 키는 개발과 데모에 적합하며, 사용자별 방 접근 제어가 필요하면 서버에서 토큰을 발급하는 방식을 씁니다.

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

export const client = createClient({
  publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!,
  // 프로덕션에서는 authEndpoint: "/api/liveblocks-auth" 로 교체
});

공개 키는 이름 그대로 브라우저에 노출되는 값이지만, 프로젝트마다 다르므로 코드에 하드코딩하기보다 환경 변수로 주입해 개발·스테이징·프로덕션을 구분하는 편이 관리하기 쉽습니다. 서버용 비밀 키(sk_...)는 절대 클라이언트 번들에 들어가면 안 됩니다.

방(Room)

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

인증 모델

공개 키 방식에서는 roomId만 알면 누구나 방에 들어올 수 있습니다. 데모에는 충분하지만 실제 제품에서는 “링크만 알면 모든 문서에 접근”하는 사고로 이어집니다. 토큰 방식에서는 서버가 사용자 ID와 접근 가능한 방, 권한(전체 접근·읽기 전용 등)을 담은 토큰을 서명해 내려주고, 클라이언트는 방에 들어갈 때마다 이 엔드포인트를 호출합니다. Next.js에서는 Route Handler에서 발급하는 구성이 흔하며, 구체적인 코드는 아래 Next.js 절에서 다룹니다.


Presence와 Awareness

Presence란?

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

반면 Storage는 CRDT로 복제되고 서버에 영속되는 협업 상태입니다. “커서는 Presence, 도형 목록은 Storage”처럼 역할을 나누면 유지보수가 쉬워집니다.

Awareness

Yjs 생태계에서는 다른 참가자의 상태를 인지하는 메커니즘을 Awareness라고 부르는데, Liveblocks에서 같은 역할을 하는 것이 Presence입니다. 다른 사용자의 Presence는 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는 연결(탭) 단위 식별자라서 같은 사용자가 탭 두 개를 열면 서로 다른 값이 됩니다. 표시 이름은 other.info를 쓰는데, 이 값은 인증 시 서버가 토큰에 넣어 준 userInfo에서 옵니다. 공개 키 방식에서는 info가 비어 있으므로 위처럼 기본값을 둬야 합니다.

설계 시 주의점

Presence 업데이트는 클라이언트가 자동으로 스로틀해서 보냅니다. createClient나 LiveblocksProvider의 throttle 옵션으로 간격을 조절할 수 있으며, 기본값은 100ms입니다. 커서 움직임이 끊겨 보이면 이 값을 낮추되, 낮출수록 메시지 수가 늘어난다는 점을 감안해야 합니다.

Presence는 같은 방의 모든 참가자에게 그대로 전달되므로 이메일 같은 민감한 정보는 넣지 않는 것이 좋습니다. 표시용 이름이나 아바타 URL은 Presence가 아니라 토큰의 userInfo로 전달하면 클라이언트가 위조할 수도 없습니다.


Storage와 CRDT

왜 CRDT인가?

여러 클라이언트가 같은 JSON 문서를 통째로 덮어쓰는 방식이면 마지막 쓰기만 남고 다른 사람의 수정은 사라집니다. CRDT(Conflict-free Replicated Data Type)는 각 복제본이 연산을 서로 다른 순서로 받아도 같은 상태로 수렴하도록 설계된 자료구조로, 목록·맵·텍스트 협업에 널리 쓰입니다.

Liveblocks Storage는 LiveObject, LiveList, LiveMap으로 트리를 구성합니다. 루트는 항상 LiveObject 하나이며, 그 아래에 도형 목록용 LiveList, 엔티티 조회용 LiveMap 등을 둡니다. LiveObject는 필드 단위로 병합되므로 두 사용자가 같은 도형의 x와 fill을 동시에 바꾸면 두 변경이 모두 남고, 같은 필드를 동시에 바꾸면 한쪽 값으로 정해집니다.

LiveList는 순서가 있는 컬렉션으로 화이트보드의 도형 배열이나 TODO 항목 순서에 씁니다. 동시 삽입·삭제가 일어나면 인덱스가 바뀔 수 있으므로, 도형마다 고유 id를 두고 인덱스가 아닌 id로 찾는 것이 안전합니다. LiveMap은 키-값 맵으로, shapeId → 도형처럼 엔티티를 키로 바로 찾아야 할 때 유리합니다. 도형이 많고 이동이 잦다면 LiveList를 순회하는 대신 LiveMap에 도형을 두고 정렬 순서만 따로 관리하는 구성도 흔합니다.

읽기: useStorage

useStorage는 Storage의 불변 스냅샷을 선택자(selector)로 구독합니다. 선택자의 반환값이 바뀔 때만 리렌더되므로 선택자가 좁을수록 리렌더가 줄어듭니다.

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

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

Storage가 아직 로드되지 않았을 때는 null을 반환하므로 분기 처리가 필요합니다. @liveblocks/react/suspense에서 같은 훅을 가져오면 로딩 중에는 Suspense 경계가 처리하고 훅은 항상 값을 반환합니다.

쓰기: useMutation

Storage는 useMutation으로 만든 콜백 안에서 바꿉니다. 콜백 하나에서 일어난 변경은 한 묶음으로 전송되고, 실행 취소(undo) 기록에도 한 단계로 남습니다.

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",
    })
  );
}, []);

mutation 콜백은 동기적으로 실행되어야 합니다. 서버 응답 같은 비동기 결과가 필요하면 콜백 안에서 기다리지 말고, 응답을 받은 뒤 mutation을 호출하는 편이 예측 가능합니다.

초기 Storage

RoomProvider의 initialStorage로 루트 구조를 선언합니다. 이 값은 방이 처음 만들어질 때만 쓰이고, 이미 존재하는 방은 서버에 저장된 상태를 그대로 따릅니다.

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와의 관계

리치 텍스트 편집기가 필요하면 Storage보다 Liveblocks의 Yjs 통합(@liveblocks/yjs)이나 Tiptap·Lexical용 공식 통합 패키지를 검토합니다. ProseMirror, Tiptap, Monaco 같은 에디터는 Yjs 바인딩이 이미 성숙해 있어서, Liveblocks는 Yjs 문서를 방에 연결하는 동기화 계층 역할을 맡습니다.


Comments와 Notifications

Comments(댓글)

Liveblocks는 스레드 댓글의 데이터 모델과 React UI 컴포넌트(@liveblocks/react-ui의 Thread, Composer 등)를 제공합니다. 스레드, 해결(resolved) 상태, 멘션, 반응(reaction)을 기본으로 다루며, 데이터 접근은 useThreads, useCreateThread 같은 훅으로 합니다. 예전 버전에서는 UI 패키지 이름이 @liveblocks/react-comments였으므로 오래된 예제를 볼 때 주의해야 합니다.

직접 결정해야 하는 부분은 댓글을 어디에 붙일지(도형 id, 텍스트 앵커, 캔버스 좌표 등)입니다. 스레드에는 임의의 metadata를 붙일 수 있으므로 앵커 정보를 여기에 저장하는 방식이 일반적입니다. 작성·삭제·해결 권한을 누구에게 줄지, 감사 로그가 필요한지도 함께 정해야 합니다. 댓글 데이터는 Liveblocks에 저장되므로, 멘션과 작성자 표시가 맞게 동작하려면 토큰에 넣는 userId를 앱의 사용자 ID와 일치시켜야 합니다.

Notifications(알림)

멘션이나 댓글 답글이 생기면 Liveblocks가 사용자별 인박스 알림을 만들고, useInboxNotifications 같은 훅과 @liveblocks/react-ui의 인박스 컴포넌트로 앱 안에 표시할 수 있습니다. 이메일이나 Slack으로 보내려면 Liveblocks 웹훅을 받아 자체 발송 파이프라인으로 넘기는 구성이 일반적입니다. 웹훅 이벤트 종류와 페이로드는 버전에 따라 바뀌므로 공식 문서의 Notifications·Webhooks 항목을 기준으로 구현해야 합니다.

웹훅 처리에서 중요한 점은 수신 엔드포인트가 빨리 200을 반환하고 실제 발송은 큐(SQS, Cloud Tasks 등)로 넘겨 재시도할 수 있게 하는 것입니다. 웹훅 요청의 서명 검증도 빠뜨리면 안 됩니다.


React 통합

Provider 구성

Liveblocks 2.x에서는 LiveblocksProvider에 publicApiKey나 authEndpoint를 직접 넘기고, 협업이 필요한 화면에서만 RoomProvider를 엽니다. 앱 전체를 한 방으로 묶으면 협업과 무관한 페이지에서도 연결이 유지되므로, 문서나 캔버스 페이지에서만 Room을 여는 편이 연결 수와 메모리 면에서 유리합니다.

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

export function AppRoot({ children }: { children: React.ReactNode }) {
  return (
    <LiveblocksProvider authEndpoint="/api/liveblocks-auth">
      {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>
  );
}

Presence와 Storage의 타입은 liveblocks.config.ts에서 전역 Liveblocks 인터페이스를 선언해 지정합니다. 이렇게 하면 useStorage((root) => root.shapes) 같은 선택자와 initialStorage가 타입 검사를 받습니다.

자주 쓰는 훅

훅용도
useStorageStorage 읽기(불변 스냅샷)
useMutationStorage 쓰기
useOthers, useOthersMapped다른 사용자 Presence
useSelf내 Presence·연결 정보
useUpdateMyPresence, useMyPresence내 Presence 갱신
useUndo, useRedo, useHistoryStorage 변경 실행 취소·다시 실행
useRoom저수준 Room 객체 접근

Next.js 통합

App Router와 'use client'

App Router에서는 Liveblocks Provider와 훅을 쓰는 컴포넌트를 클라이언트 컴포넌트로 분리합니다. 서버 컴포넌트에서는 RoomProvider를 쓸 수 없으므로, 페이지는 서버에서 roomId만 결정하고 클라이언트 래퍼에 넘깁니다.

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

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

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

클라이언트 컴포넌트도 처음에는 서버에서 HTML로 렌더링되지만, Liveblocks의 WebSocket 연결은 브라우저에서만 열리므로 대개 next/dynamic으로 따로 감쌀 필요는 없습니다. 서버 렌더링 시점에는 Storage가 없어 useStorage가 null을 반환한다는 점만 처리하면 됩니다.

인증: Route Handler에서 토큰 발급

서버에서는 @liveblocks/node 패키지와 비밀 키로 세션을 만들고, 사용자가 접근할 수 있는 방을 허용한 뒤 토큰을 발급합니다. 클라이언트는 방에 들어갈 때 { room }을 본문에 담아 이 엔드포인트에 POST합니다.

// app/api/liveblocks-auth/route.ts
import { Liveblocks } from "@liveblocks/node";

const liveblocks = new Liveblocks({
  secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});

export async function POST(request: Request) {
  const user = await getCurrentUser(request); // 앱 자체 세션 조회
  if (!user) {
    return new Response("Unauthorized", { status: 401 });
  }

  const { room } = await request.json();
  if (!(await userCanAccessRoom(user.id, room))) {
    return new Response("Forbidden", { status: 403 });
  }

  const session = liveblocks.prepareSession(user.id, {
    userInfo: { name: user.name, avatar: user.avatarUrl },
  });
  session.allow(room, session.FULL_ACCESS); // 읽기 전용이면 session.READ_ACCESS

  const { status, body } = await session.authorize();
  return new Response(body, { status });
}

getCurrentUser와 userCanAccessRoom은 앱에서 구현해야 하는 함수입니다. 여기서 방 접근 여부를 판단하지 않고 무조건 allow하면 토큰 방식을 쓰는 의미가 없어지므로, 이 검사가 인증 엔드포인트의 핵심입니다. 방에 권한을 미리 저장해 두는 ID 토큰 방식(identifyUser)도 있으며, 권한 모델이 복잡하면 그쪽이 관리하기 쉬울 수 있습니다.


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

요구사항 나누기

도형 목록은 Storage의 LiveList<LiveObject<Shape>>에 둡니다. 선택한 도구는 다른 사람에게 보여 줄 수는 있지만 저장할 필요는 없으므로 Presence의 tool 필드에 둡니다.

뷰포트(팬·줌)를 공유할지는 제품 정책에 따라 다릅니다. 각자 자유롭게 둘러보는 협업 리뷰라면 뷰포트는 로컬 상태로 두고 Presence에 “보고 있는 영역”만 표시하는 편이 낫고, 진행자가 화면을 이끄는 퍼실리테이션 도구라면 Storage에 뷰포트를 넣기도 합니다.

실행 취소는 Liveblocks가 기본으로 제공합니다. useUndo와 useRedo는 내가 만든 Storage 변경만 되돌리며 다른 사람의 변경은 건드리지 않습니다. Presence 변경은 updateMyPresence(..., { addToHistory: true })로 명시한 경우에만 기록에 들어갑니다. 드래그처럼 연속된 변경을 한 단계로 묶으려면 드래그 시작 시 history.pause(), 끝날 때 history.resume()을 호출합니다.

화이트보드 컴포넌트 골격

"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");
    const item = list.find((shape) => shape.get("id") === id);
    if (!item) return;
    item.update({ x: item.get("x") + dx, y: item.get("y") + dy });
  }, []);

  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) => {
        // 캔버스 기준 좌표로 변환해야 다른 사용자 화면에서도 같은 위치에 그려진다
        const rect = e.currentTarget.getBoundingClientRect();
        updateMyPresence({
          cursor: { x: e.clientX - rect.left, y: e.clientY - rect.top },
        });
      }}
      onPointerLeave={() => updateMyPresence({ cursor: null })}
    >
      <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>
  );
}

이 예시는 동작 원리를 보여 주는 최소 구현입니다. 드래그 중 포인터 이벤트마다 mutation이 실행되므로 실행 취소 기록이 이벤트 수만큼 쌓이는데, 위에서 설명한 history.pause()/resume()으로 드래그 한 번을 한 단계로 묶어야 합니다. 실서비스라면 히트 테스트, 다중 선택, 키보드 이동, 터치 제스처, 많은 도형에 대한 렌더링 최적화, 키보드 접근성도 추가해야 합니다.

좌표계 주의

clientX/clientY는 브라우저 뷰포트 기준이라 사용자마다 스크롤 위치나 창 크기가 다르면 같은 값이 다른 지점을 가리킵니다. 위 예시처럼 캔버스 요소 기준으로 변환한 뒤 Presence나 Storage에 기록해야 하고, 줌·팬이 있으면 그 변환까지 역으로 적용한 “캔버스 월드 좌표”를 저장해야 합니다. 이 변환을 빼먹으면 다른 사용자 화면에서 커서와 도형 위치가 어긋납니다.


운영 시 주의할 점

Storage에 이미 데이터가 있는 방은 initialStorage를 다시 적용하지 않습니다. 도형 타입에 필드를 추가하면 기존 방의 도형에는 그 필드가 없으므로, 읽을 때 기본값을 채우거나 선택적 필드로 다루는 마이그레이션 코드가 필요합니다.

Presence는 자주 바뀌고 방의 모든 참가자에게 전달되므로 작게 유지해야 합니다. 큰 객체를 Presence에 넣으면 업데이트할 때마다 네트워크 전송량과 모든 참가자의 리렌더 비용이 함께 늘어납니다.

테스트할 때는 좌표 변환이나 id 생성 같은 순수 함수를 분리해 단위 테스트하고, Room 동작은 개발용 프로젝트 키로 브라우저 두 개를 띄워 확인하는 방식이 현실적입니다. userInfo에 실명이나 이메일을 넣을지, 이니셜만 넣을지는 같은 방의 모든 참가자에게 노출된다는 점을 기준으로 프라이버시 정책에 맞춰 정합니다.


트러블슈팅

useStorage가 계속 null

Room이 아직 연결되지 않았거나 인증 엔드포인트가 실패한 경우입니다. 브라우저 개발자 도구에서 인증 요청의 응답 코드를 먼저 확인합니다. RoomProvider 바깥에서 훅을 호출하면 null이 아니라 오류가 발생하므로, 그 경우에는 트리 구조를 확인합니다.

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

두 클라이언트의 roomId가 실제로 같은지, 서로 다른 프로젝트의 API 키를 쓰고 있지 않은지 확인합니다. 개발·스테이징 키를 환경 변수로 분리해 두면 이런 실수가 줄어듭니다.

성능 저하

useStorage 선택자가 루트 전체를 반환하고 있으면 어떤 변경이든 해당 컴포넌트가 리렌더되므로, 필요한 부분만 선택하도록 좁힙니다. 커서 렌더링이 무겁다면 Presence throttle 값을 늘리고, 도형이 매우 많으면 화면 밖 도형을 그리지 않는 뷰포트 컬링을 고려합니다.


같이 보면 좋은 글