Fullstack 100

heo4·2026년 9월 7일

Fullstack

목록 보기
55/71

풀스택

팀 프로젝트 CareMatch

-모노레포 전환과 배포 아키텍처 결정기 — Neon + Render + Vercel, 그리고 Cloudflare

CareMatch 프로젝트 · 2026-09-08

오늘은 코드를 많이 짜기보다, 어디에 어떻게 배포할지를 정리하는 데 시간을 썼다.
포트폴리오용 프로젝트라 "그냥 되게" 만드는 것보다 선택의 근거를 남겨두는 게 의미가 있을 것 같아서 글로 정리한다.


1. 모노레포로 전환

그동안 백엔드 저장소만 있었는데, 프론트가 곧 붙기 때문에 구조를 먼저 잡았다.

backend/    # Spring Boot API 서버 (Render 배포)
frontend/   # React 앱 (배포 예정)
docs/       # API / ERD 문서 (공용)
.github/    # CODEOWNERS, 워크플로우
CLAUDE.md   # 프로젝트 규칙
  • 기존 백엔드 소스를 통째로 backend/ 아래로 이동
  • frontend/ 는 아직 초기화 전이라 플레이스홀더 README만 넣어둠
  • IntelliJ는 루트가 아니라 backend/ 를 Gradle 프로젝트로 임포트해야 인식됨
  • Dockerfile 빌드 컨텍스트도 backend/ 기준으로 맞춤 (Render Root Directory = backend)

브랜치 전략은 GitHub Flow 유지: main + feature/*, 배포는 태그로 표시.
관련 PR: #16, #18, #19 머지 완료.


2. 출발점: Neon + Render + Vercel

처음 생각한 그림은 흔한 3단 구성이었다.

레이어서비스이유
DBNeon (PostgreSQL)서버리스 Postgres, 무료 티어
백엔드RenderDocker 런타임 지원, Spring Boot 배포 편함
프론트VercelReact 정적 호스팅 표준

그런데 "이 3개만 연결하면 끝인가?" 를 따져보니 그렇지 않았다.

3개 연결에서 실제로 필요한 설정

  • Render 환경변수: SPRING_PROFILES_ACTIVE=prod, DB_URL(Neon 접속 URL을 JDBC 형식으로), DB_USERNAME, DB_PASSWORD, JWT_SECRET(64바이트 이상 랜덤), CORS_ALLOWED_ORIGINS(프론트 도메인)
  • Vercel 환경변수: API 베이스 URL = Render 주소
  • Render 서비스 설정: Root Directory backend, Docker, Health Check Path /actuator/health

놓치기 쉬운 함정

  1. prod 프로필은 ddl-auto: validate — Neon DB에 테이블이 하나도 없으면 부팅 자체가 실패한다.
    마이그레이션 도구(Flyway/Liquibase)가 아직 없어서, 최초 1회 스키마를 만들어줘야 한다.
  2. Neon / Render 무료 플랜은 유휴 시 잠듦 — Neon은 커넥션이 끊기고 Render는 cold start가 생긴다.
    커넥션 풀(DB_POOL_SIZE)은 작게 잡는 게 안전하다.

아직 stub으로 남아 있는 외부 연동

코드를 열어보니 인터페이스만 있고 구현체는 비어 있는 것들이 꽤 있었다.

기능현재 상태실제로 쓰려면
소셜 로그인 (네이버·카카오·구글)client-id dummy각 개발자 콘솔 앱 등록 + redirect URI 등록 + 키 6개 주입
파일 업로드STORAGE_PROVIDER=stub, 더미 URLR2 / S3 등 오브젝트 스토리지 연결
인증코드 발송 (이메일·SMS)로그만 출력SMS / 메일 서비스 연동
1:1 문의 알림로그만 출력이메일 / 카카오 알림
포인트·결제Stub, PG 미연동범위 밖 (2차)

즉 "로그인 / 회원가입 / 공고 조회" 데모 수준이면 3개 연결 + 함정 2개만 처리하면 되고,
소셜 로그인·파일 업로드·문자 인증까지 실제로 돌리려면 그만큼 외부 서비스를 더 붙여야 한다.


3. "그럼 Cloudflare 하나로 다 못 하나?"

포트폴리오라 이왕이면 다양한 걸 써보고 싶었고, Cloudflare 한 곳으로 통합하면 깔끔할 것 같았다.
그런데 지금 구조(Spring Boot + JPA + PostgreSQL)에는 잘 안 맞았다.

현재Cloudflare 대응가능 여부
Vercel (React)Pages✅ 그대로 이전 가능
Render (Spring Boot Docker)Workers / Containers⚠️ Workers는 V8 아이솔레이트 런타임 → JVM 실행 불가. Containers로 Docker는 돌지만 상시 API 서버용으로는 미성숙
Neon (PostgreSQL)D1 / Hyperdrive❌ D1은 SQLite라 Hibernate PostgreSQL 다이얼렉트·JDBC와 안 맞음. Hyperdrive는 DB가 아니라 외부 Postgres 가속용
파일 스토리지R2✅ S3 호환, 잘 맞음

핵심 걸림돌 두 가지:

  • Workers에서는 .jar 를 못 돌린다. 백엔드를 Cloudflare에서 제대로 굴리려면 Containers를 써야 하는데,
    24/7 API 호스팅 용도로는 Render보다 불편하고 콜드스타트·비용 이슈가 있다.
  • Cloudflare에는 관리형 Postgres가 없다. D1로 가려면 JPA를 걷어내고 persistence를 다시 짜야 한다. 사실상 백엔드 재작성.

DB까지 Cloudflare로 완전히 넣으려면 Spring Boot 자체를 포기해야 한다는 결론.


4. 결정: 하이브리드, 그리고 "넓이보다 깊이"

고민하다가 방향을 바꿨다.

"여러 서비스 써봤다"는 포트폴리오에서 생각보다 약한 카드다.
대시보드에서 배포 버튼 누른 수준이면 리뷰어는 금방 알아챈다.
Vercel을 Pages로 바꾸는 것도 거의 동일 기능 수평 이동이라 학습 신호가 거의 없다.

그래서 호스팅을 옆으로 늘리는 대신, 한두 곳을 깊게 파는 쪽으로 정했다.

확정 스택

레이어서비스
프론트Cloudflare Pages
백엔드Render (Spring Boot Docker)
DBNeon (PostgreSQL)
파일 스토리지Cloudflare R2

Cloudflare를 쓰되 "장식"이 아니라 "필연"으로 쓰기로 했다.
마침 FileStorageService 인터페이스가 서명(만료) URL 기반 오브젝트 스토리지 교체를 전제로 설계돼 있었다.

public interface FileStorageService {
    UploadUrlResponse issueUploadUrl(FilePurpose purpose, String originalFilename, String contentType);
    FileMetadata confirmUpload(String fileKey);
    String issueDownloadUrl(String fileKey, Duration ttl);
}

여기에 R2 구현체를 넣으면:

  • 기존 아키텍처에 자연스럽게 맞고
  • "스텁 → 실제 벤더 교체" 라는 확장 포인트를 코드로 증명하고
  • S3 호환 API·버킷 CORS·presign 만료 같은 실무 디테일을 다룬 흔적이 남는다

이게 Vercel → Pages 갈아타기보다 훨씬 나은 "Cloudflare 써봤다" 라고 판단했다.

로그인은 프론트 호스트와 무관

한 가지 확인한 것: OAuth를 전부 Spring Security(백엔드)가 처리한다.

  • redirect URI = https://<render>/login/oauth2/code/naver → 백엔드 주소
  • 프론트는 마지막에 토큰만 넘겨받는 정적 페이지

그래서 프론트를 Vercel에 두든 Cloudflare Pages에 두든 로그인 흐름에는 영향이 없다.
프론트 쪽이 신경 쓸 건 CORS 화이트리스트에 도메인 등록하는 것 정도.
(*.vercel.app 이 아니라 *.pages.dev + 커스텀 도메인으로 값을 바꿔야 한다.)


5. 다음 작업 (내일)

feature/be-r2-storage 브랜치에서 R2 구현 예정.

  • StubFileStorageService 는 그대로 두고 R2FileStorageService 추가
    → @ConditionalOnProperty(carematch.storage.provider) 로 stub / r2 전환
  • R2는 S3 호환이므로 AWS SDK for Java v2 의 S3Presigner 사용
    • issueUploadUrl() → presignPutObject (content-type + 만료)
    • confirmUpload() → HeadObjectRequest 로 존재·크기·타입 검증
    • issueDownloadUrl() → presignGetObject (TTL, 영구 공개 URL 금지)
  • Render 신규 환경변수: STORAGE_PROVIDER=r2, R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, STORAGE_BUCKET
  • 잊지 말 것: R2 버킷 자체에 CORS 정책(프론트 도메인에서 PUT/GET 허용)을 Cloudflare 대시보드에서 별도 설정

그리고 이왕이면 Flyway 마이그레이션도 같이 도입하는 게 ddl-auto: validate 함정을 근본적으로 없애는 길이다.


오늘의 교훈

  1. "3개만 연결하면 끝" 은 없다. 스텁으로 남은 연동, prod 프로필의 validate, 무료 플랜 유휴 등
    실제 배포엔 항상 곁다리가 붙는다. 코드를 열어보고 나서야 전체 그림이 보였다.
  2. 포트폴리오는 넓이가 아니라 깊이. 서비스 개수를 늘리는 것보다, 이미 설계된 확장 포인트 하나를
    끝까지 구현하는 게 더 강한 신호다.
  3. 아키텍처가 잘 잡혀 있으면 선택이 쉽다. FileStorageService 가 벤더 중립으로 설계돼 있었기 때문에
    "R2로 가자" 는 결정에 코드 리스크가 거의 없었다.

0개의 댓글