본문 바로가기

같은 파일의 두 세션 연결하기

JWT와 파일 소유권을 확인한 Socket.IO Gateway에서 파일을 저장하고, 같은 파일을 연 다른 세션에 변경을 전달합니다.

REST로 저장한 파일은 새로고침하면 다시 읽을 수 있지만, 다른 화면에 바로 보여 주려면 서버가 그 화면에 변경을 보내야 합니다. 이 글은 선택 실습으로, 같은 계정으로 로그인한 두 브라우저가 파일 하나를 번갈아 편집하는 경로를 Socket.IO Gateway로 추가합니다.

먼저 모듈 연결과 실행까지의 인증·파일 REST API를 준비해 둡니다. Gateway와 방(room)이 낯설다면 12장의 WebSocket 절을 먼저 읽어 보세요. 방은 같은 변경을 받을 연결을 서버가 모아 두는 전달 대상이며, 방 이름을 안다고 해서 파일을 열 수 있는 것은 아닙니다.

이 실습은 프로젝트 소유자만 접근하는 기존 모델을 그대로 씁니다. 다른 계정 초대와 공동 편집, 커서 표시, OT나 CRDT로 동시에 들어온 변경을 합치는 기능은 포함하지 않습니다. 한쪽에서 저장 응답과 상대 화면의 반영을 확인한 뒤 다른 쪽에서 입력하는 순서로 따라 해 주세요.


이벤트와 처리 순서

클라이언트는 입력을 멈춘 뒤 code_change를 보냅니다. 서버는 인증과 파일 권한을 확인하고, REST API와 같은 ProjectService.updateFileContent()로 저장합니다. 저장에 성공하면 같은 방의 다른 연결에 code_update를 보내고, 요청한 연결에는 ACK를 돌려줍니다. ACK(acknowledgement)는 보낸 이벤트에 대한 응답이며, 여기서 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 guard와 별도로 인증하며, join_file과 code_change 요청마다 토큰이 만료되지 않았는지 다시 확인합니다. 다만 이미 방에 참가해 수신만 하는 연결을 토큰 만료 시점에 끊거나, 권한이 바뀌는 즉시 방에서 내보내는 기능은 없습니다.

파일 저장·이벤트 전달·ACK의 서로 다른 관측

작성 세션의 변경·서버 검사/저장·상대 세션으로의 emit·작성 세션의 ACK를 별도로 관측하며 ACK와 상대 적용의 순서는 확정하지 않는다.

Nest · Save before notify

저장·전달·응답을 나누어 확인하기

같은 파일 방에 참가한 두 세션 중 한쪽만 편집하는 정상 경로입니다. 서버의 save → emit → ACK 순서를 읽고, 상대 화면의 적용 완료는 별도 관측으로 확인합니다.

저장 뒤 emit하고 ACK를 반환하는 서버 순서같은 파일에 참가한 작성 세션이 변경을 보낸다. 서버가 확인과 저장을 마친 뒤 다른 세션에 code_update를 emit하고 작성 세션에 ACK를 반환한다. ACK와 상대 화면 적용의 완료 순서는 보장하지 않는다.code_change파일 저장저장 성공code_updateACK ok: true작성 세션 Acode_changeGateway확인 · 저장DB파일 내용수신 세션 B화면 적용별도 관측
저장 뒤 emit하고 ACK를 반환하는 서버 순서 원 wide의 작성 세션 A, Gateway, DB, 수신 세션 B 네 참여자와 다섯 메시지를 두 연속 구간에 보존합니다. 첫 구간은 A의 code_change, Gateway의 파일 저장, DB의 저장 성공 반환입니다. 다음 구간은 같은 Gateway가 수신 세션 B로 code_update를 emit하고 같은 작성 세션 A에 ACK ok: true를 반환합니다. A와 Gateway의 반복 헤더와 생명선은 새 참여자가 아니라 같은 참여자의 계속입니다. 두 구간의 화살표는 원 서버 호출 순서를 설명하며 수신 세션 B의 화면 적용 완료와 A가 ACK를 관찰하는 상대 순서를 보장하지 않습니다. Gateway의 인증·소유권·파일 소속·방 참가 확인 조건은 본문의 원 코드와 같습니다. 요청 · 저장 작성 세션 A Gateway 확인 저장 DB 파일 내용 code_ change 파일 저장 저장 성공 계속: 같은 A와 Gateway emit · ACK 작성 세션 A Gateway 확인 저장 수신 세션 B code_ update ACK ok: true 같은 A·Gateway DB 저장 뒤 이어 봅니다 서버: 저장 뒤 emit 그다음 ACK 반환 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 순서를 읽고, 상대 화면의 적용 완료는 별도 관측으로 확인합니다.

저장 뒤 emit하고 ACK를 반환하는 서버 순서같은 파일에 참가한 작성 세션이 변경을 보낸다. 서버가 확인과 저장을 마친 뒤 다른 세션에 code_update를 emit하고 작성 세션에 ACK를 반환한다. ACK와 상대 화면 적용의 완료 순서는 보장하지 않는다.code_change파일 저장저장 성공code_updateACK ok: true작성 세션 Acode_changeGateway확인 · 저장DB파일 내용수신 세션 B화면 적용별도 관측
저장 뒤 emit하고 ACK를 반환하는 서버 순서 원 wide의 작성 세션 A, Gateway, DB, 수신 세션 B 네 참여자와 다섯 메시지를 두 연속 구간에 보존합니다. 첫 구간은 A의 code_change, Gateway의 파일 저장, DB의 저장 성공 반환입니다. 다음 구간은 같은 Gateway가 수신 세션 B로 code_update를 emit하고 같은 작성 세션 A에 ACK ok: true를 반환합니다. A와 Gateway의 반복 헤더와 생명선은 새 참여자가 아니라 같은 참여자의 계속입니다. 두 구간의 화살표는 원 서버 호출 순서를 설명하며 수신 세션 B의 화면 적용 완료와 A가 ACK를 관찰하는 상대 순서를 보장하지 않습니다. Gateway의 인증·소유권·파일 소속·방 참가 확인 조건은 본문의 원 코드와 같습니다. 요청 · 저장 작성 세션 A Gateway 확인 저장 DB 파일 내용 code_ change 파일 저장 저장 성공 계속: 같은 A와 Gateway emit · ACK 작성 세션 A Gateway 확인 저장 수신 세션 B code_ update ACK ok: true 같은 A·Gateway DB 저장 뒤 이어 봅니다 서버: 저장 뒤 emit 그다음 ACK 반환 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가 보장하지 않음저장 후 두 수신 방향으로 결과를 전달합니다.

위 그림은 같은 파일 방에 참가한 두 세션 중 A만 편집하는 정상 경로를 세 단계로 보여 줍니다. A가 code_change를 보낸 시점에는 아직 아무것도 저장되지 않았습니다. Gateway가 확인과 저장을 마친 뒤에야 B에게 code_update를 보내고 A에게 ok: true ACK를 돌려줍니다. B의 화면 적용은 그림에서 따로 표시한 대로 별도로 확인해야 하며, A가 ACK를 받는 시점과 B의 화면이 바뀌는 시점의 순서는 정해져 있지 않습니다.


패키지 설치

Gateway 패키지의 major 버전은 프로젝트의 @nestjs/core, @nestjs/common과 맞춥니다. 이 프로젝트는 NestJS 12이므로 12를 설치합니다. Socket.IO 4는 타입 선언을 포함하므로 @types/socket.io는 필요하지 않습니다.

npm install @nestjs/websockets@12 @nestjs/platform-socket.io@12 socket.io@4

RealtimeGateway

Gateway는 HTTP API와 같은 서버 주소와 포트를 씁니다. cors.origin은 Vite 개발 서버의 기본 출처인 http://localhost:5173에 맞춥니다. 앞 글의 app.enableCors()는 HTTP API에만 적용되므로, Gateway의 CORS는 이렇게 따로 설정합니다. 출처를 바꿨다면 두 곳을 함께 고칩니다.

src/realtime/realtime.gateway.ts
import { JwtService } from '@nestjs/jwt';
import {
  ConnectedSocket,
  MessageBody,
  OnGatewayInit,
  SubscribeMessage,
  WebSocketGateway,
} from '@nestjs/websockets';
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;
}

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const MAX_CONTENT_LENGTH = 100_000; // REST의 UpdateFileContentDto와 같은 한도

@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) {
    // 연결을 맺을 때 토큰을 확인하고 사용자 ID를 연결에 기록합니다.
    server.use(async (client, next) => {
      try {
        const payload = await this.jwt.verifyAsync<{ sub: string }>(client.handshake.auth.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');
    if (!data || !UUID.test(data.projectId) || !UUID.test(data.fileId)) {
      throw new Error('Invalid file ID');
    }
    // 소유와 프로젝트 소속은 REST API와 같은 서비스 메서드가 확인합니다.
    return this.projects.findFileById(data.projectId, data.fileId, client.data.userId);
  }

  @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 > MAX_CONTENT_LENGTH) {
        return { ok: false, message: 'Invalid content or content too long.' };
      }
      const file = await this.projects.updateFileContent(
        data.projectId,
        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.' };
    }
  }
}

afterInit()에서 등록한 server.use() middleware는 연결을 맺을 때 한 번 실행됩니다. 토큰을 검증하고 사용자가 실제로 있는지 확인한 뒤 client.data.userId에 ID를 기록하며, 실패하면 next(new Error(...))로 연결을 거부합니다. 클라이언트는 이 토큰을 io(url, { auth: { token } })의 auth 옵션으로 보냅니다.

checkFile()은 요청이 올 때마다 토큰을 다시 검증하고, 두 ID가 UUID 형식인지 확인한 뒤 findFileById()를 호출합니다. 이 메서드가 REST API와 똑같이 프로젝트의 소유자와 파일의 프로젝트 소속을 확인하므로, Gateway에서 따로 비교할 필요가 없습니다. UUID 검사는 HTTP의 ParseUUIDPipe와 같은 역할로, 잘못된 형식의 ID가 PostgreSQL의 형식 오류로 이어지지 않게 합니다.

code_change는 이 검사에 더해, 현재 연결이 join_file로 그 파일의 방에 참가했는지 확인합니다. 파일 ID만 알거나 다른 파일의 방에 참가한 연결은 곧바로 변경을 보낼 수 없습니다. 내용 길이는 REST의 UpdateFileContentDto와 같은 숫자인 100,000으로 제한하지만, 세는 방식은 다릅니다. 여기서는 문자열의 length로 세므로 이모지처럼 length가 2인 문자를 두 글자로 세고, REST의 @MaxLength()는 이런 문자를 한 글자로 셉니다. 그래서 이모지가 많은 내용은 REST로는 저장되지만 이 이벤트에서는 거부될 수 있습니다. 이 한도는 요청 빈도 제한이나 저장 용량 정책을 대신하지 않습니다. 이와 별개로 Socket.IO 서버는 기본으로 1MB(maxHttpBufferSize)보다 큰 메시지를 받으면 연결을 끊습니다.

client.to(room).emit()은 보낸 연결을 뺀 방의 나머지 연결에 이벤트를 보냅니다. 연결이 끊기면 Socket.IO가 그 연결을 모든 방에서 자동으로 빼므로, 연결 종료 처리를 따로 쓰지 않았습니다.


모듈 등록

Gateway를 RealtimeModule의 providers에 등록합니다. 앞에서 AuthModule은 AuthService와 JwtModule을, ProjectModule은 ProjectService를 내보냈으므로, 두 모듈을 import하기만 하면 됩니다. JWT 비밀 값이나 저장 서비스를 새로 만들지 않습니다.

src/realtime/realtime.module.ts
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은 그대로 두며, 데이터베이스 연결을 새로 등록하지 않습니다.

src/app.module.ts (추가 부분)
import { RealtimeModule } from './realtime/realtime.module';

// @Module({ imports: [..., AuthModule, ProjectModule, RealtimeModule] })

응답이 없을 때 저장 상태 읽기

13장 3절의 에디터는 socket.timeout(3000).emit(...)으로 ACK를 3초까지 기다립니다. ok: false는 서버가 요청을 거부했거나 저장 오류를 알린 경우입니다. 반면 timeout은 저장 실패를 뜻하지 않습니다. 서버는 저장을 마쳤지만 응답이 늦거나 연결이 끊겼을 수도 있으므로, 같은 파일을 GET해 저장된 내용을 먼저 확인합니다. 확인 없이 REST 저장으로 바꾸거나 자동으로 다시 보내면, 그 사이 다른 세션이 저장한 내용을 덮어쓸 수 있습니다.

데이터베이스 저장과 다른 연결로의 전달도 하나의 트랜잭션이 아닙니다. 저장 직후 B의 연결이 끊기면 B는 변경을 놓칩니다. B가 파일을 다시 열면 join_file 응답으로 저장된 내용을 받지만, 오프라인에서 편집한 내용이나 놓친 이벤트를 순서대로 복원하는 기능은 없습니다.

두 세션이 동시에 입력하면 전체 문자열이 서로를 덮어쓸 수 있습니다. 이 예제는 revision 비교, 중복 요청 제거, 여러 서버 사이의 저장 순서, 삭제나 권한 변경과 편집이 겹치는 경우를 처리하지 않습니다. 한 화면에서 저장 중 입력을 잠시 막아도 다른 브라우저의 입력까지 막을 수는 없으므로, 동시 편집이 필요하다면 OT나 CRDT 같은 병합 방식을 따로 도입해야 합니다.


정리

  • Gateway는 연결할 때 middleware로 JWT를 확인하고, 요청마다 토큰을 다시 검증해 만료된 토큰을 거부합니다.
  • 파일의 소유와 프로젝트 소속 확인은 REST API와 같은 ProjectService.findFileById()에 맡겨, 두 경로의 권한 규칙을 하나로 유지합니다.
  • code_change는 방에 참가한 연결만 보낼 수 있고, 저장에 성공한 뒤에 code_update 전송과 ACK 반환이 이어집니다.
  • ACK의 ok: true는 서버 저장의 성공을 뜻하며, 상대 화면의 반영은 따로 확인해야 합니다.
  • ACK timeout은 저장 실패가 아니므로, 다시 보내기 전에 GET으로 저장된 내용을 먼저 확인합니다.
  • 저장과 전달은 하나의 트랜잭션이 아니며, 동시 입력의 병합은 이 예제에서 다루지 않습니다.

연습 문제

1. timeout 뒤의 저장 상태

A는 code_change의 ACK timeout을 보았지만, 같은 파일을 GET하니 A가 보낸 내용이 들어 있습니다. B의 화면은 이전 내용 그대로입니다. 저장 실패와 전달 실패를 어떻게 구별하고, 같은 내용을 바로 다시 보내기 전에 무엇을 확인해야 할까요?

해설 보기

GET 결과에 A의 내용이 있으므로 저장은 이루어졌고, 실패한 것은 응답이나 전달 쪽입니다. 저장과 전달은 하나의 트랜잭션이 아니므로 저장만 완료된 상태가 생길 수 있습니다. 다음으로 B가 그 파일 방에 참가해 있는지, 연결이 살아 있는지, code_update를 받았는지, 받은 내용을 에디터에 적용했는지를 차례로 확인합니다.

같은 내용을 바로 다시 보내면 그 사이 B가 저장한 내용을 덮어쓸 수 있으므로, A의 저장되지 않은 입력을 먼저 보존한 뒤 판단합니다. 또 GET 결과는 조회한 시점에 그 내용이 저장되어 있었다는 사실만 알려 줄 뿐, 어느 요청이 언제 저장했는지까지 알려 주지는 않습니다.

2. 내용 길이 제한

JavaScript 문자열의 length는 UTF-16 코드 단위 수입니다. "a"의 길이는 1이고 "😀"의 길이는 2입니다. "😀".repeat(50_000)과 "😀".repeat(50_001)을 changes로 보내면 각각 어떻게 될까요?

해설 보기

첫 번째는 길이가 100,000이므로 길이 검사를 통과하고, 두 번째는 100,002이므로 Invalid content or content too long.으로 거부됩니다. 이모지는 5만 개만 들어가므로 이 한도는 글자 개수가 아니라 코드 단위 수의 한도입니다.

UTF-8로는 이모지 한 개가 4바이트이므로 첫 번째 메시지는 약 200KB입니다. Socket.IO의 기본 메시지 한도인 1MB보다 작으므로 연결이 끊기지는 않습니다. 길이 검사를 통과해도 토큰, 방 참가, 소유 확인은 그대로 거칩니다.

3. 다른 파일 방에서 보낸 변경

한 연결이 join_file로 파일 X의 방에 참가한 뒤, 같은 프로젝트의 파일 Y의 ID로 code_change를 보냈습니다. 두 파일 모두 이 사용자의 소유입니다. 응답은 어떻게 될까요?

해설 보기

{ ok: false, message: 'Join the file before editing.' }를 받습니다. checkFile()의 소유와 소속 검사는 통과하지만, client.data.fileId가 X이고 Y의 방에도 참가하지 않았으므로 저장 전에 거부됩니다.

이 검사가 없다면 Y를 연 다른 세션들은 Y의 방에 참가하지 않은 연결이 보낸 변경을 받게 됩니다. 편집하려는 파일을 먼저 열어 방에 참가하게 하면, 변경을 보내는 연결과 그 변경을 받는 연결이 같은 파일을 보고 있다는 전제가 유지됩니다.

이것으로 백엔드 API 구현을 마칩니다. 다음 글 프론트엔드 통합에서는 React의 인증 상태와 프로젝트 화면을 이 REST API에 연결하고, 에디터의 협업 모드에서 이 Gateway를 사용합니다.