실제 배포 시 고려해야 될 문제 가이드
⚠️ 참고: 예시의
doll-gacha,dollgacha이름은 이전 프로젝트 참조입니다. 실제 프로젝트에 맞게 변경하세요.
📌 프로젝트 구조 요약
기술 스택
- Backend: Spring Boot 3.3.4, Java 17
- Database: MariaDB
- 인증: JWT + OAuth2 (카카오, 구글)
- ORM: JPA + QueryDSL
- 템플릿: Thymeleaf (빈 HTML 제공, 데이터는 JS에서 REST API 호출 — CSR 방식)
- 실시간: WebSocket (STOMP + SockJS)
- 빌드: Gradle
- 배포: Docker + Railway (2번 방식: Railway가 GitHub repo로 Docker 이미지 빌드 + 실행)
🚨 배포 환경에서 확인해야 할 문제들
1. 환경변수 설정 (Railway Variables)
Railway에서 반드시 설정해야 하는 환경변수: ⚠️ 체크포인트:
.env파일은 로컬용. Railway에서는 Variables 탭에서 설정-
spring-dotenv라이브러리가.env파일 읽음 (로컬만)
2. OAuth2 Redirect URI 문제 ⭐⭐⭐
가장 흔한 배포 오류!
현재 설정 (application-prod.yml)
redirect-uri: ${APP_BASE_URL:http://localhost:8080}/login/oauth2/code/kakao
redirect-uri: ${APP_BASE_URL:http://localhost:8080}/login/oauth2/code/google
해야 할 일
카카오 개발자 , 구글 클라우드 콘솔에서 redirect-uri 추가 —
2-1. 커스텀 DNS 도메인 적용 시 체크리스트 ⭐⭐⭐
예시:
https://www.dollgacha.shop커스텀 도메인 사용 시
🔧 1. Railway 설정
1-1. 커스텀 도메인 연결
Railway Dashboard → Settings → Domains → Add Custom Domain
→ www.dollgacha.shop 입력
→ Railway가 제공하는 CNAME 값 복사
1-2. DNS 레코드 설정 (도메인 구매한 곳)
| 타입 | 호스트 | 값 |
|——|——-|—–|
| CNAME | www | Railway가 제공한 값 (예: xxx.up.railway.app) |
| A 또는 URL Redirect | @ (루트) | www로 리다이렉트 권장 |
1-3. 환경변수 변경
Railway Dashboard → Variables
APP_BASE_URL = https://www.dollgacha.shop
🔐 2. 카카오 개발자 콘솔 (https://developers.kakao.com)
2-1. 플랫폼 도메인 추가
내 애플리케이션 → 앱 설정 → 플랫폼 → Web
사이트 도메인 추가:
- https://www.dollgacha.shop
- https://dollgacha.shop
2-2. Redirect URI 추가
카카오 로그인 → Redirect URI
추가: https://www.dollgacha.shop/login/oauth2/code/kakao
🔐 3. 구글 클라우드 콘솔 (https://console.cloud.google.com)
APIs & Services → Credentials → OAuth 2.0 Client IDs → 편집
Authorized JavaScript origins 추가:
- https://www.dollgacha.shop
- https://dollgacha.shop
Authorized redirect URIs 추가:
- https://www.dollgacha.shop/login/oauth2/code/google
📋 최종 체크리스트
| 순서 | 항목 | 위치 | 완료 |
|——|——|——|——|
| 1 | 커스텀 도메인 연결 | Railway Settings → Domains | ⬜ |
| 2 | DNS CNAME 레코드 설정 | 도메인 업체 (가비아, 후이즈 등) | ⬜ |
| 3 | APP_BASE_URL 환경변수 변경 | Railway Variables | ⬜ |
| 4 | 플랫폼 도메인 추가 | 카카오 개발자 콘솔 | ⬜ |
| 5 | Redirect URI 추가 | 카카오 개발자 콘솔 | ⬜ |
| 6 | Authorized origins 추가 | 구글 클라우드 콘솔 | ⬜ |
| 7 | Redirect URI 추가 | 구글 클라우드 콘솔 | ⬜ |
| 8 | SSL 인증서 발급 확인 | Railway (자동) | ⬜ |
⏰ 주의사항
- DNS 전파에 최대 24시간 소요될 수 있음 (보통 10분~1시간)
- Railway가 자동으로 SSL 인증서 발급 (Let’s Encrypt)
- 기존 Railway 도메인(
xxx.up.railway.app)도 계속 사용 가능
3. CORS 설정 (CorsConfig.java) - ✅ 수정 완료
문제 상황
Login error: SyntaxError: Unexpected token 'I', "Invalid CORS request" is not valid JSON
서버가 분리 되지도 않았는데 왜 CORS가 뜰까 하는 문제..
🔍 문제 발생 원인 상세 분석
1. Spring에서 CORS 처리하는 2가지 방법
| 방법 | 동작 위치 | Security 인식 |
|——|———-|————–|
| WebMvcConfigurer | DispatcherServlet (늦음) | ❌ |
| CorsConfigurationSource Bean | Security Filter (빠름) | ✅ |
2. 요청 처리 순서
요청 → [Security 필터 체인] → [DispatcherServlet] → Controller
↑ ↑
CorsFilter 여기! WebMvcConfigurer 여기!
(먼저 실행됨) (너무 늦음)
3. cors(Customizer.withDefaults())의 동작
// SecurityConfig.java
http.cors(Customizer.withDefaults())
- CorsFilter를 Security 필터 체인에 추가
- CorsConfigurationSource Bean을 찾아서 사용
- Bean이 없으면 → 기본 설정 사용 (모든 요청 거부!)
4. 기존 설정의 문제
[SecurityConfig]
└── cors(Customizer.withDefaults())
└── CorsConfigurationSource Bean 찾기
└── 없음! ❌ (WebMvcConfigurer는 Bean이 아님)
└── "Invalid CORS request" 에러!
[CorsConfig implements WebMvcConfigurer]
└── DispatcherServlet에 등록됨
└── Security 필터 이후에 동작 → 이미 거부당함! 🚫
5. 같은 도메인인데 왜 CORS 체크?
- 브라우저 CORS: 같은 Origin이면 CORS 헤더 안 보냄 ✅
- Spring CorsFilter: 요청 들어오면 무조건 설정 확인 → 없으면 거부!
- “Invalid CORS request”는 브라우저 에러가 아니라 서버 측 필터의 거부!
해결 방법 (적용됨 ✅)
CorsConfigurationSource Bean으로 변경:
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList(
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:8080",
"https://dollgacha-production.up.railway.app" // 운영 도메인 추가
));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
핵심 결론
Spring Security에서 cors(Customizer.withDefaults()) 사용 시
→ 반드시 CorsConfigurationSource Bean 등록 필수!
→ WebMvcConfigurer 방식은 Security 필터보다 늦게 동작해서 무용지물!
4. 쿠키 Secure 설정 - ✅ 수정 완료
OAuth2LoginSuccessHandler,JwtLoginFilter,CustomLogoutSuccessHandler모두 적용됨🔍 쿠키 Secure 설정이란?
| 설정 | 의미 | |——|——| |
secure(false)| HTTP, HTTPS 모두에서 쿠키 전송 | |secure(true)| HTTPS에서만 쿠키 전송 (HTTP에서는 브라우저가 쿠키 안 보냄) |
왜 운영에서 true가 필요한가?
- 보안 문제:
secure(false)→ 쿠키가 HTTP 평문으로 전송될 수 있음 - 중간자 공격(MITM)으로 JWT 토큰 탈취 가능
- Railway 운영 환경은 HTTPS이므로
secure(true)설정해도 정상 동작 - 로컬 환경은 HTTP이므로
secure(true)설정하면 쿠키가 전송 안 됨 → 로그인 실패
해결 방법 (적용됨 ✅)
환경변수로 분기 처리:
application.yml (로컬):
app:
cookie:
secure: false # HTTP이므로 false
application-prod.yml (운영):
app:
cookie:
secure: true # HTTPS이므로 true
OAuth2LoginSuccessHandler.java:
@Value("${app.cookie.secure:false}")
private boolean secureCookie; // 로컬: false, 운영(HTTPS): true
// 쿠키 생성 시
.secure(secureCookie) // 환경변수로 제어
JwtLoginFilter는 Spring Bean이 아니므로 SecurityConfig에서 생성자 파라미터로secureCookie전달CustomLogoutSuccessHandler는@Component이므로@Value직접 사용
핵심 결론
로컬 (HTTP) → secure: false → 쿠키 정상 전송 ✅
운영 (HTTPS) → secure: true → 쿠키 암호화 전송 + 보안 ✅
5. Actuator 보안
현재 설정
management:
endpoints:
web:
exposure:
include: health, info, metrics
.requestMatchers("/actuator/**").permitAll() // 모두 허용됨
⚠️ 민감한 정보 노출 가능:
/actuator/health- 서버 상태 (OK)/actuator/info- 앱 정보/actuator/metrics- 메트릭 정보 (주의)
권장 설정
management:
endpoints:
web:
exposure:
include: health, info # metrics 제외
endpoint:
health:
show-details: never # 상세 정보 숨김
6. Swagger 운영 환경 비활성화
현재 /swagger-ui.html 접근 가능 (permitAll)
권장: 운영에서 Swagger 비활성화
application-prod.yml 추가:
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false
7. 파일 저장 전략 (CDN / 외부 스토리지) ⭐⭐⭐
적용 대상: 이미지, PDF, 문서 등 모든 사용자 업로드 첨부파일
🔍 문제 상황: 도커 재배포 시 파일 소실
기존 방식 (서버 내부 폴더 저장)
[Spring Boot 서버]
└── /uploads/
├── image1.png ← 이미지
├── document.pdf ← PDF 문서
├── report.xlsx ← 엑셀 파일
└── ... ← 모든 첨부파일!
- 작동: 서버 내부의 특정 경로(
/uploads)에 파일을 직접 저장 - DB에는: 서버 안의 파일 경로 기록 (
/uploads/image1.png)
⚠️ 치명적 단점
도커 재배포 흐름:
┌─────────────────────────────────────────────────────────┐
│ 코드 수정 → GitHub Push → 새 Docker 이미지 빌드 │
│ ↓ │
│ 기존 컨테이너 삭제 🗑️ → 새 컨테이너 생성 │
│ ↑ │
│ /uploads 폴더도 함께 삭제됨! 💀 │
└─────────────────────────────────────────────────────────┘
결과:
- DB: "image1.png 경로는 /uploads/image1.png" ← 데이터 남아있음
- 실제 파일: 없음! ← 컨테이너와 함께 삭제됨
- 사용자: 이미지/문서 깨짐 🖼️📄❌
도커의 근본적 스펙
| 개념 | 설명 | |——|——| | Docker 이미지 | JAR + 설정파일 등 (빌드 결과물, 읽기 전용) | | Docker 컨테이너 | 이미지를 실행한 임시 가상 컴퓨터 (EC2 같은 것) | | 재배포 시 | 기존 컨테이너 삭제 → 새 컨테이너 생성 |
핵심: 컨테이너 안에 저장된 모든 파일(이미지, PDF, 문서 등)은 재배포 시 소실
✅ 해결 방법: 외부 스토리지 (CDN) 사용
정적 자원 서빙 방식
[사용자] ──파일 업로드──→ [Spring Boot 서버]
│
↓ 파일 저장 (이미지, PDF, 문서 등 모든 첨부파일)
[AWS S3 / Cloudflare R2]
│
↓ URL 반환
https://cdn.example.com/image1.png
│
↓ DB 저장
[MariaDB] ← URL만 저장!
[사용자] ──파일 요청──→ [CDN] ──직접 응답──→ (서버 안 거침!)
이게 또 controller를 통해 파일을 보여주기 안해도 되니까 사용자입장에서 훨씬 빠르게 파일 접근.. 파일 서빙하는건 CDN이니까. 이 때 파일다운로드도 기존 컨트롤러 다운로드 대신 외부 CDN 통해서..
적용 대상 파일 종류
| 파일 유형 | 예시 | CDN 적용 | |———-|——|———| | 이미지 | .png, .jpg, .gif, .webp | ✅ | | 문서 | .pdf, .docx, .xlsx | ✅ | | 기타 첨부파일 | .zip, .csv 등 | ✅ |
결론: 사용자가 업로드하는 모든 파일은 외부 스토리지에 저장해야 함!
🛠️ 외부 스토리지 선택 (둘 다 CDN 방식!)
Supabase Storage vs Cloudflare R2
| 항목 | Supabase Storage | Cloudflare R2 | |——|———————|——————| | CDN 방식 | ✅ 맞음! | ✅ 맞음! | | 무료 저장 | 1GB | 10GB | | 무료 전송(egress) | 2GB/월 | 무제한! 🎉 | | 설정 난이도 | ⭐ 쉬움 | ⭐⭐ 중간 | | Spring Boot 연동 | REST API 호출 | S3 SDK 사용 | | 이미지 변환 | ✅ 리사이즈/포맷 변환 내장 | ❌ 없음 | | RLS (행 수준 보안) | ✅ 지원 | ❌ 없음 |
CDN URL 예시
Supabase: https://xxx.supabase.co/storage/v1/object/public/bucket/image.png
R2: https://pub-xxx.r2.dev/image.png
둘 다 서버 안 거치고 바로 파일 제공! (CDN)
선택 기준
✅ Supabase 추천:
- 설정 쉬움, 빨리 구현하고 싶음
- 이미지 리사이즈 필요
- 저장 1GB, 전송 2GB 이하로 충분
✅ Cloudflare R2 추천:
- 트래픽 예측 불가 (전송 비용 0원)
- 저장 용량 여유 필요 (10GB)
- S3 표준으로 나중에 AWS 이전 쉬움
현재 상태: ✅ Supabase Storage 적용 완료
🛠️ 구현 상세 (Supabase Storage)
전략 패턴 적용 (환경별 자동 분기)
FileStorageStrategy (인터페이스)
├── LocalFileStorage ← supabase.enabled=false (로컬)
└── SupabaseFileStorage ← supabase.enabled=true (운영)
Spring이 @ConditionalOnProperty로 환경에 맞는 구현체 자동 주입!
파일 구조
file/
├── strategy/
│ ├── FileStorageStrategy.java (인터페이스)
│ ├── LocalFileStorage.java (로컬 구현체)
│ └── SupabaseFileStorage.java (Supabase 구현체)
├── util/
│ └── FileUtil.java (전략 패턴 사용)
└── service/
└── FileService.java (DB 저장)
동작 방식
| 기능 | 로컬 (HTTP) | 운영 (HTTPS + Supabase) |
|——|————|————————|
| 파일 저장 | /uploads/ 폴더 | Supabase Storage |
| 이미지 보기 | 서버 경유 | CDN 직접 (빠름!) |
| 파일 다운로드 | 서버 API 경유 | 서버 API 경유 (원본 파일명 유지) |
| DB 저장 | /uploads/xxx.png | https://xxx.supabase.co/.../xxx.png |
다운로드 시 원본 파일명 유지 방법
문제: CDN URL로 직접 다운로드 시 UUID 파일명으로 저장됨
해결: 서버 API(/api/files/{id}/content)가 CDN에서 파일 가져와서
Content-Disposition 헤더로 원본 파일명 설정 후 응답
필요한 환경변수 (Railway Variables)
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_ANON_KEY=eyJhbGciOi...
Supabase 설정 필요 사항
- Storage →
uploads버킷 생성 - Public bucket: ON
- Policies → INSERT, SELECT 허용 (anon)