왜 비교하게 되었나?

최근에 새로운 프로젝트를 시작하면서 API 설계에 대해 고민하고 있다. 기존에는 REST API를 주로 사용했는데, GraphQL에 대한 이야기를 자주 듣게 되었다.

REST는 리소스와 일관된 인터페이스를 중심으로 한 아키텍처 스타일이고, GraphQL은 스키마를 바탕으로 필요한 필드를 요청하는 쿼리 언어와 실행 모델이다. GraphQL을 도입한다고 데이터베이스 조회나 권한 관리가 자동으로 해결되지는 않는다. 화면이 요구하는 데이터와 서버 운영 비용을 함께 비교해야 한다.

이 글은 아래의 작은 사용자·게시글 모델을 기준으로 요청 형태를 비교한다. 운영 서비스의 성능 측정 결과는 아니다.

같은 화면을 만드는 두 가지 요청

화면에는 사용자 이름과 게시글 제목만 필요하다고 가정한다. 예제 데이터는 다음과 같다.

const users = [{ id: '123', name: 'Won', email: 'won@example.com' }];
const posts = [{ id: '456', userId: '123', title: 'API 설계', content: '본문' }];

REST: 리소스별 조회

HTTP API에서는 흔히 리소스를 URL로 구분하고 메서드로 작업을 표현한다. 아래는 두 번의 조회를 사용하는 설계 예시다.

GET /api/users/123
GET /api/users/123/posts
{"id":"123","name":"Won","email":"won@example.com"}
[{"id":"456","userId":"123","title":"API 설계","content":"본문"}]

이 설계에서는 화면에 필요 없는 이메일과 본문도 반환한다. 그러나 이것이 REST의 필수 조건은 아니다. 서버가 필드 선택이나 관련 리소스 포함 기능을 제공하면 아래처럼 설계할 수도 있다. fields와 include의 의미는 해당 API가 정의해야 한다.

GET /api/users/123?fields=name&include=posts.title

REST의 원래 정의에는 무상태성, 캐싱, 계층화, 균일한 인터페이스 등의 제약이 포함된다. URL에 명사를 쓰고 JSON을 반환하는 것만으로 모든 REST 제약을 만족하는 것은 아니다. Fielding의 REST 정의를 함께 읽으면 구분에 도움이 된다.

GraphQL: 응답 필드 선택

다음은 읽기 전용 스키마의 일부다.

type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}
type Post {
  id: ID!
  title: String!
  content: String!
}
type Query {
  user(id: ID!): User
}

클라이언트는 필요한 필드를 선택한다.

query GetUserWithPosts($userId: ID!) {
  user(id: $userId) {
    name
    posts { title }
  }
}

변수는 {"userId":"123"}이며, 해당 데이터가 있으면 응답의 형태는 다음과 같다.

{"data":{"user":{"name":"Won","posts":[{"title":"API 설계"}]}}}

스키마·쿼리·응답을 나란히 보면 클라이언트가 선택하는 것은 응답의 모양이라는 점이 드러난다. 서버의 resolver가 실제 데이터를 어떻게 가져오는지는 별도 구현이다. 필드 선택과 변수 사용은 GraphQL Queries 문서에서 확인할 수 있다.

구현에서 놓치기 쉬운 부분

아래 코드는 위 배열을 사용한 교육용 처리 로직이다. 실행 가능한 전체 서버 구성은 아니며, 인증·입력 검증·데이터베이스 연결은 포함하지 않는다.

Express의 조회 핸들러

app.get('/api/users/:id', (req, res) => {
  const user = users.find(u => u.id === req.params.id);
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
});

app.get('/api/users/:id/posts', (req, res) => {
  const userPosts = posts.filter(p => p.userId === req.params.id);
  res.json(userPosts);
});

const posts = posts.filter(...)처럼 배열 이름을 다시 선언하면 우변의 posts가 아직 초기화되지 않은 지역 변수를 가리켜 오류가 발생한다. 결과 배열에는 userPosts라는 다른 이름을 사용한다.

GraphQL의 resolver

const resolvers = {
  Query: {
    user: (_, { id }) => users.find(u => u.id === id) ?? null,
  },
  User: {
    posts: parent => posts.filter(p => p.userId === parent.id),
  },
};

이 예제의 데이터는 메모리에 있다. 실제 데이터베이스에서 사용자마다 게시글을 따로 조회하면 N+1 문제가 생길 수 있다. 요청 한 번에 묶였다는 사실만으로 DB 쿼리 횟수가 줄었다고 판단하면 안 된다. 배치 조회, 쿼리 깊이·복잡도 제한, 페이지네이션을 검토해야 한다. GraphQL Performance 문서에 관련 방어 방법이 정리되어 있다.

캐싱·변경·권한 비교

판단 항목 REST 기반 HTTP API GraphQL
응답 형태 서버가 정의하며 필드 선택 기능을 추가할 수 있음 클라이언트가 스키마 안에서 필드 선택
캐싱 GET URL과 HTTP 캐시 정책을 활용하기 쉬움 전송 방식·쿼리와 변수·사용자별 응답을 고려해 캐시 설계
변경 관리 호환되는 필드 추가와 깨지는 변경을 구분 필드 추가·deprecation 활용, 필드 삭제와 타입 변경은 주의
조회 비용 엔드포인트별 비용을 추적 중첩 필드와 resolver별 비용도 추적
권한 리소스·작업에 대한 권한 확인 필드·객체 접근에 대한 권한 확인

REST가 항상 /v1, /v2를 필요로 하는 것은 아니다. GraphQL도 필드를 삭제하거나 의미를 바꾸면 기존 클라이언트가 깨질 수 있다. 사용 현황을 확인하고 폐기 기간을 정하는 것이 중요하다. GraphQL Schema Design은 스키마를 진화시키는 방식을 설명한다.

GraphQL도 GET 요청과 캐시를 사용할 수 있지만, 하나의 URL을 단순히 캐시 키로 쓰는 것만으로는 쿼리·변수·사용자 차이를 표현하기 어렵다. GraphQL Caching을 참고해 객체 식별과 클라이언트 캐시도 함께 고려한다.

두 방식 모두 업무 로직에서 권한을 확인해야 한다. GraphQL 스키마가 있다는 사실이 권한 검증을 대신하지 않는다. Authorization 문서는 권한 로직을 업무 계층에 두는 원칙을 다룬다.

내 프로젝트에서 선택할 기준

다음은 위 비교를 바탕으로 한 판단 순서다.

  1. 화면마다 필요한 데이터 조합이 얼마나 다른가? 단순 CRUD와 고정된 응답이면 기존 REST 구조부터 검토한다. 여러 클라이언트가 서로 다른 중첩 데이터를 요구한다면 GraphQL의 필드 선택이 도움이 될 수 있다.
  2. 병목은 어디인가? 요청 수, 전송량, DB 조회 수, 서버 처리 시간을 각각 측정한다. 네트워크 왕복 감소와 DB 비용 감소를 같은 것으로 취급하지 않는다.
  3. 운영 장치를 갖출 수 있는가? 권한, 페이지네이션, 제한, 관측과 캐싱을 설계할 시간을 포함한다.
  4. 변경으로 얻는 이익이 있는가? 기존 API 전체를 교체하기 전에 한 화면의 요구사항으로 두 설계를 비교한다.

Subscription은 이벤트 스트림을 위한 GraphQL 기능이며, 전송 방식과 연결 운영은 별도 선택이다. REST 기반 서비스도 SSE나 WebSocket을 함께 사용할 수 있으므로 실시간 기능 하나만으로 결론을 내리지는 않는다. GraphQL Best Practices를 참고한다.

정리

이 예제에서는 GraphQL로 화면에 필요한 필드를 한 쿼리에 표현할 수 있었다. REST도 응답 설계를 바꾸면 비슷한 요구를 충족할 수 있다. 최종 선택은 데이터 조합의 다양성, 캐싱, 변경 관리와 운영 비용을 확인한 뒤 내린다.

Day-20