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)으로 분리하는 게 일반적이다.
'Frontend' 카테고리의 다른 글
| 모두를 위한 웹을 만든다는 것, Accessibility (0) | 2026.05.13 |
|---|---|
| 메모이제이션(Memoization)의 원리와 함수 구현해보기, 적용 (2) | 2025.08.30 |
| CursorAI + MCP 로 생산성 100배 부스트하기 (Sequential Thinking과 TalkToFigma) (0) | 2025.04.07 |
| Cursor AI 등 .. 코딩할 때 AI 툴을 사용하며 느낀 개인적인 생각, 부작용 (0) | 2025.04.06 |
| Firebase 보안규칙 수정 : 파이어베이스에 안전하지 않은 규칙이 있습니다 메일 올 경우 해결 방법 (0) | 2023.02.12 |
댓글