백엔드 API를 개발할 때, RESTful API는 여전히 강력한 선택지지만, 복잡성이 커질수록 관리의 어려움이나 클라이언트 요구사항에 유연하게 대처하기 힘든 순간들을 마주하곤 합니다. 저 역시 여러 프로젝트에서 이러한 한계를 경험했고, 그때마다 GraphQL과 Apollo Server가 얼마나 강력한 대안이 될 수 있는지 직접 확인했습니다. 이번 글에서는 GraphQL의 핵심인 스키마 정의부터 Apollo Server를 이용한 실제 데이터 연동까지, 제 경험을 바탕으로 단계별 실습 가이드를 공유하고자 합니다.
직접 API를 구축하면서 느꼈던 점, 그리고 흔히 겪는 문제들을 어떻게 해결했는지 실질적인 조언과 함께 전달해 드릴 테니, GraphQL 백엔드 개발에 관심 있는 분들께 많은 도움이 되기를 바랍니다.
📑 목차
- 왜 Apollo Server와 GraphQL인가? 백엔드 개발의 새로운 지평
- GraphQL vs RESTful API, 핵심 차이점
- Apollo Server & GraphQL 환경 설정: 첫걸음
- 프로젝트 초기화 및 필수 패키지 설치
- 최소한의 Apollo Server 실행 코드
- GraphQL 스키마 정의: API의 뼈대 만들기
- 기본 타입 및 커스텀 타입 정의
- 리졸버(Resolvers) 구현: 데이터와 스키마 연결하기
- 간단한 인메모리 데이터 연동
- 데이터 연동 전략: 실제 데이터 연결하기
- 데이터소스(Data Sources) 활용
- GraphQL 쿼리 및 뮤테이션 테스트: API 활용하기
- Apollo Studio (GraphQL Playground) 활용법
- 마무리: Apollo Server와 GraphQL, 그 다음 단계는?
Image by doki7 on Pixabay
왜 Apollo Server와 GraphQL인가? 백엔드 개발의 새로운 지평
처음 GraphQL을 접했을 때, 가장 매력적으로 다가왔던 부분은 바로 클라이언트가 필요한 데이터를 직접 정의할 수 있다는 점이었습니다. RESTful API에서는 서버가 정의한 엔드포인트와 데이터 구조에 맞춰 클라이언트가 요청을 보내야 했죠. 이 때문에 여러 엔드포인트에 요청을 보내 데이터를 합치거나(N+1 문제), 필요 없는 데이터까지 함께 받아오는(Over-fetching) 경우가 빈번했습니다. 이런 문제들은 특히 모바일 환경에서 네트워크 성능에 큰 영향을 미치곤 했습니다.
하지만 GraphQL은 다릅니다. 클라이언트가 정확히 필요한 데이터 필드만 요청하고, 서버는 그에 맞춰 응답합니다. 이는 네트워크 효율성을 극대화하고, 클라이언트와 서버 간의 협업 비용을 현저히 줄여주는 결과를 가져왔습니다. 제가 직접 GraphQL API를 개발하고 프론트엔드와 연동했을 때, 프론트엔드 개발자들이 "필요한 것만 딱 받아올 수 있어서 너무 편하다"는 피드백을 주었던 것이 기억에 남습니다. 서버 개발자 입장에서도 명확한 스키마 정의 덕분에 API 문서화 부담이 줄어들고, 변경 관리도 훨씬 수월했습니다.
그렇다면 수많은 GraphQL 서버 구현체 중 왜 Apollo Server를 선택했을까요? 개인적으로 Apollo Server는 생태계가 매우 활발하고, 다양한 플러그인과 미들웨어를 제공하여 프로덕션 환경에 적합한 기능을 쉽게 추가할 수 있다는 장점이 컸습니다. 인증, 캐싱, 에러 핸들링 등 백엔드 API에 필수적인 요소들을 Apollo Server의 강력한 기능을 통해 손쉽게 구현할 수 있었습니다. 특히 내장된 GraphQL Playground (Apollo Studio)는 개발 및 테스트 단계에서 엄청난 생산성 향상을 가져다주었습니다.
GraphQL vs RESTful API, 핵심 차이점
이해를 돕기 위해 GraphQL과 RESTful API의 주요 차이점을 간단히 비교해 보겠습니다. 실제 프로젝트에서 어떤 API 스타일이 더 적합할지 판단하는 데 도움이 될 것입니다.
| 특징 | GraphQL | RESTful API |
|---|---|---|
| 데이터 요청 방식 | 클라이언트가 필요한 필드 명시 | 서버가 정의한 엔드포인트 및 데이터 구조 |
| 엔드포인트 수 | 단일 엔드포인트 (일반적으로 /graphql) |
리소스별 다수 엔드포인트 |
| 데이터 과/부족 현상 | 낮음 (클라이언트가 정확히 요청) | 잦음 (Over-fetching, Under-fetching) |
| 버전 관리 | 스키마 진화로 관리 (API 버전 관리 불필요) | URL 또는 헤더 기반 버전 관리 필요 (/v1, /v2) |
| 문서화 | 자동 생성 및 스키마 기반 | 수동 문서화 필요 (Swagger, Postman 등) |
Apollo Server & GraphQL 환경 설정: 첫걸음
본격적으로 Apollo Server를 이용한 백엔드 API 구축을 시작해 봅시다. 가장 먼저 해야 할 일은 프로젝트를 초기화하고 필요한 패키지들을 설치하는 것입니다. 이 과정은 Node.js 환경에서 진행됩니다.
프로젝트 초기화 및 필수 패키지 설치
먼저 새로운 프로젝트 폴더를 만들고, 터미널에서 해당 폴더로 이동한 후 다음 명령어를 실행하여 package.json 파일을 생성합니다.
mkdir my-graphql-server
cd my-graphql-server
npm init -y
이제 Apollo Server와 GraphQL을 사용하기 위한 핵심 패키지들을 설치합니다. apollo-server는 GraphQL 서버를 쉽게 구축할 수 있도록 도와주는 라이브러리이며, graphql은 GraphQL 자체의 스펙을 구현한 코어 라이브러리입니다.
npm install apollo-server graphql
설치가 완료되면 package.json 파일에 apollo-server와 graphql이 의존성으로 추가된 것을 확인할 수 있습니다. 이제 서버 코드를 작성할 준비가 되었습니다.
최소한의 Apollo Server 실행 코드
index.js 파일을 생성하고, 최소한의 GraphQL 서버를 구동하는 코드를 작성해 봅시다. 여기서는 간단한 "Hello world" 예제를 통해 서버가 제대로 작동하는지 확인합니다.
// index.js
const { ApolloServer, gql } = require('apollo-server');
// 1. GraphQL 스키마 정의 (Type Definitions)
// `gql` 템플릿 리터럴을 사용하여 스키마를 문자열로 정의합니다.
const typeDefs = gql`
type Query {
"간단한 인사말을 반환합니다."
hello: String
"이름을 받아 인사말을 반환합니다."
greet(name: String!): String
}
`;
// 2. 리졸버(Resolvers) 정의
// 스키마에 정의된 각 필드에 대한 데이터를 반환하는 함수들을 정의합니다.
const resolvers = {
Query: {
hello: () => '안녕하세요, GraphQL 세계에 오신 것을 환영합니다!',
greet: (parent, { name }) => `안녕하세요, ${name}님!`,
},
};
// 3. ApolloServer 인스턴스 생성
const server = new ApolloServer({ typeDefs, resolvers });
// 4. 서버 구동
server.listen().then(({ url }) => {
console.log(`🚀 서버가 ${url} 에서 실행 중입니다.`);
console.log(`Explore your API at ${url}`);
});
위 코드를 작성한 후 터미널에서 node index.js 명령어를 실행하면, Apollo Server가 기본 포트(일반적으로 4000)에서 실행되는 것을 확인할 수 있습니다. 콘솔에 출력된 URL로 접속하면 Apollo Studio (GraphQL Playground)가 열리며, 여기서 직접 쿼리를 테스트해 볼 수 있습니다.
예를 들어, 다음 쿼리를 실행해 보세요:
query {
hello
greet(name: "개발자")
}
결과로 "안녕하세요, GraphQL 세계에 오신 것을 환영합니다!"와 "안녕하세요, 개발자님!"이라는 메시지를 받아볼 수 있을 것입니다. 이 단계에서 서버가 성공적으로 구동되었다면, 기본적인 환경 설정은 완료된 것입니다.
GraphQL 스키마 정의: API의 뼈대 만들기
GraphQL API의 핵심은 스키마(Schema)에 있습니다. 스키마는 클라이언트와 서버 간의 계약서와 같습니다. 즉, 클라이언트가 어떤 데이터를 요청할 수 있고, 서버가 어떤 데이터를 제공할 수 있는지 명확하게 정의하는 역할을 합니다. 제가 여러 프로젝트에서 GraphQL을 사용하면서 가장 중요하다고 느꼈던 부분 중 하나가 바로 이 스키마를 얼마나 명확하고 확장성 있게 정의하느냐였습니다.
스키마는 SDL (Schema Definition Language)이라는 직관적인 문법으로 작성됩니다. 주로 type 키워드를 사용하여 데이터 타입을 정의하고, Query와 Mutation 타입을 통해 API의 진입점을 정의합니다.
기본 타입 및 커스텀 타입 정의
실제 백엔드 API를 구축하는 상황을 가정하여, 간단한 도서 관리 시스템의 스키마를 정의해 봅시다. 책(Book)과 저자(Author)라는 두 가지 주요 리소스가 있다고 생각할 수 있습니다.
// typeDefs.js (스키마를 별도 파일로 분리하여 관리하는 것이 좋습니다)
const { gql } = require('apollo-server');
const typeDefs = gql`
# 스칼라 타입 (Scalar Types): GraphQL이 기본적으로 제공하는 타입
# ID: 고유 식별자
# String: 문자열
# Int: 정수
# Float: 부동 소수점 숫자
# Boolean: 불리언 값
# 'Book'이라는 사용자 정의 타입 정의
type Book {
id: ID!
title: String!
author: Author!
publicationYear: Int
genres: [String]!
}
# 'Author'라는 사용자 정의 타입 정의
type Author {
id: ID!
name: String!
nationality: String
books: [Book] # 이 저자가 쓴 책 목록
}
# Query 타입: 데이터를 조회하는 작업의 진입점
type Query {
books: [Book]! # 모든 책 목록 조회
book(id: ID!): Book # 특정 ID의 책 조회
authors: [Author]! # 모든 저자 목록 조회
author(id: ID!): Author # 특정 ID의 저자 조회
}
# Mutation 타입: 데이터를 생성, 수정, 삭제하는 작업의 진입점
type Mutation {
addBook(title: String!, authorId: ID!, publicationYear: Int, genres: [String]!): Book!
updateBook(id: ID!, title: String, authorId: ID, publicationYear: Int, genres: [String]): Book
deleteBook(id: ID!): Boolean!
addAuthor(name: String!, nationality: String): Author!
updateAuthor(id: ID!, name: String, nationality: String): Author
deleteAuthor(id: ID!): Boolean!
}
`;
module.exports = typeDefs;
위 스키마에서 몇 가지 중요한 점을 짚어보겠습니다:
!(느낌표): 해당 필드가 필수 값(Non-Nullable)임을 의미합니다. 예를 들어title: String!은 책의 제목이 항상 존재해야 함을 나타냅니다.[Type]: 해당 필드가 타입의 배열임을 의미합니다.[String]!은 문자열 배열이 필수이며,[Book]은 Book 타입의 배열이 될 수 있지만 필수는 아닙니다.Query와Mutation: GraphQL API의 두 가지 주요 작업 타입입니다.Query는 데이터를 읽어오는 용도,Mutation은 데이터를 변경(생성, 수정, 삭제)하는 용도로 사용됩니다. 실무에서는 이 둘을 명확히 구분하여 API의 의도를 분명히 하는 것이 중요합니다.ID타입: 고유 식별자를 나타내는 특별한 스칼라 타입입니다. 내부적으로는String처럼 직렬화되지만, ID임을 명시하여 GraphQL 도구에서 특별하게 처리할 수 있게 합니다.
이렇게 스키마를 정의하는 것만으로도 API의 전체적인 구조를 한눈에 파악할 수 있게 됩니다. 이는 API 명세를 작성하는 것과 같은 효과를 주며, 프론트엔드 개발팀과의 소통 비용을 크게 줄여줍니다.
Image by Hans on Pixabay
리졸버(Resolvers) 구현: 데이터와 스키마 연결하기
스키마가 API의 뼈대라면, 리졸버(Resolvers)는 그 뼈대에 살을 붙이는 역할을 합니다. 스키마에 정의된 각 필드(Query, Mutation 포함)에 대해 실제 데이터를 가져오거나 조작하는 로직을 담고 있는 함수들이 바로 리졸버입니다. 제가 GraphQL을 실무에 적용하면서 느낀 것은, 리졸버의 설계가 곧 백엔드 로직의 설계와 직결된다는 점입니다.
간단한 인메모리 데이터 연동
먼저 실제 데이터베이스에 연결하기 전에, 간단한 인메모리 데이터를 사용하여 리졸버를 구현해 봅시다. 이는 스키마와 리졸버 간의 연결 관계를 이해하는 데 매우 유용합니다.
// resolvers.js (리졸버도 별도 파일로 분리하여 관리하는 것이 좋습니다)
const resolvers = {
Query: {
books: (parent, args, context, info) => {
// 실제 데이터베이스에서 모든 책을 가져오는 로직 (임시 데이터 사용)
return books;
},
book: (parent, { id }, context, info) => {
// 실제 데이터베이스에서 특정 ID의 책을 가져오는 로직
return books.find(book => book.id === id);
},
authors: () => authors,
author: (parent, { id }) => authors.find(author => author.id === id),
},
Mutation: {
addBook: (parent, { title, authorId, publicationYear, genres }) => {
const newBook = {
id: String(books.length + 1), // 임시 ID 생성
title,
author: authors.find(a => a.id === authorId), // authorId로 author 객체 찾기
publicationYear,
genres,
};
books.push(newBook);
return newBook;
},
updateBook: (parent, { id, title, authorId, publicationYear, genres }) => {
const bookIndex = books.findIndex(book => book.id === id);
if (bookIndex === -1) return null;
const updatedBook = {
...books[bookIndex],
...(title && { title }),
...(authorId && { author: authors.find(a => a.id === authorId) }),
...(publicationYear && { publicationYear }),
...(genres && { genres }),
};
books[bookIndex] = updatedBook;
return updatedBook;
},
deleteBook: (parent, { id }) => {
const initialLength = books.length;
books = books.filter(book => book.id !== id);
return books.length < initialLength; // 삭제 성공 여부 반환
},
addAuthor: (parent, { name, nationality }) => {
const newAuthor = {
id: String(authors.length + 1),
name,
nationality,
books: [] // 새 저자는 아직 책이 없습니다.
};
authors.push(newAuthor);
return newAuthor;
},
updateAuthor: (parent, { id, name, nationality }) => {
const authorIndex = authors.findIndex(author => author.id === id);
if (authorIndex === -1) return null;
const updatedAuthor = {
...authors[authorIndex],
...(name && { name }),
...(nationality && { nationality }),
};
authors[authorIndex] = updatedAuthor;
return updatedAuthor;
},
deleteAuthor: (parent, { id }) => {
const initialLength = authors.length;
authors = authors.filter(author => author.id !== id);
return authors.length < initialLength;
},
},
// Book 타입에 대한 리졸버 (관계형 데이터 처리)
Book: {
author: (parent, args, context, info) => {
// Book 타입의 author 필드는 Author 타입 객체를 반환해야 함
// parent는 현재 Book 객체입니다.
// 만약 Book 객체에 authorId만 있고 Author 객체는 없다면 여기서 조회 로직을 구현합니다.
// 현재는 addBook에서 author 객체를 직접 넣어주고 있으므로 그대로 반환합니다.
return parent.author;
},
},
// Author 타입에 대한 리졸버
Author: {
books: (parent, args, context, info) => {
// Author 타입의 books 필드는 [Book] 타입 배열을 반환해야 함
// parent는 현재 Author 객체입니다.
return books.filter(book => book.author && book.author.id === parent.id);
}
}
};
// 임시 데이터 (실제 DB 연동 전까지 사용)
let books = [
{ id: '1', title: '클린 코드', publicationYear: 2008, genres: ['기술', '프로그래밍'] },
{ id: '2', title: '실용주의 프로그래머', publicationYear: 1999, genres: ['기술', '프로그래밍'] },
{ id: '3', title: '데미안', publicationYear: 1919, genres: ['소설', '철학'] },
];
let authors = [
{ id: '1', name: '로버트 C. 마틴', nationality: '미국' },
{ id: '2', name: '앤드류 헌트', nationality: '미국' },
{ id: '3', name: '데이비드 토마스', nationality: '미국' },
{ id: '4', name: '헤르만 헤세', nationality: '독일' },
];
// 책과 저자 데이터 연결 (초기 데이터 설정)
books[0].author = authors[0];
books[1].author = authors[1]; // 실용주의 프로그래머는 두 저자가 썼으므로, 여기서는 대표 한 명만 연결
authors[1].books.push(books[1]); // 데이터 일관성을 위해 저자 객체에도 책 추가
books[2].author = authors[3];
module.exports = resolvers;
index.js 파일을 다음과 같이 업데이트하여 typeDefs와 resolvers를 분리된 파일에서 가져오도록 합니다.
// index.js
const { ApolloServer } = require('apollo-server');
const typeDefs = require('./typeDefs'); // 스키마 파일 불러오기
const resolvers = require('./resolvers'); // 리졸버 파일 불러오기
const server = new ApolloServer({ typeDefs, resolvers });
server.listen().then(({ url }) => {
console.log(`🚀 서버가 ${url} 에서 실행 중입니다.`);
});
리졸버 함수는 4개의 인자를 받습니다: (parent, args, context, info). 이 중 args는 클라이언트가 쿼리나 뮤테이션을 통해 전달하는 인자들을 포함하고, parent는 상위 필드의 결과값을 나타냅니다. 특히 Book 타입의 author 필드나 Author 타입의 books 필드처럼 관계형 데이터를 처리할 때 parent 인자가 유용하게 사용됩니다. 이를 통해 N+1 문제를 방지하기 위한 DataLoader 패턴 등을 적용할 수 있습니다.
데이터 연동 전략: 실제 데이터 연결하기
인메모리 데이터로 리졸버를 구현해 보았지만, 실제 서비스에서는 데이터베이스와 연동해야 합니다. Apollo Server는 특정 데이터베이스에 종속되지 않으므로, 여러분이 선호하는 데이터베이스(MongoDB, PostgreSQL, MySQL 등)와 ORM/ODM(Mongoose, Sequelize, TypeORM 등)을 자유롭게 사용할 수 있습니다. 여기서는 데이터베이스 연동의 개념과 간단한 시뮬레이션을 통해 어떻게 리졸버를 확장할 수 있는지 보여드리겠습니다.
데이터소스(Data Sources) 활용
Apollo Server는 Data Sources라는 개념을 제공하여, 리졸버에서 데이터베이스나 REST API 등 외부 데이터를 가져오는 로직을 추상화하고 캐싱 등의 기능을 추가할 수 있게 합니다. 이는 리졸버를 깔끔하게 유지하고 데이터 접근 로직을 중앙 집중화하는 데 큰 도움이 됩니다. 제가 여러 프로젝트에서 Apollo Data Sources를 사용해 본 결과, 코드의 가독성과 유지보수성이 크게 향상되는 것을 경험했습니다.
간단히 Data Sources를 사용하는 방식을 시뮬레이션 해보겠습니다. 실제 데이터베이스 대신, 데이터를 비동기적으로 가져오는 함수를 가정합니다.
// dataSources.js
class BookAPI {
constructor() {
// 실제로는 여기에 DB 연결 인스턴스 (예: Mongoose 모델)를 주입합니다.
this.books = [
{ id: '1', title: '클린 코드', authorId: '1', publicationYear: 2008, genres: ['기술', '프로그래밍'] },
{ id: '2', title: '실용주의 프로그래머', authorId: '2', publicationYear: 1999, genres: ['기술', '프로그래밍'] },
{ id: '3', title: '데미안', authorId: '4', publicationYear: 1919, genres: ['소설', '철학'] },
];
this.authors = [
{ id: '1', name: '로버트 C. 마틴', nationality: '미국' },
{ id: '2', name: '앤드류 헌트', nationality: '미국' },
{ id: '3', name: '데이비드 토마스', nationality: '미국' },
{ id: '4', name: '헤르만 헤세', nationality: '독일' },
];
}
// 비동기 함수로 시뮬레이션
async getAllBooks() {
return Promise.resolve(this.books);
}
async getBookById(id) {
return Promise.resolve(this.books.find(book => book.id === id));
}
async getAllAuthors() {
return Promise.resolve(this.authors);
}
async getAuthorById(id) {
return Promise.resolve(this.authors.find(author => author.id === id));
}
async getBooksByAuthorId(authorId) {
return Promise.resolve(this.books.filter(book => book.authorId === authorId));
}
async addBook(bookData) {
const newBook = { ...bookData, id: String(this.books.length + 1) };
this.books.push(newBook);
return Promise.resolve(newBook);
}
// ... 기타 CRUD 메서드
}
module.exports = { BookAPI };
index.js에서 ApolloServer를 생성할 때 dataSources 옵션을 추가하고, 리졸버에서 이 데이터소스 인스턴스를 context 인자를 통해 접근할 수 있도록 합니다.
// index.js
const { ApolloServer } = require('apollo-server');
const typeDefs = require('./typeDefs');
const resolvers = require('./resolvers');
const { BookAPI } = require('./dataSources'); // 데이터소스 불러오기
const server = new ApolloServer({
typeDefs,
resolvers,
dataSources: () => ({
bookAPI: new BookAPI(), // 여기에 데이터소스 인스턴스를 추가합니다.
}),
context: ({ req }) => {
// 여기에 인증 정보 등 요청별 컨텍스트를 추가할 수 있습니다.
// 예를 들어, JWT 토큰을 파싱하여 사용자 정보를 context에 넣을 수 있습니다.
// return { user: getUserFromToken(req.headers.authorization) };
return {};
}
});
server.listen().then(({ url }) => {
console.log(`🚀 서버가 ${url} 에서 실행 중입니다.`);
});
이제 resolvers.js에서 context 인자를 통해 dataSources에 접근하여 데이터를 가져올 수 있습니다.
// resolvers.js (일부 수정)
const resolvers = {
Query: {
books: async (parent, args, { dataSources }) => {
// dataSources.bookAPI를 통해 데이터 가져오기
const books = await dataSources.bookAPI.getAllBooks();
// authorId를 기반으로 author 객체를 찾아서 할당 (N+1 문제 해결을 위해 DataLoader 사용 고려)
return books.map(book => ({
...book,
author: dataSources.bookAPI.getAuthorById(book.authorId) // Promise 반환
}));
},
book: async (parent, { id }, { dataSources }) => {
const book = await dataSources.bookAPI.getBookById(id);
if (!book) return null;
return {
...book,
author: dataSources.bookAPI.getAuthorById(book.authorId)
};
},
authors: async (parent, args, { dataSources }) => dataSources.bookAPI.getAllAuthors(),
author: async (parent, { id }, { dataSources }) => dataSources.bookAPI.getAuthorById(id),
},
Mutation: {
addBook: async (parent, { title, authorId, publicationYear, genres }, { dataSources }) => {
const newBook = await dataSources.bookAPI.addBook({ title, authorId, publicationYear, genres });
return {
...newBook,
author: dataSources.bookAPI.getAuthorById(newBook.authorId)
};
},
// ... 나머지 Mutation 리졸버들도 dataSources를 사용하도록 수정
},
Book: {
// Book 타입의 author 필드 리졸버 (관계형 데이터 처리)
// 부모 객체 (parent)에 authorId가 있다면, 이를 이용해 author 정보를 가져옵니다.
author: async (parent, args, { dataSources }) => {
if (parent.author) return parent.author; // 이미 author 객체가 있다면 그대로 반환
return dataSources.bookAPI.getAuthorById(parent.authorId);
},
},
Author: {
// Author 타입의 books 필드 리졸버
books: async (parent, args, { dataSources }) => {
return dataSources.bookAPI.getBooksByAuthorId(parent.id);
}
}
};
module.exports = resolvers;
위 코드에서는 Book 타입의 author 필드 리졸버가 parent.authorId를 사용하여 dataSources.bookAPI.getAuthorById()를 호출하는 것을 볼 수 있습니다. 이렇게 하면 각 필드가 독립적으로 데이터를 가져올 책임을 가지게 되며, GraphQL의 장점인 필요한 데이터만 가져오는 기능을 온전히 활용할 수 있습니다. 실제 데이터베이스 연동 시에는 여기에 인증/인가 로직이나 데이터 유효성 검사 등의 미들웨어를 추가하여 API의 견고함을 높일 수 있습니다.
Image by Pezibear on Pixabay
GraphQL 쿼리 및 뮤테이션 테스트: API 활용하기
스키마를 정의하고 리졸버까지 구현했다면, 이제 API가 제대로 작동하는지 테스트할 차례입니다. Apollo Server는 개발 서버 구동 시 기본적으로 Apollo Studio (GraphQL Playground)를 제공하여, 웹 브라우저에서 직접 쿼리, 뮤테이션을 실행하고 스키마 문서를 탐색할 수 있게 해줍니다. 이는 제가 개발 생산성을 높이는 데 가장 크게 기여했다고 생각하는 기능 중 하나입니다.
Apollo Studio (GraphQL Playground) 활용법
서버가 실행 중인 상태에서 http://localhost:4000 (또는 서버가 지정한 포트)에 접속하면 Apollo Studio 화면을 볼 수 있습니다. 왼쪽 패널에 쿼리/뮤테이션을 작성하고, 가운데 'Play' 버튼을 누르면 오른쪽 패널에 결과가 출력됩니다. 오른쪽 상단의 'Docs' 탭에서는 정의된 스키마를 기반으로 자동 생성된 API 문서를 확인할 수 있습니다.
데이터 조회 (Query) 예시:
모든 책과 각 책의 저자 이름을 조회하는 쿼리:
query GetBooksWithAuthors {
books {
id
title
publicationYear
genres
author {
id
name
nationality
}
}
}
특정 ID의 책과 해당 저자의 모든 책 목록을 조회하는 쿼리:
query GetBookAndAuthorBooks {
book(id: "1") {
title
author {
name
books {
title
}
}
}
}
데이터 변경 (Mutation) 예시:
새로운 책을 추가하는 뮤테이션:
mutation AddNewBook {
addBook(
title: "리팩토링",
authorId: "1", # 로버트 C. 마틴의 ID
publicationYear: 1999,
genres: ["기술", "프로그래밍"]
) {
id
title
author {
name
}
}
}
기존 책 정보를 수정하는 뮤테이션:
mutation UpdateExistingBook {
updateBook(id: "1", title: "클린 코더", publicationYear: 2011) {
id
title
publicationYear
}
}
새로운 저자를 추가하는 뮤테이션:
mutation AddNewAuthor {
addAuthor(name: "마틴 파울러", nationality: "영국") {
id
name
nationality
}
}
이처럼 Apollo Studio를 활용하면 실제 클라이언트 애플리케이션을 개발하기 전에 API의 모든 기능을 테스트하고 디버깅할 수 있습니다. 제가 GraphQL을 처음 도입했을 때, 프론트엔드 개발자들이 Apollo Studio만으로도 거의 모든 API 연동 테스트를 마칠 수 있었다며 만족해했던 경험이 많습니다.
마무리: Apollo Server와 GraphQL, 그 다음 단계는?
이번 글에서는 Apollo Server와 GraphQL을 활용하여 백엔드 API를 구축하는 전반적인 과정을 살펴보았습니다. 스키마 정의부터 리졸버 구현, 그리고 실제 데이터 연동 및 테스트까지, 각 단계마다 실무에서 직접 경험하며 얻은 팁들을 녹여내고자 노력했습니다.
핵심을 요약하자면, GraphQL은 클라이언트가 필요한 데이터를 직접 정의할 수 있게 하여 네트워크 효율성과 개발 생산성을 극대화하고, Apollo Server는 이러한 GraphQL API를 견고하고 확장성 있게 구축할 수 있도록 돕는 강력한 프레임워크입니다. 명확한 스키마 기반의 API 설계는 프론트엔드와 백엔드 간의 소통을 원활하게 하고, 변경 관리의 복잡성을 줄여주었습니다.
물론, 여기서 다룬 내용은 GraphQL과 Apollo Server의 기본적인 기능에 불과합니다. 실제 프로덕션 환경에서는 다음과 같은 추가적인 고려 사항들이 있습니다:
- 인증 및 인가: JWT 토큰 등을 활용하여 사용자 인증을 처리하고,
context를 통해 리졸버에 사용자 정보를 전달하여 접근 권한을 제어합니다. - 에러 핸들링: GraphQL 에러를 일관된 방식으로 처리하고, 클라이언트에 의미 있는 에러 메시지를 전달합니다.
- 캐싱: DataLoader 패턴을 활용하여 N+1 문제를 해결하고, 쿼리 캐싱 전략을 구현하여 성능을 최적화합니다.
- 파일 업로드: GraphQL 스펙에 맞춰 파일 업로드 기능을 구현합니다.
- 구독(Subscriptions): 실시간 데이터 업데이트가 필요한 경우, WebSocket 기반의 구독 기능을 활용합니다.
- 테스팅: 통합 테스트, 유닛 테스트 등을 통해 API의 안정성을 확보합니다.
이러한 고급 주제들은 여러분이 GraphQL 백엔드 개발 여정을 계속하면서 자연스럽게 탐구하게 될 것입니다. 저 역시 처음에는 간단한 API부터 시작하여 점차 복잡한 요구사항들을 GraphQL로 해결해 나가는 과정에서 많은 것을 배웠습니다. 직접 코드를 작성하고 테스트해보는 것이 가장 좋은 학습 방법입니다.
Apollo Server와 GraphQL은 백엔드 개발의 새로운 가능성을 열어주는 강력한 도구입니다. 이 글이 여러분의 GraphQL 여정에 작은 이정표가 되기를 바랍니다. 혹시 이 글을 읽으면서 궁금한 점이 생기셨거나, Apollo Server와 GraphQL을 활용한 자신만의 경험이 있다면 댓글로 자유롭게 공유해 주세요!
함께 배우고 성장하는 개발 커뮤니티가 되었으면 좋겠습니다.