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