Skip to content

feat: 동화 페이지 단어장 추출 도메인 구현 #32

Merged
zzuhannn merged 18 commits into
developfrom
feat/#31
May 10, 2026
Merged

feat: 동화 페이지 단어장 추출 도메인 구현 #32
zzuhannn merged 18 commits into
developfrom
feat/#31

Conversation

@zzuhannn

@zzuhannn zzuhannn commented Apr 30, 2026

Copy link
Copy Markdown
Member

🔎 관련 이슈

📋 작업 내용

vocab 도메인

  • WordEntry 엔티티 + WordEntryRepository (UNIQUE(fairytale_id, word) → first-occurrence-wins)
  • VocabErrorCode + 예외 클래스 (ParagraphNotFoundForVocabException, VocabForbiddenException)
  • VocabExtractionResult 서비스 DTO (5-state Status enum)
  • VocabService 인터페이스 + VocabServiceImpl (carrier-agnostic — web/kafka 의존 X)
  • WordEntryRes (record + 정적 팩토리)

FastAPI 클라이언트

  • VocabExtractClient (/vocab/extract 호출, timeout/5xx 시 1회 retry, @PostConstruct warmup)
  • VocabExtractRequest/Response DTO
  • vocabRestTemplate 빈 (connect 1s / read 4s) — 기존 restTemplate은 그래프 추출용으로 분리

API

  • GET /api/fairytales/{fairytaleId}/vocab — 본인 동화 누적 단어장 조회 (인증 필요)
  • POST /internal/vocab/process — dev 프로필 한정 시연용 (카프카 없이 단독 호출)

dev 프로필 한정 인프라

  • InternalApiSecurityConfig (@Profile(\"dev\") + @Order(HIGHEST_PRECEDENCE)) — /internal/** 인증 면제
  • application-dev.properties (활성화 안내)

기존 코드 수정

  • ParagraphRepository.findByFairytaleIdAndPage 메서드 추가 (페이지 단위 조회)
  • RestTemplateConfigvocabRestTemplate 빈 추가

테스트

  • VocabServiceArchTest — vocab.service 패키지가 web/message/kafka/servlet에 의존하지 않음을 ArchUnit으로 자동 검증

💡 알림

  • 베이스 브랜치는 chore/#29 (JpaAuditingConfig 추가). PR feat: JpaAuditingConfig 추가 #30 머지되면 자동으로 develop 베이스로 변경됩니다.
  • AI 저장소에 POST /vocab/extract 엔드포인트 추가 PR이 별도로 필요합니다.
  • 시연용 호출: SPRING_PROFILES_ACTIVE=dev로 부팅 후 POST /internal/vocab/process (인증 불필요).

✅ 체크리스트

  • 빌드 성공
  • ArchUnit 테스트 통과 (carrier-agnostic 검증)
  • AI 저장소의 /vocab/extract 엔드포인트 머지 후 통합 테스트
  • dev 프로필로 부팅 → POST /internal/vocab/process 단독 동작 확인
  • 운영 프로필로 부팅 → /internal/** 401 반환 확인

zzuhannn added 14 commits April 30, 2026 22:52
vocab 도메인 service 패키지가 web/message/kafka에 의존하지 않는지
ArchUnit으로 자동 검증하기 위한 testImplementation 추가
동화 페이지의 어려운 단어 + 풀이 저장.
UNIQUE(fairytale_id, word) 제약으로 first-occurrence-wins 보장.
- existsByFairytaleIdAndWord: 중복 단어 pre-check
- findByFairytaleIdOrderByPageNoAsc: 누적 단어장 페이지 순 조회
- VocabErrorCode enum (PARAGRAPH_NOT_FOUND_FOR_VOCAB, VOCAB_FORBIDDEN, VOCAB_EXTRACT_FAILED)
- ParagraphNotFoundForVocabException
- VocabForbiddenException
5-state Status enum (SAVED/DUPLICATE/NO_DIFFICULT_WORD/EXTRACTION_FAILED/RACE_SKIPPED)
service 패키지가 web/message에 의존하지 않도록 자체 DTO 사용.
단어장 추출 시 특정 페이지의 문장만 조회하기 위한 메서드.
기존 findByFairytaleIdOrderByPageAsc는 그대로 유지.
단어장 추출용 별도 RestTemplate 분리 (connect 1s / read 4s).
기존 restTemplate은 그래프 추출용으로 그대로 유지.
- VocabExtractRequest/Response DTO
- timeout/5xx 시 1회 retry, 4xx는 즉시 실패
- @PostConstruct warmup으로 cold-start 비용 사전 흡수
- 호출자에 예외 전파하지 않고 Optional.empty()로 fail-open
- processSentences: 3문장 → LLM 단어/풀이 추출 → DB 저장 (5-state 결과)
- getVocab: 본인 동화 누적 단어장 페이지 순 조회
- DataIntegrityViolationException catch로 race condition 처리
- carrier-agnostic: web/dto, kafka/message에 의존하지 않음
- WordEntryRes DTO (record + from/listOf 정적 팩토리)
- GET /api/fairytales/{fairytaleId}/vocab — 인증된 본인 동화만 조회 가능
/internal/** 경로를 인증 없이 통과시키는 별도 SecurityFilterChain.
@Profile("dev") + @order(HIGHEST_PRECEDENCE)로 운영 환경엔 영향 없음.
POST /internal/vocab/process
- 카프카 없이 단어장 추출 비즈니스 로직 단독 호출 가능
- @Profile("dev")로 운영 환경엔 미등록
- @PostConstruct에서 등록 사실 INFO 로그로 노출
vocab.service 패키지가 web/message/kafka/servlet에 의존하지 않음을
ArchUnit으로 빌드 시점에 자동 검증.
Phase 2 카프카 어댑터 추가 시에도 service 변경 없음을 강제 보장.
dev 프로필 활성화 시 동작과 운영 환경에서 활성화 금지 안내.
@zzuhannn zzuhannn changed the title feat: 동화 페이지 단어장 추출 도메인 구현 (Phase 1, REST) feat: 동화 페이지 단어장 추출 도메인 구현 Apr 30, 2026

@Joonseok-Lee Joonseok-Lee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

궁금한 부분에 대해 리뷰 작성했습니다. 검토 부탁드립니다.

WordEntry saved = wordEntryRepository.save(entry);
return VocabExtractionResult.saved(saved);
} catch (DataIntegrityViolationException e) {
log.info("vocab race condition fairytaleId={}, word={}", fairytaleId, word);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

특정 사용자가 단어장을 생성하고 있을 때, 해당 동화의 단어장 생성 자체를 locking 해서 다른 사용자가 단어장을 생성하지 못하도록 막는걸까요?

  1. 동화 생성 단계에서 3문장 생성 시, 삽화와 단어장 생성 요청
  2. 3페이지 생성됐을 때부터 생성시도자가 동화 읽기 가능

위 로직으로 생성하게 됩니다. 경쟁상태라고 표현한 이유가 궁금합니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 한 동화에서 단어 저장 요청이 동시에 오게되었을때를 대비한 catch문이라고 이해했습니다.
만약에 kafak가 토픽/파티션을 여러개 해서 단어추출 과정을 동시에 돌리게 된다면 해당 try_catch가 필요할 것 같은데
저희가 토픽/파티션 하나를 이용해서 받게된다면 순차적으로 저장될 것 같아서 dead code가 될 것 같다는 생각이 듭니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

같은 동화에서 같은 단어가 여러 페이지에 등장하는 경우(예: "용궁"이 페이지1, 페이지2에 둘 다 나옴)를 가정한 것 입니다. 두 페이지의 단어 추출 처리가 동시에 들어오면 둘 다 existsBy 체크는 통과하는데 INSERT 시점에 UNIQUE(fairytale_id, word) 제약 때문에 한쪽이 위반이 나기때문에, 그걸 catch로 받아 RACE_SKIPPED 처리한 안전망(?)으로 작성한 것입니다 즉 다른 사용자를 막는 락이 아닌 위의 예를 말한 것 입니디 경쟁상태라고 표현한 이유는 두 트랜잭션이 같은 값을 insert 했을 때 경쟁하는 상황을 말한 것입니다.

return VocabExtractionResult.duplicate();
}

Fairytale fairytale = entityManager.getReference(Fairytale.class, fairytaleId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 동화 테이블의 기초 데이터들(캐릭터 타입, 배경, 교훈, ...)을 미리 저장하는 것을 전제로 두고 개발한걸까요? ID가 없다면, save() 시, NotFoundException이 발생할 수 있습니다.
답글로 어떤 방식으로 진행할지 적어주세요!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저와 유사한게 생각한건지는 모르겠지만
저는 사용자가 캐릭터 타입/배경/교훈을 선택해서 요청을 날리면 db에 저장후 동화 생성으로 넘어간다고 생각했습니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저와 유사한게 생각한건지는 모르겠지만 저는 사용자가 캐릭터 타입/배경/교훈을 선택해서 요청을 날리면 db에 저장후 동화 생성으로 넘어간다고 생각했습니다.
작성 시 Spring Server 쪽에 Not Null 제약 조건을 작성했다고 생각했는데, 현재 구조상 우선 DB에 캐릭터를 저장하고 시작하는 로직으로 FastAPI 서비스 로직이 구현되어있습니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fairytaleId는 동화가 먼저 저장된 뒤에 단어장 처리가 호출되는 전제로 작성했고, getReference는 SELECT 한 번 줄이려는 최적화였습니다 다만 지적한대로 그 가정이 깨질 경우 save() 시점 FK 위반이 위 race catch에 같이 잡혀RACE_SKIPPED로 묻히는 약점이 있어서 EntityManager 제거(Spring Data JPA 코멘트 반영) 정리할 때 같이 정리하겠습니다

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spring Data JPA를 사용하지 않은 이유가 궁금합니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

적용해서 반영하겠습니다

@AuthUser User user,
@PathVariable Long fairytaleId
) {
List<WordEntry> entries = vocabService.getVocab(user.getId(), fairytaleId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분이 프론트엔드와 직접적으로 통신하는 컨트롤러 클래스로 확인됩니다. 하지만, List와 같이, Entity 클래스 자체를 응답으로 전달할 시, 순환 참조 문제가 발생할 수 있습니다. 이 부분 체크되었나요?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

응답 자체는 컨트롤러에서 WordEntryRes.listOf(entries)로 DTO 변환해서 내려가고 있어서, 클라이언트로 WordEntry 엔티티가 직접 직렬화되지는 않습니다. 또 WordEntry, Fairytale, User, Paragraph 모두 단방향 @manytoone 관계만 있고 양방향 매핑이 없어서, 지금 시점에 무한 루프 형태의 순환 참조가 즉시 발생하지는 않는 상태로 보여요
다만 서비스가 List를 반환하는 구조 자체는 약점이라는 지적에 동의합니다 WordEntry.fairytale이 LAZY 프록시라 트랜잭션 밖에서 직렬화하면 LazyInitializationException 위험이 있고 나중에 Fairytale에 역참조 컬렉션이 추가되는 순간 바로 순환 참조가 터질 수 있어서요
VocabService 반환 타입을 List로 바꿔서 엔티티가 서비스 밖으로 안 나가도록 정리하겠습니다.

@NotNull
private Long fairytaleId;

@NotNull

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

페이지 수여서 @min설정하면 좋을 것 같습니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정하겠습니다

LgE02
LgE02 previously requested changes May 7, 2026
zzuhannn added 4 commits May 10, 2026 02:44
캡스톤 규모에 carrier-agnostic 강제는 과한 안전장치라 판단,
다른 도메인 컨벤션(web.dto에 응답 DTO 배치)과 일관성 확보 위해 제거.
- processSentences: getReference → fairytaleRepository.findById().orElseThrow()
- getVocab: entityManager.find → fairytaleRepository.findById().orElseThrow()
- getVocab 반환을 WordEntryRes 리스트로 변환해서 엔티티가 서비스 밖으로 새지 않도록 수정
서비스 계층이 엔티티를 외부로 노출하지 않도록 정리.
컨트롤러에서 별도 변환 호출 제거하고 carrier-agnostic 주석도 정리.
페이지 번호는 1-base이므로 0 이하 값을 거절.
@zzuhannn zzuhannn changed the base branch from chore/#29 to develop May 10, 2026 05:05
@zzuhannn zzuhannn merged commit 67b3f19 into develop May 10, 2026
@zzuhannn zzuhannn deleted the feat/#31 branch May 14, 2026 08:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants