Step 94. Flask 맛보기 — 웹 서버를 직접 만든다
Level 1 — 프로그래밍과 컴퓨터 내부 | 난이도 ★★★☆☆ | 예상 소요 시간 3시간
전제: Step 73의 HTTP 지식, Step 41~46의 파이썬 기초를 마쳤다.
- 준비물: 파이썬과
pip install flask. 이 챕터의 실측은 Flask 3.1.3,127.0.0.1(localhost)에서 수행했습니다. - 주의: 오늘부터 여러분은 요청을 보내는 쪽(클라이언트)이 아니라 받는 쪽(서버) 을 직접 만듭니다. 모든 실습은 내 컴퓨터 안의 개발용 서버로 진행하고 외부에 공개하지 않습니다. 후반에 일부러 취약한 페이지를 만드는 실험이 있습니다 — 내 서버, 내 브라우저 안에서만입니다.
Step 73에서 우리는 HTTP 요청을 보내는 법을 배웠습니다. 그런데 한 가지가 빠져 있었습니다 — 요청을 받은 쪽에서는 무슨 일이 일어나는가? 오늘 그 블랙박스를 엽니다. 도구는 Flask(플라스크) — 파이썬 몇 줄로 웹 서버를 띄울 수 있는, 가장 작은 웹 프레임워크입니다. 취약점은 추상 개념이 아니라 누군가가 짠 코드 속의 구멍입니다. 그래서 취약한 코드는 내가 직접 짜 봅니다 — 오늘의 마지막 장면에서 여러분은 처음으로 XSS(크로스 사이트 스크립팅)를 손끝으로 체감하게 됩니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- Flask로 라우트 2개 이상의 서버를 띄울 수 있다
- 쿼리 문자열(GET)과 폼 데이터(POST)를 각각 받아 처리할 수 있다
- 딕셔너리를 반환해 JSON API를 만들 수 있다
- 브라우저 Network 탭과 서버 로그를 대조해 요청을 추적할 수 있다
- XSS가 발생하는 코드와 그 원리(코드·데이터 혼합), 그리고 이스케이프 방어를 설명할 수 있다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 + Flask 3.x, localhost(127.0.0.1) 개발 서버 |
| 오늘의 코드 | @app.route(라우팅), request.args(GET 파라미터), request.form(POST 폼), 딕셔너리 반환(JSON), markupsafe.escape(이스케이프) |
| 필요한 개념 | 라우팅, GET/POST, 쿼리 문자열, 상태 코드(200/404/405), XSS와 이스케이프 |
| 오늘의 산출물 | 미니 방명록 웹 앱 + XSS 성공/차단 대조 캡처 |
2-1. 프레임워크와 라우팅 — URL을 함수에 연결하기
웹 서버가 하는 일의 본질은 단순합니다. "이 주소로 이 요청이 오면, 이 함수를 실행하고, 그 결과를 돌려줘라." 주소(URL)와 함수를 연결하는 것을 라우팅(routing)이라고 합니다. Flask에서는 데코레이터(@) 한 줄이 라우팅입니다.
@app.route("/hello")
def hello():
return "안녕!"
"/hello로 GET 요청이 오면 hello()를 실행해서, 그 반환값을 응답 본문으로 보내라." 이 두 줄이 웹 서버의 전부이자 시작입니다.
2-2. GET과 POST — 서버 입장에서 다시 보기
Step 73에서 배운 두 메서드를, 이번에는 받는 쪽 시점으로 정리합니다.
- GET — 주소창에 붙는 요청.
/hello?name=hacker의?뒷부분(쿼리 문자열)이 파라미터. 서버는request.args로 꺼냅니다. - POST — 본문(body)에 데이터가 실리는 요청. 로그인 폼처럼 "주소창에 보이면 안 되는 값"이 여기로 옵니다. 서버는
request.form으로 꺼냅니다.
2-3. 서버 사이드의 시점 — "입력은 전부 의심스럽다"
서버 코드를 짜기 시작하면 세계관이 바뀝니다. 지금까지의 여러분은 브라우저의 사용자였지만, 오늘부터는 "저 요청을 보낸 것이 정말 착한 사용자일까?" 를 묻는 쪽입니다.
서버가 받는 모든 입력 — URL의 파라미터, 폼의 글상자, 쿠키 — 은 사용자가 마음대로 조작할 수 있습니다. 개발자 도구를 열면 누구든 값을 바꿀 수 있으니까요. "서버에 도착하는 입력은 전부 오염되었을 수 있다" — 이 한 문장이 웹 보안의 제1원리이고, 오늘의 XSS 실험이 그 증명입니다.
3. 따라 하기
3-1. 설치와 최소 서버
입력
pip install flask
mkdir flask-lab && cd flask-lab
app.py:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "Hello, Web!"
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000)
출력 (2026-09-09 실측, python app.py):
* Serving Flask app 'app'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
읽는 법: 5000번 포트에서 서버가 떴습니다. 브라우저에서 http://127.0.0.1:5000을 열면 Hello, Web!이 보입니다 (2026-09-09 실측 — HTTP 상태 200과 함께 이 본문이 돌아왔습니다). 경고 문구("개발용 서버를 운영에 쓰지 마라")는 Flask의 정상 안내입니다. 코드를 고칠 때마다 서버를 스스로 재시작하게 하려면 app.run(..., debug=True)를 쓰면 됩니다 — 단, debug 모드는 오류 화면에 코드가 노출되므로 개발 중에만 켭니다.
왜: 다섯 줄로 서버가 돌았습니다. 프레임워크가 번거로운 부분(소켓, HTTP 파싱)을 전부 대신해 주므로, 우리는 "무엇을 보여 줄까"만 생각하면 됩니다.
3-2. 요청의 여행 — 브라우저와 콘솔을 동시에 보기
입력: 브라우저에서 개발자 도구(F12) → Network 탭을 열고, http://127.0.0.1:5000을 새로고침합니다.
출력 (2026-09-09 실측, 서버 콘솔):
127.0.0.1 - - [09/Sep/2026 14:29:13] "GET / HTTP/1.1" 200 -
읽는 법: 같은 사건의 양쪽 얼굴입니다. 브라우저 Network 탭에는 요청 한 줄(상태 200), 서버 콘솔에는 접속 로그 한 줄 — 누가(127.0.0.1), 언제, 무엇을(GET /), 결과가 어땠는지(200)가 찍힙니다.
왜: "요청 하나 = 로그 한 줄"이라는 대응 관계를 몸에 새기는 것이, 나중에 서버 로그로 공격을 추적하는 감각의 밑바탕입니다.
3-3. URL 파라미터 받기 — 쿼리 문자열
입력: app.py에 라우트를 추가합니다.
from flask import request
@app.route("/hello")
def hello():
name = request.args.get("name", "손님")
return f"안녕하세요, {name}님!"
출력 (2026-09-09 실측): http://127.0.0.1:5000/hello?name=hacker → 안녕하세요, hacker님!. 파라미터 없이 /hello만 열면 → 안녕하세요, 손님님!.
읽는 법: ?name=hacker의 값을 서버가 꺼내 응답에 실었습니다. request.args.get("name", "손님")의 두 번째 인자는 파라미터가 없을 때의 기본값입니다.
왜: 서버가 사용자 입력을 받아 처리하는 첫 경험입니다. 그리고 눈치채셨겠지만 — 저 name은 사용자가 마음대로 바꿀 수 있습니다. 이 사실이 3-6에서 폭발합니다.
3-4. 폼과 POST — 로그인 흉내
입력: 로그인 페이지와 처리 라우트를 추가합니다.
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
uid = request.form.get("uid")
pw = request.form.get("pw")
return f"아이디 {uid} 로 로그인 시도 (비밀번호 길이: {len(pw)})"
return '''
<form method="post">
<input name="uid" placeholder="아이디">
<input name="pw" type="password" placeholder="비밀번호">
<button>로그인</button>
</form>
'''
출력 (2026-09-09 실측): /login을 열면(GET) 폼이 보이고, 아이디 tester, 비밀번호 8글자를 넣어 전송하면(POST) 이렇게 돌아옵니다.
아이디 tester 로 로그인 시도 (비밀번호 길이: 8)
읽는 법: 같은 /login 주소인데 메서드에 따라 다른 동작 — GET이면 폼을 보여 주고, POST면 처리합니다. <form method="post">라고 적혀 있어야 데이터가 본문(POST)으로 갑니다. 비밀번호를 화면에 다시 보여 주지 않고 길이만 보여 준 것에 주목하세요 — 받은 비밀을 되돌려 보여 주는 것부터가 나쁜 습관입니다.
왜: 모든 웹 서비스의 로그인이 이 구조입니다. GET/POST 두 요청의 분리를 이해해야, 나중에 "로그인 시도가 로그에 몇 번 찍혔는가" 같은 포렌식 질문에 답할 수 있습니다.
3-5. 예측해 보기 — 서버 콘솔에 뭐가 찍힐까
예측: /login 폼을 열고(첫 방문) 전송 버튼까지 누르면 서버 콘솔에는 어떤 로그가 찍힐까요?
- (a)
"GET /login ..."한 줄 - (b)
"POST /login ..."한 줄 - (c) GET과 POST가 각각 한 줄씩
직접 확인 — 출력 (2026-09-09 실측):
127.0.0.1 - - [09/Sep/2026 14:29:13] "GET /login HTTP/1.1" 200 -
127.0.0.1 - - [09/Sep/2026 14:29:13] "POST /login HTTP/1.1" 200 -
정답은 (c) 입니다. "폼을 보는 것"과 "폼을 보내는 것"은 두 번의 별개 요청입니다.
왜 중요한가: 서버 로그는 거짓말을 하지 않습니다. 공격 추적의 첫 걸음은 이 한 줄씩을 시간 순으로 읽는 것입니다.
3-6. JSON 응답 — 화면이 아니라 데이터를 돌려주는 서버
지금까지는 사람이 읽을 문자열을 돌려줬습니다. 프로그램끼리 대화하는 서버(API)는 대신 JSON(구조화된 데이터)을 돌려줍니다.
입력
@app.route("/api/status")
def api_status():
return {"service": "netwatch-lab", "open_ports": [8000], "status": "ok"}
출력 (2026-09-09 실측, http://127.0.0.1:5000/api/status 접속):
{"open_ports":[8000],"service":"netwatch-lab","status":"ok"}
읽는 법: 파이썬 딕셔너리를 return하면 Flask가 자동으로 JSON으로 번역해 줍니다. 브라우저에는 글자덩어리로 보이지만, 이 응답은 다른 프로그램이 기계적으로 읽기 좋은 형태입니다.
왜: 종합 프로젝트(Step 95)에서 만들 도구가 바로 이런 "데이터를 돌려주는 서버"를 겸합니다. 화면용 응답과 데이터용 응답을 구분하는 것이 오늘의 조용한 수확입니다.
3-7. 취약 맛보기 — XSS의 탄생
이제 일부러 나쁜 코드를 씁니다. 입력을 HTML에 그대로 싣는 라우트입니다.
입력
@app.route("/echo")
def echo():
name = request.args.get("name", "")
return f"<h1>입력한 값: {name}</h1>" # 입력을 HTML에 그대로 삽입 — 취약!
먼저 정상 입력 — /echo?name=길동은 굵은 제목으로 입력한 값: 길동이 보입니다. 이제 공격 입력입니다. 주소창에:
http://127.0.0.1:5000/echo?name=<script>alert('XSS')</script>
출력 (2026-09-09 실측, 서버 응답 본문):
<h1>입력한 값: <script>alert('XSS')</script></h1>
서버는 입력을 가공 없이 그대로 실어 돌려줬고, 이 응답을 받은 브라우저는 <script>를 "내용"이 아니라 "명령"으로 해석해 경고 상자(alert)를 실행합니다. 입력이 데이터가 아니라 스크립트로 실행된 것입니다.
읽는 법: 이것이 XSS(크로스 사이트 스크립팅) — Step 93의 SQL Injection과 정확히 같은 뿌리(코드와 데이터의 혼합)입니다. SQL에서 따옴표가 문법이 됐듯, 여기서는 <script>가 HTML 문법이 됐습니다.
왜: 이 alert 하나가 해킹 대회의 웹 문제 수백 개의 목표물입니다. "입력이 실행된다"는 감각을 내 서버에서 체감했으니, 이 취약점을 정면으로 다룰 준비가 된 것입니다.
⚠️ 보안 연결: 이 실험은 내 컴퓨터의 내 서버에서만 이루어졌습니다. 모든 공격 실습은 내 랩·합법 플랫폼에서만. 실서비스 무단 공격은 범죄입니다. 남의 사이트의 검색창에 이 입력을 넣는 것은 실험이 아니라 공격 시도입니다.
3-8. 방어 맛보기 — 이스케이프 한 줄
취약한 코드를 봤으니 방어도 봅니다. 입력을 안전하게 바꿔 주는 도구가 있습니다.
입력
from markupsafe import escape
@app.route("/echo_safe")
def echo_safe():
name = request.args.get("name", "")
return f"<h1>입력한 값: {escape(name)}</h1>"
출력 (2026-09-09 실측, 같은 공격 입력):
<h1>입력한 값: <script>alert('XSS')</script></h1>
읽는 법: escape는 <를 <처럼 "화면에 보이기만 하고 실행은 안 되는 문자"로 바꿉니다. 브라우저 화면에는 <script>alert('XSS')</script>라는 글자 그대로 보이고, 실행은 되지 않습니다. 이것을 이스케이프(탈출 처리)라고 하며, 출력 시점에 입력을 무력화하는 기본 방어입니다.
왜: 공격을 아는 것과 방어를 아는 것은 한 세트입니다. 오늘 여러분은 XSS의 발생과 차단을 30줄 안에서 둘 다 봤습니다 — 이 대조가 기억에 남는 이유입니다.
버전 참고 (2026-09-09 실측, Flask 3.1.3): 예전 자료에 나오는 from flask import escape는 이제 오류가 납니다 — ImportError: cannot import name 'escape' from 'flask'. Flask 2.3부터 제거됐으니, Flask가 내부에서 쓰는 안전 처리 도구인 markupsafe에서 직접 가져오는 위의 형태가 정답입니다.
4. 미션과 연습문제
미션 — 미니 방명록 웹 앱 완성
/— 지금까지 남겨진 메시지 목록을 보여 주는 페이지를 만듭니다./write— 이름과 한 줄 메시지를 받는 폼(GET 표시 + POST 처리)을 만듭니다.- POST된 메시지를 파이썬 리스트에 저장해
/에 나열합니다. 단, 출력에는 반드시escape를 적용합니다. - 일부러 취약한 버전(
/echo)을 남겨 두고, XSS 성공 캡처와 escape 적용 후 실패 캡처를 나란히 확보합니다. - 서버 콘솔의 요청 로그와 브라우저 Network 탭을 대조한 화면을 캡처합니다.
- 위키에
Flask첫서버.md를 작성합니다 — 라우팅 개념 / GET·POST 차이 / XSS가 생기는 이유 3줄.
연습문제
문제 1. @app.route("/hello") 데코레이터 한 줄이 하는 일을 "주소와 함수의 연결"이라는 말로 설명해 보세요.
문제 2. GET 파라미터는 request.args, POST 폼 데이터는 request.form으로 받습니다. 비밀번호 입력란이 GET이 아니라 POST여야 하는 이유를 설명해 보세요.
문제 3. 라우트에 methods=["GET", "POST"]를 빠뜨린 채 폼을 전송하면 어떤 상태 코드가 돌아오나요? 왜 그런지 설명해 보세요.
문제 4. escape가 XSS를 막는 원리를 "코드와 데이터의 혼합"이라는 관점으로 설명해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
방명록의 뼈대:
from flask import Flask, request
from markupsafe import escape
app = Flask(__name__)
guestbook = [] # (이름, 메시지) 튜플의 리스트
@app.route("/")
def home():
items = "".join(
f"<li><b>{escape(name)}</b>: {escape(msg)}</li>"
for name, msg in guestbook
)
return f"<h1>방명록</h1><ul>{items}</ul><a href='/write'>글쓰기</a>"
@app.route("/write", methods=["GET", "POST"])
def write():
if request.method == "POST":
name = request.form.get("name", "익명")
msg = request.form.get("msg", "")
guestbook.append((name, msg))
return "저장했습니다. <a href='/'>목록으로</a>"
return '''
<form method="post">
<input name="name" placeholder="이름">
<input name="msg" placeholder="한 줄 메시지">
<button>남기기</button>
</form>
'''
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000)
검증하는 법: ① /write에서 글을 남기면 /에 나타나는가. ② 메시지에 <script>alert(1)</script>를 넣어도 /에서는 글자로만 보이고 실행되지 않는가(escape 확인). ③ 취약 버전 /echo에서는 같은 입력이 실행되는가 — 대조 캡처 두 장이 확보됐는가. ④ 서버 콘솔에 GET/POST 로그가 시간 순으로 찍혔는가. 전부 ‘예’이면 완성입니다.
연습문제 해답
문제 1 해답. "/hello라는 주소로 GET 요청이 오면, 바로 아래 함수를 실행하고 그 반환값을 응답 본문으로 돌려줘라"라는 연결(라우팅) 선언입니다. 주소와 함수를 잇는 것이 웹 서버의 본질이고, Flask는 그것을 데코레이터 한 줄로 표현합니다.
문제 2 해답. GET의 파라미터는 주소창(URL)에 그대로 보이고, 브라우저 기록과 서버 로그에도 남기 때문입니다 — 실측에서도 서버 로그에 "GET /hello?name=hacker HTTP/1.1"처럼 값이 통째로 찍혔습니다 (2026-09-09). 비밀번호가 주소와 로그에 남으면 안 되므로, 본문에 실리는 POST를 씁니다.
문제 3 해답. 405 Method Not Allowed가 돌아옵니다 (2026-09-09 실측: GET만 허용된 라우트에 POST를 보내니 405 METHOD NOT ALLOWED). Flask 라우트는 기본적으로 GET만 허용하고, 폼 전송은 POST이므로 "그 메서드는 이 주소에서 허용하지 않는다"는 거절이 나옵니다. 데코레이터의 methods와 폼 태그의 method="post"는 세트입니다.
문제 4 해답. XSS는 사용자 입력(데이터)이 HTML 문법(코드)과 한 그릇에 섞여 브라우저에 전달되면서 생깁니다. escape는 <, >, ' 같은 "문법이 되는 글자"들을 <처럼 보이기만 하는 문자로 바꿔, 입력을 영원히 데이터로만 남게 합니다. SQL의 바인딩이 입력을 데이터로 가두듯, 이스케이프는 출력 시점에 입력을 무력화합니다 — 같은 뿌리, 다른 위치의 방어입니다.
완료 기준 체크리스트
- [ ] Flask로 라우트 2개 이상의 서버를 띄울 수 있다
- [ ] 쿼리 문자열과 폼 POST를 각각 받아 처리할 수 있다
- [ ] 딕셔너리 반환으로 JSON 응답을 만들 수 있다
- [ ] 브라우저 Network 탭과 서버 로그를 대조해 요청을 추적할 수 있다
- [ ] XSS가 발생하는 코드와 그 원리(코드·데이터 혼합)를 설명할 수 있다
- [ ] escape로 XSS를 차단하는 것을 시연할 수 있다
- [ ] 미션: 미니 방명록과 성공/차단 대조 캡처를 완성했다
6. 흔한 실수와 해결
벽 1. 코드를 고쳤는데 브라우저가 그대로예요
증상: 분명 return 문을 바꿨는데 화면이 안 바뀝니다.
원인: 서버가 옛날 코드로 떠 있습니다. debug=True 없이 실행했거나, 재시작 감지가 실패한 경우입니다.
해결: 터미널에서 Ctrl+C로 서버를 멈추고 다시 python app.py. 실행문에 debug=True가 있는지 확인하세요.
벽 2. "Address already in use" 오류가 나요
증상 (출력 예시 — 환경에 따라 메시지가 다릅니다): 서버가 안 켜지며 포트가 사용 중이라고 합니다.
원인: 이전에 띄운 서버가 아직 살아 있습니다(다른 터미널 창 등).
해결: 그 창을 찾아 Ctrl+C. 못 찾겠으면 포트를 5001로 바꿔 임시 회피하세요. 실습이 끝나면 띄운 서버를 반드시 종료하는 습관을 들이세요.
벽 3. 폼을 전송하면 "Method Not Allowed"가 나와요
증상 (2026-09-09 실측): 전송 버튼을 누르면 405 METHOD NOT ALLOWED.
원인: 라우트에 methods=["GET", "POST"]를 안 적었습니다. 기본값은 GET만 허용입니다.
해결: 데코레이터에 methods를 추가하세요. 그리고 폼 태그가 <form method="post">인지도 확인 — 이 둘은 세트입니다.
벽 4. from flask import escape가 ImportError가 나요
증상 (2026-09-09 실측, Flask 3.1.3):
ImportError: cannot import name 'escape' from 'flask'
원인: escape는 Flask 2.3에서 제거됐습니다. 예전 자료를 따라 치면 이 벽을 만납니다.
해결: from markupsafe import escape로 가져오세요. Flask가 내부에서 쓰는 안전 처리 도구가 바로 그것입니다.
벽 5. 한글 입력이 주소창에서 %EA%B8%B8처럼 변해요
증상: 주소창에 한글을 넣으면 %와 숫자·알파벳의 나열로 바뀝니다.
원인: 오류가 아닙니다 — URL은 영문·숫자·일부 기호만 담을 수 있어, 나머지는 % 인코딩(퍼센트 인코딩)으로 변환됩니다. 서버는 알아서 해독합니다 (실측에서도 name=길동이 서버에서 한글로 잘 꺼내졌습니다).
해결: 그대로 두면 됩니다. 서버가 받은 값을 확인하고 싶으면 라우트 안에 print(name)을 넣어 콘솔에서 보세요.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 라우팅 | URL과 함수를 연결하는 선언 — 웹 서버의 본질 |
| GET / POST | 주소창에 붙는 요청 / 본문에 데이터가 실리는 요청 |
| 쿼리 문자열 | ?name=값 — URL 뒤에 붙는 파라미터 |
| 상태 코드 | 200 성공 / 404 없는 주소 / 405 허용 안 된 메서드 |
| XSS | 입력이 HTML 문법으로 실행되는 취약점 — 코드·데이터 혼합 |
| 이스케이프 | < → <처럼 실행 불가 문자로 바꾸는 출력 방어 |
| 개발용/운영용 서버 | app.run()은 개발 전용 — 실서비스는 별도 서버로 |
오늘의 코드
| 코드 | 하는 일 |
|---|---|
@app.route("/주소") |
주소와 함수 연결 (라우팅) |
methods=["GET", "POST"] |
허용할 메서드 지정 |
request.args.get("이름", 기본값) |
GET 쿼리 문자열 꺼내기 |
request.form.get("이름") |
POST 폼 데이터 꺼내기 |
return 딕셔너리 |
JSON 응답 자동 생성 |
escape(문자열) |
XSS 방지 이스케이프 (markupsafe에서 import) |
app.run(host="127.0.0.1", port=5000) |
localhost 개발 서버 기동 |
명령어보다 중요한 감각
서버를 직접 짜 본 오늘부터, 여러분은 웹을 두 얼굴로 봅니다. 주소창의 파라미터 하나가 request.args 한 줄에 도착하는 것을 알고, 그 값이 escape 없이 HTML에 실리면 무슨 일이 벌어지는지 압니다. 서버에 도착하는 입력은 전부 오염되었을 수 있다 — 이 문장이 웹 보안의 제1원리입니다.
그리고 프레임워크가 바뀌어도 뼈대는 안 바뀝니다. Django든 FastAPI든 라우팅, 요청 객체, GET/POST는 그대로입니다 — HTTP가 안 바뀌니까요. 오늘 만든 다섯 줄짜리 서버가, 모든 웹 서비스의 축소 모형입니다.
전부 체크되면 Step 94 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.