https://github.com/Hot6-NovelCraft/Hot6-NovelCraft
GitHub - Hot6-NovelCraft/Hot6-NovelCraft
Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.
github.com
개인별 배포용 레퍼지토리 [ AWS ]
https://github.com/MinWoo1995/Hot6-NovelCraft-local
GitHub - MinWoo1995/Hot6-NovelCraft-local
Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.
github.com
1. 오늘 한 일
오늘은 NovelCraft 프로젝트에서 AI 소설 표지 생성 기능을 동기 방식에서 Kafka 기반 비동기 처리로 고도화하고, 코드래빗 PR 리뷰를 반영하여 방어 로직을 강화했다.
- Google Gemini API 연동 기반 AI 소설 표지 생성 기능 구현
- 동기 방식(30초 블로킹) → Kafka 비동기 처리로 개선 (응답 시간 30초 → 150ms)
- CoverJob 엔티티 추가 (PENDING → PROCESSING → COMPLETED/FAILED 상태 추적)
- CoverGenerationProducer 구현 (Kafka 토픽 발행)
- CoverGenerationConsumer 구현 (Gemini 재시도 3회, S3 재시도 3회, 포인트 환불)
- CoverJobEventRelay 구현 (@TransactionalEventListener(AFTER_COMMIT) 적용)
- 표지 생성 상태 조회 API 추가 (GET /api/ai/novels/cover/status/{jobId})
- 작가 권한 + 본인 소설 검증 + 300포인트 차감 로직 구현
- 코드래빗 리뷰 5개 항목 반영 (중복 이벤트 방어, 포인트 차감 플래그, 상태 전이 가드 등)
- CoverService, CoverGenerationConsumer, CoverJob 단위 테스트 작성 (커버리지 100%)
2. AI 소설 표지 생성 기능 구현
기술 선택 - Gemini vs DALL-E 3
초기에는 DALL-E 3로 구현했으나 한국어 텍스트 렌더링이 깨지는 문제가 있었다. Google Gemini(gemini-2.5-flash-image)로 교체하자 한국어 제목과 작가명이 이미지에 완벽하게 렌더링됐다.
Gemini API는 이미지를 바이트 배열로 반환하는 구조라 DALL-E 3처럼 URL을 바로 반환하지 않는다. 따라서 S3에 직접 업로드하고 영구 URL을 반환하는 방식으로 구현했다.
프롬프트 설계
소설 DB에서 제목, 작가 닉네임, 장르, 줄거리를 자동으로 조회해 프롬프트에 주입한다.
String.format(
"Create a professional Korean novel cover image. " +
"The title '%s' must be written clearly and legibly at the top in large, stylish Korean typography. " +
"Below the title, write the author name '%s 지음' in smaller Korean text. " +
"Genre: %s. Story summary: %s. " +
"Style: cinematic, high quality book cover art.",
novel.getTitle(), authorName, novel.getGenre(), novel.getDescription()
)
3. Kafka 비동기 처리 고도화
왜 동기 방식이 문제였나
Gemini API 호출은 평균 20~30초가 소요된다. 동기 방식으로 처리하면 해당 스레드가 30초간 블로킹되어 톰캣 스레드 풀이 고갈될 위험이 있고, 다른 API 요청에도 영향을 미친다.
왜 @Async가 아닌 Kafka인가
단순 비동기 처리라면 @Async로도 가능하지만 Kafka를 선택한 이유는 세 가지다.
첫째, 메시지 영속성이다. 서버가 재시작되더라도 Kafka 브로커에 메시지가 남아있어 처리가 재개된다. @Async는 서버 재시작 시 처리 중인 작업이 유실된다.
둘째, 프로젝트에 Kafka가 이미 알림 시스템에 도입되어 있어 추가 인프라 없이 일관된 아키텍처를 유지할 수 있었다.
셋째, Consumer를 독립적으로 스케일아웃할 수 있는 구조다.
왜 AWS Lambda가 아닌 Kafka인가
Lambda도 좋은 선택지지만 이 프로젝트에서 Kafka를 선택한 이유는 다음과 같다. Lambda는 Cold Start 문제가 있어 첫 요청 시 수초의 지연이 발생할 수 있는데, Gemini API 자체가 이미 30초가 걸리는 상황에서 추가 지연은 부담이었다. 또한 Lambda + API Gateway + SQS 조합은 설정 복잡도가 높아 팀 프로젝트 일정상 Kafka가 현실적인 선택이었다.
처리 흐름
POST /api/ai/novels/{novelId}/cover
↓ 즉시 jobId 반환 (150ms)
CoverService → CoverJob 저장 → ApplicationEventPublisher.publishEvent()
↓ 트랜잭션 커밋 후
CoverJobEventRelay (@TransactionalEventListener AFTER_COMMIT)
↓
CoverGenerationProducer → Kafka 토픽 발행
↓
CoverGenerationConsumer
→ PENDING 상태 가드 (중복 이벤트 방어)
→ PROCESSING 상태 전이
→ Gemini 호출 (재시도 3회)
→ S3 업로드 (재시도 3회)
→ 포인트 차감 (isDeducted 플래그)
→ COMPLETED 상태 전이
→ 실패 시 FAILED + 포인트 환불 (차감된 경우에만)
GET /api/ai/novels/cover/status/{jobId}
→ PENDING / PROCESSING / COMPLETED / FAILED 조회
폴링 방식
현재는 클라이언트가 주기적으로 상태 조회 API를 호출하는 폴링 방식으로 구현했다. 개선 방향으로는 프로젝트에 이미 WebSocket이 알림 시스템에 도입되어 있으므로 서버에서 완료 시점에 Push하는 방식으로 전환할 수 있다.
4. 코드래빗 리뷰 반영
4-1. 중복 Kafka 이벤트 방어 (Major)
같은 jobId에 대한 중복 메시지가 도착할 경우 상태 검증 없이 모든 작업을 반복 실행하는 문제가 있었다. 포인트가 여러 번 차감될 수 있는 치명적인 버그다.
// 수정 전 - 가드 없음
job.processing();
// 수정 후 - PENDING 상태에서만 처리
if (job.getStatus() != CoverJobStatus.PENDING) {
log.warn("[Cover] 이미 처리된 Job 스킵 jobId={} status={}", event.jobId(), job.getStatus());
return;
}
4-2. 포인트 차감 전 실패 시 환불 방지 (Critical)
포인트 차감은 Gemini/S3 성공 후에 이루어지는데, 기존 catch 블록에서 차감 여부와 무관하게 무조건 charge()를 호출하고 있었다. Gemini/S3 단계에서 실패하면 차감도 안 됐는데 포인트가 그냥 증가하는 버그였다.
boolean isDeducted = false;
pointService.deduct(event.userId(), COVER_COST);
isDeducted = true;
// catch 블록에서
if (isDeducted) {
pointService.charge(event.userId(), COVER_COST); // 차감된 경우에만 환불
}
4-3. complete/fail 시 반대 필드 정리 (Major)
complete() 호출 시 이전에 설정된 errorMessage가 남아있거나, fail() 호출 시 coverImageUrl이 남아있어 조회 응답에 혼합 상태가 노출될 수 있는 문제였다.
public void complete(String coverImageUrl) {
this.status = CoverJobStatus.COMPLETED;
this.coverImageUrl = coverImageUrl;
this.errorMessage = null; // 추가
}
public void fail(String errorMessage) {
this.status = CoverJobStatus.FAILED;
this.errorMessage = errorMessage;
this.coverImageUrl = null; // 추가
}
4-4. Kafka 발행 실패 시 PENDING 영구 정체 (Major)
Kafka 발행이 실패하면 Job이 PENDING 상태로 영구히 남는 문제가 있었다. 발행 실패를 상위로 전파하거나 즉시 FAILED로 전환해야 한다.
try {
eventPublisher.publishEvent(new CoverJobCreatedEvent(jobId, novelId, userId));
} catch (Exception e) {
job.fail("Kafka 발행 실패");
coverJobRepository.save(job);
throw e;
}
4-5. 트랜잭션 커밋 전 Kafka 발행 (Major)
CoverJob을 저장한 직후 Kafka를 발행하면, Consumer가 메시지를 소비했을 때 트랜잭션이 아직 커밋되지 않아 DB에서 jobId를 찾지 못하는 경우가 발생할 수 있다. 알림 시스템의 NotificationEventRelay 패턴을 참고해 @TransactionalEventListener(AFTER_COMMIT)으로 해결했다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCoverJobCreated(CoverJobCreatedEvent event) {
coverGenerationProducer.publish(
new CoverGenerationEvent(event.jobId(), event.novelId(), event.userId())
);
}
5. 트러블슈팅
5-1. Gemini 1.5 Flash로 변경했으나 404 오류
Gemini AI에게 "이미지 생성이 가능한 모델을 알려달라"고 물어보니 gemini-1.5-flash를 추천했다. 변경 후 테스트했으나 아래 오류가 발생했다.
404 Not Found. models/gemini-1.5-flash is not found for API version v1beta,
or is not supported for generateContent.
Gemini 1.5 Flash는 텍스트 전용 모델이라 이미지 생성 자체가 불가능하다. 이미지 생성은 gemini-2.5-flash-image 모델을 사용해야 하며, 해당 모델은 무료 API 티어에서는 호출이 불가하다. AI Studio 웹 UI에서는 무료로 사용 가능하지만 API 호출은 별도 과금 구조다.
해결: Google AI Studio에서 결제 등록 후 gemini-2.5-flash-image 모델 사용
5-2. S3 업로드 후 Access Denied
S3 업로드는 성공했으나 이미지 URL 접근 시 Access Denied XML 응답이 반환됐다.
<Error>
<Code>AccessDenied</Code>
<Message>Access Denied</Message>
</Error>
원인: 버킷 정책에 퍼블릭 GetObject 허용 설정이 누락됐다.
해결: S3 버킷 정책에 아래 내용 추가
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::hot6-novelcraft-minwoo/*"
}
]
}
5-3. point_histories.type 컬럼 Data truncated 오류
AI_COVER 타입을 PointHistoryType enum에 추가했으나 실제 API 호출 시 500 에러가 발생했다.
SQL Error: 1265, SQLState: 01000
Data truncated for column 'type' at row 1
원인: point_histories.type 컬럼이 VARCHAR(10)으로 정의되어 있어 8자인 AI_COVER가 저장되지 않았다.
해결:
ALTER TABLE point_histories MODIFY COLUMN type VARCHAR(20) NOT NULL;
엔티티에도 명시적으로 length 추가
@Column(nullable = false, length = 20)
@Enumerated(value = EnumType.STRING)
private PointHistoryType type;
5-4. 기존 CoverService 코드와 포인트 중복 차감
Consumer에서 포인트 차감을 처리하는데 CoverService에도 포인트 선차감 로직이 남아있어 이중 차감이 발생할 수 있었다.
해결: CoverService에서 포인트 차감 로직 전체 제거. 포인트 처리는 Consumer에서만 담당하도록 단일 책임 원칙을 적용했다.
5-5. CoverServiceTest - ArgumentCaptor가 값을 캡처하지 못하는 문제
No argument value was captured!
You might have forgotten to use argument.capture() in verify()
원인: CoverService가 CoverGenerationProducer를 직접 호출하는 방식에서 ApplicationEventPublisher를 통한 이벤트 발행 방식으로 변경됐는데, 테스트 코드의 Mock 및 검증 대상이 업데이트되지 않았다.
해결:
// 기존
@Mock private CoverGenerationProducer coverGenerationProducer;
verify(coverGenerationProducer).publish(any(CoverGenerationEvent.class));
// 수정
@Mock private ApplicationEventPublisher eventPublisher;
verify(eventPublisher).publishEvent(any(CoverJobCreatedEvent.class));
6. 오늘의 회고
오늘 하루 동안 단순한 AI API 연동에서 시작해 Kafka 비동기 처리까지 고도화하면서 여러 가지를 배웠다.
설계 관점에서 외부 API 호출처럼 시간이 오래 걸리는 작업은 동기 방식으로 처리하면 안 된다는 것을 다시 한번 체감했다. 30초 블로킹이 단순히 사용자 경험의 문제가 아니라 서버 자원 고갈로 이어질 수 있다는 점이 핵심이었다.
Kafka와 @Async의 차이에 대해서도 명확하게 정리됐다. @Async는 구현이 간단하지만 서버 재시작 시 메시지 유실이 발생한다. Kafka는 복잡도가 높지만 메시지가 브로커에 영속화되어 장애 상황에도 재처리가 가능하다. 어떤 것이 절대적으로 옳은 것이 아니라 상황에 따라 트레이드오프를 이해하고 선택하는 것이 중요하다.
코드래빗 리뷰에서 다섯 가지 문제를 지적받았는데, 그중 Critical 항목인 "차감 전 실패에도 무조건 환불" 버그는 실제 서비스라면 사용자 포인트가 의도치 않게 증가하는 치명적인 문제였다. 테스트 코드가 이런 엣지 케이스를 잡아낼 수 있도록 시나리오를 촘촘하게 작성하는 것의 중요성을 다시 깨달았다.
오늘의 AI 이미지 생성 연결 결과물!
프롬포트를 작성하는게 아니라, 이미 가지고 있는 소설 정보를 바탕으로 알아서 자동생성해주는 기능으로 구현완료!

'spring_2기[본캠프] > 과제' 카테고리의 다른 글
| [파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 17 (0) | 2026.05.08 |
|---|---|
| [파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 15 (0) | 2026.05.06 |
| [파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 14 (0) | 2026.05.04 |
| [파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 13 (0) | 2026.05.01 |
| [파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 12 (1) | 2026.04.30 |