날짜: 2026-09-09
프로젝트: CareMatch — 요양보호사·간병인과 요양시설을 잇는 구인구직 매칭 서비스 (팀 포트폴리오)
스택: React(Vercel) + Spring Boot(Render, Docker) + PostgreSQL(Neon) + Cloudflare R2
오늘 목표는 "각자 로컬에서만 돌던 걸 실제 URL로 띄우는 것". 프론트는 Vercel, 백엔드는 Render,
DB는 Neon, 파일은 Cloudflare R2로 붙였다. 한 번에 된 게 하나도 없어서 기록으로 남긴다.
No open ports detected로 실패==> No open ports detected, continuing to scan...
==> Docs on specifying a port: https://render.com/docs/web-services#port-binding
==> Exited with status 1
Render는 컨테이너에 PORT 환경변수로 포트를 동적으로 주입하고(기본 10000), 그 포트로
서버가 리스닝하는지 확인한다. 안 하면 위 메시지와 함께 배포 실패.
처음엔 "포트를 안 읽나?" 싶었는데, Dockerfile ENTRYPOINT는 이미 -Dserver.port=${PORT:-8080}로
PORT를 참조하고 있었다. 진짜 원인은 앱이 포트를 열기 전에 죽는 것. Exited with status 1은
포트 설정 문제가 아니라 스프링 컨텍스트 초기화 실패 신호였다. (자세한 건 2·3번)
포트 설정은 코드 한 곳에서만 관리하도록 정리했다.
application.yml (공통):
server:
port: ${PORT:8080} # 배포는 PORT 주입, 로컬은 8080
Dockerfile ENTRYPOINT:
# before
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -Dserver.port=${PORT:-8080} -jar app.jar"]
# after
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]
-Dserver.port 제거 → application.yml이 담당 (진실의 출처 1개)exec 추가 → java가 PID 1이 되어 Render의 SIGTERM(무중단 배포·셧다운)이 정상 전달됨Exited with status 1 + No open ports는 대부분 "포트 설정"이 아니라 "기동 실패"다.@Lob String이 PostgreSQL에서 oid 컬럼으로 생성돼서 깨짐Render 로그:
Caused by: org.hibernate.tool.schema.spi.SchemaManagementException:
Schema-validation: missing table [application]
그리고 스키마를 만들고 보니 일부 텍스트 컬럼 타입이 이상했다.
Faq.answer, Inquiry.content, InquiryReply.content, Notice.content, Terms.content 5개가
@Lob String이었다. Hibernate 6 + PostgreSQL 조합에서 @Lob String은 text가 아니라
oid(large object 포인터) 컬럼으로 매핑된다. 이 상태로는 일반 문자열 insert/조회가 깨진다.
로컬은 H2라서 이 문제가 전혀 안 드러났다 — 배포하고 나서야 발견.
// before
@Lob
@Column(name = "content", nullable = false)
private String content;
// after
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
프로젝트에 이미 JobPosting.description이 @Column(columnDefinition = "TEXT") 컨벤션을 쓰고
있어서 그쪽에 맞췄다. 스키마 스냅샷(docs/schema/schema-postgresql.sql)의 해당 5줄도
oid → text로 반영.
@Lob 대신 @Column(columnDefinition = "TEXT")가 안전하다.@JdbcTypeCode(SqlTypes.LONGVARCHAR))ddl-auto: validate → missing table운영 프로필은 spring.jpa.hibernate.ddl-auto: validate. 즉 스키마를 만들어 주지 않고
엔티티와 실제 테이블이 맞는지 검증만 한다. Neon에 갓 만든 빈 DB에는 테이블이 하나도 없으니
검증에서 바로 실패 → 기동 불가 → (1번의 No open ports).
schema-postgresql.sql) 1회 실행terms) — 회원가입 약관 동의 검증에 필요LocalDataInitializer는 local 전용이라 운영엔 시드가 안 들어감관리자 비밀번호 해시는 프로젝트의 BCryptPasswordEncoder로 직접 생성해서 넣었다
(운영 DB에 평문을 넣을 수 없으니). 정책(8~64자, 영문·숫자·특수문자)에 맞는 랜덤 비번 생성 →
BCrypt 해시 → INSERT INTO member (...).
ddl-auto: validate는 운영에서 옳은 선택이지만, 스키마를 누가 만들 것인가를 같이 정해야 한다.ALTER TABLE 치기 싫다 → Flyway 도입백엔드에서 엔티티에 컬럼이 하나 추가될 때마다(member에 easy_mode, font_scale 추가 등)
배포 전에 Neon 콘솔에서 ALTER TABLE을 손으로 쳐야 했다. validate라서 안 치면 기동 실패.
팀 작업이라 놓치기 쉽고 위험.
의존성: org.flywaydb:flyway-core + flyway-database-postgresql
backend/src/main/resources/db/migration/
V1__baseline.sql — Flyway 도입 시점의 스키마 (그때 운영 DB 상태와 동일)V2__member_display_preference.sql — 이후 추가된 컬럼프로필별 설정
# application.yml (공통)
spring.flyway.enabled: false # local/test 는 H2 + create-drop 유지
# application-prod.yml
spring:
flyway:
enabled: true
baseline-on-migrate: true # 이미 스키마가 있는 Neon DB 위에 얹기
baseline-version: 1 # V1 은 "이미 적용됨"으로 마킹만, 실행 안 함
jpa.hibernate.ddl-auto: validate
baseline-on-migrate: true + baseline-version: 1이면:
1. flyway_schema_history 테이블이 없으면 생성
2. 스키마가 비어있지 않으므로 V1을 실행하지 않고 "baseline(=적용됨)"으로만 기록
3. V2부터 실제 적용
실제 배포 로그:
Successfully baselined schema with version: 1
Migrating schema "public" to version "2 - member display preference"
Successfully applied 1 migration to schema "public", now at version v2
이후로는 V3__*.sql 파일만 PR에 같이 넣으면 배포 시 자동 적용. Neon 콘솔 열 일이 없어졌다.
로컬엔 PostgreSQL이 없어서, H2를 PostgreSQL 호환 모드로 띄워 마이그레이션이 실행되는지만
확인했다. 그때 V2가 실패:
-- 실패 (H2는 한 ALTER TABLE에 ADD COLUMN 여러 개를 못 씀)
ALTER TABLE member
ADD COLUMN easy_mode boolean NOT NULL DEFAULT false,
ADD COLUMN font_scale varchar(10) NOT NULL DEFAULT 'NORMAL';
-- 통과 (문장 분리 — Postgres/H2 둘 다 OK)
ALTER TABLE member ADD COLUMN easy_mode boolean NOT NULL DEFAULT false;
ALTER TABLE member ADD COLUMN font_scale varchar(10) NOT NULL DEFAULT 'NORMAL';
PostgreSQL은 다중 ADD COLUMN을 지원하지만, 마이그레이션 SQL은 문장별로 나눠 쓰는 게
이식성 면에서 안전하다는 걸 배웠다.
ddl-auto: validate + 수동 SQL은 팀이 커지면 반드시 사고 난다. 초기에 Flyway를 넣는 게 낫다.baseline 개념을 이해해야 한다.Vercel에 VITE_API_BASE_URL을 넣고 재배포했는데 아무 변화가 없었다.
프론트에 그 변수를 읽는 코드 자체가 없었다. import.meta.env.VITE_API_BASE_URL을
참조하는 곳도, fetch/axios 호출도 하나도 없었다. 세션도 가짜(DEMO_USERS 하드코딩)였다.
즉 "연동"이라고 부를 게 없는 상태에서 환경변수만 넣은 것.
lib/api-client.ts — apiFetch() 래퍼VITE_API_BASE_URL) + JSON 직렬화Authorization: Bearer 자동 첨부/api/auth/reissue 1회 재시도 후 재요청 (동시에 터진 401은ApiError(code/message/fieldErrors)로 통일lib/token-store.ts — access/refresh 토큰 localStorage 보관 + 구독(탭 간 동기화)api/auth.ts, api/members.ts — 엔드포인트별 함수hooks/use-app.tsx — 가짜 세션 제거, 토큰 상태를 구독해 /api/members/me로 실제 세션 로드/login, /oauth/callback 화면.env.example(커밋) / .env.local(gitignore)라이브 백엔드에 실제로 호출해서 응답 형태가 타입 정의와 맞는지까지 확인했다.
VITE_ 접두어 경고Vercel이 VITE_API_BASE_URL에 대해 이런 경고를 띄웠다:
"Remove the public framework prefix to keep this value private.
Public prefixes expose values to the browser."
VITE_ 접두어가 붙은 변수만 클라이언트 코드에 노출한다. 접두어를 떼거나https://...onrender.com)이라 브라우저에 노출돼도 안전하다.파일 업로드가 stub 구현체라서 가짜 URL(https://files.example.invalid)만 반환하고 있었다.
자격증 이미지·사업자등록증·프로필 사진이 실제로 저장이 안 됨.
StubFileStorageService는 그대로 두고 R2FileStorageService 추가@ConditionalOnProperty(prefix="carematch.storage", name="provider", havingValue="r2")로 전환matchIfMissing=true)software.amazon.awssdk:s3) 그대로 사용S3Presigner.presignPutObject (presigned PUT URL)S3Client.headObject로 존재/크기/타입 검증presignGetObject (TTL, 영구 공개 URL 금지)S3Configuration serviceConfig = S3Configuration.builder()
.pathStyleAccessEnabled(true) // path-style
.chunkedEncodingEnabled(false) // presigned PUT 호환 (aws-chunked 인코딩 끔)
.build();
S3Client.builder()
.region(Region.of("auto")) // R2는 region을 무시하지만 SDK는 필수 → "auto"
.endpointOverride(URI.create(endpoint)) // https://<account_id>.r2.cloudflarestorage.com
.credentialsProvider(StaticCredentialsProvider.create(creds))
.httpClientBuilder(UrlConnectionHttpClient.builder()) // 가벼운 동기 클라이언트
.serviceConfiguration(serviceConfig)
.build();
region은 아무 값이나 필요 → autoendpointOverride로 R2 계정 엔드포인트 지정pathStyleAccessEnabled(true) + chunkedEncodingEnabled(false)가 presigned PUT에서 중요프론트는 백엔드가 준 presigned URL로 브라우저에서 R2로 직접 PUT/GET한다. 그래서
R2 버킷 자체에 CORS 정책(프론트 도메인 허용)을 Cloudflare 대시보드에서 따로 걸어야 한다.
백엔드(Spring)의 CORS 설정과는 완전히 별개다.
[
{
"AllowedOrigins": ["https://<프론트 도메인>", "http://localhost:5173"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["content-type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
배포 후 실제로 돌려봤다:
POST /api/files/upload-url → 진짜 R2 presigned PUT URL 반환
PUT <presigned URL> → 200 (R2에 파일 기록됨)
POST /api/files/confirm → {"exists":true, "sizeBytes":45, "contentType":"application/pdf"}
로그: [R2] 초기화 완료 endpoint=https://... bucket=carematch-prod
endpointOverride + region("auto") +STORAGE_BUCKET을./gradlew build가 이렇게 실패했다:
ERROR: JAVA_HOME is set to an invalid directory: /mnt/c/Users/SBS/.jdks/corretto-21.0.10
WSL이 Windows의 JAVA_HOME(IntelliJ가 관리하는 Windows 경로)을 그대로 상속하는데,
그 경로는 Linux에선 존재하지 않는다. sudo 비번이 없어서 apt도 못 씀.
해결: Temurin JDK 21 tarball을 홈 디렉터리에 풀고 ~/.bashrc에서 JAVA_HOME을 덮어씀.
export JAVA_HOME="$HOME/.local/jdks/jdk-21.0.12.1+1"
export PATH="$JAVA_HOME/bin:$PATH"
(빌드는 options.release = 17이라 JDK 21로 빌드해도 17 바이트코드가 나온다.)
| 문제 | 핵심 원인 | 해결 |
|---|---|---|
Render No open ports | 포트가 아니라 앱 기동 실패 | server.port: ${PORT:8080} + 로그 스택트레이스 확인 |
문자열 컬럼이 oid | Hibernate 6 + PG에서 @Lob String → oid | @Column(columnDefinition = "TEXT") |
missing table | 빈 운영 DB + ddl-auto: validate | 스키마/시드 적재 → 이후 Flyway |
| 배포마다 수동 ALTER | 마이그레이션 도구 부재 | Flyway + baseline-on-migrate |
| 환경변수 넣었는데 무반응 | 그 값을 쓰는 코드가 없었음 | API 클라이언트 계층부터 구현 |
Vercel VITE_ 경고 | 공개 접두어 일반 주의문 | 무시 (공개 URL은 안전, VITE_ 필수) |
| 파일이 저장 안 됨 | stub 스토리지 | R2 구현체 + @ConditionalOnProperty 전환 |
No open ports는 포트 문제가 아니었고,missing table은 코드가 아니라 인프라 상태 문제였다.