Skip to content

GraphQL vs REST, 실무에서의 선택 - 언제 뭘 써야 할까?

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=20

URL이 너무 길어지고 가독성이 떨어집니다.





선택 가이드

의사결정 트리

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

실무 선택 기준

상황RESTGraphQL이유
단순 CRUDOXHTTP 메서드로 충분
공개 API (외부 제공)OX표준, 문서화 우수
모바일 앱XO데이터 사용량 최소화
복잡한 필터링XO쿼리 유연성
파일 다운로드OXHTTP 표준 활용
실시간 업데이트XOSubscription 지원
캐싱 중요OXHTTP 캐싱 활용
프론트엔드 팀 다수XO필드 선택 유연성




실무 조언

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로 전환하지 않는 게 좋습니다.

  1. 작은 기능 하나에만 적용 (복잡한 조회 등)
  2. 프론트엔드 피드백 수렴
  3. 성능 및 개발 생산성 측정
  4. 확대 또는 롤백 결정

저희도 처음엔 내부 서비스 하나에만 적용했습니다. 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




읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site