fullstackopen.com/en/part8/fragments_and_subscriptions를 읽고 정리한 글.
목차
🔸 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
'GraphQL' 카테고리의 다른 글
Apollo :: 아폴로 클라이언트, 클라이언트 사이드 아키텍쳐 (0) | 2021.03.23 |
---|---|
Apollo GraphQL Error: "Cannot read property 'startsWith' of undefined" (0) | 2021.03.17 |
Fullstack Part 8 #4 Login & Cache update (0) | 2021.03.11 |
Fullstack Part 8 #3 DB (0) | 2021.03.10 |
Fullstack part 8 #2 React and GraphQL (0) | 2021.03.09 |
댓글