โ† Articles

๐Ÿงฑ Server Driven UI ์„ค๊ณ„๋ฅผ ํ†ตํ•œ UI ์œ ์—ฐํ™”

ํด๋ผ์ด์–ธํŠธ ๋ฐฐํฌ์—†์ด ํ™”๋ฉด ๊ตฌ์„ฑ ๋ณ€๊ฒฝํ•˜๊ธฐ

Table of Contents

์›น๊ณผ ๋‹ฌ๋ฆฌ ๋„ค์ดํ‹ฐ๋ธŒ ๋ชจ๋ฐ”์ผ ์•ฑ์€ ๋นŒ๋“œ, ๋ฐฐํฌ ํ›„์—๋Š” ์ˆ˜์ •์ด ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค. ๋งŒ์•ฝ ์ž˜๋ชป๋œ ์œ„์น˜์— ๋ฒ„ํŠผ์„ ๋ฐฐ์น˜ํ•œ ์ฑ„๋กœ ์Šคํ† ์–ด์— ์•ฑ์„ ๋ฐฐํฌํ–ˆ๋‹ค๋ฉด, ๊ทธ๋ฆฌ๊ณ  ์‚ฌ์šฉ์ž๊ฐ€ ์ž˜๋ชป๋œ ๋ฒ„์ „์˜ ์•ฑ์„ ์„ค์น˜ํ–ˆ๋‹ค๋ฉด ๋ฒ„ํŠผ์˜ ์œ„์น˜๋ฅผ ์ˆ˜์ •ํ•  ๋ฐฉ๋ฒ•์ด ์—†๋‹ค. ์œ ์ผํ•œ ๋ฐฉ๋ฒ•์€ ์‚ฌ์šฉ์ž๊ฐ€ ์Šค์Šค๋กœ ์Šคํ† ์–ด์— ๋“ค์–ด๊ฐ€ ์ˆ˜์ •๋œ ๋ฒ„์ „์˜ ์•ฑ์œผ๋กœ ์—…๋ฐ์ดํŠธํ•˜๋Š” ๊ฒƒ ๋ฟ์ด๋‹ค.

๋ฐฐํฌ ํ›„ ์ˆ˜์ •์ด ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค๋Š” ํŠน์„ฑ์ด ๋ถ€๋”ชํžˆ๋Š” ๋˜ ๋‹ค๋ฅธ ์ƒํ™ฉ์€ A/B ํ…Œ์ŠคํŒ…์ด๋‹ค. ์†Œํ”„ํŠธ์›จ์–ด๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋™์•ˆ ์ผ์–ด๋‚˜๋Š” ์‚ฌ์šฉ์ž์˜ ํ–‰๋™๊ณผ ๊ฒฝํ—˜์€ ํ™”๋ฉด ๊ตฌ์„ฑ์ด๋‚˜ ๋ฌธ๊ตฌ์— ๋”ฐ๋ผ ํฌ๊ฒŒ ๋‹ฌ๋ผ์ง€๊ธฐ ๋•Œ๋ฌธ์— ์ตœ์ ์˜ ํ™”๋ฉด์„ ๋””์ž์ธํ•˜๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•˜๋‹ค. ๊ทธ๋Ÿฐ๋ฐ ์‚ฌ์šฉ์ž์˜ ํ–‰๋™๊ณผ ๊ฒฝํ—˜์„ ์˜ˆ์ธกํ•˜๋Š” ๊ฒƒ์€ ๋งค์šฐ ์–ด๋ ค์šด ์ผ์ด๊ธฐ ๋•Œ๋ฌธ์— ํ˜„์‹ค์˜ ์‚ฌ์šฉ์ž๋“ค์—๊ฒŒ ๋‹ค์–‘ํ•œ ์œ ํ˜•์˜ UI๋ฅผ ์ œ๊ณตํ•˜๊ณ , ์–ด๋–ค UI๊ฐ€ ์ ํ•ฉํ•œ์ง€ ์‹ค์ธกํ•  ํ•„์š”๊ฐ€ ์žˆ๋‹ค. ์‹ค์ œ๋กœ ๋งŽ์€ ์†Œํ”„ํŠธ์›จ์–ด ๊ธฐ์—…๋“ค์ด ์‚ฌ์šฉ์ž๋ฅผ A, B ๊ทธ๋ฃน์œผ๋กœ ๋‚˜๋ˆ„๊ณ  (๋” ๋งŽ์€ ๊ทธ๋ฃน์œผ๋กœ ๋‚˜๋ˆŒ ์ˆ˜๋„ ์žˆ๋‹ค.) ๊ฐ ๊ทธ๋ฃน์—๊ฒŒ ์„œ๋กœ ๋‹ค๋ฅธ UI๋ฅผ ์ œ๊ณตํ•ด ๊ฐ€์žฅ ์ ํ•ฉํ•œ UI๋ฅผ ์„ ์ •ํ•˜๋Š” A/B ํ…Œ์ŠคํŒ…์„ ํ•˜๊ณ  ์žˆ๋‹ค.

์œ ์—ฐํ•œ UI๋ฅผ ์ œ๊ณตํ•˜๋ ค๋ฉด UI๊ฐ€ ํด๋ผ์ด์–ธํŠธ์˜ ๋นŒ๋“œ์™€ ๋ฐฐํฌ๋กœ๋ถ€ํ„ฐ ์ž์œ ๋กœ์›Œ์•ผ ํ•œ๋‹ค. ์ด๋Ÿฌํ•œ ๋ชฉํ‘œ๋ฅผ ์ด๋ฃจ๊ธฐ ์œ„ํ•ด ์›น๋ทฐ์™€ ๊ฐ™์ด ๋„ค์ดํ‹ฐ๋ธŒ ํ™˜๊ฒฝ์„ ๋ฒ—์–ด๋‚œ ๋‹ค์–‘ํ•œ ๋ฐฉ๋ฒ•์„ ์„ ํƒํ•  ์ˆ˜๋„ ์žˆ๊ฒ ์ง€๋งŒ, ํ˜„์‹ค์—์„œ๋Š” ๋‹ค์–‘ํ•œ ์ด์œ ๋กœ ์›น๋ทฐ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š” ์ƒํ™ฉ์ด ์žˆ๋‹ค. ์ด ๊ธ€์—์„œ๋Š” ์›น๋ทฐ๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค๋Š” ์ „์ œ ํ•˜์— ์œ ์—ฐํ•˜๊ฒŒ UI๋ฅผ ๋‹ค๋ฃจ๊ธฐ ์œ„ํ•œ Server Driven UI ์„ค๊ณ„์— ๋Œ€ํ•ด ์†Œ๊ฐœํ•˜๊ณ ์ž ํ•œ๋‹ค.

์„œ๋ฒ„์—์„œ UI ๋‹ค๋ฃจ๊ธฐ

ํด๋ผ์ด์–ธํŠธ๊ณผ ๋‹ฌ๋ฆฌ ์„œ๋ฒ„๋Š” ์–ธ์ œ๋“  ๋ณ€๊ฒฝ, ๋ฐฐํฌํ•  ์ˆ˜ ์žˆ๋‹ค. ๊ทธ๋ ‡๋‹ค๋ฉด ์„œ๋ฒ„์—์„œ ์ œ๊ณตํ•˜๋Š” API๋ฅผ ์ด์šฉํ•ด ๋™์ ์œผ๋กœ ํด๋ผ์ด์–ธํŠธ์˜ UI๋ฅผ ๊ตฌ์„ฑํ•˜๋ฉด ์–ด๋–จ๊นŒ? ์„œ๋ฒ„๊ฐ€ API ์‘๋‹ต์— UI ์ •๋ณด๋ฅผ ๋‹ด์•„ ํด๋ผ์ด์–ธํŠธ์— ์ œ๊ณตํ•˜๊ณ , ํด๋ผ์ด์–ธํŠธ๊ฐ€ API ์‘๋‹ต์— ๋”ฐ๋ผ ํ™”๋ฉด์„ ๋ Œ๋”๋งํ•œ๋‹ค๋ฉด ์„œ๋ฒ„์—์„œ API ์‘๋‹ต์„ ๋ณ€๊ฒฝํ•˜๋Š” ๊ฒƒ๋งŒ์œผ๋กœ ํด๋ผ์ด์–ธํŠธ์˜ ํ™”๋ฉด ๊ตฌ์„ฑ์„ ๋™์ ์œผ๋กœ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ์ด๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด ์‚ฌ์šฉ์ž์—๊ฒŒ ํ™ˆ ํ™”๋ฉด์„ ์ œ๊ณตํ•˜๋Š” ๊ฒฝ์šฐ, ์„œ๋ฒ„๊ฐ€ ์ œ๊ณตํ•˜๋Š” REST API screen์„ ํ†ตํ•ด home ํ™”๋ฉด์— ๋Œ€ํ•œ UI ์ •๋ณด๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ์„ ๊ฒƒ์ด๋‹ค.

GET /screen/home

์ด API๋Š” ํ™ˆ ํ™”๋ฉด์„ ๊ตฌ์„ฑํ•˜๋Š” UI ์š”์†Œ ๋ฆฌ์ŠคํŠธ๋ฅผ JSON ํฌ๋งท์œผ๋กœ ์‘๋‹ตํ•œ๋‹ค. ๋”ฐ๋ผ์„œ ํด๋ผ์ด์–ธํŠธ๋Š” ์‘๋‹ต์˜ data ๋ฆฌ์ŠคํŠธ๋ฅผ ์ˆœํšŒํ•˜๋ฉฐ ๊ฐ๊ฐ์˜ type์— ํ•ด๋‹นํ•˜๋Š” UI ์š”์†Œ๋ฅผ ํ™”๋ฉด์— ๊ทธ๋ ค์ฃผ๋ฉด ๋œ๋‹ค.

์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ํด๋ผ์ด์–ธํŠธ๋ฅผ ์ƒˆ๋กœ ๋ฐฐํฌํ•˜์ง€ ์•Š์•„๋„ ์„œ๋ฒ„์—์„œ data ๋ฆฌ์ŠคํŠธ์˜ ์š”์†Œ๋ฅผ ๋ณ€๊ฒฝํ•จ์œผ๋กœ์จ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์œ ์—ฐํ•œ UI๋ฅผ ์ œ๊ณตํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ์ด๋‹ค. ์ด์ฒ˜๋Ÿผ UI์— ๋Œ€ํ•œ ์ •๋ณด๋ฅผ ์„œ๋ฒ„์—์„œ ๊ด€๋ฆฌ, ์ œ๊ณตํ•˜๋Š” ๊ฒƒ์ด Server Driven UI ์„ค๊ณ„์˜ ๊ธฐ๋ณธ ๊ฐœ๋…์ด๋‹ค.

GraphQL: ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ UI ์ปดํฌ๋„ŒํŠธ ์ œ๊ณตํ•˜๊ธฐ

์„œ๋ฒ„์—์„œ UI๋ฅผ ๊ด€๋ฆฌํ•˜๋ฉด ์œ ์—ฐ์„ฑ์„ ํ™•๋ณดํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ์‚ฌ์šฉํ•˜๋Š” UI ์š”์†Œ์˜ ์žฌ์‚ฌ์šฉ์„ฑ์„ ๊ณ ๋ คํ•˜์ง€ ์•Š์œผ๋ฉด ๋‹ค์–‘ํ•œ ํ™”๋ฉด์—์„œ UI ์š”์†Œ๋ฅผ ๊ต์ฒดํ•˜๊ธฐ ์–ด๋ ค์›Œ์ง„๋‹ค. ์ด๋Ÿฌํ•œ ๋ฌธ์ œ๋ฅผ ํ”ผํ•˜๋ ค๋ฉด ๋ชจ๋“  UI ์š”์†Œ๋ฅผ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ปดํฌ๋„ŒํŠธ๋กœ ๊ตฌ์„ฑํ•˜๊ณ , UI ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋‹ค์–‘ํ•œ ํ™”๋ฉด์—์„œ ์กฐ๋ฆฝํ•ด์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ๋งŒ๋“ค์–ด์•ผ ํ•œ๋‹ค.

๋˜ํ•œ ์ˆ˜์‹œ๋กœ ํ™”๋ฉด์— ์ƒˆ๋กœ์šด ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ถ”๊ฐ€๋˜๊ณ  ์ œ๊ฑฐ๋˜๋ฉด ์„œ๋ฒ„์™€ ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด์˜ ํƒ€์ž… ์ •์˜์— ๋ถˆ์ผ์น˜๊ฐ€ ๋ฐœ์ƒํ•˜๊ธฐ ์‰ฝ๋‹ค. ์ด๋•Œ GraphQL์„ ์‚ฌ์šฉํ•˜๋ฉด ์„œ๋ฒ„์™€ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๊ณต์œ ํ•˜๋Š” ์Šคํ‚ค๋งˆ๋ฅผ ํ†ตํ•ด API์˜ ํƒ€์ž… ์•ˆ์ „์„ฑ์„ ๋ณด์žฅํ•  ์ˆ˜ ์žˆ๋‹ค.

์ฟผ๋ฆฌ ์„ค๊ณ„

์„œ๋ฒ„๋Š” UI ์ปดํฌ๋„ŒํŠธ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” screen ์ฟผ๋ฆฌ๋ฅผ ํ†ตํ•ด ํŠน์ • ํ™”๋ฉด์— ๋Œ€ํ•œ UI ์ •๋ณด๋ฅผ ์ œ๊ณตํ•œ๋‹ค.

type Query {
  screen(screenType: ScreenType!): Screen!
}

enum ScreenType {
  HOME
  SIGN_IN
}

type Screen {
  components: [Component!]!
}

ํด๋ผ์ด์–ธํŠธ๋Š” screen ์ฟผ๋ฆฌ๋ฅผ ํ˜ธ์ถœํ•˜๋ฉฐ ํ™ˆ ํ™”๋ฉด์—์„œ๋Š” screenType: HOME ์ธ์ž๋ฅผ, ๋กœ๊ทธ์ธ ํ™”๋ฉด์—์„œ๋Š” screenType: SIGN_IN ์ธ์ž๋ฅผ ์ „๋‹ฌํ•  ๊ฒƒ์ด๋‹ค. ์„œ๋ฒ„๋Š” ์ฟผ๋ฆฌ๋ฅผ ๋ฐ›์œผ๋ฉด ํ•ด๋‹น screenType์— ๋งž๋Š” ์ปดํฌ๋„ŒํŠธ๋ฅผ ์กฐํ•ฉํ•˜์—ฌ Screen ํƒ€์ž…์˜ components ํ•„๋“œ์— Component ๋ฆฌ์ŠคํŠธ๋ฅผ ๋‹ด์•„ ์‘๋‹ตํ•œ๋‹ค.

Component๋Š” ์œ ๋‹ˆ์˜จ ํƒ€์ž…์ด๋‹ค. ์œ ๋‹ˆ์˜จ ํƒ€์ž…์€ ๋‹ค์–‘ํ•œ ํƒ€์ž…์˜ ์ปดํฌ๋„ŒํŠธ๋ฅผ Component๋ผ๋Š” ํ•˜๋‚˜์˜ ํƒ€์ž…์œผ๋กœ ๋‹ค๋ฃฐ ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ค€๋‹ค. Screen ํƒ€์ž…์˜ components ํ•„๋“œ๊ฐ€ Component ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค๋Š” ๊ฒƒ์€ ๋ฆฌ์ŠคํŠธ ์•ˆ์— AppBar, TextButton, Image ํƒ€์ž…์ด ์„ž์ผ ์ˆ˜ ์žˆ๋‹ค๋Š” ์˜๋ฏธ๋‹ค.

union Component = AppBar | TextButton | Image

type AppBar {
  title: String!
}

type TextButton {
  text: String!
  route: String
}

type Image {
  url: String!
}

๋งŒ์•ฝ ์ปดํฌ๋„ŒํŠธ๋“ค์ด ๊ณตํ†ต ํ•„๋“œ๋ฅผ ๊ฐ€์ง„๋‹ค๋ฉด Component๋ฅผ ์œ ๋‹ˆ์˜จ ํƒ€์ž… ๋Œ€์‹  ์ธํ„ฐํŽ˜์ด์Šค๋กœ ๋งŒ๋“ค์–ด๋„ ๋œ๋‹ค.

interface Component {
  position: Int!
}

type AppBar implements Component {
  position: Int!
  title: String!
}

type TextButton implements Component {
  position: Int!
  text: String!
  route: String
}

type Image implements Component {
  position: Int!
  url: String!
}

์œ ๋‹ˆ์˜จ ํƒ€์ž…์€ ๋‹จ์ˆœํžˆ ๋…๋ฆฝ์ ์ธ ์ปดํฌ๋„ŒํŠธ ํƒ€์ž…๋“ค์„ ํ•˜๋‚˜์˜ ํƒ€์ž…์œผ๋กœ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•œ ๋ฐฉ์‹์ด์—ˆ๋‹ค๋ฉด, ์ธํ„ฐํŽ˜์ด์Šค๋Š” ๊ฐ๊ฐ์˜ ์ปดํฌ๋„ŒํŠธ ํƒ€์ž…๋“ค์ด ์ถ”์ƒ ํƒ€์ž…์ธ Component๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ์‹์ด๊ธฐ ๋•Œ๋ฌธ์— ์–ด๋–ค ํƒ€์ž…์ด UI ์ปดํฌ๋„ŒํŠธ์ธ์ง€ ๋ช…ํ™•ํ•ด์ง„๋‹ค๋Š” ์žฅ์ ์ด ์žˆ๋‹ค.

์š”์ฒญ๊ณผ ์‘๋‹ต

GraphQL์˜ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํ•„๋“œ ๋ฌถ์Œ์ธ ํ”„๋ž˜๊ทธ๋จผํŠธ(Fragment)๋Š” UI ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ฃผ๊ณ ๋ฐ›๊ธฐ์— ๋งค์šฐ ์ ํ•ฉํ•˜๋‹ค. ํด๋ผ์ด์–ธํŠธ์—์„œ ์ปดํฌ๋„ŒํŠธ๋ฅผ ์š”์ฒญํ•  ๋•Œ๋Š” ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋“  ์ปดํฌ๋„ŒํŠธ ํ”„๋ž˜๊ทธ๋จผํŠธ๋ฅผ ์š”์ฒญํ•  ๊ฒƒ์ด๋‹ค.

query fetchScreen {
  screen(screenType: HOME) {
    components {
      ... on AppBar {
        __typename
        title
      }
      ... on TextButton {
        __typename
        text
        route
      }
      ... on Image {
        __typename
        url
      }
    }
  }
}

์ฃผ์˜ํ•  ์ ์€ '์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋“  ์ปดํฌ๋„ŒํŠธโ€™๋ฅผ ์š”์ฒญํ•œ๋‹ค๋Š” ์ ์ด๋‹ค. ๋งŒ์•ฝ ๊ตฌํ˜„ ๋‹น์‹œ์— ์‚ฌ์šฉํ•  ์ปดํฌ๋„ŒํŠธ๋งŒ ์š”์ฒญํ•˜๋ฉด ์ฐจํ›„ ์„œ๋ฒ„์—์„œ ๋‹ค๋ฅธ ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ™”๋ฉด์— ์ถ”๊ฐ€ํ•ด๋„ ๋ณด์—ฌ์ค„ ์ˆ˜ ์—†๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค.

์š”์ฒญ์„ ๋ฐ›์€ ์„œ๋ฒ„๋Š” ํ™ˆ ํ™”๋ฉด์—์„œ ์‚ฌ์šฉํ•  ์ปดํฌ๋„ŒํŠธ๋ฅผ ๊ณจ๋ผ์„œ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ์ด ์˜ˆ์‹œ์—์„œ๋Š” ํ™ˆ ํ™”๋ฉด์— AppBar ์ปดํฌ๋„ŒํŠธ ํ•˜๋‚˜์™€ TextButton ์ปดํฌ๋„ŒํŠธ ๋‘ ๊ฐœ๋ฅผ ์‘๋‹ตํ•œ๋‹ค.

impl QueryRoot {
    fn screen(screen_type: ScreenType) -> FieldResult<Screen> {
        Ok(
            Screen {
                components: match screen_type {
                    ScreenType::Home => home_components(),
                    ScreenType::SignIn => sign_in_components(),
                }
            }
        )
    }
}

fn home_components() -> Vec<Component> {
    vec![
        Component::AppBar(AppBar {
            title: "Home".to_string(),
        }),
        Component::TextButton(TextButton {
            text: "Sign in".to_string(),
            route: Some("/sign_in".to_string()),
        }),
        Component::TextButton(TextButton {
            text: "Sign up".to_string(),
            route: None,
        }),
    ]
}

๋Ÿฌ์ŠคํŠธ๋กœ ์„œ๋ฒ„ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•œ ์ด์œ ๋Š” ์ˆœ์ „ํžˆ ๊ฐœ์ธ ์ทจํ–ฅ์ด๋ฉฐ, Server Driven UI๋‚˜ GraphQL๊ณผ๋Š” ์ „ํ˜€ ๊ด€๋ จ์ด ์—†๋‹ค. ๋‹ค์–‘ํ•œ ์–ธ์–ด๋กœ ๋œ GraphQL API ์„œ๋ฒ„ ๊ตฌํ˜„์ฒด๊ฐ€ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ์–ธ์–ด์˜ ์„ ํƒ์€ ๋ฌธ์ œ๊ฐ€ ๋˜์ง€ ์•Š๋Š”๋‹ค.[1]

์š”์ฒญ์ด ์„ฑ๊ณตํ•˜๋ฉด ์„œ๋ฒ„์—์„œ ์˜๋„ํ•œ GraphQL ์‘๋‹ต์„ ๋ฐ›์„ ์ˆ˜ ์žˆ๋‹ค.

{
  "data": {
    "screen": {
      "components": [
        {
          "__typename": "AppBar",
          "title": "Home"
        },
        {
          "__typename": "TextButton",
          "text": "Sign in",
          "route": "/sign_in"
        },
        {
          "__typename": "TextButton",
          "text": "Sign up",
          "route": null
        }
      ]
    }
  }
}

์•ž์„œ ํด๋ผ์ด์–ธํŠธ๊ฐ€ components ํ•„๋“œ ์•„๋ž˜์— Image ํ”„๋ž˜๊ทธ๋จผํŠธ๋„ ์š”์ฒญํ–ˆ์ง€๋งŒ, ์„œ๋ฒ„๊ฐ€ Image ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‘๋‹ตํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์— ๋ฆฌ์ŠคํŠธ์—๋Š” ํฌํ•จ๋˜์ง€ ์•Š์•˜๋‹ค. ๋ฐ˜๋Œ€๋กœ ์„œ๋ฒ„๊ฐ€ Image ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‘๋‹ตํ–ˆ์ง€๋งŒ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์š”์ฒญํ•˜์ง€ ๊ฒฝ์šฐ์—๋„ ๋ฆฌ์ŠคํŠธ์— ํฌํ•จ๋˜์ง€ ์•Š๋Š”๋‹ค. ๋”ฐ๋ผ์„œ ์„œ๋ฒ„๊ฐ€ ์‹ ๊ทœ ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ •์˜ํ•˜๊ฑฐ๋‚˜ ๊ธฐ์กด ์ปดํฌ๋„ŒํŠธ์— ์‹ ๊ทœ ํ•„๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ด๋„ ๊ตฌ๋ฒ„์ „ ํด๋ผ์ด์–ธํŠธ์—์„œ๋Š” ์‹ ๊ทœ ์ปดํฌ๋„ŒํŠธ์™€ ํ•„๋“œ๋ฅผ ์š”์ฒญํ•˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— ํด๋ผ์ด์–ธํŠธ์˜ ํ•˜์œ„ํ˜ธํ™˜์„ฑ์„ ํ™•๋ณดํ•  ์ˆ˜ ์žˆ๋‹ค. ๋‹จ, ๊ธฐ์กด ์ปดํฌ๋„ŒํŠธ์— ๋Œ€ํ•ด ๊ตฌ๋ฒ„์ „ ํด๋ผ์ด์–ธํŠธ์—์„œ ์‚ฌ์šฉ ์ค‘์ธ ํ•„๋“œ๋ฅผ ์ œ๊ฑฐํ•˜๊ฑฐ๋‚˜ non-nullable ํ•„๋“œ๋ฅผ nullable ํ•„๋“œ๋กœ ๋ฐ”๊พธ๋Š” ๊ฒฝ์šฐ ํ•˜์œ„ํ˜ธํ™˜์„ฑ์ด ๊นจ์ง€๋ฏ€๋กœ ์ฃผ์˜ํ•ด์•ผ ํ•œ๋‹ค.

Flutter: ๊ฒฌ๊ณ ํ•œ ๋””์ž์ธ ์‹œ์Šคํ…œ๊ณผ ์œ„์ ฏ์œผ๋กœ ํ™”๋ฉด ๊ทธ๋ฆฌ๊ธฐ

ํ†ต์ผ๊ฐ์žˆ๋Š” ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ๋””์ž์ธ ์‹œ์Šคํ…œ์ด ์ž˜ ์žกํ˜€ ์žˆ์–ด์•ผ ํ•œ๋‹ค. ๋งŒ์•ฝ UI ๋ ˆ๋ฒจ์—์„œ ๋””์ž์ธ ์‹œ์Šคํ…œ์ด ์ •๋ฆฝ๋˜์–ด ์žˆ์ง€ ์•Š๋‹ค๋ฉด ์• ์ดˆ์— ์ปดํฌ๋„ŒํŠธ๋ฅผ ๊ฐœ๋…์„ ๋„์ž…ํ•˜๋Š” ๊ฒƒ์ด ์–ด๋ถˆ์„ฑ์„ค์ผ ๋ฟ๋”๋Ÿฌ, ์„œ๋ฒ„์™€ ํด๋ผ์ด์–ธํŠธ, ๋””์ž์ธ ์‚ฌ์ด์— ์‚ฌ์šฉํ•˜๋Š” ์šฉ์–ด๊ฐ€ ๋‹ฌ๋ผ์ ธ ์ปค๋ฎค๋‹ˆ์ผ€์ด์…˜ ๋น„์šฉ๋„ ์ฆ๊ฐ€ํ•œ๋‹ค.

ํ”Œ๋Ÿฌํ„ฐ(Flutter)์˜ ๋จธํ‹ฐ๋ฆฌ์–ผ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ(Material Library)๋Š” ๊ตฌ๊ธ€์˜ ๋จธํ‹ฐ๋ฆฌ์–ผ ๋””์ž์ธ ์‹œ์Šคํ…œ์„ ๋†’์€ ์ˆ˜์ค€์œผ๋กœ ๊ตฌํ˜„ํ•˜๊ณ  ์žˆ์–ด Server Driven UI๋ฅผ ๋ฐ”๋กœ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค.

ํ”„๋ž˜๊ทธ๋จผํŠธ-์ปดํฌ๋„ŒํŠธ-์œ„์ ฏ ๋Œ€์‘

ํ”Œ๋Ÿฌํ„ฐ๊ฐ€ ๊ฐ€์ง„ ์œ„์ ฏ(Widget) ๊ฐœ๋…์ด ์ปดํฌ๋„ŒํŠธ ๊ฐœ๋…๊ณผ ๋ถ€ํ•ฉํ•œ๋‹ค๋Š” ์ ๋„ Server Driven UI ์„ค๊ณ„์™€ ์ž˜ ๋งž๋Š”๋‹ค. ํ”Œ๋Ÿฌํ„ฐ์˜ ์œ„์ ฏ์€ ์›น ํ”„๋ก ํŠธ์—”๋“œ ํ”„๋ ˆ์ž„์›Œํฌ์ธ ๋ฆฌ์•กํŠธ(React)์˜ ์ปดํฌ๋„ŒํŠธ ์‹œ์Šคํ…œ์œผ๋กœ๋ถ€ํ„ฐ ์˜๊ฐ์„ ๋ฐ›์•„ ๋งŒ๋“ค์–ด์กŒ์œผ๋ฉฐ, ๊ฐ ์œ„์ ฏ์€ ์ž์‹ ์˜ ํ˜„์žฌ ์ƒํƒœ์— ๋”ฐ๋ฅธ UI๋ฅผ ํ‘œํ˜„ํ•œ๋‹ค.[2]

ํด๋ผ์ด์–ธํŠธ์˜ ์ถ”์ƒ ํด๋ž˜์Šค Component๋Š” ์„œ๋ฒ„์—์„œ ์‘๋‹ตํ•˜๋Š” GraphQL ์œ ๋‹ˆ์˜จ ํƒ€์ž… Component์— ๋Œ€์‘๋œ๋‹ค. Component๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ํด๋ž˜์Šค๋Š” ์œ„์ ฏ์„ ๋ฐ˜ํ™˜ํ•˜๋Š” compose ๋ฉ”์„œ๋“œ๋ฅผ ํ•จ๊ป˜ ๊ตฌํ˜„ํ•ด์•ผ ํ•œ๋‹ค.

abstract class Component {
  Widget compose(Map<String, dynamic> args, BuildContext context);
}

๊ฐ€๋ น ์•ฑ ์ƒ๋‹จ์— ๋“ค์–ด๊ฐ€๋Š” ์•ฑ๋ฐ” UI๋ฅผ ์˜๋ฏธํ•˜๋Š” AppBarComponent๋Š” Component ํด๋ž˜์Šค๋ฅผ ๊ตฌํ˜„ํ•˜๋ฉฐ, AppBar ์œ„์ ฏ์„ ๋ฐ˜ํ™˜ํ•˜๋Š” compose ๋ฉ”์„œ๋“œ๋ฅผ ๊ฐ–๋Š”๋‹ค.

class AppBarComponent implements Component {
  Widget compose(Map<String, dynamic> args, BuildContext context) {
    return AppBar(
      title: Text(args['title']),
    );
  }
}

compose ๋ฉ”์„œ๋“œ๋Š” args ์ธ์ž์˜ title ํ”„๋กœํผํ‹ฐ์— ์ ‘๊ทผํ•ด ์•ฑ๋ฐ”์˜ ํƒ€์ดํ‹€์„ ์ฑ„์šด๋‹ค. ์ด๋•Œ args ์ธ์ž๋Š” ์„œ๋ฒ„์˜ ์‘๋‹ต์— ํฌํ•จ๋˜๋Š” AppBar ํ”„๋ž˜๊ทธ๋จผํŠธ์˜ ํ•„๋“œ๋“ค์ด Map<String, dynamic> ํƒ€์ž…์œผ๋กœ ์ „๋‹ฌ๋  ๊ฒƒ์ด๋‹ค.

ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ๋ฐ›์€ ์‘๋‹ต์„ ํŒŒ์‹ฑํ•œ ๋‹ค์Œ, components ํ•„๋“œ์— ํฌํ•จ๋œ ๊ฐ๊ฐ์˜ ํ”„๋ž˜๊ทธ๋จผํŠธ๋“ค์„ ์ž์‹ ์˜ ์ปดํฌ๋„ŒํŠธ์— ๋Œ€์‘์‹œํ‚ค๊ณ , ๊ฐ ์ปดํฌ๋„ŒํŠธ์˜ ์œ„์ ฏ์— ๋Œ€์‘์‹œํ‚ค๋ ค๋ฉด GraphQL ์Šคํ‚ค๋งˆ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ํ•œ ์ปดํฌ๋„ŒํŠธ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ๊ฐ€ ํ•„์š”ํ•˜๋‹ค.

class Registry {
  static final Map<String, Component> _dictionary = {
    'AppBar': AppBarComponent(),
    'TextField': TextFieldComponent(),
    'Image': ImageComponent(),
  };

  static Widget getComponent(dynamic component, BuildContext context) {
    var matchedComponent = _dictionary[component['__typename']];
    if (matchedComponent != null) {
      return matchedComponent.compose(component, context);
    } else {
      return null;
    }
  }

  static List<Widget> getComponents(dynamic components, BuildContext context) {
    var matchedComponent = components as List<dynamic>;
    return matchedComponent.map((component) => getComponent(component, context))
        .where((element) => element != null)
        .toList();
  }
}

ํด๋ผ์ด์–ธํŠธ๋Š” ์‘๋‹ต ๋‚ด์šฉ์„ ๋ฐ”ํƒ•์œผ๋กœ ์œ„์ ฏ ๋ฆฌ์ŠคํŠธ๋ฅผ ์–ป๊ธฐ ์œ„ํ•ด ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ์˜ getComponents ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜๊ณ , components ํ•„๋“œ๋ฅผ ์ˆœํšŒํ•˜๋ฉฐ getComponent ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ํ”„๋ž˜๊ทธ๋จผํŠธ๋ฅผ ์œ„์ ฏ์œผ๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค.

getCompnent ๋ฉ”์„œ๋“œ๋Š” ํ”„๋ž˜๊ทธ๋จผํŠธ์— ํฌํ•จ๋œ ๋ฉ”ํƒ€ ํ•„๋“œ __typename ๊ฐ’์„ ์ด์šฉํ•ด ๊ฐ ํ”„๋ž˜๊ทธ๋จผํŠธ๋ฅผ ์ปดํฌ๋„ŒํŠธ์— ๋Œ€์‘์‹œํ‚ค๊ณ , ํ•ด๋‹น ์ปดํฌ๋„ŒํŠธ์˜ compose ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•ด ์ปดํฌ๋„ŒํŠธ ๊ฐ๊ฐ์˜ ์œ„์ ฏ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ๋งŒ์•ฝ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋ชจ๋ฅด๋Š”(_dictionary์— ๋“ฑ๋ก๋˜์ง€ ์•Š์€) ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์‘๋‹ต์— ํฌํ•จ๋˜์–ด ์žˆ๋‹ค๋ฉด ํ•„ํ„ฐ๋ง๋  ๊ฒƒ์ด๋‹ค.

์ปดํฌ๋„ŒํŠธ ์กฐ๋ฆฝ

์•ž์„œ ์‚ดํŽด๋ณธ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ๋ฅผ ์ด์šฉํ•ด ์„œ๋ฒ„์—์„œ ์‘๋‹ตํ•˜๋Š” ๋ชจ๋“  ์ปดํฌ๋„ŒํŠธ๋ฅผ ์œ„์ ฏ์œผ๋กœ ๋ณ€ํ™˜ํ•˜๊ณ , ๊ฐ ํ™”๋ฉด์— ๋งž๋Š” ์œ„์ ฏ์„ ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ๋‹ค. ์ง€๊ธˆ๊นŒ์ง€์˜ ํ๋ฆ„์„ ์„œ๋ฒ„, API ์‘๋‹ต, ํด๋ผ์ด์–ธํŠธ๋กœ ์ •๋ฆฌํ•˜๋ฉด ์•„๋ž˜์™€ ๊ฐ™๋‹ค.

ํ”Œ๋Ÿฌํ„ฐ ์œ„์ ฏ ์ค‘ Container์ด๋‚˜ Column๊ณผ ๊ฐ™์ด ๋‹ค๋ฅธ ์œ„์ ฏ์„ child ๋˜๋Š” children์œผ๋กœ ๋‹ด๋Š” ์œ„์ ฏ๋„ ๊ฐ™์€ ๋ฐฉ์‹์œผ๋กœ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค. ์ผ์ข…์˜ '์ปดํฌ๋„ŒํŠธ์˜ ์ปดํฌ๋„ŒํŠธโ€™์ธ ์…ˆ์ด๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด GridView ์œ„์ ฏ์„ ์ƒ๊ฐํ•ด๋ณด์ž. ๊ทธ๋ฆฌ๋“œ ๋ทฐ๋Š” ๊ฒฉ์ž ์…€์ด ๋ฐ˜๋ณต๋˜๋Š” ๋ ˆ์ด์•„์›ƒ์œผ๋กœ, ๊ฐ ์…€์—๋Š” ๋‹ค๋ฅธ ์œ„์ ฏ์„ ๋ฐฐ์น˜์‹œํ‚ฌ ์ˆ˜ ์žˆ๋‹ค. ๊ทธ๋ฆฌ๋“œ์˜ ์—ด ๊ฐœ์ˆ˜์™€ ๊ฐ ์…€์— ๋„ฃ์„ ์ปดํฌ๋„ŒํŠธ๋ฅผ ์„œ๋ฒ„์—์„œ ๊ด€๋ฆฌํ•˜๊ณ ์ž ํ•œ๋‹ค๋ฉด ์•„๋ž˜์™€ ๊ฐ™์ด ์Šคํ‚ค๋งˆ๋ฅผ ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ์ด๋‹ค.

type GridView {
  column_count: Int!
  chidren: [Component!]!
}

ํด๋ผ์ด์–ธํŠธ์—์„œ ์š”์ฒญํ•  ๋•Œ๋Š” GridView ํ”„๋ž˜๊ทธ๋จผํŠธ์˜ children ํ•„๋“œ ์•„๋ž˜์— ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋“  ์ปดํฌ๋„ŒํŠธ๋ฅผ ์š”์ฒญํ•ด์•ผ ํ•œ๋‹ค. ๊ฐ™์€ ํ”„๋ž˜๊ทธ๋จผํŠธ๋ฅผ ์žฌ์‚ฌ์šฉํ•˜๋ฏ€๋กœ ์ธ๋ผ์ธ ํ”„๋ž˜๊ทธ๋จผํŠธ ๋Œ€์‹  ๋ณ„๋„์˜ ๊ธฐ๋ช… ํ”„๋ž˜๊ทธ๋จผํŠธ๋ฅผ ๋งŒ๋“ค์—ˆ๋‹ค.

query fetchScreen {
  screen(screenType: HOME) {
    components {
      ... AppBar
      ... TextButton
      ... Image
      ... on GridView {
        __typename
        column_count
        children {
          ... AppBar
          ... TextButton
          ... Image
        }
      }
    }
  }
}

fragment AppBar on AppBar {
  __typename
  title
}

fragment TextButton on TextButton {
  __typename
  text
  route
}

fragment Image on Image {
  __typename
  url
}

๋งˆ์ง€๋ง‰์œผ๋กœ GridVew ์œ„์ ฏ์„ ๋ฐ˜ํ™˜ํ•˜๋Š” GridViewComponent๋Š” ์ž์‹ ์˜ children ํ•„๋“œ ๊ฐ’์„ getComponents๋กœ ๋„˜๊ฒจ ์œ„์ ฏ ๋ฆฌ์ŠคํŠธ๋ฅผ ๊ตฌ์„ฑํ•œ๋‹ค.

class GridViewComponent implements Component {
  Widget compose(Map<String, dynamic> args, BuildContext context) {
    return Expanded(
      child: GridView.count(
        padding: const EdgeInsets.all(20),
        crossAxisSpacing: 20,
        mainAxisSpacing: 20,
        crossAxisCount: args["columnCount"],
        children: Registry.getComponents(args["children"], context),
      ),
    );
  }
}

์—ฌ๊ธฐ์„œ๋Š” padding, crossAxisSpacing, mainAxisSpacing ํ”„๋กœํผํ‹ฐ๋ฅผ ์ƒ์ˆ˜ ๊ฐ’์œผ๋กœ ์„ค์ •ํ–ˆ์ง€๋งŒ, ๋งŒ์•ฝ ์„œ๋ฒ„์—์„œ ๊ด€๋ฆฌํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด ๊ฐ„๋‹จํžˆ ํ•„๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ  ๊ฐ’์„ ๋„ฃ์–ด์ฃผ๊ธฐ๋งŒ ํ•˜๋ฉด ๋œ๋‹ค.

์ด์ œ ์„œ๋ฒ„์—์„œ GridView์˜ children ํ•„๋“œ์— TextButton ๋‘ ๊ฐœ๋ฅผ ์‘๋‹ตํ•˜๋ฉด ํด๋ผ์ด์–ธํŠธ์—์„œ๋Š” ๊ทธ๋Œ€๋กœ ๋‘ ๊ฐœ์˜ ์…€์— TextButton์ด ๋‹ด๊ธด ํ™”๋ฉด์„ ๊ตฌ์„ฑํ•œ๋‹ค. ๊ทธ๋ฆฌ๋“œ์˜ ์—ด ๊ฐœ์ˆ˜๋‚˜ ๊ฐ ์…€์˜ ๋‚ด์šฉ์€ ์„œ๋ฒ„ ์‘๋‹ต์„ ์ˆ˜์ •ํ•˜๋Š” ๊ฒƒ๋งŒ์œผ๋กœ ์–ธ์ œ๋“  ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ๋‹ค.

์‹ค์ œ ๋™์ž‘ํ•˜๋Š” ์ฝ”๋“œ๋Š” github.com/parksb/server-driven-ui์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค. ๋™์ž‘ ๋ฐฉ์‹์ด๋‚˜ ๊ฐœ๋…์€ ์ด ๊ธ€์—์„œ ์„ค๋ช…ํ•œ ๊ฒƒ๊ณผ ๋™์ผํ•˜์ง€๋งŒ, ์ปดํฌ๋„ŒํŠธ ์ข…๋ฅ˜๋‚˜ ๊ตฌ์ฒด์ ์ธ ํ•„๋“œ๋Š” ๋‹ค์†Œ ์ฐจ์ด๊ฐ€ ์žˆ๋‹ค. ๋˜ํ•œ ์นด์นด์˜ค์Šคํƒ€์ผ์—์„œ๋„ Server Driven UI ์„ค๊ณ„๋ฅผ ์ ๊ทน์ ์œผ๋กœ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๋Š”๋ฐ, ์นด์นด์˜ค์Šคํƒ€์ผ ๊ธฐ์ˆ  ๋ธ”๋กœ๊ทธ์—์„œ ์ž์„ธํžˆ ๋ณผ ์ˆ˜ ์žˆ๋‹ค.

References


  1. The GraphQL Foundation, โ€œGraphQL Code Libraries, Tools and Servicesโ€. โ†ฉ๏ธŽ

  2. Flutter, โ€œIntroduction to widgetsโ€. โ†ฉ๏ธŽ

โ†

์ฒ ๋„ ์‹œ๊ฐ„ํ‘œ๊ฐ€ ์œ ๋‹‰์Šค ์‹œ๊ฐ„์ด ๋˜๊ธฐ๊นŒ์ง€

์‹œ๊ฐ„๊ณผ ์ปดํ“จํ„ฐ ๊ณตํ•™

โ†’

์ฝ๊ธฐ ์‰ฌ์šด ์›น์„ ์œ„ํ•œ ํƒ€์ดํฌ๊ทธ๋ž˜ํ”ผ

์กฐํŒ ์›์น™์œผ๋กœ ๊ฐ€๋…์„ฑ ๋†’์ด๊ธฐ

โ† Articles