diff --git a/backend/src/main/resources/seed/quizzes.json b/backend/src/main/resources/seed/quizzes.json
index 3d2a8a6..9f53e6d 100644
--- a/backend/src/main/resources/seed/quizzes.json
+++ b/backend/src/main/resources/seed/quizzes.json
@@ -4148,5 +4148,2405 @@
"option4": "웹은 원래 롤백이 불가능하다",
"answerIndex": 0,
"explanation": "웹은 이전 이미지로 즉시 되돌릴 수 있지만, 스토어는 즉시 롤백이 어려워 새 버전을 다시 올려 심사를 받아야 하므로 출시 전 테스트가 더 중요하다."
+ },
+ {
+ "courseSlug": "lv1-what-is-a-program",
+ "question": "이 강좌에서 말하는 컴퓨터의 가장 큰 특징으로 알맞은 것은?",
+ "option1": "상황을 눈치껏 판단해 알아서 일을 처리한다",
+ "option2": "시킨 것만, 시킨 그대로 빠르고 정확하게 수행한다",
+ "option3": "모호한 지시를 받으면 스스로 빈칸을 채워 이해한다",
+ "option4": "사람의 말을 한국어 그대로 알아듣는다",
+ "answerIndex": 1,
+ "explanation": "컴퓨터는 스스로 판단하거나 눈치껏 빈칸을 채우는 능력이 전혀 없고, 시킨 일을 엄청나게 빠르게·오차 없이 그대로 수행하는 기계입니다. 그래서 '정확한 지시'를 만드는 일이 곧 프로그래밍의 본질이 됩니다."
+ },
+ {
+ "courseSlug": "lv1-what-is-a-program",
+ "question": "알람 앱, 엘리베이터, 신호등 등 거의 모든 프로그램이 공통으로 갖는 세 박자 패턴은?",
+ "option1": "설계 → 디자인 → 판매",
+ "option2": "저장 → 삭제 → 복구",
+ "option3": "입력 → 처리 → 출력",
+ "option4": "질문 → 대답 → 평가",
+ "answerIndex": 2,
+ "explanation": "프로그램은 무언가를 받아서(입력), 규칙대로 따져 보고(처리), 결과를 내보내는(출력) 구조로 움직입니다. 알람 앱은 맞춘 시각과 현재 시각을 입력받아 비교(처리)한 뒤 소리를 울리는(출력) 식으로, 일상 속 프로그램 대부분이 이 패턴을 따릅니다."
+ },
+ {
+ "courseSlug": "lv1-what-is-a-program",
+ "question": "라면 끓이기 레시피 예시에서 배운 '알고리즘'에 대한 설명으로 옳은 것은?",
+ "option1": "문제를 해결하는 단계들의 순서 있는 묶음으로, 순서가 바뀌면 결과도 달라진다",
+ "option2": "반드시 프로그래밍 언어로 작성해야만 알고리즘이라고 부를 수 있다",
+ "option3": "단계 하나쯤 빠져도 컴퓨터가 알아서 채워 주므로 결과는 같다",
+ "option4": "요리처럼 손으로 하는 일에는 적용할 수 없는 개념이다",
+ "answerIndex": 0,
+ "explanation": "알고리즘은 문제를 푸는 단계들의 순서 있는 묶음이며, 같은 단계라도 순서가 바뀌거나 하나만 빠져도 결과가 달라집니다. 또한 알고리즘은 절차 그 자체이므로 한국어나 그림으로 표현해도 되고, 코드는 그 절차를 컴퓨터 언어로 옮긴 결과물일 뿐입니다."
+ },
+ {
+ "courseSlug": "lv1-what-is-a-program",
+ "question": "프로그래밍 언어가 필요한 이유로 이 강좌에서 설명한 것은?",
+ "option1": "컴퓨터가 한국어보다 영어를 더 잘 알아듣기 때문에",
+ "option2": "0과 1을 완전히 없애고 사람의 말로만 컴퓨터를 움직이기 위해서",
+ "option3": "사람의 말은 모호하고 컴퓨터는 결국 0과 1만 이해하므로, 그 사이를 잇는 번역기가 필요해서",
+ "option4": "프로그래밍 언어를 쓰면 컴퓨터가 스스로 판단하는 능력이 생기기 때문에",
+ "answerIndex": 2,
+ "explanation": "사람의 말은 '적당히', '넉넉히'처럼 모호함투성이인데 컴퓨터는 모호함을 견디지 못하고, CPU가 실제로 알아듣는 것은 0과 1뿐입니다. 프로그래밍 언어는 사람이 읽고 쓸 수 있으면서도 기계가 0과 1로 정확히 바꿀 수 있는, 사람과 기계 사이의 번역기 역할을 합니다."
+ },
+ {
+ "courseSlug": "lv1-what-is-a-program",
+ "question": "코드가 실행되는 방식에 대한 설명으로 옳은 것은?",
+ "option1": "컴퓨터가 코드 전체를 읽고 가장 효율적인 순서로 알아서 재배열해 실행한다",
+ "option2": "코드는 위에서 아래로 한 줄씩, 쓴 순서 그대로 실행된다",
+ "option3": "에러가 나오면 그 프로그램은 다시는 고칠 수 없다",
+ "option4": "개발은 한 번 작성하면 끝이며 결과를 확인할 필요가 없다",
+ "answerIndex": 1,
+ "explanation": "컴퓨터는 라면 레시피를 따르듯 코드를 위에서 아래로 한 줄씩, 쓴 순서 그대로 실행하며 절대 스스로 순서를 바꾸지 않습니다. 그리고 개발은 작성→실행→결과 확인→고치기의 사이클을 반복하는 과정이며, 에러 메시지는 어디를 왜 못 알아들었는지 알려 주는 힌트입니다."
+ },
+ {
+ "courseSlug": "lv1-computational-thinking",
+ "question": "컴퓨팅 사고의 네 기둥이 올바르게 짝지어진 것은 무엇인가요?",
+ "option1": "분해 · 패턴 인식 · 추상화 · 알고리즘",
+ "option2": "암기 · 계산 · 타자 · 검색",
+ "option3": "변수 · 함수 · 조건문 · 반복문",
+ "option4": "기획 · 디자인 · 개발 · 테스트",
+ "answerIndex": 0,
+ "explanation": "컴퓨팅 사고는 큰 문제를 잘게 쪼개는 '분해', 반복되는 모양을 찾는 '패턴 인식', 목적에 필요한 정보만 남기는 '추상화', 순서 있는 해결 절차를 세우는 '알고리즘'의 네 기둥으로 이루어집니다. 변수·함수 등은 나중에 배울 프로그래밍 언어의 요소이지, 생각의 기둥 자체가 아니에요."
+ },
+ {
+ "courseSlug": "lv1-computational-thinking",
+ "question": "'생일파티 준비'처럼 큰 문제 앞에서 막막함이 사라지게 하는 분해의 핵심 효과는 무엇인가요?",
+ "option1": "문제 자체가 쉬워져서 아무나 풀 수 있게 된다",
+ "option2": "쪼개진 작은 조각은 '지금 당장 할 수 있는 크기'가 되어 시작점이 생긴다",
+ "option3": "컴퓨터가 문제를 대신 풀어 준다",
+ "option4": "문제의 개수가 줄어들어 할 일이 적어진다",
+ "answerIndex": 1,
+ "explanation": "막막함은 문제가 어려워서가 아니라 '커서' 생깁니다. '파티 준비'는 어디서 손대야 할지 몰라도 '오는 사람 수 세기'는 바로 할 수 있죠. 분해는 문제를 없애거나 개수를 줄이는 게 아니라, 각 조각을 풀 수 있는 크기로 만들어 시작점을 주는 기술입니다."
+ },
+ {
+ "courseSlug": "lv1-computational-thinking",
+ "question": "지하철 노선도가 강의 모양이나 실제 거리를 '틀리게' 그려 놓았는데도 유용한 이유는 무엇인가요?",
+ "option1": "정밀한 지도를 그릴 기술이 없던 시절에 만들어졌기 때문에",
+ "option2": "지도가 단순할수록 인쇄 비용이 싸지기 때문에",
+ "option3": "'몇 정거장, 어디서 환승?'이라는 목적에 필요한 정보만 남기고 나머지는 버렸기 때문에",
+ "option4": "실제 지형 정보는 보안상 공개할 수 없기 때문에",
+ "answerIndex": 2,
+ "explanation": "노선도는 추상화의 예시입니다. 목적(역의 순서와 환승 위치 파악)에 필요 없는 강 모양·실제 거리·건물 정보를 과감히 버렸기 때문에 오히려 쓸모 있어진 것이죠. 버려서 부정확해진 게 아니라, 버려서 목적에 딱 맞게 단순해진 것 — 단순화가 곧 이해력입니다."
+ },
+ {
+ "courseSlug": "lv1-computational-thinking",
+ "question": "순서도에서 '예/아니오로 갈라지는 질문(판단)'을 나타내는 기호는 무엇인가요?",
+ "option1": "둥근 모양(타원)",
+ "option2": "네모(사각형)",
+ "option3": "화살표",
+ "option4": "마름모",
+ "answerIndex": 3,
+ "explanation": "순서도의 기본 기호는 세 가지입니다. 둥근 모양은 시작/끝, 네모는 실제로 하는 일(처리), 마름모는 예/아니오로 갈라지는 판단이에요. 화살표는 기호가 아니라 기호들을 잇는 흐름입니다. '물이 끓는가?' 같은 질문이 바로 마름모에 들어가죠."
+ },
+ {
+ "courseSlug": "lv1-computational-thinking",
+ "question": "1부터 10까지 더하는 문제를 '하나씩 9번 더하기'로도, '11이 되는 5쌍을 만들어 11×5'로도 풀 수 있다는 예시가 보여 주는 것은 무엇인가요?",
+ "option1": "같은 문제라도 알고리즘은 여러 개일 수 있고, 그중 더 나은 것을 고르는 안목도 실력이다",
+ "option2": "수학 공식을 외우면 알고리즘이 필요 없다",
+ "option3": "알고리즘은 문제마다 정답이 딱 하나만 존재한다",
+ "option4": "느린 알고리즘은 틀린 알고리즘이다",
+ "answerIndex": 0,
+ "explanation": "두 방법 모두 정답 55에 도착하므로 둘 다 올바른 알고리즘입니다. 즉 같은 문제라도 해결 절차는 여러 개일 수 있어요. 다만 짝지어 더하는 방법이 더 빠르듯, 여러 길 중 더 빠르고 우아한 길을 알아보는 안목도 컴퓨팅 사고의 일부입니다. 느리다고 '틀린' 것은 아니라는 점도 중요해요."
+ },
+ {
+ "courseSlug": "lv1-first-html",
+ "question": "웹페이지의 정체에 대한 설명으로 가장 알맞은 것은 무엇인가요?",
+ "option1": "게임처럼 컴파일된 복잡한 실행 프로그램이다",
+ "option2": "약속된 표시(태그)가 들어 있는 텍스트 파일이며, 브라우저가 이를 읽어 화면에 그려 준다",
+ "option3": "인터넷 서버가 있어야만 만들고 열 수 있는 특수 파일이다",
+ "option4": "브라우저 안에서만 작성할 수 있는 문서다",
+ "answerIndex": 1,
+ "explanation": "웹페이지는 특별한 프로그램이 아니라 HTML이라는 약속된 표시가 담긴 글자 문서(텍스트 파일)입니다. 브라우저는 이 문서를 읽어 제목은 크게, 문단은 나눠서 화면에 '그려' 주는 역할을 하죠. 내 컴퓨터에 저장된 .html 파일도 똑같이 열 수 있으므로, 인터넷이나 서버 없이도 웹페이지를 만들 수 있습니다."
+ },
+ {
+ "courseSlug": "lv1-first-html",
+ "question": "HTML 문서에서
와 의 역할을 바르게 짝지은 것은 무엇인가요?",
+ "option1": "head는 화면에 보이는 내용, body는 문서에 대한 정보를 담는다",
+ "option2": "head는 문서에 대한 정보(탭 제목·charset 등)를 담고, body는 화면에 실제로 보이는 내용을 담는다",
+ "option3": "head와 body 모두 화면에 보이는 내용을 담으며 순서만 다르다",
+ "option4": "head는 최신 HTML 규칙임을 선언하는 부분이고, body는 주석을 모아 두는 부분이다",
+ "answerIndex": 1,
+ "explanation": "head는 '문서에 대한 정보'가 사는 곳이라 본문 화면에는 보이지 않습니다. 대신 은 브라우저 탭에 표시되고, charset은 글자 읽는 방식을 알려 주죠. 화면에 보이는 글·제목 등 모든 내용은 body 안에 들어갑니다. 참고로 최신 HTML 규칙임을 선언하는 것은 head가 아니라 파일 첫 줄의 입니다."
+ },
+ {
+ "courseSlug": "lv1-first-html",
+ "question": "태그의 기본 구조에 대한 설명으로 옳은 것은 무엇인가요?",
+ "option1": "여는 태그와 닫는 태그가 내용을 감싸며, 닫는 태그에는 슬래시(/)가 붙는다",
+ "option2": "모든 태그는 반드시 닫는 태그가 있어야 하며 예외는 없다",
+ "option3": "슬래시(/)는 여는 태그에 붙여서 시작을 표시한다",
+ "option4": "여는 태그와 닫는 태그 사이에는 아무 내용도 넣을 수 없다",
+ "answerIndex": 0,
+ "explanation": "태그는 '여는 태그 + 내용 + 닫는 태그'의 3단 샌드위치 구조이고, 닫는 태그에만 슬래시(/)를 붙여 \"여기까지!\"를 표시합니다(예:
내용
). 다만 , 처럼 감쌀 내용이 없어 닫는 태그 없이 혼자 다니는 예외 태그도 있으므로 2번은 틀린 설명입니다."
+ },
+ {
+ "courseSlug": "lv1-first-html",
+ "question": "h1~h6 제목 태그를 쓰는 올바른 태도는 무엇인가요?",
+ "option1": "글자를 크게 만들고 싶을 때마다 아무 h 태그나 골라 쓴다",
+ "option2": "h6가 가장 중요한 제목이므로 대표 제목에는 h6를 쓴다",
+ "option3": "h 태그는 중요도의 위계를 표현하는 도구이므로, 대표 제목 h1 아래 h2, h3 순으로 건너뛰지 않고 쓴다",
+ "option4": "한 페이지에 h1을 많이 쓸수록 구조가 좋아진다",
+ "answerIndex": 2,
+ "explanation": "h 태그는 글자 크기 버튼이 아니라 책의 장(h1)과 절(h2)처럼 '중요도의 순서'를 매기는 도구입니다. h1이 가장 크고 중요한 대표 제목(보통 페이지에 1개), 그 아래 소제목은 h2, 그 안의 묶음은 h3처럼 건너뛰지 않고 순서대로 쓰는 것이 좋은 습관이라고 배웠습니다."
+ },
+ {
+ "courseSlug": "lv1-first-html",
+ "question": "만든 페이지를 브라우저로 열었더니 한글이 '김미림'처럼 깨져 보입니다. 가장 먼저 확인해야 할 것은 무엇인가요?",
+ "option1": "body 안에 구분선이 있는지 확인한다",
+ "option2": "head 안에 이 있는지 확인한다",
+ "option3": "닫는 태그의 슬래시를 모두 지웠는지 확인한다",
+ "option4": "브라우저를 다른 회사 제품으로 바꿔서 다시 연다",
+ "answerIndex": 1,
+ "explanation": "charset은 \"이 문서의 글자를 UTF-8 방식으로 읽어 줘\"라고 브라우저에 알리는 안내입니다. 이 한 줄이 없으면 브라우저가 글자 해석 방식을 잘못 추측해 한글이 외계어처럼 깨질 수 있어요. 그래서 첫 파일의 뼈대를 만들 때부터 head 안에 을 넣어 두는 것입니다."
+ },
+ {
+ "courseSlug": "lv1-html-links-images-lists",
+ "question": "링크를 눌렀을 때 '새 탭'에서 열리게 하려면 a 태그에 무엇을 추가해야 할까요?",
+ "option1": "href=\"_blank\"",
+ "option2": "target=\"_blank\"",
+ "option3": "newtab=\"true\"",
+ "option4": "alt=\"_blank\"",
+ "answerIndex": 1,
+ "explanation": "href는 '어디로 갈지' 목적지 주소를 적는 속성이고, '어떻게 열지'를 정하는 건 target 속성입니다. target=\"_blank\"를 달면 지금 페이지를 그대로 둔 채 새 탭에서 링크가 열려요. 다른 사이트로 보낼 때 자주 쓰는 옵션입니다."
+ },
+ {
+ "courseSlug": "lv1-html-links-images-lists",
+ "question": "img 태그의 alt 속성에 대한 설명으로 옳은 것은 무엇일까요?",
+ "option1": "이미지 파일이 저장된 위치(주소)를 적는 칸이다",
+ "option2": "이미지의 크기를 픽셀 단위로 정하는 칸이다",
+ "option3": "이미지가 안 보일 때 대신 표시되고, 화면 낭독기 사용자에게 이미지를 설명해 주는 대체 텍스트다",
+ "option4": "이미지를 클릭했을 때 이동할 링크 주소를 적는 칸이다",
+ "answerIndex": 2,
+ "explanation": "이미지 위치(주소)를 적는 속성은 src이고, alt(alternative)는 '대체' 텍스트입니다. 주소가 틀려 이미지가 깨졌을 때 그 자리에 대신 표시되고, 눈이 불편해 화면 낭독기를 쓰는 사람에겐 그 이미지의 내용 전부가 되기 때문에 '창밖을 보는 고양이'처럼 그림을 말로 옮기듯 적어야 해요."
+ },
+ {
+ "courseSlug": "lv1-html-links-images-lists",
+ "question": "라면 끓이는 순서(물 끓이기 → 면 넣기 → 3분 기다리기)처럼 순서가 중요한 목록을 만들 때 알맞은 태그는 무엇일까요?",
+ "option1": "ul — 항목마다 ● 점이 찍히는 목록",
+ "option2": "li — 목록 전체를 감싸는 바구니 태그",
+ "option3": "table — 가로세로 칸으로 정리하는 태그",
+ "option4": "ol — 항목마다 1. 2. 3. 번호가 붙는 목록",
+ "answerIndex": 3,
+ "explanation": "ol은 ordered list(순서 있는 목록)의 줄임말로, 항목에 자동으로 번호가 붙습니다. 라면 끓이기처럼 순서를 뒤섞으면 뜻이 통하지 않는 내용은 ol, 좋아하는 음식처럼 순서를 바꿔도 상관없는 내용은 ul을 씁니다. li는 두 목록 안에 들어가는 '항목 하나하나'를 적는 태그이지 목록 전체를 감싸는 태그가 아니에요."
+ },
+ {
+ "courseSlug": "lv1-html-links-images-lists",
+ "question": "표를 만드는 태그들의 역할 분담으로 옳은 것은 무엇일까요?",
+ "option1": "tr은 가로 한 줄(행)을, td는 줄 안의 내용 칸 하나를 만든다",
+ "option2": "tr은 세로 한 줄(열)을, th는 표 전체를 감싸는 틀이다",
+ "option3": "td는 가로 한 줄(행)을, tr은 제목 칸을 만든다",
+ "option4": "th는 표 전체를 감싸고, table은 칸 하나하나를 만든다",
+ "answerIndex": 0,
+ "explanation": "표는 table(표 전체 틀) > tr(table row, 가로 한 줄=행) > td(table data, 내용 칸)/th(table header, 굵게 나오는 제목 칸)의 순서로 역할을 나눕니다. 표를 짤 때는 '가로 한 줄 = tr 하나'라는 규칙으로 위에서부터 한 줄씩 코드로 옮기면 되고, 줄마다 칸 개수가 같아야 표가 반듯하게 나와요."
+ },
+ {
+ "courseSlug": "lv1-html-links-images-lists",
+ "question": "div 태그를 화면에 띄워 보면 겉보기에 아무 변화가 없습니다. 그런데도 div를 쓰는 이유는 무엇일까요?",
+ "option1": "div를 넣으면 페이지 글자가 자동으로 굵어지기 때문에",
+ "option2": "관련 있는 내용을 '투명한 상자'처럼 한데 묶어 두면, 나중에 꾸미거나 배치할 때 상자째로 다룰 수 있기 때문에",
+ "option3": "div가 없으면 이미지와 링크가 아예 표시되지 않기 때문에",
+ "option4": "div가 목록에 자동으로 번호를 붙여 주기 때문에",
+ "answerIndex": 1,
+ "explanation": "div(division, 구역)는 화면에 무언가를 그리는 태그가 아니라 내용을 '묶는' 태그입니다. 그래서 넣어도 겉보기엔 변화가 없는 게 정상이에요. 하지만 이사할 때 물건을 상자에 담아 두면 옮기기 쉬운 것처럼, 내용을 div로 묶어 구역을 나눠 두면 나중에 CSS로 꾸미고 배치할 때 상자 단위로 한꺼번에 다룰 수 있습니다. 실제 웹사이트들도 수십, 수백 개의 div 블록으로 짜여 있어요."
+ },
+ {
+ "courseSlug": "lv1-how-browsers-work",
+ "question": "주소창에 www.naver.com을 치고 엔터를 눌렀을 때 일어나는 일을 가장 잘 설명한 것은?",
+ "option1": "브라우저가 내 컴퓨터 안에 저장된 네이버 화면을 꺼내 보여 준다",
+ "option2": "브라우저가 서버에 요청(Request)을 보내고, 서버가 보내 준 응답(Response)의 HTML을 화면에 그린다",
+ "option3": "서버가 완성된 그림 파일을 보내 주고, 브라우저는 그 그림을 띄우기만 한다",
+ "option4": "브라우저가 스스로 HTML을 만들어 내서 화면에 그린다",
+ "answerIndex": 1,
+ "explanation": "웹은 '요청하고 응답받는' 대화 구조예요. 브라우저가 멀리 있는 서버(남의 컴퓨터)에 요청을 보내면, 서버는 완성된 그림이 아니라 HTML이라는 글자 문서를 응답으로 보내 줍니다. 그 문서를 읽고 우리 눈에 보이는 화면으로 그려 내는 것(렌더링)이 바로 브라우저의 일이에요."
+ },
+ {
+ "courseSlug": "lv1-how-browsers-work",
+ "question": "브라우저의 세 가지 언어와 역할을 사람에 비유했을 때, 짝이 올바른 것은?",
+ "option1": "HTML = 움직임, CSS = 뼈대, JavaScript = 옷",
+ "option2": "HTML = 옷, CSS = 움직임, JavaScript = 뼈대",
+ "option3": "HTML = 뼈대, CSS = 옷, JavaScript = 움직임",
+ "option4": "HTML = 뼈대, CSS = 움직임, JavaScript = 옷",
+ "answerIndex": 2,
+ "explanation": "HTML은 '여기가 제목, 저기가 버튼'처럼 구조를 정하는 뼈대, CSS는 '제목은 크게, 버튼은 파랗게'처럼 모양을 입히는 옷, JavaScript는 '버튼을 누르면 이걸 해라'처럼 동작을 붙이는 움직임 담당이에요. 옷은 뼈대가 있어야 입힐 수 있으니 웹 공부가 HTML부터 시작하는 것도 이 순서 때문입니다."
+ },
+ {
+ "courseSlug": "lv1-how-browsers-work",
+ "question": "개발자 도구(F12)의 Elements 탭에서 뉴스 사이트의 제목을 바꿨는데, F5로 새로고침하니 원래대로 돌아왔다. 그 이유는?",
+ "option1": "내가 고친 것은 서버의 원본이 아니라 내 브라우저에 도착한 복사본이고, 새로고침은 서버에서 원본을 다시 받아 오기 때문이다",
+ "option2": "사이트 관리자가 내가 고친 것을 발견하고 즉시 되돌렸기 때문이다",
+ "option3": "개발자 도구에서는 애초에 글자를 바꾸는 것이 불가능하기 때문이다",
+ "option4": "브라우저가 고장 나서 수정 내용을 저장하지 못했기 때문이다",
+ "answerIndex": 0,
+ "explanation": "서버는 HTML 문서를 통째로 보내 주고, 내 브라우저는 그 복사본을 화면에 그려요. Elements 탭에서 고치는 건 이 복사본에 낙서하는 것이라 서버의 원본은 멀쩡합니다. 새로고침은 '복사본을 버리고 원본을 다시 받아 오기'라서 수정이 사라지는 거예요. 그래서 개발자 도구로 아무리 만져도 실제 사이트는 망가지지 않습니다."
+ },
+ {
+ "courseSlug": "lv1-how-browsers-work",
+ "question": "개발자 도구의 콘솔(Console) 탭에 대한 설명으로 옳은 것은?",
+ "option1": "콘솔은 화면의 HTML 구조를 트리 모양으로 보여 주기만 하는 곳이다",
+ "option2": "콘솔에 2+3을 입력하면 브라우저가 즉시 실행해 5라고 답하고, 페이지의 에러도 이곳에 빨간 글씨로 표시된다",
+ "option3": "콘솔은 인터넷이 연결되어 있어야만 열 수 있다",
+ "option4": "콘솔에 에러가 빨간 글씨로 뜨면 브라우저가 고장 났다는 뜻이므로 재설치해야 한다",
+ "answerIndex": 1,
+ "explanation": "콘솔은 브라우저와 나누는 대화창(채팅창)이에요. 2+3을 치면 브라우저 속 JavaScript 엔진이 그 자리에서 실행해 5라고 대답합니다. 또한 페이지의 JavaScript가 실패하면 '몇 번째 줄에서 왜' 넘어졌는지가 여기 빨간 글씨로 찍혀요. 에러는 고장 신호가 아니라 원인을 알려 주는 힌트 쪽지입니다. HTML 구조를 보여 주는 곳은 Elements 탭이에요."
+ },
+ {
+ "courseSlug": "lv1-how-browsers-work",
+ "question": "내가 만든 HTML 파일을 더블클릭해 file://로 열어 놓고 친구에게 링크를 보냈더니 친구는 볼 수 없었다. 친구도 볼 수 있게 하려면 무엇이 필요할까?",
+ "option1": "친구에게 더 좋은 브라우저를 설치하라고 한다",
+ "option2": "HTML 파일을 CSS와 JavaScript로 다시 작성한다",
+ "option3": "내 컴퓨터의 화면 밝기를 높인다",
+ "option4": "요청이 오면 응답해 주는 서버에 내 파일을 올린다 — 즉 배포(deploy)가 필요하다",
+ "answerIndex": 3,
+ "explanation": "file://은 내 컴퓨터 안의 파일을 직접 여는 방식이라, 그 파일이 없는 친구 컴퓨터에서는 열리지 않아요. 남에게 보여 주려면 누가 요청하든 응답해 줄 수 있는 서버에 파일을 올려 https:// 주소로 접근하게 해야 하고, 이 과정을 배포라고 부릅니다. file://과 https://의 차이는 결국 '문서가 어디에서 왔는가'예요."
+ },
+ {
+ "courseSlug": "lv1-first-javascript",
+ "question": "웹 페이지를 만드는 세 가지 언어 중, 버튼 클릭에 반응하거나 팝업을 띄우는 등 페이지에 '움직임'을 담당하는 것은 무엇인가요?",
+ "option1": "HTML",
+ "option2": "CSS",
+ "option3": "JavaScript",
+ "option4": "Java",
+ "answerIndex": 2,
+ "explanation": "HTML은 뼈대(글·버튼·사진 배치), CSS는 꾸밈(색·크기·배치)을 맡고, 클릭 반응·팝업·계산 같은 '움직임'은 모두 JavaScript의 일입니다. Java는 이름만 비슷할 뿐 JavaScript와는 완전히 다른 언어예요."
+ },
+ {
+ "courseSlug": "lv1-first-javascript",
+ "question": "콘솔에 console.log(안녕!); 을 입력하면 에러가 납니다. 그 이유로 옳은 것은?",
+ "option1": "세미콜론(;)을 붙였기 때문이다",
+ "option2": "글자(문자열)를 따옴표로 감싸지 않았기 때문이다",
+ "option3": "괄호를 소괄호가 아니라 대괄호로 써야 하기 때문이다",
+ "option4": "console.log는 콘솔에서는 실행할 수 없기 때문이다",
+ "answerIndex": 1,
+ "explanation": "글자 덩어리(문자열)는 반드시 따옴표로 감싸야 합니다. 따옴표가 없으면 컴퓨터는 '안녕'을 글자가 아니라 어떤 이름(변수)으로 오해해서 찾다가 에러를 냅니다. 세미콜론은 문장의 마침표 역할이라 붙여도 문제가 없어요."
+ },
+ {
+ "courseSlug": "lv1-first-javascript",
+ "question": "
+
+`;
+
+const CODE_SCRIPT_SRC = `
+
+
안녕하세요!
+
+
+
+
+
+
+console.log("별도 파일에서 실행됐어요!");`;
+
+const CODE_ALERT = `// alert — 화면 한가운데에 팝업 창을 띄웁니다.
+alert("반가워요!");
+
+// prompt — 질문을 던지고, 사용자가 입력한 답을 '돌려받아요'.
+prompt("이름이 뭐예요?");
+
+// 돌려받은 답은 이름을 붙여 잠깐 보관할 수 있어요.
+let name = prompt("이름이 뭐예요?");
+alert(name + "님 환영합니다!"); // + 는 글자를 이어 붙입니다`;
+
+const CODE_PRACTICE = `
+
+
+ 환영 인사 페이지
+
+
+
환영 인사 페이지
+
+
+
+
+
+`;
+
+const CODE_MATH = `// 콘솔은 훌륭한 계산기이기도 해요. 그대로 입력해 보세요.
+3 + 5 // 8 (더하기)
+10 - 4 // 6 (빼기)
+6 * 7 // 42 (곱하기 — x가 아니라 별표 *)
+10 / 4 // 2.5 (나누기 — ÷가 아니라 빗금 /)
+
+// 계산 순서는 수학 시간과 같아요. 곱하기·나누기 먼저, 괄호가 최우선.
+2 + 3 * 4 // 14 (3*4가 먼저)
+(2 + 3) * 4 // 20 (괄호가 먼저)`;
+
+const CODE_STRING_TRAP = `// 함정 하나 맛보기 — 아래 둘의 결과가 다릅니다!
+1 + 1 // 2 — 숫자 + 숫자 = 계산
+"1" + 1 // "11" — 문자열 + 숫자 = 이어 붙이기!
+
+// 따옴표로 감싼 "1"은 숫자가 아니라 '글자 1'이에요.
+// + 는 한쪽이라도 글자면 계산 대신 이어 붙이기를 선택합니다.
+// prompt()로 받은 답도 항상 '글자'라서, 숫자 계산 전엔 변환이 필요해요.
+// (변환하는 법은 다음 강좌에서 제대로 배웁니다 — 지금은 함정의 존재만 기억!)`;
+
+const CODE_ONCLICK = `
+
클릭해 보세요
+
+
+
+
+
+
+
+
+`;
+
+const CODE_TYPOS = `// 컴퓨터는 대소문자를 다른 글자로 봅니다.
+console.log("안녕"); // O
+Console.log("안녕"); // X — Console is not defined (C가 대문자)
+console.Log("안녕"); // X — console.Log is not a function (L이 대문자)
+
+// 괄호·따옴표는 반드시 '짝'으로 열고 닫아야 해요.
+alert("반가워요!"; // X — 닫는 괄호 ) 가 없다
+alert("반가워요!) // X — 닫는 따옴표 " 가 없다
+alert("반가워요!"); // O — 여닫이 완성
+
+// 빨간 에러가 떠도 당황하지 마세요. 메시지를 읽어 보면
+// '몇 번째 줄'에서 '무엇이' 이상한지 힌트가 적혀 있습니다.`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 지금까지의 웹 페이지는 멈춰 있는 그림이었어요. 클릭해도 조용하고,
+ 물어봐도 대답이 없죠. 오늘 그 페이지가 처음으로 말을 걸고 반응하기{' '}
+ 시작합니다. 설치할 것도 없어요 — 여러분의 브라우저 안에 이미 준비돼 있으니까요.
+ 첫 코드 한 줄의 짜릿함, 지금 시작합니다.
+
+
+ 예상 소요 60분
+ 실습: 이름 묻고 인사하는 페이지
+ 선수 지식: 없음 (브라우저만 있으면 OK)
+
+
+
+ {/* 섹션 바로가기 */}
+
+
+
+
+ 웹 페이지는 세 명이 함께 만드는 작품이에요. HTML이 글·버튼·사진을
+ 화면에 놓고(뼈대), CSS가 색과 모양을 입히죠(꾸밈). 그런데 이 둘만으로
+ 만든 페이지는 아무리 예뻐도 움직이지 않는 포스터일 뿐이에요. 버튼을
+ 눌러도 조용하고, 질문해도 대답이 없습니다.
+
+
+ 세 번째 주인공 JavaScript(자바스크립트, 줄여서 JS)가 바로 그
+ '움직임' 담당이에요. 버튼이 클릭에 반응하고, 팝업이 뜨고, 계산 결과가 화면에 나타나는
+ 것 — 여러분이 웹에서 본 거의 모든 움직임이 JS의 일입니다.
+
+ {CODE_TRIO}
+
+ 좋은 소식 하나. JavaScript는 설치할 필요가 없어요. 크롬이든 엣지든,
+ 여러분이 지금 쓰는 브라우저 안에 JS를 읽고 실행하는 장치가 이미 들어
+ 있습니다. 준비물은 브라우저 하나 — 그게 전부예요.
+
+
+ 이름 주의 JavaScript와 Java는 다른 언어예요. 이름이 비슷한 건
+ 옛날 마케팅의 흔적일 뿐, '카멜'과 '카멜레온'만큼 다릅니다. 우리가 배우는 건 브라우저
+ 속에서 웹 페이지를 움직이는 JavaScript입니다.
+
+
+
+
+
+ 브라우저 안에는 개발자를 위한 비밀 작업실, 콘솔(Console)이 숨어 있어요.
+ 코드를 입력하고 Enter를 누르면 그 자리에서 즉시 실행되는 공간이죠.
+ 파일을 만들 필요도 없으니 첫 실습 장소로 딱입니다. 여는 법: 브라우저에서{' '}
+ F12를 누르고(맥은 Cmd+Option+I), 위쪽 탭에서{' '}
+ Console을 클릭하세요.
+
+ {CODE_FIRST_LOG}
+
+ 축하해요 — 방금 그 한 줄이 여러분의 첫 프로그램입니다! 컴퓨터에게
+ "이걸 적어 줘"라고 명령했고, 컴퓨터가 그대로 따랐어요. 이제 규칙 두 가지만 챙겨 봅시다.
+
+
+ 첫째, 글자는 따옴표로 감싼다. 따옴표로 감싼 글자 덩어리를{' '}
+ 문자열(string)이라고 부르는데, 따옴표가 없으면 컴퓨터는 그걸 글자가
+ 아니라 '어떤 이름'으로 오해해서 에러를 냅니다. 둘째, 줄 끝의{' '}
+ ;(세미콜론)은 "이 문장은 여기서 끝"이라는{' '}
+ 마침표예요.
+
+ {CODE_QUOTES}
+
+ 세미콜론, 꼭 붙여야 하나요? 사실 JS는 세미콜론을 빼먹어도 대부분 알아서
+ 이해해 줘요. 하지만 가끔 애매한 상황에서 오해가 생기고, 무엇보다 "문장을 맺는 습관"이
+ 몸에 배면 나중에 다른 언어를 배울 때도 편합니다. 이 과정에서는 붙이는
+ 쪽으로 통일할게요.
+
+
+
+
+
+ 콘솔의 코드는 브라우저를 닫으면 사라져요. 코드를 내 페이지의 일부로
+ 만들려면 HTML 안에 심어야 합니다. 그 자리가 바로{' '}
+ <script> 태그예요. 이 태그의 여는 문과 닫는 문
+ 사이에 적힌 것은 전부 JavaScript로 실행됩니다.
+
+ {CODE_SCRIPT_IN_HTML}
+
+ 위치가 중요해요. <script>는 보통{' '}
+ </body> 바로 앞, 즉 몸통의 맨
+ 끝에 둡니다. 브라우저는 HTML을 위에서 아래로 읽으면서 화면을 그리는데,
+ 스크립트를 끝에 두면 화면이 다 그려진 다음에 코드가 돌아요. 코드가
+ "아직 안 그려진 버튼"을 찾다가 헛손질하는 사고를 막는 배치입니다.
+
+
+ 코드가 길어지면 HTML 파일이 지저분해지겠죠? 그럴 땐 별도의{' '}
+ .js 파일로 분리하고, src{' '}
+ 속성으로 불러옵니다. CSS 파일을 따로 두고 링크로 거는 것과 똑같은 발상이에요 —
+ 역할별로 파일을 나누면 나중에 찾기도, 고치기도 쉬워집니다.
+
+ {CODE_SCRIPT_SRC}
+
+ 직접 확인해 보기 위 첫 번째 예제를 메모장에 붙여 넣고{' '}
+ first.html로 저장한 뒤 더블클릭해 보세요. 화면엔
+ "안녕하세요!"가 보이고, F12 → Console을 열면 우리가 심어 둔 메시지가
+ 찍혀 있을 거예요. 화면과 콘솔, 두 무대를 다 확인하는 습관을 들이면 좋습니다.
+
+
+
+
+
+ 콘솔은 개발자만 보는 무대였죠. 이번엔 사용자 눈앞으로 나가 봅시다.{' '}
+ alert("...")는 화면 한가운데에 팝업 창을
+ 띄워 말을 걸고, prompt("...")는 질문과 함께{' '}
+ 입력 칸을 내밀어 사용자의 대답을 받아 옵니다.
+
+ {CODE_ALERT}
+
+ 여기서 마법 같은 일이 벌어져요. prompt가 받아 온 대답은{' '}
+ let name = ...처럼 이름을 붙여 보관해 둘
+ 수 있고(상자에 담는다고 상상하세요 — 자세한 건 다음 강좌에서!), 그걸{' '}
+ +로 다른 글자와 이어 붙여 다시 화면에
+ 되돌려 줄 수 있습니다. 사용자가 "지민"이라고 답하면{' '}
+ "지민님 환영합니다!"가 뜨는 거죠.
+
+
이제 오늘의 실습입니다. 아래 내용을 통째로 저장해서 열어 보세요.
+ {CODE_PRACTICE}
+
+ 이 순간을 기억하세요 방금 만든 페이지는 사용자에 따라 다르게
+ 반응해요. 누가 열어도 똑같던 '포스터'가, 여는 사람의 이름을 물어 그 사람만을
+ 위한 인사를 건넵니다. 이 차이 — 입력을 받아 다르게 응답하는 것 — 가 문서와{' '}
+ 프로그램의 갈림길이에요. 여러분은 방금 그 선을 넘었습니다.
+
+
+
+
+
+ JavaScript는 계산도 잘해요. 콘솔에 수식을 그대로 입력하면 즉시 답을 돌려줍니다.
+ 기호만 두 개 조심하세요. 곱하기는 x가 아니라{' '}
+ 별표 *, 나누기는 ÷가 아니라{' '}
+ 빗금 /입니다.
+
+ {CODE_MATH}
+
+ 계산 순서는 수학 시간에 배운 그대로예요 — 곱하기·나누기가 더하기·빼기보다 먼저,
+ 괄호가 있으면 괄호가 최우선. 그리고 앞으로 "이 계산 맞나?" 싶을 때마다{' '}
+ 콘솔에 먼저 쳐 보는 습관을 들이세요. 콘솔은 언제나 켜져 있는, 거짓말
+ 안 하는 계산기입니다.
+
+
+ 그런데 여기, 초보자 열에 아홉이 밟는 함정이 하나 있어요.
+
+ {CODE_STRING_TRAP}
+
+ "1" + 1이 2가 아니라 "11"이 되는 이유 —
+ 따옴표를 두른 "1"은 숫자가 아니라 글자이기
+ 때문이에요. +는 한쪽이라도 글자면 계산을 포기하고{' '}
+ 이어 붙이기를 선택합니다. 섹션 2에서 배운 "따옴표 = 문자열" 규칙이
+ 이렇게 실전에서 발목을 잡죠.
+
+
+ 미리 보는 다음 강좌prompt()로 받은 대답도 항상{' '}
+ 글자예요. 그래서 나이를 물어 1을 더하는 프로그램을 만들면 "15" + 1 =
+ "151"이 되는 사고가 납니다. 글자를 숫자로 바꾸는 법은 다음 강좌에서 제대로 배워요 —
+ 오늘은 함정이 있다는 사실만 챙기면 충분합니다.
+
+
+
+
+
+ 지금까지 코드는 페이지가 열리는 순간 한 번 실행되고 끝났어요. 진짜
+ 재미는 지금부터입니다 — 사용자가 클릭하는 순간에 코드가 실행되게
+ 만들어 볼 거예요. 버튼 태그에 onclick 속성을 붙이면
+ 됩니다. 뜻 그대로 "클릭되면(on click) 이 코드를 실행해 줘"라는 예약이에요.
+
+ {CODE_ONCLICK}
+
+ 직접 저장해서 눌러 보세요. 페이지를 열 때는 조용하다가, 여러분이 누르는
+ 순간 팝업이 튀어나옵니다. 열 번 누르면 열 번 반응해요. HTML로 그려 둔
+ 버튼(뼈대)과 JavaScript(움직임)가 onclick에서 처음으로
+ 손을 잡은 순간입니다.
+
+
+ 사소해 보여도 이 경험은 큽니다. 남이 만든 앱을 쓰기만 하던 사람에서,{' '}
+ 반응하는 것을 만드는 사람으로 자리가 바뀌었으니까요. 앞으로 배울
+ 모든 것 — 게임, 웹앱, 인공지능 서비스 — 도 결국 이 구조의 확장이에요. "어떤 일이
+ 일어나면(클릭·입력·시간), 어떤 코드를 실행한다."
+
+
+ 따옴표 겹치기 요령onclick="..."의 바깥은 이미
+ 큰따옴표가 차지하고 있죠? 그래서 안쪽 JS의 문자열은 작은따옴표로
+ 씁니다: onclick="alert('안녕!')". 같은 종류의 따옴표를
+ 겹쳐 쓰면 컴퓨터가 어디서 닫혔는지 헷갈려 에러가 나요 — 겉과 속을 다르게, 기억!
+
+
+
+
+
+ 여기까지 따라오며 이미 한 번쯤 빨간 에러를 만났을 거예요. 축하합니다,
+ 정상적으로 배우고 있다는 증거예요. 컴퓨터는 사람과 달리 "대충 알아듣기"를 전혀
+ 못해서, 딱 세 가지에서 늘 사고가 납니다.
+
+
+ 첫째, 대소문자. 컴퓨터에게 console과{' '}
+ Console은 완전히 다른 단어예요. 둘째,{' '}
+ 괄호와 따옴표의 짝. 연 것은 반드시 닫아야 합니다 — 여는 것만 있고
+ 닫는 게 없으면 컴퓨터는 문장이 끝나길 하염없이 기다리다 에러를 내요.
+
+ {CODE_TYPOS}
+
+ 셋째가 가장 중요합니다. 에러 메시지를 읽는 태도예요. 콘솔의 빨간
+ 글씨는 선생님의 빨간 펜이 아니라, "여기 몇 번째 줄, 이게 이상해요"라고
+ 알려 주는 힌트입니다. 예를 들어{' '}
+ Console is not defined는 "Console이란 건 모르는데?"라는
+ 뜻이니, 대문자 C를 소문자로 고치면 끝이죠. 에러를 두려워하는 사람은 늘지 않고,
+ 에러를 읽는 사람은 빠르게 늡니다.
+
+
+ 프로도 매일 겪어요 경력 10년 차 개발자도 하루에 몇 번씩 오타 에러를 만나요.
+ 차이는 단 하나 — 당황하지 않고 메시지를 읽고, 그 줄을 확인하는{' '}
+ 루틴이 몸에 배어 있다는 것. 오늘부터 에러가 뜨면 ① 빨간 글씨를 읽고 ② 줄 번호를
+ 찾고 ③ 대소문자·괄호·따옴표 짝을 살피세요. 이 세 걸음이면 오늘 만날 에러의 대부분이
+ 풀립니다.
+
+
+
+
+
+
✨ 여기까지 왔다면
+
+ 멈춰 있던 페이지가 오늘 처음으로 여러분에게 말을 걸고, 이름을 묻고,
+ 대답했어요. 콘솔에서 첫 코드를 실행했고, HTML에 스크립트를 심었고,
+ alert·prompt로 대화했고, 버튼을 클릭에 반응시켰고, 빨간 에러를 읽는 법까지
+ 익혔습니다. 그런데 실습에서 슬쩍 지나간 게 하나 있죠 —{' '}
+ let name = ..., 대답을 담아 두던 그 '이름 붙인 상자'요.
+ 저 상자의 정체가 바로 프로그래밍의 심장, 변수입니다. 다음{' '}
+ 변수와 자료형: 이름 붙인 상자{' '}
+ 강좌에서 상자를 마음껏 만들고, "1"+1이 11이 되던 오늘의 함정도 말끔히 풀어 봅시다.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv1FirstMiniProjectPage.jsx b/frontend/src/pages/levels/Lv1FirstMiniProjectPage.jsx
new file mode 100644
index 0000000..ed57319
--- /dev/null
+++ b/frontend/src/pages/levels/Lv1FirstMiniProjectPage.jsx
@@ -0,0 +1,465 @@
+// 이 파일이 하는 일: "첫 프로젝트: 숫자 맞히기 게임 만들기" 코스 — 초급(코딩 첫걸음) 과정의
+// 졸업 프로젝트. 지금까지 따로따로 배운 변수·조건문·반복·함수·HTML을 한 판에 모아,
+// 1~100 사이 숫자를 맞히는 웹 게임을 '설계 → 뼈대 → 로직 → 화면 → 테스트 → 다듬기'
+// 순서로 처음부터 끝까지 완성한다. 대상은 코딩을 처음 해 보는 중·고등학생.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_PREVIEW = `┌──────────────────────────────────┐
+│ 숫자 맞히기 게임 │
+│ │
+│ 1부터 100 사이의 숫자를 하나 │
+│ 생각해 뒀어요. 맞혀 보세요! │
+│ │
+│ [ 입력창: 50 ] [ 도전! ] │
+│ │
+│ → "업! 더 큰 수예요." (3번째 시도)│
+└──────────────────────────────────┘
+
+플레이 흐름:
+50 입력 → "업!" → 75 입력 → "다운!" → 62 입력 →
+"정답! 3번 만에 맞혔어요 🎉"
+
+이 화면 하나에 우리가 배운 게 전부 들어가요.
+입력창·버튼 = HTML, 정답 기억 = 변수,
+업/다운 판단 = 조건문, 도전 반복 = 사용자가 만드는 반복,
+"도전!"을 누를 때 실행되는 묶음 = 함수.`;
+
+const CODE_PLAN = `[종이 코딩 — 코드보다 먼저 쓰는 계획표]
+
+화면에 필요한 것 (HTML 재료):
+ - 제목 글자 → "숫자 맞히기 게임"
+ - 숫자 입력창 → 플레이어가 추측을 적는 곳
+ - "도전!" 버튼 → 누르면 채점
+ - 결과 문구 영역 → "업!" / "다운!" / "정답!"
+
+기억해야 하는 것 (변수 재료):
+ - answer : 컴퓨터가 뽑아 둔 정답 숫자 (1~100)
+ - tries : 지금까지 도전한 횟수
+
+해야 하는 일 (함수 재료):
+ - check() : 입력값을 읽어서 정답과 비교하고 결과를 보여 준다
+
+게임 흐름 (순서도):
+ 시작 → 정답 숫자를 몰래 뽑는다
+ → [플레이어가 숫자 입력 후 버튼 클릭]
+ → 시도 횟수 +1
+ → 입력 < 정답 ? → "업!" 표시 → 다시 입력으로
+ → 입력 > 정답 ? → "다운!" 표시 → 다시 입력으로
+ → 입력 = 정답 ? → "정답! N번 만에 성공" → 끝`;
+
+const CODE_HTML = `
+
숫자 맞히기 게임
+
1부터 100 사이의 숫자를 하나 생각해 뒀어요. 맞혀 보세요!
+
+
+
+
+
+
아직 시도 전이에요.
+
+`;
+
+const CODE_RANDOM = `// 정답 숫자 뽑기 — 게임의 심장
+// Math.random() → 0 이상 1 미만의 소수 (예: 0.7231…)
+// Math.random() * 100 → 0 이상 100 미만 (예: 72.31…)
+// Math.floor(…) → 소수점을 잘라 정수로 (예: 72) ← 0~99
+// … + 1 → 한 칸 밀어서 1~100
+
+var answer = Math.floor(Math.random() * 100) + 1;
+var tries = 0; // 시도 횟수는 0에서 시작
+
+// answer는 게임이 시작될 때 딱 한 번 뽑혀서
+// 게임이 끝날 때까지 변수 안에 '기억'되어 있어요.
+// 새로고침(F5)하면 다시 뽑히니, 매판 정답이 달라집니다.`;
+
+const CODE_CHECK = `// check() — "도전!" 버튼이 눌릴 때마다 실행될 채점 함수
+function check() {
+ // ① 입력창에서 값을 읽는다. 입력창의 값은 늘 '글자'라서
+ // Number()로 숫자로 바꿔 줘야 정답과 비교할 수 있어요.
+ var guess = Number(document.getElementById('guess').value);
+
+ // ② 시도 횟수를 하나 올린다
+ tries = tries + 1;
+
+ // ③ 조건문 삼거리: 셋 중 정확히 하나의 길로만 간다
+ if (guess === answer) {
+ console.log('정답! ' + tries + '번 만에 맞혔어요');
+ } else if (guess < answer) {
+ console.log('업! 더 큰 수예요.');
+ } else {
+ console.log('다운! 더 작은 수예요.');
+ }
+}
+
+// 지금은 결과가 console.log로만 나와요(F12 콘솔에서 확인).
+// 화면에 보여 주는 건 다음 섹션의 몫 — 한 번에 한 걸음씩!`;
+
+const CODE_UI = `
+
+
+
+
+`;
+
+const CODE_TEST = `[버그 사냥 체크리스트 — 직접 플레이하며 하나씩 확인]
+
+정상 플레이:
+ ☐ 작은 수(예: 1)를 넣으면 "업!"이 나오는가?
+ ☐ 큰 수(예: 100)를 넣으면 "다운!"이 나오는가?
+ ☐ 업/다운을 따라가면 결국 "정답!"에 도착하는가?
+ ☐ 시도 횟수가 1, 2, 3… 제대로 올라가는가?
+
+이상한 입력 (일부러 괴롭혀 보기):
+ ☐ 아무것도 안 쓰고 "도전!" → 무슨 일이?
+ ☐ 음수(-5)나 999를 넣으면?
+ ☐ 글자를 넣으면? → Number('abc')는 NaN(Not a Number)이 돼요
+
+글자 입력 방어하기 — check() 맨 앞에 문지기 한 줄:
+ if (isNaN(guess)) {
+ result.innerText = '숫자를 입력해 주세요!';
+ return; // 여기서 함수를 끝낸다 → 아래 채점은 실행 안 됨
+ }
+
+에러가 나면? 빨간 글씨는 혼내는 게 아니라 '단서'예요.
+ Uncaught ReferenceError: chek is not defined
+ → "chek라는 이름은 없는데?" = 오타! check로 고치면 끝.`;
+
+const CODE_STYLE = `
+
숫자 맞히기 게임
+
+
+
+
+
+
+ 아직 시도 전이에요.
+
+
+`;
+
+const CODE_MAP = `[여기까지 배운 개념 지도 — 게임 속 어디에 살고 있나]
+
+HTML → 제목·입력창·버튼·결과 영역 (게임의 몸)
+id → 요소의 이름표 (guess, result)
+변수 → answer(정답 기억), tries(횟수 세기)
+Math.random → 매판 다른 정답 (게임의 심장)
+함수 → check() — 채점 절차를 하나로 묶은 상자
+조건문 → 업 / 다운 / 정답 삼거리
+반복 → 플레이어가 맞힐 때까지 '도전!'을 반복
+onclick → 버튼과 함수를 잇는 전선
+innerText → 화면의 글자를 바꾸는 손
+isNaN → 이상한 입력을 막는 문지기
+에러 읽기 → 빨간 글씨 = 단서, 오타 사냥
+
+다음 정거장:
+ CSS → style 속성을 파일로 분리해 제대로 꾸미기
+ 배열 → 지난 추측들을 목록으로 기억하기 [50, 75, 62]
+ DOM → innerText 너머, 화면을 자유자재로 주무르기`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 변수, 조건문, 반복, 함수, HTML — 지금까지 하나씩 배운 조각들을 오늘{' '}
+ 전부 한 판에 모읍니다. 컴퓨터가 몰래 뽑은 1~100 사이의 숫자를
+ 업/다운 힌트로 맞히는 웹 게임을, 설계부터 자랑까지 처음부터 끝까지
+ 직접 완성해요. 끝나고 나면 확실해집니다 — "나, 코딩 할 수 있네."
+
+
+ 예상 소요 90분
+ 실습: 남은 기회 5번 제한 추가
+ 준비물: 지금까지 배운 전부
+
+ 오늘 만들 것은 숫자 맞히기 게임이에요. 규칙은 간단합니다. 컴퓨터가
+ 1부터 100 사이의 숫자를 하나 몰래 뽑아 두면, 플레이어가 숫자를 입력해서 맞히는 거예요.
+ 틀리면 컴퓨터가 힌트를 줍니다 — 정답이 더 크면 "업!", 더 작으면{' '}
+ "다운!". 맞힐 때까지 도전하고, 맞히면 몇 번 만에 성공했는지
+ 알려 줘요.
+
+
+ 무언가를 만들 땐 완성된 모습을 먼저 보는 것이 중요해요. 목적지를
+ 알아야 길을 잃지 않으니까요. 우리가 도착할 화면은 이렇게 생겼습니다.
+
+ {CODE_PREVIEW}
+
+ 작아 보여도 이 게임 안에는 지금까지 배운 개념이 전부 들어가요.
+ 입력창과 버튼은 HTML, 정답을 기억하는 건 변수,
+ 업/다운을 판단하는 건 조건문, 버튼을 누를 때마다 실행되는 채점
+ 절차는 함수입니다. 배운 조각이 하나로 맞물리는 순간을 오늘
+ 직접 경험해요.
+
+
+ 프로젝트가 최고의 복습인 이유 강의를 따라 칠 때는 "아는 것 같은" 상태예요.
+ 그런데 빈 파일 앞에서 처음부터 만들어 보면, 진짜 아는 것과 아는 것 같았던 것이
+ 갈립니다. 막히는 곳이 바로 여러분의 다음 공부 지점이에요 — 막히는 건 실패가 아니라
+ 지도에 표시되는 보물 위치입니다.
+
+
+
+
+
+ 프로가 되는 습관 하나를 여기서 배워요. 바로 코드를 치기 전에 계획부터
+ 쓰는 것입니다. 무작정 타자부터 치면 중간에 "어… 다음에 뭐 하지?" 하고
+ 길을 잃기 쉬워요. 종이(또는 메모장)에 세 가지만 적으면 길이 선명해집니다 —{' '}
+ ① 화면에 뭐가 필요한가, ② 뭘 기억해야 하는가, ③ 무슨 일을 해야 하는가.
+
+ {CODE_PLAN}
+
+ 이 계획표가 곧 이 코스의 지도예요. "화면에 필요한 것"은 섹션 3의
+ HTML이 되고, "기억해야 하는 것"은 섹션 4의 변수가 되고, "해야 하는 일"은 섹션 4~5의
+ 함수가 됩니다. 순서도의 삼거리(업/다운/정답)는 조건문{' '}
+ if / else if / else와 정확히 같은 모양이고요.
+
+
+ 변수 개수 세어 보기 이 게임에 필요한 변수는 딱 둘이에요 —{' '}
+ answer(정답)와 tries(횟수).
+ "기억할 게 뭐지?"라고 스스로 물어보는 습관이 곧 변수를 설계하는 감각입니다.
+ 나중에 '남은 기회 5번' 기능을 붙일 때, 기억할 게 하나 더 늘어나겠죠? 그게 힌트예요.
+
+
+
+
+
+ 설계도가 나왔으니 몸부터 만듭니다. 메모장이나 에디터에서{' '}
+ game.html 파일을 만들고, 계획표의 "화면에 필요한 것"
+ 네 가지 — 제목, 입력창, 버튼, 결과 영역 — 을 그대로 옮겨 적어요.
+
+ {CODE_HTML}
+
+ 여기서 제일 중요한 건 id예요.{' '}
+ id="guess", id="result"는
+ 요소에 붙이는 이름표입니다. 다음 섹션에서 자바스크립트가
+ "guess라는 이름표 달린 애, 값 좀 줘 봐"라고 이름으로 찾아오기 때문에,
+ 이름표 없는 요소는 스크립트가 만질 수 없어요.
+
+
+ 못생겨도 괜찮은 이유 지금 이 화면, 솔직히 밋밋하죠? 일부러 그래요.
+ 프로들도 "일단 동작하게, 그다음 예쁘게" 순서로 만듭니다. 꾸미기는
+ 섹션 7에서 몰아서 해요. 처음부터 예쁘게 만들려다 보면 정작 게임이 영영 완성되지
+ 않는 일이 정말 많거든요.
+
+
+
+
+
+ 이제 몸에 두뇌를 넣을 차례예요.{' '}
+ <script> 태그 안에 자바스크립트를 씁니다.
+ 첫 임무는 정답 숫자를 몰래 뽑아 두는 것 — 여기서{' '}
+ Math.random()이 등장합니다.
+
+ {CODE_RANDOM}
+
+ Math.floor(Math.random() * 100) + 1을 읽는 법:
+ 안쪽부터 바깥쪽으로, 0~1 소수 → 0~100 미만으로 늘리고 → 소수점을 잘라
+ 0~99 → 1을 더해 1~100. 마지막 + 1을 빼먹으면
+ 0이 나올 수 있고 100은 절대 안 나오는, 미묘하게 틀린 게임이 돼요.
+
+
+ 두 번째 임무는 채점입니다. 계획표의 "해야 하는 일"이었던{' '}
+ check() 함수를 만들고, 그 안에 조건문 삼거리를 놓아요.
+
+ {CODE_CHECK}
+
+ 왜 Number()가 필요할까 입력창에서 꺼낸 값은 겉보기에 숫자여도 속은{' '}
+ 글자('50')예요. 글자 '50'과 숫자 50은 다른 존재라서,{' '}
+ Number()로 변신시킨 뒤에 비교해야 정확합니다.
+ "입력값은 일단 글자다"라고 기억해 두면 앞으로 수많은 버그를 피해 갈 수 있어요.
+
+
+
+
+
+ 두뇌는 완성됐지만 아직 결과가 F12 콘솔에만 나와요. 플레이어는
+ 콘솔을 열지 않으니, 결과를 화면에 보여 줘야 진짜 게임이죠.
+ 필요한 연결은 딱 두 개입니다.
+
+
+ 첫째, 버튼과 함수를 잇는 전선 —{' '}
+ onclick="check()". "이 버튼이 클릭되면(on click)
+ check 함수를 실행해 줘"라는 뜻이에요. 둘째, 화면 글자를 바꾸는 손 —{' '}
+ innerText. 이름표로 찾아온 요소의 글자를 통째로
+ 갈아 끼웁니다.
+
+ {CODE_UI}
+
+ 저장하고 브라우저에서 열어 보세요. 숫자를 넣고 "도전!"을 누르면 화면 문구가
+ 바뀝니다. 업/다운을 따라가다 정답을 맞히면 축하 메시지와 함께 시도 횟수가 공개돼요.
+ 지금 이 순간, 여러분은 움직이는 웹 게임을 처음부터 만든 사람입니다.
+
+
+ 반복문은 어디 갔지? "반복도 배웠는데 for가 안 보여요!" — 좋은 관찰이에요.
+ 이 게임의 반복은 플레이어가 만들어요. 맞힐 때까지 버튼을 다시
+ 누르는 것 자체가 반복이거든요. 버튼이 눌릴 때마다 check()가
+ 다시 실행되니, "같은 일을 여러 번"이라는 반복의 본질은 그대로입니다. 도구만
+ 다를 뿐이에요.
+
+
+
+
+
+ "다 만들었다!"의 진짜 뜻은 "이제 테스트를 시작한다"예요.
+ 만든 사람은 자기 게임을 곱게 다루지만, 진짜 플레이어는 아무거나 넣거든요.
+ 그러니 우리가 먼저 일부러 괴롭혀 봅니다.
+
+ {CODE_TEST}
+
+ 글자를 넣으면 Number('abc')가{' '}
+ NaN(Not a Number, "숫자 아님")이 되면서 비교가
+ 전부 이상해져요. 그래서 isNaN()이라는 문지기를 함수
+ 맨 앞에 세우고, 이상한 입력이면 return으로 즉시
+ 함수를 끝냅니다 — "출입 불가, 채점 안 함"이죠.
+
+
+ 에러 읽기 기술, 실전 적용 코드가 안 돌면 F12 → Console부터
+ 여세요. 빨간 에러의 문법은 늘 같습니다 — 무엇이(에러 이름) + 어디서(파일:줄
+ 번호). check is not defined는 십중팔구 함수
+ 이름 오타, Cannot read properties of null은 십중팔구
+ id 이름표 오타예요. 에러는 혼내는 게 아니라 범인의 위치를 불러 주는 목격자입니다.
+
+
+
+
+
+ 동작이 확인됐으니 이제 옷을 입힐 차례예요. HTML 요소에{' '}
+ style 속성을 붙이면 색·크기·여백을 바꿀 수 있습니다.
+ 아래는 예시일 뿐 — 색과 크기는 여러분 취향대로 바꿔 보세요.
+ 바꾸고 → 저장하고 → 새로고침하고, 이 삼박자가 웹 개발의 기본 리듬입니다.
+
+ {CODE_STYLE}
+
+ 다 꾸몄으면 마지막 단계, 자랑입니다. 우리 게임은{' '}
+ game.html파일 하나가 전부라서,
+ 그 파일을 메신저나 메일로 보내면 친구도 더블클릭 한 번으로 플레이할 수 있어요.
+ 설치도 서버도 필요 없습니다 — 이게 웹의 힘이에요.
+
+
+ 친구는 최고의 테스터 친구가 플레이하는 걸 말없이 옆에서 지켜보세요.{' '}
+ 여러분이 상상 못 한 입력(빈칸 연타, 소수점, 999…)을 반드시 합니다. 친구가 갸웃하는
+ 지점이 곧 다음 개선 포인트예요. 만든 사람 눈에는 절대 안 보이는 게 남의 손에서는
+ 3초 만에 드러나거든요 — 프로 개발팀이 사용자 테스트를 하는 이유와 정확히 같습니다.
+
+
+
+
+
+ 숨을 고르고 뒤를 돌아봅시다. 오늘 완성한 게임 파일 안에, 초급 과정에서 배운
+ 개념이 전부 살아서 일하고 있어요. 어디에 뭐가 있는지 지도로
+ 한 장 정리하면 이렇습니다.
+
+ {CODE_MAP}
+
+ 그리고 목표 달성 확인. 이 코스의 시작에서 우리는 "처음부터 끝까지 내 손으로 웹 게임
+ 완성"을 약속했어요. 설계(종이 코딩) → 뼈대(HTML) → 두뇌(로직) → 반응(화면) →
+ 테스트 → 다듬기 — 전부 여러분 손으로 지나왔습니다.{' '}
+ "나 코딩 할 수 있네"는 이제 기분이 아니라 증거가 있는 사실이에요.
+ 증거물: game.html.
+
+
+ 졸업 실습: 남은 기회 5번 마지막 도전 과제예요. 기회를{' '}
+ 5번으로 제한해서 긴장감 있는 게임으로 업그레이드해 보세요. 힌트만
+ 드릴게요 — ① 기억할 것이 하나 늘어요(남은 기회 변수, 5에서 시작). ② 틀릴 때마다
+ 1씩 줄이고 결과 문구에 함께 보여 주세요. ③ 조건문으로 "남은 기회가 0이 되면"
+ 게임 오버 메시지와 함께 정답을 공개하세요. 섹션 2의 계획표에 연필로 먼저 추가해
+ 보고 시작하면 훨씬 수월합니다.
+
+
+
+
+
+
🚀 여기까지 왔다면
+
+ 축하해요 — 여러분은 이제 아이디어를 동작하는 웹 게임으로 바꿔 본
+ 사람입니다. 변수로 기억하고, 조건문으로 판단하고, 함수로 묶고, HTML로
+ 보여 주는 감각이 손에 남았을 거예요. 다음 정거장은 지도에 적힌 대로 CSS(제대로
+ 꾸미기), 배열(여러 값을 한 줄에 기억하기), DOM(화면을 자유자재로 다루기)입니다.{' '}
+ 학습 센터로 돌아가 다음 강좌를
+ 골라 보세요. 첫 프로젝트를 끝낸 지금이, 가장 빠르게 성장하는 순간이에요.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv1FunctionsPage.jsx b/frontend/src/pages/levels/Lv1FunctionsPage.jsx
new file mode 100644
index 0000000..da8cbf1
--- /dev/null
+++ b/frontend/src/pages/levels/Lv1FunctionsPage.jsx
@@ -0,0 +1,473 @@
+// 이 파일이 하는 일: "함수: 나만의 명령어 만들기" 코스 — 코딩을 처음 배우는 사람에게
+// '코드 묶음에 이름을 붙여 재사용한다'는 함수의 핵심을 7개 섹션으로 안내하는 정적 학습 페이지.
+// 정의와 호출의 구분 → 매개변수 → return → 작은 함수의 조합 → 스코프 맛보기 순서로 올라가,
+// 마지막 실습(미니 계산기 함수 4종)에서 지금까지의 개념을 전부 한 번에 조립하게 한다.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_REPEAT = `// 함수가 없다면 — 같은 인사를 세 번 하려면 세 번 다 써야 해요
+console.log('안녕하세요!');
+console.log('오늘도 코딩하러 오셨군요.');
+
+console.log('안녕하세요!');
+console.log('오늘도 코딩하러 오셨군요.');
+
+console.log('안녕하세요!');
+console.log('오늘도 코딩하러 오셨군요.');
+
+// 문장을 바꾸고 싶으면? 세 군데를 전부 고쳐야 합니다.
+// 한 군데라도 빼먹으면 인사가 서로 달라져 버려요.`;
+
+const CODE_REPEAT_FN = `// 함수가 있다면 — 코드 묶음에 '이름'을 붙여 두고, 이름만 부르면 끝
+function greet() {
+ console.log('안녕하세요!');
+ console.log('오늘도 코딩하러 오셨군요.');
+}
+
+greet(); // 첫 번째 인사
+greet(); // 두 번째 인사
+greet(); // 세 번째 인사
+
+// 문장을 바꾸고 싶으면? 함수 안 딱 한 군데만 고치면
+// 세 번의 인사가 전부 한꺼번에 바뀝니다.`;
+
+const CODE_DEFINE_CALL = `// ① 정의 — "이런 일을 하는 명령어를 greet라고 부르겠다"고 등록만 한 상태
+function greet() {
+ console.log('안녕하세요!');
+}
+// 여기까지 실행해도 화면엔 아무것도 안 나와요. 아직 '부르지' 않았으니까요.
+
+// ② 호출 — 이름 뒤에 괄호를 붙여야 비로소 실행됩니다
+greet(); // → 안녕하세요!
+greet(); // → 안녕하세요! (몇 번이든 다시 부를 수 있어요)
+
+// 괄호가 핵심이에요.
+greet; // 이름만 적으면? 실행되지 않아요. 리모컨을 들고만 있는 것.
+greet(); // 괄호를 붙여야 실행. 리모컨의 버튼을 '누른' 것.`;
+
+const CODE_PARAMS = `// 매개변수 — 함수에 뚫어 둔 '입력 구멍'
+function greet(name) { // name이 매개변수(재료를 받는 자리)
+ console.log(name + '님, 안녕하세요!');
+}
+
+greet('지민'); // → 지민님, 안녕하세요!
+greet('하늘'); // → 하늘님, 안녕하세요!
+greet('선생님'); // → 선생님님... 아, 이건 좀 어색하네요 :)
+
+// 호출할 때 괄호 안에 넣는 실제 값('지민')을 '인자'라고 불러요.
+// 매개변수 = 구멍의 이름, 인자 = 구멍에 실제로 넣는 재료.`;
+
+const CODE_PARAMS_MANY = `// 매개변수는 쉼표로 여러 개 받을 수 있어요 — 순서가 곧 약속입니다
+function introduce(name, age) {
+ console.log('저는 ' + name + '이고, ' + age + '살입니다.');
+}
+
+introduce('지민', 17); // → 저는 지민이고, 17살입니다.
+introduce(17, '지민'); // → 저는 17이고, 지민살입니다. (순서를 바꾸면 엉망!)
+
+// 첫 번째 인자는 첫 번째 매개변수로, 두 번째는 두 번째로.
+// 함수는 순서만 보고 재료를 나눠 갖기 때문에, 순서가 어긋나면
+// 에러 없이 '이상한 결과'가 나와요. 이런 버그가 제일 찾기 어렵습니다.`;
+
+const CODE_RETURN = `// console.log는 '화면에 보여 주기', return은 '값을 돌려주기' — 완전히 달라요
+function addAndShow(a, b) {
+ console.log(a + b); // 화면에 출력만 하고, 아무것도 돌려주지 않음
+}
+
+function add(a, b) {
+ return a + b; // 계산 결과를 '부른 쪽'에 돌려줌
+}
+
+const shown = addAndShow(3, 4); // 화면엔 7이 찍히지만...
+console.log(shown); // → undefined (돌려받은 게 없어요)
+
+const result = add(3, 4); // 화면엔 아무것도 안 찍히지만...
+console.log(result); // → 7 (값을 돌려받아 변수에 담았어요)
+console.log(result * 10); // → 70 (돌려받은 값은 이어서 쓸 수 있죠!)`;
+
+const CODE_RETURN_STOP = `// return을 만나면 함수는 그 자리에서 '즉시' 끝납니다
+function checkAge(age) {
+ if (age < 0) {
+ return '나이는 음수가 될 수 없어요'; // 여기서 함수 끝!
+ }
+ return age + '살이시군요'; // 위에서 안 끝났을 때만 도착
+}
+
+console.log(checkAge(-5)); // → 나이는 음수가 될 수 없어요
+console.log(checkAge(17)); // → 17살이시군요
+
+function afterReturn() {
+ return '여기까지만 실행돼요';
+ console.log('이 줄은 영원히 실행되지 않습니다'); // 죽은 코드
+}`;
+
+const CODE_COMPOSE = `// 큰 함수 하나보다, 작은 함수 여러 개 — 함수가 함수를 부를 수 있어요
+function toKorean(score) {
+ if (score >= 90) { return '수'; }
+ if (score >= 80) { return '우'; }
+ return '미';
+}
+
+function makeReport(name, score) {
+ const grade = toKorean(score); // 함수 안에서 다른 함수를 호출!
+ return name + ': ' + score + '점 (' + grade + ')';
+}
+
+console.log(makeReport('지민', 95)); // → 지민: 95점 (수)
+console.log(makeReport('하늘', 82)); // → 하늘: 82점 (우)
+
+// makeReport는 '등급 계산법'을 몰라도 돼요. toKorean에게 맡기니까.
+// 등급 기준이 바뀌면? toKorean 한 곳만 고치면 됩니다.`;
+
+const CODE_NAMING = `// 이름만 봐도 하는 일이 보이게 — 함수 이름은 보통 '동사'로 시작해요
+function getTotal(a, b) { return a + b; } // 가져온다(get)
+function showWelcome(name) { alert(name + '님 환영!'); } // 보여준다(show)
+function isAdult(age) { return age >= 20; } // 맞는지 묻는다(is)
+
+// 나쁜 이름의 예 — 코드를 다 읽어야만 무슨 일을 하는지 알 수 있어요
+function a1(x, y) { return x + y; } // a1이 뭘 하는 함수죠...?
+function doStuff(n) { return n >= 20; } // stuff(뭔가)는 이름이 아니에요
+
+// 좋은 이름은 공짜 설명서입니다. 두 달 뒤의 나도, 옆자리 동료도
+// 함수 속을 열어 보지 않고 이름만으로 쓸 수 있게 해 주죠.`;
+
+const CODE_SCOPE = `// 함수 안에서 만든 변수는 밖에서 '안 보여요' — 이 담장을 스코프라고 해요
+function makeSecret() {
+ const secret = '함수 안의 비밀'; // 지역 변수: 이 함수 담장 안에서만 삶
+ console.log(secret); // 담장 안이니까 OK
+}
+
+makeSecret(); // → 함수 안의 비밀
+console.log(secret); // → 에러! secret is not defined (담장 밖에선 안 보임)
+
+// 반대 방향은 보여요 — 담장 안에서 밖(전역)은 내다볼 수 있어요
+const school = '미림'; // 전역 변수: 어디서든 보임
+
+function whereAmI() {
+ console.log(school + '에서 공부 중'); // 밖의 변수를 읽는 건 OK
+}
+whereAmI(); // → 미림에서 공부 중`;
+
+const CODE_CALC = `// 실습: 미니 계산기 — 지금까지 배운 것이 전부 모이는 순간
+// 함수 정의 + 매개변수 + return + 조합 + 조건문, 다 들어 있어요.
+
+// ① 작은 함수 4종 — 하나가 하나의 연산만 책임집니다
+function add(a, b) { return a + b; }
+function subtract(a, b) { return a - b; }
+function multiply(a, b) { return a * b; }
+function divide(a, b) {
+ if (b === 0) { return '0으로는 나눌 수 없어요'; } // 방어부터!
+ return a / b;
+}
+
+// ② 지휘자 함수 — 기호를 보고 알맞은 작은 함수를 골라 부릅니다
+function calculate(a, b, op) {
+ if (op === '+') { return add(a, b); }
+ if (op === '-') { return subtract(a, b); }
+ if (op === '*') { return multiply(a, b); }
+ if (op === '/') { return divide(a, b); }
+ return '모르는 기호예요: ' + op;
+}
+
+// ③ 사용자에게 재료 받기 — prompt는 항상 '문자열'을 주는 걸 잊지 마세요
+const a = Number(prompt('첫 번째 숫자는?'));
+const b = Number(prompt('두 번째 숫자는?'));
+const op = prompt('연산 기호는? (+, -, *, /)');
+
+const result = calculate(a, b, op);
+alert(a + ' ' + op + ' ' + b + ' = ' + result);`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용 — 이것도 '함수'다!
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 지금까지는 코드를 위에서 아래로 한 줄씩 써 왔어요. 그런데 같은 일을 세 번 하려면
+ 같은 코드를 세 번 써야 할까요? 함수는 자주 쓰는 코드 묶음에{' '}
+ 이름을 붙여 두고 언제든 다시 부르는 방법이에요. 이걸 익히는 순간,
+ 코드를 '쓰는' 사람에서 코드를 '조립하는' 사람으로 한 단계 올라섭니다.
+
+
+ 예상 소요 70분
+ 실습: 미니 계산기 함수 만들기
+ 선수 지식: 변수·조건문
+
+
+
+ {/* 섹션 바로가기 */}
+
+
+
+
+ 함수를 한 문장으로 말하면 이래요. "여러 줄의 코드를 하나로 묶고, 그 묶음에
+ 이름을 붙인 것." 전자레인지의 '데우기' 버튼을 떠올려 보세요.
+ 버튼 하나를 누르면 안에서는 마그네트론을 켜고, 접시를 돌리고, 시간을 재는 복잡한
+ 일들이 벌어지죠. 하지만 우리는 그 과정을 몰라도 됩니다. 버튼(이름)만 누르면{' '}
+ 약속된 일이 통째로 일어나니까요.
+
+
먼저, 함수가 없는 세상이 얼마나 고단한지부터 볼게요.
+ {CODE_REPEAT}
+
+ 복사-붙여넣기로 버틸 수는 있어요. 하지만 인사말을 바꾸는 순간 지옥이 시작됩니다.
+ 세 군데, 열 군데, 백 군데를 전부 똑같이 고쳐야 하니까요. 함수는 이
+ 반복을 한 방에 해결합니다.
+
+ {CODE_REPEAT_FN}
+
+ 한 번 만들면 몇 번이든 재사용 — 이게 함수의 첫 번째 선물이에요.
+ 그리고 놀라운 사실 하나. 여러분이 지금까지 써 온{' '}
+ console.log()와 alert(),{' '}
+ prompt()도 전부 함수였습니다. 자바스크립트를
+ 만든 사람들이 "화면에 글자 찍기" 같은 자주 쓰는 일을 미리 함수로 만들어 둔 거예요.
+ 이름 뒤에 괄호가 붙어 있었던 게 바로 그 증거죠.
+
+
+ 관점의 전환 오늘부터 여러분은 남이 만든 명령어를 쓰기만 하는{' '}
+ 사람이 아니라, 나만의 명령어를 만드는 사람이 됩니다.{' '}
+ console.log를 만든 사람들과 같은 도구를 손에 쥐는 거예요.
+
+
+
+
+
+ 함수를 만드는 문법은 딱 네 조각이에요. function 키워드,{' '}
+ 이름, 괄호 ( ), 그리고 코드를 담는{' '}
+ 중괄호. 이렇게 함수를 만드는 걸 정의(definition)라고
+ 부릅니다.
+
+ {CODE_DEFINE_CALL}
+
+ 여기서 초보 때 가장 많이 헷갈리는 지점이 나와요. 함수를{' '}
+ 정의만 해서는 아무 일도 일어나지 않습니다. 정의는 "이런 명령어를
+ 등록해 둘게"라는 예약일 뿐이에요. 실제로 실행하려면 이름 뒤에 괄호를
+ 붙여 호출(call)해야 합니다. 요리로 비유하면 정의는{' '}
+ 레시피를 적어 두는 것이고, 호출은 그 레시피대로{' '}
+ 실제로 요리하는 것이에요. 레시피를 백 장 적어도 요리하지 않으면
+ 아무도 못 먹죠.
+
+
+ 정의와 호출을 구분하는 눈 코드를 읽다가 function{' '}
+ 키워드가 보이면 "아, 여긴 레시피를 적는 곳이구나(아직 실행 아님)",
+ 이름 뒤에 ()가 붙어 있으면 "여기서{' '}
+ 실제로 실행되는구나" — 이 두 가지를 즉시 구분할 수 있으면 이 섹션은
+ 합격입니다. "코드를 썼는데 왜 아무 일도 안 일어나죠?"라는 질문의 답은 십중팔구
+ "호출을 안 하셨어요"거든요.
+
+
+
+
+
+ 섹션 2의 greet()는 항상 똑같은 인사만
+ 해요. 그런데 사람마다 이름을 불러 주며 인사하고 싶다면? 사람 수만큼 함수를 만들 수는
+ 없잖아요. 그래서 함수에는 재료를 받는 입력 구멍을 뚫을 수 있는데,
+ 이 구멍을 매개변수(parameter)라고 부릅니다.
+
+ {CODE_PARAMS}
+
+ 자판기를 떠올려 보세요. 자판기(함수)에는 동전 넣는 구멍(매개변수)이 있고, 우리가
+ 실제로 넣는 500원짜리 동전이 인자(argument)예요. 같은 자판기라도
+ 어떤 동전과 버튼이냐에 따라 다른 음료가 나오듯, 같은 함수라도{' '}
+ 어떤 인자를 넣느냐에 따라 다른 결과가 나옵니다.
+
+
구멍은 쉼표로 구분해서 여러 개 뚫을 수도 있어요. 단, 순서가 중요합니다.
+ {CODE_PARAMS_MANY}
+
+ 용어 정리매개변수는 함수를 정의할 때 괄호 안에 적는{' '}
+ 변수 이름(name,{' '}
+ age), 인자는 호출할 때 실제로 넣는{' '}
+ 값('지민',{' '}
+ 17)이에요. 처음엔 섞어 불러도 괜찮지만, "구멍과 재료는
+ 다르다"는 감각만은 가져가세요.
+
+
+
+
+
+ 지금까지의 함수는 결과를 console.log로{' '}
+ 화면에 찍기만 했어요. 그런데 계산 결과를 받아서 이어서
+ 쓰고 싶다면 어떡할까요? 예를 들어 더한 값에 다시 10을 곱하고 싶다면요.
+ 이때 필요한 것이 return(반환)입니다.
+
+ {CODE_RETURN}
+
+ 이 차이를 심부름으로 비유하면 이래요. console.log만 하는
+ 함수는 심부름꾼이 결과를 소리 내어 외치고 그냥 가 버리는 것 — 듣긴
+ 했지만 손에 쥔 건 없어요. return하는 함수는 결과를{' '}
+ 내 손에 건네주고 가는 것 — 받은 값을 변수에 담아 두고두고 쓸 수
+ 있습니다. 그래서 돌려받은 게 없는 함수의 결과를 변수에 담으면{' '}
+ undefined(정해진 값 없음)가 나오는 거예요.
+
+
+ return에는 성질이 하나 더 있어요.{' '}
+ return을 만나는 순간 함수는 즉시 끝납니다. 그 아래 줄이 몇 줄이든
+ 전부 무시돼요.
+
+ {CODE_RETURN_STOP}
+
+ 즉시 종료를 역이용하기 위 checkAge처럼 "이상한
+ 입력이면 먼저 return으로 내보내고, 정상 입력만 아래로 통과시키는"
+ 패턴을 실무에서 정말 많이 써요. 조건문을 깊게 겹치지 않아도 되니 코드가 계단이 아니라
+ 평지처럼 읽히거든요. 섹션 7의 divide 함수에서 다시
+ 만나게 됩니다.
+
+
+
+
+
+ 함수를 배우면 이런 욕심이 생겨요. "그럼 프로그램 전체를 거대한 함수
+ 하나에 다 넣으면 되겠네?" — 돌아가긴 합니다. 하지만 좋은 개발자는 반대로
+ 갑니다. 큰 함수 하나보다, 한 가지 일만 잘하는 작은 함수 여러 개.{' '}
+ 레고 성을 통짜 플라스틱으로 찍지 않고 작은 블록으로 쌓는 것과 같은 이유예요 —
+ 블록이 작아야 고치기도, 다시 쓰기도 쉬우니까요.
+
+
그리고 함수 안에서는 다른 함수를 부를 수 있습니다. 이게 조립의 핵심이에요.
+ {CODE_COMPOSE}
+
+ makeReport는 등급을 어떻게 매기는지
+ 몰라요. 그 일은 toKorean에게 맡기고, 자기는 문장을
+ 만드는 일만 하죠. 이렇게 일을 나누면 고칠 곳도 하나로 모입니다. 등급
+ 기준이 바뀌어도 toKorean 한 곳만 고치면, 그 함수를 쓰는
+ 모든 곳이 함께 바뀌어요.
+
+
+ 조립이 잘 되려면 조건이 하나 있어요. 이름만 봐도 하는 일을 알 수 있어야{' '}
+ 합니다. 부품 상자에서 이름표 없는 부품을 뒤지는 건 고문이니까요.
+
+ {CODE_NAMING}
+
+ 이름 짓기 요령 함수는 '일'을 하니까 이름이 보통 동사로 시작해요.
+ 값을 돌려주면 get~, 화면에 보여 주면{' '}
+ show~, 참/거짓을 돌려주면{' '}
+ is~. 이 세 가지 접두어만 익혀도 여러분의 코드는 남이
+ 읽을 수 있는 코드가 됩니다.
+
+
+
+
+
+ 함수를 쓰다 보면 곧 이런 에러를 만나요. "함수 안에서 만든 변수를 밖에서 썼더니{' '}
+ not defined래요!" — 고장이 아니라 규칙입니다.
+ 변수에게는 자기가 사는 동네가 있는데, 이 동네의 경계를{' '}
+ 스코프(scope)라고 불러요.
+
+ {CODE_SCOPE}
+
+ 규칙은 두 줄로 요약돼요. ① 함수 안(지역)에서 만든 변수는 그 함수 밖에서
+ 안 보인다. ② 함수 밖(전역)에서 만든 변수는 함수 안에서도 보인다. 담장 안의
+ 사람은 담장 밖 풍경을 내다볼 수 있지만, 밖에서는 담장 안이 안 보이는 것과 같아요.
+ 그래서 함수 밖 세상을 전역(global), 함수 담장 안을{' '}
+ 지역(local)이라고 부릅니다.
+
+
+ "왜 이런 벽을 만들었을까?" 불편해 보이지만 사실은 보호막이에요.
+ 함수마다 담장이 있으니, 내 함수 안의 result와 남의 함수
+ 안의 result가 이름이 같아도 서로 안 부딪혀요.{' '}
+ 담장이 없다면 프로그램 전체에서 변수 이름이 절대 겹치지 않게 관리해야 할 텐데, 함수가
+ 수백 개쯤 되면 그건 불가능하죠.
+
+
+ 오늘은 여기까지만 스코프는 파고들면 꽤 깊은 주제예요(블록 스코프, 클로저...).
+ 지금은 "함수 안에서 만든 건 밖에서 안 보인다" 하나만 몸에 새기면
+ 충분합니다. 이 감각 하나가 앞으로 만날 수많은{' '}
+ not defined 에러의 절반을 설명해 줘요.
+
+
+
+
+
+ 이제 배운 것을 전부 조립할 시간이에요. 목표는{' '}
+ 숫자 2개와 연산 기호를 받아 결과를 돌려주는 계산기. 더하기·빼기·
+ 곱하기·나누기라는 작은 함수 4개를 먼저 만들고, 조건문으로 알맞은
+ 함수를 골라 부르는 지휘자 함수 하나로 묶습니다.
+
+ {CODE_CALC}
+
+ 코드를 뜯어보면 이번 코스의 모든 조각이 보여요. ① 정의와 호출 —
+ 작은 함수 4개를 정의해 두고 calculate가 호출하죠.{' '}
+ ② 매개변수 — a, b, op 세 개의 구멍으로
+ 재료를 받고요. ③ return — 모든 함수가 결과를 돌려주니 변수에 담아
+ 이어서 쓸 수 있어요. ④ 조합 — 지휘자가 작은 함수들을 부리고,{' '}
+ ⑤ 즉시 종료 — divide는 0으로 나누는
+ 이상한 입력을 return으로 먼저 내보냅니다.
+
+
+ 한 가지 복병이 있어요. prompt()는 사용자가 무엇을
+ 입력하든 문자열을 돌려줍니다. 그대로 더하면{' '}
+ '3' + '4'는 '34'가 돼
+ 버려요. 그래서 Number()라는 (이것도 함수!) 변환기를
+ 한 번 거쳐 진짜 숫자로 바꾼 다음 계산에 넣습니다.
+
+
+ 직접 해 보기 브라우저에서 F12를 눌러 콘솔을 열고
+ 위 코드를 붙여 넣어 보세요. 잘 돌아가면 도전 과제 세 개 — ① 나머지(
+ %) 연산 함수를 추가해 보기, ② 연산 기호를 잘못 넣었을
+ 때의 안내 문구를 더 친절하게 바꿔 보기, ③ isNaN()으로
+ 숫자가 아닌 입력을 걸러내 보기. ③까지 하면 여러분의 계산기는 어지간한 장난에도
+ 안 무너지는, 꽤 단단한 프로그램이 됩니다.
+
+
+
+
+
+
🧰 여기까지 왔다면
+
+ 이제 여러분은 나만의 명령어를 만드는 사람이에요. 코드 묶음에 이름을
+ 붙이고(정의), 필요할 때 부르고(호출), 재료를 넣고(매개변수), 결과를 돌려받고(return),
+ 작은 함수들을 조립해 계산기까지 완성했습니다. console.log가
+ 사실 함수였다는 걸 아는 사람과 모르는 사람의 코드는 이제부터 점점 달라져요. 그런데
+ 계산기를 만들며 빨간 에러 글씨를 몇 번 만나지 않으셨나요? 그 빨간 글씨는 사실 적이
+ 아니라 가장 친절한 힌트입니다. 다음{' '}
+ 에러와 친해지기: 빨간 글씨 해독법{' '}
+ 코스에서, 에러 메시지를 읽고 스스로 고치는 법을 이어서 익혀 봅시다.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv1HowBrowsersWorkPage.jsx b/frontend/src/pages/levels/Lv1HowBrowsersWorkPage.jsx
new file mode 100644
index 0000000..0416f86
--- /dev/null
+++ b/frontend/src/pages/levels/Lv1HowBrowsersWorkPage.jsx
@@ -0,0 +1,373 @@
+// 이 파일이 하는 일: "브라우저는 내 코드를 어떻게 읽을까" 코스 — 주소창에 엔터를 치는
+// 순간부터 화면이 그려지기까지를 7개 섹션으로 안내하는 정적 학습 페이지. 대상은 코딩을
+// 한 번도 안 해 본 사람(중·고등학생 눈높이). 프레이밍은 "웹의 뒷면 구경" — 개발자 도구(F12)를
+// 열어 요청↔응답, HTML·CSS·JS의 역할 분담, 그려지는 순서를 눈으로 직접 확인시킨다.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_CONVERSATION = `나(브라우저) 서버(멀리 있는 컴퓨터)
+ │ │
+ │ "www.naver.com 첫 화면 주세요!" │
+ │ ──────────── 요청(Request) ────────────▶ │
+ │ │
+ │ "네, 여기 HTML 문서요." │
+ │ ◀─────────── 응답(Response) ──────────── │
+ │ │
+받아 온 HTML을 읽고 화면에 그림을 그린다
+= 이것이 브라우저의 일 (렌더링, rendering)
+
+핵심: 웹은 '요청하고 → 응답받는' 대화예요.
+주소창에 엔터를 치는 순간, 이 대화가 시작됩니다.`;
+
+const CODE_THREE_LANGS = `브라우저가 알아듣는 세 가지 언어 — 역할이 딱 나뉘어 있어요
+
+HTML → 뼈대(구조) "여기가 제목, 저기가 문단, 그 아래는 버튼"
+CSS → 옷(모양) "제목은 크게, 버튼은 파란색, 배경은 어둡게"
+JavaScript → 움직임(동작) "버튼을 누르면 인사말을 띄워라"
+
+사람으로 치면:
+HTML = 골격, CSS = 옷차림, JavaScript = 근육(움직임)
+셋 중 하나만 있어도 페이지는 뜨지만, 셋이 모여야 '살아 있는' 웹이 됩니다.`;
+
+const CODE_ELEMENTS = `
+
+ ...제목·CSS 연결 같은 준비물...
+
+
오늘의 뉴스
← 이 글자를 더블클릭하면 바꿀 수 있어요!
+
기사 내용이 여기에...
+
+
+
+
+`;
+
+const CODE_CONSOLE = `// Console 탭에 직접 입력해 보세요 (> 뒤가 내가 치는 것)
+
+> 2 + 3
+5 // 브라우저가 즉시 계산해서 대답!
+
+> "안녕" + "하세요"
+'안녕하세요' // 글자도 붙일 수 있어요
+
+> 우와아아
+Uncaught ReferenceError: 우와아아 is not defined
+// ↑ "우와아아가 뭔지 모르겠어요"라는 브라우저의 대답.
+// 빨간 글씨 = 에러. 무섭지 않아요. '어디가 왜 안 되는지'를
+// 알려 주는 친절한 쪽지이고, 앞으로 여기서 자주 만나게 됩니다.`;
+
+const CODE_ORDER = `
+
첫 번째 제목
+
두 번째 문단
+
+
+
+
+`;
+
+const CODE_FILE_VS_HTTPS = `file:///C:/Users/me/my-page.html https://awesomedev.dev
+────────────────────────────── ──────────────────────────────
+내 컴퓨터의 파일을 '직접' 연 것 멀리 있는 서버가 '보내 준' 것
+이 컴퓨터 앞에 앉은 나만 본다 주소만 알면 전 세계 누구나 본다
+인터넷이 없어도 열린다 인터넷이 있어야 한다
+저장 → 새로고침이면 바로 반영 서버에 올리는 '배포'가 필요
+
+둘 다 똑같은 브라우저, 똑같은 HTML.
+다른 건 딱 하나 — 문서가 '어디에서 왔는가'입니다.`;
+
+const CODE_PRACTICE = `탐험 실습 체크리스트 — "포털 메인 제목을 내 이름으로"
+
+1. 좋아하는 사이트(포털 메인 등)를 연다
+2. F12 를 눌러 개발자 도구를 연다 → Elements 탭
+3. 왼쪽 위 화살표 아이콘(또는 Ctrl+Shift+C)을 켜고
+ 화면에서 바꾸고 싶은 제목 글자를 클릭 → 해당 태그로 점프!
+4. 그 태그의 글자 부분을 더블클릭 → 내 이름으로 고쳐 쓴다
+5. 스크린샷을 찍는다 (Windows: Win+Shift+S)
+6. F5 새로고침 → 원래대로 돌아오는 것까지 확인!
+ (왜 돌아오는지 설명할 수 있다면 이 코스는 합격입니다)`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 주소창에 엔터를 치면 무슨 일이 벌어질까요? 우리가 매일 쓰는 브라우저는 사실{' '}
+ 코드를 읽어서 그림을 그려 주는 기계예요. 이 코스에선{' '}
+ 개발자 도구(F12)라는 만능 열쇠를 손에 쥐고, 매일 보던 웹의{' '}
+ 뒷면을 구경합니다. 어렵지 않아요 — 구경이 곧 공부니까요.
+
+
+ 예상 소요 50분
+ 실습: 포털 제목을 내 이름으로
+ 선수 지식: 없음 (첫걸음 환영)
+
+
+
+ {/* 섹션 바로가기 */}
+
+
+
+
+ www.naver.com을 치고 엔터를 누르는 순간, 여러분의
+ 브라우저는 멀리 있는 어떤 컴퓨터에게 말을 겁니다. "네이버 첫 화면
+ 주세요!" — 이걸 요청(Request)이라고 해요. 그러면 그 컴퓨터가
+ "네, 여기요" 하고 문서를 보내 주죠. 이게 응답(Response)입니다.
+
+
+ 여기서 그 '멀리 있는 컴퓨터'를 서버(server)라고 불러요. 대단한 마법의
+ 상자가 아니라, 남의 컴퓨터입니다. 24시간 켜 놓고 "요청이 오면
+ 응답한다"는 일만 하도록 세팅해 둔 컴퓨터예요. 여러분이 보는 모든 웹페이지는
+ 어딘가의 서버가 보내 준 것입니다.
+
+ {CODE_CONVERSATION}
+
+ 그런데 서버가 보내 주는 건 완성된 '그림'이 아니라 HTML이라는 글자
+ 문서예요. 그 문서를 읽고, 해석하고, 우리 눈에 보이는 화면으로{' '}
+ 그려 내는 일 — 그게 바로 브라우저의 직업입니다. 크롬, 엣지, 사파리는
+ 전부 "HTML을 그림으로 바꿔 주는 기계"인 셈이죠.
+
+
+ 비유 배달 앱과 비슷해요. 내가 주문(요청)하면 → 식당(서버)이 음식을 보내고
+ (응답) → 나는 포장을 뜯어 상을 차립니다(렌더링). 브라우저는 '주문도 하고 상도
+ 차려 주는' 성실한 심부름꾼이에요. 이 요청↔응답 구조 하나만 기억해도
+ 웹의 절반을 이해한 겁니다.
+
+
+
+
+
+ 브라우저가 알아듣는 언어는 크게 세 가지예요. 셋이 하는 일이 딱딱
+ 나뉘어 있어서, 역할만 기억하면 절대 헷갈리지 않습니다.
+
+ {CODE_THREE_LANGS}
+
+ HTML은 "여기가 제목이고, 저기가 문단이고, 그 아래가 버튼"이라고{' '}
+ 구조를 정하는 뼈대예요. 우리가 지금까지 배워 온 바로 그 언어죠.
+ 그런데 뼈대만 있으면 모든 페이지가 흰 바탕에 검은 글씨일 거예요. 그래서{' '}
+ CSS가 "제목은 크게, 버튼은 파랗게" 하고 옷을
+ 입힙니다. 마지막으로 JavaScript가 "버튼을 누르면 이걸 해라" 하고{' '}
+ 움직임을 붙여요.
+
+
+ 중요한 건 순서예요. 옷(CSS)은 뼈대(HTML)가 있어야 입힐 수 있고,
+ 움직임(JavaScript)도 움직일 대상이 있어야 하죠. 그래서 모든 웹 공부는 HTML에서
+ 시작합니다 — 여러분은 이미 올바른 순서로 첫 단추를 끼운 거예요.
+
+
+ 지금 나는 어디쯤? HTML을 배웠다면 뼈대 만들기까지 온 거예요.
+ 이 코스에서 브라우저가 그 뼈대를 어떻게 읽는지 구경하고, 다음 코스에서 드디어
+ 움직임(JavaScript)을 배웁니다. 지도에서 내 위치를 아는 것, 그게 길을 안 잃는
+ 비결이에요.
+
+
+
+
+
+ 놀라운 사실 하나. 여러분이 보는 모든 웹사이트의 코드는 공개되어 있어요.{' '}
+ 섹션 1에서 봤듯 서버가 HTML 문서를 통째로 보내 주니까, 받은 사람(브라우저)은 당연히
+ 그 내용을 전부 갖고 있죠. 그걸 들여다보는 창이 바로 개발자 도구이고,
+ 여는 열쇠가 F12입니다(맥은{' '}
+ Cmd+Option+I).
+
+
+ 지금 바로 눌러 보세요. 화면 옆이나 아래에 낯선 패널이 열리면 성공이에요. 그중{' '}
+ Elements 탭이 "지금 이 페이지의 HTML 구조"를 보여 주는 곳입니다.
+
+ {CODE_ELEMENTS}
+
+ Elements 탭에서 태그를 더블클릭하면 글자를 고칠 수도 있어요. 뉴스
+ 제목을 "내가 세계를 정복했다"로 바꾸는 것도 가능하죠. 그런데{' '}
+ F5로 새로고침하면? 전부 원래대로{' '}
+ 돌아옵니다.
+
+
+ 왜일까요? 여러분이 고친 건 서버의 원본이 아니라, 내 브라우저에 도착한
+ 복사본이기 때문이에요. 도서관 책을 복사해 와서 복사본에 낙서한 것과 같아요.
+ 도서관의 원본은 멀쩡하고, 새로고침은 "복사본 버리고 원본 다시 받아 오기"라서 낙서가
+ 사라지는 겁니다. 요청↔응답 구조를 이해하면 이 현상이 당연해져요.
+
+
+ 안심하세요 개발자 도구로 아무리 만져도 그 사이트가 망가지지
+ 않아요. 바뀌는 건 오직 내 화면의 복사본뿐입니다. 그러니 마음껏 눌러 보고,
+ 펼쳐 보고, 고쳐 보세요 — 개발자 도구는 세상에서 가장 안전한 놀이터예요.
+
+
+
+
+
+ 개발자 도구에는 Elements 말고도 탭이 여럿 있는데, 그중 앞으로 가장 자주 쓰게 될 곳이{' '}
+ Console(콘솔) 탭이에요. 콘솔은 한마디로{' '}
+ 브라우저와 나누는 채팅창입니다. 내가 뭔가 입력하면, 브라우저가 그
+ 자리에서 실행하고 결과를 대답해 줘요.
+
+ {CODE_CONSOLE}
+
+ 2 + 3을 치고 엔터를 눌러 보세요. 브라우저가 즉시{' '}
+ 5라고 대답합니다. 방금 여러분은{' '}
+ 컴퓨터에게 처음으로 명령을 내린 거예요. 계산기 앱을 켠 게 아니라,
+ 브라우저 속 JavaScript 엔진이 여러분의 명령을 읽고 실행한 겁니다.
+
+
+ 그리고 콘솔은 에러가 표시되는 곳이기도 해요. 페이지의 JavaScript가
+ 어딘가에서 넘어지면, 빨간 글씨로 "몇 번째 줄에서 왜 넘어졌는지"가 여기 찍힙니다.
+ 나중에 "코드가 왜 안 되지?" 싶을 때 가장 먼저 열어 볼 곳이 바로 콘솔이에요.
+
+
+ 에러는 친구예요 빨간 글씨를 보면 "내가 뭘 잘못했나" 하고 움츠러들기 쉬운데,
+ 거꾸로예요. 에러 메시지는 "어디가, 왜" 안 되는지 알려 주는 힌트 쪽지
+ 입니다. 진짜 무서운 건 에러 없이 조용히 이상하게 도는 코드예요. 에러를 읽는 습관이
+ 곧 개발 실력입니다.
+
+
+
+
+
+ 브라우저가 HTML 문서를 받으면 어떻게 그릴까요? 통째로 한 번에? 아니에요.{' '}
+ 맨 위에서부터 한 줄씩 읽으면서, 읽는 족족 그립니다. 책을 읽듯
+ 위에서 아래로요. 그래서 HTML에서 먼저 쓴 것이 먼저 화면에 나타나요.
+
+ {CODE_ORDER}
+
+ 이 '위에서 아래로' 규칙 때문에 <script>(JavaScript를
+ 담는 태그)의 위치가 중요해집니다. 예제처럼 script를 body의 맨 아래에
+ 두면, 실행되는 시점엔 위의 요소들이 이미 다 그려져 있어서 안심하고
+ 찾아 쓸 수 있어요. 반대로 맨 위에 두면 "아직 안 그려진 것"을 찾다가 허탕을 칩니다.
+
+
+ 지금은 "script는 아래에 두는구나" 정도만 챙겨 가면 충분해요. 핵심은 이거예요 —{' '}
+ 컴퓨터는 순서대로 일한다. HTML을 그리는 것도, 나중에 배울 JavaScript
+ 코드가 실행되는 것도, 전부 위에서 아래로, 순서대로입니다. 이 감각
+ 하나가 앞으로 배울 모든 것의 바탕이 돼요.
+
+
+ 눈으로 확인하기 아주 긴 페이지를 느린 인터넷에서 열면, 위쪽부터 차례로
+ 나타나는 걸 볼 수 있어요. 개발자 도구의 Network 탭에서 속도를 일부러 늦추는
+ 기능(Slow 4G)으로 실험해 볼 수도 있습니다 — 브라우저가 '읽으면서 그린다'는 게
+ 실감 날 거예요.
+
+
+
+
+
+ HTML 파일을 만들어 더블클릭으로 열어 봤다면, 주소창이{' '}
+ file:///C:/...처럼 시작하는 걸 봤을 거예요. 반면
+ 네이버는 https://로 시작하죠. 이 앞부분은 "문서를{' '}
+ 어디에서 가져올까"를 뜻합니다.
+
+ {CODE_FILE_VS_HTTPS}
+
+ file://은 내 컴퓨터 안의 파일을 직접
+ 여는 거예요. 서버도, 인터넷도 필요 없죠. 대신 이 컴퓨터 앞에 앉은 나만{' '}
+ 볼 수 있습니다. 친구에게 "내 페이지 봐!"라고 링크를 보내도, 친구 컴퓨터엔 그 파일이
+ 없으니 열리지 않아요.
+
+
+ 내가 만든 페이지를 남에게 보여 주려면, 섹션 1에서 배운 그 구조가
+ 필요합니다 — 누군가 요청하면 응답해 줄 서버에 내 파일을 올려 두는
+ 거죠. 이 과정을 배포(deploy)라고 불러요. "내 컴퓨터의 습작"이
+ "전 세계가 볼 수 있는 사이트"로 바뀌는 순간입니다.
+
+
+ 미리 걱정하지 마세요 배포는 나중에 배울 즐거운 숙제로
+ 남겨 둡시다. 지금 단계에선 file://로 내 파일을 열어
+ 연습하는 것으로 충분해요. "내 것(file://)과 세상의 것(https://)은 문서의 출발지가
+ 다르다" — 이 구분만 챙기면 오늘의 목표는 달성입니다.
+
+
+
+
+
+ 이제 배운 걸 전부 꺼내 쓸 시간이에요. 오늘의 미션:{' '}
+ 좋아하는 사이트의 메인 제목을 개발자 도구로 내 이름으로 바꾸고, 스크린샷을
+ 찍어 오세요.
+
+ {CODE_PRACTICE}
+
+ 다 했다면 스스로에게 물어보세요 — 새로고침하니까 왜 원래대로
+ 돌아왔지? "내가 고친 건 서버의 원본이 아니라 내 브라우저의 복사본이고,
+ 새로고침은 원본을 다시 받아 오는 것"이라고 답할 수 있다면, 오늘 배운 요청↔응답
+ 구조가 완전히 여러분 것이 된 겁니다.
+
+
+ 그리고 한 가지 더. 미션이 끝나도 개발자 도구를 닫지 말고, 그 사이트의 HTML을{' '}
+ 천천히 구경해 보세요. 제목엔 어떤 태그를 썼는지, 메뉴는 어떻게
+ 쌓여 있는지. 잘 만든 사이트의 코드를 구경하는 건, 잘 쓴 글을 필사하는 것과 같은{' '}
+ 최고의 공부예요. 프로 개발자들도 매일 이렇게 남의 코드를 봅니다.
+
+
+ 오늘의 진짜 수확 제목을 바꾸는 기술이 아니에요.{' '}
+ "웹은 마법이 아니라 코드구나"라는 확신입니다. 매일 쓰던 사이트가
+ 전부 사람이 쓴 HTML·CSS·JS이고, 나도 그걸 열어 볼 수 있고, 언젠가 만들 수도 있다 —
+ 이 감각을 손에 쥐었다면 여러분은 이미 개발자의 문 앞에 서 있는 거예요.
+
+
+
+
+
+
🔍 여기까지 왔다면
+
+ 이제 주소창의 엔터 한 번 뒤에 숨은 요청↔응답의 대화를 알고,
+ HTML(뼈대)·CSS(옷)·JavaScript(움직임)의 역할을 구분할 수 있고,{' '}
+ F12라는 열쇠로 어떤 사이트든 뒷면을 열어 볼 수 있게 됐어요.
+ 콘솔에서 2 + 3을 계산시키며 컴퓨터에게 첫 명령도 내려 봤고요.
+ 그런데 그 콘솔 창, 계산기로만 쓰기엔 아깝지 않나요? 다음{' '}
+ 나의 첫 JavaScript{' '}
+ 코스에서, 브라우저에게 본격적으로 말을 거는 법 — 변수, 조건, 반복 — 을 배우며
+ '움직임'의 세계로 들어갑니다.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv1HtmlLinksImagesListsPage.jsx b/frontend/src/pages/levels/Lv1HtmlLinksImagesListsPage.jsx
new file mode 100644
index 0000000..84200dc
--- /dev/null
+++ b/frontend/src/pages/levels/Lv1HtmlLinksImagesListsPage.jsx
@@ -0,0 +1,475 @@
+// 이 파일이 하는 일: "태그로 페이지에 살 붙이기" 코스 — 글자만 있던 HTML 문서에
+// 링크·이미지·목록·표·구역(div)·간단한 스타일을 얹어 '진짜 웹페이지'의 모양을 갖추게 하는
+// 초급 정적 학습 페이지. 대상은 코딩을 처음 접하는 중·고등학생 — 앞 강좌에서 배운
+// "태그로 문서를 만든다"에서 출발해, 속성(attribute)이라는 새 개념을 이번에 처음 소개한다.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_LINK_BASIC = `
+구글로 가기
+
+`;
+
+const CODE_ATTRIBUTE = `
+<태그이름 속성이름="값">내용태그이름>
+
+
+새 탭으로 구글`;
+
+const CODE_TARGET_BLANK = `
+유튜브 (지금 탭에서 열림)
+유튜브 (새 탭에서 열림)
+
+`;
+
+const CODE_IMG_BASIC = `
+
+
+`;
+
+const CODE_IMG_PATH = `
+
+
+
+
+
+
+
+
+`;
+
+const CODE_IMG_DEBUG = `이미지가 안 보일 때 점검 순서 (위에서부터!)
+
+① 파일 이름 오타? cat.jpg 인데 Cat.jpg 라고 썼나?
+ (대소문자·확장자 .jpg/.png 까지 정확히)
+② 파일 위치가 맞나? HTML 파일과 같은 폴더에 있나?
+ 폴더 안이면 src에 "폴더이름/" 을 붙였나?
+③ 따옴표·등호 빠짐? src="cat.jpg" 모양이 온전한가?
+④ alt는 나오나? 깨진 그림 자리에 alt 글자가 보이면
+ → 태그는 맞고 '주소'가 틀린 것!`;
+
+const CODE_UL = `
+
+
떡볶이
+
마라탕
+
탕후루
+
+
+`;
+
+const CODE_OL = `
+
+
물을 끓인다
+
면과 스프를 넣는다
+
3분 기다린다
+
+
+`;
+
+const CODE_NESTED = `
+
+
좋아하는 게임
+
+
마인크래프트
+
발로란트
+
+
+
좋아하는 음식
+
+
떡볶이
+
+
+
+
+`;
+
+const CODE_TABLE = `
+
+
+
요일
+
월
+
화
+
+
+
1교시
+
수학
+
국어
+
+
+
2교시
+
영어
+
체육
+
+
`;
+
+const CODE_TABLE_THINK = `표를 만들기 전에 '행과 열'로 먼저 생각해 보기
+
+ │ 월 화 수 ← 열(세로줄): 요일
+────────┼─────────────────
+ 1교시 │ 수학 국어 과학
+ 2교시 │ 영어 체육 음악
+ ↑
+ 행(가로줄): 교시
+
+코드로 옮길 땐 "가로 한 줄 = tr 하나"만 기억하면 됩니다.
+1행(요일 줄) → 첫 번째 tr, 2행(1교시 줄) → 두 번째 tr...
+줄마다 칸 개수가 같아야 표가 반듯하게 나와요!`;
+
+const CODE_DIV = `
+
+
+`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 지난 시간에 만든 페이지, 솔직히 좀 밋밋했죠? 글자만 있었으니까요. 오늘은 거기에{' '}
+ 링크·이미지·목록·표를 하나씩 얹어서, 여러분이 매일 보는{' '}
+ '진짜 웹페이지'의 모양을 갖춰 봅니다. 마지막엔 내 최애를 소개하는
+ 페이지를 직접 완성해서 친구에게 자랑할 거예요.
+
+
+ 예상 소요 60분
+ 실습: '내 최애 소개' 페이지 완성
+ 선수 지식: 태그가 뭔지 정도면 충분
+
+
+
+
+
+ 웹(web)은 '거미줄'이라는 뜻이에요. 왜 거미줄이냐면, 전 세계 페이지들이{' '}
+ 링크로 서로 이어져 있어서죠. 파란 글씨를 누르면 다른 페이지로 휙 —
+ 이 당연한 동작이 없으면 웹은 그냥 문서 더미일 뿐입니다. 링크를 만드는 태그가{' '}
+ <a>예요.
+
+ {CODE_LINK_BASIC}
+
+ 그런데 여기서 처음 보는 모양이 하나 등장했어요.{' '}
+ href="..." — 태그 이름 옆에 붙은 이{' '}
+ 이름="값" 짝을 속성(attribute)이라고
+ 부릅니다. 태그가 "나 링크야"라고 종류를 말한다면, 속성은 "그래서{' '}
+ 어디로 가는 링크인데?"라는 세부 옵션을 적는 칸이에요.
+
+ {/* 학습 포인트: 속성은 이 강좌 전체를 관통하는 개념 — src, alt, target, style 모두 속성이다 */}
+ {CODE_ATTRIBUTE}
+
+ 속성은 오늘 계속 나올 거예요. 이미지의 src, 새 탭의{' '}
+ target, 스타일의 style —
+ 전부 속성입니다. 지금 이 이름="값" 모양에 익숙해지면
+ 오늘 수업의 절반은 끝난 거나 다름없어요.
+
+ {CODE_TARGET_BLANK}
+
+ 비유 태그가 가전제품이라면 속성은{' '}
+ 다이얼과 버튼이에요. 선풍기(태그)는 그대로인데 바람 세기 다이얼(속성)을
+ 어떻게 돌리느냐에 따라 동작이 달라지죠. <a>라는
+ 제품에 href 다이얼로 목적지를,{' '}
+ target 다이얼로 여는 방식을 정하는 겁니다.
+
+
+
+
+
+ 글자만 있는 페이지에 사진 한 장이 들어가는 순간, 분위기가 완전히 달라져요. 이미지를
+ 넣는 태그는 <img> — 그리고 방금 배운{' '}
+ 속성이 바로 활약합니다.
+
+ {CODE_IMG_BASIC}
+
+ alt는 "어차피 안 보이는데 대충 써도 되지 않나?" 싶지만,
+ 생각보다 중요해요. 이미지 주소가 틀렸을 때 대신 표시되고, 눈이 불편해
+ 화면 낭독기를 쓰는 사람에겐 그 이미지의 전부가 됩니다. "사진1" 말고
+ "창밖을 보는 고양이"처럼, 그림을 말로 옮긴다는 느낌으로 적어 주세요.
+
+
+ 그런데 src에 적는 주소, 즉 경로(path)에는
+ 두 종류가 있어요. 여기서 많이들 헷갈리니 천천히 봅시다.
+
+ {CODE_IMG_PATH}
+
+ 연습할 땐 이미지 파일을 HTML 파일과 같은 폴더에 두고 상대 경로로 시작하는
+ 게 제일 쉬워요. 그리고 이미지가 안 보이는 날은 반드시 옵니다(모두가 겪어요!). 그럴 때
+ 당황하지 말고 이 순서로 점검하세요.
+
+ {/* 학습 포인트: '안 될 때 점검 순서'를 미리 손에 쥐여 주면 좌절 대신 디버깅을 배운다 */}
+ {CODE_IMG_DEBUG}
+
+ 직접 확인해 보기 일부러 src를{' '}
+ "cat123.jpg"처럼 틀리게 적고 저장해 보세요. 깨진 그림
+ 아이콘과 함께 alt에 적은 글자가 나타날 거예요. '틀리면
+ 어떻게 되는지'를 미리 봐 두면, 진짜 문제가 생겼을 때 하나도 안 무섭습니다.
+
+
+
+
+
+ "좋아하는 음식: 떡볶이, 마라탕, 탕후루" — 이렇게 한 줄에 욱여넣는 것보다, 항목을
+ 하나씩 줄 세우면 훨씬 읽기 좋죠. 그게 목록(list)이고, HTML엔 목록
+ 태그가 두 종류 있습니다. 차이는 딱 하나 — 순서가 중요한가?
+
+ {CODE_UL}
+ {CODE_OL}
+
+ 이름을 풀면 외우기 쉬워요. ul ={' '}
+ unordered list(순서 없는 목록, ● 점),{' '}
+ ol = ordered list(순서
+ 있는 목록, 1. 2. 3. 번호). 그리고 둘 다 안에 들어가는 항목은 똑같이{' '}
+ li(list item)로 적습니다.
+ 바구니(ul/ol)만 다르고
+ 내용물(li)은 같은 셈이죠.
+
+
+ 한 발 더 — 목록 안에 목록도 넣을 수 있어요. 분류 안에 세부 항목이
+ 있을 때 쓰는 중첩(nesting)이라는 기술입니다. 맛보기만 해 볼게요.
+
+ {CODE_NESTED}
+
+ 헷갈릴 땐 이 질문 "항목 순서를 뒤섞어도 뜻이 통하나?" — 통하면{' '}
+ ul(좋아하는 음식은 순서 바꿔도 그만), 안 통하면{' '}
+ ol(라면 끓이는 순서를 뒤섞으면 큰일). 이 질문 하나로
+ 평생 안 헷갈립니다.
+
+
+
+
+
+ 시간표, 급식 메뉴, 게임 전적 — 가로세로 칸으로 정리해야 보기 좋은 정보가
+ 있어요. 그럴 때 쓰는 게 표(table)입니다. 표는 태그 네 개가{' '}
+ 역할을 나눠 만들어요. 한꺼번에 보면 복잡하니, 역할부터 정리할게요.
+
+
+ table은 표 전체를 감싸는 틀,{' '}
+ tr(table row)은 가로 한 줄(행),{' '}
+ td(table data)는 줄 안의 칸 하나,{' '}
+ th(table header)는 칸 중에서도{' '}
+ 제목 역할을 하는 칸(자동으로 굵게 나와요)입니다.
+
+ {CODE_TABLE}
+
+ 표를 코드로 짤 때 요령은, 코드부터 치지 말고 종이에 행과 열을 먼저 그려 보는{' '}
+ 거예요. 그다음 "가로 한 줄 = tr 하나"라는 규칙으로
+ 위에서부터 한 줄씩 옮기면 됩니다.
+
+ {/* 학습 포인트: '행 단위로 옮긴다'는 사고 절차를 주면 중첩 태그가 외울 것에서 그릴 것으로 바뀐다 */}
+ {CODE_TABLE_THINK}
+
+ 미니 도전 위 시간표 코드에 '수요일' 열을 추가해 보세요. 어디를
+ 고쳐야 할까요? — 모든 tr마다 칸을 하나씩(첫 줄엔{' '}
+ th, 나머지 줄엔 td) 더하면
+ 됩니다. "열 하나 = 모든 행에 칸 하나씩"이라는 감각, 이게 행과 열로 생각하는 연습이에요.
+
+
+
+
+
+ 페이지에 내용이 늘어나면 슬슬 어수선해져요. 자기소개 따로, 좋아하는 것 따로, 연락처
+ 따로 — 구역을 나누고 싶어지는 순간이 옵니다. 그때 쓰는 게{' '}
+ <div>(division, 구역)예요. 관련 있는 내용물을
+ 한데 담는 투명한 상자라고 생각하면 됩니다.
+
+ {CODE_DIV}
+
+ 코드를 저장하고 열어 보면... 아무것도 안 달라져 보여요. 실망하지
+ 마세요, 그게 정상입니다! div는 화면에 뭘 그리는 태그가
+ 아니라 묶는 태그거든요. 이사할 때 물건을 상자에 담아 두면 옮기기도
+ 좋고 라벨 붙이기도 좋듯이, 내용을 상자에 담아 두면 나중에 꾸미고 배치할 때{' '}
+ 상자째로 다룰 수 있어요. 실제로 여러분이 보는 모든 웹사이트는 수십, 수백 개의{' '}
+ div 상자로 짜여 있습니다.
+
+
+ 페이지를 블록으로 보는 눈 지금 자주 가는 사이트를 하나 떠올려 보세요. 위엔
+ 로고와 메뉴, 가운데엔 본문, 아래엔 회사 정보... 전부 네모난 블록으로
+ 나눠져 있죠? 웹 개발자는 페이지를 글자가 아니라 블록 덩어리로 봅니다.
+ 오늘부터 사이트를 볼 때 "여긴 상자가 몇 개일까?" 하고 눈으로 구역을 그려 보세요 —
+ 공짜로 하는 최고의 연습이에요.
+
+
+
+
+
+ 여기까지 만든 페이지, 내용은 갖췄는데 전부 검은 글씨에 흰 배경이라
+ 심심하죠. 꾸미기의 세계를 살짝만 맛봅시다. 태그에 style{' '}
+ 속성(또 속성이에요!)을 달면 그 태그의 생김새를 바꿀 수 있어요.
+
+ {CODE_STYLE}
+
+ 모양은 꾸밀항목: 값; — 여기서{' '}
+ 등호(=)가 아니라 콜론(:)인 걸 눈여겨보세요. 사실{' '}
+ style 속성의 따옴표 안은 HTML이 아니라{' '}
+ CSS라는 별개의 언어거든요. HTML이 페이지의{' '}
+ 뼈대와 내용을 맡는다면, CSS는 색·크기·배치 같은 겉모습을
+ 전담하는 언어예요. 오늘 본 건 그 거대한 세계의 문틈으로 살짝 들여다본 정도입니다.
+
+
+ 지금은 이 정도면 충분 "내가 쓴 한 줄로 화면이 바뀐다" — 오늘
+ 가져갈 건 이 경험 하나예요. color 값에 red 대신{' '}
+ hotpink, gold,{' '}
+ tomato 같은 이름도 넣어 보세요(진짜 있는 색이에요!).
+ CSS를 제대로 배우는 건 다음 과정의 몫이니, 지금은 마음껏 알록달록하게 놀면 됩니다.
+
+
+
+
+
+ 이제 오늘의 주인공, 실습입니다. 여러분이 제일 좋아하는 것 —
+ 가수든, 게임이든, 음식이든, 축구팀이든 — 하나를 골라 소개 페이지를
+ 만들어 보세요. 조건은 딱 네 가지입니다.
+
+
+ ① 이미지 2장 (img +{' '}
+ src·alt) ·{' '}
+ ② 링크 3개 (a +{' '}
+ href, 새 탭으로!) · ③ 목록 1개{' '}
+ (ul 또는 ol) ·{' '}
+ ④ 구역은 div로 나누기. 스타일은
+ 자유 — 원하는 만큼 알록달록하게.
+
+ {CODE_FINAL}
+
+ 위 코드는 예시일 뿐이에요. 그대로 베끼면 재미없죠 — 주제도, 목록
+ 내용도, 링크도 전부 여러분의 최애로 바꾸세요. 이미지는 좋아하는 사진
+ 2장을 HTML 파일과 같은 폴더에 넣고 파일 이름을{' '}
+ src에 적으면 됩니다(섹션 2에서 배운 상대 경로!). 안
+ 보이면? 당황하지 말고 점검 순서 ①→④를 따라가면 반드시 잡혀요.
+
+ {/* 학습 포인트: 완성 경험 + 보여 주기(공유)까지가 실습의 한 세트 — 만들고 끝나면 절반만 한 것 */}
+
+ 마지막 단계: 자랑하기 완성했다면 브라우저에 띄운 화면을 친구나 가족에게
+ 꼭 보여 주세요. "이거 내가 만든 거야"라고 말하는 순간의 기분 — 그게 개발자들이
+ 이 일을 계속하는 이유 중 하나거든요. 친구가 "이 링크 눌러도 돼?"라고 물으면, 여러분은
+ 이미 사용자가 있는 페이지를 만든 겁니다.
+
+
+
+
+
+
🏗️ 여기까지 왔다면
+
+ 글자뿐이던 문서가 링크로 세상과 이어지고, 이미지가 박히고, 목록과 표로 정리되고,
+ div 상자로 구역까지 나뉘었어요. 무엇보다 속성(attribute)이라는
+ 개념을 손에 넣었죠 — href,{' '}
+ src, style 전부 그
+ 하나의 문법이었으니까요. 그런데 문득 궁금하지 않나요? 내가 쓴 이 태그들을{' '}
+ 브라우저는 대체 어떻게 읽어서 화면으로 바꿔 내는 걸까요? 다음{' '}
+ 브라우저는 내 코드를 어떻게
+ 읽을까 강좌에서, 우리가 만든 페이지가 화면이 되기까지의 여정을
+ 따라가 봅시다.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv1LoopsPage.jsx b/frontend/src/pages/levels/Lv1LoopsPage.jsx
new file mode 100644
index 0000000..022c688
--- /dev/null
+++ b/frontend/src/pages/levels/Lv1LoopsPage.jsx
@@ -0,0 +1,508 @@
+// 이 파일이 하는 일: "반복문: 지루한 일은 컴퓨터에게" 코스 — 코딩을 처음 배우는 수습(중·고등학생
+// 눈높이)에게 반복문이 왜 필요한지에서 출발해, while·for·누적 계산·조건과의 콤보·배열 맛보기·
+// 별 그림 실습·흔한 함정까지 8개 섹션으로 안내하는 정적 학습 페이지.
+// 프레이밍은 "같은 일을 100번? 그건 컴퓨터의 일" — 사람은 규칙을 찾고, 반복은 기계에게 맡긴다.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_COPYPASTE = `// "안녕하세요"를 100번 출력하라는 미션을 받았다면?
+
+// 방법 1: 복사-붙여넣기 100번 (사람의 방식... 이지만 최악)
+console.log("안녕하세요"); // 1번째
+console.log("안녕하세요"); // 2번째
+console.log("안녕하세요"); // 3번째
+// ... 97줄이 더 남았어요. 그리고 "안녕하세요!"로 바꾸라는
+// 요청이 오면? 100줄을 전부 다시 고쳐야 합니다.
+
+// 방법 2: 반복문 (컴퓨터의 방식)
+for (let i = 1; i <= 100; i = i + 1) {
+ console.log("안녕하세요");
+}
+// 딱 3줄. 100번이든 100만 번이든 숫자 하나만 바꾸면 끝.
+// 문구를 바꿀 때도 한 줄만 고치면 됩니다.`;
+
+const CODE_WHILE = `// while — "조건이 참(true)인 동안" 계속 반복한다
+let count = 1; // ① 카운터: 몇 번째인지 세는 변수
+
+while (count <= 5) { // ② 조건: count가 5 이하인 동안만
+ console.log(count + "번째 인사!");
+ count = count + 1; // ③ 변화: 한 바퀴 돌 때마다 1씩 커진다
+}
+
+// 실행 결과:
+// 1번째 인사!
+// 2번째 인사!
+// 3번째 인사!
+// 4번째 인사!
+// 5번째 인사!
+// count가 6이 되는 순간 조건(count <= 5)이 거짓 → 반복 끝.`;
+
+const CODE_INFINITE = `// 무한루프 — 조건이 영원히 참이면 컴퓨터는 영원히 돈다
+let count = 1;
+while (count <= 5) {
+ console.log("멈추지 않아요...");
+ // count를 키우는 줄을 깜빡했다!
+ // count는 계속 1 → 조건은 영원히 참 → 무한루프
+}
+
+// 일부러 한 번 겪어 보세요. 브라우저 탭이 멈추면
+// 탭을 닫거나, 콘솔이라면 Ctrl + C 로 강제 종료합니다.
+// 교훈: while을 쓸 땐 "조건을 거짓으로 만드는 줄"이
+// 반복문 안에 반드시 있는지부터 확인하세요.`;
+
+const CODE_FOR = `// for — 반복의 3요소를 한 줄에 모아 둔 '반복의 정석'
+//
+// ① 초기화 ② 조건 ③ 증감
+// (시작할 때 1번) (매 바퀴 검사) (매 바퀴 끝에 실행)
+for (let i = 1; i <= 10; i = i + 1) {
+ console.log(i);
+}
+// 출력: 1 2 3 4 5 6 7 8 9 10
+
+// 읽는 법: "i를 1에서 시작해서, 10 이하인 동안, 1씩 키우며 반복해라"
+// while로 흩어져 있던 ①②③이 첫 줄에 모여 있어서
+// 한눈에 '몇 번 도는지'가 보입니다. 그래서 횟수가 정해진
+// 반복엔 거의 항상 for를 씁니다.
+
+// 참고: i = i + 1 은 i++ 로 줄여 쓸 수 있어요. 뜻은 같습니다.`;
+
+const CODE_SUM = `// 1부터 100까지의 합 — 반복 속에서 계산을 '쌓는' 패턴
+let sum = 0; // ① 반복 전에 0으로 시작 (빈 저금통)
+
+for (let i = 1; i <= 100; i++) {
+ sum = sum + i; // ② 매 바퀴 i를 저금통에 더한다
+}
+
+console.log(sum); // ③ 반복이 끝나면 결과 확인 → 5050
+
+// sum의 변화를 따라가 보면:
+// i=1일 때 sum = 0 + 1 = 1
+// i=2일 때 sum = 1 + 2 = 3
+// i=3일 때 sum = 3 + 3 = 6
+// ...
+// i=100일 때 sum = 4950 + 100 = 5050
+// 이렇게 값을 쌓아 가는 변수를 '누적 변수'라고 불러요.`;
+
+const CODE_COMBO = `// 반복 + 조건 콤보: 구구단에서 '짝수 단'만 출력하기
+for (let dan = 2; dan <= 9; dan++) {
+ if (dan % 2 === 0) { // dan을 2로 나눈 나머지가 0이면 짝수
+ console.log(dan + "단은 짝수 단!");
+ }
+}
+// 출력: 2단은 짝수 단! / 4단은 짝수 단! / 6단은 짝수 단! / 8단은 짝수 단!
+
+// 반복문이 "모든 후보를 훑고", 조건문이 "그중 골라낸다" —
+// 두 개념이 만나는 순간 표현력이 폭발합니다.`;
+
+const CODE_BREAK_CONTINUE = `// break — 반복을 '통째로' 탈출한다
+for (let i = 1; i <= 10; i++) {
+ if (i === 4) {
+ break; // i가 4가 되는 순간 반복문 전체 종료
+ }
+ console.log(i);
+}
+// 출력: 1 2 3 (4부터는 아예 실행되지 않음)
+
+// continue — '이번 바퀴만' 건너뛰고 다음 바퀴로
+for (let i = 1; i <= 5; i++) {
+ if (i === 3) {
+ continue; // 3일 때만 아래 줄을 건너뛴다
+ }
+ console.log(i);
+}
+// 출력: 1 2 4 5 (3만 빠지고 반복은 계속됨)
+
+// 비유: break는 "하교", continue는 "이 수업만 조퇴".`;
+
+const CODE_ARRAY = `// 배열 — 여러 값을 순서대로 담는 한 상자
+const fruits = ["사과", "바나나", "포도", "귤"];
+
+// 인덱스(번호)는 0부터 시작합니다!
+console.log(fruits[0]); // "사과" ← 첫 번째가 0번
+console.log(fruits[3]); // "귤" ← 네 번째가 3번
+console.log(fruits.length); // 4 ← 상자에 든 개수
+
+// for로 배열의 모든 항목 순회하기
+for (let i = 0; i < fruits.length; i++) {
+ console.log(i + "번 칸: " + fruits[i]);
+}
+// 출력:
+// 0번 칸: 사과
+// 1번 칸: 바나나
+// 2번 칸: 포도
+// 3번 칸: 귤
+
+// 조건이 i <= fruits.length가 아니라 i < fruits.length인 이유:
+// 개수는 4지만 마지막 번호는 3이니까요. (0, 1, 2, 3)`;
+
+const CODE_STARS = `// 별(★)을 한 줄씩 늘려 삼각형 만들기
+for (let line = 1; line <= 5; line++) {
+ let stars = ""; // 이번 줄에 쌓을 빈 문자열
+ for (let j = 1; j <= line; j++) { // line번째 줄엔 별을 line개
+ stars = stars + "★";
+ }
+ console.log(stars);
+}
+
+// 출력:
+// ★
+// ★★
+// ★★★
+// ★★★★
+// ★★★★★
+
+// 규칙을 발견했나요? "n번째 줄엔 별이 n개".
+// 규칙을 찾으면 코드가 짧아집니다. 바깥 for가 '줄'을,
+// 안쪽 for가 '그 줄의 별 개수'를 맡는 '반복 안의 반복(중첩)'이에요.`;
+
+const CODE_PRACTICE = `// 실습 과제: 구구단 2~9단 전부 출력 + 7단만 별표(*)로 강조
+for (let dan = 2; dan <= 9; dan++) {
+ console.log("--- " + dan + "단 ---");
+
+ for (let n = 1; n <= 9; n++) {
+ if (dan === 7) {
+ // 7단이면 앞뒤에 별표를 붙여 눈에 띄게!
+ console.log("* " + dan + " x " + n + " = " + dan * n + " *");
+ } else {
+ console.log(dan + " x " + n + " = " + dan * n);
+ }
+ }
+}
+
+// 바깥 for: 단(2~9)을 돈다 → 8바퀴
+// 안쪽 for: 곱하는 수(1~9)를 돈다 → 각 단마다 9바퀴
+// 총 8 x 9 = 72줄이 출력됩니다. 손으로 72줄을 쓸 뻔했어요!`;
+
+const CODE_OFFBYONE = `// 함정 1: 하나 차이(off-by-one) 실수
+for (let i = 1; i < 10; i++) { } // 1~9까지, 9번 반복 (10 미포함!)
+for (let i = 1; i <= 10; i++) { } // 1~10까지, 10번 반복
+for (let i = 0; i < 10; i++) { } // 0~9까지, 10번 반복 (배열용 관례)
+// "<인가 <=인가, 0부터인가 1부터인가"를 늘 소리 내어 읽어 보세요.
+
+// 함정 2: 조건을 안 바꿔 생기는 무한루프
+let n = 0;
+while (n < 3) {
+ console.log("도는 중");
+ // n++ 를 깜빡함 → 영원히 돈다 (섹션 2에서 겪어 봤죠?)
+}
+
+// 함정 3: i의 범위 착각 — 점검법
+// 헷갈리면 반복문 첫 줄에 console.log(i)를 넣고 돌려 보세요.
+// i가 실제로 어떤 값들을 거치는지 눈으로 확인하는 게
+// 머릿속 상상보다 백 배 정확합니다.`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용 — 이것도 일종의 '반복 제거'다.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ "안녕하세요"를 100번 출력하라면 어떻게 할까요? 복사-붙여넣기 100번? 컴퓨터에겐
+ 더 좋은 방법이 있어요. 100번 반복도 한 줄로 — 컴퓨터가 세상에서
+ 가장 잘하는 일, 지치지 않고 똑같이 반복하기를 시키는 법을 배웁니다.
+ 이 코스가 끝나면 구구단 72줄을 코드 몇 줄로 뽑아낼 수 있어요.
+
+
+ 예상 소요 70분
+ 실습: 구구단 + 7단 별표 강조
+ 선수 지식: 변수 · 조건문
+
+
+
+ {/* 섹션 바로가기 */}
+
+
+
+
+ 사람은 같은 일을 반복하면 지루해하고, 지루해지면 실수해요. 컴퓨터는 정반대입니다.
+ 같은 일을 100만 번 시켜도 지루해하지 않고, 단 한 번도 틀리지 않아요.{' '}
+ 그래서 프로그래밍의 절반은 "이 일에서 반복되는 부분이 어디지?"를
+ 찾아내 컴퓨터에게 떠넘기는 기술입니다.
+
+ {CODE_COPYPASTE}
+
+ 복사-붙여넣기 100번과 반복문 3줄 — 결과는 같지만 고칠 때 차이가
+ 드러나요. 문구 하나 바꾸려고 100줄을 고치는 사람과, 1줄만 고치는 사람. 개발자들은
+ 이 감각을 "같은 코드를 두 번 쓰지 마라"라는 격언으로 물려받았어요.
+ 반복문은 그 격언을 지키는 첫 번째 도구입니다.
+
+
+ 일상 속 반복문 이미 여러분은 반복문처럼 생각하고 있어요. "설거지: 그릇이{' '}
+ 남아 있는 동안, 하나 집어 → 씻고 → 헹구기" — '남아 있는 동안'이
+ 조건이고, 화살표 안이 반복할 내용이에요. 코드는 이 생각을 받아 적는 것뿐입니다.
+
+
+
+
+
+ 가장 단순한 반복문은 while이에요. 읽는 법도 영어
+ 그대로입니다. "~인 동안(while) 계속해라." 괄호 안의 조건이{' '}
+ 참(true)인 동안 중괄호 안을 계속 실행하고, 조건이 거짓이 되는 순간
+ 멈춥니다.
+
+ {CODE_WHILE}
+
+ 여기서 제일 중요한 부품은 카운터 변수{' '}
+ count예요. 반복이 "지금 몇 바퀴째인지"를 세는
+ 변수인데, 핵심은 매 바퀴 값이 변해야 한다는 것.{' '}
+ count = count + 1이 매번 값을 키워 주니까, 언젠가
+ 조건 count <= 5가 거짓이 되어 반복이 멈춥니다.
+ 조건을 변하게 만드는 줄이 없으면? 아래처럼 됩니다.
+
+ {CODE_INFINITE}
+
+ 무한루프는 일부러 한 번 겪어 보세요 위 코드를 진짜로 돌려 보고, 탭이 멈추는
+ 걸 경험한 뒤 직접 멈춰 보세요(탭 닫기, 콘솔은 Ctrl + C). 사고를
+ 안전한 환경에서 미리 겪어 두면, 나중에 실전에서 "어, 이거 무한루프네" 하고 바로
+ 알아챌 수 있어요. 소방 훈련과 같은 원리입니다.
+
+
+ while 점검 3질문 ① 조건은 무엇인가? ② 조건 속 변수를 바꾸는 줄이
+ 반복문 안에 있는가? ③ 그 변화가 언젠가 조건을 거짓으로 만드는가?
+ — 셋 중 하나라도 "아니오"면 무한루프 후보예요.
+
+
+
+
+
+ 섹션 2의 while 코드를 다시 보면, 반복에 필요한 재료가 세 군데 흩어져 있어요.
+ 시작값(let count = 1)은 반복문 위에,
+ 조건은 괄호에, 증가는 몸통 끝에. 이 셋을 첫 줄에
+ 모아 정리한 것이 for문입니다.
+
+ {CODE_FOR}
+
+ 세 요소는 세미콜론(;)으로 구분해요.{' '}
+ ① 초기화는 반복이 시작될 때 딱 한 번, ② 조건은
+ 매 바퀴 시작 전에 검사(거짓이면 즉시 종료), ③ 증감은 매 바퀴가
+ 끝날 때마다 실행됩니다. 셋이 한 줄에 있으니 "몇 번 도는 반복인지"를 첫 줄만 보고
+ 알 수 있어요. 그래서 횟수가 정해진 반복은 for, 언제 끝날지 모르는
+ 반복(예: 사용자가 그만할 때까지)은 while — 이게 관례적인 역할 분담입니다.
+
+
+ 왜 하필 i인가 반복 변수 이름으로 i를 쓰는 건
+ 전 세계 공통 관례예요. index(번호)의 첫 글자로, 수학의 수열 기호에서 건너온
+ 전통입니다. 중첩되면 j, k로
+ 이어가요. 다만 의미가 뚜렷할 땐 dan(단),{' '}
+ line(줄)처럼 뜻이 보이는 이름이 더 좋습니다 — 우리
+ 실습에서도 그렇게 쓸 거예요.
+
+
+
+
+
+ 지금까지는 반복하며 출력만 했어요. 이제 한 단계 올라가서, 반복하며{' '}
+ 계산 결과를 쌓아 봅시다. 대표 문제: "1부터 100까지 다 더하면?"
+ — 사람은 한참 걸리지만, 반복문에겐 순식간입니다.
+
+ {CODE_SUM}
+
+ 이 패턴의 주인공은 누적 변수sum이에요.
+ 반복 바깥에서 만들어 두고, 매 바퀴{' '}
+ sum = sum + i로 값을 쌓아 갑니다. "지금까지의 합에
+ 이번 값을 더해서, 다시 지금까지의 합으로 저장한다" — 저금통에 매일 동전을 넣는
+ 것과 똑같아요.
+
+
+ 왜 0으로 시작할까요? 저금통은 비어 있는 상태에서
+ 시작해야 정확히 "넣은 만큼"이 남으니까요. 덧셈에서 0은 더해도 값이 변하지 않는
+ 출발점입니다. 만약 곱셈을 누적한다면(예: 1×2×3×...) 시작값은 1이어야 해요 —
+ "그 연산에서 아무 영향이 없는 값"으로 시작한다는 게 원리입니다.
+
+
+ 이 패턴, 평생 씁니다 시험 점수 평균 내기, 장바구니 총액 구하기, 게임 누적
+ 점수 — 형태만 다를 뿐 전부 "반복 전에 0 → 매 바퀴 쌓기 → 반복 후 결과 확인"이라는
+ 같은 3단계예요. 패턴 하나를 익히면 문제 백 개가 풀립니다.
+
+
+
+
+
+ 이전 강좌에서 배운 if(조건문)를 반복문 안에 넣으면
+ 어떻게 될까요? 반복문이 모든 후보를 하나씩 훑고, 조건문이{' '}
+ 그중에서 골라냅니다. "2단부터 9단 중에 짝수 단만" 같은 문장을
+ 그대로 코드로 옮길 수 있게 돼요.
+
+ {CODE_COMBO}
+
+ 여기에 반복의 흐름 자체를 조종하는 두 개의 열쇠가 더 있어요.{' '}
+ break는 반복을 통째로 탈출하고,{' '}
+ continue는 이번 바퀴만 건너뛰고{' '}
+ 다음 바퀴로 넘어갑니다.
+
+ {CODE_BREAK_CONTINUE}
+
+ 언제 쓸까요? break는 "찾았다! 더 볼 필요 없어"의
+ 순간에(예: 명단에서 내 이름을 찾으면 멈추기), continue는
+ "이건 건너뛰고 계속"의 순간에(예: 빈칸인 답안은 채점 생략) 씁니다. 남용하면 흐름이
+ 꼬여 읽기 어려워지니, 지금은 "이런 열쇠가 있다"는 맛보기로 충분해요.
+
+
+ 표현력의 도약 변수만 알 때는 '기억'을, 조건문까지 알면 '선택'을, 반복문까지
+ 알면 '대량 처리'를 시킬 수 있어요. 그리고 셋을 조합하는 순간 —
+ "전교생 성적 중 90점 이상만 세어 줘" 같은 일이 코드 몇 줄이 됩니다. 프로그래밍의
+ 힘은 개념 하나하나보다 조합에서 나와요.
+
+
+
+
+
+ 반복문과 늘 붙어 다니는 단짝을 소개할게요. 과일 4개를 저장하려고{' '}
+ fruit1, fruit2,{' '}
+ fruit3... 변수를 따로 만들면, 그것도 결국
+ 복사-붙여넣기죠. 배열(array)은 여러 값을{' '}
+ 순서대로 한 상자에 담는 방법입니다.
+
+ {CODE_ARRAY}
+
+ 꼭 기억할 규칙 하나: 인덱스(칸 번호)는 0부터 시작합니다. 첫 번째
+ 항목이 fruits[0]이고, 4개짜리 배열의 마지막은{' '}
+ fruits[3]이에요. 처음엔 어색하지만 거의 모든
+ 프로그래밍 언어가 이 규칙을 따르니, 지금 확실히 몸에 새겨 두는 게 이득입니다.
+
+
+ 그리고 배열과 for가 만나는 순간을 보세요. 상자에 뭐가 몇 개 들었든{' '}
+ i = 0부터 i <{' '}
+ fruits.length까지 돌리면 전부 순회됩니다. "목록에 있는 것
+ 전부에 대해 ~해라" — 앞으로 만들 거의 모든 프로그램의 뼈대가 되는 문장이에요.
+
+
+ 0부터 세는 이유가 궁금하다면 인덱스는 "몇 번째"가 아니라 "시작점에서
+ 몇 칸 떨어졌나"에 가까워요. 첫 항목은 시작점에서 0칸 떨어져 있으니 0번.
+ 아파트 1층을 어떤 나라에선 G(0층)라고 부르는 것과 비슷한, 세는 기준의 차이입니다.
+
+
+
+
+
+ 배운 걸 손으로 굳힐 시간이에요. 첫 번째 워밍업: 별(★)을 한 줄씩 늘려{' '}
+ 삼각형을 만들어 봅시다. 시작 전에 먼저 규칙부터 찾아 보세요 —
+ "1번째 줄엔 별 1개, 2번째 줄엔 2개, ... n번째 줄엔 n개."
+
+ {CODE_STARS}
+
+ 여기서 반복 안의 반복(중첩 반복문)이 처음 등장했어요. 바깥{' '}
+ for가 한 바퀴 돌 때마다, 안쪽{' '}
+ for는 처음부터 끝까지 전부 돕니다.
+ 바깥이 '줄', 안쪽이 '그 줄의 내용'을 맡는 구조 — 시계의 시침이 한 칸 갈 때 분침이
+ 한 바퀴 도는 것과 같아요.
+
+
+ 이제 진짜 미션입니다. 구구단 2단부터 9단까지 전부 출력하되, 7단만
+ 별표(*)로 강조해 보세요. 필요한 재료는 전부 배웠어요 — 중첩 반복문(단 ×
+ 곱하는 수)과 조건문(7단인가?)의 콤보입니다. 먼저 스스로 시도해 보고, 막히면 아래를
+ 펼쳐 보세요.
+
+ {CODE_PRACTICE}
+
+ 스스로 점검 ① 출력이 정확히 72줄인가요? (8개 단 × 9줄) ② 7단에만 별표가
+ 붙었나요? ③ dan === 7을{' '}
+ dan === 3으로 바꾸면 3단이 강조되나요? — 셋 다
+ "네"라면 이 강좌의 목표를 달성한 거예요.
+
+
+ 한 걸음 더 (도전 과제) 별 삼각형을 거꾸로(5개부터 1개로
+ 줄어들게) 만들어 보세요. 힌트: 초기화·조건·증감 3요소 중 무엇을 뒤집으면 될까요?{' '}
+ let line = 5; line >= 1; line-- — 증감이 꼭
+ '증가'일 필요는 없답니다.
+
+
+
+
+
+ 반복문에서 초보자가 빠지는 함정은 신기할 만큼 정해져 있어요. 전 세계 개발자가
+ 똑같이 밟는 지뢰 세 개를 미리 구경해 둡시다. 나중에 같은 에러를 만나면 "아, 그거!"
+ 하고 웃으며 고칠 수 있게요.
+
+ {CODE_OFFBYONE}
+
+ ① 하나 차이(off-by-one) 실수 — <와{' '}
+ <=의 차이, 0부터와 1부터의 차이로 반복이 한 번
+ 더 돌거나 덜 도는 실수예요. 이름이 따로 있을 만큼 흔합니다. 처방: 조건을{' '}
+ 소리 내어 읽고, 첫 바퀴와 마지막 바퀴의 i 값을 손으로 적어 보기.
+
+
+ ② 무한루프 — 조건 속 변수를 바꾸는 줄을 빼먹는 실수(섹션 2).
+ 처방: while을 쓸 때마다 "조건을 거짓으로 만드는 줄이 어디지?"를 반드시 자문하기.
+ for는 증감이 첫 줄에 강제로 자리 잡고 있어서 이 실수가 훨씬 드물어요 — for가
+ '정석'인 이유 중 하나입니다.
+
+
+ ③ i의 범위 착각 — "i가 지금 무슨 값이지?"를 머릿속으로만 추측하다
+ 틀리는 경우. 처방은 단순합니다. 반복문 안에{' '}
+ console.log(i) 한 줄을 넣고 직접 돌려 보기.{' '}
+ 상상은 틀려도 출력은 거짓말을 하지 않아요. 이 습관 하나가 디버깅 실력의 절반입니다.
+
+
+ 에러는 실력의 증거 무한루프로 탭을 멈춰 봤고, off-by-one으로 한 줄이 빠져
+ 봤다면 — 잘하고 있는 거예요. 한 번 밟아 본 지뢰는 두 번 다시 같은 위력을 갖지
+ 못합니다. 중요한 건 에러를 안 내는 게 아니라, 에러에서 원인을 찾아내는
+ 연습을 쌓는 것입니다.
+
+
+
+
+
+
🔁 여기까지 왔다면
+
+ 이제 "100번 반복"이라는 말에 복사-붙여넣기 대신 for문 3줄이
+ 떠오를 거예요. while(조건이 참인 동안), for(초기화·조건·증감), 누적 변수(빈
+ 저금통에서 시작해 쌓기), 반복+조건 콤보, 배열 순회, 그리고 중첩 반복으로 그린
+ 별 삼각형까지 — 컴퓨터에게 지루한 일을 통째로 떠넘기는 법을
+ 손에 쥐었습니다. 그런데 구구단 코드를 다른 곳에서 또 쓰고 싶다면? 매번 복사해야
+ 할까요? 잘 만든 코드 뭉치에 이름을 붙여 두고 필요할 때마다 부르는{' '}
+ 방법이 있어요. 다음{' '}
+ 함수: 나만의 명령어 만들기{' '}
+ 코스에서, 코드를 재사용하는 진짜 기술을 이어서 배워 봅시다.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv1ReadingErrorsPage.jsx b/frontend/src/pages/levels/Lv1ReadingErrorsPage.jsx
new file mode 100644
index 0000000..827fbc5
--- /dev/null
+++ b/frontend/src/pages/levels/Lv1ReadingErrorsPage.jsx
@@ -0,0 +1,461 @@
+// 이 파일이 하는 일: "에러와 친해지기: 빨간 글씨 해독법" 코스 — 코딩을 처음 시작한 사람이
+// 가장 먼저 부딪히는 '빨간 에러 메시지'를 무서운 벌점이 아니라 친절한 힌트로 읽어 내는 법을
+// 8개 섹션으로 안내하는 정적 학습 페이지. 에러 메시지의 3부 구조 → 3대 에러 유형(Syntax·
+// Reference·Type) → 탐정식 디버깅 → 검색·AI 활용 → 일부러 에러 내 보는 실습까지 이어진다.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_EVERYONE = `여러분이 오늘 만난 에러 10년 차 개발자가 오늘 만난 에러
+───────────────────── ─────────────────────────
+SyntaxError: ... (3번) SyntaxError: ... (아마 더 많이)
+ReferenceError: ... (2번) ReferenceError: ... (역시 만남)
+
+차이는 '에러를 안 만나는 것'이 아니라
+'에러를 만났을 때 몇 분 만에 빠져나오는가'예요.
+에러 메시지는 벌점 통지서가 아니라,
+컴퓨터가 최선을 다해 써 준 안내문입니다.`;
+
+const CODE_ANATOMY = `// 브라우저 콘솔에 뜬 빨간 글씨 한 줄을 해부해 봅시다.
+
+Uncaught TypeError: user.sayHi is not a function
+ at greet (script.js:12:8)
+
+┌ ① 에러 이름: TypeError
+│ → "어떤 종류의 문제인가" (오늘 3가지 유형을 배워요)
+├ ② 설명: user.sayHi is not a function
+│ → "정확히 무엇이 문제인가" (sayHi가 함수가 아니래요)
+└ ③ 위치: script.js:12:8
+ → "어디를 보면 되는가" (script.js 파일 12번째 줄, 8번째 칸)
+
+읽는 순서는 거꾸로! ③ 줄번호로 먼저 이동 → ② 설명 대조 → ① 유형 파악`;
+
+const CODE_SYNTAX = `// SyntaxError — 컴퓨터가 문장을 '읽지도 못한' 상태. 실행 전에 걸립니다.
+
+// 흔한 패턴 1: 괄호 짝이 안 맞음
+console.log("안녕하세요" // ← 닫는 괄호 ) 가 없다!
+// SyntaxError: missing ) after argument list
+
+// 흔한 패턴 2: 따옴표 짝이 안 맞음
+const name = "지민; // ← 닫는 따옴표 " 가 없다!
+// SyntaxError: Invalid or unexpected token
+
+// 흔한 패턴 3: 중괄호 짝이 안 맞음
+function hello()
+ console.log("hi");
+ // ← 여는 중괄호가 없어서 몸통이 미아가 됨
+// SyntaxError: Unexpected token
+
+// 요령: 괄호·따옴표·중괄호는 항상 '쌍'이다.
+// 하나를 열면(( " [ ) 반드시 하나를 닫는다() " ]).`;
+
+const CODE_REFERENCE = `// ReferenceError — "그런 이름, 나는 모르는데?" 이름표를 못 찾은 상태.
+
+const userName = "지민";
+console.log(username);
+// ReferenceError: username is not defined
+// ^^^^^^^^
+// 범인: 대소문자! userName(대문자 N)으로 선언했는데
+// username(소문자 n)으로 불렀어요. 컴퓨터에겐 완전히 다른 이름.
+
+console.log(scroe);
+// ReferenceError: scroe is not defined
+// 범인: 오타. score를 scroe로 잘못 쳤어요.
+
+console.log(count); // ← 선언(let count = 0)보다 먼저 사용
+let count = 0;
+// ReferenceError: Cannot access 'count' before initialization
+// 범인: 순서. 이름표를 붙이기 전에 부르면 컴퓨터는 모릅니다.
+
+// "is not defined"를 보면 딱 세 가지만 의심하세요.
+// ① 대소문자가 다른가? ② 철자가 틀렸나? ③ 선언보다 먼저 썼나?`;
+
+const CODE_TYPE = `// TypeError — 이름은 찾았는데, 그 값으로 '그런 동작'을 할 수 없는 상태.
+
+const age = 15;
+age.toUpperCase();
+// TypeError: age.toUpperCase is not a function
+// 이유: toUpperCase()는 문자열 전용인데, age는 숫자예요.
+// 숫자에게 "대문자로 바꿔!"라고 시킨 셈.
+
+const user = null;
+console.log(user.name);
+// TypeError: Cannot read properties of null (reading 'name')
+// 이유: user가 비어 있는데(null) .name을 꺼내려 했어요.
+// 빈 상자에서 물건을 꺼내려는 것과 같아요.
+
+// 추적 도구: typeof — 값의 정체를 물어보는 돋보기
+console.log(typeof age); // "number" ← 아, 숫자였구나
+console.log(typeof "안녕"); // "string"
+console.log(typeof user); // "object" (null은 object로 나와요 — 유명한 옛날 버그)
+
+// "is not a function" / "Cannot read properties of ..." 가 보이면
+// → 그 값의 형(type)이 내 기대와 다르다는 뜻. typeof로 먼저 확인!`;
+
+const CODE_DEBUG = `// 탐정의 기본기: console.log를 심어 값의 흐름을 눈으로 확인한다.
+
+function calcTotal(price, count) {
+ console.log("① 들어온 값:", price, count); // 입구에서 확인
+
+ const discounted = price * 0.9;
+ console.log("② 할인 후:", discounted); // 중간에서 확인
+
+ const total = discounted * count;
+ console.log("③ 최종:", total); // 출구에서 확인
+ return total;
+}
+
+calcTotal("1000", 3);
+// ① 들어온 값: 1000 3 ← 겉보기엔 멀쩡한데...
+// ② 할인 후: 900 ← 여기까진 괜찮고
+// ③ 최종: 2700 ← 어라? 계산은 됐네?
+// console.log("①의 정체:", typeof price); // "string"!! 문자열이 들어왔다!
+
+// 반씩 좁히기: 코드가 100줄이면 50번째 줄에 log를 하나 심는다.
+// 값이 정상이면 → 뒤쪽 50줄이 범인. 이상하면 → 앞쪽 50줄이 범인.
+// 이걸 반복하면 100줄도 log 7번이면 범인 줄을 특정할 수 있어요. (2의 7승 = 128)`;
+
+const CODE_SEARCH = `// 나쁜 질문 vs 좋은 질문 — 검색이든 AI든 똑같이 적용됩니다.
+
+[나쁜 질문]
+"코드가 안 돼요. 도와주세요."
+→ 아무도(사람도 AI도) 도울 수 없어요. 정보가 0이니까.
+
+[좋은 질문 3종 세트]
+① 코드: "이 코드를 실행했는데" + 문제의 코드 몇 줄
+② 에러: "이 에러가 났어요" + 에러 메시지 전문 복사
+③ 시도한 것: "괄호 짝은 확인해 봤어요" + 내가 이미 해 본 것
+
+[검색 요령]
+- 에러 메시지를 그대로 검색창에 붙여넣기 (따옴표로 감싸면 더 정확)
+- 단, 내 파일명·변수명처럼 '나만의 부분'은 지우고 검색
+ 검색 O: "Cannot read properties of null"
+ 검색 X: "Cannot read properties of null (reading 'jiminScore')"
+ ^^^^^^^^^^ 나만 쓰는 이름`;
+
+const CODE_PRACTICE = `// 실습용 코드 — 멀쩡히 돌아가는 코드예요. 이걸 '일부러' 망가뜨려 봅시다.
+
+const students = ["지민", "서연", "하준"];
+
+function makeGreeting(name) {
+ return "안녕하세요, " + name + "님!";
+}
+
+for (const s of students) {
+ console.log(makeGreeting(s));
+}
+
+// ── 미션: 세 군데를 하나씩 망가뜨리고, 뜨는 에러를 '예측'한 뒤 확인하기 ──
+// ① makeGreeting의 닫는 괄호 ) 하나 지우기 → 무슨 에러가 뜰까?
+// ② makeGreeting(s)를 makegreeting(s)로 바꾸기 → 무슨 에러가 뜰까?
+// ③ students를 숫자 123으로 바꾸기 → 무슨 에러가 뜰까?
+//
+// 예측 답: ① SyntaxError ② ReferenceError ③ TypeError
+// (③은 숫자 123은 for...of로 돌 수 없어서 "is not iterable"류 TypeError)
+//
+// 망가뜨리기 전의 코드로 되돌릴 수 있게, 원본을 꼭 복사해 두세요!`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 코드를 실행했더니 화면 가득 빨간 글씨가 떴다면 — 축하해요, 드디어
+ 진짜 코딩을 시작한 거예요. 에러는 벌점이 아니라 컴퓨터가 주는 힌트
+ 입니다. 이 코스에서는 그 힌트를 읽고, 원인을 좁히고, 스스로 고치는 힘을 기릅니다.
+ 이 힘 하나가 앞으로 배울 모든 것의 속도를 정해요.
+
+
+ 예상 소요 60분
+ 실습: 버그 5개 스스로 고치기
+ 선수 지식: 없음 (변수·함수를 봤다면 더 좋아요)
+
+
+
+ {/* 섹션 바로가기 — 학습 포인트: SECTIONS 배열 하나로 nav를 그린다(map 재사용) */}
+
+ 처음 에러를 만나면 심장이 철렁해요. "내가 뭘 망가뜨렸나?", "컴퓨터가 고장 났나?" —
+ 둘 다 아닙니다. 에러는 컴퓨터가 "여기서 멈췄는데, 이유는 이거야"라고
+ 최선을 다해 써 준 안내문이에요. 시험지의 빨간 채점이 아니라,
+ 길을 잃었을 때 나타나는 안내 표지판에 가깝습니다.
+
+
+ 그리고 하나 더 — 에러는 모든 개발자가 매일 만나요. 10년 차 개발자도
+ 하루에 수십 번씩 에러를 봅니다. 초보와 고수의 차이는 "에러를 안 만나느냐"가 아니라,
+ 에러를 만났을 때 얼마나 빨리 빠져나오느냐예요.
+
+ {CODE_EVERYONE}
+
+ 가장 나쁜 반응은 에러 메시지를 읽지도 않고 코드를 이리저리 바꿔 보는
+ 거예요. 그건 안내 표지판을 등지고 아무 방향으로나 달리는 것과 같아요. 오늘 이후로
+ 약속 하나만 해요 — 빨간 글씨가 뜨면, 일단 읽는다. 그것만으로 문제의
+ 절반은 풀립니다.
+
+
+ 마음가짐 리셋 에러가 떴다 = "코드가 틀렸다"가 아니라 "컴퓨터가 단서를
+ 줬다"예요. 심지어 에러가 안 뜨고 조용히 이상하게 동작하는 버그가 훨씬 더
+ 무섭습니다. 소리 내서 알려 주는 에러는 오히려 고마운 쪽이에요.
+
+
+
+
+
+ 영어로 된 빨간 글씨가 길어 보여도, 뜯어 보면 언제나 세 부분으로
+ 이루어져 있어요. ① 에러 이름(어떤 종류의 문제인가),{' '}
+ ② 설명(정확히 무엇이 문제인가),{' '}
+ ③ 파일과 줄번호(어디를 보면 되는가). 이 구조만 알면 아무리 긴
+ 에러도 3초 안에 뼈대를 잡을 수 있습니다.
+
+ {CODE_ANATOMY}
+
+ 읽는 순서는 거꾸로가 효율적이에요. 먼저 ③ 줄번호로
+ 그 줄에 가 봅니다. script.js:12:8은 "script.js 파일의
+ 12번째 줄, 8번째 글자"라는 뜻이에요. 그 줄을 눈으로 보면서 ② 설명과 대조하면,
+ 대부분 그 자리에서 원인이 보입니다.
+
+
+ 영어가 겁난다면 이렇게 번역하세요. 에러 메시지의 영어는 사실{' '}
+ 몇 가지 패턴의 반복이에요. is not
+ defined = "그런 이름 모른다", is not a function =
+ "그건 함수가 아니다", unexpected token = "여기 올 수 없는
+ 글자가 왔다", cannot read properties of null = "빈 값에서
+ 뭘 꺼내려 했다". 이 네 문장만 외워도 초급 에러의 대부분을 해석할 수 있어요.
+
+
+ 습관 만들기 에러를 보면 "이름 → 위치 → 설명"을 소리 내어 읽기를
+ 추천해요. "타입에러, 12번째 줄, sayHi가 함수가 아니래" — 입 밖으로 정리하는 것만으로
+ 막연한 공포가 구체적인 할 일로 바뀝니다.
+
+
+
+
+
+ SyntaxError(문법 에러)는 컴퓨터가 코드를{' '}
+ 실행해 보기도 전에 "이건 문장이 안 돼서 못 읽겠어"라고 손을 드는
+ 경우예요. 우리말로 치면 "나는 밥을 먹었"처럼 문장이 중간에 끊긴 상태죠. 원인의
+ 대부분은 짝이 안 맞는 기호 — 괄호 ( ),
+ 따옴표 " ", 중괄호가 하나를 열고 닫지 않은 경우입니다.
+
+ {CODE_SYNTAX}
+
+ SyntaxError의 좋은 점(?)은 코드가 아예 시작도 못 하기 때문에 숨을
+ 곳이 없다는 거예요. 줄번호가 가리키는 곳 근처에서 열고 닫는 짝만 세어 보면 대부분
+ 잡힙니다. 단, 조심할 것 하나 — 에러가 가리키는 줄번호는 컴퓨터가 "여기서 이상함을
+ 눈치챈" 위치라서, 진짜 범인(예: 위쪽에서 안 닫힌 괄호)은{' '}
+ 그 줄보다 위에 있을 수 있어요.
+
+
+ 에디터를 내 편으로 VS Code 같은 에디터는 실행 전에 이미{' '}
+ 빨간 밑줄로 문법 문제를 표시해 줘요. 괄호를 클릭하면 짝이 되는
+ 괄호가 하이라이트되고요. 빨간 밑줄이 남아 있는 채로 실행 버튼부터 누르지 말고,
+ 밑줄에 마우스를 올려 메시지를 먼저 읽는 습관을 들이세요. 에러를{' '}
+ 실행 전에 미리 만나는 가장 쉬운 방법입니다.
+
+
+
+
+
+ ReferenceError(참조 에러)는 컴퓨터가 코드를 읽는 데는 성공했지만,
+ 여러분이 부른 이름을 찾지 못했을 때 나요. 핵심 메시지는{' '}
+ ... is not defined — "그런 이름은 정의된 적이 없는데?"
+ 라는 뜻입니다. 친구 이름을 잘못 불렀는데 아무도 돌아보지 않는 상황이에요.
+
+ {CODE_REFERENCE}
+
+ 통계적으로 1등 원인은 대소문자 오타예요. 컴퓨터에게{' '}
+ userName과 username은
+ 완전히 다른 이름입니다. 사람 눈에는 거의 같아 보여서 더 악질이죠. 2등은 순수한 철자
+ 오타(score → scroe),
+ 3등은 선언하기 전에 사용 — 변수는 이름표를 붙인{' '}
+ let/const 줄{' '}
+ 아래에서만 부를 수 있어요.
+
+
+ 3초 점검법 "is not defined"가 뜨면 에러가 알려 준 이름을 복사해서, 에디터
+ 검색(Ctrl+F)으로 찾아 보세요. 검색 결과가{' '}
+ 딱 한 군데(에러 난 그 줄)뿐이라면 십중팔구 오타예요 — 선언한 곳의
+ 진짜 이름과 글자 하나하나를 대조해 보면 범인이 나옵니다.
+
+
+
+
+
+ TypeError(타입 에러)는 이름은 찾았는데, 그 값의{' '}
+ 형(type)이 기대와 달라서 시킨 동작을 못 할 때 나요. 값에는 저마다
+ 형이 있어요 — 숫자(number), 문자열(string), 그리고 "비어 있음"을 뜻하는{' '}
+ null 같은 것들이요. 숫자에게 "대문자로 바꿔!"라고
+ 시키면, 컴퓨터는 "숫자는 그런 거 못 하는데…"라며 TypeError를 냅니다.
+
+ {CODE_TYPE}
+
+ 자주 보게 될 두 문장을 기억하세요. ... is not a
+ function은 "함수인 줄 알고 불렀는데 함수가 아니다",{' '}
+ Cannot read properties of null은 "빈 값에서 뭔가를
+ 꺼내려 했다"예요. 둘 다 결론은 같아요 —{' '}
+ 그 자리에 온 값이 내 예상과 다르다.
+
+
+ 이때의 추적 도구가 typeof입니다. 의심 가는 값 앞에{' '}
+ console.log(typeof 값) 한 줄을 심으면 값의 정체가
+ "number"·"string"·"object" 같은 답으로 돌아와요. "왜 안 되지?"라고
+ 고민하는 시간의 절반은, typeof 한 줄이면 끝나는 경우가 많습니다.
+
+
+ 세 에러 한 줄 정리SyntaxError = 문장이 안 됨(읽기 실패) ·{' '}
+ ReferenceError = 이름을 못 찾음(호출 실패) ·{' '}
+ TypeError = 값의 형이 다름(동작 실패). 에러 이름만 보고 이 셋 중
+ 어느 단계에서 막혔는지 말할 수 있다면, 이 코스의 절반은 이미 통과예요.
+
+
+
+
+
+ 에러 메시지가 줄번호까지 알려 주면 다행인데, 가끔은 "에러는 안 나는데 결과가
+ 이상한" 경우도 있어요. 이때부터는 탐정의 시간입니다. 기본 도구는
+ 단 하나, console.log — 코드 중간중간에 심어서{' '}
+ 값이 어떻게 흘러가는지를 눈으로 확인하는 거예요.
+
+ {CODE_DEBUG}
+
+ 코드가 길다면 반씩 좁히기를 쓰세요. 전체의 한가운데에 log를 심고,
+ 거기까지의 값이 정상이면 범인은 뒤쪽 절반, 이상하면 앞쪽 절반이에요. 남은 절반에서
+ 또 반을 가르고… 이걸 반복하면 100줄짜리 코드도 log 일곱 번이면 범인 줄을 특정할 수
+ 있습니다. 무작정 처음부터 훑는 것보다 압도적으로 빨라요.
+
+
+ 그리고 탐정의 제1원칙 — "마지막으로 고친 곳"부터 의심하라. 방금까지
+ 잘 돌던 코드가 갑자기 죽었다면, 범인은 거의 항상 그 사이에 내가 바꾼
+ 부분이에요. "내가 뭘 건드렸지?"를 먼저 되짚는 것이, 코드 전체를 뒤지는
+ 것보다 백 배 빠릅니다.
+
+
+ log에 이름표 달기console.log(price)처럼 값만
+ 찍으면, 여러 개를 찍었을 때 어느 게 어느 값인지 헷갈려요.{' '}
+ console.log("① 들어온 값:", price)처럼{' '}
+ 이름표를 함께 찍는 습관을 들이면, 콘솔이 깔끔한 수사 기록이 됩니다.
+ 다 찾은 뒤에는 심어 둔 log를 지우는 것까지가 수사의 마무리예요.
+
+
+
+
+
+ 혼자 15분쯤 씨름해도 안 풀리면, 그때는 바깥의 힘을 빌릴 차례예요. 첫 번째 요령은
+ 간단합니다 — 에러 메시지를 그대로 복사해서 검색창에 붙여넣기.{' '}
+ 전 세계 누군가가 이미 같은 에러를 만나 질문을 올렸고, 답도 달려 있을 확률이 아주
+ 높아요. 단, 내 변수명·파일명처럼 나만 쓰는 부분은 지우고 검색해야
+ 남들의 질문과 겹칩니다.
+
+ {CODE_SEARCH}
+
+ AI에게 물을 때도 똑같아요. "안 돼요"만 던지면 AI도 점쟁이가 될 수밖에 없어요.{' '}
+ ① 문제의 코드, ② 에러 메시지 전문, ③ 내가 이미 시도해 본 것 — 이
+ 3종 세트를 갖춰 물으면 답의 정확도가 완전히 달라집니다. 사실 이 세트를 준비하는
+ 과정에서 스스로 답을 찾는 경우도 많고요. 이걸 개발자들은 "고무 오리에게 설명하다
+ 답을 깨닫는다"라고 불러요.
+
+
+ 마지막으로 제일 중요한 것 — 답을 찾았으면 이해하고 나서 옮겨 적으세요.{' '}
+ 복사–붙여넣기로 일단 돌아가게 만들면 그 순간은 편하지만, 같은 에러를 다음 주에 또
+ 만나요. "이 답이 왜 내 에러를 고치는가"를 한 문장으로 설명할 수
+ 있을 때만 붙여넣기 — 이 규칙 하나가 성장 속도를 가릅니다.
+
+
+ 15분 규칙 15분은 혼자 씨름하고(여기서 실력이 늘어요), 15분이 지나면
+ 주저 없이 검색·질문하세요(여기서 시간을 아껴요). 무한정 혼자 붙드는 것도,
+ 1분 만에 바로 물어보는 것도 모두 성장을 늦춥니다.
+
+
+
+
+
+ 마지막 실습은 거꾸로 갑니다. 멀쩡한 코드를 일부러 망가뜨리고, 어떤
+ 에러가 뜰지 먼저 예측한 뒤 실행해서 확인하는 거예요. 에러를 스스로
+ 만들어 본 사람은 에러가 더 이상 무섭지 않아요 — 어떤 실수가 어떤 빨간 글씨로
+ 이어지는지, 인과가 몸에 새겨지니까요.
+
+ {CODE_PRACTICE}
+
+ 짝이 있다면 더 재미있게 할 수 있어요. 서로의 멀쩡한 코드를 몰래 세 군데
+ 망가뜨려 교환하고, 에러 메시지만 보고 전부 고쳐 내는 시합을 해 보세요.
+ "이 에러 메시지는 어떤 원인과 짝인가"를 맞히는 게임을 반복하다 보면, 어느 순간
+ 에러 이름만 봐도 손이 먼저 움직이게 됩니다.
+
+
+ 그리고 이 코스의 본 실습 — 버그 5개가 심어진 코드를 받아서, 에러
+ 메시지만 단서로 전부 고쳐 보세요. 요령은 배운 그대로예요. ① 빨간 글씨를 읽는다 →
+ ② 줄번호로 간다 → ③ 에러 이름으로 유형(문법/참조/타입)을 가른다 → ④ 막히면
+ console.log와 typeof로 추적한다 → ⑤ 15분 넘게 막히면 검색한다.
+
+
+ 속도가 곧 실력 첫 버그에 20분이 걸려도 괜찮아요. 다섯 번째 버그에서 5분이
+ 됐다면, 이 60분짜리 코스에서 얻어 갈 수 있는 최고의 성과를 이미 얻은 겁니다.
+ 디버깅 속도는 재능이 아니라 순서를 지키는 습관에서 나와요.
+
+
+
+
+
+
🩹 여기까지 왔다면
+
+ 이제 빨간 글씨는 공포가 아니라 읽을 수 있는 안내문이 됐어요.
+ 에러 메시지의 세 조각(이름·설명·위치), 3대 에러 유형(Syntax·Reference·Type),
+ console.log와 typeof로 추적하는 탐정의 기술, 그리고 검색과 AI에게 제대로 묻는
+ 법까지 — 앞으로 만날 모든 에러에 통하는 무기를 손에 쥐었습니다.
+ 이 무기를 제대로 시험해 볼 시간이에요. 다음{' '}
+ 첫 프로젝트: 숫자 맞히기 게임
+ 만들기 코스에서 처음으로 나만의 프로그램을 만들어 봅시다 — 만드는
+ 동안 에러가 여러 번 뜨겠지만, 이제 여러분은 그게 반가울 거예요.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv1VariablesAndTypesPage.jsx b/frontend/src/pages/levels/Lv1VariablesAndTypesPage.jsx
new file mode 100644
index 0000000..a3feec3
--- /dev/null
+++ b/frontend/src/pages/levels/Lv1VariablesAndTypesPage.jsx
@@ -0,0 +1,445 @@
+// 이 파일이 하는 일: "변수와 자료형: 이름 붙인 상자" 코스 — 코딩을 처음 접하는 사람을 위해
+// '값을 상자에 담아 이름으로 꺼내 쓴다'는 변수 개념에서 출발해, let/const, 문자열·숫자·불리언
+// 세 가지 기본 자료형, typeof와 형 변환의 함정, prompt 실습까지 7개 섹션으로 안내하는
+// 정적 학습 페이지. 프레이밍은 "모든 프로그램의 가장 작은 부품" — 다음 강좌(조건문)에서
+// 바로 쓰일 불리언까지 손에 쥐게 하는 것이 목표다.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+// 단, 예제 자체에 백틱(`)이 들어가는 템플릿 리터럴 코드만은 백틱 상수에 담을 수 없어서
+// 일반 문자열 배열을 join('\n')으로 이어 붙여 만든다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_BOX = `// 변수 만들기 — let이라는 낱말이 "상자 하나 만들어 줘"라는 신호예요
+let name = "지민"; // ① 선언(상자 만들기) + 할당(값 넣기)을 한 번에
+let age = 17; // 숫자도 담을 수 있어요
+
+console.log(name); // 지민 ← 이름을 부르면 안의 값이 나온다
+console.log(age); // 17
+
+// 선언과 할당을 나눠서 할 수도 있어요
+let school; // ② 먼저 빈 상자만 만들고 (선언)
+school = "미림마이스터고"; // ③ 나중에 값을 넣는다 (할당)
+console.log(school); // 미림마이스터고
+
+// 아직 값을 안 넣은 상자를 열면? '비어 있음'이라는 뜻의 undefined가 나와요
+let empty;
+console.log(empty); // undefined`;
+
+const CODE_NAMING = `// 이름 짓는 규칙 — 어기면 아예 실행이 안 돼요 (문법 오류)
+let score = 90; // O 영문 소문자로 시작
+let userName = "지민"; // O 두 단어면 두 번째 단어 첫 글자만 대문자 (camelCase)
+let _temp = 0; // O _ 와 $ 는 예외적으로 허용
+// let 2ndPlace = "은메달"; // X 숫자로 시작할 수 없다
+// let my-score = 90; // X 하이픈(-) 불가 — 빼기로 오해해요
+// let let = 5; // X let, const 같은 예약어는 이름으로 못 쓴다
+
+// 규칙만큼 중요한 건 '좋은 이름'의 감각이에요
+let a = 172.5; // △ 문법은 맞지만, a가 뭔지 아무도 몰라요
+let height = 172.5; // O 석 달 뒤의 나도, 옆자리 친구도 바로 이해
+
+// 학습 포인트: 컴퓨터는 a든 height든 상관없어요. 이름은 '사람'을 위한 거예요.`;
+
+const CODE_CONST = `// 변수(variable)라는 이름 그대로 — let 상자의 값은 언제든 바뀔 수 있어요
+let level = 1;
+console.log(level); // 1
+level = 2; // 새 값을 넣으면 이전 값은 사라진다 (덮어쓰기)
+console.log(level); // 2
+
+// 그런데 절대 바뀌면 안 되는 값도 있죠. 그럴 땐 const (constant, 상수)
+const birthYear = 2009; // "이 상자는 뚜껑을 접착제로 붙였다"
+// birthYear = 2010; // X 실행하면 오류! (TypeError: Assignment to constant)
+
+// 주의: const는 선언과 동시에 값을 넣어야 해요
+// const pi; // X 빈 const 상자는 만들 수 없다 (SyntaxError)
+const pi = 3.14; // O`;
+
+const CODE_STRING = `// 문자열(string) — 글자를 따옴표로 감싼 값. 따옴표가 곧 "이건 글자다"라는 표시예요
+let a = '작은따옴표'; // '' 작은따옴표
+let b = "큰따옴표"; // "" 큰따옴표 — 둘은 기능이 완전히 같아요. 하나로 통일만 하면 OK
+// 세 번째 종류인 백틱(키보드 1 왼쪽의 기호)은 바로 아래에서 따로 다뤄요. 특별하거든요.
+
+// + 로 문자열을 이어 붙일 수 있어요 (연결, concatenation)
+let name = "지민";
+let greeting = "안녕, " + name + "!";
+console.log(greeting); // 안녕, 지민!
+
+// 따옴표가 없으면 글자가 아니라 '변수 이름'으로 취급돼요
+console.log("name"); // name ← 글자 그대로
+console.log(name); // 지민 ← 상자 안의 값`;
+
+// 학습 포인트: 이 예제엔 백틱(`)과 ${ }가 코드 '내용물'로 들어가야 해서
+// 백틱 상수에 담을 수 없다. 일반 문자열 배열을 join으로 이어 붙여 우회한다.
+const CODE_TEMPLATE = [
+ '// 세 번째 따옴표, 백틱(`) — 문자열 안에 변수를 "구멍"처럼 끼워 넣을 수 있어요',
+ 'let name = "지민";',
+ 'let age = 17;',
+ '',
+ '// + 방식: 따옴표를 열었다 닫았다… 조금만 길어져도 정신없죠',
+ 'let intro1 = "저는 " + name + "이고, " + age + "살이에요.";',
+ '',
+ '// 템플릿 리터럴: 백틱으로 감싸고, 변수 자리는 ${변수} 라고 뚫어 둔다',
+ 'let intro2 = `저는 ${name}이고, ${age}살이에요.`;',
+ '',
+ 'console.log(intro1); // 저는 지민이고, 17살이에요.',
+ 'console.log(intro2); // 저는 지민이고, 17살이에요. ← 결과는 똑같아요',
+ '',
+ '// ${ } 구멍 안에는 계산식도 들어가요',
+ 'console.log(`내년엔 ${age + 1}살!`); // 내년엔 18살!',
+].join('\n');
+
+const CODE_NUMBER = `// 숫자(number) — 따옴표 없이 그냥 쓰면 숫자예요. 정수도 소수도 한 식구입니다
+let price = 1500; // 정수
+let rate = 0.5; // 소수
+let minus = -20; // 음수도 OK
+
+// 사칙연산: + - * / (곱하기는 x가 아니라 *, 나누기는 ÷가 아니라 /)
+console.log(3 + 4); // 7
+console.log(3 - 4); // -1
+console.log(3 * 4); // 12
+console.log(10 / 4); // 2.5 ← 나누어떨어지지 않으면 소수로
+
+// 다섯 번째 연산자 % — '나머지'를 구해요 (나눗셈 몫 말고 나머지!)
+console.log(10 % 3); // 1 (10 = 3 x 3 + 나머지 1)
+console.log(7 % 2); // 1 ← 2로 나눈 나머지가 1이면 홀수
+console.log(8 % 2); // 0 ← 나머지가 0이면 짝수. %는 홀짝 판별의 단골!
+
+// 연산 '결과'를 다시 변수에 담을 수 있어요 — 이게 프로그램이 자라는 방식
+let total = price * 3; // 4500
+total = total + 500; // 오른쪽 먼저 계산(4500+500) → 왼쪽 상자에 다시 담기
+console.log(total); // 5000`;
+
+const CODE_BOOLEAN = `// 불리언(boolean) — 값이 딱 두 개뿐인 자료형: true(참) / false(거짓)
+let isRaining = true;
+let isWeekend = false;
+
+// 불리언은 주로 '비교'에서 태어나요. 비교 연산자의 답은 항상 true 아니면 false!
+console.log(10 > 3); // true (10은 3보다 크다 → 참)
+console.log(10 < 3); // false (10은 3보다 작다 → 거짓)
+console.log(7 >= 7); // true (크거나 같다)
+console.log(7 === 7); // true (같다 — 등호 3개!)
+console.log(7 === 8); // false
+
+// === 는 '값'과 '자료형'이 모두 같아야 true예요
+console.log("7" === 7); // false ← 글자 "7"과 숫자 7은 다른 존재
+
+// 비교 결과도 값이니까, 당연히 상자에 담을 수 있죠
+let age = 17;
+let isTeen = age >= 13 && age <= 19; // && 는 "그리고" — 둘 다 참이어야 참
+console.log(isTeen); // true`;
+
+const CODE_TYPEOF = `// typeof — 값 앞에 붙이면 그 값의 자료형(type)을 알려 주는 돋보기
+console.log(typeof "안녕"); // "string" (문자열)
+console.log(typeof 42); // "number" (숫자)
+console.log(typeof true); // "boolean" (불리언)
+
+let mystery = "2026";
+console.log(typeof mystery); // "string" ← 따옴표에 싸였으니 숫자처럼 보여도 문자열!
+
+// 그래서 이런 일이 벌어져요 — 같은 + 인데 결과가 다르다
+console.log(1 + 1); // 2 숫자 + 숫자 → 덧셈
+console.log("1" + 1); // "11" 문자열이 끼면 + 는 '이어 붙이기'로 변신
+
+// 문자열을 숫자로 바꾸고 싶을 땐 Number()로 형을 변환해요
+console.log(Number("1") + 1); // 2 "1" → 1 로 바꾼 뒤 덧셈
+console.log(Number("2009")); // 2009`;
+
+// 학습 포인트: 실습 코드도 템플릿 리터럴(백틱)을 쓰므로 join 방식으로 정의한다.
+const CODE_PRACTICE = [
+ '// 실습: 이름과 출생연도를 물어봐서, 나이를 계산해 자기소개 문장을 만들어 보자',
+ 'const THIS_YEAR = 2026; // 올해 — 바뀌면 안 되니 const',
+ '',
+ '// prompt는 브라우저에 입력창을 띄우고, 입력한 내용을 "문자열로" 돌려줘요',
+ 'let name = prompt("이름이 뭐예요?");',
+ '',
+ '// 출생연도는 계산에 써야 하니 Number()로 숫자로 바꿔서 담는다 (섹션 6 복습!)',
+ 'let birthYear = Number(prompt("몇 년에 태어났어요? (예: 2009)"));',
+ '',
+ '// 연산 결과를 새 변수에 담기 (섹션 4 복습!)',
+ 'let age = THIS_YEAR - birthYear;',
+ '',
+ '// 템플릿 리터럴로 문장 조립 (섹션 3 복습!)',
+ 'let intro = `안녕하세요! 저는 ${name}이고, 올해 ${age}살이 돼요.`;',
+ '',
+ 'console.log(intro);',
+ '// 입력이 "지민", "2009"였다면 → 안녕하세요! 저는 지민이고, 올해 17살이 돼요.',
+].join('\n');
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 모든 프로그램은 결국 값을 어딘가에 담아 두고, 필요할 때 꺼내 쓰는 일의
+ 반복이에요. 그 "어딘가"가 바로 변수 — 이름표를 붙인 상자입니다.
+ 이 강좌에서는 상자를 만들고(let·const), 상자에 담기는 값의 세 가지 종류
+ (문자열·숫자·불리언)를 익힌 뒤, 여러분의 이름으로 자기소개 문장을 조립해 봅니다.
+ 코딩이 처음이어도 괜찮아요 — 여기가 첫걸음이니까요.
+
+
+ 예상 소요 60분
+ 실습: 자기소개 문장 조립
+ 선수 지식: 없음 — 진짜 첫 강좌
+
+
+
+ {/* 섹션 바로가기 — 학습 포인트: SECTIONS 배열을 map으로 돌려 반복을 없앤다 */}
+
+ 계산기를 떠올려 보세요. 3 + 4를 누르면 7이 나오지만,
+ 화면을 끄는 순간 그 7은 사라져요. 프로그램은 달라야 합니다. 계산한 값을{' '}
+ 어딘가에 보관해 뒀다가, 나중에 다시 꺼내 써야 하죠. 그 보관함이{' '}
+ 변수(variable)예요. 상자 하나를 만들고, 겉에 이름표를
+ 붙이고, 안에 값을 넣는다 — 이게 전부입니다.
+
+
+ 자바스크립트에서 상자를 만드는 낱말이 let이에요.
+ 상자를 만드는 일을 선언(declaration), 값을 넣는 일을{' '}
+ 할당(assignment)이라고 부릅니다. 여기서 딱 하나 조심할 것 —{' '}
+ =는 수학의 "같다"가 아니라{' '}
+ "오른쪽 값을 왼쪽 상자에 넣어라"라는 명령이에요.
+
+ {CODE_BOX}
+
+ 이름표는 아무렇게나 지을 수 없어요. 숫자로 시작하면 안 되고, 하이픈(-)이나 띄어쓰기도
+ 안 되고, let 같은 예약어도 안 됩니다. 그리고 규칙보다
+ 중요한 게 있어요 — 값의 정체가 드러나는 이름을 짓는 감각이요.
+
+ {CODE_NAMING}
+
+ 비유 변수는 이름표 붙인 사물함이에요. "3번째 줄 왼쪽에서 두 번째
+ 칸"이라고 외우는 대신 지민이의 사물함이라는 이름표만
+ 기억하면 되죠. 안에 뭘 넣든, 몇 번을 바꾸든, 이름표만 있으면 언제든 찾아갈 수
+ 있다 — 이게 변수의 존재 이유입니다.
+
+
+
+
+
+ 변수(변하는 수)라는 이름 그대로,{' '}
+ let으로 만든 상자의 값은 언제든 바꿀 수
+ 있어요. 게임 레벨이 오르고, 점수가 쌓이고, 남은 시간이 줄어드는 것 — 전부
+ 변수의 값이 바뀌는 일입니다. 새 값을 넣으면 이전 값은 흔적 없이
+ 사라진다(덮어쓰기)는 것만 기억하세요.
+
+
+ 그런데 세상엔 바뀌면 큰일 나는 값도 있어요. 태어난 연도, 원주율,
+ 우리 서비스의 이름 같은 것들이요. 이런 값은 const
+ (constant, 상수)로 만듭니다. 한번 값을 넣으면 바꾸려는 순간 오류를
+ 내면서 프로그램이 멈춰요 — 실수를 컴퓨터가 대신 막아 주는 거죠.
+
+ {CODE_CONST}
+
+ let과 const, 뭘 고를까 실무 개발자들의 습관은 의외로 단순해요.{' '}
+ 일단 전부 const로 쓰고, 값을 정말 바꿔야 하는 것만 let으로 바꿉니다.
+ "이 값은 안 변해요"라고 코드에 적어 두는 셈이라, 나중에 읽는 사람(대부분 미래의 나)이
+ 신경 쓸 게 줄어들거든요. 이 강좌의 예제는 배우는 단계라 let을 많이 쓰지만, 감각은
+ 지금부터 익혀 두세요.
+
+
+
+
+
+ 이제 상자에 담기는 값의 종류(자료형)를 하나씩 봅시다. 첫 번째는{' '}
+ 문자열(string) — 글자들이 실에 꿰인 듯 줄지어 선{' '}
+ 값이에요. 이름, 주소, 채팅 메시지, 지금 읽는 이 문장까지, 프로그램 속 모든 글자가
+ 문자열입니다. 만드는 법은 간단해요 — 따옴표로 감싸면 끝.
+
+ {CODE_STRING}
+
+ 작은따옴표('')와 큰따옴표("")는
+ 완전히 같은 일을 해요. 그런데 세 번째 따옴표, 백틱
+ (`` — 키보드 숫자 1 왼쪽의 키)은 특별합니다. 문자열
+ 안에 변수를 끼워 넣는 구멍을 뚫을 수 있거든요. 이걸{' '}
+ 템플릿 리터럴이라고 불러요.
+
+ {CODE_TEMPLATE}
+
+ + 와 템플릿 리터럴, 뭘 쓸까 둘 다 결과는 같아요. 다만 변수가 두세 개만 넘어가면{' '}
+ + 방식은 따옴표 지옥이 됩니다. 요즘 코드는 대부분 템플릿 리터럴을 써요 — 문장{' '}
+ 틀(템플릿)을 먼저 만들고 빈칸만 채우는 방식이라 읽기도 훨씬 편하거든요.
+ 섹션 7의 실습에서 직접 써 봅니다.
+
+
+
+
+
+ 두 번째 자료형은 숫자(number)예요. 따옴표 없이 쓰면
+ 숫자입니다. 다른 언어들은 정수와 소수를 다른 자료형으로 나누기도 하는데,
+ 자바스크립트는 17도 3.14도
+ 똑같이 number 한 식구예요. 덧셈 뺄셈은 그대로인데, 곱하기는{' '}
+ *, 나누기는 /를 쓴다는 것만
+ 새로 기억하면 됩니다.
+
+ {CODE_NUMBER}
+
+ 낯선 친구는 %일 거예요. 퍼센트가 아니라{' '}
+ 나머지 연산자입니다. 10 % 3은 "10을 3으로
+ 나눈 나머지", 즉 1이에요. "이게 어디에 쓰여?" 싶겠지만 —{' '}
+ 어떤 수를 2로 나눈 나머지가 0이면 짝수, 1이면 홀수. 다음 강좌에서
+ 조건문을 배우면 이 아이디어로 홀짝 판별기를 만들게 됩니다.
+
+
+ 계산 결과를 다시 상자에total = total + 500이
+ 이상해 보였다면 정상이에요. 수학이라면 말이 안 되는 식이죠. 하지만{' '}
+ =는 "넣어라"라는 명령(섹션 1)이었죠 —{' '}
+ 오른쪽을 먼저 계산하고(현재 total인 4500 + 500 = 5000),
+ 그 결과를 왼쪽 상자에 다시 넣는 겁니다. 점수 누적, 좋아요 카운트,
+ 게임 경험치… 프로그램의 거의 모든 '쌓이는 값'이 이 한 줄의 반복이에요.
+
+
+
+
+
+ 세 번째 자료형은 좀 독특해요. 문자열은 무한히 많고 숫자도 무한히 많은데,{' '}
+ 불리언(boolean)은 값이 딱 두 개뿐입니다 —{' '}
+ true(참)와 false(거짓).
+ "지금 비가 오나?", "로그인했나?", "정답인가?" — 예/아니오로 답할 수 있는 모든 질문의
+ 답을 담는 자료형이에요.
+
+
+ 불리언은 직접 쓰기보다 비교의 결과로 태어나는 경우가 많아요.{' '}
+ {'>'}(크다), {'<'}(작다),{' '}
+ ===(같다) 같은 비교 연산자는 양쪽 값을
+ 견줘 보고, 그 답을 반드시 true 아니면 false로 내놓습니다.
+
+ {CODE_BOOLEAN}
+
+ 등호가 왜 3개?= 하나는 이미 "상자에 넣어라"(할당)로
+ 쓰이고 있어요. 그래서 "같은가?"를 물을 땐 다른 기호가 필요했고, 자바스크립트에선{' '}
+ ===를 씁니다. 등호 2개짜리 ==도
+ 있긴 한데, 형이 달라도 대충 같다고 해 버리는 느슨한 녀석이라 요즘은 잘 안 써요.{' '}
+ "같은지 물을 땐 무조건 3개"로 외워 두면 안전합니다.
+
+
+ 다음 강좌 예고 "불리언이 왜 프로그램의 두뇌예요?" — 다음 강좌의 조건문{' '}
+ if가 바로 불리언을 보고 갈림길을 고르는{' '}
+ 문법이거든요. "나이가 19 이상이면(true) 이쪽, 아니면
+ (false) 저쪽." 오늘 배운 비교 연산자가 그대로 조건문의 재료가 됩니다.
+
+
+
+
+
+ "문자열이든 숫자든 그냥 값이면 됐지, 종류가 왜 중요해요?" 좋은 질문이에요. 답부터
+ 말하면 — 컴퓨터는 값의 종류(자료형)를 보고 행동을 결정하기 때문입니다.
+ 겉보기엔 같은 +인데, 숫자 사이에선 덧셈을
+ 하고 문자열이 끼어 있으면 이어 붙이기를 해요.
+
+
+ 값의 정체가 궁금할 땐 typeof라는 돋보기를 대 보세요.
+ 값 앞에 붙이면 그 값의 자료형을 문자열로 알려 줍니다.
+
+ {CODE_TYPEOF}
+
+ "1" + 1이 "11"이 되는 이유,
+ 이제 보이시죠? 왼쪽의 "1"은 따옴표에 싸인{' '}
+ 문자열이에요. 문자열이 낀 +는 이어
+ 붙이기로 동작하니까, 숫자 1까지 글자 취급해서 "11"을
+ 만듭니다. 오류도 안 내고 조용히 이상한 결과를 내놓는다는 게 진짜
+ 함정이에요.
+
+
+ 이 함정, 실전에서 꼭 만난다 섹션 7에서 쓸 prompt는
+ 사용자가 뭘 입력하든 항상 문자열로 돌려줘요. "2009"를 입력해도
+ 숫자 2009가 아니라 문자열 "2009"라는 뜻이죠. 그대로
+ 계산에 쓰면 사고가 나니까, Number()로{' '}
+ 형을 변환해서 담아야 합니다. 헷갈리면{' '}
+ typeof로 정체를 확인하는 습관을 들이세요.
+
+
+
+
+
+ 이제 손을 움직일 차례예요. 목표: 이름과 출생연도를 입력받아, 나이를 계산해서
+ 자기소개 문장을 출력하는 프로그램. 오늘 배운 재료가 전부 들어갑니다 —
+ 변수(1), const(2), 템플릿 리터럴(3), 뺄셈(4), 그리고 형 변환(6)까지요.
+
+
+ 새 등장인물은 prompt 하나뿐이에요. 브라우저에{' '}
+ 입력창을 띄우고, 사용자가 적은 내용을 문자열로 돌려주는 함수입니다.
+ 브라우저에서 F12를 눌러 콘솔(Console) 탭을 열고, 아래
+ 코드를 한 줄씩 입력해 보세요.
+
+ {CODE_PRACTICE}
+
+ 실행됐다면, 여기서 멈추지 말고 변수를 하나만 바꿔 보세요.{' '}
+ intro 문장은 그대로 두고{' '}
+ name에 친구 이름을 넣으면? 출생연도를 바꾸면? —{' '}
+ 변수 하나를 바꿨을 뿐인데 문장 전체가 따라 바뀌는 이 경험이 오늘의
+ 핵심입니다. 값과 틀을 분리해 두면, 값만 갈아 끼워서 무한히 재사용할 수 있다 — 모든
+ 프로그램이 이 원리로 만들어져요.
+
+
+ 도전 과제 ① THIS_YEAR - birthYear + 1로 바꿔서
+ '한국식 세는나이'를 계산해 보세요. ② age {'>'}= 20의
+ 결과를 isAdult 변수에 담아 함께 출력해 보세요 — 섹션 5의
+ 불리언이 어떻게 태어나는지 눈으로 확인할 수 있어요. ③ 일부러{' '}
+ Number()를 빼고 실행해 보세요. 나이가 이상한 값이 되는
+ 걸 보면, 섹션 6의 함정이 왜 무서운지 몸으로 알게 됩니다.
+
+
+
+
+
+
📦 여기까지 왔다면
+
+ 축하해요 — 프로그래밍의 가장 작은 부품을 손에 넣었습니다. 값을 상자에 담고(변수),
+ 바뀌면 안 되는 건 봉인하고(const), 글자·숫자·참거짓이라는 세 가지 재료를 구분하고
+ (자료형), 형이 다르면 결과도 달라진다는 함정까지 겪어 봤어요. 그런데 지금 우리
+ 프로그램은 위에서 아래로 한 길로만 흘러가죠. "나이가 19 이상이면
+ 이렇게, 아니면 저렇게" — 상황을 보고 컴퓨터가 스스로 길을 고르게{' '}
+ 만들려면, 오늘 배운 불리언이 핵심 열쇠가 됩니다. 다음{' '}
+ 조건문: 컴퓨터가 판단하게 하기{' '}
+ 강좌에서 이어서 배워 봅시다.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv1WhatIsAProgramPage.jsx b/frontend/src/pages/levels/Lv1WhatIsAProgramPage.jsx
new file mode 100644
index 0000000..2735a46
--- /dev/null
+++ b/frontend/src/pages/levels/Lv1WhatIsAProgramPage.jsx
@@ -0,0 +1,417 @@
+// 이 파일이 하는 일: "프로그램이란 무엇일까?" 코스 — 코딩을 한 번도 안 해 본 사람이
+// 코드를 한 줄도 쓰기 전에, '컴퓨터에게 일을 시킨다'는 게 무슨 뜻인지 몸으로 이해하도록
+// 7개 섹션으로 안내하는 정적 학습 페이지. 프레이밍은 "컴퓨터는 시킨 대로만 하는 기계" —
+// 정확한 지시(=프로그램)의 감각을 일상 사례(알람·엘리베이터·라면 레시피)로 먼저 세운다.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_ROBOT = `상황: 로봇에게 "목이 마르니까 물 좀 줘"라고 말했다.
+
+사람 친구라면? 컴퓨터(로봇)라면?
+───────────── ─────────────────
+"응, 잠깐만~" ...아무 일도 일어나지 않음.
+컵을 찾고, 물을 따르고,
+건네주기까지 알아서 함. 왜? '물을 준다'가 무슨 뜻인지 모름.
+ 어디 있는 컵을, 어느 정수기에서,
+ 얼마나 따라서, 누구에게 줄지
+ 하나도 정해 주지 않았기 때문.
+
+컴퓨터가 알아듣는 지시는 이런 모양이어야 해요:
+1. 부엌으로 이동한다
+2. 찬장 두 번째 칸을 연다
+3. 컵을 하나 꺼낸다
+4. 정수기 앞으로 이동한다
+5. 컵을 냉수 버튼 아래에 놓는다
+6. 버튼을 3초간 누른다
+7. 컵을 들고 사용자에게 이동한다
+8. 컵을 건넨다`;
+
+const CODE_IPO = `모든 프로그램의 공통 뼈대: 입력 → 처리 → 출력
+
+알람 앱
+ 입력: 사용자가 맞춘 시각(07:00), 지금 시각
+ 처리: "지금 시각이 07:00이 되었나?"를 계속 확인
+ 출력: 되었으면 → 소리 울리기, 화면 켜기
+
+엘리베이터
+ 입력: 눌린 버튼(5층), 현재 위치(1층)
+ 처리: 어느 방향으로 몇 층 움직일지 계산
+ 출력: 모터를 위로 돌리기, 5층에서 문 열기
+
+신호등
+ 입력: 타이머(30초가 지났나?), 보행자 버튼
+ 처리: 다음 신호가 무엇인지 정해진 규칙대로 결정
+ 출력: 빨간불 끄고 초록불 켜기
+
+패턴이 보이나요? 무엇을 받아서(입력) → 무엇을 따져서(처리)
+→ 무엇을 내보낸다(출력). 여러분이 오늘 아침에 쓴 앱들도 전부 이 모양이에요.`;
+
+const CODE_RAMEN = `라면 끓이기 프로그램 v1.0
+
+1. 냄비에 물 550ml를 넣는다
+2. 물을 불에 올려 끓인다
+3. 물이 끓으면 면과 분말수프를 넣는다
+4. 4분 30초 동안 더 끓인다
+5. 불을 끄고 그릇에 옮겨 담는다
+
+── 순서를 바꾸면? ──
+3번을 1번보다 먼저 하면 → 마른 냄비에 면이 눌어붙음 (실패)
+4번을 건너뛰면 → 설익은 면 (실패)
+2번과 5번을 바꾸면 → 차가운 물에 불은 면 (대실패)
+
+같은 5개의 단계인데, '순서'가 다르면 완전히 다른 결과가 나와요.
+프로그램도 똑같습니다. 무엇을 하느냐만큼, 어떤 순서로 하느냐가 결과를 정해요.`;
+
+const CODE_AMBIGUOUS = `사람의 말은 이렇게나 모호해요:
+
+"적당히 익혀 줘" → 적당히가 몇 분인데?
+"물 좀 넉넉히" → 넉넉히가 몇 ml인데?
+"거의 다 되면 불 꺼" → '거의 다'를 어떻게 판단하는데?
+
+사람끼리는 눈치와 경험으로 빈칸을 채우지만,
+컴퓨터는 빈칸을 채우는 능력이 0이에요. 그래서 이렇게 써 줘야 해요:
+
+"550ml의 물을 100도까지 가열한 뒤 270초간 유지한다"
+
+그리고 컴퓨터의 심장(CPU)이 진짜로 알아듣는 말은 딱 두 글자:
+
+01001000 01101001 ← 컴퓨터의 진짜 언어(0과 1)
+
+이 사이를 이어 주는 게 '프로그래밍 언어'입니다:
+
+사람의 생각 → 프로그래밍 언어(Python, Java...) → 0과 1 → 실행
+ 사람도 읽을 수 있고 번역기가
+ 컴퓨터도 번역할 수 있는 중간 언어 바꿔 줌`;
+
+const CODE_RUN = `# 진짜 파이썬(Python) 코드 — 아직 외울 필요 없어요. 구경만 하세요.
+
+print("안녕하세요")
+print("저는 컴퓨터예요")
+print("시킨 것만, 시킨 순서대로 합니다")
+
+── 실행하면 화면에 이렇게 나와요 ──
+안녕하세요
+저는 컴퓨터예요
+시킨 것만, 시킨 순서대로 합니다
+
+포인트 ①: 위에서 아래로, 한 줄씩, 쓴 순서 그대로 실행됐어요.
+포인트 ②: 두 번째 줄과 세 번째 줄의 순서를 바꾸면?
+ → 출력도 그대로 바뀝니다. 컴퓨터는 절대 알아서 안 바꿔요.`;
+
+const CODE_CYCLE = `개발자가 하루 종일 도는 사이클:
+
+ ① 작성한다 → ② 실행한다 → ③ 결과를 본다
+ ↑ │
+ └──────── ④ 고친다 ←────────────────┘
+ (생각과 다르면)
+
+처음부터 완벽한 코드를 쓰는 사람은 없어요.
+프로게이머가 연습 경기를 돌려 보며 실력을 다듬듯,
+개발자는 이 사이클을 빠르게 돌면서 프로그램을 완성해 갑니다.
+'틀리는 것'은 실패가 아니라 사이클의 정상적인 한 칸이에요.`;
+
+const CODE_DRAW = `실습: 사람 프로그래밍 — '그림 그리기 로봇' 놀이
+
+준비물: 종이 2장, 펜 2개, 친구 1명
+규칙: 지시하는 사람은 자기 그림을 보여 줄 수 없고,
+ 그리는 사람(로봇)은 질문할 수 없다. 시킨 것만 그대로 그린다.
+
+나쁜 지시(모호함): 좋은 지시(정확함):
+"집을 그려" "종이 한가운데에 한 변이 5cm인
+"지붕도 그리고" 정사각형을 그린다"
+"창문은 알아서" "정사각형의 윗변을 밑변으로 하는
+ 삼각형을 그린다. 높이는 3cm"
+ "정사각형 안, 왼쪽 아래에
+ 1cm x 2cm 직사각형을 그린다"
+
+끝나고 두 그림을 비교해 보세요.
+모호한 지시로 그린 그림은 머릿속 그림과 전혀 다를 거예요.
+컴퓨터에게 일을 시킨다는 건, 매 순간 오른쪽처럼 말하는 일입니다.`;
+
+const CODE_HOMEWORK = `과제: '아침에 일어나 학교 가기'를 컴퓨터 수준으로 쪼개기 (15단계 이상)
+
+이렇게 쓰면 안 돼요(사람용): 이렇게 쪼개기 시작하세요(컴퓨터용):
+1. 일어난다 1. 알람 소리가 들리면 눈을 뜬다
+2. 씻는다 2. 이불을 걷는다
+3. 학교에 간다 3. 침대에서 두 발을 바닥에 내린다
+ 4. 일어서서 화장실로 이동한다
+ (컴퓨터: "일어난다가 5. 수도꼭지를 왼쪽으로 돌려 물을 튼다
+ 뭔데요...?") 6. 손에 물을 묻힌다
+ ... (여러분이 이어서 15단계 이상!)
+
+채점 기준은 하나예요: "이대로만 움직이는 로봇이 정말 학교에 도착할까?"
+'양치한다' 같은 뭉툭한 단계가 보이면 더 쪼개 보세요.
+칫솔은 어디서? 치약은 얼마나? 몇 초 동안? — 그게 프로그래머의 눈입니다.`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 코딩을 배우러 왔는데, 오늘은 코드를 한 줄도 쓰지 않을 거예요. 대신
+ 그보다 훨씬 중요한 것부터 시작합니다 — 컴퓨터에게 '일을 시킨다'는 게 대체 무슨
+ 뜻인지를 몸으로 이해하는 것. 이 감각이 잡히면, 앞으로 배울 모든
+ 프로그래밍 언어가 "아, 결국 이 얘기구나" 하고 읽히기 시작해요.
+
+
+ 예상 소요 40분
+ 실습: 등교 과정 15단계 쪼개기
+ 선수 지식: 없음 (진짜로!)
+
+
+
+ {/* 섹션 바로가기 */}
+
+
+
+
+ 영화 속 AI 때문에 컴퓨터가 엄청 똑똑하다고 느껴지지만, 진실은 반대예요.{' '}
+ 컴퓨터는 스스로 판단하는 능력이 전혀 없습니다. 눈치도 없고, 상식도
+ 없고, "대충 알아서"라는 말을 세상에서 제일 못 알아듣는 기계죠. 컴퓨터가 잘하는 건 딱
+ 두 가지 — 시킨 일을 엄청나게 빠르게, 그리고{' '}
+ 한 치의 오차 없이 그대로 하는 것뿐이에요.
+
+ {CODE_ROBOT}
+
+ 위 오른쪽처럼, 사람이라면 한마디로 끝날 부탁을 컴퓨터에게는{' '}
+ 여덟 단계로 쪼개서, 하나하나 정확하게 말해 줘야 해요. 그리고 바로 이{' '}
+ '정확한 지시의 묶음'이 프로그램이고, 그걸 만드는 일이{' '}
+ 프로그래밍입니다. 어렵고 신비한 게 아니라, "바보지만 성실한 기계에게
+ 일을 아주 자세히 시키는 기술"이에요.
+
+
+ 뒤집어 생각하면 컴퓨터가 바보라는 건 좋은 소식이기도 해요. 컴퓨터는{' '}
+ 절대 제멋대로 하지 않으니까요. 프로그램이 이상하게 돌아간다면 범인은
+ 늘 하나 — 우리가 내린 지시 어딘가에 있어요. 그래서 프로그래밍은 '기계와의 싸움'이
+ 아니라 '내 지시를 점점 정확하게 다듬는 연습'입니다.
+
+
+
+
+
+ "프로그램"이라고 하면 컴퓨터 화면 속 뭔가를 떠올리기 쉽지만, 사실 여러분의 하루는
+ 이미 프로그램으로 가득 차 있어요. 아침에 깨워 준 알람 앱, 등굣길에 탄{' '}
+ 엘리베이터, 건널목의 신호등 — 전부 누군가가 미리
+ 써 둔 '정확한 지시의 묶음'이 돌아가고 있는 겁니다.
+
+
+ 그리고 이 프로그램들을 뜯어 보면 놀랍도록 닮은 패턴이 하나 나와요. 바로{' '}
+ 입력 → 처리 → 출력입니다. 무언가를 받아서, 규칙대로 따져 보고,
+ 결과를 내보낸다 — 세상의 거의 모든 프로그램이 이 세 박자로 움직여요.
+
+ {CODE_IPO}
+
+ 유튜브도 마찬가지예요. 검색어를 입력하면, 수많은 영상 중 뭘 보여 줄지{' '}
+ 처리(계산)하고, 목록을 화면에 출력하죠. 게임에서
+ 버튼을 누르면(입력) 캐릭터가 점프할 위치를 계산해서(처리) 화면에 그려 냅니다(출력).
+ 앞으로 어떤 프로그램을 보든 "여기서 입력은 뭐고, 출력은 뭐지?"라고 물어보는 습관을
+ 들여 보세요.
+
+
+ 오늘의 미션 집에 가는 길에 프로그램 세 개를 찾아 보세요. 버스 도착 안내 전광판,
+ 편의점 자동문, 교통카드 단말기... 각각의 입력·처리·출력이 뭔지 한 줄씩
+ 적어 보면, 세상이 프로그램으로 보이기 시작할 거예요. 그 순간이 개발자의 눈이 떠지는
+ 순간입니다.
+
+
+
+
+
+ 프로그램의 조상님을 하나 꼽으라면 요리 레시피예요. "무엇을, 어떤
+ 순서로, 얼마나" — 정해진 단계를 순서대로 따라가면 누구나 같은 결과를 얻는 절차.
+ 이렇게 문제를 해결하는 단계들의 순서 있는 묶음을 멋진 말로{' '}
+ 알고리즘(algorithm)이라고 부릅니다. 라면 봉지 뒷면에 이미 하나
+ 인쇄되어 있죠.
+
+ {CODE_RAMEN}
+
+ 여기서 두 가지 감각을 가져가면 됩니다. 첫째, 순서가 결과를 바꾼다.{' '}
+ 같은 다섯 단계라도 순서를 섞으면 라면이 아니라 참사가 나와요. 컴퓨터는 우리가 쓴
+ 순서를 절대 알아서 고쳐 주지 않으니, 순서를 정하는 건 온전히 프로그래머의 몫입니다.
+ 둘째, 한 단계만 빠져도 실패한다. "물이 끓으면"을 빼먹으면 찬물에
+ 면을 넣게 되죠. 프로그램의 오류(버그)도 대부분 이런 '빠진 단계, 뒤집힌 순서'에서
+ 나옵니다.
+
+
+ 알고리즘은 코드가 아니에요 알고리즘은 '문제 푸는 절차' 그 자체라서, 한국어로
+ 쓰든 그림으로 그리든 상관없어요. 코드는 그 절차를 컴퓨터가 알아듣는 언어로 옮긴
+ 결과물일 뿐이죠. 그래서 좋은 개발자는 코드를 치기 전에 절차부터{' '}
+ 종이에 정리합니다 — 오늘 여러분이 실습에서 할 바로 그 일이요.
+
+
+
+
+
+ "그럼 그냥 한국어로 시키면 안 돼요?" — 아주 좋은 질문이에요. 안 되는 이유는 사람의
+ 말이 모호함투성이이기 때문입니다. 우리는 눈치와 경험으로 빈칸을
+ 채우며 대화하지만, 컴퓨터는 모호함을 단 1도 견디지 못해요.
+
+ {CODE_AMBIGUOUS}
+
+ 게다가 컴퓨터의 심장인 CPU가 실제로 알아듣는 말은 0과 1의 나열이
+ 전부예요. 전기가 흐른다(1), 안 흐른다(0) — 이 두 가지 신호만으로 모든 걸 처리하죠.
+ 그렇다고 사람이 0과 1로 직접 지시를 쓰는 건 너무나 고통스럽기 때문에, 그 사이에{' '}
+ 프로그래밍 언어라는 다리를 놓았습니다.
+
+
+ 그러니까 프로그래밍 언어는 사람과 기계 사이의 번역기예요. 사람이
+ 읽고 쓸 수 있을 만큼은 사람의 말을 닮았고, 기계가 0과 1로 정확히 바꿀 수 있을 만큼은
+ 엄격한 — 딱 중간의 언어. Python,{' '}
+ Java, JavaScript 같은
+ 이름들을 들어 봤다면, 전부 이 '중간 언어'의 종류들입니다.
+
+
+ 언어가 여러 개인 이유 요리 도구에 칼·가위·필러가 따로 있듯, 언어마다 잘하는
+ 일이 달라요. 웹 화면엔 JavaScript, 데이터·AI엔 Python, 큰 서버엔 Java가 자주
+ 쓰입니다. 하지만 걱정 마세요 — '정확한 절차를 순서대로 쓴다'는 뼈대는 모든
+ 언어가 똑같아서, 하나를 제대로 배우면 두 번째부터는 훨씬 쉬워져요.
+
+
+
+
+
+ 이제 진짜 코드를 구경해 봅시다. 아래는 Python이라는 언어로 쓴 세
+ 줄짜리 프로그램이에요. print는 "괄호 안의 내용을
+ 화면에 출력하라"는 명령입니다.
+
+ {CODE_RUN}
+
+ 여기서 꼭 가져가야 할 감각은 하나예요. 코드는 위에서 아래로, 한 줄씩, 쓴
+ 순서 그대로 실행된다. 섹션 3의 라면 레시피와 완전히 같은 원리죠. 1번 줄이
+ 끝나야 2번 줄이 실행되고, 컴퓨터는 중간에 딴생각을 하거나 순서를 바꾸지 않습니다.
+
+
+ 그리고 개발은 '한 번 쓰고 끝'이 아니라 사이클이에요. 쓰고, 돌려 보고,
+ 결과가 생각과 다르면 고치고, 다시 돌려 보고 — 이 반복 자체가 개발이라는 일의
+ 본모습입니다.
+
+ {CODE_CYCLE}
+
+ 에러는 선생님이에요 코드를 돌렸는데 빨간 글씨(에러)가 나오면 심장이 철렁하죠.
+ 하지만 에러 메시지는 컴퓨터가 주는 가장 친절한 힌트예요 — "몇 번째 줄의, 어떤 지시를,
+ 왜 못 알아들었는지"를 알려 주거든요. 앞으로 에러를 만나면 겁내지 말고{' '}
+ 읽어 보는 습관부터 들이세요. 그게 실력이 느는 가장 빠른 길입니다.
+
+
+
+
+
+ 개발자를 '하루 종일 코드만 치는 사람'으로 상상하기 쉽지만, 본질은 다릅니다.
+ 개발자는 문제를 코드로 푸는 사람이에요. "버스가 언제 올지 몰라
+ 불편하다"는 문제를 도착 안내 앱으로, "손으로 계산하니 실수가 난다"는 문제를 자동
+ 계산 프로그램으로 — 세상의 불편함을 찾아 정확한 절차로 바꿔 내는
+ 직업이죠.
+
+
+ 푸는 문제의 종류에 따라 분야도 다양하게 갈라져요. 웹사이트를 만드는{' '}
+ 웹 개발자, 스마트폰 앱을 만드는 앱 개발자, 게임 속
+ 세계를 짓는 게임 개발자, 데이터에서 답을 찾고 인공지능을 만드는{' '}
+ AI·데이터 개발자, 그리고 이 모든 서비스가 돌아가는 서버를 책임지는{' '}
+ 서버(백엔드) 개발자까지. 쓰는 언어와 도구는 달라도, 전원의
+ 공통분모는 오늘 배운 것 — 문제를 컴퓨터가 알아들을 만큼 정확한 단계로 쪼개는
+ 능력입니다.
+
+
+ 그래서 오늘 이 40분이 그냥 워밍업이 아니에요. "컴퓨터는 시킨 대로만 한다",
+ "입력→처리→출력", "순서가 결과를 정한다", "모호함을 정확함으로 바꾼다" — 이 네
+ 문장은 여러분이 어떤 분야의 개발자가 되든 끝까지 들고 가는 출발점
+ 입니다.
+
+
+ 코딩 = 개발의 일부 실제 개발자의 시간은 코드 치기 반, 나머지 반은 '문제를
+ 이해하고 절차를 설계하고 동료와 맞추는 일'이에요. 그래서 오늘처럼 코드 없이 절차를
+ 고민하는 연습이, 타자 연습보다 백 배 중요한 기초 체력이 됩니다.
+
+
+
+
+
+ 이론은 여기까지! 이제 컴퓨터가 요구하는 '정확함'이 어떤 느낌인지 직접 겪어 볼
+ 차례예요. 준비물은 컴퓨터가 아니라 종이와 펜, 그리고 친구 한 명
+ 입니다. 친구가 컴퓨터(로봇) 역할을 맡아요.
+
+ {CODE_DRAW}
+
+ 이 놀이의 핵심 규칙은 "로봇은 질문할 수 없다"예요. 진짜 컴퓨터도
+ 그렇거든요. 지시가 모호하면 되묻는 대신, 이상한 결과를 내거나 멈춰 버립니다.
+ "창문은 알아서"라고 말한 순간 그림이 산으로 가는 경험 — 그게 바로 전 세계 개발자들이
+ 매일 겪는 버그의 정체예요.
+
+
+ 그리고 오늘의 본 과제. '아침에 일어나 학교 가기'를 컴퓨터가 이해할 수준으로
+ 15단계 이상 쪼개서 종이에 써 보세요. 이 과정에는 이름도 있어요 — 큰 문제를
+ 컴퓨터가 처리할 수 있는 작은 단계로 나누는 것, 개발자들이 매일 하는 일의 심장부입니다.
+
+ {CODE_HOMEWORK}
+
+ 잘 쪼갰는지 확인하는 법 다 썼다면 옆 사람에게 종이를 주고 "여기 적힌 것만,
+ 적힌 순서대로" 몸으로 따라 해 달라고 부탁해 보세요. 중간에 "어... 이건 어떻게
+ 하라는 거야?"가 나오는 지점이 모호함이 숨어 있던 자리입니다. 그
+ 지점을 더 쪼개서 고치는 순간, 여러분은 이미 디버깅(고치기)을 한 거예요.
+
+
+
+
+
+
💡 여기까지 왔다면
+
+ 축하해요 — 코드를 한 줄도 쓰지 않고, 프로그래밍의 심장을 이해했습니다.{' '}
+ 컴퓨터는 시킨 것만 시킨 그대로 하는 기계이고, 프로그램은{' '}
+ 정확한 지시의 순서 있는 묶음이며, 프로그래밍 언어는 사람과 0과 1
+ 사이의 번역기라는 것. 그리고 등교 과정을 15단계로 쪼개면서, 큰
+ 문제를 작은 단계로 나누는 개발자의 눈을 처음 떠 봤어요. 다음 코스에서는 이 '쪼개는
+ 눈'을 본격적으로 훈련합니다 — 패턴을 찾고, 불필요한 것을 걷어 내고, 절차를
+ 설계하는 생각법이요. 컴퓨터처럼
+ 생각하기: 컴퓨팅 사고 코스에서 이어서 만나요.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv2ArrayDataWranglingPage.jsx b/frontend/src/pages/levels/Lv2ArrayDataWranglingPage.jsx
new file mode 100644
index 0000000..31b6dd8
--- /dev/null
+++ b/frontend/src/pages/levels/Lv2ArrayDataWranglingPage.jsx
@@ -0,0 +1,490 @@
+// 이 파일이 하는 일: "배열 메서드로 데이터 요리하기" 코스 — for문으로 데이터를 다루던
+// 학생이 map·filter·reduce·sort·flatMap이라는 '주방 칼'을 손에 쥐고, API 응답을 화면에
+// 딱 맞는 모양으로 썰고 볶는 실전 감각을 익히는 7개 섹션의 정적 학습 페이지.
+// 최종 목표는 실습 — 주문 내역 JSON 200건에서 '고객별 총 구매액 TOP 5'와 '월별 매출
+// 집계'를 배열 메서드만으로 만들어 내는 것이다.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_FOR_VS_METHOD = `// 같은 요리: "만원 이상 주문의 고객 이름만 뽑기"
+
+// ① for문 — '어떻게' 할지를 한 줄씩 지시하는 명령형
+const names = [];
+for (let i = 0; i < orders.length; i++) {
+ if (orders[i].amount >= 10000) {
+ names.push(orders[i].customer);
+ }
+}
+
+// ② 배열 메서드 — '무엇을' 원하는지만 말하는 선언형
+const names2 = orders
+ .filter((o) => o.amount >= 10000) // 만원 이상만 골라서
+ .map((o) => o.customer); // 고객 이름으로 바꿔 줘
+
+// 읽는 사람은 filter·map이라는 '동사'만 보고도
+// "골라서 → 모양을 바꾼다"는 의도를 3초 만에 파악합니다.
+// for문은 i, push, 조건문을 전부 따라 읽어야 같은 결론에 닿아요.`;
+
+const CODE_MAP_SHAPE = `// 서버가 주는 모양 그대로의 API 응답 (스네이크 케이스, 원 단위 숫자)
+const res = [
+ { order_id: 1, customer_name: '김수습', total_price: 42000 },
+ { order_id: 2, customer_name: '이신입', total_price: 18000 },
+];
+
+// 화면이 원하는 모양으로 '변환' — map은 요소마다 함수를 적용한 새 배열
+const rows = res.map((o) => ({
+ id: o.order_id,
+ name: o.customer_name,
+ price: o.total_price.toLocaleString() + '원', // 42000 → "42,000원"
+}));
+
+// 원본 res는 그대로 남아 있어요. map은 항상 '새 배열'을 돌려줍니다.
+// 서버 모양(res)과 화면 모양(rows)을 분리해 두면 서로를 망가뜨리지 않죠.`;
+
+const CODE_MAP_MISTAKE = `// 흔한 실수 ①: 중괄호 블록을 열었는데 return을 빼먹음
+const bad = res.map((o) => { o.order_id }); // [undefined, undefined] !
+const ok1 = res.map((o) => { return o.order_id }); // [1, 2]
+const ok2 = res.map((o) => o.order_id); // 한 줄이면 return 생략 가능
+
+// 흔한 실수 ②: 객체를 바로 돌려줄 땐 소괄호로 감싸기
+const ok3 = res.map((o) => ({ id: o.order_id })); // ( ) 없으면 블록으로 오해받음
+
+// 두 번째 인자 index — 순위표 만들 때 요긴
+const ranked = ['김수습', '이신입'].map((n, i) => (i + 1) + '위 ' + n);
+// ['1위 김수습', '2위 이신입']`;
+
+const CODE_FILTER_FIND = `// filter: 조건에 맞는 '전부' — 결과는 항상 배열 (0개여도 빈 배열)
+const paid = orders.filter((o) => o.status === 'PAID');
+
+// find: 조건에 맞는 '첫 하나' — 결과는 요소 하나 (없으면 undefined)
+const target = orders.find((o) => o.order_id === 17);
+
+// some: 하나라도 있나요? / every: 전부 그런가요? — 결과는 true/false
+const hasRefund = orders.some((o) => o.status === 'REFUND');
+const allPaid = orders.every((o) => o.status === 'PAID');
+
+// 용도 감각:
+// 목록 화면에 뿌린다 → filter (배열이 필요하니까)
+// 상세 화면에 하나 띄운다 → find (한 건이면 충분하니까)
+// 경고 배지를 띄울지 판단 → some / every (예/아니오만 필요하니까)`;
+
+const CODE_REDUCE_SUM = `// reduce: 배열 전체를 '값 하나'로 접는다(fold)
+const total = orders.reduce((acc, o) => acc + o.amount, 0);
+// │ │ └ 초기값: 누산기의 출발점
+// │ └ 지금 처리 중인 요소
+// └ 누산기(accumulator): 지금까지 접어 온 중간 결과
+
+// 동작을 풀어 보면: 0 → 0+42000 → 42000+18000 → ... 마지막 값이 total
+const avg = total / orders.length; // 평균은 합계 ÷ 개수`;
+
+const CODE_REDUCE_GROUP = `// 배열을 '객체'로 접기 — 고객별 총 구매액 (실습 과제의 심장)
+const byCustomer = orders.reduce((acc, o) => {
+ acc[o.customer] = (acc[o.customer] || 0) + o.amount;
+ return acc; // 다음 바퀴에 넘길 누산기를 반드시 돌려준다!
+}, {}); // 초기값: 빈 객체 — 여기에 키를 하나씩 채워 나간다
+
+// 결과: { '김수습': 130000, '이신입': 87000, '박대리': 254000, ... }
+
+// 같은 요령으로 '월별 매출 집계' — 날짜 문자열에서 월만 잘라 키로 쓴다
+const byMonth = orders.reduce((acc, o) => {
+ const month = o.ordered_at.slice(0, 7); // '2026-07-13' → '2026-07'
+ acc[month] = (acc[month] || 0) + o.amount;
+ return acc;
+}, {});
+// 결과: { '2026-05': 1200000, '2026-06': 980000, '2026-07': 1430000 }`;
+
+const CODE_REDUCE_BUG = `// 초기값을 빼먹으면? '첫 요소'가 누산기의 출발점이 되어 버립니다.
+[3, 5, 7].reduce((acc, n) => acc + n); // 15 — 숫자 합계라 우연히 정상
+
+orders.reduce((acc, o) => acc + o.amount); // 앗! 첫 바퀴의 acc가 '주문 객체'
+// → "[object Object]18000..." 같은 괴상한 문자열이 만들어집니다.
+
+// 빈 배열이면 아예 에러:
+[].reduce((a, b) => a + b); // TypeError: Reduce of empty array
+[].reduce((a, b) => a + b, 0); // 0 — 초기값이 있으면 안전하게 0 반환
+
+// 결론: reduce의 초기값은 '생략 가능'이 아니라 '항상 쓰는 것'으로 외우세요.`;
+
+const CODE_SORT_COMPARE = `// 비교 함수 (a, b) => 리턴값의 약속:
+// 음수 → a가 앞으로 / 양수 → b가 앞으로 / 0 → 순서 유지
+
+[5, 1, 30].sort(); // [1, 30, 5] ?! — 문자열로 비교하는 함정
+ // '30' < '5' (첫 글자 '3' < '5')
+[5, 1, 30].sort((a, b) => a - b); // [1, 5, 30] — 숫자 오름차순
+[5, 1, 30].sort((a, b) => b - a); // [30, 5, 1] — 내림차순
+
+// 문자열(특히 한글)은 localeCompare가 정석
+names.sort((a, b) => a.localeCompare(b, 'ko'));
+
+// 날짜는 Date로 바꾸면 빼기 연산이 타임스탬프 차이가 되어 숫자처럼 정렬
+logs.sort((a, b) => new Date(a.ordered_at) - new Date(b.ordered_at));`;
+
+const CODE_SORT_MUTATE = `// sort는 '원본을 바꾸는' 몇 안 되는 메서드 — map·filter와 성격이 다르다!
+const prices = [30, 10, 20];
+const sorted = prices.sort((a, b) => a - b);
+console.log(prices); // [10, 20, 30] — 원본까지 바뀌어 버렸다
+console.log(sorted === prices); // true — 같은 배열을 가리키는 중
+
+// 방어법 ①: toSorted — 원본은 두고 '새 배열'을 돌려주는 신형 메서드
+const s1 = prices.toSorted((a, b) => a - b);
+
+// 방어법 ②: 스프레드로 복사본을 만든 뒤 그 복사본을 정렬
+const s2 = [...prices].sort((a, b) => a - b);
+
+// React에서 state 배열을 그냥 sort하면 원본이 몰래 바뀌어
+// "화면이 갱신 안 돼요" 류의 미스터리 버그가 됩니다. 복사가 습관!`;
+
+const CODE_PIPELINE = `// 실습 과제 절반 완성: '고객별 총 구매액 TOP 5 랭킹'
+// 재료(주문 200건)가 파이프라인을 타고 완성 접시가 되는 과정
+
+const top5 = Object.entries( // ③ 객체 → [이름, 합계] 쌍의 배열로
+ orders
+ .filter((o) => o.status === 'PAID') // ① 결제 완료 주문만 골라서
+ .reduce((acc, o) => { // ② 고객별 합계 객체로 접고
+ acc[o.customer] = (acc[o.customer] || 0) + o.amount;
+ return acc;
+ }, {})
+ )
+ .map((pair) => ({ name: pair[0], sum: pair[1] })) // ④ 화면용 객체로 변환
+ .sort((a, b) => b.sum - a.sum) // ⑤ 큰 금액부터 정렬
+ .slice(0, 5); // ⑥ 앞의 5명만 잘라 내기
+
+// [{ name: '박대리', sum: 254000 }, { name: '김수습', sum: 130000 }, ...]`;
+
+const CODE_PIPELINE_DEBUG = `// 체인이 길어지면 중간 결과에 '이름'을 붙여 끊어 두세요.
+const paidOrders = orders.filter((o) => o.status === 'PAID');
+const sumByName = paidOrders.reduce((acc, o) => {
+ acc[o.customer] = (acc[o.customer] || 0) + o.amount;
+ return acc;
+}, {});
+const rankingRows = Object.entries(sumByName)
+ .map((pair) => ({ name: pair[0], sum: pair[1] }))
+ .sort((a, b) => b.sum - a.sum);
+const top5 = rankingRows.slice(0, 5);
+
+// 장점 ①: 어디서 틀렸는지 console.log(sumByName)으로 바로 확인
+// 장점 ②: paidOrders, rankingRows라는 이름 자체가 주석 역할
+// 감각: 한 체인이 4~5단계를 넘거나, 중간 모양이 머리에 안 그려지면 쪼갠다.`;
+
+const CODE_FLATMAP = `// 실전 데이터는 중첩되어 있다 — 주문 '안에' 상품 배열이 또 있는 모양
+const orders = [
+ { id: 1, items: [{ name: '키보드', qty: 1 }, { name: '마우스', qty: 2 }] },
+ { id: 2, items: [{ name: '모니터', qty: 1 }] },
+];
+
+// map만 쓰면 '배열의 배열'이 되어 버린다
+orders.map((o) => o.items);
+// [ [키보드, 마우스], [모니터] ] ← 두 겹. 화면에 바로 못 뿌림
+
+// flatMap = map 하고 나서 '한 겹' 펼치기(flat)
+orders.flatMap((o) => o.items);
+// [ 키보드, 마우스, 모니터 ] ← 한 겹. 바로 쓸 수 있는 평평한 목록
+
+// 펼친 뒤엔 배운 칼들이 전부 그대로 통한다 — 전체 판매 수량:
+orders.flatMap((o) => o.items).reduce((acc, it) => acc + it.qty, 0); // 4`;
+
+const CODE_OPTIONAL = `// 서버 응답엔 '없을 수도 있는' 값이 흔하다 — undefined 방어가 필수
+const first = orders.find((o) => o.customer === '박대리');
+console.log(first.amount); // 박대리 주문이 없으면? first는 undefined
+ // → TypeError로 화면 전체가 하얗게 터진다!
+
+// 옵셔널 체이닝 ?. — 왼쪽이 없으면 에러 대신 조용히 undefined
+console.log(first?.amount); // undefined — 앱은 계속 산다
+
+// ??(널 병합)와 짝지으면 기본값까지 한 줄
+const city = user?.address?.city ?? '미입력'; // 중첩 필드도 안전하게
+const items = order.items ?? []; // 배열 필드는 '빈 배열'로 방어
+items.map((it) => it.name); // 빈 배열이면 그냥 빈 결과`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다(React 입문 섹션 2). props만 바꿔 재사용.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 실전 웹 개발의 절반은 데이터 다듬기예요. 서버가 주는 JSON은 날것의
+ 재료일 뿐, 화면에 올리려면 골라내고(filter), 모양을 바꾸고(map), 집계하고(reduce),
+ 줄을 세워야(sort) 하죠. 이 코스에서는 for문 대신{' '}
+ 배열 메서드라는 잘 벼린 칼들로 — 주문 내역 200건을{' '}
+ TOP 5 랭킹과 월별 매출표로 요리하는 실전 기술을 익힙니다.
+
+
+ 예상 소요 50분
+ 실습: 주문 200건 → TOP 5 랭킹·월별 집계
+ 선수 지식: JS 기초 문법·함수
+
+
+
+ {/* 섹션 바로가기 */}
+
+
+
+
+ 기초 과정에서 배운 for문으로도 모든 데이터 작업은 가능해요. 그런데
+ 실무 코드를 열어 보면 for문 대신 map·
+ filter·reduce가 압도적으로
+ 많습니다. 왜일까요? 답은 "누가 읽는 코드인가"에 있어요. for문은 인덱스 변수, 조건문,
+ push까지 어떻게(how) 할지를 전부 지시하지만, 배열 메서드는{' '}
+ 무엇을(what) 원하는지만 선언합니다.
+
+ {CODE_FOR_VS_METHOD}
+
+ 두 코드는 결과가 완전히 같아요. 하지만 두 번째 코드를 읽는 동료는{' '}
+ filter와 map이라는 단어만
+ 보고도 "골라서, 바꾸는구나"를 3초 만에 압니다. 코드는 쓰는 시간보다{' '}
+ 읽히는 시간이 훨씬 긴 물건이라, 이 3초의 차이가 쌓이면 팀 전체의
+ 속도가 됩니다.
+
+
+ 비유 for문이 "칼을 들고, 45도로 눕혀서, 3mm 간격으로 내리치세요"라는{' '}
+ 동작 지시서라면, 배열 메서드는 "채 썰어 주세요"라는{' '}
+ 주문서예요. 주방(자바스크립트 엔진)이 알아서 손을 움직이고, 우리는
+ 요리의 의도만 말하면 됩니다. 이 코스의 목표는 상황마다 어떤 주문서를 내밀지 아는
+ 감각이에요.
+
+
+
+
+
+ map은 배열의 모든 요소에 같은 함수를 적용해
+ 새 배열을 만드는 메서드예요. 개수는 그대로, 모양만 바뀝니다. 가장 흔한
+ 실전 용도는 서버 모양 → 화면 모양 변환이에요. 서버는{' '}
+ order_id·total_price 같은
+ 자기 사정에 맞는 필드를 주지만, 화면은 "42,000원"처럼 다듬어진 값을 원하니까요.
+
+ {CODE_MAP_SHAPE}
+
+ 여기서 원칙 하나를 새기세요. map은 원본을 절대 건드리지 않습니다.{' '}
+ 변환 결과는 언제나 새 배열이고, res는
+ 받았을 때 모습 그대로 남아요. 원본이 살아 있으면 "다른 화면에서는 원 단위 숫자가
+ 필요한데?" 같은 요구가 와도 다시 요리할 수 있죠. 이 불변성 감각은
+ 섹션 5의 sort에서 다시 시험대에 오릅니다.
+
+ {CODE_MAP_MISTAKE}
+
+ 디버깅 신호 map의 결과가 [undefined, undefined, ...]
+ 라면 십중팔구 반환 누락이에요. 화살표 함수에서 중괄호{' '}
+ {'{ }'}를 여는 순간 "여기부터는 블록"이라{' '}
+ return을 직접 써야 하고, 객체를 바로 돌려주고 싶으면
+ 소괄호로 {'({ ... })'}처럼 감싸야 합니다. 둘 다 자바스크립트
+ 문법이 놓은 덫이라, 알고 있으면 5초, 모르면 한 시간짜리 버그예요.
+
+
+
+
+
+ filter는 조건 함수 하나를 받아, 그
+ 함수가 true를 돌려준 요소들만 모은 새 배열을
+ 만들어요. "결제 완료 주문만", "만원 이상만", "이번 달 것만" — 목록 화면의 온갖
+ 필터가 전부 이 한 줄입니다.
+
+ {CODE_FILTER_FIND}
+
+ filter와 find는 겉이
+ 닮아서 자주 헷갈려요. 구분 기준은 결과의 자료형입니다.{' '}
+ filter는 조건에 맞는 전부를 담은{' '}
+ 배열(하나도 없으면 빈 배열 []),{' '}
+ find는 조건에 맞는 첫 하나인{' '}
+ 요소 자체(없으면 undefined)를 돌려줘요.
+ 목록엔 filter, 상세 조회엔 find — 손에 익혀 두세요.
+
+
+ 한편 "있는지 여부만 궁금할" 때가 있어요. 환불 건이 하나라도 있으면
+ 경고 배지를 띄운다든가요. 그럴 땐 배열을 만들 필요 없이{' '}
+ some(하나라도 만족?)과{' '}
+ every(전부 만족?)가 true/false
+ 로 바로 답해 줍니다.
+
+
+ find의 함정 예고find가 못 찾으면{' '}
+ undefined를 돌려준다고 했죠? 그 undefined에 점(.)을 찍는
+ 순간 앱이 터집니다. 이 방어법은 섹션 7의 옵셔널 체이닝에서 제대로
+ 다뤄요 — 일단 "find 결과는 없을 수도 있다"만 기억해 두세요.
+
+
+
+
+
+ map과 filter는 배열을 넣으면 배열이 나와요. 그런데 실습 과제인 "고객별{' '}
+ 총 구매액"이나 "월별 매출 합계"는 배열이 아니라{' '}
+ 접힌 결과 하나가 필요하죠. 그 접기(fold)를 담당하는 칼이{' '}
+ reduce입니다. 누산기(accumulator)라는 그릇을 들고
+ 배열을 한 바퀴 돌면서, 요소를 하나씩 그릇에 누적해 나가요.
+
+ {CODE_REDUCE_SUM}
+
+ 진짜 힘은 누산기가 숫자일 필요가 없다는 데서 나옵니다. 빈 객체{' '}
+ {'{}'}를 초기값으로 주면, 배열을 돌며 객체에 키를 채우는{' '}
+ 그룹핑이 돼요. 실습 과제의 두 집계가 모두 이 패턴 하나로 풀립니다.
+
+ {CODE_REDUCE_GROUP}
+
+ 그리고 reduce에서 가장 자주 터지는 버그 — 초기값 누락입니다. 초기값을
+ 생략하면 자바스크립트는 배열의 첫 요소를 누산기의 출발점으로 삼아요.
+ 숫자 배열의 합계라면 우연히 맞지만, 객체 배열이라면 첫 바퀴의 누산기가{' '}
+ 주문 객체 자체가 되어 엉뚱한 결과가 나옵니다.
+
+ {CODE_REDUCE_BUG}
+
+ 읽는 요령 reduce가 어렵게 느껴지면 초기값부터 읽으세요. 마지막
+ 인자가 0이면 "아, 숫자로 접는구나(합계)",{' '}
+ {'{}'}면 "객체로 접는구나(그룹핑)",{' '}
+ []면 "새 배열을 조립하는구나". 초기값이 결과물의
+ 자료형을 미리 알려 주는 예고편이에요.
+
+
+
+
+
+ 랭킹을 만들려면 줄 세우기가 필요하죠. sort는{' '}
+ 비교 함수(a, b) => 리턴값을 받는데,
+ 약속은 딱 세 가지예요. 음수면 a가 앞, 양수면 b가 앞,
+ 0이면 그대로. 그래서 숫자 오름차순은 a - b, 내림차순은{' '}
+ b - a 한 줄로 끝납니다.
+
+ {CODE_SORT_COMPARE}
+
+ 비교 함수를 생략하면 모든 요소를 문자열로 바꿔{' '}
+ 사전순 비교합니다. 그래서 [5, 1, 30]이{' '}
+ [1, 30, 5]가 되는 유명한 함정이 생겨요. 숫자 정렬엔
+ 비교 함수 필수, 한글 문자열엔 localeCompare, 날짜는{' '}
+ new Date()로 바꿔 숫자처럼 — 세 가지 요령을 세트로
+ 기억하세요.
+
+
+ 더 무서운 함정은 따로 있어요. map·filter가 새 배열을 돌려주는 것과 달리,{' '}
+ sort는 원본 배열 자체를 재배열합니다.
+ 정렬 한 번 했을 뿐인데 다른 화면의 순서까지 바뀌어 있는 미스터리의 범인이 대개
+ 이 녀석이에요.
+
+ {CODE_SORT_MUTATE}
+
+ 방어 습관 원본을 지키려면 toSorted(정렬된{' '}
+ 새 배열을 돌려주는 신형 메서드)를 쓰거나, 구형 환경까지 생각하면{' '}
+ [...arr].sort(...)처럼 스프레드 복사 후 정렬
+ 하세요. 같은 이유로 원본을 바꾸는 reverse·
+ splice에도 toReversed·
+ toSpliced라는 안전한 짝이 있습니다.
+
+
+
+
+
+ 칼을 하나씩 익혔으니 이제 요리 라인을 만들 차례예요. 배열 메서드는
+ 결과가 다시 배열(또는 접힌 값)이라, 점(.)으로 이어 붙일 수 있습니다.
+ 재료가 컨베이어 벨트를 타고 공정을 하나씩 통과하는 그림이죠. 실습 과제의 절반인{' '}
+ TOP 5 랭킹이 정확히 이 파이프라인입니다.
+
+ {CODE_PIPELINE}
+
+ 중간에 낯선 얼굴이 하나 있죠? reduce로 접은 결과는 객체라서 배열
+ 메서드를 바로 못 씁니다. Object.entries()가 객체를{' '}
+ [키, 값] 쌍의 배열로 되돌려 주는 다리
+ 역할을 해요. 객체 ↔ 배열을 오가는 이 왕복이 실전 집계 코드의 단골 패턴입니다.
+
+
+ 다만 체이닝에도 과유불급이 있어요. 한 체인이 너무 길어지면 중간에
+ 어떤 모양의 데이터가 흐르는지 머리로 따라가기 어려워집니다. 그럴 땐 중간 결과를
+ 변수로 끊고 이름을 붙이세요.
+
+ {CODE_PIPELINE_DEBUG}
+
+ 쪼개는 기준 ① 체인이 4~5단계를 넘어갈 때, ② 중간 데이터의 모양이 머리에
+ 그려지지 않을 때, ③ 같은 중간 결과를 두 군데 이상에서 쓸 때 — 이
+ 셋 중 하나라도 걸리면 변수나 함수로 쪼갤 타이밍이에요. 멋져 보이는 한 줄보다,
+ 내일의 내가 읽을 수 있는 다섯 줄이 낫습니다.
+
+
+
+
+
+ 연습용 데이터는 평평하지만, 진짜 API 응답은 객체 안에 배열, 배열 안에 객체가
+ 겹겹이 쌓여 있어요. 주문 하나에 상품이 여러 개 담기는 것처럼요. 이 중첩을 다루는
+ 두 가지 필수 도구로 코스를 마무리합니다.
+
+
+ 첫째, flatMap. "모든 주문의 상품을
+ 한 줄로 나열하고 싶다"에서 map만 쓰면 배열의 배열이라는 두 겹 결과가
+ 나와요. flatMap은 map을 하고 나서 한 겹을 펼쳐(flat), 바로 쓸 수 있는
+ 평평한 배열을 만들어 줍니다.
+
+ {CODE_FLATMAP}
+
+ 둘째, 옵셔널 체이닝 ?.. 섹션 3에서
+ 예고했죠 — find가 못 찾으면{' '}
+ undefined고, 중첩 응답에는 없을 수도 있는 필드가
+ 흔합니다. undefined에 점을 찍는 순간 TypeError로 화면이
+ 통째로 하얗게 되니, ?.로 방어벽을 세워야 해요.
+
+ {CODE_OPTIONAL}
+
+ 이제 실습입니다 주문 내역 JSON 200건을 내려받아 두 접시를 완성해 보세요.
+ ① 고객별 총 구매액 TOP 5 — 섹션 6의 파이프라인(filter → reduce →
+ Object.entries → map → sort → slice)을 그대로, ② 월별 매출 집계 —
+ 섹션 4의 slice(0, 7) 그룹핑 패턴을 그대로 쓰면 됩니다.
+ 단, for문·while문은 금지예요. 칼질은 전부 배열 메서드로!
+
+
+
+
+
+
🔪 여기까지 왔다면
+
+ 이제 날것의 JSON 앞에서 막막해하는 대신, 골라서(filter) → 모양 바꾸고(map)
+ → 접고(reduce) → 줄 세우는(sort) 요리 순서가 머리에 그려질 거예요. 원본을
+ 지키는 불변성 감각, 초기값·반환 누락 같은 덫의 위치, 중첩 데이터를 펼치고 방어하는
+ 요령까지 — 화면에 올릴 데이터를 스스로 다듬는 손을 갖췄습니다. 그런데 이 재료들,
+ 애초에 서버가 어떤 모양으로 주느냐도 누군가의 설계죠. 다음{' '}
+ REST API 설계: 주소와 동사의 문법{' '}
+ 코스에서, 이번엔 데이터를 건네는 쪽의 문법을 배워 봅시다.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv2AsyncJavascriptPage.jsx b/frontend/src/pages/levels/Lv2AsyncJavascriptPage.jsx
new file mode 100644
index 0000000..8e2280b
--- /dev/null
+++ b/frontend/src/pages/levels/Lv2AsyncJavascriptPage.jsx
@@ -0,0 +1,522 @@
+// 이 파일이 하는 일: "비동기 JavaScript: fetch와 Promise 정복" 코스 — 동기 코드가 화면을
+// 얼리는 순간에서 출발해, 콜백 지옥 → Promise → async/await → fetch → 병렬 처리 →
+// 실전 패턴(로딩·취소·타임아웃)까지 7개 섹션으로 안내하는 정적 학습 페이지.
+// 프레이밍은 "서버와 대화하는 코드" — 기초 문법을 아는 학생이 실제로 돌아가는 웹 서비스를
+// 만들 때 반드시 넘어야 하는 첫 고개인 비동기를, 환율 API 실습으로 손에 잡히게 만든다.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_FREEZE = `// 버튼을 누르면 3초 동안 '아무것도' 못 하는 코드 — 콘솔에서 직접 체험해 보세요
+function freezeFor3Seconds() {
+ const start = Date.now();
+ while (Date.now() - start < 3000) {
+ // 3초 동안 CPU를 붙잡고 제자리 돌기
+ }
+ console.log('끝!');
+}
+
+// 이 함수가 도는 동안 클릭·스크롤·입력이 전부 멈춥니다.
+// JavaScript는 '한 번에 한 가지 일'만 하는 싱글 스레드라서,
+// 오래 걸리는 일을 그 자리에서 기다리면 화면 전체가 얼어붙어요.
+// 네트워크 요청을 이런 식으로 기다렸다간? 서버가 느린 만큼 앱이 얼겠죠.`;
+
+const CODE_KITCHEN = `주방(JS 엔진)에는 요리사가 '한 명'뿐입니다.
+
+[주문서 더미(큐)] ← 끝난 일들의 쪽지(클릭 발생, 타이머 완료, 응답 도착)가 쌓이는 곳
+ ↓ 한 장씩 집어서
+[요리사(콜 스택)] ← 지금 만들고 있는 요리 딱 하나
+ ↑ 기다리는 일은 위임
+[홀 직원(브라우저)] ← 타이머, 네트워크 요청처럼 '언제 끝날지 모르는 일'을
+ 요리사 대신 맡아 주는 손들
+
+핵심: 요리사는 절대 오븐 앞에서 멍하니 기다리지 않아요.
+"다 구워지면 주문서 더미에 쪽지 넣어 줘" 하고 다음 요리로 넘어갑니다.
+요리사가 손이 빌 때마다 주문서 더미에서 쪽지를 집어 처리하는
+이 순환 구조가 바로 '이벤트 루프'입니다.`;
+
+const CODE_CALLBACK = `// 콜백: "이 일이 끝나면 이 함수를 불러 줘" — 함수를 인자로 넘기는 방식
+setTimeout(function () {
+ console.log('B: 3초 뒤에 실행됩니다');
+}, 3000);
+
+console.log('A: 이 줄이 먼저 찍힙니다!');
+
+// 출력 순서: A → (3초 뒤) → B
+// setTimeout은 타이머를 브라우저(홀 직원)에게 맡기고 '즉시' 다음 줄로 넘어가요.
+// 우리가 넘긴 함수는 쪽지에 적혀 있다가, 3초 뒤에 이벤트 루프가 집어 실행합니다.`;
+
+const CODE_HELL = `// 환율을 받고 → 환전 계산하고 → 기록을 저장하는 3단계를 콜백으로 쓰면...
+getRate('USD', function (rate) {
+ convert(1000, rate, function (result) {
+ saveHistory(result, function () {
+ console.log('완료!');
+ }, function (err) {
+ console.error('기록 실패', err); // 에러 처리 ①
+ });
+ }, function (err) {
+ console.error('계산 실패', err); // 에러 처리 ②
+ });
+}, function (err) {
+ console.error('환율 실패', err); // 에러 처리 ③
+});
+
+// 오른쪽으로 계속 밀려나는 계단 모양 — 이른바 '콜백 지옥(callback hell)'.
+// 단계가 하나 늘 때마다 들여쓰기가 한 층 깊어지고,
+// 에러 처리는 세 군데로 흩어져서 하나라도 빠뜨리기 딱 좋습니다.`;
+
+const CODE_PROMISE_STATE = `// Promise: "지금은 결과가 없지만, 나중에 꼭 주겠다"는 약속 객체
+const promise = fetch('https://open.er-api.com/v6/latest/USD');
+
+// 갓 만들어진 promise의 상태는 pending(대기 중).
+// 시간이 지나면 둘 중 하나로 '단 한 번' 정착합니다.
+//
+// pending ──┬──→ fulfilled(이행) 성공! 결과값을 품는다
+// └──→ rejected(거부) 실패! 에러를 품는다
+//
+// 한번 정착(settled)한 상태는 절대 다시 바뀌지 않아요.
+// fulfilled였다가 rejected가 되는 일은 없습니다 — 그래서 '믿고 기다릴' 수 있어요.`;
+
+const CODE_THEN = `// 콜백 지옥의 3단계를 then 체이닝으로 다시 쓰면
+getRate('USD')
+ .then(function (rate) { return convert(1000, rate); }) // 1단계 → 2단계
+ .then(function (result) { return saveHistory(result); }) // 2단계 → 3단계
+ .then(function () { console.log('완료!'); })
+ .catch(function (err) {
+ // 위 어느 단계에서 실패해도 전부 여기 '한 곳'으로 모입니다
+ console.error('어딘가에서 실패:', err);
+ });
+
+// 같은 일을 하는데 — 계단이 사라지고, 에러 처리가 catch 하나로 모였어요.
+// 비밀은 then이 '항상 새 Promise를 돌려준다'는 것.
+// 그래서 사슬(chain)처럼 계속 이어 붙일 수 있습니다.`;
+
+const CODE_AWAIT = `// 같은 로직을 async/await로 — '동기처럼 읽히는' 비동기 코드
+async function run() {
+ try {
+ const rate = await getRate('USD'); // Promise가 정착할 때까지 대기
+ const result = await convert(1000, rate);
+ await saveHistory(result);
+ console.log('완료!');
+ } catch (err) {
+ // then 체인의 catch와 같은 역할 — 실패가 한 곳으로 모입니다
+ console.error('어딘가에서 실패:', err);
+ }
+}
+
+// await는 마법이 아니라 '문법 설탕'이에요. 속은 여전히 Promise인데,
+// then 체인을 위→아래로 읽히는 모양으로 바꿔 줄 뿐입니다.
+// 규칙 두 가지: ① await는 async 함수 안에서만 ② await가 기다리는 건 Promise`;
+
+const CODE_FETCH = `// fetch는 '2단계'로 동작합니다 — 응답 도착 확인 → 본문 해석
+async function getUsdRate() {
+ const res = await fetch('https://open.er-api.com/v6/latest/USD'); // ① 응답 도착
+
+ if (!res.ok) { // ② 상태 코드 확인은 '습관'!
+ throw new Error('HTTP 오류: ' + res.status);
+ }
+
+ const data = await res.json(); // ③ 본문(JSON)을 마저 받아 해석 — 이것도 Promise
+ return data.rates.KRW; // 1달러 = ? 원
+}
+
+// 함정: fetch는 404·500 응답에도 reject되지 '않아요'!
+// 서버와 연결 자체가 실패(네트워크 끊김 등)했을 때만 reject됩니다.
+// 서버가 "그런 거 없는데요(404)"라고 '대답'한 건 fetch 입장에선 성공이거든요.
+// 그래서 res.ok(상태 200~299) 확인은 선택이 아니라 습관이어야 합니다.`;
+
+const CODE_POST = `// 옵션 객체로 메서드·헤더·본문을 지정하면 POST도 보낼 수 있어요
+const res = await fetch('/api/favorites', {
+ method: 'POST', // 기본값은 GET
+ headers: {
+ 'Content-Type': 'application/json', // "본문은 JSON이야"라고 알려주기
+ },
+ body: JSON.stringify({ currency: 'USD', amount: 1000 }), // 객체 → JSON 문자열
+});
+
+if (!res.ok) throw new Error('저장 실패: ' + res.status);
+const saved = await res.json();
+
+// 보낼 때는 JSON.stringify(객체 → 문자열), 받을 때는 res.json()(문자열 → 객체).
+// 이 왕복 변환이 '서버와 대화'의 기본 문법입니다.`;
+
+const CODE_PARALLEL = `// 방법 A: 순차 await — 앞 요청이 '끝나야' 다음이 출발 (직렬)
+const usd = await fetch(BASE + '/USD').then(function (r) { return r.json(); });
+const eur = await fetch(BASE + '/EUR').then(function (r) { return r.json(); });
+const jpy = await fetch(BASE + '/JPY').then(function (r) { return r.json(); });
+// 요청당 0.3초라면 → 합계 약 0.9초
+
+// 방법 B: Promise.all — 셋을 '동시에' 출발시키고 전부 끝나길 기다림 (병렬)
+const [usd2, eur2, jpy2] = await Promise.all([
+ fetch(BASE + '/USD').then(function (r) { return r.json(); }),
+ fetch(BASE + '/EUR').then(function (r) { return r.json(); }),
+ fetch(BASE + '/JPY').then(function (r) { return r.json(); }),
+]);
+// 동시에 달리므로 → 약 0.3초. '가장 느린 요청 하나'만큼만 걸려요.
+
+// 서로 의존하지 않는 요청(앞 결과가 뒤 요청에 필요 없는 경우)이라면
+// 병렬이 기본값이어야 합니다. console.time()으로 직접 재 보세요!`;
+
+const CODE_ALLSETTLED = `// Promise.all의 성격: 하나라도 실패하면 '전체가' 실패(reject)
+// USD·EUR은 성공했는데 JPY만 실패해도 → 성공한 둘의 결과까지 함께 버려집니다.
+// "전부 있어야만 의미 있는 데이터"라면 이게 오히려 올바른 동작이에요.
+
+// 부분 실패를 허용하고 싶다면 → Promise.allSettled
+const results = await Promise.allSettled([pUsd, pEur, pJpy]);
+
+results.forEach(function (r) {
+ if (r.status === 'fulfilled') {
+ console.log('성공:', r.value); // 성공한 것만 화면에 그리고
+ } else {
+ console.log('실패:', r.reason); // 실패한 통화엔 "조회 실패" 표시
+ }
+});
+
+// allSettled는 절대 reject되지 않아요 — 성공이든 실패든
+// '전원의 결과 보고서'를 배열로 돌려줍니다.`;
+
+const CODE_WIDGET = `// 실습 골격: 로딩·성공·실패 3상태를 갖춘 환율 검색 위젯 (바닐라 JS)
+const box = document.querySelector('#rate-box');
+
+async function loadRate(code) {
+ box.innerHTML = ' 불러오는 중...'; // 상태 ① 로딩
+ try {
+ const res = await fetch('https://open.er-api.com/v6/latest/' + code);
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ const data = await res.json();
+ const krw = Math.round(data.rates.KRW);
+ box.textContent = '1 ' + code + ' = ' + krw + '원'; // 상태 ② 성공
+ } catch (err) {
+ box.innerHTML = '환율을 불러오지 못했어요. ' +
+ ''; // 상태 ③ 실패
+ document.querySelector('#retry')
+ .addEventListener('click', function () { loadRate(code); }); // 재시도 = 같은 함수 재호출
+ }
+}
+
+loadRate('USD');
+
+// 포인트: 화면은 언제나 로딩·성공·실패 '셋 중 하나'의 상태에 있습니다.
+// 세 상태를 전부 코드로 표현했는지가 '되다 만 위젯'과 '완성된 위젯'의 차이예요.`;
+
+const CODE_ABORT = `// AbortController — 이미 보낸 요청에 '그만!'을 외치는 리모컨
+const controller = new AbortController();
+
+const promise = fetch(url, { signal: controller.signal }); // 리모컨 신호선 연결
+
+// 사용자가 검색 통화를 USD → EUR로 바꿨다면? 이전 요청은 취소
+controller.abort(); // promise가 'AbortError'라는 이름의 에러로 reject됨
+
+// ── 응용: 타임아웃 패턴 — 5초 안에 응답 없으면 포기 ──
+const ctrl = new AbortController();
+const timer = setTimeout(function () { ctrl.abort(); }, 5000);
+
+try {
+ const res = await fetch(url, { signal: ctrl.signal });
+ clearTimeout(timer); // 제때 왔으면 타이머 해제
+ const data = await res.json();
+} catch (err) {
+ if (err.name === 'AbortError') {
+ // 느린 서버 — 무한 로딩 대신 "다시 시도" 버튼을 보여 줍시다
+ } else {
+ // 그 밖의 진짜 실패 (네트워크 끊김 등)
+ }
+}`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다(React 입문 섹션 2). props만 바꿔 재사용.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 기초 문법을 배웠는데도 "진짜 서비스"가 안 만들어지는 이유의 절반은{' '}
+ 비동기에 있어요. 서버는 언제 대답할지 모르는데, JavaScript는 한 번에
+ 한 가지 일밖에 못 하니까요. 이 코스에서 콜백 지옥에서 async/await까지의
+ 진화를 따라 걷고, 마지막엔 로딩 스피너·에러 메시지·재시도 버튼을 갖춘{' '}
+ 환율 검색 위젯을 직접 만들어 봅니다.
+
+
+ 예상 소요 60분
+ 실습: 환율 검색 위젯
+ 선수 지식: JS 기초 문법
+
+
+
+ {/* 섹션 바로가기 */}
+
+
+
+
+ 출발점은 체험이에요. 아래 코드를 브라우저 콘솔에 붙여 넣고 실행해 보세요. 함수가 도는
+ 3초 동안 클릭도, 스크롤도, 입력도 전부 먹통이 됩니다. JavaScript는{' '}
+ 싱글 스레드 — 일꾼이 한 명뿐이라, 그 일꾼이 제자리에서 기다리면 화면을
+ 그릴 사람이 없거든요.
+
+ {CODE_FREEZE}
+
+ 그런데 웹 서비스의 핵심 일과는 네트워크 요청이에요. 서버가 0.1초 만에
+ 대답할지, 3초 걸릴지, 영영 대답이 없을지 보내기 전엔 알 수 없죠. 이
+ "언제 끝날지 모르는 일"을 일꾼이 그 자리에서 기다리면 앱은 서버가 느린 만큼 얼어붙어요.
+ 그래서 JavaScript는 다른 전략을 택합니다 — 기다리지 않고, 맡기는 거예요.
+
+ {CODE_KITCHEN}
+
+ 이 비유를 기억하세요 요리사(콜 스택)는 한 명, 홀 직원(브라우저)이 기다리는 일을
+ 대신 맡고, 끝난 일은 주문서 더미(큐)에 쌓였다가 요리사 손이 빌 때 처리된다 — 이{' '}
+ 이벤트 루프 그림 하나면 이 코스의 모든 문법(콜백·Promise·await)이
+ "주문서를 어떻게 예쁘게 쓰느냐"의 문제로 보이기 시작합니다.
+
+
+
+
+
+ "일을 맡겼으면, 끝났을 때 뭘 할지도 알려 줘야" 하죠. 가장 원시적인 방법이{' '}
+ 콜백(callback) — 나중에 불러 달라고 함수를 인자로 넘기는{' '}
+ 방식입니다. 이미 써 보셨을 거예요. addEventListener의 두
+ 번째 인자, setTimeout의 첫 번째 인자가 전부 콜백입니다.
+
+ {CODE_CALLBACK}
+
+ 한 단계짜리 일이면 콜백도 충분해요. 문제는 순차 작업입니다. "환율을
+ 받아서 → 그걸로 환전 계산을 하고 → 결과를 기록한다"처럼 앞 결과가 다음 단계에 필요하면,
+ 콜백 안에 콜백을, 그 안에 또 콜백을 넣을 수밖에 없어요.
+
+ {CODE_HELL}
+
+ 진짜 문제는 들여쓰기가 아니에요 계단 모양은 보기 싫은 정도지만,{' '}
+ 에러 처리가 세 군데로 흩어진 것은 사고로 이어집니다. 단계마다 실패
+ 콜백을 따로 달아야 하고, 하나라도 깜빡하면 그 에러는 조용히 증발해요. "실패를 한 곳에
+ 모을 수 없을까?" — 이 질문에 대한 답이 다음 섹션의 Promise입니다.
+
+
+
+
+
+ Promise는 "지금은 결과가 없지만 나중에 꼭 주겠다"는{' '}
+ 약속을 담은 객체예요. 콜백처럼 함수를 미리 찔러 넣는 게 아니라, 일단
+ 약속 객체를 손에 받아 두고 거기에 "성공하면 이거, 실패하면 저거"를
+ 붙이는 방식이죠. 약속의 일생은 세 상태로 요약됩니다.
+
+ {CODE_PROMISE_STATE}
+
+
그림: Promise는 pending에서 출발해 fulfilled 또는 rejected 중 하나로 단 한 번 정착한다
+
+ 약속에 후속 작업을 붙이는 문법이 then(성공 시)과{' '}
+ catch(실패 시)예요. 그리고{' '}
+ then은 항상 새 Promise를 반환하기 때문에,
+ 콜백 지옥의 계단을 수평 사슬로 펼 수 있습니다.
+
+ {CODE_THEN}
+
+ 비유 Promise는 카페 진동벨이에요. 주문하면(요청) 음료 대신
+ 진동벨(Promise)을 먼저 받죠. 벨을 손에 쥔 채 자리에서 딴 일을 하다가 — 울리면(fulfilled)
+ 음료를 받고, 점원이 와서 "재료가 떨어졌어요" 하면(rejected) 환불 처리를 합니다. 벨이
+ 두 번 다르게 울리는 일은 없어요. 단 한 번, 둘 중 하나.
+
+
+
+
+
+ then 체인은 훌륭하지만, 로직이 길어지면{' '}
+ function과 return이
+ 반복되며 눈이 미끄러져요. 그래서 나온 최종 진화형이{' '}
+ async/await — 같은 Promise를 동기 코드처럼 읽히는 모양
+ 으로 쓰는 문법입니다. 섹션 3의 체인을 그대로 옮겨 볼게요.
+
+ {CODE_AWAIT}
+
+ 짚어 둘 것은 await가 '문법 설탕(syntactic sugar)'이라는 점이에요. 새
+ 동작이 생긴 게 아니라, then 체인과 같은 일을 다르게 표기할 뿐입니다.{' '}
+ await 뒤의 Promise가 정착할 때까지 그 함수의 실행이
+ 멈추는데 — 섹션 1의 while 루프와 달리{' '}
+ 화면은 얼지 않아요. 함수만 잠들고, 일꾼(콜 스택)은 풀려나 다른 주문서를
+ 처리하러 가거든요.
+
+
+ 에러 처리는 우리가 아는 try/catch 그대로입니다.{' '}
+ try 블록 안 어느 await에서 실패해도 실행이{' '}
+ catch로 점프해요. 콜백 지옥에서 세 군데로 흩어졌던 에러
+ 처리가, 여기선 익숙한 문법 한 곳으로 모입니다.
+
+
+ 자주 하는 실수await를{' '}
+ async 없는 보통 함수 안에 쓰면 문법 에러가 납니다. 그리고{' '}
+ async 함수의 반환값은 항상 Promise예요 —{' '}
+ return 42를 해도 밖에서는{' '}
+ Promise로 감싸져 나오니, 호출하는 쪽에서도{' '}
+ await가 필요합니다. "async는 전염된다"고 기억해 두세요.
+
+
+
+
+
+ 이제 도구가 갖춰졌으니 진짜 서버와 대화해 봅시다.{' '}
+ fetch는 브라우저에 내장된 HTTP 요청 함수로, Promise를
+ 돌려줘요. 구조에서 헷갈리기 쉬운 건 await가 두 번 나온다는 점입니다 —
+ ① 응답(헤더)이 도착하길 기다리고, ② 본문이 다 내려와 해석되길 또 기다립니다.
+
+ {CODE_FETCH}
+
+ 주석의 함정을 꼭 기억하세요. fetch는{' '}
+ 404나 500 같은 에러 응답에도 reject되지 않습니다. 서버가 "실패"라고
+ 대답한 것과, 대답 자체가 안 온 것(네트워크 단절)을 구분하기 때문이에요. HTTP 수준의
+ 실패를 잡으려면 res.ok(상태 코드 200~299면 true)를 확인해
+ 직접 throw해야, 섹션 4에서 만든{' '}
+ catch 그물에 걸립니다.
+
+
+ 데이터를 보내는 쪽도 옵션 객체 하나로 해결돼요. 메서드, 헤더, 본문을
+ 지정하면 POST 요청이 됩니다.
+
+ {CODE_POST}
+
+ 직접 확인해 보기 브라우저 콘솔에서{' '}
+ await fetch('https://open.er-api.com/v6/latest/USD').then(r => r.json())
+ 를 실행해 보세요. 공개 환율 API라 키 없이도 응답이 옵니다.{' '}
+ rates.KRW가 오늘의 원/달러 환율이에요 — 섹션 7 실습에서 이
+ API를 그대로 씁니다. F12 → Network 탭에서 방금 나간 요청의
+ 상태 코드와 응답 본문도 눈으로 확인해 보세요.
+
+
+
+
+
+ await가 편하다 보니 생기는 흔한 낭비가 있어요. 서로 상관없는 요청
+ 세 개를 순서대로 await하면, 앞 요청이 끝나야 다음이{' '}
+ 출발합니다. 달러·유로·엔 환율은 서로를 필요로 하지 않는데도요.
+
+ {CODE_PARALLEL}
+
+ Promise.all은 Promise 배열을 받아{' '}
+ 전부 fulfilled되면 결과 배열을 (넣은 순서 그대로) 돌려줍니다. 단,
+ 성격이 확실해요 — 하나라도 rejected되면 전체가 즉시 reject됩니다.
+ "전원 성공 아니면 전체 실패"죠. 부분 실패를 살리고 싶을 땐{' '}
+ Promise.allSettled를 씁니다.
+
+ {CODE_ALLSETTLED}
+
+ 고르는 기준 결과들이 한 몸이라 하나라도 빠지면 의미가 없다 →{' '}
+ all(빨리 실패하고 전체 재시도). 결과들이{' '}
+ 각자 독립이라 되는 것만이라도 보여 주고 싶다 →{' '}
+ allSettled(통화별로 성공/실패 표시). 문법이 아니라{' '}
+ 화면 요구사항이 답을 정합니다.
+
+
+
+
+
+ 문법을 다 배워도 실전 코드엔 한 층이 더 있어요. 요청이 나가 있는 동안 사용자는{' '}
+ 뭔가를 보고 있어야 하고, 실패하면 다음 행동(재시도)을
+ 제안받아야 하죠. 그래서 서버와 대화하는 화면은 언제나{' '}
+ 로딩 · 성공 · 실패라는 3상태 중 하나에 있습니다. 이번 코스의 실습
+ — 환율 검색 위젯의 골격이 바로 이 3상태 관리예요.
+
+ {CODE_WIDGET}
+
+ 여기에 실무 감각 두 가지를 더하면 완성입니다. 첫째, 취소. 사용자가
+ 검색을 연타하면 이전 요청은 의미가 없어지는데, 늦게 도착한 옛 응답이 새 화면을 덮어쓰는
+ 사고가 나요. 둘째, 타임아웃. 서버가 영영 대답하지 않으면 로딩 스피너가
+ 무한히 돕니다. 둘 다 AbortController 하나로 해결돼요.
+
+ {CODE_ABORT}
+
+ AbortError는 '실패'가 아닐 수 있어요 사용자가 검색어를 바꿔서{' '}
+ 우리가 스스로 취소한 요청도 catch로 들어옵니다. 그걸 "에러 발생!"으로
+ 표시하면 이상하겠죠. 그래서 catch 안에서{' '}
+ err.name === 'AbortError'를 구분하는 습관이 필요합니다 —
+ 의도한 취소는 조용히, 진짜 실패만 에러 메시지로.
+
+
+ 실습 과제 위 두 코드를 합쳐 보세요. 통화 코드 입력창 + 검색 버튼을 만들고 →
+ 검색 시 로딩 스피너 → 성공하면 환율 표시 → 실패하면 에러 메시지와 재시도 버튼 →
+ 5초 타임아웃까지. 완성하면 여러분은 실제 서비스의 데이터 화면과 똑같은
+ 구조를 처음부터 끝까지 만들어 본 겁니다.
+
+
+
+
+
+
⏳ 여기까지 왔다면
+
+ 이제 "서버와 대화하는 코드"의 전체 지형이 손에 있어요. 이벤트 루프(주방), 콜백 지옥이
+ 남긴 교훈, Promise의 3상태(진동벨), async/await라는 문법 설탕, fetch의 2단계와{' '}
+ res.ok 습관, Promise.all의 병렬 감각, 그리고 로딩·취소·타임아웃까지 —
+ 환율 위젯 하나에 전부 담아 봤습니다. 그런데 서버에서 받아 온 데이터는 대부분{' '}
+ 배열이에요. 받아 온 환율 목록을 골라내고(filter), 바꾸고(map),
+ 합치는(reduce) 손맛이 다음 관문입니다. 다음{' '}
+ 배열 메서드로 데이터 요리하기{' '}
+ 코스에서, fetch로 받아 온 데이터를 화면에 올리기 좋게 다듬는 기술을 이어서 익혀 봅시다.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv2AuthSessionHashPage.jsx b/frontend/src/pages/levels/Lv2AuthSessionHashPage.jsx
new file mode 100644
index 0000000..048e296
--- /dev/null
+++ b/frontend/src/pages/levels/Lv2AuthSessionHashPage.jsx
@@ -0,0 +1,489 @@
+// 이 파일이 하는 일: "로그인 구현: 세션과 비밀번호 해시" 코스 — 인증/인가 구분에서 출발해,
+// 비밀번호 해시(bcrypt)·회원가입 API·세션과 쿠키·로그인/로그아웃·인증 미들웨어·최소 방어까지
+// 7개 섹션으로 안내하는 정적 학습 페이지. 프레이밍은 "내 서비스에 처음으로 '사용자'가 생기는 순간" —
+// 기초 문법을 아는 학생이 블로그 API에 진짜 계정 시스템을 붙여 보게 하는 것이 목표다.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_FLOW = `[브라우저] [서버] [저장소]
+
+① POST /signup ────────────▶ 비밀번호를 해시해서 저장 ──▶ users 테이블
+② POST /login ────────────▶ 해시 비교 → 세션 생성 ─────▶ 세션 저장소
+③ ◀── Set-Cookie: sid=abc1 ── 세션ID를 쿠키로 발급
+④ GET /posts/3 ────────────▶ 쿠키의 sid로 세션 조회
+ (Cookie: sid=abc1) → "아, 이 요청은 7번 사용자!"
+⑤ POST /logout ────────────▶ 세션 파기 + 쿠키 제거
+
+인증(Authentication) = ①~④ "너 누구야?"를 확인하는 일
+인가(Authorization) = 그 뒤 "너 이거 해도 돼?"를 판정하는 일
+→ 로그인은 인증이고, "내 글만 수정 가능"은 인가입니다. (섹션 6에서 구현)`;
+
+const CODE_PLAINTEXT = `-- 절대 이렇게 저장하면 안 되는 users 테이블 (유출되는 순간 전원 피해)
+ id | email | password
+----+-------------------+----------------
+ 1 | kim@example.com | dlrjfdlaqlqjs1! ← 평문 그대로
+ 2 | lee@example.com | correct1234!
+ 3 | park@example.com | correct1234! ← 같은 비번인 것까지 다 보임
+
+-- bcrypt 해시로 저장한 users 테이블 (유출돼도 원문을 알 수 없음)
+ id | email | password_hash
+----+-------------------+------------------------------------------
+ 1 | kim@example.com | $2b$10$N9qo8uLOickgx2ZMRZoMye3xLmN0...
+ 2 | lee@example.com | $2b$10$dXJ3SW6G7P50lGmMkkmwe.aP4Zr9...
+ 3 | park@example.com | $2b$10$Yk2QfCvyM1sD8hJp0aTzXOqWm3Lb...
+ ↑ 같은 비번(2·3번)인데도 해시가 다르다 = 솔트의 힘`;
+
+const CODE_HASH = `const bcrypt = require('bcrypt');
+
+// 해시는 '일방통행'이에요. 비밀번호 → 해시는 되지만, 해시 → 비밀번호는 불가능.
+const hash1 = await bcrypt.hash('correct1234!', 10);
+// '$2b$10$dXJ3SW6G7P50lGmMkkmwe.aP4Zr9uFq...'
+
+// 같은 비밀번호를 한 번 더 해시하면?
+const hash2 = await bcrypt.hash('correct1234!', 10);
+// '$2b$10$Yk2QfCvyM1sD8hJp0aTzXOqWm3Lb0dE...' ← 값이 다르다!
+
+// 매번 다른 이유: bcrypt가 무작위 '솔트(salt)'를 만들어 비밀번호에 섞은 뒤
+// 해시하고, 그 솔트를 해시 문자열 안에 함께 저장하기 때문이에요.
+// 덕분에 "미리 계산해 둔 해시 사전(레인보 테이블)"으로 역추적하는 공격이 막힙니다.
+
+// 그럼 검증은 어떻게? 저장된 해시에서 솔트를 꺼내 입력값을 같은 방식으로
+// 해시해 본 뒤 비교합니다. 복원이 아니라 '재현해서 대조'하는 거예요.
+await bcrypt.compare('correct1234!', hash1); // true
+await bcrypt.compare('wrongpass', hash1); // false
+
+// 둘째 인자 10은 cost(작업 횟수). 클수록 느려요 — 일부러 느리게 만들어서
+// 공격자가 초당 수백만 개를 대입해 보는 걸 비싸게 만드는 설계입니다.`;
+
+const CODE_SIGNUP = `// POST /api/signup — 회원가입: 중복 체크 → 해시 → 저장
+app.post('/api/signup', async (req, res) => {
+ const email = req.body.email;
+ const password = req.body.password;
+ if (!email || !password) {
+ return res.status(400).json({ error: '이메일과 비밀번호는 필수입니다.' });
+ }
+
+ // ① 이메일 중복 체크 — 이미 있으면 409 Conflict
+ const exists = await db.findUserByEmail(email);
+ if (exists) {
+ return res.status(409).json({ error: '이미 가입된 이메일입니다.' });
+ }
+
+ // ② 평문이 아니라 '해시'를 저장한다 (섹션 2에서 배운 그대로)
+ const passwordHash = await bcrypt.hash(password, 10);
+ const user = await db.createUser(email, passwordHash);
+
+ // ③ 응답에 비밀번호(해시조차) 절대 담지 않는다 — 필요한 필드만 골라 보낸다
+ res.status(201).json({ id: user.id, email: user.email });
+});
+
+// 나쁜 예: res.json(user) ← user 객체를 통째로 보내면
+// password_hash가 응답에 실려 나간다. '골라 담기'가 습관이 되어야 해요.`;
+
+const CODE_SESSION = `// 세션 준비 — express-session이 '세션 저장소 + 쿠키 왕복'을 대신해 준다
+const session = require('express-session');
+
+app.use(session({
+ secret: process.env.SESSION_SECRET, // 쿠키 위조 방지 서명 키 (환경변수로!)
+ resave: false,
+ saveUninitialized: false, // 로그인 전엔 세션을 만들지 않는다
+ cookie: {
+ httpOnly: true, // JS로 쿠키 접근 금지 (섹션 7)
+ maxAge: 1000 * 60 * 60 * 2, // 2시간
+ },
+}));
+
+// 동작 원리 (왕복 구조):
+// ① 로그인 성공 → 서버가 세션 저장소에 기록: sid 'abc1' = 사용자 7번
+// ② 응답 헤더: Set-Cookie: connect.sid=abc1
+// ③ 브라우저가 쿠키를 저장하고, 이후 '같은 사이트' 요청마다 자동으로 실어 보냄
+// ④ 서버는 쿠키 속 sid로 저장소를 뒤져 req.session.userId를 복원
+//
+// 핵심: 브라우저가 들고 다니는 건 '출입증 번호(세션ID)'뿐이고,
+// 실제 정보(누구인지)는 서버 쪽 저장소에 있다는 것.`;
+
+const CODE_LOGIN = `// POST /api/login — 로그인: 해시 비교 → 세션ID 재발급 → 세션에 기록
+app.post('/api/login', async (req, res) => {
+ const user = await db.findUserByEmail(req.body.email);
+
+ // 사용자가 없어도, 비밀번호가 틀려도 → '같은 메시지'로 답한다 (아래 설명)
+ const ok = user && (await bcrypt.compare(req.body.password, user.passwordHash));
+ if (!ok) {
+ return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
+ }
+
+ // 로그인 성공 시 세션ID를 '새로' 발급 — 세션 고정 공격 방어 (섹션 7)
+ req.session.regenerate((err) => {
+ if (err) return res.status(500).json({ error: '로그인 처리에 실패했습니다.' });
+ req.session.userId = user.id; // 서버 쪽 기억: "이 세션 = 이 사용자"
+ res.json({ id: user.id, email: user.email });
+ });
+});
+
+// POST /api/logout — 로그아웃 = 서버 세션 파기 + 브라우저 쿠키 제거 (둘 다!)
+app.post('/api/logout', (req, res) => {
+ req.session.destroy(() => { // ① 서버 저장소에서 세션 삭제
+ res.clearCookie('connect.sid'); // ② 브라우저에게 "쿠키 지워" 지시
+ res.status(204).end();
+ });
+});`;
+
+const CODE_AUTH_MW = `// 인증 미들웨어 — "로그인했는지" 검사를 한 곳으로 모은다
+async function requireLogin(req, res, next) {
+ if (!req.session.userId) {
+ return res.status(401).json({ error: '로그인이 필요합니다.' }); // 인증 실패
+ }
+ // 이후의 모든 핸들러가 쓸 수 있게 사용자 정보를 실어 나른다
+ req.user = await db.findUserById(req.session.userId);
+ next();
+}
+
+// 보호 라우트 — 미들웨어를 끼우면 그 아래 코드는 '로그인된 상태'를 보장받는다
+app.get('/api/me', requireLogin, (req, res) => {
+ res.json({ id: req.user.id, email: req.user.email });
+});
+
+// 인가: "내 글만 수정 가능" — 소유권 검사. 로그인했어도 남의 글이면 403
+app.put('/api/posts/:id', requireLogin, async (req, res) => {
+ const post = await db.findPost(req.params.id);
+ if (!post) return res.status(404).json({ error: '글이 없습니다.' });
+
+ if (post.authorId !== req.user.id) {
+ return res.status(403).json({ error: '본인 글만 수정할 수 있습니다.' }); // 인가 실패
+ }
+ const updated = await db.updatePost(post.id, req.body);
+ res.json(updated);
+});
+
+// 401 vs 403 — 헷갈리면 이렇게 기억하세요.
+// 401 Unauthorized: "너 누군지 모르겠어. 로그인부터 해." (인증 실패)
+// 403 Forbidden: "너 누군지는 아는데, 이건 네 권한 밖이야." (인가 실패)`;
+
+const CODE_DEFENSE = `// 최소한의 방어 3종 — 완벽한 보안이 아니라 '기본기'입니다
+// ① 쿠키 옵션: 세션ID가 새어 나갈 통로를 좁힌다
+cookie: {
+ httpOnly: true, // document.cookie로 못 읽음 → 스크립트 주입(XSS)돼도 탈취 어려움
+ secure: true, // HTTPS에서만 전송 → 중간에서 엿보기(도청) 차단 (운영 환경 필수)
+ sameSite: 'lax', // 다른 사이트발 요청엔 쿠키를 잘 안 실음 → CSRF 완화
+}
+
+// ② 로그인 시도 제한: 무차별 대입(brute force)을 비싸게 만든다
+// 아이디어: "같은 IP 또는 같은 계정이 5회 연속 실패하면 15분 잠금"
+const fails = await store.get('login-fail:' + req.body.email);
+if (fails >= 5) {
+ return res.status(429).json({ error: '잠시 후 다시 시도해 주세요.' });
+}
+// 실패할 때마다 카운트 +1 (만료 15분), 성공하면 카운트 삭제
+
+// ③ 세션ID 재발급: 로그인 '성공 직후' 반드시 새 세션ID로 갈아탄다
+req.session.regenerate(() => { req.session.userId = user.id; });
+// 공격자가 미리 심어 둔 세션ID를 사용자가 그대로 쓰게 만드는
+// '세션 고정(session fixation)' 공격을 무력화합니다.`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 지금까지 만든 API는 누가 요청해도 똑같이 응답했어요. 하지만 진짜
+ 서비스엔 '내 계정'이 있죠. 이 코스에서는 비밀번호를 지키는 법(해시)과{' '}
+ 사용자를 기억하는 법(세션)을 배워, 회원가입부터 "내 글만 수정 가능"
+ 까지 — 계정이 있는 서비스의 뼈대를 직접 세웁니다.
+
+
+ 예상 소요 80분
+ 실습: 블로그 API에 로그인 붙이기
+ 선수 지식: HTTP·Express 기초
+
+
+
+ {/* 섹션 바로가기 */}
+
+
+
+
+ 로그인을 구현하기 전에, 단어부터 정확히 잡고 갑시다. 인증(Authentication)은
+ "너 누구야?"를 확인하는 일이에요. 이메일과 비밀번호를 받아 "아, 김수습
+ 님이군요"라고 신원을 확인하는 것 — 로그인이 바로 인증입니다. 인가(Authorization)는
+ 그 다음 질문, "너 이거 해도 돼?"예요. 로그인한 사용자라도 남의 글을
+ 지울 수는 없어야 하죠. 이 코스는 1~5섹션에서 인증을, 6섹션에서 인가를 구현합니다.
+
+
+ 그런데 왜 이게 어려운 문제일까요? HTTP는 기억력이 없기 때문이에요.
+ 요청 하나하나가 서로를 모르는 남남입니다. 1초 전에 로그인에 성공했어도, 다음 요청이
+ 오면 서버는 "누구세요?"부터 다시 시작해요. 이걸 무상태(stateless)라고
+ 부릅니다. 그래서 "로그인했다"는 상태를 어딘가에 이어 붙이는 장치가
+ 필요하고, 그 장치가 이 코스의 주인공인 세션과 쿠키예요.
+
+ {CODE_FLOW}
+
+ 위 그림이 이 코스의 전체 지도입니다. ①회원가입(섹션 3) → ②~③로그인과 세션 발급(섹션
+ 4~5) → ④인증된 요청(섹션 6) → ⑤로그아웃(섹션 5). 각 화살표를 하나씩 코드로
+ 바꿔 나갈 거예요.
+
+
+ 비유 회사 건물을 떠올려 보세요. 1층 안내데스크에서 신분증을 확인받고{' '}
+ 출입증을 받는 게 인증, 그 출입증으로 열리는 문과 안
+ 열리는 문이 나뉘는 게 인가예요. 출입증이 있어도 서버실 문은 안 열리죠.
+ 로그인(인증)과 권한 검사(인가)는 이렇게 항상 한 쌍으로 다니지만 서로 다른 일입니다.
+
+
+
+
+
+ 회원가입 API를 만들기 전에 반드시 짚어야 할 철칙이 있어요.{' '}
+ 비밀번호를 평문(입력된 그대로)으로 저장하면 안 됩니다. 실제로 국내외
+ 유명 서비스들이 DB 유출 사고를 겪었고, 평문 저장이었던 곳은 사용자 전원의 비밀번호가
+ 그대로 공개됐어요. 더 무서운 건 비밀번호 재사용이에요 — 한 사이트에서
+ 털린 이메일·비밀번호 조합으로 다른 사이트 로그인을 시도하는 공격(크리덴셜 스터핑)이
+ 바로 이어집니다. 내 서비스의 부주의가 사용자의 다른 계정까지 위험하게
+ 만드는 거죠.
+
+ {CODE_PLAINTEXT}
+
+ 해법은 해시(hash)입니다. 해시 함수는 입력을 고정 길이의 뒤죽박죽
+ 문자열로 바꾸는데, 결정적인 성질이 하나 있어요 — 일방통행이라는 것.
+ 비밀번호 → 해시는 순식간이지만, 해시 → 비밀번호로 되돌리는 것은 불가능
+ 합니다. 암호화(encrypt)와 다른 점이 바로 여기예요. 암호화는 열쇠가 있으면 복호화가
+ 되지만, 해시는 애초에 되돌릴 수 없게 설계됐어요. 서버조차 사용자의 비밀번호 원문을
+ 모르는 상태 — 그게 목표입니다.
+
+ {CODE_HASH}
+
+ 위 코드에서 눈여겨볼 것 두 가지. 첫째, 같은 비밀번호인데 해시가 매번 다릅니다.{' '}
+ bcrypt가 무작위 솔트(salt)를 섞기 때문인데, 덕분에 공격자가 "자주 쓰는
+ 비밀번호 100만 개의 해시"를 미리 계산해 와도(레인보 테이블) 우리 DB엔 안 통해요.
+ 둘째, bcrypt는 일부러 느립니다. cost 값(예제의 10)을 올릴수록 한 번
+ 해시에 더 오래 걸려서, 초당 수백만 번 대입해 보려는 공격자의 비용이 폭증해요.
+ "느린 게 장점"인 흔치 않은 함수입니다.
+
+
+ 그럼 검증은 어떻게? 복원이 안 되는데 로그인 때 비밀번호가 맞는지 어떻게 확인할까요?
+ 방향을 뒤집으면 됩니다. 사용자가 입력한 값을 같은 솔트로 다시 해시해서{' '}
+ 저장된 해시와 비교하는 거예요. bcrypt.compare(입력값, 저장된해시)가
+ 그 일을 해 줍니다. '풀어서 맞추기'가 아니라 '재현해서 대조하기' — 이 감각이 해시
+ 이해의 핵심이에요.
+
+
+
+
+
+ 이제 첫 코드를 짤 차례예요. 회원가입 API의 뼈대는 세 단계입니다.{' '}
+ ① 이메일 중복 체크 — 이미 가입된 이메일이면{' '}
+ 409 Conflict로 거절합니다. 409는 "요청 자체는 이해했지만
+ 현재 상태와 충돌한다"는 뜻으로, 중복 자원에 딱 맞는 상태 코드예요.{' '}
+ ② 해시해서 저장 — 섹션 2에서 배운{' '}
+ bcrypt.hash로 변환한 값만 DB에 넣습니다.{' '}
+ ③ 응답 골라 담기 — 성공 응답(201 Created)에
+ 비밀번호 관련 필드는 절대 싣지 않아요.
+
+ {CODE_SIGNUP}
+
+ ③이 사소해 보여도 실무 사고의 단골이에요. res.json(user)처럼
+ DB에서 꺼낸 객체를 통째로 응답에 넘기면, 지금은 괜찮아 보여도 나중에
+ 누가 컬럼을 추가하는 순간 그 값까지 줄줄 새어 나갑니다. "응답엔 필요한 필드만{' '}
+ 골라 담는다"를 처음부터 손에 익히세요 — 해시조차 밖으로 내보낼 이유가
+ 없습니다.
+
+
+ 직접 확인해 보기 실습 프로젝트에서 같은 이메일로 회원가입을 두 번{' '}
+ 요청해 보세요. 첫 번째는 201, 두 번째는{' '}
+ 409가 와야 정상입니다. 그리고 DB(또는 저장 파일)를 열어{' '}
+ $2b$10$...로 시작하는 해시가 저장됐는지, 평문이 어디에도
+ 남아 있지 않은지 눈으로 확인하세요.
+
+
+
+
+
+ 섹션 1에서 봤듯 HTTP는 기억력이 없어요. 그래서 로그인 성공의 '증표'를 매 요청에 실어
+ 보내야 하는데, 그 왕복 구조가 세션 + 쿠키입니다. 흐름은 이래요.
+ 로그인에 성공하면 서버가 세션 저장소에 "세션ID abc1{' '}
+ = 사용자 7번"이라고 기록하고, 응답 헤더 Set-Cookie로 그{' '}
+ 세션ID만 브라우저에 건넵니다. 브라우저는 받은 쿠키를 저장해 뒀다가,
+ 이후 같은 사이트로 가는 모든 요청에 자동으로 실어 보내요. 서버는 쿠키
+ 속 세션ID로 저장소를 뒤져 "아, 7번 사용자구나"를 복원합니다.
+
+ {CODE_SESSION}
+
+ 여기서 역할 분담이 중요해요. 브라우저가 들고 다니는 건 뜻 없는 무작위 문자열
+ (세션ID)뿐이고, "그게 누구인지"라는 실제 정보는 서버 쪽 세션 저장소에
+ 있습니다. 마치 코트 보관소의 번호표처럼요 — 번호표 자체엔 코트에 대한 정보가 하나도
+ 없죠. 대신 이 구조의 대가로, 서버는 로그인한 사용자 수만큼 세션을 기억하고
+ 있어야 합니다(상태를 서버가 짊어지는 방식). 저장소는 개발 단계에선 메모리로
+ 충분하고, 서비스가 커지면 Redis 같은 별도 저장소로 옮깁니다.
+
+
+ 직접 확인해 보기 브라우저 개발자도구(F12)의{' '}
+ Application → Cookies를 열어 두고 로그인해 보세요.{' '}
+ connect.sid 같은 이름의 쿠키가 생기는 순간을
+ 직접 볼 수 있습니다. 값을 복사해 보면 뜻을 알 수 없는 문자열일 거예요 — 그게
+ 정상입니다(번호표니까요). 그리고 Network 탭에서 로그인
+ 응답의 Set-Cookie 헤더와, 다음 요청에 자동으로 붙는{' '}
+ Cookie 헤더를 찾아 왕복 구조를 눈으로 확인하세요.
+
+
+
+
+
+ 재료가 다 모였어요. 로그인은 ① 이메일로 사용자 찾기 → ② bcrypt.compare로
+ 비밀번호 대조 → ③ 세션에 사용자 기록 순서입니다. 로그아웃은 그 반대로,
+ 서버의 세션을 파기하고 브라우저의 쿠키도 지우게 하는
+ 것까지가 한 세트예요.
+
+ {CODE_LOGIN}
+
+ 코드에서 일부러 그렇게 짠 부분이 하나 있어요. 이메일이 틀렸든 비밀번호가 틀렸든{' '}
+ 똑같이 "이메일 또는 비밀번호가 올바르지 않습니다"라고 답합니다. 왜
+ 친절하게 구분해 주면 안 될까요? "비밀번호가 틀렸습니다"라고 알려주는 순간, 공격자에게{' '}
+ "이 이메일은 가입돼 있다"는 사실을 공짜로 확인시켜 주기 때문이에요.
+ 그 목록을 모아 비밀번호 대입 공격의 표적을 좁히죠. 에러 메시지는 사용자에겐 조금
+ 불친절해도, 공격자에게 정보를 흘리지 않는 쪽이 원칙입니다.
+
+
+ 로그아웃도 뜯어볼게요. req.session.destroy()만 하고 쿠키를
+ 안 지우면 브라우저에 죽은 번호표가 남고, 반대로 쿠키만 지우고 서버 세션을 안 지우면{' '}
+ 서버엔 여전히 유효한 세션이 살아 있어요. 누군가 그 세션ID를 알고 있다면
+ 로그아웃했다고 믿는 사용자 행세를 계속할 수 있습니다. 그래서 로그아웃 ={' '}
+ 서버 세션 파기 + 쿠키 제거, 반드시 둘 다입니다.
+
+
+ 왜 로그인 성공 시 regenerate? 코드의 req.session.regenerate()는
+ 로그인 성공 직후 세션ID를 새것으로 교체합니다. 로그인 전에 쓰던
+ 세션ID를 그대로 승격시키면, 공격자가 미리 심어 둔 세션ID로 로그인 상태를 가로채는{' '}
+ 세션 고정 공격이 가능해져요. 자세한 원리는 섹션 7에서 다룹니다 —
+ 지금은 "성공하면 번호표를 새로 발급한다"만 기억하세요.
+
+
+
+
+
+ 이제 "로그인한 사람만" 쓸 수 있는 API를 만들 차례예요. 순진하게 만들면 모든 핸들러
+ 첫 줄마다 if (!req.session.userId) ...를 복사하게 되는데,
+ 라우트가 10개면 10번 복붙이고 하나라도 빠뜨리면 그대로 구멍입니다. Express의{' '}
+ 미들웨어가 정확히 이 문제를 풉니다 — 검사를 함수 하나로
+ 모으고, 보호가 필요한 라우트에 끼워 넣는 거예요.
+
+ {CODE_AUTH_MW}
+
+ requireLogin이 하는 일은 두 가지입니다. 세션에 사용자가
+ 없으면 401로 끊어서 401 처리를 일원화하고,
+ 있으면 DB에서 사용자를 찾아 req.user에 실어{' '}
+ 다음 핸들러로 넘겨요(next()). 덕분에 뒤쪽 핸들러들은
+ "여기 도달했다 = 로그인된 상태다"를 보장받고, req.user.id를
+ 바로 꺼내 쓸 수 있습니다.
+
+
+ 그리고 드디어 인가가 등장합니다. 글 수정 라우트를 보세요 — 로그인
+ 검사(인증)를 통과했어도, post.authorId !== req.user.id면{' '}
+ 403 Forbidden으로 거절해요. 이게 소유권 검사입니다.
+ "누구냐"(401 문제)와 "네 것이냐"(403 문제)가 코드에서 어떻게 다른 층으로 갈라지는지,
+ 섹션 1의 구분이 여기서 실물이 됩니다.
+
+
+ 흔한 실수 프론트엔드에서 "수정 버튼을 숨겼으니까 됐다"고 생각하는 것.
+ 버튼을 숨겨도 API는 curl이나 개발자도구로 직접 호출할 수 있어요.
+ 인가 검사는 반드시 서버에서 해야 합니다. 프론트의 버튼 숨김은 UX일 뿐,
+ 보안이 아니에요.
+
+
+
+
+
+ 완벽한 보안은 이 코스의 범위를 넘지만, 이것조차 없으면 곤란한 기본기 세 가지는
+ 챙기고 마칩시다. 첫째, 쿠키 옵션. 세션ID는 곧 출입증이라 새어 나가면
+ 끝이에요. httpOnly는 페이지의 자바스크립트가{' '}
+ document.cookie로 쿠키를 읽지 못하게 막아서, 악성
+ 스크립트가 주입돼도(XSS) 세션ID 탈취를 어렵게 합니다.{' '}
+ secure는 HTTPS 연결에서만 쿠키를 전송하게 해서 중간
+ 도청을 차단하고요.
+
+ {CODE_DEFENSE}
+
+ 둘째, 로그인 시도 제한. 공격자는 자주 쓰는 비밀번호 목록을 기계적으로
+ 대입합니다(무차별 대입, brute force). bcrypt가 한 번 한 번을 느리게 만들어 주지만,
+ 서버 차원에서도 "5회 연속 실패 → 잠시 잠금(429)" 같은
+ 제한을 걸면 공격 비용이 훨씬 올라가요. 실패 횟수를 세고 성공하면 지우는, 단순한
+ 카운터로 시작하면 됩니다.
+
+
+ 셋째, 세션ID 재발급. 섹션 5에서 예고한 세션 고정(session
+ fixation) 공격은 이렇게 흘러가요 — 공격자가 세션ID 하나를 미리 확보해 피해자의
+ 브라우저에 심어 둡니다 → 피해자가 그 세션ID를 단 채로 로그인합니다 → 서버가 그 세션을
+ 그대로 "로그인됨"으로 승격시키면, 같은 세션ID를 아는 공격자도 로그인 상태가
+ 돼요. 방어는 간단합니다. 로그인 성공 직후 세션ID를 새로 발급
+ (regenerate)하면, 공격자가 심어 둔 옛 ID는 휴지 조각이
+ 됩니다.
+
+
+ 과장 금지 이 세 가지로 "보안 끝"이 아니에요. CSRF 토큰, 비밀번호 정책, 2단계
+ 인증 등 지형은 훨씬 넓습니다. 다만 순서가 중요해요 — 평문 저장 안 하기,
+ 에러 메시지로 정보 안 흘리기, 쿠키 옵션, 시도 제한, 세션 재발급. 이 기본기가
+ 없는 서비스에 고급 기법을 얹는 건 문이 열린 집에 CCTV만 다는 격입니다.
+
+
+
+
+
+
🔐 여기까지 왔다면
+
+ 이제 여러분의 블로그 API엔 계정이 생겼습니다. 비밀번호는 해시로만
+ 저장되고(솔트 덕에 같은 비번도 다른 해시), 로그인하면 세션ID라는 번호표가 쿠키로
+ 오가고, 보호 라우트는 401로, 남의 글은 403으로 끊어 내죠. "누구냐(인증)"와
+ "해도 되냐(인가)"를 코드로 갈라 본 경험은 앞으로 어떤 프레임워크를 만나도 그대로
+ 통합니다. 다음은 화면 차례예요 — 로그인 폼의 입력값은 어디에 두고, "로그인됨"
+ 상태는 화면에 어떻게 반영할까요? 다음{' '}
+ React 상태와 이펙트 실전{' '}
+ 코스에서, 오늘 만든 API에 살아 움직이는 프론트엔드를 붙여 봅시다.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv2DbTransactionsPage.jsx b/frontend/src/pages/levels/Lv2DbTransactionsPage.jsx
new file mode 100644
index 0000000..8489c8c
--- /dev/null
+++ b/frontend/src/pages/levels/Lv2DbTransactionsPage.jsx
@@ -0,0 +1,545 @@
+// 이 파일이 하는 일: "DB 연동과 트랜잭션: 데이터가 사는 집" 코스 — 서버를 재시작하면
+// 증발하는 메모리 배열에서 출발해, 테이블 설계·SQL 실행·CRUD·JOIN을 지나
+// all-or-nothing 트랜잭션과 롤백 실습까지 7개 섹션으로 안내하는 정적 학습 페이지.
+// 프레이밍은 "블로그 API의 저장소 교체" — 이전 과정에서 만든 메모리 배열 기반 블로그 API를
+// 그대로 들고 와서, 저장소만 PostgreSQL로 갈아 끼우며 DB의 존재 이유를 몸으로 익힌다.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_MEMORY_LOSS = `// server.js — 지금까지의 블로그 API (저장소 = 메모리 배열)
+let posts = []; // "데이터베이스"라고 부르기엔 민망한, 그냥 변수
+let nextId = 1;
+
+app.post('/api/posts', (req, res) => {
+ const post = { id: nextId++, title: req.body.title, body: req.body.body };
+ posts.push(post); // 배열에 넣는다 = RAM에 넣는다
+ res.status(201).json(post);
+});
+
+// 직접 목격하기:
+// 1) POST로 글을 3개 만든다 → GET /api/posts 에 3개가 보인다
+// 2) 터미널에서 Ctrl+C → node server.js 로 서버 재시작
+// 3) GET /api/posts → [] ...... 글이 전부 증발했다
+//
+// 이유: 변수 posts는 프로세스의 메모리(RAM) 위에 산다.
+// 프로세스가 죽으면 메모리도 반납된다. 데이터의 집이 아니라 임시 텐트였던 것.`;
+
+const CODE_SCHEMA = `-- schema.sql — 글·댓글·사용자를 테이블로 옮기기
+CREATE TABLE users (
+ id BIGSERIAL PRIMARY KEY, -- PK: 이 행의 '주민번호'
+ name VARCHAR(50) NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE posts (
+ id BIGSERIAL PRIMARY KEY,
+ author_id BIGINT NOT NULL REFERENCES users(id), -- FK: users로 가는 연결고리
+ title VARCHAR(200) NOT NULL,
+ body TEXT NOT NULL,
+ comment_count INT NOT NULL DEFAULT 0, -- 6·7섹션 실습에서 트랜잭션으로 갱신할 값
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE comments (
+ id BIGSERIAL PRIMARY KEY,
+ post_id BIGINT NOT NULL REFERENCES posts(id), -- 댓글 N개 → 글 1개 (1:N)
+ author_id BIGINT NOT NULL REFERENCES users(id),
+ body TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- 1:N 관계 읽는 법: "comments.post_id가 posts.id를 가리킨다"
+-- = 글 하나에 댓글 여러 개가 매달린다. 관계는 '외래키 컬럼 하나'로 표현된다.`;
+
+const CODE_POOL = `// db.js — 커넥션 풀: 미리 뚫어 둔 전화선 여러 가닥
+import pg from 'pg';
+
+export const pool = new pg.Pool({
+ host: 'localhost',
+ database: 'blog',
+ user: 'blog',
+ password: process.env.DB_PASSWORD, // 비밀번호는 코드에 두지 않는다
+ max: 10, // 동시에 유지할 연결 수
+});
+
+// 왜 풀인가?
+// DB 연결(connect)은 TCP 접속 + 인증을 거치는 '비싼' 작업이다.
+// 요청마다 새로 연결하면: 요청 100개 = 연결 100번 = 느리고, DB도 지친다.
+// 풀은 연결 10개를 미리 만들어 두고 빌려 줬다 돌려받는다.
+// 요청 100개가 와도 연결은 10개를 돌려 쓴다 — 식당의 '회전하는 테이블'처럼.
+
+const result = await pool.query('SELECT now()'); // 빌리고-쓰고-반납을 알아서
+console.log(result.rows[0]);`;
+
+const CODE_INJECTION = `// 위험한 코드 — 문자열 조립으로 SQL을 만드는 순간, 남이 내 SQL을 쓴다
+app.get('/api/users/search', async (req, res) => {
+ const name = req.query.name;
+ // 절대 금지: 사용자 입력을 SQL 문장에 '이어붙이기'
+ const sql = "SELECT * FROM users WHERE name = '" + name + "'";
+ const result = await pool.query(sql);
+ res.json(result.rows);
+});
+
+// 공격자가 보낸 name: ' OR '1'='1
+// 완성되는 SQL: SELECT * FROM users WHERE name = '' OR '1'='1'
+// → '1'='1'은 항상 참 → 전체 사용자가 통째로 유출된다.
+// name에 '; DROP TABLE users; -- 를 넣으면? 테이블이 날아갈 수도 있다.
+
+// 안전한 코드 — 파라미터 바인딩($1): 값은 값의 자리로만
+const result2 = await pool.query(
+ 'SELECT * FROM users WHERE name = $1', // SQL의 구조는 여기서 확정
+ [name] // 값은 별도 전달 → 문장이 될 수 없음
+);
+// DB가 "이건 어떤 내용이든 그냥 문자열 값"으로 취급하므로
+// OR이든 DROP이든 전부 '이름 글자'일 뿐이다. 인젝션이 원천 차단된다.`;
+
+const CODE_CRUD = `// posts 라우터 — 메모리 배열 CRUD를 SQL로 다시 쓰기
+
+// C: INSERT ... RETURNING — 만든 결과(자동 생성된 id 포함)를 바로 돌려받기
+app.post('/api/posts', async (req, res) => {
+ const { title, body, authorId } = req.body;
+ const result = await pool.query(
+ 'INSERT INTO posts (author_id, title, body) VALUES ($1, $2, $3) RETURNING *',
+ [authorId, title, body]
+ );
+ res.status(201).json(result.rows[0]); // id·created_at까지 채워진 완성본
+});
+
+// U: UPDATE — WHERE를 빼먹으면 '모든 글'이 같은 제목이 된다 (실제 사고 단골)
+// UPDATE posts SET title = $1 ← 전 행 수정. 참사.
+// UPDATE posts SET title = $1 WHERE id=$2 ← 이 한 줄이 회사를 살린다
+app.put('/api/posts/:id', async (req, res) => {
+ const result = await pool.query(
+ 'UPDATE posts SET title = $1, body = $2 WHERE id = $3',
+ [req.body.title, req.body.body, req.params.id]
+ );
+ if (result.rowCount === 0) return res.status(404).json({ error: '없는 글' });
+ res.json({ ok: true }); // rowCount: 실제로 영향받은 행 수 → 0이면 그 id가 없다
+});
+
+// D: DELETE — 같은 원리. rowCount로 "지울 게 있었는지"를 판단
+app.delete('/api/posts/:id', async (req, res) => {
+ const result = await pool.query('DELETE FROM posts WHERE id = $1', [req.params.id]);
+ if (result.rowCount === 0) return res.status(404).json({ error: '없는 글' });
+ res.status(204).end();
+});`;
+
+const CODE_JOIN = `-- 화면: "글 목록 + 작성자 이름 + 댓글 수" — 쿼리 한 방으로
+
+-- ① 작성자 이름 붙이기: INNER JOIN (FK를 따라 두 테이블을 이어 붙인다)
+SELECT p.id, p.title, u.name AS author_name
+FROM posts p
+INNER JOIN users u ON u.id = p.author_id;
+-- "p.author_id가 가리키는 users 행을 찾아 옆에 붙여라"
+
+-- ② 댓글 수까지: LEFT JOIN + GROUP BY
+SELECT p.id, p.title, u.name AS author_name,
+ COUNT(c.id) AS comment_count
+FROM posts p
+INNER JOIN users u ON u.id = p.author_id
+LEFT JOIN comments c ON c.post_id = p.id -- 댓글 0개인 글도 목록엔 나와야 하니 LEFT
+GROUP BY p.id, u.name -- 글 단위로 접어서(COUNT) 센다
+ORDER BY p.created_at DESC;`;
+
+const CODE_N_PLUS_ONE = `// N+1 문제 — 눈으로 확인하기
+// "글 목록을 가져온 뒤, 글마다 작성자를 또 조회"하는 순진한 코드:
+const posts = (await pool.query('SELECT * FROM posts')).rows; // 쿼리 1번
+for (const post of posts) {
+ const author = await pool.query( // 글마다 +1번
+ 'SELECT name FROM users WHERE id = $1', [post.author_id]
+ );
+ post.authorName = author.rows[0].name;
+}
+// 글이 100개면? 1 + 100 = 101번의 쿼리. 글이 늘수록 선형으로 느려진다.
+// DB 로그를 켜고(postgresql.conf: log_statement = 'all') 위 코드를 돌려 보면
+// SELECT가 폭포처럼 쏟아지는 걸 직접 볼 수 있다.
+
+// 해결: 섹션 5의 JOIN 한 방이면 쿼리 1번으로 끝
+const rows = (await pool.query(
+ 'SELECT p.*, u.name AS author_name FROM posts p JOIN users u ON u.id = p.author_id'
+)).rows;
+// 반복문 속의 await pool.query(...)가 보이면 일단 의심할 것.`;
+
+const CODE_TRANSFER = `-- 계좌이체: 트랜잭션이 없으면 생기는 일
+-- A가 B에게 10,000원을 보낸다 = 사실은 UPDATE 두 번이다
+UPDATE accounts SET balance = balance - 10000 WHERE id = 'A'; -- ① A에서 빼고
+-- ← 만약 서버가 정확히 여기서 죽는다면?
+UPDATE accounts SET balance = balance + 10000 WHERE id = 'B'; -- ② B에 더한다
+
+-- ①만 실행되고 죽으면: A의 돈은 사라졌는데 B는 못 받았다. 10,000원 증발.
+-- 이 둘은 "함께 성공하거나, 함께 없던 일"이어야 한다 = all-or-nothing.
+
+BEGIN; -- 여기부터 한 묶음
+UPDATE accounts SET balance = balance - 10000 WHERE id = 'A';
+UPDATE accounts SET balance = balance + 10000 WHERE id = 'B';
+COMMIT; -- 둘 다 성공했을 때만 '진짜로' 반영
+-- 중간에 무엇이 잘못되든 ROLLBACK; 하면 BEGIN 이전으로 완전 복귀.
+-- COMMIT 전의 변경은 '연필로 쓴 초안', COMMIT이 '볼펜으로 덮어쓰기'다.`;
+
+const CODE_TX_PRACTICE = `// 실습 핵심 — '댓글 작성 + 글의 댓글수 갱신'을 하나의 트랜잭션으로
+app.post('/api/posts/:id/comments', async (req, res) => {
+ const client = await pool.connect(); // 트랜잭션은 '같은 연결' 위에서만 성립!
+ try {
+ await client.query('BEGIN');
+
+ const comment = await client.query(
+ 'INSERT INTO comments (post_id, author_id, body) VALUES ($1, $2, $3) RETURNING *',
+ [req.params.id, req.body.authorId, req.body.body]
+ );
+ const updated = await client.query(
+ 'UPDATE posts SET comment_count = comment_count + 1 WHERE id = $1',
+ [req.params.id]
+ );
+ if (updated.rowCount === 0) throw new Error('없는 글'); // → catch → ROLLBACK
+
+ await client.query('COMMIT'); // 둘 다 성공 → 한 번에 확정
+ res.status(201).json(comment.rows[0]);
+ } catch (e) {
+ await client.query('ROLLBACK'); // 하나라도 실패 → 둘 다 없던 일로
+ res.status(400).json({ error: e.message });
+ } finally {
+ client.release(); // 빌린 연결은 반드시 풀에 반납
+ }
+});
+// 주의: pool.query()를 섞어 쓰면 안 된다 — 풀이 매번 '다른 연결'을 빌려 줄 수 있어
+// BEGIN과 INSERT가 서로 남남인 연결에서 실행될 수 있다. 트랜잭션 중엔 client만.`;
+
+const CODE_ROLLBACK_TEST = `// 실패 시나리오 실습 ① — 일부러 중간에 에러를 던져 롤백 확인
+await client.query('BEGIN');
+await client.query(
+ 'INSERT INTO comments (post_id, author_id, body) VALUES ($1, $2, $3)',
+ [postId, authorId, '이 댓글은 저장되면 안 됩니다']
+);
+throw new Error('일부러 터뜨림'); // ← COMMIT에 도달하지 못한다
+// catch에서 ROLLBACK 실행 후, psql에서 확인:
+// SELECT count(*) FROM comments; → 늘어나지 않았다. INSERT가 없던 일이 됐다.
+
+-- 실습 ② — 커밋 전·후를 '다른 세션'에서 조회해 보기 (격리 맛보기)
+-- [터미널1] psql │ [터미널2] psql (또 하나의 접속 = 다른 세션)
+-- BEGIN; │
+-- INSERT INTO comments │ SELECT count(*) FROM comments;
+-- (...) VALUES (...); │ → 안 보인다! 아직 '초안'이라 남들 눈엔 없음
+-- COMMIT; │ SELECT count(*) FROM comments;
+-- │ → 이제 보인다. COMMIT이 '공개' 스위치다.
+-- 이렇게 각 트랜잭션이 서로의 미완성 작업을 보지 못하게 하는 성질이 '격리(isolation)'.`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 지금까지 우리 블로그 API의 데이터는 서버의 변수 안에 살았어요.
+ 서버를 껐다 켜면? 전부 증발합니다. 이 코스에서는 그 데이터를{' '}
+ PostgreSQL이라는 진짜 집으로 이사시키고, "댓글도 달렸는데 댓글수는
+ 안 올라간" 어정쩡한 상태가 생기지 않도록 트랜잭션(all-or-nothing)
+ 으로 묶는 법까지 익힙니다.
+
+
+ 예상 소요 80분
+ 실습: 메모리 배열 → PostgreSQL 교체
+ 선수 지식: JS 기초 · HTTP API
+
+
+
+ {/* 섹션 바로가기 */}
+
+
+
+
+ 백문이 불여일견이에요. 지금 만든 블로그 API에 글을 몇 개 올린 다음,{' '}
+ 서버를 껐다 켜 보세요. 정성 들여 쓴 글이 흔적도 없이 사라집니다.
+ 변수는 프로세스의 메모리(RAM) 위에 살고, 프로세스가 죽으면 메모리는
+ 운영체제에 반납되니까요.
+
+ {CODE_MEMORY_LOSS}
+
+ 데이터베이스는 이 문제를 포함해 크게 세 가지를 해결해 줍니다.{' '}
+ ① 영속성 — 데이터를 디스크에 안전하게 기록해서, 서버가 죽든 정전이
+ 나든 살아남게 합니다. ② 동시성 — 사용자 100명이 동시에 글을 쓰고
+ 지워도 데이터가 꼬이지 않게 교통정리를 해 줍니다(배열에 동시에 push하는 상황을
+ 상상해 보세요). ③ 검색 — "제목에 'DB'가 들어간 글을 최신순으로
+ 10개"를, 데이터가 백만 건이어도 순식간에 찾아 줍니다. 배열이었다면 매번 전체를
+ 훑어야 했겠죠.
+
+
+ 그중에서도 우리는 관계형 DB(PostgreSQL)를 선택합니다. 기준은
+ 단순해요. 우리 데이터는 글·댓글·사용자처럼 서로 관계를 맺는 정형
+ 데이터이고, "댓글수와 실제 댓글이 어긋나면 안 된다" 같은{' '}
+ 정합성이 중요하며, 뒤에서 배울 트랜잭션이
+ 필요하니까요. 이 세 조건에 관계형 DB만큼 검증된 도구가 없습니다.
+
+
+ 비유 메모리 배열은 화이트보드예요 — 쓰긴 빠르지만 퇴근할 때
+ 지워질 수 있죠. DB는 등기소의 장부입니다. 기록이 남고, 여러 창구가
+ 동시에 써도 관리되고, 색인으로 빨리 찾을 수 있어요. 서비스의 데이터는 화이트보드가
+ 아니라 장부에 살아야 합니다.
+
+
+
+
+
+ 관계형 DB의 세계에서 데이터는 테이블(표)에 삽니다. 우리 블로그의
+ 등장인물은 셋 — 사용자, 글, 댓글. 각각을 표 하나씩으로 옮기고,
+ 그 사이의 관계를 선으로 잇는 게 설계의 전부예요.
+
+ {CODE_SCHEMA}
+
+ 핵심 개념 두 개만 잡으면 됩니다. PK(기본키, Primary Key)는 각 행의{' '}
+ 주민번호예요. 같은 이름의 사용자가 두 명이어도{' '}
+ id는 절대 겹치지 않아서, "그 행"을 정확히 집어낼 수
+ 있죠. FK(외래키, Foreign Key)는 다른 테이블의 PK를 가리키는{' '}
+ 연결고리입니다. comments.post_id가{' '}
+ posts.id를 가리키는 순간, "이 댓글은 저 글의 것"이라는
+ 관계가 데이터에 새겨져요.
+
+
+ "글 하나에 댓글 여러 개"처럼 1:N 관계는 항상{' '}
+ N 쪽 테이블에 외래키 컬럼 하나를 두는 것으로 표현합니다. 댓글(N)이
+ 자기 몸에 post_id를 적어 두는 거예요. 글(1) 쪽에
+ "내 댓글 목록"을 배열로 넣지 않는다는 점이 배열 시절과 가장 다른 사고방식입니다.
+
+
+ FK의 보너스REFERENCES posts(id)라고 선언해
+ 두면, DB가 존재하지 않는 글에 댓글 다는 것을 스스로 거부해 줍니다.
+ 앱 코드가 실수해도 데이터의 마지막 방어선이 지켜지는 셈 — 이런 걸{' '}
+ 참조 무결성이라고 불러요.
+
+
+
+
+
+ 이제 Node 서버에서 PostgreSQL에 말을 걸 차례예요. 공식에 가까운 드라이버인{' '}
+ pg 패키지를 쓰는데, 시작부터 습관 하나를 제대로
+ 들입시다 — 연결은 커넥션 풀(Pool)로.
+
+ {CODE_POOL}
+
+ DB 연결 한 번에는 TCP 접속과 인증이라는 비용이 들어요. 요청이 올 때마다 새로
+ 연결하면 그 비용을 매번 치르고, DB 쪽도 연결 수가 폭증해 버팁니다. 풀은{' '}
+ 연결 몇 개를 미리 만들어 두고 빌려 줬다 돌려받는 구조라, 요청이
+ 아무리 많아도 연결 수는 일정하게 유지돼요.
+
+
+ 그리고 이 코스에서 가장 중요한 보안 습관이 나옵니다. SQL에 사용자
+ 입력을 넣을 땐 반드시 파라미터 바인딩($1,{' '}
+ $2…)을 쓰세요. 문자열을 이어붙여 SQL을 만드는
+ 순간, 사용자가 내 SQL 문장을 대신 쓰게 됩니다.
+
+ {CODE_INJECTION}
+
+ SQL 인젝션은 이론이 아니다 위 시연처럼{' '}
+ ' OR '1'='1 한 줄이면 전체 데이터가 유출되고,{' '}
+ DROP TABLE이 섞이면 테이블이 통째로 사라질 수 있어요.
+ 실제 대형 유출 사고의 단골 원인입니다. 방어법은 외우지 않아도 될 만큼 단순해요 —{' '}
+ SQL 문장과 값을 절대 한 문자열로 섞지 않는다. 값은 언제나{' '}
+ $1 자리로만. DB는 바인딩된 값을 "어떤 내용이든 그냥
+ 값"으로 취급하므로 문장 구조를 바꿀 수 없습니다.
+
+
+
+
+
+ 준비가 끝났으니 본격 이사입니다. 배열 시절의{' '}
+ push · find ·{' '}
+ splice를 각각{' '}
+ INSERT · SELECT ·{' '}
+ UPDATE/DELETE로
+ 번역해요. 라우터의 겉모양(URL·상태코드)은 그대로 두고 속만 갈아
+ 끼우는 게 포인트 — API를 쓰는 쪽에서는 이사한 걸 눈치채지 못해야 합니다.
+
+ {CODE_CRUD}
+
+ 새로 익힐 무기가 둘 있어요. 첫째, INSERT ...
+ RETURNING * — 방금 만든 행을 (DB가 채워 준{' '}
+ id·created_at까지 포함해)
+ 그 자리에서 돌려받습니다. INSERT 후 다시 SELECT할 필요가 없죠. 둘째,{' '}
+ rowCount — 쿼리가 실제로 영향을 준
+ 행의 수예요. UPDATE·DELETE에서 이 값이 0이면 "그런
+ id의 글은 없었다"는 뜻이니, 그대로 404로 응답하면 됩니다. 배열
+ 시절의 find()가{' '}
+ undefined를 돌려주던 것과 같은 역할이에요.
+
+
+ WHERE 없는 UPDATE — 전설의 참사{' '}
+ UPDATE posts SET title = '테스트'처럼{' '}
+ WHERE를 빼먹으면 테이블의 모든 행이 바뀝니다. DELETE도 마찬가지 —
+ WHERE 없는 DELETE 한 줄로 전체 데이터가 사라진 사고가 실제로 심심찮게 일어나요.
+ 습관 하나로 예방합시다: UPDATE·DELETE를 쓸 땐 WHERE부터 먼저 쓰고{' '}
+ 앞부분을 채우기. 그리고 운영 DB에서 손으로 실행할 땐 다음 섹션의 트랜잭션으로 감싸고
+ 확인 후 COMMIT하는 것이 안전벨트입니다.
+
+
+
+
+
+ 블로그 목록 화면을 떠올려 보세요. 글 제목 옆에 작성자 이름이 있고,
+ 아래엔 댓글 수가 붙죠. 그런데 우리 테이블에서 이름은{' '}
+ users에, 댓글은 comments에
+ 살아요. 흩어진 데이터를 FK를 따라 이어 붙이는 도구가{' '}
+ JOIN입니다.
+
+ {CODE_JOIN}
+
+ INNER JOIN은 "p.author_id가 가리키는{' '}
+ users 행을 찾아 옆에 붙여라"예요. 섹션 2에서 심어 둔
+ 연결고리(FK)가 여기서 길이 됩니다. 댓글 수는{' '}
+ LEFT JOIN + GROUP BY 조합 — 글마다 매달린 댓글들을 글 단위로
+ 접어서(GROUP BY p.id){' '}
+ COUNT로 셉니다. 댓글이 0개인 글도 목록에는 나와야
+ 하니, 짝이 없어도 행을 남겨 주는 LEFT JOIN을 쓴다는 점도 눈여겨
+ 두세요(INNER였다면 댓글 없는 글이 목록에서 사라집니다).
+
+
+ JOIN을 모르면 자연스럽게 이런 코드를 쓰게 돼요 — "글 목록을 가져온 다음, 글마다
+ 작성자를 또 조회". 이것이 악명 높은 N+1 쿼리 문제입니다.
+
+ {CODE_N_PLUS_ONE}
+
+ 냄새 감지법반복문 안의 await
+ pool.query(...)가 보이면 일단 N+1을 의심하세요. 데이터 10개일 땐
+ 멀쩡하다가 1,000개가 되는 순간 느려지는 API의 단골 원인이에요. 해결의 방향은 늘
+ 같습니다 — 반복 조회를 JOIN(또는 IN 절) 한 방으로 합치기.
+
+
+
+
+
+ 이 코스의 심장부입니다. 질문 하나로 시작할게요 — 계좌이체는 DB
+ 입장에서 무엇일까요? "A에서 빼는 UPDATE"와 "B에 더하는 UPDATE",{' '}
+ 두 번의 쓰기예요. 그런데 첫 번째만 실행된 순간 서버가 죽는다면?
+
+ {CODE_TRANSFER}
+
+ 이런 "여러 개의 쓰기가 반드시 한 덩어리여야 하는" 상황을 위해 DB가 주는 도구가{' '}
+ 트랜잭션입니다. BEGIN으로 묶음을
+ 열고, 그 안의 변경은 연필로 쓴 초안 상태로 쌓이다가,{' '}
+ COMMIT에서 한꺼번에 확정돼요. 중간에 무엇이든
+ 잘못되면 ROLLBACK — BEGIN{' '}
+ 이후의 모든 변경이 통째로 없던 일이 됩니다. 절반만 반영된 어정쩡한
+ 상태는 존재할 수 없어요. 그래서 all-or-nothing.
+
+
+ 우리 블로그에도 똑같은 구조가 있어요 — 실습 과제인 "댓글 작성 + 글의
+ 댓글수(comment_count) 갱신". 댓글 INSERT만
+ 되고 댓글수 UPDATE가 실패하면, 화면엔 "댓글 3개"라는데 실제론 4개가 달린 이상한
+ 글이 됩니다. 묶읍시다.
+
+ {CODE_TX_PRACTICE}
+
+ 트랜잭션은 '같은 연결' 위에서만 풀에서 빌린{' '}
+ client 하나로 BEGIN부터{' '}
+ COMMIT까지 전부 실행해야 해요. 중간에{' '}
+ pool.query()를 섞으면 풀이 다른 연결을
+ 빌려 줄 수 있고, 그 쿼리는 내 트랜잭션 바깥에서 실행됩니다. 그리고{' '}
+ finally의 client.release()를
+ 잊으면 연결이 반납되지 않아, 풀이 말라붙어 서버 전체가 멈춰요.
+
+
+ 경계를 정하는 감각 트랜잭션은 "이것들이 반쪽만 반영되면 데이터가
+ 거짓말을 하게 되는가?"로 경계를 긋습니다. 댓글 INSERT와 댓글수 UPDATE →
+ 반쪽이면 거짓말 → 한 트랜잭션. 반면 "댓글 작성 + 조회수 로그 남기기"처럼 하나가
+ 실패해도 다른 하나가 유효한 작업까지 욕심내서 묶으면, 로그 실패가 댓글 작성까지
+ 취소시키는 과잉 결합이 돼요. 필요한 만큼만, 짧게 — 가 원칙입니다.
+
+
+
+
+
+ 트랜잭션은 성공 경로만 봐서는 절대 체득되지 않아요. 일부러 부수는 것이
+ 이번 섹션의 실습입니다. 두 가지를 눈으로 확인해 봅시다.
+
+ {CODE_ROLLBACK_TEST}
+
+ 실습 ①은 INSERT 다음에 고의로 에러를 던져{' '}
+ COMMIT에 도달하지 못하게 하는 거예요. catch에서{' '}
+ ROLLBACK이 돌고 나면, 분명 INSERT가 실행됐는데도
+ 테이블엔 아무 흔적이 없습니다. 실습 ②는 psql 터미널을{' '}
+ 두 개 열고, 한쪽에서 BEGIN 후
+ INSERT만 한 상태로 다른 쪽에서 조회해 보는 것. 커밋 전엔 안 보이고,
+ 커밋하는 순간 보입니다. 각 트랜잭션이 서로의 미완성 작업을 보지 못하게 하는 이
+ 성질이 격리(isolation)예요 — 깊은 격리 수준 이야기는 다음 기회에
+ 다루고, 오늘은 "커밋 전의 변경은 남에게 없는 것"이라는 감각만 가져가면 충분합니다.
+
+
+ 마지막으로 실무 감각 하나. 트랜잭션 안에서 외부 API를 호출하면 안
+ 됩니다. 이유는 두 겹이에요. 첫째, 트랜잭션이 열려 있는 동안 그 연결(그리고
+ DB가 잡은 잠금)은 계속 점유되는데, 외부 API가 3초 걸리면 그 3초
+ 내내 다른 요청들이 줄을 서요. 느린 외부 호출 하나가 풀 전체를 말려 버릴 수 있습니다.
+ 둘째, DB는 롤백이 되지만 외부 세계는 롤백이 안 돼요. 트랜잭션
+ 안에서 알림 메일을 보냈는데 그 뒤 롤백되면? 댓글은 없던 일이 됐는데 메일은 이미
+ 날아갔죠. 그래서 원칙은 — 트랜잭션은 DB 작업만으로 짧게 닫고, 외부 호출은
+ 커밋이 확정된 다음에.
+
+
+ 직접 확인해 보기 실습 ②를 할 때, BEGIN만 하고 COMMIT을 안 한 채 psql을
+ 그냥 꺼 보세요. PostgreSQL은 연결이 끊긴 트랜잭션을 자동으로 롤백
+ 합니다 — "중간에 죽으면?"의 답이 바로 이거예요. 서버가 어떻게 죽든, 커밋에 도달하지
+ 못한 변경은 DB가 알아서 없던 일로 만들어 줍니다.
+
+
+
+
+
+
🗄️ 여기까지 왔다면
+
+ 이제 우리 블로그의 데이터는 서버를 껐다 켜도 살아남는 진짜 집에
+ 삽니다. 테이블과 관계(PK·FK), 커넥션 풀과 $1 바인딩, RETURNING과
+ rowCount, JOIN과 N+1, 그리고 all-or-nothing 트랜잭션까지 —
+ "돌아가는 API"를 "믿을 수 있는 API"로 끌어올리는 재료를 손에 쥐었어요. 그런데
+ 데이터가 영원히 남는 순간, 새 질문이 따라옵니다. "이 요청을 보낸 사람이
+ 누구인지"는 어떻게 믿죠? 비밀번호는 DB에 어떻게 저장해야 유출돼도
+ 안전할까요? 다음{' '}
+ 로그인 구현: 세션과 비밀번호
+ 해시 코스에서, 이 집에 잠금장치를 달아 봅시다.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv2ErrorHandlingPatternsPage.jsx b/frontend/src/pages/levels/Lv2ErrorHandlingPatternsPage.jsx
new file mode 100644
index 0000000..e22ae35
--- /dev/null
+++ b/frontend/src/pages/levels/Lv2ErrorHandlingPatternsPage.jsx
@@ -0,0 +1,497 @@
+// 이 파일이 하는 일: "에러 처리 패턴: 무너지지 않는 서비스" 코스 — 에러를 '없애는' 게 아니라
+// '다스리는' 기술을 백엔드(Express 에러 미들웨어·커스텀 에러 클래스·응답 포맷)부터
+// 프론트(에러 바운더리·다시 시도 폴백 UI)·로깅까지 7개 섹션으로 안내하는 정적 학습 페이지.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_TWO_KINDS = `① 예상된 실패 (operational error) — 정상 운영 중에도 '일어나는' 일
+ · 없는 글 번호로 조회 → 404
+ · 제목 없이 글 등록 시도 → 400
+ → 코드는 멀쩡하다. 사용자에게 "무엇이 잘못됐는지" 알려 주면 된다.
+
+② 버그 (programmer error) — 코드가 잘못돼서 '일어나면 안 되는' 일
+ · undefined에서 .title을 읽음 (TypeError)
+ → 사용자가 할 수 있는 게 없다. 로그에 남기고 500.
+
+핵심: ①은 '사용자에게 설명'이 답, ②는 '개발자에게 보고'가 답.`;
+
+const CODE_SWALLOW = `// 최악의 코드: 에러를 '삼키는' catch
+try {
+ await savePost(post);
+} catch (e) {
+ // ...아무것도 안 함. 저장이 실패했는데 성공한 척 흘러간다
+}
+showToast('작성 완료!'); // 화면엔 완료가 뜨는데 DB엔 글이 없다
+
+// 에러가 사라진 게 아니라 에러를 '추적할 단서'가 사라진 것.
+// 처리 못 하겠으면 최소한: catch (e) { console.error(e); throw e; }`;
+
+const CODE_ASYNC_LEAK = `// 함정: await가 없으면 try/catch는 장식이 된다
+async function loadPosts() {
+ try {
+ fetchPosts(); // ← await 없음! Promise가 그냥 떠내려간다
+ } catch (e) {
+ // fetchPosts가 실패해도 여기는 '절대' 실행되지 않는다
+ }
+}
+// await가 없으면 에러는 try 블록이 이미 끝난 뒤 허공에서 터진다.
+// 콘솔의 "Unhandled promise rejection"이 바로 이 장면.`;
+
+const CODE_ASYNC_RIGHT = `// 올바른 형태 — await로 에러를 '이 자리로' 끌어온다
+async function loadPosts() {
+ try {
+ const posts = await fetchPosts(); // 실패하면 여기서 throw
+ return posts;
+ } catch (e) {
+ if (e instanceof NetworkError) {
+ return []; // ① 처리할 수 있는 에러 → 여기서 처리
+ }
+ throw e; // ② 모르는 에러 → 다시 던진다 (삼키지 말 것!)
+ } finally {
+ hideSpinner(); // ③ 성공하든 실패하든 '반드시' 실행 — 정리 작업
+ }
+}`;
+
+const CODE_CUSTOM_ERRORS = `// errors.js — '의미 있는' 에러 클래스 만들기
+class AppError extends Error {
+ constructor(message, statusCode) {
+ super(message);
+ this.name = this.constructor.name; // 'NotFoundError' 같은 클래스명
+ this.statusCode = statusCode; // HTTP 상태를 에러에 실어 나른다
+ this.isExpected = true; // "예상된 실패" 표식
+ }
+}
+
+class NotFoundError extends AppError {
+ constructor(message) {
+ super(message || '리소스를 찾을 수 없습니다', 404);
+ }
+}
+
+// ValidationError(400), UnauthorizedError(401)도 같은 방식으로 추가`;
+
+const CODE_CUSTOM_USE = `// 던지는 쪽 — 라우트 코드가 '무슨 일인지' 말하게 된다
+const post = await Post.findById(id);
+if (!post) throw new NotFoundError('글 ' + id + '번을 찾을 수 없습니다');
+if (!req.body.title) throw new ValidationError('제목은 필수입니다');
+
+// 받는 쪽 — instanceof로 에러의 '종류'를 분기한다
+if (err instanceof ValidationError) {
+ // 400: 사용자가 고칠 수 있는 문제
+} else if (err instanceof AppError) {
+ // err.statusCode: 에러가 자기 상태코드를 스스로 들고 왔다
+} else {
+ // 그 외 전부 = 예상 밖의 버그 → 500
+}`;
+
+const CODE_MIDDLEWARE = `// app.js — Express 에러 미들웨어: 인자가 '4개'면 에러 담당이 된다
+app.use((err, req, res, next) => {
+ // 어느 라우트에서든 next(err)로 넘긴 에러가 전부 여기로 모인다
+ const status = err.isExpected ? err.statusCode : 500;
+ res.status(status).json({
+ error: { code: err.name, message: err.message },
+ });
+});
+// 위치가 중요: 모든 라우트 등록 '뒤에' 둬야 마지막 그물이 된다.
+// 동기 라우트는 throw만 해도 Express가 알아서 여기로 배달해 준다.`;
+
+const CODE_ASYNC_WRAP = `// 함정: async 라우트의 에러는 Express 4가 '못' 받는다
+app.get('/api/posts', async (req, res) => {
+ const posts = await Post.findAll(); // 여기서 터지면? 미들웨어에 안 닿고
+ res.json(posts); // 요청이 영원히 매달린다
+});
+
+// 해결: catch로 붙잡아 next(err)로 '손수 배달'하는 래퍼
+const wrap = (fn) => (req, res, next) => {
+ Promise.resolve(fn(req, res, next)).catch(next);
+};
+
+app.get('/api/posts', wrap(async (req, res) => {
+ const posts = await Post.findAll(); // 실패하면 wrap이 next(err) 호출
+ res.json(posts);
+}));
+// (참고: Express 5부터는 async 에러도 자동으로 전달된다)`;
+
+const CODE_FORMAT = `실패 응답은 '항상' 이 모양 — 전 API 공통
+{
+ "error": {
+ "code": "POST_NOT_FOUND",
+ "message": "글을 찾을 수 없습니다"
+ }
+}
+
+- code: 기계가 읽는 이름표. 프론트가 분기할 때 쓴다 (불변)
+- message: 사람이 읽는 설명. 화면에 그대로 보여 줄 수 있는 문장`;
+
+const CODE_FORMAT_MIDDLEWARE = `// 에러 미들웨어 완성형 — 예상된 실패와 버그를 다르게 내보낸다
+app.use((err, req, res, next) => {
+ if (err.isExpected) {
+ // ① 예상된 실패: 준비된 상태코드·메시지를 그대로 전달
+ return res.status(err.statusCode).json({
+ error: { code: err.name, message: err.message },
+ });
+ }
+ // ② 예상 밖 에러(버그): 세부는 감추고 범용 메시지만
+ console.error(err); // 스택은 '로그'에만 남긴다
+ res.status(500).json({
+ error: { code: 'INTERNAL_ERROR', message: '일시적인 오류가 발생했습니다' },
+ });
+});
+
+// 절대 금지: res.status(500).json({ message: err.stack })
+// 스택엔 파일 경로·폴더 구조·라이브러리 버전이 담긴다 — 공격자용 내부 지도`;
+
+const CODE_BOUNDARY = `// ErrorBoundary.jsx — '렌더링 중' 터진 에러를 받아 내는 안전망
+import { Component } from 'react';
+
+class ErrorBoundary extends Component {
+ state = { hasError: false };
+
+ static getDerivedStateFromError() {
+ return { hasError: true }; // 자식이 터지면 폴백 모드로 전환
+ }
+
+ componentDidCatch(error, info) {
+ console.error('렌더링 에러:', error, info); // 로그 전송 자리
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return this.props.fallback; // 무너진 자리에 대신 세울 화면
+ }
+ return this.props.children; // 평소엔 자식을 그대로 렌더
+ }
+}
+
+// 쓰는 곳:
+// → PostList가 터져도 헤더·사이드바·다른 영역은 멀쩡히 산다`;
+
+const CODE_RETRY = `// PostList.jsx — '다시 시도' 버튼이 있는 폴백 (요청 실패용)
+function PostList() {
+ const [state, setState] = useState({ status: 'loading', posts: [] });
+
+ const load = () => {
+ setState({ status: 'loading', posts: [] });
+ fetch('/api/posts')
+ .then((res) => {
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ return res.json();
+ })
+ .then((posts) => setState({ status: 'ok', posts }))
+ .catch(() => setState({ status: 'error', posts: [] }));
+ };
+
+ useEffect(load, []);
+
+ if (state.status === 'error') {
+ return (
+
+
글 목록을 불러오지 못했어요.
+
+
+ );
+ }
+ // ...loading이면 스피너, ok면 목록 렌더
+}`;
+
+const CODE_LOGGING = `// ① 레벨 있는 로깅 — 심각도에 따라 다른 서랍에 넣는다
+logger.info('서버 시작: 포트 3000'); // 정상 흐름의 기록
+logger.warn('로그인 5회 연속 실패: user42'); // 이상 징후, 아직 장애는 아님
+logger.error('DB 연결 끊김', err); // 지금 살펴야 할 문제
+
+// console.log만 쓰면 전부 같은 줄로 쏟아져서, 로그 만 줄 속에서
+// "지금 무엇이 심각한가"를 걸러 낼 방법이 없다.
+// 레벨이 있으면 "error만 보여 줘" 한 번으로 소음이 걷힌다.`;
+
+const CODE_REQUEST_ID = `// ② 요청 ID — 에러와 요청을 실 하나로 잇기
+app.use((req, res, next) => {
+ req.id = crypto.randomUUID(); // 요청마다 고유한 번호표를 발급
+ next();
+});
+
+// 에러 미들웨어에서: 로그에는 번호표 + 스택 전부
+console.error('[' + req.id + ']', err);
+
+// 응답에는 번호표만 — 스택은 절대 안 나간다
+res.status(500).json({
+ error: {
+ code: 'INTERNAL_ERROR',
+ message: '일시적인 오류가 발생했습니다',
+ requestId: req.id,
+ },
+});
+
+// 사용자가 "requestId a1b2c3에서 오류"라고 문의하면,
+// 개발자는 로그에서 a1b2c3 검색 → 그 요청 하나의 전 과정을 추적한다.`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 에러는 없앨 수 없어요. 다스릴 뿐입니다. 네트워크는 끊기고, 사용자는
+ 예상 밖의 값을 넣고, DB는 가끔 응답이 늦죠. 좋은 서비스의 기준은 "에러가 나느냐"가
+ 아니라 에러가 났을 때의 표정이에요. 백엔드의 에러 미들웨어부터
+ 프론트의 '다시 시도' 폴백 UI까지, 실패의 순간에도 품위를 지키는 설계를 한 바퀴
+ 돌아봅니다.
+
+
+ 예상 소요 65분
+ 실습: 블로그 앱 에러 응답 통일 + 폴백 UI
+ 선수 지식: JS 기초·Express·React 기본
+
+
+
+ {/* 섹션 바로가기 */}
+
+
+
+
+ 모든 에러 처리는 이 구분에서 출발해요. 예상된 실패는 코드가 멀쩡해도
+ 일어나는 일이에요. 없는 글 번호로 조회하거나 제목 없이 글을 올리려 하는 것 — 서비스가
+ 살아 있는 한 매일 일어나는 정상적인 사건이죠. 반면{' '}
+ 버그는 코드가 잘못돼서 일어나면 안 되는 게 일어난 거예요.{' '}
+ undefined.title을 읽는 순간 같은 거요.
+
+ {CODE_TWO_KINDS}
+
+ 왜 나눠야 할까요? 처방이 정반대이기 때문이에요. 예상된 실패는
+ 사용자에게 "제목을 입력해 주세요"라고 설명하면 끝나지만, 버그는
+ 사용자가 할 수 있는 게 없어요 — 로그에 남겨 개발자에게 보고해야 하죠.
+ 그리고 두 종류 모두에게 통하는 단 하나의 금기가 있습니다. 에러를{' '}
+ 삼키는 것이에요.
+
+ {CODE_SWALLOW}
+
+ 왜 '삼키기'가 최악인가 에러가 나는 것보다 나쁜 건, 에러가 났는데{' '}
+ 아무도 모르는 것이에요. 빈 catch는 에러를 없애는 게 아니라 에러의{' '}
+ 증거를 인멸합니다. 사용자는 "글이 증발했다"며 떠나고, 개발자는 재현할
+ 단서조차 없죠. 처리할 수 없으면 최소한 로그를 남기고 다시 던지세요.
+
+
+
+
+
+ 자바스크립트 에러 처리에서 가장 흔한 사고 지점이 여기예요.{' '}
+ try/catch로 감쌌는데도 에러가 잡히지 않고 콘솔에{' '}
+ Unhandled promise rejection이 뜬다면, 십중팔구{' '}
+ await를 빼먹은 것입니다.
+
+ {CODE_ASYNC_LEAK}
+
+ try/catch는 지금 이 자리에서 터진 에러만{' '}
+ 잡아요. await가 없으면 함수는 Promise의 결과를 기다리지
+ 않고 지나가 버리고, 실패는 try 블록이 이미 끝난 뒤 —
+ catch의 손이 닿지 않는 곳에서 — 터집니다. await는
+ "결과(성공이든 에러든)를 이 자리로 끌어와라"는 뜻이에요.
+
+ {CODE_ASYNC_RIGHT}
+
+ catch 안에서의 판단 기준은 딱 하나입니다.{' '}
+ "여기서 이 에러를 해결할 수 있는가?" 해결할 수 있으면(빈 목록으로
+ 대체, 재시도) 처리하고, 없으면 다시 던져서 해결할 수 있는 곳까지 올려
+ 보내세요. 스피너 끄기, 연결 닫기 같은 정리 작업은{' '}
+ finally에 두면 성공·실패 어느 쪽이든 반드시 실행됩니다.
+
+
+
+
+
+ throw new Error('없음') 같은 맨몸 에러의 문제는, 받는
+ 쪽이 이게 어떤 종류의 실패인지 알 길이 없다는 거예요. 404를 줘야 할지
+ 400을 줘야 할지, message 문자열을 사람이 읽고 추측하는 수밖에 없죠. 그래서 에러에{' '}
+ 이름표(클래스)와 상태코드를 실어 나르는 설계가 나옵니다.
+
+ {CODE_CUSTOM_ERRORS}
+
+ 핵심은 세 가지예요. ① 클래스명이 곧 에러의 종류가 되고,{' '}
+ ② statusCode를 에러 객체에 실어 두면
+ "이 에러엔 몇 번 응답"이라는 지식이 던지는 쪽에 한 번만 적히며,{' '}
+ ③ isExpected 표식으로 "예상된
+ 실패"(섹션 1)임을 알립니다 — 이 표식이 없는 에러는 전부 버그로 취급하면 돼요.
+
+ {CODE_CUSTOM_USE}
+
+ instanceof의 힘e instanceof ValidationError는
+ 문자열 비교가 아니라 타입 검사예요. 상속 관계도 이해해서{' '}
+ NotFoundError 인스턴스는{' '}
+ instanceof AppError에도 true
+ 입니다. 그래서 "구체적인 종류 먼저, 넓은 부모는 나중에" 순서로 분기하는 게 정석이에요.
+
+
+
+
+
+ 라우트가 10개면 try/catch도 10벌 — 그렇게 짜면 응답 모양이 조금씩 달라지고, 하나쯤은
+ 꼭 빠뜨리게 돼요. Express의 답은 에러 미들웨어입니다. 인자를{' '}
+ 4개(err, req, res, next) 받는 미들웨어를
+ 등록하면, Express는 그걸 "에러 담당"으로 인식하고 어디서 터진 에러든 전부
+ 그리로 배달해 줘요.
+
+ {CODE_MIDDLEWARE}
+
+ 그런데 여기 유명한 함정이 하나 있어요. 동기 라우트에서 던진 에러는
+ Express가 알아서 받아 주지만, async 라우트 안에서 터진 에러는 Express
+ 4가 받지 못합니다(Promise 거절을 감지하지 못해요). 그래서 async 핸들러를 감싸{' '}
+ next(err)로 배달해 주는 래퍼 패턴이
+ 실무의 표준이 됐죠.
+
+ {CODE_ASYNC_WRAP}
+
+ 구조가 주는 것 이 패턴이 자리 잡으면 라우트의 규칙이 단순해져요 —{' '}
+ "문제가 생기면 그냥 던져라(throw)." 어떻게 응답할지는 미들웨어 한
+ 곳의 책임이라, 응답 정책을 바꿀 때 고칠 파일이 하나뿐입니다.
+
+
+
+
+
+ 에러가 미들웨어 한 곳으로 모였으니, 이제 나가는 모양을 통일할
+ 차례예요. API마다 실패 응답이 제각각이면(어떤 건 문자열, 어떤 건{' '}
+ msg, 어떤 건 error...)
+ 프론트는 API 개수만큼 에러 처리 코드를 짜야 합니다. 규약은 단순할수록 좋아요:{' '}
+ code + message.
+
+ {CODE_FORMAT}
+
+ code는 기계가 읽는 이름표라 절대 바뀌면
+ 안 되고(프론트 분기가 여기 걸려 있어요), message는{' '}
+ 사람이 읽는 문장이라 자유롭게 다듬어도 됩니다. 이 역할 분리가 없으면
+ "메시지 문구 하나 고쳤더니 프론트 분기가 깨지는" 사고가 나요.
+
+ {CODE_FORMAT_MIDDLEWARE}
+
+ 스택 트레이스는 절대 응답에 싣지 마세요 스택에는 서버의 파일 경로, 폴더 구조,
+ 사용 중인 라이브러리와 버전이 그대로 담겨요. 공격자에게는 "어떤 취약점을 시도해 볼지"
+ 고르는 내부 지도가 됩니다. 예상 밖 에러는 사용자에겐{' '}
+ 500 + 범용 메시지만, 진짜 내용은 서버 로그에만 —
+ 이 원칙은 예외가 없어요.
+
+
+ 우리 실습과의 연결 실습 전반부가 바로 이 작업이에요. 블로그 앱의 모든 API가{' '}
+ error.code / error.message 구조로 실패하게 다듬으면,
+ 후반부(섹션 6)의 폴백 UI가 어떤 API 실패든 같은 코드로 받아 냅니다.
+
+
+
+
+
+ 백엔드가 아무리 잘해도 프론트에서 렌더링 중 에러가 나면 React는 기본적으로{' '}
+ 화면 전체를 비워 버려요 — 사용자 눈엔 하얀 화면만 남죠. 이걸 막는
+ 안전망이 에러 바운더리(Error Boundary)입니다. 자식 컴포넌트가 렌더링
+ 중에 던진 에러를 받아 내고, 무너진 자리에 대체 화면(폴백)을 세우는
+ 컴포넌트예요.
+
+ {CODE_BOUNDARY}
+
+ 설계의 핵심은 어디에 두르느냐예요. 앱 전체를 하나로 감싸면 "전부
+ 죽거나 전부 살거나"가 되죠. 대신 글 목록, 댓글, 사이드바처럼 영역 단위로{' '}
+ 두르면 한 영역이 터져도 나머지는 멀쩡히 삽니다 — 배가 침수돼도 격벽 한 칸만 잠기는
+ 것처럼요.
+
+
+ 중요한 구분 하나: 에러 바운더리는 렌더링 중의 에러를 잡는 도구라,{' '}
+ fetch 실패 같은 요청 에러는 잡지
+ 못합니다(이벤트 핸들러·비동기 코드는 범위 밖이에요). 요청 실패에는 상태
+ 기반의 폴백 — '다시 시도' 버튼이 있는 에러 화면 — 을 직접 만듭니다.
+
+ {CODE_RETRY}
+
+ '다시 시도'가 주는 경험 네트워크 에러의 상당수는 일시적이에요
+ (지하철 터널 구간, 와이파이 전환). 새로고침을 강요하는 대신 실패한 영역에 버튼 하나를
+ 두면, 사용자는 입력 중이던 내용과 스크롤 위치를 지키면서 그 영역만
+ 복구할 수 있어요. 실습 후반부에서 블로그 글 목록에 이걸 직접 답니다.
+
+
+
+
+
+ 지금까지의 원칙을 한 줄로 줄이면 이래요 — 사용자에겐 친절한 요약, 로그엔
+ 냉정한 전부. 그럼 로그는 어떻게 남겨야 나중에 쓸모가 있을까요? 첫걸음은{' '}
+ console.log 하나로 버티던 습관에서{' '}
+ 레벨(level)이 있는 로깅으로 넘어가는 것입니다.
+
+ {CODE_LOGGING}
+
+ info는 정상 흐름의 발자국,{' '}
+ warn은 "아직 장애는 아니지만 눈여겨볼 것",{' '}
+ error는 "지금 살펴야 할 문제"예요. 레벨이 있어야 운영
+ 중에 "error만 보여 줘"라고 걸러 볼 수 있고, 심각한 것만 알림으로 받는
+ 자동화도 걸 수 있습니다. 두 번째 기둥은 요청 ID예요. 서버엔 수많은
+ 요청이 섞여 들어오는데, 로그 줄들이 어느 요청의 것인지 묶어 주는 실이
+ 없으면 에러 한 건의 앞뒤 맥락을 복원할 수 없거든요.
+
+ {CODE_REQUEST_ID}
+
+ 로그에도 금기가 있다 비밀번호, 토큰, 주민번호 같은 민감정보는{' '}
+ 로그에도 남기면 안 돼요. 로그 파일은 생각보다 많은 사람과 시스템이
+ 들여다봅니다. "로그엔 전부"의 '전부'는 기술적 맥락의 전부지, 비밀의
+ 전부가 아니에요.
+
+
+
+
+
+
🚨 여기까지 왔다면
+
+ 이제 에러는 두렵기만 한 대상이 아니라 설계할 수 있는 대상이 됐어요.
+ 예상된 실패와 버그를 가르고, 커스텀 에러 클래스에 상태코드를 실어, 미들웨어 한 곳에서{' '}
+ code/message로 통일해 내보내고, 프론트에선 영역 단위
+ 폴백과 '다시 시도'로 받아 내는 흐름 — 그리고 그 전 과정을 요청 ID로 추적하는 로깅까지.
+ 그런데 이렇게 다듬은 에러 처리가 정말 약속대로 동작하는지는 어떻게
+ 보장할까요? 손으로 매번 눌러 볼 수는 없잖아요. 다음{' '}
+ 테스트 작성 실전: 자신 있게 고치는
+ 힘 강좌에서, 오늘 만든 에러 응답과 폴백이 깨지면 기계가 먼저
+ 알려 주는 안전망을 이어서 만들어 봅시다.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv2ExpressCrudPage.jsx b/frontend/src/pages/levels/Lv2ExpressCrudPage.jsx
new file mode 100644
index 0000000..30fac05
--- /dev/null
+++ b/frontend/src/pages/levels/Lv2ExpressCrudPage.jsx
@@ -0,0 +1,559 @@
+// 이 파일이 하는 일: "Express로 CRUD API 만들기" 코스 — 3강에서 설계한 블로그 API 명세를
+// 진짜 돌아가는 서버로 옮기는 여정. 요청이 서버를 통과하는 원리에서 출발해, 프로젝트 셋업,
+// POST/GET/PATCH/DELETE 구현, 미들웨어 추출, API 테스트 습관, 라우터·계층 분리까지
+// 8개 섹션으로 안내하는 정적 학습 페이지.
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_JOURNEY = `브라우저/Postman 우리 Express 서버
+──────────────── ─────────────────────────────
+GET /posts/3 ──────────▶ ① 미들웨어들 (검문소: 로거, body 파서...)
+ ② 라우터 (주소·메서드 보고 담당자 배정)
+ ③ 핸들러 (실제 일: 배열에서 3번 글 찾기)
+ ◀────────── ④ 응답 (상태 코드 200 + JSON 본문)
+
+핵심: 요청 하나는 반드시 이 한 줄의 여정을 지나요.
+"어느 단계에서 멈췄지?"를 짚을 수 있으면 디버깅의 절반은 끝난 겁니다.
+포트(3000)는 한 컴퓨터 안의 '문 번호', localhost는 '내 컴퓨터 자신'.
+그러니 http://localhost:3000 은 "내 컴퓨터의 3000번 문을 두드린다"는 뜻이에요.`;
+
+const CODE_SETUP = `# 빈 폴더에서 — 프로젝트의 뼈대 세우기
+mkdir blog-api && cd blog-api
+npm init -y # package.json 생성 (프로젝트 신분증)
+npm install express # 서버 프레임워크 설치
+
+# server.js — 최소한의 서버. 이게 전부입니다.
+const express = require('express');
+const app = express();
+
+// 메모리 배열 = 이번 실습의 '임시 데이터베이스'
+// (서버를 재시작하면 사라져요 — 다음 강좌에서 진짜 DB로 옮깁니다)
+let posts = [
+ { id: 1, title: '첫 글', content: '안녕하세요', author: 'mirim' },
+ { id: 2, title: '둘째 글', content: 'Express 재밌다', author: 'mirim' },
+];
+
+// GET /posts — 목록 응답. res.json()이 배열을 JSON으로 바꿔 보낸다
+app.get('/posts', (req, res) => {
+ res.json(posts);
+});
+
+app.listen(3000, () => {
+ console.log('서버가 3000번 포트에서 듣는 중');
+});`;
+
+const CODE_NODEMON = `# 매번 Ctrl+C → node server.js 를 반복하지 않기
+npm install -D nodemon # -D: 개발할 때만 쓰는 도구라는 표시
+
+# package.json 의 scripts 에 한 줄
+"scripts": {
+ "dev": "nodemon server.js"
+}
+
+# 이제 npm run dev 로 켜 두면, 파일을 저장할 때마다
+# nodemon이 알아서 서버를 재시작해 줍니다.
+# 코드 수정 → 저장 → 바로 테스트, 이 리듬이 개발 속도를 만들어요.`;
+
+const CODE_POST = `// ★ 이 한 줄이 없으면 req.body는 undefined —
+// "body가 안 읽혀요"의 90%가 이 줄을 빼먹은 경우입니다.
+app.use(express.json());
+
+// POST /posts — 새 글 생성
+app.post('/posts', (req, res) => {
+ const title = req.body.title;
+ const content = req.body.content;
+
+ // 필수 필드 검증: 클라이언트가 뭘 보낼지는 절대 믿지 않는다
+ if (!title || !content) {
+ return res.status(400).json({
+ error: 'title과 content는 필수입니다',
+ });
+ }
+
+ const newPost = {
+ id: posts.length ? Math.max(...posts.map((p) => p.id)) + 1 : 1,
+ title: title,
+ content: content,
+ author: req.body.author || '익명',
+ };
+ posts.push(newPost);
+
+ // 201 Created + 방금 만들어진 리소스를 그대로 돌려준다.
+ // 클라이언트는 서버가 붙여 준 id를 이 응답으로 알게 돼요.
+ res.status(201).json(newPost);
+});`;
+
+const CODE_GET_DETAIL = `// GET /posts/:id — 경로 파라미터로 하나 꺼내기
+app.get('/posts/:id', (req, res) => {
+ // 주의: req.params.id 는 언제나 '문자열'. 숫자 비교 전에 변환!
+ const id = Number(req.params.id);
+ const post = posts.find((p) => p.id === id);
+
+ if (!post) {
+ // 없는 id 요청에 200 + null 을 주면 클라이언트가 혼란에 빠져요.
+ // "그런 리소스 없음"은 반드시 404로 말합니다.
+ return res.status(404).json({ error: '글을 찾을 수 없습니다' });
+ }
+ res.json(post);
+});
+
+// GET /posts?q=검색어&page=1&limit=10 — 쿼리스트링으로 검색·페이지네이션
+app.get('/posts', (req, res) => {
+ let result = posts;
+
+ // 검색: q가 있으면 제목에 포함된 글만
+ if (req.query.q) {
+ result = result.filter((p) => p.title.includes(req.query.q));
+ }
+
+ // 페이지네이션: page·limit도 문자열로 오니 숫자로 변환
+ const page = Number(req.query.page) || 1;
+ const limit = Number(req.query.limit) || 10;
+ const start = (page - 1) * limit;
+ res.json(result.slice(start, start + limit));
+});`;
+
+const CODE_PATCH_DELETE = `// PATCH /posts/:id — 부분 수정: '온 필드만' 반영한다
+app.patch('/posts/:id', (req, res) => {
+ const id = Number(req.params.id);
+ const post = posts.find((p) => p.id === id);
+ if (!post) {
+ return res.status(404).json({ error: '글을 찾을 수 없습니다' });
+ }
+
+ // undefined 체크가 핵심 — title만 보냈으면 content는 건드리지 않는다.
+ // 이 조건 없이 통째로 덮어쓰면 안 보낸 필드가 undefined로 지워져요.
+ if (req.body.title !== undefined) post.title = req.body.title;
+ if (req.body.content !== undefined) post.content = req.body.content;
+
+ res.json(post); // 수정된 최종 모습을 돌려준다
+});
+
+// DELETE /posts/:id — 삭제
+app.delete('/posts/:id', (req, res) => {
+ const exists = posts.some((p) => p.id === Number(req.params.id));
+ if (!exists) {
+ // 방침 결정 지점: 이미 없는 걸 지우라고 하면?
+ // 우리는 "없다"를 정직하게 404로 알리는 쪽을 택합니다.
+ return res.status(404).json({ error: '이미 없는 글입니다' });
+ }
+ posts = posts.filter((p) => p.id !== Number(req.params.id));
+
+ // 204 No Content: "잘 지웠고, 돌려줄 본문은 없다"
+ // json()이 아니라 end()인 이유 — 204엔 본문을 실으면 안 되니까요.
+ res.status(204).end();
+});`;
+
+const CODE_LOGGER = `// 요청 로거 미들웨어 — 모든 요청이 지나는 첫 검문소
+function logger(req, res, next) {
+ console.log(new Date().toISOString(), req.method, req.url);
+ next(); // ★ 다음 검문소로 통과시킨다. 이걸 빼먹으면?
+ // 요청이 여기 갇혀서 브라우저가 영원히 빙글빙글 돌아요.
+}
+
+// 등록 '순서'가 곧 실행 순서 — 라우트보다 위에 있어야 모든 요청을 본다
+app.use(logger);
+app.use(express.json());
+// ... 이 아래에 라우트들
+
+// 실행해 보면 매 요청마다 이렇게 찍힙니다:
+// 2026-07-20T02:10:31.000Z GET /posts
+// 2026-07-20T02:10:45.000Z POST /posts`;
+
+const CODE_EXISTS_MW = `// '리소스 존재 확인' 미들웨어 — 세 핸들러에 반복되던 404 로직을 한 곳으로
+function loadPost(req, res, next) {
+ const post = posts.find((p) => p.id === Number(req.params.id));
+ if (!post) {
+ return res.status(404).json({ error: '글을 찾을 수 없습니다' });
+ }
+ req.post = post; // 찾은 결과를 req에 실어 다음 단계로 전달
+ next();
+}
+
+// 두 번째 인자로 끼워 넣으면, 핸들러가 실행되기 '전에' 먼저 돈다
+app.get('/posts/:id', loadPost, (req, res) => {
+ res.json(req.post); // 이미 검증됐으니 바로 사용
+});
+app.patch('/posts/:id', loadPost, (req, res) => {
+ if (req.body.title !== undefined) req.post.title = req.body.title;
+ if (req.body.content !== undefined) req.post.content = req.body.content;
+ res.json(req.post);
+});
+
+// find + 404 코드가 세 군데서 한 군데로 — 이게 미들웨어의 힘입니다.`;
+
+const CODE_HTTP_FILE = `### api.http — VS Code 'REST Client' 확장으로 실행하는 요청 시나리오
+### 각 요청 위의 "Send Request"를 누르면 결과가 옆에 뜹니다
+
+### 1. 목록 조회
+GET http://localhost:3000/posts
+
+### 2. 글 생성 (성공 → 201 기대)
+POST http://localhost:3000/posts
+Content-Type: application/json
+
+{ "title": "테스트 글", "content": "REST Client에서 보냄" }
+
+### 3. 필수 필드 누락 (실패 → 400 기대)
+POST http://localhost:3000/posts
+Content-Type: application/json
+
+{ "title": "content가 없는 글" }
+
+### 4. 없는 글 조회 (실패 → 404 기대)
+GET http://localhost:3000/posts/9999
+
+### 5. 부분 수정 → 6. 삭제(204) → 7. 재삭제(404) ... 순서로 이어가기
+### 포인트: '실패 케이스'까지 시나리오에 남겨 두는 게 진짜 테스트예요.`;
+
+const CODE_ROUTER = `// routes/posts.js — 글 관련 주소를 몽땅 이 파일로
+const express = require('express');
+const router = express.Router();
+const postService = require('../services/postService');
+
+// 여기선 '/posts'를 다시 안 써요 — server.js에서 접두어를 붙여 주니까
+router.get('/', (req, res) => {
+ res.json(postService.findAll(req.query));
+});
+router.post('/', (req, res) => {
+ const created = postService.create(req.body); // 검증·생성은 서비스가
+ res.status(201).json(created); // HTTP 응답은 라우터가
+});
+
+module.exports = router;
+
+// ── server.js — 이제 조립만 담당 ──
+const postsRouter = require('./routes/posts');
+app.use('/posts', postsRouter); // /posts 로 오는 건 전부 저 파일로
+
+// 폴더 구조가 곧 팀의 지도:
+// blog-api/
+// ├─ server.js ← 조립: 미들웨어·라우터 등록
+// ├─ routes/posts.js ← 입출구: 주소 ↔ HTTP 응답
+// └─ services/postService.js ← 비즈니스 로직: 데이터를 다루는 실제 일`;
+
+// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
+// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
+function Section({ n, title, sub, children }) {
+ return (
+
+
+ 3강에서 우리는 블로그 API의 명세를 종이 위에 설계했어요. 이번엔 그
+ 설계도를 진짜 돌아가는 서버로 옮깁니다. 요청이 서버를 통과하는 원리부터
+ 라우터·미들웨어·계층 분리까지 — 생성(C)·조회(R)·수정(U)·삭제(D) 전 구간을 내 손으로
+ 구현하고, Postman 시나리오로 끝까지 검증해 봅시다.
+
+
+ 예상 소요 75분
+ 실습: 블로그 API 전 구간 구현·검증
+ 선수 지식: JS 기초 · HTTP/REST(3강)
+
+
+
+ {/* 섹션 바로가기 */}
+
+
+
+
+ 코드를 치기 전에, 요청 하나가 서버 안에서 어떤 길을 걷는지부터
+ 그려 봅시다. 브라우저나 Postman이 GET /posts/3을 보내면,
+ Express는 그 요청을 ① 미들웨어 → ② 라우터 → ③ 핸들러 → ④ 응답의
+ 한 줄로 통과시켜요. 이 지도 한 장이 이번 코스 전체의 뼈대입니다.
+
+ {CODE_JOURNEY}
+
+ 미들웨어(middleware)는 요청이 핸들러에 닿기 전에 거치는{' '}
+ 검문소예요. 공항 보안 검색대를 떠올리면 정확합니다 — 모든 승객(요청)이
+ 차례로 통과하면서 짐 검사(body 파싱), 기록(로깅), 신분 확인(인증) 같은 공통 절차를
+ 거치죠. 검문을 통과시키는 신호가 next()인데, 이건 섹션
+ 6에서 직접 만들어 보며 손에 익힙니다.
+
+
+ 포트와 localhost도 정확히 해 둘게요. 한 컴퓨터에는 여러 프로그램이 동시에
+ 네트워크를 쓰기 때문에, 프로그램마다 문 번호(포트)를 하나씩 배정받아요.
+ 우리 서버가 3000번 문 앞에서 기다리겠다고 선언하는 게{' '}
+ app.listen(3000)이고,{' '}
+ localhost는 "다른 컴퓨터가 아니라 내 컴퓨터
+ 자신"을 가리키는 특별한 이름입니다.
+
+
+ 디버깅의 반은 지도에서 "응답이 안 와요"라는 문제는 대부분 이 여정의{' '}
+ 어느 한 단계에서 멈춘 거예요. 서버가 안 떠 있나(listen), 주소·메서드가
+ 안 맞나(라우터), 핸들러가 응답을 안 보냈나(res 누락), 미들웨어가 next()를 안 불렀나 —
+ 단계를 짚어 가며 좁히는 습관이 이 코스에서 가장 오래 남을 기술입니다.
+
+
+
+
+
+ 서버 하나 띄우는 데 필요한 건 생각보다 적어요. npm init -y
+ 로 프로젝트 신분증(package.json)을 만들고, Express를
+ 설치하고, 파일 하나에 열 줄 남짓 — 이게 전부입니다. 데이터베이스는 아직 없어요. 이번
+ 실습에선 메모리 배열이 임시 DB 역할을 합니다.
+
+ {CODE_SETUP}
+
+ node server.js로 켜고 브라우저에서{' '}
+ http://localhost:3000/posts를 열면 글 두 개가 담긴 JSON
+ 배열이 보일 거예요. 축하합니다 — 방금 여러분의 첫 API 서버가 응답한
+ 겁니다. res.json()은 자바스크립트 배열·객체를 JSON
+ 문자열로 바꾸고, "이건 JSON이야"라는 안내(Content-Type 헤더)까지 붙여서 보내 줘요.
+
+
+ 그런데 코드를 고칠 때마다 서버를 껐다 켜는 건 금방 지칩니다.{' '}
+ nodemon을 붙여 "저장하면 자동 재시작"되는 개발 흐름을 만들어 두세요.
+
+ {CODE_NODEMON}
+
+ 메모리 배열의 운명let posts = [...]는 서버
+ 프로세스의 메모리에만 존재해요. 서버를 재시작하면(nodemon이 재시작할 때도!) 추가했던
+ 글이 전부 초기 상태로 돌아갑니다. 버그가 아니라 구조예요. "데이터가
+ 서버의 삶과 운명을 같이한다"는 이 불편함이, 다음 강좌에서 DB를 배우는 이유가 됩니다.
+
+
+
+
+
+ 이제 클라이언트가 보낸 데이터로 새 글을 만들어 봅시다. POST 요청의
+ 데이터는 주소가 아니라 본문(body)에 실려 오는데, Express는 기본적으로
+ body를 읽지 않아요. app.use(express.json())이라는
+ 검문소를 세워야 JSON 본문이 req.body 객체로 풀립니다.
+
+ {CODE_POST}
+
+ 여기서 상태 코드 두 개가 일하고 있어요. 성공하면 201 Created —
+ "만들어졌다"를 뜻하는 전용 코드로, 200보다 정확한 표현입니다. 이때{' '}
+ 생성된 리소스를 응답 본문에 담아 돌려주는 게 관례예요. 서버가 붙여 준{' '}
+ id를 클라이언트가 알아야 다음 요청(상세 조회, 수정)을
+ 이어 갈 수 있으니까요.
+
+
+ 그리고 400 Bad Request — 필수 필드가 빠진 요청은 배열에 넣기 전에
+ 거절합니다. 클라이언트가 보낸 데이터는 절대 믿지 않는다가 서버 개발의
+ 기본 자세예요. 검증 없이 저장하면 title: undefined인
+ 유령 글이 쌓이고, 나중에 목록 화면이 깨지고 나서야 원인을 찾게 됩니다.
+
+
+ return을 빼먹으면res.status(400).json(...) 앞의{' '}
+ return이 없으면, 응답을 보낸 뒤에도 코드가 계속 흘러서
+ 글이 저장까지 돼 버립니다. 심하면 "응답을 두 번 보냈다"는{' '}
+ ERR_HTTP_HEADERS_SENT 에러도 만나요. "응답을 보냈으면
+ 함수를 끝낸다" — return 습관으로 기억하세요.
+
+
+
+
+
+ 목록은 있으니 이제 하나만 콕 집어 꺼내 봅시다. 주소의{' '}
+ /posts/:id에서 :id는{' '}
+ 경로 파라미터 — "이 자리에 오는 값은 뭐든 받아서{' '}
+ req.params.id에 담아 줘"라는 자리표시자예요.
+
+ {CODE_GET_DETAIL}
+
+ 두 가지 함정이 숨어 있어요. 첫째, URL에서 온 값은 전부 문자열입니다.{' '}
+ '3' === 3은 false라서, Number()
+ 변환 없이 비교하면 멀쩡한 글도 못 찾아요. 둘째, 없는 id에는 반드시 404
+ 를 보냅니다. 200에 빈 응답을 주면 클라이언트는 "성공했는데 데이터가 이상하네?"라며
+ 엉뚱한 곳을 뒤지게 돼요. 상태 코드는 서버가 클라이언트에게 하는 정직한
+ 말입니다.
+
+
+ 목록 쪽엔 쿼리스트링을 붙였어요. ?q=Express&page=1
+ 처럼 주소 뒤에 붙는 옵션들은 req.query로 들어옵니다.
+ 경로 파라미터가 "어떤 리소스냐"를 정한다면, 쿼리스트링은 "그 목록을{' '}
+ 어떻게 보여 줄 거냐"(검색·정렬·페이지)를 정해요 — 3강에서 설계한 그
+ 구분이 코드로 그대로 내려온 겁니다.
+
+
+ 페이지네이션은 배려다 글이 10만 개인데 GET /posts가
+ 전부 돌려준다면? 응답은 느려지고 클라이언트는 메모리로 앓아요.{' '}
+ page·limit으로 잘라 보내는
+ 건 성능 최적화이기 전에 API를 쓰는 상대에 대한 배려입니다. 실무 API
+ 목록 응답에 페이지네이션이 빠지는 일은 거의 없어요.
+
+
+
+
+
+ 수정에는 두 가지 철학이 있어요. PUT은 "통째로 교체"(안 보낸 필드는
+ 사라짐), PATCH는 "보낸 필드만 고침". 블로그에서
+ 제목만 살짝 바꾸는 시나리오라면 PATCH가 자연스럽죠. 핵심은{' '}
+ undefined 체크 — "이 필드가 요청에 실려 왔는가"를
+ 확인하고 온 것만 반영하는 로직입니다.
+
+ {CODE_PATCH_DELETE}
+
+ 삭제 성공의 응답은 204 No Content입니다. "잘 처리했고, 돌려줄 내용은
+ 없다"는 뜻이에요. 이미 지워진 걸 다시 보여 줄 이유가 없으니 본문 없이 상태 코드만
+ 보내는 거죠. 그래서 res.json()이 아니라{' '}
+ res.status(204).end()를 씁니다.
+
+
+ 그리고 설계자가 방침을 정해야 하는 순간이 하나 있어요 — 이미 없는
+ 리소스를 지우라는 요청이 오면? "없다"는 사실을 알리는 404파와, "지워진
+ 상태가 목표였으니 결과는 같다"며 204를 주는 파가 실무에도 둘 다
+ 있습니다. 우리는 클라이언트의 버그(잘못된 id 사용)를 일찍 드러내 주는 404를 택했어요.
+ 어느 쪽이든 일관되게 정하고 명세에 적어 두는 것이 정답입니다.
+
+
+ 여기까지가 CRUD 전부 POST(Create) · GET(Read) · PATCH(Update) · DELETE(Delete) —
+ 3강 명세의 네 동사가 모두 코드가 됐어요. 나머지 섹션들은 이 코드를{' '}
+ 더 깔끔하게, 더 믿을 수 있게 만드는 이야기입니다.
+
+
+
+
+
+ 지금까지 express.json()이라는 남이 만든 검문소를
+ 썼다면, 이제 직접 만들어 봅시다. 미들웨어는 그냥{' '}
+ (req, res, next) 세 개를 받는 함수예요. 첫 작품은 모든
+ 요청을 기록하는 로거입니다.
+
+ {CODE_LOGGER}
+
+ next()가 이 구조의 심장이에요. "내 검문은 끝났으니{' '}
+ 다음 순번에게 넘겨라"는 신호죠. 이걸 부르지 않으면 요청은 그 검문소에
+ 갇혀서 응답 없이 멈춥니다. 그리고 등록 순서가 곧 실행 순서 —{' '}
+ app.use(logger)를 라우트들보다 아래에 두면, 이미 핸들러가
+ 응답해 버린 뒤라 로거는 구경도 못 해요.
+
+
+ 두 번째 작품은 더 실전적이에요. 섹션 4~5에서 상세·수정·삭제 핸들러마다{' '}
+ find + 404 코드가 세 번 복붙됐던 것,
+ 눈치챘나요? 그 반복을 미들웨어 하나로 추출합니다.
+
+ {CODE_EXISTS_MW}
+
+ 어디서 본 감각이죠? "같은 코드가 반복되면 함수로 뽑는다"는 원칙이 서버에선
+ "공통 전처리가 반복되면 미들웨어로 뽑는다"가 됩니다. 찾은 결과를{' '}
+ req.post에 실어 다음 단계로 넘기는 패턴도 기억해 두세요 —
+ 나중에 배울 인증 미들웨어가 req.user에 로그인 정보를
+ 실어 주는 것과 똑같은 원리입니다.
+
+
+
+
+
+ "브라우저 주소창에 치면 되지 않나요?" — 절반만 맞아요. 주소창에 URL을 입력하는 행위는{' '}
+ 언제나 GET 요청입니다. 메서드를 바꿀 수도, JSON 본문을 실을 수도,
+ 헤더를 만질 수도 없죠. 우리가 만든 POST·PATCH·DELETE는 주소창으로는{' '}
+ 테스트 자체가 불가능합니다. 그래서 API 전용 도구가 필요해요.
+
+
+ Postman은 메서드·헤더·본문을 자유롭게 조립해 쏘는 대표 도구고, VS Code의{' '}
+ REST Client 확장은 .http 파일에 요청을
+ 적어 두고 에디터 안에서 바로 실행하는 방식이에요. 어느 쪽이든 핵심은 같습니다 —{' '}
+ 요청을 일회용으로 쏘고 버리지 말고, 시나리오로 저장하세요.
+
+ {CODE_HTTP_FILE}
+
+ 이렇게 저장해 두면 코드를 고칠 때마다 전 구간을 몇 초 만에 재검증할
+ 수 있어요. 특히 3번·4번 같은 실패 케이스(400·404가 제대로 나오는가)를
+ 시나리오에 남겨 두는 게 중요합니다 — 성공만 확인한 API는 절반만 검증된 API예요.{' '}
+ .http 파일은 코드와 함께 저장소에 커밋되니, 팀 동료에게
+ "이 API는 이렇게 쓰는 거예요"라는 살아 있는 문서가 되기도 하고요.
+
+
+ 캡처해 기록하는 습관 요청과 응답(상태 코드·본문)을 캡처해서 실습 노트에
+ 남기세요. "내가 뭘 보냈고 서버가 뭐라 답했나"를 기록하는 습관은 나중에 버그 리포트를
+ 쓸 때, 협업 중 "제 쪽에선 이렇게 나오는데요?"를 증명할 때 그대로 힘이 됩니다. 이번
+ 실습 과제의 제출물에도 이 캡처 기록이 포함돼요.
+
+
+
+
+
+ 지금 우리 server.js엔 서버 설정, 데이터, 라우트 8개가
+ 한 파일에 살아요. 혼자 할 땐 괜찮지만, 글·댓글·사용자로 기능이 늘고 동료가 합류하는
+ 순간 모두가 한 파일을 고치며 충돌하게 됩니다. Express가 준비해 둔
+ 해법이 express.Router — 라우트 묶음을 별도 파일로 떼어
+ 내는 도구예요.
+
+ {CODE_ROUTER}
+
+ 한 걸음 더 나아가 계층 분리도 맛봤어요. 라우터는
+ 입출구 — 주소를 받고 상태 코드로 답하는 HTTP 담당. 서비스는 비즈니스
+ 로직 — 검증하고, 만들고, 찾는 실제 일 담당. 이렇게 나누면 나중에 메모리 배열을 DB로
+ 바꿀 때 서비스 파일만 고치면 되고, 라우터는 한 줄도 안 건드려요.
+ 역할이 나뉘어 있으면 교체도 국소적입니다.
+
+
+ 폴더 구조는 곧 팀의 지도예요. "댓글 API 담당해 주세요"라는 요청이{' '}
+ routes/comments.js와{' '}
+ services/commentService.js 두 파일로 명확히 번역되고,
+ 서로 다른 기능을 맡은 동료와 같은 파일에서 충돌할 일이 사라집니다. 잘 나눈 구조는
+ 코드 정리를 넘어 협업의 속도를 바꿔요.
+
+
+ 과하게 나누지는 말 것 파일 3개짜리 프로젝트에 계층 7개를 만드는 건 배보다
+ 배꼽이에요. 지금 단계에선 라우터(입출구) / 서비스(로직) 두 층이면
+ 충분합니다. 구조는 코드가 자라며 아플 때 한 층씩 늘리는 것 — 이번 실습에서도 이
+ 두 층까지만 나눠 봅니다.
+
+
+
+
+
+
🛠️ 여기까지 왔다면
+
+ 종이 위의 명세가 돌아가는 서버가 됐어요. 요청의 여정을 그릴 수 있고,
+ CRUD 네 동사를 상태 코드(200·201·204·400·404)와 함께 구현했고, 반복 로직을
+ 미들웨어로 추출했고, 실패 케이스까지 시나리오로 검증하는 습관과 라우터·서비스 계층까지
+ 손에 쥐었습니다. 그런데 찜찜함이 하나 남았죠 — 서버를 재시작하면 글이 다
+ 사라진다는 것. 데이터에게 서버의 삶과 무관한 '집'을 지어 줄 차례예요. 다음{' '}
+ DB 연동과 트랜잭션: 데이터가 사는
+ 집 강좌에서, 메모리 배열을 진짜 데이터베이스로 옮기고 "전부 성공
+ 아니면 전부 취소"라는 트랜잭션의 감각을 이어서 익혀 봅시다.
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/levels/Lv2FormValidationPage.jsx b/frontend/src/pages/levels/Lv2FormValidationPage.jsx
new file mode 100644
index 0000000..10abbd5
--- /dev/null
+++ b/frontend/src/pages/levels/Lv2FormValidationPage.jsx
@@ -0,0 +1,552 @@
+// 이 파일이 하는 일: "폼 검증: 사용자를 돕고 서버를 지키기" 코스 — 회원가입 폼을 소재로
+// 클라이언트 검증(UX)과 서버 검증(보안)을 왜 '둘 다' 해야 하는지에서 출발해, 제어 컴포넌트·
+// 규칙 설계·에러 메시지 UX·400 응답 설계·서버 에러 렌더링·제출 흐름까지 7개 섹션으로 안내하는
+// 정적 학습 페이지. 프레이밍은 "검증은 두 번 한다 — 프론트에서 친절하게, 서버에서 단호하게".
+// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
+// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
+// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
+
+import { Link } from 'react-router-dom';
+
+// ── 예제 코드 상수들 ──
+
+const CODE_TWO_LAYERS = `[사용자] ──입력──▶ [프론트 검증] [서버 검증] ──통과──▶ [DB 저장]
+ "이메일 형식이 "이 요청, 정말
+ 아니에요" (즉시) 믿어도 되나?" (최종)
+
+프론트 검증 = UX 담당 : 제출 전에 바로 알려줘서 사용자의 시간을 아낀다
+서버 검증 = 보안 담당 : 프론트를 거치지 않은 요청까지 전부 걸러 낸다
+
+핵심: 프론트 검증은 '건너뛸 수 있는' 안내판이고,
+서버 검증은 '건너뛸 수 없는' 검문소입니다.
+안내판이 아무리 친절해도, 검문소가 없으면 문은 뚫려 있는 거예요.`;
+
+const CODE_BYPASS = `// 프론트 검증이 왜 '보안'이 못 되는지 — 개발자도구 콘솔에서 직접 시연
+// 브라우저의 required, 우리가 짠 React 검증… 전부 건너뛰고
+// 서버에 요청을 '직접' 쏠 수 있습니다. 폼을 거치지 않고요.
+
+fetch('/api/signup', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email: '이건이메일이아님', // 프론트라면 걸렸을 값
+ password: '1', // 8자 최소길이? 무시
+ nickname: '',
+ }),
+});
+
+// 서버가 이 값을 검사 없이 저장하면?
+// → 깨진 데이터, 뚫린 정책, 최악엔 스크립트 주입까지.
+// 프론트 검증은 '착한 사용자'만 통과합니다. 서버는 모두를 상대해야 해요.`;
+
+const CODE_CONTROLLED = `// 제어 컴포넌트 — 입력값의 주인을 DOM이 아니라 React 상태로 옮긴다
+// 필드가 4개라도 useState 4번이 아니라, 객체 '하나'로 관리하는 패턴.
+const [form, setForm] = useState({
+ email: '',
+ password: '',
+ passwordConfirm: '',
+ nickname: '',
+});
+
+// name 속성을 열쇠로 써서, 핸들러 '하나'가 모든 필드를 처리
+function handleChange(e) {
+ const { name, value } = e.target;
+ setForm((prev) => ({ ...prev, [name]: value })); // 해당 필드만 갈아끼움
+}
+
+// JSX: value(상태 → 화면)와 onChange(화면 → 상태)를 둘 다 묶어야 '제어'다
+
+`;
+
+const CODE_INPUT_TYPES = `// 타입별로 '값이 담기는 곳'이 다르다 — 여기서 자주 미끄러져요
+function handleChange(e) {
+ const { name, type, value, checked } = e.target;
+ setForm((prev) => ({
+ ...prev,
+ // 체크박스만 value가 아니라 checked(true/false)를 읽는다
+ [name]: type === 'checkbox' ? checked : value,
+ }));
+}
+
+//