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<Paragraph, Long> {
 
     List<Paragraph> findByFairytaleIdOrderByPageAsc(Long fairytaleId);
+
+    /** 특정 페이지의 문장들 조회 — 단어장 추출 시 페이지 단위 본문 로드 */
+    List<Paragraph> findByFairytaleIdAndPage(Long fairytaleId, int page);
 }
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.*;
+
+/**
+ * 동화 페이지에서 추출된 어려운 단어 항목.
+ *
+ * <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;
+}
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);
+    }
+}
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<WordEntry, Long> {
+
+    /** 같은 동화에 같은 단어가 이미 등록되어 있는지 — first-occurrence-wins pre-check */
+    boolean existsByFairytaleIdAndWord(Long fairytaleId, String word);
+
+    /** 본인 동화 누적 단어장 조회 — 페이지 순서로 정렬 */
+    List<WordEntry> findByFairytaleIdOrderByPageNoAsc(Long fairytaleId);
+}
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..3c56239
--- /dev/null
+++ b/src/main/java/com/capstone/kkumteul/domain/vocab/service/VocabService.java
@@ -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);
+}
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..0411f2e
--- /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.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);
+            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));
+    }
+}
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 값 객체.
+ *
+ * <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);
+    }
+}
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.
+ *
+ * <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) {
+    }
+}
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..c007ed4
--- /dev/null
+++ b/src/main/java/com/capstone/kkumteul/domain/vocab/web/controller/VocabController.java
@@ -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));
+    }
+}
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..be37c8e
--- /dev/null
+++ b/src/main/java/com/capstone/kkumteul/domain/vocab/web/dto/InternalVocabProcessReq.java
@@ -0,0 +1,16 @@
+package com.capstone.kkumteul.domain.vocab.web.dto;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import lombok.Getter;
+
+@Getter
+public class InternalVocabProcessReq {
+
+    @NotNull
+    private Long fairytaleId;
+
+    @NotNull
+    @Min(1)
+    private Integer pageNo;
+}
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<WordEntryRes> listOf(List<WordEntry> entries) {
+        return entries.stream().map(WordEntryRes::from).toList();
+    }
+}
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 서버)의 단어장 추출 엔드포인트 호출 클라이언트.
+ *
+ * <p>POST {fastApiBaseUrl}/vocab/extract 호출 → {word, meaning} JSON 응답.
+ * timeout/5xx에 한해 1회 retry, 4xx는 즉시 실패.
+ * 호출자에 예외를 전파하지 않고 {@code Optional.empty()}로 fail-open.</p>
+ */
+@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<VocabExtractResponse> extract(List<String> 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<String> 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;
+}
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.
+ *
+ * <p>운영(prod) 프로필에선 이 빈이 등록되지 않으므로
+ * 기존 {@link SecurityConfig}의 {@code .anyRequest().authenticated()}가 그대로 적용된다.</p>
+ *
+ * <p>{@link com.capstone.kkumteul.global.jwt.JwtTokenFilter}는 {@code @Component}로
+ * 모든 요청에 적용되지만, 토큰이 없으면 SecurityContext에 인증 객체를 설정하지 않으므로
+ * permitAll된 {@code /internal/**}은 정상 통과된다.</p>
+ */
+@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();
+    }
+}
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();
+    }
 }
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 노출
+#
+# 운영 환경에서는 이 프로필을 활성화하지 말 것.
