Step 320. 1-day 취약점 분석 — 패치 diff로 원리 파악
Level 4 — 제보·CVE 분석·오픈소스 기여 | 난이도 ★★★★☆ | 예상 소요 시간 3시간
전제: Step 86~87의 Git 기초(diff, log, show), Step 115의 CVE 읽는 법, 파이썬 기초 문법을 안다.
- 준비물: Git Bash와 파이썬(3.10+)이 설치된 컴퓨터. 오늘은 외부 CVE를 내려받는 대신, 여러분이 직접 "취약 버전 → 패치 버전" 두 커밋을 가진 연습 저장소를 만들어 분석을 실측합니다. 원리는 실제 CVE 분석과 동일합니다.
- 주의: ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. 이 환경은 외부 네트워크가 차단되어 있어 실제 CVE 페이지는 "화면 예시"로 표기하고, 코드와 diff 분석은 전부 로컬 실측입니다.
취약점이 패치되면 벤더는 "고친 코드"를 공개합니다. 그 차이(diff)를 읽으면 "무엇이 고장 났었는가"를 역으로 계산할 수 있습니다. 이것이 1-day 분석 — 공개된 패치에서 취약 원리를 역산하는, 버그 헌터와 침투 테스터와 보안 연구자가 공유하는 고급 스킬입니다. 오늘은 직접 만든 미니 웹앱의 "패치 커밋"을 분석해, diff 한 장에서 SQL 인젝션의 위치와 원리를 읽어 내는 전 과정을 실습합니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- 1-day 취약점과 패치 diff 분석이 무엇인지, 왜 강력한지 설명한다
- "취약 버전 → 패치 버전" 두 커밋 사이의
git diff출력을 읽고 변경의 의미를 해석한다 - diff에서 보안 패치의 전형적 패턴(검증·이스케이프·길이 체크 추가)을 찾아낸다
- 변경된 코드 한 줄에서 취약점 유형(CWE), 트리거 조건, 영향을 역산한다
- 분석 결과를 "취약 원인 분석 노트" 형식으로 정리한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | Git Bash + 파이썬 3.10+ (표준 라이브러리만 사용) |
| 오늘의 명령 | git log --oneline, git diff <취약> <패치>, git show <해시>:파일 |
| 필요한 개념 | 1-day 취약점, diff 읽기(+/-), SQL 인젝션, CWE, 파라미터 바인딩 |
| 오늘의 산출물 | 취약 원인 분석 노트 1편 — 유형/트리거/영향/패치 원리 포함 |
2-1. 1-day 취약점과 n-day의 세계
0-day는 패치가 없는 상태에서 알려진 취약점, 1-day는 패치가 공개됐지만 아직 많은 시스템에 적용되지 않은 취약점입니다. "패치는 나왔는데 세상은 아직 취약한" 시간차가 1-day의 가치입니다.
공격자는 패치 diff에서 취약 원리를 역산해 패치 안 된 시스템을 노리고, 방어자는 같은 diff로 "우리 시스템이 위험한가"를 판단합니다. 양쪽 모두의 출발점이 diff 한 장입니다. 그래서 diff를 읽는 능력은 공격과 방어를 가리지 않는 공용어입니다.
2-2. 왜 diff인가 — 패치는 취약점의 지도
"취약점이 어디 있는가"를 코드 전체에서 찾는 것은 모래사장에서 바늘 찾기입니다. 그런데 패치는 그 바늘의 위치를 공개해 버립니다 — 고쳐진 곳이 곧 아팠던 곳이기 때문입니다.
보안 패치는 대부분 세 가지 얼굴로 나타납니다.
| 패턴 | 모습 | 역산되는 취약점 |
|---|---|---|
| 검증 추가 | if not is_valid(...): return 같은 조건문이 새로 생김 |
입력 검증 부재 (인젝션, 경로 조작 등) |
| 이스케이프/바인딩 | 문자열 조립이 ? 바인딩이나 escape 함수로 교체됨 |
SQL/명령/XSS 인젝션 |
| 길이·범위 체크 | len(x) > N 검사, 버퍼 크기 고정 |
버퍼 오버플로, 리소스 고갈 |
이 표를 손에 쥐고 diff를 보면, 낯선 코드베이스에서도 "보안 관련 변경"을 먼저 골라낼 수 있습니다.
2-3. SQL 인젝션 복습 — 오늘의 교과서 취약점
오늘의 연습 저장소에는 SQL 인젝션(SQL injection) 취약점이 들어 있습니다. 데이터베이스 쿼리를 문자열 덧붙이기로 만들면, 사용자 입력이 쿼리의 문법 자체를 바꿔 버리는 취약점입니다. 아이디 칸에 admin' --를 넣으면 뒤의 비밀번호 검사가 주석(--) 처리되어 비밀번호 없이 로그인됩니다.
해결책은 파라미터 바인딩(parameterized query) — 쿼리 문장과 데이터를 분리해 전달하는 방식입니다. 오늘 diff에서 이 교체를 직접 보게 됩니다. CWE 번호로는 CWE-89(SQL Injection)입니다. Step 115에서 배운 CVE가 취약점의 번호라면, CWE는 취약점의 종류 번호입니다.
2-4. 분석 노트 — 읽기의 종착지는 쓰기
1-day 분석의 산출물은 코드가 아니라 문서입니다. 네 칸의 형식을 오늘부터 고정합니다.
유형(CWE): 무엇이 문제인가의 분류
트리거: 어떤 입력/상태에서 발동하는가
영향: 성공하면 무엇이 가능한가
패치 원리: 고친 코드가 왜 이 취약점을 막는가
3. 따라 하기
3-1. 연습 저장소 만들기 — 우리가 벤더가 되어 보자
실제 CVE의 패치는 남이 만든 것이지만, 분석 연습은 직접 만든 저장소가 최선입니다 — 정답을 알고 있으니까요. 먼저 취약 버전의 미니 로그인 프로그램을 만듭니다.
입력 (Git Bash — 새 폴더에서 시작):
mkdir oneday-lab && cd oneday-lab
git init
app.py 파일을 아래 내용으로 만듭니다 (메모장이나 편집기로 저장):
"""guestbook v1.0 — 미니 로그인 (취약 버전)"""
import sqlite3
def init_db(path=":memory:"):
conn = sqlite3.connect(path)
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)")
conn.execute("INSERT INTO users (username, password) VALUES ('admin', 's3cr3t!')")
conn.execute("INSERT INTO users (username, password) VALUES ('guest', 'guest123')")
conn.commit()
return conn
def login(conn, username, password):
query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
print(f"[DEBUG] 실행된 쿼리: {query}")
cur = conn.execute(query)
row = cur.fetchone()
if row:
print(f"[OK] 로그인 성공: {row[1]}")
return True
print("[FAIL] 로그인 실패")
return False
if __name__ == "__main__":
conn = init_db()
u = input("아이디: ")
p = input("비밀번호: ")
login(conn, u, p)
읽는 법: login() 함수를 보세요. 쿼리를 f-문자열로 조립합니다 — 사용자 입력이 쿼리 문장 속에 그대로 들어갑니다. 이것이 오늘의 "취약 버전 v1.0"입니다.
커밋합니다.
git add app.py
git commit -m "guestbook 1.0 — 로그인 기능"
출력 (2026-09-09 실측):
[main (root-commit) a36349e] guestbook 1.0 — 로그인 기능
1 file changed, 25 insertions(+)
3-2. 패치 버전 커밋 만들기 — 벤더의 수정
이제 벤더가 취약점을 고치는 상황을 재현합니다. app.py의 login() 함수만 이렇게 고칩니다.
def login(conn, username, password):
query = "SELECT * FROM users WHERE username = ? AND password = ?"
cur = conn.execute(query, (username, password))
row = cur.fetchone()
if row:
print(f"[OK] 로그인 성공: {row[1]}")
return True
print("[FAIL] 로그인 실패")
return False
파일 맨 윗줄의 버전 표시도 v1.1로 바꾸고, 커밋합니다. 실제 보안 패치처럼 커밋 메시지에 취약점 표시를 넣어 봅니다 (오늘의 가상 번호 CVE-2026-EX01은 연습용 가공 번호입니다).
git add app.py
git commit -m "fix: 로그인 쿼리의 SQL 인젝션 수정 (CVE-2026-EX01)"
git log --oneline
출력 (2026-09-09 실측):
6f4cc5b fix: 로그인 쿼리의 SQL 인젝션 수정 (CVE-2026-EX01)
a36349e guestbook 1.0 — 로그인 기능
읽는 법: 이제 이 저장소는 실제 CVE 상황과 같은 구조입니다 — 아래가 취약 버전, 위가 패치 버전. 실제 분석에서는 GitHub의 릴리스 태그(예: v1.0 → v1.1)나 권고문에 적힌 "fix commit" 링크가 이 두 해시의 자리에 옵니다.
3-3. 패치 diff 뽑기 — 분석의 원재료
두 커밋 사이의 차이를 뽑습니다. 순서는 "취약 → 패치"입니다.
입력:
git diff HEAD~1 HEAD
출력 (2026-09-09 실측):
diff --git a/app.py b/app.py
index 521b589..13aef8e 100644
--- a/app.py
+++ b/app.py
@@ -1,4 +1,4 @@
-"""guestbook v1.0 — 미니 로그인 (취약 버전)"""
+"""guestbook v1.1 — 미니 로그인 (패치 버전)"""
import sqlite3
@@ -12,9 +12,8 @@ def init_db(path=":memory:"):
def login(conn, username, password):
- query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
- print(f"[DEBUG] 실행된 쿼리: {query}")
- cur = conn.execute(query)
+ query = "SELECT * FROM users WHERE username = ? AND password = ?"
+ cur = conn.execute(query, (username, password))
row = cur.fetchone()
if row:
print(f"[OK] 로그인 성공: {row[1]}")
읽는 법: Step 86에서 배운 대로 -는 삭제된 줄, +는 추가된 줄입니다. @@ -12,9 +12,8 @@는 "login 함수 근처"라는 위치 표시입니다. 핵심 변경은 세 줄입니다 — f-문자열 쿼리 조립이 사라지고, ? 자리표시자와 (username, password) 튜플로 바뀌었습니다.
실제 분석에서는 GitHub 커밋 페이지의 "Files changed" 탭이 이 출력을 웹 화면으로 보여 줍니다 (화면 예시) — 왼쪽 빨간 줄이 삭제, 오른쪽 초록 줄이 추가로 표시되는 것은 같습니다.
3-4. diff 해부 — 세 줄에서 취약점 역산하기
이제 오늘의 핵심입니다. diff의 각 변경을 질문 세 개로 해부합니다.
질문 1 — 무엇이 사라졌는가? 사라진 줄은 이렇습니다.
query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
사용자 입력이 문자열로 쿼리 안에 직접 박혔습니다. 이 줄이 있었다는 것은, 패치 전에는 입력값이 쿼리의 "데이터"가 아니라 "문법"이 될 수 있었다는 뜻입니다.
질문 2 — 무엇이 왔는가? 새 줄입니다.
query = "SELECT * FROM users WHERE username = ? AND password = ?"
cur = conn.execute(query, (username, password))
2-2의 표를 떠올리세요 — 이것은 "이스케이프/바인딩" 패턴입니다. ?가 자리만 잡고, 실제 값은 별도 튜플로 전달됩니다. 데이터베이스 드라이버가 값을 안전하게 처리하므로, 입력에 따옴표가 있어도 쿼리 문법이 바뀌지 않습니다.
질문 3 — 그 검증이 없으면 무슨 일이 가능한가? 여기서 취약점을 역산합니다. 아이디 칸에 ' OR '1'='1 같은 입력이 들어가면 쿼리 조건이 참으로 오염되고, admin' -- 같은 입력이면 비밀번호 검사가 통째로 주석 처리됩니다. 즉 비밀번호 없이 임의 계정 로그인이 가능했다는 결론입니다.
왜 하는가: 이 세 질문 — "사라진 것 / 온 것 / 없으면 어떻게 되는가" — 이 어떤 언어, 어떤 CVE의 패치에도 그대로 적용되는 만능 절차입니다.
3-5. 과거 버전 파일 꺼내기 — git show
Step 321의 재현을 위해, 취약 버전과 패치 버전의 app.py를 각각 파일로 꺼내 둡니다. checkout 없이 저장소에서 바로 꺼내는 명령입니다.
입력:
git show HEAD~1:app.py > app_v1.py
git show HEAD:app.py > app_v2.py
ls
출력 (2026-09-09 실측):
app.py app_v1.py app_v2.py
읽는 법: git show 해시:파일은 "그 커밋 시점의 이 파일 내용"을 출력합니다. HEAD~1은 "한 단계 전 커밋"(취약 버전), HEAD는 최신(패치 버전)입니다. 이 두 파일이 다음 챕터 재현 실습의 실험 재료가 됩니다. 명령 형식은 Step 87의 3-6(지운 키 꺼내 보기)에서 이미 써 본 것입니다.
3-6. 분석 노트 작성 — 읽기를 문서로 고정
지금까지 읽은 것을 2-4의 형식으로 적습니다. 이것이 오늘의 산출물입니다.
분석 노트 예시 (2026-09-09 실측 분석):
분석 대상: guestbook v1.0 → v1.1 패치 (연습 저장소, 가상 CVE-2026-EX01)
유형(CWE): CWE-89 SQL Injection
취약 위치: app.py login() — 쿼리를 f-문자열로 조립하던 줄
트리거: 아이디 입력에 ' 와 SQL 주석(--) 포함. 예: admin' --
영향: 비밀번호 없이 임의 계정으로 로그인 (인증 우회)
패치 원리: 쿼리 문장과 데이터를 분리(? 바인딩)해 입력이 문법이 되는 것을 차단
패치 검증: diff에 검증/길이 체크가 아닌 바인딩 교체가 보임 — 2-2 표의 '이스케이프' 패턴
읽는 법: 이 노트 한 장이 있으면 누구에게든 이 취약점을 5분 안에 설명할 수 있습니다. 그리고 Step 321에서는 이 노트가 재현 계획서로, Step 322에서는 혼자 하는 분석의 템플릿으로 재사용됩니다.
왜 하는가: 분석은 머리에서 끝나면 휘발됩니다. "유형/트리거/영향/패치 원리" 네 칸으로 적는 순간, 여러분의 것이 됩니다.
4. 미션과 연습문제
미션 — 두 번째 취약점의 diff 분석 노트
oneday-lab저장소에 파일search.py를 새로 만듭니다 — 검색어를 받아f"SELECT * FROM users WHERE username LIKE '%{kw}%'"형태로 조립해 실행하는 함수 하나면 됩니다- 이 상태를 "검색 기능 추가"로 커밋합니다 (취약 버전)
- 같은 파일을
?바인딩 방식으로 고치고 "fix: 검색 기능 SQL 인젝션 수정"으로 커밋합니다 git diff HEAD~1 HEAD로 패치 diff를 뽑고, 3-4의 세 질문(사라진 것/온 것/없으면 어떻게)을 문장으로 답합니다- 3-6 형식의 분석 노트를 완성합니다 — 트리거 칸에는 구체적 입력 예시를 적습니다
연습문제
문제 1. 1-day 취약점이란 무엇이며, 0-day와의 차이를 "패치 존재 여부"로 설명해 보세요.
문제 2. diff 출력의 @@ -12,9 +12,8 @@는 무엇을 뜻하나요? 그리고 - 줄과 + 줄의 의미를 각각 말해 보세요.
문제 3. 2-2의 세 패턴(검증 추가/이스케이프·바인딩/길이·범위 체크) 중, 오늘 실습의 패치는 어느 것에 해당하나요? 각 패턴이 시사하는 취약점 종류도 함께 적어 보세요.
문제 4. 거대한 diff(파일 수십 개)에서 보안 관련 변경을 먼저 찾는 요령을, 커밋 메시지와 2-2 패턴 표를 활용해 설명해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
명령 흐름은 3-1~3-3과 동일합니다. 취약 버전의 핵심 줄 예시:
def search(conn, kw):
query = f"SELECT * FROM users WHERE username LIKE '%{kw}%'"
return conn.execute(query).fetchall()
패치 버전:
def search(conn, kw):
return conn.execute(
"SELECT * FROM users WHERE username LIKE ?", ('%' + kw + '%',)
).fetchall()
세 질문의 답 예시: ① 사라진 것 — 검색어가 문자열로 쿼리에 직접 박히는 조립 방식. ② 온 것 — ? 자리표시자와 값 튜플 전달. ③ 없으면 — 검색어에 ' OR '1'='1 같은 입력으로 전체 사용자 목록 유출 가능.
분석 노트의 트리거 칸 예시: 검색어 입력에 ' 또는 SQL 키워드 포함. 예: %' OR '1'='1.
검증하는 법: ① git log --oneline에 취약·패치 커밋이 순서대로 있는가. ② diff가 두 방식의 차이만 깔끔히 보여 주는가. ③ 노트 네 칸(유형/트리거/영향/패치 원리)이 전부 채워졌고, 트리거에 구체 입력이 있는가.
연습문제 해답
문제 1 해답. 1-day는 패치(수정 코드)가 공개된 취약점입니다. 이름의 뜻은 "패치 공개 후 1일(짧은 시간) 안의 취약점"으로, 아직 패치를 적용하지 않은 시스템이 남아 있는 시간차가 위험의 본체입니다. 0-day는 패치 자체가 없는 상태에서 알려진 취약점입니다 — 그래서 방어할 공식 수단이 없습니다.
문제 2 해답. @@ -12,9 +12,8 @@는 "원본 12번 줄부터 9줄, 결과 12번 줄부터 8줄"이라는 변경 구간의 위치 표시입니다. -로 시작하는 줄은 삭제된(취약 버전에만 있던) 줄, +로 시작하는 줄은 추가된(패치 버전에 새로 생긴) 줄입니다.
문제 3 해답. "이스케이프/바인딩" 패턴입니다. 문자열 조립을 ? 바인딩으로 교체했으니, 역산되는 취약점은 인젝션(여기서는 SQL 인젝션, CWE-89)입니다. 검증 추가는 인젝션·경로 조작·권한 우회 등을, 길이·범위 체크는 버퍼 오버플로나 리소스 고객을 시사합니다.
문제 4 해답. 먼저 커밋 메시지와 권고문에서 "fix", "security", "CVE", "injection", "overflow" 같은 단서를 찾아 변경 파일을 좁힙니다. 그다음 파일 단위 diff에서 2-2의 세 패턴 — 새로 생긴 if 검증, escape/바인딩 함수 호출, 길이 비교 — 을 먼저 검색합니다. 기능 추가(새 파일, UI 변경)와 보안 수정을 섞어 올리는 경우가 많아, 이 필터링이 분석 시간을 가릅니다. 핵심 함수를 찾으면 그 함수를 호출하는 경로를 거슬러 올라가 트리거를 확정합니다.
완료 기준 체크리스트
- [ ] "취약 → 패치" 두 커밋을 가진 연습 저장소를 직접 만들었다
- [ ]
git diff HEAD~1 HEAD로 패치 diff를 뽑을 수 있다 - [ ] diff의
-/+/@@표기를 읽을 수 있다 - [ ] 보안 패치의 세 패턴(검증/이스케이프/길이 체크)을 열거할 수 있다
- [ ]
git show 해시:파일로 과거 버전 파일을 꺼낼 수 있다 - [ ] diff 한 장에서 취약 유형·트리거·영향·패치 원리를 역산할 수 있다
- [ ] 미션: 두 번째 취약점의 분석 노트를 완성했다
6. 흔한 실수와 해결
벽 1. diff가 너무 커서 어디가 핵심인지 모르겠다
증상: 실제 프로젝트의 패치 diff를 열었더니 파일 수십 개, 수천 줄입니다.
원인: 릴리스에는 기능 변경과 보안 수정이 섞여 있습니다. 전부 읽으려는 것이 함정입니다.
해결: 벽 1의 필터 순서를 쓰세요 — ① 커밋 메시지의 보안 단서(fix/security/CVE)로 파일 좁히기 → ② 2-2의 세 패턴(검증/이스케이프/길이 체크) 검색 → ③ 찾은 함수의 호출 경로를 거슬러 트리거 확인. 오늘의 작은 diff에서 이 순서를 연습해 두면 큰 diff에서도 같은 손동작이 나옵니다.
벽 2. git diff 방향을 거꾸로 쳤다
증상: git diff HEAD HEAD~1로 쳐서 +와 -가 반대로 보입니다.
원인: diff는 "왼쪽에서 오른쪽으로 가려면 무엇이 바뀌어야 하는가"를 보여 줍니다. 순서가 뒤집히면 "패치를 되돌리는 diff"가 됩니다.
해결: 항상 git diff <취약> <패치> 순서로 치세요. 출력에서 취약한 코드가 -로 보이면 순서가 맞습니다.
벽 3. "바인딩으로 고쳤다"는데 왜 안전한지 모르겠다
증상: diff는 읽었는데 ?가 왜 해결책인지 설명이 안 됩니다.
원인: 문자열 조립에서는 입력이 쿼리 문장의 일부가 되지만, 바인딩에서는 입력이 오직 값으로만 전달됩니다. 데이터베이스 드라이버가 따옴표를 문법이 아니라 글자로 처리하기 때문입니다.
해결: Step 321에서 직접 확인합니다 — admin' --를 취약 버전과 패치 버전에 각각 넣어 보는 재현이 이 질문의 완전한 답입니다.
벽 4. git show 리다이렉션 파일이 이상하다
증상 (Windows 환경 차이): git show HEAD~1:app.py > app_v1.py로 만든 파일을 메모장으로 열었더니 줄바꿈이 깨져 보이거나 인코딩이 다릅니다.
원인: Git Bash의 출력은 UTF-8/LF인데, Windows 메모장은 다른 기본값을 쓸 수 있습니다.
해결: VS Code 같은 UTF-8을 아는 편집기로 열거나, 파이썬 실행에는 지장이 없으니 그대로 진행해도 됩니다. python app_v1.py가 실행되면 정상입니다.
벽 5. 커밋 메시지에 쓴 가상 CVE 번호를 진짜로 착각한다
증상: CVE-2026-EX01을 검색해 봅니다.
원인: 오늘의 번호는 연습용으로 지어낸 가공 번호입니다 (EX = exercise).
해결: 실제 분석에서는 권고문이나 NVD에서 진짜 번호를 확인합니다. Step 115에서 배운 대로, 번호의 상태(Analyzed/REJECT)를 확인하는 습관도 함께요.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 1-day 취약점 | 패치는 공개됐지만 적용이 안 된 시간차의 취약점 |
| 패치 diff 분석 | 고쳐진 코드에서 취약 원리를 역산하는 기술 |
| 보안 패치 3패턴 | 검증 추가 / 이스케이프·바인딩 / 길이·범위 체크 |
| SQL 인젝션 (CWE-89) | 입력이 쿼리 문법이 되어 버리는 취약점 |
| 파라미터 바인딩 | 쿼리 문장과 데이터를 분리하는 인젝션 차단 방식 |
| CWE | 취약점의 "종류 번호" — CVE는 개별 사건, CWE는 분류 |
| 분석 노트 | 유형/트리거/영향/패치 원리 네 칸의 문서 |
오늘의 명령어
| 명령 | 하는 일 |
|---|---|
git log --oneline |
취약·패치 커밋 해시 확인 |
git diff <취약> <패치> |
패치 diff 뽑기 (순서 주의) |
git show <해시>:<파일> |
특정 커밋 시점의 파일 내용 꺼내기 |
git show <해시>:<파일> > 저장할파일 |
과거 버전을 파일로 추출 |
명령어보다 중요한 감각
오늘의 만능 절차는 세 질문입니다 — 무엇이 사라졌는가 / 무엇이 왔는가 / 그것이 없으면 무슨 일이 가능한가. 이 질문 세 개면 파이썬이든 C든 자바스크립트든, 어떤 보안 패치에서도 취약점을 역산할 수 있습니다.
그리고 하나 더. diff 분석의 산출물은 코드가 아니라 네 칸의 분석 노트입니다. 읽는 능력은 쓰기로 완성됩니다. 오늘 만든 노트는 Step 321에서 재현 계획서가 되고, Step 322에서는 혼자 서는 분석의 템플릿이 됩니다.
전부 체크되면 Step 320 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.