Featured image of post NestJS에서 WsAdapter 커스터마이징으로 세션 공유하기

NestJS에서 WsAdapter 커스터마이징으로 세션 공유하기

WS에서도 Express 세션을 활용하기 위한 구조 개선 기록

부스트캠프 9기 실시간 퀴즈 게임 프로젝트에서는 로그인 절차 없이 바로 참여할 수 있도록 세션 기반 사용자 식별을 적용했습니다.

사용자가 서비스에 접근하면 초기 API를 반드시 호출하도록 설계했고, 이때 쿠키에 세션 ID를 담아 식별에 활용했습니다.

문제는 REST API에서는 세션이 정상 동작하지만, WebSocket Gateway에서는 동일한 쿠키가 전달되어도 세션이 보이지 않는 상황이었습니다.

이 글은 왜 그런지를 구조적으로 짚고, 제가 실제로 적용한 해결법대안들을 정리한 기록입니다.

문제 발견

  • REST API: req.session 정상
  • WebSocket Gateway: 동일 쿠키가 있어도 req.session 접근 불가
  • 브라우저/서버 로그로 세션 생성 자체는 확인

즉, 세션이 없는 게 아니라 WS 경로에서 세션을 읽어올 수 없는 구조였습니다.

첫 시도: handleConnection 훅에서 쿠키 직접 파싱

Nest Gateway 라이프사이클에서 handleConnection(client, ...args)클라이언트 연결 시 1회 호출되며 구현에 따라 원본 HTTP 요청을 인자로 받을 수 있습니다.

처음에는 이 훅에서 쿠키를 직접 파싱하여 세션 ID를 추출했습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// gateways/play.gateway.ts (요약)
import { WebSocketGateway, OnGatewayConnection } from '@nestjs/websockets';
import type { WebSocket } from 'ws';
import type { IncomingMessage } from 'http';
import { parse } from 'cookie';

@WebSocketGateway({ path: '/ws/play' })
export class PlayGateway implements OnGatewayConnection {
  async handleConnection(client: WebSocket, request: IncomingMessage) {
    const cookies = parse(request.headers.cookie ?? '');
    client['sessionId'] = cookies['connect.sid'].split('.').at(0)?.slice(2);
  }
}

기존 방식의 한계

간단히 동작했지만 곧 한계가 드러났습니다.

  • 쿠키 포맷과 서명 정책에 강하게 결합
    • connect.sid 값은 보통 s: 접두가 붙은 서명된 문자열입니다. 당시 구현은 단순한 문자열 분해로 임시 ID를 뽑아 쓰는 수준이었기 때문에, 쿠키 이름이나 옵션, 서명 방식이 조금만 바뀌어도 쉽게 깨지는 방식으로 쿠키 포맷과 서명 정책에 강하게 결합되어 있었습니다.
  • 무결성 검증을 우회
    • express-session은 서명 검증, 재발급(regenerate), 삭제된 세션 식별 같은 절차를 미들웨어에서 처리하지만, handleConnection에서 직접 파싱하면 이 과정을 건너뛰게 되어, 변조된 쿠키나 이미 폐기된 세션을 걸러낼 근거가 없습니다.
  • 세션 수명 관리
    • express-session은 요청마다 touch()로 TTL을 연장하고, 스토어에서 최신 세션 내용을 불러오지만, 이 방식은 스토어 조회를 하지 않기 때문에 WS 쪽 상태가 갱신되지 않고, 게임 도중 세션이 만료될 수 있습니다.
  • 프레임워크 버전 변경에 취약
    • 쿠키 포맷이나 미들웨어 내부 정책이 바뀌면, 게이트웨이에 흩어진 파싱 로직을 모두 수정해야 합니다.

요약하면, 이 시도는 ‘세션처럼 보이게’만 한 것이지, 세션이 제공하는 검증·갱신·저장의 보장을 사용하지 못한 상태였습니다.

내부 구조 이해

Nest는 기본적으로 Express 위에서 동작합니다. 관건은 REST와 WS의 경로가 다른 파이프라인으로 처리된다는 점입니다.

REST 요청의 흐름

Express 라우터 체인을 통과하며 cookie-parserexpress-sessionController 순으로 실행됩니다.

graph LR
    A[클라이언트 요청] --> B[cookie-parser]
    B --> C[express-session]
    C --> D[Controller]

WebSocket 요청의 흐름

WS 연결은 HTTP 업그레이드로 시작되며, Express 라우터 체인을 거치지 않습니다.

sequenceDiagram
    participant Client as 브라우저
    participant Server as 서버(HTTP)
    Client->>Server: GET /ws (Upgrade: websocket)
    Server-->>Client: 101 Switching Protocols
    Note over Client,Server: 이후 WebSocket 프레임으로 통신

Nest의 WsAdapter는 업그레이드 요청을 받아 ws 서버로 위임합니다.

이 흐름 때문에 WS는 Express 미들웨어 체인을 통과하지 않아 기본 상태로는 req.session을 사용할 수 없습니다.

해결 방법: 업그레이드 래핑

핵심 아이디어는 간단합니다.

Nest 기본 업그레이드 흐름을 그대로 재현하되, 그 앞단에 express-session 미들웨어를 한 번 실행해 req.session을 준비하고, 연결 직후 ws.session = req.session을 추가하는 것입니다.

Nest의 WsAdapter에서 http 서버가 upgrade 요청이 발생했을 때 WS 서버로 위임해주는 과정은 ensureHttpServerExists 메서드에서 처리됩니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
export class WsAdapter extends AbstractWsAdapter {
  /* 생략 */
  protected ensureHttpServerExists(
    port: number,
    httpServer = http.createServer(),
  ) {
    if (this.httpServersRegistry.has(port)) {
      return;
    }
    this.httpServersRegistry.set(port, httpServer);

    httpServer.on('upgrade', (request, socket, head) => {
      try {
        const baseUrl = 'ws://' + request.headers.host + '/';
        const pathname = new URL(request.url!, baseUrl).pathname;
        const wsServersCollection = this.wsServersRegistry.get(port)!;

        let isRequestDelegated = false;
        for (const wsServer of wsServersCollection) {
          if (pathname === wsServer.path) {
            wsServer.handleUpgrade(request, socket, head, (ws: unknown) => {
              wsServer.emit('connection', ws, request);
            });
            isRequestDelegated = true;
            break;
          }
        }
        if (!isRequestDelegated) {
          socket.destroy();
        }
      } catch (err) {
        socket.end('HTTP/1.1 400\r\n' + err.message);
      }
    });
    return httpServer;
  }
  
  /* 생략 */
}

http 서버에 upgrade 이벤트를 등록하는 부분 httpServer.on('upgrade', (request, socket, head) => {})에서 넘겨주는 핸들러를 그대로 활용하여 세션만 붙이는 방식으로 기존 처리는 유지하면서 세션에 접근할 수 있도록 했습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// session-ws.adapter.ts (요약)
export class SessionWsAdapter extends WsAdapter {
  constructor(
    private readonly app: NestApplication, 
    private readonly sessionMiddleware: RequestHandler
  ) {
    super(app);
  }

  create(port: number, options?: any) {
    const httpServer = this.app.getHttpServer();
    const wsServer = super.create(port, options);

    httpServer.removeAllListeners('upgrade');

    httpServer.on('upgrade', (req, socket, head) => {
      this.sessionMiddleware(req, {} as any, () => {
        const pathname = new URL(req.url!, 'ws://' + req.headers.host + '/').pathname;
        const wsServers = this.wsServersRegistry.get(port);
        for (const s of wsServers) {
          if (pathname === s.path) {
            s.handleUpgrade(req, socket, head, (ws) => {
              (ws as any).session = (req as any).session;
              s.emit('connection', ws, req);
            });
            return;
          }
        }
        socket.destroy();
      });
    });

    return wsServer;
  }
}

create 메서드를 다시 구현하여 기존 처리를 그대로 수행한 후, upgrade 이벤트 핸들러를 세션 미들웨어로 감싸 세션에 직접 접근할 수 있도록 했습니다.

이러한 처리를 통해 게이트웨이는 아무 수정 없이 세션을 그대로 사용할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// play.gateway.ts
@WebSocketGateway({ path: '/ws/play' })
export class PlayGateway {
  @SubscribeMessage('start')
  handleStart(@ConnectedSocket() client: any) {
    const sid = client.session.id;
    const nickname = client.session.nick;
    // 게임 로직...
  }
}

해결 방법의 한계

이 방식은 동작이 분명하고 기존 코드를 거의 그대로 재사용할 수 있다는 장점이 있지만, 동시에 몇 가지 한계가 존재합니다.

  • 프레임워크 내부 변경에 취약
    • ensureHttpServerExists 내부 흐름을 긁어와 쓰는 구조라 Nest나 ws/http 버전 업그레이드 시 동작이 달라질 수 있습니다.
  • 세션 TTL 관리
    • 업그레이드 시점에만 세션을 붙이므로 TTL 갱신(touch)이 자동으로 되지 않아 장시간 연결에서는 세션 만료 문제가 발생할 수 있습니다.
  • 스케일 아웃 고려
    • 사용자 증가로 멀티 노드 환경이 필요하다면, 현재 구현체를 그대로 활용하거나 변경하여 사용하는 방식은 매우 효과적이지 않을 것으로 예상됩니다.

대안

선택한 방법 외에도 대안으로 선택될 수 있는 여러 옵션이 있었습니다.

Socket.IO 전환 — 전역 어댑터로 최소 변경 적용

Socket.IO를 사용하면 위와 동일한 과정을 단순하게 처리할 수 있습니다.

게이트웨이마다 설정을 반복하지 않고, 전역 IoAdapter에서 한 번만 express-session 미들웨어를 연결하면 프로젝트 전반에 일관되게 적용할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// session-io.adapter.ts
export class SessionIoAdapter extends IoAdapter {
  constructor(app: any, private readonly sessionMiddleware: RequestHandler) {
    super(app);
  }

  override create(port: number, options?: any) {
    const io = super.create(port, options) as IOServer;
    io.use((socket, next) => this.sessionMiddleware(socket.request as any, {} as any, next));
    return io;
  }
}

// main.ts
const app = await NestFactory.create(AppModule);
app.use(cookieParser(process.env.COOKIE_SECRET));

const sessionMiddleware = session({ /* 기존 세션 설정 */});
app.use(sessionMiddleware);

app.useWebSocketAdapter(new SessionIoAdapter(app, sessionMiddleware));

위와 같은 처리를 통해 Nest의 기본 처리에 변경 없이 문제를 해결할 수 있고, 마찬가지로 게이트웨이에서는 추가 코드 없이 socket.request.session을 그대로 사용할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// play.gateway.ts
@WebSocketGateway({ path: '/ws/play' })
export class PlayGateway {
  @SubscribeMessage('start')
  handleStart(@ConnectedSocket() socket: Socket) {
    const sid = socket.request.session?.id;
    const nick = socket.request.session?.nick;
    // 게임 로직...
  }
}

마찬가지로 이 방법도 여러 한계와 고려사항을 검토해야 합니다.

  • 성능 오버헤드
    • Socket.IO는 ws보다 추상화 레이어가 있어 초고성능이 필요한 경우 오버헤드가 발생할 수 있습니다.
  • 의존성 증가/리팩터링 비용
    • Socket.IO 고유의 API에 종속되므로 기존 ws 코드 전환 시 비용이 발생합니다.
  • 스케일링 복잡성
    • 멀티 노드 환경에서 Redis adapter 등 추가 설정이 필요합니다.
  • 행동 차이
    • Socket.IO의 핸드셰이크/재연결/메시지 처리 방식이 ws와 달라 클라이언트 호환성 테스트가 필요합니다.

세션을 쓰지 않는 방법

세션을 끌고 오지 않아도 사용자를 식별할 수 있습니다.

서비스에서 쿠키와 세션을 활용한 이유는 어떠한 사용자라도 고유하게 사용자를 식별할 수 있는 수단이 필요했던 것 뿐이고, express-session을 통해 이미 구현된 기능을 통해 직접 구현을 최소화 하는 것을 원했기 때문입니다.

결과적으로 서버가 고유한 값을 발급하고, 클라이언트가 그 값을 명시적으로 전달하도록 하면 어떠한 방법이든 활용할 수 있으며, 대표적으로 JWT, Opaque 토큰과 같은 방법들을 고려해볼 수 있습니다.

JWT는 서명 검증만으로 식별이 가능하고, Opaque 토큰(WS 전용 토큰)은 서버 저장소에 token → 사용자 정보를 저장해두었다가 조회하는 방식으로 처리합니다.

sequenceDiagram
    autonumber
    participant C as Client
    participant API as REST API
    participant R as Redis
    participant WS as WS 서버

    C->>API: POST /rooms/:id/join { nick }
    API->>R: 저장 (token → { roomId, nick })
    API-->>C: { token }

    C->>WS: GET /ws/play?token=... (Upgrade)
    WS->>R: token 조회
    R-->>WS: { roomId, nick }
    WS-->>C: 연결 수립 (ws.user 바인딩)

연결 전에 “게스트 입장” 같은 API 단계를 두는 것이 일반적입니다.

클라이언트가 먼저 API를 호출하면 서버는 토큰을 발급하고, 클라이언트는 WS 연결 시 쿼리 파라미터나, Sec-WebSocket-Protocol로 전달합니다. 서버는 이를 검증해 사용자 정보를 연결에 부착합니다.

JWT를 사용하는 경우에는 서버 저장소 조회가 필요 없지만, 만료 관리와 강제 무효화 설계는 별도로 고려해야 합니다.

위 방법도 여러 문제들을 먼저 고려해야 합니다.

  • JWT: 만료·갱신·강제무효화
    • JWT는 stateless이므로 리프레시 토큰이나 블랙리스트 등 만료/무효화 전략이 필요합니다.
  • JWT: 전달 경로 제약
    • 브라우저의 업그레이드 요청에서 Authorization 헤더를 자동으로 보내지 않으므로 Sec-WebSocket-Protocol이나 쿼리 파라미터 사용을 고려해야 합니다.
  • Opaque 토큰: 저장소 의존성
    • Redis 등 중앙 저장소가 장애를 겪으면 인증/접속 흐름이 영향을 받습니다.
  • 토큰 관리 복잡성
    • TTL 설계, 연장(heartbeat), 동시 접속 제어 등 정책을 잘 설계해야 합니다.
  • 운영·모니터링 비용
    • 발급/사용/회수 로그, 이상행동 탐지, 토큰 재발급 로직의 모니터링 필요할 수 있습니다.

회고

문제의 원인은 WS 경로가 Express 미들웨어 체인을 통과하지 않는다는 구조에 있었습니다.

해결은 Nest 기본 업그레이드 흐름 앞단에 express-session을 실행해 WS에 세션을 붙여 REST와 WS가 동일한 세션 컨텍스트를 공유했습니다.

다만 당시 프로젝트 상황에서는 빠른 기능 구현과 안정적인 데모가 더 중요했기 때문에, 구조를 크게 바꾸기보다는 WsAdapter를 감싸는 방식으로 해결했습니다.

이후 장기적으로는 Socket.IO 전환이나 JWT/WS 전용 토큰 방식으로 구조를 단순화하는 것도 충분히 고려할 수 있습니다.

결국 중요한 것은 서비스 요구와 운영 조건에 맞는 선택을 하는 것입니다.