조건 분기부터 운영 효율화까지
Predicate 조합과 i18n 키-값 매핑으로 콘텐츠 운영 비용을 줄여보기
당시의 문제
과거에 특정 페이지에서 커스텀 콘텐츠 및 UI를 보여줄 필요성이 있었다. 특정 자산의 종류, 언어 로케일, 컴포넌트 별로 다른 콘텐츠의 노출이 필요했고, 기간에 따라 이번 주와 다음 주가 다른 콘텐츠가 노출될 필요가 있는 경우도 있었다.
처음에는 당연히 하드코딩으로 구현해도 어렵지 않을 것이라 생각했지만, ‘제거할 때도 편할까?’에 대한 의문을 해소하지는 못했다. 하드코딩 자체도 문제지만, 더 큰 문제는 조건식과 콘텐츠 값이 컴포넌트 내부에 흩어지는 것이었다. 따라서 유지보수성이 썩 나쁜 구현이라고 생각했고, 어떤 심볼이 어떤 컴포넌트에 걸려있는지, 어떤 심볼에서의 특정 콘텐츠 컴포넌트는 숨김 처리를 한다던가, 이런 코드 자체에 대한 인지 비용이 클 것이라고 생각했다.
아이디어
당시에는 Predicate, Predicate Combinator, Named Predicate 같은 패턴에 대해 모르는 상태였다. 다만 함수 합성과 Currying에서 영감을 받았고, 함수 합성은 React 진영에서 컴포넌트를 다루는 방식과 맞닿아 있었다. 공통 입력인 심볼은 현재 페이지의 렌더링 컨텍스트에서 이미 결정되어 있었고, 각 컴포넌트마다 달라지는 것은 조건의 묶음이었다. 그래서 심볼을 먼저 고정하고, 이후에 필요한 predicate들을 주입하는 형태가 자연스럽게 나왔다. 데이터는 고정되어 있으니까, 나중에 조건을 넣는 방식을 떠올렸던 것이다. 이렇게 하면 분기식 전체를 건드리지 않고, predicate 조합 또는 개별 predicate의 반환값만 바꿔 특정 심볼에 대한 토글도 빠르게 가능해질 것 같았다.
또한 당시 해당 서비스의 다국어 지원에 따라 i18n을 사용하고 있었는데, 번역 키-값을 두고 있는 Google Sheets와 S3 + CloudFront에서 정적 에셋을 이용하는 구조도 착안했다. 심볼과 컴포넌트를 i18n 키 네임스페이스에 매핑하고 값 필드에는 심볼별 카피, 이미지 URL, 외부 링크 등을 매핑해, 개발자가 매 번 PR을 올릴 필요 없고 SSR 앱을 매 번 빌드할 필요도 없앤 것이다. 이를테면 이런 방식이었다.
assets_custom.{SYMBOL}.{COMPONENT_KEY}
t(`assets_custom.${symbol}.hero-banner-url`)
t(`assets_custom.${symbol}.sidebar-banner-url`)
t(`assets_custom.${symbol}.carousel-1`)당시 서비스는 SSR 방식을 사용하고 있었고, 이 앱의 CI/CD에서 약 15분이 소요되었는데, 커스텀 콘텐츠 몇 개의 수정을 위해 개발 서버, 스테이지 서버, 프로덕션 서버를 빌드하고 있으면 그 기다림이 말이 아니었다. 이 또한 단순 하드코딩으로 구현하면 안되겠다고 느꼈던 이유 중 하나였다. 또한, 이 구조가 되면서 배포 권한은 개발자에게 있었지만, 운영 담당자는 Google Sheets에서 직접 정적 리소스의 주소를 수정할 수도 있었다.
구현
먼저, 분기 조건을 Vue 템플릿 수준에서 v-if에 구구절절 서술하고 싶지 않았다. 중요하게 생각했던 것은 ‘현재의 동적 페이지에서 주어지는 심볼 파라미터에 커스텀이 적용되어야 하는가’를 판정할 수 있는 작은 단위였다. 이후 알게 된 용어로는 이것이 Predicate에 가까웠다.
Predicate란 프로그래밍에서 입력 값이 어떤 조건을 만족하는지 판별하는 boolean 함수를 의미한다고 한다. 그래서 Array.prototype.filter, some, every, find 같은 메서드에서 자주 쓰이는 편이다.
const isEven = (n: number) => n % 2 === 0
const isPositive = (n: number) => n > 0
numbers.filter(isEven)
numbers.some(isPositive)특정 심볼을 판별하는 함수로 발전시켜 본다면 다음과 같다.
type CustomAssetSymbolPredicate = (sb: string) => boolean
const isCustomETH: CustomAssetSymbolPredicate = sb => sb === 'ETH'
const isCustomSOL: CustomAssetSymbolPredicate = sb => sb === 'SOL'
const isCustomXRP: CustomAssetSymbolPredicate = sb => sb === 'XRP'각 조건을 이름으로 갖는 함수로 분리하면 비교식이 곧 도메인 의미를 가진다.
그리고 다음처럼 합성하는 함수를 만들 수 있다.
const isCustomAssetSymbols = (symbol: string) => {
return (...predicates: CustomAssetSymbolPredicate[]) => {
return predicates.some(predicate => predicate(symbol))
}
}그렇다면 이렇게 사용하게 될 것이다.
// example.com/assets/{symbol}
<template>
<TvlCard
v-if="isCustomAssetSymbols(symbol)(isCustomETH, isCustomSOL)"
/>
<KeyPointCarousel
v-if="isCustomAssetSymbols(symbol)(isCustomETH, isCustomXRP)"
/>
</template>같은 symbol을 기준으로 하되, 컴포넌트마다 다른 Predicate 묶음을 주입할 수 있게 된 모양이다. 이렇게 하면 조건 합성을 완성하는 파라미터에서 Predicate 함수를 넣고, 빼는 것만으로도 탄력적으로 UI에 커스텀 콘텐츠를 반영하는 걸 쉽게 만들어 줄 수 있다.
한계와 결과
사실 i18n은 번역 시스템이지, CMS로 사용하라고 나온 건 아니다. 어쩌면 ‘번역 키 테이블’이라는 책임에서도 흐려진다. 그러나 백오피스가 있었어도 별도의 CMS는 없어서 CMS 역할을 해줄 구조가 필요했는데, 그래도 i18n + Google Sheets + S3/CF를 이용하는 것이 개발/운영 비용 대비 가장 현실적인 선택으로 판단하고 결정했다. 이후에는 일부가 백오피스에 CMS 기능으로 개발되었다.
지금 생각해보면 JS 내장 자료구조인 Object나 Map을 이용하는 방법도 있었을 것 같다. 간단히 보자면 다음 같은 느낌이다.
const customSymbolGroups = {
tvlCard: ['ETH', 'SOL'],
keyPointCarousel: ['ETH', 'XRP'],
}
customSymbolGroups.keyPointCarousel.includes(symbol)장점은 어떤 심볼이, 어떤 컴포넌트에 연결되는지 관계가 직관적이고, 복잡도도 상대적으로 이 편이 낮아보인다. predicate 함수도 심볼마다 만들 필요가 없다.
그러나 당시 이 옵션은 내부에서 기획적으로 확정되어 개시했던 프로젝트가 아니었고, 실험적이고 조심스러웠다. 그래서 복잡한 조건에 대한 확장성을 고려하되, 추가와 제거가 용이한 구조가 필요하다고 판단했다.
그래서 최종적으로 Predicate 함수 조합 기반의 구조를 선택했다. 또한, i18n의 키-값 매핑과 S3/CF 캐싱 구조를 이용해, 주요 프로젝트를 병행하면서도 평균 1주 내에 출시하며, 콘텐츠 변경의 배포 시간은 1분 이내로 적용 가능한 구조를 만들 수 있었다. 심지어 이후 운영에서는 동료와 같이 담당했었는데, 코드 리뷰를 하며 커밋에서 코드 수정 사항을 볼 때 변경이 직관적이었기 때문에 이 부분도 생각하지 못한 장점으로 작용했던 기억이 있다.
다만 지금 생각해 봤을 때, 별도의 장애는 없었지만, 런타임 레벨에서의 잘못된 정적 리소스 URL에 대한 가드, 타입 좁히기와 유도같은 부분에서 더 신경썼다면 더 안정적으로 운영할 수 있었으리라 싶은 아쉬움도 남는다.