2026. 7. 6. 19:42ㆍAI/웹 크롤링
1. 셀레니움 (Selenium)
셀레니움은 파이썬 코드로 실제 웹 브라우저를 자동으로 제어할 수 있게 해주는
웹 자동화 라이브러리이다. 일반적인 requests 크롤링은 서버에서 받은 HTML만 분석하나,
셀레니움은 브라우저를 직접 실행하여 버튼 클릭, 검색 입력, 스크롤, 페이지 이동 등의
사용자 동작으 그대로 수행할 수 있기 때문에
Javascript로 동적으로 생성되는 데이터까지 가져올 수 있다.
따라서 Javascript 랜더링이 많은 사이트의 크롤링이나 웹 자동화 테스트에서 많이 사용된다.
보통 webdriver.Chrome()으로 브라우저를 실행하고
find_element()로 요소를 찾으며, click() / send_keys()를 통해 자동화 작업을 수행한다.
!python -m pip install selenium
2. 멜론 아티스트 곡 리스트 크롤링
import time
import pandas as pd
from selenium import webdriver
from selenium.webdrvier.common.by import By # 파싱
from selenium.webdriver.common.keys import Keys # 글자 입력
from selenium.webdriver.common.options import Options # 브라우저의 옵션
from selenium.webdriver.support.ui import WebDriverWait # HTML 로딩까지 대기
from selenium.webdrvier.support import expected_conditions as EC # 기다린 후 처리
페이징 처리 하지 않았을 경우
def melon_search_from_main(keyword):
# 옵션 객체 생성
options = Options()
options.add_argument("--start-maximized") # 꽉 찬 화면으로 시작
driver = webdriver.Chrome(options=options)
# 브라우저를 10초까지 기다릴 수 있게 해주는 객체
wait = WebDriverWait(driver, 10)
data = []
try:
# 페이지 이동
driver.get("https://www.melon.com")
# 2초 대기
time.sleep(2)
# 검색창 인풋 접근
# id 선택자로 접근
# top_search 찾을 때까지 기다리라는 의미 (최대 10초)
search_box = wait.until(
EC.presense_of_element_located((By.ID,"top_search"))
)
# 글씨가 써져있으면 clear
search_box.clear()
# search_box에 키워드 입력
search_box.send_keys(keyword)
# 그 이후에 Enter
search_box.send_keys(Keys.ENTER)
# xpath: HTML/XML 문서 안에서 원하는 태그를 찾아가는 주소 표현식
# //*[@id="divCollection"]/ul/li[3]/a/span
# 이 요소를 클릭할 수 있을 때 까지 기다려달라.
song_tab = wait.until(
EC.element_to_be_clickable(
(By.XPATH, '//*[@id="divCollection"]/ul/li[3]/a/span//*[@id="divCollection"]/ul/li[3]/a/span')
)
)
song_tab.click()
# 클릭 이후 3초 간 기다려줘
time.sleep(3)
# 송 리스트 테이블 가져오기
song_table = wait.until(
EC.presense_of_element_located(
(By.XPATH, '//*[@id="frm_defaultList"]/div/table')
)
)
# table tbody tr (여러 개가 있을 것)
rows = song_table.find_elements(By.CSS_SELECTOR,"tbody tr")
for row in rows:
try:
cols = row.find_elements(By.TAG_NAME, "td")
if len(cols) < 5:
continue
# 곡명 td
title_text = cols[2].text.strip()
title_lines = [line.strip() for line in title_text.split("\n") if line.strip()]
# title 끄집어내기
title = ""
# 제목만 뽑아내기
for line in title_lines:
if (
"재생" not in line
and "담기" not in line
and "상세정보" not in line
and not line.startswith("Title")
):
title = line
break
# Title 줄이 더 정확한 경우 보정하기
for line in title_lines:
if line.startswith("Title "):
title = line.replace("Title ","").strip()
break
# Artist 끄집어내기
artist_text = cols[3].text.strip()
artist_lines = [line.strip() for line in artist_text.split("\n") if line.strip()]
artist = artist_lines[0] if artist_lines else ""
# Album 끄집어내기
album_text = cols[4].text.strip()
album_lines = [line.strip() for line in album_text.split("\n") if line.strip()]
album = album_lines[0] if album_lines else ""
# 좋아요 수
like = ""
try:
like = row.find_element(By.CSS_SELECTOR, "button.like span.cnt").text.strip()
except:
pass
# 데이터 저장
if title:
data.append({
"곡명": title,
"아티스트": artist,
"앨범": album,
"좋아요수": like
})
except Exception as e:
print("행 처리 오류:", e)
df = pd.DataFrame(data)
if not df.empty:
df.index = df.index + 1
file_name = f'csv/melon_{keyword}_songs.csv'
df.to_csv(file_name, encoding='utf-8-sig')
return df
finally:
# 창 닫기
driver.quit()
melon_search_from_main("신의키스")

여러 페이지의 경우, 페이징 처리 했을 경우
def extract_current_page(driver, wait):
data = []
try:
song_table = wait.until(
EC.presence_of_element_located(
(By.XPATH, '//*[@id="frm_defaultList"]/div/table')
)
)
except TimeoutException:
return []
rows = song_table.find_elements(By.CSS_SELECTOR, "tbody tr")
for row in rows:
cols = row.find_elements(By.TAG_NAME, "td")
if len(cols) < 5:
continue
title_lines = [
line.strip()
for line in cols[2].text.split("\n")
if line.strip()
]
title = ""
for line in title_lines:
if line.startswith("Title "):
title = line.replace("Title ", "").strip()
break
if not title:
for line in title_lines:
if (
"재생" not in line
and "담기" not in line
and "상세정보" not in line
and not line.startswith("Title")
):
title = line
break
artist_lines = [
line.strip()
for line in cols[3].text.split("\n")
if line.strip()
]
artist = artist_lines[0] if artist_lines else ""
album_lines = [
line.strip()
for line in cols[4].text.split("\n")
if line.strip()
]
album = album_lines[0] if album_lines else ""
try:
like = row.find_element(
By.CSS_SELECTOR,
"button.like span.cnt"
).text.strip()
except:
like = ""
if title:
data.append({
"곡명": title,
"아티스트": artist,
"앨범": album,
"좋아요수": like
})
return data
def melon_search_all_pages(keyword, max_page=30):
options = Options()
options.add_argument("--start-maximized")
driver = Webdriver.Chrome(options=options)
wait = WebDriverWait(driver, 10)
all_data = []
try:
driver.get("https://www.melon.com/")
time.sleep(2)
search_box = wait.until(
EC.presense_of_element_located((By.Id, "top_search"))
)
search_box.clear()
search_box.send_keys(keyword)
search_box.send_keys(Keys.ENTER)
time.sleep(3)
song_tab = wait.until(
EC.element_to_be_clickable(
(By.XPATH,'///*[@id="divCollection"]/ul/li[3]/a/span')
)
)
song_tab.click()
time.sleep(3)
for page in range(1, max_page + 1):
page_data = extract_current_page(driver, wait)
if not page_data:
print("f{page}페이지 데이터가 없어 종료합니다.")
break
all_data.extend(page_data)
print(f"{page}페이지 크롤링 완료 - {len(page_data)}곡")
next_start_index = page * 50 + 1
try:
driver.execute_script(
f"pageObj.sendPage('{next_start_index}');"
)
time.sleep(3)
except Exception as e:
print("다음 페이지 이동 실패:", e)
break
df = pd.DataFrame(all_data)
if not df.empty:
df = df.drop_duplicates(subset=["곡명","아티스트","앨범"])
df.index = df.index + 1
file_name= f"./csv/melon_{keyword}_all_songs.csv"
df.to_csv(
file_name,
encoding="utf-8-sig"
)
print(f"CSV 저장 완료: {file_name}")
print(f"총 {len(df)}곡 수집 완료")
return df
finally:
driver.quit()
melon_search_all_pages('조용필')

3. 스타벅스 전체 서울 매장 크롤링
https://www.starbucks.co.kr/store/store_map.do?disp=locale
스타벅스 커피 코리아
스타벅스 커피 코리아
www.starbucks.co.kr
# 사람처럼 행동하는 자연스러운 연결
from selenium.webdriver import ActionChains
# 정규화
import re
def fetch_starbucks():
url = "https://www.starbucks.co.kr/index.do"
driver = webdriver.Chrome()
driver.maximize_window()
driver.get(url)
time.sleep(2)
# 메뉴 이동
action = ActionChains(driver)
first_tag = driver.find_element(By.CSS_SELECTOR, "#gnb > div > nav > div > ul > li.gnb_nav03")
second_tag = driver.find_element(By.CSS_SELECTOR, "#gnb > div > nav > div > ul > li.gnb_nav03 > div > div > div > ul:nth-child(1) > li:nth-child(3) > a")
action.move_to_element(first_tag).move_to_element(second_tag).click().perform()
# 서울 선택
seoul_tag = WebDriverWait(driver, 10).until(
EC.element_to_be_clickable(
(By.CSS_SELECTOR, '#container > div > form > fieldset > div > section > article.find_store_cont > article > article:nth-child(4) > div.loca_step1 > div.loca_step1_cont > ul > li:nth-child(1) > a'
)
)
seoul_tag.click()
# 구 목록 로딩 대기
WebDriverWait(driver, 5).until(
EC.presense_of_all_elements_located(
(By.CLASS_NAME, 'set_gugun_cd_btn')
)
)
gu_elements = driver.find_elements(By.CLASS_NAME,'set_gugun_cd_btn')
# 전체 클릭
gu_elements[0].click()
# 매장 목록 로딩 대기
WebDriverWait(driver, 5).until(
EC.presense_of_all_elements_located((By.CLASS_NAME, "quickResultLstCon"))
)
# HTML 가져오기
req = driver.page_source
soup = BeautifulSoup(req, "html.parser")
stores = soup.find('ul','quickSearchResultBoxSidoGugun').find_all('li')
# 데이터 저장 리스트
store_list = []
addr_list = []
lat_list = []
lng_list = []
for store in stores:
store_name = store.find("strong").text
store_addr = store.find("p").text
# 전화번호 제거
store_addr = re.sub(r'\d{4}-\d{4}$','',store_addr).strip()
# lat
store_lat = store['data-lat']
# lng
store_lng = store['data-lng']
store_list.append(store_name)
addr_list.append(store_addr)
lat_list.append(store_lat)
lng_list.append(store_lng)
df = pd.DataFrame(
{
'store': store_list,
'addr': addr_list,
'lat': lat_list,
'lng': lng_list
}
)
return df
starbucks_df = fetch_starbucks()
starbucks_df.to_csv('./csv/starbucks_seoul.csv', index=False, encoding='utf-8-sig')
starbucks_df

이렇게 크롤링을 통해 수집한 데이터를 토대로 서울시 공공데이터와 비교해본다.
https://www.data.go.kr/data/15083033/fileData.do
일단 공공 데이터 먼저 확인한다.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
shop = pd.read_csv('./csv/소상공인시장진흥공단_상가(상권)정보_서울_202603.csv', low_memory=False)
# 필요한 컬럼만 추려내기
view_columns = ['상호명','지점명','상권업종대분류명','상권업종중분류명','상권업종소분류명','시도명','시군구명','도로명','도로명주소','경도','위도']
shop = shop[view_columns]
shop

1) Null 값 혹은 주소가 서울시로 시작하지 않는 이상치 및 결측치 데이터 정제하기
shop.isnull().sum()

결측치 데이터는 지점명에만 몰려있다. (현재로썬 일단 아직 제거하지 않고 놔둔다)
shop.loc[~shop['도로명주소'].str.startswith('서울')]

이상치 데이터는 하나도 없으므로 놔두어도 문제 없을 것 같다.
2) seaborn으로 시각화하기
import seaborn as sns
plt.rcParams['font.family'] = 'Apple SD Gothic Neo'
plt.rcParams['axes.unicode_minus'] = False
plt.figure(figsize=(14, 10))
sns.scatterplot(data=shop, x='경도', y='위도', hue='시군구명')

이제 아래부터 스타벅스 데이터와 종합해서 커피 프랜차이즈 입점 전략 분석해보자.
4. 커피 프랜차이즈 입점 전략 분석
분석 목표는 다음과 같다.
- 스타벅스 주변에는 커피 전문점이 많이 분포하는가?
- 특정 커피 프랜차이즈는 스타벅스와 더 가까운 곳에 입점하는 경향이 있는가?
- 어느 자치구에서 스타벅스와 커피 프랜차이즈가 함께 밀집되어 있는가?
- 거리 구간별, 브랜드별, 자치구별 시각화를 통해 어떤 결론?
하지만, 이 데이터에는 입점 날짜를 알 수 없으므로,
스타벅스와 주변 커피숍의 입점 측면에서
인과관계를 완벽하게 증명하기가 힘들다는 점은 알고 가야 한다.
1) 데이터 불러오기
from pathlib import Path
import re
import math
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import folium
from folium.plugins import MarkerCluster
# 현재 작업 폴더 확인
BASE_DIR = Path.cwd()
print("현재 작업 폴더: ", BASE_DIR)
DATA_DIR = BASE_DIR / 'data'
print("데이터 폴더 : ", DATA_DIR)
OUTPUT_DIR = BASE_DIR / 'output'
OUTPUT_DIR.mkdir(exist_ok=True)
print("결과 저장 폴더 : ", OUTPUT_DIR)
STARBUCKS_FILE = 'starbucks_seoul.csv'
SMALL_BIZ_FILE = '소상공인시장진흥공단_상가(상권)정보_서울_202603.csv'
STARBUCKS_PATH = DATA_DIR / STARBUCKS_FILE
SMALL_BIZ_PATH = DATA_DIR / SMALL_BIZ_FILE
df_starbucks_row = pd.read_csv(STARBUCKS_PATH, encoding="utf-8")
df_raw = pd.read_csv(SMALL_BIZ_PATH, encoding="utf-8", low_memory=False)
2) 컬럼 정리
# 여러 후보 컬럼명 중 실제 데이터프레임에 존재하는 컬럼명인지 확인해주는 함수
def find_col(df, candidates):
for col in candidates:
if col in df.columns:
return col
raise KeyError(f"다음 후보 컬럼을 찾을 수 없다 : {candidates}")
df_starbucks = df_starbucks_raw.copy()
if {'store', 'addr', 'lat', 'lng'}.issubset(df_starbucks.columns):
df_starbucks = df_starbucks[['store', 'addr', 'lat', 'lng']].copy()
df_starbucks.columns = ['스타벅스지점명', '스타벅스주소', '스타벅스위도', '스타벅스경도']
elif df_starbucks.shape[1] >= 4:
df_starbucks = df_starbucks.iloc[:, :4].copy()
else:
raise ValueError("스타벅스 데이터에 최소 4개 컬럼이 필요하다. 지점명, 주소, 위도, 경도를 확인하라.")
df_starbucks

# 좌표 숫자 변환
df_starbucks['스타벅스위도'] = pd.to_numeric(df_starbucks['스타벅스위도'], errors='coerce') # 에러 시 NaN으로 처리
df_starbucks['스타벅스경도'] = pd.to_numeric(df_starbucks['스타벅스경도'], errors='coerce')
df_starbucks = df_starbucks.dropna(subset=['스타벅스위도','스타벅스경도']).drop_duplicates()
3) 소상공인 데이터에서 컬럼 처리
# 소상공인 데이터에서 필요한 컬럼 찾기
id_col = find_col(df_raw, ['상가업소번호','상가업소번호명']) # 상가업소번호
name_col = find_col(df_raw, ['상호명']) # 상호명
category_l_col = find_col(df_raw, ['상권업종대분류명', '상권업종대분류코드']) # 상권업종대분류명
category_m_col = find_col(df_raw, ['상권업종중분류명','상권업종중분류코드']) # 상권업종중분류명
category_s_col = find_col(df_raw, ['상권업종소분류명', '상권업종소분류코드']) # 상권업종소분류명
address_col = find_col(df_raw, ['도로명주소', '지번주소']) # 도로명주소
lat_col = find_col(df_raw, ['위도']) # 위도
lng_col = find_col(df_raw, ['경도']) # 경도
use_cols = [id_col, name_col, category_l_col, category_m_col, category_s_col, address_col, lat_col, lng_col]
df_store = df_raw[use_cols].copy()
df_store

# 좌표 숫자 변환
df_store['위도'] = pd.to_numeric(df_store['위도'], errors='coerce')
df_store['경도'] = pd.to_numeric(df_store['경도'], errors='coerce')
df_store = df_store.dropna(subset=['위도', '경도'])
4) 이상치 처리
# 서울 범위를 크게 벗어난 좌표 제거
df_store = df_store[(df_store['위도'].between(37.0, 38.0)) & (df_store['경도'].between(126.0, 128.0))].copy()
5) 소상공인 데이터를 필요한 업체/상호명만 걸러내기
# 커피 전문점 필터링
coffee_keywords = ['커피', '카페', '까페', 'coffee', 'cafe']
mask_coffee = (
df_store['상권업종중분류명'].astype(str).str.contains('커피|카페|음료', na=False) |
df_store['상권업종소분류명'].astype(str).str.contains('커피|카페|음료', na=False) |
df_store['상호명'].astype(str).str.contains('|'.join(coffee_keywords), case=False, na=False)
)
df_cafe = df_store[mask_coffee].copy()
df_cafe

6) 주요 프랜차이즈 브랜드 만들기
# 주요 프랜차이즈 브랜드
brand_patterns = {
'메가커피': r'메가\s*커피|메가MGC|MEGA\s*MGC|MEGA\s*COFFEE',
'컴포즈커피': r'컴포즈\s*커피|COMPOSE\s*COFFEE',
'빽다방': r'빽다방|PAIK',
'이디야': r'이디야|EDIYA',
'투썸플레이스': r'투썸|A\s*TWOSOME|TWOSOME',
'할리스': r'할리스|HOLLYS',
'커피빈': r'커피빈|COFFEE\s*BEAN',
'파스쿠찌': r'파스쿠찌|PASCUCCI',
'탐앤탐스': r'탐앤탐스|TOM\s*N\s*TOMS',
'더벤티': r'더벤티|THE\s*VENTI',
'매머드커피': r'매머드|MAMMOTH',
'텐퍼센트커피': r'텐퍼센트|10\s*PERCENT'
}
def extract_brand(name):
name = str(name).upper()
for brand, pattern in brand_patterns.items():
if re.search(pattern, name, flags=re.IGNORECASE):
return brand
return '기타커피전문점'
df_cafe['브랜드명'] = df_cafe['상호명'].apply(extract_brand)
brand_counts = df_cafe['브랜드명'].value_counts().reset_index()
brand_counts

7) 하버사인 거리 계산 함수
- 위도와 경도는 평면 좌표가 아니라 지구 표면의 좌표이다.
- 두 지점 사이의 거리를 구할 때 하버사인 공식을 사용한다.
def haversine_distance(lat1, lon1, lat2, lon2):
"""두 지점의 위도/경도를 이용해 거리를 계산한다."""
R = 6371000 # 지구 반지름(m)
lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
c = 2 * np.arcsin(np.sqrt(a))
return R * c
8) 각 커피 전문점에서 가까운 스타벅스 찾기
- 모든 커피 전문점과 모든 스타벅스의 거리를 비교한다.
- 그리고, 가장 가까운 스타벅스의 거리를 구한다.
- 주의할 점은 cross merge로 모든 조합을 df 만들 시, 메모리 많이 사용되어
- chunk 단위 numpy 계산으로 VSCode에서 안정적으로 실행되도록 구성할 것
# 각 카페에서 가장 가까운 스타벅스까지의 거리와 지점명을 추가해주는 함수
def add_nearest_starbucks(cafe_df, starbucks_df, chunk_size=2000):
cafe_df = cafe_df.copy().reset_index(drop=True)
starbucks_df = starbucks_df.copy().reset_index(drop=True)
sb_lat = starbucks_df['스타벅스위도'].to_numpy()
sb_lon = starbucks_df['스타벅스경도'].to_numpy()
sb_name = starbucks_df['스타벅스지점명'].to_numpy()
nearest_distances = []
nearest_names = []
for start in range(0, len(cafe_df), chunk_size):
end = min(start + chunk_size, len(cafe_df))
chunk = cafe_df.iloc[start:end]
cafe_lat = chunk['위도'].to_numpy()[:, None]
cafe_lon = chunk['경도'].to_numpy()[:, None]
# 모든 청크 내 모든 카페와 모든 스타벅스 간 거리계산
dist_matrix = haversine_distance(cafe_lat, cafe_lon, sb_lat[None, :], sb_lon[None, :])
min_idx = dist_matrix.argmin(axis=1) # 어떤 스타벅스와 가까운지
min_dist = dist_martix.min(axis=1) # 가장 짧은 거리를 구함
nearest_distances.extend(min_dist)
nearest_names.extend(sb_name[min_idx])
cafe_df['가장가까운스타벅스'] = nearest_names
cafe_df['스타벅스까지거리_m'] = nearest_distances
return cafe_df
# 거리 계산 실행
df_cafe_dist = add_nearest_starbucks(df_cafe, df_starbucks, chunk_size=2000)
df_cafe_dist

9) 거리 구간별 분석
- 스타벅스 주변에 얼마나 가까이 분포하는지 보기 위해 거리 구간을 나눈다.
- 0 ~ 50m -> 거의 바로 옆
- 50m ~ 100m -> 매우 가까움
- 100m ~ 300m -> 도보권
- 300m ~ 500m -> 인근 상권
- 500m ~ 1km -> 같은 생활권
- 1km 이상 -> 상대적으로 멀다
bins = [0, 50, 100, 300, 500, 1000, np.inf]
labels = ['0~50m', '50~100m', '100~300m', '300~500m', '500m~1km', '1km 이상']
# include_lowest = True => 가장 작은 값도 첫번째 구간에 포함
df_cafe_dist['거리구간'] = pd.cut(df_cafe_dist['스타벅스까지거리_m'], bins=bins, labels=labels, include_lowest=True)
distance_summary = (df_cafe_dist['거리구간'].value_counts().reindex(labels).reset_index())
distance_summary.columns = ['거리구간', '카페수']
distance_summary['비율(%)'] = distance_summary['카페수'] / distance_summary['카페수'].sum() * 100
distance_summary

10) 거리 구간별 분석 시각화
plt.rcParams["font.family"] = "Apple SD Gothic Neo"
plt.rcParams["axes.unicode_minus"] = False
plt.figure(figsize=(10, 5))
sns.barplot(data=distance_summary, x='거리구간', y='카페수')
plt.title('스타벅스 기준 거리 구간별 커피전문점 수')
plt.xlabel('스타벅스까지 거리')
plt.ylabel('커피전문점 수')
plt.xticks(rotation=30)
plt.tight_layout()
plt.savefig(OUTPUT_DIR / 'distance_bucket_count.png', dpi=150, bbox_inches='tight')
plt.show()

plt.figure(figsize=(10, 5))
sns.barplot(data=distance_summary, x='거리구간', y='비율(%)')
plt.title('스타벅스 기준 거리 구간별 커피전문점 수')
plt.xlabel('스타벅스까지 거리')
plt.ylabel('비율(%)')
plt.xticks(rotation=30)
plt.tight_layout()
plt.savefig(OUTPUT_DIR / 'distance_bucket_ratio.png', dpi=150, bbox_inches='tight')
plt.show()

11) 반경별 누적 분석
거리 구간별 개수도 중요하나 결론을 내릴 때는
스타벅스 반경 100m 안에 몇 %가 있는지 누적 비율이 더 직관적이다.
radius_list = [50, 100, 300, 500, 1000]
radius_summary = []
for radius in radius_list:
count = (df_cafe_dist['스타벅스까지거리_m'] <= radius).sum()
ratio = count / len(df_cafe_dist) * 100
radius_summary.append({
'반경': f'{radius}m 이내',
'반경_m': radius,
'카페수': count,
'비율(%)': ratio
})
radius_summary = pd.DataFrame(radius_summary)
display(radius_summary)

plt.figure(figsize=(9, 5))
sns.lineplot(data=radius_summary, x='반경_m', y='비율(%)', marker='o')
plt.title('스타벅스 반경별 커피전문점 누적 비율')
plt.xlabel('스타벅스 반경(m)')
plt.ylabel('누적 비율(%)')
plt.xticks(radius_list)
plt.tight_layout()
plt.savefig(OUTPUT_DIR / 'radius_cumulative_ratio.png', dpi=150, bbox_inches='tight')
plt.show()

12) 브랜드별 평균 거리 분석
브랜드별로 스타벅스와의 평균 거리, 중앙값 거리, 100m 이내 비율, 300m 이내 비율을 비교한다.
* 평균 거리가 짧다-> 스타벅스와 가까운 위치에 많이 분포할 가능성이 있다.
* 중앙값도 함께 볼 것 -> 일부 극단값이 평균을 왜곡할 수 있다.
* 100m/300m 이내 비율 : 실제로 가까운 매장이 얼마나 많은지 보여준다.
brand_distance = (
df_cafe_dist
.groupby('브랜드명')
.agg(
매장수=('상호명', 'count'),
평균거리_m=('스타벅스까지거리_m', 'mean'),
중앙값거리_m=('스타벅스까지거리_m', 'median'),
최소거리_m=('스타벅스까지거리_m', 'min'),
최대거리_m=('스타벅스까지거리_m', 'max')
)
.reset_index()
)
brand_distance

# 브랜드별 반경 내 비율 추가
near_100 = df_cafe_dist.assign(반경100m=df_cafe_dist['스타벅스까지거리_m'] <= 100).groupby('브랜드명')['반경100m'].mean() * 100
near_300 = df_cafe_dist.assign(반경300m=df_cafe_dist['스타벅스까지거리_m'] <= 300).groupby('브랜드명')['반경300m'].mean() * 100
brand_distance = brand_distance.merge(near_100.rename('100m이내비율(%)'), on='브랜드명')
brand_distance = brand_distance.merge(near_300.rename('300m이내비율(%)'), on='브랜드명')
brand_distance

# 매장 수가 너무 적은 브랜드는 해석이 불안정하므로 별도 기준 설정
MIN_STORE_COUNT = 10
brand_distance_filtered = brand_distance[brand_distance['매장수'] >= MIN_STORE_COUNT].copy()
brand_distance_filtered = brand_distance_filtered.sort_values('평균거리_m')
brand_distance_filtered

plt.figure(figsize=(10, 6))
plot_df = brand_distance_filtered.sort_values('평균거리_m').head(15)
sns.barplot(data=plot_df, y='브랜드명', x='평균거리_m')
plt.title('브랜드별 스타벅스까지 평균 거리 - 가까운 순')
plt.xlabel('평균 거리(m)')
plt.ylabel('브랜드명')
plt.tight_layout()
plt.savefig(OUTPUT_DIR / 'brand_average_distance.png', dpi=150, bbox_inches='tight')
plt.show()

plt.figure(figsize=(10, 6))
plot_df = brand_distance_filtered.sort_values('100m이내비율(%)', ascending=False).head(15)
sns.barplot(data=plot_df, y='브랜드명', x='100m이내비율(%)')
plt.title('브랜드별 스타벅스 100m 이내 입점 비율')
plt.xlabel('100m 이내 비율(%)')
plt.ylabel('브랜드명')
plt.tight_layout()
plt.savefig(OUTPUT_DIR / 'brand_near_100_ratio.png', dpi=150, bbox_inches='tight')
plt.show()

13) 자치구별 분석
스타벅스와 커피 전문점이 모두 특정 자치구에 몰려 있다면
단순히 스타벅스 영향이라기보다 원래 상권이 강한 지역일 가능성이 있다.
주소에서 서울 자치구를 추출하여 자치구별로 비교한다.
def extract_gu(address):
address = str(address)
match = re.search(r'서울특별시\s+([가-힣]+구)|서울\s+([가-힣]+구)', address)
if match:
return match.group(1) or match.group(2)
match = re.search(r'([가-힣]+구)', address)
if match:
return match.group(1)
return np.nan
df_cafe_dist['자치구'] = df_cafe_dist['도로명주소'].apply(extract_gu)
df_starbucks['자치구'] = df_starbucks['스타벅스주소'].apply(extract_gu)
cafe_gu = df_cafe_dist.groupby('자치구').size().reset_index(name='커피전문점수')
sb_gu = df_starbucks.groupby('자치구').size().reset_index(name='스타벅스수')
# how = 'outer'는 양쪽 데이터에 있는 자치구를 모두 살린다. na는 0으로 채운다.
gu_summary = cafe_gu.merge(sb_gu, on='자치구', how='outer').fillna(0)
gu_summary['커피전문점수'] = gu_summary['커피전문점수'].astype(int)
gu_summary['스타벅스수'] = gu_summary['스타벅스수'].astype(int)
gu_summary['스타벅스1개당커피전문점수'] = gu_summary['커피전문점수'] / gu_summary['스타벅스수'].replace(0, np.nan)
gu_summary = gu_summary.sort_values('스타벅스수', ascending=False)
gu_summary

plt.figure(figsize=(12, 6))
plot_df = gu_summary.sort_values('스타벅스수', ascending=False).head(15)
sns.barplot(data=plot_df, x='자치구', y='스타벅스수')
plt.title('자치구별 스타벅스 매장 수 TOP 15')
plt.xlabel('자치구')
plt.ylabel('스타벅스 매장 수')
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig(OUTPUT_DIR / 'gu_starbucks_count.png', dpi=150, bbox_inches='tight')
plt.show()

plt.figure(figsize=(12, 6))
plot_df = gu_summary.sort_values('커피전문점수', ascending=False).head(15)
sns.barplot(data=plot_df, x='자치구', y='커피전문점수')
plt.title('자치구별 커피전문점 수 TOP 15')
plt.xlabel('자치구')
plt.ylabel('커피전문점 수')
plt.xticks(rotation=45)
plt.tight_layout()
plt.savefig(OUTPUT_DIR / 'gu_cafe_count.png', dpi=150, bbox_inches='tight')
plt.show()

plt.figure(figsize=(8, 6))
sns.scatterplot(data=gu_summary, x='스타벅스수', y='커피전문점수')
for _, row in gu_summary.dropna(subset=['자치구']).iterrows():
if row['스타벅스수'] >= gu_summary['스타벅스수'].quantile(0.7) or row['커피전문점수'] >= gu_summary['커피전문점수'].quantile(0.7):
plt.text(row['스타벅스수'], row['커피전문점수'], row['자치구'], fontsize=9)
plt.title('자치구별 스타벅스 수와 커피전문점 수의 관계')
plt.xlabel('스타벅스 수')
plt.ylabel('커피전문점 수')
plt.tight_layout()
plt.savefig(OUTPUT_DIR / 'gu_starbucks_vs_cafe_scatter.png', dpi=150, bbox_inches='tight')
plt.show()

14) 지도 시각화
import folium
# 너무 많은 점을 찍으면 지도가 느려질 수 있으므로 필요하면 샘플링합니다.
MAX_CAFE_MARKERS = 3000
if len(df_cafe_dist) > MAX_CAFE_MARKERS:
df_cafe_map = df_cafe_dist.sample(MAX_CAFE_MARKERS, random_state=42).copy()
else:
df_cafe_map = df_cafe_dist.copy()
m = folium.Map(location=[37.5665, 126.9780], zoom_start=11, tiles='OpenStreetMap')
# 스타벅스 마커
sb_cluster = MarkerCluster(name='스타벅스').add_to(m)
for _, row in df_starbucks.iterrows():
folium.CircleMarker(
location=[row['스타벅스위도'], row['스타벅스경도']],
radius=5,
popup=f"스타벅스: {row['스타벅스지점명']}",
color='green',
fill=True,
fill_opacity=0.8
).add_to(sb_cluster)
folium.LayerControl().add_to(m)
m

# 커피전문점 마커
cafe_cluster = MarkerCluster(name='커피전문점').add_to(m)
for _, row in df_cafe_map.iterrows():
folium.CircleMarker(
location=[row['위도'], row['경도']],
radius=3,
popup=f"{row['상호명']} / {row['브랜드명']} / {row['스타벅스까지거리_m']:.0f}m",
color='red',
fill=True,
fill_opacity=0.5
).add_to(cafe_cluster)
folium.LayerControl().add_to(m)

# html 저장
map_path = OUTPUT_DIR / 'starbucks_cafe_map.html'
m.save(map_path)
15) 분석 결과 저장
앞서 우리가 만들었던 데이터프레임을 csv로 저장한다.
df_cafe_dist.to_csv(OUTPUT_DIR / 'cafe_with_nearest_starbucks.csv', index=False, encoding='utf-8-sig')
distance_summary.to_csv(OUTPUT_DIR / 'distance_summary.csv', index=False, encoding='utf-8-sig')
radius_summary.to_csv(OUTPUT_DIR / 'radius_summary.csv', index=False, encoding='utf-8-sig')
brand_distance_filtered.to_csv(OUTPUT_DIR / 'brand_distance_summary.csv', index=False, encoding='utf-8-sig')
gu_summary.to_csv(OUTPUT_DIR / 'gu_summary.csv', index=False, encoding='utf-8-sig')
print('CSV 저장 완료')
print('저장 위치:', OUTPUT_DIR)
16) 최종 결론 작성용 요약 지표
total_cafe = len(df_cafe_dist)
near_100_count = (df_cafe_dist['스타벅스까지거리_m'] <= 100).sum()
near_300_count = (df_cafe_dist['스타벅스까지거리_m'] <= 300).sum()
near_500_count = (df_cafe_dist['스타벅스까지거리_m'] <= 500).sum()
near_100_ratio = near_100_count / total_cafe * 100
near_300_ratio = near_300_count / total_cafe * 100
near_500_ratio = near_500_count / total_cafe * 100
closest_brand = brand_distance_filtered.iloc[0]
most_near_100_brand = brand_distance_filtered.sort_values('100m이내비율(%)', ascending=False).iloc[0]
top_sb_gu = gu_summary.sort_values('스타벅스수', ascending=False).iloc[0]
top_cafe_gu = gu_summary.sort_values('커피전문점수', ascending=False).iloc[0]
print('===== 최종 분석 요약 =====')
print(f'전체 커피전문점 수: {total_cafe:,}개')
print(f'스타벅스 100m 이내 커피전문점: {near_100_count:,}개 ({near_100_ratio:.2f}%)')
print(f'스타벅스 300m 이내 커피전문점: {near_300_count:,}개 ({near_300_ratio:.2f}%)')
print(f'스타벅스 500m 이내 커피전문점: {near_500_count:,}개 ({near_500_ratio:.2f}%)')
print()
print(f'평균 거리 기준 스타벅스와 가장 가까운 브랜드: {closest_brand["브랜드명"]} / 평균 {closest_brand["평균거리_m"]:.1f}m')
print(f'100m 이내 비율이 가장 높은 브랜드: {most_near_100_brand["브랜드명"]} / {most_near_100_brand["100m이내비율(%)"]:.2f}%')
print()
print(f'스타벅스가 가장 많은 자치구: {top_sb_gu["자치구"]} / {top_sb_gu["스타벅스수"]}개')
print(f'커피전문점이 가장 많은 자치구: {top_cafe_gu["자치구"]} / {top_cafe_gu["커피전문점수"]}개')

17) 결론
본 분석에서는 서울 지역 스타벅스 매장 위치와
소상공인 커피전문점 데이터를 이용하여 스타벅스 주변 상권에
커피 전문점이 얼마나 분포하는지 확인했다.
분석 결과, 스타벅스 반경 100m , 반경 300m, 500m 이내에 위치한
커피 전문점 수와 비율을 확인할 수 있었으며
일부 브랜드는 스타벅스와 상대적으로 가까운 위치에 많이 분포하는 경향을 보였다.
또한, 자치구별 분석 결과 스타벅스 매장 수가 많은 지역은
커피 전문점 수도 많은 경향을 보였다.
이는 스타벅스가 다른 커피전문점 입점에 직접적은 영향을 주었다기보다는
유동인구가 많고 상권이 발달한 지역에 스타벅스와 다른 커피전문점이
함께 밀집했을 가능성도 있음을 의미한다.
따라서 결론은 특정 커피 브랜드가 스타벅스 주변에 가까이 분포하는 경향이 있다
정도로 해석하는 것이 적절할 것이다.
다만 개업일 데이터나 매출 데이터가 없기 때문에
스타벅스를 따라 입점했다는 인과관계까지는 단정할 수 없다.
'AI > 웹 크롤링' 카테고리의 다른 글
| 웹 크롤링 - 서울시 공공자전거 실시간 대여정보 API (0) | 2026.07.07 |
|---|---|
| 웹 크롤링 - 크롤링의 활용 (4) (0) | 2026.07.02 |
| 웹 크롤링 - 크롤링과 HTML (3) (0) | 2026.07.02 |
| 웹 크롤링 - Git과 Github (2) (0) | 2026.07.02 |
| 웹 크롤링 - 인터넷과 웹 서비스 (1) (0) | 2026.07.02 |