
2026년 REST API vs GraphQL vs tRPC: 어떤 것을 선택해야 할까요?
📷 Stanislav Kondratiev / Pexels2026년 REST API vs GraphQL vs tRPC: 어떤 것을 선택해야 할까요?
현대 웹 개발을 위한 REST, GraphQL, tRPC의 상세 비교입니다. 각 접근 방식의 장단점, 성능 특성, 최적의 사용 사례를 알아보세요.
프론트엔드가 백엔드와 통신하는 방법을 선택하는 것은 웹 프로젝트에서 가장 중대한 아키텍처 결정 중 하나입니다. 2026년에 개발자들은 REST, GraphQL, tRPC라는 세 가지 성숙하고 실전에서 검증된 옵션을 가지고 있습니다. 각각 뚜렷한 강점, 트레이드오프, 이상적인 사용 사례가 있습니다. 이 글에서는 다음 프로젝트에 적합한 선택을 하는 데 도움이 되는 철저한 비교를 제공합니다.
REST API: 확립된 표준
REST (Representational State Transfer)는 20년 이상 지배적인 API 패러다임이었습니다. HTTP 메서드를 리소스에 대한 CRUD 작업에 매핑하여 직관적이고 거의 모든 개발자가 잘 이해합니다.
REST의 작동 방식
REST API는 리소스를 URL로 노출합니다. 클라이언트는 표준 HTTP 메서드를 사용하여 상호작용합니다:
// GET a list of users
const response = await fetch('https://api.example.com/users');
const users = await response.json();
// GET a single user
const user = await fetch('https://api.example.com/users/42');
// POST to create a new user
const newUser = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Alice Johnson',
email: 'alice@example.com',
}),
});
// PUT to update a user
await fetch('https://api.example.com/users/42', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice Smith' }),
});
// DELETE a user
await fetch('https://api.example.com/users/42', {
method: 'DELETE',
});
서버 측 REST 예제 (Express)
import express from 'express';
const app = express();
app.use(express.json());
// GET /users
app.get('/users', async (req, res) => {
const { page = 1, limit = 20, sort = 'name' } = req.query;
const users = await db.users.findMany({
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
orderBy: { [sort as string]: 'asc' },
});
res.json({ data: users, page: Number(page), limit: Number(limit) });
});
// GET /users/:id
app.get('/users/:id', async (req, res) => {
const user = await db.users.findUnique({
where: { id: Number(req.params.id) },
include: { posts: true, profile: true },
});
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
// POST /users
app.post('/users', async (req, res) => {
const user = await db.users.create({ data: req.body });
res.status(201).json(user);
});
REST의 강점
- 보편적으로 이해됨: 모든 개발자, 모든 언어, 모든 플랫폼이 REST를 지원합니다.
- HTTP 캐싱: GET 요청은 브라우저, CDN, 프록시에 의해 기본적으로 캐시될 수 있습니다.
- 무상태이고 간단함: 각 요청은 서버가 필요한 모든 정보를 포함합니다.
- 성숙한 도구: Swagger/OpenAPI, Postman 및 수많은 라이브러리.
- 상태 코드가 의미 있음: 200, 201, 404, 422, 500 모두 구체적인 정보를 전달합니다.
REST의 약점
- 오버 페칭: 엔드포인트가 클라이언트가 몇 개만 필요하더라도 모든 필드를 반환합니다.
- 언더 페칭: 관련 데이터를 가져오려면 종종 여러 번의 왕복이 필요합니다.
- 내장된 타입 안전성 없음: OpenAPI 코드 생성과 같은 추가 도구 없이는 클라이언트와 서버 간의 계약이 없습니다.
- 버전 관리 과제: 호환성을 깨는 변경에는 종종
/v2/엔드포인트나 헤더 기반 버전 관리가 필요합니다.
GraphQL: 유연한 쿼리
Facebook이 만들고 2015년에 오픈소스화한 GraphQL은 클라이언트가 단일 요청에서 정확히 필요한 데이터를 지정할 수 있게 합니다. 타입이 지정된 스키마를 사용하여 API 표면을 정의합니다.
GraphQL의 작동 방식
// A GraphQL query
const query = `
query GetUser($id: ID!) {
user(id: $id) {
name
email
posts(first: 5) {
title
createdAt
}
followers {
totalCount
}
}
}
`;
const response = await fetch('https://api.example.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
variables: { id: '42' },
}),
});
const { data } = await response.json();
// data.user.name, data.user.posts, data.user.followers.totalCount
서버 측 GraphQL 스키마
// schema.ts (using a schema-first approach)
const typeDefs = `
type User {
id: ID!
name: String!
email: String!
posts(first: Int, after: String): PostConnection!
followers: FollowerCount!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
createdAt: DateTime!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type Query {
user(id: ID!): User
users(first: Int, after: String): UserConnection!
post(id: ID!): Post
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
type Subscription {
postCreated: Post!
}
`;
리졸버
const resolvers = {
Query: {
user: async (_: unknown, { id }: { id: string }, ctx: Context) => {
return ctx.db.users.findUnique({ where: { id } });
},
users: async (_: unknown, args: PaginationArgs, ctx: Context) => {
return paginateUsers(ctx.db, args);
},
},
User: {
posts: async (parent: User, args: PaginationArgs, ctx: Context) => {
return ctx.loaders.userPosts.load({ userId: parent.id, ...args });
},
followers: async (parent: User, _: unknown, ctx: Context) => {
const count = await ctx.db.followers.count({
where: { followeeId: parent.id },
});
return { totalCount: count };
},
},
Mutation: {
createUser: async (_: unknown, { input }: { input: CreateUserInput }, ctx: Context) => {
return ctx.db.users.create({ data: input });
},
},
};
GraphQL의 강점
- 오버 페칭이나 언더 페칭 없음: 클라이언트가 정확히 필요한 필드를 요청합니다.
- 단일 엔드포인트: 모든 작업이 하나의 URL을 통해 이루어집니다.
- 강력한 타입 스키마: 스키마가 살아있는 문서 역할을 하며 강력한 도구를 가능하게 합니다.
- 인트로스펙션: 클라이언트가 전체 API 표면을 프로그래밍 방식으로 검색할 수 있습니다.
- 구독: WebSocket을 통한 실시간 데이터에 대한 내장 지원.
- 다양한 클라이언트에 적합: 모바일 앱은 최소한의 필드를 요청하고 데스크톱 앱은 모든 것을 가져올 수 있습니다.
GraphQL의 약점
- 복잡성: 리졸버, 데이터 로더, 지속 쿼리, 스키마 관리가 인지 부하를 추가합니다.
- 캐싱이 더 어려움: 모든 요청이 단일 엔드포인트에 대한 POST이므로 전통적인 HTTP 캐싱이 작동하지 않습니다. 클라이언트 측 캐싱(Apollo, urql)이나 지속 쿼리가 필요합니다.
- N+1 쿼리 문제: 데이터 로더 없이는 중첩된 리졸버가 과도한 데이터베이스 쿼리를 유발할 수 있습니다.
- 파일 업로드: 기본적으로 지원되지 않으며 멀티파트 요청 스펙이나 별도의 업로드 엔드포인트가 필요합니다.
- 보안 표면: 깊이 중첩되거나 비용이 많이 드는 쿼리는 쿼리 깊이 제한 및 복잡도 분석을 구현하지 않으면 서비스 거부를 유발할 수 있습니다.
tRPC: 엔드투엔드 타입 안전성
tRPC는 TypeScript 전용 프레임워크로, 완전한 타입 안전성과 코드 생성 없이 클라이언트에서 서버 함수를 직접 호출할 수 있게 합니다. 프론트엔드와 백엔드 모두 TypeScript로 작성된 모노레포 프로젝트를 위해 설계되었습니다.
tRPC의 작동 방식
tRPC는 API 레이어를 별도의 개념으로 제거합니다. 서버에서 프로시저를 정의하면 클라이언트가 로컬 함수처럼 호출합니다.
서버 측 라우터 정의:
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.users.findUnique({
where: { id: input.id },
include: { posts: true, profile: true },
});
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
return user;
}),
list: publicProcedure
.input(z.object({
page: z.number().default(1),
limit: z.number().min(1).max(100).default(20),
search: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
const { page, limit, search } = input;
const where = search
? { name: { contains: search, mode: 'insensitive' as const } }
: {};
const [users, total] = await Promise.all([
ctx.db.users.findMany({
where,
skip: (page - 1) * limit,
take: limit,
}),
ctx.db.users.count({ where }),
]);
return { users, total, page, limit };
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
}))
.mutation(async ({ input, ctx }) => {
return ctx.db.users.create({ data: input });
}),
update: protectedProcedure
.input(z.object({
id: z.string(),
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
}))
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
return ctx.db.users.update({ where: { id }, data });
}),
});
클라이언트 측 사용 (React):
// Client: full type safety, no code generation needed
import { trpc } from '../utils/trpc';
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = trpc.user.getById.useQuery({ id: userId });
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.user.getById.invalidate({ id: userId });
},
});
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error.message} />;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<button onClick={() => updateUser.mutate({ id: userId, name: 'New Name' })}>
Update Name
</button>
</div>
);
}
모든 입력, 출력, 에러가 완전히 타입이 지정됩니다. 서버에서 필드 이름을 변경하면 클라이언트에 즉시 TypeScript 에러가 표시됩니다.
tRPC의 강점
- 엔드투엔드 타입 안전성: 서버의 변경이 코드 생성 없이 클라이언트 타입에 즉시 반영됩니다.
- 최소한의 보일러플레이트: 스키마 파일, API 라우트 정의, 클라이언트 생성 단계가 없습니다.
- 탁월한 개발자 경험: IDE에서 자동 완성, 인라인 문서, 리팩토링 지원.
- 내장된 입력 유효성 검사: Zod 스키마가 런타임에 입력을 검증하고 컴파일 타임에 타입을 제공합니다.
- React Query 통합: 캐싱, 낙관적 업데이트, 실시간 리페칭에 대한 최고 수준의 지원.
- 구독: 실시간 기능을 위한 WebSocket 지원.
tRPC의 약점
- TypeScript만 지원: 클라이언트와 서버 모두 TypeScript여야 합니다. Python, Go 또는 다른 백엔드 언어에서는 작동하지 않습니다.
- 모노레포 중심: 클라이언트와 서버 코드가 함께 있을 때 가장 잘 작동합니다. 팀이 별도의 저장소에서 작업할 때는 덜 실용적입니다.
- 퍼블릭 API에 부적합: 언어에 구애받지 않는 스키마가 없으므로 서드파티가 사용하는 API에는 적합하지 않습니다.
- 클라이언트와 서버의 강한 결합: 팀이 성장하면서 강한 결합이 유지보수 문제가 될 수 있습니다.
- 작은 에코시스템: REST나 GraphQL에 비해 튜토리얼, 도구, 커뮤니티 리소스가 적습니다.
비교표
| 기능 | REST | GraphQL | tRPC |
|---|---|---|---|
| 타입 안전성 | 수동 (OpenAPI 코드 생성) | 스키마 기반 | 자동 (TypeScript) |
| 오버 페칭 | 흔함 | 제거됨 | 최소 (타입 지정된 반환) |
| 언더 페칭 | 흔함 (N+1 요청) | 제거됨 | 최소 |
| 캐싱 | 탁월 (HTTP 네이티브) | 복잡 (클라이언트 측) | React Query 내장 |
| 학습 곡선 | 낮음 | 중-높음 | 낮음 (TS를 안다면) |
| 도구 성숙도 | 탁월 | 매우 좋음 | 좋음 |
| 언어 지원 | 모든 언어 | 모든 언어 | TypeScript만 |
| 실시간 | WebSocket/SSE (별도) | 구독 | 구독 |
| 파일 업로드 | 네이티브 | 우회 방법 필요 | 우회 방법 필요 |
| 퍼블릭 API | 이상적 | 좋음 | 부적합 |
| 코드 생성 | 선택적 (OpenAPI) | 종종 필요 | 불필요 |
| 성능 | 좋음 | 좋음 (배칭 포함) | 탁월 (최소 오버헤드) |
| API 문서 | Swagger/OpenAPI | 스키마 인트로스펙션 | TypeScript 타입 |
각각을 선택해야 할 때
REST를 선택할 때
- 다양한 언어의 서드파티가 사용하는 퍼블릭 API를 구축할 때.
- CDN이나 브라우저 수준에서 HTTP 캐싱이 필요할 때 (예: 콘텐츠가 많은 사이트).
- 팀에 전통적인 HTTP API에 더 익숙한 개발자가 포함되어 있을 때.
- 서로 다른 언어 런타임에서 통신하는 마이크로서비스를 구축할 때.
- 간단한 CRUD 애플리케이션을 위한 가장 단순한 아키텍처를 원할 때.
GraphQL을 선택할 때
- 동일한 API에서 서로 다른 데이터 형태가 필요한 여러 클라이언트(웹, 모바일, TV, 워치)가 있을 때.
- 데이터가 깊이 관계형이고 클라이언트가 단일 요청에서 중첩 데이터를 자주 필요로 할 때.
- 쿼리 레이어와 밀접하게 통합된 실시간 기능(구독)이 필요할 때.
- 개발자 도구를 위한 인트로스펙션이 있는 자체 문서화 API를 원할 때.
- 여러 백엔드 서비스에서 데이터를 집계하는 게이트웨이를 구축할 때.
tRPC를 선택할 때
- 전체 스택이 TypeScript일 때 (프론트엔드와 백엔드 모두).
- 클라이언트와 서버 코드가 함께 있는 모노레포에서 작업할 때.
- 개발자 속도를 중시하고 가장 빠른 반복 속도를 원할 때.
- API가 공개적으로 노출되지 않는 내부 도구나 SaaS 제품을 구축할 때.
- 타입 안전성이 최우선이며 컴파일 타임에 API 불일치를 잡고 싶을 때.
실제 아키텍처 패턴
하이브리드 접근: tRPC + REST
많은 팀이 내부 프론트엔드-백엔드 통신에는 tRPC를 사용하고 서드파티 통합을 위해 별도의 REST API를 노출합니다:
// Internal tRPC router for the dashboard
export const appRouter = router({
user: userRouter,
billing: billingRouter,
analytics: analyticsRouter,
});
// Public REST API for integrations
app.get('/api/v1/users/:id', async (req, res) => {
const user = await db.users.findUnique({
where: { id: req.params.id },
select: publicUserFields,
});
res.json(user);
});
게이트웨이로서의 GraphQL
GraphQL은 여러 마이크로서비스의 데이터를 연합하는 API 게이트웨이로 잘 작동합니다:
// Gateway schema that combines multiple services
const gatewaySchema = stitchSchemas({
subschemas: [
{ schema: userServiceSchema, executor: userServiceExecutor },
{ schema: orderServiceSchema, executor: orderServiceExecutor },
{ schema: inventoryServiceSchema, executor: inventoryServiceExecutor },
],
});
타입 안전성을 위한 REST + OpenAPI
REST를 선택하면서도 타입 안전성을 원한다면, OpenAPI 코드 생성이 간극을 메워줍니다:
# openapi.yaml
paths:
/users/{id}:
get:
operationId: getUser
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/User'
# Generate typed client
npx openapi-typescript-codegen --input openapi.yaml --output src/api
// Generated client with full type safety
import { UserService } from './api';
const user = await UserService.getUser({ id: '42' });
// user is fully typed
성능 고려사항
REST 성능
REST는 HTTP/2 멀티플렉싱과 브라우저 캐싱의 이점을 받습니다. 캐시 가능한 콘텐츠를 제공하는 읽기 중심 API의 경우 REST는 매우 빠를 수 있습니다. 단점은 여러 엔드포인트에서 데이터가 필요할 때 여러 번의 왕복이 필요하다는 것입니다.
GraphQL 성능
GraphQL은 단일 요청으로 복잡한 쿼리를 허용하여 왕복을 줄입니다. 그러나 서버는 잠재적으로 복잡한 쿼리 트리를 해결해야 합니다. 적절한 최적화(데이터 로더, 쿼리 복잡도 제한, 지속 쿼리) 없이는 GraphQL이 실제로 REST보다 느릴 수 있습니다.
tRPC 성능
tRPC는 HTTP를 통한 간단한 JSON으로 통신하므로 직렬화 오버헤드가 최소입니다. 요청 배칭이 활성화되면 여러 프로시저 호출이 단일 HTTP 요청으로 결합되어 왕복이 줄어듭니다. React Query 통합은 네트워크 요청을 더욱 줄이는 클라이언트 측 캐싱을 제공합니다.
결론
단일 최고의 API 접근 방식은 없습니다. 올바른 선택은 팀, 기술 스택, 프로젝트 요구사항에 따라 달라집니다.
REST는 퍼블릭 API, 마이크로서비스 통신, 여러 언어를 사용하는 팀에 기본 선택으로 남아 있습니다. 간결함과 보편적인 지원이 대부분의 시나리오에서 안전한 선택입니다.
GraphQL은 복잡한 관계형 데이터와 해당 데이터의 서로 다른 뷰가 필요한 여러 클라이언트 애플리케이션이 있을 때 뛰어납니다. 복잡성을 추가하지만, 적절한 사용 사례에서는 그 유연성이 가치가 있습니다.
tRPC는 개발자 속도와 타입 안전성이 우선인 TypeScript 모노레포 프로젝트에 최고의 옵션입니다. API 계약 불일치라는 전체 범주의 버그를 제거하며, 세 가지 중 가장 빠른 개발 경험을 제공합니다.
많은 성공적인 팀이 이러한 접근 방식의 조합을 사용하며, 각각이 가장 적합한 곳에서 활용합니다. 중요한 것은 트레이드오프를 이해하고 이전 프로젝트에서 사용했던 것을 기본값으로 하지 않고 의도적인 결정을 내리는 것입니다.