재귀 함수 & 소수 알고리즘 & 웹 크롤링 정리

syeom·2026년 6월 28일

멀티캠퍼스 데이터분석 6월 23일 수업 내용 — 재귀 함수 이론 및 응용, 소수 나열 최적화, requests/BeautifulSoup/Selenium 크롤링


📌 목차

재귀 함수
1. 재귀 함수 이론 & 기본 예제
2. 재귀 함수 응용 — 평탄화 & 메뉴 데이터 & 퀸 문제

소수 알고리즘

  1. 소수 나열 — 4단계 최적화

웹 크롤링

  1. 크롤링 이론 — requests & BeautifulSoup & Selenium
  2. 네이버 증권 뉴스 크롤링 실습
  3. DB 저장 — SQLAlchemy

재귀 함수

1. 재귀 함수 이론 & 기본 예제

  • 함수가 자기 자신을 다시 호출하는 방법
  • 반드시 두 가지를 정의해야 합니다:
구성 요소설명
기동 조건언제 멈출 것인가 (재귀 탈출 조건)
재귀 단계문제를 더 작은 형태로 쪼개어 자기 자신을 호출

카운트다운

def countdown(n):
    if n == 0:
        print('발사')
        return       # 기동 조건: n이 0이면 종료

    print(n)
    countdown(n - 1)  # 재귀 단계: n-1로 자기 자신 호출

countdown(3)
# 출력: 3 2 1 발사

리스트 합계

_list = [1, 2, 3, 4, 5]

def sum_list(arr):
    if len(arr) == 0:  # 기동 조건
        return 0
    return arr[0] + sum_list(arr[1:])  # 첫 원소 + 나머지 합산

sum_list(_list)  # 15

피보나치 수열

def fibonacci(n):
    if n == 1 or n == 2:  # 기동 조건
        return 1
    return fibonacci(n - 1) + fibonacci(n - 2)

fibonacci(5)  # 5
# 피보나치 수열: 1, 1, 2, 3, 5, 8, 13, 21, ...

문자열 뒤집기

def reverse_custom(data):
    if len(data) == 0:   # 기동 조건
        return data
    return data[-1] + reverse_custom(data[:-1])

reverse_custom('python')  # 'nohtyp'

2. 재귀 함수 응용 — 평탄화 & 메뉴 데이터 & 퀸 문제

중첩 리스트 평탄화 (flat)

중첩 깊이가 일정하지 않은 리스트도 재귀로 처리합니다.

list_2 = [1, [2, 3], [4, [5, 6]], 7]

def flat(data):
    result = []
    for item in data:
        if type(item) == list:
            result += flat(item)   # 리스트면 재귀 호출해서 풀기
        else:
            result.append(item)
    return result

flat(list_2)   # [1, 2, 3, 4, 5, 6, 7]

list_3 = [1, [2, 3], [4, [5, 6, [8, 9]]], 7]
flat(list_3)   # [1, 2, 3, 4, 5, 6, 8, 9, 7]

💡 for 루프로 중첩 리스트를 처리하면 깊이가 고정되어야 합니다.
재귀를 사용하면 깊이에 상관없이 어떤 구조도 평탄화할 수 있습니다.

로또 번호 생성 — 재귀 + global

import random

lotto_list = list(range(1, 46))

def lotto(num_list=[]):
    global lotto_list   # 전역 변수 접근

    if len(num_list) == 6:   # 기동 조건: 6개가 모이면 종료
        return num_list

    random_int = random.choice(lotto_list)
    if random_int not in num_list:
        num_list.append(random_int)

    return lotto(num_list)   # 재귀 단계

print(lotto())

API 응답(dict) 평탄화 — 메뉴 데이터 파싱

중첩된 dict/list 구조의 API 응답을 재귀로 DataFrame으로 변환합니다.

import pandas as pd

api_response = {
    "store_name": "맛있는 치킨 본점",
    "categories": [
        {
            "category_name": "추천 메뉴",
            "items": [
                {"item_name": "황금 올리브 치킨", "price": 20000},
                {"item_name": "양념 치킨",        "price": 21000}
            ]
        },
        {
            "category_name": "사이드 메뉴",
            "items": [
                {
                    "item_name": "치즈볼 세트", "price": 5000,
                    "options": [
                        {"option_name": "크림치즈 변경",   "price": 5500},
                        {"option_name": "모짜렐라 곱빼기", "price": 6000}
                    ]
                },
                {"item_name": "감자튀김", "price": 4000}
            ]
        }
    ]
}

def flat_menu_data(data, menu_name='일반 메뉴'):
    result = []

    if type(data) == dict:
        if 'item_name' in data:
            menu_name = data['item_name']

        # 메뉴 행 추가
        if 'item_name' in data:
            result.append({'이름': menu_name, '가격': data['price']})
        elif 'option_name' in data:
            result.append({'이름': f"{menu_name} ( {data['option_name']} )", '가격': data['price']})

        # 하위에 list/dict가 있으면 재귀 호출
        for key, value in data.items():
            if isinstance(value, (list, dict)):
                result.extend(flat_menu_data(value, menu_name))

    elif type(data) == list:
        for item in data:
            result.extend(flat_menu_data(item, menu_name))

    return result

pd.DataFrame(flat_menu_data(api_response))

N-Queens 문제 — 백트래킹

num = 4
pos = [0] * num
cnt = 0

def is_safe(row, col):
    """현재 위치에 퀸을 놓아도 안전한지 확인"""
    for i in range(row):
        if pos[i] == col:                      # 같은 열에 퀸이 있으면 안전하지 않음
            return False
        if abs(pos[i] - col) == abs(i - row): # 대각선에 퀸이 있으면 안전하지 않음
            return False
    return True

def solve_queens(row):
    global cnt
    if row == num:   # 기동 조건: 모든 행에 배치 완료
        cnt += 1
        print(f'{cnt}번째 정답: {pos}')
        return

    for col in range(num):
        if is_safe(row, col):
            pos[row] = col
            solve_queens(row + 1)   # 재귀 단계: 다음 행으로
            # 실패하면 자동으로 다음 col로 넘어가며 이전 상태 복원 (백트래킹)

solve_queens(0)
print('총 경우의 수:', cnt)

💡 백트래킹(Backtracking) — 잘못된 경로에서 돌아와 다시 탐색합니다.
solve_queens(row + 1) 이 실패하면 for문의 다음 col 로 넘어가며 자동으로 이전 상태로 복원됩니다.


소수 알고리즘

3. 소수 나열 — 4단계 최적화

소수 — 자기 자신과 1 이외에는 나누어 떨어지지 않는 수

1단계 — 기본 방식

cnt, prime_list = 0, []

for n in range(2, 1001):
    for i in range(2, n):
        cnt += 1
        if n % i == 0:
            break
    else:                    # for문이 break 없이 끝나면 소수
        prime_list.append(n)

print('나눗셈 횟수:', cnt)
print('소수 개수:', len(prime_list))

2단계 — 짝수 건너뛰기 (2 제외)

cnt, prime_list = 0, [2]

for n in range(3, 1001, 2):   # 홀수만 검사
    for i in range(2, n):
        cnt += 1
        if n % i == 0:
            break
    else:
        prime_list.append(n)

3단계 — 기존 소수로만 나누기

소수가 아닌 수는 소수들의 곱이므로, 이미 찾은 소수로만 나눠도 충분합니다.

cnt, prime_list = 0, [2]

for n in range(3, 1001, 2):
    for i in range(1, len(prime_list)):   # 기존에 찾은 소수들로만 나눔
        cnt += 1
        if n % prime_list[i] == 0:
            break
    else:
        prime_list.append(n)

4단계 — 제곱근 이하 소수만 확인

n의 약수는 반드시 √n 이하에 존재합니다. 따라서 prime[i]² > n 이면 탐색 불필요합니다.

cnt, prime_list = 0, [2, 3]

for n in range(5, 1001, 2):
    i = 1
    while prime_list[i] ** 2 <= n:   # 제곱근 이하 소수만 검사
        cnt += 2
        if n % prime_list[i] == 0:
            break
        i += 1
    else:
        prime_list.append(n)
        cnt += 1

print('나눗셈 횟수:', cnt)
print('소수 개수:', len(prime_list))

최적화 단계별 비교

방법핵심 아이디어
1단계: 기본2 ~ n-1 전체 나눗셈
2단계: 짝수 제외홀수만 검사 (2배 절약)
3단계: 소수로만기존 소수로만 나눔
4단계: 제곱근prime[i]² ≤ n 범위만 검사 (최적)

웹 크롤링

4. 크롤링 이론 — requests & BeautifulSoup & Selenium

# !pip install bs4 requests selenium
import requests
from bs4 import BeautifulSoup as bs
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
import pandas as pd

HTML 문서 구조

태그역할
<head>외부 파일 참조, meta data, 탭 이름
<body>실제 화면 내용 — 크롤링의 핵심
<footer>페이지 하단 — 회사 정보 등

requests 주요 기능

기능설명
requests.get(url)HTTP GET 요청 & 응답 수신
params={}URL에 데이터를 붙여서 전송
headers={}요청 헤더에 데이터 포함
res.text응답 HTML 텍스트
res.json()응답 JSON → dict 변환

BeautifulSoup 주요 함수

함수반환 타입설명
find(태그, attrs={})Tag조건에 맞는 첫 번째 태그
find_all(태그)ResultSet조건에 맞는 모든 태그 리스트
태그.get_text()str현재 태그 + 자식 태그 텍스트
태그.stringstr현재 태그의 텍스트만
태그['속성명']str태그의 특정 속성 값 (예: href)

Selenium 주요 기능

기능설명
webdriver.Chrome()Chrome 브라우저 제어 객체 생성
driver.get(url)특정 URL로 이동
driver.page_source현재 페이지의 HTML 전체를 문자열로 반환
find_element(By.ID, id)조건에 맞는 첫 번째 태그 선택
find_elements(By.CLASS_NAME, cls)조건에 맞는 모든 태그 선택
태그.send_keys(텍스트)텍스트 입력 이벤트
태그.click()마우스 좌클릭 이벤트
driver.window_handles열린 탭들의 주소 목록
driver.switch_to.window(handle)특정 탭으로 이동
driver.execute_script(js)JavaScript 코드 실행

💡 Selenium을 쓰는 이유
requests는 서버에 정적 HTML을 요청하지만, 현대 웹페이지는 JavaScript로 비동기(Ajax) 방식으로 콘텐츠를 불러옵니다.
Selenium은 실제 브라우저를 제어하여 JS가 실행된 최종 DOM을 수집할 수 있습니다.


5. 네이버 증권 뉴스 크롤링 실습

1단계: requests로 뉴스 목록 수집

res2  = requests.get('http://finance.naver.com')
soup2 = bs(res2.text, 'html.parser')

# class='news_area'인 div 태그 찾기
div_tag = soup2.find('div', attrs={'class': 'news_area'})

# 뉴스 목록 li 태그 전체
li_tags = div_tag.find_all('li')

# 텍스트 추출
titles = [li.get_text().strip() for li in li_tags]

# 제목 + URL 함께 수집
base_url = 'http://finance.naver.com'
data = []
for li in li_tags:
    a_href = li.find('a')['href']   # a 태그의 href 속성 추출
    text   = li.get_text().strip()
    data.append({'title': text, 'url': base_url + a_href})

df = pd.DataFrame(data)

2단계: Selenium으로 뉴스 본문 수집

비동기 방식 페이지는 requests로 본문을 가져올 수 없으므로 Selenium을 사용합니다.

import time

driver = webdriver.Chrome()
time.sleep(0.5)

content_list = []
for url in df['url'].tolist():
    driver.get(url)
    time.sleep(0.5)   # 페이지 로딩 대기

    html_text = driver.page_source
    soup4     = bs(html_text, 'html.parser')

    content   = soup4.find('div', attrs={'id': 'newsct_article'}).get_text().replace('\n', '')
    content_list.append(content)

driver.close()
df['content'] = content_list

💡 time.sleep()이 필요한 이유
브라우저가 페이지를 완전히 로딩하기 전에 page_source를 가져오면 빈 페이지가 될 수 있습니다.
짧은 대기 시간으로 로딩을 기다립니다.


6. DB 저장 — SQLAlchemy

# !pip install pymysql sqlalchemy
from sqlalchemy import create_engine
from urllib.parse import quote_plus

password  = '1234'
safe_pass = quote_plus(password)   # 비밀번호에 특수문자 포함 시 URL 인코딩 필수

engine = create_engine(
    f'mysql+pymysql://root:{safe_pass}@localhost:3306/multicam'
)

df.to_sql(
    name      = 'finance_naver_news',
    con       = engine,
    if_exists = 'append'   # 테이블이 이미 있으면 추가, 없으면 생성
)

💡 quote_plus()가 필요한 이유
DB 비밀번호에 @, #, ! 같은 특수 문자가 포함되면 URL 파싱 오류가 발생합니다.
urllib.parse.quote_plus() 로 URL 안전 문자로 인코딩합니다.


📎 핵심 개념 요약

개념설명
재귀 함수함수가 자기 자신을 다시 호출
기동 조건재귀 종료 조건 — 반드시 필요
재귀 단계문제를 더 작은 형태로 쪼개어 재호출
백트래킹잘못된 경로에서 돌아와 다른 경로 탐색
arr[1:]첫 원소를 제외한 나머지 리스트 슬라이싱
data[-1]마지막 원소
data[:-1]마지막 원소를 제외한 나머지
N-Queens퀸이 서로 공격 못하는 배치 경우의 수 — 백트래킹
소수 기본2 ~ n-1 전체 나눗셈
소수 최적화짝수 제외 → 소수로만 나눔 → 제곱근 이하만 검사
for ~ elsefor문이 break 없이 끝나면 else 실행
prime[i]**2 <= n소수 검사 범위 제한 (제곱근 이하)
requests.get()HTTP GET 요청
bs(html, 'html.parser')HTML 파싱
find(태그, attrs)첫 번째 태그 탐색
find_all()모든 태그 탐색 (ResultSet 반환)
태그['href']태그의 속성 값 추출
get_text().strip()태그 내 텍스트 추출 후 공백 제거
driver.page_source현재 페이지 HTML 전체 문자열 반환
time.sleep()페이지 로딩 대기
quote_plus()특수 문자 포함 비밀번호 URL 인코딩
df.to_sql(if_exists='append')DataFrame을 DB 테이블에 저장
profile
공부 기록

0개의 댓글