오늘 만들 프로그램
이 수업에서 무엇을 배우나요? (수업 원리)
1~4단계에서 만든 얼굴인식·사물인식 모델은 "이 사진은 무엇인가?"를 맞히는 인식(판별) AI였습니다. 반대로 오늘 쓰는 이미지 생성 모델은 세상에 없던 그림을 새로 만들어내는 생성(Generative) AI입니다. 같은 "AI"라도 하는 일의 방향이 정반대라는 점이 이번 수업의 핵심 개념입니다.
그림은 어떻게 "생성"되나요? (쉬운 비유)
이미지 생성 AI는 수많은 (사진, 설명 글) 짝을 미리 학습합니다. 그림을 그릴 때는 처음에 지지직거리는 무작위 노이즈(잡음) 이미지에서 시작해서, "이 설명 문장과 점점 더 비슷해지도록" 노이즈를 조금씩 지워 나갑니다(디노이징, denoising). 마치 안개가 서서히 걷히면서 그 안의 그림이 드러나는 과정과 비슷합니다. 이 과정을 수십 번 반복하면 설명 글에 맞는 또렷한 이미지가 완성됩니다.
안전하게 사용하기 (디지털 시민의식)
- 실제 존재하는 사람(친구, 연예인, 선생님 등)의 얼굴처럼 보이는 사실적인 사진 요청
- 폭력적·선정적이거나 다른 사람을 비방·조롱하는 내용
- 특정 저작권 캐릭터·작품을 그대로 베끼려는 요청
프롬프트 설계 원리 — 역할·맥락·제약·출력형식
같은 그림 주제라도 프롬프트를 어떻게 설계하느냐에 따라 결과물의 품질이 크게 달라집니다. 아래 4가지 요소를 순서대로 채우면 훨씬 좋은 결과를 얻을 수 있습니다.
| 요소 | 의미 | 예시 표현 |
|---|---|---|
| ① 역할 / 화풍 | 어떤 스타일·누구의 시선으로 그릴지 | "수채화 일러스트레이터처럼", "픽셀 아트 스타일로" |
| ② 맥락 / 장면 | 주인공이 어디서 무엇을 하는 장면인지 | "우주정거장 창문 밖으로 지구를 바라보는 고양이 우주비행사" |
| ③ 제약 / 분위기 | 피해야 할 것, 지켜야 할 분위기 | "폭력적이지 않고 따뜻하고 아늑한 느낌으로" |
| ④ 출력 형식 | 구도·색감·비율 등 결과물 형태 | "정사각형 구도, 파스텔톤 색감, 부드러운 조명" |
Before / After 비교
→ 결과가 매번 들쭉날쭉하고 의도와 다른 그림이 나오기 쉽습니다.
→ 화풍·장면·분위기·구도가 모두 지정되어 원하는 그림에 훨씬 가까운 결과가 나옵니다.
나만의 프롬프트 설계 연습
그리고 싶은 장면을 4요소로 나누어 먼저 한글로 적어본 뒤, 이 내용을 이어 붙여 프롬프트 입력창에 넣어보세요(한글로 적어도 앱이 자동으로 영어로 번역해 줍니다).
| 요소 | 내가 적어보는 내용 |
|---|---|
| ① 역할/화풍 | |
| ② 맥락/장면 | |
| ③ 제약/분위기 | |
| ④ 출력형식 |
1단계. 실습 폴더 만들기
cd $HOME mkdir text_to_image_bonus_app cd text_to_image_bonus_app ls -la
2단계. 라이브러리 설치 확인
이번 5단원 수업은 Flask 웹앱으로 만듭니다. flask, requests에 더해 스마트폰 접속용 QR 코드를 만드는 qrcode가 필요합니다(3단계 페르소나 수업에서 flask·requests를 이미 설치했다면 qrcode만 추가하면 됩니다).
python3 -m pip install flask requests "qrcode[pil]"
3단계. app.py 작성하기
cd $HOME/text_to_image_bonus_app nano app.py
import os
import re
import time
import random
import socket
import urllib.parse
import requests
import qrcode
from flask import Flask, request, render_template_string
app = Flask(__name__)
# 5단원: 생성형 AI - Pollinations.ai 무료 Text-to-Image + MyMemory 무료 번역
# 둘 다 API 키/회원가입이 필요 없고 과금되지 않습니다.
GENERATED_DIR = os.path.join("static", "generated")
os.makedirs(GENERATED_DIR, exist_ok=True)
gallery = []
HANGUL_PATTERN = re.compile(r"[가-힣]")
def translate_to_english(text):
try:
response = requests.get(
"https://api.mymemory.translated.net/get",
params={"q": text, "langpair": "ko|en"},
timeout=10
)
if response.status_code == 200:
data = response.json()
translated = data.get("responseData", {}).get("translatedText", "")
if translated:
return translated
except Exception:
pass
return None
def build_image_url(prompt_text):
encoded = urllib.parse.quote(prompt_text)
seed = random.randint(0, 999999)
return (
f"https://image.pollinations.ai/prompt/{encoded}"
f"?width=768&height=768&seed={seed}&nologo=true"
)
def describe_status_error(status_code):
# Pollinations.ai는 무료 커뮤니티 서비스라 상태 코드별 의미를 안내 문구로 바꿔줍니다.
if status_code == 402:
return (
"무료 사용량이 일시적으로 초과되었을 수 있습니다 (상태 코드 402). "
"몇 초 기다렸다가 같은 프롬프트로 다시 시도하면 대부분 해결됩니다."
)
if status_code == 429:
return (
"요청이 너무 잦습니다 (상태 코드 429). "
"15초 이상 기다린 후 다시 시도하세요."
)
if status_code in (500, 502, 503, 504):
return (
f"이미지 생성 서버에 일시적인 문제가 발생했습니다 (상태 코드 {status_code}). "
"잠시 후 다시 시도해 주세요."
)
return f"이미지 생성에 실패했습니다 (상태 코드 {status_code})."
def fetch_image_with_retry(prompt_text, max_retries=3, wait_seconds=4):
# 과금 없는 무료 서비스는 가끔 일시적으로 오류를 낼 수 있어, 잠깐 기다렸다가
# 자동으로 다시 시도합니다(사람이 직접 새로고침하지 않아도 되도록).
last_response = None
last_exception = None
for attempt in range(max_retries):
url = build_image_url(prompt_text)
try:
response = requests.get(url, timeout=60)
except Exception as e:
last_exception = e
time.sleep(wait_seconds)
continue
if response.status_code == 200:
return response, None
last_response = response
if response.status_code in (402, 429, 500, 502, 503, 504):
time.sleep(wait_seconds)
continue
break
if last_response is not None:
return None, describe_status_error(last_response.status_code)
if last_exception is not None:
return None, f"이미지 생성 중 오류가 발생했습니다: {last_exception}"
return None, "이미지 생성에 실패했습니다."
def get_local_ip():
# 같은 Wi-Fi에 있는 스마트폰이 접속할 수 있는, 이 노트북의 내부 IP 주소를 찾습니다.
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
except Exception:
ip = "127.0.0.1"
finally:
s.close()
return ip
def print_qr(url):
print("/n" + "=" * 50)
print("아래 주소로 접속하거나, QR 코드를 스마트폰 카메라로 비춰보세요.")
print(url)
print("=" * 50 + "/n")
qr = qrcode.QRCode(border=1)
qr.add_data(url)
qr.make()
qr.print_ascii(invert=True)
qr.make_image(fill_color="black", back_color="white").save("qr_code.png")
print("/nQR 코드 이미지 저장 위치: qr_code.png (화면 공유로 반 전체에 보여줘도 됩니다)/n")
HTML = """
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>생성형 AI로 그림 그리기 - 5단원</title>
<style>
body{margin:0;font-family:"Noto Sans KR","Malgun Gothic",Arial,sans-serif;background:#fdf2f8;color:#172033}
.container{max-width:920px;margin:28px auto;background:white;border-radius:22px;padding:30px;box-shadow:0 8px 28px rgba(0,0,0,.12)}
h1{text-align:center;margin-top:0}
.notice{background:#fff0f7;padding:16px;border-radius:14px;line-height:1.7;margin-bottom:20px}
label{font-weight:800;display:block;margin-top:18px;margin-bottom:8px}
textarea{width:100%;box-sizing:border-box;border:1px solid #cfd6e4;border-radius:12px;padding:12px;font-size:16px;min-height:90px}
button{margin-top:15px;padding:14px 18px;border:none;border-radius:12px;font-size:16px;cursor:pointer;background:#db2777;color:white;font-weight:800}
.voice-button{background:#8a3ffc}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.voice-status{display:inline-block;margin-left:4px;font-size:14px;color:#526079}
.message{background:#fff7ed;border-radius:12px;padding:12px 14px;margin-top:14px;color:#9a3412;font-weight:700}
.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;margin-top:24px}
.gallery-item{border:1px solid #eee;border-radius:14px;overflow:hidden;background:#fafafa}
.gallery-item img{width:100%;display:block}
.gallery-caption{padding:10px;font-size:13px;line-height:1.5;color:#444}
.gallery-caption b{display:block;color:#111}
.gallery-caption .note{color:#be185d;font-size:12px;margin-top:4px}
</style>
</head>
<body>
<div class="container">
<h1>🎨 생성형 AI로 그림 그리기 (5단원)</h1>
<div class="notice">
한국어로 입력해도 자동으로 영어로 번역해 그림을 만듭니다(MyMemory 무료 번역 API, 과금 없음).<br>
🎤 마이크 버튼을 누르면 듣기 시작, 말이 끝나면 버튼을 다시 눌러 직접 끝내세요(시간 제한 없음). 끝내면 자동으로 이미지를 생성합니다.
</div>
<form method="post">
<label>그리고 싶은 장면을 글로 설명하세요</label>
<textarea id="promptInput" name="prompt" placeholder="예: 우주를 여행하는 고양이 우주비행사, 따뜻한 수채화 느낌"></textarea>
<div class="row">
<button id="micButton" class="voice-button" type="button">🎤 음성으로 말하기</button>
<span id="voiceStatus" class="voice-status">마이크 대기 중</span>
</div>
<button id="generateButton" type="submit">🖼 이미지 만들기</button>
</form>
{% if message %}
<div class="message">{{ message }}</div>
{% endif %}
<div class="gallery">
{% for item in gallery %}
<div class="gallery-item">
<img src="{{ url_for('static', filename=item.file) }}" alt="생성된 이미지">
<div class="gallery-caption">
<b>{{ item.original }}</b>
{% if item.note %}<div class="note">{{ item.note }}</div>{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
<script>
const promptInput = document.getElementById('promptInput');
const micButton = document.getElementById('micButton');
const voiceStatus = document.getElementById('voiceStatus');
const generateButton = document.getElementById('generateButton');
let recognition = null;
let recognizing = false;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
// 음성 입력은 보안 컨텍스트(HTTPS 또는 노트북 자신인 127.0.0.1/localhost)에서만 동작합니다.
// 스마트폰이 접속하는 Wi-Fi IP 주소는 보안 컨텍스트가 아니므로 마이크 버튼을 아예 비활성화합니다.
if (SpeechRecognition && window.isSecureContext) {
recognition = new SpeechRecognition();
recognition.lang = 'ko-KR';
recognition.interimResults = true;
// 스마트폰(특히 Android)은 continuous:true 상태에서 같은 구간을 중복 인식해
// "우주를우주를우주를..."처럼 단어가 겹쳐 입력되는 경우가 있습니다.
// 그래서 매번 "한 구간(continuous:false)"만 정확하게 인식시키고,
// 사용자가 끝내기 전까지는 onend에서 자동으로 재시작해 이어붙입니다.
recognition.continuous = false;
let finalTranscript = '';
let keepListening = false;
recognition.onstart = () => {
recognizing = true;
voiceStatus.textContent = '🎙 듣고 있습니다. 하고 싶은 말을 다 한 뒤 마이크 버튼을 다시 눌러 끝내세요.';
micButton.textContent = '⏹ 듣기 종료하고 전송';
};
recognition.onresult = (event) => {
let currentText = '';
for (let i = 0; i < event.results.length; i++) {
currentText += event.results[i][0].transcript;
}
promptInput.value = (finalTranscript ? finalTranscript + ' ' : '') + currentText;
};
recognition.onerror = (event) => {
if (event.error === 'no-speech' && keepListening) {
// 짧은 침묵일 뿐입니다. onend에서 자동으로 다시 듣기 시작합니다.
return;
}
keepListening = false;
recognizing = false;
if (event.error === 'not-allowed') {
voiceStatus.textContent = '⚠ 마이크 권한이 필요합니다. 브라우저 주소창의 마이크 권한을 허용하세요.';
} else if (event.error === 'no-speech') {
voiceStatus.textContent = '말소리를 듣지 못했습니다. 다시 눌러 천천히 말씀하세요.';
} else {
voiceStatus.textContent = '음성 인식 오류: ' + event.error;
}
micButton.textContent = '🎤 음성으로 말하기';
};
recognition.onend = () => {
if (keepListening) {
finalTranscript = promptInput.value.trim();
setTimeout(() => {
if (!keepListening) return;
try {
recognition.start();
} catch (e) {
keepListening = false;
recognizing = false;
micButton.textContent = '🎤 음성으로 말하기';
}
}, 150);
return;
}
recognizing = false;
micButton.textContent = '🎤 음성으로 말하기';
if (promptInput.value.trim()) {
voiceStatus.textContent = '✅ 음성 인식을 마쳤습니다. 잠시 후 이미지를 생성합니다.';
setTimeout(() => generateButton.click(), 600);
} else {
voiceStatus.textContent = '마이크 대기 중';
}
};
micButton.addEventListener('click', () => {
if (recognizing) {
keepListening = false;
recognition.stop();
return;
}
finalTranscript = '';
keepListening = true;
promptInput.value = '';
try {
recognition.start();
} catch (e) {
voiceStatus.textContent = '음성 인식을 다시 시작해 주세요.';
}
});
} else {
micButton.disabled = true;
voiceStatus.textContent = window.isSecureContext
? '이 브라우저에서는 음성 인식이 지원되지 않습니다. 최신 Chrome 또는 Edge를 사용하세요.'
: '🎤 음성 입력은 노트북 브라우저(127.0.0.1)에서만 가능합니다. 이 화면에서는 글로 입력해 주세요.';
}
</script>
</body>
</html>
"""
@app.route("/", methods=["GET", "POST"])
def index():
message = ""
if request.method == "POST":
raw_prompt = request.form.get("prompt", "").strip()
if raw_prompt:
used_prompt = raw_prompt
translated_note = ""
if HANGUL_PATTERN.search(raw_prompt):
translated = translate_to_english(raw_prompt)
if translated:
used_prompt = translated
translated_note = f"(한국어 입력을 영어로 자동 번역했습니다 → {translated})"
else:
translated_note = "(번역에 실패해 입력하신 한국어 그대로 생성을 시도합니다.)"
response, error_message = fetch_image_with_retry(used_prompt)
if error_message:
message = error_message
if translated_note:
message = translated_note + " " + message
else:
file_name = f"{int(time.time())}.jpg"
file_path = os.path.join(GENERATED_DIR, file_name)
with open(file_path, "wb") as f:
f.write(response.content)
gallery.insert(0, {
"original": raw_prompt,
"used_prompt": used_prompt,
"note": translated_note,
"file": f"generated/{file_name}"
})
else:
message = "프롬프트를 입력해 주세요."
return render_template_string(HTML, gallery=gallery, message=message)
if __name__ == "__main__":
PORT = 8503
local_ip = get_local_ip()
print_qr(f"http://{local_ip}:{PORT}")
app.run(host="0.0.0.0", port=PORT)
4단계. 실행하기
cd $HOME/text_to_image_bonus_app python3 app.py
http://127.0.0.1:8503
- 프롬프트 설계 연습(4요소 표)에서 만든 문장을 입력창에 적습니다. 한글로 적어도 됩니다.
- 노트북 브라우저(127.0.0.1)에서는 🎤 음성으로 말하기도 쓸 수 있습니다. 시간 제한이 없으니 천천히 말하고, 다 말했으면 같은 버튼(⏹ 듣기 종료하고 전송)을 다시 눌러 직접 끝냅니다. (스마트폰에서는 🎤 버튼이 비활성화되어 있습니다 — 아래 5단계 참고)
- 입력을 마치면(또는 듣기를 끝내면) 한글 입력은 자동으로 영어로 번역된 뒤 이미지가 생성됩니다.
- 생성된 이미지는 화면 아래 갤러리에 계속 쌓여 여러 결과를 비교할 수 있습니다.
5단계. 스마트폰에서 QR 코드로 접속하기
이 앱은 실행하자마자 같은 Wi-Fi 안에서 바로 쓸 수 있는 주소와 QR 코드를 자동으로 만들어 줍니다. 별도 배포나 설치 없이, 지금 노트북에서 py -3.12 app.py를 실행 중인 동안에만 스마트폰에서 접속할 수 있습니다.
- 노트북과 스마트폰을 같은 Wi-Fi에 연결합니다(예: 같은 교실 공유기).
- py -3.12 app.py를 실행하면 CMD 창에 QR 코드와 주소(http://노트북IP:8503)가 표시됩니다.
- 스마트폰 카메라 앱으로 CMD 창의 QR 코드를 비추거나, 저장된 qr_code.png를 화면에 띄워 비춥니다.
- 처음 실행 시 Windows 보안 경고(Windows Defender 방화벽) 팝업이 뜨면 "개인 네트워크"에 체크하고 액세스 허용을 눌러야 스마트폰에서 접속할 수 있습니다. (팝업을 못 봤거나 이미 차단을 눌렀다면, Windows 보안 → 방화벽 및 네트워크 보호 → 앱이 방화벽을 통해 통신하도록 허용에서 Python의 "개인" 체크를 직접 켜주세요.)
6단계. 내 폰에 반영구적으로 설치하기 (Netlify 정적 배포)
5단계(QR)는 노트북이 켜져 있고 같은 Wi-Fi일 때만 작동하는 임시 접속이었습니다. 이번 6단계는 노트북을 꺼도, 다른 Wi-Fi·데이터망에서도 언제나 그대로 열리는 나만의 영구 앱을 만듭니다. Python·Flask 서버 없이, 브라우저 안에서만 동작하는 "정적 웹페이지" 한 장으로 다시 만들어 Netlify라는 무료 호스팅에 올리는 방식입니다.
6-1. 정적 앱 폴더 만들기
cd $HOME mkdir my_art_app cd my_art_app nano 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>
<style>
body{margin:0;font-family:"Noto Sans KR","Malgun Gothic",Arial,sans-serif;background:#fdf2f8;color:#172033}
.container{max-width:720px;margin:0 auto;padding:24px 16px 60px}
h1{text-align:center;font-size:26px}
.notice{background:#fff0f7;padding:14px 16px;border-radius:14px;line-height:1.7;font-size:14px;margin-bottom:18px}
label{font-weight:800;display:block;margin-top:16px;margin-bottom:6px}
textarea{width:100%;box-sizing:border-box;border:1px solid #cfd6e4;border-radius:12px;padding:12px;font-size:16px;min-height:80px}
button{margin-top:12px;padding:13px 16px;border:none;border-radius:12px;font-size:15px;cursor:pointer;background:#db2777;color:#fff;font-weight:800}
.voice-button{background:#8a3ffc}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.status{font-size:13px;color:#526079;margin-top:8px;min-height:18px}
.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px;margin-top:22px}
.gallery-item{border:1px solid #eee;border-radius:12px;overflow:hidden;background:#fafafa}
.gallery-item img{width:100%;display:block;aspect-ratio:1/1;object-fit:cover}
.gallery-caption{padding:8px;font-size:11px;line-height:1.4;color:#444}
.gallery-caption b{display:block;color:#111;font-size:12px}
.empty{text-align:center;color:#9ca3af;padding:30px 0;font-size:13px}
.clear-btn{background:#6b7280;font-size:13px;padding:8px 12px}
</style>
</head>
<body>
<div class="container">
<h1>🎨 나만의 그림 생성 앱</h1>
<div class="notice">
이 페이지는 내 스마트폰(브라우저)에만 저장되는 개인 갤러리입니다. 다른 사람에게는 보이지 않습니다.<br>
한국어로 입력해도 자동으로 영어로 번역됩니다. 🎤 버튼으로 음성 입력도 됩니다.
</div>
<label>그리고 싶은 장면을 글로 설명하세요</label>
<textarea id="promptInput" placeholder="예: 우주를 여행하는 고양이 우주비행사, 따뜻한 수채화 느낌"></textarea>
<div class="row">
<button id="micButton" class="voice-button" type="button">🎤 음성으로 말하기</button>
</div>
<div class="status" id="voiceStatus">마이크 대기 중</div>
<button id="generateButton" type="button">🖼 이미지 만들기</button>
<div class="status" id="genStatus"></div>
<div class="row" style="margin-top:18px">
<button class="clear-btn" id="clearButton" type="button">갤러리 비우기</button>
</div>
<div id="gallery" class="gallery"></div>
<div id="emptyMsg" class="empty">아직 만든 그림이 없어요. 위에서 프롬프트를 입력해 보세요.</div>
</div>
<script>
const STORAGE_KEY = 'my_art_gallery_v1';
const MAX_ITEMS = 50;
const promptInput = document.getElementById('promptInput');
const generateButton = document.getElementById('generateButton');
const genStatus = document.getElementById('genStatus');
const galleryEl = document.getElementById('gallery');
const emptyMsg = document.getElementById('emptyMsg');
const clearButton = document.getElementById('clearButton');
const HANGUL_PATTERN = /[가-힣]/;
function loadGallery() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
function saveGallery(items) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items.slice(0, MAX_ITEMS)));
} catch (e) {
console.warn('저장 공간이 가득 찼습니다.');
}
}
function renderGallery() {
const items = loadGallery();
galleryEl.innerHTML = '';
if (items.length === 0) {
emptyMsg.style.display = 'block';
return;
}
emptyMsg.style.display = 'none';
items.forEach(item => {
const div = document.createElement('div');
div.className = 'gallery-item';
div.innerHTML = `
<img src="${item.imageUrl}" alt="생성된 이미지" loading="lazy">
<div class="gallery-caption"><b>${item.original}</b>${item.note ? '<br>' + item.note : ''}</div>
`;
galleryEl.appendChild(div);
});
}
async function translateToEnglish(text) {
try {
const res = await fetch(
'https://api.mymemory.translated.net/get?q=' + encodeURIComponent(text) + '&langpair=ko|en'
);
if (!res.ok) return null;
const data = await res.json();
return (data.responseData && data.responseData.translatedText) || null;
} catch (e) {
return null;
}
}
function buildImageUrl(promptText) {
const encoded = encodeURIComponent(promptText);
const seed = Math.floor(Math.random() * 999999);
return `https://image.pollinations.ai/prompt/${encoded}?width=768&height=768&seed=${seed}&nologo=true`;
}
function tryLoadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(url);
img.onerror = () => reject(new Error('load-failed'));
img.src = url;
});
}
async function generateWithRetry(promptText, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const url = buildImageUrl(promptText);
try {
await tryLoadImage(url);
return url;
} catch (e) {
if (attempt < maxAttempts) {
genStatus.textContent = `무료 서비스가 혼잡할 수 있어요. 다시 시도하는 중... (${attempt}/${maxAttempts})`;
await new Promise(r => setTimeout(r, 4000));
}
}
}
return null;
}
async function handleGenerate() {
const rawPrompt = promptInput.value.trim();
if (!rawPrompt) {
genStatus.textContent = '프롬프트를 입력해 주세요.';
return;
}
generateButton.disabled = true;
genStatus.textContent = '이미지를 생성하는 중입니다...';
let usedPrompt = rawPrompt;
let note = '';
if (HANGUL_PATTERN.test(rawPrompt)) {
const translated = await translateToEnglish(rawPrompt);
if (translated) {
usedPrompt = translated;
note = `(한국어 입력을 영어로 자동 번역했습니다 → ${translated})`;
} else {
note = '(번역에 실패해 입력하신 한국어 그대로 생성을 시도합니다.)';
}
}
const imageUrl = await generateWithRetry(usedPrompt);
if (!imageUrl) {
genStatus.textContent = '이미지 생성에 실패했습니다. 잠시 후 다시 시도해 주세요(무료 서비스 혼잡일 수 있어요).';
generateButton.disabled = false;
return;
}
const items = loadGallery();
items.unshift({ original: rawPrompt, usedPrompt, note, imageUrl, savedAt: Date.now() });
saveGallery(items);
renderGallery();
genStatus.textContent = '완성되었습니다! 아래 갤러리에서 확인하세요.';
generateButton.disabled = false;
}
generateButton.addEventListener('click', handleGenerate);
clearButton.addEventListener('click', () => {
if (confirm('내 갤러리를 전부 지울까요? 되돌릴 수 없습니다.')) {
localStorage.removeItem(STORAGE_KEY);
renderGallery();
}
});
/* 음성 입력 (5단원 로컬 버전과 동일한 방식) */
const micButton = document.getElementById('micButton');
const voiceStatus = document.getElementById('voiceStatus');
let recognition = null;
let recognizing = false;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SpeechRecognition) {
recognition = new SpeechRecognition();
recognition.lang = 'ko-KR';
recognition.interimResults = true;
// 스마트폰(특히 Android)은 continuous:true 상태에서 같은 구간을 중복 인식해
// "우주를우주를우주를..."처럼 단어가 겹쳐 입력되는 경우가 있습니다.
// 그래서 매번 "한 구간(continuous:false)"만 정확하게 인식시키고,
// 사용자가 끝내기 전까지는 onend에서 자동으로 재시작해 이어붙입니다.
recognition.continuous = false;
let finalTranscript = '';
let keepListening = false;
recognition.onstart = () => {
recognizing = true;
voiceStatus.textContent = '🎙 듣고 있습니다. 다 말하면 버튼을 다시 눌러 끝내세요.';
micButton.textContent = '⏹ 듣기 종료';
};
recognition.onresult = (event) => {
let currentText = '';
for (let i = 0; i < event.results.length; i++) {
currentText += event.results[i][0].transcript;
}
promptInput.value = (finalTranscript ? finalTranscript + ' ' : '') + currentText;
};
recognition.onerror = (event) => {
if (event.error === 'no-speech' && keepListening) {
return;
}
keepListening = false;
recognizing = false;
voiceStatus.textContent = '음성 인식 오류: ' + event.error;
micButton.textContent = '🎤 음성으로 말하기';
};
recognition.onend = () => {
if (keepListening) {
finalTranscript = promptInput.value.trim();
setTimeout(() => {
if (!keepListening) return;
try {
recognition.start();
} catch (e) {
keepListening = false;
recognizing = false;
micButton.textContent = '🎤 음성으로 말하기';
}
}, 150);
return;
}
recognizing = false;
micButton.textContent = '🎤 음성으로 말하기';
if (promptInput.value.trim()) {
voiceStatus.textContent = '✅ 음성 인식 완료. 잠시 후 이미지를 생성합니다.';
setTimeout(() => generateButton.click(), 600);
} else {
voiceStatus.textContent = '마이크 대기 중';
}
};
micButton.addEventListener('click', () => {
if (recognizing) {
keepListening = false;
recognition.stop();
return;
}
finalTranscript = '';
keepListening = true;
promptInput.value = '';
try {
recognition.start();
} catch (e) {
voiceStatus.textContent = '음성 인식을 다시 시작해 주세요.';
}
});
} else {
micButton.disabled = true;
voiceStatus.textContent = '이 브라우저에서는 음성 인식이 지원되지 않습니다.';
}
renderGallery();
</script>
</body>
</html>
6-2. Netlify 무료 계정 만들기
- netlify.com에서 무료 계정을 만듭니다(신용카드 등록 불필요).
- 로그인 후 app.netlify.com/drop 페이지로 이동합니다.
6-3. 배포하기
- 방금 만든 my_art_app 폴더(index.html 파일이 들어있는 폴더) 전체를 그 화면에 드래그 앤 드롭합니다.
- 몇 초 뒤 https://임의이름.netlify.app 주소가 생성됩니다.
- Site settings → Change site name에서 기억하기 쉬운 이름으로 바꿀 수 있습니다(예: 내 이름을 넣은 주소).
6-4. 스마트폰에 설치하기
- 스마트폰 브라우저로 발급받은 주소에 접속합니다.
- Chrome/Safari 메뉴에서 "홈 화면에 추가"를 누르면 앱 아이콘처럼 폰 화면에 생깁니다.
- 이제 노트북이 꺼져 있어도, 어떤 Wi-Fi·데이터망에서도 그 아이콘을 눌러 바로 쓸 수 있습니다.
보충 자료 · 학교 네트워크에서 Netlify가 막혀 있다면 (무료 터널 대체 수업)
이 경우 대안으로 Cloudflare Tunnel이라는 무료 도구를 이용해, 지금 실행 중인 로컬 Flask 앱(app.py)을 교실 밖에서도 임시로 접속 가능하게 만들 수 있습니다. 다만 이건 6단계처럼 영구적이지 않고, 노트북과 앱, 터널 프로그램이 모두 켜져 있는 동안에만 작동한다는 점이 다릅니다.
winget install --id Cloudflare.cloudflared
cloudflared tunnel --url http://127.0.0.1:8503
실행하면 터미널에 https://아무이름.trycloudflare.com 형태의 공개 주소가 출력됩니다. 이 주소는 와이파이와 상관없이 인터넷이 되는 곳이면 어디서든 열리지만, cloudflared 창을 닫거나 노트북을 끄면 즉시 접속이 끊기고, 다시 켜도 주소가 바뀝니다.
python3 -c "import qrcode; qrcode.make('여기에_trycloudflare_주소_붙여넣기').save('qr_tunnel.png')"
잘 안 될 때
| 증상 | 원인 / 해결 |
|---|---|
| 상태 코드 402 | "결제 필요"라는 뜻의 코드지만, 이 무료 서비스에서는 보통 무료 사용량이 일시적으로 몰렸다는 신호입니다. 이 앱은 자동으로 몇 초 뒤 재시도하지만, 메시지에 402가 계속 보이면 1~2분 쉬었다가 다시 시도하세요. |
| 상태 코드 429 | 요청이 너무 잦다는 뜻입니다(무료 익명 사용은 15초에 1회 권장). 연속으로 여러 번 누르지 말고 간격을 두고 시도하세요. |
| 상태 코드 500 / 503 | 이미지 생성 서버의 일시적인 오류입니다. 같은 프롬프트로 다시 시도하거나, 프롬프트를 더 짧고 단순하게 바꿔봅니다. |
| 번역이 이상하게 됨 | MyMemory는 간단한 무료 번역기라 완벽하지 않습니다. 문장을 더 짧고 명확하게 적어보세요. |
| 오래 기다려도 이미지가 안 나옴 | 무료 서비스라 사용자가 몰리면 느려질 수 있습니다. 잠시 후 같은 프롬프트로 다시 시도합니다. |
| 연결 오류(상태 코드 없음) | 학교 네트워크의 방화벽이 외부 서비스를 막고 있을 수 있습니다. 선생님에게 네트워크 정책을 확인합니다. |
| 스마트폰에서 🎤 버튼이 회색으로 비활성화되어 있음 | 정상입니다(의도된 동작). 음성 입력은 노트북 브라우저(127.0.0.1)에서만 지원되고, 스마트폰은 텍스트 입력만 사용합니다. |
| 노트북에서도 🎤 버튼이 눌러지지 않음 | 주소가 http://127.0.0.1:8503인지 확인하세요. http://노트북IP:8503(Wi-Fi IP)으로 접속하면 노트북에서도 마이크가 비활성화됩니다. |
| 최신 Chrome/Edge인데도 노트북에서 음성 인식 자체가 안 됨 | 주소가 127.0.0.1인지 다시 확인한 뒤에도 안 되면 브라우저 지원 여부입니다. 최신 Chrome 또는 Edge를 사용하세요. |
| 음성 인식이 너무 빨리 끝남 | 이 앱은 버튼을 다시 누르기 전까지 계속 듣습니다. 자동으로 끝난다면 페이지를 새로고침한 뒤 최신 코드로 다시 저장했는지 확인하세요. |
| 같은 단어·문장이 여러 번 반복돼서 입력됨 | 스마트폰(특히 Android)에서 "계속 듣기(continuous)" 모드가 같은 구간을 중복 인식하던 문제입니다. 이제는 한 구간씩 깨끗하게 인식한 뒤 자동으로 이어 듣는 방식으로 바뀌어 해결되었습니다. 혹시 이전에 복사한 코드를 쓰고 있다면, 교안의 최신 코드로 다시 복사해 저장(로컬은 app.py, 폰 전용은 Netlify 재배포)해 주세요. |
| 이미지가 기대와 다르게 나옴 | 프롬프트 설계 4요소(역할·맥락·제약·출력형식)를 더 구체적으로 채워봅니다. |
| 스마트폰에서 QR을 스캔해도 안 열림(타임아웃) | 노트북 자신(127.0.0.1)에서는 되는데 폰에서만 타임아웃이면 거의 항상 Windows 방화벽 문제입니다. Windows 보안 → 방화벽 및 네트워크 보호 → 앱이 방화벽을 통해 통신하도록 허용에서 Python의 "개인" 체크가 켜져 있는지 확인하세요. 그래도 안 되면 ① 같은 Wi-Fi인지 재확인 ② 백신 프로그램의 자체 방화벽 확인 ③ 학교 Wi-Fi 기기간 통신 차단 시 스마트폰 핫스팟으로 전환. |
| CMD 창에 QR 코드가 깨져 보임 | CMD 글꼴을 "D2Coding", "Consolas" 같은 고정폭 글꼴로 바꾸면 정사각형 모양으로 깨끗하게 보입니다. 안 바꿔도 저장된 qr_code.png 파일로 대신 스캔할 수 있습니다. |
| 접속 주소의 IP가 수업 때마다 바뀜 | 정상입니다(5단계). Wi-Fi에 새로 연결될 때마다 IP가 바뀔 수 있어, 매번 app.py를 새로 실행해 새 QR 코드를 받아야 합니다. 6단계(Netlify 영구 설치)는 이런 문제 없이 주소가 고정됩니다. |
| (6단계) 이미지 자리에 깨진 그림 아이콘만 보임 | 정적 앱은 인터넷에 연결되어 있어야 Pollinations 서버에서 이미지를 불러옵니다. Wi-Fi/데이터 연결 상태를 확인하세요. |
| (6단계) 내 폰이 아닌 다른 기기에서 갤러리가 안 보임 | 정상입니다. 갤러리는 localStorage로 그 브라우저·그 기기에만 저장되는 개인 보관함입니다. |
| (6단계) 어느 날 갤러리가 사라져 있음 | 브라우저의 "사이트 데이터/방문 기록 삭제"를 하면 localStorage도 함께 지워집니다. 시크릿(비공개) 모드에서 썼다면 창을 닫는 순간 사라집니다. |
수업 끝나고 정리하기
이 실습은 과금이 발생하지 않으므로 반드시 삭제할 필요는 없습니다. 저장 공간을 정리하고 싶다면 아래 명령을 사용하세요.
cd $HOME rmdir /s /q text_to_image_bonus_app ls -la