Rust의 async-graphql를 활용한 고성능, 타입 안전 GraphQL 서버 구축
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
현대의 웹 개발 환경에서 API는 현대 애플리케이션의 근간을 이룹니다. REST가 오랫동안 지배적인 패러다임이었지만, GraphQL은 유연성, 효율성, 개발자 친화성 덕분에 빠르게 주목받고 있습니다. GraphQL을 사용하면 클라이언트가 필요한 데이터만 정확하게 요청할 수 있어 과잉 및 부족 가져오기(over-fetching and under-fetching)를 줄일 수 있으며, 이는 특히 복잡한 애플리케이션 및 모바일 클라이언트에 유익합니다. 고성능의 복원력 있는 백엔드를 구축하는 데 있어 Rust는 탁월한 메모리 안전성, 동시성 및 속도를 제공합니다. GraphQL의 강력한 기능과 Rust의 보증을 결합하면 차세대 API 구축을 위한 설득력 있는 솔루션이 탄생합니다. 이 글에서는 강력한 Rust용 GraphQL 라이브러리인 async-graphql
을 활용하여 높은 성능을 자랑할 뿐만 아니라 본질적으로 타입 안전성을 갖춘 서버 측 GraphQL API를 구축하는 방법을 살펴봅니다.
핵심 개념 이해
실질적인 구현에 들어가기 전에 Rust와 async-graphql
을 사용하여 GraphQL 서비스를 구축하는 데 중심이 되는 몇 가지 핵심 용어를 간략하게 정의해 보겠습니다.
- GraphQL: API를 위한 오픈 소스 데이터 쿼리 및 조작 언어이며, 이를 기존 데이터로 충족시키는 런타임입니다. 여러 데이터 집계를 위해 여러 엔드포인트가 필요할 수 있는 REST와 달리 GraphQL은 단일 엔드포인트를 사용하며 클라이언트가 필요한 정확한 데이터 구조를 지정하도록 허용합니다.
- 스키마 정의 언어(SDL): 타입, 필드, 관계 및 작업(쿼리, 뮤테이션, 구독)을 포함하여 GraphQL API의 구조를 정의하는 데 사용되는 언어에 구애받지 않는 구문입니다.
- 쿼리: 서버에서 데이터를 읽기 위한 작업입니다.
- 뮤테이션: 서버에서 데이터를 쓰거나 수정하기 위한 작업입니다.
- 구독: 일반적으로 WebSockets를 사용하여 서버에서 실시간 업데이트를 수신하기 위한 작업입니다.
- 리졸버: GraphQL 스키마의 특정 필드에 해당하는 데이터를 가져오는 책임을 맡은 서버의 함수 또는 메서드입니다.
- 타입 안전성: 타입 오류를 방지하여 올바른 타입의 데이터에 대해서만 연산이 수행되도록 보장하는 프로그래밍 언어의 속성입니다. Rust의 강력한 정적 타입 시스템은 컴파일 시점에 탁월한 타입 안전성을 제공합니다.
async-graphql
: Rust를 위한 인기 있고 성능이 뛰어나며 기능이 풍부한 GraphQL 서버 라이브러리입니다. 비동기 작업, Rust의 강력한 타입 시스템을 통한 타입 안전성, 관용적인 Rust API를 강조합니다.async/await
: Rust의 비동기 코드를 작성하는 내장 메커니즘으로, 기존 스레딩 모델의 오버헤드 없이 동시 작업을 가능하게 합니다. 이는 네트워크 서버와 같은 고성능 I/O 바인딩 애플리케이션에 중요합니다.
타입 안전 GraphQL 서버 구축
async-graphql
을 사용하면 절차적 매크로로 장식된 Rust 구조체와 열거형을 직접 사용하여 GraphQL 스키마를 정의할 수 있습니다. 이 접근 방식은 본질적으로 타입 안전성을 강제합니다. 책 관리용 간단한 API를 구축하는 예제를 살펴보겠습니다.
프로젝트 설정
먼저 새로운 Rust 프로젝트를 생성하고 Cargo.toml
에 필요한 종속성을 추가합니다.
[package] name = "book_api" version = "0.1.0" edition = "2021" [dependencies] async-graphql = { version = "7.0", features = ["apollo_tracing", "tracing"] } # 더 나은 디버깅을 위해 추적 기능 추가 async-graphql-poem = "7.0" # Poem 웹 프레임워크 커넥터 poem = { version = "1.0", features = ["static-files", "rustls", "compression"] } # 간단하고 빠른 웹 프레임워크 tokio = { version = "1.0", features = ["full"] } # 비동기 런타임 serde = { version = "1.0", features = ["derive"] } # (역)직렬화를 위해, 종종 유용함 uuid = { version = "1.0", features = ["v4", "serde"] } # 고유 ID 생성용
데이터 모델 정의
먼저 데이터에 해당하는 Rust 구조체를 정의하는 것부터 시작하겠습니다. 이들은 자연스럽게 GraphQL 타입으로 매핑될 것입니다.
use async_graphql::{Enum, Object, ID, SimpleObject}; use uuid::Uuid; #[derive(SimpleObject, Debug, Clone)] struct Book { id: ID, title: String, author: String, genre: Genre, published_year: i32, } #[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)] #[graphql(remote = "Genre")] // 필요한 경우 별도의 모듈에서 열거형을 정의할 수 있게 함 enum Genre { Fiction, NonFiction, ScienceFiction, Fantasy, Mystery, } // 실제 애플리케이션에서는 데이터베이스를 사용할 가능성이 높습니다. // 단순화하기 위해 메모리 내 저장소를 사용합니다. struct BookStore { books: Vec<Book>, } impl BookStore { fn new() -> Self { BookStore { books: Vec::new() } } fn add_book(&mut self, book: Book) { self.books.push(book); } fn get_book(&self, id: &ID) -> Option<&Book> { self.books.iter().find(|b| &b.id == id) } fn get_all_books(&self) -> Vec<Book> { self.books.clone() // 단순화를 위해 복제합니다. 실제 애플리케이션에서는 참조 또는 Arc를 고려하십시오. } }
#[derive(SimpleObject)]
및 #[derive(Enum)]
매크로에 주목하십시오. 이 매크로들은 async-graphql
에서 제공하며 Rust 타입에서 필요한 GraphQL 스키마 정의를 자동으로 생성하여 백엔드 로직과 GraphQL API 간의 타입 일관성을 보장합니다. async-graphql
의 ID
타입은 문자열로 직렬화되는 GraphQL ID
스칼라에 매핑됩니다.
쿼리 정의
다음으로 최상위 Query
객체를 정의해 보겠습니다. 이 구조체는 데이터 가져오기를 위한 리졸버 메서드를 포함합니다.
use async_graphql::{Context, Object, ID}; use std::sync::Arc; // BookStore를 스레드 간에 안전하게 공유하기 위함 pub struct Query; #[Object] impl Query { /// 모든 책의 목록을 반환합니다. async fn books(&self, ctx: &Context<'_>) -> Vec<Book> { let store = ctx.data::<Arc<BookStore>>().expect("BookStore not found in context"); store.get_all_books() } /// ID로 단일 책을 반환합니다. async fn book(&self, ctx: &Context<'_>, id: ID) -> Option<Book> { let store = ctx.data::<Arc<BookStore>>().expect("BookStore not found in context"); store.get_book(&id).cloned() // 소유한 Book을 반환하므로 복제합니다. } }
#[Object]
매크로는 Query
구조체를 GraphQL 객체로 변환합니다. impl
블록 내의 각 async fn
은 GraphQL Query
타입의 필드가 됩니다. ctx: &Context<'_>
매개변수는 공유 애플리케이션 상태에 액세스할 수 있게 해주며, 여기서 BookStore
를 배치할 것입니다.
뮤테이션 정의
이제 클라이언트가 새 책을 추가할 수 있도록 뮤테이션을 추가해 보겠습니다.
use async_graphql::{Context, InputObject, Object, ID}; use uuid::Uuid; use std::sync::Arc; use tokio::sync::Mutex; // 동시 환경에서 BookStore에 대한 변경 가능한 액세스용 #[derive(InputObject)] struct NewBook { title: String, author: String, genre: Genre, published_year: i32, } pub struct Mutation; #[Object] impl Mutation { /// 새 책을 생성합니다. async fn add_book(&self, ctx: &Context<'_>, input: NewBook) -> Book { let store_arc = ctx.data::<Arc<Mutex<BookStore>>>().expect("BookStore Mutex not found in context"); let mut store = store_arc.lock().await; let new_book = Book { id: ID(Uuid::new_v4().to_string()), title: input.title, author: input.author, genre: input.genre, published_year: input.published_year, }; store.add_book(new_book.clone()); new_book } }
여기서 #[derive(InputObject)]
는 뮤테이션 인수에 사용되는 GraphQL 입력 타입을 정의합니다. BookStore
를 Arc<Mutex<T>>
로 감싸는 것에 주목하십시오. Arc
(Atomic Reference Counted)는 스레드 간에 데이터의 다중 소유를 허용하며, Mutex
는 동시 async
환경에서 BookStore
에 대한 안전한 변경 가능한 액세스를 제공합니다.
스키마 및 서버 조립
마지막으로 async_graphql::Schema
를 사용하여 쿼리와 뮤테이션을 실행 가능한 GraphQL 스키마로 결합하고 poem
과 같은 웹 프레임워크를 통해 노출합니다.
use async_graphql::{EmptySubscription, Schema}; use async_graphql_poem::{GraphQLResponse, GraphQLRequest}; use poem::{ get, handler, listener::TcpListener, web::{Html, Data}, EndpointExt, IntoResponse, Route, Server }; use std::sync::Arc; use tokio::sync::Mutex; // BookStore에 대한 변경 가능한 액세스용 mod models; // models.rs에 Book, Genre, BookStore가 있다고 가정 mod queries; // queries.rs에 Query가 있다고 가정 mod mutations; // mutations.rs에 Mutation이 있다고 가정 use models::BookStore; use queries::Query; use mutations::Mutation; // GraphiQL Playground HTML const GRAPHIQL_HTML: &str = r#"""#, <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>GraphiQL</title> <link href="https://unpkg.com/graphiql/graphiql.min.css" rel="stylesheet" /> </head> <body> <div id="graphiql" style="height: 100vh;"></div> <script crossorigin src="https://unpkg.com/react/umd/react.production.min.js"></script> <script crossorigin src="https://unpkg.com/react-dom/umd/react-dom.production.min.js"></script> <script crossorigin src="https://unpkg.com/graphiql/graphiql.min.js"></script> <script> window.onload = function() { ReactDOM.render( React.createElement(GraphiQL, {{ fetcher: GraphiQL.createFetcher({{ url: "/graphql" }}), defaultQuery: ` query { books { id title author genre publishedYear } } mutation AddBook { addBook(input: {{ title: "The Rust Programming Language", author: "Steve Klabnik & Carol Nichols", genre: ScienceFiction, publishedYear: 2018 }}) { id title author genre } } `, }}), document.getElementById('graphiql'), ); }; </script> </body> </html> """#; #[handler] async fn graphql_playground() -> impl IntoResponse { Html(GRAPHIQL_HTML) } #[handler] async fn graphql_handler( schema: Data<&Schema<Query, Mutation, EmptySubscription>>, req: GraphQLRequest, ) -> GraphQLResponse { schema.execute(req.0).await.into() } #[tokio::main] async fn main() -> Result<(), std::io::Error> { // 메모리 내 책 스토어 초기화 let book_store = Arc::new(Mutex::new(BookStore::new())); // GraphQL 스키마 생성 let schema = Schema::build(Query, Mutation, EmptySubscription) .data(book_store.clone()) // book_store를 스키마 컨텍스트에 추가 .finish(); println!("GraphQL Playground: http://localhost:8000"); // Poem을 사용하여 GraphQL 엔드포인트 및 플레이그라운드 서비스 제공 Server::new(TcpListener::bind("127.0.0.1:8000")) .run( Route::new() .at("/graphql", get(graphql_playground).post(graphql_handler)) .data(schema) ) .await }
이 main
함수에서:
BookStore
를 초기화하고Arc<Mutex<T>>
로 감싸 다른 요청 핸들러 간에 안전하고 공유되며 변경 가능한 액세스를 허용합니다.Schema::build
를 사용하여Query
,Mutation
,EmptySubscription
타입을 전달하여Schema
를 빌드합니다.async-graphql
은 절차적 매크로를 사용하여 이러한 타입을 자동으로 검사하여 전체 GraphQL 스키마를 구성합니다..data(book_store.clone())
을 사용하여BookStore
를 GraphQL 컨텍스트에 주입하여 리졸버에서 액세스할 수 있도록 합니다.localhost:8000
에서 수신 대기하는poem
서버를 설정합니다./graphql
엔드포인트는 GET 요청(GraphiQL 플레이그라운드 표시)과 POST 요청(GraphQL 쿼리/뮤테이션 처리)을 모두 처리합니다.async-graphql-poem
은 편리한 통합을 제공합니다.
이 접근 방식의 이점
- 컴파일 타임 타입 안전성: GraphQL 스키마가 Rust 타입에서 직접 파생되므로 API 정의와 Rust 구현 간의 불일치는 컴파일 타임 오류를 발생시킵니다. 이는 런타임 버그를 크게 줄이고 유지보수성을 향상시킵니다.
- 고성능: Rust의 제로 비용 추상화, 효율적인 메모리 관리 및
async/await
런타임은 GraphQL 서버가 최소한의 오버헤드로 많은 수의 동시 요청을 처리할 수 있음을 의미합니다.async-graphql
은 성능을 위해 특별히 설계되었습니다. - 동시성:
async-graphql
은 Rust의 비동기 생태계와 원활하게 통합되어 리졸버가 서버를 차단하지 않고 I/O 바인딩 작업(데이터베이스 호출 또는 외부 API 요청과 같은)을 동시에 수행할 수 있습니다. - 관용적인 Rust: Rust에 익숙한 개발자는 API가 자연스럽고 직관적이라고 생각할 것입니다.
- 풍부한 기능:
async-graphql
은 구독, 지시문, 인터페이스, 유니온 등 광범위한 GraphQL 기능을 지원하므로 복잡한 애플리케이션에 적합합니다.
애플리케이션 시나리오
이 설정은 다음과 같은 경우에 이상적입니다.
- 마이크로서비스: 분산된 백엔드 서비스에 대한 통합 API 게이트웨이를 제공합니다.
- 실시간 애플리케이션: 채팅 애플리케이션, 게임 또는 금융 대시보드의 라이브 업데이트를 위한 구독 활용.
- 모바일 및 웹 백엔드: 특정 필드만 필요한 클라이언트에 효율적으로 데이터를 제공하여 네트워크 사용량 최적화.
- 내부 도구: 데이터 일관성이 가장 중요한 내부 애플리케이션을 위한 강력하고 타입 안전한 API 구축.
결론
async-graphql
을 사용한 Rust에서의 고성능, 타입 안전 GraphQL 서버 구축은 현대 API 개발을 위한 강력하고 안정적인 솔루션을 제공합니다. Rust의 강력한 타입 시스템과 async/await
기능을 활용함으로써 async-graphql
은 개발자가 Rust 코드에서 직접 복잡한 GraphQL 스키마를 정의할 수 있게 하여 컴파일 타임 타입 안전성을 보장하고 탁월한 런타임 성능을 제공합니다. 이 조합은 더 강력하고 유지보수 가능하며 확장 가능한 백엔드 서비스를 결과물로 하며, GraphQL과 Rust 생태계 모두의 최고를 진정으로 구현합니다. 이는 최첨단을 달리는 언어 기능이 어떻게 우수한 애플리케이션 아키텍처로 이어질 수 있는지를 보여주는 증거입니다.