코드 에디터와 파일 목록
Monaco Editor와 파일 목록을 REST 자동 저장에 연결하고, 선택 협업 모드에서 서버 저장·다른 세션 반영·연결 정리를 다룹니다.
파일을 열고 저장하기
이번에는 목록에서 파일을 골라 Monaco Editor로 편집하고, 마지막 입력 뒤 800 ms에 저장하는 흐름을 연결합니다. 먼저 백엔드 실행 절의 API로 파일 하나를 만들어 두세요. 화면에는 평탄한 파일 목록을 제공하며, 파일 생성 UI와 폴더 트리는 확장 과제입니다.
기본 모드는 REST입니다. 같은 파일의 두 세션 연결하기의 Gateway를 추가했다면 협업 모드 사용을 선택할 수 있습니다. 이 모드에서는 Socket 이벤트를 받은 서버가 저장하므로, 클라이언트가 같은 입력을 다시 PATCH하지 않습니다. 기본 REST 흐름을 먼저 읽은 뒤 선택 모드로 이어가도 됩니다.
아래 파일은 로그인·프로젝트 라우팅이 준비된 React 프로젝트에 붙이는 예제입니다. 이전의 에디터 스케치를 이 파일로 바꿉니다. 서버 주소는 앞 절의 http://localhost:3000을 사용하며, 회원가입 화면과 DTO, DB·CORS 준비는 앞 절의 안내를 따릅니다.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import Editor from '@monaco-editor/react';
import type { editor } from 'monaco-editor';
import { io, type Socket } from 'socket.io-client';
import api from '../api/axiosInstance';
interface FileInfo {
id: string; name: string; path: string; content: string; type: string;
}
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('http://localhost:3000', {
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={() => 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>;
}기본 REST 흐름 읽기
처음에는 협업 모드가 꺼져 있습니다. 파일을 고르고 console.log(2);처럼 내용을 바꿔 보세요. 입력을 멈춘 뒤 800ms에 /content PATCH를 보내고, 화면의 저장 상태를 바꿉니다. 디바운스(debounce)는 입력마다 즉시 저장하는 대신 마지막 입력 뒤 잠시 기다리는 방식입니다. 다음 입력이 들어오면 이전 타이머를 지우고 다시 기다립니다.
저장 전 파일을 바꾸면 아직 보내지 않은 내용을 잃을 수 있습니다. 그래서 미저장 내용이나 저장 요청이 있는 동안 파일 버튼과 모드 선택을 잠시 막습니다. 저장 응답을 기다리는 동안에는 에디터도 읽기 전용으로 바꿔 요청이 겹치지 않게 합니다. 이것은 현재 화면의 편의를 위한 처리이며 서버의 동시성 제어는 아닙니다.
파일을 다시 선택할 때는 목록에 남아 있던 예전 내용 대신 GET으로 최신 저장 내용을 읽습니다. 조회 중에는 입력을 잠시 막지만 기존 파일의 연결은 유지하고, 조회가 성공한 뒤 새 파일로 전환합니다. 조회가 실패하면 기존 파일에서 계속 편집할 수 있습니다. 타이머와 연결은 해제 시 정리하고, 이전 파일·페이지에서 늦게 돌아온 응답은 generationRef로 현재 상태와 구분합니다. 페이지를 닫을 때 보내지 않은 내용까지 저장하는 기능은 없으므로, 미저장 내용이 있다면 먼저 복사해 두세요.
REST 저장 완료가 보이더라도 같은 파일을 GET하거나 새로고침해 content를 확인합니다. 실패하면 Network 탭의 PATCH 응답·현재 토큰·파일 ID부터 살펴봅니다. 이 예제는 실패한 요청을 자동으로 재시도하지 않습니다.
타이머와 파일 ID를 함께 따라가기
파일 A에 첫 입력을 한 시점을 0 ms, 다음 입력을 300 ms라고 합시다. 두 번째 입력에서 이전 타이머를 지우므로 첫 입력의 800 ms 시점에는 저장하지 않고, 다른 입력이 없다면 1,100 ms 시점에 두 번째 값으로 저장을 시작합니다. 800 ms는 요청 완료 시간이나 상대 화면 반영 시간의 상한이 아닙니다.
타이머를 만들 때 fileId·generation·입력 value를 붙잡아 둡니다. 나중에 돌아온 응답은 complete()에서 현재 generation과 비교한 뒤 화면 상태를 바꿉니다. 이 비교는 예전 페이지의 응답이 새 화면을 바꾸지 않게 하는 장치입니다. 이미 서버로 보낸 요청을 취소하거나 DB에 저장된 값을 되돌리는 기능은 아닙니다.
응답이 늦은 경우 생각해 보기
파일 A의 요청을 보낸 뒤 페이지를 나갔고, 새 화면을 연 뒤 A의 성공 응답이 돌아왔다고 합시다. 예전 콜백이 화면에서 무시되면 A의 서버 저장도 취소됐다고 판단할 수 있나요? ACK timeout일 때도 같은 이유로 답해 보세요.
화면 생명주기와 저장의 범위 확인
판단할 수 없습니다. generation 비교는 현재 화면을 갱신할지 정하는 조건입니다. 요청은 이미 서버에 도착해 저장됐을 수 있습니다. timeout도 응답을 정해진 시간에 확인하지 못했다는 뜻이므로 미저장 내용을 보존한 뒤 같은 파일의 GET 결과와 서버 상태를 확인합니다. 현재 예제에는 요청 취소와 revision 비교로 오래된 저장을 차단하는 서버 계약이 없습니다.
선택 협업 모드로 이어가기
Gateway를 등록하고 같은 계정으로 로그인한 두 화면에서 같은 파일을 엽니다. 두 화면 모두 협업 모드를 선택한 뒤 협업 저장 준비를 기다립니다. 한쪽에서 입력하면 서버의 저장 ACK와 다른 화면의 상대 변경 반영을 확인하는 흐름입니다. 다른 계정의 파일에 참가하거나 잘못된 프로젝트·파일 ID를 보내면 참가할 수 없어야 합니다. 이 절의 절차는 예상 확인 방법이며 실행 결과를 뜻하지 않습니다.
원격 내용은 applyContent()에서 Monaco와 React 상태에 적용합니다. 이때 변경 핸들러가 다시 호출되어도 applyingRemoteRef와 모델 초기화 이벤트의 isFlush를 확인해 새로운 저장을 보내지 않습니다. 서버도 요청한 연결을 제외한 파일 방에만 전달하므로, 자기 변경을 다시 수신해 저장하는 경로를 줄입니다.
협업 모드에서는 PATCH와 Socket 저장을 함께 하지 않습니다. ACK가 없으면 저장 여부가 불확실하므로 GET으로 현재 내용을 먼저 확인합니다. 연결이 끊겼을 때는 자동 재접속이나 REST 저장으로 바꾸지 않습니다. 현재 내용을 복사하고 서버·토큰을 확인한 뒤 페이지를 새로 불러와 주세요.
상대 변경을 받을 때 미저장 내용이나 진행 중인 저장이 있으면 타이머를 멈추고 안내를 표시합니다. 이미 서버에 보낸 요청은 그 뒤에도 저장될 수 있으며, 이 처리가 두 변경을 합치거나 충돌을 완전히 막는 것은 아닙니다. 두 화면이 동시에 입력하면 전체 문자열이 덮어써질 수 있으므로 한쪽의 저장과 반영을 기다린 뒤 번갈아 편집합니다. 공유 초대·revision 비교·OT/CRDT·오프라인 복구는 별도 확장입니다.
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 · 상대 화면 적용: 별도 | 전송과 저장 확인을 구분합니다. |
준비와 확인 순서
프런트엔드 프로젝트는 앞 절의 Vite 설정에서 npm run dev로 엽니다. 기본 출처는 http://localhost:5173입니다. 회원가입 화면, DTO, 서버 CORS와 DB 설정이 준비되어 있어야 하며, 기본 REST 확인에는 Gateway가 필요하지 않습니다.
회원가입·로그인 뒤 프로젝트를 만들고 에디터 화면으로 이동합니다.
파일이 없다면 API의 POST /projects/:projectId/files로 준비한 뒤 다시 불러옵니다. 파일 생성 UI와 폴더 트리는 이 화면에 포함하지 않습니다.
기본 모드에서 파일을 편집하고 PATCH 응답 뒤 같은 파일을 재조회합니다. 저장 실패 상태에서 새로고침하기 전에는 편집한 내용을 복사해 둡니다.
선택 협업을 다룰 때만 Gateway를 등록하고 두 로그인 세션에서 같은 파일에 참가합니다. 한쪽 입력→서버 ACK→상대 화면 반영→파일 재조회의 순서로 읽습니다.
다음 테스트, 배포, 운영 전략에서는 이런 확인 절차를 테스트와 운영 계획으로 연결합니다. 두 세션의 작은 전달 예제가 공유 권한·동시 편집·운영 복구까지 완성했다는 뜻은 아닙니다.
같은 연결에서도 내용의 최신성을 따로 확인하기
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 공식 문서에서 확인할 수 있습니다.
미저장 내용과 원격 변경이 겹친 경우에는 의도적으로 값을 합치지 않습니다. dirty=true에서 code_update가 오면 conflictRef=true, joined=false가 되고 타이머를 취소합니다. 내용은 기존 로컬 값을 보존합니다. 그 뒤 이미 전송한 저장의 성공 ACK가 돌아와도 complete()는 saving을 해제한 뒤 conflictRef를 검사하여 돌아오므로 dirty를 false로 지우지 않습니다. 이는 자동 병합 완료가 아니라 사용자가 내용을 보존하고 다시 확인해야 하는 상태입니다.
이벤트 순서로 화면 상태를 계산하는 문제
문제 1. 같은 파일·generation에서 위 표 순서로 S1 이벤트와 S0 참가 ACK를 받았습니다. generation 검사가 통과하면 마지막 값은 반드시 최신 S1인가요?
풀이. 아닙니다. 이 코드의 마지막 applyContent() 호출은 참가 ACK의 S0이므로 화면에는 S0이 남습니다. generation은 예전 연결의 응답을 걸러내지만 같은 연결 안의 내용 최신성을 판정하지 않습니다. 서버가 이벤트에 revision을 붙여 비교하게 하는 등의 추가 계약이 필요하며, 단순한 콜백 세대 검사와 구별합니다.
문제 2. 파일 A의 현재 연결에서 파일 B의 code_update를 받았습니다. dirty=false이고 generation도 맞으면 B의 내용을 A에 적용하나요?
풀이. 적용하지 않습니다. 수신 핸들러는 update.fileId !== fileId이면 먼저 돌아옵니다. 연결 세대와 파일 식별자를 모두 맞춰야 현재 파일의 내용 후보가 됩니다. 둘 다 맞더라도 문제 1처럼 최신성은 별도 계약입니다.
문제 3. 로컬 값 L이 미저장이고 저장 응답을 기다리는 중에 원격 값 R을 받았습니다. 충돌 안내 후 원래 저장의 성공 ACK가 왔으니 dirty가 false가 되고 편집을 자동 재개하나요?
풀이. 아닙니다. 원격 핸들러는 L을 보존하고 conflictRef를 켜며 joined를 false로 바꿉니다. 뒤의 complete()는 saving을 해제하지만 conflictRef가 켜져 있으면 dirty를 지우거나 상태 안내를 덮어쓰지 않습니다. 협업 모드에서 joined=false이므로 에디터는 읽기 전용입니다. 로컬 내용을 보존하고 저장 내용과 상대 변경을 확인한 뒤 다시 참가해야 하며, 성공 ACK가 충돌 해결이나 상대 화면의 적용 완료를 보장하지 않습니다.