From d32ea05d723b90804357f8185e7303e420a0b954 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 22:48:45 +0900 Subject: [PATCH 01/74] =?UTF-8?q?feat:=20JpaAuditingConfig=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseEntity의 @CreatedDate/@LastModifiedDate가 실제로 동작하도록 @EnableJpaAuditing 활성화 --- .../kkumteul/global/config/JpaAuditingConfig.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/global/config/JpaAuditingConfig.java diff --git a/src/main/java/com/capstone/kkumteul/global/config/JpaAuditingConfig.java b/src/main/java/com/capstone/kkumteul/global/config/JpaAuditingConfig.java new file mode 100644 index 0000000..da87a94 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.capstone.kkumteul.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} From 4602f09b34b6312e619fc76a8f7d53efc21c30cd Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 22:52:37 +0900 Subject: [PATCH 02/74] =?UTF-8?q?chore:=20ArchUnit=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vocab 도메인 service 패키지가 web/message/kafka에 의존하지 않는지 ArchUnit으로 자동 검증하기 위한 testImplementation 추가 --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index d2b05bd..668bcd9 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + // ArchUnit (carrier-agnostic 패키지 의존 검증용) + testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' } tasks.named('test') { From 78384b72923382406bcf22251a0b02df0df258cb Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 22:53:25 +0900 Subject: [PATCH 03/74] =?UTF-8?q?feat:=20WordEntry=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동화 페이지의 어려운 단어 + 풀이 저장. UNIQUE(fairytale_id, word) 제약으로 first-occurrence-wins 보장. --- .../domain/vocab/entity/WordEntry.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/vocab/entity/WordEntry.java diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/entity/WordEntry.java b/src/main/java/com/capstone/kkumteul/domain/vocab/entity/WordEntry.java new file mode 100644 index 0000000..40eb4c5 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/entity/WordEntry.java @@ -0,0 +1,45 @@ +package com.capstone.kkumteul.domain.vocab.entity; + +import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +/** + * 동화 페이지에서 추출된 어려운 단어 항목. + * + *

중복 정책: first-occurrence-wins — 같은 동화 안에서 같은 단어가 + * 여러 페이지에 등장해도 최초로 추출된 페이지 1개 row만 저장한다. + * UNIQUE(fairytale_id, word)로 강제하며, race condition은 + * {@code DataIntegrityViolationException} catch로 처리한다.

+ */ +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "word_entry", uniqueConstraints = { + @UniqueConstraint(name = "uk_word_entry_fairytale_word", columnNames = {"fairytale_id", "word"}) +}, indexes = { + @Index(name = "idx_word_entry_fairytale", columnList = "fairytale_id") +}) +public class WordEntry extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "word_entry_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fairytale_id", nullable = false) + private Fairytale fairytale; + + @Column(name = "page_no", nullable = false) + private int pageNo; + + @Column(nullable = false, length = 100) + private String word; + + @Column(nullable = false, columnDefinition = "TEXT") + private String meaning; +} From 953221606cf8f7cc433699507accacb141267add Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 22:53:53 +0900 Subject: [PATCH 04/74] =?UTF-8?q?feat:=20WordEntryRepository=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - existsByFairytaleIdAndWord: 중복 단어 pre-check - findByFairytaleIdOrderByPageNoAsc: 누적 단어장 페이지 순 조회 --- .../vocab/repository/WordEntryRepository.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/vocab/repository/WordEntryRepository.java diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/repository/WordEntryRepository.java b/src/main/java/com/capstone/kkumteul/domain/vocab/repository/WordEntryRepository.java new file mode 100644 index 0000000..e82622d --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/repository/WordEntryRepository.java @@ -0,0 +1,17 @@ +package com.capstone.kkumteul.domain.vocab.repository; + +import com.capstone.kkumteul.domain.vocab.entity.WordEntry; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface WordEntryRepository extends JpaRepository { + + /** 같은 동화에 같은 단어가 이미 등록되어 있는지 — first-occurrence-wins pre-check */ + boolean existsByFairytaleIdAndWord(Long fairytaleId, String word); + + /** 본인 동화 누적 단어장 조회 — 페이지 순서로 정렬 */ + List findByFairytaleIdOrderByPageNoAsc(Long fairytaleId); +} From da8b57502d592a193fe299578f134732df482435 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 22:54:48 +0900 Subject: [PATCH 05/74] =?UTF-8?q?feat:=20VocabErrorCode=20=EB=B0=8F=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VocabErrorCode enum (PARAGRAPH_NOT_FOUND_FOR_VOCAB, VOCAB_FORBIDDEN, VOCAB_EXTRACT_FAILED) - ParagraphNotFoundForVocabException - VocabForbiddenException --- .../ParagraphNotFoundForVocabException.java | 9 +++++++++ .../vocab/exception/VocabErrorCode.java | 20 +++++++++++++++++++ .../exception/VocabForbiddenException.java | 9 +++++++++ 3 files changed, 38 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/vocab/exception/ParagraphNotFoundForVocabException.java create mode 100644 src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabErrorCode.java create mode 100644 src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabForbiddenException.java diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/exception/ParagraphNotFoundForVocabException.java b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/ParagraphNotFoundForVocabException.java new file mode 100644 index 0000000..f77257b --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/ParagraphNotFoundForVocabException.java @@ -0,0 +1,9 @@ +package com.capstone.kkumteul.domain.vocab.exception; + +import com.capstone.kkumteul.global.exception.BaseException; + +public class ParagraphNotFoundForVocabException extends BaseException { + public ParagraphNotFoundForVocabException() { + super(VocabErrorCode.PARAGRAPH_NOT_FOUND_FOR_VOCAB); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabErrorCode.java b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabErrorCode.java new file mode 100644 index 0000000..f518fbe --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabErrorCode.java @@ -0,0 +1,20 @@ +package com.capstone.kkumteul.domain.vocab.exception; + +import com.capstone.kkumteul.global.response.code.BaseResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import static com.capstone.kkumteul.global.constant.StaticValue.*; + +@Getter +@AllArgsConstructor +public enum VocabErrorCode implements BaseResponseCode { + + PARAGRAPH_NOT_FOUND_FOR_VOCAB("VOCAB_404_1", NOT_FOUND, "해당 페이지의 본문을 찾을 수 없습니다."), + VOCAB_FORBIDDEN("VOCAB_403_1", FORBIDDEN, "본인 동화의 단어장만 조회할 수 있습니다."), + VOCAB_EXTRACT_FAILED("VOCAB_500_1", INTERNAL_SERVER_ERROR, "단어장 추출에 실패했습니다."); + + private final String code; + private final int httpStatus; + private final String message; +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabForbiddenException.java b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabForbiddenException.java new file mode 100644 index 0000000..679cd6c --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/exception/VocabForbiddenException.java @@ -0,0 +1,9 @@ +package com.capstone.kkumteul.domain.vocab.exception; + +import com.capstone.kkumteul.global.exception.BaseException; + +public class VocabForbiddenException extends BaseException { + public VocabForbiddenException() { + super(VocabErrorCode.VOCAB_FORBIDDEN); + } +} From 89baf44b15420bfb2cb01d60e0ac08b23857b674 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 22:55:22 +0900 Subject: [PATCH 06/74] =?UTF-8?q?feat:=20VocabExtractionResult=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5-state Status enum (SAVED/DUPLICATE/NO_DIFFICULT_WORD/EXTRACTION_FAILED/RACE_SKIPPED) service 패키지가 web/message에 의존하지 않도록 자체 DTO 사용. --- .../service/dto/VocabExtractionResult.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/vocab/service/dto/VocabExtractionResult.java diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/service/dto/VocabExtractionResult.java b/src/main/java/com/capstone/kkumteul/domain/vocab/service/dto/VocabExtractionResult.java new file mode 100644 index 0000000..37bf114 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/dto/VocabExtractionResult.java @@ -0,0 +1,48 @@ +package com.capstone.kkumteul.domain.vocab.service.dto; + +import com.capstone.kkumteul.domain.vocab.entity.WordEntry; + +/** + * VocabService 처리 결과를 나타내는 service-layer 값 객체. + * + *

web/dto, kafka/message 패키지에 의존하지 않는다. + * Controller나 KafkaListener는 이 결과를 자기 layer DTO로 변환해 사용한다.

+ * + * @param status 처리 결과 상태 + * @param entry 성공 시 저장된 엔티티, 그 외엔 null + */ +public record VocabExtractionResult(Status status, WordEntry entry) { + + public enum Status { + /** 새 단어가 추출되어 정상 저장됨 */ + SAVED, + /** 추출됐지만 같은 단어가 이미 단어장에 존재함 (first-occurrence-wins) */ + DUPLICATE, + /** LLM이 어려운 단어를 찾지 못함 (해당 페이지에 어려운 단어 없음) */ + NO_DIFFICULT_WORD, + /** LLM 호출 실패 또는 응답 파싱 실패 */ + EXTRACTION_FAILED, + /** 동시 INSERT race condition으로 인해 다른 트랜잭션이 먼저 저장 */ + RACE_SKIPPED + } + + public static VocabExtractionResult saved(WordEntry entry) { + return new VocabExtractionResult(Status.SAVED, entry); + } + + public static VocabExtractionResult duplicate() { + return new VocabExtractionResult(Status.DUPLICATE, null); + } + + public static VocabExtractionResult noDifficultWord() { + return new VocabExtractionResult(Status.NO_DIFFICULT_WORD, null); + } + + public static VocabExtractionResult extractionFailed() { + return new VocabExtractionResult(Status.EXTRACTION_FAILED, null); + } + + public static VocabExtractionResult raceSkipped() { + return new VocabExtractionResult(Status.RACE_SKIPPED, null); + } +} From 84c0e6636444d0c31f272e8dd735a92755eb5142 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 22:55:56 +0900 Subject: [PATCH 07/74] =?UTF-8?q?refactor:=20ParagraphRepository=EC=97=90?= =?UTF-8?q?=20findByFairytaleIdAndPage=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단어장 추출 시 특정 페이지의 문장만 조회하기 위한 메서드. 기존 findByFairytaleIdOrderByPageAsc는 그대로 유지. --- .../domain/fairytale/repository/ParagraphRepository.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/repository/ParagraphRepository.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/repository/ParagraphRepository.java index 1bf5020..aeb4f04 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/repository/ParagraphRepository.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/repository/ParagraphRepository.java @@ -10,4 +10,7 @@ public interface ParagraphRepository extends JpaRepository { List findByFairytaleIdOrderByPageAsc(Long fairytaleId); + + /** 특정 페이지의 문장들 조회 — 단어장 추출 시 페이지 단위 본문 로드 */ + List findByFairytaleIdAndPage(Long fairytaleId, int page); } From 8aae78cbd497b91608fbbb0f874adb737ed04ab0 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 22:56:23 +0900 Subject: [PATCH 08/74] =?UTF-8?q?refactor:=20RestTemplateConfig=EC=97=90?= =?UTF-8?q?=20vocabRestTemplate=20=EB=B9=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단어장 추출용 별도 RestTemplate 분리 (connect 1s / read 4s). 기존 restTemplate은 그래프 추출용으로 그대로 유지. --- .../global/config/RestTemplateConfig.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java b/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java index 4d7ecea..9e0cf3e 100644 --- a/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java +++ b/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java @@ -1,9 +1,12 @@ package com.capstone.kkumteul.global.config; +import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.client.RestTemplate; +import java.time.Duration; + @Configuration public class RestTemplateConfig { @@ -11,4 +14,17 @@ public class RestTemplateConfig { public RestTemplate restTemplate() { return new RestTemplate(); } + + /** + * 단어장 추출용 RestTemplate. + * 페이지당 1회 호출로 응답 빠르게 받아야 하므로 짧은 timeout 적용. + * (기본 restTemplate은 GraphService 등 그래프 추출에 쓰여 timeout 길어도 무방) + */ + @Bean + public RestTemplate vocabRestTemplate(RestTemplateBuilder builder) { + return builder + .connectTimeout(Duration.ofSeconds(1)) + .readTimeout(Duration.ofSeconds(4)) + .build(); + } } From 719be9978be933565e364403f9c1b50dcf890132 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 22:57:28 +0900 Subject: [PATCH 09/74] =?UTF-8?q?feat:=20VocabExtractClient=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(FastAPI=20/vocab/extract=20=ED=98=B8=EC=B6=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VocabExtractRequest/Response DTO - timeout/5xx 시 1회 retry, 4xx는 즉시 실패 - @PostConstruct warmup으로 cold-start 비용 사전 흡수 - 호출자에 예외 전파하지 않고 Optional.empty()로 fail-open --- .../global/client/VocabExtractClient.java | 79 +++++++++++++++++++ .../client/dto/VocabExtractRequest.java | 13 +++ .../client/dto/VocabExtractResponse.java | 15 ++++ 3 files changed, 107 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/global/client/VocabExtractClient.java create mode 100644 src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractRequest.java create mode 100644 src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractResponse.java diff --git a/src/main/java/com/capstone/kkumteul/global/client/VocabExtractClient.java b/src/main/java/com/capstone/kkumteul/global/client/VocabExtractClient.java new file mode 100644 index 0000000..88e09be --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/client/VocabExtractClient.java @@ -0,0 +1,79 @@ +package com.capstone.kkumteul.global.client; + +import com.capstone.kkumteul.global.client.dto.VocabExtractRequest; +import com.capstone.kkumteul.global.client.dto.VocabExtractResponse; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Optional; + +/** + * FastAPI(AI 서버)의 단어장 추출 엔드포인트 호출 클라이언트. + * + *

POST {fastApiBaseUrl}/vocab/extract 호출 → {word, meaning} JSON 응답. + * timeout/5xx에 한해 1회 retry, 4xx는 즉시 실패. + * 호출자에 예외를 전파하지 않고 {@code Optional.empty()}로 fail-open.

+ */ +@Slf4j +@Service +public class VocabExtractClient { + + private final RestTemplate vocabRestTemplate; + private final String fastApiBaseUrl; + + public VocabExtractClient( + @Qualifier("vocabRestTemplate") RestTemplate vocabRestTemplate, + @Value("${fastapi.base-url:http://localhost:8000}") String fastApiBaseUrl + ) { + this.vocabRestTemplate = vocabRestTemplate; + this.fastApiBaseUrl = fastApiBaseUrl; + } + + /** + * 부팅 시 FastAPI 헬스체크를 한 번 호출해 cold-start 비용을 미리 흡수. + * 실패해도 부팅은 계속 (WARN만 남김). + */ + @PostConstruct + public void warmup() { + try { + vocabRestTemplate.getForObject(fastApiBaseUrl + "/health", String.class); + log.info("FastAPI warmup 성공: {}", fastApiBaseUrl); + } catch (RestClientException e) { + log.warn("FastAPI warmup 실패 (서버 미기동 가능): {}", e.getMessage()); + } + } + + /** + * 3문장에서 어려운 단어 1개와 풀이를 추출. + * timeout/5xx 시 1회 retry. 모두 실패하면 {@code Optional.empty()}. + */ + public Optional extract(List sentences) { + for (int attempt = 1; attempt <= 2; attempt++) { + try { + VocabExtractResponse response = vocabRestTemplate.postForObject( + fastApiBaseUrl + "/vocab/extract", + new VocabExtractRequest(sentences), + VocabExtractResponse.class + ); + return Optional.ofNullable(response); + } catch (ResourceAccessException | HttpServerErrorException retryable) { + log.warn("vocab extract 일시적 실패 attempt={}: {}", attempt, retryable.getMessage()); + } catch (RestClientException e) { + HttpStatusCode status = (e instanceof org.springframework.web.client.HttpStatusCodeException hse) + ? hse.getStatusCode() : null; + log.warn("vocab extract 실패 (status={}): {}", status, e.getMessage()); + return Optional.empty(); + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractRequest.java b/src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractRequest.java new file mode 100644 index 0000000..19ff9f7 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractRequest.java @@ -0,0 +1,13 @@ +package com.capstone.kkumteul.global.client.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class VocabExtractRequest { + + private List sentences; +} diff --git a/src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractResponse.java b/src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractResponse.java new file mode 100644 index 0000000..eb4dad8 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/client/dto/VocabExtractResponse.java @@ -0,0 +1,15 @@ +package com.capstone.kkumteul.global.client.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class VocabExtractResponse { + + /** LLM이 선택한 어려운 단어. 없으면 null 또는 빈 문자열. */ + private String word; + + /** 유아 눈높이 풀이. */ + private String meaning; +} From c9400706c1721a6ef59acdcac1cc318abf385511 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 22:58:59 +0900 Subject: [PATCH 10/74] =?UTF-8?q?feat:=20VocabService=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=B2=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - processSentences: 3문장 → LLM 단어/풀이 추출 → DB 저장 (5-state 결과) - getVocab: 본인 동화 누적 단어장 페이지 순 조회 - DataIntegrityViolationException catch로 race condition 처리 - carrier-agnostic: web/dto, kafka/message에 의존하지 않음 --- .../domain/vocab/service/VocabService.java | 33 +++++++ .../vocab/service/VocabServiceImpl.java | 97 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java create mode 100644 src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java new file mode 100644 index 0000000..5643496 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java @@ -0,0 +1,33 @@ +package com.capstone.kkumteul.domain.vocab.service; + +import com.capstone.kkumteul.domain.vocab.entity.WordEntry; +import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; + +import java.util.List; + +/** + * 단어장 추출/조회 서비스. + * + *

이 인터페이스는 carrier-agnostic 하다 (web/dto, kafka/message에 의존하지 않음). + * REST controller / KafkaListener / 그 외 어떤 어댑터든 동일 시그니처로 호출 가능.

+ */ +public interface VocabService { + + /** + * 페이지(3문장 단위)에서 어려운 단어 1개를 추출하고 누적 단어장에 저장. + * + * @param fairytaleId 동화 ID + * @param pageNo 페이지 번호 (1-base) + * @param sentences 해당 페이지의 문장들 (보통 3개) + * @return 처리 결과 (저장됨 / 중복 / 단어 없음 / 추출 실패 / race skip) + */ + VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List sentences); + + /** + * 본인 동화의 누적 단어장 조회. 페이지 번호 오름차순. + * + * @param userId 요청자 (소유권 검증용) + * @param fairytaleId 동화 ID + */ + List getVocab(Long userId, Long fairytaleId); +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java new file mode 100644 index 0000000..13a7d4a --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java @@ -0,0 +1,97 @@ +package com.capstone.kkumteul.domain.vocab.service; + +import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.domain.fairytale.exception.FairytaleNotFoundException; +import com.capstone.kkumteul.domain.vocab.entity.WordEntry; +import com.capstone.kkumteul.domain.vocab.exception.VocabForbiddenException; +import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; +import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; +import com.capstone.kkumteul.global.client.VocabExtractClient; +import com.capstone.kkumteul.global.client.dto.VocabExtractResponse; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VocabServiceImpl implements VocabService { + + private final WordEntryRepository wordEntryRepository; + private final VocabExtractClient vocabExtractClient; + private final EntityManager entityManager; + + /** + * 페이지 3문장 → LLM으로 단어 추출 → 풀이 생성 → DB 저장. + * + *

처리 흐름:

+ *
    + *
  1. FastAPI 호출 → 단어/풀이 1회에 추출
  2. + *
  3. 응답 비어있으면 NO_DIFFICULT_WORD
  4. + *
  5. 실패하면 EXTRACTION_FAILED (예외 전파 X, fail-open)
  6. + *
  7. 이미 단어장에 있으면 DUPLICATE (first-occurrence-wins)
  8. + *
  9. 저장 시도 → race condition으로 UNIQUE 위반이면 RACE_SKIPPED
  10. + *
  11. 정상 저장이면 SAVED
  12. + *
+ */ + @Override + @Transactional + public VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List sentences) { + Optional extracted = vocabExtractClient.extract(sentences); + if (extracted.isEmpty()) { + return VocabExtractionResult.extractionFailed(); + } + + VocabExtractResponse response = extracted.get(); + String word = response.getWord(); + String meaning = response.getMeaning(); + if (word == null || word.isBlank() || meaning == null || meaning.isBlank()) { + return VocabExtractionResult.noDifficultWord(); + } + + if (wordEntryRepository.existsByFairytaleIdAndWord(fairytaleId, word)) { + return VocabExtractionResult.duplicate(); + } + + Fairytale fairytale = entityManager.getReference(Fairytale.class, fairytaleId); + WordEntry entry = WordEntry.builder() + .fairytale(fairytale) + .pageNo(pageNo) + .word(word) + .meaning(meaning) + .build(); + + try { + WordEntry saved = wordEntryRepository.save(entry); + return VocabExtractionResult.saved(saved); + } catch (DataIntegrityViolationException e) { + log.info("vocab race condition fairytaleId={}, word={}", fairytaleId, word); + return VocabExtractionResult.raceSkipped(); + } + } + + /** + * 본인 동화 누적 단어장 조회. + * 동화 소유권 검증 후 페이지 순서로 반환. + */ + @Override + public List getVocab(Long userId, Long fairytaleId) { + Fairytale fairytale = entityManager.find(Fairytale.class, fairytaleId); + if (fairytale == null) { + throw new FairytaleNotFoundException(); + } + Objects.requireNonNull(fairytale.getUser(), "Fairytale.user는 null이 될 수 없음"); + if (!fairytale.getUser().getId().equals(userId)) { + throw new VocabForbiddenException(); + } + return wordEntryRepository.findByFairytaleIdOrderByPageNoAsc(fairytaleId); + } +} From 1ffe4034869e4290d0e5a8d73345fcc99e98d88e Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 22:59:47 +0900 Subject: [PATCH 11/74] =?UTF-8?q?feat:=20VocabController=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EB=8B=A8=EC=96=B4=EC=9E=A5=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WordEntryRes DTO (record + from/listOf 정적 팩토리) - GET /api/fairytales/{fairytaleId}/vocab — 인증된 본인 동화만 조회 가능 --- .../vocab/web/controller/VocabController.java | 36 +++++++++++++++++++ .../domain/vocab/web/dto/WordEntryRes.java | 20 +++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/VocabController.java create mode 100644 src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/WordEntryRes.java diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/VocabController.java b/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/VocabController.java new file mode 100644 index 0000000..e3f3e71 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/VocabController.java @@ -0,0 +1,36 @@ +package com.capstone.kkumteul.domain.vocab.web.controller; + +import com.capstone.kkumteul.domain.user.entity.User; +import com.capstone.kkumteul.domain.vocab.entity.WordEntry; +import com.capstone.kkumteul.domain.vocab.service.VocabService; +import com.capstone.kkumteul.domain.vocab.web.dto.WordEntryRes; +import com.capstone.kkumteul.global.response.SuccessResponse; +import com.capstone.kkumteul.global.security.AuthUser; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/fairytales") +public class VocabController { + + private final VocabService vocabService; + + /** 본인 동화의 누적 단어장 조회 (페이지 순). */ + @GetMapping("/{fairytaleId}/vocab") + public ResponseEntity>> getVocab( + @AuthUser User user, + @PathVariable Long fairytaleId + ) { + List entries = vocabService.getVocab(user.getId(), fairytaleId); + return ResponseEntity.status(HttpStatus.OK) + .body(SuccessResponse.ok(WordEntryRes.listOf(entries))); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/WordEntryRes.java b/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/WordEntryRes.java new file mode 100644 index 0000000..0ce0442 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/WordEntryRes.java @@ -0,0 +1,20 @@ +package com.capstone.kkumteul.domain.vocab.web.dto; + +import com.capstone.kkumteul.domain.vocab.entity.WordEntry; + +import java.util.List; + +public record WordEntryRes( + Long wordEntryId, + int pageNo, + String word, + String meaning +) { + public static WordEntryRes from(WordEntry entry) { + return new WordEntryRes(entry.getId(), entry.getPageNo(), entry.getWord(), entry.getMeaning()); + } + + public static List listOf(List entries) { + return entries.stream().map(WordEntryRes::from).toList(); + } +} From b5f05598f922b1ca4814eab67a14478f2705c2c9 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 23:00:26 +0900 Subject: [PATCH 12/74] =?UTF-8?q?feat:=20InternalApiSecurityConfig=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(dev=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=ED=95=9C=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /internal/** 경로를 인증 없이 통과시키는 별도 SecurityFilterChain. @Profile("dev") + @Order(HIGHEST_PRECEDENCE)로 운영 환경엔 영향 없음. --- .../config/InternalApiSecurityConfig.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/global/config/InternalApiSecurityConfig.java diff --git a/src/main/java/com/capstone/kkumteul/global/config/InternalApiSecurityConfig.java b/src/main/java/com/capstone/kkumteul/global/config/InternalApiSecurityConfig.java new file mode 100644 index 0000000..6888fca --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/config/InternalApiSecurityConfig.java @@ -0,0 +1,35 @@ +package com.capstone.kkumteul.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +/** + * dev 프로필 한정 — {@code /internal/**} 경로를 인증 없이 통과시키는 SecurityFilterChain. + * + *

운영(prod) 프로필에선 이 빈이 등록되지 않으므로 + * 기존 {@link SecurityConfig}의 {@code .anyRequest().authenticated()}가 그대로 적용된다.

+ * + *

{@link com.capstone.kkumteul.global.jwt.JwtTokenFilter}는 {@code @Component}로 + * 모든 요청에 적용되지만, 토큰이 없으면 SecurityContext에 인증 객체를 설정하지 않으므로 + * permitAll된 {@code /internal/**}은 정상 통과된다.

+ */ +@Configuration +@Profile("dev") +public class InternalApiSecurityConfig { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityFilterChain internalApiSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/internal/**") + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } +} From a5d54776ccb0d649fa628b3b6293023bb27109a6 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 23:01:21 +0900 Subject: [PATCH 13/74] =?UTF-8?q?feat:=20InternalVocabController=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(dev=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=8B=9C=EC=97=B0=EC=9A=A9=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /internal/vocab/process - 카프카 없이 단어장 추출 비즈니스 로직 단독 호출 가능 - @Profile("dev")로 운영 환경엔 미등록 - @PostConstruct에서 등록 사실 INFO 로그로 노출 --- .../controller/InternalVocabController.java | 68 +++++++++++++++++++ .../web/dto/InternalVocabProcessReq.java | 14 ++++ 2 files changed, 82 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/InternalVocabController.java create mode 100644 src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/InternalVocabProcessReq.java diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/InternalVocabController.java b/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/InternalVocabController.java new file mode 100644 index 0000000..ff29bf5 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/InternalVocabController.java @@ -0,0 +1,68 @@ +package com.capstone.kkumteul.domain.vocab.web.controller; + +import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; +import com.capstone.kkumteul.domain.vocab.exception.ParagraphNotFoundForVocabException; +import com.capstone.kkumteul.domain.vocab.service.VocabService; +import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; +import com.capstone.kkumteul.domain.vocab.web.dto.InternalVocabProcessReq; +import com.capstone.kkumteul.domain.vocab.web.dto.WordEntryRes; +import com.capstone.kkumteul.global.response.SuccessResponse; +import jakarta.annotation.PostConstruct; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * dev 프로필 한정 — Kafka 없이 단어장 추출 비즈니스 로직을 단독 호출하기 위한 시연/테스트용 API. + * + *

운영 환경에서는 {@link InternalApiSecurityConfig}와 함께 등록되지 않으므로 노출되지 않는다.

+ */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/internal/vocab") +@Profile("dev") +public class InternalVocabController { + + private final ParagraphRepository paragraphRepository; + private final VocabService vocabService; + + @PostConstruct + public void announce() { + log.info("[DEV] /internal/vocab/process registered (dev profile active)"); + } + + @PostMapping("/process") + public ResponseEntity> process( + @Valid @RequestBody InternalVocabProcessReq req + ) { + List paragraphs = paragraphRepository.findByFairytaleIdAndPage( + req.getFairytaleId(), req.getPageNo() + ); + if (paragraphs.isEmpty()) { + throw new ParagraphNotFoundForVocabException(); + } + List sentences = paragraphs.stream().map(Paragraph::getText).toList(); + + VocabExtractionResult result = vocabService.processSentences( + req.getFairytaleId(), req.getPageNo(), sentences + ); + + WordEntryRes wordRes = result.entry() == null ? null : WordEntryRes.from(result.entry()); + return ResponseEntity.status(HttpStatus.OK) + .body(SuccessResponse.ok(new ProcessRes(result.status().name(), wordRes))); + } + + public record ProcessRes(String status, WordEntryRes word) { + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/InternalVocabProcessReq.java b/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/InternalVocabProcessReq.java new file mode 100644 index 0000000..08663dc --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/InternalVocabProcessReq.java @@ -0,0 +1,14 @@ +package com.capstone.kkumteul.domain.vocab.web.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class InternalVocabProcessReq { + + @NotNull + private Long fairytaleId; + + @NotNull + private Integer pageNo; +} From 8fd9fa37c4a36c0cef4abeb364ce1c02c0825420 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 23:02:08 +0900 Subject: [PATCH 14/74] =?UTF-8?q?test:=20VocabServiceArchTest=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(carrier-agnostic=20=EA=B2=80=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vocab.service 패키지가 web/message/kafka/servlet에 의존하지 않음을 ArchUnit으로 빌드 시점에 자동 검증. Phase 2 카프카 어댑터 추가 시에도 service 변경 없음을 강제 보장. --- .../vocab/service/VocabServiceArchTest.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceArchTest.java diff --git a/src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceArchTest.java b/src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceArchTest.java new file mode 100644 index 0000000..f41ff22 --- /dev/null +++ b/src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceArchTest.java @@ -0,0 +1,36 @@ +package com.capstone.kkumteul.domain.vocab.service; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * vocab service 패키지가 carrier(웹/카프카) 의존을 갖지 않음을 빌드 시점에 검증. + * + *

이 테스트가 깨진다는 것은 누군가 service에 web DTO 또는 kafka import를 추가했다는 신호. + * Phase 2 카프카 어댑터 추가 시점에도 service 코드 자체는 변경되지 않아야 한다.

+ */ +class VocabServiceArchTest { + + private static final JavaClasses CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("com.capstone.kkumteul"); + + @Test + void vocab_service_has_no_web_or_kafka_dependencies() { + noClasses() + .that().resideInAPackage("..vocab.service..") + .should().dependOnClassesThat() + .resideInAnyPackage( + "..vocab.web..", + "..vocab.message..", + "org.springframework.kafka..", + "org.springframework.web..", + "jakarta.servlet.." + ) + .check(CLASSES); + } +} From f3b3a8aa0254ee8f1449c7e2ec80f7df61e5b350 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 30 Apr 2026 23:02:30 +0900 Subject: [PATCH 15/74] =?UTF-8?q?chore:=20application-dev.properties=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(dev=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EC=A3=BC=EC=84=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev 프로필 활성화 시 동작과 운영 환경에서 활성화 금지 안내. --- src/main/resources/application-dev.properties | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/main/resources/application-dev.properties diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..dec2ce8 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,8 @@ +# dev 프로필 전용 설정. +# 활성화 방법: -Dspring.profiles.active=dev 또는 SPRING_PROFILES_ACTIVE=dev +# +# dev 프로필일 때 추가 동작: +# - InternalApiSecurityConfig 등록 → /internal/** 경로 인증 면제 +# - InternalVocabController 등록 → POST /internal/vocab/process 시연용 API 노출 +# +# 운영 환경에서는 이 프로필을 활성화하지 말 것. From 34d4c4298ede35e5dbe8687323077711921b69c7 Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Mon, 4 May 2026 12:47:00 +0900 Subject: [PATCH 16/74] =?UTF-8?q?Docs:=20Spring=20Kafka=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index d2b05bd..af3f677 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.kafka:spring-kafka' + testImplementation 'org.springframework.kafka:spring-kafka-test' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' From 773b1b4c0252a6aabe1f48ab778e4f2da66c547e Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Mon, 4 May 2026 14:37:11 +0900 Subject: [PATCH 17/74] =?UTF-8?q?Feat:=20=EB=8F=99=ED=99=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=9A=94=EC=B2=AD=EC=9D=B4=20=EB=93=A4=EC=96=B4?= =?UTF-8?q?=EC=98=AC=20=EB=95=8C,=20=ED=95=B4=EB=8B=B9=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=9D=84=20DTO=EB=A1=9C=20=EA=B4=80=EB=A6=AC=ED=95=A0?= =?UTF-8?q?=20=EC=88=98=20=EC=9E=88=EA=B2=8C=EB=81=94=20=EA=B0=9D=EC=B2=B4?= =?UTF-8?q?=20=EC=84=A0=EC=96=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/FairytaleGenerateReq.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/FairytaleGenerateReq.java diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/FairytaleGenerateReq.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/FairytaleGenerateReq.java new file mode 100644 index 0000000..bdfaf66 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/FairytaleGenerateReq.java @@ -0,0 +1,23 @@ +package com.capstone.kkumteul.domain.fairytale.web.dto; + +import com.capstone.kkumteul.domain.fairytale.entity.Background; +import com.capstone.kkumteul.domain.fairytale.entity.CharSpecies; +import com.capstone.kkumteul.domain.fairytale.entity.Morality; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class FairytaleGenerateReq { + + @NotNull(message = "배경은 비어있을 수 없습니다.") + private final Background background; + + @NotNull(message = "등장인물 종류는 비어있을 수 없습니다.") + private final CharSpecies charSpecie; + + @NotNull(message = "교훈은 비어있을 수 없습니다.") + private final Morality morality; + +} From fc45113a6468a4be9ae4375f43303c02e922ccba Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Mon, 4 May 2026 14:49:38 +0900 Subject: [PATCH 18/74] =?UTF-8?q?Feat:=20Kafka=20Producer=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95,=20key=20-=20String,=20value=20-=20Json=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=8C=8C=EC=8B=B1=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=ED=95=A8.=20=EB=98=90=ED=95=9C,=20KafkaTemplate=EB=A1=9C=20?= =?UTF-8?q?=EB=A9=94=EC=84=B8=EC=A7=80=20=EB=B6=80=EB=B6=84=EC=9D=84=20Mes?= =?UTF-8?q?sageInterface=EB=A1=9C=20=EB=9F=B0=ED=83=80=EC=9E=84=EC=97=90?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=EC=9D=84=20=EC=B6=94=EB=A1=A0=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=ED=95=98=EC=97=AC=20=EB=8F=99=EC=A0=81=20=EB=8B=A4?= =?UTF-8?q?=ED=98=95=EC=84=B1=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/KafkaProducerConfig.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java diff --git a/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java b/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java new file mode 100644 index 0000000..f368cad --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java @@ -0,0 +1,47 @@ +package com.capstone.kkumteul.global.config; + +import com.capstone.kkumteul.domain.kafka.dto.FairytaleGenerateMessage; +import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaProducerConfig { + + private final String KAFKA_URL; + + public KafkaProducerConfig( + @Value("${KAFKA_URL}") String KAFKA_URL + ) { this.KAFKA_URL = KAFKA_URL; } + + @Bean + + public ProducerFactory producerFactory() { + + Map config = new HashMap<>(); + + // key - String Serialization + // value - Json Serialization + config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_URL); + config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); + + return new DefaultKafkaProducerFactory<>(config); + } + + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} From 0c56107031d40ee9b7d107cf13b1aac2368208a6 Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Mon, 4 May 2026 14:50:51 +0900 Subject: [PATCH 19/74] =?UTF-8?q?Feat:=20KafkaTemplate=EC=9D=98=20?= =?UTF-8?q?=EB=A9=94=EC=84=B8=EC=A7=80=20=EA=B0=9D=EC=B2=B4=EB=A5=BC=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=20=EB=8B=A4=ED=98=95=EC=84=B1=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=84=A4=EA=B3=84=ED=95=98=EA=B8=B0=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20MessageInterface=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EA=B0=9D=EC=B2=B4=20=EC=84=A0=EC=96=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkumteul/domain/kafka/dto/MessageInterface.java | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/kafka/dto/MessageInterface.java diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/MessageInterface.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/MessageInterface.java new file mode 100644 index 0000000..fd733ce --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/MessageInterface.java @@ -0,0 +1,6 @@ +package com.capstone.kkumteul.domain.kafka.dto; + +public interface MessageInterface { + + Long getUserId(); +} From 838f4ba5a6322312130939b736ba89fbe9cb53c9 Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Mon, 4 May 2026 14:52:27 +0900 Subject: [PATCH 20/74] =?UTF-8?q?Feat:=20=EB=8F=99=ED=99=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?FastAPI=EB=A1=9C=20=EC=A0=84=ED=8C=8C=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20=ED=95=84=EC=9A=94=ED=95=9C=20=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EB=A9=94=EC=84=B8=EC=A7=80=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=8C=8C=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=A9=94=EC=84=B8?= =?UTF-8?q?=EC=A7=80=20=EA=B0=9D=EC=B2=B4=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kafka/dto/FairytaleGenerateMessage.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleGenerateMessage.java diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleGenerateMessage.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleGenerateMessage.java new file mode 100644 index 0000000..f0488af --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleGenerateMessage.java @@ -0,0 +1,20 @@ +package com.capstone.kkumteul.domain.kafka.dto; + +import com.capstone.kkumteul.domain.fairytale.entity.Background; +import com.capstone.kkumteul.domain.fairytale.entity.CharSpecies; +import com.capstone.kkumteul.domain.fairytale.entity.Morality; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class FairytaleGenerateMessage implements MessageInterface { + + private final Long userId; + + private final Background background; + private final CharSpecies charSpecies; + private final Morality morality; +} From 1fcde037857995ed945ac5992e6bb0f16adba001 Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Mon, 4 May 2026 14:54:22 +0900 Subject: [PATCH 21/74] =?UTF-8?q?Feat:=20Kafka=EC=97=90=20=EB=A9=94?= =?UTF-8?q?=EC=84=B8=EC=A7=80=EB=A5=BC=20=EB=93=B1=EB=A1=9D=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=ED=95=98=EB=8A=94=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EA=B0=9D=EC=B2=B4=EC=99=80=20=EB=8F=99=ED=99=94=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=84=ED=8C=8C=ED=95=98=EB=8F=84=EB=A1=9D=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/kafka/service/EventService.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java new file mode 100644 index 0000000..58e55c6 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java @@ -0,0 +1,33 @@ +package com.capstone.kkumteul.domain.kafka.service; + +import com.capstone.kkumteul.domain.fairytale.web.dto.FairytaleGenerateReq; +import com.capstone.kkumteul.domain.kafka.dto.FairytaleGenerateMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; + +/* 동화 생성 이벤트 전파 */ + +@Service +@Slf4j(topic = "event") +@RequiredArgsConstructor +public class EventService { + + private final KafkaTemplate kafkaTemplate; + + public void commentMessageCreate(Long userId, FairytaleGenerateReq request) { + + FairytaleGenerateMessage message = FairytaleGenerateMessage.builder() + .userId(userId) + .background(request.getBackground()) + .charSpecies(request.getCharSpecie()) + .morality(request.getMorality()) + .build(); + + log.info("fairytale_generate userId={}, message={}", userId, message); + + kafkaTemplate.send("fairytale_generate", message); + + } +} From df6059cb4b56a73f5ed380f840270f078fa1ca25 Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Wed, 6 May 2026 19:41:05 +0900 Subject: [PATCH 22/74] =?UTF-8?q?Docs:=20Kafka=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=9C=84=ED=95=B4=20.env=EC=97=90=20Kafka?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=9C=20=ED=86=B5=EC=8B=A0=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EC=A3=BC=EC=86=8C=20=EA=B8=B0=EB=A1=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=20.env=EB=A5=BC=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=EC=9D=BD=EA=B2=8C=ED=95=98=EC=97=AC=20Kafka=20=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=20=EC=A3=BC=EC=86=8C=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 ++ .../kkumteul/kafka/KafkaProducerTest.java | 51 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java diff --git a/build.gradle b/build.gradle index af3f677..292218c 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + // for Kafka test + testImplementation 'me.paulschwarz:spring-dotenv:4.0.0' } tasks.named('test') { diff --git a/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java b/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java new file mode 100644 index 0000000..deef991 --- /dev/null +++ b/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java @@ -0,0 +1,51 @@ +package com.capstone.kkumteul.kafka; + +import com.capstone.kkumteul.domain.fairytale.entity.Background; +import com.capstone.kkumteul.domain.fairytale.entity.CharSpecies; +import com.capstone.kkumteul.domain.fairytale.entity.Morality; +import com.capstone.kkumteul.domain.kafka.dto.FairytaleGenerateMessage; +import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; +import com.capstone.kkumteul.global.config.KafkaProducerConfig; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest( + classes = KafkaProducerConfig.class, + properties = "KAFKA_URL=52.78.205.133:9092" +) +class KafkaProducerTest { + + @Autowired + private KafkaTemplate kafkaTemplate; + + @Test + void messagePublicationTest() throws Exception { + + FairytaleGenerateMessage message = FairytaleGenerateMessage.builder() + .userId(1L) + .background(Background.FOREST_NATURE) + .charSpecies(CharSpecies.ANIMAL) + .morality(Morality.KINDNESS_REWARDED) + .build(); + + CompletableFuture> future = + kafkaTemplate.send("fairytale_generate", message); + + SendResult result = future.get(10, TimeUnit.SECONDS); + + assertThat(result.getRecordMetadata().topic()).isEqualTo("fairytale_generate"); + assertThat(result.getRecordMetadata().offset()).isGreaterThanOrEqualTo(0); + + System.out.println("토픽: " + result.getRecordMetadata().topic()); + System.out.println("파티션: " + result.getRecordMetadata().partition()); + System.out.println("오프셋: " + result.getRecordMetadata().offset()); + } +} From d228a432925b6efecb20948bc403f5fcf20d900a Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Wed, 6 May 2026 19:41:23 +0900 Subject: [PATCH 23/74] =?UTF-8?q?Test:=20Kafka=20=ED=86=B5=EC=8B=A0=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4,=20?= =?UTF-8?q?=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/capstone/kkumteul/kafka/KafkaProducerTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java b/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java index deef991..1611b9e 100644 --- a/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java +++ b/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java @@ -18,8 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest( - classes = KafkaProducerConfig.class, - properties = "KAFKA_URL=52.78.205.133:9092" + classes = KafkaProducerConfig.class ) class KafkaProducerTest { From 383bcc3c687a0882055b0762e45092b4d74dc97c Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Wed, 6 May 2026 19:43:03 +0900 Subject: [PATCH 24/74] =?UTF-8?q?Fix:=20kakfa=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=A0=9C=EB=84=A4=EB=A6=AD=EC=9D=84=20=EC=84=A0?= =?UTF-8?q?=EC=96=B8=ED=95=9C=20Bean=20=ED=83=80=EC=9E=85=EA=B3=BC=20?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../capstone/kkumteul/domain/kafka/service/EventService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java index 58e55c6..3891864 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java @@ -2,6 +2,7 @@ import com.capstone.kkumteul.domain.fairytale.web.dto.FairytaleGenerateReq; import com.capstone.kkumteul.domain.kafka.dto.FairytaleGenerateMessage; +import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.core.KafkaTemplate; @@ -14,7 +15,7 @@ @RequiredArgsConstructor public class EventService { - private final KafkaTemplate kafkaTemplate; + private final KafkaTemplate kafkaTemplate; public void commentMessageCreate(Long userId, FairytaleGenerateReq request) { From 2a477d3503c2800d31c124b75e26929efed3d46f Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Thu, 7 May 2026 18:50:40 +0900 Subject: [PATCH 25/74] =?UTF-8?q?Docs:=20Kafka=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=9C=84=ED=95=B4=20.env=20=EB=82=B4?= =?UTF-8?q?=EC=9D=98=20Kafka=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=EC=A3=BC=EC=9E=85=EB=B0=9B=EA=B2=8C=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=EB=A5=BC=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 292218c..053c33c 100644 --- a/build.gradle +++ b/build.gradle @@ -43,8 +43,8 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' - // for Kafka test - testImplementation 'me.paulschwarz:spring-dotenv:4.0.0' + // .env 파일 로딩 + implementation 'me.paulschwarz:spring-dotenv:4.0.0' } tasks.named('test') { From 5002fd1408506c98b88a4bcd77544f86a4ecbe70 Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Thu, 7 May 2026 18:51:15 +0900 Subject: [PATCH 26/74] =?UTF-8?q?Fix:=20DI=EB=B0=9B=EB=8D=98=20kafkaTempla?= =?UTF-8?q?te=EC=9D=98=20=EB=A7=A4=EA=B0=9C=EB=B3=80=EC=88=98=EB=A5=BC=20F?= =?UTF-8?q?airytaleGenerateReq=EC=97=90=EC=84=9C=20MessageInterface?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../capstone/kkumteul/domain/kafka/service/EventService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java index 3891864..cc8c3b4 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java @@ -17,7 +17,7 @@ public class EventService { private final KafkaTemplate kafkaTemplate; - public void commentMessageCreate(Long userId, FairytaleGenerateReq request) { + public void createFairytaleMessageSend(Long userId, FairytaleGenerateReq request) { FairytaleGenerateMessage message = FairytaleGenerateMessage.builder() .userId(userId) From 8bab1403db09f2a132634f2cba6fa78482085aff Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Thu, 7 May 2026 18:51:38 +0900 Subject: [PATCH 27/74] =?UTF-8?q?Feat:=20=EB=8F=99=ED=99=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=9A=94=EC=B2=AD=EC=9D=B4=20=EB=93=A4=EC=96=B4?= =?UTF-8?q?=EC=98=AC=20=EC=8B=9C,=20Kafka=EC=97=90=20=EB=8F=99=ED=99=94=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A9=94=EC=84=B8=EC=A7=80=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=ED=95=98=EB=8A=94=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EB=A9=94=EC=86=8C=EB=93=9C=EB=A5=BC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/FairytaleController.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java index 8780372..acc463a 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java @@ -3,10 +3,13 @@ import com.capstone.kkumteul.domain.fairytale.entity.Island; import com.capstone.kkumteul.domain.fairytale.service.FairytaleService; import com.capstone.kkumteul.domain.fairytale.web.dto.FairytaleDetailRes; +import com.capstone.kkumteul.domain.fairytale.web.dto.FairytaleGenerateReq; import com.capstone.kkumteul.domain.fairytale.web.dto.FairytaleListRes; +import com.capstone.kkumteul.domain.kafka.service.EventService; import com.capstone.kkumteul.domain.user.entity.User; import com.capstone.kkumteul.global.response.SuccessResponse; import com.capstone.kkumteul.global.security.AuthUser; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -21,6 +24,19 @@ public class FairytaleController { private final FairytaleService fairytaleService; + private final EventService eventService; + + @PostMapping + public ResponseEntity> createFairytale( + @AuthUser User user, + @Valid @RequestBody FairytaleGenerateReq request + ) { + eventService.createFairytaleMessageSend(user.getId(), request); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(SuccessResponse.empty()); + } @GetMapping("/my") public ResponseEntity>> getMyFairytales( From 603562eb89b9f6563506a3c2014d22490c3f7bbf Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Thu, 7 May 2026 19:00:53 +0900 Subject: [PATCH 28/74] =?UTF-8?q?Docs:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20import=EB=AC=B8=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fairytale/exception/FairytaleErrorCode.java | 2 +- .../fairytale/web/dto/FairytaleGenerateReq.java | 2 +- .../kkumteul/domain/game/entity/NodeCategory.java | 3 +-- .../kkumteul/domain/game/service/GameServiceImpl.java | 6 +----- .../kkumteul/domain/kafka/service/EventService.java | 11 ++++++++++- .../kkumteul/global/config/KafkaProducerConfig.java | 1 - .../global/exception/GlobalExceptionHandler.java | 2 +- .../capstone/kkumteul/kafka/KafkaProducerTest.java | 8 +++++--- 8 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/FairytaleErrorCode.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/FairytaleErrorCode.java index ad82647..4c3abb1 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/FairytaleErrorCode.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/exception/FairytaleErrorCode.java @@ -4,7 +4,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; -import static com.capstone.kkumteul.global.constant.StaticValue.*; +import static com.capstone.kkumteul.global.constant.StaticValue.NOT_FOUND; @Getter @AllArgsConstructor diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/FairytaleGenerateReq.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/FairytaleGenerateReq.java index bdfaf66..386732a 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/FairytaleGenerateReq.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/FairytaleGenerateReq.java @@ -15,7 +15,7 @@ public class FairytaleGenerateReq { private final Background background; @NotNull(message = "등장인물 종류는 비어있을 수 없습니다.") - private final CharSpecies charSpecie; + private final CharSpecies charSpecies; @NotNull(message = "교훈은 비어있을 수 없습니다.") private final Morality morality; diff --git a/src/main/java/com/capstone/kkumteul/domain/game/entity/NodeCategory.java b/src/main/java/com/capstone/kkumteul/domain/game/entity/NodeCategory.java index cd29c58..3cf97a6 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/entity/NodeCategory.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/entity/NodeCategory.java @@ -1,10 +1,9 @@ package com.capstone.kkumteul.domain.game.entity; +import com.capstone.kkumteul.domain.game.exception.InvalidCategoryException; import lombok.AllArgsConstructor; import lombok.Getter; -import com.capstone.kkumteul.domain.game.exception.InvalidCategoryException; - import java.util.Arrays; @Getter diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java index 68fe4c7..6ea1cb9 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java @@ -1,12 +1,8 @@ package com.capstone.kkumteul.domain.game.service; import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; -import com.capstone.kkumteul.domain.game.entity.GraphEdge; -import com.capstone.kkumteul.domain.game.entity.GraphNode; -import com.capstone.kkumteul.domain.game.entity.NodeCategory; -import com.capstone.kkumteul.domain.game.entity.EdgeChoice; -import com.capstone.kkumteul.domain.game.entity.GameResult; import com.capstone.kkumteul.domain.fairytale.exception.FairytaleNotFoundException; +import com.capstone.kkumteul.domain.game.entity.*; import com.capstone.kkumteul.domain.game.exception.*; import com.capstone.kkumteul.domain.game.repository.EdgeChoiceRepository; import com.capstone.kkumteul.domain.game.repository.GameResultRepository; diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java index cc8c3b4..18d4aeb 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java @@ -5,6 +5,7 @@ import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Service; @@ -17,6 +18,9 @@ public class EventService { private final KafkaTemplate kafkaTemplate; + @Value("${FAIRYTALE_GENERATION}") + private String FAIRYTALE_GENERATION; + public void createFairytaleMessageSend(Long userId, FairytaleGenerateReq request) { FairytaleGenerateMessage message = FairytaleGenerateMessage.builder() @@ -28,7 +32,12 @@ public void createFairytaleMessageSend(Long userId, FairytaleGenerateReq request log.info("fairytale_generate userId={}, message={}", userId, message); - kafkaTemplate.send("fairytale_generate", message); + kafkaTemplate.send(FAIRYTALE_GENERATION, message) + .whenComplete((result, e) -> { + if (e != null) { + log.error("fairytale_generate failed", e); + } + }); } } diff --git a/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java b/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java index f368cad..f04bf37 100644 --- a/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java +++ b/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java @@ -1,6 +1,5 @@ package com.capstone.kkumteul.global.config; -import com.capstone.kkumteul.domain.kafka.dto.FairytaleGenerateMessage; import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.common.serialization.StringSerializer; diff --git a/src/main/java/com/capstone/kkumteul/global/exception/GlobalExceptionHandler.java b/src/main/java/com/capstone/kkumteul/global/exception/GlobalExceptionHandler.java index d35e58e..477184d 100644 --- a/src/main/java/com/capstone/kkumteul/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/capstone/kkumteul/global/exception/GlobalExceptionHandler.java @@ -8,10 +8,10 @@ import org.springframework.validation.BindException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.multipart.support.MissingServletRequestPartException; import org.springframework.web.servlet.NoHandlerFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException; diff --git a/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java b/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java index 1611b9e..9578cc9 100644 --- a/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java +++ b/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java @@ -6,6 +6,7 @@ import com.capstone.kkumteul.domain.kafka.dto.FairytaleGenerateMessage; import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; import com.capstone.kkumteul.global.config.KafkaProducerConfig; +import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -17,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; +@Slf4j @SpringBootTest( classes = KafkaProducerConfig.class ) @@ -43,8 +45,8 @@ void messagePublicationTest() throws Exception { assertThat(result.getRecordMetadata().topic()).isEqualTo("fairytale_generate"); assertThat(result.getRecordMetadata().offset()).isGreaterThanOrEqualTo(0); - System.out.println("토픽: " + result.getRecordMetadata().topic()); - System.out.println("파티션: " + result.getRecordMetadata().partition()); - System.out.println("오프셋: " + result.getRecordMetadata().offset()); + log.info("토픽: {}", result.getRecordMetadata().topic()); + log.info("파티션: {}", result.getRecordMetadata().partition()); + log.info("오프셋: {}", result.getRecordMetadata().offset()); } } From 848d19379d160a08b58a007e033bf64afa22e40d Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sat, 9 May 2026 15:02:52 +0900 Subject: [PATCH 29/74] =?UTF-8?q?feat:=20redis=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + build.gradle | 3 ++ .../kkumteul/KkumteulApplication.java | 2 + .../service/FairytaleCheckService.java | 10 +++++ .../service/FairytaleCheckServiceImpl.java | 39 +++++++++++++++++++ .../kkumteul/global/config/RedisConfig.java | 26 +++++++++++++ 6 files changed, 81 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java create mode 100644 src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java create mode 100644 src/main/java/com/capstone/kkumteul/global/config/RedisConfig.java diff --git a/.gitignore b/.gitignore index bba9ce9..d2b4a92 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,5 @@ src/main/resources/application.properties ### Claude Code ### .omc +.claude/ diff --git a/build.gradle b/build.gradle index d2b05bd..35392ea 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' } tasks.named('test') { diff --git a/src/main/java/com/capstone/kkumteul/KkumteulApplication.java b/src/main/java/com/capstone/kkumteul/KkumteulApplication.java index 0c47d23..41b5660 100644 --- a/src/main/java/com/capstone/kkumteul/KkumteulApplication.java +++ b/src/main/java/com/capstone/kkumteul/KkumteulApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling +@EnableCaching public class KkumteulApplication { public static void main(String[] args) { diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java new file mode 100644 index 0000000..7278cda --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java @@ -0,0 +1,10 @@ +package com.capstone.kkumteul.domain.fairytale.service; + +public interface FairytaleCheckService { + + void markVocabDone(Long fairytaleId, int page); + + void markImageDone(Long fairytaleId, int page); + + boolean isBothDone(Long fairytaleId, int page); +} diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java new file mode 100644 index 0000000..abd7a65 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java @@ -0,0 +1,39 @@ +package com.capstone.kkumteul.domain.fairytale.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FairytaleCheckServiceImpl implements FairytaleCheckService{ + + private final RedisTemplate redisTemplate; + + private static final String VOCAB_KEY="vocab:%d:%d"; + private static final String IMAGE_KEY="image:%d:%d"; + private static final String DONE = "done"; + + @Override + public void markVocabDone(Long fairytaleId, int page) { + redisTemplate.opsForValue().set(String.format(VOCAB_KEY, fairytaleId, page), DONE); + if (isBothDone(fairytaleId, page)) { + // SSE 이벤트 + } + } + + @Override + public void markImageDone(Long fairytaleId, int page) { + redisTemplate.opsForValue().set(String.format(IMAGE_KEY, fairytaleId, page), DONE); + if (isBothDone(fairytaleId, page)) { + // SSE 이벤트 + } + } + + @Override + public boolean isBothDone(Long fairytaleId, int page) { + String vocabStatus = redisTemplate.opsForValue().get(String.format(VOCAB_KEY, fairytaleId, page)); + String imageStatus = redisTemplate.opsForValue().get(String.format(IMAGE_KEY, fairytaleId, page)); + return DONE.equals(vocabStatus) && DONE.equals(imageStatus); + } +} diff --git a/src/main/java/com/capstone/kkumteul/global/config/RedisConfig.java b/src/main/java/com/capstone/kkumteul/global/config/RedisConfig.java new file mode 100644 index 0000000..e7a6fd6 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/config/RedisConfig.java @@ -0,0 +1,26 @@ +package com.capstone.kkumteul.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + return template; + } +} From 2c256bb3af81a4706c952ec2b4bb44d61b85f9fa Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sat, 9 May 2026 15:32:13 +0900 Subject: [PATCH 30/74] =?UTF-8?q?feat:=20redis=20=EB=8F=84=EC=BB=A4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 438a563..47cf218 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,19 @@ services: - "8080" depends_on: - fastapi + - redis + networks: + - kkumteul + + redis: + image: redis:latest + container_name: redis + restart: always + command: redis-server --appendonly yes + volumes: + - redis-data:/data + expose: + - "6379" networks: - kkumteul @@ -42,3 +55,6 @@ services: networks: kkumteul: driver: bridge + +volumes: + redis-data: From 881ba16d6ffe9ff375a8bfd2c48300c260f1216d Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sat, 9 May 2026 20:42:01 +0900 Subject: [PATCH 31/74] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20SSE=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fairytale/service/sse/SseService.java | 8 +++ .../fairytale/service/sse/SseServiceImpl.java | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseService.java create mode 100644 src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseServiceImpl.java diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseService.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseService.java new file mode 100644 index 0000000..8b69ebc --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseService.java @@ -0,0 +1,8 @@ +package com.capstone.kkumteul.domain.fairytale.service.sse; + +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +public interface SseService { + SseEmitter subscribe(Long fairytaleId); + void sendToClient(Long fairytaleId, String eventName, Object data); +} \ No newline at end of file diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseServiceImpl.java new file mode 100644 index 0000000..7266bf1 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/sse/SseServiceImpl.java @@ -0,0 +1,50 @@ +package com.capstone.kkumteul.domain.fairytale.service.sse; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Service +public class SseServiceImpl implements SseService { + private final Map sseEmittersMap = new ConcurrentHashMap<>(); + + public SseEmitter subscribe(Long fairytaleId) { + long timeout = 1000L * 60 * 60; + + SseEmitter emitter = new SseEmitter(timeout); + sseEmittersMap.put(fairytaleId, emitter); + + emitter.onCompletion(() -> sseEmittersMap.remove(fairytaleId)); //complete시 콜백함수 + emitter.onTimeout(() -> sseEmittersMap.remove(fairytaleId)); //타임아웃시 삭제 + emitter.onError(e -> { + log.error("SSE 에러 fairytaleId={}", fairytaleId, e); + sseEmittersMap.remove(fairytaleId); + }); //전송중 에러시 삭제 + + //연결 성공시 + sendToClient(fairytaleId, "connect", "sse connect..."); + + return emitter; + } + + public void sendToClient(Long fairytaleId, String eventName, Object data) { + SseEmitter emitter = sseEmittersMap.get(fairytaleId); + if (emitter == null) return; + try { + emitter.send(SseEmitter.event() + .name(eventName) + .data(data)); + if ("done".equals(eventName)) { + emitter.complete(); //sse 스트림 종료 + } + } catch (IOException e) { + log.error("SSE 전송 실패 fairytaleId={}", fairytaleId, e); + sseEmittersMap.remove(fairytaleId); + } + } +} \ No newline at end of file From 3217099d28386bdf5621207055a1e61d21ac2967 Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sat, 9 May 2026 20:44:09 +0900 Subject: [PATCH 32/74] =?UTF-8?q?feat:=20Paragraph=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=97=90=20imageurl=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../capstone/kkumteul/domain/fairytale/entity/Paragraph.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Paragraph.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Paragraph.java index b012c69..54a448a 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Paragraph.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Paragraph.java @@ -25,4 +25,8 @@ public class Paragraph extends BaseEntity { @Column(nullable = false, columnDefinition = "TEXT") private String text; + + //nullable 제약은 추후 + @Column + private String imageUrl; } From 46b89de98d8e8a1e5eb0ed54ef48a5e489f7aea9 Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sat, 9 May 2026 20:44:40 +0900 Subject: [PATCH 33/74] =?UTF-8?q?feat:=20SSE=EC=97=B0=EA=B2=B0=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fairytale/web/controller/FairytaleController.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java index 8780372..da52bfe 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java @@ -7,13 +7,16 @@ import com.capstone.kkumteul.domain.user.entity.User; import com.capstone.kkumteul.global.response.SuccessResponse; import com.capstone.kkumteul.global.security.AuthUser; +import com.capstone.kkumteul.domain.fairytale.service.sse.SseService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController @RequiredArgsConstructor @@ -21,6 +24,7 @@ public class FairytaleController { private final FairytaleService fairytaleService; + private final SseService sseService; @GetMapping("/my") public ResponseEntity>> getMyFairytales( @@ -49,4 +53,9 @@ public ResponseEntity> getFairytaleDetail( FairytaleDetailRes res = fairytaleService.getFairytaleDetail(fairytaleId); return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.ok(res)); } + + @GetMapping(value="/{fairytaleId}/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter subscribe(@PathVariable Long fairytaleId){ + return sseService.subscribe(fairytaleId); + } } From f394ff7954ca88b8e0851a2d6e69a75f52f09b14 Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sat, 9 May 2026 20:45:49 +0900 Subject: [PATCH 34/74] =?UTF-8?q?feat:=20=EB=8B=A8=EC=96=B4=EC=B2=B4?= =?UTF-8?q?=ED=81=AC&BothCheck=EC=8B=9C=20=EC=A0=84=EC=86=A1=EB=A1=9C?= =?UTF-8?q?=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/FairytaleCheckServiceImpl.java | 59 +++++++++++++++---- .../domain/fairytale/web/dto/SseEventRes.java | 15 +++++ .../vocab/repository/WordEntryRepository.java | 3 + .../vocab/service/VocabServiceImpl.java | 3 + 4 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/SseEventRes.java diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java index abd7a65..58a5af1 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java @@ -1,33 +1,43 @@ package com.capstone.kkumteul.domain.fairytale.service; +import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; +import com.capstone.kkumteul.domain.fairytale.service.sse.SseService; +import com.capstone.kkumteul.domain.fairytale.web.dto.SseEventRes; +import com.capstone.kkumteul.domain.vocab.entity.WordEntry; +import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Optional; + +@Slf4j @Service @RequiredArgsConstructor -public class FairytaleCheckServiceImpl implements FairytaleCheckService{ +public class FairytaleCheckServiceImpl implements FairytaleCheckService { private final RedisTemplate redisTemplate; + private final SseService sseService; + private final WordEntryRepository wordEntryRepository; + private final ParagraphRepository paragraphRepository; - private static final String VOCAB_KEY="vocab:%d:%d"; - private static final String IMAGE_KEY="image:%d:%d"; + private static final String VOCAB_KEY = "vocab:%d:%d"; + private static final String IMAGE_KEY = "image:%d:%d"; private static final String DONE = "done"; @Override public void markVocabDone(Long fairytaleId, int page) { redisTemplate.opsForValue().set(String.format(VOCAB_KEY, fairytaleId, page), DONE); - if (isBothDone(fairytaleId, page)) { - // SSE 이벤트 - } + checkAndSend(fairytaleId, page); } @Override public void markImageDone(Long fairytaleId, int page) { redisTemplate.opsForValue().set(String.format(IMAGE_KEY, fairytaleId, page), DONE); - if (isBothDone(fairytaleId, page)) { - // SSE 이벤트 - } + checkAndSend(fairytaleId, page); } @Override @@ -36,4 +46,33 @@ public boolean isBothDone(Long fairytaleId, int page) { String imageStatus = redisTemplate.opsForValue().get(String.format(IMAGE_KEY, fairytaleId, page)); return DONE.equals(vocabStatus) && DONE.equals(imageStatus); } -} + + //sse전송 + private void checkAndSend(Long fairytaleId, int page) { + if (!isBothDone(fairytaleId, page)) return; + + Optional wordEntry = wordEntryRepository.findByFairytaleIdAndPageNo(fairytaleId, page); + List paragraphs = paragraphRepository.findByFairytaleIdAndPage(fairytaleId, page); + + if (wordEntry.isEmpty() || paragraphs.isEmpty()) { + log.warn("SSE 발송 실패 - 데이터 없음 fairytaleId={}, page={}", fairytaleId, page); + return; + } + + WordEntry word = wordEntry.get(); + + List sentences = paragraphs.stream() + .map(Paragraph::getText) + .toList(); + + SseEventRes event = new SseEventRes( + fairytaleId, + page, + sentences, + new SseEventRes.Vocabulary(word.getWord(), word.getMeaning()), + paragraphs.getFirst().getImageUrl() + ); + + sseService.sendToClient(fairytaleId, "page_content", event); + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/SseEventRes.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/SseEventRes.java new file mode 100644 index 0000000..7f6e98d --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/SseEventRes.java @@ -0,0 +1,15 @@ +package com.capstone.kkumteul.domain.fairytale.web.dto; + +import java.util.List; + +public record SseEventRes( + Long fairytaleId, + int pageNo, + List text, + Vocabulary vocab, + String imageUrl +) { public record Vocabulary( + String word, + String meaning +){} +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/repository/WordEntryRepository.java b/src/main/java/com/capstone/kkumteul/domain/vocab/repository/WordEntryRepository.java index e82622d..b390ef7 100644 --- a/src/main/java/com/capstone/kkumteul/domain/vocab/repository/WordEntryRepository.java +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/repository/WordEntryRepository.java @@ -5,6 +5,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface WordEntryRepository extends JpaRepository { @@ -14,4 +15,6 @@ public interface WordEntryRepository extends JpaRepository { /** 본인 동화 누적 단어장 조회 — 페이지 순서로 정렬 */ List findByFairytaleIdOrderByPageNoAsc(Long fairytaleId); + + Optional findByFairytaleIdAndPageNo(Long fairytaleId, int pageNo); } diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java index 13a7d4a..8cc0cd6 100644 --- a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java @@ -2,6 +2,7 @@ import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; import com.capstone.kkumteul.domain.fairytale.exception.FairytaleNotFoundException; +import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; import com.capstone.kkumteul.domain.vocab.entity.WordEntry; import com.capstone.kkumteul.domain.vocab.exception.VocabForbiddenException; import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; @@ -28,6 +29,7 @@ public class VocabServiceImpl implements VocabService { private final WordEntryRepository wordEntryRepository; private final VocabExtractClient vocabExtractClient; private final EntityManager entityManager; + private final FairytaleCheckService fairytaleCheckService; /** * 페이지 3문장 → LLM으로 단어 추출 → 풀이 생성 → DB 저장. @@ -71,6 +73,7 @@ public VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List try { WordEntry saved = wordEntryRepository.save(entry); + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); return VocabExtractionResult.saved(saved); } catch (DataIntegrityViolationException e) { log.info("vocab race condition fairytaleId={}, word={}", fairytaleId, word); From 48c9bff5f836a747d95f10d2166cb4e0765c88d0 Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sun, 10 May 2026 02:02:22 +0900 Subject: [PATCH 35/74] =?UTF-8?q?feat:=20=EC=A0=84=EC=86=A1=ED=9B=84=20red?= =?UTF-8?q?is=20=EB=82=B4=EC=97=AD=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/fairytale/service/FairytaleCheckServiceImpl.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java index 58a5af1..1706d52 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java @@ -74,5 +74,8 @@ private void checkAndSend(Long fairytaleId, int page) { ); sseService.sendToClient(fairytaleId, "page_content", event); + + redisTemplate.delete(String.format(VOCAB_KEY, fairytaleId, page)); + redisTemplate.delete(String.format(IMAGE_KEY, fairytaleId, page)); } } \ No newline at end of file From 589813fb714464e40fc3e20c7ebd7ef12036e9e5 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Sun, 10 May 2026 02:44:01 +0900 Subject: [PATCH 36/74] =?UTF-8?q?chore:=20ArchUnit=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20VocabServiceArchTest=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캡스톤 규모에 carrier-agnostic 강제는 과한 안전장치라 판단, 다른 도메인 컨벤션(web.dto에 응답 DTO 배치)과 일관성 확보 위해 제거. --- build.gradle | 3 -- .../vocab/service/VocabServiceArchTest.java | 36 ------------------- 2 files changed, 39 deletions(-) delete mode 100644 src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceArchTest.java diff --git a/build.gradle b/build.gradle index 668bcd9..d2b05bd 100644 --- a/build.gradle +++ b/build.gradle @@ -40,9 +40,6 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' - - // ArchUnit (carrier-agnostic 패키지 의존 검증용) - testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' } tasks.named('test') { diff --git a/src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceArchTest.java b/src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceArchTest.java deleted file mode 100644 index f41ff22..0000000 --- a/src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceArchTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.capstone.kkumteul.domain.vocab.service; - -import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; -import org.junit.jupiter.api.Test; - -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - -/** - * vocab service 패키지가 carrier(웹/카프카) 의존을 갖지 않음을 빌드 시점에 검증. - * - *

이 테스트가 깨진다는 것은 누군가 service에 web DTO 또는 kafka import를 추가했다는 신호. - * Phase 2 카프카 어댑터 추가 시점에도 service 코드 자체는 변경되지 않아야 한다.

- */ -class VocabServiceArchTest { - - private static final JavaClasses CLASSES = new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .importPackages("com.capstone.kkumteul"); - - @Test - void vocab_service_has_no_web_or_kafka_dependencies() { - noClasses() - .that().resideInAPackage("..vocab.service..") - .should().dependOnClassesThat() - .resideInAnyPackage( - "..vocab.web..", - "..vocab.message..", - "org.springframework.kafka..", - "org.springframework.web..", - "jakarta.servlet.." - ) - .check(CLASSES); - } -} From 2fbfce0ed4abd8c7922c0c81f00da7c066b9ffb5 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Sun, 10 May 2026 02:44:24 +0900 Subject: [PATCH 37/74] =?UTF-8?q?refactor:=20VocabServiceImpl=EC=97=90?= =?UTF-8?q?=EC=84=9C=20EntityManager=20=EC=A0=9C=EA=B1=B0=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20FairytaleRepository=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - processSentences: getReference → fairytaleRepository.findById().orElseThrow() - getVocab: entityManager.find → fairytaleRepository.findById().orElseThrow() - getVocab 반환을 WordEntryRes 리스트로 변환해서 엔티티가 서비스 밖으로 새지 않도록 수정 --- .../domain/vocab/service/VocabServiceImpl.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java index 13a7d4a..0411f2e 100644 --- a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java @@ -2,13 +2,14 @@ import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; import com.capstone.kkumteul.domain.fairytale.exception.FairytaleNotFoundException; +import com.capstone.kkumteul.domain.fairytale.repository.FairytaleRepository; import com.capstone.kkumteul.domain.vocab.entity.WordEntry; import com.capstone.kkumteul.domain.vocab.exception.VocabForbiddenException; import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; +import com.capstone.kkumteul.domain.vocab.web.dto.WordEntryRes; import com.capstone.kkumteul.global.client.VocabExtractClient; import com.capstone.kkumteul.global.client.dto.VocabExtractResponse; -import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -27,7 +28,7 @@ public class VocabServiceImpl implements VocabService { private final WordEntryRepository wordEntryRepository; private final VocabExtractClient vocabExtractClient; - private final EntityManager entityManager; + private final FairytaleRepository fairytaleRepository; /** * 페이지 3문장 → LLM으로 단어 추출 → 풀이 생성 → DB 저장. @@ -61,7 +62,8 @@ public VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List return VocabExtractionResult.duplicate(); } - Fairytale fairytale = entityManager.getReference(Fairytale.class, fairytaleId); + Fairytale fairytale = fairytaleRepository.findById(fairytaleId) + .orElseThrow(FairytaleNotFoundException::new); WordEntry entry = WordEntry.builder() .fairytale(fairytale) .pageNo(pageNo) @@ -83,15 +85,13 @@ public VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List * 동화 소유권 검증 후 페이지 순서로 반환. */ @Override - public List getVocab(Long userId, Long fairytaleId) { - Fairytale fairytale = entityManager.find(Fairytale.class, fairytaleId); - if (fairytale == null) { - throw new FairytaleNotFoundException(); - } + public List getVocab(Long userId, Long fairytaleId) { + Fairytale fairytale = fairytaleRepository.findById(fairytaleId) + .orElseThrow(FairytaleNotFoundException::new); Objects.requireNonNull(fairytale.getUser(), "Fairytale.user는 null이 될 수 없음"); if (!fairytale.getUser().getId().equals(userId)) { throw new VocabForbiddenException(); } - return wordEntryRepository.findByFairytaleIdOrderByPageNoAsc(fairytaleId); + return WordEntryRes.listOf(wordEntryRepository.findByFairytaleIdOrderByPageNoAsc(fairytaleId)); } } From 4c3f547dca6a186e0946f89b7b25e2b54e648ae1 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Sun, 10 May 2026 02:44:31 +0900 Subject: [PATCH 38/74] =?UTF-8?q?refactor:=20VocabService.getVocab=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20=ED=83=80=EC=9E=85=EC=9D=84=20List=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서비스 계층이 엔티티를 외부로 노출하지 않도록 정리. 컨트롤러에서 별도 변환 호출 제거하고 carrier-agnostic 주석도 정리. --- .../kkumteul/domain/vocab/service/VocabService.java | 7 ++----- .../domain/vocab/web/controller/VocabController.java | 5 ++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java index 5643496..3c56239 100644 --- a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java @@ -1,15 +1,12 @@ package com.capstone.kkumteul.domain.vocab.service; -import com.capstone.kkumteul.domain.vocab.entity.WordEntry; import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; +import com.capstone.kkumteul.domain.vocab.web.dto.WordEntryRes; import java.util.List; /** * 단어장 추출/조회 서비스. - * - *

이 인터페이스는 carrier-agnostic 하다 (web/dto, kafka/message에 의존하지 않음). - * REST controller / KafkaListener / 그 외 어떤 어댑터든 동일 시그니처로 호출 가능.

*/ public interface VocabService { @@ -29,5 +26,5 @@ public interface VocabService { * @param userId 요청자 (소유권 검증용) * @param fairytaleId 동화 ID */ - List getVocab(Long userId, Long fairytaleId); + List getVocab(Long userId, Long fairytaleId); } diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/VocabController.java b/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/VocabController.java index e3f3e71..c007ed4 100644 --- a/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/VocabController.java +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/VocabController.java @@ -1,7 +1,6 @@ package com.capstone.kkumteul.domain.vocab.web.controller; import com.capstone.kkumteul.domain.user.entity.User; -import com.capstone.kkumteul.domain.vocab.entity.WordEntry; import com.capstone.kkumteul.domain.vocab.service.VocabService; import com.capstone.kkumteul.domain.vocab.web.dto.WordEntryRes; import com.capstone.kkumteul.global.response.SuccessResponse; @@ -29,8 +28,8 @@ public ResponseEntity>> getVocab( @AuthUser User user, @PathVariable Long fairytaleId ) { - List entries = vocabService.getVocab(user.getId(), fairytaleId); + List entries = vocabService.getVocab(user.getId(), fairytaleId); return ResponseEntity.status(HttpStatus.OK) - .body(SuccessResponse.ok(WordEntryRes.listOf(entries))); + .body(SuccessResponse.ok(entries)); } } From 75d504de52f9e47a2d8d172ac045852b76068ddc Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Sun, 10 May 2026 02:44:37 +0900 Subject: [PATCH 39/74] =?UTF-8?q?feat:=20InternalVocabProcessReq.pageNo?= =?UTF-8?q?=EC=97=90=20@Min(1)=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 페이지 번호는 1-base이므로 0 이하 값을 거절. --- .../kkumteul/domain/vocab/web/dto/InternalVocabProcessReq.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/InternalVocabProcessReq.java b/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/InternalVocabProcessReq.java index 08663dc..be37c8e 100644 --- a/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/InternalVocabProcessReq.java +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/InternalVocabProcessReq.java @@ -1,5 +1,6 @@ package com.capstone.kkumteul.domain.vocab.web.dto; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import lombok.Getter; @@ -10,5 +11,6 @@ public class InternalVocabProcessReq { private Long fairytaleId; @NotNull + @Min(1) private Integer pageNo; } From f95d6438cef4f087078b377c6ae78be1af5e8e25 Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sun, 10 May 2026 11:31:58 +0900 Subject: [PATCH 40/74] =?UTF-8?q?fix:=20paragraph=EC=99=80=20=EC=9D=BC?= =?UTF-8?q?=EB=8C=80=EB=8B=A4=20=EA=B4=80=EA=B3=84=20=EB=A7=A4=EC=B9=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkumteul/domain/fairytale/entity/Fairytale.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Fairytale.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Fairytale.java index 9a41fca..632dd31 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Fairytale.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Fairytale.java @@ -5,6 +5,9 @@ import jakarta.persistence.*; import lombok.*; +import java.util.ArrayList; +import java.util.List; + @Entity @Getter @Builder @@ -36,6 +39,8 @@ public class Fairytale extends BaseEntity { @Column(nullable = false) private Background background; - @Column(columnDefinition = "TEXT") - private String content; + @Builder.Default + @OneToMany(mappedBy = "fairytale", cascade = CascadeType.ALL, orphanRemoval = true) + private List paragraphs = new ArrayList<>(); + } From adf43e97862b76f05f228034be3845021495092d Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sun, 10 May 2026 11:34:32 +0900 Subject: [PATCH 41/74] =?UTF-8?q?fix:=203=EB=AC=B8=EC=9E=A5=EC=9D=80=20?= =?UTF-8?q?=ED=95=9C=EB=B2=88=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98?= =?UTF-8?q?=EB=A9=B4=EC=84=9C=20db=EC=97=90=EC=84=9C=20=EA=BA=BC=EB=82=B4?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/FairytaleCheckServiceImpl.java | 19 ++++++++++--------- .../domain/game/service/GameServiceImpl.java | 9 ++++++++- .../controller/InternalVocabController.java | 2 +- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java index 1706d52..6357ffa 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java @@ -54,23 +54,24 @@ private void checkAndSend(Long fairytaleId, int page) { Optional wordEntry = wordEntryRepository.findByFairytaleIdAndPageNo(fairytaleId, page); List paragraphs = paragraphRepository.findByFairytaleIdAndPage(fairytaleId, page); - if (wordEntry.isEmpty() || paragraphs.isEmpty()) { - log.warn("SSE 발송 실패 - 데이터 없음 fairytaleId={}, page={}", fairytaleId, page); + if (paragraphs.isEmpty()) { + sseService.sendToClient(fairytaleId, "error", "문단 데이터 없음"); + log.warn("SSE 발송 실패 - 문단 없음 fairytaleId={}, page={}", fairytaleId, page); return; } - WordEntry word = wordEntry.get(); - - List sentences = paragraphs.stream() - .map(Paragraph::getText) - .toList(); + Paragraph paragraph = paragraphs.getFirst(); + List sentences = List.of(paragraph.getText().split("\n")); + SseEventRes.Vocabulary vocab = wordEntry + .map(w -> new SseEventRes.Vocabulary(w.getWord(), w.getMeaning())) + .orElse(null); SseEventRes event = new SseEventRes( fairytaleId, page, sentences, - new SseEventRes.Vocabulary(word.getWord(), word.getMeaning()), - paragraphs.getFirst().getImageUrl() + vocab, + paragraph.getImageUrl() ); sseService.sendToClient(fairytaleId, "page_content", event); diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java index 68fe4c7..c3f5e24 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java @@ -1,6 +1,8 @@ package com.capstone.kkumteul.domain.game.service; import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; import com.capstone.kkumteul.domain.game.entity.GraphEdge; import com.capstone.kkumteul.domain.game.entity.GraphNode; import com.capstone.kkumteul.domain.game.entity.NodeCategory; @@ -36,6 +38,7 @@ public class GameServiceImpl implements GameService { private final GameSessionManager sessionManager; private final EntityManager entityManager; private final GraphService graphService; + private final ParagraphRepository paragraphRepository; /** * 게임 시작 — POST /game/start @@ -69,7 +72,11 @@ public GameStartRes startGame(Long userId, Long fairytaleId) { // graph_nodes 테이블에서 fairytaleId로 그래프 존재 확인 → 없으면 FastAPI 호출 if (!graphNodeRepository.existsByFairytaleId(fairytaleId)) { log.info("그래프 미존재 — FastAPI 추출 호출: fairytaleId={}", fairytaleId); - graphService.extractAndSave(fairytale, fairytale.getContent()); + List paragraphs = paragraphRepository.findByFairytaleIdOrderByPageAsc(fairytaleId); + String content = paragraphs.stream() + .map(Paragraph::getText) + .collect(java.util.stream.Collectors.joining(" ")); + graphService.extractAndSave(fairytale, content); } // 기존 세션 제거 — 뒤로 가기 후 재진입 시 새 세션으로 처음부터 diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/InternalVocabController.java b/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/InternalVocabController.java index ff29bf5..f990633 100644 --- a/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/InternalVocabController.java +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/InternalVocabController.java @@ -52,7 +52,7 @@ public ResponseEntity> process( if (paragraphs.isEmpty()) { throw new ParagraphNotFoundForVocabException(); } - List sentences = paragraphs.stream().map(Paragraph::getText).toList(); + List sentences = List.of(paragraphs.getFirst().getText().split("\n")); VocabExtractionResult result = vocabService.processSentences( req.getFairytaleId(), req.getPageNo(), sentences From f64100da7654396ca54615225fcab3ffc6ba1a59 Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sun, 10 May 2026 11:34:58 +0900 Subject: [PATCH 42/74] =?UTF-8?q?chore:=EB=AC=B8=EC=9E=A5=EC=9D=84=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkumteul/domain/fairytale/web/dto/ParagraphRes.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/ParagraphRes.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/ParagraphRes.java index 063148c..b5bca26 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/ParagraphRes.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/dto/ParagraphRes.java @@ -2,14 +2,18 @@ import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import java.util.List; + public record ParagraphRes( int page, - String text + List sentences, + String imageUrl ) { public static ParagraphRes from(Paragraph paragraph) { return new ParagraphRes( paragraph.getPage(), - paragraph.getText() + List.of(paragraph.getText().split("\n")), + paragraph.getImageUrl() ); } } From 6284822912bb9e861ba72096f310ed0faffd31b5 Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sun, 10 May 2026 11:35:36 +0900 Subject: [PATCH 43/74] =?UTF-8?q?fix:=20=EB=8B=A8=EC=96=B4=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=20=EC=96=B4=EB=8A=90=EA=B2=BD=EC=9A=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=EB=8F=84=20redis=EC=B2=B4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkumteul/domain/vocab/service/VocabServiceImpl.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java index f029bd3..8fb7d15 100644 --- a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java @@ -50,6 +50,7 @@ public class VocabServiceImpl implements VocabService { public VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List sentences) { Optional extracted = vocabExtractClient.extract(sentences); if (extracted.isEmpty()) { + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); return VocabExtractionResult.extractionFailed(); } @@ -57,10 +58,12 @@ public VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List String word = response.getWord(); String meaning = response.getMeaning(); if (word == null || word.isBlank() || meaning == null || meaning.isBlank()) { + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); return VocabExtractionResult.noDifficultWord(); } if (wordEntryRepository.existsByFairytaleIdAndWord(fairytaleId, word)) { + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); return VocabExtractionResult.duplicate(); } From 588275769ac577b95669825514cdbae47441b511 Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sun, 10 May 2026 11:36:07 +0900 Subject: [PATCH 44/74] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.properties | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 src/main/resources/application-dev.properties diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties deleted file mode 100644 index dec2ce8..0000000 --- a/src/main/resources/application-dev.properties +++ /dev/null @@ -1,8 +0,0 @@ -# dev 프로필 전용 설정. -# 활성화 방법: -Dspring.profiles.active=dev 또는 SPRING_PROFILES_ACTIVE=dev -# -# dev 프로필일 때 추가 동작: -# - InternalApiSecurityConfig 등록 → /internal/** 경로 인증 면제 -# - InternalVocabController 등록 → POST /internal/vocab/process 시연용 API 노출 -# -# 운영 환경에서는 이 프로필을 활성화하지 말 것. From 354bb257a547d4707c8346804ba8c9739ac46c77 Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Sun, 10 May 2026 14:07:14 +0900 Subject: [PATCH 45/74] =?UTF-8?q?fix:=20FairytaleController=20RequestMappi?= =?UTF-8?q?ng=EC=9D=B4=20/api=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=ED=95=98?= =?UTF-8?q?=EB=93=9C=20=ED=94=BD=EC=8A=A4=ED=95=B4=EB=91=94=20=EA=B2=83?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/fairytale/web/controller/FairytaleController.java | 2 +- .../capstone/kkumteul/domain/kafka/service/EventService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java index acc463a..d959556 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java @@ -20,7 +20,7 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/api/fairytales") +@RequestMapping("/fairytales") public class FairytaleController { private final FairytaleService fairytaleService; diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java index 18d4aeb..789215e 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java @@ -26,7 +26,7 @@ public void createFairytaleMessageSend(Long userId, FairytaleGenerateReq request FairytaleGenerateMessage message = FairytaleGenerateMessage.builder() .userId(userId) .background(request.getBackground()) - .charSpecies(request.getCharSpecie()) + .charSpecies(request.getCharSpecies()) .morality(request.getMorality()) .build(); From 3e7e7c8162905f8bdc1b8da62c35494bbe6795b2 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Sun, 10 May 2026 15:50:56 +0900 Subject: [PATCH 46/74] =?UTF-8?q?feat:=20vocab=5Fextracted=20=ED=86=A0?= =?UTF-8?q?=ED=94=BD=EC=9A=A9=20Consumer=20=EC=A0=84=EC=9A=A9=20DTO=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI 서버가 발행하는 메시지 페이로드 DTO - MessageInterface 미구현 (Producer 마커이므로 Consumer 전용 DTO에는 부적합) - @JsonIgnoreProperties(ignoreUnknown=true)로 AI측 신규 필드 추가에도 호환 --- .../kafka/dto/VocabExtractedMessage.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/kafka/dto/VocabExtractedMessage.java diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/VocabExtractedMessage.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/VocabExtractedMessage.java new file mode 100644 index 0000000..e29a6db --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/VocabExtractedMessage.java @@ -0,0 +1,29 @@ +package com.capstone.kkumteul.domain.kafka.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * AI 서버가 발행하는 vocab_extracted 토픽의 Consumer-only 메시지. + * + *

설계상 {@code MessageInterface}를 구현하지 않는다 — Producer-side marker이고 + * 본 DTO는 Consumer 단에서만 사용한다. {@code userId}/{@code messageId}는 정보용 필드로, + * 트레이싱/로그에만 쓰인다 (dedup 키로 사용 X).

+ */ +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class VocabExtractedMessage { + + private Long fairytaleId; + private int pageNo; + private String word; + private String meaning; + private Long userId; + private String messageId; +} From c5411788a22b47fcb615884f63591c89de68d8b7 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Sun, 10 May 2026 15:51:08 +0900 Subject: [PATCH 47/74] =?UTF-8?q?feat:=20KafkaConsumerConfig=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20Producer/Consumer=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ErrorHandlingDeserializer로 wrap해 poison pill 메시지가 listener thread를 죽이지 않도록 방어 - DLT publish 직전 markVocabDone 호출로 SSE hang 방지 (instanceof 패턴으로 ClassCast 안전) - FixedBackOff(0L, 2L)로 1+2=3회 시도 후 DLT - DeserializationException, MethodArgumentNotValidException은 즉시 not-retryable 처리 - DLT recoverer 람다를 buildVocabRecoverer로 분리해 단위 테스트 가능하게 - KafkaProducerConfig, EventService에 @Profile("!dev") 일관 적용 (dev 환경 격리) --- .../domain/kafka/service/EventService.java | 2 + .../global/config/KafkaConsumerConfig.java | 107 ++++++++++++++++++ .../global/config/KafkaProducerConfig.java | 2 + 3 files changed, 111 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java index 789215e..05b9587 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java @@ -6,12 +6,14 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Service; /* 동화 생성 이벤트 전파 */ @Service +@Profile("!dev") @Slf4j(topic = "event") @RequiredArgsConstructor public class EventService { diff --git a/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java b/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java new file mode 100644 index 0000000..d9354be --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java @@ -0,0 +1,107 @@ +package com.capstone.kkumteul.global.config; + +import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; +import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.listener.ConsumerRecordRecoverer; +import org.springframework.kafka.listener.ContainerProperties.AckMode; +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; +import org.springframework.kafka.listener.DefaultErrorHandler; +import org.springframework.kafka.support.serializer.DeserializationException; +import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.util.backoff.FixedBackOff; +import org.springframework.web.bind.MethodArgumentNotValidException; + +import java.util.HashMap; +import java.util.Map; + +/** + * vocab_extracted 토픽 Consumer 인프라. + * + *

역직렬화 실패(poison pill)로 listener thread가 죽지 않도록 ErrorHandlingDeserializer로 wrap한다. + * 재시도 후에도 실패하면 DLT로 보내기 직전 markVocabDone을 호출해 SSE hang을 방지한다.

+ */ +@Configuration +@Profile("!dev") +public class KafkaConsumerConfig { + + private final String kafkaUrl; + private final String vocabGroupId; + private final FairytaleCheckService fairytaleCheckService; + + public KafkaConsumerConfig( + @Value("${KAFKA_URL}") String kafkaUrl, + @Value("${VOCAB_EXTRACTED_GROUP_ID:kkumteul-vocab}") String vocabGroupId, + FairytaleCheckService fairytaleCheckService + ) { + this.kafkaUrl = kafkaUrl; + this.vocabGroupId = vocabGroupId; + this.fairytaleCheckService = fairytaleCheckService; + } + + @Bean + public ConsumerFactory vocabConsumerFactory() { + Map config = new HashMap<>(); + config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaUrl); + config.put(ConsumerConfig.GROUP_ID_CONFIG, vocabGroupId); + config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class); + config.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class.getName()); + config.put(JsonDeserializer.TRUSTED_PACKAGES, "com.capstone.kkumteul.domain.kafka.dto"); + config.put(JsonDeserializer.VALUE_DEFAULT_TYPE, VocabExtractedMessage.class.getName()); + config.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); + config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); + config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest"); + return new DefaultKafkaConsumerFactory<>(config); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory vocabKafkaListenerContainerFactory( + KafkaTemplate kafkaTemplate + ) { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(vocabConsumerFactory()); + factory.getContainerProperties().setAckMode(AckMode.RECORD); + factory.setCommonErrorHandler(buildVocabErrorHandler(kafkaTemplate)); + return factory; + } + + /** + * DLT recoverer + retry backoff. record.value()가 VocabExtractedMessage이면 + * DLT publish 직전 markVocabDone을 호출해 SSE hang을 방지한다. + */ + public DefaultErrorHandler buildVocabErrorHandler(KafkaTemplate kafkaTemplate) { + DefaultErrorHandler errorHandler = new DefaultErrorHandler( + buildVocabRecoverer(kafkaTemplate), + new FixedBackOff(0L, 2L) + ); + errorHandler.addNotRetryableExceptions(DeserializationException.class, MethodArgumentNotValidException.class); + return errorHandler; + } + + /** + * DLT 직전 markVocabDone 호출 + DeadLetterPublishingRecoverer 위임 람다. + * 단위 테스트에서 직접 호출 가능하도록 분리. + */ + public ConsumerRecordRecoverer buildVocabRecoverer(KafkaTemplate kafkaTemplate) { + DeadLetterPublishingRecoverer dlpr = new DeadLetterPublishingRecoverer(kafkaTemplate); + return (record, ex) -> { + if (record.value() instanceof VocabExtractedMessage m) { + fairytaleCheckService.markVocabDone(m.getFairytaleId(), m.getPageNo()); + } + dlpr.accept(record, ex); + }; + } +} diff --git a/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java b/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java index f04bf37..475bdb3 100644 --- a/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java +++ b/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.kafka.core.DefaultKafkaProducerFactory; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.kafka.core.ProducerFactory; @@ -15,6 +16,7 @@ import java.util.Map; @Configuration +@Profile("!dev") public class KafkaProducerConfig { private final String KAFKA_URL; From 821cd508ce42b0106113d69a3d72e16196790967 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Sun, 10 May 2026 15:51:21 +0900 Subject: [PATCH 48/74] =?UTF-8?q?feat:=20VocabExtractedListener=20?= =?UTF-8?q?=EB=B0=8F=20image-side=20=ED=8F=B4=EB=B0=B1=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20SSE=20=ED=9D=90=EB=A6=84=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VocabService.processExtractedWord: AI Producer 메시지 처리 진입점 - word=null/blank → NO_DIFFICULT_WORD (DB 저장 없이 markVocabDone) - 모든 종착 분기(SAVED/DUPLICATE/NO_DIFFICULT_WORD/RACE_SKIPPED)에서 markVocabDone 호출 - RACE_SKIPPED 분기도 호출 (Phase 1 processSentences와 의도적 divergence) - VocabExtractedListener: vocab_extracted 토픽 컨슈머, 예외는 ErrorHandler에 위임 - FairytaleCheckServiceImpl.markImageDone: image done 시 paragraph age가 임계 초과면 빈 vocab 강제 mark - 임계 외부화: vocab.fallback-threshold-seconds (기본 300초) - 별도 @Scheduled 빈 없이 image lane inline check로 단순화 --- .../service/FairytaleCheckServiceImpl.java | 27 +++++++++++ .../kafka/service/VocabExtractedListener.java | 36 +++++++++++++++ .../domain/vocab/service/VocabService.java | 7 +++ .../vocab/service/VocabServiceImpl.java | 46 +++++++++++++++++++ 4 files changed, 116 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListener.java diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java index 6357ffa..ce10606 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java @@ -8,9 +8,12 @@ import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; +import java.time.Duration; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -24,6 +27,9 @@ public class FairytaleCheckServiceImpl implements FairytaleCheckService { private final WordEntryRepository wordEntryRepository; private final ParagraphRepository paragraphRepository; + @Value("${vocab.fallback-threshold-seconds:300}") + private long vocabFallbackThresholdSeconds; + private static final String VOCAB_KEY = "vocab:%d:%d"; private static final String IMAGE_KEY = "image:%d:%d"; private static final String DONE = "done"; @@ -37,9 +43,30 @@ public void markVocabDone(Long fairytaleId, int page) { @Override public void markImageDone(Long fairytaleId, int page) { redisTemplate.opsForValue().set(String.format(IMAGE_KEY, fairytaleId, page), DONE); + forceVocabIfStale(fairytaleId, page); checkAndSend(fairytaleId, page); } + /** + * image done 시점에 vocab 마커가 없고 paragraph 생성 후 임계 초과면 빈 vocab으로 강제 mark. + * AI Producer가 vocab_extracted를 누락한 경우의 SSE hang을 방지한다. + */ + private void forceVocabIfStale(Long fairytaleId, int page) { + String vocabKey = String.format(VOCAB_KEY, fairytaleId, page); + if (redisTemplate.opsForValue().get(vocabKey) != null) return; + + List paragraphs = paragraphRepository.findByFairytaleIdAndPage(fairytaleId, page); + if (paragraphs.isEmpty()) return; + + LocalDateTime created = paragraphs.getFirst().getCreatedAt(); + if (created == null) return; + long ageSeconds = Duration.between(created, LocalDateTime.now()).getSeconds(); + if (ageSeconds < vocabFallbackThresholdSeconds) return; + + log.warn("vocab fallback fired fairytaleId={}, page={}, ageSeconds={}", fairytaleId, page, ageSeconds); + redisTemplate.opsForValue().set(vocabKey, DONE); + } + @Override public boolean isBothDone(Long fairytaleId, int page) { String vocabStatus = redisTemplate.opsForValue().get(String.format(VOCAB_KEY, fairytaleId, page)); diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListener.java b/src/main/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListener.java new file mode 100644 index 0000000..689b66b --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListener.java @@ -0,0 +1,36 @@ +package com.capstone.kkumteul.domain.kafka.service; + +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; +import com.capstone.kkumteul.domain.vocab.service.VocabService; +import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +/** + * vocab_extracted 토픽 Consumer. + * + *

처리 실패는 그대로 throw해서 {@link com.capstone.kkumteul.global.config.KafkaConsumerConfig}의 + * DefaultErrorHandler + DLT recoverer에 위임한다 (재시도 후 DLT 직전 markVocabDone 호출).

+ */ +@Slf4j +@Component +@Profile("!dev") +@RequiredArgsConstructor +public class VocabExtractedListener { + + private final VocabService vocabService; + + @KafkaListener( + topics = "${VOCAB_EXTRACTED:vocab_extracted}", + groupId = "${VOCAB_EXTRACTED_GROUP_ID:kkumteul-vocab}", + containerFactory = "vocabKafkaListenerContainerFactory" + ) + public void onMessage(VocabExtractedMessage message) { + VocabExtractionResult result = vocabService.processExtractedWord(message); + log.info("vocab_extracted processed fairytaleId={}, pageNo={}, status={}, messageId={}", + message.getFairytaleId(), message.getPageNo(), result.status(), message.getMessageId()); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java index 3c56239..d01de3a 100644 --- a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java @@ -1,5 +1,6 @@ package com.capstone.kkumteul.domain.vocab.service; +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; import com.capstone.kkumteul.domain.vocab.web.dto.WordEntryRes; @@ -20,6 +21,12 @@ public interface VocabService { */ VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List sentences); + /** + * AI 서버가 vocab_extracted 토픽으로 발행한 메시지를 처리. + * word가 null/blank이면 NO_DIFFICULT_WORD 처리. 모든 종착 분기에서 markVocabDone 호출 (SSE guarantee). + */ + VocabExtractionResult processExtractedWord(VocabExtractedMessage message); + /** * 본인 동화의 누적 단어장 조회. 페이지 번호 오름차순. * diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java index 8fb7d15..0e280bc 100644 --- a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java @@ -4,6 +4,7 @@ import com.capstone.kkumteul.domain.fairytale.exception.FairytaleNotFoundException; import com.capstone.kkumteul.domain.fairytale.repository.FairytaleRepository; import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; import com.capstone.kkumteul.domain.vocab.entity.WordEntry; import com.capstone.kkumteul.domain.vocab.exception.VocabForbiddenException; import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; @@ -86,6 +87,51 @@ public VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List } } + /** + * AI Producer가 vocab_extracted 토픽에 발행한 메시지를 처리. + * + *

모든 종착 분기에서 {@link FairytaleCheckService#markVocabDone}을 호출해 SSE hang을 막는다. + * RACE_SKIPPED 분기에서도 호출하는 점이 Phase 1 {@link #processSentences}와 의도적으로 다르다.

+ */ + @Override + @Transactional + public VocabExtractionResult processExtractedWord(VocabExtractedMessage message) { + Long fairytaleId = message.getFairytaleId(); + int pageNo = message.getPageNo(); + String word = message.getWord(); + String meaning = message.getMeaning(); + + if (word == null || word.isBlank()) { + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); + return VocabExtractionResult.noDifficultWord(); + } + + if (wordEntryRepository.existsByFairytaleIdAndWord(fairytaleId, word)) { + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); + return VocabExtractionResult.duplicate(); + } + + Fairytale fairytale = fairytaleRepository.findById(fairytaleId) + .orElseThrow(FairytaleNotFoundException::new); + WordEntry entry = WordEntry.builder() + .fairytale(fairytale) + .pageNo(pageNo) + .word(word) + .meaning(meaning) + .build(); + + try { + WordEntry saved = wordEntryRepository.save(entry); + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); + return VocabExtractionResult.saved(saved); + } catch (DataIntegrityViolationException e) { + log.info("vocab race condition (extracted) fairytaleId={}, word={}, messageId={}", + fairytaleId, word, message.getMessageId()); + fairytaleCheckService.markVocabDone(fairytaleId, pageNo); + return VocabExtractionResult.raceSkipped(); + } + } + /** * 본인 동화 누적 단어장 조회. * 동화 소유권 검증 후 페이지 순서로 반환. From 3c5f228bcfc986ba7f6b3be7ef6ccafaf05090aa Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Sun, 10 May 2026 15:51:28 +0900 Subject: [PATCH 49/74] =?UTF-8?q?feat:=20application-dev.properties=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20dev=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spring.profiles.active=dev는 작성하지 않음 (부트스트랩 패러독스 방지) - dev 프로필 활성화는 -Dspring.profiles.active=dev 또는 SPRING_PROFILES_ACTIVE=dev로 - dev 프로필은 Kafka 빈 미등록 → 동화 생성 API 미지원, vocab만 시연 가능 --- src/main/resources/application-dev.properties | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/main/resources/application-dev.properties diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..b034870 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,6 @@ +# dev profile — Kafka 미가용 환경에서 vocab만 시연. +# 활성화는 -Dspring.profiles.active=dev 또는 SPRING_PROFILES_ACTIVE=dev 환경변수로. +# spring.profiles.active=dev 는 부트스트랩 패러독스 방지 위해 여기에 작성하지 않는다. + +fastapi.base-url=http://localhost:8000 +logging.level.com.capstone.kkumteul=DEBUG From 7956ef29256948b45abe61148899588d32b4a0ed Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Sun, 10 May 2026 15:54:18 +0900 Subject: [PATCH 50/74] =?UTF-8?q?test:=20vocab=20Kafka=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84/=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20broker=20=EC=9D=98=EC=A1=B4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B2=A9=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VocabServiceProcessExtractedWordTest: 5개 분기(NO_DIFFICULT_WORD null/blank, DUPLICATE, SAVED, RACE_SKIPPED) markVocabDone 호출 검증 - FairytaleCheckServiceFallbackTest: image fallback inline check 4 시나리오 (vocab already marked / paragraph missing / age below threshold / age over threshold) - KafkaConsumerConfigDltTest: DLT recoverer 단위 테스트 (VocabExtractedMessage 페이로드 → markVocabDone 호출 + DLT publish, null 페이로드 → markVocabDone 미호출) - VocabExtractedListenerIntegrationTest: EmbeddedKafka로 wire-format round-trip 검증 - KkumteulApplicationTests: KAFKA_URL/토픽 더미 properties 주입으로 Kafka 빈 부팅 검증 - KafkaProducerTest: @Tag("kafka-broker") + @EnabledIfEnvironmentVariable로 fresh checkout 빌드 보호 - build.gradle: testCompileOnly/testAnnotationProcessor lombok, awaitility:4.2.2 추가 - build.gradle: test task에서 kafka-broker 태그 제외 + kafkaBrokerTest task 신설 --- build.gradle | 18 ++- .../kkumteul/KkumteulApplicationTests.java | 6 +- .../FairytaleCheckServiceFallbackTest.java | 111 +++++++++++++++ ...VocabExtractedListenerIntegrationTest.java | 100 ++++++++++++++ .../VocabServiceProcessExtractedWordTest.java | 127 ++++++++++++++++++ .../config/KafkaConsumerConfigDltTest.java | 85 ++++++++++++ .../kkumteul/kafka/KafkaProducerTest.java | 4 + 7 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceFallbackTest.java create mode 100644 src/test/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListenerIntegrationTest.java create mode 100644 src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceProcessExtractedWordTest.java create mode 100644 src/test/java/com/capstone/kkumteul/global/config/KafkaConsumerConfigDltTest.java diff --git a/build.gradle b/build.gradle index d845c02..8a2b888 100644 --- a/build.gradle +++ b/build.gradle @@ -34,8 +34,11 @@ dependencies { compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.awaitility:awaitility:4.2.2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // JWT @@ -54,5 +57,18 @@ dependencies { } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform { + excludeTags 'kafka-broker' + } +} + +tasks.register('kafkaBrokerTest', Test) { + description = 'Run kafka-broker tagged tests (EmbeddedKafka 또는 실제 broker 의존)' + group = 'verification' + useJUnitPlatform { + includeTags 'kafka-broker' + } + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + shouldRunAfter test } diff --git a/src/test/java/com/capstone/kkumteul/KkumteulApplicationTests.java b/src/test/java/com/capstone/kkumteul/KkumteulApplicationTests.java index 2c933cd..aa2b8a2 100644 --- a/src/test/java/com/capstone/kkumteul/KkumteulApplicationTests.java +++ b/src/test/java/com/capstone/kkumteul/KkumteulApplicationTests.java @@ -3,7 +3,11 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest +@SpringBootTest(properties = { + "KAFKA_URL=localhost:9092", + "FAIRYTALE_GENERATION=fairytale_generate", + "VOCAB_EXTRACTED=vocab_extracted" +}) class KkumteulApplicationTests { @Test diff --git a/src/test/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceFallbackTest.java b/src/test/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceFallbackTest.java new file mode 100644 index 0000000..742037a --- /dev/null +++ b/src/test/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceFallbackTest.java @@ -0,0 +1,111 @@ +package com.capstone.kkumteul.domain.fairytale.service; + +import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; +import com.capstone.kkumteul.domain.fairytale.service.sse.SseService; +import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * markImageDone inline fallback (M1) 단위 테스트. + * Paragraph 생성 시각이 임계 초과 + vocab 마커 미존재 시 빈 vocab으로 강제 mark. + */ +@ExtendWith(MockitoExtension.class) +class FairytaleCheckServiceFallbackTest { + + @Mock private RedisTemplate redisTemplate; + @Mock private ValueOperations valueOps; + @Mock private SseService sseService; + @Mock private WordEntryRepository wordEntryRepository; + @Mock private ParagraphRepository paragraphRepository; + + @InjectMocks private FairytaleCheckServiceImpl service; + + private static final Long FAIRYTALE_ID = 10L; + private static final int PAGE = 3; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "vocabFallbackThresholdSeconds", 1L); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + } + + @Test + @DisplayName("vocab 마커 이미 있으면 fallback 미발화") + void noFallbackWhenVocabAlreadyMarked() { + when(valueOps.get("vocab:10:3")).thenReturn("done"); + + service.markImageDone(FAIRYTALE_ID, PAGE); + + verify(valueOps).set(eq("image:10:3"), eq("done")); + verify(valueOps, never()).set(eq("vocab:10:3"), anyString()); + verify(paragraphRepository, never()).findByFairytaleIdAndPage(FAIRYTALE_ID, PAGE); + } + + @Test + @DisplayName("paragraph 없으면 fallback 미발화") + void noFallbackWhenParagraphMissing() { + when(valueOps.get("vocab:10:3")).thenReturn(null).thenReturn(null); + when(paragraphRepository.findByFairytaleIdAndPage(FAIRYTALE_ID, PAGE)).thenReturn(List.of()); + + service.markImageDone(FAIRYTALE_ID, PAGE); + + verify(valueOps, never()).set(eq("vocab:10:3"), anyString()); + } + + @Test + @DisplayName("paragraph age가 임계 미만이면 fallback 미발화") + void noFallbackWhenAgeBelowThreshold() { + Paragraph fresh = paragraphWithCreatedAt(LocalDateTime.now()); + when(valueOps.get("vocab:10:3")).thenReturn(null).thenReturn(null); + when(paragraphRepository.findByFairytaleIdAndPage(FAIRYTALE_ID, PAGE)).thenReturn(List.of(fresh)); + + service.markImageDone(FAIRYTALE_ID, PAGE); + + verify(valueOps, never()).set(eq("vocab:10:3"), anyString()); + } + + @Test + @DisplayName("paragraph age가 임계 초과이면 빈 vocab 마커 강제 세팅") + void fallbackFiresWhenAgeOverThreshold() { + Paragraph stale = paragraphWithCreatedAt(LocalDateTime.now().minusSeconds(60)); + when(valueOps.get("vocab:10:3")).thenReturn(null).thenReturn(null); + when(paragraphRepository.findByFairytaleIdAndPage(FAIRYTALE_ID, PAGE)).thenReturn(List.of(stale)); + + service.markImageDone(FAIRYTALE_ID, PAGE); + + verify(valueOps).set(eq("vocab:10:3"), eq("done")); + } + + private Paragraph paragraphWithCreatedAt(LocalDateTime createdAt) { + Paragraph p = Paragraph.builder().page(PAGE).text("dummy").build(); + try { + Field f = p.getClass().getSuperclass().getDeclaredField("createdAt"); + f.setAccessible(true); + f.set(p, createdAt); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + return p; + } +} diff --git a/src/test/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListenerIntegrationTest.java b/src/test/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListenerIntegrationTest.java new file mode 100644 index 0000000..72af8d2 --- /dev/null +++ b/src/test/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListenerIntegrationTest.java @@ -0,0 +1,100 @@ +package com.capstone.kkumteul.domain.kafka.service; + +import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; +import com.capstone.kkumteul.domain.vocab.service.VocabService; +import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.kafka.config.KafkaListenerEndpointRegistry; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.listener.MessageListenerContainer; +import org.springframework.kafka.support.serializer.JsonSerializer; +import org.springframework.kafka.test.EmbeddedKafkaBroker; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.kafka.test.utils.ContainerTestUtils; +import org.springframework.test.annotation.DirtiesContext; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * vocab_extracted 토픽 Listener의 EmbeddedKafka 통합 테스트. + * + *

실제 Kafka broker(임베디드)를 띄우고 메시지 1건 발행 → Listener가 + * VocabService.processExtractedWord를 호출하는지 wire-format 라운드트립을 검증한다.

+ */ +@Tag("kafka-broker") +@SpringBootTest(properties = { + "KAFKA_URL=${spring.embedded.kafka.brokers}", + "FAIRYTALE_GENERATION=fairytale_generate", + "VOCAB_EXTRACTED=vocab_extracted", + "VOCAB_EXTRACTED_GROUP_ID=kkumteul-vocab-test" +}) +@EmbeddedKafka(partitions = 1, topics = {"vocab_extracted", "fairytale_generate"}) +@DirtiesContext +class VocabExtractedListenerIntegrationTest { + + @Autowired + private EmbeddedKafkaBroker embeddedKafkaBroker; + + @Autowired + private KafkaListenerEndpointRegistry registry; + + @MockBean + private VocabService vocabService; + + @MockBean + private FairytaleCheckService fairytaleCheckService; + + @Test + @DisplayName("vocab_extracted 토픽 메시지 1건 → Listener가 VocabService.processExtractedWord 호출") + void listenerInvokesProcessExtractedWord() { + when(vocabService.processExtractedWord(any(VocabExtractedMessage.class))) + .thenReturn(VocabExtractionResult.noDifficultWord()); + + for (MessageListenerContainer container : registry.getListenerContainers()) { + ContainerTestUtils.waitForAssignment(container, embeddedKafkaBroker.getPartitionsPerTopic()); + } + + Map producerProps = new HashMap<>(); + producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, embeddedKafkaBroker.getBrokersAsString()); + producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + producerProps.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); + + ProducerFactory pf = new DefaultKafkaProducerFactory<>(producerProps); + KafkaTemplate template = new KafkaTemplate<>(pf); + + VocabExtractedMessage msg = VocabExtractedMessage.builder() + .fairytaleId(11L) + .pageNo(3) + .word(null) + .meaning(null) + .userId(42L) + .messageId("integration-1") + .build(); + template.send("vocab_extracted", msg); + template.flush(); + + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> + verify(vocabService, times(1)).processExtractedWord(any(VocabExtractedMessage.class)) + ); + } +} diff --git a/src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceProcessExtractedWordTest.java b/src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceProcessExtractedWordTest.java new file mode 100644 index 0000000..b588483 --- /dev/null +++ b/src/test/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceProcessExtractedWordTest.java @@ -0,0 +1,127 @@ +package com.capstone.kkumteul.domain.vocab.service; + +import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.domain.fairytale.repository.FairytaleRepository; +import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; +import com.capstone.kkumteul.domain.vocab.entity.WordEntry; +import com.capstone.kkumteul.domain.vocab.repository.WordEntryRepository; +import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * VocabServiceImpl#processExtractedWord 단위 테스트. + * 모든 종착 분기에서 markVocabDone이 호출되는지(SSE guarantee) 검증. + */ +@ExtendWith(MockitoExtension.class) +class VocabServiceProcessExtractedWordTest { + + @Mock private WordEntryRepository wordEntryRepository; + @Mock private FairytaleRepository fairytaleRepository; + @Mock private FairytaleCheckService fairytaleCheckService; + @Mock private com.capstone.kkumteul.global.client.VocabExtractClient vocabExtractClient; + + @InjectMocks private VocabServiceImpl vocabService; + + private static final Long FAIRYTALE_ID = 10L; + private static final int PAGE_NO = 1; + + @BeforeEach + void setUp() { + } + + private VocabExtractedMessage message(String word, String meaning) { + return VocabExtractedMessage.builder() + .fairytaleId(FAIRYTALE_ID) + .pageNo(PAGE_NO) + .word(word) + .meaning(meaning) + .userId(42L) + .messageId("msg-1") + .build(); + } + + @Test + @DisplayName("word=null이면 NO_DIFFICULT_WORD 반환 + DB 저장 없음 + markVocabDone 호출") + void noDifficultWordWhenWordNull() { + VocabExtractionResult result = vocabService.processExtractedWord(message(null, null)); + + assertThat(result.status()).isEqualTo(VocabExtractionResult.Status.NO_DIFFICULT_WORD); + verify(wordEntryRepository, never()).save(any()); + verify(fairytaleCheckService, times(1)).markVocabDone(FAIRYTALE_ID, PAGE_NO); + } + + @Test + @DisplayName("word=blank이면 NO_DIFFICULT_WORD") + void noDifficultWordWhenWordBlank() { + VocabExtractionResult result = vocabService.processExtractedWord(message(" ", "meaning")); + + assertThat(result.status()).isEqualTo(VocabExtractionResult.Status.NO_DIFFICULT_WORD); + verify(wordEntryRepository, never()).save(any()); + verify(fairytaleCheckService, times(1)).markVocabDone(FAIRYTALE_ID, PAGE_NO); + } + + @Test + @DisplayName("이미 같은 fairytaleId+word가 있으면 DUPLICATE + markVocabDone 호출") + void duplicateWhenWordExists() { + when(wordEntryRepository.existsByFairytaleIdAndWord(FAIRYTALE_ID, "용감")).thenReturn(true); + + VocabExtractionResult result = vocabService.processExtractedWord(message("용감", "...")); + + assertThat(result.status()).isEqualTo(VocabExtractionResult.Status.DUPLICATE); + verify(wordEntryRepository, never()).save(any()); + verify(fairytaleCheckService, times(1)).markVocabDone(FAIRYTALE_ID, PAGE_NO); + } + + @Test + @DisplayName("정상 저장이면 SAVED + markVocabDone 호출") + void savedWhenNew() { + when(wordEntryRepository.existsByFairytaleIdAndWord(FAIRYTALE_ID, "용감")).thenReturn(false); + when(fairytaleRepository.findById(FAIRYTALE_ID)).thenReturn(Optional.of(mockFairytale())); + when(wordEntryRepository.save(any(WordEntry.class))).thenAnswer(inv -> inv.getArgument(0)); + + VocabExtractionResult result = vocabService.processExtractedWord(message("용감", "씩씩한 마음")); + + assertThat(result.status()).isEqualTo(VocabExtractionResult.Status.SAVED); + verify(wordEntryRepository, times(1)).save(any(WordEntry.class)); + verify(fairytaleCheckService, times(1)).markVocabDone(FAIRYTALE_ID, PAGE_NO); + } + + @Test + @DisplayName("save 시 DataIntegrityViolationException → RACE_SKIPPED + markVocabDone 호출 (Phase1과 의도적 divergence)") + void raceSkippedWhenUniqueViolation() { + when(wordEntryRepository.existsByFairytaleIdAndWord(FAIRYTALE_ID, "용감")).thenReturn(false); + when(fairytaleRepository.findById(FAIRYTALE_ID)).thenReturn(Optional.of(mockFairytale())); + when(wordEntryRepository.save(any(WordEntry.class))) + .thenThrow(new DataIntegrityViolationException("UNIQUE violated")); + + VocabExtractionResult result = vocabService.processExtractedWord(message("용감", "...")); + + assertThat(result.status()).isEqualTo(VocabExtractionResult.Status.RACE_SKIPPED); + verify(fairytaleCheckService, times(1)).markVocabDone(FAIRYTALE_ID, PAGE_NO); + } + + private Fairytale mockFairytale() { + return Fairytale.builder().id(FAIRYTALE_ID).build(); + } +} diff --git a/src/test/java/com/capstone/kkumteul/global/config/KafkaConsumerConfigDltTest.java b/src/test/java/com/capstone/kkumteul/global/config/KafkaConsumerConfigDltTest.java new file mode 100644 index 0000000..627ef7b --- /dev/null +++ b/src/test/java/com/capstone/kkumteul/global/config/KafkaConsumerConfigDltTest.java @@ -0,0 +1,85 @@ +package com.capstone.kkumteul.global.config; + +import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; +import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; +import com.capstone.kkumteul.domain.kafka.dto.VocabExtractedMessage; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.TopicPartition; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.listener.ConsumerRecordRecoverer; +import org.springframework.kafka.support.SendResult; + +import java.util.concurrent.CompletableFuture; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * KafkaConsumerConfig.buildVocabRecoverer 단위 테스트. + * + *

recoverer 람다가 다음을 만족함을 직접 호출로 검증: + *

    + *
  • record.value()가 {@link VocabExtractedMessage}이면 markVocabDone 호출 후 DLT publish
  • + *
  • record.value()가 null이면 markVocabDone 미호출, DLT publish만 수행
  • + *
+ */ +class KafkaConsumerConfigDltTest { + + @SuppressWarnings({"rawtypes", "unchecked"}) + private ConsumerRecordRecoverer buildRecoverer( + FairytaleCheckService check, KafkaTemplate template + ) { + TopicPartition tp = new TopicPartition("vocab_extracted.DLT", 0); + RecordMetadata md = new RecordMetadata(tp, 0L, 0, 0L, 0, 0); + SendResult sendResult = new SendResult(new ProducerRecord<>("vocab_extracted.DLT", null), md); + when(template.send(any(ProducerRecord.class))).thenReturn(CompletableFuture.completedFuture(sendResult)); + KafkaConsumerConfig config = new KafkaConsumerConfig("localhost:9092", "kkumteul-vocab", check); + return config.buildVocabRecoverer(template); + } + + @Test + @DisplayName("VocabExtractedMessage 페이로드면 markVocabDone 호출 후 DLT publish") + @SuppressWarnings({"rawtypes", "unchecked"}) + void recovererCallsMarkVocabDoneThenPublishesDlt() { + FairytaleCheckService check = mock(FairytaleCheckService.class); + KafkaTemplate template = mock(KafkaTemplate.class); + ConsumerRecordRecoverer recoverer = buildRecoverer(check, template); + + VocabExtractedMessage payload = VocabExtractedMessage.builder() + .fairytaleId(7L).pageNo(2).word("용감").meaning("...").messageId("m-7-2").build(); + ConsumerRecord record = + new ConsumerRecord<>("vocab_extracted", 0, 0L, null, payload); + + recoverer.accept(record, new RuntimeException("simulated")); + + verify(check, times(1)).markVocabDone(eq(7L), eq(2)); + verify(template, atLeastOnce()).send(any(ProducerRecord.class)); + } + + @Test + @DisplayName("record.value()가 null(역직렬화 실패)이면 markVocabDone 미호출, DLT publish만") + @SuppressWarnings({"rawtypes", "unchecked"}) + void recovererSkipsMarkWhenValueNull() { + FairytaleCheckService check = mock(FairytaleCheckService.class); + KafkaTemplate template = mock(KafkaTemplate.class); + ConsumerRecordRecoverer recoverer = buildRecoverer(check, template); + + ConsumerRecord record = + new ConsumerRecord<>("vocab_extracted", 0, 0L, null, null); + + recoverer.accept(record, new RuntimeException("deserialization failure")); + + verify(check, never()).markVocabDone(any(), any(Integer.class)); + verify(template, atLeastOnce()).send(any(ProducerRecord.class)); + } +} diff --git a/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java b/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java index 9578cc9..755e656 100644 --- a/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java +++ b/src/test/java/com/capstone/kkumteul/kafka/KafkaProducerTest.java @@ -7,7 +7,9 @@ import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; import com.capstone.kkumteul.global.config.KafkaProducerConfig; import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.kafka.core.KafkaTemplate; @@ -19,6 +21,8 @@ import static org.assertj.core.api.Assertions.assertThat; @Slf4j +@Tag("kafka-broker") +@EnabledIfEnvironmentVariable(named = "KAFKA_URL", matches = ".+") @SpringBootTest( classes = KafkaProducerConfig.class ) From 2873844f581b6a50b36b68909f11094d50a3bdd0 Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sun, 10 May 2026 16:19:25 +0900 Subject: [PATCH 51/74] =?UTF-8?q?feat:=20Consumer=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/fairytale/entity/Paragraph.java | 4 ++ .../global/config/KafkaConsumerConfig.java | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Paragraph.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Paragraph.java index 54a448a..8d11764 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Paragraph.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Paragraph.java @@ -29,4 +29,8 @@ public class Paragraph extends BaseEntity { //nullable 제약은 추후 @Column private String imageUrl; + + public void updateImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } } diff --git a/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java b/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java new file mode 100644 index 0000000..756275a --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java @@ -0,0 +1,42 @@ +package com.capstone.kkumteul.global.config; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; + +import java.util.HashMap; +import java.util.Map; + +@EnableKafka +@Configuration +public class KafkaConsumerConfig { + + private final String KAFKA_URL; + + public KafkaConsumerConfig( + @Value("${KAFKA_URL}") String KAFKA_URL + ) { this.KAFKA_URL = KAFKA_URL; } + + @Bean + public ConsumerFactory consumerFactory() { + Map config = new HashMap<>(); + config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_URL); + config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + return new DefaultKafkaConsumerFactory<>(config); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + return factory; + } +} \ No newline at end of file From a44d1385dca51ca2871084820da06be963b96be8 Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sun, 10 May 2026 16:19:54 +0900 Subject: [PATCH 52/74] =?UTF-8?q?feat:=20Consumer=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consumer/FairytaleKafkaConsumer.java | 52 +++++++++++++++++++ .../domain/kafka/dto/ImageMessage.java | 12 +++++ 2 files changed, 64 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java create mode 100644 src/main/java/com/capstone/kkumteul/domain/kafka/dto/ImageMessage.java diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java b/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java new file mode 100644 index 0000000..c2befbb --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java @@ -0,0 +1,52 @@ +package com.capstone.kkumteul.domain.kafka.consumer; + +import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; +import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; +import com.capstone.kkumteul.domain.kafka.dto.FairytaleCompletedMessage; +import com.capstone.kkumteul.domain.kafka.dto.ImageMessage; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FairytaleKafkaConsumer { + + private final ParagraphRepository paragraphRepository; + private final FairytaleCheckService fairytaleCheckService; + private final ObjectMapper objectMapper; + + @KafkaListener(topics = "fairytale_done", groupId = "kkumteul-group") + public void consumeDone(String message) { + try { + FairytaleCompletedMessage msg = objectMapper.readValue(message, FairytaleCompletedMessage.class); + fairytaleCheckService.markTotalPages(msg.getFairytaleId(), msg.getTotalPages()); + } catch (Exception e) { + log.error("fairytale_done 처리 실패 message={}", message, e); + } + } + + @Transactional + @KafkaListener(topics = "fairytale_image", groupId = "kkumteul-group") + public void consumeImage(String message) { + try { + ImageMessage img = objectMapper.readValue(message, ImageMessage.class); + List paragraphs = paragraphRepository.findByFairytaleIdAndPage(img.getFairytaleId(), img.getPageNo()); + if (paragraphs.isEmpty()) { + log.warn("이미지 저장 실패 - 문단 없음 fairytaleId={}, page={}", img.getFairytaleId(), img.getPageNo()); + return; + } + paragraphs.getFirst().updateImageUrl(img.getImageurl()); + fairytaleCheckService.markImageDone(img.getFairytaleId(), img.getPageNo()); + } catch (Exception e) { + log.error("fairytale_image 처리 실패 message={}", message, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/ImageMessage.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/ImageMessage.java new file mode 100644 index 0000000..49a4c8d --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/ImageMessage.java @@ -0,0 +1,12 @@ +package com.capstone.kkumteul.domain.kafka.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ImageMessage { + private Long fairytaleId; + private int pageNo; + private String imageurl; +} \ No newline at end of file From cb29b394426e8ea09d42eb70b9018a4a10a0a19d Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sun, 10 May 2026 16:20:06 +0900 Subject: [PATCH 53/74] =?UTF-8?q?feat:=20Consumer=20done=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/FairytaleCheckService.java | 2 ++ .../service/FairytaleCheckServiceImpl.java | 25 +++++++++++++++++++ .../kafka/dto/FairytaleCompletedMessage.java | 13 ++++++++++ 3 files changed, 40 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleCompletedMessage.java diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java index 7278cda..8725643 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckService.java @@ -7,4 +7,6 @@ public interface FairytaleCheckService { void markImageDone(Long fairytaleId, int page); boolean isBothDone(Long fairytaleId, int page); + + void markTotalPages(Long fairytaleId, int totalPages); } diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java index 6357ffa..db38d87 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java @@ -26,6 +26,8 @@ public class FairytaleCheckServiceImpl implements FairytaleCheckService { private static final String VOCAB_KEY = "vocab:%d:%d"; private static final String IMAGE_KEY = "image:%d:%d"; + private static final String TOTAL_KEY = "total:%d"; + private static final String SENT_KEY = "sent:%d"; private static final String DONE = "done"; @Override @@ -78,5 +80,28 @@ private void checkAndSend(Long fairytaleId, int page) { redisTemplate.delete(String.format(VOCAB_KEY, fairytaleId, page)); redisTemplate.delete(String.format(IMAGE_KEY, fairytaleId, page)); + + Long sent = redisTemplate.opsForValue().increment(String.format(SENT_KEY, fairytaleId)); + checkAndSendDone(fairytaleId, sent); + } + + @Override + public void markTotalPages(Long fairytaleId, int totalPages) { + redisTemplate.opsForValue().set(String.format(TOTAL_KEY, fairytaleId), String.valueOf(totalPages)); + String sentStr = redisTemplate.opsForValue().get(String.format(SENT_KEY, fairytaleId)); + long sent = sentStr == null ? 0L : Long.parseLong(sentStr); + checkAndSendDone(fairytaleId, sent); + } + + private void checkAndSendDone(Long fairytaleId, Long sent) { + String totalStr = redisTemplate.opsForValue().get(String.format(TOTAL_KEY, fairytaleId)); + if (totalStr == null) return; + + if (sent >= Long.parseLong(totalStr)) { + sseService.sendToClient(fairytaleId, "done", String.valueOf(fairytaleId)); + redisTemplate.delete(String.format(TOTAL_KEY, fairytaleId)); + redisTemplate.delete(String.format(SENT_KEY, fairytaleId)); + log.info("SSE done 전송 fairytaleId={}", fairytaleId); + } } } \ No newline at end of file diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleCompletedMessage.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleCompletedMessage.java new file mode 100644 index 0000000..8634f8f --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleCompletedMessage.java @@ -0,0 +1,13 @@ +package com.capstone.kkumteul.domain.kafka.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class FairytaleCompletedMessage { + private Long fairytaleId; + @JsonProperty("total_pages") + private int totalPages; +} \ No newline at end of file From c2fde4688e98b0b84822ebcc6f59deadb9fde6f9 Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Sun, 10 May 2026 16:49:14 +0900 Subject: [PATCH 54/74] =?UTF-8?q?Feat:=20=EB=8F=99=ED=99=94=20=EC=A0=84?= =?UTF-8?q?=EB=AC=B8=EC=9D=84=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8A=94=20con?= =?UTF-8?q?tent=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../capstone/kkumteul/domain/fairytale/entity/Fairytale.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Fairytale.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Fairytale.java index 632dd31..9888ca2 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Fairytale.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/entity/Fairytale.java @@ -39,6 +39,9 @@ public class Fairytale extends BaseEntity { @Column(nullable = false) private Background background; + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + @Builder.Default @OneToMany(mappedBy = "fairytale", cascade = CascadeType.ALL, orphanRemoval = true) private List paragraphs = new ArrayList<>(); From 9764a536e4df809818803bafad4f345b634e2a98 Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Sun, 10 May 2026 16:49:49 +0900 Subject: [PATCH 55/74] =?UTF-8?q?Feat:=20Fairytale=EC=9D=84=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=EC=9D=84=20Fas?= =?UTF-8?q?tAPI=20=EC=84=9C=EB=B2=84=EB=A1=9C=20=EB=B3=B4=EB=82=BC=20?= =?UTF-8?q?=EB=95=8C,=20=EC=83=9D=EC=84=B1=EB=90=9C=20fairytaleId=EB=A5=BC?= =?UTF-8?q?=20=EC=A0=84=EB=8B=AC=ED=95=98=EB=8F=84=EB=A1=9D=20DTO=EC=97=90?= =?UTF-8?q?=20fairytaleId=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkumteul/domain/kafka/dto/FairytaleGenerateMessage.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleGenerateMessage.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleGenerateMessage.java index f0488af..5f737ba 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleGenerateMessage.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleGenerateMessage.java @@ -14,6 +14,7 @@ public class FairytaleGenerateMessage implements MessageInterface { private final Long userId; + private final Long fairytaleId; private final Background background; private final CharSpecies charSpecies; private final Morality morality; From 6bf5b8a35e5adf5f984b96790fddf4dc16b9bd56 Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Sun, 10 May 2026 16:50:40 +0900 Subject: [PATCH 56/74] =?UTF-8?q?Feat:=20not=20null=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EC=93=B0=EB=A0=88=EA=B8=B0=EA=B0=92=EC=9D=84=20?= =?UTF-8?q?=EC=B1=84=EC=9B=8C=20DB=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98?= =?UTF-8?q?=EA=B3=A0,=20ID=EA=B0=92=EC=9D=84=20DTO=EC=97=90=20=EB=8B=B4?= =?UTF-8?q?=EC=95=84=20Kafka=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=ED=95=98=EA=B2=8C=20=ED=95=98=EB=A9=B0,=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EB=90=9C=20fairytaleId=EB=A5=BC=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20EventService=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/kafka/service/EventService.java | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java index 789215e..ef1f750 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/service/EventService.java @@ -1,13 +1,17 @@ package com.capstone.kkumteul.domain.kafka.service; +import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.domain.fairytale.repository.FairytaleRepository; import com.capstone.kkumteul.domain.fairytale.web.dto.FairytaleGenerateReq; import com.capstone.kkumteul.domain.kafka.dto.FairytaleGenerateMessage; import com.capstone.kkumteul.domain.kafka.dto.MessageInterface; +import com.capstone.kkumteul.domain.user.entity.User; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; /* 동화 생성 이벤트 전파 */ @@ -17,20 +21,34 @@ public class EventService { private final KafkaTemplate kafkaTemplate; + private final FairytaleRepository fairytaleRepository; @Value("${FAIRYTALE_GENERATION}") private String FAIRYTALE_GENERATION; - public void createFairytaleMessageSend(Long userId, FairytaleGenerateReq request) { + @Transactional + public Long createFairytaleMessageSend(User user, FairytaleGenerateReq request) { + + Fairytale created = Fairytale.builder() + .user(user) + .title("NONE") + .content("") + .morality(request.getMorality()) + .background(request.getBackground()) + .charSpecies(request.getCharSpecies()) + .build(); + + Fairytale saved = fairytaleRepository.save(created); FairytaleGenerateMessage message = FairytaleGenerateMessage.builder() - .userId(userId) + .userId(user.getId()) + .fairytaleId(saved.getId()) .background(request.getBackground()) .charSpecies(request.getCharSpecies()) .morality(request.getMorality()) .build(); - log.info("fairytale_generate userId={}, message={}", userId, message); + log.info("fairytale_generate userId={}, message={}", user.getId(), message); kafkaTemplate.send(FAIRYTALE_GENERATION, message) .whenComplete((result, e) -> { @@ -39,5 +57,6 @@ public void createFairytaleMessageSend(Long userId, FairytaleGenerateReq request } }); + return saved.getId(); } } From b9f6202ba55076d5a3f6d4f6e3e07f50461324ef Mon Sep 17 00:00:00 2001 From: Joonseok-Lee Date: Sun, 10 May 2026 16:51:20 +0900 Subject: [PATCH 57/74] =?UTF-8?q?Feat:=20=EB=8F=99=ED=99=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=9A=94=EC=B2=AD=EC=9D=98=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=A4=91,=20FE=EA=B0=80=20=EB=8F=99=ED=99=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20SSE=EB=A5=BC=20=EC=97=B0=EA=B2=B0=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20fairytaleId=EB=A5=BC?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20data=20=ED=95=84=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/fairytale/web/controller/FairytaleController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java index 370a4cb..0373c1b 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/web/controller/FairytaleController.java @@ -36,11 +36,11 @@ public ResponseEntity> createFairytale( @AuthUser User user, @Valid @RequestBody FairytaleGenerateReq request ) { - eventService.createFairytaleMessageSend(user.getId(), request); + Long fairytaleId = eventService.createFairytaleMessageSend(user, request); return ResponseEntity .status(HttpStatus.CREATED) - .body(SuccessResponse.empty()); + .body(SuccessResponse.created(fairytaleId)); } @GetMapping("/my") From c4ce34a7c3555f8cc269452dfb88d42de83bef10 Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sun, 10 May 2026 17:08:31 +0900 Subject: [PATCH 58/74] =?UTF-8?q?fix:=20snail=5Fcase=20consumer=ED=98=95?= =?UTF-8?q?=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkumteul/domain/kafka/dto/FairytaleCompletedMessage.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleCompletedMessage.java b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleCompletedMessage.java index 8634f8f..ccb1740 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleCompletedMessage.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/dto/FairytaleCompletedMessage.java @@ -7,6 +7,7 @@ @Getter @NoArgsConstructor public class FairytaleCompletedMessage { + @JsonProperty("fairytale_id") private Long fairytaleId; @JsonProperty("total_pages") private int totalPages; From c91eeac5843867c09fed75341bf051301a625aa0 Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sun, 10 May 2026 17:45:12 +0900 Subject: [PATCH 59/74] =?UTF-8?q?fix:=20dev=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkumteul/domain/kafka/service/VocabExtractedListener.java | 2 -- .../kkumteul/domain/vocab/service/VocabServiceImpl.java | 1 + .../capstone/kkumteul/global/config/KafkaConsumerConfig.java | 1 - .../capstone/kkumteul/global/config/KafkaProducerConfig.java | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListener.java b/src/main/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListener.java index 689b66b..658c4f1 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListener.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/service/VocabExtractedListener.java @@ -5,7 +5,6 @@ import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Profile; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; @@ -17,7 +16,6 @@ */ @Slf4j @Component -@Profile("!dev") @RequiredArgsConstructor public class VocabExtractedListener { diff --git a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java index 0e280bc..04f5ba9 100644 --- a/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabServiceImpl.java @@ -50,6 +50,7 @@ public class VocabServiceImpl implements VocabService { @Transactional public VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List sentences) { Optional extracted = vocabExtractClient.extract(sentences); + log.info("extracted={}",extracted); if (extracted.isEmpty()) { fairytaleCheckService.markVocabDone(fairytaleId, pageNo); return VocabExtractionResult.extractionFailed(); diff --git a/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java b/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java index 0e47e50..b17d859 100644 --- a/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java +++ b/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java @@ -39,7 +39,6 @@ */ @EnableKafka @Configuration -@Profile("!dev") public class KafkaConsumerConfig { private final String kafkaUrl; diff --git a/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java b/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java index 475bdb3..3b7e6a5 100644 --- a/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java +++ b/src/main/java/com/capstone/kkumteul/global/config/KafkaProducerConfig.java @@ -16,7 +16,6 @@ import java.util.Map; @Configuration -@Profile("!dev") public class KafkaProducerConfig { private final String KAFKA_URL; From b099a4f3e79216a6c0fa820419222c707ddcc8fc Mon Sep 17 00:00:00 2001 From: LgE02 Date: Sun, 10 May 2026 19:08:25 +0900 Subject: [PATCH 60/74] =?UTF-8?q?fix:=20log=20=EB=B0=8F=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fairytale/service/FairytaleCheckServiceImpl.java | 9 ++++++++- .../domain/kafka/consumer/FairytaleKafkaConsumer.java | 7 ++++--- .../kkumteul/global/config/KafkaConsumerConfig.java | 3 +-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java index faad82f..5988c92 100644 --- a/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/fairytale/service/FairytaleCheckServiceImpl.java @@ -39,12 +39,14 @@ public class FairytaleCheckServiceImpl implements FairytaleCheckService { @Override public void markVocabDone(Long fairytaleId, int page) { redisTemplate.opsForValue().set(String.format(VOCAB_KEY, fairytaleId, page), DONE); + log.info("[VOCAB DONE] fairytaleId={}, page={}", fairytaleId, page); checkAndSend(fairytaleId, page); } @Override public void markImageDone(Long fairytaleId, int page) { redisTemplate.opsForValue().set(String.format(IMAGE_KEY, fairytaleId, page), DONE); + log.info("[IMAGE DONE] fairytaleId={}, page={}", fairytaleId, page); forceVocabIfStale(fairytaleId, page); checkAndSend(fairytaleId, page); } @@ -78,7 +80,9 @@ public boolean isBothDone(Long fairytaleId, int page) { //sse전송 private void checkAndSend(Long fairytaleId, int page) { - if (!isBothDone(fairytaleId, page)) return; + boolean both = isBothDone(fairytaleId, page); + log.info("[CHECK] fairytaleId={}, page={}, isBothDone={}", fairytaleId, page, both); + if (!both) return; Optional wordEntry = wordEntryRepository.findByFairytaleIdAndPageNo(fairytaleId, page); List paragraphs = paragraphRepository.findByFairytaleIdAndPage(fairytaleId, page); @@ -103,18 +107,21 @@ private void checkAndSend(Long fairytaleId, int page) { paragraph.getImageUrl() ); + log.info("[PAGE_CONTENT SEND] fairytaleId={}, page={}", fairytaleId, page); sseService.sendToClient(fairytaleId, "page_content", event); redisTemplate.delete(String.format(VOCAB_KEY, fairytaleId, page)); redisTemplate.delete(String.format(IMAGE_KEY, fairytaleId, page)); Long sent = redisTemplate.opsForValue().increment(String.format(SENT_KEY, fairytaleId)); + log.info("[SENT COUNT] fairytaleId={}, sent={}", fairytaleId, sent); checkAndSendDone(fairytaleId, sent); } @Override public void markTotalPages(Long fairytaleId, int totalPages) { redisTemplate.opsForValue().set(String.format(TOTAL_KEY, fairytaleId), String.valueOf(totalPages)); + log.info("[TOTAL SET] fairytaleId={}, totalPages={}", fairytaleId, totalPages); String sentStr = redisTemplate.opsForValue().get(String.format(SENT_KEY, fairytaleId)); long sent = sentStr == null ? 0L : Long.parseLong(sentStr); checkAndSendDone(fairytaleId, sent); diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java b/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java index c2befbb..de137f4 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java @@ -10,7 +10,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -33,17 +32,19 @@ public void consumeDone(String message) { } } - @Transactional @KafkaListener(topics = "fairytale_image", groupId = "kkumteul-group") public void consumeImage(String message) { try { ImageMessage img = objectMapper.readValue(message, ImageMessage.class); + log.info("[IMAGE RECEIVED] fairytaleId={}, page={}", img.getFairytaleId(), img.getPageNo()); List paragraphs = paragraphRepository.findByFairytaleIdAndPage(img.getFairytaleId(), img.getPageNo()); if (paragraphs.isEmpty()) { log.warn("이미지 저장 실패 - 문단 없음 fairytaleId={}, page={}", img.getFairytaleId(), img.getPageNo()); return; } - paragraphs.getFirst().updateImageUrl(img.getImageurl()); + Paragraph paragraph = paragraphs.getFirst(); + paragraph.updateImageUrl(img.getImageurl()); + paragraphRepository.save(paragraph); fairytaleCheckService.markImageDone(img.getFairytaleId(), img.getPageNo()); } catch (Exception e) { log.error("fairytale_image 처리 실패 message={}", message, e); diff --git a/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java b/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java index b17d859..1c9fef2 100644 --- a/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java +++ b/src/main/java/com/capstone/kkumteul/global/config/KafkaConsumerConfig.java @@ -8,7 +8,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; import org.springframework.kafka.annotation.EnableKafka; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.core.ConsumerFactory; @@ -84,7 +83,7 @@ public ConsumerFactory vocabConsumerFactory() { config.put(JsonDeserializer.VALUE_DEFAULT_TYPE, VocabExtractedMessage.class.getName()); config.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); - config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest"); + config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); return new DefaultKafkaConsumerFactory<>(config); } From 5bd7824d40b8e3d275a8b089a2dbea01c39136a0 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:25:03 +0900 Subject: [PATCH 61/74] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20RestTemplate?= =?UTF-8?q?=EC=97=90=20connect/read=20timeout=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkumteul/global/config/RestTemplateConfig.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java b/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java index 9e0cf3e..a95561b 100644 --- a/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java +++ b/src/main/java/com/capstone/kkumteul/global/config/RestTemplateConfig.java @@ -10,15 +10,21 @@ @Configuration public class RestTemplateConfig { + /** + * 기본 RestTemplate — 그래프 추출(FastAPI → OpenAI) 호출에 사용. + * OpenAI 평균 5~15초 응답 + cold start 대응으로 read 30초. + */ @Bean - public RestTemplate restTemplate() { - return new RestTemplate(); + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .connectTimeout(Duration.ofSeconds(2)) + .readTimeout(Duration.ofSeconds(30)) + .build(); } /** * 단어장 추출용 RestTemplate. * 페이지당 1회 호출로 응답 빠르게 받아야 하므로 짧은 timeout 적용. - * (기본 restTemplate은 GraphService 등 그래프 추출에 쓰여 timeout 길어도 무방) */ @Bean public RestTemplate vocabRestTemplate(RestTemplateBuilder builder) { From e75a55a82253bfd91f490e018e1b2ea9a4e5ae92 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:26:26 +0900 Subject: [PATCH 62/74] =?UTF-8?q?refactor:=20GraphPersister=20=EB=B9=88=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=EB=A1=9C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EA=B2=BD=EA=B3=84=EC=99=80=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?I/O=20=EA=B2=A9=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/client/GraphPersister.java | 76 +++++++++++++++++++ .../kkumteul/global/client/GraphService.java | 61 +++------------ 2 files changed, 85 insertions(+), 52 deletions(-) create mode 100644 src/main/java/com/capstone/kkumteul/global/client/GraphPersister.java diff --git a/src/main/java/com/capstone/kkumteul/global/client/GraphPersister.java b/src/main/java/com/capstone/kkumteul/global/client/GraphPersister.java new file mode 100644 index 0000000..1903010 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/client/GraphPersister.java @@ -0,0 +1,76 @@ +package com.capstone.kkumteul.global.client; + +import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.domain.game.entity.EdgeChoice; +import com.capstone.kkumteul.domain.game.entity.GraphEdge; +import com.capstone.kkumteul.domain.game.entity.GraphNode; +import com.capstone.kkumteul.domain.game.entity.NodeCategory; +import com.capstone.kkumteul.domain.game.repository.EdgeChoiceRepository; +import com.capstone.kkumteul.domain.game.repository.GraphEdgeRepository; +import com.capstone.kkumteul.domain.game.repository.GraphNodeRepository; +import com.capstone.kkumteul.global.client.dto.GraphExtractResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; + +/** + * 지식그래프 추출 응답을 DB 에 저장하는 빈. + * + *

{@link GraphService} 와 별도 빈으로 분리되어 있는 이유는 Spring AOP self-invocation 함정을 피하기 위함이다. + * {@code GraphService} 내부에서 {@code @Transactional} 메서드를 호출하면 프록시를 거치지 않아 트랜잭션이 적용되지 않는다. + * 외부 I/O 는 {@code GraphService} 가, DB 저장은 본 빈이 담당한다.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GraphPersister { + + private final GraphNodeRepository graphNodeRepository; + private final GraphEdgeRepository graphEdgeRepository; + private final EdgeChoiceRepository edgeChoiceRepository; + + @Transactional + public void persist(Fairytale fairytale, GraphExtractResponse response) { + // 1. graph_nodes 저장 + temp_id → real PK 매핑 + Map tempIdToNode = new HashMap<>(); + + for (GraphExtractResponse.NodeDto nodeDto : response.getNodes()) { + GraphNode node = GraphNode.builder() + .fairytale(fairytale) + .word(nodeDto.getWord()) + .category(NodeCategory.fromLabel(nodeDto.getCategory())) + .build(); + graphNodeRepository.save(node); + tempIdToNode.put(nodeDto.getTempId(), node); + } + + // 2. graph_edges + edge_choices 저장 + for (GraphExtractResponse.EdgeDto edgeDto : response.getEdges()) { + GraphNode fromNode = tempIdToNode.get(edgeDto.getFromTempId()); + GraphNode toNode = tempIdToNode.get(edgeDto.getToTempId()); + + GraphEdge edge = GraphEdge.builder() + .fromNode(fromNode) + .toNode(toNode) + .description(edgeDto.getDescription()) + .build(); + graphEdgeRepository.save(edge); + + for (GraphExtractResponse.ChoiceDto choiceDto : edgeDto.getChoices()) { + EdgeChoice choice = EdgeChoice.builder() + .edge(edge) + .content(choiceDto.getContent()) + .isAnswer(choiceDto.getIsAnswer() != null && choiceDto.getIsAnswer()) + .build(); + edgeChoiceRepository.save(choice); + } + } + + log.info("그래프 저장 완료: fairytaleId={}, nodes={}, edges={}", + fairytale.getId(), response.getNodes().size(), response.getEdges().size()); + } +} diff --git a/src/main/java/com/capstone/kkumteul/global/client/GraphService.java b/src/main/java/com/capstone/kkumteul/global/client/GraphService.java index e652ff7..8691bd2 100644 --- a/src/main/java/com/capstone/kkumteul/global/client/GraphService.java +++ b/src/main/java/com/capstone/kkumteul/global/client/GraphService.java @@ -1,39 +1,31 @@ package com.capstone.kkumteul.global.client; import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; -import com.capstone.kkumteul.domain.game.entity.EdgeChoice; -import com.capstone.kkumteul.domain.game.entity.GraphEdge; -import com.capstone.kkumteul.domain.game.entity.GraphNode; -import com.capstone.kkumteul.domain.game.entity.NodeCategory; -import com.capstone.kkumteul.domain.game.repository.EdgeChoiceRepository; -import com.capstone.kkumteul.domain.game.repository.GraphEdgeRepository; -import com.capstone.kkumteul.domain.game.repository.GraphNodeRepository; import com.capstone.kkumteul.global.client.dto.GraphExtractRequest; import com.capstone.kkumteul.global.client.dto.GraphExtractResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; -import java.util.HashMap; -import java.util.Map; - +/** + * FastAPI 지식그래프 추출 클라이언트. + * + *

외부 I/O 호출만 담당하고, DB 저장은 {@link GraphPersister} 에 위임한다. + * 트랜잭션 경계는 GraphPersister 가 별도 빈으로 보유하므로 본 클래스에는 {@code @Transactional} 을 두지 않는다.

+ */ @Slf4j @Service @RequiredArgsConstructor public class GraphService { - private final GraphNodeRepository graphNodeRepository; - private final GraphEdgeRepository graphEdgeRepository; - private final EdgeChoiceRepository edgeChoiceRepository; private final RestTemplate restTemplate; + private final GraphPersister graphPersister; @Value("${fastapi.base-url:http://localhost:8000}") private String fastApiBaseUrl; - @Transactional public void extractAndSave(Fairytale fairytale, String content) { GraphExtractRequest request = new GraphExtractRequest(fairytale.getId(), content); @@ -47,42 +39,7 @@ public void extractAndSave(Fairytale fairytale, String content) { throw new RuntimeException("FastAPI 그래프 추출 응답이 비어있습니다."); } - // 1. graph_nodes 저장 + temp_id → real PK 매핑 - Map tempIdToNode = new HashMap<>(); - - for (GraphExtractResponse.NodeDto nodeDto : response.getNodes()) { - GraphNode node = GraphNode.builder() - .fairytale(fairytale) - .word(nodeDto.getWord()) - .category(NodeCategory.fromLabel(nodeDto.getCategory())) - .build(); - graphNodeRepository.save(node); - tempIdToNode.put(nodeDto.getTempId(), node); - } - - // 2. graph_edges + edge_choices 저장 - for (GraphExtractResponse.EdgeDto edgeDto : response.getEdges()) { - GraphNode fromNode = tempIdToNode.get(edgeDto.getFromTempId()); - GraphNode toNode = tempIdToNode.get(edgeDto.getToTempId()); - - GraphEdge edge = GraphEdge.builder() - .fromNode(fromNode) - .toNode(toNode) - .description(edgeDto.getDescription()) - .build(); - graphEdgeRepository.save(edge); - - for (GraphExtractResponse.ChoiceDto choiceDto : edgeDto.getChoices()) { - EdgeChoice choice = EdgeChoice.builder() - .edge(edge) - .content(choiceDto.getContent()) - .isAnswer(choiceDto.getIsAnswer() != null && choiceDto.getIsAnswer()) - .build(); - edgeChoiceRepository.save(choice); - } - } - - log.info("그래프 추출 완료: fairytaleId={}, nodes={}, edges={}", - fairytale.getId(), response.getNodes().size(), response.getEdges().size()); + graphPersister.persist(fairytale, response); + log.info("그래프 추출 완료: fairytaleId={}", fairytale.getId()); } } From 2ea49194416aeea0cec5c87d2c920e031bed3a40 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:28:08 +0900 Subject: [PATCH 63/74] =?UTF-8?q?fix:=20startGame=20=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=20=EC=B6=94=EC=B6=9C=20=ED=8F=B4?= =?UTF-8?q?=EB=B0=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/game/service/GameServiceImpl.java | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java index b6adf4d..289fd10 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java @@ -1,8 +1,6 @@ package com.capstone.kkumteul.domain.game.service; import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; -import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; -import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; import com.capstone.kkumteul.domain.game.entity.GraphEdge; import com.capstone.kkumteul.domain.game.entity.GraphNode; import com.capstone.kkumteul.domain.game.entity.NodeCategory; @@ -17,7 +15,6 @@ import com.capstone.kkumteul.domain.game.repository.GraphNodeRepository; import com.capstone.kkumteul.domain.game.web.dto.*; import com.capstone.kkumteul.domain.user.entity.User; -import com.capstone.kkumteul.global.client.GraphService; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -38,8 +35,6 @@ public class GameServiceImpl implements GameService { private final GameResultRepository gameResultRepository; private final GameSessionManager sessionManager; private final EntityManager entityManager; - private final GraphService graphService; - private final ParagraphRepository paragraphRepository; /** * 게임 시작 — POST /game/start @@ -48,10 +43,13 @@ public class GameServiceImpl implements GameService { *
    *
  1. 동화 존재 확인 (EntityManager.find → 없으면 404)
  2. *
  3. game_results에서 (userId, fairytaleId) 조회 → completed=true면 409
  4. - *
  5. graph_nodes에서 fairytaleId로 그래프 존재 확인 → 없으면 404
  6. + *
  7. graph_nodes에서 fairytaleId로 그래프 존재 확인 → 없으면 404 GRAPH_NOT_FOUND
  8. *
  9. 기존 세션 제거 (뒤로 가기 후 재진입 시 처음부터 재시작)
  10. *
  11. 노드/엣지 DB 조회 → 인메모리 세션에 캐싱
  12. *
+ * + *

그래프는 동화 생성 시점에 Kafka consumer 가 비동기로 추출하므로, + * 본 메서드에서 동기 폴백 호출은 하지 않는다.

*/ @Override @Transactional @@ -70,14 +68,10 @@ public GameStartRes startGame(Long userId, Long fairytaleId) { } }); - // graph_nodes 테이블에서 fairytaleId로 그래프 존재 확인 → 없으면 FastAPI 호출 + // graph_nodes 테이블에서 fairytaleId 로 그래프 존재 확인 → 없으면 즉시 404 if (!graphNodeRepository.existsByFairytaleId(fairytaleId)) { - log.info("그래프 미존재 — FastAPI 추출 호출: fairytaleId={}", fairytaleId); - List paragraphs = paragraphRepository.findByFairytaleIdOrderByPageAsc(fairytaleId); - String content = paragraphs.stream() - .map(Paragraph::getText) - .collect(java.util.stream.Collectors.joining(" ")); - graphService.extractAndSave(fairytale, content); + log.warn("그래프 미존재 — fairytaleId={}", fairytaleId); + throw new GraphNotFoundException(); } // 기존 세션 제거 — 뒤로 가기 후 재진입 시 새 세션으로 처음부터 From 2e76b75b18156888fb0df9a3b50f195cae1d61b4 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:29:13 +0900 Subject: [PATCH 64/74] =?UTF-8?q?feat:=20=EA=B7=B8=EB=9E=98=ED=94=84=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EC=A0=84=EC=9A=A9=20Async=20Executor=20?= =?UTF-8?q?=EB=B9=88=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkumteul/global/config/AsyncConfig.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/global/config/AsyncConfig.java diff --git a/src/main/java/com/capstone/kkumteul/global/config/AsyncConfig.java b/src/main/java/com/capstone/kkumteul/global/config/AsyncConfig.java new file mode 100644 index 0000000..c684dc5 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/config/AsyncConfig.java @@ -0,0 +1,30 @@ +package com.capstone.kkumteul.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +/** + * 비동기 작업 전용 Executor 빈 등록. + * + *

현재는 그래프 추출 (FastAPI → OpenAI) 호출 1 종류에만 사용. 풀 사이즈는 캡스톤 시연 부하 기준으로 산정하며 + * 동화 동시 생성 부하가 늘어나면 core/max 를 상향 조정한다.

+ */ +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "graphExtractExecutor") + public Executor graphExtractExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(10); + executor.setThreadNamePrefix("graph-extract-"); + executor.initialize(); + return executor; + } +} From cfaa01e84c23af9ef6095962f62d2a318e16bc85 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:29:20 +0900 Subject: [PATCH 65/74] =?UTF-8?q?feat:=20fairytale=5Fdone=20=EC=88=98?= =?UTF-8?q?=EC=8B=A0=20=EC=8B=9C=20=EB=B9=84=EB=8F=99=EA=B8=B0=EB=A1=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=20=EC=B6=94=EC=B6=9C=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consumer/FairytaleKafkaConsumer.java | 3 + .../global/client/GraphExtractTrigger.java | 64 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/global/client/GraphExtractTrigger.java diff --git a/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java b/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java index de137f4..e2903e9 100644 --- a/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java +++ b/src/main/java/com/capstone/kkumteul/domain/kafka/consumer/FairytaleKafkaConsumer.java @@ -5,6 +5,7 @@ import com.capstone.kkumteul.domain.fairytale.service.FairytaleCheckService; import com.capstone.kkumteul.domain.kafka.dto.FairytaleCompletedMessage; import com.capstone.kkumteul.domain.kafka.dto.ImageMessage; +import com.capstone.kkumteul.global.client.GraphExtractTrigger; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,6 +21,7 @@ public class FairytaleKafkaConsumer { private final ParagraphRepository paragraphRepository; private final FairytaleCheckService fairytaleCheckService; + private final GraphExtractTrigger graphExtractTrigger; private final ObjectMapper objectMapper; @KafkaListener(topics = "fairytale_done", groupId = "kkumteul-group") @@ -27,6 +29,7 @@ public void consumeDone(String message) { try { FairytaleCompletedMessage msg = objectMapper.readValue(message, FairytaleCompletedMessage.class); fairytaleCheckService.markTotalPages(msg.getFairytaleId(), msg.getTotalPages()); + graphExtractTrigger.triggerAsync(msg.getFairytaleId()); } catch (Exception e) { log.error("fairytale_done 처리 실패 message={}", message, e); } diff --git a/src/main/java/com/capstone/kkumteul/global/client/GraphExtractTrigger.java b/src/main/java/com/capstone/kkumteul/global/client/GraphExtractTrigger.java new file mode 100644 index 0000000..f9ad7fb --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/global/client/GraphExtractTrigger.java @@ -0,0 +1,64 @@ +package com.capstone.kkumteul.global.client; + +import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.domain.fairytale.entity.Paragraph; +import com.capstone.kkumteul.domain.fairytale.repository.FairytaleRepository; +import com.capstone.kkumteul.domain.fairytale.repository.ParagraphRepository; +import com.capstone.kkumteul.domain.game.repository.GraphNodeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 동화 본문이 모두 저장된 직후 호출되는 비동기 그래프 추출 트리거. + * + *

Kafka {@code fairytale_done} 컨슈머에서 호출하며, FastAPI {@code /graph/extract} 호출과 그래프 저장은 + * {@link GraphService} 가 담당한다. 본 메서드는 {@code @Async("graphExtractExecutor")} 로 호출되어 + * 컨슈머 스레드를 점유하지 않는다.

+ * + *

실패는 동화 생성 흐름에 영향을 주지 않도록 try/catch 로 흡수하고 ERROR 로깅만 남긴다.

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GraphExtractTrigger { + + private final FairytaleRepository fairytaleRepository; + private final ParagraphRepository paragraphRepository; + private final GraphNodeRepository graphNodeRepository; + private final GraphService graphService; + + @Async("graphExtractExecutor") + public void triggerAsync(Long fairytaleId) { + try { + if (graphNodeRepository.existsByFairytaleId(fairytaleId)) { + log.info("그래프 이미 존재 — skip fairytaleId={}", fairytaleId); + return; + } + + Fairytale fairytale = fairytaleRepository.findById(fairytaleId).orElse(null); + if (fairytale == null) { + log.warn("그래프 추출 보류 - 동화 미존재 fairytaleId={}", fairytaleId); + return; + } + + List paragraphs = paragraphRepository.findByFairytaleIdOrderByPageAsc(fairytaleId); + if (paragraphs.isEmpty()) { + log.warn("그래프 추출 보류 - paragraphs 미존재 fairytaleId={}", fairytaleId); + return; + } + + String content = paragraphs.stream() + .map(Paragraph::getText) + .collect(Collectors.joining(" ")); + + graphService.extractAndSave(fairytale, content); + } catch (Exception e) { + log.error("그래프 추출 실패 fairytaleId={}", fairytaleId, e); + } + } +} From c42f7d396bfba1c79c78815cd1ec58df237bc940 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:30:58 +0900 Subject: [PATCH 66/74] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=97=AC=EB=B6=80=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/game/service/GameService.java | 2 ++ .../domain/game/service/GameServiceImpl.java | 19 +++++++++++++++++++ .../game/web/controller/GameController.java | 10 ++++++++++ .../domain/game/web/dto/GameStatusRes.java | 15 +++++++++++++++ 4 files changed, 46 insertions(+) create mode 100644 src/main/java/com/capstone/kkumteul/domain/game/web/dto/GameStatusRes.java diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameService.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameService.java index 7aee940..cdf6f20 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameService.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameService.java @@ -15,4 +15,6 @@ public interface GameService { EdgeDetailRes getEdgeDetail(Long userId, Long edgeId); GraphDetailRes getGraph(Long userId, Long fairytaleId); + + GameStatusRes getStatus(Long userId, Long fairytaleId); } diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java index 289fd10..df6c1e2 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java @@ -247,6 +247,25 @@ public GraphDetailRes getGraph(Long userId, Long fairytaleId) { return GraphDetailRes.of(fairytaleId, nodes, edges); } + /** + * 게임 완료 여부 조회 — GET /api/game/status + * + *

앱 진입 시 "동화 해설" 버튼 분기에 사용. fairytale 미존재만 404, 그 외에는 completed boolean 으로 반환한다.

+ */ + @Override + public GameStatusRes getStatus(Long userId, Long fairytaleId) { + Fairytale fairytale = entityManager.find(Fairytale.class, fairytaleId); + if (fairytale == null) { + throw new FairytaleNotFoundException(); + } + + boolean completed = gameResultRepository.findByUserIdAndFairytaleId(userId, fairytaleId) + .map(GameResult::isCompleted) + .orElse(false); + + return GameStatusRes.of(fairytaleId, completed); + } + /** * 게임 완료 여부 검증 — 3단계 조회 API 공통. * game_results에서 (userId, fairytaleId) 조합으로 completed=true인지 확인. diff --git a/src/main/java/com/capstone/kkumteul/domain/game/web/controller/GameController.java b/src/main/java/com/capstone/kkumteul/domain/game/web/controller/GameController.java index 3fd5e4f..48620d5 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/web/controller/GameController.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/web/controller/GameController.java @@ -72,4 +72,14 @@ public ResponseEntity> getGraph( GraphDetailRes res = gameService.getGraph(user.getId(), fairytaleId); return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.ok(res)); } + + /** 동화 해설 진입 분기용 — 게임 완료 여부 조회 */ + @GetMapping("/status") + public ResponseEntity> getStatus( + @AuthUser User user, + @RequestParam("fairytale_id") Long fairytaleId + ) { + GameStatusRes res = gameService.getStatus(user.getId(), fairytaleId); + return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.ok(res)); + } } diff --git a/src/main/java/com/capstone/kkumteul/domain/game/web/dto/GameStatusRes.java b/src/main/java/com/capstone/kkumteul/domain/game/web/dto/GameStatusRes.java new file mode 100644 index 0000000..015f9fc --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/game/web/dto/GameStatusRes.java @@ -0,0 +1,15 @@ +package com.capstone.kkumteul.domain.game.web.dto; + +/** + * GET /api/game/status 응답 DTO. + * 앱이 동화 모음집에서 "동화 해설" 진입 시 게임 시작 / 관계도 조회 분기에 사용한다. + */ +public record GameStatusRes( + Long fairytaleId, + boolean completed +) { + + public static GameStatusRes of(Long fairytaleId, boolean completed) { + return new GameStatusRes(fairytaleId, completed); + } +} From 6c0d320ddfc25fe7b9215f79426ff753cd4585d8 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:32:26 +0900 Subject: [PATCH 67/74] =?UTF-8?q?feat:=20GameForbiddenException=20?= =?UTF-8?q?=EB=B0=8F=20GAME=5F403=5F1=20=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkumteul/domain/game/exception/GameErrorCode.java | 3 ++- .../domain/game/exception/GameForbiddenException.java | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/capstone/kkumteul/domain/game/exception/GameForbiddenException.java diff --git a/src/main/java/com/capstone/kkumteul/domain/game/exception/GameErrorCode.java b/src/main/java/com/capstone/kkumteul/domain/game/exception/GameErrorCode.java index 8bc1165..b7cecec 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/exception/GameErrorCode.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/exception/GameErrorCode.java @@ -18,7 +18,8 @@ public enum GameErrorCode implements BaseResponseCode { ALREADY_ANSWERED("GAME_409_2", CONFLICT, "이미 완료한 엣지입니다."), QUIZ_NOT_FOUND("GAME_404_4", NOT_FOUND, "퀴즈를 찾을 수 없습니다."), EDGE_NOT_FOUND("GAME_404_5", NOT_FOUND, "해당 관계를 찾을 수 없습니다."), - GAME_NOT_COMPLETED("GAME_404_6", NOT_FOUND, "완료된 게임이 없습니다."); + GAME_NOT_COMPLETED("GAME_404_6", NOT_FOUND, "완료된 게임이 없습니다."), + GAME_FORBIDDEN("GAME_403_1", FORBIDDEN, "본인 동화가 아닙니다."); private final String code; private final int httpStatus; diff --git a/src/main/java/com/capstone/kkumteul/domain/game/exception/GameForbiddenException.java b/src/main/java/com/capstone/kkumteul/domain/game/exception/GameForbiddenException.java new file mode 100644 index 0000000..24216f6 --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/game/exception/GameForbiddenException.java @@ -0,0 +1,10 @@ +package com.capstone.kkumteul.domain.game.exception; + +import com.capstone.kkumteul.global.exception.BaseException; + +public class GameForbiddenException extends BaseException { + + public GameForbiddenException() { + super(GameErrorCode.GAME_FORBIDDEN); + } +} From 3cb126572c102ca9a59f3df0dcb9430015d39915 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:32:32 +0900 Subject: [PATCH 68/74] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=9E=84=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EA=B2=80=EC=A6=9D=20helper=20=EB=A5=BC=20?= =?UTF-8?q?=EC=86=8C=EC=9C=A0=EA=B6=8C=EC=9A=A9/=EA=B7=B8=EB=9E=98?= =?UTF-8?q?=ED=94=84=EC=9A=A9=20=EB=91=90=20=EA=B0=88=EB=9E=98=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/game/service/GameServiceImpl.java | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java index df6c1e2..ee1dcbd 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java @@ -213,7 +213,7 @@ public QuizAnswerRes answerQuiz(String sessionId, String quizId, Long selectedCh *

관계도 화면에서 선 클릭 시 호출. edge_id로 단건 조회 후 description 반환.

*
    *
  • edge_id → graph_edges 조회 (없으면 404)
  • - *
  • fromNode의 fairytale_id로 game_results 검증 → 본인 완료 데이터가 아니면 403
  • + *
  • 본인 동화가 아니면 403, 본인 동화이지만 미완료면 404
  • *
*/ @Override @@ -221,9 +221,8 @@ public EdgeDetailRes getEdgeDetail(Long userId, Long edgeId) { GraphEdge edge = graphEdgeRepository.findById(edgeId) .orElseThrow(EdgeNotFoundException::new); - // fromNode → fairytale → game_results에서 해당 유저의 완료 여부 검증 - Long fairytaleId = edge.getFromNode().getFairytale().getId(); - validateGameCompleted(userId, fairytaleId); + Fairytale fairytale = edge.getFromNode().getFairytale(); + validateOwnedAndCompleted(userId, fairytale); return EdgeDetailRes.from(edge); } @@ -239,7 +238,7 @@ public EdgeDetailRes getEdgeDetail(Long userId, Long edgeId) { */ @Override public GraphDetailRes getGraph(Long userId, Long fairytaleId) { - validateGameCompleted(userId, fairytaleId); + validateGraphCompleted(userId, fairytaleId); List nodes = graphNodeRepository.findByFairytaleId(fairytaleId); List edges = graphEdgeRepository.findByFairytaleId(fairytaleId); @@ -267,18 +266,34 @@ public GameStatusRes getStatus(Long userId, Long fairytaleId) { } /** - * 게임 완료 여부 검증 — 3단계 조회 API 공통. - * game_results에서 (userId, fairytaleId) 조합으로 completed=true인지 확인. - * 결과가 없거나 미완료면 GameNotCompletedException. + * 본인 동화 + 게임 완료 여부 검증 — GET /game/edge 전용. + *

① 동화 소유권: fairytale.user.id != userId 면 {@link GameForbiddenException} (403). + * ② 완료 여부: game_results.completed != true 면 {@link GameNotCompletedException} (404).

*/ - private void validateGameCompleted(Long userId, Long fairytaleId) { - GameResult result = gameResultRepository.findByUserIdAndFairytaleId(userId, fairytaleId) + private void validateOwnedAndCompleted(Long userId, Fairytale fairytale) { + if (!fairytale.getUser().getId().equals(userId)) { + throw new GameForbiddenException(); + } + GameResult result = gameResultRepository.findByUserIdAndFairytaleId(userId, fairytale.getId()) .orElseThrow(GameNotCompletedException::new); if (!result.isCompleted()) { throw new GameNotCompletedException(); } } + /** + * 완성된 그래프 조회 자격 검증 — GET /game/graph 전용. + *

game_results.completed != true (또는 row 없음) 인 경우를 모두 {@link GraphNotFoundException} (404 GRAPH_NOT_FOUND) 로 통일.

+ */ + private void validateGraphCompleted(Long userId, Long fairytaleId) { + boolean completed = gameResultRepository.findByUserIdAndFairytaleId(userId, fairytaleId) + .map(GameResult::isCompleted) + .orElse(false); + if (!completed) { + throw new GraphNotFoundException(); + } + } + /** game_results INSERT — 2단계 완료 시 서버가 자동 저장 (앱 크래시 대비) */ private void saveGameResult(GameSession session) { if (gameResultRepository.existsByUserIdAndFairytaleId(session.getUserId(), session.getFairytaleId())) { From 3cbe7b50fe1ded4f5be9890d3972f35cb844a6de Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:33:22 +0900 Subject: [PATCH 69/74] =?UTF-8?q?fix:=20game=5Fresults=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20INSERT=20race=20=ED=9D=A1=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/game/service/GameServiceImpl.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java index ee1dcbd..4609ad1 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java @@ -16,6 +16,7 @@ import com.capstone.kkumteul.domain.game.web.dto.*; import com.capstone.kkumteul.domain.user.entity.User; import jakarta.persistence.EntityManager; +import org.springframework.dao.DataIntegrityViolationException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -294,7 +295,12 @@ private void validateGraphCompleted(Long userId, Long fairytaleId) { } } - /** game_results INSERT — 2단계 완료 시 서버가 자동 저장 (앱 크래시 대비) */ + /** + * game_results INSERT — 2단계 완료 시 서버가 자동 저장 (앱 크래시 대비). + * + *

(userId, fairytaleId) UNIQUE 제약이 걸려 있어 동시 INSERT 시 한쪽은 {@link DataIntegrityViolationException} 을 던진다. + * race 패자는 INFO 로 흡수하고 정상 흐름으로 반환한다.

+ */ private void saveGameResult(GameSession session) { if (gameResultRepository.existsByUserIdAndFairytaleId(session.getUserId(), session.getFairytaleId())) { return; @@ -306,6 +312,11 @@ private void saveGameResult(GameSession session) { .fairytale(fairytale) .completed(true) .build(); - gameResultRepository.save(result); + try { + gameResultRepository.save(result); + } catch (DataIntegrityViolationException e) { + log.info("game result race 흡수 userId={}, fairytaleId={}", + session.getUserId(), session.getFairytaleId()); + } } } From 6335a1c197faa00db5976beb4a210ece54195622 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:35:09 +0900 Subject: [PATCH 70/74] =?UTF-8?q?refactor:=20GameSession=20=EC=BA=A1?= =?UTF-8?q?=EC=8A=90=ED=99=94=20=EB=B0=8F=20SessionEdge=20description=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/game/service/GameSession.java | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java index a583a19..05ed2df 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java @@ -10,7 +10,6 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; -@Getter public class GameSession { private final String sessionId; @@ -18,6 +17,7 @@ public class GameSession { private final Long fairytaleId; private final Map nodeMap; private final List edges; + private final Map edgeMap; private final Set classifiedNodeIds; private final Set completedEdgeIds; private final Map quizEdgeMap; @@ -35,12 +35,44 @@ public GameSession(Long userId, Long fairytaleId, List nodes, List(); + for (SessionEdge edge : this.edges) { + this.edgeMap.put(edge.getId(), edge); + } this.classifiedNodeIds = ConcurrentHashMap.newKeySet(); this.completedEdgeIds = ConcurrentHashMap.newKeySet(); this.quizEdgeMap = new ConcurrentHashMap<>(); this.lastActivity = LocalDateTime.now(); } + public String getSessionId() { + return sessionId; + } + + public Long getUserId() { + return userId; + } + + public Long getFairytaleId() { + return fairytaleId; + } + + public Collection getNodes() { + return Collections.unmodifiableCollection(nodeMap.values()); + } + + public List getEdges() { + return edges; + } + + public int getTotalEdges() { + return edges.size(); + } + + public SessionEdge findEdge(Long edgeId) { + return edgeMap.get(edgeId); + } + public void touch() { this.lastActivity = LocalDateTime.now(); } @@ -91,14 +123,6 @@ public Long getEdgeIdByQuizId(String quizId) { return quizEdgeMap.get(quizId); } - public int getTotalEdges() { - return edges.size(); - } - - public Collection getNodes() { - return nodeMap.values(); - } - @Getter public static class SessionNode { private final Long id; @@ -121,15 +145,22 @@ public static class SessionEdge { private final Long id; private final Long fromNodeId; private final Long toNodeId; + private final String description; - private SessionEdge(Long id, Long fromNodeId, Long toNodeId) { + private SessionEdge(Long id, Long fromNodeId, Long toNodeId, String description) { this.id = id; this.fromNodeId = fromNodeId; this.toNodeId = toNodeId; + this.description = description; } public static SessionEdge from(GraphEdge edge) { - return new SessionEdge(edge.getId(), edge.getFromNode().getId(), edge.getToNode().getId()); + return new SessionEdge( + edge.getId(), + edge.getFromNode().getId(), + edge.getToNode().getId(), + edge.getDescription() + ); } } } From 98b144018a60c028c448d61cd3455446b7f95e92 Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:35:16 +0900 Subject: [PATCH 71/74] =?UTF-8?q?refactor:=20answerQuiz=20=EC=A0=95?= =?UTF-8?q?=EB=8B=B5=20=EB=B6=84=EA=B8=B0=EC=97=90=EC=84=9C=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=BA=90=EC=8B=9C=20=EC=82=AC=EC=9A=A9=20=EB=B0=8F?= =?UTF-8?q?=20import=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/game/service/GameServiceImpl.java | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java index 4609ad1..44242e6 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java @@ -1,24 +1,36 @@ package com.capstone.kkumteul.domain.game.service; import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.domain.fairytale.exception.FairytaleNotFoundException; +import com.capstone.kkumteul.domain.game.entity.EdgeChoice; +import com.capstone.kkumteul.domain.game.entity.GameResult; import com.capstone.kkumteul.domain.game.entity.GraphEdge; import com.capstone.kkumteul.domain.game.entity.GraphNode; import com.capstone.kkumteul.domain.game.entity.NodeCategory; -import com.capstone.kkumteul.domain.game.entity.EdgeChoice; -import com.capstone.kkumteul.domain.game.entity.GameResult; -import com.capstone.kkumteul.domain.fairytale.exception.FairytaleNotFoundException; -import com.capstone.kkumteul.domain.game.entity.*; -import com.capstone.kkumteul.domain.game.exception.*; +import com.capstone.kkumteul.domain.game.exception.AlreadyAnsweredException; +import com.capstone.kkumteul.domain.game.exception.EdgeNotFoundException; +import com.capstone.kkumteul.domain.game.exception.GameAlreadyCompletedException; +import com.capstone.kkumteul.domain.game.exception.GameForbiddenException; +import com.capstone.kkumteul.domain.game.exception.GameNotCompletedException; +import com.capstone.kkumteul.domain.game.exception.GraphNotFoundException; +import com.capstone.kkumteul.domain.game.exception.InvalidEdgeException; +import com.capstone.kkumteul.domain.game.exception.QuizNotFoundException; import com.capstone.kkumteul.domain.game.repository.EdgeChoiceRepository; import com.capstone.kkumteul.domain.game.repository.GameResultRepository; import com.capstone.kkumteul.domain.game.repository.GraphEdgeRepository; import com.capstone.kkumteul.domain.game.repository.GraphNodeRepository; -import com.capstone.kkumteul.domain.game.web.dto.*; +import com.capstone.kkumteul.domain.game.web.dto.ClassifyRes; +import com.capstone.kkumteul.domain.game.web.dto.EdgeDetailRes; +import com.capstone.kkumteul.domain.game.web.dto.GameStartRes; +import com.capstone.kkumteul.domain.game.web.dto.GameStatusRes; +import com.capstone.kkumteul.domain.game.web.dto.GraphDetailRes; +import com.capstone.kkumteul.domain.game.web.dto.QuizAnswerRes; +import com.capstone.kkumteul.domain.game.web.dto.QuizRes; import com.capstone.kkumteul.domain.user.entity.User; import jakarta.persistence.EntityManager; -import org.springframework.dao.DataIntegrityViolationException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -191,9 +203,11 @@ public QuizAnswerRes answerQuiz(String sessionId, String quizId, Long selectedCh return QuizAnswerRes.incorrect(); } - // 정답 처리 — 엣지 완료 표시 - GraphEdge edge = graphEdgeRepository.findById(edgeId) - .orElseThrow(InvalidEdgeException::new); + // 정답 처리 — 엣지 완료 표시 (description 은 세션 캐시에서 읽음) + GameSession.SessionEdge edge = session.findEdge(edgeId); + if (edge == null) { + throw new InvalidEdgeException(); + } session.markEdgeCompleted(edgeId); // 모든 엣지 완료 → 2단계 종료 From 5b092cee4c6b9a1b5c467526704f074ffd5ad5cd Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:38:28 +0900 Subject: [PATCH 72/74] =?UTF-8?q?refactor:=20=EA=B7=B8=EB=9E=98=ED=94=84?= =?UTF-8?q?=20=EC=B6=94=EC=B6=9C=20RuntimeException=20=EC=9D=84=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=98=88=EC=99=B8=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkumteul/domain/game/exception/GameErrorCode.java | 4 +++- .../game/exception/GraphExtractFailedException.java | 10 ++++++++++ .../game/exception/InvalidGraphPayloadException.java | 10 ++++++++++ .../kkumteul/global/client/GraphPersister.java | 4 ++++ .../capstone/kkumteul/global/client/GraphService.java | 3 ++- .../capstone/kkumteul/global/constant/StaticValue.java | 1 + 6 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/capstone/kkumteul/domain/game/exception/GraphExtractFailedException.java create mode 100644 src/main/java/com/capstone/kkumteul/domain/game/exception/InvalidGraphPayloadException.java diff --git a/src/main/java/com/capstone/kkumteul/domain/game/exception/GameErrorCode.java b/src/main/java/com/capstone/kkumteul/domain/game/exception/GameErrorCode.java index b7cecec..dddde85 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/exception/GameErrorCode.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/exception/GameErrorCode.java @@ -19,7 +19,9 @@ public enum GameErrorCode implements BaseResponseCode { QUIZ_NOT_FOUND("GAME_404_4", NOT_FOUND, "퀴즈를 찾을 수 없습니다."), EDGE_NOT_FOUND("GAME_404_5", NOT_FOUND, "해당 관계를 찾을 수 없습니다."), GAME_NOT_COMPLETED("GAME_404_6", NOT_FOUND, "완료된 게임이 없습니다."), - GAME_FORBIDDEN("GAME_403_1", FORBIDDEN, "본인 동화가 아닙니다."); + GAME_FORBIDDEN("GAME_403_1", FORBIDDEN, "본인 동화가 아닙니다."), + GRAPH_EXTRACT_FAILED("GAME_502_1", BAD_GATEWAY, "지식그래프 추출에 실패했습니다."), + INVALID_GRAPH_PAYLOAD("GAME_500_1", INTERNAL_SERVER_ERROR, "지식그래프 응답이 유효하지 않습니다."); private final String code; private final int httpStatus; diff --git a/src/main/java/com/capstone/kkumteul/domain/game/exception/GraphExtractFailedException.java b/src/main/java/com/capstone/kkumteul/domain/game/exception/GraphExtractFailedException.java new file mode 100644 index 0000000..776c6db --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/game/exception/GraphExtractFailedException.java @@ -0,0 +1,10 @@ +package com.capstone.kkumteul.domain.game.exception; + +import com.capstone.kkumteul.global.exception.BaseException; + +public class GraphExtractFailedException extends BaseException { + + public GraphExtractFailedException() { + super(GameErrorCode.GRAPH_EXTRACT_FAILED); + } +} diff --git a/src/main/java/com/capstone/kkumteul/domain/game/exception/InvalidGraphPayloadException.java b/src/main/java/com/capstone/kkumteul/domain/game/exception/InvalidGraphPayloadException.java new file mode 100644 index 0000000..a2d569f --- /dev/null +++ b/src/main/java/com/capstone/kkumteul/domain/game/exception/InvalidGraphPayloadException.java @@ -0,0 +1,10 @@ +package com.capstone.kkumteul.domain.game.exception; + +import com.capstone.kkumteul.global.exception.BaseException; + +public class InvalidGraphPayloadException extends BaseException { + + public InvalidGraphPayloadException() { + super(GameErrorCode.INVALID_GRAPH_PAYLOAD); + } +} diff --git a/src/main/java/com/capstone/kkumteul/global/client/GraphPersister.java b/src/main/java/com/capstone/kkumteul/global/client/GraphPersister.java index 1903010..36e80e6 100644 --- a/src/main/java/com/capstone/kkumteul/global/client/GraphPersister.java +++ b/src/main/java/com/capstone/kkumteul/global/client/GraphPersister.java @@ -5,6 +5,7 @@ import com.capstone.kkumteul.domain.game.entity.GraphEdge; import com.capstone.kkumteul.domain.game.entity.GraphNode; import com.capstone.kkumteul.domain.game.entity.NodeCategory; +import com.capstone.kkumteul.domain.game.exception.InvalidGraphPayloadException; import com.capstone.kkumteul.domain.game.repository.EdgeChoiceRepository; import com.capstone.kkumteul.domain.game.repository.GraphEdgeRepository; import com.capstone.kkumteul.domain.game.repository.GraphNodeRepository; @@ -52,6 +53,9 @@ public void persist(Fairytale fairytale, GraphExtractResponse response) { for (GraphExtractResponse.EdgeDto edgeDto : response.getEdges()) { GraphNode fromNode = tempIdToNode.get(edgeDto.getFromTempId()); GraphNode toNode = tempIdToNode.get(edgeDto.getToTempId()); + if (fromNode == null || toNode == null) { + throw new InvalidGraphPayloadException(); + } GraphEdge edge = GraphEdge.builder() .fromNode(fromNode) diff --git a/src/main/java/com/capstone/kkumteul/global/client/GraphService.java b/src/main/java/com/capstone/kkumteul/global/client/GraphService.java index 8691bd2..138bf99 100644 --- a/src/main/java/com/capstone/kkumteul/global/client/GraphService.java +++ b/src/main/java/com/capstone/kkumteul/global/client/GraphService.java @@ -1,6 +1,7 @@ package com.capstone.kkumteul.global.client; import com.capstone.kkumteul.domain.fairytale.entity.Fairytale; +import com.capstone.kkumteul.domain.game.exception.GraphExtractFailedException; import com.capstone.kkumteul.global.client.dto.GraphExtractRequest; import com.capstone.kkumteul.global.client.dto.GraphExtractResponse; import lombok.RequiredArgsConstructor; @@ -36,7 +37,7 @@ public void extractAndSave(Fairytale fairytale, String content) { ); if (response == null || response.getNodes() == null) { - throw new RuntimeException("FastAPI 그래프 추출 응답이 비어있습니다."); + throw new GraphExtractFailedException(); } graphPersister.persist(fairytale, response); diff --git a/src/main/java/com/capstone/kkumteul/global/constant/StaticValue.java b/src/main/java/com/capstone/kkumteul/global/constant/StaticValue.java index b517d10..c287a5b 100644 --- a/src/main/java/com/capstone/kkumteul/global/constant/StaticValue.java +++ b/src/main/java/com/capstone/kkumteul/global/constant/StaticValue.java @@ -17,6 +17,7 @@ public class StaticValue { /* 5xx response */ public static final int INTERNAL_SERVER_ERROR = 500; + public static final int BAD_GATEWAY = 502; private StaticValue() {} From 9f23e2d11aa7af399bb89d6bc8a4fee4056fe1be Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:41:30 +0900 Subject: [PATCH 73/74] =?UTF-8?q?refactor:=20GameSession=20wildcard=20impo?= =?UTF-8?q?rt=20=EB=AA=85=EC=8B=9C=EC=A0=81=20import=20=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkumteul/domain/game/service/GameSession.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java index 05ed2df..91ff62a 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameSession.java @@ -7,7 +7,14 @@ import lombok.Getter; import java.time.LocalDateTime; -import java.util.*; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; public class GameSession { From 2479d88dd5b88c46666901709c1b9f71bc2c74bf Mon Sep 17 00:00:00 2001 From: zzuhannn Date: Thu, 14 May 2026 12:43:21 +0900 Subject: [PATCH 74/74] =?UTF-8?q?refactor:=20GameServiceImpl=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20javadoc=20=EC=8A=AC=EB=A6=BC=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=95=EB=8B=B5=20=EB=B6=84=EA=B8=B0=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/game/service/GameServiceImpl.java | 69 +------------------ 1 file changed, 3 insertions(+), 66 deletions(-) diff --git a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java index 44242e6..89b9439 100644 --- a/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java +++ b/src/main/java/com/capstone/kkumteul/domain/game/service/GameServiceImpl.java @@ -50,19 +50,7 @@ public class GameServiceImpl implements GameService { private final EntityManager entityManager; /** - * 게임 시작 — POST /game/start - * - *

처리 흐름:

- *
    - *
  1. 동화 존재 확인 (EntityManager.find → 없으면 404)
  2. - *
  3. game_results에서 (userId, fairytaleId) 조회 → completed=true면 409
  4. - *
  5. graph_nodes에서 fairytaleId로 그래프 존재 확인 → 없으면 404 GRAPH_NOT_FOUND
  6. - *
  7. 기존 세션 제거 (뒤로 가기 후 재진입 시 처음부터 재시작)
  8. - *
  9. 노드/엣지 DB 조회 → 인메모리 세션에 캐싱
  10. - *
- * - *

그래프는 동화 생성 시점에 Kafka consumer 가 비동기로 추출하므로, - * 본 메서드에서 동기 폴백 호출은 하지 않는다.

+ * 그래프는 Kafka consumer 가 비동기로 추출하므로, 본 메서드에서 동기 폴백 호출은 하지 않는다. */ @Override @Transactional @@ -100,16 +88,6 @@ public GameStartRes startGame(Long userId, Long fairytaleId) { return GameStartRes.of(session.getSessionId(), session.getNodes()); } - /** - * 1단계 바구니 분류 — POST /game/classify - * - *

드래그할 때마다 즉시 호출. 세션 캐싱 데이터로 채점하므로 DB 조회 없음.

- *
    - *
  • 이미 정답 처리된 노드 재제출 → correct: true (멱등성 보장)
  • - *
  • 카테고리 한글 라벨("등장인물") → NodeCategory.fromLabel()로 변환 후 비교
  • - *
  • 전체 노드 분류 완료 시 stage_complete=true + 2단계 데이터(노드+카테고리+총 엣지 수) 반환
  • - *
- */ @Override public ClassifyRes classify(String sessionId, Long nodeId, String category) { // 세션 조회 + TTL 갱신 (없으면 404) @@ -136,17 +114,6 @@ public ClassifyRes classify(String sessionId, Long nodeId, String category) { return ClassifyRes.correct(false); } - /** - * 2단계 퀴즈 요청 — POST /game/quiz - * - *

두 노드 사이에 선을 그을 때 호출.

- *
    - *
  • 양방향 매칭으로 엣지 조회 (A→B든 B→A든 동일 엣지)
  • - *
  • 이미 정답 처리된 엣지면 409 (ALREADY_ANSWERED)
  • - *
  • 유효하지 않은 조합이면 400 (INVALID_EDGE)
  • - *
  • 보기 3개는 랜덤 셔플하여 반환
  • - *
- */ @Override public QuizRes requestQuiz(String sessionId, Long fromNodeId, Long toNodeId) { GameSession session = sessionManager.get(sessionId); @@ -170,14 +137,7 @@ public QuizRes requestQuiz(String sessionId, Long fromNodeId, Long toNodeId) { } /** - * 2단계 퀴즈 정답 제출 — POST /game/quiz/answer - * - *

choice_id(PK)로 정답 제출. 텍스트 매칭 대신 PK 비교로 안전 채점.

- *
    - *
  • 정답 시 description 반환 → 앱에서 관계 설명 모달 표시
  • - *
  • 오답 시 힌트 반환 (재시도 제한 없음 — 유아 대상)
  • - *
  • 모든 엣지 완료 시 game_results 자동 저장 + 완성된 그래프 반환
  • - *
+ * 텍스트 매칭이 아닌 choice_id(PK) 비교로 채점한다. */ @Override @Transactional @@ -194,12 +154,7 @@ public QuizAnswerRes answerQuiz(String sessionId, String quizId, Long selectedCh EdgeChoice selectedChoice = edgeChoiceRepository.findById(selectedChoiceId) .orElseThrow(QuizNotFoundException::new); - // 선택한 보기가 해당 엣지에 속하는지 검증 - if (!selectedChoice.getEdge().getId().equals(edgeId)) { - return QuizAnswerRes.incorrect(); - } - - if (!selectedChoice.isAnswer()) { + if (!selectedChoice.getEdge().getId().equals(edgeId) || !selectedChoice.isAnswer()) { return QuizAnswerRes.incorrect(); } @@ -222,15 +177,6 @@ public QuizAnswerRes answerQuiz(String sessionId, String quizId, Long selectedCh return QuizAnswerRes.correct(edge.getDescription()); } - /** - * 3단계 엣지 상세 조회 — GET /game/edge - * - *

관계도 화면에서 선 클릭 시 호출. edge_id로 단건 조회 후 description 반환.

- *
    - *
  • edge_id → graph_edges 조회 (없으면 404)
  • - *
  • 본인 동화가 아니면 403, 본인 동화이지만 미완료면 404
  • - *
- */ @Override public EdgeDetailRes getEdgeDetail(Long userId, Long edgeId) { GraphEdge edge = graphEdgeRepository.findById(edgeId) @@ -242,15 +188,6 @@ public EdgeDetailRes getEdgeDetail(Long userId, Long edgeId) { return EdgeDetailRes.from(edge); } - /** - * 3단계 전체 관계도 조회 — GET /game/graph - * - *

동화 모음집에서 '관계도' 버튼 클릭 시 호출. 완성된 그래프(노드+엣지)를 반환.

- *
    - *
  • game_results에서 (userId, fairytaleId) 완료 검증 → 미완료/미존재 시 404
  • - *
  • graph_nodes + graph_edges 조회 후 반환
  • - *
- */ @Override public GraphDetailRes getGraph(Long userId, Long fairytaleId) { validateGraphCompleted(userId, fairytaleId);