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가 아니라 파일 첫 줄의 <!DOCTYPE html>입니다." + }, + { + "courseSlug": "lv1-first-html", + "question": "태그의 기본 구조에 대한 설명으로 옳은 것은 무엇인가요?", + "option1": "여는 태그와 닫는 태그가 내용을 감싸며, 닫는 태그에는 슬래시(/)가 붙는다", + "option2": "모든 태그는 반드시 닫는 태그가 있어야 하며 예외는 없다", + "option3": "슬래시(/)는 여는 태그에 붙여서 시작을 표시한다", + "option4": "여는 태그와 닫는 태그 사이에는 아무 내용도 넣을 수 없다", + "answerIndex": 0, + "explanation": "태그는 '여는 태그 + 내용 + 닫는 태그'의 3단 샌드위치 구조이고, 닫는 태그에만 슬래시(/)를 붙여 \"여기까지!\"를 표시합니다(예: <p>내용</p>). 다만 <br />, <hr />처럼 감쌀 내용이 없어 닫는 태그 없이 혼자 다니는 예외 태그도 있으므로 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 안에 <hr /> 구분선이 있는지 확인한다", + "option2": "head 안에 <meta charset=\"UTF-8\" />이 있는지 확인한다", + "option3": "닫는 태그의 슬래시를 모두 지웠는지 확인한다", + "option4": "브라우저를 다른 회사 제품으로 바꿔서 다시 연다", + "answerIndex": 1, + "explanation": "charset은 \"이 문서의 글자를 UTF-8 방식으로 읽어 줘\"라고 브라우저에 알리는 안내입니다. 이 한 줄이 없으면 브라우저가 글자 해석 방식을 잘못 추측해 한글이 외계어처럼 깨질 수 있어요. 그래서 첫 파일의 뼈대를 만들 때부터 head 안에 <meta charset=\"UTF-8\" />을 넣어 두는 것입니다." + }, + { + "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": "<script> 태그를 보통 </body> 바로 앞(몸통 맨 끝)에 두는 이유는 무엇인가요?", + "option1": "브라우저가 HTML을 위에서 아래로 읽으므로, 화면이 다 그려진 다음에 코드가 실행되게 하려고", + "option2": "맨 끝에 두어야 코드 실행 속도가 두 배 빨라지기 때문에", + "option3": "head 안에 두면 JavaScript가 아예 실행되지 않기 때문에", + "option4": "body 끝에 두어야만 별도 .js 파일로 분리할 수 있기 때문에", + "answerIndex": 0, + "explanation": "브라우저는 HTML을 위에서 아래로 읽으며 화면을 그립니다. 스크립트를 body 끝에 두면 화면이 완성된 뒤 코드가 실행되므로, 코드가 '아직 안 그려진 버튼'을 찾다가 실패하는 사고를 막을 수 있어요. 속도가 빨라지거나 head에서 실행이 안 되는 것은 아닙니다." + }, + { + "courseSlug": "lv1-first-javascript", + "question": "콘솔에 \"1\" + 1 을 입력하면 결과는 무엇인가요?", + "option1": "2", + "option2": "\"11\"", + "option3": "에러가 발생한다", + "option4": "11 (숫자)", + "answerIndex": 1, + "explanation": "따옴표로 감싼 \"1\"은 숫자가 아니라 글자(문자열)입니다. + 연산자는 한쪽이라도 글자면 계산 대신 '이어 붙이기'를 선택하므로 결과는 문자열 \"11\"이 됩니다. prompt()로 받은 답도 항상 글자라서 같은 함정에 빠지기 쉬워요." + }, + { + "courseSlug": "lv1-first-javascript", + "question": "코드를 실행했더니 콘솔에 'Console is not defined'라는 빨간 에러가 떴습니다. 가장 알맞은 대처는?", + "option1": "브라우저를 재설치한다 — JavaScript가 고장 난 것이다", + "option2": "빨간 글씨는 무시하고 코드를 처음부터 다시 짠다", + "option3": "에러 메시지를 힌트로 읽는다 — 대문자 C를 소문자 c로 고치면 해결된다", + "option4": "세미콜론을 지우면 해결된다", + "answerIndex": 2, + "explanation": "JavaScript는 대소문자를 구분하므로 Console과 console은 완전히 다른 단어입니다. 'Console is not defined'는 'Console이란 건 모른다'는 뜻의 힌트이므로, 대문자 C를 소문자로 고치면 됩니다. 콘솔의 빨간 글씨는 벌점이 아니라 몇 번째 줄에 무엇이 이상한지 알려 주는 힌트예요." + }, + { + "courseSlug": "lv1-variables-and-types", + "question": "const로 만든 변수에 새 값을 넣으려고 하면(예: const birthYear = 2009; birthYear = 2010;) 어떤 일이 일어날까요?", + "option1": "아무 일 없이 값이 2010으로 바뀐다", + "option2": "오류가 발생하며 값 변경이 막힌다", + "option3": "값이 undefined로 초기화된다", + "option4": "2009와 2010이 둘 다 저장된다", + "answerIndex": 1, + "explanation": "const는 constant(상수)의 줄임말로, 한번 값을 넣으면 바꿀 수 없는 상자예요. 재할당을 시도하면 TypeError가 발생합니다. 바뀌면 안 되는 값을 const로 선언해 두면, 실수로 값을 바꾸는 사고를 컴퓨터가 대신 막아 주기 때문에 중요합니다." + }, + { + "courseSlug": "lv1-variables-and-types", + "question": "console.log(\"1\" + 1)을 실행하면 결과가 무엇이고, 왜 그럴까요?", + "option1": "2 — 자바스크립트가 문자열을 자동으로 숫자로 바꿔 덧셈하기 때문", + "option2": "오류 발생 — 문자열과 숫자는 더할 수 없기 때문", + "option3": "\"11\" — 문자열이 끼어 있으면 +가 이어 붙이기로 동작하기 때문", + "option4": "11 — 결과가 항상 숫자로 변환되기 때문", + "answerIndex": 2, + "explanation": "\"1\"은 따옴표에 싸인 문자열이에요. +는 양쪽 값의 자료형을 보고 행동을 결정하는데, 문자열이 끼어 있으면 덧셈이 아니라 '이어 붙이기(연결)'로 동작합니다. 그래서 숫자 1까지 글자 취급되어 문자열 \"11\"이 되고, 오류도 없이 조용히 이상한 결과가 나오는 것이 이 함정의 무서운 점입니다." + }, + { + "courseSlug": "lv1-variables-and-types", + "question": "10 % 3의 결과는 무엇일까요?", + "option1": "1", + "option2": "3", + "option3": "3.333...", + "option4": "0.1", + "answerIndex": 0, + "explanation": "%는 퍼센트가 아니라 '나머지 연산자'예요. 10을 3으로 나누면 몫이 3이고 나머지가 1이므로(10 = 3×3 + 1), 10 % 3은 1입니다. 어떤 수를 2로 나눈 나머지(% 2)가 0이면 짝수, 1이면 홀수라서 홀짝 판별에 자주 쓰입니다." + }, + { + "courseSlug": "lv1-variables-and-types", + "question": "7 === 7이나 10 > 3 같은 비교 연산의 결과는 어떤 자료형의 값일까요?", + "option1": "숫자(number) — 1 또는 0이 나온다", + "option2": "문자열(string) — \"참\" 또는 \"거짓\"이 나온다", + "option3": "비교식마다 다른 자료형이 나온다", + "option4": "불리언(boolean) — true 또는 false가 나온다", + "answerIndex": 3, + "explanation": "비교 연산자(>, <, === 등)의 답은 반드시 true 아니면 false, 즉 불리언입니다. 불리언은 값이 이 두 개뿐인 자료형으로, 다음 강좌의 조건문(if)이 이 true/false를 보고 갈림길을 고르기 때문에 프로그램의 판단에 쓰이는 핵심 재료가 됩니다." + }, + { + "courseSlug": "lv1-variables-and-types", + "question": "prompt(\"몇 년에 태어났어요?\")로 사용자에게 2009를 입력받아 나이 계산에 쓰려고 합니다. 왜 Number()로 감싸서 담아야 할까요?", + "option1": "prompt는 입력값을 항상 문자열로 돌려주기 때문에, 계산에 쓰려면 숫자로 형 변환이 필요해서", + "option2": "Number()를 쓰지 않으면 prompt 입력창이 아예 뜨지 않아서", + "option3": "prompt가 입력값을 불리언(true/false)으로 돌려주기 때문에", + "option4": "Number()가 입력값을 자동으로 올해 연도로 바꿔 주기 때문에", + "answerIndex": 0, + "explanation": "prompt는 사용자가 무엇을 입력하든 항상 문자열로 돌려줍니다. 2009를 입력해도 숫자 2009가 아니라 문자열 \"2009\"라서, 그대로 계산에 쓰면 형이 달라 이상한 결과가 날 수 있어요. 그래서 Number(\"2009\")처럼 형 변환을 해서 숫자로 만든 뒤 변수에 담아야 뺄셈(나이 계산)이 올바르게 동작합니다." + }, + { + "courseSlug": "lv1-conditionals", + "question": "if 문의 괄호 안 조건이 false일 때, 중괄호 블록 안의 코드는 어떻게 될까요?", + "option1": "실행되지 않고 건너뛰어지며, 프로그램은 블록 다음 줄부터 계속 진행된다", + "option2": "코드가 삭제되어 다시는 실행할 수 없다", + "option3": "에러가 발생하며 프로그램이 멈춘다", + "option4": "절반만 실행되고 나머지는 건너뛴다", + "answerIndex": 0, + "explanation": "조건이 false면 블록은 '건너뛰어질' 뿐 사라지는 게 아닙니다. 프로그램은 블록 다음 줄부터 아무 일 없었다는 듯 계속 실행돼요. 그래서 if 블록 뒤에 있는 코드는 조건과 상관없이 항상 실행됩니다." + }, + { + "courseSlug": "lv1-conditionals", + "question": "let age = 15; 라고 쓸 때의 = 와, age === 15 라고 쓸 때의 === 의 차이로 옳은 것은?", + "option1": "둘 다 같은 뜻이고 취향 차이일 뿐이다", + "option2": "= 는 값을 넣는 대입이고, === 는 같은지 물어보는 비교로, 하는 일이 완전히 다르다", + "option3": "= 는 숫자에만, === 는 글자에만 쓴다", + "option4": "=== 는 값을 세 번 넣는다는 뜻이다", + "answerIndex": 1, + "explanation": "= 하나는 '상자에 값을 넣어라'라는 대입이고, === 세 개는 '두 값이 같은가?'를 판정해 true/false를 돌려주는 비교입니다. if 조건에 실수로 = 를 쓰면 대입이 일어나 조건이 항상 참이 되고 변수 값까지 바뀌는 버그가 생겨요." + }, + { + "courseSlug": "lv1-conditionals", + "question": "놀이기구 탑승 조건이 if (height >= 120 && age >= 8) 일 때, 키 150cm에 나이 7살인 사람의 결과는?", + "option1": "키 조건이 true이므로 탑승 가능하다", + "option2": "&& 는 하나만 true여도 되므로 탑승 가능하다", + "option3": "&& 는 둘 다 true여야 하는데 나이 조건이 false이므로 탑승 불가다", + "option4": "키와 나이를 더한 값으로 판정하므로 탑승 가능하다", + "answerIndex": 2, + "explanation": "&&(그리고)는 양쪽 조건이 모두 true일 때만 전체가 true가 됩니다. 150 >= 120은 true지만 7 >= 8은 false라서 true && false → false, 즉 else 쪽이 실행되어 탑승 불가입니다. '하나만 통과해도 되는' 연산자는 ||(또는)예요." + }, + { + "courseSlug": "lv1-conditionals", + "question": "점수 등급을 나누는 else if 사다리에서 if (score >= 70)을 맨 위에 두면 95점이 'C 등급'으로 출력됩니다. 그 이유는?", + "option1": "else if 는 아래에서부터 위로 검사하기 때문이다", + "option2": "95는 70보다 작다고 판정되기 때문이다", + "option3": "else if 는 모든 조건을 검사한 뒤 마지막 결과를 쓰기 때문이다", + "option4": "위에서부터 차례로 검사하다가 처음 true가 된 블록만 실행하고 빠져나오는데, 95도 '70 이상'이라 첫 조건에 걸리기 때문이다", + "answerIndex": 3, + "explanation": "else if 사다리는 위에서부터 순서대로 검사하고, 처음으로 true가 나온 블록 하나만 실행한 뒤 나머지는 검사조차 하지 않습니다. 95 >= 70이 true라서 거기서 끝나 버리는 거죠. 그래서 겹치는 조건은 좁은(빡빡한) 조건부터 위에 둬야 합니다." + }, + { + "courseSlug": "lv1-conditionals", + "question": "number % 2 === 0 이라는 조건이 true가 되는 경우와 그 의미로 옳은 것은?", + "option1": "number를 2로 나눈 나머지가 0일 때 true가 되며, 이는 number가 짝수라는 뜻이다", + "option2": "number가 2보다 클 때 true가 되며, 이는 양수라는 뜻이다", + "option3": "number를 2로 나눈 몫이 0일 때 true가 되며, 이는 홀수라는 뜻이다", + "option4": "number가 0일 때만 true가 된다", + "answerIndex": 0, + "explanation": "% 는 나눗셈의 '나머지'를 구하는 연산자입니다. 어떤 수를 2로 나눈 나머지가 0이라는 것은 2로 나누어떨어진다는 뜻이므로 짝수입니다(예: 10 % 2 === 0은 true, 7 % 2는 1이라 false). 참고로 0도 2로 나눈 나머지가 0이므로 이 조건에서는 true가 됩니다." + }, + { + "courseSlug": "lv1-loops", + "question": "다음 while문이 무한루프에 빠지는 이유로 가장 알맞은 것은?\n\nlet count = 1;\nwhile (count <= 5) {\n console.log(\"멈추지 않아요\");\n}", + "option1": "while은 원래 멈출 수 없는 반복문이라서", + "option2": "반복문 안에 count 값을 바꾸는 줄이 없어 조건이 영원히 참이라서", + "option3": "console.log가 반복을 강제로 계속 실행시키기 때문에", + "option4": "조건에 <= 대신 < 를 써야 하기 때문에", + "answerIndex": 1, + "explanation": "while은 조건이 참인 동안 계속 반복합니다. count를 키우는 줄(count = count + 1)이 없으면 count는 영원히 1이고, 조건 count <= 5가 영원히 참이라 반복이 멈추지 않습니다. 조건 속 변수를 변하게 만드는 줄이 반복문 안에 있어야 언젠가 조건이 거짓이 되어 멈춥니다." + }, + { + "courseSlug": "lv1-loops", + "question": "for (let i = 1; i <= 10; i++) 에서 세 요소의 실행 시점을 올바르게 설명한 것은?", + "option1": "초기화는 딱 한 번, 조건은 매 바퀴 시작 전 검사, 증감은 매 바퀴 끝에 실행된다", + "option2": "세 요소 모두 매 바퀴마다 한 번씩 실행된다", + "option3": "초기화와 증감은 한 번씩만, 조건은 마지막에 한 번 검사된다", + "option4": "조건은 반복이 다 끝난 뒤에 검사되어 최소 한 번은 무조건 실행된다", + "answerIndex": 0, + "explanation": "for문의 3요소는 역할과 시점이 다릅니다. 초기화(let i = 1)는 반복 시작 때 딱 한 번, 조건(i <= 10)은 매 바퀴 시작 전에 검사해 거짓이면 즉시 종료, 증감(i++)은 매 바퀴가 끝날 때마다 실행됩니다. 셋이 첫 줄에 모여 있어 몇 번 도는 반복인지 한눈에 보이는 것이 for의 장점입니다." + }, + { + "courseSlug": "lv1-loops", + "question": "1부터 100까지의 합을 구할 때 누적 변수 sum을 반복 전에 0으로 시작하는 이유는?", + "option1": "자바스크립트 문법상 변수는 반드시 0으로 초기화해야 해서", + "option2": "0이 아니면 반복문이 실행되지 않기 때문에", + "option3": "덧셈에서 0은 더해도 값을 바꾸지 않는 출발점이라, 빈 저금통처럼 시작해야 정확히 쌓은 만큼만 남기 때문에", + "option4": "0으로 시작하면 반복 횟수가 한 번 줄어들어 더 빠르기 때문에", + "answerIndex": 2, + "explanation": "누적 변수는 빈 저금통에서 시작해야 반복하며 더한 값만 정확히 남습니다. 덧셈에서 0은 결과에 아무 영향을 주지 않는 값이라 출발점으로 적합합니다. 같은 원리로 곱셈을 누적한다면 시작값은 1이어야 합니다." + }, + { + "courseSlug": "lv1-loops", + "question": "break와 continue의 차이를 올바르게 설명한 것은?", + "option1": "break는 이번 바퀴만 건너뛰고, continue는 반복 전체를 끝낸다", + "option2": "break는 반복 전체를 즉시 끝내고, continue는 이번 바퀴만 건너뛰고 다음 바퀴로 넘어간다", + "option3": "둘 다 반복 전체를 끝내지만 break가 더 빠르다", + "option4": "continue는 조건문에서만, break는 반복문에서만 쓸 수 있다", + "answerIndex": 1, + "explanation": "break는 반복문을 통째로 탈출합니다(예: i가 4일 때 break하면 4부터는 아예 실행되지 않음). continue는 이번 바퀴의 남은 코드만 건너뛰고 반복 자체는 계속됩니다(예: i가 3일 때 continue하면 3만 빠지고 4, 5는 출력됨). 비유하면 break는 하교, continue는 이 수업만 조퇴입니다." + }, + { + "courseSlug": "lv1-loops", + "question": "const fruits = [\"사과\", \"바나나\", \"포도\", \"귤\"]; 일 때, 다음 중 옳은 것은?", + "option1": "fruits[1]은 \"사과\"이고, fruits.length는 3이다", + "option2": "fruits[0]은 \"사과\"이고, 마지막 항목은 fruits[4]이다", + "option3": "fruits[0]은 \"사과\"이고, fruits.length는 4이며 마지막 항목은 fruits[3]이다", + "option4": "인덱스는 1부터 시작하므로 fruits[4]가 \"귤\"이다", + "answerIndex": 2, + "explanation": "배열의 인덱스는 0부터 시작합니다. 첫 번째 항목이 fruits[0](\"사과\"), 네 번째이자 마지막 항목이 fruits[3](\"귤\")입니다. length는 담긴 개수인 4이지만 마지막 인덱스는 3이므로, for로 순회할 때 조건을 i < fruits.length로 쓰는 것입니다. i <= fruits.length로 쓰면 존재하지 않는 4번 칸까지 접근하는 하나 차이(off-by-one) 실수가 됩니다." + }, + { + "courseSlug": "lv1-functions", + "question": "함수를 function greet() { console.log('안녕!'); } 로 정의만 하고 다른 코드를 쓰지 않았습니다. 실행하면 화면에 무엇이 나올까요?", + "option1": "안녕! 이 한 번 출력된다", + "option2": "아무것도 출력되지 않는다", + "option3": "에러가 발생한다", + "option4": "안녕! 이 계속 반복 출력된다", + "answerIndex": 1, + "explanation": "함수 정의는 '이런 명령어를 등록해 둔다'는 예약일 뿐, 그 자체로는 실행되지 않습니다. 함수 안의 코드가 실제로 돌아가려면 greet()처럼 이름 뒤에 괄호를 붙여 호출해야 합니다. 정의(레시피 적기)와 호출(실제 요리하기)의 구분이 이 강좌의 첫 번째 핵심이에요." + }, + { + "courseSlug": "lv1-functions", + "question": "function add(a, b) { return a + b; } 에서 a와 b, 그리고 add(3, 4)로 호출할 때의 3과 4를 부르는 올바른 용어 짝은 무엇일까요?", + "option1": "a·b는 인자, 3·4는 매개변수", + "option2": "a·b는 매개변수, 3·4는 인자", + "option3": "둘 다 매개변수라고 부른다", + "option4": "a·b는 전역 변수, 3·4는 지역 변수", + "answerIndex": 1, + "explanation": "매개변수(parameter)는 함수를 정의할 때 괄호 안에 적어 두는 '입력 구멍'의 이름(a, b)이고, 인자(argument)는 호출할 때 그 구멍에 실제로 넣는 값(3, 4)입니다. 자판기 비유로는 동전 구멍이 매개변수, 실제로 넣는 동전이 인자예요." + }, + { + "courseSlug": "lv1-functions", + "question": "function addAndShow(a, b) { console.log(a + b); } 를 const r = addAndShow(3, 4); 로 실행한 뒤 console.log(r); 을 하면 무엇이 출력될까요?", + "option1": "7", + "option2": "34", + "option3": "undefined", + "option4": "에러가 발생한다", + "answerIndex": 2, + "explanation": "console.log는 값을 화면에 '보여 주기만' 하고 돌려주지는 않습니다. 함수에 return이 없으면 부른 쪽에 건네지는 값이 없어서, 변수 r에는 undefined가 담겨요. 화면에 7이 찍히는 것과 값을 돌려받는 것은 완전히 다른 일이라는 점이 return 섹션의 핵심입니다." + }, + { + "courseSlug": "lv1-functions", + "question": "함수 실행 중에 return 문을 만나면 어떤 일이 일어날까요?", + "option1": "값을 돌려준 뒤 함수의 나머지 줄도 계속 실행된다", + "option2": "값을 돌려주고 함수는 그 자리에서 즉시 끝난다", + "option3": "함수가 처음부터 다시 실행된다", + "option4": "return 아래 줄이 한 번만 더 실행되고 끝난다", + "answerIndex": 1, + "explanation": "return을 만나는 순간 함수는 값을 돌려주고 즉시 종료되며, 그 아래의 코드는 몇 줄이 있든 실행되지 않습니다. 이 성질을 역이용해 divide 함수처럼 '0으로 나누기 같은 이상한 입력을 먼저 return으로 내보내는' 방어 패턴을 만들 수 있어요." + }, + { + "courseSlug": "lv1-functions", + "question": "function makeSecret() { const secret = '비밀'; } 을 실행한 뒤, 함수 밖에서 console.log(secret); 을 하면 어떻게 될까요?", + "option1": "비밀 이 정상 출력된다", + "option2": "undefined가 출력된다", + "option3": "secret is not defined 에러가 난다", + "option4": "빈 문자열이 출력된다", + "answerIndex": 2, + "explanation": "함수 안에서 만든 변수는 그 함수의 담장(지역 스코프) 안에서만 살기 때문에, 밖에서는 아예 존재하지 않는 이름이라 not defined 에러가 납니다. 이 벽 덕분에 함수마다 같은 이름의 변수를 써도 서로 부딪히지 않아요. 반대로 함수 안에서 밖(전역)의 변수를 읽는 것은 가능합니다." + }, + { + "courseSlug": "lv1-reading-errors", + "question": "에러 메시지 'Uncaught TypeError: user.sayHi is not a function at greet (script.js:12:8)'에서 'script.js:12:8'이 뜻하는 것은 무엇인가요?", + "option1": "에러가 12월 8일에 발생했다는 기록", + "option2": "script.js 파일의 12번째 줄, 8번째 칸이라는 위치 정보", + "option3": "에러가 지금까지 12번 발생했고 8번 무시됐다는 횟수", + "option4": "script.js 파일의 크기가 12.8KB라는 정보", + "answerIndex": 1, + "explanation": "에러 메시지는 이름·설명·위치의 세 부분으로 이루어지는데, 'script.js:12:8'은 그중 위치 부분으로 '파일명:줄번호:칸번호'를 뜻해요. 에러를 읽을 때는 이 줄번호로 먼저 이동해서 코드를 눈으로 확인하는 것이 가장 빠른 첫걸음이기 때문에, 이 표기를 읽을 줄 아는 것이 중요합니다." + }, + { + "courseSlug": "lv1-reading-errors", + "question": "console.log(username)을 실행했더니 'ReferenceError: username is not defined'가 떴습니다. 본문에서 배운 '1등 원인'으로 가장 먼저 의심해야 할 것은 무엇인가요?", + "option1": "인터넷 연결이 끊겨서 변수를 불러오지 못했다", + "option2": "컴퓨터 메모리가 부족해서 변수가 지워졌다", + "option3": "userName처럼 대소문자가 다르게 선언된 이름을 잘못 불렀다", + "option4": "변수를 너무 많이 만들어서 한도를 초과했다", + "answerIndex": 2, + "explanation": "ReferenceError의 'is not defined'는 컴퓨터가 그 이름을 찾지 못했다는 뜻이고, 가장 흔한 원인은 대소문자 오타예요. 컴퓨터에게 userName과 username은 완전히 다른 이름이지만 사람 눈에는 거의 같아 보여서 자주 놓칩니다. 그 다음으로 철자 오타, 선언 전에 사용한 경우를 의심하면 됩니다." + }, + { + "courseSlug": "lv1-reading-errors", + "question": "숫자 변수에 대해 age.toUpperCase()를 호출하면 어떤 에러가 나며, 그 이유는 무엇인가요?", + "option1": "SyntaxError — 괄호의 짝이 맞지 않아서", + "option2": "ReferenceError — age라는 이름을 찾을 수 없어서", + "option3": "TypeError — toUpperCase는 문자열 전용인데 age는 숫자라서", + "option4": "에러가 나지 않고 숫자가 그대로 출력된다", + "answerIndex": 2, + "explanation": "TypeError는 값은 찾았지만 그 값의 형(type)이 기대와 달라 시킨 동작을 할 수 없을 때 발생해요. toUpperCase()는 문자열을 대문자로 바꾸는 문자열 전용 동작이라, 숫자인 age에게 시키면 'is not a function' 형태의 TypeError가 납니다. 이럴 때 typeof로 값의 정체를 확인하면 원인을 빨리 찾을 수 있어요." + }, + { + "courseSlug": "lv1-reading-errors", + "question": "에러 없이 결과만 이상한 100줄짜리 코드에서 범인 줄을 찾을 때, 본문이 권한 '반씩 좁히기' 방법으로 옳은 것은 무엇인가요?", + "option1": "1번째 줄부터 100번째 줄까지 한 줄씩 차례로 모두 읽는다", + "option2": "한가운데에 console.log를 심어 값이 정상이면 뒤쪽 절반, 이상하면 앞쪽 절반으로 범위를 좁혀 간다", + "option3": "코드를 전부 지우고 처음부터 새로 작성한다", + "option4": "에러가 없으므로 고칠 필요가 없다고 판단한다", + "answerIndex": 1, + "explanation": "반씩 좁히기는 코드 한가운데에 console.log를 심어 그 지점까지의 값이 정상인지 확인하고, 정상이면 뒤쪽 절반에, 이상하면 앞쪽 절반에 범인이 있다고 판단해 범위를 절반씩 줄여 가는 방법이에요. 이렇게 하면 100줄짜리 코드도 log 일곱 번 정도면 범인 줄을 특정할 수 있어서, 처음부터 전부 훑는 것보다 훨씬 빠릅니다." + }, + { + "courseSlug": "lv1-reading-errors", + "question": "에러를 해결하려고 검색하거나 AI에게 질문할 때, 본문에서 배운 올바른 태도는 무엇인가요?", + "option1": "\"코드가 안 돼요\"라고만 짧게 물어보는 것이 예의다", + "option2": "찾은 답은 이해할 필요 없이 최대한 빨리 복사해 붙여넣는다", + "option3": "에러 메시지에 내 변수명·파일명을 반드시 포함해서 그대로 검색한다", + "option4": "코드·에러 메시지·시도해 본 것을 함께 담아 질문하고, 답은 왜 통하는지 이해한 뒤에 옮겨 적는다", + "answerIndex": 3, + "explanation": "좋은 질문에는 ① 문제의 코드 ② 에러 메시지 전문 ③ 이미 시도해 본 것, 이 3종 세트가 담겨야 정확한 답을 받을 수 있어요. 검색할 때는 내 변수명·파일명처럼 나만 쓰는 부분은 지워야 남들의 같은 질문과 겹칩니다. 그리고 답을 찾았어도 '왜 이 답이 내 에러를 고치는가'를 설명할 수 있을 때만 옮겨 적어야 같은 에러를 다시 만났을 때 스스로 해결할 수 있어요." + }, + { + "courseSlug": "lv1-first-mini-project", + "question": "Math.floor(Math.random() * 100) + 1 이 만들어 내는 숫자의 범위는?", + "option1": "0부터 99까지의 정수", + "option2": "1부터 100까지의 정수", + "option3": "0부터 100까지의 소수", + "option4": "1부터 99까지의 정수", + "answerIndex": 1, + "explanation": "Math.random()은 0 이상 1 미만의 소수를 주고, 100을 곱하면 0 이상 100 미만, Math.floor로 소수점을 자르면 0~99의 정수가 됩니다. 마지막에 1을 더하기 때문에 최종 범위는 1~100이에요. +1을 빼먹으면 0이 나올 수 있고 100은 절대 안 나오는 게임이 됩니다." + }, + { + "courseSlug": "lv1-first-mini-project", + "question": "HTML 요소에 id=\"result\" 같은 id를 붙이는 가장 중요한 이유는?", + "option1": "브라우저가 요소를 더 빨리 화면에 그리게 하려고", + "option2": "요소의 글자 색과 크기를 자동으로 예쁘게 바꾸려고", + "option3": "자바스크립트가 그 요소를 이름으로 찾아와 다룰 수 있게 하려고", + "option4": "같은 요소를 여러 번 복사해서 쓰려고", + "answerIndex": 2, + "explanation": "id는 요소에 붙이는 '이름표'입니다. document.getElementById('result')처럼 자바스크립트가 이름으로 요소를 찾아와서 값을 읽거나 innerText로 글자를 바꿀 수 있어요. 이름표가 없으면 스크립트가 그 요소를 만질 방법이 없기 때문에 id가 필요합니다." + }, + { + "courseSlug": "lv1-first-mini-project", + "question": "버튼을 눌렀을 때 check() 함수가 실행되게 하려면 버튼에 무엇을 써야 할까요?", + "option1": "<button onclick=\"check()\">도전!</button>", + "option2": "<button innerText=\"check()\">도전!</button>", + "option3": "<button id=\"check()\">도전!</button>", + "option4": "<button style=\"check()\">도전!</button>", + "answerIndex": 0, + "explanation": "onclick은 '이 요소가 클릭되면(on click) 이 코드를 실행해 줘'라고 버튼과 함수를 잇는 전선 역할을 합니다. innerText는 요소의 글자를 바꾸는 속성, id는 이름표, style은 색·크기 등 꾸미기 속성이라 함수 실행과는 관계가 없어요." + }, + { + "courseSlug": "lv1-first-mini-project", + "question": "입력창에서 꺼낸 값을 Number()로 바꿔 주는 이유는 무엇일까요?", + "option1": "입력창의 값은 항상 글자(문자열)라서, 숫자로 바꿔야 정답과 올바르게 비교할 수 있기 때문", + "option2": "Number()를 쓰지 않으면 입력창에 아예 값이 들어가지 않기 때문", + "option3": "Number()가 입력된 숫자를 자동으로 1~100 범위로 잘라 주기 때문", + "option4": "Number()가 정답 숫자를 새로 뽑아 주기 때문", + "answerIndex": 0, + "explanation": "입력창에서 꺼낸 값은 겉보기에 숫자여도 속은 글자('50')입니다. 글자 '50'과 숫자 50은 다른 존재라서, Number()로 숫자로 변신시킨 뒤에야 정답과 정확하게 비교할 수 있어요. Number()는 범위를 자르거나 정답을 뽑는 기능과는 무관합니다." + }, + { + "courseSlug": "lv1-first-mini-project", + "question": "플레이어가 입력창에 글자(예: 'abc')를 넣었을 때 일어나는 일과 올바른 방어 방법은?", + "option1": "Number('abc')는 0이 되므로 아무 문제 없이 채점된다", + "option2": "Number('abc')는 NaN이 되므로, isNaN()으로 확인해서 이상한 입력이면 return으로 함수를 끝낸다", + "option3": "브라우저가 알아서 글자를 숫자로 바꿔 주므로 방어가 필요 없다", + "option4": "innerText가 자동으로 글자 입력을 막아 준다", + "answerIndex": 1, + "explanation": "글자를 Number()로 바꾸면 NaN(Not a Number, '숫자 아님')이 되어 정답과의 비교가 전부 이상해집니다. 그래서 check() 함수 맨 앞에 isNaN()이라는 문지기를 세우고, 이상한 입력이면 안내 문구를 보여 준 뒤 return으로 즉시 함수를 끝내서 아래의 채점 코드가 실행되지 않게 막아야 해요." + }, + { + "courseSlug": "lv2-async-javascript", + "question": "fetch로 요청을 보냈는데 서버가 404 상태 코드로 응답했다. 이때 fetch가 돌려준 Promise는 어떻게 될까요?", + "option1": "rejected되어 catch 블록으로 실행이 넘어간다", + "option2": "fulfilled된다 — HTTP 에러 응답은 reject 사유가 아니므로 res.ok를 직접 확인해야 한다", + "option3": "pending 상태에 계속 머무른다", + "option4": "브라우저가 자동으로 요청을 3회 재시도한다", + "answerIndex": 1, + "explanation": "fetch는 서버와의 연결 자체가 실패(네트워크 단절 등)했을 때만 reject됩니다. 서버가 404나 500으로 '대답'한 것은 fetch 입장에서는 통신 성공이므로 Promise는 fulfilled됩니다. 그래서 HTTP 수준의 실패를 잡으려면 res.ok(상태 코드 200~299)를 확인하고 직접 throw해서 catch 그물에 걸리게 해야 합니다." + }, + { + "courseSlug": "lv2-async-javascript", + "question": "Promise의 상태 전이에 대한 설명으로 옳은 것은?", + "option1": "fulfilled 상태였다가 나중에 rejected로 바뀔 수 있다", + "option2": "pending에서 시작해 fulfilled 또는 rejected 중 하나로 단 한 번 정착하며, 정착 후에는 바뀌지 않는다", + "option3": "rejected된 Promise는 재시도하면 같은 객체가 다시 pending으로 돌아간다", + "option4": "then을 호출해야 비로소 pending 상태가 시작된다", + "answerIndex": 1, + "explanation": "Promise는 pending(대기)에서 출발해 성공하면 fulfilled, 실패하면 rejected로 전이하는데, 이 전이는 단 한 번뿐이고 한번 정착(settled)한 상태는 절대 되돌아가거나 바뀌지 않습니다. 이 '단 한 번, 둘 중 하나' 보장 덕분에 결과를 믿고 기다릴 수 있습니다. 재시도는 새 Promise를 만드는 것이지 기존 객체를 되돌리는 게 아닙니다." + }, + { + "courseSlug": "lv2-async-javascript", + "question": "async/await에 대한 설명으로 옳은 것은?", + "option1": "await는 Promise와 무관한 완전히 새로운 비동기 실행 방식이다", + "option2": "await를 쓰면 섹션 1의 while 루프처럼 브라우저 화면 전체가 멈춘다", + "option3": "await는 Promise를 기다리는 문법 설탕으로, 해당 async 함수만 멈추고 콜 스택은 풀려나 다른 일을 처리한다", + "option4": "async 함수에서 return 42를 하면 호출한 쪽에서 숫자 42를 바로 받는다", + "answerIndex": 2, + "explanation": "await는 새 동작이 아니라 then 체인과 같은 일을 위에서 아래로 읽히게 표기하는 문법 설탕입니다. await에서 멈추는 것은 그 async 함수 하나뿐이고, 일꾼(콜 스택)은 풀려나 다른 주문서를 처리하므로 동기 while 루프와 달리 화면이 얼지 않습니다. 또한 async 함수의 반환값은 항상 Promise로 감싸져 나오므로 호출하는 쪽에서도 await가 필요합니다." + }, + { + "courseSlug": "lv2-async-javascript", + "question": "서로 의존하지 않는 환율 요청 3개(USD·EUR·JPY)를 처리할 때, Promise.all과 Promise.allSettled의 차이로 옳은 것은?", + "option1": "Promise.all은 하나라도 실패하면 전체가 reject되고, allSettled는 성공·실패를 가리지 않고 전원의 결과 보고서를 배열로 돌려준다", + "option2": "Promise.all은 요청을 하나씩 순차 실행하고, allSettled만 병렬로 실행한다", + "option3": "allSettled는 실패한 요청을 자동으로 재시도한 뒤 결과를 준다", + "option4": "둘 다 하나라도 실패하면 즉시 reject되며, 차이는 결과 배열의 순서뿐이다", + "answerIndex": 0, + "explanation": "Promise.all은 '전원 성공 아니면 전체 실패'라서 하나만 rejected돼도 성공한 결과까지 함께 버려집니다 — 결과들이 한 몸일 때 적합합니다. Promise.allSettled는 절대 reject되지 않고 각 Promise의 성공(fulfilled/value)·실패(rejected/reason)를 담은 결과 배열을 돌려주므로, 되는 것만이라도 화면에 보여 주는 부분 실패 허용에 적합합니다. 둘 다 요청은 동시에(병렬로) 출발합니다." + }, + { + "courseSlug": "lv2-async-javascript", + "question": "AbortController를 이용한 요청 취소·타임아웃 패턴에 대한 설명으로 옳은 것은?", + "option1": "controller.abort()를 호출하면 fetch의 Promise가 fulfilled로 정착하며 빈 응답을 돌려준다", + "option2": "AbortController는 아직 보내지 않은 요청만 예약 취소할 수 있다", + "option3": "fetch 옵션에 signal을 연결해 두고 abort()를 호출하면 해당 Promise가 AbortError로 reject되므로, catch에서 err.name으로 의도한 취소와 진짜 실패를 구분해야 한다", + "option4": "타임아웃은 fetch에 timeout 숫자 옵션을 넘기는 것이 표준 방법이다", + "answerIndex": 2, + "explanation": "fetch 옵션의 signal에 controller.signal을 연결해 두면, abort() 호출 시 그 Promise가 'AbortError'라는 이름의 에러로 reject되어 이미 나가 있는 요청도 끊을 수 있습니다. 다만 검색어 변경처럼 우리가 의도적으로 취소한 경우도 catch로 들어오므로, err.name === 'AbortError'를 확인해 의도한 취소는 조용히 처리하고 진짜 실패만 에러 메시지로 보여 줘야 합니다. 타임아웃도 setTimeout에서 abort()를 호출하는 같은 패턴으로 구현합니다." + }, + { + "courseSlug": "lv2-array-data-wrangling", + "question": "res.map((o) => { o.order_id }) 를 실행하면 결과 배열의 요소는 무엇으로 채워질까요?", + "option1": "각 주문의 order_id 값", + "option2": "undefined", + "option3": "원본 주문 객체 그대로", + "option4": "문법 오류로 아예 실행되지 않는다", + "answerIndex": 1, + "explanation": "화살표 함수에서 중괄호 { }를 여는 순간 '블록'이 되어 return을 직접 써야 합니다. 이 코드는 o.order_id를 계산만 하고 아무것도 반환하지 않으므로, map은 요소마다 undefined를 받아 [undefined, undefined, ...] 배열을 만듭니다. 문법 오류는 아니라서 조용히 잘못된 결과가 나오는 것이 이 실수가 무서운 이유입니다." + }, + { + "courseSlug": "lv2-array-data-wrangling", + "question": "객체 배열에 reduce를 쓰면서 초기값을 생략하면 어떤 일이 벌어질까요?", + "option1": "자동으로 0이 초기값이 되어 문제없이 합계가 구해진다", + "option2": "항상 TypeError가 발생해 즉시 알아챌 수 있다", + "option3": "배열의 첫 요소(객체)가 누산기의 출발점이 되어 엉뚱한 결과가 나온다", + "option4": "누산기가 빈 객체 {}로 시작해 그룹핑이 된다", + "answerIndex": 2, + "explanation": "초기값을 생략하면 자바스크립트는 배열의 '첫 요소'를 누산기의 출발점으로 삼습니다. 객체 배열이라면 첫 바퀴의 acc가 주문 객체 자체가 되어 \"[object Object]18000...\" 같은 괴상한 문자열이 만들어집니다. TypeError는 '빈 배열 + 초기값 생략'일 때만 나므로, 데이터가 있으면 조용히 틀립니다. 그래서 초기값은 항상 쓰는 것으로 익혀야 합니다." + }, + { + "courseSlug": "lv2-array-data-wrangling", + "question": "sort에 관한 설명으로 옳은 것은 무엇일까요?", + "option1": "sort는 map·filter처럼 원본을 두고 새 배열을 돌려준다", + "option2": "비교 함수를 생략하면 요소를 문자열로 바꿔 비교하므로 [5, 1, 30].sort()는 [1, 30, 5]가 된다", + "option3": "비교 함수가 양수를 반환하면 a가 앞으로 온다", + "option4": "toSorted도 sort와 마찬가지로 원본 배열을 바꾼다", + "answerIndex": 1, + "explanation": "비교 함수 없이 sort를 부르면 모든 요소를 문자열로 바꿔 사전순 비교하므로 '30'이 '5'보다 앞에 옵니다('3' < '5'). 나머지는 반대로 알아야 합니다 — sort는 원본 자체를 재배열하는 메서드이고(그래서 [...arr].sort나 toSorted로 방어), toSorted는 원본을 두고 새 배열을 돌려주며, 비교 함수가 음수일 때 a가 앞으로 옵니다." + }, + { + "courseSlug": "lv2-array-data-wrangling", + "question": "조건에 맞는 주문이 하나도 없을 때, filter와 find는 각각 무엇을 돌려줄까요?", + "option1": "filter는 빈 배열 [], find는 undefined", + "option2": "둘 다 undefined", + "option3": "둘 다 빈 배열 []", + "option4": "filter는 undefined, find는 빈 배열 []", + "answerIndex": 0, + "explanation": "filter는 조건에 맞는 '전부'를 담은 배열을 돌려주므로 없으면 빈 배열 []이고, find는 조건에 맞는 '첫 하나'의 요소 자체를 돌려주므로 없으면 undefined입니다. 이 차이 때문에 find 결과에 바로 점(.)을 찍으면 TypeError가 날 수 있어, 옵셔널 체이닝(?.)으로 방어해야 합니다." + }, + { + "courseSlug": "lv2-array-data-wrangling", + "question": "주문마다 items 배열이 들어 있는 중첩 데이터에서, 모든 상품을 한 겹의 평평한 배열로 나열하려면 어떤 메서드가 알맞을까요?", + "option1": "orders.map((o) => o.items) — map만으로 충분하다", + "option2": "orders.filter((o) => o.items)", + "option3": "orders.flatMap((o) => o.items)", + "option4": "orders.reduce((acc, o) => acc + o.items, 0)", + "answerIndex": 2, + "explanation": "map만 쓰면 요소마다 items '배열'이 반환되어 [[키보드, 마우스], [모니터]]처럼 배열의 배열(두 겹)이 됩니다. flatMap은 map을 하고 나서 결과를 한 겹 펼쳐(flat) [키보드, 마우스, 모니터] 같은 평평한 배열을 만들어 줍니다. filter는 골라내기, reduce에 초기값 0을 주면 숫자로 접는 것이라 상품 나열이라는 목적과 맞지 않습니다." + }, + { + "courseSlug": "lv2-rest-api-design", + "question": "REST API에서 URL을 설계할 때 올바른 원칙은 무엇인가요?", + "option1": "URL에 getAllPosts처럼 동사를 넣어 행동을 명확히 표현한다", + "option2": "URL에는 명사를 복수형으로 쓰고, 행동은 HTTP 메서드로 표현한다", + "option3": "URL은 짧을수록 좋으므로 /p, /c처럼 축약해서 쓴다", + "option4": "리소스마다 단수형과 복수형을 자유롭게 섞어 써도 된다", + "answerIndex": 1, + "explanation": "REST에서 URL은 자원(리소스)의 '주소' 역할만 합니다. 그래서 /posts처럼 명사 복수형으로 쓰고, 조회·생성·삭제 같은 '행동'은 GET·POST·DELETE 등 HTTP 메서드가 맡습니다. /getAllPosts처럼 동사가 URL에 들어가면 메서드가 할 일을 URL이 뺏은 것이고, 단수·복수를 섞으면 일관성이 깨져 사용하기 어려워집니다." + }, + { + "courseSlug": "lv2-rest-api-design", + "question": "PUT과 PATCH의 차이로 옳은 것은 무엇인가요?", + "option1": "PUT은 부분 수정, PATCH는 전체 교체다", + "option2": "PUT은 조회용이고 PATCH는 삭제용이다", + "option3": "PUT은 전체 교체라서 보내지 않은 필드가 사라질 수 있고, PATCH는 보낸 필드만 바꾼다", + "option4": "둘은 완전히 같은 동작을 하며 이름만 다르다", + "answerIndex": 2, + "explanation": "PUT은 리소스를 보낸 내용으로 '통째로 갈아 끼우는' 전체 교체입니다. 그래서 title만 담아 PUT을 보내면 content나 tags 같은 나머지 필드가 사라지는 사고가 납니다. 반면 PATCH는 보낸 필드만 바꾸고 나머지는 그대로 두는 부분 수정이므로, '제목만 고치기' 같은 기능에는 PATCH가 맞습니다." + }, + { + "courseSlug": "lv2-rest-api-design", + "question": "멱등성(idempotency)에 대한 설명으로 옳은 것은 무엇인가요?", + "option1": "POST는 멱등해서 네트워크 오류 시 마음 놓고 재시도해도 된다", + "option2": "멱등성이란 같은 요청을 두 번 보내도 결과가 한 번 보낸 것과 같다는 뜻이며, POST는 멱등하지 않다", + "option3": "GET은 보낼 때마다 데이터가 변하므로 멱등하지 않다", + "option4": "멱등성은 요청 속도가 빠른지를 나타내는 성능 지표다", + "answerIndex": 1, + "explanation": "멱등성은 '같은 요청을 두 번 보내도 결과가 한 번과 같은가'라는 성질입니다. GET은 몇 번 읽어도 데이터가 변하지 않고 DELETE도 결과 상태가 같아 멱등하지만, POST는 보낼 때마다 새 리소스가 하나 더 생기므로 멱등하지 않습니다. 그래서 네트워크가 끊겨 재시도할 때 POST를 무심코 다시 보내면 글이 두 번 등록되거나 결제가 두 번 나가는 사고가 납니다." + }, + { + "courseSlug": "lv2-rest-api-design", + "question": "HTTP 상태 코드의 쓰임으로 옳은 것은 무엇인가요?", + "option1": "401은 권한 부족, 403은 로그인이 안 된 상태를 뜻한다", + "option2": "삭제 성공처럼 돌려줄 바디가 없는 성공에는 204를 쓴다", + "option3": "클라이언트가 필수 필드를 빠뜨렸을 때도 500으로 응답하는 것이 표준이다", + "option4": "201은 리소스를 삭제했을 때 쓰는 코드다", + "answerIndex": 1, + "explanation": "204 No Content는 '성공했지만 돌려줄 바디가 없다'는 뜻으로 DELETE 성공이 대표 사례입니다. 401은 '누구세요?'(인증 안 됨), 403은 '누군지는 알지만 권한이 없음'으로 1번 보기는 둘이 뒤바뀌었고, 201은 삭제가 아니라 POST로 새 리소스가 만들어졌을 때 씁니다. 500은 서버 코드가 터졌을 때만 써야 하며, 클라이언트 실수까지 500으로 뭉뚱그리면 4xx/5xx로 책임 소재를 가리는 디버깅 나침반이 망가집니다." + }, + { + "courseSlug": "lv2-rest-api-design", + "question": "경로 파라미터와 쿼리 파라미터를 고르는 기준으로 옳은 것은 무엇인가요?", + "option1": "값이 없으면 대상 자체가 특정되지 않는 것은 경로로, 없어도 기본값으로 동작하는 정렬·페이지 같은 옵션은 쿼리로 표현한다", + "option2": "숫자는 항상 경로에, 문자는 항상 쿼리에 넣는다", + "option3": "정렬과 페이지네이션은 반드시 경로 파라미터로 표현해야 한다", + "option4": "쿼리 파라미터는 한 번에 하나만 쓸 수 있으므로 조합이 필요하면 경로를 쓴다", + "answerIndex": 0, + "explanation": "선택 기준은 '그 값이 없으면 대상 자체가 특정되지 않는가'입니다. 3번 글의 댓글은 3이 없으면 성립하지 않으므로 경로(/posts/3/comments)로 쓰고, 정렬·페이지는 없어도 기본 정렬·1페이지로 동작할 수 있으므로 쿼리(?sort=-createdAt&page=2)로 씁니다. 즉 경로는 자원의 '주소', 쿼리는 자원을 보는 '방식'이며, 쿼리는 &로 얼마든지 조합할 수 있습니다." + }, + { + "courseSlug": "lv2-express-crud", + "question": "POST 요청의 JSON 본문(body)이 req.body에서 undefined로 나온다. 강좌에서 배운 내용상 가장 먼저 의심해야 할 원인은?", + "option1": "nodemon을 설치하지 않아서", + "option2": "app.use(express.json()) 미들웨어를 등록하지 않아서", + "option3": "포트 번호를 3000이 아닌 다른 번호로 설정해서", + "option4": "res.json() 대신 res.send()를 사용해서", + "answerIndex": 1, + "explanation": "Express는 기본적으로 요청 본문을 읽지 않기 때문에, express.json() 미들웨어를 등록해야 JSON 본문이 파싱되어 req.body 객체로 들어옵니다. 강좌에서도 \"body가 안 읽혀요\"의 90%가 이 한 줄을 빼먹은 경우라고 배웠습니다. nodemon은 자동 재시작 도구, 포트는 문 번호일 뿐 body 파싱과는 무관합니다." + }, + { + "courseSlug": "lv2-express-crud", + "question": "리소스 생성 성공과 삭제 성공에 어울리는 상태 코드를 순서대로 짝지은 것은?", + "option1": "200 Created — 404 Not Found", + "option2": "204 No Content — 201 Created", + "option3": "201 Created — 204 No Content", + "option4": "400 Bad Request — 200 OK", + "answerIndex": 2, + "explanation": "생성 성공에는 \"만들어졌다\"를 뜻하는 전용 코드 201 Created를 쓰고, 이때 서버가 붙인 id를 알 수 있도록 생성된 리소스를 본문에 담아 돌려줍니다. 삭제 성공은 \"잘 처리했고 돌려줄 내용이 없다\"는 204 No Content가 어울리며, 204에는 본문을 싣지 않으므로 res.json()이 아닌 res.status(204).end()를 사용합니다." + }, + { + "courseSlug": "lv2-express-crud", + "question": "GET /posts/:id 핸들러에서 posts.find((p) => p.id === req.params.id)가 존재하는 글도 못 찾는 이유는?", + "option1": "find는 배열이 아닌 객체에만 쓸 수 있는 메서드라서", + "option2": "경로 파라미터는 req.query로 접근해야 해서", + "option3": ":id 자리표시자는 숫자만 받을 수 있어서", + "option4": "req.params.id는 항상 문자열이라 숫자 id와 === 비교하면 false가 되기 때문에", + "answerIndex": 3, + "explanation": "URL에서 온 값은 전부 문자열입니다. '3' === 3은 false이므로, Number(req.params.id)로 변환한 뒤 비교해야 숫자 id와 일치시킬 수 있습니다. 이는 강좌에서 짚은 대표적인 함정으로, 쿼리스트링(req.query)의 page·limit 값도 같은 이유로 숫자 변환이 필요합니다." + }, + { + "courseSlug": "lv2-express-crud", + "question": "직접 만든 미들웨어 함수 안에서 next()를 호출하지 않으면 어떤 일이 벌어지는가?", + "option1": "요청이 다음 단계로 넘어가지 못하고 갇혀서, 클라이언트는 응답을 받지 못한 채 계속 기다리게 된다", + "option2": "Express가 자동으로 다음 미들웨어를 실행해 주므로 아무 문제가 없다", + "option3": "서버가 즉시 500 에러를 응답한다", + "option4": "해당 미들웨어가 건너뛰어지고 핸들러가 바로 실행된다", + "answerIndex": 0, + "explanation": "next()는 \"내 검문은 끝났으니 다음 순번에게 넘겨라\"는 신호입니다. 미들웨어가 next()도 부르지 않고 응답도 보내지 않으면 요청은 그 검문소에 갇혀 멈추고, 브라우저는 응답 없이 계속 기다립니다. Express가 자동으로 넘겨주지 않으며, 에러를 대신 응답해 주지도 않습니다." + }, + { + "courseSlug": "lv2-express-crud", + "question": "브라우저 주소창만으로는 우리가 만든 API를 전부 테스트할 수 없어서 Postman·REST Client 같은 도구가 필요하다. 그 근본 이유는?", + "option1": "주소창은 localhost 주소를 입력할 수 없기 때문에", + "option2": "주소창 입력은 언제나 GET 요청이라 POST·PATCH·DELETE를 보내거나 JSON 본문을 실을 방법이 없기 때문에", + "option3": "브라우저는 JSON 응답을 화면에 표시하지 못하기 때문에", + "option4": "주소창으로는 쿼리스트링을 붙일 수 없기 때문에", + "answerIndex": 1, + "explanation": "주소창에 URL을 입력하는 행위는 언제나 GET 요청입니다. 메서드를 바꾸거나 JSON 본문·헤더를 실을 수 없으므로 POST·PATCH·DELETE는 주소창으로 테스트 자체가 불가능합니다. 반면 localhost 접속과 쿼리스트링(?q=...&page=1)은 주소창으로도 가능하고, 브라우저는 JSON 응답을 텍스트로 잘 보여 줍니다." + }, + { + "courseSlug": "lv2-db-transactions", + "question": "메모리 배열에 저장하던 블로그 글이 서버를 재시작하면 사라지는 근본 이유는 무엇인가요?", + "option1": "Node.js가 재시작 시 파일을 자동 삭제하기 때문", + "option2": "변수는 프로세스의 메모리(RAM)에 살아서, 프로세스가 죽으면 메모리가 운영체제에 반납되기 때문", + "option3": "HTTP가 무상태 프로토콜이라 요청이 끝나면 데이터를 지우기 때문", + "option4": "배열의 최대 크기 제한을 초과했기 때문", + "answerIndex": 1, + "explanation": "변수(배열)는 디스크가 아니라 프로세스의 RAM 위에 존재합니다. 프로세스가 종료되면 그 메모리는 운영체제에 반납되므로 데이터가 함께 사라져요. DB는 데이터를 디스크에 기록해 이 '영속성' 문제를 해결합니다." + }, + { + "courseSlug": "lv2-db-transactions", + "question": "사용자 입력을 SQL에 넣을 때 문자열 이어붙이기 대신 파라미터 바인딩($1)을 써야 하는 이유로 옳은 것은?", + "option1": "바인딩된 값은 SQL 문장 구조를 바꿀 수 없는 '그냥 값'으로 취급되어 인젝션이 원천 차단되기 때문", + "option2": "$1 문법이 문자열 조립보다 실행 속도가 항상 10배 빠르기 때문", + "option3": "문자열 조립은 PostgreSQL에서 문법 오류를 일으켜 아예 실행되지 않기 때문", + "option4": "바인딩을 쓰면 커넥션 풀 없이도 연결이 자동 관리되기 때문", + "answerIndex": 0, + "explanation": "문자열 조립은 ' OR '1'='1 같은 입력이 SQL 문장의 일부가 되어 전체 데이터 유출·테이블 삭제로 이어질 수 있습니다. 바인딩은 SQL 구조를 먼저 확정하고 값을 별도로 전달하므로, DB가 입력을 어떤 내용이든 단순한 값으로만 취급해 문장을 바꿀 수 없어요." + }, + { + "courseSlug": "lv2-db-transactions", + "question": "UPDATE 쿼리 실행 후 result.rowCount가 0이었다면, API는 이를 어떻게 해석하고 응답하는 것이 적절한가요?", + "option1": "쿼리가 실패했으므로 500 Internal Server Error로 응답한다", + "option2": "WHERE 조건에 해당하는 행이 없었다는 뜻이므로 404 Not Found로 응답한다", + "option3": "수정할 내용이 기존 값과 같았다는 뜻이므로 200 OK로 응답한다", + "option4": "트랜잭션이 롤백되었다는 뜻이므로 재시도한다", + "answerIndex": 1, + "explanation": "rowCount는 쿼리가 실제로 영향을 준 행의 수입니다. UPDATE ... WHERE id = $1에서 0이 나왔다면 그 id의 행이 존재하지 않았다는 뜻이므로, 배열 시절 find()가 undefined를 돌려주던 것과 같은 신호로 보고 404로 응답하는 것이 맞습니다." + }, + { + "courseSlug": "lv2-db-transactions", + "question": "'댓글 작성 INSERT'와 '글의 댓글수 UPDATE'를 하나의 트랜잭션으로 묶었을 때, 댓글수 UPDATE가 실패하면 어떤 일이 일어나나요?", + "option1": "INSERT는 이미 실행됐으므로 댓글만 저장되고 댓글수는 그대로 남는다", + "option2": "ROLLBACK에 의해 INSERT까지 함께 취소되어, 두 작업 모두 없던 일이 된다", + "option3": "DB가 댓글수를 자동으로 다시 계산해 맞춰 준다", + "option4": "COMMIT이 절반만 실행되어 댓글이 임시 상태로 저장된다", + "answerIndex": 1, + "explanation": "트랜잭션의 핵심이 all-or-nothing입니다. BEGIN 이후의 변경은 COMMIT 전까지 '초안' 상태이고, 중간에 하나라도 실패해 ROLLBACK하면 BEGIN 이후의 모든 변경이 통째로 취소됩니다. 그래서 '댓글은 달렸는데 댓글수는 안 오른' 어정쩡한 상태가 존재할 수 없어요." + }, + { + "courseSlug": "lv2-db-transactions", + "question": "트랜잭션 안에서 외부 API(예: 알림 메일 발송)를 호출하면 안 되는 이유로 본문이 든 것은?", + "option1": "외부 API 응답이 SQL 인젝션 공격에 노출되기 때문", + "option2": "PostgreSQL이 트랜잭션 중의 네트워크 요청을 차단하기 때문", + "option3": "느린 외부 호출 동안 연결과 잠금이 계속 점유되고, 롤백해도 이미 나간 외부 호출은 되돌릴 수 없기 때문", + "option4": "외부 API 호출이 COMMIT을 자동으로 실행해 버리기 때문", + "answerIndex": 2, + "explanation": "이유는 두 겹입니다. 첫째, 트랜잭션이 열려 있는 동안 그 연결과 DB 잠금이 계속 점유되어 느린 외부 호출 하나가 커넥션 풀 전체를 말릴 수 있습니다. 둘째, DB 변경은 롤백되지만 이미 발송된 메일 같은 외부 세계의 일은 롤백되지 않아 데이터와 현실이 어긋납니다. 그래서 트랜잭션은 DB 작업만으로 짧게 닫고, 외부 호출은 커밋 확정 후에 합니다." + }, + { + "courseSlug": "lv2-auth-session-hash", + "question": "같은 비밀번호를 bcrypt.hash()로 두 번 해시하면 서로 다른 결과가 나온다. 그 이유로 가장 알맞은 것은?", + "option1": "bcrypt가 해시할 때마다 현재 시각을 결과에 포함하기 때문에", + "option2": "bcrypt가 매번 무작위 솔트(salt)를 만들어 비밀번호에 섞은 뒤 해시하기 때문에", + "option3": "bcrypt가 내부적으로 비밀번호를 암호화했다가 다시 복호화하기 때문에", + "option4": "해시 함수는 원래 같은 입력에도 항상 다른 출력을 내는 함수이기 때문에", + "answerIndex": 1, + "explanation": "bcrypt는 해시할 때마다 무작위 솔트를 생성해 비밀번호에 섞고, 그 솔트를 해시 문자열 안에 함께 저장합니다. 그래서 같은 비밀번호도 매번 다른 해시가 되고, 미리 계산해 둔 해시 사전(레인보 테이블)으로 역추적하는 공격이 막힙니다. 검증할 때는 저장된 해시에서 솔트를 꺼내 입력값을 같은 방식으로 다시 해시해 대조합니다(bcrypt.compare). 해시 함수 자체는 같은 입력+같은 솔트면 항상 같은 출력을 냅니다." + }, + { + "courseSlug": "lv2-auth-session-hash", + "question": "로그인 실패 시 '이메일이 틀렸는지'와 '비밀번호가 틀렸는지'를 구분하지 않고 같은 메시지로 응답하는 이유는?", + "option1": "두 경우를 구분하는 코드가 복잡해서 서버 성능이 떨어지기 때문에", + "option2": "HTTP 표준이 401 응답에는 하나의 에러 메시지만 담도록 규정하기 때문에", + "option3": "구분해서 알려주면 공격자에게 '이 이메일은 가입돼 있다'는 사실을 확인시켜 주기 때문에", + "option4": "사용자가 에러 메시지를 읽고 혼란스러워하는 것을 막기 위한 UX 원칙이기 때문에", + "answerIndex": 2, + "explanation": "'비밀번호가 틀렸습니다'라고 답하는 순간 공격자는 해당 이메일이 가입된 계정임을 공짜로 확인하게 됩니다. 그렇게 모은 유효 계정 목록은 비밀번호 대입 공격의 표적을 좁히는 데 쓰이므로, 에러 메시지는 공격자에게 정보를 흘리지 않도록 두 경우를 똑같이 '이메일 또는 비밀번호가 올바르지 않습니다'로 응답하는 것이 원칙입니다." + }, + { + "courseSlug": "lv2-auth-session-hash", + "question": "세션 방식 로그인에서 브라우저와 서버의 역할 분담으로 옳은 것은?", + "option1": "브라우저는 뜻 없는 세션ID만 쿠키로 들고 다니고, 사용자가 누구인지는 서버의 세션 저장소가 기억한다", + "option2": "브라우저가 사용자 이메일과 권한 정보를 쿠키에 담아 두고, 서버는 아무것도 저장하지 않는다", + "option3": "브라우저와 서버가 각각 비밀번호 해시를 나눠 보관하고 매 요청마다 대조한다", + "option4": "서버가 세션ID를 URL 파라미터로 발급하고 브라우저는 이를 localStorage에 저장한다", + "answerIndex": 0, + "explanation": "세션 방식의 핵심은 코트 보관소 번호표 구조입니다. 로그인에 성공하면 서버가 세션 저장소에 '세션ID=사용자 몇 번'을 기록하고, Set-Cookie로 세션ID만 브라우저에 건넵니다. 브라우저는 이후 요청마다 그 쿠키를 자동으로 실어 보내고, 서버가 세션ID로 저장소를 조회해 사용자를 복원합니다. 실제 정보는 서버 쪽에 있고 브라우저가 가진 것은 무작위 문자열뿐입니다." + }, + { + "courseSlug": "lv2-auth-session-hash", + "question": "로그인한 사용자가 다른 사람이 쓴 글의 수정 API를 호출했다. 서버가 돌려줘야 할 응답으로 가장 알맞은 것은?", + "option1": "401 Unauthorized — 인증이 안 된 요청이므로", + "option2": "403 Forbidden — 누구인지는 확인됐지만 권한 밖의 요청이므로", + "option3": "409 Conflict — 글의 현재 상태와 충돌하는 요청이므로", + "option4": "404 Not Found — 그 사용자에게는 존재하지 않는 글이므로", + "answerIndex": 1, + "explanation": "401은 '너 누군지 모르겠어, 로그인부터 해'(인증 실패), 403은 '너 누군지는 아는데 이건 네 권한 밖이야'(인가 실패)입니다. 이 경우 사용자는 이미 로그인해 인증은 통과했지만 글의 소유자가 아니므로, 소유권 검사(post.authorId !== req.user.id)에 걸려 403으로 거절하는 것이 맞습니다. 409는 이메일 중복 가입처럼 자원 상태 충돌에 쓰는 코드입니다." + }, + { + "courseSlug": "lv2-auth-session-hash", + "question": "로그인 성공 직후 req.session.regenerate()로 세션ID를 새로 발급하는 이유는 무엇을 막기 위해서인가?", + "option1": "공격자가 미리 심어 둔 세션ID가 로그인 상태로 승격되는 세션 고정(session fixation) 공격", + "option2": "자주 쓰는 비밀번호를 기계적으로 대입해 보는 무차별 대입(brute force) 공격", + "option3": "다른 사이트에서 유출된 계정 정보로 로그인을 시도하는 크리덴셜 스터핑", + "option4": "악성 스크립트가 document.cookie로 쿠키를 읽어 가는 XSS 기반 탈취", + "answerIndex": 0, + "explanation": "세션 고정 공격은 공격자가 확보한 세션ID를 피해자 브라우저에 미리 심어 두고, 피해자가 그 ID를 단 채로 로그인하면 같은 ID를 아는 공격자도 로그인 상태가 되는 공격입니다. 로그인 성공 직후 세션ID를 새것으로 교체하면 공격자가 심어 둔 옛 ID는 무효가 됩니다. 무차별 대입은 시도 제한(429)으로, XSS 기반 쿠키 탈취는 httpOnly 옵션으로 각각 방어합니다." + }, + { + "courseSlug": "lv2-react-state-effects", + "question": "장바구니 화면에서 items 배열이 이미 state로 있을 때, 합계 금액(totalPrice)은 어떻게 다루는 것이 가장 좋을까요?", + "option1": "useState로 별도 state를 만들고 items를 바꿀 때마다 함께 갱신한다", + "option2": "state로 두지 않고 렌더링 때마다 items에서 reduce로 계산한다", + "option3": "useEffect에서 items가 바뀔 때마다 setTotalPrice로 동기화한다", + "option4": "서버에 합계를 요청해서 받아 온다", + "answerIndex": 1, + "explanation": "totalPrice는 items에서 파생 가능한 값이므로 state가 아니라 계산으로 둡니다. 별도 state로 저장하면 items와 두 곳에 같은 사실이 존재하게 되어, 삭제 등에서 갱신을 깜빡하는 순간 화면 합계가 실제와 어긋나는 동기화 버그가 생깁니다. 렌더링마다 원본에서 다시 계산하면 어긋날 방법 자체가 없습니다." + }, + { + "courseSlug": "lv2-react-state-effects", + "question": "user.level = 2; setUser(user); 처럼 객체 내부를 직접 수정한 뒤 같은 객체를 set 함수에 넘기면 화면이 갱신되지 않습니다. 그 이유는 무엇일까요?", + "option1": "직접 수정하면 자바스크립트 엔진이 에러를 던져 렌더링이 중단되기 때문", + "option2": "React가 객체 내부 값을 전부 비교하는 데 시간이 오래 걸려 갱신을 미루기 때문", + "option3": "React는 이전 값과 새 값을 참조(같은 객체인지)로만 비교하는데, 같은 객체를 넘기면 변경이 없다고 판단하기 때문", + "option4": "setUser는 숫자와 문자열만 받을 수 있고 객체는 받을 수 없기 때문", + "answerIndex": 2, + "explanation": "React는 성능을 위해 내용물을 뒤져 보지 않고 '이전 객체 === 새 객체'라는 참조 비교로 변경을 감지합니다. 내부 값을 고쳐도 같은 상자(참조)를 다시 건네면 React는 이전과 같다고 보고 리렌더링을 건너뜁니다. 그래서 스프레드 등으로 새 객체를 만들어 교체해야 합니다." + }, + { + "courseSlug": "lv2-react-state-effects", + "question": "검색창(SearchBar)과 글 목록(PostList)이 형제 컴포넌트로서 검색어 상태를 공유해야 할 때, 상태의 주인은 어디에 두어야 할까요?", + "option1": "SearchBar 안에 두고 PostList가 SearchBar를 직접 참조하게 한다", + "option2": "두 컴포넌트를 모두 품는 가장 가까운 공통 부모에 두고, 값은 props로, 변경은 콜백으로 내려 준다", + "option3": "PostList 안에 두고 SearchBar로 복사본 state를 하나 더 만든다", + "option4": "가능한 한 최상위 컴포넌트(App)에 두는 것이 항상 정답이다", + "answerIndex": 1, + "explanation": "React의 데이터는 부모에서 자식으로 한 방향으로만 흐르므로 형제끼리 직접 대화할 수 없습니다. 그래서 상태를 두 형제가 함께 쓰는 최소 공통 부모로 끌어올리고, 부모가 값(props)과 바꿀 권한(콜백)을 내려 줍니다. 필요 이상으로 높이 올리면 관계없는 컴포넌트까지 리렌더링되므로 '가장 가까운' 공통 부모가 기준입니다." + }, + { + "courseSlug": "lv2-react-state-effects", + "question": "useEffect(() => { ... }, [query]) 에서 클린업 함수가 실행되는 정확한 시점으로 옳은 것은?", + "option1": "컴포넌트가 언마운트될 때 단 한 번만 실행된다", + "option2": "이펙트 본문이 실행되기 직전에 매번 먼저 실행된다(첫 실행 포함)", + "option3": "컴포넌트가 언마운트될 때, 그리고 query가 바뀌어 이펙트가 다시 실행되기 직전에 실행된다", + "option4": "setState가 호출될 때마다 실행된다", + "answerIndex": 2, + "explanation": "클린업은 두 시점에 실행됩니다. 컴포넌트가 화면에서 사라질 때(언마운트), 그리고 의존성이 바뀌어 새 이펙트가 실행되기 직전(이전 이펙트의 클린업이 먼저)입니다. 첫 렌더링에는 치울 이전 이펙트가 없으므로 실행되지 않습니다. 이 '새 이펙트 전에 이전 클린업 먼저' 순서 덕분에 타이머·구독이 중복으로 쌓이지 않고, 데이터 페칭의 ignore 깃발 패턴도 이 순서에 기대어 동작합니다." + }, + { + "courseSlug": "lv2-react-state-effects", + "question": "검색어를 빠르게 연달아 입력해 fetch 요청이 여러 개 나갔을 때, 페칭 이펙트에 ignore 깃발(let ignore = false; 클린업에서 ignore = true)을 두는 이유는 무엇일까요?", + "option1": "요청을 하나로 합쳐서 서버 부하를 줄이기 위해", + "option2": "응답이 출발 순서대로 도착한다는 보장이 없어, 늦게 도착한 옛 요청의 결과가 최신 화면을 덮어쓰는 레이스 컨디션을 막기 위해", + "option3": "fetch가 실패했을 때 자동으로 재시도하기 위해", + "option4": "로딩 스피너가 깜빡이는 것을 막기 위해", + "answerIndex": 1, + "explanation": "네트워크 응답은 출발 순서대로 도착한다는 보장이 없습니다. 방어 없이 setPosts를 하면 늦게 도착한 옛 검색어의 결과가 최신 결과를 덮어써서 검색어와 목록이 어긋납니다. query가 바뀌면 이전 이펙트의 클린업이 먼저 실행되어 이전 요청의 ignore가 true가 되고, 늦게 도착한 응답은 if (!ignore) 문턱에서 걸러져 항상 최신 요청만 반영됩니다. 언마운트 후 setState 경고도 같은 깃발로 함께 해결됩니다." + }, + { + "courseSlug": "lv2-react-routing", + "question": "SPA 내부에서 페이지를 이동할 때 <a href> 대신 <Link>를 써야 하는 가장 큰 이유는 무엇인가요?", + "option1": "<a> 태그는 보안상 취약해서 브라우저가 차단하기 때문에", + "option2": "<a>는 서버에 새 HTML 문서를 요청해 앱 전체가 새로고침되고 상태가 사라지기 때문에", + "option3": "<Link>가 <a>보다 렌더링 속도가 항상 2배 이상 빠르기 때문에", + "option4": "<a> 태그는 React 컴포넌트 안에서 문법 오류를 일으키기 때문에", + "answerIndex": 1, + "explanation": "<a href>는 브라우저의 기본 동작인 '서버에 새 문서 요청'을 일으켜 앱 전체가 새로고침되고, 입력 중이던 값 등 메모리의 상태가 전부 사라집니다. <Link>는 History API로 주소만 바꾸고 라우터가 화면을 교체하므로 새로고침 없이 상태가 유지됩니다." + }, + { + "courseSlug": "lv2-react-routing", + "question": "라우트를 <Route path=\"/posts/:id\" ... />로 선언했을 때, 컴포넌트 안에서 :id 자리에 들어온 값을 꺼내는 방법과 그 값의 타입으로 옳은 것은?", + "option1": "useParams()로 꺼내며, 값은 항상 문자열이다", + "option2": "useParams()로 꺼내며, 값은 항상 숫자다", + "option3": "useSearchParams()로 꺼내며, 값은 항상 문자열이다", + "option4": "props.id로 자동 전달되며, 값은 항상 숫자다", + "answerIndex": 0, + "explanation": "경로 파라미터는 useParams()로 꺼내며, /posts/42로 접근해도 값은 숫자 42가 아니라 문자열 \"42\"입니다. 그래서 숫자 비교가 필요하면 Number(id)로 변환해야 하며, 이를 잊으면 id === 42가 조용히 false가 되는 버그가 생깁니다. useSearchParams는 ?keyword= 같은 쿼리스트링용입니다." + }, + { + "courseSlug": "lv2-react-routing", + "question": "중첩 라우트에서 <Outlet />의 역할로 옳은 것은 무엇인가요?", + "option1": "현재 주소와 일치하는 메뉴를 강조 표시한다", + "option2": "존재하지 않는 주소로 접근했을 때 404 화면을 그린다", + "option3": "부모 레이아웃 안에서 자식 라우트가 그려질 자리를 표시한다", + "option4": "로그인하지 않은 사용자를 로그인 페이지로 돌려보낸다", + "answerIndex": 2, + "explanation": "Outlet은 레이아웃 라우트(부모) 안에서 자식 라우트의 화면이 끼워질 '구멍'입니다. 액자(공통 헤더·푸터)는 그대로 두고 그림(자식 페이지)만 갈아 끼우는 구조라서, 페이지를 오가도 헤더가 다시 그려지지 않고 그 안의 상태도 유지됩니다. 메뉴 강조는 NavLink, 404는 와일드카드(*), 튕겨내기는 ProtectedRoute의 역할입니다." + }, + { + "courseSlug": "lv2-react-routing", + "question": "검색어처럼 어떤 상태를 useState가 아니라 URL 쿼리스트링(useSearchParams)에 두는 판단 기준으로 본문이 제시한 것은?", + "option1": "값이 문자열이면 URL에, 숫자면 useState에 둔다", + "option2": "그 상태가 담긴 링크를 남에게 보냈을 때 의미가 있으면 URL에 둔다", + "option3": "서버 API가 사용하는 값이면 무조건 useState에 둔다", + "option4": "자주 바뀌는 값은 URL에, 거의 안 바뀌는 값은 useState에 둔다", + "answerIndex": 1, + "explanation": "URL은 공유·북마크가 가능한 상태 저장소이므로, '이 링크를 남에게 보냈을 때 같은 화면이 재현되어야 의미 있는 상태'(검색어·페이지 번호·정렬 등)는 URL에 싣습니다. 반면 입력 중인 초안이나 모달 열림 여부처럼 남에게 보내도 의미 없는 상태는 useState로 충분합니다. 새로고침·링크 공유에도 결과가 유지되는 것이 URL에 두는 이점입니다." + }, + { + "courseSlug": "lv2-react-routing", + "question": "ProtectedRoute가 미로그인 사용자를 로그인 페이지로 보낼 때 state에 원래 위치(from)를 담아 보내는 이유는 무엇인가요?", + "option1": "로그인 페이지가 어떤 화면에서 왔는지 서버에 로그로 남기기 위해", + "option2": "뒤로 가기 버튼을 완전히 비활성화하기 위해", + "option3": "프론트 검사만으로 서버 인증을 대체할 수 있게 하기 위해", + "option4": "로그인 성공 직후 사용자를 원래 가려던 페이지로 되돌려 보내기 위해", + "answerIndex": 3, + "explanation": "글쓰기(/write)에 가려다 막힌 사용자의 원래 목적지를 state.from에 담아 보내면, 로그인 페이지에서 로그인 성공 시 location.state?.from?.pathname을 읽어 navigate(from, { replace: true })로 원래 가려던 곳에 바로 데려다줄 수 있습니다. 홈으로 던져 놓는 것보다 훨씬 좋은 사용자 경험이며, 프론트 보호는 UX일 뿐 실제 보안은 서버의 토큰 검증이 담당합니다." + }, + { + "courseSlug": "lv2-form-validation", + "question": "프론트엔드에서 이미 검증을 했는데도 서버가 같은 규칙을 반드시 다시 검사해야 하는 가장 근본적인 이유는 무엇인가요?", + "option1": "서버 검증이 프론트 검증보다 실행 속도가 훨씬 빠르기 때문에", + "option2": "브라우저마다 React 검증 코드가 다르게 동작하기 때문에", + "option3": "개발자도구 콘솔의 fetch 등으로 폼과 프론트 검증을 아예 거치지 않고 서버에 직접 요청을 보낼 수 있기 때문에", + "option4": "프론트 검증은 정규식을 사용할 수 없기 때문에", + "answerIndex": 2, + "explanation": "서버는 요청이 우리 폼에서 왔는지 알 수 없습니다. 개발자도구에서 fetch 한 줄이면 프론트 검증을 한 글자도 거치지 않고 서버에 직접 요청할 수 있으므로, 프론트 검증은 '건너뛸 수 있는 안내판'일 뿐이고 서버 검증이 '건너뛸 수 없는 검문소' 역할을 해야 합니다. 프론트 검증은 UX 담당, 서버 검증은 보안 담당입니다." + }, + { + "courseSlug": "lv2-form-validation", + "question": "제어 컴포넌트로 여러 타입의 입력을 핸들러 하나로 처리할 때, 체크박스에서 값을 읽는 올바른 방법은 무엇인가요?", + "option1": "e.target.value가 아니라 e.target.checked(true/false)를 읽어야 한다", + "option2": "체크박스도 텍스트 입력과 똑같이 e.target.value를 읽으면 된다", + "option3": "체크박스는 제어 컴포넌트로 만들 수 없어서 DOM에서 직접 읽어야 한다", + "option4": "체크박스는 onChange 대신 onBlur에서만 값을 읽을 수 있다", + "answerIndex": 0, + "explanation": "입력 타입마다 값이 담기는 자리가 다릅니다. 텍스트·셀렉트는 value에 값이 담기지만, 체크박스는 checked(불리언)를 읽어야 합니다. 이를 놓치고 value를 읽으면 체크 여부와 무관한 이상한 문자열이 상태에 잡히므로, 핸들러에서 type === 'checkbox'일 때만 checked를 읽도록 분기합니다." + }, + { + "courseSlug": "lv2-form-validation", + "question": "'비밀번호 확인' 필드는 필수·길이·정규식 같은 필드별 규칙 표(RULES)만으로는 검증할 수 없습니다. 그 이유는 무엇인가요?", + "option1": "비밀번호는 보안상 프론트에서 검증하면 안 되기 때문에", + "option2": "정규식이 비밀번호 문자를 처리하지 못하기 때문에", + "option3": "비밀번호 확인 필드는 blur 이벤트가 발생하지 않기 때문에", + "option4": "올바른 값이 고정된 규칙이 아니라 다른 필드(비밀번호)의 현재 값에 달려 있는 교차 검증이기 때문에", + "answerIndex": 3, + "explanation": "필드별 규칙 표는 '이 필드 하나의 값'만 보고 판정합니다. 그런데 비밀번호 확인의 정답은 비밀번호 필드에 지금 무엇이 입력됐는지에 따라 달라지므로, 필드 하나가 아니라 폼 전체를 받아서 필드 사이를 건너 비교하는 교차 검증(cross-field validation) 단계에서 따로 처리해야 합니다." + }, + { + "courseSlug": "lv2-form-validation", + "question": "이메일 형식은 완벽한데 이미 가입된 이메일이라 회원가입이 거절되는 상황에 어울리는 HTTP 상태코드와 그 이유는 무엇인가요?", + "option1": "400 — 입력값이 검증 규칙을 위반했기 때문에", + "option2": "409 — 이미 존재하는 데이터와 부딪히는 충돌이며, DB를 가진 서버만 알 수 있는 에러이기 때문에", + "option3": "500 — 서버 내부에서 처리 중 오류가 발생했기 때문에", + "option4": "404 — 해당 이메일의 사용자를 찾을 수 없기 때문에", + "answerIndex": 1, + "explanation": "이메일 중복은 입력 형식(규칙)의 문제가 아니라 '이미 있는 것과 부딪히는' 상황이므로 400이 아닌 409 Conflict를 쓰는 관례가 있습니다. 형식이 아무리 완벽해도 중복 여부는 DB를 가진 서버만 알 수 있는, 프론트가 미리 잡을 수 없는 에러이며, 프론트는 이를 받아 '로그인해 보시겠어요?'처럼 다음 행동을 제안하는 문구로 안내합니다." + }, + { + "courseSlug": "lv2-form-validation", + "question": "회원가입 제출이 서버 검증에서 실패(400)로 돌아왔을 때, 프론트가 지켜야 할 원칙으로 옳은 것은 무엇인가요?", + "option1": "보안을 위해 즉시 폼을 초기화해서 모든 입력값을 비운다", + "option2": "제출 버튼을 영구히 비활성화해서 재시도를 막는다", + "option3": "입력값은 그대로 보존하고, 필드별 에러만 각 입력칸 아래에 표시한다", + "option4": "네트워크 실패와 똑같이 '연결이 불안정해요' 문구를 폼 상단에 띄운다", + "answerIndex": 2, + "explanation": "실패했을 때 폼을 비우면 닉네임 하나 때문에 거절당한 사용자가 이메일·비밀번호까지 처음부터 다시 입력해야 합니다. 실패 시 입력값 보존이 원칙이고, 400 응답의 errors 객체를 에러 상태에 부어 각 입력칸 아래에 필드별로 표시합니다. 네트워크 실패는 사용자가 고칠 수 있는 문제가 아니므로 검증 실패와 구분해 다른 문구로 안내해야 합니다." + }, + { + "courseSlug": "lv2-error-handling-patterns", + "question": "사용자가 존재하지 않는 글 번호로 조회를 요청해서 에러가 발생했습니다. 강좌의 분류에 따르면 이 에러는 어떻게 다루는 것이 옳을까요?", + "option1": "버그이므로 즉시 서버를 재시작하고 코드를 수정한다", + "option2": "예상된 실패이므로 404와 함께 무엇이 잘못됐는지 사용자에게 알려 준다", + "option3": "500 상태코드와 범용 메시지로 감춘다", + "option4": "catch에서 아무 처리도 하지 않고 넘어간다", + "answerIndex": 1, + "explanation": "없는 리소스 조회는 코드가 멀쩡해도 정상 운영 중 매일 일어나는 '예상된 실패(operational error)'입니다. 이런 에러는 사용자가 스스로 상황을 이해하고 고칠 수 있으므로 404 같은 적절한 상태코드와 친절한 설명이 답입니다. 500+범용 메시지로 감추는 처방은 예상 밖의 버그에만 해당하고, 에러를 삼키는 것(4번)은 어떤 경우에도 최악입니다." + }, + { + "courseSlug": "lv2-error-handling-patterns", + "question": "async 함수 안에서 try/catch로 감쌌는데도 fetchPosts()의 실패가 catch에 잡히지 않고 콘솔에 'Unhandled promise rejection'이 뜹니다. 가장 유력한 원인은 무엇일까요?", + "option1": "fetchPosts() 호출 앞에 await를 빼먹어서 에러가 try 블록이 끝난 뒤에 터졌기 때문", + "option2": "try/catch는 async 함수 안에서는 원래 동작하지 않기 때문", + "option3": "catch 블록에 인자를 선언하지 않았기 때문", + "option4": "finally 블록이 없어서 catch가 무시되었기 때문", + "answerIndex": 0, + "explanation": "try/catch는 '지금 이 자리에서 터진' 에러만 잡습니다. await가 없으면 함수는 Promise의 결과를 기다리지 않고 지나가 버리고, 실패는 try 블록이 이미 끝난 뒤 catch의 손이 닿지 않는 곳에서 터집니다. await는 성공이든 에러든 결과를 '이 자리로 끌어오는' 역할이라, await를 붙이면 catch가 정상적으로 잡습니다. try/catch 자체는 async 함수 안에서도 잘 동작합니다." + }, + { + "courseSlug": "lv2-error-handling-patterns", + "question": "Express에서 어떤 미들웨어가 '에러 처리 담당'으로 인식되는 기준은 무엇일까요?", + "option1": "app.use가 아닌 app.error로 등록한 미들웨어", + "option2": "파일 이름이 error.js인 미들웨어", + "option3": "인자를 4개(err, req, res, next) 받는 미들웨어", + "option4": "가장 먼저 등록된 미들웨어", + "answerIndex": 2, + "explanation": "Express는 미들웨어 함수의 인자 개수를 보고 역할을 구분합니다. 인자를 4개(err, req, res, next) 받으면 에러 처리 미들웨어로 인식하고, 어느 라우트에서든 throw되거나 next(err)로 넘긴 에러를 전부 그리로 배달합니다. 등록 위치는 모든 라우트 '뒤'여야 마지막 그물 역할을 할 수 있는데, 이는 동작 조건이지 인식 기준은 아닙니다." + }, + { + "courseSlug": "lv2-error-handling-patterns", + "question": "예상 밖의 에러(버그)가 발생했을 때 스택 트레이스를 API 응답에 담아 보내면 안 되는 핵심 이유는 무엇일까요?", + "option1": "스택 트레이스는 용량이 커서 네트워크 비용이 많이 들기 때문", + "option2": "서버의 파일 경로·폴더 구조·라이브러리 버전 같은 내부 정보가 공격자에게 노출되기 때문", + "option3": "JSON으로 직렬화할 수 없는 형식이기 때문", + "option4": "브라우저가 스택 트레이스를 파싱하지 못해 화면이 깨지기 때문", + "answerIndex": 1, + "explanation": "스택 트레이스에는 서버 내부의 파일 경로, 폴더 구조, 사용 중인 라이브러리와 버전이 그대로 담깁니다. 이는 공격자에게 '어떤 취약점을 시도해 볼지' 고르게 해 주는 내부 지도를 무료로 배포하는 셈입니다. 그래서 예상 밖 에러는 사용자에겐 500 + 범용 메시지만 보내고, 스택 같은 진짜 내용은 서버 로그에만 남기는 것이 원칙입니다." + }, + { + "courseSlug": "lv2-error-handling-patterns", + "question": "React 에러 바운더리(Error Boundary)에 대한 설명으로 옳은 것은 무엇일까요?", + "option1": "fetch 요청 실패를 포함해 앱에서 발생하는 모든 에러를 잡아 준다", + "option2": "반드시 앱 전체를 하나로 감싸야 효과가 있다", + "option3": "자식 컴포넌트가 렌더링 중 던진 에러를 받아 폴백 화면을 대신 보여 주지만, 요청(fetch) 실패는 잡지 못한다", + "option4": "에러를 잡으면 자동으로 해당 요청을 재시도한다", + "answerIndex": 2, + "explanation": "에러 바운더리는 자식 컴포넌트가 '렌더링 중' 던진 에러를 받아 화면 전체가 하얗게 비는 대신 폴백 UI를 세우는 안전망입니다. 이벤트 핸들러나 fetch 같은 비동기 코드의 에러는 범위 밖이라, 요청 실패에는 상태 기반의 '다시 시도' 폴백을 따로 만들어야 합니다. 또한 앱 전체가 아니라 글 목록·댓글 같은 영역 단위로 두르는 것이 '전부 죽거나 전부 살거나'를 피하는 설계입니다." + }, + { + "courseSlug": "lv2-practical-testing", + "question": "코드를 고친 뒤 '고치지 않은 곳'이 몰래 깨지는 버그를 무엇이라고 부르나요?", + "option1": "회귀(regression) 버그", + "option2": "컴파일 에러", + "option3": "메모리 누수", + "option4": "경쟁 상태(race condition)", + "answerIndex": 0, + "explanation": "어제까지 멀쩡하던 기능이 다른 코드를 수정한 뒤 다시 깨지는 것을 회귀 버그라고 합니다. 사람은 고친 곳만 확인하기 쉽지만, 테스트는 고치지 않은 곳까지 기계가 매번 전부 다시 확인해 주기 때문에 회귀 버그를 잡는 안전망이 됩니다." + }, + { + "courseSlug": "lv2-practical-testing", + "question": "단위 테스트의 뼈대인 AAA 패턴의 세 단계를 순서대로 올바르게 나열한 것은?", + "option1": "실행(Act) → 준비(Arrange) → 검증(Assert)", + "option2": "준비(Arrange) → 실행(Act) → 검증(Assert)", + "option3": "검증(Assert) → 준비(Arrange) → 실행(Act)", + "option4": "준비(Arrange) → 검증(Assert) → 실행(Act)", + "answerIndex": 1, + "explanation": "먼저 입력을 만들고(준비, Arrange), 대상 함수를 호출하고(실행, Act), 결과가 기대와 같은지 expect로 단언(검증, Assert)합니다. 이 순서가 뒤엉키면 테스트가 읽기 어려워지며, API 테스트든 컴포넌트 테스트든 모든 테스트가 이 뼈대를 공유합니다." + }, + { + "courseSlug": "lv2-practical-testing", + "question": "supertest로 로그인 API를 테스트할 때, '비밀번호가 틀린 경우'에 확인해야 할 것으로 본문이 강조한 조합은?", + "option1": "상태 코드 200과 토큰이 문자열인지", + "option2": "상태 코드 404와 응답 헤더의 캐시 설정", + "option3": "상태 코드 401과 토큰이 응답에 새어 나오지 않았는지", + "option4": "상태 코드 409와 중복 가입 안내 문구", + "answerIndex": 2, + "explanation": "인증 실패는 401로 응답해야 하고, 실패했는데도 토큰이 응답 바디에 포함되면 보안 사고입니다. 그래서 '있어야 할 것'뿐 아니라 토큰이 undefined인지처럼 '없어야 할 것'을 확인하는 단언이 중요하다고 배웠습니다. 409는 중복 가입(Conflict)에 쓰는 코드입니다." + }, + { + "courseSlug": "lv2-practical-testing", + "question": "테스트 하나만 골라 돌리면 실패하는데 전체를 순서대로 돌리면 통과한다면, 가장 가능성이 높은 원인은?", + "option1": "Vitest의 감시 모드가 꺼져 있어서", + "option2": "앞 테스트가 남긴 데이터에 의존하는 순서 의존이 생겨서", + "option3": "커버리지가 100%에 도달하지 못해서", + "option4": "테스트 이름을 '무엇이 어때야 한다' 형식으로 쓰지 않아서", + "answerIndex": 1, + "explanation": "테스트끼리 순서 의존이 생기면 앞 테스트가 만들어 둔 DB 상태에 기대게 되어, 혼자 돌리거나 순서를 바꾸면 결과가 달라집니다. 본문에서 이를 위험 신호로 규정했고, 해결책은 각 테스트 전에 초기화와 시드(beforeEach)로 항상 같은 출발점을 보장해 독립성을 확보하는 것입니다." + }, + { + "courseSlug": "lv2-practical-testing", + "question": "Testing Library의 철학에 가장 잘 맞는 요소 찾기 방법은?", + "option1": "CSS 클래스 이름으로 요소를 선택한다", + "option2": "컴포넌트 내부 state 값을 직접 읽어 확인한다", + "option3": "DOM 트리의 몇 번째 자식인지 인덱스로 접근한다", + "option4": "getByRole로 역할과 보이는 이름, getByLabelText로 라벨을 통해 찾는다", + "answerIndex": 3, + "explanation": "Testing Library의 철학은 '사용자가 쓰는 방식대로 테스트하라'입니다. 사용자는 CSS 클래스나 내부 state를 모르고, '가입하기'라는 버튼과 '이메일'이라는 라벨을 보고 행동합니다. getByRole·getByLabelText로 찾으면 구현이 아닌 동작에 붙는 테스트가 되어 리팩터링에도 잘 깨지지 않습니다." + }, + { + "courseSlug": "lv2-mini-project-deploy", + "question": "MVP(최소 기능 제품)를 자를 때 '이번엔 안 만든다' 목록이 중요한 이유로 이 강좌가 말한 것은?", + "option1": "기능을 줄여야 코드 품질이 자동으로 좋아지기 때문에", + "option2": "안 만드는 기능이 정해져야 '끝나는 날'을 지킬 수 있고, 미완성으로 끝나는 것을 막기 때문에", + "option3": "안 만드는 기능 목록이 있어야 저작권 문제를 피할 수 있기 때문에", + "option4": "포트폴리오에는 기능 개수가 적을수록 유리하기 때문에", + "answerIndex": 1, + "explanation": "MVP를 자르는 것은 기능을 포기하는 게 아니라 마감일을 지키는 행위입니다. [안 함]·[나중] 목록이 없으면 부가 기능에 시간을 쓰다 전부 미완성으로 끝나기 쉽고, 미완성 프로젝트는 포트폴리오가 되지 못한다고 강좌에서 강조했습니다." + }, + { + "courseSlug": "lv2-mini-project-deploy", + "question": "프론트(localhost:5173)에서 백엔드(localhost:8080) API를 호출했더니 CORS 에러가 발생했다. 올바른 해결 방향은?", + "option1": "프론트의 fetch 코드에 특별한 헤더를 추가해서 브라우저를 통과시킨다", + "option2": "브라우저의 보안 설정을 꺼서 차단을 해제한다", + "option3": "서버 쪽에서 프론트의 출처(origin)를 허용 목록에 등록한다", + "option4": "프론트와 백엔드의 포트 번호를 같게 맞춘다", + "answerIndex": 2, + "explanation": "CORS는 버그가 아니라 브라우저의 보안 규칙으로, '다른 출처의 응답은 서버가 명시적으로 허용한 경우에만 JS에 전달한다'는 원칙입니다. 따라서 해결은 항상 서버 쪽에서 허용 출처를 등록하는 것이며(예: Spring의 @CrossOrigin), 프론트 코드를 고치는 것으로는 풀리지 않습니다." + }, + { + "courseSlug": "lv2-mini-project-deploy", + "question": "DB 비밀번호 같은 시크릿을 실수로 git에 커밋했다가 다음 커밋에서 지웠다. 이 강좌가 말하는 올바른 수습은?", + "option1": "지웠으므로 안전하다. 추가 조치는 필요 없다", + "option2": ".gitignore에 .env를 추가하면 과거 커밋의 값도 함께 가려진다", + "option3": "저장소를 비공개로 전환하면 유출이 없던 일이 된다", + "option4": "유출로 간주하고 해당 비밀번호·키를 새 값으로 교체한다", + "answerIndex": 3, + "explanation": "git은 이력을 보존하는 도구라서 다음 커밋에서 지워도 과거 커밋에는 시크릿이 그대로 남습니다. 저장소를 본 적 있는 모든 사람이 그 값을 가졌다고 가정해야 하므로, 유일한 수습은 커밋 삭제가 아니라 비밀번호·키 자체를 새 값으로 교체하는 것입니다." + }, + { + "courseSlug": "lv2-mini-project-deploy", + "question": "npm run dev(개발 모드)와 npm run build(프로덕션 빌드)의 차이에 대한 설명으로 옳은 것은?", + "option1": "프로덕션 빌드는 파일을 압축·최적화하고 에러 메시지를 최소화한, 사용자에게 내보내기 위한 결과물이다", + "option2": "개발 모드가 프로덕션 빌드보다 실행 성능이 더 좋다", + "option3": "프로덕션 빌드는 에러 메시지를 더 길고 친절하게 보여 준다", + "option4": "두 모드는 같은 결과물을 만들며 실행 명령어만 다르다", + "answerIndex": 0, + "explanation": "개발 모드는 '나를 위한 모드'로 소스를 그대로 쓰고 친절한 에러와 즉시 새로고침을 제공하는 대신 무겁습니다. 프로덕션 빌드는 '사용자를 위한 모드'로 압축·난독화와 파일 지문(hash)을 적용하고 내부 노출을 막기 위해 에러 메시지를 최소화합니다. 그래서 배포 전 npm run preview로 빌드 결과물을 확인해야 합니다." + }, + { + "courseSlug": "lv2-mini-project-deploy", + "question": "\"내 컴퓨터에선 됐는데 배포하니 안 돼요\"의 원인 유형으로 이 강좌가 꼽은 것이 아닌 것은?", + "option1": "환경 차이 — 내 PC에만 있는 설정이나 운영 .env(환경변수) 누락", + "option2": "데이터 차이 — 실서버 DB는 비어 있어서 빈 배열에서 터지는 코드", + "option3": "주소·경로 차이 — 하드코딩된 localhost, http/https 혼용, 새로고침 404", + "option4": "언어 차이 — 프론트와 백엔드를 서로 다른 프로그래밍 언어로 만들어서 생기는 충돌", + "answerIndex": 3, + "explanation": "강좌는 원인 유형을 ① 환경 차이(파일·설정·환경변수), ② 데이터 차이(빈 DB·글 0개), ③ 주소·경로 차이(localhost 하드코딩, http/https 혼용, SPA 새로고침 404)로 정리했습니다. 프론트와 백엔드가 다른 언어인 것은 정상적인 구조(React+Spring 등)이지 문제의 원인이 아닙니다. 공통점은 코드가 아니라 코드를 둘러싼 환경이 문제라는 점입니다." + }, + { + "courseSlug": "lv3-layered-architecture", + "question": "레이어드 아키텍처에서 계층을 나누는 가장 근본적인 이유는 무엇인가요?", + "option1": "코드 실행 속도가 계층 수에 비례해 빨라지기 때문에", + "option2": "서로 다른 이유로 바뀌는 코드를 분리해, 변경이 한 계층 안에 갇히도록 하기 위해", + "option3": "파일 수를 늘려 여러 개발자가 커밋 충돌 없이 작업하게 하기 위해", + "option4": "프레임워크(Spring)가 계층 구조 없이는 동작하지 않기 때문에", + "answerIndex": 1, + "explanation": "관심사 분리의 본질은 '서로 다른 이유로 바뀌는 코드는 서로 다른 자리에 둔다'입니다. URL 개편·정책 변경·DB 교체는 각각 바뀌는 이유가 다른데, 한 곳에 섞여 있으면 하나가 바뀔 때마다 전부를 다시 읽고 테스트해야 합니다. 계층은 그 변경을 한 칸 안에 가두는 담장이지, 성능이나 프레임워크 요구사항 때문이 아닙니다." + }, + { + "courseSlug": "lv3-layered-architecture", + "question": "'얇은 컨트롤러' 원칙에 따를 때, 컨트롤러가 해도 되는 일은 무엇인가요?", + "option1": "관심종목이 30개를 넘었는지 한도를 판정하는 것", + "option2": "Repository를 직접 호출해 데이터를 조회하는 것", + "option3": "HTTP 요청을 자바 객체로 번역하고 서비스에 위임하는 것", + "option4": "JPA 엔티티를 그대로 JSON 응답으로 반환하는 것", + "answerIndex": 2, + "explanation": "얇은 컨트롤러는 '번역과 위임' 딱 두 가지만 합니다. 한도 판정 같은 비즈니스 규칙은 서비스의 일이고, Repository 직접 호출은 규칙 계층을 건너뛰는 지름길이며, 엔티티 직접 반환은 DB 내부 사정(숨겨야 할 필드, 지연로딩)을 API로 노출시키므로 모두 금지 목록에 해당합니다." + }, + { + "courseSlug": "lv3-layered-architecture", + "question": "계층 간 의존성 방향이 '바깥(web) → 안쪽(domain)' 한 방향이어야 하는 이유로 옳은 것은?", + "option1": "자주 바뀌는 HTTP 쪽이 천천히 바뀌는 규칙 쪽에 의존해야, 바깥이 바뀌어도 안쪽 규칙이 흔들리지 않기 때문", + "option2": "안쪽 계층의 코드가 바깥 계층보다 먼저 컴파일되어야 하기 때문", + "option3": "web 패키지의 클래스 수가 domain보다 항상 많기 때문", + "option4": "양방향 의존은 스프링에서 컴파일 자체가 불가능하기 때문", + "answerIndex": 0, + "explanation": "핵심은 변경 속도의 차이입니다. URL·필드명 같은 HTTP 스펙은 자주 바뀌고 업무 규칙은 천천히 바뀝니다. 자주 바뀌는 쪽이 천천히 바뀌는 쪽에 의존해야 API가 아무리 출렁여도 규칙 코드는 무사합니다. 반대 방향 의존이 생기면 필드명 하나 바꾸는 데 규칙까지 수술해야 합니다. 컴파일 순서나 클래스 수와는 무관합니다." + }, + { + "courseSlug": "lv3-layered-architecture", + "question": "빈약한 도메인 모델(Anemic Domain Model)의 문제점을 가장 잘 설명한 것은?", + "option1": "엔티티에 메서드가 많아져 클래스가 비대해진다", + "option2": "getter/setter만 있는 데이터 봉지가 되어 규칙이 서비스 여기저기에 흩어지고, setter로 규칙을 우회할 문이 열린다", + "option3": "도메인 객체가 HTTP 타입에 직접 의존하게 된다", + "option4": "record를 쓸 수 없어 DTO 작성이 불가능해진다", + "answerIndex": 1, + "explanation": "빈약한 모델은 엔티티가 getter/setter 덩어리가 되고 규칙이 전부 서비스로 흩어지는 상태입니다. setter가 열려 있으면 '보유 종목 100개 제한' 같은 규칙은 지켜 주길 바라는 부탁일 뿐이고, 같은 검증이 여러 서비스에 복사되는 병이 생깁니다. 행동 메서드 안에 규칙을 두면 우회할 문이 없어 규칙이 한 곳에서 강제됩니다." + }, + { + "courseSlug": "lv3-layered-architecture", + "question": "traderbox가 관심사 분리를 마이크로서비스 수준으로 확장한 방식으로 옳지 않은 것은?", + "option1": "서비스마다 자기 스키마만 사용하는 스키마-퍼-서비스로 데이터를 격리한다", + "option2": "동기 호출은 /internal + 공유키, 비동기·감사는 Kafka, 실시간 팬아웃은 Valkey Pub/Sub로 채널을 나눈다", + "option3": "게이트웨이가 URL 규칙으로 요청을 각 서비스에 라우팅해 서비스 자율성을 지킨다", + "option4": "안정성을 위해 같은 데이터를 Kafka와 Valkey 두 채널에 모두 흘려 이중화한다", + "answerIndex": 3, + "explanation": "채널 규율의 핵심은 오히려 '같은 데이터를 두 채널로 흘리지 않는다'입니다. 용도별로 채널을 하나만 정해야(동기=/internal, 비동기·감사=Kafka, 실시간=Valkey) 데이터 흐름의 관심사가 분리됩니다. 두 채널에 이중으로 흘리면 어느 쪽이 진실인지 모호해지는 숨은 결합이 생깁니다. 나머지 셋은 본문에서 설명한 실제 규칙입니다." + }, + { + "courseSlug": "lv3-design-patterns", + "question": "market 서비스의 시세 프로바이더 분기를 전략(Strategy) 패턴으로 재설계했을 때, 새 프로바이더(예: 일본 주식)를 추가하는 비용으로 가장 정확한 것은?", + "option1": "모든 분기(if/switch)가 있는 메서드를 찾아 else-if를 하나씩 추가한다", + "option2": "@Component가 붙은 구현 클래스 하나만 새로 작성하면 되고, 기존 파일 수정은 0개다", + "option3": "QuoteService의 생성자를 수정해 새 프로바이더를 수동으로 등록해야 한다", + "option4": "인터페이스에 새 시장용 메서드를 추가하고 모든 구현체를 수정해야 한다", + "answerIndex": 1, + "explanation": "전략 패턴 도입 후에는 스프링이 같은 인터페이스(QuoteProvider)의 빈을 List로 자동 주입해 주므로, @Component 클래스 하나만 추가하면 자동으로 합류합니다. 기존 코드를 수정하지 않고 확장할 수 있다는 것은 개방 폐쇄 원칙(OCP)의 실현이며, 기존 코드를 안 건드리므로 기존 기능이 깨질 위험도 사라집니다. Before(if 분기) 방식에서는 분기가 복붙된 곳을 전부 수색해 고쳐야 하고 하나라도 빠뜨리면 런타임 버그가 됩니다." + }, + { + "courseSlug": "lv3-design-patterns", + "question": "같은 클래스 안에서 this.method()로 내부 호출을 하면 @Transactional이 적용되지 않는 이유는 무엇인가?", + "option1": "내부 호출은 프록시(대리인) 객체를 거치지 않고 진짜 객체로 직행하기 때문", + "option2": "@Transactional은 컨트롤러 계층에서만 동작하도록 설계되었기 때문", + "option3": "내부 호출 시 스프링 컨테이너가 새 인스턴스를 생성해 트랜잭션 컨텍스트가 초기화되기 때문", + "option4": "this 키워드가 자바에서 트랜잭션 전파를 차단하는 예약어이기 때문", + "answerIndex": 0, + "explanation": "스프링의 @Transactional은 프록시 패턴으로 구현됩니다. 스프링은 해당 클래스를 감싼 대리인(프록시) 객체를 빈으로 등록하고, 외부 호출이 프록시를 거칠 때 트랜잭션 시작·커밋·롤백을 앞뒤에 끼워 넣습니다. 그런데 같은 클래스 안에서 this로 부르는 내부 호출은 프록시를 우회해 진짜 객체의 메서드로 바로 가므로, 트랜잭션 처리가 끼어들 기회가 없습니다. 프록시 구조를 이해하면 이 함정이 '마법의 버그'가 아니라 당연한 결과임을 알 수 있습니다." + }, + { + "courseSlug": "lv3-design-patterns", + "question": "어댑터·데코레이터·퍼사드는 셋 다 '감싸는' 패턴이라 헷갈리기 쉽다. 각 패턴의 목적을 올바르게 짝지은 것은?", + "option1": "어댑터=기능 추가, 데코레이터=인터페이스 번역, 퍼사드=접근 제어", + "option2": "어댑터=여러 개를 하나로 묶기, 데코레이터=모양 바꾸기, 퍼사드=기능 겹겹이 입히기", + "option3": "어댑터=모양(인터페이스) 번역, 데코레이터=같은 인터페이스로 기능 추가, 퍼사드=복잡한 하위 시스템에 단순한 창구 제공", + "option4": "셋 모두 목적이 동일하며 이름만 다르다", + "answerIndex": 2, + "explanation": "구분 기준은 '왜 감싸는가'입니다. 어댑터는 외부 API처럼 모양이 안 맞는 것을 우리 표준 인터페이스로 번역하기 위해(예: 키움 브리지의 부호 접두어 가격을 Quote로 변환), 데코레이터는 같은 인터페이스를 유지한 채 캐시·로깅 같은 기능을 겹겹이 더하기 위해, 퍼사드는 여러 하위 시스템 호출 절차를 창구 하나 뒤로 숨겨 단순하게 보이기 위해 감쌉니다. 목적이 다르므로 적용할 상황도 다릅니다." + }, + { + "courseSlug": "lv3-design-patterns", + "question": "스프링 환경에서 싱글턴 패턴에 대한 설명으로 옳은 것은?", + "option1": "스프링에서는 getInstance()를 가진 교과서식 싱글턴 클래스를 직접 작성하는 것이 권장된다", + "option2": "@Service·@Component 빈은 기본 스코프가 싱글턴이므로, 직접 싱글턴 코드를 짜지 않아도 인스턴스 공유의 이득을 이미 누리고 있다", + "option3": "스프링 빈은 요청마다 새 인스턴스가 생성되므로 싱글턴이 필요하면 별도 구현이 필수다", + "option4": "직접 만든 정적(static) 싱글턴이 스프링 빈보다 테스트에서 교체하기 쉽다", + "answerIndex": 1, + "explanation": "스프링 빈의 기본 스코프는 싱글턴이라, 컨테이너가 인스턴스 하나를 만들어 필요한 곳에 주입해 줍니다. 즉 JwtService나 MarketBus 같은 @Service/@Component 클래스는 이미 '컨테이너가 관리하는 싱글턴'입니다. 직접 만든 정적 싱글턴은 정적 결합 때문에 테스트에서 가짜(Mock)로 갈아 끼우기 어렵지만, 스프링 빈은 주입 구조 덕에 교체가 쉽습니다. 교훈은 '프레임워크가 이미 준 패턴을 손으로 다시 만들지 말 것'입니다." + }, + { + "courseSlug": "lv3-design-patterns", + "question": "패턴 도입과 리팩터링의 타이밍에 대한 설명으로 이 강좌의 내용과 일치하는 것은?", + "option1": "패턴은 많이 쓸수록 좋은 코드이므로, 케이스가 2개뿐이어도 미리 전략 패턴을 깔아 두는 것이 바람직하다", + "option2": "리팩터링은 구조를 바꾸는 작업이므로 테스트는 구조 변경이 끝난 뒤에 작성하는 것이 효율적이다", + "option3": "구현체가 하나뿐인 인터페이스라도 선배가 만든 구조라면 걷어내지 않고 유지해야 한다", + "option4": "\"언젠가 필요할지도 몰라서\" 미리 추상화를 쌓는 것(YAGNI 위반)이 가장 위험한 신호이며, 패턴 도입 전에 현재 동작을 고정하는 테스트를 먼저 만들어야 한다", + "answerIndex": 3, + "explanation": "패턴은 목적이 아니라 도구이므로, 실제 코드 냄새(같은 분기의 복붙, 수정 공포 등)가 나타났을 때 도입해야 합니다. 아직 오지 않은 요구사항을 위한 추상화는 YAGNI(You Aren't Gonna Need It) 위반으로, 읽기 비용과 헛된 탐색만 늘립니다. 또한 패턴 도입은 '동작은 그대로, 구조만 바꾸기'이므로, 도입 전 테스트로 현재 동작을 고정하고 변경 후 같은 테스트가 통과함을 확인하는 전/후 초록불이 유일한 안전망입니다. 과잉 추상화를 발견하면 걷어내는 것도 리팩터링입니다." + }, + { + "courseSlug": "lv3-db-indexing", + "question": "EXPLAIN과 EXPLAIN ANALYZE의 차이로 옳은 것은?", + "option1": "EXPLAIN ANALYZE는 계획만 보여 주고, EXPLAIN은 실제로 실행한다", + "option2": "EXPLAIN은 플래너의 계획과 추정치만 보여 주고, EXPLAIN ANALYZE는 쿼리를 실제로 실행해 실측 시간과 실제 행 수까지 보여 준다", + "option3": "둘은 완전히 같고 출력 형식만 다르다", + "option4": "EXPLAIN ANALYZE는 SELECT에만 사용할 수 있어 UPDATE에는 붙일 수 없다", + "answerIndex": 1, + "explanation": "EXPLAIN은 실행하지 않고 플래너의 계획(cost·추정 rows)만 보여 주는 반면, ANALYZE를 붙이면 쿼리를 실제로 실행해 actual time·actual rows 같은 실측치를 함께 보여 줍니다. 그래서 성능 진단은 추정과 실측을 나란히 비교하는 것이 기본기이고, 같은 이유로 UPDATE·DELETE에 EXPLAIN ANALYZE를 붙이면 데이터가 정말 바뀌므로 BEGIN → ROLLBACK으로 감싸야 합니다." + }, + { + "courseSlug": "lv3-db-indexing", + "question": "복합 인덱스 (user_id, created_at)이 걸려 있을 때, 이 인덱스를 활용하지 못하는 조건은?", + "option1": "WHERE user_id = 7 AND created_at > '2026-07-01'", + "option2": "WHERE user_id = 7", + "option3": "WHERE created_at > '2026-07-01' (user_id 조건 없이)", + "option4": "WHERE user_id = 7 ORDER BY created_at", + "answerIndex": 2, + "explanation": "복합 인덱스는 전화번호부가 (성, 이름) 순으로 정렬된 것과 같아서, 앞 컬럼(user_id) 없이 뒤 컬럼(created_at)만으로는 정렬 트리를 탈 수 없습니다. 앞 컬럼만 쓰거나 앞+뒤를 함께 쓰는 조건은 인덱스를 활용할 수 있지만, 뒤 컬럼 단독 조건은 이 인덱스를 쓰지 못합니다." + }, + { + "courseSlug": "lv3-db-indexing", + "question": "인덱스가 걸린 컬럼인데도 인덱스를 타지 못하는 쿼리 패턴은?", + "option1": "WHERE email = 'kim@awesomedev.dev'", + "option2": "WHERE title LIKE '보안%' (전방 일치)", + "option3": "WHERE lower(email) = 'kim@awesomedev.dev' (일반 email 인덱스만 있는 경우)", + "option4": "WHERE created_at >= '2026-07-01' (범위 조건)", + "answerIndex": 2, + "explanation": "인덱스는 원본 컬럼값 그대로 정렬돼 있으므로, lower()처럼 컬럼을 함수로 감싸는 순간 그 정렬표를 쓸 수 없게 됩니다. 꼭 필요하면 lower(email)에 대한 함수 기반 인덱스를 따로 만들어야 합니다. 반면 등호 비교, 전방 일치 LIKE('보안%'), 범위 조건은 모두 B-Tree 인덱스가 처리할 수 있는 형태입니다." + }, + { + "courseSlug": "lv3-db-indexing", + "question": "EXPLAIN ANALYZE 결과에서 플래너의 추정 rows와 actual rows가 수십 배 어긋날 때, 가장 먼저 시도할 처방은?", + "option1": "인덱스를 하나 더 추가한다", + "option2": "ANALYZE 테이블명; 으로 통계 정보를 갱신한다", + "option3": "테이블을 삭제하고 다시 만든다", + "option4": "쿼리에 LIMIT을 붙여 행 수를 줄인다", + "answerIndex": 1, + "explanation": "플래너는 테이블의 통계 정보(값 분포 표본)로 행 수를 추정해 계획을 고르는데, 대량 INSERT·DELETE 직후엔 이 통계가 현실과 어긋나 오판이 생깁니다. 추정과 실측이 크게 어긋나는 것은 인덱스 부족이 아니라 통계가 낡았다는 신호이므로, ANALYZE 한 줄로 통계를 갱신하는 것이 가장 싸고 효과적인 첫 처방입니다." + }, + { + "courseSlug": "lv3-db-indexing", + "question": "인덱스 추가가 오히려 서비스를 느리게 만들 수 있는 이유로 옳은 것은?", + "option1": "인덱스가 많을수록 SELECT가 항상 느려지기 때문", + "option2": "인덱스는 읽기 전용이라 쓰기와 무관하기 때문", + "option3": "INSERT/UPDATE/DELETE 때마다 인덱스도 함께 갱신해야 해서 쓰기가 많은 테이블에선 쓰기 처리량이 떨어질 수 있기 때문", + "option4": "인덱스를 만들면 기존 데이터가 모두 삭제되기 때문", + "answerIndex": 2, + "explanation": "인덱스는 공짜가 아닙니다. 인덱스가 5개 걸린 테이블에 INSERT 한 번은 사실상 쓰기 6번(본문 1 + 인덱스 5)이 됩니다. 쓰기가 압도적으로 많은 테이블에 조회용 인덱스를 여럿 얹으면 쓰기 처리량이 주저앉을 수 있어요. 그래서 개선 전후 비교표에 읽기 시간뿐 아니라 쓰기 영향과 인덱스 크기도 함께 기록해야 온전한 측정입니다." + }, + { + "courseSlug": "lv3-transaction-isolation", + "question": "잔고 100만원인 계좌에 80만원짜리 매수 요청 두 개가 동시에 들어와 둘 다 '잔고 충분'으로 판단하고 차감해 잔고가 -60만원이 되었다. 이 사고의 근본 원인은 무엇인가요?", + "option1": "매수 금액 계산 로직에 오버플로 버그가 있어서", + "option2": "'읽고 → 판단하고 → 쓰는' 구간 사이의 빈틈에 다른 트랜잭션이 끼어들었기 때문", + "option3": "커밋된 데이터가 디스크에 저장되지 않아 지속성이 깨졌기 때문", + "option4": "@Transactional이 붙어 있으면 항상 발생하는 정상 동작이라서", + "answerIndex": 1, + "explanation": "코드 자체에는 버그가 없어도, 두 트랜잭션이 각각 '잔고 확인(읽기)'과 '차감(쓰기)' 사이의 시간 틈에 서로 끼어들면 둘 다 옛 잔고를 근거로 판단하게 됩니다. 이런 동시성 사고는 요청을 하나씩 보내는 테스트로는 재현되지 않고, 격리 수준이나 잠금(FOR UPDATE, 낙관적 락) 같은 동시성 제어로 막아야 합니다." + }, + { + "courseSlug": "lv3-transaction-isolation", + "question": "PostgreSQL의 기본 격리 수준과, 그 수준에서 여전히 발생할 수 있는 이상 현상의 조합으로 올바른 것은?", + "option1": "READ UNCOMMITTED — 더티 리드가 발생할 수 있다", + "option2": "READ COMMITTED — 논-리피터블 리드가 발생할 수 있다", + "option3": "REPEATABLE READ — 더티 리드가 발생할 수 있다", + "option4": "SERIALIZABLE — 팬텀 리드가 발생할 수 있다", + "answerIndex": 1, + "explanation": "Postgres의 기본값은 READ COMMITTED입니다. 이 수준은 커밋되지 않은 값을 읽는 더티 리드는 차단하지만, 같은 트랜잭션 안에서 같은 행을 두 번 읽었을 때 값이 달라지는 논-리피터블 리드(그리고 팬텀 리드)는 허용합니다. 더티 리드만 막아도 대부분의 조회에 충분히 안전하면서 동시성 비용이 가장 낮기 때문에 기본값으로 채택된 것입니다." + }, + { + "courseSlug": "lv3-transaction-isolation", + "question": "JPA 엔티티에 @Version 컬럼을 두는 낙관적 락에 대한 설명으로 옳은 것은?", + "option1": "SELECT 시점에 해당 행에 배타락을 걸어 다른 트랜잭션의 접근을 즉시 차단한다", + "option2": "UPDATE 시 WHERE 조건에 읽었을 때의 버전을 포함시켜, 그 사이 누가 먼저 고쳤으면 갱신이 실패하고 예외가 발생한다", + "option3": "충돌이 매우 잦은 데이터(예: 재고 1개짜리 상품)에 가장 적합한 방식이다", + "option4": "테이블 전체를 잠그기 때문에 행 잠금보다 동시성이 높다", + "answerIndex": 1, + "explanation": "낙관적 락은 DB 잠금을 걸지 않고, UPDATE ... WHERE id = ? AND version = ? 형태로 저장 시점에 버전을 검사합니다. 그 사이 다른 트랜잭션이 먼저 수정했다면 버전이 달라져 0건 갱신이 되고 OptimisticLockException이 발생해 재시도하거나 사용자에게 알리게 됩니다. 잠그지 않는 방식이므로 충돌이 드문 데이터에 유리하고, 충돌이 잦다면 미리 잠그는 비관적 락이 더 쌉니다." + }, + { + "courseSlug": "lv3-transaction-isolation", + "question": "트랜잭션 A는 1번 계좌를 잠근 뒤 2번을 기다리고, B는 2번을 잠근 뒤 1번을 기다리는 데드락이 반복된다. 이를 '원천적으로' 예방하는 가장 좋은 방법은?", + "option1": "격리 수준을 READ UNCOMMITTED로 낮춰 잠금을 없앤다", + "option2": "여러 자원을 잠글 때는 항상 같은 순서(예: id 오름차순)로 잠그는 규칙을 팀 전체에 적용한다", + "option3": "데드락이 나면 서버를 재시작하도록 모니터링을 건다", + "option4": "재시도 시 대기 시간 없이 즉시 같은 트랜잭션을 다시 실행한다", + "answerIndex": 1, + "explanation": "데드락은 두 트랜잭션이 서로 엇갈린 순서로 자원을 잡을 때 생기므로, '항상 같은 순서로 잠근다'는 규칙 하나면 서로가 서로를 기다리는 구조 자체가 만들어질 수 없습니다. 타임아웃과 재시도는 어디까지나 보조 안전망이고, 재시도할 때는 같은 충돌이 반복되지 않도록 짧은 무작위 대기를 섞는 것이 좋습니다. 데드락은 한 코드의 버그가 아니라 두 코드 경로의 조합에서 나오므로 규칙으로 막는 것이 맞습니다." + }, + { + "courseSlug": "lv3-transaction-isolation", + "question": "같은 @Service 클래스 안에서 buyAll()이 this.buy()를 호출하는데, buy()에 붙은 @Transactional이 동작하지 않는다. 이유는 무엇인가요?", + "option1": "스프링 트랜잭션은 프록시가 호출을 가로채 적용되는데, this로 부르는 내부 호출은 프록시를 거치지 않기 때문", + "option2": "@Transactional은 반복문 안에서 호출된 메서드에는 적용되지 않도록 설계되었기 때문", + "option3": "buy() 메서드가 public이어서 트랜잭션 프록시가 접근할 수 없기 때문", + "option4": "readOnly 속성을 지정하지 않으면 @Transactional이 무시되기 때문", + "answerIndex": 0, + "explanation": "스프링의 @Transactional은 마법이 아니라 프록시(대리인) 객체가 메서드 호출의 앞뒤에서 BEGIN/COMMIT을 감싸 주는 방식입니다. 외부에서 빈을 통해 들어온 호출만 프록시를 통과하고, this.buy() 같은 self-invocation은 대리인을 건너뛰고 본체를 직접 부르므로 트랜잭션이 적용되지 않습니다. 해법은 트랜잭션이 필요한 메서드를 별도의 빈으로 분리해 주입받아 호출하는 것입니다." + }, + { + "courseSlug": "lv3-query-optimization", + "question": "게시글 20건짜리 목록을 열었더니 SQL 로그에 select가 21번 찍혔습니다. 이 'N+1 문제'가 발생하는 직접적인 원인은 무엇인가요?", + "option1": "DB 인덱스가 없어서 매 행마다 풀스캔이 일어나기 때문", + "option2": "지연 로딩(LAZY)된 연관을 반복문에서 행마다 접근할 때, 그때마다 추가 쿼리가 나가기 때문", + "option3": "JPA가 버그로 같은 쿼리를 중복 실행하기 때문", + "option4": "트랜잭션이 readOnly로 선언되지 않았기 때문", + "answerIndex": 1, + "explanation": "N+1은 버그가 아니라 지연 로딩이라는 정상 동작의 부작용입니다. LAZY 연관은 '실제로 쓸 때' 가져오는데, 목록을 반복문으로 돌며 행마다 연관(예: 작성자)에 접근하면 그 순간마다 select가 1번씩 나가서 목록 1번 + 행 수 N번의 쿼리가 됩니다. 인덱스·readOnly는 별개의 문제입니다." + }, + { + "courseSlug": "lv3-query-optimization", + "question": "컬렉션(OneToMany) 연관을 fetch join하면서 동시에 페이징(Pageable)을 걸면 어떤 일이 벌어지나요?", + "option1": "하이버네이트가 자동으로 batch_size를 적용해 문제없이 동작한다", + "option2": "컴파일 에러가 발생해 애초에 실행되지 않는다", + "option3": "DB에서 limit을 걸지 못해 전체 데이터를 메모리로 가져온 뒤 애플리케이션에서 잘라, 데이터가 크면 메모리 장애로 이어진다", + "option4": "count 쿼리만 두 번 실행될 뿐 성능에는 영향이 없다", + "answerIndex": 2, + "explanation": "컬렉션을 조인하면 부모 행이 자식 수만큼 불어나 DB에서 limit을 정확히 걸 수 없습니다. 그래서 하이버네이트는 'applying in memory!' 경고를 찍고 전체 결과를 메모리로 퍼 올린 뒤 애플리케이션에서 자릅니다. 실행은 되지만(에러 아님) 데이터가 크면 OOM으로 직행하므로, 컬렉션의 페이징은 batch_size로 푸는 것이 정석입니다." + }, + { + "courseSlug": "lv3-query-optimization", + "question": "목록 화면에는 제목·작성자·작성일 3개 컬럼만 표시하는데 API가 느립니다. '필요한 만큼만 가져오기' 관점에서 가장 적절한 처방은?", + "option1": "연관을 전부 FetchType.EAGER로 바꿔 미리 로딩해 둔다", + "option2": "select 절에 화면이 쓰는 컬럼만 명시해 DTO로 바로 받는 프로젝션 조회로 바꾼다", + "option3": "엔티티를 그대로 조회하되 트랜잭션을 없앤다", + "option4": "본문(TEXT) 컬럼에 인덱스를 추가한다", + "answerIndex": 1, + "explanation": "프로젝션(DTO 조회)은 select 절에 필요한 컬럼만 명시하므로 무거운 본문(TEXT)이나 안 쓰는 연관이 아예 실려 오지 않고, 연관을 '로딩'하는 게 아니라 값만 '조회'하므로 N+1도 구조적으로 생기지 않습니다. EAGER는 그 연관을 안 쓰는 화면에서까지 항상 가져오게 만들어 해법이 아니라 함정입니다." + }, + { + "courseSlug": "lv3-query-optimization", + "question": "\"이 게시글에 댓글이 하나라도 있는가?\"만 확인하면 되는 상황입니다. count(*) 대신 EXISTS(또는 existsBy 메서드)를 써야 하는 이유는?", + "option1": "count(*)는 조건에 맞는 행을 끝까지 다 세지만, EXISTS는 첫 행을 찾는 순간 멈추기 때문", + "option2": "count(*)는 트랜잭션 안에서만 실행할 수 있기 때문", + "option3": "EXISTS는 결과를 자동으로 캐시에 저장해 주기 때문", + "option4": "count(*)는 N+1 문제를 반드시 일으키기 때문", + "answerIndex": 0, + "explanation": "존재 여부만 궁금할 때 count(*)를 쓰면 DB는 댓글이 10만 개든 조건에 맞는 행을 전부 세고 나서야 답합니다. 반면 EXISTS는 조건을 만족하는 첫 행을 발견하는 즉시 탐색을 멈추므로 훨씬 저렴합니다. 캐시 저장이나 트랜잭션 제약과는 무관하며, count 자체가 N+1을 일으키는 것도 아닙니다." + }, + { + "courseSlug": "lv3-query-optimization", + "question": "N+1을 fetch join으로 고친 뒤 '쿼리 카운트 회귀 테스트'(assertSelectCount 등)를 남겨 두는 가장 중요한 이유는 무엇인가요?", + "option1": "테스트가 있으면 쿼리 실행 속도 자체가 빨라지기 때문", + "option2": "회귀 테스트가 없으면 fetch join 문법이 컴파일되지 않기 때문", + "option3": "운영 DB의 느린 쿼리 로그를 대체할 수 있기 때문", + "option4": "나중에 누군가 코드를 수정하며 지연 로딩을 건드려 N+1이 재발했을 때, 배포 후 장애가 아니라 CI 단계에서 먼저 잡아내기 위해", + "answerIndex": 3, + "explanation": "N+1은 고쳐도 다시 생깁니다 — 몇 달 뒤 DTO에 필드를 추가하며 무심코 연관 접근을 넣는 식으로요. '이 API는 쿼리 2번을 넘지 않는다'를 테스트로 박아 두면 재발이 운영 장애가 아니라 CI의 빨간불로 먼저 드러납니다. 테스트가 쿼리를 빠르게 해 주는 것은 아니며, DB 측 느린 쿼리 로그와는 역할이 다른 보완 관계입니다." + }, + { + "courseSlug": "lv3-caching-strategy", + "question": "히트율이 낮은 캐시가 '오히려 손해'인 이유로 가장 정확한 것은?", + "option1": "캐시 서버는 히트율이 낮으면 자동으로 꺼지기 때문에", + "option2": "미스가 많으면 요청마다 캐시 확인 + DB 조회 + 캐시 적재라는 추가 단계를 밟아, 캐시 없이 DB만 보던 때보다 일이 늘어나기 때문에", + "option3": "히트율이 낮으면 DB의 데이터가 자동으로 삭제되기 때문에", + "option4": "히트율이 낮으면 TTL이 무한대로 늘어나기 때문에", + "answerIndex": 1, + "explanation": "캐시는 히트일 때만 이득을 봅니다. 미스가 나면 캐시 확인이라는 헛걸음에 더해 DB 조회와 캐시 적재까지 세 단계를 밟으므로, 히트율이 낮으면 요청당 평균 작업량이 캐시 없던 시절보다 늘어납니다. 여기에 무효화 코드의 복잡도와 불일치 위험이라는 유지비까지 더해지므로, 히트율 낮은 캐시는 떼는 것이 나을 수 있습니다." + }, + { + "courseSlug": "lv3-caching-strategy", + "question": "우리 플랫폼의 시세 캐시 market:quote가 로컬(인메모리) 캐시가 아니라 분산 캐시(Valkey)에 사는 핵심 이유는?", + "option1": "로컬 캐시보다 Valkey의 응답 속도가 더 빠르기 때문에", + "option2": "Valkey는 TTL 기능이 없어서 데이터가 영원히 보존되기 때문에", + "option3": "시세는 수집기가 쓰고 여러 API 서버가 읽는 데이터라, 모든 인스턴스가 같은 최신 값을 봐야 하기 때문에", + "option4": "Valkey에 두면 DB 조회가 완전히 사라져 DB를 없앨 수 있기 때문에", + "answerIndex": 2, + "explanation": "속도만 보면 네트워크 왕복이 없는 로컬 캐시(나노초)가 Valkey(약 1ms)보다 빠릅니다. 그런데도 분산 캐시를 쓰는 이유는 공유 때문입니다. 시세는 수집기가 갱신하고 여러 API 서버가 읽는데, 로컬 캐시로는 서버마다 다른 값을 들게 되고 수집기가 각 서버의 메모리에 값을 넣어 줄 방법도 없습니다. 쓰는 쪽과 읽는 쪽이 다르고 읽는 쪽이 여럿이면 분산 캐시가 답입니다." + }, + { + "courseSlug": "lv3-caching-strategy", + "question": "쓰기 시점의 캐시 처리에서 '갱신(SET 새 값)'보다 '삭제(DEL)'가 기본 선택으로 권장되는 이유는?", + "option1": "삭제가 갱신보다 다음 읽기 요청을 항상 더 빠르게 만들기 때문에", + "option2": "거의 동시에 쓰는 두 요청이 엇갈리면 갱신은 낡은 값이 캐시에 남을 수 있지만, 삭제는 그 경합 자체가 없고 값을 만드는 책임이 읽기 경로 하나로 통일되기 때문에", + "option3": "Redis에는 SET 명령이 없고 DEL 명령만 있기 때문에", + "option4": "삭제하면 DB의 원본 데이터도 함께 정리되기 때문에", + "answerIndex": 1, + "explanation": "갱신은 요청 A와 B가 거의 동시에 쓸 때 DB에는 나중 값이 남았는데 캐시에는 늦게 도착한 이전 값이 눌러앉는 경합 위험이 있습니다. 삭제는 캐시를 비우기만 하므로 이 경합이 원천적으로 없고, 값을 다시 계산하는 주체가 읽기 경로 하나로 모여 코드도 단순해집니다. 다음 읽기 한 번이 미스가 되는 건 삭제의 대가이지 장점이 아니므로, 갱신은 계산이 아주 비싸고 쓰기 경합이 없다고 확신할 때만 선택합니다." + }, + { + "courseSlug": "lv3-caching-strategy", + "question": "Cache-Aside(지연 로딩)가 캐싱 패턴의 '기본형'으로 꼽히는 이유가 아닌 것은?", + "option1": "캐시 서버가 통째로 재시작돼도 한동안 전부 미스가 될 뿐, 서비스는 틀리지 않고 계속 돈다", + "option2": "실제로 읽힌 데이터만 캐시에 올라가 메모리가 수요를 따라간다", + "option3": "캐시에만 쓰고 DB에는 나중에 몰아서 반영하므로 쓰기 속도가 매우 빠르다", + "option4": "DB 쓰기 코드가 캐시의 존재를 몰라도 되고, 삭제 한 줄만 얹으면 된다", + "answerIndex": 2, + "explanation": "3번은 Cache-Aside가 아니라 Write-Behind(Write-Back)의 설명입니다. Write-Behind는 쓰기가 빠른 대신 캐시가 죽으면 아직 DB로 옮기지 못한 쓰기가 유실되는 무거운 위험을 집니다. Cache-Aside는 읽기 경로에서 캐시를 확인하고 미스면 DB에서 읽어 채우는 패턴으로, 실패에 강하고(전부 미스여도 동작), 읽힌 것만 캐시하며, 쓰기 코드와의 결합이 약하다는 점이 기본형으로 꼽히는 이유입니다." + }, + { + "courseSlug": "lv3-caching-strategy", + "question": "캐시 스탬피드(인기 키 만료 순간 동시 미스 폭주)에 대한 방어로 본문에서 소개한 방법의 공통 원리는?", + "option1": "캐시 메모리를 늘려서 키가 절대 만료되지 않게 한다", + "option2": "모든 요청을 DB로 직접 보내 캐시 의존을 없앤다", + "option3": "TTL을 전부 0으로 설정해 캐시를 항상 비워 둔다", + "option4": "락으로 한 요청만 DB에 가게 하거나 확률적 조기 만료로 갱신 시점을 흩어, '동시에' 일어나는 일을 없앤다", + "answerIndex": 3, + "explanation": "스탬피드의 본질은 미스와 갱신이 '동시에' 몰리는 것입니다. 락(setnx)은 미스가 나도 한 요청만 DB에 가고 나머지는 잠깐 기다렸다가 채워진 캐시를 읽게 하고, 확률적 조기 만료는 남은 TTL이 짧을수록 높은 확률로 미리 갱신을 자원하게 해 갱신 시점을 자연스럽게 분산시킵니다. TTL 지터도 같은 원리입니다. 세 방어 모두 동시성을 시간 축으로 펼쳐 놓는 설계입니다." + }, + { + "courseSlug": "lv3-performance-profiling", + "question": "응답 시간 지표로 평균 대신 p95·p99 같은 백분위를 쓰는 가장 큰 이유는 무엇인가요?", + "option1": "평균은 계산 비용이 커서 서버에 부담을 주기 때문에", + "option2": "평균은 소수의 아주 느린 요청을 다수의 빠른 요청 속에 숨겨 버리기 때문에", + "option3": "백분위는 에러율까지 한 번에 보여 주기 때문에", + "option4": "평균은 부하 테스트 도구에서 측정할 수 없기 때문에", + "answerIndex": 1, + "explanation": "평균은 대부분 50ms인데 일부만 2,000ms인 경우에도 147ms처럼 '괜찮아 보이는' 값이 나와 느린 꼬리를 가립니다. 사용자가 체감하는 '느리다'는 평균이 아니라 꼬리(p95·p99)에서 오기 때문에, 95%·99%의 요청이 끝나는 시간을 직접 보는 백분위가 실태를 정직하게 보여 줍니다." + }, + { + "courseSlug": "lv3-performance-profiling", + "question": "k6 부하 테스트 시나리오에서 본 측정 전에 웜업(warm-up) 단계를 두는 이유는 무엇인가요?", + "option1": "JIT 컴파일과 캐시가 덥혀지기 전에 재면 실제보다 느린 왜곡된 숫자가 나오기 때문에", + "option2": "웜업 없이 시작하면 k6가 스크립트 오류를 감지하지 못하기 때문에", + "option3": "웜업 구간의 p95가 곧 최종 측정 결과로 쓰이기 때문에", + "option4": "가상 사용자(VU)를 만들려면 최소 30초의 준비 시간이 필수이기 때문에", + "answerIndex": 0, + "explanation": "JVM은 JIT 컴파일러가 자주 쓰는 코드를 기계어로 최적화한 뒤에야 제 속도가 나고, 캐시도 처음엔 비어 있습니다. 웜업 없이 곧바로 재면 이 '덥혀지기 전' 상태가 섞여 실제 운영 성능보다 나쁜 숫자가 나오므로, 가벼운 웜업 후 안정 상태(지속 구간)에서 측정해야 합니다." + }, + { + "courseSlug": "lv3-performance-profiling", + "question": "커넥션 풀이나 스레드 풀의 포화가 CPU 프로파일(불꽃 그래프)에 잘 잡히지 않는 이유는 무엇인가요?", + "option1": "풀 대기는 커널 영역에서 일어나 JVM 프로파일러가 접근할 수 없기 때문에", + "option2": "async-profiler가 스레드 정보를 수집하지 않기 때문에", + "option3": "기다리는 스레드는 CPU를 쓰지 않아 샘플링에 조용하게 나타나기 때문에", + "option4": "풀 포화는 항상 에러를 발생시켜 프로파일링 전에 서버가 먼저 죽기 때문에", + "answerIndex": 2, + "explanation": "커넥션이 나올 때까지 줄 서 있는 스레드는 CPU 연산을 하지 않으므로 CPU 샘플링 기반의 불꽃 그래프에서는 넓게 나타나지 않습니다. 그래서 코드는 멀쩡한데 p95만 치솟는 이 유형은 풀의 활성 커넥션 수·대기 시간 같은 지표를 따로 봐야 발견할 수 있습니다." + }, + { + "courseSlug": "lv3-performance-profiling", + "question": "GC 로그에서 'Pause Full ... 1843.7ms' 같은 긴 Full GC가 자주 보일 때, 이것이 서비스 성능에 미치는 영향으로 옳은 것은?", + "option1": "백그라운드에서만 돌기 때문에 요청 처리에는 영향이 없다", + "option2": "stop-the-world로 모든 애플리케이션 스레드가 멈춰, 그 순간의 요청들이 통째로 지연된다", + "option3": "메모리가 정리되므로 오히려 그 직후 p95가 항상 좋아진다", + "option4": "CPU 사용률만 올라가고 응답 시간에는 변화가 없다", + "answerIndex": 1, + "explanation": "Full GC의 stop-the-world 구간에서는 수거가 끝날 때까지 모든 애플리케이션 스레드가 정지하므로, 그 시간 동안 들어온 요청 전부가 그만큼 지연됩니다. 'p99만 이상하게 튀는' 현상의 단골 원인이며, 잦고 길다면 힙 크기·과도한 객체 할당·메모리 누수를 순서대로 의심해야 합니다." + }, + { + "courseSlug": "lv3-performance-profiling", + "question": "Micrometer의 세 계측기 중 '현재 버퍼에 쌓인 항목 수'처럼 오르내리는 순간값을 보고 싶을 때 알맞은 것은?", + "option1": "게이지(Gauge)", + "option2": "카운터(Counter)", + "option3": "타이머(Timer)", + "option4": "히스토그램 전용 카운터", + "answerIndex": 0, + "explanation": "게이지는 '지금 이 순간'의 값을 들여다보는 창으로, 버퍼 크기나 활성 연결 수처럼 오르내리는 값에 씁니다. 카운터는 총 에러 수처럼 늘어나기만 하는 누적값, 타이머는 횟수에 더해 소요 시간 분포(p95·p99)까지 기록하는 용도입니다 — '몇 번?'은 카운터, '얼마나 걸렸나?'는 타이머, '지금 얼마나?'는 게이지." + }, + { + "courseSlug": "lv3-web-security", + "question": "PreparedStatement(파라미터 바인딩)가 SQL 인젝션을 막는 근본 원리는 무엇인가요?", + "option1": "사용자 입력에서 따옴표 같은 위험한 문자를 자동으로 삭제하기 때문", + "option2": "쿼리의 구조를 먼저 확정하고 입력은 값 자리에만 끼워 넣어, 입력이 문법(코드)을 바꿀 수 없기 때문", + "option3": "쿼리를 암호화해서 전송하므로 공격자가 내용을 볼 수 없기 때문", + "option4": "DB 계정의 권한을 자동으로 최소화해 주기 때문", + "answerIndex": 1, + "explanation": "인젝션의 본질은 입력이 데이터가 아니라 코드로 해석되는 것입니다. PreparedStatement는 쿼리의 구조(명령)를 먼저 확정한 뒤 입력을 값 자리에만 바인딩하므로, 어떤 교묘한 입력도 문법을 바꿀 수 없습니다. 위험 문자를 지우는 블랙리스트 필터링은 우회 기법이 많아 방어가 되지 못합니다." + }, + { + "courseSlug": "lv3-web-security", + "question": "CSRF 공격이 성립하는 핵심 조건은 무엇인가요?", + "option1": "서버가 HTTPS를 사용하지 않아 요청이 도청될 수 있다", + "option2": "프론트엔드가 사용자 입력을 이스케이프하지 않고 화면에 그린다", + "option3": "브라우저가 대상 사이트로 가는 요청에 쿠키를 자동으로 붙여 보내므로, 다른 사이트발 위조 요청도 로그인 사용자의 요청처럼 보인다", + "option4": "비밀번호가 평문으로 데이터베이스에 저장되어 있다", + "answerIndex": 2, + "explanation": "CSRF의 범인은 '쿠키 자동 전송'입니다. 로그인한 사용자가 공격자의 페이지를 열면, 그 페이지가 우리 서버로 보내는 요청에도 세션 쿠키가 자동으로 실려 가서 서버는 정상 요청과 구분하지 못합니다. 그래서 SameSite 쿠키, CSRF 토큰, Origin 검증을 겹쳐 방어합니다. 도청(HTTPS)이나 이스케이프(XSS), 평문 저장(암호화 실패)은 다른 유형의 문제입니다." + }, + { + "courseSlug": "lv3-web-security", + "question": "저장형(Stored) XSS가 세 종류 중 가장 파괴적이라고 평가되는 이유는 무엇인가요?", + "option1": "공격 스크립트가 서버(DB)에 저장되어, 해당 콘텐츠를 보는 모든 사용자의 브라우저에서 실행되기 때문", + "option2": "서버를 거치지 않고 프론트엔드 자바스크립트 안에서만 실행되기 때문", + "option3": "피해자가 조작된 링크를 클릭해야만 성립하기 때문", + "option4": "SQL 데이터베이스의 관리자 권한을 즉시 탈취하기 때문", + "answerIndex": 0, + "explanation": "저장형 XSS는 게시글·채팅·닉네임 같은 곳에 공격 스크립트가 DB에 저장되어, 그 콘텐츠를 열람하는 모든 사용자에게 반복 실행됩니다. 링크 클릭이 필요한 것은 반사형, 서버와 무관하게 프론트 JS가 스스로 실행시키는 것은 DOM 기반 XSS의 특징입니다. XSS는 브라우저에서 실행되는 공격이지 DB 관리자 권한 탈취가 아닙니다." + }, + { + "courseSlug": "lv3-web-security", + "question": "IDOR(Insecure Direct Object Reference) 취약점의 올바른 방어 방법은 무엇인가요?", + "option1": "프론트엔드에서 남의 자원으로 가는 버튼과 링크를 화면에서 숨긴다", + "option2": "자원 ID를 추측하기 어려운 긴 난수로만 바꾼다", + "option3": "로그인(인증)만 통과하면 모든 자원 조회를 허용한다", + "option4": "서버가 모든 요청마다 '이 요청자가 이 자원의 소유자(권한자)인가'를 조회 조건에 포함해 검증한다", + "answerIndex": 3, + "explanation": "IDOR는 인증은 통과했지만 인가(소유권 검증)를 빠뜨린 접근 통제 결함입니다. 공격자는 화면이 아니라 API를 직접 두드리므로 프론트에서 숨기는 것은 UX일 뿐 보안이 아니며, 인증만으로는 남의 자원 접근(수평 권한 상승)을 막지 못합니다. findByIdAndUserId처럼 서버측에서 매 요청 소유권을 함께 확인하는 것이 정답입니다." + }, + { + "courseSlug": "lv3-web-security", + "question": "심층 방어(defense in depth) 관점에서 CSP(Content-Security-Policy) 헤더의 역할로 가장 알맞은 것은?", + "option1": "출력 인코딩을 대체하므로 CSP만 설정하면 이스케이프는 필요 없다", + "option2": "출력 인코딩을 실수로 빠뜨린 곳이 있어도, 허용되지 않은 출처의 스크립트 실행을 막아 주는 마지막 방어선 역할을 한다", + "option3": "SQL 인젝션을 데이터베이스 계층에서 차단한다", + "option4": "쿠키가 다른 사이트로 자동 전송되는 것을 막아 CSRF를 방어한다", + "answerIndex": 1, + "explanation": "심층 방어는 한 층의 실수가 전체 붕괴로 이어지지 않게 방어층을 겹치는 것입니다. XSS 방어의 1층은 프레임워크 자동 이스케이프 같은 출력 인코딩이고, CSP는 인코딩이 뚫렸을 때도 허용 안 된 출처의 스크립트 실행을 차단하는 마지막 방어선입니다. 대체재가 아니라 보완층이며, SQL 인젝션은 파라미터 바인딩, 쿠키 자동 전송 차단은 SameSite 속성의 역할입니다." + }, + { + "courseSlug": "lv3-auth-architecture", + "question": "HTTP 상태 코드 401과 403의 차이를 가장 정확히 설명한 것은?", + "option1": "401은 서버 오류, 403은 클라이언트 오류다", + "option2": "401은 인증 실패(누구인지 확인 불가), 403은 인가 실패(신원은 알지만 권한 없음)다", + "option3": "401은 토큰 만료, 403은 토큰 위조를 뜻한다", + "option4": "둘은 같은 의미이며 서버 구현에 따라 골라 쓴다", + "answerIndex": 1, + "explanation": "인증(Authentication)은 '너 누구야?'라는 신원 확인이고, 인가(Authorization)는 '너 이거 해도 돼?'라는 권한 판단입니다. 401 Unauthorized는 신원 확인에 실패했을 때, 403 Forbidden은 신원은 확인됐지만 권한이 없을 때 반환됩니다. 누구인지 모르면 권한을 따질 수 없으므로 항상 인증이 인가보다 먼저입니다." + }, + { + "courseSlug": "lv3-auth-architecture", + "question": "JWT가 세션 방식과 비교해 갖는 대표적인 '대가'는 무엇인가?", + "option1": "서버를 여러 대로 늘리면 검증이 불가능해진다", + "option2": "매 요청마다 세션 저장소를 조회해야 해서 느리다", + "option3": "한번 발급한 토큰을 만료 전에 서버가 무효화할 방법이 없다", + "option4": "페이로드가 암호화되어 있어 서버도 내용을 읽을 수 없다", + "answerIndex": 2, + "explanation": "JWT는 서버가 아무것도 기억하지 않는 무상태 방식이라, 저장소 조회 없이 서명 검증만으로 처리되고 서버 확장이 쉽습니다. 하지만 바로 그 '기억하지 않음' 때문에 발급된 토큰을 만료 전에 취소할 수 없습니다(유출돼도 만료까지 유효). 그래서 수명을 짧게 잡고 리프레시 토큰으로 보완합니다. 참고로 페이로드는 암호화가 아니라 Base64 인코딩이라 누구나 읽을 수 있습니다." + }, + { + "courseSlug": "lv3-auth-architecture", + "question": "리프레시 토큰 회전(rotation)에서, 이미 폐기된 옛 리프레시 토큰이 다시 들어오는 상황이 의미하는 것은?", + "option1": "정상적인 재로그인 절차이므로 새 토큰을 발급하면 된다", + "option2": "액세스 토큰이 만료되었다는 신호다", + "option3": "토큰이 도난당했을 가능성이 높다는 신호이므로 해당 사용자의 토큰을 끊어야 한다", + "option4": "Valkey 캐시가 만료되어 재저장이 필요하다는 뜻이다", + "answerIndex": 2, + "explanation": "회전 방식에서는 리프레시할 때마다 새 리프레시 토큰을 발급하고 옛것을 즉시 폐기합니다. 따라서 폐기된 토큰이 다시 사용된다는 것은 정상 사용자와 공격자 중 한쪽이 이미 새 토큰을 받아갔는데 다른 쪽이 옛 토큰을 들고 온 상황, 즉 토큰 도난의 강력한 신호입니다. 이때 해당 사용자의 토큰을 전부 무효화해 피해를 차단할 수 있습니다." + }, + { + "courseSlug": "lv3-auth-architecture", + "question": "토큰 저장 위치로서 localStorage와 httpOnly 쿠키의 트레이드오프를 옳게 설명한 것은?", + "option1": "localStorage는 XSS에 뚫리면 토큰이 통째로 유출되고, httpOnly 쿠키는 스크립트가 토큰을 읽을 수 없는 대신 CSRF 방어(SameSite 등)가 필요하다", + "option2": "httpOnly 쿠키는 XSS와 CSRF를 모두 완벽하게 막아 주므로 추가 방어가 필요 없다", + "option3": "localStorage는 브라우저가 요청마다 자동으로 전송해 주므로 CSRF에 취약하다", + "option4": "httpOnly 쿠키에 저장하면 XSS 공격 자체가 불가능해진다", + "answerIndex": 0, + "explanation": "localStorage는 JS로 읽을 수 있어 XSS 스크립트 한 줄에 토큰이 통째로 유출되지만, 자동 전송이 없어 CSRF에는 해당되지 않습니다. httpOnly 쿠키는 스크립트에서 읽을 수 없어 XSS로도 토큰 자체는 훔칠 수 없지만, 브라우저가 자동 첨부하는 성질 때문에 CSRF 위험이 있어 SameSite 속성과 CSRF 토큰으로 막아야 합니다. 또한 httpOnly로 옮겨도 XSS 자체가 무해해지는 것은 아니므로(토큰을 못 읽을 뿐 요청 위조는 가능) 방어는 겹겹이 필요합니다." + }, + { + "courseSlug": "lv3-auth-architecture", + "question": "OAuth2 Authorization Code 흐름에서 브라우저 리다이렉트로는 1회용 '인가 코드'만 전달하고, 실제 토큰은 서버 간 통신으로 교환하는 이유는?", + "option1": "브라우저는 토큰처럼 긴 문자열을 URL에 담을 수 없기 때문이다", + "option2": "브라우저 리다이렉트는 주소창·히스토리 등에 노출될 위험이 있는 경로라 가치가 낮은 1회용 코드만 태우고, 진짜 토큰은 client_secret으로 신원을 증명하는 서버 간 통신으로만 주고받기 위해서다", + "option3": "인가 코드가 곧 액세스 토큰이므로 교환 절차는 형식적인 것이다", + "option4": "사용자 비밀번호를 우리 서버에 전달하기 위한 중간 단계가 필요하기 때문이다", + "answerIndex": 1, + "explanation": "브라우저를 거치는 리다이렉트(앞문)는 주소창과 히스토리에 노출될 수 있는 경로입니다. 그래서 노출돼도 피해가 작은 1회용 단명 코드만 태우고, 진짜 액세스 토큰은 우리 서버와 인가 서버가 client_secret으로 서로를 확인하는 뒷문(서버 간 통신)으로만 교환합니다. 이 흐름에서 사용자 비밀번호는 구글(인가 서버)에만 입력되며 우리 앱은 전혀 볼 수 없습니다." + }, + { + "courseSlug": "lv3-observability", + "question": "장애 대응에서 관측 가능성 3기둥의 역할 분담으로 가장 올바른 설명은?", + "option1": "로그가 '무엇이 느린가'를, 메트릭이 '왜 느린가'를 답한다", + "option2": "메트릭이 '무엇이 이상한가'를 알리고, 트레이스로 구간을 좁힌 뒤, 로그로 원인을 확정한다", + "option3": "트레이스만 있으면 로그와 메트릭은 필요 없다", + "option4": "세 기둥은 같은 데이터를 형식만 바꿔 저장한 것이므로 하나만 쌓으면 된다", + "answerIndex": 1, + "explanation": "세 기둥은 성격이 다른 데이터로 역할을 분담합니다. 메트릭은 숫자의 시계열로 '무엇이, 얼마나 이상한가'를 싸고 빠르게 알려 주고, 트레이스는 요청 하나의 여정에서 느린 구간을 짚어 주며, 로그는 그 순간의 맥락으로 '왜'를 확정합니다. 알림(메트릭) → 구간 좁히기(트레이스) → 원인 확정(로그)이 장애 대응의 기본 동선입니다." + }, + { + "courseSlug": "lv3-observability", + "question": "메트릭 라벨(tag)에 사용자 ID처럼 무한히 늘어나는 값을 쓰면 안 되는 이유는?", + "option1": "사용자 ID는 개인정보라서 메트릭 시스템이 법적으로 저장을 거부하기 때문", + "option2": "라벨 값의 종류마다 별도의 시계열이 생겨, 시계열 수가 폭발하며 Prometheus 메모리를 고갈시키기 때문", + "option3": "Micrometer가 문자열 라벨을 아예 지원하지 않기 때문", + "option4": "라벨이 많으면 Grafana 대시보드의 색상이 부족해지기 때문", + "answerIndex": 1, + "explanation": "메트릭의 라벨은 값의 종류(카디널리티)마다 별도의 시계열을 만듭니다. 라우트나 상태코드처럼 유한한 '분류'는 괜찮지만, 사용자 ID처럼 무한히 늘어나는 '개체'를 라벨로 쓰면 사용자 수만큼 시계열이 생겨 저장소가 터집니다(카디널리티 폭발). 개체 단위 추적은 메트릭이 아니라 로그와 트레이스의 몫입니다." + }, + { + "courseSlug": "lv3-observability", + "question": "지연 시간 지표로 평균 대신 p99를 보라고 하는 이유로 가장 적절한 것은?", + "option1": "p99는 평균보다 계산이 빨라 서버 부하가 적기 때문", + "option2": "평균은 항상 p99보다 큰 값이 나와 과잉 경보를 일으키기 때문", + "option3": "대부분 요청이 빠르면 소수의 매우 느린 요청이 평균에 묻히는데, p99는 가장 불행한 사용자층의 체감을 드러내기 때문", + "option4": "p99는 에러율까지 함께 포함해서 보여 주는 지표이기 때문", + "answerIndex": 2, + "explanation": "요청 100개 중 99개가 10ms이고 1개가 5초여도 평균은 약 60ms로 멀쩡해 보입니다. 그러나 그 1%의 사용자는 실제로 5초를 기다렸죠. p99는 분포의 꼬리, 즉 '가장 불행한 1%가 겪는 경험'을 숫자로 드러내기 때문에 사용자 체감을 대변합니다. 사용자 체감은 평균이 아니라 꼬리에 삽니다." + }, + { + "courseSlug": "lv3-observability", + "question": "새벽에 온콜을 깨우는 알림 설계 원칙으로 본문의 설명과 일치하는 것은?", + "option1": "CPU 사용률 같은 내부 원인 지표가 높으면 사용자 영향과 무관하게 즉시 깨운다", + "option2": "임계치를 넘는 순간마다 바로 알림을 보내야 대응이 빨라지므로 지속시간 조건은 두지 않는다", + "option3": "에러율·p99 초과 같은 증상(사용자 영향) 기반으로 깨우고, 임계치에 지속시간 조건을 더해 순간 스파이크 노이즈를 거른다", + "option4": "장애를 놓치지 않도록 관련 서비스 전부가 각자 알림을 울리게 한다", + "answerIndex": 2, + "explanation": "사람을 깨울 알림은 사용자가 실제로 겪는 증상(에러율 상승, p99 초과) 기반이어야 하고, CPU 같은 원인 지표는 참고용으로 돌립니다. 또 'p99가 1.5초를 10분간 계속 넘을 때'처럼 지속시간(for) 조건을 곱하면 스스로 지나가는 순간 스파이크가 걸러집니다. 서비스 여러 개가 중복으로 우는 것은 원인 알림을 묻어 버리는 안티패턴입니다." + }, + { + "courseSlug": "lv3-observability", + "question": "SLO가 '30일 창에서 99.9%'일 때, 에러 버짓에 대한 설명으로 올바른 것은?", + "option1": "에러 버짓은 100% - SLO로, 30일 중 약 43분의 장애 허용량이며 버짓이 바닥나면 신규 배포를 늦추고 안정화에 투자하는 의사결정 도구다", + "option2": "에러 버짓은 서버 증설에 쓸 수 있는 예산 금액을 뜻한다", + "option3": "에러 버짓이 남아 있으면 일부러 장애를 내서 소진해야 한다", + "option4": "가용성 목표는 항상 100%로 잡는 것이 이상적이며 에러 버짓은 임시방편이다", + "answerIndex": 0, + "explanation": "에러 버짓은 100% - SLO, 즉 '써도 되는 실패량'입니다. 99.9%면 30일 중 약 43분이죠. 진짜 쓸모는 의사결정에 있습니다 — 버짓이 넉넉하면 배포·실험에, 바닥이면 안정화에 투자합니다. 100%를 목표로 하지 않는 이유는 9 하나를 더할 때마다 비용이 약 10배로 뛰는데 사용자는 그 차이를 대부분 느끼지 못하기 때문입니다." + }, + { + "courseSlug": "lv3-zero-downtime-deploy", + "question": "배포 직후 JVM 프로세스는 떴지만 DB 커넥션 풀이 아직 채워지지 않은 서비스가 있다. 헬스체크 관점에서 올바른 상태와 처방은?", + "option1": "liveness DOWN — 컨테이너를 즉시 재시작해야 한다", + "option2": "liveness UP, readiness DOWN — 트래픽을 보내지 말고 준비될 때까지 기다린다", + "option3": "liveness UP, readiness UP — HTTP 서버가 떴으므로 트래픽을 보내도 된다", + "option4": "readiness DOWN — 즉시 이전 버전으로 롤백해야 한다", + "answerIndex": 1, + "explanation": "liveness는 '살아 있나', readiness는 '손님 받을 준비가 됐나'라는 서로 다른 질문입니다. 프로세스는 살아 있으므로 재시작할 일이 아니고(재시작하면 영원히 준비를 못 마칠 수 있음), 준비가 안 됐으므로 트래픽만 주지 않고 기다리는 것이 맞습니다. 진단을 둘로 나누는 이유는 처방이 다르기 때문입니다." + }, + { + "courseSlug": "lv3-zero-downtime-deploy", + "question": "users.name 컬럼을 users.display_name으로 바꾸려 한다. 무중단 배포 원칙에 맞는 진행 방식은?", + "option1": "배포와 동시에 RENAME COLUMN을 실행해 한 번에 교체한다", + "option2": "신버전 배포 직후 곧바로 옛 컬럼을 DROP해 스키마를 깔끔히 정리한다", + "option3": "새 컬럼을 추가만 하고(확장), 새 코드가 양쪽에 쓰며 데이터를 옮긴 뒤(이행), 모두 전환된 다음 릴리스에서 옛 컬럼을 지운다(수축)", + "option4": "ddl-auto: update가 컬럼 이름 변경을 자동으로 처리하므로 별도 작업이 필요 없다", + "answerIndex": 2, + "explanation": "어떤 배포 전략이든 구버전과 신버전 코드가 동시에 살아 있는 구간이 생기므로, DB는 그동안 양쪽 모두와 호환돼야 합니다. 확장→이행→수축 3단계를 여러 릴리스로 나누면 각 단계 사이에서 언제든 멈추거나 되돌릴 수 있습니다. 한 방에 RENAME하면 구버전 코드가 즉사하고, ddl-auto: update는 컬럼 이름 변경을 아예 처리하지 못합니다." + }, + { + "courseSlug": "lv3-zero-downtime-deploy", + "question": "블루-그린 배포가 롤링 배포보다 '되돌림(롤백)'에서 유리한 핵심 이유는?", + "option1": "구버전(blue)이 그대로 살아 있어 앞단 업스트림 설정만 되돌리면 재빌드·재기동 없이 즉시 복귀되기 때문", + "option2": "환경을 2벌 유지해 서버 비용이 절반으로 줄어들기 때문", + "option3": "구버전과 신버전이 동시에 살아 있는 구간이 아예 생기지 않기 때문", + "option4": "트래픽을 5%씩 점진적으로 늘리며 지표를 관찰할 수 있기 때문", + "answerIndex": 0, + "explanation": "블루-그린은 운영과 똑같은 환경을 한 벌 더 두고 트래픽 스위치만 넘기는 방식이라, 문제가 생기면 스위치를 되돌리는 것만으로 롤백이 끝납니다. 비용은 오히려 2배로 늘고(2번 오답), 전환 검증 구간 동안 두 버전이 공존하며(3번 오답), 트래픽을 점진적으로 늘리는 것은 카나리아 전략의 특징입니다(4번 오답)." + }, + { + "courseSlug": "lv3-zero-downtime-deploy", + "question": "JPA의 ddl-auto: update를 운영에서 계속 쓰는 것이 무중단·롤백 관점에서 위험한 이유로 옳은 것은?", + "option1": "앱 시작 속도가 느려져 readiness 통과가 늦어지기 때문", + "option2": "스키마 변경 내용을 배포 전에 리뷰할 수 없고, 되돌릴 스크립트도 존재하지 않기 때문", + "option3": "Flyway와 함께 쓰면 마이그레이션이 두 번 실행되기 때문", + "option4": "컬럼 추가가 불가능해 확장(expand) 단계를 수행할 수 없기 때문", + "answerIndex": 1, + "explanation": "ddl-auto: update는 앱이 뜰 때 스키마를 알아서 바꾸므로 무엇이 바뀔지 배포 전에 알 수 없어 리뷰가 불가능하고, 롤백 스크립트라는 개념 자체가 없습니다. 또한 컬럼 삭제·이름 변경은 못 하고 추가만 흉내냅니다(즉 컬럼 추가는 가능하므로 4번은 오답). Flyway 도입 시에는 ddl-auto를 validate로 바꿔 앱의 스키마 변경 권한을 회수하는 것이 정석입니다." + }, + { + "courseSlug": "lv3-zero-downtime-deploy", + "question": "롤백 트리거 조건을 문서에 적을 때 가장 바람직한 형태는?", + "option1": "\"문제가 심각하다고 판단되면 담당자 협의 후 롤백한다\"", + "option2": "\"사용자 신고가 여러 건 들어오면 롤백을 검토한다\"", + "option3": "\"팀장이 승인하면 롤백한다\"", + "option4": "\"5xx 비율이 1%를 10분 넘게 유지하면 롤백한다\"처럼 숫자와 시간이 있는 조건으로 적는다", + "answerIndex": 3, + "explanation": "\"심각하면 롤백\" 같은 문장은 새벽 3시의 배포 담당자가 심각함을 판정하느라 시간을 태우게 만듭니다. 숫자(임계값)와 시간(지속 조건)이 명시된 조건이어야 현장에서는 판단이 아니라 실행만 남습니다. 조건은 판단력이 흐린 배포 순간이 아니라 '대낮에' 미리 문서로 정해 두는 것이 핵심이며, 원인 분석은 되돌린 다음에 합니다." + }, + { + "courseSlug": "lv3-code-review-refactoring", + "question": "리팩터링의 올바른 정의는 무엇인가요?", + "option1": "버그를 고치면서 동시에 코드도 정리하는 작업", + "option2": "겉으로 보이는 동작을 바꾸지 않으면서 코드의 내부 구조를 개선하는 것", + "option3": "낡은 코드를 삭제하고 처음부터 새로 작성하는 것", + "option4": "성능을 높이기 위해 알고리즘을 더 빠른 것으로 교체하는 것", + "answerIndex": 1, + "explanation": "리팩터링은 입력이 같으면 출력도 같도록, 즉 사용자가 아무 변화도 느끼지 못하게 유지하면서 내부 구조만 개선하는 작업입니다. 버그 수정은 동작이 바뀌므로 리팩터링이 아니고(기능 개발 모자와 리팩터링 모자를 한 커밋에서 섞지 않는 규율), 전면 재작성은 리팩터링과 정반대의 접근입니다." + }, + { + "courseSlug": "lv3-code-review-refactoring", + "question": "테스트가 없는 코드를 리팩터링해야 할 때 가장 먼저 해야 할 일은 무엇인가요?", + "option1": "현재 동작을 그대로 고정하는 테스트부터 작성한다", + "option2": "IDE의 자동 리팩터링 기능을 믿고 바로 구조를 바꾼다", + "option3": "코드가 검증되지 않았으므로 전면 재작성으로 전환한다", + "option4": "리팩터링과 함께 테스트를 나중에 한 커밋으로 묶어 올린다", + "answerIndex": 0, + "explanation": "\"동작이 바뀌지 않았다\"는 사람 눈이 아니라 테스트가 보장합니다. 그래서 안전망(현재 동작을 고정하는 테스트)을 먼저 만들고, 초록불 → 작은 변경 → 초록불 확인의 짧은 보폭을 반복하는 것이 리팩터링의 순서입니다. IDE 자동 리팩터링이 참조를 잘 따라가 주긴 하지만 테스트라는 안전망을 대체하지는 못합니다." + }, + { + "courseSlug": "lv3-code-review-refactoring", + "question": "리팩터링 우선순위를 정할 때 가장 먼저 정리해야 할 코드는 어디인가요?", + "option1": "거의 수정되지 않지만 가장 오래된 레거시 코드", + "option2": "코드 냄새가 가장 많이 발견된 파일 전부", + "option3": "자주 변경되면서 코드 상태가 지저분한 파일", + "option4": "신입이 처음 읽게 될 진입점 코드", + "answerIndex": 2, + "explanation": "기술 부채의 이자는 그 코드를 '만질 때마다' 나갑니다. 아무도 열지 않는 지저분한 코드는 이자가 0원이므로 급하지 않고, 변경 빈도가 높은데 지저분한 곳(뜨거운 길목)부터 갚아야 투자 대비 효과가 가장 큽니다. 변경 빈도는 감이 아니라 git log 같은 기록으로 확인합니다." + }, + { + "courseSlug": "lv3-code-review-refactoring", + "question": "코드 리뷰의 목적에 대한 설명으로 본문과 일치하는 것은 무엇인가요?", + "option1": "리뷰의 목적은 결함 발견 하나이며, 나머지는 부수 효과다", + "option2": "리뷰는 작성자의 실력을 평가해 팀 내 등급을 정하는 절차다", + "option3": "리뷰는 시니어가 주니어의 코드를 승인해 주는 일방향 관문이다", + "option4": "결함 발견 외에 지식 공유와 팀 표준 정착도 리뷰의 핵심 효용이다", + "answerIndex": 3, + "explanation": "리뷰의 효용은 결함 발견, 지식 공유, 표준 정착 세 가지입니다. 리뷰를 거친 코드는 최소 두 사람이 이해하는 코드가 되어 담당자 부재에도 팀이 멈추지 않고, 네이밍·계층 규칙 같은 팀 합의가 코멘트를 통해 전파됩니다. 또한 '사람이 아니라 코드를 리뷰한다'가 제1원칙이므로 실력 평가라는 해석은 리뷰 문화를 방어전으로 만드는 잘못된 관점입니다." + }, + { + "courseSlug": "lv3-code-review-refactoring", + "question": "리뷰 코멘트에서 '블로킹'과 '제안'을 구분하는 기준으로 옳은 것은 무엇인가요?", + "option1": "블로킹은 시니어의 코멘트, 제안은 주니어의 코멘트를 말한다", + "option2": "블로킹은 머지 전 반드시 해결해야 할 문제이고, 제안은 작성자 재량에 맡기는 개선 의견이다", + "option3": "블로킹은 성능 문제에만, 제안은 보안 문제에만 붙이는 표시다", + "option4": "코멘트 수가 5개를 넘으면 자동으로 블로킹이 된다", + "answerIndex": 1, + "explanation": "블로킹은 보안 구멍, N+1, 롤백 불가처럼 머지 전에 반드시 해결돼야 하는 문제이고, 제안(non-blocking)은 네이밍 취향처럼 '나는 이렇게 하겠지만 당신 판단에 맡긴다'는 개선 의견입니다. 코멘트마다 어느 쪽인지 명시하면 \"다 고쳐야 하나요?\" 같은 소모전이 사라져 리뷰 속도와 분위기가 좋아집니다. 누가 썼는지나 개수와는 무관한 구분입니다." + }, + { + "courseSlug": "lv4-distributed-systems-basics", + "question": "CAP 정리에 대한 설명으로 가장 정확한 것은?", + "option1": "일관성(C)·가용성(A)·분할내성(P) 중 마음에 드는 두 개를 자유롭게 고르는 문제다", + "option2": "네트워크 분할이 일어났을 때, 일관성(C)과 가용성(A) 중 하나를 포기해야 한다는 정리다", + "option3": "분할내성(P)을 포기하면 일관성과 가용성을 모두 얻을 수 있으므로 P를 포기하는 것이 실무의 정석이다", + "option4": "CAP은 분할이 없는 평상시의 지연 시간과 일관성의 트레이드오프를 설명한다", + "answerIndex": 1, + "explanation": "네트워크 분할은 반드시 언젠가 일어나므로 P는 선택지가 아니라 전제입니다. 따라서 '셋 중 둘'이 아니라, 분할이 일어난 순간 C(일부 요청 거절)와 A(어긋남 허용) 중 무엇을 택할지가 실제 선택입니다. P를 포기한다는 것은 네트워크가 절대 끊기지 않는다고 가정하는 것이라 설계가 될 수 없고, 평상시의 지연 vs 일관성 트레이드오프는 CAP이 아니라 확장판인 PACELC의 Else 부분이 다루는 내용입니다." + }, + { + "courseSlug": "lv4-distributed-systems-basics", + "question": "타임아웃·재시도 설계에서 멱등성(idempotency)이 반드시 세트로 필요한 이유는?", + "option1": "멱등성이 있으면 타임아웃 값을 계산할 필요가 없어지기 때문이다", + "option2": "타임아웃이 터졌을 때 요청이 이미 서버에서 처리됐을 가능성을 배제할 수 없어서, 같은 요청을 다시 보내도 결과가 한 번 처리한 것과 같도록 보장해야 하기 때문이다", + "option3": "멱등성이 재시도 간격을 자동으로 지수적으로 늘려 주기 때문이다", + "option4": "멱등성은 네트워크 분할 자체를 예방해 재시도가 필요 없게 만들기 때문이다", + "answerIndex": 1, + "explanation": "타임아웃 시 호출자는 '요청 미도착 / 처리 중 / 처리 완료 후 응답만 유실'의 세 경우를 구분할 수 없습니다. 즉 재시도하는 요청은 이미 처리됐을 수 있으므로, Idempotency-Key 같은 장치로 '같은 요청 두 번 = 한 번'을 서버가 보장해야 중복 결제 같은 사고 없이 안전하게 재시도할 수 있습니다. 재시도 간격 조절은 지수 백오프의 역할이고, 멱등성은 분할을 예방하지 못합니다." + }, + { + "courseSlug": "lv4-distributed-systems-basics", + "question": "쿼럼에서 복제본 N개, 쓰기 확인 W개, 읽기 확인 R개일 때 W + R > N 조건이 보장하는 것은?", + "option1": "모든 노드의 물리 시계가 동기화된다", + "option2": "네트워크 분할이 발생하지 않는다", + "option3": "읽기에 참여하는 노드와 쓰기에 성공한 노드가 반드시 한 개 이상 겹쳐서, 읽기가 최소 하나의 최신 값을 만난다", + "option4": "쓰기 성능과 읽기 성능이 항상 동일해진다", + "answerIndex": 2, + "explanation": "W + R > N이면 쓰기가 성공한 노드 집합과 읽기가 확인하는 노드 집합이 수학적으로 반드시 교집합을 가집니다. 그래서 읽기가 최소 한 노드에서는 최신 값을 만나게 되어 낡은 값만 읽는 상황을 막습니다. 같은 원리(과반은 하나뿐이라 반드시 겹친다)가 리더 선출에서 스플릿 브레인을 막는 다수결의 근거이기도 합니다. 시계 동기화나 분할 예방과는 무관하며, N=3/W=3/R=1처럼 쓰기와 읽기 성능은 구성에 따라 비대칭이 됩니다." + }, + { + "courseSlug": "lv4-distributed-systems-basics", + "question": "LWW(Last-Write-Wins) 충돌 해소가 위험한 근본 이유는?", + "option1": "타임스탬프 비교 연산 자체가 너무 느려서 시스템 전체의 처리량이 떨어지기 때문이다", + "option2": "서버들의 물리 시계가 어긋나 있으면 실제로는 나중에 일어난 정당한 쓰기가 에러나 로그도 없이 조용히 사라질 수 있기 때문이다", + "option3": "LWW는 반드시 리더-팔로워 복제에서만 동작하기 때문이다", + "option4": "LWW를 쓰면 읽기 요청이 항상 모든 노드를 거쳐야 하기 때문이다", + "answerIndex": 1, + "explanation": "LWW는 '타임스탬프가 늦은 쓰기가 이긴다'는 규칙인데, 그 타임스탬프가 서로 어긋난 물리 시계에서 나온다는 것이 함정입니다. 시계가 빠른 서버의 이전 쓰기가 시계가 정상인 서버의 나중 쓰기를 이겨 버리면, 정당한 데이터가 에러도 로그도 없이 증발합니다. NTP로 맞춰도 오차는 남으므로, 잔고처럼 모든 쓰기가 의미 있는 데이터에는 버전 번호·벡터 시계·단일 리더 직렬화 같은 대안을 써야 합니다. 성능 문제가 아니라 정합성(무손실) 문제입니다." + }, + { + "courseSlug": "lv4-distributed-systems-basics", + "question": "traderbox에서 시세 틱은 Valkey Pub/Sub(AP 성향), 인증·잔고성 데이터는 단일 PostgreSQL(강한 일관성)로 설계된 이유로 옳은 것은?", + "option1": "시세는 유실돼도 다음 틱이 곧 최신 상태로 덮어 주지만, 잔고성 데이터는 한 건의 어긋남이 그대로 사고이고 스스로 복구되지 않기 때문이다", + "option2": "Valkey가 PostgreSQL보다 데이터를 더 안전하게 영구 보존하기 때문이다", + "option3": "시세 데이터가 잔고 데이터보다 중요해서 더 빠른 저장소를 배정했기 때문이다", + "option4": "하나의 제품은 반드시 하나의 일관성 수준으로 통일해야 한다는 원칙 때문이다", + "answerIndex": 0, + "explanation": "시세의 가치는 신선도가 전부라 1초 전 가격은 이미 낡은 데이터이고, 몇 틱을 놓쳐도 다음 틱이 화면을 최신으로 되돌리는 자기 치유가 있습니다. 그래서 전달 보장을 일부러 포기하고 Pub/Sub으로 흘려보냅니다. 반면 인증·잔고성 데이터는 한 건의 유실·어긋남이 복구 불가능한 사고이므로 트랜잭션이 보장되는 단일 Postgres에 두고 지연을 감수합니다. 핵심 관점은 'CP냐 AP냐는 시스템이 아니라 데이터마다 다르게 정한다'는 것으로, 4번 선택지는 이 관점과 정반대입니다." + }, + { + "courseSlug": "lv4-message-queue-event-driven", + "question": "동기 REST 체인으로 회원가입 후 이메일·포인트·통계 서비스를 차례로 호출할 때 생기는 문제로, 강좌에서 설명한 것이 아닌 것은?", + "option1": "가장 느린 서비스가 전체 응답 시간의 하한을 정한다", + "option2": "부가 기능인 통계 서비스의 장애가 회원가입 자체를 실패시킨다", + "option3": "알림 채널을 추가하려면 가입 API 코드를 수정해야 한다", + "option4": "브로커에 메시지가 쌓여 컨슈머 랙이 계속 증가한다", + "answerIndex": 3, + "explanation": "컨슈머 랙은 비동기 메시징(브로커) 환경에서 소비가 생산을 못 따라갈 때 생기는 지표입니다. 동기 REST 체인에는 브로커가 없으므로 랙이라는 개념 자체가 없고, 동기 체인의 문제는 지연 전파·연쇄 장애·강한 결합 세 가지입니다." + }, + { + "courseSlug": "lv4-message-queue-event-driven", + "question": "Kafka에서 파티션 키를 userId로 지정했을 때 얻는 효과로 옳은 것은?", + "option1": "토픽 전체에 걸쳐 모든 메시지의 순서가 보장된다", + "option2": "같은 사용자의 이벤트는 같은 파티션에 들어가 순서가 유지되고, 다른 사용자끼리는 병렬 처리된다", + "option3": "파티션 수와 무관하게 컨슈머를 무한히 늘려 병렬성을 높일 수 있다", + "option4": "메시지가 소비되는 즉시 파티션에서 삭제된다", + "answerIndex": 1, + "explanation": "Kafka는 같은 파티션 안에서만 순서를 보장하고, 같은 키의 메시지는 항상 같은 파티션으로 갑니다. 따라서 키를 userId로 주면 사용자별 순서 보장과 사용자 간 부하 분산을 동시에 얻습니다. 전체 순서 보장은 불가능하고, 병렬성의 상한은 파티션 수이며, Kafka는 소비돼도 메시지를 지우지 않습니다." + }, + { + "courseSlug": "lv4-message-queue-event-driven", + "question": "at-least-once 전달에서 컨슈머가 같은 메시지를 중복으로 받게 되는 대표적 원인은?", + "option1": "브로커가 성능을 위해 모든 메시지를 기본적으로 두 번씩 보내기 때문", + "option2": "컨슈머가 작업 처리를 마친 직후, 오프셋을 커밋하기 전에 죽으면 브로커가 같은 메시지를 다시 전달하기 때문", + "option3": "파티션 키를 지정하지 않으면 메시지가 자동으로 복제되기 때문", + "option4": "DLQ로 보낸 메시지가 자동으로 원본 토픽에 재발행되기 때문", + "answerIndex": 1, + "explanation": "컨슈머가 실제 작업(예: 알림 발송)을 끝낸 뒤 오프셋 커밋 전에 죽으면, 브로커 입장에서는 '아직 안 읽은' 메시지이므로 재전달합니다. 유실을 막으려면 재전송이 불가피하고 그 대가가 중복이므로, 이는 버그가 아니라 at-least-once의 정상 동작입니다. 그래서 eventId로 중복을 제거하는 멱등 컨슈머가 필요합니다." + }, + { + "courseSlug": "lv4-message-queue-event-driven", + "question": "트랜잭셔널 아웃박스 패턴이 해결하려는 핵심 문제는 무엇인가?", + "option1": "컨슈머가 같은 메시지를 두 번 처리해 알림이 중복 발송되는 문제", + "option2": "DB에 데이터를 저장한 뒤 Kafka에 발행하기 전에 서버가 죽으면 이벤트가 유실되는(또는 그 반대로 유령 이벤트가 생기는) 문제", + "option3": "컨슈머 랙이 증가해 알림이 늦게 도착하는 문제", + "option4": "이벤트 스키마에서 필드를 삭제하면 옛 컨슈머가 깨지는 문제", + "answerIndex": 1, + "explanation": "아웃박스는 이벤트를 브로커가 아니라 비즈니스 데이터와 같은 DB 트랜잭션으로 outbox 테이블에 먼저 기록해, 'DB 저장과 이벤트 발행이 어긋나는 틈'을 없애는 패턴입니다. 별도 릴레이가 outbox를 읽어 발행하므로 유실이 없습니다. 중복 소비 문제는 아웃박스가 아니라 멱등 컨슈머가 담당하는 별개의 방어입니다." + }, + { + "courseSlug": "lv4-message-queue-event-driven", + "question": "이벤트와 커맨드의 구분에 대한 설명으로 옳은 것은?", + "option1": "UserSignedUp은 이미 일어난 과거형 사실(이벤트)이고, SendEmail은 특정 수신자에게 행동을 요구하는 요청(커맨드)이다", + "option2": "이벤트는 반드시 동기로, 커맨드는 반드시 비동기로 전달해야 한다", + "option3": "커맨드로 설계하면 발행자가 수신자를 몰라도 되므로 결합이 가장 느슨해진다", + "option4": "이벤트는 미래에 해야 할 일을 명령형으로 서술한 메시지다", + "answerIndex": 0, + "explanation": "이벤트는 '가입했다'처럼 이미 일어난 과거형 사실이며 발행자는 누가 듣는지 모릅니다. 커맨드는 '보내라'처럼 특정 수신자에게 특정 행동을 요구하는 요청입니다. 결합을 느슨하게 하는 쪽은 이벤트이고(커맨드는 수신자와 의도를 명시해 결합이 생김), 동기/비동기 여부와는 별개의 구분입니다." + }, + { + "courseSlug": "lv4-monolith-vs-microservices", + "question": "콘웨이의 법칙이 아키텍처 선택에 시사하는 바로 가장 적절한 것은?", + "option1": "시스템은 항상 최신 기술 트렌드를 따라 설계해야 한다", + "option2": "마이크로서비스 도입 여부는 조직이 감당할 수 있는 독립 배포 주기의 수라는 조직 질문으로 판단해야 한다", + "option3": "팀 규모와 무관하게 서비스 수는 많을수록 좋다", + "option4": "시스템 구조는 사용하는 프로그래밍 언어에 의해 결정된다", + "answerIndex": 1, + "explanation": "콘웨이의 법칙은 '시스템 설계는 그 조직의 커뮤니케이션 구조를 닮는다'는 관찰입니다. 3명이 30개 서비스를 운영하면 온콜·배포 부담이 조직을 초과하고, 30명이 모놀리스 하나에 매달리면 배포 열차와 머지 충돌이 병목이 됩니다. 따라서 쪼갤지 말지는 기술 질문이 아니라 '우리 조직이 몇 개의 독립 배포 주기를 감당할 수 있나'라는 조직 질문으로 판단해야 합니다." + }, + { + "courseSlug": "lv4-monolith-vs-microservices", + "question": "'분산 모놀리스'의 대표적인 증상으로 본문에서 든 것은?", + "option1": "서비스 하나를 배포하려면 다른 서비스들도 같이 배포해야 한다", + "option2": "모든 서비스가 하나의 코드베이스에 들어 있다", + "option3": "서비스마다 자기 스키마의 데이터를 소유하고 있다", + "option4": "동기 호출과 비동기 이벤트를 용도에 맞게 나눠 쓴다", + "answerIndex": 0, + "explanation": "분산 모놀리스는 쪼개서 네트워크 비용(지연·부분 실패·버전 관리)은 전부 내는데, 마이크로서비스의 핵심 보상인 '독립 배포'는 못 받는 최악의 형태입니다. A를 배포하려면 B·C도 같이 배포해야 하거나, 여러 서비스가 한 테이블을 같이 읽고 쓰는 것이 전형적 증상입니다. 나머지 보기들은 정상적인 모놀리스이거나 오히려 건강한 마이크로서비스의 특징입니다." + }, + { + "courseSlug": "lv4-monolith-vs-microservices", + "question": "도메인 주도 설계에서 바운디드 컨텍스트(서비스 경계 후보)를 찾는 실마리로 본문이 제시한 것은?", + "option1": "화면·비즈니스 로직·DB 같은 기술 계층이 나뉘는 지점", + "option2": "코드 라인 수가 일정 기준을 넘는 지점", + "option3": "같은 단어가 문맥에 따라 다른 뜻이 되는 지점", + "option4": "개발자들이 가장 자주 수정하는 파일이 모인 지점", + "answerIndex": 2, + "explanation": "쇼핑몰의 '상품'이 카탈로그에선 사진·검색 키워드, 재고에선 SKU·창고 위치, 주문에선 가격 스냅샷, 배송에선 무게·부피를 뜻하듯 — 같은 단어의 의미가 갈라지는 지점이 곧 바운디드 컨텍스트의 경계입니다. 반대로 기술 계층(화면/로직/DB)으로 자르면 기능 하나를 고칠 때 여러 서비스를 전부 건드리게 되는 나쁜 경계가 됩니다." + }, + { + "courseSlug": "lv4-monolith-vs-microservices", + "question": "사가 패턴에서 이메일 발송이나 외부 결제 승인처럼 보상이 불가능하거나 비싼 단계를 어디에 배치해야 하는가?", + "option1": "사가의 첫 단계 — 가장 먼저 처리해 실패를 빨리 발견한다", + "option2": "사가의 중간 — 앞뒤 단계가 감싸도록 한다", + "option3": "사가의 마지막 단계 — 뒤에 실패할 단계가 없으면 보상할 일도 없다", + "option4": "순서는 무관하다 — 2단계 커밋이 자동으로 되돌려 준다", + "answerIndex": 2, + "explanation": "사가는 중간 단계가 실패하면 이미 커밋된 앞 단계들을 보상 트랜잭션으로 역순으로 되돌리는 패턴입니다. 발송된 이메일은 거둘 수 없고 외부 PG 승인 취소는 별개의 복잡한 절차이므로, 이런 단계를 마지막에 두면 그 뒤에 실패할 단계가 없어 보상이 필요한 상황 자체가 생기지 않습니다. 2PC는 참여자 전원이 락을 잡고 대기해 하나만 느려져도 전체가 멈추기 때문에 실무에서 기피됩니다." + }, + { + "courseSlug": "lv4-monolith-vs-microservices", + "question": "traderbox가 채택한 '스키마-퍼-서비스'(단일 PostgreSQL + 서비스별 스키마 격리) 접근의 장점으로 옳은 것은?", + "option1": "여러 서비스가 서로의 테이블에 자유롭게 JOIN할 수 있다", + "option2": "DB 인스턴스는 한 대라 운영이 가볍고, 경계는 강제되며, 나중에 커진 스키마만 별도 DB로 이사시킬 수 있다", + "option3": "처음부터 서비스 수만큼 DB 인스턴스를 운영해 완전한 물리적 격리를 얻는다", + "option4": "트랜잭션이 서비스 경계를 넘어도 하나의 BEGIN~COMMIT으로 묶인다", + "answerIndex": 1, + "explanation": "스키마-퍼-서비스는 DB 분리의 단계적 접근입니다. 인프라는 한 대라 운영 부담이 적으면서도, 서비스별 DB 계정에 자기 스키마 권한만 부여하면 '남의 테이블에 JOIN 못 함'이 DB 수준에서 강제됩니다. 코드가 이미 경계를 지키고 있으므로 나중에 트래픽이 커진 스키마만 별도 인스턴스로 옮기면 됩니다. 서로의 테이블에 직접 JOIN하는 것은 공유 DB 안티패턴으로, 스키마 변경의 자유와 독립 배포를 무너뜨립니다." + }, + { + "courseSlug": "lv4-db-replication-sharding", + "question": "글을 쓴 직후 새로고침했더니 방금 쓴 글이 목록에 보이지 않는다. 리더-팔로워 복제 환경에서 이 현상의 가장 유력한 원인은?", + "option1": "리더 DB가 INSERT를 거부하고도 성공 응답을 보냈기 때문", + "option2": "복제 지연으로 아직 글이 도착하지 않은 팔로워에서 읽었기 때문", + "option3": "팔로워가 읽기 전용이라 SELECT 자체가 실패했기 때문", + "option4": "WAL이 손상되어 리더의 데이터가 유실됐기 때문", + "answerIndex": 1, + "explanation": "쓰기는 리더에 커밋됐지만 WAL이 팔로워에 재생되기 전에 읽기 요청이 팔로워로 라우팅되면, 아직 글이 없는 상태를 읽게 됩니다(read-your-writes 위반). DB가 고장 난 게 아니라 '복제는 시간이 걸린다'는 성질을 설계가 반영하지 못한 것으로, 쓰기 직후 리더에서 읽기·세션 고정·타임스탬프 라우팅 같은 패턴으로 해결합니다." + }, + { + "courseSlug": "lv4-db-replication-sharding", + "question": "비동기 복제 + 자동 failover 환경에서 '커밋 성공 응답을 받은 쓰기'가 사라질 수 있는 이유는?", + "option1": "비동기 복제에서 커밋 성공은 리더에만 적혔다는 뜻이라, WAL 전달 전에 리더가 죽고 팔로워가 승격되면 그 쓰기가 새 리더에 없기 때문", + "option2": "팔로워가 승격될 때 자신의 데이터를 전부 초기화하고 새로 시작하기 때문", + "option3": "자동 failover 도구가 승격 과정에서 최근 트랜잭션을 의도적으로 롤백하기 때문", + "option4": "동기 복제보다 비동기 복제의 디스크 기록이 느려서 커밋이 유실되기 때문", + "answerIndex": 0, + "explanation": "비동기 복제는 리더가 자기 디스크에 쓰자마자 성공을 응답하고 WAL은 뒤따라 전송됩니다. 그 WAL이 팔로워에 도착하기 전에 리더가 죽으면, 승격된 새 리더에는 해당 쓰기가 존재하지 않아 클라이언트만 성공했다고 믿는 상태가 됩니다. 그래서 잃으면 안 되는 쓰기는 동기 복제나 별도 원장으로 보호해야 합니다." + }, + { + "courseSlug": "lv4-db-replication-sharding", + "question": "해시 샤딩과 레인지 샤딩의 트레이드오프 설명으로 옳은 것은?", + "option1": "해시 샤딩은 범위 조회에 유리하지만 데이터가 특정 샤드에 쏠리기 쉽다", + "option2": "레인지 샤딩은 항상 모든 샤드에 부하를 균등하게 분배한다", + "option3": "해시 샤딩은 분포가 균등하지만 범위 조회가 모든 샤드를 두드리고, 레인지 샤딩은 범위 조회에 유리하지만 최신 구간에 쓰기가 몰릴 수 있다", + "option4": "두 방식 모두 샤드 수를 늘려도 데이터 이동이 전혀 필요 없다", + "answerIndex": 2, + "explanation": "해시 샤딩은 키를 해시해 균등하게 뿌리므로 분포는 고르지만, 연속된 범위의 키가 여러 샤드에 흩어져 범위 조회가 스캐터-개더가 됩니다. 레인지 샤딩은 범위째 담아 범위 조회가 한 샤드로 끝나지만, 시간순 키라면 최신 구간 샤드에만 쓰기가 집중되는 핫 샤드가 생깁니다. 균등 분포와 조회 지역성은 서로 당기는 방향이 반대인 트레이드오프입니다." + }, + { + "courseSlug": "lv4-db-replication-sharding", + "question": "시계열 데이터(시세·로그)에 시간 기반 파티셔닝을 적용할 때, 오래된 데이터 정리에 DELETE 대신 파티션 DROP을 쓰는 이유는?", + "option1": "DELETE는 트랜잭션을 지원하지 않아 데이터 정합성이 깨지기 때문", + "option2": "DROP은 파티션 파일째 제거해 밀리초에 끝나지만, DELETE는 수억 행을 개별 삭제하며 죽은 튜플과 VACUUM 부담을 남기기 때문", + "option3": "DROP TABLE은 실행 후 되돌릴 수 있어 DELETE보다 안전하기 때문", + "option4": "파티션된 테이블에서는 DELETE 문 실행이 문법적으로 불가능하기 때문", + "answerIndex": 1, + "explanation": "DELETE는 행을 하나씩 지우면서 죽은 튜플(dead tuple)을 남겨 VACUUM 부담을 키우고 오래 걸립니다. 반면 시간 기반 파티션은 기간이 지난 파티션을 DROP(또는 DETACH 후 DROP)하면 파일 단위로 즉시 제거되어 부스러기가 없습니다. 그래서 보존 정책이 있는 시계열 데이터는 '지울 수 있게'가 아니라 '떨어뜨릴 수 있게' 설계하는 것이 정석입니다." + }, + { + "courseSlug": "lv4-db-replication-sharding", + "question": "'유저 1천만 명, 일 쓰기 5천만 건' 서비스의 확장 전략으로 이 강좌의 판단 순서에 가장 부합하는 것은?", + "option1": "유저가 1천만 명이므로 지체 없이 샤딩부터 도입한다", + "option2": "복제는 쓰기도 늘려 주므로 레플리카만 충분히 늘리면 샤딩은 영원히 불필요하다", + "option3": "일 5천만 건은 평균 초당 약 580건 수준이므로, 캐시와 읽기 레플리카로 읽기를 분산하고 쓰기는 튜닝으로 버티다 쓰기 한계 전망이 보일 때 샤딩을 준비한다", + "option4": "RDS 같은 관리형 서비스로 옮기면 샤드 키 설계와 복제 지연 대응까지 자동으로 해결된다", + "answerIndex": 2, + "explanation": "일 5천만 건은 평균 초당 약 580건으로, 피크를 감안해도 잘 튜닝된 단일 Postgres가 감당할 수 있는 범위입니다. 따라서 인덱스·쿼리 튜닝·캐시·읽기 레플리카(복제)를 먼저 쓰고, 쓰기 성장이 한 대의 천장에 닿을 전망이 보일 때 샤딩을 준비하는 것이 순서입니다. 복제는 쓰기를 모든 복제본에 반복시키므로 쓰기 확장 수단이 아니며, 관리형 서비스는 운영 노동을 덜어 줄 뿐 복제 지연 대응이나 샤드 키 설계는 여전히 설계자의 몫입니다." + }, + { + "courseSlug": "lv4-high-traffic-architecture", + "question": "평균 응답시간이 45ms로 좋아 보이는데도 실무에서는 p99 지연을 더 중요하게 보는 이유로 가장 적절한 것은?", + "option1": "p99가 평균보다 항상 계산하기 쉽기 때문이다", + "option2": "평균은 소수의 느린 요청을 다수의 빠른 요청이 희석해 버리고, 하루에 여러 번 요청하는 사용자는 '1%의 느린 요청'을 높은 확률로 매일 겪기 때문이다", + "option3": "p99는 서버 비용을 직접 결정하는 유일한 지표이기 때문이다", + "option4": "평균은 읽기 요청에만 적용되고 쓰기 요청에는 적용할 수 없기 때문이다", + "answerIndex": 1, + "explanation": "평균은 소수의 아주 느린 요청을 다수의 빠른 요청이 희석합니다. 하루 50회 요청하는 사용자가 p99 지연을 한 번이라도 만날 확률은 1-(0.99^50)≈39%로, '1%의 느린 요청'은 사실상 세 명 중 한 명이 매일 겪는 경험입니다. 게다가 한 화면이 API 여러 개를 부르면 가장 느린 하나가 화면 전체의 체감 속도를 정하므로, 사용자 경험을 결정하는 것은 평균이 아니라 p99입니다." + }, + { + "courseSlug": "lv4-high-traffic-architecture", + "question": "로드밸런싱 알고리즘 중 컨시스턴트 해싱(consistent hashing)을 선택하는 가장 적절한 상황은?", + "option1": "모든 요청의 처리 비용이 비슷하고 서버 사양이 동일할 때", + "option2": "요청마다 처리 시간이 들쭉날쭉해서 연결 수가 적은 서버로 보내고 싶을 때", + "option3": "캐시 지역성이 중요해서 같은 키는 늘 같은 서버로 보내되, 서버 증감 시 재배치되는 키를 최소화하고 싶을 때", + "option4": "죽은 서버를 자동으로 감지해서 트래픽 풀에서 제외하고 싶을 때", + "answerIndex": 2, + "explanation": "컨시스턴트 해싱은 키(유저ID 등)를 해시해 같은 키를 항상 같은 서버로 보내므로 캐시 지역성이 좋고, 서버를 추가·제거해도 이동하는 키가 전체의 1/N 정도에 그칩니다. 요청 비용이 균일하면 라운드로빈, 처리 시간이 들쭉날쭉하면 최소 연결이 어울리고, 죽은 서버 제외는 알고리즘이 아니라 헬스체크의 역할입니다." + }, + { + "courseSlug": "lv4-high-traffic-architecture", + "question": "캐시 스탬피드(cache stampede)에 대한 설명으로 옳은 것은?", + "option1": "인기 키의 TTL이 만료되는 순간 그동안 캐시가 막아 주던 대량의 요청이 한꺼번에 원본(DB)으로 몰리는 현상으로, 잠금·확률적 조기 갱신·TTL 지터로 막는다", + "option2": "캐시 서버의 메모리가 가득 차서 오래된 키가 자동으로 삭제되는 현상으로, 메모리를 늘리면 해결된다", + "option3": "쓰기 요청이 캐시와 DB에 동시에 기록되면서 응답이 느려지는 현상으로, write-behind 패턴으로 막는다", + "option4": "CDN 엣지 서버가 원본과 통신이 끊겨 낡은 콘텐츠를 계속 제공하는 현상으로, purge API로 막는다", + "answerIndex": 0, + "explanation": "캐시 스탬피드는 인기 키가 만료되는 그 순간, 초당 수천 건의 요청이 전부 캐시 miss가 되어 동시에 원본으로 돌진하는 현상입니다. 대책은 본문에서 배운 세 가지 — 한 요청만 락을 잡고 재계산하는 잠금, TTL 임박 시 미리 갱신하는 확률적 조기 갱신, 만료 시각을 흩는 TTL 랜덤 지터입니다. 나머지 보기는 캐시 축출·쓰기 패턴·CDN 무효화라는 별개의 주제입니다." + }, + { + "courseSlug": "lv4-high-traffic-architecture", + "question": "레이트리밋 알고리즘에서 토큰 버킷(token bucket)과 슬라이딩 윈도우(sliding window)의 차이로 옳은 것은?", + "option1": "토큰 버킷은 유저 기준 제한에만, 슬라이딩 윈도우는 IP 기준 제한에만 쓸 수 있다", + "option2": "슬라이딩 윈도우는 순간 폭발(burst)을 허용하는 방식이라 로그인 시도 제한에 적합하다", + "option3": "토큰 버킷은 모아 둔 토큰만큼 순간 폭발(burst)을 허용해 API 트래픽에 어울리고, 슬라이딩 윈도우는 시간 창 안의 횟수를 엄격히 세서 로그인 시도처럼 버스트도 막고 싶을 때 어울린다", + "option4": "두 알고리즘은 동작이 완전히 같고 구현 언어만 다르다", + "answerIndex": 2, + "explanation": "토큰 버킷은 초당 r개씩 토큰이 채워지고(최대 b개) 요청이 토큰을 소모하는 구조라, 평시 속도는 제한하면서도 모아 둔 토큰만큼의 짧은 몰림(burst)은 자연스럽게 허용합니다. 슬라이딩 윈도우는 '최근 60초 동안 N회까지'처럼 시간 창을 미끄러뜨리며 정확히 세므로 버스트까지 엄격히 막아야 하는 로그인 시도·인증 코드 발송에 어울립니다. 무엇을 기준으로 셀지(IP·유저·API 키)는 알고리즘과 별개의 선택입니다." + }, + { + "courseSlug": "lv4-high-traffic-architecture", + "question": "외부 시세 API 호출용 스레드풀과 결제용 스레드풀을 분리해 두었더니, 시세 API가 멈춰 해당 스레드가 모두 잠겨도 결제 기능은 정상 동작했다. 이때 적용된 장애 격리 패턴은?", + "option1": "서킷 브레이커 — 실패율이 임계값을 넘으면 호출을 차단한다", + "option2": "벌크헤드 — 배의 격벽처럼 자원 풀을 기능별로 나눠 한 기능의 장애가 다른 기능으로 번지지 않게 가둔다", + "option3": "백프레셔 — 소비 속도를 생산자에게 알려 유입을 늦춘다", + "option4": "stale-while-revalidate — 낡은 응답을 먼저 주고 백그라운드에서 갱신한다", + "answerIndex": 1, + "explanation": "벌크헤드는 배의 격벽에서 온 이름으로, 스레드풀·커넥션풀 같은 자원을 기능별로 분리해 한 구획의 장애가 전체로 번지지 않게 가두는 패턴입니다. 공용 풀 하나였다면 시세 API 대기가 풀 전체를 잠식해 무관한 결제까지 함께 죽었을 것입니다. 서킷 브레이커는 실패율 기반 '호출 차단', 백프레셔는 '유입 속도 조절', stale-while-revalidate는 캐시 응답 전략으로 각각 다른 문제를 풉니다." + }, + { + "courseSlug": "lv4-kubernetes-cloud-native", + "question": "쿠버네티스의 '선언형(declarative)' 운영 방식에 대한 설명으로 가장 정확한 것은?", + "option1": "관리자가 컨테이너를 띄우는 명령을 순서대로 실행하면 시스템이 그 순서를 기록해 재실행한다", + "option2": "원하는 상태를 YAML로 선언해 두면 컨트롤 루프가 현재 상태와의 차이를 계속 좁혀 그 상태로 수렴시킨다", + "option3": "모든 설정을 이미지 안에 하드코딩해 어떤 환경에서도 같은 상태가 보장되게 한다", + "option4": "파드가 죽으면 관리자에게 알림을 보내 수동 복구를 빠르게 하도록 돕는다", + "answerIndex": 1, + "explanation": "선언형의 핵심은 '어떻게(절차)'가 아니라 '어떤 상태여야 하는지(결과)'를 적는 것입니다. 컨트롤 루프(reconciliation)가 원하는 상태와 현재 상태를 끊임없이 비교해 차이를 좁히기 때문에, 파드가 죽어 replicas가 모자라면 사람 개입 없이 새 파드를 만들어 선언된 수로 복원합니다. 1번은 명령형 사고이고, 4번처럼 수동 복구에 의존하는 것이 바로 compose 시절의 한계입니다." + }, + { + "courseSlug": "lv4-kubernetes-cloud-native", + "question": "파드 IP가 재생성 때마다 바뀌는 문제를 해결하기 위해 쿠버네티스가 제공하는 것은?", + "option1": "Deployment — 파드에 영구 고정 IP를 할당해 준다", + "option2": "ConfigMap — 바뀐 IP를 환경변수로 자동 갱신해 준다", + "option3": "Service — 라벨로 파드 무리를 묶어 고정된 이름(DNS)과 가상 IP를 부여하고 트래픽을 분배한다", + "option4": "StatefulSet — 모든 파드의 IP가 절대 바뀌지 않게 막는다", + "answerIndex": 2, + "explanation": "파드는 일회용이라 죽고 새로 뜰 때마다 IP가 바뀝니다. Service는 라벨 셀렉터로 파드들을 묶어 안정적인 이름과 가상 IP라는 '고정된 문패'를 제공하고, 들어온 요청을 뒤의 파드들에 분배하는 내장 로드밸런싱까지 해 줍니다. 클러스터 안에서는 서비스 이름이 곧 DNS 호스트명이 되므로 호출하는 쪽은 파드 IP 변화를 전혀 신경 쓰지 않아도 됩니다." + }, + { + "courseSlug": "lv4-kubernetes-cloud-native", + "question": "liveness 프로브와 readiness 프로브의 차이를 올바르게 설명한 것은?", + "option1": "liveness 실패 시 컨테이너를 재시작하고, readiness 실패 시 재시작 없이 Service의 트래픽 분배 대상에서만 제외한다", + "option2": "liveness는 배포 때만 검사하고, readiness는 운영 중에만 검사한다", + "option3": "readiness 실패 시 컨테이너를 재시작하고, liveness 실패 시 트래픽만 차단한다", + "option4": "둘은 이름만 다르고 실패 시 동작은 동일하다", + "answerIndex": 0, + "explanation": "liveness는 '살았나?'를 묻는 질문이라 실패하면 회복 불능(데드락 등)으로 보고 컨테이너를 재시작합니다. readiness는 '지금 트래픽을 받을 준비가 됐나?'를 묻는 질문이라 실패해도 재시작하지 않고 Service의 분배 대상에서만 잠시 빼 둡니다. JVM 워밍업처럼 '죽은 게 아니라 아직 준비 안 된' 상태를 재시작으로 다루면 안 되기 때문에 두 질문을 분리하는 것이며, readiness를 성급하게 OK로 만들면 배포 때마다 준비 안 된 파드로 트래픽이 흘러 502가 납니다." + }, + { + "courseSlug": "lv4-kubernetes-cloud-native", + "question": "리소스 limits를 초과했을 때 CPU와 메모리에 가해지는 제재로 옳은 것은?", + "option1": "CPU 초과와 메모리 초과 모두 컨테이너가 즉시 강제 종료된다", + "option2": "CPU 초과는 스로틀링(속도 제한)되고, 메모리 초과는 OOMKill로 컨테이너가 강제 종료된다", + "option3": "CPU 초과는 OOMKill로 강제 종료되고, 메모리 초과는 스로틀링된다", + "option4": "둘 다 경고 로그만 남기고 동작에는 영향이 없다", + "answerIndex": 1, + "explanation": "CPU는 시간을 쪼개 나눠 쓸 수 있는 자원이라 limit을 넘으려 하면 스로틀링으로 속도만 늦춰지지만, 메모리는 이미 점유한 것을 뺏을 수 없는 자원이라 limit을 넘으면 OOMKill로 컨테이너가 강제 종료됩니다. '앱이 이유 없이 재시작된다'는 문제의 단골 원인이 JVM 힙 설정과 어긋난 빡빡한 메모리 limit인 것도 이 때문입니다." + }, + { + "courseSlug": "lv4-kubernetes-cloud-native", + "question": "DB·Kafka 같은 상태를 가진(stateful) 워크로드에 대해 많은 실무 팀이 내리는 판단과 그 이유로 가장 적절한 것은?", + "option1": "쿠버네티스에 올리면 StatefulSet이 복제·장애 승격까지 전부 자동으로 해 주므로 반드시 클러스터에 넣는다", + "option2": "상태 워크로드는 컨테이너화가 불가능하므로 물리 서버에만 설치한다", + "option3": "무상태 앱은 쿠버네티스에 두고, DB 등은 백업·복제·장애 조치를 대신 맡아 주는 관리형 서비스에 두는 팀이 많다", + "option4": "DB를 무상태 앱과 같은 Deployment로 배포해 자가 치유를 받게 한다", + "answerIndex": 2, + "explanation": "StatefulSet과 PersistentVolume이라는 오브젝트가 있긴 하지만, 디스크 연결·복제 순서·장애 시 승격 같은 DB 고유의 운영 난제를 쿠버네티스가 대신 풀어 주지는 않습니다. 그래서 복제·치유가 공짜인 무상태 앱만 클러스터에 올리고, 상태 워크로드는 관리형 서비스(RDS, MSK 등)에 맡겨 운영 부담을 비용과 맞바꾸는 선택을 하는 팀이 많습니다. '전부 클러스터에 넣어야 진짜'라는 규칙은 없고, 워크로드 성격에 따라 두는 곳을 고르는 것이 설계입니다." + }, + { + "courseSlug": "lv4-observability-metrics-tracing", + "question": "모니터링과 관측성(observability)의 차이를 가장 정확하게 설명한 것은?", + "option1": "모니터링은 미리 아는 질문에 답을 준비해 두는 것이고, 관측성은 사고 당일 처음 떠올린 질문에도 데이터로 답할 수 있는 상태다", + "option2": "모니터링은 무료 도구를 쓰고, 관측성은 유료 상용 도구를 쓰는 것이다", + "option3": "모니터링은 백엔드용이고, 관측성은 프론트엔드용이다", + "option4": "관측성은 모니터링에서 로그만 제거한 개념이다", + "answerIndex": 0, + "explanation": "모니터링은 'CPU가 80% 넘으면 알려 줘'처럼 미리 아는 질문에 대한 답을 준비하는 일입니다. 하지만 장애는 늘 예상 못 한 모양으로 오기 때문에, 시스템이 내보내는 메트릭·로그·트레이스가 충분히 풍부해서 새로운 질문에도 즉석에서 답을 캘 수 있는 상태 — 그것이 관측성입니다. 도구의 가격이나 적용 계층의 문제가 아닙니다." + }, + { + "courseSlug": "lv4-observability-metrics-tracing", + "question": "메트릭 레이블에 user_id를 넣으면 안 되는 이유는?", + "option1": "개인정보 보호법 위반이라 법적으로 금지되어 있기 때문", + "option2": "메트릭 저장소는 레이블 값 조합마다 별도의 시계열을 만들기 때문에, 무한히 늘어나는 값을 레이블로 쓰면 시계열 수가 곱셈으로 폭발해 저장소가 감당하지 못한다", + "option3": "user_id는 문자열이라 숫자만 저장하는 메트릭에 넣을 수 없기 때문", + "option4": "user_id가 들어가면 그래프의 색이 너무 많아져 보기 어렵기 때문", + "answerIndex": 1, + "explanation": "이것이 카디널리티 폭발입니다. 메트릭 저장소는 레이블 값의 조합마다 시계열을 하나씩 만드는데, method(5종)×path(50종)×status(10종)=2,500 시계열 정도는 괜찮지만 여기에 유저 10만 명의 user_id가 곱해지면 2억 5천만 시계열이 되어 메트릭 서버가 먼저 죽습니다. 레이블에는 종류가 유한하고 적은 값만 쓰고, 유저별 분석 같은 질문은 트레이스와 로그의 몫입니다." + }, + { + "courseSlug": "lv4-observability-metrics-tracing", + "question": "응답 시간을 볼 때 평균(mean) 대신 퍼센타일(p50/p95/p99)을 봐야 하는 이유는?", + "option1": "평균은 계산 비용이 너무 커서 실시간으로 구할 수 없기 때문", + "option2": "퍼센타일이 평균보다 항상 작은 값이 나와서 보고서가 좋아 보이기 때문", + "option3": "응답 시간 분포는 꼬리가 긴 모양이라 소수의 매우 느린 요청이 평균을 부풀리거나, 반대로 다수의 빠른 요청이 일부 사용자의 고통을 가려 버리기 때문", + "option4": "평균은 히스토그램이 아니라 카운터로만 계산할 수 있기 때문", + "answerIndex": 2, + "explanation": "응답 시간 분포는 한쪽 꼬리가 긴 비대칭 모양입니다. 본문 예시처럼 10건 중 단 한 건의 2.4초가 평균을 285ms로 5배 부풀릴 수 있고, 반대로 p99가 나쁜데 평균이 멀쩡하면 '일부 사용자만 계속 고통받는' 상태가 숨겨집니다. 그래서 p50은 절반의 경험, p99는 가장 아픈 1%의 경험으로 나눠 보고, SLO도 평균이 아니라 퍼센타일로 겁니다." + }, + { + "courseSlug": "lv4-observability-metrics-tracing", + "question": "분산 트레이싱에서 trace_id가 gateway에서 auth로, 즉 서로 다른 서비스(프로세스)를 건너가는 방법은?", + "option1": "두 서비스가 같은 데이터베이스 테이블을 조회해서 trace_id를 공유한다", + "option2": "호출하는 쪽이 W3C 표준 traceparent HTTP 헤더에 trace_id를 실어 보내면, 받는 쪽이 새 트레이스를 만들지 않고 같은 trace_id 아래 자기 스팬을 단다(컨텍스트 전파)", + "option3": "각 서비스가 무작위로 trace_id를 생성한 뒤 나중에 시간순으로 짜맞춘다", + "option4": "trace_id는 서비스를 건너갈 수 없어서 서비스마다 트레이스를 따로 봐야 한다", + "answerIndex": 1, + "explanation": "비밀은 단순한 HTTP 헤더입니다. W3C 표준 traceparent 헤더에 trace_id와 부모 span_id를 실어 보내면 받는 서비스가 같은 trace_id 아래에 자기 스팬을 이어 붙입니다. 이를 컨텍스트 전파(context propagation)라고 하며, Kafka로 건널 때는 메시지 헤더에 같은 값을 싣습니다. 전파가 한 군데라도 끊기면 트레이스가 두 동강 난 고아 스팬이 되므로, 자체 스레드풀 같은 절단 지점은 자동 계측에 맡기는 것이 안전합니다." + }, + { + "courseSlug": "lv4-observability-metrics-tracing", + "question": "새벽에 담당자를 깨우는 페이징 알람의 우선순위 원칙으로 옳은 것은?", + "option1": "CPU 사용률 같은 원인 기반 알람이 근본 원인을 가리키므로 항상 페이징 1순위다", + "option2": "가능한 모든 지표에 알람을 걸어 두는 것이 놓치는 장애를 줄이는 최선이다", + "option3": "알람은 한 번 설정하면 수정하지 않는 것이 일관성 유지에 좋다", + "option4": "에러율·지연처럼 사용자가 지금 아프다는 증상 기반 알람을 페이징으로 하고, CPU·디스크 같은 원인 기반 알람은 사용자 영향이 없다면 티켓·채널 알림으로 강등한다", + "answerIndex": 3, + "explanation": "우선순위는 언제나 증상이 먼저입니다. CPU가 99%여도 사용자 지연이 없으면 새벽에 깨울 일이 아니고, CPU가 30%여도 에러율이 5%면 즉시 일어나야 합니다. 또 '혹시 몰라서' 걸어 둔 알람이 헛울음을 반복하면 알람 피로가 쌓여 진짜 장애 알람까지 무시되므로, 조치 없이 닫힌 알람은 버그처럼 취급해 임계값을 고치거나 페이징에서 강등해야 합니다." + }, + { + "courseSlug": "lv4-sre-incident-response", + "question": "SRE가 100% 가용성을 목표로 삼지 않는 가장 근본적인 이유는 무엇인가요?", + "option1": "100%를 측정할 수 있는 모니터링 도구가 없기 때문에", + "option2": "어느 선을 넘으면 사용자가 차이를 느끼지 못하는데 비용은 9 하나마다 약 10배씩 급증하기 때문에", + "option3": "SLA 계약서에 100%를 명시하는 것이 법적으로 금지되어 있기 때문에", + "option4": "클라우드 사업자가 100% 가용성을 기술적으로 지원하지 않기 때문에", + "answerIndex": 1, + "explanation": "사용자의 인터넷·기기·통신사부터가 100%가 아니므로 일정 수준을 넘으면 사용자는 개선을 체감하지 못합니다. 반면 신뢰성에는 비용 곡선이 있어 9가 하나 늘 때마다 비용이 대략 10배로 뜁니다. 그래서 SRE의 첫 질문은 '우리에게 몇 개의 9가 정말 필요한가'입니다." + }, + { + "courseSlug": "lv4-sre-incident-response", + "question": "SLO가 99.9%일 때 '에러 버짓'에 대한 설명으로 옳은 것은 무엇인가요?", + "option1": "한 달에 약 43분의 허용된 실패이며, 소진되면 신규 기능 배포를 멈추고 안정화에 투자한다", + "option2": "장애를 일으킨 팀에게 부과되는 벌점으로, 누적되면 온콜 순번이 늘어난다", + "option3": "외부 고객과 맺는 계약이므로 SLO보다 항상 엄격하게 잡아야 한다", + "option4": "CPU·메모리 사용률이 넘지 말아야 할 상한선을 뜻한다", + "answerIndex": 0, + "explanation": "에러 버짓은 100% - SLO로, 99.9%면 한 달(30일)에 약 43.2분의 '써도 되는 실패' 예산입니다. 벌점이 아니라 배포·실험에 쓰는 예산이며, 소진 시 배포를 동결하고 안정화 작업으로 전환하는 정책을 평시에 합의해 두는 것이 핵심입니다. 참고로 외부 계약인 SLA는 SLO보다 느슨하게 잡습니다." + }, + { + "courseSlug": "lv4-sre-incident-response", + "question": "장애 대응 중 인시던트 커맨더(IC)의 올바른 행동은 무엇인가요?", + "option1": "가장 실력 있는 사람이므로 직접 터미널을 열고 디버깅을 주도한다", + "option2": "원인이 완전히 규명될 때까지 롤백을 보류하도록 지시한다", + "option3": "직접 디버깅하지 않고 우선순위 결정과 역할 배정 등 전체 지휘에 집중한다", + "option4": "혼란을 줄이기 위해 원인이 확정될 때까지 외부 공지를 중단시킨다", + "answerIndex": 2, + "explanation": "IC가 디버깅에 뛰어드는 것이 가장 흔한 실수입니다. 전원이 디버깅하면 전체 그림을 보는 사람이 0명이 되므로, IC는 우선순위 결정·역할 배정·상황 파악에 전념해야 합니다. 또한 복구가 우선이라 의심되는 배포는 일단 롤백하고, 커뮤니케이션은 침묵이 최악이므로 주기적으로(다음 업데이트 시각 명시) 이어가야 합니다." + }, + { + "courseSlug": "lv4-sre-incident-response", + "question": "장애 진단에서 '최근에 뭐가 바뀌었나?'가 첫 질문인 이유는 무엇인가요?", + "option1": "변경 이력 확인이 로그 분석보다 법적 책임 소재를 가리기에 유리하기 때문에", + "option2": "멀쩡히 돌던 시스템이 스스로 무너지는 경우는 드물고, 장애 대다수가 배포·설정·트래픽 같은 변화 직후에 오기 때문에", + "option3": "최근 변경을 찾으면 변경한 사람에게 대응을 맡길 수 있기 때문에", + "option4": "이분 탐색은 변경 이력이 있어야만 수행할 수 있기 때문에", + "answerIndex": 1, + "explanation": "장애의 대다수는 배포, 설정 변경, 인프라 작업, 트래픽 급증 같은 '변화' 직후에 발생합니다. 지표가 꺾인 시각과 배포 시각이 겹치면 조사의 80%가 끝난 셈입니다. 변경자에게 책임을 묻기 위함이 아니며(비난 없는 문화), 이분 탐색적 격리는 변화가 안 보일 때도 쓸 수 있는 별개의 기법입니다." + }, + { + "courseSlug": "lv4-sre-incident-response", + "question": "비난 없는(blameless) 포스트모템의 원칙에 부합하는 것은 무엇인가요?", + "option1": "재발 방지를 위해 실수한 사람의 이름을 근본 원인 항목에 명시한다", + "option2": "액션 아이템은 부담을 줄이기 위해 담당자와 기한 없이 방향성만 적는다", + "option3": "같은 장애가 두 번 나면 담당자의 부주의로 기록하고 교육을 강화한다", + "option4": "'누가 그랬나'가 아니라 '무엇이 그 행동을 합리적으로 보이게 했나'를 묻고, 시스템의 빈틈을 근본 원인으로 판다", + "answerIndex": 3, + "explanation": "사람을 벌하면 실수를 숨기게 되어 학습이 사라지고 같은 장애가 반복됩니다. 그래서 개인이 아니라 '잘못이 검증 없이 운영까지 흘러갈 수 있었던 시스템의 빈틈'을 근본 원인으로 다룹니다. 액션 아이템은 담당자와 기한이 없으면 쓰지 않은 것과 같고, 같은 장애의 재발은 사람 잘못이 아니라 시스템(포스트모템 프로세스) 잘못으로 회고해야 합니다." + }, + { + "courseSlug": "lv4-system-design-url-shortener", + "question": "시스템 설계 문제를 받았을 때 가장 먼저 해야 할 일로 이 강좌가 강조하는 것은 무엇인가요?", + "option1": "컴포넌트 다이어그램을 그려 전체 그림부터 잡는다", + "option2": "기능 요구와 비기능 요구를 질문으로 명확히 한다", + "option3": "익숙한 기술 스택(캐시·샤딩)을 먼저 확정한다", + "option4": "테이블 스키마를 설계해 데이터부터 고정한다", + "answerIndex": 1, + "explanation": "요구사항이 다르면 정답도 다르기 때문입니다. 하루 1만 건짜리 사내 도구와 하루 1억 건짜리 글로벌 서비스는 이름만 같은 다른 문제라서, 그림·스키마·기술 선택은 모두 요구사항(기능·규모·지연·가용성)이 확정된 뒤에야 의미가 있습니다. '일단 그림부터'가 아니라 '일단 질문부터'가 루틴의 출발점입니다." + }, + { + "courseSlug": "lv4-system-design-url-shortener", + "question": "쓰기 하루 100만 건, 읽기:쓰기 = 100:1이라는 가정에서 용량 산정이 내려 준 핵심 결론은 무엇인가요?", + "option1": "저장 용량이 병목이므로 처음부터 DB를 샤딩해야 한다", + "option2": "쓰기가 초당 수천 건이라 쓰기 경로 최적화가 급하다", + "option3": "읽기 편중 시스템이므로 설계 에너지를 읽기 경로(캐시)에 쏟아야 한다", + "option4": "읽기와 쓰기가 비슷한 수준이라 균형 잡힌 설계가 필요하다", + "answerIndex": 2, + "explanation": "계산하면 쓰기는 약 12건/초로 한가한 반면 읽기는 평균 1,200건/초, 피크 수천 건에 이릅니다. 저장 용량도 5년치 약 1TB로 단일 DB가 감당할 수준이라 문제가 아닙니다. 결국 '읽기가 지배한다'는 특성이 캐시 우선 조회, 최단 읽기 경로 등 이후 모든 설계 결정을 지배하게 됩니다." + }, + { + "courseSlug": "lv4-system-design-url-shortener", + "question": "키 생성 서비스(KGS) 패턴에서 키 '범위'를 서버마다 미리 떼어 주는 방식의 핵심 이점은 무엇인가요?", + "option1": "각 서버가 조율 없이 로컬 카운터만으로 충돌 없는 키를 발급할 수 있다", + "option2": "키가 순차적으로 발급되어 다음 키를 쉽게 예측할 수 있다", + "option3": "해시 충돌이 발생해도 재시도 없이 자동으로 해결된다", + "option4": "서버가 죽어도 배정된 범위의 키를 하나도 잃지 않는다", + "answerIndex": 0, + "explanation": "범위를 미리 떼어 주면 각 서버는 자기 범위 안에서 로컬 카운터만 올리면 되므로, 전역 카운터라는 단일 장애점·병목 없이도 충돌이 원천적으로 없습니다. 대신 서버가 죽으면 그 범위는 버려지는데, 62^7 ≈ 3.5조 개라는 넉넉한 키 공간에서 100만 개쯤의 낭비는 조율을 없애는 대가로 기꺼이 지불할 수 있는 비용입니다." + }, + { + "courseSlug": "lv4-system-design-url-shortener", + "question": "클릭 분석이 핵심 상품인 URL 단축 서비스가 리다이렉트에 302를 선택하는 이유는 무엇인가요?", + "option1": "302가 301보다 브라우저 캐시를 더 오래 유지해 서버 부하가 줄기 때문", + "option2": "301은 브라우저가 결과를 캐시해 이후 클릭이 서버에 도달하지 않아 집계가 불가능하기 때문", + "option3": "302만이 HTTPS 환경에서 동작하는 유일한 리다이렉트 코드이기 때문", + "option4": "301은 리다이렉트 속도가 302보다 항상 느리기 때문", + "answerIndex": 1, + "explanation": "301(영구 이동)을 주면 브라우저가 결과를 캐시해서 같은 링크를 다시 눌러도 서버에 오지 않고 목적지로 바로 갑니다. 서버 부하는 줄지만 클릭이 잡히지 않아 통계가 불가능하고, 만료·차단·목적지 변경도 반영되지 않습니다. 302는 매 클릭이 서버를 통과하므로 부하는 그대로지만 분석과 즉시 반영이 가능합니다. 요구사항(분석이 상품)이 상태 코드 하나까지 결정하는 대표적 트레이드오프입니다." + }, + { + "courseSlug": "lv4-system-design-url-shortener", + "question": "존재하지 않는 키를 대량 조회하는 '캐시 관통' 공격에서 블룸 필터가 유효한 방어가 되는 이유는 무엇인가요?", + "option1": "모든 키의 원본 URL을 압축 저장해 DB 조회를 완전히 대체하기 때문", + "option2": "'있다'는 판정이 100% 정확해 캐시 적중률을 끌어올리기 때문", + "option3": "'없다'는 판정은 확실하므로 없는 키 요청을 DB 앞에서 걸러 낼 수 있기 때문", + "option4": "공격자의 IP를 식별해 요청 자체를 차단해 주기 때문", + "answerIndex": 2, + "explanation": "존재하지 않는 키는 캐시에 없으므로 요청이 전부 DB로 관통해 DB가 먼저 쓰러집니다. 블룸 필터는 확률적 자료구조라 '있다'는 답에는 오차(실제로는 없을 수 있음)가 있지만, '없다'는 답만큼은 확실합니다. 이 성질 덕분에 없는 키 폭격을 DB에 닿기 전 메모리 단계에서 걸러 낼 수 있고, 짧은 TTL의 네거티브 캐시와 함께 겹으로 쓰입니다." + }, + { + "courseSlug": "lv4-system-design-chat", + "question": "실시간 시세 스트림에는 SSE가 적합하지만 채팅에는 WebSocket이 더 적합한 근본적인 이유는 무엇인가요?", + "option1": "SSE는 HTTPS에서 동작하지 않기 때문에", + "option2": "SSE는 서버→클라이언트 단방향 스트림이라 방송에는 충분하지만, 채팅은 양방향 저지연 대화가 필요하기 때문에", + "option3": "WebSocket이 SSE보다 항상 대역폭을 적게 쓰기 때문에", + "option4": "SSE는 자동 재접속을 지원하지 않기 때문에", + "answerIndex": 1, + "explanation": "SSE는 서버가 클라이언트로 일방적으로 밀어주는 단방향 스트림이라 시세처럼 '방송'이면 충분합니다. 반면 채팅은 클라이언트도 수시로 메시지를 밀어넣어야 하는 양방향 대화이므로, 하나의 연결 위에서 양쪽 모두 push할 수 있는 WebSocket이 자연스러운 선택입니다. 참고로 SSE는 자동 재접속을 오히려 내장하고 있고, HTTPS에서도 잘 동작합니다." + }, + { + "courseSlug": "lv4-system-design-chat", + "question": "유저 A는 chat-server-1에, 유저 B는 chat-server-7에 WebSocket으로 접속해 있습니다. A의 메시지를 B에게 전달하는 가장 일반적인 확장 가능한 구조는?", + "option1": "로드밸런서가 두 유저를 항상 같은 서버로 몰아 붙인다", + "option2": "chat-server-1이 나머지 모든 서버에 HTTP로 순차 질의해 B를 찾는다", + "option3": "Pub/Sub 백본(예: Valkey)에 채널 메시지를 발행하고, 각 서버가 구독해 자기에게 붙은 참여자에게만 소켓으로 전달한다", + "option4": "B의 브라우저가 1초마다 모든 서버에 폴링해서 새 메시지를 찾는다", + "answerIndex": 2, + "explanation": "Pub/Sub 백본에 메시지를 한 번 발행하면 구독 중인 모든 채팅 서버가 받아, 각자 자기에게 연결된 참여자에게만 fan-out합니다. 서버가 늘어나도 구조가 그대로 확장되죠. traderbox의 MarketBus가 Valkey 채널(market.ticks)로 시세를 각 인스턴스에 뿌리는 것과 같은 구조입니다. 같은 서버로 몰아 붙이는 방식은 방 참여자가 여러 서버에 흩어지는 걸 막을 수 없어 근본 해법이 못 됩니다." + }, + { + "courseSlug": "lv4-system-design-chat", + "question": "채팅 메시지의 순서 보장에 대한 설명으로 옳은 것은?", + "option1": "전 세계 모든 메시지에 전역 순서를 부여해야 사용자 경험이 성립한다", + "option2": "클라이언트 기기가 찍은 타임스탬프를 순서 기준으로 쓰는 것이 가장 정확하다", + "option3": "순서는 채널(방) 단위로만 보장하면 충분하며, 채널 내 단조 증가 ID로 순서·페이지네이션·동기화 커서를 함께 해결할 수 있다", + "option4": "순서 보장은 저장소가 자동으로 해 주므로 설계에서 고려할 필요가 없다", + "answerIndex": 2, + "explanation": "필요한 것은 '같은 방을 보는 사람들이 같은 순서를 보는 것'뿐이므로, 보장 범위를 채널 단위로 좁히면 전역 순서라는 분산 시스템의 난제를 피할 수 있습니다. 채널 내에서만 단조 증가하는 ID 하나로 정렬 순서, 커서 기반 페이지네이션, 오프라인 동기화 커서까지 함께 풀립니다. 클라이언트 시계는 기기마다 어긋나므로(클럭 스큐) 순서 기준으로 쓰면 안 되고 표시용으로만 씁니다." + }, + { + "courseSlug": "lv4-system-design-chat", + "question": "오프라인이었던 유저가 재접속했을 때 밀린 메시지를 받아 가는 방식과, 이때 발생할 수 있는 문제의 대비책으로 옳은 조합은?", + "option1": "마지막 수신 메시지 ID(커서)를 서버에 알려 그 이후 메시지만 받고, 중복 수신에 대비해 클라이언트가 메시지 ID 기준으로 dedupe한다", + "option2": "서버가 모든 이력을 처음부터 다시 전송하고, 중복은 서버가 완벽히 막아 주므로 클라이언트는 신경 쓰지 않는다", + "option3": "오프라인 동안의 메시지는 유실을 허용하고 재접속 이후 것만 받는다", + "option4": "클라이언트가 접속 시각 타임스탬프를 보내면 서버가 그 시각 이후 메시지를 추정해서 보낸다", + "answerIndex": 0, + "explanation": "재접속 시 '내 커서는 여기까지'라고 알리면 서버가 그 이후 메시지만 몰아 전달하는 커서 기반 inbox 동기화가 정석입니다. 다만 실시간 push와 동기화가 겹치거나 재전송이 일어나면 같은 메시지가 두 번 도착할 수 있는데, 네트워크에서 '정확히 한 번 전송'은 불가능하므로 '최소 한 번 전송 + 수신 측이 msgId로 멱등 처리(dedupe)'가 실제 구현 가능한 전달 보장의 모양입니다." + }, + { + "courseSlug": "lv4-system-design-chat", + "question": "읽음 표시와 온라인 상태(presence) 설계에 대한 설명으로 옳은 것은?", + "option1": "읽음은 메시지×참여자마다 read 플래그를 남기는 것이 쓰기 비용이 가장 적다", + "option2": "presence는 메시지 본문과 똑같이 절대 유실·지연되면 안 되는 강한 일관성이 필요하다", + "option3": "읽음은 참여자별 '어디까지 읽음' 커서 하나로 관리하고, presence는 하트비트+TTL 기반의 최종 일관성으로 충분하다", + "option4": "300명 그룹에서 읽음·presence 이벤트를 전원에게 그대로 실시간 중계해도 부하는 무시할 수준이다", + "answerIndex": 2, + "explanation": "읽음을 메시지별 플래그로 만들면 300명 방에서 쓰기가 폭발하지만, 참여자별 커서(포인터) 하나면 몇백 개를 읽어도 UPDATE 한 번이고 안 읽은 수도 '최신 ID − 커서'로 즉시 계산됩니다. presence는 초록 불이 몇 초 늦게 꺼져도 아무 피해가 없으므로 하트비트+TTL의 최종 일관성이면 충분합니다. 반대로 300명 방에서 이벤트를 전원에게 그대로 중계하면 틱당 최대 9만 건(300×300)까지 불어나므로 batch·throttle 같은 절감책이 필요합니다." + }, + { + "courseSlug": "lv4-system-design-newsfeed", + "question": "뉴스피드가 '읽기 폭발형' 시스템이라 불리는 근본 이유로 가장 정확한 것은?", + "option1": "피드 데이터가 이미지·영상 때문에 용량이 크기 때문", + "option2": "쓰기 1건이 팔로워 수만큼의 읽기(노출) 요구로 증폭되는 구조이기 때문", + "option3": "글 작성 트랜잭션이 팔로워 수에 비례해 느려지기 때문", + "option4": "데이터베이스가 시간순 정렬을 지원하지 않기 때문", + "answerIndex": 1, + "explanation": "게시물 1건은 그 작성자의 팔로워 전원의 피드에 나타나야 합니다. 평균 팔로워 200명이면 쓰기 1건이 200건의 피드 노출 요구로 부풀어, 읽기:쓰기 비율이 수백 대 일을 넘는 증폭 구조가 됩니다. 그래서 설계의 중심 질문이 'N개 소스의 병합 정렬 비용을 읽을 때 낼 것인가, 쓸 때 낼 것인가'로 좁혀집니다." + }, + { + "courseSlug": "lv4-system-design-newsfeed", + "question": "풀(pull) 모델과 푸시(push) 모델의 비용 특성을 옳게 짝지은 것은?", + "option1": "풀: 읽기 지연이 팔로잉 수에 비례 / 푸시: 쓰기 비용이 팔로워 수에 비례", + "option2": "풀: 저장 중복이 심하다 / 푸시: 글 원본 한 벌만 저장한다", + "option3": "풀: 게시가 느리다 / 푸시: 조회가 팔로잉 수에 비례해 느려진다", + "option4": "풀: 캐시를 쓸 수 없다 / 푸시: Kafka를 쓸 수 없다", + "answerIndex": 0, + "explanation": "풀 모델은 요청 시 팔로잉 N명의 최신 글을 모아 병합하므로 읽기 지연이 N에 비례하고, 대신 쓰기는 INSERT 1건으로 저렴하며 저장 중복이 없습니다. 푸시 모델은 게시 시점에 팔로워 각자의 피드함에 zadd 하므로 쓰기 비용이 팔로워 수에 비례하고, 대신 조회는 이미 정렬된 피드함을 읽기만 하면 되어 사실상 상수 시간입니다. 병합 비용이 사라지는 게 아니라 읽기↔쓰기 사이로 옮겨지는 것입니다." + }, + { + "courseSlug": "lv4-system-design-newsfeed", + "question": "푸시 모델에서 팬아웃을 게시 API가 직접 수행하지 않고 'Kafka 이벤트 → 팬아웃 워커'의 비동기 파이프라인으로 처리하는 이유는?", + "option1": "Valkey sorted set은 동기 호출로는 쓸 수 없기 때문", + "option2": "Kafka를 거치면 팬아웃 쓰기 총량 자체가 줄어들기 때문", + "option3": "게시 API가 팔로워 수만 명의 피드함 쓰기를 다 마치고 응답하면 게시가 수십 초씩 걸리므로, '수 초 내 반영'이라는 느슨한 요구사항을 근거로 적재를 뒤로 미루는 것", + "option4": "동기로 처리하면 피드 정렬 순서가 뒤섞이기 때문", + "answerIndex": 2, + "explanation": "팬아웃 쓰기 총량은 비동기로 해도 그대로입니다. 핵심은 사용자 경험과 요구사항입니다. 게시 API는 글 저장과 이벤트 발행만 하고 즉시 응답하며, 피드함 적재는 워커가 뒤에서 처리합니다. 이것이 허용되는 근거는 게시 반영 목표가 '즉시'가 아니라 '수 초 내'로 잡혀 있기 때문으로, 요구사항의 한 줄이 아키텍처의 갈래를 결정하는 예입니다." + }, + { + "courseSlug": "lv4-system-design-newsfeed", + "question": "팔로워 1,000만 명의 셀럽 계정 문제에 대한 하이브리드 전략의 설명으로 옳은 것은?", + "option1": "셀럽의 글은 더 많은 워커를 투입해 푸시 팬아웃을 더 빠르게 돌린다", + "option2": "일반 유저는 푸시로 피드함에 적재하고, 셀럽 글은 팬아웃하지 않은 채 조회 시점에 풀로 가져와 피드함과 병합한다", + "option3": "셀럽의 글은 피드에서 제외하고 별도 알림으로만 전달한다", + "option4": "일반 유저는 풀, 셀럽은 푸시로 처리해 쓰기 부하를 분산한다", + "answerIndex": 1, + "explanation": "셀럽 글 1건을 푸시하면 zadd 1,000만 번이 필요해(초당 1만 건 처리 워커 기준 약 17분) '수 초 내 반영' 요구가 무너지고 다른 유저의 팬아웃까지 밀립니다. 그래서 셀럽은 팬아웃하지 않고, 피드를 읽는 시점에 내 피드함(푸시분)과 팔로우한 셀럽들의 최신 글(풀분)을 병합합니다. 셀럽은 극소수라 풀 쪽 병합 소스가 적고, 수백만 명이 같은 글을 읽으니 캐시 적중률도 매우 높습니다. 방향을 뒤집은 4번은 오답입니다." + }, + { + "courseSlug": "lv4-system-design-newsfeed", + "question": "인기 게시물의 좋아요 카운터에서 발생하는 핫키 문제와 그 대응 패턴으로 옳은 것은?", + "option1": "같은 행을 갱신하려는 트랜잭션이 잠금 경합을 일으키므로, 캐시(Valkey)에서 INCR로 집계하고 주기적으로 델타를 DB에 플러시한다", + "option2": "카운터 컬럼에 인덱스를 추가해 UPDATE 속도를 높인다", + "option3": "좋아요 요청을 CDN에서 처리해 DB 접근을 없앤다", + "option4": "정확성이 중요하므로 결제 잔액에도 동일한 캐시 집계 패턴을 적용한다", + "answerIndex": 0, + "explanation": "인기 글 하나에 좋아요가 초당 수만 건 몰리면 같은 행(row)에 대한 UPDATE 잠금 경합으로 DB가 먼저 무너집니다. 그래서 쓰기 폭주는 메모리 연산인 캐시 INCR로 흡수하고, 플러셔가 주기적으로(예: 5초마다) 누적 델타를 DB에 반영합니다. 이 패턴은 플러시 주기만큼의 표시 오차를 허용하는 거래이므로 좋아요 수에는 적합하지만, 정확성이 필수인 결제 잔액에는 쓰면 안 됩니다." + }, + { + "courseSlug": "lv4-tech-leadership-adr", + "question": "ADR(아키텍처 결정 기록)의 기본 구조로 올바른 것은?", + "option1": "요구사항 → 구현 → 테스트", + "option2": "맥락(Context) → 결정(Decision) → 결과(Consequences)", + "option3": "문제 → 원인 → 재발 방지 대책", + "option4": "목표 → 일정 → 담당자", + "answerIndex": 1, + "explanation": "ADR은 '어떤 제약과 상황이었나(맥락) → 무엇을 하기로 했나(결정) → 그 선택으로 무엇을 얻고 무엇을 포기했나(결과)'의 순서로 결정 하나를 기록합니다. 여기에 기각한 대안과 그 이유를 더하면, 3년 뒤에도 사고 과정을 재생할 수 있는 문서가 됩니다. 3번은 장애 포스트모템의 구조에 가깝습니다." + }, + { + "courseSlug": "lv4-tech-leadership-adr", + "question": "'일방향 문(one-way door)'과 '양방향 문(two-way door)' 결정에 대한 설명으로 옳은 것은?", + "option1": "모든 기술 결정은 일방향 문이므로 항상 신중하게 내려야 한다", + "option2": "양방향 문은 되돌리기 어려운 결정이므로 문서와 리뷰가 필수다", + "option3": "되돌리기 쉬운 결정(양방향 문)은 빠르게, 되돌리기 어려운 결정(일방향 문)은 신중하게 통과한다", + "option4": "일방향 문 결정은 팀장만 내릴 수 있다", + "answerIndex": 2, + "explanation": "결정의 무게에 맞는 절차를 고르는 것이 핵심입니다. 라이브러리 선택처럼 틀리면 되돌리면 되는 양방향 문은 빠르게 시도하고, DB 교체·공개 API 계약·서비스 경계처럼 되돌리기 비싼 일방향 문은 문서·대안 비교·리뷰를 거쳐 천천히 통과합니다. 모든 결정을 일방향 문처럼 다루면 팀이 느려지고, 반대면 장기 부채가 생깁니다." + }, + { + "courseSlug": "lv4-tech-leadership-adr", + "question": "이미 승인된 ADR의 결정을 나중에 뒤집게 됐을 때 올바른 처리 방법은?", + "option1": "기존 ADR 파일을 열어 새 결정 내용으로 수정한다", + "option2": "기존 ADR 파일을 삭제하고 새로 작성한다", + "option3": "새 ADR을 작성하고, 기존 ADR은 상태만 'superseded(대체됨)'로 바꿔 남겨 둔다", + "option4": "ADR은 한번 승인되면 어떤 경우에도 뒤집을 수 없다", + "answerIndex": 2, + "explanation": "ADR은 불변 기록입니다. 옛 문서를 고치거나 지우면 '그때 왜 그렇게 판단했는지'라는 역사가 사라집니다. 대신 무엇이 달라져서 재검토하는지를 담은 새 ADR을 쓰고, 기존 ADR의 상태만 'Superseded by ADR-NNN'으로 바꿉니다. 이렇게 하면 저장소에 판단의 역사(결정의 지층)가 쌓입니다." + }, + { + "courseSlug": "lv4-tech-leadership-adr", + "question": "설계 리뷰(디자인 독 리뷰)의 본질적인 목적으로 이 강좌가 강조한 것은?", + "option1": "상급자의 승인 도장을 받는 공식 절차", + "option2": "결함을 코드가 되기 전, 가장 싼 시점에 찾아내는 장치", + "option3": "작성자의 역량을 평가해 등급을 매기는 자리", + "option4": "일정 지연의 책임 소재를 미리 정해 두는 자리", + "answerIndex": 1, + "explanation": "문서 단계의 결함은 문장 몇 줄 고치면 끝나지만, 배포 후의 결함은 장애 대응과 마이그레이션 비용으로 갚아야 합니다. 그래서 리뷰는 승인 절차가 아니라 '결함을 싸게 찾는 장치'이고, 리뷰어가 찾아 준 구멍은 공격이 아니라 가장 싼 시점에 도착한 버그 리포트입니다. 좋은 리뷰어는 '왜 안 했나'보다 '어떤 제약이 있었나'를 묻습니다." + }, + { + "courseSlug": "lv4-tech-leadership-adr", + "question": "기술 부채에 대한 이 강좌의 관점으로 옳은 것은?", + "option1": "부채는 악이므로 리더는 부채를 0으로 만드는 것을 목표로 해야 한다", + "option2": "의도적 부채는 무엇을 미뤘는지 알고 기록과 상환 계획이 있는 부채이며, 리더의 일은 부채의 이자율을 관리하는 것이다", + "option3": "부채 상환은 기술적 문제이므로 경영진에게는 기술 용어 그대로 설명하는 것이 가장 효과적이다", + "option4": "문서와 현실의 불일치(문서 드리프트)는 코드가 아니므로 부채에 해당하지 않는다", + "answerIndex": 1, + "explanation": "부채는 출시를 앞당기려 미래 시간을 당겨 쓰는 대출이며, 문제는 빚 자체가 아니라 이자율을 아무도 관리하지 않는 상태입니다. 의도적 부채(인지·기록·상환 계획 있음)와 무지성 부채(인식조차 없어 장애로 청구서가 오는 부채)를 구분하고, 상환을 로드맵에 올릴 땐 '분기당 3주치 개발력이 이자로 나간다'처럼 사업 언어로 비용을 증명해야 합니다. 문서 드리프트도 잘못된 판단을 유발하는 이자가 붙는 부채입니다." } -] \ No newline at end of file +] diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6bad6e0..7476dd7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,9 +4,11 @@ // 학습 포인트: 코스 라우트는 여기 일일이 쓰지 않는다 — courseCatalog.jsx의 데이터를 .map으로 돌려 // 자동 생성한다. 코스가 100개가 되어도 이 파일은 그대로다. import { Suspense } from 'react'; -import { Routes, Route, NavLink, Navigate, Outlet } from 'react-router-dom'; +import { Routes, Route, NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom'; import { useAuth } from './AuthContext'; import { ALL_COURSES } from './courseCatalog'; +import { LEVEL_COURSES } from './levelCatalog'; +import { TrackProvider, useTrack, TRACK_OPTIONS } from './trackContext'; import CourseShell from './components/CourseShell'; import ScrollToTop from './components/ScrollToTop'; import LoginPage from './pages/LoginPage'; @@ -51,6 +53,33 @@ function RequireMentor() { return <Outlet />; } +// 과정(수습용/초급/중급/고급/특급) 선택 셀렉트 박스 — 헤더에 산다. +// 학습 포인트: 바꾸는 순간 학습 센터로 이동한다 — "과정을 골랐으면 그 과정의 강좌를 +// 바로 보여 준다"가 자연스러운 기대이기 때문. 선택은 localStorage에 저장돼 유지된다. +function TrackSelect() { + const { track, setTrack } = useTrack(); + const navigate = useNavigate(); + + function onChange(e) { + setTrack(e.target.value); + navigate('/learn'); + } + + return ( + <select + className="topnav-track" + value={track} + onChange={onChange} + aria-label="학습 과정 선택" + title="학습 과정 선택" + > + {TRACK_OPTIONS.map((t) => ( + <option key={t.key} value={t.key}>{t.label}</option> + ))} + </select> + ); +} + // 공통 레이아웃: 상단 네비게이션 + 페이지 본문 function Layout() { const { user, logout } = useAuth(); @@ -88,6 +117,8 @@ function Layout() { )} </nav> <div className="topnav-user"> + {/* 학습 과정 선택 — 수습용/초급/중급/고급/특급. 바꾸면 학습 센터로 이동한다. */} + <TrackSelect /> {/* 학습 포인트: 예전엔 {name}{role==='MENTOR' ? ' 멘토' : ''} 였는데, 멘토 계정 이름이 "멘토"라서 화면에 "멘토 멘토"로 나왔다. 이름과 역할은 다른 정보이니 역할은 배지로 분리한다 — 중복도 없어지고 폭도 준다. */} @@ -124,7 +155,7 @@ function Layout() { export default function App() { return ( - <> + <TrackProvider> {/* 주소가 바뀔 때마다 화면을 맨 위로 — 강좌를 클릭하면 페이지 처음부터 보이게. */} <ScrollToTop /> <Routes> @@ -149,6 +180,15 @@ export default function App() { element={<CourseShell course={course} />} /> ))} + {/* 수준별 과정(초급~특급) 강좌 라우트 — 같은 방식으로 자동 생성. + 슬러그가 lv1-, lv2-... 접두어라 수습용 강좌와 절대 겹치지 않는다. */} + {LEVEL_COURSES.map((course) => ( + <Route + key={course.slug} + path={`/learn/${course.slug}`} + element={<CourseShell course={course} />} + /> + ))} {/* 학습 포인트: :slug는 "주소의 이 자리에 오는 값을 변수로 받겠다"는 뜻. 72개 퀴즈 라우트를 하나로 처리한다 — QuizPage가 useParams()로 꺼내 쓴다. */} <Route path="/learn/:slug/quiz" element={<QuizPage />} /> @@ -172,6 +212,6 @@ export default function App() { {/* 그 외 주소는 대시보드로 */} <Route path="*" element={<Navigate to="/" replace />} /> </Routes> - </> + </TrackProvider> ); } diff --git a/frontend/src/levelCatalog.jsx b/frontend/src/levelCatalog.jsx new file mode 100644 index 0000000..62db2b2 --- /dev/null +++ b/frontend/src/levelCatalog.jsx @@ -0,0 +1,137 @@ +// 이 파일이 하는 일: 수준별 과정(초급/중급/고급/특급)의 강좌 카탈로그. +// courseCatalog.jsx(수습용 84강좌)와 같은 구조 — 과정마다 카테고리 1개에 12강좌. +// 학습 포인트: 헤더의 과정 셀렉트(trackContext)가 key(lv1~lv4)를 고르면 +// LearnHubPage가 여기서 그 과정의 강좌 목록을 꺼내 그린다. 라우트는 App.jsx가 +// LEVEL_COURSES를 map으로 돌려 자동 생성한다 — 수습용과 완전히 같은 패턴이다. +import { lazy } from 'react'; + +// ── 초급(lv1) → 특급(lv4) 순서, 각 12강좌 ── +const Lv1WhatIsAProgramPage = lazy(() => import('./pages/levels/Lv1WhatIsAProgramPage')); +const Lv1ComputationalThinkingPage = lazy(() => import('./pages/levels/Lv1ComputationalThinkingPage')); +const Lv1FirstHtmlPage = lazy(() => import('./pages/levels/Lv1FirstHtmlPage')); +const Lv1HtmlLinksImagesListsPage = lazy(() => import('./pages/levels/Lv1HtmlLinksImagesListsPage')); +const Lv1HowBrowsersWorkPage = lazy(() => import('./pages/levels/Lv1HowBrowsersWorkPage')); +const Lv1FirstJavascriptPage = lazy(() => import('./pages/levels/Lv1FirstJavascriptPage')); +const Lv1VariablesAndTypesPage = lazy(() => import('./pages/levels/Lv1VariablesAndTypesPage')); +const Lv1ConditionalsPage = lazy(() => import('./pages/levels/Lv1ConditionalsPage')); +const Lv1LoopsPage = lazy(() => import('./pages/levels/Lv1LoopsPage')); +const Lv1FunctionsPage = lazy(() => import('./pages/levels/Lv1FunctionsPage')); +const Lv1ReadingErrorsPage = lazy(() => import('./pages/levels/Lv1ReadingErrorsPage')); +const Lv1FirstMiniProjectPage = lazy(() => import('./pages/levels/Lv1FirstMiniProjectPage')); +const Lv2AsyncJavascriptPage = lazy(() => import('./pages/levels/Lv2AsyncJavascriptPage')); +const Lv2ArrayDataWranglingPage = lazy(() => import('./pages/levels/Lv2ArrayDataWranglingPage')); +const Lv2RestApiDesignPage = lazy(() => import('./pages/levels/Lv2RestApiDesignPage')); +const Lv2ExpressCrudPage = lazy(() => import('./pages/levels/Lv2ExpressCrudPage')); +const Lv2DbTransactionsPage = lazy(() => import('./pages/levels/Lv2DbTransactionsPage')); +const Lv2AuthSessionHashPage = lazy(() => import('./pages/levels/Lv2AuthSessionHashPage')); +const Lv2ReactStateEffectsPage = lazy(() => import('./pages/levels/Lv2ReactStateEffectsPage')); +const Lv2ReactRoutingPage = lazy(() => import('./pages/levels/Lv2ReactRoutingPage')); +const Lv2FormValidationPage = lazy(() => import('./pages/levels/Lv2FormValidationPage')); +const Lv2ErrorHandlingPatternsPage = lazy(() => import('./pages/levels/Lv2ErrorHandlingPatternsPage')); +const Lv2PracticalTestingPage = lazy(() => import('./pages/levels/Lv2PracticalTestingPage')); +const Lv2MiniProjectDeployPage = lazy(() => import('./pages/levels/Lv2MiniProjectDeployPage')); +const Lv3LayeredArchitecturePage = lazy(() => import('./pages/levels/Lv3LayeredArchitecturePage')); +const Lv3DesignPatternsPage = lazy(() => import('./pages/levels/Lv3DesignPatternsPage')); +const Lv3DbIndexingPage = lazy(() => import('./pages/levels/Lv3DbIndexingPage')); +const Lv3TransactionIsolationPage = lazy(() => import('./pages/levels/Lv3TransactionIsolationPage')); +const Lv3QueryOptimizationPage = lazy(() => import('./pages/levels/Lv3QueryOptimizationPage')); +const Lv3CachingStrategyPage = lazy(() => import('./pages/levels/Lv3CachingStrategyPage')); +const Lv3PerformanceProfilingPage = lazy(() => import('./pages/levels/Lv3PerformanceProfilingPage')); +const Lv3WebSecurityPage = lazy(() => import('./pages/levels/Lv3WebSecurityPage')); +const Lv3AuthArchitecturePage = lazy(() => import('./pages/levels/Lv3AuthArchitecturePage')); +const Lv3ObservabilityPage = lazy(() => import('./pages/levels/Lv3ObservabilityPage')); +const Lv3ZeroDowntimeDeployPage = lazy(() => import('./pages/levels/Lv3ZeroDowntimeDeployPage')); +const Lv3CodeReviewRefactoringPage = lazy(() => import('./pages/levels/Lv3CodeReviewRefactoringPage')); +const Lv4DistributedSystemsBasicsPage = lazy(() => import('./pages/levels/Lv4DistributedSystemsBasicsPage')); +const Lv4MessageQueueEventDrivenPage = lazy(() => import('./pages/levels/Lv4MessageQueueEventDrivenPage')); +const Lv4MonolithVsMicroservicesPage = lazy(() => import('./pages/levels/Lv4MonolithVsMicroservicesPage')); +const Lv4DbReplicationShardingPage = lazy(() => import('./pages/levels/Lv4DbReplicationShardingPage')); +const Lv4HighTrafficArchitecturePage = lazy(() => import('./pages/levels/Lv4HighTrafficArchitecturePage')); +const Lv4KubernetesCloudNativePage = lazy(() => import('./pages/levels/Lv4KubernetesCloudNativePage')); +const Lv4ObservabilityMetricsTracingPage = lazy(() => import('./pages/levels/Lv4ObservabilityMetricsTracingPage')); +const Lv4SreIncidentResponsePage = lazy(() => import('./pages/levels/Lv4SreIncidentResponsePage')); +const Lv4SystemDesignUrlShortenerPage = lazy(() => import('./pages/levels/Lv4SystemDesignUrlShortenerPage')); +const Lv4SystemDesignChatPage = lazy(() => import('./pages/levels/Lv4SystemDesignChatPage')); +const Lv4SystemDesignNewsfeedPage = lazy(() => import('./pages/levels/Lv4SystemDesignNewsfeedPage')); +const Lv4TechLeadershipAdrPage = lazy(() => import('./pages/levels/Lv4TechLeadershipAdrPage')); + +/** key → 그 과정의 카테고리 배열 (courseCatalog의 COURSE_CATEGORIES와 같은 모양) */ +export const LEVEL_TRACKS = { + lv1: [{ + category: '초급 과정 — 코딩 첫걸음', + desc: '코딩이 처음인 사람을 위한 12강좌 — 변수 하나에서 첫 프로그램까지.', + items: [ + { slug: 'lv1-what-is-a-program', icon: '💡', title: '프로그램이란 무엇일까?', desc: '코드를 한 줄도 쓰기 전에, 컴퓨터에게 \'일을 시킨다\'는 게 무슨 뜻인지부터 몸으로 이해해요.', level: '입문', Component: Lv1WhatIsAProgramPage }, + { slug: 'lv1-computational-thinking', icon: '🧩', title: '컴퓨터처럼 생각하기: 컴퓨팅 사고', desc: '코드 없이도 기를 수 있는 문제 해결 근육 — 쪼개고, 패턴을 찾고, 순서를 세우는 법을 배워요.', level: '입문', Component: Lv1ComputationalThinkingPage }, + { slug: 'lv1-first-html', icon: '🌐', title: '나의 첫 웹페이지: HTML 첫걸음', desc: '메모장 수준의 도구만으로 브라우저에 뜨는 \'진짜 내 페이지\'를 오늘 바로 만들어요.', level: '입문', Component: Lv1FirstHtmlPage }, + { slug: 'lv1-html-links-images-lists', icon: '🏗️', title: '태그로 페이지에 살 붙이기', desc: '링크·이미지·목록·표를 넣어서 밋밋한 문서를 \'진짜 웹페이지\'처럼 만들어요.', level: '입문', Component: Lv1HtmlLinksImagesListsPage }, + { slug: 'lv1-how-browsers-work', icon: '🔍', title: '브라우저는 내 코드를 어떻게 읽을까', desc: '주소창에 엔터를 치면 무슨 일이 벌어질까? 개발자 도구를 열고 웹의 뒷면을 구경해요.', level: '입문', Component: Lv1HowBrowsersWorkPage }, + { slug: 'lv1-first-javascript', icon: '✨', title: '나의 첫 JavaScript', desc: '멈춰 있던 페이지가 말을 걸고 반응하기 시작해요 — 첫 코드 한 줄의 짜릿함.', level: '입문', Component: Lv1FirstJavascriptPage }, + { slug: 'lv1-variables-and-types', icon: '📦', title: '변수와 자료형: 이름 붙인 상자', desc: '값을 상자에 담아 두고 꺼내 쓰는 법 — 모든 프로그램의 가장 작은 부품을 배워요.', level: '실전', Component: Lv1VariablesAndTypesPage }, + { slug: 'lv1-conditionals', icon: '🚦', title: '조건문: 컴퓨터가 판단하게 하기', desc: '"만약 ~라면" 한마디로 프로그램이 갈림길에서 스스로 길을 고르게 만들어요.', level: '실전', Component: Lv1ConditionalsPage }, + { slug: 'lv1-loops', icon: '🔁', title: '반복문: 지루한 일은 컴퓨터에게', desc: '100번 반복도 한 줄로 — 컴퓨터가 가장 잘하는 일을 시키는 법을 배워요.', level: '실전', Component: Lv1LoopsPage }, + { slug: 'lv1-functions', icon: '🧰', title: '함수: 나만의 명령어 만들기', desc: '자주 쓰는 코드에 이름을 붙여 언제든 다시 부르는 법 — 진짜 개발자처럼 코드를 조립해요.', level: '실전', Component: Lv1FunctionsPage }, + { slug: 'lv1-reading-errors', icon: '🩹', title: '에러와 친해지기: 빨간 글씨 해독법', desc: '에러는 벌점이 아니라 힌트예요. 메시지를 읽고, 원인을 좁히고, 스스로 고치는 힘을 길러요.', level: '심화', Component: Lv1ReadingErrorsPage }, + { slug: 'lv1-first-mini-project', icon: '🚀', title: '첫 프로젝트: 숫자 맞히기 게임 만들기', desc: '지금까지 배운 전부를 총동원해 처음부터 끝까지 \'내가 만든 웹 게임\'을 완성해요.', level: '프로젝트', Component: Lv1FirstMiniProjectPage }, + ], + }], + lv2: [{ + category: '중급 과정 — 실전 웹 개발', + desc: '돌아가는 웹 서비스를 직접 만드는 12강좌.', + items: [ + { slug: 'lv2-async-javascript', icon: '⏳', title: '비동기 JavaScript: fetch와 Promise 정복', desc: '콜백 지옥에서 async/await까지 — 서버와 대화하는 코드를 제대로 쓰는 법', level: '실전', Component: Lv2AsyncJavascriptPage }, + { slug: 'lv2-array-data-wrangling', icon: '🔪', title: '배열 메서드로 데이터 요리하기', desc: 'map·filter·reduce로 API 응답을 화면에 딱 맞는 데이터로 썰고 볶는 실전 기술', level: '실전', Component: Lv2ArrayDataWranglingPage }, + { slug: 'lv2-rest-api-design', icon: '📐', title: 'REST API 설계: 주소와 동사의 문법', desc: '코드를 짜기 전에 API 명세부터 — 프론트와 백이 싸우지 않는 계약서 쓰는 법', level: '실전', Component: Lv2RestApiDesignPage }, + { slug: 'lv2-express-crud', icon: '🛠️', title: 'Express로 CRUD API 만들기', desc: '설계한 명세를 진짜 돌아가는 서버로 — 라우터·미들웨어·계층 분리까지 한 번에', level: '실전', Component: Lv2ExpressCrudPage }, + { slug: 'lv2-db-transactions', icon: '🗄️', title: 'DB 연동과 트랜잭션: 데이터가 사는 집', desc: '서버를 껐다 켜도 살아남는 데이터 — SQL 연동부터 all-or-nothing 트랜잭션까지', level: '심화', Component: Lv2DbTransactionsPage }, + { slug: 'lv2-auth-session-hash', icon: '🔐', title: '로그인 구현: 세션과 비밀번호 해시', desc: '비밀번호를 지키고 사용자를 기억하는 법 — 회원가입부터 보호 라우트까지 직접 구현', level: '심화', Component: Lv2AuthSessionHashPage }, + { slug: 'lv2-react-state-effects', icon: '⚛️', title: 'React 상태와 이펙트 실전', desc: '상태를 \'설계\'하는 단계로 — 불변 업데이트, useEffect 데이터 페칭, 커스텀 훅까지', level: '실전', Component: Lv2ReactStateEffectsPage }, + { slug: 'lv2-react-routing', icon: '🧭', title: 'React Router로 멀티 페이지 SPA', desc: '주소창이 곧 상태다 — URL 파라미터, 중첩 레이아웃, 보호 라우트로 진짜 앱 골격 만들기', level: '실전', Component: Lv2ReactRoutingPage }, + { slug: 'lv2-form-validation', icon: '✅', title: '폼 검증: 사용자를 돕고 서버를 지키기', desc: '검증은 두 번 한다 — 프론트에서 친절하게, 서버에서 단호하게. 이중 검증 완전 정복', level: '실전', Component: Lv2FormValidationPage }, + { slug: 'lv2-error-handling-patterns', icon: '🚨', title: '에러 처리 패턴: 무너지지 않는 서비스', desc: '에러는 없앨 수 없다, 다스릴 뿐 — 백엔드 에러 미들웨어부터 프론트 폴백 UI까지', level: '심화', Component: Lv2ErrorHandlingPatternsPage }, + { slug: 'lv2-practical-testing', icon: '🧪', title: '테스트 작성 실전: 자신 있게 고치는 힘', desc: '고칠 때마다 손이 떨리지 않으려면 — 단위·API·컴포넌트 테스트를 실제 코드에 붙이기', level: '심화', Component: Lv2PracticalTestingPage }, + { slug: 'lv2-mini-project-deploy', icon: '🚀', title: '미니 프로젝트: 설계부터 배포까지', desc: '11주간 만든 조각을 하나로 — 기획·조립·배포·회고까지, 포트폴리오가 되는 첫 서비스', level: '프로젝트', Component: Lv2MiniProjectDeployPage }, + ], + }], + lv3: [{ + category: '고급 과정 — 아키텍처·성능·보안', + desc: '"잘 만드는 법"을 파고드는 12강좌.', + items: [ + { slug: 'lv3-layered-architecture', icon: '🏛️', title: '레이어드 아키텍처와 관심사 분리', desc: '돌아가는 코드에서 오래 사는 코드로 — 계층으로 변경을 가두는 법.', level: '심화', Component: Lv3LayeredArchitecturePage }, + { slug: 'lv3-design-patterns', icon: '🧩', title: '디자인 패턴 실전', desc: '외우는 패턴 말고 쓰는 패턴 — \'이 문제엔 이 해법\'을 우리 코드로.', level: '실전', Component: Lv3DesignPatternsPage }, + { slug: 'lv3-db-indexing', icon: '🔍', title: 'DB 인덱스와 실행계획 읽기', desc: '쿼리가 왜 느린지 감으로 찍지 말고 EXPLAIN으로 증명하는 법.', level: '심화', Component: Lv3DbIndexingPage }, + { slug: 'lv3-transaction-isolation', icon: '🔒', title: '트랜잭션과 격리 수준', desc: '동시에 밀려드는 요청 속에서 돈과 데이터를 지키는 규칙.', level: '심화', Component: Lv3TransactionIsolationPage }, + { slug: 'lv3-query-optimization', icon: '🐢', title: 'N+1과 쿼리 최적화', desc: '목록 하나 여는데 쿼리 수백 개 — ORM이 숨긴 재앙을 걷어내기.', level: '실전', Component: Lv3QueryOptimizationPage }, + { slug: 'lv3-caching-strategy', icon: '⚡', title: '캐싱 전략 — 로컬과 Redis', desc: '빠르게 만드는 마법이 아니라, 무효화와 일관성을 감당하는 설계.', level: '심화', Component: Lv3CachingStrategyPage }, + { slug: 'lv3-performance-profiling', icon: '📈', title: '성능 측정과 프로파일링', desc: '추측하지 말고 측정하라 — 병목은 늘 예상 밖에 숨어 있다.', level: '실전', Component: Lv3PerformanceProfilingPage }, + { slug: 'lv3-web-security', icon: '🛡️', title: '웹 보안 심화 — OWASP Top 10', desc: '공격자의 눈으로 내 서비스를 보고, 계층으로 방어하는 법.', level: '심화', Component: Lv3WebSecurityPage }, + { slug: 'lv3-auth-architecture', icon: '🔑', title: '인증 아키텍처 — 세션 vs JWT vs OAuth', desc: '쿠키냐 토큰이냐, 무상태의 자유와 대가를 저울질하는 설계.', level: '심화', Component: Lv3AuthArchitecturePage }, + { slug: 'lv3-observability', icon: '🔭', title: '로깅·모니터링·알림 설계', desc: '무엇이 느린가와 왜 느린가를 나눠 답하는 관측 가능성.', level: '실전', Component: Lv3ObservabilityPage }, + { slug: 'lv3-zero-downtime-deploy', icon: '🚦', title: '무중단 배포와 롤백', desc: '배포가 무섭지 않아야 자주 배포한다 — 되돌릴 수 있게 나가는 법.', level: '실전', Component: Lv3ZeroDowntimeDeployPage }, + { slug: 'lv3-code-review-refactoring', icon: '🧭', title: '코드 리뷰와 리팩터링 전략', desc: '이 과정의 종착점 — 설계·성능·보안·운영을 리뷰 한 판에 녹여내기.', level: '프로젝트', Component: Lv3CodeReviewRefactoringPage }, + ], + }], + lv4: [{ + category: '특급 과정 — 대규모 시스템', + desc: '대용량·분산 환경을 설계하는 전문가 12강좌.', + items: [ + { slug: 'lv4-distributed-systems-basics', icon: '🌐', title: '분산 시스템 첫걸음 — 왜 서버 한 대로는 안 될까', desc: '서버가 여러 대가 되는 순간 생기는 근본 문제들 — CAP, 일관성 모델, 부분 실패를 몸으로 이해합니다.', level: '심화', Component: Lv4DistributedSystemsBasicsPage }, + { slug: 'lv4-message-queue-event-driven', icon: '📨', title: '메시지 큐와 이벤트 드리븐 — 시스템을 느슨하게 잇는 법', desc: '동기 호출의 한계를 넘어 Kafka 같은 메시지 브로커로 시스템을 분리하고, 이벤트 중심으로 사고하는 법을 배웁니다.', level: '심화', Component: Lv4MessageQueueEventDrivenPage }, + { slug: 'lv4-monolith-vs-microservices', icon: '🧩', title: '모놀리스 vs 마이크로서비스 — 쪼개는 것의 진짜 비용', desc: '유행이 아니라 트레이드오프로 아키텍처를 고릅니다. 서비스 경계 긋기, 분산 트랜잭션, 그리고 \'쪼개지 않을 용기\'까지.', level: '심화', Component: Lv4MonolithVsMicroservicesPage }, + { slug: 'lv4-db-replication-sharding', icon: '🗄️', title: 'DB 레플리케이션과 샤딩 — 데이터베이스를 늘리는 두 가지 축', desc: '읽기를 늘리는 복제, 쓰기를 늘리는 샤딩. 복제 지연과 핫 샤드 같은 부작용까지 다루는 데이터 확장 설계.', level: '심화', Component: Lv4DbReplicationShardingPage }, + { slug: 'lv4-high-traffic-architecture', icon: '🚦', title: '대용량 트래픽 설계 — 밀려오는 요청 앞에서 무너지지 않기', desc: '로드밸런싱, CDN, 캐시 계층, 레이트리밋, 백프레셔까지 — 트래픽이 10배가 되어도 버티는 구조를 설계합니다.', level: '심화', Component: Lv4HighTrafficArchitecturePage }, + { slug: 'lv4-kubernetes-cloud-native', icon: '☸️', title: '쿠버네티스와 클라우드 네이티브 — 선언하면 알아서 굴러가는 인프라', desc: 'docker compose 다음 단계. 파드·서비스·디플로이먼트부터 오토스케일링까지, 선언형 운영의 사고방식을 익힙니다.', level: '심화', Component: Lv4KubernetesCloudNativePage }, + { slug: 'lv4-observability-metrics-tracing', icon: '🔭', title: '관측성 — 보이지 않으면 고칠 수 없다', desc: '메트릭·로그·트레이스 세 기둥으로 분산 시스템 내부를 들여다봅니다. \'요청이 어디서 느려졌나\'에 답하는 기술.', level: '심화', Component: Lv4ObservabilityMetricsTracingPage }, + { slug: 'lv4-sre-incident-response', icon: '🚨', title: 'SRE와 장애 대응 — 무너져도 빨리 일어나는 팀', desc: 'SLO와 에러 버짓으로 신뢰성을 숫자로 관리하고, 장애를 침착하게 지휘하고, 비난 없는 포스트모템으로 배우는 법.', level: '심화', Component: Lv4SreIncidentResponsePage }, + { slug: 'lv4-system-design-url-shortener', icon: '🔗', title: '시스템 설계 훈련 1 — URL 단축기를 처음부터 끝까지', desc: '가장 고전적인 설계 문제로 \'요구사항 → 용량 산정 → API → 데이터 → 확장\'의 설계 루틴을 몸에 새깁니다.', level: '프로젝트', Component: Lv4SystemDesignUrlShortenerPage }, + { slug: 'lv4-system-design-chat', icon: '💬', title: '시스템 설계 훈련 2 — 실시간 채팅 시스템', desc: '연결이 오래 유지되는 실시간 시스템의 설계. WebSocket, 메시지 순서, 읽음 표시, 오프라인 동기화까지 파고듭니다.', level: '프로젝트', Component: Lv4SystemDesignChatPage }, + { slug: 'lv4-system-design-newsfeed', icon: '📰', title: '시스템 설계 훈련 3 — 뉴스피드와 타임라인', desc: '푸시 vs 풀, 팬아웃, 랭킹, 셀럽 문제 — 읽기 폭발형 시스템의 끝판왕 설계로 3부작을 완성합니다.', level: '프로젝트', Component: Lv4SystemDesignNewsfeedPage }, + { slug: 'lv4-tech-leadership-adr', icon: '🧭', title: '기술 리더십과 ADR — 결정을 설계하고 기록하는 사람', desc: '특급 과정의 마무리. 아키텍처 결정 기록(ADR), 설계 리뷰 문화, 트레이드오프 커뮤니케이션으로 팀을 움직이는 개발자가 됩니다.', level: '심화', Component: Lv4TechLeadershipAdrPage }, + ], + }], +}; + +/** 모든 수준별 강좌를 평평하게 — 라우트 생성과 퀴즈 제목 조회에 쓴다. */ +export const LEVEL_COURSES = Object.values(LEVEL_TRACKS) + .flat() + .flatMap((c) => c.items.filter((i) => i.slug)); diff --git a/frontend/src/pages/CertificatePage.jsx b/frontend/src/pages/CertificatePage.jsx index 9f44407..2220b75 100644 --- a/frontend/src/pages/CertificatePage.jsx +++ b/frontend/src/pages/CertificatePage.jsx @@ -29,7 +29,11 @@ export default function CertificatePage() { ]) .then(([course, quiz, coding, subs]) => { if (cancelled) return; - const completedCourses = course.data.length; + // 수료증은 "수습 과정" 증명이므로, 완료 수를 수습용 강좌로만 센다. + // 학습 포인트: 서버의 완료 목록에는 수준별 과정(lv1-~lv4-) 강좌도 섞여 올 수 있다. + // 그대로 세면 "90/84"처럼 분모를 넘는 수가 나온다 — 분자와 분모의 모집단을 맞춘다. + const traineeSlugs = new Set(ALL_COURSES.map((c) => c.slug)); + const completedCourses = course.data.filter((s) => traineeSlugs.has(s)).length; const quizPerfect = quiz.data.filter((q) => q.total > 0 && q.bestScore === q.total).length; setStats({ completedCourses, diff --git a/frontend/src/pages/LearnHubPage.jsx b/frontend/src/pages/LearnHubPage.jsx index 3a1533a..5e34c5a 100644 --- a/frontend/src/pages/LearnHubPage.jsx +++ b/frontend/src/pages/LearnHubPage.jsx @@ -1,19 +1,39 @@ -// 이 파일이 하는 일: "학습 센터" 허브 — courseCatalog의 데이터를 카테고리별 카드로 그리고, -// 내 코스 완료 현황(서버)을 얹어 진도율을 보여준다. -// 학습 포인트: 코스 목록(정적 데이터)은 courseCatalog.jsx에, 완료 여부(사용자별 데이터)는 -// 서버 DB에 있다 — "무엇이 어디에 사는 게 맞는가"를 구분하는 것이 설계의 시작이다. +// 이 파일이 하는 일: "학습 센터" 허브 — 선택한 과정(수습용/초급/중급/고급/특급)의 강좌를 +// 카테고리별 카드로 그리고, 내 코스 완료 현황(서버)을 얹어 진도율을 보여준다. +// 학습 포인트: 코스 목록(정적 데이터)은 courseCatalog/levelCatalog에, 완료 여부(사용자별 +// 데이터)는 서버 DB에 있다 — "무엇이 어디에 사는 게 맞는가"를 구분하는 것이 설계의 시작이다. import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import client from '../api/client'; -import { COURSE_CATEGORIES, ALL_COURSES } from '../courseCatalog'; +import { COURSE_CATEGORIES } from '../courseCatalog'; +import { LEVEL_TRACKS } from '../levelCatalog'; +import { useTrack, TRACK_OPTIONS } from '../trackContext'; + +// 과정별 히어로 소개 문구 — 과정이 바뀌면 인사말도 바뀐다. +const TRACK_INTRO = { + trainee: '하드웨어에서 네트워크, 서버, 코드, 디자인까지 — 아래에서 위로 쌓는 순서 그대로.', + lv1: '코딩이 처음이어도 괜찮아요 — 변수 하나부터, "나 코딩 할 수 있네"까지.', + lv2: '문법을 아는 사람에서 "돌아가는 서비스를 만드는 사람"으로 — 실전 웹 개발.', + lv3: '만드는 법 다음은 잘 만드는 법 — 아키텍처·성능·보안으로 한 단계 위로.', + lv4: '대규모 트래픽과 분산 환경을 설계하는 시야 — 전문가의 사고법.', +}; export default function LearnHubPage() { + const { track } = useTrack(); // 완료한 코스 slug들 — 포함 여부 확인이 잦으니 Set이 제격 (대시보드와 같은 패턴) const [completedSlugs, setCompletedSlugs] = useState(new Set()); - // 지금 보고 있는 카테고리. 처음엔 첫 카테고리(컴퓨터 기초)를 연다. - // 학습 포인트: 코스 83개를 한 번에 다 펼치면 압도적이라, 카테고리 하나씩만 보여준다. - // "한 화면에 담을 정보의 양"을 줄이는 게 직관성의 핵심이다. - const [activeCategory, setActiveCategory] = useState(COURSE_CATEGORIES[0].category); + + // 현재 과정의 카테고리 목록. 수습용은 7개, 수준별 과정은 1개(12강좌). + const categories = track === 'trainee' ? COURSE_CATEGORIES : LEVEL_TRACKS[track] || []; + const trackLabel = TRACK_OPTIONS.find((t) => t.key === track)?.label || '수습용'; + + // 지금 보고 있는 카테고리. 과정이 바뀌면 그 과정의 첫 카테고리로 리셋한다. + // 학습 포인트: 코스를 한 번에 다 펼치면 압도적이라, 카테고리 하나씩만 보여준다. + const [activeCategory, setActiveCategory] = useState(categories[0]?.category); + useEffect(() => { + setActiveCategory(categories[0]?.category); + // eslint-disable-next-line react-hooks/exhaustive-deps -- track이 바뀔 때만 리셋하면 충분 + }, [track]); useEffect(() => { let cancelled = false; @@ -25,21 +45,22 @@ export default function LearnHubPage() { }; }, []); - const totalDone = ALL_COURSES.filter((c) => completedSlugs.has(c.slug)).length; - const totalCount = ALL_COURSES.length; + // 진도율은 "지금 보고 있는 과정" 기준으로 센다. + const trackCourses = categories.flatMap((c) => c.items.filter((i) => i.slug)); + const totalDone = trackCourses.filter((c) => completedSlugs.has(c.slug)).length; + const totalCount = trackCourses.length; const percent = totalCount === 0 ? 0 : Math.round((totalDone / totalCount) * 100); // 선택된 카테고리 (없으면 첫 번째로 안전하게 폴백) - const group = - COURSE_CATEGORIES.find((g) => g.category === activeCategory) || COURSE_CATEGORIES[0]; + const group = categories.find((g) => g.category === activeCategory) || categories[0]; return ( <div> <div className="hero"> - <div className="eyebrow">Learning Center</div> + <div className="eyebrow">Learning Center · {trackLabel}</div> <h1>어썸데브 학습 센터</h1> <p> - 하드웨어에서 네트워크, 서버, 코드, 디자인까지 — 아래에서 위로 쌓는 순서 그대로. + {TRACK_INTRO[track] || TRACK_INTRO.trainee}{' '} 코스를 다 읽으면 하단의 <strong>"✓ 이 코스 완료하기"</strong>를 눌러 진도를 남기세요. </p> <div className="chip-row" style={{ alignItems: 'center' }}> @@ -55,7 +76,7 @@ export default function LearnHubPage() { </div> <div className="chip-row"> <span className="chip">카드의 번호(01, 02…)가 배우는 순서예요</span> - <span className="chip">카테고리당 12강좌</span> + <span className="chip">헤더에서 과정(초급~특급)을 바꿀 수 있어요</span> <span className="chip">강좌당 40~90분</span> </div> {/* 회사 한 줄 소개 — 대시보드와 같은 문구(홈페이지 모토) */} @@ -64,10 +85,16 @@ export default function LearnHubPage() { </p> </div> + {/* 아직 강좌가 없는 과정(준비 중)일 때의 안내 */} + {categories.length === 0 && ( + <p className="empty">이 과정의 강좌를 준비하고 있어요. 곧 만나요!</p> + )} + {/* 카테고리 선택 탭 — 누른 카테고리의 강좌만 아래에 보여준다. - 각 탭에 "이름 + 완료/전체"를 함께 표시해 어디를 얼마나 했는지 한눈에. */} + 각 탭에 "이름 + 완료/전체"를 함께 표시해 어디를 얼마나 했는지 한눈에. + (수준별 과정은 카테고리가 1개라 탭이 하나만 보인다 — 그래도 진도 표시는 유용하다.) */} <div className="tabs"> - {COURSE_CATEGORIES.map((g) => { + {categories.map((g) => { const gDone = g.items.filter((i) => i.slug && completedSlugs.has(i.slug)).length; return ( <button @@ -83,7 +110,7 @@ export default function LearnHubPage() { </div> {/* 선택된 카테고리 하나만 렌더 */} - {(() => { + {group && (() => { const groupDone = group.items.filter((i) => i.slug && completedSlugs.has(i.slug)).length; return ( <div key={group.category}> diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 7e43bd4..e02cbc5 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -8,11 +8,14 @@ import { useEffect, useState } from 'react'; import { Link, useParams, Navigate } from 'react-router-dom'; import client from '../api/client'; import { ALL_COURSES } from '../courseCatalog'; +import { LEVEL_COURSES } from '../levelCatalog'; export default function QuizPage() { // 주소의 :slug 로 어느 코스의 퀴즈인지 알아낸다. (라우팅은 App.jsx 참고) const { slug } = useParams(); - const course = ALL_COURSES.find((c) => c.slug === slug); + // 수습용·수준별 과정 어느 쪽 강좌든 찾는다 (퀴즈 주소는 /learn/:slug/quiz 하나로 통일). + const course = + ALL_COURSES.find((c) => c.slug === slug) || LEVEL_COURSES.find((c) => c.slug === slug); const [questions, setQuestions] = useState([]); const [answers, setAnswers] = useState({}); // {문항id: 고른 보기 번호} diff --git a/frontend/src/pages/levels/Lv1ComputationalThinkingPage.jsx b/frontend/src/pages/levels/Lv1ComputationalThinkingPage.jsx new file mode 100644 index 0000000..23fd0ec --- /dev/null +++ b/frontend/src/pages/levels/Lv1ComputationalThinkingPage.jsx @@ -0,0 +1,464 @@ +// 이 파일이 하는 일: "컴퓨터처럼 생각하기: 컴퓨팅 사고" 코스 — 코딩을 한 번도 안 해 본 +// 사람을 위한 초급 과정의 첫 강좌. 코드를 한 줄도 쓰지 않고, 문제를 쪼개고(분해), +// 반복을 찾고(패턴 인식), 중요한 것만 남기고(추상화), 순서를 세우는(알고리즘) 네 가지 +// 생각 기술을 7개 섹션으로 안내하는 정적 학습 페이지. 마지막엔 자판기·횡단보도 같은 +// 실생활 문제를 순서도로 그려 보며 "다음 강좌부터 이 그림이 코드가 된다"로 연결한다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제(순서도·의사코드)는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 상수들 ── +// 학습 포인트: 이 강좌의 '코드'는 전부 한국어 의사코드와 그림(ASCII)이다. +// 프로그래밍 언어 문법이 하나도 안 나와도, 컴퓨팅 사고는 충분히 연습할 수 있다. + +const CODE_FOUR_PILLARS = `컴퓨팅 사고의 네 기둥 +───────────────────────────────────────────── +① 분해 큰 문제를 풀 수 있는 크기의 작은 문제들로 쪼갠다 +② 패턴 인식 쪼갠 조각들 사이에서 반복되는 모양을 찾는다 +③ 추상화 목적에 필요 없는 정보는 지우고 중요한 것만 남긴다 +④ 알고리즘 남은 것들을 순서 있는 해결 절차로 늘어세운다 + +포인트: ①→②→③→④ 순서로 생각이 흘러가요. +쪼개야 패턴이 보이고, 패턴이 보여야 무엇이 중요한지 알고, +중요한 것만 남아야 깔끔한 순서(알고리즘)를 세울 수 있습니다.`; + +const CODE_PARTY = `문제: "친구 생일파티를 준비하자" ← 막막하다! + +쪼개 보기 (1단계) +├─ 장소 정하기 +├─ 사람 부르기 +├─ 먹을 것 준비하기 +└─ 선물·이벤트 준비하기 + +더 쪼개 보기 (2단계: "먹을 것 준비하기"만) +├─ 몇 명이 오는지 세기 +├─ 메뉴 정하기 (알레르기 있는 친구 확인!) +├─ 케이크 주문하기 +└─ 음료 사 오기 + +포인트: "생일파티 준비"는 못 해도 +"몇 명이 오는지 세기"는 지금 당장 할 수 있어요. +막막함은 문제가 어려워서가 아니라, 문제가 커서 생깁니다.`; + +const CODE_PATTERN = `친구 20명에게 초대장을 보내야 한다면? + +민지에게 보내기: 이름 쓰기 → 날짜 쓰기 → 장소 쓰기 → 보내기 +서준에게 보내기: 이름 쓰기 → 날짜 쓰기 → 장소 쓰기 → 보내기 +하윤에게 보내기: 이름 쓰기 → 날짜 쓰기 → 장소 쓰기 → 보내기 +... (17번 더) + +어? 이름만 바뀌고 나머지는 똑같네? ← 이게 '패턴 발견' + +패턴으로 다시 쓰기: +"친구 목록의 각 사람에 대해 반복한다. + 그 사람 이름 쓰기 → 날짜 쓰기 → 장소 쓰기 → 보내기" + +20줄짜리 일이 2줄짜리 규칙이 됐어요. +컴퓨터는 이런 '반복 규칙'을 실행하는 데 세계 최강입니다.`; + +const CODE_SUBWAY = `실제 서울 지도 지하철 노선도 +───────────────────────────────────────────── +강의 굽이굽이 모양 → (지운다) +건물·산·도로 → (지운다) +역 사이의 실제 거리 → (지운다) +역의 이름과 순서 → 남긴다! +어디서 갈아타는지 → 남긴다! + +노선도는 '틀린 지도'가 아니라 +"목적지에 가려면 몇 정거장, 어디서 환승?"이라는 +질문에만 답하도록 다듬어진 지도예요. + +이렇게 목적에 맞춰 중요한 것만 남기는 일 = 추상화`; + +const CODE_FLOW_SYMBOLS = `순서도(플로차트)의 기본 기호 3가지 + +( 시작 / 끝 ) ← 둥근 모양: 여기서 출발, 여기서 종료 +[ 처 리 ] ← 네모: 실제로 '하는 일' (물 붓기, 문 열기) +< 판 단 ? > ← 마름모: 예/아니오로 갈라지는 질문 + +화살표(→)로 기호들을 이으면 '해결의 흐름'이 됩니다. + +예: 라면 끓이기 +(시작) → [물 550ml 붓기] → [끓이기] + → <물이 끓는가?> ─ 아니오 → 다시 [끓이기]로 + └ 예 → [면·스프 넣기] → [4분 기다리기] → (끝)`; + +const CODE_TWO_WAYS = `같은 문제: "1부터 10까지 모두 더하라" + +알고리즘 A (하나씩 더하기) +1+2=3, 3+3=6, 6+4=10, ... 9번 더해서 55 + +알고리즘 B (짝지어 더하기 — 수학자 가우스의 방법) +1+10=11, 2+9=11, 3+8=11, 4+7=11, 5+6=11 +→ 11이 5쌍이니까 11 x 5 = 55 + +둘 다 정답 55에 도착하지만, B가 훨씬 빨라요. +같은 문제라도 알고리즘은 여러 개 — +그중 더 좋은 것을 고르는 안목도 컴퓨팅 사고의 일부입니다.`; + +const CODE_PSEUDO_IF = `의사코드: 문법 없이 한국어로 쓰는 코드 + +── 조건은 "만약 ~라면 / 아니면" ── + +만약 비가 온다면 + 우산을 챙긴다 +아니면 + 모자를 쓴다 + +만약 편의점 결제 금액이 포인트보다 작거나 같다면 + 포인트로 결제한다 +아니면 + 카드로 결제한다 + +포인트: 컴퓨터의 판단은 전부 이 모양이에요. +"만약 (예/아니오 질문) 이라면 → 이 길, 아니면 → 저 길"`; + +const CODE_PSEUDO_LOOP = `── 반복은 "~할 때까지 반복" / "~에 대해 각각" ── + +물이 끓을 때까지 반복한다 + 3초 기다린다 + 물 상태를 확인한다 + +신발장의 각 신발에 대해 반복한다 + 먼지를 턴다 + 제자리에 넣는다 + +비밀번호가 맞을 때까지 반복한다 + 비밀번호를 입력받는다 + 만약 5번 틀렸다면 + 계정을 잠그고 반복을 멈춘다 + +포인트: 조건(만약)과 반복(~까지)만 있으면 +세상 대부분의 절차를 적을 수 있습니다. 진짜예요.`; + +const CODE_VENDING = `자판기를 순서도 언어로 읽어 보기 + +(시작) + → [돈 투입을 기다린다] + → <투입 금액 >= 음료 가격 ?> + 아니오 → [금액 부족 표시] → 다시 [돈 투입을 기다린다]로 ← 반복! + 예 → [버튼 입력을 기다린다] + → <선택한 음료 재고가 있는가?> + 아니오 → [품절 표시] → 다시 [버튼 입력을 기다린다]로 + 예 → [음료를 내보낸다] → [거스름돈을 내보낸다] → (끝) + +우리가 배운 게 전부 들어 있죠? +마름모 2개 = 조건, "다시 ~로" = 반복, 네모들 = 처리. +자판기 설계자는 코드를 쓰기 전에 먼저 이 그림을 그렸을 거예요.`; + +const CODE_PRACTICE = `실습(50분 중 15분): 엘리베이터를 순서도 한 장으로 + +상황: 3층에 서 있던 엘리베이터. 누군가 7층 버튼을 눌렀다. + +종이에 그려 볼 것: +(시작) 과 (끝)은 어디인가? +[처리] 네모에 들어갈 일은? (문 닫기, 위로 이동, 문 열기 ...) +<판단> 마름모에 들어갈 질문은? (현재 층 == 목표 층 ?) +"아직 아니오"일 때 어디로 돌아가는가? ← 반복이 숨어 있다! + +힌트: 다 그렸다면 "누군가 문 사이에 서 있다면?"을 추가해 보세요. +판단 마름모가 하나 더 생길 거예요. 현실의 예외를 그림에 +반영하는 것 — 그게 바로 개발자가 매일 하는 일입니다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다 — 사실 이것도 '패턴 인식'이다. +// 섹션 7개가 전부 같은 구조(번호·제목·부제·본문)라는 패턴을 찾아, 하나의 부품으로 만들었다. +function Section({ n, title, sub, children }) { + return ( + <section className="step-card" id={`sec-${n}`}> + <div className="step-head"> + <span className="step-num">{n}</span> + <div> + <h3>{title}</h3> + {sub && <span className="step-sub">{sub}</span>} + </div> + </div> + <div className="step-body">{children}</div> + </section> + ); +} + +function Code({ children }) { + return <pre className="code-block"><code>{children}</code></pre>; +} + +const SECTIONS = [ + { n: 1, label: '컴퓨팅 사고란' }, + { n: 2, label: '분해' }, + { n: 3, label: '패턴 인식' }, + { n: 4, label: '추상화' }, + { n: 5, label: '알고리즘' }, + { n: 6, label: '의사코드' }, + { n: 7, label: '순서도 실습' }, +]; + +export default function Lv1ComputationalThinkingPage() { + return ( + <div> + {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} + <div className="hero"> + <div className="eyebrow">Course · 초급 과정</div> + <h1>컴퓨터처럼 생각하기<br />: 컴퓨팅 사고 🧩</h1> + <p> + 코딩의 첫 수업인데, 오늘은 <strong>코드를 한 줄도 쓰지 않아요.</strong> 대신 코드보다 + 먼저 필요한 것 — 문제를 <strong>쪼개고</strong>, <strong>패턴을 찾고</strong>,{' '} + <strong>순서를 세우는</strong> 생각의 근육을 기릅니다. 이 근육은 코딩 없이도 기를 수 + 있고, 한번 붙으면 어떤 언어를 배우든 그대로 씁니다. 시험공부 계획을 세우는 데도요. + </p> + <div className="chip-row"> + <span className="chip">예상 소요 50분</span> + <span className="chip">실습: 엘리베이터 순서도 그리기</span> + <span className="chip">선수 지식: 없음 — 진짜 첫걸음</span> + </div> + </div> + + {/* 섹션 바로가기 */} + <nav className="pill-nav"> + {SECTIONS.map((s) => ( + <a key={s.n} href={`#sec-${s.n}`}> + <b>{s.n}</b> + {s.label} + </a> + ))} + </nav> + + <Section n={1} title="컴퓨팅 사고란 무엇인가" sub="코딩보다 먼저 필요한 생각의 기술"> + <p> + <strong>컴퓨팅 사고(Computational Thinking)</strong>는 "컴퓨터처럼 계산을 빨리 하는 + 능력"이 아니에요. <strong>컴퓨터에게 시킬 수 있을 만큼, 문제를 명확하게 정리하는 + 사람의 생각 기술</strong>입니다. 컴퓨터는 놀랍도록 빠르지만 눈치가 전혀 없어서, + "알아서 잘 해 줘"가 통하지 않아요. 그래서 시키는 사람이 문제를 대신 정리해 줘야 + 하고, 그 정리법에는 이름 붙은 네 가지 기둥이 있습니다. + </p> + <Code>{CODE_FOUR_PILLARS}</Code> + <p> + 이 네 가지가 이번 강좌의 전부예요. 섹션 2~5에서 하나씩 만나고, 섹션 6~7에서 합쳐서 + 써 봅니다. 중요한 건, 이 기둥들이 <strong>컴퓨터 문제에만 쓰이는 게 아니라는 것</strong>. + 방 청소, 시험공부 계획, 동아리 행사 준비 — 일상의 막막한 문제에 그대로 적용됩니다. + 실제로 이번 시간의 연습 문제들은 전부 일상에서 가져올 거예요. + </p> + <div className="tip"> + <b>왜 코딩보다 먼저?</b> 프로그래밍 언어는 '표현 수단'이고, 컴퓨팅 사고는 '표현할 + 내용'이에요. 내용 없이 수단부터 배우면, 문법은 아는데 뭘 만들지 모르는 상태가 됩니다. + 거꾸로 생각이 서 있으면, 언어는 그 생각을 받아 적는 도구일 뿐이에요. 다음 강좌부터 + 배울 HTML·JavaScript가 훨씬 덜 무서워집니다. + </div> + </Section> + + <Section n={2} title="분해 — 큰 문제를 잘게 쪼개기" sub="작은 문제는 풀 수 있는 크기가 된다"> + <p> + "친구 생일파티를 준비해 줘"라는 말을 들으면 잠깐 막막하죠. 그런데 그 막막함의 정체를 + 들여다보면, 문제가 <strong>어려운</strong> 게 아니라 <strong>큰</strong> 거예요. + 분해(Decomposition)는 그 큰 덩어리를, 하나하나는 만만해 보일 때까지 쪼개는 + 기술입니다. + </p> + <Code>{CODE_PARTY}</Code> + <p> + 쪼개는 순간 두 가지가 생깁니다. 첫째, <strong>시작점</strong>이 생겨요. "파티 + 준비"는 어디서부터 손대야 할지 모르지만 "오는 사람 수 세기"는 지금 바로 할 수 + 있죠. 둘째, <strong>나눠 맡기</strong>가 가능해져요. 케이크는 내가, 초대장은 네가 — + 실제 개발 현장에서 여러 명이 한 프로그램을 함께 만들 수 있는 이유도, 큰 문제가 + 작은 조각들로 분해되어 있기 때문입니다. + </p> + <div className="tip"> + <b>얼마나 잘게?</b> 정답 기준이 하나 있어요 — <strong>"이건 그냥 하면 되네"라는 + 생각이 들 때까지.</strong> 조각을 보고도 여전히 막막하면 아직 덜 쪼갠 거예요. + "메뉴 정하기"가 막막하면 "알레르기 있는 친구 확인 → 후보 3개 적기 → 투표"로 한 번 + 더 쪼개면 됩니다. 나중에 배울 프로그래밍의 '함수'가 정확히 이 조각 하나에 + 해당해요. + </div> + </Section> + + <Section n={3} title="패턴 인식 — 반복되는 것 찾기" sub="한 번 푼 방법을 재사용하는 습관"> + <p> + 문제를 쪼개 놓고 조각들을 늘어놓으면, 신기하게도 <strong>비슷하게 생긴 조각들</strong>이 + 눈에 들어오기 시작해요. 초대장 20장 보내기, 수학 문제집의 같은 유형 10문제, 매주 + 반복되는 주번 활동 — 패턴 인식(Pattern Recognition)은 이 "어? 또 이 모양이네?"를 + 알아차리는 눈입니다. + </p> + <Code>{CODE_PATTERN}</Code> + <p> + 패턴을 찾으면 일이 <strong>절반 이하로</strong> 줄어요. 20개의 서로 다른 일이 아니라 + "규칙 1개 + 반복 20번"이 되니까요. 그리고 더 큰 보상이 있습니다 — <strong>비슷한 + 문제는 비슷한 해법을 가진다</strong>는 사실이에요. 자판기에서 "돈이 모자라면 더 + 기다린다"를 풀어 봤다면, 엘리베이터의 "목표 층이 아니면 더 움직인다"도 같은 + 모양이라는 걸 알아보게 됩니다. 한 번 푼 방법이 재산이 되는 거죠. + </p> + <div className="tip"> + <b>오늘부터 하는 연습</b> 숙제나 집안일을 하다가 "아까도 이거 했는데"라는 기분이 + 들면 멈추고 적어 보세요. <strong>무엇이 똑같고, 무엇만 바뀌는지.</strong> 위의 초대장 + 예시에선 '이름'만 바뀌었죠. 이 "바뀌는 부분"에 이름을 붙이는 것이 나중에 배울 + '변수'의 정체입니다. 벌써 프로그래밍의 절반을 만난 셈이에요. + </div> + </Section> + + <Section n={4} title="추상화 — 중요한 것만 남기기" sub="지하철 노선도가 실제 지도가 아닌 이유"> + <p> + 지하철 노선도를 떠올려 보세요. 실제 서울 지도와 비교하면 '엉터리'예요. 노선은 자로 잰 + 듯 직선이고, 역 사이 간격도 실제 거리와 다르고, 한강은 대충 그려져 있죠. 그런데 + 아무도 불평하지 않습니다. 왜일까요? + </p> + <Code>{CODE_SUBWAY}</Code> + <p> + 노선도의 목적은 "어느 역에서 타고, 어디서 갈아타는가"라는 질문에 답하는 것뿐이에요. + 그 목적에 강의 모양이나 실제 거리는 필요 없으니 <strong>과감히 버린</strong> 겁니다. + 이렇게 <strong>목적에 필요한 정보만 남기고 나머지를 지우는 일</strong>이 + 추상화(Abstraction)예요. 버려서 부정확해지는 게 아니라, 버려서 비로소 쓸모 있어지는 + 것 — 단순화가 곧 이해력입니다. + </p> + <p> + 섹션 7에서 그릴 순서도도 마찬가지예요. 엘리베이터의 모터 종류, 케이블 굵기, 버튼 + 색깔은 전부 지우고 <strong>"층 버튼에 어떻게 반응하는가"라는 목적에 필요한 동작만</strong>{' '} + 남길 겁니다. 무엇을 버릴지 결정하는 것도 실력이에요. + </p> + <div className="tip"> + <b>추상화는 이미 쓰고 있다</b> 친구에게 학교 가는 길을 알려 줄 때 "전봇대 37개를 + 지나서"라고 하지 않죠. "편의점에서 좌회전"처럼 <strong>길 찾기에 중요한 것만</strong>{' '} + 말합니다. 그게 추상화예요. 컴퓨팅 사고는 새 능력을 심는 게 아니라, 이미 쓰고 있는 + 능력에 이름을 붙여 의식적으로 꺼내 쓰게 만드는 과정에 가깝습니다. + </div> + </Section> + + <Section n={5} title="알고리즘 — 순서 있는 해결책" sub="시작 → 처리 → 판단 → 끝의 흐름"> + <p> + 이제 재료가 다 모였어요. 문제를 쪼갰고(분해), 반복을 찾았고(패턴), 필요 없는 건 + 버렸습니다(추상화). 마지막 기둥은 남은 조각들을 <strong>순서대로 늘어세우는 것</strong> — + 이 순서 있는 해결 절차를 <strong>알고리즘(Algorithm)</strong>이라고 부릅니다. 거창한 + 말 같지만 요리 레시피, 가구 조립 설명서, 라면 봉지 뒷면이 전부 알고리즘이에요. + </p> + <p> + 알고리즘을 그림으로 나타낸 것이 <strong>순서도(플로차트)</strong>입니다. 기호는 딱 + 세 개만 기억하면 돼요. + </p> + <Code>{CODE_FLOW_SYMBOLS}</Code> + {/* 학습 포인트: 순서도의 '판단 마름모'는 예/아니오 두 갈래로만 갈라진다. + 아래 SVG도 그 규칙대로 — 끓는가? 에서 예/아니오 두 화살표만 나간다. */} + <svg className="diagram" viewBox="0 0 640 210" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="라면 끓이기 순서도. 시작에서 물 붓기, 끓이기로 진행하고, 물이 끓는가라는 판단에서 아니오면 끓이기로 되돌아가고 예면 면과 스프를 넣고 끝난다."> + <rect x="20" y="80" width="90" height="44" rx="22" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" /> + <text x="65" y="107" textAnchor="middle" fontSize="13" fontWeight="600" fill="var(--ink)">시작</text> + + <rect x="140" y="80" width="100" height="44" rx="8" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" /> + <text x="190" y="107" textAnchor="middle" fontSize="12" fill="var(--ink)">물 붓기</text> + + <rect x="270" y="80" width="100" height="44" rx="8" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" /> + <text x="320" y="107" textAnchor="middle" fontSize="12" fill="var(--ink)">끓이기</text> + + <polygon points="455,66 520,102 455,138 390,102" fill="var(--card)" stroke="var(--amber)" strokeWidth="2" /> + <text x="455" y="106" textAnchor="middle" fontSize="12" fontWeight="600" fill="var(--ink)">끓는가?</text> + + <rect x="545" y="80" width="80" height="44" rx="22" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" /> + <text x="585" y="100" textAnchor="middle" fontSize="12" fontWeight="600" fill="var(--ink)">면·스프</text> + <text x="585" y="116" textAnchor="middle" fontSize="11" fill="var(--muted)">→ 끝</text> + + <line x1="110" y1="102" x2="134" y2="102" stroke="var(--line)" strokeWidth="2" /> + <path d="M140 102 L130 97 L130 107 Z" fill="var(--muted)" /> + <line x1="240" y1="102" x2="264" y2="102" stroke="var(--line)" strokeWidth="2" /> + <path d="M270 102 L260 97 L260 107 Z" fill="var(--muted)" /> + <line x1="370" y1="102" x2="384" y2="102" stroke="var(--line)" strokeWidth="2" /> + <path d="M390 102 L380 97 L380 107 Z" fill="var(--muted)" /> + + <line x1="520" y1="102" x2="539" y2="102" stroke="var(--teal)" strokeWidth="2" /> + <path d="M545 102 L535 97 L535 107 Z" fill="var(--teal)" /> + <text x="532" y="92" textAnchor="middle" fontSize="11" fill="var(--teal)">예</text> + + {/* 아니오: 판단에서 아래로 내려가 '끓이기'로 되돌아가는 반복 화살표 */} + <line x1="455" y1="138" x2="455" y2="176" stroke="var(--amber)" strokeWidth="2" strokeDasharray="5 4" /> + <line x1="455" y1="176" x2="320" y2="176" stroke="var(--amber)" strokeWidth="2" strokeDasharray="5 4" /> + <line x1="320" y1="176" x2="320" y2="130" stroke="var(--amber)" strokeWidth="2" strokeDasharray="5 4" /> + <path d="M320 124 L315 134 L325 134 Z" fill="var(--amber)" /> + <text x="390" y="168" textAnchor="middle" fontSize="11" fill="var(--amber)">아니오 — 다시 끓이기 (반복!)</text> + </svg> + <p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: 마름모(판단)에서 '아니오'가 뒤로 돌아가는 화살표 — 순서도에 반복이 그려지는 방식</p> + <p> + 한 가지 더. 알고리즘엔 <strong>정답이 하나가 아니에요.</strong> 같은 문제라도 도착만 + 한다면 여러 길이 있고, 그중엔 더 빠르고 우아한 길이 있습니다. + </p> + <Code>{CODE_TWO_WAYS}</Code> + <div className="tip"> + <b>좋은 알고리즘의 조건</b> ① <strong>명확할 것</strong> — 눈치 없는 컴퓨터도 헷갈리지 + 않게, 각 단계가 하나의 뜻만 갖기. ② <strong>끝날 것</strong> — "끓을 때까지"처럼 + 반복엔 반드시 빠져나오는 조건이 있어야 해요. 빠져나올 수 없는 반복은 영원히 도는 + '무한 루프'가 됩니다(프로그램이 멈추는 흔한 원인!). ③ <strong>같은 입력엔 같은 + 결과</strong> — 어제는 되고 오늘은 안 되는 절차는 절차가 아니죠. + </div> + </Section> + + <Section n={6} title="의사코드 — 한국어로 쓰는 코드" sub="문법 걱정 없이 논리에만 집중하는 연습"> + <p> + 순서도가 알고리즘의 '그림'이라면, <strong>의사코드(pseudocode)</strong>는 알고리즘의 + '글'이에요. 진짜 프로그래밍 언어가 아니라, <strong>사람 말(한국어)로 코드 흉내를 내며 + 쓰는 글</strong>입니다. 세미콜론이 어쩌고 괄호가 어쩌고 하는 문법 걱정이 0이라서, + 순수하게 <strong>논리에만</strong> 집중할 수 있어요. 실제 개발자들도 복잡한 문제 앞에선 + 코드보다 의사코드를 먼저 씁니다. + </p> + <p> + 재료는 놀랍게도 두 가지면 충분해요. 갈림길을 만드는 <strong>조건</strong>과, 되돌아가게 + 하는 <strong>반복</strong>. + </p> + <Code>{CODE_PSEUDO_IF}</Code> + <Code>{CODE_PSEUDO_LOOP}</Code> + <p> + 눈치챘나요? 조건의 "만약 ~라면"은 순서도의 <strong>마름모</strong>이고, "~할 때까지 + 반복"은 <strong>뒤로 돌아가는 화살표</strong>예요. 순서도와 의사코드는 같은 생각을 + 그림으로 그리느냐, 글로 쓰느냐의 차이일 뿐입니다. 그리고 다음 강좌들에서 만날 진짜 + 코드의 <span className="icode">if</span>와 <span className="icode">while</span>은, 이 + 한국어 문장을 영어 낱말로 바꿔 쓴 것에 지나지 않아요. + </p> + <div className="tip"> + <b>직접 해 보기 (5분)</b> "아침에 일어나서 학교 갈 준비"를 의사코드 7줄 이내로 써 + 보세요. 조건("만약 늦잠을 잤다면 → 아침은 생략한다")과 반복("가방에 오늘 시간표의 + 각 과목 교과서를 넣는다")이 최소 하나씩 들어가면 성공입니다. 잘 써졌는지 확인하는 + 법: <strong>동생에게 그대로 시켜 보기.</strong> 동생이 "이게 무슨 말이야?"라고 하는 + 줄이 바로 컴퓨터도 못 알아들을 줄이에요. + </div> + </Section> + + <Section n={7} title="실생활 문제를 순서도로 그리기" sub="이 그림이 다음 강좌에서 코드가 된다"> + <p> + 이제 네 기둥을 전부 합쳐 봅시다. 매일 스치는 기계들 — 자판기, 횡단보도 신호등, + 엘리베이터 — 이 전부 <strong>누군가 그린 순서도가 코드가 되어 돌아가는 것</strong>들이에요. + 먼저 자판기를 우리가 배운 언어로 읽어 볼게요. + </p> + <Code>{CODE_VENDING}</Code> + <p> + 횡단보도 신호등도 같은 눈으로 볼 수 있어요. "보행 버튼이 눌렸는가?"(조건) → + "차량 신호를 노란불로" → "빨간불로" → "보행 초록불 켜기" → "30초를 셀 때까지 + 반복하며 숫자 표시" → 다시 처음으로(전체가 큰 반복). <strong>조건과 반복이 억지로 + 끼워 넣은 게 아니라, 세상의 동작을 정직하게 적기만 해도 자연스럽게 등장한다</strong>는 + 걸 느끼는 것이 이 섹션의 목표입니다. + </p> + <p>이제 여러분 차례예요. 이번 강좌의 실습입니다.</p> + <Code>{CODE_PRACTICE}</Code> + <div className="tip"> + <b>제출 전 자가 점검</b> ① 시작과 끝이 둥근 모양으로 있는가? ② 마름모에서 나가는 + 화살표에 <strong>예/아니오가 둘 다</strong> 붙어 있는가? ③ "아니오"가 갈 곳을 잃고 + 허공에 떠 있지 않은가? ④ 어떤 경로로 따라가도 언젠가 (끝)에 도착하는가? — ④가 + 안 되면 무한 루프입니다. 손가락으로 화살표를 따라가며 직접 검사해 보세요. 이 + '손가락 실행'이 개발자들이 말하는 디버깅의 원형이에요. + </div> + <div className="warn"> + <b>완벽한 그림은 없어요</b> 같은 엘리베이터라도 그리는 사람마다 순서도가 다릅니다 + — 알고리즘은 여러 개일 수 있다고 했죠(섹션 5). 친구와 그림을 비교하며 "너는 왜 + 여기서 판단을 넣었어?"를 이야기하는 것이 정답 맞히기보다 훨씬 좋은 공부입니다. + </div> + </Section> + + <div className="step-card"> + <div className="step-body"> + <h3 style={{ marginBottom: 6 }}>🧩 여기까지 왔다면</h3> + <p className="muted"> + 코드를 한 줄도 안 썼는데, 여러분은 이미 개발자의 하루를 살아 봤어요. 문제를 + 쪼개고(분해), 반복을 찾고(패턴), 필요 없는 걸 버리고(추상화), 순서를 세워 + 그림으로 그렸으니까요(알고리즘·순서도). 이 네 근육은 앞으로 배울 모든 것의 + 바탕이 됩니다. 그런데 머릿속 순서도를 <strong>진짜 화면</strong>으로 만들려면, + 이제 컴퓨터가 알아듣는 표현 수단이 하나 필요해요. 다음{' '} + <Link to="/learn/lv1-first-html"><strong>나의 첫 웹페이지: HTML 첫걸음</strong></Link>{' '} + 강좌에서, 오늘 세운 생각을 브라우저에 처음으로 띄워 봅시다. + </p> + </div> + </div> + </div> + ); +} diff --git a/frontend/src/pages/levels/Lv1ConditionalsPage.jsx b/frontend/src/pages/levels/Lv1ConditionalsPage.jsx new file mode 100644 index 0000000..4dc3972 --- /dev/null +++ b/frontend/src/pages/levels/Lv1ConditionalsPage.jsx @@ -0,0 +1,475 @@ +// 이 파일이 하는 일: "조건문: 컴퓨터가 판단하게 하기" 코스 — 코딩을 처음 접하는 수습을 위해, +// '비가 오면 우산을 챙긴다'라는 일상 문장에서 출발해 if → else → else if → 비교·논리 연산자 → +// 매표소/놀이기구 실습 → 흔한 함정까지 7개 섹션으로 안내하는 정적 학습 페이지. +// 프레이밍은 "프로그램에 갈림길 만들기" — 지금까지 위에서 아래로만 흐르던 코드가, +// 조건문을 만나 스스로 길을 고르는 순간을 눈으로 확인시킨다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_DAILY = `일상의 문장 프로그램의 문장 +──────────────────────────────────────────────────── +"비가 오면, 우산을 챙긴다" → if (비가 온다) 우산을 챙긴다 +"배가 고프면, 밥을 먹는다" → if (배가 고프다) 밥을 먹는다 +"신호가 초록불이면, 건넌다" → if (신호 === 초록불) 건넌다 + +공통 구조: [조건]이 맞으면 → [행동]을 한다 + +핵심: 우리는 하루에도 수십 번 이 판단을 해요. +조건문은 그 판단을 컴퓨터에게 맡기는 문법입니다. +순서도에서 마름모(판단)를 그리던 바로 그 자리가, 코드에선 if가 됩니다.`; + +const CODE_IF_SHAPE = `// if 문의 생김새 — 딱 세 부분만 기억하세요 +if (조건) { + // 조건이 true(참)일 때만 실행되는 코드 +} + +// ① if : "만약"이라는 신호탄 +// ② (조건) : 괄호 안에서 true/false를 판정 +// ③ { ... } : 중괄호로 묶인 '블록' — 조건이 참일 때 실행할 묶음`; + +const CODE_IF_BASIC = `let temperature = 33; + +if (temperature >= 30) { + console.log("폭염이에요. 물을 챙기세요!"); +} + +console.log("오늘도 좋은 하루!"); + +// temperature가 33이니 30 이상 → 조건이 true → 블록 안이 실행됩니다. +// 출력: +// 폭염이에요. 물을 챙기세요! +// 오늘도 좋은 하루! +// +// 만약 temperature가 25였다면? 조건이 false → 블록을 '건너뛰고' +// 마지막 줄만 실행돼요. if는 지우는 게 아니라 건너뛰는 문법입니다.`; + +const CODE_ELSE = `let number = 7; + +if (number % 2 === 0) { + console.log("짝수입니다."); +} else { + console.log("홀수입니다."); +} + +// % 는 '나머지 연산자' — 7 % 2 는 7을 2로 나눈 나머지, 즉 1이에요. +// 나머지가 0이면 짝수, 아니면 홀수. 조건이 false라서 else 쪽이 실행됩니다. +// 출력: 홀수입니다. +// +// 중요: if 블록과 else 블록은 '둘 중 반드시 하나만' 실행돼요. +// 둘 다 실행되거나, 둘 다 건너뛰는 일은 없습니다.`; + +const CODE_ELSEIF = `let score = 85; + +if (score >= 90) { + console.log("A 등급"); +} else if (score >= 80) { + console.log("B 등급"); +} else if (score >= 70) { + console.log("C 등급"); +} else { + console.log("재도전!"); +} + +// 위에서부터 차례로 검사합니다. +// 85 >= 90 ? false → 다음으로 +// 85 >= 80 ? true → "B 등급" 출력, 나머지는 쳐다보지도 않고 끝! +// +// 하나가 걸리는 순간 전체 사다리를 빠져나온다 — 이게 else if의 규칙이에요.`; + +const CODE_ELSEIF_ORDER = `// 순서를 뒤집으면 어떻게 될까요? (일부러 틀린 코드) +let score = 95; + +if (score >= 70) { // 95 >= 70 ? true → 여기서 걸림! + console.log("C 등급"); // 95점인데 C 등급이 나와 버립니다 +} else if (score >= 80) { // 영원히 도달 못 함 + console.log("B 등급"); +} else if (score >= 90) { // 영원히 도달 못 함 + console.log("A 등급"); +} + +// 95점도 "70 이상"이긴 하니까, 제일 먼저 만난 조건에 걸려 버려요. +// 교훈: 겹치는 조건들은 '좁은(빡빡한) 조건부터' 위에 두세요. +// 조건의 순서가 결과를 바꿉니다.`; + +const CODE_COMPARE = `// = 와 === 는 이름만 비슷할 뿐, 하는 일이 완전히 달라요. +let age = 15; // = : 넣기(대입). "age라는 상자에 15를 넣어라" +age === 15; // === : 비교하기. "age가 15와 같은가?" → true/false + +// 자주 쓰는 비교 연산자들 +age === 15 // 같다 → true +age !== 20 // 다르다 → true +age > 10 // 크다(초과) → true +age >= 15 // 크거나 같다(이상) → true +age < 20 // 작다(미만) → true +age <= 14 // 작거나 같다(이하) → false + +// 비교의 결과는 언제나 true 아니면 false, 딱 두 가지뿐이에요. +// if의 괄호는 바로 이 true/false를 받아서 갈림길을 정합니다.`; + +const CODE_LOGICAL = `// 조건 여러 개를 묶는 접착제 — 논리 연산자 +let height = 145; +let age = 13; + +height >= 120 && age >= 8 // && (그리고): 둘 다 true여야 true +height >= 160 || age >= 8 // || (또는) : 하나만 true여도 true +!(height >= 160) // ! (부정) : true/false를 뒤집는다 + +// 위 값들을 계산해 보면? +// 145 >= 120 && 13 >= 8 → true && true → true +// 145 >= 160 || 13 >= 8 → false || true → true +// !(145 >= 160) → !false → true +// +// 말로 옮기면 외우기 쉬워요. +// && = "키도 되고 '그리고' 나이도 되면" +// || = "회원이거나 '또는' 초대장이 있으면" +// ! = "미성년자가 '아니면'"`; + +const CODE_TICKET = `// 매표소 프로그램 — prompt로 나이를 받아 요금을 안내한다 +let input = prompt("나이를 입력하세요:"); +let age = Number(input); // prompt는 글자("15")를 주니 숫자로 변환 + +if (age < 8) { + alert("무료입니다. 즐겁게 관람하세요!"); +} else if (age < 20) { + alert("청소년 요금 5,000원입니다."); +} else { + alert("성인 요금 9,000원입니다."); +} + +// 읽는 법: 8살 미만 → 무료 / (8살 이상이면서) 20살 미만 → 청소년 / 그 외 → 성인. +// else if에 "age >= 8" 을 또 쓰지 않아도 되는 이유 — +// 첫 조건(age < 8)에서 이미 걸러졌기 때문에, 여기 도착했다는 것 자체가 +// '8살 이상'이라는 뜻이거든요. 위에서부터 차례로 검사하는 성질 덕분입니다.`; + +const CODE_RIDE = `// 놀이기구 탑승 판별기 — 조건을 '말'로 먼저 쓰고, 코드로 옮긴다 +// 말: "키가 120cm 이상 '그리고' 나이가 8살 이상이면 탈 수 있다" + +let height = Number(prompt("키(cm)를 입력하세요:")); +let age = Number(prompt("나이를 입력하세요:")); + +if (height >= 120 && age >= 8) { + alert("탑승 가능합니다! 안전바를 꼭 잡으세요."); +} else { + alert("아쉽지만 탑승할 수 없어요. 다음에 만나요!"); +} + +// 경계값을 직접 시험해 보세요. +// 키 딱 120, 나이 8 → 120 >= 120 은 true → 탑승 가능 (>= 는 '이상') +// 키 119.9, 나이 8 → false && true → 탑승 불가 +// 키 150, 나이 7 → true && false → 탑승 불가 (&&는 둘 다 통과해야!) +// +// 만약 조건을 height > 120 으로 썼다면? 딱 120cm인 친구는 못 타요. +// '이상(>=)'과 '초과(>)'의 한 글자 차이가 현실의 한 사람을 가릅니다.`; + +const CODE_PITFALL_ASSIGN = `// 함정 ①: = 를 하나만 쓴 비교 (일부러 틀린 코드) +let level = 1; + +if (level = 5) { // 비교가 아니라 '대입'이 되어 버림! + console.log("최고 레벨!"); // 항상 실행됩니다 +} + +// level = 5 는 "level에 5를 넣어라"라는 뜻이고, 그 결과값 5는 +// true 취급을 받아서 조건이 '항상 참'이 돼요. 게다가 level 값까지 +// 5로 바뀌어 버리죠. 비교할 땐 반드시 === 세 개! +if (level === 5) { console.log("이게 진짜 비교"); }`; + +const CODE_PITFALL_BRACES = `// 함정 ②: 중괄호를 생략했을 때의 착각 (일부러 헷갈리는 코드) +let isRaining = false; + +if (isRaining) + console.log("우산을 챙기세요."); + console.log("장화도 신으세요."); // 이 줄은 if와 무관하게 '항상' 실행! + +// 들여쓰기가 같아서 둘 다 if에 묶인 것처럼 '보이지만', +// 중괄호가 없으면 if는 바로 다음 '한 줄'만 책임져요. +// 출력: 장화도 신으세요. (비가 안 오는데도!) +// +// 해결: 한 줄이어도 무조건 중괄호로 묶는 습관 +if (isRaining) { + console.log("우산을 챙기세요."); + console.log("장화도 신으세요."); +}`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( + <section className="step-card" id={`sec-${n}`}> + <div className="step-head"> + <span className="step-num">{n}</span> + <div> + <h3>{title}</h3> + {sub && <span className="step-sub">{sub}</span>} + </div> + </div> + <div className="step-body">{children}</div> + </section> + ); +} + +function Code({ children }) { + return <pre className="code-block"><code>{children}</code></pre>; +} + +const SECTIONS = [ + { n: 1, label: '일상 속 조건문' }, + { n: 2, label: 'if 기본형' }, + { n: 3, label: 'else' }, + { n: 4, label: 'else if' }, + { n: 5, label: '비교·논리 연산자' }, + { n: 6, label: '실습: 탑승 판별기' }, + { n: 7, label: '흔한 함정' }, +]; + +export default function Lv1ConditionalsPage() { + return ( + <div> + {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} + <div className="hero"> + <div className="eyebrow">Course · 초급 과정</div> + <h1>🚦 조건문<br />— 컴퓨터가 판단하게 하기</h1> + <p> + 지금까지 우리가 쓴 코드는 <strong>위에서 아래로 한 길</strong>만 걸었어요. 이번 시간엔 + 그 길에 <strong>갈림길</strong>을 만듭니다. "만약 ~라면" 한마디, 즉{' '} + <strong>조건문(if)</strong>을 배우면 프로그램이 상황을 보고{' '} + <strong>스스로 길을 고르기</strong> 시작해요. 나이에 따라 요금을 다르게 알려 주는 + 매표소 프로그램까지, 직접 만들어 봅시다. + </p> + <div className="chip-row"> + <span className="chip">예상 소요 60분</span> + <span className="chip">실습: 매표소·놀이기구 판별기</span> + <span className="chip">선수 지식: 변수와 자료형</span> + </div> + </div> + + {/* 섹션 바로가기 — 학습 포인트: SECTIONS 배열 하나로 목차를 그린다. 항목 추가도 배열 한 줄. */} + <div className="chip-row"> + {SECTIONS.map((s) => ( + <a key={s.n} className="chip" href={`#sec-${s.n}`}> + <b>{s.n}</b> {s.label} + </a> + ))} + </div> + + <Section n={1} title="일상은 조건문 투성이" sub="'비가 오면 우산을 챙긴다'의 구조 분석"> + <p> + 코드를 보기 전에, 오늘 아침을 떠올려 보세요. 하늘을 보고 <strong>"비가 오면 + 우산을 챙긴다"</strong>, 시계를 보고 "늦었으면 뛴다", 급식 메뉴를 보고 "돈가스면 + 두 줄을 선다" — 우리는 하루에도 수십 번씩 <strong>조건을 보고 행동을 고르는 판단</strong>을 + 해요. 이 문장들을 가만히 뜯어 보면 전부 같은 뼈대입니다. + </p> + <Code>{CODE_DAILY}</Code> + <p> + 뼈대는 <strong>[조건] → [행동]</strong> 두 칸이에요. 앞 칸엔 "맞다/아니다"로 답할 수 + 있는 <strong>질문</strong>이 오고, 뒤 칸엔 그 답이 "맞다"일 때 할 <strong>일</strong>이 + 옵니다. 수학 시간에 순서도를 그려 봤다면 <strong>마름모</strong> 기억나죠? 판단을 + 맡던 그 마름모가, 코드에서는 <span className="icode">if</span> 한 단어가 됩니다. + </p> + <div className="tip"> + <b>연습</b> 코드를 쓰기 전에, 오늘 내가 한 판단을 세 개만 "만약 ~라면, ~한다" 꼴로 + 적어 보세요. 예: "만약 폰 배터리가 20% 아래라면, 충전기를 꽂는다." 이 문장을 쓸 수 + 있다면 조건문의 절반은 이미 배운 거예요. 나머지 절반은 이걸 컴퓨터의 말투로 옮기는 + 것뿐입니다. + </div> + </Section> + + <Section n={2} title="if 문 기본형" sub="조건이 true일 때만 실행되는 블록"> + <p> + 이제 컴퓨터의 말투로 옮겨 봅시다. 자바스크립트의 조건문은{' '} + <span className="icode">if</span>로 시작하고, 생김새는 딱 세 부분이에요. + </p> + <Code>{CODE_IF_SHAPE}</Code> + <p> + 괄호 안의 <strong>조건</strong>은 계산하면 반드시 <span className="icode">true</span>(참) + 아니면 <span className="icode">false</span>(거짓)가 되는 식이에요. 그리고 중괄호{' '} + <span className="icode">{'{ }'}</span>로 묶인 부분을 <strong>블록(block)</strong>이라고 + 부릅니다. "조건이 참이면 <strong>이 묶음을 통째로</strong> 실행해라"라는 단위죠. + 블록 안에는 한 줄이든 열 줄이든 넣을 수 있어요. + </p> + <Code>{CODE_IF_BASIC}</Code> + <p> + 꼭 기억할 점: 조건이 <span className="icode">false</span>면 블록 안의 코드는{' '} + <strong>실행되지 않고 건너뛰어질 뿐</strong>, 사라지는 게 아니에요. 프로그램은 블록 + 다음 줄부터 아무 일 없었다는 듯 계속 갑니다. 위 예제에서 마지막 줄{' '} + <span className="icode">"오늘도 좋은 하루!"</span>가 온도와 상관없이 항상 출력되는 + 이유예요. + </p> + <div className="tip"> + <b>비유</b> if 블록은 <strong>지하철의 임산부 배려석 안내방송</strong> 같아요. 조건에 + 해당하는 순간에만 "켜지는" 구역이죠. 방송이 안 나온다고 그 좌석이 없어진 게 아니듯, + 조건이 false라고 코드가 지워지는 게 아니라 <strong>이번엔 조용히 지나갈 뿐</strong>입니다. + </div> + </Section> + + <Section n={3} title="else: 아니면 이렇게" sub="두 갈래 중 반드시 하나만 실행된다"> + <p> + "비가 오면 우산을 챙긴다"의 다음 문장은 자연스럽게 이거죠 — "<strong>아니면</strong>{' '} + 선크림을 바른다". 이 '아니면'이 바로 <span className="icode">else</span>입니다.{' '} + <span className="icode">if</span> 뒤에 붙여서, 조건이 <span className="icode">false</span>일 + 때 갈 <strong>두 번째 길</strong>을 만들어요. 변수 시간에 잠깐 만났던 나머지 연산자{' '} + <span className="icode">%</span>를 다시 불러와서, 짝수/홀수 판별기를 만들어 봅시다. + </p> + <Code>{CODE_ELSE}</Code> + <p> + <span className="icode">if/else</span>의 성질 중 시험에 꼭 나오는 것 하나 —{' '} + <strong>두 블록 중 반드시, 정확히 하나만 실행됩니다.</strong> 조건이 true면 if 블록, + false면 else 블록. 둘 다 실행되는 경우도, 둘 다 건너뛰는 경우도 절대 없어요. + 갈림길에서 몸이 두 개로 나뉘거나 제자리에 멈추는 사람이 없는 것과 같죠. + </p> + <div className="tip"> + <b>% 복습</b> <span className="icode">a % b</span>는 a를 b로 나눈 <strong>나머지</strong>예요.{' '} + <span className="icode">10 % 2</span>는 0, <span className="icode">7 % 2</span>는 1. + "2로 나눈 나머지가 0이다"는 곧 "짝수다"라는 뜻이라, 짝수 판별의 단골 재료입니다. + 숫자를 바꿔 가며 <span className="icode">number % 3 === 0</span>(3의 배수 판별)도 + 직접 시험해 보세요. + </div> + </Section> + + <Section n={4} title="else if: 여러 갈래" sub="위에서부터 차례로 검사한다"> + <p> + 갈림길이 항상 두 개인 건 아니에요. 점수로 등급을 나눈다면 A/B/C/재도전, 최소 네 갈래죠. + 이럴 때 <span className="icode">if</span>와 <span className="icode">else</span> 사이에{' '} + <span className="icode">else if</span>를 끼워 넣어 <strong>사다리</strong>를 만듭니다. + </p> + <Code>{CODE_ELSEIF}</Code> + <p> + 동작 규칙은 하나예요. <strong>위에서부터 차례로 조건을 검사하다가, 처음으로 true가 + 나온 블록 하나만 실행하고 사다리 전체를 빠져나온다.</strong> 그래서 85점은{' '} + <span className="icode">score >= 80</span>에서 걸려 B가 되고, 그 아래{' '} + <span className="icode">score >= 70</span>은 검사조차 하지 않습니다. 마지막{' '} + <span className="icode">else</span>는 "위의 어떤 조건에도 안 걸렸을 때"를 받아 주는 + 안전망이고요. + </p> + <p> + 그런데 이 "위에서부터"라는 규칙 때문에, <strong>조건의 순서가 결과를 바꿀 수</strong>{' '} + 있어요. 사다리를 거꾸로 세우면 무슨 일이 벌어지는지 보세요. + </p> + <Code>{CODE_ELSEIF_ORDER}</Code> + <p> + 이 성질을 잘 쓰면 오히려 코드가 짧아져요. 이 강좌의 목표였던{' '} + <strong>매표소 프로그램</strong>이 좋은 예입니다. <span className="icode">prompt</span>로 + 나이를 물어보고, 사다리로 요금을 나눠 봅시다. + </p> + <Code>{CODE_TICKET}</Code> + <div className="tip"> + <b>직접 실행해 보기</b> 브라우저에서 <strong>F12 → Console</strong> 탭을 열고 위 + 매표소 코드를 붙여 넣어 보세요. 입력창이 뜨면 7, 15, 25를 차례로 넣어 보고, 경계인 + 8과 20도 꼭 시험해 보세요. 8은 "8 미만"이 아니니 청소년 요금, 20은 "20 미만"이 + 아니니 성인 요금 — 예상과 같았나요? + </div> + </Section> + + <Section n={5} title="비교와 논리 연산자" sub="=(넣기)와 ===(비교하기)는 완전히 다르다"> + <p> + 조건문 괄호 안에는 결국 <strong>비교하는 식</strong>이 들어가요. 그런데 여기서 초보 + 때 가장 크게 데이는 지점이 있습니다. <span className="icode">=</span> 하나는{' '} + <strong>넣기(대입)</strong>, <span className="icode">===</span> 세 개는{' '} + <strong>비교하기</strong> — 생김새만 닮은 남남이에요. + </p> + <Code>{CODE_COMPARE}</Code> + <p> + 비교식의 답은 언제나 <span className="icode">true</span> 아니면{' '} + <span className="icode">false</span>, 두 가지뿐입니다. 그럼 조건이 <strong>여러 개</strong>일 + 땐 어떻게 할까요? "키도 되고 <strong>그리고</strong> 나이도 되면" 같은 문장을 위해{' '} + <strong>논리 연산자</strong>가 있어요. + </p> + <Code>{CODE_LOGICAL}</Code> + <p> + 정리하면 — <span className="icode">&&</span>(그리고)는 <strong>둘 다</strong> true여야 + true, <span className="icode">||</span>(또는)는 <strong>하나라도</strong> true면 true,{' '} + <span className="icode">!</span>(부정)는 true/false를 <strong>뒤집기</strong>. 우리말 + "그리고 / 또는 / 아니다"에 하나씩 대응하니, 조건을 말로 먼저 써 보면 어떤 연산자를 + 쓸지 저절로 보입니다. + </p> + <div className="tip"> + <b>기억법</b> <span className="icode">&&</span>는 <strong>깐깐한 심사위원 둘</strong> — + 둘 다 합격을 줘야 통과. <span className="icode">||</span>는 <strong>문이 두 개인 교실</strong> — + 아무 문으로나 들어오면 출석. <span className="icode">!</span>는 <strong>반대로 말하는 + 청개구리</strong> — 참을 거짓으로, 거짓을 참으로. + </div> + </Section> + + <Section n={6} title="실습: 놀이기구 탑승 판별기" sub="조건을 말로 먼저 쓰고, 코드로 옮기기"> + <p> + 배운 걸 전부 모아 봅시다. 놀이공원의 탑승 기준은 보통 이래요 — "<strong>키 120cm + 이상, 그리고 나이 8살 이상</strong>이면 탑승 가능". 코드부터 치지 말고, 이렇게{' '} + <strong>조건을 우리말 문장으로 먼저</strong> 써 보세요. '그리고'가 보이면{' '} + <span className="icode">&&</span>, '또는'이 보이면 <span className="icode">||</span> — + 번역은 그다음입니다. + </p> + <Code>{CODE_RIDE}</Code> + <p> + 실습에서 제일 중요한 건 <strong>경계값 시험</strong>이에요. 키가 넉넉히 크거나 한참 + 작은 경우는 누가 짜도 맞아요. 버그는 늘 <strong>딱 120cm, 딱 8살</strong> 같은 + 경계에서 태어납니다. <span className="icode">>=</span>(이상)와{' '} + <span className="icode">></span>(초과)의 한 글자 차이로 딱 120cm인 친구가 타느냐 + 못 타느냐가 갈리니까요. 프로그램을 만들면 반드시 경계값을 넣어 보는 습관 — + 이게 개발자의 기본기입니다. + </p> + <div className="tip"> + <b>도전 과제</b> 판별기를 업그레이드해 보세요. ① 키는 되는데 나이만 안 될 때 + "보호자와 함께라면 탑승 가능"이라고 안내하기(<span className="icode">else if</span>{' '} + 사다리 추가). ② "140cm 미만 <strong>또는</strong> 65세 이상이면 안전벨트 보조 좌석 + 안내"처럼 <span className="icode">||</span>를 쓰는 조건 하나 더하기. 말로 쓰고 → + 연산자 고르고 → 코드로 옮기고 → 경계값 시험, 네 박자를 지켜 보세요. + </div> + </Section> + + <Section n={7} title="흔한 함정 모음" sub="선배들이 다 밟아 본 지뢰 세 개"> + <p> + 마지막으로, 조건문을 배운 사람이라면 <strong>거의 예외 없이 한 번씩 밟는 지뢰</strong>를 + 미리 구경해 둡시다. 미리 본 지뢰는 지뢰가 아니에요. + </p> + <p> + <strong>지뢰 ① — 비교한다면서 <span className="icode">=</span>를 하나만 쓴다.</strong>{' '} + 대입이 되어 버려서 조건이 항상 참이 되고, 변수 값까지 바뀝니다. 에러도 안 나서 더 + 무서워요. + </p> + <Code>{CODE_PITFALL_ASSIGN}</Code> + <p> + <strong>지뢰 ② — <span className="icode">==</span>와 <span className="icode">===</span> 사이에서 + 헤맨다.</strong> 자바스크립트엔 등호 두 개짜리 비교(<span className="icode">==</span>)도 + 있는데, 이 녀석은 비교 전에 값의 형태를 몰래 바꿔서(예: 글자{' '} + <span className="icode">"5"</span>와 숫자 <span className="icode">5</span>를 같다고 판정) + 예상 밖의 결과를 만들곤 해요. 규칙을 다 외우는 대신, 우리는 간단한 회피법을 씁니다 —{' '} + <strong>비교는 언제나 <span className="icode">===</span>와{' '} + <span className="icode">!==</span>로 통일.</strong> 실무 팀들도 대부분 이 규칙을 써요. + </p> + <p> + <strong>지뢰 ③ — 중괄호를 생략했다가 착각에 빠진다.</strong> 중괄호 없는 if는 바로 + 다음 <strong>한 줄만</strong> 책임져요. 들여쓰기를 아무리 맞춰도 소용없습니다. + </p> + <Code>{CODE_PITFALL_BRACES}</Code> + <div className="tip"> + <b>한 줄 요약 세 개</b> 비교는 <span className="icode">===</span> 세 개(넣기는{' '} + <span className="icode">=</span> 하나) · <span className="icode">==</span>는 쓰지 않고{' '} + <span className="icode">===</span>로 통일 · if 블록은 한 줄이라도{' '} + <strong>중괄호로 묶는다</strong>. 이 세 습관만 있어도 조건문 버그의 대부분을 + 예방할 수 있어요. + </div> + </Section> + + <div className="step-card"> + <div className="step-body"> + <h3 style={{ marginBottom: 6 }}>🚦 여기까지 왔다면</h3> + <p className="muted"> + 이제 여러분의 프로그램은 <strong>외길이 아니에요.</strong> "만약 ~라면"으로 갈림길을 + 만들고(if), "아니면"으로 두 번째 길을 놓고(else), 사다리로 여러 갈래를 나누고(else + if), <span className="icode">===</span>·<span className="icode">&&</span>· + <span className="icode">||</span>로 조건을 정확히 표현하는 법까지 — 컴퓨터에게{' '} + <strong>판단을 맡기는 도구</strong>를 손에 쥐었습니다. 그런데 매표소 프로그램을 + 손님 100명에게 쓰려면 100번 실행해야 할까요? 똑같은 일을{' '} + <strong>지루해하지 않고 반복하는 것</strong>이야말로 컴퓨터가 제일 잘하는 일이에요. + 다음 <Link to="/learn/lv1-loops"><strong>반복문: 지루한 일은 컴퓨터에게</strong></Link>{' '} + 강좌에서, 갈림길에 이어 <strong>순환로</strong>를 놓아 봅시다. + </p> + </div> + </div> + </div> + ); +} diff --git a/frontend/src/pages/levels/Lv1FirstHtmlPage.jsx b/frontend/src/pages/levels/Lv1FirstHtmlPage.jsx new file mode 100644 index 0000000..9ecc0a6 --- /dev/null +++ b/frontend/src/pages/levels/Lv1FirstHtmlPage.jsx @@ -0,0 +1,467 @@ +// 이 파일이 하는 일: "나의 첫 웹페이지: HTML 첫걸음" 코스 — 코딩을 한 번도 안 해 본 사람이 +// 메모장 수준의 도구(VS Code)만으로 브라우저에 뜨는 '진짜 내 페이지'를 오늘 만들도록, +// 웹페이지의 정체(그냥 텍스트 파일이다)에서 출발해 기본 골격·제목/문단·강조·저장→새로고침 리듬· +// 자주 하는 실수까지 7개 섹션으로 안내하는 정적 학습 페이지. 눈높이는 중·고등학생. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_WHATIS = `여러분이 매일 보는 웹페이지의 정체는... + + ┌──────────────────┐ ┌──────────────────┐ + │ index.html │ │ 🖥 브라우저 │ + │ (그냥 텍스트 파일) │ ───→ │ 글자를 읽고 │ + │ │ 읽기 │ 화면을 '그려' 줌 │ + └──────────────────┘ └──────────────────┘ + +핵심: 웹페이지는 특별한 프로그램이 아니라 '글자로 된 문서'예요. +브라우저(크롬·엣지·사파리)가 그 문서를 읽고, 규칙(HTML)에 따라 +제목은 크게, 문단은 줄을 나눠서 화면에 그려 주는 것뿐입니다. +그래서 인터넷이 없어도, 서버가 없어도, 내 컴퓨터의 파일 하나로 +'진짜 웹페이지'를 만들 수 있어요. 오늘 그걸 직접 해 봅니다.`; + +const CODE_FOLDER = `내 문서/ +└── my-first-web/ ← 오늘의 작업 폴더 (이름은 영문 추천) + └── index.html ← 우리가 만들 첫 파일 + +· 폴더 하나 = 프로젝트 하나. 바탕화면에 파일을 흩어 두지 않기. +· index.html 이라는 이름은 관례예요 — "이 폴더의 대표 페이지"라는 뜻. +· 확장자 .html이 브라우저에게 "나를 웹페이지로 읽어 줘"라고 알려 줍니다. + (.txt로 저장하면 그냥 메모로 취급돼요 — 섹션 7에서 다시!)`; + +const CODE_SKELETON = `<!DOCTYPE html> +<html lang="ko"> + <head> + <meta charset="UTF-8" /> + <title>나의 첫 페이지 + + + 안녕하세요! 이건 제가 만든 첫 웹페이지예요. + + + +`; + +const CODE_HEADINGS = ` +

김미림

+

저를 소개합니다

+

안녕하세요. 저는 웹페이지를 처음 만들어 보는 김미림입니다.

+

오늘 처음으로 HTML이라는 것을 배웠어요.

+ +

좋아하는 것

+

음악 듣기, 그림 그리기, 그리고 이제는 코딩도요.

+ + +`; + +const CODE_TAG_ANATOMY = `태그의 3단 구조 — 샌드위치를 떠올리세요 + +

안녕하세요, 반가워요!

+ ─┬─ ─────────┬───────── ─┬── + 여는 태그 내용 닫는 태그 + (빵 위) (속재료) (빵 아래, /가 붙음) + +· 여는 태그

와 닫는 태그

가 내용을 감쌉니다. +· 닫는 태그의 슬래시(/)가 "여기까지!"라는 표시예요. +· 예외:
처럼 내용 없이 혼자 다니는 태그도 있어요(섹션 5).`; + +const CODE_ABOUT_ME = ` + + + + 김미림을 소개합니다 + + + +

김미림

+

안녕하세요! 저는 웹페이지를 처음 만들어 본 김미림입니다.

+ +
+ +

저는 이런 사람이에요

+

+ 아침엔 느긋하고, 밤엔 집중이 잘 되는 편이에요.
+ 좋아하는 계절은 가을,
+ 좋아하는 음식은 떡볶이입니다. +

+ +

앞으로의 목표

+

두 달 안에 나만의 홈페이지를 완성하는 것!

+ + + +`; + +const CODE_LOOP = `개발의 리듬 — 오늘부터 몸에 새길 4박자 + + ① 고친다 VS Code에서 글자 하나를 바꾼다 + ② 저장한다 Ctrl + S (Mac은 Cmd + S) + ③ 새로고침 브라우저에서 F5 (또는 Ctrl + R) + ④ 확인한다 내가 바꾼 게 화면에 떴나? + + ①→②→③→④→①→②→③→④→ ... (무한 반복) + +· 저장을 안 하면 브라우저는 '옛날 파일'을 계속 보여 줘요. + "바꿨는데 왜 안 변하지?"의 90%는 저장을 빼먹은 것. +· 프로 개발자도 하루 종일 이 4박자를 돕니다. 단지 더 빠를 뿐!`; + +const CODE_MISTAKE_TAG = ` +

첫 번째 문단 +

두 번째 문단

+ + +

여기부터 굵게... 어라, 안 닫았네.

+

다음 문단까지 전부 굵어져 버립니다!

+ +`; + +const CODE_MISTAKE_CHARSET = ` + + + + +`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '웹페이지의 정체' }, + { n: 2, label: '도구 준비' }, + { n: 3, label: '첫 HTML 파일' }, + { n: 4, label: '제목과 문단' }, + { n: 5, label: '내 소개에 살 붙이기' }, + { n: 6, label: '개발의 리듬' }, + { n: 7, label: '자주 하는 실수' }, +]; + +export default function Lv1FirstHtmlPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 초급 과정
+

나의 첫 웹페이지
— HTML 첫걸음 🌐

+

+ "코딩은 어렵고 준비물이 많다"는 소문과 달리, 웹페이지 만들기의 준비물은{' '} + 메모장 수준의 도구 하나뿐이에요. 오늘 이 시간이 끝나면, 여러분 + 컴퓨터의 브라우저에 여러분이 직접 만든 '진짜 페이지'가 떠 있을 + 겁니다. 인터넷도, 서버도, 어려운 수학도 필요 없어요. 시작해 볼까요? +

+
+ 예상 소요 50분 + 실습: 나를 소개하는 한 페이지 + 선수 지식: 없음 — 진짜 첫걸음 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 유튜브, 네이버, 지금 보고 있는 이 페이지까지 — 세상 모든 웹페이지의 정체를 알면 + 코딩이 훨씬 만만해져요. 정답부터 말하면, 웹페이지는{' '} + 그냥 글자로 된 문서(텍스트 파일)입니다. 게임처럼 복잡한 프로그램이 + 아니라, 여러분이 메모장에 쓰는 일기와 같은 종류의 파일이에요. +

+

+ 다만 그 문서에는 약속된 표시가 들어 있어요. "여긴 제목이야", + "여긴 문단이야" 같은 표시요. 이 약속의 이름이 바로{' '} + HTML(HyperText Markup Language)입니다. 그리고{' '} + 브라우저(크롬·엣지·사파리)가 이 문서를 읽어서, 표시대로 제목은 + 크게, 문단은 나눠서 화면에 그려 줍니다. +

+ {CODE_WHATIS} +

+ 여기서 오늘의 가장 중요한 사실 하나. 이 문서는{' '} + 인터넷 없이도 내 컴퓨터에서 만들고 열 수 있어요. 브라우저는 멀리 + 있는 서버의 문서만 여는 게 아니라, 내 컴퓨터에 저장된{' '} + .html 파일도 똑같이 열어 줍니다. 그러니 오늘 우리는 + 아무것도 설치된 서버 없이, 파일 하나로 웹페이지를 만들 거예요. +

+
+ 직접 확인해 보기 아무 웹페이지에서나 마우스 오른쪽 클릭 →{' '} + "페이지 소스 보기"를 눌러 보세요. 화려한 화면 뒤에 숨어 있던 + '글자로 된 원본 문서'가 나타납니다. 지금은 외계어처럼 보여도 괜찮아요 — 50분 뒤엔 + 그중 몇 줄이 읽히기 시작할 테니까요. +
+
+ +
+

+ HTML 문서는 정말로 메모장으로도 만들 수 있어요. 하지만 우리는 처음부터 개발자들이 + 실제로 쓰는 도구, VS Code(Visual Studio Code)를 쓸 거예요. + 공짜인 데다, 코드에 색을 입혀 주고(어디가 태그인지 한눈에 보임), + 내가 태그를 열면 닫는 태그를 지어서 추천해 주거든요. 메모장보다 + 실수를 훨씬 덜 하게 됩니다. +

+

+ 설치는 간단해요. 공식 사이트(code.visualstudio.com)에서 내 운영체제용 설치 파일을 + 받아 실행하면 끝. 처음 열면 영어 화면이 낯설 수 있는데, 오늘 쓸 건 딱 두 가지예요.{' '} + 파일 열기/저장, 그리고 폴더 열기(File → Open + Folder). 나머지 버튼은 몰라도 전혀 지장 없습니다. +

+

+ 도구를 준비했으면 작업 폴더를 만듭시다. 바탕화면이나 문서 폴더에{' '} + my-first-web 같은 폴더를 하나 만들고, VS Code로 그{' '} + 폴더째 여세요. 앞으로 이 강좌의 파일은 전부 이 폴더 안에서 + 살아갑니다. +

+ {CODE_FOLDER} +

+ 폴더 이름 옆의 .html이라는 꼬리표, 이걸{' '} + 확장자라고 불러요. 파일 이름의 마침표 뒤에 붙어서 "이 파일은 어떤 + 종류다"를 알려 주는 표시입니다. .txt는 그냥 메모,{' '} + .html은 웹페이지, .png는 + 그림. 같은 글자 내용이라도 확장자가 .html이어야 + 브라우저가 "아, 웹페이지구나" 하고 그려 줍니다. +

+
+ 정리하는 습관 = 실력의 절반 "프로젝트 하나 = 폴더 하나"는 개발자의 기본 + 예절이에요. 파일이 바탕화면에 흩어져 있으면 나중에 파일끼리 서로를 못 찾는 문제 + (다음 강좌에서 이미지를 넣을 때!)가 생깁니다. 오늘 만든 폴더 습관이 두 달 뒤의 + 여러분을 구해 줘요. +
+
+ +
+

+ 이제 진짜로 만들어 봅시다. VS Code에서 새 파일을 만들어 이름을{' '} + index.html로 저장하고, 아래 내용을{' '} + 직접 타이핑하세요. 복사-붙여넣기보다 손으로 쳐야 손가락이 + 기억합니다. +

+ {CODE_SKELETON} +

+ 한 줄씩 뜯어 볼게요. 맨 위의 <!DOCTYPE html>은 + "이 문서는 최신 HTML 규칙으로 쓰였어요"라고 브라우저에게 알리는{' '} + 선언문이에요. 항상 파일의 첫 줄에 딱 한 번 씁니다. 그 아래부터 + 문서 전체가 <html>로 감싸이고, 그 안이 두 + 부분으로 나뉘어요. +

+

+ <head>는 '문서에 대한 정보'를 + 담는 곳이에요. 브라우저 탭에 표시될 제목(<title>), + 글자를 읽는 방식(charset) 같은 것들이죠. 화면 + 본문에는 안 보입니다. 반대로{' '} + <body>는 '화면에 보이는 모든 것'이 + 사는 곳이에요. 우리가 쓰는 글, 제목, 나중에 배울 이미지까지 전부{' '} + body 안에 들어갑니다. 머리(head)엔 생각이, 몸(body)엔 + 보이는 모습이 — 이름 그대로예요. +

+

+ 다 쳤으면 저장(Ctrl + S)하고, 파일 + 탐색기에서 index.html을{' '} + 더블클릭하세요. 브라우저가 열리면서... 여러분이 쓴 문장이 화면에 + 떠 있을 겁니다. 축하해요 — 방금 생애 첫 웹페이지를 만들었습니다. +

+
+ 탭 제목을 확인해 보세요 브라우저 위쪽 탭에 "나의 첫 페이지"라고 떠 있나요? + 그게 바로 <head> 안에 쓴{' '} + <title>이에요. 본문엔 안 보이지만 이렇게 탭에서 + 일하고 있죠. head가 하는 일을 처음으로 눈으로 확인한 순간입니다. +
+
+ +
+

+ 빈 종이에 글만 덩그러니 있으면 읽기 힘들죠. 책이 장 제목 → 절 제목 → + 본문으로 정리되듯, 웹페이지도 제목과 문단으로 정리합니다. 도구는 딱 + 두 종류예요. 제목은 <h1>부터{' '} + <h6>까지, 문단은{' '} + <p>. +

+ {CODE_HEADINGS} +

+ h1이 가장 크고 중요한 제목,{' '} + h6로 갈수록 작아져요. 여기서 초보자가 자주 놓치는 + 포인트 하나 — h 태그는 글자를 크게 만드는 버튼이 아니라, 중요도의 순서를 + 매기는 도구입니다. 페이지의 대표 제목은 h1{' '} + 하나, 그 아래 소제목은 h2, 그 안의 더 작은 묶음은{' '} + h3... 이렇게 건너뛰지 않고 순서대로{' '} + 쓰는 게 좋은 습관이에요. +

+

+ 그리고 지금까지 눈치챘을 수도 있는 규칙을 정식으로 소개할게요. 태그는 대부분{' '} + 3단 구조입니다. +

+ {CODE_TAG_ANATOMY} +

+ 여는 태그(<p>)와 닫는 태그 + (</p>)가 내용을 샌드위치처럼 감싸요. 닫는 + 태그에만 슬래시(/)가 붙는다는 것, 꼭 기억하세요. + 여는 것과 닫는 것이 이라는 감각만 잡히면 HTML의 절반은 끝난 + 겁니다. +

+
+ 실험해 보기 위 예제를 여러분의 body 안에 + 넣고 저장 → 브라우저 새로고침(F5) 해 보세요. 그다음{' '} + h1h3으로 바꿔서 + 다시 새로고침 — 제목이 작아지죠? 이렇게 바꾸고 → 확인하는 실험이 + 코딩을 배우는 가장 빠른 길이에요(섹션 6에서 이 리듬을 제대로 다룹니다). +
+
+ +
+

+ 이제 오늘의 실습 목표, '나를 소개하는 한 페이지'를 완성해 + 봅시다. 뼈대(섹션 3)와 제목·문단(섹션 4)에 세 가지 양념을 더할 거예요. +

+

+ 첫째, 강조. <strong>은 "이건 + 중요해!"라며 굵게, <em>은 + "여기에 어감을 실어 줘"라며 기울여 보여 줍니다. 둘째,{' '} + 줄바꿈과 구분선. <br />은 문단을 + 새로 열 정도는 아니지만 줄만 바꾸고 싶을 때(시·주소·짧은 목록),{' '} + <hr />은 내용이 크게 바뀌는 지점에 가로 구분선을 + 긋습니다. 이 둘은 감쌀 내용이 없어서 닫는 태그 없이 혼자 다니는 + 특별한 태그예요. +

+

+ 셋째, 주석. <!-- 이렇게 -->{' '} + 사이에 쓴 글은 브라우저가 완전히 무시합니다. 화면엔 안 나오고, + 코드를 여는 사람에게만 보여요. "여긴 나중에 고치자", "이 부분은 이런 뜻" 같은{' '} + 나를 위한 메모를 남기는 자리입니다. +

+ {CODE_ABOUT_ME} +

+ 이 예제를 참고하되, 내용은 여러분 자신의 이야기로 바꾸세요. 이름, + 성격, 좋아하는 것, 목표 — 뭐든 좋아요. 조건은 하나입니다.{' '} + 제목(h1·h2), 문단(p), 강조(strong·em)를 모두 한 번 이상 쓸 것. + 저장하고 브라우저로 열었을 때 '나'가 담긴 페이지가 뜨면 오늘의 실습 성공이에요. +

+
+ strong과 em, 뭐가 다르죠? 화면만 보면 "굵게/기울임"이지만, 속뜻은{' '} + 중요함(strong)어감·강세(em)로 달라요. "마감은{' '} + 금요일입니다"처럼 놓치면 안 되는 정보엔 strong, "저는 떡볶이를{' '} + 정말 좋아해요"처럼 말맛을 살릴 땐 em. 겉모습이 아니라 뜻으로 + 태그를 고르는 것 — 이게 HTML을 잘 쓰는 사람의 감각입니다. +
+
+ +
+

+ 소개 페이지를 만들다 보면 자연스럽게 어떤 동작의 반복에 빠지게 + 돼요. VS Code에서 고치고, 저장하고, 브라우저로 가서 새로고침하고, 확인하고. 사실 + 이게 바로 개발이라는 활동의 기본 박자입니다. 오늘 이 리듬을 몸에 + 익히면, 앞으로 어떤 언어를 배워도 같은 박자로 갑니다. +

+ {CODE_LOOP} +

+ 이 리듬의 매력은 즉각성이에요. h1의 + 글자를 바꾸고 F5를 누르는 순간, 화면이{' '} + 바로 바뀝니다. 내가 쓴 글자가 곧장 결과가 되는 이 재미가, 많은 + 개발자를 이 길로 끌어들인 첫 경험이기도 해요. +

+

+ 그리고 요령 하나 — 조금 바꾸고, 자주 확인하세요. 열 군데를 한꺼번에 + 고친 뒤 새로고침했다가 화면이 이상해지면, 어디가 문제인지 찾기 어려워요. 한두 줄 + 바꾸고 확인, 또 한두 줄 바꾸고 확인. 이렇게 하면 문제가 생겨도 "방금 바꾼 그 + 부분"만 의심하면 되니까 금방 잡힙니다. +

+
+ "바꿨는데 왜 안 변해요?" 앞으로 여러분이 가장 자주 하게 될 질문이에요. + 답의 90%는 저장을 안 했기 때문입니다. VS Code 탭의 파일 이름 옆에{' '} + 동그라미(●)가 보이면 아직 저장 전이라는 뜻이에요.{' '} + Ctrl + S를 누르면 동그라미가 사라지고, 그때 새로고침해야 + 바뀐 내용이 보입니다. +
+
+ +
+

+ 마지막으로, 처음 배우는 사람 대부분이 밟는 함정 세 개를 미리{' '} + 구경해 둡시다. 미리 본 함정은 함정이 아니에요 — 나중에 만나면 "아, 그거!" 하고 + 웃으며 지나갈 수 있습니다. +

+

+ 함정 ① 닫는 태그 빼먹기. HTML의 재미있는(그리고 무서운) 점은, + 문법이 틀려도 에러 화면이 뜨지 않는다는 거예요. 브라우저가 최대한 + 알아서 추측해 그려 주거든요. 문제는 그 추측이 내 의도와 다를 때 — 예를 들어{' '} + <strong>을 안 닫으면 그 뒤 내용이{' '} + 전부 굵어져 버립니다. +

+ {CODE_MISTAKE_TAG} +

+ 함정 ② .txt로 잘못 저장되기. 메모장으로 저장하면 파일 이름을{' '} + index.html로 써도 실제로는{' '} + index.html.txt가 되는 경우가 있어요. 그러면 + 더블클릭해도 브라우저가 아니라 메모장이 열리죠. 게다가 윈도우는 기본 설정에서{' '} + 확장자를 숨겨서 이걸 알아채기 어렵습니다. 파일 탐색기 상단{' '} + 보기 → 파일 확장명에 체크해서 확장자를 항상 보이게 해 두세요. + VS Code로 저장하면 이 함정은 거의 안 밟습니다. +

+

+ 함정 ③ 한글 깨짐. 페이지를 열었더니 한글이{' '} + 김미림 같은 외계어로 보인다면, 십중팔구{' '} + <head> 안의{' '} + <meta charset="UTF-8" />이 빠진 거예요. +

+ {CODE_MISTAKE_CHARSET} +
+ 실수는 커리큘럼의 일부 오늘 소개한 함정을 일부러 한 번씩 밟아 보는 것도 + 훌륭한 공부예요. charset 줄을 지우고 새로고침해서 + 한글이 깨지는 걸 직접 본 사람은, 나중에 같은 화면을 만나도 3초 만에 고칩니다.{' '} + 안전한 내 폴더 안에서는 마음껏 부수고 고쳐 보세요. +
+
+ +
+
+

🌐 여기까지 왔다면

+

+ 오늘 여러분은 웹페이지의 정체(그냥 텍스트 파일!)를 알았고, VS Code와 작업 폴더를 + 갖췄고, 뼈대 → 제목·문단 → 강조·구분선·주석을 거쳐 '나를 소개하는 + 한 페이지'를 브라우저에 띄웠어요. 저장→새로고침의 리듬과 세 가지 함정까지 챙겼으니, + 이제 첫걸음이 아니라 두 걸음째를 디딜 차례입니다. 그런데 소개 + 페이지를 만들다 보면 욕심이 생기죠 — 사진도 넣고 싶고, 좋아하는 사이트로 가는 + 링크도 걸고 싶고, 취미를 목록으로 정리하고 싶고. 다음{' '} + 태그로 페이지에 살 + 붙이기 강좌에서 링크·이미지·목록 태그로 그 욕심을 전부 + 채워 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv1FirstJavascriptPage.jsx b/frontend/src/pages/levels/Lv1FirstJavascriptPage.jsx new file mode 100644 index 0000000..7ff8afa --- /dev/null +++ b/frontend/src/pages/levels/Lv1FirstJavascriptPage.jsx @@ -0,0 +1,426 @@ +// 이 파일이 하는 일: "나의 첫 JavaScript" 코스 — 코딩을 한 번도 안 해 본 사람이 +// 브라우저 콘솔에서 첫 코드를 실행하고, HTML에 스크립트를 연결하고, alert/prompt로 +// 페이지와 '대화'하고, 버튼 클릭에 반응시키기까지 7개 섹션으로 안내하는 정적 학습 페이지. +// 프레이밍은 "멈춰 있던 페이지가 말을 걸기 시작한다" — HTML/CSS로 그려 둔 화면에 +// 처음으로 '움직임'을 불어넣는 짜릿함을 몸으로 겪게 하는 것이 목표다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── +// 학습 포인트: 초심자 예제라 템플릿 리터럴 대신 + 로 문자열을 잇는다(따옴표 규칙에 집중시키기 위해). + +const CODE_TRIO = `HTML ──────────── CSS ──────────── JavaScript +(뼈대) (꾸밈) (움직임) + +글·버튼·사진을 색·크기·배치를 클릭에 반응하고 +화면에 '놓는다' '입힌다' 팝업을 띄우고 + 계산을 '한다' + +핵심: HTML과 CSS만으로 만든 페이지는 예쁜 '포스터'예요. +움직이지도, 대답하지도 않죠. 거기에 JavaScript가 들어가는 순간 +페이지가 여러분의 클릭을 알아채고, 말을 걸고, 계산까지 해 줍니다.`; + +const CODE_FIRST_LOG = `// 콘솔에 아래 한 줄을 입력하고 Enter를 눌러 보세요. +console.log("안녕!"); + +// 결과: 콘솔에 안녕! 이 출력됩니다. +// 방금 여러분은 컴퓨터에게 처음으로 '명령'을 내렸어요. +// console.log( ... ) 는 "괄호 안의 내용을 콘솔에 적어 줘"라는 뜻입니다.`; + +const CODE_QUOTES = `console.log("안녕!"); // O — 글자는 따옴표로 감싼다 +console.log('안녕!'); // O — 작은따옴표도 똑같이 동작한다 +console.log(안녕!); // X — 따옴표가 없으면 에러! + +// 따옴표로 감싼 글자를 '문자열(string)'이라고 불러요. +// 따옴표가 없으면 컴퓨터는 "안녕이라는 이름의 무언가를 찾아라"로 +// 오해하고, 그런 건 없으니 빨간 에러를 냅니다. + +console.log("안녕!") // 세미콜론(;)이 없어도 대부분 동작하지만, +console.log("안녕!"); // "이 문장 여기서 끝!"이라는 마침표로 붙이는 습관을 들여요.`; + +const CODE_SCRIPT_IN_HTML = ` + + + 나의 첫 JavaScript + + +

안녕하세요!

+ + + + +`; + +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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: 'JS가 하는 일' }, + { n: 2, label: '콘솔 첫 실행' }, + { n: 3, label: 'HTML에 연결' }, + { n: 4, label: 'alert · prompt' }, + { n: 5, label: '숫자 계산' }, + { n: 6, label: '버튼 클릭 반응' }, + { n: 7, label: '오타와의 전쟁' }, +]; + +export default function Lv1FirstJavascriptPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 초급 과정
+

✨ 나의 첫 JavaScript

+

+ 지금까지의 웹 페이지는 멈춰 있는 그림이었어요. 클릭해도 조용하고, + 물어봐도 대답이 없죠. 오늘 그 페이지가 처음으로 말을 걸고 반응하기{' '} + 시작합니다. 설치할 것도 없어요 — 여러분의 브라우저 안에 이미 준비돼 있으니까요. + 첫 코드 한 줄의 짜릿함, 지금 시작합니다. +

+
+ 예상 소요 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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '프로젝트 훑어보기' }, + { n: 2, label: '종이 코딩' }, + { n: 3, label: 'HTML 뼈대' }, + { n: 4, label: '게임 로직' }, + { n: 5, label: '화면 반응' }, + { n: 6, label: '테스트' }, + { n: 7, label: '다듬기' }, + { n: 8, label: '다음 여정' }, +]; + +export default function Lv1FirstMiniProjectPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 초급 과정
+

🚀 첫 프로젝트
— 숫자 맞히기 게임 만들기

+

+ 변수, 조건문, 반복, 함수, HTML — 지금까지 하나씩 배운 조각들을 오늘{' '} + 전부 한 판에 모읍니다. 컴퓨터가 몰래 뽑은 1~100 사이의 숫자를 + 업/다운 힌트로 맞히는 웹 게임을, 설계부터 자랑까지 처음부터 끝까지 + 직접 완성해요. 끝나고 나면 확실해집니다 — "나, 코딩 할 수 있네." +

+
+ 예상 소요 90분 + 실습: 남은 기회 5번 제한 추가 + 준비물: 지금까지 배운 전부 +
+
+ + {/* 섹션 바로가기 — 학습 포인트: 배열.map으로 반복되는 링크를 찍어 낸다 */} +
+ {SECTIONS.map((s) => ( + + {s.n}. {s.label} + + ))} +
+ +
+

+ 오늘 만들 것은 숫자 맞히기 게임이에요. 규칙은 간단합니다. 컴퓨터가 + 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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '함수가 뭐길래' }, + { n: 2, label: '만들고 부르기' }, + { n: 3, label: '매개변수' }, + { n: 4, label: 'return' }, + { n: 5, label: '함수 조합' }, + { n: 6, label: '스코프 맛보기' }, + { n: 7, label: '실습: 계산기' }, +]; + +export default function Lv1FunctionsPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 초급 과정
+

함수 🧰
— 나만의 명령어 만들기

+

+ 지금까지는 코드를 위에서 아래로 한 줄씩 써 왔어요. 그런데 같은 일을 세 번 하려면 + 같은 코드를 세 번 써야 할까요? 함수는 자주 쓰는 코드 묶음에{' '} + 이름을 붙여 두고 언제든 다시 부르는 방법이에요. 이걸 익히는 순간, + 코드를 '쓰는' 사람에서 코드를 '조립하는' 사람으로 한 단계 올라섭니다. +

+
+ 예상 소요 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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '엔터 치면 생기는 일' }, + { n: 2, label: '세 가지 언어' }, + { n: 3, label: '개발자 도구 F12' }, + { n: 4, label: '콘솔과 첫 만남' }, + { n: 5, label: '그려지는 순서' }, + { n: 6, label: '내 컴퓨터와 인터넷' }, + { n: 7, label: '탐험 실습' }, +]; + +export default function Lv1HowBrowsersWorkPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 초급 과정
+

브라우저는 내 코드를
어떻게 읽을까 🔍

+

+ 주소창에 엔터를 치면 무슨 일이 벌어질까요? 우리가 매일 쓰는 브라우저는 사실{' '} + 코드를 읽어서 그림을 그려 주는 기계예요. 이 코스에선{' '} + 개발자 도구(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 = ` +
    +
  1. 물을 끓인다
  2. +
  3. 면과 스프를 넣는다
  4. +
  5. 3분 기다린다
  6. +
+ +`; + +const CODE_NESTED = ` + + +`; + +const CODE_TABLE = ` + + + + + + + + + + + + + + + + +
요일
1교시 수학국어
2교시영어체육
`; + +const CODE_TABLE_THINK = `표를 만들기 전에 '행과 열'로 먼저 생각해 보기 + + │ 월 화 수 ← 열(세로줄): 요일 +────────┼───────────────── + 1교시 │ 수학 국어 과학 + 2교시 │ 영어 체육 음악 + ↑ + 행(가로줄): 교시 + +코드로 옮길 땐 "가로 한 줄 = tr 하나"만 기억하면 됩니다. +1행(요일 줄) → 첫 번째 tr, 2행(1교시 줄) → 두 번째 tr... +줄마다 칸 개수가 같아야 표가 반듯하게 나와요!`; + +const CODE_DIV = ` +
+

자기소개

+

안녕하세요, 웹을 배우는 중입니다.

+
+ +
+

좋아하는 것

+ +
+ +`; + +const CODE_STYLE = ` +

이 문장은 빨간색!

+

이 문장은 큼직하게!

+

파랗고 크게 — 세미콜론(;)으로 이어요

+ +
+

div에 주면 상자 안 전체가 노란 배경!

+
+ +`; + +const CODE_FINAL = ` + + + 내 최애 소개 + + +

내 최애를 소개합니다 🎤

+ +
+

누구냐면

+

내가 제일 좋아하는 가수를 소개할게요.

+ 무대 위의 최애 가수 + 콘서트 응원봉 물결 +
+ +
+

왜 좋아하냐면

+ +
+ +
+

더 알아보기

+ 뮤직비디오 보러 가기 + 노래 들으러 가기 + 최애 검색해 보기 +
+ +`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '링크' }, + { n: 2, label: '이미지' }, + { n: 3, label: '목록' }, + { n: 4, label: '표' }, + { n: 5, label: 'div 상자' }, + { n: 6, label: '스타일 맛보기' }, + { n: 7, label: '미니 실습' }, +]; + +export default function Lv1HtmlLinksImagesListsPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 초급 과정
+

🏗️ 태그로 페이지에
살 붙이기

+

+ 지난 시간에 만든 페이지, 솔직히 좀 밋밋했죠? 글자만 있었으니까요. 오늘은 거기에{' '} + 링크·이미지·목록·표를 하나씩 얹어서, 여러분이 매일 보는{' '} + '진짜 웹페이지'의 모양을 갖춰 봅니다. 마지막엔 내 최애를 소개하는 + 페이지를 직접 완성해서 친구에게 자랑할 거예요. +

+
+ 예상 소요 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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '왜 반복인가' }, + { n: 2, label: 'while' }, + { n: 3, label: 'for' }, + { n: 4, label: '계산 쌓기' }, + { n: 5, label: '반복+조건' }, + { n: 6, label: '배열 맛보기' }, + { n: 7, label: '실습: 별 그림' }, + { n: 8, label: '흔한 함정' }, +]; + +export default function Lv1LoopsPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 초급 과정
+

🔁 반복문
: 지루한 일은 컴퓨터에게

+

+ "안녕하세요"를 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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '에러는 힌트' }, + { n: 2, label: '메시지 해부' }, + { n: 3, label: 'SyntaxError' }, + { n: 4, label: 'ReferenceError' }, + { n: 5, label: 'TypeError' }, + { n: 6, label: '탐정 디버깅' }, + { n: 7, label: '검색과 AI' }, + { n: 8, label: '실습' }, +]; + +export default function Lv1ReadingErrorsPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 초급 과정
+

🩹 에러와 친해지기
— 빨간 글씨 해독법

+

+ 코드를 실행했더니 화면 가득 빨간 글씨가 떴다면 — 축하해요, 드디어 + 진짜 코딩을 시작한 거예요. 에러는 벌점이 아니라 컴퓨터가 주는 힌트 + 입니다. 이 코스에서는 그 힌트를 읽고, 원인을 좁히고, 스스로 고치는 힘을 기릅니다. + 이 힘 하나가 앞으로 배울 모든 것의 속도를 정해요. +

+
+ 예상 소요 60분 + 실습: 버그 5개 스스로 고치기 + 선수 지식: 없음 (변수·함수를 봤다면 더 좋아요) +
+
+ + {/* 섹션 바로가기 — 학습 포인트: SECTIONS 배열 하나로 nav를 그린다(map 재사용) */} +
+ {SECTIONS.map((s) => ( + + {s.n} {s.label} + + ))} +
+ +
+

+ 처음 에러를 만나면 심장이 철렁해요. "내가 뭘 망가뜨렸나?", "컴퓨터가 고장 났나?" — + 둘 다 아닙니다. 에러는 컴퓨터가 "여기서 멈췄는데, 이유는 이거야"라고 + 최선을 다해 써 준 안내문이에요. 시험지의 빨간 채점이 아니라, + 길을 잃었을 때 나타나는 안내 표지판에 가깝습니다. +

+

+ 그리고 하나 더 — 에러는 모든 개발자가 매일 만나요. 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등 원인은 대소문자 오타예요. 컴퓨터에게{' '} + userNameusername은 + 완전히 다른 이름입니다. 사람 눈에는 거의 같아 보여서 더 악질이죠. 2등은 순수한 철자 + 오타(scorescroe), + 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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '변수 = 상자' }, + { n: 2, label: 'let과 const' }, + { n: 3, label: '문자열' }, + { n: 4, label: '숫자' }, + { n: 5, label: '불리언' }, + { n: 6, label: '자료형의 힘' }, + { n: 7, label: '실습' }, +]; + +export default function Lv1VariablesAndTypesPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 초급 과정
+

📦 변수와 자료형
— 이름 붙인 상자

+

+ 모든 프로그램은 결국 값을 어딘가에 담아 두고, 필요할 때 꺼내 쓰는 일의 + 반복이에요. 그 "어딘가"가 바로 변수 — 이름표를 붙인 상자입니다. + 이 강좌에서는 상자를 만들고(let·const), 상자에 담기는 값의 세 가지 종류 + (문자열·숫자·불리언)를 익힌 뒤, 여러분의 이름으로 자기소개 문장을 조립해 봅니다. + 코딩이 처음이어도 괜찮아요 — 여기가 첫걸음이니까요. +

+
+ 예상 소요 60분 + 실습: 자기소개 문장 조립 + 선수 지식: 없음 — 진짜 첫 강좌 +
+
+ + {/* 섹션 바로가기 — 학습 포인트: SECTIONS 배열을 map으로 돌려 반복을 없앤다 */} +
+ {SECTIONS.map((s) => ( + + {s.n} {s.label} + + ))} +
+ +
+

+ 계산기를 떠올려 보세요. 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)예요. 따옴표 없이 쓰면 + 숫자입니다. 다른 언어들은 정수와 소수를 다른 자료형으로 나누기도 하는데, + 자바스크립트는 173.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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '컴퓨터는 바보다' }, + { n: 2, label: '일상 속 프로그램' }, + { n: 3, label: '레시피 = 알고리즘' }, + { n: 4, label: '왜 프로그래밍 언어?' }, + { n: 5, label: '코드가 실행된다는 것' }, + { n: 6, label: '개발자의 일' }, + { n: 7, label: '첫 실습' }, +]; + +export default function Lv1WhatIsAProgramPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 초급 과정
+

💡 프로그램이란
무엇일까?

+

+ 코딩을 배우러 왔는데, 오늘은 코드를 한 줄도 쓰지 않을 거예요. 대신 + 그보다 훨씬 중요한 것부터 시작합니다 — 컴퓨터에게 '일을 시킨다'는 게 대체 무슨 + 뜻인지를 몸으로 이해하는 것. 이 감각이 잡히면, 앞으로 배울 모든 + 프로그래밍 언어가 "아, 결국 이 얘기구나" 하고 읽히기 시작해요. +

+
+ 예상 소요 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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '왜 메서드인가' }, + { n: 2, label: 'map' }, + { n: 3, label: 'filter·find' }, + { n: 4, label: 'reduce' }, + { n: 5, label: 'sort와 불변성' }, + { n: 6, label: '체이닝' }, + { n: 7, label: '중첩 데이터' }, +]; + +export default function Lv2ArrayDataWranglingPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 중급 과정
+

배열 메서드로
데이터 요리하기 🔪

+

+ 실전 웹 개발의 절반은 데이터 다듬기예요. 서버가 주는 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} +

+ 두 코드는 결과가 완전히 같아요. 하지만 두 번째 코드를 읽는 동료는{' '} + filtermap이라는 단어만 + 보고도 "골라서, 바꾸는구나"를 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} +

+ filterfind는 겉이 + 닮아서 자주 헷갈려요. 구분 기준은 결과의 자료형입니다.{' '} + 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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '왜 비동기인가' }, + { n: 2, label: '콜백 지옥' }, + { n: 3, label: 'Promise 3상태' }, + { n: 4, label: 'async/await' }, + { n: 5, label: 'fetch 실전' }, + { n: 6, label: 'Promise.all' }, + { n: 7, label: '로딩·취소·타임아웃' }, +]; + +export default function Lv2AsyncJavascriptPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 중급 과정
+

비동기 JavaScript
— fetch와 Promise 정복

+

+ 기초 문법을 배웠는데도 "진짜 서비스"가 안 만들어지는 이유의 절반은{' '} + 비동기에 있어요. 서버는 언제 대답할지 모르는데, 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} + + + pending + 대기 중... + + + fulfilled + 성공 · 결과값 보유 → then + + + rejected + 실패 · 에러 보유 → catch + + {/* pending → fulfilled */} + + + 성공 (resolve) + + {/* pending → rejected */} + + + 실패 (reject) + + 전이는 단 한 번 — 정착(settled)한 뒤엔 절대 되돌아가지 않는다 + +

그림: Promise는 pending에서 출발해 fulfilled 또는 rejected 중 하나로 단 한 번 정착한다

+

+ 약속에 후속 작업을 붙이는 문법이 then(성공 시)과{' '} + catch(실패 시)예요. 그리고{' '} + then항상 새 Promise를 반환하기 때문에, + 콜백 지옥의 계단을 수평 사슬로 펼 수 있습니다. +

+ {CODE_THEN} +
+ 비유 Promise는 카페 진동벨이에요. 주문하면(요청) 음료 대신 + 진동벨(Promise)을 먼저 받죠. 벨을 손에 쥔 채 자리에서 딴 일을 하다가 — 울리면(fulfilled) + 음료를 받고, 점원이 와서 "재료가 떨어졌어요" 하면(rejected) 환불 처리를 합니다. 벨이 + 두 번 다르게 울리는 일은 없어요. 단 한 번, 둘 중 하나. +
+
+ +
+

+ then 체인은 훌륭하지만, 로직이 길어지면{' '} + functionreturn이 + 반복되며 눈이 미끄러져요. 그래서 나온 최종 진화형이{' '} + 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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '인증 vs 인가' }, + { n: 2, label: '비밀번호 해시' }, + { n: 3, label: '회원가입 API' }, + { n: 4, label: '세션과 쿠키' }, + { n: 5, label: '로그인·로그아웃' }, + { n: 6, label: '미들웨어·보호 라우트' }, + { n: 7, label: '최소한의 방어' }, +]; + +export default function Lv2AuthSessionHashPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 중급 과정
+

🔐 로그인 구현
— 세션과 비밀번호 해시

+

+ 지금까지 만든 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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '왜 DB인가' }, + { n: 2, label: '테이블 설계' }, + { n: 3, label: 'Node에서 SQL' }, + { n: 4, label: 'CRUD를 SQL로' }, + { n: 5, label: 'JOIN' }, + { n: 6, label: '트랜잭션' }, + { n: 7, label: '실패 시나리오' }, +]; + +export default function Lv2DbTransactionsPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 중급 과정
+

DB 연동과 트랜잭션:
데이터가 사는 집

+

+ 지금까지 우리 블로그 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에서 한꺼번에 확정돼요. 중간에 무엇이든 + 잘못되면 ROLLBACKBEGIN{' '} + 이후의 모든 변경이 통째로 없던 일이 됩니다. 절반만 반영된 어정쩡한 + 상태는 존재할 수 없어요. 그래서 all-or-nothing. +

+

+ 우리 블로그에도 똑같은 구조가 있어요 — 실습 과제인 "댓글 작성 + 글의 + 댓글수(comment_count) 갱신". 댓글 INSERT만 + 되고 댓글수 UPDATE가 실패하면, 화면엔 "댓글 3개"라는데 실제론 4개가 달린 이상한 + 글이 됩니다. 묶읍시다. +

+ {CODE_TX_PRACTICE} +
+ 트랜잭션은 '같은 연결' 위에서만 풀에서 빌린{' '} + client 하나로 BEGIN부터{' '} + COMMIT까지 전부 실행해야 해요. 중간에{' '} + pool.query()를 섞으면 풀이 다른 연결을 + 빌려 줄 수 있고, 그 쿼리는 내 트랜잭션 바깥에서 실행됩니다. 그리고{' '} + finallyclient.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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '에러의 두 종류' }, + { n: 2, label: 'async와 try/catch' }, + { n: 3, label: '커스텀 에러 클래스' }, + { n: 4, label: '에러 미들웨어' }, + { n: 5, label: '응답 포맷 통일' }, + { n: 6, label: '폴백 UI' }, + { n: 7, label: '로깅 기초' }, +]; + +export default function Lv2ErrorHandlingPatternsPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 중급 과정
+

🚨 에러 처리 패턴
— 무너지지 않는 서비스

+

+ 에러는 없앨 수 없어요. 다스릴 뿐입니다. 네트워크는 끊기고, 사용자는 + 예상 밖의 값을 넣고, 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 ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '요청의 여정' }, + { n: 2, label: '셋업과 첫 GET' }, + { n: 3, label: 'POST: 생성' }, + { n: 4, label: 'GET 상세·목록' }, + { n: 5, label: 'PATCH·DELETE' }, + { n: 6, label: '미들웨어 추출' }, + { n: 7, label: '테스트 습관' }, + { n: 8, label: '라우터와 계층' }, +]; + +export default function Lv2ExpressCrudPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 중급 과정
+

🛠️ Express로
CRUD API 만들기

+

+ 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, + })); +} + +// +`; + +const CODE_RULES = `// 검증 규칙을 if문 더미가 아니라 '데이터(객체)'로 선언한다 +// 규칙이 데이터면 → 프론트·서버가 같은 표를 보고 검사할 수 있고, 고칠 곳도 한 곳. +const RULES = { + email: { + required: true, + pattern: /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/, // '무언가@무언가.무언가' 골격 + message: '이메일 형식으로 입력해 주세요. 예: mirim@awesomedev.dev', + }, + password: { + required: true, + minLength: 8, + message: '비밀번호는 8자 이상이어야 해요.', + }, + nickname: { + required: true, + minLength: 2, + maxLength: 12, + message: '닉네임은 2~12자로 지어 주세요.', + }, +}; + +// 검사기는 규칙 표를 '읽기만' 한다 — 새 규칙이 생겨도 이 함수는 안 바뀜 +function validateField(name, value) { + const rule = RULES[name]; + if (!rule) return ''; + if (rule.required && value.trim() === '') return '필수 입력 항목이에요.'; + if (rule.minLength && value.length < rule.minLength) return rule.message; + if (rule.maxLength && value.length > rule.maxLength) return rule.message; + if (rule.pattern && !rule.pattern.test(value)) return rule.message; + return ''; // 빈 문자열 = 통과 +}`; + +const CODE_CROSS = `// 교차 검증 — '비밀번호 확인'은 규칙 표만으론 못 잡는다 +// 한 필드의 정답이 '다른 필드의 값'에 달려 있기 때문. 폼 전체를 보는 단계가 따로 필요. +function validateForm(form) { + const errors = {}; + + // ① 필드별 규칙 검사 (RULES 표 기반) + for (const name of Object.keys(RULES)) { + const msg = validateField(name, form[name]); + if (msg) errors[name] = msg; + } + + // ② 필드 사이를 건너 보는 교차 검증 + if (form.passwordConfirm !== form.password) { + errors.passwordConfirm = '비밀번호가 서로 달라요. 다시 확인해 주세요.'; + } + + return errors; // 비어 있으면({}) 전부 통과 +}`; + +const CODE_ERROR_TIMING = `// 에러를 '언제' 보여줄까 — touched(만진 필드)로 타이밍을 제어한다 +const [errors, setErrors] = useState({}); +const [touched, setTouched] = useState({}); // 사용자가 떠나 본 필드 기록 + +// blur(필드를 떠날 때): 이 필드를 '만졌다'고 기록하고 그때부터 검사 +function handleBlur(e) { + const { name, value } = e.target; + setTouched((prev) => ({ ...prev, [name]: true })); + setErrors((prev) => ({ ...prev, [name]: validateField(name, value) })); +} + +// 화면: '만진 필드'의 에러만 보여준다 — 타이핑 시작도 전에 빨간 글씨 금지 +{touched.email && errors.email && ( +

{errors.email}

+)} + +// 정리하면: +// 입력 중(onChange) 검사 → 너무 성급함 (한 글자 쳤는데 "형식 오류"라니) +// blur 시 검사 → 기본값으로 무난함 (다 쓰고 떠날 때 알려줌) +// 제출 시 검사 → 최후의 그물 (안 만진 필드까지 전부 훑는다)`; + +const CODE_FOCUS = `// 제출을 눌렀는데 에러가 있다면 — '첫 에러 필드'로 포커스를 옮겨 주는 배려 +function handleSubmit(e) { + e.preventDefault(); + const nextErrors = validateForm(form); + setErrors(nextErrors); + setTouched({ email: true, password: true, passwordConfirm: true, nickname: true }); + + const firstErrorField = Object.keys(nextErrors)[0]; + if (firstErrorField) { + // name 속성으로 해당 input을 찾아 포커스 — 긴 폼에서 특히 고마운 동작 + document.querySelector('[name="' + firstErrorField + '"]')?.focus(); + return; // 에러가 있으면 서버로 보내지 않는다 + } + // ...여기부터 서버 전송 (섹션 7) +}`; + +const CODE_SERVER_VALIDATE = `// 서버(Express 예시) — 프론트와 '같은 규칙'을 서버 언어로 한 번 더 +// 프론트 코드를 아무리 잘 짜도, 서버는 그걸 본 적이 없다는 듯 다시 검사해야 한다. +function validateSignup(req, res, next) { + const { email, password, passwordConfirm, nickname } = req.body; + const errors = {}; + + if (!email || !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) { + errors.email = '이메일 형식이 올바르지 않습니다.'; + } + if (!password || password.length < 8) { + errors.password = '비밀번호는 8자 이상이어야 합니다.'; + } + if (password !== passwordConfirm) { + errors.passwordConfirm = '비밀번호가 일치하지 않습니다.'; + } + if (!nickname || nickname.length < 2 || nickname.length > 12) { + errors.nickname = '닉네임은 2~12자여야 합니다.'; + } + + if (Object.keys(errors).length > 0) { + // 400 Bad Request: "요청 자체가 규칙에 어긋난다"는 상태코드 + return res.status(400).json({ message: '입력값을 확인해 주세요.', errors }); + } + next(); // 통과 → 다음 단계(실제 가입 처리)로 +} + +// 미들웨어로 분리했으니 라우트는 '검증 끝난 값'만 받는다 +app.post('/api/signup', validateSignup, signupHandler);`; + +const CODE_400_FORMAT = `// 400 응답 포맷 — '필드별' 에러를 담는 게 핵심 설계 포인트 +// 나쁜 예: { "message": "입력값이 잘못됐습니다" } +// → 프론트가 '어느 칸'이 틀렸는지 몰라서 입력칸 아래에 못 그려 준다. + +// 좋은 예: 필드 이름을 열쇠로 쓰는 errors 객체 +{ + "message": "입력값을 확인해 주세요.", + "errors": { + "email": "이메일 형식이 올바르지 않습니다.", + "nickname": "닉네임은 2~12자여야 합니다." + } +} + +// 프론트의 errors 상태({ email: '...', nickname: '...' })와 +// 모양이 '같다'는 게 보이나요? 그래서 다음 섹션에서 +// 서버 에러를 setErrors(data.errors) 한 줄로 폼에 부어 넣을 수 있습니다.`; + +const CODE_APPLY_SERVER_ERRORS = `// 서버의 400/409 응답을 폼에 '다시 그리기' +try { + const res = await fetch('/api/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(form), + }); + + if (res.status === 400) { + // 검증 실패: 필드별 에러를 그대로 폼 에러 상태에 붓는다 + const data = await res.json(); + setErrors(data.errors); // 각 입력칸 아래에 표시됨 + return; + } + + if (res.status === 409) { + // 409 Conflict: '이미 있는 것과 부딪힘' — 이메일 중복처럼 + // 서버(DB)만 알 수 있는 에러. 프론트가 미리 잡을 방법이 없다. + setErrors({ email: '이미 가입된 이메일이에요. 로그인해 보시겠어요?' }); + return; + } + + if (!res.ok) throw new Error('서버 오류'); // 500 등 나머지 + // ...성공 처리 (섹션 7) +} catch (err) { + // fetch가 throw로 떨어지는 건 '응답 자체가 없는' 네트워크 실패 + // 검증 실패(400)와는 성격이 다르다 — 사용자 잘못이 아니니 문구도 달라야 한다 + setGlobalError('연결이 불안정해요. 잠시 후 다시 시도해 주세요.'); +}`; + +const CODE_SUBMIT_FLOW = `// 제출 흐름 완성 — 중복 제출 방지 + 성공/실패 뒷정리 +const [submitting, setSubmitting] = useState(false); + +async function handleSubmit(e) { + e.preventDefault(); + if (submitting) return; // 이중 안전장치 + + const nextErrors = validateForm(form); + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors); + return; // 프론트에서 걸리면 서버까지 안 간다 + } + + setSubmitting(true); // ① 버튼 잠금 — 연타로 가입 2번 방지 + try { + const res = await fetch('/api/signup', { /* ...섹션 6과 동일... */ }); + if (res.ok) { + setForm({ email: '', password: '', passwordConfirm: '', nickname: '' }); + navigate('/welcome'); // ② 성공: 폼 초기화 후 이동 + } + // 400/409 처리는 섹션 6 — 이때 setForm을 부르지 않는 게 포인트! + // ③ 실패해도 입력값은 보존: 사용자가 처음부터 다시 치게 만들지 않는다 + } finally { + setSubmitting(false); // 성공이든 실패든 반드시 잠금 해제 + } +} + +`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '왜 두 번인가' }, + { n: 2, label: '제어 컴포넌트' }, + { n: 3, label: '검증 규칙 설계' }, + { n: 4, label: '에러 메시지 UX' }, + { n: 5, label: '서버 검증과 400' }, + { n: 6, label: '서버 에러 그리기' }, + { n: 7, label: '제출 흐름 완성' }, +]; + +export default function Lv2FormValidationPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 중급 과정
+

✅ 폼 검증
— 사용자를 돕고 서버를 지키기

+

+ "프론트에서 검증했으니까 서버는 믿어도 되겠지?" — 이 한 문장이 실제 서비스 사고의 + 단골 원인이에요. 검증은 두 번 합니다. 프론트에서는{' '} + 친절하게(사용자의 시간을 아끼는 UX), 서버에서는{' '} + 단호하게(어떤 요청도 그냥 통과 못 하는 보안). 회원가입 폼 하나를 + 끝까지 만들며 이 이중 검증의 감각을 손에 붙여 봅시다. +

+
+ 예상 소요 60분 + 실습: 회원가입 폼 이중 검증 + 선수 지식: React 기초·HTTP 기초 +
+
+ +
+

+ 먼저 역할부터 갈라 봅시다. 클라이언트(프론트) 검증의 임무는{' '} + 사용자 경험이에요. 이메일 칸에 오타를 낸 사용자에게 서버까지 갔다 오는 + 긴 왕복 없이 그 자리에서 즉시 "형식이 달라요"라고 알려 주죠. 반면{' '} + 서버 검증의 임무는 보안과 데이터 무결성입니다. + 서버에 도착한 요청이 규칙에 맞는지를 최종적으로 판정하고, 아니면 + 거절해요. +

+ {CODE_TWO_LAYERS} +

+ "프론트에서 이미 막았는데 서버가 또 검사할 필요가 있나?"라는 의문이 들 수 있어요. + 핵심은 이겁니다 — 서버는 요청이 우리 폼에서 왔는지 알 수 없어요.{' '} + 브라우저 개발자도구 콘솔에서 fetch 한 줄이면, 우리가 짠 + React 검증을 한 글자도 거치지 않고 서버에 직접 요청을 보낼 수 있거든요. +

+ {CODE_BYPASS} +

+ '프론트만 믿은' 서비스가 겪는 사고 유형은 정해져 있어요. ① 깨진 데이터{' '} + — 이메일 아닌 값이 이메일 칸에 저장돼 메일 발송이 줄줄이 실패하고,{' '} + ② 정책 붕괴 — "비밀번호 8자 이상"이 뚫려 1자짜리 계정이 생기고,{' '} + ③ 주입 공격 — 검사 없이 저장된 스크립트 문자열이 다른 사용자의 화면에서 + 실행됩니다. 셋 다 서버 검증 하나로 막히는 사고예요. +

+
+ 한 줄 원칙 프론트 검증은 있으면 친절이고, 서버 검증은{' '} + 없으면 사고입니다. 순서를 굳이 정하면 서버 검증이 먼저예요 — UX는 + 나중에 입힐 수 있지만, 보안 구멍은 열린 순간부터 위험이니까요. +
+
+ +
+

+ 검증을 하려면 먼저 지금 입력칸에 뭐가 적혀 있는지를 React가 알아야 + 해요. 그래서 쓰는 게 제어 컴포넌트(controlled component) 패턴입니다.{' '} + value로 상태를 화면에 흘려보내고,{' '} + onChange로 화면의 변화를 상태에 되돌리는 —{' '} + 양방향으로 묶인 입력이죠. 이렇게 하면 입력값의 주인이 DOM이 아니라 + React 상태가 되고, 검증 함수는 그 상태만 들여다보면 됩니다. +

+

+ 회원가입 폼엔 필드가 넷(이메일·비밀번호·비밀번호 확인·닉네임)이에요.{' '} + useState를 네 번 부르는 대신, 객체 하나에 + 담고 name 속성을 열쇠 삼아 핸들러 하나로 처리하는 패턴을 + 쓰면 필드가 늘어나도 코드가 늘지 않습니다. +

+ {CODE_CONTROLLED} + {/* 학습 포인트: [name]: value 의 대괄호는 '계산된 속성 이름' — 변수의 값을 객체 키로 쓴다 */} +

+ 한 가지 함정이 있어요. 입력 타입마다 값이 담기는 자리가 달라요. + 텍스트·셀렉트는 value지만, 체크박스는{' '} + checked(불리언)를 읽어야 합니다. 이걸 놓치면 체크박스 + 값이 항상 "on" 같은 이상한 문자열로 잡혀요. +

+ {CODE_INPUT_TYPES} +
+ 비유 제어 컴포넌트는 칠판과 출석부예요. 학생(사용자)이 칠판(입력칸)에 + 쓰는 즉시 선생님(React)이 출석부(상태)에 옮겨 적고, 칠판에 보이는 글씨도 항상 출석부 + 기준으로 다시 씁니다. 출석부가 단일한 진실이니, 검증도 출석부만 보면 되는 거죠. +
+
+ +
+

+ 이제 "무엇이 올바른 입력인가"를 정할 차례예요. 회원가입 폼의 규칙은 세 종류로 + 정리됩니다. ① 필수(required) — 비어 있으면 안 된다,{' '} + ② 최소/최대 길이 — 비밀번호 8자 이상, 닉네임 2~12자,{' '} + ③ 포맷(정규식) — 이메일은 '무언가@무언가.무언가' 골격을 갖춰야 한다. +

+

+ 이 규칙들을 if문으로 필드마다 흩뿌리면, 나중에 "닉네임 + 최대 길이를 12에서 16으로" 바꿀 때 코드를 뒤져야 해요. 대신 규칙을{' '} + 데이터(객체)로 한곳에 선언하고, 검사기는 그 표를 읽기만 하게 만듭니다. +

+ {CODE_RULES} + {/* 학습 포인트: 로직과 데이터의 분리 — 규칙(변하는 것)과 검사기(안 변하는 것)를 갈라 둔다 */} +

+ 그런데 비밀번호 확인은 이 표로 못 잡아요. "올바른 값"이 고정된 규칙이 + 아니라 다른 필드(비밀번호)의 현재 값에 달려 있으니까요. 이런 걸{' '} + 교차 검증(cross-field validation)이라 부르고, 필드 하나가 아니라{' '} + 폼 전체를 받아 보는 단계에서 처리합니다. +

+ {CODE_CROSS} +
+ 규칙을 데이터로 두는 진짜 보상 섹션 5에서 서버에도 같은 검사를 만들 텐데, + 규칙이 표로 정리돼 있으면 서버 코드가 같은 표를 옮겨 적기만 하면 + 됩니다. 프론트는 8자, 서버는 6자 — 이런 규칙 어긋남이 이중 검증의 + 단골 버그인데, 표 하나가 그걸 막아 줘요. +
+
+ +
+

+ 검증 로직이 있어도 보여 주는 타이밍이 나쁘면 사용자를 괴롭히는 폼이 + 됩니다. 이메일 첫 글자 m을 치자마자 "이메일 형식이 + 아닙니다"라고 빨간 글씨가 뜨면 어떤 기분일까요? 아직 쓰는 중인데요. 그래서 보통{' '} + blur(필드를 떠날 때)를 기본 타이밍으로 잡습니다 — 다 쓰고 다음 칸으로 + 넘어갈 때 조용히 알려 주는 거죠. +

+ {CODE_ERROR_TIMING} + {/* 학습 포인트: touched는 '검증 결과'가 아니라 '보여줄지 말지'를 정하는 별도 상태다 */} +

+ 문구도 UX예요. "잘못된 입력입니다"는 판정문이지 안내문이 아닙니다. + 사용자가 다음에 할 행동이 문구에 들어 있어야 해요 — "이메일 형식으로 입력해 주세요. + 예: mirim@awesomedev.dev"처럼 고치는 방법과 예시까지요. 섹션 3의 규칙 + 표에 message를 함께 둔 이유가 이겁니다. 규칙과 안내문은 + 한 몸이에요. +

+

+ 마지막 배려는 포커스 이동입니다. 제출 버튼을 눌렀는데 화면 위쪽 어딘가의 + 필드가 틀렸다면? 사용자가 스크롤하며 빨간 글씨를 찾아 헤매게 하지 말고, 첫 번째 + 에러 필드로 포커스를 옮겨 주세요. 키보드만 쓰는 사용자와 화면낭독기 사용자에게는 + 배려를 넘어 필수입니다. +

+ {CODE_FOCUS} +
+ 타이밍 3단 정리 처음 쓰는 중엔 조용히(onChange 검사는 성급함) → + 필드를 떠나면 알려주고(blur가 기본기) → 제출 때{' '} + 전부 훑는다(안 만진 필드까지 최후의 그물). 단, 한 번 에러가 표시된 + 필드는 다시 입력하는 즉시 에러를 지워 주면 "고치고 있어요"가 실시간으로 반영돼 더 + 친절해요. +
+
+ +
+

+ 이제 검문소를 세울 차례예요. 섹션 1에서 봤듯 서버는 어떤 요청도 믿지 않고{' '} + 같은 규칙을 다시 검사합니다. 프론트의 규칙 표를 서버 코드로 옮겨 적는 거죠 — 필수·길이· + 포맷·교차 검증 전부요. 그리고 검증 로직을 라우트 핸들러 안에 섞지 않고{' '} + 미들웨어로 분리하면, 가입 처리 코드는 "검증이 끝난 깨끗한 값"만 받는 + 다는 확신을 가질 수 있어요. +

+ {CODE_SERVER_VALIDATE} + {/* 학습 포인트: 미들웨어 = 요청이 핸들러에 닿기 전 거치는 관문. 통과는 next(), 거절은 즉시 응답 */} +

+ 거절할 땐 상태코드 400 Bad Request를 씁니다 — "요청 자체가 규칙에 + 어긋난다"는 뜻이에요. 그런데 상태코드만큼 중요한 게 응답 몸통의 포맷 + 입니다. "뭔가 잘못됐어요" 한 줄짜리 메시지로는 프론트가 어느 입력칸에 + 에러를 그려야 할지 알 수 없거든요. +

+ {CODE_400_FORMAT} +
+ 포맷은 약속이다 errors 객체의 열쇠를{' '} + 폼 필드의 name과 똑같이 맞추는 것 — 이게 프론트·백 협업의 작은 계약서예요. + 이 약속만 지키면 프론트는 서버 에러를 받아 setErrors(data.errors){' '} + 한 줄로 화면에 뿌릴 수 있습니다. 다음 섹션에서 바로 해 볼 거예요. +
+
+ +
+

+ 서버가 단호하게 거절했다면, 프론트는 그 거절을 친절하게 번역해서 + 보여 줘야 해요. 섹션 5의 약속(필드명 열쇠의 errors 객체) + 덕분에, 400 응답의 필드 에러는 프론트의 에러 상태에 그대로 부어 넣으면{' '} + 각 입력칸 아래에 표시됩니다. +

+

+ 그런데 서버만 아는 에러가 있어요. 대표가 이메일 중복 + 입니다. 형식이 아무리 완벽해도 이미 가입된 이메일인지는 DB를 가진 서버만{' '} + 알 수 있죠. 이런 '이미 있는 것과 부딪히는' 상황엔 400 대신{' '} + 409 Conflict를 쓰는 관례가 있고, 프론트는 이를 받아 "이미 가입된 + 이메일이에요. 로그인해 보시겠어요?"처럼 다음 행동을 제안하는 문구로 + 안내합니다. +

+ {CODE_APPLY_SERVER_ERRORS} + {/* 학습 포인트: fetch는 400·409·500에도 catch로 안 떨어진다 — 상태코드 분기는 우리 몫, + catch에 오는 건 응답 자체가 없는 네트워크 실패다 */} +

+ 마지막 갈래는 네트워크 실패예요. 와이파이가 끊겼거나 서버가 죽어서{' '} + 응답 자체가 오지 않은 경우죠. 이건 사용자의 입력이 틀린 게 아니니까, + 입력칸 옆 빨간 글씨가 아니라 폼 상단의 안내로 "연결이 불안정해요. + 잠시 후 다시 시도해 주세요"처럼 구분해서 보여 줍니다. 검증 실패는{' '} + 사용자가 고칠 수 있는 문제, 네트워크 실패는{' '} + 기다리거나 재시도할 문제 — 안내가 달라야 해요. +

+
+ 세 갈래 요약 400 = 입력이 규칙 위반 → 필드별로 그린다 ·{' '} + 409 = 서버만 아는 충돌(중복) → 다음 행동을 제안한다 ·{' '} + 네트워크 실패 = 응답 없음 → 재시도를 안내한다. 에러의{' '} + 원인 주체(사용자/데이터/환경)가 다르면 화면도 달라집니다. +
+
+ +
+

+ 부품이 다 모였으니 제출 버튼을 눌렀을 때의 전체 흐름을 완성합시다. + 여기서 챙길 세 가지: ① 제출 중 버튼 비활성화 — 응답을 기다리는 동안 + 버튼을 연타하면 같은 가입 요청이 두 번 날아가요. submitting{' '} + 상태로 버튼을 잠그고, 문구도 "가입 처리 중..."으로 바꿔 기다리는 중임을{' '} + 보여 줍니다. +

+

+ ② 성공 시 뒷정리 — 폼을 초기화하고 환영 페이지로 이동합니다. 성공했는데 + 입력값이 그대로 남아 있으면 "된 건가?" 싶어 또 누르게 되거든요.{' '} + ③ 실패 시 입력값 보존 — 반대로 실패했을 땐 절대 폼을 비우지 않습니다. + 닉네임 하나 때문에 거절당했는데 이메일·비밀번호까지 처음부터 다시 치게 만드는 것만큼 + 사용자를 떠나보내는 지름길이 없어요. +

+ {CODE_SUBMIT_FLOW} + {/* 학습 포인트: finally는 성공·실패 어느 길로 빠져도 반드시 실행 — 잠금 해제를 여기 두면 + "에러 났는데 버튼이 영영 안 눌려요" 같은 잠금 고착 버그가 원천 차단된다 */} +
+ 실습 마무리 — 우회 공격 방어 확인 폼이 완성됐다면 섹션 1의 시연을{' '} + 스스로에게 해 보세요. 개발자도구 콘솔에서 엉터리 값으로{' '} + fetch('/api/signup', ...)을 직접 쏘는 겁니다. 서버가{' '} + 400으로 단호하게 거절하면 성공이에요. 프론트 검증을 지워도 서버가 + 막아 준다 — 이걸 눈으로 확인하는 순간, "검증은 두 번"이 지식이 아니라 감각이 됩니다. +
+
+ +
+
+

✅ 여기까지 왔다면

+

+ 회원가입 폼 하나에 이렇게 많은 설계가 숨어 있었어요. 프론트의 친절(제어 컴포넌트· + 규칙 표·blur 타이밍·포커스 이동)과 서버의 단호함(미들웨어 검증·400 포맷·409 중복), + 그리고 둘을 잇는 약속(필드명 열쇠의 errors 객체)까지 — 이제 여러분의 폼은 사용자를 + 돕고 서버를 지킵니다. 그런데 이번 코스에서 스친 질문이 하나 남아 있어요. 500 에러는? + 응답이 오다 만 경우는? 실패가 겹칠 땐 어디서부터 수습하죠? 다음{' '} + 에러 처리 패턴: 무너지지 + 않는 서비스 코스에서, 실패를 전제로 설계하는 감각을 이어서 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv2MiniProjectDeployPage.jsx b/frontend/src/pages/levels/Lv2MiniProjectDeployPage.jsx new file mode 100644 index 0000000..a95304b --- /dev/null +++ b/frontend/src/pages/levels/Lv2MiniProjectDeployPage.jsx @@ -0,0 +1,472 @@ +// 이 파일이 하는 일: "미니 프로젝트: 설계부터 배포까지" 코스 — 11주간 배운 조각(React·API·DB·git)을 +// 하나의 실제 서비스로 조립하는 마무리 강좌. 기획을 유저 스토리로 쪼개는 것에서 출발해, +// 명세 → 커밋 전략 → 프론트·백 조립(CORS) → 배포 준비 → 실제 배포 → 배포 후 운영 → 회고까지 +// 8개 섹션으로 안내하는 정적 학습 페이지. 프레이밍은 "포트폴리오가 되는 첫 서비스". +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_STORY = `아이디어: "내 블로그 서비스를 만들고 싶다" + ↓ 유저 스토리로 쪼개기 — "누가, 무엇을, 왜" + +"방문자는 글 목록을 최신순으로 볼 수 있다" [필수] 난이도 하 +"방문자는 글 하나를 눌러 본문을 읽을 수 있다" [필수] 난이도 하 +"나는 로그인해서 글을 쓰고 지울 수 있다" [필수] 난이도 중 +"방문자는 글에 댓글을 달 수 있다" [나중] 난이도 중 +"글을 카테고리로 묶어서 볼 수 있다" [나중] 난이도 중 +"다크 모드를 지원한다" [안 함] 난이도 하 + +핵심: [안 함]과 [나중] 목록이 [필수]보다 중요합니다. +기능을 포기하는 게 아니라, '끝나는 날'을 지키는 거예요. +전부 만들다 미완성으로 끝난 프로젝트는 포트폴리오가 못 됩니다.`; + +const CODE_SPEC = `화면에서 '거꾸로' API를 도출한다: + +[글 목록 화면] 이 화면에 뭐가 필요하지? → GET /api/posts +[글 상세 화면] 글 하나의 본문이 필요해 → GET /api/posts/1 +[글쓰기 화면] 작성한 글을 저장해야 해 → POST /api/posts + +명세서 양식 (3강에서 배운 그대로 재사용): +메서드 경로 요청 본문 응답 +GET /api/posts - 글 배열 (id·제목·날짜) +GET /api/posts/1 - 글 하나 (본문 포함) +POST /api/posts 제목·본문 (+토큰) 만든 글 · 201 Created +DELETE /api/posts/1 (+토큰) 204 No Content + +이 표가 곧 '프론트와 백엔드의 계약서'입니다. +계약서가 있으면 두 쪽을 따로 만들어도 나중에 맞아 들어가요.`; + +const CODE_COMMIT = `# 나쁜 하루 — 저녁에 몰아서 한 번 +git add . +git commit -m "오늘 작업" # 한 달 뒤의 나: "...뭘 했다는 거지?" + +# 좋은 하루 — 의미 단위로 여러 번 +git commit -m "feat: 글 목록 API 연동" +git commit -m "fix: 빈 목록일 때 안내 문구 표시" +git commit -m "feat: 글 상세 페이지 라우팅" + +# 브랜치 기본 흐름 — main은 '항상 돌아가는 상태'로 지킨다 +git switch -c feature/post-detail # 새 브랜치에서 작업 시작 +# ... 작업하고 커밋 (여러 번) ... +git switch main +git merge feature/post-detail # 다 되면 main에 합치기`; + +const CODE_CORS = `Access to fetch at 'http://localhost:8080/api/posts' +from origin 'http://localhost:5173' has been blocked by CORS policy: +No 'Access-Control-Allow-Origin' header is present on the +requested resource. + +번역: "프론트(5173)와 백엔드(8080)는 서로 다른 출처(origin)인데, +백엔드가 '5173 출신 요청 허용'이라고 밝히지 않아서 브라우저인 내가 +응답을 압수했다." + +해결은 항상 '서버 쪽'에서 — (Spring Boot 예) +@CrossOrigin(origins = "http://localhost:5173") // 컨트롤러에 붙이거나 +// 전역 CORS 설정에 허용할 출처를 등록한다. +// 프론트 코드를 아무리 고쳐도 CORS는 안 풀립니다. 서버의 허락이니까요.`; + +const CODE_ENV = `// 나쁜 예 — 주소를 코드에 박아 두면, 배포 날 전부 찾아 바꿔야 한다 +fetch('http://localhost:8080/api/posts') + +// 좋은 예 — 주소는 환경변수로, 코드는 그대로 +// .env.development → VITE_API_URL=http://localhost:8080 +// .env.production → VITE_API_URL=https://api.myblog.dev +const API = import.meta.env.VITE_API_URL; +fetch(API + '/api/posts') + +// 같은 코드가 내 컴퓨터에선 localhost로, 운영에선 실서버로 향합니다. +// "환경마다 달라지는 값은 코드 밖으로" — 배포의 제1원칙이에요.`; + +const CODE_PROXY = `// vite.config.js — 개발 프록시: 프론트 개발 서버가 /api를 대신 전달 +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:8080', // /api/* 요청을 백엔드로 넘겨 줘 + }, + }, +}); + +// 브라우저 입장에선 5173 하고만 대화 → 출처가 하나 → CORS가 아예 안 생김 +// 운영에선 게이트웨이·웹서버가 이 '중간 전달자' 역할을 그대로 이어받습니다.`; + +const CODE_BUILD = `npm run build # dist/ 폴더에 배포용 정적 파일이 만들어진다 + +개발 모드 (npm run dev) 프로덕션 빌드 (npm run build) +────────────────────────────────────────────────────────── +소스 그대로 · 즉시 새로고침 압축·난독화 · 파일명에 지문(hash) +에러 메시지가 친절하고 김 에러 메시지 최소화 (내부 노출 방지) +나 혼자 쓰니까 느려도 됨 성능이 곧 사용자 경험 +환경변수 .env.development 환경변수 .env.production + +포인트: 개발 모드는 '나를 위한 모드', 빌드는 '사용자를 위한 모드'. +배포 전에 반드시 빌드 결과물로 한 번 열어 봐야(npm run preview) 합니다.`; + +const CODE_SECRET = `# .gitignore 에 반드시 — 시크릿 파일은 저장소에 올리지 않는다 +.env +.env.production + +# .env.example 은 올린다 — '어떤 키가 필요한지' 목록만, 값은 비워서 +# DB_PASSWORD= ← 이렇게. 동료는 이걸 복사해 자기 값을 채운다 + +# 경고: 한 번이라도 커밋된 비밀번호는 '지워도' git 이력에 영원히 남습니다. +# 실수로 올렸다면 → 커밋 삭제가 아니라 '비밀번호 교체'가 정답이에요.`; + +const CODE_DEPLOY = `# ① 백엔드 + DB — 클라우드 서버(예: EC2 한 대)에 올리기 +./mvnw -q -DskipTests package # 서버용 jar 빌드 +scp target/blog.jar ubuntu@서버IP:~/app/ # 서버로 복사 +ssh ubuntu@서버IP +java -jar app/blog.jar # (실무에선 서비스로 등록해 상시 실행) + +# ② 프론트 — 정적 호스팅(Netlify·Vercel·S3 등)에 배포 +VITE_API_URL=https://api.myblog.dev npm run build +# → dist/ 를 업로드하면 끝. 정적 파일은 서버 프로세스가 필요 없어요. + +# ③ HTTPS 확인 — 요즘 웹의 기본값 +curl -I https://myblog.dev # 200 OK + 주소창 자물쇠 확인`; + +const CODE_LOG = `# 친구: "글쓰기 버튼 눌렀는데 아무 일도 안 일어나!" +# 내 컴퓨터에선 재현이 안 된다 → 서버 로그를 본다 + +tail -f app.log # 실시간으로 로그 지켜보기 +grep "ERROR" app.log | tail -20 # 최근 에러만 추려 보기 + +2026-07-20 21:14:03 ERROR PostController - +IllegalArgumentException: title must not be null + at PostService.create(PostService.java:31) + +읽는 법: 언제(21:14) · 어디서(PostController→PostService 31행) · +무엇이(title이 null). → 프론트가 제목 없이 POST를 보냈다 +→ 프론트에 유효성 검사를 넣고 핫픽스 배포. 로그가 곧 목격자입니다.`; + +const CODE_README = `# 미림 블로그 +글을 쓰고 나누는 미니 블로그 — 데모: https://myblog.dev +(첫 화면 스크린샷 1~2장) + +## 기술 스택 +React 19 · Vite · Spring Boot · PostgreSQL + +## 문제 → 해결 → 배운 점 +- 문제: 배포하니 상세 페이지에서 새로고침하면 404가 떴다 + 해결: 호스팅에 SPA 폴백(모든 경로 → index.html) 설정 + 배운 점: 라우팅은 브라우저만이 아니라 '서버도' 알아야 한다 + +- 문제: 로컬에선 되던 API가 배포 후 CORS로 막혔다 + 해결: 서버 허용 출처에 운영 도메인을 추가 + 배운 점: CORS는 프론트가 아니라 서버가 푸는 문제다 + +## 다음에 다르게 할 것 +- 기능을 다 만들고 배포하는 대신, 1주차에 '빈 화면'부터 배포하기`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '기능 목록' }, + { n: 2, label: '화면·API 명세' }, + { n: 3, label: '마일스톤·커밋' }, + { n: 4, label: '프론트·백 조립' }, + { n: 5, label: '배포 준비' }, + { n: 6, label: '실제 배포' }, + { n: 7, label: '배포 후' }, + { n: 8, label: '회고·포트폴리오' }, +]; + +export default function Lv2MiniProjectDeployPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 중급 과정
+

🚀 미니 프로젝트
— 설계부터 배포까지

+

+ 11주 동안 화면을 만들고, API를 부르고, DB에 저장하는 법을 각각 배웠어요. 이번 주엔 + 그 조각들을 하나의 진짜 서비스로 조립합니다. 기획을 기능 목록으로 + 자르고, 조각을 이어 붙이고, 실제 URL로 배포해서 친구가 써 보게 + 하고, 회고까지 — 끝나면 여러분 손에 포트폴리오가 되는 첫 서비스가 + 남습니다. +

+
+ 예상 소요 90분 + 실습: 내 서비스를 실제 URL로 배포 + 선수 지식: React·API·git 기초 +
+
+ + {/* 섹션 바로가기 — 학습 포인트: SECTIONS 배열 하나로 링크를 map 렌더링 */} +
+ {SECTIONS.map((s) => ( + + {s.n}. {s.label} + + ))} +
+ +
+

+ 프로젝트가 산으로 가는 첫 번째 이유는 코드가 아니라 기획이 덩어리째인 + 거예요. "블로그를 만든다"는 문장으론 오늘 뭘 해야 할지 알 수 없죠. 그래서 아이디어를{' '} + 유저 스토리 — "누가, 무엇을 할 수 있다" 형태의 문장 — 로 쪼갭니다. + 문장 하나가 만들 기능 하나가 되도록요. +

+ {CODE_STORY} +

+ 쪼갰으면 이제 MVP(최소 기능 제품)를 자릅니다. 위 예시에서 [필수] + 세 줄만 있어도 "글을 쓰고 읽는 블로그"는 성립해요. 나머지는 붙이면 좋은 장식이고요. + 역설적이지만 '이번엔 안 만든다' 목록이 더 중요합니다. 그 목록이 + 없으면 마감 전날의 여러분이 다크 모드를 만지고 있을 테니까요. +

+

+ 마지막으로 각 스토리에 난이도(상·중·하)와 우선순위를 매기세요. + 난이도 '하'인 [필수]부터 시작하면 초반에 완성 경험이 쌓여 탄력이 붙고, 난이도 '상'을 + 일찍 발견하면 일정을 미리 조정할 수 있습니다. +

+
+ 판별법 어떤 기능이 MVP인지 헷갈리면 이렇게 물어보세요 — "이게 없으면 이 + 서비스가 서비스로서 성립하지 않는가?" 성립한다면 [나중]입니다. + 블로그는 댓글 없이도 블로그지만, 글 읽기가 없으면 블로그가 아니에요. +
+
+ +
+

+ 기능 목록이 생겼으면 바로 코딩하고 싶겠지만, 한 단계만 참으세요.{' '} + 손그림 와이어프레임으로 화면 흐름부터 확정합니다. 종이에 네모 + 몇 개면 충분해요 — "목록 화면에서 글을 누르면 상세로, 글쓰기 버튼은 로그인해야 + 보이고..." 이 흐름을 그려 보면 빠뜨린 화면(로그인은? 글이 0개일 + 땐?)이 코드 짜기 전에 드러납니다. +

+

+ 화면이 정해지면 API는 화면에서 거꾸로 도출돼요. "이 화면을 그리려면 + 무슨 데이터가 필요하지?"를 화면마다 물으면 필요한 API 목록이 저절로 나옵니다. +

+ {CODE_SPEC} +

+ 양식은 3강에서 배운 명세서 그대로 재사용하면 됩니다. 이 표는{' '} + 프론트와 백엔드 사이의 계약서예요. 계약서가 있으면 프론트는 가짜 + 데이터로 화면을 먼저 만들고, 백엔드는 표대로 API를 만들어서, 나중에 둘을 붙이기만 + 하면 맞아 들어갑니다. +

+
+ 혼자 프로젝트여도 명세를 쓰는 이유 일주일 뒤의 나는 남입니다. "POST에 뭘 + 보내기로 했더라?"를 기억에 의존하면 프론트와 백엔드가 서로 다른 계약을 + 구현하게 돼요. 표 한 장이 그 어긋남을 막습니다. +
+
+ +
+

+ 4주짜리 프로젝트를 통째로 만들고 마지막에 실행해 보는 것만큼 위험한 계획은 없어요. + 대신 '항상 돌아가는 상태'를 유지하는 작은 단위로 갑니다. 1주차 끝엔 + 글 목록만 나오는 앱이, 2주차 끝엔 상세까지 되는 앱이 돌아가는 채로{' '} + 존재하는 거예요. 무언가 깨져도 "어제까진 됐다"는 확실한 복귀 지점이 있고요. +

+

+ 그 리듬을 기록하는 도구가 커밋입니다. 커밋은 저장 버튼이 아니라{' '} + 의미 있는 변화 한 덩이예요. 메시지 규칙은 우리가 계속 봐 온 그대로 — + 새 기능은 feat:, 버그 수정은{' '} + fix:. +

+ {CODE_COMMIT} +

+ 브랜치는 이 원칙을 지키는 안전장치입니다. main은 항상 + 돌아가는 버전으로 남겨 두고, 새 기능은 브랜치에서 작업하다가 완성되면 + 합치는(merge) 흐름이 기본이에요. 실험이 실패하면 브랜치만 버리면 되니 + 부담 없이 시도할 수 있습니다. +

+
+ 커밋 크기 감각 "커밋 메시지를 한 문장으로 못 쓰겠다"면 커밋이 너무 큰 + 거예요. 반대로 fix: 오타가 하루 20개면 너무 잘게 썬 + 거고요. 유저 스토리 하나를 커밋 2~5개로 만드는 정도가 좋은 + 출발점입니다. +
+
+ +
+

+ 이제 각자 만들던 프론트와 백엔드를 연결합니다. 그리고 거의 모든 사람이 여기서{' '} + 같은 벽에 부딪혀요. 콘솔을 빨갛게 물들이는 그 이름, CORS. +

+ {CODE_CORS} +

+ CORS는 버그가 아니라 브라우저의 보안 규칙이에요. 브라우저는 "다른 + 출처(도메인·포트가 다르면 다른 출처!)에서 온 응답은, 그 서버가 명시적으로 허락한 + 경우에만 JS에게 준다"는 원칙을 지킵니다. 그래서 해결도 항상{' '} + 서버 쪽에서 허용 출처를 등록하는 것이지, 프론트 코드를 고치는 게 + 아니에요. +

+

+ 연결이 되면 다음 함정은 주소 하드코딩입니다. 코드 곳곳에{' '} + localhost:8080이 박혀 있으면 배포하는 날 전부 찾아 + 바꿔야 하죠. API 주소는 환경변수로 분리합니다. +

+ {CODE_ENV} +

+ 그리고 로컬 개발을 매끄럽게 해 주는 마지막 조각, 개발 프록시예요. + 프론트 개발 서버가 /api 요청을 백엔드로 대신 + 전달하게 하면, 브라우저는 출처 하나와만 대화하니 CORS가 아예 생기지 않습니다. +

+ {CODE_PROXY} +
+ 에러를 읽는 태도 CORS 에러 메시지엔 어느 출처에서 어디로 보낸 요청이 왜 + 막혔는지가 전부 적혀 있어요. 빨간 글씨를 닫지 말고 한 줄씩 + 번역해 보는 습관 — 이게 이번 프로젝트에서 얻어 갈 가장 큰 근육입니다. +
+
+ +
+

+ npm run dev로 보던 화면을 그대로 서버에 올리면 될까요? + 안 됩니다. 개발 모드는 나를 위한 모드예요 — 소스를 압축하지 않고, + 에러를 장황하게 보여 주고, 즉시 새로고침을 위해 무거운 도구를 달고 있죠. 사용자에게 + 내보내는 건 프로덕션 빌드입니다. +

+ {CODE_BUILD} +

+ 빌드와 함께 챙길 것이 개발·운영 환경 분리예요. DB 주소, API 주소, + 로그 수준 — 환경마다 달라지는 값은 전부 .env 계열 + 파일로 나눠 관리합니다. 개발용은{' '} + .env.development, 운영용은{' '} + .env.production. 코드는 하나, 설정만 갈아 끼우는 + 구조죠. +

+

+ 그리고 이 강좌에서 딱 하나만 기억해야 한다면 이것입니다.{' '} + 시크릿(DB 비밀번호, API 키)은 절대 커밋하지 않는다. +

+ {CODE_SECRET} +
+ 왜 '교체'가 정답인가 git은 이력을 보존하는 도구라, 다음 커밋에서 지워도{' '} + 과거 커밋엔 그대로 남아요. 저장소를 본 적 있는 모든 사람(과 봇)이 + 그 비밀번호를 가졌다고 가정해야 합니다. 그래서 유출된 시크릿은 지우는 게 아니라{' '} + 새 값으로 바꾸는 것이 유일한 수습이에요. +
+
+ +
+

+ 드디어 인터넷에 올립니다. 우리 구조는 두 갈래예요. 백엔드와 DB는 클라우드 + 서버(계속 켜져 있어야 하는 프로세스니까)에, 프론트는 정적 + 호스팅(빌드 결과물은 그냥 파일이니까)에 올립니다. +

+ {CODE_DEPLOY} +

+ 프론트를 올릴 때 핵심은 빌드 시점에 운영 API 주소를 넣는 것이에요. + 섹션 4에서 분리해 둔 VITE_API_URL이 여기서 빛을 + 냅니다 — 코드 수정 없이 환경변수만 바꿔 빌드하면 프론트가 실서버를 바라봐요. +

+

+ 마지막 확인은 HTTPS. 주소창의 자물쇠는 장식이 아니라 "사용자와 서버 + 사이의 대화를 중간에서 훔쳐보거나 바꿀 수 없다"는 보증입니다. + 로그인 비밀번호가 오가는 서비스라면 선택이 아니라 기본값이에요. 요즘 정적 호스팅은 + HTTPS를 자동으로 켜 주지만, 백엔드 API 주소도 https인지까지 꼭 + 확인하세요 — 페이지는 https인데 API가 http면 브라우저가 혼합 콘텐츠로 차단합니다. +

+
+ 배포는 이벤트가 아니라 반복 첫 배포에서 한 번에 다 되길 기대하지 마세요. + 보통 대여섯 번의 시행착오(환경변수 누락, CORS에 운영 도메인 추가 + 누락, 새로고침 404...)가 정상입니다. 그래서 마감 전날이 아니라{' '} + 1~2주차에 '빈 화면'부터 배포해 두는 팀이 이깁니다. +
+
+ +
+

+ 배포가 끝이 아니라 여기서부터가 진짜예요. 친구 두 명에게 URL을 + 보내고 아무 설명 없이 써 보게 하세요. 장담하는데, 10분 안에 여러분이 한 번도 못 본 + 버그가 나옵니다. 내가 만든 순서대로만 써 온 나와 달리, 사용자는{' '} + 내가 상상 못 한 순서로 누르거든요. +

+

+ "안 돼요"라는 제보를 받으면 어디를 볼까요? 사용자의 화면은 못 보지만,{' '} + 서버 로그는 모든 요청의 목격자입니다. +

+ {CODE_LOG} +

+ 원인을 찾아 고쳤으면 핫픽스 배포 — 고치고, 커밋하고( + fix:), 다시 배포하는 한 바퀴를 직접 돌아 보세요. + 이 사이클을 한 번이라도 돌아 본 사람과 아닌 사람은 실무 감각이 완전히 다릅니다. +

+

+ 그리고 이번 주 내내 만나게 될 문장, "내 컴퓨터에선 됐는데..."의 + 원인은 대개 이 유형들이에요. ① 환경 차이 — 내 PC에만 있는 파일·설정· + 환경변수(운영 .env 누락이 단골). ② 데이터 + 차이 — 내 DB엔 늘 데이터가 있지만 실서버 첫 화면은 글 0개, 빈 배열에서 + 터지는 코드. ③ 주소·경로 차이 — 하드코딩된 localhost, http/https + 혼용, 새로고침 404. 셋 다 공통점은 코드가 아니라 코드를 둘러싼 환경이 + 문제라는 것 — 그래서 로그와 설정부터 의심하는 게 지름길입니다. +

+
+ 피드백 받는 요령 "어때?"라고 물으면 "좋네"만 돌아와요. 대신{' '} + 과제를 주세요 — "글 하나 쓰고, 지워 봐 줘." 그리고 막히는 순간에 + 입을 떼지 말고 지켜보세요. 사용자가 3초 머뭇거린 그 화면이 여러분의 다음 이슈입니다. +
+
+ +
+

+ 마지막 조각은 코드가 아니라 입니다. 몇 달 뒤 이 프로젝트를 보게 될 + 사람(면접관, 동료, 그리고 미래의 나)은 코드 1만 줄을 읽어 주지 않아요.{' '} + README가 곧 프로젝트의 얼굴입니다. 구조는{' '} + 문제 → 해결 → 배운 점 — "뭘 만들었다"보다 "뭘 만났고 어떻게 + 넘었다"가 백 배 힘이 셉니다. +

+ {CODE_README} +

+ 필수 요소 세 가지 — 스크린샷(안 열어 봐도 뭔지 알게),{' '} + 데모 URL(직접 눌러 볼 수 있게), 기술 스택(뭘 다뤄 + 봤는지 한눈에). 이 셋이 있는 저장소와 없는 저장소는 받는 대접이 다릅니다. +

+

+ 그리고 회고. 거창할 필요 없이 "다음에 다르게 할 것" 딱 하나만 + 뽑으세요. "테스트를 미루지 않기"든 "배포를 1주차에 하기"든 — 하나를 골라 적는 행위 + 자체가, 이번 경험을 다음 프로젝트의 실력으로 바꿔 줍니다. 열 개의 반성보다 실행되는 + 하나가 낫습니다. +

+
+ 회고 3질문 ① 계획대비 뭐가 달랐나(예측 훈련) ② 가장 오래 막힌 곳은 + 어디였고 결국 뭐가 문제였나(디버깅 복기) ③ 다음에 다르게 할 것 하나(실행 항목). + 30분이면 충분하고, 이 세 답이 그대로 README의 뼈대가 됩니다. +
+
+ + {/* 마무리 카드 — 다음 행선지 안내 */} +
+
+

🚀 여기까지 왔다면

+

+ 축하해요. 이제 여러분에겐 실제 URL로 열리는 서비스가 있습니다. + 아이디어를 유저 스토리로 자르고, 명세라는 계약서를 쓰고, 항상 돌아가는 상태를 + 지키며 조립하고, CORS와 환경변수를 넘어 배포하고, 로그로 버그를 쫓고, README로 + 갈무리하는 — 서비스 하나의 전체 생애를 한 바퀴 돌았어요. 이 + 사이클은 규모가 커져도 모양이 같습니다. 앞으로 만들 모든 것의 축소판을 이미 손에 + 쥔 셈이에요. 다음 여정은 학습 센터에서 — + 방금 배포한 그 서비스를 더 단단하게 만들 다음 강좌를 골라 이어 가 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv2PracticalTestingPage.jsx b/frontend/src/pages/levels/Lv2PracticalTestingPage.jsx new file mode 100644 index 0000000..6b8d829 --- /dev/null +++ b/frontend/src/pages/levels/Lv2PracticalTestingPage.jsx @@ -0,0 +1,506 @@ +// 이 파일이 하는 일: "테스트 작성 실전: 자신 있게 고치는 힘" 코스 — 왜 테스트가 리팩터링의 +// 안전망인지에서 출발해, Vitest 단위 테스트·무엇을 테스트할지 고르는 눈·supertest API 통합 +// 테스트·테스트 DB 격리·Testing Library 컴포넌트 테스트·지속 가능한 습관까지 7개 섹션으로 +// 안내하는 정적 학습 페이지. 프레이밍은 "고칠 때마다 손이 떨리지 않는 개발자" — 기초 문법을 +// 아는 수강생이 실제로 돌아가는 서비스에 테스트를 '붙이는' 경험을 하도록 이끈다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_COST = `수동 확인의 하루 테스트가 있는 하루 +────────────────── ────────────────── +코드 수정 코드 수정 +→ 서버 재시작 → npm test (수 초) +→ 브라우저 열고 로그인 → 초록불 확인, 커밋 +→ 화면 눌러 보며 확인 +→ "다른 데는 안 깨졌겠지..?" +→ (사실 깨졌는데 모름) + +핵심: 수동 확인은 '고친 곳'만 봅니다. 테스트는 '고치지 않은 곳'까지 +기계가 매번 전부 다시 확인해 줍니다. 이게 회귀(regression) 버그를 +잡는 안전망이고, 코드를 자신 있게 고칠 수 있는 힘의 정체예요.`; + +const CODE_FIRST_TEST = `// src/utils/validate.js — 테스트할 순수 함수 (입력 → 출력, 부수효과 없음) +export function isValidEmail(email) { + if (typeof email !== 'string') return false; + const at = email.indexOf('@'); + const dot = email.lastIndexOf('.'); + return at > 0 && dot > at + 1 && dot < email.length - 1; +} + +// src/utils/validate.test.js — 같은 폴더에 .test.js를 나란히 두는 관례 +import { describe, it, expect } from 'vitest'; +import { isValidEmail } from './validate'; + +describe('isValidEmail', () => { + it('올바른 이메일이면 true를 돌려준다', () => { + const input = 'intern@awesomedev.dev'; // ① 준비 (Arrange) + const result = isValidEmail(input); // ② 실행 (Act) + expect(result).toBe(true); // ③ 검증 (Assert) + }); + + it('@가 없으면 false를 돌려준다', () => { + expect(isValidEmail('intern.awesomedev.dev')).toBe(false); + }); +});`; + +const CODE_RUN = `# frontend/ 에서 — Vitest 설치와 실행 +npm install -D vitest + +# package.json의 "scripts"에 한 줄: +# "test": "vitest" + +npm test # 감시 모드: 파일을 저장할 때마다 관련 테스트 재실행 +npx vitest run # 한 번만 실행하고 종료 (CI에서 쓰는 방식) + +# 실행 결과 읽는 법: +# ✓ isValidEmail > 올바른 이메일이면 true를 돌려준다 +# ✗ isValidEmail > @가 없으면 false를 돌려준다 +# → expected true to be false ← 어떤 '명세'가 깨졌는지 문장으로 보인다`; + +const CODE_EDGE = `// 무엇을 테스트할까 — 정상 경로 1개보다 경계·실패 3개가 값지다 +import { describe, it, expect } from 'vitest'; +import { calcDiscount } from './price'; + +describe('calcDiscount', () => { + // 정상 경로: 물론 필요하다. 하지만 여기서 멈추면 반쪽짜리. + it('10000원에 10% 할인이면 9000원이다', () => { + expect(calcDiscount(10000, 10)).toBe(9000); + }); + + // 경계값: 버그는 늘 '가장자리'에서 태어난다 + it('할인율 0%면 원가 그대로다', () => { + expect(calcDiscount(10000, 0)).toBe(10000); + }); + it('할인율 100%면 0원이다', () => { + expect(calcDiscount(10000, 100)).toBe(0); + }); + + // 실패 케이스: 잘못된 입력에 어떻게 '행동해야 하는지'도 명세다 + it('할인율이 음수면 에러를 던진다', () => { + expect(() => calcDiscount(10000, -5)).toThrow(); + }); +});`; + +const CODE_SUPERTEST = `// server/routes/auth.test.js — supertest로 '진짜 HTTP 요청'을 흉내 내기 +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import { app } from '../app'; // listen() 하지 않은 Express 앱 객체를 그대로 넘긴다 + +describe('POST /api/login', () => { + it('올바른 계정이면 200과 토큰을 돌려준다', async () => { + const res = await request(app) + .post('/api/login') + .send({ email: 'intern@test.dev', password: 'pass1234!' }); + + expect(res.status).toBe(200); // ① 상태 코드 + expect(res.body.token).toBeTypeOf('string'); // ② 응답 바디 + expect(res.headers['content-type']).toContain('json'); // ③ 헤더까지 + }); + + it('비밀번호가 틀리면 401을 돌려준다', async () => { + const res = await request(app) + .post('/api/login') + .send({ email: 'intern@test.dev', password: 'wrong!' }); + + expect(res.status).toBe(401); + expect(res.body.token).toBeUndefined(); // 실패했는데 토큰이 새면 안 된다 + }); +}); + +describe('POST /api/signup', () => { + it('이미 가입된 이메일이면 409를 돌려준다', async () => { + await request(app).post('/api/signup') + .send({ email: 'dup@test.dev', password: 'pass1234!' }); + const res = await request(app).post('/api/signup') + .send({ email: 'dup@test.dev', password: 'pass1234!' }); + + expect(res.status).toBe(409); // Conflict — '중복'의 표준 대답 + }); +});`; + +const CODE_AUTH_FLOW = `// 로그인이 필요한 API 테스트 — 테스트 안에서 세션(토큰)을 직접 만든다 +it('내 정보 조회는 로그인한 사용자에게만 열린다', async () => { + // ① 준비: 로그인부터 해서 토큰을 손에 쥔다 + const login = await request(app) + .post('/api/login') + .send({ email: 'intern@test.dev', password: 'pass1234!' }); + const token = login.body.token; + + // ② 실행: 토큰을 붙여 보호된 API를 부른다 + const res = await request(app) + .get('/api/me') + .set('Authorization', 'Bearer ' + token); + + // ③ 검증 + expect(res.status).toBe(200); + expect(res.body.email).toBe('intern@test.dev'); +}); + +it('토큰 없이 부르면 401이다', async () => { + const res = await request(app).get('/api/me'); + expect(res.status).toBe(401); +});`; + +const CODE_TESTDB = `// tests/setup.js — 매 테스트 전에 '깨끗한 무대'를 다시 차린다 +import { beforeEach } from 'vitest'; +import { db } from '../server/db'; + +beforeEach(async () => { + await db.query('DELETE FROM users'); // ① 초기화: 이전 테스트의 흔적 제거 + await db.query( // ② 시드: 늘 같은 출발점 심기 + "INSERT INTO users (email, password_hash) VALUES ('intern@test.dev', '...')" + ); +}); + +// .env.test — 테스트는 반드시 '자기 전용 DB'를 본다 +// DATABASE_URL=postgres://localhost:5432/mirim_test ← 개발 DB와 다른 이름! + +// 위험 신호 체크리스트: +// · 테스트를 한 개만 돌리면 실패하는데 전체를 돌리면 성공한다 +// → 앞 테스트가 남긴 데이터에 기대고 있다는 뜻 (순서 의존) +// · 테스트 순서를 바꿨더니 결과가 달라진다 → 같은 병 +// · 로컬에선 되는데 동료 PC에선 깨진다 → 시드 없이 '내 DB 상태'에 기댄 것`; + +const CODE_RTL = `// SignupForm.test.jsx — 사용자가 보는 대로: 역할과 라벨로 찾고, 입력하고, 확인한다 +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import SignupForm from './SignupForm'; + +describe('SignupForm', () => { + it('이메일 없이 제출하면 검증 에러가 보인다', async () => { + const user = userEvent.setup(); + render(); // ① 준비: 화면에 그린다 + + // ② 실행: 사용자가 하듯 — 버튼을 '가입하기'라는 이름으로 찾아 누른다 + await user.click(screen.getByRole('button', { name: '가입하기' })); + + // ③ 검증: 화면에 에러 문구가 '보이는가' + expect(screen.getByText('이메일을 입력해 주세요')).toBeInTheDocument(); + }); + + it('짧은 비밀번호를 입력하면 안내 문구가 보인다', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('이메일'), 'intern@test.dev'); + await user.type(screen.getByLabelText('비밀번호'), '123'); // 8자 미만 + await user.click(screen.getByRole('button', { name: '가입하기' })); + + expect(screen.getByText('비밀번호는 8자 이상이어야 해요')).toBeInTheDocument(); + }); +}); + +// getByRole('button', { name: ... }) / getByLabelText(...) 가 기본기. +// CSS 클래스나 내부 state로 찾기 시작하면 '구현'에 붙는 테스트가 된다.`; + +const CODE_HABIT = `// package.json — 테스트를 '흐름'에 심는다 +{ + "scripts": { + "test": "vitest", + "test:run": "vitest run", + "precommit": "vitest run" // 커밋 전에 자동 실행 (husky 등으로 연결) + } +} + +# CI(예: GitHub Actions / Jenkins)의 핵심 한 줄도 결국 같다: +# npm ci && npx vitest run +# 초록불이어야만 병합 — '깨진 채로 합쳐지는 일'을 구조적으로 막는다. + +# 커버리지는 참고 지표로만: +# npx vitest run --coverage +# 숫자 100%보다 "핵심 시나리오 10개가 전부 초록"이 훨씬 믿을 만하다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다(React 입문 섹션 2). props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '왜 테스트인가' }, + { n: 2, label: '첫 단위 테스트' }, + { n: 3, label: '무엇을 테스트할까' }, + { n: 4, label: 'API 통합 테스트' }, + { n: 5, label: '테스트용 DB' }, + { n: 6, label: '컴포넌트 테스트' }, + { n: 7, label: '지속 가능한 습관' }, +]; + +export default function Lv2PracticalTestingPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 중급 과정
+

테스트 작성 실전
: 자신 있게 고치는 힘

+

+ 코드를 고칠 때마다 "다른 데가 깨지진 않았을까" 손이 떨린 적 있나요? 그 떨림을 없애는 + 도구가 테스트입니다. 이 코스에서는 순수 함수의 단위 테스트부터 + 로그인 API의 통합 테스트, 회원가입 폼의 컴포넌트 테스트까지 — + 장식이 아니라 실제 코드에 붙는 테스트를 직접 써 봅니다. +

+
+ 예상 소요 70분 + 실습: 핵심 시나리오 테스트 10개 + 선수 지식: JS 기초 · HTTP · React 입문 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 기능을 하나 고치면, 정말 무서운 건 고친 곳이 아니라{' '} + 안 고친 곳이에요. 어제까지 멀쩡하던 로그인이 오늘 결제 코드를 만졌더니 + 깨지는 것 — 이런 버그를 회귀(regression)라고 부릅니다. 사람 눈으로는 + 매번 전부 다시 확인할 수 없지만, 테스트가 있으면 기계가 수 초 만에 전 + 영역을 다시 확인해 줍니다. +

+ {CODE_COST} +

+ "테스트 쓸 시간에 기능 하나 더 만들지" 싶을 수 있어요. 처음 며칠은 맞는 말입니다. + 하지만 서비스가 커질수록 수동 확인 비용은 매번 들고, 테스트 작성 비용은{' '} + 한 번만 듭니다. 이 두 곡선이 교차하는 시점 — 대략 "고칠 때마다 확인할 + 화면이 세 개를 넘는 순간" — 부터는 테스트가 있는 쪽이 무조건 빠릅니다. +

+

+ 관점을 하나 더 얹을게요. 잘 쓴 테스트는 실행 가능한 명세서예요. + "비밀번호가 틀리면 401을 돌려준다"라는 문서는 낡을 수 있지만, 같은 내용의 테스트는 + 코드가 그 약속을 어기는 순간 빨간불로 알려 줍니다. 문서는 거짓말을 해도 + 테스트는 못 하죠. +

+
+ 비유 테스트는 등반가의 로프예요. 로프를 거는 데 시간이 들지만, + 로프가 있어야 과감하게 다음 홀드로 손을 뻗을 수 있죠. 리팩터링도 같습니다 — 안전망이 + 있는 개발자만 코드를 자신 있게 고칩니다. 없는 개발자는 "건드리면 + 깨질까 봐" 낡은 코드를 평생 껴안고 살아요. +
+
+ +
+

+ 가장 작은 단위부터 시작합니다. 단위 테스트(unit test)는 함수 하나를 + 떼어 놓고 "이 입력을 주면 이 출력이 나와야 한다"를 확인하는 테스트예요. 첫 대상으로는{' '} + 순수 함수 — DB도 네트워크도 안 건드리고 입력만으로 출력이 정해지는 + 함수 — 가 제일 좋습니다. 준비할 게 없어서 테스트가 짧고 빠르거든요. +

+ {CODE_FIRST_TEST} +

+ 테스트 본문의 세 줄을 보세요. ① 준비(Arrange) — 입력을 만들고,{' '} + ② 실행(Act) — 대상 함수를 부르고, ③ 검증(Assert) — + 결과가 기대와 같은지 expect로 단언합니다. 이{' '} + AAA 패턴은 앞으로 만나는 모든 테스트 — API든 컴포넌트든 — 의 공통 + 뼈대예요. 테스트가 읽기 어렵다면 십중팔구 이 세 단계가 뒤엉켜 있는 겁니다. +

+ {CODE_RUN} +

+ 테스트 이름도 기술입니다. 'test1'이나{' '} + 'works'가 아니라 "무엇이 어때야 한다" — + "올바른 이메일이면 true를 돌려준다"처럼 쓰세요. 그래야 실패했을 때 코드를 열어 보지 + 않아도 어떤 약속이 깨졌는지 결과 화면만으로 읽힙니다. 섹션 1에서 말한 + '실행 가능한 명세서'는 바로 이 이름들의 목록이에요. +

+
+ 직접 확인해 보기 일부러 isValidEmail의{' '} + at > 0at >= 0으로 + 바꿔 보세요. 저장하는 순간 감시 모드의 테스트가 빨간불로 바뀝니다. 이 "고장 내면 즉시 + 알아챈다"는 감각이 테스트의 본질입니다 — 초록불만 보다 끝나면 테스트를 배운 게 아니에요. +
+
+ +
+

+ 도구를 익혔으니 이제 과녁을 고를 차례예요. 초보의 테스트는 대부분 + "잘 되는 경우"만 확인하고 끝납니다. 하지만 잘 되는 경우는 개발하면서 이미 수십 번 + 눌러 봤잖아요? 버그는 가장자리에서 태어납니다 — 0%와 100%, 빈 문자열, + 음수, 딱 경계에 걸친 값. 테스트의 가성비는 여기서 나와요. +

+ {CODE_EDGE} +

+ 어디부터 쓸지 막막하면 우선순위는 이렇습니다. ① 검증 함수(이메일·비밀번호 + 규칙 — 틀리면 가입이 막히거나 뚫린다), ② 돈 계산(할인·수수료 — 틀리면 + 실제 손해), ③ 날짜·상태 전환 로직(마감 판정 같은 것). 공통점은{' '} + 틀렸을 때 아픈 순수 로직이라는 거예요. 화면 꾸미기 코드보다 이런 곳의 + 테스트 한 개가 열 배 값집니다. +

+

+ 그리고 한 가지 원칙 — 구현이 아니라 동작을 테스트하세요. "내부에서 + indexOf를 호출한다"를 검증하면, 나중에 정규식으로 + 리팩터링하는 순간 기능은 멀쩡한데 테스트만 깨집니다. 안전망이어야 할 + 테스트가 리팩터링을 방해하는 족쇄가 되는 거죠. 테스트는 바깥에서 보이는 약속 — + 입력과 출력 — 에만 붙이세요. +

+
+ 고르는 눈 새 함수를 짰다면 스스로에게 물어보세요 — "이 함수가{' '} + 제일 이상한 입력을 받는다면 뭘까?" 그 답(빈 값, 음수, 한계값, 잘못된 + 타입)이 곧 다음에 쓸 테스트 목록입니다. 정상 1개 + 경계 2개 + 실패 1개, 이 4종 세트를 + 기본기로 삼으면 됩니다. +
+
+ +
+

+ 단위 테스트가 부품 검사라면, 통합 테스트는 조립된 기계를 돌려 보는 + 검사예요. 라우터·미들웨어·DB까지 이어진 진짜 요청↔응답을 확인합니다. + Node 서버에서는 supertest가 표준 도구예요 — + 서버를 실제 포트에 띄우지 않고도 앱 객체에 HTTP 요청을 그대로 흉내 내 줍니다. +

+ {CODE_SUPERTEST} +

+ 검증 대상은 세 층입니다. ① 상태 코드 — 200·401·409처럼 "무슨 일이 + 일어났는지"의 요약, ② 응답 바디 — 토큰이 있는지, 실패했는데 있으면 안 + 될 값이 새어 나오진 않는지, ③ 헤더 —{' '} + content-type 같은 형식 약속. 특히 "틀린 비밀번호인데 + 토큰이 undefined인지"처럼 없어야 할 것을 + 확인하는 단언은 보안 사고를 미리 막는 테스트입니다. +

+

+ 로그인이 필요한 API는 어떻게 할까요? 테스트 안에서 세션을 직접 만들면{' '} + 됩니다 — 준비 단계에서 로그인 API를 먼저 불러 토큰을 받고, 그 토큰을 붙여 보호된 API를 + 부르는 거죠. AAA 패턴의 '준비'가 조금 길어질 뿐, 뼈대는 똑같습니다. +

+ {CODE_AUTH_FLOW} +
+ 실습 예고 이 코스의 실습 과제 — 로그인 API의 성공 · 비밀번호 틀림 · + 중복 가입 시나리오 — 가 바로 이 섹션의 패턴 그대로예요. 위 코드에서 상태 + 코드(200 · 401 · 409)와 바디 검증을 여러분의 라우트에 맞게 옮겨 쓰는 것부터 시작하세요. +
+
+ +
+

+ API 테스트가 DB를 건드리는 순간 새 문제가 생깁니다. 개발하며 쓰던 DB에 테스트를 돌리면{' '} + 테스트가 내 데이터를 지우고, 반대로 내가 넣어 둔 데이터 때문에{' '} + 테스트 결과가 그날그날 달라져요. 그래서 철칙 하나 —{' '} + 테스트는 자기 전용 DB를 본다.{' '} + mirim_dev와 별개로 mirim_test를 + 만들고, 테스트 실행 때만 그쪽을 가리키게 합니다. +

+ {CODE_TESTDB} +

+ 두 번째 철칙은 독립성이에요. 모든 테스트는{' '} + beforeEach에서 초기화(비우기) → 시드(기본 데이터 + 심기)를 거쳐 항상 같은 출발점에서 시작해야 합니다. 그래야 어떤 테스트든{' '} + 혼자 돌려도, 어떤 순서로 돌려도 같은 결과가 나와요. 섹션 4의 중복 가입 + 테스트가 늘 성공하는 이유도, 매번 users 테이블이 깨끗한 상태에서 시작하기 때문입니다. +

+

+ 거꾸로 말하면 — "테스트 A를 먼저 돌려야 B가 통과한다" 같은 순서 의존이 + 생겼다면 그건 위험 신호예요. 지금은 우연히 초록불이지만, 테스트를 병렬로 돌리거나 하나만 + 골라 돌리는 순간 무너집니다. 위 코드 주석의 체크리스트 세 줄이 그 병의 대표 증상이니, + 보이면 바로 시드부터 점검하세요. +

+
+ 비유 좋은 테스트는 연극 무대와 같아요. 매 공연(테스트) 전에 + 무대를 싹 치우고(초기화) 소품을 제자리에 배치합니다(시드). 앞 공연이 어질러 놓은 무대에서 + 다음 공연을 시작하면, 배우(코드)는 그대로인데 공연만 이상하게 망가지죠. +
+
+ +
+

+ 마지막 조각은 화면이에요. Testing Library의 철학은 한 문장입니다 —{' '} + "사용자가 쓰는 방식대로 테스트하라." 사용자는 컴포넌트의 state나 CSS + 클래스를 몰라요. 그저 '가입하기'라고 적힌 버튼을 보고, '이메일'이라는{' '} + 라벨이 붙은 칸에 입력하죠. 테스트도 똑같이 찾고, 똑같이 행동해야 합니다. +

+ {CODE_RTL} +

+ 요소를 찾는 기본기는 두 가지입니다. getByRole — + "버튼·제목·링크 같은 역할 + 보이는 이름"으로 찾기,{' '} + getByLabelText — 입력 칸을{' '} + 라벨 텍스트로 찾기. 이 둘이 잘 안 통하는 화면은 대개 스크린 리더 + 사용자에게도 불친절한 화면이에요 — 접근성과 테스트 용이성은 같은 뿌리에서 나옵니다. +

+

+ 흐름은 역시 AAA예요. render로 그리고(준비),{' '} + userEvent로 클릭·입력하고(실행), "화면에 이 문구가 + 보이는가"를 단언합니다(검증). 주의할 점은 userEvent가{' '} + 비동기라는 것 — await를 빼먹으면 + 이벤트가 끝나기 전에 검증이 먼저 달려서 엉뚱하게 실패합니다. +

+
+ 실습 예고 실습 과제의 나머지 절반 — 회원가입 폼의 검증 에러 표시 — + 가 이 패턴입니다. "빈 채로 제출하면 에러 문구가 보인다", "짧은 비밀번호면 안내가 보인다" + 같은 시나리오를 getByRole· + getByLabelText만으로 써 보세요. 클래스 이름으로 찾고 싶어지는 + 순간이 오면, 그게 바로 '구현에 붙은 테스트'로 미끄러지는 순간입니다. +
+
+ +
+

+ 도구와 과녁을 다 익혔으니, 마지막은 오래가는 습관이에요. 먼저 흔한 함정 + 하나 — 커버리지 100%. 커버리지는 "코드의 몇 %가 테스트 중에 실행됐나"라는 + 숫자일 뿐, 제대로 검증했는가와는 다릅니다. 단언 없이 함수만 호출해도 + 커버리지는 오르거든요. 숫자를 채우려 억지 테스트를 늘리는 것보다, 아픈 시나리오 + 10개가 확실히 초록인 쪽이 훨씬 믿을 만합니다. +

+

+ 두 번째 함정은 깨지기 쉬운 테스트예요. 대표가 snapshot 남용 — 화면 + 전체를 통째로 사진 찍어 비교하는 테스트는, 여백 한 줄만 바꿔도 깨집니다. 그러면 개발자는 + 내용을 보지 않고 습관적으로 스냅샷을 갱신하게 되고, 그 순간 테스트는 + 아무것도 지키지 않는 장식이 돼요. 자주 깨지는데 버그는 못 잡는 테스트가 보이면, 고치지 + 말고 시나리오 테스트로 다시 쓰세요. +

+ {CODE_HABIT} +

+ 마지막으로, 테스트는 사람의 기억이 아니라 흐름에 심어야 삽니다. + "커밋 전에 꼭 돌려야지"는 반드시 잊게 돼요. 커밋 훅에서 자동으로 돌고, CI에서{' '} + vitest run이 초록이어야만 병합되게 만들면 — 깨진 코드가 + 합쳐지는 사고를 사람의 성실함이 아니라 구조가 막아 줍니다. +

+
+ 졸업 기준 이 코스를 제대로 통과했다면, 이제 리팩터링 요청이 두렵지 않아야 해요. + "일단 테스트부터 돌려 보고요" — 이 한마디가 자연스럽게 나오면 됩니다. 실습 과제(로그인 + API 3종 + 회원가입 폼 시나리오, 합계 10개)를 전부 초록으로 만들고 다음 강좌로 넘어가세요. +
+
+ +
+
+

🧪 여기까지 왔다면

+

+ 이제 여러분에게는 안전망이 생겼어요. 순수 함수를 AAA로 검증하고, + 경계값과 실패 케이스를 먼저 노리고, supertest로 API의 약속을 붙들고, 격리된 테스트 + DB 위에서 독립적으로 돌리고, 사용자가 보는 대로 컴포넌트를 확인하는 — 그리고 이 모두를 + 커밋과 CI의 흐름에 심는 힘. 코드를 고치는 손이 더는 떨리지 않죠. 그럼 이 자신감으로 + 무엇을 할까요? 처음부터 끝까지 — 설계하고, 만들고, 테스트하고, 세상에 내놓는 것까지. + 다음 미니 프로젝트: 설계부터 + 배포까지 강좌에서, 지금까지 배운 조각들을 하나의 진짜 서비스로 + 완성해 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv2ReactRoutingPage.jsx b/frontend/src/pages/levels/Lv2ReactRoutingPage.jsx new file mode 100644 index 0000000..bd6d103 --- /dev/null +++ b/frontend/src/pages/levels/Lv2ReactRoutingPage.jsx @@ -0,0 +1,510 @@ +// 이 파일이 하는 일: "React Router로 멀티 페이지 SPA" 코스 — 새로고침 없이 화면이 바뀌는 +// SPA에서 '페이지'가 무엇인지에서 출발해, 라우트 선언·URL 파라미터·중첩 레이아웃·쿼리스트링· +// 프로그래매틱 이동·보호 라우트까지 7개 섹션으로 안내하는 정적 학습 페이지. +// 프레이밍은 "주소창이 곧 상태다" — URL을 공유·북마크 가능한 상태 저장소로 보는 관점을 심고, +// 블로그 프론트(목록·상세·글쓰기)를 라우팅으로 엮는 실전 감각까지 데려간다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── +// 학습 포인트: 템플릿 리터럴 안에서 문자열 조립은 ' + '를 쓴다 — 상수 안에 백틱·보간이 +// 들어가면 이 파일 자체가 깨지기 때문. 예제 코드도 그 규칙에 맞춰 작성했다. + +const CODE_SPA_VS_MPA = `전통 웹(MPA) ──────────────── SPA(Single Page App) +(페이지마다 HTML을 새로 받음) (HTML은 한 번, 이후엔 JS가 화면 교체) + +링크 클릭 → 서버에 요청 링크 클릭 → JS가 주소만 바꾸고 +→ 흰 화면 → 새 HTML 도착 → 필요한 컴포넌트로 화면 교체 +→ 화면 전체 다시 그림 → 새로고침 없음, 상태 유지 + +핵심: SPA에서 '페이지 이동'은 실제 이동이 아니라 +"주소를 바꾸고, 그 주소에 맞는 컴포넌트를 그리는" 연출이에요. +그 연출을 브라우저 표준 History API(pushState)가 받쳐 주고, +React Router는 그 위에 얹힌 '주소 ↔ 컴포넌트' 매칭 담당입니다.`; + +const CODE_ROUTES = `// App.jsx — 경로와 컴포넌트를 짝지어 선언한다 +import { BrowserRouter, Routes, Route } from 'react-router-dom'; + +function App() { + return ( + + + } /> + } /> + } /> + + + ); +} + +// 읽는 법: "주소가 /posts면 PostListPage를 그려라." +// 라우트 선언은 지도이지 순서도가 아니에요 — 어떤 주소에 +// 어떤 화면이 대응하는지를 한눈에 보여 주는 목차입니다.`; + +const CODE_LINK = `// 이동은 가 아니라 로 +import { Link, NavLink } from 'react-router-dom'; + +// X 이렇게 하면 SPA가 무너져요 — 브라우저가 서버에 새 HTML을 +// 요청해서 앱 전체가 새로고침(상태 전부 증발)됩니다. +글 목록 + +// O Link는 History API로 주소만 바꾸고, 라우터가 화면을 교체 +글 목록 + +// NavLink는 '지금 이 주소에 있음'을 아는 Link — +// 현재 경로와 일치하면 isActive가 true가 되어 메뉴 강조에 딱 + (nav.isActive ? { fontWeight: 700 } : undefined)}> + 글 목록 +`; + +const CODE_PARAMS = `// ① 선언: :id 자리는 "무엇이든 올 수 있는 빈칸" +} /> + +// ② 꺼내기: PostDetailPage 안에서 useParams로 +import { useParams } from 'react-router-dom'; + +function PostDetailPage() { + const { id } = useParams(); // /posts/42 로 왔다면 id === "42" + + // ③ 그 값으로 상세 API를 조회한다 (문자열임에 주의!) + useEffect(() => { + fetch('/api/posts/' + id) + .then((res) => { + if (res.status === 404) throw new Error('NOT_FOUND'); + return res.json(); + }) + .then(setPost) + .catch(() => setNotFound(true)); // ④ 없는 글이면 화면에 반영 + }, [id]); + + if (notFound) return

존재하지 않는 글이에요. 목록으로 돌아가세요.

; + // ... +}`; + +const CODE_NESTED = `// 중첩 라우트 — 공통 뼈대(레이아웃)는 부모, 내용은 자식 +import { Outlet } from 'react-router-dom'; + +function BlogLayout() { + return ( +
+
블로그 공통 헤더 · 메뉴
+ {/* ← 자식 라우트가 그려지는 '자리' */} +
공통 푸터
+
+ ); +} + + + }> + } /> {/* /posts */} + } /> {/* /posts/42 */} + + + {/* 관리 구역도 같은 원리 — /admin/* 전체가 AdminLayout 안에서 */} + }> + } /> + } /> + + + +// Outlet은 '액자'예요. 액자(레이아웃)는 그대로 두고 +// 그림(자식 페이지)만 갈아 끼우니, 헤더가 깜빡이지 않습니다.`; + +const CODE_SEARCH = `// 검색 조건을 URL에 싣기 — /posts?keyword=react&page=2 +import { useSearchParams } from 'react-router-dom'; + +function PostListPage() { + const [searchParams, setSearchParams] = useSearchParams(); + + const keyword = searchParams.get('keyword') || ''; // 없으면 빈 문자열 + const page = Number(searchParams.get('page') || 1); + + // 검색 버튼을 누르면 state가 아니라 'URL'을 바꾼다 + function handleSearch(nextKeyword) { + setSearchParams({ keyword: nextKeyword, page: 1 }); + } + + // 이 URL을 복사해 친구에게 보내면, 친구도 '같은 검색 결과'를 봅니다. + // 새로고침해도 keyword가 URL에 살아 있으니 결과가 유지되고요. + // useState였다면? 새로고침 순간 검색어는 증발합니다. +}`; + +const CODE_NAVIGATE = `// 코드가 이동을 '결정'해야 할 때 — useNavigate +import { useNavigate } from 'react-router-dom'; + +function WritePage() { + const navigate = useNavigate(); + + async function handleSubmit(form) { + const res = await fetch('/api/posts', { method: 'POST', body: form }); + const saved = await res.json(); + + // 저장 성공 → 방금 쓴 글의 상세 페이지로 데려가기 + navigate('/posts/' + saved.id); + } +} + +// 자주 쓰는 변형 3가지: +navigate(-1); // 뒤로 가기 — '취소' 버튼에 제격 +navigate('/login', { replace: true }); // 현재 기록을 '교체' — 뒤로 가기로 + // 못 돌아오게 (로그인 후 이동 등) +navigate('/posts/7', { state: { from: 'write' } }); // 데이터 살짝 동봉 +// state는 새로고침엔 살아남지만 'URL 직접 입력'으론 재현이 안 돼요. +// 그러니 없어도 화면이 그려지게 — 보조 정보만 담는 게 안전합니다.`; + +const CODE_PROTECTED = `// ① 없는 주소 안내 — 와일드카드(*)는 항상 맨 마지막 그물 + + } /> + } /> + } /> {/* 어디에도 안 걸리면 */} + + +// ② 보호 라우트 — 문 앞에서 로그인 여부를 확인하는 문지기 +import { Navigate, useLocation } from 'react-router-dom'; + +function ProtectedRoute({ children }) { + const location = useLocation(); + const isLoggedIn = Boolean(localStorage.getItem('accessToken')); + + if (!isLoggedIn) { + // 어디를 가려다 막혔는지(state.from)를 들려 보낸다 + return ; + } + return children; +} + + +} /> + +// ③ 로그인 성공 후 — 원래 가려던 곳으로 되돌려 보내기 +function LoginPage() { + const navigate = useNavigate(); + const location = useLocation(); + + async function handleLogin(form) { + await login(form); + const from = location.state?.from?.pathname || '/'; + navigate(from, { replace: true }); // 글쓰기를 가려다 막혔다면 → /write로 + } +}`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: 'SPA의 페이지란' }, + { n: 2, label: '라우트 선언과 이동' }, + { n: 3, label: 'URL 파라미터' }, + { n: 4, label: '중첩 라우트·레이아웃' }, + { n: 5, label: '쿼리스트링' }, + { n: 6, label: '프로그래매틱 이동' }, + { n: 7, label: '404와 보호 라우트' }, +]; + +export default function Lv2ReactRoutingPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 중급 과정
+

React Router로
멀티 페이지 SPA

+

+ "React 앱인데 페이지가 여러 개인 것처럼 움직여요. 어떻게요?" — 비밀은{' '} + 주소창이 곧 상태라는 데 있어요. URL 파라미터로 상세 페이지를 열고, + 중첩 레이아웃으로 구역을 나누고, 보호 라우트로 문을 지키면 — 화면 한 장짜리 연습작이{' '} + 진짜 앱의 골격을 갖춥니다. 블로그 프론트에 목록·상세·글쓰기를 이어 + 붙이며 그 골격을 손에 익혀 봅시다. +

+
+ 예상 소요 60분 + 실습: 블로그 목록·상세·글쓰기 라우팅 + 선수 지식: React 컴포넌트·fetch 기초 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 전통적인 웹에선 링크를 누를 때마다 브라우저가 서버에 새 HTML 문서를 + 요청했어요. 흰 화면이 잠깐 스치고, 페이지 전체가 다시 그려지죠. 반면 우리가 만드는{' '} + SPA(Single Page App)는 HTML을 처음에 딱 한 번만 받고, 그다음부터는{' '} + 자바스크립트가 화면을 갈아 끼웁니다. 페이지 '이동'처럼 보이는 것은 + 사실 "주소를 바꾸고, 그 주소에 맞는 컴포넌트를 그리는" 연출이에요. +

+ {CODE_SPA_VS_MPA} +

+ 이 연출을 받쳐 주는 브라우저 표준 기능이 History API예요.{' '} + history.pushState()를 부르면 브라우저는{' '} + 서버에 요청을 보내지 않고 주소창의 URL만 바꾸고, 방문 기록에 한 줄을 + 쌓습니다. 그래서 뒤로 가기 버튼도 자연스럽게 동작하죠. React Router는 이 API 위에 + 얹혀서 "지금 주소가 뭐지? → 그럼 이 컴포넌트를 그리자"를 대신해 주는 도구입니다. +

+

+ 여기서 이 코스 전체를 관통하는 관점 하나를 챙겨 가세요.{' '} + URL은 공유·북마크가 가능한 상태 저장소라는 관점이에요.{' '} + /posts/42라는 주소엔 "42번 글을 보는 중"이라는 상태가,{' '} + ?keyword=react엔 "react로 검색한 상태"가 담겨 있죠. + 이 주소를 복사해 동료에게 보내면 동료도 같은 화면을 봅니다.{' '} + useState 속 상태는 절대 못 하는 일이에요. +

+
+ 역할 분담 그럼 서버 라우팅은 필요 없어진 걸까요? 아니에요. 서버는{' '} + 어떤 주소로 와도 같은 index.html을 내주는 일(그리고{' '} + /api/* 데이터 응답)을 맡고, "그 주소에 어떤 화면을 + 보여 줄지"는 클라이언트(React Router)가 맡습니다. 배포 후{' '} + /posts/42에서 새로고침하면 404가 뜨는 단골 사고가 + 바로 이 분담 설정(모든 경로 → index.html)이 빠졌을 때 생겨요. +
+
+ +
+

+ 라우팅의 절반은 선언이에요. "이 주소면 이 컴포넌트"라는 짝을{' '} + <Routes> 안에{' '} + <Route>로 나열합니다. 우리 블로그 실습이라면 + 목록(/posts)·글쓰기(/write) + 부터 시작하죠. +

+ {CODE_ROUTES} +

+ 나머지 절반은 이동입니다. 그리고 여기에 SPA 초보가 가장 많이 밟는 + 지뢰가 있어요. 습관처럼 <a href>를 쓰는 것.{' '} + <a>는 브라우저의 기본 동작 — "서버에 새 문서 + 요청" — 을 일으켜서 앱 전체가 새로고침됩니다. 애써 쌓은 상태(입력 + 중이던 글, 스크롤 위치)가 전부 증발하죠. 그래서 앱 내부 이동은 반드시{' '} + <Link>를 씁니다. +

+ {CODE_LINK} +
+ Link와 NavLink, 언제 뭘? 본문 속 일반 링크는 Link, + 상단 메뉴·사이드바처럼 "내가 지금 여기 있음"을 표시해야 하는 곳은{' '} + NavLink예요. NavLink는 + 현재 주소와 자기 to가 일치하는지를{' '} + isActive로 알려 주니, 활성 메뉴 강조를 직접 계산할 + 필요가 없습니다. +
+
+ +
+

+ 블로그 글이 1,000개라면 라우트를 1,000개 선언해야 할까요? 당연히 아니죠. 경로에{' '} + 빈칸을 뚫어 두면 됩니다. /posts/:id의{' '} + :id가 그 빈칸이에요. /posts/1도{' '} + /posts/999도 전부 이 한 줄에 걸리고, 빈칸에 들어온 실제 + 값은 컴포넌트 안에서 useParams()로 꺼냅니다. +

+ {CODE_PARAMS} +

+ 흐름을 말로 풀면 이래요. 사용자가 목록에서 글을 클릭 →{' '} + /posts/42로 이동 → 상세 컴포넌트가{' '} + useParams()"42"를 꺼냄 → + 그 값으로 /api/posts/42를 조회 → 응답을 화면에 그림.{' '} + 주소가 곧 조회 조건인 셈이죠. +

+

+ 한 가지 더 — 성실한 상세 페이지는 실패도 그립니다. 사용자가 주소창에{' '} + /posts/99999를 직접 쳐서 들어올 수 있으니까요. 서버가 + 404를 돌려주면 "존재하지 않는 글" 안내와 목록으로 돌아가는 길을 보여 주는 것까지가 + 상세 페이지의 일입니다. 위 코드의 notFound 분기가 그 + 역할이에요. +

+
+ 파라미터는 항상 문자열 useParams()가 주는 값은{' '} + 42(숫자)가 아니라 "42" + (문자열)이에요. 숫자 비교나 계산이 필요하면 Number(id)로 + 바꿔 쓰세요. id === 42가 조용히 false가 되는 버그의 + 단골 원인입니다. +
+
+ +
+

+ 목록과 상세 페이지엔 공통점이 있어요. 둘 다 같은 헤더, 같은 사이드바를 씁니다. + 페이지마다 헤더를 복사해 붙일 수도 있지만, 그러면 메뉴 하나 바꿀 때마다 모든 파일을 + 고쳐야 하죠. React Router의 답은 레이아웃 라우트예요. 공통 뼈대를{' '} + 부모 라우트로 두고, 각 페이지를 자식 라우트로 + 중첩시키는 겁니다. +

+ {CODE_NESTED} +

+ 열쇠는 <Outlet />이에요. 부모 레이아웃 안에서{' '} + "자식 라우트가 그려질 자리"를 표시하는 구멍입니다.{' '} + /posts로 오면 그 구멍에 목록이,{' '} + /posts/42로 오면 상세가 끼워지죠. 액자(헤더·푸터)는 + 한 번 그려진 채 그대로 있고 그림만 바뀌니, 페이지를 오가도 헤더가 + 깜빡이지 않고 헤더 안의 상태(예: 열어 둔 드롭다운)도 유지됩니다. +

+

+ 이 구조는 구역 나누기에도 그대로 쓰여요. 위 코드의{' '} + /admin처럼 관리 영역 전체를{' '} + AdminLayout 밑에 두면, 관리 전용 사이드바·권한 확인을{' '} + 한 곳에 두고 하위 페이지를 늘려 갈 수 있습니다. 섹션 7의 보호 + 라우트를 레이아웃에 걸면 "구역 전체를 한 번에 잠그는" 것도 되고요. +

+
+ index 라우트 자식 중 index가 붙은 라우트는 + "부모 주소 그 자체(/posts)로 왔을 때" + 그려지는 기본 화면이에요. path가 없는 대신 index가 그 역할을 표시합니다. 폴더의{' '} + index.html과 같은 감각이죠. +
+
+ +
+

+ 블로그에 검색을 붙인다고 해 봅시다. 검색어를 useState에 + 담으면 동작은 해요. 하지만 새로고침하는 순간 검색어가 증발하고, "이 검색 결과 좀 봐" + 하고 링크를 보내 줄 방법도 없죠. 검색 조건처럼 화면의 정체성을 이루는 + 상태는 URL의 쿼리스트링(?keyword=react&page=2) + 에 싣는 게 정석입니다. +

+ {CODE_SEARCH} +

+ useSearchParamsuseState와 + 닮은꼴이에요 — 읽는 값과 바꾸는 함수의 쌍. 다른 점은 저장소가 메모리가 아니라{' '} + 주소창이라는 것뿐이죠. 덕분에 새로고침해도, 북마크했다가 다음 주에 + 열어도, 링크를 받은 동료가 열어도 같은 검색 결과가 재현됩니다. +

+

+ 그럼 뭐든 URL에 실으면 될까요? 판단 기준은 이거예요.{' '} + "이 상태가 담긴 링크를 남에게 보냈을 때 의미가 있는가?" 검색어·페이지 + 번호·정렬 순서·선택한 탭 → 의미 있음, URL로. 입력 중인 글 초안·모달 열림 여부·마우스 + 호버 상태 → 남에게 보내도 의미 없음, useState로. + 경로(/posts/:id)가 "어느 방에 있나"라면, 쿼리스트링은 + "그 방을 어떻게 보고 있나"입니다. +

+
+ 파라미터 vs 쿼리스트링 자원의 식별은 경로 파라미터 + (/posts/42), 조회의 옵션은 쿼리스트링 + (?page=2&sort=recent) — 백엔드 API 설계에서 배우는 + 그 관례가 프론트 라우팅에도 똑같이 적용돼요. 프론트와 서버가 같은 문법으로 대화하는 + 셈입니다. +
+
+ +
+

+ Link사용자가 클릭해서 이동하는 + 도구예요. 그런데 이동의 주도권이 코드에 있어야 할 때가 있죠. 대표 장면이 우리 실습의{' '} + 글 작성 완료입니다. 저장 버튼 → API 호출 → 성공 응답이 오면{' '} + 그때 방금 쓴 글의 상세 페이지로 데려가야 해요. "성공하면"이라는 + 조건이 붙는 순간, 필요한 건 useNavigate입니다. +

+ {CODE_NAVIGATE} +

+ 변형 세 가지도 눈에 익혀 두세요. navigate(-1)은{' '} + 뒤로 가기 — 글쓰기 화면의 '취소' 버튼처럼 "왔던 곳이 어디든 그리로 + 돌아가기"에 알맞아요. replace: true는 방문 기록에 줄을{' '} + 추가하지 않고 현재 줄을 교체합니다. 로그인 성공 후 이동에 쓰면, + 사용자가 뒤로 가기를 눌러도 로그인 화면으로 되돌아가지 않죠(이미 로그인했는데 또 + 로그인 폼이 뜨면 이상하니까요). +

+

+ state 옵션은 이동하면서 데이터를 살짝 동봉하는 + 방법이에요. 다만 규칙이 있습니다 — state는 주소창에 보이지 않아서, 그 URL을{' '} + 직접 입력해 들어온 사람에겐 없어요. 그러니 화면을 그리는 데 꼭 필요한 + 값(글 id 같은 것)은 반드시 URL(파라미터·쿼리)에 싣고, state엔 "없어도 동작에 지장 + 없는 보조 정보"(어느 화면에서 왔는지 등)만 담는 게 안전한 사용법입니다. +

+
+ navigate 남용 주의 사용자가 클릭해서 가는 이동까지{' '} + onClick + navigate로 짜지 마세요.{' '} + Link는 진짜 <a> 태그를 + 만들어 주므로 "새 탭에서 열기", 마우스 오버 시 주소 미리보기, 접근성까지 공짜로 + 얻습니다. 클릭 이동은 Link, 조건부 이동은 navigate — 이 한 줄이면 + 충분해요. +
+
+ +
+

+ 마지막으로 앱의 가장자리를 다듬을 차례예요. 첫째, 사용자가 어떤 + 엉뚱한 주소로 들어와도 흰 화면 대신 안내를 보여 줘야 합니다. 라우트 경로의{' '} + 와일드카드 *는 "위의 어떤 라우트에도 + 걸리지 않은 나머지 전부"를 받아 내는 그물이에요. 여기에 404 페이지를 + 걸어 둡니다. +

+

+ 둘째, 우리 실습의 마지막 요구사항 — 미로그인 사용자가 글쓰기 + (/write)에 접근하면 로그인 페이지로 보내기.{' '} + 라우트와 페이지 사이에 문지기 컴포넌트를 세우는{' '} + ProtectedRoute 패턴으로 풉니다. +

+ {CODE_PROTECTED} +

+ 문지기의 동작을 따라가 볼까요. /write 요청이 오면{' '} + ProtectedRoute가 먼저 렌더링되고, 로그인 상태를 + 확인해요. 로그인돼 있으면 children(글쓰기 페이지)을 + 그대로 통과시키고, 아니면 <Navigate>로 로그인 + 페이지에 돌려보냅니다. 이때 두 가지 배려가 들어가요.{' '} + replace로 기록을 교체해서 뒤로 가기가 다시 문 앞으로 + 오지 않게 하고, state.from에{' '} + 원래 가려던 곳을 들려 보냅니다. +

+

+ 그 from이 빛나는 순간이 로그인 성공 직후예요. "글을 + 쓰려다 로그인 페이지로 튕긴" 사용자를 로그인이 끝나자마자{' '} + 원래 목적지(/write)로 되돌려 보내는 거죠. + 홈으로 던져 놓고 "다시 찾아가세요" 하는 앱과, 가려던 곳에 데려다주는 앱 — 사용자는 + 이 차이를 반드시 느낍니다. +

+
+ 프론트 보호는 UX, 진짜 보안은 서버 ProtectedRoute는 + 어디까지나 화면 안내예요. 개발자 도구를 열 줄 아는 사용자는 프론트 + 검사를 우회할 수 있습니다. 글 저장을 실제로 막는 최후의 방어선은{' '} + 서버의 인증 검사(토큰 검증)예요. 프론트 문지기는 "정상 사용자가 + 헛걸음하지 않게 하는 배려", 서버 검사는 "누구도 뚫을 수 없는 잠금장치" — 둘 다 + 있어야 완성입니다. +
+
+ +
+
+

🧭 여기까지 왔다면

+

+ 이제 주소창을 보는 눈이 달라졌을 거예요. URL은 공유·북마크되는 상태 + 저장소이고, 라우트 선언은 앱의 지도이며, Outlet은 그림만 갈아 끼우는 + 액자, ProtectedRoute는 문 앞의 문지기 — 블로그의 목록·상세·글쓰기를 잇는 골격이 + 전부 손에 들어왔습니다. 그런데 글쓰기 페이지의 문을 열고 들어간 사용자가 마주하는 + 건 결국 이에요. 빈 제목으로 저장을 누르면? 본문이 10만 자면? + 악의적인 입력이 서버까지 닿으면? 다음{' '} + 폼 검증: 사용자를 돕고 서버를 + 지키기 강좌에서, 라우팅으로 세운 골격에 믿을 수 있는 입력 처리를 + 채워 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv2ReactStateEffectsPage.jsx b/frontend/src/pages/levels/Lv2ReactStateEffectsPage.jsx new file mode 100644 index 0000000..875d4e7 --- /dev/null +++ b/frontend/src/pages/levels/Lv2ReactStateEffectsPage.jsx @@ -0,0 +1,561 @@ +// 이 파일이 하는 일: "React 상태와 이펙트 실전" 코스 — 입문에서 배운 useState를 +// '상태 설계'의 관점으로 끌어올리고, 불변 업데이트·상태 끌어올리기·useEffect·데이터 페칭· +// 커스텀 훅까지 7개 섹션으로 안내하는 정적 학습 페이지. 프레이밍은 "상태를 다루는 손에서 +// 상태를 설계하는 눈으로" — 실습은 우리 블로그 API에서 글 목록을 불러와 검색·필터가 +// 실시간으로 동작하는 목록 화면을 만드는 것이다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_UI_F_STATE = `UI = f(state) + +state(데이터) ──→ f(컴포넌트 함수) ──→ UI(화면) + + count: 3 function Counter()

3개

+ count: 4 같은 함수, 다시 실행

4개

+ +핵심: React에서 화면은 "그때그때 손으로 고치는 것"이 아니라 +"상태를 넣으면 나오는 계산 결과"예요. 화면을 바꾸고 싶으면 +DOM을 만지는 게 아니라 상태를 바꿉니다. 나머지는 React의 몫.`; + +const CODE_RENDER_FLOW = `// 상태가 바뀌면 React가 하는 일 — 순서대로 추적해 보기 +function Counter() { + const [count, setCount] = useState(0); + + return ( + + ); +} + +// ① 클릭 → setCount(1) 호출. 이 순간 count가 바로 바뀌는 게 아니라 +// "다음 렌더링 때 1로 해 줘"라고 예약만 됩니다. +// ② React가 Counter 함수를 '처음부터 다시' 실행 — 이게 리렌더링. +// 이번엔 useState가 1을 돌려줘요. +// ③ 새 JSX(1개)와 이전 JSX(0개)를 비교해서, 달라진 텍스트만 +// 실제 DOM에 반영합니다. 전체를 다시 그리지 않아요.`; + +const CODE_DERIVED = `// 장바구니 — 무엇을 state로 두고, 무엇을 계산으로 둘 것인가 +const [items, setItems] = useState([]); // ① 진짜 원본 데이터 → state + +// ② 아래는 전부 items에서 '파생'되는 값 → state가 아니라 그냥 계산 +const totalCount = items.length; +const totalPrice = items.reduce((sum, it) => sum + it.price * it.qty, 0); +const isEmpty = items.length === 0; + +// 렌더링될 때마다 다시 계산되니 items와 어긋날 방법이 없어요. +// state로 두는 순간부터는 "items가 바뀔 때마다 같이 고쳐 줘야 하는 짐"이 됩니다.`; + +const CODE_DUP_BUG = `// 나쁜 예 — 같은 사실을 두 곳에 저장하면 언젠가 서로 어긋난다 +const [items, setItems] = useState([]); +const [totalPrice, setTotalPrice] = useState(0); // items에서 파생 가능한데 state로! + +function addItem(item) { + setItems([...items, item]); + setTotalPrice(totalPrice + item.price); // 여기까진 기억했는데... +} + +function removeItem(id) { + setItems(items.filter((it) => it.id !== id)); + // 앗, totalPrice 갱신을 깜빡! → 화면의 합계가 실제와 어긋남. + // 이런 버그는 조용히 숨어 있다가 한참 뒤에 발견돼요. +} + +// 고치는 법: totalPrice state를 지우고, 렌더링 때 items에서 계산. +// "최소 상태" 원칙 — 저장은 원본만, 나머지는 계산으로.`; + +const CODE_MUTATE_BUG = `// 왜 직접 수정하면 화면이 안 바뀔까? +const [user, setUser] = useState({ name: '미림', level: 1 }); + +// 나쁜 예 — 객체 내부를 직접 수정 +function levelUpWrong() { + user.level = 2; // 값은 바뀌었지만... + setUser(user); // '같은 객체'를 넘김 → React: "이전이랑 같은데? 스킵" +} + +// React는 성능을 위해 내용물을 다 뒤져 보지 않고 +// "이전 객체 === 새 객체" 참조 비교만 합니다. +// 같은 상자를 다시 건네면, 안의 내용이 바뀌었어도 알아채지 못해요. + +// 좋은 예 — 새 객체를 만들어 교체 +function levelUpRight() { + setUser({ ...user, level: 2 }); // 새 상자 → 참조가 다름 → 리렌더! +}`; + +const CODE_IMMUTABLE = `// 배열 상태의 불변 업데이트 3종 세트 — 전부 '새 배열을 돌려주는' 메서드만 사용 +const [todos, setTodos] = useState([]); + +// 추가: push 대신 스프레드로 새 배열 +setTodos([...todos, newTodo]); + +// 삭제: splice 대신 filter +setTodos(todos.filter((t) => t.id !== targetId)); + +// 수정: 직접 대입 대신 map으로 "그 항목만 새 객체로 교체" +setTodos(todos.map((t) => + t.id === targetId ? { ...t, done: !t.done } : t +)); + +// 금지 목록(원본을 바꾸는 메서드): push, pop, splice, sort, reverse +// 허용 목록(새 배열을 주는 메서드): map, filter, slice, concat, [...spread]`; + +const CODE_NESTED = `// 중첩 객체 업데이트 — 바깥부터 안쪽까지 '지나는 길목의 껍데기'를 전부 복사 +const [profile, setProfile] = useState({ + name: '미림', + settings: { theme: 'dark', noti: { email: true, push: false } }, +}); + +// push 알림만 켜고 싶다면: +setProfile({ + ...profile, // ① 최상위 복사 + settings: { + ...profile.settings, // ② settings 복사 + noti: { + ...profile.settings.noti, // ③ noti 복사 + push: true, // ④ 바꿀 값만 교체 + }, + }, +}); + +// 세 겹만 넘어가면 손이 아프죠? 그건 "상태가 너무 깊다"는 신호이기도 해요. +// 상태를 평평하게(flat) 쪼개거나, 나중에 배울 useReducer를 고려할 타이밍입니다.`; + +const CODE_LIFT = `// 상태 끌어올리기 — 검색창과 목록이 같은 상태를 봐야 할 때 +// 나쁜 구조: SearchBar 안에 query가 갇혀 있으면 PostList가 볼 수 없다. + +// 좋은 구조: 두 형제가 함께 쓰는 '최소 공통 부모'가 상태의 주인이 된다. +function PostBoard() { + const [query, setQuery] = useState(''); // 주인은 부모 + + return ( +
+ {/* 값 + 바꿀 권한 */} + {/* 값만 읽기 */} +
+ ); +} + +function SearchBar({ query, onChange }) { + return ( + onChange(e.target.value)} /> + ); +} + +// 데이터는 아래로(props), 변경 요청은 위로(콜백). React의 기본 물길이에요.`; + +const CODE_EFFECT_DEPS = `// 의존성 배열 3형태 — "언제 다시 실행되는가"가 전부 다르다 +useEffect(() => { + console.log('매 렌더링마다 실행'); +}); // ① 배열 없음: 렌더링될 때마다 — 거의 안 씀 + +useEffect(() => { + console.log('처음 화면에 나타날 때 딱 한 번'); +}, []); // ② 빈 배열: 마운트 시 1회 — 초기 데이터 로드에 애용 + +useEffect(() => { + console.log('query가 바뀔 때마다 실행'); +}, [query]); // ③ [query]: 첫 렌더 + query가 달라진 렌더 후 + +// 규칙: 이펙트 안에서 읽는 컴포넌트의 값(state·props)은 +// 전부 의존성 배열에 넣는 게 원칙이에요. 빼먹으면 '옛날 값'을 보는 버그가 생깁니다.`; + +const CODE_CLEANUP = `// 클린업 함수 — 이펙트가 벌인 일을 치우는 담당 +useEffect(() => { + const timer = setInterval(() => { + console.log('1초마다 똑딱'); + }, 1000); + + return () => clearInterval(timer); // ← 이게 클린업 함수 +}, []); + +// 클린업이 실행되는 정확한 시점 두 가지: +// ① 컴포넌트가 화면에서 사라질 때(언마운트) +// ② 의존성이 바뀌어 이펙트가 '다시' 실행되기 직전 +// (새 이펙트 실행 전에, 이전 이펙트의 클린업이 먼저!) +// +// 순서 예: query가 'a' → 'ab'로 바뀌면 +// 'a' 이펙트의 클린업 실행 → 'ab' 이펙트 실행 +// 이 순서 덕분에 타이머·구독이 중복으로 쌓이지 않아요.`; + +const CODE_FETCH = `// 데이터 페칭의 정석 — 로딩·에러·데이터 3상태 + 레이스 컨디션 방어 +function PostList({ query }) { + const [posts, setPosts] = useState([]); // ① 데이터 + const [loading, setLoading] = useState(true); // ② 로딩 중인가 + const [error, setError] = useState(null); // ③ 실패했는가 + + useEffect(() => { + let ignore = false; // 이번 요청이 '유효한지' 표시하는 깃발 + setLoading(true); + setError(null); + + fetch('/api/posts?query=' + encodeURIComponent(query)) + .then((res) => { + if (!res.ok) throw new Error('서버 응답 오류: ' + res.status); + return res.json(); + }) + .then((data) => { + if (!ignore) setPosts(data); // 깃발이 안 꽂혔을 때만 반영 + }) + .catch((err) => { + if (!ignore) setError(err.message); + }) + .finally(() => { + if (!ignore) setLoading(false); + }); + + return () => { ignore = true; }; // 클린업: 이전 요청에 "무시해" 깃발 + }, [query]); // query가 바뀔 때마다 다시 요청 + + if (loading) return

불러오는 중...

; + if (error) return

문제가 생겼어요: {error}

; + if (posts.length === 0) return

결과가 없어요.

; + return
    {posts.map((p) =>
  • {p.title}
  • )}
; +}`; + +const CODE_RACE = `// 레이스 컨디션 — 빠른 연속 요청이 뒤바뀌어 도착하는 사고 +사용자가 타이핑: 'r' → 're' → 'react' (요청 3발이 연달아 출발) + +출발 순서: 요청A('r') 요청B('re') 요청C('react') +도착 순서: 요청C 먼저! ...한참 뒤 요청A가 도착 + +방어 없이 setPosts를 그냥 하면? +→ 마지막에 도착한 요청A('r')의 결과가 화면을 덮어써서 + 검색어는 'react'인데 목록은 'r' 검색 결과인 어긋남이 생겨요. + +ignore 깃발의 동작: +query가 바뀌면 → 이전 이펙트의 클린업이 먼저 실행(섹션 5에서 배운 순서!) +→ 이전 요청의 ignore가 true가 됨 +→ 늦게 도착해도 if (!ignore) 문턱에서 걸러짐. 최신 요청만 화면에 반영.`; + +const CODE_USEFETCH = `// 커스텀 훅 — 반복되는 페칭 로직을 useFetch 하나로 추출 +function useFetch(url) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let ignore = false; + setLoading(true); + setError(null); + + fetch(url) + .then((res) => { + if (!res.ok) throw new Error('서버 응답 오류: ' + res.status); + return res.json(); + }) + .then((json) => { if (!ignore) setData(json); }) + .catch((err) => { if (!ignore) setError(err.message); }) + .finally(() => { if (!ignore) setLoading(false); }); + + return () => { ignore = true; }; + }, [url]); // url이 바뀌면 자동으로 다시 요청 + + return { data, loading, error }; // 쓰는 쪽에 3상태를 세트로 전달 +} + +// 사용하는 쪽 — 컴포넌트가 이렇게 얇아진다 +function PostList({ query }) { + const { data: posts, loading, error } = + useFetch('/api/posts?query=' + encodeURIComponent(query)); + + if (loading) return

불러오는 중...

; + if (error) return

문제가 생겼어요: {error}

; + return
    {(posts ?? []).map((p) =>
  • {p.title}
  • )}
; +}`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: 'UI = f(state)' }, + { n: 2, label: '무엇을 state로' }, + { n: 3, label: '불변 업데이트' }, + { n: 4, label: '상태 끌어올리기' }, + { n: 5, label: 'useEffect' }, + { n: 6, label: '데이터 페칭' }, + { n: 7, label: '커스텀 훅' }, +]; + +export default function Lv2ReactStateEffectsPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 중급 과정
+

⚛️ React 상태와
이펙트 실전

+

+ 입문에서 useState로 버튼 카운터를 만들어 봤다면, 이제 + 질문이 바뀝니다. "상태를 어떻게 쓰는가"에서 "무엇을 상태로 두고, + 누가 주인이 되고, 바깥 세상과는 어떻게 동기화하는가" — 즉 상태를 + '설계'하는 단계로요. 불변 업데이트, useEffect 데이터 페칭, 커스텀 훅까지, + 실제 서비스 코드에서 매일 마주치는 패턴을 한 줄씩 손에 익혀 봅시다. +

+
+ 예상 소요 75분 + 실습: 블로그 글 목록 + 실시간 검색·필터 + 선수 지식: JS 기초 문법 · React 입문(useState) +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ React를 관통하는 문장은 딱 하나예요. UI = f(state). 화면(UI)은 + 상태(state)를 컴포넌트 함수(f)에 넣었을 때 나오는 계산 결과라는 + 뜻입니다. 화면을 바꾸고 싶으면 화면을 직접 만지는 게 아니라 상태를 + 바꿉니다. 그러면 React가 함수를 다시 실행해서 새 화면을 계산해 내죠. +

+ {CODE_UI_F_STATE} +

+ 그럼 setCount를 호출하는 순간 정확히 무슨 일이 + 벌어질까요? 많은 분들이 "변수가 바로 바뀐다"고 생각하는데, 실제로는{' '} + 예약 → 재실행 → 비교 반영의 세 단계를 거칩니다. +

+ {CODE_RENDER_FLOW} +

+ 여기서 중요한 건 ②의 '함수를 처음부터 다시 실행한다'는 사실이에요. + 컴포넌트 안에 쓴 일반 변수·계산식은 렌더링마다 새로 만들어지고, 오직{' '} + useState에 맡긴 값만 렌더링 사이에서 살아남습니다. + 이 그림이 머리에 있어야 다음 섹션들 — "왜 파생값은 state가 아닌가", "왜 직접 수정하면 + 안 되는가" — 가 전부 자연스럽게 이어져요. +

+
+ 관점의 승격 입문 때는 "useState를 호출할 줄 아는 것"이 목표였다면, 이번 + 과정의 목표는 "이 화면에는 어떤 상태가 몇 개 필요한가"를 먼저 종이에 그릴 + 수 있는 것이에요. 코드는 그 설계의 결과물일 뿐입니다. +
+
+ +
+

+ 상태 설계의 제1원칙: state는 최소한으로. 판별법은 간단해요. "이 값, + 다른 state나 props에서 계산으로 만들어 낼 수 있나?" — 만들어 낼 수 + 있다면 그건 state가 아니라 그냥 변수(계산)여야 합니다. 장바구니로 + 연습해 볼게요. +

+ {CODE_DERIVED} +

+ "합계도 자주 쓰니까 state로 두면 편하지 않나?"라는 생각이 들 수 있어요. 그런데 같은 + 사실을 두 곳(items와{' '} + totalPrice)에 저장하는 순간, 둘을{' '} + 영원히 손으로 동기화해야 하는 계약이 생깁니다. 그리고 사람은 반드시 + 한 번은 깜빡하죠. +

+ {CODE_DUP_BUG} +

+ 정리하면, state 후보는 이 세 관문을 통과해야 해요. ① 시간이 지나며 + 변하는가?(안 변하면 상수) ② 부모가 props로 주는 값인가? + (그렇다면 부모의 것) ③ 다른 값에서 계산 가능한가?(가능하면 계산으로). + 셋을 다 통과한 값만 useState에 올립니다. +

+
+ 실습 미리보기 이번 실습의 목록 화면에서 state는 딱 넷이에요 —{' '} + posts(서버가 준 원본),{' '} + query(검색어), category + (선택된 카테고리), 로딩·에러. "필터링된 목록"은? state가 아니라{' '} + posts에서 매 렌더링마다 계산합니다. 이 구분이 이번 과정의 절반이에요. +
+
+ +
+

+ 숫자·문자열은 setCount(count + 1)처럼 새 값을 주면 + 그만인데, 객체와 배열은 함정이 있어요. 내부를 직접 고치면{' '} + 화면이 안 바뀝니다. React가 게을러서가 아니라, 변경 감지를{' '} + 참조 비교(이전 것과 같은 상자인가?)로 하기 때문이에요. +

+ {CODE_MUTATE_BUG} +

+ 그래서 규칙은 하나로 수렴합니다. "고치지 말고, 복사본을 만들어 바꿀 부분만 + 교체한 뒤, 통째로 새로 건넨다." 배열에서 자주 쓰는 패턴 세 가지를 손에 + 익혀 두세요 — 추가는 스프레드, 삭제는 filter, 수정은 map입니다. +

+ {CODE_IMMUTABLE} +

+ 객체가 여러 겹 중첩돼 있으면, 바꾸려는 값까지 지나가는 길목의 껍데기를 전부 + 복사해야 해요. 안 그러면 중간 껍데기는 여전히 옛 상자라서, 그 아래를 보는 + 컴포넌트가 변경을 놓칠 수 있거든요. +

+ {CODE_NESTED} +
+ 조용한 버그의 대표 주자 todos.push(...) 후{' '} + setTodos(todos) — 에러도 경고도 없이 그냥 화면만 안 + 바뀝니다. "분명 값은 바뀌었는데 렌더가 안 돼요"의 90%가 이 직접 수정이에요. 콘솔에 + 찍어 보면 값은 바뀌어 있어서 더 헷갈리죠. 의심되면 먼저 물어보세요 — "나 지금 원본을 + 고치고 있나, 새것을 만들고 있나?" +
+
+ +
+

+ 실습 화면을 그려 보면 바로 부딪히는 문제예요. 검색창(SearchBar)에 + 입력한 검색어를, 형제인 목록(PostList)이 + 알아야 필터링을 하죠. 그런데 React의 데이터는 부모에서 자식으로, 한 + 방향으로만 흐릅니다. 형제끼리는 직접 대화할 수 없어요. +

+

+ 해법이 상태 끌어올리기(lifting state up)입니다. 상태를 두 형제가{' '} + 함께 쓰는 가장 가까운 공통 부모로 올리고, 부모가 값은 props로 + 내려 주고 바꿀 권한은 콜백으로 내려 주는 거예요. +

+ {CODE_LIFT} +

+ "상태의 주인을 어디에 둘까"의 기준은 늘 같아요. 그 상태를 읽거나 바꾸는 + 컴포넌트들을 전부 모아, 그들을 모두 품는 가장 낮은(가까운) 부모. 그보다 + 높이 올리면 관계없는 컴포넌트까지 리렌더링되고, 낮게 두면 필요한 쪽이 못 봅니다. +

+
+ props가 깊어질 때의 신호 부모 → 자식 → 손자 → 증손자로 같은 props를 그냥 + '전달만' 하는 층이 3~4개를 넘으면(이걸 props drilling이라 불러요), + 컴포넌트 경계를 다시 그리거나 Context 같은 도구를 고려할 타이밍입니다. 다만 이번 + 실습 규모(부모 하나 + 형제 둘)에서는 끌어올리기만으로 충분해요 — 도구는 문제가 + 생긴 다음에 꺼내는 겁니다. +
+
+ +
+

+ 지금까지의 이야기는 전부 렌더링 안의 세계였어요. state가 들어가면 + JSX가 나오는 순수한 계산이죠. 그런데 실제 앱은 렌더링 밖 세상과도 + 손잡아야 합니다 — 서버에 데이터를 요청하고, 타이머를 걸고, 브라우저 이벤트를 구독하는 + 일들요. 이 "바깥 세상과의 동기화"를 담당하는 게 useEffect + 예요. 렌더링이 화면에 반영된 뒤에 실행됩니다. +

+

+ useEffect의 성격을 결정하는 건 두 번째 인자, 의존성 배열입니다. + 세 가지 형태의 차이를 정확히 구분하세요 — 면접에서도, 실무 버그에서도 단골이에요. +

+ {CODE_EFFECT_DEPS} +

+ 이펙트가 벌인 일(타이머, 구독, 진행 중인 요청)은 누군가 치워야 + 합니다. 그 담당이 이펙트가 return하는 클린업 함수예요. 실행 시점이 + 두 곳이라는 걸 꼭 기억하세요 — 사라질 때 한 번만이 아닙니다. +

+ {CODE_CLEANUP} +
+ 이펙트 남용 주의 "state가 바뀌면 다른 state를 계산해서 set하는 이펙트"는 + 대부분 잘못된 설계예요. 그건 섹션 2에서 배운 파생값 — 렌더링 중 계산 + 으로 풀어야 합니다. 이펙트는 어디까지나 React 바깥(서버·타이머·DOM + API)과 만날 때만 꺼내는 도구예요. "이거 바깥 세상이랑 관련 있나?"를 먼저 물어보세요. +
+
+ +
+

+ 드디어 실습의 심장부입니다. 우리가 만든 블로그 API에서 글 목록을 + 불러와 봅시다. 서버 요청은 "성공해서 데이터가 온다"만 있는 게 아니에요.{' '} + 기다리는 중일 수도, 실패할 수도 있죠. 그래서 페칭은 + 늘 로딩·에러·데이터 3상태를 한 세트로 관리합니다. +

+ {CODE_FETCH} +

+ 코드에 낯선 장치가 하나 있죠 — ignore 깃발. 이게 없으면 + 어떤 사고가 나는지 봅시다. 사용자가 검색창에 빠르게 타이핑하면 요청이 연발로 나가는데, + 네트워크는 출발 순서대로 도착한다는 보장이 없어요. +

+ {CODE_RACE} +

+ 섹션 5에서 배운 클린업의 실행 순서("새 이펙트 전에 이전 클린업 먼저")가 여기서 + 빛납니다. query가 바뀔 때마다 이전 요청의 깃발이 + 먼저 꽂히니, 늦게 도착한 옛 응답은 조용히 무시되고 항상 최신 요청만 + 화면에 반영돼요. 덤으로, 컴포넌트가 화면에서 사라진 뒤 도착한 응답도 같은 깃발에 + 걸러지므로 "언마운트된 컴포넌트에 setState" 경고도 함께 사라집니다. +

+
+ 실습 조립법 이제 부품이 다 모였어요. ① 부모가{' '} + query·category state를 + 들고(섹션 4) → 그 값이 바뀌면 이펙트가 API를 다시 호출하고(이번 + 섹션) → 카테고리 필터링은 받아 온 posts + 에서 계산으로(섹션 2) → 목록 갱신은 전부 새 배열 + 교체로(섹션 3). 과정 전체가 화면 하나에 조립되는 순간입니다. +
+
+ +
+

+ 섹션 6의 페칭 코드, 잘 보면 어느 화면에서든 똑같이 반복될 모양이에요. + 글 목록에서도, 글 상세에서도, 댓글에서도 — 3상태 선언, fetch, ignore 깃발, 클린업이 + 매번 복붙되겠죠. React는 이런 state가 얽힌 로직의 재사용을 위해{' '} + 커스텀 훅이라는 길을 열어 뒀습니다. use로 + 시작하는 이름의 함수를 만들고, 그 안에서 다른 훅을 쓰면 돼요. +

+ {CODE_USEFETCH} +

+ 효과를 보세요. PostList"무엇을 보여 줄지"만 + 남고 "어떻게 가져올지"는 사라졌습니다. 컴포넌트는 얇아지고, 페칭 로직은 한 + 곳에서 관리되니 버그 수정도 한 번이면 되죠. 한 가지 주의 — 커스텀 훅은 로직을 + 재사용하는 것이지 state를 공유하는 게 아니에요. useFetch를 쓰는 + 컴포넌트마다 각자의 독립된 data·loading·error를 가집니다. +

+

+ 마지막으로 훅의 규칙. 모든 훅(useState·useEffect·커스텀 훅)은 + 컴포넌트나 커스텀 훅의 최상위에서만 호출해야 해요.{' '} + if문·반복문·중첩 함수 안은 금지입니다. React는 훅이{' '} + 호출된 순서로 "첫 번째 useState는 이 값, 두 번째는 저 값"을 + 기억하거든요. 조건문 안에서 호출하면 렌더링마다 순서가 달라져서, React가 state를 + 엉뚱한 훅에 배달하게 됩니다. +

+
+ 이름의 약속 use 접두어는 장식이 아니에요. + "이 함수 안에서 훅이 호출된다"는 신호라서, React와 ESLint가 이 이름을 보고 훅의 + 규칙 위반을 잡아 줍니다. 훅을 쓰는 함수엔 반드시 use를 + 붙이고, 훅을 안 쓰는 유틸 함수엔 붙이지 마세요. +
+
+ 한 걸음 더 실무에선 이 useFetch의 발전형이 바로 TanStack Query(React Query) + 같은 라이브러리예요. 캐싱·재시도·백그라운드 갱신까지 얹은 '아주 잘 만든 커스텀 훅 + 모음'이라고 보면 됩니다. 직접 useFetch를 만들어 본 지금이라면, 그 라이브러리 문서가 + 전혀 다르게 읽힐 거예요. +
+
+ +
+
+

⚛️ 여기까지 왔다면

+

+ 이제 여러분은 상태를 '쓰는' 사람에서 '설계하는' 사람으로 한 계단 올라왔어요. + 최소 상태 고르기, 복사해서 교체하는 불변 업데이트, 최소 공통 부모로 끌어올리기, + 바깥 세상과 동기화하는 useEffect, 레이스 컨디션까지 막아 내는 페칭, 그리고 그 + 전부를 useFetch 한 줄로 접어 넣는 커스텀 훅까지 — 실전 React의 뼈대를 손에 + 쥐었습니다. 그런데 화면이 하나뿐인 앱은 없죠. 글 목록에서 글 상세로,{' '} + URL이 바뀌며 화면이 전환되는 진짜 서비스의 구조가 다음 주제예요. + 다음 React Router로 멀티 페이지 + SPA 강좌에서, 오늘 만든 목록 화면에 상세 페이지를 이어 붙여 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv2RestApiDesignPage.jsx b/frontend/src/pages/levels/Lv2RestApiDesignPage.jsx new file mode 100644 index 0000000..d1998f5 --- /dev/null +++ b/frontend/src/pages/levels/Lv2RestApiDesignPage.jsx @@ -0,0 +1,494 @@ +// 이 파일이 하는 일: "REST API 설계: 주소와 동사의 문법" 코스 — 코드를 짜기 전에 +// API 명세부터 쓰는 이유에서 출발해, URL 설계·HTTP 메서드·상태 코드·바디 설계· +// 쿼리스트링·문서화까지 7개 섹션으로 안내하는 정적 학습 페이지. 프레이밍은 +// "API는 프론트와 백엔드 사이의 계약서" — 명세를 먼저 합의하면 두 팀이 동시에 +// 달릴 수 있고, 나중에 싸울 일이 사라진다는 감각을 심어 준다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_CONTRACT = `프론트엔드 개발자 백엔드 개발자 +(화면을 만든다) (데이터를 다룬다) + | | + | ┌──────────────┐ | + └──────────▶ | API 명세서 | ◀──────────┘ + | (계약서) | + └──────────────┘ + "글 목록은 GET /posts 로 주세요" + "응답엔 id, title, createdAt 이 있어요" + +핵심: 명세가 먼저 합의되면 두 사람은 서로를 기다리지 않아요. +프론트는 명세대로 가짜 데이터(mock)를 만들어 화면을 짜고, +백엔드는 명세대로 서버를 짭니다. 마지막에 붙이면? 딱 맞습니다. +명세 없이 시작하면 — "필드 이름이 왜 달라요?" 싸움이 시작되죠.`; + +const CODE_BAD_URLS = `나쁜 URL (동사가 주소에 들어감) 좋은 URL (명사 + 메서드) +────────────────────────────────────────────────────────────── +GET /getAllPosts → GET /posts +POST /createPost → POST /posts +POST /deletePost?id=3 → DELETE /posts/3 +GET /getCommentsOfPost/3 → GET /posts/3/comments +POST /likePost/3 → POST /posts/3/likes + +규칙 세 줄 요약: +① URL에는 명사만, 그것도 복수형으로 (/posts, /comments) +② "무엇을 할지"는 URL이 아니라 HTTP 메서드가 말한다 +③ 소속 관계는 경로 계층으로 (/posts/3/comments = 3번 글의 댓글들)`; + +const CODE_HIERARCHY = `GET /posts 글 전체 목록 +GET /posts/3 3번 글 하나 +GET /posts/3/comments 3번 글에 달린 댓글 목록 +GET /posts/3/comments/12 3번 글의 12번 댓글 하나 + +읽는 법: 왼쪽에서 오른쪽으로 갈수록 "큰 서랍 → 작은 서랍". +폴더 경로(posts/3/comments)와 똑같은 감각이에요. +단, 계층이 3단을 넘어가면(/a/1/b/2/c/3/d…) 오히려 읽기 어려워지니 +그땐 /comments/12 처럼 최상위 리소스로 바로 접근하는 걸 고려합니다.`; + +const CODE_METHODS = `메서드 뜻 예시 멱등성 +────────────────────────────────────────────────────────── +GET 조회 GET /posts/3 O (몇 번 읽어도 같음) +POST 생성 POST /posts X (보낼 때마다 새 글!) +PUT 전체 교체 PUT /posts/3 O (같은 걸로 계속 덮음) +PATCH 부분 수정 PATCH /posts/3 상황에 따라 다름 +DELETE 삭제 DELETE /posts/3 O (이미 없어도 없는 상태) + +멱등성(idempotent) = "같은 요청을 두 번 보내도 결과가 한 번과 같은가?" +네트워크가 끊겨 재시도할 때, 멱등한 요청은 안심하고 다시 보낼 수 있어요. +POST가 멱등하지 않다는 것 — 결제 버튼 두 번 클릭 사고의 근원입니다.`; + +const CODE_PUT_PATCH = `// 원래 데이터: { "title": "제목", "content": "본문", "tags": ["일상"] } + +// PUT — 전체 교체: 보낸 것"만" 남는다 +PUT /posts/3 +{ "title": "새 제목" } +// 결과: { "title": "새 제목" } ← content와 tags가 사라졌다! + +// PATCH — 부분 수정: 보낸 것"만" 바뀐다 +PATCH /posts/3 +{ "title": "새 제목" } +// 결과: { "title": "새 제목", "content": "본문", "tags": ["일상"] } + +// 그래서 "제목만 고치는 기능"엔 PATCH가 맞습니다. +// PUT을 쓰려면 클라이언트가 나머지 필드까지 전부 다시 보내야 해요.`; + +const CODE_STATUS = `코드 이름 언제 쓰나 누구 잘못? +───────────────────────────────────────────────────────────────── +200 OK 조회·수정 성공, 돌려줄 바디 있음 - +201 Created POST로 새 리소스가 만들어짐 - +204 No Content 성공했는데 돌려줄 바디가 없음 - + (DELETE 성공이 대표 사례) +400 Bad Request 요청 형식이 틀림 (필수 필드 누락 등) 클라이언트 +401 Unauthorized "누구세요?" — 로그인이 안 됨 클라이언트 +403 Forbidden "누군지는 아는데 권한이 없어요" 클라이언트 +404 Not Found 그런 리소스가 없음 클라이언트 +409 Conflict 현재 상태와 충돌 (중복 아이디 등) 클라이언트 +500 Internal Server Error 서버 코드가 터짐 서버(우리) + +4xx = "요청이 잘못됐어요" (클라이언트가 고쳐야 함) +5xx = "서버가 잘못했어요" (우리가 고쳐야 함) — 이 경계가 디버깅의 나침반.`; + +const CODE_LIST_BODY = `// GET /posts?page=2&limit=10 의 응답 — 목록엔 '포장지'를 씌운다 +{ + "items": [ + { "id": 11, "title": "열한 번째 글", "likeCount": 3, "createdAt": "2026-07-18T09:00:00Z" }, + { "id": 12, "title": "열두 번째 글", "likeCount": 0, "createdAt": "2026-07-19T14:30:00Z" } + ], + "page": 2, // 지금 몇 페이지인지 + "limit": 10, // 한 페이지에 몇 개인지 + "total": 57 // 전체가 몇 개인지 → 프론트가 "6페이지 중 2페이지"를 그릴 수 있다 +} + +// 배열을 맨바깥에 그냥 [ ... ] 로 보내면? 나중에 total을 추가할 자리가 없어요. +// 처음부터 객체로 감싸 두면 필드를 더해도 기존 코드가 안 깨집니다.`; + +const CODE_ERROR_BODY = `// 에러 응답도 '형식'을 통일한다 — 성공만 설계하면 반쪽짜리 명세 +// 400 Bad Request +{ + "code": "VALIDATION_FAILED", // 기계가 읽는 에러 코드 (분기 처리용) + "message": "title은 1자 이상 100자 이하여야 합니다." // 사람이 읽는 설명 +} + +// 409 Conflict +{ + "code": "DUPLICATE_LIKE", + "message": "이미 좋아요를 누른 글입니다." +} + +// 프론트 입장에서: 어떤 에러든 code로 분기하고 message를 띄우면 끝. +// 어떤 API는 문자열, 어떤 API는 객체... 형식이 제각각이면 프론트가 웁니다.`; + +const CODE_QUERY = `# 검색·필터·정렬·페이지 — 전부 쿼리스트링의 일 +GET /posts?keyword=여행 검색: '여행'이 들어간 글 +GET /posts?author=kim&tag=일상 필터: kim이 쓴 '일상' 태그 글 (AND 조합) +GET /posts?sort=-createdAt 정렬: 최신순 (- 는 내림차순 관례) +GET /posts?sort=likeCount 정렬: 좋아요 적은 순 (오름차순) +GET /posts?page=2&limit=10 페이지: 2페이지, 10개씩 + +# 조합도 자유롭다 — 쿼리는 서로 독립적인 '옵션'이니까 +GET /posts?tag=일상&sort=-likeCount&page=1&limit=5 + +# 선택 기준 한 줄: +# 경로(/posts/3) = 자원의 "주소" — 이게 없으면 대상 자체가 특정 안 됨 +# 쿼리(?sort=..) = 자원을 보는 "방식" — 없어도 기본값으로 동작해야 함`; + +const CODE_SPEC_TABLE = `미니 블로그 API 명세서 v1 (실습에서 완성할 표) +────────────────────────────────────────────────────────────────────── +메서드 경로 설명 성공 주요 실패 +GET /api/v1/posts 글 목록(페이지) 200 400(잘못된 page) +POST /api/v1/posts 글 작성 201 400, 401 +GET /api/v1/posts/:id 글 상세 200 404 +PATCH /api/v1/posts/:id 글 수정 200 401, 403, 404 +DELETE /api/v1/posts/:id 글 삭제 204 401, 403, 404 +GET /api/v1/posts/:id/comments 댓글 목록 200 404(글 없음) +POST /api/v1/posts/:id/comments 댓글 작성 201 400, 401, 404 +POST /api/v1/posts/:id/likes 좋아요 201 401, 404, 409(중복) +DELETE /api/v1/posts/:id/likes 좋아요 취소 204 401, 404 + +표 한 장이지만 — 메서드·경로·상태 코드·실패 시나리오까지 +이 코스에서 배운 전부가 이 안에 들어 있습니다.`; + +const CODE_OPENAPI = `# OpenAPI(Swagger) — 명세를 '기계가 읽는 형식'으로 쓰면 생기는 일 +# 주의: OpenAPI의 경로 파라미터는 {id} 중괄호 표기예요 (:id는 Express 코드 쪽 문법) +paths: + /posts/{id}: + get: + summary: 글 상세 조회 + responses: + "200": + description: 조회 성공 + "404": + description: 글이 존재하지 않음 + +# 이렇게 YAML로 적어 두면 도구가 알아서: +# ① 사람이 보기 좋은 문서 페이지를 자동 생성 (Swagger UI) +# ② 브라우저에서 바로 "Try it out" — 요청을 쏴 볼 수 있는 버튼 +# ③ 프론트용 API 호출 코드 뼈대까지 자동 생성 +# 우리가 표로 쓰던 명세의 '전산화 버전'이라고 보면 됩니다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: 'API는 계약이다' }, + { n: 2, label: 'URL 설계' }, + { n: 3, label: 'HTTP 메서드' }, + { n: 4, label: '상태 코드' }, + { n: 5, label: '바디 설계' }, + { n: 6, label: '쿼리스트링' }, + { n: 7, label: '명세서와 문서화' }, +]; + +export default function Lv2RestApiDesignPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 중급 과정
+

📐 REST API 설계
: 주소와 동사의 문법

+

+ 기능부터 코딩하고 싶은 마음, 잠깐만 눌러 두세요. 실전 웹 서비스는{' '} + 프론트와 백엔드 두 사람이 동시에 만듭니다. 둘이 싸우지 않으려면 + 코드보다 먼저 써야 하는 게 있어요 — 바로 API 명세, 즉 계약서입니다. + 이 코스에서는 URL이라는 '주소'와 HTTP 메서드라는 '동사'로 그 계약서를 쓰는 + 문법을 익힙니다. +

+
+ 예상 소요 55분 + 실습: 미니 블로그 API 명세서 작성 · 짝 리뷰 + 선수 지식: HTTP 요청/응답 기초 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 웹 서비스는 크게 두 덩어리예요. 화면을 그리는 프론트엔드, 데이터를 + 보관하고 처리하는 백엔드. 이 둘은 서로의 코드를 보지 않습니다. 대신{' '} + API라는 창구로만 대화하죠. "이 주소로 이렇게 요청하면, 이런 모양의 + 응답을 준다" — 이 약속의 목록이 API 명세이고, 성격은 정확히{' '} + 계약서입니다. +

+ {CODE_CONTRACT} +

+ 명세를 먼저 쓰면 개발이 빨라지는 원리는 단순해요. 계약서가 확정되는 순간{' '} + 프론트와 백엔드가 서로를 기다릴 필요가 없어집니다. 프론트는 명세에 + 적힌 응답 모양대로 가짜 데이터를 만들어 화면을 완성하고, 백엔드는 명세대로 진짜 + 서버를 만들죠. 마지막 날 둘을 연결하면 — 계약대로 만들었으니 맞물립니다. 반대로 + 명세 없이 각자 출발하면, 연결하는 날 "필드 이름이 다르다", "그런 주소 없는데요"가 + 쏟아져요. +

+

+ 그 계약서를 쓰는 문법으로 오늘날 사실상 표준이 된 것이 REST + (REpresentational State Transfer)입니다. 특별한 라이브러리나 프로토콜이 아니라,{' '} + HTTP가 원래 가진 재료(URL, 메서드, 상태 코드)를 정직하게 쓰자는{' '} + 설계 스타일이에요. 재료가 이미 모든 브라우저·서버·언어에 들어 있으니 + 별도 학습 비용이 거의 없고, 그래서 어디서나 통합니다. +

+
+ 비유 API 명세는 식당의 메뉴판이에요. 손님(프론트)은 주방(백엔드) + 내부를 몰라도 메뉴판만 보면 주문할 수 있죠. 메뉴판이 정확하면 주방이 바빠도 홀은 + 돌아갑니다. 이 코스의 목표는 "읽기 좋은 메뉴판을 쓰는 법"입니다. +
+
+ +
+

+ REST의 첫 번째 문법은 이거예요. URL은 '자원(리소스)의 주소'다. 글, + 댓글, 좋아요, 사용자 — 서비스가 다루는 데이터 덩어리 하나하나가 리소스입니다. 주소는 + 그 리소스를 가리키기만 하고, "가져와라 / 만들어라 / 지워라" 같은 행동은 + 다음 섹션의 HTTP 메서드가 맡아요. 그래서 URL에는 명사만, 복수형으로{' '} + 씁니다. +

+ {CODE_BAD_URLS} +

+ 왜 복수형(/posts)일까요? /posts는 + '글들이 모인 서랍'이고, /posts/3은 '그 서랍의 3번 글'— + 하나의 규칙으로 목록과 개별 항목을 모두 표현할 수 있기 때문입니다. 단수·복수를 + 섞으면(/post, /comments) + 쓰는 사람이 매번 "여긴 뭐였지?"를 기억해야 해요. 일관성이 곧 사용성입니다. +

+

+ 리소스끼리 소속 관계가 있으면 경로의 계층으로 드러냅니다. "3번 글에 + 달린 댓글들"은 /posts/3/comments — 폴더 경로를 읽듯 + 자연스럽게 읽히죠. +

+ {CODE_HIERARCHY} +
+ 스스로 점검하는 법 설계한 URL에서 동사를 찾아보세요.{' '} + get, create,{' '} + delete, update 같은 단어가 + URL 안에 보인다면 십중팔구 메서드가 할 일을 URL이 뺏은 겁니다. 동사를 지우고, + 그 뜻을 메서드로 옮기세요. +
+
+ +
+

+ URL이 명사라면 HTTP 메서드는 동사입니다. 같은{' '} + /posts/3이라도 GET이면 + "읽어 줘", DELETE면 "지워 줘"가 되죠. 다섯 동사의 + 용도를 정확히 구분하는 것이 이 섹션의 전부입니다. +

+ {CODE_METHODS} +

+ 가장 자주 헷갈리는 짝이 PUT과 PATCH예요. 둘 다 '수정'이지만 방식이 + 다릅니다. PUT전체 교체 — 리소스를 + 보낸 내용으로 통째로 갈아 끼웁니다. PATCH는{' '} + 부분 수정 — 보낸 필드만 바꾸고 나머지는 그대로 둡니다. 이 차이를 + 모르고 PUT에 제목만 담아 보내면, 본문과 태그가 증발하는 사고가 납니다. +

+ {CODE_PUT_PATCH} +

+ 표의 마지막 열, 멱등성(idempotency)도 짚고 갑시다. "같은 요청을 두 번 + 보내도 결과가 한 번 보낸 것과 같은가?"라는 질문이에요. GET은 + 몇 번을 읽어도 데이터가 변하지 않고, DELETE는 두 번째엔 + 이미 없지만 '없는 상태'라는 결과는 같으니 멱등합니다. 반면{' '} + POST는 보낼 때마다 새 리소스가 하나 더{' '} + 생기니 멱등하지 않아요. +

+
+ 멱등성이 실무에서 중요한 순간 네트워크는 종종 끊깁니다. 응답을 못 받은 + 클라이언트가 "재시도해도 될까?"를 판단할 때 기준이 멱등성이에요. 멱등한 요청은 + 안심하고 다시 보내면 되지만, 멱등하지 않은 POST를 무심코 재시도하면 — + 글이 두 번 등록되고, 결제가 두 번 나갑니다. "저장 버튼 연타" 사고의 정체가 + 바로 이것입니다. +
+
+ +
+

+ 응답의 첫인상은 상태 코드입니다. 바디를 열어 보기 전에, 숫자 세 자리가 + "성공했는지, 실패했다면 누구 잘못인지"를 먼저 말해 주죠. 이 분류만 + 정확해도 디버깅 시간이 절반으로 줄어듭니다. +

+ {CODE_STATUS} +

+ 성공 3형제부터 구분해 볼게요. 200은 "성공했고, 결과물을 바디에 + 담았다" — 조회·수정의 기본값. 201은 "성공했는데, 그 결과{' '} + 새로운 것이 만들어졌다" — POST 성공 전용. 204는 + "성공했지만 돌려줄 내용이 없다" — 삭제 성공이 대표적이에요. 지운 것을 다시 보여 줄 + 이유는 없으니까요. +

+

+ 4xx 형제들은 전부 "요청이 잘못됐다"는 뜻인데, 이유가 다 다릅니다.{' '} + 400은 요청 자체의 형식 오류(필수 필드 누락, 타입 틀림).{' '} + 401은 "누구신지 모르겠어요"(로그인 필요), 403은 + "누군지는 알겠는데 이건 못 하세요"(남의 글 삭제 시도). 404는 그런 + 리소스가 없음, 409는 현재 상태와 충돌 — 이미 좋아요를 누른 글에 또 + 좋아요를 누를 때가 딱 409입니다. +

+
+ 500은 서버의 사과문 500은 "당신 잘못이 아니라 우리 서버 코드가 + 터졌습니다"라는 뜻이에요. 그래서 클라이언트 실수(잘못된 입력, 없는 글 요청)까지 + 전부 500으로 뭉뚱그리면 안 됩니다. 프론트 입장에선 "내가 고칠 문제인지(4xx), + 기다릴 문제인지(5xx)"를 구분할 수 없게 되고, 서버 개발자는 진짜 버그 알림이 + 가짜 알림에 파묻혀 버려요. 4xx로 걸러낼 수 있는 건 전부 4xx로. +
+
+ 외우는 요령 401과 403이 늘 헷갈린다면 이렇게 기억하세요. 401은{' '} + "출입증이 없네요"(Unauthorized = 인증 안 됨), 403은{' '} + "출입증은 있는데 이 방은 못 들어가요"(Forbidden = 권한 없음). + 문 앞의 문제냐, 문 안의 문제냐의 차이입니다. +
+
+ +
+

+ 주소와 동사, 상태 코드까지 정했으면 이제 내용물(JSON 바디)의 규칙을 + 정할 차례예요. 여기서 중요한 건 화려함이 아니라 일관성입니다. 딱 + 세 가지만 팀 규칙으로 못 박으면 됩니다. +

+

+ 첫째, 필드 네이밍 규칙. JSON 필드 이름은{' '} + camelCase(likeCount,{' '} + createdAt)로 통일하는 것이 자바스크립트 진영의 + 관례예요. 중요한 건 어느 쪽을 고르느냐보다 한 API 안에서 절대 섞지 않는 것 + 입니다. created_atlikeCount가 + 한 응답에 공존하는 순간, 프론트 코드엔 "이 필드는 어느 표기였더라?" 하는 지뢰가 + 깔립니다. +

+

+ 둘째, 목록 응답에는 포장지를. 목록을 배열로만 덜렁 보내지 말고, + 객체로 감싸 페이지네이션 정보를 함께 담습니다. +

+ {CODE_LIST_BODY} +

+ page·limit· + total 세 필드만 있으면 프론트는 "전체 6페이지 중 + 2페이지" 같은 UI를 그릴 수 있어요. 글이 5만 개가 되어도 서버는 한 번에 10개만 + 보내면 되니 성능도 지켜집니다. +

+

+ 셋째, 에러 응답도 형식을 통일. 성공 응답만 설계하는 건 반쪽짜리 + 계약서예요. 실패했을 때 바디가 어떤 모양인지도 계약에 들어가야 프론트가 에러 처리를 + 한 가지 코드로 끝낼 수 있습니다. +

+ {CODE_ERROR_BODY} +
+ code와 message의 역할 분담 code는{' '} + 기계가 읽습니다 — 프론트가 "이 코드면 로그인 화면으로 보내자"처럼 + 분기하는 데 쓰죠. message사람이 + 읽습니다 — 화면에 그대로 띄워도 어색하지 않은 문장으로요. 하나는 분기용, 하나는 + 표시용 — 둘을 겸직시키지 마세요. +
+
+ +
+

+ "여행 키워드로 검색하고, 최신순으로 정렬해서, 2페이지를 보여 줘" — 이런 요구를 + 전부 URL 경로로 만들 순 없습니다. 조합이 무한하니까요. 이때 쓰는 것이{' '} + 쿼리스트링, 주소 뒤에 ?이름=값 형태로 + 붙는 옵션들입니다. +

+ {CODE_QUERY} +

+ ?sort=-createdAt마이너스(-) 기호는 + "내림차순"을 뜻하는 널리 쓰이는 관례예요. sort=createdAt이면 + 오래된 순, sort=-createdAt이면 최신순. 파라미터 하나로 + 방향까지 표현되니 order=desc 같은 파라미터를 따로 둘 + 필요가 없어 간결합니다. 물론 이것도 관례일 뿐 — 어느 쪽을 쓰든 명세에 적고 + 전체 API에서 통일하는 것이 본질이에요. +

+

+ 그럼 경로 파라미터와 쿼리 파라미터는 언제 무엇을 쓸까요? 기준은 한 + 줄입니다. 그 값이 없으면 대상 자체가 특정되지 않는가? 3번 글의 댓글은{' '} + 3이 없으면 아예 성립하지 않으니{' '} + 경로(/posts/3/comments)로. 정렬·페이지는 + 없어도 "기본 정렬, 1페이지"로 동작할 수 있으니 쿼리로. 즉 경로는 + 자원의 주소, 쿼리는 자원을 보는 방식입니다. +

+
+ 직접 확인해 보기 지금 쓰는 쇼핑몰이나 검색 사이트에서 검색을 한 뒤{' '} + 주소창을 관찰해 보세요. ?query=...,{' '} + &sort=..., &page=...가 + 그대로 보일 거예요. 여러분이 매일 쓰는 서비스가 이미 이 문법으로 말하고 있습니다. +
+
+ +
+

+ 이제 배운 것을 전부 모아 계약서 본문을 씁니다. 거창한 도구는 + 필요 없어요. 메서드 · 경로 · 설명 · 성공 코드 · 주요 실패 다섯 열짜리 + 표 하나면, 프론트와 백엔드가 같은 그림을 보고 일할 수 있습니다. 이 코스의 실습이 + 바로 이 표를 완성하는 것이고요. +

+ {CODE_SPEC_TABLE} +

+ 경로에 붙은 /v1이 보이시나요? API에{' '} + 버전을 붙이는 이유는 계약서라는 성격 그 자체에 있습니다. 서비스가 + 자라면 응답 구조를 바꿔야 할 날이 반드시 와요. 그런데 이미 v1 계약을 믿고 만들어진 + 프론트가 세상에 돌고 있다면? 기존 계약을 깨는 대신 /v2라는{' '} + 새 계약서를 나란히 열고, 옛 사용자가 이사 올 시간을 주는 겁니다. + 붙이는 시점은 첫 배포 때부터 — 나중에 붙이려면 그게 바로 첫 번째 + 계약 파기가 되니까요. +

+

+ 표가 커지면 사람 손으로 관리하기 버거워지는데, 그때 등장하는 것이{' '} + Swagger(OpenAPI)입니다. 명세를 기계가 읽을 수 있는 형식(YAML/JSON)으로 + 쓰면, 문서 페이지 생성부터 "바로 쏴 보기" 버튼까지 도구가 대신해 줘요. +

+ {CODE_OPENAPI} +
+ 실습 진행법 — 짝 리뷰가 핵심 ① 위 양식으로 미니 블로그(글·댓글·좋아요) + 명세서를 각자 완성하세요. ② 짝과 서로 바꿔 읽으며 빈틈을 찾습니다 — + "없는 글에 댓글을 달면 몇 번이죠?", "좋아요를 두 번 누르면요?", "삭제 성공은 200인가요 + 204인가요?" ③ 답이 막히는 곳이 바로 계약서의 구멍이에요. 실제 현장의 API 리뷰가 + 정확히 이 대화로 이루어집니다. +
+
+ 명세는 살아 있는 문서 한 번 쓰고 끝이 아니에요. 구현하다 보면 반드시 명세를 + 고칠 일이 생깁니다. 그때 코드만 바꾸고 명세를 안 바꾸는 것이 최악의 + 습관이에요 — 거짓말하는 계약서는 없느니만 못합니다. "명세를 먼저 고치고, 상대에게 + 알리고, 코드를 바꾼다" — 이 순서를 몸에 붙이세요. +
+
+ +
+
+

📐 여기까지 왔다면

+

+ 이제 여러분은 코드를 한 줄도 짜기 전에 서비스의 뼈대를 표 한 장으로 설계할 + 수 있게 됐어요. 명사로 된 주소, 동사인 메서드, 책임 소재를 말하는 상태 코드, + 일관된 바디, 옵션을 담는 쿼리 — 그리고 그 전부를 담은 계약서까지. 그런데 계약서는 + 결국 이행되어야 의미가 있죠. 여러분이 방금 쓴 미니 블로그 명세서를 + 진짜로 응답하는 서버로 만들어 볼 차례입니다. 다음{' '} + Express로 CRUD API 만들기{' '} + 강좌에서, 이 계약서를 코드로 이행하며 "명세대로 만든다"는 감각을 손에 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv3AuthArchitecturePage.jsx b/frontend/src/pages/levels/Lv3AuthArchitecturePage.jsx new file mode 100644 index 0000000..7147727 --- /dev/null +++ b/frontend/src/pages/levels/Lv3AuthArchitecturePage.jsx @@ -0,0 +1,434 @@ +// 이 파일이 하는 일: "인증 아키텍처 — 세션 vs JWT vs OAuth" 코스 — 로그인 뒤에 숨은 +// 설계 결정을 7개 섹션으로 파고드는 고급 과정 정적 학습 페이지. 인증/인가 구분에서 출발해 +// 세션·JWT·리프레시 토큰·저장 위치·OAuth2/OIDC·마이크로서비스 인증까지, "쿠키냐 토큰이냐"라는 +// 질문을 '무상태의 자유와 대가'라는 저울로 다시 읽는다. 프레이밍은 "서비스를 만들어 본 사람"이 +// 우리 실제 플랫폼(Spring 마이크로서비스 + JWT + Valkey 리프레시)의 설계 이유를 되짚는 것. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_AUTHN_AUTHZ = `인증(Authentication) 인가(Authorization) +"너 누구야?" "너 이거 해도 돼?" + +로그인 화면에서 일어나는 일 로그인 '이후' 매 요청마다 일어나는 일 +아이디+비밀번호 → 신원 확인 역할(role)·소유권 → 권한 판단 +실패하면 → 401 Unauthorized 실패하면 → 403 Forbidden + +순서가 중요합니다: 반드시 인증이 먼저, 인가가 나중. +"누구인지도 모르는데 권한을 따질 수는 없다." + +그리고 갈림길 하나 — +서버가 '누가 로그인해 있는지'를 기억하면 → 상태 유지(stateful, 세션) +서버가 아무것도 기억하지 않으면 → 무상태(stateless, JWT) +이 갈림길이 이 코스 전체를 관통하는 저울입니다.`; + +const CODE_SESSION = `[세션 기반 인증의 한 사이클] + +① 로그인 클라이언트 → 서버 : POST /login (id, pw) +② 발급 서버: 세션 저장소에 기록을 만든다 + sessionId=ab12cd → 사용자 7번, 로그인 시각, 역할 ... +③ 전달 서버 → 클라이언트 : Set-Cookie: SID=ab12cd; HttpOnly; Secure +④ 이후요청 클라이언트 → 서버 : Cookie: SID=ab12cd (브라우저가 자동 첨부) +⑤ 조회 서버: 저장소에서 ab12cd를 찾아 "아, 7번 사용자구나" +⑥ 로그아웃 서버: 저장소에서 ab12cd를 지운다 → 그 즉시 무효 + +핵심: 쿠키에 담긴 건 '열쇠 번호'뿐, 사용자 정보는 전부 서버에 있다. +그래서 서버가 마음만 먹으면 언제든 끊을 수 있다(강제 로그아웃이 쉽다). +대신 서버는 모든 로그인 사용자의 상태를 '기억'해야 한다(stateful).`; + +const CODE_JWT = `[JWT 한 장의 해부 — 점(.)으로 나뉜 세 덩어리] + +eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiI3Iiwicm9sZSI6IlVTRVIifQ . dBjftJeZ4CVP... +──────────────────── ────────────────────────────────── ────────────── +① 헤더(Header) ② 페이로드(Payload) ③ 서명(Signature) +어떤 알고리즘으로 실제 내용(클레임). 예: 헤더+페이로드를 +서명했나 (HS256 등) sub: "7" (사용자 ID) 비밀키로 서명한 값. + role: "USER" 한 글자라도 바뀌면 + displayName: "김수습" 서명이 안 맞는다. + expert: false + exp: 1767225600 (만료 시각) + +검증: 서버가 받은 토큰의 ①+②를 자기 비밀키로 다시 서명해 ③과 비교. +일치하면 "내가 발급한 그대로다" — 저장소 조회 없이 그 자리에서 신뢰. + +주의: ②는 암호화가 아니라 Base64 '인코딩'일 뿐! +누구나 디코딩해 읽을 수 있으니 비밀번호·주민번호는 절대 넣지 않는다.`; + +const CODE_REFRESH = `[짧은 액세스 + 긴 리프레시 — 우리 auth 서비스의 흐름] + +액세스 토큰 수명 ~15분. 매 API 요청에 실려 다닌다. 서버는 서명만 검증. +리프레시 토큰 수명 ~14일. 오직 "새 액세스 토큰 주세요"에만 쓴다. + 발급 즉시 Valkey에 저장 → 서버가 '기억하는' 토큰. + +① 로그인 → 액세스 + 리프레시 발급, 리프레시는 Valkey에 기록 +② 평소 요청 → 액세스 토큰만 검증 (Valkey 조회 없음, 무상태) +③ 액세스 만료(401) → 리프레시 토큰으로 POST /api/auth/refresh +④ 회전(rotation) → Valkey 대조 후 새 액세스 + '새' 리프레시 발급, + 옛 리프레시는 그 자리에서 폐기 (재사용되면 도난 신호!) +⑤ 로그아웃 → 서버가 Valkey에서 리프레시를 삭제 + → 남은 액세스 토큰이 죽는 최대 15분만 버티면 완전 차단 + +설계의 저울: 무상태의 속도(②)는 지키고, +통제가 필요한 지점(④⑤)에만 최소한의 '상태'를 되살렸다.`; + +const CODE_STORAGE = `[토큰을 어디에 두나 — 두 자리의 트레이드오프] + + localStorage httpOnly 쿠키 +───────────────────────────────────────────────────────────── +JS로 읽기 가능 불가능 (스크립트에 안 보임) +XSS에 뚫리면 토큰이 통째로 유출 토큰 자체는 못 훔친다 +CSRF 해당 없음(자동 전송 안 됨) 위험 있음 → SameSite로 방어 +전송 방식 코드가 Authorization 헤더에 브라우저가 요청마다 자동 첨부 + 직접 실어야 함 +편의성 구현 단순, SPA에서 흔함 서버가 Set-Cookie로 관리 + +한 줄 정리: +localStorage는 CSRF엔 강하지만 XSS 한 방에 전부 내준다. +httpOnly 쿠키는 XSS로부터 토큰을 숨기고, CSRF는 SameSite=Lax/Strict + +CSRF 토큰으로 막는다. 현대 웹의 무게추는 httpOnly 쪽으로 기울어 있다.`; + +const CODE_OAUTH = `[OAuth2 Authorization Code 흐름 — "구글로 로그인" 버튼 뒤에서] + +등장인물: 사용자 / 우리 앱(클라이언트) / 구글(인가 서버 + 자원 서버) + +① 사용자가 "구글로 로그인" 클릭 + → 우리 앱이 구글 인가 서버로 보낸다 (client_id, redirect_uri, scope 포함) +② 사용자가 '구글 화면에서' 로그인 + "이 앱에 프로필 제공 동의" + → 비밀번호는 구글에만 입력된다. 우리 앱은 구경도 못 한다. +③ 구글 → 우리 앱 : redirect_uri로 '인가 코드' 전달 (1회용, 수명 짧음) +④ 우리 서버 → 구글 : 코드 + client_secret을 내밀고 토큰 교환 + → access_token (+ OIDC라면 id_token) 수령 +⑤ 우리 서버: id_token 서명 검증 → "구글이 보증하는 이 사람" 확인 + → 우리 회원과 연결하고, '우리' 액세스/리프레시 토큰을 발급 + +왜 코드를 한 번 거치나? ③은 브라우저(앞문)로 오가서 노출 위험이 있고, +④는 서버끼리(뒷문)의 통신이라 client_secret으로 신원을 증명할 수 있다. +앞문으로는 1회용 코드만, 진짜 토큰은 뒷문으로 — 이게 이 흐름의 요지.`; + +const CODE_MSA = `[마이크로서비스에서 "누가 검증하나" — 두 가지 배치] + +(A) 게이트웨이 집중 검증 (B) 서비스 자체 검증 ← 우리 방식 +게이트웨이가 JWT를 까고 게이트웨이는 라우팅만. +뒤 서비스엔 헤더로 전달 auth·market·content... 각자가 + 같은 비밀키로 서명을 직접 검증 +장점: 검증 로직 한 곳 장점: 게이트웨이가 뚫려도 각 서비스가 +단점: 게이트웨이 뒤 내부망을 최종 관문. 서비스 단독 테스트 쉬움 + '무조건 신뢰'하게 됨 단점: 검증 코드·키가 서비스마다 복제 + +서비스 사이의 신뢰는 별도 문제: +사용자 없이 서비스끼리 부르는 /internal/** 경로는 +X-Internal-Key 라는 공유 비밀 헤더로 "동료 서비스 맞음"을 증명한다. +사용자의 신원이 필요하면? JWT를 그대로 전파(propagation)해서 +받는 쪽도 다시 검증한다 — "전달받은 신뢰도 검증 후에 쓴다."`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '인증 vs 인가' }, + { n: 2, label: '세션 기반' }, + { n: 3, label: 'JWT 해부' }, + { n: 4, label: '액세스·리프레시' }, + { n: 5, label: '토큰 보관 위치' }, + { n: 6, label: 'OAuth2·OIDC' }, + { n: 7, label: 'MSA 인증' }, +]; + +export default function Lv3AuthArchitecturePage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 고급 과정
+

🔑 인증 아키텍처
— 세션 vs JWT vs OAuth

+

+ 로그인은 만들 줄 알아요. 이제 질문이 달라집니다 — 왜 세션이 아니라 + JWT인가? 왜 리프레시 토큰을 Valkey에 두는가? 토큰은 어디에 보관해야 하는가?{' '} + 쿠키냐 토큰이냐의 선택은 취향이 아니라, 무상태(stateless)의 자유와 + 그 대가를 저울질하는 설계 결정이에요. 우리 플랫폼의 실제 auth 설계를 + 해부하며 그 저울을 손에 넣어 봅시다. +

+
+ 예상 소요 65분 + 실습: JWT+리프레시 시퀀스 다이어그램 + 선수 지식: HTTP·쿠키·서비스 구축 경험 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 인증 설계의 첫걸음은 용어를 가르는 거예요. 인증(Authentication)은 + "너 누구야?" — 아이디와 비밀번호로 신원을 확인하는 일이고,{' '} + 인가(Authorization)는 "너 이거 해도 돼?" — 확인된 신원의{' '} + 권한을 판단하는 일입니다. 순서도 정해져 있어요. 누구인지 모르는데 + 권한을 따질 수는 없으니, 항상 인증이 먼저, 인가가 나중입니다. +

+ {CODE_AUTHN_AUTHZ} +

+ HTTP 상태 코드가 이 구분을 그대로 담고 있어요. 401 + Unauthorized는 "당신이 누군지 모르겠다"(인증 실패), 403 + Forbidden은 "누군지는 알겠는데 이건 안 된다"(인가 실패). 이 둘을 섞어 쓰면 + 디버깅할 때 엉뚱한 곳을 파게 됩니다. +

+

+ 그리고 이 코스 전체를 관통하는 갈림길이 하나 있어요. 인증에 성공한 뒤, 서버가 + "누가 로그인해 있는지"를 기억할 것인가(stateful), 아니면{' '} + 기억하지 않을 것인가(stateless). 기억하면 통제가 쉽고 확장이 + 어려워지고, 기억하지 않으면 확장이 쉽고 통제가 어려워집니다. 섹션 2와 3이 + 이 갈림길의 양쪽 끝이에요. +

+
+ 비유 인증은 건물 로비의 신분증 확인, 인가는 각 층 문 앞의{' '} + 출입 카드 등급이에요. 신분증 확인을 통과했다고 모든 층에 들어갈 수 + 있는 건 아니죠. 로비(인증)는 한 번, 층별 문(인가)은 매번 — 요청마다 반복됩니다. +
+
+ +
+

+ 웹 인증의 원조는 세션이에요. 로그인에 성공하면 서버가 자기 쪽{' '} + 세션 저장소에 "세션 ab12cd = 7번 사용자"라는 기록을 만들고, + 클라이언트에겐 그 열쇠 번호(세션 ID)만 쿠키로 건넵니다. 이후 브라우저는 + 요청마다 쿠키를 자동으로 실어 보내고, 서버는 번호로 저장소를 뒤져 "아, 7번이구나"를 + 알아내죠. +

+ {CODE_SESSION} +

+ 이 방식의 힘은 즉시 통제예요. 진실이 전부 서버에 있으니, 저장소에서 + 기록 한 줄을 지우면 그 사용자는 그 순간 로그아웃됩니다. 계정 도용이 + 의심될 때 강제로 끊어내는 것도 한 줄이면 되죠. +

+

+ 대가는 확장할 때 드러납니다. 서버를 3대로 늘리면? 1번 서버 메모리에만 + 있는 세션을 2번 서버는 모르니 로그인이 풀려요. 해법은 세션 저장소를{' '} + Redis/Valkey 같은 공용 저장소로 빼는 것 — 하지만 이제 모든 요청이 + "저장소 조회 1회"라는 통행료를 냅니다. 서버는 무거워지지 않지만, 저장소가 모두의 + 병목이자 단일 장애점이 되죠. +

+
+ 세션의 고전적 공격 두 가지 세션 고정(fixation): 공격자가 미리 + 알아낸 세션 ID를 피해자에게 심어 두고, 피해자가 그 ID로 로그인하면 공격자도 같은 + 세션을 공유하게 되는 수법 — 방어는 로그인 성공 시 세션 ID를 새로 + 발급하는 것. 세션 탈취(hijacking): 쿠키를 훔쳐 그대로 행세하는 + 수법 — 방어는 HttpOnly(JS로 못 읽게) +{' '} + Secure(HTTPS로만 전송) + 짧은 만료입니다. +
+
+ +
+

+ JWT(JSON Web Token)는 발상을 뒤집어요. 서버가 기억하는 대신,{' '} + 사용자 정보를 토큰 안에 담아 서명해서 클라이언트에게 통째로 맡깁니다. + 점(.)으로 나뉜 세 덩어리 — 헤더(어떤 알고리즘), 페이로드 + (실제 내용), 서명(위조 방지 도장)이에요. +

+ {CODE_JWT} +

+ 검증의 핵심은 서명입니다. 서버는 받은 토큰의 헤더+페이로드를 자기 + 비밀키로 다시 서명해 세 번째 덩어리와 비교해요. 일치하면 "내가 발급한 그대로, 한 글자도 + 안 바뀌었다"가 수학적으로 보장됩니다. 저장소 조회가 필요 없으니 서버를 + 10대로 늘려도 같은 비밀키만 있으면 어느 서버든 즉시 검증할 수 있어요. 이게 무상태의 + 자유입니다. +

+

+ 우리 플랫폼의 토큰에는 role·displayName· + expert가 담겨요. 덕분에 각 서비스는 DB를 안 보고도 + "관리자인가? 화면에 뭐라고 표시하나? 전문가 권한인가?"를 판단합니다. 단, 여기엔 두 가지 + 주의가 따라요. 첫째, 페이로드는 암호화가 아니라 인코딩 — 누구나 열어 + 볼 수 있으니 민감정보는 금지. 둘째, 토큰에 담긴 값은 발급 시점의 + 스냅샷이라, 관리자가 role을 바꿔도 기존 토큰이 만료될 때까지는 옛 role로 + 행세합니다. +

+
+ '취소 불가'라는 대가 세션은 서버 기록을 지우면 즉시 끊기지만, JWT는 서버가 + 기억하는 게 없으니 한번 발급한 토큰을 만료 전에 무효화할 방법이 + 없습니다. 유출돼도 만료까지 유효하죠. 그래서 JWT 설계의 제1원칙은{' '} + 수명을 짧게 — 그리고 짧은 수명이 만드는 "15분마다 재로그인?" 문제를 + 다음 섹션의 리프레시 토큰이 풉니다. +
+
+ +
+

+ 해법은 토큰을 두 장으로 나누는 거예요. 액세스 토큰은 + 수명 15분 안팎으로 짧게 — 유출돼도 피해 창이 좁습니다. 리프레시 토큰은 + 수명을 길게 주되, 오직 "새 액세스 토큰 주세요"라는 한 가지 용도로만 쓰고,{' '} + 발급 즉시 서버(Valkey)에 저장해요. 눈치채셨나요? 리프레시 토큰은 + 사실상 세션의 귀환입니다 — 서버가 다시 '기억하는' 토큰이니까요. +

+ {CODE_REFRESH} +

+ 우리 auth 서비스가 정확히 이 구조예요. 평소 API 요청(②)은 액세스 토큰 서명 검증만으로 + 처리돼 Valkey를 건드리지 않고(무상태의 속도), 갱신(④)과 로그아웃(⑤) + 같은 통제가 필요한 순간에만 Valkey를 봅니다. 무상태의 자유는 최대한 + 누리되, 대가가 아픈 지점에만 최소한의 상태를 되살린 절충이죠. +

+

+ ④의 회전(rotation)은 그냥 재발급이 아니에요. 리프레시할 때마다{' '} + 새 리프레시 토큰을 주고 옛것은 즉시 폐기합니다. 이러면 공격자가 + 리프레시 토큰을 훔쳐도 정상 사용자와 공격자 중 한쪽이 먼저 쓰는 순간 다른 쪽의 + 토큰이 죽고, 이미 폐기된 토큰이 다시 들어오면 "도난당했구나"를 탐지해 해당 + 사용자의 토큰을 전부 끊을 수 있어요. +

+
+ 서버측 로그아웃이 '진짜'인 이유 클라이언트에서 토큰을 지우는 것만으로는 + 로그아웃이 아니에요 — 복사해 둔 토큰은 여전히 살아 있으니까요. 우리 설계처럼{' '} + 서버가 Valkey에서 리프레시 토큰을 삭제해야 갱신 경로가 끊기고, 남은 + 액세스 토큰의 잔여 수명(최대 15분)만 지나면 완전히 차단됩니다. 그 15분마저 못 참는 + 서비스라면 액세스 토큰 블랙리스트를 두지만, 그만큼 매 요청 조회가 + 늘어나 무상태의 이점을 깎아 먹죠 — 여기서도 저울질입니다. +
+
+ +
+

+ 토큰 설계가 끝나도 질문이 하나 남아요. 브라우저에서 그 토큰을 어디에 + 두는가. 후보는 둘 — JS로 자유롭게 읽고 쓰는 localStorage, + 그리고 JS에게는 보이지 않는 httpOnly 쿠키. 저장 위치의 + 선택이 곧 어떤 공격에 노출될지를 결정합니다. +

+ {CODE_STORAGE} +

+ localStorage의 약점은 XSS예요. 악성 + 스크립트가 한 줄이라도 실행되면 localStorage.getItem(){' '} + 한 번에 토큰이 통째로 공격자 서버로 넘어갑니다. httpOnly{' '} + 쿠키는 스크립트에서 아예 읽을 수 없으니 XSS로도 토큰 자체는 못 훔쳐요. + 대신 브라우저가 요청마다 자동 첨부하는 성질 때문에 CSRF(다른 사이트가 + 내 브라우저를 시켜 요청을 위조)에 노출되는데, 이건 SameSite=Lax/Strict{' '} + 속성과 CSRF 토큰으로 막는 정석이 있습니다. XSS·CSRF 공격의 원리는 8강(웹 보안)에서 + 다뤘던 그대로예요 — 이 섹션은 그 지식이 저장소 선택으로 이어지는 + 다리입니다. +

+

+ 우리 플랫폼은 현재 토큰을 localStorage 계열에 보관하고 있고, 그래서{' '} + httpOnly 쿠키 전환이 보안 과제로 잡혀 있어요. "동작하는데 왜 + 바꾸나?"가 아니라 — XSS 한 방에 전 사용자의 토큰이 걸린 구조냐, 토큰만은 지켜지는 + 구조냐의 차이입니다. 잘 만드는 법이란 이런 기본값을 다시 고르는 일이에요. +

+
+ 완벽한 자리는 없다 httpOnly로 옮겨도 XSS 자체가 무해해지는 건 아니에요 — + 스크립트가 토큰을 '못 읽을' 뿐, 사용자인 척 요청을 대신 보내는 것은 + 여전히 가능합니다. 저장 위치는 피해 범위를 줄이는 장치지, XSS 방어(입력 이스케이프, + CSP)를 대체하지 않아요. 방어는 언제나 겹겹이(defense in depth)입니다. +
+
+ +
+

+ "구글로 로그인"의 본질은 비밀번호를 우리에게 알려주지 않고, 구글이 대신 + 보증하게 하는 것이에요. 이를 위한 표준 절차가 OAuth2이고, + 그중 웹 서비스의 정석은 Authorization Code 흐름입니다. 한 단계씩 + 따라가 봅시다. +

+ {CODE_OAUTH} +

+ 흐름의 묘수는 인가 코드를 한 번 거치는 것이에요. 브라우저 리다이렉트 + (③)는 주소창·히스토리에 노출될 수 있는 '앞문'이라 1회용 코드만 태우고, + 진짜 토큰은 우리 서버와 구글 서버가 client_secret으로 + 서로를 확인하는 '뒷문'(④)으로만 오갑니다. 노출 위험이 있는 길에는 가치가 낮은 것만 + 싣는다 — 보안 설계의 반복되는 문법이죠. +

+

+ 여기서 용어 하나를 정확히 가르고 갑시다. OAuth2는 인가(Authorization) + 프로토콜이에요 — "이 앱이 내 구글 캘린더를 읽어도 좋다"는 권한 + 위임이 본래 목적이죠. 그런데 다들 이걸 로그인(인증)에 쓰고 싶어 하니, OAuth2 + 위에 인증 층을 표준으로 얹은 것OIDC(OpenID + Connect)입니다. OIDC가 추가한 id_token은 그 + 자체가 JWT여서 — 섹션 3에서 배운 서명 검증으로 "구글이 서명한 이 + 사람"을 확인해요. 배운 조각들이 맞물리는 순간입니다. +

+
+ 소셜 로그인이 끝이 아니다 ⑤에서 구글 확인이 끝나면, 우리 서버는{' '} + 우리 자신의 액세스/리프레시 토큰을 새로 발급합니다. 구글의 토큰은 + "구글 자원에 접근할 권한"이지 우리 API의 통행증이 아니니까요. 즉 소셜 로그인은{' '} + 인증의 입구만 빌려오는 것이고, 입장 후의 세계는 섹션 4에서 만든 우리 + 토큰 체계가 그대로 다스립니다. +
+
+ +
+

+ 서비스가 6개로 쪼개지면 새 질문이 생깁니다. JWT는 누가 검증하나?{' '} + 배치는 크게 두 가지예요. (A) 게이트웨이 집중 검증 — 관문에서 한 번 + 까고 뒤로는 신뢰를 전제하는 방식, (B) 서비스 자체 검증 — 게이트웨이는 + 길만 안내하고, 각 서비스가 같은 비밀키로 서명을 직접 확인하는 방식. 우리 플랫폼은{' '} + (B)입니다. +

+ {CODE_MSA} +

+ (A)는 검증 로직이 한 곳이라 깔끔하지만, 게이트웨이 뒤의 내부망을 무조건 + 신뢰하게 돼요. 게이트웨이가 뚫리거나 내부망에 다른 침입 경로가 생기면 모든 + 서비스가 무방비가 되죠. (B)는 검증 코드가 서비스마다 복제되는 대신, 각 서비스가 + 자기 문 앞의 최종 관문이 됩니다 — "내부에서 온 요청도 검증한다"는 제로 트러스트 + 방향의 선택이에요. JWT가 무상태라서 가능한 배치이기도 합니다. 세션이었다면 서비스 6개가 + 전부 세션 저장소에 붙어야 했을 테니까요. +

+

+ 사용자 없이 서비스끼리 부르는 호출은 결이 달라요. 예컨대 다른 서비스가 + auth의 /internal/notifications를 부를 때는 사용자 JWT가 + 없으니, X-Internal-Key라는 공유 비밀 헤더로 + "나 동료 서비스야"를 증명합니다. 게이트웨이가 /internal/**{' '} + 경로를 외부에 아예 열지 않는 것과 겹쳐, 키 + 경로 차단의 이중 방어가 + 되죠. 반대로 "이 요청을 시킨 사용자가 누구인지"가 뒤 서비스에도 + 필요하면, JWT를 헤더에 실어 그대로 전파하고 받은 쪽이 다시 검증합니다. +

+
+ 실습 — 이 코스의 마무리 우리 auth의 로그인→API 호출→액세스 만료→리프레시 + (회전)→로그아웃 흐름을 시퀀스 다이어그램으로 그려 보세요. 등장인물은 + 브라우저·게이트웨이·auth·Valkey·일반 서비스 하나면 충분합니다. 다 그렸다면 같은 + 시나리오를 세션 방식이었다면으로 한 장 더 그리고, 화살표 수·저장소 + 조회 횟수·강제 로그아웃 지점을 비교해 보세요. 두 그림의 차이가 곧 이 코스에서 배운 + 저울 — 무상태의 자유와 대가입니다. +
+
+ +
+
+

🔑 여기까지 왔다면

+

+ 이제 "세션이냐 JWT냐"가 유행의 문제가 아니라 상태를 어디에 둘 것인가의 + 문제라는 걸 알게 됐어요. 서버가 기억하는 세션의 통제력, 서명 하나로 달리는 JWT의 + 확장성, 그 사이에서 리프레시 토큰과 Valkey로 균형을 잡은 우리 설계, 저장 위치와 + OAuth2·OIDC, 그리고 서비스 6개 사이의 신뢰 배치까지 — 인증이라는 축 하나로 아키텍처 + 전체를 관통해 봤습니다. 그런데 이렇게 설계한 시스템이 실제로 잘 돌고 + 있는지는 어떻게 알까요? 로그인 실패가 급증하면 누가 먼저 알아채야 할까요? + 다음 로깅·모니터링·알림 설계{' '} + 코스에서, 돌아가는 시스템을 '보이게' 만드는 기술을 이어서 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv3CachingStrategyPage.jsx b/frontend/src/pages/levels/Lv3CachingStrategyPage.jsx new file mode 100644 index 0000000..6087d09 --- /dev/null +++ b/frontend/src/pages/levels/Lv3CachingStrategyPage.jsx @@ -0,0 +1,493 @@ +// 이 파일이 하는 일: "캐싱 전략 — 로컬과 Redis" 코스 — 캐시를 '빨라지는 마법'이 아니라 +// 무효화와 일관성을 감당하는 설계 문제로 다루는 고급 과정 페이지. 캐시를 쓰기 전 질문에서 +// 출발해, 계층 선택(로컬 vs Redis) → 무효화 → 패턴(Cache-Aside 등) → 장애(스탬피드·관통·눈사태) +// → 일관성·보안 → 운영·측정까지 7개 섹션으로 안내한다. 프레이밍은 "서비스를 만들어 본 사람"이 +// 우리 플랫폼의 실제 설계(market:quote 시세 캐시, 리프레시 토큰 저장)를 읽어 내는 것. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_CHECKLIST = `캐시를 붙이기 전에 답해야 할 세 가지 질문 + +① 이 데이터, 읽기가 쓰기보다 압도적으로 많은가? + - 시세 조회: 1초에 수천 번 읽고, 갱신은 초당 몇 번 → 캐시감 + - 주문 내역: 쓰자마자 바로 정확히 읽어야 함 → 캐시 비추천 + +② 조금 낡은(stale) 값을 보여 줘도 되는가? 몇 초까지? + - 공지사항 목록: 30초 늦어도 아무도 모름 → TTL 30s + - 계좌 잔고: 1원이라도 틀리면 사고 → 캐시 말고 DB + +③ 캐시가 전부 날아가도 서비스가 버티는가? + - 캐시는 '있으면 빠른' 층이지 '없으면 죽는' 층이 아니어야 함 + - 이 질문에 "아니오"라면, 그건 캐시가 아니라 두 번째 DB를 + 만들고 있는 것 — 설계를 다시 봐야 함`; + +const CODE_LAYERS = `로컬(인메모리) 캐시 분산 캐시 (Valkey/Redis) +───────────────────────── ───────────────────────── +같은 JVM 힙 안의 Map/Caffeine 별도 서버, TCP로 왕복 +지연: ~수십 나노초 (메모리 접근) 지연: ~1ms 안팎 (네트워크 포함) +서버가 3대면 캐시도 3벌 모든 서버가 같은 값을 봄 +프로세스 재시작 = 캐시 증발 서버 재시작에도 살아남음 +서로 다른 값 들고 있을 수 있음 한 곳만 무효화하면 끝 + +선택 기준: "여러 인스턴스가 같은 값을 봐야 하는가?" +- 그렇다 → 분산 캐시. 우리 시세 캐시 market:quote가 Valkey에 사는 + 이유가 이것 — 수집기가 쓴 최신가를 어떤 API 서버가 받아도 + 똑같이 읽어야 하고, 서버를 늘려도 캐시가 쪼개지면 안 된다. +- 아니다(불변 설정, 코드표 등) → 로컬로 충분. 네트워크 왕복조차 아깝다.`; + +const CODE_MULTILAYER = `// 다계층 캐시: L1(로컬) + L2(Redis) — 큰 서비스의 흔한 구성 +// 요청 → L1(나노초) → 미스 → L2(1ms) → 미스 → DB(수십 ms) + +String getQuoteName(String code) { + String v = l1.getIfPresent(code); // ① L1: JVM 안, 공짜에 가까움 + if (v != null) return v; + + v = redis.get("market:quote:" + code); // ② L2: 네트워크 1회 + if (v != null) { + l1.put(code, v); // L1에 되채워 다음 요청 대비 + return v; + } + + v = db.findName(code); // ③ 원본: 제일 비싼 길 + redis.setex("market:quote:" + code, 5, v); // L2에 TTL 5초로 적재 + l1.put(code, v); + return v; +} + +// 대가: 층이 늘수록 '서로 다른 값'을 들고 있을 확률도 는다. +// 그래서 L1의 TTL은 L2보다 훨씬 짧게(예: 1초) 잡아 불일치 창을 좁힌다.`; + +const CODE_INVALIDATION = `무효화 전략 두 갈래 — 언제 캐시를 버릴 것인가 + +① TTL(Time-To-Live) 만료: "n초 지나면 알아서 버려" + SET market:quote:005930 "72,300" EX 5 # 5초 뒤 자동 소멸 + - 장점: 구현이 없다. 잊어도 결국 낡은 값이 사라진다 (안전망) + - 단점: TTL 동안은 낡은 값을 그대로 서빙 + +② 이벤트 기반: "원본이 바뀌는 순간, 내가 직접 버려" + // 설정 저장 API 안에서 + db.save(config); // 1) 원본 갱신 + redis.del("config:" + config.key()); // 2) 캐시 삭제 + - 장점: 불일치 창이 밀리초 단위로 준다 + - 단점: '바뀌는 모든 경로'를 빠짐없이 찾아 심어야 한다 (누락 = 버그) + +실무 정석: 둘을 겹친다. 이벤트로 즉시 지우되, +TTL을 안전망으로 깔아 "지우는 걸 깜빡한 경로"의 피해를 시간으로 제한.`; + +const CODE_UPDATE_VS_DELETE = `쓰기 시점에 캐시를 '갱신'할까, '삭제'할까? + + 갱신(SET 새값) 삭제(DEL) + ────────────── ────────── + 다음 읽기도 즉시 히트 다음 읽기 한 번은 미스(DB행) + 두 요청이 엇갈리면 값을 다시 계산하는 주체가 + 낡은 값이 이길 수 있음 ←위험 '읽기' 하나로 통일됨 ←단순 + + [갱신의 함정] 요청 A(값=1 계산)와 B(값=2 계산)가 거의 동시에 쓰면 + DB엔 2가 남았는데 캐시엔 A가 늦게 도착해 1이 남을 수 있다. + 삭제는 이 경합 자체가 없다 — 그래서 기본 선택은 '삭제'다. + 갱신은 "계산이 아주 비싸고 쓰기 경합이 없다"고 확신할 때만.`; + +const CODE_ASIDE = `// Cache-Aside(지연 로딩) — 캐싱 패턴의 기본형 +// "캐시를 먼저 보고, 없으면 DB에서 읽어 캐시에 채워 넣는다" + +public Quote getQuote(String code) { + String key = "market:quote:" + code; + + String cached = redis.get(key); // ① 캐시 먼저 + if (cached != null) { + return parse(cached); // 히트: DB 안 감 + } + + Quote q = quoteRepository.find(code); // ② 미스: 원본에서 읽고 + redis.setex(key, 5, serialize(q)); // ③ 캐시에 채운다 (TTL 5초) + return q; +} + +// 왜 기본형인가: +// - 캐시가 통째로 날아가도 그냥 '전부 미스'가 될 뿐, 서비스는 돈다 +// - 실제로 읽힌 것만 캐시에 올라간다 (안 쓰는 데이터로 메모리 안 낭비) +// - DB 쓰기 코드는 캐시의 존재를 몰라도 된다 (삭제 한 줄만 얹으면 끝)`; + +const CODE_WRITE_PATTERNS = `쓰기까지 캐시가 책임지는 패턴들 — Cache-Aside와의 비교 + +Write-Through: 쓰기가 캐시를 '통과'해 DB까지 간다 + app → cache → DB (둘 다 성공해야 쓰기 완료) + + 캐시와 DB가 항상 같다 - 쓰기가 느려진다(두 번 씀) + +Write-Behind(Write-Back): 캐시에만 쓰고, DB엔 나중에 몰아서 + app → cache ---(비동기 배치)--→ DB + + 쓰기가 매우 빠르다 - 캐시가 죽으면 아직 안 옮긴 + 쓰기가 '유실'된다 ←무거운 위험 + +Refresh-Ahead: 만료 직전에 미리 백그라운드로 갱신 + TTL 60초짜리 인기 키를 55초쯤에 미리 다시 읽어 채움 + + 인기 키는 미스를 아예 안 맞음 - '인기'를 예측해야, 빗나가면 낭비 + +기본기: 읽기 가속이 목적이면 Cache-Aside부터. 나머지는 +쓰기 지연·유실 감수라는 명확한 대가를 알고 고르는 특수해.`; + +const CODE_STAMPEDE = `// 캐시 스탬피드: 인기 키 하나가 만료되는 순간, +// 대기하던 요청 1,000개가 '동시에 미스' → 전부 DB로 돌진 → DB 사망 + +// 방어 ① 락: 처음 한 놈만 DB에 가고, 나머지는 잠깐 기다린다 +String v = redis.get(key); +if (v == null) { + boolean won = redis.setnx(key + ":lock", "1", 3); // 3초짜리 락 + if (won) { + v = db.load(); // 이긴 1명만 원본 조회 + redis.setex(key, 60, v); + redis.del(key + ":lock"); + } else { + Thread.sleep(50); // 진 999명: 잠깐 쉬고 + v = redis.get(key); // 채워진 캐시를 다시 읽음 + } +} + +// 방어 ② 확률적 조기 만료: 만료를 '다 같이' 맞지 않게 +// 남은 TTL이 짧을수록 높은 확률로 '내가 미리 갱신'을 자원한다. +// 여럿이 각자 주사위를 굴리므로 갱신 시점이 자연히 흩어진다. +if (remainingTtl < renewWindow * random()) { + refreshInBackground(key); // 만료 전에 미리 채움 +}`; + +const CODE_METRICS = `# 운영 점검 — Valkey/Redis에서 캐시 건강 읽기 +redis-cli INFO stats | grep keyspace +# keyspace_hits:184220 ← 캐시가 답한 횟수 +# keyspace_misses:9711 ← DB까지 간 횟수 +# 히트율 = hits / (hits + misses) = 184220 / 193931 = 약 95% + +redis-cli INFO memory | grep -E "used_memory_human|maxmemory" +# used_memory_human:412.33M ← 지금 쓰는 메모리 +redis-cli CONFIG GET maxmemory-policy +# allkeys-lru ← 가득 차면 '가장 오래 안 읽힌 키'부터 축출 + +redis-cli INFO stats | grep evicted_keys +# evicted_keys:120394 ← 메모리가 모자라 쫓겨난 키 수 +# 이 값이 빠르게 늘면: 메모리 증설 or TTL 단축 or 캐시 대상 축소 + +# 판단 기준(경험칙): +# 히트율 90%+ → 캐시가 제 몫을 함 +# 히트율 50%- → 캐시 유지비(코드 복잡도+불일치 위험)가 이득을 넘음 +# → 키 설계를 고치거나, 캐시를 떼는 게 답일 수 있다`; + +const CODE_LAB = `실습: 자주 조회되는 조회 API 하나에 Cache-Aside 적용하기 + +① 대상 고르기 — "읽기 많고, 몇 초 낡아도 되는" 조회 하나 + (예: 종목 기본정보 조회, 화면 설정 조회) + +② 측정 먼저 — 캐시 없이 현재 상태를 숫자로 남긴다 + for i in 1 2 3 4 5; do + time curl -s "http://localhost:8080/api/..." > /dev/null + done # 5회 응답시간 기록 (예: 평균 38ms) + +③ Cache-Aside 구현 — 섹션 4의 코드 골격 그대로 + get → 미스면 DB → setex(TTL은 ①에서 정한 '감당 가능한 낡음') + +④ 다시 측정 — 같은 요청 5회 (예: 평균 2ms), INFO stats로 히트율 확인 + +⑤ 무효화 검증 — 데이터를 바꾼 직후 조회해 본다 + 낡은 값이 TTL 동안 보이는가? 그게 ①에서 합의한 범위 안인가? + +제출물: 전/후 응답시간, 히트율, 그리고 "TTL을 왜 그 값으로 정했나" 한 단락. +숫자 없는 "빨라졌어요"는 실습 완료가 아닙니다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '쓰기 전 질문' }, + { n: 2, label: '계층 고르기' }, + { n: 3, label: '무효화' }, + { n: 4, label: '캐싱 패턴' }, + { n: 5, label: '무너질 때' }, + { n: 6, label: '일관성과 위험' }, + { n: 7, label: '운영과 측정' }, +]; + +export default function Lv3CachingStrategyPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 고급 과정
+

⚡ 캐싱 전략
— 로컬과 Redis

+

+ 캐시는 "붙이면 빨라지는 마법"으로 소개되곤 하지만, 진짜 비용은 붙인 다음에 옵니다. + 언제 버릴 것인가(무효화), 낡은 값을 얼마나 감당할 것인가(일관성) — + 이 두 질문에 답하지 못한 캐시는 성능 개선이 아니라 '가끔 틀린 값을 아주 빠르게 + 보여 주는 버그'예요. 이 코스에서는 우리 플랫폼의 실제 설계(시세 캐시, 토큰 저장소)를 + 교재 삼아, 캐시를 설계 문제로 다루는 법을 익힙니다. +

+
+ 예상 소요 60분 + 실습: Cache-Aside 적용 + 히트율·응답시간 측정 + 선수 지식: DB 조회 API를 만들어 본 경험 +
+
+ + {/* 섹션 바로가기 — 학습 포인트: 정적 앵커(#sec-n)만으로 만드는 목차 */} +
+ {SECTIONS.map((s) => ( + + {s.n}. {s.label} + + ))} +
+ +
+

+ 캐시의 원리는 단순해요. 비싸게 구한 답을 가까운 곳에 적어 두고, 같은 질문이 + 오면 적어 둔 걸 보여 준다. 그래서 캐시가 이득을 보는 조건도 명확합니다 — + 같은 질문이 충분히 자주 반복되고(읽기가 많고), 적어 둔 답이{' '} + 한동안 유효해야 하죠(자주 안 바뀜). 이 둘 중 하나라도 무너지면 + 캐시는 일을 안 하거나, 틀린 답을 합니다. +

+ {CODE_CHECKLIST} +

+ 특히 첫 번째 질문이 숫자로 이어집니다. 캐시가 답한 비율을{' '} + 히트율(hit rate)이라고 하는데, 히트율이 낮으면 캐시는 오히려 + 손해예요. 요청마다 캐시 확인(미스) + DB 조회 + 캐시 적재 + 라는 세 단계를 밟게 되니, 캐시 없이 DB만 보던 때보다 일이 늘어난 + 셈이죠. 여기에 무효화 코드의 복잡도, 불일치 사고의 위험까지 얹으면 — 히트율 낮은 + 캐시는 이자만 나가는 빚입니다. +

+
+ 고급 과정의 첫 감각 "캐시 없이 풀 수 있으면 캐시를 쓰지 마라." 인덱스 추가, + 쿼리 개선, 불필요한 조회 제거로 충분히 빨라진다면 그쪽이 항상 낫습니다. 캐시는 + 상태(복사본)를 하나 더 만드는 일이고, 상태가 하나 늘면 틀릴 수 있는 곳도 + 하나 는다는 게 이 코스 전체를 관통하는 전제예요. +
+
+ +
+

+ 캐시를 쓰기로 했다면, 다음 질문은 어디에 둘 것인가입니다. 크게 두 + 자리가 있어요. 앱 프로세스 안의 로컬(인메모리) 캐시 — 자바라면 + Map이나 Caffeine 같은 라이브러리 — 그리고 별도 서버로 띄우는{' '} + 분산 캐시, 대표적으로 Redis와 그 오픈소스 포크인 Valkey죠. +

+ {CODE_LAYERS} +

+ 속도만 보면 로컬이 압도적입니다. 메모리 접근이니까요. 그런데도 우리 시세 캐시{' '} + market:quote는 Valkey에 삽니다. 이유는 표의 마지막 + 기준 그대로예요. 시세는 수집기가 쓰고, 여러 API 서버가 읽는{' '} + 데이터입니다. 로컬 캐시로 하면 서버마다 다른 가격을 들고 있게 되고, 수집기가 새 + 가격을 어느 서버 메모리에 넣어 줄 방법도 없죠. 쓰는 쪽과 읽는 쪽이 다르고, + 읽는 쪽이 여럿이면 분산 캐시가 유일한 답입니다. +

+

+ 큰 서비스는 둘을 겹쳐 씁니다. L1(로컬)에서 나노초에 걸러 내고, + 미스만 L2(Redis)로, 거기서도 미스면 DB로 — 층마다 지연이 수십~수백 배씩 뛰기 + 때문에 위층에서 많이 막을수록 이득이죠. +

+ {CODE_MULTILAYER} +
+ 대가 없는 층은 없다 다계층은 빠르지만, 불일치할 수 있는 곳이 두 + 군데가 됩니다. L2를 지워도 L1엔 낡은 값이 남아 있을 수 있어요. 그래서 + L1의 TTL은 L2보다 훨씬 짧게 잡아 "낡아도 1초"로 창을 좁힙니다. 층을 하나 더 얹는 + 결정은 항상 "지연이 얼마나 줄고, 불일치 창이 얼마나 느는가"의 교환이에요. +
+
+ +
+

+ "컴퓨터 과학에는 어려운 문제가 둘 있다. 캐시 무효화, 그리고 이름 짓기." — 농담처럼 + 도는 말이지만 절반은 진담입니다. 캐시에 값을 넣는 코드는 누구나 + 짜요. 어려운 건 원본이 바뀌었을 때 그 복사본을 언제, 누가, 어떻게 + 버리느냐입니다. 여기서 실수하면 사용자는 '어제의 데이터'를 보고, 개발자는 + "분명 저장했는데 왜 안 바뀌죠?"라는 문의를 받게 되죠. +

+ {CODE_INVALIDATION} +

+ TTL은 게으르지만 확실한 방식이에요. 우리 시세 캐시가 짧은 TTL을 쓰는 + 것도 이 맥락입니다 — 시세는 어차피 계속 새로 들어오니, "몇 초 안에 알아서 사라진다"는 + 보장만으로 충분하거든요. 반대로 관리자가 바꾸는 설정값처럼 "바뀌는 + 순간이 명확하고, 바뀌면 즉시 반영돼야 하는" 데이터는 이벤트 기반 삭제가 어울립니다. +

+

+ 이벤트 기반을 고르면 한 가지 선택이 더 남아요. 쓰기 시점에 캐시를 새 값으로{' '} + 갱신할 것인가, 그냥 삭제할 것인가. +

+ {CODE_UPDATE_VS_DELETE} +
+ 기본값은 삭제 갱신은 "다음 읽기도 히트"라는 이득이 있지만, 동시에 쓰는 두 + 요청의 순서가 꼬이면 DB와 다른 값이 캐시에 눌러앉는 위험을 삽니다. + 삭제는 그 경합 자체를 없애고, 값을 만드는 책임을 '읽기 경로 하나'로 모아 코드도 + 단순해져요. 헷갈리면 삭제 — 갱신은 근거가 있을 때만. +
+
+ +
+

+ "캐시 먼저 보고, 없으면 DB에서 읽어 채운다" — 섹션 2와 3에서 이미 여러 번 스친 이 + 흐름에는 이름이 있습니다. Cache-Aside(지연 로딩, lazy loading). + 애플리케이션이 캐시를 옆에(aside) 두고 직접 확인·적재를 관리하는 + 패턴이에요. +

+ {CODE_ASIDE} +

+ Cache-Aside가 기본형인 이유는 실패에 강해서입니다. 캐시 서버가 + 통째로 재시작돼도 결과는 "한동안 전부 미스" — 느려질 뿐 틀리지 않아요. 또 실제로 + 읽힌 키만 캐시에 올라가니 메모리도 수요를 따라갑니다. 반면 쓰기까지 캐시가 책임지는 + 패턴들은 더 강한 보장을 주는 대신, 더 무거운 대가를 요구해요. +

+ {CODE_WRITE_PATTERNS} +
+ Write-Behind의 무게 셋 중 Write-Behind만은 특별히 조심하세요. "캐시에만 쓰고 + DB엔 나중에"라는 말은, 캐시가 죽는 순간 아직 안 옮긴 쓰기가 사라진다는 + 뜻입니다. 조회수 집계처럼 몇 건 잃어도 되는 데이터라면 좋은 도구지만, 주문·잔고처럼 + 한 건도 잃으면 안 되는 데이터에 썼다간 사고가 됩니다. 패턴 이름보다{' '} + "유실을 감당할 수 있는가"를 먼저 물으세요. +
+
+ +
+

+ 캐시는 평소엔 방패지만, 무너지는 순간 뒤에 숨어 있던 DB가 그대로 노출 + 됩니다. 캐시 덕에 DB가 전체 트래픽의 5%만 받고 있었다면, 캐시가 비는 순간 갑자기 + 20배의 부하가 밀려드는 거죠. 무너지는 모양엔 이름이 붙어 있어요. +

+

+ · 스탬피드(stampede, 동시 미스 폭주) — 인기 키 하나가 + 만료되는 순간, 그 키를 기다리던 요청 수백 개가 동시에 미스를 맞고 전부 DB로 + 돌진합니다. 답은 하나면 충분한데 같은 쿼리가 수백 번 실행되는 낭비이자 위험이죠. +

+

+ · 관통(penetration)존재하지 않는 키를 계속 + 조회당하는 경우예요. 없는 데이터는 캐시에 안 담기니 매번 DB까지 뚫고 내려갑니다. + 악의적인 요청이 무작위 ID를 쏘면 캐시가 무력화되죠. 방어는 "없음"이라는 사실 + 자체를 짧은 TTL로 캐시하는 것(negative caching)입니다. +

+

+ · 눈사태(avalanche) — 같은 시각에 적재된 키들이 같은 TTL + 로 동시에 만료되거나, 캐시 서버 자체가 죽어서 미스가 한꺼번에 + 쏟아지는 상황. 배포 직후 캐시를 일괄 예열했다면, 정확히 TTL 뒤에 일괄 만료가 + 돌아옵니다. 방어는 TTL에 무작위 지터(jitter)를 더해 만료 시점을 흩는 것. +

+ {CODE_STAMPEDE} +
+ 공통 원리 세 방어(락, 확률적 조기 만료, TTL 지터)의 본질은 하나예요 —{' '} + "동시에"를 없애는 것. 미스가 나더라도 한 명만 DB에 가게 하고(락), + 만료가 오더라도 다 같이 맞지 않게 흩는다(지터·조기 갱신). 캐시 장애 대비란 결국 + 동시성을 시간 축으로 펼쳐 놓는 설계입니다. +
+
+ +
+

+ 여기서 불편한 진실 하나를 정면으로 봅시다. 캐시와 DB는 어차피 완벽히 같을 + 수 없습니다. 원본을 고치고 캐시를 지우는 그 짧은 틈에도 낡은 값은 읽혀요. + 그래서 고급 설계의 질문은 "불일치를 어떻게 없애나"가 아니라, "몇 초짜리 + 불일치까지 이 데이터가 감당하는가"입니다. 시세 5초, 공지 30초, 잔고 0초 — + 이 숫자를 데이터마다 정해 TTL과 무효화 전략으로 옮기는 게 캐싱 설계의 실체예요. + 0초짜리 데이터의 답은 간단합니다. 캐시하지 않는 것. +

+

+ 이 렌즈로 우리 플랫폼의 설계 하나를 읽어 봅시다. 우리 인증 서버는{' '} + 리프레시 토큰을 Valkey에 저장합니다. "캐시에 토큰을?" 싶지만, + 이건 캐시가 아니라 Valkey를 TTL이 내장된 원본 저장소로 쓰는 + 경우예요. 토큰은 만료 시각이 곧 삭제 시각이니 TTL이 삶의 주기와 정확히 일치하고, + 로그아웃 시 DEL 한 번이면 즉시 무효화되고, 검증 + 요청마다 밀리초로 응답해야 하죠. 같은 도구라도 복사본(캐시)으로 쓰는가, + 원본(저장소)으로 쓰는가에 따라 설계 규칙이 완전히 달라집니다 — 원본으로 + 쓴다면 유실 대비(영속화·백업)가 필요해지니까요. +

+

+ 마지막으로 보안. 캐시는 데이터가 사는 곳이 하나 늘어난다는 뜻이고, + 지켜야 할 곳도 하나 늡니다. +

+

+ · 민감 데이터는 캐시 대상에서 먼저 의심하라 — 개인정보·인증 정보를 + 캐시에 넣는 순간, DB 접근 통제를 우회해 읽을 수 있는 경로가 생깁니다. 넣어야만 + 한다면 TTL을 최소로, 접근 통제는 DB와 동급으로. +

+

+ · 키 설계에 권한을 담아라user:42:portfolio + 처럼 소유자가 키에 박혀 있어야, "남의 캐시를 읽는" 사고를 구조적으로 막기 쉽습니다. + 공유 키에 사용자별 데이터를 섞는 실수가 캐시 보안 사고의 단골이에요. +

+

+ · 캐시 서버 자체를 잠가라 — Redis/Valkey는 기본 설정이 느슨한 + 편입니다. 비밀번호 없이 외부에 노출된 Redis는 공격자에게 데이터 열람은 물론 서버 + 장악 발판까지 내주는 고전적인 사고 경로예요. 내부망 한정 + 인증은 기본값이어야 + 합니다. +

+
+ 설계 감각 "이 데이터가 캐시에서 유출되면 DB에서 유출된 것과 피해가 같은가?" + — 같다면 캐시에도 DB와 같은 수준의 보호를 하거나, 아예 캐시하지 않아야 합니다. + 성능을 위해 복사한 데이터라고 해서 보안 등급까지 복사본 취급하면 안 돼요. +
+
+ +
+

+ 캐시는 붙이는 순간이 아니라 운영되는 내내 검증돼야 합니다. 트래픽 + 패턴이 바뀌면 어제 95%던 히트율이 오늘 60%가 될 수 있고, 데이터가 늘면 메모리가 + 차서 멀쩡한 키가 쫓겨나기 시작하거든요. 지켜볼 숫자는 세 가지입니다 —{' '} + 히트율, 메모리 사용량, 축출(eviction) 수. +

+ {CODE_METRICS} +

+ 축출 정책도 선택입니다. 메모리가 가득 찼을 때 무엇부터 버릴지 —{' '} + allkeys-lru(전체 키 중 가장 오래 안 읽힌 것부터)가 + 캐시 용도의 무난한 기본값이에요. LRU(Least Recently Used)는 "최근에 읽힌 건 또 + 읽힐 것"이라는 가정인데, 대부분의 조회 패턴과 맞습니다. 반대로 섹션 6의 리프레시 + 토큰처럼 원본으로 쓰는 데이터가 같은 인스턴스에 있다면 함부로 + 축출되면 안 되니, 용도가 섞일수록 인스턴스 분리를 고민해야 하죠. +

+

+ 그리고 이 코스의 마무리, 실습입니다. 핵심은 구현이 아니라 측정이에요 — + 캐시 도입 전후의 응답시간과 히트율을 숫자로 남겨, "이 캐시는 밥값을 하는가"를 + 스스로 판정해 보세요. +

+ {CODE_LAB} +
+ 측정이 곧 설득 "캐시 붙였더니 빨라진 것 같아요"는 감상이고, "평균 38ms → + 2ms, 히트율 94%, DB 조회 수 16분의 1"은 설계 근거입니다. 반대로 측정했더니 히트율이 + 40%라면? 캐시를 떼자고 말할 수 있는 것도 숫자를 가진 사람뿐이에요. 고급 과정의 + 모든 결정은 이렇게 숫자로 시작하고 숫자로 끝납니다. +
+
+ +
+
+

⚡ 여기까지 왔다면

+

+ 이제 캐시가 '빨라지는 마법'이 아니라 무효화와 일관성의 값을 치르고 사는 + 속도라는 걸 알게 됐어요. 쓰기 전 세 가지 질문, 로컬과 Redis의 자리, + 삭제가 기본인 무효화, Cache-Aside라는 기본형, 스탬피드를 흩는 법, 그리고 "감당 + 가능한 불일치"라는 설계 언어까지 — 우리 플랫폼의 시세 캐시와 토큰 저장소가 왜 + 그렇게 생겼는지 읽어 낼 수 있게 됐습니다. 그런데 섹션 7에서 계속 강조한 그{' '} + 숫자, 응답시간과 병목은 어떻게 정확히 재는 걸까요? 평균의 함정, + p99라는 언어, 프로파일러로 병목을 짚는 법 — 다음{' '} + 성능 측정과 프로파일링{' '} + 코스에서, 캐시를 붙일지 말지를 판정하던 그 '측정'을 본격적인 기술로 벼려 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv3CodeReviewRefactoringPage.jsx b/frontend/src/pages/levels/Lv3CodeReviewRefactoringPage.jsx new file mode 100644 index 0000000..747cbb7 --- /dev/null +++ b/frontend/src/pages/levels/Lv3CodeReviewRefactoringPage.jsx @@ -0,0 +1,460 @@ +// 이 파일이 하는 일: "코드 리뷰와 리팩터링 전략" 코스 — 고급 과정의 종착점. +// 동작을 바꾸지 않고 구조를 개선하는 규율(리팩터링)에서 출발해, 코드 냄새 목록, +// 작은 단계 기법, 우선순위 정하기, 리뷰의 진짜 목적, 좋은 리뷰 실천을 거쳐, +// 앞 11강에서 배운 설계·성능·보안·운영 관점을 하나의 리뷰 체크리스트로 통합한다. +// 프레이밍은 "혼자 잘 짜는 사람"에서 "팀이 잘 만들게 하는 사람"으로의 전환. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── +// 학습 포인트: 리뷰·리팩터링은 언어보다 '판단'이 핵심이라, 예제도 표·디프·체크리스트 +// 같은 텍스트 형태를 주로 쓴다. 코드가 필요한 곳은 파이썬풍 의사코드로 적었다. + +const CODE_TWO_HATS = `기능 개발 모자 🎩 리팩터링 모자 🧢 +───────────────────────────────────────────────────── +새 동작을 추가한다 동작은 그대로, 구조만 바꾼다 +테스트를 '새로' 쓴다 기존 테스트가 '계속' 통과해야 한다 +결과: 사용자가 차이를 느낀다 결과: 사용자는 차이를 못 느낀다 + +규율: 한 커밋 안에서 두 모자를 동시에 쓰지 않는다. +"기능도 넣고 김에 정리도 했어요" 커밋은 — +버그가 나면 어느 쪽 때문인지 아무도 모르게 됩니다.`; + +const CODE_SAFETY_NET = `리팩터링의 순서 — 안전망이 먼저다 + +① 바꾸려는 코드에 테스트가 있는가? + 없다 → 먼저 '현재 동작'을 고정하는 테스트를 쓴다 + 있다 → ②로 +② 테스트를 돌려 전부 초록불인지 확인한다 +③ 구조를 '조금' 바꾼다 (한 번에 한 기법) +④ 테스트를 다시 돌린다 — 초록불이면 커밋, 빨간불이면 즉시 되돌린다 +⑤ ③~④를 반복한다 + +포인트: 테스트 없는 리팩터링은 리팩터링이 아니라 '수정 도박'입니다. +초록불 → 초록불 사이를 짧은 보폭으로 건너는 것이 전부예요.`; + +const CODE_SMELL_MAP = `코드 냄새 → 리팩터링 기법 매핑 (자주 만나는 것부터) + +냄새 증상 처방 +────────────────────────────────────────────────────────────── +긴 함수 스크롤해야 끝이 보인다 함수 추출(Extract Function) +큰 클래스 이름에 Manager·Util·Service만 클래스 분리, 책임별로 쪼개기 +중복 코드 복붙 흔적, 세 번째 복사 중 공통 함수로 추출 후 호출 +기능 편애 남의 데이터만 주무르는 함수 그 데이터가 사는 곳으로 이동 +매직 넘버 코드에 3, 86400, 0.1 같은 숫자 이름 있는 상수로 추출 +긴 매개변수 목록 인자가 5개 이상 객체 하나로 묶기 +주석으로 때우기 "여기서 X를 함" 주석 필수 이름을 바꿔 주석을 지운다 + +앞 강의와의 연결: +· N+1 쿼리(성능 강의) → '반복문 속 쿼리'라는 냄새의 DB 버전 +· God Service(설계 강의) → '큰 클래스' 냄새가 서비스 계층에서 자란 것`; + +const CODE_EXTRACT = `# 함수 추출 — 가장 자주 쓰는 기본기 (의사코드) + +# before: 주문 처리 함수 하나가 검증·계산·알림을 다 한다 (긴 함수 냄새) +def 주문_처리(주문): + if 주문.수량 <= 0: raise 검증오류("수량은 1 이상") + if not 주문.회원: raise 검증오류("로그인이 필요합니다") + 금액 = 주문.단가 * 주문.수량 + if 주문.회원.등급 == "VIP": 금액 = 금액 * 0.9 + 알림서비스.보내기(주문.회원, "주문 완료: " + str(금액)) + +# after: 세 덩어리에 '이름'을 붙여 꺼냈다 — 동작은 완전히 동일 +def 주문_처리(주문): + 주문_검증(주문) + 금액 = 결제금액_계산(주문) + 주문완료_알림(주문.회원, 금액) + +# 무엇이 좋아졌나: 함수 본문이 곧 목차가 됐다. +# 리뷰어는 세 줄만 읽고 흐름을 파악하고, 의심 가는 덩어리만 파고든다.`; + +const CODE_SMALL_COMMITS = `커밋을 작게 쪼개기 — 되돌리기 쉬운 역사 만들기 + +나쁜 예 (커밋 1개): + fix: 정산 버그 수정 및 코드 정리 ← 300줄 변경, 뭐가 뭔지 모름 + +좋은 예 (커밋 4개, 순서대로): + test: 정산 금액 계산 현재 동작 고정 ← ① 안전망 먼저 + refactor: 정산 계산 함수 추출 ← ② 구조만 변경 (동작 동일) + refactor: 매직 넘버 0.033을 수수료율 상수로 ← ③ 역시 구조만 + fix: VIP 등급 수수료 누락 버그 수정 ← ④ 이제야 '동작' 변경 1줄 + +효과: ④에서 문제가 생기면 ④만 되돌리면 된다(revert). +IDE 자동 리팩터링(이름 변경·함수 추출)은 참조를 기계가 전부 따라가므로 +손으로 찾아 바꾸기(Ctrl+H)보다 훨씬 안전하다 — 단, 커밋은 사람이 쪼갠다.`; + +const CODE_PRIORITY = `어디부터 리팩터링할까 — 변경 빈도 × 코드 상태 + + │ 코드가 깨끗함 코드가 지저분함 +─────────────────────┼────────────────────────────────────── +자주 바뀌는 파일 │ 그대로 두기 ★ 최우선 정리 대상 +거의 안 바뀌는 파일 │ 그대로 두기 급하지 않음(놔둬도 됨) + +이유: 지저분해도 아무도 안 여는 코드는 이자를 물리지 않는다. +빚(기술 부채)의 이자는 '그 코드를 만질 때마다' 나가기 때문에, +자주 만지는 곳의 빚부터 갚는 게 이득이 가장 크다. + +변경 빈도는 감이 아니라 기록으로 확인한다: + git log --since="3 months ago" --name-only 로 파일별 등장 횟수 세기 + → 상위 파일 = 우리 팀의 '뜨거운 길목'`; + +const CODE_PR_CONVENTION = `우리 팀 커밋·PR 매너 (한국어 컨벤션) + +커밋 메시지 — 접두어 + 한국어 요약: + feat: 관심종목 알림 조건 추가 + fix: 정산 반올림 오차 수정 + refactor: 주문 검증 로직 함수 추출 + docs: 배포 절차에 롤백 단계 보강 + +PR 설명에 꼭 들어갈 3가지: + · 무엇을: 한 줄 요약 (제목만 읽어도 알게) + · 왜: 배경 — 이슈 번호, 장애 링크, 요구 근거 + · 어떻게 확인: 리뷰어가 따라 할 수 있는 재현/검증 절차 + +리뷰 코멘트 — 사람이 아니라 코드를 향해 쓴다: + ✗ "이렇게 짜시면 안 되죠" (사람을 평가) + ○ "이 함수가 검증과 저장을 같이 해서, (코드를 서술) + 검증만 실패하는 케이스 테스트가 어려워 보여요. + 검증부를 추출하면 어떨까요?" (근거 + 제안)`; + +const CODE_REVIEW_CHECKLIST = `리뷰어 체크리스트 — 관점별로 한 바퀴 (블로킹/제안 표시) + +[설계] + □ 이 변경이 한 가지 일만 하는가? (PR이 크면 분리 요청 — 블로킹) + □ 새 의존성 방향이 기존 계층 규칙을 거스르지 않는가? — 블로킹 + □ 이름만 읽고 역할을 알 수 있는가? — 제안 + +[성능] + □ 반복문 안에 쿼리·외부 호출이 없는가? (N+1) — 블로킹 + □ 큰 목록에 페이징·상한이 있는가? — 블로킹 + □ 캐시를 얹을 만한 반복 조회인가? — 제안 + +[보안] + □ 입력 검증이 서버 쪽에 있는가? (프론트 검증만으론 부족) — 블로킹 + □ 시크릿·토큰이 코드/로그에 남지 않는가? — 블로킹 + □ 권한 확인이 '누락된 경로'는 없는가? — 블로킹 + +[운영] + □ 실패 시 로그로 원인 추적이 되는가? — 제안 + □ 롤백이 가능한 변경인가? (스키마 변경 주의) — 블로킹 + +블로킹 = 머지 전에 반드시 해결. 제안 = 다음에 개선해도 됨(작성자 재량). +이 구분을 코멘트에 명시하면 "다 고쳐야 하나요?" 소모전이 사라진다.`; + +const CODE_FINAL_LADDER = `12강의 사다리 — 전부 리뷰 한 판에서 만난다 + + 설계 강의 → "이 책임, 이 계층에 있는 게 맞나?" + 성능 강의 → "이 쿼리, 데이터가 10배 늘면 버티나?" + 보안 강의 → "이 입력, 악의적인 사용자가 보내면?" + 운영 강의 → "이거 장애 나면 새벽에 로그만 보고 찾을 수 있나?" + 테스트 강의 → "이 변경, 안전망이 같이 왔나?" + 이번 강의 → 위 질문들을 '체크리스트'로 만들어 매 PR마다 돌린다 + +좋은 팀의 비밀은 천재가 아니라 '반복되는 좋은 질문'입니다. +체크리스트는 그 질문을 개인의 컨디션에서 떼어내 팀의 습관으로 만들어요.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '리팩터링이라는 규율' }, + { n: 2, label: '코드 냄새 목록' }, + { n: 3, label: '작은 단계 리팩터링' }, + { n: 4, label: '우선순위' }, + { n: 5, label: '리뷰의 목적' }, + { n: 6, label: '좋은 리뷰 실천' }, + { n: 7, label: '종합: 잘 만드는 팀' }, +]; + +export default function Lv3CodeReviewRefactoringPage() { + return ( +
+ {/* 히어로: 이 코스가 고급 과정 전체의 종착점임을 선언 */} +
+
Course · 고급 과정
+

코드 리뷰와
리팩터링 전략

+

+ 여기까지 오면서 설계·성능·보안·운영을 하나씩 배웠어요. 그런데 그 지식이 실제로 + 팀의 코드 품질을 지키는 현장은 어디일까요? 바로 매일 열리는 PR 리뷰와{' '} + 일상의 리팩터링입니다. 이 과정의 종착점 — 앞 11강의 관점을{' '} + 리뷰 한 판에 녹여내는 법을 익혀 봅시다. +

+
+ 예상 소요 75분 + 실습: 우리 팀 코드리뷰 체크리스트 만들기 + 선수 지식: 고급 과정 앞 강의들 +
+
+ + {/* 섹션 바로가기 — 학습 포인트: SECTIONS 배열 하나로 목차를 생성(데이터 → 화면) */} + + +
+

+ 리팩터링(Refactoring)의 정의는 한 줄이에요.{' '} + "겉으로 보이는 동작을 바꾸지 않으면서, 코드의 내부 구조를 개선하는 것."{' '} + 입력이 같으면 출력도 같아야 하고, 사용자는 아무 변화도 느끼지 못해야 합니다. + "정리하는 김에 버그도 고쳤어요"는 리팩터링이 아니에요 — 그 순간 동작이 바뀌었으니까요. +

+

+ 그래서 첫 번째 규율은 모자를 하나만 쓰는 것입니다. 기능을 추가할 땐 + 기능 개발 모자를, 구조를 고칠 땐 리팩터링 모자를 — 한 커밋 안에서 둘을 섞지 + 않아요. 섞는 순간, 나중에 버그가 났을 때 "새 기능 때문인지, 정리하다 깨진 + 건지" 아무도 구분할 수 없게 됩니다. +

+ {CODE_TWO_HATS} +

+ 두 번째 규율은 안전망이 먼저라는 거예요. "동작이 안 바뀌었다"를 + 사람 눈으로 보장할 수는 없습니다. 그걸 보장하는 건 테스트예요. + 바꾸려는 코드에 테스트가 없다면, 리팩터링을 시작하기 전에 현재 동작을 + 고정하는 테스트부터 씁니다. 그다음 초록불 → 작은 변경 → 초록불 확인, 이 + 짧은 보폭을 반복하는 게 리팩터링의 전부입니다. +

+ {CODE_SAFETY_NET} +
+ 비유 리팩터링은 영업 중인 가게의 진열대 정리예요. 손님(사용자)이 + 사는 물건(동작)은 그대로인데, 점원(개발자)이 물건을 찾는 속도가 빨라지죠. 진열대를 + 엎고 가게를 새로 차리는 것(재작성)과는 완전히 다른 행위입니다 — 그 차이는 섹션 4에서 + 다시 봐요. +
+
+ +
+

+ 코드 냄새(Code Smell)는 "당장 버그는 아니지만, 더 깊은 문제가 있을 + 가능성을 풍기는 코드의 특징"이에요. 냄새라는 표현이 절묘한 게, 그 자체로 유죄는 + 아니지만 코를 킁킁거리며 원인을 찾아보라는 신호거든요. 대표 냄새 + 네 가지만 기억해도 리뷰의 눈이 달라집니다. +

+

+ ① 긴 함수 — 한 함수가 검증·계산·저장·알림을 다 해요. 읽는 사람이 + 중간에 길을 잃죠. ② 큰 클래스 — 이름이{' '} + OrderManager, CommonUtil{' '} + 같이 두루뭉술하고, 필드와 메서드가 수십 개예요. ③ 중복 코드 — + 같은 로직이 복붙으로 세 군데 살아요. 한 곳만 고치면 나머지 둘이 조용히 낡습니다.{' '} + ④ 기능 편애(Feature Envy) — 어떤 함수가 자기 클래스보다{' '} + 남의 클래스 데이터를 더 열심히 주물러요. 그 로직은 데이터가 사는 + 곳으로 이사 가야 한다는 신호입니다. +

+ {CODE_SMELL_MAP} +

+ 표의 아래 두 줄을 보세요. 사실 우리는 이미 냄새 훈련을 해 왔어요. 성능 강의에서 잡은{' '} + N+1 쿼리는 "반복문 안의 외부 호출"이라는 냄새의 DB 버전이고, 설계 + 강의의 God Service는 "큰 클래스" 냄새가 서비스 계층에서 자란 + 모습입니다. 냄새 목록은 새 지식이 아니라, 흩어져 있던 감각에 이름을 붙여 + 목록으로 만든 것이에요. 이름이 붙어야 리뷰 코멘트로 쓸 수 있습니다. +

+
+ 냄새 ≠ 즉시 수리 냄새를 발견했다고 그 자리에서 다 고치는 게 아니에요. + 기록해 두고(이슈, TODO 주석보다는 이슈가 좋아요) 섹션 4의 우선순위로 + 갚을 순서를 정합니다. 냄새 감지와 수리 결정은 별개의 판단이에요. +
+
+ +
+

+ 리팩터링 기법은 책 한 권 분량이지만, 실무 체감 8할은 세 가지 기본기예요.{' '} + ① 추출(Extract) — 긴 함수에서 덩어리를 꺼내 이름을 붙입니다.{' '} + ② 이름 변경(Rename)data2,{' '} + temp를 역할이 보이는 이름으로 바꿉니다. 이름이 좋아지면 + 주석이 필요 없어져요. ③ 인라인(Inline) — 반대로, 한 줄짜리 의미 없는 + 함수·변수는 다시 안으로 밀어 넣어 단계를 줄입니다. 추출의 되감기 버튼이죠. +

+ {CODE_EXTRACT} +

+ 이 기본기들은 IDE의 자동 리팩터링으로 하는 게 원칙이에요. IntelliJ나 + VS Code에서 함수 추출·이름 변경을 실행하면, 프로젝트 전체의 참조를 기계가 + 전부 따라가며 바꿔 줍니다. 손으로 찾아 바꾸기(Ctrl+H)는 문자열이 우연히 같은 + 엉뚱한 곳까지 바꿔 버릴 수 있어요. 기계가 잘하는 일은 기계에게 맡기고, 사람은{' '} + 무엇을 추출하고 뭐라고 이름 붙일지라는 판단에 집중합니다. +

+

+ 그리고 판단의 마지막 조각 — 커밋을 작게 쪼개기. 리팩터링 커밋과 동작 + 변경 커밋을 분리하면, 문제가 생겼을 때 해당 커밋 하나만 되돌리면(revert){' '} + 됩니다. 섞인 300줄 커밋은 되돌리는 순간 멀쩡한 변경까지 같이 날아가요. +

+ {CODE_SMALL_COMMITS} +
+ 직접 확인해 보기 에디터에서 아무 함수나 블록을 선택하고 리팩터링 메뉴(IntelliJ는{' '} + Ctrl+Alt+M, VS Code는 전구 아이콘)를 눌러 보세요. + 함수 추출이 참조까지 한 번에 정리되는 걸 눈으로 보면, 다시는 손으로 안 하게 됩니다. +
+
+ +
+

+ 냄새 목록을 만들면 항상 같은 고민이 옵니다. "이걸 언제 다 고치지?" — 답은{' '} + 다 고치지 않는 것이에요. 기술 부채의 이자는{' '} + 그 코드를 만질 때마다 나갑니다. 아무도 안 여는 낡은 코드는 지저분해도 + 이자가 0원이에요. 그러니 자주 바뀌는 곳부터(변경 빈도 × 코드 상태) + 정리하는 게 투자 대비 효과가 가장 큽니다. +

+ {CODE_PRIORITY} +

+ 일상에서는 보이스카웃 규칙이 이 우선순위를 자동으로 실행해 줘요. + "캠핑장을 떠날 때는 왔을 때보다 조금 더 깨끗하게" — 코드에 적용하면,{' '} + 어떤 파일을 고치러 들어갔다면 나올 때 아주 조금만 더 깨끗하게 하고 + 나온다는 규칙입니다. 이름 하나 고치기, 매직 넘버 하나 상수로 빼기 정도요. + 어차피 자주 만지는 파일에 자주 들어가게 되니, 뜨거운 길목일수록 저절로 자주 + 정리됩니다. +

+

+ 마지막으로 전면 재작성의 유혹을 경계해야 해요. 지저분한 코드를 보면 + "이건 갈아엎는 게 빠르겠다"는 생각이 들죠. 하지만 낡은 코드에는{' '} + 수년치 버그 수정과 엣지 케이스 처리가 배어 있고, 재작성은 그걸 전부 + 다시 발견해야 합니다. 재작성하는 몇 달 동안 기능 개발은 멈추고, 새 코드는 새 버그를 + 데려와요. 정말 재작성이 답인 경우도 있지만(플랫폼 수명 종료 등), 대부분은{' '} + 돌아가는 시스템을 작은 단계로 고쳐 가는 쪽이 이깁니다. +

+
+ 비유 재작성은 살면서 집을 허물고 새로 짓기예요. 그동안 어디서 + 살죠? 리팩터링은 살면서 방 하나씩 고치기고요. 불편해도 집은 계속 + 집이어야 합니다 — 서비스는 재작성 중에도 계속 돌아가야 하니까요. +
+
+ +
+

+ 코드 리뷰를 "버그 잡는 관문"으로만 알고 있다면 절반만 아는 거예요. 리뷰의 효용은 + 크게 셋입니다. ① 결함 발견 — 물론 중요하죠. 그런데{' '} + ② 지식 공유가 못지않게 커요. 리뷰를 거친 코드는{' '} + 최소 두 사람이 이해하는 코드가 됩니다. 담당자가 휴가를 가도, 퇴사해도 + 팀이 멈추지 않는 이유죠. ③ 표준 정착 — 네이밍, 계층 규칙, 에러 처리 + 방식 같은 팀의 합의가 리뷰 코멘트를 통해 매일 조금씩 전파되고 단단해집니다. +

+

+ 그리고 리뷰 문화의 제1원칙 — 사람이 아니라 코드를 리뷰한다.{' '} + "당신이 틀렸다"가 아니라 "이 코드가 이런 상황에서 이렇게 동작할 것 같다"로 말해요. + 작성자도 마찬가지로, 코멘트를 내 실력에 대한 판정이 아니라 코드에 대한 + 정보로 받습니다. 이 분리가 무너지면 리뷰는 방어전이 되고, 방어전이 된 팀에선 + 아무도 솔직한 코멘트를 달지 않아요 — 그때부터 리뷰는 요식 행위가 됩니다. +

+ {CODE_PR_CONVENTION} +

+ 우리 팀은 커밋 메시지·PR 설명·리뷰 코멘트를 한국어로 쓰고, 커밋 + 접두어(feat: fix:{' '} + refactor: docs:)로 변경의 + 성격을 먼저 밝힙니다. 접두어 덕에 git log만 훑어도 + "동작이 바뀐 커밋"과 "구조만 바뀐 커밋"이 구분돼요 — 섹션 1의 두 모자 규율이 기록에도 + 남는 거죠. +

+
+ 리뷰는 저자에게도 이득 신기하게도, "누가 읽는다"는 사실만으로 코드가 좋아져요. + PR을 올리기 전에 스스로 한 번 훑으며 설명을 쓰다 보면 본인이 먼저 문제를 발견하는 + 일이 흔합니다. 이걸 셀프 리뷰라고 하고, 리뷰어의 시간을 아껴 주는 + 기본 매너이기도 해요. +
+
+ +
+

+ 좋은 리뷰의 첫째 조건은 리뷰어가 아니라 작성자가 만듭니다 — 바로{' '} + 작은 PR이에요. 변경 1,000줄짜리 PR은 아무리 성실한 리뷰어도 대충 훑고 + "LGTM"을 누르게 됩니다(다 읽을 수 없으니까요). 통계적으로도 리뷰 품질은 변경량이 + 커질수록 급격히 떨어져요. 대략 200~400줄 이내, "한 가지 일"만 하는 + PR이 리뷰가 가장 정확합니다. 커밋을 쪼갰듯 PR도 쪼개는 거예요. +

+

+ 둘째는 재현 가능한 근거입니다. "느려질 것 같아요"보다{' '} + "이 쿼리를 1만 건 데이터로 돌려 보니 3.2초 걸렸어요, 실행계획은 이렇습니다"가 + 강해요. 리뷰 코멘트도, PR 설명도 따라 해 볼 수 있는 절차와 숫자가 + 붙으면 논쟁이 짧아집니다. 취향의 문제는 취향이라고 인정하고, 사실의 문제는 사실로 + 판정하는 거죠. +

+

+ 셋째, 리뷰어는 관점 체크리스트를 돌립니다. 즉흥적으로 눈에 띄는 + 것만 보면 그날 컨디션에 따라 품질이 출렁여요. 설계 → 성능 → 보안 → 운영, 관점별로 + 한 바퀴 도는 습관이 누락을 막습니다. +

+ {CODE_REVIEW_CHECKLIST} +

+ 체크리스트 끝의 구분을 눈여겨보세요. 블로킹(blocking)은 "이건 머지 + 전에 반드시 해결돼야 한다"(보안 구멍, N+1, 롤백 불가), 제안(non-blocking)은 + "나는 이렇게 하겠지만 당신 판단에 맡긴다"(네이밍 취향, 사소한 개선)예요. 모든 코멘트가 + 블로킹처럼 읽히면 작성자는 지치고 PR은 며칠씩 묶입니다. 코멘트마다 어느 쪽인지 + 명시하는 것만으로 리뷰 속도와 분위기가 눈에 띄게 좋아져요. +

+
+ 승인의 의미 "Approve"는 "완벽하다"가 아니라 "내가 이 코드에 같이 책임을 + 진다"는 뜻이에요. 그래서 대충 누르는 LGTM이 위험한 겁니다. 반대로, 제안 코멘트만 + 남았다면 승인과 함께 맡기는 게 좋아요 — 완벽주의로 PR을 붙들면 팀 전체가 느려집니다. +
+
+ +
+

+ 이제 사다리 꼭대기에서 지나온 계단을 내려다볼 차례예요. 설계 강의는 "책임이 제자리에 + 있는가"를, 성능 강의는 "데이터가 늘어도 버티는가"를, 보안 강의는 "악의적인 입력에도 + 안전한가"를, 운영 강의는 "장애가 나면 추적할 수 있는가"를 물었죠. 그 질문들이 각각 + 따로 노는 게 아니라 — 매 PR의 리뷰에서 한꺼번에 던져질 때, 비로소 + 팀의 품질이 됩니다. +

+ {CODE_FINAL_LADDER} +

+ 그래서 이 강의의 실습이 체크리스트 만들기인 거예요. 섹션 6의 예시를 + 출발점 삼아, 앞 강의들에서 여러분이 실제로 데인 것들(우리 코드에서 + 만난 N+1, 우리가 놓쳤던 검증 누락)을 항목으로 옮기세요. 남의 체크리스트를 복사하면 + 장식이 되고, 우리 팀의 사고가 담기면 도구가 됩니다. 그리고 실제 PR + 하나에 적용해 보세요 — 항목이 너무 많으면 줄이고, 매번 걸리는 게 있으면 자동화(린터· + CI)로 내리는 겁니다. +

+

+ 마지막으로, 체크리스트는 완성품이 아니라 살아 있는 문서예요. 장애를 + 겪으면 항목이 하나 늘고, 린터가 대신 잡게 되면 항목이 하나 줄어요. 이렇게{' '} + 회고 → 반영 → 자동화의 고리를 도는 것이 지속적 개선 문화고, "잘 만드는 + 팀"의 실체입니다. 천재 한 명이 아니라, 반복되는 좋은 질문이 품질을 + 만들어요. +

+
+ 실습 과제 ① 섹션 6 체크리스트를 바탕으로 우리 팀용 10~15개 항목을 + 추려 문서로 만드세요(블로킹/제안 표시 포함). ② 최근 머지된 실제 PR 하나를 골라 그 + 체크리스트로 다시 리뷰해 보세요. ③ 원 리뷰가 놓친 항목이 있었다면, + 왜 놓쳤는지(항목이 없어서? 급해서?)를 한 줄로 기록하세요 — 그 한 줄이 체크리스트 + 버전 2의 재료입니다. +
+
+ + {/* 마무리 카드: 고급 과정 전체의 마무리 — 학습 센터로 안내 */} +
+
+

🧭 여기까지 왔다면

+

+ 고급 과정의 사다리를 전부 올랐습니다. 서비스를 만드는 사람에서, + 설계·성능·보안·운영을 저울에 올려 잘 만드는 사람으로 — 그리고 그 + 감각을 리뷰와 체크리스트로 팀에 전파하는 사람으로 한 계단씩 + 올라왔어요. 이제 남은 건 반복입니다. 오늘 만든 체크리스트로 내일의 PR을 리뷰하고, + 데일 때마다 항목을 고치세요. 다른 과정의 지도가 궁금하다면{' '} + 학습 센터로 돌아가 다음 여정을 골라 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv3DbIndexingPage.jsx b/frontend/src/pages/levels/Lv3DbIndexingPage.jsx new file mode 100644 index 0000000..040e9e9 --- /dev/null +++ b/frontend/src/pages/levels/Lv3DbIndexingPage.jsx @@ -0,0 +1,532 @@ +// 이 파일이 하는 일: "DB 인덱스와 실행계획 읽기" 코스 — 쿼리가 왜 느린지 감으로 찍지 말고 +// EXPLAIN으로 증명하는 법을 7개 섹션으로 안내하는 정적 학습 페이지. 프레이밍은 "고급 과정 — +// 잘 만드는 법": 서비스를 이미 만들어 본 사람이 설계·성능·보안을 고민하는 단계에서, +// B-Tree의 원리 → 실행계획 해석 → 인덱스 설계 → 함정 → 우리 스키마 실습 → 운영 → 측정 루프로 이어진다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_BOOK = `풀 스캔(Seq Scan) 인덱스 스캔(Index Scan) +────────────────── ───────────────────── +책을 1쪽부터 끝까지 넘기며 책 뒤 '색인'에서 단어를 찾고 +원하는 단어를 찾는다 적힌 쪽 번호로 바로 펼친다 + +100만 행 → 100만 행 다 읽음 100만 행 → 트리 몇 단계 + 해당 행만 +데이터가 늘수록 비례해서 느려짐 데이터가 10배 늘어도 한두 단계만 깊어짐 + +핵심: 인덱스는 "정렬된 별도의 찾아보기 표"다. +테이블 본문은 그대로 두고, (컬럼값 → 행 위치) 쌍을 +B-Tree라는 정렬 트리에 따로 보관해 두는 것.`; + +const CODE_BTREE = `-- B-Tree가 '범위 검색'에 강한 이유: 잎(leaf) 노드가 정렬된 채 이어져 있다 +-- +-- [ 500 ] ← 루트: 몇 단계만 내려가면 +-- / \\ +-- [200 350] [700 900] ← 중간 노드 +-- / | \\ / | \\ +-- [..] [..] [..] [..] [..] [..] ← 잎 노드: 정렬 + 옆으로 연결 +-- +-- WHERE created_at >= '2026-07-01' 같은 범위 조건이면 +-- ① 트리를 타고 시작점(7월 1일)까지 내려간 뒤 +-- ② 잎 노드를 옆으로 쭉 훑기만 하면 된다. 전체를 뒤질 필요가 없다. + +-- 단, 인덱스는 공짜가 아니다: +-- · 쓰기 비용 — INSERT/UPDATE/DELETE 때마다 트리도 함께 고쳐야 한다 +-- · 용량 비용 — 인덱스도 디스크를 먹는다 (큰 테이블에선 본문만큼 클 수도) +-- 그래서 "일단 다 걸자"는 설계가 아니라, 읽기 패턴을 보고 고르는 것.`; + +const CODE_EXPLAIN_BEFORE = `-- EXPLAIN ANALYZE: 계획(추정)만 보는 EXPLAIN과 달리, 실제로 실행해서 실측치까지 보여 준다 +EXPLAIN ANALYZE +SELECT * FROM svc_log.audit_events + WHERE actor = 'user-1042' + ORDER BY created_at DESC + LIMIT 20; + +-- ↓ 인덱스가 없을 때의 전형적인 모습 +Limit (cost=25439.02..25439.07 rows=20 width=182) + (actual time=312.441..312.448 rows=20 loops=1) + -> Sort (cost=25439.02..25451.55 rows=5012 width=182) + (actual time=312.439..312.442 rows=20 loops=1) + Sort Key: created_at DESC + -> Seq Scan on audit_events ← 테이블 전체를 읽었다! + (cost=0.00..25105.00 rows=5012 width=182) + (actual time=0.031..305.877 rows=4890 loops=1) + Filter: (actor = 'user-1042'::text) + Rows Removed by Filter: 995110 ← 99만 행을 읽고 버렸다 +Planning Time: 0.184 ms +Execution Time: 312.501 ms`; + +const CODE_EXPLAIN_READ = `실행계획 숫자 읽는 법 — 괄호 두 쌍을 나란히 비교한다 + +(cost=시작비용..총비용 rows=추정행수 width=행평균바이트) ← 플래너의 '추정' +(actual time=시작ms..총ms rows=실제행수 loops=반복횟수) ← 실행한 '실측' + +· cost 단위 없는 상대값. 플래너가 계획을 고를 때 쓰는 점수표 +· rows 추정 rows와 actual rows가 크게 다르면 → 통계가 낡았다는 신호 +· actual time loops만큼 반복됐다면 (시간 × loops)가 진짜 소요 시간 +· Rows Removed by Filter 읽고 나서 버린 행 수 — 클수록 헛수고가 많았다는 뜻 + +스캔 종류 세 가지: +· Seq Scan 테이블 처음부터 끝까지 (작은 테이블·대부분 행이 필요할 땐 오히려 정답) +· Index Scan 인덱스로 위치를 찾아 행을 하나씩 짚어 감 (소수 행 조회에 최적) +· Bitmap Scan 인덱스로 '해당 페이지 지도'를 먼저 그린 뒤 묶어서 읽음 (중간 규모에 유리)`; + +const CODE_EXPLAIN_AFTER = `-- 조회 패턴에 맞는 복합 인덱스를 걸어 보자 +CREATE INDEX idx_audit_events_actor_created + ON svc_log.audit_events (actor, created_at DESC); + +EXPLAIN ANALYZE +SELECT * FROM svc_log.audit_events + WHERE actor = 'user-1042' + ORDER BY created_at DESC + LIMIT 20; + +Limit (cost=0.43..24.85 rows=20 width=182) + (actual time=0.049..0.101 rows=20 loops=1) + -> Index Scan using idx_audit_events_actor_created on audit_events + (cost=0.43..6120.32 rows=5012 width=182) + (actual time=0.047..0.096 rows=20 loops=1) + Index Cond: (actor = 'user-1042'::text) +Planning Time: 0.201 ms +Execution Time: 0.128 ms ← 312.5ms → 0.13ms + +-- 눈여겨볼 것: Sort 노드가 통째로 사라졌다. +-- 인덱스가 이미 (actor, created_at DESC) 순으로 정렬돼 있어서 +-- ORDER BY를 위해 따로 정렬할 필요가 없어진 것.`; + +const CODE_COMPOSITE = `-- 복합 인덱스의 컬럼 순서: '등호(=) 조건 먼저, 범위·정렬은 뒤에' +-- 전화번호부가 (성, 이름) 순인 것과 같다. 성을 모르면 이름만으론 못 찾는다. + +CREATE INDEX idx_a ON orders (user_id, created_at); + +WHERE user_id = 7 AND created_at > '2026-07-01' -- 인덱스 풀가동 O +WHERE user_id = 7 -- 앞 컬럼만 → O +WHERE created_at > '2026-07-01' -- 뒤 컬럼만 → 이 인덱스 못 씀 + +-- 선택도(selectivity): 조건이 행을 얼마나 좁히는가 +-- user_id = 7 → 100만 중 50행 (잘 좁힘 = 선택도 높음) +-- status = 'DONE' → 100만 중 90만 행 (거의 못 좁힘 = 선택도 낮음) +-- 잘 좁히는 컬럼을 앞에 둘수록 트리에서 일찍 후보가 줄어든다.`; + +const CODE_COVERING = `-- 커버링 인덱스: 필요한 컬럼이 전부 인덱스 안에 있으면 테이블에 갈 필요가 없다 +CREATE INDEX idx_noti_user_read + ON svc_auth.notifications (user_id, is_read) + INCLUDE (title, created_at); -- 검색 키는 아니지만 '동봉'해 둔다 + +SELECT title, created_at + FROM svc_auth.notifications + WHERE user_id = 7 AND is_read = false; + +-- 실행계획에 이렇게 뜨면 성공: +Index Only Scan using idx_noti_user_read on notifications + Heap Fetches: 0 ← 테이블 본문(heap)을 한 번도 안 건드렸다 + +-- 반대로 카디널리티가 낮은 컬럼 단독 인덱스는 헛돈다: +CREATE INDEX idx_bad ON users (is_active); -- true/false 두 값뿐 +-- 절반이 true라면 인덱스로 50만 행을 하나하나 짚는 것보다 +-- 그냥 순서대로 다 읽는(Seq Scan) 게 빠르다. 플래너도 그렇게 판단해서 +-- 이 인덱스는 만들어 두고도 영영 안 쓰일 가능성이 크다.`; + +const CODE_BROKEN = `-- 인덱스가 '있는데도' 안 먹는 대표 패턴들 + +-- ① 컬럼을 함수로 감싸면 → 인덱스는 원본값으로 정렬돼 있어서 못 쓴다 +WHERE lower(email) = 'kim@awesomedev.dev' -- X (email 인덱스 무용지물) +WHERE email = 'kim@awesomedev.dev' -- O +-- 꼭 필요하면 함수 기반 인덱스를 따로 만든다: +CREATE INDEX idx_users_email_lower ON users (lower(email)); + +-- ② 암묵적 형변환 — 타입이 다르면 DB가 컬럼 쪽을 변환해 버린다 +WHERE phone = 01012345678 -- phone이 varchar면 X (숫자와 비교하려고 캐스팅) +WHERE phone = '01012345678' -- O + +-- ③ 앞이 열린 LIKE — 시작 글자를 모르면 정렬 트리를 탈 수 없다 +WHERE title LIKE '%보안%' -- X (앞이 %면 못 탐) +WHERE title LIKE '보안%' -- O (전방 일치는 범위 검색으로 변환됨) + +-- ④ OR — 양쪽 다 인덱스가 있어야 Bitmap OR로 합쳐 쓸 수 있다 +WHERE actor = 'a' OR ip = '1.2.3.4' -- 한쪽만 인덱스면 결국 Seq Scan + +-- ⑤ NULL — 'col = NULL'은 항상 거짓. IS NULL을 써야 하고, +-- Postgres B-Tree는 NULL도 인덱싱하지만 통계상 분포가 쏠리면 플랜이 흔들린다.`; + +const CODE_STATS = `-- 플래너는 '통계 정보'로 계획을 고른다. 통계가 낡으면 오판한다. +-- 증상: EXPLAIN의 추정 rows와 ANALYZE의 actual rows가 수십 배 차이 + +Seq Scan on audit_events (cost=... rows=120 ...) ← 플래너: "120행쯤이겠지" + (actual ... rows=48200 ...) ← 현실: 4만 8천 행 + +-- 대량 INSERT·DELETE 직후에 자주 생긴다. 처방은 통계 갱신: +ANALYZE svc_log.audit_events; + +-- 다시 EXPLAIN 해 보면 추정치가 현실에 가까워지고, +-- 플래너가 Seq Scan ↔ Index Scan 선택을 바로잡는 경우가 많다.`; + +const CODE_OUR_SCHEMA = `-- 우리 운영 DB는 Postgres 17, 서비스마다 스키마를 나눠 쓴다(schema-per-service) +-- 지금 어떤 인덱스가 있는지부터 확인: +SELECT schemaname, tablename, indexname, indexdef + FROM pg_indexes + WHERE schemaname NOT IN ('pg_catalog', 'information_schema') + ORDER BY schemaname, tablename; + +-- PK·UNIQUE 제약에는 인덱스가 자동으로 따라온다. +-- 문제는 '조회 패턴'에 맞춘 보조 인덱스 — 이건 사람이 판단해서 걸어야 한다. + +-- 후보 ①: 감사 로그. 관리자 화면이 "누가, 최근에"로 조회한다 +CREATE INDEX idx_audit_events_actor_created + ON svc_log.audit_events (actor, created_at DESC); + +-- 후보 ②: 알림함. "내 것 중 안 읽은 것"이 압도적 다수 쿼리 +CREATE INDEX idx_notifications_user_unread + ON svc_auth.notifications (user_id, created_at DESC) + WHERE is_read = false; -- 부분(partial) 인덱스: 안 읽은 행만 담아 작고 빠르다`; + +const CODE_SLOWLOG = `-- 최적화 후보는 감이 아니라 '느린 쿼리 로그'로 찾는다 +-- postgresql.conf (또는 ALTER SYSTEM): +ALTER SYSTEM SET log_min_duration_statement = '200ms'; -- 200ms 넘는 쿼리만 기록 +SELECT pg_reload_conf(); + +-- 로그에 이런 줄이 쌓인다: +LOG: duration: 312.501 ms + statement: SELECT * FROM svc_log.audit_events WHERE actor = ... + +-- 더 체계적으로는 pg_stat_statements 확장: +SELECT calls, mean_exec_time, query + FROM pg_stat_statements + ORDER BY mean_exec_time * calls DESC -- '총 소요 시간' 순 = 개선 효과 큰 순 + LIMIT 10;`; + +const CODE_INDEX_OPS = `-- ① 안 쓰는 인덱스 찾기: 스캔 횟수가 0인데 자리만 차지하는 것들 +SELECT schemaname, relname, indexrelname, + idx_scan, -- 이 인덱스가 사용된 횟수 + pg_size_pretty(pg_relation_size(indexrelid)) AS size + FROM pg_stat_user_indexes + WHERE idx_scan = 0 + ORDER BY pg_relation_size(indexrelid) DESC; +-- 단, 통계 리셋 직후이거나 월배치용일 수 있으니 충분히 관찰한 뒤 DROP. + +-- ② 중복 인덱스: (a)와 (a, b)가 둘 다 있으면 (a)는 대개 잉여다 +-- — (a, b) 인덱스가 a 단독 조건도 처리할 수 있기 때문. + +-- ③ VACUUM과 인덱스 팽창(bloat) +-- Postgres는 UPDATE/DELETE된 행을 바로 지우지 않고 '죽은 행'으로 남긴다. +-- VACUUM이 이를 회수하는데, 회수가 밀리면 인덱스가 부풀어 스캔이 느려진다. +VACUUM (VERBOSE) svc_log.audit_events; -- 죽은 공간 회수 +ANALYZE svc_log.audit_events; -- 통계 갱신 (섹션 4의 오판 예방) +-- 심하게 부풀었다면 서비스 중에도 가능한 재구축: +REINDEX INDEX CONCURRENTLY svc_log.idx_audit_events_actor_created;`; + +const CODE_MEASURE = `-- 실습 리포트 양식: '전후 비교표' — 숫자가 없으면 개선이 아니라 주장일 뿐 +-- 같은 쿼리를 인덱스 추가 전/후로 EXPLAIN ANALYZE 3회씩 돌려 중앙값을 적는다. +-- (첫 실행은 캐시가 비어 느릴 수 있으니 1회는 워밍업으로 버린다) + +항목 | 개선 전 | 개선 후 +──────────────────────┼────────────────────┼───────────────────── +스캔 방식 | Seq Scan | Index Scan +Execution Time | 312.501 ms | 0.128 ms +읽고 버린 행 | 995,110 | 0 +Sort 노드 | 있음 | 없음 (인덱스 정렬 활용) +추가된 인덱스 크기 | - | 31 MB +INSERT 영향(참고) | - | 건당 소폭 증가 + +-- 결론 문장 예시: +-- "actor+created_at 복합 인덱스로 감사 로그 조회가 312ms → 0.13ms. +-- 쓰기 부하 증가는 실측상 무시 가능 수준. 인덱스 크기 31MB 감수."`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '인덱스는 왜 빠른가' }, + { n: 2, label: '실행계획 읽기' }, + { n: 3, label: '좋은 인덱스 설계' }, + { n: 4, label: '안 먹는 순간' }, + { n: 5, label: '우리 스키마 실습' }, + { n: 6, label: '인덱스 운영' }, + { n: 7, label: '측정으로 마무리' }, +]; + +export default function Lv3DbIndexingPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 고급 과정
+

🔍 DB 인덱스와
실행계획 읽기

+

+ "이 쿼리 느린 것 같은데요"는 관찰이지 진단이 아니에요. 데이터베이스는{' '} + 자기가 무슨 짓을 했는지 전부 실행계획으로 자백합니다. + 쿼리가 왜 느린지 감으로 찍지 말고 EXPLAIN으로 증명하는 법 — + B-Tree의 원리에서 출발해, 실행계획의 숫자를 읽고, 좋은 인덱스를 설계하고, + 우리 Postgres 스키마에 직접 적용해 전후를 측정하는 데까지 갑니다. +

+
+ 예상 소요 65분 + 실습: EXPLAIN ANALYZE 전후 비교표 + 선수 지식: SQL·서비스 개발 경험 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 500쪽짜리 책에서 "트랜잭션"이라는 단어가 나온 곳을 찾는다고 해 봅시다. 방법은 둘이에요. + 1쪽부터 끝까지 넘기며 눈으로 훑거나(풀 스캔), 책 뒤{' '} + 색인(index)에서 "트랜잭션 — 87, 214쪽"을 찾아 바로 펼치거나. + 데이터베이스 인덱스가 정확히 이 색인이에요. 테이블 본문은 그대로 두고,{' '} + (컬럼값 → 행 위치) 쌍을 정렬해 따로 보관해 둔 찾아보기 표죠. +

+ {CODE_BOOK} +

+ 그 정렬표의 자료구조가 B-Tree입니다. 값들을 정렬된 트리로 쌓아 두면 + 100만 행에서도 몇 단계 만에 목표 지점에 도달하고, 데이터가 10배로 늘어도 트리가 + 한두 단계 깊어질 뿐이에요. 특히 잎 노드들이 정렬된 채 옆으로 이어져{' '} + 있어서, "7월 1일 이후" 같은 범위 검색은 시작점만 찾으면 옆으로 + 훑기만 하면 됩니다. +

+ {CODE_BTREE} + {/* 학습 포인트: 인덱스의 대가(쓰기·용량)를 처음부터 못 박는다 — "일단 다 걸자"를 예방. */} +
+ 인덱스는 공짜가 아니다 색인이 있는 책에 새 페이지를 끼워 넣으면{' '} + 색인도 고쳐야 하죠. 마찬가지로 인덱스가 5개 걸린 테이블에{' '} + INSERT 한 번은 사실상 쓰기 6번입니다 + (본문 1 + 인덱스 5). 디스크도 먹고요. 인덱스 설계란 "읽기를 얼마나 벌고, 쓰기를 + 얼마나 내줄 것인가"의 거래예요. 그래서 이 코스의 결론은 늘 + 같습니다 — 걸기 전에 재고, 건 뒤에 다시 잰다. +
+
+ +
+

+ EXPLAIN은 플래너(planner)가 세운 계획과 추정치만 + 보여 줍니다. 뒤에 ANALYZE를 붙이면{' '} + 실제로 실행해서 실측 시간·실제 행 수까지 같이 보여 줘요. 성능 진단의 + 기본기는 이 추정(cost·rows)과 실측(actual time·rows)을 나란히 읽는 것입니다. + 우리 감사 로그 테이블에서 특정 사용자의 최근 이력을 뽑는 쿼리로 볼게요. +

+ {CODE_EXPLAIN_BEFORE} +

+ 범인이 그대로 드러나죠. Seq Scan이 테이블 전체를 읽었고,{' '} + Rows Removed by Filter: 995110 — 100만 행을 읽어서{' '} + 99만 5천 행을 버렸어요. 필요한 건 20행인데요. 이 "읽고 버린 행"이 + 클수록 인덱스가 필요하다는 신호입니다. +

+ {CODE_EXPLAIN_READ} + {/* 학습 포인트: Seq Scan = 항상 악당이 아니다 — 플래너의 선택엔 이유가 있다는 감각. */} +
+ Seq Scan이 항상 나쁜 게 아니에요 행이 수백 개뿐인 테이블, 혹은 어차피{' '} + 대부분의 행이 필요한 집계 쿼리라면 순서대로 쭉 읽는 게 제일 빠릅니다. + 플래너가 인덱스를 두고도 Seq Scan을 고르는 건 대개 합리적인 계산의 결과예요. + "Seq Scan이 보인다 → 무조건 인덱스 추가"가 아니라, 읽고 버린 행이 많은가를 + 먼저 보세요. +
+
+ ANALYZE는 진짜 실행한다 EXPLAIN ANALYZE는 쿼리를 + 실제로 돌립니다. SELECT는 안전하지만{' '} + UPDATE·DELETE에 붙이면{' '} + 정말로 데이터가 바뀌어요. 쓰기 쿼리를 진단할 땐{' '} + BEGIN; → EXPLAIN ANALYZE →{' '} + ROLLBACK;으로 감싸는 습관을. +
+
+ +
+

+ 섹션 2의 느린 쿼리를 고쳐 봅시다. 조회 패턴이 "actor로 좁히고,{' '} + created_at 역순으로 최근 20개"이므로, 그 모양 그대로{' '} + 복합 인덱스를 겁니다. +

+ {CODE_EXPLAIN_AFTER} +

+ 312ms가 0.13ms로 — 약 2,400배입니다. 더 눈여겨볼 건{' '} + Sort 노드가 사라진 점이에요. 인덱스가 이미 원하는 순서로 + 정렬돼 있으니 ORDER BY가 공짜가 됐죠. 그럼 복합 인덱스의{' '} + 컬럼 순서는 어떻게 정할까요? 원칙은 전화번호부와 같습니다 —{' '} + (성, 이름) 순으로 정렬된 명부에서 성을 모르면 이름만으론 못 찾아요. +

+ {CODE_COMPOSITE} +

+ 여기서 선택도(selectivity)라는 개념이 나옵니다. 조건이 행을 얼마나 + 좁히느냐 — user_id = 7처럼 잘 좁히는(값 종류가 많은 ={' '} + 카디널리티 높은) 컬럼을 앞에 둘수록 트리에서 일찍 후보가 줄어요. + 한 발 더 나아가, 조회할 컬럼을 전부 인덱스에 담으면 테이블 본문에 갈 필요조차 + 없어집니다 — 이게 커버링 인덱스예요. +

+ {CODE_COVERING} + {/* 학습 포인트: 카디널리티 낮은 컬럼 단독 인덱스 = 대표적인 '만들었는데 안 쓰이는' 인덱스. */} +
+ 정리 — 설계 3원칙 ① 인덱스는 쿼리 모양(WHERE·ORDER BY)을 보고 + 만든다, 테이블만 보고 만들지 않는다. ② 복합 인덱스는 등호 조건·선택도 높은 + 컬럼을 앞에. ③ is_active처럼 값이 두어 종류뿐인 + 컬럼의 단독 인덱스는 플래너가 외면한다 — 정말 필요하면 섹션 5의{' '} + 부분 인덱스처럼 조건을 걸어 좁혀라. +
+
+ +
+

+ "인덱스를 걸었는데도 느려요"의 대부분은 인덱스가 없어서가 아니라,{' '} + 있는 인덱스를 못 쓰게 쿼리를 쓴 경우예요. 핵심 원리는 하나입니다 — + 인덱스는 원본 컬럼값 그대로 정렬돼 있어서, 컬럼을 가공하는 순간 + 그 정렬표는 무용지물이 됩니다. +

+ {CODE_BROKEN} +

+ 다섯 패턴 모두 진단법은 같아요 — EXPLAIN을 떠서 Seq Scan인지 확인. + 특히 ②번 암묵적 형변환은 코드 리뷰에서도 눈에 잘 안 띄는 단골 범인입니다. ORM이 + 파라미터 타입을 잘못 바인딩할 때도 똑같은 일이 벌어져요. +

+

+ 쿼리는 멀쩡한데 플래너가 이상한 계획을 고르는 경우도 있습니다. + 플래너는 테이블의 통계 정보(값 분포 표본)로 행 수를 추정하는데, + 대량 적재·삭제 직후엔 이 통계가 현실과 어긋나요. +

+ {CODE_STATS} + {/* 학습 포인트: '추정 rows vs actual rows 괴리 = 통계 문제'라는 진단 공식 하나를 심는다. */} +
+ 진단 공식 EXPLAIN ANALYZE에서 추정 rows와 actual rows가 수십 배 + 어긋나면, 인덱스 문제가 아니라 통계 문제를 먼저 의심하세요. + 처방은 ANALYZE 테이블명; 한 줄 — 인덱스를 새로 만드는 + 것보다 훨씬 싸고, 의외로 자주 이걸로 끝납니다. +
+
+ +
+

+ 이제 우리 운영 환경으로 옵니다. 우리 플랫폼은 Postgres 17 하나에 + 서비스별 스키마(schema-per-service)를 나눠 쓰죠 — 감사 로그, 알림, 계정이 + 각자의 스키마에 삽니다. 실습 첫걸음은 지금 뭐가 걸려 있는지 확인이에요. +

+ {CODE_OUR_SCHEMA} +

+ 두 후보의 성격이 달라요. 감사 로그는 "누가(actor), 최근에(created_at DESC)"라는 관리자 + 화면의 조회 모양 그대로 복합 인덱스. 알림함은 전체 행 중{' '} + 안 읽은 것만 조회가 몰리니, WHERE is_read = + false 조건을 인덱스 자체에 박은 부분(partial) 인덱스 — 읽은 + 알림이 수백만 건 쌓여도 인덱스는 작고 가볍게 유지됩니다. +

+

+ 그런데 애초에 어느 쿼리부터 손대야 할까요? 감이 아니라 로그로 찾습니다. +

+ {CODE_SLOWLOG} + {/* 학습 포인트: 우선순위 = mean_exec_time이 아니라 (평균 × 호출수). 한 번 느린 배치보다 + 매초 도는 200ms 쿼리가 서비스엔 더 아프다. */} +
+ 우선순위는 총량으로 평균 2초짜리 일일 배치와 평균 200ms인데 초당 50번 도는 + 조회 쿼리 — 서비스를 더 아프게 하는 건 후자예요. 그래서 위 쿼리도{' '} + mean_exec_time × calls, 즉 총 소요 시간{' '} + 순으로 정렬합니다. 개선 효과가 큰 순서와 같거든요. +
+
+ 운영 DB에서 인덱스 만들 땐 CREATE INDEX는 기본적으로 + 그 테이블의 쓰기를 잠가요. 트래픽이 있는 테이블엔{' '} + CREATE INDEX CONCURRENTLY를 쓰세요 — 시간은 더 걸리지만 + 서비스를 멈추지 않습니다. 실습은 로컬 DB에서 마음껏, 운영 반영은 반드시 이 방식으로. +
+
+ +
+

+ 인덱스는 만드는 것보다 치우는 게 어렵습니다. 섹션 1에서 봤듯 인덱스마다 + 쓰기 비용이 붙으니, 안 쓰는 인덱스는 세금만 내는 자산이에요. 다행히 + Postgres는 인덱스별 사용 횟수를 세고 있어서, 통계로 걷어낼 후보를 찾을 수 있습니다. +

+ {CODE_INDEX_OPS} +

+ VACUUM 얘기를 조금 더 하면 — Postgres는 UPDATE· + DELETE된 행을 즉시 지우지 않고 "죽은 행"으로 남겨 둡니다 + (동시성을 위한 MVCC 설계). VACUUM이 이를 회수하는데, 회수가 밀리면 테이블과 인덱스가{' '} + 부풀어(bloat) 같은 스캔에 더 많은 페이지를 읽게 돼요. "인덱스가 있는데 + 점점 느려져요"의 흔한 원인입니다. 평소엔 autovacuum이 알아서 하지만, 대량 + 삭제·갱신 뒤엔 수동 VACUUM + ANALYZE를 + 챙기세요. +

+ {/* 학습 포인트: ddl-auto: update는 컬럼은 더해 줘도 인덱스 운영은 못 한다 — + 인덱스는 '코드 밖 자산'이라는 인식. 마이그레이션 도구 도입의 복선이기도 하다. */} +
+ ddl-auto: update 환경의 함정 우리 백엔드처럼 JPA가{' '} + ddl-auto: update로 스키마를 자동 반영하는 환경에선 착각하기 + 쉬워요 — 이 설정은 인덱스를 관리해 주지 않습니다. 엔티티에{' '} + @Index를 달면 새로 만들어 주긴 하지만, 어노테이션을{' '} + 지워도 DB의 인덱스는 남고, 컬럼 순서 변경 같은 개편도 반영되지 않아요. + 즉 DB에 직접 만든 인덱스는 코드 어디에도 기록이 없는 유령 자산이 되기 + 쉽습니다. 최소한 어떤 인덱스를 왜 만들었는지 문서(또는 마이그레이션 스크립트)로 남기는 + 습관이 필요해요 — 이게 나중에 Flyway 같은 마이그레이션 도구로 넘어가는 이유이기도 합니다. +
+
+ +
+

+ 이제 이 코스의 실습 과제를 완성할 차례예요. 느린 SELECT 하나를 골라 + (섹션 5의 느린 쿼리 로그에서), 인덱스 추가 전후로 EXPLAIN ANALYZE를 각각 + 돌려 아래 양식의 비교표로 정리합니다. 실행 시간은 캐시 상태에 따라 출렁이니{' '} + 3회 돌려 중앙값을 적으세요. +

+ {CODE_MEASURE} +

+ 한 가지 정직하게 짚고 갈 것 — 인덱스 추가가 오히려 느리게 만든 사례도 + 실제로 있습니다. 쓰기가 압도적으로 많은 테이블에 조회용 인덱스를 여럿 얹었다가{' '} + INSERT 처리량이 주저앉는 경우, 카디널리티 낮은 컬럼 + 인덱스를 플래너가 잘못 집어 들어 Bitmap Scan이 Seq Scan보다 느려지는 경우. + 그래서 비교표에 쓰기 영향 줄이 있는 거예요 — 읽기만 재고 끝내면 + 반쪽짜리 측정입니다. +

+ {/* 학습 포인트: 이 코스의 최종 습관 — 가설 → 측정 → 반영 → 재측정. 성능 작업의 + 산출물은 인덱스가 아니라 '전후 숫자가 담긴 근거'다. */} +
+ 몸에 남길 루프가설 — "이 쿼리는 actor 조건에 인덱스가 없어 + 느릴 것이다" ② 측정 — EXPLAIN ANALYZE로 전후 실측 ③{' '} + 반영 — 근거와 함께 적용(운영은 CONCURRENTLY) ④ 재측정 — + 며칠 뒤 pg_stat_user_indexes로 정말 쓰이는지 확인. + "느린 것 같아서 인덱스 걸었어요"와 "312ms가 0.13ms가 됐고 근거는 이 실행계획입니다"의 + 차이 — 그게 이 과정이 목표하는 개발자의 말하기 방식이에요. +
+
+ +
+
+

🔍 여기까지 왔다면

+

+ 이제 느린 쿼리 앞에서 감으로 찍는 대신, 실행계획을 떠서 읽고 → + 쿼리 모양에 맞는 인덱스를 설계하고 → 전후를 숫자로 증명하는 한 바퀴를 돌 수 + 있게 됐어요. 그런데 인덱스로 조회가 빨라지고 트래픽이 몰리기 시작하면, 다음 질문이 + 기다립니다 — 여러 요청이 같은 행을 동시에 읽고 쓸 때 무슨 일이 + 벌어질까요? 잔액 차감이 두 번 실행되면? 읽는 도중에 값이 바뀌면? 다음{' '} + 트랜잭션과 격리 수준{' '} + 코스에서, 동시성이라는 다음 층의 지형을 이어서 읽어 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv3DesignPatternsPage.jsx b/frontend/src/pages/levels/Lv3DesignPatternsPage.jsx new file mode 100644 index 0000000..892251b --- /dev/null +++ b/frontend/src/pages/levels/Lv3DesignPatternsPage.jsx @@ -0,0 +1,571 @@ +// 이 파일이 하는 일: "디자인 패턴 실전" 코스 — GoF 23개를 외우는 대신 '문제→해법'으로 +// 읽는 법에서 출발해, 생성·구조·행동 패턴의 핵심 6개, 스프링이 숨겨 둔 패턴들, SOLID라는 +// 나침반, 그리고 안티패턴을 되돌리는 용기까지 7개 섹션으로 안내하는 정적 학습 페이지. +// 프레이밍은 "우리 코드가 실물 교재" — market 서비스의 시세 프로바이더 분기와 MarketBus를 +// 예제 삼아, 패턴이 교과서가 아니라 지금 내 코드의 문제를 푸는 도구임을 체감시킨다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_PATTERN_MAP = `패턴을 읽는 올바른 순서: 이름 → X, 문제 → 해법 → O + +"자주 만나는 문제" "검증된 해법(패턴)" +────────────────────────────────────────────────────── +생성 로직이 여기저기 흩어져 있다 → 팩토리 (생성을 한곳에 모은다) +생성자 인자가 7개를 넘어간다 → 빌더 (조립 과정을 읽기 좋게) +외부 API 모양이 우리와 안 맞는다 → 어댑터 (모양을 번역하는 겉옷) +분기(if/switch)가 계속 자란다 → 전략 (분기를 다형성으로) +한 사건을 여럿이 알아야 한다 → 옵저버 (구독-발행으로 느슨하게) +흐름은 같은데 일부만 다르다 → 템플릿 메서드 (뼈대 고정, 차이만 위임) + +GoF 책의 23개 중 실무에서 매일 만나는 건 이 6개 안팎이에요. +나머지는 "그런 게 있다"만 알아 두고, 문제를 만났을 때 찾아보면 됩니다.`; + +const CODE_BUILDER = `// 생성자 인자가 늘어나면 호출부가 암호문이 된다 +Article a = new Article("삼성전자 2분기 실적", body, "KR", true, false, null, 3); +// true가 뭐고 false가 뭔지, null은 왜 있는지 — 아무도 모른다 + +// 빌더: '무엇을 넣는지'가 이름으로 드러난다 +Article a = Article.builder() + .title("삼성전자 2분기 실적") + .body(body) + .marketRegion("KR") + .premium(true) // 아, 프리미엄 글이구나 + .pinned(false) // 상단 고정은 아니고 + .priority(3) + .build(); + +// 포인트: 빌더의 가치는 '멋'이 아니라 가독성과 실수 방지. +// 인자 2~3개짜리 객체에 빌더를 씌우는 건 과용입니다.`; + +const CODE_SINGLETON = `// 교과서 싱글턴 — 인스턴스를 딱 하나만 만들고 전역에서 공유 +public class JwtService { + private static final JwtService INSTANCE = new JwtService(); + private JwtService() {} // 밖에서 new 금지 + public static JwtService getInstance() { return INSTANCE; } +} + +// 그런데 스프링에선 이렇게 쓸 일이 거의 없어요. +@Service +public class JwtService { ... } +// @Service·@Component 빈의 기본 스코프가 이미 '싱글턴'이기 때문. +// 컨테이너가 인스턴스 하나를 만들어 필요한 곳에 주입해 줍니다. +// 직접 만든 싱글턴은 테스트에서 갈아 끼우기 어렵지만(정적 결합), +// 스프링 빈은 테스트에서 가짜(Mock)로 교체할 수 있죠. +// 교훈: 프레임워크가 이미 준 패턴을 손으로 다시 만들지 말 것.`; + +const CODE_ADAPTER = `// 외부 시세 API 3곳 — 응답 모양이 제각각이다 +// A사: { last: 71000, chg: -1.2 } +// B사: { price: "71000", changeRate: "-1.2%" } +// 키움 브리지: { cur_prc: "-71000", flu_rt: "-1.20" } (부호가 접두어!) + +// 어댑터: 바깥의 낯선 모양을 '우리 표준 모양'으로 번역하는 겉옷 +public interface QuoteSource { + Quote fetch(String symbol); // 우리 서비스가 아는 단 하나의 모양 +} + +public class KiwoomAdapter implements QuoteSource { + private final KiwoomBridgeClient bridge; // 낯선 외부 세계 + + public Quote fetch(String symbol) { + var raw = bridge.currentPrice(symbol); + // 번역은 전부 여기서: 부호 접두어 제거, 문자열→숫자, 필드명 통일 + long price = Math.abs(Long.parseLong(raw.curPrc())); + return new Quote(symbol, price, parseRate(raw.fluRt())); + } +} + +// 효과: 외부 API가 아무리 괴상해도, 그 괴상함이 어댑터 안에 갇힌다. +// 나머지 코드는 Quote 하나만 알면 된다.`; + +const CODE_STRATEGY_BEFORE = `// [Before] market 서비스의 시세 조회 — 프로바이더 분기가 메서드마다 반복 +public Quote getQuote(String market, String symbol) { + if (market.equals("KR")) { + var raw = kiwoomBridge.currentPrice(symbol); + return toQuote(raw); + } else if (market.equals("US")) { + var raw = usVendorClient.quote(symbol); + return convertUs(raw); + } else if (market.equals("CRYPTO")) { + var raw = cryptoClient.ticker(symbol); + return convertCrypto(raw); + } + throw new IllegalArgumentException("unknown market: " + market); +} +// 같은 if 사다리가 getQuote·getOrderbook·getCandles… 곳곳에 복붙된다. +// 프로바이더 하나 추가 = 흩어진 분기를 전부 찾아 고치기 (누락하면 버그)`; + +const CODE_STRATEGY_AFTER = `// [After] 전략 패턴 — '시장별로 다른 부분'을 각자의 클래스로 분리 +public interface QuoteProvider { + String market(); // 자기가 담당하는 시장 + Quote getQuote(String symbol); + Orderbook getOrderbook(String symbol); +} + +@Component class KiwoomProvider implements QuoteProvider { + public String market() { return "KR"; } + public Quote getQuote(String symbol) { /* 키움 브리지 호출 */ } + ... +} +@Component class UsVendorProvider implements QuoteProvider { /* "US" */ } +@Component class CryptoProvider implements QuoteProvider { /* "CRYPTO" */ } + +// 선택 로직은 단 한 곳 — 스프링이 구현체들을 List로 다 모아 준다 +@Service +public class QuoteService { + private final Map providers; + + QuoteService(List all) { // 구현체 전부 자동 주입! + this.providers = new HashMap<>(); + for (var p : all) providers.put(p.market(), p); + } + + public Quote getQuote(String market, String symbol) { + var p = providers.get(market); + if (p == null) throw new IllegalArgumentException(market); + return p.getQuote(symbol); // if 사다리가 사라졌다 + } +}`; + +const CODE_STRATEGY_COST = `새 프로바이더(예: 일본 주식 "JP") 추가 비용 비교 + +[Before — if 분기] [After — 전략 패턴] +① getQuote에 else-if 추가 ① JpProvider 클래스 하나 신규 작성 +② getOrderbook에 else-if 추가 ② 끝. (@Component만 붙이면 +③ getCandles에 else-if 추가 스프링이 List에 자동 포함) +④ …분기가 있는 곳을 전부 수색 +⑤ 하나라도 빠뜨리면 런타임 버그 기존 파일 수정: 0개 + → 기존 코드를 안 건드리니 +기존 파일 수정: 3~N개 기존 기능이 깨질 수가 없다 +검증 범위: 수정한 파일 전부 검증 범위: 새 클래스 하나`; + +const CODE_OBSERVER = `// 옵저버/Pub-Sub — 우리 MarketBus가 실물 교재 +// "시세 틱 하나가 도착했다"를 알아야 하는 곳: SSE 스트림, 캐시, 알림 판정… +// 발행자가 구독자를 일일이 알면 → 새 구독자가 생길 때마다 발행자를 수정해야 한다. + +// 그래서 market 서비스는 Valkey Pub/Sub 채널(market.ticks)에 '발행'만 하고, +publisher.publish("market.ticks", tickJson); // 누가 듣는지 모른 채 발행 + +// 듣고 싶은 쪽이 각자 '구독'한다. +subscriber.subscribe("market.ticks", (msg) -> { + var tick = parse(msg); + sseEmitters.forEach(e -> e.send(tick)); // SSE로 브라우저에 전달 +}); + +// 발행자와 구독자는 서로의 존재를 모른다 — 채널 이름만 공유한다. +// 옵저버(같은 프로세스 안, 리스트로 직접 호출)와 +// Pub/Sub(중간 브로커를 경유, 프로세스 경계를 넘음)은 형제 관계: +// '느슨한 결합으로 사건을 퍼뜨린다'는 핵심이 같다.`; + +const CODE_TEMPLATE = `// 템플릿 메서드 — 공통 흐름은 부모가 고정, 다른 부분만 자식에게 위임 +public abstract class MarketCollector { + // 뼈대(템플릿): 수집의 순서는 누구도 못 바꾼다 + public final void collect() { + var raw = fetchRaw(); // ← 여기만 시장마다 다름 + var ticks = normalize(raw); // ← 여기도 시장마다 다름 + validate(ticks); // 공통: 이상값 걸러내기 + publish(ticks); // 공통: MarketBus로 발행 + } + protected abstract RawData fetchRaw(); + protected abstract List normalize(RawData raw); + private void validate(List t) { /* 공통 검증 */ } + private void publish(List t) { /* 공통 발행 */ } +} + +public class KrCollector extends MarketCollector { + protected RawData fetchRaw() { /* 키움 브리지에서 */ } + protected List normalize(RawData raw) { /* 부호 접두어 처리 */ } +} +// 전략과의 차이: 전략은 '알고리즘 전체'를 갈아 끼우고(합성), +// 템플릿 메서드는 '흐름 속 빈칸'만 갈아 끼운다(상속). +// 요즘은 상속 대신 람다를 넘기는 '템플릿 콜백' 변형을 더 많이 씁니다(섹션 5).`; + +const CODE_SPRING_DI = `// 스프링을 쓰는 순간, 우리는 이미 패턴 위에 서 있다 + +// ① DI(의존성 주입) = 의존성 역전 원칙(DIP)의 구현 +@Service +public class AuthService { + private final TokenStore tokenStore; // 인터페이스에 의존 + AuthService(TokenStore tokenStore) { // 구현체는 밖에서 주입 + this.tokenStore = tokenStore; // (내가 new 하지 않는다) + } +} +// AuthService는 TokenStore가 Valkey인지 메모리인지 모른다. +// '무엇을 쓸지'의 결정권이 사용처에서 조립자(컨테이너)로 역전(IoC)됐다. + +// ② @Transactional = 프록시 패턴 +// 스프링은 이 클래스를 감싼 '대리인(proxy)' 객체를 만들어 빈으로 등록한다. +// 호출 → [프록시: 트랜잭션 시작] → 진짜 메서드 → [프록시: commit/rollback] +// 우리가 쓴 적 없는 트랜잭션 코드가 실행되는 비밀이 이것. +// 같은 클래스 안에서 this.method()로 부르면 프록시를 안 거쳐 +// @Transactional이 무시되는 함정도, 프록시 구조를 알면 당연해진다. + +// ③ JdbcTemplate·RestClient의 Template = 템플릿 콜백 +// 연결 열기 → [내 코드] → 예외 처리 → 연결 닫기 +// 지루한 앞뒤는 템플릿이, 가운데 빈칸만 내가 람다로 채운다.`; + +const CODE_SOLID = `SOLID — 패턴을 '언제' 쓸지 알려주는 다섯 개의 나침반 + +S 단일 책임 한 클래스가 바뀌는 이유는 하나여야 한다 + → 시세 조회와 프로바이더 선택이 한 메서드에? 분리 신호. +O 개방 폐쇄 확장에는 열리고, 수정에는 닫히게 + → "새 프로바이더 추가에 기존 코드 수정 0개" = 전략 패턴의 효과. +L 리스코프 자식은 부모 자리에 그대로 끼워도 동작해야 한다 + → QuoteProvider 구현체는 어느 것을 꽂아도 계약이 같아야. +I 인터페이스 뚱뚱한 인터페이스 하나보다 날씬한 여러 개 + 분리 → 호가를 지원 안 하는 프로바이더에게 getOrderbook을 강요하지 말 것. +D 의존성 역전 구체가 아니라 추상에 의존하라 + → QuoteService는 KiwoomProvider가 아닌 QuoteProvider를 본다. + +순서를 기억하세요: SOLID가 원칙(왜), 패턴은 그 원칙의 구현 기법(어떻게). +패턴 이름을 하나도 몰라도 SOLID를 지키면 절반은 성공입니다.`; + +const CODE_SMELLS = `전략 패턴 도입을 알리는 코드 냄새 체크리스트 + +[ ] 같은 모양의 if/switch 사다리가 두 곳 이상에 복붙되어 있다 +[ ] 새 케이스를 추가할 때마다 '분기가 있는 곳 수색'부터 해야 한다 +[ ] 분기 하나의 몸통이 화면 반 이상을 차지한다 +[ ] "여기 고치면 다른 시장 것도 깨지지 않을까" 겁이 난다 + +반대로, 도입을 미뤄야 할 신호 + +[ ] 분기가 단 한 곳에만 있고, 각 가지가 서너 줄이다 +[ ] 케이스가 2개뿐이고 늘어날 계획이 없다 +[ ] "언젠가 필요할지도 몰라서" 미리 만들고 있다 ← 가장 위험 + +리팩터링의 철칙: 테스트 먼저. +① 현재 동작을 고정하는 테스트 작성 → ② 초록 확인 → +③ 패턴으로 구조 변경(동작은 그대로) → ④ 테스트가 여전히 초록인지 확인. +안전망 없는 리팩터링은 리팩터링이 아니라 도박입니다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '패턴 읽는 법' }, + { n: 2, label: '생성 패턴' }, + { n: 3, label: '구조 패턴' }, + { n: 4, label: '행동 패턴' }, + { n: 5, label: '스프링 속 패턴' }, + { n: 6, label: 'SOLID 나침반' }, + { n: 7, label: '안티패턴' }, +]; + +export default function Lv3DesignPatternsPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 고급 과정
+

🧩 디자인 패턴 실전

+

+ 외우는 패턴 말고 쓰는 패턴 — 서비스를 만들어 본 사람이라면 이미 + 패턴이 풀어 줄 문제를 코드 안에 갖고 있어요. GoF 23개를 암기하는 대신, + '이 문제엔 이 해법'이라는 대응표를 우리 코드(시세 프로바이더 분기, + MarketBus)로 직접 확인합니다. 마지막엔 패턴을 되돌리는 용기까지요. +

+
+ 예상 소요 60분 + 실습: 시세 프로바이더 Strategy 재설계 + 선수 지식: 서비스 개발 경험·Java·스프링 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 디자인 패턴 공부가 실패하는 전형적인 경로가 있어요. GoF(Gang of Four) 책의{' '} + 23개 패턴 이름을 앞에서부터 외우기 시작하는 거죠. 싱글턴, 팩토리 + 메서드, 추상 팩토리, 프로토타입… 열 개쯤에서 지치고, 정작 내 코드의 if 지옥 앞에선 + 아무것도 떠오르지 않습니다. 순서가 거꾸로였기 때문이에요. +

+

+ 패턴의 정체는 "자주 만나는 문제에 대한, 선배들이 검증한 해법의 이름" + 입니다. 그러니 읽는 순서도 문제가 먼저여야 해요. "생성 로직이 + 흩어졌네 → 팩토리", "분기가 자꾸 자라네 → 전략". 문제를 겪어 본 사람만 해법의 가치를 + 알아봅니다 — 여러분이 이 과정의 대상인 이유예요. +

+ {CODE_PATTERN_MAP} +

+ 그리고 처음부터 분명히 해 둘 것: 패턴은 목적이 아니라 도구입니다. + 패턴을 많이 쓴 코드가 좋은 코드가 아니라, 문제에 맞는 패턴을 문제가 생겼을 + 때 쓴 코드가 좋은 코드예요. 도구 자랑을 위해 문제를 발명하는 순간, + 패턴은 오버엔지니어링이라는 이름의 빚이 됩니다(섹션 7에서 다시). +

+
+ 패턴의 진짜 보너스, 어휘 패턴 이름은 팀의 공용어이기도 해요. + "여기 프로바이더 선택을 전략으로 빼죠"라는 한 문장이, 설계 회의 30분짜리 설명을 + 대신합니다. 코드 리뷰에서 "이거 퍼사드 하나 두는 게 낫지 않아요?"가 통하는 팀과 + 아닌 팀은 소통 속도가 다릅니다. +
+
+ +
+

+ 생성 패턴의 공통 질문은 하나예요. "객체를 만드는 방법이 사용하는 쪽에 + 노출되어야 하나?" new KiwoomBridgeClient(host, port, + retry, ...) 같은 조립 지식이 사용처마다 복사되면, 조립법이 바뀔 때 전부 + 고쳐야 하죠. 팩토리는 그 조립 지식을 한곳에 모아 감춥니다 — + "나는 create("KR")만 부를게, 뭘 어떻게 만드는지는 + 네가 알아서". +

+

+ 빌더는 생성 과정 자체가 복잡할 때의 해법이에요. 인자가 예닐곱 개를 + 넘어가는 생성자는 호출부가 암호문이 됩니다. 무엇이 제목이고 무엇이 플래그인지, + 자리만 보고는 알 수 없으니까요. +

+ {CODE_BUILDER} +

+ 그리고 가장 유명한 생성 패턴, 싱글턴. "인스턴스를 하나만 만들어 + 전역에서 공유한다"는 아이디어인데 — 스프링을 쓰는 우리에겐 반전이 있습니다. +

+ {CODE_SINGLETON} +
+ 우리 코드에서 찾아보기 service-auth의 JwtService, + market의 MarketBus — 전부 @Service/ + @Component 빈, 즉 컨테이너가 관리하는 싱글턴 + 이에요. 우리가 싱글턴 코드를 한 줄도 안 썼는데 싱글턴의 이득(인스턴스 공유)을 누리고 + 있죠. 이것이 이 코스의 반복 주제입니다: 프레임워크가 이미 준 패턴을 알아보는 + 눈이 패턴을 새로 짜는 손보다 먼저입니다. +
+
+ +
+

+ 구조 패턴은 이미 있는 것들을 어떻게 이어 붙이느냐의 문제를 다룹니다. + 가장 자주 만나는 상황은 이거예요. 외부 API의 응답 모양이 우리 도메인 모델과 다르다 — + 그것도 프로바이더마다 제각각 다르다. +

+ {CODE_ADAPTER} +

+ 어댑터의 효과는 격리예요. 키움 브리지의 "부호가 접두어로 붙는 + 가격" 같은 괴상함이 KiwoomAdapter 밖으로 한 발짝도 + 못 나옵니다. 외부 API가 필드명을 바꿔도 고칠 곳은 어댑터 하나뿐이고요. 여행용 + 돼지코(플러그 어댑터)와 원리가 같아서 이름도 어댑터입니다. +

+

+ 데코레이터는 "기능을 겹겹이 입히기"예요. 기존 객체를 감싸서(같은 + 인터페이스로) 호출 앞뒤에 일을 더합니다. 캐시를 입힌 + CachingQuoteSource(realSource), 로깅을 입힌 + LoggingQuoteSource(cachingSource)처럼 런타임에 조합이 되죠. + 상속으로 같은 걸 하려면 '캐시+로깅', '캐시만', '로깅만'… 조합 수만큼 자식 클래스가 + 폭발합니다. 겹겹이 입힐 기능이라면 상속보다 데코레이터 — 이 구분이 + 섹션 5의 프록시·AOP를 이해하는 열쇠가 돼요. +

+

+ 퍼사드(facade, 건물의 정면)는 복잡한 하위 시스템에{' '} + 창구 하나만 여는 패턴입니다. "주문 체결 알림을 보내려면 구독 조회, + 템플릿 렌더링, 푸시 발송, 감사 로그를 순서대로…" — 이 지식을 호출하는 쪽마다 갖게 + 하지 말고, NotificationFacade.notifyFill(order) 하나 + 뒤로 숨기는 거예요. 호출부는 단순해지고, 내부 절차가 바뀌어도 창구 뒤에서만 바뀝니다. +

+
+ 셋의 구분법 셋 다 '감싸기'라 헷갈리면 목적을 보세요. 어댑터는 모양을 + 바꾸려고(인터페이스 번역), 데코레이터는 기능을 더하려고(같은 + 인터페이스 유지), 퍼사드는 단순하게 보이려고(여러 개를 하나로) 감쌉니다. +
+
+ +
+

+ 드디어 이 코스의 실습 무대입니다. market 서비스에는 한국(키움 브리지)·미국·코인, + 시장마다 다른 시세 프로바이더가 있고, 초기 코드는 이걸 if 분기로 + 갈랐어요. 처음엔 나쁘지 않았습니다. 문제는 같은 분기가 여러 메서드에 + 복붙되면서 시작됐죠. +

+ {CODE_STRATEGY_BEFORE} +

+ 전략(Strategy) 패턴의 처방: "시장마다 다른 부분"을 각자의 클래스로 + 분리하고, 공통 인터페이스로 묶은 뒤, 선택을 한 곳으로 모읍니다. + if 사다리가 다형성으로 바뀌는 순간이에요. +

+ {CODE_STRATEGY_AFTER} +

+ 눈여겨볼 대목은 생성자의 List<QuoteProvider>{' '} + 주입입니다. 스프링은 같은 인터페이스의 빈을 전부 모아 List로 넣어 + 줘요. 새 프로바이더는 @Component 클래스 하나만 추가하면 + 자동으로 합류합니다. 재설계의 성과를 숫자로 비교해 보죠 — 이게 실습의 핵심 산출물이에요. +

+ {CODE_STRATEGY_COST} +

+ 두 번째 행동 패턴, 옵저버와 Pub/Sub. 멀리 갈 것 없이 우리{' '} + MarketBus가 실물 교재입니다. 시세 틱 하나를 SSE 스트림· + 캐시·알림 판정이 모두 알아야 하는데, 발행자가 구독자 목록을 직접 들고 있으면 구독자가 + 늘 때마다 발행자를 수정해야 하죠. 그래서 중간에 채널을 둡니다. +

+ {CODE_OBSERVER} +

+ 세 번째, 템플릿 메서드. 시장별 시세 수집기의 흐름은 전부 + 같은데(가져오기→정규화→검증→발행) 앞의 두 단계만 시장마다 달라요. 흐름을 + 부모가 final로 고정하고, 다른 부분만 추상 메서드로 + 자식에게 위임합니다. +

+ {CODE_TEMPLATE} +
+ 전략 vs 템플릿 메서드, 뭘 쓰지? 갈아 끼우는 단위로 정하세요. 알고리즘 + 전체가 다르면 전략(합성 — 런타임 교체 가능, 테스트 쉬움), 흐름은 + 같고 일부 단계만 다르면 템플릿 메서드(상속 — 흐름 자체를 강제하고 싶을 때). + 현대 자바에선 상속의 경직성 때문에, 템플릿 메서드도 람다를 받는 '템플릿 콜백'으로 + 많이 대체합니다. +
+
+ +
+

+ 여기까지 오면 이상한 기시감이 들 거예요. "어… 이거 스프링이 맨날 하던 거잖아?" + 맞습니다. 스프링 프레임워크 자체가 디자인 패턴의 박물관이에요. + 우리는 패턴을 배우기 전부터 이미 패턴 위에서 개발해 왔습니다. 이름을 몰랐을 뿐이죠. +

+ {CODE_SPRING_DI} +

+ DI/IoC부터 풀어 보죠. 생성자 주입은 "구체 클래스를 내가{' '} + new 하지 않고, 인터페이스로 받는다"는 것 —{' '} + 의존성 역전 원칙(DIP)의 구현 그 자체입니다. '무엇을 쓸지'의 결정권이 + 사용하는 클래스에서 조립하는 컨테이너로 역전(Inversion)되었기에 + IoC(제어의 역전)라고 부르고요. 섹션 4에서 List<QuoteProvider> + 가 자동 주입된 것도 이 메커니즘의 선물이었습니다. +

+

+ @Transactional의 비밀은 프록시 패턴이에요. 스프링은 여러분의 클래스를 + 감싼 대리인 객체를 만들어 빈으로 등록합니다. 호출이 대리인을 거치며 + 트랜잭션 시작·커밋·롤백이 앞뒤에 끼워지는 거죠. 섹션 3의 데코레이터와 형제인데, + 데코레이터는 기능 추가가 목적이고 프록시는 접근 제어·대리가 + 목적이라는 뉘앙스 차이가 있습니다. AOP(관점 지향)도 같은 원리 — 로깅·보안 + 같은 공통 관심사를 프록시가 끼워 넣는 것입니다. +

+

+ 이 구조를 알면 유명한 함정도 당연해져요. 같은 클래스 안에서{' '} + this.method()로 부르면 @Transactional + 이 안 먹는다 — 내부 호출은 대리인(프록시)을 거치지 않고 진짜 객체로 직행하기 + 때문입니다. "스프링이 마법을 부린다"가 아니라 "프록시가 감싸고 있다"로 이해하는 순간, + 마법이 공학이 됩니다. +

+
+ 템플릿 콜백 찾아보기 JdbcTemplate· + RestClient처럼 이름에 Template이 들어가거나 람다를 받는 + 스프링 API는 대부분 템플릿 콜백이에요. 연결 열기·예외 변환·자원 정리 + 같은 지루한 앞뒤를 템플릿이 맡고, 우리는 가운데 빈칸만 채웁니다. 섹션 4의 템플릿 + 메서드에서 상속을 람다로 바꾼 변형이죠. +
+
+ +
+

+ 패턴 목록을 알아도 남는 질문이 있어요. "그래서 지금 이 코드엔 뭘 써야 + 하지?" 그 판단의 나침반이 SOLID 원칙입니다. 패턴이 + '어떻게(How)'라면, SOLID는 '왜(Why)' — 원칙이 위반된 지점이 곧 패턴이 들어갈 + 자리예요. +

+ {CODE_SOLID} +

+ 섹션 4의 실습을 SOLID로 다시 읽어 봅시다. Before 코드의 문제는{' '} + 개방 폐쇄 원칙(OCP) 위반이었어요 — 새 시장을 '확장'하려면 기존 + 메서드를 '수정'해야 했으니까요. 전략 패턴 도입 후엔 새 클래스 추가만으로 확장되고 + 기존 코드는 닫혀 있죠. 또 QuoteService가 구체 + 프로바이더가 아닌 인터페이스만 보는 건 의존성 역전(DIP), 프로바이더 + 선택과 시세 조회를 분리한 건 단일 책임(SRP)의 적용입니다. 패턴 + 하나가 원칙 셋을 동시에 회복시킨 거예요. +

+

+ 인터페이스 분리(ISP)는 실전에서 이렇게 씁니다. 어떤 프로바이더는 + 호가(orderbook)를 지원하지 않는데 QuoteProvider에{' '} + getOrderbook이 있다면? 지원 안 하는 구현체가 예외를 + 던지는 어색한 코드가 생겨요(리스코프 위반이기도!). 답은 인터페이스를{' '} + QuoteProviderOrderbookProvider + 로 쪼개서, 되는 것만 구현하게 하는 것입니다. +

+
+ 절반의 성공 순서를 헷갈리지 마세요. 패턴 없이도 SOLID면 절반은 + 성공입니다. 작은 클래스, 인터페이스 의존, 한 가지 책임 — 이것만 지켜도 + 코드는 이미 유연해요. 패턴은 SOLID를 지키기 위한 정형화된 수단이지, + SOLID를 대신하는 부적이 아닙니다. 원칙이 먼저, 패턴은 그다음. +
+
+ +
+

+ 마지막 관문은 타이밍입니다. 같은 전략 패턴도 너무 늦으면 if 지옥에서 + 고생하고, 너무 이르면 오버엔지니어링이 돼요. 도입 시점을 알려주는 건 이론이 아니라{' '} + 코드 냄새(code smell) — 코드가 보내는 구조적 신호입니다. +

+ {CODE_SMELLS} +

+ 체크리스트의 아래쪽, "언젠가 필요할지도 몰라서"가 가장 위험한 + 이유를 짚고 갑시다. 아직 오지 않은 요구사항을 위해 추상화를 쌓으면, 그 추상화 자체가 + 비용이 됩니다 — 읽을 때마다 인터페이스를 한 겹 더 통과해야 하고, 구현체가 하나뿐인 + 인터페이스는 독자에게 "다른 구현이 있나?" 하는 헛된 탐색을 시켜요. 업계에선 이를{' '} + YAGNI(You Aren't Gonna Need It — 어차피 안 쓸걸)라고 부릅니다. + 섹션 4의 실습이 정당했던 건 분기가 이미 세 곳에 복붙되어 있었기 + 때문이지, 패턴이 멋있어서가 아니에요. +

+

+ 그리고 리팩터링의 안전망, 테스트. 패턴 도입은 "동작은 그대로, 구조만 + 바꾸기"입니다. 그런데 동작이 그대로인 걸 무엇으로 증명하죠? 도입 전에{' '} + 현재 동작을 고정하는 테스트를 만들고, 구조 변경 후에 그 테스트가 + 여전히 통과함을 확인하는 것 — 이 전/후 초록불이 유일한 증명입니다. 실습에서도 + 프로바이더 분기를 옮기기 전에 getQuote의 시장별 결과를 + 고정하는 테스트부터 작성하세요. +

+

+ 마지막으로, 되돌리는 용기. 이미 들어간 과잉 추상화를 발견했다면 — + 구현체 하나뿐인 인터페이스, 케이스 둘뿐인 전략, 아무도 안 쓰는 확장 포인트 —{' '} + 걷어내는 것도 리팩터링입니다. "선배가 만든 구조라서", "지우면 없어 + 보여서" 남겨 두는 코드가 다음 사람의 학습 비용이 돼요. 패턴을 넣을 줄 아는 개발자는 + 많지만, 뺄 줄 아는 개발자는 드뭅니다. 후자가 시니어예요. +

+
+ 실습 최종 점검 재설계를 마쳤다면 스스로에게 물어보세요. ① 새 프로바이더 추가 + 시 기존 파일 수정이 0개인가? ② 전/후로 같은 테스트가 + 통과하는가? ③ 6개월 뒤의 내가 이 구조를 보고 왜 이렇게 했는지 알 수 + 있는가? 셋 다 예라면, 여러분은 패턴을 외운 게 아니라 쓴 것입니다. +
+
+ +
+
+

🧩 여기까지 왔다면

+

+ 이제 패턴은 암기 목록이 아니라 문제→해법 대응표가 됐어요. if 지옥엔 + 전략을, 낯선 외부 API엔 어댑터를, 사건 전파엔 옵저버를 — 그리고 스프링이 이미 깔아 둔 + 패턴들(DI·프록시·템플릿 콜백)을 알아보는 눈과, 과한 추상화를 걷어내는 용기까지 + 손에 쥐었습니다. 그런데 좋은 구조가 코드에만 있는 건 아니에요. 쿼리 한 줄이 수백만 + 행을 훑느냐 수십 행만 짚느냐도 자료구조의 설계가 정합니다. 다음{' '} + DB 인덱스와 실행계획 읽기{' '} + 코스에서, 데이터베이스가 숨겨 둔 설계의 세계로 이어 갑시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv3LayeredArchitecturePage.jsx b/frontend/src/pages/levels/Lv3LayeredArchitecturePage.jsx new file mode 100644 index 0000000..eeb07fb --- /dev/null +++ b/frontend/src/pages/levels/Lv3LayeredArchitecturePage.jsx @@ -0,0 +1,508 @@ +// 이 파일이 하는 일: "레이어드 아키텍처와 관심사 분리" 코스 — 돌아가는 코드에서 오래 사는 +// 코드로 넘어가는 첫 관문인 '계층'을 다룬다. 왜 나누는가(변경 격리)에서 출발해, 3계층의 +// 책임 경계, 의존성 방향, 도메인 로직의 자리, 계층 간 데이터 이동, 마이크로서비스로의 확장, +// 안티패턴 자가진단까지 7개 섹션으로 안내하는 정적 학습 페이지. +// 프레이밍은 "서비스를 만들어 본 사람이 잘 만드는 사람이 되는 길" — 이미 CRUD를 돌려 본 +// 수습이, 실제 사내 코드(traderbox의 web·domain·config 패키지 규칙)를 거울 삼아 +// 자기 코드의 섞인 책임을 알아채고 갈라내는 눈을 기르게 한다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_SPAGHETTI = `// 계층이 없는 코드 — '일단 돌아가는' 컨트롤러의 흔한 최후 +@RestController +class SignupController { + + @PostMapping("/api/auth/signup") + ResponseEntity> signup(@RequestBody Map body) { + // ① 입력 검증 (입출력의 일) + String email = body.get("email"); + if (email == null || !email.contains("@")) { /* 400 */ } + + // ② 비즈니스 규칙 (규칙의 일) — 이미 있는 이메일? 금지 도메인? + if (userRepository.existsByEmail(email)) { /* 409 */ } + if (email.endsWith("@banned.example")) { /* 403 */ } + + // ③ SQL 냄새 나는 저장 (저장의 일) + jdbcTemplate.update("insert into users ...", email, hash(body.get("pw"))); + + // ④ 부수 효과 (또 다른 규칙의 일) — 가입 축하 알림, 감사 로그... + kafkaTemplate.send("audit.events", "SIGNUP:" + email); + + return ResponseEntity.ok(Map.of("ok", true)); + } +} +// 한 메서드에 네 가지 관심사가 산다. 지금은 '짧아서' 문제가 안 보이지만, +// "가입 규칙이 바뀌었어요"가 오는 순간 ①~④를 전부 다시 읽어야 한다.`; + +const CODE_PACKAGES = `# traderbox(사내 실전 코드)의 패키지 규칙 — 계층이 폴더로 보인다 +click.axpdev.tdatalab.auth +├── web/ # 입출력: 컨트롤러, 요청/응답 DTO — HTTP를 아는 유일한 곳 +├── domain/ # 규칙+저장: 서비스, 엔티티, 리포지토리 — HTTP를 모른다 +├── config/ # 조립: 보안 설정, 빈 등록 — 앱을 '엮는' 코드 +└── (특화 패키지) security/ token/ audit/ push/ ... + +# 규칙은 단순하다: +# - web은 domain을 부른다. domain은 web을 모른다. (한 방향) +# - "이 코드는 어느 폴더?"에 3초 안에 답 못 하면, 책임이 섞인 것.`; + +const CODE_THIN_CONTROLLER = `// '얇은 컨트롤러' — 번역과 위임만 한다 +@RestController +@RequestMapping("/api/watchlist") +class WatchlistController { + + private final WatchlistService watchlistService; + + WatchlistController(WatchlistService watchlistService) { + this.watchlistService = watchlistService; + } + + @PostMapping + WatchlistItemResponse add(@AuthenticationPrincipal AuthUser user, + @RequestBody AddWatchlistRequest request) { + // 하는 일 딱 두 가지: HTTP → 자바(번역), 서비스 호출(위임) + return watchlistService.add(user.id(), request); + } +} + +// 실패는 서비스가 던지고, 스프링이 HTTP로 번역한다 +@Service +class WatchlistService { + WatchlistItemResponse add(Long userId, AddWatchlistRequest request) { + if (repository.countByUserId(userId) >= MAX_ITEMS) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "관심종목은 최대 30개까지 담을 수 있어요"); + } + // ... 규칙 통과 후 저장 + } +}`; + +const CODE_FORBIDDEN = `계층별 "절대 하지 말 것" 목록 +────────────────────────────────────────────── +Controller가 하면 안 되는 것 + · if로 비즈니스 규칙 판정 (한도 검사, 권한 규칙, 상태 전이) + · Repository 직접 호출 (서비스를 건너뛰는 지름길) + · 엔티티를 그대로 반환 (섹션 3에서 왜인지 본다) + +Service가 하면 안 되는 것 + · HttpServletRequest, @RequestHeader 등 HTTP 타입 만지기 + · JSON 직렬화 형태를 걱정하기 (그건 web의 일) + +Repository가 하면 안 되는 것 + · 비즈니스 판단 (findActivePremiumUserForEvent 같은 규칙 범벅 쿼리) + · 다른 서비스 호출, 알림 발송 (저장 외의 부수 효과) +────────────────────────────────────────────── +공통: 자기 위 계층의 이름을 아는 순간, 경계는 이미 무너진 것.`; + +const CODE_DIRECTION = `의존성 방향 — 화살표는 언제나 바깥 → 안쪽 +┌────────────────────────────────────────────────┐ +│ web (바깥) domain (안쪽) │ +│ │ +│ Controller ──────▶ Service ──────▶ Repository │ +│ 요청/응답 DTO 엔티티·규칙 저장 │ +│ │ +│ ◀──── 이 방향 화살표가 하나라도 생기면 사고 ──── │ +└────────────────────────────────────────────────┘ + +왜 이 방향인가? +· HTTP는 자주 바뀐다 (URL 개편, 필드 추가, v2 API...) +· 규칙은 천천히 바뀐다 ("관심종목 30개 제한"은 API가 바뀌어도 그대로) +· 자주 바뀌는 것이 천천히 바뀌는 것에 의존해야, + 바깥이 흔들려도 안쪽이 무사하다. 반대면 규칙까지 같이 흔들린다.`; + +const CODE_DTO = `// DTO는 record로 — 계층 경계의 통행증 (traderbox 컨벤션) +// 요청: 바깥에서 안으로 들어올 때 '이것만 받겠다'는 선언 +public record AddWatchlistRequest(String symbol, String market) {} + +// 응답: 안에서 바깥으로 나갈 때 '이것만 보여 주겠다'는 선언 +public record WatchlistItemResponse(Long id, String symbol, + String market, Instant addedAt) {} + +// 엔티티는 domain에 산다 — passwordHash처럼 밖에 내보이면 안 되는 +// 필드, JPA 지연로딩 프록시 같은 '내부 사정'을 품고 있다. +// 엔티티를 컨트롤러까지 올려보내 JSON으로 찍는 순간: +// ① DB 컬럼 이름이 곧 API 스펙이 된다 (컬럼 하나 바꾸면 앱이 깨진다) +// ② 숨겨야 할 필드가 실수 한 번에 응답에 실린다 +// ③ 직렬화가 지연로딩을 건드려 예상 못 한 쿼리가 터진다 +// record는 불변이고, 필드가 곧 전부라 '통행증'으로 꼭 맞는 그릇이다.`; + +const CODE_ANEMIC = `// 빈약한 도메인 모델(Anemic Model) — getter/setter 덩어리 +class Portfolio { + private List holdings; + public List getHoldings() { return holdings; } + public void setHoldings(List h) { this.holdings = h; } +} +// 규칙은 전부 서비스에 흩어진다. 어디서든 setHoldings로 +// 아무 값이나 꽂을 수 있으니, "합계가 왜 안 맞지?"의 범인 찾기가 시작된다. + +// 행동을 가진 객체 — 규칙이 데이터와 같은 집에 산다 +class Portfolio { + private final List holdings = new ArrayList<>(); + + void addHolding(Holding holding) { + if (holdings.size() >= 100) { + throw new IllegalStateException("보유 종목은 100개를 넘을 수 없어요"); + } + holdings.add(holding); + } + + BigDecimal totalValue() { // 계산 규칙도 데이터 곁에 + return holdings.stream() + .map(Holding::value) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } +} +// 이제 '100개 제한'을 우회할 문이 없다. 규칙이 한 곳에만 있으니까.`; + +const CODE_RULE_HOME = `규칙의 집을 고르는 간단한 기준 +────────────────────────────────────────────── +도메인 객체에 둘 규칙 — 그 객체 하나만 알면 판정 끝 + · "보유 종목 100개 제한" → Portfolio 안 + · "수량은 0보다 커야" → Holding 생성자 안 + · "총평가액 = 보유분 합" → Portfolio.totalValue() + +서비스에 둘 규칙 — 여러 객체·외부 세계를 엮어야 판정 가능 + · "중복 이메일 가입 금지" → DB를 물어야 함 → Service + · "매수 기록 후 감사 이벤트 발행" → Kafka라는 바깥세상 → Service + · "프리미엄 시세는 로그인 회원만" → 사용자+정책 조합 → Service +────────────────────────────────────────────── +질문 하나로 요약: "이 판정, 객체 혼자 아는 것으로 되나?" +되면 객체로, 안 되면 서비스로.`; + +const CODE_MAPPING = `// 매핑은 '경계를 아는 계층'인 서비스(또는 web의 매퍼)에서 +@Service +class WatchlistService { + + WatchlistItemResponse add(Long userId, AddWatchlistRequest request) { + WatchlistItem saved = repository.save( + WatchlistItem.of(userId, request.symbol(), request.market())); + return toResponse(saved); // 엔티티 → 응답 DTO, 여기서 끝 + } + + private WatchlistItemResponse toResponse(WatchlistItem item) { + return new WatchlistItemResponse( + item.getId(), item.getSymbol(), item.getMarket(), item.getAddedAt()); + } +} +// 요청 DTO와 응답 DTO를 '하나로 합치고 싶은' 유혹이 온다. 참자. +// · 요청엔 없어야 할 필드(id, addedAt)가 응답엔 필요하고 +// · 응답엔 없어야 할 필드(비밀번호 원문)가 요청엔 있다 +// 하나로 합치면 "이 필드, 지금 들어오는 값이야 나가는 값이야?"를 +// 코드를 읽는 모두가 매번 추리해야 한다. DTO 두 벌은 비용이 아니라 문서다.`; + +const CODE_MSA = `# 계층 분리를 서비스 경계로 승격 — traderbox의 실제 지형 +게이트웨이(8080)가 URL로 교통정리 + /api/auth/** → service-auth (8081) 회원·토큰·관심종목 + /api/market/** → service-market (8083) 시세·호가 + /api/content/** → service-content(8084) 공시·아티클 + +데이터도 계층처럼 격리 — 스키마-퍼-서비스 + 단일 Postgres 안에서 서비스마다 자기 스키마만 사용. + 남의 스키마에 조인하는 순간, 서비스 경계는 그림일 뿐이다. + +서비스끼리 말 거는 채널 세 가지 — 채널 규율 + 동기(즉답 필요) → 게이트웨이 우회 /internal/** + 공유키 + 비동기(감사·이력) → Kafka 토픽 audit.events + 실시간(시세 팬아웃)→ Valkey Pub/Sub market.ticks + 규율: 같은 데이터를 두 채널로 흘리지 않는다.`; + +const CODE_SMELLS = `안티패턴 자가진단 체크리스트 — 하나라도 '네'면 리팩터링 후보 +────────────────────────────────────────────── +□ 컨트롤러 메서드가 20줄을 넘고, if의 절반이 비즈니스 판정이다 +□ 컨트롤러가 Repository를 import하고 있다 +□ 응답 타입이 엔티티 클래스 그대로다 +□ Repository 메서드 이름에 정책이 들어 있다 + (findEligibleUsersForPromotion — '자격'은 규칙인데 저장소가 안다) +□ 서비스 A가 B를 부르고, B가 다시 A를 부른다 (순환 의존) +□ 한 서비스 클래스가 800줄이고 이름이 CommonService다 (God Service) +────────────────────────────────────────────── +고치는 방향은 늘 같다: + 판정 로직 → 서비스나 도메인 객체로 내리고 + 공용 로직 → 이름 있는 작은 서비스로 쪼개고 + 순환 → 공통 조각을 셋째 클래스로 뽑아 둘 다 그것에 의존시킨다`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '왜 나누나' }, + { n: 2, label: '3계층의 책임' }, + { n: 3, label: '의존성 방향' }, + { n: 4, label: '도메인 로직의 집' }, + { n: 5, label: '데이터 옮기기' }, + { n: 6, label: '서비스 경계로 확장' }, + { n: 7, label: '자가진단' }, +]; + +export default function Lv3LayeredArchitecturePage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 고급 과정
+

레이어드 아키텍처와
관심사 분리

+

+ 서비스를 한 번 만들어 봤다면 알 거예요 — 돌아가게 만드는 것과{' '} + 오래 살게 만드는 것은 다른 기술입니다. 기능이 늘수록 코드가 서로를 + 붙잡고 무너지는 이유는 대부분 하나, 변경이 갇히지 않아서예요. + 계층(layer)은 변경을 한 곳에 가두는 가장 오래되고 확실한 담장입니다. 사내 실전 + 코드(traderbox)를 거울 삼아, 그 담장을 세우는 법을 익혀 봅시다. +

+
+ 예상 소요 55분 + 실습: 컨트롤러 리팩터링 스케치 + 대상: 서비스를 만들어 본 사람 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 관심사 분리(Separation of Concerns)는 거창한 말이 아니에요. + "서로 다른 이유로 바뀌는 코드는, 서로 다른 자리에 두자" — 이게 전부입니다. + URL이 바뀌는 이유(기획 개편)와 가입 규칙이 바뀌는 이유(정책 변경)와 DB가 바뀌는 + 이유(성능·인프라)는 전부 달라요. 그런데 그 셋이 한 메서드에 살면, 어느 하나가 + 바뀔 때마다 셋 전부를 다시 읽고, 다시 테스트하고, 다시 두려워해야 합니다. +

+ {CODE_SPAGHETTI} +

+ 계층 없는 코드가 커질 때 치르는 대가는 정확히 두 가지예요. 하나는{' '} + 스파게티 — 모든 코드가 모든 코드를 만질 수 있으니, 수정 하나가 어디까지 + 번질지 아무도 장담 못 하는 상태. 다른 하나는 중복 — "가입 규칙"이 + 컨트롤러 세 군데에 복사돼 있다가, 두 군데만 고쳐져서 생기는 버그. 스파게티는 변경을 + 느리게 만들고, 중복은 변경을 틀리게 만듭니다. +

+

+ 그래서 우리 회사의 실전 코드(traderbox)는 모든 서비스가 같은 규칙으로 패키지를 + 가릅니다. web/(입출력),{' '} + domain/(규칙과 저장),{' '} + config/(조립). 폴더 이름이 곧 "이 코드가 바뀌는 + 이유"의 선언이에요. +

+ {CODE_PACKAGES} +
+ 담장의 비유 계층은 방화벽 담장이에요. 불(변경)이 안 나게 해 주진 못합니다. + 다만 불이 났을 때 옆 칸으로 번지지 않게 해 주죠. "API 필드명 바꿔 + 주세요"라는 불은 web/ 칸 안에서 꺼져야 정상이고,{' '} + domain/까지 번진다면 담장 어딘가에 구멍이 있는 겁니다. +
+
+ +
+

+ 한 줄 정의부터요. Controller는 입출력 — HTTP라는 바깥세상 말을 자바로 + 번역하고, 결과를 다시 HTTP로 번역합니다. Service는 규칙 — "관심종목은 + 30개까지", "중복 가입 금지" 같은 업무 판단이 사는 곳. Repository는 저장{' '} + — 데이터를 넣고 꺼내는 것, 그 이상도 이하도 아닙니다. +

+

+ 여기서 나오는 원칙이 '얇은 컨트롤러'예요. 컨트롤러는 번역과 위임만 + 하고, 판단은 전부 아래로 내려보냅니다. 실패했을 때는 서비스가{' '} + ResponseStatusException을 던지고, 스프링이 그걸 HTTP + 상태 코드로 번역해 주죠 — traderbox 전 서비스가 따르는 컨벤션입니다. +

+ {CODE_THIN_CONTROLLER} +

+ "무엇을 해야 하나"만큼 중요한 게 "무엇을 하면 안 되나"예요. 경계는 + 금지 목록으로 지켜집니다. +

+ {CODE_FORBIDDEN} +
+ 식당의 비유 Controller는 홀 직원(주문을 받아 주방 말로 전달), + Service는 주방장(레시피와 판단), Repository는{' '} + 냉장고 담당(재료를 넣고 꺼냄)이에요. 홀 직원이 요리를 시작하거나, + 냉장고 담당이 "이 손님에겐 이 메뉴가 어울려"를 판단하는 식당을 상상해 보세요 — + 바쁜 날 반드시 사고가 납니다. +
+
+ +
+

+ 계층을 나눴다면 이제 화살표를 관리할 차례예요. 규칙은 하나입니다 —{' '} + 의존은 바깥에서 안쪽으로(web → domain), 한 방향만. 컨트롤러는 + 서비스를 알지만, 서비스는 컨트롤러의 존재조차 몰라야 해요. traderbox의{' '} + domain/ 패키지에서 web/을 + import하는 코드가 하나라도 보이면, 그건 담장에 뚫린 구멍입니다. +

+ {CODE_DIRECTION} +

+ 이 방향이어야 하는 이유는 변경 속도의 차이예요. HTTP 스펙(URL, + 필드명, 버전)은 자주 바뀌고, 업무 규칙은 천천히 바뀝니다. 자주 바뀌는 쪽이 천천히 + 바뀌는 쪽에 의존해야, 바깥이 아무리 출렁여도 안쪽 규칙은 흔들리지 않아요. 반대로 + 안쪽이 바깥을 알면, API 필드명 하나 바꾸는 데 규칙 코드까지 수술해야 합니다. +

+

+ 같은 이유로 엔티티를 컨트롤러까지 올려보내지 않습니다. 엔티티는 + 안쪽(domain)의 주민이라 passwordHash 같은 내부 사정을 + 품고 있어요. 그대로 JSON이 되는 순간 DB 컬럼이 곧 API 스펙이 되고, 숨겨야 할 필드가 + 바깥으로 새어 나갑니다. 경계를 건널 때는 전용 그릇, DTO를 씁니다 — + traderbox 컨벤션대로 자바 record로요. +

+ {CODE_DTO} +
+ 기억법 DTO는 공항의 세관 신고서예요. 나라(계층) 경계를 넘는 + 짐은 정해진 양식에 적힌 것만 통과합니다. 집 안 살림(엔티티)을 통째로 들고 국경을 + 넘겠다는 게 '엔티티 직접 반환'이고, 세관(경계)이 그걸 허용하는 순간 무엇이 오가는지 + 아무도 통제할 수 없게 돼요. +
+
+ +
+

+ 3계층을 잘 지켜도 남는 질문이 있어요 — "이 규칙, 서비스에 둬요? 객체에 + 둬요?" 이 질문에 무감각하면 흔히 도착하는 곳이{' '} + 빈약한 도메인 모델(Anemic Domain Model)입니다. 엔티티는 getter/setter만 + 가득한 데이터 봉지가 되고, 규칙은 전부 서비스에 흩어지는 상태죠. 겉보기엔 계층이 + 멀쩡한데, 규칙의 주소가 없어서 같은 검증이 서비스 세 군데에 복사되는 + 병이 생깁니다. +

+ {CODE_ANEMIC} +

+ 아래 객체가 좋은 이유는 딱 하나예요 — '100개 제한'이라는 규칙을 우회할 문이 + 없다는 것. setter가 열려 있으면 규칙은 "지켜 주길 바라는 부탁"이지만, 행동 + 메서드 안에 있으면 "어길 수 없는 법"이 됩니다. 그럼 모든 규칙을 객체로 밀어 넣어야 + 할까요? 아니요, 기준이 있습니다. +

+ {CODE_RULE_HOME} +
+ 자가진단 한 줄 엔티티 파일을 열어 보세요. getter/setter 말고 동사 + 메서드가 하나도 없다면, 그 도메인의 규칙들은 지금 어딘가를 떠돌고 있는 + 거예요. 서비스 코드에서 같은 if 판정이 두 번 이상 + 보인다면, 그 판정이 바로 집 잃은 규칙 — 객체 안으로 이사시킬 1순위 후보입니다. +
+
+ +
+

+ 경계를 넘을 때 DTO를 쓴다면, 엔티티↔DTO 변환(매핑)은 누가 하나요?{' '} + 답은 "경계를 아는 계층" — 보통 서비스(또는 web 쪽의 전용 매퍼)입니다. + 컨트롤러가 엔티티를 받아서 직접 변환하려면 엔티티가 web까지 올라와야 하니 탈락이고, + 리포지토리가 DTO를 만들면 저장소가 API 응답 모양을 알게 되니 역시 탈락이에요. +

+ {CODE_MAPPING} +

+ 요청 DTO와 응답 DTO를 따로 두는 값어치도 여기서 나옵니다. 둘은 생김새가 + 비슷해 보여도 방향이 달라요. 요청은 "받을 것만"(id 없음, 생성일 없음), 응답은 "보여줄 + 것만"(비밀번호 없음). 하나로 합치는 순간 모든 필드가 "들어오는 값인지 나가는 값인지" + 모호해지고, 그 모호함은 코드 리뷰마다 세금처럼 걷혀 갑니다. +

+

+ 반대로 매핑을 아무 데서나 하면 매핑 누수가 생겨요. 컨트롤러 A는 + 서비스가 준 DTO를 쓰는데, 컨트롤러 B는 엔티티를 받아 직접 변환하고 있다면 — 응답 + 모양을 바꿀 때 두 곳을 다르게 고쳐야 하고, 언젠가 한 곳을 빼먹습니다. 변환 규칙이 + 여러 계층에 흩어지는 것, 그게 계층 사이의 숨은 결합이에요. +

+
+ 실무 감각 "DTO 만들기 귀찮은데 그냥 엔티티 쓰면 안 돼요?"는 모든 신입이 한 + 번씩 하는 질문이에요. 답은 "지금은 되고, 석 달 뒤에 안 된다"입니다. 필드 5개짜리 + 토이 프로젝트에선 못 느끼지만, 필드 30개·컨슈머 3팀이 되는 순간 DTO는 선택이 아니라{' '} + API 스펙을 지키는 유일한 장치가 됩니다. +
+
+ +
+

+ 여기까지는 한 서비스 안의 이야기였어요. 그런데 관심사 분리의 원리는 + 더 크게도 적용됩니다 — traderbox는 인증(auth)·시세(market)·콘텐츠(content) 같은{' '} + 관심사를 아예 별도 서비스로 가르고, 하나의 모노레포에 담아 운영해요. + 계층이 "패키지 담장"이라면, 마이크로서비스는 "프로세스 담장"입니다. +

+ {CODE_MSA} +

+ 눈여겨볼 지점이 셋이에요. 첫째, 스키마-퍼-서비스 — 데이터베이스는 + 하나여도 서비스마다 자기 스키마만 만집니다. 계층에서 "리포지토리만 DB를 안다"였던 + 규칙이, 서비스 단위로 "내 스키마만 내가 안다"로 승격된 거예요. 둘째,{' '} + 게이트웨이 라우팅 — 바깥세상은 게이트웨이 하나만 보고, URL 규칙으로 + 각 서비스에 배달됩니다. 서비스는 자기 관심사만 지키면 되니 자율성이 + 생기죠. 셋째, 채널 규율 — 서비스끼리 말을 걸 때 동기는{' '} + /internal/**(공유키), 비동기·감사는 Kafka, 실시간 + 팬아웃은 Valkey Pub/Sub. 그리고 같은 데이터를 두 채널로 흘리지 않는다는 + 규칙이 채널판의 관심사 분리입니다. +

+
+ 순서가 중요해요 "그럼 처음부터 마이크로서비스로 짜면 되겠네요?"는 위험한 + 결론이에요. 한 서비스 안에서 계층도 못 가르는 팀이 서비스를 가르면, 스파게티가{' '} + 네트워크를 타고 다니는 더 비싼 스파게티가 될 뿐입니다. 계층 분리가 + 먼저 몸에 배야, 그 경계를 따라 서비스를 잘라 낼 자리도 보입니다. +
+
+ +
+

+ 마지막으로, 배운 것을 탐지 도구로 바꿔 봅시다. 아키텍처가 무너지는 건 + 대개 큰 결단이 아니라 "이번 한 번만"의 누적이에요. 그래서 냄새를 초기에 맡는 코가 + 중요합니다. +

+

+ ① 컨트롤러에 스며든 비즈니스 로직 — 컨트롤러 안의{' '} + if를 보세요. "값이 비었나?"(형식 검증)는 괜찮지만, + "한도를 넘었나? 권한이 되나?"(업무 판정)가 보이면 규칙이 홀에 나와 요리하는 중입니다.{' '} + ② 리포지토리가 도메인 규칙을 아는 냄새 — 메서드 이름에{' '} + eligible, valid 같은{' '} + 정책 단어가 들어가기 시작하면, 냉장고 담당이 레시피를 외우고 있는 + 거예요. ③ 순환 의존과 God Service — A→B→A로 서로를 부르는 서비스 + 쌍, 그리고 아무 일이나 다 하는 800줄짜리{' '} + CommonService. 둘 다 처방은 같습니다 —{' '} + 공통 조각을 이름 있는 셋째 클래스로 뽑아내고, 둘 다 그것에 의존하게 + 만드는 것. +

+ {CODE_SMELLS} +
+ 이 코스의 실습 사내 코드 service-auth에서 + 컨트롤러 하나를 골라 열어 보세요(예:{' '} + AuthController). 메서드마다 "이 줄은 입출력인가, + 규칙인가, 저장인가"를 색칠해 보고, web·domain 책임이 섞인 부분을 하나 찾아{' '} + 리팩터링 스케치(어떤 메서드를 어느 계층으로 옮길지 화살표 그림)를 + 그려 멘토에게 보여 주세요. 코드를 실제로 고치는 건 그다음 일 — 먼저{' '} + 보는 눈이 목표입니다. +
+
+ +
+
+

🏛️ 여기까지 왔다면

+

+ 이제 코드를 볼 때 "돌아가나?" 다음 질문이 생겼을 거예요 — "변경이 + 갇히나?" 계층의 이유(변경 격리), 3계층의 금지 목록, 바깥→안쪽 한 방향 + 의존, 규칙의 주소 정하기, DTO라는 통행증, 그리고 서비스 경계로의 승격까지 — + 오래 사는 코드의 뼈대를 손에 쥐었습니다. 그런데 계층 안에서 코드를 + 잘 빚는 데는 또 다른 어휘가 필요해요. 반복되는 설계 문제에 선배들이 붙여 둔 이름,{' '} + 디자인 패턴 실전 코스에서 + 그 어휘를 이어서 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv3ObservabilityPage.jsx b/frontend/src/pages/levels/Lv3ObservabilityPage.jsx new file mode 100644 index 0000000..ebf0300 --- /dev/null +++ b/frontend/src/pages/levels/Lv3ObservabilityPage.jsx @@ -0,0 +1,432 @@ +// 이 파일이 하는 일: "로깅·모니터링·알림 설계" 코스 — 서비스를 이미 만들어 본 사람이 +// "무엇이 느린가"와 "왜 느린가"를 나눠 답하는 관측 가능성(observability)을 설계하도록, +// 로그·메트릭·트레이스 3기둥에서 출발해 대시보드·알림·SLO까지 7개 섹션으로 안내하는 +// 정적 학습 페이지. 프레이밍은 "돌아가는 서비스"에서 "지켜볼 수 있는 서비스"로의 승격이다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_PILLARS = `장애가 났다. 사용자가 "느려요"라고 한다. 이제 세 가지 질문이 생긴다. + +메트릭(Metrics) "무엇이, 얼마나 이상한가?" + 숫자의 시계열. p99 지연이 2초를 넘었다 — 사실을 알려 준다. + 싸고 빠르다. 대신 '왜'는 모른다. + +트레이스(Traces) "그 요청은 어디서 시간을 썼는가?" + 요청 하나의 여정. 게이트웨이 30ms → auth 40ms → DB 1800ms. + 느린 '구간'을 짚어 준다. 대신 전체 추세는 못 본다. + +로그(Logs) "그 순간, 정확히 무슨 일이 있었는가?" + 사건의 기록. "커넥션 풀 고갈, 대기 큐 47건" — 맥락과 원인. + 가장 풍부하다. 대신 가장 비싸고, 검색 없이는 바다다. + +셋은 경쟁자가 아니라 역할 분담입니다. +메트릭이 "이상하다"고 알리면 → 트레이스로 "어느 구간"인지 좁히고 +→ 로그로 "왜"를 확정한다. 이 흐름이 관측의 기본 동선이에요.`; + +const CODE_LOG_LEVELS = `// 로그 레벨 — "누가, 언제 이 로그를 읽게 되는가"로 정한다 +ERROR 지금 사람이 봐야 하는 실패. 요청이 죽었거나 데이터가 어긋났다. + → 알림과 연결될 수 있는 레벨. 남발하면 알림이 무뎌진다. +WARN 아직 실패는 아니지만 곧 문제가 될 조짐. + 재시도로 살아난 호출, 임계치 80%에 닿은 커넥션 풀. +INFO 서비스의 '큰 사건' 서사. 기동/종료, 주문 체결, 로그인 성공. + 운영 중 기본으로 켜 두는 레벨. +DEBUG 개발자가 원인을 좁힐 때만 켜는 현미경. 파라미터, 분기 판단. + 운영에서 상시로 켜면 비용과 소음만 남는다. + +흔한 실패: 모든 catch에서 ERROR를 찍는 것. +사용자가 비밀번호를 틀린 건 '시스템의 에러'가 아니라 '정상 동작'이다. +그건 INFO(혹은 WARN)이고, ERROR는 "코드가 예상 못 한 상황"에 아껴 쓴다.`; + +const CODE_JSON_LOG = `// 구조화 로그 — grep이 아니라 '질의'가 가능한 로그 +// 나쁜 예: 사람은 읽어도 기계가 못 자른다 +2026-07-20 14:02:11 ERROR 주문 실패 user=482 order=9912 결제사 타임아웃 + +// 좋은 예: JSON 한 줄 — 필드별로 검색·집계·필터가 된다 +{ + "ts": "2026-07-20T14:02:11.482+09:00", + "level": "ERROR", + "service": "service-order", + "traceId": "b3f1a9c2e4d85f70", // ← 상관관계 ID: 이 값 하나로 + "userId": 482, // 게이트웨이·주문·결제 로그를 + "orderId": 9912, // 한 사건으로 꿴다 + "event": "payment.timeout", + "elapsedMs": 3012, + "message": "결제사 응답 3초 초과로 주문 중단" +} + +// traceId가 없으면: 서비스 5개의 로그를 시각으로 어림잡아 이어 붙인다(고통). +// traceId가 있으면: traceId=b3f1... 검색 한 번으로 요청의 전 생애가 나온다.`; + +const CODE_MICROMETER = `// Micrometer — 스프링 코드에 심는 '계측기'. Prometheus가 주기적으로 걷어 간다 +// build.gradle / pom.xml에 micrometer-registry-prometheus 추가 후: + +@RestController +public class OrderController { + private final MeterRegistry registry; + + @PostMapping("/api/orders") + public OrderResponse create(@RequestBody OrderRequest req) { + return Timer.builder("http.order.create") // 메트릭 이름 + .tag("result", "success") // 라벨(주의: 섹션 본문 참고) + .register(registry) + .record(() -> orderService.create(req)); // 소요 시간을 자동 기록 + } +} + +// 스프링 액추에이터를 켜면 /actuator/prometheus 엔드포인트가 열리고, +// Prometheus 서버가 15초마다 이 페이지를 '긁어(scrape)' 시계열 DB에 쌓는다. +// 우리가 푸시하는 게 아니라 Prometheus가 당겨 가는(pull) 구조라는 점이 핵심.`; + +const CODE_RED_USE = `RED — '요청을 받는 것들'(API, 서비스)을 볼 때 + Rate 초당 요청 수 "트래픽이 얼마나 오나" + Errors 실패한 요청 수(비율) "그중 몇이 죽나" + Duration 요청 처리 시간 분포 "얼마나 걸리나" (평균 말고 p95·p99!) + +USE — '자원'(CPU, 디스크, 커넥션 풀)을 볼 때 + Utilization 사용률 "얼마나 바쁜가" + Saturation 포화도(대기 큐) "일이 얼마나 밀려 있나" + Errors 자원 수준의 에러 "디스크 I/O 에러 같은 것" + +왜 평균이 아니라 p99인가? +요청 100개 중 99개가 10ms, 1개가 5초라면 평균은 60ms — 멀쩡해 보인다. +그러나 그 1%의 사용자는 5초를 기다렸다. p99는 "가장 불행한 1%가 +겪는 경험"을 숫자로 드러낸다. 사용자 체감은 평균이 아니라 꼬리에 산다.`; + +const CODE_TRACE = `// 분산 추적 — 헤더로 '이야기의 실'을 넘겨주는 원리 +// 게이트웨이가 요청을 받으면 traceId를 만들어 붙이고, 하류로 전파한다. + +클라이언트 → 게이트웨이 traceparent: 00-b3f1a9c2...-01 (새로 발급) +게이트웨이 → service-auth traceparent: 00-b3f1a9c2...-02 (같은 traceId, +게이트웨이 → service-order traceparent: 00-b3f1a9c2...-03 새 spanId) +service-order → PostgreSQL (DB 쿼리도 하나의 스팬으로 기록) + +수집기(예: Tempo, Zipkin)가 스팬들을 traceId로 모아 붙이면: + +[게이트웨이 12ms] + └─ [auth: 토큰 검증 8ms] + └─ [order: 주문 생성 1840ms] ← 범인은 여기 + └─ [DB: INSERT 대기 1790ms] ← 그중에서도 여기 + └─ [Kafka: 발행 6ms] + +스프링에선 Micrometer Tracing이 이 전파를 대신해 준다 — +RestClient·Kafka 헤더에 traceparent를 자동으로 실어 보낸다. +직접 스레드를 만들거나 헤더를 손으로 조립할 때만 전파가 '끊긴다'.`; + +const CODE_ALERT_RULE = `# Prometheus 알림 규칙 초안 — "p99가 임계를 '지속해서' 넘을 때만" +groups: + - name: order-api + rules: + - alert: OrderApiP99High + # 최근 5분 창에서 주문 API p99가 1.5초 초과 + expr: | + histogram_quantile(0.99, + rate(http_server_requests_seconds_bucket[5m]) + ) > 1.5 + for: 10m # ← 10분간 '계속' 참이어야 발화. 스파이크 무시 + labels: + severity: page # 사람을 깨우는 등급 + annotations: + summary: "주문 API p99 1.5s 초과 10분 지속" + runbook: "wiki/runbook/order-latency" # 받은 사람이 볼 첫 문서 + +# 포인트 세 가지: +# 1) 임계치(1.5s)는 SLO에서 역산한다. 감이 아니라 약속에서 나온 숫자. +# 2) for: 10m — '지속시간' 조건이 노이즈의 절반을 걸러 낸다. +# 3) runbook 링크 — 새벽 3시의 나는 낮의 나보다 훨씬 멍청하다.`; + +const CODE_SLO = `SLI (Indicator) 측정하는 지표 그 자체 + 예: "주문 API 요청 중 500ms 안에 성공한 비율" + +SLO (Objective) 그 지표에 대한 우리의 약속 + 예: "30일 창에서 99.9% 이상" + +에러 버짓 100% - SLO = 우리가 '써도 되는' 실패량 + 99.9%라면 30일 중 약 43분의 장애가 허용치. + +에러 버짓의 진짜 쓸모는 의사결정입니다. + 버짓이 넉넉하다 → 배포 자주 하고, 실험하고, 기능에 투자 + 버짓이 바닥이다 → 신규 배포를 늦추고, 안정화 작업에 투자 +"더 안정적으로"라는 구호가 아니라, 숫자가 우선순위를 정해 준다. + +100%를 목표로 하지 않는 이유: 마지막 9 하나를 더할 때마다 +비용은 10배가 되는데, 사용자는 그 차이를 대부분 느끼지 못한다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '3기둥' }, + { n: 2, label: '로그 설계' }, + { n: 3, label: '메트릭' }, + { n: 4, label: '분산 추적' }, + { n: 5, label: '대시보드·감사' }, + { n: 6, label: '알림 설계' }, + { n: 7, label: 'SLO와 배움' }, +]; + +export default function Lv3ObservabilityPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 고급 과정
+

로깅·모니터링·알림 설계

+

+ 서비스는 만들었어요. 그런데 새벽 2시에 "느려요"라는 제보가 오면, 여러분은 + 어디부터 봅니까? 관측 가능성(observability)은 그 순간을 위해 + 미리 깔아 두는 감각기관이에요. '무엇이 느린가'(메트릭)와{' '} + '왜 느린가'(트레이스·로그)를 나눠 답할 수 있게 — 로그·메트릭· + 트레이스 3기둥부터 알림과 SLO까지, 지켜볼 수 있는 서비스의 설계를 익혀 봅시다. +

+
+ 예상 소요 60분 + 실습: RED 메트릭 + p99 알림 규칙 초안 + 선수 지식: HTTP·서비스 구조·운영 경험 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ "모니터링 붙였어요"라고 말할 때, 그 안에는 사실 성격이 전혀 다른 세 가지 데이터가 + 섞여 있어요. 로그(사건의 기록), 메트릭(숫자의 + 시계열), 트레이스(요청 하나의 여정). 이 셋을 뭉뚱그리면 "다 쌓고는 + 있는데 장애 때 아무것도 못 찾는" 상태가 됩니다. 먼저 역할부터 갈라 봅시다. +

+ {CODE_PILLARS} +

+ 핵심 구도는 이거예요. 메트릭은 '무엇이 느린가'에 답하고, 트레이스와 로그는 + '왜 느린가'에 답합니다. 메트릭만 있으면 이상한 건 아는데 원인을 못 찾고, + 로그만 있으면 원인은 있는데 이상한 줄을 늦게 알아요. 장애 대응의 동선 — + 알림(메트릭) → 구간 좁히기(트레이스) → 원인 확정(로그) — 이 세 기둥을 순서대로 + 타고 내려가는 길입니다. +

+
+ 관측은 기능이지, 나중 일이 아니다 "일단 기능부터 만들고 모니터링은 나중에"는 + "일단 배부터 만들고 계기판은 나중에"와 같은 말이에요. 코드를 쓰는 그 순간이 + "이 함수가 실패하면 뭘 알고 싶지?"를 가장 잘 아는 순간입니다. 관측 코드는 + 기능 코드와 같은 PR에 들어가는 게 원칙이에요. +
+
+ +
+

+ 로그는 아무 데나 log.info("여기 옴")을 뿌리는 게 + 아니라 설계 대상이에요. 첫 번째 설계는 레벨입니다. + 기준은 간단해요 — "누가, 언제 이 로그를 읽게 되는가." +

+ {CODE_LOG_LEVELS} +

+ 두 번째 설계는 구조화예요. 사람이 읽을 문장 대신{' '} + 기계가 자를 수 있는 JSON 한 줄로 남기면, 로그가 '읽는 것'에서 + '질의하는 것'으로 바뀝니다. 그리고 그 JSON에 반드시 실어야 할 필드가{' '} + 상관관계 ID(traceId) — 여러 서비스에 흩어진 로그를 한 사건으로 + 꿰는 실이에요(섹션 4에서 이 실이 어떻게 전파되는지 봅니다). +

+ {CODE_JSON_LOG} +
+ 절대 남기면 안 되는 것 비밀번호, 액세스/리프레시 토큰, + 카드번호, 주민번호, API 키. 로그는 DB보다 접근 통제가 느슨한 곳(수집기, 대시보드, + 복사된 텍스트)으로 흘러 다녀요. Authorization 헤더나 + 요청 바디를 통째로 찍는 습관이 가장 흔한 유출 경로입니다. + 남겨야 한다면 마스킹(user****@...)하거나 식별자 + (userId)만 남기세요. "로그에 찍는 순간, 그 값은 더 이상 비밀이 아니다"가 기준이에요. +
+
+ +
+

+ 메트릭은 숫자의 시계열이에요. "지금 초당 요청이 몇인가", "p99가 + 몇 ms인가"를 15초 간격 같은 주기로 계속 쌓습니다. 스프링 생태계의 표준 조합은{' '} + Micrometer(코드에 심는 계측 API)와 Prometheus + (긁어 가서 쌓고 질의하는 시계열 DB)예요. +

+ {CODE_MICROMETER} +

+ 그럼 무엇을 잴 것인가? 막막할 때 꺼내 쓰는 두 개의 방법론이 + 있습니다. 요청을 받는 것들엔 RED, 자원엔 USE. +

+ {CODE_RED_USE} +
+ 라벨 카디널리티 폭발 메트릭의 라벨(tag)은 값의 종류마다 별도의 + 시계열을 만들어요. tag("route", "/api/orders") + 처럼 종류가 유한한 값은 좋지만, tag("userId", ...)처럼 + 무한히 늘어나는 값을 라벨로 쓰면 사용자 수만큼 시계열이 생겨 + Prometheus 메모리가 터집니다. 규칙: 라벨엔 '분류'(라우트, 상태코드, 결과)를, + '개체'(사용자 ID, 주문 ID)는 로그와 트레이스에. 개체를 좇는 건 메트릭의 일이 아니에요. +
+
+ +
+

+ 마이크로서비스에서 요청 하나는 게이트웨이 → 인증 → 도메인 서비스 → DB + 처럼 여러 프로세스를 건너다녀요. 각 서비스의 로그는 자기 구간만 알죠. "전체에서 + 2초가 걸렸는데, 그 2초는 누구 몫인가?"에 답하는 게 분산 추적입니다. +

+

+ 원리는 소박해요. 요청이 처음 들어올 때 traceId를 하나 발급하고, + 하류 서비스를 부를 때마다 HTTP 헤더로 그 ID를 넘겨줍니다. 각 + 서비스는 자기가 쓴 시간을 스팬(span)이라는 단위로 기록하면서 같은 + traceId를 달아 두고요. 나중에 수집기가 같은 traceId의 스팬들을 모아 붙이면, 요청 + 하나의 여정이 폭포수 그림으로 복원됩니다. +

+ {CODE_TRACE} +

+ 이 그림의 힘은 "느린 구간을 짚는" 데 있어요. 위 예시에서 전체 + 1.8초의 범인은 auth도 Kafka도 아니고 DB INSERT 대기라는 게 한눈에 + 보이죠. 메트릭이 "주문 API가 느리다"까지 데려다주면, 트레이스가 "그중 DB 구간이다" + 까지 데려다줍니다. 남은 '왜'(커넥션 풀? 락 경합?)는 그 시각·그 traceId의 로그가 + 마무리하고요. +

+
+ 전파가 끊기는 자리 프레임워크가 헤더를 자동으로 실어 주는 경로(RestClient, + Kafka 템플릿)는 공짜지만, 직접 만든 스레드나 수동으로 조립한 HTTP + 호출에선 traceId가 조용히 끊겨요. "트레이스가 게이트웨이에서 뚝 끊긴다"면 십중팔구 + 누군가 손으로 비동기 처리를 짠 자리입니다. 끊긴 곳이 곧 계측의 사각지대예요. +
+
+ +
+

+ 쌓기만 한 메트릭은 아직 정보가 아니에요. Grafana 같은 도구로 + "서비스 하나당 한 화면"의 계기판을 만듭니다. 구성은 섹션 3의 방법론을 그대로 + 옮기면 됩니다 — 맨 윗줄에 RED(요청·에러율·p99), 그 아래{' '} + USE(CPU·메모리·커넥션 풀 포화도), 마지막 줄에 그 서비스 고유의 + 지표. 장애 때 이 화면 하나로 "무엇이 이상한가"의 1차 판단이 끝나는 게 목표예요. +

+

+ 여기서 시야를 한 칸 넓힙시다. 관측 대상은 시스템만이 아니라{' '} + 비즈니스이기도 해요. "분당 주문 수", "로그인 성공률", "결제 전환율" + 같은 지표는 시스템 메트릭이 멀쩡해도 비즈니스가 죽어 있는 장애 + (예: 결제 버튼이 프론트에서 안 보이는 버그)를 잡아 줍니다. CPU는 한가한데 매출이 + 0인 새벽 — 시스템 대시보드만 보던 팀은 아침까지 모릅니다. +

+

+ 우리 트레이딩 플랫폼에도 이미 이 구조의 씨앗이 있어요. 각 서비스의{' '} + AuditEventPublisher가 "누가 언제 무엇을 했나"를 + Kafka의 audit.events 토픽으로 발행하면, log 서비스의{' '} + AuditEventConsumer가 받아서 쌓아 두죠. 이건 감사 + (audit)용 기록이지만, 관점을 바꾸면 비즈니스 이벤트의 스트림이기도 + 합니다 — "시간당 로그인 이벤트 수"를 세면 그대로 비즈니스 지표가 돼요. +

+
+ 감사 로그와 앱 로그는 다르다 앱 로그는 디버깅용이라 유실돼도 아프고 말지만, + 감사 이벤트는 "누가 무엇을 했는가"의 증거라 유실되면 안 돼요. + 그래서 우리도 파일 로그가 아니라 Kafka라는 내구성 있는 통로로 + 따로 흘립니다. 같은 '기록'이어도 요구사항이 다르면 배관을 달리 깐다 — 이게 설계예요. +
+
+ +
+

+ 알림은 관측의 꽃이자, 가장 망치기 쉬운 부분이에요. 첫 번째 원칙:{' '} + 증상 기반(symptom-based)으로 깨우고, 원인 기반(cause-based)은 참고로 + 돌린다. 증상은 "사용자가 겪는 것"(에러율 상승, p99 초과)이고, 원인은 + "그럴 수도 있는 내부 상태"(CPU 80%, 디스크 70%)예요. CPU가 90%여도 사용자가 + 멀쩡하면 새벽에 깨울 일이 아니고, CPU가 20%여도 에러율이 10%면 당장 깨워야 합니다. +

+

+ 두 번째 원칙: 임계치에 지속시간을 곱해라. 순간 스파이크는 대부분 + 스스로 지나갑니다. "p99가 1.5초를 10분간 계속 넘을 때"처럼 + 지속 조건을 달면, 알림의 절반을 차지하는 노이즈가 걸러져요. +

+ {CODE_ALERT_RULE} +

+ 그리고 안티패턴들 — 온콜을 갈아 넣는 알림의 전형이에요.{' '} + ① 조치 없는 알림: 받아도 할 일이 없는 알림은 다음번에 무시되고, + 그 무시가 진짜 장애를 삼킵니다. ② 중복 폭풍: DB 하나가 죽었는데 + 그 위의 서비스 6개가 각자 울면, 정작 원인(DB) 알림이 묻혀요. ③ 임계치 + 방치: 트래픽이 두 배가 됐는데 알림 기준이 작년 그대로면, 매일 울리는 + 양치기 소년이 됩니다. +

+
+ 알림의 품질 지표 "울린 알림 중 실제로 조치가 필요했던 비율"을 주기적으로 + 세어 보세요. 이 비율이 낮다는 건 팀이 알림을 불신하도록 훈련되고 있다는 + 뜻이에요. 알림 하나를 지우는 일은 하나를 추가하는 일만큼 가치 있습니다. +
+
+ +
+

+ 마지막 기둥은 문화예요. "얼마나 안정적이어야 하는가"를 감이 아니라 숫자로 합의하는 + 도구가 SLI·SLO·에러 버짓입니다. +

+ {CODE_SLO} +

+ SLO가 있으면 섹션 6의 임계치가 역산됩니다. "30일 99.9%"를 + 약속했다면, 에러 버짓이 얼마나 빠르게 타고 있는지(burn rate)가 알림 기준이 되죠. + 감으로 정한 "p99 1.5초"가 아니라, 약속에서 도출된 숫자가 새벽에 + 사람을 깨우는 겁니다. +

+

+ 그리고 장애가 났다면 — 그건 관측 체계를 업그레이드할 기회예요.{' '} + 포스트모템(사후 분석)은 "누구 잘못인가"가 아니라{' '} + "시스템의 어디가 약했고, 다음엔 어떻게 더 빨리 알아챌 것인가"를 + 묻는 문서입니다. 비난 없는(blameless) 회고여야 사람들이 사실을 숨기지 않아요. +

+

+ 포스트모템의 고정 질문 하나를 추천할게요 — "우리는 무엇을 관측하지 않고 + 있었나?" 알림이 늦게 온 장애라면 지속시간·임계치를 튜닝하고, 알림 자체가 + 없던 장애라면 새 SLI를 추가하고, 로그가 없어 원인을 못 찾았다면 그 자리에 로그를 + 심습니다. 이렇게 장애 → 관측 보강 → 다음 장애를 더 빨리 감지하는 + 루프가 돌기 시작하면, 팀의 관측 체계는 장애를 겪을수록 강해져요. +

+
+ 실습 과제 여러분 서비스의 핵심 API 하나를 골라 ① Micrometer로{' '} + RED 세 지표를 심고, ② "p99가 몇 초를 몇 분간 넘으면, 누구에게, + 어떤 런북 링크와 함께" 알림이 가는지 알림 규칙 초안을 섹션 6의 + YAML 형태로 써 보세요. 임계치의 근거(왜 그 숫자인가)를 한 줄 주석으로 달 수 + 있다면, 이 코스는 통과입니다. +
+
+ +
+
+

🔭 여기까지 왔다면

+

+ 이제 "모니터링 있어요?"라는 질문에 층위를 나눠 답할 수 있게 됐어요. 메트릭으로{' '} + 무엇이 느린지 알아채고, 트레이스로 구간을 좁히고, 로그로{' '} + 왜 느린지를 확정한다 — 그리고 그 위에 증상 기반 알림과 SLO라는 + 약속을 얹는다. 그런데 관측이 잘 될수록 눈에 밟히는 순간이 하나 있죠. 바로{' '} + 배포하는 순간이에요. 새 버전을 내보낼 때마다 에러율 그래프가 + 움찔한다면, 다음 과제는 배포 자체를 안전하게 만드는 것. 다음{' '} + 무중단 배포와 롤백{' '} + 코스에서, 오늘 만든 계기판을 보며 트래픽을 옮기는 법을 이어서 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv3PerformanceProfilingPage.jsx b/frontend/src/pages/levels/Lv3PerformanceProfilingPage.jsx new file mode 100644 index 0000000..3645ca4 --- /dev/null +++ b/frontend/src/pages/levels/Lv3PerformanceProfilingPage.jsx @@ -0,0 +1,444 @@ +// 이 파일이 하는 일: "성능 측정과 프로파일링" 코스 — 감으로 하는 최적화가 왜 실패하는지에서 +// 출발해, 백분위 지표·부하 테스트·JVM 프로파일링·병목 진단·상시 관측·성능 예산까지 7개 섹션으로 +// 안내하는 정적 학습 페이지. 프레이밍은 "추측하지 말고 측정하라" — 서비스를 이미 만들어 본 +// 수습이, 잘 만드는 법(설계·성능·보안)으로 넘어가는 고급 과정의 성능 편이다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_LOOP = `느낌으로 하는 최적화 측정으로 하는 최적화 +───────────────────── ───────────────────── +"여기가 느릴 것 같아" ① 측정: 지금 얼마나 느린가? (기준선) +→ 일주일 코드 수술 ② 분석: 어디서 시간을 쓰는가? (프로파일) +→ ...별로 안 빨라짐 ③ 개선: 가장 큰 병목 '하나만' 고친다 +→ 코드만 복잡해짐 ④ 재측정: 정말 빨라졌나? 숫자로 확인 + → ①로 돌아가 반복 + +핵심: 병목은 대개 예상과 다른 곳에 있다. +"쿼리가 느리겠지" 했는데 직렬화가 범인이고, +"코드가 느리겠지" 했는데 커넥션 풀 대기가 범인인 일이 흔하다. +그래서 순서가 생명 — 고치기 전에 반드시 잰다.`; + +const CODE_PERCENTILE = `어떤 API의 응답 시간 100건 (ms): + 대부분 50ms 근처, 그런데 5건이 2,000ms + + 평균 = 약 147ms ← "음, 나쁘지 않네?" (거짓말) + p50 = 52ms ← 절반의 사용자는 52ms 이하 + p95 = 1,980ms ← 상위 5%는 2초 가까이 기다림! + p99 = 2,050ms ← 100명 중 1명은 2초 넘게 기다림 + +p95 = "요청 100건을 느린 순으로 세웠을 때, 95번째까지는 이 시간 안에 끝났다" +평균은 소수의 아주 느린 요청을 다수의 빠른 요청 속에 숨겨 버린다. +사용자가 체감하는 '느리다'는 평균이 아니라 꼬리(p95·p99)에서 온다. +하루 100만 요청이면 p99 밖의 1%만 해도 1만 건 — 결코 적지 않다.`; + +const CODE_K6 = `// load-test.js — k6로 핵심 API에 현실적인 부하 만들기 +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 10 }, // ① 웜업: 가볍게 몸 풀기 (JIT·캐시 덥히기) + { duration: '1m', target: 100 }, // ② 램프업: 서서히 100 VU까지 올리기 + { duration: '3m', target: 100 }, // ③ 지속: 100 VU를 3분간 유지 (본 측정 구간) + { duration: '30s', target: 0 }, // ④ 램프다운 + ], + thresholds: { + http_req_duration: ['p(95)<300'], // p95가 300ms를 넘으면 테스트 실패! + http_req_failed: ['rate<0.01'], // 에러율 1% 미만 + }, +}; + +export default function () { + const res = http.get('http://localhost:8080/api/market/quotes/005930'); + check(res, { 'status 200': (r) => r.status === 200 }); + sleep(1); // 실제 사용자처럼 요청 사이에 숨 고르기 +} + +// 실행: k6 run load-test.js +// 끝나면 p90·p95 응답시간, 초당 요청수(TPS), 에러율이 요약되어 나온다.`; + +const CODE_PROFILER = `# async-profiler — 돌고 있는 JVM에 붙여서 30초간 CPU 샘플링 +./asprof -d 30 -f cpu-profile.html +# → 불꽃 그래프(flame graph) HTML 생성. 가로로 넓은 함수 = CPU를 많이 먹는 함수 + +# 할당(alloc) 프로파일 — "누가 객체를 이렇게 많이 만드나?" (GC 압박의 범인 찾기) +./asprof -e alloc -d 30 -f alloc-profile.html + +# 힙 덤프 — 메모리 누수 의심 시, 힙의 스냅샷을 떠서 분석 도구로 연다 +jcmd GC.heap_dump /tmp/heap.hprof + +# GC 로그 켜기 (JVM 옵션) — stop-the-world가 언제 얼마나 길었는지 기록 +-Xlog:gc*:file=gc.log:time,uptime + +# gc.log에서 이런 줄을 찾는다: +# Pause Young (Normal) ... 12.3ms ← 짧은 젊은 세대 수거, 정상 +# Pause Full ... 1843.7ms ← 1.8초 전체 멈춤! 이 동안 모든 요청이 정지 +# Full GC가 잦다면: 힙이 작거나, 누수가 있거나, 객체를 너무 많이 만드는 것.`; + +const CODE_POOL = `# 커넥션 풀 포화의 전형적 증상 — 코드는 멀쩡한데 p95만 치솟는다 +# HikariCP 기본 풀 크기: 10개. 동시 요청 100개가 들어오면? +# → 10개는 DB 작업, 90개는 '커넥션 나올 때까지 줄서기' +# → 프로파일러엔 안 잡힌다 (CPU를 안 쓰고 그냥 기다리니까) +# → 풀 대기 시간을 재는 지표를 따로 봐야 보인다 + +spring: + datasource: + hikari: + maximum-pool-size: 10 # 무작정 늘리면 DB가 먼저 쓰러진다 + connection-timeout: 3000 # 3초 기다려도 못 얻으면 예외 (무한 대기 금지) + +# 스레드 풀도 같은 원리 — 톰캣 기본 최대 200 스레드. +# 스레드 하나가 느린 외부 API를 '동기로' 3초씩 기다리면, +# 200개가 전부 기다림에 묶이는 순간 서버는 새 요청을 아예 못 받는다. +# 게이트웨이처럼 모든 트래픽이 지나는 길목에서 이게 터지면 전 서비스가 멈춘다.`; + +const CODE_MICROMETER = `// Micrometer — 코드 안에 '측정 지점'을 심어 두는 표준 계측 라이브러리 +// Spring Boot Actuator를 넣으면 기본 지표(HTTP·JVM·풀)는 공짜로 나온다. +// 우리 서비스 고유의 지표는 직접 심는다: + +@Service +public class QuoteService { + private final Counter cacheMiss; // ① 카운터: 늘어나기만 하는 누적값 + private final Timer fetchTimer; // ② 타이머: 횟수 + 걸린 시간 분포(백분위까지) + + public QuoteService(MeterRegistry registry, TickBuffer buffer) { + this.cacheMiss = registry.counter("quote.cache.miss"); + this.fetchTimer = registry.timer("quote.fetch"); + // ③ 게이지: '지금 이 순간'의 값을 들여다보는 창 (오르내림) + registry.gauge("tick.buffer.size", buffer, TickBuffer::size); + } + + public Quote fetch(String symbol) { + return fetchTimer.record(() -> { // 이 블록의 소요 시간이 자동 기록 + Quote hit = cache.get(symbol); + if (hit != null) return hit; + cacheMiss.increment(); // 캐시 미스 1건 추가 + return loadFromUpstream(symbol); + }); + } +} + +// 구분법: 몇 번? → 카운터 / 얼마나 걸렸나? → 타이머 / 지금 얼마나? → 게이지`; + +const CODE_BUDGET = `# 성능 예산(performance budget) — 기능마다 미리 정해 두는 '속도의 상한선' +기능 예산(p95) 근거 +────────────────────────────────────────────────────── +시세 단건 조회 API 150ms 화면 갱신 체감 한계 +관심종목 목록 (로그인 직후) 300ms 첫 화면 이탈률과 직결 +공시 검색 800ms '검색'은 사용자가 조금 기다려 줌 +SSE 첫 tick 도착 2s 연결 후 빈 화면 허용 한도 + +# CI에 회귀 감시 붙이기 — 예산을 어기면 머지가 막힌다 +# (k6 thresholds가 곧 예산의 코드화 — 섹션 3의 p(95)<300이 그것) +perf-check: + script: + - k6 run --quiet load-test.js # thresholds 위반 시 exit code 99 + rules: + - if: $CI_MERGE_REQUEST_ID # MR마다 자동 실행 + +# 예산 안에 들어왔으면? 거기서 멈춘다. +# 150ms 예산에 120ms면 충분 — 80ms로 줄이는 일주일은 다른 기능에 쓰는 게 낫다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '측정이 먼저' }, + { n: 2, label: '무엇을 재나' }, + { n: 3, label: '부하 테스트' }, + { n: 4, label: 'JVM 프로파일링' }, + { n: 5, label: '앱 병목' }, + { n: 6, label: '상시 관측' }, + { n: 7, label: '성능 예산' }, +]; + +export default function Lv3PerformanceProfilingPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 고급 과정
+

📈 성능 측정과
프로파일링

+

+ 추측하지 말고 측정하라 — 병목은 늘 예상 밖에 숨어 있습니다.{' '} + "여기가 느릴 것 같은데"라는 감으로 시작한 최적화는 대개 엉뚱한 곳을 수술하고 + 끝나요. 이 코스에선 측정→분석→개선→재측정의 루프를 몸에 익히고, + 부하 테스트로 숫자를 얻고, 프로파일러로 범인을 지목하는 법을 배웁니다. +

+
+ 예상 소요 65분 + 실습: 부하 → p95 측정 → 병목 3개 지목 + 선수 지식: Spring Boot·DB 튜닝 기초 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 성능 이야기의 출발점은 유명한 격언 하나예요. "성급한 최적화는 만악의 + 근원이다." 왜 '악'까지 갈까요? 감으로 하는 최적화는 세 가지 비용을 + 치르기 때문입니다. ① 시간 — 병목이 아닌 곳을 고치느라 며칠을 + 쓰고, ② 복잡도 — 빨라지지도 않았는데 코드는 읽기 어려워지고,{' '} + ③ 착각 — "최적화했으니 빨라졌겠지"라는 검증 안 된 믿음이 + 남습니다. +

+ {CODE_LOOP} +

+ 병목이 예상과 다른 곳에 있는 건 우연이 아니에요. 우리가 코드를 읽을 때 눈에 + 띄는 건 복잡해 보이는 로직이지만, 실제 시간을 잡아먹는 건{' '} + 기다림 — DB 커넥션 대기, 느린 외부 API, 디스크 I/O — 인 경우가 + 많거든요. 기다림은 코드에 안 보입니다. 그래서 잽니다. +

+

+ 루프에서 하나 더 눈여겨볼 것: ③에서 병목을 '하나만' 고친다는 + 부분이에요. 한 번에 세 군데를 고치면, 재측정에서 빨라져도{' '} + 무엇이 효과였는지 알 수 없어요. 변인 통제 — 과학 실험과 + 똑같습니다. +

+
+ 오해 방지 "최적화를 미루라"는 말이지 "설계를 대충 하라"는 말이 아니에요. + 인덱스 없는 테이블, 무한정 쌓이는 캐시처럼 나중에 고치기 어려운 구조적 + 선택은 처음부터 제대로 하는 게 맞습니다. 미루는 건 '증거 없는 미세 + 튜닝'이지, '상식적인 설계'가 아니에요. +
+
+ +
+

+ 측정하기로 했으면 다음 질문은 "무엇을?"입니다. 가장 먼저 떠오르는 건{' '} + 평균 응답 시간이겠지만, 평균에는 치명적인 함정이 있어요. +

+ {CODE_PERCENTILE} +

+ 그래서 실무에선 백분위(percentile)를 봅니다.{' '} + p95는 "95%의 요청이 이 시간 안에 끝났다",{' '} + p99는 "99%가 이 안에 끝났다"는 뜻이에요. 느린 + 꼬리를 숨길 수 없죠. 지연시간 목표를 말할 땐 반드시{' '} + "p95 300ms"처럼 백분위로 말하는 습관을 들이세요. +

+

+ 지연시간(latency) 말고도 함께 봐야 할 기본 지표가 셋 있습니다.{' '} + ① 처리량(throughput) — 초당 몇 건을 처리하나(TPS),{' '} + ② 에러율 — 빨라도 실패하면 의미 없으니까,{' '} + ③ 자원 사용률 — CPU·메모리·커넥션이 얼마나 차 있나. 넷은 서로를 + 비추는 거울이에요. 예를 들어 에러율이 0인데 p95만 치솟는다면 "죽진 않았지만 + 어딘가에서 줄을 서고 있다"는 신호입니다. +

+
+ 레이턴시 vs 스루풋 이 둘은 종종 맞바꿈(trade-off) 관계예요. + 요청을 모아서 한꺼번에 처리(배치)하면 처리량은 오르지만 개별 요청의 대기는 + 길어지고, 반대로 매 건 즉시 처리하면 지연은 짧지만 총 처리량이 줄 수 있어요. + "무엇이 더 중요한 API인가"를 먼저 정해야 튜닝의 방향이 정해집니다 — 시세 + 스트림은 지연이, 야간 정산 배치는 처리량이 왕이죠. +
+
+ +
+

+ p95를 재려면 요청이 많이 필요해요. 혼자 브라우저로 새로고침 몇 번 해서는 아무것도 + 알 수 없죠. 그래서 부하 테스트 도구로 가상 사용자(VU)를 만들어 + 동시에 요청을 퍼붓습니다. 대표 주자는 k6(스크립트가 JS라 + 가볍고 CI 친화적)와 JMeter(GUI 기반, 오래된 만큼 기능이 방대) + — 이 코스에선 k6로 갑니다. +

+ {CODE_K6} +

+ 시나리오의 4단계엔 각각 이유가 있어요. 웜업을 건너뛰면 JVM의 + JIT 컴파일러가 코드를 아직 기계어로 덥히기 전이라, 실제보다 훨씬 느린 숫자가 + 나옵니다(캐시도 비어 있고요). 램프업은 갑자기 100명을 때려 넣는 + 대신 서서히 올려서 "몇 명부터 무너지는가"를 관찰하게 해 주고,{' '} + 지속 구간이 진짜 측정 구간 — 안정 상태(steady state)의 p95가 + 우리가 원하는 숫자입니다. +

+

+ 한 가지 주의: SSE·스트리밍 엔드포인트는 일반 API처럼 재면 + 안 돼요. 요청-응답이 짧게 끝나는 게 아니라 연결을 오래 물고 있는{' '} + 모델이라, "응답 시간"보다 동시 연결 수를 몇 개까지 버티나,{' '} + 첫 이벤트가 도착할 때까지 얼마나 걸리나가 진짜 질문입니다. + 연결 1만 개가 각자 스레드를 하나씩 잡는 구조라면 그게 곧 한계가 되죠. +

+
+ 운영 서버에 쏘지 마세요 부하 테스트는 이름 그대로 서버를{' '} + 고의로 한계까지 미는 행위예요. 반드시 로컬이나 스테이징 같은 + 격리된 환경에서 하고, 공용 환경이라면 팀에 미리 알리세요. 남의 서비스에 무단으로 + 쏘면 공격(DoS)과 구분되지 않습니다. +
+
+ +
+

+ 부하 테스트가 "얼마나 느린가"를 알려 줬다면, 프로파일러는 + "어디서 느린가"를 알려 줍니다. JVM 진영의 사실상 표준은{' '} + async-profiler — 돌고 있는 프로세스에 붙여서 주기적으로 "지금 뭐 + 하고 있어?"를 묻고(샘플링), 그 답을 쌓아{' '} + 불꽃 그래프(flame graph)로 그려 줘요. 가로로 넓은 함수일수록 + CPU 시간을 많이 쓴 함수 — 넓은 막대를 따라가면 핫스팟이 나옵니다. +

+ {CODE_PROFILER} +

+ CPU만 보면 절반이에요. -e alloc으로{' '} + 할당 프로파일을 뜨면 "누가 객체를 쏟아내는가"가 보이는데, 객체를 + 많이 만들수록 GC가 자주 돌고, GC가 돌수록 앱이 자주 멈추니까요. 메모리가 계속 + 우상향으로 차오르며 안 내려온다면 누수 의심 —{' '} + 힙 덤프를 떠서 "어떤 타입의 객체가 몇 개 살아 있고, 누가 그걸 + 붙들고 있나"를 추적합니다. +

+

+ 마지막 조각은 GC 로그예요. GC 중에서도{' '} + stop-the-world — 수거하는 동안 모든 애플리케이션 + 스레드가 정지하는 구간 — 이 길면, 그 순간 들어온 요청 전부가 그만큼 + 지연됩니다. "p99만 이상하게 튀는" 미스터리의 단골 범인이 바로 이 Full GC예요. + 로그에서 Pause Full이 자주, 길게 보인다면 힙 크기· + 할당 패턴·누수를 순서대로 의심하세요. +

+
+ 샘플링이라 부담이 적다 async-profiler는 모든 호출을 기록하는 게 아니라 + 주기적으로 스냅샷을 뜨는 샘플링 방식이라 오버헤드가 작아요. + 그래서 부하 테스트 중인 서버에 붙여도 측정 자체가 결과를 크게 왜곡하지 않습니다 + — "측정이 대상을 바꾸면 안 된다"는 원칙에 맞는 도구죠. +
+
+ +
+

+ 프로파일을 떠 보면 병목은 대개 몇 가지 익숙한 얼굴로 나타나요. 첫째 그룹은{' '} + 데이터 접근 — 인덱스 없는 느린 쿼리, 목록 하나 그리는데 쿼리가 + N+1번 나가는 패턴(앞 강 DB 심화에서 다룬 그 녀석들), 그리고 + 거대한 객체 그래프를 통째로 JSON으로 바꾸는 과한 직렬화. 불꽃 + 그래프에서 직렬화 라이브러리가 넓게 차지하고 있다면, 응답에 안 쓰는 필드까지 + 퍼 나르고 있을 확률이 높습니다 — DTO로 필요한 만큼만 담으세요. +

+

+ 둘째 그룹이 더 교묘해요. 풀(pool) 포화 — 코드가 아니라 + '줄서기'가 병목인 경우입니다. +

+ {CODE_POOL} +

+ 이 유형이 무서운 이유는 CPU 프로파일에 안 잡히기 때문이에요. + 기다리는 스레드는 CPU를 안 쓰니까 불꽃 그래프에선 조용하죠. 그래서 풀 지표 + (활성 커넥션 수·대기 시간·대기 큐 길이)를 따로 봐야 합니다 — + 다음 섹션의 상시 관측이 필요한 이유이기도 해요. +

+

+ 셋째, 동기 블로킹 호출의 전염. 서비스 하나가 느려지는 건 사고가 + 아니지만, 그 느린 서비스를 모든 트래픽이 지나는 게이트웨이가 동기로 + 기다리면 이야기가 달라져요. 게이트웨이의 스레드 풀이 그 기다림에 전부 + 묶이는 순간, 멀쩡한 다른 서비스로 가는 요청까지 통째로 막힙니다. 한 칸의 정체가 + 고속도로 전체의 정체가 되는 거죠. 타임아웃을 짧게 걸고, 실패가 반복되면 아예 + 빠르게 포기하는 방어선이 필요합니다. +

+
+ 실습 힌트 이 코스의 실습 — "부하를 주고 p95를 잰 뒤 상위 병목 3개 지목" — + 에서 병목 후보를 찾을 땐 이 도감 순서로 훑으세요. ① 불꽃 그래프의 넓은 막대 + (쿼리·직렬화) → ② 풀 지표(커넥션·스레드 대기) → ③ GC 로그(Pause Full). 세 곳을 + 보면 열에 아홉은 걸립니다. +
+
+ +
+

+ 부하 테스트와 프로파일링은 일회성 검사예요. 건강검진처럼 + 유용하지만, 검진일 사이에 생기는 문제는 못 봅니다. 그래서 잘 만든 서비스는 + 지표를 상시로 내보내요 — 그 표준 도구가{' '} + Micrometer입니다. Spring Boot Actuator를 붙이면 HTTP 응답 + 시간·JVM 메모리·커넥션 풀 같은 기본 지표는 자동으로 나오고, 우리 도메인 고유의 + 지표는 코드에 직접 심습니다. +

+ {CODE_MICROMETER} +

+ 세 가지 계측기의 구분이 핵심이에요. 카운터는 늘어나기만 하는 + 누적값(총 캐시 미스 횟수, 총 에러 수) — 그 자체보다 증가 속도를 + 봅니다. 타이머는 횟수에 더해 소요 시간의 분포까지 + 기록해서 p95·p99를 뽑을 수 있고요. 게이지는 지금 이 순간의 값 + (버퍼 크기, 활성 연결 수)을 들여다보는 창이라 오르내립니다. "몇 번?"은 카운터, + "얼마나 걸렸나?"는 타이머, "지금 얼마나?"는 게이지 — 이 한 줄만 기억하세요. +

+
+ 다음 강으로 이어지는 다리 심어 둔 지표는 어딘가에 모아서 그래프로 보고, + 이상하면 알림을 받아야 비로소 쓸모가 있죠. 그 수집·시각화·경보의 세계 + (Prometheus·Grafana)가 모니터링 강의 주제입니다. 이번 강에서 + 지표를 심는 법을 익혀 두면, 다음 강에선 그걸{' '} + 읽고 대응하는 법으로 자연스럽게 이어져요. +
+
+ +
+

+ 마지막 질문은 의외로 이겁니다. "언제 멈추나?" 최적화는 중독성이 + 있어서, 기준이 없으면 끝없이 파고들게 돼요. 그래서 잘하는 팀은 기능마다{' '} + 성능 예산(performance budget) — "이 API는 p95 300ms까지 허용" — + 을 미리 정해 둡니다. 예산이 있으면 두 가지가 명확해져요. 예산을 + 넘으면 고쳐야 하고, 예산 안이면 멈춰도 됩니다. +

+ {CODE_BUDGET} +

+ 예산의 진짜 힘은 회귀(regression) 방지에서 나와요. 성능은 한 번 + 좋게 만들어 놓아도, 기능이 추가될 때마다 조금씩 무거워지다가 어느 날 갑자기 + "느려졌어요"가 터집니다. 그 '조금씩'을 잡으려면 사람의 기억이 아니라{' '} + CI가 감시해야 해요 — 섹션 3에서 본 k6의{' '} + thresholds가 바로 예산의 코드화입니다. MR마다 + 자동으로 부하를 걸고, p95가 예산을 넘으면 빌드를 깨뜨리는 거죠. +

+

+ 그리고 멈출 용기. 예산이 150ms인데 지금 120ms라면, 80ms로 줄이겠다고 일주일을 + 쓰는 건 최적화가 아니라 낭비예요. 그 일주일은 예산을 넘긴 다른 + API나, 아예 새로운 기능에 쓰는 게 팀 전체로 이득입니다. 성능 작업도 결국{' '} + 우선순위 게임 — 숫자가 있어야 그 판단이 가능해지고, 그래서 이 + 코스의 처음과 끝이 같은 문장인 거예요. 추측하지 말고, 측정하라. +

+
+ 예산은 사용자에게서 거꾸로 예산 숫자는 서버 사정이 아니라{' '} + 사용자 경험에서 역산하세요. "시세 화면은 0.15초 안에 떠야 + 거래에 지장이 없다" → 그게 API 예산이 됩니다. 기술이 아니라 제품이 기준을 정하는 + 것 — 고급 과정 전체를 관통하는 감각이에요. +
+
+ +
+
+

📈 여기까지 왔다면

+

+ 이제 "느려요"라는 말을 들었을 때 코드부터 뒤지는 대신, 측정→분석→개선→ + 재측정의 루프를 돌릴 수 있게 됐어요. p95로 말하고, k6로 부하를 만들고, + 불꽃 그래프와 GC 로그로 범인을 지목하고, Micrometer로 측정 지점을 심고, 성능 + 예산으로 멈출 지점까지 정하는 것 — 잘 만드는 개발자의 성능 감각을 손에 + 쥐었습니다. 그런데 서비스가 빨라지고 커질수록, 노리는 사람도 많아져요. 다음{' '} + 웹 보안 심화 — OWASP Top 10{' '} + 코스에서, 공격자의 눈으로 우리 서비스를 살펴보는 법을 이어서 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv3QueryOptimizationPage.jsx b/frontend/src/pages/levels/Lv3QueryOptimizationPage.jsx new file mode 100644 index 0000000..0218885 --- /dev/null +++ b/frontend/src/pages/levels/Lv3QueryOptimizationPage.jsx @@ -0,0 +1,490 @@ +// 이 파일이 하는 일: "N+1과 쿼리 최적화" 코스 — 목록 하나 여는데 쿼리 수백 개가 나가는 +// ORM의 조용한 재앙(N+1)을 눈으로 확인하는 것에서 출발해, fetch join·프로젝션·집계 최적화· +// 읽기 부하 분산·측정 도구·실전 리팩터링 흐름까지 7개 섹션으로 안내하는 정적 학습 페이지. +// 프레이밍은 "이미 서비스를 만들어 본 사람"이 대상인 고급 과정 — 돌아가는 코드를 넘어, +// 데이터가 쌓여도 버티는 코드를 만드는 감각을 기른다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_NPLUS1_LOG = `-- 게시글 목록 "20개"를 여는 순간, 하이버네이트 로그에 찍힌 것 +select p.id, p.title, p.author_id from post p limit 20; -- ① 목록 조회 1번 + +select u.* from users u where u.id = 1; -- ② 1행의 작성자 조회 +select u.* from users u where u.id = 7; -- 2행의 작성자 조회 +select u.* from users u where u.id = 3; -- 3행의 작성자 조회 +... (행마다 반복, 총 20번) + +-- 합계: 1 + 20 = 21번. 목록이 100개면 101번, 500개면 501번. +-- 처음(1번) 조회 뒤에 행 수(N)만큼 추가 쿼리가 터진다 — 그래서 'N+1'. +-- 코드에는 반복문 한 줄뿐인데, DB에는 폭탄이 떨어지고 있습니다.`; + +const CODE_LAZY = `// 범인은 버그가 아니라 '지연 로딩(LAZY)'이라는 정상 동작이에요 +@Entity +public class Post { + @ManyToOne(fetch = FetchType.LAZY) // 작성자는 '실제로 쓸 때' 가져온다 + private User author; +} + +// 목록을 돌며 작성자 이름을 꺼내는 순간마다 — +for (Post p : posts) { + rows.add(new PostRow(p.getTitle(), p.getAuthor().getName())); + // ↑ 여기서 매번 select 1번씩 나간다 +} + +// LAZY 자체는 좋은 기본값입니다(안 쓰면 안 가져오니까). +// 문제는 "목록 + 반복문 + 연관 접근"이라는 조합 — 이때만 N+1로 돌변해요.`; + +const CODE_SHOW_SQL = `# application.yml — 개발 환경에서 나가는 쿼리를 '눈으로' 보기 +spring: + jpa: + properties: + hibernate: + format_sql: true # SQL을 읽기 좋게 줄바꿈 +logging: + level: + org.hibernate.SQL: debug # 실행되는 SQL 전부 출력 + org.hibernate.orm.jdbc.bind: trace # 바인딩되는 파라미터 값까지 + +# 최적화의 첫걸음은 튜닝이 아니라 '관찰'입니다. +# 로그를 켜고 목록 화면을 한 번 열어 보세요 — 스크롤이 밀려 내려가는 +# select의 행렬을 본 순간, N+1은 더 이상 남의 얘기가 아니게 됩니다.`; + +const CODE_FETCH_JOIN = `// ① fetch join — "목록 가져올 때 작성자도 같이 실어 와" (쿼리 1번) +@Query("select p from Post p join fetch p.author where p.board = :board") +List findWithAuthor(@Param("board") String board); +// → select p.*, u.* from post p join users u on u.id = p.author_id +// 연관이 처음부터 채워져 있으니 반복문에서 추가 쿼리가 안 나간다. + +// ② @EntityGraph — 메서드 이름 쿼리를 유지하면서 함께 로딩 +@EntityGraph(attributePaths = "author") +List findByBoard(String board); +// JPQL을 직접 쓰기 아까운 단순 조회라면 이쪽이 깔끔해요.`; + +const CODE_BATCH = `# ③ default_batch_fetch_size — 지연 로딩을 IN 절로 '묶는' 전역 안전망 +spring: + jpa: + properties: + hibernate: + default_batch_fetch_size: 100 + +-- 적용 전: where u.id = 1 / where u.id = 7 / where u.id = 3 ... (N번) +-- 적용 후: where u.id in (1, 7, 3, 12, ...) (묶어서 1번) + +-- N+1이 '1+1'로 줄어든다. fetch join처럼 극적이진 않지만, +-- 미처 못 잡은 지연 로딩까지 전부 커버하는 보험이라 실무에선 거의 필수.`; + +const CODE_PAGING_TRAP = `// 함정: 컬렉션(OneToMany) fetch join + 페이징은 같이 못 간다 +@Query("select b from Board b join fetch b.posts") +Page findAll(Pageable pageable); + +// 실행하면 로그에 이 경고가 뜹니다: +// WARN HHH90003004: firstResult/maxResults specified with collection fetch; +// applying in memory! +// → "DB에서 못 자르니 '메모리에서' 자르겠다"는 뜻. +// 조인 결과는 자식 수만큼 행이 불어나 limit을 DB에 걸 수 없거든요. +// 전체를 다 퍼 올린 뒤 애플리케이션에서 자르므로, 데이터가 크면 OOM 직행. + +// 정리: ManyToOne(단건 연관)은 fetch join + 페이징 OK. +// OneToMany(컬렉션)의 페이징은 batch_size로 푸는 게 정석입니다.`; + +const CODE_PROJECTION = `// 화면엔 제목·작성자·작성일만 쓰는데 엔티티 통째로? — 프로젝션(DTO)으로 끊기 +public record PostRow(Long id, String title, + String authorName, LocalDateTime createdAt) {} + +@Query(""" + select new dev.awesomedev.board.PostRow( + p.id, p.title, u.displayName, p.createdAt) + from Post p join p.author u + where p.board = :board + order by p.createdAt desc + """) +Page findRows(@Param("board") String board, Pageable pageable); + +// select 절에 딱 4개 컬럼만 명시 — +// 본문(TEXT 컬럼)도, 작성자 엔티티의 나머지 필드도 아예 실려 오지 않는다. +// 연관을 '로딩'하는 게 아니라 필요한 값만 '조회'하니 N+1이 원천 봉쇄돼요.`; + +const CODE_COUNT_SPLIT = `// 페이지네이션의 count 쿼리는 따로, 가볍게 +@Query(value = """ + select p from Post p + join fetch p.author + where p.board = :board + """, + countQuery = "select count(p) from Post p where p.board = :board") +Page findPage(@Param("board") String board, Pageable pageable); + +// countQuery를 안 주면 본문 쿼리의 join을 count에도 그대로 끌고 갑니다. +// '전체 몇 건인지' 세는 데 조인은 필요 없는데도요. +// 목록 API가 느릴 때 범인이 본문이 아니라 count 쪽인 경우가 의외로 많아요.`; + +const CODE_EXISTS = `-- "댓글이 하나라도 있나?" — 있는지만 궁금한데 전부 세고 있진 않나요? + +-- 나쁨: 다 센다 (댓글 10만 개면 10만 행을 훑고 나서야 답한다) +select count(*) from comment where post_id = 42; + +-- 좋음: 하나 찾으면 바로 멈춘다 +select exists (select 1 from comment where post_id = 42); + +// Spring Data JPA라면 메서드 하나로 끝 — 내부적으로 위처럼 최적화됩니다. +boolean existsByPostId(Long postId);`; + +const CODE_PREAGG = `-- 무거운 GROUP BY를 요청마다 돌리지 말고, 미리 계산해 두기 + +-- 매 요청마다: 게시판 목록 열 때마다 글 수를 집계 (글 100만 건이면 매번 풀스캔) +select board_id, count(*) from post group by board_id; + +-- 미리 계산: 글을 쓰고 지울 때 카운터 컬럼을 함께 갱신해 둔다 +update board set post_count = post_count + 1 where id = :boardId; +-- 조회는 이제 board 테이블 한 줄 읽기로 끝. + +-- 트레이드오프: 쓰기가 한 단계 늘고, 카운터가 어긋날 위험을 관리해야 한다. +-- '읽기 1000번 : 쓰기 1번' 같은 비율이라면 남는 장사입니다.`; + +const CODE_READONLY = `// 읽기 전용 트랜잭션 — "이 안에선 아무것도 안 바꿀 거야"라고 선언하기 +@Transactional(readOnly = true) +public Page list(String board, Pageable pageable) { + return postRepository.findRows(board, pageable); +} + +// 효과 ① JPA가 더티 체킹용 스냅샷을 만들지 않는다 → 메모리·CPU 절약 +// 효과 ② 실수로 섞인 쓰기 로직이 플러시되지 않아 안전장치가 된다 +// 효과 ③ DB를 주(쓰기)/복제본(읽기)으로 나눈 구조라면, +// readOnly 트랜잭션을 복제본으로 라우팅해 쓰기 DB의 부하를 덜 수 있다`; + +const CODE_P6SPY = `# p6spy — 실제로 나간 쿼리를 파라미터까지 찍어 주는 감청 장치 (개발용) +# build.gradle 의존성 한 줄이면 JDBC 커넥션 사이에 끼어들어 전부 기록해요 +implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1' + +2026-07-20 14:02:11 | took 3ms | select p.id, p.title ... limit 20 +2026-07-20 14:02:11 | took 1ms | select u.* from users u where u.id = 1 +2026-07-20 14:02:11 | took 1ms | select u.* from users u where u.id = 7 +... (같은 모양의 쿼리가 주르륵 = N+1의 지문) + +# DB 쪽에서도 볼 수 있어요 — PostgreSQL 느린 쿼리 로그: +# log_min_duration_statement = 200 # 200ms 넘게 걸린 쿼리만 기록`; + +const CODE_COUNT_TEST = `// 쿼리 카운트 회귀 테스트 — 고친 N+1이 다시 살아나지 못하게 대못 박기 +@Test +void 게시글_목록은_쿼리_2번을_넘지_않는다() { + SQLStatementCountValidator.reset(); + + postService.list("free", PageRequest.of(0, 20)); + + SQLStatementCountValidator.assertSelectCount(2); // 본문 1 + count 1 +} + +// 몇 달 뒤 누군가 DTO에 필드 하나를 추가하며 무심코 지연 로딩을 건드리면? +// 배포 후 장애가 아니라, CI의 빨간불이 먼저 알려 줍니다. +// "빨라졌다"는 감상이 아니라 assert할 수 있는 숫자로 남기는 것 — 이게 고급의 태도예요.`; + +const CODE_FLOW = `실전 리팩터링 5단계 — admin-web 회원 목록 API를 예로 들면 + +① 측정 p6spy 켜고 /api/admin/users?page=0 호출 + → 쿼리 41번, 응답 480ms 확인 (감이 아니라 숫자) +② 원인 User → 최근 로그인 이력 → 권한 목록을 행마다 지연 로딩 + (1 + 20 + 20 구조) +③ 처방 화면에 쓰는 6개 컬럼만 DTO 프로젝션으로 조회, + 이력은 join으로 최신 1건만 +④ 재측정 쿼리 2번(목록 + count), 응답 60ms — 개선을 숫자로 증명 +⑤ 고정 assertSelectCount(2) 회귀 테스트 추가 → 재발 방지 + +순서가 핵심입니다. ③(처방)부터 시작하면 뭐가 나아졌는지 아무도 몰라요.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: 'N+1이란' }, + { n: 2, label: 'JPA에서 잡기' }, + { n: 3, label: '필요한 만큼만' }, + { n: 4, label: '집계·조인' }, + { n: 5, label: '읽기 부하' }, + { n: 6, label: '측정 도구' }, + { n: 7, label: '리팩터링 흐름' }, +]; + +export default function Lv3QueryOptimizationPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 고급 과정
+

🐢 N+1과 쿼리 최적화

+

+ 목록 하나 여는데 쿼리가 수백 개 나가고 있다면 — 코드가 아니라 + ORM이 숨긴 재앙일 가능성이 높아요. 개발 DB에선 멀쩡하다가 데이터가 쌓이면 + 기어가는 API, 그 뒤에는 거의 항상 N+1이 있습니다. 이 코스에선 + 쿼리를 눈으로 세는 법부터 fetch join·프로젝션으로 걷어내고, + 회귀 테스트로 재발까지 막는 흐름을 익힙니다. +

+
+ 예상 소요 55분 + 실습: 목록 API 쿼리 수 절반 이하로 + 선수 지식: JPA·SQL 기초 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 이름부터 풀어 봅시다. 목록을 가져오는 쿼리가 1번, 그리고 목록의{' '} + 행 수(N)만큼 연관 데이터를 가져오는 추가 쿼리가 터지는 패턴 — + 그래서 N+1 문제라고 불러요. 20건짜리 목록이면 21번, 500건이면 + 501번의 쿼리가 DB로 날아갑니다. +

+ {CODE_NPLUS1_LOG} +

+ 무서운 점은 이게 버그가 아니라는 것이에요. JPA의{' '} + FetchType.LAZY(지연 로딩)는 "연관 데이터는 실제로 + 쓸 때 가져온다"는 합리적인 기본값인데, 목록 + 반복문 + 연관 접근이라는 + 조합을 만나는 순간 행마다 쿼리를 쏘는 폭탄으로 돌변합니다. 코드는 어떤 예외도 던지지 + 않고, 화면도 멀쩡히 뜨죠. 그래서 '조용한 재앙'입니다. +

+ {CODE_LAZY} +

+ 잡으려면 먼저 보여야 합니다. 개발 환경에서 SQL 로그를 켜고 목록 + 화면을 한 번 열어 보세요. 같은 모양의 select가 + 주르륵 이어지는 광경 — 그게 N+1의 지문이에요. +

+ {CODE_SHOW_SQL} +
+ 개발 DB에선 왜 안 느릴까 로컬 DB는 데이터가 수십 건이고 네트워크 왕복이 + 0.1ms 수준이라 쿼리 100번도 티가 안 나요. 운영에선 데이터가 수만 건, DB까지의 + 왕복이 수 ms — 같은 코드가 다르게 늙습니다. "지금 빠르다"가 아니라 + "쿼리가 몇 번 나가나"로 판단하는 습관이 고급 과정의 출발점이에요. +
+
+ +
+

+ 처방은 세 갈래입니다. 첫째, fetch join — JPQL에서{' '} + join fetch를 쓰면 "목록을 가져올 때 연관도 같이 + 실어 와"가 되어, N+1이 쿼리 1번으로 접힙니다. 둘째,{' '} + @EntityGraph — JPQL을 직접 쓰지 않는 메서드 이름 쿼리에 + 같은 효과를 얹는 애너테이션이에요. +

+ {CODE_FETCH_JOIN} +

+ 셋째는 batch_size. 지연 로딩이 일어나긴 하되, 한 건씩이 아니라{' '} + where id in (...)으로 묶어서 가져오게 + 하는 전역 설정입니다. N+1이 '1+1'로 줄어들죠. fetch join만큼 극적이진 않지만, + 미처 못 찾은 지연 로딩까지 전부 커버하는 보험이라 실무에선 거의 + 기본으로 깔아 둡니다. +

+ {CODE_BATCH} +

+ 단, fetch join엔 유명한 함정이 하나 있어요. 컬렉션(OneToMany) fetch join과 + 페이징은 같이 못 갑니다. 조인하면 부모 행이 자식 수만큼 불어나{' '} + limit을 DB에 걸 수 없거든요. 하이버네이트는 경고를 + 찍고 전체를 메모리로 퍼 올린 뒤 애플리케이션에서 자릅니다 — + 데이터가 크면 그대로 메모리 장애입니다. +

+ {CODE_PAGING_TRAP} +
+ 즉시 로딩(EAGER)은 해법이 아니에요 "LAZY가 문제라면 EAGER로 바꾸면 + 되잖아?" — 함정입니다. EAGER는 그 연관을 안 쓰는 화면에서도 항상 + 가져오게 만들고, 쿼리가 어디서 몇 번 나가는지 예측을 더 어렵게 해요. 기본은 LAZY로 + 두고, 필요한 조회에만 fetch join·EntityGraph를 골라 쓰는 것이 + 정석입니다. +
+
+ +
+

+ N+1을 잡았다면 다음 질문은 이겁니다. "가져온 것 중에 실제로 쓰는 건 + 얼마나 되지?" 엔티티로 조회하면 모든 컬럼이 실려 옵니다 — 목록 화면엔 + 제목·작성자·날짜 3개만 쓰는데, 수십 KB짜리 본문(TEXT)까지 매 행마다 네트워크를 + 타고 오는 거죠. SELECT *의 대가는 전송량만이 아니라 + DB의 읽기량, JPA의 영속성 컨텍스트 관리 비용까지 포함입니다. +

+

+ 해법은 프로젝션(DTO 조회) — select 절에 화면이 쓰는 컬럼만 명시해서 + 곧바로 DTO로 받는 방식이에요. 연관 엔티티를 '로딩'하는 게 아니라 필요한 값만 + '조회'하니, N+1이 구조적으로 생길 수 없다는 보너스까지 따라옵니다. +

+ {CODE_PROJECTION} +

+ 목록 API의 숨은 비용이 하나 더 있어요. 페이지네이션의 count 쿼리입니다.{' '} + Page를 반환하면 전체 건수를 세는 쿼리가 자동으로 한 번 더 + 나가는데, 본문 쿼리의 join을 count까지 끌고 가면 집계가 필요 이상으로 무거워져요.{' '} + countQuery를 분리해 가볍게 만들어 줍니다. +

+ {CODE_COUNT_SPLIT} +
+ 판단 기준: 화면이 정한다 "엔티티 조회 vs 프로젝션"의 정답은 화면에 있어요. + 상세 페이지처럼 대부분의 필드를 쓰고 수정도 하는 곳은 엔티티가 자연스럽고, + 목록·통계처럼 일부 컬럼만 읽는 곳은 프로젝션이 맞습니다. admin-web의 + 목록 화면들을 떠올려 보세요 — 테이블에 뜨는 컬럼은 대여섯 개뿐입니다. +
+
+ +
+

+ 연관 데이터를 합치는 방법은 두 가지예요. DB 조인(SQL{' '} + join으로 DB가 합쳐서 반환)과{' '} + 애플리케이션 조인(두 번 조회한 뒤 자바 코드에서{' '} + Map으로 합치기). 기본은 DB 조인이 유리합니다 — + 인덱스를 타고, 네트워크를 한 번만 타니까요. 하지만 조인 결과가 크게 불어나는 경우나, + 상대가 다른 서비스의 DB라 조인 자체가 불가능한 경우(마이크로서비스!)엔 + 애플리케이션 조인이 답이 됩니다. 이때도 행마다 조회하면 N+1이니, 반드시{' '} + where id in (...)으로 한 번에 가져와서 + 합치세요. +

+

+ 무거운 집계는 미리 계산해 둘 수 있습니다. 게시판마다 글 수를 보여 + 주려고 요청마다 GROUP BY를 돌리는 대신, 글을 쓰고 + 지울 때 카운터 컬럼을 함께 갱신해 두면 조회는 한 줄 읽기로 끝나요. +

+ {CODE_PREAGG} +

+ 마지막으로 아주 흔한 낭비 하나 — 존재 여부만 궁금한데 전부 세는 것. + "댓글이 하나라도 있나?"에 count(*)를 쓰면 DB는 조건에 + 맞는 행을 끝까지 다 세고 나서야 답합니다. EXISTS는 + 첫 행을 찾는 순간 멈추죠. +

+ {CODE_EXISTS} +
+ 미리 계산의 청구서 카운터 컬럼 같은 사전 집계는 쓰기 경로가 복잡해지고, + 값이 어긋날 위험을 떠안는 거래예요. 동시에 여러 요청이 카운터를 갱신하면 + 경합도 생기고요. "읽기가 쓰기보다 압도적으로 많은가?"를 먼저 확인하고, 어긋남을 + 바로잡는 보정(배치 재계산 등) 수단까지 준비된 뒤에 도입하세요. +
+
+ +
+

+ 여기까지는 쿼리 하나하나를 다듬는 얘기였다면, 이번엔 시야를 넓혀 봅시다. 대부분의 + 서비스에서 트래픽의 압도적 다수는 읽기이고, 그중 상당수는{' '} + 방금 다른 사용자가 읽은 것과 똑같은 데이터예요. 자주 읽히고 덜 + 바뀌는 데이터라면 — 매번 DB에 갈 이유가 없습니다. 결과를 캐시에 + 올려 두고 DB를 아예 건너뛰는 거죠. 이 주제는 깊어서 다음 강좌(캐싱 전략) 하나를 + 통째로 씁니다. 여기선 "쿼리를 아무리 갈아도 안 되면, 쿼리를 안 하는 방법이 있다"는 + 출구를 기억해 두세요. +

+

+ 당장 적용할 수 있는 것은 읽기 전용 트랜잭션입니다. 조회 서비스에{' '} + @Transactional(readOnly = true)를 선언하면 JPA가 + 변경 감지(더티 체킹)용 스냅샷을 만들지 않아 메모리와 CPU를 아끼고, 나중에 DB를 + 주/복제본으로 나눌 때 읽기를 복제본으로 흘려보내는 라우팅 기준이 + 되어 줍니다. +

+ {CODE_READONLY} +

+ 그리고 비정규화 — 정규화 원칙을 알면서도 의도적으로{' '} + 중복을 허용하는 타협입니다. 댓글 테이블에 작성자 이름을 복사해 두면 목록 조회에서 + 조인이 사라지죠. 대가는 명확해요. 작성자가 이름을 바꾸면 복사된 모든 행을 따라 + 고치거나, "작성 당시 이름"이라는 정책으로 안 고치기로 결정해야 + 합니다. 섹션 4의 카운터 컬럼도 사실 비정규화의 한 형태예요. +

+
+ 순서가 있어요 비정규화·복제본은 강력하지만 되돌리기 어려운 구조 변경입니다. + 순서는 이렇게: ① N+1 제거 → ② 프로젝션·인덱스 → ③ readOnly → + ④ 캐시 → ⑤ 비정규화·복제본. 앞 단계를 건너뛰고 뒤를 먼저 하면, 느린 + 쿼리를 여러 곳에 복사해 두는 꼴이 됩니다. +
+
+ +
+

+ "최적화했더니 빨라진 것 같아요"는 고급 과정에선 통하지 않는 문장이에요. 필요한 건{' '} + 숫자입니다 — 요청 하나에 쿼리가 몇 번 나갔고, 몇 ms 걸렸는지. + 그 숫자를 주는 도구가 p6spy(애플리케이션 쪽에서 실제 나간 쿼리를 + 파라미터까지 기록)와 느린 쿼리 로그(DB 쪽에서 기준 시간을 넘긴 + 쿼리만 기록)입니다. +

+ {CODE_P6SPY} +

+ 그런데 측정보다 중요한 게 있어요. 재발 방지입니다. N+1은 고쳐도 + 다시 생깁니다 — 몇 달 뒤 누군가 DTO에 필드 하나를 추가하면서 무심코 지연 로딩을 + 건드리는 식으로요. 그래서 쿼리 카운트 회귀 테스트를 남깁니다. + "이 API는 쿼리 2번을 넘지 않는다"를 테스트로 박아 두면, 재발은 장애가 아니라 + CI의 빨간불로 먼저 나타나요. +

+ {CODE_COUNT_TEST} +
+ 전후 비교는 같은 조건에서 개선 전 측정과 개선 후 측정은 같은 데이터, + 같은 페이지 크기로 해야 의미가 있어요. 그리고 첫 요청은 커넥션 풀·JIT + 워밍업 때문에 유독 느리니, 몇 번 호출해 본 뒤의 값으로 비교하세요. "41번 → 2번, + 480ms → 60ms"처럼 쿼리 수와 시간을 함께 기록하는 습관이 좋습니다. +
+
+ +
+

+ 이제 배운 걸 하나의 흐름으로 엮어 봅시다. 실습 과제는 이렇습니다 —{' '} + admin-web이 부르는 목록 API 하나를 골라, 요청당 쿼리 수를 세고, + fetch join·프로젝션으로 절반 이하로 줄이기. 순서가 절대적으로 중요해요. + 처방부터 시작하면 뭐가 나아졌는지 증명할 수 없으니까요. +

+ {CODE_FLOW} +

+ 리팩터링 중에 과거의 유산을 만날 수 있어요. 예전에 누군가 N+1을 급히 막느라{' '} + FetchType.EAGER를 여기저기 박아 둔 경우입니다. + 당장은 동작해도, 그 엔티티를 쓰는 모든 조회가 연관을 끌고 다니게 + 되죠. 이런 즉시 로딩은 LAZY로 되돌리고, 정말 필요한 조회에만 fetch join을 달아{' '} + 균형을 되찾는 것까지가 이번 실습의 범위예요. +

+

+ 마지막으로, 멈출 지점을 정합시다. 쿼리 41번을 2번으로 줄이는 건 + 큰 성과지만, 2번을 1번으로 줄이려고 코드를 비틀기 시작하면 얻는 건 몇 ms고 잃는 건 + 가독성입니다. 최적화는 측정된 병목이 있을 때만 계속하고, 목표 + 숫자(예: 요청당 쿼리 3번 이하, p95 100ms 이하)에 닿으면 멈추는 것 — 그것도 + 설계 감각입니다. +

+
+ 하나만 골라서, 끝까지 실습에서 여러 API를 조금씩 건드리고 싶은 유혹이 + 올 거예요. 참으세요. API 하나를 골라 측정 → 처방 → 재측정 → 회귀 + 테스트까지 5단계를 완주하는 것이, 다섯 API를 어중간하게 만지는 + 것보다 백 배 남습니다. 완주 경험이 곧 다음 API의 속도가 돼요. +
+
+ +
+
+

🐢 여기까지 왔다면

+

+ 이제 목록 API가 느릴 때 "DB가 느린가 봐요" 대신, 로그를 켜 쿼리를 세고, + fetch join과 프로젝션으로 걷어내고, 회귀 테스트로 대못을 + 박는 흐름이 손에 잡혔을 거예요. 거북이(🐢)는 쿼리 습관의 문제였지, DB의 + 잘못이 아니었던 거죠. 그런데 섹션 5에서 열어 둔 출구가 하나 남았습니다 — 아무리 + 다듬어도 매 요청마다 DB에 가는 것 자체가 아까운 데이터는요? 다음{' '} + 캐싱 전략 — 로컬과 Redis{' '} + 강좌에서, DB 앞에 기억을 두는 기술과 그 대가(무효화라는 난제)를 이어서 다룹니다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv3TransactionIsolationPage.jsx b/frontend/src/pages/levels/Lv3TransactionIsolationPage.jsx new file mode 100644 index 0000000..9a82378 --- /dev/null +++ b/frontend/src/pages/levels/Lv3TransactionIsolationPage.jsx @@ -0,0 +1,500 @@ +// 이 파일이 하는 일: "트랜잭션과 격리 수준" 코스 — 동시에 밀려드는 요청 속에서 +// 돈과 데이터를 지키는 규칙을 7개 섹션으로 안내하는 정적 학습 페이지. +// 프레이밍은 "서비스를 만들어 본 사람의 다음 고민" — 기능은 도는데, 두 사용자가 +// 같은 잔고를 '동시에' 건드리면 무슨 일이 벌어지는가? ACID에서 출발해 +// 동시성 이상 현상 → 격리 수준 4단계 → 잠금 → 데드락 → 스프링 실전 → 분산 환경의 +// 한계까지, 포트폴리오 매수 로직을 실험대 삼아 끝까지 걸어간다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_ACID = `트랜잭션(Transaction) = "전부 되거나, 전부 안 되거나"의 작업 묶음 + +A · 원자성(Atomicity) 묶음 안의 작업은 쪼개지지 않는다. + 중간에 실패하면? 전부 없던 일로(롤백). +C · 일관성(Consistency) 트랜잭션 전후로 규칙(잔고>=0, 제약조건)이 깨지지 않는다. +I · 격리성(Isolation) 동시에 도는 트랜잭션끼리 서로의 '중간 상태'를 보지 않는다. + ← 이 코스의 주인공. 완벽한 격리는 비싸서 '수준'을 고른다. +D · 지속성(Durability) 커밋된 결과는 서버가 꺼져도 남는다(WAL 로그 → 디스크). + +커밋(COMMIT) = "이 묶음, 확정" — 이후엔 누구나 이 결과를 본다. +롤백(ROLLBACK) = "이 묶음, 전부 취소" — 처음부터 없었던 것처럼.`; + +const CODE_BUY = `// 포트폴리오 매수 — @Transactional 경계가 곧 '원자 단위' +@Service +public class PortfolioService { + + // 학습 포인트: 이 메서드 전체가 하나의 트랜잭션. + // ①②③ 중 하나라도 예외가 터지면 셋 다 롤백된다. + @Transactional + public void buy(Long userId, String ticker, int qty, long price) { + Account acc = accountRepository.findByUserId(userId); + + long cost = (long) qty * price; + if (acc.getBalance() < cost) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잔고 부족"); + } + + acc.withdraw(cost); // ① 잔고 차감 + positionRepository.addQuantity(userId, ticker, qty); // ② 보유 수량 증가 + tradeLogRepository.save(TradeLog.buy(userId, ticker, qty, price)); // ③ 체결 기록 + } // ← 여기서 정상 반환하면 커밋, 예외가 통과하면 롤백 +}`; + +const CODE_ANOMALY = `-- 동시성 3대 이상 현상: 트랜잭션 A와 B가 '동시에' 돌 때 +-- (시간은 위에서 아래로 흐른다) + +[더티 리드 Dirty Read] — 커밋 안 된 값을 읽어 버림 +A: UPDATE account SET balance = 0 WHERE id = 7; -- 아직 커밋 전! +B: SELECT balance FROM account WHERE id = 7; -- 0으로 보임 +A: ROLLBACK; -- 없던 일이 됨 +→ B는 '세상에 존재한 적 없는 값'으로 매수 가능 여부를 판단했다. + +[논-리피터블 리드 Non-Repeatable Read] — 같은 행을 두 번 읽으니 값이 다름 +B: SELECT balance ...; -- 100만원 +A: UPDATE ... balance = 30만원; COMMIT; +B: SELECT balance ...; -- 30만원 (아까랑 다르다!) +→ B가 "잔고 확인 → 차감" 사이에 발밑이 바뀌었다. + +[팬텀 리드 Phantom Read] — 같은 조건으로 두 번 세니 '행 개수'가 다름 +B: SELECT count(*) FROM position WHERE user_id = 7; -- 3건 +A: INSERT INTO position ...; COMMIT; +B: SELECT count(*) ...; -- 4건 (유령 등장) +→ "보유 종목 10개 제한" 같은 개수 기반 규칙이 뚫린다.`; + +const CODE_LEVELS = `격리 수준 4단계 — 위로 갈수록 빠르고, 아래로 갈수록 안전하다 + +수준 더티 리드 논-리피터블 팬텀 리드 +───────────────────────────────────────────────────── +READ UNCOMMITTED 발생 발생 발생 +READ COMMITTED 차단 발생 발생 ← Postgres 기본값 +REPEATABLE READ 차단 차단 차단* ← MySQL(InnoDB) 기본값 +SERIALIZABLE 차단 차단 차단 + +* 표준은 REPEATABLE READ에서 팬텀을 허용하지만, Postgres는 + 스냅샷(MVCC) 방식이라 이 수준에서 팬텀까지 막힌다. +* Postgres는 READ UNCOMMITTED를 골라도 내부적으로 + READ COMMITTED로 동작한다 — 더티 리드는 아예 불가능.`; + +const CODE_TRY = `-- 직접 실험: psql 두 개를 띄워 놓고 (터미널 A / 터미널 B) + +-- [터미널 A] +BEGIN; +UPDATE account SET balance = balance - 500000 WHERE user_id = 7; +-- 아직 COMMIT 하지 않고 멈춰 둔다 + +-- [터미널 B] +BEGIN; +SELECT balance FROM account WHERE user_id = 7; +-- READ COMMITTED: A가 커밋 전이므로 '옛 값'이 보인다 (더티 리드 차단) + +-- [터미널 A] +COMMIT; + +-- [터미널 B] +SELECT balance FROM account WHERE user_id = 7; +-- 값이 바뀌어 보인다! 같은 트랜잭션 안에서 두 번 읽은 값이 다름 +-- = 논-리피터블 리드를 눈으로 확인 + +-- 이제 B를 REPEATABLE READ로 다시: +BEGIN ISOLATION LEVEL REPEATABLE READ; +-- 같은 실험을 반복하면, A가 커밋해도 B에겐 '처음 스냅샷'이 유지된다.`; + +const CODE_LOCK = `-- 비관적 락: "어차피 충돌할 테니, 먼저 잠그고 시작한다" +SELECT balance FROM account + WHERE user_id = 7 + FOR UPDATE; -- 이 행에 배타락. 다른 트랜잭션의 FOR UPDATE는 여기서 '대기' + +-- 공유락(FOR SHARE): 읽기끼리는 동시에 OK, 쓰기만 막는다. +-- 배타락(FOR UPDATE): 읽기용 잠금조차 못 들어온다. 나 혼자 쓴다. + +// JPA에서는 이렇게 쓴다 +@Lock(LockModeType.PESSIMISTIC_WRITE) // → SELECT ... FOR UPDATE +@Query("select a from Account a where a.userId = :userId") +Account findForUpdate(Long userId);`; + +const CODE_VERSION = `// 낙관적 락: "충돌은 드물 테니, 일단 진행하고 저장할 때 검사한다" +@Entity +public class Account { + @Id private Long id; + private long balance; + + @Version // ← 이 한 줄이 핵심 + private long version; // UPDATE 때마다 자동으로 +1 +} + +// JPA가 실제로 날리는 SQL: +// UPDATE account SET balance = ?, version = version + 1 +// WHERE id = ? AND version = ? ← 내가 읽었을 때의 버전과 같아야만 성공 +// +// 그 사이 누가 먼저 고쳤다면? version이 달라져 0건 갱신 +// → OptimisticLockException → 잡아서 재시도하거나 사용자에게 알린다. + +// 선택 기준: +// 충돌이 잦다(인기 종목 잔고, 재고 1개) → 비관적 락: 기다리는 게 싸다 +// 충돌이 드물다(내 프로필, 내 메모) → 낙관적 락: 안 잠그는 게 싸다`; + +const CODE_DEADLOCK = `-- 데드락의 전형: 서로가 가진 것을 서로 기다린다 +[트랜잭션 A] [트랜잭션 B] +UPDATE account WHERE id = 1; UPDATE account WHERE id = 2; + (1번 행 잠금 획득) (2번 행 잠금 획득) +UPDATE account WHERE id = 2; UPDATE account WHERE id = 1; + → B가 잡고 있어 대기... → A가 잡고 있어 대기... + +둘 다 영원히 기다린다 = 데드락(교착 상태). +Postgres는 이를 감지해 한쪽을 강제 롤백시킨다: +ERROR: deadlock detected + +-- 예방 1순위: 잠금 순서 일관성 +-- "계좌를 여러 개 잠글 땐 항상 id 오름차순으로" 같은 규칙 하나면 +-- 위 시나리오는 원천적으로 불가능해진다 (둘 다 1번부터 잡으므로). + +-- 보조 수단: 타임아웃 + 재시도 +SET lock_timeout = '3s'; -- 잠금 대기 3초 넘으면 포기 +-- 애플리케이션에선 데드락/타임아웃 예외를 잡아 +-- 짧은 무작위 대기 후 트랜잭션 전체를 재시도한다.`; + +const CODE_PROPAGATION = `// 전파(Propagation): "이미 트랜잭션이 도는 중에 또 @Transactional을 만나면?" + +@Transactional // 기본값 = REQUIRED +public void buy(...) { + ... + auditService.record("BUY", ...); // 이 안의 트랜잭션은 어떻게 될까? +} + +// REQUIRED (기본): 이미 있으면 '합류', 없으면 새로 시작. +// → buy()와 record()가 한 배를 탄다. 하나 터지면 둘 다 롤백. +@Transactional(propagation = Propagation.REQUIRED) +public void record(String action, ...) { ... } + +// REQUIRES_NEW: 바깥을 잠시 멈추고 '독립된 새 트랜잭션'을 판다. +// → 매수가 롤백돼도 감사 기록은 남는다. "실패도 기록해야 하는" 로그에 적합. +@Transactional(propagation = Propagation.REQUIRES_NEW) +public void record(String action, ...) { ... } + +// readOnly: 쓰지 않는 조회는 선언하자. +// 플러시 생략·스냅샷 비보관으로 가벼워지고, 실수로 쓰면 막아 준다. +@Transactional(readOnly = true) +public PortfolioSummary summary(Long userId) { ... }`; + +const CODE_SELF = `// 함정: self-invocation — 같은 클래스 안에서 자기 메서드를 부르면 +// @Transactional이 '조용히' 무시된다 +@Service +public class PortfolioService { + + public void buyAll(List orders) { + for (Order o : orders) { + this.buy(o); // ← 프록시를 거치지 않는 내부 호출! + } // buy()의 @Transactional은 적용되지 않는다 + } + + @Transactional + public void buy(Order o) { ... } +} + +// 왜? 스프링 트랜잭션은 '프록시(대리인)'가 메서드 앞뒤에서 +// BEGIN/COMMIT을 감싸 주는 방식. 바깥에서 들어온 호출만 프록시를 +// 통과하고, this.buy()는 대리인을 건너뛰고 본체를 직접 부른다. +// +// 해법: 트랜잭션이 필요한 메서드를 '다른 빈'으로 분리해 +// 주입받아 호출한다 — 호출이 반드시 프록시를 지나가게.`; + +const CODE_SAGA = `분산 환경: 하나의 DB 트랜잭션으로 묶을 수 없는 세계 + +[2PC가 어려운 이유] +서비스 6개가 각자의 스키마를 가진 우리 구조에서, "전원 준비됐나? → 전원 +커밋!"을 지휘하는 2단계 커밋(2PC)은 조정자가 죽는 순간 전원이 잠금을 +쥔 채 멈춘다. 느리고, 깨지기 쉽고, Kafka·HTTP 경계는 아예 참여 불가. + +[그래서 최종 일관성 Eventual Consistency] +auth 서비스: 자기 DB 트랜잭션만 확실히 커밋 + ↓ (커밋 후) Kafka 토픽 audit.events 로 이벤트 발행 +log 서비스: 이벤트를 소비해 자기 DB에 반영 +→ '지금 이 순간' 둘이 다를 수 있지만, '결국' 같아진다. + +[Saga 패턴] 긴 여정을 로컬 트랜잭션의 사슬로 쪼갠다 +주문 → 결제 → 배송 각자 커밋. 중간에 실패하면? +거꾸로 '보상 트랜잭션'(결제 취소, 주문 취소)을 실행해 되돌린다. + +[아웃박스 패턴] "DB 커밋과 이벤트 발행"을 원자적으로 +이벤트를 같은 DB의 outbox 테이블에 INSERT (본 작업과 한 트랜잭션!) +→ 별도 릴레이가 outbox를 읽어 Kafka로 발행 +→ "커밋은 됐는데 이벤트가 유실"되는 반쪽 상태를 없앤다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: 'ACID' }, + { n: 2, label: '동시성 문제' }, + { n: 3, label: '격리 수준 4단계' }, + { n: 4, label: '잠금(Lock)' }, + { n: 5, label: '데드락' }, + { n: 6, label: '스프링 실전' }, + { n: 7, label: '분산 환경' }, +]; + +export default function Lv3TransactionIsolationPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 고급 과정
+

🔒 트랜잭션과
격리 수준

+

+ 기능은 이미 돌아가요. 문제는 동시에 돌아갈 때 시작됩니다. + 두 요청이 같은 잔고를 0.001초 차이로 읽고 쓰면, 돈이 두 번 빠지거나 + 있지도 않은 돈으로 매수가 체결되죠. 이 코스는 동시에 밀려드는 요청 속에서 + 돈과 데이터를 지키는 규칙 — 트랜잭션, 격리 수준, 잠금, 그리고 그 규칙이 + 닿지 않는 분산 환경까지를 다룹니다. +

+
+ 예상 소요 60분 + 실습: 매수 로직에 동시 요청 걸기 + 선수 지식: SQL·Spring 기초 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ "잔고를 차감했는데 보유 수량을 늘리기 직전에 서버가 죽으면?" — 이 질문에 답하려고 + 트랜잭션이 있어요. 트랜잭션은 여러 개의 읽기·쓰기를 쪼개지지 않는 하나의 + 묶음으로 만드는 약속이고, 그 약속의 내용이 네 글자 ACID입니다. +

+ {CODE_ACID} +

+ 스프링에서 이 묶음의 경계를 긋는 도구가{' '} + @Transactional이에요. 메서드에 붙이면 "이 메서드의 + 시작이 BEGIN, 정상 반환이 COMMIT, 예외 통과가 ROLLBACK"이 됩니다. 즉{' '} + @Transactional 경계가 곧 원자 단위 — 잔고 차감·수량 증가·체결 기록이 + 함께 살거나 함께 죽어요. +

+ {CODE_BUY} +
+ 비유 트랜잭션은 계약서에 도장 찍기예요. 조항을 아무리 많이 + 고쳐 써도(쿼리 실행) 도장(COMMIT)을 찍기 전엔 효력이 없고, 마음이 바뀌면 찢어 버리면 + (ROLLBACK) 그만입니다. 도장을 찍는 순간부터는 — 지속성(D) — 불이 나도 계약은 남아요. +
+
+ +
+

+ 트랜잭션이 하나씩 차례로만 돈다면 격리(I)를 고민할 일이 없어요. + 하지만 그러면 서버는 한 번에 한 명만 상대하는 창구가 됩니다. 그래서 DB는 여러 + 트랜잭션을 겹쳐서 돌리고, 그 순간 세 가지 유령이 나타납니다. +

+ {CODE_ANOMALY} +

+ 이게 왜 포트폴리오·잔고에서 치명적일까요? 매수 로직의 뼈대는 늘{' '} + "읽고(잔고 확인) → 판단하고(충분한가?) → 쓴다(차감)"예요. 그런데 읽기와 + 쓰기 사이의 빈틈에 다른 트랜잭션이 끼어들면 — 잔고 100만원인 계좌에 + 80만원짜리 매수 요청 두 개가 동시에 들어와, 둘 다 "잔고 충분"이라 읽고 둘 다 + 차감해 버립니다. 결과는 잔고 -60만원. 코드 어디에도 버그는 없는데 + 돈이 새는, 동시성 특유의 사고죠. +

+
+ 테스트가 못 잡는 버그 이런 문제는 요청을 하나씩 보내는 단위 테스트로는 절대 + 재현되지 않아요. 타이밍이 곧 재현 조건이라, 운영에서 트래픽이 몰리는 + 날 처음 터집니다. 그래서 이 코스의 실습이 "동시 요청을 일부러 걸어 보는 것"이에요 — + 유령은 불러내 봐야 잡는 법을 배웁니다. +
+
+ +
+

+ 유령을 셋 다 막는 완벽한 격리(SERIALIZABLE)가 있는데 왜 전부 그걸 쓰지 않을까요? + 격리를 올릴수록 동시성을 잃기 때문입니다. 더 많이 잠그고, 더 자주 + 기다리고, 더 자주 "충돌했으니 다시 하세요"가 발생해요. 그래서 SQL 표준은 격리를 + 4단계로 나눠 어디까지 감수할지 고르게 했습니다. +

+ {CODE_LEVELS} +

+ Postgres의 기본값이 READ COMMITTED인 이유도 이 시소에 있어요. + 더티 리드만 막아도 "존재한 적 없는 값" 사고는 사라지는데, 그 이상은 MVCC 스냅샷 + 유지·직렬화 검사 비용이 붙거든요. 대부분의 조회 트래픽에는 READ COMMITTED가 + 충분히 안전하면서 가장 빠른 균형점입니다. 대신 "읽고 → 판단하고 → 쓰는" + 돈 계산 구간은 이 수준만으로 못 지켜요 — 그건 다음 섹션의 잠금이 맡습니다. +

+

+ REPEATABLE READ는 트랜잭션 시작 시점의 스냅샷을 끝까지 유지해 + "읽을 때마다 값이 다른" 문제를 없애고, SERIALIZABLE은 아예 "모든 + 트랜잭션이 한 줄로 선 것과 같은 결과"를 보장합니다. 대가는? 동시에 건드린 트랜잭션 + 중 하나가 직렬화 오류로 강제 롤백될 수 있어, 애플리케이션에 재시도 + 코드가 필수가 돼요. 공짜 안전은 없습니다. +

+ {CODE_TRY} +
+ 실습 포인트 위 psql 실험을 그대로 해 보세요. 같은 SELECT가 격리 수준에 따라 + 다른 값을 보여 주는 걸 눈으로 확인하는 순간, 격리 수준은 암기 항목이 + 아니라 손에 잡히는 스위치가 됩니다. 그다음 매수 API에 동시 요청을 걸어(간단히는 + 같은 curl 두 개를 &로 병렬 실행) 잔고가 어떻게 무너지는지 재현해 보세요. +
+
+ +
+

+ 격리 수준이 DB 전체의 기본 방어선이라면, 잠금은 위험한 구간에만 + 거는 정밀 방어예요. 종류는 두 축으로 나뉩니다. 먼저{' '} + 무엇을 허용하나: 공유락(읽기끼리는 동시 허용, 쓰기만 + 차단)과 배타락(나 말고 전부 차단). 다음은{' '} + 어디를 잠그나: 행 하나만 잠그는 행 잠금과 테이블 + 전체를 잠그는 테이블 잠금 — 좁게 잠글수록 다른 요청이 덜 기다립니다. +

+ {CODE_LOCK} +

+ 그런데 잠금엔 태도의 문제가 하나 더 있어요. "충돌이 날 것이다"라고 + 보고 미리 잠그는 비관적 락과, "충돌은 드물 것이다"라고 보고 저장 + 시점에 검사만 하는 낙관적 락. 낙관적 락은 DB 잠금을 아예 쓰지 않고{' '} + 버전 컬럼 하나로 구현합니다. +

+ {CODE_VERSION} +
+ 비유 비관적 락은 회의실 예약 — 들어가기 전에 잡아 두니 충돌은 + 없지만, 내가 안 쓰는 동안에도 남들이 못 씁니다. 낙관적 락은{' '} + 위키 문서 편집 — 자유롭게 고치되 저장할 때 "누가 먼저 고쳤네요, + 다시 확인하세요"를 받는 방식이죠. 트래픽의 성격(충돌 빈도)이 어느 쪽이 싼지를 정합니다. +
+
+ +
+

+ 잠금을 배우면 곧바로 잠금의 부작용을 만나요. 트랜잭션 A가 1번 계좌를 잡고 2번을 + 기다리는데, B는 2번을 잡고 1번을 기다린다면? 둘 다 영원히 기다립니다. + 이게 데드락(교착 상태) — 여러 행을 잠그는 로직(이체, 일괄 매수, + 리밸런싱)에서 나오는 전형적인 사고예요. +

+ {CODE_DEADLOCK} +

+ 예방의 왕도는 잠금 순서 일관성입니다. "여러 자원을 잠글 땐 언제나 + 같은 순서(예: id 오름차순)로"라는 팀 규칙 하나면, '서로 엇갈려 기다리는' 구조 자체가 + 만들어질 수 없어요. 데드락은 코드 한 줄의 버그가 아니라 두 코드 경로의 + 조합에서 나오므로, 개인기가 아닌 규칙으로 막는 게 맞습니다. +

+

+ 그래도 새는 경우를 위한 안전망이 타임아웃과 재시도예요. 잠금 대기에 + 상한(lock_timeout)을 두고, 데드락 감지로 롤백당한 + 트랜잭션은 짧은 무작위 대기 후 전체를 재시도합니다. 무작위를 섞는 + 이유는, 둘이 동시에 재시도하면 같은 충돌이 반복되기 때문이에요(엘리베이터 앞에서 + 양보하다 또 마주치는 그 상황). +

+
+ 재시도의 전제 = 멱등성 트랜잭션 전체가 롤백된 뒤의 재시도는 안전하지만, + 트랜잭션 바깥의 부수 효과(푸시 알림 발송, 외부 API 호출)까지 함께 + 재실행되면 알림이 두 번 갑니다. 재시도 루프를 짜기 전에 "이 묶음, 두 번 돌아도 + 같은 결과인가?"를 반드시 물어보세요. +
+
+ +
+

+ 이제 스프링으로 내려와 봅시다. 실전에서 가장 자주 갈리는 선택이{' '} + 전파(propagation) — "트랜잭션이 도는 중에 또 다른 @Transactional + 메서드를 부르면 합류시킬 것인가, 새로 팔 것인가"입니다. +

+ {CODE_PROPAGATION} +

+ 기준은 운명을 같이해야 하는가예요. 잔고 차감과 체결 기록은 한 배 + (REQUIRED)를 타야 하지만, "매수 시도가 있었다"는 감사 기록은 매수가{' '} + 실패해도 남아야 하니 독립된 배(REQUIRES_NEW)가 맞습니다. 한편{' '} + readOnly = true는 조회 전용 선언인데, 주의할 함정이 + 하나 — 트랜잭션이 메서드 끝에서 닫히므로, 반환한 엔티티의 지연 로딩 + 필드를 컨트롤러에서 뒤늦게 건드리면 세션이 이미 없어 예외가 터져요. 필요한 데이터는 + 트랜잭션 안에서 다 채워(fetch join, DTO 변환) 나가야 합니다. +

+

+ 마지막으로, 스프링 트랜잭션 최대의 함정 — self-invocation입니다. +

+ {CODE_SELF} +
+ 원리로 기억하기 @Transactional은 마법이 아니라{' '} + 프록시(대리인) 객체가 메서드 호출을 가로채 BEGIN/COMMIT을 감싸 주는 + 것뿐이에요. 그래서 대리인을 거치지 않는 this.메서드() + 호출엔 아무 일도 일어나지 않습니다. "왜 롤백이 안 되지?"의 상당수가 이 한 줄에서 + 나와요 — 어노테이션이 안 먹는 것 같으면 호출 경로부터 의심하세요. +
+
+ +
+

+ 여기까지의 무기는 전부 한 DB 안에서 통해요. 그런데 서비스가 + 커지면 — 우리처럼 auth·market·log가 각자의 스키마를 갖는 마이크로서비스 + 구조라면 — "auth의 커밋과 log의 커밋을 한 트랜잭션으로" 묶을 방법이 마땅치 않습니다. + 교과서의 답인 2단계 커밋(2PC)은 모든 참여자가 잠금을 쥔 채 조정자의 + 신호를 기다리는 구조라, 조정자 장애 = 전원 멈춤이고 Kafka·HTTP 같은 경계는 아예 + 참여할 수도 없어요. +

+ {CODE_SAGA} +

+ 그래서 분산 세계의 현실적 선택은 최종 일관성(eventual consistency) + 입니다. 각 서비스는 자기 DB 트랜잭션만 확실히 지키고, 서비스 사이는 + 이벤트로 잇죠. 우리 플랫폼이 감사 로그를 auth에서 직접 log DB에 쓰지 않고{' '} + Kafka 토픽 audit.events로 발행 → + log 서비스가 소비해 저장하는 것이 정확히 이 패턴이에요. 두 DB가 '지금 이 순간'은 + 다를 수 있어도 '결국' 같아집니다. +

+

+ 더 긴 여정(주문→결제→배송)은 Saga 패턴 — 로컬 트랜잭션의 사슬 + + 실패 시 거꾸로 되돌리는 보상 트랜잭션으로 풀고, "DB 커밋은 됐는데 + 이벤트 발행 직전에 죽으면?"이라는 반쪽 상태는 아웃박스 패턴 — + 이벤트를 본 작업과 같은 트랜잭션으로 outbox 테이블에 넣고 별도 + 릴레이가 발행 — 으로 막습니다. 이번엔 맛보기지만, 이름과 문제의식만 쥐고 있어도 + 설계 회의에서 길을 잃지 않아요. +

+
+ 격리 감각의 확장 최종 일관성을 택했다면 "아직 반영 전인 순간"이 정상 상태라는 + 뜻이에요. 그 순간을 사용자에게 어떻게 보여 줄지(처리 중 표시, 잠깐의 지연 안내)까지가 + 설계입니다. 트랜잭션 공부의 끝은 결국 "어디까지의 불일치를 허용할 것인가"를 + 스스로 정하는 감각이에요. +
+
+ +
+
+

🔒 여기까지 왔다면

+

+ 이제 "동시에 들어오면 어떻게 되지?"라는 질문에 층위별 답을 갖게 됐어요. + 묶음의 경계는 트랜잭션(ACID)이, 기본 방어는 격리 수준이, 정밀 방어는 잠금이, + 잠금의 부작용은 순서 규칙과 재시도가, 그리고 DB 하나를 넘어서는 순간은 + 최종 일관성과 Saga·아웃박스가 맡습니다. 그런데 격리와 잠금으로 정확해진{' '} + 쿼리가, 이번엔 느려서 문제를 일으키기 시작해요. 조회 하나에 쿼리가 + 수백 번 나가는 N+1, 인덱스를 타지 않는 조건절 — 다음{' '} + N+1과 쿼리 최적화{' '} + 코스에서, 정확함 위에 빠름을 얹는 법을 이어서 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv3WebSecurityPage.jsx b/frontend/src/pages/levels/Lv3WebSecurityPage.jsx new file mode 100644 index 0000000..4792e3f --- /dev/null +++ b/frontend/src/pages/levels/Lv3WebSecurityPage.jsx @@ -0,0 +1,459 @@ +// 이 파일이 하는 일: "웹 보안 심화 — OWASP Top 10" 코스 — 서비스를 만들어 본 수습이 +// 공격자의 눈으로 자기 서비스를 다시 보고, 인젝션·XSS·CSRF·접근 통제·설정·남용까지 +// 7개 섹션으로 계층 방어(defense in depth)의 감각을 익히는 정적 학습 페이지. +// 프레이밍은 "우리 서비스에 실제로 있었던 구멍" — 무인증 쓰기 남용 차단, 로그인 IP +// 레이트리밋, 프리미엄 조회 로그인 게이팅 같은 우리 커밋 이력을 교재로 삼는다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_TOP10 = `OWASP Top 10 (2021) — 가장 자주, 가장 아프게 뚫리는 10가지 +──────────────────────────────────────────────────────── +A01 접근 통제 결함 내 것이 아닌 자원에 손이 닿는다 → 섹션 5 +A02 암호화 실패 비밀이 평문으로 흐르거나 저장된다 +A03 인젝션 입력이 '코드'로 해석된다 (SQL·XSS) → 섹션 2·3 +A04 안전하지 않은 설계 기능 자체가 남용 가능하게 설계됨 → 섹션 7 +A05 보안 설정 오류 기본값·디버그·열린 포트 → 섹션 6 +A06 취약한 구식 컴포넌트 낡은 라이브러리가 뒷문이 된다 → 섹션 6 +A07 식별·인증 실패 무차별 대입, 약한 세션 관리 → 섹션 7 +A08 SW·데이터 무결성 실패 검증 없는 업데이트·역직렬화 +A09 로깅·모니터링 실패 뚫렸는데 아무도 모른다 → 섹션 7 +A10 SSRF 서버가 공격자 대신 요청을 보낸다 + +핵심: 이건 '신기술 목록'이 아니라 '반복되는 실수 목록'이에요. +10개 중 절반은 코드가 아니라 설계·설정·운영에서 태어납니다.`; + +const CODE_SQLI = `// SQL 인젝션 — 입력이 '데이터'가 아니라 '코드'로 해석되는 사고 +// 잘못된 예: 문자열을 이어 붙여 쿼리를 만든다 +String sql = "SELECT * FROM users WHERE email = '" + email + "'"; +// email에 ' OR '1'='1 이 들어오면? +// → SELECT * FROM users WHERE email = '' OR '1'='1' +// → 조건이 항상 참이 되어 전체 사용자가 조회됩니다. + +// 올바른 예: PreparedStatement — 쿼리의 '구조'를 먼저 확정하고 +// 입력은 나중에 '값 자리'에만 끼워 넣는다 (파라미터 바인딩) +PreparedStatement ps = conn.prepareStatement( + "SELECT * FROM users WHERE email = ?"); +ps.setString(1, email); // email 안의 따옴표는 그냥 '글자'로 취급됨 + +// 원리: 구조(코드)와 데이터를 분리하면, 아무리 교묘한 입력도 +// 문법을 바꿀 수 없어요. 인젝션 방어의 제1원칙입니다.`; + +const CODE_ORM = `// "ORM 쓰면 안전하죠?" — 반은 맞고 반은 틀립니다 +// 안전: 파생 쿼리·바인딩 파라미터는 내부적으로 PreparedStatement +List findByEmail(String email); // 안전 +@Query("SELECT u FROM User u WHERE u.email = :email") // 안전 (바인딩) + +// 위험: ORM 위에서도 '문자열 이어 붙이기'를 하는 순간 원점 +String jpql = "SELECT u FROM User u WHERE u.name = '" + + name + "'"; // 다시 인젝션! +em.createQuery(jpql); + +// 같은 원리가 다른 곳에서도 반복됩니다: +// OS 커맨드: Runtime.exec("ping " + host) → host에 "; rm -rf /" +// LDAP: "(uid=" + user + ")" → 필터 조작 +// 어디서든 "입력을 이어 붙여 명령을 만들면" 인젝션의 문이 열립니다.`; + +const CODE_XSS = `XSS 3종 — 내 스크립트가 남의 브라우저에서 실행되는 세 가지 경로 +────────────────────────────────────────────────────── +저장형(Stored) 공격 스크립트가 DB에 '저장'됨 (게시글·채팅·닉네임) + → 그 글을 보는 모든 사용자에게 실행. 가장 파괴적. +반사형(Reflected) 요청에 실은 스크립트가 응답에 '반사'되어 실행 + → 피해자가 조작된 링크를 클릭해야 성립. +DOM 기반 서버는 무관. 프론트 JS가 URL 조각 등을 + innerHTML로 꽂다가 스스로 실행시킴. + +예: 채팅 메시지에 +를 넣었는데 화면에 '그대로' 렌더링되면 → 보는 사람 모두의 쿠키가 유출.`; + +const CODE_XSS_DEFENSE = `// 방어 1층: 출력 인코딩 — 데이터를 화면에 '글자'로만 그리기 +// React JSX는 기본적으로 자동 이스케이프합니다 +
{userMessage}
// +// 브라우저는 our-service.kr로 가는 요청에 쿠키를 '자동으로' 붙입니다. +// 서버 입장에선 정상 로그인 사용자의 요청과 구분이 안 돼요. + +// 방어 (겹쳐 쓴다): +// ① SameSite 쿠키 — 다른 사이트발 요청엔 쿠키를 안 붙임 +Set-Cookie: SESSION=...; HttpOnly; Secure; SameSite=Lax +// ② CSRF 토큰 — 폼마다 예측 불가 토큰을 심고 서버가 대조 +// ③ Origin/Referer 검증 — 요청이 어디서 왔는지 서버가 확인`; + +const CODE_IDOR = `// IDOR — Insecure Direct Object Reference +// URL의 숫자만 바꿨는데 남의 것이 열린다면? +GET /api/trade-journal/1041 // 내 매매일지 (정상) +GET /api/trade-journal/1042 // 옆 사람 매매일지 — 이게 열리면 사고 + +// 잘못된 코드: '존재하는지'만 확인하고 '누구 것인지'를 안 본다 +JournalEntry entry = repo.findById(id).orElseThrow(); +return entry; // 인증은 됐지만 인가(소유권)가 없음 + +// 올바른 코드: 조회 조건에 소유자를 함께 박는다 +JournalEntry entry = repo.findByIdAndUserId(id, currentUserId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND)); + +// 수평 상승: 같은 등급의 '남'의 자원 접근 (위 예) +// 수직 상승: 일반 사용자가 관리자 기능 접근 (role 검증 누락)`; + +const CODE_SECRETS = `# 보안 설정 오류의 단골: 코드에 박힌 dev 기본값 +# application.yml — 우리 컨벤션: 전부 환경변수 주입, 기본값은 dev 전용 +jwt: + secret: \${JWT_SECRET:dev-only-secret} # 운영은 .env로 반드시 override +internal: + key: \${INTERNAL_KEY:dev-internal-key} + +# 규칙 세 가지 +# ① 시크릿은 코드·git에 두지 않는다 — .env(로컬) / .env.prod(운영) +# ② dev 기본값은 '진짜 비밀'로 승격시키지 않는다 (운영 미주입 = 사고) +# ③ 실수로 커밋된 시크릿은 '삭제'가 아니라 '폐기(rotate)' — 이력에 남는다 + +# 의존성 스캔(SCA): 내 코드가 완벽해도 라이브러리가 뚫리면 끝 +# OWASP Dependency-Check, npm audit, GitHub Dependabot 등으로 +# 알려진 취약점(CVE) 있는 버전을 CI에서 자동 검출한다`; + +const CODE_RATELIMIT = `// 남용 방어: 레이트리밋 — '정상 기능의 비정상 사용량'을 막는다 +// 예: 로그인 시도를 IP당 분당 N회로 제한 (무차별 대입 차단) +String key = "ratelimit:login:" + clientIp; +Long count = valkey.opsForValue().increment(key); +if (count == 1) valkey.expire(key, Duration.ofMinutes(1)); +if (count > MAX_ATTEMPTS_PER_MINUTE) { + throw new ResponseStatusException(TOO_MANY_REQUESTS); // 429 +} + +// 남용 방어 + 보안 로깅은 한 쌍이에요 +// 실패한 로그인, 차단된 요청, 권한 거부 — '거절한 사실'을 기록해야 +// 공격의 패턴이 보입니다 (성공 로그만 남기면 공격은 안 보여요) +auditPublisher.publish(new AuditEvent( + "LOGIN_BLOCKED", clientIp, Instant.now())); // → Kafka audit.events + +// A09(로깅·모니터링 실패)의 교훈: 뚫린 것보다 무서운 건 +// '뚫렸는데 아무도 모르는 것'입니다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: 'Top 10 지도' }, + { n: 2, label: '인젝션 방어' }, + { n: 3, label: 'XSS 3종' }, + { n: 4, label: 'CSRF' }, + { n: 5, label: '접근 통제' }, + { n: 6, label: '설정·시크릿' }, + { n: 7, label: '남용 방어·로깅' }, +]; + +export default function Lv3WebSecurityPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 고급 과정
+

웹 보안 심화
— OWASP Top 10

+

+ 서비스를 '돌아가게' 만드는 단계는 지났습니다. 이제 질문이 바뀌어요 —{' '} + "이 기능을 공격자가 본다면 뭘 노릴까?" 이 코스는 OWASP Top 10을 + 지도 삼아, 공격자의 눈으로 내 서비스를 훑고 계층으로 방어(defense in + depth)하는 감각을 만듭니다. 교재는 남의 사고 사례가 아니라{' '} + 우리 서비스에 실제로 있었던 구멍들이에요. +

+
+ 🛡️ 예상 소요 70분 + 실습: 무인증 엔드포인트 위협 모델링 + 선수 지식: HTTP·백엔드 개발 경험 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ OWASP(Open Worldwide Application Security Project)는 전 세계 실제 + 침해 데이터를 모아, 웹에서 가장 자주·가장 아프게 뚫리는 유형 10가지를 + 몇 년마다 발표합니다. 이게 Top 10이에요. 신기한 해킹 기법 목록이 + 아니라, 수십만 서비스가 반복해서 저지르는 실수의 목록입니다. +

+ {CODE_TOP10} +

+ 읽는 법이 중요해요. "SQL 인젝션 취약점이 있다"를 개발자는 버그 하나로 + 읽지만, 공격자는 기회로 읽습니다 — "이 입력창으로 DB에 명령을 내릴 수 + 있다면, 전체 회원 정보를 뽑고, 관리자 계정을 만들고, 다음 서버로 넘어갈 수 있겠군." + 취약점 하나는 목적이 아니라 발판이에요. 그래서 방어도 "이 구멍 하나 + 막기"가 아니라 발판이 이어지지 않게 끊기여야 합니다. +

+

+ 그 사고방식이 심층 방어(defense in depth)예요. 성 하나를 지킬 때 + 성벽 하나에 다 걸지 않고 해자·성벽·내성·경비를 겹쳐 두듯이 — + 입력 검증이 뚫려도 파라미터 바인딩이 막고, 인코딩을 빠뜨려도 CSP가 막고, 다 뚫려도 + 로그에 남아 탐지되게 만듭니다. 이 코스의 모든 섹션이 이 원칙 위에 + 서 있어요. +

+
+ 완벽한 방어층은 없다 각 방어층은 언젠가 실수로 구멍이 나요. 계층 방어의 목표는 + "절대 안 뚫림"이 아니라, 한 층의 실수가 전체 붕괴로 이어지지 않게{' '} + 하는 겁니다. 보안을 잘하는 팀과 못하는 팀의 차이는 실수의 유무가 아니라, 실수가 + 터졌을 때의 피해 반경이에요. +
+
+ +
+

+ 인젝션(A03)은 가장 고전적이면서 아직도 상위권인 유형이에요. + 본질은 하나입니다 — 사용자 입력이 데이터가 아니라 코드(명령)로 해석되는 + 것. 대표가 SQL 인젝션이죠. 원리와 방어를 코드로 봅시다. +

+ {CODE_SQLI} +

+ PreparedStatement가 안전한 이유를 정확히 알아 두세요. + 입력을 "잘 필터링해서"가 아니라, 쿼리의 구조를 먼저 확정하고 입력은 + 값 자리에만 끼우기 때문이에요. 구조와 데이터가 분리되면 어떤 입력도 문법을 바꿀 수 + 없습니다. 블랙리스트 필터링("따옴표를 지우자")은 우회 기법이 수십 가지라 방어가 못 + 돼요. +

+ {/* 학습 포인트: "ORM 쓰니까 안전"이라는 흔한 오해를 정면으로 깬다 */} +

+ "우린 JPA 쓰는데요?" — 좋은 습관이지만 면죄부는 아닙니다. ORM도 + 결국 SQL을 만들어요. 바인딩 파라미터를 쓰는 경로는 안전하지만, 동적 + 쿼리를 문자열로 이어 붙이는 순간 같은 구멍이 다시 열립니다. +

+ {CODE_ORM} +
+ 패턴으로 기억하기 SQL·OS 커맨드·LDAP·NoSQL — 대상은 달라도 공식은 하나예요.{' '} + "입력을 이어 붙여 명령을 만들면 인젝션". 반대로 방어 공식도 하나 —{' '} + "명령의 구조와 데이터를 분리하라". 새 기술을 만나도 이 공식을 대 보면 + 위험한 코드가 보입니다. +
+
+ +
+

+ XSS(Cross-Site Scripting)는 인젝션의 브라우저판이에요. 사용자 입력이 + HTML에 섞여 들어가, 다른 사용자의 브라우저에서 스크립트로 실행되는 + 거죠. 실행된 스크립트는 그 사용자의 권한으로 쿠키를 훔치고, API를 호출하고, 화면을 + 위조할 수 있습니다. 경로에 따라 세 종류로 나눠요. +

+ {CODE_XSS} +

+ 셋의 구분이 중요한 이유: 고치는 위치가 달라서예요. 저장형·반사형은 + 서버가 응답을 만들 때, DOM 기반은 프론트 JS가 화면을 그릴 때 문제가 생깁니다. + 우리처럼 채팅·댓글 등 사용자가 쓴 글을 다른 사용자에게 보여 주는{' '} + 기능이 있는 서비스는 저장형 XSS가 1순위 점검 대상이에요. +

+ {CODE_XSS_DEFENSE} +

+ 방어의 뼈대는 출력 인코딩입니다 — 데이터가 화면에 나갈 때{' '} + <&lt;로 바꿔 + '글자'로만 그리는 것. React·Vue 같은 프레임워크는 이걸 자동으로 해 + 줘서, 우리가 실수할 표면이 크게 줄었어요. 위험한 건 그 자동 방어를 스스로 끄는 + 순간 — dangerouslySetInnerHTML, 혹은 서버 템플릿에서 + 이스케이프를 끄는 옵션들입니다. +

+
+ 계층 방어의 실전 ① 프레임워크 자동 이스케이프(기본 방어) → ② HTML을 꼭 넣어야 + 하면 서버측 정화 라이브러리(sanitizer) → ③ CSP 헤더(다 뚫렸을 때의 + 마지막 방어선). 인코딩 실수는 언젠가 나요. CSP가 있으면 그 실수가 "화면 깨짐"으로 + 끝나고, 없으면 "전 사용자 세션 탈취"로 끝납니다. +
+
+ +
+

+ CSRF(Cross-Site Request Forgery)는 이름 그대로 요청 + 위조예요. 공격자가 사용자 몰래, 사용자의 권한으로 우리 서버에 요청을 보내게 + 만듭니다. 성립 조건은 단 하나 — 브라우저가 쿠키를 자동으로 전송한다는 + 것. 사용자가 우리 서비스에 로그인한 상태로 공격자의 페이지를 열면, 그 페이지가 우리 + 서버로 보내는 요청에도 세션 쿠키가 자동으로 실려 갑니다. +

+ {CODE_CSRF} +

+ 방어 세 가지를 겹쳐 씁니다. ① SameSite 쿠키 — 다른 사이트에서 시작된 + 요청엔 쿠키를 붙이지 않게 브라우저에게 지시(Lax가 + 현실적 기본값). ② CSRF 토큰 — 정상 화면에만 심어 둔 예측 불가 값을 + 요청에 함께 보내게 하고 서버가 대조. ③ Origin 검증 — 요청 헤더의 + 출처가 우리 도메인인지 서버가 확인. +

+ {/* 학습 포인트: 추상 개념을 우리 서비스의 진행 중인 과제와 연결한다 */} +

+ 우리 서비스와의 연결고리가 하나 있어요. 지금 우리는 토큰을{' '} + localStorage에서 httpOnly 쿠키로 옮기는 + 전환 과제를 진행 중입니다. httpOnly 쿠키는 JS가 못 읽으니 XSS로 + 토큰이 탈취되는 경로를 끊어 주지만, 쿠키가 되는 순간 자동 전송이라는 + CSRF의 성립 조건이 새로 생겨요. 그래서 이 전환은 반드시{' '} + SameSite 설정과 짝으로 가야 합니다. +

+
+ 저장소별 트레이드오프 localStorage 토큰: CSRF엔 강함(자동 전송 없음), XSS엔 + 약함(JS로 읽힘). httpOnly 쿠키: XSS 탈취엔 강함, CSRF 대비 필요. 완벽한 + 저장소는 없고, 어느 위협을 더 크게 보느냐의 선택이에요 — 이런 트레이드오프 + 판단이 바로 '고급 과정'이 요구하는 감각입니다. +
+
+ +
+

+ 접근 통제 결함(Broken Access Control)은 2021년 Top 10에서{' '} + 1위예요. 인증(누구인지 확인)은 통과했는데 인가(그 사람이 + 이걸 해도 되는지)를 빠뜨린 모든 경우가 여기 들어갑니다. 가장 흔한 형태가{' '} + IDOR — URL이나 파라미터의 식별자만 바꿔서 남의 자원에 접근하는 + 거죠. +

+ {CODE_IDOR} +

+ 권한 상승엔 두 방향이 있어요. 수평 상승은 같은 등급의 다른 사용자 + 자원에 접근(내 일지 → 남의 일지), 수직 상승은 더 높은 권한의 기능에 + 접근(일반 사용자 → 관리자 API). 둘 다 뿌리는 같습니다 — 서버가 "이 요청자가 + 이 자원에 권한이 있나"를 확인하지 않은 것. +

+

+ 철칙은 하나예요. 권한 검증은 서버에서, 모든 요청마다, 절대 생략하지 + 않는다. "프론트에서 버튼을 숨겼으니까", "URL을 모르면 못 들어오니까"는 방어가 + 아닙니다. 공격자는 화면이 아니라 API를 직접 두드려요. 프론트의 숨김은 + UX일 뿐, 보안은 서버 코드 안에만 존재합니다. +

+ {/* 학습 포인트: 실제 우리 커밋에서 배우는 사례 — 추상론이 아니라 지난주의 일 */} +
+ 우리 서비스의 실제 사례 우리 시세 서비스의 프리미엄 조회 + API(호가·투자자동향)가 한동안 로그인 없이 열려 있었어요. + 화면에선 로그인 사용자에게만 보였지만, API를 직접 호출하면 누구나 프리미엄 데이터를 + 가져갈 수 있었죠 — 전형적인 "프론트 숨김 ≠ 접근 통제"입니다. 최근에 서버측{' '} + 로그인 게이팅을 넣어 막았어요. 여러분이 실습에서 찾아야 할 게 바로 + 이런 종류의 구멍입니다. +
+
+ +
+

+ Top 10의 절반은 코드 로직이 아니라 설정과 운영에서 나옵니다.{' '} + 보안 설정 오류(A05)의 단골 목록: 디버그 모드가 켜진 운영 서버, + 바깥에 열린 관리 콘솔, 기본 계정·기본 비밀번호, 그리고 코드에 하드코딩된 dev + 기본 시크릿이 운영까지 그대로 흘러가는 사고. +

+ {CODE_SECRETS} +

+ 우리 컨벤션이 정확히 이 지점을 겨냥해요. 설정값은 전부{' '} + 환경변수:기본값 형태로 주입하고, 시크릿은 + 코드가 아니라 .env(로컬)/.env.prod + (운영)에만 둡니다. 단, 함정이 하나 있어요 — dev 기본값이 코드에 남아 있다는 + 것 자체가 위험 요소입니다. 운영에서 env 주입을 빠뜨리면 dev 시크릿으로 + 돌아가 버리니까요. "기본값으로도 일단 돌아간다"는 편리함이 곧 사고의 씨앗이에요. +

+

+ 취약한 컴포넌트(A06)도 같은 결이에요. 내 코드가 완벽해도, 의존하는 + 라이브러리에 알려진 취약점(CVE)이 있으면 거기가 뒷문이 됩니다. 방어는{' '} + SCA(Software Composition Analysis) — 의존성 목록을 취약점 DB와 + 대조하는 자동 스캔(npm audit, Dependabot, OWASP + Dependency-Check)을 CI에 넣어, 사람이 기억하지 않아도 걸리게 만드는 거예요. +

+
+ 커밋된 시크릿은 지워도 안 지워진다 시크릿을 실수로 커밋했다면, 파일에서 + 지우고 다시 커밋해도 git 이력엔 그대로 남아요. 올바른 대응은 삭제가 + 아니라 폐기(rotate) — 그 키를 즉시 무효화하고 새 키를 발급하는 + 겁니다. "노출됐을 가능성이 있는 비밀은 이미 비밀이 아니다"로 취급하세요. +
+
+ +
+

+ 마지막 층은 기능이 아니라 사용량의 문제예요. 로그인 API 자체는 + 멀쩡해도, 한 IP가 초당 수백 번 두드리면 무차별 대입(brute force)이 + 되고, 무인증 쓰기 엔드포인트는 스팸·도배의 통로가 됩니다. Top 10의{' '} + 안전하지 않은 설계(A04)인증 실패(A07)가 겹치는 + 영역이죠. 방어의 기본기가 레이트리밋입니다. +

+ {CODE_RATELIMIT} + {/* 학습 포인트: 이번에도 우리 커밋 이력이 교재 — 로그인 IP 제한과 무인증 쓰기 차단 */} +

+ 이것도 남의 얘기가 아니에요. 우리 서비스는 최근 로그인에 IP 기반 + 레이트리밋을 넣어 무차별 대입과 가입·재설정 남용을 막았고, 무인증으로 + 열려 있던 쓰기 엔드포인트들(광고 문의·채팅·신고)의 남용 차단 작업을 + 했습니다. "누구나 쓸 수 있어야 하는 기능"과 "무한정 쓸 수 있는 기능"은 다르다는 걸 + 기억하세요. +

+

+ 그리고 로깅·모니터링 실패(A09). 침해 사고의 진짜 비극은 뚫린 것이 + 아니라 몇 달 동안 아무도 몰랐다는 것인 경우가 많아요. 그래서 보안 + 이벤트 — 로그인 실패, 레이트리밋 차단, 권한 거부 — 는 반드시 기록합니다. 우리는 + 감사 이벤트를 Kafka 토픽 audit.events로 흘려 로그 + 서비스가 적재해요. 성공 로그만 남기면 공격은 영원히 안 보입니다 —{' '} + 거절한 사실이야말로 공격 탐지의 원료예요. +

+

+ 같은 원칙이 코드 레벨에도 있어요. 실패를 조용히 넘기지 않기. 빈{' '} + catch 블록, 무시된 에러 응답, "일단 200 리턴" — 이런 + 코드는 장애만 숨기는 게 아니라 공격의 흔적도 같이 숨깁니다. 실패는 + 명시적으로 처리하고, 보안적으로 의미 있는 실패는 로그로 남기세요. +

+
+ 실습 안내 우리 서비스의 무인증 쓰기 엔드포인트 하나(광고 + 문의·채팅·신고 중 택 1)를 골라, 이 코스에서 배운 렌즈로 위협 모델링을 정리해 + 보세요. ① 공격자는 이 엔드포인트로 뭘 할 수 있나(스팸? 인젝션? 저장형 XSS?) → + ② 지금 있는 방어층은 무엇인가 → ③ 어떤 층을 더 겹칠 것인가(입력 검증·레이트리밋· + 로깅) → ④ 뚫렸다면 어떻게 알아챌 것인가. 네 질문에 답이 되면 실습 완료입니다. +
+
+ +
+
+

🛡️ 여기까지 왔다면

+

+ 이제 기능 명세 옆에 항상 두 번째 질문이 따라붙을 거예요 — "공격자라면 여길 어떻게 + 쓸까?" 인젝션엔 구조와 데이터의 분리, XSS엔 인코딩과 CSP, CSRF엔 SameSite와 토큰, + 접근 통제엔 서버측 검증, 설정엔 env 주입, 남용엔 레이트리밋과 로깅 — 그리고 이 모든 + 층을 겹쳐 쌓는 심층 방어까지. 그런데 섹션 4에서 만난 질문이 하나 + 남아 있죠. 토큰은 어디에 두고, 세션과 JWT 중 무엇을 택하고, 소셜 로그인은 어떻게 + 얹어야 할까요? 다음{' '} + 인증 아키텍처 — 세션 vs JWT vs + OAuth 코스에서, 보안의 심장인 인증 설계를 정면으로 다룹니다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv3ZeroDowntimeDeployPage.jsx b/frontend/src/pages/levels/Lv3ZeroDowntimeDeployPage.jsx new file mode 100644 index 0000000..24e65f3 --- /dev/null +++ b/frontend/src/pages/levels/Lv3ZeroDowntimeDeployPage.jsx @@ -0,0 +1,456 @@ +// 이 파일이 하는 일: "무중단 배포와 롤백" 코스 — 재시작 몇 초가 왜 장애인지에서 출발해, +// 롤링·블루-그린·카나리아 전략 비교, 헬스체크와 트래픽 전환, DB 확장→이행→수축 마이그레이션, +// 롤백 설계와 기능 플래그, 카나리아 자동 관측, 배포 파이프라인 규율까지 7개 섹션으로 안내하는 +// 정적 학습 페이지. 프레이밍은 "배포가 무섭지 않아야 자주 배포한다" — 서비스를 만들어 본 수습이 +// '내는 것'을 넘어 '되돌릴 수 있게 내는 것'을 설계하도록, 우리 compose 운영 구조에 발을 딛고 간다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_DOWNTIME = `# "잠깐 껐다 켜면 되죠" — 그 잠깐 동안 벌어지는 일 (타임라인) + +14:00:00 docker compose up -d --build 실행 +14:00:01 구(舊) 컨테이너 중지 → 이 순간부터 요청은 갈 곳이 없다 +14:00:01 사용자 A: 주문 버튼 클릭 → 502 Bad Gateway +14:00:03 사용자 B: 대시보드 새로고침 → 연결 실패, SSE 시세 스트림 끊김 +14:00:25 새 컨테이너 기동 중 (JVM 워밍업, DB 커넥션 풀 채우는 중) +14:00:40 헬스체크 통과 → 정상 응답 재개 + +체감: 개발자에겐 "40초 재시작", 사용자에겐 "장 중에 앱이 죽었다". +그리고 이 공포가 쌓이면 → 배포를 미루고 → 한 번에 큰 덩어리를 내고 +→ 실패 확률과 원인 추적 난도가 함께 오르는 악순환이 시작됩니다.`; + +const CODE_STRATEGIES = `전략 방식 비용 위험 되돌림 속도 +────────────────────────────────────────────────────────────────── +롤링 인스턴스를 하나씩 교체 낮음 중간 느림(다시 롤링) + (구·신 버전이 잠시 공존) + +블루-그린 똑같은 환경 2벌(blue/green) 높음 낮음 매우 빠름 + 신버전을 green에 띄우고 (2배) (스위치만 + 검증 후 트래픽 스위치 되돌리면 끝) + +카나리아 신버전에 트래픽 일부(5%)만 중간 매우낮음 빠름 + 흘려 지표 관찰 → 점차 확대 (5%만 영향) + +공통 전제: 어떤 전략이든 "구버전과 신버전이 동시에 살아 있는 구간"이 +생깁니다. 그래서 4섹션의 앞뒤 호환(DB·API)이 모든 전략의 기초 체력이에요.`; + +const CODE_BLUEGREEN = `# 블루-그린을 compose로 흉내 내기 — 같은 이미지 계열의 서비스 2벌 +# docker-compose.prod.yml 오버레이에 색깔만 다른 서비스를 나란히 둔다 + +services: + web-blue: # 현재 운영 중 (v1.4) + image: tdatalab/web:1.4 + ports: + - "3000:3000" + web-green: # 새 버전 후보 (v1.5) — 아직 트래픽 없음 + image: tdatalab/web:1.5 + ports: + - "3010:3000" + +# 절차: +# ① green 기동 → ② green에 직접 접속해 검증(3010 포트, 내부에서만) +# ③ Caddy 업스트림을 blue→green으로 전환(리로드는 무중단) +# ④ 이상 없으면 blue 중지 / 이상 있으면 업스트림만 blue로 되돌림 +# 롤백 = "설정 한 줄 되돌리기". 재빌드도 재기동도 필요 없다.`; + +const CODE_HEALTH = `# Spring Boot Actuator — readiness와 liveness는 다른 질문이다 +# application.yml +management: + endpoint: + health: + probes: + enabled: true # /actuator/health/readiness · /liveness 활성화 + +# liveness = "살아 있나?" → 아니오면: 컨테이너를 재시작하라 +# readiness = "손님 받을 준비 됐나?" → 아니오면: 트래픽을 보내지 마라 +# +# JVM은 떴지만 DB 커넥션 풀이 아직 안 채워졌다면? +# liveness: UP / readiness: DOWN ← 재시작할 일도, 트래픽 줄 일도 아님 +# +# compose 헬스체크에는 readiness를 물어봐야 한다: +healthcheck: + test: curl -fs http://localhost:8080/actuator/health/readiness + interval: 5s + retries: 12 + start_period: 30s # JVM 워밍업 시간은 실패로 세지 않기`; + +const CODE_EXPAND = `-- 확장 → 이행 → 수축: 컬럼 이름을 바꾸는 '무중단' 3단계 +-- 목표: users.name 을 users.display_name 으로 바꾸고 싶다 +-- 금지: ALTER TABLE users RENAME COLUMN ... 을 배포와 동시에 한 방에 +-- (구버전 코드가 name을 읽는 순간 전부 깨진다) + +-- [1단계: 확장(expand)] 새 컬럼을 '추가만' 한다 — 구·신 코드 모두 무사 +ALTER TABLE users ADD COLUMN display_name VARCHAR(80); + +-- [2단계: 이행(migrate)] 새 코드는 양쪽에 쓰고 새 컬럼을 먼저 읽는다. +-- 기존 데이터는 배포와 '분리된' 배치로 천천히 채운다 +UPDATE users SET display_name = name WHERE display_name IS NULL; + +-- [3단계: 수축(contract)] 모든 코드가 새 컬럼만 쓰는 걸 확인한 '다음 릴리스'에서 +ALTER TABLE users DROP COLUMN name; + +-- 핵심: 1~3단계는 최소 세 번의 배포로 나눠 나간다. +-- 각 단계 사이에서 언제든 멈추거나 되돌릴 수 있는 게 이 방식의 값어치.`; + +const CODE_FLYWAY = `# ddl-auto: update 의 문제 — "앱이 스키마를 마음대로 만진다" +# · 무엇이 바뀔지 배포 전에 알 수 없다 (리뷰 불가) +# · 컬럼 삭제·이름 변경은 아예 못 한다 (추가만 흉내냄) +# · 롤백 개념이 없다 — 되돌릴 스크립트 자체가 존재하지 않는다 + +# Flyway 도입 — 마이그레이션을 '번호 달린 파일'로 코드처럼 관리 +src/main/resources/db/migration/ + V1__init_users.sql + V2__add_display_name.sql # ← 확장 단계 + V3__drop_legacy_name.sql # ← 수축 단계 (몇 릴리스 뒤) + +# application.yml +spring: + jpa: + hibernate: + ddl-auto: validate # 앱은 검증만! 스키마 변경 권한을 회수한다 + flyway: + enabled: true + +# 이제 스키마 변경도 PR 리뷰를 거치고, 어떤 버전의 DB인지 이력이 남는다.`; + +const CODE_FLAG = `# 기능 플래그 — "코드 배포"와 "기능 공개"를 분리하는 스위치 +# 새 화면 코드는 이미 운영에 나가 있지만, 플래그가 꺼져 있어 아무도 못 본다 + +# 설정(예: 환경변수 또는 설정 테이블) +FEATURE_NEW_SCREENER=false # 배포는 어제 했고, 공개는 오늘 오후에 + +# 서비스 코드는 플래그를 읽어 갈림길만 태운다 (의사코드) +# 플래그 켜짐 → 새 스크리너 화면으로 +# 플래그 꺼짐 → 기존 화면 그대로 + +# 무엇이 좋아지나: +# · 문제가 생기면 '재배포 없이' 플래그만 끈다 → 가장 빠른 롤백 +# · 특정 그룹(사내 계정)에만 먼저 켜서 운영 데이터로 검증 +# · 큰 기능을 작은 배포 여러 번으로 쪼개 숨겨 두고, 마지막에 켠다 +# 주의: 다 켜고 안정화된 플래그는 정리(삭제)까지가 한 사이클 — +# 안 지운 플래그가 쌓이면 갈림길 미로가 된다.`; + +const CODE_CANARY = `# 카나리아 롤아웃 — 지표가 게이트를 통과해야 다음 단계로 +단계 트래픽 관찰 시간 통과 조건(게이트) +───────────────────────────────────────────────────── +1 5% 15분 5xx 비율 < 0.5% · p95 지연 < 800ms +2 25% 30분 위와 동일 + 로그인 성공률 기존 대비 -1%p 이내 +3 100% - 안정화 확인 후 구버전 정리 + +# 게이트를 '자동'으로 만드는 게 핵심: +# 지표가 조건을 벗어나면 → 사람을 기다리지 말고 → 즉시 트래픽을 0%로. +# 새벽 3시의 배포 담당자는 판단력이 흐리다. 조건은 '대낮에' 문서로 정해 둔다. +# +# 폭발 반경(blast radius) 감각: +# 100% 배포에서 터지면 전 사용자 장애, 5%에서 터지면 '지표에 남은 흔적'. +# 같은 버그라도 언제 발견하느냐가 사고의 크기를 정한다.`; + +const CODE_CHECKLIST = `# 배포 체크리스트 — '기억'이 아니라 '목록'이 지키게 한다 + +[배포 전] + □ CI 통과 확인 (빌드 · 테스트 · 타입체크 — 게이트를 우회하지 않았나) + □ 이번 변경에 DB 마이그레이션이 있는가? → 있다면 확장 단계인지 확인 + □ 롤백 방법을 한 줄로 말할 수 있는가? (없으면 배포 보류) + □ 백업 확인 — 최근 백업이 존재하고 복원 절차를 알고 있는가 + +[배포 중] + □ readiness 통과 후에만 트래픽 전환 + □ 에러율 · 지연 대시보드를 옆에 띄워 두고 진행 + +[배포 후 15분] + □ 5xx 비율 · p95 지연 · 로그인 성공률 정상 범위인가 + □ 신규 에러 로그 급증 없는가 + □ 이상 시: 사전에 적어 둔 롤백 트리거 조건에 따라 '즉시' 되돌린다 + (원인 분석은 되돌린 다음에 — 순서를 바꾸지 않는다)`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '왜 무중단인가' }, + { n: 2, label: '배포 전략 비교' }, + { n: 3, label: '헬스체크와 전환' }, + { n: 4, label: 'DB 마이그레이션' }, + { n: 5, label: '롤백 설계' }, + { n: 6, label: '안전장치' }, + { n: 7, label: '파이프라인 규율' }, +]; + +export default function Lv3ZeroDowntimeDeployPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 고급 과정
+

🚦 무중단 배포와 롤백

+

+ 배포 버튼 앞에서 심장이 빨라진다면, 문제는 담력이 아니라 설계예요. + 배포가 무섭지 않아야 자주 배포하고, 자주 배포해야 한 번의 변경이 작아져 더 안전해집니다. + 이 코스에서는 재시작 몇 초가 왜 장애인지에서 출발해, 되돌릴 수 있게 내보내는 + 법 — 전략 선택, 헬스체크, DB 호환성, 롤백과 안전장치까지를 익힙니다. +

+
+ 예상 소요 70분 + 실습: 블루-그린 절차 + 롤백 조건 문서 설계 + 선수 지식: Docker·compose 운영, HTTP, DB 기초 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ "잠깐 껐다 켜는 건데요, 30초면 돼요." — 개발자의 30초와 사용자의 30초는 다른 + 시간입니다. 우리처럼 장 중 실시간 시세를 다루는 서비스라면 더더욱요. + 재시작하는 그 순간에도 누군가는 주문 직전이고, 누군가는 SSE로 시세 스트림을 받고 + 있어요. 그들에게 재시작은 '점검'이 아니라 장애입니다. +

+ {CODE_DOWNTIME} +

+ 더 무서운 건 2차 효과예요. 배포가 아프면 사람은 배포를 미룹니다. + 미루면 변경이 쌓이고, 쌓인 변경을 한 번에 내면 실패 확률이 오르고, 실패하면 원인 + 후보가 수십 개라 추적도 오래 걸리죠. 반대로 배포가 아무렇지 않으면{' '} + 작게, 자주 내게 되고, 작은 배포는 실패해도 원인이 뻔히 보입니다. + 무중단 배포는 기술 과제이기 전에 배포 빈도를 끌어올리는 심리적 장치예요. +

+
+ 이 코스의 한 문장 좋은 배포의 기준은 "안 터지는 배포"가 아니라{' '} + "터져도 몇 분 안에 되돌릴 수 있는 배포"입니다. 롤백 가능성이 곧 + 안전벨트예요. 안전벨트가 있어야 속도를 낼 수 있고요. 이후 모든 섹션은 이 한 문장을 + 기술로 구현하는 방법들입니다. +
+
+ +
+

+ 무중단을 이루는 전략은 크게 셋이에요. 공통 아이디어는 하나 — 새 버전이 + 준비를 마치기 전까지 구버전을 죽이지 않는다. 차이는 "몇 벌의 환경을 유지하고, + 트래픽을 어떻게 옮기느냐"입니다. +

+ {CODE_STRATEGIES} +

+ 롤링은 인스턴스를 하나씩 갈아 끼우는 방식이라 추가 서버가 거의 필요 + 없지만, 문제 발견 시 되돌리는 것도 다시 한 바퀴 롤링이라 느립니다.{' '} + 블루-그린은 운영(blue)과 똑같은 환경(green)을 한 벌 더 두고, 신버전을 + green에 올려 검증한 뒤 트래픽 스위치만 딸깍 넘겨요. 비용은 2배지만{' '} + 롤백이 스위치 한 번이라는 게 압도적 장점이죠. 카나리아는 + 광산의 카나리아처럼 신버전에 트래픽 일부만 먼저 흘려 보고, 지표가 멀쩡하면 비중을 + 늘립니다. +

+

+ 우리 운영 구조 — 단일 호스트에서 docker-compose.yml에{' '} + docker-compose.prod.yml 오버레이를 겹쳐 올리는 방식 — + 에서는 쿠버네티스식 자동 롤링은 없어요. 하지만 서비스를 색깔별로 2벌 정의하고 + 앞단(Caddy)의 업스트림만 바꾸는 수동 블루-그린은 지금 구조로도 충분히 + 가능합니다. 실습에서 이 절차를 문서로 설계할 거예요. +

+
+ 전략 선택의 감각 정답은 규모가 정해요. 인스턴스가 수십 개면 롤링이 자연스럽고, + 한두 개면 블루-그린이 단순하고 확실합니다. 카나리아는 "지표를 볼 눈(관측)"이 갖춰진 + 다음에야 의미가 있어요 — 5%에게 나간 버그를 아무도 감지 못 하면 그냥 5% 사용자를 + 실험대에 올린 것뿐이니까요(6섹션). +
+
+ +
+

+ 모든 무중단 전략의 공통 부품은 "새 버전이 준비됐는지 아는 방법", + 즉 헬스체크입니다. 여기서 많이들 뭉개는 구분이 하나 있어요 —{' '} + liveness(살아 있나)와 readiness(손님 받을 준비가 + 됐나)는 다른 질문이라는 것. +

+ {CODE_HEALTH} +

+ 이 구분이 왜 중요할까요? JVM 프로세스는 떴지만 DB 커넥션 풀이 아직 안 채워진 서비스에 + 트래픽을 넣으면, 사용자는 에러를 받아요. 그렇다고 그 서비스를 재시작하면? 영원히 + 준비를 못 마칩니다. liveness 실패의 처방은 재시작, readiness 실패의 처방은 + "트래픽 주지 말고 기다리기" — 처방이 다르니 진단도 나눠야 하는 거예요. +

+

+ 트래픽 전환은 앞단에서 일어납니다. 우리 구조에선 두 겹이 있어요 — + 맨 앞의 Caddy(HTTPS 종단)와 그 뒤의 게이트웨이(8080, + 서비스 라우팅). 블루-그린 전환점은 이 앞단의 업스트림 설정이고, Caddy는 설정 리로드가 + 무중단이라 전환 자체가 끊김을 만들지 않습니다. +

+ {CODE_BLUEGREEN} +
+ 흔한 함정: 얕은 헬스체크 /actuator/health가 + 200을 준다고 "준비 완료"가 아닐 수 있어요. DB 연결·필수 캐시 연결이 빠진 채 HTTP + 서버만 뜬 상태도 200을 줄 수 있거든요. readiness에는 그 서비스가 일하는 데 + 꼭 필요한 의존성(DB, Valkey 등)의 상태가 포함돼야 합니다. 반대로 너무 깊게 + — 옆 서비스의 상태까지 — 물면, 남의 장애에 내가 재시작당하는 연쇄가 생겨요. +
+
+ +
+

+ 무중단 배포의 최종 보스는 코드가 아니라 데이터베이스입니다. 이유는 + 2섹션에서 봤듯 어떤 전략이든 구버전과 신버전 코드가 동시에 살아 있는 구간이 + 생기기 때문이에요. 그 구간 동안 DB는 양쪽 모두를 만족시켜야 합니다. + 구버전이 읽는 컬럼을 신버전 배포와 동시에 지워 버리면, 전환이 끝나기 전의 구버전이 + 즉사하죠. 그래서 스키마 변경은 뒤로도(backward) 앞으로도(forward) 호환되게, + 세 걸음으로 나눠 딛습니다. +

+ {CODE_EXPAND} +

+ 확장(expand) 단계에선 추가만 해요 — 컬럼 추가, 테이블 추가는 아무도 + 해치지 않습니다. 이행(migrate) 단계에서 새 코드가 새 구조를 쓰기 + 시작하고, 데이터를 옮깁니다. 수축(contract)은 모두가 새 구조로 넘어간 + 걸 확인한 다음 릴리스에서야 옛것을 지워요. 성급한 수축이 사고의 + 단골 원인입니다. +

+

+ 그런데 우리에겐 그보다 앞선 숙제가 있어요. 지금 백엔드는 JPA{' '} + ddl-auto: update앱이 뜰 때 스키마를 알아서 + 바꾸는 골격 단계 설정을 쓰고 있죠. 편하지만, 무엇이 바뀔지 배포 전에 리뷰할 + 수 없고, 되돌릴 스크립트도 없습니다. 무중단·롤백을 진지하게 하려면 스키마 변경을{' '} + Flyway 같은 마이그레이션 도구의 버전 파일로 옮겨, 코드처럼 리뷰하고 + 이력을 남겨야 해요. +

+ {CODE_FLYWAY} +
+ 비유 확장→이행→수축은 다리를 옆에 새로 놓고 건너는 것과 + 같아요. 옛 다리를 부수기 전에 새 다리를 먼저 놓고(확장), 차들이 새 다리로 옮겨 타는 + 걸 지켜본 뒤(이행), 아무도 안 다니는 걸 확인하고 옛 다리를 철거합니다(수축). + 한 방에 갈아끼우는 건 차가 달리는 중에 다리를 부수는 일이고요. +
+
+ +
+

+ 롤백은 배포가 터진 뒤에 고민하는 게 아니라 배포 전에 설계하는 것입니다. + 첫걸음은 이번 변경이 어느 쪽인지 가르는 거예요 — 되돌릴 수 있는 변경(코드 + 로직, 화면, 설정값)과 불가역이거나 비싼 변경(컬럼 삭제, 데이터 변환·삭제, + 외부 시스템에 이미 나간 요청). 앞쪽은 이전 이미지로 갈아타면 그만이지만, 뒤쪽은 이미지가 + 돌아가도 데이터는 돌아오지 않습니다. +

+

+ 그래서 규율은 이렇게 세웁니다. ① 마이그레이션과 롤백을 한 세트로 계획한다 — + "이 변경을 되돌리려면 어떤 순서로 무엇을 하나"가 배포 문서에 함께 적혀 있어야 해요.{' '} + ② 코드 롤백이 DB 롤백을 요구하지 않게 만든다 — 4섹션의 확장 단계만 + 나간 상태라면, 코드를 이전 버전으로 되돌려도 (새 컬럼은 무시될 뿐) DB는 그대로 둬도 + 됩니다. 이게 확장→수축을 릴리스 여러 개로 쪼개는 진짜 이유예요.{' '} + ③ 불가역 작업은 최대한 뒤로 미루고, 백업을 먼저 확인한다. +

+

+ 그리고 롤백을 아예 배포 없이 해내는 도구가 있습니다 —{' '} + 기능 플래그(feature flag). 코드를 내보내는 일과 기능을 사용자에게 + 공개하는 일을 분리하는 스위치예요. +

+ {CODE_FLAG} +
+ 롤백 트리거는 '조건'으로 적는다 "문제가 심각하면 롤백한다"는 문장은 새벽 3시에 + 아무도 실행하지 못해요. 심각함을 판정하느라 30분을 태우거든요.{' '} + "5xx 비율이 1%를 10분 넘게 유지하면 롤백"처럼 숫자와 시간이 있는 + 조건이어야, 판단이 아니라 실행만 남습니다. 이번 실습의 핵심 산출물이 + 바로 이 트리거 조건 목록이에요. +
+
+ +
+

+ 카나리아 전략(2섹션)이 진짜 힘을 내는 건 지표 관측과 결합될 때입니다. + 신버전에 5% 트래픽을 흘리면서 에러율·지연·핵심 전환율을 보고, 조건을 벗어나는 순간{' '} + 사람의 승인을 기다리지 않고 트래픽을 회수하는 것 — 이게 자동 롤백이에요. +

+ {CODE_CANARY} +

+ 여기서 앞 강(에러 추적과 모니터링)이 그대로 이어집니다. 배포 직후 몇 분은{' '} + 에러 로그가 가장 정직한 시간이에요. 평소에 없던 예외가 갑자기 분당 + 수십 건 찍히면, 사용자 신고가 오기 전에 지표가 먼저 알려 줍니다. 그래서 배포 절차에 + "배포 후 15분간 에러 대시보드 관찰"이 절차의 일부로 들어가야 하고, + 더 나아가면 "배포 직후 신규 에러 급증 → 알림 → 자동 롤백"까지 묶을 수 있어요. +

+

+ 이 모든 장치를 관통하는 개념이 폭발 반경(blast radius)입니다. + 같은 버그라도 100%에게 나가면 전면 장애, 5%에게 나가면 지표의 흔적이에요. 점진적 + 롤아웃, 기능 플래그의 그룹별 공개, 사내 계정 선공개 — 전부 "터질 수 있다는 전제 + 아래, 터졌을 때의 반경을 줄이는" 같은 철학의 변주입니다. +

+
+ 완벽한 자동화가 없어도 우리 규모에서 카나리아 트래픽 분배 자동화는 아직 과할 + 수 있어요. 하지만 축소판은 오늘부터 가능합니다 — 블루-그린의 green을 전환 전에{' '} + 내부에서 먼저 두들겨 보는 것이 트래픽 0%짜리 카나리아고, 배포 후 + 15분 지표 관찰이 수동 게이트예요. 도구보다 순서와 조건이 먼저입니다. +
+
+ +
+

+ 마지막 조각은 기술이 아니라 규율입니다. 무중단 장치를 다 갖춰도, + 테스트를 건너뛴 빌드가 운영에 나가면 소용없으니까요. 파이프라인의 각 단계 —{' '} + 빌드 → 테스트 → 배포 — 를 게이트로 세우고, 게이트를 + 통과하지 못한 산출물은 다음 단계로 못 가게 하는 것. "급하니까 이번만"이 규율의 + 가장 큰 적이에요. 급할수록 실수 확률이 높고, 실수했을 때 기댈 건 절차뿐입니다. +

+

+ 그리고 최후의 안전망 — 백업. 우리 플랫폼에는 Postgres와 Valkey를 + 하루 한 번 백업하고 30일 보관하는 백업 데몬이 이미 돌고 있죠. 롤백 설계가 아무리 + 좋아도 데이터가 깨지는 최악의 시나리오는 남고, 그때 복원할 백업이 있느냐가 서비스의 + 생사를 가릅니다. 중요한 건 "백업이 돌고 있다"가 아니라 "복원해 본 적이 + 있다"예요. 복원 안 해 본 백업은 있다고 믿는 파일일 뿐입니다. +

+ {CODE_CHECKLIST} +

+ 체크리스트가 촌스러워 보일 수 있지만, 항공기 조종사도 수천 번 한 이륙 앞에서 목록을 + 읽어요. 긴장한 사람의 기억은 구멍이 나고, 목록은 나지 않으니까요. 배포 후 확인까지 + 끝나야 배포가 끝난 겁니다 — 머지가 아니라 "운영에서 멀쩡히 도는 것"이 + 완료의 정의예요. +

+
+ 실습 과제 우리 서비스 중 하나(예: web 프론트)를 골라 문서 한 장을 쓰세요 —{' '} + ① 블루-그린 배포 절차(green 기동 → 검증 방법 → 전환 명령 → blue 정리) + 단계별로, ② 롤백 트리거 조건(숫자·시간이 있는 조건 3개 이상),{' '} + ③ 롤백 실행 절차(누가, 무엇을, 몇 분 안에). 완성 기준은 하나 —{' '} + 이 문서만 들고 처음 보는 사람이 새벽 3시에 실행할 수 있는가. +
+
+ +
+
+

🚦 여기까지 왔다면

+

+ 이제 배포는 "숨 참고 누르는 버튼"이 아니라 설계된 절차가 됐어요. + 준비된 것만 트래픽을 받고(헬스체크), DB는 세 걸음으로 나눠 딛고(확장→이행→수축), + 터지면 조건에 따라 몇 분 안에 되돌립니다(롤백 트리거·기능 플래그). 되돌릴 수 있다는 + 확신이 생기면 자연히 작게, 자주 내게 되죠. 그런데 작게 자주 내는 + 팀의 코드는 또 다른 근육을 요구해요 — 매일 흐르는 변경을 서로 읽고 다듬는 힘이요. + 다음 코드 리뷰와 리팩터링 + 전략 코스에서, 변경의 품질을 지키는 팀의 기술을 이어서 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv4DbReplicationShardingPage.jsx b/frontend/src/pages/levels/Lv4DbReplicationShardingPage.jsx new file mode 100644 index 0000000..427983c --- /dev/null +++ b/frontend/src/pages/levels/Lv4DbReplicationShardingPage.jsx @@ -0,0 +1,495 @@ +// 이 파일이 하는 일: "DB 레플리케이션과 샤딩" 코스(특급 과정) — 하나의 데이터베이스가 +// 병목이 되는 순서에서 출발해, 읽기를 늘리는 복제(레플리케이션)와 쓰기를 늘리는 샤딩, +// 그리고 그 둘이 반드시 데려오는 부작용(복제 지연, failover의 함정, 핫 샤드, 크로스 샤드 +// 쿼리)까지 8개 섹션으로 안내하는 정적 학습 페이지. 프레이밍은 "확장은 공짜가 아니다" — +// 대규모 트래픽 앞에서 DB를 늘리는 두 가지 축과 그 대가를 함께 계산하는 시야를 기른다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_BOTTLENECK = `단일 DB가 무너지는 '순서' — 대개 이 계단을 밟는다 + +① 커넥션 고갈 앱 서버가 늘면서 커넥션 요청이 max_connections를 초과 + → 대응: 커넥션 풀(HikariCP) 정리, PgBouncer 같은 풀러 +② 느린 쿼리 특정 쿼리가 CPU를 태우고 다른 쿼리까지 줄 세움 + → 대응: EXPLAIN ANALYZE, 인덱스, 쿼리 리라이트 +③ 디스크 IO 포화 데이터가 메모리(캐시)를 넘어서며 디스크를 두드리기 시작 + → 대응: 캐시(Valkey), 더 큰 메모리, 읽기 분산 +④ 쓰기 자체의 한계 아무리 튜닝해도 한 대가 받아낼 수 있는 쓰기엔 천장이 있다 + → 대응: 여기서야 비로소 '샤딩'이 등판 + +핵심: 복제와 샤딩은 ①~③을 건너뛰는 마법이 아니라, +①~③을 다 하고도 모자랄 때 꺼내는 '마지막 카드'입니다.`; + +const CODE_RW_SPLIT = `-- 확장을 결정하기 전에: 읽기 부하와 쓰기 부하를 '분리해서' 측정한다 +-- Postgres 통계 뷰로 테이블별 읽기/쓰기 비율 훑어보기 +SELECT relname, + seq_scan + idx_scan AS reads, -- 읽기(스캔 횟수) + n_tup_ins + n_tup_upd + n_tup_del AS writes -- 쓰기(행 변경 수) +FROM pg_stat_user_tables +ORDER BY reads + writes DESC +LIMIT 10; + +-- 읽기 : 쓰기 = 100 : 1 → 복제(읽기 분산)가 먼저 +-- 읽기 : 쓰기 = 2 : 1 → 캐시로 읽기를 줄이고, 쓰기 한계라면 샤딩 검토 +-- '어느 축이 아픈지'를 모르면 어느 약을 쓸지도 정할 수 없어요.`; + +const CODE_REPLICATION = `# Postgres 스트리밍 복제 — 리더의 WAL을 팔로워가 실시간으로 재생 +# 리더(primary) postgresql.conf +wal_level = replica # WAL에 복제용 정보를 담아라 +max_wal_senders = 5 # 동시에 WAL을 보낼 수 있는 팔로워 수 +synchronous_commit = off # off=비동기(빠름), on/remote_apply=동기(안전) + +# 팔로워(standby)는 리더의 스냅샷에서 출발한다 +pg_basebackup -h leader-host -D /var/lib/postgresql/data -R +# -R : standby.signal 생성 → 이 서버는 '읽기 전용 팔로워'로 기동 + +# 팔로워에 쓰기를 시도하면? +INSERT INTO posts ...; +-- ERROR: cannot execute INSERT in a read-only transaction +-- 쓰기는 오직 리더로. 팔로워는 읽기 전용 창구입니다.`; + +const CODE_LAG_BUG = `[복제 지연 300ms인 세상에서 벌어지는 일] + +t=0ms 사용자가 글 작성 → 리더 DB에 INSERT 커밋 (성공 응답) +t=50ms 사용자가 "내 글 목록" 새로고침 +t=51ms 로드밸런서가 이 SELECT를 '팔로워'로 보냄 +t=52ms 팔로워엔 아직 그 글이 도착 전 → 빈 목록 응답 + → 사용자: "방금 쓴 글이 사라졌어요!" (read-your-writes 위반) +t=300ms WAL이 팔로워에 재생 완료 → 이제야 글이 보임 + +DB는 한순간도 고장 나지 않았습니다. +'복제는 시간이 걸린다'는 물리 법칙을 설계가 잊었을 뿐이에요. + +해결 패턴 세 가지: +① 쓰기 직후 N초는 리더에서 읽기 (가장 단순, 리더 부하 소폭 증가) +② 세션 고정: 같은 사용자는 같은 복제본으로 (핀 고정, sticky) +③ 타임스탬프 라우팅: 내 마지막 쓰기 시각 ≤ 복제본 재생 시각인 곳만 선택`; + +const CODE_LAG_MONITOR = `-- 복제 지연 모니터링 — 리더에서 실행 +SELECT client_addr, + state, + pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes, + replay_lag AS lag_time +FROM pg_stat_replication; + +-- client_addr | state | lag_bytes | lag_time +-- 10.0.1.21 | streaming | 16384 | 00:00:00.12 ← 정상(수십 ms) +-- 10.0.1.22 | streaming | 91238400 | 00:00:07.4 ← 7초?! 경보 대상 + +-- 허용 한계는 '기능이 정한다': +-- 댓글 목록 1~2초 지연 → 대부분 무해 +-- 결제 잔액 조회 1초 지연 → 사고. 이런 읽기는 애초에 리더로 보낸다.`; + +const CODE_FAILOVER = `[비동기 복제 + 자동 failover에서 쓰기가 '증발'하는 시나리오] + +t=0 리더가 INSERT 커밋, 클라이언트에 "성공" 응답 + (비동기라서 WAL은 아직 팔로워로 전송 중) +t=10ms 리더 서버 전원 장애. 방금 그 WAL은 끝내 전달되지 못함 +t=3s 감시자(Patroni 등)가 리더 사망 판정 → 팔로워를 새 리더로 승격 +t=3s~ 새 리더에겐 그 INSERT가 '없다'. 클라이언트는 성공했다고 믿는 중 + +→ 커밋 응답을 받았어도, 비동기 복제에선 그 쓰기가 사라질 수 있다. + 잃으면 안 되는 쓰기(결제·주문)는 동기 복제(remote_apply)나 + 별도 원장(이벤트 로그)으로 지켜야 합니다. + +[스플릿 브레인 — 더 무서운 쪽] +네트워크 단절로 '옛 리더는 살아 있는데' 감시자만 죽었다고 판단하면? +→ 리더가 둘. 양쪽 모두 쓰기를 받으면 데이터가 갈라져 봉합 불능. +→ 그래서 failover엔 펜싱(fencing: 옛 리더를 확실히 격리/차단)과 + 과반수 합의(quorum)가 반드시 따라붙습니다. (분산 합의 강좌 재방문)`; + +const CODE_SHARD_KEY = `// 해시 샤딩 — 샤드 키(user_id)를 해시해 N개 샤드에 균등 분배 +// 학습 포인트: 어떤 키로 나누느냐가 샤딩 설계의 8할이다. +function shardOf(userId, shardCount) { + const h = hashCode(String(userId)); // 문자열 → 정수 해시 + return Math.abs(h) % shardCount; // 0 ~ shardCount-1 +} + +shardOf(1001, 2) // → 0번 샤드 (postgres-a) +shardOf(1002, 2) // → 1번 샤드 (postgres-b) + +// 해시 샤딩: 균등 분포 O, 범위 조회(id 1000~2000) X +// 레인지 샤딩: 범위 조회 O, 최신 구간에 쓰기 몰림(핫 샤드) 주의 +// +// 좋은 샤드 키의 두 조건 — 서로 당기는 힘이 반대라 '트레이드오프': +// ① 균등 분포: 특정 샤드에 데이터·트래픽이 쏠리지 않을 것 +// ② 조회 지역성: 자주 함께 읽는 데이터가 같은 샤드에 있을 것`; + +const CODE_SCATTER = `// 크로스 샤드 집계 — "전체 유저의 주문 총액"은 이제 한 방 쿼리가 아니다 +// 스캐터(scatter): 같은 질문을 모든 샤드에 뿌리고 +const partials = await Promise.all( + shards.map((db) => + db.query('SELECT count(*) AS cnt, sum(amount) AS total FROM orders') + ) +); + +// 개더(gather): 부분 결과를 애플리케이션에서 합친다 +const cnt = partials.reduce((acc, r) => acc + Number(r.rows[0].cnt), 0); +const total = partials.reduce((acc, r) => acc + Number(r.rows[0].total), 0); + +// 단일 DB에선 SQL 한 줄이던 것이 — 네트워크 왕복 N번 + 앱 레벨 병합이 됐다. +// count/sum은 이렇게 합칠 수 있지만, 정확한 전체 정렬 상위 10(top-N)이나 +// 크로스 샤드 JOIN·트랜잭션은 훨씬 어렵다. 샤딩의 진짜 비용은 여기에 있어요.`; + +const CODE_TIME_PARTITION = `-- 시간 기반 파티셔닝 — 시세·로그 같은 시계열의 정석 +CREATE TABLE ticks ( + symbol text NOT NULL, + price numeric NOT NULL, + traded_at timestamptz NOT NULL +) PARTITION BY RANGE (traded_at); + +CREATE TABLE ticks_2026_06 PARTITION OF ticks + FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); +CREATE TABLE ticks_2026_07 PARTITION OF ticks + FOR VALUES FROM ('2026-07-01') TO ('2026-08-01'); + +-- 보존 정책: 90일 지난 데이터 정리 +DELETE FROM ticks WHERE traded_at < now() - interval '90 days'; +-- → 수억 행을 한 줄씩 지우고, 죽은 튜플이 남아 VACUUM 대란 + +DROP TABLE ticks_2026_03; -- 또는 DETACH 후 DROP +-- → 파일째 삭제. 수억 행이 밀리초에 사라지고 부스러기도 없다 +-- 오래된 데이터를 '지우는' 게 아니라 '통째로 떨어뜨리는' 설계.`; + +const CODE_PRACTICE = `# 실습: user_id 해시 샤딩을 로컬 Postgres 2개로 흉내 내기 +# ① 샤드 두 개 띄우기 (포트만 다른 동일한 Postgres) +docker run -d --name shard-a -e POSTGRES_PASSWORD=dev -p 5433:5432 postgres:17 +docker run -d --name shard-b -e POSTGRES_PASSWORD=dev -p 5434:5432 postgres:17 + +# ② 양쪽에 같은 스키마 생성 +# CREATE TABLE orders (id bigserial, user_id bigint, amount numeric); + +# ③ 시드 스크립트: user_id를 hash % 2로 라우팅해 1만 건 INSERT +# shardOf(userId, 2) === 0 → 5433(shard-a), 1 → 5434(shard-b) + +# ④ 크로스 샤드 집계를 스캐터-개더로 직접 구현 +# - "전체 주문 총액" → 두 샤드에 sum 쿼리 후 앱에서 합산 +# - "주문 많은 유저 top 5" → 각 샤드 top 5 뽑아 병합 정렬 후 상위 5 +# (각 샤드에서 5개보다 적게 가져오면 왜 틀릴 수 있는지 생각해 보기) + +# ⑤ 같은 데이터를 단일 DB에 넣고 같은 질문을 SQL 한 줄로 실행 → 코드량·왕복 비교 +# "샤딩은 쓰기를 사고, 쿼리의 단순함을 판다"를 손으로 체감하는 게 목표`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '병목의 순서' }, + { n: 2, label: '레플리케이션' }, + { n: 3, label: '복제 지연 버그' }, + { n: 4, label: 'failover의 함정' }, + { n: 5, label: '파티셔닝·샤딩' }, + { n: 6, label: '샤딩의 부작용' }, + { n: 7, label: '시계열 파티션' }, + { n: 8, label: '실전 판단' }, +]; + +export default function Lv4DbReplicationShardingPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 특급 과정
+

🗄️ DB 레플리케이션과 샤딩
— 데이터베이스를 늘리는 두 가지 축

+

+ 앱 서버는 부족하면 한 대 더 띄우면 됩니다. 그런데 DB는요? 데이터가 한 + 곳에 있어야 한다는 성질 때문에, DB 확장에는 전혀 다른 문법이 필요해요. + 축은 두 개입니다. 읽기를 늘리는 복제(레플리케이션),{' '} + 쓰기를 늘리는 샤딩. 그리고 두 축 모두 공짜가 아니에요 — 복제 지연, + failover의 함정, 핫 샤드, 사라지는 JOIN까지, 대가를 함께 계산하는 설계를 배웁니다. +

+
+ 예상 소요 85분 + 실습: 2샤드 스캐터-개더 + 선수 지식: SQL·인덱스·분산 시스템 기초 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 트래픽이 늘 때 단일 DB는 보통 정해진 순서로 무너집니다. 먼저 + 커넥션이 고갈되고, 다음은 느린 쿼리가 CPU를 태우고, 그다음 데이터가 메모리를 넘어서며 + 디스크 IO가 포화되고, 마지막에야 쓰기 처리량 자체의 한계가 옵니다. + 이 순서가 중요한 이유는 — 각 단계마다 복제·샤딩보다 훨씬 싼 처방이 + 따로 있기 때문이에요. +

+ {CODE_BOTTLENECK} +

+ 그래서 확장을 결정하기 전에 반드시 물어야 할 질문이 있습니다.{' '} + "지금 아픈 건 읽기인가, 쓰기인가?" 읽기가 아프면 캐시와 복제가 약이고, + 쓰기가 아프면 그 둘은 아무 소용이 없어요(복제는 쓰기를 모든 복제본에 + 반복시키니 오히려 짐입니다). 읽기 부하와 쓰기 부하를 분리해 측정하는 습관이 + 설계의 출발점입니다. +

+ {CODE_RW_SPLIT} +
+ 순서를 지키는 이유 인덱스 한 줄이면 끝날 문제를 샤딩으로 풀면, 얻는 건 없고 + 운영 복잡도(섹션 6)만 평생 짊어집니다. 대부분의 서비스는 튜닝 + 캐시 + + 복제에서 끝나요. 샤딩까지 가는 서비스는 소수고, 가더라도 최대한 늦게 가는 게 + 이기는 설계입니다. +
+
+ +
+

+ 레플리케이션(복제)의 기본 형태는 리더-팔로워입니다. + 쓰기(INSERT·UPDATE·DELETE)는 오직 리더 한 대가 받고, 리더는 자신이 + 받은 변경 기록 — Postgres에선 WAL(Write-Ahead Log) — 을 팔로워들에게 + 흘려보냅니다. 팔로워는 그 로그를 같은 순서로 재생해서 리더와 같은 + 상태를 따라가고, 대신 읽기 전용 창구로 일합니다. +

+

+ 왜 데이터를 통째로 복사하지 않고 로그를 보낼까요? 로그는 "무슨 일이 + 있었는지"의 순서 있는 기록이라, 같은 로그를 같은 순서로 재생하면 반드시 같은 + 상태가 되기 때문이에요. 상태 복사보다 훨씬 싸고, 이어받기(중단 지점부터 재개)도 + 쉽습니다. +

+ {CODE_REPLICATION} +

+ 갈림길은 "리더가 언제 커밋 성공을 응답하느냐"입니다.{' '} + 비동기 복제는 리더가 자기 디스크에 쓰자마자 응답해요 — 빠르지만, + 팔로워에 도착하기 전에 리더가 죽으면 그 쓰기는 사라질 수 있습니다(섹션 4).{' '} + 동기 복제는 팔로워가 받았다는 확인까지 기다렸다가 응답해요 — 안전하지만 + 모든 쓰기에 네트워크 왕복이 얹혀 느려지고, 팔로워가 죽으면 쓰기가 멈출 수도 있죠.{' '} + 내구성과 지연의 거래입니다. +

+
+ 우리 스택과의 연결 traderbox의 Postgres 앞단을 떠올려 보세요. 공시·아티클처럼 + 읽기가 압도적인 데이터는 읽기 전용 레플리카를 붙여 SELECT를 분산하기 좋은 후보입니다. + 반대로 감사 로그처럼 쓰기 중심 데이터에 레플리카를 늘리는 건 복제 부담만 늘려요 — + 섹션 1의 "읽기/쓰기 분리 측정"이 여기서 판단 근거가 됩니다. +
+
+ +
+

+ 리더와 팔로워 사이에는 언제나 시간차(복제 지연, replication lag)가 + 있습니다. 평소엔 수십 ms지만, 트래픽이 몰리거나 팔로워가 느려지면 초 단위로 벌어져요. + 그리고 이 시간차는 아주 구체적인 버그로 나타납니다 — 방금 쓴 글이 새로고침하면 + 안 보이는 현상, 즉 read-your-writes(내 쓰기 읽기) 위반입니다. +

+ {CODE_LAG_BUG} +

+ 세 가지 해결 패턴엔 공통 원리가 있어요. "모든 읽기가 최신일 필요는 없다. + 단, '방금 쓴 사람의 읽기'만은 최신이어야 한다" — 전체를 동기 복제로 만드는 + 비싼 길 대신, 문제가 되는 그 경로만 리더로 보내거나 충분히 따라잡은 + 복제본으로 보내는 겁니다. 일관성을 전역이 아니라 경로 단위로 파는 + 사고방식이죠. +

+ {CODE_LAG_MONITOR} +
+ 지연은 '있다/없다'가 아니라 '얼마까지 허용'의 문제 복제 지연을 0으로 만들 수는 + 없습니다. 대신 기능별로 허용 한계를 정하세요 — "게시글 목록은 2초까지 OK, 잔액·주문 + 상태는 0초(리더 직행)". 그리고 replay_lag에 경보를 걸어 + 한계를 넘으면 해당 팔로워를 라우팅에서 빼는 것까지가 운영입니다. +
+
+ +
+

+ 리더는 한 대뿐이니, 리더가 죽으면 쓰기가 전부 멈춥니다. 그래서 팔로워 + 중 하나를 새 리더로 승격(promote)하는 절차가 필요해요. 이걸 사람이 + 하면 느리고(새벽 3시…), 자동으로 하면 빠르지만 — 자동 failover에는 분산 시스템의 + 고전적 함정이 둘 따라옵니다. +

+ {CODE_FAILOVER} +

+ 첫째 함정은 커밋의 증발입니다. 비동기 복제에서 "커밋 성공"은{' '} + "리더 한 대에 적혔다"는 뜻일 뿐, 살아남는다는 보장이 아니에요. 둘째 + 함정은 스플릿 브레인 — 죽은 줄 알았던 옛 리더가 살아 있어서 리더가 + 두 명이 되는 사고입니다. "느린 것과 죽은 것을 구분할 수 없다"는 분산 시스템의 근본 + 문제가, DB failover에서 가장 비싼 형태로 되돌아오는 거죠. +

+

+ 마지막 조각은 애플리케이션 쪽입니다. 리더가 바뀌면 앱의 커넥션 풀은 + 죽은 옛 리더를 물고 있어요. 그래서 실전에선 ① 커넥션 문자열이 아닌{' '} + 가상 주소(DNS·프록시·VIP)로 리더를 가리키고, ② 커넥션 풀에{' '} + 연결 검증과 재연결을 켜 두고, ③ failover 직후 몰려드는 재시도 폭풍을{' '} + 백오프로 눌러 줍니다. 승격은 DB의 일이지만, 무중단은 앱까지 합작해야 + 완성돼요. +

+
+ 왜 과반수인가 Patroni 같은 failover 도구가 etcd 등 합의 저장소의 + 과반수(quorum)를 요구하는 이유 — 반쪽 네트워크에서 양쪽이 각자 리더를 뽑는 + 사고를 막기 위해서예요. 과반수는 정의상 하나뿐이니까요. 분산 합의 강좌에서 본 원리가 + DB 운영의 심장부에서 그대로 재사용됩니다. +
+
+ +
+

+ 복제는 읽기를 늘리지만 쓰기는 여전히 리더 한 대의 몫입니다. 쓰기 자체를 늘리려면{' '} + 데이터를 쪼개서 여러 대에 나눠 담아야 해요. 쪼개는 방향은 둘입니다.{' '} + 수직 분할은 테이블(관심사) 단위로 나누는 것 — "주문 DB와 로그 DB를 + 분리"처럼요. 수평 분할(샤딩)은 같은 테이블의 을 + 나누는 것 — "user_id 홀수는 A 서버, 짝수는 B 서버"처럼요. +

+

+ 수평 분할에서 어떤 컬럼으로 나눌지가 바로 샤드 키이고, + 이 선택이 샤딩 설계의 8할입니다. 한번 정하면 데이터 전체가 그 기준으로 물리적으로 + 흩어지기 때문에, 바꾸려면 전 데이터를 옮겨야(리샤딩, 섹션 6) 하거든요. +

+ {CODE_SHARD_KEY} +

+ 나누는 방식은 크게 둘이에요. 해시 샤딩은 키를 해시해 균등하게 + 뿌립니다 — 분포는 고르지만 "user_id 1000~2000" 같은 범위 조회가 모든 샤드를 + 두드리게 되죠. 레인지 샤딩은 범위째 담습니다 — 범위 조회는 + 한 샤드로 끝나지만, 시간순 키라면 최신 구간 샤드에만 쓰기가 몰리는{' '} + 문제가 생겨요. 균등 분포와 조회 지역성, 두 미덕은 서로 당기는 방향이 반대라 여기서도 + 공짜 점심은 없습니다. +

+
+ 판단 기준 한 줄 "우리 서비스에서 가장 빈번한 쿼리가 무엇을 + 기준으로 데이터를 찾는가?"에서 출발하세요. 조회의 9할이 user_id 기준이라면 user_id가 + 유력한 샤드 키입니다 — 한 유저의 데이터가 한 샤드에 모여, 대부분의 쿼리가{' '} + 샤드 하나로 끝나기 때문이에요. +
+
+ +
+

+ 샤딩의 첫 번째 부작용은 핫 샤드입니다. user_id로 균등하게 나눠도,{' '} + 트래픽은 균등하지 않아요. 팔로워 천만의 유명 유저 한 명이 배정된 + 샤드는 혼자 불타고, 나머지 샤드는 한가합니다. 대응은 부하의 성격에 따라 다릅니다 — + 읽기 쏠림이면 그 키만 캐시로 막고, 쓰기 쏠림이면 뜨거운 키에 접미사를 + 붙여 여러 조각으로 더 쪼개는(키 스플리팅) 식이죠. +

+

+ 두 번째 부작용이 더 근본적입니다. 단일 DB의 최대 무기였던 JOIN·트랜잭션·집계가 + 샤드 경계를 넘는 순간 무력화돼요. 다른 샤드의 두 행을 한 트랜잭션으로 묶을 수 + 없고(분산 트랜잭션의 늪), JOIN은 네트워크 건너 데이터를 끌어와야 합니다. 우회는 두 + 갈래예요 — 자주 함께 읽는 데이터를 미리 한 샤드에 비정규화해 두거나, + 모든 샤드에 질문을 뿌리고 결과를 합치는 스캐터-개더를 쓰거나. +

+ {CODE_SCATTER} +

+ 세 번째는 리샤딩입니다. 샤드 2개를 4개로 늘리면? 순진한{' '} + hash % N 방식에선 N이 바뀌는 순간 거의 모든 + 키의 자리가 바뀌어 데이터 대이동이 벌어져요. 컨시스턴트 해싱은 + 키와 샤드를 같은 해시 링 위에 올려, 샤드를 추가해도 이웃 구간의 키만{' '} + 이동하게 만듭니다 — 이동량이 전체의 1/N 수준으로 줄죠. 캐시 분산에서 배운 그 기법이 + DB 샤딩에서도 같은 이유로 등장합니다. +

+
+ 샤딩 전 마지막 점검 샤딩을 도입하는 순간 잃는 것들의 목록을 적어 보세요 — + 크로스 샤드 JOIN, 전역 유니크 제약, 단일 트랜잭션, 쉬운 백업·마이그레이션. 이 목록을 + 보고도 쓰기 한계가 더 아프면 그때 샤딩입니다. 목록이 더 아파 보이면 아직 때가 아니에요. +
+
+ +
+

+ 시세·로그·감사 이벤트 같은 시계열 데이터는 성질이 독특합니다. 쓰기는 + 항상 '지금'에만 일어나고, 읽기는 최근에 몰리고, 오래된 데이터는 언젠가 버려져요. 이 + 성질에 딱 맞는 도구가 시간 기반 파티셔닝입니다 — 한 서버 안에서 + 테이블을 월·일 단위 조각으로 나누는, 말하자면 서버 내부의 레인지 샤딩 + 이죠. +

+ {CODE_TIME_PARTITION} +

+ 백미는 버리는 방식입니다. DELETE는 수억 + 행을 한 줄씩 지우며 죽은 튜플과 VACUUM 부담을 남기지만,{' '} + DROP TABLE은 파티션 파일째 떨어뜨려요 — + 밀리초에 끝나고 부스러기도 없죠. 보존 정책(retention)이 있는 데이터라면 + "지울 수 있게 설계"가 아니라 "떨어뜨릴 수 있게 설계"가 정답입니다. +

+

+ 한 걸음 더 — 저장소 자체를 나누는 것도 같은 사고의 연장입니다. + traderbox가 실시간 시세는 Valkey(휘발성·최신값 캐시)에, 공시·아티클은{' '} + Postgres에 담는 이유가 그거예요. "초당 수천 번 갱신되고 1초 뒤면 + 낡는 데이터"와 "한 번 쓰고 오래 읽는 데이터"는 요구 조건이 정반대니, 억지로 한 DB에 + 담지 않고 성질별로 다른 저장소에 분할한 겁니다. 샤딩적 사고는 꼭 + 같은 DB N대만 뜻하지 않아요. +

+
+ 파티션 수명 관리는 자동화가 기본 "매달 새 파티션 생성 + 기한 지난 파티션 + DROP"을 사람이 하면 언젠가 잊습니다 — 파티션이 없어 INSERT가 실패하는 사고가 월초에 + 터지죠. pg_partman 같은 확장이나 크론 잡으로 생성과 + 폐기를 묶어 자동화하는 것까지가 설계입니다. +
+
+ +
+

+ 가상 시나리오로 근육을 써 봅시다. 유저 1천만 명, 일 쓰기 5천만 건의 + 서비스라면 복제 먼저일까요, 샤딩 먼저일까요? 계산부터 — 5천만 건/86,400초 ≈{' '} + 평균 초당 약 580건, 피크를 10배로 잡아도 수천 TPS입니다. 잘 튜닝된 + Postgres 한 대가 받아낼 수 있는 범위예요. 그럼 순서는 이렇게 나옵니다: + 읽기 부하는 캐시 + 읽기 레플리카로 받고(복제 먼저), 쓰기는 튜닝하며 버티다, + 쓰기 성장 곡선이 한 대의 천장에 닿을 전망이 보일 때 샤딩을 준비한다 — + 숫자 없이 "1천만이면 샤딩이지"라고 답했다면, 그게 이 강좌가 고치려는 반사신경입니다. +

+

+ 관리형 서비스(RDS·Aurora)는 이 그림의 어디를 대신해 줄까요? 복제본 + 생성, 자동 failover, 백업, 패치 — 섹션 2·4의 운영 노동은 크게 덜어 + 줍니다. 하지만 복제 지연은 물리 법칙이라 여전히 존재하고(read-your-writes + 설계는 내 몫), 샤드 키 선택·크로스 샤드 쿼리·핫 샤드 대응도 서비스가 대신 정해 주지 + 않아요. 관리형은 운영을 사는 것이지, 설계 면제권을 사는 게 아닙니다. +

+

+ 마지막으로 NewSQL(분산 SQL) — CockroachDB, Spanner, TiDB 같은 + 시스템은 "샤딩을 DB 안으로 숨기고, 분산 트랜잭션과 SQL을 그대로 주겠다"고 약속합니다. + 실제로 리샤딩 자동화·분산 트랜잭션은 크게 편해져요. 대신 합의(quorum) 기반 쓰기라{' '} + 단일 노드 대비 지연이 길고, 핫스팟 문제는 여전하며, 운영 난도와 비용이 + 만만치 않습니다. 도구가 바뀌어도 이 강좌에서 배운 트레이드오프의 축은 그대로 + 예요 — 그래서 원리를 배운 겁니다. +

+ {CODE_PRACTICE} +
+ 실습에서 꼭 느껴야 할 것 ④의 top-N 병합에서 "각 샤드 top 5를 모아 다시 top 5"가 + 왜 안전한지(그리고 각 샤드에서 3개만 가져오면 왜 답이 틀릴 수 있는지)를 스스로 + 설명해 보세요. 크로스 샤드 쿼리의 미묘함이 이 한 문제에 압축돼 있습니다. +
+
+ +
+
+

🗄️ 여기까지 왔다면

+

+ 이제 "DB가 느려요"라는 말에 어느 축이 아픈지부터 묻게 됐을 거예요. + 병목의 순서, 읽기를 늘리는 복제와 그 그림자(지연·failover), 쓰기를 늘리는 샤딩과 + 그 대가(핫 샤드·사라진 JOIN·리샤딩), 그리고 시계열은 떨어뜨리게 설계한다는 감각까지 — + 데이터 계층을 늘리는 두 축을 손에 쥐었습니다. 그런데 DB는 시스템의 한 층일 뿐이죠. + 로드밸런서부터 캐시, 큐, DB까지 요청이 지나는 전체 경로를 놓고 + 병목을 설계하는 법은 다음{' '} + 대용량 트래픽 설계 — 밀려오는 + 요청 앞에서 무너지지 않기 강좌에서 이어집니다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv4DistributedSystemsBasicsPage.jsx b/frontend/src/pages/levels/Lv4DistributedSystemsBasicsPage.jsx new file mode 100644 index 0000000..0f32fe0 --- /dev/null +++ b/frontend/src/pages/levels/Lv4DistributedSystemsBasicsPage.jsx @@ -0,0 +1,544 @@ +// 이 파일이 하는 일: "분산 시스템 첫걸음 — 왜 서버 한 대로는 안 될까" 코스 — 서버가 여러 대가 +// 되는 순간 새로 생기는 근본 문제들(부분 실패, CAP, 일관성 모델, 시간)을 7개 섹션으로 안내하는 +// 정적 학습 페이지. 프레이밍은 "특급 과정 — 대규모 트래픽과 분산 환경을 설계하는 시야" — +// 코드 한 줄보다 '어떤 데이터에 어떤 일관성이 필요한가'를 스스로 판단하는 감각을 기른다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_SCALE = `수직 확장(스케일업) 수평 확장(스케일아웃) +"더 좋은 서버 한 대" "평범한 서버 여러 대" + +비용 | * 비용 | + | * | **** + | * | **** + | * | **** + | * | *** + +------------ 성능 +------------ 성능 + 성능이 오를수록 가격이 '기하급수' 대체로 '선형'으로 늘어난다 + 그리고 물리적 상한이 있다 대신 소프트웨어가 복잡해진다 + +핵심: 스케일업은 언젠가 벽을 만나요. 그 벽 앞에서 서버를 2대로 +늘리는 순간 — 분산 시스템의 모든 문제가 한꺼번에 시작됩니다. +상태를 어디에 둘지, 두 서버의 시계가 다르면 어쩔지, 한 대만 +죽으면 어떻게 알아챌지. 이 코스는 그 문제들의 지도입니다.`; + +const CODE_FALLACIES = `분산 컴퓨팅의 8가지 오류 (fallacies of distributed computing) +— 신입 설계자가 '참'이라고 믿어 버리기 쉬운 8가지 거짓말 + +1. 네트워크는 신뢰할 수 있다 → 아니다. 패킷은 유실된다. +2. 지연 시간(latency)은 0이다 → 아니다. 서울-버지니아는 왕복 ~180ms. +3. 대역폭은 무한하다 → 아니다. 시세 틱을 다 쏘면 터진다. +4. 네트워크는 안전하다 → 아니다. 그래서 내부 호출도 키를 검증한다. +5. 토폴로지는 변하지 않는다 → 아니다. 컨테이너는 수시로 죽고 뜬다. +6. 관리자는 한 명이다 → 아니다. 팀·클라우드·통신사가 얽힌다. +7. 전송 비용은 0이다 → 아니다. 직렬화·복사·과금이 붙는다. +8. 네트워크는 균질하다 → 아니다. 구간마다 속도·정책이 다르다. + +이 8줄을 뒤집으면 그대로 분산 설계 체크리스트가 됩니다.`; + +const CODE_TIMEOUT = `// "응답이 안 온다"의 세 가지 가능성 — 호출자는 구분할 수 없다 +// +// 클라이언트 ──요청──> ??? +// +// 경우 A: 요청이 서버에 도착하지 못함 (처리 안 됨) +// 경우 B: 서버가 받아서 처리 중 / 처리 완료 (처리 됨!) +// 경우 C: 처리는 끝났는데 응답만 유실됨 (처리 됨!) +// +// 타임아웃이 터졌을 때 보이는 건 셋 다 똑같이 '침묵'이에요. +// 그래서 재시도는 "이미 처리됐을 수도 있다"를 전제해야 합니다. + +// 멱등성(idempotency): 같은 요청을 몇 번 보내도 결과가 한 번과 같게 +POST /api/orders +Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 + +// 서버는 이 키를 본 적 있으면 '저장해 둔 그때의 응답'을 그대로 돌려준다. +// 잔고 차감이 두 번 일어나는 사고는 이 한 줄이 막는다.`; + +const CODE_BACKOFF = `// 재시도 폭풍(retry storm): 서버가 느려짐 → 전원이 타임아웃 +// → 전원이 '동시에' 재시도 → 부하 2배 → 더 느려짐 → 또 재시도... +// 도우려던 재시도가 장애를 눈덩이처럼 키우는 악순환이에요. + +// 처방 ①: 지수 백오프 — 간격을 1s, 2s, 4s, 8s... 로 늘린다 +// 처방 ②: 지터(jitter) — 간격에 무작위를 섞어 '동시에'를 깨뜨린다 +long delayMs = baseMs * (1L << attempt); // 지수 백오프 +delayMs = ThreadLocalRandom.current() + .nextLong(0, delayMs + 1); // full jitter +Thread.sleep(Math.min(delayMs, maxDelayMs)); // 상한도 필수 + +// 처방 ③: 재시도 횟수 상한 + 회로 차단(circuit breaker). +// "포기할 줄 아는 클라이언트"가 시스템 전체를 살립니다.`; + +const CODE_CAP = `CAP 정리 — 흔한 오해와 정확한 문장 + +[오해] "C, A, P 셋 중 마음에 드는 두 개를 고르세요" +[정확] "네트워크 분할(P)이 '일어났을 때', C와 A 중 하나를 포기해야 한다" + + C (Consistency) 모든 노드가 같은 순간, 같은 데이터를 보인다 + A (Availability) 모든 요청이 (오래된 값일지라도) 응답을 받는다 + P (Partition tolerance) 노드 사이 통신이 끊겨도 시스템이 동작한다 + +네트워크는 반드시 언젠가 끊깁니다(8가지 오류 1번). 그래서 P는 +'선택지'가 아니라 '전제'예요. 실제 선택은 이것뿐입니다: + + 분할이 일어난 순간 → CP: 일관성을 지키려 일부 요청을 거절한다 + AP: 응답은 계속 주되 잠시 어긋남을 허용한다 + + CP 예: 결제 잔고 — 틀린 잔고를 보여줄 바엔 잠깐 멈추는 게 낫다 + AP 예: 좋아요 수 — 숫자가 3초 어긋나도 서비스는 계속 돌아야 한다`; + +const CODE_PACELC = `PACELC — CAP의 확장판: "분할이 없을 때도 공짜는 없다" + + if (Partition) → A(가용성) vs C(일관성) ... 여기까지가 CAP + Else → L(지연) vs C(일관성) ... CAP이 놓친 절반 + +평상시(분할 없음)에도, 강한 일관성을 원하면 여러 노드의 확인을 +기다려야 하므로 응답이 '느려'집니다. 빠른 응답을 원하면 가까운 +복제본 하나만 읽으므로 '오래된 값'일 수 있고요. + + PA/EL 성향: DynamoDB, Cassandra — 늘 빠르게, 어긋남 허용 + PC/EC 성향: 전통 RDB + 동기 복제 — 늘 정확하게, 지연 감수 + +즉 일관성의 값은 장애 때만 내는 게 아니라, 매 요청의 +지연 시간으로 '평소에도 조금씩' 지불하고 있는 셈입니다.`; + +const CODE_CONSISTENCY = `일관성 모델 스펙트럼 — 강할수록 느리고, 약할수록 빠르다 + +강함 ◀──────────────────────────────────────────▶ 약함 + +선형성(linearizability) + "쓰기가 끝난 '실제 시각' 이후의 모든 읽기는 새 값을 본다" + 단일 서버처럼 행동. 가장 직관적, 가장 비싸다. + +순차 일관성(sequential) + "모두가 '같은 순서'로 사건을 본다 — 그 순서가 실제 시각과 + 일치한다는 보장은 없다" + +인과적 일관성(causal) + "원인과 결과의 순서만 지킨다 — 댓글이 원글보다 먼저 보이는 + 일은 없다. 서로 무관한 사건의 순서는 어긋나도 된다" + +최종 일관성(eventual) + "쓰기가 멈추면 '언젠가는' 모두 같은 값에 수렴한다" + 언제인지는 약속하지 않는다. 가장 싸고, 가장 조심해야 한다.`; + +const CODE_CLIENT_CENTRIC = `클라이언트 중심 보장 — 사용자 한 명의 눈높이에서 본 일관성 + +read-your-writes "내가 쓴 건 내가 바로 읽을 수 있다" + → 프로필을 수정했는데 새로고침하니 옛 사진이 보인다? 이 보장이 + 없어서예요. 내 쓰기가 간 노드에서 내 읽기도 처리하면 해결. + +monotonic reads "한번 본 값보다 과거로 돌아가지 않는다" + → 새 댓글 3개를 봤는데 새로고침하니 1개로 줄었다? 요청이 + 복제 지연이 다른 노드로 번갈아 가서 그래요. 같은 사용자를 + 같은 복제본에 붙이면(sticky session) 해결. + +시스템 전체는 최종 일관성이어도, 이 두 가지만 지켜 주면 +사용자가 체감하는 '이상함'의 대부분이 사라집니다.`; + +const CODE_QUORUM = `쿼럼(quorum) — 복제본 N개에서 몇 개의 동의를 받을 것인가 + + N = 복제본 수, W = 쓰기 성공에 필요한 응답 수, R = 읽기 시 확인하는 수 + + 규칙: W + R > N 이면 읽기와 쓰기가 반드시 '한 노드 이상 겹친다' + → 읽기가 최소 하나는 최신 값을 만난다 + + 예) N=3, W=2, R=2 → 2+2 > 3 ✓ 균형형 (흔한 기본값) + N=3, W=3, R=1 → 쓰기는 느리지만 읽기가 매우 빠르다 + N=3, W=1, R=1 → 1+1 < 3 ✗ 빠르지만 낡은 값을 읽을 수 있다 + +다수결(과반)이 필요한 이유: 노드가 5대일 때 3대의 동의를 받으면, +어떤 다른 결정도 3대를 못 모은다(3+3 > 5, 반드시 겹침). +'서로 모순되는 두 결정'이 동시에 성립하는 걸 수학적으로 막는 장치.`; + +const CODE_RAFT = `Raft 합의 알고리즘 — 큰 그림 세 단계 (암기 말고 흐름만) + +① 리더 선출 (leader election) + 팔로워는 리더의 심장박동(heartbeat)을 기다린다. + 일정 시간 못 받으면 → "리더가 죽었나?" → 후보로 나서서 투표 요청. + 과반 득표한 노드가 새 리더. (과반이므로 리더는 동시에 둘일 수 없다) + +② 로그 복제 (log replication) + 모든 쓰기는 리더로만 들어온다. 리더는 자기 로그에 적고, + 팔로워들에게 복사본을 보낸다. + +③ 커밋 (commit) + 과반의 팔로워가 "받았다"고 답하면 그때 확정(commit)하고 + 클라이언트에 성공을 알린다. 과반에 새겨진 기록은 + 리더가 죽어도 다음 리더에게 반드시 살아남는다. + +etcd(쿠버네티스의 두뇌), Consul 등이 Raft로 굴러갑니다.`; + +const CODE_LAMPORT = `// 램포트 타임스탬프 — 물리 시계 없이 사건의 '순서' 정하기 +// 규칙은 딱 두 개: +// ① 내 사건이 하나 생길 때마다 내 카운터를 +1 +// ② 메시지를 받으면: 내 카운터 = max(내 것, 받은 것) + 1 + +서버A: 카운터 1 → 2 → 3(메시지 발송, 3을 실어 보냄) +서버B: 카운터 1 → 수신! max(1, 3)+1 = 4 + +이제 "발송(3) → 수신(4)"의 인과 순서가 숫자로 남았어요. +서버 A와 B의 벽시계가 몇 초씩 어긋나 있어도 상관없습니다. + +한계: 카운터가 5와 7이라고 5가 '먼저 일어났다'는 보장은 없어요. +(서로 무관한 사건일 수 있음) — 인과가 있으면 숫자가 크다, +의 역은 성립하지 않는다는 것. 이 한계를 메우려고 벡터 시계가 나옵니다.`; + +const CODE_LWW = `LWW(Last-Write-Wins) — "타임스탬프가 늦은 쓰기가 이긴다" + + 서버A (시계가 30초 빠름) 서버B (시계 정상) + 10:00:30 잔고 = 5000 저장 10:00:10 잔고 = 8000 저장 + (실제로는 B가 '나중' 쓰기!) + + 충돌 해소: LWW → 타임스탬프 비교 → 10:00:30 > 10:00:10 + → A의 5000이 승리 → B의 정당한 쓰기가 '조용히 증발' + +무서운 점: 에러도 로그도 없이 데이터가 사라진다는 것. +NTP로 시계를 맞춰도 수십 ms 오차는 남고, 가상머신은 초 단위로 +튀기도 해요. 그래서 '무엇이 이길지'가 돈과 관련되면 LWW 대신 +버전 번호, 벡터 시계, 혹은 아예 단일 리더 직렬화를 씁니다.`; + +const CODE_TRADERBOX = `traderbox의 실제 선택 — 같은 제품, 다른 일관성 + +┌──────────────┬──────────────────────┬─────────────────────┐ +│ 데이터 │ 시세 틱 (실시간 가격) │ 인증·회원·잔고성 │ +├──────────────┼──────────────────────┼─────────────────────┤ +│ 저장/전달 │ Valkey Pub/Sub │ 단일 PostgreSQL │ +│ │ (market.ticks 채널) │ (스키마-퍼-서비스) │ +│ 성향 │ AP 쪽 │ CP 쪽 (강한 일관성) │ +│ 유실되면? │ 다음 틱이 곧 덮는다 │ 사고 (복구 불가) │ +│ 지연되면? │ 치명적 (가치 소멸) │ 수백 ms는 감수 가능 │ +│ 재전송? │ 안 한다 (fire&forget) │ 트랜잭션으로 보장 │ +└──────────────┴──────────────────────┴─────────────────────┘ + +시세: "1초 전 가격"은 이미 낡은 데이터 — 유실 복구에 시간을 +쓰느니 최신 틱을 빨리 흘리는 게 낫다. 그래서 Pub/Sub. +인증·잔고: 한 건이라도 어긋나면 안 된다 — 그래서 트랜잭션이 +있는 단일 Postgres. 속도는 일관성 값으로 지불한다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '단일 서버의 한계' }, + { n: 2, label: '부분 실패와 타임아웃' }, + { n: 3, label: 'CAP 정리' }, + { n: 4, label: '일관성 모델' }, + { n: 5, label: '복제와 합의' }, + { n: 6, label: '분산 시스템의 시간' }, + { n: 7, label: 'traderbox로 보기' }, +]; + +export default function Lv4DistributedSystemsBasicsPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 특급 과정
+

분산 시스템 첫걸음
— 왜 서버 한 대로는 안 될까

+

+ 서버가 한 대일 때 세상은 단순해요. 트랜잭션은 원자적이고, 시계는 + 하나고, 죽으면 그냥 다 죽습니다. 그런데 트래픽이 자라 서버가 두 대가 + 되는 순간 — 상태 공유, 부분 실패, 시계 불일치라는 전혀 새로운 종류의 문제가 + 한꺼번에 태어납니다. 이 코스에서는 CAP, 일관성 모델, 부분 실패를 + 몸으로 이해하고, 우리 traderbox의 실제 설계 결정으로 그 감각을 검증해 봅니다. +

+
+ 예상 소요 80분 + 실습: 데이터 10가지 CP/AP 분류표 + 선수 지식: HTTP·DB 트랜잭션·백엔드 실무 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 트래픽이 늘면 처음엔 수직 확장(스케일업)을 해요. CPU를 더 좋은 걸로, + 메모리를 더 크게 — 코드는 한 줄도 안 바꿔도 되니까요. 문제는 비용 곡선입니다. + 성능이 2배인 서버는 값이 2배가 아니라 4배, 8배로 뛰고, 어느 지점부터는 + 돈이 있어도 살 수 없는 사양이 돼요. 그래서 결국{' '} + 수평 확장(스케일아웃) — 평범한 서버를 여러 대 놓는 길로 갑니다. +

+ {CODE_SCALE} +

+ 서버가 2대가 되는 순간, 한 대일 땐 존재하지 않던 문제 세 가지가 태어납니다.{' '} + ① 상태 공유 — 사용자의 세션이 1번 서버에 있는데 다음 요청이 2번 + 서버로 가면? ② 시계 불일치 — 두 서버의 시계가 몇 초 어긋나 있으면 + "어느 쓰기가 먼저인가"를 누가 정하나요? ③ 부분 실패 — 한 대일 땐 + 죽으면 다 죽어서 차라리 깔끔했는데, 이젠 절반만 죽은 상태가 + 생깁니다. 분산 시스템의 어려움은 대부분 이 세 번째, 부분 실패에서 나와요. +

+

+ 이런 함정을 선배들이 여덟 줄로 정리해 둔 게{' '} + 분산 컴퓨팅의 8가지 오류입니다. 1990년대 Sun Microsystems의 + 엔지니어들이 남긴 목록인데, 30년이 지난 지금도 한 줄도 낡지 않았어요. +

+ {CODE_FALLACIES} +
+ 읽는 법 이 8가지는 "외워야 할 지식"이 아니라 설계 리뷰 때 던지는 + 질문이에요. 새 기능이 서비스 간 호출을 추가한다면 — "이 호출이 유실되면? + 느려지면? 상대가 죽어 있으면?" 을 습관처럼 묻는 것. 그 질문이 이 코스 전체의 + 출발점입니다. +
+
+ +
+

+ 단일 서버 안의 함수 호출은 성공 아니면 예외, 둘 중 하나예요. 그런데 + 네트워크 건너편으로의 호출엔 제3의 상태가 있습니다 — 침묵. + 타임아웃이 터졌을 때, 요청이 아예 도착을 못 한 건지, 서버가 지금 처리 중인 건지, + 처리는 끝났는데 응답만 유실된 건지 호출자는 절대 구분할 수 없어요. + 이게 분산 시스템에서 가장 근본적인 불확실성입니다. +

+ {CODE_TIMEOUT} +

+ 그래서 타임아웃 + 재시도를 설계할 땐 반드시{' '} + 멱등성(idempotency)이 세트예요. "이미 처리됐을지도 모르는 요청을 + 한 번 더 보내도 안전한가?" — 조회(GET)는 원래 안전하지만, 주문 생성이나 잔고 차감 + 같은 쓰기는 Idempotency-Key 같은 장치로 "같은 요청 두 번 = + 한 번"을 서버가 보장해 줘야 재시도를 마음 놓고 걸 수 있습니다. 멱등성 없는 재시도는 + 중복 결제 사고의 지름길이에요. +

+

+ 재시도엔 함정이 하나 더 있어요. 서버가 살짝 느려진 순간, 수천 개의 클라이언트가{' '} + 동시에 타임아웃을 맞고 동시에 재시도하면 — 부하가 + 두 배가 되고, 서버는 더 느려지고, 또 전원이 재시도하는 악순환,{' '} + 재시도 폭풍(retry storm)이 붑니다. +

+ {CODE_BACKOFF} +
+ 지터가 핵심인 이유 지수 백오프만 쓰면 "1초 뒤, 2초 뒤, 4초 뒤"에 여전히{' '} + 모두가 동시에 몰려옵니다. 파도가 늦게 올 뿐 파도인 건 같아요. + 무작위(지터)를 섞어야 파도가 이슬비로 흩어집니다. "백오프는 간격을 + 벌리고, 지터는 발맞춤을 깨뜨린다" — 둘은 항상 한 쌍입니다. +
+
+ 타임아웃 값은 공짜가 아니다 타임아웃을 짧게 잡으면 멀쩡히 처리 중인 요청을 + 실패로 오판하고(→ 중복 위험), 길게 잡으면 죽은 서버를 붙들고 스레드가 고갈됩니다. + 정답 값은 없고, 그 호출의 정상 응답 분포(예: p99 지연)를 보고 + 정하는 게 실무의 방법이에요. +
+
+ +
+

+ 분산 시스템에서 가장 유명하고, 가장 자주 틀리게 인용되는 정리가 + CAP입니다. "일관성·가용성·분할내성 중 두 개를 고르세요"라는 문장은 시험엔 나올지 + 몰라도 설계엔 못 씁니다. 정확한 문장은 이거예요 — "네트워크 분할이 일어난 + 동안, 일관성과 가용성을 동시에 가질 수 없다." +

+ {CODE_CAP} +

+ 왜 P가 전제냐면 — 섹션 1의 8가지 오류 1번, "네트워크는 신뢰할 수 있다"가 거짓이기 + 때문이에요. 스위치 고장, 케이블 절단, GC 멈춤으로 인한 오탐까지, 분할은{' '} + 반드시 언젠가 일어납니다. "P를 포기한다"는 건 "네트워크가 절대 안 + 끊긴다고 가정한다"는 뜻인데, 그건 설계가 아니라 기도죠. 그래서 실전 질문은 늘 + 하나입니다 — "분할이 일어난 그 순간, 우리는 멈출 것인가(CP), 어긋남을 + 허용할 것인가(AP)?" +

+

+ 그리고 이 선택은 시스템 전체가 아니라 데이터마다 다르게 내릴 수 + 있어요. 결제 잔고는 CP — 틀린 잔고로 출금을 허용하느니 "잠시 후 다시 시도해 + 주세요"가 낫습니다. 좋아요 수는 AP — 숫자가 3초쯤 어긋나도 서비스가 멈추는 것보단 + 백 배 낫죠. 섹션 7에서 우리 traderbox가 정확히 이 논리로 갈라지는 걸 보게 됩니다. +

+ {CODE_PACELC} +
+ PACELC로 한 단계 더 CAP은 "장애가 났을 때" 이야기만 해요. PACELC는 거기에 + "평상시(Else)에도 지연(L) vs 일관성(C) 트레이드오프가 있다"를 + 덧붙입니다. 강한 일관성은 매 요청마다 여러 노드의 확인을 기다리는 지연 + 비용을 항상 내고 있다는 것 — 일관성은 장애 보험료가 아니라{' '} + 월 구독료에 가깝습니다. +
+
+ +
+

+ CP냐 AP냐는 시작일 뿐이에요. "일관성을 얼마나 느슨하게 할 것인가"엔 훨씬 고운 + 눈금이 있습니다. 이 눈금을 일관성 모델이라 불러요. 강할수록 + 직관적이고 느리며, 약할수록 빠르고 위험합니다. +

+ {CODE_CONSISTENCY} +

+ 가장 강한 선형성은 "분산돼 있다는 사실 자체를 숨기는" 보장이에요. + 쓰기가 완료된 실제 시각 이후의 읽기는 어느 노드로 가든 새 값을 봅니다. 대신 매 + 연산마다 노드 간 조율이 필요해 비쌉니다. 인과적 일관성은 실무에서 + 가성비가 좋은 중간 지점이에요 — "댓글이 원글보다 먼저 보이는" 종류의 어색함만 막고, + 서로 무관한 사건의 순서는 놓아줍니다. 최종 일관성은 "언젠가는 + 맞는다"만 약속해요. 그 "언젠가"가 오기 전의 창(window)에서 무슨 일이 벌어져도 + 되는지를 설계자가 판단해야 합니다. +

+

+ 시스템 전체의 보장과 별개로, 사용자 한 명의 눈높이에서 지켜 줘야 할 + 최소한이 있어요. 이걸 클라이언트 중심 보장이라 부릅니다. +

+ {CODE_CLIENT_CENTRIC} +

+ 기준을 하나로 정리하면 이렇습니다. SNS 팔로워 수는 잠깐 안 맞아도 + 돼요 — 어긋난 값을 봐도 사용자가 내리는 결정이 달라지지 않고, 곧 수렴하며, 틀린 + 기간의 피해가 0에 가깝기 때문이에요. 계좌 잔고는 안 됩니다 — 어긋난 + 값이 다음 행동(출금·주문)의 입력이 되고, 그 행동은 되돌릴 수 없기 + 때문이에요. "이 값이 잠깐 틀리면, 그걸 보고 누가 무엇을 결정하는가?"가 일관성 + 수준을 정하는 질문입니다. +

+
+ 실습 예고 이 질문을 이 코스의 실습에서 그대로 씁니다 — 우리 서비스의 데이터 + 10가지를 골라 CP/AP 성향과 필요한 일관성 수준을 표로 분류하고, 근거를 한 줄씩 + 적어 보세요. "왜"를 적을 수 없다면 아직 그 데이터를 이해 못 한 거예요. +
+
+ +
+

+ 가용성을 위해 데이터를 여러 노드에 복제하면, 이번엔 "누구 말이 + 진짜인가"라는 문제가 생깁니다. 가장 흔한 답은 리더-팔로워 복제 — + 쓰기는 리더 한 곳으로만 받고, 팔로워들은 그걸 복사합니다. 쓰기 충돌이 원천적으로 + 없어서 단순하죠. 그런데 리더가 죽으면? +

+

+ 팔로워 중 하나를 새 리더로 승격시키면 되는데, 여기 무서운 함정이 있어요. 옛 리더가 + 진짜 죽은 게 아니라 네트워크가 잠깐 끊겼던 것뿐이라면? 잠시 후 + 돌아온 옛 리더는 자기가 아직 리더인 줄 알고 쓰기를 계속 받습니다. 이제 리더가{' '} + — 서로 모르는 채 각자 다른 데이터를 쌓아 가는 이 상태를{' '} + 스플릿 브레인(split brain)이라 부릅니다. 나중에 발견해도 두 갈래의 + 데이터를 합칠 방법이 없는, 분산 시스템 최악의 사고 중 하나예요. +

+

+ 해독제가 다수결입니다. "과반의 동의 없이는 아무도 리더가 될 수 + 없다"고 정해 두면, 과반은 정의상 하나뿐이므로 리더도 하나뿐이에요. 이 다수결을 + 읽기·쓰기에 일반화한 게 쿼럼입니다. +

+ {CODE_QUORUM} +

+ 그리고 "리더 선출과 로그 복제를 다수결로 안전하게 해내는 절차"를 통째로 알고리즘으로 + 만든 것이 합의(consensus) 알고리즘이에요. 원조는 Paxos지만 + 악명 높게 어렵고, 요즘의 표준은 이해 가능성을 목표로 설계된 Raft입니다. +

+ {CODE_RAFT} +
+ 직접 구현하지 마세요 합의 알고리즘은 "큰 그림을 이해하고, 검증된 구현(etcd, + ZooKeeper, 각 DB 내장 합의)을 쓰는" 영역이에요. 논문 그대로 구현해도 + 엣지 케이스(리더 교체 중의 중복 커밋 등)에서 수년 묵은 버그가 나오는 게 이 동네입니다. + 여러분이 가져갈 것은 구현이 아니라 "과반이 왜 안전한가"라는 직관이에요. +
+
+ +
+

+ 단일 서버에선 "A가 B보다 먼저 일어났다"를 시계 한 번 보면 알아요. 분산 환경에선 + 이 당연한 것이 무너집니다. 각 서버의 물리 시계는 수정 진동자의 + 미세한 편차 때문에 계속 어긋나고(drift), NTP로 주기적으로 맞춰도 + 네트워크 지연 때문에 수 ms~수백 ms의 오차가 남아요. 심지어 NTP가 시계를 뒤로{' '} + 되감는 순간도 있습니다 — "시간은 단조 증가한다"는 가정마저 깨지는 + 거죠. +

+

+ 그래서 분산 시스템은 발상을 바꿉니다 — "몇 시에 일어났나"를 포기하고{' '} + "어느 것이 먼저인가"만 지키자. 이게 논리적 시계고, + 가장 단순한 형태가 램포트 타임스탬프예요. +

+ {CODE_LAMPORT} +

+ 이 원리를 모르면 밟게 되는 지뢰가 LWW(Last-Write-Wins)입니다. + "충돌하면 타임스탬프가 늦은 쓰기가 이긴다" — 문장은 합리적으로 들리지만, 그 + 타임스탬프가 어긋난 물리 시계에서 나온다는 게 함정이에요. +

+ {CODE_LWW} +
+ 그래서 언제 LWW를 써도 되나 "마지막 값만 의미 있고, 중간 값이 증발해도 아무도 + 다치지 않는" 데이터 — 예를 들어 사용자의 마지막 접속 시각, 캐시된 + 프로필 사진 URL 같은 것들이에요. 반대로 잔고·재고·주문 상태처럼 모든 + 쓰기가 의미 있는 데이터에 LWW를 쓰는 건, 시계 오차만큼의 확률로 돈을 지우는 + 장치를 설치하는 것과 같습니다. +
+
+ +
+

+ 이제 배운 렌즈로 우리 제품을 봅시다. traderbox에는 성격이 정반대인 + 데이터 두 부류가 공존해요 — 시세 틱인증·잔고성 + 데이터. 그리고 실제 아키텍처가 이 둘을 정확히 다르게 다룹니다. +

+ {CODE_TRADERBOX} +

+ 시세는 왜 AP 성향인가. 시세 데이터의 가치는 신선도가 + 전부예요. 1초 전 가격은 이미 낡은 데이터고, 유실된 틱을 재전송받는 사이에 새 틱이 + 두 개 더 도착합니다. 그래서 Valkey Pub/Sub(market.ticks{' '} + 채널)로 흘려보내고 잊는 구조를 씁니다 — 구독자가 잠깐 끊겨 몇 틱을 + 놓쳐도, 다음 틱이 곧 도착해 화면을 최신으로 되돌려요. 일종의 자기 치유죠. 대신 + "모든 틱이 반드시 전달된다"는 보장은 일부러 포기했습니다. +

+

+ 인증·잔고성 데이터는 왜 CP 성향인가. 회원 정보, 토큰, 포트폴리오 + 기록은 정반대예요 — 한 건의 유실이나 어긋남이 그대로 사고이고, + "다음 값이 곧 덮어 준다"는 자기 치유도 없습니다. 그래서 트랜잭션이 보장되는{' '} + 단일 PostgreSQL(스키마-퍼-서비스)에 두고, 강한 일관성을 유지해요. + 수백 ms의 지연은 감수합니다 — 로그인이 0.2초 느린 건 아무도 기억 못 하지만, 잔고가 + 틀린 건 모두가 기억하니까요. +

+

+ 여기서 가져갈 관점은 하나입니다 — "우리 시스템은 CP인가 AP인가"는 잘못된 + 질문이라는 것. 옳은 질문은 "이 데이터는 CP인가 AP인가"예요. + 같은 제품 안에서도 데이터마다 일관성 요구가 다르고, 좋은 아키텍처는 그 차이를 + 채널부터(Pub/Sub vs 트랜잭션 DB) 다르게 설계한 아키텍처입니다. +

+
+ 실습 — 데이터 10가지 분류표 우리 서비스의 데이터 10가지를 골라(예: 시세 틱, + 회원 정보, 관심종목, 알림 발송 이력, 라이브 채팅 메시지, 공시 아티클, 감사 로그...) + 각각 CP/AP 성향필요한 일관성 수준(선형성 / + 인과적 / read-your-writes면 충분 / 최종 일관성)을 표로 분류하고, 근거를 한 + 줄씩 적어 보세요. 근거의 형식은 섹션 4의 질문 그대로 — "이 값이 잠깐 + 틀리면, 그걸 보고 누가 무엇을 결정하는가?" 팀원과 표를 비교해 보면, 같은 데이터를 + 다르게 분류한 지점에서 가장 좋은 토론이 벌어질 거예요. +
+
+ +
+
+

🌐 여기까지 왔다면

+

+ 이제 "서버를 늘리면 되지"라는 말 뒤에 숨은 대가를 읽을 수 있게 됐어요. 부분 + 실패라는 제3의 상태, 분할 시 C와 A 사이의 선택, 강함과 빠름을 맞바꾸는 일관성 + 스펙트럼, 과반이 지켜 주는 리더의 유일성, 그리고 믿을 수 없는 시계까지 — 분산 + 설계의 기본 어휘를 손에 쥐었습니다. 그런데 서비스 사이를 동기 호출로만 이으면, + 한 서비스의 장애가 사슬처럼 번지는 문제가 남아요. 호출을 메시지로 + 바꿔 시스템을 느슨하게 잇는 법 — 다음{' '} + 메시지 큐와 이벤트 + 드리븐 — 시스템을 느슨하게 잇는 법 코스에서 이어서 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv4HighTrafficArchitecturePage.jsx b/frontend/src/pages/levels/Lv4HighTrafficArchitecturePage.jsx new file mode 100644 index 0000000..2dfaff4 --- /dev/null +++ b/frontend/src/pages/levels/Lv4HighTrafficArchitecturePage.jsx @@ -0,0 +1,587 @@ +// 이 파일이 하는 일: "대용량 트래픽 설계 — 밀려오는 요청 앞에서 무너지지 않기" 코스 — +// 용량 산정(봉투 뒷면 계산)에서 출발해 로드밸런싱·CDN·다층 캐시·레이트리밋·백프레셔· +// 장애 격리·종합 설계 연습까지 8개 섹션으로 안내하는 정적 학습 페이지. +// 프레이밍은 "특급 과정 — 대규모 시스템을 설계하는 시야". 실무 수준 개발자가 +// 트래픽이 10배가 되어도 버티는 구조를 스스로 그릴 수 있게 만드는 것이 목표다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_CAPACITY = `# 봉투 뒷면 계산(back-of-envelope) — 정확한 숫자보다 '자릿수'를 맞추는 연습 +DAU 100만 명, 1인당 하루 평균 요청 50회라고 하자. + + 하루 총 요청 = 1,000,000 x 50 = 50,000,000 요청/일 + 평균 RPS = 50,000,000 / 86,400초 ≈ 580 RPS + 피크 RPS = 평균 x 2~5 (서비스 특성에 따라) + = 약 1,200 ~ 3,000 RPS + +여기에 한 요청의 평균 응답 크기가 20KB라면: + 피크 대역폭 ≈ 3,000 x 20KB ≈ 60MB/s → 480Mbps + +결론: "서버 몇 대가 필요한가?"에 답하기 전에 +이 네 줄 계산부터 한다. 자릿수가 틀리면 설계 전체가 틀린다.`; + +const CODE_P99 = `# 왜 평균이 아니라 p99인가 — 같은 시스템의 두 얼굴 +평균 응답시간 45ms ← "빠르네요!" +p50 (중앙값) 30ms +p95 120ms +p99 900ms ← 100명 중 1명은 거의 1초를 기다린다 + +하루 50회 요청하는 사용자가 p99 지연을 만날 확률: + 1 - (0.99 ^ 50) ≈ 39% +→ "1%의 느린 요청"은 사실상 세 명 중 한 명이 매일 겪는 경험이다. + +그리고 페이지 하나가 API 10개를 부르면? + 전부 p99 이내로 끝날 확률 = 0.99 ^ 10 ≈ 90% +→ 열 명 중 한 명의 '화면'은 가장 느린 API 하나에 발목 잡힌다. +평균은 마케팅 숫자, p99가 사용자 경험이다.`; + +const CODE_LB_ALGO = `알고리즘 동작 방식 어울리는 상황 +────────────────────────────────────────────────────────────── +라운드로빈 1번→2번→3번→1번… 순서대로 요청 비용이 비슷하고 +(round robin) 공평하게 돌린다 서버 사양이 같을 때 + +최소 연결 지금 연결 수가 가장 적은 요청마다 처리 시간이 +(least conn) 서버로 보낸다 들쭉날쭉할 때 (긴 쿼리 등) + +컨시스턴트 해싱 키(유저ID 등)를 해시해 같은 캐시 지역성이 중요할 때 — +(consistent hash) 키는 늘 같은 서버로 서버 추가/제거 시에도 + 이동하는 키가 1/N뿐 + +핵심 질문: "서버 하나가 죽거나 늘어날 때, 트래픽이 어떻게 재배치되는가?" +컨시스턴트 해싱이 나온 이유가 바로 이 질문 때문이다.`; + +const CODE_HEALTHCHECK = `# nginx 업스트림 예시 — 죽은 서버로 트래픽을 보내지 않기 +upstream api_servers { + least_conn; # 최소 연결 알고리즘 + server 10.0.1.11:8080 max_fails=3 fail_timeout=10s; + server 10.0.1.12:8080 max_fails=3 fail_timeout=10s; + server 10.0.1.13:8080 max_fails=3 fail_timeout=10s; + # 10초 안에 3번 실패하면 → 10초간 풀에서 자동 제외(passive health check) +} + +# 능동(active) 헬스체크는 별도 엔드포인트를 주기적으로 찌른다 +# GET /actuator/health → 200이면 살아 있음, 아니면 제외 +# 주의: 헬스체크가 DB까지 찌르면, DB가 느려질 때 '멀쩡한 앱 서버'까지 +# 전부 제외되어 장애가 증폭된다. 얕은 체크와 깊은 체크를 구분할 것.`; + +const CODE_CACHE_CONTROL = `# 정적 자산 — "1년 캐시 + 파일명에 버전 해시"가 정석 +GET /assets/app.3f9a2c.js +Cache-Control: public, max-age=31536000, immutable +# 내용이 바뀌면 해시도 바뀌어 다른 URL이 된다 → 무효화 걱정 자체가 없음 + +# HTML — 늘 최신 확인이 필요하니 재검증 조건부 +GET /index.html +Cache-Control: no-cache # "캐시하되, 쓰기 전에 서버에 물어봐" +ETag: "a1b2c3" +# 다음 요청: If-None-Match: "a1b2c3" → 안 바뀌었으면 304 (본문 전송 생략) + +# 준(準)동적 콘텐츠 — 엣지에서 낡은 걸 먼저 주고 뒤에서 갱신 +GET /api/content/articles/popular +Cache-Control: s-maxage=60, stale-while-revalidate=300 +# 60초는 신선, 이후 5분간은 '낡은 응답을 즉시' 주면서 백그라운드로 재검증 +# 사용자는 항상 캐시 속도를 경험하고, 원본 부하는 재검증 1회로 합쳐진다`; + +const CODE_CACHE_LAYERS = `요청이 통과하는 캐시의 관문 — 앞에서 끝날수록 싸고 빠르다 + + 브라우저 캐시 (0ms, 네트워크 자체가 없음) + ↓ miss + CDN / 엣지 (~10ms, 원본 서버 부하 0) + ↓ miss + 애플리케이션 캐시 (~1ms, Valkey/Redis — DB 부하 0) + ↓ miss + DB 버퍼 캐시 (디스크 대신 메모리에서 읽기) + ↓ miss + 디스크 (여기까지 오면 제일 비싸다) + +설계 목표: 트래픽의 대부분을 위쪽 두 층에서 끝내서, +가장 비싸고 늘리기 어려운 자원(DB)에는 '진짜 필요한 요청'만 도달시키기.`; + +const CODE_CACHE_PATTERNS = `// cache-aside — 가장 흔한 패턴 (읽기 위주 서비스의 기본기) +String cached = valkey.get("market:quote:" + symbol); +if (cached != null) return cached; // ① 캐시 hit → 즉시 반환 +String fresh = db.findQuote(symbol); // ② miss → 원본 조회 +valkey.setex("market:quote:" + symbol, 5, fresh); // ③ TTL 5초로 캐시에 적재 +return fresh; + +// write-through: 쓸 때 캐시와 DB를 '함께' 갱신 — 캐시가 늘 최신, 쓰기 지연 증가 +// write-behind: 캐시에 먼저 쓰고 DB엔 나중에 몰아서 — 빠르지만 유실 위험 +// 선택 기준: 읽기:쓰기 비율. 읽기 100:1이면 cache-aside로 충분한 경우가 대부분.`; + +const CODE_STAMPEDE = `// 캐시 스탬피드 — 인기 키의 TTL이 만료되는 '그 1초'에 벌어지는 일 +// 초당 3,000개 요청이 전부 miss → 3,000개가 동시에 DB로 돌진 → DB 사망 + +// 대책 ① 잠금(단 한 명만 재계산): 첫 miss가 락을 잡고 갱신, 나머지는 잠깐 대기 +boolean locked = valkey.setnx("lock:quote:" + symbol, "1", 3); +if (locked) { + String fresh = db.findQuote(symbol); // 나만 DB에 간다 + valkey.setex("market:quote:" + symbol, 5, fresh); +} else { + sleepBriefly(); // 남은 요청은 잠깐 기다렸다 + return valkey.get("market:quote:" + symbol); // 갱신된 캐시를 읽는다 +} + +// 대책 ② 확률적 조기 갱신: TTL이 임박할수록 높은 확률로 '미리' 재계산 +// 만료를 다 같이 기다리지 않으니 만료의 절벽 자체가 사라진다 +// 대책 ③ 만료 시각 분산: TTL에 랜덤 지터(5초 ± 1초)를 더해 동시 만료를 흩기`; + +const CODE_RATELIMIT = `토큰 버킷 vs 슬라이딩 윈도우 — 무엇을 허용하고 무엇을 막나 + +토큰 버킷 (token bucket) + 양동이에 초당 r개씩 토큰이 채워지고(최대 b개), 요청 1건 = 토큰 1개 소모 + → 평소엔 초당 r로 제한하되, 모아 둔 토큰만큼 '순간 폭발(burst)'은 허용 + → API처럼 짧은 몰림이 자연스러운 트래픽에 어울린다 + +슬라이딩 윈도우 (sliding window) + "최근 60초 동안 N회까지" — 시간 창을 미끄러뜨리며 정확하게 센다 + → 버스트도 엄격히 막고 싶을 때 (로그인 시도, 인증 코드 발송) + +무엇을 기준으로 셀 것인가: + IP 기준 — 비로그인 구간의 기본 방어선 (프록시 뒤 공유 IP 주의) + 유저 기준 — 로그인 후 공정한 사용량 배분 + API 키 기준 — 외부 파트너별 계약 용량(쿼터) 관리`; + +const CODE_SHEDDING = `// 부하 제한(load shedding) — 다 같이 죽느니 일부를 '정중히' 거절한다 +// 서버가 수용량을 넘기면: 전 요청이 다 느려짐 → 타임아웃 → 재시도 폭주 → 전멸 +// 대신 넘치는 만큼만 즉시 429/503으로 돌려보내면 나머지는 정상 속도를 지킨다 + +if (inFlightRequests.get() > MAX_CONCURRENT) { + response.setStatus(503); + response.setHeader("Retry-After", "2"); // "2초 뒤에 다시 와 주세요" + return; // DB 근처에도 못 가게 초입에서 차단 +} + +// 거절에도 등급을 둘 수 있다: +// 헬스체크·결제 같은 핵심 요청 → 끝까지 보호 +// 목록 새로고침·자동완성 → 먼저 거절 (사용자가 다시 누르면 그만) +// '무엇을 포기할지'를 평시에 정해 두는 것이 과부하 설계의 절반이다.`; + +const CODE_BACKPRESSURE = `// 무제한 큐가 위험한 이유 — 큐는 문제를 없애지 않고 '숨긴다' +// 유입 1,000/s, 처리 800/s이면 큐에는 매초 200개가 쌓인다. +// 10분 뒤: 대기 12만 건, 대기 시간 2.5분 — 이미 클라이언트는 타임아웃으로 떠났고 +// 서버는 '아무도 기다리지 않는 요청'을 처리하다 메모리까지 고갈된다. + +// 백프레셔 = 소비 속도를 생산자에게 거슬러 올려 보내는 신호 +BlockingQueue queue = new ArrayBlockingQueue<>(1000); // ① 반드시 유한하게 + +boolean accepted = queue.offer(task); // ② 가득 차면 즉시 false +if (!accepted) { + return tooManyRequests(); // ③ 상류에 "지금은 늦춰라"를 알린다 +} + +// TCP의 수신 윈도우, Kafka 컨슈머의 poll 속도, Reactive Streams의 request(n) — +// 전부 같은 원리다: 하류가 버거우면 상류가 보내는 속도를 늦춘다.`; + +const CODE_CIRCUIT = `서킷 브레이커의 3상태 — 두꺼비집이 회로를 보호하듯 + + 닫힘(CLOSED) 평상시. 요청을 통과시키며 실패율을 계측한다. + │ 최근 10초간 실패율 > 50% (임계값) + ▼ + 열림(OPEN) 호출 자체를 차단하고 즉시 폴백으로 응답. + │ 죽어 가는 하류를 더 때리지 않고 회복할 틈을 준다. + │ 대기 시간 경과 (예: 30초) + ▼ + 반열림(HALF-OPEN) 탐색 요청 몇 개만 살짝 흘려 본다. + │ 성공 → 닫힘 복귀 │ 실패 → 다시 열림 + ▼ ▼ + +임계값 설계의 감각: + - 표본이 적을 때(요청 10개 미만) 열리지 않게 최소 호출 수를 둔다 + - 타임아웃도 실패로 센다 — '느린 성공'이 제일 위험한 실패다`; + +const CODE_K6 = `// load-test.js — k6로 게이트웨이에 단계적 부하 걸기 (이 강좌의 실습 골격) +import http from 'k6/http'; +import { check } from 'k6'; + +export const options = { + stages: [ + { duration: '2m', target: 50 }, // 워밍업: 가상 사용자 50명까지 + { duration: '3m', target: 200 }, // 평시 부하 + { duration: '3m', target: 800 }, // 피크의 4배 — 어디서 무너지나? + { duration: '2m', target: 0 }, // 회복 관찰 + ], + thresholds: { + http_req_duration: ['p(99)<500'], // 성능 예산: p99 500ms — 관문(gate) + http_req_failed: ['rate<0.01'], // 실패율 1% 미만 + }, +}; + +export default function () { + const res = http.get('http://localhost:8080/api/market/quote?symbol=AAPL'); + check(res, { 'status 200': (r) => r.status === 200 }); +} + +// 실행: k6 run load-test.js +// 캐시·레이트리밋을 넣기 '전'과 '후'에 같은 스크립트를 돌려 +// p99·실패율·무너지기 시작한 VU 수를 표로 비교하는 것이 과제다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '용량 산정' }, + { n: 2, label: '로드밸런싱' }, + { n: 3, label: 'CDN과 엣지' }, + { n: 4, label: '다층 캐시' }, + { n: 5, label: '레이트리밋' }, + { n: 6, label: '백프레셔' }, + { n: 7, label: '장애 격리' }, + { n: 8, label: '종합 설계 연습' }, +]; + +export default function Lv4HighTrafficArchitecturePage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 특급 과정
+

🚦 대용량 트래픽 설계
— 밀려오는 요청 앞에서 무너지지 않기

+

+ 기능이 도는 것과 10배의 트래픽에서도 도는 것은 다른 문제입니다. + 로드밸런싱, CDN, 캐시 계층, 레이트리밋, 백프레셔 — 요청이 밀려올 때 시스템을 + 지키는 도구들을 하나의 지도로 엮어, 어느 층에서 무엇으로 버틸지를 + 설계하는 시야를 기릅니다. 특급 과정의 핵심은 코드 한 줄이 아니라 이 시야예요. +

+
+ 예상 소요 90분 + 실습: k6 부하 테스트 → p99 전후 비교 + 대상: 실무 수준 개발자 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 대용량 설계의 첫걸음은 화려한 아키텍처 그림이 아니라 숫자입니다. + "많이 들어와요"는 설계의 입력이 될 수 없어요. DAU(일간 활성 사용자)에서 출발해 + 평균 RPS, 그리고 피크 RPS까지 내려가는 짧은 계산 — + 이른바 봉투 뒷면 계산(back-of-envelope)이 모든 판단의 출발점입니다. +

+ {CODE_CAPACITY} +

+ 이 계산의 목적은 정밀함이 아니라 자릿수를 맞추는 것이에요. + 580 RPS인지 583 RPS인지는 중요하지 않지만, 500인지 5,000인지는 서버 대수·캐시 전략· + DB 선택을 전부 바꿉니다. 그리고 설계는 반드시 평균이 아니라 피크에 + 맞춰야 해요 — 시스템은 하루 중 가장 바쁜 1분에 무너지니까요. +

+

+ 두 번째 숫자 감각은 지연의 분포입니다. 평균 응답시간은 소수의 아주 + 느린 요청을 다수의 빠른 요청이 희석해 버려요. 그래서 실무에서는 p95·p99 같은{' '} + 백분위수(percentile)로 말합니다. +

+ {CODE_P99} +

+ 마지막으로 읽기:쓰기 비율. 읽기가 100:1로 압도적이라면 캐시와 + 복제본(replica)으로 수평 확장하기 좋은, 비교적 다루기 쉬운 문제입니다. 반대로 쓰기가 + 많다면 캐시가 거의 못 돕고, 샤딩·큐잉 같은 훨씬 무거운 도구가 필요해져요. 이 비율 + 하나가 아키텍처의 큰 방향을 정합니다. +

+
+ 현업의 감각 설계 리뷰에서 "이거 트래픽 얼마나 봐요?"라는 질문에 30초 안에 + 위의 네 줄 계산으로 답할 수 있으면, 그 순간부터 대화의 수준이 달라집니다. 숫자가 + 없으면 취향 싸움이 되고, 숫자가 있으면 공학이 돼요. +
+
+ +
+

+ 서버 한 대의 한계를 넘는 가장 기본적인 방법은 여러 대에 요청을 나누는 것 + 입니다. 그 교통정리를 맡는 것이 로드밸런서(LB)인데, 어느 층에서 보느냐에 따라 둘로 + 갈려요. L4(전송 계층)는 IP·포트만 보고 TCP 연결 단위로 빠르게 + 분배합니다 — 내용을 안 보니 가볍고 빠르죠. L7(애플리케이션 계층)은 + HTTP를 해석해 /api/market은 시세 서버로,{' '} + /api/auth는 인증 서버로 — 경로·헤더 기반 + 라우팅이 가능합니다. 우리 traderbox의 게이트웨이가 하는 일이 바로 L7 + 라우팅이에요. +

+ {CODE_LB_ALGO} +

+ 분배만큼 중요한 것이 죽은 서버를 걸러내는 일입니다. 3대 중 1대가 + 죽었는데 LB가 모른다면, 사용자의 1/3은 에러를 봐요. 그래서 LB는 헬스체크로 각 서버의 + 생사를 확인하고, 실패가 반복되는 서버를 풀에서 자동 제외합니다. +

+ {CODE_HEALTHCHECK} +

+ 마지막 쟁점은 스티키 세션(sticky session) — "같은 사용자는 늘 같은 + 서버로" 보내는 설정입니다. 서버 메모리에 세션을 저장하는 구조라면 필요하지만, 대가가 + 커요. 특정 서버에 부하가 쏠리고, 그 서버가 죽으면 거기 붙어 있던 사용자들의 세션이 + 통째로 날아갑니다. 그래서 현대적인 설계는 세션을 서버 밖(토큰 또는 Valkey 같은 외부 + 저장소)으로 빼서 어느 서버가 받아도 되는 무상태(stateless)로 만들고, + 스티키 세션 자체를 없애는 쪽을 선호해요. 우리 서비스들이 JWT를 쓰는 이유이기도 합니다. +

+
+ 선택 기준 요약 경로 기반 라우팅·헤더 검사·쿠키가 필요하면 L7, 극한의 처리량과 + 단순 분배면 L4. 알고리즘은 "서버 증감 시 트래픽이 어떻게 재배치되는가"를 먼저 물어보고 + 고르세요. 그리고 무엇을 고르든 — 무상태 설계가 로드밸런싱을 가장 쉽게 + 만듭니다. +
+
+ +
+

+ 트래픽을 버티는 가장 우아한 방법은 서버를 늘리는 게 아니라{' '} + 요청이 서버에 도착하기 전에 끝내는 것입니다. CDN(Content Delivery + Network)은 전 세계 곳곳의 엣지 서버에 응답 사본을 두고, 사용자와 + 가장 가까운 곳에서 대신 응답해요. 원본 서버 입장에선 그만큼의 트래픽이{' '} + 아예 존재하지 않는 것과 같습니다. +

+

+ 엣지가 무엇을 얼마나 들고 있을지는 원본이 HTTP 캐시 헤더로 + 지시합니다. 핵심은 세 가지 — Cache-Control(얼마나 + 오래), ETag(바뀌었는지 확인), 그리고 동적 콘텐츠를 위한{' '} + stale-while-revalidate(낡은 걸 먼저 주고 뒤에서 갱신). +

+ {CODE_CACHE_CONTROL} +

+ stale-while-revalidate는 특히 눈여겨보세요. "인기 글 + 목록"처럼 몇 초쯤 낡아도 되는 동적 콘텐츠를 엣지에서 즉시 응답하게 + 해 줍니다. 사용자는 항상 캐시의 속도를 경험하고, 원본은 백그라운드 재검증 한 번만 + 감당하면 돼요. "동적이니까 캐시 불가"라는 고정관념을 깨는 패턴입니다. +

+

+ 남는 문제는 무효화(invalidation) — 배포했는데 사용자에게 옛 파일이 + 보이는 사고를 어떻게 막을까요? 정답은 둘로 갈립니다. 정적 자산은{' '} + 버전 해시 파일명(app.3f9a2c.js) 전략 — + 내용이 바뀌면 URL 자체가 바뀌니 무효화가 필요 없고, 1년짜리{' '} + immutable 캐시를 걸 수 있어요. 반면 URL을 바꿀 수 없는 + 것(이미지 교체, API 응답)은 CDN의 purge API로 지목해서 지웁니다. + purge는 전파 지연과 실수 여지가 있으니, 해시 파일명으로 해결할 수 있는 건 + 전부 그쪽으로 미는 것이 정석이에요. +

+
+ 비유 CDN은 동네 편의점이에요. 서울 본사 창고(원본)까지 안 가도 + 집 앞에서 물건을 사죠. max-age는 유통기한,{' '} + ETag는 "본사에 전화해서 신상품 나왔는지만 확인", + stale-while-revalidate는 "일단 진열된 걸 팔고, 그 사이 새 물건을 채워 넣기"입니다. +
+
+ +
+

+ 캐시는 한 군데가 아니라 여러 겹의 관문으로 설계합니다. 요청이 + 지나가는 길목마다 캐시가 있고, 앞쪽에서 끝날수록 싸고 빠르죠. +

+ {CODE_CACHE_LAYERS} +

+ 애플리케이션 캐시 층에서는 읽고 쓰는 패턴을 골라야 합니다. 가장 + 흔한 것이 cache-aside — 캐시를 먼저 보고, 없으면 원본에서 가져와 + 캐시에 채워 넣는 방식이에요. +

+ {CODE_CACHE_PATTERNS} +

+ 실전 예가 우리 곁에 있습니다. traderbox의 시세 조회는 Valkey의{' '} + market:quote 캐시를 먼저 봅니다. 시세는 수천 명이{' '} + 같은 종목을 동시에 조회하는 극단적 읽기 편중 데이터라, 짧은 TTL의 + 캐시 한 층만으로 원본(외부 시세 API·DB)으로 가는 트래픽이 수백분의 일로 줄어요. + "몇 초쯤 낡아도 되는가?"에 예(5초)라고 답할 수 있는 데이터였기에 가능한 설계입니다. +

+

+ 그런데 캐시에는 유명한 함정이 있어요. 캐시 스탬피드(cache stampede) — + 인기 키의 TTL이 만료되는 순간, 그동안 캐시가 막아 주던 수천 개의 요청이{' '} + 한꺼번에 원본으로 돌진하는 현상입니다. 평소엔 멀쩡하다가 "정각마다 + DB가 죽어요" 같은 미스터리 장애로 나타나죠. +

+ {CODE_STAMPEDE} +
+ 캐시 설계 3문 새 캐시를 넣기 전에 스스로 물어보세요. ① 이 데이터는{' '} + 몇 초까지 낡아도 되는가?(TTL) ② 만료 순간 몇 개의 요청이 + 동시에 miss가 되는가?(스탬피드 대비) ③ 원본이 바뀔 때 캐시를{' '} + 어떻게 알리는가?(무효화) — 셋 중 하나라도 답이 없으면 아직 설계가 + 끝난 게 아닙니다. +
+
+ +
+

+ 아무리 캐시를 쌓아도 수용량은 유한합니다. 그래서 성숙한 시스템은 자신의 한계를{' '} + 명시적인 규칙으로 만들어요 — "이 이상은 받지 않는다"를 미리 정해 두는 + 것이 레이트리밋(rate limit)입니다. 대표 알고리즘은 두 가지예요. +

+ {CODE_RATELIMIT} +

+ 알고리즘보다 먼저 정할 것은 무엇을 기준으로 셀 것인가입니다. 최근 + 우리가 traderbox 로그인에 IP 기준 레이트리밋을 넣은 이유를 떠올려 + 보세요. 로그인 전에는 "누구인지"를 모르니 유저 기준으로 셀 수 없고, 크리덴셜 스터핑 + (탈취 계정 대입 공격)은 소수의 IP에서 대량 시도로 나타나기에, 비로그인 + 구간에서는 IP가 사실상 유일하고 유효한 기준이기 때문입니다. 로그인 뒤에는 + 유저 기준으로, 외부 파트너에겐 API 키 기준으로 — 구간마다 기준이 달라져요. +

+

+ 레이트리밋이 "개별 클라이언트의 과속"을 막는다면, 부하 제한(load + shedding)은 "시스템 전체의 침수"를 막습니다. 수용량을 넘어선 요청을 받아서 + 다 같이 느려지느니, 넘치는 만큼을 초입에서 즉시, 정중하게 거절하는 + 거예요. +

+ {CODE_SHEDDING} +
+ 정중한 거절의 형식 거절에도 프로토콜이 있습니다. 상태 코드는{' '} + 429 Too Many Requests(클라이언트 과속) 또는{' '} + 503 Service Unavailable(서버 과부하),{' '} + Retry-After 헤더로 "언제 다시 오라"까지 알려 주세요. + 클라이언트가 이 신호를 존중해 지수 백오프로 재시도하면, 거절은 장애가 아니라{' '} + 시스템이 숨을 고르는 방법이 됩니다. +
+
+ +
+

+ 처리 속도보다 유입이 빠를 때 흔한 대응이 "중간에 큐를 두자"입니다. 짧은 스파이크엔 + 유효하지만, 유입이 지속적으로 처리량을 넘는다면 무제한 큐는 문제를 + 해결하는 게 아니라 연기하며 키우는 것이에요. 대기 시간은 무한히 + 늘어나고(지연 폭발), 큐 자체가 메모리를 삼키다 프로세스를 쓰러뜨립니다. +

+ {CODE_BACKPRESSURE} +

+ 백프레셔(backpressure)는 이 흐름을 거꾸로 세우는 원리입니다 — + 소비자(하류)의 처리 속도를 생산자(상류)에게 신호로 거슬러 올려 보내, + 받는 쪽이 버거우면 보내는 쪽이 속도를 늦추게 하는 거예요. TCP의 수신 윈도우, Kafka + 컨슈머가 자기 속도로 poll하는 구조, Reactive Streams의{' '} + request(n) — 겉모습은 달라도 전부 같은 원리입니다. +

+

+ 스트리밍에서는 이 문제가 더 예민해집니다. traderbox는 시세를 SSE로 흘려보내는데, + 프론트 설정에 compress: false가 박혀 있어요. 중간 + 계층(Next.js·프록시)의 gzip 압축과 버퍼링은 이벤트를 모았다가 한꺼번에{' '} + 내보내려 하는데, 실시간 스트림에서 그 버퍼링은 곧 지연이고, 흐름 신호를 왜곡하기 + 때문입니다. 또 하나의 고전 문제는 느린 클라이언트 — 수신이 느린 + 구독자 한 명 때문에 서버가 그 연결의 송신 버퍼에 데이터를 무한정 쌓아 주면, 한 명이 + 전체 서버의 메모리를 위협해요. 그래서 스트리밍 서버는 연결별 버퍼에 상한을 두고, + 못 따라오는 클라이언트는 이벤트를 건너뛰게 하거나 연결을 끊는 선택을 + 설계에 포함해야 합니다. +

+
+ 설계 감각 시스템 어딘가에 큐가 보이면 반사적으로 물어보세요 — "이 큐,{' '} + 상한이 있나? 가득 차면 무슨 일이 일어나나?" 이 두 질문에 답이 없는 + 큐는 아직 설계되지 않은 장애 지점입니다. '거절'과 '버림(drop)'도 어엿한 설계 + 선택지예요 — 무너지는 것보다 낫습니다. +
+
+ +
+

+ 분산 시스템에서 진짜 무서운 것은 장애 자체가 아니라 장애의 전염 + 입니다. 추천 서비스 하나가 느려졌을 뿐인데, 그걸 호출하며 기다리던 스레드들이 쌓여 + 주문 서버까지 마비되는 식이죠. 이를 끊는 첫 번째 도구가{' '} + 서킷 브레이커(circuit breaker) — 집의 두꺼비집처럼, 하류 호출의 + 실패율이 임계값을 넘으면 회로를 열어 호출 자체를 차단합니다. +

+ {CODE_CIRCUIT} +

+ 두 번째 도구는 벌크헤드(bulkhead) — 배의 격벽에서 온 이름입니다. + 한 구획에 물이 차도 배 전체는 가라앉지 않도록, 스레드풀·커넥션풀을 기능별로 + 나눠 두는 거예요. 예컨대 외부 시세 API 호출용 스레드 20개, 결제용 30개를 + 분리해 두면, 시세 API가 멈춰 스레드 20개가 모두 잠겨도 결제는 멀쩡히 돕니다. 나누지 + 않았다면? 공용 풀 전체가 시세 대기에 잠식되어 아무 상관 없는 기능까지 함께{' '} + 죽었을 거예요. +

+

+ 차단했다면 그 자리에 무엇을 줄지가 남습니다 — 폴백(fallback) 설계예요. + 선택지는 세 가지: ① 캐시된 값("5분 전 기준 데이터입니다" — 낡음을 + 표시하고 제공), ② 기본값(추천 실패 시 빈 목록 대신 인기 목록), + ③ 정중한 에러("잠시 후 다시 시도해 주세요"). 기준은 데이터의 + 성격입니다 — 시세처럼 낡아도 참고가 되는 건 캐시 폴백이 좋지만, 결제 잔액을 + 낡은 캐시로 보여 주는 건 재앙이에요. 차라리 정직한 에러가 낫습니다. +

+
+ 세 패턴은 세트다 타임아웃(무한 대기 금지) → 서킷 브레이커(반복 실패 시 차단) + → 벌크헤드(번짐 방지) → 폴백(차단 시의 응답). Spring 생태계에선 Resilience4j가 이 + 네 가지를 애너테이션으로 제공합니다. 도구는 쉬워요 — 어려운 건{' '} + 임계값과 폴백을 기능별로 '결정'하는 일이고, 그게 설계자의 몫입니다. +
+
+ +
+

+ 이제 배운 것을 한 판에 올려 봅시다. 시나리오: 우리 서비스가 방송을 타서 트래픽이{' '} + 순식간에 10배가 됐습니다. 어디부터 무너질까요? 층별로 추적해 보면 — + ① DB가 먼저입니다. 커넥션풀이 마르고 쿼리가 밀리죠(캐시가 없다면). + ② 다음은 앱 서버 — DB를 기다리는 스레드가 쌓여 스레드풀 고갈. + ③ 그다음 큐와 메모리 — 밀린 요청이 쌓이며 지연 폭발. 무너짐은 늘{' '} + 가장 늘리기 어려운 자원(DB)에서 시작해 위로 번집니다. 그래서 방어는 + 역순이에요: 캐시·CDN으로 DB 도달량을 줄이고, 레이트리밋·load shedding으로 초입에서 + 수문을 조절하고, 백프레셔·벌크헤드로 번짐을 끊는 것. +

+

+ 여기서 특급 개발자의 구분법 하나 — 돈으로 살 수 있는 것과 설계로만 되는 + 것을 가르는 눈입니다. 무상태 앱 서버의 부족은 스케일아웃, 즉 돈으로 해결돼요 + (인스턴스를 늘리면 됩니다). 하지만 핫키(hot key) — 전 국민이 같은 + 인기 종목 하나를 조회하는 상황 — 는 서버를 100대로 늘려도 그 키를 가진 캐시 노드 + 한 대에 부하가 쏠립니다(로컬 캐시 복제나 키 분산 같은 설계가 필요).{' '} + 단일 리더 쓰기도 마찬가지 — 쓰기는 리더 한 대로 모이는 구조라면 읽기 + 복제본을 아무리 늘려도 쓰기 처리량은 늘지 않아요(샤딩이라는 설계 결정이 필요). + 스케일아웃 버튼이 안 통하는 지점을 미리 알아보는 것, 그게 설계 리뷰의 핵심입니다. +

+

+ 마지막 조각은 이 모든 것을 배포 전 관문(gate)으로 만드는 일입니다. + "p99 500ms 이하, 실패율 1% 미만"처럼 성능 예산을 숫자로 정하고, + 부하 테스트가 그 예산을 어기면 배포가 멈추게 하는 거예요. 성능이 "나중에 볼 일"이 + 아니라 테스트처럼 매번 통과해야 하는 조건이 됩니다. +

+ {CODE_K6} +

+ 위 스크립트가 이 강좌의 실습 골격입니다. ① 현재 상태의 게이트웨이에 + 단계적 부하를 걸어 p99가 치솟기 시작하는 VU(가상 사용자) 수 — 즉{' '} + 무너지는 지점을 찾고, ② 캐시와 레이트리밋을 추가한 뒤 + 같은 스크립트를 다시 돌려, 전후의 p99·실패율·한계 VU를 표로 비교하는 + 리포트를 작성하세요. "캐시를 넣으니 좋아졌다"가 아니라 "한계 VU가 200에서 + 650으로, p99가 1.8s에서 320ms로"라고 말하는 것 — 그것이 이 과정 전체가 기른 + 근육입니다. +

+
+ 실습 체크리스트 ① 부하 대상은 반드시 로컬/개발 환경(운영에 + k6를 쏘지 마세요!) ② 단계(stages)를 줄여 가며 무너지는 지점을 두 번 이상 재현해 + 우연이 아님을 확인 ③ 전후 비교는 같은 스크립트·같은 환경에서 — + 조건이 다르면 비교가 아니라 착시입니다. +
+
+ +
+
+

🚦 여기까지 왔다면

+

+ 이제 "트래픽이 늘면 어떡하죠?"라는 막연한 불안 대신, 층별 지도를 + 갖게 됐어요. 숫자로 부하를 말하고(용량 산정), 요청을 나누고(로드밸런싱), 서버에 + 오기 전에 끝내고(CDN·다층 캐시), 한계를 규칙으로 만들고(레이트리밋·load shedding), + 흐름을 거슬러 신호를 보내고(백프레셔), 번짐을 끊는(서킷 브레이커·벌크헤드) — + 열 배의 트래픽 앞에서 꺼내 들 도구들이 손에 잡힙니다. 그런데 이 설계대로 서버를 + 늘리고 줄이는 일을 매번 사람이 손으로 할 수는 없겠죠. 다음{' '} + 쿠버네티스와 클라우드 + 네이티브 — 선언하면 알아서 굴러가는 인프라 강좌에서, 오늘 그린 + 아키텍처를 선언 한 줄로 스스로 굴러가게 만드는 법을 이어서 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv4KubernetesCloudNativePage.jsx b/frontend/src/pages/levels/Lv4KubernetesCloudNativePage.jsx new file mode 100644 index 0000000..9e680ab --- /dev/null +++ b/frontend/src/pages/levels/Lv4KubernetesCloudNativePage.jsx @@ -0,0 +1,554 @@ +// 이 파일이 하는 일: "쿠버네티스와 클라우드 네이티브" 코스 — docker compose로 운영하던 +// 사람이 다음 단계로 넘어갈 때 필요한 '선언형 운영'의 사고방식을, 파드·디플로이먼트·서비스· +// 인그레스 같은 핵심 오브젝트부터 헬스체크·오토스케일링·생태계 조망까지 8개 섹션으로 안내하는 +// 정적 학습 페이지. 프레이밍은 "compose를 아는 개발자가 보는 쿠버네티스" — 이미 아는 +// 컨테이너·게이트웨이 라우팅·ENV 주입 컨벤션을 다리 삼아 새 개념을 얹는다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_COMPOSE_LIMIT = `docker compose ──────────────── 쿠버네티스 +(한 대의 서버) (여러 대를 하나처럼) + +서버 1대 위에서만 동작 여러 노드에 알아서 배치(스케줄링) +컨테이너 죽으면 restart 정도 죽으면 새로 만들어 원래 수를 복원(자가 치유) +배포 = 잠깐 끊고 다시 up 새것 띄우고 확인 후 옛것 종료(무중단) +스케일 = 사람이 서버 늘리고 설정 부하 보고 파드 수를 자동 조절(HPA) + +핵심: compose는 "컨테이너를 어떻게 띄울지"를 적고, +쿠버네티스는 "시스템이 어떤 상태여야 하는지"를 적습니다. +띄우는 건 내가, 유지하는 건 시스템이 — 역할이 넘어갑니다.`; + +const CODE_DECLARATIVE = `# 명령형(imperative) — "이걸 해라"를 순서대로 지시 +docker run -d --name auth-1 tdatalab/auth:1.2 +docker run -d --name auth-2 tdatalab/auth:1.2 +# auth-2가 죽으면? → 사람이 알아채고 다시 run 해야 함 + +# 선언형(declarative) — "이 상태가 되어라"를 문서로 선언 +# deployment.yaml +spec: + replicas: 2 # auth 파드는 항상 2개여야 한다 + template: + spec: + containers: + - name: auth + image: tdatalab/auth:1.2 + +# 하나가 죽으면? → 컨트롤 루프가 차이(2개여야 하는데 1개)를 +# 감지하고 스스로 새 파드를 만들어 2개로 되돌립니다. 사람 개입 없이.`; + +const CODE_DEPLOYMENT = `# gateway-deployment.yaml — 디플로이먼트: "이 앱을 이렇게 유지해라" +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tbox-gateway +spec: + replicas: 3 # 항상 3개 — 하나가 죽어도 서비스는 계속 + selector: + matchLabels: + app: tbox-gateway + strategy: + type: RollingUpdate # 새 버전을 한 개씩 갈아 끼우는 무중단 배포 + rollingUpdate: + maxUnavailable: 0 # 교체 중에도 가용 파드 수는 줄이지 않는다 + maxSurge: 1 # 잠깐 4개까지 허용하며 하나씩 교체 + template: + metadata: + labels: + app: tbox-gateway + spec: + containers: + - name: gateway + image: tdatalab/gateway:1.4.0 # 태그를 바꿔 apply하면 롤링 업데이트 시작 + ports: + - containerPort: 8080 + +# 롤백도 선언 한 줄: kubectl rollout undo deployment/tbox-gateway`; + +const CODE_SERVICE_INGRESS = `# 파드 IP는 재생성될 때마다 바뀝니다. 그래서 '안정적 접점'이 필요해요. +# gateway-service.yaml — Service: 파드들 앞의 고정된 문패 + 내장 로드밸런싱 +apiVersion: v1 +kind: Service +metadata: + name: tbox-gateway # 클러스터 안에서는 이 이름(DNS)으로 호출 +spec: + selector: + app: tbox-gateway # 이 라벨을 단 파드들에게 트래픽을 분배 + ports: + - port: 80 + targetPort: 8080 +--- +# ingress.yaml — Ingress: 외부 트래픽을 경로별로 라우팅 (게이트웨이 라우팅 선언과 닮은꼴) +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: tbox-ingress +spec: + rules: + - host: tdatalab.example.com + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: tbox-gateway # /api → gateway Service로 + port: + number: 80`; + +const CODE_CONFIG = `# ConfigMap·Secret — 설정을 이미지 밖으로 분리 (ENV 주입 컨벤션의 k8s 버전) +apiVersion: v1 +kind: ConfigMap +metadata: + name: auth-config +data: + SPRING_PROFILES_ACTIVE: "prod" + VALKEY_HOST: "tbox-valkey" # Service 이름이 곧 호스트명 +--- +apiVersion: v1 +kind: Secret # 시크릿은 별도 오브젝트로 — 접근 권한을 다르게 +metadata: + name: auth-secret +stringData: + JWT_SECRET: "운영값은-여기가-아니라-외부-시크릿-매니저에서" +--- +# 파드에서 참조 — 컨테이너에게는 그냥 환경변수로 보입니다 + spec: + containers: + - name: auth + envFrom: + - configMapRef: + name: auth-config + - secretRef: + name: auth-secret`; + +const CODE_PROBES = `# 헬스체크 프로브 — "살았나"와 "받을 준비 됐나"는 다른 질문 + spec: + containers: + - name: auth + livenessProbe: # 살았나? 실패 → 컨테이너를 재시작 + httpGet: + path: /actuator/health/liveness + port: 8081 + periodSeconds: 10 + readinessProbe: # 트래픽 받을 준비 됐나? 실패 → 트래픽만 차단 + httpGet: + path: /actuator/health/readiness + port: 8081 + initialDelaySeconds: 5 # JVM 기동에 시간이 필요 — 너무 이르면 오탐 + periodSeconds: 5 + +# 무중단 배포의 실제 순서: +# ① 새 파드 생성 → ② readiness 통과 → ③ Service가 트래픽을 새 파드로 +# → ④ 구 파드에 SIGTERM → ⑤ 처리 중인 요청 마무리(graceful shutdown) → 종료`; + +const CODE_HPA = `# 리소스 요청·제한 — 스케줄링과 안정성의 기준선 + spec: + containers: + - name: gateway + resources: + requests: # "최소 이만큼은 필요" → 스케줄러가 배치 기준으로 사용 + cpu: "250m" # 0.25 코어 + memory: "512Mi" + limits: # "이 이상은 금지" → CPU 초과는 스로틀링, + cpu: "1" # 메모리 초과는 OOMKill(강제 종료) + memory: "1Gi" +--- +# HPA — 수평 오토스케일러: 지표를 보고 replicas를 자동 조절 +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: tbox-gateway-hpa +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: tbox-gateway + minReplicas: 3 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 # 평균 CPU 70%를 넘으면 파드를 늘린다`; + +const CODE_KIND_LAB = `# 실습 — 로컬 kind 클러스터에서 자가 치유를 눈으로 확인하기 +# ① 클러스터 생성 (kind = Kubernetes IN Docker, 도커 안에 k8s 노드를 띄움) +kind create cluster --name tbox-lab + +# ② gateway·auth 두 서비스를 선언형으로 배포 +kubectl apply -f k8s/gateway-deployment.yaml -f k8s/gateway-service.yaml +kubectl apply -f k8s/auth-deployment.yaml -f k8s/auth-service.yaml +kubectl apply -f k8s/ingress.yaml +kubectl get pods -w # 파드 3개가 Running으로 수렴하는 과정을 관찰 + +# ③ 파드를 강제로 죽여 본다 — 자가 치유의 순간 +kubectl delete pod -l app=tbox-gateway --wait=false +kubectl get pods -w # 지운 즉시 새 파드가 생겨나 3개로 복원되는 걸 확인 + +# ④ 이미지 태그를 바꿔 apply → 무중단 롤링 업데이트 관찰 +kubectl set image deployment/tbox-gateway gateway=tdatalab/gateway:1.4.1 +kubectl rollout status deployment/tbox-gateway +kubectl rollout undo deployment/tbox-gateway # 문제가 있으면 한 줄 롤백`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: 'compose 다음' }, + { n: 2, label: '선언형 사고' }, + { n: 3, label: '파드·디플로이먼트' }, + { n: 4, label: '서비스·인그레스' }, + { n: 5, label: '상태의 문제' }, + { n: 6, label: '헬스체크·자가 치유' }, + { n: 7, label: '오토스케일링' }, + { n: 8, label: '생태계 조망' }, +]; + +export default function Lv4KubernetesCloudNativePage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 특급 과정
+

☸️ 쿠버네티스와 클라우드 네이티브
— 선언하면 알아서 굴러가는 인프라

+

+ docker compose로 서비스를 올리고 내리는 건 이제 손에 익었죠. 그런데 서버가 여러 대가 + 되고, 새벽에 컨테이너가 죽고, 배포 때마다 몇 초씩 끊긴다면? 쿠버네티스는 + "이 상태를 유지해라"라고 선언하면 시스템이 알아서 맞추는 운영 방식을 + 제안합니다. 파드·서비스·디플로이먼트부터 오토스케일링까지, 선언형 운영의 사고방식을 + 익혀 봅시다. +

+
+ 예상 소요 90분 + 실습: kind 클러스터에 gateway+auth 올리기 + 선수 지식: Docker·compose 운영 경험 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 우리는 지금 docker compose up -d --build 한 줄로 서비스 + 여섯 개와 인프라를 올리고 있어요. 훌륭한 방식이지만, 세 가지 벽이 있습니다. + 첫째, 단일 호스트 — compose는 서버 한 대 위에서만 돕니다. 트래픽이 + 늘어 서버를 늘리려면 배치·네트워크·설정을 전부 사람이 다시 설계해야 해요. + 둘째, 수동 복구 — 컨테이너가 죽으면 restart{' '} + 정책이 다시 띄워 주긴 하지만, 서버 자체가 죽으면 그걸로 끝입니다. 셋째,{' '} + 무중단 배포의 어려움up --build는 + 옛 컨테이너를 내리고 새것을 올리는 사이에 짧은 단절이 생겨요. +

+ {CODE_COMPOSE_LIMIT} +

+ 쿠버네티스가 약속하는 건 세 단어로 요약됩니다. 스케줄링 — 여러 노드 + (서버) 중 자리가 맞는 곳에 컨테이너를 알아서 배치하고, 자가 치유 — + 죽으면 스스로 다시 만들어 선언된 수를 복원하고, 선언형 상태 수렴 — + "어떻게 할지"가 아니라 "어떤 상태여야 하는지"를 적으면 시스템이 그 상태로 계속 + 수렴합니다. +

+
+ 도입 비용도 큽니다 쿠버네티스는 배울 것(오브젝트·네트워크·스토리지 모델)과 + 운영할 것(클러스터 자체의 업그레이드·모니터링)이 확 늘어나는 기술이에요. 서버 한 대에 + compose로 잘 돌아가고, 몇 초의 배포 단절을 감내할 수 있는 지금 규모라면{' '} + compose가 정답일 수 있습니다. 냉정한 판단 기준은 이것: 노드가 여러 대 + 필요한가? 새벽 장애에 자동 복구가 필요한가? 배포가 하루 수차례라 무중단이 필수인가? + 셋 다 "아니오"면 아직입니다. 이 코스는 "지금 갈아타자"가 아니라, 그 시점이 + 왔을 때 알아볼 수 있는 눈을 만드는 과정이에요. +
+
+ +
+

+ 쿠버네티스에서 가장 중요한 건 명령어 암기가 아니라 사고방식의 전환이에요. + 지금까지 우리는 명령형(imperative)으로 일했습니다 — "이 컨테이너를 + 띄워라, 저걸 재시작해라"처럼 절차를 지시했죠. 쿠버네티스는{' '} + 선언형(declarative)입니다 — "auth 파드가 항상 2개인 상태가 되어라"라고{' '} + 결과를 적어 두면, 절차는 시스템이 알아서 밟아요. +

+ {CODE_DECLARATIVE} +

+ 이걸 가능하게 하는 엔진이 컨트롤 루프(reconciliation loop)예요. + 쿠버네티스 안의 컨트롤러들은 끊임없이 두 가지를 비교합니다 — 원하는 상태 + (내가 YAML에 선언한 것)와 현재 상태(실제 클러스터). 차이가 있으면 + 그 차이를 좁히는 행동을 하고, 다시 비교하고, 또 좁히고… 온도를 설정해 두면 알아서 + 냉난방을 조절하는 보일러의 온도 조절기와 같은 원리죠. 파드가 죽어 2개가 + 1개가 되면, "차이 발견 → 1개 생성"이 자동으로 일어납니다. +

+
+ YAML이 곧 문서이자 코드 선언형의 부수 효과가 강력해요. "운영 환경이 어떻게 + 생겼나?"의 답이 누군가의 머릿속이 아니라 YAML 파일에 있으니, 그 파일을 + git에 넣으면 인프라의 변경 이력·리뷰·롤백이 코드와 똑같아집니다. 이 발상을 끝까지 + 밀어붙인 게 섹션 8에서 만날 GitOps예요 — "git에 선언된 상태 = 클러스터의 + 실제 상태"를 자동으로 유지하는 방식. 씨앗은 여기, 선언형 사고에 있습니다. +
+
+ +
+

+ 쿠버네티스는 컨테이너를 바로 띄우지 않고 파드(Pod)라는 최소 배포 + 단위로 감쌉니다. 파드는 보통 컨테이너 1개지만, 네트워크와 저장소를 공유하는 도우미 + 컨테이너(로그 수집기 같은)를 옆에 붙일 수도 있어요. 왜 한 겹 감싸냐면 — "함께 배포되고 + 함께 죽는 단위"에 이름과 IP를 부여해, 스케줄링·복제·치유의 공통 단위로 + 삼기 위해서입니다. 컨테이너 낱개를 상대하면 이 모든 관리가 제각각이 되거든요. +

+

+ 그런데 파드는 일회용이에요. 죽으면 그걸로 끝, 스스로 부활하지 않습니다. + 그래서 그 위에 레플리카셋(ReplicaSet)이 있어요 — "이 파드를 N개 + 유지해라"를 책임지는 컨트롤러죠. 그리고 그 위에 디플로이먼트(Deployment)가 + 있습니다 — 레플리카셋을 버전 단위로 관리하면서 롤링 업데이트와 롤백을 + 선언으로 처리해 줘요. 실무에서 우리가 직접 만지는 건 대부분 디플로이먼트입니다. +

+ {CODE_DEPLOYMENT} +

+ replicas: 3의 의미를 곱씹어 보세요. 파드 하나가 죽어도 + 나머지 둘이 트래픽을 받고, 그 사이 레플리카셋이 세 번째를 다시 만듭니다. 즉{' '} + "하나쯤 죽어도 서비스는 계속된다"가 설정 한 줄로 선언되는 거예요. + 배포도 마찬가지 — 이미지 태그만 바꿔 apply하면 + 디플로이먼트가 새 레플리카셋을 만들어 한 개씩 갈아 끼우고, 문제가 생기면{' '} + rollout undo 한 줄로 이전 레플리카셋으로 되돌립니다. +

+
+ 일회용이라는 설계 철학 "파드는 언제든 죽고 새로 만들어진다"는 전제는 불편이 + 아니라 이에요. 어느 파드가 죽어도 괜찮게 앱을 만들면(무상태 — + 섹션 5), 노드 장애·스케일링·배포가 전부 "파드 지우고 새로 만들기"라는 같은 동작으로 + 풀립니다. 소(pet)처럼 이름 붙여 돌보지 말고, 소떼(cattle)처럼 다루라는 유명한 비유가 + 여기서 나왔어요. +
+
+ +
+

+ 파드가 일회용이라면 곤란한 게 하나 생겨요 — 파드 IP가 계속 바뀝니다.{' '} + gateway가 auth를 호출하려는데 auth 파드가 방금 재생성되어 IP가 달라졌다면? 이 문제를 + 푸는 게 서비스(Service)예요. 라벨(app: + tbox-auth)로 파드 무리를 묶어 고정된 이름과 가상 IP를 부여하고, + 들어온 요청을 뒤의 파드들에게 알아서 분배합니다. 클러스터 안에서는{' '} + http://tbox-auth처럼 서비스 이름이 곧 DNS 호스트명이 + 돼요 — 내장 로드밸런서가 공짜로 따라오는 셈이죠. +

+ {CODE_SERVICE_INGRESS} +

+ 외부에서 들어오는 트래픽은 인그레스(Ingress)가 맡아요. "이 도메인의{' '} + /api는 이 서비스로" 같은 라우팅 규칙을 선언하는 + 오브젝트인데 — 어디서 본 구조 같지 않나요? 우리 게이트웨이의{' '} + application.yml에 경로별 라우트를 선언하는 것과 정확히 + 닮은꼴입니다. 익숙한 "경로 → 목적지" 선언이 클러스터의 문 앞으로 올라간 것뿐이에요. +

+

+ 설정 분리는 ConfigMap과 Secret이 담당합니다. 우리는 이미 설정값을 + 전부 {'${ENV:default}'} 형태로 env에서 주입하고 + 시크릿은 .env로 빼는 컨벤션을 쓰고 있죠 — ConfigMap이 + 그 env 파일의 쿠버네티스 버전이고, Secret은 민감한 값만 따로 담아 접근 권한을 다르게 + 걸 수 있는 상자입니다. 컨테이너 입장에서는 둘 다 그냥 환경변수로 보여서,{' '} + 앱 코드는 한 줄도 안 바꿔도 돼요. +

+ {CODE_CONFIG} +
+ Secret은 이름만큼 비밀스럽지 않다 기본 Secret은 base64 인코딩일 + 뿐 암호화가 아니에요(인코딩은 누구나 되돌릴 수 있죠). 클러스터 접근 권한이 있으면 + 읽을 수 있으니, 운영에서는 접근 제어(RBAC)와 외부 시크릿 매니저 연동까지 봐야 합니다. + "Secret에 넣었으니 안전"이라고 넘기지 않는 게 특급 과정의 감각이에요. +
+
+ +
+

+ 섹션 3의 "파드는 소떼처럼"이 성립하려면 조건이 하나 있어요 — 파드가 상태를 + 갖지 않아야 합니다. 우리 auth·gateway 같은 API 서버가 좋은 예죠. 요청을 + 처리할 뿐 자기 디스크에 중요한 걸 남기지 않으니(세션도 토큰 저장소도 밖에 있으니), + 어느 파드가 죽고 어느 파드가 새로 떠도 아무도 눈치채지 못합니다.{' '} + 이런 무상태(stateless) 앱은 복제·치유·스케일링이 전부 공짜예요. 쿠버네티스와 궁합이 + 가장 좋은 워크로드입니다. +

+

+ 문제는 상태를 가진(stateful) 워크로드 — 대표적으로 DB예요. Postgres + 파드가 죽고 새로 뜨면 데이터는? 이를 위해{' '} + PersistentVolume(파드와 수명이 분리된 디스크)과{' '} + StatefulSet(파드마다 고정된 이름·순서·자기 디스크를 보장하는 + 디플로이먼트의 형제)이 있긴 합니다. 하지만 디스크 연결, 복제 순서, 장애 시 승격(failover) + 같은 DB 고유의 어려움은 쿠버네티스가 대신 풀어 주지 않아요 — 오브젝트가 있다는 것과 + 운영이 쉽다는 건 다른 얘기입니다. +

+ {`궁합 좋음 (stateless) 조심해서 (stateful) +────────────────────────────────────────────────────── +gateway · auth · content 등 PostgreSQL · Kafka · Valkey(영속 모드) +API 서버, 워커 메시지 브로커, 검색 엔진 + +파드가 죽어도 잃을 게 없다 파드가 죽으면 디스크·복제·승격 문제 +Deployment + HPA로 끝 StatefulSet + PV + 깊은 운영 지식 필요`} +
+ 실무의 흔한 선택 그래서 많은 팀이 이렇게 선을 긋습니다 — 무상태 앱은 + 쿠버네티스에, DB·Kafka 같은 상태 워크로드는 관리형 서비스(RDS, MSK 등)에.{' '} + 백업·패치·복제·장애 조치를 클라우드가 대신 짊어지는 대가로 비용을 내는 거래인데, + DB 운영 사고의 비용을 생각하면 남는 장사인 경우가 많아요. "전부 클러스터에 넣어야 + 진짜 쿠버네티스"라는 건 없습니다 — 워크로드 성격 따라 두는 곳을 고르는 게 설계예요. +
+
+ +
+

+ 자가 치유가 동작하려면 쿠버네티스가 "이 파드가 건강한가"를 판단할 근거가 + 필요해요. 그 근거가 프로브(probe)인데, 두 종류를 구분하는 게 핵심입니다.{' '} + liveness는 "살았나?"를 묻습니다 — 실패하면 컨테이너가 회복 불능 + (데드락 등)이라 보고 재시작해요. readiness는 "지금 + 트래픽을 받을 준비가 됐나?"를 묻습니다 — 실패하면 재시작이 아니라 Service의 + 분배 대상에서만 잠시 제외합니다. JVM 워밍업 중이거나 의존 서비스 연결 전이라면 + 죽은 게 아니라 아직 준비가 안 된 것뿐이니까요. +

+ {CODE_PROBES} +

+ readiness를 잘못 짜면 어떻게 될까요? 배포 때마다 502가 나는 고전적 사고가 여기서 + 나옵니다. 새 파드가 아직 요청을 못 받는데 readiness가 성급하게 OK를 + 주면(예: 프로브 경로가 앱 기동과 무관한 정적 응답이거나,{' '} + initialDelaySeconds가 너무 짧으면), Service가 준비 안 된 + 파드로 트래픽을 흘려보내요. 반대로 readiness에 의존 서비스 상태까지{' '} + 묶어 버리면, DB가 잠깐 흔들릴 때 모든 파드가 일제히 "준비 안 됨"이 되어 멀쩡한 앱 + 전체가 트래픽에서 빠지는 연쇄 장애가 됩니다. 프로브는 "이 파드 하나"의 준비 + 상태만 답하게 짜는 게 원칙이에요. +

+

+ 이 프로브들이 갖춰지면 무중단 배포의 실제 순서가 완성됩니다. + ① 새 버전 파드 생성 → ② readiness 통과를 기다림 → ③ Service가 트래픽을 새 파드로 + 돌림 → ④ 구 파드에 종료 신호(SIGTERM) → ⑤ 처리 중이던 요청을 마무리하고(graceful + shutdown) 종료. 어느 순간에도 "준비 안 된 파드가 트래픽을 받는" 구간이 없죠. + 섹션 3의 maxUnavailable: 0과 이 프로브들이 합쳐져야 + 비로소 무중단이 됩니다 — 어느 한쪽만으로는 안 돼요. +

+
+ 우리 스택과의 연결 Spring Boot Actuator는 이 구분을 이미 알고 있어요 —{' '} + /actuator/health/liveness와{' '} + /readiness 엔드포인트를 따로 제공합니다. 앱 쪽 준비는 + 사실상 되어 있는 셈이라, 프로브 선언만 정확히 얹으면 됩니다. +
+
+ +
+

+ 스케줄러가 파드를 어느 노드에 둘지 정하려면 "이 파드가 자원을 얼마나 + 쓰는지"를 알아야 해요. 그 신고서가 requests와{' '} + limits입니다. requests는 + "최소 보장" — 스케줄러는 이 값 기준으로 자리가 있는 노드를 고릅니다.{' '} + limits는 "상한" — 넘으려 하면 제재가 들어가는데,{' '} + CPU와 메모리의 제재가 달라요. CPU 초과는{' '} + 스로틀링(속도를 늦출 뿐 죽이진 않음), 메모리 초과는{' '} + OOMKill(컨테이너 강제 종료)입니다. "앱이 이유 없이 재시작돼요"의 + 단골 범인이 메모리 limit이 너무 빡빡한 경우예요 — JVM 힙 설정과 limit이 어긋나면 + 특히 잘 일어납니다. +

+ {CODE_HPA} +

+ 파드 수를 자동 조절하는 게 HPA(Horizontal Pod Autoscaler)예요. + "평균 CPU 70%를 넘으면 늘리고, 여유가 생기면 줄여라"처럼 지표와 목표를 + 선언하면, 컨트롤 루프가 replicas를 계속 조정합니다. 어떤 지표로 늘릴지가 설계 + 포인트인데 — CPU는 무난한 기본값이지만, 트래픽 특성에 따라 메모리, 초당 요청 수, + 큐 길이 같은 커스텀 지표가 더 정직할 수 있어요. 시세 스트리밍처럼 연결 수가 부하를 + 결정하는 워크로드라면 CPU만 봐서는 늦게 반응합니다. +

+
+ 자동화가 청구서로 돌아오는 지점 파드를 늘리다 노드가 꽉 차면? 노드 자체를 + 늘리는 클러스터 오토스케일러가 클라우드에 새 서버를 주문합니다. + 여기서부터 "선언 한 줄"이 실제 돈이 돼요. maxReplicas 상한 없이 HPA를 + 열어 두거나, requests를 실사용보다 크게 잡아 노드를 낭비하면, 월말 청구서가 + 그 대가를 알려 줍니다. 오토스케일링 설계는 성능 설계이자 비용 설계 — + 상한을 정하고, 실사용 지표로 requests를 주기적으로 다듬는 것까지가 한 세트예요. +
+
+ +
+

+ '클라우드 네이티브'는 쿠버네티스 하나가 아니라 사고방식과 도구의 + 생태계예요. 뿌리에는 12-factor 앱 원칙이 있습니다 — 설정은 + 환경변수로, 앱은 무상태로, 로그는 표준 출력으로, 프로세스는 쉽게 죽고 쉽게 뜨게. + 눈치챘겠지만 우리가 이미 지키는 컨벤션({'${ENV:default}'}{' '} + 주입, 무상태 API 서버, 컨테이너 빌드)이 상당 부분 이거예요. 컨테이너 친화적 설계를 + 해 뒀다면 쿠버네티스 도입의 절반은 이미 끝난 셈입니다. +

+

+ 그 위의 도구들은 위치만 잡아 둡시다. 헬름(Helm)은 YAML 뭉치의 + 패키징·템플릿 도구 — 서비스가 많아져 매니페스트가 수십 장이 될 때 필요해집니다.{' '} + GitOps(ArgoCD 등)는 섹션 2에서 심은 씨앗의 완성형 — "git의 선언 = + 클러스터의 실제"를 자동 동기화하죠. 서비스 메시(Istio 등)는 서비스 간 + 통신에 암호화·재시도·트래픽 분할을 앱 코드 수정 없이 끼워 넣는 층인데, 복잡도가 커서 + "서비스 수십 개 + 전담 팀" 규모부터 고려하는 게 보통이에요. 셋 다 "지금 + 필요한가?"를 먼저 물으세요 — 도구는 문제가 생긴 뒤에 사도 늦지 않습니다. +

+ {`도입 로드맵 스케치 — 한 번에 점프하지 않는다 + +지금 compose 운영 + 12-factor 컨벤션 유지 (이미 진행 중) +1단계 무상태 서비스 몇 개만 매니페스트 작성, kind로 로컬 검증 +2단계 스테이징 클러스터에 gateway·auth부터 — 프로브·리소스 다듬기 +3단계 나머지 무상태 서비스 이전, HPA 도입 · DB는 관리형 검토 +그 뒤 매니페스트가 늘면 헬름, 배포가 잦으면 GitOps 검토 + +각 단계마다 "이 단계가 실제 문제를 풀었나?"를 확인하고 다음으로.`} +

+ traderbox의 현실도 정직하게 보죠 — 일부 서비스에는 k8s/{' '} + 디렉터리가 있지만 service-macro· + service-content에는 없고, 실제 운영은 compose입니다. + 이건 부끄러운 게 아니라 점진 도입의 자연스러운 중간 상태예요. + 위 로드맵처럼 "어느 서비스부터, 어떤 문제를 풀려고" 옮기는지 순서를 그릴 수 있다면, + 이 코스의 목표는 달성입니다. +

+
+ 실습 — 눈으로 확인하는 자가 치유 개념은 손으로 만져야 남아요. 로컬에서{' '} + kind로 클러스터를 만들고, gateway·auth를 디플로이먼트· + 서비스·인그레스로 올린 뒤, 파드를 강제로 죽여 보세요. 지우는 순간 + 새 파드가 생겨나 선언된 수로 복원되는 것, 이미지 태그를 바꿔 롤링 업데이트가 한 개씩 + 진행되는 것을 kubectl get pods -w로 지켜보는 경험이 + 이 코스의 하이라이트입니다. +
+ {CODE_KIND_LAB} +
+ +
+
+

☸️ 여기까지 왔다면

+

+ 이제 "쿠버네티스 = 어려운 유행어"가 아니라, 선언형 상태 수렴이라는 하나의 + 원리로 스케줄링·자가 치유·무중단 배포·오토스케일링이 전부 설명된다는 걸 + 알게 됐어요. 그리고 그만큼 중요한 감각 — 도입 비용과 시점을 냉정하게 판단하고, + 상태 있는 워크로드 앞에서 멈춰 서는 감각도요. 그런데 파드가 수십 개로 늘어나는 + 순간, 새 질문이 옵니다. 지금 어느 파드가 느린지, 요청이 어느 서비스에서 + 막혔는지 어떻게 알죠? 다음{' '} + 관측성 — 보이지 않으면 + 고칠 수 없다 코스에서, 분산된 시스템의 안을 들여다보는 눈을 이어서 + 만들어 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv4MessageQueueEventDrivenPage.jsx b/frontend/src/pages/levels/Lv4MessageQueueEventDrivenPage.jsx new file mode 100644 index 0000000..94a8808 --- /dev/null +++ b/frontend/src/pages/levels/Lv4MessageQueueEventDrivenPage.jsx @@ -0,0 +1,524 @@ +// 이 파일이 하는 일: "메시지 큐와 이벤트 드리븐 — 시스템을 느슨하게 잇는 법" 코스 — +// 동기 REST 체인의 한계에서 출발해, 큐/토픽 모델, Kafka의 파티션·오프셋·컨슈머 그룹, +// 전달 보장과 멱등 컨슈머, 이벤트 드리븐 설계, DLQ·랙 모니터링, 이벤트 소싱·CQRS까지 +// 7개 섹션으로 안내하는 정적 학습 페이지. 프레이밍은 "대규모 시스템을 설계하는 시야" — +// traderbox의 audit.events 토픽과 Valkey Pub/Sub이라는 실전 사례를 다리 삼아, +// '시스템을 느슨하게 잇는' 비동기 메시징의 사고방식을 익힌다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_SYNC_CHAIN = `[동기 REST 체인] 회원가입 요청 하나가 4개 서비스를 줄줄이 기다린다 + +사용자 → 가입API → 이메일서비스 → 포인트서비스 → 통계서비스 → 응답 + 50ms + 300ms + 80ms + 200ms = 630ms + +문제 ① 지연 전파: 제일 느린 놈(이메일 300ms)이 전체 응답 시간을 정한다 +문제 ② 연쇄 장애: 통계서비스가 죽으면? 가입 자체가 실패한다 (통계 때문에!) +문제 ③ 강한 결합: 알림 채널을 하나 더 붙이려면 가입API 코드를 고쳐야 한다 + +[비동기 메시징] 가입API는 "가입됐다"는 사실만 남기고 즉시 응답한다 + +사용자 → 가입API → (UserSignedUp 이벤트 발행) → 응답 = 60ms + ↓ 브로커(Kafka) + 이메일서비스 ── 포인트서비스 ── 통계서비스 ← 각자 알아서 구독 + (느려도 OK) (죽어도 가입은 성공, 살아나면 밀린 것 처리)`; + +const CODE_QUEUE_VS_TOPIC = `[포인트-투-포인트 큐] "일감 나눠 갖기" — 메시지 하나 = 일 하나 = 처리자 하나 + + 생산자 → [ 큐 ] → 워커A (메시지1 가져감·삭제) + → 워커B (메시지2 가져감·삭제) + · 워커를 늘리면 처리량이 는다 (작업 분배) + · 한 메시지는 딱 한 워커만 본다 — 이미지 리사이징, 메일 발송 같은 '작업'에 적합 + +[발행-구독 토픽] "방송하기" — 메시지 하나 = 사실 하나 = 듣는 쪽 여럿 + + 생산자 → [ 토픽 ] → 이메일서비스 (전부 받음) + → 포인트서비스 (전부 받음) + → 통계서비스 (전부 받음) + · 구독자를 늘려도 서로 영향 없음 — "가입했다"는 사실의 방송 + · Kafka는 여기에 '로그'를 더했다: 읽어도 지우지 않고, 각자 어디까지 + 읽었는지(오프셋)만 기억한다 → 나중에 온 구독자도 과거를 읽을 수 있다`; + +const CODE_KAFKA_ANATOMY = `토픽 audit.events (파티션 3개라고 가정) + + 파티션 0: [0][1][2][3][4][5] ←새 메시지는 끝에만 붙는다(append-only) + 파티션 1: [0][1][2][3] + 파티션 2: [0][1][2][3][4] + ↑ + 오프셋(offset): 파티션 안에서의 순번. 컨슈머는 "나 파티션0의 + 3번까지 읽었어"를 브로커에 기록(커밋)해 두고 이어 읽는다. + +순서 보장의 범위: 같은 파티션 안에서만 순서가 보장된다. + → 파티션 키를 userId로 주면: 같은 사용자의 이벤트는 같은 파티션에 + 들어가 순서 유지, 다른 사용자끼리는 병렬 처리. (키 선택 = 설계 결정) + +컨슈머 그룹: 같은 groupId를 가진 컨슈머들이 파티션을 나눠 맡는다. + 그룹 [welcome-noti] 컨슈머A → 파티션 0, 1 담당 + 컨슈머B → 파티션 2 담당 + · 컨슈머를 3대로 늘리면 → 1대당 파티션 1개 (처리량 UP) + · 4대로 늘리면? → 1대는 논다. 파티션 수가 병렬성의 상한이다. + · 컨슈머가 죽거나 늘면 파티션 재배정(리밸런싱)이 일어난다 — 이 순간 + 잠깐 소비가 멈추고, 처리 중이던 메시지가 다시 배정될 수 있다.`; + +const CODE_PRODUCER = `// service-auth 쪽 — 회원가입이 '성공한 뒤' 이벤트를 발행한다 +// 학습 포인트: 이벤트 이름은 과거형(UserSignedUp). "이미 일어난 사실"이기 때문. +public record UserSignedUpEvent( + String eventId, // UUID — 컨슈머가 중복 제거에 쓸 유일 키 + Long userId, + String email, + Instant occurredAt // 사실이 '일어난' 시각 (발행 시각과 구분) +) implements AuditEvent + +@Service +public class SignupEventPublisher + + private final KafkaTemplate kafka; + + public void publish(User user) + var event = new UserSignedUpEvent( + UUID.randomUUID().toString(), + user.getId(), user.getEmail(), Instant.now()); + // 파티션 키 = userId → 같은 사용자의 이벤트는 순서 보장 + kafka.send("user.events", String.valueOf(user.getId()), toJson(event)); + +// traderbox의 AuditEventPublisher가 audit.events 토픽에 하는 일과 같은 구조다. +// 발행자는 "누가 듣는지" 모른다 — 알 필요도 없다. 그게 느슨한 결합이다.`; + +const CODE_IDEMPOTENT_CONSUMER = `// 웰컴 알림 컨슈머 — 같은 이벤트가 '두 번' 와도 알림은 '한 번'만 +// 학습 포인트: at-least-once 세상에서 컨슈머의 기본 예의는 멱등(idempotent)이다. +@Component +public class WelcomeNotiConsumer + + private final ProcessedEventRepository processed; // 처리한 eventId 저장소 + private final NotificationSender sender; + + @KafkaListener(topics = "user.events", groupId = "welcome-noti") + public void on(String message) + var event = fromJson(message, UserSignedUpEvent.class); + + // ① 이미 처리한 이벤트인가? — eventId로 중복 제거 + if (processed.existsById(event.eventId())) + return; // 두 번째 도착: 조용히 무시. 이게 멱등 처리의 전부다. + + // ② 실제 작업 + sender.sendWelcome(event.userId(), event.email()); + + // ③ 처리 완료 기록 — ②와 ③을 같은 DB 트랜잭션으로 묶으면 더 안전 + processed.save(new ProcessedEvent(event.eventId(), Instant.now())); + +// 왜 중복이 오나? 컨슈머가 알림을 보낸 '직후, 오프셋 커밋 직전'에 죽으면 +// 브로커는 "아직 안 읽었네" 하고 같은 메시지를 다시 준다. 이건 버그가 아니라 +// at-least-once의 정상 동작이다. 그래서 방어는 언제나 컨슈머 몫이다.`; + +const CODE_OUTBOX = `// 트랜잭셔널 아웃박스 — "DB 저장과 이벤트 발행"을 사실상 하나로 묶는 패턴 +// 문제: DB에 회원을 저장하고 → Kafka에 발행하는 사이에 서버가 죽으면? +// 회원은 있는데 이벤트는 없다. (반대로 발행 먼저 하면 유령 이벤트가 생긴다) + +// 해법: 이벤트를 Kafka가 아니라 '같은 DB의 outbox 테이블'에 먼저 쓴다. +@Transactional +public void signup(SignupRequest req) + var user = userRepository.save(newUser(req)); // ① 회원 저장 + outboxRepository.save(OutboxRow.of( // ② 이벤트도 같은 + "UserSignedUp", toJson(eventOf(user)))); // 트랜잭션으로 저장 + // 커밋 → 둘 다 저장되거나, 둘 다 안 되거나. 어긋날 틈이 없다. + +// ③ 별도의 릴레이(폴러 또는 CDC)가 outbox 테이블을 읽어 Kafka로 발행하고 +// 발행 성공한 행을 지운다. 릴레이가 죽어도 행은 남아 있으니 재시도하면 그만. + +// '정확히 한 번'처럼 보이는 것의 정체: +// 아웃박스(발행 유실 방지) + 멱등 컨슈머(중복 소비 무해화) +// = 유실도 없고 중복의 '효과'도 없음. 브로커 설정 하나로 되는 마법이 아니라 +// 생산자와 소비자 양쪽의 설계로 만들어 내는 성질이다.`; + +const CODE_EVENT_SCHEMA = `// 이벤트 스키마와 하위 호환 — 이벤트는 '발행되는 순간 공공재'가 된다 +// v1 구독자가 돌고 있는데 발행자만 v2로 올라가는 상황은 일상이다. + +// v1 v2 (호환 OK — 필드 추가) +"eventId": "...", "eventId": "...", +"userId": 42, "userId": 42, +"email": "a@b.dev", "email": "a@b.dev", +"occurredAt": "..." "occurredAt": "...", + "referrer": "friend-invite" // 새 필드 + +→ v1 컨슈머는 모르는 필드(referrer)를 그냥 무시하면 된다. 안전. + +// v2' (호환 깨짐 — 필드 삭제/의미 변경) +"eventId": "...", +"userId": 42, // email 삭제! +"occurredAt": "..." + +→ email을 쓰던 웰컴 알림 컨슈머가 그 자리에서 깨진다. + 규칙: 필드 추가는 자유, 삭제·이름변경·타입변경은 금지(정 필요하면 + 새 버전 토픽이나 마이그레이션 기간을 두고 단계적으로).`; + +const CODE_DLQ = `// 포이즌 메시지와 DLQ — '한 놈'이 전체 라인을 세우지 않게 +// 포이즌 메시지: 파싱 불가, 규칙 위반 등으로 몇 번을 재시도해도 실패하는 메시지. +// 이걸 무한 재시도하면? 그 파티션의 뒷 메시지 전부가 영원히 막힌다. + +@KafkaListener(topics = "user.events", groupId = "welcome-noti") +public void on(String message) + try + handle(message); + catch (TransientException e) + throw e; // 일시 오류(네트워크 등) → 재시도 대상 + catch (Exception e) + // 영구 오류 → 재시도 N회 후 데드레터 토픽으로 치우고 다음으로 진행 + // (Spring Kafka: DefaultErrorHandler + DeadLetterPublishingRecoverer) + // user.events → user.events.DLT + // DLQ는 '버리는 곳'이 아니라 '나중에 사람이 볼 병원'이다. + // → 알람 걸고, 원인 고친 뒤, DLQ에서 본 토픽으로 재발행(재처리)한다. + +// 컨슈머 랙(lag) = (파티션의 최신 오프셋) − (내 그룹이 커밋한 오프셋) +// 랙 0~수십 : 실시간으로 따라가는 중. 정상. +// 랙이 계속 증가: 소비 속도 < 생산 속도. 컨슈머 장애이거나 처리량 부족. +// 알람 기준은 절대값보다 '증가 추세 + 지속 시간'으로 잡는 게 실전적이다. +// (장 마감 직후처럼 원래 몰리는 시간대의 일시적 랙은 정상일 수 있으니까)`; + +const CODE_EVENT_SOURCING = `[상태 저장] 계좌 테이블에 '지금 잔고'만 남긴다 + balance = 30,000 ← 어쩌다 3만원이 됐는지는 아무도 모른다 + +[이벤트 소싱] '일어난 일'을 전부 남기고, 상태는 그걸 재생해 계산한다 + AccountOpened (+0) + MoneyDeposited (+50,000) + MoneyWithdrawn (-30,000) + MoneyDeposited (+10,000) + ──────────────────────── 재생(replay) → balance = 30,000 + + 회계 장부와 같은 사고방식: 지우지 않고 덧붙인다. 감사(audit)·시점 복원· + "그때 왜 그랬지?" 추적이 공짜로 따라온다. 대신 조회가 어려워진다 — + "잔고 10만원 이상 계좌 목록"을 이벤트 더미에서 바로 뽑을 순 없으니까. + +[CQRS] 그래서 읽기 모델을 따로 둔다 + 쓰기: 커맨드 → 이벤트 저장 (정합성·이력에 최적화) + 읽기: 이벤트를 구독해 조회 전용 테이블/캐시를 갱신 (조회 속도에 최적화) + 대가: 두 모델 사이에 '잠깐의 시차'(최종 일관성)가 생긴다. + +판단 기준: 이력 자체가 도메인의 핵심(금융 원장, 감사)이면 강력하다. +그 외 대부분의 팀에는 [아웃박스 + 토픽 + 멱등 컨슈머]가 충분하고, +그게 이 강좌에서 여러분이 이미 다 배운 조합이다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '동기 호출의 사슬' }, + { n: 2, label: '큐 vs 토픽' }, + { n: 3, label: 'Kafka 해부' }, + { n: 4, label: '전달 보장' }, + { n: 5, label: '이벤트 드리븐 설계' }, + { n: 6, label: '실패와 지연' }, + { n: 7, label: '이벤트 소싱·CQRS' }, +]; + +export default function Lv4MessageQueueEventDrivenPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 특급 과정
+

메시지 큐와 이벤트 드리븐
— 시스템을 느슨하게 잇는 법 📨

+

+ 서비스가 서비스를 직접 부르는 동기 호출은 직관적이지만, 시스템이 + 커질수록 "하나가 느려지면 다 느려지는" 사슬이 됩니다. 이 코스에서는 그 사슬을{' '} + Kafka 같은 메시지 브로커로 끊어 내고, "요청과 응답" 대신{' '} + "일어난 사실(이벤트)"을 중심으로 사고하는 법을 배웁니다. 파티션과 + 오프셋, 멱등 컨슈머, 아웃박스, DLQ까지 — 대규모 분산 환경을 설계하는 시야를 함께 + 길러 봅시다. +

+
+ 예상 소요 85분 + 실습: 가입 이벤트 발행 + 멱등 웰컴 컨슈머 + 선수 지식: REST API·DB 트랜잭션 +
+
+ +
+

+ 회원가입 하나를 생각해 봅시다. 가입 처리 후에 웰컴 이메일을 보내고,{' '} + 가입 포인트를 적립하고, 통계도 갱신해야 한다면? + 가장 직관적인 방법은 가입 API가 세 서비스를 차례로 REST 호출하는 + 거예요. 작을 땐 이걸로 충분합니다. 그런데 시스템이 커지면 이 사슬이 세 가지 방식으로 + 발목을 잡습니다. +

+ {CODE_SYNC_CHAIN} +

+ 첫째, 지연 전파 — 응답 시간이 사슬에 묶인 서비스들의 이 + 되고, 제일 느린 서비스가 전체의 하한을 정합니다. 둘째, 연쇄 장애 — + 통계서비스가 죽었을 뿐인데 회원가입이 실패해요. 부가 기능의 장애가 핵심 기능을 + 무너뜨리는 거죠. 셋째, 강한 결합 — 알림 채널 하나 추가할 때마다 + 가입 API 코드를 열어야 하니, 서비스들이 서로의 존재를 너무 잘 압니다. +

+

+ 비동기 메시징은 이 셋을 한 번에 풀어요. 가입 API는 브로커에 + "가입됐다"는 메시지만 남기고 즉시 응답합니다(시간적 분리). 트래픽이 + 몰려도 브로커가 버퍼가 되어 컨슈머는 제 속도로 소화하고(부하 흡수), + 컨슈머 하나가 죽어도 메시지는 브로커에 남아 있다가 복구 후 처리됩니다 + (장애 격리). +

+
+ 그래도 동기가 맞는 경우 모든 걸 비동기로 바꾸라는 얘기가 아니에요.{' '} + 호출한 쪽이 결과를 알아야 다음 단계로 갈 수 있는 일 — 잔고 조회, + 비밀번호 검증, 주문 가능 여부 확인 — 은 동기가 맞습니다. 기준은 하나예요. + "이 응답을 지금 당장 알아야 하는가?" 예스면 동기, 나중에 처리돼도 + 되면 비동기. 회원가입의 '가입'은 동기, '웰컴 메일'은 비동기인 이유입니다. +
+
+ +
+

+ "메시지 큐"라고 뭉뚱그려 부르지만, 그 안엔 목적이 다른 두 모델이 있어요.{' '} + 포인트-투-포인트 큐작업 분배가 목적입니다. + 메시지 하나는 일감 하나고, 워커 중 딱 하나가 가져가 처리하면 + 큐에서 사라져요. 반면 발행-구독(pub/sub) 토픽은{' '} + 사실의 방송이 목적이라, 구독자 전원이 같은 메시지를 + 받습니다. +

+ {CODE_QUEUE_VS_TOPIC} +

+ 브로커의 구조도 갈립니다. RabbitMQ류는 전통적 큐 모델 — 브로커가 + 메시지를 워커에게 밀어 주고, 처리 확인(ack)을 받으면 지웁니다. + 라우팅이 유연하고 작업 큐에 강해요. Kafka류는{' '} + 로그 기반 — 메시지를 지우지 않고 순서대로 쌓아 두고, 컨슈머가 + "어디까지 읽었는지"만 각자 기억합니다. 그래서 같은 데이터를 여러 팀이 각자의 + 속도로 읽을 수 있고, 나중에 합류한 컨슈머도 과거부터 읽을 수 있죠. +

+

+ 우리가 참고하는 실전 사례, traderbox를 보면 이 구분이 선명해요. 실시간 + 시세는 Valkey Pub/Sub(market.ticks 채널)로 + 흘립니다 — 시세는 "지금 이 순간"이 전부라, 놓친 틱을 나중에 읽을 이유가 없거든요. + 가볍고 빠른 인메모리 방송이면 충분합니다. 반면 감사 로그는 + Kafka(audit.events 토픽)로 보내요 — 감사 기록은{' '} + 하나도 잃으면 안 되고, 나중에 다시 읽을 수도 있어야 하니까요. +

+
+ 선택 기준 한 줄 "놓친 메시지를 나중에 읽어야 하는가?" — 아니오(시세, 채팅 + 프레즌스)면 가벼운 Pub/Sub, 예(감사, 정산, 이벤트 이력)면 로그 기반 브로커. + 같은 데이터를 두 채널로 흘리지 않는 것도 중요한 원칙이에요. 채널마다 보장이 + 다르면 "어느 쪽이 진실이냐"는 골치 아픈 질문이 생기니까요. +
+
+ +
+

+ Kafka의 심장은 "지우지 않는 로그"입니다. 토픽은 여러 개의{' '} + 파티션으로 쪼개져 있고, 각 파티션은 메시지가 끝에만 붙는 + (append-only) 파일이에요. 파티션 안의 순번이 오프셋이고, 컨슈머는 + "나 여기까지 읽었어"라는 오프셋만 커밋해 둡니다. 메시지를 지우는 게 아니라{' '} + 책갈피를 옮기는 방식이라, 여러 그룹이 같은 토픽을 각자 읽어도 서로 + 방해가 없어요. +

+ {CODE_KAFKA_ANATOMY} +

+ 여기서 특급 개발자의 첫 설계 결정이 나옵니다. 파티션 키를 뭘로 + 잡을 것인가. Kafka는 같은 파티션 안에서만 순서를 보장하는데, 같은 + 키의 메시지는 항상 같은 파티션으로 가요. 키를 userId로 + 주면 "한 사용자의 이벤트는 순서대로, 사용자끼리는 병렬로"가 되고, 키를 안 주면 + 순서는 포기하는 대신 부하가 고르게 퍼집니다. 순서 보장과 부하 분산을 한 번에 + 결정하는 한 줄인 셈이죠. +

+

+ 컨슈머 그룹은 소비 쪽의 병렬화 장치예요. 같은{' '} + groupId의 컨슈머들이 파티션을 나눠 맡고, 컨슈머를 + 늘리면 파티션이 재배정(리밸런싱)되며 처리량이 늘어요. 단, 파티션 + 수가 병렬성의 상한이라 파티션 3개짜리 토픽에 컨슈머 4대를 붙이면 1대는 놉니다. + 그리고 리밸런싱 중엔 소비가 잠깐 멈추고, 처리 중이던 메시지가 다른 컨슈머로 다시 + 갈 수 있어요 — 섹션 4의 "중복 소비"가 여기서도 태어납니다. +

+
+ 실전 구성 읽기 traderbox의 AuditEventPublisher는 + 각 서비스에서 감사 이벤트를 audit.events 토픽으로 + 발행하고, log 서비스의 AuditEventConsumer가 자기 + 그룹으로 구독해 DB에 적재합니다. 발행하는 서비스들은 log 서비스가 죽었는지조차 + 모릅니다 — 죽어 있는 동안의 이벤트도 토픽에 쌓여 있다가, 살아나면 밀린 오프셋부터 + 따라잡아요. "지우지 않는 로그"가 주는 장애 격리를 눈으로 볼 수 있는 구성이에요. +
+
+ +
+

+ 메시징 시스템의 전달 보장은 세 등급으로 말해요. at-most-once(최대 + 한 번 — 유실 가능, 중복 없음), at-least-once(최소 한 번 — 유실 + 없음, 중복 가능), exactly-once(정확히 한 번). 실무의 기본값은{' '} + at-least-once입니다. 왜냐하면 "처리했다"는 확인이 오가는 분산 + 환경에서는, 확인이 유실됐을 때 다시 보내는 것 말고는 유실을 막을 + 방법이 없거든요. 다시 보내면? 중복이 생기죠. 즉 유실을 막는 대가가 + 중복이고, 중복은 장애가 아니라 설계에 반영해야 할 전제입니다. +

+

+ 그래서 컨슈머의 기본 소양이 멱등(idempotent) 처리예요. 같은 이벤트가 + 두 번 와도 효과는 한 번이게 만드는 것. 가장 정직한 방법은 이벤트마다 + 유일한 eventId를 실어 보내고, 컨슈머가 "처리한 ID + 장부"를 확인하는 겁니다. 이번 실습의 핵심이 바로 이거예요. +

+ {CODE_IDEMPOTENT_CONSUMER} +

+ 그럼 exactly-once는요? 흔한 오해가 "브로커 설정 하나 켜면 되는 것" + 인데, 브로커 혼자서는 못 만듭니다. 브로커 바깥 — 여러분의 DB, 이메일 + 발송, 외부 API — 까지 걸치는 순간, 보장은 양 끝단의 설계 몫이 돼요. 생산 쪽의 고전적 + 해법이 트랜잭셔널 아웃박스 패턴입니다. +

+ {CODE_OUTBOX} +
+ 공식으로 기억하세요 실무에서 "정확히 한 번처럼"은{' '} + 아웃박스(발행 유실 방지) + at-least-once 전달 + 멱등 컨슈머(중복 무해화) + 의 합으로 만듭니다. 셋 중 하나라도 빠지면 어딘가에서 유실되거나 두 번 실행돼요. + 이번 실습에서 이 세 조각을 전부 손으로 만져 봅니다. +
+
+ +
+

+ 도구를 알았으니 이제 설계 언어를 다듬을 차례예요. 첫 구분은{' '} + 이벤트 vs 커맨드입니다. UserSignedUp + (가입했다)은 이벤트 — 이미 일어난 과거형 사실이고, + 발행자는 누가 듣는지 모르며, 듣는 쪽이 뭘 할지는 듣는 쪽 마음입니다.{' '} + SendEmail(보내)은 커맨드 — + 특정 수신자에게 특정 행동을 요구하는 요청이죠. 이벤트로 설계하면 + 발행자가 홀가분해지고(결합 감소), 커맨드로 설계하면 의도가 명확해집니다(책임 명시). + 가입 API가 SendWelcomeEmail 커맨드를 쏘는 순간, + "이메일을 보내야 한다"는 지식이 다시 가입 API로 돌아온다는 점을 눈여겨보세요 — + 동기 호출 시절의 결합이 모양만 바꿔 부활한 겁니다. +

+

+ 두 번째는 스키마 호환성이에요. 이벤트는 발행되는 순간, 지금 있는 + 컨슈머뿐 아니라 미래의 컨슈머토픽에 쌓인 과거까지 + 상대하는 공공재가 됩니다. 발행자와 구독자는 따로 배포되니, 스키마 변경은 언제나 + "옛 컨슈머가 새 메시지를 읽는" 상황을 견뎌야 해요. +

+ {CODE_EVENT_SCHEMA} +

+ 세 번째는 흐름의 지휘 방식입니다. 코레오그래피 + (choreography)는 지휘자 없이 각 서비스가 앞 이벤트에 반응해 자기 일을 하고 + 다음 이벤트를 내는 방식 — 결합이 가장 느슨하지만, "주문 하나가 지금 어디까지 + 진행됐지?"를 아무도 한눈에 모릅니다. 오케스트레이션(orchestration)은 + 지휘자(오케스트레이터)가 순서를 관리하며 각 단계를 호출하는 방식 — 흐름이 한 곳에 + 보이는 대신 지휘자가 결합점이자 단일 장애점이 되기 쉬워요. 단계가 적고 독립적이면 + 코레오그래피, 단계가 많고 보상 처리(취소·환불)가 얽히면 오케스트레이션 쪽이 + 관리하기 쉽다는 게 통례입니다. +

+
+ 이름 짓기가 설계다 이벤트 이름을 지을 때 과거형 동사로 안 써지면 —{' '} + ProcessPayment처럼 — 그건 이벤트가 아니라 커맨드일 + 가능성이 높아요. 이름을 과거형으로 바꿔 보려는 시도 자체가 "이건 사실인가, + 요청인가"를 강제로 묻게 하는 좋은 설계 훈련입니다. +
+
+ +
+

+ 동기 호출은 실패하면 호출자가 바로 압니다. 비동기는 반대예요 — + 발행자는 이미 응답을 끝냈으니, 컨슈머의 실패는 아무도 모르게 + 쌓입니다. 그래서 비동기 시스템의 운영은 "실패를 어디에 모아 두고, 지연을 어떻게 + 감지하느냐"의 문제가 됩니다. +

+

+ 첫 번째 도구가 데드레터 큐(DLQ)예요. 몇 번을 재시도해도 실패하는{' '} + 포이즌 메시지를 무한 재시도하면, 같은 파티션 뒤의 멀쩡한 메시지 + 전부가 막힙니다. 그래서 "N회 실패하면 별도 토픽(.DLT) + 으로 치우고 다음으로 진행"이 표준이에요. 단, 치우기 전에{' '} + 일시 오류(재시도하면 될 일)와 영구 오류(백번 해도 안 될 일)를 구분 + 해야 합니다 — 네트워크 순단까지 DLQ로 보내면 멀쩡한 메시지를 버리는 셈이니까요. +

+ {CODE_DLQ} +

+ 두 번째 도구가 컨슈머 랙(lag) 모니터링입니다. 랙은 "생산된 최신 + 오프셋과 내가 커밋한 오프셋의 차이" — 즉 밀린 일감의 양이에요. + 랙이 0 근처면 실시간, 계속 증가하면 컨슈머가 죽었거나 처리량이 + 모자란 겁니다. 비동기 시스템에서 랙은 사실상 유일한 조기 경보라, "웰컴 메일이 안 + 와요"라는 고객 문의보다 몇 시간 먼저 문제를 알려 줍니다. +

+

+ 마지막으로 재처리(replay) — 로그 기반 브로커의 특권입니다. 컨슈머 + 버그로 일주일치 이벤트를 잘못 처리했다면? 오프셋을 되감아 다시 읽으면 돼요. 단, + 조건이 있습니다. ① 컨슈머가 멱등할 것(다시 읽으면 이미 처리한 + 이벤트도 다시 오니까), ② 토픽 보존 기간(retention) 안일 것,{' '} + ③ 이벤트에 처리에 필요한 정보가 자족적으로 담겨 있을 것(발행 시점의 + 외부 상태에 기대면 재생 시점엔 그 상태가 없어요). 섹션 4에서 만든 멱등 컨슈머가 + 여기서 다시 효자가 됩니다. +

+
+ 알람 기준 잡기 랙 알람을 "랙 > 1000" 같은 절대값으로만 걸면, 트래픽이 + 원래 몰리는 시간대(장 마감 직후 등)마다 거짓 알람이 울려요. 증가 추세가 + N분 이상 지속되는지를 함께 보는 게 실전적입니다. 알람이 자주 거짓말하면 + 사람이 알람을 끄게 되고, 그날부터 모니터링은 없는 것과 같아요. +
+
+ +
+

+ 마지막으로, 이벤트 중심 사고를 끝까지 밀어붙인 패턴을 구경해 봅시다.{' '} + 이벤트 소싱은 "현재 상태"를 저장하는 대신 일어난 이벤트 + 전부를 저장하고, 상태는 이벤트를 처음부터 재생해 계산하는 방식이에요. + 회계 장부와 똑같습니다 — 잔고를 고쳐 쓰는 게 아니라 거래를 덧붙이고, 잔고는 + 장부를 합산해 얻죠. +

+ {CODE_EVENT_SOURCING} +

+ 얻는 것: 완벽한 감사 이력, 임의 시점 복원, "왜 이 상태가 됐는지"에 대한 완전한 + 답. 잃는 것: 조회의 단순함입니다. 이벤트 더미에서 "잔고 10만원 + 이상 계좌 목록"을 바로 뽑을 순 없으니, 조회용 모델을 따로 두게 되고 — 그게{' '} + CQRS(Command Query Responsibility Segregation, 쓰기 모델과 읽기 + 모델의 분리)입니다. 쓰기는 정합성과 이력에, 읽기는 조회 속도에 각각 최적화하는 + 대신, 두 모델 사이에 최종 일관성(잠깐의 시차)을 받아들이는 거래예요. +

+

+ 도입 판단은 냉정하게 하세요. 이벤트 소싱 + CQRS는 이력 자체가 도메인의 + 핵심인 곳 — 금융 원장, 감사 추적, 재고 이동 — 에서 빛나지만, 이벤트 스키마 + 버전 관리·재생 성능·스냅샷 같은 운영 비용이 만만치 않습니다. 대부분의 팀에는 이 + 강좌에서 배운 아웃박스 + 토픽 + 멱등 컨슈머 조합이면 충분해요. + 패턴의 이름값이 아니라, "우리 도메인이 이력을 얼마나 필요로 하는가"가 기준입니다. +

+
+ 실습 과제 이제 손으로 만들 차례예요. ① 회원가입 성공 시{' '} + UserSignedUp 이벤트를 Kafka 토픽으로 발행 + 하고(파티션 키는 userId, 이벤트에 eventId 포함),{' '} + ② 웰컴 알림 컨슈머를 별도 컨슈머 그룹으로 만들어 구독한 뒤,{' '} + ③ 같은 이벤트를 일부러 두 번 발행해도 알림이 한 번만 가는지{' '} + eventId 장부로 멱등 처리해 증명하세요. 컨슈머를 + 끈 채 이벤트를 쌓았다가 켜서 따라잡는 것까지 확인하면, 이 강좌의 절반을 몸으로 + 이해한 겁니다. +
+
+ +
+
+

📨 여기까지 왔다면

+

+ 이제 서비스 사이의 화살표를 볼 때 "이건 지금 답을 알아야 하는 호출인가, 나중에 + 처리해도 되는 사실인가"를 먼저 묻게 됐을 거예요. 동기 사슬을 끊는 브로커, 순서와 + 병렬성을 함께 정하는 파티션 키, 중복을 전제로 한 멱등 컨슈머, 유실을 막는 + 아웃박스, 실패를 모아 두는 DLQ까지 — 느슨하게 잇는 도구와 사고방식을 + 한 바퀴 돌았습니다. 그런데 이 도구들이 진짜 필요해지는 순간은, 하나였던 시스템을{' '} + 여러 조각으로 쪼갤 때예요. 쪼개면 정말 좋아질까요, 아니면 분산 + 시스템의 고통만 사 오는 걸까요? 다음{' '} + 모놀리스 vs 마이크로서비스 + — 쪼개는 것의 진짜 비용 강좌에서, 그 판단의 저울을 함께 들어 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv4MonolithVsMicroservicesPage.jsx b/frontend/src/pages/levels/Lv4MonolithVsMicroservicesPage.jsx new file mode 100644 index 0000000..ba2c06b --- /dev/null +++ b/frontend/src/pages/levels/Lv4MonolithVsMicroservicesPage.jsx @@ -0,0 +1,539 @@ +// 이 파일이 하는 일: "모놀리스 vs 마이크로서비스 — 쪼개는 것의 진짜 비용" 코스 — +// 유행이 아니라 트레이드오프로 아키텍처를 고르는 눈을 기르는 특급 과정 페이지. +// 모놀리스의 강점에서 출발해 분산의 세금, 서비스 경계 긋기(DDD), 통신 설계, 사가 패턴, +// 스키마-퍼-서비스, 그리고 전환 전략(스트랭글러 무화과)까지 7개 섹션으로 안내한다. +// 프레이밍은 "쪼개지 않을 용기" — 실제 운영 중인 traderbox의 6서비스 분할을 살아 있는 +// 교재로 삼아, 경계를 왜 거기에 그었는지 근거를 읽는 훈련을 시킨다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── +// 학습 포인트: 백틱 상수 안에는 백틱과 달러-중괄호 보간이 들어가면 안 된다. +// 그래서 코드 예제도 중괄호 없는 의사코드·SQL·다이어그램 위주로 구성했다. + +const CODE_TRADEOFF = `모놀리스 (하나의 배포 단위) 마이크로서비스 (여러 배포 단위) +────────────────────────────── ────────────────────────────── +배포: jar 하나, 명령 하나 배포: 서비스별로 독립 배포 +호출: 로컬 함수 호출 (나노초) 호출: 네트워크 호출 (밀리초 + 실패 가능) +트랜잭션: DB 하나, BEGIN~COMMIT 트랜잭션: 서비스마다 따로 → 사가 필요 +확장: 전체를 복제 (통짜 스케일아웃) 확장: 병목인 서비스만 골라 확장 +팀: 한 코드베이스를 모두가 공유 팀: 서비스별 오너십, 독립 배포 주기 + +핵심: 어느 쪽도 '정답'이 아니다. +왼쪽 열이 주는 단순함을 포기하는 대가로 오른쪽 열의 자율성을 사는 것. +그 거래가 남는 장사인지가 유일한 질문이다.`; + +const CODE_CONWAY = `콘웨이의 법칙 (Conway's Law, 1968) + +"시스템을 설계하는 조직은, + 그 조직의 커뮤니케이션 구조를 닮은 설계를 내놓는다." + +3명이 하나의 제품을 만드는 팀 + → 서비스 30개로 쪼개면: 한 사람이 10개 서비스를 오가며 배포·온콜 + → 조직이 감당 못 하는 아키텍처는 종이 위에서만 아름답다 + +30명이 5개 도메인 팀으로 나뉜 조직 + → 모놀리스 하나면: 배포 열차에 5팀이 줄 서고, 머지 충돌이 일상 + → 팀 경계 = 서비스 경계로 맞추면 각 팀이 자기 속도로 달린다 + +결론: "마이크로서비스로 갈까?"는 기술 질문이 아니라 + "우리 조직이 몇 개의 독립된 배포 주기를 감당할 수 있나?"라는 조직 질문.`; + +const CODE_TAX = `// 같은 한 줄이, 경계를 넘는 순간 얼마나 달라지는가 + +[모놀리스 안에서] + 주문서비스.재고확인(상품ID) + → 나노초 단위, 실패 모드는 사실상 '버그'뿐 + → 호출자와 피호출자가 같이 배포되므로 시그니처 변경도 컴파일러가 잡아줌 + +[마이크로서비스에서 같은 일] + GET http://inventory-svc/internal/stock/상품ID + → 걸리는 시간: 수 밀리초 (수만 배 느림) + → 새로 생기는 실패 모드: + - 타임아웃 (상대는 살아있는데 응답이 늦음 → 재시도? 몇 번?) + - 부분 실패 (주문은 됐는데 재고 차감 응답을 못 받음 → 됐나 안 됐나?) + - 버전 불일치 (재고팀이 응답 필드를 바꿨는데 주문팀은 옛 버전 파싱) + → 그래서 필요해지는 것들: + 타임아웃 설정, 재시도 정책, 서킷 브레이커, API 버저닝, 분산 추적... + +이 목록 전부가 '분산의 세금'이다. 쪼개는 순간 자동으로 청구된다.`; + +const CODE_DIST_MONOLITH = `분산 모놀리스 — 최악의 형태 판별법 + +증상 1: A 서비스를 배포하려면 B, C도 같이 배포해야 한다 + (독립 배포라는 유일한 보상을 못 받으면서 네트워크 세금만 냄) +증상 2: 서비스들이 하나의 DB 테이블을 같이 읽고 쓴다 + (스키마 하나 바꾸면 전 서비스가 멈춤 — 사실상 결합돼 있음) +증상 3: 기능 하나 추가에 서비스 4개의 PR이 동시에 필요하다 + (경계를 도메인이 아니라 기술 계층으로 그었을 때 흔한 증상) + +운영 비용도 서비스 수에 비례해 늘어난다: + 서비스 6개 = 배포 파이프라인 6개 + 대시보드 6개 + 로그 스트림 6개 + + 알림 규칙 6벌 + 장애 시 의심 지점 6곳 + 온콜 부담 +모놀리스에서 스택트레이스 한 장이면 끝나던 디버깅이, +분산에선 서비스 3개의 로그를 시간순으로 이어 붙이는 고고학이 된다.`; + +const CODE_BOUNDED = `바운디드 컨텍스트 찾기 — "같은 단어가 다른 뜻이 되는 지점" + +쇼핑몰에서 '상품(Product)'이라는 한 단어: + + 카탈로그 컨텍스트의 상품 = 이름, 설명, 사진, 카테고리, 검색 키워드 + 재고 컨텍스트의 상품 = SKU, 창고 위치, 수량, 입고 예정일 + 주문 컨텍스트의 상품 = 주문 시점의 가격 스냅샷, 수량, 환불 정책 + 배송 컨텍스트의 상품 = 무게, 부피, 파손 주의 여부 + +네 곳의 '상품'은 이름만 같지 관심사가 전혀 다르다. +→ 이 네 덩어리가 각각 바운디드 컨텍스트, 곧 서비스 경계의 유력 후보. + +데이터 소유권 원칙: 한 테이블의 주인은 정확히 하나의 서비스다. + - 다른 서비스가 그 데이터를 원하면? 주인의 API를 부르거나 이벤트를 구독한다. + - 다른 서비스가 그 테이블에 직접 SELECT/UPDATE? 그 순간 경계는 무너진다.`; + +const CODE_TRADERBOX = `traderbox의 실제 분할 — 경계 근거 읽기 (6서비스) + + gateway (8080) 진입점. 라우팅·횡단 관심사 — 도메인 로직 없음 + auth (8081) 사용자·토큰·워치리스트·포트폴리오 — '사용자의 것' + market (8083) 시세·호가 — 유일하게 초단위 실시간, 부분 확장 대상 + content (8084) 공시·아티클·라이브 — 편집/발행 주기가 시세와 전혀 다름 + macro (8085) 거시 지표 — 하루 몇 번 수집, 배포 주기 독립 + log (8082) 감사 로그 — 전 서비스의 이벤트를 Kafka로 받아 적재만 + +경계 근거를 뜯어 보면: + market을 따로 둔 이유 → 확장 비대칭. 장중 트래픽이 다른 서비스의 수십 배. + 시세 때문에 auth까지 스케일아웃할 이유가 없다. + log를 따로 둔 이유 → 쓰기 패턴이 다름(append-only) + 어떤 서비스도 + 감사 적재가 느리다고 같이 느려지면 안 됨(비동기 격리). + content와 macro 분리 → 데이터 수명주기가 다름. 공시·아티클은 편집 도메인, + 거시는 수집 배치 도메인. 팀이 나뉘어도 자연스러운 선. + +주목: '주문/결제/상품'처럼 명사로 자른 게 아니라, + 트래픽 패턴·배포 주기·데이터 수명주기가 갈라지는 지점에서 잘랐다.`; + +const CODE_COMM = `traderbox의 통신 규칙 — 채널 3개, 그리고 한 가지 금기 + +동기 (지금 답이 필요할 때): + 서비스 → 서비스 직접 호출, 경로는 /internal/** + 공유키 헤더 + 게이트웨이를 다시 타지 않는다 (내부 호출이 외부 인증 스택을 또 탈 이유 없음) + 예: content가 auth에 "이 사용자에게 알림 하나 만들어 줘" + +비동기 (지금 답이 필요 없을 때): + Kafka 토픽 audit.events — 각 서비스가 감사 이벤트를 발행, + log 서비스가 소비해 적재. 발행자는 log가 죽어 있어도 멈추지 않는다. + +실시간 팬아웃 (다수에게 즉시 뿌릴 때): + Valkey Pub/Sub 채널 market.ticks — 시세 틱을 발행하면 + 구독 중인 인스턴스들이 SSE로 브라우저에 밀어 준다. + +금기: 같은 데이터를 두 채널로 흘리지 않는다. + 시세를 Kafka에도 넣고 Pub/Sub에도 넣으면? 두 경로가 미묘하게 어긋나는 순간 + "어느 쪽이 진실인가"라는 답 없는 질문이 시작된다. 채널은 데이터당 하나.`; + +const CODE_SAGA = `사가(Saga) — 로컬 트랜잭션의 연쇄 + 보상 트랜잭션 + +주문 흐름을 3개 서비스가 나눠 가졌다고 하자: + + 단계 1 [주문] 주문 레코드 생성 보상: 주문 취소로 마킹 + 단계 2 [재고] 재고 차감 보상: 재고 복원 + 단계 3 [결제] 카드 승인 (외부 PG) 보상: 사실상 불가(승인 취소는 별개 절차) + 단계 4 [알림] 확정 이메일 발송 보상: 불가능 (보낸 메일은 못 거둠) + +정상 흐름: 1 → 2 → 3 → 4 (각 단계는 자기 DB에서 로컬 커밋) +3에서 실패: 2의 보상(재고 복원) → 1의 보상(주문 취소) 을 역순 실행 + +설계 원칙: 보상이 불가능하거나 비싼 단계(외부 결제, 이메일)는 + 반드시 마지막에 배치한다. 뒤에 실패할 단계가 없으면 + 그 단계는 보상할 일도 없다. + +왜 2PC(2단계 커밋)가 아닌가: + 2PC는 코디네이터가 전원 합의할 때까지 참여자 전부가 락을 잡고 대기한다. + 참여자 하나가 느려지면 전체가 멈추고, 코디네이터가 죽으면 락이 고아가 된다. + 가용성을 위해 쪼갠 시스템에 전역 락을 다시 끼얹는 셈 — 실무에서 기피되는 이유.`; + +const CODE_SCHEMA = `-- 단계적 접근: DB를 물리적으로 나누기 전에, 스키마로 먼저 격리한다 +-- (traderbox 방식: 단일 PostgreSQL, 스키마-퍼-서비스) + +CREATE SCHEMA auth; -- auth 서비스만 접근 +CREATE SCHEMA market; -- market 서비스만 접근 +CREATE SCHEMA content; -- content 서비스만 접근 + +-- 각 서비스의 DB 계정에는 자기 스키마 권한만 부여 +GRANT USAGE ON SCHEMA auth TO svc_auth; +REVOKE ALL ON SCHEMA auth FROM svc_market, svc_content; + +-- 이러면 인프라는 DB 한 대(운영 부담 최소)인데, +-- 코드 관점에서는 "남의 테이블에 JOIN 못 함"이 강제된다. +-- 나중에 트래픽이 커지면 스키마를 통째로 별도 DB 인스턴스로 +-- 이사시키면 된다 — 코드는 이미 경계를 지키고 있으니까. + +-- 서비스 간 JOIN이 필요해지면? (예: "주문 목록에 상품 이름 붙이기") +-- 1) API 합성: 주문 API 응답을 받아 상품 API를 이어 부른다 (N+1 주의) +-- 2) 데이터 복제: 주문 스키마에 상품 이름을 이벤트로 받아 복사해 둔다 +-- 3) 읽기 전용 뷰: 조회 전용 서비스가 이벤트를 구독해 조합 테이블을 만든다`; + +const CODE_STRANGLER = `스트랭글러 무화과(Strangler Fig) 패턴 — 가장자리부터 갈아끼우기 + +숙주 나무를 감고 자라다 결국 대체하는 무화과나무처럼: + + 1단계 [프록시/게이트웨이] → 모든 요청이 모놀리스로 + 2단계 [프록시] → /api/notifications 만 새 서비스로, 나머지는 모놀리스로 + 3단계 [프록시] → 떼어낸 경로가 하나씩 늘어남 + N단계 모놀리스에 남은 것이 없으면 조용히 은퇴 + +핵심: 빅뱅 재작성(한 번에 다시 쓰기)은 "기존 기능 전부를 멈춘 채 재현"하는 + 게임이라 거의 항상 진다. 스트랭글러는 매 단계가 배포 가능하고 되돌릴 수 있다. + +그 전에: 모듈러 모놀리스로 경계를 코드 안에서 먼저 증명하라. + 한 배포 단위 안에서 패키지·모듈 경계를 긋고 (order / inventory / payment), + 모듈 간에는 공개 인터페이스로만 호출하게 강제한다. + 이 경계가 6개월간 지켜졌다면 → 네트워크로 승격해도 살아남을 경계다. + 이 경계가 계속 뚫린다면 → 쪼갰어도 분산 모놀리스가 됐을 것이다. 미리 안 것에 감사. + +쪼개기 결정 체크리스트 (하나도 해당 없으면, 아직 쪼갤 때가 아니다): + [ ] 배포 충돌: 서로 다른 팀/기능의 배포가 주기적으로 서로를 막는가? + [ ] 확장 비대칭: 특정 부분만 수십 배 트래픽인데 전체를 복제 중인가? + [ ] 팀 크기: 한 코드베이스에 사람이 많아 머지·리뷰가 병목인가?`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '모놀리스는 죄가 아니다' }, + { n: 2, label: '분산의 세금' }, + { n: 3, label: '서비스 경계 긋기' }, + { n: 4, label: '서비스 간 통신' }, + { n: 5, label: '사가 패턴' }, + { n: 6, label: '스키마-퍼-서비스' }, + { n: 7, label: '전환 전략' }, +]; + +export default function Lv4MonolithVsMicroservicesPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 특급 과정
+

🧩 모놀리스 vs 마이크로서비스
— 쪼개는 것의 진짜 비용

+

+ "요즘은 다 마이크로서비스로 가던데요?" — 그 문장이 이 코스의 출발점이자, + 버려야 할 첫 문장입니다. 아키텍처는 유행이 아니라 트레이드오프로 + 고르는 것. 모놀리스가 공짜로 주던 것들의 가격표를 읽고, 서비스 경계를 긋는 + 도구를 손에 쥐고, 트랜잭션이 쪼개질 때의 대가를 계산한 뒤 —{' '} + '쪼개지 않을 용기'까지 갖추는 것이 목표입니다. +

+
+ 예상 소요 80분 + 실습: 쇼핑몰 모놀리스 경계 그리기 발표 + 대상: 대규모 트래픽·분산 설계를 보는 실무 개발자 +
+
+ + {/* 섹션 바로가기 — 학습 포인트: 정적 앵커(#sec-n)만으로 만드는 목차 */} +
+ {SECTIONS.map((s) => ( + + {s.n}. {s.label} + + ))} +
+ +
+

+ 모놀리스는 애플리케이션 전체가 하나의 배포 단위인 + 구조예요. jar 하나를 빌드해 서버에 올리면 끝. 이 단순함이 공짜로 주는 것이 + 셋 있습니다. ① 단순한 배포 — 파이프라인 하나, 버전 하나, 롤백도 + 하나. ② 로컬 함수 호출 — 주문 모듈이 재고 모듈을 부르는 건 + 메모리 안의 일이라 나노초 단위이고, 네트워크 실패라는 개념 자체가 없어요.{' '} + ③ 하나의 트랜잭션 — 주문 생성과 재고 차감을{' '} + BEGINCOMMIT 사이에 + 넣으면 DB가 원자성을 보장해 줍니다. 둘 다 되거나, 둘 다 안 되거나. +

+

+ 마이크로서비스는 이 셋을 포기하는 대가로 다른 + 것을 삽니다. 독립 배포 — 시세 서비스만 하루 다섯 번 배포해도 + 인증 서비스는 무관. 팀 자율성 — 서비스마다 오너 팀이 있어 + 머지 충돌 없이 자기 속도로 달림. 부분 확장 — 트래픽이 몰리는 + 서비스만 골라 인스턴스를 늘림. 어느 쪽도 정답이 아니고, 이 거래가 + 남는 장사인지가 유일한 질문이에요. +

+ {CODE_TRADEOFF} +

+ 그리고 이 거래의 손익을 결정하는 가장 큰 변수는 기술이 아니라{' '} + 조직입니다. 1968년 멜빈 콘웨이가 남긴 관찰 —{' '} + 시스템의 구조는 그것을 만드는 조직의 소통 구조를 닮는다 — 이 + 여기서 실전 도구가 돼요. 세 명이 서비스 서른 개를 운영하면 한 사람이 열 개의 + 온콜을 지고, 서른 명이 모놀리스 하나에 매달리면 배포 열차에 다섯 팀이 줄을 + 섭니다. 어느 쪽도 아키텍처 다이어그램에는 안 나오는 실패죠. +

+ {CODE_CONWAY} +
+ 이 코스의 제1원칙 "마이크로서비스로 갈까?"라고 묻지 말고,{' '} + "우리 조직이 독립된 배포 주기를 몇 개나 감당할 수 있나?"라고 + 물으세요. 앞의 질문엔 유행이 답하고, 뒤의 질문엔 현실이 답합니다. 성공한 회사 + 다수가 모놀리스로 시작해 아픈 지점이 증명된 뒤에야 쪼갰다는 + 사실은 우연이 아니에요. +
+
+ +
+

+ 모놀리스에서 재고확인(상품ID)는 그냥 함수 + 호출이에요. 마이크로서비스에서 같은 일은 네트워크 호출이 되고, + 그 순간 세 가지 세금이 자동으로 청구됩니다. 지연 — 나노초가 + 밀리초로, 수만 배 느려져요. 부분 실패 — "주문은 됐는데 재고 + 차감 응답을 못 받았다"라는, 모놀리스엔 존재하지 않던 상태가 생깁니다. 재시도해야 + 할까요? 이미 차감됐다면 두 번 차감되는데요. 버전 관리 — 호출자와 + 피호출자가 따로 배포되니, 응답 필드 하나 바꾸는 것도 이제 '계약 변경'이에요. +

+ {CODE_TAX} +

+ 이 세금을 다 내고도 보상(독립 배포·자율성·부분 확장)을 못 받는 구조가 + 있는데, 그게 바로 분산 모놀리스입니다. 쪼개긴 쪼갰는데 A를 + 배포하려면 B·C도 같이 배포해야 하고, 여러 서비스가 한 테이블을 같이 쓰고, + 기능 하나에 PR 네 개가 동시에 필요한 상태 — 모놀리스의 결합도에 + 마이크로서비스의 운영 비용을 더한, 두 세계의 나쁜 점만 모은 형태예요. +

+ {CODE_DIST_MONOLITH} +
+ 운영 비용은 선형으로 늘지 않는다 서비스가 6개면 파이프라인·대시보드·알림 + 규칙이 6벌이지만, 장애 조사 경로는 조합적으로 늘어요. "느려요" + 한 마디에 의심할 구간이 서비스 수 × 통신 구간만큼 생기니까요. 분산 추적(trace + ID를 요청에 실어 전 구간을 잇는 것)이 사치가 아니라 생존 도구가 되는 이유입니다. +
+
+ +
+

+ 쪼개기로 했다면, 다음 질문은 "어디서 자르나"예요. 여기서 도메인 + 주도 설계(DDD)의 바운디드 컨텍스트(Bounded Context)가 칼이 + 됩니다. 찾는 법은 의외로 언어적이에요 —{' '} + 같은 단어가 다른 뜻이 되는 지점이 경계입니다. 쇼핑몰의 + '상품'이라는 한 단어가 카탈로그 팀에겐 사진과 검색 키워드, 재고 팀에겐 SKU와 + 창고 위치, 주문 팀에겐 가격 스냅샷, 배송 팀에겐 무게와 부피를 뜻한다면 — 그 + 네 덩어리가 각각 하나의 컨텍스트예요. +

+ {CODE_BOUNDED} +

+ 경계를 그었다면 지키는 규칙은 하나, 데이터 소유권 원칙입니다.{' '} + 한 테이블의 주인은 정확히 한 서비스. 남의 데이터가 필요하면 + 주인의 API를 부르거나 이벤트를 구독하지, 절대 그 테이블에 직접{' '} + SELECT하지 않아요. 직접 접근을 한 번 허용하는 + 순간 스키마 변경이 남의 서비스를 깨뜨리게 되고, 경계는 그림에만 남습니다. +

+

+ 이론을 들었으니 실물을 봅시다. 우리가 운영하는 트레이딩 플랫폼 + traderbox는 6개 서비스로 나뉘어 있는데, 그 경계 근거를 읽어 보면 명사(주문·상품)가 + 아니라 트래픽 패턴·배포 주기·데이터 수명주기가 갈라지는 지점에서 + 잘랐다는 걸 알 수 있어요. +

+ {CODE_TRADERBOX} +
+ 경계의 좋은 냄새 vs 나쁜 냄새 좋은 경계는 "이 서비스는 혼자 배포해도 + 아무도 신경 안 쓴다"로 증명돼요. 나쁜 경계의 전형은{' '} + 기술 계층으로 자르기(화면 서비스·비즈니스 서비스·DB 서비스) — + 기능 하나를 고치는데 세 서비스를 전부 건드리게 되죠. 자를 땐 세로(도메인)로, + 가로(계층)로 자르지 마세요. +
+
+ +
+

+ 경계를 그었으면 이제 경계를 건너는 방법을 정해야 해요. 기준은 + 단순합니다. 지금 당장 답이 필요한가? 필요하면{' '} + 동기(REST 등 직접 호출) — 대신 상대가 죽으면 나도 기다리다 + 실패하는 결합을 감수합니다. 답이 필요 없고 "이런 일이 있었다"만 알리면 되면{' '} + 비동기(이벤트) — 발행자는 소비자가 죽어 있어도 멈추지 않지만, + "언젠가 처리된다"는 최종 일관성을 감수하죠. +

+ {CODE_COMM} +

+ traderbox의 규칙을 다시 읽어 보면 세 채널이 역할별로 갈려 있어요.{' '} + 동기는 /internal/** + 공유키 — + 내부 호출이 게이트웨이의 외부 인증 스택을 다시 탈 이유가 없으니 서비스끼리 + 직접 부르되, 공유키 헤더로 "내부에서 온 요청"임을 증명합니다.{' '} + 비동기·감사는 Kafka — 감사 로그 적재가 느려져도 본 서비스는 + 영향받지 않아요. 실시간 팬아웃은 Valkey Pub/Sub — 시세 틱을 + 다수의 구독자에게 즉시 뿌립니다. 그리고 금기 하나:{' '} + 같은 데이터를 두 채널로 흘리지 않는다. 두 경로가 어긋나는 순간 + "어느 쪽이 진실인가"라는 답 없는 질문이 시작되니까요. +

+

+ 마지막 조각이 API 게이트웨이예요. 클라이언트가 서비스 6개의 + 주소를 다 알 필요는 없죠. 게이트웨이가 단일 진입점이 되어{' '} + 라우팅(경로를 보고 뒤의 서비스로 전달), 인증 위임 + (토큰 검사를 어디서 할지 정하기 — traderbox는 각 서비스가 자체 검증하는 방식),{' '} + 횡단 관심사(레이트리밋·CORS·로깅처럼 모든 요청에 공통인 일)를 + 맡습니다. 단, 게이트웨이에 도메인 로직을 넣기 시작하면 그게 새로운 모놀리스가 + 돼요 — 게이트웨이는 얇게가 철칙입니다. +

+
+ 선택 감각 기르기 "회원가입 직후 환영 메일 발송"은 동기일까요 비동기일까요? + 가입 응답이 메일 서버를 기다릴 이유가 없죠 — 비동기가 자연스러워요. + 반대로 "주문 직전 재고 확인"은 지금 답이 없으면 진행 자체가 불가능하니{' '} + 동기. 이렇게 업무 흐름이 답을 기다리는가로 + 가르면 대부분 자명해집니다. +
+
+ +
+

+ 섹션 1에서 모놀리스가 공짜로 주던 것 중 가장 비싼 게{' '} + 하나의 트랜잭션이었어요. 주문 생성과 재고 차감이 서로 다른 + 서비스, 서로 다른 DB로 갈라지는 순간 BEGIN~COMMIT + 으로 묶을 방법이 사라집니다. 교과서엔 2단계 커밋(2PC)이라는 + 답이 있지만 — 코디네이터가 참여자 전원의 합의를 받을 때까지{' '} + 전부가 락을 잡고 대기하는 구조라, 하나만 느려져도 전체가 멈추고 + 코디네이터가 죽으면 락이 고아가 돼요. 가용성을 얻으려고 쪼갠 시스템에 전역 락을 + 다시 끼얹는 셈이라 실무에서 기피됩니다. +

+

+ 실무의 답은 사가(Saga) 패턴이에요. 발상의 전환은 이렇습니다 — + 전체를 한 번에 원자적으로 묶으려 하지 말고, 각 서비스의 로컬 트랜잭션을 + 연쇄시키되, 중간에 실패하면 이미 커밋된 앞 단계들을 '보상 + 트랜잭션'으로 역순으로 되돌린다. 재고를 차감했다면 복원하고, 주문을 + 만들었다면 취소로 마킹하는 식이죠. +

+ {CODE_SAGA} +

+ 여기서 설계 감각이 드러나는 지점이 단계의 순서예요. 보상은 + 공짜가 아니고, 어떤 단계는 보상이 아예 불가능합니다. 발송된 + 이메일은 거둘 수 없고, 외부 PG의 카드 승인 취소는 별개의 복잡한 절차예요. + 그래서 원칙은 — 보상이 불가능하거나 비싼 단계는 사가의 마지막에 + 배치한다. 마지막 단계 뒤에는 실패할 것이 없으니, 보상할 일도 없거든요. +

+
+ 보상은 '삭제'가 아니라 '상쇄'다 보상 트랜잭션은 앞 단계를 없던 일로 + 지우는 게 아니라, 반대 방향의 새 기록을 추가해 상쇄하는 거예요. + 회계 장부에서 잘못된 기입을 지우개로 지우지 않고 반대 분개를 넣는 것과 같죠. + "주문 취소됨"이라는 기록이 남는 것 — 그게 오히려 장애 조사와 감사에서 + 자산이 됩니다. +
+
+ +
+

+ "서비스마다 DB를 따로 둬야 한다"는 말을 자주 듣지만, 처음부터 DB 인스턴스를 + 6대 운영하는 건 부담이 커요. 단계적 접근이 있습니다 —{' '} + 단일 DB 안에서 스키마로 먼저 격리하는 거예요. traderbox가 정확히 + 이 방식이죠(단일 PostgreSQL + 스키마-퍼-서비스). 인프라는 한 대라 운영이 가볍고, + 각 서비스 계정에 자기 스키마 권한만 주면 "남의 테이블에 JOIN 못 함"이 + DB 수준에서 강제됩니다. 나중에 특정 서비스가 커지면 그 스키마만 별도 + 인스턴스로 이사시키면 되고요 — 코드는 이미 경계를 지키고 있으니까요. +

+ {CODE_SCHEMA} +

+ 격리하고 나면 반드시 그리워지는 게 JOIN이에요. "주문 목록에 + 상품 이름을 붙여 보여 줘" — 모놀리스에선 JOIN 한 줄이던 게 이제 서비스 두 개에 + 걸칩니다. 선택지는 셋이에요. ① API 합성 — 주문 목록을 받아 + 상품 API를 이어 부르기. 간단하지만 목록이 100건이면 호출이 100번 튀는 N+1을 + 조심해야 해요. ② 데이터 복제 — 상품 이름 정도는 주문 스키마에 + 복사해 두고, 상품 변경 이벤트를 구독해 갱신하기. 조회는 빠르지만 복제 지연을 + 받아들여야 하죠. ③ 읽기 전용 뷰 — 조회 전용 서비스가 여러 + 서비스의 이벤트를 구독해 화면에 맞는 조합 테이블을 미리 만들어 두기(CQRS로 + 이어지는 발상). +

+

+ 그리고 절대 고르면 안 되는 네 번째 선택지가 공유 DB 안티패턴 + 입니다. "그냥 옆 서비스 테이블에 직접 JOIN하면 되잖아?" — 되긴 돼요. 대신 + 그 순간 무너지는 것들: 스키마 변경의 자유(주인이 컬럼 하나 + 바꾸면 남의 서비스가 깨짐), 독립 배포(스키마 변경에 두 서비스 + 배포를 맞춰야 함), 캡슐화(비즈니스 규칙을 우회해 데이터를 + 읽으니 "이 값이 왜 이래?"의 원인 추적 불가). 쪼갠 이유 전부가 여기서 증발합니다. +

+
+ 참고 — 시세는 왜 Postgres가 아닌가 traderbox에서 공시·아티클·거시 지표는 + Postgres에 있지만 시세는 Valkey(인메모리)에 있어요. 초당 수십 + 번 갱신되고 1초 전 값은 가치가 없는 데이터라, 영속성보다 속도가 우선이기 + 때문이죠. 데이터 소유권 원칙은 "서비스마다 자기 데이터"에서 한 발 더 나아가{' '} + "데이터 성격에 맞는 저장소"까지 갑니다. +
+
+ +
+

+ 마지막 질문 — 이미 굴러가는 모놀리스가 있다면 어떻게 쪼개나요? + 가장 유혹적이고 가장 위험한 답이 빅뱅 재작성("전부 멈추고 + 마이크로서비스로 새로 짓자")이에요. 기존 기능 전부를 재현하는 동안 제품은 + 멈춰 있고, 그 사이 요구사항은 계속 바뀌고, 재현이 끝날 즈음엔 목표 자체가 + 이동해 있죠. 거의 항상 집니다. +

+

+ 실무의 답은 스트랭글러 무화과(Strangler Fig) 패턴이에요. 숙주 + 나무를 감고 자라다 결국 대체해 버리는 무화과나무처럼 — 게이트웨이(프록시)를 + 앞에 세우고, 가장자리의 기능 하나(예: 알림)만 새 서비스로 빼서 + 그 경로만 새 서비스로 라우팅합니다. 잘 되면 다음 조각, 또 다음 조각.{' '} + 매 단계가 배포 가능하고, 문제가 생기면 라우팅만 되돌리면 되죠. +

+ {CODE_STRANGLER} +

+ 그보다 먼저 할 수 있는 게 모듈러 모놀리스입니다. 쪼개기 전에{' '} + 경계를 코드 안에서 먼저 증명하는 거예요. 한 배포 단위 안에서 + 패키지 경계를 긋고(order· + inventory·payment), + 모듈 간에는 공개 인터페이스로만 호출하게 강제합니다. 이 경계가 여섯 달을 + 버티면 네트워크로 승격해도 살아남을 경계고, 계속 뚫린다면 — 쪼갰어도 분산 + 모놀리스가 됐을 거라는 뜻이니, 배포 한 번 없이 그걸 미리 알아낸 겁니다. +

+

+ 그리고 쪼개기 결정 자체는 감이 아니라 체크리스트로 하세요.{' '} + ① 배포 충돌 빈도 — 서로 다른 팀의 배포가 주기적으로 서로를 + 막는가? ② 확장 비대칭 — 특정 부분만 트래픽이 수십 배인데 + 전체를 복제하고 있는가?(traderbox의 market이 정확히 이 근거로 분리된 사례){' '} + ③ 팀 크기 — 한 코드베이스에 사람이 많아 머지와 리뷰가 + 병목인가? 셋 다 아니라면, 아직 쪼갤 때가 아닙니다. 그때 모놀리스를 + 유지하는 건 게으름이 아니라 판단이에요 — 이 코스 제목의 '쪼개지 않을 용기'가 + 바로 이것입니다. +

+
+ 실습 안내 가상의 쇼핑몰 모놀리스(회원·카탈로그·주문·재고·결제·배송·리뷰가 + 한 덩어리)를 받아 바운디드 컨텍스트 지도를 그리고,{' '} + "지금 쪼갤 것 1개 / 쪼개지 않을 것 3개"를 근거와 함께 발표하세요. + 채점 기준은 정답이 아니라 근거의 품질입니다 — "요즘 다들 + 쪼개니까"는 0점, "이 모듈만 트래픽이 30배라 확장 비대칭이 증명됐다"는 만점. + 쪼개지 않을 것 3개의 근거가 쪼갤 것 1개의 근거만큼 단단해야 통과예요. +
+
+ + {/* 마무리 카드 — 다음 강좌 안내 */} +
+
+

🧩 여기까지 왔다면

+

+ 이제 "마이크로서비스 하시나요?"라는 질문에 기술 이름이 아니라{' '} + 트레이드오프의 언어로 답할 수 있게 됐어요. 모놀리스가 공짜로 + 주던 세 가지, 네트워크를 건너는 순간 청구되는 세금, 같은 단어가 갈라지는 + 지점에 긋는 경계, 채널 셋으로 정리한 통신 규칙, 보상으로 되돌리는 사가, + 스키마로 먼저 격리하는 단계적 접근, 그리고 가장자리부터 갈아끼우는 전환까지 — + 쪼갤 근거와 쪼개지 않을 근거를 모두 손에 쥐었습니다. 그런데 서비스를 아무리 + 잘 나눠도, 트래픽이 정말 커지면 결국 데이터베이스 자체가 + 마지막 병목으로 남아요. 읽기를 복제로 늘릴 것인가, 쓰기를 샤딩으로 쪼갤 + 것인가 — 다음{' '} + DB 레플리케이션과 샤딩 — + 데이터베이스를 늘리는 두 가지 축 코스에서 그 두 축을 이어서 + 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv4ObservabilityMetricsTracingPage.jsx b/frontend/src/pages/levels/Lv4ObservabilityMetricsTracingPage.jsx new file mode 100644 index 0000000..38f3b14 --- /dev/null +++ b/frontend/src/pages/levels/Lv4ObservabilityMetricsTracingPage.jsx @@ -0,0 +1,559 @@ +// 이 파일이 하는 일: "관측성 — 보이지 않으면 고칠 수 없다" 코스 — 모니터링과 관측성의 +// 차이에서 출발해, 메트릭 설계·구조화 로깅·분산 트레이싱·OpenTelemetry·대시보드/알람 설계, +// 그리고 실전 미스터리 풀이까지 7개 섹션으로 안내하는 정적 학습 페이지. 프레이밍은 +// "분산 시스템은 눈이 없으면 고장도 못 찾는다" — 특급 과정답게 traderbox의 실제 구조 +// (gateway→auth, Kafka audit.events, SSE 스트림)를 관측 대상 삼아 개념을 실무에 붙인다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_PILLARS = `모니터링 관측성(Observability) +"내가 아는 질문에 답한다" "몰랐던 질문에도 답할 수 있다" + +- CPU가 80% 넘었나? - 왜 '이 유저'의 '이 요청'만 느리지? +- 서버가 죽었나? - 어제 배포 이후 p99가 왜 튀지? +- 미리 만든 그래프를 본다 - 그때그때 새 질문을 데이터에 던진다 + +세 기둥의 역할 분담: + 메트릭 → "무엇이 이상한가" 숫자·추세. 싸고 빠르다. 알람의 재료. + 트레이스 → "어디가 느린가" 한 요청의 여정. 서비스 사이를 잇는다. + 로그 → "정확히 무슨 일이" 그 지점의 상세 사건 기록. 가장 비싸다. + +넓게(메트릭) → 좁혀서(트레이스) → 깊게(로그). 셋은 경쟁자가 아니라 순서다.`; + +const CODE_METRIC_TYPES = `// 메트릭 3형제 — Micrometer(Spring Boot 내장) 기준 +// ① 카운터: 오르기만 하는 수. "지금까지 몇 번?" +Counter.builder("auth.login.total") + .tag("result", "success") // 성공/실패를 레이블로 구분 + .register(registry) + .increment(); + +// ② 게이지: 오르내리는 현재값. "지금 몇 개?" +Gauge.builder("market.sse.connections", connections, Set::size) + .register(registry); // SSE 동시 접속 수 — 스냅샷 + +// ③ 히스토그램: 값의 '분포'. "지연이 어떻게 퍼져 있나?" +Timer.builder("gateway.request.duration") + .publishPercentiles(0.5, 0.95, 0.99) // p50 / p95 / p99 + .register(registry) + .record(elapsed); + +// 용도 감각: 요청 수=카운터, 큐 길이·접속 수=게이지, 지연·크기=히스토그램`; + +const CODE_RED_USE = `RED — '서비스'를 보는 렌즈 (요청을 받는 모든 것에) + Rate 요청률 초당 몇 건이 들어오나 + Errors 에러율 그중 몇 건이 실패하나 + Duration 지연 각 요청이 얼마나 걸리나 (반드시 분포로!) + +USE — '자원'을 보는 렌즈 (CPU·메모리·디스크·커넥션풀에) + Utilization 사용률 자원이 얼마나 바쁜가 + Saturation 포화도 대기줄이 얼마나 쌓였나 (큐 길이·대기 스레드) + Errors 에러 자원 수준의 실패 (디스크 I/O 에러 등) + +실전 조합: 서비스마다 RED 세 개 + 그 서비스가 기대는 자원의 USE. +tbox-auth라면: 로그인 RED 3종 + DB 커넥션풀 USE. 이것만으로 개요판 완성.`; + +const CODE_PERCENTILE = `같은 하루, 같은 API의 응답 시간 10건: + 40ms 42ms 45ms 48ms 50ms 52ms 55ms 60ms 65ms 2400ms + + 평균(mean) = 285ms ← "평균 285ms면 좀 느리네?" 실체 없는 숫자. + p50 (중앙값) = 51ms ← 절반의 사용자는 51ms 이하를 경험 + p90 = 65ms ← 90%는 65ms 이하 + p99 ≈ 2400ms ← 상위 1%가 겪는 지옥. 여기가 진짜 문제. + +평균의 거짓말: 단 한 건의 2.4초가 평균을 5배로 부풀렸다. +반대로 p99가 나쁜데 평균이 멀쩡하면 "일부 사용자만 계속 고통받는" 상태. +트래픽이 클수록 그 1%는 수천 명이다. SLO는 평균이 아니라 퍼센타일로 건다.`; + +const CODE_CARDINALITY = `# 카디널리티 = 한 레이블이 가질 수 있는 '서로 다른 값'의 수 +# 메트릭 저장소는 레이블 조합마다 별도의 시계열을 만든다. + +http_requests_total(method="GET", path="/api/auth/login", status="200") +http_requests_total(method="POST", path="/api/auth/login", status="401") +# method(5종) x path(50종) x status(10종) = 최대 2,500 시계열 → OK + +# 여기에 user_id 레이블을 넣는 순간: +http_requests_total(..., user_id="a3f9...") # 유저 10만 명이면 +# 2,500 x 100,000 = 2억 5천만 시계열 → 메트릭 서버가 먼저 죽는다. + +# 규칙: 레이블엔 '종류가 유한하고 적은 것'만 (메서드·상태코드·서비스명). +# user_id·요청ID·URL 전체처럼 무한히 늘어나는 값은 +# 메트릭이 아니라 로그·트레이스의 몫이다.`; + +const CODE_LOG_JSON = `// grep의 세계: 사람이 읽는 한 줄 문자열 +2026-07-20 14:03:12 ERROR Login failed for user 4821 from 10.0.3.7 after 3 attempts + +// 쿼리의 세계: 기계가 읽는 구조화(JSON) 로그 +JSON 한 줄 = 하나의 사건(event): + "ts": "2026-07-20T14:03:12.481Z", + "level": "ERROR", + "service": "tbox-auth", + "event": "login.failed", + "user_id": 4821, + "ip": "10.0.3.7", + "attempts": 3, + "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736" // ← 트레이스와 잇는 다리! + +// 이제 이런 질문이 '쿼리 한 줄'이 된다: +// service=tbox-auth AND event=login.failed AND attempts>=3 +// → 지난 1시간, IP별로 집계 +// 비결은 화려한 도구가 아니라 '모든 서비스가 같은 필드 이름을 쓰는 것'. +// service·event·user_id·trace_id 같은 공통 스키마를 팀 규칙으로 못박아라.`; + +const CODE_LOG_LEVEL = `레벨 찍는 것 운영 기본값 +ERROR 사람이 조치해야 하는 실패 항상 켬 +WARN 지금은 견뎠지만 신호인 것 (재시도 성공) 항상 켬 +INFO 비즈니스 사건 (로그인, 주문, 배포) 항상 켬 +DEBUG 개발자용 상세 (파라미터, 분기 경로) 끔 — 필요할 때만 +TRACE 극상세 (루프 내부까지) 끔 + +"운영에서 DEBUG를 켜면 생기는 일": + 초당 500 요청 x 요청당 DEBUG 30줄 = 초당 15,000줄 + → 디스크가 차오르고, 수집 파이프라인이 밀리고, + 정작 중요한 ERROR가 소음에 파묻힌다. 로그도 비용이다. +해법: 전체를 켜지 말고 '특정 로거만, 잠깐만' 런타임에 올렸다 내린다.`; + +const CODE_TRACE = `한 요청의 트레이스(trace) — 스팬(span)들의 폭포수(waterfall): + +trace_id: 4bf92f35... 총 340ms +├─ [gateway] route /api/auth/me ────────────── 340ms +│ ├─ [auth] GET /api/auth/me ──────────── 310ms +│ │ ├─ [auth] JWT 검증 ─ 2ms +│ │ ├─ [auth] DB SELECT users ──────── 210ms ← 병목! +│ │ └─ [auth] 응답 직렬화 ─ 3ms +│ └─ [gateway] 응답 전달 ─ 5ms + +읽는 법: +- 트레이스 = 요청 하나의 전체 여정. 스팬 = 그 안의 구간 하나. +- 각 스팬은 부모를 가리켜 트리를 이룬다 (gateway 스팬이 auth 스팬의 부모). +- 폭포수에서 '가장 길고 자식이 없는 스팬'이 병목. 위에선 DB SELECT 210ms. +- 스팬 사이의 '빈 시간'도 단서다 — 큐 대기, 커넥션풀 대기가 거기 숨는다.`; + +const CODE_CONTEXT = `# 컨텍스트 전파 — trace_id가 서비스를 '건너가는' 방법 +# 비밀은 그냥 HTTP 헤더다. W3C 표준 traceparent: + +GET /api/auth/me HTTP/1.1 +Host: tbox-auth:8081 +traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 +# │ └── trace_id (요청 전체에서 동일) └── 부모 span_id + +# gateway가 auth를 부를 때 이 헤더를 '이어 붙여' 보내면, +# auth는 새 트레이스를 만들지 않고 같은 trace_id 아래 자기 스팬을 단다. +# Kafka로 건널 땐? 메시지 헤더에 같은 값을 실어 보낸다 (audit.events도 가능). + +# 전파가 한 군데라도 끊기면 → 트레이스가 두 동강 난 '고아 스팬'이 된다. +# 자체 스레드풀·비동기 경계가 단골 절단 지점이니 계측 라이브러리에 맡기는 게 안전.`; + +const CODE_SAMPLING = `# 샘플링 — 전부 저장할 수 없을 때 무엇을 남길까 +# 초당 1만 요청 x 요청당 스팬 10개 = 초당 10만 스팬. 다 저장하면 저장소가 터진다. + +head-based (머리에서 결정) + 요청이 '시작될 때' 저장 여부를 정한다. 예: 10%만 무작위 저장. + 장점: 구현 단순, 부하 예측 가능 + 단점: 하필 안 뽑힌 그 요청이 장애였다면? 놓친다. + +tail-based (꼬리에서 결정) + 요청이 '끝난 뒤' 결과를 보고 정한다. + 예: 에러난 것 100% + 1초 넘은 것 100% + 정상은 1%만. + 장점: 문제 있는 트레이스는 반드시 남는다 ← 디버깅엔 이게 정답 + 단점: 끝날 때까지 전 스팬을 버퍼에 들고 있어야 해서 수집기가 무겁다 + +실무 감각: 트래픽이 작을 땐 100% 저장부터 시작하고, 아플 때 샘플링을 도입해라.`; + +const CODE_OTEL = `# OpenTelemetry(OTel) = 계측의 '표준 규격' — 특정 벤더의 것이 아니다 +# 코드에는 OTel 규격으로 심고, 보내는 곳(백엔드)은 설정으로 갈아끼운다. +# 앱 --OTLP 프로토콜--> Collector --> Tempo / Jaeger / Datadog / ... (교체 자유) + +# Spring Boot: '자동 계측' — 에이전트를 붙이기만 하면 +# HTTP 서버/클라이언트, JDBC, Kafka 스팬이 공짜로 생긴다 +java -javaagent:opentelemetry-javaagent.jar \\ + -Dotel.service.name=tbox-auth \\ + -Dotel.exporter.otlp.endpoint=http://otel-collector:4318 \\ + -jar service-auth.jar +# (Micrometer Tracing으로 의존성만 넣는 방법도 있다 — Boot가 자동 구성) + +# Next.js: 서버 사이드에 instrumentation.ts 훅으로 등록 +// instrumentation.ts +export async function register() { + const otel = await import('@vercel/otel'); + otel.registerOTel({ serviceName: 'tbox-web' }); +} + +# 자동 계측이 공짜로 주는 것: HTTP·DB·메시징 스팬, 표준 메트릭 +# 직접 심어야 하는 것: 비즈니스 스팬("호가 계산"), 도메인 속성(종목코드 등)`; + +const CODE_COMPOSE = `# 실습 스택 — Prometheus(메트릭) + Grafana(화면) + Tempo(트레이스) +# platform/docker-compose.observability.yml (새로 만든다) +services: + prometheus: + image: prom/prometheus:v3.4.0 + volumes: ["./prometheus.yml:/etc/prometheus/prometheus.yml"] + ports: ["9090:9090"] # 15초마다 각 서비스 /actuator/prometheus를 긁는다 + tempo: + image: grafana/tempo:2.7.0 + ports: ["4318:4318"] # 앱들이 OTLP로 스팬을 쏘는 곳 + grafana: + image: grafana/grafana:12.0.0 + ports: ["3100:3000"] # 데이터소스로 Prometheus + Tempo를 등록 + +# 미션: service-auth의 /api/auth/me 처리에 일부러 200ms 지연을 심고, +# ① Grafana에서 gateway p99가 뛰는 걸 메트릭으로 목격 +# ② Tempo에서 그 시간대 느린 트레이스를 하나 열어 +# ③ 폭포수에서 '어느 스팬이 200ms를 먹었는지' 코드 안 보고 짚어내기 +# 성공 기준: 지연을 심은 위치를 트레이스만 보고 맞히면 통과.`; + +const CODE_ALERT = `증상 기반 알람 (사용자가 아프다) — 페이징(새벽에 깨움) 대상 + - 5분간 에러율 > 1% "지금 사용자가 실패를 보고 있다" + - 5분간 p99 > 2s "지금 사용자가 기다리고 있다" + - SSE 활성 연결 수가 5분 새 80% 급감 "시세 스트림이 끊기고 있다" + +원인 기반 알람 (자원이 아프다) — 티켓/채널 알림이면 충분 + - CPU > 90% 그래서? 사용자 지연이 없다면 새벽엔 안 깨워도 된다 + - 디스크 > 85% 내일 아침에 늘리면 된다 (단, 예측상 오늘 밤 차면 페이징) + - GC 시간 증가 지표로 지켜보다 지연에 번지면 그때 증상 알람이 운다 + +알람 피로(alert fatigue)의 법칙: + 울려도 아무도 안 움직이는 알람이 3개 생기면, 4번째 진짜 알람도 무시된다. + "이 알람이 울리면 새벽 3시에 일어나 '즉시 할 일'이 있는가?" — 없으면 페이징에서 빼라.`; + +const CODE_SCENARIO = `미스터리: "VIP 유저 몇 명만 대시보드가 느리다" — 좁혀가기 3단계 + +① 메트릭 (무엇이): Grafana에서 /api/market p99 = 3.2s, p50 = 45ms + → 평균은 멀쩡한데 꼬리만 나쁘다 = '일부 요청'만 느리다. 전역 장애 아님. +② 트레이스 (어디가): Tempo에서 duration > 2s 트레이스 검색 → 20건 열람 + → 전부 market의 '관심종목 시세 조회' 스팬이 2.9s. DB 스팬이 그 대부분. +③ 로그 (왜): 그 trace_id로 로그 조인 → "watchlist size: 480" (보통 유저는 ~15) + → 결론: 관심종목 수백 개를 등록한 헤비 유저만 N+1 조회 폭발. + +메트릭이 범위를, 트레이스가 위치를, 로그가 이유를 줬다. +trace_id가 세 기둥을 하나로 꿴 실이었다는 것 — 이게 이 코스 전체의 요약이다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '관측성이란' }, + { n: 2, label: '메트릭 설계' }, + { n: 3, label: '구조화 로깅' }, + { n: 4, label: '분산 트레이싱' }, + { n: 5, label: 'OpenTelemetry' }, + { n: 6, label: '대시보드·알람' }, + { n: 7, label: '실전 시나리오' }, +]; + +export default function Lv4ObservabilityMetricsTracingPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 특급 과정
+

관측성
— 보이지 않으면 고칠 수 없다

+

+ 서비스가 6개로 쪼개지는 순간, "코드에 print 찍어 보기"는 죽은 기술이 됩니다. + 요청 하나가 게이트웨이→인증→DB를 건너다니는데, 어느 상자 안에서 느려졌는지 + 바깥에선 보이지 않으니까요. 메트릭·로그·트레이스라는 세 기둥으로 + 분산 시스템의 내부를 들여다보고, "이 요청, 어디서 느려졌나"에 + 데이터로 답하는 기술 — 그게 관측성입니다. +

+
+ 예상 소요 85분 + 실습: 트레이스로 200ms 지연 찾기 + 선수 지식: MSA·Docker Compose·HTTP +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 모니터링은 "CPU가 80%를 넘으면 알려 줘"처럼 미리 아는 + 질문에 답을 준비해 두는 일이에요. 훌륭하지만 한계가 뚜렷합니다 — 장애는 + 늘 예상 못 한 모양으로 오거든요. 관측성(observability)은 + 한 발 더 갑니다. 시스템이 밖으로 내보내는 데이터(메트릭·로그·트레이스)가 충분히 + 풍부해서, 사고 당일에 처음 떠올린 질문에도 즉석에서 답을 캘 수 있는 + 상태를 말해요. 그래프를 "보는" 게 아니라 데이터에 "질문하는" 겁니다. +

+

+ 왜 지금 배울까요? 모놀리스 시절엔 프로세스가 하나라 디버거와{' '} + print로도 버텼어요. 그런데 우리 traderbox처럼 + gateway·auth·market·log·content·macro가 각자의 컨테이너에서 돌면, + 한 요청의 흔적이 여섯 군데에 흩어집니다. 어느 프로세스에 디버거를 붙일지조차 + 모르는 상태 — 여기서 print 디버깅은 무력해집니다. +

+ {CODE_PILLARS} +

+ 세 기둥은 서로 역할이 다릅니다. 메트릭은 값싸고 빨라서 "무엇이 + 이상한가"를 넓게 감시하고, 트레이스는 한 요청의 여정을 따라가 "어디가 느린가"를 + 짚고, 로그는 그 지점에서 "정확히 무슨 일이 있었나"를 증언해요. 하나만으로는 + 부족하고, 넓게→좁게→깊게 순서로 갈아타는 게 기본기입니다. +

+
+ 비유 병원 진료와 같아요. 체온·혈압(메트릭)으로 "이상하다"를 감지하고, + CT 촬영(트레이스)으로 "어느 부위인지" 좁히고, 조직 검사(로그)로 "정확한 원인"을 + 확정합니다. 체온계만으로 수술 부위를 정하는 의사는 없죠 — 지표 하나로 원인을 + 단정하는 개발자도 마찬가지입니다. +
+
+ +
+

+ 메트릭은 이름 + 레이블 + 시간에 따른 숫자예요. 원재료는 딱 세 + 종류입니다. 오르기만 하는 카운터(총 요청 수), 오르내리는 현재값{' '} + 게이지(동시 접속 수), 값의 분포를 담는 히스토그램 + (응답 시간). 어떤 걸 쓸지는 "이 숫자가 시간이 지나면 어떻게 움직이나"를 자문하면 + 답이 나옵니다. +

+ {CODE_METRIC_TYPES} +

+ "그래서 재야 하죠?"엔 검증된 답안이 있어요. 요청을 받는 + 서비스엔 RED(요청률·에러율·지연), CPU·커넥션풀 같은 자원엔{' '} + USE(사용률·포화도·에러). 이 두 렌즈만 끼면 "일단 다 수집하고 + 보자"는 늪을 피할 수 있습니다. +

+ {CODE_RED_USE} +

+ 지연(D)을 볼 땐 함정이 하나 있어요 — 평균은 거짓말을 합니다. + 응답 시간 분포는 한쪽 꼬리가 긴 모양이라, 소수의 아주 느린 요청이 평균을 통째로 + 끌어올리거나, 반대로 다수의 빠른 요청이 소수의 고통을 가려 버려요. 그래서{' '} + 퍼센타일로 봅니다. p50은 "절반의 경험", p99는 "가장 아픈 1%의 + 경험"이에요. +

+ {CODE_PERCENTILE} +

+ 마지막 함정은 카디널리티 폭발. 메트릭 저장소는 레이블 값의 조합마다 + 시계열을 하나씩 만들기 때문에, 레이블에 user_id처럼 + 무한히 늘어나는 값을 넣는 순간 시계열 수가 곱셈으로 폭발합니다. +

+ {CODE_CARDINALITY} +
+ 실수 1순위 "유저별로 지연을 보고 싶어서" 레이블에 user_id를 넣는 것. + 의도는 좋지만 그 질문의 정답 도구는 메트릭이 아니라 트레이스와 로그 + 입니다(섹션 7에서 실제로 그렇게 풉니다). 메트릭 레이블은{' '} + 값의 종류가 유한하고 적은 것만 — 이 한 줄이 메트릭 서버의 수명을 + 정해요. +
+
+ +
+

+ 로그를 "사람이 읽을 한 줄 문장"으로 남기면, 서버 6대 시대엔 6군데에 흩어진 문장을{' '} + grep으로 뒤지는 수밖에 없어요. 발상을 바꿔 로그를{' '} + 기계가 읽을 JSON 사건 데이터로 남기면, 로그 더미가 통째로{' '} + 쿼리 가능한 데이터베이스가 됩니다. +

+ {CODE_LOG_JSON} +

+ 여기서 도구보다 중요한 게 일관된 필드 스키마예요. auth는{' '} + userId, market은 uid, + log는 user라고 제각각 쓰면, 서비스를 가로지르는 쿼리가 + 그 즉시 불가능해집니다. service· + event·user_id· + trace_id 같은 공통 필드를 팀 규칙으로 + 못박는 것 — 그게 구조화 로깅의 절반입니다. 특히{' '} + trace_id는 섹션 4의 트레이스와 로그를 잇는 다리라서 + 빠지면 안 돼요. +

+

+ 두 번째 축은 로그 레벨 전략. 레벨은 "중요한 순서"가 아니라{' '} + "운영에서 상시로 켜 둘 것과 아닌 것"의 경계선이에요. +

+ {CODE_LOG_LEVEL} +

+ 마지막으로 중앙 수집. 컨테이너는 언제든 죽고 다시 뜨니, 로그가 + 컨테이너 안에만 있으면 시체와 함께 사라져요. 각 서비스가 표준 출력으로 뱉으면 + 수집기가 중앙 저장소로 모으는 파이프라인이 필요합니다. 사실 우리 traderbox엔 이미 + 그 원형이 있어요 — 각 서비스의 AuditEventPublisher가 + 감사 사건을 Kafka 토픽 audit.events로 쏘고, log + 서비스의 AuditEventConsumer가 받아 저장하죠. + "사건을 구조화해 → 비동기 파이프로 → 중앙에 모은다"는 뼈대가 정확히 같습니다. +

+
+ 관측 관점에서 다시 읽기 Kafka를 끼운 이유도 관측성 문법으로 읽을 수 있어요. + 로그·감사 수집이 본 요청의 응답 속도를 방해하면 안 되고(비동기), + 수집기가 잠시 죽어도 사건이 유실되면 안 되기(버퍼) 때문입니다. + 관측 데이터는 어디까지나 조연이라, 주연(사용자 요청)의 발목을 잡는 순간 설계가 + 잘못된 거예요. +
+
+ +
+

+ 이 코스의 심장입니다. 트레이스는 요청 하나가 시스템을 통과한{' '} + 전체 여정의 기록이고, 스팬은 그 여정의 구간 + 하나("auth의 DB 조회")예요. 스팬마다 시작 시각·소요 시간·부모 스팬이 붙어서, + 모아 보면 폭포수 모양의 트리가 됩니다. +

+ {CODE_TRACE} +

+ 폭포수를 읽는 눈은 하나예요 — 가장 넓고(오래 걸리고) 자식이 없는 + 스팬을 찾는 것. 자식이 있는 스팬은 시간의 대부분을 자식에게 위임한 것이라, + 진짜 범인은 항상 잎(leaf)에 있습니다. 위 예시라면 340ms 중 210ms를 먹은{' '} + DB SELECT가 범인이죠. 스팬과 스팬 사이의{' '} + 빈 틈도 단서예요 — 커넥션풀 대기, 큐 대기처럼 "아무 코드도 안 + 돌지만 시간이 가는" 구간이 거기 숨습니다. +

+

+ 그런데 서비스가 다른 프로세스, 다른 컨테이너인데 어떻게 스팬들이 한 트레이스로 + 묶일까요? 비밀은 허무할 만큼 단순합니다 — HTTP 헤더로 trace_id를 넘겨주는 + 것, 이걸 컨텍스트 전파(context propagation)라고 불러요. +

+ {CODE_CONTEXT} +

+ 마지막 조각은 샘플링. 트레이스는 요청마다 생기니 트래픽이 크면 + 전부 저장할 수 없어요. 언제 "남길지"를 정하는 두 전략이 있습니다. +

+ {CODE_SAMPLING} +
+ 전파가 끊기는 순간 트레이싱 도입 후 가장 흔한 사고는 "트레이스가 gateway에서 + 끝나 있어요"입니다. 서비스 하나가 traceparent 헤더를 + 안 넘기면 그 뒤는 전부 고아 스팬이 돼요. 직접 만든 스레드풀, + 비동기 콜백, 커스텀 HTTP 클라이언트가 단골 절단 지점 — 그래서 다음 섹션의{' '} + 자동 계측에 맡기는 게 수공예보다 안전합니다. +
+
+ +
+

+ 메트릭·로그·트레이스를 코드에 심는 일을 계측(instrumentation)이라 + 해요. 문제는 이걸 특정 상용 도구의 SDK로 해 버리면, 도구를 바꿀 때{' '} + 서비스 전부의 코드를 다시 짜야 한다는 것 — 전형적인 벤더 락인이죠.{' '} + OpenTelemetry(OTel)는 그래서 나온 업계 표준 규격 + 입니다. 코드에는 OTel API로 심고, 데이터는 OTLP라는 공용 프로토콜로 내보내고, + 받는 쪽(Tempo든 Jaeger든 상용 SaaS든)은 설정 한 줄로 바꿉니다. +

+ {CODE_OTEL} +

+ 실무 감각의 핵심은 자동 계측과 수동 계측의 경계예요.{' '} + 자동 계측은 에이전트가 HTTP 서버/클라이언트·JDBC·Kafka 같은 + 표준 라이브러리를 알아서 감싸 주는 것 — 붙이기만 하면 섹션 4의 폭포수가 공짜로 + 나옵니다. 반면 "이 스팬이 어떤 종목의 호가 계산이었는지" 같은{' '} + 비즈니스 의미는 기계가 알 수 없으니, 중요한 도메인 로직에만 + 수동 스팬과 속성을 골라서 심어요. 전부 수동으로 심겠다는 건 + 수공예 낭비고, 전부 자동에 맡기겠다는 건 "무엇을 하던 요청인지" 모르는 반쪽 + 트레이스입니다. +

+
+ 우리 스택과의 연결 Spring Boot는 Micrometer가 이미 내장돼 + 있어 메트릭은 Actuator 엔드포인트로 거의 공짜고, 트레이싱은 Micrometer Tracing이나 + OTel 자바 에이전트 중 하나로 붙입니다. Next.js는{' '} + instrumentation.ts 훅이 서버 사이드 계측의 표준 + 입구예요. 프론트(web)→gateway→auth까지 같은 trace_id가 흐르게 + 만드는 것이 목표 그림입니다. +
+
+ +
+

+ 데이터를 모았으면 이제 보여 주는 설계입니다. 나쁜 대시보드는 + "모을 수 있는 그래프를 전부 붙인 벽지"예요. 좋은 대시보드는{' '} + 질문 하나에 답하는 한 장입니다. 추천 구성은 서비스마다{' '} + 한 장짜리 개요 화면: 맨 윗줄에 RED 세 개(요청률·에러율·p99), + 그 아래 의존 자원의 USE(커넥션풀·CPU·큐), 마지막 줄에 배포 시각 표시. 새벽에 + 비상 걸린 사람이 30초 안에 "정상인가, 어디부터 볼까"를 판단할 수 + 있으면 합격이에요. +

+

+ 알람은 대시보드보다 훨씬 엄격해야 합니다. 기준은 두 가지 분류에서 나와요 —{' '} + 증상 기반(사용자가 지금 아프다: 에러율·지연)과{' '} + 원인 기반(자원이 아프다: CPU·디스크). 우선순위는 언제나{' '} + 증상이 먼저입니다. CPU가 99%여도 사용자 지연이 없으면 새벽에 깨울 + 일이 아니고, CPU가 30%여도 에러율이 5%면 지금 당장 일어나야 하니까요. +

+ {CODE_ALERT} +
+ 알람 피로는 기술 부채다 "혹시 몰라서" 걸어 둔 알람이 매일 밤 헛울음을 울면, + 팀은 그 채널을 근육처럼 무시하게 됩니다. 그러다 진짜 장애 알람도 같이 묻혀요. + 울렸는데 아무 조치 없이 닫힌 알람은 버그와 똑같이 취급해서 임계값을 + 고치거나 페이징에서 강등하세요. 알람 목록은 코드처럼 리뷰하고 정리하는 대상입니다. +
+
+ 배포 시각을 그래프에 그려라 장애의 가장 흔한 원인은 방금 한 배포입니다. + 대시보드에 배포 이벤트를 세로선으로 겹쳐 두면, "그 선 직후 p99가 꺾였네"를 누구나 + 한눈에 봐요. 섹션 7의 카나리아 판단도 결국 이 그림 하나에서 시작합니다. +
+
+ +
+

+ 도구를 아는 것과 수사 순서가 몸에 밴 건 다릅니다. 실제 티켓 + 하나로 훈련해 봅시다 — "특정 유저 몇 명만 대시보드가 느리대요. 재현은 안 돼요." + print 디버깅이라면 시작조차 못 할 문제죠. +

+ {CODE_SCENARIO} +

+ 장수명 연결도 특급 과정다운 관측 대상이에요. 우리{' '} + MarketStreamService의 SSE 시세 스트림처럼{' '} + 몇 분씩 살아 있는 연결은 "요청률·지연" 같은 단발 요청 지표로는 + 안 보입니다. 대신 활성 연결 수 게이지, 연결 지속시간 + 히스토그램, 종료 사유별 카운터(정상 종료/타임아웃/에러)를 + 봐야 해요. "활성 연결이 주기적으로 뚝 떨어졌다 회복"하는 톱니 모양이 보이면, 중간의 + 프록시 타임아웃이나 버퍼링(우리가 compress: false로 + 막아 둔 바로 그 문제)을 의심하는 식입니다. +

+

+ 마지막은 배포 직후 지표 비교 — 카나리아 판단의 데이터 근거예요. + 새 버전을 일부 트래픽에만 열고, 구버전과 신버전의 같은 지표 + (에러율·p99·자원 사용)를 나란히 놓습니다. "신버전 p99가 구버전보다 20% 이상 나쁘면 + 자동 롤백" 같은 규칙을 걸면, 배포 판정이 사람의 감이 아니라 숫자의 + 일이 되죠. 관측성이 없으면 카나리아는 그냥 "일부 유저로 도박하기"입니다. +

+
+ 실습 미션 아래 스택을 platform/에 얹고, + gateway→auth 경로에 트레이스를 심은 뒤, auth 코드 어딘가에{' '} + 일부러 200ms 지연을 숨겨 두세요(짝꿍이 숨기면 더 좋습니다). + 그리고 코드를 다시 열지 말고 Grafana+Tempo만으로 지연의 위치를 + 짚어내면 통과입니다. "트레이스가 코드 리딩을 대체하는 순간"을 한 번 겪고 나면, + 관측성에 드는 품이 왜 남는 장사인지 몸으로 알게 돼요. +
+ {CODE_COMPOSE} +
+ +
+
+

🔭 여기까지 왔다면

+

+ 이제 분산 시스템이 블랙박스가 아니게 됐어요. 메트릭으로 이상을 + 감지하고(RED·USE·퍼센타일), 트레이스로 위치를 좁히고(스팬·컨텍스트 전파), + 로그로 원인을 확정하는(구조화·trace_id 조인) — 세 기둥을 순서대로 갈아타는 + 수사법을 손에 쥐었습니다. 그런데 새벽 3시, 증상 알람이 정말로 울린 그 순간부터는 + 다른 종류의 기술이 필요해요. 누가 지휘하고, 무엇부터 복구하고, 끝난 뒤 어떻게 + 같은 사고를 막을까요? 다음{' '} + SRE와 장애 대응 — 무너져도 + 빨리 일어나는 팀 코스에서, 관측성이 켜 준 불빛 아래 움직이는 법을 + 이어서 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv4SreIncidentResponsePage.jsx b/frontend/src/pages/levels/Lv4SreIncidentResponsePage.jsx new file mode 100644 index 0000000..aa16807 --- /dev/null +++ b/frontend/src/pages/levels/Lv4SreIncidentResponsePage.jsx @@ -0,0 +1,513 @@ +// 이 파일이 하는 일: "SRE와 장애 대응 — 무너져도 빨리 일어나는 팀" 코스 — 특급(대규모 시스템·전문가) +// 과정의 정적 학습 페이지. SRE의 핵심 사상에서 출발해 SLI·SLO·에러 버짓, 장애 등급과 온콜, +// 인시던트 커맨드, 진단의 기술, 비난 없는 포스트모템, 카오스 훈련, 신뢰성 문화까지 8개 섹션으로 안내한다. +// 프레이밍은 "장애는 없앨 수 없다 — 다만 빨리 일어나는 팀은 만들 수 있다". 우리 플랫폼의 +// 실제 구성요소(Valkey 시세 스트림, 일1회 백업 데몬)를 시나리오 재료로 삼아 실전 감각을 붙인다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_COST_CURVE = `가용성 한 달 허용 다운타임 비용 곡선 +────────────────────────────────────────────────────── +99% (two nines) 약 7시간 18분 기본기만으로 도달 +99.9% (three nines) 약 43분 모니터링·자동 복구 필요 +99.99% (four nines) 약 4분 19초 다중 리전·자동 페일오버 +99.999%(five nines) 약 26초 사람 개입 불가능한 영역 + +핵심: 9가 하나 늘 때마다 비용은 대략 10배로 뜁니다. +"우리 서비스에 몇 개의 9가 정말 필요한가?"가 SRE의 첫 질문이에요. +100%는 목표가 아니라, 돈을 무한히 태우는 방향일 뿐입니다.`; + +const CODE_SLO = `# 시세 스트림 서비스의 SLI → SLO 정의 예시 (문서로 합의하는 것이 핵심) + +SLI (측정 지표 — 사용자가 실제로 느끼는 것만 고른다) + - 가용성: /api/market/stream 연결 성공 수 / 전체 연결 시도 수 + - 지연: tick 이벤트가 발행 후 1초 안에 클라이언트에 도착한 비율 + +SLO (내부 목표 — 우리가 우리에게 하는 약속) + - 30일 이동 창(rolling window) 기준 가용성 99.9% + - 지연 SLI 99% 이상 + +SLA (외부 약속 — 어기면 배상이 따르는 계약, SLO보다 느슨하게) + - 고객 계약서: 월 가용성 99.5% 미만 시 이용료 10% 크레딧 + +에러 버짓 = 100% - SLO + - 99.9% SLO → 한 달(30일)에 43.2분의 '써도 되는 실패' + - 이 43분은 벌점이 아니라 예산: 배포·실험·마이그레이션에 쓴다`; + +const CODE_BUDGET_POLICY = `# 에러 버짓 정책 — 숫자가 개발과 운영의 다툼을 중재한다 + +if (남은 에러 버짓 > 0) then + 신규 기능 배포 계속 진행 + 카나리 배포·실험도 허용 (버짓이 곧 실험의 연료) + +if (에러 버짓 소진) then + 신규 기능 배포 동결 (feature freeze) + 스프린트를 안정화 작업으로 전환: + - 알림 정리, 자동 롤백, 재시도 로직, 용량 확충 + 버짓이 다시 차오르면 동결 해제 + +포인트: 이 정책을 '장애가 난 다음'에 만들면 늦어요. +평시에 개발 리더와 운영 리더가 서명해 둬야, +장애 후 "배포를 멈추자 vs 일정이 급하다" 싸움이 숫자로 끝납니다.`; + +const CODE_SEV = `# 심각도(SEV) 등급 정의 예시 — 기준은 '기술'이 아니라 '영향' + +SEV1 전체 사용자가 핵심 기능을 못 쓴다 / 매출·데이터 유실 진행 중 + 예: 게이트웨이 다운, 결제 불가, DB 유실 진행 + → 즉시 전원 소집, 상태 페이지 공지, 경영진 보고 + +SEV2 상당수 사용자에게 주요 기능 저하 + 예: Valkey 다운으로 시세 스트림 전면 중단 (조회는 됨) + → 온콜 + 담당 서비스 오너 소집, 내부 30분 주기 공유 + +SEV3 일부 기능·일부 사용자에게 영향, 우회로 존재 + 예: 푸시 알림 지연, 특정 종목 차트 렌더 오류 + → 온콜이 단독 대응, 업무 시간 내 처리 + +SEV4 사용자 영향 없음 (내부 지표 이상, 디스크 80% 경보 등) + → 티켓으로 전환, 다음 업무일 처리 + +포인트: 등급을 '장애 중'에 토론하지 마세요. 표를 미리 합의해 두고, +장애가 나면 표에 대조해 3초 안에 등급을 '선언'하는 겁니다.`; + +const CODE_ONCALL = `# 작은 팀의 현실적 온콜 설계 (전담 SRE 0명에서 시작) + +로테이션 + - 주(primary) 1명 + 부(secondary) 1명, 1주일 단위 교대 + - 개발자 전원이 로테이션에 포함 — "내가 짠 코드는 내가 받는다" + +에스컬레이션 경로 (알림이 무시되지 않게 층을 쌓는다) + 1차: 주온콜 호출 → 5분 무응답 시 + 2차: 부온콜 호출 → 10분 무응답 시 + 3차: 팀 리드 호출 (여기까지 오면 프로세스 실패로 보고 회고) + +작은 팀의 타협점 + - 야간엔 SEV1·SEV2만 호출, SEV3 이하는 아침에 + - 알림 기준을 '증상'(SLO 위반)에 걸지, '원인'(CPU 높음)에 걸지 않기 + → 새벽에 깨울 가치가 있는 알림만 남긴다`; + +const CODE_ROLES = `# 인시던트 커맨드 — 역할을 나누는 순간 혼란이 절반으로 준다 + +인시던트 커맨더 (IC) + - 사고의 '지휘자'. 직접 디버깅하지 않는다 (가장 흔한 실수!) + - 우선순위 결정, 역할 배정, "지금 뭘 시도 중인가"를 항상 안다 + +커뮤니케이션 리드 + - 상태 페이지·사내 공지를 30분마다 갱신 + - "다음 업데이트는 30분 뒤"를 항상 명시 — 침묵이 최악의 공지 + +조사 담당 (Ops) + - 실제로 손을 움직이는 사람(들). 시도한 것·결과를 타임라인 채널에 기록 + +한 사람이 겸직해도 됩니다 (3인 팀이면 IC+커뮤니케이션 겸직). +중요한 건 "지금 누가 IC인가"를 채팅에 선언해 두는 것. +지휘자가 없는 장애 대응은 전원이 각자 디버깅하는 각개전투가 됩니다.`; + +const CODE_COMMS = `# 장애 중 커뮤니케이션 — 추측 금지, 주기 약속 + +[내부 채널 #incident-0714 — 좋은 예] +14:02 (IC 선언) SEV2. Valkey 다운, 시세 스트림 전면 중단. IC는 김OO. +14:05 조사: market 서비스 로그에 Valkey 연결 거부 다발. 재현 확인. +14:10 조치: Valkey 컨테이너 재기동 시도 → 실패 (디스크 풀 추정) +14:25 조치: 오래된 AOF 파일 정리 후 재기동 → 성공, 스트림 복구 확인 +14:30 (IC) 모니터링 30분 지속 후 종료 선언 예정 + +[상태 페이지 — 좋은 예] +"시세 실시간 갱신이 지연되고 있습니다. 원인을 파악해 복구 중이며, + 다음 업데이트는 30분 이내에 드리겠습니다." (14:10) + +[나쁜 예] +"해커 공격으로 추정됩니다" ← 확인 안 된 추측은 절대 금지. +틀리면 신뢰를 잃고, 맞아도 확인 전엔 말할 이유가 없습니다.`; + +const CODE_DIAGNOSIS = `# 진단의 첫 질문: "최근에 뭐가 바뀌었나?" +# 장애의 대다수는 '변화' 직후에 온다 — 배포, 설정, 인프라, 트래픽. + +체크 순서 (통계적으로 확률 높은 순) + 1. 배포: 최근 24시간 내 배포가 있었나? → 있으면 롤백부터 검토 + 2. 설정: env·피처 플래그·인프라 설정 변경은? + 3. 의존성: 외부 API·인프라(DB·캐시·큐) 상태는? + 4. 트래픽: 갑작스런 급증·비정상 패턴은? + +이분 탐색적 격리 — 용의자를 절반씩 줄이기 + Q1. 게이트웨이 지표는 정상인가? + → 정상이면 문제는 게이트웨이 뒤쪽. 절반 제거. + Q2. market 서비스 API 응답(REST)은 되는데 SSE만 죽었나? + → 그럼 DB가 아니라 스트림 경로(Valkey pub/sub)가 용의자. + Q3. Valkey에 직접 PING이 되나? + → 안 되면 확정. 이제야 '왜'를 판다. + +포인트: 가설 없이 로그의 바다에 뛰어들지 마세요. +"X라면 Y가 보일 것" 형태의 가설 → 대시보드·로그로 검증 → 절반 소거. +앞 강좌에서 익힌 관측성 도구(메트릭·로그·트레이스)가 여기서 실전 투입됩니다.`; + +const CODE_POSTMORTEM = `# 포스트모템 문서 골격 — 실습에서 이 구조 그대로 작성합니다 + +제목: 2026-07-14 Valkey 다운으로 시세 스트림 전면 중단 (SEV2) + +1. 요약 + - 영향: 28분간 전체 사용자 시세 실시간 갱신 중단. REST 조회는 정상. +2. 타임라인 (판단이 아니라 사실만, 시각과 함께) + - 13:57 Valkey 디스크 사용률 100% 도달 (경보 없었음) + - 14:02 SLO 위반 알림 → 온콜 인지, SEV2 선언 + - 14:25 AOF 정리 후 재기동, 복구 +3. 근본 원인 (사람 이름이 아니라 시스템의 빈틈) + - AOF 무한 증가를 막는 rewrite 설정 부재 + 디스크 경보 부재. + "누가 설정을 빼먹었다"가 아니라 "설정 누락을 잡을 검증이 없었다" +4. 잘된 점 / 아쉬운 점 + - 잘된 점: 이분 탐색으로 15분 만에 Valkey 특정 + - 아쉬운 점: 디스크가 6일에 걸쳐 차오르는 걸 아무도 못 봄 +5. 액션 아이템 (담당자·기한 없으면 쓰지 않은 것과 같다) + - [ ] Valkey AOF auto-rewrite 설정 — 담당: 박OO, 기한: 7/18 + - [ ] 디스크 80% 경보 추가 (전 노드) — 담당: 이OO, 기한: 7/21 + - [ ] Valkey 다운 시 스트림 degraded 모드 — 담당: 김OO, 기한: 8/01`; + +const CODE_GAMEDAY = `# 게임데이 시나리오 예시 — 통제된 환경에서 '미리' 무너져 보기 + +준비 (반드시 스테이징 또는 격리 환경에서) + - 가설: "Valkey가 죽어도 REST 조회는 살고, 스트림은 5분 내 복구된다" + - 범위: 스테이징의 Valkey 컨테이너 1개만. 중단 스위치(원복 명령) 준비. + - 참관: 온콜 경험 없는 주니어를 IC 역할로 지정 (훈련 목적) + +실행 + docker stop tbox-valkey # 장애 주입 + # → 팀은 실제 장애처럼 대응: SEV 선언, 역할 분담, 타임라인 기록 + +관찰 포인트 + - 알림이 몇 분 만에 왔나? (안 왔다면 그게 첫 번째 수확) + - 대시보드만으로 Valkey를 지목할 수 있었나? + - 상태 공유는 주기적으로 이뤄졌나? + +백업·복구 훈련 — 복구해 본 적 없는 백업은 백업이 아니다 + - 우리는 Postgres·Valkey를 일 1회 백업하는 데몬을 돌리고 있죠. + - 분기 1회: 최신 백업으로 스테이징 DB를 실제 복원하고, + 앱을 붙여 로그인·조회가 되는지까지 확인해야 '검증된 백업'입니다. + - 복원 소요 시간을 기록해 두세요 — 그게 곧 우리의 RTO(복구 목표 시간)입니다.`; + +const CODE_TOIL = `# 토일(toil) 측정 — 자동화 우선순위는 감이 아니라 장부로 + +토일의 정의: 수작업 · 반복적 · 자동화 가능 · 서비스가 클수록 늘어나는 운영 작업 + +주간 토일 장부 예시 + 작업 주당 소요 자동화 난이도 우선순위 + ───────────────────────────────────────────────────────── + 배포 후 수동 헬스체크 90분 낮음 1순위 + 디스크 정리 수동 실행 40분 낮음 2순위 + 고객 문의용 로그 수동 조회 60분 중간 3순위 + 인증서 갱신 월 20분 낮음 4순위 + +규칙: 토일이 업무 시간의 50%를 넘으면 기능 개발을 멈추고 자동화한다. + (구글 SRE의 기준. 작은 팀이라면 30%쯤에서 경보를 울리는 게 안전)`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: 'SRE란' }, + { n: 2, label: 'SLI·SLO·에러 버짓' }, + { n: 3, label: '장애 등급·온콜' }, + { n: 4, label: '장애 지휘' }, + { n: 5, label: '진단의 기술' }, + { n: 6, label: '포스트모템' }, + { n: 7, label: '카오스와 훈련' }, + { n: 8, label: '신뢰성 문화' }, +]; + +export default function Lv4SreIncidentResponsePage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 특급 과정
+

🚨 SRE와 장애 대응
— 무너져도 빨리 일어나는 팀

+

+ 장애 없는 시스템은 없습니다. 규모가 커질수록 질문은 "장애를 어떻게 없앨까"에서{' '} + "장애가 났을 때 얼마나 빨리, 얼마나 침착하게 일어날까"로 바뀌어요. + 이 코스에서는 SLO와 에러 버짓으로 신뢰성을 숫자로 관리하고, 사고 + 현장을 역할 분담으로 지휘하고, 비난 없는 포스트모템으로 같은 장애를 + 두 번 겪지 않는 팀의 작동법을 익힙니다. +

+
+ 예상 소요 80분 + 실습: 포스트모템 작성·모의 리뷰 + 선수 지식: 관측성·배포 파이프라인 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ SRE(Site Reliability Engineering)는 구글에서 나온 한 문장으로 + 요약됩니다 — "운영은 소프트웨어 문제다." 서버 재기동, 디스크 정리, + 배포 후 확인 같은 반복 수작업(이걸 토일(toil)이라 불러요)을 사람이 + 손으로 계속 하는 대신, 코드로 없애 버리는 겁니다. 운영자를 뽑는 게 아니라,{' '} + 운영을 자동화하는 엔지니어를 두는 발상의 전환이죠. +

+

+ 두 번째 사상이 더 급진적입니다. 100% 가용성은 목표가 아니에요.{' '} + 사용자의 인터넷, 기기, 통신사부터가 100%가 아니기 때문에, 어느 선을 넘으면 아무리 + 돈을 부어도 사용자는 차이를 느끼지 못합니다. 반면 비용은 9 하나에 + 10배씩 뛰죠. +

+ {CODE_COST_CURVE} +

+ 그럼 SRE는 조직에서 뭘 하는 존재일까요? 개발팀은 빨리 배포하고 싶고, 운영은 아무것도 + 안 바뀌길 바랍니다. 이 긴장을 목소리 큰 쪽이 아니라 숫자로 중재하는 + 것 — 그게 SRE의 역할이에요. 그 숫자가 바로 다음 섹션의 SLO와 에러 버짓입니다. +

+
+ 규모의 감각 "우리는 작은 팀이라 SRE는 사치"라고 생각하기 쉬운데, 반대예요. + 작은 팀일수록 한 사람이 새벽에 깨는 비용이 크고, 토일이 개발 시간을 직접 갉아먹습니다. + 전담 SRE를 뽑으라는 게 아니라, SRE의 사고방식(측정하고, 자동화하고, + 숫자로 중재하기)을 팀 전체가 나눠 갖자는 이야기입니다. +
+
+ +
+

+ "요즘 서비스가 좀 불안하지 않아?" — 이런 대화는 아무것도 바꾸지 못해요. 신뢰성을 + 관리하려면 먼저 측정해야 합니다. 세 단어의 관계를 정확히 잡으세요.{' '} + SLI(Indicator)는 측정 지표, SLO + (Objective)는 그 지표에 거는 내부 목표, SLA + (Agreement)는 어기면 배상이 따르는 외부 계약입니다. SLA는 항상 + SLO보다 느슨하게 잡아요 — 내부 목표를 어겨도 계약 위반까지는 여유가 있도록요. +

+ {CODE_SLO} +

+ 좋은 SLI의 기준은 하나입니다. 사용자가 실제로 느끼는 것인가? CPU + 사용률·메모리는 SLI로 부적합해요 — CPU가 90%여도 응답이 빠르면 사용자는 행복하고, + CPU가 20%여도 응답이 안 오면 장애입니다. 그래서 SLI는 대부분{' '} + 성공률(요청 중 성공 비율)과 지연(N초 안에 응답한 + 비율) 두 계열로 수렴합니다. +

+

+ 그리고 SLO의 진짜 힘은 뒤집었을 때 나옵니다. 99.9%를 약속했다는 건,{' '} + 0.1%는 실패해도 된다고 약속한 것이기도 해요. 한 달이면 약 43분 — + 이걸 에러 버짓(error budget)이라 부릅니다. 벌점이 아니라{' '} + 예산이에요. 새 기능 배포, 위험한 마이그레이션, 카나리 실험이 모두 + 이 예산을 소비하는 활동입니다. +

+ {CODE_BUDGET_POLICY} +
+ 왜 이게 '중재'인가 버짓이 남아 있으면 개발팀은 당당하게 배포합니다 — "우린 + 예산 안에서 움직이고 있다". 버짓이 바닥나면 운영은 당당하게 배포를 멈춥니다 — "예산 + 초과다, 안정화가 먼저다". 어느 쪽도 감정이 아니라 숫자를 근거로 말하게{' '} + 되는 것, 이것이 에러 버짓이 조직에 주는 가장 큰 선물이에요. +
+
+ +
+

+ 장애 대응의 절반은 장애가 나기 전에 끝나 있어야 합니다. 첫 번째가{' '} + 심각도(SEV) 등급표예요. 기준은 기술적 화려함이 아니라{' '} + 영향입니다 — 매출이 새는가, 전체 사용자인가 일부인가, 우회로가 + 있는가. 이 표가 미리 합의돼 있어야, 장애가 터진 순간 등급을 '토론'하지 않고 + '선언'할 수 있어요. +

+ {CODE_SEV} +

+ 두 번째가 온콜(on-call) 체계입니다. 핵심은 "누가 받나"보다{' '} + "안 받으면 어떻게 되나" — 즉 에스컬레이션 경로예요. 알림이 한 사람의 + 무음 모드에 막혀 사라지는 구조라면, 온콜이 없는 것과 같습니다. +

+ {CODE_ONCALL} +
+ 알림 피로가 온콜을 죽인다 온콜 설계에서 가장 흔한 실패는 사람이 아니라{' '} + 알림 그 자체예요. 새벽에 울렸는데 조치할 게 없는 알림이 반복되면, + 사람은 곧 알림을 무시하도록 학습됩니다 — 그리고 진짜 장애가 그 사이로 빠져나가요. + 알림은 SLO 위반(증상)에 걸고, CPU·메모리 같은 원인 지표는 대시보드로 + 내리세요. "이 알림을 받으면 내가 뭘 해야 하지?"에 답이 없는 알림은 삭제 대상입니다. +
+
+ +
+

+ 장애가 터지면 유능한 엔지니어일수록 반사적으로 터미널을 엽니다. 그런데 전원이 + 디버깅에 뛰어드는 순간, 전체 그림을 보는 사람이 0명이 돼요. 같은 걸 + 두 명이 조사하고, 누군가 알리지 않고 서비스를 재기동하고, 대표는 "지금 어떤 상황이냐"고 + 물을 사람을 못 찾죠. 그래서 소방·재난 대응에서 빌려온 것이{' '} + 인시던트 커맨드 체계입니다. +

+ {CODE_ROLES} +

+ 두 번째 원칙: 복구가 먼저, 원인 규명은 나중. 사용자는 "왜 죽었는지"가 + 아니라 "언제 되는지"에만 관심이 있어요. 최근 배포가 의심되면 일단 롤백 + 하세요. 롤백은 패배 선언이 아니라, 상황을 안전한 상태로 되돌리는 가장 빠르고 검증된 + 복구 수단입니다. "내 코드가 원인이 아닐 텐데…"라며 롤백을 미루는 30분이 SEV2를 SEV1로 + 키웁니다. 원인은 서비스가 살아난 뒤 로그가 도망가지 않으니 천천히 파면 돼요. +

+ {CODE_COMMS} +
+ 침묵이 최악의 공지 장애 중 사용자와 동료가 가장 못 견디는 건 나쁜 소식이 아니라{' '} + 소식이 없는 것이에요. "원인 파악 중이며 다음 업데이트는 30분 뒤"라는 + 한 줄이, 어설픈 추측 열 줄보다 신뢰를 지킵니다. 그리고 확인 안 된 추측은 내부 채널에서도 + "추정"이라고 명시하세요 — 추측이 사실처럼 번지면 조사 방향 전체가 오염됩니다. +
+
+ +
+

+ 진단의 첫 질문은 정해져 있습니다. "최근에 뭐가 바뀌었나?" 멀쩡히 + 돌던 시스템이 스스로 무너지는 경우는 드물어요. 장애의 대다수는 변화 — + 배포, 설정 변경, 인프라 작업, 트래픽 급증 — 직후에 옵니다. 그래서 성숙한 팀은 배포·설정 + 변경 이력을 대시보드에 겹쳐 그려 두죠. 지표가 꺾인 시각과 배포 시각이 겹치면, 조사의 + 80%가 끝난 겁니다. +

+ {CODE_DIAGNOSIS} +

+ 변화가 안 보일 때 쓰는 무기가 이분 탐색적 격리입니다. 코드에서 + 버그를 반씩 좁혀 찾듯, 시스템에서도 계층별·서비스별로 용의자를 절반씩 소거해요. + "게이트웨이는 정상인가 → 뒤쪽 절반만 남는다. REST는 되는데 SSE만 죽었나 → 스트림 + 경로만 남는다." 질문 몇 개면 수십 개 컴포넌트가 한두 개로 줄어듭니다. +

+

+ 이 과정 전체를 받치는 것이 앞 강좌에서 익힌 관측성 도구예요. 메트릭으로 + "어디가 아픈지" 넓게 보고, 트레이스로 "요청이 어느 구간에서 죽는지" 따라가고, 로그로 + "그 순간 무슨 일이 있었는지" 확정합니다. 도구를 배우는 것과 장애의 압박 속에서 꺼내 + 쓰는 건 전혀 다른 문제라서 — 섹션 7의 훈련이 필요한 거고요. +

+
+ 확증 편향 주의 "아 이거 지난번 그 문제네"라는 직감은 강력하지만 위험합니다. + 직감이 가설을 주는 건 좋아요 — 단, 검증 없이 조치하지 마세요. 지난번과 + 증상이 같아도 원인이 다르면, 지난번 처방이 상황을 악화시킬 수 있습니다. 가설 → 예측 + ("X라면 Y가 보일 것") → 확인 → 조치. 이 리듬을 압박 속에서도 지키는 게 시니어의 + 디버깅입니다. +
+
+ +
+

+ 장애가 끝나면 진짜 중요한 일이 시작됩니다 — 포스트모템(postmortem), + 장애 회고 문서예요. 여기서 절대 원칙이 비난 없음(blameless)입니다. + 이유는 도덕이 아니라 공학이에요. 사람을 벌하면 다음부터 사람들은 실수를{' '} + 숨깁니다. 숨겨진 실수는 학습되지 않고, 같은 장애가 반복돼요. 그래서 + 질문을 바꿉니다. "누가 그랬나?"가 아니라 "무엇이 그 행동을 그 시점에 + 합리적으로 보이게 했나?" — 잘못된 설정을 넣은 사람이 아니라, 잘못된 설정이 + 검증 없이 운영까지 흘러갈 수 있었던 시스템의 빈틈을 파는 겁니다. +

+ {CODE_POSTMORTEM} +

+ 문서의 심장은 액션 아이템입니다. 그리고 철칙 하나 —{' '} + 담당자와 기한이 없는 액션 아이템은 쓰지 않은 것과 같아요. "모니터링을 + 강화한다" 같은 문장은 아무도 움직이지 않습니다. "디스크 80% 경보 추가 — 담당 이OO, + 기한 7/21"처럼 티켓으로 만들어 일반 업무와 같은 트래킹에 태우세요. 포스트모템 리뷰 + 때 지난 장애의 액션 아이템 완료 여부를 먼저 확인하는 팀이, 실제로 장애가 줄어드는 + 팀입니다. +

+
+ 같은 장애가 두 번 나면 그건 사람 잘못이 아니라 시스템 잘못 + 입니다. 첫 번째 장애 때 액션 아이템이 안 나왔거나, 나왔는데 실행이 안 됐거나, 실행됐는데 + 불충분했다는 뜻이니까요. 재발은 개인의 부주의가 아니라 포스트모템 프로세스의 + 실패로 회고해야, 세 번째가 없습니다. +
+
+ +
+

+ 소방관은 불이 나기 전에 훈련합니다. 그런데 많은 개발팀의 첫 장애 대응 경험은{' '} + 실제 장애예요 — 가장 비싼 수업료를 내고 배우는 셈이죠. 대안이{' '} + 게임데이(game day)입니다. 가상 장애 시나리오를 통제된 환경에 주입하고, + 팀이 실제 장애처럼 대응해 보는 훈련이에요. 목적은 시스템 테스트가 아니라{' '} + 사람과 프로세스 테스트입니다 — 알림은 울리는가, 역할 분담은 되는가, + 런북은 쓸 만한가. +

+ {CODE_GAMEDAY} +

+ 이걸 한 단계 밀어붙인 것이 카오스 엔지니어링입니다. 넷플릭스가 운영 + 환경에서 서버를 무작위로 죽이는 도구(Chaos Monkey)로 유명해졌죠. 다만 원칙을 놓치면 + 그냥 방화입니다. ① 작게 시작하고(컨테이너 1개부터),{' '} + ② 통제된 범위에서(중단 스위치·폭발 반경 제한 필수),{' '} + ③ 가설을 갖고("Valkey가 죽어도 REST는 산다") 실험해야 해요. 가설이 + 깨지면 그게 수확입니다 — 진짜 장애가 알려주기 전에 약점을 찾은 거니까요. +

+
+ 복구해 본 적 없는 백업은 백업이 아니다 백업 파일이 매일 쌓이는 것과, 그걸로 + 실제 복구가 되는 건 완전히 다른 문제예요. 백업 스크립트의 버그, 빠진 스키마, 복원 + 절차의 문서 누락 — 전부 복원을 시도해 봐야만 드러납니다. 우리도 일 + 1회 백업 데몬을 돌리고 있으니, 분기에 한 번은 스테이징에 실제 복원하고 앱을 붙여 + 동작까지 확인하세요. 그때 잰 복원 소요 시간이 곧 우리의 실제 복구 능력입니다. +
+
+ +
+

+ 마지막 조각은 도구가 아니라 문화입니다. 냉정한 진실 하나 —{' '} + 장애 공유가 승진에 불리하면, 아무도 공유하지 않습니다. 장애를 잘 + 수습하고 좋은 포스트모템을 쓴 사람이 조용히 넘어간 사람보다 나쁜 평가를 받는 조직에서, + 비난 없는 포스트모템은 문서 양식일 뿐이에요. 반대로 훌륭한 포스트모템이 팀 전체에 + 공유되고 칭찬받는 조직에서는, 실패가 조직의 자산으로 바뀝니다. 제도가 행동을 만들어요. +

+

+ 두 번째는 토일의 가시화입니다. "요즘 운영 잡무가 많다"는 느낌으로는 + 자동화 투자를 설득할 수 없어요. 주 단위로 토일을 장부에 적으면, 어떤 자동화가 몇 시간을 + 돌려주는지 투자수익으로 말할 수 있게 됩니다. +

+ {CODE_TOIL} +

+ 세 번째는 주니어가 온콜에서 성장하는 구조예요. 온콜을 "시니어가 + 견디는 벌"로 두면 병목과 번아웃이 오고, 주니어를 준비 없이 던지면 사고가 납니다. 계단을 + 놓으세요. ① 섀도잉 — 시니어의 온콜에 동행하며 관찰만 한다 →{' '} + ② 부온콜 — 알림을 같이 받되 최종 판단은 시니어가 → ③ + 주온콜 — 든든한 에스컬레이션 경로를 등에 업고 홀로 선다. 장애 대응 능력은 + 강의가 아니라 이 계단에서 길러집니다. +

+
+ 실습 안내 — 이 코스의 마무리 과거에 겪은 실제 장애 하나(없다면 가상 시나리오 + "Valkey 다운으로 시세 스트림 전면 중단")를 골라, 섹션 6의 골격대로{' '} + 포스트모템 문서를 완성하세요. 타임라인 복원 → 영향 산정 → 근본 원인 + (사람이 아니라 시스템의 빈틈으로) → 담당자·기한 있는 액션 아이템까지. 다 쓰면 동료 + 한 명과 모의 리뷰를 진행합니다 — 리뷰어의 역할은 "비난하는 문장 + 찾아내기"와 "담당자·기한 없는 액션 아이템 반려하기" 두 가지예요. +
+
+ +
+
+

🚨 여기까지 왔다면

+

+ 이제 신뢰성을 "느낌"이 아니라 SLO와 에러 버짓이라는 숫자로 다루고, + 장애 현장을 역할 분담으로 지휘하고, 비난 없는 포스트모템으로 실패를 자산으로 바꾸는 + 팀의 작동법을 손에 쥐었습니다. 무너지지 않는 시스템이 아니라{' '} + 빨리 일어나는 팀 — 그게 대규모 시스템을 오래 운영하는 유일한 + 방법이에요. 다음은 지금까지 배운 확장·신뢰성·관측성 감각을 총동원하는 종합 훈련입니다.{' '} + 시스템 설계 훈련 1 — + URL 단축기를 처음부터 끝까지 코스에서, 작아 보이는 서비스 하나를 + 대규모 트래픽 기준으로 처음부터 끝까지 설계해 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv4SystemDesignChatPage.jsx b/frontend/src/pages/levels/Lv4SystemDesignChatPage.jsx new file mode 100644 index 0000000..5d8f0a7 --- /dev/null +++ b/frontend/src/pages/levels/Lv4SystemDesignChatPage.jsx @@ -0,0 +1,530 @@ +// 이 파일이 하는 일: "시스템 설계 훈련 2 — 실시간 채팅 시스템" 코스 — URL 단축기(훈련 1)와 +// 결정적으로 다른 '상태 있는 장수명 연결'의 세계를 8개 섹션으로 안내하는 정적 학습 페이지. +// 요구사항 자르기 → 연결 기술(WebSocket) → 서버 상태와 Pub/Sub fan-out → 저장·순서 → +// 전달 보장·오프라인 동기화 → 읽음·presence → 그룹 fan-out 전략 → 전체 조립·장애 방어까지. +// 프레이밍은 "우리가 이미 가진 것과 잇기" — traderbox의 SSE 시세 스트림·MarketBus(Valkey +// Pub/Sub)·라이브 채팅 레이트리밋 경험을 다리 삼아, 카카오톡급 채팅의 설계 감각을 기른다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_SCOPE = `[면접식 요구사항 자르기 — "채팅 만들어 보세요"에 바로 그리지 말 것] + +기능 요구 (오늘 범위 O) 오늘 범위 밖 (X — 말하고 자른다) +───────────────────────────────────────────────────────────── +1:1 채팅 + 소규모 그룹(최대 300명) 통화·화상 (별도 미디어 서버의 세계) +텍스트 메시지 + 전달/읽음 표시 E2E 암호화 (키 관리가 별도 주제) +온라인 상태(presence) 표시 10만 명 방송 채널 (섹션 7에서 경계만) +오프라인이었다가 접속 시 밀린 메시지 메시지 검색 (검색 인덱스는 훈련 3 이후) + +비기능 요구 — 숫자로 박아 두기 +───────────────────────────────────────────────────────────── +전달 지연: 같은 방 상대에게 수백 ms 이내 (체감 '즉시') +유실: 서버가 ACK한 메시지는 절대 유실 불가 +규모: 동시 접속 수백만 연결, 일 메시지 수십억 건 +순서: 같은 채널 안에서는 모두가 같은 순서로 본다`; + +const CODE_TRANSPORT = `[클라이언트 ↔ 서버 실시간 통신 3형제] + +① 숏/롱 폴링 — 클라가 반복해서 "새 거 있어요?" + 숏폴링: 1초마다 HTTP 요청. 수백만 명이면 빈손 요청이 서버를 때린다. + 롱폴링: 서버가 새 메시지 올 때까지 응답을 붙잡음. 낫지만 + 메시지마다 연결을 다시 맺는 비용 + 양방향은 여전히 어색. + +② SSE (Server-Sent Events) — HTTP 위의 단방향 방송 + 서버 → 클라 한 방향 스트림. 자동 재접속 내장, 프록시 친화적. + traderbox 시세가 정확히 이 케이스: 서버가 틱을 '뿌리기만' 하면 됨. + 클라 → 서버는 별도 HTTP POST로 — 방송엔 충분, 대화엔 반쪽. + +③ WebSocket — 하나의 TCP 연결 위 양방향 프레임 + 최초 HTTP 핸드셰이크(Upgrade) 후 프로토콜 전환. + 클라·서버 어느 쪽이든 즉시 밀어넣기(push) 가능. + 채팅 = "서로가 서로에게 수시로 말 걸기" → WebSocket이 정답. + +결론: 단방향 방송이면 SSE로 충분하고(시세·알림), 양방향 대화면 WebSocket. +기술은 요구사항이 고른다 — 유행이 고르지 않는다.`; + +const CODE_REGISTRY = `// 문제: 유저 A는 chat-server-1에, 유저 B는 chat-server-7에 붙어 있다. +// A가 보낸 메시지를 B에게 주려면, 누군가 "B가 어디 있는지" 알아야 한다. + +// 방법 1 — 세션 레지스트리: 접속할 때 위치를 공유 저장소에 기록 +// (Valkey 해시: 유저 → 서버 매핑, TTL로 죽은 항목 자동 청소) +HSET session:user:B server chat-server-7 connectedAt 1721455200 +EXPIRE session:user:B 60 // 하트비트마다 갱신 — 안 오면 소멸 + +// 방법 2 — Pub/Sub 백본: 위치를 아예 안 찾고 '방송'으로 해결 +// 각 채팅 서버는 자기에게 붙은 유저들의 채널을 구독해 둔다. +SUBSCRIBE channel:room:42 // chat-server-1도, 7도 구독 중 +PUBLISH channel:room:42 '메시지 페이로드' +// → 구독 중인 모든 서버가 받아서, 자기에게 붙은 참여자에게만 소켓으로 전달 + +// traderbox MarketBus가 정확히 이 구조다: +// 수집기가 Valkey 채널 market.ticks에 PUBLISH → 각 서버의 +// MarketStreamService가 구독 → 자기에게 붙은 SSE 클라이언트에 fan-out. +// '시세 틱'을 '채팅 메시지'로 바꾸면 그대로 채팅 백본이 된다.`; + +const CODE_MSGID = `[메시지 ID 설계 — 순서와 페이지네이션을 한 번에] + +요구: 같은 채널 안에서 모두가 같은 순서로 본다 + "이 메시지 이후 것 주세요" 조회 + +나쁜 답: 클라이언트가 찍은 시각 → 기기마다 시계가 다르다(클럭 스큐) +아쉬운 답: 전역 auto-increment 1개 → 전 세계 메시지가 한 카운터에 줄 섬 (병목) + +좋은 답: '채널 안에서만' 단조 증가하는 ID + msgId = (채널별 시퀀스) // 채널 42의 1, 2, 3... — 채널끼리는 독립 + 또는 Snowflake 변형: [타임스탬프 41bit][서버 10bit][시퀀스 12bit] + → 시간순 정렬 가능 + 발급이 분산돼 병목 없음 + +이 ID 하나로 세 문제가 풀린다: + · 순서: 채널 내 ID 오름차순 = 모두가 보는 순서 + · 페이지네이션: WHERE channel=42 AND id < 커서 ORDER BY id DESC LIMIT 50 + · 동기화 커서: "id 1037까지 받았어요" → 그 뒤만 다시 받기 (섹션 5) + +전역 순서(모든 채널을 통틀어 한 줄)는 아무도 요구하지 않았다. +보장 범위를 채널로 좁히는 순간, 분산 시스템의 난제가 평범한 문제가 된다.`; + +const CODE_ACK = `[전달 보장 — 송신 확인(ACK) 3단계] + +A(보낸 사람) 서버 B(받는 사람) +──────────────────────────────────────────────────────────────── +send(msg, tempId) ────────▶ + ① 수신 + 저장소에 기록 (여기부터 유실 불가 구간) +◀──────── ack:saved(tempId → msgId) // 클라: 시계 1개 ✓ + ② B의 연결 찾아 push ────────▶ 수신 + ◀──────── ack:delivered(msgId) +◀──────── ack:delivered // 시계 2개 ✓✓ + ③ B가 읽음 커서 갱신 (섹션 6) +◀──────── read-cursor 갱신 이벤트 // 읽음 ✓✓ (파란색) + +핵심 규칙 +· ①이 오기 전엔 클라가 tempId로 재전송해도 된다 — 서버가 걸러 줄 테니. +· 오프라인 B: ②가 불가능 → 저장소에 남겨 두고, 푸시 알림으로 깨운다. +· B가 다시 접속하면: "내 커서는 1037" → 서버가 1038부터 몰아서 전달 (inbox 동기화) +· 재전송·재동기화가 겹치면 같은 메시지가 두 번 올 수 있다 + → 클라이언트는 msgId 기준으로 dedupe (멱등 수신). 화면에 두 번 그리지 않는다. + +전달 보장의 진짜 모양 = "정확히 한 번 전송"이 아니라 +"최소 한 번 전송 + 수신 측 멱등"이다. 네트워크 위에서 전자는 존재하지 않는다.`; + +const CODE_READ = `[읽음 표시 — 플래그가 아니라 커서] + +나쁜 모델: 메시지 × 참여자마다 read 플래그 + 300명 방에 메시지 1개 → 읽음 행 300개, 메시지 1만 개 → 행 300만 개 + 쓰기 폭발 + "안 읽은 수" 세려면 매번 집계 + +좋은 모델: 참여자마다 '어디까지 읽음' 포인터 1개 + read_cursor(user=B, channel=42) = 1037 + · 읽음 처리: 포인터 1개 UPDATE (몇 개를 읽었든 쓰기 1번) + · 안 읽은 수: 채널 최신 ID − 내 커서 = 즉시 계산 + · "누가 읽었나": 커서 ≥ 그 메시지 ID인 참여자 목록 + +[presence(온라인 표시) — 정확함보다 싸게] + 접속 중 클라가 30초마다 하트비트 → Valkey에 TTL 60초로 기록 + SET presence:user:B online EX 60 + · 키가 있으면 온라인, 없으면 오프라인 — 끊겨도 최대 60초 안에 자연 소멸 + · 몇 초 늦게 '오프라인'로 바뀌어도 아무도 다치지 않는다 → 최종 일관성이면 충분 + · 잔인한 산수: 300명 방에서 전원의 읽음·presence 변화를 전원에게 실시간 중계하면 + 이벤트 1건 → 300 fan-out, 300명이 움직이면 300×300 = 9만 건/틱. + 그래서 실무는 묶어 보내기(batch)·간격 제한(throttle)·방 화면 볼 때만 구독으로 깎는다.`; + +const CODE_FANOUT = `[그룹 크기에 따라 전략이 뒤집힌다] + +쓰기 fan-out (push): 보낼 때 참여자 전원의 inbox에 미리 복사 + · 읽기가 빛의 속도 (내 inbox만 보면 끝) + · 1:1, 소그룹(수십~수백)에 적합 — 카톡 방 대부분이 여기 + · 10만 명 채널이면? 메시지 1개에 쓰기 10만 번 — 자폭 + +읽기 fan-out (pull): 채널에 1번만 쓰고, 읽는 사람이 그때 가져감 + · 쓰기가 가볍다 (1회) · 읽을 때 채널을 조회하는 비용을 냄 + · 대형 방송 채널에 적합 — 구독자가 많을수록 유리 + +하이브리드(실무 정답): 임계값(예: 참여자 1천 명)으로 갈라 탄다 + 작은 방 → push / 거대 채널 → pull / 유명인 방 → 팔로워는 pull, 친한 친구는 push + +10만 명 '채널'의 정체: 모두가 말하는 채팅이 아니라 +소수가 쓰고 다수가 읽는 구조 = 사실상 피드(feed)다. +→ 다음 강좌(뉴스피드와 타임라인)가 정확히 이 지점에서 시작한다.`; + +const CODE_ARCH = `[완성된 컴포넌트 다이어그램 — 말로 그릴 수 있어야 한다] + +클라이언트(웹·앱) + │ wss:// (WebSocket, 스티키 없이도 OK — 상태는 레지스트리에) + ▼ +L4/L7 로드밸런서 ──▶ 채팅 서버군 (stateless에 가깝게: 연결만 들고, 진실은 밖에) + │ │ + ┌─────────────┤ └──────────────┐ + ▼ ▼ ▼ + Valkey (Pub/Sub 메시지 저장소 푸시 게이트웨이 + 백본 + 세션 (쓰기 多·최근 조회 편중 (오프라인 유저 → + 레지스트리 + → 채널+시간 파티셔닝, FCM/APNs로 깨우기) + presence TTL) 와이드컬럼 or 파티션드 RDB) + +장애 시나리오 3종 세트 — 설계 리뷰에서 반드시 찔러 본다 +① Pub/Sub 다운 → 실시간 전달만 멈춘다. 저장은 계속되므로 유실은 없다. + 복구 후 클라 커서 재동기화로 따라잡기. (저장이 진실, 방송은 가속) +② 저장소 지연 → ack:saved가 늦어진다. 클라는 '보내는 중...' 표시 + 재시도. + 절대 하지 말 것: 저장 전에 saved ACK 보내기 (유실 불가 약속 파기) +③ 채팅 서버 재시작 → 수만 연결이 동시에 재접속 (thundering herd) + → 클라에 지수 백오프 + 지터(무작위 지연)를 심어 파도를 넓게 편다`; + +const CODE_PRACTICE = `# 실습(85분 중 후반 40분): 서버 2대 × Valkey Pub/Sub 미니 채팅 +# 목표: '서로 다른 서버에 붙은 두 브라우저'가 실시간 대화 — 오늘 배운 백본을 손으로. + +# ① Valkey 하나 띄우기 (채팅 백본) +docker run -d --name chat-valkey -p 6379:6379 valkey/valkey:8 + +# ② 같은 서버 코드를 포트만 바꿔 2개 인스턴스로 +# server.js 요지 (ws + ioredis): +# - 브라우저가 ws://localhost:400X 로 접속 +# - 소켓으로 온 메시지 → PUBLISH chat.room1 +# - SUBSCRIBE chat.room1 로 받은 메시지 → 내게 붙은 모든 소켓에 send +# (발행자 자신도 구독으로 돌려받는다 — echo 중복은 msgId로 dedupe: 섹션 5 복습) +PORT=4001 node server.js # 채팅 서버 A +PORT=4002 node server.js # 채팅 서버 B + +# ③ 브라우저 두 창: 하나는 4001, 하나는 4002 접속 페이지 열기 +# → 다른 서버에 붙었는데 대화가 된다 = Pub/Sub fan-out을 눈으로 확인 + +# ④ 방어 연습: docker stop chat-valkey 후 메시지를 보내 보라. +# 무엇이 멈추고, 무엇이 살아 있는가? 복구(docker start) 후엔? +# 이 관찰을 섹션 8의 장애 시나리오 ①과 대조해 보며 마무리.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '요구사항 자르기' }, + { n: 2, label: '연결 기술 선택' }, + { n: 3, label: '서버 상태와 fan-out' }, + { n: 4, label: '저장과 순서 보장' }, + { n: 5, label: '전달 보장·오프라인' }, + { n: 6, label: '읽음·presence' }, + { n: 7, label: '그룹과 대형 채널' }, + { n: 8, label: '조립과 방어 연습' }, +]; + +export default function Lv4SystemDesignChatPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 특급 과정
+

시스템 설계 훈련 2
— 실시간 채팅 시스템

+

+ 훈련 1의 URL 단축기는 요청이 오면 답하고 끊어지는 시스템이었어요. + 채팅은 다릅니다. 수백만 개의 연결이 몇 시간씩 살아 있고, 서버가 + 먼저 말을 걸고, 유실은 용납되지 않죠. 이번 훈련에서는 WebSocket 연결 관리부터 + 메시지 순서, 읽음 표시, 오프라인 동기화까지 — 상태 있는 장수명 연결의 + 세계를 설계합니다. 우리가 운영해 본 SSE 시세 스트림과 MarketBus가 좋은 디딤돌이 + 되어 줄 거예요. +

+
+ 예상 소요 85분 + 실습: 서버 2대 × Valkey Pub/Sub 채팅 + 선수 지식: 훈련 1(URL 단축기)·HTTP/WebSocket 기초 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ "채팅 시스템을 설계해 보세요"라는 문장 안에는 사실 여러 개의 제품이 + 숨어 있어요. 1:1 대화, 300명 그룹, 10만 명 방송 채널, 온라인 표시, 읽음 확인, + 사진·영상 전송, 통화까지 — 전부 "채팅"이라고 불리죠. 훈련 1에서 배운 첫 동작을 + 그대로 씁니다. 그리기 전에 자른다. 무엇을 만들고 무엇을 오늘 범위 + 밖으로 밀지 소리 내어 확정하는 것이 설계의 첫 문장이에요. +

+ {CODE_SCOPE} +

+ 비기능 요구 중 두 개가 이 시스템의 성격을 결정합니다. 첫째,{' '} + 전달 지연 수백 ms — 상대가 보낸 말이 '즉시' 떠야 하니, 클라이언트가 + 주기적으로 물어보는 방식으론 부족하고 서버가 밀어넣는(push) 경로가 + 필요해요. 둘째, 유실 불가 — 은행 이체만큼은 아니어도, "보냈는데 + 상대에게 안 간 메시지"는 채팅 서비스의 신뢰를 끝장냅니다. 빠르되 잃지 않기, 이 + 긴장이 코스 전체를 관통합니다. +

+
+ 훈련 1과의 결정적 차이 URL 단축기는 요청↔응답이 끝나면 서버가 그 사용자를{' '} + 잊어도 됐어요(무상태). 채팅 서버는 "지금 누가 나에게 접속해 + 있는가"라는 상태를 연결이 살아 있는 내내 들고 있어야 합니다. + 상태 있는 장수명 연결 — 이 한 구절이 이번 훈련에서 만나는 거의 모든 어려움의 + 출처예요. 스케일아웃도, 배포도, 장애 복구도 전부 이것 때문에 어려워집니다. +
+
+ +
+

+ "서버가 먼저 말을 건다"를 이루는 기술은 크게 셋이에요. 반복해서 물어보는{' '} + 폴링, 서버→클라이언트 한 방향 스트림인 SSE, + 그리고 하나의 연결 위에서 양쪽이 자유롭게 밀어넣는 WebSocket. + 각각이 나쁜 기술이어서가 아니라, 요구사항이 다른 답을 고르게 합니다. +

+ {CODE_TRANSPORT} +

+ 우리에게는 살아 있는 비교 대상이 있죠. traderbox의 시세 스트림은{' '} + MarketStreamService가 SSE로{' '} + event:tick을 흘려보내는 구조예요. 시세는 서버가 + 클라이언트에게 일방적으로 방송하면 끝이라 SSE가 정확히 맞습니다. + 반면 채팅은 클라이언트도 수시로 말을 얹어야 하는 양방향 대화라서, + 요청마다 새 HTTP를 여는 반쪽짜리 조합 대신 WebSocket이 자연스러운 답이 돼요. +

+

+ 다만 장수명 연결은 인프라의 협조를 요구합니다. 우리가 SSE를 + 운영하며 이미 데인 적 있는 함정들 그대로예요. 중간의 프록시·게이트웨이가{' '} + 유휴 연결을 60초 만에 끊어 버리거나, 응답을 gzip 버퍼링 + 하느라 실시간성이 죽거나(우리 프론트가 compress:false를 + 고집하는 이유), 로드밸런서가 연결 수가 아닌 요청 수로 분배해 특정 서버에 + 연결이 편중되거나. WebSocket도 같은 벽을 전부 만납니다 — 기술을 고르는 일은 + 그 기술이 지나갈 길목 전체를 점검하는 일이에요. +

+
+ 면접 화법 "무조건 WebSocket이요"보다 강한 답: "알림처럼 단방향이면 SSE로 + 충분하고 운영도 단순합니다. 채팅은 양방향 저지연이 요구라 WebSocket을 쓰되, 프록시 + 타임아웃·LB 연결 편중을 함께 설계하겠습니다." — 탈락시킨 선택지의 이유를 + 말하는 사람이 신뢰를 얻습니다. +
+
+ +
+

+ 서버를 수평 확장하는 순간 채팅만의 문제가 태어납니다. 유저 A는{' '} + chat-server-1에, 유저 B는{' '} + chat-server-7에 WebSocket이 붙어 있어요. A의 메시지를 + B에게 주려면 서버끼리 전달해야 하죠. URL 단축기에는 없던, 연결이 + 상태이기 때문에 생기는 문제예요. +

+ {CODE_REGISTRY} +

+ 실무에서는 두 방법을 섞습니다. Pub/Sub 백본으로 채널 메시지를 + 서버들에 방송해 fan-out을 해결하고, 세션 레지스트리는 특정 유저를 + 정조준해야 할 때(1:1 알림, 강제 로그아웃 등) 씁니다. 백본은 Valkey Pub/Sub처럼 + 가볍고 빠른 것으로 시작하고, "구독자가 없던 순간의 메시지도 남아야 한다"면 Kafka처럼 + 로그가 남는 것으로 올라가요 — 우리 스택이 감사 이벤트를 Kafka로, 실시간 틱을 Valkey로 + 나눠 태우는 것과 같은 감각입니다. +

+

+ 그리고 상태 있는 서버의 숙명, 재접속 폭풍. 연결 5만 개를 든 서버를 + 재시작(배포!)하면 5만 클라이언트가 같은 순간 재접속을 시도해 남은 + 서버들을 때립니다. 방어는 클라이언트에 심어요 — 재시도 간격을 지수적으로 늘리는{' '} + 지수 백오프에, 전원이 같은 박자로 몰리지 않게 무작위 지연{' '} + 지터(jitter)를 더하는 것. 서버 쪽에서는 연결을 한 번에 다 끊지 않고 + 천천히 흘려보내는(drain) 배포 절차가 짝을 이룹니다. +

+
+ 이미 본 구조다 MarketBus가 Valkey 채널{' '} + market.ticks에 PUBLISH하면 각 인스턴스가 구독해서 + 자기 SSE 클라이언트에게 뿌리죠. 페이로드를 시세 틱에서 채팅 메시지로 바꾸면 그게 + 바로 채팅 백본입니다. 오늘 실습(섹션 8)에서 이 구조를 최소 형태로 직접 만들어 봐요. +
+
+ +
+

+ 채팅 이력의 접근 패턴은 뚜렷해요. 쓰기가 압도적으로 많고(일 수십억 + 건), 읽기는 최근 메시지에 극단적으로 편중됩니다(방을 열면 마지막 + 50개, 위로 스크롤하면 그 이전 50개). 조인도 복잡한 트랜잭션도 없죠. 그래서 채팅 + 저장소는 채널+시간 기준 파티셔닝이 자연스럽고, 쓰기에 강한 + 와이드컬럼 계열(HBase·Cassandra류)이 대규모에서 자주 선택돼요. 물론 우리 규모라면 + 파티셔닝한 Postgres로도 충분히 출발할 수 있습니다 — 저장소의 이름보다{' '} + 접근 패턴을 먼저 말하는 것이 훈련의 핵심이에요. +

+ {CODE_MSGID} +

+ 여기서 훈련 1에서 익힌 무기를 다시 씁니다. 보장 범위를 좁혀서 난제를 + 평범하게 만들기. "모든 메시지의 전역 순서"는 분산 시스템의 악몽이지만, + 곰곰이 보면 아무도 그걸 원하지 않아요. 필요한 건 같은 방을 보는 사람들이 + 같은 순서를 보는 것뿐 — 즉 채널 단위 순서입니다. 채널 안에서만 단조 증가하는 + ID를 쓰면 순서·페이지네이션·동기화 커서(다음 섹션)까지 한 번에 풀리고, 채널끼리는 + 완전히 독립이라 병목도 없습니다. +

+
+ 클라이언트 시계는 믿지 않는다 메시지 순서를 "보낸 기기의 시각"으로 정하면, + 시계가 3분 느린 폰의 메시지가 과거로 꽂힙니다. 순서의 기준은 언제나{' '} + 서버가 발급한 ID예요. 클라이언트 타임스탬프는 "오후 3:42" 같은{' '} + 표시용으로만 씁니다. 결정하는 값과 보여 주는 값을 분리하는 것 — + 분산 시스템의 오랜 위생 습관입니다. +
+
+ +
+

+ "보낸 메시지는 유실 불가"라는 약속을 기술로 번역하면 단계별 확인(ACK)이 + 됩니다. 카카오톡에서 본 그 표시들이 사실 프로토콜이에요 — 서버가 받아 저장했는지, + 상대 기기까지 갔는지, 상대가 읽었는지를 각각 다른 신호로 확인합니다. +

+ {CODE_ACK} +

+ 오프라인 유저가 이 그림의 시험대예요. B가 지하철에서 끊겨 있는 동안 + 온 메시지들은 저장소에 안전히 남아 있고(①에서 이미 저장됐으니), 푸시 알림이 B를 + 깨웁니다. B가 재접속하면 협상은 한 문장이에요 — "내 커서는 1037이야."{' '} + 서버는 채널별로 1038부터를 몰아서 내려 주죠. 섹션 4의 단조 증가 ID가 여기서 + 동기화 커서로 재활용되는 겁니다. 유저별 밀린 메시지함을 커서로 + 따라잡는 이 방식을 inbox 모델이라고 불러요. +

+

+ 마지막 조각은 중복입니다. "ACK가 안 와서 재전송했는데 사실 첫 번째가 + 도착해 있었다", "실시간 push와 커서 동기화가 겹쳤다" — 분산 환경에서 중복 수신은 + 예외가 아니라 일상이에요. 그래서 수신 클라이언트는{' '} + msgId를 기준으로 이미 가진 메시지를 버립니다(dedupe). + 같은 메시지가 두 번 와도 화면엔 한 번만 — 이것이 멱등 수신이고, + "최소 한 번 전송 + 수신 멱등"의 조합이 네트워크 위에서 구현 가능한 전달 보장의 + 진짜 모양입니다. +

+
+ 훈련 1의 메아리 URL 단축기에서 "생성 요청 재시도 시 같은 단축코드를 돌려주기"로 + 멱등을 배웠다면, 채팅에서는 수신 쪽 멱등을 배웁니다. 위치는 달라도 + 원리는 하나예요 — 같은 일이 두 번 일어나도 결과는 한 번과 같게.{' '} + 분산 시스템 설계의 답안지에 가장 자주 등장하는 단어이니 몸에 붙여 두세요. +
+
+ +
+

+ "읽음 1이 사라지는 것"과 "초록 불"은 사용자에겐 당연한 기능이지만, 설계자에겐{' '} + 본문 메시지보다 트래픽이 많아질 수 있는 골칫거리예요. 사람은 메시지를 + 보내는 것보다 읽고·접속하고·나가는 일을 훨씬 자주 하니까요. 그래서 이 둘은 "얼마나 + 정확해야 하는가"를 먼저 협상하고 들어갑니다. +

+ {CODE_READ} +

+ 읽음은 모델 선택이 승부예요. 메시지마다 읽음 플래그를 남기는 대신, + 참여자마다 "어디까지 읽었다" 포인터 하나만 유지합니다. 섹션 4의 + 단조 증가 ID 덕에 "커서 이하는 전부 읽음"이 성립하니, 몇백 개를 한 번에 읽어도 + 쓰기는 UPDATE 한 번이죠. 같은 ID 하나가 순서 → 페이지네이션 → 동기화 → 읽음까지 + 네 번째 일을 하고 있다는 것, 눈치채셨나요? 좋은 원시(primitive) 하나가 + 기능 여럿을 공짜로 만듭니다. +

+

+ presence는 정확성을 흥정하는 훈련이에요. 초록 불이 몇 초 늦게 + 꺼진다고 메시지가 유실되나요? 아니죠. 그래서 하트비트 + TTL이라는 값싼 구조로 + 충분하고, 이것이 최종 일관성(eventual consistency)으로 충분한 데이터의 + 교과서적 예입니다. 반대로 메시지 본문은 유실 불가 — 같은 시스템 안에서도 데이터마다 + 요구하는 보장 강도가 다르고, 그걸 구분해 각각 알맞은 값을 치르는 것이 특급 설계자의 + 일이에요. +

+
+ fan-out 산수를 입에 붙이기 300명 방에서 읽음·presence 이벤트를 전원에게 + 그대로 중계하면 이벤트당 300배, 전원이 활동하면 틱당 9만 건짜리 부가 기능이 됩니다. + 설계 리뷰에서 "읽음 표시도 되나요?"라는 가벼운 질문에 이 산수를 보여 주며 batch· + throttle·화면 볼 때만 구독 같은 절감책을 붙이는 것 — 그게 "네, 됩니다"보다 훨씬 + 비싼 대답이에요. +
+
+ +
+

+ 그룹 메시지를 참여자들에게 퍼뜨리는 방법은 근본적으로 둘입니다. 보낼 때 전원의 + 수신함에 미리 복사해 두기(쓰기 fan-out·push), 아니면 채널에 한 번만 + 쓰고 읽는 사람이 가져가기(읽기 fan-out·pull). 어느 쪽이 옳은가는 + 방의 크기가 정합니다. +

+ {CODE_FANOUT} +

+ 그리고 10만 명 "채널"을 자세히 보면, 그건 이미 채팅이 아니에요. 소수가 쓰고 다수가 + 읽는 방송 — 구조적으로 피드(feed)에 가깝습니다. 유명인 계정 하나가 + 수백만 팔로워의 타임라인을 흔드는 문제와 정확히 같은 모양이죠. 이 지점은 다음 + 훈련(뉴스피드와 타임라인)의 출발점이니, 오늘은 경계선만 정확히 그어 둡니다 —{' '} + "참여자 수가 어떤 임계를 넘으면 push에서 pull로 전략을 바꾸겠습니다"가 + 오늘 챙길 답이에요. +

+

+ 마지막으로, 열린 채팅에는 반드시 악성 사용자가 옵니다. 우리가 + traderbox 라이브 채팅에서 직접 겪고 막았던 것 그대로예요 — 무인증 쓰기 + 차단(익명에게 쓰기를 열면 스팸 봇의 놀이터가 된다), 유저·IP 단위{' '} + 레이트리밋(초당 메시지 수 제한), 그리고 신고·차단 동선. 설계 + 다이어그램에 이 층이 없으면 "운영해 본 적 없는 설계"라는 인상을 줍니다. 방어층은 + 연결의 입구(인증된 WebSocket 핸드셰이크)와 메시지의 입구(발행 직전 검사) 양쪽에 + 세우세요. +

+
+ 임계값은 외우는 숫자가 아니다 "1천 명부터 pull"은 정답이 아니라 예시예요. + 진짜 답은 쓰기 비용과 읽기 비용이 교차하는 지점을 자기 워크로드로 계산해 + 정한다는 태도입니다. 면접에서도 숫자보다 "이렇게 재서 정하겠다"는 방법을 + 말하는 쪽이 살아남아요. +
+
+ +
+

+ 이제 부품을 전부 책상 위에 올렸습니다. 조립하면 이렇게 돼요 — 로드밸런서 뒤에{' '} + 연결만 들고 진실은 밖에 두는 채팅 서버군, 그 뒤에 Valkey(Pub/Sub + 백본·세션 레지스트리·presence), 메시지 저장소, 오프라인 유저를 깨우는 푸시 + 게이트웨이. 그리고 완성된 그림에는 반드시 장애를 주입해 봅니다. + 설계 리뷰의 후반전은 언제나 "이게 죽으면요?"거든요. +

+ {CODE_ARCH} +

+ 장애 시나리오에서 가장 소중한 문장은 "저장이 진실이고, 방송은 가속이다" + 예요. Pub/Sub이 죽어도 저장소가 살아 있으면 유실은 없고 실시간성만 잠시 잃습니다 — + 커서 동기화가 따라잡아 주니까요. 반대로 저장을 건너뛰고 방송만 먼저 하면 빨라 + 보이지만, 그 순간 "유실 불가" 약속이 깨집니다. 리전 단절 같은 큰 재해에서도 원칙은 + 같아요. 어떤 보장을 지키고 어떤 보장을 잠시 내려놓을지를 먼저 + 정해 두면, 복구 절차는 그 결정을 따라 자연히 써집니다. +

+

+ 말로만 끝내면 아까우니, 오늘의 백본을 손으로 만들어 봅시다. 채팅 + 서버 2개 인스턴스를 Valkey Pub/Sub으로 묶고, 서로 다른 서버에 붙은 두 브라우저가 + 대화하게 만드는 미니 실습이에요. 섹션 3의 그림이 40분 만에 눈앞에서 움직입니다. +

+ {CODE_PRACTICE} +
+ 설계 리뷰 답변 훈련 리뷰어의 질문은 정답 확인이 아니라 트레이드오프 + 감각 검사예요. "왜 Kafka가 아니라 Valkey Pub/Sub인가요?" → "실시간 fan-out엔 + 지연이 낮은 Pub/Sub이 맞고, 유실 불가 보장은 저장소가 이미 지고 있어서 백본에 + 로그까지 요구하지 않았습니다. 구독자 부재 시에도 메시지가 남아야 하는 요구가 생기면 + Kafka로 올립니다." — 선택 + 근거 + 바뀌는 조건 3박자로 답하는 연습을 + 이 코스의 모든 선택지에 대해 해 보세요. +
+
+ +
+
+

💬 여기까지 왔다면

+

+ 요청↔응답의 세계(훈련 1)를 지나 상태 있는 장수명 연결의 세계를 + 한 바퀴 돌았습니다. WebSocket을 고른 이유, Pub/Sub 백본으로 서버 사이를 잇는 법, + 채널 단위 단조 증가 ID 하나로 순서·페이지네이션·동기화·읽음을 전부 푸는 감각, + "최소 한 번 + 멱등"이라는 전달 보장의 진짜 모양, 그리고 크기가 커지면 push에서 + pull로 전략이 뒤집힌다는 것까지. 그런데 마지막 문턱에서 우리는 봤죠 — 10만 명 + 채널은 이미 채팅이 아니라 피드라는 걸. 소수가 쓰고 수백만이 읽는 + 타임라인은 어떻게 만들어질까요? 다음{' '} + 시스템 설계 훈련 3 — + 뉴스피드와 타임라인에서, 오늘 그어 둔 경계선 너머를 이어서 + 설계해 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv4SystemDesignNewsfeedPage.jsx b/frontend/src/pages/levels/Lv4SystemDesignNewsfeedPage.jsx new file mode 100644 index 0000000..54c0c70 --- /dev/null +++ b/frontend/src/pages/levels/Lv4SystemDesignNewsfeedPage.jsx @@ -0,0 +1,433 @@ +// 이 파일이 하는 일: "시스템 설계 훈련 3 — 뉴스피드와 타임라인" 코스 — 읽기 폭발형 시스템의 +// 대표 문제인 뉴스피드를 재료로, 푸시 vs 풀, 팬아웃 파이프라인, 셀럽 문제와 하이브리드 전략, +// 랭킹·카운터 같은 부속 시스템까지 7개 섹션으로 안내하는 정적 학습 페이지. 특급 과정의 +// 시스템 설계 3부작을 마무리하며, 개별 문제 풀이를 넘어 '문제 유형별 첫수'로 일반화한다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_REQUIREMENTS = `[요구사항을 숫자로 고정한다] +- 피드 로드: p95 200ms 이내 +- 게시 반영: 게시 후 수 초 내 팔로워 피드에 노출 +- 규모: 일 게시물 수억 건, 활성 유저 수천만 + +[증폭 계산 — 쓰기 1건이 만드는 읽기 요구] +평균 팔로워를 200명이라 하면, + 게시 1건 → 200명의 피드에 '나타나야' 한다 +일 게시물 2억 건 × 200 = 일 400억 개의 피드 항목 노출 요구 + +읽기:쓰기가 수백 대 일을 넘는 전형적인 '읽기 폭발형' 시스템. +그래서 설계의 중심 질문은 하나로 좁혀집니다: +"N개 소스의 병합 정렬 비용을 — 읽을 때 낼 것인가, 쓸 때 낼 것인가?"`; + +const CODE_PULL = `-- 풀(pull) 모델: 요청이 올 때마다 그 자리에서 조립한다 +-- ① 내가 팔로우하는 사람 목록 (N명) +SELECT followee_id FROM follow WHERE follower_id = :me; + +-- ② 그들의 최신 글을 모아 온다 +SELECT * FROM post + WHERE author_id IN ( ...N명... ) + ORDER BY created_at DESC + LIMIT 50; + +-- ③ 애플리케이션에서 시간순 병합 후 상위 50건 반환 + +읽기 비용: 팔로잉 수 N에 비례 — N=1,000이면 1,000개 소스의 병합 +쓰기 비용: INSERT 1건이 전부 — 세상에서 제일 저렴 +저장 비용: 글은 원본 한 벌만 — 중복 없음`; + +const CODE_FANOUT = `// 푸시(push) 모델: 쓸 때 뿌려 둔다 — fan-out on write +// ① 게시 API — 저장하고 이벤트만 발행, 사용자는 즉시 응답을 받는다 +postRepository.save(post); +kafka.send("feed.post.created", new PostCreated(post.id(), authorId)); + +// ② 팬아웃 워커 — 팔로워 목록을 청크로 나눠 각자의 피드함에 적재 +List followers = followRepository.findFollowerIds(authorId); +for (List chunk : partition(followers, 1000)) { + for (Long uid : chunk) { + // 유저별 피드함 = Valkey sorted set (score=게시 시각, member=글 id) + valkey.zadd("feed:" + uid, post.createdAt(), post.id()); + valkey.zremrangebyrank("feed:" + uid, 0, -801); // 최근 800건만 유지 + } +} + +// ③ 피드 조회 — 이미 정렬돼 있으니 그냥 읽는다 +List ids = valkey.zrevrange("feed:" + me, 0, 49); + +// 읽기 비용: 팔로잉 수와 무관하게 사실상 상수 — 200ms 목표는 이쪽이 지킨다 +// 쓰기 비용: 팔로워 수만큼의 zadd — 청구서가 쓰기 쪽으로 이동했다`; + +const CODE_CELEB = `[팬아웃 폭발 — 봉투 뒤 계산] +팔로워 1,000만 계정이 글 1건을 올리면 + → zadd 1,000만 번 + → 워커가 초당 1만 건씩 처리해도 약 17분 + → 그동안 워커는 이 글에 붙잡혀 다른 유저의 팬아웃까지 밀린다 + → "게시 후 수 초 내 반영" 요구가 무너진다 + +[하이브리드 전략] +일반 유저(팔로워 < 임계값): 푸시 — 피드함에 미리 적재 +셀럽(팔로워 >= 임계값): 풀 — 팬아웃하지 않고, 읽는 쪽이 가져간다 + +피드 조회 = 내 피드함(푸시분) + + 내가 팔로우한 셀럽들의 최신 글(풀분) + → 두 목록을 시간순으로 병합해 반환`; + +const CODE_THRESHOLD = `[경계값을 '데이터로' 정하는 법 — 감이 아니라 측정] +① 팔로워 수 분포를 뽑는다 (p50 / p99 / p99.9 — 대개 극단적 롱테일) +② 팬아웃 지연 SLO를 정한다 — 예: 게시 후 5초 내 95% 적재 완료 +③ 워커 처리량으로 역산한다 + 초당 2만 zadd 처리 가능 × 5초 = 팔로워 10만 명까지는 푸시로 감당 +④ 임계값 10만으로 시작 → 팬아웃 지연 대시보드를 보며 조정 + +포인트: 임계값은 한 번 정하는 상수가 아니라, +워커 증설·트래픽 변화에 따라 움직이는 '운영 파라미터'입니다.`; + +const CODE_RANKING = `// 점수 기반 랭킹의 기본 골격 — 시간순을 넘어서 +score(post, viewer) = + freshness(post.age) // 신선도: 시간 감쇠(오래될수록 점수 하락) + × affinity(viewer, post.author) // 친밀도: 이 작성자에게 얼마나 반응해 왔나 + × engagement(post) // 참여도: 좋아요·댓글·체류 시간 + +// 랭킹을 '어디서' 계산하나 — 지연 vs 신선도 트레이드오프 +미리 계산(팬아웃 때 점수까지): 조회는 빠르다 / 점수가 금방 낡는다 +요청 시 계산(조회 때 재랭킹): 항상 신선하다 / 후보 수백 건 스코어링 지연 + +// 흔한 절충 +팬아웃은 시간순으로 적재해 두고, 조회 때 상위 후보 200건만 재랭킹 + +// ML 랭킹은 인터페이스 뒤로 분리한다 +rank(candidates, viewer) -> sortedList +피드 팀은 '후보 생성'까지, 랭킹 팀은 이 함수 안쪽만 — +모델이 룰 기반이든 딥러닝이든 피드 시스템은 몰라도 되게.`; + +const CODE_COUNTER = `-- 좋아요 카운터의 핫키 문제 +UPDATE post SET like_count = like_count + 1 WHERE id = :hot; +-- 인기 글 하나에 초당 수만 번 → 같은 행(row)의 잠금 경합 → DB가 먼저 죽는다 + +// 패턴: 캐시에서 집계하고, 주기적으로 DB에 플러시 +valkey.incr("like:" + postId); // 메모리 연산 — 초당 수만 건도 거뜬 + +// 별도 플러셔가 5초마다 한 번: +long delta = valkey.getdel("like:" + postId); +jdbc.update("UPDATE post SET like_count = like_count + ? WHERE id = ?", + delta, postId); + +// 미디어는 피드에 싣지 않는다 +게시물 레코드에는 image_url(오브젝트 스토리지 키)만 저장, +실제 바이트는 S3류 스토리지 + CDN이 서빙 — +피드 API는 끝까지 '작은 JSON'으로 유지한다.`; + +const CODE_PLAYBOOK = `[3부작의 공통 뼈대 — 어떤 문제든 이 순서로] +① 요구사항 고정 : 지연·신선도·규모를 '숫자'로 못 박는다 +② 봉투 뒤 산정 : QPS·저장량·증폭 계수를 어림 계산한다 +③ 저장/전달 모델 : 비용을 읽기 쪽에 낼지 쓰기 쪽에 낼지 정한다 +④ 예외 격리 : 핫키·셀럽·폭주 — 평균이 아니라 '꼬리'를 따로 설계한다 + +[문제 유형별 첫수] +읽기 편중형 (뉴스피드·상품 목록) → 캐시와 사전 계산(팬아웃)부터 본다 +쓰기 편중형 (로그 수집·시세 적재) → 파티셔닝과 배치 적재부터 본다 +연결 유지형 (채팅·알림·실시간 시세) → 커넥션 관리와 fan-out 채널부터 본다 + +낯선 문제는 대부분 이 셋의 '조합'입니다. +"이 문제는 어느 유형이 지배적인가?" — 이것이 언제나 첫 질문.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '문제 정의' }, + { n: 2, label: '풀 모델' }, + { n: 3, label: '푸시 모델' }, + { n: 4, label: '셀럽 문제' }, + { n: 5, label: '랭킹과 개인화' }, + { n: 6, label: '미디어와 카운터' }, + { n: 7, label: '3부작 총정리' }, +]; + +export default function Lv4SystemDesignNewsfeedPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 특급 과정
+

시스템 설계 훈련 3
— 뉴스피드와 타임라인

+

+ 📰 트위터·인스타그램의 첫 화면, 그 피드는 어떻게 200ms 만에 + 뜰까요? 쓰기 1건이 팔로워 수만큼의 읽기 요구로 증폭되는{' '} + 읽기 폭발형 시스템의 끝판왕이 바로 뉴스피드입니다. 푸시 vs 풀, + 팬아웃, 셀럽 문제, 랭킹까지 — 이 문제 하나를 끝까지 밀어붙이며{' '} + 시스템 설계 3부작을 완성합니다. +

+
+ 예상 소요 85분 + 실습: 푸시·풀 피드 API 실측 비교 + 선수 지식: 시스템 설계 훈련 1·2 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 피드의 정의부터 정확히 해 둡시다. 내가 N명을 팔로우하고 있다면, + 내 피드는 N개의 글 소스를 시간순으로 병합 정렬한 결과예요. + N이 3명이면 데이터베이스 쿼리 한 방이면 됩니다. 그런데 N이 1,000명이고, + 그런 유저가 수천만 명이고, 모두가 하루에도 수십 번 새로고침을 한다면요? + 단순한 문제가 규모 때문에 어려워지는 — 시스템 설계의 전형적인 순간입니다. +

+

+ 이 문제의 성격을 결정짓는 건 증폭(amplification)이에요. + 글쓰기는 1건인데, 그 글은 팔로워 모두의 피드에 나타나야 하죠. + 쓰기 1건이 읽기 수백 건의 요구로 부풀어 오르는 구조 — 훈련 1·2에서처럼 + 먼저 요구사항을 숫자로 고정하고 봉투 뒤 계산을 해 봅시다. +

+ {CODE_REQUIREMENTS} +

+ 마지막 줄의 질문이 이 코스 전체를 관통합니다. 병합 정렬이라는 비용은 + 사라지지 않아요. 다만 읽을 때 낼지, 쓸 때 낼지 선택할 수 + 있을 뿐이죠. 읽을 때 내는 방식이 섹션 2의 풀 모델, + 쓸 때 내는 방식이 섹션 3의 푸시 모델입니다. +

+
+ {/* 학습 포인트: 설계 문제는 '비용을 없애는' 게 아니라 '비용을 옮기는' 게임임을 먼저 심는다 */} + 관점 하나 시스템 설계에서 공짜는 없습니다. 캐시도, 팬아웃도, 복제도 + 전부 "비용을 언제·어디서 낼 것인가"를 옮기는 장치예요. + 이 관점을 쥐고 있으면 처음 보는 아키텍처도 "아, 이건 비용을 저쪽으로 옮겼구나"로 + 읽어 낼 수 있습니다. +
+
+ +
+

+ 가장 정직한 방법부터요. 풀(pull) 모델은 피드 요청이 올 때마다 + 그 자리에서 조립합니다. 내 팔로잉 목록을 조회하고 → 각자의 최신 글을 가져와 → + 시간순으로 병합해 돌려주는 거예요. 저장된 피드라는 건 어디에도 없고, + 매번 즉석에서 만들어집니다. +

+ {CODE_PULL} +

+ 장점은 분명해요. 쓰기가 저렴하고(글 INSERT 한 번이 전부), + 글이 원본 한 벌만 존재하니 저장 중복이 없습니다. 수정·삭제도 + 원본만 고치면 모두의 피드에 즉시 반영되죠. 문제는 읽기입니다.{' '} + 읽기 지연이 팔로잉 수 N에 비례해요. 팔로잉이 열 명인 유저는 + 빠르지만, 천 명인 유저의 요청은 천 개 소스를 긁어 병합해야 하니 + p95 200ms 목표가 위태로워집니다. +

+
+ {/* 학습 포인트: "무조건 큰 회사 아키텍처"가 아니라 규모에 맞는 선택을 가르친다 */} + 그래도 풀부터 소규모 서비스라면 풀 모델 + 캐시로 + 꽤 오래 버틸 수 있어요. 유저가 수만 명 수준이면 IN 쿼리 한 방 + 결과 캐시 + 몇 초로 충분합니다. "트위터가 하니까 우리도 팬아웃"은 설계가 아니라 모방이에요. + 지금 규모의 숫자를 먼저 계산하고, 그 숫자가 시키는 걸 하세요. +
+
+ +
+

+ 읽기가 병목이라면 비용을 쓰기 쪽으로 옮깁니다. 푸시(push) 모델은 + 글이 게시되는 순간, 팔로워 각자의 피드함(inbox)에 글을 밀어 넣어{' '} + 둬요. 이걸 fan-out on write(쓰기 시점 팬아웃)라고 부릅니다. + 조회 시점엔 이미 정렬된 내 피드함을 읽기만 하면 되니, 팔로잉이 몇 명이든 + 응답 시간이 일정해지죠. +

+

+ 피드함 저장소로는 유저별 정렬 리스트가 필요한데,{' '} + Valkey의 sorted set이 정확히 이 모양이에요. 게시 시각을 + score로 쓰면 삽입만으로 시간순이 유지되고, 조회는{' '} + zrevrange 한 번입니다. 피드함은 어차피 최근 + 글만 보여 주면 되니 유저당 수백 건으로 잘라 메모리를 아끼고요. +

+

+ 중요한 건 팬아웃을 비동기로 돌린다는 점이에요. 게시 API가 + 팔로워 수만 명의 피드함을 직접 다 쓰고 응답한다면 글 하나 올리는 데 수십 초가 + 걸리겠죠. 그래서 게시 API는 글만 저장하고 이벤트를 발행한 뒤 + 즉시 응답하고, 뒤에서 팬아웃 워커가 이벤트를 받아 피드함을 + 채웁니다. 우리 스택으로 말하면 Kafka가 그 이벤트 통로예요. +

+ {CODE_FANOUT} +
+ {/* 학습 포인트: 요구사항의 "수 초 내 반영"이 왜 '비동기 허용'의 근거인지 연결한다 */} + 요구사항 다시 보기 섹션 1에서 게시 반영 목표를 "수 초 내"로 잡았죠. + "즉시"가 아니라요. 이 느슨함이 비동기 팬아웃을 허용하는 근거입니다. + 요구사항의 한 줄이 아키텍처의 큰 갈래를 결정하는 장면 — 요구사항을 숫자로 + 못 박는 습관이 왜 중요한지 여기서 드러나요. +
+
+ 공짜가 아니다 푸시 모델의 청구서는 저장 중복(같은 글 id가 + 팔로워 수만큼 복사됨)과 팬아웃 쓰기량입니다. 팔로워 200명까진 + 귀엽지만, 200만 명이라면요? 그 계산이 다음 섹션의 주인공, 셀럽 문제입니다. +
+
+ +
+

+ 푸시 모델의 아킬레스건은 팔로워 분포의 롱테일이에요. + 대부분의 유저는 팔로워가 수백 명이지만, 꼬리 끝엔 팔로워 수백만·수천만의{' '} + 셀럽이 있습니다. 그 계정의 글 하나가 + 팬아웃 파이프라인에 어떤 일을 벌이는지 계산해 봅시다. +

+ {CODE_CELEB} +

+ 그래서 실전 답은 하이브리드입니다. 일반 유저는 + 푸시로 피드함에 미리 적재하고, 셀럽은 팬아웃하지 않아요. + 대신 피드를 읽는 시점에, 내 피드함(푸시분)과 내가 팔로우한 + 셀럽들의 최신 글(풀분)을 병합합니다. 셀럽은 전체 유저의 극소수이므로 + 풀 쪽 병합 대상은 몇 개 소스에 불과하고, 셀럽 최신 글은 어차피 수백만 명이 + 읽으니 캐시 적중률이 극도로 높습니다. 푸시의 읽기 성능과 + 풀의 쓰기 절약을 각자 잘하는 자리에 배치한 거예요. +

+

+ 남는 질문은 경계값이죠. 팔로워 몇 명부터 셀럽으로 취급할 + 것인가? 이걸 감으로 정하면 설계가 아니라 점술입니다. 데이터로 정하는 + 절차는 이렇습니다. +

+ {CODE_THRESHOLD} +
+ {/* 학습 포인트: 훈련 1·2에서 반복해 온 '꼬리를 따로 설계한다' 원칙의 재등장을 짚는다 */} + 패턴의 재등장 "평균은 문제없는데 극단값이 시스템을 부순다"는 구조, + 낯익지 않나요? 캐시를 부수는 핫키, 파티션을 기울이는 인기 상품 — 앞선 + 훈련들에서 만난 그 패턴의 뉴스피드 버전이 셀럽 문제입니다. 대규모 시스템 + 설계란 결국 평균이 아니라 꼬리를 설계하는 일이에요. +
+
+ +
+

+ 여기까지의 피드는 시간순이었어요. 구현이 단순하고 예측 + 가능하지만 한계가 있습니다. 새벽에 올라온 절친의 소식이, 방금 올라온 + 광고성 글에 밀려 화면 밖으로 사라지죠. 팔로잉이 많을수록 피드는 "최신이지만 + 나와 무관한 글"로 채워집니다. 그래서 큰 피드 서비스들은{' '} + 점수 기반 랭킹으로 넘어가요. 기본 구조는 세 축의 곱입니다. +

+ {CODE_RANKING} +

+ 설계자가 정해야 할 건 수식 자체보다 랭킹을 어디서 계산하느냐예요. + 팬아웃 때 미리 계산해 두면 조회는 빠르지만, 참여도는 계속 + 변하니 점수가 금방 낡습니다. 요청 시 계산하면 항상 신선하지만 + 후보 수백 건을 매 요청마다 스코어링하는 지연을 떠안죠. 흔한 절충은 피드함 + 적재는 시간순으로 하되, 조회 시 상위 후보 일부만 재랭킹하는 + 것 — 지연과 신선도 사이의 비용 재배치가 여기서도 반복됩니다. +

+
+ {/* 학습 포인트: 시스템 경계 설계 = 조직 경계 설계라는 특급 과정다운 시야를 심는다 */} + 인터페이스로 팀을 나눈다 ML 랭킹 고도화는 끝이 없는 세계라, 잘하는 + 조직은 이걸 다른 팀의 문제로 분리합니다. 피드 시스템은 + "후보 목록을 넣으면 정렬된 목록이 나온다"는 rank(){' '} + 인터페이스만 알고, 그 안쪽이 룰 기반인지 딥러닝인지는 몰라도 되게요. + 시스템의 경계를 긋는 일이 곧 조직의 경계를 긋는 일 — 특급 과정에서 + 계속 만나게 될 시야입니다. +
+
+ +
+

+ 피드 본체가 서도 부속 시스템이 무너지면 소용없어요. 첫째,{' '} + 이미지·영상. 미디어 바이트를 피드 응답에 실으면 API가 + 순식간에 무거워집니다. 원칙은 단순해요 —{' '} + 오브젝트 스토리지 + CDN이 바이트를 서빙하고, 피드에는{' '} + URL만 흘립니다. 둘째, 좋아요 카운터. + 인기 글 하나에 좋아요가 초당 수만 건 몰리면, 같은 행을 갱신하려는 트랜잭션이 + 잠금 앞에 줄을 서는 핫키 문제가 됩니다. +

+ {CODE_COUNTER} +

+ 카운터 패턴의 교환 조건도 정확히 알아 둡시다. 캐시 집계 + 주기 플러시는 + 쓰기 폭주를 흡수하는 대신, 플러시 주기만큼의 오차를 + 허용합니다. 좋아요 수가 5초쯤 덜 보여도 아무도 다치지 않으니 괜찮은 + 거래예요. 반대로 결제 잔액이라면 절대 이 패턴을 쓰면 안 되겠죠 —{' '} + 정확성 요구가 패턴을 고릅니다. +

+
+ {/* 학습 포인트: 배운 패턴을 자사 프로덕트(traderbox)에 투영해 보는 전이 훈련 */} + 우리 프로덕트에 대입해 보면 traderbox의 아티클·공시 피드를 + 떠올려 보세요. 종목을 '팔로우'하는 유저에게 새 공시를 흘린다면 — + 삼성전자 공시 한 건은 수십만 관심 유저에게 가야 하는 셀럽 글이고, + 조회수·북마크 수는 핫키 카운터이며, 첨부 문서는{' '} + URL만 피드에 싣는 미디어입니다. 이 코스의 설계가 SNS만의 + 얘기가 아니라, 구독·알림이 있는 모든 서비스의 뼈대라는 뜻이에요. +
+
+ +
+

+ 세 번의 훈련이 끝났습니다. 문제는 매번 달랐지만, 풀이의 뼈대는 매번 + 같았어요. 요구사항을 숫자로 고정하고 → 봉투 뒤 계산으로 규모를 산정하고 → + 비용을 읽기와 쓰기 중 어느 쪽에 낼지 저장/전달 모델을 고르고 → 마지막으로 + 평균이 아닌 꼬리(핫키·셀럽·폭주)를 따로 격리했죠. + 이 뼈대를 문제 유형별 '첫수'와 함께 한 장으로 정리합니다. +

+ {CODE_PLAYBOOK} +

+ 면접장에서든 실무 회의에서든, 낯선 설계 문제를 받았을 때 백지에서 시작하지 + 마세요. "이 문제는 내가 아는 어떤 문제와 닮았지?" — + 읽기가 폭발하는가(피드형), 쓰기가 폭주하는가(수집형), 연결을 오래 쥐고 + 있어야 하는가(실시간형). 지배적인 유형을 찾으면 첫수가 나오고, 첫수가 + 나오면 나머지는 이 3부작에서 연습한 트레이드오프 협상입니다. + 낯선 문제를 기존 패턴으로 환원하는 힘, 그게 3부작이 + 남기려던 근육이에요. +

+
+ 패턴 맹신 금지 환원은 출발점이지 결론이 아닙니다. "뉴스피드니까 + 무조건 팬아웃"이 아니라, 지금 이 서비스의 숫자를 계산한 + 뒤에 패턴을 고르세요. 섹션 2에서 봤듯, 작은 규모에선 풀 + 캐시가 + 정답인 경우가 훨씬 많습니다. 패턴은 지도이지 목적지가 아니에요. +
+
+ 실습 안내 팔로우 관계 1만 건을 시드 데이터로 넣고,{' '} + 푸시·풀 두 방식의 피드 API를 모두 구현해 보세요. 그리고 + 팔로워 수 구간별로 게시 지연(글 저장 → 피드함 반영 완료)과{' '} + 조회 지연(피드 요청 → 응답)을 실측해 표로 비교합니다. + "풀은 읽기가, 푸시는 쓰기가 팔로워 수에 비례해 늘어난다"를 그래프로 + 직접 확인하는 순간, 이 코스의 모든 문장이 자기 경험이 됩니다. +
+
+ +
+
+

📰 3부작을 완주했다면

+

+ 요구사항 → 산정 → 모델 선택 → 꼬리 격리. 이제 낯선 시스템 문제 앞에서 + 무엇부터 계산해야 하는지 아는 개발자가 됐어요. 그런데 설계 실력이 커질수록 + 새로운 문제가 생깁니다. 내 머릿속의 트레이드오프를 팀은 어떻게 + 아나요? 6개월 뒤에 합류한 동료는 "왜 여기만 하이브리드지?"라는 + 질문의 답을 어디서 찾죠? 좋은 설계는 결정을 내리는 것에서 끝나지 않고,{' '} + 결정을 기록하고 설득하는 것까지입니다. 다음{' '} + 기술 리더십과 ADR — + 결정을 설계하고 기록하는 사람 코스에서, 설계자의 마지막 + 역량을 이어서 익혀 봅시다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv4SystemDesignUrlShortenerPage.jsx b/frontend/src/pages/levels/Lv4SystemDesignUrlShortenerPage.jsx new file mode 100644 index 0000000..920d8b1 --- /dev/null +++ b/frontend/src/pages/levels/Lv4SystemDesignUrlShortenerPage.jsx @@ -0,0 +1,463 @@ +// 이 파일이 하는 일: "시스템 설계 훈련 1 — URL 단축기를 처음부터 끝까지" 코스 — +// 가장 고전적인 설계 문제를 재료 삼아 '요구사항 → 용량 산정 → API → 데이터 모델 → +// 상세 설계 → 확장'이라는 설계 루틴을 처음부터 끝까지 한 바퀴 도는 정적 학습 페이지. +// 프레이밍은 "그림이 아니라 질문이 먼저" — 특급 과정답게 숫자로 산정하고, +// 트레이드오프를 말로 방어할 수 있는 상태를 목표로 한다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_ROUTINE = `설계 문제를 받았을 때의 루틴 — 면접이든 실무든 순서는 같다 + +① 요구사항 명확화 "무엇을, 얼마나, 얼마나 빨리?" ← 여기가 절반 +② 용량 산정 RPS · 저장 용량 · 대역폭을 숫자로 +③ API 설계 바깥에서 보이는 계약부터 확정 +④ 데이터 모델 테이블 · 키 · 인덱스 +⑤ 상세 설계 핵심 알고리즘과 컴포넌트 다이어그램 +⑥ 확장과 운영 캐시 · 샤딩 · 다중 리전 · SLO + +흔한 실패: ①을 건너뛰고 ⑤의 그림부터 그리기 시작하는 것. +요구사항이 다르면 정답도 다르다 — "일단 그림부터"가 아니라 "일단 질문부터".`; + +const CODE_CAPACITY = `가정: 단축 생성(쓰기) 하루 100만 건, 읽기:쓰기 = 100:1 + +■ RPS (초당 요청 수) +쓰기 = 1,000,000 / 86,400초 ≈ 12 writes/sec +읽기 = 12 × 100 ≈ 1,200 reads/sec +피크 = 평균의 2~3배 잡으면 ≈ 3,000~4,000 reads/sec + +■ 저장 용량 (레코드 크기 × 건수 × 보존 기간) +레코드 1건 ≈ 500바이트 (키 + 원본 URL + 메타데이터) +하루 = 500B × 100만 = 500MB/일 +1년 = 500MB × 365 ≈ 182GB/년 +5년 보존 = 182GB × 5 ≈ 1TB — 단일 DB로도 감당 가능한 크기 + +■ 결론 +쓰기는 한가하고(12/sec), 읽기가 지배한다(수천/sec). +→ 이 "읽기 편중"이라는 특성이 이후 모든 설계 결정을 지배한다. + 캐시를 두껍게, 읽기 경로를 최단으로, 쓰기는 단순하게.`; + +const CODE_BASE62 = `base62 = 0-9, a-z, A-Z (62개 문자)로 숫자를 표현하는 인코딩 + +키 길이별 표현 가능한 개수: +6자 → 62^6 ≈ 568억 개 +7자 → 62^7 ≈ 3.5조 개 ← 하루 100만 건 × 100년을 써도 남는다 + +■ 방식 A: 해시 잘라 쓰기 +md5(원본URL) 앞 7자 사용 +장점: 무상태 — 어느 서버든 즉시 계산 +단점: 충돌 — 다른 URL이 같은 7자가 될 수 있어 재시도 로직 필수 + +■ 방식 B: 카운터 기반 발급 +전역 카운터 1, 2, 3... 을 base62로 변환 (1억 → "6LAze") +장점: 충돌이 원천적으로 없다 +단점: 키가 순차적 → 다음 키를 예측해 남의 URL을 긁을 수 있고, + 전역 카운터 자체가 단일 장애점 + 병목 + +■ 방식 C: 키 생성 서비스(KGS) +키 '범위'를 서버마다 미리 떼어 준다 +서버1 ← [1 ~ 100만] 서버2 ← [100만+1 ~ 200만] +각 서버는 자기 범위 안에서 로컬 카운터만 증가 — 조율 없이 발급. +서버가 죽으면 그 범위만 버린다(3.5조 개 중 100만 개쯤은 아깝지 않다).`; + +const CODE_API = `■ API — 바깥에서 보이는 계약은 딱 두 개 +POST /shorten + body: 원본 URL (+ 선택: 커스텀 별칭, 만료일) + 응답: 201 Created, 단축 URL + +GET /:key + 응답: 301 또는 302 + Location: 원본 URL + +■ 301 vs 302 — 리다이렉트 한 줄에 숨은 트레이드오프 +301 Moved Permanently + 브라우저가 결과를 캐시 → 다음부터 우리 서버를 거치지 않는다 + + 서버 부하 감소 - 클릭 집계 불가, 목적지 변경 반영 안 됨 +302 Found (임시) + 매 클릭이 우리 서버를 통과 + + 클릭 분석 가능, 만료·차단 즉시 반영 - 부하는 그대로 +→ 분석이 상품인 서비스(bit.ly류)는 302를 고른다. 요구사항이 결정한다. + +■ 테이블 설계 +urls ( + key VARCHAR(7) PK, -- base62 단축 키 + original TEXT NOT NULL, + created_by BIGINT, -- 생성자 (익명 허용 시 NULL) + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ -- NULL이면 무기한 +) +조회는 오직 key 등가 조회 하나 — 조인도 범위 스캔도 없다. + +■ 만료 처리 두 갈래 +지연 삭제(lazy): 조회 시 expires_at 지났으면 404 주고 그때 지운다 +배치 청소: 새벽마다 만료 행 일괄 삭제 +→ 보통 둘 다: 정확성은 lazy가, 공간 회수는 배치가 맡는다.`; + +const CODE_READPATH = `읽기 경로 — 요청 1,200/sec가 흐르는 길을 최단으로 + +GET /abc1234 + ① 캐시(Redis) 조회 ── 적중 ──→ 즉시 301/302 응답 (대부분 여기서 끝) + ② 캐시 미스 → DB 조회 → 캐시에 적재 → 응답 + ③ DB에도 없음 → 404 + +■ 왜 캐시가 잘 먹히나 +단축 URL 트래픽은 전형적인 멱법칙: 상위 20% 키가 트래픽의 80%. +전체 1TB를 다 캐시할 필요 없이, 뜨거운 20%만 메모리에 올려도 +적중률 80%+ — DB가 받는 부하는 1/5 이하로 줄어든다. + +■ 존재하지 않는 키 폭격 (캐시 관통, cache penetration) +공격자가 무작위 키를 대량 조회하면? 캐시엔 당연히 없으니 +전부 DB로 관통 — DB가 먼저 쓰러진다. 방어 두 가지: +· 네거티브 캐시: "없음"이라는 사실도 짧은 TTL로 캐시 +· 블룸 필터: "확실히 없다"를 메모리에서 판정하는 확률적 자료구조 + (있다고 하면 진짜 있을 수도/없을 수도, 없다고 하면 확실히 없음) + +■ 극단: CDN 레벨 리다이렉트 +리다이렉트 응답 자체를 엣지(CDN)에 캐시하면 +요청이 아예 우리 서버에 도착하지도 않는다. +단, 302로 분석을 하기로 했다면 이 최적화와 충돌 — 또 트레이드오프.`; + +const CODE_SCALE = `■ 키 기반 샤딩 — 앞 강좌의 샤드 키 논의를 그대로 적용 +shard = hash(key) % N +· key는 균등 분포(base62 랜덤/범위 발급) → 핫스팟 없음 +· 조회가 전부 key 등가 조회 → 크로스 샤드 쿼리가 아예 없다 +→ URL 단축기는 샤딩하기 '가장 쉬운' 워크로드다. + +■ 다중 리전 — 어느 지역 유저가 어느 DB를 치나 +서울 유저가 미국 DB를 치면 왕복 150ms+ — 리다이렉트가 눈에 띄게 느리다. +· 읽기: 리전마다 읽기 복제본(replica) + 캐시 → 가까운 곳에서 응답 +· 쓰기: 생성은 상대적으로 드물다(12/sec) → 단일 프라이머리로 몰아도 + 복제 지연 몇 초는 허용 가능 ("방금 만든 링크가 지구 반대편에서 + 1초 늦게 열린다" — 대개 받아들일 수 있는 결과 정합성) + +■ SLO — 단축기가 죽으면 '모든 링크'가 죽는다 +이 서비스의 장애 반경은 자기 자신이 아니라, 이 링크를 박아 둔 +전 세계의 문서·메일·광고 전부다. 그래서 가용성 요구가 유독 높다. +99.9% = 월 43분 장애 허용 — 링크 서비스엔 부족하다는 평가가 많다 +99.99% = 월 4.3분 — 읽기 경로만큼은 이 수준을 노린다 +힌트: 읽기(리다이렉트)와 쓰기(생성)의 SLO를 분리하라. +생성이 10분 죽는 것과 리다이렉트가 10분 죽는 것은 사고의 급이 다르다.`; + +const CODE_ANALYTICS = `요구 추가: "클릭 통계를 보여 주세요" — 기존 설계를 어떻게 흔들까? + +■ 후보 1: 동기 기록 +리다이렉트 처리 중에 클릭 로그를 DB에 INSERT하고 응답 +- 읽기 경로에 쓰기가 끼어든다 → 1,200/sec 읽기가 1,200/sec 쓰기로 둔갑 +- DB가 느려지면 리다이렉트까지 느려진다 — 핵심 경로 오염 + +■ 후보 2: 이벤트 발행 (비동기) +리다이렉트는 즉시 응답, 클릭 이벤트만 Kafka에 발행 +소비자(consumer)가 뒤에서 받아 집계 테이블에 반영 ++ 읽기 경로는 그대로 빠르다, 집계가 밀려도 리다이렉트는 무사 +- 통계는 몇 초~몇 분 지연 (준실시간) + +■ 그래서 다시 요구사항 질문으로 +"통계는 실시간이어야 하나요, 몇 분 늦어도 되나요?" +→ 대시보드용이면 준실시간으로 충분 — 후보 2 채택. +→ 기존 설계는 거의 그대로: 읽기 경로에 '이벤트 발행 한 줄'만 얹는다. +좋은 설계의 증거는 '기능 추가가 기존 구조를 크게 흔들지 않는 것'이다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '설계 루틴' }, + { n: 2, label: '요구사항·용량 산정' }, + { n: 3, label: '짧은 키 만들기' }, + { n: 4, label: 'API·데이터 모델' }, + { n: 5, label: '읽기 경로 최적화' }, + { n: 6, label: '확장과 가용성' }, + { n: 7, label: '분석 기능 추가' }, +]; + +export default function Lv4SystemDesignUrlShortenerPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 특급 과정
+

🔗 시스템 설계 훈련 1
— URL 단축기를 처음부터 끝까지

+

+ 긴 URL을 넣으면 tdl.ink/abc1234 같은 짧은 주소를 + 돌려주는 서비스 — 기능만 보면 한나절 과제 같지만, 하루 100만 건 생성, + 초당 수천 건 리다이렉트라는 규모가 붙는 순간 캐시·샤딩·가용성 논의가 전부 + 소환됩니다. 그래서 이 문제는 시스템 설계의 가장 고전적인 훈련장이에요. 이 강좌에서{' '} + 요구사항 → 용량 산정 → API → 데이터 → 확장이라는 설계 루틴을 + 처음부터 끝까지 한 바퀴 돌며 몸에 새깁니다. +

+
+ 예상 소요 75분 + 실습: 45분 화이트보드 전체 설계 + 선수 지식: DB·캐시·샤딩 기초 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 설계 문제를 받으면 손이 먼저 그림을 그리고 싶어 합니다. 박스 몇 개, 화살표 몇 개 — + 그럴듯해 보이죠. 하지만 요구사항이 다르면 정답도 다릅니다. 하루 + 1만 건짜리 사내 도구와 하루 1억 건짜리 글로벌 서비스는 이름만 같은 다른 문제예요. + 그래서 훈련된 설계자는 그림 대신 질문부터 꺼냅니다. +

+

+ 질문은 두 갈래로 나눠요. 기능 요구(무엇을 하나 — 단축, 리다이렉트, + 만료, 커스텀 별칭?)와 비기능 요구(어떤 품질로 — 규모는? 지연은? + 가용성은?). 설계를 실제로 좌우하는 건 대개 후자입니다. 그리고 순서는 면접이든 + 실무든 같습니다. +

+ {CODE_ROUTINE} +

+ 이 루틴의 힘은 되돌아갈 지점을 만들어 준다는 데 있어요. 뒤 단계에서 + 막히면 "요구사항을 잘못 잡았나?"로 돌아가고, 기능이 추가되면(섹션 7) 다시 ①부터 + 짧게 도는 겁니다. 그림은 이 루틴의 결과물이지 출발점이 아니에요. +

+
+ 실무에서도 똑같다 우리 트레이딩 플랫폼에 "실시간 알림 넣어 주세요"라는 요청이 + 오면, 훈련된 사람은 코드를 열기 전에 묻습니다 — "몇 명에게? 지연은 몇 초까지 허용? + 놓치면 안 되는 알림인가?" 이 세 질문의 답에 따라 구현이 폴링/SSE/푸시로 완전히 + 갈라지죠. 이 강좌의 루틴이 바로 그 근육입니다. +
+
+ +
+

+ 요구사항이 잡혔으면 숫자로 바꿉니다. 이른바 봉투 뒷면 계산(back-of-the-envelope) — + 정밀할 필요는 없고, 자릿수(order of magnitude)만 맞으면 됩니다. + 우리의 가정: 단축 생성(쓰기)이 하루 100만 건, 읽기:쓰기 비율은 100:1. +

+ {CODE_CAPACITY} +

+ 계산에서 두 가지 사실이 드러났어요. 첫째, 저장 용량은 문제가 아닙니다.{' '} + 5년치가 1TB — 요즘 DB 한 대로 충분한 크기죠. 둘째, 읽기가 쓰기의 100배입니다. + 초당 12건의 쓰기는 한가하지만, 피크 수천 건의 읽기는 진짜 부하예요. 그러니 이 시스템의 + 설계 에너지는 온통 읽기 경로에 쏟아야 합니다 — 캐시는 두껍게(섹션 5), + 리다이렉트는 최단 거리로, 쓰기는 단순하게. +

+ {/* 학습 포인트: 산정의 목적은 정확한 숫자가 아니라 '설계 결정의 근거'다. + "읽기 편중 → 캐시 중심" 같은 문장으로 연결되지 않는 계산은 장식일 뿐이다. */} +
+ 산정이 틀리면? 괜찮습니다. 봉투 뒷면 계산의 목적은 예언이 아니라{' '} + 방향 결정이에요. 100만이 300만이 되어도 "읽기 편중, 저장은 여유"라는 + 결론은 안 바뀝니다. 결론이 뒤집히는 지점(예: 쓰기가 초당 수만 건)이 어디인지 아는 것, + 그게 산정의 진짜 수확이에요. +
+
+ +
+

+ 이제 이 시스템의 심장, "긴 URL에 어떤 짧은 키를 붙일 것인가"입니다. + 먼저 키가 몇 자여야 하는지부터 계산하죠. 재료는 base62 — 숫자 10개, + 소문자 26개, 대문자 26개로 62진법 표기를 하는 겁니다. URL에 안전하게 들어가는 + 문자만 쓰는 거죠. +

+ {CODE_BASE62} +

+ 7자면 3.5조 개. 하루 100만 건씩 100년을 만들어도 365억 개 — 공간은 + 넉넉합니다. 문제는 "그 키를 누가, 어떻게, 겹치지 않게 발급하나"예요. + 해시 방식(A)은 서버가 아무 상태도 안 가져서 좋지만 충돌을 감수해야 + 하고, 카운터 방식(B)은 충돌이 없지만 키가 순차적이라 예측 가능하고 + 전역 카운터가 단일 장애점이 됩니다. +

+

+ 그래서 실전 답안이 키 생성 서비스(KGS, Key Generation Service){' '} + 패턴이에요. 키 범위를 애플리케이션 서버마다 미리 떼어 주면, 각 서버는 + 자기 범위 안에서 아무 조율 없이 로컬 카운터만 올리며 발급합니다. 범위가 바닥나면 다음 + 범위를 받아 가고요. 서버가 죽어 범위 일부가 버려져도 — 3.5조 개 중 100만 개는 + 버려도 되는 비용입니다. 낭비를 허용해서 조율을 없애는, 분산 시스템의 + 전형적인 거래죠. +

+ {/* 학습 포인트: 세 방식 중 '정답'을 외우는 게 아니라, 충돌·예측가능성·단일 장애점이라는 + 세 축으로 각 방식을 평가하는 눈을 기르는 것이 목적이다. */} +
+ 순차 키의 함정 카운터 키가 abc1234였다면 다음 키는{' '} + abc1235 — 공격자가 번호를 하나씩 올려 가며{' '} + 남이 만든 단축 URL을 전부 수집할 수 있어요. 비공개 문서 링크가 섞여 + 있다면 사고입니다. KGS로 범위를 무작위 배정하거나 발급 시 키를 섞는(셔플) 이유가 + 여기 있어요. 설계 논의에서 보안 관점을 한 번씩 스치는 습관을 들이세요. +
+
+ +
+

+ 바깥에서 보이는 계약은 단출합니다. 만들기(POST /shorten)와 + 이동하기(GET /{key}) — 끝. 하지만 그 단출한 + 리다이렉트 응답에서 상태 코드를 301로 줄지 302로 줄지가 이 서비스의 + 성격을 가릅니다. +

+ {CODE_API} +

+ 301(영구 이동)은 브라우저가 결과를 캐시해서, 같은 링크를 다시 누르면 + 우리 서버에 오지도 않고 목적지로 갑니다. 서버 부하는 최소가 되지만 — + 클릭이 안 잡히니 통계가 불가능하고, 목적지를 바꾸거나 링크를 차단해도 + 이미 캐시한 브라우저엔 반영이 안 돼요. 302(임시)는 정반대: 매 클릭이 + 서버를 통과하니 분석·차단·만료가 다 살아 있는 대신 부하를 그대로 받습니다. 클릭 분석이 + 상품인 서비스가 302를 고르는 이유죠. 요구사항이 상태 코드 하나까지 결정합니다. +

+

+ 데이터 모델은 겸손할수록 좋아요. 테이블 하나, 조회는 키 등가 조회 하나 — + 조인도 범위 스캔도 없습니다. 이 단순함이 섹션 6에서 샤딩을 아주 쉽게 만들어 줘요. + 만료 처리는 지연 삭제(lazy)배치 청소를 겸용합니다. + 조회 순간에 만료 여부를 판정하니 정확성은 lazy가 보장하고, 디스크 공간 회수는 새벽 + 배치가 맡는 분업이에요. +

+
+ 커스텀 별칭이라는 복병 "tdl.ink/summer-sale처럼 + 원하는 이름을 쓰게 해 주세요"라는 요구가 오면 — 발급이 아니라 선점 + 문제가 됩니다. 중복 확인과 동시 요청 처리(유니크 제약 + 충돌 시 409 응답)가 필요해지죠. + 기능 한 줄이 데이터 모델에 어떤 파문을 일으키는지 감지하는 것도 설계 감각입니다. +
+
+ +
+

+ 섹션 2의 결론을 실행할 차례입니다. 초당 수천 건의 GET /{key}를 + DB가 직접 받게 둘 수는 없어요. 다행히 단축 URL 트래픽은 전형적인{' '} + 멱법칙 분포를 따릅니다 — 방금 SNS에 공유된 소수의 링크가 트래픽을 + 독식하고, 대부분의 링크는 잠들어 있죠. 상위 20% 키가 트래픽의 80%를 + 차지하니, 전체 1TB가 아니라 뜨거운 소수만 메모리에 올려도 적중률이 + 치솟습니다. +

+ {CODE_READPATH} +

+ 평화로운 시나리오만 있는 건 아닙니다. 누군가 존재하지 않는 키를 + 무작위로 대량 조회하면? 캐시엔 당연히 없으니 요청이 전부 DB로 관통(cache + penetration)합니다 — 캐시가 무용지물이 되고 DB부터 쓰러져요. 방어는 두 겹: + "없음"이라는 결과 자체를 짧게 캐시하는 네거티브 캐시, 그리고 "이 키는 + 확실히 없다"를 메모리에서 판정해 주는 블룸 필터입니다. 블룸 필터는 + '있다'는 답엔 오차가 있지만 '없다'는 답은 확실하다는 성질 덕분에, + 없는 키 폭격을 DB 앞에서 걸러 내는 데 딱 맞아요. +

+ {/* 학습 포인트: 최적화는 공짜가 아니다 — CDN 캐싱은 302로 확보한 '분석 가능성'과 + 정면충돌한다. 섹션 4의 결정이 섹션 5의 선택지를 제한하는 것, 이게 설계의 연쇄다. */} +

+ 더 극단으로 가면 CDN 레벨 리다이렉트가 있습니다. 리다이렉트 응답 + 자체를 엣지 서버에 캐시해서 요청이 우리 인프라에 도착조차 안 하게{' '} + 만드는 거죠. 다만 여기서 섹션 4의 결정이 발목을 잡습니다 — 302를 골라 클릭 분석을 + 하기로 했다면, 엣지에서 응답을 돌려보내는 순간 클릭이 안 잡혀요. 최적화 하나하나가 + 앞선 결정과 연쇄되어 있다는 것, 이게 시스템 설계의 실감입니다. +

+
+ 우리 스택과의 연결 이 캐시 우선 조회는 우리 플랫폼이 시세를 다루는 방식과 + 같은 문법이에요 — Valkey에 market:quote 캐시를 두고 + DB 아닌 메모리에서 응답하죠. "읽기가 지배하면 캐시를 앞세운다"는 원칙은 도메인이 + 바뀌어도 그대로입니다. +
+
+ +
+

+ 트래픽이 10배, 100배가 되면? 캐시로 버티다가 결국 DB를 쪼갤 때가 옵니다. 다행히 + 섹션 4에서 데이터 모델을 겸손하게 지어 둔 보상이 여기서 옵니다 — 조회가 전부{' '} + 키 등가 조회뿐이라, 앞 강좌에서 배운 샤드 키 논의를 교과서처럼 + 적용할 수 있어요. hash(key) % N으로 나누면 크로스 샤드 + 쿼리가 아예 존재하지 않습니다. +

+ {CODE_SCALE} +

+ 지리적 확장도 같은 논리로 풉니다. 읽기는 초당 수천 건이고 지연에 민감하니{' '} + 리전마다 복제본과 캐시를 두고 가까운 곳에서 응답합니다. 쓰기는 초당 + 12건으로 한가하니 단일 프라이머리로 몰아도 되고, 복제가 몇 초 늦어 + "방금 만든 링크가 지구 반대편에서 1초 늦게 열리는" 정도는 대개 수용 가능한{' '} + 결과 정합성이에요. 읽기와 쓰기의 요구 수준이 다르면, 아키텍처도 + 다르게 대접하는 겁니다. +

+

+ 마지막으로 가용성. 이 서비스의 무서운 점은 장애 반경이 자기 자신이 아니라는 + 것입니다. 단축기가 10분 죽으면, 그 10분 동안 전 세계 문서·메일·광고에 박힌{' '} + 모든 링크가 함께 죽어요. 그래서 SLO 논의에서 이 서비스는 유독 높은 + 기준을 요구받습니다 — 그리고 영리한 답은 읽기와 쓰기의 SLO를 분리하는 + 거예요. 링크 생성이 10분 안 되는 것과 리다이렉트가 10분 안 되는 것은 사고의 급이 + 다르니까, 읽기 경로(리다이렉트)만큼은 99.99%를 노리고 그쪽에 이중화를 몰아 줍니다. +

+
+ 숫자의 무게를 알기 99.9%는 월 43분, 99.99%는 월 4.3분의 장애 허용입니다. + 9 하나를 더 붙일 때마다 비용은 곱절로 뛰어요(다중 AZ → 다중 리전 → 자동 페일오버…). + "우리 서비스는 무조건 99.999%"라고 외치는 건 설계가 아니라 구호입니다 — 어느 경로에 + 어느 수준이 필요한지부터 말할 수 있어야 해요. +
+
+ +
+

+ 설계가 끝났다고 생각한 순간, 요구가 도착합니다 — "링크별 클릭 통계를 보여 + 주세요." 실무의 설계는 늘 이렇게 움직이는 표적이에요. 여기서 훈련할 것은 + 두 가지: 핵심 경로를 오염시키지 않고 기능을 얹는 법, 그리고 루틴의 + ①로 돌아가 요구사항을 재질문하는 습관입니다. +

+ {CODE_ANALYTICS} +

+ 동기 기록(후보 1)의 문제는 명확합니다. 리다이렉트라는 가장 빠르고 가장 높은 + SLO를 받는 경로에 DB 쓰기를 끼워 넣는 순간, 읽기 1,200/sec가 쓰기 1,200/sec로 + 둔갑하고 통계 DB가 느려지면 리다이렉트까지 함께 느려져요. 이벤트 발행(후보 2)은 그 + 결합을 끊습니다 — 리다이렉트는 즉시 응답하고, 클릭 이벤트만 Kafka에 던지면 소비자가 + 뒤에서 집계하죠. 우리 플랫폼이 감사 로그를 audit.events{' '} + 토픽으로 흘려보내는 것과 정확히 같은 패턴입니다. +

+

+ 다만 후보 2를 고르기 전에 반드시 물어야 합니다 — "통계는 실시간이어야 + 하나요?" 몇 분 늦어도 되는 대시보드라면 비동기 집계로 충분하지만, "지금 이 + 순간의 클릭"이 상품이라면 얘기가 달라지니까요. 기능 하나가 추가될 때마다 루틴의 ①로 + 짧게 돌아가는 것 — 이게 설계를 루틴이라고 부르는 이유입니다. 그리고 + 최종 그림을 보세요: 기존 설계는 거의 그대로고, 읽기 경로에 이벤트 발행 한 + 줄이 얹혔을 뿐입니다. 좋은 설계의 증거는 바로 이 변경의 작음이에요. +

+ {/* 학습 포인트: 실습의 핵심은 '완성'이 아니라 '방어'다. 트래픽 100배 추궁에 + "캐시 적중률·샤드 수·복제 구조가 어떻게 변하는가"를 숫자로 답할 수 있어야 한다. */} +
+ 실습 — 45분 화이트보드 타이머를 45분에 맞추고, 화이트보드(또는 excalidraw)에 + URL 단축기 전체 설계를 완성하세요 — 요구사항 가정, 용량 산정, API, 테이블, 키 발급, + 캐시, 샤딩까지 이 강좌의 루틴 순서대로. 끝나면 동료에게 부탁하세요: "트래픽이 + 100배가 되면?" 하고 추궁해 달라고. 캐시 적중률은? 샤드는 몇 개로? 프라이머리 + 쓰기는 버티나? — 숫자로 방어하지 못한 지점이 바로 다음에 공부할 지점입니다. +
+
+ +
+
+

🔗 여기까지 왔다면

+

+ 축하해요 — 설계 문제 하나를 요구사항부터 확장까지 처음부터 끝까지 + 완주했습니다. base62와 KGS, 301과 302, 캐시 관통과 블룸 필터 같은 부품도 얻었지만, + 진짜 수확은 루틴이에요. 어떤 설계 문제가 와도 "일단 질문부터, + 그다음 숫자로" 시작할 수 있는 순서 감각이요. 다만 URL 단축기는 읽기 편중에 + 상태가 단순한, 어찌 보면 '착한' 문제였습니다. 다음{' '} + 시스템 설계 훈련 2 — 실시간 채팅 + 시스템에서는 연결이 계속 살아 있고, 메시지 순서가 중요하고, 읽음 + 표시까지 얽히는 상태 가득한 문제로 같은 루틴을 밀어붙여 봅니다. +

+
+
+
+ ); +} diff --git a/frontend/src/pages/levels/Lv4TechLeadershipAdrPage.jsx b/frontend/src/pages/levels/Lv4TechLeadershipAdrPage.jsx new file mode 100644 index 0000000..63eea9c --- /dev/null +++ b/frontend/src/pages/levels/Lv4TechLeadershipAdrPage.jsx @@ -0,0 +1,527 @@ +// 이 파일이 하는 일: "기술 리더십과 ADR — 결정을 설계하고 기록하는 사람" 코스 — 특급 과정의 +// 마무리. 연차가 쌓이면 산출물이 코드에서 '결정'으로 바뀐다는 관점 전환에서 출발해, +// 좋은 결정의 조건, ADR(아키텍처 결정 기록), 설계 문서와 리뷰, 트레이드오프 커뮤니케이션, +// 기술 부채의 리더십, 주니어 성장 사다리, 그리고 수료 프로젝트까지 8개 섹션으로 안내한다. +// 프레이밍은 "Lv4 전 과정이 사실 결정 연습이었다" — CAP, 푸시/풀, 서비스 분리 같은 앞선 +// 강좌의 선택들을 이번엔 '기록하고 설득하는 기술'로 다시 본다. +// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의 +// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지). +// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다. + +import { Link } from 'react-router-dom'; + +// ── 예제 코드 상수들 ── + +const CODE_OUTPUT_SHIFT = `주니어 ──────────── 미들 ──────────── 시니어/리드 +"내 코드" "우리 모듈" "팀의 결정" + +산출물: PR, 기능 산출물: 설계, 리뷰 산출물: 결정, 문서, 사람 +성공지표: 머지 수 성공지표: 장애 감소 성공지표: 팀 전체의 속도 + +핵심 전환: 연차가 쌓일수록 '내가 짠 코드'보다 +'내가 내린(그리고 기록한) 결정'이 조직에 더 오래 남는다. +결정은 코드보다 수명이 길다 — 코드는 리팩터링되지만, +"왜 이렇게 갔는가"는 3년 뒤 신규 입사자의 첫 질문이 된다.`; + +const CODE_DOORS = `결정을 내리기 전에 먼저 물을 것: "이 문은 몇 방향인가?" + +양방향 문 (two-way door) 일방향 문 (one-way door) +───────────────────── ───────────────────── +되돌리기 쉽다 되돌리기 어렵다/비싸다 +예: 라이브러리 선택, 예: DB 엔진 교체, 공개 API 계약, + 내부 함수 시그니처, 데이터 스키마의 대규모 변경, + feature flag 뒤의 실험 서비스 경계(모노리스→MSA) + +→ 빠르게 결정하고 시도한다 → 문서 쓰고, 대안 비교하고, + 틀리면 되돌리면 된다 리뷰 받고, 천천히 통과한다 + +흔한 실수: 모든 결정을 일방향 문처럼 다뤄 팀이 느려지거나, +일방향 문을 양방향 문처럼 통과해 3년짜리 부채를 만드는 것.`; + +const CODE_ADR = `# ADR-007: 실시간 시세 배포를 Valkey Pub/Sub으로 결정 + +## 상태 (Status) +승인됨 (2026-05-02) + +## 맥락 (Context) +- 시세 틱은 초당 수백 건, 구독자(SSE 커넥션)는 수천 개까지 예상. +- 유실 허용: 틱 하나를 놓쳐도 다음 틱이 0.5초 안에 온다(최신값만 의미 있음). +- 이미 감사 이벤트용으로 Kafka가, 캐시용으로 Valkey가 스택에 존재. + +## 결정 (Decision) +시세 fan-out은 Valkey Pub/Sub 채널(market.ticks)로 나른다. +market 서비스가 발행하고, 각 인스턴스의 스트림 서비스가 구독해 +SSE로 클라이언트에 흘린다. 같은 데이터를 Kafka로 중복 발행하지 않는다. + +## 기각한 대안 (Alternatives Considered) +1. Kafka 토픽으로 배포 + - 장점: 재생(replay) 가능, 유실 없음, 이미 운영 중. + - 기각 이유: 시세는 재생이 무의미(옛 가격은 가치 없음)하고, + 컨슈머 그룹 리밸런싱 지연이 실시간성에 불리. 과잉 보장. +2. 서비스 간 직접 HTTP 콜백 + - 장점: 인프라 추가 없음. + - 기각 이유: 구독자 수만큼 연결 관리 코드가 늘고, + 인스턴스 증설 시 라우팅이 지옥이 됨. + +## 결과 (Consequences) +- (+) 지연 최소, 코드 단순, 인프라 재사용. +- (-) Valkey 재시작 시 그 순간의 틱은 유실 — 감내하기로 함. +- (-) "유실 감내"라는 전제가 깨지는 데이터(주문 체결 등)에는 + 이 채널을 절대 재사용하지 말 것. → 그건 Kafka로.`; + +const CODE_SUPERSEDE = `# ADR은 불변 기록 — 뒤집을 때의 규칙 + +잘못된 방법: + ADR-007 파일을 열어 내용을 고쳐 쓴다 ✗ + → "그때 왜 그렇게 판단했는지"라는 역사가 증발한다. + +올바른 방법: + 1. 새 파일 ADR-019를 쓴다: "시세 배포를 XXX로 변경" + 2. ADR-019의 맥락에 "무엇이 달라져서 재검토하는지"를 적는다 + (예: 구독자 10배 증가, 유실 허용 전제가 깨짐) + 3. ADR-007의 상태만 바꾼다: + 승인됨 → 대체됨 (Superseded by ADR-019) + +이렇게 하면 저장소에 '결정의 지층'이 쌓인다. +git log가 코드의 역사이듯, ADR 디렉터리는 판단의 역사다.`; + +const CODE_DESIGN_DOC = `# 디자인 독(Design Doc) 표준 골격 + +## 1. 배경 (Background) + 왜 지금 이 문제인가. 읽는 사람이 사전지식 없이도 따라오게. + +## 2. 목표 / 비목표 (Goals / Non-Goals) + 목표: 이번에 해결할 것. + 비목표: 일부러 안 하는 것 ← 여기가 진짜 중요. + "다국어 지원은 이번 범위에서 제외한다"라고 적어 두면 + 리뷰가 산으로 가는 걸 막고, 나중의 오해도 막는다. + +## 3. 제안 (Proposed Design) + 아키텍처 다이어그램, 데이터 흐름, API 계약, 스키마. + +## 4. 대안 (Alternatives) + 최소 1~2개. "대안이 없다"는 대개 "안 찾아봤다"는 뜻. + +## 5. 리스크와 완화 (Risks & Mitigations) + 실패 모드, 마이그레이션 경로, 롤백 계획. + +리뷰의 목적: 승인 도장이 아니라 '결함을 싸게 찾는 것'. +코드가 되기 전의 결함은 문장 몇 줄 고치면 끝나지만, +배포된 뒤의 결함은 장애 대응 + 마이그레이션 비용이 된다.`; + +const CODE_TRANSLATE = `상황: 경영진에게 "리프레시 토큰 저장 방식 개선"을 설득해야 한다 + +개발자의 언어 (전달 실패) + "localStorage 대신 httpOnly 쿠키로 옮기고 + 토큰 로테이션을 도입해야 합니다. XSS에 취약해서요." + → 듣는 사람: (XSS가 뭐지... 급한 건가?) + +리스크·비용·시간의 언어 (전달 성공) + "지금 구조에선 악성 스크립트 하나가 유출되면 + 고객 계정 전체가 탈취될 수 있습니다. ← 리스크 + 금융 데이터 서비스라 사고 시 계약 해지 사유가 되고요. + 고치는 데 개발 2주, 사용자 영향은 재로그인 1회입니다. ← 비용·시간 + 출시 전인 지금이 가장 싸게 고칠 수 있는 시점입니다." + +번역 공식: 기술 용어 → 그것이 실현됐을 때의 +사업적 결과(돈, 신뢰, 시간)로 바꿔 말한다.`; + +const CODE_DEBT = `기술 부채 = 대출. 문제는 빚 자체가 아니라 '이자율 관리'다. + +의도적 부채 (전략적 대출) 무지성 부채 (카드 돌려막기) +────────────────────── ────────────────────── +"출시가 8월 말이라 지금은 "그냥 몰라서 / 귀찮아서 + ddl-auto: update로 가고, 테스트 없이 복붙했다. + Flyway는 출시 후 1순위로 도입한다" 기록도 없다." + +- 무엇을 미뤘는지 안다 - 미뤘다는 인식조차 없다 +- 갚을 시점이 로드맵에 있다 - 어느 날 장애로 청구서가 온다 +- ADR/이슈로 기록돼 있다 - 원인 추적에 며칠을 쓴다 + +부채 상환을 로드맵에 올리는 법 — 사업 언어로 이자 증명: + "이 모듈 때문에 기능 하나당 이틀씩 더 듭니다. + 분기당 개발 3주치가 이자로 나가는 셈입니다. + 2주 투자하면 이 이자가 사라집니다."`; + +const CODE_LADDER = `주니어 성장 사다리 — 관찰에서 소유로 + +1단계 코드 리뷰 받기 → 피드백을 '받는' 훈련 +2단계 코드 리뷰 하기 → 남의 코드에서 의도를 읽는 훈련 +3단계 온콜 섀도잉 → 장애 대응을 '옆에서' 경험 (책임 없이) +4단계 설계 리뷰 참관 → 결정이 만들어지는 현장을 구경 +5단계 작은 설계 소유 → 디자인 독을 직접 쓰고 리뷰 받기 +6단계 온콜 주인공 + 백업 → 책임을 지되, 뒤에 안전망이 있게 + +위임의 기술: 답이 아니라 '결정 프레임'을 준다. + 나쁜 위임: "그건 network-first로 하세요." (답 제공 → 성장 0) + 좋은 위임: "이 데이터는 유실돼도 되나요? 최신성과 정확성 중 + 뭐가 더 중요하죠? 그 답에 따라 전략이 갈립니다." + (프레임 제공 → 다음엔 스스로 결정한다)`; + +const CODE_PORTFOLIO = `수료 프로젝트: 나의 아키텍처 포트폴리오 + +① ADR 묶음 (Lv4 전 과정 소급 정리) + - 최소 3편: 시세 배포 채널, 서비스 경계, 캐시 전략 등 + - 각 ADR에 기각한 대안 2개 이상 + 기각 이유 필수 + - 형식: 맥락 → 결정 → 대안 → 결과 + +② 가상 신규 서비스 디자인 독 1편 + - 예: "알림 집계 서비스", "백테스트 잡 러너" + - 배경 / 목표·비목표 / 제안 / 대안 / 리스크 골격 준수 + - 동료(또는 멘토)에게 리뷰 세션 1회 — 코멘트를 문서에 반영 + +③ 다음 성장 방향 한 쪽짜리 회고 + - 도메인 깊이 vs 폭? IC 트랙 vs 리드 트랙? + - 정답 없음. 단, '왜'를 스스로 설명할 수 있을 것. + +제출물은 전부 markdown — 코드 한 줄 없는 과제지만, +이것이 시니어의 산출물이 어떤 모양인지에 대한 첫 경험이다.`; + +// ── 이 페이지 안에서만 쓰는 작은 부품들 ── +// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용. +function Section({ n, title, sub, children }) { + return ( +
+
+ {n} +
+

{title}

+ {sub && {sub}} +
+
+
{children}
+
+ ); +} + +function Code({ children }) { + return
{children}
; +} + +const SECTIONS = [ + { n: 1, label: '코드에서 결정으로' }, + { n: 2, label: '좋은 결정의 조건' }, + { n: 3, label: 'ADR' }, + { n: 4, label: '설계 문서와 리뷰' }, + { n: 5, label: '트레이드오프 설득' }, + { n: 6, label: '부채의 리더십' }, + { n: 7, label: '주니어 키우기' }, + { n: 8, label: '수료 프로젝트' }, +]; + +export default function Lv4TechLeadershipAdrPage() { + return ( +
+ {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */} +
+
Course · 특급 과정
+

🧭 기술 리더십과 ADR
— 결정을 설계하고 기록하는 사람

+

+ 특급 과정의 마무리입니다. 지금까지 CAP과 파티셔닝, 푸시와 풀, 서비스를 쪼갤까 말까를 + 배웠다면 — 사실 그 전부가 결정 연습이었어요. 이번 코스에선 그 결정을{' '} + 기록하는 법(ADR), 검증받는 법(설계 리뷰),{' '} + 설득하는 법(트레이드오프 커뮤니케이션)을 익힙니다. 코드를 잘 짜는 + 개발자에서, 팀을 움직이는 개발자로 넘어가는 마지막 다리예요. +

+
+ 예상 소요 70분 + 실습: traderbox 결정 1개를 정식 ADR로 + 대상: 대규모 시스템을 설계하는 실무 개발자 +
+
+ + {/* 섹션 바로가기 */} + + +
+

+ 주니어 시절의 산출물은 명확해요. 머지된 PR이죠. 그런데 연차가 쌓이면 + 이상한 일이 생깁니다. 코드는 예전보다 덜 짜는데, 팀에 미치는 영향은 더 커져요. + 산출물이 코드에서 결정과 문서와 사람으로 바뀌기 때문입니다. + "이 데이터는 어느 채널로 나를까", "이 서비스는 쪼갤까", "이 부채는 언제 갚을까" — + 이런 결정 하나가 주니어 다섯 명의 한 달을 좌우해요. +

+ {CODE_OUTPUT_SHIFT} +

+ 여기서 흔한 오해 하나를 걷어냅시다. 기술 리더십은 관리직이 아니에요.{' '} + 사람을 평가하고 일정을 관리하는 매니저 트랙과 별개로, 코드와 설계로 영향력을 넓히는{' '} + IC(Individual Contributor) 트랙이 있습니다. 스태프·프린시펄 엔지니어가 + 그 길이에요. 팀장이 되지 않아도, 좋은 결정을 내리고 그것을 팀이 따라올 수 있게 만드는 + 사람은 이미 기술 리더입니다. +

+

+ 그리고 눈치채셨겠지만 — Lv4 전 과정이 사실 결정 연습이었어요. CP를 + 택할까 AP를 택할까(일관성 강좌), 푸시로 나를까 풀로 나를까(실시간 데이터 강좌), + 서비스를 쪼갤까 말까(MSA 강좌). 전부 "정답이 뭐냐"가 아니라 "이 제약에선 뭐가 + 덜 나쁘냐"를 묻는 문제였죠. 이번 코스는 그 근육에 기록과 설득이라는 + 마지막 두 동작을 붙입니다. +

+
+ 자기 점검 최근 한 달간 내가 한 일 중, 코드가 아닌 산출물(리뷰 코멘트, 설계 제안, + 누군가의 질문에 답한 문서)이 몇 개였는지 세어 보세요. 0개라면 아직 기회를 못 만난 것이고, + 여러 개라면 — 이미 이 코스가 말하는 전환이 시작된 겁니다. +
+
+ +
+

+ 학교 시험엔 정답이 있지만 아키텍처엔 없어요. 있는 건 제약(시간, 인원, + 트래픽, 돈)과 그 아래에서의 트레이드오프 선택뿐입니다. "Kafka가 + Valkey Pub/Sub보다 좋은가요?"는 성립하지 않는 질문이에요. "유실을 감내할 수 있고 + 지연이 생명인 시세 틱에는 어느 쪽이 덜 나쁜가요?"가 성립하는 질문이죠. +

+

+ 그렇다면 모든 결정에 똑같은 신중함을 쏟아야 할까요? 아니요. 결정을 나누는 유용한 + 기준이 "이 문은 몇 방향인가"입니다. +

+ {CODE_DOORS} +

+ 양방향 문(되돌리기 쉬운 결정)은 빠르게 통과하세요. 라이브러리 하나 + 고르는 데 회의 세 번을 하는 팀은 그 자체로 병목입니다. 반대로 일방향 문 + (되돌리기 어려운 결정 — DB 교체, 공개 API 계약, 서비스 경계)은 문서를 쓰고 대안을 + 비교하고 리뷰를 받으며 천천히 통과해야 해요. 결정의 무게에 맞는 절차를 고르는 것, + 그게 시니어의 감각입니다. +

+

+ 하나 더 — '지금 규모'와 '3년 뒤 규모'를 분리해서 판단하세요. "나중에 + 사용자가 100만이 되면 필요할 테니 지금 샤딩을 넣자"는 과잉 설계의 전형이에요. 3년 뒤 + 규모는 3년 뒤의 팀이, 3년치의 데이터를 갖고 더 잘 결정합니다. 지금 할 일은 미래의 + 확장을 막지 않는 구조(양방향 문으로 남겨 두기)까지지, 미래를 미리 + 구현하는 게 아니에요. +

+
+ 우리 코드베이스에서 traderbox가 Flyway 없이 ddl-auto: + update로 달리는 건 골격 단계의 의도된 선택이에요(빠른 반복이 + 우선). 다만 이건 일방향 문에 가까워지는 중입니다 — 운영 데이터가 쌓일수록 마이그레이션 + 도구 없는 스키마 변경의 위험이 커지니까요. "언제 이 결정을 재검토할 것인가"까지 정해 + 두는 것이 좋은 결정의 마지막 조각입니다. +
+
+ +
+

+ ADR(Architecture Decision Record)은 중요한 기술 결정 하나를 짧은 + 문서 하나로 남기는 관행이에요. 구조는 단순합니다. 맥락(Context) — + 어떤 제약과 상황이었나 → 결정(Decision) — 무엇을 하기로 했나 →{' '} + 결과(Consequences) — 이 선택으로 무엇을 얻고 무엇을 포기했나. + 여기에 기각한 대안과 그 이유를 더하면 완성이에요. +

+

+ traderbox의 실제 결정 하나를 ADR로 옮겨 볼게요. "시세는 Valkey Pub/Sub, 감사 이벤트는 + Kafka, 같은 데이터를 두 채널로 흘리지 않는다" — 지금은 컨벤션 문서에 한 줄로만 남아 + 있는 이 암묵적 결정을, 정식 기록으로 소급해 보면 이렇습니다. +

+ {CODE_ADR} +

+ 주목할 곳은 결정 부분이 아니라 기각한 대안이에요. "Kafka도 검토했지만 + 시세는 재생이 무의미해서 기각했다"는 한 줄이, 3년 뒤 "왜 이거 Kafka로 안 했어요?"라고 + 물을 신규 입사자의 하루를 아껴 줍니다. 결정만 적힌 문서는 결과물이고, 대안까지 적힌 + 문서는 사고 과정의 재생 장치예요. 같은 논쟁을 반복하지 않게 해 주죠. +

+

+ 그리고 ADR엔 중요한 규칙이 하나 있어요. 불변 기록이라는 것. + 상황이 바뀌어 결정을 뒤집게 되더라도 옛 ADR을 고쳐 쓰지 않습니다. +

+ {CODE_SUPERSEDE} +
+ 실습 예고 이 코스의 실습이 바로 이거예요. traderbox의 실제 결정 하나(시세 채널, + 스키마-퍼-서비스, 게이트웨이 없는 서비스별 JWT 검증 중 택 1)를 골라 대안 + 2개와 함께 정식 ADR로 작성하고 팀 리뷰를 받습니다. 코드 0줄짜리 과제지만, + 아마 이 과정에서 가장 오래 고민하게 될 거예요. +
+
+ +
+

+ ADR이 결정 하나의 기록이라면, 디자인 독(Design Doc)은 결정에 + 도달하기 위한 사전 문서예요. 만들기 전에 쓰고, 리뷰받고, 그 결론이 + ADR로 남는 흐름이죠. 표준 골격은 이렇습니다. +

+ {CODE_DESIGN_DOC} +

+ 골격에서 가장 저평가되는 칸이 비목표(Non-Goals)예요. "이번에 안 하는 + 것"을 명시하면 리뷰가 "그런데 다국어는요?" 같은 범위 밖 논쟁으로 새는 걸 막고, + 출시 후 "이거 왜 안 됐어요?"라는 오해도 막습니다. 범위를 좁힌 걸 사과하지 마세요 — + 좁힌 범위를 선언하는 게 좋은 설계 문서입니다. +

+

+ 그리고 설계 리뷰의 본질. 리뷰는 승인 절차가 아니라 결함을 싸게 찾는 + 장치예요. 문서 단계의 결함은 문장 몇 줄로 고치지만, 배포 후의 결함은 장애 + 대응과 데이터 마이그레이션 비용으로 갚습니다. 리뷰어가 구멍을 찾아 줬다면 그건 공격이 + 아니라 가장 싼 시점에 도착한 버그 리포트예요. +

+

+ 좋은 리뷰어의 질문법도 있어요. "왜 안 했나"보다 "어떤 제약이 있었나"를 + 물으세요. "왜 Kafka 안 썼어요?"는 심문처럼 들리지만, "시세 쪽에 어떤 제약이 있어서 + Pub/Sub으로 가셨어요?"는 작성자가 사고 과정을 펼쳐 보이게 합니다. 같은 정보를 얻으면서 + 방어 대신 설명을 끌어내는 질문이 리뷰 문화를 만들어요. +

+
+ 리뷰 받는 쪽의 기술 문서 첫머리에 "특히 봐 줬으면 하는 곳"을 두세 개 적어 두세요. + "대안 2를 기각한 논리가 약한 것 같은데 검증 부탁", "마이그레이션 경로가 현실적인지" — + 리뷰어의 화력을 내가 불안한 곳에 집중시키는 것도 작성자의 역량입니다. +
+
+ +
+

+ 아무리 옳은 결정도 전달에 실패하면 실행되지 않아요. 특히 듣는 사람이 + 비개발자(경영진)라면, 기술 용어는 소음입니다. 통하는 언어는 셋뿐이에요 —{' '} + 리스크, 비용, 시간. +

+ {CODE_TRANSLATE} +

+ 다음은 반대 의견 다루기입니다. 설계 리뷰에서 내 의견이 기각됐어요. + 이때의 프로토콜이 disagree and commit(반대하되 헌신한다)입니다. + 실제 작동은 이래요: 결정 에는 반대 근거를 끝까지, 문서로 남기며 + 주장합니다. 결정이 내려진 후에는 회의실 밖에서 "난 반대했는데"를 + 반복하지 않고, 그 결정이 성공하도록 전력으로 돕습니다. 단, 내 반대 근거는 ADR의 + 대안 칸에 남아 있죠 — 만약 예측대로 문제가 생기면 "거봐요"가 아니라 그 기록을 근거로{' '} + 재검토를 제안하면 됩니다. 반대가 기록되니 승복이 굴복이 아니게 돼요. +

+

+ 마지막으로, 신뢰 자본 이야기. '모른다'와 '틀렸다'를 빨리 인정하는 사람이 + 장기적으로 가장 설득력이 셉니다. 역설 같지만 원리는 단순해요 — 모르는 걸 + 모른다고 말하는 사람의 "이건 확실합니다"는 액면가로 믿을 수 있거든요. 모든 질문에 + 그럴듯한 답을 지어내는 사람은 어느 순간부터 맞는 말도 검증 대상이 됩니다. 인정의 + 속도가 곧 다음 발언의 이자율이에요. +

+
+ 연습 문제 "SSE 경로에 gzip 압축을 켜면 안 됩니다"를 경영진 언어로 번역해 + 보세요. 힌트: 압축·버퍼링이라는 단어 없이, "실시간 시세가 화면에서 멈추는 장애가 + 납니다. 고객이 가장 먼저 알아채는 유형의 장애입니다"처럼 — 결과에서 출발하는 겁니다. +
+
+ +
+

+ 시니어가 되면 물려받는 것이 하나 있어요. 남이 만든 부채입니다. + 여기서 첫 번째 관점 교정 — 기술 부채는 악이 아니에요. 대출입니다. + 출시를 앞당기려고 미래의 시간을 당겨 쓰는 것, 사업에선 정상적인 금융 활동이죠. + 문제는 빚을 졌다는 사실이 아니라 이자율을 아무도 관리하지 않는 상태예요. +

+ {CODE_DEBT} +

+ 핵심 구분은 의도적 부채 vs 무지성 부채입니다. "8월 말 출시가 우선이라 + Flyway는 출시 후 도입한다"는 의도적 부채예요 — 무엇을 미뤘는지 알고, 기록이 있고, + 갚을 시점이 로드맵에 있죠. 반면 몰라서·귀찮아서 쌓인 부채는 기록이 없어 어느 날 + 장애라는 청구서로 돌아옵니다. 리더의 일은 부채를 0으로 만드는 게 아니라, + 모든 부채를 의도적 부채로 전환하는 것(찾아내고, 기록하고, 이자를 + 측정하는 것)이에요. +

+

+ 부채 상환을 로드맵에 올리려면 섹션 5의 번역 기술이 필요합니다. "리팩터링 하고 + 싶어요"는 영원히 우선순위에서 밀려요. "이 모듈 때문에 기능 하나당 이틀씩 + 더 듭니다 — 분기당 3주치 개발력이 이자로 나갑니다"는 로드맵에 올라갑니다. + 부채의 이자를 사업 언어(시간, 돈, 장애 위험)로 측정해 보여 주는 것, 그게 상환 예산을 + 따내는 유일한 방법이에요. +

+

+ 그리고 뜻밖의 부채 하나 — 문서 드리프트. 문서가 현실과 어긋난 채 + 방치되면, 읽는 사람마다 잘못된 판단을 하게 만드는 이자가 붙어요. 우리 저장소의 + CLAUDE.md가 "README·STRUCTURE는 서비스별 독립 repo를 전제하나 현재는 단일 + 모노레포다"라고 불일치 자체를 기록해 둔 것은 그래서 가치 있는 + 관행입니다. 문서를 다 못 고치더라도, "여기부터는 현실과 다르다"는 정직한 표지판 + 하나가 이자를 크게 낮춰요. +

+
+ 이자 감각 기르기 이번 주에 "이거 원래 이래요"라는 말을 들었다면 그게 부채 + 후보예요. 왜 그런지 아무도 설명 못 하면 무지성 부채, 설명과 상환 계획이 있으면 + 의도적 부채. 후보를 발견할 때마다 이슈 하나로 기록하는 습관이 이자율 관리의 시작입니다. +
+
+ +
+

+ 팀의 처리량을 두 배로 만드는 방법은 둘이에요. 내가 두 배로 일하거나(불가능),{' '} + 옆 사람이 성장하거나. 그래서 주니어를 키우는 건 시니어의 선택 과목이 + 아니라 핵심 업무입니다. 관건은 성장을 우연에 맡기지 않고 사다리로 설계 + 하는 거예요. +

+ {CODE_LADDER} +

+ 사다리의 원리는 관찰 → 참여 → 소유의 점진적 이행이에요. 온콜을 예로 + 들면, 처음부터 새벽 알람의 주인공을 시키는 게 아니라 섀도잉(책임 없이 + 옆에서 대응 과정을 지켜보기)부터, 다음엔 백업이 있는 상태의 주인공으로. 설계도 + 마찬가지 — 리뷰 참관으로 결정이 만들어지는 현장을 먼저 보여 준 뒤, + 작은 설계의 소유를 맡깁니다. +

+

+ 위임할 때 가장 흔한 실수가 답을 줘 버리는 것이에요. "그건 이렇게 + 하세요"는 당장 빠르지만 성장이 0이고, 다음에도 같은 질문이 돌아옵니다. 대신{' '} + 결정 프레임을 주세요 — "이 데이터는 유실돼도 되나요? 그 답에 따라 + 전략이 갈려요." 프레임을 받은 사람은 다음번엔 스스로 결정합니다. 시간이 두 배 들지만, + 그 시간은 비용이 아니라 투자예요. +

+

+ 이 모든 것의 토대가 심리적 안전감입니다. 질문했다고 "그것도 몰라요?"가 + 돌아오고, 장애를 공유했다고 벌점이 되는 팀에선 사다리가 작동하지 않아요 — 모르는 것을 + 숨기고, 사고를 늦게 보고하게 되니까요. 숨겨진 무지와 늦은 보고는 반드시 더 큰 장애로 + 돌아옵니다. 시니어가 먼저 "이건 제가 모르겠네요", "이번 장애는 제 판단 미스였어요"라고 + 말하는 팀에서만, 주니어도 안전하게 모르고 안전하게 실패할 수 있어요. 섹션 5의 신뢰 + 자본이 여기서도 같은 원리로 작동합니다. +

+
+ 질문을 대하는 태도 주니어의 질문 하나에 "좋은 질문이에요, 왜냐하면"으로 시작해 + 보세요. 그 질문이 문서 부재를 드러냈다면(대개 그렇습니다) 답을 준 뒤 "이거 문서에 + 없네요 — 같이 한 단락 추가할까요?"까지. 질문이 문서가 되는 순환이 만들어지면, 같은 + 질문이 팀에서 두 번 소비되지 않습니다. +
+
+ +
+

+ 특급 과정의 마지막 과제입니다. 이번엔 코드를 제출하지 않아요. 제출물은 전부{' '} + 문서 — 섹션 1에서 말한 "시니어의 산출물"을 직접 만들어 보는 겁니다. +

+ {CODE_PORTFOLIO} +

+ ① ADR 묶음은 Lv4 전 과정에서 만난 설계 결정들을 소급 기록하는 + 작업이에요. 시세 배포 채널(이 코스의 예제를 그대로 쓰지 말고 본인의 언어로 다시 + 쓰세요), 스키마-퍼-서비스, 서비스별 JWT 검증 — 어느 것이든 좋습니다. 채점 기준은 + 하나예요: 기각한 대안이 설득력 있게 적혔는가. +

+

+ ② 디자인 독은 존재하지 않는 가상 서비스를 하나 설계하는 과제입니다. + 핵심은 완벽한 설계가 아니라 리뷰 세션이에요 — 동료의 코멘트를 받고, + 방어하고 싶은 마음을 누르고, 문서에 반영하는 그 한 사이클이 이 과제의 본체입니다. +

+

+ ③ 성장 방향 회고엔 정답이 없어요. 한 도메인을 깊게 팔지(깊이) 여러 + 도메인을 넘나들지(폭), IC로 남을지 리드로 갈지 — 지금의 답이 3년 뒤에 바뀌어도 + 됩니다. 어차피 그 회고도 나중에 superseded 처리하고 새로 쓰면 되니까요. 중요한 건{' '} + '왜'를 스스로 설명할 수 있는 상태로 다음 단계를 시작하는 것입니다. +

+
+ 이 과제가 어렵게 느껴진다면 정상이에요. 코드는 컴파일러가 틀렸다고 말해 주지만, + 결정 문서는 아무도 정답을 확인해 주지 않거든요. 그 불확실함 속에서 근거를 쌓아 판단하고, + 기록을 남겨 검증 가능하게 만드는 것 — 그게 정확히 이 코스가, 그리고 특급 과정 전체가 + 연습시키려던 능력입니다. +
+
+ + {/* 마무리 카드 */} +
+
+

🧭 특급 과정을 마치며

+

+ 여기까지 오셨다면 이제 결정을 내리고(트레이드오프), 기록하고(ADR), + 검증받고(설계 리뷰), 설득하는(리스크·비용·시간의 언어) 한 사이클을 손에 + 쥐었습니다. 대규모 시스템을 다루는 기술의 끝이 결국 사람과 기록의 문제로 수렴한다는 + 것 — 그게 특급 과정이 마지막에 남기고 싶은 이야기였어요. 수료 프로젝트의 ADR 묶음을 + 완성했다면, 학습 센터로 돌아가 전체 과정을 + 돌아보고 다음 성장 방향을 정해 봅시다. 축하합니다 — 이제 결정을 설계하는 + 사람입니다. +

+
+
+
+ ); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 2d04040..4e7f5ca 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -152,6 +152,24 @@ a { white-space: nowrap; } +/* 학습 과정(수습용/초급~특급) 선택 셀렉트 — 헤더 톤에 맞춘 작은 셀렉트 박스 */ +.topnav-track { + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--bg); + color: var(--ink); + font: inherit; + font-size: 13.5px; + font-weight: 600; + cursor: pointer; +} + +.topnav-track:focus { + outline: 2px solid var(--primary-soft); + border-color: var(--primary); +} + .topnav-name { font-weight: 600; color: var(--ink); diff --git a/frontend/src/trackContext.jsx b/frontend/src/trackContext.jsx new file mode 100644 index 0000000..5d19e35 --- /dev/null +++ b/frontend/src/trackContext.jsx @@ -0,0 +1,45 @@ +// 이 파일이 하는 일: "지금 어느 과정(수습용/초급/중급/고급/특급)을 보고 있나"를 +// 앱 전체가 공유하는 컨텍스트. 헤더의 셀렉트 박스가 값을 바꾸고, 학습 센터가 값을 읽는다. +// +// 학습 포인트 ① — 왜 컨텍스트인가? +// 셀렉트 박스는 헤더(Layout)에 있고, 그 값을 쓰는 화면은 학습 센터(LearnHubPage)다. +// 둘은 부모-자식이 아니라서 props로 전달하려면 중간 계층을 전부 거쳐야 한다(prop drilling). +// 컨텍스트는 "전역 게시판"처럼 어디서든 읽고 쓸 수 있게 해 준다. +// +// 학습 포인트 ② — localStorage로 기억하기. +// 새로고침해도 선택한 과정이 유지되도록 localStorage에 저장한다. +// (서버에 저장할 수도 있지만, "보고 있는 과정"은 기기별 취향이라 브라우저 저장이 알맞다.) +import { createContext, useContext, useState } from 'react'; + +/** 과정(트랙) 목록 — 셀렉트 박스의 선택지. key는 저장값, label은 표시명. */ +export const TRACK_OPTIONS = [ + { key: 'trainee', label: '수습용' }, + { key: 'lv1', label: '초급' }, + { key: 'lv2', label: '중급' }, + { key: 'lv3', label: '고급' }, + { key: 'lv4', label: '특급' }, +]; + +const STORAGE_KEY = 'mirim.track'; + +const TrackContext = createContext({ track: 'trainee', setTrack: () => {} }); + +export function TrackProvider({ children }) { + const [track, setTrackState] = useState(() => { + // 저장된 값이 유효한 트랙일 때만 복원 — 이상한 값이 들어 있어도 앱이 안 깨진다. + const saved = localStorage.getItem(STORAGE_KEY); + return TRACK_OPTIONS.some((t) => t.key === saved) ? saved : 'trainee'; + }); + + function setTrack(next) { + setTrackState(next); + localStorage.setItem(STORAGE_KEY, next); + } + + return {children}; +} + +/** 어디서든 현재 과정을 읽고 바꾸는 훅. */ +export function useTrack() { + return useContext(TrackContext); +} diff --git a/frontend/src/trackContext.test.jsx b/frontend/src/trackContext.test.jsx new file mode 100644 index 0000000..6a020e1 --- /dev/null +++ b/frontend/src/trackContext.test.jsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { TrackProvider, useTrack, TRACK_OPTIONS } from './trackContext.jsx' + +// 이 파일이 하는 일: 과정 선택 컨텍스트의 규칙을 검증한다 — +// 기본값, localStorage 복원, 잘못된 저장값 방어, 변경 시 저장. + +// 컨텍스트를 읽고 바꾸는 최소 소비자 컴포넌트. +function Probe() { + const { track, setTrack } = useTrack() + return ( +
+ {track} + +
+ ) +} + +beforeEach(() => { + localStorage.clear() +}) + +describe('trackContext', () => { + it('과정 선택지는 수습용+초급~특급 5개다', () => { + expect(TRACK_OPTIONS.map((t) => t.label)).toEqual(['수습용', '초급', '중급', '고급', '특급']) + }) + + it('저장된 값이 없으면 수습용(trainee)이 기본이다', () => { + render() + expect(screen.getByTestId('track')).toHaveTextContent('trainee') + }) + + it('localStorage에 저장된 과정을 복원한다', () => { + localStorage.setItem('mirim.track', 'lv3') + render() + expect(screen.getByTestId('track')).toHaveTextContent('lv3') + }) + + it('저장값이 이상한 값이면 수습용으로 안전하게 폴백한다', () => { + // 학습 포인트: localStorage는 사용자가 임의로 고칠 수 있는 저장소다 — 항상 검증하고 쓴다. + localStorage.setItem('mirim.track', 'hacker-value') + render() + expect(screen.getByTestId('track')).toHaveTextContent('trainee') + }) + + it('과정을 바꾸면 상태와 localStorage 둘 다 갱신된다', () => { + render() + fireEvent.click(screen.getByRole('button', { name: '중급으로' })) + expect(screen.getByTestId('track')).toHaveTextContent('lv2') + expect(localStorage.getItem('mirim.track')).toBe('lv2') + }) +})