Conversation
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 — 인증된 본인 동화만 조회 가능
POST /internal/vocab/process - 카프카 없이 단어장 추출 비즈니스 로직 단독 호출 가능 - @Profile("dev")로 운영 환경엔 미등록 - @PostConstruct에서 등록 사실 INFO 로그로 노출
vocab.service 패키지가 web/message/kafka/servlet에 의존하지 않음을 ArchUnit으로 빌드 시점에 자동 검증. Phase 2 카프카 어댑터 추가 시에도 service 변경 없음을 강제 보장.
dev 프로필 활성화 시 동작과 운영 환경에서 활성화 금지 안내.
Joonseok-Lee
left a comment
There was a problem hiding this comment.
궁금한 부분에 대해 리뷰 작성했습니다. 검토 부탁드립니다.
| WordEntry saved = wordEntryRepository.save(entry); | ||
| return VocabExtractionResult.saved(saved); | ||
| } catch (DataIntegrityViolationException e) { | ||
| log.info("vocab race condition fairytaleId={}, word={}", fairytaleId, word); |
There was a problem hiding this comment.
특정 사용자가 단어장을 생성하고 있을 때, 해당 동화의 단어장 생성 자체를 locking 해서 다른 사용자가 단어장을 생성하지 못하도록 막는걸까요?
- 동화 생성 단계에서 3문장 생성 시, 삽화와 단어장 생성 요청
- 3페이지 생성됐을 때부터 생성시도자가 동화 읽기 가능
위 로직으로 생성하게 됩니다. 경쟁상태라고 표현한 이유가 궁금합니다.
There was a problem hiding this comment.
저는 한 동화에서 단어 저장 요청이 동시에 오게되었을때를 대비한 catch문이라고 이해했습니다.
만약에 kafak가 토픽/파티션을 여러개 해서 단어추출 과정을 동시에 돌리게 된다면 해당 try_catch가 필요할 것 같은데
저희가 토픽/파티션 하나를 이용해서 받게된다면 순차적으로 저장될 것 같아서 dead code가 될 것 같다는 생각이 듭니다.
There was a problem hiding this comment.
같은 동화에서 같은 단어가 여러 페이지에 등장하는 경우(예: "용궁"이 페이지1, 페이지2에 둘 다 나옴)를 가정한 것 입니다. 두 페이지의 단어 추출 처리가 동시에 들어오면 둘 다 existsBy 체크는 통과하는데 INSERT 시점에 UNIQUE(fairytale_id, word) 제약 때문에 한쪽이 위반이 나기때문에, 그걸 catch로 받아 RACE_SKIPPED 처리한 안전망(?)으로 작성한 것입니다 즉 다른 사용자를 막는 락이 아닌 위의 예를 말한 것 입니디 경쟁상태라고 표현한 이유는 두 트랜잭션이 같은 값을 insert 했을 때 경쟁하는 상황을 말한 것입니다.
| return VocabExtractionResult.duplicate(); | ||
| } | ||
|
|
||
| Fairytale fairytale = entityManager.getReference(Fairytale.class, fairytaleId); |
There was a problem hiding this comment.
이 부분은 동화 테이블의 기초 데이터들(캐릭터 타입, 배경, 교훈, ...)을 미리 저장하는 것을 전제로 두고 개발한걸까요? ID가 없다면, save() 시, NotFoundException이 발생할 수 있습니다.
답글로 어떤 방식으로 진행할지 적어주세요!
There was a problem hiding this comment.
저와 유사한게 생각한건지는 모르겠지만
저는 사용자가 캐릭터 타입/배경/교훈을 선택해서 요청을 날리면 db에 저장후 동화 생성으로 넘어간다고 생각했습니다.
There was a problem hiding this comment.
저와 유사한게 생각한건지는 모르겠지만 저는 사용자가 캐릭터 타입/배경/교훈을 선택해서 요청을 날리면 db에 저장후 동화 생성으로 넘어간다고 생각했습니다.
작성 시 Spring Server 쪽에 Not Null 제약 조건을 작성했다고 생각했는데, 현재 구조상 우선 DB에 캐릭터를 저장하고 시작하는 로직으로 FastAPI 서비스 로직이 구현되어있습니다.
There was a problem hiding this comment.
fairytaleId는 동화가 먼저 저장된 뒤에 단어장 처리가 호출되는 전제로 작성했고, getReference는 SELECT 한 번 줄이려는 최적화였습니다 다만 지적한대로 그 가정이 깨질 경우 save() 시점 FK 위반이 위 race catch에 같이 잡혀RACE_SKIPPED로 묻히는 약점이 있어서 EntityManager 제거(Spring Data JPA 코멘트 반영) 정리할 때 같이 정리하겠습니다
There was a problem hiding this comment.
Spring Data JPA를 사용하지 않은 이유가 궁금합니다.
| @AuthUser User user, | ||
| @PathVariable Long fairytaleId | ||
| ) { | ||
| List<WordEntry> entries = vocabService.getVocab(user.getId(), fairytaleId); |
There was a problem hiding this comment.
해당 부분이 프론트엔드와 직접적으로 통신하는 컨트롤러 클래스로 확인됩니다. 하지만, List와 같이, Entity 클래스 자체를 응답으로 전달할 시, 순환 참조 문제가 발생할 수 있습니다. 이 부분 체크되었나요?
There was a problem hiding this comment.
응답 자체는 컨트롤러에서 WordEntryRes.listOf(entries)로 DTO 변환해서 내려가고 있어서, 클라이언트로 WordEntry 엔티티가 직접 직렬화되지는 않습니다. 또 WordEntry, Fairytale, User, Paragraph 모두 단방향 @manytoone 관계만 있고 양방향 매핑이 없어서, 지금 시점에 무한 루프 형태의 순환 참조가 즉시 발생하지는 않는 상태로 보여요
다만 서비스가 List를 반환하는 구조 자체는 약점이라는 지적에 동의합니다 WordEntry.fairytale이 LAZY 프록시라 트랜잭션 밖에서 직렬화하면 LazyInitializationException 위험이 있고 나중에 Fairytale에 역참조 컬렉션이 추가되는 순간 바로 순환 참조가 터질 수 있어서요
VocabService 반환 타입을 List로 바꿔서 엔티티가 서비스 밖으로 안 나가도록 정리하겠습니다.
| @NotNull | ||
| private Long fairytaleId; | ||
|
|
||
| @NotNull |
캡스톤 규모에 carrier-agnostic 강제는 과한 안전장치라 판단, 다른 도메인 컨벤션(web.dto에 응답 DTO 배치)과 일관성 확보 위해 제거.
- processSentences: getReference → fairytaleRepository.findById().orElseThrow() - getVocab: entityManager.find → fairytaleRepository.findById().orElseThrow() - getVocab 반환을 WordEntryRes 리스트로 변환해서 엔티티가 서비스 밖으로 새지 않도록 수정
서비스 계층이 엔티티를 외부로 노출하지 않도록 정리. 컨트롤러에서 별도 변환 호출 제거하고 carrier-agnostic 주석도 정리.
페이지 번호는 1-base이므로 0 이하 값을 거절.
🔎 관련 이슈
📋 작업 내용
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,@PostConstructwarmup)VocabExtractRequest/ResponseDTOvocabRestTemplate빈 (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메서드 추가 (페이지 단위 조회)RestTemplateConfig에vocabRestTemplate빈 추가테스트
VocabServiceArchTest— vocab.service 패키지가 web/message/kafka/servlet에 의존하지 않음을 ArchUnit으로 자동 검증💡 알림
chore/#29(JpaAuditingConfig 추가). PR feat: JpaAuditingConfig 추가 #30 머지되면 자동으로develop베이스로 변경됩니다.POST /vocab/extract엔드포인트 추가 PR이 별도로 필요합니다.SPRING_PROFILES_ACTIVE=dev로 부팅 후POST /internal/vocab/process(인증 불필요).✅ 체크리스트
/vocab/extract엔드포인트 머지 후 통합 테스트POST /internal/vocab/process단독 동작 확인/internal/**401 반환 확인