🔷 GraphQL2. 실행 & 리졸버05 — 에러와 부분 응답 (Errors & Partial Response)

05 — 에러와 부분 응답 (Errors & Partial Response)

질문: 트리 순회 중 한 노드가 실패하면 응답에는 어떤 일이 일어나는가? 한 줄 답: 응답은 { data, errors } 둘 다 있을 수 있다 — 부분 성공이 정상이다. 그리고 non-null 필드가 null이 되면 부모로 전파된다 (null propagation). 이게 spec에 명시된 동작이다.


Why — REST와 다른 패러다임

REST에서 에러는 HTTP 상태 코드다.

GET /users/1/posts
→ 200 OK + body  (성공)
→ 404 Not Found  (user 없음)
→ 500 Internal Error  (DB 죽음)

전부 아니면 전무. 부분 성공이라는 개념이 없다.

GraphQL은 다르다.

query {
  me { name }              # 성공 → "Ada"
  posts { title }          # 실패 → DB 타임아웃
  weather(city: "Seoul") { # 성공 → {...}
    temp
  }
}
{
  "data": {
    "me": { "name": "Ada" },
    "posts": null,
    "weather": { "temp": 24 }
  },
  "errors": [
    {
      "message": "DB timeout",
      "path": ["posts"],
      "locations": [{ "line": 3, "column": 3 }]
    }
  ]
}

HTTP 상태는 대부분 200. 한 필드의 실패가 전체 요청을 망치지 않는다. 클라이언트는 받은 만큼 그리고, 못 받은 부분만 에러 처리한다.

“부분 응답”이 정상 동작이다. 이게 GraphQL이 그래프 쿼리 언어임을 가장 잘 드러내는 동작 — 그래프의 한 가지가 끊겨도 나머지는 살아 있다.


How — 응답 형식

spec이 정한 응답 모양 (§7)

type GraphQLResponse = {
  data?:       { ... } | null,    // 성공 데이터 (부분일 수 있음)
  errors?:     GraphQLError[],     // 에러 배열 (없으면 생략)
  extensions?: { ... },            // 트레이스·캐시 힌트 등 자유 영역
}

errors 배열의 각 원소 (§7.1.2)

type GraphQLError = {
  message:    string;             // 필수 — 사람이 읽을 메시지
  locations?: { line, column }[]; // 쿼리 문자열에서의 위치
  path?:      (string | number)[]; // 응답 트리에서의 좌표
  extensions?: {                  // 자유 영역 (custom code, stacktrace 등)
    code?:   string;              // "UNAUTHENTICATED" | "FORBIDDEN" | ...
    [k: string]: any;
  };
}

path가 결정적이다 — 클라이언트가 응답 트리의 어느 위치가 망가졌는지 정확히 안다.

{
  "errors": [{
    "message": "Cannot return null for non-nullable field Post.title",
    "path": ["users", 2, "posts", 0, "title"],
    "locations": [{ "line": 5, "column": 9 }]
  }]
}

How — null propagation

규칙 (spec §6.4.3)

Non-Null 필드의 CompleteValue가 null을 반환하면, 그 자리에 에러를 만들고 부모 필드의 값을 null로 만든다. 부모도 Non-Null이면 또 위로 전파한다.

예 — nullable

type User {
  name: String       # nullable
  posts: [Post!]     # 리스트 자체는 nullable
}
User: {
  name: () => { throw new Error('crashed') },
  posts: () => [...],
}
{
  "data": {
    "user": {
      "name": null,
      "posts": [...]
    }
  },
  "errors": [{ "message": "crashed", "path": ["user", "name"] }]
}

name이 nullable이라 그 자리에서 멈춤. user 자체는 살아남는다.

예 — non-null

type User {
  name: String!      # non-null
  posts: [Post!]
}

같은 에러를 던지면:

{
  "data": {
    "user": null
  },
  "errors": [{
    "message": "crashed",
    "path": ["user", "name"]
  }]
}

name!이 null이 못 되니, user 전체가 null이 된다. 이게 propagation의 한 단계.

끝까지 전파

type Query {
  user: User!        # non-null
}
type User {
  name: String!      # non-null
}
{
  "data": null,
  "errors": [{
    "message": "crashed",
    "path": ["user", "name"]
  }]
}

name! null → user!로 전파 → root까지 → data 전체가 null.

⚠️ 한 필드의 작은 실패가 root까지 올라간다. non-null을 남발하면 작은 장애로 응답 전체가 사라진다.

시각화

리스트와의 상호작용

type Query { users: [User!]! }
type User { id: ID!, name: String! }

users[2].name이 null을 반환하면?

  • name: String! → null 못 됨 → users[2] 전체가 null이 되어야 하지만…
  • [User!]! → 리스트 안의 원소도 non-null → 결국 users 전체가 null
  • users: ... ! → root data로 전파 → data: null

[User!]! 같은 이중 non-null 리스트는 한 원소의 실패가 리스트 전체를 죽인다. 그래서 기본은 nullable 권장이라는 GraphQL 모범사례가 있다.


What — formatted vs original error

분리되는 두 단계

  1. 리졸버에서 던진 에러 (original)

    • throw new Error('crashed') 또는 Promise.reject(...)
    • 서버 메모리에 stack trace 포함
  2. 클라이언트에 보내는 에러 (formatted)

    • spec의 GraphQLError 모양에 맞춰 가공됨
    • stack trace는 제거 (보안)

Apollo Server의 formatter

const server = new ApolloServer({
  formatError: (formattedError, originalError) => {
    // formattedError = 전송될 모양
    // originalError = 서버 내부 에러 객체
 
    if (originalError instanceof PrismaError) {
      return {
        message: 'Database error',          // 일반화
        extensions: { code: 'DB_ERROR' },   // 코드는 보존
      };
    }
    return formattedError;
  },
});

→ DB 에러 메시지가 그대로 클라이언트에 노출되면 SQL 구조가 새어나간다. formatter에서 sanitize는 필수.

커스텀 에러 클래스 (관용)

Apollo / GraphQL Yoga에서 흔히 쓰는 분류:

에러 클래스codeHTTP 의미 매핑
AuthenticationErrorUNAUTHENTICATED401
ForbiddenErrorFORBIDDEN403
UserInputErrorBAD_USER_INPUT400
ValidationErrorGRAPHQL_VALIDATION_FAILED(스키마 검증 단계)
기본INTERNAL_SERVER_ERROR500

extensions.code로 클라이언트가 자동 분기할 수 있다.

// 클라이언트
if (error.extensions?.code === 'UNAUTHENTICATED') {
  refreshTokenAndRetry();
}

What-if — 잘못 이해하면

1) “에러면 HTTP 4xx/5xx 반환되겠지”

대부분 200이다. 부분 성공이 정상 동작이라서 — 단 한 가지 예외:

  • 쿼리 파싱/검증 실패 → 400 (spec이 권장하지 않지만, 거의 모든 서버가 400 반환)
  • 인증 실패 (요청 진입 자체) → 401
  • 실행 중 에러 → 200 + errors 배열

→ 클라이언트가 HTTP 상태로만 분기하면 GraphQL 에러를 놓친다. response.errors항상 체크.

2) “non-null이 안전하다고 모든 곳에 !

GraphQL 안티패턴 1위. spec의 Lee Byron 본인 블로그 글“Non-null이 디폴트로 들어가면 안 된다”. nullable이 디폴트여야 한다는 이유:

  • 작은 장애가 root까지 올라간다 (위 예 참고)
  • 스키마 진화가 어려워진다 (nullable → non-null은 breaking change)
  • 클라이언트에 부분 데이터를 못 보낸다

원칙: 정말로 그 데이터 없이는 부모가 의미 없는 경우만 non-null.

3) “errors 배열을 못 본 척”

// ❌ 클라이언트
const { data } = await client.query({ query: ... });
useData(data);   // errors 무시!

data.user는 받았지만 data.user.posts는 에러로 null일 수 있다. errors를 처리해야 partial UI를 그릴 수 있다.

4) “에러 메시지에 비밀 노출”

throw new Error(`SELECT * FROM users WHERE email='${email}' failed`);
// → 클라이언트에 SQL이 그대로 노출

formatError에서 강제 sanitize. 또는 ApolloError처럼 허용된 메시지만 통과시키는 패턴.

5) “path를 무시하고 message만 본다”

path클라이언트 UI에 직접 매핑할 수 있는 좌표다. ["user", "posts", 2, "title"]이면 그 위치의 UI만 에러 상태로 표시 — 나머지는 정상. 이게 GraphQL UX의 강점인데 활용 안 하면 아깝다.

6) “리스트의 한 원소가 실패하면 그 원소만 null”

만약 [Post] (nullable list of nullable Post)면 그렇다posts: [{title:"A"}, null, {title:"C"}]로 응답. 하지만 [Post!]그 자리에서 멈춤 → list 자체가 null. [Post!]!부모로 전파. 타입 한 글자가 전파 범위를 바꾼다.


Insight — partial response가 만든 UX 패턴

”GitHub GraphQL이 부분 응답을 적극 활용”

GitHub API v4는 repository(owner: "x", name: "y")없는 repo이면 errors에 NOT_FOUND를 넣고 나머지 필드는 그대로 반환한다. 한 화면에 5개 repo의 카드를 그릴 때, 3개는 정상 표시 / 2개는 에러 카드 — 한 번의 요청으로 완성.

REST였다면 5번의 요청 + 일부 실패를 client에서 재조합해야 했다.

”FB의 originalGraphQL이 이걸 선택한 이유”

Lee Byron의 회고 — 모바일 환경에서는 네트워크가 부분적으로 끊긴다. 한 정보 조각이 실패했다고 전체 화면을 못 그리는 건 사용자 경험에 치명적이다. 부분 응답이 디폴트가 되어야 했다.

“Mobile first” 시대의 결정이 spec에 박혔다 — 데스크톱 일변도였다면 전체 성공 or 전체 실패가 더 자연스러웠을지 모른다.

”null propagation의 두 얼굴”

이 동작은 디자인 결정이지 기술적 필연이 아니다.

  • 장점: non-null 보장이 클라이언트 타입 시스템과 맞물린다. String!을 받으면 클라이언트는 null 체크 없이 쓴다.
  • 단점: 한 작은 실패가 root까지 올라간다.

Relay는 @throwOnFieldError 같은 directive 실험으로 원하는 곳에서만 전파하는 방향을 모색 중. 미래 GraphQL은 전파 범위를 클라이언트가 제어하게 될 가능성도.

”errors가 stable shape다 — 그래서 monitoring이 쉽다”

error.extensions.code, error.path[0]집계가 깔끔하다.

Top 10 error paths (last 1h):
  [posts, 0, title]   — 234 회 (DB timeout)
  [me]                — 89  회 (UNAUTHENTICATED)
  [search]            — 41  회 (RATE_LIMITED)

REST의 500 Internal Server Error: ...보다 분석 가능성이 훨씬 높다 — 어떤 필드어떤 종류로 실패했는지 자체 메타데이터.


요약

응답은 { data, errors } 두 키가 공존할 수 있다 — 부분 성공이 정상이다. non-null 필드가 null이면 부모로 전파된다. !를 남발하면 작은 실패가 root까지 올라간다. errors는 message + path + locations + extensions — path가 응답 좌표라서 UI에 직접 매핑된다. 리졸버에서 던진 original errorformatted error로 가공되어 전송된다 — sanitize는 서버 책임.

다음: 06 — Async & Promises — 위의 모든 동작이 비동기 리졸버에서도 같게 동작하는 메커니즘.