왜 NestJS WebSocket Gateway를 선택했나?

프로젝트에는 이미 Hocuspocus 소켓 서버가 공동편집용으로 존재합니다. 하지만 폴더 트리 동기화를 위해 Hocuspocus에 API 포트를 추가로 열고 싶지 않았습니다.

NestJS WebSocket Gateway를 사용하면:

  • 기존 NestJS 서버의 포트를 그대로 활용
  • EventEmitter를 통한 서비스 레이어와의 자연스러운 연동
  • Room 기반의 효율적인 브로드캐스팅

전체 아키텍처 구조

flowchart LR
    subgraph "공동편집"
        Client1["Client"] <-->|"Yjs sync"| Hocuspocus["Hocuspocus<br/>:1234"]
    end

    subgraph "폴더 트리 동기화"
        Client2["Client"] <-->|"Socket.IO<br/>/tree"| Gateway["NestJS<br/>WebSocket Gateway"]
        Gateway <-->|"EventEmitter"| Service["Service Layer"]
        Service --> DB[(Redis + Postgres)]
    end

1. WebSocket Gateway (websocket.gateway.ts)

/tree 네임스페이스를 사용하여 폴더 트리 전용 WebSocket 연결을 관리합니다:

@WebSocketGateway({
    cors: { origin: '*', methods: ['GET', 'POST'], credentials: true },
    namespace: '/tree',
    transports: ['websocket', 'polling'],
})
export class WebsocketGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
    @WebSocketServer()
    server: Server;
 
    @SubscribeMessage('join-tree')
    async handleJoinTree(
        @MessageBody() payload: { organizationId: string; userId: string },
        @ConnectedSocket() client: SocketWithUser,
    ) {
        const { organizationId, userId } = payload;
 
        // 4가지 Room에 참여
        const privateRoom = `private:${organizationId}:${userId}`;
        const publicRoom = `public:${organizationId}`;
        const shareRoom = `share:${organizationId}:${userId}`;
        const notificationRoom = `notification:${userId}`;
 
        await client.join(privateRoom);
        await client.join(publicRoom);
        await client.join(shareRoom);
        await client.join(notificationRoom);
 
        // 초기 트리 데이터 전송
        const [privateTree, publicTree, shareTree] = await Promise.all([
            this.folderTreeService.findPrivateTree(organizationId, userId),
            this.folderTreeService.findPublicTree(organizationId),
            this.folderTreeService.findShareTree(userId),
        ]);
 
        client.emit('tree-update', { type: 'ALL', privateTree, publicTree, shareTree });
    }
}

2. EventEmitter 기반 이벤트 시스템 (event.service.ts)

데이터 변경 시 WebSocket 게이트웨이에 이벤트를 전달하는 중간 계층입니다:

@Injectable()  
export class EventService {  
    constructor(private readonly eventEmitter: EventEmitter2) {}  
  
    emitFolderTreeEvent(type: 'PRIVATE' | 'PUBLIC' | 'ALL', organizationId: string, userId?: string) {  
        this.emit('folderTree.changed', { organizationId, userId, type });  
    }  
  
    // 노드 ID로 폴더타입을 조회하여 적절한 이벤트 발생  
    async emitFolderTreeEventById(nodeType: NodeType, id: number | string, userId?: string) {  
        // DB에서 폴더/문서 조회 후 적절한 room에 이벤트 전송  
        // ...    }  
}  

3. Room 기반 선택적 브로드캐스팅

Socket.IO의 Room 기능을 활용하여 효율적인 메시지 전송을 구현했습니다:

Room형식용도
Privateprivate:{orgId}:{userId}개인 폴더 변경사항
Publicpublic:{orgId}공용 폴더 변경사항
Shareshare:{orgId}:{userId}공유받은 폴더 변경사항
Notificationnotification:{userId}알림 (권한 요청, 읽지 않은 수 등)

4. EventEmitter 이벤트 핸들러

서비스 레이어에서 발생하는 다양한 이벤트를 처리합니다:

// 권한 변경 시
@OnEvent('permission.changed')
async handlePermissionChanged(payload: {
    organizationId: string;
    itemId: number;
    itemType: 'DOCUMENT' | 'FOLDER';
    changes: Array<{ userId: string; action: 'added' | 'removed' | 'updated' }>;
}) {
    for (const change of changes) {
        if (change.action === 'removed') {
            // 권한 제거: 트리에서 노드 제거 알림
            this.server.to(`private:${organizationId}:${change.userId}`)
                .emit('tree-node-removed', { itemId, reason: 'permission-revoked' });
        } else if (change.action === 'added') {
            // 권한 부여: 전체 트리 새로고침
            const privateTree = await this.folderTreeService.findPrivateTree(organizationId, change.userId);
            this.server.to(`private:${organizationId}:${change.userId}`)
                .emit('tree-update', { type: 'PRIVATE', privateTree });
        }
    }
}
 
// 소유권 이전 시
@OnEvent('ownership.transferred')
async handleOwnershipTransferred(payload: {
    organizationId: string;
    itemId: number;
    oldOwnerId: string;
    newOwnerId: string;
}) {
    // 이전 소유자: private → share로 이동
    this.server.to(`private:${organizationId}:${oldOwnerId}`)
        .emit('tree-node-moved', { fromType: 'PRIVATE', toType: 'SHARE', itemId });
 
    // 새 소유자: share → private로 이동
    this.server.to(`share:${organizationId}:${newOwnerId}`)
        .emit('tree-node-moved', { fromType: 'SHARE', toType: 'PRIVATE', itemId });
}
 
// 알림 관련
@OnEvent('notification.new')
async handleNewNotification(payload: { userId: string; notification: any; unreadCount: number }) {
    this.server.to(`notification:${payload.userId}`)
        .emit('notification:new', { notification: payload.notification, unreadCount: payload.unreadCount });
}

전체 이벤트 목록:

  • folderTree.changed - 폴더 트리 변경
  • permission.changed - 권한 변경
  • ownership.transferred - 소유권 이전
  • item.moved - 아이템 이동
  • document.deleted - 문서 삭제
  • notification.new - 새 알림
  • notification.unread_count_updated - 읽지 않은 수 업데이트
  • notification.permission-updated - 권한 요청 상태 변경
  • library-config.updated - 라이브러리 설정 변경

🚨 중요: IoAdapter 설정의 필수성

NestJS에서 WebSocket을 사용할 때 가장 중요하면서도 놓치기 쉬운 설정이 바로 IoAdapter 설정입니다.

main.ts의 핵심 설정

// main.ts:25  
app.useWebSocketAdapter(new IoAdapter(app));  

⚠️ IoAdapter가 없을 때 발생하는 문제

이 한 줄의 설정이 빠지면 다음과 같은 문제들이 발생합니다:

  1. 조용한 실패: 별다른 에러 로그 없이 WebSocket 연결이 실패합니다.
  2. 서비스 시작 실패: 애플리케이션도 정상적으로 시작되지 않습니다.
  3. 클라이언트 연결 거부: 404 Not Found 또는 연결 타임아웃이 발생합니다

왜 이런 문제가 발생하는가?

NestJS는 기본적으로 HTTP 어댑터만 설정되어 있습니다. WebSocket Gateway를 사용하려면 반드시 WebSocket 어댑터를 명시적으로 설정해야 합니다. 이 설정이 없으면:

  • WebSocket 게이트웨이가 초기화되지 않음
  • Socket.IO 서버가 HTTP 서버와 연결되지 않음
  • 클라이언트의 WebSocket 연결 시도가 처리되지 않음

모듈 구조와 의존성

WebSocket 모듈 구성

@Module({  
    providers: [WebsocketGateway],  
    imports: [FolderTreeModule], // 폴더 트리 서비스 주입  
})  
export class WebSocketModule {}  

EventEmitter 설정

공통 모듈과 폴더 트리 모듈 모두에서 EventEmitter를 설정:

// common.module.ts  
@Module({  
    imports: [EventEmitterModule.forRoot()],    // ...})  
export class CommonModule {}  
  
// folder-tree.module.ts  @Module({  
    imports: [EventEmitterModule.forRoot()],    // ...})  
export class FolderTreeModule {}  

실시간 동기화 플로우

  1. 클라이언트 연결: 사용자가 /tree 네임스페이스로 연결
  2. Room 참여: join-tree 이벤트로 적절한 Room 참여
  3. 데이터 변경: 폴더/문서 생성/수정/삭제 시
  4. 이벤트 발생: EventService.emitFolderTreeEvent() 호출
  5. 게이트웨이 수신: @OnEvent('folderTree.changed') 핸들러 실행
  6. 선택적 브로드캐스트: 해당 Room의 클라이언트들에게만 업데이트 전송

트러블슈팅 팁

1. WebSocket 연결이 안 될 때

  • app.useWebSocketAdapter(new IoAdapter(app)) 설정 확인
  • CORS 설정 점검
  • 네트워크/방화벽 설정 확인

2. 이벤트가 전달되지 않을 때

  • EventEmitterModule 설정 확인
  • Room 참여 상태 확인
  • Gateway의 @OnEvent 핸들러 등록 확인

3. 메모리 누수 방지

  • 클라이언트 연결 해제 시 자동 Room 해제 (Socket.IO가 자동 처리)
  • 하트비트 대신 Socket.IO 내장 ping/pong 사용

마치며

NestJS의 WebSocket 게이트웨이는 강력한 실시간 기능을 제공하지만, IoAdapter 설정과 같은 기본 설정을 놓치면 디버깅하기 어려운 문제가 발생할 수 있습니다. 특히 별다른 에러 없이 조용히 실패하는 특성 때문에 초기 설정 시 각별한 주의가 필요합니다.

EventEmitter와 Room 기반 선택적 브로드캐스팅을 조합하면 효율적이고 확장 가능한 실시간 동기화 시스템을 구축할 수 있습니다.