STEP 7 / 7 · 심화(온라인 학습·강화학습)
학생용 교재 · 데이터가 쌓일수록 똑똑해지는 가위바위보 AI

🧠 대결할수록 똑똑해지는 가위바위보 상대 만들기

6장에서 만든 웹캠 가위바위보 게임을 확장해, 대결 기록이 쌓일수록 컴퓨터의 승률이 올라가는 적응형 상대를 두 가지 다른 방식(온라인 학습 · 강화학습)으로 만들고 승률 곡선을 비교합니다.

오늘 만들 프로그램

1. 마르코프 체인 상대내가 직전에 낸 수를 보고 "다음에 뭘 낼지" 통계적으로 예측해서 그걸 이기는 패를 냅니다.
2. Q-learning 상대진짜 강화학습으로, 대결 결과(승/패/무)를 보상으로 삼아 "이 상황에서 무엇을 내야 이득인지"를 스스로 업데이트합니다.
3. 승률 곡선 비교두 방식 모두 대결 기록을 파일로 계속 쌓고, 판수가 늘어날수록 컴퓨터 승률이 어떻게 변하는지 그래프로 비교합니다.
이게 강화학습인가요? 둘 다 "데이터가 쌓일수록 똑똑해진다"는 결과는 같지만, 방식은 다릅니다. 마르코프 체인은 "다음 수를 예측하는 문제"일 뿐이라 엄밀히는 온라인 학습(지도학습의 사촌)이지 강화학습이 아닙니다. 보상(reward) 개념 없이 그냥 통계만 씁니다. Q-learning은 state(직전 상태)·action(컴퓨터의 수)·reward(승+1/무0/패−1)를 정의하고, "이 상태에서 이 행동을 하면 기대 보상이 얼마인지"를 나타내는 Q-table을 매 판 업데이트하는 진짜 강화학습 구조입니다. 이 교재에서 두 가지를 모두 만들어서 직접 비교하는 이유가 이 차이를 눈으로 보여주기 위해서입니다.
왜 이 모듈은 로컬 Python으로 만드나요(웹이 아니라)? 6장은 이미 웹캠으로 손모양을 인식하는 rps_model.joblib(scikit-learn 모델)을 쓰고 있습니다. 이 모델은 Python 전용 형식이라 브라우저에서 바로 실행할 수 없습니다. 웹캠 인식까지 웹으로 옮기려면 TensorFlow.js 같은 걸로 모델 자체를 다시 만들어야 해서 작업이 커집니다. 그래서 이번 모듈은 6장의 실제 웹캠 인식 결과를 그대로 재사용하는 로컬 Python 확장으로 만듭니다. 손모양 인식 없이 버튼 클릭만으로 같은 알고리즘(마르코프 체인·Q-learning)을 체험하는 가벼운 웹 데모는 별도로 만들어 Cloudflare 등에 배포할 수 있습니다(심화 선택 과제, 이 페이지 맨 아래 참고).

1단계. 필요한 라이브러리 추가 설치

6장의 rps_ai_game_app 폴더를 그대로 사용합니다. 승률 그래프를 그리기 위한 matplotlib만 추가로 설치하면 됩니다.

폴더 이동 + matplotlib 설치
cd $HOME/rps_ai_game_app
python3 -m pip install matplotlib
모델 파일이 꼭 필요합니다: 이 모듈은 6장의 model/rps_model.joblib을 그대로 불러와 씁니다. 아직 6장을 하지 않았다면 먼저 rps/index.html 교재의 1~5단계(이미지 수집·모델 학습)를 끝내세요.

2단계. 마르코프 체인 적응형 상대 만들기

원리: 내가 "직전에 낸 수"를 key로, "그 다음에 실제로 낸 수"가 몇 번씩 나왔는지 표(transition table)에 기록합니다. 대결할 때마다 이 표를 보고 "지금 직전 수 다음엔 어떤 수가 가장 많이 나왔었는지" 찾아서, 그 수를 이기는 패를 냅니다. 이 표는 model/markov_transition.json에 저장되어 프로그램을 다시 실행해도 이어서 쌓입니다.
파일 만드는 방법: CMD에서 notepad 5_adaptive_rps_markov.py를 실행해 새 파일을 만들고, 아래 전체 코드를 붙여넣은 뒤 Ctrl+S로 저장합니다.
파일 만들기 명령어
cd $HOME/rps_ai_game_app
nano 5_adaptive_rps_markov.py
5_adaptive_rps_markov.py 전체 코드
import os
import time
import random
import json
import csv
import joblib
import cv2
import numpy as np

# 마르코프 체인 기반 적응형 가위바위보 상대 (온라인 학습 - 강화학습 아님)
MODEL_PATH = "model/rps_model.joblib"
TRANSITION_PATH = "model/markov_transition.json"
LOG_PATH = "model/markov_log.csv"

if not os.path.exists(MODEL_PATH):
    print("모델 파일이 없습니다. 먼저 python3 2_train_rps_model.py 를 실행하세요.")
    exit()

bundle = joblib.load(MODEL_PATH)
model = bundle["model"]

CHOICES = ["가위", "바위", "보"]
DISPLAY_NAME = {"가위": "Scissors", "바위": "Rock", "보": "Paper"}
CONFIDENCE_THRESHOLD = 50
BEATS = {"가위": "보", "바위": "가위", "보": "바위"}  # key가 value를 이긴다
COUNTER = {v: k for k, v in BEATS.items()}  # 이 수를 이기려면 어떤 수를 내야 하는지

def extract_features(image):
    image = cv2.resize(image, (96, 96))
    hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
    hist = cv2.calcHist([hsv], [0, 1, 2], None, [8, 8, 8], [0, 180, 0, 256, 0, 256])
    hist = cv2.normalize(hist, hist).flatten()
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    gray = cv2.resize(gray, (64, 64))
    hog = cv2.HOGDescriptor(_winSize=(64, 64), _blockSize=(16, 16), _blockStride=(8, 8), _cellSize=(8, 8), _nbins=9)
    return np.concatenate([hist, hog.compute(gray).flatten()])

def predict_hand(image):
    feature = extract_features(image).reshape(1, -1)
    pred = model.predict(feature)[0]
    probs = model.predict_proba(feature)[0]
    confidence = float(np.max(probs)) * 100
    return pred, confidence

def load_transition():
    if os.path.exists(TRANSITION_PATH):
        with open(TRANSITION_PATH, "r", encoding="utf-8") as f:
            data = json.load(f)
        return data.get("table", {}), data.get("last_move")
    return {}, None

def save_transition(table, last_move):
    with open(TRANSITION_PATH, "w", encoding="utf-8") as f:
        json.dump({"table": table, "last_move": last_move}, f, ensure_ascii=False, indent=2)

def predict_next_move(table, last_move):
    key = last_move if last_move is not None else "START"
    if key not in table or sum(table[key].values()) == 0:
        return random.choice(CHOICES)
    counts = table[key]
    return max(counts, key=counts.get)

def update_transition(table, last_move, actual_next_move):
    key = last_move if last_move is not None else "START"
    if key not in table:
        table[key] = {c: 0 for c in CHOICES}
    table[key][actual_next_move] += 1
    return table

def log_round(round_no, user_choice, computer_choice, comp_result):
    file_exists = os.path.exists(LOG_PATH)
    with open(LOG_PATH, "a", encoding="utf-8", newline="") as f:
        writer = csv.writer(f)
        if not file_exists:
            writer.writerow(["round", "user", "computer", "result"])
        writer.writerow([round_no, user_choice, computer_choice, comp_result])

table, last_move = load_transition()
round_no = sum(sum(v.values()) for v in table.values())

cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
if not cap.isOpened():
    cap = cv2.VideoCapture(0)
if not cap.isOpened():
    print("웹캠을 열 수 없습니다.")
    exit()

STATE_WAIT, STATE_COUNT, STATE_RESULT = "wait", "count", "result"
state = STATE_WAIT
count_start = 0.0
COUNT_SECONDS = 3
result_start = 0.0
RESULT_SECONDS = 3

user_choice, computer_choice, comp_result = "", "", ""
user_confidence = 0.0
comp_win, comp_lose, comp_draw = 0, 0, 0

print(f"지금까지 누적된 대결 기록: {round_no}판 (markov_transition.json)")
print("스페이스바(또는 s)를 누르면 카운트다운이 시작됩니다. q: 종료")

while True:
    ret, frame = cap.read()
    if not ret:
        break
    frame = cv2.flip(frame, 1)

    h, w = frame.shape[:2]
    box_size = min(w, h) // 2
    x1, y1 = w // 2 - box_size // 2, h // 2 - box_size // 2
    x2, y2 = x1 + box_size, y1 + box_size
    roi = frame[y1:y2, x1:x2]

    now = time.time()

    if state == STATE_COUNT:
        remaining = COUNT_SECONDS - (now - count_start)
        if remaining > 0:
            text = str(int(remaining) + 1)
        else:
            pred, conf = predict_hand(roi)
            user_choice, user_confidence = pred, conf
            if conf < CONFIDENCE_THRESHOLD:
                computer_choice = ""
                comp_result = "재시도"
            else:
                predicted_next = predict_next_move(table, last_move)
                computer_choice = COUNTER[predicted_next]
                if computer_choice == user_choice:
                    comp_result = "DRAW"
                    comp_draw += 1
                elif BEATS.get(computer_choice) == user_choice:
                    comp_result = "WIN"
                    comp_win += 1
                else:
                    comp_result = "LOSE"
                    comp_lose += 1
                table = update_transition(table, last_move, user_choice)
                last_move = user_choice
                round_no += 1
                save_transition(table, last_move)
                log_round(round_no, user_choice, computer_choice, comp_result)
            state = STATE_RESULT
            result_start = now
            text = "SHOOT!"
        cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 255), 3)
        cv2.putText(frame, text, (x1 + box_size // 2 - 60, y1 + box_size // 2),
                    cv2.FONT_HERSHEY_SIMPLEX, 2.0, (0, 255, 255), 4)

    elif state == STATE_RESULT:
        if comp_result == "재시도":
            box_color = (0, 165, 255)
        else:
            box_color = (0, 255, 0) if comp_result == "WIN" else (0, 0, 255) if comp_result == "LOSE" else (0, 255, 255)
        cv2.rectangle(frame, (x1, y1), (x2, y2), box_color, 3)
        cv2.putText(frame, f"ME: {DISPLAY_NAME.get(user_choice, user_choice)} ({user_confidence:.0f}%)", (20, 40),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2)
        if comp_result == "재시도":
            cv2.putText(frame, "CONFIDENCE LOW - TRY AGAIN", (20, 135),
                        cv2.FONT_HERSHEY_SIMPLEX, 1.1, box_color, 3)
        else:
            cv2.putText(frame, f"AI: {DISPLAY_NAME.get(computer_choice, computer_choice)}", (20, 80),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2)
            cv2.putText(frame, f"COMPUTER {comp_result}", (20, 135),
                        cv2.FONT_HERSHEY_SIMPLEX, 1.3, box_color, 3)
        if now - result_start >= RESULT_SECONDS:
            state = STATE_WAIT

    else:
        cv2.rectangle(frame, (x1, y1), (x2, y2), (255, 255, 255), 2)
        cv2.putText(frame, "Press SPACE to start", (x1, y1 - 15),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2)

    total_games = comp_win + comp_lose + comp_draw
    comp_rate = (comp_win / total_games * 100) if total_games > 0 else 0
    cv2.putText(frame, f"COMPUTER WIN RATE (this session): {comp_rate:.0f}%  ({comp_win}W {comp_lose}L {comp_draw}D)", (20, h - 20),
                cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
    cv2.putText(frame, "SPACE: start   q: quit", (20, h - 50),
                cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)

    cv2.imshow("Adaptive RPS - Markov Chain", frame)

    key = cv2.waitKey(1) & 0xFF
    if key == ord("q"):
        break
    if (key == ord(" ") or key == ord("s")) and state == STATE_WAIT:
        state = STATE_COUNT
        count_start = now

cap.release()
cv2.destroyAllWindows()
print(f"이번 세션 컴퓨터 전적 - 승:{comp_win} 패:{comp_lose} 무:{comp_draw}")
print(f"누적 학습 데이터: {round_no}판 (다음에 실행하면 이어서 더 똑똑해집니다)")
실행 명령어
cd $HOME/rps_ai_game_app
python3 5_adaptive_rps_markov.py
30~40판 정도 플레이해보세요. 같은 수만 반복해서 내거나, 특정 패턴(가위→바위→보 순서 반복 등)으로 내면 컴퓨터가 금방 그 패턴을 외워서 이기기 시작하는 걸 느낄 수 있습니다. 반대로 최대한 무작위로 내려고 하면 컴퓨터 승률이 33%(순수 확률) 근처에서 잘 안 올라갈 수도 있습니다 — 그것도 유효한 관찰입니다.

3단계. Q-learning(강화학습) 적응형 상대 만들기

원리: state(직전에 내가 낸 수), action(컴퓨터가 낼 수), reward(컴퓨터 기준 승=+1, 무=0, 패=−1)를 정의합니다. 매 판이 끝날 때마다 Q[state][action] 값을 "실제로 받은 보상"과 "다음 상태에서 기대할 수 있는 최선의 보상"을 이용해 조금씩 업데이트합니다(Q-learning 공식). 가끔(15% 확률)은 일부러 무작위 수를 내서 탐험(exploration)도 합니다. 이 Q-table은 model/qlearning_qtable.json에 저장되어 계속 이어서 쌓입니다.
파일 만드는 방법: CMD에서 notepad 6_adaptive_rps_qlearning.py를 실행해 새 파일을 만들고, 아래 전체 코드를 붙여넣은 뒤 Ctrl+S로 저장합니다.
파일 만들기 명령어
cd $HOME/rps_ai_game_app
nano 6_adaptive_rps_qlearning.py
6_adaptive_rps_qlearning.py 전체 코드
import os
import time
import random
import json
import csv
import joblib
import cv2
import numpy as np

# Q-learning 기반 적응형 가위바위보 상대 (진짜 강화학습)
MODEL_PATH = "model/rps_model.joblib"
QTABLE_PATH = "model/qlearning_qtable.json"
LOG_PATH = "model/qlearning_log.csv"

if not os.path.exists(MODEL_PATH):
    print("모델 파일이 없습니다. 먼저 python3 2_train_rps_model.py 를 실행하세요.")
    exit()

bundle = joblib.load(MODEL_PATH)
model = bundle["model"]

CHOICES = ["가위", "바위", "보"]
DISPLAY_NAME = {"가위": "Scissors", "바위": "Rock", "보": "Paper"}
CONFIDENCE_THRESHOLD = 50
BEATS = {"가위": "보", "바위": "가위", "보": "바위"}

ALPHA = 0.3    # 학습률: 새 경험을 얼마나 빨리 반영할지
GAMMA = 0.9    # 할인율: 미래 보상을 얼마나 중요하게 볼지
EPSILON = 0.15 # 탐험 확률: 가끔 일부러 다른 수를 시도

def extract_features(image):
    image = cv2.resize(image, (96, 96))
    hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
    hist = cv2.calcHist([hsv], [0, 1, 2], None, [8, 8, 8], [0, 180, 0, 256, 0, 256])
    hist = cv2.normalize(hist, hist).flatten()
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    gray = cv2.resize(gray, (64, 64))
    hog = cv2.HOGDescriptor(_winSize=(64, 64), _blockSize=(16, 16), _blockStride=(8, 8), _cellSize=(8, 8), _nbins=9)
    return np.concatenate([hist, hog.compute(gray).flatten()])

def predict_hand(image):
    feature = extract_features(image).reshape(1, -1)
    pred = model.predict(feature)[0]
    probs = model.predict_proba(feature)[0]
    confidence = float(np.max(probs)) * 100
    return pred, confidence

def load_qtable():
    if os.path.exists(QTABLE_PATH):
        with open(QTABLE_PATH, "r", encoding="utf-8") as f:
            data = json.load(f)
        return data.get("q", {}), data.get("last_state", "START")
    return {}, "START"

def save_qtable(q, last_state):
    with open(QTABLE_PATH, "w", encoding="utf-8") as f:
        json.dump({"q": q, "last_state": last_state}, f, ensure_ascii=False, indent=2)

def get_q(q, state):
    if state not in q:
        q[state] = {a: 0.0 for a in CHOICES}
    return q[state]

def choose_action(q, state):
    if random.random() < EPSILON:
        return random.choice(CHOICES)
    q_values = get_q(q, state)
    best = max(q_values.values())
    best_actions = [a for a, v in q_values.items() if v == best]
    return random.choice(best_actions)

def update_q(q, state, action, reward, next_state):
    q_values = get_q(q, state)
    next_q_values = get_q(q, next_state)
    best_next = max(next_q_values.values())
    q_values[action] += ALPHA * (reward + GAMMA * best_next - q_values[action])

def log_round(round_no, user_choice, computer_choice, comp_result):
    file_exists = os.path.exists(LOG_PATH)
    with open(LOG_PATH, "a", encoding="utf-8", newline="") as f:
        writer = csv.writer(f)
        if not file_exists:
            writer.writerow(["round", "user", "computer", "result"])
        writer.writerow([round_no, user_choice, computer_choice, comp_result])

q, last_state = load_qtable()
if os.path.exists(LOG_PATH):
    with open(LOG_PATH, encoding="utf-8") as f:
        round_no = max(sum(1 for _ in f) - 1, 0)
else:
    round_no = 0

cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
if not cap.isOpened():
    cap = cv2.VideoCapture(0)
if not cap.isOpened():
    print("웹캠을 열 수 없습니다.")
    exit()

STATE_WAIT, STATE_COUNT, STATE_RESULT = "wait", "count", "result"
state_ui = STATE_WAIT
count_start = 0.0
COUNT_SECONDS = 3
result_start = 0.0
RESULT_SECONDS = 3

user_choice, computer_choice, comp_result = "", "", ""
user_confidence = 0.0
comp_win, comp_lose, comp_draw = 0, 0, 0

print(f"지금까지 누적된 대결 기록: {round_no}판 (qlearning_qtable.json)")
print("스페이스바(또는 s)를 누르면 카운트다운이 시작됩니다. q: 종료")

while True:
    ret, frame = cap.read()
    if not ret:
        break
    frame = cv2.flip(frame, 1)

    h, w = frame.shape[:2]
    box_size = min(w, h) // 2
    x1, y1 = w // 2 - box_size // 2, h // 2 - box_size // 2
    x2, y2 = x1 + box_size, y1 + box_size
    roi = frame[y1:y2, x1:x2]

    now = time.time()

    if state_ui == STATE_COUNT:
        remaining = COUNT_SECONDS - (now - count_start)
        if remaining > 0:
            text = str(int(remaining) + 1)
        else:
            pred, conf = predict_hand(roi)
            user_choice, user_confidence = pred, conf
            if conf < CONFIDENCE_THRESHOLD:
                computer_choice = ""
                comp_result = "재시도"
            else:
                computer_choice = choose_action(q, last_state)
                if computer_choice == user_choice:
                    comp_result, reward = "DRAW", 0
                    comp_draw += 1
                elif BEATS.get(computer_choice) == user_choice:
                    comp_result, reward = "WIN", 1
                    comp_win += 1
                else:
                    comp_result, reward = "LOSE", -1
                    comp_lose += 1
                next_state = user_choice
                update_q(q, last_state, computer_choice, reward, next_state)
                last_state = next_state
                round_no += 1
                save_qtable(q, last_state)
                log_round(round_no, user_choice, computer_choice, comp_result)
            state_ui = STATE_RESULT
            result_start = now
            text = "SHOOT!"
        cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 255), 3)
        cv2.putText(frame, text, (x1 + box_size // 2 - 60, y1 + box_size // 2),
                    cv2.FONT_HERSHEY_SIMPLEX, 2.0, (0, 255, 255), 4)

    elif state_ui == STATE_RESULT:
        if comp_result == "재시도":
            box_color = (0, 165, 255)
        else:
            box_color = (0, 255, 0) if comp_result == "WIN" else (0, 0, 255) if comp_result == "LOSE" else (0, 255, 255)
        cv2.rectangle(frame, (x1, y1), (x2, y2), box_color, 3)
        cv2.putText(frame, f"ME: {DISPLAY_NAME.get(user_choice, user_choice)} ({user_confidence:.0f}%)", (20, 40),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2)
        if comp_result == "재시도":
            cv2.putText(frame, "CONFIDENCE LOW - TRY AGAIN", (20, 135),
                        cv2.FONT_HERSHEY_SIMPLEX, 1.1, box_color, 3)
        else:
            cv2.putText(frame, f"AI: {DISPLAY_NAME.get(computer_choice, computer_choice)}", (20, 80),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2)
            cv2.putText(frame, f"COMPUTER {comp_result}", (20, 135),
                        cv2.FONT_HERSHEY_SIMPLEX, 1.3, box_color, 3)
        if now - result_start >= RESULT_SECONDS:
            state_ui = STATE_WAIT

    else:
        cv2.rectangle(frame, (x1, y1), (x2, y2), (255, 255, 255), 2)
        cv2.putText(frame, "Press SPACE to start", (x1, y1 - 15),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2)

    total_games = comp_win + comp_lose + comp_draw
    comp_rate = (comp_win / total_games * 100) if total_games > 0 else 0
    cv2.putText(frame, f"COMPUTER WIN RATE (this session): {comp_rate:.0f}%  ({comp_win}W {comp_lose}L {comp_draw}D)", (20, h - 20),
                cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
    cv2.putText(frame, "SPACE: start   q: quit", (20, h - 50),
                cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)

    cv2.imshow("Adaptive RPS - Q-learning", frame)

    key = cv2.waitKey(1) & 0xFF
    if key == ord("q"):
        break
    if (key == ord(" ") or key == ord("s")) and state_ui == STATE_WAIT:
        state_ui = STATE_COUNT
        count_start = now

cap.release()
cv2.destroyAllWindows()
print(f"이번 세션 컴퓨터 전적 - 승:{comp_win} 패:{comp_lose} 무:{comp_draw}")
print(f"누적 학습 데이터: {round_no}판 (다음에 실행하면 이어서 더 똑똑해집니다)")
실행 명령어
cd $HOME/rps_ai_game_app
python3 6_adaptive_rps_qlearning.py
EPSILON(탐험 확률)을 바꿔보세요: 0.15를 0으로 바꾸면 컴퓨터가 항상 "지금까지 가장 좋다고 배운 수"만 내서(탐험을 전혀 안 함) 당장은 승률이 더 높아 보일 수 있지만, 상대의 패턴이 바뀌었을 때 적응을 못 합니다. 반대로 0.5처럼 너무 높이면 거의 무작위로 내는 셈이라 승률이 안 오릅니다. 탐험과 활용(exploration vs exploitation)의 균형이 강화학습의 핵심 주제입니다.

4단계. 승률 곡선 비교 그래프 만들기

파일 만드는 방법: CMD에서 notepad 7_plot_winrate.py를 실행해 새 파일을 만들고, 아래 전체 코드를 붙여넣은 뒤 Ctrl+S로 저장합니다.
파일 만들기 명령어
cd $HOME/rps_ai_game_app
nano 7_plot_winrate.py
7_plot_winrate.py 전체 코드
import csv
import os
import matplotlib
matplotlib.rcParams["font.family"] = "Malgun Gothic"   # Windows 한글 폰트 (그래프 글자 깨짐 방지)
matplotlib.rcParams["axes.unicode_minus"] = False
import matplotlib.pyplot as plt

# 마르코프 체인 vs Q-learning 승률 곡선 비교
MARKOV_LOG = "model/markov_log.csv"
QLEARNING_LOG = "model/qlearning_log.csv"
WINDOW = 20

def load_results(path):
    if not os.path.exists(path):
        return []
    with open(path, encoding="utf-8") as f:
        reader = csv.DictReader(f)
        return [row["result"] for row in reader]

def rolling_win_rate(results, window=WINDOW):
    rates = []
    for i in range(len(results)):
        start = max(0, i - window + 1)
        chunk = results[start:i + 1]
        wins = sum(1 for r in chunk if r == "WIN")
        rates.append(wins / len(chunk) * 100)
    return rates

markov_results = load_results(MARKOV_LOG)
qlearning_results = load_results(QLEARNING_LOG)

if not markov_results and not qlearning_results:
    print("아직 쌓인 대결 기록이 없습니다. 5_adaptive_rps_markov.py 또는 6_adaptive_rps_qlearning.py를 먼저 플레이하세요.")
    exit()

plt.figure(figsize=(9, 5))
if markov_results:
    plt.plot(range(1, len(markov_results) + 1), rolling_win_rate(markov_results),
              label="마르코프 체인 (온라인 학습)", color="#2563eb")
if qlearning_results:
    plt.plot(range(1, len(qlearning_results) + 1), rolling_win_rate(qlearning_results),
              label="Q-learning (강화학습)", color="#dc2626")

plt.axhline(33.3, color="gray", linestyle="--", linewidth=1, label="무작위 기준선 (33.3%)")
plt.xlabel("대결 판수")
plt.ylabel(f"최근 {WINDOW}판 컴퓨터 승률 (%)")
plt.title("가위바위보 적응형 AI - 컴퓨터 승률 변화")
plt.ylim(0, 100)
plt.legend()
plt.tight_layout()
plt.savefig("model/win_rate_comparison.png")
print("그래프를 model/win_rate_comparison.png 로 저장했습니다.")
plt.show()
실행 명령어
cd $HOME/rps_ai_game_app
python3 7_plot_winrate.py
그래프 글자가 깨져 보인다면: 코드 맨 위의 matplotlib.rcParams["font.family"] = "Malgun Gothic"이 한글 깨짐을 막기 위한 부분입니다. OpenCV의 cv2.putText가 한글을 못 그리는 것과 같은 종류의 문제가 matplotlib에도 있는데, Windows에 기본 설치된 맑은 고딕 폰트를 지정해서 해결했습니다. 그래도 깨진다면 CMD에서 py -3.12 -c "import matplotlib.font_manager as fm; print([f.name for f in fm.fontManager.ttflist if 'Gothic' in f.name or 'Malgun' in f.name])"로 폰트가 설치되어 있는지 확인하세요.

5단계. 관찰 및 토론

관찰 포인트해석
그래프가 꾸준히 올라간다내가 낸 수에 어떤 패턴이 있었고, AI가 그 패턴을 학습해서 점점 더 잘 이긴다는 뜻입니다.
그래프가 33% 근처에서 평평하다내가 거의 완전히 무작위로 냈다는 뜻입니다. 가위바위보는 이론적으로 상대가 100% 무작위면 어떤 전략도 33.3%보다 나을 수 없습니다 — AI의 한계가 아니라 문제 자체의 한계입니다.
마르코프 체인이 더 빨리 좋아진다마르코프 체인은 바로 직전 수 하나만 보고 즉시 반응하기 때문에 적응이 빠릅니다. 대신 상대가 패턴을 자주 바꾸면 쉽게 속습니다.
Q-learning이 초반엔 비슷한데 나중에 따라잡거나 더 좋아진다Q-learning은 탐험(EPSILON) 때문에 초반엔 손해를 보지만, 장기적으로 더 안정적인 전략을 찾을 수 있습니다. 데이터가 적을 땐 손해, 많을 땐 이득 — 강화학습의 전형적인 트레이드오프입니다.
생각해보기: 두 세션(마르코프 vs Q-learning) 각각 같은 사람이 같은 방식으로 냈는데도 그래프 모양이 다르다면, 그건 알고리즘 차이 때문입니다. 반대로 한 사람은 패턴 있게, 다른 사람은 무작위에 가깝게 냈는데 같은 알고리즘을 썼다면, 그 차이는 데이터(상대방의 행동) 때문입니다. 이 두 가지를 구분해서 보는 것이 머신러닝 실험 설계의 핵심입니다.

잘 안 될 때

증상해결
모델 파일이 없다고 나옴6장(rps/index.html)의 1~5단계를 먼저 끝내서 model/rps_model.joblib을 만드세요.
No module named matplotlibpy -3.12 -m pip install matplotlib을 실행하세요.
그래프 창이 안 뜨고 멈춘 것처럼 보임정상입니다. plt.show()는 그래프 창을 닫을 때까지 프로그램이 기다립니다. 창을 닫으면 콘솔로 돌아옵니다.
승률이 전혀 안 올라감무작위에 가깝게 냈을 가능성이 높습니다(정상적인 결과입니다). 일부러 패턴을 넣어(예: 가위→바위→보 반복) 다시 플레이해 AI가 그 패턴을 배우는지 확인해보세요.
처음부터 다시 배우게 하고 싶음model/markov_transition.json, model/markov_log.csv, model/qlearning_qtable.json, model/qlearning_log.csv 파일을 지우면 학습 데이터가 초기화됩니다.

체크리스트

심화 선택 과제. 나만의 웹 버전 만들어 Cloudflare에 직접 배포하기

무엇을 하는 단계인가요? 지금까지는 웹캠으로 손모양을 인식하는 로컬 Python 버전을 만들었습니다. 이번에는 같은 알고리즘(마르코프 체인·Q-learning)을 손모양 인식 없이 가위/바위/보 버튼 클릭으로 동작하는 웹페이지(HTML/JS)로 다시 만들고, 내 Cloudflare 계정에 직접 배포해서 전 세계 누구나 접속할 수 있는 나만의 웹사이트를 완성합니다.
예시: https://rps-adaptive-ai.pages.dev — 완성하면 이런 형태의 사이트가 됩니다. 먼저 한번 플레이해보고 시작하세요.

1) 실습 폴더 만들기

이번엔 Python이 아니라 HTML/JavaScript만 사용합니다. 서버·설치 과정이 필요 없는 순수 웹페이지입니다.
폴더 생성 명령어
cd $HOME
mkdir rps_web_app
cd rps_web_app

2) index.html 작성하기

파일 만드는 방법: CMD에서 notepad index.html을 실행해 새 파일을 만들고, 아래 전체 코드를 붙여넣은 뒤 Ctrl+S로 저장합니다. 이 코드 안에 가위바위보 UI, 마르코프 체인·Q-learning 알고리즘, 승률 그래프가 전부 들어 있습니다(single-file 웹앱).
파일 만들기 명령어
nano index.html
index.html 전체 코드
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>데이터가 쌓일수록 똑똑해지는 가위바위보 AI</title>
<meta name="description" content="가위바위보를 할수록 AI가 내 패턴을 학습해 승률이 올라가는 웹 데모. 마르코프 체인과 Q-learning(강화학습) 두 방식을 비교합니다.">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<style>
:root{
  --bg:#f5f7fb; --card:#ffffff; --ink:#172033; --muted:#64748b; --line:#e5e7eb;
  --accent1:#6d28d9; --accent2:#06b6d4;
  --win:#16a34a; --lose:#dc2626; --draw:#ca8a04;
  --shadow:0 10px 26px rgba(15,23,42,.08);
}
@media (prefers-color-scheme: dark){
  :root:not([data-theme="light"]){
    --bg:#0b1020; --card:#131a2e; --ink:#e7ecf7; --muted:#94a3b8; --line:#24304a;
    --shadow:0 10px 26px rgba(0,0,0,.4);
  }
}
:root[data-theme="dark"]{
  --bg:#0b1020; --card:#131a2e; --ink:#e7ecf7; --muted:#94a3b8; --line:#24304a;
  --shadow:0 10px 26px rgba(0,0,0,.4);
}
*{box-sizing:border-box}
body{
  margin:0; background:var(--bg); color:var(--ink);
  font-family:-apple-system,BlinkMacSystemFont,"Noto Sans KR","Malgun Gothic",Segoe UI,sans-serif;
  line-height:1.65; padding:0 16px 48px;
}
.wrap{max-width:760px;margin:0 auto}
header{padding:28px 0 10px}
h1{font-size:24px;margin:0 0 8px;font-weight:900;background:linear-gradient(135deg,var(--accent1),var(--accent2));-webkit-background-clip:text;background-clip:text;color:transparent}
.sub{color:var(--muted);font-size:14px;margin:0}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:20px;margin:16px 0;box-shadow:var(--shadow)}
h2{font-size:17px;margin:0 0 12px}
.algo-toggle{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}
.algo-btn{flex:1;min-width:140px;padding:12px;border-radius:12px;border:1px solid var(--line);background:transparent;color:var(--ink);font-weight:800;font-size:13px;cursor:pointer;text-align:center}
.algo-btn.active{background:linear-gradient(135deg,var(--accent1),var(--accent2));color:#fff;border-color:transparent}
.choices{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:14px 0}
.choice-btn{padding:18px 8px;border-radius:14px;border:1px solid var(--line);background:var(--bg);font-size:28px;cursor:pointer;transition:transform .12s}
.choice-btn:hover{transform:translateY(-2px)}
.choice-btn:active{transform:translateY(0)}
.choice-label{display:block;font-size:12px;color:var(--muted);margin-top:4px;font-weight:700}
.result-box{text-align:center;padding:18px;border-radius:14px;margin:14px 0;min-height:86px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px}
.result-box.win{background:rgba(22,163,74,.12)}
.result-box.lose{background:rgba(220,38,38,.12)}
.result-box.draw{background:rgba(202,138,4,.12)}
.result-box.idle{background:var(--bg);color:var(--muted)}
.result-verdict{font-size:20px;font-weight:900}
.result-verdict.win{color:var(--win)}
.result-verdict.lose{color:var(--lose)}
.result-verdict.draw{color:var(--draw)}
.result-detail{font-size:13px;color:var(--muted)}
.score-row{display:flex;justify-content:space-around;text-align:center;margin:10px 0}
.score-item b{display:block;font-size:20px}
.score-item span{font-size:12px;color:var(--muted)}
.notice{border-radius:12px;padding:12px 14px;margin:10px 0;font-size:13px;line-height:1.6;border:1px solid}
.blue{background:rgba(37,99,235,.08);border-color:rgba(37,99,235,.25)}
.purple{background:rgba(109,40,217,.08);border-color:rgba(109,40,217,.25)}
.chart-wrap{position:relative;height:260px}
.btn-row{display:flex;gap:8px;margin-top:14px;flex-wrap:wrap}
.ghost-btn{flex:1;min-width:120px;padding:10px;border-radius:10px;border:1px solid var(--line);background:transparent;color:var(--ink);font-weight:800;font-size:12px;cursor:pointer}
.ghost-btn:hover{background:var(--bg)}
.footer{text-align:center;color:var(--muted);font-size:12px;padding:20px 0}
code{background:rgba(127,127,127,.15);border-radius:4px;padding:1px 5px;font-family:"D2Coding","Consolas",monospace}
@media(max-width:420px){.choice-btn{font-size:22px;padding:14px 6px}}
</style>
</head>
<body>
<div class="wrap">
  <header>
    <h1>✂️ 대결할수록 똑똑해지는 가위바위보 AI</h1>
    <p class="sub">같은 알고리즘, 다른 결과 — 마르코프 체인(온라인 학습) vs Q-learning(강화학습)을 직접 비교해보세요. 기록은 이 브라우저에만 저장됩니다.</p>
  </header>

  <div class="card">
    <h2>1. 상대 AI 알고리즘 선택</h2>
    <div class="algo-toggle">
      <button class="algo-btn active" data-algo="markov">마르코프 체인<br><small>(온라인 학습)</small></button>
      <button class="algo-btn" data-algo="qlearning">Q-learning<br><small>(강화학습)</small></button>
    </div>
    <div class="notice blue" id="algoExplain"></div>
  </div>

  <div class="card">
    <h2>2. 가위바위보 내기</h2>
    <div class="choices">
      <button class="choice-btn" data-choice="가위">✌️<span class="choice-label">가위</span></button>
      <button class="choice-btn" data-choice="바위">✊<span class="choice-label">바위</span></button>
      <button class="choice-btn" data-choice="보">✋<span class="choice-label">보</span></button>
    </div>
    <div class="result-box idle" id="resultBox">
      <div class="result-verdict" id="resultVerdict">버튼을 눌러 시작하세요</div>
      <div class="result-detail" id="resultDetail">나: - · AI: -</div>
    </div>
    <div class="score-row">
      <div class="score-item"><b id="scoreWin">0</b><span>AI 승</span></div>
      <div class="score-item"><b id="scoreLose">0</b><span>AI 패</span></div>
      <div class="score-item"><b id="scoreDraw">0</b><span>무승부</span></div>
      <div class="score-item"><b id="scoreRate">0%</b><span>AI 승률</span></div>
    </div>
  </div>

  <div class="card">
    <h2>3. 승률 곡선 비교 (최근 20판 이동평균)</h2>
    <div class="chart-wrap"><canvas id="winRateChart"></canvas></div>
    <div class="notice purple">그래프가 33.3%(점선) 근처에서 평평하면 당신이 거의 무작위로 냈다는 뜻입니다 — 상대가 완전히 무작위면 어떤 전략도 33.3%보다 나을 수 없는 게 가위바위보의 수학적 한계입니다.</div>
    <div class="btn-row">
      <button class="ghost-btn" id="resetCurrentBtn">현재 알고리즘 기록 초기화</button>
      <button class="ghost-btn" id="resetAllBtn">전체 초기화</button>
    </div>
  </div>

  <div class="footer">ROBOGRAM AI·SW · 7단계 심화 모듈 웹 데모 · 기록은 서버로 전송되지 않고 이 브라우저에만 저장됩니다</div>
</div>

<script>
(function(){
  "use strict";
  var CHOICES = ["가위","바위","보"];
  var BEATS = {"가위":"보","바위":"가위","보":"바위"}; // key가 value를 이긴다
  var COUNTER = {}; Object.keys(BEATS).forEach(function(k){ COUNTER[BEATS[k]] = k; });
  var WINDOW = 20;
  var ALPHA = 0.3, GAMMA = 0.9, EPSILON = 0.15;

  var ALGO_EXPLAIN = {
    markov: "직전에 당신이 낸 수를 보고 '다음엔 뭘 낼지' 통계로 예측해 그걸 이기는 패를 냅니다. 보상 개념이 없는 순수 패턴 예측이라 강화학습은 아닙니다.",
    qlearning: "state(직전 수)·action(AI의 수)·reward(승+1/무0/패-1)를 정의하고, 매 판 결과로 Q-table을 업데이트하는 진짜 강화학습입니다. 가끔(15%) 일부러 다른 수를 시도(탐험)합니다."
  };

  var currentAlgo = "markov";

  function storageKey(algo){ return "rps_adaptive_" + algo; }

  function defaultState(algo){
    if (algo === "markov") return { table:{}, lastMove:null, history:[] };
    return { q:{}, lastState:"START", history:[] };
  }

  function loadState(algo){
    try{
      var raw = localStorage.getItem(storageKey(algo));
      if (!raw) return defaultState(algo);
      var parsed = JSON.parse(raw);
      return parsed || defaultState(algo);
    }catch(e){ return defaultState(algo); }
  }

  function saveState(algo, state){
    try{ localStorage.setItem(storageKey(algo), JSON.stringify(state)); }catch(e){}
  }

  function emptyCounts(){ var o={}; CHOICES.forEach(function(c){o[c]=0;}); return o; }

  function predictNextMoveMarkov(table, lastMove){
    var key = lastMove === null || lastMove === undefined ? "START" : lastMove;
    var counts = table[key];
    if (!counts) return CHOICES[Math.floor(Math.random()*3)];
    var total = CHOICES.reduce(function(s,c){return s+counts[c];},0);
    if (total === 0) return CHOICES[Math.floor(Math.random()*3)];
    var best = CHOICES[0];
    CHOICES.forEach(function(c){ if (counts[c] > counts[best]) best = c; });
    return best;
  }

  function updateTransition(table, lastMove, actualNext){
    var key = lastMove === null || lastMove === undefined ? "START" : lastMove;
    if (!table[key]) table[key] = emptyCounts();
    table[key][actualNext] += 1;
  }

  function getQ(q, state){
    if (!q[state]) q[state] = emptyCounts();
    return q[state];
  }

  function chooseAction(q, state){
    if (Math.random() < EPSILON) return CHOICES[Math.floor(Math.random()*3)];
    var qv = getQ(q, state);
    var best = CHOICES[0];
    CHOICES.forEach(function(c){ if (qv[c] > qv[best]) best = c; });
    return best;
  }

  function updateQ(q, state, action, reward, nextState){
    var qv = getQ(q, state);
    var nextQv = getQ(q, nextState);
    var bestNext = Math.max(nextQv["가위"], nextQv["바위"], nextQv["보"]);
    qv[action] += ALPHA * (reward + GAMMA * bestNext - qv[action]);
  }

  function judge(userChoice, computerChoice){
    if (userChoice === computerChoice) return "DRAW";
    if (BEATS[computerChoice] === userChoice) return "WIN";  // AI 기준 승
    return "LOSE";
  }

  function playRound(userChoice){
    var state = loadState(currentAlgo);
    var computerChoice, result;

    if (currentAlgo === "markov"){
      var predictedNext = predictNextMoveMarkov(state.table, state.lastMove);
      computerChoice = COUNTER[predictedNext];
      result = judge(userChoice, computerChoice);
      updateTransition(state.table, state.lastMove, userChoice);
      state.lastMove = userChoice;
    } else {
      computerChoice = chooseAction(state.q, state.lastState);
      result = judge(userChoice, computerChoice);
      var reward = result === "WIN" ? 1 : (result === "LOSE" ? -1 : 0);
      updateQ(state.q, state.lastState, computerChoice, reward, userChoice);
      state.lastState = userChoice;
    }

    state.history.push(result);
    saveState(currentAlgo, state);
    renderResult(userChoice, computerChoice, result);
    renderScore(state.history);
    renderChart();
  }

  function renderResult(userChoice, computerChoice, result){
    var box = document.getElementById("resultBox");
    var verdict = document.getElementById("resultVerdict");
    var detail = document.getElementById("resultDetail");
    box.className = "result-box " + result.toLowerCase();
    verdict.className = "result-verdict " + result.toLowerCase();
    verdict.textContent = result === "WIN" ? "AI 승리!" : (result === "LOSE" ? "AI 패배" : "무승부");
    detail.textContent = "나: " + userChoice + " · AI: " + computerChoice;
  }

  function renderScore(history){
    var win = history.filter(function(r){return r==="WIN";}).length;
    var lose = history.filter(function(r){return r==="LOSE";}).length;
    var draw = history.filter(function(r){return r==="DRAW";}).length;
    var total = history.length;
    document.getElementById("scoreWin").textContent = win;
    document.getElementById("scoreLose").textContent = lose;
    document.getElementById("scoreDraw").textContent = draw;
    document.getElementById("scoreRate").textContent = total ? Math.round(win/total*100) + "%" : "0%";
  }

  function rollingWinRate(history){
    var rates = [];
    for (var i=0;i<history.length;i++){
      var start = Math.max(0, i - WINDOW + 1);
      var chunk = history.slice(start, i+1);
      var wins = chunk.filter(function(r){return r==="WIN";}).length;
      rates.push(Math.round(wins/chunk.length*1000)/10);
    }
    return rates;
  }

  var chart = null;
  function renderChart(){
    var markovHistory = loadState("markov").history;
    var qHistory = loadState("qlearning").history;
    var maxLen = Math.max(markovHistory.length, qHistory.length, 1);
    var labels = []; for (var i=1;i<=maxLen;i++) labels.push(i);

    var datasets = [];
    if (markovHistory.length){
      datasets.push({
        label: "마르코프 체인",
        data: rollingWinRate(markovHistory),
        borderColor: "#2563eb", backgroundColor: "transparent", tension:0.25, pointRadius:0, borderWidth:2
      });
    }
    if (qHistory.length){
      datasets.push({
        label: "Q-learning",
        data: rollingWinRate(qHistory),
        borderColor: "#dc2626", backgroundColor: "transparent", tension:0.25, pointRadius:0, borderWidth:2
      });
    }
    datasets.push({
      label: "무작위 기준선 (33.3%)",
      data: labels.map(function(){return 33.3;}),
      borderColor: "#94a3b8", borderDash:[6,4], pointRadius:0, borderWidth:1
    });

    var ctx = document.getElementById("winRateChart").getContext("2d");
    var textColor = getComputedStyle(document.body).color;
    if (chart) chart.destroy();
    chart = new Chart(ctx, {
      type: "line",
      data: { labels: labels, datasets: datasets },
      options: {
        responsive: true, maintainAspectRatio: false,
        scales: {
          y: { min:0, max:100, ticks:{color:textColor}, grid:{color:"rgba(127,127,127,.15)"} },
          x: { ticks:{color:textColor, maxTicksLimit:10}, grid:{display:false} }
        },
        plugins: { legend: { labels:{color:textColor, boxWidth:14, font:{size:11}} } }
      }
    });
  }

  function setAlgo(algo){
    currentAlgo = algo;
    document.querySelectorAll(".algo-btn").forEach(function(btn){
      btn.classList.toggle("active", btn.getAttribute("data-algo") === algo);
    });
    document.getElementById("algoExplain").textContent = ALGO_EXPLAIN[algo];
    var state = loadState(algo);
    renderScore(state.history);
    document.getElementById("resultBox").className = "result-box idle";
    document.getElementById("resultVerdict").textContent = "버튼을 눌러 시작하세요";
    document.getElementById("resultDetail").textContent = "나: - · AI: -";
  }

  document.querySelectorAll(".algo-btn").forEach(function(btn){
    btn.addEventListener("click", function(){ setAlgo(btn.getAttribute("data-algo")); });
  });

  document.querySelectorAll(".choice-btn").forEach(function(btn){
    btn.addEventListener("click", function(){ playRound(btn.getAttribute("data-choice")); });
  });

  document.getElementById("resetCurrentBtn").addEventListener("click", function(){
    if (!confirm("현재 알고리즘(" + (currentAlgo==="markov"?"마르코프 체인":"Q-learning") + ")의 학습 기록을 초기화할까요?")) return;
    localStorage.removeItem(storageKey(currentAlgo));
    setAlgo(currentAlgo);
    renderChart();
  });

  document.getElementById("resetAllBtn").addEventListener("click", function(){
    if (!confirm("두 알고리즘의 기록을 모두 초기화할까요?")) return;
    localStorage.removeItem(storageKey("markov"));
    localStorage.removeItem(storageKey("qlearning"));
    setAlgo(currentAlgo);
    renderChart();
  });

  setAlgo("markov");
  renderChart();
})();
</script>
</body>
</html>

3) 내 컴퓨터에서 미리 테스트하기

서버도, 설치도 필요 없습니다. rps_web_app 폴더 안의 index.html 파일을 그냥 더블클릭하면 크롬 같은 브라우저로 열립니다. 가위/바위/보 버튼을 눌러보고, 알고리즘을 바꿔보고, 그래프가 그려지는지 확인하세요.
확인할 것: ① 버튼을 누르면 결과(승/패/무)가 바로 표시되는가 ② 같은 수만 계속 내면 AI가 금방 이기기 시작하는가 ③ 알고리즘을 Q-learning으로 바꿔도 따로 기록되는가. 모두 잘 되면 배포 준비 완료입니다.

4) Cloudflare 계정 준비하기

설치는 아무것도 필요 없습니다. 이 방법은 Node.js도, CMD 명령어도 필요 없이 브라우저에서 파일을 끌어다 놓기만 하면 끝납니다.
  1. dash.cloudflare.com에서 무료 계정을 만듭니다(이미 있다면 생략).
  2. 가입한 이메일과 비밀번호로 dash.cloudflare.com에 로그인합니다.

5) 드래그 앤 드롭으로 배포하기

파일 탐색기에서 rps_web_app 폴더를 열어두고, 브라우저로 Cloudflare 대시보드를 함께 띄워놓은 뒤 아래 순서를 따라 하세요.
  1. Cloudflare 대시보드 왼쪽 메뉴에서 Workers & Pages를 클릭합니다.
  2. Create application(만들기) → Pages 탭 → Upload assets(직접 업로드) 를 선택합니다. (화면 구성은 Cloudflare가 가끔 바꾸므로, "Pages"·"Upload"·"Direct Upload"라는 단어가 보이는 버튼을 찾으면 됩니다.)
  3. 프로젝트 이름을 입력합니다. 예: hong-gildong-rps-ai (전 세계에서 겹치지 않아야 하므로 본인 이름·학번을 넣는 걸 추천합니다). 이 이름이 나중에 https://이름.pages.dev 주소가 됩니다.
  4. 파일 탐색기에서 index.html 파일을 화면의 업로드 영역으로 드래그해서 끌어다 놓습니다.
  5. Deploy site(배포) 버튼을 클릭합니다.
몇 초 뒤 배포가 끝나면 https://프로젝트이름.pages.dev 주소가 화면에 표시됩니다. 그 주소를 클릭하면 내가 만든 사이트가 전 세계에 공개된 것을 바로 확인할 수 있습니다. 친구에게 주소를 보내 같이 플레이해볼 수도 있습니다.

6) 수정하고 다시 배포하기 (재배포)

코드를 고치고 싶으면 index.html을 nano 편집기으로 열어 수정 → 저장한 뒤, Cloudflare 대시보드에서 방금 만든 프로젝트로 들어가 Create deployment(새 배포 만들기) 버튼을 누르고, 수정한 index.html을 다시 드래그해서 끌어다 놓으면 몇 초 만에 같은 주소로 새 버전이 올라갑니다.

참고 — CLI(터미널)로 배포하는 방법도 있습니다

지금처럼 손으로 클릭하는 대신, wrangler라는 도구로 명령어 한 줄에 배포할 수도 있습니다. Node.js 설치가 추가로 필요하지만, 파일을 여러 번 고치고 자주 재배포할 때는 이 방법이 더 빠릅니다. 관심 있다면 CMD에서 아래 순서로 해볼 수 있습니다 (①②는 최초 1회만).
CLI 배포 명령어 (선택)
cd $HOME/rps_web_app

:: ① Cloudflare 계정 로그인 (브라우저가 열리면 Allow 클릭)
npx wrangler login

:: ② Pages 프로젝트 만들기 (최초 1회, my-rps-ai를 본인 이름으로 바꾸세요)
npx wrangler pages project create my-rps-ai --production-branch=main

:: ③ 배포 (수정할 때마다 이 줄만 다시 실행하면 재배포됩니다)
npx wrangler pages deploy . --project-name=my-rps-ai --commit-dirty=true

잘 안 될 때

증상해결
그래프가 안 그려지고 콘솔에 Chart is not defined인터넷 연결을 확인하세요. 코드 맨 위 <script src="https://cdnjs.cloudflare.com/...">가 Chart.js 라이브러리를 불러오는 부분인데, 오프라인이거나 차단되면 그래프 기능만 동작하지 않습니다(가위바위보 게임 자체는 정상 작동).
업로드 영역에 파일을 끌어다 놔도 반응이 없음폴더째로 끌지 말고 index.html 파일 자체를 끌어다 놓으세요. 파일 탐색기 창과 브라우저 창이 겹치지 않게 both 화면에 보이도록 배치하면 드래그가 쉽습니다.
프로젝트 이름이 이미 사용 중이라고 나옴Cloudflare Pages 프로젝트 이름은 전역으로 유일해야 합니다. 본인 이름이나 학번을 넣은 이름으로 바꿔보세요.
배포는 됐는데 사이트가 바로 안 열림(연결 시간 초과)처음 배포 직후에는 전 세계에 퍼지는 데 몇 초~수십 초 걸릴 수 있습니다. 잠시 후 다시 접속해보세요.
수업이 끝난 뒤 사이트를 지우고 싶음Cloudflare 대시보드의 Workers & Pages에서 해당 프로젝트를 선택 → Settings → Delete project를 누르면 삭제됩니다(되돌릴 수 없습니다).
← 메인으로 돌아가기