같은 파일의 두 세션 연결하기
JWT와 파일 소유권을 확인한 Socket.IO Gateway에서 파일을 저장하고, 같은 파일을 연 다른 세션에 변경을 전달합니다.
REST로 저장한 파일은 새로고침하면 다시 읽을 수 있습니다. 다른 화면에도 바로 보여 주려면 서버가 그 화면에 변경을 보내야 합니다. 이번 선택 실습에서는 같은 계정으로 로그인한 두 브라우저가 파일 하나를 번갈아 편집하는 경로를 추가합니다.
먼저 모듈 연결과 실행의 인증·파일 REST API를 준비해 주세요. Gateway의 기본 역할과 방(room)이 낯설다면 WebSocket 절을 복습합니다. 방은 같은 변경을 받을 연결을 모아 놓는 서버의 전달 대상입니다. 방 이름을 안다고 파일을 열 수 있는 것은 아닙니다.
이 실습은 프로젝트 소유자만 접근하는 기존 모델을 그대로 사용합니다. 공유 초대, 다른 계정과의 공동 편집, 커서 표시, OT/CRDT로 여러 변경을 합치는 기능은 포함하지 않습니다. 한쪽의 저장 응답과 상대 반영을 기다린 뒤 다른 쪽에서 입력하는 순서로 읽어 주세요.
전달과 저장의 순서 정하기
클라이언트는 입력을 멈춘 뒤 code_change를 보냅니다. 서버는 인증과 파일 권한을 확인하고 기존 ProjectService.updateFileContent()로 저장합니다. 저장이 성공한 뒤 같은 방의 다른 연결에 code_update를 보내고, 요청한 연결에는 ACK를 돌려줍니다. ACK는 해당 요청에 대한 응답이며, 여기서 ok: true는 서버의 저장 호출이 성공했다는 뜻입니다. 상대 화면이 이미 갱신되었다는 보장은 아닙니다.
| 이벤트 | 클라이언트가 보내는 값 | 서버가 확인하거나 반환하는 값 |
|---|---|---|
join_file | { projectId, fileId } | JWT·파일 소유권·프로젝트 소속을 확인하고 { ok, fileId, content } 반환 |
code_change | { projectId, fileId, changes } | 참가한 파일인지 확인하고 전체 문자열을 저장한 뒤 { ok, fileId } 반환 |
code_update | 서버에서 수신 | { fileId, userId, changes }; 작성자는 JWT에서 결정 |
leave_file | 별도 값 없음 | 현재 파일 방에서 나가고 { ok: true } 반환 |
이 이벤트 계약에서는 클라이언트가 userId를 보내지 않습니다. 요구사항 절의 이벤트 후보를 모두 구현하는 것도 아닙니다. 연결은 HTTP의 Passport 가드와 별도로 인증하고, join_file·code_change 요청마다 토큰 만료를 다시 확인합니다. 이미 방에 참가해 수신만 하는 연결을 토큰 만료 시점에 자동으로 끊거나, 권한 변경 즉시 방에서 내보내는 기능은 없습니다.
파일 저장·이벤트 전달·ACK의 서로 다른 관측
작성 세션의 변경·서버 검사/저장·상대 세션으로의 emit·작성 세션의 ACK를 별도로 관측하며 ACK와 상대 적용의 순서는 확정하지 않는다.
Nest · Save before notify
저장·전달·응답을 나누어 확인하기
같은 파일 방에 참가한 두 세션 중 한쪽만 편집하는 정상 경로입니다. 서버의 save → emit → ACK 순서를 읽고, 상대 화면의 적용 완료는 별도 관측으로 확인합니다.
실선은 변경 요청과 서버 처리의 다음 단계, 점선은 저장 응답과 비동기 전달입니다. 넓은 그림의 아래쪽은 서버 호출 순서를 나타내며 ACK와 상대 화면의 완료 순서를 정하지 않습니다. 모바일도 수신 경로와 작성자 ACK를 서로 다른 끝점으로 나눕니다.
ACK timeout이면 저장 여부가 불확실합니다. 같은 파일을 GET하고 미저장 내용을 보존합니다. 저장·다른 세션 전달은 하나의 트랜잭션이 아니며, 동시 입력의 revision·충돌 제어를 이 그림이 보장하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 같은 파일에 참가한 작성 세션이 변경을 보낸다. 서버가 확인과 저장을 마친 뒤 다른 세션에 code_update를 emit하고 작성 세션에 ACK를 반환한다. ACK와 상대 화면 적용의 완료 순서는 보장하지 않는다.
- 원래 가로 그림의 작성 세션 A, Gateway, DB, 수신 세션 B 네 참여자와 다섯 메시지를 두 연속 구간에 보존합니다. 첫 구간은 A의 code_change, Gateway의 파일 저장, DB의 저장 성공 반환입니다. 다음 구간은 같은 Gateway가 수신 세션 B로 code_update를 emit하고 같은 작성 세션 A에 ACK ok: true를 반환합니다. A와 Gateway의 반복 헤더와 생명선은 새 참여자가 아니라 같은 참여자의 계속입니다. 두 구간의 화살표는 원 서버 호출 순서를 설명하며 수신 세션 B의 화면 적용 완료와 A가 ACK를 관찰하는 상대 순서를 보장하지 않습니다. Gateway의 인증·소유권·파일 소속·방 참가 확인 조건은 본문의 원 코드와 같습니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 파일 저장·이벤트 전달·ACK의 서로 다른 관측 |
| 20 | 원래 가로 그림의 작성 세션 A, Gateway, DB, 수신 세션 B 네 참여자와 다섯 메시지를 두 연속 구간에 보존합니다. 첫 구간은 A의 code_change, Gateway의 파일 저장, DB의 저장 성공 반환입니다. 다음 구간은 같은 Gateway가 수신 세션 B로 code_update를 emit하고 같은 작성 세션 A에 ACK ok: true를 반환합니다. A와 Gateway의 반복 헤더와 생명선은 새 참여자가 아니라 같은 참여자의 계속입니다. 두 구간의 화살표는 원 서버 호출 순서를 설명하며 수신 세션 B의 화면 적용 완료와 A가 ACK를 관찰하는 상대 순서를 보장하지 않습니다. Gateway의 인증·소유권·파일 소속·방 참가 확인 조건은 본문의 원 코드와 같습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 작성 세션의 변경 같은 파일의 작성 세션이 변경을 전송합니다. | 발신: 작성 세션 · 대상: 같은 파일 · 저장: 아직 미완료 | 변경이 서버 입력으로 들어옵니다. |
| 서버 확인·저장 서버가 확인을 마친 뒤 변경을 저장합니다. | 서버: 확인·저장 · 다른 세션: 아직 적용 증명 없음 | 저장 완료가 후속 통지의 앞 조건이 됩니다. |
| 방송과 ACK 다른 세션에 code_update를 emit하고 작성 세션에 ACK를 반환합니다. | 다른 세션: code_update · 작성자: ACK · 상대 적용 완료: ACK가 보장하지 않음 | 저장 후 두 수신 방향으로 결과를 전달합니다. |
파일 저장·이벤트 전달·ACK의 서로 다른 관측
작성 세션의 변경·서버 검사/저장·상대 세션으로의 emit·작성 세션의 ACK를 별도로 관측하며 ACK와 상대 적용의 순서는 확정하지 않는다.
Nest · Save before notify
저장·전달·응답을 나누어 확인하기
같은 파일 방에 참가한 두 세션 중 한쪽만 편집하는 정상 경로입니다. 서버의 save → emit → ACK 순서를 읽고, 상대 화면의 적용 완료는 별도 관측으로 확인합니다.
실선은 변경 요청과 서버 처리의 다음 단계, 점선은 저장 응답과 비동기 전달입니다. 넓은 그림의 아래쪽은 서버 호출 순서를 나타내며 ACK와 상대 화면의 완료 순서를 정하지 않습니다. 모바일도 수신 경로와 작성자 ACK를 서로 다른 끝점으로 나눕니다.
ACK timeout이면 저장 여부가 불확실합니다. 같은 파일을 GET하고 미저장 내용을 보존합니다. 저장·다른 세션 전달은 하나의 트랜잭션이 아니며, 동시 입력의 revision·충돌 제어를 이 그림이 보장하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 같은 파일에 참가한 작성 세션이 변경을 보낸다. 서버가 확인과 저장을 마친 뒤 다른 세션에 code_update를 emit하고 작성 세션에 ACK를 반환한다. ACK와 상대 화면 적용의 완료 순서는 보장하지 않는다.
- 원래 가로 그림의 작성 세션 A, Gateway, DB, 수신 세션 B 네 참여자와 다섯 메시지를 두 연속 구간에 보존합니다. 첫 구간은 A의 code_change, Gateway의 파일 저장, DB의 저장 성공 반환입니다. 다음 구간은 같은 Gateway가 수신 세션 B로 code_update를 emit하고 같은 작성 세션 A에 ACK ok: true를 반환합니다. A와 Gateway의 반복 헤더와 생명선은 새 참여자가 아니라 같은 참여자의 계속입니다. 두 구간의 화살표는 원 서버 호출 순서를 설명하며 수신 세션 B의 화면 적용 완료와 A가 ACK를 관찰하는 상대 순서를 보장하지 않습니다. Gateway의 인증·소유권·파일 소속·방 참가 확인 조건은 본문의 원 코드와 같습니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 파일 저장·이벤트 전달·ACK의 서로 다른 관측 |
| 20 | 원래 가로 그림의 작성 세션 A, Gateway, DB, 수신 세션 B 네 참여자와 다섯 메시지를 두 연속 구간에 보존합니다. 첫 구간은 A의 code_change, Gateway의 파일 저장, DB의 저장 성공 반환입니다. 다음 구간은 같은 Gateway가 수신 세션 B로 code_update를 emit하고 같은 작성 세션 A에 ACK ok: true를 반환합니다. A와 Gateway의 반복 헤더와 생명선은 새 참여자가 아니라 같은 참여자의 계속입니다. 두 구간의 화살표는 원 서버 호출 순서를 설명하며 수신 세션 B의 화면 적용 완료와 A가 ACK를 관찰하는 상대 순서를 보장하지 않습니다. Gateway의 인증·소유권·파일 소속·방 참가 확인 조건은 본문의 원 코드와 같습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 작성 세션의 변경 같은 파일의 작성 세션이 변경을 전송합니다. | 발신: 작성 세션 · 대상: 같은 파일 · 저장: 아직 미완료 | 변경이 서버 입력으로 들어옵니다. |
| 서버 확인·저장 서버가 확인을 마친 뒤 변경을 저장합니다. | 서버: 확인·저장 · 다른 세션: 아직 적용 증명 없음 | 저장 완료가 후속 통지의 앞 조건이 됩니다. |
| 방송과 ACK 다른 세션에 code_update를 emit하고 작성 세션에 ACK를 반환합니다. | 다른 세션: code_update · 작성자: ACK · 상대 적용 완료: ACK가 보장하지 않음 | 저장 후 두 수신 방향으로 결과를 전달합니다. |
Gateway를 기존 모듈에 붙이기
아래 설치는 Nest 11 프로젝트를 기준으로 합니다. 기존 @nestjs/core와 @nestjs/common의 major가 다르면 WebSocket 패키지도 그 major에 맞춰 주세요. 이 실습을 위해 인증·ORM 패키지 전체를 다른 major로 바꾸지 않습니다. Socket.IO 4에는 타입 선언이 들어 있으므로 별도 @types/socket.io 설치는 필요하지 않습니다.
npm install @nestjs/websockets@11 @nestjs/platform-socket.io@11 socket.io@4HTTP API와 같은 서버 주소를 사용합니다. 다음 파일을 만들고, Gateway의 cors.origin을 Vite의 출처에 맞춥니다. 아래 값은 기본 출처인 http://localhost:5173입니다. 출처를 바꿨다면 이 값과 HTTP 서버의 CORS 설정도 함께 맞춰 주세요. Gateway의 CORS 설정이 REST API의 CORS 설정까지 바꾸지는 않습니다.
import {
OnGatewayInit, WebSocketGateway, SubscribeMessage,
MessageBody, ConnectedSocket,
} from '@nestjs/websockets';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';
import { AuthService } from '../auth/auth.service';
import { ProjectService } from '../project/project.service';
interface FileRequest { projectId: string; fileId: string; }
interface ChangeRequest extends FileRequest { changes: string; }
@WebSocketGateway({
cors: { origin: 'http://localhost:5173' },
})
export class RealtimeGateway implements OnGatewayInit {
constructor(
private readonly jwt: JwtService,
private readonly auth: AuthService,
private readonly projects: ProjectService,
) {}
afterInit(server: Server) {
server.use(async (client, next) => {
try {
const token = client.handshake.auth.token;
const payload = await this.jwt.verifyAsync<{ sub: string }>(token);
const user = await this.auth.validateUser(payload.sub);
if (!user) throw new Error('Unknown user');
client.data.userId = user.id;
next();
} catch {
next(new Error('Login is required.'));
}
});
}
private room(fileId: string) { return `file:${fileId}`; }
private async checkFile(client: Socket, data: FileRequest) {
const payload = await this.jwt.verifyAsync<{ sub: string }>(
client.handshake.auth.token,
);
if (payload.sub !== client.data.userId) throw new Error('Invalid user');
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!data || !uuid.test(data.projectId) || !uuid.test(data.fileId)) {
throw new Error('Invalid file ID');
}
const file = await this.projects.findFileById(data.fileId, client.data.userId);
if (file.projectId !== data.projectId) throw new Error('Wrong project');
return file;
}
@SubscribeMessage('join_file')
async join(@ConnectedSocket() client: Socket, @MessageBody() data: FileRequest) {
try {
const file = await this.checkFile(client, data);
if (client.data.fileId) await client.leave(this.room(client.data.fileId));
await client.join(this.room(file.id));
client.data.fileId = file.id;
return { ok: true, fileId: file.id, content: file.content ?? '' };
} catch {
return { ok: false, message: 'Unable to open this file.' };
}
}
@SubscribeMessage('leave_file')
async leave(@ConnectedSocket() client: Socket) {
if (client.data.fileId) await client.leave(this.room(client.data.fileId));
client.data.fileId = undefined;
return { ok: true };
}
@SubscribeMessage('code_change')
async change(@ConnectedSocket() client: Socket, @MessageBody() data: ChangeRequest) {
try {
await this.checkFile(client, data);
if (client.data.fileId !== data.fileId || !client.rooms.has(this.room(data.fileId))) {
return { ok: false, message: 'Join the file before editing.' };
}
if (typeof data.changes !== 'string' || data.changes.length > 200_000) {
return { ok: false, message: 'Invalid content or content too long.' };
}
const file = await this.projects.updateFileContent(
data.fileId, client.data.userId, data.changes,
);
client.to(this.room(file.id)).emit('code_update', {
fileId: file.id, userId: client.data.userId, changes: file.content,
});
return { ok: true, fileId: file.id };
} catch {
return { ok: false, message: 'Save failed. Check login, owner and server.' };
}
}
}checkFile()은 파일의 소유자뿐 아니라 요청한 projectId가 실제 파일의 소속과 같은지도 확인합니다. code_change는 이 검사에 더해 현재 연결이 해당 파일 방에 참가했는지 확인합니다. 파일 ID를 알거나 다른 파일의 방에 참가한 연결이 곧바로 변경을 보내지 못하도록 하는 부분입니다. 문자열 길이 제한은 changes.length 기준이며, 운영용 요청 빈도 제한이나 저장 용량 정책을 대신하지 않습니다.
이 Gateway를 다음 모듈의 providers에 등록합니다. 기존 AuthModule은 AuthService와 JwtModule을, ProjectModule은 ProjectService를 내보내므로 별도 JWT 비밀이나 다른 저장 서비스를 만들 필요가 없습니다.
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ProjectModule } from '../project/project.module';
import { RealtimeGateway } from './realtime.gateway';
@Module({
imports: [AuthModule, ProjectModule],
providers: [RealtimeGateway],
})
export class RealtimeModule {}기존 src/app.module.ts에 다음 import를 추가하고, 기존 imports 배열에 RealtimeModule을 한 항목 추가합니다. ConfigModule, TypeOrmModule, AuthModule, ProjectModule은 그대로 둡니다. 같은 DB 연결을 새로 등록하지 않습니다.
import { RealtimeModule } from './realtime/realtime.module';예시
같은 파일에 참가한 두 세션 A·B 중 A만 새 내용을 보냅니다. updateFileContent()가 완료된 뒤 서버는 B에게 code_update를 emit하고 A에게 성공 ACK를 반환합니다. B의 이벤트 처리와 화면 갱신은 클라이언트 쪽 작업이므로 A가 ACK를 먼저 볼 수도 있습니다. 서버에서 emit을 호출했다는 기록, A의 ACK, B의 화면 반영을 각각 관찰합니다.
길이 조건도 코드의 단위로 계산해 보세요. JavaScript의 문자열 length는 UTF-16 코드 단위 수입니다. "a"는 1이고 "😀"는 2입니다. 따라서 "😀".repeat(100_000)은 길이 200,000으로 이 길이 검사를 통과하지만, 한 개 더 붙이면 200,002로 거부됩니다. 이는 이모지 개수나 UTF-8 저장 바이트 한도가 아니며, 다른 인증·방 참가·저장 검사를 생략한다는 뜻도 아닙니다.
전달 결과 판단 문제
A가 ACK timeout을 보았지만 GET한 파일에는 A가 보낸 내용이 있습니다. B는 이전 내용을 보여 줍니다. 저장 실패와 화면 전달 실패를 어떻게 구분하고, 즉시 같은 내용을 재전송하는 대신 무엇을 먼저 확인할까요?
관측 범위 확인
GET 결과는 조회한 시점에 그 내용이 저장되어 있었다는 관측입니다. timeout만으로 저장 실패를 확정하지 않습니다. B의 파일 참가·연결·이벤트 수신·원격 변경 적용을 따로 확인하고, A의 미저장 내용을 먼저 보존합니다. 저장과 전달이 하나의 트랜잭션이 아니므로 저장만 완료된 경우도 가능합니다. 같은 문자열이 저장되어 있다는 결과 하나로 어느 요청이 언제 확정됐는지나 이후 덮어쓰기까지 증명할 수는 없습니다.
응답이 없을 때 저장 상태 읽기
클라이언트는 다음 에디터 절에서 socket.timeout(3000).emit(...)으로 ACK 대기 시간을 둡니다. ok: false는 서버가 요청을 거부하거나 저장 오류를 알린 경우입니다. timeout은 저장 실패를 확정하지 않습니다. 서버가 저장했지만 응답이 늦거나 끊겼을 수도 있으므로 같은 파일을 GET하여 내용을 먼저 확인합니다. 바로 REST 저장으로 바꾸거나 자동 재전송하면 확인되지 않은 변경을 다시 덮어쓸 수 있습니다.
DB 저장과 다른 연결로의 전달도 하나의 트랜잭션은 아닙니다. 저장 직후 연결이 끊기면 다른 화면이 변경을 놓칠 수 있습니다. 다시 파일을 열 때 join_file 응답의 저장된 내용을 읽지만, 오프라인 편집이나 놓친 이벤트를 순서대로 복원하는 기능은 없습니다.
두 세션이 동시에 입력하면 전체 문자열이 서로 덮어써질 수 있습니다. revision 비교, 요청 중복 제거, 여러 서버 사이의 저장 순서, 삭제·권한 변경과 편집의 동시 처리도 이 예제에서 보장하지 않습니다. 화면에서 저장 중 입력을 잠시 막는 것만으로 다른 브라우저의 입력까지 막을 수는 없습니다.
이제 코드 에디터와 파일 목록의 선택 협업 모드로 연결합니다. 기본 REST 모드에서는 서버에 PATCH를 보내고, 협업 모드에서는 Gateway가 저장을 맡습니다. 한 번의 입력에 두 저장 요청을 보내지 않는 것이 두 경로를 읽는 출발점입니다.