FinanceDoc MSA에는 “금융 리터러시 교육(Edu-Service)”이라는 중요한 기능이 있습니다.
사용자가 금융을 어렵게 느끼지 않고, 쉽게 접근할 수 있도록 금융 용어 카드뉴스, 4지선다 퀴즈, 랜덤 학습 경험을 제공하는 모듈입니다.
이 서비스를 구현하기 위한 일련의 전체 흐름을 기록합니다
크롤링 서버(python) 구축 → Kubernetes Job 실행 → PostgreSQL 적재(pv)→ 랜덤 카드뉴스 API(spring), 퀴즈 생성 API(spring)
1. Edu-Service의 목표와 기능 구성
Edu-Service는 크게 두 부분으로 나눕니다.
기능설명기술 포인트
| 기능 | 설명 | 기술포인트 |
| ① 금융용어 사전(카드뉴스) | 한국은행·KDI 금융/경제 사전 데이터 자동 수집 → DB 저장 → 프론트에서 랜덤 제공 | Python 크롤러 + PostgreSQL 적재 + K8s Job |
| ② 금융 퀴즈 모드(게이미피케이션) | 금융용어 기반 4지선다 객관식 문제 자동 생성 | 랜덤 키워드 조합, 정답 마스킹, 점수 계산 |
두 기능은 “금융 리터러시 교육”이라는 공통 목적을 가지며, MSA에서 edu-service라는 독립 서비스를 통해 제공됩니다.
📌 크롤링 서버 설계 고민: Spring API로 만들까, Python Job으로 갈까?
Edu-Service를 설계하면서 가장 먼저 마주한 질문은 이것이었다.
“금융용어 크롤링 서버를 어떻게 구현할까?”
Spring 내부 API로 처리할까? 아니면 별도 크롤링 서버를 만들까?
초기에는 간단하게 Spring Boot 안에 /admin/crawl 같은 관리용 API를 만들어두고,
필요할 때마다 호출해서 크롤링을 수행하는 구조를 생각했다.
하지만 실제로 검토해 보니 여러 문제가 있었다.
(1) Spring(Java) 기반 크롤링의 비효율성
웹 크롤링 작업은 CPU 사용량이 높고, I/O도 많고, 스레드도 오래 붙잡는다.
즉 “빠르게 응답해야 하는 API 서버”와는 특성이 정반대다.
Spring에서 크롤링을 돌리면:
- JVM이 불필요하게 무거움
- 메모리 사용량 증가 → 이미 단일 노드인데 OOMKilled 위험 존재
- 크롤링 실패 시 API 서버에도 영향을 주게 됨
- 크롤링 시간이 길어지면 API가 블로킹됨
즉, “API 서버에서 크롤링까지 책임지는 구조는 MSA 철학에도 안 맞고, 안정성도 떨어짐” 이라는 결론을 얻었다.
(2) 쿠버네티스 단일 노드 환경에서는 더더욱 위험하다!
공모전 환경 때문에 NKS 단일 노드 클러스터를 사용하고 있었다.
- API 서비스 + Gateway + LLM 서비스 + DB Pod가 다 한 노드에 떠 있고
- 메모리/CPU 압박이 매우 큰 상황
여기서 지속적인 크롤링 작업을 돌린다면?
→ 가장 먼저 OOMKilled(메모리 초과) 뜨는 건 API 서버
→ 실제로 테스트 중 여러 차례 OOMKilled를 경험함
즉, 크롤링은 절대 “상시 서비스”로 둘 수 없다는 판단이 명확해졌다.
(3) 크롤링에는 Python이 훨씬 적합
웹 크롤링은 Python이 사실상 표준이다.
- requests / BeautifulSoup / Playwright 최적화
- JSON/HTML 파싱이 훨씬 간편
- 의존성 설치/관리 용이
- 실행 프로세스도 가벼움
반면 Java/Spring에서:
- HTML 파싱: Jsoup 정도만 가능
- JSON/HTML 섞여 있으면 코드가 복잡해짐
- JVM 부하 큼
그래서 최종적으로는 “크롤링만큼은 Python으로 만들자” 라고 결정했다.
(4) 최종 결론: Python 크롤링 서버 + Kubernetes Job 구조
[Python 크롤링 서버 (Docker)]
↓ (단발 실행)
[Kubernetes Job 실행]
↓
[PostgreSQL 저장]
↓
[Spring edu-service(API)] → 카드뉴스/퀴즈 제공
✔ API 서버와 크롤링 작업이 완전히 분리 (격리성 ↑)
크롤링 실패해도 API 서버 CPU/메모리를 건드리지 않음.
✔ Job은 “한 번 실행 후 종료”하므로 리소스 낭비 없음
Running 상태가 유지되지 않음.
✔ Spring은 오로지 API 제공 역할만 담당
카드뉴스 랜덤 제공 & 4지선다 퀴즈 생성 → 빠르고 안정적인 사용자 경험 가능
✔ Python으로 크롤링 전략을 자유롭게 구성 가능
JSON 요청(BOK), HTML 파싱(KDI), Playwright 헤드리스 크롤링 → 모두 대응 가능
2. 금융용어 사전 크롤링 — HTML/JSON 구조 차이 해결
크롤링 대상은 두 곳입니다. 두 사이트의 구조가 완전히 달라서, 각각 다른 전략을 세워야 했습니다.
1. 한국은행 경제용어사전(BOK) https://www.bok.or.kr/portal/ecEdu/ecWordDicary/search.do?menuNo=200688
| 경제용어사전 | 경제교육 | 대국민 서비스 | 한국은행 홈페이지
※ 왼쪽 경제용어를 선택해 주세요.
www.bok.or.kr
API응답이 JSON형태로 "searchCont.json?ecWordSn={id}" 형식으로 호출
https://www.bok.or.kr/portal/ecEdu/ecWordDicary/searchCont.json?ecWordSn=1

파싱전략
- requests.get().json()
- ecWordNm, ecWordCn 추출
- HTML이 섞여있으므로 정규식/HTML 엔티티 정리 후 저장
2. KDI 경제교육정보센터 경제용어사전 https://eiec.kdi.re.kr/material/wordDic.do
시사용어사전 | KDI 경제교육·정보센터
다양하고 차별화된 경제교육 콘텐츠를 제공합니다.
eiec.kdi.re.kr
HTML페이지로 응답 → BeautifulSoup으로 파싱해야 함
https://eiec.kdi.re.kr/material/wordDicDetail.do?dic_idx=277


파싱 전략:
- BeautifulSoup으로 HTML 파싱
- <dt> → 용어명(term)
- <dd> → 설명(description)
- HTML 태그 제거 및 공백 정리
- JS 로딩 없이도 HTML만으로 파싱 가능
| 항목 | BOK(한국은행) | KDI(경제용어사전) |
| 응답 형태 | JSON | HTML |
| 파싱 방식 | res.json() | BeautifulSoup 필요 |
| 추가 헤더 | 거의 없음 | User-Agent 필요 |
➡ JSON + HTML 크롤링 로직을 공존 가능하도록 분리 설계
3. 크롤링 서버 아키텍처 — “상시 서비스가 아닌 일시적 작업(Job)”로 구성
크롤러는 상시로 돌아가야 하는 서버가 아닙니다.
- CPU를 많이 먹고
- 외부 사이트 수십~수백 회 요청하고
- 다 끝나면 종료되는 작업
그래서 Kubernetes Deployment로 상시 운영하면 오히려 리소스 낭비 발생
→ 최적 방식은 Kubernetes Job
전체 구조
[Python 크롤러] → [K8s Job으로 실행] → [PostgreSQL 저장] → [edu-service 랜덤 카드뉴스 제공]
- Job은 한 번만 실행되고 바로 끝나므로 리소스 누수 없음
- 실패 시 자동 retry
- Docker 이미지 교체만으로 크롤러 버전 관리 가능
- Edu 서비스와 완전히 분리됨 → 확장성 상승
4. Dockerfile & Job 적용
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "crawler.py"]
이미지 빌드 및 푸시
docker build --platform linux/amd64 -t crawler-service .
docker push financedoc.kr.ncr.ntruss.com/crawler-service:latest
K8s Job 적용
apiVersion: batch/v1
kind: Job
metadata:
name: findoc-crawler-job
spec:
template:
spec:
containers:
- name: crawler-service
image: financedoc.kr.ncr.ntruss.com/crawler-service:latest
env:
- name: DB_HOST
value: edu-db.db.svc.cluster.local
- name: DB_NAME
value: edudb
- name: DB_USER
value: user
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: edu-db-secret
key: EDU_DATASOURCE_PASSWORD
restartPolicy: Never
backoffLimit: 4
restartPolicy: Job이 끝난후 Pod를 재시작하지 않도록 Never로 설정
실행
kubectl apply -f job.yaml
kubectl logs -l job-name=findoc-crawler-job

5. PostgreSQL Insert — psycopg2 활용
(1) 사전에 스프링 엔티티 정의를 통해 keyword 테이블 생성

(2) DB환경변수 컨테이너에 매핑
env:
- name: DB_NAME
value: edudb
- name: DB_USER
value: user
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: edu-db-secret
key: EDU_DATASOURCE_PASSWORD
- name: DB_HOST
value: edu-db.db.svc.cluster.local
(3) psycopg2 패키지를 통해 PostgreSQL Insert
pip install psycopg2-binary
def save_to_db(data: list):
conn = psycopg2.connect(
dbname=os.getenv("DB_NAME"), # 환경 변수에서 DB 정보 가져오기
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
host=os.getenv("DB_HOST")
)
cursor = conn.cursor()
insert_query = """
INSERT INTO keyword (term, description, source, link)
VALUES (%s, %s, %s, %s)
"""
cursor.executemany(insert_query, data) # 여러 데이터를 한번에 저장
conn.commit()
cursor.close()
conn.close()
- psycopg2.connect를 통해 DB연결 생성(conn)
- cursor객체를 생성해서 테이터 튜플의 한단위마다 INSERT작업을 수행
- conn.commit()을 통해 DB에 변경 사항 반영
- cursor.close()와 conn.close()로 연결 종료

6. Edu-Service: 랜덤 카드뉴스 API
@Repository
public interface KeywordRepository extends JpaRepository<Keyword, Long> {
// PostgreSQL용 랜덤 쿼리
@Query(value = "SELECT * FROM keyword ORDER BY RANDOM() LIMIT 1", nativeQuery = true)
Keyword findRandomKeyword();
}
PostgreSQL의 ORDER BY RANDOM()을 사용하면 손쉽게 랜덤 데이터를 가져옴 ( ↔ MySQL은 RAND()을 사용 )
LIMIT 1 덕분에 항상 하나의 랜덤 키워드만 반환
@Repository
public interface KeywordRepository extends JpaRepository<Keyword, Long> {
@Query(value = "SELECT * FROM keyword ORDER BY RANDOM() LIMIT 1", nativeQuery = true)
Keyword findyRandomKeyword();
@Query(value = "SELECT * FROM keyword WHERE keyword_id <> :keywordId ORDER BY RANDOM() LIMIT 1",
nativeQuery = true)
Keyword findyRandomKeywordExcept(@Param("keywordId") Long keywordId);
}
현재키워드는 제외할 수 있도록 두가지로 처리
public KeywordResponse getRandomKeyword(Long keywordId){
Keyword keyword = (keywordId == null)
? keywordRepository.findyRandomKeyword()
: keywordRepository.findyRandomKeywordExcept(keywordId);
return KeywordResponse.builder()
.keywordId(keyword.getKeywordId())
.keywordName(keyword.getTerm())
.keywordDesc(keyword.getDescription())
.keywordSrc(keyword.getSource())
.keywordLink(keyword.getLink())
.build();
}
@GetMapping("/keyword/random")
public ResponseEntity<?> getRandomKeyword( @RequestParam(required = false) Long currentKeywordId ){
KeywordResponse randomKeyword = keywordService.getRandomKeyword(currentKeywordId);
return ResponseEntity.ok(randomKeyword);
}
프론트에서는:
- 매 호출마다 새로운 금융용어 카드 랜덤 제공
- “넘겨보기” 버튼 누를 때마다 새 용어 반환
7. Edu-Service: 랜덤 단어퀴즈 API
사지선다 문제 총 5개 생성 ( 구조 : question + answer + choices )
- question : 설명 중 일부가 빈칸으로 표시된 문장
"금융시장에서 자금의 수요와 공급에 의해 결정되는 ( )를 말한다." - answer : 정답용어
"시장금리" - choices : 정답 + 다른 랜덤키워드 3개
"시장금리" "가계수지" "저축률" "이자"
[Keyword 테이블]
↓ 랜덤 하나 선택 (정답)
[description 일부 마스킹 → question 생성]
↓
[RANDOM()로 오답 3개 선택]
↓
[정답 + 오답 조합 → 순서 랜덤 섞기]
↓
[4지선다 퀴즈 결과 반환]