본문 바로가기
GraphQL

Fullstack Part 8 #5 Fragments and subscriptions

by kicksky 2021. 3. 12.

fullstackopen.com/en/part8/fragments_and_subscriptions를 읽고 정리한 글.

 

Fullstack part8 |

Open online course on Javascript based modern web development by University of Helsinki and Houston Inc..

fullstackopen.com

 

목차

🔸 fragments - 반복되는 쿼리 결과를 변수에 선언하여 재사용 하는 방법

🔸 subscriptions - 브라우저에서 서버로 요청하는 방식이 아니라 반대로 서버에서 브라우저로 알림을 받는 것

🔸 n + 1 문제 - 불필요한 쿼리 발생을 join 쿼리 혹은 populate로 최적화

 

fragments

여러 쿼리가 비슷한 결과를 리턴하는 경우는 흔하다. 복수의 쿼리가 완전히 동일한 필드를 리턴하곤 한다. 이런 경우에 fragments를 사용해서 쿼리를 더 단순하게 표현할 수 있다.

- GraphQL schema가 아니라 client에 정의된다.

- fragment를 변수에 선언해서 ${FRAGMENTS_VARIABLES} 이렇게 사용하는 것이 낫다.

 

subscriptions

query와 mutation 타입이 아닌 세 번째 타입. client가 서버에서 일어나는 변화에 대한 업데이트를 구독할 수 있다. 앞선 두 타입은 리액트가 브라우저에서 서버로 HTTP 요청을 보냄으로써 브라우저와 서버가 상호작용하는 방식이었다면, subscription은 그와 정반대이다.

- 어플리케이션이 subscription을 만들고 나서 서버를 listen한다. 서버에서 변화가 생기면 서버는 모든 subscribers에게 알림을 보낸다. 

- 기술적으로 말하면 HTTP 프로토콜은 서버와 브라우저의 소통에 그리 적합하지 않다. 따라서 Apollo는 서버 subscriber 커뮤니케이션을 위해 웹소켓을 사용한다. 

 

서버에서의 subscriptions

type Subscription {
  personAdded: Person!
} 

이를테면 새로운 사람과 그의 전화번호를 추가한다고 해보자. 그 사람의 정보는 모든 구독자에게 전송된다. personAdded 구독은 구독자에게 알림을 보내는 resolver가 필요하다.

// 발행-구독 모델. PubSub 인터페이스를 통해 객체를 활용한다.
const { PubSub } = require('apollo-server')
const pubsub = new PubSub() 

  Mutation: {
    addPerson: async (root, args, context) => {
      const person = new Person({ ...args })
      const currentUser = context.currentUser

      if (!currentUser) {
        throw new AuthenticationError("not authenticated")
      }

      try {
        await person.save()
        currentUser.friends = currentUser.friends.concat(person)
        await currentUser.save()
      } catch (error) {
        throw new UserInputError(error.message, {
          invalidArgs: args,
        })
      }
      
      // 새로운 사람을 주가하면 이 작업에 대해 모든 구독자에게 알림을 발행한다.
      // PubSub의 publish 메소드를 이용한다.
      pubsub.publish('PERSON_ADDED', { personAdded: person })

      return person
    },  
  },
  Subscription: { 
    // personAdded 구독 resolver는 모든 구독자에게 적절한 iterator 객체를 리턴함으로써
    // 그들을 등록(register)한다. 
    personAdded: {
      subscribe: () => pubsub.asyncIterator(['PERSON_ADDED'])
    },
  },

서버를 다음과 같이 시작한다.

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

 

클라이언트에서의 subscriptions

구독을 리액트에서 사용하려면 설정configuration을 바꿔주어야 한다. 어플리케이션은 GraphQL 서버에 웹소켓 뿐만 아니라 HTTP로 연결 되어야 하기 때문이다.

npm install @apollo/client subscriptions-transport-ws
import { 
  ApolloClient, ApolloProvider, HttpLink, InMemoryCache, 
  split
} from '@apollo/client'
import { setContext } from 'apollo-link-context'

/* 아폴로 클라이언트 라이브러리 */
import { getMainDefinition } from '@apollo/client/utilities'
import { WebSocketLink } from '@apollo/client/link/ws'

const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('phonenumbers-user-token')
  return {
    headers: {
      ...headers,
      authorization: token ? `bearer ${token}` : null,
    }
  }
})

/* HTTP와 웹소켓 연결 */
const wsLink = new WebSocketLink({
  uri: `ws://localhost:4000/graphql`,
  options: {
    reconnect: true
  }
})

const httpLink = createHttpLink({
  uri: 'http://localhost:4000',
})

/* split 링크 */
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query)
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,
  authLink.concat(httpLink),
)

/* 아폴로 클라이언트 객체의 link 수정 */
const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: splitLink
})

ReactDOM.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>, 
  document.getElementById('root')
)

구독은 useSubscription Hook 함수를 사용한다.

export const PERSON_ADDED = gql`
  subscription {
    personAdded {
      ...PersonDetails
    }
  }
  
${PERSON_DETAILS}
`

import {
  useQuery, useMutation, useSubscription, useApolloClient
} from '@apollo/client'

const App = () => {
  // ...

  useSubscription(PERSON_ADDED, {
    onSubscriptionData: ({ subscriptionData }) => {
      console.log(subscriptionData)
    }
  })

  // ...
}

전화번호부에 새로운 사람이 등록되면, 어디에서 등록되었는지는 상관없이 콘솔에 해당 정보가 출력된다.

- 서버가 → 클라이언트로 알림을 보낸다.

- onSubscriptionData 속성에 정의된 콜백 함수가 호출되고, 새로운 사람에 대한 정보(subscriptionData)가 파라미터로 들어온다.

- 어플리케이션이 새로운 사람을 생성할 때, 캐시에 두 번 추가되어야 한다.

/* App 컴포넌트 */
const App = () => {
  // ...

  const updateCacheWith = (addedPerson) => {
  	// 중복 검사
    const includedIn = (set, object) => 
      set.map(p => p.id).includes(object.id)  

	// state 불러오기
    const dataInStore = client.readQuery({ query: ALL_PERSONS })
    // state 내에 새로 추가된 인물에 대한 정보가 없는 경우
    if (!includedIn(dataInStore.allPersons, addedPerson)) {
      // state 캐시 추가
      client.writeQuery({
        query: ALL_PERSONS,
        data: { allPersons : dataInStore.allPersons.concat(addedPerson) }
      })
    }   
  }

  useSubscription(PERSON_ADDED, {
    onSubscriptionData: ({ subscriptionData }) => {
      const addedPerson = subscriptionData.data.personAdded
      notify(`${addedPerson.name} added`)
      // 추가된 인물에 대한 정보를 인자로 넣어 updateCachWith 함수 호출
      updateCacheWith(addedPerson)
    }
  })

  // ...
}

/* PersonForm 컴포넌트 */
const PersonForm = ({ setError, updateCacheWith }) => {
  // ...

  const [ createPerson ] = useMutation(CREATE_PERSON, {
    onError: (error) => {
      setError(error.graphQLErrors[0].message)
    },
    update: (store, response) => {
      updateCacheWith(response.data.addPerson)
    }
  })
   
  // ..
} 

 

n+1 문제

백엔드로 돌아와서, 어떤 사람이 누구의 친구인지를 확인하고 싶은 경우

Person 타입과 findPerson 쿼리를 수정한다.

/* Person 타입 재정의 */
type Person {
  name: String!
  phone: String
  address: Address!
  friendOf: [User!]!
  id: ID!
}

/* 쿼리 */
query {
  findPerson(name: "Leevi Hellas") {
    friendOf{
      username
    }
  }
}

friendOf는 DB의 Person 객체의 필드가 아니므로 resolver를 따로 정의한다. root 파라미터는 friends list가 생성되는 person 객체이다. User 객체 중에서 찾고자 하는 것은 friend list에서 root._id를 가진 데이터이다. 

Person: {
  address: (root) => {
    return { 
      street: root.street,
      city: root.city
    }
  },
  friendOf: async (root) => {
     const friends = await User.find({
       friends: {
         $in: [root._id]
       } 
     })

     return friends
   }
},

다음과 같이 모든 유저의 친구를 찾을 수도 있다.

query {
  allPersons {
    name
    friendOf {
      username
    }
  }
}

그러나 DB에서 불필요하게 많은 쿼리를 하는 경우도 있다. 우리가 5명의 유저를 저장하는 경우에 다음과 같은 로그를 볼 수 있다.

Person.find
User.find
User.find
User.find
User.find
User.find

모든 사람에 대해서 하나의 쿼리를 한다고 해도, 각각의 사람이 resolver에서 하나 이상의 쿼리를 발생시킨다.

이를 n + 1 문제라고 하는데, 보통 이를 해결하는 방법은 쿼리를 여러 번 하는 대신 join 쿼리를 하는 것이다.

 

User를 다음과 같이 저장해서 "join 쿼리"를 하거나, Person 객체들을 가져올 때 friendOf 필드를 populate 하면 된다.

const schema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
    unique: true,
    minlength: 5
  },
  phone: {
    type: String,
    minlength: 5
  },
  street: {
    type: String,
    required: true,
    minlength: 5
  },  
  city: {
    type: String,
    required: true,
    minlength: 5
  },
  friendOf: [
    {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'User'
    }
  ], 
})
Query: {
  allPersons: (root, args) => {    
    console.log('Person.find')
    if (!args.phone) {
      return Person.find({}).populate('friendOf')
    }

    return Person.find({ phone: { $exists: args.phone === 'YES' } })
      .populate('friendOf')
  },
  // ...
}

이렇게 하면 resolver에 friendOf 필드를 둘 필요가 없다.

 

+) resolver 함수의 네 번째 파라미터를 이용하면 쿼리를 더욱 최적화할 수 있다. 이 파라미터는 쿼리 자체를 조사하는 데 쓰인다. n+1 문제가 예상되는 경우에 join 쿼리를 하면 된다. 

 

필요하다면 DataLoader 라이브러리를 사용하자.


ko.wikipedia.org/wiki/%EB%B0%9C%ED%96%89-%EA%B5%AC%EB%8F%85_%EB%AA%A8%EB%8D%B8 

댓글