# 목적
네이버 쇼핑 사이트의 제품명, 가격 등에 대한 데이터들을 확장성을 갖고 정기적으로 수집이 가능한 Python 실행 프로그램을 구현하기 위함이다.
직접 requests을 사용해 파싱할 HTML 문서를 수집하는 방식이 아닌 Selenuim을 사용한다.
Selenuim을 선택한 이유는 반응형 & 동적 웹페이지의 데이터를 원할하게 수집하기 위함이다. 추후 로그인을 필요로 하는 수집 사이트도 확장하기 위함이다.
네이버 쇼핑, 다나와의 SRP를 확인하였을 때 URL Query를 통해 페이지 접근이 가능하지만, 페이지 렌더링 시간이 불규칙적인 점과 추후 크롤링의 안정성과 확장성을 위해서 Selenuim을 사용한 크롤링을 진행했다.
- 최종 목적 아래와 같다.쿼리 명이 담겨 있는 엑셀 파일 명과 함께 프로그램을 동작시키면, 각 쿼리 별로 200개의 아이템을 크롤링하여 별도의 엑셀 파일로 결과를 반환한다.
- 엑셀 결과 파일 명은 별도로 지정하거나 "요청시간_완료시간"을 기본값으로 갖도록 한다.
- 해당 프로그램을 Linux 환경에서 크론 탭을 사용하여 정기적인 크롤링이 가능하도록 한다.
- 더 나아가, Container 이미지로 빌드하여 컨테이너 환경에서 크롤링 결과물을 생성가능하도록 한다.
# 개발 프로세스
우선 프로그램 언어는 Python, M1 Mac 환경을 사용한다.
OS: M1 Sonoma 14.2.1
Python: 3.9.18
- Selenuim을 사용하기 위한 환경 설정 및 기본 사용법 대해 이해한 후, Python 리스트로 5개의 쿼리를 임의로 선언여 네이버 메인 페이지로부터 쇼핑 페이지에서 해당 쿼리로 검색한 페이지를 반환하도록 한다.
- 쇼핑 페이지에서 첫 번째 페이지의 모든 아이템에 대해 크롤링을 진행한다.
- 200개의 아이템을 수집하도록 페이지를 이동하고 결과물을 엑셀 파일로 저장한다.
- 별도의 엑셀 파일에 수집할 쿼리를 담고, 해당 엑셀 파일명을 매개변수로 지정하여 쿼리별 200개의 아이템이 담긴 엑셀 파일이 반환되도록 한다.
- 위 요건을 만족한 경우, Linux 시스템에서 동작 가능하도록 애플리케이션을 수정한다.
- 동작이 완료되면 OCI Image로 이미지를 빌드하고 Docker를 사용하여 테스트를 진행한다.
크롤링으로 수집할 데이터는 아래와 같다.
Attribute | Meaning |
title | 아이템 제목 |
link | 아이템 판매 링크(SDP) |
registedDate | 등록일 |
category | 카테고리 |
price | 가격 |
deliveryFee | 배송비 |
ad | 광고 상품 유무 |
seller | 판매자 |
예외 처리 경우
- 판매처가 여러 곳인 경우, 최저가 판매자를 수집한다.
- 카테고리의 경우 "디지털/가전 > 휴대폰 > 자급제폰" 형식으로 수집한다.
프로그램의 인터페이스는 다음과 같다.
[precondition] - N개의 쿼리가 담긴 qu_ns_240303.xlsx 파일
[input] - python ns_crawler qu_ns_240303 re_ns_240303
[output] - re_ns_240303.xlsx
- 결과 엑셀 파일 명이 동일할 경우 _{id}를 접미사로 붙여 파일이 덮어씌워지지 않도록 처리한다.
# Selenium Setting
Python 가상환경 세팅 및 활성화
python3.9 -m venv .
source bin/activate
Selenuim, openpyxl 설치
pip install -upgrade pip
pip install selenium
pip install openpyxl
Selenuim을 동작시키기 위해서 Chrome Driver를 다운로드 받아야한다.
Seleuim은 최초에 웹 페이지 테스트의 용도로 고안되었는데, 실제 사용자가 웹 페이지를 이용하는 것처럼 브라우저를 통해 클릭, 입력 등의 동작을 수행할 수 있다.
따라서, 사용하고자하는 브라우저 드라이버를 통해 동작하므로 해당 의존성이 필수적이다.
본인은 M1 Mac OS를 사용하므로 해당 환경에 일치하는 크롬 드라이버를 설치했다.
해당 실행 바이너리는 Python 가상환경 디렉토리에 위치시켰다.
크롬 드라이버에 대한 정보를 확인할 수 있는 공식 문서이다.
현재 시스템에 존재하는 크롬 버전을 지원하는 드라이버를 다운로드 해야한다.
현재 크롬 브라우저가 최신 버전이기 때문에 위 URL에서 Stable한 크롬 드라이버를 설치한 후 Python 가상 환경 디렉토리에 옮겨주었다.
Selenium 동작시키기
우선, Selenuim을 통해 Chrome Driver가 정상적으로 동작하는지 테스트를 진행했다.
아래는 Selenuim을 통해 네이버 메인 홈페이지에 접속하는 테스트 코드이다.
from selenium import webdriver
driver_path = 'Chrome Driver Path'
service = webdriver.ChromeService(executable_path=driver_path)
options = webdriver.ChromeOptions()
options.add_experimental_option("detach", True)
driver = webdriver.Chrome(service=service, options=options)
driver.get("https://www.naver.com")
크롬 드라이버를 통해 크롬 브라우저를 열면 위와 같이 추가 배너가 표시된다.
이 과정에서 chrome driver에 대한 악성 코드 의심 알림창이 발생할 수 있는데, Mac 자체 설정에서 임의로 풀어주었다.
이후 해당 코드가 담긴 스크립트를 실행시키면 정상적으로 작동된다.
로컬 환경에 저장한 크롬 드라이버를 사용하기 위해선 Driver Service Class를 통하여 Local Driver Location을 지정해야한다. Service Class를 통해서 사용 포트 또한 지정이 가능하다. log_output을 지정하여 크롬 드라이버를 사용하면서 생기는 로그를 저장할 수도 있다. 해당 정보는 아래의 Selenium 공식 홈페이지에서 확인할 수 있다.
추가로 브라우저가 열렸음을 직관적으로 확인하기 위하여 'options.add_experimental_option("detach", True)' 옵션을 지정하였다. 해당 옵션을 지정하면, 별도로 quit() 메소드를 주지 않으면 세션이 종료되지 않는다.
실제로 크롤링을 동작시킬 경우에는 headless 옵션을 통하여 백그라운드로 웹 드라이버가 실행되도록 하는 것이 리소스 효율적이다.
실제 크롤링을 시작하기 전까진 해당 옵션을 키고 진행하였다.
# 네이버 쇼핑으로 이동하기
웹 드라이버를 네이버 메인 홈페이지로 이동 시켰으면, 네이버 쇼핑으로 이동해야한다. 바로 네이버 쇼핑으로 크롬 드라이버를 이동시키면 이 과정은 생략되어도 된다.
네이버 쇼핑으로 이동하기 위해선 쇼핑 탭으로 가기 위한 HTML 요소를 찾아야한다.
크롬 브라우저의 개발자 도구를 사용하여 쇼핑 탭의 버튼의 속성을 파악하면 아래와 같다.
웹 드라이버의 find_element() 메소드를 통해여 해당 요소를 유일하게 식별할 수 있는 속성을 뽑아내야한다.
이를 이용하기 위해선, 'a.link_service[href='https://shopping.naver.com/home']' CSS 셀렉터를 이용하였다. 현재는 XPATH 패턴을 사용했다.
a 태그의 href 속성을 명시적으로 지정하여 WebElement를 선택하였다.
이후, 네이버 메인 페이지에서 쇼핑 페이지로 Driver를 명시적인 전환이 필요하다.
따라서, 웹 드라이버의 window_handles 속성에 담긴 윈도우를 파악하여 명시적으로 전환을 수행한다.
이를 확인하면, 네이버 쇼핑 탭이 리스트에 Append 됨을 확인할 수 있다.
이를 활용하여 아래의 코드로 윈도우 전환을 수행할 수 있다.
driver = webdriver.Chrome(service=service, options=options)
# 네이버 메인 페이지 이동
driver.get("https://www.naver.com")
# 로딩 대기
driver.implicitly_wait(5)
print("크롬 드라이버 현재 URL: " + driver.current_url)
print("크롬 드라이버 현재 URL Title: " + driver.title)
# 쇼핑 버튼 클릭
driver.find_element(By.CSS_SELECTOR, "a.link_service[href='https://shopping.naver.com/home']").click()
# 전환
shopping_window_handle = driver.window_handles[-1]
driver.switch_to.window(shopping_window_handle)
print("크롬 드라이버 전환 후 URL: " + driver.current_url)
print("크롬 드라이버 전환 후 URL Title: " + driver.title)
이제 네이버 쇼핑에서 입력 form 요소를 찾아보면 아래와 같다.
이를 조작하다 예외처리가 필요한 상황을 파악할 수 있었다. 네이버 쇼핑 페이지가 화면의 크기에 따라 페이지가 달라지는 반응형임을 확인할 수 있었다.
이를 처리하기 위해서 브라우저의 크기를 기준으로 판단하기보단, try except문을 활용해서 웹 용 페이지인지 모바일 용 페이지임을 확인하는 요소를 통해서 검색 화면을 찾는 방식을 적용하려 했다.
하지만, 모바일 반응형에서의 크롤링을 위한 화면 구조까지 예외 처리를 두어야한다고 판단하여, 브라우저를 최대로 설정하여 모바일 화면으로 전환되는 상황을 방지하는 방식을 사용했다.
이 부분은 추후 보완점으로 남긴다.
브라우저를 최대로하는 옵션은 'options.add_argument("--start-maximized")' 을 통해 지정이 가능하다.
이런 저런 시도를 하면서, XPath로 검색 창 클릭을 진행했다.
driver.find_element(By.XPATH, "/html/body/div[3]/div/div[1]/div/div/div/div[2]/div/div[2]/div/div[2]/form/div[1]/div/input").click()
코드를 실행하면, 자동으로 아래와 같은 화면을 볼 수 있어야 한다.
이후 원하는 쿼리를 집어넣고 키보드의 Enter를 입력하여 검색 화면으로 전환시켜야하는데, 이를 위해 Keys 클래스를 추가하고 아래의 코드를 작성하였다.
from selenium.webdriver.common.keys import Keys
search.send_keys("아이폰 14 Pro")
search.send_keys(Keys.ENTER)
keys.ENTER는 키보드의 엔터 키 입력을 의미한다.
이제 1단계가 마무리되었고, 아래는 코드의 전문이다.
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
driver_path = 'Chrome WebDriver Path'
service = webdriver.ChromeService(executable_path=driver_path, log_output='log/test_log')
options = webdriver.ChromeOptions()
options.add_experimental_option("detach", True)
options.add_argument("--start-maximized")
# options.add_argument("--headless")
driver = webdriver.Chrome(service=service, options=options)
# 네이버 메인 페이지 이동
driver.get("https://www.naver.com")
# 로딩 대기
driver.implicitly_wait(5)
# 쇼핑 버튼 클릭
# driver.find_element(By.CSS_SELECTOR, "a.link_service[href='https://shopping.naver.com/home']").click()
driver.find_element(By.XPATH, "/html/body/div[2]/div[1]/div/div[5]/ul/li[4]/a").click() # XPATH
driver.implicitly_wait(5)
# 전환
shopping_window_handle = driver.window_handles[-1]
driver.switch_to.window(shopping_window_handle)
# 입력 버튼 선택
# driver.find_element(By.CLASS_NAME, "_searchInput_search_text_3CUDs").click() # Class
search = driver.find_element(By.XPATH, "/html/body/div[3]/div/div[1]/div/div/div/div[2]/div/div[2]/div/div[2]/form/div[1]/div/input") # XPATH
search.click()
# 쿼리 검색
search.send_keys("아이폰 14 Pro")
search.send_keys(Keys.ENTER)
driver.implicitly_wait(3)
implicitly_wait 메소드는 해당 페이지 로딩이 완료될때까지 대기하기 위해 사용했다.
해당 코드를 실행하면 아래와 같은 크롬 브라우저를 확인할 수 있다.
네이버 쇼핑 페이지에서 아이폰 14 Pro가 정상적으로 검색되었음을 확인할 수 있다.
# SRP Parsing
무한 스크롤 처리
이제 검색 화면에서 요소들의 정보를 실제로 끄집어내야한다.
그 전에 네이버 쇼핑 페이지는 스크롤을 내릴때마다 아이템을 동적으로 받아오기 때문에 이 점을 처리해야 했다.
즉, 한 페이지의 모든 데이터를 온전하게 수집하기 위해선 크롬 브라우저의 스크롤을 반복적으로 내려주는 작업이 필요하다.
이는 JS 명령어를 통해서 현재 스크롤의 Y 위치를 확인하며 처리를 수행할 수 있다.
beforeScrollY = driver.execute_script("return window.scrollY")
while True:
driver.find_element(By.CSS_SELECTOR, "body").send_keys(Keys.END)
time.sleep(0.5)
afterScrollY = driver.execute_script("return window.scrollY")
if beforeScrollY == afterScrollY:
break
beforeScrollY = afterScrollY
기존 스크롤 높이를 변수에 기록하고 키보드 END를 눌러 내려간 스크롤의 높이를 비교하여 스크롤이 계속 내려간다면 반복적으로 스크롤을 내리고는 로직이다.
스크롤을 내릴 때 time.sleep()으로 로딩 시간을 명시적으로 걸어주지 않으면 제대로 무한 스크롤 처리가 되지 않을 수 있다.
이를 추가하고 코드를 실행하면, 브라우저가 맨 밑으로 이동된다.
상품 목록 Parsing
아이폰 14 Pro 검색화면을 확인해보았을 때, 상품 목록이 아래와 같이 광고 상품과 아닌 상품으로 나뉘어져 있다.
네이버의 추천 시스템으로 노출된 광고 상품과 일반 상품을 모두 크롤링하고, 이를 구분하기 위한 속성을 추가하는 방식으로 진행했다.
세가지 div 태그로 하나의 아이템이 구성되어 있고, 첫번째 태그에는 사진이, 두번째 태그에는 상세 속성이, 마지막 태그에는 판매자 정보가 존재한다.
각 HTML 요소에서 원하는 정보를 파싱해오면 되는데, 아래 코드로 명시했던 데이터를 Python 딕셔너리에 담았다.
우선 모든 요소를 담은 HTML 태그는 "div.basicList_list_basis__uNBZx"임을 확인할 수 있다.
이를 크롬 드라이버에서 find_element로 가져온 후 광고 아이템의 개수를 확인해보자.
items = driver.find_element(By.CLASS_NAME, "basicList_list_basis__uNBZx")
# 광고 제품 HTML 요소 가져오기 - div > adProduct_inner__W_nuz && adProduct_item__1zC9h
adItems = items.find_elements(By.CLASS_NAME, "adProduct_item__1zC9h")
print("광고 아이템 개수:", end=" ")
print(len(adItems))
직접 페이지의 HTML 요소를 확인했을 때, 광고 아이템은 총 6개임을 확인할 수 있다.
이제 전체 아이템 리스트를 광고 & 비광고로 나누어서 필요한 정보들을 파싱하여 딕셔너리에 저장하면 현재 챕터의 목적을 달성한다.
수집해야할 데이터를 다시 정리하면 아래와 같다.
Attribute | Meaning |
title | 아이템 제목 |
link | 아이템 판매 링크(SDP) |
registedDate | 등록일 |
category | 카테고리 |
price | 가격 |
deliveryFee | 배송비 |
ad | 광고 상품 유무 |
seller | 판매자 |
우선 광고 아이템 리스트를 위의 기준에 맞게 파싱한다.
광고 아이템을 스크래핑하는 코드 블럭은 아래와 같다.
실제 정리된 코드는 간단한 리팩토링 및 모듈화를 진행했으므로 실제 사용된 코드는 포스팅 아래의 깃허브 링크를 참조하길 바란다.
adItems = []
for adElement in adElements:
# 아이템 제목
title = adElement.find_element(By.CLASS_NAME, "adProduct_link__NYTV9.linkAnchor").text
# SDP Link
redirectLink = adElement.find_element(By.CLASS_NAME, "adProduct_link__NYTV9.linkAnchor").get_attribute("href")
try:
subDriver.get(redirectLink)
link = subDriver.current_url
except:
link = "수집된 SDP는 유효하지 않습니다."
# 둥록 일자
registedDate = adElement.find_element(By.CLASS_NAME, "adProduct_etc_box__UJJ90").find_element(By.TAG_NAME, "span").text[4:]
# 가격
try:
price = adElement.find_element(By.CLASS_NAME, "price_num__S2p_v").text
except:
price = "판매 중단"
# 배송비
deliveryFee = adElement.find_element(By.CLASS_NAME, "price_delivery__yw_We").text.split()[-1]
# 카테고리
categories = []
adElementCategories = adElement.find_elements(By.CLASS_NAME, "adProduct_category__ZIAfP.adProduct_nohover__zHCEV")
for adElementCategory in adElementCategories:
categories.append(adElementCategory.text)
# 광고 유무
ad = True
# 판매자
sellerElement = adElement.find_element(By.CLASS_NAME, "adProduct_mall__zeLIC.linkAnchor")
if sellerElement.text == "":
seller= sellerElement.find_element(By.TAG_NAME, "img").get_attribute("alt")
else:
seller= sellerElement.text
adItem = {
"title": title,
"link": link,
"price": price,
"deliveryFee": deliveryFee,
"registedDate": registedDate,
"category": categories,
"ad": ad,
"seller": seller
}
adItems.append(adItem)
코드를 간단하게 리뷰하면, 다음과 같다.
- 아이템 제목을 CLASS NAME으로 파싱한다. 텍스트가 제목에 해당된다.
- SDP Link를 추가적인 크롬 드라이버를 통하여 수집한다. 해당 부분은 포스팅 아래의 주의점에 따로 정리할 예정이다.
- 등록 일자의 경우는 CLASS NAME으로 수집한다.
- 가격의 경우 판매 중단인 경우를 고려하여 예외 처리 로직을 추가하여 수집하였다.
- 배송비의 경우 수집 이후 문자열.split() 을 통하여 "\n"를 분리하여 가격 부분만 수집했다.
- 카테고리의 경우 반복처리를 하여 리스트로 값을 저장하였다.
- 광고 유무는 명시적으로 선언하였다.
- 판매자의 경우도 아래의 주의점에 따로 작성할 계획이다. 판매자가 여러개인 경우는 우선 제외하였다. 판매자의 정보는 텍스트로 제공되거나 이미지로 대체되는데 이 정보를 분기 처리하여 수집하였다.
해당 코드로 수집되는 정보는 아래와 같다.
모든 정보가 정상적으로 크롤링되었음을 확인할 수 있었다.
일반 상품의 경우는 중복되는 부분이 많아 따로 코드를 공유하진 않을 예정이다.
# 수집한 데이터를 엑셀로 옮기기
수집한 각 데이터 딕셔너리로 저장되고, 하나의 리스트에 저장된다. 이를 반영하여 엑셀에 저장한 코드는 다음과 같다.
우선 엑셀로 결과물을 반환하기 위해 'openpyxl' 라이브러리를 사용한다.
.csv로도 저장할 수 있는 옵션을 추가하는 것은 보완점으로 남길 예정이다.
wb = Workbook()
ws = wb.active
headers = list(crawledItems[0].keys())
for col_idx, header in enumerate(headers, start=1):
ws.cell(row=1, column=col_idx, value=header)
for row_idx, data_dict in enumerate(crawledItems, start=2):
for col_idx, header in enumerate(headers, start=1):
ws.cell(row=row_idx, column=col_idx, value=data_dict.get(header))
wb.save(output_name + ".xlsx")
print("[" + output_name + "] 크롤링 결과물이 생성되었습니다.")
기존에 딕셔너리로 저장되어있기 때문에 간단하게 엑셀 파일로 변환이 가능했다.
결과물을 확인하면 아래와 같다.
최종적으로 한 페이지에 수집된 데이터의 개수는 46개이다. 실제로 쇼핑 페이지 하나의 아이템 세보았을 때 46임을 확인할 수 있었고 모든 아이템을 수집했다.
여러 개의 판매자가 존재하는 경우 예외 처리를 진행하지 않은 점 또한 보완점으로 남길 예정이다.
# 주의점
크롤링 상품 링크가 리다이렉션 링크인 점
네이버 쇼핑에 존재하는 상품 링크는 네이버 자체 페이지이다. 즉, 실제 판매자의 링크가 아니라는 점을 확인할 수 있었다.
실제 판매자 상품 링크를 확인하기 위해서 URL 리다이렉트한 결과 URL를 반환받기 위한 조치를 수행했다.
일차적으로 requests 모듈을 통해서 간단하게 HTTP 요청을 통해 원하는 URL을 찾아내려했지만, 정상적으로 요청이 보내지지 않았다.
요청이 5초의 타임아웃을 벗어나는 것을 확인할 수 있다.
따라서, 이 부분을 처리하기 위해서 추가적인 Chrome Driver를 두어 해당 링크를 열어 실제 상품 SDP Link를 수집하도록 처리했다.
redirectLink = adElement.find_element(By.CLASS_NAME, "adProduct_link__NYTV9.linkAnchor").get_attribute("href")
try:
subDriver.get(redirectLink)
link = subDriver.current_url
except:
link = "수집된 SDP는 유효하지 않습니다."
이를 위해서 상품을 크롤링하기 전 headless 옵션을 포함시킨 subDriver를 선언하여 크롬 드라이버를 통하여 SDP URL을 수집하도록 했다.
성능적으로는 떨어질 것이라 예상되고 보완점으로 남길 계획이다.
정상적으로 수집된 SDP Link를 확인할 수 있다.
상품 판매자 정보가 이미지 혹은 텍스트
우선 추가적인 예외 상황이 확인 된다면 추후 수정될 수 있다는 점을 밝힌다.
위는 판매자 정보가 이미지로만 구성되어 있는 경우이다.
위의 판매자는 KREAM으로 Text 형태로 판매자 정보를 확인할 수 있다.
이 두가지 케이스를 판별하여 판매자 정보를 수집해야하므로 분기 처리를 진행했다.
sellerElement = adElement.find_element(By.CLASS_NAME, "adProduct_mall__zeLIC.linkAnchor")
if sellerElement.text == "":
seller= sellerElement.find_element(By.TAG_NAME, "img").get_attribute("alt")
else:
seller= sellerElement.text
해당 클래스에 속한 텍스트가 없으면 이미지의 alt 속성을 판매자 명으로 수집하고, 아닌 경우는 그대로 수집한다.
"등록일" 블록의 순서 문제
리뷰가 포함된 아이템과 아닌 아이템 간의 요소 배치가 다르다는 점을 확인할 수 있었다.
HTML 구조상 리뷰의 경우는 a 태그로 감싸져 있고 나머지 등록일, 찜하기, 정보 수정 요청은 span 태그로 선언되어 있음을 확인할 수 있었다.
이를 명시적으로 지정하기 위해 XPATH를 사용하여 첫번째 span 태그를 지정함으로써 예외처리를 진행했다.
# 정리
현재까지 만들어진 프로그램은 다음과 같다.
고정된 쿼리에 대하여 네이버 쇼핑의 첫번재 페이지의 아이템 46개를 크롤링한다.
크롤링하는 정보는 각각 제목, 링크, 가격, 배송비, 등록일, 카테고리, 광고유무, 판매자이다.
크롤링된 결과는 최종적으로 엑셀에 저장된다.
현재까지의 전체 코드는 아래의 깃허브에서 확인할 수 있다.
보완점
- 판매자 정보가 여러 개인 경우의 처리를 하지 못했다.
- 쿼리를 Argument로 입력받아 크롤링하도록 구현하지 못했다.
- 모바일 반응형 페이지로 전환될 경우 처리를 하지 못했다.
- 다음 페이지로 넘어가 크롤링을 하는 기능을 구현하지 못했다.
- 여러 쿼리에 대한 크롤링 기능을 구현하지 못했다.
- 성능 최적화를 수행하지 못했다. - 현재 한 페이지를 크롤링하는데 약 1초가 소요된다.
- 크롤링 산출물을 .csv로도 저장하는 옵션 기능을 구현하지 못했다.
Reference
'Dev > Web' 카테고리의 다른 글
CSS Selector & XPath 개념 및 사용법 (7) | 2024.04.12 |
---|---|
네이버 쇼핑 검색 과정 자동화(크롤링) with Selenuim - 2 (3) | 2024.04.10 |
FastAPI & Nginx 특정 엔드포인트(Health Check) 로그 제거 - ALB Health Check 로그 제외시키기 (2) | 2023.10.29 |
FastAPI 기반 Server Sent Event(SSE) 구현 과정 및 시행착오 - UTF-8 Decoding 및 ANSI Escape Sequence 처리 (2) | 2023.10.28 |
FastAPI Swagger URL 변경 - /docs URL 변경 (2) | 2023.10.26 |