프론트엔드에서 GrpahQL 를 다루기 위한 대표적인 라이브러리로는 Apollo Client와 Relay 가 있습니다.
그 중에 저희는 Apollo Client 를 사용하고 있는데요.
처음에 Apollo 와 Relay 중 어느것을 하는가 비교했었을때 Next.js 와 SSR 기능과의 통합성을 좀 중점적으로 보았습니다.
왜냐하면 그 전까지 React 로 CSR 만 사용하다가 이번 프로젝트에 Next.js 를 처음 도입하게된 것이었고. 고객 유입을 위한 SEO 가 중요하여 SSR를 적극 사용할 것으로 기대했기 때문입니다.
Relay 는 SSR을 공식적으로 지원하지 않기에 별도로 복잡한 설정을 해주어야 하고. 성능 최적화. 캐시 자동관리 등에 중점적이어서 대규모 서비스에 적합하다는 말이 있었고.
Apollo Client SSR을 공식적으로 지원하여 설정이 매우 간단하고 빠르게 개발을 시작해야 할때 적합했기 때문에 선택했을 뿐이지 사실 선택에 대한 대단한 이유는 없었습니다. Apollo 사이트를 들어가보면 GraphQL 의 미래에 대한 엄청난 포부가 느껴지는데요. 사실 그런 부분도 크게 작용한거 같기는 합니다.
근데 지금 거의 반년 이상 Apollo Client 를 쓰며 느낀점은...
일단 빠르게 시작하기엔 적합한건 맞습니다. 저희는 급했기 때문에 빠르게 개발이 필요했기 때문에 잘 사용했습니다.
그러나 먼 훗날 성능 최적화를 신경쓰고 고도화를 하면서 부터 캐시를 다루게 되고.
큰 데이터 덩이리에서 프레그먼트들을 수동으로 캐시를 업데이트 해줘야 하는 경우가 생겨나면서 부터 좀 불편하고 피로도를 느꼈습니다.
심지어 Relay에는 캐시에 대한 접근 카운트를 하여 접근이 없으면 삭제해주는 자동 가비지 커렉션이 있는데 Apollo 에는 없기 때문에 이런걸 또 어떻게 처리해줘야 할 지에 대한 고민이 됩니다.
물론 그만큼 유연하고 자유도를 주는 것이겠지만, 사실 피곤하고 코드가 길어지는건 유지보수 차원에서 줄이고 싶은게 어쩔 수 없나 봅니다... 그래서 요즘 같이 여유가 조금 생겼을때. 아 우리도 Relay 를 조금 고민해볼만 할까? 그런 생각도 들긴 합니다.
가비지 컬렉션 | 자동 가비지 컬렉션 없음. 수동으로 캐시를 무효화하거나 리셋해야 함 | 자동 가비지 컬렉션 제공. 불필요한 데이터는 참조되지 않으면 삭제됨 |
수동 캐시 업데이트 | 캐시를 수동으로 업데이트하거나 무효화할 수 있음 (writeQuery, evict) | 수동 캐시 업데이트 기능 없음. 데이터는 서버와 자동 동기화됨 |
캐시 관리 철학 | 개발자가 캐시 데이터를 직접 관리. 필요한 경우 캐시 무효화 또는 갱신 | 자동으로 데이터 동기화 및 캐시 관리. 수동 관리 필요 없음 |
ChatGPT 에 물어보면 이렇게 답변 해줍니다.
Apollo Client와 Relay는 모두 GraphQL 클라이언트로, React 애플리케이션에서 GraphQL API와 상호작용할 때 자주 사용됩니다. 이 두 도구는 유사한 목적을 가지고 있지만, 아키텍처, 사용 방법, 최적화 측면에서 차이점이 있습니다. 다음은 주요 비교 내용입니다.
1. 설계 철학 및 목적
- Apollo Client:
- Apollo는 사용성을 중점에 둔 일반적인 GraphQL 클라이언트입니다. 초심자와 전문가 모두에게 적합하며, GraphQL 쿼리 및 변이를 쉽게 다루는 것이 목표입니다.
- 기본적으로 강력한 상태 관리 기능과 캐싱을 제공합니다. 서버 데이터를 클라이언트 쪽에서 쉽게 다룰 수 있게 도와줍니다.
- 프론트엔드에서 GraphQL을 유연하게 사용하고자 할 때 적합합니다.
- Relay:
- Relay는 Facebook이 만든 GraphQL 클라이언트로, 성능 최적화와 대규모 애플리케이션을 염두에 둔 설계가 특징입니다. Relay는 명시적으로 데이터를 최적화하고 효율적으로 처리하는 데 중점을 둡니다.
- GraphQL의 전체 구조와 깊은 통합이 되어 있으며, 쿼리 프래그먼트(fragment) 개념을 사용하여 데이터를 더 정교하게 관리할 수 있습니다.
- 복잡하고 규모가 큰 프로젝트에서 최적의 성능을 내기 위한 툴로 적합합니다.
2. 데이터 관리 및 캐싱
- Apollo Client:
- Apollo는 기본적으로 in-memory cache를 제공하여, 동일한 데이터를 다시 쿼리하지 않도록 캐시를 사용합니다.
- 캐시 업데이트 방식이 직관적이며, 유저가 직접 명령형 방식으로 캐시를 관리할 수 있습니다. 필요 시 데이터 갱신도 쉽게 할 수 있습니다.
- 간단한 쿼리와 변이(mutations)에 적합하며, 캐싱이 더 직관적입니다.
- Relay:
- Relay는 더 복잡한 캐시 구조를 가지고 있으며, 캐시 관리가 더 세밀합니다. 캐싱 효율을 극대화하기 위해 더 정교한 구조를 사용합니다.
- Relay의 캐시 동기화는 Apollo보다 더 자동화되어 있으며, 클라이언트 쪽에서 명시적인 관리 없이도 일관된 상태를 유지할 수 있습니다.
- 쿼리 프래그먼트를 사용하여 뷰의 데이터 요구 사항을 컴포넌트 단위로 관리하고, Relay는 필요한 데이터만 가져오도록 최적화합니다.
3. 쿼리 프래그먼트 및 코드 분할
- Apollo Client:
- Apollo는 쿼리를 한 곳에서 작성하고, 다양한 컴포넌트에서 쿼리 데이터를 사용할 수 있습니다.
- 특정 컴포넌트에 맞춘 쿼리를 사용하는 방식이 가능하지만, Relay처럼 자동으로 컴포넌트별로 쿼리를 나누는 방식은 제공하지 않습니다.
- Relay:
- Relay의 쿼리 프래그먼트 기능은 각 컴포넌트에서 필요한 데이터만 요청하도록 합니다. 이를 통해 성능 최적화를 극대화합니다.
- 컴포넌트가 자체적으로 필요한 데이터를 선언하고, Relay는 이를 조합하여 서버에 적절한 요청을 보냅니다. 이로 인해 데이터 요구 사항이 컴포넌트에 가까워지고 코드 분할이 자연스럽게 이뤄집니다.
4. 학습 곡선
- Apollo Client:
- Apollo는 상대적으로 학습 곡선이 완만합니다. 기본적인 사용법이 직관적이고, 다양한 튜토리얼과 문서가 잘 제공되기 때문에 빠르게 시작할 수 있습니다.
- 복잡한 기능을 사용하기 전에도 기본적인 CRUD 작업을 쉽게 구현할 수 있습니다.
- Relay:
- Relay는 학습 곡선이 상대적으로 가파릅니다. 특히 초기 설정이 복잡할 수 있으며, 성능 최적화를 위한 고급 기능을 이해하고 사용하려면 상당한 학습이 필요합니다.
- 하지만 이를 통해 대규모 애플리케이션에서는 뛰어난 성능을 보장합니다.
5. 커뮤니티 및 에코시스템
- Apollo Client:
- 매우 활발한 오픈소스 커뮤니티를 가지고 있으며, 다양한 플러그인과 확장 도구들이 존재합니다. 이를 통해 여러 환경과 쉽게 통합할 수 있습니다.
- Apollo Server와 같은 백엔드 도구들과의 통합도 매우 쉽습니다.
- Relay:
- Relay는 Facebook 내부 프로젝트로 시작된 만큼 더 제한적인 커뮤니티를 가지고 있습니다. 하지만 대규모 애플리케이션에 적합한 도구들을 많이 제공합니다.
- 다른 도구들과의 통합보다는 자체적인 성능 최적화에 더 중점을 둡니다.
6. 사용 사례
- Apollo Client:
- 작은 규모의 프로젝트부터 중간 규모의 애플리케이션까지 매우 적합합니다. 빠르게 구축해야 하거나, 다양한 데이터 소스와 유연하게 통합하고 싶을 때 강력합니다.
- Relay:
- 대규모 애플리케이션에 적합하며, 특히 성능이 중요한 프로젝트에서 유용합니다. Facebook 같은 대규모 데이터 요구가 있는 프로젝트에서 최적의 성능을 발휘합니다.
요약
- Apollo Client는 일반적인 용도에 더 적합하고 학습이 쉬우며, 유연하고 강력한 상태 관리 및 캐싱 기능을 제공합니다.
- Relay는 성능 최적화와 데이터 관리를 중요시하는 대규모 애플리케이션에 적합하며, 더 세밀한 데이터 관리와 쿼리 최적화를 제공합니다.
코드 비교해보기
Apollo Client와 Relay를 사용하여 데이터를 가져오는 예제를 보여드리겠습니다. 두 클라이언트 모두 GraphQL 쿼리를 통해 데이터를 가져오지만, 접근 방식과 설정이 다릅니다.
1. Apollo Client 예시
설정
먼저, Apollo Client를 설정합니다. Apollo Client를 설치하고 GraphQL API를 사용할 수 있도록 클라이언트를 구성해야 합니다.
ApolloProvider를 이용해 클라이언트를 설정합니다.
// ApolloClient 설정
import { ApolloClient, InMemoryCache, ApolloProvider, gql, useQuery } from '@apollo/client';
import React from 'react';
const client = new ApolloClient({
uri: 'https://your-graphql-endpoint.com/graphql',
cache: new InMemoryCache(),
});
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`;
function Users() {
const { loading, error, data } = useQuery(GET_USERS);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<h3>User List:</h3>
<ul>
{data.users.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
</div>
);
}
function App() {
return (
<ApolloProvider client={client}>
<Users />
</ApolloProvider>
);
}
export default App;
설명
- ApolloClient는 GraphQL API 엔드포인트와 메모리 캐시를 설정합니다.
- gql 태그로 쿼리를 정의하고, useQuery 훅을 사용해 데이터를 가져옵니다.
- 쿼리의 상태(loading, error, data)를 처리하고 데이터를 화면에 출력합니다
2. Relay 예시
설정
Relay는 조금 더 복잡한 설정이 필요합니다. Relay 환경과 Babel 플러그인을 설정해야 합니다.
module.exports = {
src: './src',
schema: './data/schema.graphql',
language: 'javascript',
};
Relay 환경 설정
Relay는 RelayEnvironment를 설정하는 부분이 필요합니다.
// RelayEnvironment 설정
import { Environment, Network, RecordSource, Store } from 'relay-runtime';
import { RelayEnvironmentProvider, useLazyLoadQuery, graphql } from 'react-relay';
import React from 'react';
function fetchQuery(operation, variables) {
return fetch('https://your-graphql-endpoint.com/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: operation.text,
variables,
}),
}).then(response => response.json());
}
const environment = new Environment({
network: Network.create(fetchQuery),
store: new Store(new RecordSource()),
});
const UsersQuery = graphql`
query UsersQuery {
users {
id
name
email
}
}
`;
function Users() {
const data = useLazyLoadQuery(UsersQuery, {});
return (
<div>
<h3>User List:</h3>
<ul>
{data.users.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
</div>
);
}
function App() {
return (
<RelayEnvironmentProvider environment={environment}>
<Users />
</RelayEnvironmentProvider>
);
}
export default App;
설명
- Environment와 Network를 설정해 Relay가 GraphQL API와 상호작용하도록 설정합니다.
- graphql 태그로 쿼리를 정의하고, useLazyLoadQuery 훅을 사용해 데이터를 가져옵니다.
- 쿼리 데이터가 준비되면 화면에 출력합니다.
주요 차이점:
- Apollo Client는 useQuery 훅을 사용해 간단하게 데이터를 가져오고 상태(loading, error, data)를 처리합니다.
- Relay는 쿼리를 더 정교하게 나눠서 처리할 수 있으며, Relay 환경 설정이 더 복잡하지만 성능 최적화가 뛰어납니다.
캐시가 어떻게 저장되고 관리되는지 비교
1. Apollo Client의 캐시 구조
Apollo Client는 기본적으로 InMemoryCache를 사용하여 데이터를 캐싱합니다. 이 캐시는 GraphQL 쿼리의 결과를 메모리 안에 저장하고, 쿼리의 결과를 재사용하여 네트워크 요청을 최소화합니다.
캐시 저장 방식
- Normalized Cache: Apollo Client는 데이터를 정규화(normalization)하여 저장합니다. 이는 데이터베이스의 테이블처럼 데이터를 구조화하고, 특정 데이터에 접근할 때 데이터를 중복 없이 효율적으로 저장하고 관리할 수 있도록 해줍니다.
- 캐시 키: Apollo Client는 각 엔티티마다 고유한 __typename과 id(혹은 custom key)를 결합하여 고유한 캐시 키를 만듭니다. 예를 들어, 다음과 같은 쿼리가 있다고 가정할 때:
query GetUsers {
users {
id
name
email
}
}
이 쿼리의 결과에서 id가 1인 User가 있다면, 이 데이터는 캐시에 User:1이라는 키로 저장됩니다. 이 방식으로 동일한 데이터를 여러 쿼리에서 사용하더라도, 캐시에서 데이터를 재활용할 수 있습니다.
캐시 데이터의 구조
캐시에 저장된 데이터는 정규화된 객체의 집합으로 저장됩니다. 예를 들어, 위 쿼리의 결과가 다음과 같다면:
{
"users": [
{ "id": 1, "name": "Alice", "email": "alice@example.com" },
{ "id": 2, "name": "Bob", "email": "bob@example.com" }
]
}
이 데이터는 캐시에 다음과 같은 구조로 저장됩니다:
{
"ROOT_QUERY": {
"users": ["User:1", "User:2"]
},
"User:1": {
"id": 1,
"name": "Alice",
"email": "alice@example.com"
},
"User:2": {
"id": 2,
"name": "Bob",
"email": "bob@example.com"
}
}
이렇게 데이터를 정규화하면, 동일한 데이터가 여러 곳에서 참조될 때 중복을 방지하고, 데이터를 효율적으로 관리할 수 있습니다.
캐시 관리
- 자동 캐시 업데이트: Apollo는 쿼리가 성공적으로 완료되면 자동으로 해당 데이터를 캐시에 저장합니다.
- 수동 캐시 업데이트: writeQuery, writeFragment, readQuery, readFragment를 사용하여 캐시를 수동으로 읽고 쓸 수 있습니다.
- 캐시 무효화: cache.evict나 cache.reset을 사용하여 특정 데이터를 캐시에서 제거하거나 전체 캐시를 무효화할 수 있습니다.
2. Relay의 캐시 구조
Relay는 캐시 시스템이 Apollo보다 더 복잡하고 최적화된 방식으로 동작합니다. Relay는 대규모 애플리케이션에서 성능을 극대화하기 위해 설계되었으며, 데이터를 효율적으로 가져오기 위해 세밀한 캐싱을 사용합니다.
캐시 저장 방식
- Record-based Storage: Relay의 캐시는 레코드 기반 저장 방식을 사용합니다. 이는 GraphQL 쿼리의 결과를 "레코드"로 분할하고, 각 레코드를 고유한 ID로 식별하여 저장합니다. 이러한 구조는 Apollo의 정규화된 캐싱 방식과 유사하지만, Relay는 이를 더 세밀하게 관리합니다.
- ID와 Type을 기반으로 한 캐시 키: Relay는 각 레코드에 고유한 ID를 부여하고, 이 ID를 기반으로 데이터를 저장합니다. 또한 GraphQL 스키마의 타입 정보를 기반으로 데이터를 정규화합니다. 예를 들어, User라는 타입을 가진 엔티티의 ID가 1이라면, 해당 엔티티는 User:1이라는 캐시 키를 가집니다.
Fragment 기반 데이터 관리
- Fragment를 통한 데이터 분할: Relay의 핵심 개념 중 하나는 "쿼리 프래그먼트(fragment)"입니다. 각 컴포넌트는 자신이 필요한 데이터만 요청하는 프래그먼트를 정의하며, Relay는 이러한 프래그먼트를 기반으로 필요한 데이터만 쿼리하고 캐시합니다. 이는 컴포넌트 단위로 데이터를 효율적으로 관리할 수 있도록 도와줍니다.
- 캐시 데이터의 구조: Relay에서 캐시된 데이터는 RecordSource에 저장되며, 각 레코드는 고유한 ID를 기준으로 저장됩니다. 예를 들어, 다음과 같은 쿼리가 있다고 가정할 때:
query GetUsers {
users {
id
name
email
}
}
- 이 쿼리의 결과가 Relay의 캐시에 저장될 때는 각 User 레코드가 고유한 키(예: User:1, User:2)로 저장됩니다.
- Relay의 데이터는 RecordSource에서 저장된 레코드의 집합으로 관리됩니다. 각 레코드는 해당 데이터를 포함하며, Relay는 이를 기반으로 캐시를 관리합니다.
캐시 관리
- 자동 캐싱 및 업데이트: Relay는 쿼리 결과를 자동으로 캐싱하고, 데이터 변경이 발생하면 캐시를 자동으로 업데이트합니다.
- Garbage Collection (GC): Relay는 가비지 컬렉션 시스템을 통해 더 이상 참조되지 않는 데이터를 캐시에서 제거합니다. 이는 메모리 사용량을 최적화하는 데 중요한 역할을 합니다.
- Store와 RecordSource: Relay의 캐시는 Store와 RecordSource라는 두 가지 주요 요소로 관리됩니다. Store는 현재 사용 중인 캐시 데이터를 관리하고, RecordSource는 데이터를 저장하는 저장소 역할을 합니다.
비교 요약
특징 | Apollo Client | Relay |
캐시 구조 | 정규화된 객체 기반 캐시 (InMemoryCache) | 레코드 기반 캐시 (RecordSource) |
캐시 키 | __typename + id | ID + Type 기반 (예: User:1) |
캐시 방식 | 쿼리의 결과를 정규화하여 저장 | 프래그먼트 기반으로 세밀하게 데이터를 관리 |
캐시 데이터 관리 | writeQuery, readQuery로 수동 관리 가능 | 자동화된 캐시 관리 및 가비지 컬렉션 지원 |
캐시 최적화 | 간단한 캐싱, 수동 업데이트 가능 | 고급 최적화 및 가비지 컬렉션으로 효율적인 관리 |
캐시 데이터를 가져오는 것 비교
1. Apollo Client - 캐시 데이터 가져오기
Apollo Client는 기본적으로 InMemoryCache를 사용하여 데이터를 캐싱합니다. 캐시 데이터를 명시적으로 가져오거나 업데이트할 수 있습니다. 다음은 Apollo Client에서 캐시 데이터를 사용하는 예시입니다.
캐시 데이터를 가져오는 코드 예시
import { ApolloClient, InMemoryCache, ApolloProvider, gql, useQuery } from '@apollo/client';
import React from 'react';
// Apollo Client 설정
const client = new ApolloClient({
uri: 'https://your-graphql-endpoint.com/graphql',
cache: new InMemoryCache(),
});
// GraphQL 쿼리 정의
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`;
// 캐시에서 데이터 가져오기
function Users() {
const { loading, error, data } = useQuery(GET_USERS);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<h3>User List:</h3>
<ul>
{data.users.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
</div>
);
}
// 캐시에서 데이터 수동으로 가져오기
function fetchUsersFromCache() {
const cachedData = client.cache.readQuery({ query: GET_USERS });
if (cachedData) {
console.log('Cache Data:', cachedData);
} else {
console.log('No data in cache');
}
}
function App() {
// 수동으로 캐시 데이터 가져오기 실행
React.useEffect(() => {
fetchUsersFromCache();
}, []);
return (
<ApolloProvider client={client}>
<Users />
</ApolloProvider>
);
}
export default App;
설명:
- useQuery 훅을 사용하면 Apollo Client는 기본적으로 쿼리 데이터를 캐시합니다.
- client.cache.readQuery 메서드를 사용하여 수동으로 캐시된 데이터를 가져올 수 있습니다.
- 캐시에 저장된 데이터를 먼저 가져온 후 네트워크 요청을 할지, 또는 네트워크 요청이 실패했을 때 캐시를 사용할지 선택할 수 있습니다.
2. Relay - 캐시 데이터 가져오기
Relay는 더 복잡한 캐싱 메커니즘을 가지고 있으며, 자동으로 필요한 데이터를 관리합니다. Relay에서는 store를 사용하여 캐시된 데이터를 가져올 수 있습니다.
캐시 데이터를 가져오는 코드 예시
Relay에서는 기본적으로 컴포넌트에서 필요한 데이터가 캐시될 경우, 다시 요청하지 않고 캐시된 데이터를 자동으로 반환합니다. Relay의 캐시 데이터는 store를 통해 관리됩니다.
import { Environment, Network, RecordSource, Store } from 'relay-runtime';
import { RelayEnvironmentProvider, useLazyLoadQuery, graphql } from 'react-relay';
import React from 'react';
// Relay 환경 설정
const store = new Store(new RecordSource());
const environment = new Environment({
network: Network.create((operation, variables) =>
fetch('https://your-graphql-endpoint.com/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: operation.text,
variables,
}),
}).then(response => response.json())
),
store,
});
// GraphQL 쿼리 정의
const UsersQuery = graphql`
query UsersQuery {
users {
id
name
email
}
}
`;
// 캐시에서 데이터 가져오기
function Users() {
const data = useLazyLoadQuery(UsersQuery, {});
return (
<div>
<h3>User List:</h3>
<ul>
{data.users.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
</div>
);
}
// 수동으로 캐시 데이터 확인하기
function fetchUsersFromCache() {
const cachedRecord = store.getSource().get('ROOT_QUERY');
if (cachedRecord) {
console.log('Cache Data:', cachedRecord);
} else {
console.log('No data in cache');
}
}
function App() {
// 캐시 데이터를 수동으로 가져오기 실행
React.useEffect(() => {
fetchUsersFromCache();
}, []);
return (
<RelayEnvironmentProvider environment={environment}>
<Users />
</RelayEnvironmentProvider>
);
}
export default App;
설명:
- Relay는 store와 RecordSource를 사용해 캐시된 데이터를 관리합니다.
- store.getSource().get('ROOT_QUERY')를 사용해 캐시된 데이터를 직접 가져올 수 있습니다.
- 기본적으로 Relay는 필요한 경우 캐시된 데이터를 자동으로 반환하지만, 개발자가 명시적으로 캐시 데이터를 읽고자 할 경우 위와 같이 할 수 있습니다.
요약:
- Apollo Client는 client.cache.readQuery 메서드를 사용하여 명시적으로 캐시된 데이터를 읽을 수 있으며, useQuery 훅에서 캐시를 자동으로 사용합니다.
- Relay는 store와 RecordSource를 사용해 캐시 데이터를 관리하며, Relay는 기본적으로 데이터를 자동으로 캐시하고 필요한 경우 캐시된 데이터를 제공합니다.
'프론트엔드 개발 > GraphQL' 카테고리의 다른 글
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 graphql 스키마와 리졸버에 대해 알아보자. 사용법 (0) | 2023.02.12 |
Apollo 오디세이(ODYSSEY) graphql 강의 (0) | 2023.02.11 |