일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
- Typescript
- 비동기 처리
- Webpack
- 스코프체이닝
- 마이크로 프론트엔드
- Apollo Server
- NextJS
- 세션스토리지
- Architecture
- 실행 컨텍스트
- Apollo Client
- Spa
- 무한 스크롤
- SAGA 패턴
- redux saga
- 식별자 결정
- useRef
- 변수 섀도잉
- 타입스크립트
- restore scroll position
- task-definition
- Babel
- SSR
- ESLint
- graphql
- CSR
- Next.js
- nvm
- scrollTo 안됨
- 환경 레코드
- Today
- Total
minguri brain is busy
Apollo Client에서 GraphQL의 객체 결과가 섞이는 오류(feat. Apollo 캐시) 본문
문제 상황
gql.ts
query user {
user {
code
status
data {
userId
...
children {
id
...
tendency {
id
keyword
}
interest {
id
keyword
}
}
}
}
}
이러한 gql 쿼리문이 있다고 했을 때 같은 Keyword 타입을 사용하는 tendency와 interest의 배열이 섞이는 상황이 발생했다.
순서만 섞이는 것이 아닌 규칙이 없이 섞였고, apollo graphql API sandbox와 네트워크 상에서는 정상적으로 불러와지나 프론트 ui상에서만 섞여서 노출되어서 더욱 혼란스러웠다. (뒤에서 살펴보겠지만 사실 규칙이 없는 것이 아니었다!)
해결
결론을 우선 말하자면 Apollo Client 캐싱 관련한 오류였다. GraphQL의 __typename @skip(if: true) 지시문을 사용하여 Apollo가 기본적으로 캐시 하는 필드를 명시적으로 제외할 수 있다.
gql.ts
query user {
user {
code
status
data {
userId
...
children {
id
...
tendency {
id
__typename @skip(if: true)
keyword
}
interest {
id
__typename @skip(if: true)
keyword
}
}
}
}
}
그렇다면 왜 그런 오류가 생겼는지 Apollo 캐시에 대해 자세히 알아보자.
Apollo Client에서 캐시
기본적으로 Apollo는 인메모리 캐시를 사용한다. Apollo가 GraphQL 객체는 응답에 id 또는 _id 속성과 __typename속성이 포함 되어있는지 여부에 따라 캐시를 할지 말지 결정한다.
예를 들어 Product GraphQL schema를 선언한다고 해보자:
type Product {
sku: ID!
title: String!
quantity: Int!
}
Product는 식별자를 갖고 있고 sku필드에 저장한다.
그런다음 다음과 같은 쿼리를 작성했다:
{
product(sku: "abc123") {
sku
title
quantity
}
}
sku별로 product의 sku, title, quantity 를 가져오는 간단한 쿼리이다.
Apollo clinet를 통한 응답은 다음과 같다:
{
data: {
product: {
sku: "abc123",
title: "My cool product",
quantity: 4,
__typename: "Product"
}
}
}
이때 Apollo가 SKU 별로 Product 객체를 캐시 할 것이라고 생각할 수 있지만, 캐시 하지 않는다. __typename속성만 있고 id 또는 _id 속성이 없기 때문이다.
해결하는 방법:
1. 스키마를 변경한다.
2. Apollo의 캐싱 구성에서 dataIdFromObject 옵션을 사용한다.
3. id가 될 SKU에 Alias를 준다.
4. sku를 유지하면서 _id로 SKU Alias를 준다.
2번 dataIdFromObject 옵션을 사용한 방법을 살펴보자. 이 방법은 클라이언트 쪽 cofiguration을 바꿀 때 사용하면 좋다:
import { InMemoryCache, defaultDataIdFromObject } from 'apollo-cache-inmemory';
const cache = new InMemoryCache({
dataIdFromObject: object => {
switch (object.__typename) {
case 'Product': return object.sku; // Product쿼리일 경우 'sku'를 primary key로 사용
default: return defaultDataIdFromObject(object); // default 처리
}
}
});
configuration을 바꾸고 싶지 않다면 3,4번의 alias를 추가하는 방법으로 객체의 ID가 무엇인지 명시해줄 수 있다. 3번보다는 GraphQL을 유지한 채로 호출자가 응답을 처리할 수 있기 때문에 추가 _id필드를 추가하는 것이 더 간단해보인다.
쿼리를 업데이트 해보자:
{
product(sku: "abc123") {
sku
title
quantity
_id: sku
}
}
응답은 다음과 같다:
{
data: {
product: {
sku: "abc123",
title: "My cool product",
quantity: 4,
_id: "abc123",
__typename: "Product"
}
}
}
이제 Apollo는 이 ID로 Product를 캐시 한다!
멱등성이 없는 객체 캐싱
실제 작업을 하다 보면 더 많은 데이터가 있으므로 GraphQL 스키마가 더 복잡해진다. 그에 따라 애플리케이션이 많은 id속성을 가질 수 있어서 주의하지 않으면 오히려 불리하게 작용할 수도 있다.
예를 들어 보자. product는 다수의 색깔을 가질 수 있고, 각 product별로 primary 색깔을 표시할 수 있다고 하자. 그 primary 색깔은 상품이 사이트에 보일 때 디폴트로 먼저 나온다. 이 색깔의 이름은 static하다.
스키마는 다음과 같다:
type Color {
id: Int!
name: String!
is_primary: Boolean!
}
GraphQL 쿼리는 id를 지정하고 여러 개의 product를 가져올 수 있도록 다음과 같이 작성했다:
{
products(skus: ["abc123", "xyz890"]) {
_id: sku
sku
title
quantity
colors {
id
is_primary
name
}
}
}
응답:
{
data: {
products: [
{
sku: "abc123",
title: "My cool product",
quantity: 4,
colors: [
{ id: 1, name: "Red", is_primary: true, __typename: "Color" },
{ id: 2, name: "Blue", is_primary: false, __typename: "Color" }
],
_id: "abc123",
__typename: "Product"
},
{
sku: "xyz890",
title: "Another cool product",
quantity: 0,
colors: [
{ id: 1, name: "Red", is_primary: false, __typename: "Color" },
{ id: 3, name: "Yellow", is_primary: true, __typename: "Color" }
],
_id: "xyz890",
__typename: "Product"
}
]
}
}
여기서 Apllo가 캐시 할 조건에 대해 생각해보자. Apollo가 어떤 순서로 처리할까?
1. products[0]을 처리한다.
2. Product의 ID가 abc123인 상품을 캐시한다.(_id 필드 캐시)
3. products[0].colors[0]을 처리한다.
4. Color의 ID가 1인 색상 Red를 캐시한다.(id 필드 캐시)
4번에서 ID로 Red 색상을 캐시했다. 그렇다면 이것이 Apollo가 다음 product에 어떤 영향을 미칠까?
1. product[1].colors[0]를 처리한다.
2. ID가 1인 색상 Red가 캐시에 있다. 해당 객체를 재사용한다.
실제 응답을 보자:
Apollo가 Red의 캐시를 사용했고, product[1]은 primary 색깔이 제대로 들어오지 않게 된다.
내가 마주했던 오류가 바로 이 원인에서 비롯된 것이다!
근본 원인
Apollo는 엔터티가 동일한 ID를 가진 쿼리에서 멱등성이 있다고 가정한다. 우리의 예시 상황에서 다른 product가 동일한 clolr에 대해 다른 is_primary를 가질 수 있기 때문에 이것은 사실이 아니다.
해결 방법
방법 1. 스키마 수정
이 문제를 해결하는 올바른 방법은 스키마를 변경하여 Product에 primary_color필드를 추가하는 것이다.
type Product {
sku: ID!
title: String!
quantity: Int!
colors: [Color]!
primary_color: Color
}
type Color {
id: Int!
name: String!
}
방법 2. id 말고 다른 ID alias 주기
{
products(skus: ["abc123", "xyz890") {
_id: sku
sku
title
quantity
colors {
color_id: id
is_primary
name
}
}
}
_id나 id 필드가 없으므로 더 이상 Color 객체에 따른 캐시 트리거가 되지 않는다.
이 방법의 단점은 추후에 나 또는 다른 개발자가 무심코 id를 추가할 경우 동일한 실수를 깨닫지 못할 수도 있다.
방법 3. __typename 필드 제외
Apollo에서 기본적으로 추가하는 __typename 필드를 명시적으로 제외시키는 방법이다:
{
products(skus: ["abc123", "xyz890"]) {
_id: sku
sku
title
quantity
colors {
color_id: id
is_primary
name
# Color는 Apollo 캐시를 사용하지 않음
__typename @skip(if: true)
}
}
}
GraphQL의 @skip 지시문을 사용하여 Color에 대한 결과를 캐시하지 않는다는 것을 명시적으로 나타낼 수 있다.
참고로, addTypename: false 옵션을 사용하여 __typename 자동 추가를 비활성화할 수 있다.
그러나 전체 캐싱을 비활성화하고 싶다면 매우 큰 쿼리 배치와 응답이므로 비용이 많이 들기 때문에 no-cache fetch policy를 사용하는 것이 더 나을 것이다.
'FE > 오류해결' 카테고리의 다른 글
last-child가 동작하지 않을 때 해결방법 (0) | 2023.04.14 |
---|---|
다른 서브도메인으로 쿠키 값 전달안되는 오류 해결 (1) | 2022.12.23 |
window.addEventListener가 동작 안될 때 해결방법 (0) | 2022.12.23 |