서버 개발자가 헥사코드를 몰라도 되게 만들기

안녕하세요, 카카오스타일 지그재그 전시BE파트 밀리입니다.

전시 개발자로서 혹시 이런 하루를 보내고 있나요?

  • 문구 하나 바꾸는 데 PR 하나, 리뷰 둘, 배포 한 번. “최대"를 “최다"로 고치는 수정 자체는 5초인데, 프로세스는 반나절이에요.
  • 이벤트는 끝났는데 if문은 퇴근을 안 해요. 켜는 것도 배포, 끄는 것도 배포예요. 바쁘면 지난 이벤트 분기가 다음 이벤트 때까지 코드에 남아 있어요.
  • “다음 주 화요일 0시에 켜주세요.” 스케줄 분기를 미리 심어두거나, 화요일 0시에 누군가 배포 버튼을 눌러야 해요.
  • “그 색 아닌데요.” 디자이너의 한마디에 배포가 또 나가요. 그런데 ACCENT_400이랑 KEY_COLOR_DEFAULT 중에 뭘 써야 하는지도 사실 몰라요. 값은 같거든요.
  • “신규 유저한테만요. 아, A/B도 걸어주세요.” 타게팅은 전부 코드 분기예요. 테스트가 끝나면 그걸 지우는 배포가 또 나가요.

요청 하나하나는 사소해요. 문제는 전부 개발자를 거쳐야 하고, 전부 배포가 필요하다는 거예요. 이 글은 왜 그런 일이 벌어졌고, 그걸 없애기 위해 무엇을 만들었는지에 대한 이야기예요.

먼저, SDUI가 무엇인가요?

지그재그의 주요 화면은 SDUI(Server-Driven UI) 방식으로 그려져요.

일반적인 API는 서버가 데이터를 주고, 앱이 그 데이터를 보고 화면을 어떻게 그릴지 결정해요.

일반 API   서버 → { price: 12900, discountRate: 30 }
              → 앱이 "12,900원"을 어떤 색, 어떤 크기로 그릴지 결정

SDUI      서버 → { text: "12,900원", color: "#F06ADF", bold: true }
              → 앱은 받은 값을 그대로 화면에 뿌리기만 함

화면을 어떻게 그릴지에 대한 결정권이 앱에서 서버로 넘어와요. 가장 큰 장점은 앱 배포 없이 화면을 바꿀 수 있다는 거예요. 스토어 심사를 기다리지 않아도 되고, 구버전 앱을 쓰는 사용자에게도 새 화면을 보여줄 수 있어요.

처음 SDUI를 접했을 때는 이 점이 무척 좋아 보였어요. 기획 요구사항이 생겼을 때 앱 배포 주기에 구애받지 않고 유연하게 대응할 수 있겠다고 생각했죠.

그런데 하드코딩이 서버로 옮겨왔어요

막상 개발을 해보니 결정권만 넘어온 게 아니었어요. 앱에 있던 하드코딩이 그대로 서버로 넘어왔어요.

아래는 상품 상세에 노출되는 배너를 만드는 기존 코드예요. 클래스명과 문구는 치환했지만 구조는 그대로예요.

fun getBanner(...): Banner? {
  // ① 노출 조건
  if (price.isBannerAvailable().not()) return null
  val target = banners.firstOrNull { validate(it) } ?: return null

  // ② 문구
  val prefix = "${target.title} 종료 시"
  val post = "${price.withComma()}원 비싸져요"

  // ③ 색상을 HTML 문자열로 조립
  val html = "<font color='${ColorConstants.GRAY_LIGHT_100}'>$prefix </font>" +
    "<b><font color='${ColorConstants.ACCENT_400}'>$post</font></b>"

  return Banner(
    text = prefix,               // deprecated. 구버전 앱이 보고 있어서 못 지움
    price = post,                // deprecated
    banner_text = CommonText(text = "$prefix $post", html = ...),
    icon_url = CommonImageUrl(normal = BANNER_ARROW_UP_ICON),  // ④ 이미지 URL
  )
}

함수 하나에 노출 조건과 문구와 색상과 이미지가 다 들어 있어요. 색상은 서버가 <font> 태그를 문자열로 조립해서 내려주고, 문구는 두 군데에 중복돼 있어요. deprecated 필드는 구버전 앱이 아직 보고 있어서 지울 수도 없었어요.

색상 상수는 이런 파일에 모여 있었어요.

object ColorConstants {
  const val KEY_COLOR_DEFAULT = "#F06ADF"
  const val ACCENT_400 = "#F06ADF"      // 위와 같은 값, 이름만 다름
  const val GRAY_LIGHT_600 = "#6A7074"
  const val GRAY_600 = "#6A7074"        // 위와 같은 값, 이름만 다름
  const val HEX_FA4433 = "#FA4433"      // 이름을 붙일 수 없어서 헥사코드가 상수명
  const val BRAND_LEGACY = ACCENT_400   // 안 쓰는데 남아 있음
}

실제로는 상수가 60개가 넘었어요. 값이 같은데 이름만 다른 쌍이 여러 개 있고, 이름을 붙이기 어려운 색은 헥사코드를 그대로 상수명으로 썼어요. 다크 모드 값이 아예 빠져 있는 색도 있었어요. 디자인 시스템에서 관리하는 컬러값이 변경되면 이 파일도 같이 개정해야 하는데, 그걸 보장하는 장치는 없었어요.

화면 구성도 서버 코드가 정했어요.

class PageMakerService(
  private val headerUseCase: HeaderUseCase,
  private val priceUseCase: PriceUseCase,
  ...
  // 컴포넌트가 늘어날 때마다 파라미터도 늘어나요. 20개를 넘었어요
) {
  suspend fun getPage(...) = Page(
    topComponentList = listOfNotNull(
      imageGroup.await(),
      priceGroup.await(),
      banner.await() ?: firstBanner.await(),
    ),
  )
}

배너 순서를 바꾸려면 이 리스트의 줄 순서를 바꿔야 해요. 기획 정책은 ?: 연산자에 담겨 있어요.

처음에는 이게 크게 문제라고 생각하지 않았어요. 기획자나 운영자가 화면 구성을 바꾸고 싶을 때 서버 개발만으로 대응이 가능했으니까요. 하지만 이런 프로세스가 반복될수록 SDUI가 오히려 병목이 된다는 느낌을 받았어요.

  • 문구 하나 바꾸는 데 PR과 리뷰와 배포가 붙어요.
  • 이벤트가 생기면 if문이 늘고, 끝나면 누군가 지워야 해요. 제때 챙기지 않으면 한동안 방치되기도 했어요.
  • A/B 테스트나 특정 유저군 타게팅은 전부 코드 분기예요.

그럼 하드코딩을 다시 앱으로 돌려주면 될까요?

가장 먼저 떠오르는 해결책은 “결정권을 다시 앱으로 넘기자"였어요. 실제로 SDUI를 걷어내면 서버 코드에서 색상과 문구가 사라져요.

그런데 이건 문제를 해결하지 않아요. 앱으로 돌려주면 화면 변경이 다시 스토어 배포 주기에 묶이고, iOS·Android·웹이 각자 하드코딩하면서 구현이 파편화돼요. SDUI를 도입한 이유가 그대로 다시 문제가 되는 거죠.

다른 방법도 생각해봤어요. 어드민을 만들어서 운영자가 값을 직접 넣게 하는 것이요. 그런데 어드민에서 헥사코드를 입력받으면, 결국 운영자가 헥사코드를 알아야 해요. 하드코딩하는 주체가 개발자에서 운영자로 바뀌는 것뿐이에요.

두 방법이 모두 부족했던 이유는 문제의 원인을 잘못 짚었기 때문이었어요. 진짜 문제는 ‘서버가 화면을 그린다’는 것도, ‘누가 색을 입력하느냐’도 아니었어요.

화면을 어떻게 구성할지에 대한 결정이 코드 안에 갇혀 있다는 것

이게 문제였어요. 결정이 코드 안에 있으니 결정을 바꾸려면 배포를 해야 하고, 배포를 해야 하니 개발자를 거쳐야 했던 거예요.

그래서 저희는 SDUI를 유지하면서, 화면 구성을 코드가 아닌 데이터로 다루기로 했어요. 개발자는 “상품 카드 캐로셀"이라는 모듈을 만드는 도구를 개발하고, 그 캐로셀을 홈 세 번째 자리에 놓고, 제목을 붙이고, 추천 상품을 연결하고, 20대에게만 보이게 하고, 다음 주 화요일에 켜는 일은 운영자가 하는 거예요.

이를 위해 만든 시스템이 UX Builder예요. UX Builder를 통해 운영자는 화면 버전을 만들고, 슬롯에 모듈을 배치하고, 각 모듈별 내용을 정해요. 사용자 요청이 들어오면 UX Builder 서버는 적절한 화면을 선택해 렌더링하고, 앱은 내려받은 결과를 그대로 그려요.

UX Builder의 구조

화면을 데이터로 다루기 위해 아래와 같은 계층을 설계했어요.

Page              앱 화면 하나 (홈, 카테고리 목록, 검색 결과 …)
 └ PageRevision     화면의 버전. 작업 중(draft) / 공개됨(released)
    └ Layout          설계도. 타게팅 조건별로 여러 개가 공존해요
       └ ModuleSlot     화면 안의 고정 영역
          └ Module        그 영역에 들어가는 콘텐츠 단위
             ├ Block        모듈을 구성하는 UI 조각. 스타일과 문구가 여기 들어가요
             ├ DataSource   블록을 그리기 위한 원천 데이터
             └ Exposure     모듈이 화면에 노출되는 조건

기존 코드와 비교하면 이렇게 대응돼요.

기존 UX Builder
listOfNotNull(...) 의 줄 순서 ModuleSlot 안의 Module 배치
if (user.isNew && today in period) Layout의 타게팅 조건 + 스케줄
ColorConstants.ACCENT_400 Block 옵션의 컬러 토큰
?: 로 표현된 대체 노출 규칙 Exposure 타입
하드코딩된 추천 키값 DataSource 옵션

아래에서 하나씩 살펴볼게요.

PageRevision: 버전과 롤백

운영자가 화면을 구성하는 도중에 그 내용이 라이브로 나가면 안 돼요. 그래서 임시 저장 버전(draft)과 라이브 버전(released)을 나눴어요. 버전을 두었기 때문에 문제가 생기면 이전 버전으로 롤백하는 것도 쉬워요.

Layout: 타게팅과 스케줄

Layout은 타게팅 조건과 스케줄을 갖는 화면 설계도예요. 유저 유형이나 A/B 테스트에 따라 모듈 구성을 다르게 운영하고 싶은 니즈는 항상 있어왔어요.

기존에는 if (user.isNew && today in eventPeriod) 같은 코드 분기로 대응했지만, 이제는 설정된 조건과 스케줄을 보고 알아서 타게팅해요.

val livePageRevisionId = getPageRevisionIdByStage(pageKey)
val candidates = layoutService.getLayoutByPageRevisionId(livePageRevisionId)
return layoutSelector.selectLayout(candidates, session)

Module: 콘텐츠 단위

Module은 화면에 놓이는 하나의 운영 단위이고, 1차적으로 UI 단위인 Block으로 구성돼요. 블록은 타이틀, 이미지, 버튼, 상품 카드 등 가장 작은 UI 구성 단위구요. 이 구성 단위에 대한 옵션을 어드민에서 받아 처리하면서 상수 하드코딩에서 벗어날 수 있어요.

현재 제공 중인 여러 가지 모듈들

Block: 색을 값이 아니라 토큰으로

Block은 타이틀, 이미지, 버튼, 상품 카드처럼 가장 작은 UI 구성 단위예요. 블록 타입마다 받을 수 있는 옵션이 정해져 있어요.

data class ButtonBlockOption(
  val text: Text,
  val landingUrl: String?,
  val backgroundColor: Color? = null,
  val position: ButtonPositionType? = null,
) : BlockOption

헤더 타이틀을 운영자가 직접 수정하는 어드민 화면

여기서 색을 어떻게 받을지가 가장 중요한 문제였어요. 앞서 말했듯이 어드민에서 헥사코드를 입력받으면 하드코딩의 주체만 바뀔 뿐이니까요.

그래서 색은 값이 아니라 디자인 시스템 토큰으로 받아요.

디자인 직군이 관리하는 컬러 토큰 Figma. 플러그인을 연동해서 데이터 변경 시 자동으로 UX Builder 코드에 반영된다.

data class Color(
  val hexColor: HexColor? = null,
  val token: ColorToken? = null,
)

enum class ColorToken(val label: String, val normal: String, val dark: String?) {
  COLOR_SEMANTIC_BACKGROUND_BASE(
    label = "Background/Base",
    normal = "#F5F6F6",
    dark = "#050505",
  ),
  // 디자인 시스템 토큰 113개
}

운영자는 #F5F6F6을 입력하는 게 아니라 목록에서 Background/Base를 골라요. 라이트와 다크 값은 토큰에 함께 붙어 있어요.

앞에서 봤던 ColorConstants에서는 같은 색이 이름만 다르게 두 번 있거나 다크 값이 빠져 있었는데, 이제 그런 상태가 구조적으로 나올 수 없어요. 색은 디자인 시스템에 정의된 것만 사용할 수 있고, 팔레트가 개정되면 토큰 정의 한 곳만 바뀌어요.

ColorConstants.ACCENT_400이 있던 자리에는 이제 option.backgroundColor가 있어요. 색을 바꾸는 일이 배포에서 저장으로 바뀌었어요.

Action: 인터랙션도 서버에서

UI에 대한 인터랙션도 서버가 지시해요. 기존에는 앱이 데이터를 보고 직접 랜딩을 처리했다면, 이제는 서버가 액션 규약을 내려주고 앱은 그대로 실행해요. 액션은 모듈 응답의 meta에 실려 나가요.

module_view {
  meta { action_list { type, trigger_type, target, option_list } }
  body { ... }
}
{ "type": "DEEP_LINK", "landingUrl": "deeplink://product?product_id=138449765" }

검색 필터처럼 서버가 다시 받아야 하는 값도 액션에 담아요.

{
  "type": "SEARCH_FILTER_CHANGE",
  "search_query_state": {
    "filter_list": [
      {
        "filter_type": "SHIPPING_TYPE",
        "value_list": [{ "label": "빠른출발", "value": "FAST_DELIVERY" }]
      }
    ],
    "order": "추천순"
  }
}

이 액션은 앱이 해석하지 않고 서버에 그대로 되돌려줘요. 앱은 판단하지 않고 전달만 해요. 서버는 필터에 필요한 값을 주고, 다시 받아서 결과를 내려주기만 하면 돼요.

DataSource: 어떤 데이터를 붙일지 운영자가 정해요

배너는 CMS에서, 추천 상품은 전시추천 시스템에서 가져와요. 기존에는 하드코딩된 키값으로 고정된 정책 안에서만 운영이 가능했지만, 이제는 어떤 배너를 쓸지, 어떤 추천 정책을 붙일지 선택지가 운영자에게 열려 있어요.

어떤 상품 추천 정책을 붙일지 운영자가 선택하는 화면

data class DrDataSourceOption(
  val pageComponentKey: DrPageComponentKey,  // 어떤 추천을
  val limit: Int?,                           // 몇 개 가져올지
) : DataSourceOption

코드 상수였던 값이 저장값으로 내려왔어요.

Exposure: 언제 노출될지도 설정이에요

상품 카드 리스트 중간중간에 배너를 꽂고 싶고, 그 위치도 운영 상황에 맞춰 자유롭게 바꿀 수 있어야 했어요. 단순 삽입 외에도 “찜하면 다른 모듈이 나타난다”, “특정 모듈이 렌더될 때만 같이 나타난다” 같은 요구사항도 있었어요. 그래서 네 가지 노출 타입을 제공해요.

노출 타입 언제 렌더되는가
기본 항상. 슬롯당 하나
삽입 기본 모듈이 만든 목록 사이 지정한 위치에
조건부 특정 인터랙션이 일어났을 때만
종속 특정 모듈이 실제로 렌더된 경우에만, 그 모듈 기준 상대 위치에
sealed class ModuleExposure {
  data object Default : ModuleExposure()
  data class Injection(val positions: List<Int>) : ModuleExposure()
  data object Conditional : ModuleExposure()
  data class Dependent(val anchorModuleId: String, val side: DependentSide) : ModuleExposure()
}

운영자가 “상품 3번째와 8번째에 배너"라고 설정하면 이렇게 저장돼요.

{ "type": "INJECTION", "positions": [3, 8] }

하드코딩 if 분기로만 가능했던 노출 제어를 운영자가 직접 하게 되었어요.

유연해진 만큼 안정성도 챙겨야 해요

운영자가 한 화면에 여러 모듈을 자유롭게 조합하면, 그 모듈들은 각자 다른 외부 API를 호출해요. 그중 하나가 장애를 겪었을 때 화면 전체가 비면 안 돼요. 자유도를 준 대가로 장애 지점이 늘어난 셈이니까요.

그래서 모듈 렌더를 개별로 격리해요.

return try {
  moduleRenderService.render(arguments, session)
} catch (e: CancellationException) {
  throw e          // 취소 신호는 전파해야 해요
} catch (e: Exception) {
  log.error(e) { "모듈 렌더링 실패, 빈 결과 반환" }
  emptyList()      // 이 모듈만 빠지고 화면은 떠요
}

그런데 모듈이 빠져도 괜찮은 화면이 있고, 그렇지 않은 화면도 있어요. 홈에서 추천 상품 리스트가 통째로 빠지면 사용자에게 상품을 추천할 수가 없어요. 그래서 렌더가 실패하면 재시도하는 MODULE_REFETCH 모듈을 만들었어요.

data class ModuleRefetchActionOption(
  val delay: Long = 0,
  // 1부터 시작해 재시도마다 증가. 3회에 도달하면 액션을 안 붙여서 종료돼요
  val tryCount: Int = 1,
  override val renderParams: List<RenderParam>,
) : ActionOption.ServerProcessed.ParamEmbedded

재시도 간격과 횟수 상한까지 서버가 응답에 실어 보내요. 여기서도 앱은 판단하지 않고 그대로 돌려주기만 해요.

같은 장치를 성능에도 활용했어요. 초기 렌더에서는 상단 네 개 슬롯만 실제로 그리고 나머지는 스켈레톤으로 내려보낸 뒤, 앱이 노출 시점에 추가 렌더를 요청해요. 이렇게 해서 ATF(첫 화면에 보이는 영역)의 로딩 속도를 확보했어요.

무엇이 바뀌었나요

첫 번째, 화면 변경이 배포가 아니라 저장으로 끝나게 되었어요. 문구를 바꾸는 것, 배너를 세 번째에서 다섯 번째로 옮기는 것, 특정 사용자에게만 다른 구성을 보여주는 것, 다음 주 화요일 0시에 켜는 것 모두 어드민에서 저장하면 끝이에요. PR도 리뷰도 배포도 붙지 않아요.

두 번째, 이벤트용 if문이 사라졌어요. 이벤트가 끝났을 때 누군가 지워야 하는 코드가 없어요. 스케줄이 지나면 그냥 노출되지 않아요. 방치된 분기가 코드에 남는 일도 없어졌어요.

세 번째, 색상 불일치가 구조적으로 불가능해졌어요. 60개가 넘던 색상 상수는 113개의 디자인 시스템 토큰으로 정리됐고, 이름만 다른 중복이나 다크 모드 누락도 사라졌어요. 팔레트가 개정되면 토큰 정의 한 곳만 바뀌고, 서버 코드는 손대지 않아요.

네 번째, 서버 개발자가 헥사코드를 볼 일이 없어졌어요. 이벤트마다 배지 이미지 URL을 갈아끼우지도 않아요. 저는 이제 모듈이 데이터를 어떻게 가져와 어떻게 조합할지를 개발해요. 원래 하고 싶었던 일이요.

아직 남은 것들

물론 다 옮긴 건 아니에요.

모듈은 아직 커요

지금 운영자가 다루는 최소 단위는 모듈이에요. “상품 카드 캐로셀"을 어디에 놓을지, 어떤 데이터를 붙일지, 누구에게 보여줄지는 정할 수 있어요. 하지만 그 캐로셀 안에서 상품 카드가 어떻게 생겼는지는 여전히 모듈 코드가 정해요.

그래서 “이 캐로셀만 가격을 크게”, “저기는 리뷰 수를 앞에” 같은 요청은 결국 저에게 와요. 모듈을 하나 더 만들거나, 옵션을 하나 더 뚫거나 둘 중 하나예요. 모듈 타입이 늘어나는 속도가 운영 요구를 따라가는 구조라 언젠가는 막혀요.

다음은 모듈 안쪽을 더 잘게 쪼개는 방향이에요. 지금은 개발자가 블록 조합을 정해서 모듈을 만들지만, 그 조합 자체를 운영자가 만들 수 있게 하려고 해요. 모듈을 고르는 게 아니라 컴포넌트를 조립하는 쪽으로요. 그러면 새 화면이 필요할 때마다 모듈 타입을 새로 파지 않아도 돼요.

어드민은 아직 어려워요

지금 UX Builder 어드민은 이 시스템을 아는 사람만 쓸 수 있어요. 모듈 슬롯이 뭔지, 노출 타입이 뭔지, 데이터 소스를 뭘 골라야 하는지 알아야 화면 하나가 나와요. 결국 운영이 몇 명에게 몰리고, 그 몇 명이 바쁘면 다시 개발자에게 와요. 병목을 옮긴 것뿐이에요.

화면 담당 운영자들이 직접 화면을 만들 수 있으려면 두 가지가 필요하다고 보고 있어요.

하나는 안정성이에요. 잘못 눌러도 화면이 깨지지 않아야 해요. 지금은 설정을 잘못하면 저장은 되는데 앱에서 이상하게 나오는 경우가 있어요. 저장 시점에 걸러내고, 미리보기로 확인하고, 문제가 생기면 이전 버전으로 되돌릴 수 있어야 해요.

다른 하나는 설정 경험이에요. 개념을 몰라도 만들 수 있어야 해요. 자주 쓰는 구성은 템플릿으로 꺼내 쓰고, 기본값이 충분히 채워져 있고, 지금 만지는 게 화면의 어느 부분인지 바로 보이는 쪽으로 가려고 해요.

마무리

SDUI는 화면의 결정권을 앱에서 서버로 옮기는 기술이에요. 그런데 결정권만 옮기고 결정이 코드 안에 있다는 사실을 그대로 두면, 앱이 하던 하드코딩을 서버가 이어받게 돼요. 저희가 겪은 병목은 SDUI 때문이 아니라 여기서 왔어요.

그래서 저희는 SDUI를 걷어내는 대신, 화면 구성을 코드에서 데이터로 꺼냈어요. 색은 토큰으로, 노출 조건은 Exposure로, 타게팅은 Layout으로, 데이터 연결은 DataSource로요. 결정이 데이터가 되자 그 결정을 바꾸는 데 배포가 필요하지 않게 되었어요.

개발자가 개발만 하려고 만든 플랫폼인데, 아직은 이 플랫폼을 쓰는 데에 개발자가 필요해요. 거기까지 없애는 게 다음 목표예요.

만약 SDUI를 도입했거나 도입을 고민하고 있다면, 화면에 대한 결정이 지금 어디에 있는지 한 번 확인해보세요. 그 결정이 코드 안에 있다면, 결정권을 서버로 옮기는 것만으로는 아무것도 편해지지 않을 거예요.



comments powered by Disqus