728x90
반응형
단순히 사진을 업로드하고 게시하는 서비스는 지루합니다. '오늘가챠'는 유저가 찍은 평범한 일상 사진을 화려한 능력치와 세계관을 가진 카드로 자동 변환합니다. 이를 위해 생성형 AI를 결합한 경험입니다.

1. 기술 선정: 왜 Gemini인가?
OpenAI의 GPT 모델 대신 구글의 Gemini 2.0 Flash 계열을 선택했습니다. 가격이 압도적으로 저렴하고 처리 속도가 빠르기 때문입니다. 하지만 무료 티어는 분당 요청 수(Rate Limit) 제한이 빡빡했습니다. 이를 극복하기 위해 GeminiClient에 다중 모델 Fallback 로직을 구현했습니다. 실제 프로젝트의 전체 Fallback 코드입니다.
// GeminiClient.java — 다중 모델 Fallback 전략
// 시도할 모델 목록 (우선순위 순서)
private static final List<String> GEMINI_MODELS = List.of(
"gemini-2.0-flash", "gemini-2.5-flash-lite", "gemini-flash-latest");
public CardAnalysis analyze(byte[] imageBytes, String mimeType,
String userComment, String locationName) {
String base64Image = Base64.getEncoder().encodeToString(imageBytes);
// ...
Exception lastException = null;
for (String modelName : GEMINI_MODELS) {
String url = "https://generativelanguage.googleapis.com/v1beta/models/"
+ modelName + ":generateContent?key=" + apiKey;
try {
ResponseEntity<String> response = restTemplate.exchange(
url, HttpMethod.POST,
new HttpEntity<>(objectMapper.writeValueAsString(requestBody), headers),
String.class);
log.info("Gemini 분석 성공 (사용 모델: {})", modelName);
return parseResponse(response.getBody());
} catch (HttpClientErrorException.TooManyRequests e) {
log.warn("Gemini API Rate Limit 초과 (모델: {}). 다음 모델 시도...", modelName);
lastException = e;
} catch (HttpServerErrorException e) {
log.warn("Gemini 서버 에러 (모델: {}, 코드: {}). 다음 모델 시도...",
modelName, e.getStatusCode());
lastException = e;
}
try { Thread.sleep(1000); } // 모델 간 전환 시 1초 대기
catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
}
// 모든 모델이 실패한 경우 의미 있는 에러 메시지 반환
if (lastException instanceof HttpClientErrorException.TooManyRequests) {
throw new RuntimeException(
"무료 AI 분석 한도(1분 20회)를 초과했습니다. 약 1분 뒤에 다시 시도해 주세요.");
}
throw new RuntimeException("AI 서버 접속이 원활하지 않습니다.", lastException);
}
하나의 모델이 한도 초과(429 Too Many Requests) 에러를 뱉으면, 즉시 다음 대체 모델로 요청을 넘겨 유저가 에러를 겪지 않게 방어했습니다. 3개 모델을 모두 실패해도 사용자 친화적인 메시지를 반환합니다.
2. 안전 정책과 프롬프트 엔지니어링
생성형 AI 도입 시 가장 조심해야 할 것은 윤리 및 정책 위반(예: 타인의 얼굴 사진 훼손)입니다.
프롬프트 단에 아주 강력한 가드레일(Guardrails)을 세웠습니다. 실제 GeminiClient.java에 선언된 프롬프트의 전문입니다.
private static final String ANALYSIS_PROMPT = """
당신은 일상 사진을 분석하여 트레이딩 카드 게임의 카드 메타데이터를 생성하는 AI입니다.
## 안전 검사 (최우선)
아래 항목에 해당하면 safe=false로 판정하고 rejectReason을 작성하세요:
- 선정적·음란한 이미지 (노출, 성적 암시 포함)
- 과도한 폭력·고어 이미지
- 불법 활동·약물 관련 이미지
- 혐오·차별 조장 콘텐츠
- 저작권 침해가 명백한 이미지 (스크린샷 등)
- 사람의 얼굴(이목구비)이 식별 가능한 이미지 (초상권 보호)
위 항목에 해당하지 않으면 safe=true로 판정하세요.
## 카드 메타데이터 (safe=true일 때만 생성)
1. theme: 반드시 다음 11개 중 하나:
FOOD, CITY, NATURE, ANIMAL, OBJECT, SKY,
FASHION, RIDE, SPACE, HOBBY, MOMENT
2. name: 카드의 창의적이고 감성적인 이름 (한국어, 15자 이내)
3. flavor: 카드 뒷면의 감성적인 한 줄 문구 (한국어, 30자 이내)
반드시 다음 JSON 형식으로만 응답하세요:
{"safe":true,"theme":"FOOD","name":"카드이름","flavor":"플레이버텍스트","rejectReason":""}
부적절한 이미지일 경우:
{"safe":false,"rejectReason":"거부 사유"}
""";
JSON 응답 파싱과 예외 처리
AI의 응답이 마크다운 백틱(```json)으로 감싸져 오는 엣지 케이스도 처리했습니다.
// GeminiClient.java — 응답 파싱 (마크다운 백틱 정제 포함)
private CardAnalysis parseResponse(String responseBody) {
try {
JsonNode root = objectMapper.readTree(responseBody);
String text = root.at("/candidates/0/content/parts/0/text").asText();
// AI가 ```json ... ``` 으로 감싸서 보내는 경우 정제
text = text.replaceAll("```json\\s*", "").replaceAll("```\\s*", "").trim();
JsonNode json = objectMapper.readTree(text);
boolean safe = json.path("safe").asBoolean(true);
if (!safe) {
return new CardAnalysis(false,
json.path("rejectReason").asText("부적절한 콘텐츠가 감지되었습니다."),
null, null, null);
}
return new CardAnalysis(true, null,
validateTheme(json.path("theme").asText("OBJECT")),
json.path("name").asText("이름 없는 카드"),
json.path("flavor").asText("누군가의 일상에서 탄생한 카드."));
} catch (Exception e) {
throw new RuntimeException("AI 분석 중 오류가 발생했습니다.", e);
}
}
// 유효하지 않은 테마값이 오면 기본값 "OBJECT"로 Fallback
private String validateTheme(String theme) {
return switch (theme.toUpperCase()) {
case "FOOD", "CITY", "NATURE", "ANIMAL", "OBJECT",
"SKY", "FASHION", "RIDE", "SPACE", "HOBBY", "MOMENT" ->
theme.toUpperCase();
default -> "OBJECT";
};
}
부적절 이미지 차단 시 후처리
AI가 safe=false로 판정하면, 해당 사진을 즉시 숨김 처리(hidden)합니다. 유저에게는 거절 사유를 안내합니다.
// CardFacade.java — craft() 메서드 내 안전 검사 후처리
if (!analysis.safe()) {
// 부적절한 이미지는 숨김 처리 (사용자에게 보이지 않음, 재업로드 불가)
photoService.hidePhoto(up.getId());
String reason = analysis.rejectReason() != null
? analysis.rejectReason() : "부적절한 이미지입니다.";
throw new IllegalStateException(reason);
}
3. 마무리
AI 분석 코드를 보면 단순한 API 호출이 아니라, JSON 포맷을 강제하기 위한 텍스트 정규화(마크다운 백틱 치환 등)와 예외 모델 Fallback까지 실무적인 엣지 케이스들을 꼼꼼하게 처리했습니다. AI API 연동은 코딩보다 응답을 통제하는 엔지니어링이 진정한 핵심 역량입니다.
728x90
반응형