3.SW개발/GraphQL 배우기

9편. GraphQL Query & Mutation 기본 구조와 CRUD 실습

쿼드큐브 2025. 12. 2. 08:46
반응형
반응형

 

9편. GraphQL Query & Mutation 기본 구조와 CRUD 실습

 

📚 목차
1. Query와 Mutation의 개념 및 기본 문법
2. Arguments와 Input Type 설계 방법
3. CRUD 예제에 적용한 서비스 계층 구조
4. User 엔티티 기반 CRUD 실습
✔ 마무리 - Query와 Mutation, 실무로 가는 첫걸음

 

 

GraphQL을 처음 배우는 분들이 가장 먼저 마주하는 개념은 Query와 Mutation입니다. Query는 데이터를 읽는 역할, Mutation은 데이터를 쓰는(추가, 수정, 삭제) 역할을 담당합니다.

REST API에서도 GET, POST, PUT, DELETE 같은 HTTP 메서드로 구분했듯이, GraphQL도 기능에 따라 구조가 나뉩니다.

이번 글에서는 이 기본 개념을 실무 코드로 구현해 보고, 마지막에는 User 엔티티를 활용한 단순 CRUD 예제를 실행해 보겠습니다.

 


📂 [GitHub 실습 코드 보러가기] (https://github.com/cericube/graphql-tutorial-server)

 

1. Query와 Mutation의 개념 및 기본 문법

GraphQL에서 클라이언트가 서버로 요청하는 방식은 크게 Query와 Mutation으로 나뉩니다.


🔸 Query: 데이터를 조회(Read)할 때 사용합니다. 서버 상태를 변경하지 않습니다.
🔸 Mutation: 데이터를 생성(Create), 수정(Update), 삭제(Delete)할 때 사용합니다. 서버 상태를 변경합니다.

Query와 Mutation 개념
Query와 Mutation 개념

🔷 Query 예시

type Query {
  getUser(id: ID!): User
  listUsers: [User!]!
}

▸ getUser: 특정 ID의 User를 조회
▸ listUsers: 모든 User 목록 조회

 

✔️ 실행 예시

query {
  getUser(id: "1") {
    id
    name
    email
  }
}

원하는 필드만 선택해서 응답을 받을 수 있습니다.

 

🔷 Mutation 예시

type Mutation {
  createUser(name: String!, email: String!): User!
  updateUser(id: ID!, name: String, email: String): User!
  deleteUser(id: ID!): Boolean!
}

▸ createUser: 새로운 User 생성
▸ updateUser: 기존 User 정보 수정
▸ deleteUser: 특정 User 삭제

 

✔️ 실행 예시

mutation {
  createUser(name: "홍길동", email: "hong@test.com") {
    id
    name
  }
}

▸ Mutation도 Query처럼 원하는 필드만 요청할 수 있습니다.

 

2. Arguments와 Input Type 설계 방법

GraphQL에서 Arguments(인자)는 클라이언트가 서버에 요청을 보낼 때 필요한 데이터를 전달하는 방법입니다.

쉽게 말해, "이 요청을 처리하려면 어떤 값을 넘겨줘야 하는가?"를 정의하는 것이죠.

Arguments는 Query와 Mutation 모두에서 사용할 수 있으며, 주로 조회 시 필터 조건이나 생성·수정 시 입력 값을 전달하는 데 사용됩니다.

 

🔷 단일 인자 방식

단일 인자 방식은 하나의 필드에 필요한 값이 적고 구조가 단순할 때 사용합니다.
예를 들어 특정 사용자의 ID로 조회하는 경우입니다.

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

# 요청 예시
query {
  getUser(id: "1") {
    id
    name
    email
  }
}

📌 장점
▸ 직관적이고 간단함
▸ 필드별 인자가 바로 보이므로 스키마를 읽기 쉽다
📌 단점
▸ 인자가 많아지면 필드 선언이 복잡해짐
▸ 중첩 구조나 여러 필드를 함께 전달할 때 가독성이 떨어짐

 

🔷 다중 인자 방식

인자가 2개 이상 필요한 경우, 각 인자를 별도로 정의할 수 있습니다.

type Query {
  searchUsers(name: String, email: String): [User!]!
}

# 요청 예시
query {
  searchUsers(name: "홍길동", email: "hong@test.com") {
    id
    name
  }
}

▸ 단일 인자 방식보다 조금 더 복잡한 검색 조건을 처리 가능
▸ 하지만 인자가 많아질수록 스키마 관리가 번거로워질 수 있음

 

🔷 Input Type 방식

인자가 많아지거나, 중첩된 객체 구조를 전달해야 하거나, 여러 Mutation에서 동일한 인자 구조를 재사용해야 한다면 Input Type을 사용합니다.

input CreateUserInput {
  name: String!
  email: String!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
}

# 요청 예시
mutation {
  createUser(input: { name: "홍길동", email: "hong@test.com" }) {
    id
    name
  }
}

📌 장점
▸ 구조적 데이터 전달 가능 (중첩 데이터 포함)
▸ 재사용성 높음: 다른 Mutation에서도 같은 Input Type 활용 가능
▸ 유효성 검사(Validation) 적용 시 효율적
▸ 인자가 많아도 스키마가 깔끔하게 유지됨
📌 단점
▸ 단순 요청에는 오히려 코드가 길어질 수 있음
▸ Input Type을 추가로 정의해야 하므로 초기 설계 시 신경을 써야 함

 

🔷 Input Type 활용 예시: 복잡한 구조 전달

input AddressInput {
  street: String!
  city: String!
}

input CreateUserInput {
  name: String!
  email: String!
  address: AddressInput!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
}

# 요청 예시
mutation {
  createUser(
    input: {
      name: "홍길동",
      email: "hong@test.com",
      address: { street: "종로 1가", city: "서울" }
    }
  ) {
    id
    name
    email
  }
}

이 방식은 특히 회원가입, 주문 생성, 게시물 작성처럼 여러 필드와 중첩 데이터가 필요한 경우에 매우 유용합니다.

 

✔️ 실무 설계 팁

▸ 단일 인자 방식은 단순 조회나 식별자(ID) 기반 요청에 적합
▸ 다중 인자 방식은 조건이 여러 개인 검색 요청에 적합
▸ Input Type 방식은 데이터 구조가 복잡하거나, 재사용이 필요한 Mutation 설계에 필수
▸ Input Type은 파일 하나(*.schema.ts)에 모아서 관리하면 재사용성이 높음
▸ 필수 값(!) 여부를 설계 단계에서 확실히 정의해야 이후 API 안정성이 유지됨

 

3. CRUD 예제에 적용한 서비스 계층 구조

GraphQL 프로젝트에서 흔히 처음 시도하는 방식은 Resolver 내부에 모든 로직을 작성하는 것입니다.

간단한 예제라면 문제가 없지만, 실무로 가면 다음과 같은 문제가 발생합니다.

1. 코드 가독성 저하
▸ Resolver에 데이터베이스 쿼리, 유효성 검사, 변환 로직이 모두 뒤섞여 복잡해집니다.
2. 재사용성 부족
▸ 동일한 로직이 다른 Resolver나 Mutation에서 중복 작성됩니다.
3. 테스트 어려움
▸ 비즈니스 로직이 GraphQL 실행 흐름에 묶여 있어, 독립적인 단위 테스트(Unit Test)가 힘들어집니다.


그래서 [GraphQL 시리즈]에서는 서비스 계층(Service Layer) 분리를 기본 원칙으로 삼았습니다.

즉, Resolver는 요청을 받아 서비스에 위임하고, 서비스는 비즈니스 로직을 수행하는 구조입니다.

 

🔷 구조 예시

exam9/src/
├── modules/
│   └── user/
│       ├── user.schema.ts     // GraphQL 스키마 정의
│       ├── user.service.ts    // 비즈니스 로직 & 데이터 처리
│       └── user.resolver.ts   // 요청과 응답 처리

🔸user.schema.ts

▸ Query, Mutation, Input Type, Type 정의
▸ 서버의 데이터 구조와 요청 형식을 결정
🔸user.service.ts
▸ 실제 데이터 조작(생성, 조회, 수정, 삭제)
▸ 현재는 메모리 배열을 사용하지만, DB로 쉽게 교체 가능
▸ createUser, getUser, updateUser, deleteUser 등 CRUD 메서드 포함
🔸 user.resolver.ts
▸ 클라이언트 요청을 받아 해당 서비스 메서드 호출
▸ GraphQL 인자(Arguments)를 받아 Service에 전달
▸ Service에서 반환한 결과를 그대로 응답

 

🔷 적용 예시

🔸 서비스 계층 메서드 (user.service.ts)

createUser(name: string, email: string): User {
  const newUser = { id: String(this.idCounter++), name, email };
  this.users.push(newUser);
  return newUser;
}

▸ 새로운 User 객체를 생성하고 내부 저장소에 추가
▸ DB 연동 시 INSERT 쿼리 또는 ORM 메서드로 교체 가능

 

🔸 Resolver에서 서비스 호출 (user.resolver.ts)

아래 예제는 createUser Mutation에서 userService.createUser()를 호출하는 Resolver 코드입니다.
여기서 중요한 점은, GraphQL 스키마에 정의된 CreateUserInput 타입이 TypeScript 타입과 동일하지 않다는 사실입니다.

// CreateUserInput은 GraphQL 스키마(SDL) 안의 input 타입 이름일 뿐,
// TypeScript 세계의 타입이 아님
type CreateUserArgs = { input: { name: string; email: string } };

createUser: (_: unknown, { input }: CreateUserArgs) => {
  return userService.createUser(input.name, input.email);
},

▸ GraphQL SDL에서 정의한 input CreateUserInput은 서버 실행 시 스키마의 일부로만 존재합니다.

▸ TypeScript 컴파일러는 SDL 타입을 인식하지 못하므로, 별도로 TS 타입을 정의해야 합니다.

▸ 위 예제에서는 CreateUserArgs라는 타입을 만들어, input 객체의 name과 email이 문자열이며 필수 값임을 TypeScript 수준에서 보장합니다.

🔸(_: unknown, { input }: CreateUserArgs)

▸ 첫 번째 인자 _는 부모 객체(root)를 의미하지만 이 Mutation에서는 사용하지 않으므로 unknown 처리합니다.
▸ 두 번째 인자는 GraphQL 요청에서 전달되는 Arguments이며, 구조 분해 할당으로 input만 꺼내 사용합니다.


✔️ 왜 any를 쓰지 않는가?

any를 사용하면 타입 검증이 전혀 되지 않아서, 잘못된 속성명이나 타입이 들어와도 컴파일러가 오류를 잡지 못합니다.
예를 들어 아래처럼 nam(오타)로 요청해도 컴파일 단계에서 에러가 나지 않습니다.

createUser: (_: unknown, { input }: any) => {
  // input.nam <- 오타지만 TS는 경고하지 않음
}

반면 CreateUserArgs를 명시하면, 잘못된 속성명이나 타입을 사용할 경우 빌드 단계에서 즉시 오류가 발생합니다.

// ❌ 'nam' 속성이 존재하지 않음 오류
createUser: (_: unknown, { input }: CreateUserArgs) => {
  return userService.createUser(input.nam, input.email);
}

이렇게 하면 런타임 오류 가능성을 줄이고, IDE 자동완성도 지원받을 수 있어 개발 생산성이 향상됩니다.

 

✔️ 실무 팁

▸ GraphQL SDL의 Input Type과 TS 타입은 자동 변환되지 않으므로, type 또는 interface로 명시적으로 정의하는 습관을 들이세요.
▸ 대규모 프로젝트에서는 graphql-code-generator 같은 도구를 사용해 SDL에서 TypeScript 타입을 자동 생성하면 타입 정의 중복을 줄일 수 있습니다.
▸ unknown은 사용하지 않는 인자에 안전한 기본 타입으로 적합합니다. (any보다 안전하며, 사용하려면 반드시 타입 검증을 거쳐야 함)

 

4. User 엔티티 기반 CRUD 실습

지금까지 우리는 Query와 Mutation의 핵심 개념, 설계 원칙, 그리고 서비스 계층 분리 구조를 살펴보았습니다.
이제 이러한 이론을 실제 코드로 구현해 보겠습니다. 이번 실습에서는 User 엔티티를 대상으로 CRUD(Create, Read, Update, Delete) 기능을 완성합니다.


구현 환경은 GraphQL Yoga v5를 기반으로 하며, 데이터 저장소는 데이터베이스 대신 간단한 메모리 배열을 사용합니다.
이 방식은 서버를 재시작하면 데이터가 초기화되지만, 학습 단계에서 구조를 빠르게 이해하고 기능을 검증하기에 적합합니다.

 

1. user.schema.ts – 스키마 정의

User 타입과 CreateUserInput, UpdateUserInput 입력 타입을 정의하고, Query와 Mutation 타입을 선언합니다.
이 단계에서 API의 기능 명세서를 작성하는 것과 같습니다.

// src/modules/user/user.schema.ts

// GraphQL 스키마 정의
// - User 타입과 관련된 Query, Mutation, Input 타입을 선언
// - 이 파일은 서버에서 사용할 데이터 구조와 요청 형태를 명세하는 역할을 함
export const userTypeDefs = /* GraphQL */ `
  # User 객체 타입 정의
  # 클라이언트가 요청 시 받을 수 있는 User 데이터 구조
  type User {
    id: ID! # 고유 식별자 (필수)
    name: String! # 사용자 이름 (필수)
    email: String! # 사용자 이메일 (필수)
  }

  # 사용자 생성 시 입력받을 데이터 구조
  # - name과 email은 필수 값
  input CreateUserInput {
    name: String!
    email: String!
  }

  # 사용자 수정 시 입력받을 데이터 구조
  # - id는 필수 (수정할 대상을 식별)
  # - name과 email은 선택적으로 수정 가능
  input UpdateUserInput {
    id: ID!
    name: String
    email: String
  }

  # 데이터 조회를 위한 Query 타입 정의
  type Query {
    # ID로 특정 사용자 조회
    getUser(id: ID!): User

    # 전체 사용자 목록 조회
    listUsers: [User!]!
  }

  # 데이터 생성·수정·삭제를 위한 Mutation 타입 정의
  type Mutation {
    # 새로운 사용자 생성
    createUser(input: CreateUserInput!): User!

    # 기존 사용자 정보 수정
    updateUser(input: UpdateUserInput!): User!

    # 사용자 삭제 (성공 시 true 반환)
    deleteUser(id: ID!): Boolean!
  }
`;

 

2. user.service.ts – 서비스 계층

데이터를 직접 관리하고, 비즈니스 로직을 담당하는 부분입니다. 실무에서는 이곳에서 DB 쿼리를 실행하거나 외부 API를 호출합니다.
이번 예제에서는 메모리 배열을 활용해 상태를 유지합니다.

// /src/modules/user/user.service.ts

// User 데이터 타입 정의
// - 실제 서비스에서는 DB 모델 또는 Prisma Schema와 매핑될 수 있음
type User = {
  id: string; // 고유 식별자
  name: string; // 사용자 이름
  email: string; // 사용자 이메일
};

// 사용자 관련 비즈니스 로직을 담당하는 서비스 클래스
// - 메모리 배열을 데이터 저장소로 사용 (학습/테스트용)
// - 실무에서는 DB 접근 코드(ORM, SQL 쿼리 등)로 대체 가능
export class UserService {
  // 메모리 기반 사용자 목록 저장소
  private users: User[] = [];

  // ID 생성을 위한 카운터 (DB 사용 시 자동 증가 컬럼/UUID로 대체)
  private idCounter = 1;

  /**
   * 사용자 생성
   * @param name - 사용자 이름
   * @param email - 사용자 이메일
   * @returns 생성된 사용자 객체
   */
  createUser(name: string, email: string): User {
    const newUser = { id: String(this.idCounter++), name, email };
    this.users.push(newUser);
    return newUser;
  }

  /**
   * ID로 사용자 조회
   * @param id - 조회할 사용자 ID
   * @returns 사용자 객체 또는 undefined (없으면)
   */
  getUser(id: string): User | undefined {
    return this.users.find((user) => user.id === id);
  }

  /**
   * 전체 사용자 목록 조회
   * @returns 사용자 배열
   */
  listUser(): User[] {
    return this.users;
  }

  /**
   * 사용자 정보 수정
   * @param id - 수정할 사용자 ID
   * @param name - 변경할 이름 (선택)
   * @param email - 변경할 이메일 (선택)
   * @returns 수정된 사용자 객체
   * @throws 사용자 미존재 시 에러 발생
   */
  updateUser(id: string, name?: string, email?: string): User {
    const user = this.getUser(id);
    if (!user) throw new Error('User not found'); // 존재하지 않으면 예외 발생
    if (name) user.name = name; // name이 전달되면 변경
    if (email) user.email = email; // email이 전달되면 변경
    return user;
  }

  /**
   * 사용자 삭제
   * @param id - 삭제할 사용자 ID
   * @returns 삭제 성공 여부 (true: 삭제됨, false: 해당 ID 없음)
   */
  deleteUser(id: string): boolean {
    const index = this.users.findIndex((user) => user.id === id);
    if (index === -1) return false; // 삭제 대상이 없으면 false 반환
    this.users.splice(index, 1); // 해당 인덱스에서 1개 요소 제거
    return true;
  }
}

 

3. user.resolver.ts – 요청 처리

리졸버는 클라이언트 요청을 받아 서비스 계층을 호출하고, 결과를 반환합니다.
GraphQL의 각 필드에 대응되는 함수라고 생각하면 됩니다.

import { UserService } from './user.service';

// GraphQL 스키마(SDL)에 정의된 input 타입은 TypeScript에서 직접 인식되지 않음
// 따라서, Resolver 함수 인자 타입을 별도로 TypeScript에서 정의해야 함

// CreateUserArgs: createUser Mutation에서 받을 인자의 타입
type CreateUserArgs = { input: { name: string; email: string } };

// UpdateUserArgs: updateUser Mutation에서 받을 인자의 타입
// name, email은 선택적(optional) 속성
type UpdateUserArgs = { input: { id: string; name?: string; email?: string } };

// UserService 인스턴스 생성
// 실제 데이터 처리 로직은 서비스 계층(UserService)에 위임
const userService = new UserService();

// GraphQL Resolver 객체
// - Query와 Mutation 필드를 각각 구현
// - 각 필드명은 GraphQL 스키마에 정의된 Query/Mutation 이름과 동일해야 함
export const userResolvers = {
  Query: {
    /**
     * 단일 사용자 조회
     * @param _ - parent(부모 리졸버 결과), 여기서는 사용하지 않으므로 unknown 처리
     * @param id - args 객체에서 구조 분해 할당으로 추출
     * @returns 해당 ID의 사용자 객체 (없으면 undefined)
     */
    getUser: (_: unknown, { id }: { id: string }) => {
      return userService.getUser(id);
    },

    /**
     * 전체 사용자 목록 조회
     * @returns User 객체 배열
     */
    listUsers: () => {
      return userService.listUser();
    },
  },

  Mutation: {
    /**
     * 새로운 사용자 생성
     * @param _ - parent, 사용하지 않음
     * @param input - name, email 값을 포함하는 CreateUserArgs 타입 객체
     * @returns 생성된 User 객체
     */
    createUser: (_: unknown, { input }: CreateUserArgs) => {
      return userService.createUser(input.name, input.email);
    },

    /**
     * 기존 사용자 정보 수정
     * @param _ - parent, 사용하지 않음
     * @param input - id(필수)와 변경할 name/email(선택)
     * @returns 수정된 User 객체
     */
    updateUser: (_: unknown, { input }: UpdateUserArgs) => {
      return userService.updateUser(input.id, input.name, input.email);
    },

    /**
     * 사용자 삭제
     * @param _ - parent, 사용하지 않음
     * @param id - 삭제할 사용자 ID
     * @returns 삭제 성공 여부 (true: 삭제됨, false: 해당 ID 없음)
     */
    deleteUser: (_: unknown, { id }: { id: string }) => {
      return userService.deleteUser(id);
    },
  },
};

 

4. src/schema.ts, src/resolvers.ts, src/index.ts,
참조: 6편. GraphQL Scalar 타입 완전 정복

 

✔️ Playground 테스트 예시

서버를 실행한 뒤, http://localhost:4000/graphql 에 접속하여 다음 요청을 실행해 보세요

PS D:\GraphQL\graphql-tutorial-server\ch09> npx ts-node .\src\index.ts
Server ready at http://localhost:4000/graphql
1 .Mutation 예시 – User 생성
mutation {
  createUser(input: { name: "홍길동", email: "hong@test.com" }) {
    id
    name
    email
  }
}

2. Query 예시 – User 목록 조회
query {
  listUsers {
    id
    name
    email
  }
}

3. Mutation 예시 – User 수정
mutation {
  updateUser(input: { id: "1", name: "이몽룡" }) {
    id
    name
    email
  }
}

4. Mutation 예시 – User 삭제 
mutation {
  deleteUser(id: "1")
}

Playground 쿼리 실습 결과 예시
Playground 쿼리 실습 결과 예시

✔️ 실무 확장 포인트

▸ 현재 예제는 메모리 기반이므로 서버 재시작 시 데이터가 초기화됩니다.
▸ 실무에서는 Prisma ORM을 연결해 실제 데이터베이스(PostgreSQL, MySQL 등)에 저장하는 방식으로 확장합니다.
▸ 인증/권한 로직을 Mutation에 추가하면 보안성을 높일 수 있습니다.
▸ Validation 라이브러리(class-validator 등)를 사용해 Input 데이터 유효성 검사를 강화할 수 있습니다.

 

✔ 마무리 - Query와 Mutation, 실무로 가는 첫걸음

이번 글에서는 GraphQL의 핵심 요청 방식인 Query와 Mutation을 개념부터 기본 문법, 그리고 User 엔티티 기반 CRUD 예제까지 구현해 보았습니다.

실무에서 이 두 가지를 적용할 때 가장 중요한 점은 다음과 같습니다.
🔸 역할 분리
▸ Query는 데이터 조회 전용, Mutation은 데이터 변경 전용으로 설계해 API의 의도를 명확히 해야 합니다.
▸ 설계 단계에서 이 구분이 모호하면, 이후 유지보수 시 로직이 복잡해지고 캐싱 전략 수립이 어려워집니다.

 

🔸 Input Type 적극 활용
▸ 인자가 많거나 구조가 복잡한 요청은 Input Type으로 정의하면 재사용성과 가독성이 높아집니다.
▸ 특히 Mutation에서는 Input Type을 사용하면 유효성 검사와 스키마 확장이 용이합니다.

 

🔸 서비스 계층 분리
▸ Resolver에서 직접 로직을 구현하지 말고, 서비스 계층으로 위임해 테스트 가능성과 확장성을 확보합니다.
▸ 이는 DB 연동, 인증/권한 추가, 로깅 기능 확장 시 큰 이점을 줍니다.

 

🔸 개발 환경 최적화

▸ 타입 안정성을 위해 GraphQL SDL의 타입과 TypeScript 타입을 명시적으로 연결하거나, graphql-code-generator 같은 도구를 사용해 자동 생성하는 것을 권장합니다.
▸ Playground나 GraphQL IDE를 활용해 설계 단계부터 요청/응답을 시뮬레이션하면 개발 속도와 품질이 향상됩니다.


📂 [GitHub 실습 코드 보러가기] (https://github.com/cericube/graphql-tutorial-server)


 

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

반응형

 

반응형