카테고리 없음

금융 리터러시 교육 서비스 구현 : 경제용어 사전 크롤러(python) + 게이미피케이션 퀴즈 시스템(spring)

갬짱 2025. 11. 27. 21:25

 

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지선다 퀴즈 결과 반환]