✅ 질문 생성 시나리오
1️⃣ 질문 생성
- 23시 30분에 질문 생성 스케줄링이 시작.(하루에 카테고리 별로 1개씩 총 6개 질문 생성)
- AI에게 질문 생성을 요청하고 받아온 질문의 중복을 검증.
- 질문 생성은 GPT 4o mini 모델에 의해 이루어짐.
- 검증 과정은 text-embedding-3-large 임베딩 모델과 Redis Vector Database를 통해 이루어짐.
- 통과한 질문들은 데이터베이스에 저장한다.
- Redis Vector Database엔 질문의 벡터 값, MySQL에는 질문을 저장
- MySQL의 question 테이블에는 question_status라는 컬럼이 존재하는데 이는 세 가지의 Enum값을 가짐
- CREATED: 질문이 막 생성된 상태
- ACTIVATED: 질문이 활성화된 상태
- INACTIVATED: 질문이 비활성화된 상태
- 막 생성된 질문은 CREATED로 저장된다.
4. 모든 카테고리에 대해 중복없는 질문이 생성될 때까지 반복한다.
2️⃣ 질문 상태 업데이트
- 00시에 질문 업데이트 스케줄링이 시작.
- 기존의 ACTIVATED 상태의 질문들을 INACTIVATED 상태로 수정.
- 23시 30분에 생성된 질문들은 CREATED로 설정 되어 있는데 해당 질문의 상태를 ACTIVATED로 수정.
- 이후 질문을 조회 시 ACTIVATED 상태의 질문만 조회되도록 설정
💡 question_status 컬럼을 두는 이유
사실 오늘의 질문 여부를 판단하려면 단순하게 Date 타입의 컬럼을 두고 날짜로 판별해도 문제는 없고 오히려 이게 더 직관적이다.
하지만 질문 생성은 외부 API인 Open AI에 의존하고 있다. 외부 API 호출은 언제든지 실패할 가능성을 배제할 수 없다.
만약 질문 생성이 실패한다고 생각해보자.
1. 날짜로 오늘의 질문을 조회하는 경우
먼저 단순하게 날짜로 오늘의 질문을 조회하는 경우 오늘의 질문이 조회되지 않고 예외가 반환될 것이다.
2. question_status로 오늘의 질문을 조회하는 경우
반면 question_status로 질문을 조회하는 경우 ACTIVATED 상태의 질문만을 조회하기 때문에 실패한다 하더라도 어제의 질문이 자연스럽게 반환된다.
질문 생성 실패시, 예외가 발생하는 것보다는 어제의 질문이 그대로 노출되는 것이 훨씬 사용자경험(UX)상 좋을 것이다.
그렇기에 question_status라는 별도의 컬럼을 둔 것이다.
물론 날짜로 오늘의 질문을 조회하는 경우에도 예외를 잡아서 별도로 처리하면 되겠지만 이러면 코드가 늘어나고 데이터베이스 커넥션도 한번 더 사용하기 때문에 좋은 선택이 아닐 것이라고 생각했다.
✅ 구현
1️⃣ 질문 생성
private final QuestionAIService questionAIService; // AI 질문 관련 서비스
// 매일 23시 30분에 스케줄링 실행
@Scheduled(cron = "0 30 23 * * ?", zone = "Asia/Seoul")
public void createQuestion() {
log.info("질문 생성 스케줄링 시작");
int maxRetries = 10; // 최대 재시도 횟수
int attempt = 0;
while (attempt < maxRetries) {
try {
attempt++;
initList(); // 리스트 초기화
while (!categories.isEmpty()) {
Map<String, String> createdQuestions = questionAIService.createQuestions(categories);
for (String categoryName : createdQuestions.keySet()) {
String question = createdQuestions.get(categoryName);
if (!questionAIService.isSimilarTextExist(question)) {
questionAIService.saveQuestion(categoryName, question); // 데이터베이스에 질문 저장
log.info("질문 검증 완료: {}: {}", categoryName, question);
} else {
log.info("질문 중복: {}: {}", categoryName, question);
}
}
}
log.info("모든 질문 생성완료.");
log.info("질문 생성 스케줄링 성공");
break; // 성공 시 루프 종료
} catch (Exception e) {
log.error("질문 생성 스케줄링 실패. 시도 횟수: {}", attempt, e);
if (attempt >= maxRetries) {
log.error("모든 재시도가 실패했습니다.");
// TODO: 이메일로 알림
}
}
}
}
1. 매일 23시 30분에 질문 생성 스케줄링 실행
// 매일 23시 30분에 스케줄링 실행
@Scheduled(cron = "0 30 23 * * ?", zone = "Asia/Seoul")
public void createQuestion() {
2. QuestionAIService
private final QuestionAIService questionAIService; // AI 질문 관련 서비스
AI가 질문을 생성하고 중복을 검증하고 데이터베이스(MySQL, Redis Vector Database)에 접근하는 로직을 별도의 서비스로 분리하여 처리하였다.
3. AI로 부터 질문 받기
Map<String, String> createdQuestions = questionAIService.createQuestions(categories);
카테고리 목록이 담긴 리스트를 넘겨주고 AI는 이 리스트를 토대로 각 카테고리에 맞는 질문을 생성해준 뒤 처리하여 Map형태로 반환한다.
이때 AI는 ChatGPT 4o-mini가 활용된다.
4. 카테고리별로 질문 중복을 검증하고 검증에 성공하면 데이터베이스에 해당 질문을 저장
for (String categoryName : createdQuestions.keySet()) {
String question = createdQuestions.get(categoryName);
List<String> similarQuestions = questionAIService.findSimilarText(question);
if (similarQuestions.isEmpty()) {
questionAIService.saveQuestion(categoryName, question); // 데이터베이스에 질문 저장
log.info("질문 검증 완료: {}: {}", categoryName, question);
} else {
log.info("질문 중복: {}: {}", categoryName, question);
}
}
- 질문 중복 검증의 경우 앞선 포스트에서 설명했듯 텍스트 임베딩 모델인 text-embedding-3-large가 사용된다. 이때 유사도가 0.8이상이면 중복된 질문이라고 판단했다.
/**
* 비슷한 텍스트가 있는지 확인
*/
public boolean isSimilarTextExist(String text) {
// 유사도가 0.8이상인 텍스트를 하나만 검색
List<Document> documents = vectorStore.similaritySearch(
SearchRequest.query(text)
.withSimilarityThreshold(0.8)
.withTopK(1)
);
return !documents.isEmpty();
}
- 검증 성공시 데이터베이스에 질문을 저장한다.
- MySQL에는 질문을 Redis Vector Database에는 질문의 임베딩 값을 저장한다.
- MySQL에 저장된 질문의 question_status는 CREATED로 저장된다.
- 이런식으로 모든 질문에 대해 저장되면 로직은 종료된다.
✔️ 결과 예시
2️⃣ 질문 상태 업데이트
private final QuestionAIService questionAIService; // AI 질문 관련 서비스
@Scheduled(cron = "0 0 0 * * ?", zone = "Asia/Seoul") // 매일 00시 00분에 실행되도록 설정
public void updateQuestions() {
int maxRetries = 3; // 최대 재시도 횟수
int attempt = 0; // 현재 시도 횟수
while (attempt < maxRetries) {
try {
attempt++;
log.info("질문 업데이트 스케줄링 작업 실행, 시도 횟수: {}", attempt);
// 질문 상태 업데이트 로직
questionAIService.updateQuestions();
// 성공하면 루프 종료
log.info("질문 업데이트 스케줄링 작업 완료, 시도 횟수: {}", attempt);
break;
} catch (Exception e) {
log.error("질문 업데이트 스케줄링 작업 실패, 시도 횟수: {}, 에러: {}", attempt, e.getMessage());
// 마지막 시도에서 실패했을 때 추가 처리
if (attempt >= maxRetries) {
log.error("스케줄링 작업 모든 재시도 실패. ");
// TODO: 이메일로 알림
}
}
}
}
1. 매일 자정 질문 상태 업데이트 스케줄링 실행
@Scheduled(cron = "0 0 0 * * ?", zone = "Asia/Seoul") // 매일 00시 00분에 실행되도록 설정
public void updateQuestions() {
2. QuestionAIService
private final QuestionAIService questionAIService; // AI 질문 관련 서비스
AI가 질문을 생성하고 중복을 검증하고 데이터베이스(MySQL, Redis Vector Database)에 접근하는 로직을 별도의 서비스로 분리하여 처리하였다.
3. 질문 상태 업데이트
// 질문 상태 업데이트 로직
questionAIService.updateQuestions();
- 내부에서는 두 가지 업데이트가 이루어진다.
- 방금 막 생성된 질문들: CREATED → ACTIVATED
- 어제 질문들: ACTIVATED → INACTIVATED
✔️ 결과 예시
'데브코스 > 실습 & 프로젝트' 카테고리의 다른 글
[최종 프로젝트] 좋아요 API 동시성 이슈 해결 - 0. 배경 설명 (1) | 2024.12.31 |
---|---|
[최종 프로젝트] 질문 생성 기능 - 5. 질문 생성 로직 개선하기(질문 생성 횟수, 데이터 불일치 개선) (0) | 2024.12.30 |
[최종 프로젝트] 질문 생성 기능 - 3. 질문의 벡터를 저장하기 위한 Redis Vector Database & Vector Search (Spring AI) (0) | 2024.12.30 |
[최종 프로젝트] 질문 생성 기능 - 2. 중복 질문 방지를 위한 텍스트 임베딩 모델 조사 (2) | 2024.12.29 |
[최종 프로젝트] 질문 생성 기능 - 1. 질문 생성을 위한 생성형 AI 모델 조사 (2) | 2024.12.28 |