1편에서 "왜 이 일을 하게 됐는지"와
CRDT, Yjs, WebSocket을 손에 익히기까지 과정을 정리했다.
2편에서는 그다음 단계였던 WebSocket 서버 구조 설계랑
회사 네임스페이스 환경에 적응한 기록을 남겨 둔다.
0. 1편에서 여기까지 왔었다
1편 기준으로는 대략 여기까지 정리가 되어 있었다.
- 회사에서 "CRDT를 검토해서 폼 실시간 공유에 써보자"는 요구가 내려왔다
- CRDT 라이브러리는 Yjs로 결정했다
- 로컬에서 Node WebSocket 서버와 Next 페이지 하나로 기본 통신 테스트를 완료했다
- Yjs 문서와 WebSocket을 묶어서 폼 데이터를 실시간으로 동기화하는 구조를 한 번 돌려 봤다
이제 남은 일은
- 이 로컬 구조를 회사 WebSocket 인프라에 맞게 옮기는 것
- 네임스페이스 기반 구조 안에서 Yjs 동기화를 안정적으로 굴리는 것
- 프론트엔드에서 소켓 연결 수명 관리를 명확히 정리하는 것
1. 로컬 WebSocket 서버 구조 정리
먼저 내가 처음 로컬에서 구성했던 WebSocket 서버 구조를 정리해 둔다.
나중에 회사 서버 구조와 비교할 때 기준점이 된다.
1.1 서버 엔드포인트와 기본 역할
가장 처음에는 정말 단순하게 잡았다.
- 엔드포인트는 "/ws" 하나만 열었다
- 접속이 들어오면 해당 소켓을 연결 목록에 넣는다
- 어떤 클라이언트에서 메시지가 오면, 나머지 클라이언트에게 브로드캐스트한다
Yjs 관점에서는 이런 흐름이다.
- 각 클라이언트가 로컬에서 문서를 수정하면
- Yjs가 내부에서 업데이트(델타)를 만들고
- WebSocket을 통해 서버로 보낸다
- 서버는 그 델타를 다시 다른 클라이언트에게 돌려준다
즉, 서버는 "상태를 이해하는 존재"가 아니라
그냥 "업데이트를 중계하는 파이프"에 가까운 역할로 두었다.
1.2 메시지 포맷을 어떻게 가져갈지
처음에는 동작만 확인하려고
메시지 포맷을 거의 신경 쓰지 않고 바이너리 그대로 흘려보냈다.
조금 지나고 나서 아래 정보가 필요해졌다.
- 어떤 문서에 대한 업데이트인지
- 어느 폼이나 페이지에서 온 것인지
- 나중에 네임스페이스가 생겼을 때 무엇으로 그룹을 나눌 것인지
그래서 최소한 이 정도는 묶어서 보내는 쪽으로 바꿨다.
- namespace 또는 room에 해당하는 식별자
- document id 혹은 form id
- Yjs 업데이트 바이너리 데이터
이렇게 만들어 두면, 나중에 회사 네임스페이스 구조에 맞춰서
필요한 필드만 갈아 끼워도 전체 설계를 다시 뒤엎을 필요는 없겠다고 판단했다.
2. 회사 WebSocket 인프라 이해하기
로컬에서 구조를 한 번 돌려보고 나니
이제는 회사 인프라와 어떻게 맞출지 봐야 했다.
회사 WebSocket 서버는 대략 이런 느낌이었다.
- "xxx-socket" 같은 이름의 서버가 이미 운영 중이었다
- 네임스페이스를 기준으로 소켓 연결이 구분되어 있었다
- 여러 서비스가 같은 소켓 인프라를 공유하고 있었다
내가 새롭게 해야 할 일은
- "폼 실시간 공유" 기능을 이 환경 안에 끼워 넣는 것
- 이미 돌아가고 있는 다른 네임스페이스들을 망가뜨리지 않는 것
2.1 네임스페이스 구조 읽기
우선 사내 GitLab 레포를 받아서 기존 구조를 읽어봤다.
- 어떤 네임스페이스가 어떤 서비스와 매핑되어 있는지
- 인증 토큰을 어디서 검증하는지
- 로그와 모니터링은 어느 레이어에서 처리하는지
이 흐름을 먼저 파악하고 나서야
"내가 추가할 방과 이벤트는 어디에 넣는 게 안전한가"를 그릴 수 있었다.
2.2 싱글 서버 구조와의 차이
로컬 싱글 서버 구조와 비교하면 차이가 꽤 뚜렷했다.
- 로컬
- 모든 연결이 한 풀에 모여 있다
- 방 개념도 "문서 id" 정도만 있으면 된다
- 회사 서버
- 네임스페이스 단위로 연결이 나뉜다
- 그 안에서 다시 방이나 채널 개념으로 쪼개는 구조다
그래서 단순히 "문서 id만 방 키로 쓰자"가 아니라
네 가지 정도를 염두에 두고 설계해야 했다.
- 네임스페이스
- 서비스 종류
- 폼 id 또는 채널 id
- 그 조합으로 만들어지는 room 키
3. 내 코드 이식하기
구조를 한 번 훑어본 뒤에는
이제 실제로 코드를 옮겨 넣는 작업이었다.
3.1 개인 레포에서 회사 레포로
흐름은 대략 이렇게 흘렀다.
- 로컬에서 쓰던 WebSocket 서버 코드를 최소 단위로 정리했다
- "xxx-socket" 레포 구조에 맞춰 폴더와 파일 위치를 다시 배치했다
- 기존 이벤트들과 충돌하지 않도록 이벤트 이름과 네임스페이스를 정했다
- Yjs 동기화 로직을 별도 모듈로 분리해서, 특정 네임스페이스에서만 쓰도록 구성했다
여기서 신경 쓴 부분은 두 가지였다.
- "내가 추가한 코드 때문에 다른 팀 이벤트가 영향을 받지 않게 할 것"
- "문제가 생기면 관련 코드 범위를 빠르게 좁힐 수 있게 할 것"
3.2 네임스페이스와 문서 id 매핑
네임스페이스와 문서 id를 어떻게 묶을지도 고민이 좀 있었다.
결국 아래처럼 정리했다.
- 네임스페이스는 서비스 기준으로 나눈다
- 그 안에서 폼 id나 채널 id를 기반으로 방을 만든다
- Yjs 문서 하나를 방 하나와 매핑한다
이렇게 정해 두니 정리가 수월해졌다.
- 어떤 폼 화면에 들어가면
- 해당 네임스페이스 + 폼 id 방에 조인한다
- 그 방에서만 Yjs 업데이트를 주고받는다
이 매핑 규칙은 나중에 별도로 문서화해 두면
다른 기능이 같은 인프라를 쓸 때도 기준점이 될 수 있을 것 같았다.
4. 프론트엔드에서 소켓 연결 수명 관리
서버 쪽 구조가 어느 정도 정리되고 나면
프론트엔드에서 소켓 연결을 어디에서 만들고 언제 정리할지가 중요한 문제로 올라온다.
4.1 연결을 어디에서 만들 것인지
처음에는 페이지 안에서 바로 소켓을 생성했다.
- Next 페이지 컴포넌트가 마운트될 때 소켓을 연결한다
- 언마운트될 때 연결을 끊는다
이 방식의 장점은 단순함이다.
- "이 페이지에 들어온 동안만 연결"이라는 게 직관적이다
하지만 실제로 사용하다 보니 아래 상황들도 고려해야 했다.
- 같은 브라우저에서 탭을 여러 개 열었을 때
- 페이지 내부에서 폼 화면을 부분적으로만 교체할 때
그래서 소켓 연결과 Yjs 문서 생명주기를
조금 더 상위 레벨에서 관리하는 쪽으로 구조를 조정했다.
4.2 이벤트 핸들러와 클린업
실무에서 자주 생기는 문제 중 하나가
이벤트 핸들러를 여러 번 등록해 두고 정리는 제대로 안 하는 것이다.
WebSocket과 Yjs를 같이 쓰면 리스너가 두 계층에 생긴다.
- 소켓 이벤트 핸들러
- Yjs 업데이트 리스너
이걸 명확하게 정리하지 않으면
탭 이동이나 재접속 시에 이벤트가 중복으로 실행되는 현상이 생긴다.
그래서 정리한 원칙은 단순하게 가져갔다.
- 소켓 연결을 만들 때 리스너를 한 번만 등록한다
- 연결을 끊을 때는 리스너를 같이 정리한다
- 페이지 전환과 연결 수명 주기를 코드에서 명시적으로 맞춘다
특별한 알고리즘을 쓴 건 아니고
"나중에 내가 코드를 다시 봐도 흐름이 한 번에 보이도록"
구조를 정리하는 데 중점을 두었다.
5. 중간에 기억나는 삽질들
큰 장애가 터진 건 아니었지만,
앞으로 비슷한 작업을 다시 할 때 피하고 싶은 포인트들을 메모해 둔다.
- 연결 상태 플래그를 중복으로 관리하려다 꼬였던 부분
→ 소켓 객체 내부 상태와, 내가 따로 둔 상태 플래그가 어긋난 적이 있었다
→ "진짜 연결 상태는 어디에서만 믿을 것인지" 기준을 하나로 통일했다 - 최초 동기화 타이밍
→ 폼 초기값을 언제 Yjs 문서에 반영할지 애매했던 부분이 있었다
→ Yjs 문서를 먼저 만들고, 연결이 잡힌 뒤에 초기값을 넣는 순서로 규칙을 정했다 - 에러 로깅 위치
→ 서버 로그, 프론트 콘솔, 사용자에게 직접 보여줄 메시지를 어떻게 나눌지 고민이 좀 있었다
→ 최소한 WebSocket 관련 에러는 서버와 클라이언트 양쪽에 남기도록 맞춰 뒀다
전반적으로는 "새로운 개념 때문에 막혔다"기보다는
상태가 두 군데 이상 흩어져 있을 때
수명 주기가 애매하게 겹치면서 생기는 문제들을 한 번씩 밟아 본 정도라고 보면 된다.
6. 정리
2편에서 정리한 내용을 한 줄씩 다시 적어 보면 대략 이렇다.
- 로컬에서는 가장 단순한 WebSocket 서버 구조로 Yjs 동기화를 먼저 돌려 봤다
- 회사 WebSocket 인프라는 네임스페이스 기반이라, 그 구조를 먼저 이해하는 데 시간을 썼다
- 개인 레포에서 만든 코드를 "xxx-socket" 구조 안으로 옮기면서, 네임스페이스와 문서 id 매핑 규칙을 정리했다
- 프론트엔드에서는 소켓 연결과 Yjs 문서 수명 주기를 명확하게 나누고, 이벤트 핸들러 정리 규칙을 잡았다
1편이 "왜 이걸 하게 됐는지"에 대한 이야기였다면
2편은 "그걸 실제 회사 인프라에 어떻게 녹였는지"에 대한 이야기라고 보면 된다.
7. 3편 예고
지금까지는 인프라와 통신 쪽에 집중해서 정리했다.
3편에서는 Yjs 쪽에 조금 더 포커스를 맞출 예정이다.
- Yjs Awareness를 사용해서 커서, 선택 영역, 타이핑 상태를 어떻게 공유했는지
- 폼 구조를 문서 단위로 어디까지 쪼갤지 기준을 어떻게 잡았는지
- 실시간 공유 화면에서 "상대가 지금 어디를 보고 있는지"를 보여주는 UX를 어떻게 구현했는지
를 중심으로 정리해서
CRDT 폼 실시간 공유 시리즈를 "배경 → 인프라 → UX" 순서로 마무리할 생각이다.
'FrontEnd > JavaScript' 카테고리의 다른 글
| JavaScript Function.call / apply / bind - this (0) | 2025.09.09 |
|---|---|
| [실무 기록] 폼 실시간 공유 3편 – Yjs Awareness로 상대 커서,선택 상태 보여주기 (0) | 2025.09.08 |
| [실무 기록] 폼 실시간 공유 1편 CRDT, Yjs, WebSocket을 파보기까지 (0) | 2025.09.08 |
| [실무 기록] jQuery jsTree를 모듈로 감싸서 쓰면서 배운 것들 (0) | 2025.09.08 |
| JavaScript async/await와 Promise 체이닝, 어떻게 골라 쓸까 (0) | 2024.11.28 |