Intelligence. Knowledge. Creativity. The entity that had it all… The Allangbanggu King, GPT.
The legendary meme it left behind, Italian Brainrot, has opened the curtain on a grand era 🫨
📌 Spring AI 도입
현재 개발하고 있는 프로젝트에 사용자가 입력한 정보를 바탕으로 맞춤형 예상 면접 질문과 답변을 자동으로 생성해 주는 기능을 추가하기 위해 OpenAI의 GPT 모델을 활용했다.
OpenAI의 API를 직접 호출할 수도 있지만, Spring에서 제공하는 Spring AI를 사용하면 훨씬 간단하게 구성할 수 있다.
// ChatClientDialogueGenerator.java
public GeneratedDialogues generate(CustomizedPrompt customizedPrompt) {
try {
String rawPrompt = customizedPrompt.getRawPrompt();
ResponseEntity<ChatResponse, List<GeneratedDialogueDto>> chatResponse = chatClient.prompt()
.user(rawPrompt)
.call()
.responseEntity(new ParameterizedTypeReference<>() {
});
return createGeneratedDialogues(chatResponse);
} catch (Exception e) {
throw new NexterviewException(NexterviewErrorCode.CHAT_API_UNAVAILABLE);
}
}
일반적으로 외부 API 호출 시에 RestTemplate이나 WebClient로 일일이 JSON을 구성하고 헤더를 붙여야 하는데, ChatClient는 이 과정을 추상화하여 프롬프트 구성부터 응답처리까지 간단하게 코드를 작성할 수 있다.
💰 아이 앰 어 푸어 스튜던트
현재 서비스에서는 gpt-4o-mini 모델을 사용하고 있으며, OpenAI의 텍스트 토큰은 다음과 같이 가격이 측정된다:
아무튼 우리의 통장 잔고는 유한하니 API 요청 제한 정책을 세워야 한다.
✅ 전제 조건
- 총 예산: $0.75 (한화 약 1,000원)
- Input 단가: $0.15 / 1M tokens
- Output 단가: $0.60 / 1M tokens
- System prompt: 요청마다 평균 44tokens
- 한글 기준: 한글 1자 ≈ 2.5 tokens
- Input 한도: 최대 500자
💰 사용 가능한 Output 토큰 수 추정
- 비율 기준
- Output이 Input의 4배 가격이니 Output 중심으로 사용자에게 토큰을 할당한다.
- 매 요청 시 gpt의 응답 토큰 수가 사용자 입력 토큰의 두 배라고 가정한다.
- Input 비용은 전체 Output 비용의 1/8로 간주된다.
- $0.75 중 Output에 할당되는 비용 (Output : Input = 8 : 1)
- 사용 가능한 Output 토큰 수
👥 사용자별 Output 토큰 분배
- 유저 수: 총 8명 (로그인 유저 2명, 게스트 유저 6명)
- 로그인 유저는 게스트 유저보다 2배 더 많은 사용량을 허용한다.
그룹 | Output 총량 | 1인당 Output |
🔐 로그인 | 4/10 = 444,444 | 222,222 tokens |
🔓 게스트 | 6/10 = 666,667 | 111,111 tokens |
📐 1회 최대 요청당 소모량 계산
- Input 토큰 수: 500자 × 2.5 = 1,250 tokens
- 기본 System prompt 44 tokens -> 총 1,294 tokens
- Output 토큰 수 (Input의 2배로 계산)
- 1,294 tokens × 2 = 2,588 tokens
🧮 최대 질문 가능 횟수
그룹 | 1회당 Output | 1인당 Output 할당 | 최대 질문 가능 횟수 |
🔐 로그인 | 2,588 tokens | 222,222 tokens | 85회 |
🔓 게스트 | 2,588 tokens | 125,000 tokens | 48회 |
🎯 최종 정책 정리
∙ 🔓 게스트 유저:
→ 하루 1회 요청 가능 (최대 31회/월)
∙ 🔐 로그인 유저:
→ 매달 222,222 tokens 할당 (최소 85회/월)
🥸 게스트 API 호출 제한
1. 게스트 식별 정보 추출
게스트 유저는 로그인 정보가 없기 때문에 IP 기준으로 요청을 식별해야 한다. 프록시나 로드밸런서를 고려해서 헤더도 함께 체크해 주는 IpExtractor 클래스를 구현했다.
@Component
public class IpExtractor {
private static final String HEADER_X_FORWARDED_FOR = "X-Forwarded-For";
private static final String IP_DELIMITER = ",";
public String extract(HttpServletRequest request) {
String forwarded = request.getHeader(HEADER_X_FORWARDED_FOR);
if (forwarded != null && !forwarded.isEmpty()) {
return forwarded.split(IP_DELIMITER)[0].trim();
}
return request.getRemoteAddr();
}
}
2. 컨트롤러에서 IP 추출
인터뷰 문답 생성 요청이 들어오면, 컨트롤러에서 로그인 여부 확인 후 게스트인 경우 IP를 추출해서 서비스 레이어로 전달한다.
@PostMapping("/prompts/{promptId}/dialogues")
public List<GeneratedDialogueDto> generateDialogues(
@PathVariable Long promptId, @RequestBody @Valid ApiPromptAnswersRequest apiRequest,
HttpServletRequest servletRequest
) {
GenerateDialoguesRequest request = new GenerateDialoguesRequest(promptId, apiRequest.promptAnswers());
if (authenticatedUserContext.isAuthenticated()) {
return promptService.generateDialoguesForUser(request);
}
String clientIp = ipExtractor.extract(servletRequest);
return promptService.generateDialoguesForGuest(request, clientIp);
}
3. 서비스에서 제한 검사 및 문답 생성
게스트 요청이 들어오면 PromptAccessLimiter를 통해 하루 1회 제한을 검사한 후에 OpenAI에 문답 생성 요청을 전송한다.
public List<GeneratedDialogueDto> generateDialoguesForGuest(GenerateDialoguesRequest request, String clientIp) {
promptAccessLimiter.validateAccess(clientIp);
GeneratedDialogues dialogues = generateDialogues(request);
promptAccessLimiter.markAccessed(clientIp);
return dialogues.dialogues();
}
4. Redis 기반 PromptAccessLimiter
validateAccess메서드에서는 동일 IP가짤은 시간 안에 중복 요청을 보내지 못하도록 30초 동안 임시 락을 걸고, 해당 IP를 가진 게스트가 오늘 요청을 보낸 내역이 있으면 예외를 던진다.
public void validateAccess(String clientIp) {
String lockKey = LOCK_PREFIX + clientIp;
Boolean lockAcquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, MARK_PRESENT, TEMP_LOCK_TTL);
if (Boolean.FALSE.equals(lockAcquired)) {
throw new NexterviewException(NexterviewErrorCode.REQUEST_TEMPORARILY_LOCKED);
}
String key = PREFIX + clientIp;
if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
redisTemplate.delete(lockKey);
throw new NexterviewException(NexterviewErrorCode.GUEST_PROMPT_ACCESS_EXCEEDED);
}
}
그리고 문답 생성이 완료된 후에는 해당 IP로 키를 저장하고, 만료 시간을 당일 자정까지 설정해 게스트의 API 호출을 제한한다.
public void markAccessed(String clientIp) {
String key = PREFIX + clientIp;
LocalDateTime now = LocalDateTime.now(ZONE_ID);
LocalDateTime midnight = now.toLocalDate().plusDays(1).atStartOfDay();
Duration untilMidnight = Duration.between(now, midnight);
redisTemplate.opsForValue().set(key, MARK_PRESENT, untilMidnight);
redisTemplate.delete(LOCK_PREFIX + clientIp);
}
다시 보면 절대 이해가 안 될 것 같은 동시성 테스트 코드도 작성해 준다:
@Test
void 동시에_여러_요청이_들어오면_하나만_성공한다() throws InterruptedException {
// ...
int threadCount = 5;
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger lockFailCount = new AtomicInteger(0);
try (ExecutorService executorService = Executors.newFixedThreadPool(threadCount)) {
for (int i = 0; i < threadCount; i++) {
executorService.execute(() -> {
awaitLatch(startLatch);
try {
promptService.generateDialoguesForGuest(request, ip);
successCount.incrementAndGet();
} catch (NexterviewException e) {
if (e.getErrorCode() == NexterviewErrorCode.REQUEST_TEMPORARILY_LOCKED) {
lockFailCount.incrementAndGet();
}
} finally {
doneLatch.countDown();
}
});
}
startLatch.countDown();
if (!doneLatch.await(15, TimeUnit.SECONDS)) {
executorService.shutdownNow();
throw new RuntimeException("Done latch await timeout");
}
assertThat(successCount.get()).isEqualTo(1);
assertThat(lockFailCount.get()).isEqualTo(threadCount - 1);
assertThat(redisTemplate.hasKey(accessKey)).isTrue();
assertThat(redisTemplate.hasKey(lockKey)).isFalse();
}
}
실행해 보면 호출 제한이 잘 구현된 것을 확인할 수 있다.
🏴☠️ 투비컨티뉴드
'감자-갱생-프로젝트' 카테고리의 다른 글
💵 유저 API 호출 제한하다가 (3) | 2025.05.23 |
---|---|
Spring Security는 사드세요... 제발 - Authentication 편 (2) | 2025.04.25 |
Spring Security는 사드세요... 제발 - Architecture 편 (1) | 2025.04.14 |
내 이름은 지속적 통합, 실패했죠 (3) | 2025.04.10 |
동시성의 이름으로 날 용서하지 않겠다 - Distributed Lock 편 (2) | 2024.12.03 |