[실무 기록] 폼 실시간 공유 3편 – Yjs Awareness로 상대 커서,선택 상태 보여주기

2025. 9. 8. 13:33·FrontEnd/JavaScript

1편에서 왜 CRDT·Yjs·WebSocket을 파게 됐는지 정리했고
2편에서 WebSocket 서버 구조랑 회사 네임스페이스에 적응한 기록을 남겼다.
3편은 이제 그 위에 올라가는 UX 레이어 이야기다.
"실제로 폼 화면에서 상대가 뭘 하고 있는지 어떻게 보여줬는지" 정리해 둔다.


0. 이 글에서 다루는 범위

이 글은 Yjs 기본 개념이나 Y.Doc, Y.Map 같은 타입 설명을 다시 하지 않는다.

이미 전제는 이렇다.

  • Yjs로 폼 상태를 동기화하고 있고
  • WebSocket 서버를 통해 델타를 잘 주고받는 상태다

여기서 한 발 더 나가서 아래를 다룬다.

  • 누가 폼을 보고 있는지
  • 상대가 지금 어느 필드를 편집 중인지
  • 동시에 수정할 때 "부딪혔다"는 느낌을 어떻게 줄일지
  • "내가 뭘 하고 있는지"를 Awareness에 어떻게 싱크할지

즉, 데이터 동기화보다는
실시간 협업 UX 쪽에 초점을 맞춘 기록이다.


1. Awareness로 공유한 정보 정하기

Awareness는 요약하면
"지금 연결된 클라이언트들에 대한 가벼운 상태 정보"를 주고받는 용도다.

실제로는 아래 정도를 공유했다.

  • userId
  • displayName 혹은 닉네임
  • role
    예: "mentor" 또는 "mentee"
  • currentField
    현재 커서를 두고 있는 필드 이름
    예: "name", "birthDate", "question"
  • typing
    현재 타이핑 중인지 여부(true/false)

형식은 대략 이런 느낌으로 잡았다.

type AwarenessState = {
  userId: string;
  displayName: string;
  role: "mentor" | "mentee";
  currentField: string | null;
  typing: boolean;
};

그리고 Yjs Awareness 객체에는 이걸 그대로 넣지 않고,
필요한 필드만 골라서 setLocalState에 넘겼다.

awareness.setLocalState({
  userId,
  displayName,
  role,
  currentField,
  typing,
});

중요한 건
"모든 상태를 Awareness로 보내지 않는다"는 거다.

  • 실제 폼 데이터
    예: input 값, 체크박스 선택 여부
    → Yjs 문서(Y.Map, Y.Text)로 관리
  • "누가 어디를 보고 있다/입력 중이다" 같은 가벼운 정보
    → Awareness로 관리

이렇게 역할을 나눠야 나중에 구조가 덜 꼬인다.


2. 인풋 포커스와 Awareness 연동

가장 먼저 붙인 것은 현재 포커스가 어디 있는지 공유하는 기능이었다.

2.1 폼 필드 이름 규칙

Awareness에 currentField를 넣으려면
각 필드를 명확히 식별할 수 있어야 한다.

그래서 각 폼 필드에 이름을 하나씩 붙였다.

  • "application_reason"
  • "preferred_schedule"
  • "student_name"
  • "student_phone"

이런 식으로 키를 먼저 정해 두고,
다음처럼 상수로 관리했다.

const FORM_FIELD_KEYS = {
  APPLICATION_REASON: "application_reason",
  PREFERRED_SCHEDULE: "preferred_schedule",
  STUDENT_NAME: "student_name",
  STUDENT_PHONE: "student_phone",
} as const;

2.2 포커스 이벤트에서 Awareness 갱신

각 인풋에 onFocus, onBlur를 연결해서
Awareness의 currentField를 업데이트했다.

function handleFocus(fieldKey: string) {
  const prev = awareness.getLocalState() || {};
  awareness.setLocalState({
    ...prev,
    currentField: fieldKey,
    typing: false,
  });
}

function handleBlur() {
  const prev = awareness.getLocalState() || {};
  awareness.setLocalState({
    ...prev,
    currentField: null,
    typing: false,
  });
}

각 인풋에서는 이런 식으로 사용했다.

<input
  name="student_name"
  onFocus={() => handleFocus(FORM_FIELD_KEYS.STUDENT_NAME)}
  onBlur={handleBlur}
/>

이렇게만 해도
다른 단말에서는 "상대가 지금 어느 필드를 보고 있는지"를 표시할 수 있다.


3. 타이핑 상태 표시

포커스만 공유하면 뭔가 아쉽다.
"거기 칸을 보고 있긴 한데, 진짜 쓰고 있는 건지"는 알 수 없다.

그래서 typing 상태도 같이 공유했다.

3.1 입력 이벤트에서 typing 토글

간단하게는 onInput이나 onChange에서
typing: true를 보내면 된다.

let typingTimeout: ReturnType<typeof setTimeout> | null = null;

function handleChange(fieldKey: string, value: string) {
  // 실제 값은 Yjs 문서에 기록
  formMap.set(fieldKey, value);

  const prev = awareness.getLocalState() || {};
  awareness.setLocalState({
    ...prev,
    currentField: fieldKey,
    typing: true,
  });

  // 입력이 멈췄다고 판단하는 타이머
  if (typingTimeout) {
    clearTimeout(typingTimeout);
  }
  typingTimeout = setTimeout(() => {
    const latest = awareness.getLocalState() || {};
    awareness.setLocalState({
      ...latest,
      typing: false,
    });
  }, 1000);
}

포인트는 두 가지였다.

  • 실제 값은 Yjs 문서에 넣고
  • Awareness에는 "지금 이 필드를 타이핑 중이다" 정도만 올린다

이렇게 하면
멘티 화면에서 "멘토가 현재 이 칸을 입력 중"이라는 표시를 줄 수 있다.


4. 상대 커서,선택 상태를 UI에 녹이기

Awareness에 사람들이 모여 있으면,
이제 이걸 UI에 어떻게 보여줄지가 고민거리다.

실제 화면에서는 아래 정도를 구현했다.

  • 상대가 보고 있는 필드에 연한 하이라이트
  • 우상단 정도에 "지금 접속 중인 사람 목록"
  • 특정 필드에 "멘토가 작성 중입니다" 같은 작은 표시

4.1 Awareness 변경 구독

우선 Awareness의 변경 이벤트를 구독한다.

import type { Awareness } from "y-protocols/awareness";

function subscribeAwareness(awareness: Awareness, onUpdate: () => void) {
  const handler = () => {
    onUpdate();
  };

  awareness.on("change", handler);

  return () => {
    awareness.off("change", handler);
  };
}

onUpdate에서는
awareness.getStates()로 전체 사용자 상태를 가져와서

  • React 상태나 별도 스토어에 넣고
  • 다시 렌더링하게 만들면 된다.
function getRemoteStates(awareness: Awareness, selfId: number) {
  const states = Array.from(awareness.getStates().entries());

  return states
    .filter(([clientId]) => clientId !== selfId)
    .map(([, state]) => state as AwarenessState);
}

4.2 필드 하이라이트

렌더링 시점에는
각 필드가 Awareness 상에서 "누군가의 currentField인지"를 확인해서

CSS 클래스로 표현했다.

function isRemoteFocused(fieldKey: string, remotes: AwarenessState[]) {
  return remotes.some((s) => s.currentField === fieldKey);
}

// 렌더에서
const remotes = useRemoteAwarenessStates(); // 위에서 만든 getRemoteStates 활용

<input
  name="student_name"
  className={
    isRemoteFocused(FORM_FIELD_KEYS.STUDENT_NAME, remotes)
      ? "border-blue-400 bg-blue-50"
      : ""
  }
  onFocus={() => handleFocus(FORM_FIELD_KEYS.STUDENT_NAME)}
  onBlur={handleBlur}
  onChange={(e) =>
    handleChange(FORM_FIELD_KEYS.STUDENT_NAME, e.target.value)
  }
/>

이 정도만 해도
상대가 현재 어느 입력 칸을 보고 있는지 시각적으로 꽤 잘 전달된다.


5. "부딪힘" UX를 어떻게 가져갈지

실제 사용 시 가장 신경 쓰였던 부분은
"같은 칸을 동시에 바꾸면 어쩌지?"였다.

CRDT 특성상 최종 데이터는 잘 수렴하겠지만,
사용자 입장에서는 "내가 쓰던 내용이 갑자기 바뀐 느낌"을 받을 수 있다.

그래서 완전한 락(lock)까지는 아니더라도
몇 가지 완충 장치를 넣었다.

5.1 부드러운 소프트 락

아래 정도 규칙을 썼다.

  • 같은 필드에 멘토·멘티가 동시에 포커스를 두는 상황
    → 멘티 쪽에 "멘토가 작성 중이라 잠시 후 입력해 주세요" 같은 메시지 표시
    → 입력은 막지 않지만, 시그널은 준다
  • 멘토가 입력 중인 필드
    → 멘티 UI에서 placeholder를 연한 색으로 바꾸고, 옆에 작은 아이콘 표시

코드 레벨에서는 간단했다.

function getFieldStatus(
  fieldKey: string,
  remotes: AwarenessState[]
): "idle" | "remoteFocused" | "remoteTyping" {
  const target = remotes.find((s) => s.currentField === fieldKey);
  if (!target) return "idle";
  if (target.typing) return "remoteTyping";
  return "remoteFocused";
}

렌더링 쪽에서는 상태에 따라 클래스와 메시지만 바꿨다.

const status = getFieldStatus(FORM_FIELD_KEYS.STUDENT_NAME, remotes);

return (
  <div className="field-wrapper">
    <input
      /* ...생략... */
      className={
        status === "remoteTyping"
          ? "border-blue-400 bg-blue-50"
          : status === "remoteFocused"
          ? "border-blue-300 bg-blue-25"
          : ""
      }
    />
    {status === "remoteTyping" && (
      <p className="text-xs text-blue-500 mt-1">
        멘토가 이 필드를 작성 중입니다.
      </p>
    )}
  </div>
);

하드 락처럼 입력을 막지는 않지만
"지금은 누가 여기 손 대고 있다"는 시그널을 주는 수준으로 맞췄다.


6. Awareness 수명 관리와 클린업

2편에서 WebSocket 연결 수명 이야기를 했었는데,
Awareness도 비슷한 고민이 있다.

  • 언제 setLocalState를 초기화할지
  • 탭을 닫을 때, 브라우저를 나갈 때 상태를 어떻게 정리할지

실제 코드에서는
다음 정도만 지켜도 크게 문제는 없었다.

6.1 페이지를 떠날 때 상태 초기화

폼 화면을 떠날 때는
Awareness의 로컬 상태를 깔끔하게 비워 두는 게 좋다.

function clearLocalAwareness(awareness: Awareness) {
  awareness.setLocalState(null);
}
  • 페이지 언마운트 시
  • beforeunload 이벤트 시

이런 곳에서 위 함수를 호출해 줬다.

6.2 연결 해제와 Awareness

WebSocket이 끊어지면
Awareness에서 해당 클라이언트는 자동으로 제거되지만,
로컬 코드 입장에서는
"내가 언제 상태를 비웠는지"를 명확하게 인지해 두는 편이 낫다.

그래서 실제 구현에서는

  • 소켓 연결 해제 → Awareness localState 초기화

이 두 동작을 항상 같이 묶어 두었다.


7. 정리

3편에서 정리한 내용을 한 줄씩 다시 적어 보면 이렇다.

  • 폼 데이터는 Yjs 문서로,
    "누가 어디를 보고 있고 무엇을 하는지"는 Awareness로 분리했다
  • 인풋 포커스·타이핑 상태를 Awareness에 올려서
    상대 커서와 입력 상태를 화면에 표현했다
  • 같은 필드를 동시에 수정하는 상황에서는
    하드 락 대신 소프트한 UI 시그널을 통해 "지금 누가 작업 중인지"를 알려 줬다
  • 페이지를 떠날 때, 소켓 연결이 끊길 때 Awareness 상태를 정리하는 규칙을 함께 세웠다

1편이 왜 이 일을 하게 됐는지,
2편이 회사 인프라 위에 어떻게 올렸는지,
3편은 그 위에 어떤 UX를 올렸는지에 대한 기록이라고 보면 된다.


8. 작게 남겨두는 회고

이걸 회고록이라고 부를 수준인지는 모르겠는데,
다음에 또 하라고 하면 솔직히 지금해온걸 뚝딱 해낼 자신은 없다.
이번에도 "해내야 하니까, 모르는 건 찾아서라도 한다"는 마음으로
머리 박으면서 버틴 느낌에 더 가깝다.

그래도 이렇게 기록을 남겨두면,
나중에 비슷한 요구가 들어왔을 때
지금보다는 조금 더 현명하게, 조금 더 빠르게 움직일 수 있지 않을까 싶다.

저작자표시 변경금지 (새창열림)

'FrontEnd > JavaScript' 카테고리의 다른 글

DOM으로 HTML 다루기 - innerHTML, insertAdjacentHTML, createContextualFragment  (0) 2025.09.09
JavaScript Function.call / apply / bind - this  (0) 2025.09.09
[실무 기록] 폼 실시간 공유 2편 - WebSocket 서버 구조랑 네임스페이스 적응기  (0) 2025.09.08
[실무 기록] 폼 실시간 공유 1편 CRDT, Yjs, WebSocket을 파보기까지  (0) 2025.09.08
[실무 기록] jQuery jsTree를 모듈로 감싸서 쓰면서 배운 것들  (0) 2025.09.08
'FrontEnd/JavaScript' 카테고리의 다른 글
  • DOM으로 HTML 다루기 - innerHTML, insertAdjacentHTML, createContextualFragment
  • JavaScript Function.call / apply / bind - this
  • [실무 기록] 폼 실시간 공유 2편 - WebSocket 서버 구조랑 네임스페이스 적응기
  • [실무 기록] 폼 실시간 공유 1편 CRDT, Yjs, WebSocket을 파보기까지
프론트엔드 개발자 jbeat
프론트엔드 개발자 jbeat
프론트엔드 개발자 블로그인데 일상도 쪼그으믐
  • 프론트엔드 개발자 jbeat
    jbeat 님의 블로그
    프론트엔드 개발자 jbeat
  • 전체
    오늘
    어제
    • 분류 전체보기 (44)
      • FrontEnd (43)
        • TypeScript (6)
        • JavaScript (18)
        • Next.js (3)
        • React (1)
        • Testing (2)
        • Third Party (1)
        • web (10)
        • Tooling (1)
        • coding test (0)
        • A.I (1)
      • 일상 (1)
        • wedding (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 인기 글

  • 태그

    Android
    컬렉션
    고차함수
    TypeScript
    Utility
    배열
    코테
    CrossOrigin
    타입스크립트
    omit
    CRDT
    WebSocket
    preconnect
    Next.js
    주니어
    이터러블
    pick
    playwright
    javascript
    yjs
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
프론트엔드 개발자 jbeat
[실무 기록] 폼 실시간 공유 3편 – Yjs Awareness로 상대 커서,선택 상태 보여주기
상단으로

티스토리툴바