크래프톤 정글 6기 · 최종 프로젝트
Code Sync
실시간 화상 코드리뷰 플랫폼
크래프톤 정글 6기 최종 팀 프로젝트로, GitHub PR 리뷰에 화상 대화와 실시간 협업 기능을 더한 서비스입니다. 프론트엔드 핵심 기능을 맡아 PR 데이터 조회와 리뷰 화면, 코드 동시 편집, 파일·커서·화면 위치 동기화, Host와 Guest 사이의 데이터 공유 흐름을 구현했습니다.
- React
- TypeScript
- Yjs
- Monaco Editor
- GitHub API

CODE SYNC · PRODUCT EVIDENCE
리뷰 화면과 협업 흐름
GitHub PR 리뷰, 동시 편집, 문서·화이트보드 협업을 연결한 실제 화면입니다.
CASE 01
변경된 코드에서도 리뷰 맥락 유지하기
상황
리뷰 댓글은 과거 커밋의 파일을 가리키지만 GitHub REST API는 최신 변경 파일만 반환했습니다. 파일이 삭제되거나 경로가 바뀌면 댓글은 남아 있어도 연결할 파일을 찾지 못해 리뷰 맥락이 끊겼습니다.
판단
문제를 단순한 API 오류로 처리하지 않고 댓글과 파일의 기준 시점이 다르다는 데이터 모델의 차이로 정의했습니다. 추가 API 호출 없이 클라이언트에서 현재 파일 목록과 댓글의 경로·상태를 비교하도록 했습니다.
구현
- 최신 변경 파일 목록을 기준으로 댓글이 참조한 경로의 존재 여부를 판별하고, 목록에 없으면 Outdated 상태로 분류했습니다.
- Outdated 댓글은 회색 배경과 안내 메시지로 구분해 사용자가 댓글이 사라진 것이 아니라 이전 코드에 남은 리뷰임을 이해하도록 했습니다.
- PR 병합 뒤 head 브랜치가 삭제되는 경우에는 브랜치명에 의존하지 않고 head.sha와 base.sha를 기준으로 변경 내역을 조회하도록 분기했습니다.
결과
삭제·이동된 파일의 댓글도 리뷰 맥락을 유지하고, 병합 이후에도 변경 내역을 다시 확인할 수 있게 했습니다.
댓글과 최신 파일 목록의 상태 비교
최신 변경 파일 목록에 댓글의 경로가 없으면, 댓글을 삭제하지 않고 이전 코드에 남은 리뷰로 분류합니다.
const currentPaths = new Set(
changedFiles.map((file) => file.filename),
);
const commentsWithStatus = comments.map((comment) => ({
...comment,
isOutdated: !currentPaths.has(comment.path),
}));
구현 로직 요약 · 실제 식별자와 API 전송 형식은 축약
CASE 02
PR 데이터는 한 번 조회하고 함께 사용하기
상황
참여자마다 PR 메타데이터, 변경 파일, 파일별 diff, 댓글을 다시 요청했습니다. 변경 파일이 10개인 경우 한 참여자당 13회 이상의 API 호출이 발생했고, 참여자가 늘수록 같은 요청이 반복됐습니다.
판단
각 사용자가 데이터를 요청하는 구조를 방 생성자의 최초 조회 결과를 공유하는 구조로 전환했습니다. 서버는 GitHub 데이터를 반복해서 중계하기보다 WebSocket 기반 상태 동기화에 집중하도록 책임을 나눴습니다.
구현
- Host가 조회한 파일·댓글은 Y.Array(fileMetadata·commentMetadata), PR 정보는 Y.Map(prInfoMetadata)에 저장했습니다.
- 방 생성자만 GitHub API를 최초 1회 호출하고 PR 데이터를 Y.Doc에 저장했습니다. 이후 참여자는 Yjs 동기화로 데이터를 받아 별도 API 요청 없이 화면을 렌더링했습니다.
- PR의 head.sha·base.sha를 함께 저장해 공유된 PR snapshot과 커밋 기준 변경 내역을 같은 리뷰 화면에서 사용했습니다.
결과
Host가 조회한 PR 데이터를 Y.Doc으로 공유하고 Guest가 복원하도록 연결했습니다. GitHub API 호출을 44회에서 22회로 줄여 중복 호출과 Rate Limit 위험을 낮췄습니다.
연결 완료와 문서 동기화 완료 구분
소켓 연결과 공유 문서 동기화는 별개의 상태이므로, Yjs의 sync 이벤트를 기다린 뒤 Guest의 PR 데이터를 복원하도록 했습니다.
export const waitForYdocSync = (provider: WebsocketProvider) => {
if (provider.synced) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
const handleSync = (isSynced: boolean) => {
if (!isSynced) return;
provider.off("sync", handleSync);
resolve();
};
provider.on("sync", handleSync);
});
};
구현 코드 · yjs.ts
src/lib/yjs.ts · GitHub에서 원본 보기CASE 03
같은 코드, 같은 위치에서 리뷰하기
상황
참여자가 서로 다른 파일과 위치를 보면 설명을 반복해야 했습니다. 파일 전환 뒤 이전 파일의 커서가 남는 상태도 정리해야 했습니다.
판단
동시 편집 내용의 동기화와 충돌 병합은 Yjs를 사용했습니다. 직접 구현한 부분은 파일별 Monaco 연결, 상대방 위치 표시, 화면 이동과 파일 전환 시 정리 로직입니다.
구현
- 회의마다 Y.Doc을 생성하고 파일명을 키로 Y.Text를 구분했습니다. 선택한 파일의 Y.Text와 Monaco 모델을 MonacoBinding으로 연결하고, 파일을 바꾸면 기존 바인딩을 해제했습니다.
- Awareness에 현재 파일 경로를 저장하고 상대방의 위치를 화면에 표시했습니다. 화면 동기화 버튼을 누르면 상대방이 보는 파일·노트·그림판으로 이동하도록 구현했습니다.
- BlockNote와 Excalidraw의 협업 기능을 같은 Yjs 세션에 연동하고, 파일 전환 시 기존 커서 상태와 이벤트 리스너를 정리했습니다.
결과
리뷰 중 상대방이 설명하는 파일로 바로 이동하고, 같은 파일을 함께 편집할 수 있게 했습니다.
Awareness에 현재 파일 경로 저장
Awareness의 상태 공유 기능에 사용자 정보와 현재 파일 경로를 저장했습니다. 정리 함수에서는 이전 사용자 상태를 비웁니다.
provider.awareness.setLocalStateField("user", {
name: checkUser.data.name,
color: "#ff6161",
colorLight: "#30bced33",
cursor: {
current_file_path: selectedCommitFile.filename,
},
});
return () => {
provider?.awareness.setLocalStateField("user", null);
};
구현 코드 · MainFrame.tsx
src/components/Frame/MainFrame.tsx · GitHub에서 원본 보기CASE 04
Route 단위 Code Splitting과 Lazy Loading
상황
여러 협업 기능과 무거운 편집·화상·문서 라이브러리를 사용하는 SPA가 처음 진입할 때, 사용하지 않는 화면 코드까지 함께 불러왔습니다. 첫 화면에 필요한 범위보다 초기 JavaScript가 커져 진입 비용이 늘어나는 구조였습니다.
판단
화면 단위로 코드를 나누고 필요한 경로에 진입할 때 불러오도록 해, 첫 화면과 이후 기능의 로딩 시점을 분리했습니다.
구현
- 로그인·회원가입·회의 생성·이전 회의·저장·공유 페이지를 React.lazy와 Suspense로 분리해 route 단위 청크로 나눴습니다.
- 초기 화면에 필요한 코드만 먼저 불러오고, 분리된 화면은 해당 경로에 진입할 때 로드되도록 구성했습니다.
- 초기 진입 JavaScript 번들의 변경 전후 크기를 비교해 최적화 결과를 확인했습니다.
결과
당시 기록 기준 초기 JS 번들을 1,820KB에서 99KB로 줄였습니다.
당시 기록한 초기 JS 청크 크기이며, 전체 파일 용량이나 현재 빌드 크기를 뜻하지 않습니다.
화면 단위로 코드 나누기
회의와 협업 화면을 초기 번들에서 분리하고, 해당 경로에 진입했을 때 필요한 코드를 불러오도록 구성했습니다.
const MeetingPage = lazy(() => import("./pages/MeetingPage"));
const SavedMeetingPage = lazy(
() => import("./pages/SavedMeetingPage"),
);
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/meeting" element={<MeetingPage />} />
<Route path="/saved" element={<SavedMeetingPage />} />
</Routes>
</Suspense>;
구현 로직 요약 · 실제 페이지명과 경로는 축약



