구어체로 설명하는 다이어리

[스터디] 내 주변의 저렴한 카페를 검색해주는 웹페이지 만들기 - 1 본문

스터디/1사이클 스터디 회고

[스터디] 내 주변의 저렴한 카페를 검색해주는 웹페이지 만들기 - 1

씨씨상 2025. 12. 24. 17:55

 

 

 

이 회고를 작성하기 전에 내 작업방식이 틀렸음을 먼저 밝히고 시작하겠다. 이미 어느 정도 진행된 바가 있어서 이것을 뒤엎는다는 결정을 하는 것은 매몰비용이 상당히 커서 나로서도 굉장히 고통스러운 부분이었지만 나는 단순히 연습용으로 이 프로젝트를 마주하고 싶은 것이 아니라 실서비스하는 수준으로 고려하고 생각하고 싶었기 때문에 잘못 판단한 부분은 정확하게 짚고 넘어가고 싶었다. 그래서 오늘은 초기에 어떤 방식으로 작업을 진행했는지와 어떤 부분에서 문제점을 찾았는지 그리고 최종적으로 어떤 결론에 도달했는지 공유하겠다.

 

우선 지난 주에 토이 프로젝트의 개요 및 짤막한 계획에 대해 백엔드 개발자님에게 공유하는 시간을 가졌다. 크롤링을 한다면 어떤 방식으로 하겠냐고 하셔서, ○○역 카페 혹은 ○○구 카페라는 이름으로 검색시킨 후 리스트를 수집하고 네이버 지도와 카카오맵 두 곳에서 검색결과가 더 많이 나오는 플랫폼의 크롤링을 진행할거다, 라고 대답했는데 시간이 흘러서 차분히 생각해 보니 내가 아무리 꼼꼼하게 진행한다고 해도 누락될 데이터가 많을 것 같았다. 누락 없이 데이터를 가져올 수 있으면서 제일 효율적인 방법이 뭘까 생각하다가 대안으로 생각해 낸 것이 공공데이터를 이용하는 방법이었다. 공공데이터란 누구나 접근, 활용, 편집, 공유할 수 있는 데이터이다. 공공데이터 포털에 들어가면 서울시 휴게음식점 정보를 받을 수 있는데 여기서 나에게 필요한 정보만 필터링해서 (영업상태가 정상이면서 업태구분이 커피숍인 경우 등) 데이터베이스에 적재할 계획을 세웠다.

 

이 부분에서 나의 잘못된 판단이 있었는데 네이버지도나 카카오맵의 카페 리스트보다 공공데이터 정보가 더 정확할 것이라 맹신했던 것이었다. 내가 네이버 지도를 유저로써 사용할 때는 축척을 조정할 때마다 리스트를 다르게 보여주는 것처럼 동작했기 때문에 ○○역 카페나 ○○구 카페라고 입력하더라도 해당하는 전체 카페 리스트를 찾아주는 게 진짜 맞는 걸까에 대한 의심이 있었다. 당연히 전체 리스트 가져와서 타입과 축척에 따라 다르게 보여주는게 아니었을까 지금 보니 왜 그렇게 생각했는지 의문 그래서 공공데이터의 데이터 갱신일이 최신이 아닌 점만 감안하면 더 정확한 데이터라고 판단했던 것이었다. 다시 계획하라고 하면 데이터의 정확도를 파악하고 싶었을 때 특정한 지역을 정해서 네이버의 검색결과, 카카오맵의 검색결과, 공공데이터의 필터링 결과를 비교해서 어떤 것이 제일 결과가 많은지 먼저 파악하고 누락된 데이터가 있는지 교차검증을 했을 것 같다. 이 교차검증이라는 것도 결국 서로의 플랫폼에 검색시키는 정도이기 때문에 직접 방문하지 않는 이상 절대적 기준은 되지 못하겠지만 적어도 그 정도의 근거는 있어야 어떤 데이터가 더 정확한지 가르는 기준이 되었을 것 같다.

 

어쨌든 삽질한 과정 자체도 의미있었으므로 지금까지 진행한 내용을 공유해 보겠다.

 

 

작업 순서

 

 

진행한 내역은 다음과 같다. 1. 테이블을 생성하고, 2. 공공데이터에서 내가 사용할 데이터를 테이블에 넣는다. 3. 그리고 네이버지도에서 영업시간 및 메뉴 정보를 크롤링한다. 저번에 스터디를 진행하면서 mariaDB 세팅한 것이 있으니 그곳에 새로운 데이터베이스를 생성한다. 데이터베이스 스키마는 저번에 작성한 것이 있으니 패스하겠다. 대신 컬럼을 추가했는데 공공데이터의 no값과 네이버 지도에서 메뉴를 크롤링할 때 어떤 사유로 실패했는지를 기록하는 컬럼이다.

 

 

그다음에는 DB에 적재한 데이터를 네이버지도에 검색시켜서 메뉴를 크롤링해온다. 나는 프론트엔드 개발자고 업무를 하면서 직접 크롤링을 할 일은 없으니까 두루뭉술하게 이런 식으로 진행하겠지~ 하고 상상만 하던 부분이었는데 실제로 진행을 해보려 하니 엄청난 노가다라 놀랐다. 백엔드 개발자들은 늘 이런 귀찮은 일을 하고 있었던 것인가...... 어쨌든 메뉴 크롤링은 파이썬만 설치하면 바로 실행할 수 있는 selenium을 사용했고 크롤링해오는 코드는 gemini에게 부탁했다. 그런데 공공데이터의 카페 데이터와 네이버 지도의 카페 데이터를 매칭시키는 과정이 생각보다 쉽지 않았다. 업체명만 검색하면 너무 많은 데이터가 나와서 주소 일부 + 업체명으로 전략을 바꿨다. 하지만 이 업체명이라는 것도 공공데이터에 등록된 정보와 네이버 지도에 등록된 정보가 다른 경우가 다수 있고, 해당 업체를 네이버지도에서 찾지 못하거나 주소만 검색되거나 검색 결과가 없거나 하는 케이스가 있어서 검색어를 요리조리 돌려가면서 재검색하도록 시키는 방법 밖에 없었다.

 

진심 집인데 집에 가고싶다

 

최종적으로 AI와 함께 완성한 코드는 아래와 같다.

 

import time
import re
import math
import random
import pymysql
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import InvalidSessionIdException, WebDriverException

# [DB 설정] 전하의 접속 정보로 수정하시옵소서.
db_config = {}

def get_query(address, name):
    match = re.search(r'([가-힣]+로\s?\d+)', address)
    return f"{match.group(1)} {name}" if match else f"{name}"

def format_to_time_sql(time_str):
    if not time_str: return None
    parts = time_str.split(':')
    if len(parts) == 2:
        return f"{time_str}:00"
    return time_str

def create_driver():
    """브라우저 세션을 새로 생성하옵니다."""
    options = Options()
    options.add_argument('--blink-settings=imagesEnabled=false')
    options.add_argument('user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
    options.add_argument('window-size=1920,1080')
    options.add_experimental_option("excludeSwitches", ["enable-automation"])
    
    driver = webdriver.Chrome(options=options)
    driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
    return driver

def run_resilient_crawler():
    driver = create_driver()
    wait = WebDriverWait(driver, 8)

    conn = pymysql.connect(**db_config)
    cursor = conn.cursor(pymysql.cursors.DictCursor)

    # [수정] 변경된 테이블명 cafes 반영 및 컬럼 확인
    try:
        cursor.execute("SHOW COLUMNS FROM cafes LIKE 'crawl_status'")
        if not cursor.fetchone():
            cursor.execute("ALTER TABLE cafes ADD COLUMN crawl_status VARCHAR(20) DEFAULT 'PENDING'")
            conn.commit()
    except Exception as e:
        print(f" - [컬럼 확인 에러]: {e}")

    # [전하의 명: 하루 1천 개 분할 실행]
    cursor.execute("SELECT id, name, address FROM cafes WHERE crawl_status = 'PENDING' LIMIT 1000")
    cafes = cursor.fetchall()
    
    print(f"--- 금일 원정 시작 (대상: {len(cafes)}개) ---")

    for cafe in cafes:
        cafe_id = int(cafe['id'])
        original_name = cafe['name']
        original_address = cafe['address']
        
        # 세션 체크 및 복구 로직
        try:
            driver.current_url
        except (InvalidSessionIdException, WebDriverException):
            print(" - [경고] 드라이버 세션이 만료되어 재시작하옵니다.")
            driver.quit()
            driver = create_driver()
            wait = WebDriverWait(driver, 8)

        addr_match = re.search(r'([가-힣]+로\s?\d+)', original_address)
        target_road_address = addr_match.group(1) if addr_match else ""
        search_query = get_query(original_address, original_name)
        
        print(f"\n[작업 시작] ID {cafe_id}: {original_name}")

        try:
            time.sleep(random.uniform(3.5, 6.5))
            driver.get(f"https://map.naver.com/p/search/{search_query}")
            time.sleep(5)

            # --- [1단계: 검색 및 재검색 로직 (Case A~D)] ---
            driver.switch_to.default_content()
            is_fail_abc = driver.find_elements(By.CSS_SELECTOR, "p.correction_result_text, div.title_wrap_box, ul.listbox")
            
            is_fail_d = False
            if not is_fail_abc and driver.find_elements(By.ID, "searchIframe"):
                driver.switch_to.frame("searchIframe")
                first_titles = driver.find_elements(By.CSS_SELECTOR, "li.VLTHu span.YwYLL")
                if first_titles and original_name not in first_titles[0].text:
                    is_fail_d = True
                driver.switch_to.default_content()

            if is_fail_abc or is_fail_d:
                driver.get(f"https://map.naver.com/p/search/{original_name}")
                time.sleep(5)
                
                driver.switch_to.default_content()
                if driver.find_elements(By.CSS_SELECTOR, "p.correction_result_text") or \
                   (driver.find_elements(By.ID, "searchIframe") and (driver.switch_to.frame("searchIframe") or True) and driver.find_elements(By.CSS_SELECTOR, "div.FYvSc")):
                    if "메가" in original_name:
                        mega_name = re.sub(r'메가엠지씨커피|메가MGC커피', '메가커피', original_name)
                        driver.switch_to.default_content()
                        driver.get(f"https://map.naver.com/p/search/{mega_name}")
                        time.sleep(5)

                driver.switch_to.default_content()
                if driver.find_elements(By.ID, "searchIframe"):
                    driver.switch_to.frame("searchIframe")
                    items = driver.find_elements(By.CSS_SELECTOR, "li.VLTHu")
                    found_target = False
                    for item in items:
                        try:
                            addr_btn = item.find_element(By.CSS_SELECTOR, "a.uFxr1")
                            driver.execute_script("arguments[0].click();", addr_btn)
                            time.sleep(1)
                            road_text = item.find_element(By.CSS_SELECTOR, "span.hAvkz").text
                            if target_road_address in road_text:
                                detail_btn = item.find_element(By.CSS_SELECTOR, "a.C6RjW")
                                driver.execute_script("arguments[0].click();", detail_btn)
                                time.sleep(4)
                                found_target = True
                                break
                            else:
                                driver.execute_script("arguments[0].click();", item.find_element(By.CSS_SELECTOR, "a.yVGVT"))
                        except: continue
                    if not found_target:
                        cursor.execute("UPDATE cafes SET crawl_status = 'NOT_FOUND' WHERE id = %s", (cafe_id,))
                        conn.commit()
                        continue

            # --- [2단계: 상세페이지 진입] ---
            driver.switch_to.default_content()
            try:
                wait.until(EC.presence_of_element_located((By.ID, "entryIframe")))
                driver.switch_to.frame("entryIframe")
            except: 
                print(" - [건너뜀] entryIframe 로딩 실패")
                continue

            # --- [3단계: 영업시간 수집 (business_hours 테이블)] ---
            try:
                time_btns = driver.find_elements(By.CSS_SELECTOR, "a.gKP9i")
                if time_btns:
                    driver.execute_script("arguments[0].click();", time_btns[0])
                    time.sleep(1.2)
                    rows = driver.find_elements(By.CSS_SELECTOR, "div.w9QyJ")
                    for row in rows[1:]:
                        day_of_week = row.find_element(By.CSS_SELECTOR, "span.i8cJw").text[0]
                        is_off = "undefined" in row.get_attribute("class")
                        raw_text = row.find_element(By.CSS_SELECTOR, "div.H3ua4").text.replace("\n", " ")
                        
                        open_t, close_t, s_break, e_break = None, None, None, None
                        if not is_off:
                            times = re.findall(r'\d{1,2}:\d{2}', raw_text)
                            if len(times) >= 2:
                                open_t = format_to_time_sql(times[0])
                                close_t = format_to_time_sql(times[1])
                            if "브레이크타임" in raw_text and len(times) >= 4:
                                s_break = format_to_time_sql(times[2])
                                e_break = format_to_time_sql(times[3])

                        cursor.execute("""
                            INSERT INTO business_hours (cafe_id, day_of_week, open_time, close_time, start_break_time, end_break_time)
                            VALUES (%s, %s, %s, %s, %s, %s)
                        """, (cafe_id, day_of_week, open_t, close_t, s_break, e_break))
            except: pass

            # --- [4단계: 메뉴 수집 (menu_items 테이블)] ---
            tabs = driver.find_elements(By.CSS_SELECTOR, "a.tpj9w, a.sp_main_tab")
            menu_tab = next((tab for tab in tabs if "메뉴" in tab.text), None)
            if menu_tab:
                driver.execute_script("arguments[0].click();", menu_tab)
                time.sleep(5)
                
                menu_dict = {}
                if driver.find_elements(By.CSS_SELECTOR, "div.OrderHome__order_list__JaVas"):
                    container = driver.find_element(By.CSS_SELECTOR, "div.OrderHome__order_list__JaVas")
                    total_h = driver.execute_script("return arguments[0].scrollHeight", container)
                    for _ in range(math.ceil(total_h / 800) + 3):
                        driver.execute_script("window.scrollBy(0, 800);")
                        time.sleep(0.5)
                    time.sleep(2)
                    for wrap in driver.find_elements(By.CSS_SELECTOR, "div.OrderHome__order_list_wrap__0eZSI"):
                        for item in wrap.find_elements(By.CSS_SELECTOR, "li.MenuContent__order_list_item__itwHW"):
                            try:
                                m_name = item.find_element(By.CSS_SELECTOR, "div.MenuContent__tit__313LA").text
                                m_price = item.find_element(By.CSS_SELECTOR, "div.MenuContent__price__lhCy9 strong").text
                                if m_name: menu_dict[m_name] = m_price
                            except: continue
                else:
                    while True:
                        m_btns = [b for b in driver.find_elements(By.CSS_SELECTOR, "a.fvwqf") if b.is_displayed()]
                        if not m_btns: break
                        for b in m_btns:
                            driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", b)
                            time.sleep(0.5); driver.execute_script("arguments[0].click();", b); time.sleep(1.2)
                    for sec in driver.find_elements(By.CSS_SELECTOR, "div.place_section_content"):
                        for it in sec.find_elements(By.CSS_SELECTOR, "li.E2jtL"):
                            try:
                                m_name = it.find_element(By.CSS_SELECTOR, "span.lPzHi").text
                                m_price = it.find_element(By.CSS_SELECTOR, "div.GXS1X em").text
                                if m_name: menu_dict[m_name] = m_price
                            except: continue
                
                for m_name, m_price in menu_dict.items():
                    price_val = int(re.sub(r'[^0-9]', '', m_price)) if m_price and re.sub(r'[^0-9]', '', m_price) else 0
                    cursor.execute("INSERT INTO menu_items (cafe_id, name, price) VALUES (%s, %s, %s)", (cafe_id, m_name, price_val))

            # --- [5단계: 성공 처리] ---
            cursor.execute("UPDATE cafes SET crawl_status = 'SUCCESS', updated_at = NOW() WHERE id = %s", (cafe_id,))
            conn.commit()

        except Exception as e:
            print(f" - [에러] ID {cafe_id}: {e}")
            # 단순 에러 시에도 상태를 기록하여 무한 루프 방지
            cursor.execute("UPDATE cafes SET crawl_status = 'FAILED' WHERE id = %s", (cafe_id,))
            conn.commit()
        
        driver.switch_to.default_content()

    driver.quit()
    conn.close()
    print("\n--- 금일 수집 종료 ---")

if __name__ == "__main__":
    run_resilient_crawler()

 

여기까지 진행하고 나서야 내 방식이 틀렸음을 인정하기로 했다...... 왜냐면 테스트로 1000개 뽑아봤는데 검색어를 보정해야 하는 케이스가 너무 많아서 속도가 나지 않았고 보정 후 재검색해도 업체를 못 찾는 경우가 많았기 때문이다. 업체와 업체의 메뉴를 뽑고 싶었으면 굳이 타 사이트에서 데이터를 가져와 맞춰서 메뉴를 뽑는 것보다 동일한 플랫폼에서 데이터를 가져오는 것이 더 효율적이고 공수도 덜 들고 출처도 명확한 것이 아니었을까......

 

그래서 어떻게 할 거냐면...

 

 

지금까지 느낀 점을 종합해서 도달한 결론에 대해 공유하겠다.

 

1. 플랫폼을 확실하게 정한다.

- 플랫폼에서 ○○역 카페, 혹은 ○○구 카페로 검색한다.

- 각 플랫폼의 데이터 수를 비교한다.

 

2. 해당하는 플랫폼의 결괏값을 가져오는 방법을 생각해 본다.

- 현재 진행했던 것처럼 일반적으로 카페 리스트를 전부 크롤링하는 방법이 있을 것 같다.

- 가령 네이버 지도의 네트워크를 보면 allSearch라고 하는 api를 호출해서 리스트를 보여주는 것을 알 수 있다. 이 api는 외부에서 요청할 수 없게 막아뒀는데 요청만 브라우저를 띄워서 하고 응답 값만 쏙 빼올 수 있는 방법이 있지 않을까? 만약 이 방법이 가능하다면 카페의 id값을 가져올 수 있기 때문에 크롤링하는 난이도도 더 수월해지지 않을까 생각이 든다.

 

근데 이게 바로 탈취 아닌가...? 윤리적으로 문제가 되는 부분이라면 당연히 시도하지 않겠지만 여러 가지 방법 중의 하나로 제시해본다

 

3. 크롤링하는 방식 변경

- 현재는 selenium을 사용했는데, 내가 카페 검색 후 sleep 시키는 구간이 많아서 그런지 개인적으로 느리다는 인상이 강했다. 이런 부분을 개선할 방법이 없는지 찾아봤는데 브라우저를 여러 개 띄워서 수집하거나 비동기 처리가 가능한 라이브러리를 사용해서 가능하다고 하는데, 이렇게 시도할 경우 네이버 측에서 내 PC를 차단시킬 가능성이 있을 것 같아 이 부분은 조금 더 고려가 필요해 보인다.