GraphQL Apollo Server/Client Cheat Sheet

    0. 이 문서를 정리한 이유, Relay 와 비교 인사이트 

    전 회사에서 graphql 쓰긴 했는데 Relay(Client)를 썼었고, 서버에선 apollo 썼었음

    이번에 공부하면서 Apollo 와 Relay 를 비교해볼 수 있었는데,

    거의 비슷하긴 하다만 달랐던 것은 1) 데이터 선언 방식 2) 컴파일러 유무 3) 유연성 이렇게 세가지인 것 같다. 

     

    Apollo는 Relay에 비해 useQuery 를 이용해서 자유롭게 어디서든 쿼리 작성 가능하다. 하지만 Relay 는 컴포넌트에 fragment 강제 colocate. Apollo 는 런타임에 gql이 문자열 파싱하지만 Relay 는 빌드타임에 컴파일러가 정적 분석한다. (즉 Apollo는 서버 요청 날려봐야 에러 알 수 있어서 안정성 떨어짐 .... ) codegen을 돌리면 타입은 생성되지만, 쿼리 자체의 유효성 검증은 여전히 런타임이라는 한계가 있음. Apollo Client에서 @graphql-codegen/client-preset을 쓰면 codegen 시점에 스키마와 비교해서 유효하지 않은 필드를 잡아주긴 하는데, Relay 컴파일러만큼 강제성은 없다. 

     

    위의 설명들에서 대충 느껴지지만 Apollo 는 유연함 우선이고, 규칙 없이 자유롭게 쓸 수 있지만 그만큼 개발자가 신경 써야 할 게 많아보이고, Relay는 엄격한 구조를 강제해서 자유도가 낮은 대신 대규모 앱에서 안전하고 예측 가능해보인다. 

     

    아 그리고 서치하다가 발견한 것인데 Apollo Client는 SSR 서비스라면 훨씬 유리한 것 같다. ssrMode 옵션과 getDataFromTree 유틸리티를 제공하고, Next.js와의 통합 가이드도 공식 문서에 명시되어 있어서 별도 설정 없이 바로 사용할 수 있다.

    Relay는 공식 문서(relay.dev)에 SSR 전용 가이드가 없다. 사용 자체가 불가능한 건 아니지만 직접 fetchQuery로 서버에서 데이터를 먼저 가져오고, Relay 스토어를 직렬화해서 클라이언트에 전달하는 과정을 수동으로 구현해야 한다. WunderGraph 같은 서드파티 라이브러리가 이 과정을 대신 처리해주는 솔루션을 제공하기도 한다. 그래서 SEO 가 중요한 서비스에서 graphql 클라이언트 라이브러리 고민할 땐 apollo 가 유리할 것으로 보인다. 

     

    그 외에 Apollo 튜토리얼 보면서 코드 치면서 포인트 될만한 것들만 정리 ~~

     

    1. 전체 구조

    GraphQL 서버는 세 가지 핵심 요소로 구성된다.

    • 스키마  어떤 데이터를 요청할 수 있는지 정의
    • 리졸버  각 필드의 데이터를 어떻게 가져올지 구현
    • 데이터소스  실제 REST API나 DB에 접근하는 레이어
     
    클라이언트 → Apollo Server → 리졸버 → 데이터소스 → REST API / DB

    2. 스키마 (schema.graphql)

    GraphQL의 단일 진실 공급원(source of truth). 모든 타입과 필드를 여기서 정의한다.

    type Query {
      featuredListings: [Listing!]!
      listing(id: ID!): Listing
    }
    
    type Mutation {
      createListing(input: CreateListingInput!): CreateListingResponse!
    }
    
    type Listing {
      id: ID!
      title: String!
      description: String!
      numberOfViews: Int
      amenities: [Amenity!]!
    }
    
    type Amenity {
      id: ID!
      name: String!
      category: String!
    }
    
    input CreateListingInput {
      title: String!
      description: String!
      numOfBeds: Int!
      costPerNight: Float!
      amenities: [ID!]!
    }
    
    type CreateListingResponse {
      code: Int!
      success: Boolean!
      message: String!
      listing: Listing  # 실패 가능성 있으므로 nullable
    }

     

    • !  non-nullable
    • [Listing!]!  배열 자체도, 배열 안 요소도 null 불가
    • 뮤테이션 응답은 code, success, message, 수정된 객체 패턴을 쓰는 게 관례
    • input 타입은 여러 인자를 하나로 묶을 때 사용. 필드는 스칼라/enum/다른 input 타입만 가능

    3. 데이터소스 (RESTDataSource)

    Apollo의 RESTDataSource를 상속해서 REST API 호출을 캡슐화한다.

    import { RESTDataSource } from "@apollo/datasource-rest";
    import { Listing, Amenity, CreateListingInput } from "../types";
    
    export class ListingAPI extends RESTDataSource {
      baseURL = "https://api.example.com/";
    
      getFeaturedListings(): Promise<Listing[]> {
        return this.get<Listing[]>("featured-listings");
      }
    
      getListing(listingId: string): Promise<Listing> {
        return this.get<Listing>(`listings/${listingId}`);
      }
    
      getAmenities(listingId: string): Promise<Amenity[]> {
        return this.get<Amenity[]>(`listings/${listingId}/amenities`);
      }
    
      createListing(listing: CreateListingInput): Promise<Listing> {
        return this.post<Listing>("listings", { body: { listing } });
      }
    }

    4. 리졸버 (resolvers.ts)

    리졸버 함수는 파라미터 4개를 받는다.


    parent 상위 리졸버가 반환한 값
    args 쿼리에서 전달된 인자 { id, input… }
    contextValue 모든 리졸버가 공유하는 객체 (dataSources 등)
    info 실행 상태 정보 (거의 안 씀)
     
    import { Resolvers } from "./types";
    import { validateFullAmenities } from "./helpers";
    
    export const resolvers: Resolvers = {
      Query: {
        featuredListings: (_, __, { dataSources }) => {
          return dataSources.listingAPI.getFeaturedListings();
        },
        listing: (_, { id }, { dataSources }) => {
          return dataSources.listingAPI.getListing(id);
        },
      },
    
      // Query 타입 외에도 어떤 타입의 필드든 리졸버 정의 가능
      Listing: {
        amenities: ({ id, amenities }, _, { dataSources }) => {
          return validateFullAmenities(amenities)
            ? amenities
            : dataSources.listingAPI.getAmenities(id);
        },
      },
    
      Mutation: {
        createListing: async (_, { input }, { dataSources }) => {
          try {
            const response = await dataSources.listingAPI.createListing(input);
            return { code: 200, success: true, message: "Created!", listing: response };
          } catch (err) {
            return { code: 500, success: false, message: `Error: ${err.message}`, listing: null };
          }
        },
      },
    };

    5. 리졸버 체인

    리졸버는 순서대로 연쇄 실행된다. 각 리졸버는 반환값을 parent로 다음 리졸버에 전달한다.

    Query.featuredListings() → Listing[]
      └── Listing.amenities() → parent = Listing 객체
            └── Amenity.name() → parent = Amenity 객체

     

    쿼리에 amenities 필드가 없으면 Listing.amenities() 리졸버는 아예 호출되지 않는다. 이 덕분에 필요할 때만 추가 API 요청이 발생한다.


    6. 서버 설정 (index.ts)

    import { ApolloServer } from "@apollo/server";
    import { startStandaloneServer } from "@apollo/server/standalone";
    import { typeDefs } from "./schema";
    import { resolvers } from "./resolvers";
    import { ListingAPI } from "./datasources/listing-api";
    
    const server = new ApolloServer({ typeDefs, resolvers });
    
    const { url } = await startStandaloneServer(server, {
      context: async () => {
        const { cache } = server;
        return {
          dataSources: {
            listingAPI: new ListingAPI({ cache }),
          },
        };
      },
    });

     

    context 함수의 반환값이 모든 리졸버의 세 번째 파라미터 contextValue가 된다.


    7. GraphQL Codegen

    스키마에서 TypeScript 타입을 자동 생성해주는 도구. 스키마가 바뀌면 타입도 자동으로 따라온다.

    // codegen.ts
    import type { CodegenConfig } from "@graphql-codegen/cli";
    
    const config: CodegenConfig = {
      schema: "./src/schema.graphql",
      generates: {
        "./src/types.ts": {
          plugins: ["typescript", "typescript-resolvers"],
          config: {
            contextType: "./context#DataSourceContext",
          },
        },
      },
    };
    
    export default config;

     

    contextType: "./context#DataSourceContext"  import { DataSourceContext } from "./context"; 랑 같은 의미인데 codegen이 설정 파일(JSON/TS 객체)에서 타입을 지정해야 하다 보니, import 구문을 쓸 수 없어서 파일경로#타입이름 형태로 표기. codegen만의 컨벤션이라고 보면 됨

     

    // context.ts
    import { ListingAPI } from "./datasources/listing-api";
    
    export type DataSourceContext = {
      dataSources: {
        listingAPI: ListingAPI;
      };
    };

     

    contextType을 지정하면 Resolvers 타입의 기본값이 DataSourceContext로 설정되어, 모든 리졸버에서 dataSources 타입이 자동으로 추론된다.


    8. Apollo Client

    // Apollo Client 초기화
    const client = new ApolloClient({
      uri: "http://localhost:4000/graphql",
      cache: new InMemoryCache(),
    });

     

    useQuery

    const GET_TRACK = gql(`
      query GetTrack($trackId: ID!) {
        track(id: $trackId) {
          id
          title
          numberOfViews
        }
      }
    `);
    
    const { data, loading, error } = useQuery(GET_TRACK, {
      variables: { trackId },
      fetchPolicy: "cache-and-network",
    });

     

    useMutation

    const INCREMENT_VIEWS = gql(`...`); // 컴포넌트 밖에 선언
    
    const [incrementViews, { data, loading, error }] = useMutation(INCREMENT_VIEWS, {
      variables: { id },
      onCompleted: (data) => console.log(data),
    });
    
    // 직접 호출해야 실행됨
    <button onClick={() => incrementViews()}>조회수 증가</button>

     

      useQuery useMutation
    실행 시점 마운트 시 자동 직접 호출 시
    반환 { data, loading, error } [mutateFn, { data, loading, error }]
    캐시 자동 읽기 응답의 id로 자동 업데이트

    9. Apollo Client 캐시

    Apollo Client는 id + __typename을 키로 응답을 정규화해서 저장한다.

    뮤테이션 응답에 id와 __typename이 있으면, 캐시에 있는 같은 객체를 자동으로 업데이트하고 UI도 자동으로 리렌더링된다.

     

    흐름 정리

    카드 클릭 (onClick)
    → incrementViews() 호출
    → Apollo Client → fetch() Web API에 위임 → Call Stack에서 빠짐
    → 페이지 이동 + 캐시에서 기존 데이터 렌더링 (numberOfViews: 0)
    → fetch 응답 도착 → Promise 큐 → Call Stack 비면 실행
    → Apollo Client 캐시 업데이트 → UI 자동 리렌더 (numberOfViews: 1)

    10. gql 선언 위치

    // ✅ 컴포넌트 밖 — 모듈 로드 시 한 번만 파싱
    const GET_USER = gql(`...`);
    
    const UserCard = () => {
      const { data } = useQuery(GET_USER);
    };
    
    // ❌ 컴포넌트 안 — 렌더링마다 새로 파싱, 캐시 오동작 가능
    const UserCard = () => {
      const GET_USER = gql(`...`); // 매 렌더마다 실행
    };

     

    컴포넌트 안에 선언했다가 캐시 업데이트 안되서 디버깅 하고 알게됨 .. ㅠ 바보


    11. REST API vs GraphQL 아키텍처

    기존 REST 위에 GraphQL 얹기 (BFF 패턴)

    클라이언트 → GraphQL 서버 → 기존 REST API → DB

     

    여러 클라이언트가 같은 REST API를 다르게 소비할 때 유용. GraphQL이 클라이언트별 데이터 조합을 담당한다.

     

    GraphQL을 메인 API로 처음부터 사용

    클라이언트 → GraphQL 서버(리졸버) → DB

     

    리졸버가 REST를 거치지 않고 DB에 직접 접근해서 쓸 수도 있음! 몽고 디비 연결해서 해봄 


    13. 실시간 처리

      GraphQL Subscription React Query polling
    방향 서버 → 클라이언트 푸시 클라이언트 → 서버
    연결 WebSocket (지속 연결) HTTP (매 요청마다 새 연결)
    지연 거의 즉시 polling 간격만큼 지연
    적합 위치/상태 실시간 CRUD, 주기적 동기화

    실시간성이 중요한 도메인에서는 보통 둘을 함께 사용한다.

    정적 데이터 조회는 Query, 실시간 스트림은 Subscription(또는 별도 WebSocket)으로 분리하는 게 일반적이다.

    댓글