Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/Web Development/EN

Timed Reveal Travel Log Map Promotion Site

여행 동선을 지도에서 따라가는 프로모션 사이트

공개 시각이 되면 스스로 본편으로 바뀌는 프로모션 사이트

사람 없이 열려야 하는 오픈

아이돌 그룹 여섯 명이 5일 동안 다닌 여행을, 팬이 지도 위에서 따라 걷는 프로모션 사이트입니다.

오픈은 두 단계로 나뉘어 있었습니다. 1차 오픈에는 스케줄 표만 보이고, 2차 오픈 시각이 지나면 같은 주소가 본편으로 바뀌어야 했어요. 전환은 배포가 아니라 정해진 시각을 기준으로 이뤄져야 했고, 그전에는 /trips/1 같은 주소를 직접 찍어도 콘텐츠가 나가면 안 됐습니다.

콘텐츠는 어드민에서 직접 관리해야 했습니다. 5일치 사진·영상·오디오·글과 멤버별 동선을 개발자 없이 올리고, 순서를 바꾸고, 잘못 올린 걸 갈아 끼울 수 있어야 했습니다.

시스템 경계와 외부 의존
시스템 경계와 외부 의존

관리형 앱 셋과 CDN 경계

Turborepo 모노레포에 Next.js 앱 둘(web, admin)과 Hono API 하나

web과 admin이 같은 데이터를 다르게 보여주는 구조였습니다. 잠금 판정처럼 틀리면 사고가 나는 로직을 한 군데에만 두고 싶었고, Hono의 AppType을 그대로 hc 클라이언트로 뽑아 두 프론트가 같은 타입을 쓰게 했어요. Next.js Route Handler에 API를 다 넣었다면 admin이 web의 내부 함수를 부르거나 로직이 복제됐을 겁니다. 팀이 작아서 서비스를 더 쪼갤 여력이 없었고, 도메인 자체는 테이블 여섯 개로 끝나는 단순한 크기였습니다.

ECS Fargate + ALB + CloudFront 3장 + Aurora PostgreSQL Serverless v2

트래픽 모양이 아주 뾰족했습니다. 오픈 순간에만 몰리고 평소엔 거의 0이에요. Serverless v2는 평소 비용을 낮게 깔고, ECS는 desired count로 오픈 직전에 손으로 올릴 수 있었습니다. 실제로 prod web은 desired count 4, api·admin은 2로 배포합니다. 오픈 시각이 고정이라 스케일 아웃이 늦으면 되돌릴 수 없었습니다. 오토스케일링이 반응하기를 기다릴 여유가 없어 미리 올려 두는 쪽을 택했어요.

브라우저는 /api/upstream 동일 출처 프록시로, 서버 컴포넌트는 API_INTERNAL_URL로 ALB에 직행

커스텀 도메인을 붙이는 순간 브라우저 CORS와 CloudFront 앞단 정책이 동시에 걸립니다. 브라우저 호출을 전부 같은 오리진의 Route Handler로 흘리면 CORS 자체가 생기지 않아요. 반대로 컨테이너 안에서 도는 서버 컴포넌트는 굳이 CDN을 한 바퀴 돌 이유가 없어서 ALB DNS로 바로 쏘고, X-Forwarded-Service: api 헤더로 타깃 그룹을 골랐습니다. Node.js fetch(undici)가 상대 경로를 못 받아서 서버 쪽은 절대 URL이 강제됐고, 그래서 경로 결정이 두 갈래로 갈렸습니다.

배포와 인프라 구성
배포와 인프라 구성

마이그레이션 뒤에 뜨는 웹

ECS 서비스가 셋(api, web, admin) 있고 앞에 ALB, 그 앞에 각각 CloudFront가 붙습니다. 미디어는 별도 S3 버킷과 별도 CloudFront로 나갑니다. 배포에서 가장 신경 쓴 건 순서였습니다. DB 마이그레이션은 API 컨테이너가 부팅하면서 스스로 돌립니다. 대신 HTTP 서버를 먼저 띄우고 마이그레이션은 뒤에서 돌려요. /health는 항상 200이라 ECS 헬스체크가 통과하고, /ready는 마이그레이션이 끝나야 200이 됩니다. 그동안 /health와 /ready를 뺀 모든 경로는 503으로 막습니다. 배포 워크플로는 ECS 롤아웃이 COMPLETED가 될 때까지 기다린 다음 /ready를 폴링하고, 200을 받고 나서야 web과 admin을 올립니다. 이 순서를 안 지키면 새 스키마를 기대하는 웹이 옛 스키마를 보는 창이 잠깐 생기죠. 이 글에서 깊게 파는 건 지도 렌더링 하나입니다. CloudFront의 RSC 캐시 키 분리와 어드민 인증, 미디어 업로드 흐름은 각각 따로 다룰 가치가 있는 이야기라 여기서는 접습니다.

핵심 데이터 모델
핵심 데이터 모델

지도 라이브러리를 걷어내고 SVG 한 장에 카메라를 붙였다

지도라는 말을 들으면 Leaflet이나 Mapbox부터 꺼내게 됩니다. 손그림 일러스트를 타일로 굽고, 스팟마다 위경도를 붙여 마커를 얹는 방식이죠. 그런데 이 지도는 실제 지리가 아니라 그림입니다. 위경도가 애초에 없고, 억지로 만들어 붙이면 그림이 수정될 때마다 좌표를 다시 따야 합니다. 게다가 핀은 단순 마커가 아니라 멤버 사진 여러 장이 비스듬히 겹쳐 쌓인 카드 덱이었어요. 라이브러리 마커 API 위에 이걸 얹는 순간, 라이브러리와 싸우는 시간이 그리는 시간보다 길어집니다.

지도 라이브러리를 아예 쓰지 않았습니다. 일러스트에서 뽑은 SVG 한 장을 fetch로 받아 innerHTML로 DOM에 심고, 카메라를 {k, cx, cy} 세 숫자로 정의한 다음 그 값을 viewBox 문자열로 바꿔 매 프레임 SVG에 다시 씁니다. 스팟 위치는 SVG 안 [data-spot-id] 요소의 getBBox() 중심을 읽어 viewBox 기준으로 컨테이너 픽셀에 사영하고, 그 자리에 HTML div 핀을 절대 배치합니다. 핀과 실제 스팟을 잇는 점선 리더와 마름모 앵커는 또 다른 오버레이 SVG에 화면 좌표로 직접 그립니다. 휠 줌은 dampZoomTarget이 한계 근처에서 스텝을 줄이고, constrainCamera가 최소 줌으로 돌아올수록 중심을 홈 위치로 섞어 넣습니다.

벡터라 4.7배까지 확대해도 안 깨지고, 타일을 굽는 파이프라인 자체가 사라집니다. 좌표계는 일러스트레이터가 준 viewBox 하나뿐이라 그림이 바뀌어도 data-spot-id만 유지되면 코드는 그대로예요. 핀이 평범한 HTML이라 사진 덱 레이아웃, 눌림 상태, Next.js 라우팅을 전부 일반 DOM으로 다룰 수 있고, 데이 전환은 #DAY${d} 그룹의 display 토글로 끝납니다. 라이브러리를 안 쓴 대가로 관성, 핀치, 경계 감쇠를 전부 직접 썼는데, 그 계산이 전부 우리 손에 있으니 "최소 줌에서는 항상 홈 프레이밍으로 되돌아온다" 같은 요구를 그냥 한 줄로 넣을 수 있었습니다.

오픈은 배포가 아니라 DB 한 줄이다

가장 흔한 방법은 클라이언트에서 unlocksAt을 현재 시각과 비교해 잠금 UI를 씌우는 겁니다.

잠금을 서버로 내렸습니다. launch_phases 테이블에 phase 1과 2 두 행만 두고, 각 행의 unlocks_at과 현재 시각을 비교해 currentPhase를 계산합니다. Next.js 미들웨어가 모든 요청에서 이 값을 확인해, phase가 2 미만이면 /와 /health를 뺀 전부를 /404로 rewrite 하면서 Cache-Control: no-store를 붙이고 /api/*는 그냥 404를 돌려줍니다.

주소를 알아도 못 들어갑니다. 판정이 브라우저 밖에 있으니까요. 그리고 오픈이 배포에서 분리됩니다.

데모 영상

유저가 할 수 있는 일

오픈 전에 들어와 스케줄 표만 보고 나간다
오픈 전에 들어와 스케줄 표만 보고 나간다

오픈 후에도 스케줄 표를 다시 펼쳐 본다
오픈 후에도 스케줄 표를 다시 펼쳐 본다

날짜 포스터를 넘겨 열린 하루를 고른다
날짜 포스터를 넘겨 열린 하루를 고른다

손그림 지도를 확대하고 끌어서 하루를 훑는다
손그림 지도를 확대하고 끌어서 하루를 훑는다

멤버를 골라 그 사람의 동선만 남긴다
멤버를 골라 그 사람의 동선만 남긴다

핀을 눌러 그 스팟의 갤러리를 연다
핀을 눌러 그 스팟의 갤러리를 연다

썸네일을 눌러 사진과 영상을 크게 본다
썸네일을 눌러 사진과 영상을 크게 본다

BGM을 끄고 켜고, 영상을 볼 땐 알아서 비켜 준다
BGM을 끄고 켜고, 영상을 볼 땐 알아서 비켜 준다

운영자가 어드민에 로그인한다
운영자가 어드민에 로그인한다

1차·2차 오픈 시각을 KST로 잡아 저장한다
1차·2차 오픈 시각을 KST로 잡아 저장한다

Day별 제목과 잠금·해제 썸네일을 올린다
Day별 제목과 잠금·해제 썸네일을 올린다

하루치 BGM 파일을 교체하고 들어 본다
하루치 BGM 파일을 교체하고 들어 본다

스팟을 만들고 멤버별 방문 순서를 짠다
스팟을 만들고 멤버별 방문 순서를 짠다

스팟에 사진·영상·오디오·글을 올리고 멤버를 태깅한다
스팟에 사진·영상·오디오·글을 올리고 멤버를 태깅한다

파비콘·OG 이미지·지도 SVG 같은 정적 자산을 갈아 끼운다
파비콘·OG 이미지·지도 SVG 같은 정적 자산을 갈아 끼운다

어드민 계정을 추가하고 지운다
어드민 계정을 추가하고 지운다

1 / 1

IaC 없이 남은 2,300줄 지도

IaC가 한 줄도 없습니다. ECS 태스크 정의를 GitHub Actions 안의 Python 인라인 스크립트가 describe-task-definition 결과에서 필드를 지우고 환경변수를 갈아 끼우는 방식으로 패치해요. 워크플로 파일에 ALB DNS와 S3 버킷 이름이 문자열로 박혀 있고, 클러스터를 다시 세우려면 콘솔에서 손으로 만든 것들을 기억해 내야 합니다. 처음부터 CDK나 Terraform으로 갔어야 했습니다. 당시엔 오픈 날짜가 먼저였고 "일단 뜨게 하자"가 이겼는데, 그 판단 자체는 지금도 크게 틀렸다고 보진 않아요. 다만 두 번째 환경(prod)을 만들 때 비용을 한 번에 다 치렀습니다.

지도 뷰가 단일 파일 2,300줄짜리 컴포넌트로 남아 있는 것도 부채입니다. 카메라, 핀 레이아웃, 제스처, 라우팅이 한 컴포넌트 안에 엉켜 있어서 테스트가 거의 안 붙어요. 카메라 수학은 순수 함수로 잘 빠져 있으니 거기부터 훅으로 뽑았어야 했습니다.

여기서는 지도와 오픈 게이팅만 다뤘습니다. 라우트를 넘나들어도 끊기지 않는 BGM은 sessionStorage 체크포인트와 Web Audio·HTMLAudio 이중 경로, 영상 재생 시 자동 덕킹까지 얽혀 있어 그것만으로 한 편이 나옵니다. 다음에 따로 쓰겠습니다.

Read next

3D 스크롤 인터랙션 기반 채용 랜딩 사이트

APM COMPANY — 2025