API 설계 방법에 있어 REST와 비교되는 GraphQL의 핵심적인 개념에 대해 공부해보자. GraphQL은 특정 언어에 종속된 기술이 아니기 때문에, 여기서는 언어 독립적인 방식으로 설명할 것임을 밝힌다. GraphQL 클라이언트/서버 라이브러리는 여기서 설명하는 핵심 개념을 이해한 후에 자신만의 기준으로 적절한 것을 골라 사용하기 바란다.
1. 핵심 개념
1-1. GraphQL이란?
GraphQL은 REST와 마찬가지로 API를 설계하는 여러 방식 중 하나로, 일종의 쿼리 언어이다. 그런데 이는 특정 언어에 종속되는 기술이 아니다. 대신, 어느 곳에서든 쉽게 적용되어 활용할 수 있도록 언어별로 여러 라이브러리가 제공되고 있다(아래 링크 참조).
GraphQL: https://graphql.org/code/
1-2. GraphQL은 어떻게 동작하는가?
서버에서는 클라이언트가 GraphQL 방식으로 요청할 수 있는 데이터의 타입들과 각 타입에 대해 요청할 수 있는 필드들을 정의하여 타입 시스템을 구축하고, 각 타입의 각 필드에 대한 요청을 해석 및 처리하는 로직을 Resolver 함수들로 구현한다. 이후 클라이언트가 GraphQL 쿼리를 보내면, 서버는 미리 정의해둔 타입 시스템에 따라 해당 쿼리를 검증(Validation)하고, 문제가 없다면 미리 구현해둔 Resolver 함수들을 호출하여 해당 쿼리를 실행(Execution)한 결과를 클라이언트에게 응답한다.
이때 GraphQL 방식의 요청(= GraphQL 쿼리)이란, 클라이언트가 요청하는 데이터들의 타입 및 필드 정보를 GraphQL에서 정해둔 형식의 문자열(= 쿼리문)로 표현하여 Body에 담고 메소드로는 POST를 사용하는 요청을 말한다.
1-3. GraphQL은 왜 사용하는가?
REST로 설계된 API의 경우 필요한 데이터만 요청하려면 쿼리 파라미터 등을 이용해야 하므로 요청 자체가 복잡해지기 쉽고, 요청의 형식으로부터 응답의 형식을 정확히 예측하기는 어렵다. GraphQL은 이러한 문제를 해결한다. 클라이언트가 필요한 데이터만 서버에게 요청할 수 있고, 요청의 형식으로부터 응답의 형식을 정확히 예측할 수 있기 때문이다.
1-4. 예시
그렇다면 GraphQL의 간단한 예시를 하나 살펴보자. 각 코드에 대한 자세한 내용은 뒤에서 하나씩 설명할 테니 여기서는 간단히 맛만 보고 넘어가도록 하자. 먼저, 서버에서 타입 시스템과 Resolver 함수들이 다음과 같이 정의/구현되어 있다고 가정하자.
그리고 클라이언트가 다음과 같은 GraphQL 쿼리를 보냈다고 가정하자. (실제로는 이와 같은 형식의 문자열, 즉 쿼리문을 Body에 담고 메소드로는 POST를 사용하는 요청으로 구현될 것이다.)
그러면 우선 서버는
Query
타입에 me
필드가 있는지 확인하고, 이어서 User
타입에 id
, name
, age
필드가 있는지 등을 확인하여 쿼리의 유효성을 판단한다. 이것이 검증(Validation) 단계이다.검증 단계를 통과하면 이제
Query
타입의 me
필드를 해석 및 처리하는 Query_me()
함수를 호출하여 User
타입의 객체를 얻고, 그 객체를 인자로 하여 User
타입의 id
, name
, age
필드를 해석 및 처리하는 User_id()
, User_name()
, User_age()
함수를 호출한다. 이것이 클라이언트에게 응답할 데이터를 계산하는 실행(Execution) 단계이다.결과적으로, 해당 예시에서 서버는 클라이언트에게 다음과 같은 JSON 데이터를 응답할 것이다. 앞서 설명했듯이, 클라이언트가 서버의 타입 시스템만 인지하고 있으면 요청의 형식으로부터 응답의 형식을 정확히 예측할 수 있다는 사실을 확인할 수 있다.
2. 타입 시스템 (Schema)
클라이언트가 보내는 GraphQL 쿼리는 결국 특정 타입에 대해 특정 필드를 선택하는 과정의 연속이다. 예를 들어, 위에서 살펴본 예시에서 다음 쿼리는
Query
타입에 대해 me
필드를 선택하고, 다시 me
필드의 타입인 User
타입에 대해 id
, name
, age
필드를 선택하는 과정에 불과하다.그런데 클라이언트는 서버에서 타입과 필드가 어떻게 정의되어 있는지 알고 있어야 적절한 쿼리를 보낼 수 있다. 이것이 서버에서 타입 시스템(Schema)을 정의해야 하는 이유이다. 그리고 서버가 쿼리를 검증하고 실행하는 단계도 이러한 타입 시스템에 근거하여 처리된다. 그렇다면 GraphQL 타입 시스템에서 정의할 수 있는 타입들로는 무엇이 있는지 하나씩 알아보도록 하자.
2-1. Query/Mutation 타입 (feat. Operation)
Query 타입과 Mutation 타입은 클라이언트가 서버에게 요청할 때 엔트리 포인트로 사용하는 타입으로, 클라이언트가 요청할 수 있는 API를 필드로서 표현한다. 예를 들어 Query 타입과 Mutation 타입이 다음과 같이 정의된 타입 시스템이라고 가정하자.
그러면 Query 타입으로 요청할 수 있는 API로는
myId
, myName
, myAge
가 있고, 이들은 각각 ID
타입의 데이터, String
타입의 데이터, Int
타입의 데이터를 응답한다는 것을 알 수 있다. 그리고 Mutation 타입으로 요청할 수 있는 API로는 increaseMyAge
가 있고, 이는 Int
타입의 데이터를 응답한다는 것을 알 수 있다. 이러한 API들에 대한 쿼리문은 다음과 같을 것이다.여기서
query
와 mutation
은 Operation 타입을, MyInfo
와 ChangeMyInfo
는 Operation 이름을 나타낸다. 만약 하나의 요청에 여러 Operation이 담겨 있다면 구분을 위해 Operation 타입과 이름을 반드시 명시해줘야 하는데, 타입이 query
인 Operation 하나만 존재한다면 Operation 타입과 이름을 생략해도 된다. 그러나 웬만하면 Operation 타입과 이름은 항상 명시해주는 게 좋다. 클라이언트 단에서의 디버깅이나 서버 단에서의 로깅 시에 문제가 되는 Operation을 특정할 때 굉장히 많은 시간을 절약시켜주기 때문이다. 또한 (뒤에서 설명할) Variable을 사용하려면 Operation 타입과 이름이 어차피 반드시 필요하다.위 예시의 두 Operation을 실행한 결과, 즉 클라이언트에게 응답할 JSON 데이터는 각각 다음과 같을 것이다.
그런데 Query 타입과 Mutation 타입은 각각 언제 이용할까? API의 이름에서 눈치챘을 수도 있지만, 서버 단의 데이터를 특별히 조작하지 않고 조회만 한다면 Query 타입을 이용하고 서버 단의 데이터에 조작을 가한다면 Mutation 타입을 이용하는 것이 관습이다. 이는 마치 HTTP 메소드에서 GET과 POST의 관계와 유사하다.
참고로 Query 타입과 Mutation 타입은 세부적인 요청 내용을 필드로 표현한다는 점에서 이어서 설명할 Object 타입과 거의 동일하다. 단지 요청의 엔트리 포인트로 사용한다는 것만 다를 뿐이다.
2-2. Object 타입
Object 타입은 GraphQL 타입 시스템의 가장 기본적인 구성 요소로, 클라이언트가 요청할 수 있는 객체의 종류(타입 이름)와 모양(필드 구성)을 나타낸다. 이어서 설명할 Scalar/Enumeration 타입과는 달리 필드를 가지는데, 그러한 필드 각각도 특정 타입(Object, Scalar, Enumeration 중 하나)을 가진다. 요청의 엔트리 포인트가 아니라는 것 외에는 Query/Mutation 타입과 거의 동일하다.
bestFriend
필드에서 알 수 있듯이, Object 타입의 필드도 Object 타입일 수 있기 때문에 연관된 객체들을 한 번의 요청으로 가져오기가 수월하다. 반면 REST로 설계된 API의 경우 연관된 객체들을 한 번에 가져오기 어려워 여러 번의 요청을 해야 하는 경우가 많고, 한 번에 가져오려 해도 쿼리 파라미터 등을 이용해야 하므로 요청 자체가 복잡해지기 쉽다. GraphQL은 이런 걱정을 할 필요가 없다.2-3. Scalar 타입
Query/Mutation 타입의 필드와 Object 타입의 필드를 거치다 보면 결국 마지막에는 Scalar 타입으로 귀결된다. 즉, Scalar 타입은 쿼리의 말단 잎(leaf)과 같은 것이다. 따라서 Scalar 타입은 필드를 가지지 않는다. GraphQL에 기본적으로 내장된 Scalar 타입들은 다음과 같다.
Scalar 타입 | 설명 |
Int | 부호 있는 32비트 정수 |
Float | 부호 있는 Double-Precision 부동 소수점 실수 |
String | UTF‐8 문자열 |
Boolean | true 또는 false |
ID | 고유 식별자를 나타내는 타입으로, 시리얼라이즈 방식은 String 타입과 동일하다. 하지만 String 타입 대신 ID 타입을 사용하는 것은 해당 문자열이 인간이 읽는 용도가 아님을 나타낸다. |
또한 대부분의 GraphQL 구현체에서는 커스텀 Scalar 타입을 정의하는 방법도 제공한다. 예를 들어, 날짜 및 시각을 표현하는
Date
타입을 정의할 수도 있다. 이 경우 해당 타입의 시리얼라이즈 로직, 디시리얼라이즈 로직, 검증 로직 등은 직접 구현해줘야 한다. 물론 그 형식에 관한 약속에 대해서는 클라이언트도 반드시 인지하고 있어야 할 것이다.2-4. Enumeration 타입 (Enum 타입)
Enumeration 타입(Enum 타입)은 특정 몇 개의 값들로만 한정되는 특별한 종류의 Scalar 타입이다. 이는 필드 혹은 (뒤에서 설명할) Argument/Variable의 값이 특정 몇 개의 값들로만 한정되도록 강제할 때 사용된다. Enum 타입의 예시는 다음과 같다.
여러 GraphQL 구현체는 언어 종류에 따라 Enum 타입을 처리하는 방법이 조금씩 다르다. 예를 들어, JavaScript와 같이 Enum 타입을 지원하지 않는 언어에서는 Enum 타입의 값이 내부적으로는 정수로 맵핑될 것이다. 그러나 이러한 구현 상세까지 클라이언트가 알 필요는 없다. 클라이언트는 그저 약속한 이름을 이용하여 Enum 타입의 값을 표현하면 되기 때문이다.
2-5. 타입 Modifier (Non-Null, List)
필드 혹은 (뒤에서 설명할) Argument/Variable의 타입을 지정하는 부분에는 해당 값의 검증 로직에 영향을 주는 타입 Modifier를 첨가할 수 있다. 타입 Modifier에는 두 종류가 있다. 하나는 Non-Null 타입을 표현하는 느낌표(
!
)이고, 다른 하나는 List 타입을 표현하는 중괄호([
, ]
)이다. 예시를 보자.먼저, 타입의 오른쪽에 느낌표(
!
)가 붙으면 이는 Non-Null 타입이 된다. 즉, 그 자리에는 절대로 null
값이 올 수 없다는 뜻이다. 다음으로, 타입을 중괄호([
, ]
)로 감싸면 이는 List 타입이 된다. 즉, 그 자리에는 해당 타입의 값이 담긴 배열이 와야 한다는 뜻이다.앞서 말했듯 이는 필드 혹은 Argument/Variable의 값을 검증하는 로직에 영향을 미친다. 만약 서버가 쿼리를 실행한 결과 특정 필드에 적절한 타입의 값이 반환되지 않았다면 실행 에러가 발생할 것이고, 특정 Argument/Variable에 적절한 타입의 값이 전달되지 않았다면 서버에서 검증 에러가 발생할 것이다.
한편, Non-Null 타입과 List 타입은 함께 사용될 수도 있다. 예를 들어,
[String!]!
타입은 그 값이 null
이 아닌 배열이어야 하고 그 배열을 구성하는 각각의 요소도 null
이 아닌 값이어야 한다는 것을 나타낸다.2-6. 추상 타입 (인터페이스, Union 타입)
추상 타입은 여러 타입을 하나로 묶는 수단을 제공한다. 따라서 추상 타입을 사용하면 여러 타입의 객체가 반환될 수 있는 자리에 타입을 지정할 수 있다. 추상 타입으로는 인터페이스와 Union 타입이 있다.
인터페이스란 다른 타입이 구현할 수 있는 공통 필드 구성을 정의해둔 추상 타입을 말한다. 인터페이스를 구현하는 타입은 그러한 공통 필드들을 반드시 똑같이 정의해야 하고, 해당 타입에 대해서만 존재하는 필드들은 자유롭게 정의해주면 된다. 다음 예시를 보자.
다음으로 Union 타입이란 여러 Object 타입을
|
연산자로 연결하여 정의하는 추상 타입으로, 인터페이스와 비슷하지만 공통 필드 구성을 정의하지는 않는다는 차이가 있다.클라이언트는 추상 타입에 대해 쿼리를 보낼 때 그 추상 타입이 나타내는 여러 타입이 공통으로 가지는 필드 구성을 인지하고 있어야 한다. 만약 특정 타입에만 존재하는 필드를 요청하고 싶다면 (뒤에서 설명할) 인라인 Fragment를 사용해야 한다. 그리고 요청한 객체가 정확히 무슨 타입인지
String
타입의 값으로 알고 싶다면 메타 필드인 __typename
을 요청하면 된다. 다음 예시를 보자.2-7. Input 타입
(뒤에서 설명할) Argument/Variable에 지정할 수 있는 타입을 Input 타입이라고 한다. 지금까지 다룬 타입은 전부 Output 타입이다. Input 타입으로는 앞에서 설명한 Scalar/Enumeration 타입과 Input Object 타입이 있다. 앞에서 설명한 Object 타입과 추상 타입(인터페이스, Union 타입)은 Input 타입으로 사용할 수 없음에 주의하자.
이때 Input Object 타입은 사실상 Object 타입과 거의 동일한데, 선언 시
type
대신 input
키워드를 사용한다는 점과 필드가 Argument를 가질 수 없다는 점이 다르다. Input Object 타입의 예시는 다음과 같다.3. Argument/Variable
이제 앞에서 계속 별다른 설명 없이 언급했던 Argument와 Variable에 대해서 알아보자. 결론부터 얘기하자면 Argument는 필드의 매개변수, Variable은 쿼리의 매개변수이다. 그리고 둘 다 입력으로서의 기능을 하기 때문에 Input 타입(Scalar 타입, Enumeration 타입, Input Object 타입)만 지정할 수 있다. 그럼 각각의 개념에 대해 본격적으로 알아보도록 하자.
3-1. Argument
Argument는 필드의 매개변수이다. 서버에서 타입 시스템을 정의할 때 각 타입의 각 필드에 대해 매개변수를 정의할 수 있는데, 이를 Argument라고 부른다. 그래서 REST에서는 매개변수를 넘기려면 URL 세그먼트나 쿼리 파라미터 등을 복잡하게 이용해야 하는 반면, GraphQL에서는 필드별로 Argument라는 매개변수를 깔끔하게 넘길 수 있다. 예시를 한 번 살펴보자.
Query
타입의 user
필드에는 id
라는 이름의 Argument가, User
타입의 name
필드에는 lan
이라는 이름의 Argument가 정의되어 있다. 따라서 클라이언트에서 쿼리문을 작성할 때 두 Argument의 값을 지정해준 것을 볼 수 있다. 물론 lan
Argument의 경우에는 디폴트 값이 정의되어 있기 때문에 지정해주지 않아도 문제가 없을 것이다. 이렇게 전달되는 Argument의 값들은 서버에서 호출되는 Resolver 함수들의 인자로 전달될 것이다.만약 서로 다른 Argument로 동일한 필드를 요청하고 싶다면, 다음과 같이 필드 별칭(Alias)을 사용하면 된다.
3-2. Variable
Variable은 쿼리(정확히는 Operation)의 매개변수이다. 따라서 서버가 아닌 클라이언트에서 정의하는 매개변수이다. Variable을 선언하는 방법은 TypeScript의 함수 선언 방식과 매우 유사하다. Operation 이름 옆에
$variableName: Type
형식으로 Variable을 선언하면 된다. 그리고 각 Variable의 값은 쿼리문과는 별개의 딕셔너리에 담고 이를 쿼리문과 함께 서버에게 보낸다. 그리고 이렇게 선언된 Variable은 쿼리문 내에서 접근할 수 있기 때문에, Argument에 정적인 값이 아닌 동적인 값을 넘겨주는 것도 가능해진다. 다음 예시를 보자.Variable 딕셔너리는 쿼리문과는 별도로 존재하기 때문에, 쿼리문은 건드리지 않고 특정 Variable의 값을 변경하며 Argument의 값을 동적으로 변경하는 것이 가능해진다. 그런데 만약 Variable 딕셔너리를 별도로 두지 않았다면 Argument의 값을 변경할 때마다 쿼리문 자체를 매번 다시 만들어야 할 것이다. 또한, Variable 딕셔너리를 별도로 두었기 때문에 동적인 값을 가지는 Argument가 무엇인지도 한눈에 쉽게 파악할 수 있다.
Variable의 타입이 Non-Null이라면 필수적인 Variable, 아니라면 선택적인 Variable이 된다. 이는 Argument에서도 마찬가지다. 그리고 만약 Non-Null 타입의 Argument에서 사용될 Variable이라면 해당 Variable의 타입도 반드시 Non-Null이어야 한다. 그렇지 않으면 서버에서 검증 에러가 발생할 것이다. 또한, 선택적인 Variable의 경우 선택적인 Argument와 마찬가지 방식으로 디폴트 값을 정의해줄 수 있다. (위 예시에서
$lan
Variable의 선언부 참고)4. Fragment
Fragment는 반복적으로 사용되는 필드 구성을 재활용할 수 있도록 따로 정의해둔 필드 뭉치를 말하며, 클라이언트에서 선언하여 쿼리문에 포함시켜 서버에게 보내게 된다. 다음 예시를 통해 Fragment의 활용 방법을 확인하자.
위 예시를 통해, Fragment의 선언이 특정 타입에 의존한다는 것을 알 수 있다. 즉, 특정 타입의 필드 구성을 재활용하는 것이다. 또한, Fragment는 쿼리의 매개변수인 Variable에 접근 가능하다는 것을 알 수 있다.
추가적으로, Fragment는 인라인으로도 선언 가능하다. 이를 인라인 Fragment라고 한다. Fragment와 인라인 Fragment의 관계는 JavaScript에서 함수와 익명 함수의 관계와 유사하다. 별도의 이름 없이 그 자리에서 바로 선언하고 사용한다는 것이다. 따라서 인라인 Fragment는 필드 구성의 재활용이 목적이 아니다. 한 곳에서 선언과 동시에 사용되고 끝나면 다른 곳에서 쓸 수 없기 때문이다. 대신, 인라인 Fragment는 추상 타입(인터페이스, Union 타입)의 필드에서 반환되는 객체가 특정 타입인 경우에만 특정 필드를 요청하고 싶은 경우에 사용한다. 다음 예시를 참고하자.
name
, languages
필드는 User
인터페이스에 정의되어 있기 때문에 직접 요청 가능하지만, roles
필드는 AdminUser
타입인 경우에만 존재하고 penalty
필드는 NormalUser
타입인 경우에만 존재하기 때문에 인라인 Fragment를 활용하여 필드 요청에 분기 처리를 한 것을 볼 수 있다. 한편 __typename
은 메타 필드의 일종인데, 반환된 객체가 구체적으로 무슨 타입인지 String
타입의 값으로 알려주는 필드이다. 이 메타 필드를 요청하면 클라이언트에서 객체의 타입에 따라 적절한 분기 처리를 할 수 있다.참고로 인라인 Fragment가 아닌 일반적인 Fragment도 이러한 분기 처리를 위해 사용할 수 있다. 기본적으로 Fragment는 특정 타입에 의존하도록 선언되기 때문이다.
5. Directive
Directive는 Variable의 값에 따라 특정 필드 또는 Fragment를 동적으로 포함/제외시키는 수단으로, 서버는 전달받은 Variable의 값에 따라 해당 필드 또는 Fragment를 포함시킬지 제외시킬지 결정하게 된다. Directive는 다음과 같이 두 종류이다.
@include(if: Boolean)
: if 값이true
이면 해당 필드 또는 Fragment를 포함
@skip(if: Boolean)
: if 값이true
이면 해당 필드 또는 Fragment를 제외
예시는 다음과 같다.
Directive로 인해 특정 필드 또는 Fragment의 포함/제외를 동적으로 결정하기 위해 쿼리문 자체를 매번 다시 만들 필요가 없어진다. 위에서 설명했던 Variable 딕셔너리 존재 의의와 거의 같다. 쿼리문은 최대한 건드리지 않도록 하는 것이다.