GraphQL

김민범·2025년 6월 11일

AI

목록 보기
4/12

GraphQL은 API를 위한 쿼리 언어이자 런타임으로, Facebook에서 2012년에 개발하고 2015년에 오픈소스로 공개했습니다. REST API의 한계를 극복하기 위해 만들어졌으며, 클라이언트가 필요한 데이터만 정확히 요청할 수 있게 해줍니다.

GraphQL의 핵심 개념

1. 단일 엔드포인트

REST API는 여러 엔드포인트를 가지지만, GraphQL은 보통 /graphql 하나의 엔드포인트만 사용합니다.

2. 스키마 기반

모든 데이터 구조와 가능한 작업을 스키마로 정의합니다. 스키마는 API의 계약서 역할을 합니다.

3. 세 가지 작업 타입

  • Query: 데이터 조회
  • Mutation: 데이터 변경 (생성, 수정, 삭제)
  • Subscription: 실시간 데이터 구독

4. 타입 시스템

GraphQL은 강력한 타입 시스템을 제공합니다:

  • Scalar Types: String, Int, Float, Boolean, ID
  • Object Types: 커스텀 객체 타입
  • Enum Types: 열거형
  • Interface Types: 인터페이스
  • Union Types: 유니온 타입

REST API vs GraphQL 비교

Over-fetching 문제 해결

// REST API - 사용자 정보 전체를 받음
GET /api/users/1
{
  "id": 1,
  "name": "홍길동",
  "email": "hong@example.com",
  "phone": "010-1234-5678",
  "address": "서울시 강남구...",
  "createdAt": "2023-01-01",
  // ... 많은 불필요한 데이터
}

// GraphQL - 필요한 데이터만 요청
query {
  user(id: 1) {
    name
    email
  }
}

Under-fetching 문제 해결

// REST API - 여러 요청 필요
GET /api/users/1        // 사용자 정보
GET /api/users/1/posts  // 사용자의 포스트
GET /api/posts/1/comments // 포스트의 댓글

// GraphQL - 한 번의 요청으로 해결
query {
  user(id: 1) {
    name
    posts {
      title
      comments {
        content
        author {
          name
        }
      }
    }
  }
}

Spring Boot GraphQL 서버 예시

1. 의존성 추가 (build.gradle)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-graphql'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'com.h2database:h2'
}

2. 엔티티 클래스

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false, unique = true)
    private String email;
    
    @OneToMany(mappedBy = "author", cascade = CascadeType.ALL)
    private List<Post> posts = new ArrayList<>();
    
    // 생성자, getter, setter
    public User() {}
    
    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }
    
    // getters and setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    
    public List<Post> getPosts() { return posts; }
    public void setPosts(List<Post> posts) { this.posts = posts; }
}

@Entity
@Table(name = "posts")
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String title;
    
    @Column(columnDefinition = "TEXT")
    private String content;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private User author;
    
    @CreationTimestamp
    private LocalDateTime createdAt;
    
    // 생성자, getter, setter
    public Post() {}
    
    public Post(String title, String content, User author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }
    
    // getters and setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
    
    public User getAuthor() { return author; }
    public void setAuthor(User author) { this.author = author; }
    
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

3. Repository 인터페이스

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    List<Post> findByAuthorId(Long authorId);
}

4. GraphQL 스키마 정의 (src/main/resources/graphql/schema.graphqls)

type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
}

type Post {
    id: ID!
    title: String!
    content: String
    author: User!
    createdAt: String!
}

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

input CreatePostInput {
    title: String!
    content: String
    authorId: ID!
}

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

type Mutation {
    createUser(input: CreateUserInput!): User!
    createPost(input: CreatePostInput!): Post!
    deleteUser(id: ID!): Boolean!
}

5. GraphQL Controller (Resolver)

@Controller
public class UserController {
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private PostRepository postRepository;
    
    // Query resolvers
    @QueryMapping
    public List<User> users() {
        return userRepository.findAll();
    }
    
    @QueryMapping
    public Optional<User> user(@Argument Long id) {
        return userRepository.findById(id);
    }
    
    @QueryMapping
    public List<Post> posts() {
        return postRepository.findAll();
    }
    
    @QueryMapping
    public Optional<Post> post(@Argument Long id) {
        return postRepository.findById(id);
    }
    
    // Mutation resolvers
    @MutationMapping
    public User createUser(@Argument CreateUserInput input) {
        User user = new User(input.getName(), input.getEmail());
        return userRepository.save(user);
    }
    
    @MutationMapping
    public Post createPost(@Argument CreatePostInput input) {
        User author = userRepository.findById(input.getAuthorId())
            .orElseThrow(() -> new RuntimeException("User not found"));
        
        Post post = new Post(input.getTitle(), input.getContent(), author);
        return postRepository.save(post);
    }
    
    @MutationMapping
    public Boolean deleteUser(@Argument Long id) {
        if (userRepository.existsById(id)) {
            userRepository.deleteById(id);
            return true;
        }
        return false;
    }
    
    // Field resolvers (N+1 문제 해결을 위한 DataLoader 사용 권장)
    @SchemaMapping
    public List<Post> posts(User user) {
        return postRepository.findByAuthorId(user.getId());
    }
}

// Input 클래스들
public class CreateUserInput {
    private String name;
    private String email;
    
    // getters and setters
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}

public class CreatePostInput {
    private String title;
    private String content;
    private Long authorId;
    
    // getters and setters
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
    
    public Long getAuthorId() { return authorId; }
    public void setAuthorId(Long authorId) { this.authorId = authorId; }
}

6. 설정 파일 (application.yml)

spring:
  graphql:
    graphiql:
      enabled: true
      path: /graphiql
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password: 
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
  h2:
    console:
      enabled: true

Next.js TypeScript 프론트엔드 예시

1. 패키지 설치

npm install @apollo/client graphql
npm install @types/node typescript

2. Apollo Client 설정 (lib/apollo-client.ts)

import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';

const httpLink = createHttpLink({
  uri: 'http://localhost:8080/graphql',
});

const client = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      errorPolicy: 'ignore',
    },
    query: {
      errorPolicy: 'all',
    },
  },
});

export default client;

3. 타입 정의 (types/graphql.ts)

export interface User {
  id: string;
  name: string;
  email: string;
  posts: Post[];
}

export interface Post {
  id: string;
  title: string;
  content?: string;
  author: User;
  createdAt: string;
}

export interface CreateUserInput {
  name: string;
  email: string;
}

export interface CreatePostInput {
  title: string;
  content?: string;
  authorId: string;
}

4. GraphQL 쿼리 및 뮤테이션 정의 (graphql/queries.ts)

import { gql } from '@apollo/client';

export const GET_USERS = gql`
  query GetUsers {
    users {
      id
      name
      email
      posts {
        id
        title
        createdAt
      }
    }
  }
`;

export const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      posts {
        id
        title
        content
        createdAt
      }
    }
  }
`;

export const GET_POSTS = gql`
  query GetPosts {
    posts {
      id
      title
      content
      createdAt
      author {
        id
        name
        email
      }
    }
  }
`;

export const CREATE_USER = gql`
  mutation CreateUser($input: CreateUserInput!) {
    createUser(input: $input) {
      id
      name
      email
    }
  }
`;

export const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      id
      title
      content
      createdAt
      author {
        id
        name
      }
    }
  }
`;

export const DELETE_USER = gql`
  mutation DeleteUser($id: ID!) {
    deleteUser(id: $id)
  }
`;

5. App Provider 설정 (pages/_app.tsx)

import type { AppProps } from 'next/app';
import { ApolloProvider } from '@apollo/client';
import client from '../lib/apollo-client';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ApolloProvider client={client}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}

6. 사용자 목록 페이지 (pages/users.tsx)

import { useState } from 'react';
import { useQuery, useMutation } from '@apollo/client';
import { GET_USERS, CREATE_USER, DELETE_USER } from '../graphql/queries';
import { User, CreateUserInput } from '../types/graphql';

export default function UsersPage() {
  const [formData, setFormData] = useState<CreateUserInput>({
    name: '',
    email: '',
  });

  const { data, loading, error, refetch } = useQuery<{ users: User[] }>(GET_USERS);
  const [createUser, { loading: creating }] = useMutation(CREATE_USER);
  const [deleteUser] = useMutation(DELETE_USER);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      await createUser({
        variables: { input: formData },
      });
      setFormData({ name: '', email: '' });
      refetch(); // 목록 새로고침
    } catch (error) {
      console.error('사용자 생성 실패:', error);
    }
  };

  const handleDelete = async (id: string) => {
    if (confirm('정말 삭제하시겠습니까?')) {
      try {
        await deleteUser({ variables: { id } });
        refetch();
      } catch (error) {
        console.error('사용자 삭제 실패:', error);
      }
    }
  };

  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>에러 발생: {error.message}</div>;

  return (
    <div style={{ padding: '20px' }}>
      <h1>사용자 관리</h1>
      
      {/* 사용자 생성 폼 */}
      <form onSubmit={handleSubmit} style={{ marginBottom: '30px' }}>
        <h2>새 사용자 추가</h2>
        <div style={{ marginBottom: '10px' }}>
          <input
            type="text"
            placeholder="이름"
            value={formData.name}
            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
            required
            style={{ marginRight: '10px', padding: '5px' }}
          />
          <input
            type="email"
            placeholder="이메일"
            value={formData.email}
            onChange={(e) => setFormData({ ...formData, email: e.target.value })}
            required
            style={{ marginRight: '10px', padding: '5px' }}
          />
          <button type="submit" disabled={creating}>
            {creating ? '생성 중...' : '사용자 추가'}
          </button>
        </div>
      </form>

      {/* 사용자 목록 */}
      <h2>사용자 목록</h2>
      <div>
        {data?.users.map((user) => (
          <div
            key={user.id}
            style={{
              border: '1px solid #ccc',
              padding: '15px',
              marginBottom: '10px',
              borderRadius: '5px',
            }}
          >
            <h3>{user.name}</h3>
            <p>이메일: {user.email}</p>
            <p>포스트 수: {user.posts.length}</p>
            <button
              onClick={() => handleDelete(user.id)}
              style={{
                backgroundColor: '#ff4444',
                color: 'white',
                border: 'none',
                padding: '5px 10px',
                borderRadius: '3px',
                cursor: 'pointer',
              }}
            >
              삭제
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}

7. 포스트 목록 페이지 (pages/posts.tsx)

import { useState } from 'react';
import { useQuery, useMutation } from '@apollo/client';
import { GET_POSTS, GET_USERS, CREATE_POST } from '../graphql/queries';
import { Post, User, CreatePostInput } from '../types/graphql';

export default function PostsPage() {
  const [formData, setFormData] = useState<CreatePostInput>({
    title: '',
    content: '',
    authorId: '',
  });

  const { data: postsData, loading: postsLoading, refetch } = useQuery<{ posts: Post[] }>(GET_POSTS);
  const { data: usersData } = useQuery<{ users: User[] }>(GET_USERS);
  const [createPost, { loading: creating }] = useMutation(CREATE_POST);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      await createPost({
        variables: { input: formData },
      });
      setFormData({ title: '', content: '', authorId: '' });
      refetch();
    } catch (error) {
      console.error('포스트 생성 실패:', error);
    }
  };

  if (postsLoading) return <div>로딩 중...</div>;

  return (
    <div style={{ padding: '20px' }}>
      <h1>포스트 관리</h1>
      
      {/* 포스트 생성 폼 */}
      <form onSubmit={handleSubmit} style={{ marginBottom: '30px' }}>
        <h2>새 포스트 작성</h2>
        <div style={{ marginBottom: '10px' }}>
          <input
            type="text"
            placeholder="제목"
            value={formData.title}
            onChange={(e) => setFormData({ ...formData, title: e.target.value })}
            required
            style={{ width: '100%', padding: '8px', marginBottom: '10px' }}
          />
          <textarea
            placeholder="내용"
            value={formData.content}
            onChange={(e) => setFormData({ ...formData, content: e.target.value })}
            style={{ width: '100%', padding: '8px', height: '100px', marginBottom: '10px' }}
          />
          <select
            value={formData.authorId}
            onChange={(e) => setFormData({ ...formData, authorId: e.target.value })}
            required
            style={{ padding: '8px', marginRight: '10px' }}
          >
            <option value="">작성자 선택</option>
            {usersData?.users.map((user) => (
              <option key={user.id} value={user.id}>
                {user.name}
              </option>
            ))}
          </select>
          <button type="submit" disabled={creating}>
            {creating ? '작성 중...' : '포스트 작성'}
          </button>
        </div>
      </form>

      {/* 포스트 목록 */}
      <h2>포스트 목록</h2>
      <div>
        {postsData?.posts.map((post) => (
          <article
            key={post.id}
            style={{
              border: '1px solid #ddd',
              padding: '20px',
              marginBottom: '15px',
              borderRadius: '8px',
            }}
          >
            <h3>{post.title}</h3>
            <p style={{ color: '#666', fontSize: '14px' }}>
              작성자: {post.author.name} | 
              작성일: {new Date(post.createdAt).toLocaleDateString('ko-KR')}
            </p>
            {post.content && (
              <div style={{ marginTop: '10px', lineHeight: '1.6' }}>
                {post.content}
              </div>
            )}
          </article>
        ))}
      </div>
    </div>
  );
}

8. 네비게이션 컴포넌트 (components/Navigation.tsx)

import Link from 'next/link';

export default function Navigation() {
  return (
    <nav style={{
      backgroundColor: '#f0f0f0',
      padding: '10px 20px',
      borderBottom: '1px solid #ddd'
    }}>
      <Link href="/users" style={{ marginRight: '20px', textDecoration: 'none' }}>
        사용자 관리
      </Link>
      <Link href="/posts" style={{ textDecoration: 'none' }}>
        포스트 관리
      </Link>
    </nav>
  );
}

GraphQL의 고급 기능

1. DataLoader (N+1 문제 해결)

@Component
public class PostDataLoader {
    
    @Autowired
    private PostRepository postRepository;
    
    public DataLoader<Long, List<Post>> createDataLoader() {
        return DataLoaderFactory.newMappedDataLoader((Set<Long> userIds) -> {
            List<Post> posts = postRepository.findByAuthorIdIn(userIds);
            return userIds.stream()
                .collect(Collectors.toMap(
                    userId -> userId,
                    userId -> posts.stream()
                        .filter(post -> post.getAuthor().getId().equals(userId))
                        .collect(Collectors.toList())
                ));
        });
    }
}

2. Subscription (실시간 업데이트)

@Controller
public class SubscriptionController {
    
    @SubscriptionMapping
    public Flux<Post> postAdded() {
        return postEventPublisher.getPostStream();
    }
}

3. Custom Scalar Types

@Component
public class CustomScalarConfig {
    
    @Bean
    public RuntimeWiringConfigurer runtimeWiringConfigurer() {
        return wiringBuilder -> wiringBuilder
            .scalar(ExtendedScalars.DateTime)
            .scalar(ExtendedScalars.Json);
    }
}

4. Error Handling

@ControllerAdvice
public class GraphQLExceptionHandler {
    
    @ExceptionHandler(DataFetchingException.class)
    public ResponseStatusException handleDataFetchingException(DataFetchingException ex) {
        return new ResponseStatusException(HttpStatus.BAD_REQUEST, ex.getMessage());
    }
}

GraphQL의 장단점

장점

  • 정확한 데이터 요청: 필요한 데이터만 요청 가능
  • 단일 엔드포인트: API 버전 관리가 쉬움
  • 강력한 타입 시스템: 컴파일 타임에 오류 발견 가능
  • 실시간 기능: Subscription을 통한 실시간 업데이트
  • 개발자 도구: GraphiQL, Apollo DevTools 등 풍부한 도구

단점

  • 학습 곡선: REST보다 복잡한 개념
  • 캐싱 복잡성: HTTP 캐싱 활용이 어려움
  • 쿼리 복잡성: 복잡한 쿼리로 인한 성능 이슈 가능
  • 파일 업로드: 표준이 없어 구현이 복잡
  • 오버헤드: 간단한 API에는 과도할 수 있음

GraphQL은 복잡한 데이터 요구사항을 가진 현대적인 애플리케이션에서 매우 유용한 기술입니다. 특히 모바일 앱이나 다양한 클라이언트를 지원해야 하는 경우에 그 진가를 발휘합니다.

0개의 댓글