Tiny Star

프로젝트/뉴스 요약 (AI)

[뉴스 요약] 요청 제한 + 로그 기록 (Redis + Kafka)

하얀지 2025. 6. 26. 13:42

뉴스 요약 기능을 혼자 만들면서, 나중에 사용자에게 공개했을 때를 대비해 OpenAI API 호출에 제한을 둘 필요가 있다고 판단했다. 처음에는 프론트엔드에서 쿠키 기반으로 하루 5회 제한을 두는 식으로 간단하게 처리했지만, 브라우저나 기기를 바꾸면 무력화되는 구조라 한계가 뚜렷했다.

그래서 이번에 요청 제한을 백엔드 Redis로 이전하고, 동시에 요청 이력을 Kafka를 통해 비동기로 DB에 저장하는 구조를 추가했다. 아직 나 혼자만 사용하는 서비스이긴 하지만, 구조를 미리 잘 설계해두면 나중에 트래픽이 생겼을 때 대응하기 수월할 것 같았다.

 

 

 

전체 구조

전체 구조

요청이 들어오면 Redis에서 하루 5회 제한을 검사하고, 통과한 경우 GPT 요약 처리를 진행한다. 요약 결과는 DB에 저장하고, 동시에 요청 이력을 Kafka로 전송해서 별도 로그 테이블에 비동기로 쌓는다.

 

 

 

요청 제한: 프론트 캐시에서 Redis로 이전

처음엔 프론트엔드에서 쿠키 기반으로 하루 5회 제한을 걸었지만, 클라이언트에서 얼마든지 우회할 수 있다는 점이 가장 큰 문제였다. 그래서 제한 책임을 백엔드로 옮기고, Redis에 `ip + 날짜` 조합으로 요청 횟수를 기록하도록 했다.

먼저 현재 카운트를 조회하고, 요청이 성공한 경우에만 명시적으로 증가시키도록 처리했다.

int ANALYZE_LIMIT = 5, ANALYZE_TTL_SECONDS = 86400;
String ip = request.getRemoteAddr();
String key = "rate:" + ip + ":" + LocalDate.now();
long current = rateLimiter.getCurrentCount(key);
if (current >= ANALYZE_LIMIT) { // 5회 제한
    throw new CustomException(ErrorCode.RATE_LIMIT_EXCEEDED);
}
NewsResponse newsResponse = newsService.getNewsResponse(newsRequest.getUrl(), ip);
rateLimiter.incrementWithTtlIfNeeded(key, ANALYZE_TTL_SECONDS); // 정상 완료 후 INCR
public long getCurrentCount(String key) {
    String value = redisTemplate.opsForValue().get(key);
    return value == null ? 0 : Long.parseLong(value);
}

public void incrementWithTtlIfNeeded(String key, long ttlSeconds) {
    Long current = redisTemplate.opsForValue().increment(key);
    if (current != null && current == 1) {
        redisTemplate.expire(key, Duration.ofSeconds(ttlSeconds));
    }
}

 

 

 

요청 로그 저장: Kafka 비동기 전송

요약 요청이 성공했을 때 요청 정보를 별도 로그로 남기고 싶었다. 하지만 매번 DB에 직접 insert 하는 방식은 응답 시간에 영향을 줄 수 있어서, Kafka를 이용한 비동기 로그 전송 방식을 선택했다.

Kafka로 전송하는 메시지는 `RequestLogMessage` DTO이며, `userId`, `ipAddress`, `requestUrl`, `success`, `requestTime` 를 포함한다.

RequestLogMessage log = new RequestLogMessage(
    userId, ip, url, true, LocalDateTime.now()
);
requestLogProducer.sendLog(log);

Kafka Consumer는 `rate-log` 토픽을 구독해 메시지를 수신하고, 이를 DB에 저장한다. 실패한 요청도 함께 기록되기 때문에 운영 상황을 추적할 때 도움이 된다.

@KafkaListener(topics = "rate-log", groupId = "request-log-group")
public void consume(RequestLogMessage message) {
    UserRequestLog log = UserRequestLog.builder()
        .userId(message.getUserId())
        .ipAddress(message.getIpAddress())
        .requestUrl(message.getRequestUrl())
        .isSuccess(message.isSuccess())
        .requestTime(message.getRequestTime())
        .build();
    logRepository.save(log);
}

 

 

 

로그 테이블 설계

처음엔 기존에 쓰던 `news_summary_history` 테이블에 로그도 함께 넣을까 고민했지만, 요약 결과와 요청 로그는 성격이 완전히 다르다고 판단해서 분리했다.

CREATE TABLE user_request_log (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  ip_address VARCHAR(45),
  user_id BIGINT NULL,
  request_url TEXT,
  request_time DATETIME,
  is_success BOOLEAN
);

회원/비회원 통합 관리가 가능하고, 추후 로그인 기능이 생기더라도 구조 변경 없이 확장할 수 있다. 실패 요청도 함께 기록되기 때문에, 통계나 이상 탐지에 유용하게 활용할 수 있다.

 

 

클래스 간 흐름 구조

클래스 간 구조는 다음과 같다.

  • `NewsController` → 요청을 받고 `RateLimiter`, `NewsService` 호출
  • `RateLimiter` → Redis에서 제한 검사
  • `NewsService` → GPT 호출 + Kafka 로그 전송
  • `RequestLogProducer` → `RequestLogMessage` 전송
  • `RequestLogConsumer` → 메시지 수신 → `UserRequestLogRepository` 통해 DB 저장

이 구조를 통해 요약 요청 처리, 요청 제한, 로그 기록이 각각 분리된 책임으로 명확하게 구성된다.

 

 

 

적용 후 소감

사용자 트래픽이 거의 없는 사이드 프로젝트지만, 이런 구조를 미리 설계해두니까 마음이 편하다. 나중에 진짜 사람들이 쓰게 되면 갑자기 생기는 요청 폭주나 악의적 접근에 대해 최소한의 방어선이 생긴 셈이다.

또 Redis와 Kafka 같이 부담스러워 보이는 기술도 작게, 단순하게 사용하면 충분히 사이드 프로젝트에서도 잘 쓸 수 있다는 확신이 들었다. 이번 작업은 구조적으로 서비스의 뼈대를 다지는 느낌이었다. 

 

 

[레디스 적용 깃허브]

[카프카 적용 깃허브] 

top