이번 주 학습 내용
SQL은 조회 위주였던 지난주를 넘어 데이터를 직접 넣고 고치고 지우는 DML을 다뤘고, Python에서 MySQL에 붙는 법까지 이어졌다. 후반부는 완전히 새로운 영역인 웹 크롤링이었다. 공공 API와 오픈 API로 데이터를 받아오는 것부터 시작해서, 정적 페이지를 파싱하고 동적 페이지를 브라우저로 직접 조작하는 것까지 한 주 만에 쭉 훑었다.
01. SQL — INSERT, UPDATE, DELETE
DML(Data Manipulation Language)로 실제 데이터를 다루는 문법을 익혔다.
INSERT INTO 테이블(컬럼1, 컬럼2, ...) VALUES (...)— 컬럼을 명시하면 순서를 바꿔 넣거나 일부만 넣을 수 있다. 여러 행을 한 번에 넣을 때는VALUES뒤에 콤마로 이어서 작성UPDATE 테이블 SET 컬럼 = 값 WHERE 조건—WHERE를 빼먹으면 전체 행이 바뀐다. 실습 중에도 몇 번 조건을 다시 확인하고 실행했다DELETE FROM 테이블 WHERE 조건—ORDER BY+LIMIT을 붙이면 정렬 기준으로 상위 N개만 지울 수 있다
UPDATE의 SET 값으로 서브쿼리를 바로 쓰면 에러가 난다는 걸 이번에 알게 됐다. 자세한 내용은 트러블슈팅에 정리했다.
02. SQL — REPLACE와 데이터 타입 변환
REPLACE — PK가 겹치면 기존 행을 지우고 새로 넣고, 겹치지 않으면 그냥 INSERT처럼 동작한다. INSERT를 썼다면 중복 키 에러가 났을 자리에 REPLACE는 조용히 값을 갱신해준다.
-- menu_code = 17이 이미 있으면 삭제 후 재삽입, 없으면 그냥 삽입
REPLACE INTO tbl_menu VALUES (17, '참기름소주', 5000, 10, 'Y');
-- SET 구문으로도 사용 가능
REPLACE tbl_menu
SET menu_code = 2, menu_name = '우럭쥬스', menu_price = 2000,
category_code = 9, orderable_status = 'N';데이터 타입 변환 — CAST(값 AS 타입)과 CONVERT(값, 타입)은 결과가 같고 문법만 다르다. 재밌었던 건 MySQL이 상황에 맞게 알아서 형을 바꿔주는 암묵적 변환이었다.
'1' + '2'— 숫자로 변환되어35 > '6MAY'— 문자열 앞의 숫자만 읽어6으로 비교5 > 'M6AY'— 숫자로 시작하지 않으면0으로 취급'2023-5-30'처럼 날짜로 읽힐 수 있는 문자열은 필요할 때 DATE로 변환된다
03. SQL — 문자열·숫자·날짜 내장 함수
실무에서 자주 쓸 법한 내장 함수를 종류별로 훑었다. 하나하나 외우기보다 필요할 때 찾아 쓸 수 있게 존재를 아는 게 목적이었다.
- 문자열 —
CONCAT_WS(구분자, ...)로 구분자 넣어 결합,LPAD/RPAD로 자리수 맞추기,SUBSTRING_INDEX(문자열, 구분자, n)로 이메일에서 도메인만 잘라내기 - 숫자 —
CEILING/FLOOR/ROUND반올림 계열,TRUNCATE(값, 자릿수)는 반올림 없이 그냥 잘라냄,MOD와%는 동일하게 동작 - 날짜 —
ADDDATE/SUBDATE로 날짜 연산,DATEDIFF로 날짜 차이 계산,NOW()와CURRENT_TIMESTAMP는 동일한 값을 반환
SELECT SUBSTRING_INDEX('hong.test@gmail.com', '.', -2);
-- test@gmail.com — 뒤에서부터 구분자 2개 기준으로 자름04. Python으로 MySQL 연동하기
mysql.connector로 Python 코드에서 직접 데이터베이스에 접속하고 쿼리를 실행하는 법을 익혔다.
mysql.connector.connect(host, user, password, database)로 연결 객체 생성,connection.cursor()로 쿼리 실행용 커서 생성cursor.execute(sql, values)—%s플레이스홀더에 값을 안전하게 바인딩. 문자열을 직접 이어붙이지 않아도 된다- INSERT/UPDATE/DELETE는
connection.commit()을 호출해야 실제로 반영된다. 조회는cursor.fetchall()로 결과를 받는다 with connection.cursor() as cursor:형태로 쓰면 블록이 끝날 때 커서와 커넥션이 자동으로 정리된다.close()를 매번 손으로 호출하지 않아도 돼서 훨씬 깔끔했다
비밀번호를 코드에 그대로 적지 않고 .env 파일에 넣은 뒤 python-dotenv의 load_dotenv()와 os.getenv()로 불러오는 방식도 함께 익혔다.
from dotenv import load_dotenv
import os
load_dotenv()
db_password = os.getenv('DB_PASSWORD')
with mysql.connector.connect(
host="localhost", user="root",
password=db_password, database="python_test"
) as connection:
with connection.cursor() as cursor:
sql = "UPDATE users SET email = %s WHERE name = %s"
cursor.execute(sql, ("new_encore@example.com", "Encore"))
connection.commit()05. Open API로 데이터 수집하기
여기서부터 크롤링 주간이 시작됐다. 첫 단계는 사이트를 직접 긁는 대신, 서비스가 제공하는 API를 통해 정제된 데이터를 받아오는 것이었다.
- 영화진흥위원회 박스오피스 API —
requests.get(url, params=params)로 요청하고response.json()으로 바로 dict를 받는다.response.raise_for_status()로 HTTP 에러를 예외로 띄울 수 있다는 게 편했다 - 받은 데이터를
csv.writer로 저장한 뒤pandas로 다시 불러와dropna(),pd.to_datetime()으로 정제하는 흐름까지 이어졌다 - 네이버 도서 검색 API —
X-Naver-Client-Id,X-Naver-Client-Secret헤더에 인증 정보를 실어 요청한다.display는 한 번에 받을 개수,start는 시작 위치라서start를 100씩 늘려가며 반복 요청하면 100개 제한을 넘어서 전체 데이터를 모을 수 있다
total_count = response_body['total']
start_num = 1
loop_count = total_count // 100 + 1
book_list = []
for i in range(loop_count):
url = base_url + f'&start={start_num}'
# ... 요청 후 book_list += response_body['items']
start_num += 100
if start_num > 1000: break06. 정적 웹 크롤링 — requests와 BeautifulSoup
API가 없는 페이지는 HTML을 직접 받아 파싱해야 한다. 요청한 URL의 응답이 그대로 최종 화면인 정적 페이지를 requests로 받아 BeautifulSoup으로 뜯었다.
find(태그, {속성})— 조건에 맞는 첫 번째 태그 하나만 반환.find_all()은 전체를 리스트로 반환select_one(CSS선택자)/select(CSS선택자)— id, class, 자식 선택자(>)까지 CSS 문법 그대로 쓸 수 있어서 훨씬 손에 익었다.find_parent()로 자식 태그에서 부모 태그로 거슬러 올라갈 수 있다. 텍스트는 있는데 링크는 부모<a>태그에 있는 경우에 유용했다
이 문법으로 네이버 뉴스 검색 결과에서 제목·링크·썸네일을 추출하고 urlretrieve()로 이미지까지 저장해봤고, 벅스뮤직 차트 페이지에서 곡 정보와 앨범 이미지를 같은 방식으로 긁어봤다.
news_contents = bs.select('.fds-news-item-list-tab > div')
for content in news_contents:
title_tag = content.select_one('span.sds-comps-text-type-headline1')
href_tag = title_tag.find_parent('a')
title, href = title_tag.text, href_tag['href']07. 동적 웹 크롤링 — Selenium
검색 결과나 스크롤로 계속 불러오는 페이지는 최초 응답 HTML에 원하는 내용이 없다. Selenium으로 실제 브라우저를 띄워서 사람처럼 조작한 뒤 그 결과 화면을 읽어야 했다.
webdriver.Chrome(service=Service('chromedriver.exe'))로 크롬을 직접 실행.driver.get(url)로 페이지 이동find_element(By.ID, ...)/find_elements(By.CSS_SELECTOR, ...)— 단수/복수 조회를 구분해서 사용send_keys('검색어')+send_keys(Keys.RETURN)으로 검색창에 입력하고 엔터까지 흉내- 무한 스크롤 페이지는
body.send_keys(Keys.END)를 반복해서 화면 끝까지 내려야 다음 콘텐츠가 로드된다 - 페이지네이션이 있는 경우
next_btn.click()을 반복하며 여러 페이지를 순회. 매 동작 사이에time.sleep()으로 로딩을 기다려줘야 요소를 못 찾는 일이 줄어든다
이 방식으로 네이버 뉴스 검색 결과의 제목·링크를 스크롤하며 모으고, 네이버 영화 검색 결과에서 페이지를 넘겨가며 상영작 이미지를 내려받는 것까지 실습했다.
08. 첫 팀 프로젝트
수업과 별개로 이번 주부터 첫 팀 프로젝트도 함께 진행했다. 전기차 구매를 고려하는 사용자에게 지역별 보조금·충전소·정비소 정보와 제조사 FAQ를 한 곳에서 보여주는 서비스로, 이번 주에 배운 크롤링과 MySQL 연동을 곧바로 실전에 써볼 수 있었다. 나는 DB 담당을 맡아 스키마 설계와 데이터 적재 쪽을 진행했다. 진행 중인 프로젝트라 자세한 내용은 다음 회고에서 더 다루기로 한다.
KPT 회고
Keep
API 요청 → 데이터 정제 → 저장까지 하나의 흐름으로 완성해본 게 좋았다. 코드 조각을 따로 배우는 게 아니라 데이터가 어디서 와서 어디로 가는지 눈으로 따라갈 수 있었다.
같은 데이터를 requests로도 못 가져오면 Selenium으로 바꿔서 시도해보는 식으로, 막히면 다른 방법을 바로 시도해본 것도 도움이 됐다.
Problem
Selenium 실습에서 요소를 찾기 전에 페이지 로딩이 끝나길 기다리지 않아서 에러가 나는 경우가 잦았다. time.sleep()으로 임시방편은 됐지만 근본적인 대기 방식은 아니라는 느낌이 남았다.
SQL UPDATE에서 서브쿼리를 곧바로 조건에 넣었다가 에러를 만난 것도 이번 주였다. 아래 트러블슈팅에 정리했다.
Try
time.sleep() 대신 특정 요소가 나타날 때까지 기다리는 명시적 대기(WebDriverWait) 방식을 다음에 찾아보고 적용해본다. 무작정 몇 초 기다리는 것보다 필요한 만큼만 기다리는 게 맞을 것 같다.
새롭게 발견한 것
REPLACE의 동작 방식. REPLACE는 겉보기엔 UPDATE 같지만 내부적으로는 기존 행을 DELETE하고 새 행을 INSERT하는 방식이다. 그래서 AUTO_INCREMENT 컬럼처럼 일부 값만 넘기고 나머지를 비워두면 원래 있던 값이 사라지고 기본값으로 채워질 수 있다는 걸 알게 됐다.
with 구문으로 커넥션 관리. 파일 I/O에서 익힌 with open() as f:와 똑같은 패턴이 DB 커넥션에도 그대로 적용된다는 게 흥미로웠다. with connection.cursor() as cursor:로 감싸면 예외가 나도 커서와 커넥션이 자동으로 정리된다.
find_parent(). BeautifulSoup에서 자식 태그의 텍스트는 찾았는데 정작 필요한 링크는 부모 <a> 태그의 속성에 있는 경우가 많았다. select로 자식만 내려가는 방향에 익숙했는데, find_parent()로 거꾸로 올라갈 수 있다는 걸 이번에 처음 써봤다.
트러블슈팅
문제 : UPDATE의 WHERE 조건에 같은 테이블 서브쿼리를 쓰니 1093 에러
메뉴 이름으로 코드를 찾아서 그 코드를 기준으로 같은 테이블을 UPDATE하려 했는데, MySQL이 "수정 대상 테이블을 FROM 절에 다시 지정할 수 없다"는 의미의 1093 에러를 냈다.
-- 수정 전 — 같은 테이블(tbl_menu)을 서브쿼리에서 그대로 참조
UPDATE tbl_menu
SET category_code = 6
WHERE menu_code = (
SELECT menu_code FROM tbl_menu WHERE menu_name = '파인애플탕'
);
-- ERROR 1093: You can't specify target table 'tbl_menu' for update in FROM clause수정 중인 테이블을 서브쿼리 안에서 동시에 조회하는 걸 MySQL이 막아둔 것이었다. 서브쿼리 결과를 한 번 더 감싸서 임시 결과셋으로 만들면, MySQL 입장에서는 원본 테이블이 아니라 새로운 결과를 조회하는 것이 되어 제한을 피할 수 있었다.
-- 수정 후 — 서브쿼리를 한 번 더 감싸서 임시 테이블처럼 취급
UPDATE tbl_menu
SET category_code = 6
WHERE menu_code = (
SELECT tmp.menu_code FROM (
SELECT menu_code FROM tbl_menu WHERE menu_name = '파인애플탕'
) tmp
);같은 테이블을 참조·수정하려면 한 단계를 더 거쳐야 한다는 걸 에러를 직접 만나고서야 이해했다.
문제 : Selenium에서 요소를 못 찾고 NoSuchElementException 발생
검색 후 곧바로 뉴스 탭 버튼을 클릭하려 했는데, 페이지가 아직 다 그려지지 않은 상태라 요소를 찾지 못하고 에러가 났다.
-- 수정 전 — 대기 없이 바로 다음 동작 실행
search_box.send_keys(Keys.RETURN)
news_tab = driver.find_element(By.CSS_SELECTOR, "a.tab[href*='where=news']")
news_tab.click()
# NoSuchElementException: 아직 렌더링되지 않은 요소를 찾으려 함정적 페이지와 달리 동적 페이지는 요청을 보낸 시점과 화면이 실제로 완성되는 시점 사이에 시간차가 있다. 눈으로 볼 때는 순식간이라 놓치기 쉬운 부분이었다.
-- 수정 후 — 각 동작 사이에 대기 시간 확보
search_box.send_keys(Keys.RETURN)
time.sleep(1)
news_tab = driver.find_element(By.CSS_SELECTOR, "a.tab[href*='where=news']")
news_tab.click()
time.sleep(1)지금은 sleep으로 눈에 보이게 막았지만, 다음번엔 요소가 실제로 나타났는지 확인하고 넘어가는 방식을 써보고 싶다.
다음 주 목표
- Selenium의 명시적 대기(WebDriverWait) 방식 익혀서 sleep 의존 줄이기
- 크롤링으로 모은 데이터를 정제해서 간단히 시각화까지 해보기
- 계속 미뤄온 예외 처리(try / except / finally) 정리하고 넘어가기
이번 주는 데이터가 "어디서 와서 어디로 가는지"를 따라간 한 주였다.