API와 Socket 서버 분리는 처음이지?

chloe·2022년 12월 5일
2
post-thumbnail

실시간 서비스를 보장하는 웹 애플리케이션 서버를 어떻게 구성할지에 대해 고민한 글입니다.

설계 이후의 고민은 Socket Multi Namespace Connect 옳은가?

발단

이번 프로젝트의 주요 기능은 실시간 채팅 서비스이다.
채팅을 socket으로 구현하게 되면 서버에 부하가 많이 갈텐데, 여러 사용자가 socket 연결과 API 요청을 보내는 것을 원활하게 처리하기 위해 부하 분산을 목적으로 API 서버와 Socket 서버를 분리하여 개발하는 것으로 설계하였다.

용어
 - 커뮤니티 : 여러 채널이 존재하는 상위 개념으로 슬랙의 워크스페이스와 유사
 - 채널 : 실제 채팅이 이루어지는 곳으로 커뮤니티의 사용자들은 공개 또는 비공개 채널에 참여할 수 있다.

전개

설계를 하면서 고민한 사항은 다음과 같다.

  1. 채팅방에 입장하면 어떻게 예전 메세지들을 받아올 것인가?
  2. socket 연결은 무엇을 기준으로 할 것인가?
  3. 내가 속한 채널이지만 지금 당장 채팅에 참여하고 있지 않을 때 안 읽은 메세지를 어떻게 알릴 것인가?
  4. 내가 속한 커뮤니티에서 새로운 메세지가 왔을 때, 지금 당장 다른 커뮤니티에서 채팅을 하고 있다면 서버에서 어떻게 알릴 것인가?
  5. 채팅 메세지를 어떤 서버에 알리고 어떤 서버가 저장할 것인가?

논의 전 생각한 로직

namespace는 따로 없이 community unique id를 기반으로 client는 socket event listen을 하고 있고, socket event가 channel id기반으로 socket 서버에 들어오면 socket 서버는 channel id를 listen 하는 client에 전체 메세지를 보내고, DB에서 해당 채널의 커뮤니티 unique id를 확인하여 커뮤니티 unique id로 listen하는 client에 다시 메세지를 보내는 방식이다.

이런 방식으로 했을 때, 2가지 단점이 있다.
1. DB를 계속 확인해보거나 서버에서 정보를 저장해야한다.
2. 서버에서는 채널 id로 한번, 커뮤니티 id로 한번 => socket으로 총 2번 씩 같은 내용의 data를 보내야 함

단점을 보안하기 위해 논의를 거쳐 나온 로직

커뮤니티 별로 namespace기반 socket 연결을 하여 DB에서 커뮤니티 정보를 찾을 필요성을 없앴다.
그럼 이제 2번의 단점을 수정하자.

최종 버전

커뮤니티 별로 namespace기반 socket 연결을 하고, 각 커뮤니티에 들어가면 자신의 채널 정보를 socket 서버에 전달하여, socket 서버는 받은 채널 정보를 기반으로 해당 client를 channel로 구분된 room에 입장시킨다.
새로운 메세지는 해당 namespace의 해당 room에 속한 사람들에게만 전송하도록 변경했다.

socket 서버는 잘 설계된 것(?) 같은데 한가지 고민은 API server에서 메세지 저장을 위해 DB에 접근하는 요청을 한번 보내고 socket server에도 한 번 보낸다는 점이었다.

위기

멘토님과 만나는 시간에 API 서버와 socket 서버 두 번 보내는 방식이 괜찮은지에 대해서 가볍게 질문하려고 했는데, 대답은 다른 방식으로 구현해보는 것을 권장하셨다.

서버에서 다른 서버로 보내서 처리하는 로직을 말씀해주셨고, 메세지 큐와 카프카에 대해서 소개해주셨다.

서버 to 서버 처리 방식은 이전에 프로젝트를 진행하면서 한번도 해보지 않아서 구현해본다면 재미있을 것 같았지만, 우리에게 남은 시간이 부족했다..

안 읽은 메세지 저장, 리팩토링, DB 최적화, Test등의 남아있는 기능들에 서버 to 서버까지 하려면 새벽4시 취침이 아니라 그냥 잠을 안자고 해야할 것 같았다..

최종_v2

멘토링 후, 우리가 단 시간에 바꾸는 것이 가능한 방법은 두 가지가 있었다.
1. socket 서버에서 DB에 접근하여 메세지를 추가해주는 business logic을 수행
2. socket 서버에서 API 서버로 메세지 추가 요청

1번의 경우, 이미 작성된 API의 business logic을 모두 socket으로 옮기고 socket server에서도 DB에 연결하는 작업이 필요했다.
2번의 경우, socket 서버에서 fetch 요청만 보내면 되기 때문에 수정사항이 가장 적어 2번을 선택했다.

채널에 들어가서 채팅과 관련된 로직은 Client입장에서 전부 Socket server와 통신한다.

오히려 좋아

soscket 서버에서 API 서버로 메세지 추가 요청을 보내는 방식으로 하면서, API 서버의 응답에 따라 다른 사용자에게 메세지 브로드캐스팅을 할지 결정한다.

이때, 메세지를 보낸 클라이언트가 요청 성공 여부에 따라 화면에 다른 상태를 표현해주기 위해 Optimistic UI를 적용하기로 했다.
이때 Socket.IO의 Acknowledgements를 사용하면 Client가 전달한 callback 함수를 서버에서 값을 전달해 실행하도록 제어할 수 있었다.

@SubscribeMessage('chat')
  async chatEvents(
    @MessageBody() data: NewMessage | ModifyMessage | DeleteMessage,
    @ConnectedSocket() socket: SocketWithAuth,
  ) {
    const communityName = socket.nsp.name;
    const { chatType, channelId } = data;

    const result = await requestApiServer({요청에 필요한 data});
    if (result) {
      socket.to(channelId).emit(`${chatType}-chat`, result);
    }

    const written = result ? true : false;
    return { written, chatInfo: result };
  }

결말

커뮤니티에 포함된 채널에서 들어오는 이벤트를 명확히 구분하기 위해, namespaceroom기능을 사용했다.

1개의 커뮤니티에서 발생하는 이벤트는 하나의 namespace가 담당하고, 커뮤니티내 사용자들은 채널별로 Room을 사용하여 그룹핑했다.

socket 서버 구현 시에는 namespace로 연결되는 커뮤니티는 굉장히 많기 때문에 동적 namespace로 접속 요청이 온 커뮤니티에 대한 정보를 따로 지정하지 않고도 연결 가능하고, socket이 들어왔을 때 socket instance가 가진 nsp 속성을 활용하여 개발을 진행했다.

사용자가 채팅을 입력하면, 웹소켓을 통해 채팅 정보를 소켓 서버로 전송하고, 소켓 서버에서는 API 서버로 채팅 저장 요청을 보낸다.
서버에서 DB 쓰기 작업을 수행하고, 성공 응답을 소켓 서버로 전달한다.
소켓 서버는 동일한 채널에 속해있는 사용자들에게 API 응답으로 받은 채팅 정보를 브로드캐스팅 한다.


@WebSocketGateway({namespace: /\/socket\/commu-.+/})
export class SocketGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
  
  @WebSocketServer() server: Server;
  private logger: Logger = new Logger('Socket');

  @SubscribeMessage('join')
  joinEvent(@MessageBody() data: Join, @ConnectedSocket() socket: Socket) {
    const community = socket.nsp;
    const communityName = socket.nsp.name;
    const { channels } = data;
    community.socketsJoin(channels);
    console.log(
      `This socket which ns is '${communityName}' join rooms : ` + Array.from(socket.rooms),
    );
  }
  
  @SubscribeMessage('chat')
  async chatEvents(
    @MessageBody() data: NewMessage | ModifyMessage | DeleteMessage,
    @ConnectedSocket() socket: SocketWithAuth,
  ) {
    const communityName = socket.nsp.name;
    const { chatType, channelId } = data;

    const result = await requestApiServer({요청에 필요한 data});
    if (result) {
      socket.to(channelId).emit(`${chatType}-chat`, result);
    }

    const written = result ? true : false;
    return { written, chatInfo: result };
  }
}

이후의 고민들

여러 개의 namespace를 연결하는 방식이 비용적인 측면에서 괜찮을지 고민하여 알아보았습니다.
Socket Multi Namespace Connect 옳은가?

profile
삽질전문 아티스트

0개의 댓글