최근 프로젝트에서 실시간 폴더 트리 동기화 기능을 구현하면서 NestJS의 WebSocket 게이트웨이와 EventEmitter를 활용한 아키텍처를 구성했습니다. 이 과정에서 발견한 중요한 설정과 구조에 대해 정리해보려고 합니다.
전체 아키텍처 구조
현재 프로젝트의 실시간 동기화는 다음과 같은 구조로 동작합니다:
Client ← WebSocket → Gateway ← EventEmitter ← Service (DB 변경)
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;
// Room 기반 접근: private, public 구분
async handleJoinTree(payload: { organizationId: string; userId: string }, client: SocketWithUser) {
const privateRoom = `private:${organizationId}:${userId}`;
const publicRoom = `public:${organizationId}`;
await client.join(privateRoom);
await client.join(publicRoom);
}
// EventEmitter로부터 이벤트 수신
@OnEvent('folderTree.changed')
async handleTreeChanged(payload: { organizationId: string; userId?: string; type: 'PRIVATE' | 'PUBLIC' | 'ALL' }) {
// Room별 선택적 브로드캐스팅
if (payload.type === 'PRIVATE' && payload.userId) {
const privateRoom = `private:${payload.organizationId}:${payload.userId}`;
this.server.to(privateRoom).emit('tree-update', { /* 데이터 */ });
}
// ... }
}
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 기능을 활용하여 효율적인 메시지 전송을 구현했습니다:
- Private Room:
private:{organizationId}:{userId}
- 개인 폴더 변경사항 - Public Room:
public:{organizationId}
- 공용 폴더 변경사항
이를 통해 불필요한 네트워크 트래픽을 줄이고, 보안을 강화할 수 있습니다.
🚨 중요: IoAdapter 설정의 필수성
NestJS에서 WebSocket을 사용할 때 가장 중요하면서도 놓치기 쉬운 설정이 바로 IoAdapter 설정입니다.
main.ts의 핵심 설정
// main.ts:25
app.useWebSocketAdapter(new IoAdapter(app));
⚠️ IoAdapter가 없을 때 발생하는 문제
이 한 줄의 설정이 빠지면 다음과 같은 문제들이 발생합니다:
- 조용한 실패: 별다른 에러 로그 없이 WebSocket 연결이 실패합니다.
- 서비스 시작 실패: 애플리케이션도 정상적으로 시작되지 않습니다.
- 클라이언트 연결 거부:
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 {}
실시간 동기화 플로우
- 클라이언트 연결: 사용자가
/tree
네임스페이스로 연결 - Room 참여:
join-tree
이벤트로 적절한 Room 참여 - 데이터 변경: 폴더/문서 생성/수정/삭제 시
- 이벤트 발생:
EventService.emitFolderTreeEvent()
호출 - 게이트웨이 수신:
@OnEvent('folderTree.changed')
핸들러 실행 - 선택적 브로드캐스트: 해당 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 기반 선택적 브로드캐스팅을 조합하면 효율적이고 확장 가능한 실시간 동기화 시스템을 구축할 수 있습니다.
댓글 (0)