Fullstack 101

heo4·2026년 9월 8일

Fullstack

목록 보기
56/71

풀스택

CareMatch 배포연동

날짜: 2026-09-09
프로젝트: CareMatch — 요양보호사·간병인과 요양시설을 잇는 구인구직 매칭 서비스 (팀 포트폴리오)
스택: React(Vercel) + Spring Boot(Render, Docker) + PostgreSQL(Neon) + Cloudflare R2

오늘 목표는 "각자 로컬에서만 돌던 걸 실제 URL로 띄우는 것". 프론트는 Vercel, 백엔드는 Render,
DB는 Neon, 파일은 Cloudflare R2로 붙였다. 한 번에 된 게 하나도 없어서 기록으로 남긴다.


1. Render 배포가 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는 대부분 "포트 설정"이 아니라 "기동 실패"다.
    로그의 스택트레이스부터 봐야 한다.
  • 같은 값을 Dockerfile과 yml 두 군데서 잡으면 나중에 헷갈린다. 한 곳으로.

2. @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로 반영.

배운 점

  • "로컬 H2 / 운영 PostgreSQL" 이중화는 편하지만, 방언 차이(타입 매핑, 함수)가 배포 때 터진다.
  • 긴 문자열은 @Lob 대신 @Column(columnDefinition = "TEXT")가 안전하다.
    (또는 @JdbcTypeCode(SqlTypes.LONGVARCHAR))

3. 빈 운영 DB + ddl-auto: validate → missing table

원인

운영 프로필은 spring.jpa.hibernate.ddl-auto: validate. 즉 스키마를 만들어 주지 않고
엔티티와 실제 테이블이 맞는지 검증만
한다. Neon에 갓 만든 빈 DB에는 테이블이 하나도 없으니
검증에서 바로 실패 → 기동 불가 → (1번의 No open ports).

해결 (이때는 수동)

  1. Neon 콘솔 → SQL Editor에서 전체 스키마 스크립트(schema-postgresql.sql) 1회 실행
  2. 시드 데이터 수동 insert:
    • 약관 3종(terms) — 회원가입 약관 동의 검증에 필요
    • 관리자 계정 1개 — LocalDataInitializer는 local 전용이라 운영엔 시드가 안 들어감

관리자 비밀번호 해시는 프로젝트의 BCryptPasswordEncoder로 직접 생성해서 넣었다
(운영 DB에 평문을 넣을 수 없으니). 정책(8~64자, 영문·숫자·특수문자)에 맞는 랜덤 비번 생성 →
BCrypt 해시 → INSERT INTO member (...).

배운 점

  • ddl-auto: validate는 운영에서 옳은 선택이지만, 스키마를 누가 만들 것인가를 같이 정해야 한다.
    안 그러면 첫 배포에서 무조건 막힌다.
  • 이 수동 과정이 배포마다 반복될 게 뻔해서 → 4번(Flyway)으로 이어졌다.

4. 매번 손으로 ALTER TABLE 치기 싫다 → Flyway 도입

문제

백엔드에서 엔티티에 컬럼이 하나 추가될 때마다(member에 easy_mode, font_scale 추가 등)
배포 전에 Neon 콘솔에서 ALTER TABLE을 손으로 쳐야 했다. validate라서 안 치면 기동 실패.
팀 작업이라 놓치기 쉽고 위험.

해결: Flyway

  • 의존성: 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

이미 운영 중인 DB에 Flyway를 처음 붙일 때 포인트

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 콘솔 열 일이 없어졌다.

삽질: H2 스모크 테스트에서 걸린 것

로컬엔 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를 넣는 게 낫다.
  • 이미 데이터가 있는 DB에 마이그레이션 도구를 처음 붙일 땐 baseline 개념을 이해해야 한다.
  • 마이그레이션 SQL은 벤더 특화 문법을 피하고 문장을 잘게 쪼갠다.

5. "환경변수 넣었는데 왜 안 돼?" — 프론트-백엔드 연동 토대가 없었다

증상

Vercel에 VITE_API_BASE_URL을 넣고 재배포했는데 아무 변화가 없었다.

원인

프론트에 그 변수를 읽는 코드 자체가 없었다. import.meta.env.VITE_API_BASE_URL을
참조하는 곳도, fetch/axios 호출도 하나도 없었다. 세션도 가짜(DEMO_USERS 하드코딩)였다.
즉 "연동"이라고 부를 게 없는 상태에서 환경변수만 넣은 것.

해결: API 계층부터 깔기

  • lib/api-client.ts — apiFetch() 래퍼
    • baseURL(VITE_API_BASE_URL) + JSON 직렬화
    • Authorization: Bearer 자동 첨부
    • 401 → refresh 토큰으로 /api/auth/reissue 1회 재시도 후 재요청 (동시에 터진 401은
      재발급 1번으로 합침)
    • 실패는 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 환경변수는 빌드 시점에 번들에 문자열로 박힌다. 그래서:
    • 변수를 바꾸면 재배포해야 반영됨
    • 배포된 JS 번들을 열어보면 어떤 값이 박혔는지 grep으로 확인 가능
  • "환경변수를 넣었다"와 "그 값을 쓰는 코드가 있다"는 별개다.

6. Vercel의 VITE_ 접두어 경고

상황

Vercel이 VITE_API_BASE_URL에 대해 이런 경고를 띄웠다:

"Remove the public framework prefix to keep this value private.
Public prefixes expose values to the browser."

결론: 무시해도 된다

  • Vite는 VITE_ 접두어가 붙은 변수만 클라이언트 코드에 노출한다. 접두어를 떼거나
    "Config"(비공개)로 바꾸면 프론트가 값을 못 읽어서 연동이 통째로 깨진다.
  • 값이 그냥 공개 API URL(https://...onrender.com)이라 브라우저에 노출돼도 안전하다.
    시크릿(DB 비번, API 키)은 애초에 프론트 환경변수에 두지 않는다.
  • Vercel 경고는 "공개 접두어에 시크릿 넣지 마라"는 일반 주의문일 뿐, 공개 URL엔 해당 없음.

7. Cloudflare R2 (S3 호환) 파일 스토리지 붙이기

배경

파일 업로드가 stub 구현체라서 가짜 URL(https://files.example.invalid)만 반환하고 있었다.
자격증 이미지·사업자등록증·프로필 사진이 실제로 저장이 안 됨.

구현

  • StubFileStorageService는 그대로 두고 R2FileStorageService 추가
  • @ConditionalOnProperty(prefix="carematch.storage", name="provider", havingValue="r2")로 전환
    (stub은 matchIfMissing=true)
  • R2는 S3 호환이라 AWS SDK v2(software.amazon.awssdk:s3) 그대로 사용
    • 업로드: S3Presigner.presignPutObject (presigned PUT URL)
    • 확인: S3Client.headObject로 존재/크기/타입 검증
    • 다운로드: presignGetObject (TTL, 영구 공개 URL 금지)

R2 특유의 함정

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은 아무 값이나 필요 → auto
  • endpointOverride로 R2 계정 엔드포인트 지정
  • pathStyleAccessEnabled(true) + chunkedEncodingEnabled(false)가 presigned PUT에서 중요

놓치기 쉬운 것: R2 버킷 CORS

프론트는 백엔드가 준 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
  }
]

E2E 검증

배포 후 실제로 돌려봤다:

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

배운 점

  • S3 호환 스토리지(R2/MinIO 등)는 AWS SDK를 그대로 쓰되 endpointOverride + region("auto") +
    path-style + chunked encoding off 조합을 기억해두면 편하다.
  • 브라우저 직접 업로드 구조에서는 스토리지 버킷의 CORS를 잊지 말 것. (백엔드 CORS와 별개)
  • 리소스 이름 헷갈림 주의: API 토큰 이름과 버킷 이름을 다르게 만들었다가 STORAGE_BUCKET을
    잘못 넣을 뻔했다.

삽질 하나 더: WSL에 JDK가 없었다

./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} + 로그 스택트레이스 확인
문자열 컬럼이 oidHibernate 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 전환

배포 연동에서 반복해서 느낀 것

  1. 에러 메시지를 표면 그대로 믿지 말 것. No open ports는 포트 문제가 아니었고,
    missing table은 코드가 아니라 인프라 상태 문제였다.
  2. 로컬(H2)과 운영(PostgreSQL)의 차이가 배포 시점에 몰려서 터진다. 타입 매핑, 스키마 관리,
    방언 함수. 가능하면 운영과 같은 DB로 한 번은 돌려봐야 한다.
  3. "설정했다"와 "그 설정을 쓰는 코드가 있다"는 다르다. (환경변수, 스토리지 provider 등)
  4. 인프라 경계마다 CORS가 따로 있다. 백엔드 CORS ≠ 스토리지 버킷 CORS.
  5. 검증은 반드시 라이브 환경에서 실제 요청으로. 빌드 통과 ≠ 동작.

0개의 댓글