멀티캠퍼스 데이터분석 6월 23일 수업 내용 — 재귀 함수 이론 및 응용, 소수 나열 최적화, requests/BeautifulSoup/Selenium 크롤링
재귀 함수
1. 재귀 함수 이론 & 기본 예제
2. 재귀 함수 응용 — 평탄화 & 메뉴 데이터 & 퀸 문제
소수 알고리즘
웹 크롤링
| 구성 요소 | 설명 |
|---|---|
| 기동 조건 | 언제 멈출 것인가 (재귀 탈출 조건) |
| 재귀 단계 | 문제를 더 작은 형태로 쪼개어 자기 자신을 호출 |
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'
중첩 깊이가 일정하지 않은 리스트도 재귀로 처리합니다.
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루프로 중첩 리스트를 처리하면 깊이가 고정되어야 합니다.
재귀를 사용하면 깊이에 상관없이 어떤 구조도 평탄화할 수 있습니다.
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())
중첩된 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))
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로 넘어가며 자동으로 이전 상태로 복원됩니다.
소수 — 자기 자신과 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))
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)
소수가 아닌 수는 소수들의 곱이므로, 이미 찾은 소수로만 나눠도 충분합니다.
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)
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 범위만 검사 (최적) |
# !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
| 태그 | 역할 |
|---|---|
<head> | 외부 파일 참조, meta data, 탭 이름 |
<body> | 실제 화면 내용 — 크롤링의 핵심 |
<footer> | 페이지 하단 — 회사 정보 등 |
| 기능 | 설명 |
|---|---|
requests.get(url) | HTTP GET 요청 & 응답 수신 |
params={} | URL에 데이터를 붙여서 전송 |
headers={} | 요청 헤더에 데이터 포함 |
res.text | 응답 HTML 텍스트 |
res.json() | 응답 JSON → dict 변환 |
| 함수 | 반환 타입 | 설명 |
|---|---|---|
find(태그, attrs={}) | Tag | 조건에 맞는 첫 번째 태그 |
find_all(태그) | ResultSet | 조건에 맞는 모든 태그 리스트 |
태그.get_text() | str | 현재 태그 + 자식 태그 텍스트 |
태그.string | str | 현재 태그의 텍스트만 |
태그['속성명'] | str | 태그의 특정 속성 값 (예: href) |
| 기능 | 설명 |
|---|---|
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을 수집할 수 있습니다.
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)
비동기 방식 페이지는 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를 가져오면 빈 페이지가 될 수 있습니다.
짧은 대기 시간으로 로딩을 기다립니다.
# !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 ~ else | for문이 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 테이블에 저장 |