Post

GraphQL

GraphQL

git : https://github.com/handsome-tiger-789/graphql-demo

GraphQL 왜 써야하는가?

MSA 구조가 많아지면서 프론트엔드에서 호출 해야하는 end point가 다양해지기 시작했다.
백엔드 입장에서도 API가 많아지거나 하나의 API에 담아주는 데이터가 많아지면서 네트워크에서 오고가는 데이터의 양이 많아지기도 했다.

시대가 PC에서 모바일 환경이 더 주가 되기 시작하면서 통신 시 많은 데이터가 오가면 느리기도 하고 데이터 소모가 더 많아지는 문제가 발생했다.
(이를 해결하려면 각 호출 기능마다 필요한 데이터만 보내야하는데 그러면 API 개수가 많아진다.)

GraphQL은 이 두가지를 해결할 수 있는 대안이다.

  1. end point의 통일
    /graphql 로 end point는 통일 되고 원하는 데이터 쿼리를 body에 담아 호출할 수 있다.
  2. 필요한 데이터만 응답받을 수 있다 (데이터 소모 감소)
    백엔드 서버에선 모든 컬럼을 조회해도 프론트엔드가 호출 시 보낸 쿼리에 맞는 컬럼만 응답해준다.
    (API 호출 시 서버에선 20개의 컬럼을 조회해도 프론트엔드가 5개의 컬럼만 요청한 경우 네트워크 응답은 요청된 5개의 컬럼만 담긴다.)



Spring Boot 에 GraphQL 적용하기


의존성 추가

1
implementation 'org.springframework.boot:spring-boot-starter-graphql:4.0.2'

spring-boot-starter 로 제공되는 graphql 의 의존성을 추가한다.
(DB연결 스펙은 원하는대로 선택)

graphql 설정 (properties)

application.yml (또는 properties) 에 graphql 설정을 추가한다.

1
2
3
4
5
6
7
8
spring:
...
# GraphQL  
graphql:  
  graphiql:  
    enabled: true  
  http:  
    path: /graphql


graphql을 활성화 하고 endpoint url 설정에 관한 내용

schema 정의

resources > graphql > schema.graphqls 파일에 응답하고자 하는 데이터를 정의한다.
(아래 나올 @QueryMapping 에 사용될 필드를 정의한다.)


작성 예시

1
2
3
4
5
6
7
8
9
10
11
12
type Query {  
    members: [MemberResponse]  
}

type MemberResponse {  
    id: ID!  
    name: String!  
    email: String!  
    birth: String!  
    createdAt: String!  
    updatedAt: String  
}

해석은 어렵지 않다.

type Query 부분의 키(members)는 @QueryMappingmembers() 호출한다.
그 값(MemberResponse) 부분은 아래 정의한 타입 이름(매핑을 위한)으로 members() 에서 return 되는 객체와 매핑된다.


type MemberResponse 를 뜯어보면 키는 컬럼명, 값은 매핑될 자료형이다. (camelCase 규칙으로 java 객체와 자동으로 매핑된다.)
이때 ! 는 필수여부를 지정한다.

위와 같이 작성된 API의 요청 바디는 아래와 같다.

1
2
3
4
5
6
POST http://localhost:8081/graphql  
Content-Type: application/json
  
{  
  "query": "{ members { id name email birth createdAt updatedAt } }"  
}

members라는 이름으로 MemberResponse 값을 받기로 하였으며, MemberResponse 내 받기 원하는 컬럼을 나열한다.


우선 GraphQL을 위와 같이 구성하고
좀 더 구체적인 CRUD API를 아래 만들어보자.


조회 API (READ)

회원 목록 조회 코드로 비교해보자.

스키마부터 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
type Query {  
    members: [MemberResponse]  
}

type MemberResponse {  
    id: ID!  
    name: String!  
    email: String!  
    birth: String!  
    createdAt: String!  
    updatedAt: String  
}

목록 조회 시 요청할 쿼리 객체의 이름은 members로 작성한다.
그리고 그 안의 객체를 아래 MemberResponse 로 작성하여 컬럼명과 타입, 필수값(!)을 작성한다.
!가 없는 컬럼은 null이 반환될 수 있고, !가 있는 컬럼에서 null이 반환되려 하면 오류가 발생한다.

Controller는 아래와 같다.

1
2
3
4
@QueryMapping  
public List<MemberResponse> members() {  
    return memberService.getMembers();  
}

차이점
기존 REST API였다면 @GetMapping 을 썼을 것이다.
GraphQL 에서는 @QueryMapping을 사용한다.

스키마에서 작성한 매서드명인 members() 로 메서드를 작성했고, MemberResponse는 스키마와 이름이 같지 않아도 상관 없다.

API 실행 테스트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
### 회원 목록 조회  
POST http://localhost:8081/graphql  
Content-Type: application/json  
  
{  
  "query": "{ members { id name email birth createdAt updatedAt } }"  
}

---
### 응답
HTTP/1.1 200 
Content-Type: application/json


{
  "data": {
    "members": [
      {
        "id": "2",
        "name": "김영희",
        "email": "kim@example.com",
        "birth": "1995-11-20",
        "createdAt": "2026-02-23T03:16:53.049971",
        "updatedAt": null
      },
      ...
    ]
  }
}

이제 회원 조회 또한 POST 요청으로 진행되며 요청 body에 내가 원하는 컬럼을 선택하여 작성한다.
응답은 200 코드로 data > members 형태로 배열로 돌아온다.

등록 API (CREATE)

그렇다면 Create 작업은 어떻게 이루어지는지 보자.

스키마는 아래와 같이 작성된다.

1
2
3
4
5
6
7
8
9
10
type Mutation {  
    createMember(input: MemberCreateInput!): MemberResponse  
}

input MemberCreateInput {  
    name: String  
    email: String  
    password: String  
    birth: String  
}

조회에서 작성한 스키마와 많이 다른 모습이 보인다.

우선 input 이라는 타입이 새로 등장했다.
input은 클라이언트로 부터 데이터를 받을때 사용되는 타입이다.
MemberCreateInput은 클라이언트로 어떤 값을 받을 것인지, 필수값은 무엇인지, enum을 이용한다면 정해진 값 중에만 입력이 가능하도록 등 다양한 validation 작성이 가능해진다.

그리고 기존에는 type Query {} 로 작성 했으나 이번에는 type Mutation {} 으로 작성되었다.
크게 다르지 않고 이번엔 @MutationMapping 중에 createMember(MemberCreateInput!) 으로 된 메서드를 실행 할 것이며 : MemberResponse 로 아까 위에서 만들어둔 MemberResponse 객체를 응답해줄 것이다.

컨트롤러는 아래와 같다.

1
2
3
4
@MutationMapping  
public MemberResponse createMember(@Argument MemberCreateRequest input) {  
    return memberService.createMember(input);  
}

@MutationMapping 으로 작성 되었으며 매개변수에는 @Argument 어노테이션이 붙는다.
정의해둔대로 MemberResponse를 응답하도록 되어있다.

이제 실제 실행 테스트를 진행하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
### 회원 생성 - 정상  
POST http://localhost:8081/graphql  
Content-Type: application/json  
  
{  
  "query": "mutation { createMember(input: { name: \"홍길동\", email: \"hong22@example.com\", password: \"pass1234\", birth: \"1990-01-15\" }) { id name email birth createdAt updatedAt } }"  
}

---
### 응답
HTTP/1.1 200 
Content-Type: application/json

{
  "data": {
    "createMember": {
      "id": "45",
      "name": "홍길동",
      "email": "hong22@example.com",
      "birth": "1990-01-15",
      "createdAt": "2026-02-23T18:36:23.983244",
      "updatedAt": "2026-02-23T18:36:23.983244"
    }
  }
}

우선 요청 body 부터 뜯어보면 기존에는 query 다음 바로 응답 받고자 하는 메서드명과 컬럼을 나열했으나,
input 하고자 하는 값은 mutation 으로 한번 감싸서 작성된다.
응답은 동일하게 뒤쪽에 메서드명 없이 컬럼을 나열한다.

이제 오류를 한번 발생해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

### 회원 생성 - 필드 누락 (name 빈값 → INVALID_INPUT)
POST http://localhost:8081/graphql  
Content-Type: application/json  
  
{  
  "query": "mutation { createMember(input: { email: \"test@example.com\", password: \"pass1234\", birth: \"1990-01-15\" }) { id } }"  
}

---
### 응답 1)
HTTP/1.1 200 
Content-Type: application/json

{
  "errors": [
    {
      "message": "이름은 필수입니다",
      "locations": [
        {
          "line": 1,
          "column": 12
        }
      ],
      "path": [
        "createMember"
      ],
      "extensions": {
        "classification": "INVALID_INPUT"
      }
    }
  ],
  "data": {
    "createMember": null
  }
}

---
### 응답 2)
HTTP/1.1 200 
Content-Type: application/json

{
  "errors": [
    {
      "message": "Validation error (WrongType@[createMember]) : argument 'input' with value 'ObjectValue{objectFields=[ObjectField{name='email', value=StringValue{value='test@example.com'}}, ObjectField{name='password', value=StringValue{value='pass1234'}}, ObjectField{name='birth', value=StringValue{value='1990-01-15'}}]}' is missing required fields '[name]'",
      "locations": [
        {
          "line": 1,
          "column": 25
        }
      ],
      "extensions": {
        "classification": "ValidationError"
      }
    }
  ]
}


응답 1과 응답 2의 오류에는 차이가 발생한다.
응답 1은 스키마에 name 컬럼을 필수값으로 지정하지 않았기 때문에 java 에서 validation 처리가 가능 했고,
응답 2는 스키마에 name 컬럼을 필수값으로 지정하여 비즈니스 로직까지 넘어오지 않고 처리되었기 때문에 차이가 발생했다.

위 테스트는 사실 응답 코드가 계속 200으로 발생하여 오류코드를 작성하려다 알게 된 사실이다.

GraphQL은 여러 쿼리를 한번에 요청할 수 있는 특성때문인지 오류를 HTTP 상태 코드로 응답하지 않고 errors 배열을 통해 바디로 응답해준다.
비즈니스로직에서 처리하도록 하면 HTTP 코드 또한 커스텀이 가능한 듯 한데 이 부분은 설계에 맞춰 진행하면 될 듯 하다.

결론

✅ READ는 Query, CUD는 Mutation
✅ 기본적으로 응답코드는 무조건 200
errors로 조회 할 것인지? HTTP 응답 코드를 커스텀 할 것인지? 그렇다면 validation 설정 레벨도 확인할 것
✅ graphsql 스키마 패키지 규칙 및 명명규칙 확인할 것

GraphQL 기술은 이미 알고 있었으나 내가 본 글에서는 크로스 플랫폼에서의 사용을 예시로 들었던 것인지
frontend > graphql server > backend 이렇게 중간에서 BFF 마냥 동작하는걸로 이해하고 있었다.
역시 직접 해보지 않고는 섣불리 안다고 할 수 없는 듯!

This post is licensed under CC BY 4.0 by the author.