글 검색

제목으로 글을 찾습니다

프로모션 페이지, 코드에서 데이터로

2025-09-16#nextjs#server-driven-ui#react

매번 2~3주 걸리던 프로모션 페이지 개발을, 어드민 설정을 JSON으로 내려받아 조합 렌더링하는 구조로 바꿔 런칭당 개발 공수를 0으로 만든 기록.

숙박 예약 플랫폼에서 프로모션은 월 1~3건씩 나간다. 그동안은 건마다 새 페이지를 개발했다. 기획서가 나오고, 디자인이 나오고, 개발하고, QA하고 배포하면 2~3주가 지나 있었다. 클라이언트 개발자 한 명이 사실상 프로모션을 전담하는 구조였고, 그게 나였다. 몇 건을 반복하고 나서 확신이 생겼다 — 이건 개발할 일이 아니라 시스템으로 흡수할 일이다.

하드코딩 시절의 코드#

전환 전의 프로모션 페이지가 어땠는지부터. 레포에는 이벤트 페이지 파일이 15개, 약 7,000줄 쌓여 있었다. 한 페이지를 열어보면 이런 코드가 나온다.

// 지점별 최저가가 소스에 상수로 박혀 있다
const lowestPrice: Record<string, number> = {
  '5': 108000,
  '19': 117000,
  '21': 99000,
  // ...지점 수만큼 계속
};
 
// 쿠폰 ID는 환경 분기로
const couponIds =
  process.env.APP_ENV === 'production' ? [24, 25, 26, 27] : [1];
 
// 종료일도 코드에
const isDisabled = dayjs().isAfter('2025-02-01 00:00:00');

이미지도 레포의 public/ 폴더에 커밋했다. 그러니 버튼 문구 하나, 가격 하나를 바꿔도 배포가 필요했다. 프로모션이 끝나면 이 수백 줄은 통째로 죽은 코드가 된다. 다음 프로모션은 이 파일을 복사해서 다시 시작한다.

페이지를 만들지 않는 구조#

방향은 하나였다. 페이지를 만드는 게 아니라, 페이지를 조립하는 시스템을 만든다. 흔히 server-driven UI라고 부르는 구조다.

  1. 어드민에서 프로모션 구성(이미지, 탭, 버튼, 객실 목록…)을 폼으로 설정하면, 구성값이 JSON으로 직렬화되어 저장된다.
  2. 서버는 그 JSON을 해석하지 않고 그대로 보관했다가 GET /v1/promotion/{slug} 한 번으로 내려준다.
  3. 클라이언트/event/[slug] 단일 라우트에서 JSON 배열을 순회하며 요소 타입별 컴포넌트를 조합해 렌더링한다.

데이터 구조와 클라이언트 렌더링은 내가 설계·구현하고, 서버는 백엔드 개발자와 짝 프로그래밍으로 진행했다. 어드민 폼도 직접 만들었다.

데이터 구조 — 섹션 배열과 닫힌 요소 타입#

페이지는 위에서 아래로 쌓이는 섹션의 배열이다. 각 섹션은 contentType 하나를 갖고, 타입은 확정된 목록으로 닫혀 있다.

type ContentType =
  | 'IMAGE'      // 통이미지 섹션
  | 'TAB'        // 상단 고정 탭 (스크롤 이동)
  | 'BUTTON'     // 링크 / 리워드 발급 / 하단 고정 CTA
  | 'LISTING'    // 이미지 배너 그리드
  | 'FOLDING'    // 접기/펼치기 (유의사항)
  | 'BRANCHES'   // 지점 모아보기
  | 'ROOMTYPES'; // 객실 모아보기 (실시간 가격)

어드민이 저장하는 JSON은 이런 모양이다.

[
  { "contentType": "IMAGE", "src": "hero_summer" },
  {
    "contentType": "TAB",
    "activeColor": "#86cecb",
    "activeTextColor": "#ffffff",
    "deactiveColor": "#000000",
    "deactiveTextColor": "#ffffff",
    "tabs": [
      { "title": "혜택 1", "handleId": "benefit1" },
      { "title": "객실 보기", "handleId": "rooms" }
    ]
  },
  { "contentType": "IMAGE", "src": "benefit1_img", "handleId": "benefit1" },
  {
    "contentType": "BUTTON",
    "buttonType": "REWARD",
    "title": "쿠폰팩 받기",
    "src": "btn_coupon",
    "rewardIds": [12]
  },
  {
    "contentType": "ROOMTYPES",
    "roomtypeIds": [201, 202, 305],
    "tabType": "REGION",
    "maxPrice": 150000
  },
  { "contentType": "FOLDING", "title": "유의 사항", "src": "notice_img" },
  {
    "contentType": "BUTTON",
    "buttonType": "FLOATING",
    "title": "지금 예약하기",
    "src": "btn_cta",
    "backgroundColor": "#000000",
    "url": "/search?promo=summer"
  }
]

섹션 순서 = 배열 순서다. 순서를 바꾸고 싶으면 어드민에서 카드를 드래그하면 되고, 배포는 없다.

handleId는 섹션 간의 연결 키다. 탭의 handleId와 같은 값을 가진 이미지 섹션이 스크롤 이동 목적지가 되고, 하단 고정 CTA는 자기 handleId와 같은 이미지 섹션을 지나야 나타난다. "탭을 누르면 어디로 가는가", "CTA가 언제 뜨는가"까지 데이터로 정한다.

클라이언트 — 배열을 순회하며 조합한다#

프로모션 페이지 렌더링 결과 — 요소 조합 화면과 객실 모아보기
화면

렌더링의 뼈대는 매핑 루프 하나다. 실제 코드를 단순화하면 이렇다.

const contents = JSON.parse(event.contents) as Content[];
 
{contents.map((content, index) => {
  switch (content.contentType) {
    case 'IMAGE':
      return <PromotionImage key={index} {...content} />;
    case 'TAB':
      return <PromotionTab key={index} {...content} />;
    case 'BUTTON':
      return <PromotionButton key={index} {...content} />;
    case 'LISTING':
      return <PromotionListing key={index} {...content} />;
    case 'FOLDING':
      return <PromotionFolding key={index} {...content} />;
    case 'ROOMTYPES':
      return <PromotionRoomtypes key={index} {...content} />;
    // ...
    default:
      return null; // 모르는 타입은 그리지 않는다
  }
})}

버전 1은 이미지·탭·버튼 3종으로 시작했다. 프로모션 페이지의 대부분은 디자이너가 만든 통이미지라서, 이미지 섹션과 이미지 버튼만 있어도 웬만한 페이지가 나온다. 이후 요구가 생길 때마다 리워드 버튼, 배너 그리드, 유의사항 아코디언, 객실 모아보기를 타입으로 추가했다. 새 프로모션 요구가 "새 페이지"가 아니라 "새 요소 타입"으로 들어오게 된 것이 이 구조의 핵심 변화다. 타입이 한번 추가되면 다음 프로모션부터는 그냥 조합하면 된다.

객실 모아보기(ROOMTYPES)만 조금 무겁다. 날짜·인원을 고르는 피커와 지역/지점 탭, 실시간 가격이 붙은 객실 카드 그리드가 들어간다. 가격은 하드코딩 시절처럼 상수가 아니라 검색 API를 그대로 쓴다 — 프로모션 페이지가 곧 검색 결과 페이지의 부분 집합이 되도록, 서버사이드에서 프리페치하고 날짜가 바뀌면 다시 조회한다.

서버 — 정규화 대신 통짜 JSON#

서버 구현은 백엔드 개발자와 짝으로 진행했는데, 여기서 설계 결정이 하나 있었다. 처음에는 섹션을 정규화된 테이블(섹션 행 + 속성 JSON 컬럼)로 저장했다. 운영해보니 서버가 페이지 구조를 알 필요가 전혀 없었다. 섹션을 행으로 쪼개 봐야 조회할 때 다시 조립해서 내려줄 뿐이고, 요소 타입이 추가될 때마다 서버도 같이 수정해야 했다.

그래서 정규화 테이블을 걷어내고 contents 문자열 컬럼 하나로 바꿨다. 서버는 JSON을 파싱하지 않고 저장·반환만 한다 (pass-through). 스키마의 주인은 어드민 폼과 클라이언트 렌더러 둘이고, 요소 타입이 늘어나도 서버는 배포할 게 없다. 대신 클라이언트는 모르는 타입을 만나면 그리지 않고 건너뛰어, 앱 구버전 호환을 지킨다.

어드민 — 폼이 곧 스키마#

프로모션 어드민 에디터 — 기본 정보, 메타 데이터, 컨텐츠 카드
리스트

어드민 에디터는 기본 정보(이름, URL, 노출 기간, 상태)와 메타 데이터(SEO), 그리고 컨텐츠 카드 리스트로 구성했다. 카드 하나가 섹션 하나다. 카드를 추가하고 요소 타입을 고르면 타입에 맞는 폼이 펼쳐지고, 드래그로 순서를 바꾼다. 색상은 컬러 피커로, 이미지는 업로드하면 CDN 키가 JSON에 들어간다.

리워드 버튼처럼 상태가 여러 갈래인 요소는 문구까지 폼으로 열어뒀다. 비로그인 상태에서 눌렀을 때, 이미 발급받았을 때, 발급 성공했을 때의 모달 제목·내용을 각각 설정할 수 있다. 이걸 열어두지 않으면 결국 "문구만 바꿔달라"는 요청이 개발로 돌아온다.

디자인 시스템 — 자유도 대신 확정된 목록#

이런 빌더를 만들 때 가장 흔한 함정이 자유도다. 위치·크기·색을 전부 열어주면 마케터가 쓰기엔 어렵고, 결과물의 UI 일관성은 무너진다. 반대로 너무 닫으면 결국 개발 요청이 돌아온다.

그래서 디자이너와 함께 프로모션 전용 컴포넌트를 먼저 확정했다 — CTA 버튼, 상단 고정 탭, 날짜·인원 Range Picker, 아코디언, 객실 카드. 컴포넌트의 레이아웃과 여백, 타이포는 디자인 시스템 쪽에 고정하고, 프로모션마다 달라져야 하는 것만 JSON 필드로 열었다. 열어둔 것은 사실상 색(탭 4종 색상, CTA 바 배경색)과 이미지, 문구뿐이다.

기능 범위도 같은 방식으로 정했다. 마케터·기획자·디자이너와 협의해 "프로모션 페이지에 들어갈 수 있는 것"을 확정된 목록으로 고정했다. 목록에 없는 요구가 나오면 그때 요소 타입을 추가한다. 자유도를 주는 대신 어휘를 늘리는 방식이라, 개발 개입 없이도 UI 일관성이 유지된다.

결과#

  • 프로모션 런칭당 클라이언트 개발 공수 0 — 어드민 설정만으로 페이지가 나간다. 건당 약 3 man day 절감이고, 월 1~3건이니 매달 그만큼이 반복 절감된다.
  • 기획→디자인→개발 2~3주 걸리던 리드타임에서 개발 구간이 사라졌다. 디자인 시안이 나오면 당일 어드민에서 조립해 확인한다.
  • 문구·이미지·기간 수정에 배포가 필요 없다.
  • 이후의 프로모션 요구는 페이지가 아니라 요소 타입 단위로 쌓인다. 리워드 버튼, 배너 그리드, 객실 모아보기가 그렇게 추가됐고, 한 번 추가된 타입은 다음 프로모션에서 재사용된다.

프로모션 전담이던 시간이 사라지면서, 그 시간은 플랫폼 코어 개발로 돌아왔다. 반복 업무는 더 빨리 처리하는 게 아니라 구조로 없애는 것이라는 걸 체감한 프로젝트였다.