GraphQL은 API를 위한 쿼리 언어이자 런타임으로, Facebook에서 2012년에 개발하고 2015년에 오픈소스로 공개했습니다. REST API의 한계를 극복하기 위해 만들어졌으며, 클라이언트가 필요한 데이터만 정확히 요청할 수 있게 해줍니다.
REST API는 여러 엔드포인트를 가지지만, GraphQL은 보통 /graphql 하나의 엔드포인트만 사용합니다.
모든 데이터 구조와 가능한 작업을 스키마로 정의합니다. 스키마는 API의 계약서 역할을 합니다.
GraphQL은 강력한 타입 시스템을 제공합니다:
// 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
}
}
// 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
}
}
}
}
}
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'
}
@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; }
}
@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);
}
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!
}
@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; }
}
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
npm install @apollo/client graphql
npm install @types/node typescript
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;
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;
}
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)
}
`;
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>
);
}
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>
);
}
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>
);
}
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>
);
}
@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())
));
});
}
}
@Controller
public class SubscriptionController {
@SubscriptionMapping
public Flux<Post> postAdded() {
return postEventPublisher.getPostStream();
}
}
@Component
public class CustomScalarConfig {
@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer() {
return wiringBuilder -> wiringBuilder
.scalar(ExtendedScalars.DateTime)
.scalar(ExtendedScalars.Json);
}
}
@ControllerAdvice
public class GraphQLExceptionHandler {
@ExceptionHandler(DataFetchingException.class)
public ResponseStatusException handleDataFetchingException(DataFetchingException ex) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, ex.getMessage());
}
}
GraphQL은 복잡한 데이터 요구사항을 가진 현대적인 애플리케이션에서 매우 유용한 기술입니다. 특히 모바일 앱이나 다양한 클라이언트를 지원해야 하는 경우에 그 진가를 발휘합니다.