CareMatch 프로젝트 · 2026-09-08
오늘은 코드를 많이 짜기보다, 어디에 어떻게 배포할지를 정리하는 데 시간을 썼다.
포트폴리오용 프로젝트라 "그냥 되게" 만드는 것보다 선택의 근거를 남겨두는 게 의미가 있을 것 같아서 글로 정리한다.
그동안 백엔드 저장소만 있었는데, 프론트가 곧 붙기 때문에 구조를 먼저 잡았다.
backend/ # Spring Boot API 서버 (Render 배포)
frontend/ # React 앱 (배포 예정)
docs/ # API / ERD 문서 (공용)
.github/ # CODEOWNERS, 워크플로우
CLAUDE.md # 프로젝트 규칙
backend/ 아래로 이동frontend/ 는 아직 초기화 전이라 플레이스홀더 README만 넣어둠backend/ 를 Gradle 프로젝트로 임포트해야 인식됨backend/ 기준으로 맞춤 (Render Root Directory = backend)브랜치 전략은 GitHub Flow 유지: main + feature/*, 배포는 태그로 표시.
관련 PR: #16, #18, #19 머지 완료.
처음 생각한 그림은 흔한 3단 구성이었다.
| 레이어 | 서비스 | 이유 |
|---|---|---|
| DB | Neon (PostgreSQL) | 서버리스 Postgres, 무료 티어 |
| 백엔드 | Render | Docker 런타임 지원, Spring Boot 배포 편함 |
| 프론트 | Vercel | React 정적 호스팅 표준 |
그런데 "이 3개만 연결하면 끝인가?" 를 따져보니 그렇지 않았다.
SPRING_PROFILES_ACTIVE=prod, DB_URL(Neon 접속 URL을 JDBC 형식으로), DB_USERNAME, DB_PASSWORD, JWT_SECRET(64바이트 이상 랜덤), CORS_ALLOWED_ORIGINS(프론트 도메인)backend, Docker, Health Check Path /actuator/healthddl-auto: validate — Neon DB에 테이블이 하나도 없으면 부팅 자체가 실패한다.DB_POOL_SIZE)은 작게 잡는 게 안전하다.코드를 열어보니 인터페이스만 있고 구현체는 비어 있는 것들이 꽤 있었다.
| 기능 | 현재 상태 | 실제로 쓰려면 |
|---|---|---|
| 소셜 로그인 (네이버·카카오·구글) | client-id dummy | 각 개발자 콘솔 앱 등록 + redirect URI 등록 + 키 6개 주입 |
| 파일 업로드 | STORAGE_PROVIDER=stub, 더미 URL | R2 / S3 등 오브젝트 스토리지 연결 |
| 인증코드 발송 (이메일·SMS) | 로그만 출력 | SMS / 메일 서비스 연동 |
| 1:1 문의 알림 | 로그만 출력 | 이메일 / 카카오 알림 |
| 포인트·결제 | Stub, PG 미연동 | 범위 밖 (2차) |
즉 "로그인 / 회원가입 / 공고 조회" 데모 수준이면 3개 연결 + 함정 2개만 처리하면 되고,
소셜 로그인·파일 업로드·문자 인증까지 실제로 돌리려면 그만큼 외부 서비스를 더 붙여야 한다.
포트폴리오라 이왕이면 다양한 걸 써보고 싶었고, 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 호환, 잘 맞음 |
핵심 걸림돌 두 가지:
.jar 를 못 돌린다. 백엔드를 Cloudflare에서 제대로 굴리려면 Containers를 써야 하는데,DB까지 Cloudflare로 완전히 넣으려면 Spring Boot 자체를 포기해야 한다는 결론.
고민하다가 방향을 바꿨다.
"여러 서비스 써봤다"는 포트폴리오에서 생각보다 약한 카드다.
대시보드에서 배포 버튼 누른 수준이면 리뷰어는 금방 알아챈다.
Vercel을 Pages로 바꾸는 것도 거의 동일 기능 수평 이동이라 학습 신호가 거의 없다.
그래서 호스팅을 옆으로 늘리는 대신, 한두 곳을 깊게 파는 쪽으로 정했다.
| 레이어 | 서비스 |
|---|---|
| 프론트 | Cloudflare Pages |
| 백엔드 | Render (Spring Boot Docker) |
| DB | Neon (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 구현체를 넣으면:
이게 Vercel → Pages 갈아타기보다 훨씬 나은 "Cloudflare 써봤다" 라고 판단했다.
한 가지 확인한 것: OAuth를 전부 Spring Security(백엔드)가 처리한다.
https://<render>/login/oauth2/code/naver → 백엔드 주소그래서 프론트를 Vercel에 두든 Cloudflare Pages에 두든 로그인 흐름에는 영향이 없다.
프론트 쪽이 신경 쓸 건 CORS 화이트리스트에 도메인 등록하는 것 정도.
(*.vercel.app 이 아니라 *.pages.dev + 커스텀 도메인으로 값을 바꿔야 한다.)
feature/be-r2-storage 브랜치에서 R2 구현 예정.
StubFileStorageService 는 그대로 두고 R2FileStorageService 추가@ConditionalOnProperty(carematch.storage.provider) 로 stub / r2 전환S3Presigner 사용issueUploadUrl() → presignPutObject (content-type + 만료)confirmUpload() → HeadObjectRequest 로 존재·크기·타입 검증issueDownloadUrl() → presignGetObject (TTL, 영구 공개 URL 금지)STORAGE_PROVIDER=r2, R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, STORAGE_BUCKET그리고 이왕이면 Flyway 마이그레이션도 같이 도입하는 게 ddl-auto: validate 함정을 근본적으로 없애는 길이다.
validate, 무료 플랜 유휴 등FileStorageService 가 벤더 중립으로 설계돼 있었기 때문에