ArchitectureDeployment

Deployment (Render · Vercel · Branch policy)

Overview

컴포넌트호스트트리거
Backend (linkmusic-msa-space-was)Rendermain push → auto-deploy
Frontend admin (apps/admin)Vercel (별도 project · admin.space.linkmusic.io)main push → production / 그 외 → preview
Frontend space (apps/space)Vercel (별도 project · space.linkmusic.io, 신설 필요)동일
Frontend docs (apps/docs)Vercel (별도 project, 신설)동일
PostgreSQLRender PostgreSQLmanaged

Branch 정책 (Git Flow 단순화)

  • main — production (자동 배포). 직 push 금지.
  • develop — default 통합 브랜치. 모든 PR base.
  • feature/* — 작업 단위. SPEC 당 1 브랜치.

머지 정책:

방향머지 모드
feature/* → developsquash (gh pr merge --squash)
develop → mainmerge commit (gh pr merge --merge)
rebase merge비활성

Branch protection (main · develop):

  • Build CI 통과 필수
  • 1 review approval 필수 (Copilot review 포함 자체 평가)
  • 직 push 금지

main → release 자동화

develop → main 머지 후 무조건 release 생성 (memory feedback_release_on_main_merge):

gh release create vX.Y.Z --title "vX.Y.Z" --notes "포함 SPEC: #NNN · #MMM ..." --target main

SemVer:

  • 사용자 기능 추가 (인증·HQ 등록 등) — minor (0.1.0 · 0.2.0 …)
  • 디자인 시스템 · 인프라 — patch
  • breaking change — major (1.0.0 = 베타 출시)

Vercel 설정 (apps/admin)

항목
Root Directoryapps/admin
FrameworkNext.js
Build Commandpnpm build
Install Commandpnpm install --frozen-lockfile
Node20.20.2 (.nvmrc 안에)
pnpm10.33.0 (packageManager 안에)

Vercel monorepo + sub-app pattern:

  • Root Directory 는 sub-app 폴더 (apps/admin).
  • 그 폴더 안에도 .nvmrc + engines.node + packageManager ([[feedback-9]]).
  • Vercel 이 root engines / .nvmrc 만 보고 sub-app 의 minor 가 안 맞으면 engine-strict fail.

Vercel 설정 (apps/space) — 신설 필요

매장 클라이언트(본사+점장 한 앱). admin 과 다른 origin(space.linkmusic.io)이므로 별도 Vercel project.

항목
Root Directoryapps/space
FrameworkNext.js
Build Commandpnpm build
Install Commandpnpm install --frozen-lockfile
Node20.20.2 (apps/space/.nvmrc)
pnpm10.33.0 (packageManager)
Port3001 (dev)
도메인space.linkmusic.io (후속) / 초기엔 Vercel preview URL

env vars (server-side only):

  • BACKEND_BASE_URL — backend API base (prod: https://linkmusic-msa-space-was.onrender.com)
  • SESSION_PASSWORD — iron-session 암호화 키 (≥32 bytes random). apps/admin 과 별개 값으로 둘 것 (세션 cookie 네임스페이스 분리 — lm_space_session). 누락 시 부팅 거부.

Vercel 설정 (apps/docs) — 신설 필요

항목
Root Directoryapps/docs
FrameworkNext.js
Build Commandpnpm build
Install Commandpnpm install --frozen-lockfile
Node20.20.2
Port3100 (dev)

도메인:

  • 초기 — Vercel preview URL
  • 후속 — docs.linkmusic.io 또는 linkmusic-docs.vercel.app

Render 설정 (backend)

  • Service type: Web Service
  • Runtime: Docker (또는 native Java)
  • Auto-deploy: main push
  • Health check: /actuator/health
  • env vars (prod):
    • JWT_SECRET (≥64 chars, base64)
    • DATABASE_URL (Render PG 자동 주입)
    • SPRING_PROFILES_ACTIVE=render
    • STORAGE_AZURE_CONNECTION_STRING (#041 — 음원 blob 스토리지. 선택: 미설정 시 Local 어댑터로 부팅·blob 미저장. production 실사용 전 필수)
    • STORAGE_AZURE_CONTAINER (#041 — 기본 music)
    • STORAGE_AZURE_PREFIX (#041 — 기본 music)
    • UPLOAD_TICKET_SECRET (#177 — 대용량 직접 업로드 티켓 서명 시크릿, ≥256-bit 랜덤. JWT_SECRET분리. 미설정 시 티켓 발급/검증 불가 → 업로드 503 UPLOAD_NOT_CONFIGURED(fail-lazy — 부팅·다른 기능은 정상). production 실사용 전 필수)
    • … (env var 추가 시 main 머지 전 사용자 안내 의무 — [[memory: announce_new_env_vars]])

Azure Blob 스토리지 · 스토리지 추상화 (#041)

음원 파일 업로드(#041)는 backend StoragePort 추상화로 저장소를 분리한다 — Local 어댑터(기본)AzureBlobStorageAdapter. Azure 어댑터는 storage.azure.connection-string(env STORAGE_AZURE_CONNECTION_STRING)이 주입됐을 때만 @ConditionalOnProperty 로 활성화되고, 없으면 Local 어댑터로 부팅(blob 미저장)한다. 따라서 Azure 자격증명을 구축·제공하기 전에도 backend 가 부팅·동작한다(blob 저장만 비활성). production 실사용 전 connection-string 주입이 필수다.

env기본의미
STORAGE_AZURE_CONNECTION_STRING(없음 → Local fallback)Azure Storage 연결 문자열. 있을 때만 Azure 어댑터 활성
STORAGE_AZURE_CONTAINERmusicblob 컨테이너
STORAGE_AZURE_PREFIXmusicblob key prefix ({prefix}/{id}.mp3)

main 머지 전 안내 의무([[memory: announce_new_env_vars]]) — Azure connection-string 은 production 시크릿. 사용자가 구축 후 제공한다. 미설정 시 Local 어댑터로 부팅돼 deploy 자체는 실패하지 않으나 blob 이 저장되지 않는다. 업로드 흐름·태그 재기록은 Music Upload.

음원 서빙·전송 캐시 (SPEC #179)

재생 음원·CM 은 public blob URL 직접 서빙(SAS·CDN 없음 — 서명 파라미터가 없어 URL=캐시 키 등식이 유지된다)이며, 전송 원가는 클라이언트 캐시 적중으로 줄인다. 서버·인프라 측 계약:

  • 교체 = 새 UUID key 발급 (D1 — 음원·CM. BE MusicUploadService.replace·HqCommercialService): 같은 key 덮어쓰기를 폐지해 key 가 내용 주소가 된다. URL 이 바뀌면 클라이언트 캐시(브라우저 HTTP + FE Cache Storage)가 자동 무효화된다. 옛 blob 은 삭제하지 않는다(D11 — 재생 중 클라이언트가 옛 URL 을 최대 30분(큐 refetch 주기) 들고 있으므로 즉시 삭제는 재생 실패를 만든다). TTS 는 예외 — 같은 key 덮어쓰기 유지(audioUrl 불변 계약).
  • Cache-Control (D2·D3): 음원·CM 업로드 = public, max-age=31536000, immutable(key 불변 전제) · TTS = no-store 명시 · 티켓 첨부 = 미지정(인증 프록시 경유라 blob 헤더 무관). StoragePort.upload()cacheControl 인자.
  • 인프라 후속(I1·I2 — 운영 액션): Azure Storage 계정 CORS 룰(GET · FE 캐시 계층의 fetch(cors) 전제 — 미설정이어도 FE 는 원격 폴백으로 무해, 캐시만 미동작) + 기존 blob Cache-Control 백필 스크립트 1회(BE 배포 — 순서가 안전성이다: 배포 전 백필은 같은-key 교체 시절 파일에 immutable 을 고착시킨다). 새 env·시크릿 없음.
  • FE 캐시 계층(Cache Storage + objectURL·프리페치·cacheHit 계측)은 Store Player §음원 캐시 계층 참고.

대용량 파일 직접 업로드 · Vercel 4.5MB 우회 (#177)

음원·CM송·CS 티켓 첨부 파일 업로드는 원래 브라우저 → Vercel 서버리스 BFF proxy (/api/backend/[...path], runtime="nodejs", 요청 본문을 arrayBuffer 로 전량 버퍼링) → 백엔드 경로를 탔다. 그런데 Vercel 서버리스 함수는 요청 본문 4.5MB 하드 리밋(FUNCTION_PAYLOAD_TOO_LARGE)이 있어 그 이상 파일이 413 으로 차단됐다(백엔드는 음원·CM 20MB · CS 첨부 10MB 허용). 이 리밋은 플랫폼 제약이라 설정으로 올릴 수 없다.

SPEC #177 — 파일 본문만 백엔드로 직접(cross-origin) 전송해 Vercel 을 우회한다. 인증은 백엔드가 발급하는 단기·용도한정 업로드 티켓(별도 시크릿 UPLOAD_TICKET_SECRET 서명 JWT, ~10분):

  1. FE 가 POST /api/v1/uploads/tickets(작은 JSON, proxy 경유·기존 JWT 인증)로 티켓 + uploadUrl(백엔드 공개 절대 URL)을 받는다. 백엔드가 role↔purpose 를 검증한다(OPERATOR→MUSIC·TICKET_ATTACHMENT_ADMIN / HQ_MANAGER→COMMERCIAL·TICKET_ATTACHMENT_HQ·TICKET_ATTACHMENT_HQ_STORE / STORE_MANAGER→TICKET_ATTACHMENT_STORE). CS 첨부는 targetId(티켓 id)의 채널 격리도 함께 검증한다.
  2. FE 가 uploadUrl(= POST /api/v1/uploads/files)로 파일 multipart 를 직접 POST(X-Upload-Ticket 헤더·permitAll+티켓 검증). Vercel proxy 를 거치지 않으므로 4.5MB 리밋 무관(백엔드 상한만 적용).

음원·CM 은 크기와 무관하게 항상 이 경로를 쓰고, CS 첨부는 4MB 초과분만 이 경로로 우회한다(이하는 채널 multipart endpoint 로 요청 1회). apps/space proxy 에서 uploads/tickets 의 세션 스코프는 경로가 아니라 본문 purpose 로 갈린다 — 본사 purpose 만 impersonation-aware 이고 점장 purpose·판독 실패는 실 로그인(fail-closed)이다.

CORS 는 기존 SecurityConfig/api/** 에 FE 오리진을 허용해 충족된다. UPLOAD_TICKET_SECRET 미설정 시 fail-lazy — 부팅·다른 기능은 정상이고 업로드만 503 UPLOAD_NOT_CONFIGURED 로 실패한다. FE 신규 env 없음(uploadUrl 은 발급 응답값). 자세한 계약은 Endpoints · Uploads.

실시간 통지 채널 · 스트림 티켓 (#180)

점장 player 는 본사 즉시방송을 SSE 로 즉시 통지받는다. 연결 경로는 위 대용량 업로드(#177)와 같은 브라우저 → 백엔드 직접 + 단기 티켓 패턴이다 — BFF 스트리밍 프록시 라우트는 쓰지 않는다.

  1. FE 가 POST /api/v1/store/announcements/stream-ticket(작은 JSON · proxy 경유 · 기존 JWT 인증)로 { ticket, streamUrl, expiresInSeconds } 를 받는다. streamUrl 은 티켓까지 포함된 완전한 절대 URL 이라 FE 가 백엔드 base URL 을 조립하지 않는다.
  2. new EventSource(streamUrl)GET /api/v1/store/announcements/stream?ticket=…(permitAll + 티켓 검증)로 직접 연결. EventSource 는 커스텀 헤더를 실을 수 없어 Bearer 인증이 불가능하므로 티켓이 쿼리 파라미터로 간다(TTL 60초 · 단일 용도 · 신호만 흐르는 스트림이라 leak 가치가 낮다).

왜 BFF 프록시가 아닌가: (a) Vercel maxDuration(Hobby 300s/Pro 800s)에 스트리밍 시간이 포함돼 5~13분마다 전 기기가 강제 재연결된다. (b) in-flight 스트림이 Provisioned Memory 를 상시 과금시켜, 호출 비용 절감이 동기인 변경이 새 상시 비용을 만든다. (c) 백엔드의 30분 emitter 수명을 활용할 수 없다. 직접 연결은 재연결당 티켓 mint 1회(기기당 일 ~144회)로 끝난다.

  • 시크릿·env: UPLOAD_TICKET_SECRET재사용하되 type=stream-ticket claim 으로 업로드 티켓과 상호 사용을 차단한다 — 새 env 0 · DB·마이그레이션 0. 미구성 시 fail-lazy(발급 503, 방송은 폴링으로 그대로 수신).
  • CORS: 기존 SecurityConfig/api/** 오리진 허용을 그대로 쓴다. preview 오리진이 목록에 없으면 SSE 만 조용히 실패하고 폴링으로 폴백한다(방송 수신은 무손실 — 필요 시 오리진 추가는 env 통보 사안). 이 경우 티켓 발급은 200 인데 연결만 계속 실패하므로, FE 는 connected 를 한 번도 받지 못한 실패가 5회를 넘으면 재시도 상한을 5분으로 올린다 — 그대로 두면 매 분 티켓을 mint 해 기기당 하루 1,440회(폴링 완화로 아낀 호출을 상쇄)가 된다.
  • keepalive: 서버가 25초 주기로 SSE comment(: ping)를 보내 중간 프록시(Render LB)의 유휴 컷을 막는다. comment 는 브라우저가 JS 로 노출하지 않으므로 클라이언트는 선제 재연결(10분)연결 확정 워치독(15초) 으로 죽은 커넥션을 걷어낸다.
  • 단일 인스턴스 전제: 구독자 registry 가 인메모리다. 스케일아웃하면 SSE 만 동시성을 잃고 폴링으로 동작한다(기능 유지). Redis pub/sub 은 그때 별도 SPEC.
  • FE 훅·폴링 완화(연결 연속 90초 유지 후 60초 / 그 외 20초 · 히스테리시스)·계측은 Store Player §실시간 통지 참고.

env var 정책

  • 클라이언트 노출은 NEXT_PUBLIC_*.
  • .env* 파일은 편집/커밋 금지.
  • production 시크릿 (JWT_SECRET · SESSION_PASSWORD · API key) — 추가 시 main 머지 사용자에게 명시적 안내. 누락 시 deploy fail.
  • .env.example 만 commit.

CI (GitHub Actions)

  • .github/workflows/ci.yml:
    • Trigger: push to develop/main + PR
    • matrix: node 20.20.2
    • steps: pnpm install --frozen-lockfilepnpm -r lint typecheck test build
  • .github/workflows/e2e.yml: 풀스택 E2E harness(docker compose pg→backend→frontend + Playwright happy-path). develop 대상 PR·수동 dispatch에서만 동작하며 현재 논블로킹(required status check 미등록, 안정화 후 승격 검토). standalone 빌드는 BUILD_STANDALONE=1(Dockerfile.e2e)로 E2E 도커 이미지에서만 켜진다 — 기본/Vercel 빌드 무영향.
  • branch protection 에 status check 등록

Pre-deploy 체크리스트 (memory local_verify_before_push)

push 전:

  • pnpm -r typecheck 통과
  • pnpm -r lint 통과
  • pnpm -r test 통과
  • pnpm -r build 통과 (실제 배포 시 발생 가능한 실수 사전 차단)
  • 새 env var 있다면 사용자 안내 노트 작성
  • cross-cutting grep — 같은 helper 가 다른 호출처에 적용 필요한지 ([[feedback-16]])

Roadmap

  • staging 환경 분리 — develop → preview · main → production (현재 dev 가 develop 통합 역할)
  • env var 자동 비교 (CI 가 .env.example 과 Render env 비교)
  • Sentry · Vercel Analytics 도입

References

  • 워크스페이스 CLAUDE.md (Git 전략)
  • .github/workflows/ci.yml
  • linkmusic-frontend-space/apps/admin/.nvmrc
  • memory feedback_* (workflow 전반)