Go에서 gqlgen을 사용하여 타입 안전한 스키마 우선 GraphQL 서버 구축하기
Emily Parker
Product Engineer · Leapcell

견고하고 유지보수 가능한 API를 구축하는 것은 현대 소프트웨어 개발의 초석입니다. 애플리케이션의 복잡성이 증가함에 따라 효율적인 데이터 가져오기, 클라이언트와 서버 간의 명확한 계약, 단순화된 개발 워크플로우의 필요성이 중요해집니다. 전통적으로 REST API는 인기 있는 선택이었지만, 종종 과도한 요청, 부족한 요청, 여러 엔드포인트 관리의 복잡성과 같은 문제를 야기합니다. 바로 여기서 GraphQL이 빛을 발하며 유연하고 강력한 대안을 제공합니다. 그러나 GraphQL을 단순히 사용하는 것만으로는 충분하지 않습니다. 특히 Go와 같은 강력한 타입 언어에서 GraphQL의 이점을 진정으로 활용하려면 타입 안전하고 스키마 우선적인 접근 방식을 채택하는 것이 중요합니다. 이 글에서는 Go 생태계에 GraphQL의 최고 기능을 제공하는 강력한 도구인 gqlgen
을 사용하여 이러한 서버를 구축하는 과정을 안내합니다.
핵심 개념 이해하기
구현에 뛰어들기 전에 서버 개발의 기반이 되는 기본 개념을 명확히 이해합시다.
GraphQL: 본질적으로 GraphQL은 API를 위한 쿼리 언어이며 기존 데이터를 사용하여 쿼리를 충족하는 런타임입니다. 일반적으로 데이터를 수집하기 위해 여러 엔드포인트에 접근하는 REST와 달리, GraphQL은 클라이언트가 단일 쿼리에서 계층적으로 구조화된 데이터를 정확히 요청할 수 있도록 합니다. 이를 통해 네트워크 요청과 과도한 요청이 최소화됩니다.
스키마 우선 개발: 이 패러다임은 GraphQL 스키마를 API의 단일 진실 공급원(single source of truth)으로 정의하는 것을 강조합니다. 먼저 GraphQL의 스키마 정의 언어(SDL)로 스키마를 작성하여 API가 지원하는 모든 타입, 필드 및 작업(쿼리, 변경, 구독)을 지정합니다. gqlgen
과 같은 도구는 이 스키마를 사용하여 서버 측 코드의 상당 부분을 생성하므로 구현이 정의된 계약을 엄격하게 준수하도록 보장합니다. 이 접근 방식은 프런트엔드와 백엔드 팀 간의 명확한 커뮤니케이션을 촉진하고 API 진화를 단순화합니다.
타입 안전성: Go와 같은 강력한 타입 언어에서 타입 안전성은 컴파일 시간에 변수와 표현식이 명확하게 정의된 타입을 갖도록 보장하여 타입 관련 오류를 방지하고 코드를 더 예측 가능하고 유지보수 가능하게 만드는 것입니다. 스키마 우선 개발과 결합되면 gqlgen
은 GraphQL 스키마의 타입 정의를 활용하여 Go 구조체와 인터페이스를 생성하므로 GraphQL 타입을 Go 타입에 효과적으로 매핑합니다. 이를 통해 클라이언트 쿼리부터 Go 리졸버 함수까지 엔드투엔드 타입 안전성을 제공하여 개발 주기 초기에 잠재적인 문제를 포착할 수 있습니다.
gqlgen
: GraphQL 스키마에서 GraphQL 서버를 생성하는 Go 라이브러리입니다. 스키마 우선 개발에 대한 의견이 강하고 강력하고 유연한 코드 생성 엔진을 제공하는 데 중점을 두어 개발자가 상용구 코드보다는 비즈니스 로직 구현에 집중할 수 있도록 합니다.
gqlgen으로 GraphQL 서버 구축하기
Todo
항목 목록을 관리하기 위한 간단한 GraphQL API를 구축해 보겠습니다.
프로젝트 설정
먼저 Go가 설치되어 있는지 확인하십시오. 그런 다음 새 Go 모듈을 만듭니다:
mkdir todo-graphql-server cd todo-graphql-server go mod init todo-graphql-server
다음으로 gqlgen
및 해당 종속성을 설치합니다:
go get github.com/99designs/gqlgen go get github.com/99designs/gqlgen/cmd@latest
이제 프로젝트 내에서 gqlgen
을 초기화합니다. 이렇게 하면 gqlgen.yml
, graph/schema.resolvers.go
, graph/schema.graphqls
, graph/generated.go
와 같은 필수 파일이 생성됩니다.
go run github.com/99designs/gqlgen init
GraphQL 스키마 정의하기
graph/schema.graphqls
를 엽니다. 이 파일에는 GraphQL 스키마 정의가 포함됩니다. Todo
타입을 정의하고 기본 쿼리 및 변경 사항을 정의해 보겠습니다:
# graph/schema.graphqls type Todo { id: ID! text: String! done: Boolean! user: User! } type User { id: ID! name: String! } type Query { todos: [Todo!]! } type Mutation { createTodo(text: String!, userId: ID!): Todo! markTodoDone(id: ID!): Todo }
스키마 업데이트 후 gqlgen generate
를 실행하여 생성된 Go 코드를 업데이트합니다:
go run github.com/99designs/gqlgen generate
이 명령은 graph/generated.go
와 graph/model/models_gen.go
를 업데이트합니다. models_gen.go
에는 이제 Todo
및 User
타입, 정의된 경우 입력 타입을 나타내는 Go 구조체가 포함됩니다. 예를 들면 다음과 같습니다:
# graph/model/models_gen.go package model type NewTodo struct { Text string `json:"text"` UserID string `json:"userId"` } type Todo struct { ID string `json:"id"` Text string `json:"text"` Done bool `json:"done"` User *User `json:"user"` } type User struct { ID string `json:"id"` Name string `json:"name"` }
gqlgen
이 createTodo
변경 사항의 인수를 기반으로 NewTodo
입력 타입을 자동으로 추론했음을 알 수 있습니다.
리졸버 구현하기
생성된 graph/schema.resolvers.go
파일에는 리졸버의 기본 골격이 포함되어 있습니다. 리졸버는 스키마의 특정 필드에 대한 데이터를 가져오는 함수입니다. createTodo
, todos
, markTodoDone
에 대한 로직을 구현하도록 graph/schema.resolvers.go
를 수정해 보겠습니다. 단순성을 위해 메모리 내 저장소를 사용합니다.
먼저 graph/resolver.go
에서 데이터 저장소와 고유 ID를 생성하는 방법을 정의합니다:
# graph/resolver.go package graph import ( "context" "fmt" "math/rand" "sync" "time" "todo-graphql-server/graph/model" ) // This file will not be regenerated automatically. // // It serves as dependency injection for your app, add any dependencies you require here. type Resolver struct { mu sync.Mutex todos []*model.Todo users []*model.User } func init() { rand.Seed(time.Now().UnixNano()) } var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") func randString(n int) string { b := make([]rune, n) for i := range b { b[i] = letterRunes[rand.Intn(len(letterRunes))] } return string(b) } func (r *Resolver) GetUserByID(id string) *model.User { for _, user := range r.users { if user.ID == id { return user } } return nil }
이제 graph/schema.resolvers.go
에서 리졸버 로직을 채워 보겠습니다:
# graph/schema.resolvers.go package graph // This file will be automatically regenerated based on the schema, any resolver implementations // will be copied through when generating and any unknown code will be moved to the end. // Code generated by github.com/99designs/gqlgen version v0.17.45 import ( "context" "fmt" "todo-graphql-server/graph/model" ) // CreateTodo is the resolver for the createTodo field. func (r *mutationResolver) CreateTodo(ctx context.Context, text string, userID string) (*model.Todo, error) { r.mu.Lock() defer r.mu.Unlock() user := r.GetUserByID(userID) if user == nil { return nil, fmt.Errorf("user with ID %s not found", userID) } newTodo := &model.Todo{ ID: randString(8), // Generate a unique ID Text: text, Done: false, User: user, } r.todos = append(r.todos, newTodo) return newTodo, nil } // MarkTodoDone is the resolver for the markTodoDone field. func (r *mutationResolver) MarkTodoDone(ctx context.Context, id string) (*model.Todo, error) { r.mu.Lock() defer r.mu.Unlock() for _, todo := range r.todos { if todo.ID == id { todo.Done = true return todo, nil } } return nil, fmt.Errorf("todo with ID %s not found", id) } // Todos is the resolver for the todos field. func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) { r.mu.Lock() defer r.mu.Unlock() return r.todos, nil } // User is the resolver for the user field. func (r *todoResolver) User(ctx context.Context, obj *model.Todo) (*model.User, error) { // The user is already embedded in the Todo model, so we just return it. // In a real application, you might fetch the user from a database here if it's not eager-loaded. return obj.User, nil } // Mutation returns MutationResolver implementation. func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } // Query returns QueryResolver implementation. func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } // Todo returns TodoResolver implementation. func (r *Resolver) Todo() TodoResolver { return &todoResolver{r} } type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } type todoResolver struct{ *Resolver }
Todo
내 User
필드에 대한 생성된 Todo()
리졸버에 주목하십시오. 이것은 타입의 특정 필드가 어떻게 해결되는지 사용자 정의할 수 있는 필드 리졸버입니다. Todo
구조체에 이미 User
객체가 포함되어 있으므로 단순히 반환합니다. Todo
구조체에 ID
로만 저장된 경우 여기에서 해당 ID를 기반으로 데이터 저장소에서 User
객체를 가져올 것입니다. 이러한 유연성은 GraphQL의 주요 강점입니다.
서버 설정
마지막으로 GraphQL API를 노출하는 HTTP 서버를 설정해야 합니다. 프로젝트 루트에 server.go
파일을 만듭니다:
# server.go package main import ( "log" "net/http" "os" "github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/playground" "todo-graphql-server/graph" "todo-graphql-server/graph/model" ) const defaultPort = "8080" func main() { port := os.Getenv("PORT") if port == "" { port = defaultPort } // 일부 더미 데이터로 리졸버를 초기화합니다. resolver := &graph.Resolver{ odos: []*model.Todo{}, users: []*model.User{ {ID: "U1", Name: "Alice"}, {ID: "U2", Name: "Bob"}, }, } srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: resolver})) http.Handle("/", playground.Handler("GraphQL playground", "/query")) http.Handle("/query", srv) log.Printf("Connect to http://localhost:%s/ for GraphQL playground", port) log.Fatal(http.ListenAndServe(":"+port, nil)) }
server.go
에서 graph.Resolver
를 초기화하고 gqlgen
의 실행 가능한 스키마에 주입합니다. 그런 다음 두 개의 HTTP 핸들러를 설정합니다. 하나는 GraphQL 플레이그라운드(API 테스트를 위한 유용한 GUI)이고 다른 하나는 실제 GraphQL 엔드포인트입니다.
서버 실행 및 테스트
서버를 실행합니다:
go run server.go
브라우저에서 http://localhost:8080/
으로 이동합니다. GraphQL 플레이그라운드가 표시됩니다.
몇 가지 작업을 수행해 보겠습니다.
Todo 생성:
mutation CreateTodo { createTodo(text: "Learn gqlgen", userId: "U1") { id text done user { name } } }
모든 Todo 가져오기:
query GetTodos { todos { id text done user { id name } } }
Todo를 완료로 표시 (createTodo 변경에서 가져온 ID로 TODO_ID
를 대체):
mutation MarkTodoDone { markTodoDone(id: "TODO_ID") { id text done user { name } } }
반환되는 응답이 강력하게 타이핑되어 있고 쿼리에서 요청한 구조와 일치함을 확인할 수 있습니다. gqlgen
은 Go 리졸버 함수가 GraphQL 스키마에 정확히 일치하는 인수와 반환 값을 받도록 보장하여 전체 개발 프로세스에 걸쳐 탁월한 타입 안전성을 제공합니다.
애플리케이션 시나리오
gqlgen
을 사용한 이 타입 안전하고 스키마 우선적인 접근 방식은 다음과 같은 경우에 이상적입니다.
- 대규모 협업 팀: 스키마가 명확한 계약 역할을 하여 프런트엔드와 백엔드 팀이 병렬로 작업하고 오해를 줄이는 데 도움이 됩니다.
- 복잡한 API: API 표면이 늘어남에 따라 생성된 코드와 타입 안전성은 복잡성을 관리하고 미묘한 오류를 방지하는 데 도움이 됩니다.
- 마이크로서비스 아키텍처: GraphQL은 API 게이트웨이 역할을 하여 다양한 마이크로서비스에서 데이터를 집계할 수 있습니다.
gqlgen
을 사용하면 이 게이트웨이에 대한 통합 스키마를 정의하는 것이 간단합니다. - 공개 API: 잘 정의되고 타입 안전한 스키마는 클라이언트 라이브러리 생성을 단순화하고 API 소비자의 개발자 경험을 향상시킵니다.
결론
gqlgen
을 사용하여 Go에서 타입 안전하고 스키마 우선적인 GraphQL 서버를 구축하는 것은 개발 효율성, 강력한 타입 검사 및 유지보수 가능한 코드의 강력한 조합을 제공합니다. GraphQL 스키마를 확정적인 계약으로 시작함으로써 gqlgen
은 상용구 코드를 제거하고 Go 리졸버가 API 사양과 정확히 일치하도록 보장하여 버그를 줄이고 개발 경험을 향상시킵니다. 이 접근 방식은 유연한 데이터 가져오기와 내재된 Go 타입 안전성 사이의 격차를 해소하여 진화하는 API를 위한 견고한 기반을 제공합니다.