---
# System prepended metadata

title: Subscribe로 채팅 구현하기
tags: [tech sharing]

---

# Subscribe로 채팅 구현하기
###### tags: `tech sharing`

# Apollo server

## pubsub
pubsub 인스턴스가 서버 코드가 특정한 레이블에 이벤트를 publish 하게 할 수 있고 특정한 레이블과 관련된 이벤트를 listen 할 수 있게 한다.

### publish
`publish` 메소드로 이벤트를 publish 할 수 있다.

```javascript=
pubsub.publish('POST_CREATED', {
  postCreated: {
    author: 'Ali Baba',
    comment: 'Open sesame'
  }
});
```

1. 첫번째 매개변수는 이벤트 레이블의 이름이다.
    - publishing을 하기 전에 레이블 이름을 등록할 필요는 없다.
2. 두번째 배개변수는 이벤트와 관련된 payload이다.

`subscription`의 return value가 `update` 되어야할 때 `publish`를 사용한다. 예를들어서 댓글이 새로 추가가 되었을 때 mutation을 하는데 이 때 publish까지 할 수 있다.

## 이벤트 listening
`AsyncIterator` 객체는 특정한 레이블과 관련되어 있는 이벤트를 listening 한다. 그리고 그것을 queue에 넣는다. `AsyncIterator`는 `Pubsub`의 메소드를 호출해서 만들 수 있다.

```javascript=
pubsub.asyncIterator(['POST_CREATED']);
```

리졸버의 Subscription 필드 내의 모든 subscribe 함수는 `AsyncIterator`를 반환해야한다.

### 필터링 이벤트
어떤 경우에는 클라이언트가 특정한 조건에 맞춰서 데이터를 받기를 원할 수도 있다. 그러기 위해서는 `withFilter` helper 함수를 리졸버의 subscription 필드에서 사용해야한다.

만약 서버가 `commentAdded`라는 subscription을 제공하고 댓글이 추가되었을 때 클라이언트에게 알려주기를 바란다면 클라이언트의 subscription은 다음과 같을 것이다.

```gql=
subscription($repoName: String!){
  commentAdded(repoFullName: $repoName) {
    id
    content
  }
}
```

그런데 `COMMENT_ADDED` 이벤트를 어느 레포지토리에나 publish 한다. 이것은 `commentAdded` 리졸버가 모든 새로운 댓글에 대해서 작동하고 어떤 레포지토리에 추가를 하는지와는 상관이 없다는 말이다. 결과적으로 클라이언트는 원하지 않는 데이터를 받을 수 있다.

이런 문제를 `withFilter` 헬퍼 함수를 사용해서 해결할 수 있다.

`withFilter` 함수를 사용하는 예는 다음과 같다.

```javascript=
const { withFilter } = require('apollo-server');

const resolvers = {
  Subscription: {
    commentAdded: {
      subscribe: withFilter(
        () => pubsub.asyncIterator('COMMENT_ADDED'),
        (payload, variables) => {
          // Only push an update if the comment is on
          // the correct repository for this operation
          return (payload.commentAdded.repository_name === variables.repoFullName);
        },
      ),
    }
  },
    // ...other resolvers...
};


```

`withFilter` 함수는 두 개의 매개변수를 받는다.

1. 첫번째 매개변수는 subscribe에 사용하는 함수이다.
2. 두번째 매개변수는 특정 클라이언트에 보내져야하면 true를 반환하는 filter 함수이다. 이 함수는 두 개의 매개변수를 받는다.
    1. `payload`: 이벤트가 publish 될 때의 payload이다.
    2. `variables`: 클라이언트가 subscription을 시작할 때 제공한 매개변수들을 가지고 있는 객체

## Operation context

metadata를 context 함수에 제공된 req에서 추출하는 것과는 다르게  subscription에서는 connection 객체를 사용해야한다.

모든 operation type이 같은 context 초기화 함수를 사용하기 때문에 context 함수 내에서 req나 connection이 있는지를 확인해야한다.

```javascript=
const server = new ApolloServer({
  context: ({ req, connection }) => {
    if (connection) { // Operation is a Subscription
      // Obtain connectionParams-provided token from connection.context
      const token = connection.context.authorization || "";
      return { token };
    } else { // Operation is a Query/Mutation
      // Obtain header-provided token from req.headers
      const token = req.headers.authorization || "";
      return { token };
    }
  },
});


```

## `onConnect`와 `onDisconnect`

subscription request가 연결될 때와 연결이 끊길 때 아폴로 서버가 실행하는 함수를 정의할 수 있다.

`onConnect` 함수를 정의하면 다음과 같은 이점이 있다.
1. 특정한 연결을 onConnect에서 false를 반환하면서 거절할 수 있다.(인증에 매우 유용하다.)
2. `onConnect`에서 객체를 반환하면 그 객체는 웹소켓 connectiond의 `context` 객체에 추가된다.
    - 이 context는 리졸버로 전달되는 context는 아니지만 operation context를 초기화할 때 전달할 수 있다.

`ApolloServer`의 생성자로 전달 하면서 정의할 수 있다.

```javascript=
const server = new ApolloServer(
  subscriptions: {
    onConnect: (connectionParams, webSocket, context) => {
      console.log('Connected!')
    },
    onDisconnect: (webSocket, context) => {
      console.log('Disconnected!')
    },
    // ...other options...
  },
);


```

- `connectionParams` : token과 같이 request 안에 포함되어 있는 정보들을 담고 있다.
- `webSocket`
- `Context`: 웹소켓 연결의 context이다. subscription operation과 관련된 context가 아니다.

## `onConnect`로 인증

클라이언트에서 `SubscriptionsClient`는 `connectionParams`에 토큰을 추가할 수 있도록 해준다. 서버에서 모든 그래프큐엘 subscription들은 connection이 모두 인증되고 onConnect가 truethy 값을 반환하기까지 지연된다.

`connectionParams` 매개변수는 클라이언트로부터 넘겨받은 정보를 갖고 있다. 이것을 이용해서 사용자 인증을 할 수 있다. graphql context는 더 정밀한 권한 점검을 위해서 확장 가능하다.

```javascript=
const { ApolloServer } = require('apollo-server');
const { resolvers, typeDefs } = require('./schema');

const validateToken = authToken => {
  // ... validate token and return a Promise, rejects in case of an error
};

const findUser = authToken => {
  return tokenValidationResult => {
    // ... finds user by auth token and return a Promise, rejects in case of an error
  };
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
  subscriptions: {
    onConnect: (connectionParams, webSocket) => {
      if (connectionParams.authToken) {
        return validateToken(connectionParams.authToken)
          .then(findUser(connectionParams.authToken))
          .then(user => {
            return {
              currentUser: user,
            };
          });
      }

      throw new Error('Missing auth token!');
    },
  },
});

server.listen().then(({ url, subscriptionsUrl }) => {
  console.log(`🚀 Server ready at ${url}`);
  console.log(`🚀 Subscriptions ready at ${subscriptionsUrl}`);
});


```

위 예제는 사용자의 토큰을 확인한 뒤에 사용자를 찾고 사용자 객체를 프로미스로 반환한다.


## 미들웨어 integration
```javascript=
const http = require('http');
const { ApolloServer } = require('apollo-server-express');
const express = require('express');

const PORT = 4000;
const app = express();
const server = new ApolloServer({ typeDefs, resolvers });

server.applyMiddleware({app})

const httpServer = http.createServer(app);
server.installSubscriptionHandlers(httpServer);

// Make sure to call listen on httpServer, NOT on app.
httpServer.listen(PORT, () => {
  console.log(`🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`)
  console.log(`🚀 Subscriptions ready at ws://localhost:${PORT}${server.subscriptionsPath}`)
})


```

# Apollo Client

```javascript=
const COMMENTS_SUBSCRIPTION = gql`
  subscription OnCommentAdded($repoFullName: String!) {
    commentAdded(repoFullName: $repoFullName) {
      id
      content
    }
  }
`;


```

apollo client가 `onCommentAdded` subscription을 실행하면 그래프큐엘 서버와 연결을 하고 응답을 listen 한다. query와는 다르게 서버가 즉시 처리를 하고 응답을 주는게 아니라 백엔드에 특정한 이벤트가 발생했을 때 클라이언트에 데이터를 보낸다. 서버에서 데이터를 보낼 때는 데이터는 subscription에 실행된 구조와 똑같은 구조의 데이터가 온다.

subscription이 지속적인 연결을 유지하기 때문에 기본적인 HTTP transport를 사용하지 못하고 보통 subscriptions-transport-ws 라이브러리의 웹소켓을 사용한다.

## 필요한 라이브러리 설치

`Apollo Link`는 아폴로 클라이언트 네트워킹의 customize를 도와주는 라이브러리 컬렉션이다. 그 중 하나는 `@apollo/client/link/ws`으로 웹소켓 연결을 가능하게 해준다.

```bash=
npm install @apollo/client subscriptions-transport-ws
```

## `WebSocketLink` 초기화

```javascript=
import { WebSocketLink } from '@apollo/client/link/ws';

const wsLink = new WebSocketLink({
  uri: 'ws://localhost:5000/graphql',
  options: {
    reconnect: true
  }
});


```

## operation에 따라서 다른 transport 사용하기

subscription에 대해서는 웹소켓을 사용해야하지만 query와 mutation에 대해서는 사용하면 안된다. 따라서 아폴로 클라이언트 라이브러리는 `split` 함수를 가지고 있어서 boolean check 결과에 따라서 다른 Link를 사용하게 해준다. (우리 서비스에서는 query와 mutation에서는 graphql-request를 사용하니 상관 없을 것 같다.)

아래와 같이 `split` 함수를 통해서 두 개의 Link를 하나로 합칠 수 있다.

```javascript=
import { split, HttpLink } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
import { WebSocketLink } from '@apollo/client/link/ws';

const httpLink = new HttpLink({
  uri: 'http://localhost:3000/graphql'
});

const wsLink = new WebSocketLink({
  uri: 'ws://localhost:5000/graphql',
  options: {
    reconnect: true
  }
});

// The split function takes three parameters:
//
// * A function that's called for each operation to execute
// * The Link to use for an operation if the function returns a "truthy" value
// * The Link to use for an operation if the function returns a "falsy" value
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,
  httpLink,
);


```


## 웹소켓으로 인증하기

`connectionParams`을 `WebSocketLink` 생성자 안에 넣어서 인증을 할 수 있다.

```javascript=
import { WebSocketLink } from '@apollo/client/link/ws';

const wsLink = new WebSocketLink({
  uri: 'ws://localhost:5000/graphql',
  options: {
    reconnect: true,
    connectionParams: {
      authToken: user.authToken,
    },
  },
});


```

그런데.. 우리는 쿠키에 토큰을 넣어놨는데 이건 어떻게 하면 좋을까... 찾아봐야겠다.


## subscription 실행하기

apollo client의 `useSubscription` 훅을 사용해서 리액트에서 subscription을 할 수 있다. `useSubscription`은 `loading`, `error`, `data` 값을 반환한다. 서버에서 새로운 데이터를 보낼 때마다 컴포넌트에서 리렌더링이 일어난다. 아마 state처럼 쓰이는 것 같다.

## Subscribing to updates for a query

apollo client의 query가 결과를 반환할 때 결과는 `subscribeToMore`이라는 함수를 포함하고 있다. 이 함수를 통해서 query의 original result에 업데이트를 하는 subscription을 실행할 수 있다. (우리는 swr을 사용해서 사용할 일이 없을 것 같다.)

## useSubscription

### 옵션
- subscription: graphql subscription 코드
- variables: subscription을 실행하기 위한 모든 변수
- shouldResubscribe: subscription이 구독 해제된 다음에 다시 구독되어야 되는지를 나타낸다.
- skip: true이면 그 subscription이 생략된다.
- onSubscriptionData: subscription이 데이터를 받을 때마다 실행되는 콜백함수를 지정할 수 있다.


# 구현을 어떻게?

## 채팅방을 어떻게 나눌 것인가?
- 각 채팅방마다 동적으로 레이블을 생성해서 연결한다... 가능한가..?
- withFilter에서 filtering 해준다.