
웹소켓은 클라이언트와 서버 간에 지속적인 연결을 유지함으로써 실시간 양방향 통신을 가능하게 하는 프로토콜이다. 일반적인 HTTP 요청이 요청–응답 단위로 연결을 맺고 끊는 방식이라면, 웹소켓은 한 번 연결을 맺은 뒤 그 연결을 계속 유지하며 데이터를 주고받는다. 이 덕분에 알림, 채팅과 같은 실시간 기능을 구현하기에 적합하다.
알림 기능의 경우 WebSocket보다는 SSE(Server-Sent Events) 가 더 적합하다는 의견도 종종 접했다. 실제로 단방향 알림만 필요하다면 SSE가 구조적으로 더 단순한 선택일 수 있다. 다만 Express.js 환경에서 이미 WebSocket을 다뤄본 경험이 있었고, 그 연장선에서 Nest.js에서도 WebSocket을 사용하기로 결정했다.
Nest.js에서는 Gateway 클래스를 통해 WebSocket 서버를 설정하고 관리한다. @WebSocketGateway 데코레이터를 사용하면 클라이언트와 서버 간의 실시간 통신을 손쉽게 구성할 수 있으며, 클라이언트 연결, 메시지 수신, 연결 해제와 같은 이벤트를 인터페이스 구현을 통해 처리할 수 있다.
WebSocket 서버 설정
@WebSocketGateway 데코레이터는 최대 두 개의 인자를 받는다.
첫 번째 인자는 별도의 포트를 사용하고 싶은 경우 지정하는 포트 번호다.
두 번째 인자는 gateway 설정을 위한 옵션 객체다.
별도의 포트를 열 필요가 없다면, 아래처럼 옵션 객체만 전달해도 된다.
@WebSocketGateway({
namespace: 'notification',
cors: {
origin: ['http://localhost:3000', 'http://localhost:3001'],
credentials: true,
},
})
export class NotificationGateway implements OnGatewayDisconnect {
@WebSocketServer()
server: Server;
}Gateway 클래스는 OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 같은 인터페이스를 구현할 수 있다. 내 경우에는 연결 해제 시점의 처리가 필요했기 때문에 OnGatewayDisconnect만 구현했다.
기존 HTTP 서버의 연결을 WebSocket으로 업그레이드하고 싶다면, main.ts에서 WebSocket 어댑터를 설정해주면 된다.
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useWebSocketAdapter(new IoAdapter(app));
await app.listen(3000);
}
bootstrap();클라이언트 연결 및 연결 해제
프론트엔드와 백엔드 모두 마음대로 정할 수 있는 건 없다. 결국 모든 것은 양쪽의 합의다.
내 경우에는 프론트엔드를 담당하는 ayden과 백엔드를 담당하는 ayden이 긴 회의 끝에 다음과 같은 규칙을 정했다.
클라이언트는 WebSocket 연결 직후
userConnect이벤트로 자신의userId를 서버에 전달한다.서버는 이를 받아
userId와socket을 매핑해 저장한다.연결이 종료되면 해당 정보를 제거한다.
export class NotificationGateway implements OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private clients: Map<string, Socket> = new Map();
@SubscribeMessage('userConnect')
handleUserConnect(client: Socket, { userId }: { userId: string }) {
client.data.userId = userId;
this.clients.set(userId, client);
console.log(`userId ${userId} connected`);
return 'webSocket Connected';
}
handleDisconnect(client: Socket) {
const { userId } = client.data;
if (userId) {
this.clients.delete(userId);
console.log(`userId ${userId} disconnected`);
}
}
}client.data를 활용해 소켓에 사용자 정보를 심어두면, 연결 해제 시점에도 비교적 깔끔하게 정리할 수 있다.
메시지 송신
알림을 전송하기 위해 sendMessage 메서드를 만들었다. 이 메서드는 userId를 기준으로 연결된 소켓을 찾고, 해당 클라이언트에게 알림 메시지를 전송한다.
export class NotificationGateway implements OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private clients: Map<string, Socket> = new Map();
sendMessage(userId: string, notification: Notification) {
const client = this.clients.get(userId);
if (client) {
console.log(`emit ${notification.type} to ${userId}`);
client.emit('notification', notification);
} else {
console.log(`Client not connected: ${userId}`);
}
}
}Gateway 역시 Nest.js에서는 프로바이더로 취급된다. 따라서 모듈에서 export 해주면, 다른 모듈에서도 이를 주입받아 사용할 수 있다.
@Controller('comments')
export class CommentsController {
constructor(
private reportService: ReportService,
private commentService: CommentService,
private notificationService: NotificationService,
private notificationGateway: NotificationGateway,
) {}
async sendNotification(parentId, userId) {
const report = await this.reportService.getReport(parentId);
if (report.user.id !== userId) {
const noti = await this.notificationService.createNotification(
report.user.id,
{
senderId: userId,
reportId: parentId,
type: 'NEW_COMMENT_ON_REPORT',
},
);
this.notificationGateway.sendMessage(report.user.id, noti);
}
}
}단일 서버 전제와 한계
현재 나는 단 하나의 백엔드 서버만 운영하고 있기 때문에, 소켓 연결 관리를 위해 Map 객체를 사용해도 문제가 없다. 모든 연결 정보가 온메모리에 존재하고, 서버가 하나뿐이니 동기화 이슈도 없다.
하지만 서버를 여러 대로 확장하는 순간 이 방식은 곧바로 한계를 드러낸다. Map은 프로세스 메모리에 존재하므로, 서버 간에 상태를 공유할 수 없기 때문이다.
이런 경우 일반적으로는 Redis를 사용해 소켓 연결 정보를 중앙에서 관리한다. Nest.js 역시 Redis 어댑터를 통해 다중 서버 환경에서의 WebSocket 구성을 지원한다.
다만 지금의 나는 “Redis는 세션 id 관리하는 존나 빠른 DB” 정도의 지식만 가지고 있고, 아직 백엔드 서버를 여러 대로 운영할 계획도 없다. 언젠가 서버를 확장하게 된다면, Redis와 Redis 어댑터를 사용한 WebSocket 구성도 한 번쯤은 제대로 다뤄볼 생각이다. 지금은 여기까지로 충분하다.
더 읽어보기
2025.05.21
14. Redis를 사용한 세션 관리 및 캐싱
새로운 프로젝트를 준비하면서 인증 방식부터 다시 고민하게 되었다. 이전 프로젝트에서는 JWT를 사용해 유저 인증과 상태 관리를 처리했지만, 이번에는 Redis를 활용한 세션 방식으로 전환하기로 결정했다. 이 방식은 유저 상태를 서버에서 직접 관리할 수 있어 보안 측면에서도 유리하고, 필…
2025.01.30
비즈니스 로직은 어디에 있어야 할까
Nest.js에서 컨트롤러와 각각의 프로바이더는 분명한 책임을 갖는다. 하지만 막상 서버를 개발하다 보면, 이 책임들을 깊이 고민하지 않은 채 코드를 작성하게 되고, 그 결과 클래스들이 서로의 영역을 침범하는 상황이 반복된다. 나 역시 컨트롤러에 비즈니스 로직이 섞이거나, 서비스가 지나…
2024.12.07
13. Jest 테스트 구성
이 포스트를 시작하기에 앞서 한 가지 분명히 밝혀두고 싶은 점이 있다. 나는 컨트롤러(혹은 GraphQL 기준으로는 리졸버) 와 레포지토리 클래스에 대한 테스트를 거의 작성하지 않는다.이유는 단순하다. 애플리케이션에서 가장 많은 비즈니스 규칙이 응집되어 있는 곳은 서비스 계층이며, 테스…
2024.11.14
12. GraphQL로 요청 처리
GraphQL은 페이스북에서 개발한 데이터 쿼리 언어이자 런타임으로, 기존 REST API가 가진 구조적 한계를 보완하기 위해 등장했다. 전통적인 REST API에서는 각 엔드포인트마다 반환되는 데이터 구조가 고정되어 있고, 동일한 리소스에 대해 서로 다른 작업을 수행하기 위해 HTTP…
2026.04.11
Trie 자료구조
문자열 데이터를 다룰 때 단순히 “이 단어가 있나?”만으로는 부족한 순간이 있다. 자동 완성처럼 특정 접두사로 시작하는 후보를 모아야 할 때도 있고, 어떤 키에 값을 두고 빠르게 찾고 싶을 때도 있다. 이럴 때 가장 자연스럽게 떠올릴 수 있는 자료구조가 바로 Trie이다.Trie는 무엇…
2026.03.19
Streams API 부록 2. 왜 이미지는 위에서 아래로 나타날까
웹 페이지에서 이미지를 로드할 때 흥미로운 장면을 종종 볼 수 있다. 이미지가 한 번에 완전히 나타나는 것이 아니라, 위에서 아래로 조금씩 채워지면서 나타나는 경우가 있기 때문이다. 특히 네트워크가 느리거나 이미지가 큰 경우 이런 현상이 더 분명하게 보인다. 마치 이미지가 위쪽부터 스캔…
2026.03.19
Streams API 부록 1. HTTP 다운로드 진행률은 어떻게 계산될까
파일을 다운로드할 때 가끔 몇 퍼센트 진행되었는지 혹은 진행 막대(progress bar)가 조금씩 채워지는 모습을 볼 수 있다. 그런데 모든 다운로드가 이런 식으로 진행률을 보여 주는 것은 아니다. 어떤 다운로드는 퍼센트가 표시되지만, 어떤 경우에는 진행 막대 없이 로딩 스피너만 계속…
2026.03.13
Streams API 4. 왜 모든 언어에는 Stream API가 존재할까
Streams API를 공부하다 보면 묘한 기시감을 느끼게 된다. JavaScript에서 ReadableStream, WritableStream, TransformStream을 살펴보고 있는데, 어딘가 낯설지 않다. Java를 써 본 사람이라면 InputStream, OutputStre…
댓글
댓글을 불러오는 중...