JWT default_secret · CORS * · BCrypt 10 한 번에 갚기, Gemini 키 노출 이후 Groq 로 옮긴 이야기 — POP-SPOT v1.1
Gemini 키 노출로 quota 0 사고 + 시크릿 평문 7 종 세트. JWT 32바이트 강제, CORS 화이트리스트, BCrypt 12, Bucket4j, 서버 결제 재검증, Oracle→PostgreSQL, Groq 까지 한 번에 정리.
v1.0 에서 미뤄둔 일곱 가지 빚을 한 번에 갚은 버전이다. 발단은 깃에 올라간 Gemini 키가 자동 차단되어 AI 호출이 통째로 죽은 사건이었음.
이 글에서 다루는 것
모르는 단어 한 줄로
| 용어 | 한 줄 설명 |
|---|---|
| HS256 | JWT 서명 방식 중 하나. 같은 비밀 키 한 개로 서명·검증한다 |
| Bucket4j | 자바용 호출 한도(Rate Limit) 라이브러리. "분당 5 회까지" 같은 정책을 코드로 표현 |
| Flyway | DB 스키마 변경을 버전 파일로 관리하는 도구. 누가 언제 어떤 컬럼을 추가했는지 추적된다 |
| IDENTITY (PostgreSQL) | PK 컬럼을 자동 채번. Oracle 의 Sequence 직접 호출이 필요 없게 된다 |
| Sentry | 코드에서 던진 에러를 한 곳에 모아 보는 외부 서비스 |
| Micrometer | JVM/HTTP 상태를 표준 메트릭으로 노출하는 라이브러리 |
무엇이 바뀌었나
| 분류 | v1.0 | v1.1 |
|---|---|---|
| DB | Oracle · Sequence 수동 | PostgreSQL · IDENTITY 자동 + Flyway |
| 시크릿 | application.properties 평문 + 깃 커밋 | <code>${ENV:}</code> 환경변수만 참조, 누락 시 부팅 실패 |
| JWT | <code>default_secret</code> 그대로 | HS256 · 32 바이트 이상 강제, 부팅 시 검증 |
| CORS | <code>*</code> | 패턴 화이트리스트 (<code>https://*.vercel.app</code>, 도메인) |
| Rate Limit | 없음 | Bucket4j — 로그인 5/분, 이메일 5/시간 |
| BCrypt | strength 10 | strength 12 |
| 결제 | 클라이언트 응답만 신뢰 | 서버에서 PortOne 재조회 + 자동 환불 |
| 관측 | 없음 | Sentry (에러) · Micrometer (메트릭) |
| AI | Gemini Free · RPM 10 | Groq llama-3.3-70b · RPM 30 |
왜 이렇게 했음
시크릿 — v1.0 에서 깃에 올라간 Gemini 키가 Google 의 자동 스캐너에 잡혀 프로젝트 quota 가 0 으로 강제 변경된 사건이 있었다. 새 키를 발급해도 같은 프로젝트면 차단이 풀리지 않음. 그래서 키 자체를 분리해서 코드 밖으로 빼는 동시에, 누락된 상태로 부팅이 통과되지 않게 막아야 한다고 판단함.
JWT — 32 바이트 미만이면 HS256 의 강도 가정이 깨진다. 라이브러리 디폴트 (default_secret) 는 누구나 같은 값으로 토큰을 만들 수 있었다는 뜻이므로 사실상 인증 없음이나 마찬가지였음.
CORS — * 는 "누구나 호출 가능" 이라서 CSRF 등 다른 공격 표면이 같이 열린다. Vercel 의 preview URL 까지 허용하려면 정확 일치로는 부족했고, 패턴 매칭으로 바꿔야 했음.
Rate Limit — 무료 한도 외에는 막을 게 없다는 안일함이 있었다. 로그인을 분당 5 회로 묶지 않으면 자동화된 사전 대입 (=흔히 쓰는 비밀번호 목록을 빠르게 시도) 에 무방비.
Oracle → PostgreSQL — Sequence 를 손으로 가져와 INSERT 채워주는 패턴이 너무 번거롭고 ORA-01400 (NULL 못 들어감) 사고를 두 번이나 만들었다. IDENTITY 로 옮기면서 동시에 스키마 변경을 Flyway 로 관리하기 시작함.
Gemini → Groq — Gemini Free 의 RPM 10 은 자동수집 시즌이 되면 부족해질 게 보였고, 무엇보다 위 사고로 한 번 신뢰가 깨졌음. Groq 는 무료 한도 RPM 30, 일일 14,400 요청까지 가능해서 자동수집까지 받아낼 여유가 됨.
코드로는 어떻게 (필요한 부분만)
# v1.0 — application.properties 에 평문 키 그대로
jwt.secret=default_secret # 라이브러리 디폴트. 누구나 동일 값 추측 가능
gemini.api-key=AIzaSy_실제키_평문 # 깃에 올라가면 Google 스캐너가 자동 차단
spring.datasource.password=1234 # DB 비밀번호. 레포 접근자에게 노출# v1.1 — 환경변수만 참조. 디폴트를 일부러 비워 "누락 시 부팅 실패" 를 유도
jwt.secret=${JWT_SECRET:}
# ^^^^^^^^^^^^^ Spring 표현. 환경변수 JWT_SECRET 값을 읽음
# : 뒤에 아무것도 안 적으면 "디폴트 = 빈 문자열"
# 빈 문자열은 아래 @PostConstruct 에서 차단됨
groq.api-key=${GROQ_API_KEY:}
# ^^^^^^^^^^^^^^^^ Gemini 대신 Groq. 동일 패턴으로 키 분리
spring.datasource.password=${DB_PASSWORD:}
# ^^^^^^^^^^^^^^^ DB 비밀번호도 환경변수로파일: popspot-backend/src/main/java/com/popspot/security/JwtProperties.java (시크릿 부팅 검증)
// v1.1 — 애플리케이션 부팅 직후 시크릿 검증
@PostConstruct // Spring 이 빈 초기화 끝낸 직후 1회 실행
void validateSecret() {
if (secret == null || secret.getBytes().length < 32) {
// ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^
// | | UTF-8 기준 바이트 수 (한글 1자=3바이트)
// +-- 환경변수 비어있으면 ${JWT_SECRET:} 의 디폴트 빈문자열 → null
throw new IllegalStateException("JWT_SECRET 은 32 바이트 이상이어야 함");
// 예외 던지면 Spring 부팅 자체가 멈춤. 시크릿 설정 안 된 채로 서비스가 뜨지 않게 하는 안전장치
}
}
// 32바이트 = HS256 수학적 안전성 최소치. 이 미만이면 brute-force 키 추측이 이론상 가능CORS 는 정확 일치 → 패턴 매칭으로 옮긴다. *.vercel.app 같은 와일드카드 대응을 위해 필수.
파일: popspot-backend/src/main/java/com/popspot/config/SecurityConfig.java 안 의 corsConfigurationSource() 메서드
// v1.0 — 정확 일치 하는 origin 만 허용
config.setAllowedOrigins(List.of("https://popspot.vercel.app"));
// ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// | 이 하나만 허용. preview-abc.vercel.app 은 차단
// +-- 문자열 완전 일치 검사
// 결과: Vercel 이 PR 마다 자동 생성하는 preview 주소가 전부 차단됨// v1.1 — 패턴 매칭. 와일드카드 * 허용
config.setAllowedOriginPatterns(List.of(
"https://popspot.co.kr", // 운영 도메인
"https://*.vercel.app" // *.vercel.app — 모든 Vercel preview 도메인 자동 일치
// ^ 하위 도메인 한 레벨 와일드카드
));
// setAllowedOriginPatterns 는 setAllowedOrigins 와 달리 패턴 해석 지원
// credentials: include 와도 공존 가능 (Origins 는 credentials 와 공존이 안 됨)환경변수 파싱은 , 로 자르고 반드시 trim(). 쉼표 뒤 공백 한 글자가 도메인 매칭을 깬다.
// v1.1 — 환경변수 파싱 (쉼표 구분 주소 목록)
Arrays.stream(allowedOriginsRaw.split(","))
// ^^^^^^^^^^ ", " 가 아닌 "," 만 자름
// 따라서 " https://b.com" 처럼 앞에 공백 붙은 값이 나올 수 있음
.map(String::trim)
// ^^^^^^^^^^^^ 메서드 레퍼런스 — 각 문자열 앞·뒤 공백 제거
// 이게 없으면 " https://b.com" 으로 저장되어 도메인 매칭 실패
.filter(s -> !s.isEmpty())
// ^^^^^^^^^^^^^^^^ 빈 문자열 제거
// " , , https://b.com " 같은 입력 대응 (주소 없는 구간)
.toList();
// ^^^^^^^ 불변 List 로 수집 (Java 16+)Bucket4j 로 로그인 호출에 분당 5 회 한도.
파일: popspot-backend/src/main/java/com/popspot/security/RateLimitInterceptor.java
// v1.1 — Bucket4j 로 엔드포인트별 호출 한도 구성
Bandwidth limit = Bandwidth.classic(5, Refill.intervally(5, Duration.ofMinutes(1)));
// ^^^^^^^ ^ ^^^^^^^^^^^^^^^^ ^ ^^^^^^^^^^^^^^^^^^^^^
// | | | | |
// | | | | +-- 1분 주기
// | | | +----- 1분마다 버킷에 5개씩 리필
// | | +----------------------- intervally 방식: 주기마다 일괄 충전
// | +--------------------------- 버킷 용량 5 (최대 저장 가능한 토큰 수)
// +----------------------------------- 고전적 토큰버킷 알고리즘
Bucket bucket = Bucket.builder().addLimit(limit).build();
// ^^^^^ 위에서 설정한 대역을 버킷에 첨부
// 여러 addLimit() 도 가능 — 예: "초당 1회 + 분당 10회 + 시간당 100회"
if (!bucket.tryConsume(1)) {
// ^^^^^^^^^^^ 토큰 1개 소비 시도. 성공=true / 남은 토큰 0=false
// 설정 없이 계속 호출하면 처음 다섯 번까지는 성공, 이후 1분까지 false
return ResponseEntity.status(429).body("잠시 후 다시 시도");
// ^^^ 429 Too Many Requests — 호출 한도 초과 표준 코드
}
// email 별로 버킷을 따로 관리하려면 ConcurrentHashMap<String, Bucket> 을 쓴다
// v2.22 에서 이 맵이 무한 증가 위험이 있어 Caffeine 으로 교체핵심 파일: popspot-backend/src/main/java/.../config/SecurityConfig.java, RateLimitInterceptor.java
비하인드 · 사고
Gemini 차단을 풀려고 같은 프로젝트에서 새 키를 두 번 발급했음. 둘 다 처음부터 quota 0. 결국 새 GCP 프로젝트를 만들거나 LLM 을 갈아탈 수밖에 없었는데, 후자가 더 빠르고 동시에 RPM 한도까지 늘릴 수 있어서 그쪽을 택했다.
교훈 한 줄. 시크릿은 한 번 깃에 올라가면 "이미 유출된 것" 으로 간주하고 즉시 폐기·재발급한다. 깃 히스토리 정리는 그다음 일이다.