GraphQL
- API용 쿼리 언어이며 데이터에 대해 정의한 유형 시스템을 사용하여 쿼리를 실행하기 위한 서버 측 런타임입니다.
- 특정 데이터베이스 또는 스토리지 엔진에 연결되지 않으며 대신 기존 코드 및 데이터로 지원됩니다.
- 필요한 것을 요청하고 정확히 가져옵니다.
- 단일 요청으로 많은 리소스 가져올 수 있습니다.
구조 (Apollo 사용)
일단 자세한 설명에 앞서서 전체 구조가 이런 식으로 돌아간다는 이해하시고 읽으면 이해가 더 잘 될 겁니다.
프론트엔드 : Apollo Client 를 통해 스키마의 앤드포인트에서 필요한 데이터를 GraphQL 쿼리문으로 요청
백엔드 : Apollo Server 에 스키마, 리졸버, 데이터소스 를 구성
- 리졸버 : 데이터소스에서 데이터 가져와서 스키마 필드를 채워서 응답을 보냄
- 스키마 : 클라이언트에서 접근할 수 있는 앤드포인트(Query, Mutation) 의 타입을 정의
- 데이터 소스 : 데이터베이스 및 타 REST API 등의 데이터
스키마
- 서버와 클라이언트 간의 계약 입니다.
- 백엔드 구현 세부 정보를 숨기면서 소비자에게 유연성을 제공하는 추상화 층입니다.
스키마 정의 언어 (SDL)
- 스키마는 필드를 포함하는 객체 타입의 모음입니다.
- 각 필드에는 고유한 타입이 있습니다.
- 필드 타입은 스칼라(Int 또는 String) 이거나 다른 사용자 정의 객체 타입일 수 있습니다.
- 쉽게 생각하면 타입스크립트의 interface 나 Type 정도로 생각하면 됩니다.
- 똑같이 ParscalCase 로 작성합니다.
- 그러나 쉼표로 구분되지 않으며, null 허용하지 않는 경우 필드 뒤에 느낌표를 붙여야 합니다. non nullable
- 자바스크립트 원시 타입인 string, number 이 아니라 String, Int 등을 사용해야 합니다.
- 주석을 달 수도 있습니다.
"""
여러 줄 주석
"""
type SpaceCat { // ParscalCase 로 작성합니다
"한 줄 주석"
name : String! // Non Nullable
age: Int
missions: [Mission]
}
EntryPoint (진입점)
- 클라이언트가 접근할 수 있는 필드 입니다.
- 조회 요청은 Query, 쓰기 요청은 Mutation 안에 작성합니다.
- Mutation 의 경우 이름은 add, delete, create 같은 동사로 시작하는게 좋습니다.
- 그 외에 선언된 type 은 모두 커스텀 타입 정도로 생각하면 됩니다.
- gql 은 무엇인가요 ?
- 스키마 정의와 같은 GraphQL 문자열을 래핑하는 데 사용되는 태그가 지정된 템플릿 리터럴입니다.
- GraphQL 문자열을 Apollo 가 작업 및 스키마로 작업할 때 예상하는 형식으로 변환하고 구문 강조도 활성화 합니다.
const { gql } = require("apollo-server");
const typeDefs = gql`
type Query { // 조회 요청에 대한 진입점
"Query to get tracks array for the homepage grid"
tracksForHome: [Track!]! // 이것이 진입점
}
type Mutation { // 쓰기 요청에 대한 진입점
"Increment the number of views of a given track, when the track card is clicked"
incrementTrackViews(id: ID!): IncrementTrackViewsResponse!
}
"A track is a group of Modules that teaches about a specific topic"
type Track { // 이것은 커스텀 타입 정도로 생각
id: ID!
"The track's title"
title: String!
}
`
module.exports = typeDefs;
Mutation 요청의 에러 처리
Mutation 요청의 응답에는 클라이언트에 유용한 정보를 담아서 반환값으로 던져주어야 합니다.
- code: Int HTTP 상태 코드와 유사한 응답의 상태를 나타내는 입니다.
- success: Boolean 변이가 담당하는 모든 업데이트가 성공했는지 여부를 나타내는 플래그입니다.
- message: String 클라이언트 측에서 변형 결과에 대한 정보를 표시합니다. 이것은 돌연변이가 부분적으로만 성공했고 일반적인 오류 메시지가 전체 내용을 말할 수 없는 경우에 특히 유용합니다.
type AssignMissionResponse {
code: Int!
success: Boolean!
message: String!
spacecat: Spacecat
misson: Misson
}
Mutation 오류 해결을 위해 try catch 문으로 감싸주어야 합니다.
incrementTrackViews: async (_, {id}, {dataSources}) => {
try {
const track = await dataSources.trackAPI.incrementTrackViews(id);
return {
code: 200,
success: true,
message: `Successfully incremented number of views for track ${id}`,
track
};
} catch (err) {
return {
code: err.extensions.response.status,
success: false,
message: err.extensions.response.body,
track: null
};
}
},
인수 argument
- 인수란 쿼리의 특정 필드에 제공하는 값입니다.
- 인수를 통해 여러가지를 할 수 있습니다.
- 특정 개체를 검색
- 개체 집합을 통해 필터링
- 필드의 반환 값을 변환
- 사용자가 제출한 검색어를 사용
- 아래와 같이 함수 만들듯 인수 넣어주면 됩니다.
type Query {
spacecat(id: ID!): SpaceCat
missions(to: String, scheduled: Boolean): [Mission]
}
리졸버는 2번째 매개변수인 args 를 구조분해해서 접근할 수 있습니다.
const resolvers = {
Query: {
spaceCat: (_, {id}, {dataSources}) => {
return dataSources.spaceCatsAPI.getSpaceCat(id)
}
}
}
Operation 변수
클라이언트 측에서는 아래와 같이 $ 기호를 사용하여 GraphQL 변수를 작성할 수 있습니다.
$변수명 : 유형
query GetTrack($trackId: ID!) {
track(id: $trackId){
id,
title
modules {
id
title
length
}
}
}
그리고 Varaiables 에 trackId 추가 하면 인자 넘겨줘서 쿼리 가능합니다.
{
"trackId": "c_0"
}
프론트에서는 useQuery 의 두번째 매개변수의 옵션에 variables 로 인수를 넘겨줄 수 있습니다.
const GET_SPACECAT = gql`
query getSpacecat(&spaceCatId: ID!) {
spacecat(id: &spaceCatId){
name
}
}
`
const {loading, error, data} = useQuery(GET_SPACECAT, {
variables: { spaceCatId }
})
쓰기 요청에는 useMutation 을 사용하면 됩니다. useQuery 와 달리 반환값이 배열이라는 점에 주목해야 합니다!
const INCREMENT_TRACK_VIEWS = gql`
mutation IncrementTrackViews($incrementTrackViewsId: ID!) {
incrementTrackViews(id: $incrementTrackViewsId) {
code
success
message
track {
id
numberOfViews
}
}
}
`;
const [incrementTrackViews, {loading, error, data}] = useMutation(INCREMENT_TRACK_VIEWS, {
variables: { incrementTrackViewsId: id },
// to observe what the mutation response returns
onCompleted: (data) => {
console.log(data);
},
});
리졸버 (resolver)
- GraphQL의 장점은 여러 데이터 소스를 혼합하여 클라이언트 앱의 요구 사항을 충족하는 API를 생성할 수 있다는 것입니다. 바로 이 역할을 리졸버가 해줍니다.
- 리졸버는 DataSource 에서 데이터를 가져온 다음 해당 데이터를 클라이언트가 요구하는 모양으로 변환하고 스키마의 필드에 대한 데이터를 채우는 역할을 합니다.
- DataSource 는 데이터베이스, 타사 API, 웹후크 등 모든 종류의 위치를 의미합니다.
- 주의할 점으로는 리졸버의 필드명을 스키마에 작성된 필드이름과 똑같이 작성해줘야 합니다.
리졸버 생성 함수
필드명: (parent, args, context, info) => {},
- parent: parent
- 이 필드의 부모 리졸버의 반환 값입니다. 리졸버 체인을 다룰 때 유용합니다.
- args: args
- 특정 항목(예: 모든 id args는 GraphQL 작업에 의해 필드에 제공된 모든 GraphQL 인수를 포함하는 개체입니다. 트랙 이 아닌 특정 트랙)을 쿼리할 때 클라이언트 영역에서 서버 영역에서 이 매개 변수를 통해 액세스할 수 있는 인수를 사용하여 쿼리를 만듭니다. 이 내용은 Lift-off III에서 더 자세히 다루겠습니다.
- 컨텍스트: contextRESTDataSource
- 특정 작업을 위해 실행 중인 모든 리졸버에서 공유되는 객체입니다. 리졸버는 상태 공유 (인증 정보, 데이터베이스 연결 ) 또는 RESTDataSource 경우를 위해 이 context가 필요합니다
- info: info
- 필드 이름, 루트에서 필드까지의 경로 등 작업의 실행 상태에 대한 정보를 포함합니다. 다른 것만큼 자주 사용되지는 않지만 확인자 수준에서 캐시 정책을 설정하는 것과 같은 고급 작업에 유용할 수 있습니다.
매개변수 필요 없는 경우에는 아래와 같이 언더바(_) 처리를 하여 스킵해줍니다.
- 처음 생략할 매개변수는 _ 처리 합니다.
- 그 다음 매개변수는 __ 처리 합니다.
- 이게 매개변수 순서가 중요한게 아니라 등장한 순서에 맞춰 언더바(_) 를 써주면 됩니다.
const resolvers = {
Query: {
tracksForHome1: (parent, args, context, info) => {},
tracksForHome2: (_, __, { dataSources }, info) => {},
tracksForHome3: (parent, _, { dataSources }) => {}, // 2번째 매개변수지만 1번째 생략이기 때문에 언더바가 1개
},
};
RESTDataSource
- 왜 필요한가요? 상황을 가정하여 설명해보겠습니다.
- /tracks 엔드포인트가 100개의 트랙을 반환합니다.
- 각 트랙의 작성자 정보를 얻으려면 /tracks/:id 엔드포인트를 100번 호출해야 합니다.
- 모든 트랙의 작성자가 모두 같은 저자라면 똑같은 엔드포인트를 100번 호출하는 것입니다.
- 이런 비효율적인 문제를 해결하기 위해 캐싱 전략을 세워야 합니다.
- Apollo 전용 DataSource클래스인 RESTDataSource 를 백엔드 서버에 구현하면 REST API 호출에 대한 리소스 캐싱 및 중복 제거를 처리해줍니다.
- 또한 전용 클래스에서 데이터 가져오기 구현을 유지하고 리졸버를 단순하고 깨끗하게 유지하기 위해 사용합니다.
- 설치 : npm install apollo-datasource-rest
const { RESTDataSource } = require("apollo-datasource-rest");
class TrackAPI extends RESTDataSource {
constructor() {
super();
this.baseURL = "https://odyssey-lift-off-rest-api.herokuapp.com/";
}
}
module.exports = TrackAPI;
리졸버와 데이터 소스 연결
// /server/src/index.js
const { ApolloServer } = require('apollo-server');
const typeDefs = require('./schema');
const resolvers = require('./resolvers');
const TrackAPI = require('./datasources/track-api');
const server = new ApolloServer({
typeDefs,
resolvers,
dataSources: () => {
return {
trackAPI: new TrackAPI(),
};
},
});
에러가 발생하는 경우
- 잘못된 Query 를 날리면 errors 배열이 반환됩니다.
- 발생한 오류들이 담긴 배열입니다.
- 오류코드 참고 https://www.apollographql.com/docs/apollo-server/v3/data/errors/#error-codes
const response = {
"errors": [
{
"message": "Cannot query field \"numberOfViews\" on type \"Query\".",
"extensions": {
"code": "GRAPHQL_VALIDATION_FAILED",
"exception": {
"stacktrace": [
"GraphQLError: Cannot query field \"numberOfViews\" on type \"Query\".",
" at Object.Field (/Users/mac-rent/Documents/GitHub/odyssey-lift-off-part2/server/node_modules/graphql/validation/rules/FieldsOnCorrectTypeRule.js:48:31)",
" at Object.enter (/Users/mac-rent/Documents/GitHub/odyssey-lift-off-part2/server/node_modules/graphql/language/visitor.js:323:29)",
" at Object.enter (/Users/mac-rent/Documents/GitHub/odyssey-lift-off-part2/server/node_modules/graphql/utilities/TypeInfo.js:370:25)",
" at visit (/Users/mac-rent/Documents/GitHub/odyssey-lift-off-part2/server/node_modules/graphql/language/visitor.js:243:26)",
" at validate (/Users/mac-rent/Documents/GitHub/odyssey-lift-off-part2/server/node_modules/graphql/validation/validate.js:69:24)",
" at validate (/Users/mac-rent/Documents/GitHub/odyssey-lift-off-part2/server/node_modules/apollo-server-core/dist/requestPipeline.js:186:39)",
" at processGraphQLRequest (/Users/mac-rent/Documents/GitHub/odyssey-lift-off-part2/server/node_modules/apollo-server-core/dist/requestPipeline.js:98:34)",
" at processTicksAndRejections (node:internal/process/task_queues:96:5)",
" at async processHTTPRequest (/Users/mac-rent/Documents/GitHub/odyssey-lift-off-part2/server/node_modules/apollo-server-core/dist/runHttpQuery.js:221:30)"
]
}
}
}
]
}
리졸버 채이닝
/track/:id 호출해서 authorId 를 가져오고 이걸로 /author/:id 를 호출해서 작가의 name 을 가져온다면…
한 리졸버가 하는일이 너무 많습니다.
Query.track 리졸버의 결과가 Track.author 리졸버로 들어간다면 자연스럽게 역할이 분담되어 동작할 수 있지 않을까요?
실제로 이런 식으로 리졸버를 최대한 가볍게 분리를 시켜야 관리하기도 편하고 추후 확장하기도 좋다고 합니다.
아래는 조금 다르지만 리졸버 체이닝 예시입니다.
const resolvers = {
Query: {
// returns an array of Tracks that will be used to populate the homepage grid of our web client
tracksForHome: (_, __, { dataSources }) => {
return dataSources.trackAPI.getTracksForHome();
},
},
Track: {
author: ({ authorId }, _, { dataSources }) => {
return dataSources.trackAPI.getAuthor(authorId);
},
},
};
module.exports = resolvers;
'프론트엔드 개발 > GraphQL' 카테고리의 다른 글
Apollo Client 와 Relay 를 비교해보자 (1) | 2024.10.01 |
---|---|
Next.js 의 ServerSide 에서 Apollo Client Cache 데이터가 모든 사용자 간에 공유되는 이슈 (0) | 2023.12.19 |
Apollo Subscription 으로 GraphQL 서버와 WebSocket 통신하기 (0) | 2023.04.17 |
React에서 Apollo Client 를 상태관리 라이브러리로 사용하기 (0) | 2023.04.16 |
Apollo 오디세이(ODYSSEY) graphql 강의 (0) | 2023.02.11 |