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전체가 nullusers: ... !→ root data로 전파 → data: null
→ [User!]! 같은 이중 non-null 리스트는 한 원소의 실패가 리스트 전체를 죽인다. 그래서 기본은 nullable 권장이라는 GraphQL 모범사례가 있다.
What — formatted vs original error
분리되는 두 단계
-
리졸버에서 던진 에러 (original)
throw new Error('crashed')또는Promise.reject(...)- 서버 메모리에 stack trace 포함
-
클라이언트에 보내는 에러 (formatted)
- spec의
GraphQLError모양에 맞춰 가공됨 - stack trace는 제거 (보안)
- spec의
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에서 흔히 쓰는 분류:
| 에러 클래스 | code | HTTP 의미 매핑 |
|---|---|---|
AuthenticationError | UNAUTHENTICATED | 401 |
ForbiddenError | FORBIDDEN | 403 |
UserInputError | BAD_USER_INPUT | 400 |
ValidationError | GRAPHQL_VALIDATION_FAILED | (스키마 검증 단계) |
| 기본 | INTERNAL_SERVER_ERROR | 500 |
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 error는 formatted error로 가공되어 전송된다 — sanitize는 서버 책임.
다음: 06 — Async & Promises — 위의 모든 동작이 비동기 리졸버에서도 같게 동작하는 메커니즘.