코드 에디터와 파일 목록
Monaco Editor와 파일 목록을 REST 자동 저장에 연결하고, 선택 협업 모드에서 서버 저장·다른 세션 반영·연결 정리를 다룹니다.
앞 글의 목록에서 프로젝트를 고르면 이 글의 에디터 화면이 열립니다. 왼쪽에는 프로젝트의 파일 목록을, 오른쪽에는 Monaco Editor를 두고, 입력을 멈추면 800ms 뒤에 자동으로 저장합니다. 화면에는 폴더 구분 없이 파일만 나열하며, 파일 생성 UI와 폴더 트리는 확장 과제로 남깁니다. 실습하기 전에 모듈 연결과 실행의 API로 파일을 하나 이상 만들어 두세요.
저장 방식은 두 가지입니다. 기본은 REST 모드로, 파일 내용을 PATCH /projects/:projectId/files/:fileId/content로 저장합니다. 같은 파일의 두 세션 연결하기의 Gateway를 추가했다면 협업 모드를 켤 수 있으며, 이때는 Socket.IO의 code_change를 받은 서버가 저장하므로 클라이언트는 같은 입력을 PATCH로 다시 보내지 않습니다. 먼저 REST 흐름을 읽고 협업 모드로 넘어가도 됩니다.
EditorPage
@monaco-editor/react는 기본 설정에서 jsDelivr CDN에서 Monaco를 내려받으므로 개발 중에도 인터넷 연결이 필요합니다. 설치한 monaco-editor 패키지는 이 코드에서 에디터 인스턴스의 타입을 가져오는 데만 씁니다.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router';
import Editor from '@monaco-editor/react';
import type { editor } from 'monaco-editor';
import { io, type Socket } from 'socket.io-client';
import api, { API_URL } from '../api/axiosInstance';
interface FileInfo {
id: string;
name: string;
path: string;
content: string | null;
type: string | null;
}
interface ProjectDetails {
id: string;
name: string;
files: FileInfo[];
}
interface Ack {
ok: boolean;
content?: string;
message?: string;
}
export default function EditorPage() {
const { projectId } = useParams<{ projectId: string }>();
const [project, setProject] = useState<ProjectDetails | null>(null);
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null);
const [content, setContent] = useState('');
const [realtime, setRealtime] = useState(false);
const [joined, setJoined] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [selecting, setSelecting] = useState(false);
const [status, setStatus] = useState('파일을 불러오는 중');
const socketRef = useRef<Socket | null>(null);
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const timerRef = useRef<number | null>(null);
const dirtyRef = useRef(false);
const savingRef = useRef(false);
const applyingRemoteRef = useRef(false);
const generationRef = useRef(0);
const selectionRef = useRef(0);
const selectingRef = useRef(false);
const conflictRef = useRef(false);
const cancelTimer = useCallback(() => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = null;
}, []);
const markDirty = useCallback((value: boolean) => {
dirtyRef.current = value;
setDirty(value);
}, []);
const applyContent = useCallback((value: string) => {
applyingRemoteRef.current = true;
editorRef.current?.setValue(value);
setContent(value);
applyingRemoteRef.current = false;
}, []);
useEffect(() => {
let active = true;
++selectionRef.current;
cancelTimer();
selectingRef.current = false;
savingRef.current = false;
markDirty(false);
setSelecting(false);
setSaving(false);
setProject(null);
setSelectedFile(null);
api.get<ProjectDetails>(`/projects/${projectId}`).then(({ data }) => {
if (!active) return;
setProject(data);
const first = data.files[0] ?? null;
setSelectedFile(first);
applyContent(first?.content ?? '');
setStatus(first ? 'REST 저장 준비' : 'API로 파일을 먼저 만드세요.');
}).catch(() => {
if (active) setStatus('프로젝트 조회 실패: 토큰·프로젝트 ID를 확인하세요.');
});
return () => { active = false; };
}, [projectId, applyContent, cancelTimer, markDirty]);
useEffect(() => {
setJoined(false);
conflictRef.current = false;
const generation = ++generationRef.current;
if (!realtime || !projectId || !selectedFile) return;
const socket = io(API_URL, {
auth: { token: localStorage.getItem('accessToken') },
reconnection: false,
});
socketRef.current = socket;
const fileId = selectedFile.id;
const active = () => generationRef.current === generation;
socket.on('connect', () => {
socket.timeout(3000).emit('join_file', { projectId, fileId },
(error: Error | null, ack: Ack) => {
if (!active()) return;
if (error || !ack?.ok) {
setStatus('파일 참가 실패: 토큰·ID·소유권을 확인하세요.');
return;
}
applyContent(ack.content ?? '');
setJoined(true);
setStatus('협업 저장 준비');
});
});
socket.on('code_update', (update: { fileId: string; changes: string }) => {
if (!active() || update.fileId !== fileId) return;
if (dirtyRef.current || savingRef.current) {
conflictRef.current = true;
cancelTimer();
setJoined(false);
setStatus('편집이 겹쳤습니다. 현재 내용을 복사한 뒤 페이지를 새로 불러오세요.');
return;
}
applyContent(update.changes);
setStatus('상대 변경 반영');
});
socket.on('connect_error', () => {
if (active()) setStatus('Socket 인증·접속 실패');
});
socket.on('disconnect', () => {
if (!active()) return;
cancelTimer();
setJoined(false);
setStatus('연결이 끊겼습니다. 미저장 내용을 복사한 뒤 페이지를 새로 불러오세요.');
});
return () => {
++generationRef.current;
cancelTimer();
socket.removeAllListeners();
socket.disconnect();
socketRef.current = null;
};
}, [realtime, projectId, selectedFile?.id, applyContent, cancelTimer]);
useEffect(() => () => {
++generationRef.current;
++selectionRef.current;
cancelTimer();
}, [cancelTimer]);
const handleChange = (value: string | undefined, event: editor.IModelContentChangedEvent) => {
// 초기 내용·상대 변경을 적용할 때는 새 저장 요청을 만들지 않습니다.
if (value === undefined || applyingRemoteRef.current || event.isFlush ||
!selectedFile || savingRef.current || selectingRef.current || (realtime && !joined)) return;
setContent(value);
markDirty(true);
setStatus('마지막 입력 뒤 800ms에 저장');
cancelTimer();
const fileId = selectedFile.id;
const generation = generationRef.current;
timerRef.current = window.setTimeout(async () => {
timerRef.current = null;
savingRef.current = true;
setSaving(true);
setStatus('저장 응답을 기다리는 중');
const complete = (ok: boolean, message: string) => {
if (generation !== generationRef.current) return;
savingRef.current = false;
setSaving(false);
if (conflictRef.current) return;
if (ok) markDirty(false);
setStatus(message);
};
if (realtime) {
const socket = socketRef.current;
if (!socket?.connected) {
complete(false, '연결 없음: 현재 내용은 미저장입니다.');
return;
}
socket.timeout(3000).emit('code_change', { projectId, fileId, changes: value },
(error: Error | null, ack: Ack) => {
if (error) complete(false, 'ACK timeout: GET으로 저장 내용을 확인하세요.');
else complete(!!ack?.ok, ack?.ok ? '저장 완료' : ack?.message || '저장 거부');
});
} else {
try {
await api.patch(`/projects/${projectId}/files/${fileId}/content`, { content: value });
complete(true, 'REST 저장 완료');
} catch {
complete(false, 'REST 저장 실패: 토큰·응답을 확인하세요.');
}
}
}, 800);
};
const selectFile = async (file: FileInfo) => {
if (dirtyRef.current || savingRef.current || selectingRef.current ||
file.id === selectedFile?.id) return;
selectingRef.current = true;
setSelecting(true);
const selection = ++selectionRef.current;
cancelTimer();
try {
const { data } = await api.get<FileInfo>(`/projects/${projectId}/files/${file.id}`);
if (selection !== selectionRef.current) return;
++generationRef.current;
setJoined(false);
setSelectedFile(data);
applyContent(data.content ?? '');
setStatus(realtime ? '파일 참가 중' : 'REST 저장 준비');
} catch {
if (selection === selectionRef.current) setStatus('파일 조회 실패: 토큰·파일 ID를 확인하세요.');
} finally {
if (selection === selectionRef.current) {
selectingRef.current = false;
setSelecting(false);
}
}
};
return <main>
<h2>Project: {project?.name ?? 'Loading'}</h2>
<label>
<input type="checkbox" checked={realtime} disabled={dirty || saving || selecting}
onChange={(event) => setRealtime(event.target.checked)} />협업 모드 사용
</label>
<p role="status">{status}</p>
<div style={{ display: 'flex' }}>
<aside style={{ width: '20%' }}>
<h3>Files</h3>
{project?.files.map((file) => <button key={file.id}
disabled={dirty || saving || selecting} onClick={() => void selectFile(file)}>{file.name}</button>)}
</aside>
<section style={{ width: '80%' }}>
<h3>Editing: {selectedFile?.path}</h3>
{selectedFile && <Editor height="70vh" language={selectedFile.type || 'plaintext'}
value={content} onMount={(instance) => { editorRef.current = instance; }}
onChange={handleChange}
options={{ readOnly: saving || selecting || (realtime && !joined), minimap: { enabled: false } }} />}
</section>
</div>
</main>;
}서버가 돌려주는 파일의 content와 type은 데이터베이스에서 NULL일 수 있으므로 FileInfo에서도 string | null로 적었습니다. 에디터에 넣을 때는 content ?? ''로 빈 문자열로 바꾸고, 언어는 type || 'plaintext'로 정합니다. Socket.IO 연결은 프론트엔드 통합에서 만든 src/api/axiosInstance.ts의 API_URL을 쓰므로, 배포 주소를 바꿀 때 한 곳만 고치면 됩니다.
입력 하나에 저장 경로 하나
REST 또는 Socket으로 저장하는 에디터 흐름
800 ms 타이머·REST/socket 택일·서버 저장·GET/ACK 확인에서 저장 준비와 관측 상태가 달라진다.
Nest · One save path per mode
같은 입력은 한 경로로 저장합니다
기본 모드는 REST입니다. 선택 협업은 같은 소유 계정의 두 세션이 같은 파일을 번갈아 편집하는 경로이며, Socket 요청을 받은 서버가 저장합니다.
| 경로 | 입력에서 다음 관찰까지 | 저장·정리의 범위 |
|---|---|---|
| REST 저장 · 기본 | 마지막 입력 뒤 800ms에 content PATCH를 보냅니다. 서버 응답 뒤 같은 파일을 GET하여 저장 내용을 읽습니다. | 이 모드에서는 code_change를 보내지 않습니다. 저장 실패는 응답·토큰·파일 ID부터 확인합니다. |
| Socket 저장 · 선택 | 요청한 연결의 JWT와 파일 소유권·프로젝트 소속·방 참가를 확인합니다. DB 저장 뒤 다른 참가자에게 code_update를 보내고 작성자에게 ACK를 돌려줍니다. | 같은 입력을 PATCH로 다시 저장하지 않습니다. ACK 성공은 저장 호출의 성공이며, 상대 화면의 반영 완료는 아닙니다. |
| 수신·전환·해제 | 상대 내용은 Monaco와 React 상태에 적용하고, 이 변경으로 새 저장을 만들지 않습니다. 파일 전환은 최신 GET 조회와 새 연결로 이어집니다. | 미저장·저장 중에는 전환을 막습니다. 해제 시 타이머·이벤트 수신·연결을 정리하며, 이미 서버에 보낸 요청의 저장까지 취소하는 것은 아닙니다. |
- REST 저장 · 기본
- 입력에서 다음 관찰까지
- 마지막 입력 뒤 800ms에 content PATCH를 보냅니다. 서버 응답 뒤 같은 파일을 GET하여 저장 내용을 읽습니다.
- 저장·정리의 범위
- 이 모드에서는 code_change를 보내지 않습니다. 저장 실패는 응답·토큰·파일 ID부터 확인합니다.
- Socket 저장 · 선택
- 입력에서 다음 관찰까지
- 요청한 연결의 JWT와 파일 소유권·프로젝트 소속·방 참가를 확인합니다. DB 저장 뒤 다른 참가자에게 code_update를 보내고 작성자에게 ACK를 돌려줍니다.
- 저장·정리의 범위
- 같은 입력을 PATCH로 다시 저장하지 않습니다. ACK 성공은 저장 호출의 성공이며, 상대 화면의 반영 완료는 아닙니다.
- 수신·전환·해제
- 입력에서 다음 관찰까지
- 상대 내용은 Monaco와 React 상태에 적용하고, 이 변경으로 새 저장을 만들지 않습니다. 파일 전환은 최신 GET 조회와 새 연결로 이어집니다.
- 저장·정리의 범위
- 미저장·저장 중에는 전환을 막습니다. 해제 시 타이머·이벤트 수신·연결을 정리하며, 이미 서버에 보낸 요청의 저장까지 취소하는 것은 아닙니다.
ACK timeout은 저장 실패를 확정하지 않습니다. GET으로 현재 내용을 확인하고, 연결이 끊겼으면 미저장 내용을 복사한 뒤 파일을 다시 엽니다. DB 저장과 다른 화면 전달은 하나의 트랜잭션이 아닙니다.
동시 입력은 전체 문자열을 덮어쓸 수 있습니다. 공유 초대·revision 충돌 제어·OT/CRDT·자동 재시도·오프라인 복구는 이 예제에 포함하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 마지막 입력의 800 ms 타이머 뒤 협업 모드에 따라 content PATCH 또는 code_change 중 하나만 사용한다. 서버 저장과 GET·ACK 확인을 구분하며 ACK는 상대 화면 적용 완료를 보장하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 6 | 마지막 입력의 800 ms 타이머 뒤 협업 모드에 따라 content PATCH 또는 code_change 중 하나만 사용한다. 서버 저장과 GET·ACK 확인을 구분하며 ACK는 상대 화면 적용 완료를 보장하지 않는다. |
| 10 | 800 ms 타이머 |
| 30 | 마지막 입력 뒤 800 ms에 content PATCH를 보냅니다. 서버 응답 뒤 같은 파일을 GET하여 저장 내용을 읽습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 입력마다 timer 교체 편집 입력이 바뀌면 마지막 입력 기준 타이머를 기다립니다. | 타이머: 800 ms · 업무: 아직 저장 전 | 연속 입력의 마지막 변경만 전송 후보로 남습니다. |
| 모드에 맞는 한 전송 타이머 뒤 REST PATCH 또는 socket code_change 중 하나를 사용합니다. | REST: content PATCH · 협업: code_change · 전송: 하나만 선택 | 동일 변경의 전송 경로가 결정됩니다. |
| 서버 저장 확인 서버 저장 뒤 GET 또는 ACK로 결과를 확인합니다. | REST 확인: GET · 협업 확인: ACK · 상대 화면 적용: 별도 | 전송과 저장 확인을 구분합니다. |
REST 또는 Socket으로 저장하는 에디터 흐름
800 ms 타이머·REST/socket 택일·서버 저장·GET/ACK 확인에서 저장 준비와 관측 상태가 달라진다.
Nest · One save path per mode
같은 입력은 한 경로로 저장합니다
기본 모드는 REST입니다. 선택 협업은 같은 소유 계정의 두 세션이 같은 파일을 번갈아 편집하는 경로이며, Socket 요청을 받은 서버가 저장합니다.
| 경로 | 입력에서 다음 관찰까지 | 저장·정리의 범위 |
|---|---|---|
| REST 저장 · 기본 | 마지막 입력 뒤 800ms에 content PATCH를 보냅니다. 서버 응답 뒤 같은 파일을 GET하여 저장 내용을 읽습니다. | 이 모드에서는 code_change를 보내지 않습니다. 저장 실패는 응답·토큰·파일 ID부터 확인합니다. |
| Socket 저장 · 선택 | 요청한 연결의 JWT와 파일 소유권·프로젝트 소속·방 참가를 확인합니다. DB 저장 뒤 다른 참가자에게 code_update를 보내고 작성자에게 ACK를 돌려줍니다. | 같은 입력을 PATCH로 다시 저장하지 않습니다. ACK 성공은 저장 호출의 성공이며, 상대 화면의 반영 완료는 아닙니다. |
| 수신·전환·해제 | 상대 내용은 Monaco와 React 상태에 적용하고, 이 변경으로 새 저장을 만들지 않습니다. 파일 전환은 최신 GET 조회와 새 연결로 이어집니다. | 미저장·저장 중에는 전환을 막습니다. 해제 시 타이머·이벤트 수신·연결을 정리하며, 이미 서버에 보낸 요청의 저장까지 취소하는 것은 아닙니다. |
- REST 저장 · 기본
- 입력에서 다음 관찰까지
- 마지막 입력 뒤 800ms에 content PATCH를 보냅니다. 서버 응답 뒤 같은 파일을 GET하여 저장 내용을 읽습니다.
- 저장·정리의 범위
- 이 모드에서는 code_change를 보내지 않습니다. 저장 실패는 응답·토큰·파일 ID부터 확인합니다.
- Socket 저장 · 선택
- 입력에서 다음 관찰까지
- 요청한 연결의 JWT와 파일 소유권·프로젝트 소속·방 참가를 확인합니다. DB 저장 뒤 다른 참가자에게 code_update를 보내고 작성자에게 ACK를 돌려줍니다.
- 저장·정리의 범위
- 같은 입력을 PATCH로 다시 저장하지 않습니다. ACK 성공은 저장 호출의 성공이며, 상대 화면의 반영 완료는 아닙니다.
- 수신·전환·해제
- 입력에서 다음 관찰까지
- 상대 내용은 Monaco와 React 상태에 적용하고, 이 변경으로 새 저장을 만들지 않습니다. 파일 전환은 최신 GET 조회와 새 연결로 이어집니다.
- 저장·정리의 범위
- 미저장·저장 중에는 전환을 막습니다. 해제 시 타이머·이벤트 수신·연결을 정리하며, 이미 서버에 보낸 요청의 저장까지 취소하는 것은 아닙니다.
ACK timeout은 저장 실패를 확정하지 않습니다. GET으로 현재 내용을 확인하고, 연결이 끊겼으면 미저장 내용을 복사한 뒤 파일을 다시 엽니다. DB 저장과 다른 화면 전달은 하나의 트랜잭션이 아닙니다.
동시 입력은 전체 문자열을 덮어쓸 수 있습니다. 공유 초대·revision 충돌 제어·OT/CRDT·자동 재시도·오프라인 복구는 이 예제에 포함하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 마지막 입력의 800 ms 타이머 뒤 협업 모드에 따라 content PATCH 또는 code_change 중 하나만 사용한다. 서버 저장과 GET·ACK 확인을 구분하며 ACK는 상대 화면 적용 완료를 보장하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 6 | 마지막 입력의 800 ms 타이머 뒤 협업 모드에 따라 content PATCH 또는 code_change 중 하나만 사용한다. 서버 저장과 GET·ACK 확인을 구분하며 ACK는 상대 화면 적용 완료를 보장하지 않는다. |
| 10 | 800 ms 타이머 |
| 30 | 마지막 입력 뒤 800 ms에 content PATCH를 보냅니다. 서버 응답 뒤 같은 파일을 GET하여 저장 내용을 읽습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 입력마다 timer 교체 편집 입력이 바뀌면 마지막 입력 기준 타이머를 기다립니다. | 타이머: 800 ms · 업무: 아직 저장 전 | 연속 입력의 마지막 변경만 전송 후보로 남습니다. |
| 모드에 맞는 한 전송 타이머 뒤 REST PATCH 또는 socket code_change 중 하나를 사용합니다. | REST: content PATCH · 협업: code_change · 전송: 하나만 선택 | 동일 변경의 전송 경로가 결정됩니다. |
| 서버 저장 확인 서버 저장 뒤 GET 또는 ACK로 결과를 확인합니다. | REST 확인: GET · 협업 확인: ACK · 상대 화면 적용: 별도 | 전송과 저장 확인을 구분합니다. |
위 그림은 입력 하나가 저장되기까지의 세 단계를 보여 줍니다. 입력이 들어올 때마다 이전 타이머를 지우고 800ms 타이머를 새로 걸며, 이 시점에는 아직 아무것도 저장되지 않았습니다. 타이머가 끝나면 협업 모드가 꺼져 있을 때는 content PATCH를, 켜져 있을 때는 code_change를 보내며, 둘 중 하나만 씁니다. 서버의 저장 결과는 PATCH 응답이나 ACK로 확인하고, 필요하면 같은 파일을 GET해 다시 확인합니다.
handleChange()의 타이머 콜백은 realtime 값 하나로 경로를 고릅니다. 한 입력을 두 경로로 모두 보내면 서버에 같은 내용이 두 번 저장되고, 협업 모드에서는 PATCH로 저장된 변경이 다른 세션에 전달되지도 않습니다. 그래서 두 경로는 서로를 대신하는 관계가 아니라, 모드에 따라 하나만 쓰는 관계입니다.
기본 REST 흐름
처음 화면에서는 협업 모드가 꺼져 있습니다. 파일을 고르고 console.log(2);처럼 내용을 바꾸면, 입력을 멈춘 뒤 800ms에 PATCH를 보내고 상태 문구를 바꿉니다. 이처럼 입력할 때마다 저장하지 않고 마지막 입력 뒤 잠시 기다렸다가 한 번 실행하는 방식을 디바운스(debounce)라고 합니다. 기다리는 동안 다음 입력이 들어오면 이전 타이머를 지우고 다시 기다립니다.
저장하기 전에 다른 파일로 바꾸면 아직 보내지 않은 내용을 잃을 수 있습니다. 그래서 미저장 내용이 있거나 저장 요청이 진행 중인 동안에는 파일 버튼과 모드 선택을 잠급니다. 저장 응답을 기다리는 동안에는 에디터도 읽기 전용으로 바꿔, 응답을 받기 전에 새 저장 요청이 겹치지 않게 합니다. 이 처리는 이 화면 안에서만 동작하며, 서버의 동시성 제어를 대신하지 않습니다.
파일을 다시 고를 때는 프로젝트를 처음 불러올 때 받은 목록의 내용 대신, GET으로 최신 저장 내용을 읽습니다. 조회하는 동안에는 입력을 잠시 막지만 기존 파일은 그대로 두고, 조회가 성공한 뒤에 새 파일로 바꿉니다. 조회가 실패하면 기존 파일에서 계속 편집할 수 있습니다.
타이머를 만들 때는 그 시점의 fileId, generation, 입력 value를 함께 붙잡아 둡니다. 나중에 돌아온 응답은 complete()에서 현재 generationRef와 비교한 뒤에만 화면 상태를 바꿉니다. 이 비교는 다른 파일이나 이전 연결에서 늦게 돌아온 응답이 지금 화면을 바꾸지 않게 할 뿐, 이미 서버로 보낸 요청을 취소하거나 저장된 값을 되돌리지는 않습니다. 페이지를 닫을 때 보내지 않은 내용을 저장하는 기능도 없으므로, 미저장 내용이 있다면 먼저 복사해 둡니다.
화면에 REST 저장 완료가 보이면 같은 파일을 GET하거나 새로고침해 content가 바뀌었는지 확인합니다. 저장에 실패하면 브라우저 개발자 도구의 Network 탭에서 PATCH 응답, 현재 토큰, 파일 ID부터 살펴봅니다. 이 예제는 실패한 요청을 자동으로 다시 보내지 않습니다.
선택 협업 모드
Gateway를 등록했다면 같은 계정으로 로그인한 두 화면에서 같은 파일을 엽니다. 두 화면 모두 협업 모드를 켜고 상태가 협업 저장 준비로 바뀔 때까지 기다립니다. 한쪽에서 입력하면 그 화면에는 저장 ACK가, 다른 화면에는 상대 변경 반영이 표시되어야 합니다. 다른 계정의 파일이나 잘못된 프로젝트·파일 ID로는 참가할 수 없어야 합니다.
다른 세션의 변경은 applyContent()로 Monaco와 React 상태에 함께 적용합니다. 이때 setValue() 때문에 변경 핸들러가 다시 호출되지만, applyingRemoteRef와 모델 전체를 바꿀 때 켜지는 event.isFlush를 확인해 새 저장 요청을 만들지 않습니다. 서버도 보낸 연결을 뺀 방의 나머지 연결에만 code_update를 보내므로, 자기 변경을 다시 받아 저장하는 일이 없습니다. 수신 핸들러는 update.fileId가 지금 연 파일과 다르면 먼저 돌아오므로, 다른 파일의 변경이 현재 에디터에 들어오지도 않습니다.
협업 모드에서 ACK가 오지 않으면 저장되었는지 알 수 없으므로, 같은 파일을 GET해 현재 내용을 먼저 확인합니다. 연결이 끊겼을 때도 자동으로 다시 연결하거나 REST 저장으로 바꾸지 않습니다. 현재 내용을 복사해 두고, 서버와 토큰을 확인한 뒤 페이지를 새로 불러옵니다.
다른 세션의 변경을 받았는데 이 화면에 미저장 내용이나 진행 중인 저장이 있으면, 두 내용을 합치지 않고 충돌로 처리합니다. conflictRef를 켜고 타이머를 멈추며 joined를 false로 바꾸므로 에디터가 읽기 전용이 되고, 로컬 내용은 그대로 남습니다. 그 뒤 이미 보낸 저장의 성공 ACK가 돌아와도 complete()는 saving만 해제하고 conflictRef를 확인해 돌아오므로, dirty를 지우거나 안내 문구를 덮어쓰지 않습니다. 성공 ACK는 충돌이 해결되었다는 뜻이 아니므로, 사용자가 내용을 보존한 뒤 저장된 내용과 상대 변경을 확인하고 다시 참가해야 합니다.
두 화면에서 동시에 입력하면 전체 문자열이 서로를 덮어쓸 수 있으므로, 한쪽의 저장과 반영을 확인한 뒤 번갈아 편집합니다. 다른 계정 초대, revision 비교, OT나 CRDT, 오프라인 복구는 이 예제의 범위 밖입니다.
같은 연결에서 내용의 최신성
generation은 연결과 화면의 세대를 비교할 뿐, 내용이 얼마나 최신인지는 비교하지 않습니다. 예를 들어 서버가 참가 응답에 담을 내용을 S0로 읽은 뒤, 다른 세션의 저장 S1이 완료되어 code_update(S1)을 먼저 보내고 참가 ACK(S0)를 나중에 보냈다고 해 보겠습니다. 실제로 이런 순서가 생길지는 서버에서 응답을 만들고 저장하는 시점에 달려 있으며, 아래 표는 클라이언트의 콜백을 읽기 위해 구성한 사례입니다.
| 콜백 순서 | 현재 조건 | 이 코드의 화면 값 |
|---|---|---|
code_update(S1) | 같은 fileId와 generation, dirty=false, saving=false | applyContent()로 S1 적용 |
| 참가 ACK(S0) | 같은 generation, ok=true | ACK의 content로 S0 적용 |
Socket.IO가 서버에서 보낸 순서대로 이벤트를 전달하더라도, 서버가 먼저 읽어 둔 내용에 최신이라는 표시가 붙지는 않습니다. 이 코드는 참가 ACK에서 applyContent(ack.content ?? '')를 호출하고 버전을 비교하지 않으므로, 이 순서에서는 오래된 S0가 마지막 화면 값이 됩니다. 이를 막으려면 참가 응답과 변경 이벤트에 같은 revision 번호를 붙여 비교하거나, 참가가 끝나기 전에 받은 이벤트를 모아 두었다가 참가 응답의 내용을 기준으로 처리하는 규칙을 서버와 클라이언트가 함께 정해야 합니다. 이벤트의 전송 순서와 전달 보장의 범위는 Socket.IO 공식 문서에서 확인할 수 있습니다.
실행과 확인 순서
프런트엔드 프로젝트 폴더에서 npm run dev로 개발 서버를 엽니다. 기본 주소는 http://localhost:5173이며, 백엔드의 main.ts에서 이 출처에 CORS를 허용해 두었습니다. 기본 REST 모드를 확인하는 데는 Gateway가 필요하지 않습니다.
먼저 회원 가입과 로그인을 마치고 프로젝트를 만들면 에디터 화면으로 이동합니다. 새 프로젝트에는 파일이 없으므로, API의 POST /projects/:projectId/files로 파일을 만든 뒤 페이지를 새로 불러옵니다. 기본 모드에서 파일을 편집하고 PATCH 응답이 온 뒤 같은 파일을 다시 조회해 내용을 확인합니다. 저장에 실패했다면 새로고침하기 전에 편집한 내용을 복사해 둡니다.
협업 모드를 확인할 때만 Gateway를 등록하고, 두 로그인 세션에서 같은 파일에 참가합니다. 한쪽에서 입력하고, 서버의 ACK, 상대 화면의 반영, 파일 재조회의 순서로 결과를 확인합니다.
정리
- 에디터는 마지막 입력 뒤 800ms에 저장하며, 다음 입력이 오면 이전 타이머를 지우고 다시 기다립니다.
- 한 입력은 모드에 따라 REST PATCH나 Socket
code_change중 한 경로로만 저장합니다. - 미저장 내용이나 진행 중인 저장이 있으면 파일 전환과 모드 전환을 잠가, 보내지 않은 내용을 잃지 않게 합니다.
generation비교는 늦게 돌아온 응답이 지금 화면을 바꾸지 않게 할 뿐, 서버의 저장을 취소하지는 않습니다.- 원격 변경을 적용할 때는
applyingRemoteRef와isFlush로 새 저장 요청을 막고, 미저장 내용과 겹치면 합치지 않고 충돌로 처리합니다. - 연결 세대가 같아도 내용의 최신성은 보장되지 않으므로, 순서를 보장하려면 revision 같은 규칙을 서버와 함께 정해야 합니다.
연습 문제
1. 연속 입력과 저장 시점
파일 A에 첫 입력을 한 시점을 0ms, 두 번째 입력을 300ms라고 합시다. 그 뒤로는 입력이 없습니다. 저장 요청은 언제, 어떤 내용으로 시작될까요? 800ms가 서버 저장이 끝나는 시간이라고 볼 수 있을까요?
해설 보기
두 번째 입력에서 첫 입력의 타이머를 지우므로 800ms 시점에는 저장하지 않습니다. 두 번째 입력부터 800ms가 지난 1,100ms 시점에, 두 번째 입력까지 반영된 내용으로 저장 요청을 시작합니다.
800ms는 요청을 시작하기까지 기다리는 시간일 뿐, 요청이 끝나는 시간이나 상대 화면에 반영되는 시간의 상한이 아닙니다. 저장이 끝났는지는 PATCH 응답이나 ACK로 확인합니다.
2. 페이지를 떠난 뒤 돌아온 응답
파일 A의 저장 요청을 보낸 직후 다른 페이지로 이동했고, 새 화면이 열린 뒤 A의 성공 응답이 돌아왔습니다. 예전 콜백이 화면에서 무시되었다면 A의 서버 저장도 취소되었다고 볼 수 있을까요? 협업 모드에서 ACK timeout이 난 경우와 비교해 보세요.
해설 보기
볼 수 없습니다. generation 비교는 지금 화면을 갱신할지 정하는 조건이며, 요청은 이미 서버에 도착해 저장되었을 수 있습니다. 실제로 성공 응답이 돌아왔다면 저장은 완료된 것입니다.
ACK timeout도 정해진 시간 안에 응답을 확인하지 못했다는 뜻일 뿐, 저장 여부를 알려 주지 않습니다. 두 경우 모두 미저장 내용을 보존한 뒤 같은 파일을 GET해 서버에 저장된 내용을 확인합니다. 이 예제에는 요청을 취소하거나 revision으로 오래된 저장을 막는 서버 기능이 없습니다.
3. 늦게 도착한 참가 응답
같은 파일과 generation에서 code_update(S1)을 먼저 받고, 그 뒤에 내용이 S0인 참가 ACK를 받았습니다. 두 콜백 모두 generation 검사를 통과합니다. 화면의 마지막 값은 무엇일까요?
해설 보기
S0입니다. 마지막으로 실행된 applyContent()가 참가 ACK의 S0를 적용하기 때문입니다. generation은 이전 연결의 응답을 걸러 낼 뿐, 같은 연결 안에서 어느 내용이 더 최신인지는 판단하지 않습니다.
최신 내용을 지키려면 서버가 저장할 때마다 revision 번호를 올려 참가 응답과 code_update에 함께 담고, 클라이언트가 지금 화면의 revision보다 작은 내용은 적용하지 않는 규칙을 추가해야 합니다.
이것으로 13장 3절의 프런트엔드 통합을 마칩니다. 다음 글 테스트, 배포, 운영 전략 수립에서는 지금까지 손으로 확인한 절차를 테스트와 배포, 운영 기준으로 옮깁니다.