오늘은 강사 jeff의 마지막 날이자 미니 프로젝트의 최종장이었다.
오늘의 velog는 프로젝트를 기억하는 블로그로 하도록 하자.
먼저 저희가 진행한 프로젝트의 github 주소입니다.
https://github.com/no-easy-days/aws13th-team2-news-scraping.git
저희가 진행한 프로젝트는 테크 과학 뉴스 검색 서비스이다.
여기서 내가 맡은 부분은 검색 기능 구현이다.
1. 검색 유틸리티 (utils/search_utils.py)
normalize(text: str) -> str:
- 목적: 텍스트를 소문자로 변환하고 공백을 표준화하여 비교 준비를 합니다.
- 구현: 입력 text를 소문자로 변환하고, 정규 표현식을 사용하여 여러 공백 문자를 단일 공백으로 바꾸고 앞뒤 공백을 제거한다.
make_ngram(text: str, n=2):
- 목적: 주어진 텍스트에서 n-gram(n개 문자의 시퀀스)을 생성합니다. 이는 유사도 비교를 위해 텍스트를 나타내는 데 사용됩니다. 기본 n은 2(빅그램)이다.
- 구현: 먼저 normalize 함수를 사용하여 입력 text를 정규화한다. 그런 다음 정규화된 텍스트를 반복하며 길이 n의 부분 문자열 목록을 만듭니다. 예를 들어, n=2일 때 "apple"은 ['ap', 'pp', 'pl','le']가 된다.
get_similarity(query: str, target: str):
- 목적: n-gram을 사용하여 검색 query와 target 문자열(예: 기사 제목) 간의 자카드 유사도를 계산한다.
여기서 자카드 유사도란?
두 집합 사이의 유사도를 측정하는 지표로, 두 집합의 교집합 크기를 합집합 크기로 나눈 값 입니다. 0과 1 사이의 값을 가지며, 1에 가까울수록 두 집합이 매우 유사함을 의미합니다.
- 구현: make_ngram을 사용하여 query와 target 모두에 대한 n-gram을 생성합니다. 이 n-gram은 각각 세트(search_bar 및 title)로 변환됩니다. 둘 중 하나의 세트가 비어 있으면 0.0을 반환한다.(유사도 없음). 두 세트의 교집합(intersection = search_bar & title)을 계산하여 공통 n-gram을 나타냅니다. 두 세트의 합집합(union = search_bar | title)을 계산하여 두 텍스트의 모든 고유 n-gram을 나타낸다.
- 자카드 유사도는 len(intersection) / len(union)으로 계산된다. 이 비율은 공유된 n-gram을 기반으로 두 텍스트가 얼마나 유사한지를 0에서 1 사이의 점수로 나타냅니다.
2. 기사 검색 엔드포인트 (routers/articles.py)
@router.get("/articles"):
- 목적: 기사 검색 요청을 처리하는 GET 엔드포인트 /articles를 정의합니다.
- 의존성:
- keyword: str = Query(..., description="검색 키워드"): 클라이언트로부터 keyword 쿼리 매개변수를 예상합니다.
- db: Session = Depends(get_db): 데이터베이스와 상호 작용하기 위한 데이터베이스 세션을 주입합니다.
- 입력 유효성 검사:
- keyword에서 공백을 제거합니다 (clean_keyword).
- clean_keyword가 비어 있으면 400 Bad Request 오류를 반환합니다.
- clean_keyword가 2자 미만이면 400 Bad Request 오류를 반환합니다.
- 기사 검색:
- 데이터베이스에서 모든 기사를 검색합니다 (db.query(Article).all()).
- 유사도 계산 및 필터링:
- 검색된 모든 db_articles를 반복합니다.
- 각 article에 대해 get_similarity(keyword, article.title)를 호출하여 score를 계산합니다. 이는 검색 키워드와 기사의 제목을 비교합니다.
- score가 0.01 이상이면 기사의 세부 정보(ID, 제목, URL, 설명, 게시일, 썸네일 URL, 생성일)가 계산된 data_score와 함께 results 목록에 추가됩니다. data_score는 소수점 둘째 자리까지 반올림됩니다.
- 정렬:
- results 목록은 data_score를 기준으로 내림차순으로 정렬되어 가장 관련성이 높은 기사가 먼저 나타납니다.
- 응답 처리:
- 필터링 후 results가 비어 있으면 결과가 없음을 나타내는 메시지와 함께 NOT_FOUND 상태를 반환합니다.
- 그렇지 않으면 data(정렬된 기사 목록)와 찾은 기사의 total_count를 포함하는 success 상태를 반환합니다.
요약
- 사용자가 검색 keyword를 /articles 엔드포인트로 보냅니다.
- 엔드포인트는 키워드를 유효성 검사합니다(비어 있지 않고 최소 2자여야 함).
- 데이터베이스에서 모든 기사를 검색합니다.
- 각 기사에 대해 빅그램 기반 자카드 유사도 알고리즘을 사용하여 사용자 keyword와 기사 title 간의 유사도 점수를 계산합니다.
- 유사도 점수가 0.01 이상인 기사는 일치하는 것으로 간주됩니다.
- 일치하는 기사는 유사도 점수 내림차순으로 정렬됩니다.
데이터베이스 연결 (database.py)
database.py 파일은 SQLAlchemy를 사용하여 데이터베이스 연결을 설정하는 역할을 합니다.
- 설정 로딩:
- from settings import settings: settings.py에서 settings를 가져옵니다. 이 settings 객체는 DATABASE_URL 및 기타 구성 매개변수를 포함할 것으로 예상됩니다. 이는 구성을 중앙 집중화하고 다른
환경(예: 개발, 프로덕션)을 쉽게 관리할 수 있도록 합니다.
- DATABASE_URL = settings.database_url: 로드된 설정에서 데이터베이스 연결 문자열을 검색합니다. 이 문자열에는 일반적으로 데이터베이스 유형, 자격 증명, 호스트, 포트 및 데이터베이스 이름이
포함됩니다(예: SQLite의 경우 sqlite:///./sql_app.db, PostgreSQL의 경우 postgresql://user:password@host:port/dbname).
- 엔진 생성:
- engine = create_engine(DATABASE_URL): 이 줄은 SQLAlchemy Engine 인스턴스를 생성합니다. Engine은 모든 SQLAlchemy 애플리케이션의 시작점이며, 데이터베이스에 대한 연결을 처리하는 역할을 합니다.
DATABASE_URL을 해석하여 적절한 데이터베이스에 연결합니다.
- 세션 로컬 및 기본 선언:
- SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine): SessionLocal 클래스를 생성합니다. SessionLocal의 인스턴스는 실제 데이터베이스 "세션"이 됩니다.
- autocommit=False: 변경 사항이 데이터베이스에 자동으로 커밋되지 않도록 합니다. 변경 사항을 저장하려면 명시적으로 db.commit()을 호출해야 합니다.
- autoflush=False: 쿼리 실행 전에 변경 사항이 데이터베이스에 자동으로 플러시되는 것을 비활성화합니다.
- bind=engine: 이 세션 생성기를 이전에 생성된 engine에 바인딩하여 SessionLocal에서 생성된 모든 세션이 이 데이터베이스 연결을 사용하도록 합니다.
- Base = declarative_base(): ORM 모델의 기본 클래스를 생성합니다. 애플리케이션의 모든 SQLAlchemy 모델(테이블)은 이 Base를 상속합니다. 이를 통해 SQLAlchemy는 모델을 알고 데이터베이스 테이블에
매핑할 수 있습니다.
- 데이터베이스 세션 종속성 (
get_db):
- def get_db():: 이 함수는 FastAPI 종속성으로 사용되도록 설계되었습니다.
- db = SessionLocal(): 새 SQLAlchemy 세션을 생성합니다.
- try...finally: 요청 처리 중에 오류가 발생하더라도 데이터베이스 세션(db)이 항상 닫히도록 try...finally 블록을 사용합니다. 이는 데이터베이스 연결을 연결 풀로 해제하고 리소스 누수를 방지하는 데
중요합니다.
- yield db: yield 키워드는 이 함수를 제너레이터 함수로 만듭니다. FastAPI는 이를 사용하여 경로 핸들러에 db 세션을 제공하며, yield 이후의 코드는 응답이 전달되거나(예외가 발생하더라도)
db.close()가 호출되도록 실행됩니다.
요약하자면, database.py는 핵심 SQLAlchemy 구성 요소를 설정합니다: 연결을 위한 Engine, 데이터베이스 상호 작용 세션 생성을 위한 SessionLocal 클래스, ORM 모델 정의를 위한 Base. get_db 함수는 FastAPI
경로가 데이터베이스 세션을 얻고 관리하는 깔끔한 종속성 주입 방식을 제공합니다.
웹 크롤러 (crawler/mk_news.py)
이 스크립트는 뉴스 기사를 크롤링하도록 설계되었습니다. HTTP 요청을 위해 requests를 사용하고 HTML 콘텐츠 파싱을 위해 BeautifulSoup를 사용합니다. 또한 데이터 유효성 검사를 위해
schemas.article.ArticleCreate를 활용하고 데이터베이스 작업을 위해 repositories.article_repository.ArticleRepository를 활용합니다.
주요 구성 요소 및 기능:
crawl_mk_news(days: int = 365) -> list[ArticleCreate]:
- 목적: 크롤링 프로세스를 총괄하는 메인 함수입니다. 뉴스 섹션에서 기사를 가져옵니다.
- 인수:
- days: 오늘로부터 몇 일 전까지의 기사를 크롤링할지 지정합니다(기본값은 365일, 즉 1년).
- 헤더 및 쿠키:
- 웹 브라우저 요청을 모방하기 위해 User-Agent 헤더를 사용하며, 이는 차단을 피하는 데 도움이 됩니다.
- 특정 PCID 및 SCOUTER 쿠키를 사용합니다.
- 페이지네이션 및 루프:
- URL의 page 매개변수를 증가시키면서 while 루프를 사용하여 뉴스 기사 페이지를 반복합니다.
- 루프는 페이지에서 더 이상 기사를 찾을 수 없거나 cutoff_date보다 오래된 기사를 발견할 때까지 계속됩니다(stop_crawling이 True가 됨).
- 오류 처리 및 재시도:
- 잠재적인 네트워크 문제 또는 임시 서버 오류를 처리하기 위해 requests.get 호출에 대한 재시도 메커니즘(최대 3회)을 포함합니다.
- time.sleep() 및 random.uniform()을 사용하여 요청 사이에 지연을 도입하여 공격적인 크롤링을 방지하고 차단될 가능성을 줄입니다.
- HTML 파싱:
- BeautifulSoup(response.text, "html.parser")는 각 페이지의 HTML 콘텐츠를 파싱하는 데 사용됩니다.
- soup.select("li.article_list")를 사용하여 기사 목록 항목을 선택합니다.
- 기사 파싱 및 필터링:
- 각 기사 HTML 요소에 대해 _parse_article을 호출하여 관련 데이터를 추출합니다.
- published_at 날짜를 기준으로 기사를 필터링하며, cutoff_date보다 오래된 기사가 발견되면 크롤링을 중단합니다. 이는 지정된 days 범위 내의 기사만 수집하도록 보장합니다.
- 로깅:
- logging을 사용하여 크롤링 진행 상황 및 발생한 오류를 보고합니다.
_parse_article(article) -> ArticleCreate | None:
- 목적: 단일 HTML 기사 요소에서 특정 데이터 포인트를 추출하는 도우미 함수입니다.
- 추출:
- BeautifulSoup 셀렉터(select_one)를 사용하여 링크(a.news_item), 제목(h4), 설명(p.art_desc), 게시 시간(div.time_area span), 썸네일 이미지(div.list_thumb img)를 찾습니다.
- 날짜 파싱:
- time_area의 date_text를 datetime.strptime을 사용하여 datetime 객체로 파싱하려고 시도합니다.
- 데이터 유효성 검사 및 객체 생성:
- 필수 요소(링크, 제목, 게시 날짜)가 있는지 확인하기 위한 기본적인 검사를 수행합니다.
- 추출된 데이터로 ArticleCreate 객체(schemas.article에서)를 구성합니다. 이 단계는 ArticleCreate Pydantic 모델에 대해 데이터를 암시적으로 유효성 검사합니다.
- 필수 데이터가 없거나 Pydantic 유효성 검사가 실패하면 오류를 로깅하고 None을 반환합니다.
save_to_db(articles: list[ArticleCreate]) -> tuple[int, int]:
- 목적: ArticleCreate 객체 목록을 받아 데이터베이스에 저장합니다.
- 데이터베이스 상호 작용:
- SessionLocal()을 사용하여 새 데이터베이스 세션을 생성합니다.
- 세션으로 ArticleRepository를 인스턴스화합니다.
- repo.bulk_create(articles)를 호출하여 여러 기사를 데이터베이스에 효율적으로 삽입합니다.
- finally 블록을 사용하여 데이터베이스 세션이 닫히도록 합니다.
요약하자면, 웹 크롤러는 다음과 같습니다.
- 웹 페이지를 가져오고 파싱하기 위해 requests 및 BeautifulSoup를 사용합니다.
- 지정된 days보다 오래된 기사에 도달하면 중지하면서 시간을 거슬러 페이지를 크롤링합니다.
- 견고하고 정중하게 작동하기 위해 재시도 로직과 지연을 포함합니다.
- 개별 기사 세부 정보를 구조화된 ArticleCreate Pydantic 모델로 파싱합니다.
- 유효성 검사를 거친 기사를 대량 삽입을 위해 ArticleRepository를 사용하여 데이터베이스에 저장합니다.
전반적인 아키텍처, 데이터베이스 연결 및 애플리케이션 초기화 (main.py)
main.py 파일은 FastAPI 애플리케이션의 진입점 역할을 하며, 웹 서버 설정, 데이터베이스 통합, 웹 크롤러 스케줄링 및 미들웨어 구성을 담당합니다.
- FastAPI 애플리케이션 인스턴스:
- app = FastAPI(lifespan=lifespan): FastAPI 애플리케이션을 초기화합니다. lifespan 컨텍스트 관리자는 시작 및 종료 이벤트를 관리하기 위해 여기에 전달됩니다.
- CORS 미들웨어:
- app.add_middleware(CORSMiddleware, ...): CORS(Cross-Origin Resource Sharing)를 구성합니다. 이는 프런트엔드(예: localhost:5173에서 실행)가 백엔드 API에 요청해야 하는 웹 애플리케이션에
필수적입니다.
- allow_origins: 요청을 보낼 수 있는 허용된 출처를 지정합니다(이 경우 프런트엔드의 http://localhost:5173 및 http://127.0.0.1:5173).
- allow_credentials=True: 자격 증명(예: 쿠키 또는 HTTP 인증)이 교차 출처 요청과 함께 전송될 수 있도록 허용합니다.
- allow_methods=["*"], allow_headers=["*"]: 모든 HTTP 메서드 및 헤더를 허용하며, 이는 개발 시 일반적이지만 프로덕션에서는 제한될 수 있습니다.
- 데이터베이스 테이블 생성:
- Base.metadata.create_all(bind=engine): 데이터베이스 설정에 중요한 줄입니다.
- Base(database.py에서)는 자신을 상속하는 모든 ORM 모델(Article, Bookmark, User)에 대해 알고 있습니다.
- metadata는 테이블 객체의 컬렉션입니다.
- create_all(bind=engine)은 SQLAlchemy에 engine이 연결된 데이터베이스에 정의된 모든 테이블을 아직 존재하지 않는 경우 생성하도록 지시합니다. 이는 일반적으로 애플리케이션 시작 또는
마이그레이션 중에 한 번 실행됩니다.
- 라우터 포함:
- app.include_router(articles.router): routers/articles.py에 정의된 API 경로를 포함합니다. 이는 /articles(검색용)와 같은 엔드포인트가 주요 FastAPI 애플리케이션의 일부가 됨을 의미합니다.
- app.include_router(bookmark_router): routers/bookmark.py에 정의된 API 경로를 포함합니다. 이는 북마크 관리와 관련된 엔드포인트를 활성화합니다.
- 웹 크롤러 스케줄링 (Lifespan 컨텍스트 관리자):
- @asynccontextmanager async def lifespan(app: FastAPI):: FastAPI가 애플리케이션 시작 및 종료 시 코드를 실행하는 데 사용하는 비동기 컨텍스트 관리자를 정의합니다.
- 시작 (
scheduler.add_job(...), scheduler.start()):
- apscheduler의 BackgroundScheduler가 초기화됩니다.
- scheduler.add_job(run_crawler, "interval", minutes=30, next_run_time=datetime.now()): run_crawler 함수를 30분마다 실행하도록 스케줄링합니다. next_run_time=datetime.now()는 애플리케이션이
처음 시작될 때 즉시 실행되도록 합니다.
- scheduler.start(): 백그라운드 스케줄러 스레드를 시작합니다.
- 종료 (
scheduler.shutdown()):
- yield: 스케줄러가 시작된 후 애플리케이션이 진행됩니다.
- 애플리케이션이 종료되면 yield 이후의 코드가 실행됩니다: scheduler.shutdown()은 백그라운드 크롤링 프로세스를 정상적으로 중지합니다.
run_crawler() 함수:
- 이 함수는 스케줄러에 의해 호출됩니다.
- crawl_mk_news(days=1)을 호출하여 지난 하루 동안의 기사를 가져옵니다.
- 그런 다음 save_to_db(articles)를 호출하여 크롤링된 기사를 데이터베이스에 저장합니다.
- 연결 설정: database.py는 settings.py의 DATABASE_URL을 사용하여 engine 및 SessionLocal을 설정합니다.
- 스키마 생성: main.py는 시작 시 Base.metadata.create_all(bind=engine)을 호출하여 ORM 모델을 기반으로 데이터베이스 테이블(articles, bookmarks, users)이 생성되었는지 확인합니다.
- 종속성 주입: 데이터베이스 액세스가 필요한 FastAPI 경로(예: routers/articles.py 및 routers/bookmark.py의)는 get_db(database.py에서)에 대한 종속성을 선언합니다.
- 세션 관리: get_db는 경로 핸들러에 SQLAlchemy Session 객체(db)를 제공합니다. 이 세션은 요청 시작 시 자동으로 열리고 끝날 때 닫히므로 적절한 리소스 관리가 보장됩니다.
- 리포지토리 사용: 경로 핸들러 내에서 제공된 db 세션을 사용하여 ArticleRepository 또는 BookmarkRepository 인스턴스가 생성되어 구조화되고 추상화된 데이터베이스 작업을 수행할 수 있습니다.
- 크롤러 데이터베이스 상호 작용: run_crawler 함수도 save_to_db를 사용하며, 이는 다시 ArticleRepository 및 SessionLocal을 사용하여 새로 크롤링된 기사를 저장합니다.
전반적인 아키텍처 다이어그램 :
+-------------------+ +-------------------+
| 프런트엔드 | <---> | FastAPI 앱 |
| (예: React/Vite) | | (main.py) |
+-------------------+ +--------^----------+
|
+------------------------------------+------------------------------------+
| API 경로 (routers/) | 스케줄러 (apscheduler) |
| - /articles (articles.py) | - crawl_mk_news 실행 (30분마다) |
| - /bookmarks (bookmark.py) | - save_to_db 호출 |
+------------------------------------+------------------------------------+
| |
v v
+-------------------+ +-------------------+
| 리포지토리 | | 웹 크롤러 |
| (article_repo.py, | | (mk_news.py) |
| bookmark_repo.py) | <--------------> | |
+-------------------+ +-------------------+
^ ^
| 종속성 주입 (get_db) |
v v
+-------------------+ +-------------------+
| SQLAlchemy ORM | <---> | 데이터베이스 |
| (models/, Base) | | (SQLite/Postgres) |
+-------------------+ +-------------------+
^
| 구성
v
+-------------------+
| 설정 |
| (settings.py) |
+-------------------+