# 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 해준다.