Apollo Server를 사용하여 GraphQL BFF 구축

GraphQL BFF와 Apollo Server를 사용하여 프론트엔드 API 최적화

Page content

프론트엔드를 위한 백엔드(BFF) 패턴을 GraphQL과 Apollo Server와 결합하면 현대 웹 애플리케이션에 강력한 아키텍처를 구축할 수 있습니다.

이 접근법은 여러 소스에서 데이터를 집계하면서도 관심사의 명확한 분리가 유지되는 클라이언트 최적화된 API를 구축할 수 있게 해줍니다.

git-flow

BFF 패턴 이해하기

BFF 패턴은 다양한 데이터 요구사항을 가진 여러 프론트엔드 애플리케이션(웹, 모바일, 데스크탑)을 지원하는 데 있어 나타나는 문제에 대한 해결책으로 등장했습니다. 모든 클라이언트가 단일 일반적인 API를 사용하도록 강요하는 대신, BFF는 각 클라이언트의 특정 요구사항에 맞춘 전용 백엔드 서비스를 생성합니다.

BFF의 주요 이점

  • 클라이언트 최적화된 API: 각 프론트엔드는 필요한 데이터와 기대하는 형식으로 정확히 데이터를 받습니다.
  • 클라이언트 복잡성 감소: 데이터 집계 및 변환 로직은 백엔드로 이동합니다.
  • 독립적인 진화: 프론트엔드는 다른 클라이언트나 핵심 서비스에 영향을 주지 않고 진화할 수 있습니다.
  • 더 나은 성능: 더 적은 라운드트립과 더 작은 페이로드가 애플리케이션 속도를 향상시킵니다.
  • 팀 자율성: 프론트엔드 팀은 자신의 BFF를 소유하여 더 빠른 반복이 가능합니다.

GraphQL이 BFF에 완벽하게 맞는 이유

GraphQL의 유연한 쿼리 언어는 BFF 구현에 이상적입니다:

  1. 정확한 데이터 가져오기: 클라이언트는 필요한 필드만 요청합니다.
  2. 단일 요청: 여러 소스의 데이터를 하나의 쿼리로 결합할 수 있습니다.
  3. 강력한 타이핑: 스키마는 프론트엔드와 백엔드 간의 명확한 계약을 제공합니다.
  4. 실시간 기능: 구독을 통해 라이브 데이터 업데이트가 가능합니다.
  5. 개발자 경험: 인트로스펙션과 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 }),
});

성능 최적화 팁

  1. DataLoader 사용: 요청을 배치하고 캐시하기 위해 항상 DataLoader를 사용합니다.
  2. 필드 수준 캐싱 구현: 비용이 많이 드는 계산을 필드 수준에서 캐시합니다.
  3. 쿼리 복잡도 분석: 악용을 방지하기 위해 쿼리 깊이와 복잡도를 제한합니다.
  4. 지속된 쿼리 사용: 생산 환경에서 페이로드 크기를 줄이기 위해 지속된 쿼리를 사용합니다.
  5. 응답 압축: gzip/brotli 압축을 사용하여 응답을 압축합니다.
  6. 쿼리 성능 모니터링: 느린 쿼리를 추적하고 리졸버를 최적화합니다.
  7. 정적 스키마를 위한 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`);
          },
        };
      },
    },
  ],
});

피해야 할 일반적인 함정

  1. N+1 쿼리 문제: 관련 데이터를 위한 DataLoader 사용 필수
  2. 백엔드에서의 과도한 데이터 가져오기: GraphQL 선택 집합 기반으로 백엔드 쿼리 최적화
  3. 오류 처리 누락: 적절한 오류 처리 및 로깅 구현
  4. 레이트 제한 없음: BFF를 악용으로부터 보호하기 위해 레이트 제한 적용
  5. 보안 무시: 입력 검증, 인증 구현, 쿼리 복잡도 제한
  6. 불량한 스키마 설계: 클라이언트 요구사항을 고려한 스키마 설계
  7. 캐싱 전략 없음: 여러 수준에서 캐싱 구현

유용한 링크