백엔드트러블슈팅갈아엎기 시리즈

JWT default_secret · CORS * · BCrypt 10 한 번에 갚기, Gemini 키 노출 이후 Groq 로 옮긴 이야기 — POP-SPOT v1.1

김동현
··5분 읽기

Gemini 키 노출로 quota 0 사고 + 시크릿 평문 7 종 세트. JWT 32바이트 강제, CORS 화이트리스트, BCrypt 12, Bucket4j, 서버 결제 재검증, Oracle→PostgreSQL, Groq 까지 한 번에 정리.

v1.0 에서 미뤄둔 일곱 가지 빚을 한 번에 갚은 버전이다. 발단은 깃에 올라간 Gemini 키가 자동 차단되어 AI 호출이 통째로 죽은 사건이었음.

이 글에서 다루는 것

  • 시크릿 평문 커밋 사고가 어떻게 시작되어 어디서 멈췄는지
  • JWT 시크릿 32 바이트 강제, CORS 패턴 화이트리스트, BCrypt strength 10 → 12 의 의미
  • Oracle 의 Sequence 직접 관리에서 PostgreSQL 의 IDENTITY 자동 채번으로 옮긴 이유
  • Gemini Free (분당 10 회) 에서 Groq llama-3.3-70b (분당 30 회) 로 LLM 이사 간 결정
  • Bucket4j 로 로그인·이메일 호출에 분당 한도 거는 법
  • 모르는 단어 한 줄로

    용어한 줄 설명
    HS256JWT 서명 방식 중 하나. 같은 비밀 키 한 개로 서명·검증한다
    Bucket4j자바용 호출 한도(Rate Limit) 라이브러리. "분당 5 회까지" 같은 정책을 코드로 표현
    FlywayDB 스키마 변경을 버전 파일로 관리하는 도구. 누가 언제 어떤 컬럼을 추가했는지 추적된다
    IDENTITY (PostgreSQL)PK 컬럼을 자동 채번. Oracle 의 Sequence 직접 호출이 필요 없게 된다
    Sentry코드에서 던진 에러를 한 곳에 모아 보는 외부 서비스
    MicrometerJVM/HTTP 상태를 표준 메트릭으로 노출하는 라이브러리

    무엇이 바뀌었나

    분류v1.0v1.1
    DBOracle · 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/시간
    BCryptstrength 10strength 12
    결제클라이언트 응답만 신뢰서버에서 PortOne 재조회 + 자동 환불
    관측없음Sentry (에러) · Micrometer (메트릭)
    AIGemini Free · RPM 10Groq 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 요청까지 가능해서 자동수집까지 받아낼 여유가 됨.


    코드로는 어떻게 (필요한 부분만)

    javascript
    # v1.0 — application.properties 에 평문 키 그대로
    jwt.secret=default_secret              # 라이브러리 디폴트. 누구나 동일 값 추측 가능
    gemini.api-key=AIzaSy_실제키_평문      # 깃에 올라가면 Google 스캐너가 자동 차단
    spring.datasource.password=1234         # DB 비밀번호. 레포 접근자에게 노출
    java
    # 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 (시크릿 부팅 검증)

    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() 메서드

    java
    // v1.0 — 정확 일치 하는 origin 만 허용
    config.setAllowedOrigins(List.of("https://popspot.vercel.app"));
    //     ^^^^^^^^^^^^^^^^^^         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //     |                          이 하나만 허용. preview-abc.vercel.app 은 차단
    //     +-- 문자열 완전 일치 검사
    // 결과: Vercel 이 PR 마다 자동 생성하는 preview 주소가 전부 차단됨
    java
    // v1.1 — 패턴 매칭. 와일드카드 * 허용
    config.setAllowedOriginPatterns(List.of(
        "https://popspot.co.kr",      // 운영 도메인
        "https://*.vercel.app"        // *.vercel.app — 모든 Vercel preview 도메인 자동 일치
    //             ^ 하위 도메인 한 레벨 와일드카드
    ));
    // setAllowedOriginPatterns 는 setAllowedOrigins 와 달리 패턴 해석 지원
    // credentials: include 와도 공존 가능 (Origins 는 credentials 와 공존이 안 됨)

    환경변수 파싱은 , 로 자르고 반드시 trim(). 쉼표 뒤 공백 한 글자가 도메인 매칭을 깬다.

    java
    // 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

    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 한도까지 늘릴 수 있어서 그쪽을 택했다.

    교훈 한 줄. 시크릿은 한 번 깃에 올라가면 "이미 유출된 것" 으로 간주하고 즉시 폐기·재발급한다. 깃 히스토리 정리는 그다음 일이다.


    관련 글

  • 이전 — v1.0, 첫 배포에 깔린 보안 빚
  • 다음 — v1.2, GCP Free Tier 만료 D-25 와 친구 NAS 이전
  • 공유

    댓글