개발 slecs

발행-번역 cron 분리, SSR 버그, 소셜 프루프까지

목차

저녁 6시쯤 자리 잡고 앉았는데 결국 자정이 넘도록 키보드를 놨다. 이날 건드린 게 세 개 레포에 걸쳐 있었고, 서로 맥락이 달랐지만 결국 하나의 큰 흐름이었다. "배포 파이프라인 안정화" + "랜딩 전환율 끌어올리기". 둘 다 미뤄두면 안 되는 작업이었고, 마침 이날 시간이 생겼다.


cron 분리: 429 한 방에 터지던 구조를 바꾸다

제일 먼저 손댄 게 봇 cron 구조였다. 원래는 06:00에 발행(publish)이 돌고 나서 바로 번역(translate)까지 연달아 실행되는 구조였는데, 이게 운영 환경에서 아주 불쾌한 패턴을 만들어내고 있었다.

발행 직후에 번역 API 호출이 한꺼번에 몰리면 외부 번역 서비스 쪽에서 429가 쏟아진다. 특히 콘텐츠가 배치로 여러 개 쌓여 있을 때 더 심각했다. 재시도 로직이 있긴 했는데, 재시도가 또 429를 부르는 악순환. 결국 번역이 절반만 되고 끊기거나, 세션이 겹쳐서 이전 실행이 끝나기도 전에 다음 cron이 켜지는 상황도 생겼다.

해결 방향은 단순했다. 발행과 번역을 분리한다. 발행은 06:00 그대로 두고, 번역은 09:00으로 늦춘다. 그리고 translate-deploy.sh를 완전히 별도 스크립트로 떼어냈다.

# kfoodlab-bot.cron (변경 후)
0 6 * * * /opt/kfoodlab-bot/bot/daily.sh publish >> /var/log/kfoodlab-publish.log 2>&1
0 9 * * * /opt/kfoodlab-bot/bot/translate-deploy.sh >> /var/log/kfoodlab-translate.log 2>&1

그리고 daily.sh에 멀티세션 가드를 추가했다. lockfile 방식으로, 이미 실행 중인 프로세스가 있으면 새 cron이 켜지자마자 종료되도록. 이게 없으면 서버 부하 피크 때 cron이 겹쳐 돌아 중복 발행이 생길 수 있다.

3시간 간격을 둔 이유가 하나 더 있다. 번역 서비스 쪽 rate limit이 시간당 호출 수 기준이라, 발행 직후 3시간이면 쿼터가 거의 리셋되는 타이밍이다. 이론상 429가 안 나야 한다. 실측은 다음날 이후가 되어야 알겠지만, 구조 자체는 맞다.


trailing-slash canonical + hreflang: GSC 리디렉션 페이지 해소

봇 쪽 작업을 마치고 나서 GSC(Google Search Console) 쪽 이슈로 넘어갔다. Astro 기반 사이트에서 "리디렉션 페이지" 상태로 묶인 URL들이 꽤 있었는데, 원인을 파보니 canonical이랑 hreflang에 trailing slash 불일치가 있었다.

예를 들어, 실제 서빙 URL은 /ko/recipe/abc/(trailing slash 있음)인데 canonical 태그에는 /ko/recipe/abc(없음)가 박혀 있던 것. 구글이 이걸 보고 "이 페이지는 리디렉션 대상" 으로 판정해버린다. hreflang 도 마찬가지였다. 언어별 alternate URL이 trailing slash 없이 들어가 있으니 크롤러 입장에서는 혼란스럽다.

astro.config.mjs에서 trailingSlash: 'always'를 확인하고, src/i18n/config.ts에서 URL 조합 함수가 slash를 붙이는지 재점검했다. 생각보다 조용한 버그였다. 함수 자체는 맞는데 일부 경로에서 slice/join 조합이 double slash가 되거나, 반대로 slash를 빼먹는 케이스가 있었다.

문제 유형 변경 전 변경 후
canonical /ko/recipe/abc /ko/recipe/abc/
hreflang alternate /en/recipe/abc /en/recipe/abc/
서빙 URL /ko/recipe/abc/ (리디렉션) 정합

이 작업을 마무리하면서 동시에 daily.sh의 auto-publish cron도 활성화했다. 사이트가 이제 일정 주기로 새 컨텐츠를 게시하게 되니, GSC 크롤 주기도 자연스럽게 따라붙을 거라는 기대다.


소셜 프루프 레이어와 컨버전 콘텐츠: 랜딩 전환율을 올리는 심리 설계

저녁 중반부터는 완전히 다른 프로젝트, Next.js 기반 랜딩 페이지로 넘어갔다. 여기서 오늘 작업량의 절반 이상이 나왔다.

이 사이트는 B2C 제품을 파는 랜딩 페이지였는데, 그동안 페이지 자체는 완성됐지만 구매 심리를 자극하는 레이어가 아예 없었다. 구매 버튼 바로 위에 가격만 덩그러니 있고 끝. 전환율이 낮을 수밖에 없는 구조.

그래서 이날 두 가지를 붙였다.

첫 번째는 소셜 프루프 레이어다. src/lib/socialProof.ts를 새로 만들어서, 구매 심리 메시지 생성 로직을 여기 집중시켰다. "최근 N명이 구매했습니다", "지금 M명이 보고 있어요" 같은 활동 지표를 시뮬레이션하는 방식인데, 완전 랜덤이 아니라 시간대와 launch epoch 기준으로 결정론적으로 계산한다. 새벽에 "지금 43명 접속 중"이 뜨면 오히려 역효과니까.

// socialProof.ts 핵심 로직 (단순화)
const LAUNCH_EPOCH = new Date('2026-06-13T00:00:00Z').getTime()

function getDaysSinceLaunch(): number {
  return Math.floor((Date.now() - LAUNCH_EPOCH) / 86_400_000)
}

function getSyntheticPurchaseCount(): number {
  const days = getDaysSinceLaunch()
  // 일별 누적 곡선 - 초기는 낮고, 시간이 지날수록 완만하게 증가
  return Math.floor(BASE + days * DAILY_GROWTH_RATE)
}

여기서 한 번 삽질이 있었다. 처음에 launch epoch을 2025-06-13으로 잘못 박아뒀다. 1년 전 기준이니까 "days since launch"가 400일 넘게 계산되면서 구매 카운트가 비현실적으로 부풀어 올랐다. 짧은 커밋 하나로 2026-06-13으로 수정했고, 숫자가 제자리를 찾았다. 사소한 실수지만 런칭 날짜 하나가 전체 숫자 스케일을 바꾸는 거라 눈에 잘 안 띄는 버그다.

두 번째는 컨버전 콘텐츠다. SellingPoints 컴포넌트를 만들어서 "왜 이걸 사야 하는가"에 대한 답을 구체적으로 풀어냈다. 단순 피처 나열이 아니라 상황(Moments) 중심으로, 그리고 FAQ 형식으로. 이게 중요한 게 사람들은 "기능"보다 "내 상황에 맞는가"를 더 먼저 묻기 때문이다.

감성 메시지(LiveActivity 피드에 뜨는 짧은 문장들)도 이날 대거 추가했다. 구매 완료 메시지, 사용 후기 스타일 메시지, "방금 주문했어요" 류의 실시간 느낌 메시지들. 이것도 socialProof.ts 에 배열로 관리하고, 시간 기반 시드로 순환시킨다.


SSR hydration 불일치: React #418을 잡아내다

소셜 프루프 붙이고 로컬에서 확인하는데 콘솔에 React #418 hydration mismatch 경고가 떴다. LiveTicker 컴포넌트에서 서수(ordinal, 1st/2nd/3rd...)를 표시하는 로직이 문제였다.

SSR 단계에서 서버가 렌더링한 숫자 형식과, 브라우저에서 hydration될 때의 형식이 달랐다. 구체적으로는 Intl.PluralRules를 사용한 ordinal 변환이 Node.js 환경과 브라우저 환경 사이에서 미묘하게 결과가 다른 케이스가 있었다.

해결은 ordinal 생성 로직을 클라이언트 사이드 전용으로 격리하는 것. SSR 때는 그냥 숫자만 렌더링하고, hydration 이후 클라이언트에서 ordinal suffix를 붙이도록 수정했다. useEffect 안에서 상태로 관리하는 방식.

const [ordinal, setOrdinal] = useState<string>('')

useEffect(() => {
  // 클라이언트에서만 계산
  setOrdinal(getOrdinalSuffix(rank))
}, [rank])

return <span>{rank}{ordinal}</span>

서버가 <span>3</span>을 보내고, 클라이언트가 hydration 후 <span>3rd</span>으로 업데이트하는 식. mismatch 경고 사라짐. 이런 류의 SSR 이슈는 로케일-의존 포맷팅에서 자주 튀어나오는데, 처음엔 어디서 나오는지 잘 안 보여서 디버그 시간이 좀 걸렸다.


자정 넘어서 마지막 커밋을 올렸을 때 체크리스트가 꽤 깨끗하게 지워진 기분이었다. cron 구조 바꾼 게 내일 09:00에 실제로 돌아서 429 없이 지나가는지가 제일 관건. 랜딩 쪽은 배포하고 지표 봐야 뭔가 말할 수 있다. 지금은 그냥 구조적으로 맞다는 확신 정도. 실측이 단일 진실이니까, 며칠치 로그 쌓이면 다시 볼 것.


🛒 이 글과 어울리는 추천 상품

*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

댓글 0

첫 댓글 달아줘.