🍁🍂
매년 가을만 되면 화담숲을 가는데, 이곳 예매 방식이 선착순이다. 특히 단풍이 가장 예쁘게 물드는 특정 날짜는 예매 경쟁이 정말 치열해서 사실상 예매가 불가능에 가깝다. 그렇다고 하루 종일 컴퓨터 앞에 앉아서 예매 사이트만 쳐다볼 수도 없는 노릇.
그래서 Playwright를 이용해 특정 날짜의 티켓(취소표)이 열리면 자동으로 예매 과정을 진행하는 구조를 적용해보기로 했다.
상업적 용도나 재판매 목적은 전혀 없습니다. (실제 예매도 개인 사용 목적으로 2장 구매했습니다.)
마지막 <⚠️ 자동화 예매 관련 주의사항 참조, 공연법 제4조의 2> 必
🗂 티켓 예매 프로세스
1. [페이지 이동]
화담숲 티켓 예매 사이트(놀유니버스)로 이동한다.
2. [옵션 열기]
'옵션 선택하기' 버튼을 찾아 클릭한다.
3. [날짜 탐색(원하는 달 및 요일 선택)]
원하는 예매 날짜(예: 11월 2일)가 나올 때까지 '다음 달로 이동' 버튼을 클릭하며, 해당 날짜의 버튼이 활성화(표가 풀리는지)되었는지 계속 확인한다.
4. [시간 선택]
날짜가 선택되면, 미리 지정해 둔 시간 목록 (e.g., 11:00, 11:15...) 중 가장 빠른 시간을 찾아 클릭한다.
5. [옵션 선택]
이용자: '성인'
이용자 할인: '일반'
옵션을 순서대로 클릭한다. 티켓은 1장씩 구매하는 것이 더 안정적이라 판단해 수량 추가 로직은 제외했다.
6. [구매 확정]
'구매하기' 버튼을 클릭하여 예매를 시도한다.
7. [예외 처리]
만약 위 과정 중 어느 한 곳에서라도 클릭이 실패하거나 다음 단계로 넘어가지 않으면, 다시 3번 과정으로 돌아가 날짜 탐색부터 반복한다. (접어두기 → 옵션 선택하기 → 다음 달로 이동) 로직
<프로세스>
나는 11월 예매를 위해 다음 달로 넘어가는 로직을 추가했고, 2장을 한 번에 예매하기보다 1장이라도 빨리 사는 게 낫다고 판단해서 인원수 추가(+ 버튼) 로직은 생략하고 바로 '구매하기'를 클릭하도록 구현했다.
🧱 실행 환경 및 자동화 프로세스
- 언어/라이브러리: Python, Playwright
- 실행 환경: 로컬에서 실행 중인 Chrome 브라우저에 연결 (CDP)
사용자가 미리 로그인해 둔 디버깅 모드의 크롬(Chrome) 브라우저에 연결하여 자동화를 진행더보기디버깅(Debugging) 코드 크롬 연결이란?
- 사용자가 직접 디버깅 모드로 크롬 실행
스크립트를 실행하기 전에, 사용자는 터미널이나 명령 프롬프트에서 아래와 유사한 명령어를 입력해 크롬을 미리 실행해야 합니다.
"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="C:/ChromeDebug"
- --remote-debugging-port=9222
이 옵션은 외부 프로그램(여기서는 Playwright 스크립트)이 9222번 포트를 통해 이 크롬 브라우저를 제어할 수 있도록 허용합니다. - --user-data-dir
별도의 사용자 프로필 폴더를 지정하여, 기존 크롬 설정과 분리된 상태로 실행합니다. 이 창에서 미리 놀유니버스 사이트에 로그인을 해두면, 그 로그인 정보가 유지됩니다.
- --remote-debugging-port=9222
- 스크립트에서 기존 크롬에 연결
코드의 아래 부분이 바로 미리 띄워놓은 크롬 브라우저에 연결하는 역할을 합니다.
browser = p.chromium.connect_over_cdp("http://localhost:9222")
- connect_over_cdp
"Chrome DevTools Protocol을 통해 연결한다"는 의미입니다. 새로운 브라우저를 시작(launch)하는 대신, localhost:9222에서 실행 중인 브라우저를 제어하게 됩니다.
- connect_over_cdp
- 사용자가 직접 디버깅 모드로 크롬 실행
- 핵심 로직
- 사용자가 미리 놀유니버스에 로그인한다.
- 스크립트가 지정된 예매 페이지로 이동한다.
- '옵션 선택하기' 버튼을 클릭해 달력을 연다.
- 원하는 날짜가 활성화될 때까지
- (접어두기 → 옵션 선택하기 → 다음 달로 이동) 로직을 무한 반복한다.
- 날짜가 활성화되면 클릭하고, 지정된 시간대를 선택한다.
- 인원 수, 할인 종류를 차례대로 선택 후 '구매하기' 버튼을 최종적으로 클릭한다.
코드
from playwright.sync_api import sync_playwright
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
import time
def keep_session_alive(page, last_action_time, interval=60):
"""주기적으로 세션을 유지하기 위해 간단한 DOM 액션 실행"""
if time.time() - last_action_time >= interval:
try:
# 1. 마우스 움직임으로 세션 유지
page.mouse.move(100, 200)
# 2. 또는 console 로그 실행
page.evaluate("console.log('keep alive')")
print("⏰ 세션 유지 액션 실행")
except Exception as e:
print(f"⚠️ 세션 유지 중 오류 발생: {e}")
return time.time()
return last_action_time
def try_select_date(page, target_date):
"""달력에서 target_date 버튼이 활성화되면 클릭"""
date_btn = page.query_selector(f"button:has(abbr[aria-label='{target_date}'])")
if date_btn and date_btn.get_attribute("disabled") is None:
date_btn.scroll_into_view_if_needed()
page.wait_for_timeout(300)
date_btn.click()
print(f"✅ 날짜 {target_date} 클릭 완료")
return True
return False
def reopen_option_and_next(page):
"""접어두기 → 옵션 선택하기 → 다음 날짜 이동"""
# 접어두기 버튼
close_btn = page.query_selector("button:has-text('접어두기')")
if close_btn:
close_btn.scroll_into_view_if_needed()
page.wait_for_timeout(300)
close_btn.click()
print("↩️ 접어두기 버튼 클릭")
# 옵션 선택하기 버튼 (두 번째)
option_btns = page.locator("button:has-text('옵션 선택하기')")
if option_btns.count() > 1:
option_btn = option_btns.nth(1)
option_btn.scroll_into_view_if_needed()
page.wait_for_timeout(500)
option_btn.click()
print("✅ 옵션 선택 버튼 클릭")
# 다음 날짜 버튼
next_btn = page.locator("button[aria-label='다음 날짜로 이동']").first
if next_btn:
next_btn.scroll_into_view_if_needed()
page.wait_for_timeout(300)
next_btn.click()
print("✅ 다음 날짜 버튼 클릭")
TARGET_DATE = "2025년 11월 2일"
TARGET_TIMES = ["11:00", "11:15", "11:30", "11:45", "12:00", "12:15", "12:30"]
def main():
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp("http://localhost:9222")
# 기존 브라우저 탭 선택 (첫 번째 탭)
context = browser.contexts[0]
page = context.pages[0]
page.goto("https://leisure-web.yanolja.com/leisure/10265844")
page.wait_for_load_state("domcontentloaded")
last_action_time = time.time()
while True:
clicked_option = False
while not clicked_option:
try:
option_btns = page.locator("button:has-text('옵션 선택하기')")
count = option_btns.count()
if count > 1: # 두 번째 버튼 확인
option_btn = option_btns.nth(1) # 0-based index
option_btn.scroll_into_view_if_needed()
page.wait_for_timeout(500) # 애니메이션 대기
option_btn.click()
print("✅ 옵션 선택 버튼 클릭 성공")
clicked_option = True
else:
time.sleep(1) # 버튼 아직 없음
except Exception as e:
print(f"❌ 옵션 버튼 처리 중 오류: {e}")
time.sleep(1)
# -----------------------------
# 2단계: 달력 이동 + 날짜 선택
# -----------------------------
# 날짜 활성화 모니터링
# 다음 버튼 클릭(다음달)
try:
next_btn = page.locator("button[aria-label='다음 날짜로 이동']").first
# 화면에 보이도록 스크롤
next_btn.scroll_into_view_if_needed()
# 클릭
next_btn.click()
print("✅ 다음 날짜 버튼 클릭 성공")
except Exception as e:
print(f"❌ 다음 날짜 버튼 클릭 실패: {e}")
clicked_date = False
while True:
if page.query_selector("#confirmClose"):
print("⏰ 제한시간 종료, 재시작")
break # while True 루프 처음으로
# 세션 유지
last_action_time = keep_session_alive(page, last_action_time)
# 날짜 클릭 시도
if try_select_date(page, TARGET_DATE):
clicked_date = True
time.sleep(1)
clicked_time = False
for t in TARGET_TIMES:
for _ in range(10): # 최대 10초 동안 재시도
time_btn = page.query_selector(f"button[aria-label='{t} 선택']")
if time_btn:
found_any_time_btn = True
btn_class = time_btn.get_attribute("class") or ""
is_disabled = time_btn.get_attribute("disabled") is not None
if not ("cursor-not-allowed" in btn_class or
"line-through" in btn_class or
is_disabled):
time_btn.scroll_into_view_if_needed()
time.sleep(0.3) # 스크롤/렌더링 대기
time_btn.click()
print(f"✅ 시간대 {t} 클릭 완료")
clicked_time = True
break
time.sleep(1) # 1초 후 다시 확인
# 🔹 시간대를 끝까지 못 찾은 경우 처리
if clicked_time:
break
# 🔹 시간대가 아예 없거나 전부 매진인 경우
if not clicked_time:
if not found_any_time_btn:
print("❌ 시간대 버튼 자체가 없음 (표 없음) → 다음 날짜로 이동")
else:
print("❌ 원하는 시간대 없음 (모두 매진) → 다음 날짜로 이동")
reopen_option_and_next(page)
continue # 날짜 루프 처음으로 이동
# '입장권 성인' 버튼 클릭
adult_ticket_btn = page.query_selector("button:has-text('입장권 성인')")
if adult_ticket_btn:
page.evaluate("(el) => el.click()", adult_ticket_btn)
print("✅ 입장권 성인 선택 완료")
# 이용자 할인 선택 (일반)
discount_btn = page.locator("button:has-text('일반')").first
if discount_btn:
# 버튼이 활성화 되어 있는지 확인
aria_disabled = discount_btn.get_attribute("aria-disabled")
if aria_disabled == "false":
discount_btn.scroll_into_view_if_needed()
page.wait_for_timeout(500)
discount_btn.click()
print("✅ 이용자 할인 일반 선택 완료")
time.sleep(0.3)
else:
print("❌ '이용자 할인 일반' 버튼이 비활성화 상태")
reopen_option_and_next(page)
continue
else:
print("❌ '이용자 할인 일반' 버튼 없음 → 다음 날짜로 이동")
reopen_option_and_next(page)
continue
# # 인원 추가 클릭 (플러스 버튼)
# add_person_btn = page.query_selector("#stepper-plus")
# if add_person_btn:
# # Playwright 기준으로 클릭 가능 여부 확인
# is_enabled = add_person_btn.is_enabled()
# if is_enabled:
# add_person_btn.click()
# print("✅ 인원 1명 추가 완료 (총 2명)")
# time.sleep(0.3)
# else:
# print("❌ 플러스 버튼 비활성화 → 잔여 티켓 1장, 스킵")
# reopen_option_and_next(page) # 접어두기 → 옵션 선택하기 → 다음 날짜
# continue # 다음 날짜로 루프 이동
# else:
# print("❌ 플러스 버튼 없음 → 잔여 티켓 1장, 스킵")
# reopen_option_and_next(page) # ⬅️ 다음 날짜로 이동
# continue # 다음 while 루프로 이동
# 1️⃣ 먼저 버튼 선택
purchase_btn = page.query_selector("button:has-text('구매하기')")
# 2️⃣ 버튼이 존재하면 클릭
if purchase_btn:
# JS로 바로 클릭
purchase_btn.click() # sync API에서는 evaluate 없이도 클릭 가능
print("🎉 구매하기 버튼 클릭 완료")
# 2️⃣ 구매 후 일정 시간 동안 세션 유지
last_action_time = time.time()
end_time = time.time() + 60 # 60초 동안 세션 유지
while time.time() < end_time:
last_action_time = keep_session_alive(page, last_action_time, interval=10)
time.sleep(1)
else:
print("❌ 구매하기 버튼을 찾지 못함")
break
else:
print(f"❌ {TARGET_DATE} 없음, 다시 시도")
reopen_option_and_next(page)
time.sleep(1) # API 과부하 방지용
time.sleep(0.3)
if clicked_date:
print("✅ 날짜와 시간대 클릭 완료, 루프 종료")
break # 성공적으로 진행되었으므로 루프 종료
if __name__ == "__main__":
main()
진행 중 이슈
- 로그인 세션 만료
분명히 세션을 유지하기 위해 주기적으로 마우스 움직임을 시뮬레이션하거나 간단한 스크립트를 실행하는 로직을 추가했는데도, 이유 없이 자꾸 로그인이 풀리는 문제가 발생했다. 자동화의 가장 큰 적은 역시 인증 문제인 것 같다. - '이용자 선택(성인)' 오류
잘 동작하던 로직인데, 어느 순간부터 '입장권 성인' 버튼을 클릭하는 부분에서 스크립트가 멈추는 에러가 생겼다. 선택자를 못 찾는 건지, 다른 문제인지 아직 원인을 파악하지 못했다.
결론(중요)
중간에 Error가 발생하더라도, 4. '[시간 선택]'까지만 진행되면 해당 티켓 구매가 선점된다는 사실을 알아냈다. 즉, 스크립트가 시간 선택까지 성공한 후 로그인이 풀리거나 다른 에러가 나더라도, 그 창은 그대로 두고 새 탭에서 다시 로그인한 뒤 수동으로 나머지 과정을 진행하면 예매가 가능했다.
⚠️ 자동화 예매 관련 주의사항
상업적 목적이나 서비스 약관을 위반하는 행위(대량 예매, 티켓 재판매 등)에 절대 사용되어서는 안 됩니다.
특히,
- 각 웹사이트의 robots.txt 정책과 이용약관을 반드시 확인해야 합니다.
대부분의 웹사이트에는 robots.txt 파일(예: https://example.com/robots.txt)이 있다. 이 파일은 "이 페이지는 수집해가지 마세요"라고 크롤러에게 보내는 약속과 같다. 비록 강제성은 없지만, 웹 예절을 위해 이 규칙은 반드시 지켜야 한다. - 자동화 스크립트를 통한 접근은 사이트의 안정성에 영향을 줄 수 있으며, 일부 서비스에서는 불법적인 접근으로 간주될 수 있습니다.
[과도한 요청은 금물]
내 스크립트가 1초에 수십, 수백 번씩 사이트에 요청을 보낸다면 서버에 엄청난 부담을 주게 되고, 다른 사용자들이 서비스를 이용 못 하는 상황이 벌어질 수 있다. 이는 명백한 민폐 행위이며, 심하면 IP가 차단당할 수도 있다. time.sleep()을 이용해 요청 사이에 적절한 간격을 두는 것이 필수다.
[상업적 이용은 위험하다]
개인적인 편의를 위해 만드는 것과, 스크래핑한 데이터를 상업적으로 이용하는 것은 전혀 다른 문제다. 웹사이트의 콘텐츠는 저작권의 보호를 받을 수 있으며, 서비스 이용 약관(Terms of Service)에서 스크래핑을 금지하는 경우가 많다. 이를 어기고 상업적으로 활용하면 법적 분쟁으로 이어질 수 있으니 각별히 주의해야 한다.
[공유 시 책임을 명확히 해야 합니다]
본 포스팅의 모든 내용은 기술적 참고용으로만 작성되었으며,
해당 코드를 실제 서비스에 적용하는 것은 각자의 책임입니다.
본 블로그는 코드 사용으로 인한 불이익에 대해 어떠한 책임도 지지 않습니다.
[매크로를 이용한 '암표'는 불법, 공연법 제4조의 2]
최근 공연법이 개정(2024년 3월 22일 시행)되면서, 매크로 프로그램을 이용해 입장권을 구매한 뒤 웃돈을 받고 되파는 행위(암표)가 법적으로 금지됐다. 위반 시 1년 이하의 징역 또는 1천만 원 이하의 벌금에 처해질 수 있다고 하니, 정말 조심해야 한다.
공연법 제4조의2(입장권등의 부정판매 금지 등)
① 문화체육관광부장관은 공연의 입장권ㆍ관람권 또는 할인권ㆍ교환권 등(이하 “입장권등”이라 한다)의 부정판매(입장권등을 판매하거나 그 판매를 위탁받은 자의 동의를 받지 아니한 자가 다른 사람에게 입장권등을 상습 또는 영업으로 자신이 구입한 가격을 넘은 금액으로 판매하거나 이를 알선하는 행위를 말한다. 이하 같다)를 방지하기 위하여 노력하여야 한다. <개정 2023. 3. 21.>
② 누구든지 「정보통신망 이용촉진 및 정보보호 등에 관한 법률」 제2조제1항제1호에 따른 정보통신망에 지정된 명령을 자동으로 반복 입력하는 프로그램을 이용하여 입장권등을 부정판매하여서는 아니 된다. <신설 2023. 3. 21.>
구분 | 설명 | 리스크 수준 |
개인 1~2매 구매 | OK | 거의 없음 |
상업적/대량 예약 | 🚫 | 법적·약관 위반 가능성 |
결제 자동화 | ⚠️ | 약관 위반 및 결제 오류 가능성 |
단순 선택 자동화 (사람이 결제) | ✅ | 비교적 안전 |
현실적인 안전선
- “사람처럼 동작”하도록 제한
- 클릭 간격 랜덤(0.3~1.5초)
- 재시도 횟수 제한
- 일정 시간 후 자동 중단
- 결제(‘구매하기’) 전에는 사람이 직접 확인
- 자동화는 “시간대 탐색 + 옵션 미리 선택”까지만 수행
- 결제 버튼은 사람이 직접 눌러 완료
- → 이 경우 사실상 “자동 보조 도구”로 간주되어 문제가 거의 없습니다.
- 로그인 세션 유지용 계정 따로 사용
- 본계정으로 로그인하지 않고, “예비 계정”으로 로그인 후 예약 테스트
- 1회성 자동화
- 주기적 예약이 아니라, 이번 구매용으로 한 번만 실행했다면 문제될 여지는 거의 없음.
'그냥 해봤는데' 카테고리의 다른 글
OpenAI ChatGPT VS Google Gemini (3) | 2025.07.28 |
---|---|
베라쥬얼리 반지 계약 후기 💍 (+ 할인 혜택) (3) | 2025.07.06 |
아이티컨벤션 웨딩홀 계약 후기 ✨ (0) | 2025.06.17 |
🎨티스토리 사이드바 카테고리 디자인 개선 (0) | 2025.05.28 |
티스토리 사이드바 상단에 프로필 이미지 넣는 방법 (0) | 2025.05.24 |
댓글