GraphQL vs REST, 실무에서의 선택 - 언제 뭘 써야 할까?
TL;DR
- 문제: REST API로 필드 많은 응답 → 불필요한 데이터까지 전송, 엔드포인트 20~30개 폭발, DTO 클래스 남발
- 원인: Over-fetching (필요한 것만 받을 수 없음), Under-fetching (여러 API 호출 필요)
- 해결: GraphQL 도입 - 클라이언트가 필요한 필드만 요청, 하나의 엔드포인트로 통합
- 효과: API 엔드포인트 감소, 데이터 전송량 50% 감소, 프론트 개발 속도 ↑
- 한계: N+1 문제, 학습 곡선, 캐싱 복잡, Over-fetching 일부 상황에서 REST가 더 나음
서론
프론트엔드 팀에서 요청이 들어왔습니다.
“이 API, 필드가 너무 많아서 불필요한 데이터까지 다 받게 돼요. 필요한 것만 받을 수 있으면 좋겠는데…”
REST API로 서비스를 운영하다 보면 자주 듣는 이야기입니다. 처음엔 엔드포인트를 하나 더 만들면 되지 않을까 생각했는데, 이게 쌓이다 보니 API가 20개, 30개로 늘어났습니다. DTO 클래스는 더 많아졌고, UserSimpleResponse, UserDetailResponse, UserWithOrdersResponse… 비슷한 이름의 클래스가 계속 생겼습니다.
문득 GraphQL이 떠올랐습니다. “필요한 필드만 요청할 수 있다”는 장점이 딱 우리 상황에 맞는 것 같았거든요.
그래서 이번에 내부 서비스 하나에 GraphQL을 도입해봤습니다. 이 글에서는 약 6개월간 양쪽을 써본 경험을 바탕으로, 어떤 상황에서 무엇을 선택해야 하는지 정리해봤습니다.
REST API의 Over-fetching 문제
전형적인 상황
사용자 목록 조회 API가 있다고 가정해봅시다.
@GetMapping("/api/users")
public List<UserResponse> getUsers() {
List<User> users = userService.findAll();
return users.stream()
.map(this::toResponse)
.collect(Collectors.toList());
}
// UserResponse DTO - 필드가 점점 늘어남
public class UserResponse {
private Long id;
private String name;
private String email;
private String phoneNumber;
private String address;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<OrderSummary> recentOrders; // 최근 주문 내역
private UserProfile profile; // 프로필 정보
// ... 15개 이상의 필드
}문제는 프론트엔드에서 실제로 필요한 건 id, name, email 3개뿐인데, 모든 필드와 연관 데이터까지 함께 전송된다는 겁니다.
// 프론트엔드에서 실제 사용하는 부분
users.forEach(user => {
console.log(user.name, user.email) // id, name, email만 필요
// 나머지 12개 필드는 안 씀
})전형적인 Over-fetching입니다. 네트워크 대역폭 낭비는 물론이고, 불필요한 DB 조회까지 발생합니다.
해결 시도 - 엔드포인트 증식
처음엔 이렇게 해결하려고 했습니다.
// 간략한 사용자 목록
@GetMapping("/api/users/simple")
public List<UserSimpleResponse> getUsersSimple() { ... }
// 상세 사용자 정보
@GetMapping("/api/users/detail")
public List<UserDetailResponse> getUsersDetail() { ... }
// 사용자 + 주문 정보
@GetMapping("/api/users/with-orders")
public List<UserWithOrdersResponse> getUsersWithOrders() { ... }
// 사용자 + 프로필 정보
@GetMapping("/api/users/with-profile")
public List<UserWithProfileResponse> getUsersWithProfile() { ... }결과는 처참했습니다.
- API 엔드포인트가 20개 넘게 늘어남
- DTO 클래스만 15개 이상 생성
- 프론트엔드팀이 “주문이랑 프로필 둘 다 필요한데요?” 하면 또 엔드포인트 추가
- 유지보수 부담이 눈덩이처럼 불어남
Query Parameter로 해결하려고도 해봤습니다.
@GetMapping("/api/users")
public List<Map<String, Object>> getUsers(
@RequestParam(required = false) String fields
) {
// fields=id,name,email 형태로 요청
List<User> users = userService.findAll();
return users.stream()
.map(user -> filterFields(user, fields))
.collect(Collectors.toList());
}근데 이것도 문제가 많았습니다.
- 필드 파싱 로직이 복잡해짐
- 타입 안전성 부족 (런타임에 터짐)
- 중첩된 객체(
profile.age) 처리가 어려움 - 결국 커스텀 직렬화 로직을 처음부터 다 짜야 함
이 시점에서 GraphQL을 진지하게 고려하게 됐습니다.
GraphQL 도입 경험
Spring Boot + GraphQL 구성
Spring Boot 3.x부터는 GraphQL 지원이 잘 되어 있어서 구성은 간단했습니다.
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-graphql'스키마 정의:
# src/main/resources/graphql/schema.graphqls
type User {
id: ID!
name: String!
email: String!
phoneNumber: String
address: String
createdAt: String
profile: UserProfile
recentOrders: [Order]
}
type UserProfile {
age: Int
bio: String
}
type Order {
id: ID!
productName: String!
amount: Int!
}
type Query {
users: [User]
user(id: ID!): User
}Resolver 구현:
@Controller
public class UserGraphQLController {
private final UserService userService;
private final OrderService orderService;
@QueryMapping
public List<User> users() {
return userService.findAll();
}
@QueryMapping
public User user(@Argument Long id) {
return userService.findById(id);
}
// 연관 데이터는 필드별 Resolver로 분리
@SchemaMapping(typeName = "User", field = "recentOrders")
public List<Order> recentOrders(User user) {
return orderService.findRecentByUserId(user.getId());
}
}이제 프론트엔드에서는 필요한 필드만 요청하면 됩니다.
# 필요한 필드만 요청
query {
users {
id
name
email
}
}{
"data": {
"users": [
{ "id": "1", "name": "홍길동", "email": "hong@example.com" },
{ "id": "2", "name": "김철수", "email": "kim@example.com" }
]
}
}딱 요청한 3개 필드만 응답에 포함됩니다. 깔끔하죠.
좋았던 점
필드 선택의 유연성
프론트엔드가 원하는 필드만 요청할 수 있다는 게 정말 편했습니다.
# 프로필도 필요할 때만 추가
query {
users {
id
name
profile {
age
bio
}
}
}REST였다면 /api/users/with-profile 엔드포인트를 새로 만들었을 겁니다. 근데 GraphQL은 프론트엔드가 알아서 필요한 필드를 추가하면 됩니다. 백엔드 작업 없이요.
N+1 문제 가시화
사실 이건 장점이자 단점입니다. GraphQL은 N+1 문제를 더 명확하게 드러냅니다.
// 이렇게 짜면 N+1 발생
@SchemaMapping(typeName = "User", field = "recentOrders")
public List<Order> recentOrders(User user) {
// 사용자 100명이면 쿼리 100번 실행
return orderService.findRecentByUserId(user.getId());
}처음엔 “이거 왜 이렇게 느리지?” 했는데, DataLoader를 도입하면서 해결했습니다.
@Configuration
public class GraphQLConfig {
@Bean
public DataLoader<Long, List<Order>> ordersDataLoader(OrderService orderService) {
return DataLoader.newMappedDataLoader((Set<Long> userIds) -> {
// 한 번에 배치 조회
Map<Long, List<Order>> orders = orderService.findByUserIds(userIds);
return CompletableFuture.completedFuture(orders);
});
}
}REST에서도 동일한 문제가 있긴 한데, GraphQL은 이걸 더 명시적으로 강제합니다. 어찌 보면 좋은 거죠. 숨겨진 성능 문제를 일찍 발견할 수 있으니까요.
단일 엔드포인트
모든 요청이 /graphql 하나로 통합됩니다.
- REST: 엔드포인트 20개 넘음
- GraphQL: 엔드포인트 1개
라우팅 관리가 훨씬 간소화됩니다.
아쉬웠던 점
캐싱이 생각보다 어려웠습니다
이건 예상 밖이었습니다. REST는 HTTP 캐싱을 그대로 활용할 수 있거든요.
@GetMapping("/api/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userService.findById(id);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS))
.body(user);
}근데 GraphQL은 POST 요청이라 HTTP 캐싱이 안 됩니다.
POST /graphql
Content-Type: application/json
{
"query": "{ users { id name } }"
}결국 Redis 캐싱 레이어를 별도로 구축해야 했습니다. REST에서 당연하게 누리던 걸 포기하고 추가 작업을 해야 했던 거죠.
에러 처리가 다릅니다
REST는 HTTP 상태 코드로 명확합니다.
400 Bad Request - 잘못된 요청
401 Unauthorized - 인증 실패
404 Not Found - 리소스 없음
500 Internal Server Error - 서버 오류GraphQL은 항상 200 OK를 반환하고, 에러는 응답 본문에 포함됩니다.
{
"data": null,
"errors": [
{
"message": "User not found",
"path": ["user"],
"extensions": {
"classification": "NOT_FOUND"
}
}
]
}프론트엔드에서 에러 처리 방식을 완전히 바꿔야 했습니다.
파일 업로드는 결국 REST로 돌아왔습니다
REST는 multipart/form-data로 간단합니다.
@PostMapping("/api/users/profile-image")
public void uploadProfile(@RequestParam("file") MultipartFile file) {
// 파일 처리
}GraphQL은 파일 업로드가 스펙에 없어서 Apollo Upload를 쓰거나, Base64로 인코딩하거나, 별도 REST 엔드포인트를 만들어야 합니다.
결국 우리는 파일 업로드만 REST로 유지했습니다. GraphQL로 파일을 다루는 건 너무 번거로웠거든요.
쿼리 복잡도 제어가 필요합니다
클라이언트가 이런 쿼리를 보낼 수 있습니다.
query {
users {
recentOrders {
items {
product {
category {
subcategories {
products {
# 무한 중첩...
}
}
}
}
}
}
}
}이런 쿼리는 서버를 마비시킬 수 있습니다. Complexity 계산 로직을 추가해서 최대 깊이와 복잡도를 제한해야 합니다.
REST가 더 나은 경우
GraphQL을 써봤지만, 모든 상황에서 좋은 건 아니었습니다.
단순 CRUD
// 사용자 생성
POST /api/users
Body: { "name": "홍길동", "email": "hong@example.com" }
// 사용자 조회
GET /api/users/123
// 사용자 수정
PUT /api/users/123
Body: { "name": "김철수" }
// 사용자 삭제
DELETE /api/users/123이런 단순한 CRUD는 REST가 훨씬 직관적입니다. HTTP 메서드만으로 의도가 명확하거든요.
GraphQL로 만들면 오히려 복잡해집니다.
mutation {
createUser(input: { name: "홍길동", email: "hong@example.com" }) {
id
name
}
}파일 다운로드
@GetMapping("/api/reports/{id}/download")
public ResponseEntity<Resource> downloadReport(@PathVariable Long id) {
ByteArrayResource resource = reportService.generatePdf(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=report.pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(resource);
}파일 다운로드는 REST가 표준이고 간단합니다.
캐싱이 중요한 경우
공개 API나 읽기 위주의 서비스에서는 HTTP 캐싱이 매우 효과적입니다.
@GetMapping("/api/products")
public ResponseEntity<List<Product>> getProducts() {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES).cachePublic())
.body(productService.findAll());
}CDN과 브라우저 캐시가 자동으로 작동합니다. 이걸 포기하고 GraphQL로 가는 건 트레이드오프를 잘 따져봐야 합니다.
외부 API 연동
third-party API를 연동할 때는 대부분 REST입니다. 이걸 GraphQL로 감싸는 건 불필요한 복잡도를 추가할 뿐입니다.
GraphQL이 빛나는 경우
모바일 앱 개발
모바일 환경에서는 네트워크 비용이 중요합니다.
REST:
GET /api/users/123 → 약 200KB
GET /api/users/123/orders → 약 150KB
GET /api/users/123/profile → 약 50KB
─────────────────────────────────
총 3번 요청, 약 400KB 전송GraphQL:
query {
user(id: 123) {
name
email
orders(limit: 5) {
productName
amount
}
profile {
bio
}
}
}POST /graphql → 약 50KB (필요한 필드만)
─────────────────────────────────
총 1번 요청, 약 50KB 전송데이터 사용량이 8분의 1로 줄어들었습니다. 모바일 데이터 요금에 민감한 사용자들한테 큰 차이입니다.
프론트엔드 팀이 여러 개
프론트엔드 팀마다 필요한 데이터가 다를 때:
- 웹 팀: 사용자 이름, 이메일, 프로필 이미지, 최근 활동
- 앱 팀: 사용자 이름, 포인트, 주문 내역
- 관리자 팀: 모든 정보
REST였다면 각 팀마다 엔드포인트를 만들어야 했겠지만, GraphQL은 각 팀이 필요한 필드를 요청하면 됩니다. 백엔드 변경 없이요.
실시간 구독이 필요한 경우
GraphQL Subscription은 WebSocket 기반 실시간 업데이트를 지원합니다.
subscription {
orderUpdated(userId: 123) {
id
status
updatedAt
}
}@Component
public class OrderSubscription {
@SubscriptionMapping
public Flux<Order> orderUpdated(@Argument Long userId) {
return orderService.subscribeToOrders(userId);
}
}REST로 구현하려면 SSE나 WebSocket을 별도로 구축해야 합니다.
복잡한 필터링과 정렬
query {
products(
filter: {
category: "ELECTRONICS"
priceRange: { min: 10000, max: 50000 }
inStock: true
}
sort: { field: PRICE, direction: ASC }
pagination: { page: 1, size: 20 }
) {
id
name
price
stock
}
}REST로 하면:
GET /api/products?category=ELECTRONICS&minPrice=10000&maxPrice=50000&inStock=true&sort=price&direction=asc&page=1&size=20URL이 너무 길어지고 가독성이 떨어집니다.
선택 가이드
의사결정 트리
graph TD
Start{API 설계 시작}
Simple{단순 CRUD?}
Mobile{모바일 앱<br/>지원 필요?}
Multi{복잡한 쿼리<br/>조합 필요?}
Public{공개 API<br/>또는 캐싱 중요?}
REST[REST API 선택]
GraphQL[GraphQL 선택]
Hybrid[혼합 사용 고려]
Start --> Simple
Simple -->|예| Public
Simple -->|아니오| Mobile
Public -->|예| REST
Public -->|아니오| Mobile
Mobile -->|예| GraphQL
Mobile -->|아니오| Multi
Multi -->|예| GraphQL
Multi -->|아니오| Hybrid
style GraphQL fill:#e8f5e9
style REST fill:#e3f2fd
style Hybrid fill:#fff3e0실무 선택 기준
| 상황 | REST | GraphQL | 이유 |
|---|---|---|---|
| 단순 CRUD | O | X | HTTP 메서드로 충분 |
| 공개 API (외부 제공) | O | X | 표준, 문서화 우수 |
| 모바일 앱 | X | O | 데이터 사용량 최소화 |
| 복잡한 필터링 | X | O | 쿼리 유연성 |
| 파일 다운로드 | O | X | HTTP 표준 활용 |
| 실시간 업데이트 | X | O | Subscription 지원 |
| 캐싱 중요 | O | X | HTTP 캐싱 활용 |
| 프론트엔드 팀 다수 | X | O | 필드 선택 유연성 |
실무 조언
GraphQL 도입 전 체크리스트
도입 전에 이런 것들을 확인해보면 좋습니다.
- 팀원 전체가 GraphQL 학습 의지 있는가?
- Over-fetching/Under-fetching 문제가 실제로 있는가?
- N+1 문제 해결 역량이 있는가? (DataLoader 등)
- 캐싱 전략을 새로 구축할 여유가 있는가?
- 프론트엔드 팀과 충분히 협의했는가?
혼합 전략 (우리 팀의 현재)
// GraphQL - 복잡한 조회
POST /graphql
// REST - 단순 CRUD, 파일 업로드
GET /api/users
POST /api/users
PUT /api/users/{id}
DELETE /api/users/{id}
POST /api/users/profile-image두 가지를 병행하면서 각각의 장점을 활용하고 있습니다. 처음엔 “일관성이 없는 거 아닌가?” 고민도 했는데, 실용적으로 가는 게 맞다고 결론 내렸습니다.
점진적 도입
처음부터 전체를 GraphQL로 전환하지 않는 게 좋습니다.
- 작은 기능 하나에만 적용 (복잡한 조회 등)
- 프론트엔드 피드백 수렴
- 성능 및 개발 생산성 측정
- 확대 또는 롤백 결정
저희도 처음엔 내부 서비스 하나에만 적용했습니다. 6개월 정도 돌려보고 나서 다른 서비스에 확대했습니다.
성능 모니터링 필수
@Component
public class GraphQLInstrumentation extends SimplePerformantInstrumentation {
@Override
public CompletableFuture<ExecutionResult> instrumentExecutionResult(
ExecutionResult executionResult,
InstrumentationExecutionParameters parameters,
InstrumentationState state
) {
long duration = System.currentTimeMillis() - startTime;
log.info("GraphQL Query executed in {}ms", duration);
// 느린 쿼리 감지
if (duration > 1000) {
log.warn("Slow query detected: {}", parameters.getQuery());
}
return super.instrumentExecutionResult(executionResult, parameters, state);
}
}느린 쿼리를 감지하고 최적화하는 게 중요합니다. GraphQL은 클라이언트가 쿼리를 자유롭게 작성하기 때문에 예상치 못한 성능 문제가 생길 수 있거든요.
시스템 점검 체크리스트
저도 GraphQL을 도입할 때 이 항목들을 확인합니다. API 설계를 고려한다면 참고하시면 좋을 것 같습니다.
- N+1 문제 해결: DataLoader를 적용했는가? Batch 로딩이 동작하는가?
- 쿼리 깊이 제한: 악의적인 깊은 쿼리를 막기 위해 depth limit을 설정했는가?
- 필드 권한: 민감한 필드(비밀번호, 토큰 등)를 GraphQL에서 노출하지 않는가?
- 캐싱 전략: GET 요청처럼 HTTP 캐싱을 할 수 없으니 별도 캐싱 전략이 있는가?
- GraphQL vs REST 혼용: 간단한 API 는 REST로 남기고, 복잡한 API만 GraphQL로 했는가?
결론
두 기술 다 훌륭합니다. 중요한 건 상황에 맞는 선택입니다.
저희 팀은 6개월간 양쪽을 써보면서 이런 결론에 도달했습니다.
“GraphQL은 은탄환이 아니다.”
처음엔 GraphQL이 모든 문제를 해결해줄 것 같았습니다. Over-fetching도 없고, Under-fetching도 없고, 엔드포인트 증식도 막을 수 있을 거라고요.
하지만 현실은 달랐습니다.
캐싱이 어렵다는 건 예상 밖이었습니다. REST에서 당연하게 누리던 HTTP 캐싱을 포기하고 Redis 레이어를 추가로 구축해야 했습니다.
N+1 문제는 여전했습니다. 오히려 GraphQL이 더 쉽게 N+1을 만들어냅니다. DataLoader로 해결하긴 했지만, 팀원들이 익숙해지는 데 시간이 걸렸습니다.
파일 업로드는 결국 REST로 돌아왔습니다. GraphQL로 파일을 다루는 건 너무 번거로웠습니다.
그래서 우리 팀은 혼합 전략을 선택했습니다.
- GraphQL: 모바일 앱의 복잡한 조회 쿼리, 실시간 알림
- REST: 관리자 CRUD, 파일 업로드, 공개 API
처음엔 “어디까지 GraphQL로 가야 할까?”를 두고 고민이 많았습니다. 결국 각 기술의 장점을 살리는 쪽으로 정리됐습니다.
지금 돌아보면 이 선택이 맞았던 것 같습니다. 무리하게 한쪽으로만 갔다면 캐싱, 파일 업로드, 에러 처리 등에서 불필요한 복잡도만 늘었을 겁니다.
저는 이 경험을 통해 한 가지 깨달았습니다.
“최신 기술이 항상 정답은 아니다. 우리 팀이 감당할 수 있고, 비즈니스에 실제로 가치를 주는 기술이 최선이다.”
GraphQL 도입을 고민하고 계시다면, 작은 부분부터 시작해보는 것도 좋은 방법입니다. 저희도 그렇게 했거든요.
참고 :
https://spring.io/projects/spring-graphql
https://graphql.org/learn/
https://www.apollographql.com/docs/
https://netflix.github.io/dgs/
https://www.baeldung.com/spring-graphql