icon안동민 개발노트

GraphQL API 개발


 GraphQL은 API를 위한 쿼리 언어로, 클라이언트가 필요한 데이터를 정확히 요청할 수 있게 해주는 강력한 도구입니다.

 TypeScript와 결합하면 타입 안전성과 개발자 경험을 크게 향상시킬 수 있습니다.

GraphQL vs RESTful API

 GraphQL은 단일 엔드포인트를 통해 필요한 데이터만 정확히 요청할 수 있어 오버페칭과 언더페칭 문제를 해결합니다.

 RESTful API와 달리, 클라이언트가 데이터 구조를 결정합니다.

TypeScript와 GraphQL의 시너지

 TypeScript의 정적 타입 시스템은 GraphQL 스키마와 자연스럽게 조화를 이룹니다.

 이를 통해 런타임 이전에 많은 오류를 잡아낼 수 있으며, 자동완성과 타입 추론을 통해 개발 생산성을 높일 수 있습니다.

Apollo Server 설정

  1. 필요한 패키지 설치
npm install apollo-server graphql reflect-metadata type-graphql
  1. Apollo Server 설정
import { ApolloServer } from 'apollo-server';
import { buildSchema } from 'type-graphql';
import { UserResolver } from './resolvers/UserResolver';
 
async function bootstrap() {
  const schema = await buildSchema({
    resolvers: [UserResolver],
    emitSchemaFile: true,
  });
 
  const server = new ApolloServer({
    schema,
    context: ({ req }) => ({ req })
  });
 
  const { url } = await server.listen(4000);
  console.log(`Server is running, GraphQL Playground available at ${url}`);
}
 
bootstrap();

GraphQL 스키마 정의

 Type-GraphQL을 사용한 코드 퍼스트 접근 방식

import { ObjectType, Field, ID } from 'type-graphql';
 
@ObjectType()
class User {
  @Field(() => ID)
  id: string;
 
  @Field()
  name: string;
 
  @Field()
  email: string;
}

 이 접근 방식의 이점은 TypeScript 클래스와 GraphQL 스키마를 동시에 정의할 수 있어 코드 중복을 줄이고 타입 안전성을 보장한다는 것입니다.

리졸버 구현

 타입 안전한 리졸버 함수

import { Resolver, Query, Mutation, Arg } from 'type-graphql';
import { User } from '../entities/User';
 
@Resolver(User)
class UserResolver {
  @Query(() => [User])
  async users(): Promise<User[]> {
    // 데이터베이스에서 사용자 목록을 가져오는 로직
  }
 
  @Mutation(() => User)
  async createUser(
    @Arg('name') name: string,
    @Arg('email') email: string
  ): Promise<User> {
    // 새 사용자를 생성하는 로직
  }
}

컨텍스트 객체 타입 정의

import { Request } from 'express';
 
interface Context {
  req: Request;
  user?: {
    id: string;
    roles: string[];
  };
}
 
const server = new ApolloServer({
  schema,
  context: ({ req }): Context => ({ req })
});

쿼리, 뮤테이션, 서브스크립션 구현

  1. 쿼리
@Query(() => User, { nullable: true })
async user(@Arg('id') id: string): Promise<User | null> {
  // 사용자를 찾는 로직
}
  1. 뮤테이션
@Mutation(() => Boolean)
async deleteUser(@Arg('id') id: string): Promise<boolean> {
  // 사용자를 삭제하는 로직
}
  1. 서브스크립션
@Subscription(() => User, {
  topics: 'NEW_USER'
})
newUser(@Root() user: User): User {
  return user;
}

DataLoader를 사용한 N+1 문제 해결

import DataLoader from 'dataloader';
 
const createUserLoader = () => 
  new DataLoader<string, User>(async (userIds) => {
    const users = await User.findByIds(userIds);
    const userMap = new Map(users.map(u => [u.id, u]));
    return userIds.map(id => userMap.get(id));
  });
 
// Context에 DataLoader 추가
interface Context {
  userLoader: ReturnType<typeof createUserLoader>;
}
 
const server = new ApolloServer({
  schema,
  context: (): Context => ({
    userLoader: createUserLoader()
  })
});
 
// 리졸버에서 사용
@FieldResolver(() => User)
async author(@Root() book: Book, @Ctx() ctx: Context): Promise<User> {
  return ctx.userLoader.load(book.authorId);
}

스키마 지시어(Directive) 구현

import { SchemaDirectiveVisitor } from 'apollo-server';
import { defaultFieldResolver, GraphQLField } from 'graphql';
 
class UppercaseDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field: GraphQLField<any, any>) {
    const { resolve = defaultFieldResolver } = field;
    field.resolve = async function(...args) {
      const result = await resolve.apply(this, args);
      if (typeof result === 'string') {
        return result.toUpperCase();
      }
      return result;
    };
  }
}
 
// 스키마에 지시어 추가
const schema = makeExecutableSchema({
  typeDefs,
  resolvers,
  schemaDirectives: {
    upper: UppercaseDirective
  }
});

인증과 권한 부여

import { createMethodDecorator } from 'type-graphql';
import { AuthenticationError } from 'apollo-server';
 
function Authorized(roles: string[] = []) {
  return createMethodDecorator(async ({ context }, next) => {
    if (!context.user) {
      throw new AuthenticationError('Not authenticated');
    }
    if (roles.length > 0 && !roles.some(role => context.user.roles.includes(role))) {
      throw new AuthenticationError('Not authorized');
    }
    return next();
  });
}
 
// 리졸버에서 사용
@Query(() => [User])
@Authorized(['ADMIN'])
async users(): Promise<User[]> {
  // 관리자만 접근 가능한 로직
}

GraphQL API 테스팅

import { gql } from 'apollo-server';
import { createTestClient } from 'apollo-server-testing';
import { server } from '../server';
 
const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
    }
  }
`;
 
describe('User Query', () => {
  it('should return a user', async () => {
    const { query } = createTestClient(server);
    const res = await query({ query: GET_USER, variables: { id: '1' } });
    expect(res.data.user).toBeDefined();
    expect(res.data.user.name).toBe('John Doe');
  });
});

Best Practices와 구조화 전략

  1. 스키마 우선 설계 : API 설계를 스키마부터 시작하여 일관성을 유지합니다.
  2. 모듈화 : 기능별로 리졸버와 타입을 모듈화하여 관리합니다.
  3. 입력 유효성 검사 : class-validator를 활용하여 입력 데이터를 검증합니다.
  4. 에러 처리 : 일관된 에러 형식을 정의하고 사용합니다.
  5. 페이지네이션 : Relay 스타일의 커서 기반 페이지네이션을 구현합니다.
  6. 캐싱 전략 : Apollo Server의 캐싱 기능을 활용합니다.
  7. 성능 모니터링 : Apollo Studio를 사용하여 쿼리 성능을 모니터링합니다.
  8. 코드 생성 : GraphQL Code Generator를 사용하여 타입과 리졸버 시그니처를 자동 생성합니다.
  9. 버전 관리 : 스키마 변경을 신중히 관리하고 하위 호환성을 유지합니다.
  10. 문서화 : GraphQL 스키마 주석을 활용하여 자동으로 문서를 생성합니다.

 GraphQL과 TypeScript를 함께 사용하면 타입 안전성, 개발자 경험, 그리고 API의 유연성을 크게 향상시킬 수 있습니다.

 Apollo Server와 Type-GraphQL 같은 도구를 활용하면 강력하고 확장 가능한 API를 구축할 수 있습니다.

 하지만 GraphQL API 개발 시 항상 보안을 최우선으로 고려해야 합니다.

 쿼리 복잡도 제한, 적절한 인증 및 권한 부여 메커니즘 구현, 그리고 입력 데이터의 철저한 검증이 중요합니다.