Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4602f09
chore: ArchUnit 의존성 추가
zzuhannn Apr 30, 2026
78384b7
feat: WordEntry 엔티티 추가
zzuhannn Apr 30, 2026
9532216
feat: WordEntryRepository 추가
zzuhannn Apr 30, 2026
da8b575
feat: VocabErrorCode 및 예외 클래스 추가
zzuhannn Apr 30, 2026
89baf44
feat: VocabExtractionResult 서비스 DTO 추가
zzuhannn Apr 30, 2026
84c0e66
refactor: ParagraphRepository에 findByFairytaleIdAndPage 추가
zzuhannn Apr 30, 2026
8aae78c
refactor: RestTemplateConfig에 vocabRestTemplate 빈 추가
zzuhannn Apr 30, 2026
719be99
feat: VocabExtractClient 추가 (FastAPI /vocab/extract 호출)
zzuhannn Apr 30, 2026
c940070
feat: VocabService 인터페이스 및 구현체 추가
zzuhannn Apr 30, 2026
1ffe403
feat: VocabController 추가 (단어장 조회 API)
zzuhannn Apr 30, 2026
b5f0559
feat: InternalApiSecurityConfig 추가 (dev 프로필 한정)
zzuhannn Apr 30, 2026
a5d5477
feat: InternalVocabController 추가 (dev 프로필 시연용 API)
zzuhannn Apr 30, 2026
8fd9fa3
test: VocabServiceArchTest 추가 (carrier-agnostic 검증)
zzuhannn Apr 30, 2026
f3b3a8a
chore: application-dev.properties 추가 (dev 프로필 안내 주석)
zzuhannn Apr 30, 2026
589813f
chore: ArchUnit 의존성 및 VocabServiceArchTest 제거
zzuhannn May 9, 2026
2fbfce0
refactor: VocabServiceImpl에서 EntityManager 제거하고 FairytaleRepository로 통일
zzuhannn May 9, 2026
4c3f547
refactor: VocabService.getVocab 반환 타입을 List<WordEntryRes>로 변경
zzuhannn May 9, 2026
75d504d
feat: InternalVocabProcessReq.pageNo에 @Min(1) 검증 추가
zzuhannn May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@
public interface ParagraphRepository extends JpaRepository<Paragraph, Long> {

List<Paragraph> findByFairytaleIdOrderByPageAsc(Long fairytaleId);

/** 특정 페이지의 문장들 조회 — 단어장 추출 시 페이지 단위 본문 로드 */
List<Paragraph> findByFairytaleIdAndPage(Long fairytaleId, int page);
}
Original file line number Diff line number Diff line change
@@ -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.*;

/**
* 동화 페이지에서 추출된 어려운 단어 항목.
*
* <p>중복 정책: <b>first-occurrence-wins</b> — 같은 동화 안에서 같은 단어가
* 여러 페이지에 등장해도 최초로 추출된 페이지 1개 row만 저장한다.
* UNIQUE(fairytale_id, word)로 강제하며, race condition은
* {@code DataIntegrityViolationException} catch로 처리한다.</p>
*/
@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;
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<WordEntry, Long> {

/** 같은 동화에 같은 단어가 이미 등록되어 있는지 — first-occurrence-wins pre-check */
boolean existsByFairytaleIdAndWord(Long fairytaleId, String word);

/** 본인 동화 누적 단어장 조회 — 페이지 순서로 정렬 */
List<WordEntry> findByFairytaleIdOrderByPageNoAsc(Long fairytaleId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.capstone.kkumteul.domain.vocab.service;

import com.capstone.kkumteul.domain.vocab.service.dto.VocabExtractionResult;
import com.capstone.kkumteul.domain.vocab.web.dto.WordEntryRes;

import java.util.List;

/**
* 단어장 추출/조회 서비스.
*/
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<String> sentences);

/**
* 본인 동화의 누적 단어장 조회. 페이지 번호 오름차순.
*
* @param userId 요청자 (소유권 검증용)
* @param fairytaleId 동화 ID
*/
List<WordEntryRes> getVocab(Long userId, Long 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.

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.

적용해서 반영하겠습니다

Original file line number Diff line number Diff line change
@@ -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.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 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 FairytaleRepository fairytaleRepository;

/**
* 페이지 3문장 → LLM으로 단어 추출 → 풀이 생성 → DB 저장.
*
* <p>처리 흐름:</p>
* <ol>
* <li>FastAPI 호출 → 단어/풀이 1회에 추출</li>
* <li>응답 비어있으면 NO_DIFFICULT_WORD</li>
* <li>실패하면 EXTRACTION_FAILED (예외 전파 X, fail-open)</li>
* <li>이미 단어장에 있으면 DUPLICATE (first-occurrence-wins)</li>
* <li>저장 시도 → race condition으로 UNIQUE 위반이면 RACE_SKIPPED</li>
* <li>정상 저장이면 SAVED</li>
* </ol>
*/
@Override
@Transactional
public VocabExtractionResult processSentences(Long fairytaleId, int pageNo, List<String> sentences) {
Optional<VocabExtractResponse> 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 = 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);
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.raceSkipped();
}
}

/**
* 본인 동화 누적 단어장 조회.
* 동화 소유권 검증 후 페이지 순서로 반환.
*/
@Override
public List<WordEntryRes> 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 WordEntryRes.listOf(wordEntryRepository.findByFairytaleIdOrderByPageNoAsc(fairytaleId));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.capstone.kkumteul.domain.vocab.service.dto;

import com.capstone.kkumteul.domain.vocab.entity.WordEntry;

/**
* VocabService 처리 결과를 나타내는 service-layer 값 객체.
*
* <p>web/dto, kafka/message 패키지에 의존하지 않는다.
* Controller나 KafkaListener는 이 결과를 자기 layer DTO로 변환해 사용한다.</p>
*
* @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);
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>운영 환경에서는 {@link InternalApiSecurityConfig}와 함께 등록되지 않으므로 노출되지 않는다.</p>
*/
@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<SuccessResponse<ProcessRes>> process(
@Valid @RequestBody InternalVocabProcessReq req
) {
List<Paragraph> paragraphs = paragraphRepository.findByFairytaleIdAndPage(
req.getFairytaleId(), req.getPageNo()
);
if (paragraphs.isEmpty()) {
throw new ParagraphNotFoundForVocabException();
}
List<String> 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) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.capstone.kkumteul.domain.vocab.web.controller;

import com.capstone.kkumteul.domain.user.entity.User;
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<SuccessResponse<List<WordEntryRes>>> getVocab(
@AuthUser User user,
@PathVariable Long fairytaleId
) {
List<WordEntryRes> entries = vocabService.getVocab(user.getId(), fairytaleId);
return ResponseEntity.status(HttpStatus.OK)
.body(SuccessResponse.ok(entries));
}
}
Loading