Kelog - 새로운 지식을 정리하는 개발 블로그

AWS WAF에서 특정 경로만 안전하게 허용하기
AWS WAF에서 특정 경로만 안전하게 허용하기

AWS WAF에서 /@username, /p/123 같은 경로만 안전하게 열려면, 경로 정규식을 좁게 쓰는 것보다 **규칙 순서와 rate-based 규칙**이 더 중요합니다. 이 글은 그 판단 과정을 정리한 것입니다. 문제 상황 DDoS 때문에 기본 액션을 Block으로 두고 필요한 것만 여는 웹 ACL에서, 아래 네 가지 경로 패턴을 국가·IP 조건과 상관없이 허용해야 했습니다. - /@{사용자명} - /p/{ID} - /v/{ID} - /s/{ID} 처음 든 질문은 두 가지였습니다. 와일드카드에 자릿수까지 제한을 걸면 더 안전한가, 그리고 이 경로를 Allow로 빼도 괜찮은가. 결론부터 말하면 자릿수 제한은 조금 도움이 되는 정도이고, 진짜 위험은 Allow 규칙이 이후 규칙을 통째로 건너뛰게 만든다는 점이었습니다. WAF에는 와일드카드가 없고, 정규식으로 표현합니다 /p/* 같은 식의 와일드카드 문법은 AWS WAF 규칙에 없습니다. 경로를 검사하는 방법은 크게 두 가지입니다. - **String match**: /p/로 시작하는지(starts with)처럼 문자열 위치 조건으로 비교합니다. 대소문자를 구분하므로 필요하면 LOWERCASE 같은 text transformation을 겁니다. - **Regex match / Regex pattern set**: 문자 범위와 자릿수를 제한하려면 이 방법만 가능합니다. AWS 문서에 따르면 AWS WAF는 PCRE(libpcre)의 패턴 문법을 **일부 예외와 함께** 지원합니다. 문서에 명시된 미지원 항목은 다음과 같습니다. - 역참조(backreference)와 측정 부분식(capturing subexpression) - 서브루틴 참조와 재귀 패턴 - 조건 패턴, 백트래킹 제어 동사 - \C, \R, \K 디렉티브 - 콜아웃과 내장 코드 - 원자적 그룹(atomic grouping)과 소유 수량자(possessive quantifier) zero-width assertion은 일부만 지원한다고 되어 있지만 정확한 범위를 적은 목록은 찾지 못했습니다. 지원되지 않는 문법을 쓰면 API가 오류를 반환하므로, 패턴은 저장 단계에서 검증됩니다. 정규식 패턴 세트에 패턴을 여러 개 넣으면 **OR**로 평가됩니다. 그래서 경로 형태마다 패턴을 나눠 넣는 방식이 괄호 없이 쓸 수 있어 안전합니다. 정규식 설계: 시작과 끝을 반드시 고정합니다 경로 정규식의 핵심은 앞뒤 앵커(^, $)와 허용 문자셋입니다. 실제 ID 형식에 맞게 고치세요. 아래는 예시입니다. ^/@[A-Za-z0-9_]{3,10}$ ^/[pvs]/[A-Za-z0-9_-]{1,40}$ - **$ 앵커**: 없으면 /p/123/../../admin 같은 경로도 앞부분만 맞으면 통과합니다. - **사용자명은 상한과 하한을 모두 서비스 규칙에 맞축니다**: {3,10}처럼 서비스가 실제로 받는 범위와 같아야 합니다. - **ID 길이는 하한을 느슬하게**: ID 길이가 나중에 늘어도 정상 유저가 막히지 않도록 상한만 정합니다. 하위 경로(/@abcde/contents, /@abcde/shorts)까지 한 줄로 쓰려면 선택 그룹을 쓰면 됩니다. ^/@[A-Za-z0-9_]{3,10}(?:/(?:contents|shorts|products))?/?$ 이 패턴은 /@abcde, /@abcde/, /@abcde/contents, /@abcde/contents/를 모두 허용합니다. 끝 슬래시를 허용하지 않으려면 /?를 빼면 됩니다. (?:...)가 미지원 목록의 "capturing subexpression"에 해당하는지는 문서만으로 확정하지 못했습니다. 저장이 안 되면 괄호 없이 최상위 |로 풀어 쓰거나 패턴을 나눠 넣으면 됩니다. ^/@[A-Za-z0-9_]{3,10}/?$|^/@[A-Za-z0-9_]{3,10}/contents/?$|^/@[A-Za-z0-9_]{3,10}/shorts/?$|^/@[A-Za-z0-9_]{3,10}/products/?$ 한글 등 다국어 사용자명을 허용한다면 퍼센트 인코딩된 값이 들어오므로 URL_DECODE 변환 후 문자셋에 해당 범위를 넣어야 합니다. URI path만 보면 쿼리 스트링은 상관없습니다 규칙의 검사 대상을 **URI path**로 지정하면 ? 뒤의 쿼리 스트링은 검사에 포함되지 않습니다. AWS 문서는 URL www.xyz.com/images/daily-ad.jpg?UserName=abcsection2를 예로 URI path(/images/daily-ad.jpg), 쿼리 스트링(UserName=abc), 프래그먼트(section2)를 별도 구성 요소로 나눠 설명합니다. 그래서 /@abcde?page=2나 /p/123?utm_source=x 같은 요청도 $ 앵커가 붙은 정규식에 매칭됩니다. 주의할 점이 두 가지 있습니다. - **검사 대상을 잘못 고르면 정상 요청이 막힙니다.** 전체 URI 같은 옵션을 고르면 쿼리가 포함되어 $ 때문에 쿼리가 붙은 요청이 전부 불일치합니다. - **쿼리 스트링은 이 규칙에서 아무 검증도 받지 않습니다.** 필요하면 쿼리 스트링 크기 조건을 AND로 추가해 상한을 둘 수 있습니다. 문서에는 text transformation을 쓰지 않으면 AWS WAF가 URI를 정규화하지 않고 클라이언트가 보낸 그대로 검사한다고 되어 있습니다. 그래서 %2e%2e 같은 인코딩 우회를 막으려면 URL_DECODE를 걸어 두는 편이 안전합니다. Allow는 종료 액션이라 규칙 순서가 방어를 좌우합니다 이 글에서 가장 중요한 내용입니다. AWS WAF의 Allow는 요청을 보호 대상 리소스로 넘기는 **종료(terminating) 액션**이고, 종료 액션이 실행되면 그 뒤 규칙은 평가하지 않습니다. AWS 모범 사례 가이드도 Allow 규칙은 뒤에 오는 규칙이 차단했을 요청까지 허용해 버릴 수 있어, 특히 상단에 둘 때 주의해야 한다고 설명합니다. 그러므로 /p/* 같은 경로를 Allow로 빼는 순간, 그 규칙보다 뒤에 있는 rate-based 규칙이나 Bot Control 같은 규칙은 해당 경로에 적용되지 않습니다. 유효한 형식의 경로로 대량 요청을 보내는 DDoS는 정규식으로 막을 수 없기 때문에, 자릿수를 맞춰도 그대로 통과합니다. 즉 정규식은 “어떤 요청이 후보인가”를 정하는 역할이고, 대량 요청 방어는 다른 규칙이 맡아야 합니다. 두 가지 설계 방향이 있습니다. 1. **"허용 패턴이 아니면 Block" 방식**: NOT 문장으로 경로 조건을 뒤집어 Block 규칙을 만듭니다. 허용 경로도 이후 규칙(rate-based 등)을 계속 거칩니다. NOT 문장은 내부 문장이 매칭되지 않을 때 매칭되는 논리 부정으로, 예를 들면 "경로가 허용 패턴이 아니면 Block"처럼 쓸 수 있습니다. 2. **Allow를 쓰되 rate-based 규칙을 그보다 앞에 둔다**: 다음 섹션의 구성입니다. 권장 규칙 구성: 경로 전용 rate-based 규칙 뒤에 Allow 경로를 국가·IP 필터보다 앞에서 허용해야 한다면, Allow 규칙 바로 앞에 **그 경로만 세는 rate-based Block 규칙**을 두세요. rate-based 규칙은 scope-down 문장으로 추적·제한 대상 요청을 좁힐 수 있습니다. 문서는 기본 WCU를 2, 사용자 지정 집계 키 1개당 30을 더하고 scope-down 문장의 WCU는 따로 더하라고 설명합니다. | 순서 | 규칙 | 액션 | 역할 | | --- | --- | --- | --- | | 1 | 악성 IP 차단 등 기존 방어 규칙 | Block | 알려진 나쁜 트래픽은 새 경로에 닿기 전에 차단 | | 2 | 경로 전용 rate-based 규칙 | Block | 4개 경로에 대해 IP당 요청 수 초과 시 차단 | | 3 | 경로 Allow 규칙 | Allow | 정규식과 GET 조건이 맞으면 허용, 이후 국가 필터를 건너뜀 | | 4 | 국가·IP 필터 등 나머지 규칙 | — | 그 외 요청 처리 | | 기본 | 웹 ACL 기본 액션 | Block | 어느 규칙에도 맞지 않으면 차단 | 2번이 3번 앞에 있다는 점이 핵심입니다. 순서가 바뀌면 이 경로는 rate 제한을 통째로 건너뀝니다. 기존 전역 rate 규칙이 뒤에 있어도 이 경로에는 적용되지 않으므로, 경로 전용 rate 규칙이 유일한 flood 방어선이 됩니다. 임계치는 이 경로의 정상 트래픽 로그(예: IP당 최대 요청 수)를 보고 정하세요. 문서만으로 정할 수 있는 값이 아닙니다. 적용 절차: Count 모드로 검증한 뒤 전환합니다 운영 중인 웹 ACL에 규칙을 바로 Allow·Block으로 넣으면 정상 요청이 막히거나 의도하지 않은 경로가 열릴 수 있습니다. 아래 순서를 권합니다. - [ ] 정규식 패턴 세트를 만들고 저장이 되는지 확인합니다(미지원 문법이면 저장 단계에서 오류가 납니다). - [ ] 검사 대상을 URI path로 지정하고 URL_DECODE를 설정합니다. - [ ] 경로 전용 rate-based 규칙을 **Count**로 만들어 로그에서 정상 트래픽이 임계치를 넘지 않는지 봅니다. - [ ] Allow 규칙을 **Count**로 만들어 정규식에 맞는 요청과 안 맞는 요청을 비교합니다. - [ ] 두 규칙의 순서를 확인합니다(rate-based가 Allow보다 앞). - [ ] 로그가 깨끗하면 rate-based 규칙을 Block, Allow 규칙을 Allow로 바꿉니다. - [ ] 전환 후에도 샘플링된 요청과 지표를 일정 기간 봅니다. 한계와 참고 문서 이 글의 내용 중 문서로 확인하지 못한 부분은 아래와 같습니다. 실제 적용 전에 직접 확인해 보세요. - 정규식 패턴의 길이 제한은 확인한 문서에 없었습니다. - 비캡처 그룹 (?:...)가 미지원 목록에 걸리는지, 지원되는 zero-width assertion의 범위는 문서만으로 확정하지 못했습니다. - rate-based 규칙의 적절한 임계치는 문서로 정해지지 않으며, 서비스의 정상 트래픽에 따라 정해야 합니다. - CloudFront나 프록시 뒤에 웹 ACL이 붙는 구조에서는 IP 집계 방식을 별도로 확인해야 합니다. 참고 문서: - [Supported regular expression syntax in AWS WAF](https://docs.aws.amazon.com/waf/latest/developerguide/waf-regex-pattern-support.html) - [Managing your own regex pattern sets](https://docs.aws.amazon.com/waf/latest/developerguide/waf-regex-pattern-set-managing.html) - [Rate-based rule statement](https://docs.aws.amazon.com/waf/latest/developerguide/waf-rule-statement-type-rate-based.html) - [NOT rule statement](https://docs.aws.amazon.com/waf/latest/developerguide/waf-rule-statement-type-not.html) - [AWS WAF request components](https://docs.aws.amazon.com/waf/latest/developerguide/waf-rule-statement-fields-list.html) - [Rule action](https://docs.aws.amazon.com/waf/latest/developerguide/waf-rule-action.html) - [Recommended WAF rule order (AWS best practices)](https://aws.github.io/aws-security-services-best-practices/guides/waf/recommended-waf-rule-order/docs/)

AWS,WAF
2026년 10월 01일, 0개의 댓글

Veo 3.1 영상 생성 비용 줄이기, Vertex AI에서 `generateAudio: false` 적용하기

본문 초안 Google Veo 3.1 API를 사용하면서 영상 생성 비용을 줄일 방법을 찾고 있었다. 내가 원했던 건 단순했다. > 영상은 필요하지만, 오디오는 필요 없다. > 그럼 오디오 생성을 끄면 비용이 줄어들지 않을까? 사용 중인 SDK는 다음 패키지였다. ts import { GoogleGenAI, ThinkingLevel, ApiError } from '@google/genai'; 그리고 SDK 타입을 보면 generateAudio라는 파라미터가 존재한다. ts generateAudio?: boolean 문제는 이 파라미터가 **어떤 API 경로에서는 보이지만 실제로는 동작하지 않거나, 아예 지원되지 않는 것처럼 보인다**는 점이었다. 이번 글은 그 삽질 기록이다. --- 결론 먼저 결론부터 말하면 다음과 같다. | 구분 | generateAudio: false | 비용 절감 가능성 | |---|---:|---:| | Gemini Developer API | 불명확하거나 제한적 | 기대하기 어려움 | | Vertex AI / Gemini Enterprise Agent Platform | 가능 | 가능 | | 후처리로 오디오 제거 | 가능 | 비용 절감 없음 | 즉, **오디오 없는 Veo 영상을 생성해서 비용을 줄이고 싶다면 Vertex AI API 경로를 써야 했다.** --- 처음에는 Gemini API에서 시도했다 처음에는 일반 Gemini Developer API 방식으로 호출하고 있었다. ts const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY, }); 그리고 대략 이런 식으로 Veo 영상을 생성했다. ts const operation = await ai.models.generateVideos({ model: 'veo-3.1-fast-generate-preview', prompt, config: { durationSeconds: 8, aspectRatio: '16:9', resolution: '720p', generateAudio: false, }, }); SDK 타입에는 generateAudio가 있었기 때문에 당연히 동작할 것이라고 생각했다. 하지만 실제로는 기대와 달랐다. - 에러가 나거나 - 파라미터가 무시되는 것처럼 보이거나 - 결과 영상에 여전히 오디오가 붙는 문제가 있었다 즉, 단순히 SDK 타입에 필드가 있다고 해서 모든 backend에서 실제 지원되는 것은 아니었다. --- 후처리로 오디오를 제거하는 것은 비용 절감이 아니다 처음에는 이런 방법도 생각할 수 있다. bash ffmpeg -i input.mp4 -an -c:v copy output.mp4 이렇게 하면 결과 mp4에서 오디오 트랙은 제거된다. 하지만 이건 이미 생성된 영상에서 오디오를 지우는 것일 뿐이다. Veo API 호출 시점에 이미 오디오 생성 비용이 발생했다면, 후처리로 오디오를 제거해도 API 비용은 줄지 않는다. 내가 원한 건 “결과물을 무음으로 만들기”가 아니라 “처음부터 오디오 생성을 하지 않아서 비용을 줄이기”였다. 그래서 후처리는 해결책이 아니었다. --- Vertex AI 문서를 다시 확인해봤다 그러다가 Vertex AI, 현재 문서상으로는 Gemini Enterprise Agent Platform 쪽 문서를 다시 확인했다. 여기서 중요한 차이를 발견했다. Vertex AI의 VideoGenerationModelParams 스키마에는 generateAudio가 명시되어 있다. json { "generateAudio": boolean } 문서 설명도 다음과 같다. > Whether to generate audio along with the video. If true, an audio track is generated for the videos. Defaults to true. 즉 generateAudio는 boolean 값이고, 기본값은 true다. true일 때 영상에 오디오 트랙을 생성한다는 의미다.generateAudio boolean: Whether to generate audio along with the video. If true, an audio track is generated for the videos. Defaults to true. 그리고 더 중요한 점은 가격표였다. Vertex AI / Agent Platform 가격표에는 Veo 3.1이 다음처럼 나뉘어 있었다. | 모델 | Video + Audio | Video only | |---|---:|---:| | Veo 3.1 Standard 720p/1080p | $0.40/sec | $0.20/sec | | Veo 3.1 Fast 720p | $0.10/sec | $0.08/sec | | Veo 3.1 Lite 720p | $0.05/sec | $0.03/sec | 즉 Vertex AI 쪽에서는 **Video + Audio generation**과 **Video generation**이 별도 가격으로 분리되어 있다.Veo 3.1: Video + Audio generation $0.40/second, Video generation $0.20/second; Veo 3.1 Fast: Video + Audio generation $0.10/second at 720p, Video generation $0.08/second at 720p; Veo 3.1 Lite: Video + Audio generation $0.05/second at 720p, Video generation $0.03/second at 720p. 이걸 보고 나서야 “아, 이건 Gemini Developer API가 아니라 Vertex AI API 경로로 가야 하는 문제구나”라고 정리됐다. --- Vertex AI로 호출 방식 변경 일반 Gemini API 방식은 API key를 사용한다. ts const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY, }); 반면 Vertex AI 방식은 Google Cloud project와 location을 사용한다. ts const ai = new GoogleGenAI({ vertexai: true, project: 'YOUR_PROJECT_ID', location: 'us-central1', }); 여기서 중요한 점은 apiKey가 아니라 **Google Cloud IAM 인증**을 사용한다는 것이다. 로컬 개발 환경에서는 보통 다음을 실행한다. bash gcloud auth login gcloud auth application-default login 서버에서는 service account를 사용한다. AWS 서버나 Docker 환경이라면 service account JSON key를 마운트하거나, 더 안전하게는 Workload Identity Federation을 사용할 수 있다. --- Vertex AI용 모델명 주의 Gemini Developer API에서 쓰던 모델명과 Vertex AI에서 쓰는 모델명이 다를 수 있다. 예를 들어 아래처럼 preview 모델명을 그대로 쓰면 404가 날 수 있다. txt veo-3.1-lite-generate-preview Vertex AI / Agent Platform 쪽에서는 다음처럼 001 모델명을 사용했다. txt veo-3.1-lite-generate-001 veo-3.1-fast-generate-001 veo-3.1-generate-001 실제로 잘못된 모델명을 사용했을 때는 이런 오류가 발생했다. txt Publisher model ... was not found or your project does not have access to it. Ensure you are using a valid model name and that the model is available in the specified region. 이 경우 먼저 확인할 것은 다음이다. 1. 모델명이 Vertex AI용인지 2. location이 us-central1인지 3. 프로젝트에 Vertex AI API가 활성화되어 있는지 4. 해당 계정 또는 service account에 Vertex AI 권한이 있는지 --- 실제 호출 예시 최종적으로는 이런 형태로 호출했다. ts import { GoogleGenAI } from '@google/genai'; const ai = new GoogleGenAI({ vertexai: true, project: process.env.GOOGLE_CLOUD_PROJECT!, location: 'us-central1', }); const operation = await ai.models.generateVideos({ model: 'veo-3.1-lite-generate-001', prompt: 'A cinematic shot of a coffee cup on a wooden table.', config: { durationSeconds: 4, aspectRatio: '16:9', resolution: '720p', generateAudio: false, numberOfVideos: 1, }, }); console.dir(operation, { depth: null }); generateAudio: false를 넣는 위치는 config 안이다. ts config: { generateAudio: false, } REST 기준으로 보면 parameters 안에 들어간다. json { "instances": [ { "prompt": "A cinematic shot of a coffee cup on a wooden table." } ], "parameters": { "durationSeconds": 4, "aspectRatio": "16:9", "resolution": "720p", "generateAudio": false, "sampleCount": 1 } } --- Docker 서버에서 인증하기 내 경우 서버는 AWS에서 Docker로 돌고 있었다. 가장 간단한 방식은 Google service account key를 컨테이너에 마운트하는 것이다. bash docker run -d \ --name app \ -v /opt/secrets/gcp-veo-sa.json:/secrets/gcp-veo-sa.json:ro \ -e GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-veo-sa.json \ -e GOOGLE_CLOUD_LOCATION=us-central1 \ your-image:latest docker-compose라면 다음처럼 설정할 수 있다. yaml services: app: image: your-image:latest environment: GOOGLE_APPLICATION_CREDENTIALS: /secrets/gcp-veo-sa.json GOOGLE_CLOUD_LOCATION: us-central1 volumes: - /opt/secrets/gcp-veo-sa.json:/secrets/gcp-veo-sa.json:ro 주의할 점은 service account JSON key는 사실상 비밀번호라는 것이다. - Git에 넣으면 안 된다. - Docker image 안에 COPY하면 안 된다. - 실행 시 read-only volume으로 마운트하는 편이 낫다. - 운영에서는 가능하면 Workload Identity Federation을 쓰는 것이 더 안전하다. --- 여러 프로젝트를 돌려쓰는 경우 기존에 Gemini API key를 여러 개 쓰고 있었다면, 사실상 여러 프로젝트의 quota를 나눠 쓰고 있었을 가능성이 높다. Google의 rate limit은 API key 단위가 아니라 project 단위로 적용된다.Rate limits are applied per project, not per API key. Vertex AI에서도 비슷하게 여러 프로젝트를 사용할 수 있다. 다만 API key를 바꿔 끼우는 게 아니라, 하나의 service account에 여러 프로젝트 권한을 주고 projectId만 바꿔 호출하는 구조가 된다. ts const projectIds = [ 'project-a', 'project-b', 'project-c', ]; let index = 0; function pickProjectId() { const projectId = projectIds[index % projectIds.length]; index += 1; return projectId; } function createClient(projectId: string) { return new GoogleGenAI({ vertexai: true, project: projectId, location: 'us-central1', }); } 각 프로젝트에는 동일한 service account를 IAM member로 추가하고, Vertex AI 사용자 역할을 부여하면 된다. 역할 ID는 다음이다. txt roles/aiplatform.user --- 콘솔에서 켜야 하는 것 Google Cloud Console에서는 이름이 조금 헷갈린다. 현재 Google은 Vertex AI를 Gemini Enterprise Agent Platform 쪽으로 개편하고 있다. 공식 블로그에서도 Agent Platform을 Vertex AI의 evolution이라고 설명한다.It’s the evolution of Vertex AI... Moving forward, all Vertex AI services and roadmap evolutions will be delivered exclusively through the Agent Platform, rather than as a standalone service 하지만 실제 API endpoint와 IAM role은 여전히 aiplatform 계열을 사용한다. 콘솔에서는 프로젝트별로 다음을 확인하면 된다. 1. Vertex AI API 활성화 txt API 및 서비스 → 라이브러리 → Vertex AI API → 사용 설정 직접 URL: txt https://console.cloud.google.com/apis/library/aiplatform.googleapis.com 2. IAM 역할 부여 txt IAM 및 관리자 → IAM → 액세스 권한 부여 역할: txt Vertex AI 사용자 Role ID: txt roles/aiplatform.user 3. GCS 저장을 쓴다면 Storage 권한도 추가 txt Storage Object User Role ID: txt roles/storage.objectUser --- 정리 이번 문제의 핵심은 generateAudio 파라미터가 SDK 타입에 있다고 해서 모든 API 경로에서 동일하게 동작하는 것은 아니라는 점이었다. 내가 최종적으로 정리한 내용은 다음과 같다. 1. Gemini Developer API에서는 generateAudio: false가 기대대로 동작하지 않을 수 있다. 2. Vertex AI / Gemini Enterprise Agent Platform의 VideoGenerationModelParams에는 generateAudio가 명시되어 있다. 3. Vertex AI 가격표에는 Video + Audio와 Video only가 분리되어 있다. 4. 따라서 오디오 없는 Veo 영상을 생성해 비용을 줄이려면 Vertex AI API 경로를 쓰는 것이 맞다. 5. Vertex AI는 API key가 아니라 IAM/service account 인증을 사용한다. 6. 여러 프로젝트 quota를 쓰고 있다면, service account 하나에 여러 프로젝트 권한을 부여하고 projectId를 로테이션하는 구조로 바꿀 수 있다. 결론적으로, 비용 최적화를 위해서는 단순히 ffmpeg로 오디오를 지우는 것이 아니라, **생성 요청 단계에서 generateAudio: false를 적용할 수 있는 API 경로를 선택해야 한다.** 나의 경우 그 답은 Vertex AI API였다.

GoogleVeo
2026년 08월 13일, 0개의 댓글

Gemini 3.5 Flash에서 이미지 URL을 넣으면 429가 나는 문제 정리

한 줄 요약 gemini-3.5-flash에서 텍스트 호출은 정상인데 fileData.fileUri로 외부 이미지 URL을 넣으면 429 RESOURCE_EXHAUSTED가 나는 경우, 단순 quota 초과가 아니라 **Gemini backend의 외부 URL fetch 실패 또는 모델별 URL ingestion 경로 차이**를 의심해야 한다. --- 문제 상황 Node.js에서 @google/genai SDK로 gemini-3.5-flash를 호출할 때 다음과 같은 에러가 발생했다. json { "error": { "code": 429, "message": "Resource has been exhausted (e.g. check quota).", "status": "RESOURCE_EXHAUSTED" } } 처음에는 일반적인 quota 문제처럼 보였다. 하지만 확인해보니 상황이 달랐다. - 프로젝트는 Tier 3 - 콘솔상 RPM, RPD, TPM 모두 정상 - 같은 API key로 curl 호출은 정상 - SDK에서도 "hi" 같은 텍스트만 넣으면 정상 - gemini-3.1-flash-image-preview에서는 이미지 URL 입력이 정상 - gemini-3.5-flash에서만 외부 이미지 URL을 fileData.fileUri로 넣으면 실패 즉, API key나 프로젝트 quota 문제보다는 **payload, 특히 외부 이미지 URL 처리 문제**로 좁혀졌다. --- 사용한 payload 최종적으로 사용한 payload는 대략 이런 형태였다. json { "contents": [ { "role": "user", "parts": [ { "text": "하이" }, { "fileData": { "mimeType": "image/webp", "fileUri": "https://i.namu.wiki/i/7XrzAIHYm9NTJ7N56qvYpN8SmbHHpE8q1mSUnQ-kcE64qhFdlHEfsxqL-koOWsNq4g7CrmRNvZSJVH9KkNw8UQ.webp" } } ] } ] } 처음에는 mimeType을 image/jpeg로 보냈지만, 실제 URL은 .webp였고 응답 헤더도 image/webp였다. MIME을 image/webp로 고쳐도 결과는 동일했다. --- 공식 문서상으로는 가능한 사용법 문서만 보면 gemini-3.5-flash는 이미지 입력을 지원한다. 입력으로 Text, Image, Video, Audio, PDF를 받고, 출력은 Text다. “Supported data types… Inputs Text, Image, Video, Audio, and PDF… Output Text” 또한 Gemini API는 외부 HTTPS URL을 fileData.fileUri로 직접 넘기는 방식을 지원한다. 문서상 Gemini API가 처리 중 해당 URL을 fetch하며, 단 Gemini 2.0 계열은 이 기능을 지원하지 않는다고 되어 있다. “You can pass publicly accessible HTTPS URLs or pre-signed URLs directly in your request. The Gemini API will fetch the content securely during processing… Note: Gemini 2.0 family of models are not supported” 이미지 타입도 image/jpeg, image/png, image/webp 등이 지원된다. “Image file types… image/bmp… image/jpeg… image/png… image/webp” Cloud 문서에서도 fileData.fileUri에 HTTP URL을 넣을 수 있고, 이 URL은 publicly readable 해야 한다고 설명한다. “HTTP URL: The file URL must be publicly readable. You can specify… up to 10 image files per request… When specifying a fileURI, you must also specify the media type” 따라서 문서상으로는 다음 조합이 가능해야 한다. txt gemini-3.5-flash + fileData.fileUri + public HTTPS image URL + image/webp --- 그런데 실제로는 왜 실패할 수 있나 핵심은 **외부 URL을 누가 가져오느냐**다. fileData.fileUri를 쓰면 내 서버가 이미지를 Gemini에 직접 보내는 것이 아니다. Gemini backend가 해당 URL을 직접 fetch한다. 따라서 내 서버나 브라우저에서 URL이 잘 열려도, Gemini backend에서는 실패할 수 있다. 가능한 원인은 다음과 같다. - CDN의 hotlink 방지 - User-Agent 기반 차단 - Google backend IP 차단 - region별 접근 제한 - timeout - redirect 처리 문제 - URL safety check 실패 - WebP 처리 경로 차이 - 모델별 file ingestion 경로 차이 - 일시적인 URL retrieval 장애 실제로 @google/genai GitHub 이슈에도, 같은 presigned S3 URL과 같은 prompt인데도 Gemini 쪽에서 간헐적으로 Cannot fetch content from the provided URL이 발생하고, retry하면 성공하는 사례가 보고되어 있다. “Intermittently receive… Cannot fetch content from the provided URL… Immediately verify the same presigned URL is still valid… Retry the exact same request… often succeeds with no URL/payload changes.” 즉, 외부 URL fetch는 항상 deterministic하게 성공한다고 보기 어렵다. --- 모델별 차이가 실제로 있나? 공식 문서가 “gemini-3.1-flash-image-preview와 gemini-3.5-flash는 서로 다른 URL fetch path를 쓴다”고 직접 말하진 않는다. 하지만 다음 사실은 확인된다. gemini-3.5-flash는 이미지 입력은 지원하지만 이미지 생성은 지원하지 않는 text-output 모델이다. “Output Text… Image generation Not supported” 반면 gemini-3.1-flash-image 계열은 이미지 생성 및 편집용 모델이고, 입력은 Text and Image/PDF, 출력은 Image and Text다. “Supported data types… Inputs Text and Image / PDF… Output Image and Text… Image generation Supported” 즉 두 모델은 모두 이미지 입력을 받을 수 있지만, 모델의 목적과 processing pipeline은 다를 수 있다. 따라서 다음과 같이 정리하는 게 정확하다. > gemini-3.5-flash가 외부 이미지 URL을 지원하지 않는 것은 아니다. 문서상 지원한다. > 하지만 실제 운영에서는 모델별, endpoint별, URL host별로 외부 URL fetch 실패가 발생할 수 있고, 이 실패가 generic 429 RESOURCE_EXHAUSTED로 보일 수 있다. --- 왜 429로 보이나? 정상적이라면 URL fetch 실패는 400 INVALID_ARGUMENT나 Cannot fetch content from the provided URL 같은 에러로 나오는 것이 자연스럽다. 그런데 이번 케이스는 다음처럼 generic 429가 나왔다. json { "code": 429, "message": "Resource has been exhausted (e.g. check quota).", "status": "RESOURCE_EXHAUSTED" } 이 때문에 처음에는 quota 문제처럼 보인다. 하지만 다음 조건이 모두 맞다면 quota보다는 URL retrieval 문제를 먼저 의심해야 한다. - 텍스트-only 호출은 성공 - 같은 key로 curl 호출 성공 - Tier, RPM, TPM, RPD 모두 정상 - 다른 이미지 모델은 성공 - 특정 모델에서 외부 URL을 넣을 때만 실패 - inlineData로 보내면 성공 --- 재현 및 분리 테스트 방법 1. 텍스트만 넣어서 테스트 ts await ai.models.generateContent({ model: "gemini-3.5-flash", contents: "hi", }); 이게 성공하면 SDK, key, project 자체는 정상이다. --- 2. Google 공식 샘플 이미지 URL로 테스트 ts await ai.models.generateContent({ model: "gemini-3.5-flash", contents: [ { role: "user", parts: [ { text: "What is shown in this image?" }, { fileData: { mimeType: "image/jpeg", fileUri: "https://storage.googleapis.com/cloud-samples-data/generative-ai/image/scones.jpg", }, }, ], }, ], }); 결과 해석: - 샘플 URL은 성공, 내 URL만 실패 -> URL host/CDN 접근 문제 가능성 높음 - 샘플 URL도 실패 -> gemini-3.5-flash + external URL 경로 문제 가능성 - inlineData는 성공 -> 모델의 vision 기능은 정상, 외부 URL fetch만 문제 --- 3. 같은 이미지를 inlineData로 보내기 ts const imageUrl = "https://example.com/image.webp"; const imageResponse = await fetch(imageUrl); if (!imageResponse.ok) { throw new Error(Image fetch failed: ${imageResponse.status}); } const mimeType = imageResponse.headers.get("content-type")?.split(";")[0] ?? "image/webp"; const imageBuffer = Buffer.from(await imageResponse.arrayBuffer()); const result = await ai.models.generateContent({ model: "gemini-3.5-flash", contents: [ { role: "user", parts: [ { text: "하이" }, { inlineData: { mimeType, data: imageBuffer.toString("base64"), }, }, ], }, ], }); 이 방식은 Gemini backend가 URL을 fetch하지 않는다. 내 서버가 이미지를 받아서 base64로 Gemini에 직접 전달한다. 운영에서는 이 방식이 가장 안정적이다. --- 운영 권장 방식 외부 이미지 URL을 그대로 fileData.fileUri에 넣는 방식은 편하지만, 운영 안정성은 떨어질 수 있다. 권장 순서는 다음과 같다. 작은 이미지 txt server fetch -> content-type 확인 -> base64 변환 -> inlineData로 전달 큰 이미지 또는 재사용 이미지 txt server fetch -> Gemini File API upload -> 반환된 file.uri를 fileData.fileUri로 전달 피하는 것이 좋은 방식 txt 임의의 외부 CDN URL -> fileData.fileUri에 직접 전달 특히 다음 출처의 이미지는 직접 URL 전달을 피하는 것이 좋다. - namu.wiki - 커뮤니티 CDN - 쇼핑몰 CDN - signed URL - hotlink 방지 가능성이 있는 이미지 서버 - region별 응답이 달라지는 CDN - User-Agent에 따라 응답이 달라지는 서버 --- 최종 결론 이번 문제는 단순 quota 문제가 아니었다. gemini-3.5-flash는 문서상 이미지 입력과 외부 URL 입력을 지원한다. 하지만 실제 서비스에서는 fileData.fileUri로 넘긴 외부 URL을 Gemini backend가 직접 fetch해야 하며, 이 과정은 모델별, endpoint별, URL host별로 실패할 수 있다. 특히 gemini-3.1-flash-image-preview에서는 같은 URL이 동작하더라도, gemini-3.5-flash에서 동일하게 동작한다고 보장할 수는 없다. 운영 환경에서는 외부 이미지 URL을 모델에 직접 넘기기보다, 서버에서 이미지를 fetch한 뒤 inlineData로 전달하는 방식이 가장 안전하다.

2026년 08월 13일, 0개의 댓글

vscode에서 퀵 오픈으로 전체 검색하기 (% 자동입력)
vscode에서 퀵 오픈으로 전체 검색하기 (% 자동입력)

나의 경우 vscode나 cursor를 사용하면서 전체 검색을 자주 사용하는 편이다. 물론 cmd + shift + f 를 이용하면 기본적으로 제공하는 전체 검색이 되긴 한다. 이게 편할때도 있다. 하지만 대형 모니터가 아니라 노트북만으로 작업하거나 할때는 왼쪽 창 자체가 열리는게 싫을 떄가 있다. 그리고 띄워 놓지 않고 단순 검색으로 특정 파일의 위치로 가고 싶을때는 퀵 오픈을 이용한다. cmd + p를 누르면 퀵 오픈 창이 뜬다. 여기에서 %를 입력하고 검색하고 싶은 검색어를 입력하면 파일별로 검색된 위치를 방향키로 선택할 수 있게 드롭다운으로 표시해준다. 여기서 문제는 매번 cmd + p를 누르고 %을 따로 입력해줘야 하는 것이었다. cmd + shift + p 는 명령어를 입력할 수 있는 퀵 오픈이 뜨고 cmd + t를 누르면 심볼 단위로 검색할 수 있는 퀵오픈이 뜨도록 기본적으로 제공한다. 근데 검색은 기본 제공이 안된다. 그래서 따로 세팅을 해주면 단축키 한번에 %이 입력된 채로 이용할 수 있게 된다. 방법은 다음과 같다. 1. cmd + shift + p를 눌러 명령어 퀵 오픈을 띄운다. 2. Preferences: Open Keyboard Shortcuts (JSON) 를 입력하여 선택한다. 그러면 keybindings.json이라는 파일이 열린다. 아래와 같이 JSON형식에 맞춰 추가한다. { "key": "cmd + ctrl + p", "command": "workbench.action.quickOpen", "args": "%" } 저장하고 파일을 닫아준다. 이제 cmd + ctrl + p를 누르면 이렇게 바로 %이 입력된 채로 바로 검색을 할 수 있게 된다.

vscode,cursor,전체 검색,퀵오픈,keybinding
2026년 01월 12일, 0개의 댓글

nodejs에서 pyenv처럼 프로젝트별로 node 버전 관리하기

설치방법 macOS / Linux 터미널에 다음 명령어를 입력한다. (Homebrew 사용 시) Bash brew install fnm 또는 설치 스크립트 사용: Bash curl -fsSL https://fnm.vercel.app/install | bash PowerShell(Windows)에서 Winget을 사용 PowerShell winget install Schniz.fnm 설정 .zshrc (or .bashrc) eval "$(fnm env --use-on-cd)" PowerShell ( windows) fnm env --use-on-cd | Out-String | Invoke-Expression 사용 방법 프로젝트 루트에 .node-version이나 .nvmrc 파일을 만든다. 그리고 해당 파일 안에 원하는 버전을 입력하면 된다. 만약 버전이 없으면 설치 안내가 나온다. y를 눌러서 설치하면 사용 가능하다. nvm과는 별도로 동작하므로 .zshrc에 nvm 설정은 주석처리한다. 그밖에 특정 디렉토리에 진입했을 떄 .nvmrc가 있으면 그거로 바꿔주기만 한다. 예를들면 nvm에서 기본으로 18.18.0을 사용한다고 했을 때, a라는 프로젝트 루트에 .nvmrc가 있고 그게 25.1.0 버전으로 되어 있다고 가정하자. 처음에는 어떤 경로에서든 18.18.0이 선택되어 있다. a라는 디렉토리에 들어가면 그때 25.1.0버전으로 변경된다. 그리고 다시 다른 곳에 진입해도 25.1.0이 유지된다. 18.18.0으로 돌아가지 않는다. 해결방법 home(~)에 .node-version을 생성하여 기본 버전을 명시하면 된다. 이렇거 버전을 명시하고 a라는 버전에서 cd ..해서 상위로 올라가면 더 상위(~)에 버전을 참고하게 된다. 근데 이게 안되는 상황이 발생할 수 있다. ~까지 가면 버전이 바뀌는데 중간에는 안바뀌는 경우 예를들어 ~/proj/a 였다면 a에서는 25.1.0 인데 cd .. 하면(~/proj) 안바뀌는 경우가 있다. 이때 해결방법은 우선 fnm env를 입력해본다. export FNM_VERSION_FILE_STRATEGY="local" 인지 확인한다. 만약 local이라면 이걸 recursive로 바꿔줘야 더 최상위의 .node-version파일을 참고할 수 있게 된다. 방법은 .zshrc를 다음과 같이 변경한다. eval "$(fnm env --use-on-cd --version-file-strategy=recursive)" 끝.

nodejs,nvm,js,fnm
2026년 01월 09일, 0개의 댓글

vscode, cursor 에 .prettierrc가 제대로 작동 안 될 경우
vscode, cursor 에 .prettierrc가 제대로 작동 안 될 경우

.prettierrc 파일을 루트에 작성했음에도 제대로 작동하지 않는 경우가 있다. 이때 이 곳들을 확인해보면 된다. (설정 내부의 항목들은 설정의 검색창에서 검색하면 쉽게 찾을 수 있다.) 1번) - 설정 - default formatter를 확인한다. Prettier - Code formatter 가 선택되어 있는지 확인한다. - 설정 - format on save를 체크한다. 저장시 prettier가 적용되는것을 원한다면 반드시 체크한다. 2번) .prettierrc 파일에 오타가 있는지 확인한다. 인간은 누구나 실수하기에. 3번) 직접 실행해서 prettier가 동작하는지 확인한다. sh npx prettier --write filename.js prettier 자체가 문제일 수 있으니 체크해볼 필요는 있다. 4번) eslint와 충돌나는지 확인한다. 5번) prettier 플러그인이 disable되어있는지, 아니면 특정 workspace에서만 disable되어있는지 확인한다. 6번) 설정화면을 JSON으로 변경하고 설정을 확인한다. 1-5번까지 모두 문제가 없다면 아마 editor.defaultFormatter가 의도한것과 다르게 설정되어 있을 수도 있다. 예를들면 typescript만 vscode default formatter로 따로 세팅되어 있다던지. vscode는 문서 포맷별로 formatter를 다르게 설정할 수 있어 모르는 사이에 저렇게 세팅되어있을 수 있다. "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.tabSize": 4 }, 이런식으로 defaultFormatter를 esbenp.prettier-vscode 로 살포시 바꿔주자. 위 케이스 중에서는 거의 대부분 해결을 볼 수 있을 것이다.

개발,vscode,cursorAi,cursor,IDE,prettier,prettierrc
2025년 01월 15일, 0개의 댓글

openai GPT API에 GPT-4 Turbo 적용 방법
openai GPT API에 GPT-4 Turbo 적용 방법

openai GPT모델 중 현재 제일 최신 모델은 GPT-4 Turbo이다. API를 이용하여 GPT-4-Turbo모델을 적용하기 위해서는 현재 openai에서 제공하는 정확한 모델명을 우선 찾아야 한다. [https://platform.openai.com/docs/models/gpt-4-and-gpt-4-turbo](https://platform.openai.com/docs/models/gpt-4-and-gpt-4-turbo) 를 참조해보면 다음과 같다. 여기에 있는 MODEL명을 정확하게 입력해주어야 한다. gpt-4 turbo발표 시 최초로 제공되었던 모델은 gpt-4-1106-preview이고 현재 제일 최신 모델은**gpt-4-0125-preview**이다. API 비용은 [https://openai.com/pricing](https://openai.com/pricing) 를 참조하자. 비용은 호출/출력 시 사용되는 토큰으로 계산되는데 1K Tokens 단위 기준으로 가격이 나와있으니 적절히 토큰을 계산해서 API를 구현하는게 좋다. > **여담** 수용할 수 있는 토큰수가 예전에 비해 엄청나게 늘어났기 때문에 GPT 초기에 첨부자료를 자르고 임베딩하고 하는 일을 하지 않아도 특정 문서를 참고해서 GPT가 답변하게 하는것이 훨씬 쉽게 가능해졌다. 프로덕션 레벨에서는 결국 토큰 절약 차원에서 기존에 사용했던 임베딩 등 기술들을 최대한 사용해서 전체 사용 토큰 수를 줄이는 작업을 해야한다. 매번 모든 문서를 질문할때마다 넣는건 꽤 비용이 들 것이니 말이다. 비용 상관없이 정확도를 요하거나 개발 편의성을 요할경우에는 사용해볼만 하다. 아이디어 검증용으로 프로토타이핑할때도 훨씬 쉽게 접근이 가능한 상태다. 아니면 문서 전체를 참고해야만 정확한 답변이 나와야 하는 경우에도 이전보다 정교한 답변을 낼 수 있을 것이다.

gpt,openai,chatgpt,gpt-4,gpt-4-turbo
2024년 02월 07일, 0개의 댓글

http code 정리

HTTP 상태 코드는 웹 서버와 클라이언트 간의 통신 결과를 나타내는 코드입니다. 이들은 대략적으로 다음과 같은 카테고리로 분류됩니다: - **2xx (성공)**: 클라이언트의 요청이 성공적으로 수행되었음을 나타냅니다. - **3xx (리다이렉션)**: 클라이언트를 다른 URL로 리다이렉션해야 함을 나타냅니다. - **4xx (클라이언트 오류)**: 클라이언트의 요청에 오류가 있음을 나타냅니다. - **5xx (서버 오류)**: 서버가 유효한 요청을 처리하지 못했음을 나타냅니다. 각 범주에서 자주 사용되는 HTTP 상태 코드를 정리하면 다음과 같습니다: 2xx 성공 - **200 OK**: 요청이 성공적으로 처리되었습니다. - **201 Created**: 요청이 성공적으로 처리되었으며, 새로운 리소스가 생성되었습니다. - **204 No Content**: 요청이 성공적으로 처리되었으나, 전송할 콘텐츠가 없습니다. 3xx 리다이렉션 - **301 Moved Permanently**: 요청한 리소스가 영구적으로 새 위치로 이동했습니다. - **302 Found**: 요청한 리소스가 일시적으로 다른 URI에 위치해 있음을 나타냅니다. - **304 Not Modified**: 클라이언트가 조건부 GET 요청을 했고, 문서가 변경되지 않았습니다. 클라이언트는 캐시된 버전을 사용할 수 있습니다. 4xx 클라이언트 오류 - **400 Bad Request**: 서버가 요청을 이해할 수 없습니다. - **401 Unauthorized**: 인증이 필요한 페이지를 요청했습니다. - **403 Forbidden**: 서버가 요청을 거부합니다. - **404 Not Found**: 서버가 요청한 페이지(Resource)를 찾을 수 없습니다. - **405 Method Not Allowed**: 요청된 메소드는 허용되지 않습니다. 5xx 서버 오류 - **500 Internal Server Error**: 서버 내부 오류로 요청을 수행할 수 없습니다. - **501 Not Implemented**: 서버가 요청 메소드를 지원하지 않거나 구현할 수 없습니다. - **502 Bad Gateway**: 서버가 게이트웨이나 프록시 역할을 하며, 상류 서버로부터 잘못된 응답을 받았습니다. - **503 Service Unavailable**: 서버가 일시적으로 요청을 처리할 준비가 되어 있지 않습니다. 일반적으로 서버 과부하나 유지보수로 인한 것입니다. - **504 Gateway Timeout**: 게이트웨이/프록시 서버가 상류 서버로부터 시간 내에 응답을 받지 못했습니다.

web,http,api
2024년 02월 03일, 0개의 댓글