Apollo Server를 사용하여 GraphQL BFF 구축
GraphQL BFF와 Apollo Server를 사용하여 프론트엔드 API 최적화
프론트엔드를 위한 백엔드(BFF) 패턴을 GraphQL과 Apollo Server와 결합하면 현대 웹 애플리케이션에 강력한 아키텍처를 구축할 수 있습니다.
이 접근법은 여러 소스에서 데이터를 집계하면서도 관심사의 명확한 분리가 유지되는 클라이언트 최적화된 API를 구축할 수 있게 해줍니다.

BFF 패턴 이해하기
BFF 패턴은 다양한 데이터 요구사항을 가진 여러 프론트엔드 애플리케이션(웹, 모바일, 데스크탑)을 지원하는 데 있어 나타나는 문제에 대한 해결책으로 등장했습니다. 모든 클라이언트가 단일 일반적인 API를 사용하도록 강요하는 대신, BFF는 각 클라이언트의 특정 요구사항에 맞춘 전용 백엔드 서비스를 생성합니다.
BFF의 주요 이점
- 클라이언트 최적화된 API: 각 프론트엔드는 필요한 데이터와 기대하는 형식으로 정확히 데이터를 받습니다.
- 클라이언트 복잡성 감소: 데이터 집계 및 변환 로직은 백엔드로 이동합니다.
- 독립적인 진화: 프론트엔드는 다른 클라이언트나 핵심 서비스에 영향을 주지 않고 진화할 수 있습니다.
- 더 나은 성능: 더 적은 라운드트립과 더 작은 페이로드가 애플리케이션 속도를 향상시킵니다.
- 팀 자율성: 프론트엔드 팀은 자신의 BFF를 소유하여 더 빠른 반복이 가능합니다.
GraphQL이 BFF에 완벽하게 맞는 이유
GraphQL의 유연한 쿼리 언어는 BFF 구현에 이상적입니다:
- 정확한 데이터 가져오기: 클라이언트는 필요한 필드만 요청합니다.
- 단일 요청: 여러 소스의 데이터를 하나의 쿼리로 결합할 수 있습니다.
- 강력한 타이핑: 스키마는 프론트엔드와 백엔드 간의 명확한 계약을 제공합니다.
- 실시간 기능: 구독을 통해 라이브 데이터 업데이트가 가능합니다.
- 개발자 경험: 인트로스펙션과 GraphQL 플레이그라운드는 개발을 간소화합니다.
BFF를 위한 Apollo Server 설정
Apollo Server는 GraphQL BFF 계층을 구축하는 데 견고한 기반을 제공합니다. 생산용 구현을 만드는 과정을 살펴보겠습니다.
설치 및 기본 설정
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { buildSubgraphSchema } from '@apollo/subgraph';
import { gql } from 'graphql-tag';
// GraphQL 스키마 정의
const typeDefs = gql`
type User {
id: ID!
email: String!
name: String!
orders: [Order!]!
}
type Order {
id: ID!
status: String!
total: Float!
items: [OrderItem!]!
}
type OrderItem {
id: ID!
productId: ID!
quantity: Int!
price: Float!
}
type Query {
me: User
user(id: ID!): User
orders(userId: ID!): [Order!]!
}
`;
// 리졸버 구현
const resolvers = {
Query: {
me: async (_, __, { dataSources, user }) => {
return dataSources.userAPI.getUser(user.id);
},
user: async (_, { id }, { dataSources }) => {
return dataSources.userAPI.getUser(id);
},
orders: async (_, { userId }, { dataSources }) => {
return dataSources.orderAPI.getOrdersByUser(userId);
},
},
User: {
orders: async (parent, _, { dataSources }) => {
return dataSources.orderAPI.getOrdersByUser(parent.id);
},
},
};
// Apollo Server 인스턴스 생성
const server = new ApolloServer({
typeDefs,
resolvers,
});
// 서버 시작
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => ({
token: req.headers.authorization,
dataSources: {
userAPI: new UserAPI(),
orderAPI: new OrderAPI(),
},
}),
listen: { port: 4000 },
});
console.log(`🚀 서버가 ${url}에서 실행 중입니다.`);
데이터 소스 구현
데이터 소스는 다양한 백엔드에서 데이터를 가져오는 데 깔끔한 추상화를 제공합니다:
import { RESTDataSource } from '@apollo/datasource-rest';
class UserAPI extends RESTDataSource {
override baseURL = 'https://api.example.com/users/';
async getUser(id: string) {
return this.get(`${id}`);
}
async getUsersByIds(ids: string[]) {
return Promise.all(ids.map(id => this.getUser(id)));
}
async updateUser(id: string, data: any) {
return this.patch(`${id}`, { body: data });
}
}
class OrderAPI extends RESTDataSource {
override baseURL = 'https://api.example.com/orders/';
async getOrdersByUser(userId: string) {
return this.get('', {
params: { userId },
});
}
async getOrder(id: string) {
return this.get(`${id}`);
}
}
DataLoader로 최적화
DataLoader는 N+1 쿼리 문제를 방지하기 위해 요청을 배치하고 캐시합니다:
import DataLoader from 'dataloader';
const createUserLoader = (userAPI: UserAPI) =>
new DataLoader(async (ids: readonly string[]) => {
const users = await userAPI.getUsersByIds([...ids]);
return ids.map(id => users.find(user => user.id === id));
});
// 컨텍스트에서 사용
const context = async ({ req }) => ({
dataSources: {
userAPI: new UserAPI(),
orderAPI: new OrderAPI(),
},
loaders: {
userLoader: createUserLoader(new UserAPI()),
},
});
고급 BFF 패턴
여러 서비스 집계
BFF의 핵심 강점 중 하나는 여러 백엔드 서비스에서 데이터를 결합하는 것입니다:
const resolvers = {
Query: {
dashboard: async (_, __, { dataSources, user }) => {
// 여러 서비스에서 데이터를 병렬로 가져옵니다.
const [userData, orders, recommendations, analytics] = await Promise.all([
dataSources.userAPI.getUser(user.id),
dataSources.orderAPI.getRecentOrders(user.id),
dataSources.recommendationAPI.getRecommendations(user.id),
dataSources.analyticsAPI.getUserStats(user.id),
]);
return {
user: userData,
recentOrders: orders,
recommendations,
stats: analytics,
};
},
},
};
오류 처리 및 회복성
생산용 BFF에 견고한 오류 처리를 구현합니다:
import { GraphQLError } from 'graphql';
const resolvers = {
Query: {
user: async (_, { id }, { dataSources }) => {
try {
const user = await dataSources.userAPI.getUser(id);
if (!user) {
throw new GraphQLError('사용자를 찾을 수 없습니다', {
extensions: {
code: 'NOT_FOUND',
http: { status: 404 },
},
});
}
return user;
} catch (error) {
if (error instanceof GraphQLError) throw error;
throw new GraphQLError('사용자 가져오기 실패', {
extensions: {
code: 'INTERNAL_SERVER_ERROR',
originalError: error.message,
},
});
}
},
},
};
캐싱 전략
성능 향상을 위해 효율적인 캐싱을 구현합니다:
import { KeyValueCache } from '@apollo/utils.keyvaluecache';
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';
const server = new ApolloServer({
typeDefs,
resolvers,
cache: new InMemoryLRUCache({
maxSize: Math.pow(2, 20) * 100, // 100 MB
ttl: 300, // 5분
}),
plugins: [
{
async requestDidStart() {
return {
async willSendResponse({ response, contextValue }) {
// 캐시 제어 헤더 설정
response.http.headers.set(
'Cache-Control',
'max-age=300, public'
);
},
};
},
},
],
});
인증 및 권한 부여
BFF를 적절한 인증 및 권한 부여로 보호합니다:
import jwt from 'jsonwebtoken';
const context = async ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return { user: null };
}
try {
const user = jwt.verify(token, process.env.JWT_SECRET);
return { user };
} catch (error) {
throw new GraphQLError('잘못된 인증 토큰', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
};
// 리졸버 보호
const resolvers = {
Query: {
me: (_, __, { user }) => {
if (!user) {
throw new GraphQLError('로그인해야 합니다', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return user;
},
},
};
마이크로서비스를 위한 Apollo Federation
여러 팀과 서비스와 함께 작업할 때 Apollo Federation은 분산된 GraphQL 아키텍처를 가능하게 합니다:
import { buildSubgraphSchema } from '@apollo/subgraph';
const typeDefs = gql`
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.3",
import: ["@key", "@shareable"])
type User @key(fields: "id") {
id: ID!
email: String!
name: String!
}
`;
const resolvers = {
User: {
__resolveReference: async (reference, { dataSources }) => {
return dataSources.userAPI.getUser(reference.id);
},
},
};
const server = new ApolloServer({
schema: buildSubgraphSchema({ typeDefs, resolvers }),
});
성능 최적화 팁
- DataLoader 사용: 요청을 배치하고 캐시하기 위해 항상 DataLoader를 사용합니다.
- 필드 수준 캐싱 구현: 비용이 많이 드는 계산을 필드 수준에서 캐시합니다.
- 쿼리 복잡도 분석: 악용을 방지하기 위해 쿼리 깊이와 복잡도를 제한합니다.
- 지속된 쿼리 사용: 생산 환경에서 페이로드 크기를 줄이기 위해 지속된 쿼리를 사용합니다.
- 응답 압축: gzip/brotli 압축을 사용하여 응답을 압축합니다.
- 쿼리 성능 모니터링: 느린 쿼리를 추적하고 리졸버를 최적화합니다.
- 정적 스키마를 위한 CDN 사용: 엣지에서 인트로스펙션 쿼리를 캐시합니다.
BFF 테스트
GraphQL BFF에 대한 포괄적인 테스트를 작성합니다:
import { ApolloServer } from '@apollo/server';
describe('사용자 쿼리', () => {
let server: ApolloServer;
beforeAll(() => {
server = new ApolloServer({
typeDefs,
resolvers,
});
});
it('ID로 사용자 가져오기', async () => {
const result = await server.executeOperation({
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
email
name
}
}
`,
variables: { id: '123' },
});
expect(result.body.kind).toBe('single');
if (result.body.kind === 'single') {
expect(result.body.singleResult.data?.user).toEqual({
id: '123',
email: 'test@example.com',
name: '테스트 사용자',
});
}
});
});
배포 고려사항
컨테이너화
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 4000
CMD ["node", "dist/index.js"]
환경 구성
환경 변수를 사용하여 구성합니다:
const config = {
port: process.env.PORT || 4000,
userServiceUrl: process.env.USER_SERVICE_URL,
orderServiceUrl: process.env.ORDER_SERVICE_URL,
jwtSecret: process.env.JWT_SECRET,
nodeEnv: process.env.NODE_ENV || 'development',
};
모니터링 및 관찰 가능성
포괄적인 모니터링을 구현합니다:
import { ApolloServerPluginInlineTrace } from '@apollo/server/plugin/inlineTrace';
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginInlineTrace(),
process.env.NODE_ENV === 'production'
? ApolloServerPluginLandingPageDisabled()
: ApolloServerPluginLandingPageLocalDefault(),
{
async requestDidStart() {
const start = Date.now();
return {
async willSendResponse({ operationName, contextValue }) {
const duration = Date.now() - start;
console.log(`Operation ${operationName} took ${duration}ms`);
},
};
},
},
],
});
피해야 할 일반적인 함정
- N+1 쿼리 문제: 관련 데이터를 위한 DataLoader 사용 필수
- 백엔드에서의 과도한 데이터 가져오기: GraphQL 선택 집합 기반으로 백엔드 쿼리 최적화
- 오류 처리 누락: 적절한 오류 처리 및 로깅 구현
- 레이트 제한 없음: BFF를 악용으로부터 보호하기 위해 레이트 제한 적용
- 보안 무시: 입력 검증, 인증 구현, 쿼리 복잡도 제한
- 불량한 스키마 설계: 클라이언트 요구사항을 고려한 스키마 설계
- 캐싱 전략 없음: 여러 수준에서 캐싱 구현