3.SW개발/GraphQL 배우기

5편. GraphQL 스키마와 리졸버 구조 이해

쿼드큐브 2025. 11. 30. 12:50
반응형
반응형

 

5편. GraphQL 스키마와 리졸버 구조 이해

 

📚 목차
1. Query / Mutation / ObjectType 구조
2. 필드, 리턴 타입, 인자 정의 방법
3. 리졸버 함수 작성 및 스키마의 연결 방식
4. 스키마 설계 시 고려사항 (명확성/일관성/확장성)
✔ 마무리 - GraphQL 스키마와 리졸버의 실무 핵심

 

GraphQL의 핵심은 스키마(Schema)입니다.

스키마는 서버에서 제공하는 데이터의 형태와 기능을 선언적으로 정의하는 계약서 역할을 하며, 클라이언트가 요청할 수 있는 쿼리(Query)와 변형(Mutation), 그리고 반환 데이터의 구조(ObjectType)를 명시합니다.


이 장에서는 GraphQL 스키마를 구성하는 주요 요소와, 각 필드 정의 방식, 그리고 이를 실제 데이터 처리 로직(리졸버)과 연결하는 과정을 실습 코드와 함께 살펴봅니다. 마지막으로, 실무에서 스키마 설계 시 반드시 고려해야 할 세 가지 원칙도 정리합니다.

 

1. Query / Mutation / ObjectType 구조

GraphQL 스키마는 기본적으로 다음 세 가지 루트 타입으로 구성됩니다

🔸Query: 클라이언트가 데이터를 조회할 때 사용하는 진입점입니다.
🔸Mutation:
데이터를 생성·수정·삭제하는 등 변경 작업을 처리하는 루트 타입입니다.
🔸ObjectType:
사용자 정의 타입으로, 반환되는 객체의 필드와 구조를 정의합니다.

 

아래는 사용자(User) 정보를 조회하고 생성하는 가장 단순한 형태의 스키마 예시입니다.

// src/modules/user/user.schema.ts
import { createSchema } from 'graphql-yoga'

export const userTypeDefs = /* GraphQL */ `
  type User {
    id: ID!
    name: String!
    email: String!
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
  }

  type Mutation {
    createUser(name: String!, email: String!): User!
  }
`

▸ type User: 사용자 정보를 표현하는 ObjectType입니다. 각 필드는 id, name, email을 필수(!)로 정의했습니다.

▸ type Query:

- users: 모든 사용자를 배열로 반환합니다.
- user(id: ID!): 특정 ID 값을 전달받아 해당 사용자를 반환합니다.

▸ type Mutation:

- createUser: name과 email을 인자로 받아 새로운 사용자 객체를 생성해 반환합니다.

 

실무에서는 이 세 가지 루트 타입이 서비스 데이터 흐름의 중심이 되며, 모든 요청은 Query나 Mutation에서 시작해 정의된 ObjectType을 통해 구조화된 응답으로 반환됩니다.

 

2. 필드, 리턴 타입, 인자 정의 방법

GraphQL에서 각 필드(Field)는 단순히 값을 나타내는 요소가 아니라, 인자(Arguments)와 반환 타입(Return Type)까지 함께 정의하는 구조입니다.

이 방식 덕분에 클라이언트는 어떤 데이터를 어떤 조건으로 요청할 수 있는지, 그리고 그 응답의 형태가 무엇인지 스키마만 보고도 명확하게 파악할 수 있습니다.

 

다음은 단일 사용자 정보를 조회하는 쿼리 예시입니다.

type Query {
  user(id: ID!): User
}

▸ user: 필드 이름이며, 클라이언트가 요청할 수 있는 엔드포인트 역할을 합니다.
▸ (id: ID!): 인자 정의, ID 타입을 사용하며, !는 필수 값임을 의미합니다.
▸ : User: 반환 타입을 나타냅니다. 여기서는 User 객체를 반환합니다.

 

✔️ Null 허용 여부와 !의 의미
GraphQL에서 느낌표(!)는 null을 허용하지 않음을 나타냅니다.
▸ ID! → ID 값이 반드시 존재해야 함
▸ [User!]! → 배열의 각 항목과 배열 자체 모두 null이 될 수 없음

 

예를 들어

users: [User!]!

이 선언은 "null이 아닌 User 객체들로 구성된, null이 될 수 없는 배열"이라는 의미입니다.

이 덕분에 클라이언트는 응답 구조의 안정성을 보장받을 수 있습니다.

 

✔️ 실무 적용 팁

🔸 필수 값만 !를 사용하여 API 유연성을 유지합니다.

🔸 배열 타입에서는 항목과 배열 자체의 null 허용 여부를 명확히 지정합니다.
🔸 인자가 여러 개이거나 필터 구조가 복잡한 경우 Input Object 타입을 활용하면 가독성과 유지보수성이 향상됩니다.

 

이러한 타입 지정 규칙은 개발자 간 협업 시 혼선을 줄이고, 클라이언트 측에서 타입 안전성을 높이는 중요한 역할을 합니다.

 

3. 리졸버 함수 작성 및 스키마 연결 방식

GraphQL 스키마는 선언만으로는 동작하지 않습니다.

각 필드에 대응되는 리졸버(Resolver) 함수를 작성해야 실제 데이터 조회·변경 요청을 처리할 수 있습니다.

리졸버는 스키마와 데이터 소스(DB, API, 캐시 등)를 연결하는 실행 로직의 핵심입니다.

리졸버와 스키마의 연결 구조 개념
리졸버와 스키마의 연결 구조 개념

🔷 리졸버 함수의 기본 구조

GraphQL의 리졸버 함수는 고정된 시그니처를 갖습니다.
다음과 같은 네 개의 인자를 받습니다

(fieldName): (parent, args, context, info) => { ... }
인자 역할 설명
parent 상위 필드의 리턴 값. Nested Field에서 주로 사용되며, 최상위(Query/Mutation)에서는 보통 null 또는 undefined
args GraphQL 쿼리에서 클라이언트가 전달한 인자 객체. 예: user(id: "1") 호출 시 { id: "1" }
context 요청당 공유되는 컨텍스트 객체. 예: PrismaClient, 인증 유저 정보, 설정 값 등을 포함
info GraphQL 내부 실행 정보. AST(Abstract Syntax Tree), 필드 선택 정보 등이 들어있어 고급 기능에 사용

 

🔷리졸버 작성 예시
초기 구조를 이해하기 위해 데이터베이스 없이 메모리 배열과 하드코딩 값으로 동작하는 예제를 살펴보겠습니다.

// src/modules/user/user.resolver.ts
export const userResolvers = {
  Query: {
    users: () => {
      return [
        { id: '1', name: 'Alice', email: 'alice@example.com' },
        { id: '2', name: 'Bob', email: 'bob@example.com' }
      ]
    },
    user: (_: unknown, args: { id: string }) => {
      return { id: args.id, name: 'Temp', email: 'temp@example.com' }
    }
  },

  Mutation: {
    createUser: (_: unknown, args: { name: string; email: string }) => {
      return {
        id: Math.random().toString(),
        name: args.name,
        email: args.email
      }
    }
  }
}

 

✔️users Query

users: () => [ ... ]

▸ 클라이언트가 users 필드를 요청했을 때, 모든 사용자 객체를 배열로 반환합니다.
▸ 현재는 메모리에 고정된 배열로 응답하지만, 이후 Prisma의 findMany()와 같은 DB 연동 로직으로 대체됩니다.

 

✔️user Query

user: (_: unknown, args: { id: string }) => { ... }

▸ 클라이언트가 user(id: \"1\") 요청 시, 전달된 args.id 값을 활용해 단일 사용자 데이터를 반환합니다.
▸ 여기선 가짜 데이터지만, 실무에선 prisma.user.findUnique({ where: { id: Number(args.id) } })와 같은 DB 쿼리로 대체됩니다.

 

✔️createUser Mutation

createUser: (_: unknown, args: { name: string; email: string }) => { ... }

▸ 사용자 이름과 이메일을 인자로 받아 새로운 사용자 객체를 생성해 반환합니다.
▸ 현재는 id를 Math.random()으로 생성하지만, 실무에서는 DB의 자동 증가 필드로 대체됩니다.

 

🔷 리졸버를 스키마에 연결하는 방법

스키마 정의와 리졸버를 연결해야 서버가 클라이언트 요청을 처리할 수 있습니다.
graphql-yoga에서는 createSchema() 함수를 통해 이들을 연결합니다.

// src/schema.ts
import { createSchema } from 'graphql-yoga'
import { userTypeDefs } from './modules/user/user.schema'
import { userResolvers } from './modules/user/user.resolver'

export const schema = createSchema({
  typeDefs: [userTypeDefs],
  resolvers: [userResolvers]
})

▸ typeDefs: GraphQL SDL 형식의 스키마 정의 모음
▸ resolvers: 각 필드에 대응되는 실행 로직

 

🔷 요청 처리 흐름 예시

클라이언트에서 다음과 같은 쿼리를 전송했다고 가정해 봅니다:

query {
  users {
    id
    name
  }
}

GraphQL 서버는 다음과 같은 흐름으로 요청을 처리합니다:

1. type Query에서 users 필드 정의를 찾음
2. 해당 필드에 연결된 userResolvers.Query.users 함수 실행
3. 사용자 배열 반환
4. 요청한 필드(id, name)만 추출해 응답 생성
5. 클라이언트에 JSON 형식으로 전송

 

이처럼 리졸버는 GraphQL 스키마에 생명력을 불어넣는 핵심 구성 요소이며, 스키마와의 연결을 통해 클라이언트의 요청 → 실제 데이터 처리 → 응답의 흐름이 완성됩니다.

 

✔️ GraphQL 리졸버 작동 흐름도

GraphQL 리졸버 작동 흐름도
GraphQL 리졸버 작동 흐름도

 

① GraphQL Playground 또는 프론트엔드에서 쿼리를 요청합니다.

② 서버는 type Query 안의 필드에서 users를 찾습니다.

③ 해당 필드에 연결된 리졸버 함수 userResolvers.Query.users()가 실행됩니다.

④ 리졸버는 사용자 객체 배열을 반환합니다.

⑤ GraphQL은 쿼리에서 요청한 필드(id, name)만 추려서 응답에 포함시킵니다.

⑥ 요청한 구조대로 data.users 배열이 응답으로 전송됩니다

 

✔️ 실무 팁

리졸버 로직이 길어지면 서비스 레이어로 분리해 테스트 가능성과 유지보수성을 높이는 것이 좋습니다.

예를 들어 userService.getAll() 같은 별도 모듈로 위임하면, 스키마·리졸버 정의와 비즈니스 로직을 명확히 구분할 수 있습니다.

 

4. 스키마 설계 시 고려사항 (명확성/일관성/확장성)

GraphQL 스키마는 단순한 타입 선언이 아니라, 프론트엔드와 백엔드 간 데이터 계약서입니다.

스키마 구조가 명확하고 일관적이며 확장 가능해야 API 품질이 유지되고, 변경 시에도 영향 범위를 최소화할 수 있습니다.

실무에서 반드시 고려해야 할 세 가지 핵심 원칙은 다음과 같습니다.

 

🔷 1. 명확성 (Clarity)

▸ 필드명과 인자명은 의미가 즉시 이해될 수 있도록 직관적으로 작성해야 합니다.
▸ 불필요하게 긴 접두어나 동사형 표현은 지양합니다. GraphQL은 데이터 질의 언어이므로 REST 스타일의 get / create 같은 동사를 넣을 필요가 없습니다.

# 권장하지 않음
type Query {
  getUserById(id: ID!): User
}

# 권장
type Query {
  user(id: ID!): User
}

명확한 네이밍은 API 문서를 별도로 확인하지 않아도 스키마만 보고도 데이터 구조를 이해할 수 있게 합니다.

 

🔷 2. 일관성 (Consistency)

프로젝트 전체에서 네이밍 규칙을 통일해야 합니다.
일반적으로 다음 패턴을 사용합니다.

🔸 타입명: PascalCase → User, Product

🔸 필드명: camelCase → createdAt, emailVerified

🔸 Enum 값: 대문자 + 스네이크 케이스 → PUBLISHED_DRAFT, ACTIVE_USER

enum UserStatus {
  ACTIVE
  INACTIVE
}

네이밍 컨벤션이 일관되면 코드 리뷰 시 불필요한 논쟁이 줄고, 자동완성 기능 활용 시 생산성이 높아집니다.

 

🔷 3. 확장성 (Scalability)

▸ 인자가 많거나 필터 조건이 복잡할 경우, InputObject를 사용해 구조화하면 유지보수성이 향상됩니다.
▸ 고정된 옵션값은 문자열 대신 Enum 타입을 사용하여 안정성을 확보합니다.

input UserFilterInput {
  status: UserStatus
  keyword: String
  isActive: Boolean
}

enum UserStatus {
  ACTIVE
  INACTIVE
}

type Query {
  users(filter: UserFilterInput): [User!]!
}

✔️ 장점

▸ 새로운 필터 조건을 추가하더라도 기존 필드의 시그니처를 변경할 필요가 없습니다.

▸ 클라이언트 측에서는 자동완성 기능을 그대로 활용할 수 있습니다.
▸ API 문서화 과정이 간단해지고 유지보수 효율이 높아집니다.

 

✔️ 실무 팁

스키마 변경은 API 계약 변경과 동일하므로, 팀 내 리뷰 절차를 거쳐야 하며, 가능하면 버전 관리(Versioning) 전략을 병행하는 것이 안전합니다.

 

✔ 마무리 - GraphQL 스키마와 리졸버의 실무 핵심

GraphQL 스키마는 API의 데이터 계약서로, 클라이언트와 서버 간 요청·응답 구조를 정의하는 핵심입니다.

실무에서는 다음 원칙을 중심으로 설계·구현하는 것이 중요합니다.

 

1. 루트 타입 명확화
🔸 Query: 조회

🔸 Mutation: 변경

🔸 ObjectType: 응답 구조 정의


2. 엄격한 타입 지정

🔸 !로 null 허용 여부 명시

🔸 InputObject와 Enum으로 인자 구조 확장

3. 리졸버 연결

🔸 각 필드에 대응 리졸버 작성

🔸 비즈니스 로직은 서비스 레이어로 분리

🔸 매개변수 타입을 명시해 안정성 확보

4. 설계 원칙 준수

🔸 명확성, 일관성, 확장성 유지

 

결국, 잘 설계된 스키마는 단순 정의를 넘어 협업 표준이 되어, API 변경 리스크를 줄이고 예측 가능한 개발 환경을 제공합니다.

 


※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

 

반응형