Android SDUI 도입기(with. GraphQL, Apollo)

송규빈·2024년 2월 15일
1

개요

우리 앱의 홈화면이 다채롭게 동적으로 변경되어야 하는 상황에서 SDUI(Server-Driven User Interface) 방식을 도입하게 되었습니다. 이에 따라 궁합이 좋은 Graph QL을 사용하게 되었습니다.

프론트 개발자 입장에서 디자인 요소 등을 변경할 때마다 작업을 해야하고, 사용자 입장에서는 변경된 버전을 보려면 매번 업데이트를 해야 합니다.

이 경우, 두 개의 플랫폼(Android, IOS 기준)에서 작업을 해야하며, 사용자들은 업데이트를 기다려야 합니다.

하지만 SDUI 방식을 사용하면, 앱, 디자인, 백엔드가 프로토콜과 컨벤션만 맞추면 서버의 배포만으로도 AndroidIOS의 업데이트 없이 사용자가 새로운 UI를 경험할 수 있습니다.

SDUI와 GraphQL

그럼 Graph QL은 무엇이고, 왜 SDUI 방식에 Graph QL을 사용했을까요?

GraphQL

GraphQLAPI를 위한 쿼리 언어이며, 해당 쿼리를 실행하는 서버 사이드 런타임입니다.

간단히 말하면, 쿼리를 작성하여 서버에 요청하면 해당 쿼리에 맞는 데이터만 가져와서 응답합니다.

REST API와는 달리 GraphQL은 단일 엔드포인트 방식을 사용합니다.

서버 리소스 및 부하 감소

컴포넌트 기반 쿼리

SDUI 환경에서 개별 UI 컴포넌트가 필요한 데이터를 쿼리로 요청해 받을 수 있습니다.

네트워크 리소스 감소

단일 쿼리로 필요한 모든 UI 구성 요소와 데이터를 한 번에 요청할 수 있어 네트워크 요청 횟수를 줄일 수 있습니다.

언더 페칭 및 오버 페칭 방지

쿼리를 통해 필요한 데이터만 가져올 수 있으므로 언더 페칭 및 오버 페칭을 방지하여 서버의 부하를 감소시킬 수 있습니다.

GraphQL 구조

GraphQL의 구조에는 쿼리(Query)뮤테이션(Mutation)이 있습니다.

현재 우리 앱은 쿼리만 다루고 있으므로 뮤테이션은 다음 기회에 설명하겠습니다.

둘의 차이는 크지 않습니다.

(내부적으로 쿼리는 데이터를 읽는데 사용하고, 뮤테이션은 데이터를 변경하는데 사용합니다.)

쿼리 예시

간단한 예시로, 아래는 홈 스크린의 타이틀을 서버에서 조정하는 쿼리입니다.

query GetHomeScreenTitleStyle {
  homeScreen {
    titleStyle {
      color
      fontSize
    }
  }
}

앱의 시그니처 컬러가 변경되어 타이틀 텍스트 색상이 파란색(Blue)에서 초록색(Green)으로 변경되었다고 가정해봅시다.

안드로이드 개발자와 IOS 개발자가 textColorGreen으로 변경하고… 앱 빌드를 해서… 심사를 넣고… 출시를 한 다음… 사용자가 앱을 업데이트 해야합니다.

하지만 위와 같이 쿼리를 이용하여 요청하는 GraphQL을 사용한 SDUI 방식에서는 백엔드에서 응답을 초록색(Green)으로 보내주기만 하면 앱을 따로 배포하지 않아도 변경할 수 있습니다.

지원 라이브러리

GraphQL을 지원하는 라이브러리는 릴레이(Relay)아폴로(Apollo Client)가 있습니다.

그러나 대부분은 아폴로를 사용하고 있고, 릴레이는 매우 복잡하게 디자인되어 있다고 합니다.

우리 앱에서도 아폴로를 사용했는데, 처음 도입하는 기술이어서 걱정했지만, 아주 편리하게 설계된 라이브러리 덕분에 다소 순조롭게 도입했습니다.

이 아폴로는 튜토리얼도 자세하게 잘 나와있으니 한 번 보면 쉽게 따라할 수 있을 것입니다.

쿼리와 스키마

쿼리와 스키마는 GraphQL에서 필수적인 요소이다.

스키마에 각 타입들을 정의해놓고 이 타입들에 맞는 쿼리를 작성하여 필요한 데이터들을 요청한다.

스키마

다음은 현재 사용중인 스키마의 일부분이다.

# 피드 인터페이스 정의
interface Feed {
  id: ID!
}

# 피드 큐레이션 아이템
type FeedItem implements Feed {
  id: ID!
  type: FeedType
  item: Item
}

type Item {
  itemIdx: Long!
  title: String
  price: Long
}
...

interfacetype과 같은 타입 시스템 구성요소들을 사용하여 선언하고, id, title, link와 같은 필드들과 함께 FeedItem과 같은 GraphQL 타입을 구성한다.

물론 각 GraphQL 타입은 서로의 필드에서 FeedTypeFeedItem과의 관계처럼 사용가능하다.

쿼리

쿼리는 위에서 정의한 스키마의 타입만으로 작성해야 한다.
아래는 쿼리의 일부이다.
쿼리의 내용이 잘 이해가 가지 않아도 지금은 쓱 보면 된다.

query Feed($first: Int, $after: String) {
    feeds(first: $first, after: $after) {
        pageInfo {
            hasNextPage
            endCursor
        }
        edges {
            cursor
            node {
                id
                __typename
                ...FeedItem
            }
        }
    }
}

fragment FeedItem on FeedItem {
    type
    itemGroup: item{
        ...Item
    }
}

fragment Item on Item {
    itemIdx
    title
    price
}

스키마에 정의되어있는 타입 내에서 받아오고 싶은 쿼리를 작성한다.
fragment 등은 아폴로 클라이언트를 설명할 때 보충하겠다.
위 쿼리를 해석해보자면,

이를 한 번에 정리하면 적혀있는 쿼리에 있는 것들만 서버에 요청을 한다. 라고 정리할 수 있겠다.

아폴로 클라이언트 (with. Android 🤖)

작업했던 내용 스코프 안에서 아폴로 클라이언트에 대해 설명해보고자 한다.

(안드로이드 스튜디오 세팅법은 튜토리얼 참고)

쿼리 Genereate

쿼리를 작성하고 빌드를 하게 되면 아폴로 클라이언트(이하 아폴로)가 쿼리를 읽고 자동으로 아래와 같이

작성한 쿼리에 맞게 class 파일을 generate 해준다.

(안드로이드의 경우 쿼리 파일이 존재하는 모듈의 task를 보면 generateApolloSources를 실행하면 따로 빌드를 하지 않고도 쿼리 파일만 genereate 해준다.)

query Feed($first: Int, $after: String) {
    feeds(first: $first, after: $after) {
        pageInfo {
            hasNextPage
            endCursor
        }
        edges {
            cursor
            node {
                id
                __typename
                ... on FeedItem {
                	type
                    itemGroup: item {
                    	itemIdx
                        title
                        price
                    }
                }
            }
        }
    }
}


쿼리의 이름이 Feed 이므로 FeedQuery라는 이름으로 클래스가 생성된다.

Field

왼쪽의 쿼리와 비교하였을 때 객체 형태의 필드들은 그대로 data class의 형태로 만들어지는 것을 볼 수 있다.

node 필드를 보면, 요청 쿼리에 있는 대로 id, __typename, FeedItem이 프로퍼티로 들어가 있는 것을 볼 수 있다.

node {
	id
    __typename
    ... on FeedItem{
    	type
        item {
        	itemIdx
            title
            price
        }
    }
}

… on

FeedItem 옆에 … on(인라인 프래그먼트) 이라는 구문을 볼 수 있다.
이 구문은 특정 타입에 대해서만 적용되는 필드들을 쿼리할 때 사용된다.
아래는 스키마 코드이다.

# 피드 인터페이스 정의
interface Feed {
  id: ID!
}

# 피드 페이징 Edge
type FeedEdge {
  cursor: String
  node: Feed
}

# 피드 아이템
type FeedItem implements Feed {
  id: ID!
  type: FeedType
  item: Item
}
# 피드 큐레이션 아이템
type FeedCurationItem implements Feed {
  id: ID!
  type: FeedType
  title: FeedTitle
  link: String
  featuredIdx: Int
  items: [Item]
}

FeedEdge 타입의 필드인 node를 보시면 Feed형으로 정의가 되어있습니다.

Feed를 따라가보면 interface로 정의가 되어 있고, FeedCurationItemFeedItem은 이 인터페이스를 상속받고 있습니다.

즉, Feed 형으로 선언된 node에는 FeedItem이 올 수도 있고, FeedCurationItem이 올 수도 있습니다.

…on 구문은 이처럼 공통 인터페이스를 구현한 타입들을 각 구현 타입별로 특화된 필드를 쿼리할 수 있습니다. 이러한 방식은 Interface 이외에도 Union Type과 같은 타입들을 처리할 때 사용됩니다.

그렇다면 FeedCurationItem을 추가해 보겠습니다.

query Feed($first: Int, $after: String) {
    feeds(first: $first, after: $after) {
        pageInfo {
            hasNextPage
            endCursor
        }
        edges {
            cursor
            node {
                id
                __typename
                ... on FeedItem {
                    type
                    itemGroup: item {
                        itemIdx
                        title
                        price
                    }
                }
                ... on FeedCurationItem {
                    type
                    itemGroups: items {
                        itemIdx
                        title
                        price
                    }
                }
            }
        }
    }
}

이렇게 노드 안에 … on 구문을 사용하여 Feed를 구현한 타입을 쿼리할 수 있습니다.

Fragment

위 쿼리에서 Generate된 코드를 봅시다.

ItemItem1은 같은 프로퍼티를 사용하고, 스키마에 정의된 타입 또한 동일하지만 나뉘어서 생성이 된 이유는 쿼리의 구조와 클라이언트 측의 타입 시스템 때문입니다.

이러한 이유는 FeedItemFeedCurationItem은 다른 컨텍스트를 갖고 있지만, FeedQuery 내부에서 함께 생성되기 때문에 이를 구분하기 위함입니다.

이렇게 되면 공통으로 사용하는 타입임에도 매번 다른 class가 생성이 되어 효율적이지 못하고, UI에서 사용하기 위한 Model Mapping 작업도 추가가 되겠죠?

이를 해결하기 위해서 GraphQL에서는 fragment 타입을 제공합니다.

fragment FeedItem on FeedItem {
    type
    item{
        ...Item
    }
}

fragment FeedCurationItem on FeedCurationItem {
    title {
        text
    }
    type
    featuredIdx
    link
    items {
        ...Item
    }
}

fragment Item on Item {
    itemIdx
    title
    price
}

위와 같이 프래그먼트를 사용함으로써 Item을 재사용 할 수 있었다. 또한, 생성되는 패키지 구조도 달라지게 된다.

프래그먼트를 사용하기 전에는 FeedQuery의 내부 클래스 모여있었기 때문에 따로 클래스 파일이 만들어지지는 않았다.


하지만, 프래그먼트로 나누게 되면 아래와 같이 패키지 구조가 생성된다.

이전에는 없던 fragment 패키지가 생성이 되고 그 하위에 FeedItem, FeedCurationItem, Item이 각각 만들어진다.

query Feed($first: Int, $after: String) {
    feeds(first: $first, after: $after) {
        pageInfo {
            hasNextPage
            endCursor
        }
        edges {
            cursor
            node {
                id
                __typename
                ...FeedItem
                ...FeedCurationItem
            }
        }
    }
}

fragment FeedItem on FeedItem {
    type
    item{
        ...Item
    }
}

fragment FeedCurationItem on FeedCurationItem {
    title {
        text
    }
    type
    featuredIdx
    link
    items {
        ...Item
    }
}

fragment Item on Item {
    itemIdx
    title
    price
}

이렇게 쿼리를 작성할 수 있지만 여기에도 문제가 있다. 만약 피드 종류가 더 많아지고 그에 대한 프로퍼티가 많아진다면?

한 쿼리 파일의 코드 길이가 길어지기 때문에 굉장히 복잡하게 보일 것이다. 사실 지금도 예시로 보여주기 위해

프로퍼티를 줄인 것이라 이정도지만, 개선하기 전에는 정말 복잡했다.

그래서 개선점을 알아보던 중 GraphQL에서도 import 기능을 지원한다는 것을 봤다.

Import

이러한 형식으로 GraphQL 파일을 각 피드 종류별로 나누었고, 아래와 같이 깔끔하게 수정할 수 있었다.

query Feed($first: Int, $after: String) {
    feeds(first: $first, after: $after) {
        pageInfo {
            hasNextPage
            endCursor
        }
        edges {
            cursor
            node {
                id
                __typename
                ...FeedItem
                ...FeedCurationItem
            }
        }
    }
}

마무리

GraphQL은 앞서 말했던 네트워크 리소스를 감소할 수 있는 장점도 분명히 있지만, 개인적으로는 생산성이 올라간다고 생각이 들었다.

또한, “뭐..? 서버에서 UI 관련된 것들을 컨트롤한다고????” 라는 관념이 있었지만 이러한 틀을 깰 수 있었고 생전 처음 갖고 놀아본 재미진 장난감이었다!(일은 신중하게 했다.)

Android와 IOS 기준으로 보면 이러한 방식은 Compose/SwiftUI(이하 스유)와 궁합이 아주 찰떡이라고 생각이 들었다.

IOS팀에서는 스유를 도입한 것 같지만 우리 Android팀에서는 이제 컴포즈를 슬슬 도입하는 중이라 아직 SDUI 방식에 녹여내지는 못했다.(도전!)

profile
🚀 상상을 좋아하는 개발자

0개의 댓글