이벤트 페이지를 AI로 만들며 무엇을 모델에 맡길지 정하기

커버 이미지

이전 글(이벤트 페이지 제작 공수를 줄이기 위한 드래그&드롭 빌더 개발기)에서 이벤트 페이지를 코드 없이 만드는 빌더를 언급했습니다. 어드민에서 요소를 드래그로 배치하면 결과가 JSON으로 저장되고, 사용자 사이트의 전용 렌더러가 그 JSON을 그리는 구조입니다.

빌더를 도입하기 전에는 이벤트 페이지 하나에 약 1.5일이 들었습니다. 디자이너 작업물을 기다리는 데 하루, 받은 이미지로 HTML을 짜는 데 반나절이었습니다. 빌더는 뒤쪽 반나절을 없앴습니다.

그러나 운영에 도입해보니 앞쪽 하루는 그대로였습니다. 페이지를 채우는 배경과 문구가 전부 이미지라서, 운영자가 빌더를 열기 전에 디자이너의 작업물이 먼저 도착해야 했습니다. 문구 한 줄을 고치는 데도 이미지 재제작을 요청해야 했습니다. 개발 의존은 끊었지만 디자인 의존은 그대로 남아 있었던 것입니다.

이 글은 그 의존을 없애기 위해 배경과 문구를 AI로 생성하면서 내린 판단들을 정리한 것입니다.

텍스트를 이미지에서 분리한 이유

가장 먼저 한 일은 AI를 붙이는 것이 아니라 텍스트를 이미지에서 꺼내는 것이었습니다. content-section(이벤트 내용)과 notice(유의사항)라는 요소 타입을 추가해, 섹션 제목·본문·조건 목록·유의사항을 이미지가 아니라 HTML로 렌더링하게 바꿨습니다.

이미지 생성 모델은 한글을 정확히 그리지 못하고, 그 실패율은 글자 수에 비례해 올라갑니다. 한두 줄짜리 제목은 잘못 그려져도 한눈에 검수되지만, 수백 자짜리 유의사항을 그림으로 그리면 매번 이미지 속 글자를 원문과 한 글자씩 대조해야 합니다. 그러면 없애려던 수작업이 제작에서 교정으로 자리만 옮깁니다.

그래서 짧고 장식적인 글자는 모델이 그림으로, 길고 자주 고치는 글자는 렌더러가 텍스트로 그리기로 선을 그었습니다. 이후의 판단들은 대체로 이 선을 따라갔습니다.

AI와 무관하게도 이득이 있었습니다. 문구 수정이 필드 편집으로 끝나고, 스크린 리더가 읽을 수 있는 텍스트가 생기고, 배경 이미지 용량이 줄었습니다.

스키마를 확장하면서 가장 신경 쓴 것은 이미 저장된 이벤트였습니다. 새 필드는 전부 선택 필드로 두고 값이 없으면 기본값으로 그리게 해서, 구 버전 레이아웃이 수정 없이 그대로 렌더링되도록 했습니다.

배경 생성: 모델의 비율 제약 우회

이벤트 상세 페이지의 배경은 1080×4500 정도의 긴 세로 이미지 한 장입니다. 이미지 생성 모델은 이런 비율을 지원하지 않고, 억지로 요청하면 구도가 무너집니다.

그래서 상단(히어로)과 하단(푸터)을 모델이 잘 그리는 비율로 따로 생성하고, 그 사이를 서버가 직접 채우는 방식으로 바꿨습니다. 중단은 상단 조각의 맨 아랫줄과 하단 조각의 맨 윗줄을 컬럼별로 선형 보간해서 픽셀을 만듭니다. 평균색 하나로 채우지 않고 가로 위치별로 색을 잇기 때문에 조각의 좌우 색 변화가 중단까지 이어집니다.

상단 조각과 하단 조각을 모델이 생성하고, 서버가 그 사이 중단을 컬럼별 선형 보간으로 채워 이어 붙이는 배경 합성 구조 다이어그램

지시문 대신 후처리로 보정

각 조각의 지시문(서버가 모델에 함께 보내는 생성 규칙)에는 맞닿는 쪽 40%를 단색으로 마감하라는 규칙을 넣었습니다. 문제는 모델이 이 규칙을 자주 무시하고 그림을 끝까지 그려 넣는다는 것이었습니다. 그러면 조각과 중단이 만나는 지점에 선명한 가로선이 생깁니다.

지시문을 더 다듬는 대신 결과를 후처리로 보정하기로 했습니다. 각 조각의 맞닿는 끝을 그 조각의 끝줄 색으로 서서히 덮어서, 모델이 하드 라인을 그렸더라도 그 위쪽부터 배경에 녹아들게 했습니다.

같은 판단을 이후에도 여러 번 반복했습니다. 지시문은 확률적으로 지켜지고 후처리는 항상 실행됩니다. 모델의 출력을 통제하려 애쓰는 것보다, 어떤 출력이 와도 받아낼 수 있게 뒤를 받치는 편이 안정적이었습니다.

자동으로 못 고치는 것은 수치로 알리기

후처리로도 못 고치는 경우가 있습니다. 상단과 하단의 톤 자체가 다르면 중단이 어색한 색 전환 띠가 되는데, 이건 자동으로 해결할 수 없습니다. 대신 맞닿는 두 가장자리의 색 거리를 계산해, 임계값을 넘으면 후보 카드에 "이음새 주의"를, 넘지 않으면 "이음새 양호"를 표시했습니다. 운영자가 경고가 붙은 후보를 버리고 재생성하도록 유도하는 쪽을 택한 것입니다.

텍스트 스타일: 선택은 모델, 검증은 코드

배경이 정해지면 그 위에 얹을 텍스트 색을 정해야 합니다. 이 문제는 성격이 다른 두 질문으로 나뉩니다. 어떤 색이 이 배경에 어울리는지는 취향이라 코드로 풀기 어렵고, 그 색이 배경 위에서 읽히는지는 계산으로 답이 나옵니다.

앞쪽은 모델에 맡겼습니다. 생성된 배경을 280px로 줄여 보여주고 본문색·강조색·카드색·폰트 프리셋을 JSON으로 답하게 합니다. 이미지를 보고 고르는 판단은 모델이 안정적으로 잘하고, 출력이 색상값이라 한글 오탈자 같은 실패가 생길 여지가 없습니다.

뒤쪽은 코드가 맡습니다. 추천받은 색과 배경색의 WCAG 대비율(글자가 배경 위에서 읽히는지 판단하는 접근성 기준 수치)을 계산하고, 기준에 못 미치면 검정이나 흰색 쪽으로 조금씩 섞어 가며 기준을 넘길 때까지 보정합니다. 보정 방향은 배경 휘도의 반대쪽으로 잡아서, 밝은 배경이면 어둡게 가는 식으로 원래 톤을 최대한 남깁니다.

function ensureContrast(color, backgrounds, minRatio) {
  const meets = (c) =>
    backgrounds.every((bg) => contrastRatio(c, bg) >= minRatio);
  if (meets(color)) return { color, adjusted: false };
 
  const avgLuminance =
    backgrounds.reduce((sum, bg) => sum + relativeLuminance(bg), 0) /
    backgrounds.length;
  const target =
    avgLuminance > 0.5 ? { r: 0, g: 0, b: 0 } : { r: 255, g: 255, b: 255 };
 
  for (let t = 0.1; t <= 1; t += 0.1) {
    const candidate = mixColor(color, target, t);
    if (meets(candidate)) return { color: candidate, adjusted: true };
  }
  return { color: target, adjusted: true };
}

모델 호출이 실패하거나 응답이 JSON이 아니어도, 배경 색조에서 색을 유도하는 기본값이 같은 보정을 거쳐 나옵니다. 어느 경로로 들어와도 읽히지 않는 색은 저장되지 않습니다.

버튼과 장식 이미지: 배경에 맞춰 만들기

배경 위에는 참여 버튼과 장식 이미지가 얹힙니다. 이것도 매번 디자이너에게 요청하던 것이라 생성 대상에 넣었는데, 배경과는 다른 문제가 두 가지 있었습니다.

첫째는 톤을 맞추는 문제입니다. 페이지마다 배경 분위기가 다른데 버튼만 따로 만들면 얹었을 때 겉돕니다. 그래서 버튼과 장식 이미지를 생성할 때는 서버가 현재 배경 이미지를 가져와 참조로 함께 보냅니다. 512px로 줄여 첨부하고, 지시문에는 참조의 스타일·팔레트·마감 수준을 따르라는 규칙을 넣었습니다. 운영자는 "황금 복주머니" 정도만 쓰면 되고, 페이지 톤에 맞추는 일은 알아서 처리됩니다.

버튼 문구는 모델이 이미지에 직접 그립니다. 앞에서 그은 선(짧고 장식적인 글자는 모델이 그림으로)을 그대로 적용한 것입니다. 한 줄짜리 문구라 잘못 그려져도 후보 목록에서 바로 걸러집니다.

둘째는 투명 배경 문제입니다. 버튼과 장식 이미지는 배경 위에 얹히니 주변이 투명해야 하는데, 이미지 생성 모델은 투명(알파 채널)을 만들지 못합니다.

그래서 모델에게는 대상 바깥을 완전한 단색으로 채우라고 지시하고 그 색을 서버가 지웁니다. 지우는 방식으로는 flood fill을 썼습니다. 그림판의 페인트통 도구와 같은 방식으로, 한 지점에서 출발해 비슷한 색으로 이어진 이웃 픽셀을 따라가며 번져 나가다가 색이 다른 경계를 만나면 멈춥니다. 여기서는 이미지의 네 변에서 출발시켜, 가장자리와 이어진 배경색만 투명하게 바꿉니다.

이렇게 하면 대상 안쪽에 같은 색이 있어도 가장자리와 끊겨 있으니 그대로 남습니다. 비슷한 색을 전부 지우는 방식으로 만들었다면 흰 버튼의 흰 글자가 같이 사라졌을 겁니다.

투명 처리 후에는 남은 영역의 경계에 맞춰 잘라냅니다. 여백이 남으면 캔버스에서 크기를 조절할 때 실제 버튼이 의도보다 작게 보이기 때문입니다. 전체가 배경으로 판정되면 원본을 그대로 돌려줘서, 제거에 실패한 후보도 목록에서 사라지지 않게 했습니다.

모델이 만든 단색 배경 위의 버튼과 배경을 제거해 잘라낸 결과 비교

초안 자동 생성: 좌표를 AI에 맡기지 않기

여기까지 오면 배경과 텍스트 스타일, 버튼과 장식 이미지를 모두 AI로 만들 수 있습니다. 그런데도 운영자는 여전히 빈 캔버스에서 요소를 하나씩 추가해야 합니다. 마지막으로 남은 일은 프롬프트 한 줄에서 채워진 초안이 열리게 만드는 것이었습니다.

가장 단순한 방법은 LLM에게 레이아웃 JSON 전체를 만들게 하는 것입니다. 이 방법은 택하지 않았습니다. 좌표와 크기는 조금만 틀려도 페이지가 무너지는데 LLM은 그 조금 틀림을 확률적으로 만들어 냅니다. 반면 문구 작성은 LLM이 잘하고, 틀려도 운영자가 빌더에서 바로 고칩니다.

그래서 LLM에게는 텍스트만 맡겼습니다. 운영자가 이벤트를 설명하는 프롬프트를 넣으면 섹션별 제목·본문·조건 목록, 유의사항 항목, 버튼 문구를 정해진 JSON 형태로 뽑아 줍니다. 지시문에서 신경 쓴 것은 지어내지 않게 하는 쪽이었습니다. 프롬프트에 없는 수량·날짜·URL은 자리표시 문구로 두게 하고, 기간을 운영자가 직접 입력했으면 그 값을 그대로 쓰게 했습니다. 반대로 프롬프트에 캐릭터나 상품명이 있으면 웹 검색으로 확인한 뒤 문구를 쓰게 했습니다.

좌표를 계산하는 대신 검증된 레이아웃 재사용

좌표는 처음에 계산할 생각이었습니다. 조건 목록이 다섯 줄인 섹션은 두 줄짜리보다 키가 크니 다음 섹션이 아래로 밀리는데, 그 높이를 추정해서 y 좌표로 환산하는 방식입니다.

이 계획은 실제로 만들어 본 이벤트 세 건의 저장된 레이아웃을 겹쳐 보고 나서 접었습니다. 배치 값이 거의 고정이었기 때문입니다. 배경은 항상 x 0%, y 0%, 폭 100%, 콘텐츠 섹션은 항상 x 50%, 폭 85%, 버튼은 항상 x 50%, 폭 200px이었습니다.

추정 로직을 만드는 대신 운영자가 실제로 만들어 검증한 이벤트의 레이아웃을 템플릿으로 뽑아 두고 텍스트 자리만 비웠습니다. 3단 구성과 2단 구성 두 종류를 갤러리로 두고, 운영자가 고른 템플릿의 섹션 슬롯 수를 지시문에 넘겨 정확히 그만큼 생성하게 했습니다.

템플릿 갤러리에서 구성을 고르고 이벤트 설명을 입력하는 초안 생성 화면

export function fillTemplate(template, draft) {
  const elements = [];
  let sectionIndex = 0;
 
  for (const el of template.layout.elements) {
    if (el.type === "content-section") {
      const section = draft.sections[sectionIndex++];
      if (!section) continue; // 채울 섹션이 없는 슬롯은 제거
      elements.push({
        ...el,
        heading: section.heading,
        body: section.body ?? "",
      });
    } else if (el.type === "notice") {
      elements.push({
        ...el,
        title: draft.notice.title,
        items: [...draft.notice.items],
      });
    } else {
      elements.push({ ...el });
    }
  }
  return { builderType: template.layout.builderType, elements };
}

높이 추정 코드가 아예 필요 없어졌고, 초안의 좌표는 이미 실제 페이지에서 검증된 값이 되었습니다. 자동화한다고 해서 전부 계산으로 만들어 낼 필요는 없었습니다.

텍스트를 먼저, 배경은 나중에

초안 한 건에는 모델 호출이 여러 번 들어갑니다. 텍스트 초안은 웹 검색을 거쳐 30초 이상, 배경은 상단·하단·스타일 추천까지 서너 번 호출해 1분 가까이 걸립니다. 두 단계를 모두 끝낸 뒤에 화면을 채우면 그만큼을 그대로 기다려야 합니다.

그래서 텍스트 초안을 먼저 그려 보여주고 배경은 뒤에서 채워지게 했습니다. 텍스트가 오면 섹션과 유의사항이 채워진 화면이 뜨고, 거기에 배경 후보가 하나씩 붙습니다. 배경이 오기까지는 계속 기다려야 하지만, 그동안 화면에는 만들어진 문구가 떠 있어서 무엇이 나오고 있는지 보면서 기다리게 됩니다.

텍스트 초안이 먼저 표시되고 뒤이어 배경 후보가 채워지는 과정

운영자가 결과를 통제하는 방법

배경, 버튼, 장식 이미지, 문구까지 생성 대상이 늘면서 하나의 질문이 따라왔습니다. 모델이 만든 것을 운영자가 어디서 손댈 수 있게 할 것인가입니다. 생성 전과 생성 후 두 지점에 자리를 뒀습니다.

참조 이미지로 생성 방향 잡기

프롬프트만으로 원하는 그림이 나오는 경우는 드뭅니다. "가을 톤 일러스트"라고 써도 호출할 때마다 다른 결과가 나오고, 콜라보 이벤트처럼 특정 캐릭터가 들어가야 하면 문장으로는 지정할 방법이 아예 없습니다.

그래서 배경·버튼·장식 이미지 생성 모두 참조 이미지를 최대 두 장까지 첨부할 수 있게 했습니다. 첨부하면 그 캐릭터나 오브젝트를 그대로 살려 그리고, 두 장을 넣으면 둘을 한 장면에 함께 담습니다.

참조를 붙일 때는 지시문도 함께 바꿉니다. 참조 없이 생성할 때는 상업용 일러스트 느낌과 파스텔 계열 팔레트를 지시하는데, 참조가 있으면 그 지시를 빼고 참조의 화풍·비율·표정을 유지하라는 규칙으로 교체합니다. 스타일을 강제하는 문구가 남아 있으면 모델이 참조 캐릭터를 자기 식으로 다듬어서, 원본과 비슷하지만 미묘하게 다른 그림이 나오기 때문입니다. 머리와 몸의 비율을 바꾸지 말라거나 더 귀엽게 만들지 말라는 규칙까지 넣고 나서야 원본에 가까운 결과가 나왔습니다.

첨부한 참조 캐릭터와 그 캐릭터를 살려 생성한 배경 결과

버튼과 장식 이미지에는 서버가 붙이는 배경 참조와 운영자가 올린 참조가 함께 들어갑니다. 배경이 톤을 잡고 올린 이미지가 대상을 잡는 식입니다.

후보를 만들고 사람이 고르기

생성 결과를 그대로 내보내는 흐름은 만들지 않았습니다. 이벤트 페이지는 대외 노출물이라 잘못 나간 이미지의 비용이 크고, 생성 품질에는 편차가 있습니다.

그래서 배경, 버튼, 초안 모두 후보를 서너 개 만들고 운영자가 고르는 형태로 통일했습니다. 고르는 단계가 검수를 겸하고, 후보가 모두 마음에 들지 않으면 다시 생성하면 됩니다. 앞서 3분할 배경에서 이음새 품질을 양호·주의로 표시한 것도 같은 목적입니다. 자동으로 못 고치는 문제는 운영자가 고르는 시점에 판단 근거를 주는 쪽으로 넘겼습니다.

마음에 안 드는 부분만 다시 만들기

초안 전체를 받아들이거나 처음부터 다시 하거나 둘 중 하나를 고르게 하면, 사소한 불만 하나 때문에 1분 넘는 생성을 다시 돌려야 합니다. 한 번 부르는 데 드는 시간이 있으니 이 비용이 작지 않습니다. 그래서 걸리는 부분만 골라 고칠 수 있게 했습니다.

배경은 초안 화면에서 언제든 다시 생성할 수 있습니다. 처음에는 결과가 비었을 때만 재생성 버튼을 보여줬는데, 실제로 써 보니 "나쁘지 않지만 다른 것도 보고 싶다"는 경우가 훨씬 잦아서 상시 노출로 바꿨습니다. 배경만 바뀌고 문구와 배치는 그대로 남습니다.

버튼이나 장식 이미지는 캔버스에서 그 요소를 선택하면 우측 패널에서 바로 다시 생성됩니다. 이때 프롬프트에 추가 요청을 덧붙이거나 참조 이미지를 바꿔 넣을 수 있어서, 같은 자리의 이미지만 원하는 방향으로 몇 번 굴려 볼 수 있습니다.

요소를 선택하면 우측 패널에서 해당 요소만 다시 생성하거나 값을 고칠 수 있는 빌더 화면

문구는 애초에 텍스트 요소라 필드 편집으로 끝납니다. 색이 마음에 들지 않으면 페이지 스타일을 다시 추천받거나, 특정 요소 하나만 다른 색으로 덮어쓸 수 있습니다.

적용 결과와 효과

캐릭터 참조 이미지를 올리고 프롬프트를 입력하자, 그 캐릭터가 담긴 배경과 문구로 채워진 이벤트 페이지 초안이 완성되는 전체 과정
시점소요없어진 것
빌더 도입 전디자인 대기 1일 + HTML 작성 0.5일
빌더 도입 후디자인 대기 1일 + 조립 30분HTML 손코딩
AI 초안 도입 후초안 약 3분 + 검수·보정디자인 작업물 대기

두 단계에서 없앤 것의 성격이 다릅니다. 빌더는 사람이 반복하던 손코딩을 도구로 대체했고, 줄어든 시간이 그대로 절약된 작업 시간이 됐습니다. AI 초안은 다른 팀의 일정에 묶여 있던 하루를 풀었습니다. 실무에서는 30분이 3분이 된 것보다 이 하루가 사라진 쪽이 훨씬 크게 작용했습니다. 30분은 운영자가 마음먹으면 언제든 쓸 수 있는 시간이지만, 하루의 대기는 운영자가 통제할 수 없는 시간이기 때문입니다.

3분이 곧 완성은 아닙니다. 초안은 출발점이고 그 뒤에 문구를 다듬고 위치를 보정하고 최종 컨펌을 받는 과정이 남습니다. 그 단계는 의도적으로 남겨 둔 것이고, 이번 작업이 없앤 것은 검수가 아니라 검수할 대상이 도착하기까지의 대기입니다.

남은 과제

대기 시간 줄이기

텍스트를 먼저 보여주는 것은 순서를 바꾼 것이지 시간을 줄인 것이 아닙니다. 첫 화면이 뜨기까지의 30초는 여전히 빈 화면이고, 전체 3분 가운데 운영자가 손을 쓰는 시간보다 기다리는 시간이 깁니다.

두 가지 방향을 보고 있습니다. 하나는 텍스트 생성의 웹 검색을 필요할 때만 돌리는 것입니다. 지금은 모든 요청에 검색 도구를 붙이는데, 프롬프트에 낯선 고유명사가 없으면 검색할 이유가 없습니다. 다른 하나는 텍스트와 배경 호출을 처음부터 함께 시작해 두는 것입니다. 배경 생성은 텍스트 결과를 기다릴 필요가 없어서, 지금처럼 순서대로 부를 이유가 없습니다.

출석체크형 지원

지금 초안 생성은 일반형 이벤트까지입니다. 출석체크형은 출석 캘린더, 스탬프, 오늘 날짜와 출석일 숫자까지 요소가 더 많은데, 이 요소들이 어떤 좌표 규칙으로 놓이는지 계속해서 파악 중입니다.

접근 방식은 일반형과 같습니다. 일반형 템플릿도 계산으로 만들어 낸 것이 아니라 실제로 등록한 이벤트 세 건의 레이아웃을 겹쳐 공통 배치 값을 찾은 것이었습니다. 출석체크형도 빌더로 만든 사례가 쌓이면 같은 방식으로 규칙을 뽑아 템플릿으로 만들 수 있습니다. 지금 부족한 것은 방법이 아니라 비교할 표본이라 생각합니다.

마치며

작업하면서 계속 부딪힌 질문은 어디까지를 모델에 맡길 것인가였습니다. 결과적으로 모델에 맡긴 것은 배경 일러스트, 배경에 어울리는 색과 폰트, 프롬프트에서 뽑은 문구였습니다. 정답이 하나가 아니고 결과가 조금 달라도 사람이 보고 고르면 되는 것들입니다.

코드에 남긴 것은 요소의 좌표와 크기, 텍스트 가독성 대비, 조각을 잇는 픽셀 연산이었습니다. 틀리면 페이지가 무너지는, 확률적이어서는 안 되는 것들입니다.

이렇게 나누고 나니 실패해도 무너지지 않았습니다. 모델이 이상한 색을 골라도 대비 보정이 걸러내고, 모델이 응답하지 않아도 기본값이 같은 보정을 거치고, 배경 생성이 실패해도 텍스트 초안은 이미 채워져 있습니다. 모델이 담당하는 것이 결과의 품질이지 동작 여부가 아니게 만드는 것이 이번 작업의 목표였습니다.

한 가지 더 남는 건, 실제로 만들어 보기 전에는 알 수 없는 것이 많았다는 점입니다. 좌표 추정 로직은 이벤트 세 건을 겹쳐 보고 나서야 불필요하다는 걸 알았습니다. 설계 문서를 오래 붙들고 있는 것보다 사례를 하나 만들어 보는 편이 빨랐습니다.