티스토리 뷰

Web/GraphQL 정리글

GraphQL 2장 _ 스키마 정리

구름뭉치 2022. 6. 18. 22:12

GraphQL을 사용하면 기존의 REST 엔드 포인트의 집합이 아니라 타입 집합으로 API가 보이게 된다. 따라서 GraphQL API를 만들기 전에는 우선적으로 API에서 반환할 데이터 타입에 대해 생각하고 정의해야 한다. 이러한 데이터 타입의 집합을 스키마(Schema)라고 부른다.

 

GraphQL은 스키마 정의를 위해 SDL(Shema Definition Language)를 지원한다. 이 스키마 문서는 애플리케이션에서 사용할 타입을 정의해 둔 텍스트 문서이다. 여기서 정의한 타입은 나중에 클라이언트-서버 통신 시 GraphQL 요청에 대한 유효성 검사에서 사용된다.


사진 공유 애플리케이션

사진 공유 애플리케이션을 만들어 보면서 GraphQL 타입과 스키마를 공부해보자.

  • 사진 공유 애플리케이션은 깃허브 계정으로 로그인하여 사진을 게시하고 사진에 사람을 태그할 수 있는 기능을 갖는다.
  • 주요 타입은 User, Photo이다.

타입

타입은 필드를 가진다. 각 필드는 객체의 데이터와 관련이 있으며 특정 종류의 데이터를 반환한다. 이 데이터는 스칼라타입일 수도 있고 커스텀 타입 객체이거나 리스트일 수도 있다.

*스칼라타입 : Int, Float, String, Boolean, ID

 

Photo 타입

scala DateTime

type Photo {
  id: ID!
  name: String
  url: String!
  description: String
  created: DateTime!
  category: PhotoCategory!
}

enum PhotoCategory {
    SELFIE
    PORTRAIT
    ACTION
    LANDSCAPE
    GRAPHIC
}
  • ! non-nullable을 의미한다.
  • ID 타입은 반환값은 문자열이지만 고유한 값인지 유효성 검사를 받게 된다.
  • DateTime은 커스텀 스칼라 타입으로 유효성 검사를 할 수 있다.
    • 문자열 값이 직렬화와 유효성 검사를 거쳤는지, 공식 날짜 및 시간으로 형식이 맞춰졌는지 등
    • graphql-custom-type은 자주 사용하는 커스텀 스칼라 타입을 모아둔 npm 패키지이다.

리스트

필드에 리스트가 들어가는 경우 []로 표기한다.

type User {
    ...
    postedPhotos: [Photo!]!
}

!는 non-nullable이라고 했는데 리스트에선 4가지 경우가 나올 수 있다.

  • [Int] : 리스트안에 담긴 정수는 nullable하고 리스트는 nullable하다.
  • [Int!] : 리스트안에 담긴 정수는 non-nullable하고 리스트는 nullable하다.
  • [Int]! : 리스트안에 담긴 정수는 nullable하고 리스트는 non-nullable하다. 
  • [Int!]! : 리스트안에 담긴 정수는 non-nullable하고 리스트는 non-nullable하다. 

일반적으로 [_타입_!]! 을 주로 사용한다. 비어있으면 그냥 비어있는 리스트를 반환하지 null을 반환하는건 지향하자. 빈 리스트 != null

일대일 연결

GraphQL이 객체간의 관계를 그래프로 표현하듯이 커스텀 객체 타입으로 필드를 만들면 두 객체는 서로 연결된다. 이러한 연결, 또는 링크를 Edge(간선) 라고 한다.

 

User와 Photo간에 단방향 연결

Photo User 일대일 단방향 관계

type User {
    githubLogin: ID!
    name: String
    avatar: String
}

type Photo {
    id: ID!
    name: String
    url: String!
    description: String
    created: DateTime!
    category: PhotoCategory!
    postedBy: User!
}

Photo에 등록자 User가 들어가면서 단방향 관계가 생성되었다.

일대다 연결

GraphQL 서비스는 최대한 방향성이 없도록 연결하는 것이 좋다. 방향이 없으면 아무 노드에서 그래프 횡단을 시작할 수 있으므로 클라이언트 쪽에서 쿼리를 최대한 자유롭게 만들 수 있기 때문이다.

 

이를 위해서는 User 타입에서 Photo타입으로 되돌아갈 수 있는 패스가 있어야 한다. 즉, User 관련 쿼리를 작성할 때 해당 사용자가 게시한 사진 정보 역시 모두 받을 수 있어야 한다는 뜻이다.

User Photo 일대다 단방향 관계

이를 위해 User에서 Photo로의 일대다 관계를 만들어줌으로서 서로간의 양방향을 만들어서 무방향이 되도록 만들자. User는 여러개의 Photo를 올릴 수 있고, Photo는 한명의 등록자를 가지기 때문에 그러한 관계를 갖는다.

type User {
    githubLogin: ID!
    name: String
    avatar: String
    postPhotos: [Photo!]!  // 추가
}

쿼리 정의

Photo, User에 대한 Query를 정의해보자.

type Query {
    totalPhotos: Int!
    allPhotos: [Photo!]!
    totalUsers: Int!
    allUsers: [User!]!
}

schema {
    query: Query
}

조회 요청을 위한 API이므로 Query 타입으로 API를 정의했고 API명: 반환타입 형식으로 작성되었다. 이 Query를 schema의 query에 추가했다.

그럼 아래와 같은 Query를 보낼 수 있게 된다.

query getTotalPhotoCountAndPhotos {
  totalPhotos
  allPhotos {
    name
    url
  }
}

다대다 연결

사진과 사용자간의 다대다 관계가 생길 때도 있다.

태그를 생각해보자. User가 Photo를 올리는데 해당 Photo에 여러명의 User를 Tag할 수 있고, 각 사용자는 여러 Photo에 Tag될 수 있다.

User Photo 양방향 다대다 관계

다대다 양방향 관계는 일대다 단방향을 서로 맺어줌으로서 생성된다.

type User {
    ...
    inPhotos: [Photo!]!
}

type Photo {
    ...
    taggedUsers: [User!]!
}

통과 타입 (Through type, 관계 자체에 대한 정보를 위한 타입) 

User와 User간의 Edge를 친구라는 관계로 맺고 싶고, 해당 Edge에 친구관계에 대한 정보(어디서 만났고, 얼마나 만났고, 사건 사고 등)을 넣고 싶다고 해보자.

// 기존 User - User 관계
type User {
    ...
    friends: [User!]!
}

이때는 Edge 자체를 Custom 객체 타입로 만들어야 한다.

// User -(Friendship)- User
type User {
    ...
    friends: [Friendship!]!
}

type Friendship {
    friend_a: User!
    friend_b: User!
    howLong: Int!
    whereMeet: Location
}

여러 타입을 담는 리스트

Query에서 배웠던 union, interface를 스키마에 정의해보자.

 

일정 앱을 예시로 들어본다. 일정은 여러 종류의 이벤트가 들어갈 수 있고, 이벤트마다 데이터 필드가 달라질 수 있다. 예를들어 운동 이벤트와 스터디 모임 이벤트는 완전히 다를 수 있다. 이러한 상이한 이벤트를 일정앱은 모두 가질 수 있어야 한다.

 

이러한 일정 스키마를 만들기 위해서 Union, Interface를 사용하면 된다.

유니언 타입

일정에 들어가는 이벤트의 종류가 다양할 경우를 처리

type Query {
    agenda: [AgendaItem!]!
}

union AgendaItem = StudyGroup | Workout | Class

type Class {
    name: String!
    subject: String!
    room: String!
    start: String
    end: String
}

type StudyGroup {
    name: String!
    subject: String!
    member: [User!]!
}

type Workout {
    name: String!
    reps: Int!
}

인터페이스

인터페이스도 한 필드안에 여러 타입을 넣을 때 사용한다. 객체 타입 용도로 만드는 추상 타입이며, 스키마 코드의 구조를 조직할 때 아주 좋은 방법이다. 인터페이스를 사용하므로서 특정 필드가 특정 타입에 무조건 포함되도록 만들 수 있고, 이 필드들은 쿼리에서 사용할 수 있다.

 

위 agenda를 인터페이스로 새로 설계해보자.

scala DateTime

type Query {
    ...
    agenda: [AgendaItem!]!
}

interface AgendaItem {
    name: String!
    start: DateTime!
    end: DateTime!
}

type Class implements AgendaItem {
    name: String!
    start: DateTime!
    end: DateTime!
    subject: String!
    room: String!
}

type StudyGroup implements AgendaItem {
    name: String!
    start: DateTime!
    end: DateTime!
    subject: String!
    member: [User!]!
}

type Workout implements {
    name: String!
    start: DateTime!
    end: DateTime!
    reps: Int!
}
  • 인터페이스로 만든 타입이 갖는 필드는 이를 구현하는 타입이 무조건 가져야한다.

인자 (Argument)

Query 타입에 allUsers, allPhtos는 있는데 특정 User, Photo만 반환하도록 하는 api는 없다. 이를 추가해보자.

type Query {
    ...
    Photo(id: ID!): Photo!
    User(githubLogin: ID!): User!
}

데이터 필터링

반드시 인자로 값을 반환하도록 만들지 않을 수 있다. 특정 인자를 필터링 요소로 사용하므로서 넣으면 필터로서 작동하고 넣지 않으면 필터가 작동하지 않도록 할 수 있다.

Query {
    ... 
    allPhotos(category: PhotoCategory): [Photo!]!
}

category 값은 nullable하고 해당 값이 있다면 해당하는 Photo들만을 아니면 모든 Photo를 반환하게 될 것이다.

데이터 페이징

  • 스프링에서 DB에서 Pageable을 사용하던 페이징과 같은것이다.
  • 쿼리에 인자를 전달해 반환 데이터의 양을 조절하는데 API 수준에서 조절해준다.
  • first, start 두개의 인자를 사용한다.
    • first : 페이지 한 장 당 들어가는 레코드 수
    • start : 첫 번째 레코드가 시작되는 인덱스
Query {
    ...
    allUsers(first: Int=50, start: Int=0): [User!]!
    allPhotos(first: Int=25, start: Int=0): [Photo!]!
}
  • first, start에 default 값을 설정해놓았다. 만약 인자로 값을 넣어주지 않으면 해당 값으로 작동하게된다.
  • 모든 유저를 앞에서 50개 / 모든 포토를 앞에서 25개 반환하게 된다.
query {
    allUsers(first: 10, start: 3) {
        name
        avatar
    }
}
  • 10명의 User단위로 페이징하고, 3번째 User부터 10명의 User를 반환한다.

뮤테이션

애플리케이션의 대부분의 이벤트에 해당하는 부분으로 동사역할을 한다.

 

pohtPhoto 요청 mutation 정의

type Mutation {
    postPhoto(
        name: String!,
        description: String,
        category: PhotoCategory = PORTRAIT
    ): Photo!
}
  • 이름은 필수, 설명과 카테고리는 nullable 하게 설정 이때 category는 미설정 시 "PORTRAIT"로default 지정
  • 반환형은 Photo!

스키마에 Mutation 등록

schema {
    query: Query
    mutation: Mutation
}

postPhoto 요청 뮤테이션 예시

Input type

Query에서도 설명했지만 요청 인자로 인풋 객체를 넘길 수 있다. 이를 통해 인자를 좀 더 체계적으로 할 수 있다.

위 예제를 input 타입을 사용하도록 변경해본다.

type Mutation {
    postPhoto(
        input: PostPhotoInput!
    ): Photo!
}

input PostPhotoInput {
    name: String!,
    description: String,
    category: PhotoCategory = PORTRAIT
}

postPhoto 요청 mutation _ input 타입 사용

  • 훨씬 깔끔해진걸 볼 수 있다. 또한 값을 통합 관리하므로 더욱 안정적이다.

input 타입을 활용해서 기존 코드를 최적화 해보자

scala DateTime

type Query {
    allPhotos(
        filter: PhotoFilter
        paging: DataPage
        sorting: DataSort
    ): [Photo!]!

    allUsers(
        paging: DataPage
        sorting: DataSort
    ): [User!]!
    
    ...
}

type Mutation {
    postPhoto(
        input: PostPhotoInput!
    ): Photo!
}

type User {
    githubLogin: ID!
    name: String
    avatar: String
    postPhotos(
        filter: PhotoFilter
        paging: DataPage
        sort: DataSort
    ): [Photo!]!
    inPhotos(
        filter: PhotoFilter
        paging: DataPage
        sort: DataSort
    ): [Photo!]!
    friends: [Friendship!]!
}

type Photo {
    id: ID!
    name: String
    url: String!
    description: String
    created: DateTime!
    category: PhotoCategory!
    postedBy: User!
    taggedUsers(
        sort: DataSort
    ): [User!]!
}

...

input PostPhotoInput {
    name: String!,
    description: String,
    category: PhotoCategory = PORTRAIT
}

input PhotoFilter {
    category: PhotoCategory
    createdBetween: DateRange
    taggedUsers: [ID!]
    searchText: String
}

input DateRange {
    start: DateTime!
    end: DateTime!
}

input DataPage {
    first: Int = 25,
    start: Int = 0,
}

input DataSort {
    sort: SortDirection = DESENDING
    sortBy: SortablePhotoField = created
}

...

schema {
    query: Query
    mutation: Mutation
}
  • input을 추가하고, 사용하고, 변경된 type에 대해서만 적었다.
  • Photo 전체를 조회하는 allPhotos를 보면 훨씬 깔끔해진 것을 볼 수 있다.
    • filter, paging, sort 를 각각의 input 타입으로 정의했다.
    • 없어도 되는 것들이므로 !가 없는것을 확인할 수 있다.
    • 각 input 타입들도 정의 되어있다.

allPhotos 요청 Query

좌) Query  / 우) Parameters

하나씩 잘 비교해서 봐보면 이해가 될것이다. 이제 위 요청이 가면

  • category는 PORTRAIT
  • & 생성날짜는 1995/11/09 15:30 ~ 1995/11/09 18:30
  • & "birthDay" 문장이 포함된
  • 사진을
    • start 0번부터 첫 5개만
    • 오름차순(즉, 날짜가 빠른 순서대로)으로 정렬해서
      • 반환한다.

요청타입

현재는 반환타입이 모두 User 또는 Photo 인데 Github OAuth 로그인을 하거나 그 외의 상황에서 전송된 페이로드 데이터 말고도 쿼리나 뮤테이션에 대한 메타 정보를 함께 받아야 할 때가 있다. 예를들어 사용자가 로그인 상태이거나 인증을 거친 상태라면 User 페이로드에 관련 토큰을 같이 반환해야할 것이다.

 

추후 Github OAuth를 통해 로그인하려면 OAuth 코드를 받아와야 하는데 계정을 설정하고 정상적인 깃허브_코드를 받아왔다고 가정하고 해당 깃허브_코드를 전달해서 사용자 User와 token을 반환 받도록 하자. 이 토큰은 Authentication 용도로 사용되어서 요청 때 같이 포함되어 전달될 것이다.

// 깃허브 코드 전달 시 AuthPayload 리턴 타입을 반환
type Mutation {
    ...
    githubAuth(code: String!): AuthPayload!
}


// AuthPayload 리턴 타입을 정의
type AuthPayload {
    user: User!
    token: String!
}

Subscription 서브스크립션

subscription 타입 또한 Query, Mutation을 정의했던 것들과 별반 차이가 없다. 다만 나중에 기능 개발을 위해 실시간 데이터 전송과 더불어서 PubSub 디자인 패턴을 사용하는데 Subscription 타입이 이 패턴을 따르도록 만드는게 우리의 목적이다.

 

Subscription 타입을 이용해서 Photo 또는 User 타입이 생성될 때 마다 그 소식을 클라이언트에서 받아 볼 수 있도록 만들 수 있다.

type Subscription {
    newPhoto: Photo!
    newUser: User!
}

schema {
    ...
    subscription: Subscription
}

사진이 새로 게시되면 newPhoto를 구독중인 모든 클라이언트는 새로운 사진에 대한 알림을 받게 된다. User 도 똑같다.

 

인자를 활용할 수 있는데, 새로운 사진이 특정 카데고리에 속한 경우에만 구독하도록 필터를 추가할 수 있다.

type Subscription {
    newPhoto(category: PhotoCategory): Photo!
    newUser: User!
}

새로운 Photo 생성에 대해 구독 요청 Subscription

스키마 문서화

REST API를 많이 사용해본 개발자라면 대부분 Swagger2, 3를 사용해서 API를 문서화 해왔을 것이다. GraphQL은 인트로스펙션 기능을 사용해서 서버에서 제공하는 쿼리 정보를 얻을 수 있다.

익숙한 REST API 문서

GraphQL 스키마를 작성할 때는 옵션으로 각 필드에 대한 설명을 적어줄 수 있는데, 이로써 스키마 타입과 필드에 대한 부가정보를 제공할 수 있는 것이다. 설명을 잘 적어두면 자기 자신 뿐만 아니라 API 사용자들이 스키마를 쉽게 이해할 수 있을 것이다.

 

REST API의 경우 Swagger에서 제공하는 @ApiOperation등을 통해 API 명세를 작성했는데 GraphQL도 이와 비슷한 방식인 것이다.

 

//type User 객체 코멘트

''''''
깃허브에서 한 번 이상 권한을 부여받은 사용자
''''''
type User {
    ''''''
    사용자의 깃허브 로그인 ID
    ''''''
    githubLogin: ID!

    ''''''
    사용자의 이름
    ''''''
    name: String

    ''''''
    사용자의 깃허브 프로필 이미지 URL
    ''''''
    avatar: String

    ''''''
    사용자가 올린 모든 사진
    ''''''
    postPhotos(
        filter: PhotoFilter
        paging: DataPage
        sort: DataSort
    ): [Photo!]!

    ''''''
    사용자가 들어간 모든 사진
    ''''''
    inPhotos(
        filter: PhotoFilter
        paging: DataPage
        sort: DataSort
    ): [Photo!]!

    ''''''
    사용자와 관계가 맺어진 모든 사람
    ''''''
    friends: [Friendship!]!
}

//type Mutation 내 API 코멘트

type Mutation {
    ''''''
    사진 등록
    ''''''
    postPhoto(
        ''input: 신규 사진 이름, 설명, 카테고리''
        input: PostPhotoInput!
    ): Photo!

    ''''''
    깃허브 사용자 권한 부여
    ''''''
    githubAuth(
        ''사용자 권한 부여를 위해 깃허브에서 받아 온 유니크 코드''
        code: String!
    ): AuthPayload!
}

// input 타입에 대한 코멘트

''''''
postPhoto 뮤테이션과 함께 전송되는 input 값
''''''
input PostPhotoInput {
    ''신규 사진명''
    name: String!,
    ''(옵션) 사진에 대한 설명''
    description: String,
    ''(옵션) 사진 카테고리''
    category: PhotoCategory = PORTRAIT
}

위와 같은 형식으로 작성해주면 GraphQL 플레이그라운드 or GraphiQL 툴의 스키마 문서에 자동으로 나오게 된다. 이에 대해 인트로스펙션 쿼리를 통해 조회(검색)도 가능하게 된다.

 

견고하게 잘 정의된 스키마는 GraphQL 프로젝트의 핵심이다. 스키마는 개발 로드맵이 되어주고, 프론트엔드와 백엔드가 서로 공유하는 일종의 계약서의 역할을 해준다.

 

이렇게 1장에서 쿼리, 뮤테이션, 서브스크립션에 대해 알아봤고 2장에서 스키마를 정의해 봤으니 다음 3장에서는 해당 스키마 계약서를 충실히 이행하는 GraphQL 애플리케이션을 만들어보겠다.

반응형
Comments
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday