실제 배포 시 고려해야 될 문제 가이드

⚠️ 참고: 예시의 doll-gacha, dollgacha 이름은 이전 프로젝트 참조입니다. 실제 프로젝트에 맞게 변경하세요.

📌 프로젝트 구조 요약

기술 스택

🚨 배포 환경에서 확인해야 할 문제들

1. 환경변수 설정 (Railway Variables)

Railway에서 반드시 설정해야 하는 환경변수: ⚠️ 체크포인트:

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 (자동) | ⬜ |

⏰ 주의사항


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())
4. 기존 설정의 문제
[SecurityConfig]
  └── cors(Customizer.withDefaults())  
        └── CorsConfigurationSource Bean 찾기
              └── 없음! ❌ (WebMvcConfigurer는 Bean이 아님)
                    └── "Invalid CORS request" 에러!

[CorsConfig implements WebMvcConfigurer]  
  └── DispatcherServlet에 등록됨
        └── Security 필터 이후에 동작 → 이미 거부당함! 🚫
5. 같은 도메인인데 왜 CORS 체크?

해결 방법 (적용됨 ✅)

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가 필요한가?

해결 방법 (적용됨 ✅)

환경변수로 분기 처리:

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()  // 모두 허용됨

⚠️ 민감한 정보 노출 가능:

권장 설정

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     ← 엑셀 파일
          └── ...             ← 모든 첨부파일!
⚠️ 치명적 단점
도커 재배포 흐름:
┌─────────────────────────────────────────────────────────┐
│  코드 수정 → 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 설정 필요 사항
  1. Storage → uploads 버킷 생성
  2. Public bucket: ON
  3. Policies → INSERT, SELECT 허용 (anon)