📚 취업아카데미 · 웹 개발 복습 노트

웹 개발 학습 노트

내가 실습한 코드를 바탕으로 한 줄 요약 → 쉽게 말하면(비유) → 개념 설명 → 주석 달린 코드 → 핵심 정리 순서로 다시 공부하는 복습 노트입니다. 처음에는 카드가 요약만 보이게 접혀 있어요 — 훑어보다가 궁금한 단원만 펼쳐서 깊이 읽으면 됩니다.

마지막 업데이트: 2026-08-03 HTML 7개 단원 CSS 12개 단원 JavaScript 18개 단원 React 14개 단원 + 🎓 졸업 과제 🧭 학습 여정 4/4과목 기초 개념 사전 39개 항목 ✏️ 시험 대비 83문제 강의자료·정리 문서 7건
☕ Java 백엔드 학습 노트로 이동 →
7
HTML 단원
12
CSS 단원
18
JavaScript 단원
14
React 단원
4/4
🧭 학습 여정 과목
39
기초 개념 사전 항목
0/0
학습 완료 진도 (0%)

🎯 오늘의 복습 추천

💾 내 진도 백업

완료 체크·즐겨찾기·메모·복습 기록은 이 브라우저에만 저장됩니다. 캐시를 지우거나 다른 기기로 옮기면 사라지니, 가끔 내보내기로 백업해 두고 필요할 때 가져오기로 복원하세요.

📖 이 노트로 복습하는 방법

  1. 카드는 처음에 접혀 있고 한 줄 요약쉽게 말하면(일상 비유)만 보입니다. 이 두 줄만 쭉 훑어도 전체 흐름이 복습돼요.
  2. 더 알고 싶은 단원은 펼쳐서 공부하기를 누르세요. 핵심 개념 → 주석 달린 코드 → 핵심 정리 → TIP 순서로 이어집니다. 각 과목 첫머리의 단원 목차를 눌러 바로 이동할 수도 있습니다.
  3. 코드로 보기초록색 설명 주석만 따라 읽어도 코드 흐름이 복습됩니다. 복사 버튼으로 직접 실행해 볼 수도 있어요.
  4. 위쪽 검색에 키워드(예: flex, 배열)를 치면 모든 과목에서 해당 카드만 걸러 줍니다. 밤에는 다크 모드 버튼으로 화면을 어둡게 바꿀 수 있습니다.
  5. 카드 하나하나는 이해되는데 "그래서 전체 흐름이 뭐지?" 큰 그림이 안 잡히면 위쪽 탭에서 🧭 학습 여정을 열어보세요. 과목별 서브탭(HTML·CSS·JS·React)을 골라 커리큘럼 순서 그대로 복습할 수 있습니다 — React는 어떤 파일이 어떤 파일을 부르고 데이터가 어디로 흐르는지, HTML·CSS·JS는 이전 레슨에서 배운 게 다음에 어떻게 확장되는지를 그림으로 정리했습니다.
  6. 수업 진도와 상관없이 혼자 개념을 하나씩 짚고 싶다면 위쪽 탭에서 기초 개념 사전을 열어보세요. 용어 하나하나를 독립적으로 설명하고, 처음 보는 사람을 위한 읽는 순서도 안내되어 있습니다.
  7. 객관식 시험을 앞두고 있다면 ✏️ 시험 대비 탭에서 연습문제를 풀어보세요. 한 문제씩 풀면서 고르는 즉시 정답과 해설이 나오고, 🔎 자세한 해설을 펼치면 보기 네 개를 하나씩 짚어 주는 풀이와 더 쉬운 정리가 이어집니다. ⭐ 즐겨찾기 · 🔥 어려움으로 표시해 둔 문제나 틀린 문제만 골라 다시 푸는 것도 됩니다(오답 기록은 💾 진도 백업에도 포함).
  8. 카드 제목 옆 완료 체크박스로 다 본 카드를 표시해두면(브라우저에 저장됨) 위 학습 완료 진도 카드에 자동 반영되고, 🔗 버튼으로 그 카드만의 링크를 복사할 수 있습니다. 5분 복습 모드는 "다시 볼래요"를 누른 카드를 기억해뒀다가 다음에 더 자주 보여줍니다.
  9. 완료 체크 옆 ☆ 버튼을 누르면 그 카드가 "헷갈리는 카드"로 표시되고(⭐), 검색창 옆 ⭐ 헷갈리는 것만 버튼으로 그 카드들만 모아볼 수 있습니다. 카드 태그를 클릭해도(키보드 Tab+Enter도 가능) 같은 검색으로 바로 필터링됩니다. 카드를 펼쳐서 공부하면 자동으로 "이어보기"에 기억돼 다음 방문 시 바로 이어서 볼 수 있고, 하단에는 자유롭게 적는 📝 내 메모 칸이 있습니다(개요의 "내가 남긴 메모 모아보기"에서 전체를 한눈에 확인). 위쪽 🎯 오늘의 복습 추천은 5분 복습에서 자주 "다시 볼래요"를 누른 카드를 자동으로 모아 보여줍니다.
  10. 완료·즐겨찾기·메모·복습 기록은 전부 이 브라우저에만 저장됩니다. 💾 내 진도 백업의 내보내기로 JSON 파일을 받아두면, 캐시를 지우거나 기기를 옮겨도 가져오기로 그대로 복원할 수 있습니다.

1. HTML

문서 구조, 태그 작성 규칙, 텍스트·목록·표 요소를 중심으로 마크업의 기본기를 복습합니다.

01

HTML 기본 사용법

DOCTYPEhead/bodyh1~h6p

한 줄 요약HTML 문서는 DOCTYPE → html → head/body의 고정 뼈대로 시작하며, head는 보이지 않는 메타정보를, body는 화면에 그려지는 콘텐츠를 담는다.

쉽게 말하면HTML 문서는 한 권의 책이에요. <!DOCTYPE html>은 표지에 붙은 “이건 HTML 책입니다” 딱지, <head>는 제목·저자 정보 페이지(화면에는 안 보임), <body>가 실제 본문입니다. h1~h6는 책의 장(章) 제목 크기라고 생각하면 됩니다.

문서의 뼈대

모든 HTML 문서는 <!DOCTYPE html> 선언으로 시작해서 <html> 태그 안에 <head><body>를 담는 고정된 골격을 가집니다. 이 구조를 벗어나면 브라우저가 문서를 제대로 해석하지 못할 수 있으므로, 모든 HTML 학습은 이 뼈대를 외우는 것에서 시작합니다.

head와 body의 역할

<head>는 화면에 직접 보이지 않는 메타정보(문자 인코딩, 제목, CSS 연결 등)를 담습니다. 반대로 <body>는 브라우저 화면에 실제로 그려지는 콘텐츠를 담는 영역입니다.

제목과 문단 태그

h1부터 h6까지는 제목의 중요도를 숫자로 나타내며 숫자가 작을수록 크고 굵게 표시되고, p는 하나의 문단을 나타내며 태그 앞뒤로 자동 여백이 생깁니다. 실습 파일은 플레이그라운드 형태로 구성되어 있어 각 챕터 카드의 예제 코드를 에디터에 적용해 보며 문서 구조와 제목/문단 태그의 실제 동작을 눈으로 확인할 수 있습니다.

HTML html01_기본사용법.html
<!-- HTML5 문서 선언: 최신 HTML 문서임을 브라우저에 알림 -->
<!DOCTYPE html>
<html>
<head>
    <!-- head: 화면에 보이지 않는 메타정보 영역 -->
    <meta charset="UTF-8">  <!-- 한글 깨짐 방지 인코딩 -->
    <title>나의 첫 HTML 문서</title>  <!-- 브라우저 탭 제목 -->
</head>
<body>
    <!-- body: 브라우저 화면에 실제로 그려지는 콘텐츠 영역 -->
    <h1>환영합니다!</h1>
    <p>기본 구조 학습 중입니다.</p>
</body>
</html>

<!-- 제목 태그 h1~h6: 숫자가 작을수록 크고 중요한 제목 -->
<h1>가장 큰 제목 (H1)</h1>
<h2>두 번째 제목 (H2)</h2>
<h3>세 번째 제목 (H3)</h3>

<!-- p: 하나의 문단. 앞뒤로 자동 여백이 생기며 문단마다 줄바꿈 -->
<p>이것은 일반 글씨가 들어가는 문단(Paragraph)입니다.</p>
<p>다른 문단을 작성하면 자연스럽게 줄바꿈이 일어납니다.</p>
핵심 정리
  • HTML 문서는 항상 DOCTYPE - html - head/body 순서의 고정 구조를 따른다.
  • head는 화면에 보이지 않는 메타정보, body는 실제로 렌더링되는 콘텐츠를 담는다.
  • h1~h6은 숫자가 작을수록 더 크고 중요한 제목이며, p는 한 문단을 의미한다.
TIPh 태그를 글씨 크기 조절 용도로만 남용하지 말고, 실제 문서 구조(제목의 위계)를 나타낼 때만 사용하는 것이 시맨틱 마크업의 기본입니다.
실습 파일: html01
02

블록요소 vs 인라인요소

divspanblockinline

한 줄 요약블록 요소(div·h1·ul)는 줄을 바꾸며 가로 폭 전체를 차지하고, 인라인 요소(span)는 내용 크기만큼만 차지하며 블록 안에 담겨 흐른다.

쉽게 말하면블록요소는 한 줄을 통째로 차지하는 벽돌, 인라인요소는 문장 속에 끼어드는 단어예요. 벽돌(div, p)은 쌓을 때마다 줄이 바뀌고, 단어(span, a)는 글 흐름 속에 나란히 이어집니다.

블록 요소란

HTML 요소는 화면에서 차지하는 영역의 성격에 따라 블록(block) 요소와 인라인(inline) 요소로 나뉩니다. 블록 요소는 항상 새로운 줄에서 시작하며 부모 너비 전체를 차지하려는 성질이 있어서, 레이아웃의 큰 틀(구조)이나 여러 요소를 하나로 묶는 그룹을 만들 때 사용합니다. div, h1, ul, li가 대표적인 블록 요소입니다.

인라인 요소란

인라인 요소는 자신만의 줄 바꿈이 없고 내용물 크기만큼만 공간을 차지하며, 반드시 블록 요소 내부에 담겨 문장의 일부처럼 흘러갑니다. span이 대표적인 인라인 요소입니다.

두 컨테이너 비교

실습 파일에서는 Container1에 블록 요소인 divul/li로 메뉴 구조를 만들고, Container2에는 인라인 요소인 span 세 개를 나란히 배치했습니다. 두 방식이 화면에서 어떻게 다르게 배치되는지를 직접 비교해 볼 수 있습니다.

HTML html02_블럭요소_인라인요소.html
<!-- 블록 요소 실험: div는 구조(레이아웃)와 그룹을 만든다 -->
<div id="Container1">
    <!-- h1도 블록 요소: 한 줄 전체를 차지하며 줄바꿈된다 -->
    <h1>블럭요소: 구조를 나타내는 기능, 그룹을 나타내는 기능</h1>
    <div class="menu">
        <!-- ul/li도 블록 요소: 항목마다 새 줄에서 시작 -->
        <ul>
            <li>메뉴1</li>
            <li>메뉴2</li>
            <li>메뉴3</li>
        </ul>
    </div>
</div>

<!-- 인라인 요소 실험: span은 내용 크기만큼만 차지한다 -->
<div id="Container2">
    <h1>인라인요소: 형태가 없음, 블럭요소 내부에 담겨서 표현</h1>
    <div>
        <!-- span 3개가 줄바꿈 없이 옆으로 나란히 배치된다 -->
        <span>요소</span>
        <span>요소</span>
        <span>요소</span>
    </div>
</div>
핵심 정리
  • 블록 요소는 줄바꿈 되며 부모의 가로 폭 전체를 차지하려 한다 (div, h1, ul, li 등).
  • 인라인 요소는 줄바꿈 없이 내용 크기만큼만 차지하며 블록 요소 안에 존재한다 (span, a, strong 등).
  • 블록 요소는 구조/그룹을 만드는 용도, 인라인 요소는 문장 안의 일부를 꾸미는 용도로 쓰인다.
TIPCSS의 display 속성(inline, block, inline-block 등)으로 요소의 기본 성질을 바꿀 수 있다는 점도 함께 기억해두면 레이아웃을 다룰 때 도움이 됩니다.
실습 파일: html02
03

목록(List) 요소

ul·li 중첩li>uldisplay 토글querySelectorAll

한 줄 요약li 안에 ul을 중첩해 서브메뉴 구조를 만들고, CSS로 숨겨둔 뒤 클릭 시 JavaScript로 display를 block으로 바꿔 펼치는 아코디언 메뉴의 원리를 배운다.

쉽게 말하면목록 태그는 메모지에 쓰는 목록과 같아요. 순서가 중요하면(조리 순서) 번호 목록 <ol>, 순서가 상관없으면(장볼 물건) 점 목록 <ul>, “용어: 설명” 짝이면 사전처럼 <dl>을 씁니다.

중첩 목록 구조

목록은 ul(순서 없는 목록)과 li(목록 항목)의 조합으로 만들며, li 안에 또 다른 ul을 넣으면 중첩된 목록, 즉 하위 메뉴(서브메뉴) 구조를 만들 수 있습니다. 실습 파일에서는 "JAVA"와 "데이터베이스" 항목 안에 각각 세부 학습 내용을 담은 ul>li 목록을 중첩시켜 계층 구조를 표현했습니다.

자식 선택자로 숨기기

CSS의 li { list-style: none; }는 기본으로 붙는 불릿(•) 표시를 제거하는 속성입니다. li>ul { display: none; }은 자식 선택자(>)를 이용해 li의 직계 자식인 ul만 골라 처음에는 화면에서 숨겨둔 것입니다.

클릭으로 펼치기

JavaScript의 window.onload로 페이지 로딩이 끝난 뒤, b 태그(굵은 글씨, 여기서는 클릭 가능한 제목 역할)를 클릭하면 querySelectorAll("li>ul")로 찾은 해당 서브메뉴의 style.display"block"으로 바꿔 숨겨져 있던 목록을 펼쳐 보입니다. 아코디언 메뉴의 기본 원리를 실습하는 예제입니다.

HTML html03_목록요소.html
<!-- CSS: 불릿 제거 + 서브메뉴는 처음에 숨겨둔다 -->
<style>
    li { list-style: none; }   /* 기본 불릿(•) 표시 제거 */
    li>ul { display: none; }   /* li의 직계 자식 ul만 숨김 */
    b { cursor: pointer; }     /* 클릭 가능해 보이는 손 커서 */
</style>

<!-- ul 안의 li 안에 다시 ul: 중첩(서브) 목록 구조 -->
<ul>
    <li>
        <b>JAVA</b>  <!-- 클릭하면 서브메뉴가 펼쳐질 제목 -->
        <ul class="submenu">
            <li>변수의 사용</li>
            <li>제어문</li>
            <li>객체지향[상속,은닉성,다형성]</li>
        </ul>
    </li>
    <li>
        <b>데이터베이스</b>
        <ul>
            <li>환경설정</li>
            <li>DB모델링의 개념</li>
            <li>SQL[DDL,DML,DCL]</li>
            <li>테이블/뷰/프로시저</li>
        </ul>
    </li>
    <li>HTML</li>
    <li>CSS</li>
</ul>
JS html03_목록요소.html
// onload: 페이지 로딩이 끝난 뒤에 실행되는 이벤트 속성
window.onload = function () {
    // querySelectorAll은 배열처럼 반환되므로 [0], [1]로 접근
    // 첫 번째 b(JAVA)를 클릭 → 첫 번째 서브메뉴를 펼침
    document.querySelectorAll("b")[0].onclick = function () {
        document.querySelectorAll("li>ul")[0]
            .style.display = "block";
    }

    // 두 번째 b(데이터베이스)를 클릭 → 두 번째 서브메뉴를 펼침
    document.querySelectorAll("b")[1].onclick = function () {
        document.querySelectorAll("li>ul")[1]
            .style.display = "block";
    }
}
핵심 정리
  • ul(목록 전체) 안에 li(항목)를 넣고, li 안에 다시 ul을 넣으면 중첩(서브) 목록이 만들어진다.
  • CSS list-style: none으로 불릿을 없애고, li>ul처럼 자식 결합자(>)로 특정 계층만 선택할 수 있다.
  • display:none / display:block을 JavaScript로 토글하면 클릭 시 펼쳐지는 메뉴를 구현할 수 있다.
TIPquerySelectorAll은 조건에 맞는 요소를 배열처럼 반환하므로 [0], [1] 같은 인덱스로 몇 번째 요소인지 짚어서 접근해야 한다는 점을 헷갈리지 않도록 주의합니다.
실습 파일: html03
04

표(Table) 만들기

thead·tbody·tfootcolspandisplay:table@media

한 줄 요약표는 table·tr·th·td로 만들고 thead/tbody/tfoot·caption·col로 구조화하며, div에 display:table 계열 속성을 주면 table 태그 없이도 표 형태를 만들 수 있다.

쉽게 말하면<table>은 엑셀 시트를 태그로 그리는 거예요. <tr>은 한 줄(행), <td>는 그 줄의 칸(셀), <th>는 굵은 제목 칸. colspan/rowspan은 엑셀의 “셀 병합” 기능입니다.

표의 기본 구성

표는 table 태그 안에 행을 나타내는 tr, 그 안에 제목 셀 th와 일반 셀 td를 넣어 만듭니다. 여기서 한 단계 더 나아가 caption(표 제목)과 col(열 단위 너비 지정)로 표의 부가 정보를 지정할 수 있습니다.

영역 나누기와 병합

thead/tbody/tfoot은 표를 머리글·본문·꼬리글 영역으로 의미상 구분해 줍니다. tfootcolspan="3"처럼 colspan은 셀을 가로로 병합해 여러 칸을 하나로 합쳐줍니다.

div로 만드는 표

표가 반드시 table 태그로만 만들어지는 것은 아닙니다. CSS의 display: table, display: table-row, display: table-cell을 각각 div에 적용하면 일반 div들도 표처럼 줄과 칸을 맞춰 렌더링됩니다. 이 방식은 @media 쿼리로 화면이 좁아졌을 때 display: block으로 바꿔 표 형태를 세로로 풀어주는 반응형 레이아웃을 만들 때 유용합니다.

HTML html04_표만들기.html
<!-- 표의 다양한 구성 요소: caption, col, thead/tbody/tfoot -->
<table border="1">
    <caption>표의 제목</caption>  <!-- 표 전체의 제목 -->
    <!-- col: 열 단위로 너비를 한 번에 지정 -->
    <col width="100" />
    <col width="200" />
    <col width="100" />
    <thead>  <!-- 머리글 영역: 제목 셀은 th -->
        <tr>
            <th>번호</th>
            <th>제목</th>
            <th>작성일</th>
        </tr>
    </thead>
    <tbody>  <!-- 본문 영역: 데이터 셀은 td -->
        <tr>
            <td>1</td>
            <td>월드컵16강일정</td>
            <td>2026.07.07</td>
        </tr>
    </tbody>
    <tfoot>  <!-- 꼬리글 영역 -->
        <tr>
            <!-- colspan="3": 가로로 3칸을 하나로 병합 -->
            <td colspan="3">2026년 북중미 월드컵</td>
        </tr>
    </tfoot>
</table>

<!-- table 태그 없이 div로 만드는 표 (아래 CSS와 세트) -->
<div class="table">
    <div class="tr">
        <div class="td">1</div>
        <div class="td">2</div>
        <div class="td">3</div>
    </div>
</div>
CSS html04_표만들기.html
table {
    border-collapse: collapse; /* 셀 사이 이중 테두리를 하나로 */
    width: 400px;
}

/* div 요소를 표처럼 렌더링하는 display: table 계열 */
.table { display: table; }     /* table 역할 */
.tr { display: table-row; }    /* 행(tr) 역할 */
.td {
    display: table-cell;       /* 칸(td) 역할 */
    border: 1px solid black;
    padding: 5px 40px;
}

/* 모바일(600px 이하): 전부 block으로 바꿔 세로로 풀어줌 */
@media screen and (max-width:600px) {
    .table { display: block; }
    .tr { display: block; }
    .td { display: block; }
}
핵심 정리
  • table > tr > th/td 순서로 표를 구성하며, th는 제목 셀, td는 일반 데이터 셀이다.
  • thead/tbody/tfoot으로 표의 영역을 의미상 나누고, caption으로 표 전체 제목을, col로 열 너비를 지정한다.
  • colspan은 셀을 가로로 병합하며, div에 display:table 계열 속성을 주면 table 태그 없이도 표처럼 보이게 만들 수 있다.
TIPdiv로 만든 가짜 표는 스크린리더 등 접근성 도구에는 실제 표로 인식되지 않을 수 있으므로, 진짜 데이터 표라면 table 태그를 쓰는 것이 원칙입니다.
실습 파일: html04
05

실습 — 인용문·목록·구분선

qhraddresstarget="_blank"

한 줄 요약짧은 인용은 q, 영역 구분은 hr, 새 탭 링크는 target이 "_blank"인 a, 연락처 표시는 address 태그로 처리하는 시맨틱 태그 종합 실습이다.

쉽게 말하면워드에서 하던 ‘들여쓴 인용 상자·글머리 기호·수평선 넣기’를 태그로 해 본 실습이에요. blockquote는 남의 말을 들여 쓰는 인용 상자, hr은 문단 사이를 나누는 구분선입니다.

인용과 구분선

q 태그는 문장 속에 짧은 인용구를 넣을 때 사용하는 인라인 태그로, 브라우저가 자동으로 앞뒤에 따옴표를 붙여 표시해줍니다. 긴 인용문을 블록으로 표시할 때는 blockquote를 쓰지만, 이 실습에서는 짧은 인용을 q로 처리했습니다. hr은 수평선을 그어 콘텐츠 영역을 시각적으로 구분하는 데 쓰이며, 이 파일에서는 인용문·목록·하단 링크/주소 영역 사이를 hr로 나누고 있습니다.

독립된 목록 그룹

여러 개의 ul을 연속으로 배치해서 목록이 각각 독립된 그룹으로 존재할 수 있음을 보여줍니다. 하나의 긴 목록 대신 주제별로 ul을 나누면 의미 단위가 분명해집니다.

새 탭 링크와 연락처

a 태그의 target="_blank" 속성은 링크를 새 탭에서 열도록 설정합니다. 마지막의 address 태그는 단순한 텍스트가 아니라 "이 문서의 연락처 정보"라는 의미를 브라우저와 검색엔진에 전달하는 시맨틱 태그로, 여기서는 이메일 주소를 의미상 올바르게 표시했습니다.

HTML test.html
<!-- q: 짧은 인라인 인용. 브라우저가 앞뒤에 따옴표를 붙임 -->
<q>
    Neque porro quisquam est qui dolorem ipsum
    quia dolor sit amet, consectetur, adipisci velit...
</q>

<!-- hr: 영역을 나누는 수평 구분선 -->
<hr>

<!-- 독립된 ul을 여러 개 나열해 목록 그룹을 구분 -->
<ul>
    <li>Lorem ipsum dolor sit amet, consectetur elit.</li>
    <li>Integer id nisl dictum, rhoncus tortor ut.</li>
</ul>

<hr>

<p>문장</p>
<!-- target="_blank": 링크를 새 탭에서 연다 -->
<a href="http:www.lipsum.com" target="_blank">Lorem Ipsum</a>
<!-- address: 연락처 정보를 뜻하는 시맨틱 태그 -->
<address>help@lipsum.com</address>
핵심 정리
  • q는 짧은 인라인 인용문에 자동으로 따옴표를 붙여주는 태그이다.
  • hr은 콘텐츠를 시각적/의미적으로 구분하는 수평 구분선이다.
  • a 태그의 target="_blank"는 링크를 새 탭에서 열고, address는 연락처 정보를 의미상 표시하는 시맨틱 태그이다.
TIPq는 인라인, blockquote는 블록 요소라는 차이를 기억해두면 인용문의 길이나 용도에 맞게 올바른 태그를 고를 수 있습니다.
실습 파일: test
06

실습 — 인적사항 입력 표

rowspancolspanborder-collapse

한 줄 요약rowspan은 세로로, colspan은 가로로 셀을 병합하며, 둘을 조합하면 사진 칸이 있는 인적사항 양식처럼 불규칙한 칸 배치도 표로 만들 수 있다.

쉽게 말하면은행 가입신청서 같은 종이 양식을 웹으로 옮긴 실습이에요. 표(table)가 칸을 잡아 주고, input이 빈칸, 라디오/체크박스가 “해당란에 O표 하세요”에 해당합니다.

세로 병합 rowspan

이 실습은 표의 셀 병합 기능인 rowspancolspan을 실제 서식(인적사항 입력 양식)에 적용하는 연습입니다. rowspan="6"이 걸린 첫 번째 td는 세로로 6개 행 전체에 걸쳐 하나의 칸처럼 합쳐지는데, 이렇게 하면 사진 붙이는 칸처럼 여러 줄에 걸친 영역을 표 안에서 자연스럽게 표현할 수 있습니다.

가로 병합 colspan

반대로 colspan="3"은 가로 방향으로 3칸을 하나로 합쳐서 "(한문)"처럼 넓은 입력 공간을 만듭니다. 즉 표는 단순히 데이터를 나열하는 용도뿐 아니라, rowspan/colspan을 조합하면 이력서·신청서 같은 실제 서류 양식의 불규칙한 칸 배치도 표현할 수 있습니다.

테두리 정리

CSS에서는 border-collapse: collapse로 셀 사이의 이중 테두리를 하나로 합쳐 깔끔하게 보이도록 처리했고, .asd 클래스로 사진 칸의 가로 폭을 고정했습니다.

HTML test2.html
<table border="1">
    <tbody>
        <tr>
            <!-- rowspan="6": 세로 6개 행 병합 (사진 칸) -->
            <td rowspan="6" class="asd"></td>
            <td>성명</td>
            <!-- colspan="3": 가로 3칸 병합 → 넓은 입력 공간 -->
            <td colspan="3">(한문)</td>
        </tr>
        <!-- 병합된 첫 칸 아래 행들은 나머지 셀부터 시작 -->
        <tr>
            <td>주민번호</td>
            <td></td>
            <td>생년월일</td>
            <td>년 월 일(음력/양력)</td>
        </tr>
        <tr>
            <td>주소</td>
            <td colspan="3"></td>
        </tr>
        <tr>
            <td>전화번호</td>
            <td></td>
            <td>E-MAIL</td>
            <td></td>
        </tr>
    </tbody>
</table>
CSS test2.html
table {
    border-collapse: collapse; /* 이중 테두리를 하나로 합침 */
    width: 700px;
}

/* 사진 칸(rowspan 셀)의 가로 폭 고정 */
.asd {
    width: 120px;
}
핵심 정리
  • rowspan="N"은 해당 셀을 세로로 N개 행만큼 병합한다.
  • colspan="N"은 해당 셀을 가로로 N개 칸만큼 병합한다.
  • rowspan/colspan을 조합하면 실제 서류 양식처럼 불규칙한 셀 배치를 만들 수 있다.
TIProwspan으로 셀을 병합하면 그 아래 행에서는 해당 열의 td를 아예 적지 않아야 표가 어긋나지 않는다는 점에 주의합니다(이 파일에서도 병합된 첫 칸 이후 각 행은 나머지 셀부터 시작합니다).
실습 파일: test2
07

실습 — 경력사항 표

colspanCSS 클래스 재사용표 반복 배치

한 줄 요약colspan으로 좁은 항목 칸과 넓은 서술 칸을 한 표에 섞고, 공용 CSS 클래스로 같은 구조의 표를 여러 번 반복 배치하는 실전 양식 패턴입니다.

쉽게 말하면이력서의 경력사항 칸처럼 행과 열이 얽힌 표를 만드는 실습이에요. 셀 병합(rowspan·colspan)을 쓰면 “같은 회사에서 2년”처럼 여러 칸에 걸치는 칸을 하나로 합칠 수 있습니다.

한 표 안의 두 가지 행

하나의 경력사항 표 안에서 위쪽은 회사명·주요사업처럼 좁은 항목별 입력 칸으로, 아래쪽은 자유 서술 칸으로 구성합니다. colspan="3"은 "주요업무" 입력란을 나머지 3칸 너비로 넓히고, colspan="4"는 "업무상세내용 및 주요성과" 제목 행과 그 아래 내용 칸을 표 전체 너비로 병합합니다.

클래스로 크기를 통일

.qwe(너비 200px), .asd(높이 150px) 같은 CSS 클래스를 만들어 두면, 표 구조를 여러 번 복사-붙여넣기 해도 열 너비와 행 높이가 일관되게 유지됩니다. 실습 파일에서는 동일한 표를 3번 반복해 첫 번째·두 번째·세 번째 회사의 경력을 각각 독립된 표로 나열하는 실전 패턴을 연습합니다.

CSS test3.html — <style>
table {
    width: 100%;              /* 표를 화면 가로 폭 전체로 */
    border-collapse: collapse; /* 셀 사이 이중 테두리를 하나로 합침 */
}

/* 반복되는 모든 표에 공통 적용할 크기 클래스 */
.qwe { width: 200px; }   /* 입력 칸의 열 너비 통일 */
.asd { height: 150px; }  /* 자유 서술 칸의 행 높이 확보 */
HTML test3.html
<!-- 같은 구조의 표를 회사 수만큼 반복 배치 -->
<table border="1">
    <!-- 위쪽: 좁은 항목별 입력 칸 (총 4열) -->
    <tr>
        <td>회사명</td>
        <td class="qwe"></td>   <!-- 입력칸: 너비 200px -->
        <td>주요사업</td>
        <td class="qwe"></td>
    </tr>
    <tr>
        <td>근무기간</td>
        <td class="qwe"></td>
        <td>근무부서/직책</td>
        <td class="qwe"></td>
    </tr>
    <!-- 제목 1칸 + 병합 3칸 = 4열을 맞춘다 -->
    <tr>
        <td>주요업무</td>
        <td colspan="3"></td>
    </tr>
    <!-- 아래쪽: 표 전체 너비(4칸 병합)의 자유 서술 영역 -->
    <tr>
        <td colspan="4">업무상세내용 및 주요성과</td>
    </tr>
    <tr>
        <td colspan="4" class="asd"></td>  <!-- 높이 150px -->
    </tr>
</table>
핵심 정리
  • colspan을 이용해 항목 행(좁은 여러 칸)과 서술 행(넓은 한 칸)을 한 표 안에서 함께 구성할 수 있다.
  • CSS 클래스(.qwe, .asd)를 미리 정의해두면 반복되는 여러 표에서 열 너비·행 높이를 일관되게 재사용할 수 있다.
  • 같은 구조의 표를 여러 번 복사해 나열하면 회사별 경력을 항목별로 반복 입력하는 양식을 만들 수 있다.
TIP표 구조가 반복될 때는 각 행의 colspan 합이 실제 열 개수(4)와 맞는지 항상 검산하는 습관을 들이면 표가 깨지는 실수를 줄일 수 있습니다.
실습 파일: test3

2. CSS

선택자, 폰트·배경·박스 모델, position과 flexbox 레이아웃까지 CSS의 핵심 개념을 복습합니다.

01

CSS 작성 방식

인라인 style<style><link rel="stylesheet">

한 줄 요약CSS를 적용하는 방법은 인라인·내부·외부 세 가지이며, 우선순위는 인라인이 가장 높고 유지보수에는 외부 스타일시트가 가장 유리하다.

쉽게 말하면CSS 적용 3가지 방식은 옷 입히는 규칙과 같아요. 인라인은 옷에 직접 매직으로 그리기(그 한 벌만), 내부 스타일은 우리 집 옷장에 붙인 규칙(그 페이지만), 외부 파일은 전 지점 공통 유니폼 규정집이에요. 실무 표준은 규정집(외부 파일)입니다.

세 가지 적용 방식

인라인(inline) 방식은 태그의 style 속성에 직접 선언하는 것으로, 해당 요소 하나에만 적용되며 우선순위가 가장 높습니다. 내부(internal) 방식은 <head> 안에 <style> 태그를 두어 그 문서 전체에 규칙을 적용합니다. 외부(external) 방식은 별도의 .css 파일을 만들어 <link rel="stylesheet" href="경로">로 연결하며, 여러 HTML 파일이 하나의 css 파일을 공유할 수 있어 유지보수에 가장 유리합니다.

세 방식을 한 파일에서

이 파일은 첫 번째 p에 style 속성으로 인라인 스타일(color: aqua; background-color: black;)을, <head><style>로 모든 p에 공통 스타일(color: red; background-color: blue;)을, <link>로 css/test.css를 연결해 외부 스타일을 동시에 보여줍니다. 인라인이 적용된 첫 p는 내부 스타일보다 우선하여 아쿠아색 글자로 표시되고, 나머지 p들은 내부 스타일의 영향을 받습니다.

HTML css01_작성방식.html
<!-- 외부 스타일: 별도 .css 파일을 link로 연결
     여러 문서가 공유할 수 있어 유지보수에 가장 유리 -->
<link rel='stylesheet' type='text/css' media='screen'
    href='css/test.css'>

<!-- 내부 스타일: head 안 <style>에 작성, 이 문서 전체에 적용 -->
<style>
    p {
        color: red;             /* 모든 p 태그: 빨간 글자 */
        background-color: blue; /* 파란 배경 */
    }
</style>
</head>

<body>
    <!-- 인라인 스타일: 태그의 style 속성에 직접 작성
         우선순위가 가장 높아 내부 스타일(red/blue)을 이긴다 -->
    <p style='color: aqua; background-color: black;'>in-line 방식</p>

    <!-- 인라인이 없으므로 내부 스타일(red/blue)이 적용된다 -->
    <p>내부스타일방식</p>

    <!-- strong의 글자색은 외부 파일 test.css가 결정한다 -->
    <p><strong>외부스타일방식</strong></p>
</body>
CSS css/test.css
@charset "UTF-8";

/* 외부 스타일시트: link로 연결한 모든 문서에 적용된다 */
strong {
    color: chocolate; /* "외부스타일방식" 문단의 strong 글자색 */
}
핵심 정리
  • 인라인 스타일은 style 속성으로 요소에 직접 작성하며 세 방식 중 우선순위가 가장 높습니다.
  • 내부 스타일은 <style> 태그를 <head>에 작성하며 해당 문서 전체에만 적용됩니다.
  • 외부 스타일은 별도 .css 파일을 <link rel="stylesheet">로 연결하며 여러 문서에서 재사용할 수 있습니다.
TIP실무에서는 유지보수와 재사용성 때문에 대부분 외부 스타일시트를 사용하고, 인라인 스타일은 되도록 지양합니다.
실습 파일: css01
02

Selector(선택자) 표현식

#id.class[attr="값"]자손·자식

한 줄 요약선택자는 CSS가 스타일을 입힐 요소를 고르는 규칙이며, id·class·속성값·요소 간 관계(자손/자식)가 모두 선택 기준이 된다.

쉽게 말하면선택자는 “누구에게 스타일을 입힐지” 지목하는 방법이에요. 태그 선택자는 “학생 전원”, .class는 “안경 쓴 사람들”(여러 명 가능), #id는 “출석번호 7번”(딱 한 명), 자손 선택자는 “3반 안에서 안경 쓴 사람”처럼 범위를 좁힙니다.

선택자의 네 가지 기본형

셀렉터(선택자)는 CSS가 문서의 어떤 요소를 골라서 스타일을 적용할지 지정하는 규칙입니다. 대표적으로 태그 이름 자체를 쓰는 타입 선택자(p, h1), 문서 안에서 유일해야 하는 id 선택자(#id), 여러 요소에 반복해서 붙일 수 있는 class 선택자(.class), 특정 속성값을 가진 요소만 고르는 속성 선택자([속성="값"])가 있습니다.

관계로 고르는 선택자

요소들 사이의 관계도 선택 기준이 됩니다. 선택자를 공백으로 연결하면 자손 선택자가 되어 하위 모든 단계의 후손을 선택하고, >로 연결하면 자식 선택자가 되어 바로 한 단계 아래 자식만 선택합니다.

연습용 마크업 구조

이 파일은 선택자들이 적용될 대상을 만들기 위한 마크업 연습 파일입니다. div에 id="container1", h1에 id="sub_title1"을 부여해 id 선택자의 대상을 만들고, 여러 p에 동일하게 class="c"를 부여해 class 선택자로 한꺼번에 스타일링할 수 있게 했습니다. 또한 p 안에 <b>, <span><b></b></span>처럼 태그를 중첩시켜 자손·자식 선택자 연습이 가능하게 했고, p에 title="주식관련기사" 속성을 부여해 속성 선택자의 대상도 마련했습니다.

HTML css02_selector표현식.html
<!-- 외부 css(css02.css)의 선택자들이 이 문서를 대상으로 동작 -->
<div id="container1">
    <!-- id 선택자의 대상: id는 문서에서 유일해야 한다 -->
    <h1 id="sub_title1">삼성전자 2·4분기 실적을...</h1>

    <!-- class="c"를 여러 p에 반복 부여 → .c 규칙 하나로 일괄 적용 -->
    <p class="c">
        ... <b>39만원</b>으로 낮춘 증권사도 나왔다.
        <!-- span 속의 b: 자손 선택자(p b)에는 선택되지만
             자식 선택자(p>b)에는 선택되지 않는 중첩 구조 -->
        <span><b>실적</b></span> 호조에는 공감하면서도 ...
    </p>
    <p class="c">
        <span><b>KB증권은</b>지난</span> 8일 삼성전자 목표주가를 ...
    </p>

    <!-- 속성 선택자 [title="주식관련기사"]의 대상 -->
    <p title="주식관련기사">
        2·4분기 실적에 대해서도 긍정적인 평가를 내렸다.
        ...
    </p>
</div>
CSS css/css02.css
/* 타입 선택자: 태그 이름으로 선택 */
p {
    width: 800px;
    background-color: bisque;
}

/* id 선택자: 문서에서 유일한 요소 하나만 */
#sub_title1 {
    font-size: 10px;
}

/* class 선택자: class="c"를 가진 요소 전부 */
.c {
    color: blue;
}

/* 자식 선택자: h1의 '바로 아래' 자식 b만 */
#sub_title1>b {
    color: red;
}

/* 자손 선택자(공백): h1 하위의 모든 b (몇 단계든) */
h1 b {
    color: blueviolet;
}

/* 속성 선택자: title 값이 정확히 일치하는 p만 */
p[title="주식관련기사"] {
    background-color: gold;
}
핵심 정리
  • id 선택자(#id)는 문서에서 단 하나의 요소만 가리켜야 하고, class 선택자(.class)는 여러 요소에 재사용할 수 있습니다.
  • 자손 선택자(공백으로 연결)는 하위의 모든 후손을 선택하고, 자식 선택자(>)는 바로 한 단계 아래 자식만 선택합니다.
  • 속성 선택자([attr="value"])는 title="주식관련기사"처럼 특정 속성값을 가진 요소만 정확히 골라냅니다.
TIP같은 class="c"를 여러 p 태그에 반복 부여하면 규칙 한 번 작성으로 모든 문단에 동일한 스타일을 일괄 적용할 수 있어 효율적입니다.
실습 파일: css02
03

셀렉터 종합 실습

*A+BA~BA>B

한 줄 요약기본 선택자에 더해 전체 선택자(*)와 형제 선택자(+는 바로 다음 하나, ~는 이후 전부)까지 한 파일에서 모아 연습하는 종합 예제다.

쉽게 말하면여러 선택자를 실전처럼 섞어 쓴 실습이에요. 요점은 더 구체적으로 지목한 규칙이 이긴다는 것 — 전교생 규칙보다 3반 규칙이, 3반 규칙보다 출석번호 지목이 우선입니다.

기본 선택자 복습

이 파일은 지금까지 배운 다양한 선택자를 한 번에 모아 실습하는 예제입니다. 타입 선택자(p), id 선택자(#a), class 선택자(.b) 같은 기본 선택자 외에, 전체 선택자(*)로 모든 요소에 padding:2px를 일괄 적용하는 방법과, 자식 선택자(P>strong)로 p의 바로 아래 자식인 strong만 골라 색을 바꾸는 방법을 보여줍니다.

형제 선택자와 속성 선택자

특히 눈여겨볼 부분은 형제 선택자입니다. #c+p는 id가 c인 요소 바로 다음에 오는 형제 p 하나만 선택하는 인접 형제 선택자이고, #c~p는 그 이후에 등장하는 모든 형제 p를 선택하는 일반 형제 선택자입니다. 또한 p[title="attr"]처럼 속성 선택자로 title 속성을 가진 p만 골라 font-size를 다르게 지정하는 것도 함께 연습합니다.

CSS css03.html
/* 타입 선택자: 모든 p에 배경색 */
p { background-color: pink; }

/* id 선택자: id="a"인 요소 하나만 */
#a { color: red; }

/* class 선택자: class="b"인 요소 전부 */
.b { color: blue; }

/* 전체 선택자: 문서의 모든 요소에 일괄 적용 */
* { padding: 2px; }

/* 자식 선택자: p '바로 아래' 자식 strong만 */
P>strong { color: yellow; }

/* 인접 형제 선택자: #c '바로 다음' 형제 p 하나만 */
#c+p { color: green; }

/* 일반 형제 선택자: #c 이후에 오는 형제 p 전부 */
#c~p { color: green; }

/* 속성 선택자: title="attr"인 p만 글자 크기 변경 */
p[title="attr"] { font-size: 15pt; }
HTML css03.html
<h1><b>셀렉터</b> 작성 연습하기</h1>
<div>
    <p>1. p태그에 배경색 pink적용하기</p>
    <p id="a">2. id로 선택하여 color:red로 적용하기</p>
    <p class="b">3. class로 선택하여 color:blue로 적용하기</p>
</div>
<div>
    <p>4. 전체를 선택하여 padding:2px 적용하기</p>
    <!-- strong은 p의 직계 자식 → P>strong에 선택된다 -->
    <p>5. p태그의 <strong>자식요소</strong>중
        strong태그에 color:yellow 적용하기</p>
    <!-- #c 기준: 바로 다음 형제 p는 +와 ~ 모두에 선택된다 -->
    <p id="c">6.id=c 인 p태그 다음에 인접한 p태그에
        color:green 적용하기</p>
    <p>id=c인 속성을 가진 p태그 다음에 오는 p태그</p>
    <p title="attr">7.title속성을 가진 p태그
        font-size:15pt 로 적용하기</p>
</div>
핵심 정리
  • 전체 선택자(*)는 문서의 모든 요소에 스타일을 적용합니다 (여기서는 padding:2px).
  • 인접 형제 선택자(A+B)는 A 바로 다음의 형제 B 하나만 선택하고, 일반 형제 선택자(A~B)는 이후 등장하는 형제 B 전부를 선택합니다.
  • 자식 선택자(A>B)는 A의 직계 자식 B만 선택하며, 손자 이하의 후손은 포함하지 않습니다.
TIP#c+p와 #c~p의 결과가 이 예제에서 같아 보이는 이유는 #c 뒤에 형제 p가 하나뿐이기 때문이며, 형제가 여러 개라면 두 선택자의 차이가 뚜렷하게 나타납니다.
실습 파일: css03
04

폰트(Font)

font 축약형20px/50pxfont-family

한 줄 요약여섯 개의 폰트 속성은 font 축약형 한 줄로 합칠 수 있지만, style·variant·weight → size/line-height → family 순서를 반드시 지켜야 한다.

쉽게 말하면글꼴 속성은 문서 편집기의 글꼴 도구 모음이에요. font-family는 글꼴 선택 드롭다운, font-size는 크기 칸, font-weight는 굵게(B) 버튼. 글꼴을 쉼표로 여러 개 쓰는 건 “1지망 없으면 2지망” 대기 목록입니다.

폰트 속성 여섯 가지

폰트 관련 속성에는 font-size(크기), font-weight(굵기), font-style(기울임 등), font-variant(작은 대문자 등), line-height(줄 간격), font-family(글꼴)가 있습니다. 이 속성들을 하나씩 나열하는 대신 font라는 축약(shorthand) 속성 하나로 합쳐서 쓸 수 있습니다.

축약형은 순서가 생명

font 축약형은 순서를 반드시 지켜야 합니다. [font-style/font-variant/font-weight] → [font-size/line-height] → [font-family] 순서로 작성해야 브라우저가 올바르게 해석합니다. 파일의 .c 클래스는 여섯 개 속성을 개별로 나열한 규칙과, 동일한 결과를 font: bold italic small-caps 20px/50px "맑은 고딕" 굴림; 한 줄로 표현한 축약형 규칙을 나란히 비교해서 보여줍니다. font-sizeline-height는 슬래시(/)로 함께 표기한다는 점도 이 예제에서 확인할 수 있습니다.

CSS css04_font.html
/* 방법 1: 여섯 개 속성을 하나씩 개별 나열 */
.c {
    font-size: 20px;              /* 글자 크기 */
    font-weight: bold;            /* 굵기 */
    font-style: italic;           /* 기울임 */
    font-variant: small-caps;     /* 소문자를 작은 대문자로 */
    line-height: 50px;            /* 줄 간격 */
    font-family: "맑은 고딕" 굴림;  /* 글꼴 (앞이 없으면 뒤로 대체) */
}

/* 방법 2: 위와 동일한 결과를 font 축약형 한 줄로
   size와 line-height는 슬래시(/)로 묶는다 → 20px/50px */
.c {
    font: bold italic small-caps 20px/50px "맑은 고딕" 굴림;
}

/*
font축약형은 순서를 지켜야 한다.
1.font : [font-weight / font-style / font-variant]
2.font : [font-size / line-height]
3.font : [font-family]
font: 1 2 3 순으로 작성해야 함
*/
핵심 정리
  • font 축약형은 style/variant/weight → size/line-height → family 순서를 반드시 지켜야 하며, 순서를 어기면 스타일이 적용되지 않습니다.
  • font-sizeline-height는 슬래시(/)로 함께 표기합니다 (예: 20px/50px).
  • font-family에 여러 값을 나열하면 앞의 글꼴이 없을 때 다음 글꼴로 대체(fallback)됩니다.
TIP공백이 포함된 한글 폰트 이름("맑은 고딕")은 반드시 따옴표로 감싸야 하지만, 공백이 없는 이름(굴림)은 따옴표 없이도 동작합니다.
실습 파일: css04
05

문단 스타일 실습

인라인 stylefont 축약형text-decoration

한 줄 요약같은 문단을 개별 속성 나열 방식과 font 축약형 방식으로 두 번씩 꾸며 비교하되, 밑줄·들여쓰기·정렬은 축약형에 못 넣는 별도 속성임을 확인하는 실습이다.

쉽게 말하면자간·행간·정렬은 책 조판과 같아요. line-height는 줄과 줄 사이의 공기, letter-spacing은 글자 사이 간격, text-align은 왼쪽/가운데 맞춤 — 읽기 편한 문단을 만드는 도구들입니다.

문단마다 다른 인라인 스타일

이 파일은 인라인 스타일을 이용해 문단(p) 하나하나에 서로 다른 텍스트 스타일을 적용하는 연습입니다. font-weight(굵기), font-style(기울임), text-decoration(밑줄), text-indent(첫 줄 들여쓰기), text-align(정렬), line-height(줄 간격), color(글자색), font-family(글꼴) 등을 조합해 여섯 개의 문단을 각각 다르게 꾸밉니다.

축약형으로 다시 쓰기

후반부에서는 동일한 여섯 문단을 font: bold 14px sans-serif; 같은 font 축약형으로 다시 작성하여, 개별 속성 나열 방식과 축약형 방식이 같은 결과를 만든다는 것을 비교해서 보여줍니다. 단, text-decoration·text-indent·text-align처럼 font 축약형에 포함되지 않는 속성은 별도로 계속 작성해야 한다는 점도 확인할 수 있습니다.

HTML css05.html
<!-- [앞부분] 개별 속성을 하나씩 나열하는 방식 -->
<!-- 굵게 + 크기 + 글자색 -->
<p style="font-weight: bold; font-size: 14px; color: blue;">
    1.오늘 점심은 어떤 메뉴일까?</p>

<!-- 기울임 + 글꼴 + 16진수 색상 -->
<p style="font-style: italic; font-size: 16px;
    font-family: '굴림'; color: #E8334D">
    2.오늘 점심은 어떤 메뉴일까?</p>

<!-- 밑줄: text-decoration은 font와 별개의 속성 -->
<p style="text-decoration: underline; font-size: 12px;
    font-family: '돋움';">3.오늘 점심은 어떤 메뉴일까?</p>

<!-- 첫 줄 들여쓰기: text-indent -->
<p style="text-indent: 10px; font-family: '돋움';
    font-size:15px ">4.오늘 점심은 어떤 메뉴일까?</p>

<hr>
<!-- [뒷부분] 같은 문단을 font 축약형으로 다시 작성
     결과는 위와 동일하다 -->
<p style="font: bold 14px sans-serif; color: blue;">
    1.오늘 점심은 어떤 메뉴일까?</p>
<p style="font: italic 16px '굴림'; color: #E8334D">
    2.오늘 점심은 어떤 메뉴일까?</p>
<!-- 밑줄·들여쓰기는 축약형에 못 넣으므로 별도로 작성 -->
<p style="font: 12px '돋움'; text-decoration: underline;">
    3.오늘 점심은 어떤 메뉴일까?</p>
핵심 정리
  • 개별 속성을 하나씩 지정하는 방식과 font 축약형으로 한 줄에 작성하는 방식은 결과가 동일합니다.
  • text-decoration(밑줄), text-indent(들여쓰기), text-align(정렬)은 font 축약형에 포함되지 않는 별도 속성입니다.
  • 인라인 스타일은 style 속성 안에서 세미콜론(;)으로 여러 선언을 구분합니다.
TIPfont 축약형을 쓰면 코드가 짧아지지만 순서 규칙(weight/style → size/line-height → family)을 지키지 않으면 적용되지 않으니 항상 순서를 기억해야 합니다.
실습 파일: css05
06

배경(Background) 속성

background::afteropacityz-index

한 줄 요약배경 네 속성은 background 축약형(이미지 반복 위치/크기)으로 한 줄에 쓸 수 있고, ::after 가상 요소와 opacity·z-index를 조합하면 반투명 배경 오버레이를 만들 수 있다.

쉽게 말하면background 속성은 방 벽지 고르기예요. 색만 칠할지(color), 그림 벽지를 붙일지(image), 무늬를 반복할지(repeat), 어느 위치에 붙일지(position), 스크롤해도 따라오게 할지(attachment)를 정합니다.

background 축약형

배경 관련 속성으로는 background-image(배경 이미지), background-size(이미지 크기), background-repeat(반복 여부), background-position(이미지 위치)이 있으며, 이 네 가지를 각각 나열하는 대신 background 축약형 하나로 background: 이미지 반복 위치/크기; 순서로 합쳐 쓸 수 있습니다. 이 파일의 #box1이 바로 그 예로, background: url("asd.jpg") no-repeat 20px 50px / 100px 100px;처럼 위치와 크기를 슬래시(/)로 구분해 한 줄에 작성했습니다.

::after로 만드는 반투명 오버레이

#box2 부분은 ::after 가상 요소를 이용해 반투명 오버레이 효과를 만드는 예제입니다. ::after는 요소의 실제 콘텐츠 뒤에 가상의 요소를 하나 더 생성하는데, 반드시 content 속성이 있어야 화면에 나타납니다. 여기서는 opacity:0.5로 반투명하게 만든 배경 이미지를 position:absolutez-index:0으로 원래 콘텐츠 뒤에 겹쳐 배치하고 있습니다.

CSS css06.html
#box1 {
    border: 1px solid gray;
    width: 300px;
    height: 300px;
    /* 축약형: 이미지 반복 위치 / 크기 를 한 줄로
       (image + repeat + position / size) */
    background: url("asd.jpg") no-repeat 20px 50px / 100px 100px;
}

#box2 {
    width: 300px;
    height: 300px;
    /* 자식 ::after의 z-index 기준을 만들기 위해
       static이 아닌 position 값을 준다 */
    position: relative;
    z-index: 10;    /* 콘텐츠(글)를 오버레이보다 앞에 */
}

/* 가상 요소: #box2 콘텐츠 뒤에 겹쳐질 반투명 배경층 */
#box2::after {
    content: "";    /* content가 있어야 화면에 렌더링된다 */
    width: 300px;
    height: 300px;
    background-image: url("asd.jpg");
    background-size: 300px 300px;
    opacity: 0.5;             /* 반투명 효과 */
    position: absolute;       /* #box2 기준으로 겹쳐 배치 */
    left: 0px;
    top: 0px;
    z-index: 0;               /* 글(10)보다 뒤로 보낸다 */
}
HTML css06.html
<h1>배경속성</h1>
<!-- 축약형 배경: 테두리 안 지정 위치에 100x100 이미지 -->
<div id="box1"></div>
<!-- 글 뒤에 ::after 반투명 이미지가 겹쳐 보인다 -->
<div id="box2">
    <p class="c">
        korea8일 삼성전자 목표주가를 기존 55만원에서 ...
    </p>
</div>
핵심 정리
  • background 축약형은 이미지, 반복 방식, 위치를 먼저 쓰고 슬래시(/) 뒤에 크기를 붙이는 순서로 작성합니다.
  • ::after 가상 요소는 반드시 content 속성이 있어야 화면에 실제로 렌더링됩니다.
  • opacity로 반투명 효과를 만들고 position:absolute + z-index로 원래 요소 위(또는 뒤)에 겹치게 배치할 수 있습니다.
TIPz-index는 부모나 본인이 position:relative/absolute처럼 static이 아닌 값을 가져야만 의미가 있으며, #box2에 position:relative를 준 것도 자식 ::after의 z-index 기준을 만들기 위해서입니다.
실습 파일: css06
07

박스 모델(Box Model)

content-boxborder-boxmargin:0 auto

한 줄 요약모든 요소는 content·padding·border·margin 4겹 상자이며, box-sizing을 border-box로 바꾸면 padding과 border까지 width에 포함되어 지정한 크기 그대로 렌더링된다.

쉽게 말하면모든 HTML 요소는 택배 상자예요. 내용물(content) → 뽁뽁이(padding) → 상자 벽(border) → 상자와 상자 사이 간격(margin) 순서로 감싸져 있어요. 크기 계산이 헷갈리면 box-sizing:border-box가 “상자 겉치수 기준”으로 바꿔 줍니다.

4겹의 상자 구조

모든 HTML 요소는 안쪽부터 content(내용) → padding(안쪽 여백) → border(테두리) → margin(바깥 여백) 순서로 겹겹이 둘러싸인 상자(box) 형태를 가지는데, 이것을 박스 모델이라고 합니다. 요소의 실제 크기는 이 네 겹이 합쳐져서 결정됩니다.

두 가지 크기 계산법

기본값인 box-sizing: content-box에서는 width/height가 content 영역만을 의미하기 때문에, padding과 border를 추가할수록 실제 화면에 보이는 크기는 지정한 값보다 커집니다. 반면 box-sizing: border-box를 쓰면 width/height 안에 padding과 border까지 포함시켜 계산하므로, 지정한 크기 그대로 렌더링되어 레이아웃을 예측하기 쉬워집니다.

수작업 계산 vs 자동

이 파일은 .content-box(width:300px, padding:10px, border:2px)와 .border-box(width:274px, padding:20px, border:5px)를 나란히 비교합니다. padding·border 차이(총 26px)를 미리 width에서 빼서 274px로 맞춰야 두 박스의 실제 크기가 같아지는데, box-sizing: border-box를 적용하면 이런 수작업 계산 없이 브라우저가 자동으로 처리해 줍니다. 또한 margin: 0 auto는 좌우 마진을 자동으로 나눠 계산해 블록 요소를 가운데 정렬시키는 대표적인 관용 기법입니다.

CSS css07.html
/* 페이지 틀: 좌우 마진을 auto로 → 블록 요소 가운데 정렬 */
main {
    border: 1px solid red;
    width: 1000px;
    margin: 0 auto;          /* 위아래 0, 좌우는 자동 분배 */
}

/* reset 코드: 브라우저 기본 여백을 모두 제거 */
* {
    margin: 0;
    padding: 0;
}

/* BOX1: 기본값(content-box) 기준으로 크기를 잡은 박스 */
.content-box {
    width: 300px;            /* content 영역만 300px */
    height: 200px;
    background-color: aquamarine;
    border: 2px solid red;   /* 테두리만큼 실제 크기 증가 */
    padding: 10px;           /* 안쪽 여백만큼 또 증가 */
}

/* BOX2: padding 20px·border 5px라서 width를 미리 깎은 박스 */
/* padding 차이 20px + border 차이 6px = 26px → 300-26=274px */
.border-box {
    width: 274px;
    height: 200px;
    background-color: aquamarine;
    border: 5px solid red;
    padding: 20px;
}

/* 위 수작업 계산을 브라우저가 대신 해 주는 설정 */
/* width 안에 padding+border까지 포함해서 계산한다 */
.content-box,
.border-box {
    box-sizing: border-box;
}
핵심 정리
  • 박스 모델은 content - padding - border - margin 4겹 구조로 이루어집니다.
  • box-sizing:content-box(기본값)는 width/height가 content 영역만을 의미해 padding·border만큼 실제 크기가 커집니다.
  • box-sizing:border-box는 width/height에 padding과 border까지 포함시켜 지정한 크기 그대로 렌더링되게 합니다.
  • margin:0 auto는 좌우 마진을 자동 계산해 블록 요소를 가운데 정렬시키는 관용적 기법입니다.
TIP실무에서는 크기 계산 실수를 막기 위해 * { box-sizing: border-box; }를 전역으로 걸어두는 경우가 많습니다.
실습 파일: css07
08

Position 속성 & 메뉴 만들기

position:relativeposition:absolutez-index:hover

한 줄 요약relative는 자리를 지키며 자식 absolute의 기준이 되고, absolute는 흐름에서 빠져 그 기준으로 이동하며, 이 조합에 display 전환을 더하면 드롭다운 메뉴가 완성된다.

쉽게 말하면position은 줄 세우기 규칙이에요. static은 기본 줄 서기, relative는 “내 원래 자리 기준으로 살짝 이동”, absolute는 대열에서 빠져나와 “기준 부모 안 아무 데나”, fixed는 화면에 붙인 포스트잇 — 스크롤해도 안 움직입니다.

relative와 absolute

position 속성은 요소를 어떻게 배치할지 결정합니다. position: relative는 요소를 원래 자리를 기준으로 이동시키면서도 문서 흐름에서 자리를 그대로 차지하고, 동시에 자식 요소가 absolute로 배치될 때 기준이 되는 containing block 역할을 합니다. position: absolute는 가장 가까운 relative(또는 absolute) 조상을 기준으로 left/top 값만큼 이동하며 문서 흐름에서 완전히 빠져나옵니다.

z-index 쌓임 순서

여러 요소가 겹칠 때는 z-index 값이 클수록 위쪽에 쌓입니다. 이 파일의 .myred(z-index:100), .myblue(50), .mygreen(10)이 겹치는 순서를 이 값이 결정하며, :hover 시 z-index를 150으로 올려 마우스가 올라간 원이 항상 맨 위로 오게 합니다.

드롭다운 메뉴 패턴

후반부의 메뉴 만들기는 position을 활용한 실전 패턴입니다. 서브 메뉴(.sub_menu)는 기본적으로 display: none으로 숨겨져 있다가, 부모 li에 마우스를 올리면(.main_menu>li:hover>.sub_menu) display: block으로 나타납니다. 이때 서브 메뉴는 absolute로 배치되고, 기준이 되는 .main_menu>li에 relative가 걸려 있어 항상 해당 메뉴 항목 옆 정확한 위치에 나타납니다.

CSS css08.html
/* 자식 absolute의 기준(containing block)이 되는 부모 */
#box {
    height: 600px;
    width: 600px;
    position: relative;      /* 이 박스가 배치 기준이 된다 */
    margin-left: 100px;
}

/* 빨간 원: 문서 흐름에서 빠져 #box 기준으로 이동 */
.myred {
    background-color: red;
    position: absolute;
    left: 200px;             /* #box 왼쪽에서 200px */
    top: 10px;               /* #box 위에서 10px */
    z-index: 100;            /* 숫자가 클수록 위에 쌓인다 */
}
/* .myblue는 z-index:50, .mygreen은 z-index:10 (구조 동일) */

/* 마우스를 올린 원을 키우고 맨 위(150)로 끌어올린다 */
.myred:hover,
.myblue:hover,
.mygreen:hover {
    color: white;
    font-weight: bold;
    font-size: 50px !important; /* 우선순위 무시하고 강제 적용 */
    z-index: 150;
}

/* --- 드롭다운 메뉴 --- */
/* 서브 메뉴: 평소엔 숨김, 부모 li 기준으로 absolute 배치 */
.sub_menu {
    display: none;
    width: 150px;
    position: absolute;
    left: 120px;             /* 부모 li의 오른쪽 옆에 표시 */
    top: 2px;
    background-color: brown;
}

/* 메인 메뉴 li: 서브 메뉴의 배치 기준이 되도록 relative */
.main_menu>li {
    position: relative;
    width: 100px;
}

/* 부모 li에 마우스를 올리면 그 안의 서브 메뉴만 표시 */
.main_menu>li:hover>.sub_menu {
    display: block;
}
HTML css08.html
<!-- 메인 메뉴: li 하나가 서브 메뉴(ul)를 품고 있는 구조 -->
<ul class="main_menu">
    <li>
        <b>메뉴01</b>
        <!-- 평소에는 display:none으로 숨겨져 있는 부분 -->
        <ul class="sub_menu">
            <li>서브메뉴01</li>
            <li>서브메뉴02</li>
            <li>서브메뉴03</li>
        </ul>
    </li>
    <!-- 메뉴02, 메뉴03도 같은 구조를 반복 -->
</ul>
핵심 정리
  • position:relative는 요소를 원래 자리 기준으로 이동시키며, 동시에 자식의 absolute 배치 기준(containing block)이 됩니다.
  • position:absolute는 가장 가까운 relative(또는 absolute) 조상을 기준으로 이동하며 문서 흐름에서 빠집니다.
  • z-index는 숫자가 클수록 위에 쌓이며, position 값이 static이 아닌 요소에만 의미가 있습니다.
  • 드롭다운 메뉴는 하위 메뉴를 display:none으로 숨겼다가 부모 :hover 시 display:block으로 보여주는 패턴으로 구현합니다.
TIP!important는 다른 모든 우선순위 규칙을 무시하고 강제 적용되므로, 남용하면 이후 다른 스타일로 덮어쓰기가 매우 어려워집니다.
실습 파일: css08
09

Flexbox 레이아웃 실습

display:flexflex-directionrow-reversecolumn

한 줄 요약display:flex를 선언하면 자식들이 flex item이 되어 flex-direction이 정한 방향(row·row-reverse·column)으로 배치되며, flex는 안팎으로 중첩해 쓸 수 있다.

쉽게 말하면Flexbox는 정리 도우미예요. 컨테이너에 display:flex만 켜면 아이템들이 한 줄로 서고, justify-content(가로 방향 배분)와 align-items(세로 맞춤)로 “균등한 간격으로, 가운데 맞춰 주세요” 같은 지시를 한 줄로 끝냅니다.

컨테이너와 아이템

display: flex를 선언하면 그 요소는 flex container(플렉스 컨테이너)가 되고, 그 안의 자식 요소들은 자동으로 flex item이 되어 flex-direction이 정한 방향(주축)을 따라 나란히 배치됩니다.

방향을 정하는 주축

flex-direction: row(기본값)는 가로로, row-reverse는 가로로 배치하되 순서를 반대로, column은 세로로 아이템을 쌓습니다. 이 파일의 #container1#container2는 row-reverse로 박스 1~5의 표시 순서를 뒤집었고, #container3은 column으로 header - content - footer를 세로로 배치했습니다.

중첩 flex 레이아웃

#container3 내부의 #content는 다시 display: flex; flex-direction: row로 세 개의 하위 div를 가로로 나열하며, min-height: 80vh로 뷰포트 높이의 80%만큼 최소 높이를 확보합니다. 이렇게 flex는 바깥쪽(세로 레이아웃)과 안쪽(가로 레이아웃)에 중첩해서 적용할 수 있다는 것이 이 예제의 핵심입니다.

CSS css09.html
/* flex 선언 → 이 요소는 컨테이너, 자식들은 아이템이 된다 */
#container1 {
    border: 1px solid red;
    display: flex;
    flex-direction: row-reverse; /* 가로 배치 + 순서 뒤집기 */
}

/* 세로 레이아웃: header→content→footer를 위에서 아래로 */
#container3 {
    border: 1px solid red;
    display: flex;
    flex-direction: column;      /* 주축을 세로로 변경 */
}

#header,
#footer {
    width: 100%;
    height: 100px;
    background-color: aqua;
}

/* 중첩 flex: content 안에서는 다시 가로(row)로 나열 */
#content {
    display: flex;
    flex-direction: row;
    min-height: 80vh;            /* 뷰포트 높이의 80% 확보 */
}

#content>div {
    width: 600px;
    background-color: blue;
    margin: 10px;
}
HTML css09.html
<!-- 박스 1~5: row-reverse라서 5,4,3,2,1 순서로 보인다 -->
<div id="container1">
    <div class="box">1</div>
    <div class="box">2</div>
    <div class="box">3</div>
    <div class="box">4</div>
    <div class="box">5</div>
</div>

<!-- 바깥은 세로(column), 안쪽 content는 가로(row)로 중첩 -->
<div id="container3">
    <div id="header">header</div>
    <div id="content">
        <div>1</div>
        <div>2</div>
        <div>3</div>
    </div>
    <div id="footer">footer</div>
</div>
핵심 정리
  • display:flex를 선언하면 자식 요소들이 자동으로 flex item이 되어 정렬됩니다.
  • flex-direction:row-reverse는 배치 방향은 유지한 채 아이템의 표시 순서만 반대로 바꿉니다.
  • flex-direction:column은 주축을 세로로 바꿔 아이템을 위에서 아래로 쌓습니다.
  • min-height:80vh처럼 뷰포트 단위(vh)를 쓰면 화면 높이에 비례한 최소 높이를 줄 수 있습니다.
TIPflex-direction을 바꾸면 justify-content/align-items가 작동하는 축(주축/교차축)도 함께 바뀌므로, 정렬 속성을 쓸 때는 항상 현재 방향을 먼저 확인해야 합니다.
실습 파일: css09
10

실습 — 선택자 스타일링

inline-blockfont-size:0vertical-align:top

한 줄 요약inline-block은 옆으로 나란히 흐르면서도 크기를 지정할 수 있는 배치 방식이며, 태그 사이 공백 여백은 부모 font-size:0 트릭으로 없앤다.

쉽게 말하면배운 선택자들을 실제 문서에 하나씩 적용해 보는 실습이에요. “이 규칙이 어느 요소에, 왜 적용됐는지”를 눈으로 확인하는 게 목적입니다.

inline-block의 특성

display: inline-block은 인라인 요소처럼 옆으로 나란히 흐르면서도, 블록 요소처럼 width·height·margin·padding을 자유롭게 지정할 수 있는 특성입니다. 이 파일의 #Card1, #Card2는 inline-block으로 카드 두 개를 가로로 나란히 배치하면서 각각 250px×150px 크기를 지정합니다. vertical-align: top은 카드 높이가 다르더라도 위쪽 기준선을 맞춰 나란히 정렬되게 합니다.

공백 제거 트릭

#big { font-size: 0 }은 inline-block 요소 특유의 문제를 해결하는 트릭입니다. HTML 소스에서 태그와 태그 사이에 있는 줄바꿈이나 공백이 inline 요소 사이에서는 작은 여백처럼 렌더링되는데, 부모의 font-size를 0으로 만들면 그 공백의 폭도 0이 되어 사라집니다. 대신 카드 내부에는 font-size: 16px을 다시 지정해 글자가 안 보이지 않도록 복구합니다.

카드 내부 정렬

#card_insidebox-sizing: border-box로 padding 15px을 포함해도 카드 크기가 유지되게 하고, text-align: centerline-height: 110px로 한 줄 텍스트를 카드 가운데에 배치합니다.

CSS test01.html
/* 부모 font-size:0 → 자식 태그 사이 공백의 폭도 0이 된다 */
#big {
    font-size: 0;
}

/* 카드1: 인라인처럼 나란히 + 블록처럼 크기 지정 가능 */
#Card1 {
    display: inline-block;
    width: 250px;
    height: 150px;
    vertical-align: top;     /* 위쪽 기준선을 맞춰 정렬 */
    font-size: 16px;         /* 부모가 0으로 만든 글자 복구 */
    margin-right: 20px;      /* 카드 사이 의도된 간격 */
    border: 5px solid #2c3e50;
}

/* 카드2: margin-right만 없고 나머지는 카드1과 동일 */
#Card2 {
    display: inline-block;
    width: 250px;
    height: 150px;
    vertical-align: top;
    font-size: 16px;
    border: 5px solid #2c3e50;
}

/* 카드 내부: border-box라 padding을 줘도 크기 유지 */
#card_inside {
    box-sizing: border-box;
    padding: 15px;
    text-align: center;      /* 가로 가운데 */
    line-height: 110px;      /* 한 줄 텍스트 세로 가운데 */
}
HTML test01.html
<!-- 태그 사이 줄바꿈·공백이 여백처럼 보이는 문제를
     부모 #big의 font-size:0으로 제거한다 -->
<div id="big">
    <div id="Card1">
        <div id="card_inside">Card 1</div>
    </div>
    <div id="Card2">
        <div id="card_inside">Card 2</div>
    </div>
</div>
핵심 정리
  • display:inline-block은 인라인처럼 옆으로 배치되면서도 width/height/margin/padding을 모두 지정할 수 있습니다.
  • 인라인 요소 사이 공백(줄바꿈)이 여백처럼 보이는 문제는 부모에 font-size:0을, 자식에 다시 원래 font-size를 지정해 해결할 수 있습니다.
  • vertical-align:top으로 높이가 다른 요소들도 위쪽 기준으로 나란히 정렬할 수 있습니다.
TIPfont-size:0 트릭은 예전부터 쓰이던 방법이며, 최신 레이아웃에서는 flexbox의 gap 속성으로 더 간단하게 요소 사이 간격 문제를 해결할 수 있습니다.
실습 파일: test01
11

실습 — 종합 스타일링

justify-contentalign-itemsflex-growmin-height:100vh

한 줄 요약헤더·본문·푸터 페이지 레이아웃을 flexbox만으로 완성하는 종합 미션으로, 주축 정렬(justify-content)·교차축 정렬(align-items)·남는 공간 차지(flex-grow)가 총동원된다.

쉽게 말하면글꼴·배경·박스·배치를 한 페이지에 모두 적용해 보는 종합 실습이에요. 요리로 치면, 지금까지 배운 재료 손질법으로 한 상을 차려 보는 시간입니다.

전체 뼈대 세우기

이 파일은 헤더 - 본문 - 푸터로 이루어진 전형적인 페이지 레이아웃을 flexbox만으로 완성하는 종합 미션입니다. #wrapperdisplay: flex; flex-direction: column; min-height: 100vh로 전체 화면을 세로로 감싸는 뼈대가 됩니다.

두 축의 정렬

header는 display: flexjustify-content: space-between(로고와 버튼을 양쪽 끝으로), align-items: center(세로 중앙 정렬)를 함께 사용합니다. justify-content는 주축(row일 때 가로) 정렬을, align-items는 교차축(세로) 정렬을 담당한다는 점이 이 예제에서 명확히 드러납니다. footer는 justify-content: centeralign-items: center를 함께 써서 저작권 문구를 가로·세로 모두 정중앙에 배치합니다.

남는 공간 차지하기

#contentdisplay: flex; flex-direction: row로 aside(좌) - main - aside(우)를 가로로 배치하고, flex-grow: 1로 wrapper 안에서 남는 세로 공간을 모두 차지합니다. 그 안의 main에도 flex-grow: 1을 주어 좌우 사이드바를 제외한 남은 가로 공간을 혼자 꽉 채우도록 합니다.

CSS test02.html
/* 전체 틀: 세로 flex + 브라우저 전체 높이 확보 */
#wrapper {
    display: flex;
    flex-direction: column;
    min-height: 100vh;       /* 최소 화면 전체 높이 */
}

/* 헤더: 로고와 로그인 버튼을 양쪽 끝으로, 세로는 중앙 */
header {
    display: flex;
    justify-content: space-between; /* 주축(가로) 정렬 */
    align-items: center;            /* 교차축(세로) 정렬 */
}

/* 본문: aside-main-aside 가로 배치 + 남는 세로 공간 차지 */
#content {
    display: flex;
    flex-direction: row;
    flex-grow: 1;            /* wrapper의 남는 높이를 채운다 */
}

/* main: 좌우 사이드바를 뺀 나머지 가로 공간을 혼자 채운다 */
#content main {
    flex-grow: 1;
}

/* 푸터: 저작권 문구를 가로·세로 모두 정중앙에 */
footer {
    display: flex;
    justify-content: center;
    align-items: center;
}
HTML test02.html
<!-- header / content(aside-main-aside) / footer 3단 구조 -->
<div id="wrapper">
    <header>
        <div class="logo">My WebLogo</div>
        <button class="login-btn">로그인</button>
    </header>

    <div id="content">
        <aside class="left">Left Menu</aside>
        <main>
            <h2>메인 콘텐츠 영역</h2>
            <p>여기에 본문 내용이 채워집니다.</p>
        </main>
        <aside class="right">Banner / Info</aside>
    </div>

    <footer>
        <p>© 2026 Flexbox Practice. All rights reserved.</p>
    </footer>
</div>
핵심 정리
  • justify-content는 주축(가로) 정렬을, align-items는 교차축(세로) 정렬을 담당하며, header에서 space-between + center로 함께 사용됩니다.
  • flex-grow:1을 주면 형제 요소들 사이에서 남는 공간을 그 요소가 모두 차지하도록 확장됩니다 (#content, main에 적용).
  • justify-content:center와 align-items:center를 동시에 쓰면 가로·세로 모두 정중앙 정렬이 완성됩니다 (footer 예시).
TIPflex-grow는 값이 클수록 더 많은 남는 공간을 차지하며, 형제 요소마다 다른 값을 주면 비율대로 공간이 나뉩니다.
실습 파일: test02
12

외부 스타일시트 파일

<link>@charset외부 .css

한 줄 요약CSS 규칙을 별도의 .css 파일로 분리해 link 태그로 연결하면 여러 HTML 문서가 같은 스타일을 공유하고 한 곳에서 일괄 수정할 수 있다.

쉽게 말하면CSS를 별도 파일로 빼는 건 유니폼 규정집을 본사에 한 부만 두는 것과 같아요. 각 페이지는 <link>로 연결만 하면 같은 규정을 입고, 규정을 한 번 고치면 모든 페이지에 한꺼번에 반영됩니다.

외부 파일로 분리

외부 스타일시트는 CSS 규칙을 별도의 .css 파일에 작성한 뒤, HTML 문서의 <head> 안에서 <link rel="stylesheet" type="text/css" href="경로">로 연결해서 사용하는 방식입니다. 이렇게 하면 하나의 css 파일을 여러 HTML 파일이 공유할 수 있어 코드 중복을 줄이고, 디자인을 한 곳에서 일괄 수정할 수 있습니다. 예를 들어 css01_작성방식.html은 css/test.css를, css02_selector표현식.html은 css/css02.css를 각각 연결해서 사용합니다.

선택자 모음 css02.css

css02.css는 타입·id·class·전체 선택자부터 자식(>)·인접형제(+)·일반형제(~) 결합자, 속성 선택자([title="..."]), 가상 요소(::after), 가상 클래스(:link, :visited, :active, :hover)까지 다양한 선택자 문법을 실제로 모아둔 파일입니다. 파일 맨 위의 @charset "UTF-8";은 CSS 파일 자체의 문자 인코딩을 지정해 한글 주석과 내용이 깨지지 않도록 합니다.

재사용되는 test.css

test.css처럼 규칙 하나뿐인 아주 짧은 파일(strong { color: chocolate; })도 css01처럼 다른 문서에서 <link>로 그대로 재사용할 수 있습니다. 파일 크기와 상관없이 "분리해 두면 어디서든 다시 쓴다"는 것이 외부 스타일시트의 핵심입니다.

CSS css02.css
/* 파일 자체의 인코딩 지정: 한글 주석·내용 깨짐 방지 */
@charset "UTF-8";

/* 타입 선택자: 태그 이름으로 모든 p 요소 선택 */
p {
    width: 800px;
    background-color: bisque;
}

/* id 선택자: id="sub_title1"인 요소 하나만 선택 */
#sub_title1 {
    font-size: 10px;
}

/* 자식 선택자: sub_title1의 '직계 자식' b만 선택 */
#sub_title1>b {
    color: red;
}

/* 인접 형제 선택자: h1 '바로 다음'에 오는 p 하나 */
h1+p {
    border: 1px solid red;
}

/* 일반 형제 선택자: h1 뒤에 오는 '모든' 형제 p */
h1~p {
    width: 800px;
    background-color: aquamarine;
}

/* 속성 선택자: title 속성값이 정확히 일치하는 p */
p[title="주식관련기사"] {
    background-color: gold;
}

/* 가상 클래스: 링크의 상태(방문 전)에 따라 스타일 */
a:link {
    background-color: chartreuse;
}

/* 가상 클래스: 마우스를 올렸을 때만 적용 */
p:hover {
    background-color: yellow;
    font-weight: bold;
}
CSS test.css
@charset "UTF-8";

/* 규칙 하나뿐인 짧은 파일도 훌륭한 외부 스타일시트다 */
/* HTML에서 <link rel="stylesheet" href="css/test.css"> 로 */
/* 연결하면 어느 문서에서든 그대로 재사용된다 */
strong {
    color: chocolate;
}
핵심 정리
  • <link rel="stylesheet" href="경로">를 <head> 안에 작성하면 외부 .css 파일의 모든 규칙이 해당 문서에 그대로 적용됩니다.
  • @charset "UTF-8";은 CSS 파일 자체의 문자 인코딩을 지정해 한글이 깨지지 않도록 합니다.
  • 하나의 외부 css 파일(test.css)을 여러 HTML 파일에서 재사용할 수 있으며, css01과 css02는 각기 다른 외부 파일을 연결해 사용합니다.
TIP외부 스타일시트의 href 경로는 HTML 파일 위치를 기준으로 한 상대경로이므로, 파일이나 폴더 구조를 옮기면 경로가 깨지지 않는지 항상 확인해야 합니다.
실습 파일: css02.css, test.css

3. JavaScript

작성 방식부터 변수/타입, DOM 조작, 함수·객체·클로저, ES6 문법, 배열/문자열, 날짜, BOM, Cookie, AJAX까지 복습합니다.

01

JS 작성방식 & 3대 구성요소

외부·내부·인라인COREBOMDOM

한 줄 요약자바스크립트는 외부·내부·인라인 세 가지 방식으로 문서에 작성할 수 있고, 언어 자체는 CORE·BOM·DOM이라는 3대 구성요소로 이루어져 있다.

쉽게 말하면웹페이지를 사람에 비유하면 HTML은 뼈대, CSS는 옷, JavaScript는 근육(움직임)이에요. JS 코드는 HTML 안에 직접 쓸 수도 있고(내부), 별도 파일로 빼서(외부) 연결할 수도 있습니다.

세 가지 작성 방식

자바스크립트 코드는 크게 세 가지 방식으로 문서에 작성할 수 있습니다. 첫째는 외부 작성 방식으로 <script src='js/test.js'>처럼 별도의 js 파일을 불러와 연결하는 방식이고, 둘째는 내부 작성 방식으로 <head><body> 안에 <script> 태그를 두고 그 안에 직접 함수를 정의하는 방식입니다. 셋째는 인라인 작성 방식으로 태그의 onclick 같은 이벤트 속성 안에 자바스크립트 코드를 바로 써넣는 방식입니다.

왜 외부 방식인가

실무에서는 유지보수와 코드 재사용성 때문에 외부 파일 방식을 가장 권장합니다. 인라인 방식은 HTML과 JS가 뒤섞이기 때문에 간단한 테스트 외에는 잘 쓰지 않습니다.

3대 구성요소

자바스크립트는 문법·제어문·클로저 같은 언어 자체의 핵심 기능인 CORE(ECMAScript), 창(window)·문서(document)·히스토리 등을 다루는 BOM(Browser Object Model), 그리고 HTML 요소(Node, Element, Event)를 다루는 DOM(Document Object Model)으로 이루어져 있습니다. 이 세 축을 구분해서 이해하는 것이 자바스크립트 학습의 출발점입니다.

HTML js01_작성방식.html
<!-- 1. 외부 작성 방식: 별도의 js 파일을 불러와 연결 -->
<script src='js/test.js'></script>

<!-- 2. 내부 작성 방식: script 태그 안에 직접 함수를 정의 -->
<script>
    function embedded() {
        alert("내부 작성 방식");
    }
</script>

<!-- 3. 인라인 작성 방식: 이벤트 속성 안에 코드를 바로 작성 -->
<dl>
    <dt onclick="var txt='inline방식입니다.';alert(txt);">CORE</dt>
    <dd>문법(변수,제어문,클로져등..)</dd>
    <dd>내장객체(Math,String,Array,Object,Date,RegExp,JSON)</dd>
    <!-- 내부 방식으로 정의한 embedded() 함수를 호출 -->
    <dt onclick="embedded()">BOM</dt>
    <dd>객체(Window,Document,History,Navigator,Screen,Location)</dd>
    <!-- 외부 js 파일(test.js)에 정의된 linked() 함수를 호출 -->
    <dt onclick="linked()">DOM</dt>
    <dd>객체(Node,Element,Event)</dd>
</dl>

<!-- console.log(): 여러 값을 콤마로 나열해 한 번에 출력 -->
<script>
    console.log("콘솔에 메시지 출력", "값1", "값2", "값3");
</script>
핵심 정리
  • 자바스크립트 작성 방식은 외부(external), 내부(internal), 인라인(inline) 세 가지이다.
  • CORE(문법+내장객체), BOM(window 관련 객체), DOM(문서 요소 관련 객체)이 자바스크립트의 3대 구성요소이다.
  • console.log()는 여러 값을 콤마로 나열해 한 번에 출력할 수 있다.
TIP인라인 방식은 HTML과 JS가 뒤섞여 유지보수가 어려워지므로 실무 코드에서는 가급적 피하고 이벤트 리스너로 분리하는 습관을 들이는 것이 좋습니다.
실습 파일: js01
02

대화창 함수

alert()confirm()prompt()

한 줄 요약alert·confirm·prompt는 사용자와 대화하는 3가지 내장 팝업 함수이며, 반환값(없음 / true·false / 문자열 또는 null)이 각각 다르다는 것이 핵심이다.

쉽게 말하면alert/confirm/prompt는 브라우저가 기본 제공하는 3가지 대화창이에요. alert는 알림만(확인 버튼), confirm은 예/아니오 질문(true/false 반환), prompt는 빈칸 있는 질문지(입력값 반환) — 반환값이 서로 다르다는 게 핵심입니다.

세 가지 대화창

자바스크립트는 사용자와 간단히 상호작용할 수 있는 세 가지 대화창(팝업) 함수를 기본으로 제공합니다. alert()는 확인 버튼만 있는 메시지 창이고, confirm()은 확인/취소 버튼을 제공하며 사용자가 확인을 누르면 true, 취소를 누르면 false를 반환합니다. prompt()는 확인/취소 버튼과 함께 텍스트 입력창을 제공하여 사용자가 입력한 문자열을 반환하는데, 취소를 누르면 null이 반환된다는 점이 특징입니다.

반환값이 핵심이다

예제에서는 prompt()로 받은 값을 switch문의 case로 분기 처리하면서 case null:로 취소 상황까지 구분하고 있습니다. 이때 prompt()가 반환하는 값은 항상 문자열(string) 타입이므로 typeof로 확인하면 "1", "2" 같은 문자열이지 숫자가 아니라는 점에 유의해야 합니다.

JS js02_대화창함수.html
// 1. alert(): 확인 버튼만 제공, 메시지만 출력 (반환값 없음)
function alertTest() {
    alert("단순히 메시지만 출력하는 창");
}

// 2. confirm(): 확인/취소 버튼 제공, boolean 반환
function confirmTest() {
    if (confirm("확인버튼을 누르면 true 반환")) {
        console.log("삭제를 진행합니다"); // 확인 → true
    } else {
        console.log("삭제를 취소합니다"); // 취소 → false
    }
}

// 3. prompt(): 입력창 제공, 입력한 문자열 반환 (취소 시 null)
function promptTest() {
    var txt = prompt("과목을 선택하세요(1:java,2:DB,3:JS)",
        "해당번호입력!"); // 두 번째 인자는 입력창의 기본값
    // 반환값은 항상 문자열(string) 타입이라는 점에 주의
    console.log("입력한 값:" + txt, typeof txt);

    // case는 값과 타입까지 정확히 일치(===)해야 매칭된다
    switch (txt) {
        case "1": console.log("자바를 선택했습니다."); break;
        case "2": console.log("데이터베이스를 선택했습니다."); break;
        case "3": console.log("자바스크립트를 선택했습니다."); break;
        case null: console.log("취소했습니다"); break; // 취소 → null
        default: console.log("다시 입력"); break;
    }

    // 입력값을 화면의 span(id="inputVal")에 표시
    document.getElementById("inputVal").textContent = txt;
}
핵심 정리
  • alert()는 메시지 출력, confirm()은 true/false 반환, prompt()는 입력받은 문자열(또는 취소 시 null) 반환.
  • prompt()의 반환값은 항상 문자열이므로 숫자 비교가 필요하면 형변환이 필요하다.
  • switch문의 case는 값과 타입까지 정확히 일치(===)해야 매칭된다.
TIPprompt()에서 사용자가 취소를 누르면 빈 문자열("")이 아니라 null이 반환되므로 case ""와 case null을 혼동하지 않도록 주의해야 합니다.
실습 파일: js02
03

변수와 타입

var·let·const호이스팅typeof스코프

한 줄 요약자바스크립트 변수는 선언 위치에 따라 전역·지역 스코프가 갈리고, var의 호이스팅·재선언 문제를 let·const가 보완하며, 타입은 값을 할당하는 순간 동적으로 결정된다.

쉽게 말하면변수는 이름표 붙은 보관함이에요. var는 옛날식 보관함(같은 이름으로 또 만들 수 있어 위험), let은 내용물을 바꿀 수 있는 보관함, const는 자물쇠 잠긴 보관함(재할당 불가)입니다. typeof는 내용물의 종류를 알려 줘요.

선언 규칙과 스코프

자바스크립트 변수는 대소문자를 구별하며 영문자·_·$로 시작하고 예약어는 사용할 수 없다는 선언 규칙을 가집니다. 변수는 선언된 위치에 따라 스코프(적용 범위)가 달라지는데, 함수 밖에서 선언하면 전역변수가 되어 어디서든 접근 가능하고, 함수 안에서 선언하면 지역변수가 되어 해당 함수 내부에서만 유효합니다.

호이스팅 함정

예제의 test02() 함수처럼 지역변수를 var로 선언하면 실제 선언문보다 위에서 그 변수를 참조해도 에러가 나지 않고 undefined가 출력됩니다. 이는 var의 선언부만 함수 최상단으로 끌어올려지는 "호이스팅" 현상 때문입니다. 또한 var는 같은 이름으로 여러 번 재선언해도 에러 없이 덮어써지는 문제가 있는데(var a=5; var a=10; → 10), ES6의 let은 이 재선언 문제를 막아주고 const는 재할당 자체를 막아 상수를 선언할 수 있게 해줍니다.

동적 타입과 typeof

자바스크립트는 변수를 선언할 때 타입을 명시하지 않는 대신, 값을 할당하는 순간 그 값에 따라 타입이 결정되는 "동적 타입 언어"입니다. typeof 연산자를 사용하면 현재 값의 타입을 확인할 수 있는데, 숫자는 "number", 문자열은 "string", 중괄호로 만든 객체는 "object"가 반환됩니다. 타입이 실행 중에 자유롭게 바뀌다 보니 코드가 커질수록 실수하기 쉬운데, 이런 명확성 문제를 보완하기 위해 등장한 것이 타입을 강제하는 TypeScript입니다.

JS js03_변수와타입.html
var variable = 10; // 함수 밖에서 선언 → 전역변수

function test01() {
    // 전역변수는 함수 안 어디서든 접근·수정 가능
    variable = variable + 5;
    console.log(variable); // 15
}

function test02() {
    // var 지역변수는 선언부만 함수 최상단으로 끌어올려짐(호이스팅)
    // → 전역변수 값이 아니라 undefined가 출력된다
    console.log(variable); // undefined
    var variable = 10;
}

// 할당하기(타입): 값을 넣는 순간 타입이 결정된다(동적 타입)
var t1 = 6;             // 숫자 타입
console.log(typeof t1); // "number"
t1 = "문자";            // 문자열을 넣으면 문자열 타입으로 변경
console.log(typeof t1); // "string"

t1 = { // 중괄호로 만든 객체를 할당
    key1: 5,
    key2: "값",
    key3: function () { console.log("함수객체") }
};
console.log(typeof t1, t1.key2); // "object" "값"
// 타입 명확성 문제를 보완하기 위해 TypeScript 등장

// var 재선언의 문제점: 에러 없이 그대로 덮어써진다
var a = 5;
var a = 10;
console.log(a); // 10 (let은 재선언 시 에러로 막아준다)
핵심 정리
  • var는 함수 스코프이며 재선언이 가능하고 호이스팅으로 인해 선언 전 참조 시 undefined가 나온다.
  • let은 재선언을 막고, const는 재할당을 막는다 (모두 블록 스코프).
  • typeof 연산자로 변수의 현재 타입(number, string, object, function 등)을 확인할 수 있다.
TIP전역변수와 지역변수의 이름이 같으면 함수 안에서는 지역변수가 우선되므로, test02()처럼 var 선언이 함수 뒤쪽에 있어도 호이스팅 때문에 전역변수 값이 아닌 undefined가 출력된다는 점을 꼭 기억해야 합니다.
실습 파일: js03
04

DOM 탐색 메서드

getElementByIdquerySelectorAlltextContent·innerHTML

한 줄 요약DOM 탐색 메서드는 id·name·태그이름·CSS선택자로 HTML 요소를 찾아내는 함수들이며, 1개를 반환하는지 여러 개(배열 형태)를 반환하는지 구분하는 것이 핵심이다.

쉽게 말하면DOM 탐색은 문서에서 “사람 찾기”예요. id로 찾기는 주민번호 조회(정확히 1명), class·태그로 찾기는 “같은 반 전원”(목록으로 반환), querySelector는 CSS 선택자 문법 그대로 쓰는 만능 검색창입니다.

요소를 찾는 4가지 방법

DOM(Document Object Model) 탐색 메서드는 HTML 문서 안의 특정 요소(element)를 자바스크립트에서 찾아내기 위한 함수들입니다. document.getElementById("id이름")id 속성으로 엘리먼트 객체 1개를 반환하고, document.getElementsByName("name이름")은 폼 요소에서 자주 쓰는 name 속성으로 여러 개를 찾아 배열과 유사한 형태(HTMLCollection)로 반환합니다. document.getElementsByTagName("태그이름")은 태그 이름으로 여러 요소를 찾고, document.querySelectorAll("CSS선택자")는 CSS 선택자 문법을 그대로 사용할 수 있어 가장 유연하고 많이 쓰입니다(단수형은 querySelector()).

여러 개는 인덱스로

여러 개를 반환하는 메서드들은 배열처럼 인덱스([0], [1]...)로 접근하고 반복문으로 순회해야 한다는 공통점이 있습니다. 예제처럼 inputs[0].value로 첫 번째 요소만 다루거나, for문으로 전체를 돌면서 스타일을 바꿀 수 있습니다.

내용 바꾸기

요소를 찾은 뒤에는 .style.속성명으로 CSS 스타일을 직접 바꿀 수 있고(p.style.backgroundColor = "red"), .textContent는 순수 텍스트만 교체하는 반면 .innerHTML은 HTML 태그까지 포함한 마크업을 그대로 해석해서 넣어줍니다. 이 차이 때문에 사용자 입력값을 그대로 innerHTML에 넣으면 보안 문제(XSS)가 생길 수 있어, 단순 텍스트를 넣을 때는 textContent를 쓰는 것이 더 안전합니다.

JS js04_DOM탐색메서드.html
function searchId() {
    // 1. id 속성으로 탐색 → 엘리먼트 객체 1개 반환
    const p = document.getElementById("idTest");
    p.style.backgroundColor = "red"; // 스타일 직접 변경
    p.style.color = "white";
    p.textContent = "id로 탐색 가능"; // 순수 텍스트만 교체
    // innerHTML은 태그까지 해석해서 삽입한다
    p.innerHTML = "<b> 태그 내부 html요소 접근</b>";
}

function searchName() {
    // 2. name 속성으로 탐색 → 여러 개(HTMLCollection) 반환
    const inputs = document.getElementsByName("test02");
    inputs[0].value = "첫번째 요소"; // 배열처럼 인덱스로 접근
    for (let index = 0; index < inputs.length; index++) {
        inputs[index].style.backgroundColor = "yellow";
    }
}

function searchTagName() {
    // 3. 태그 이름으로 탐색 → 여러 개 반환, for문으로 순회
    const spans = document.getElementsByTagName("span");
    for (let index = 0; index < spans.length; index++) {
        spans[index].style.backgroundColor = "yellow";
    }
}

function searchQuery() {
    // 4. CSS 선택자를 그대로 사용 (단수형은 querySelector)
    const spans = document.querySelectorAll("p:last-child > span");
    spans[0].style.color = "blue";
}
JS js05.html
// 연습 1: name으로 탐색한 input들의 입력값 읽기
function nameChk() {
    const inputs = document.getElementsByName("nameval01");
    const id = inputs[0].value;  // 첫 번째 input의 값
    const pwd = inputs[1].value; // 두 번째 input의 값
    alert("id:" + id + "/pwd:" + pwd);
}

// 연습 2: div 태그 중 두 번째 요소만 배경색 적용
function tagNameChk() {
    const tags = document.getElementsByTagName("div");
    const tag2 = tags[1]; // 인덱스 1 = 두 번째 엘리먼트
    tag2.style.backgroundColor = "yellow";
}
핵심 정리
  • getElementById는 요소 1개, getElementsByName/getElementsByTagName/querySelectorAll은 여러 개(배열형태)를 반환한다.
  • querySelectorAll은 CSS 선택자를 그대로 쓸 수 있어 가장 유연하다.
  • textContent는 순수 텍스트만, innerHTML은 HTML 마크업까지 해석해서 삽입한다.
TIPgetElementsByTagName/getElementsByName의 반환값은 진짜 배열이 아니라 HTMLCollection이므로 map, filter 같은 배열 전용 메서드를 바로 쓸 수 없고 for문으로 순회해야 합니다.
실습 파일: js04, js05
05

형변환 함수

Number()parseInt()parseFloat()eval()

한 줄 요약input에서 가져온 값은 항상 문자열이므로 숫자 계산 전에 Number·parseInt·parseFloat로 명시적 형변환을 해야 하고, eval은 문자열 수식을 코드로 실행해주지만 보안상 지양한다.

쉽게 말하면형변환은 환전이에요. "100"(문자)과 100(숫자)은 생김새는 같아도 통화가 달라서 계산이 안 됩니다. parseInt/Number가 문자→숫자 환전소, String이 숫자→문자 환전소예요. "1"+1이 11이 되는 사고가 왜 나는지 이해하는 게 핵심입니다.

input 값은 문자열

HTML 입력 필드(<input>)에서 가져온 값은 자바스크립트에서 항상 문자열(string) 타입으로 취급되기 때문에, 이 값을 숫자로 계산하려면 명시적으로 형변환을 해줘야 합니다. 예를 들어 "1234" + 20처럼 문자열에 숫자를 +로 더하면 문자열 결합(concatenation)이 일어나 "123420"이라는 엉뚱한 결과가 나옵니다.

변환 함수 3총사

문자열 전체를 숫자로 바꿀 때는 Number() 함수를 사용합니다. 정수만 필요할 때는 parseInt(), 소수점이 있는 실수가 필요할 때는 parseFloat()을 사용하며, 이 두 함수는 문자열 앞부분에 숫자가 아닌 문자가 섞여 있어도 앞쪽 숫자 부분만 해석해준다는 특징이 있습니다.

eval()의 힘과 위험

eval() 함수는 문자열로 된 코드("5+10" 같은 수식)를 실제 자바스크립트 코드로 해석해서 실행시켜 그 결과를 반환합니다. 강력한 만큼 임의 코드 실행 위험이 있어 신뢰할 수 없는 입력에는 사용하지 않는 것이 원칙입니다.

JS js06.html
// 1. Number(): 문자열 전체를 숫자로 변환
function numTest() {
    const inputObj = document.getElementById("num1");
    // input의 value는 항상 문자열 → "1234"+20 은 "123420"
    // Number()로 숫자로 바꾼 뒤 더해야 1254가 나온다
    console.log(typeof inputObj.value,
        Number(inputObj.value) + 20);
}

// onclick="intTest(int1)"처럼 태그의 id를 그대로 넘기면
// 그 DOM 엘리먼트 객체 자체가 매개변수로 전달된다
function intTest(inputObj) {
    // nodeName: 전달받은 태그의 이름 확인용 → "INPUT"
    console.log(inputObj.nodeName);
    // 2. parseInt(): 정수로 변환 (소수점 이하 버림)
    let paramVal = parseInt(inputObj.value);
    console.log(paramVal + 100);
}

// 3. parseFloat(): 소수점을 포함한 실수로 변환
function floatTest(inputObj) {
    let parseVal = parseFloat(inputObj.value);
    console.log(parseVal + 20);
}

// 4. eval(): 문자열로 된 수식을 실제 코드로 해석해 실행
function evalTest(inputObj) {
    let eValue = inputObj.value; // 예) "5+10"
    console.log(eval(eValue));   // 15
    // 계산 결과를 id가 result인 요소에 출력
    document.querySelectorAll("#result")[0]
        .textContent = eval(eValue);
}
핵심 정리
  • Number(), parseInt(), parseFloat()는 각각 문자열을 숫자/정수/실수로 변환한다.
  • input 요소의 value는 항상 문자열이므로 +연산 전에 형변환이 필요하다.
  • eval()은 문자열을 코드로 실행해주지만 보안상 실무에서는 사용을 지양한다.
TIPonclick="intTest(int1)"처럼 함수 호출 시 태그의 id를 그대로 넘기면 그 DOM 엘리먼트 객체 자체가 매개변수로 전달되므로, 함수 안에서 .value로 값을 꺼내야 합니다.
실습 파일: js06
06

함수·객체·클로저·ES6

화살표 함수prototype클로저구조분해할당

한 줄 요약함수의 세 가지 형태(선언적·익명·화살표), 객체를 만드는 세 가지 방법(리터럴·생성자·프로토타입), 상태를 유지하는 클로저, 그리고 구조분해할당·템플릿 리터럴 같은 ES6 문법을 한 번에 정리하는 단원이다.

쉽게 말하면함수는 자판기(넣으면(매개변수) 나온다(반환값)), 객체는 이름표 달린 서랍장(key로 value를 꺼냄), 클로저는 자판기가 자기 금고(바깥 변수)를 계속 기억하는 성질이에요. 화살표 함수는 자판기 설계도의 축약 표기법입니다.

함수의 세 형태

자바스크립트의 함수는 크게 이름이 있는 선언적 함수(function func01(){...})와 이름이 없는 익명함수(const func02 = function(){...})로 나뉘며, ES6부터는 () => {} 형태의 화살표 함수도 자주 사용합니다. 화살표 함수는 문법이 짧을 뿐 아니라 자신만의 this를 만들지 않고 바깥 스코프의 this를 그대로 물려받는다는 중요한 차이가 있어, 객체의 메서드보다는 콜백 함수(예: setInterval의 인자)로 쓸 때 유용합니다.

객체 만드는 세 방법

첫째, { key: value } 형태로 직접 값을 나열하는 객체 리터럴 방식은 가장 간단하며, jsonObj.subject = "JS"처럼 값을 바꾸고(update), jsonObj["test"] = "value"로 새 속성을 추가하며(insert), delete jsonObj["credit"]로 속성을 지울 수(delete) 있습니다. 둘째, function Info(subject){ this.subject = subject; ... }처럼 this로 정의하고 new Info(...)로 호출하는 생성자 함수 방식은 같은 형태의 객체를 여러 개 찍어낼 때 사용하며, 생성자 함수는 프로토타입이 없는 화살표 함수로는 만들 수 없습니다. 셋째, Info.prototype.addFunc = function(){...}처럼 프로토타입에 메서드를 추가하면 그 생성자로 만든 모든 인스턴스가 동일한 함수를 공유해서 사용할 수 있어 메모리 효율이 좋습니다.

클로저와 ES6 문법

클로저(closure)는 함수가 자신이 생성될 때의 외부 변수(스코프)를 기억해서, 함수 실행이 끝난 뒤에도 그 변수를 계속 유지·활용할 수 있게 하는 기법입니다. 예제의 closureTest2()처럼 내부에 count 변수를 두고 그 변수를 조작하는 함수를 return하면, 반환된 함수를 변수에 담아 호출할 때마다 count가 초기화되지 않고 누적됩니다. ES6 문법으로는 let { subject, test } = jsonObj;처럼 객체의 속성을 변수로 바로 꺼내는 구조분해할당(destructuring)과, 백틱과 ${}로 문자열 속에 변수를 끼워 넣는 템플릿 리터럴을 예제에서 확인할 수 있습니다.

JS js07.html
// 1. 선언적 함수: 이름이 정의되어 있는 기본 함수
function func01() {
    let val = func01_2(5, 10);   // 함수를 호출해 반환값을 받는다
    console.log("결과값:", val); // 15
}
function func01_2(a, b) {
    return a + b; // a+b의 결과를 호출한 쪽으로 반환
}

// 2. 생성자 함수: this로 속성을 정의하고 new로 객체 생성
// (함수 선언은 호이스팅되므로 정의보다 위에서 호출 가능)
const info = new Info();
function Info(subject) {
    this.subject = subject; // 속성 초기화
    this.credit = 2;
    // 화살표 함수는 자신의 this를 만들지 않고
    // 바깥(생성 중인 객체)의 this를 그대로 사용한다
    this.printout = () => this.subject + "," + this.credit + "학점";
    // this가 없는 일반 변수 → 외부에서 접근 불가(은닉화)
    let test = "일반 변수 선언";
    this.getTest = () => test; // 내부 함수를 통해서만 접근
}

// 3. 프로토타입: 모든 인스턴스가 공유하는 메서드 추가
Info.prototype.addFunc = function () {
    console.log(`기능추가:${this.subject}`); // 템플릿 리터럴
};

// 4. 구조분해할당(ES6): 객체의 속성을 변수로 바로 꺼낸다
let { subject, test } = jsonObj;
console.log(`${subject}와${test}를 출력합니다.`);

// 5. 클로저: 함수가 자신의 스코프 변수를 기억해 상태 유지
function closureTest2() {
    let count = 0;          // 반환된 함수가 계속 기억하는 변수
    return function () {    // 이 익명함수가 클로저
        count++;
        console.log(count); // 호출할 때마다 1, 2, 3... 누적
    };
}
// 함수 자체를 변수에 담아두고 호출해야 count가 유지된다
let closureTest2_1 = closureTest2();
핵심 정리
  • 함수는 선언적 함수, 익명함수, 화살표함수로 나뉘며 화살표함수는 자신의 this를 만들지 않는다.
  • 객체는 리터럴 방식, 생성자 함수(new+this) 방식, 프로토타입 공유 방식으로 만들 수 있다.
  • 클로저는 함수가 자신의 스코프 변수를 기억해서 상태를 계속 유지시키는 기법이다.
  • 구조분해할당({a,b}=obj)과 템플릿 리터럴(${})은 ES6에서 추가된 문법이다.
TIPclosureTest2()()처럼 매번 새로 호출하면 그때마다 count가 0으로 초기화되지만, closureTest2()의 반환값을 변수에 한 번만 담아두고 그 변수를 계속 호출하면 count가 누적된다는 차이를 꼭 구분해서 기억해야 합니다.
실습 파일: js07
07

String 객체

concat()===indexOf()split()

한 줄 요약문자열은 +·concat·템플릿 리터럴 등으로 합치고, ==가 아닌 ===로 타입까지 엄격하게 비교하며, indexOf·substring·split·trim 조합으로 원하는 부분을 찾아 잘라내는 것이 핵심이다.

쉽게 말하면String 메서드는 문자열 전용 문구용품 세트예요. substring은 가위(잘라내기), replace는 수정테이프(바꾸기), toUpperCase는 대문자 도장, indexOf는 “몇 번째 글자에 있나” 재는 자. 원본은 안 바뀌고 항상 새 문자열이 반환된다는 게 포인트입니다.

문자열 합치는 네 가지

가장 기본은 + 연산자로 이어붙이는 방식(concatenation)이고, "a".concat("b","c")처럼 여러 문자열을 한 번에 합칠 수도 있습니다. 배열이라면 join()으로 요소들을 구분자와 함께 이어붙일 수 있습니다. 백틱과 ${}를 사용하는 템플릿 리터럴은 변수를 문자열 안에 바로 끼워 넣을 수 있어 가독성이 가장 좋습니다.

==와 ===의 차이

==는 타입을 자동 변환해서 값만 비교하는 느슨한 비교이고, ===는 타입까지 같아야 참이 되는 엄격한 비교입니다. 예제에서 리터럴 "한경"new String("한경") 객체는 ==로는 같지만, 타입(원시값 vs 객체)이 달라 ===로는 같지 않다고 나옵니다. 대소 비교 연산자(<, >)는 유니코드 순서로 사전식 비교를 하며, 다국어 환경에서 더 정확한 정렬이 필요하면 localeCompare()를 사용합니다.

찾아서 자르고 나누기

특정 문자의 위치는 indexOf()(처음 위치)와 lastIndexOf()(마지막 위치)로 찾고, 없으면 -1을 반환합니다. 찾은 인덱스로 substring(시작, 끝) 하면 일부를 잘라낼 수 있고, split(",")은 구분자를 기준으로 문자열을 배열로 쪼개며, trim()은 앞뒤 공백을 제거합니다. match(/[0-9]/)처럼 정규표현식을 넣으면 숫자 포함 여부 같은 패턴 검사도 가능합니다.

JS js08.html
// [1] 문자열 합치기 4가지 방법
function strTest01() {
    let string01 = "String";
    let string02 = "Test";

    // + 연산자: 만나는 값을 모두 문자열로 바꿔 이어붙임
    let string03 = string01 + string02;

    // concat(): 여러 문자열을 한 번에 합칠 때
    let newString = "String".concat("test", "java", "script");

    // 템플릿 리터럴: 백틱(`)과 ${}로 변수를 바로 끼워 넣음
    let templateStr = `${string01} ${string02} Javascript`;

    // join(): 배열 요소를 구분자와 함께 하나의 문자열로
    let arr = ["String", "Test", "Javascript"];
    let joinStr = arr.join(" ");
}

// [2] 문자열 비교: ==(느슨한 비교) vs ===(엄격한 비교)
function strTest02() {
    let numVal = 10; // 숫자형
    // ==: 타입을 자동 변환해서 값만 비교 → true
    if (numVal == "10") console.log("==연산자사용:값이 같습니다.");
    // ===: 타입(숫자 vs 문자열)이 달라 → false
    if (numVal === "10") { }
    else console.log("===연산자사용:값이 다릅니다.");

    // 리터럴 문자열 vs new String() 객체
    let strLit = "한경";             // 원시값(string)
    let strObj = new String("한경"); // 객체(object)
    if (strLit == strObj) console.log("==같다"); // 값만 비교
    if (strLit === strObj) { }
    else console.log("===같지않다"); // 타입이 달라 false

    // match(): 정규표현식으로 패턴 검사(숫자 포함 여부)
    let strVal = prompt("당신의 회사명은?", "");
    if (strVal.match(/[0-9]/)) alert("숫자는 포함하면 안되요");
}

// [3] 위치 찾기 → 잘라내기 → 나누기 → 공백 제거
function strTest03() {
    let strVal =
        "문자열 추출하기. 관련 메서드:indexOf()메서드, substring()메서드";
    let sIdx = strVal.indexOf(":");     // ":"가 처음 나온 위치
    let eIdx = strVal.lastIndexOf("."); // "."가 마지막 나온 위치
    // 시작 인덱스+1부터 끝 인덱스 전까지 추출
    let result = strVal.substring(sIdx + 1, eIdx);
    let splitVal = result.split(",");   // ","를 기준으로 배열로 분리
    let strVal01 = splitVal[0].trim();  // 앞뒤 불필요한 공백 제거
    let strVal02 = splitVal[1].trim();
}
핵심 정리
  • ==는 타입을 자동 변환해서 비교하고, ===는 타입까지 같아야 참이 된다.
  • indexOf/lastIndexOf로 위치를 찾고 substring/split으로 문자열을 잘라내거나 나눈다.
  • 템플릿 리터럴(백틱+${})은 문자열 결합보다 가독성이 좋아 실무에서 많이 쓰인다.
TIPnew String("값")으로 만든 문자열은 겉보기엔 같아 보여도 타입이 object이므로, ===로 리터럴 문자열과 비교하면 항상 false가 나온다는 점이 자주 헷갈리는 포인트입니다.
실습 파일: js08
08

Array 배열

sort()push()/pop()얕은·깊은 복사...(스프레드)

한 줄 요약배열은 리터럴 방식으로 만들고, 숫자 정렬에는 비교 함수가 필요하며, 단순 대입은 주소만 복사하는 얕은 복사라서 slice나 스프레드 연산자로 깊은 복사를 해야 원본이 보호된다.

쉽게 말하면배열은 번호 붙은 사물함 한 줄이에요. 번호(인덱스)는 0부터 시작하고, push/pop은 맨 뒤에 넣기/빼기, unshift/shift는 맨 앞에 넣기/빼기, splice는 중간 칸을 수술하는 도구입니다.

리터럴과 크기순 정렬

배열은 new Array(3)처럼 길이만 지정해서 만들 수도 있지만 이 경우 값이 자동 초기화되지 않으므로, 실무에서는 [1, 2, 3, 4]처럼 대괄호를 사용하는 배열 리터럴 방식이 권장됩니다. sort()는 기본적으로 요소를 문자(사전식)로 취급해 정렬하기 때문에 숫자 배열에 그냥 쓰면 102보다 앞에 오는 문제가 생깁니다. 이를 해결하려면 arr.sort((a, b) => a - b)처럼 오름차순/내림차순을 직접 정해주는 비교 함수를 넣어야 합니다.

넣고 빼는 메서드

push()는 배열 끝에 요소를 추가합니다. shift()는 첫 번째 요소를, pop()은 마지막 요소를 꺼내면서 동시에 배열에서 제거합니다. 꺼낸 값은 변수로 받아 바로 사용할 수 있습니다.

얕은 복사 vs 깊은 복사

const cc = aa;처럼 그냥 대입하면 실제 값이 아니라 메모리 주소만 복사되는 얕은 복사가 일어나서, cc를 바꾸면 원본 aa까지 함께 바뀝니다. 반면 slice()나 스프레드 연산자([...ee])는 새 배열을 만들어 값을 복사하는 깊은 복사라서 복사본을 바꿔도 원본이 유지됩니다(단, 배열 안의 값이 다시 객체/배열이면 내부까지는 복사되지 않는 한계가 있습니다). 또한 function spreadTest(...val)처럼 매개변수 앞에 ...을 붙이면 개수가 정해지지 않은 인자들을 배열 하나로 모아 받는데, 이를 rest 파라미터라고 하며 스프레드 연산자와 짝을 이루는 ES6 문법입니다.

JS js09.html
// 배열 선언: new Array()보다 리터럴([]) 방식을 권장
let arrayObj = new Array(3);  // 길이만 지정, 값은 초기화 안 됨
let arrayLit = [1, 2, 3, 4];  // 배열 리터럴(권장)

// 숫자 크기순 정렬: sort()는 기본이 사전식이라 비교함수 필요
function sortTest02() {
    let arrayTest = [1, 3, 2, 10, 7, 6, 4, 5, 9, 8];
    // (a, b) => a - b : 음수면 a가 앞 → 오름차순
    // (b - a로 바꾸면 내림차순)
    arrayTest.sort((a, b) => a - b);
    console.log(arrayTest.toString());
}

// push(): 끝에 추가 / shift(): 첫 요소를 꺼내며 제거
// pop(): 마지막 요소를 꺼내며 제거
function pushAndShift() {
    const queue = [];
    queue.push("first");
    queue.push("second");
    queue.push("third");
    let val = queue.shift(); // "first"를 꺼냄
    let val2 = queue.pop();  // "third"를 꺼냄
}

// 얕은 복사 vs 깊은 복사
function sliceTest() {
    const aa = [1, 2, 3, 4, 5];
    // 얕은 복사: 주소만 복사 → cc를 바꾸면 원본 aa도 바뀜
    const cc = aa;
    cc[0] = 10;

    // 깊은 복사: 값을 하나씩 새 배열에 복사 → 원본 유지
    const bb = [1, 2, 3, 4, 5];
    const dd = [bb[0], bb[1], bb[2], bb[3], bb[4]];

    // 스프레드 연산자로 깊은 복사(값이 기본타입일 때)
    const ee = [1, 2, 3, 4, 5];
    const ff = [...ee];

    spreadTest(1, 2, 3, 4, 5, 6); // 인자 개수가 가변적
}

// rest 파라미터: 넘어온 인자들을 val 배열 하나로 모아 받음
function spreadTest(...val) {
    for (i = 0; i < val.length; i++) {
        console.log(val[i]);
    }
}
핵심 정리
  • sort()는 기본이 사전식 정렬이므로 숫자 크기순 정렬은 비교함수 (a,b)=>a-b가 필요하다.
  • 단순 대입(=)은 얕은 복사(주소 공유)이고, slice()나 스프레드(...)는 깊은 복사(새 배열 생성)이다.
  • ...val 형태의 rest 파라미터로 개수가 정해지지 않은 인자를 배열로 받을 수 있다.
TIP스프레드 연산자로 만든 깊은 복사도 배열 안의 값이 배열/객체(참조타입)라면 그 내부는 여전히 주소를 공유하므로 완전한 깊은 복사가 아니라는 점을 주의해야 합니다.
실습 파일: js09
09

Lotto 번호 생성기

Math.random()includes()appendChild()transform

한 줄 요약생성자 함수·난수·중복 검사·정렬·DOM 조작을 한데 모아, 중복 없는 1~45 로또 번호를 뽑아 화면에 출력하고 당첨 확인과 움직임 효과까지 붙이는 종합 실습이다.

쉽게 말하면로또 기계를 코드로 만든 실습이에요. Math.random()이 공 뽑기, 중복 검사가 “이미 나온 공은 다시 안 뽑기”, sort가 뽑은 공을 번호순으로 정렬해 보여 주기에 해당합니다.

생성자 함수로 뽑기

js10.htmlfunction Lotto(){ this.balls=[]; ... } 형태의 생성자 함수 안에 번호 생성 메서드 makeBall()과, 7개(당첨 6개+보너스 1개)가 찰 때까지 반복하는 lottoBalls()를 정의합니다. Math.random()은 0 이상 1 미만의 실수를 반환하므로 Math.floor(Math.random() * 45) + 1로 계산하면 1~45 사이의 정수가 됩니다. 이미 뽑힌 번호가 다시 나오지 않도록 this.balls.includes(ball)(구버전 방식은 indexOf(ball) == -1)로 중복 여부를 검사합니다.

보너스 분리와 출력

번호를 다 뽑은 뒤에는 스프레드 연산자로 배열을 복사([...lotto.balls])하고 pop()으로 마지막 번호를 보너스로 분리합니다. 이어서 sort((a,b)=>a-b)로 나머지 6개를 오름차순 정렬하고 join("-")으로 "1-2-3-4-5-6" 형식의 문자열을 만들어 span.textContent에 출력합니다.

당첨 확인과 움직임 효과

js10_당첨기능.htmlmakeLotto()isCheck()로 중복 없는 번호를 채우고, 입력한 매수만큼 userBall()로 로또 배열을 만든 뒤 win()으로 당첨번호와 하나씩 비교해 일치하면 td.setAttribute("bgcolor", "orange")로 당첨 칸을 표시합니다. 이때 innerHTML 대신 createElement·createTextNode·appendChild()로 표의 행과 셀을 직접 만들어 붙이는 DOM 생성 방식을 사용합니다. js10_효과.html은 로또공 45개의 transform: translate() 값을 Math.random()으로 계속 바꿔 공이 움직이는 효과를 만들고, 공을 클릭하면 6개까지 선택되어 옆으로 정렬되며 그 이상은 alert()로 막습니다.

JS js10.html
// 생성자 함수: this 예약어로 속성·메서드를 정의
function Lotto() {
    this.balls = []; // 뽑은 번호를 저장할 배열

    // 1~45 정수 난수: random()은 0 이상 1 미만 실수 반환
    this.makeBall = () => {
        return Math.floor(Math.random() * 45) + 1;
    }

    // 중복 없이 7개(당첨 6 + 보너스 1)가 찰 때까지 반복
    this.lottoBalls = () => {
        let count = 0;
        while (count < 7) {
            let ball = this.makeBall(); // 랜덤 숫자 생성
            // includes(): 이미 뽑힌 번호면 건너뜀
            // (구버전 호환 방식은 indexOf(ball) == -1)
            if (!this.balls.includes(ball)) {
                this.balls.push(ball); // 배열에 저장
                count++;
            }
        }
    }
}

function lottoPrint() {
    const lotto = new Lotto();
    lotto.lottoBalls();
    const arr = [...lotto.balls]; // 스프레드로 배열 복사
    const bonus = arr.pop();      // 마지막 번호를 보너스로 분리
    arr.sort((a, b) => a - b);    // 남은 6개를 오름차순 정렬
    // "1-2-3-4-5-6" 형식으로 span에 출력
    document.querySelectorAll("span")[1].textContent = arr.join("-");
    document.querySelectorAll("span")[3].textContent = bonus;
}
JS js10_당첨기능.html · js10_효과.html
// [js10_당첨기능.html] 당첨번호에 같은 숫자가 있는지 확인
function win(lottos, userNum) {
    var bool = false;
    for (var i = 0; i < lottos.length; i++) {
        if (lottos[i] == userNum) bool = true;
    }
    return bool;
}

// innerHTML 없이 표의 행(tr)·셀(td)을 직접 만들어 붙이기
var tr = document.createElement("tr");
var td = document.createElement("td");
var txt = document.createTextNode(user[j][k]);
td.appendChild(txt);
tr.appendChild(td);
if (win(lotto, user[j][k])) {
    td.setAttribute("bgcolor", "orange"); // 당첨 칸 표시
}

// [js10_효과.html] ~~는 소수점을 버리는 트릭(Math.floor와 유사)
function rnum() { return ~~(Math.random() * 500); }
// transform 값을 난수로 계속 바꿔 공이 움직이는 효과
balls[i].style.transform =
    "translate(" + rnum() + "px," + rnum() + "px)";
핵심 정리
  • Math.floor(Math.random()*45)+1 공식으로 1~45 사이의 정수 난수를 만든다.
  • includes()나 indexOf()==-1로 배열 내 중복값 여부를 검사해서 중복 없는 번호를 뽑는다.
  • pop()/sort()/join()을 조합해 보너스 분리 → 정렬 → "1-2-3" 형식 문자열 완성까지 한 흐름으로 처리한다.
  • createElement/createTextNode/appendChild로도 innerHTML 없이 요소를 직접 만들어 화면에 붙일 수 있다.
TIP~~(물결 두 개, 비트 NOT 연산자를 두 번 적용)는 Math.floor()와 비슷하게 소수점을 버리고 정수로 만드는 트릭으로, js10_효과.html과 js10_당첨기능.html에서 Math.floor() 대신 사용된 것을 볼 수 있습니다.
실습 파일: js10, 당첨기능, 효과
10

Array 반복문 4종 비교

forforEach()map()filter()

한 줄 요약배열 순회 4가지 중 forEach는 단순 반복, map은 같은 길이의 새 배열로 만드는 "가공", filter는 조건에 맞는 요소만 남기는 "선별"이라는 반환값 차이가 핵심이다.

쉽게 말하면반복문 4종은 같은 목적지로 가는 다른 교통수단이에요. for는 수동 운전(인덱스 직접 제어), for...of는 값을 하나씩 꺼내 주는 자동 운전, forEach는 배열 전용 셔틀, for...in은 객체의 key를 도는 열차 — 배열에 잘못 태우면 순서 보장이 안 될 수 있어요.

for문과 forEach

배열을 순회하는 방법은 한 가지가 아니며, 이 파일은 가장 많이 쓰이는 네 가지(일반 for문, for-in문, forEach, map/filter)를 나란히 비교합니다. 일반 for문은 index 변수를 직접 제어하기 때문에 코드가 길어지지만, 큰 배열을 다룰 때 상대적으로 성능이 좋습니다. forEach는 ES5(2009년)에 등장한 문법으로 index 변수를 실수로 잘못 다루는 위험을 줄여주므로, 특별히 index를 직접 써야 할 이유가 없다면 for문보다 forEach를 권장합니다.

map은 가공, filter는 선별

mapfilter는 forEach와 달리 "새로운 배열"을 반환한다는 점이 핵심입니다. map은 배열을 순회하면서 각 요소를 변형한 값으로 새 배열을 만들기 때문에 콜백 함수 안에 반드시 return 값이 있어야 합니다. filter는 조건식이 true인 요소만 걸러서 새 배열을 만듭니다. 즉 map은 "가공", filter는 "선별"이 목적이라는 차이를 기억해야 합니다.

JS js11.html
const array = [1, 2, 3, 4, 5];

// [1] 일반 for문: index를 직접 제어. 큰 배열에서 성능 유리
for (let index = 0; index < array.length; index++) {
    const element = array[index];
}

// [2] for-in문: index 없이 key를 순회(주로 객체 속성용)
for (const key in object) {
    console.log(key);
}

// [3] forEach문: ES5(2009년). 코드가 간소화되고
//     index 변수를 잘못 선언해 실수할 확률도 줄여줌
array.forEach(function (item, index, arr) {
    console.log(`${index} : ${item}`);
});

// [4] map(): 배열을 순회하며 각 요소를 가공한 값으로
//     "새로운 배열"을 만들어 반환
const arrayMap = array.map((item) => {
    let newVal = item + 1;
    return newVal; // map은 반환값이 꼭 있어야 한다
});

// filter(): 조건이 true인 요소만 골라 새 배열 생성
const arrayFilter = array.filter((item) => {
    return item % 2 === 0; // 2의 배수만 선별
});
console.log(arrayFilter.toString());
핵심 정리
  • for문은 index를 직접 다루므로 실수 위험은 있지만 대용량 배열에서 성능이 유리합니다.
  • forEach는 반환값이 없고(undefined), map은 콜백의 return 값들로 새 배열을 만들며, filter는 조건이 참인 요소만 골라 새 배열을 만듭니다.
  • for-in문은 배열보다는 객체의 속성(key)을 순회할 때 주로 사용되며, 배열에서는 index 없이 값을 얻기 어렵습니다.
TIPmap은 "무조건 배열의 길이가 그대로 유지되는 변형", filter는 "조건에 따라 길이가 줄어드는 선별"이라고 구분해서 외우면 헷갈리지 않습니다.
실습 파일: js11
11

Date 객체

new Date()getMonth()+1getTime()setDate()

한 줄 요약Date 객체의 월은 0부터 시작하므로 표시할 때 +1이 필요하고, 두 날짜의 차이는 getTime이 반환하는 밀리초 값을 빼서 하루(1000×60×60×24ms)로 나누면 구할 수 있다.

쉽게 말하면Date 객체는 손목시계 겸 달력이에요. new Date()로 “지금”을 찍고 getFullYear() 등으로 읽는데, 함정은 getMonth()가 0부터 시작(1월=0)한다는 것 — 화면에 보여 줄 땐 +1 해야 합니다.

월은 0부터 시작

Date 객체는 날짜와 시간을 다루기 위한 자바스크립트 내장 객체입니다. new Date()를 인자 없이 호출하면 현재 시각을 담은 객체가 생성되고, new Date(년, 월, 일)처럼 값을 직접 넣으면 특정 날짜를 만들 수 있습니다. 가장 헷갈리는 부분은 월(month)인데, getMonth()와 Date 생성자 모두 1월을 0으로 취급하는 0부터 시작하는 인덱스라서 실제 "7월"을 표현하려면 6을 넣어야 합니다(코드에서는 7 - 1로 명시해 실수를 막았습니다).

getTime으로 날짜 차이

날짜 간의 차이를 계산할 때는 getTime() 메서드가 핵심입니다. getTime()은 1970년 1월 1일 기준으로 흘러간 시간을 밀리초(ms) 단위로 반환하므로, 두 Date 객체의 getTime() 값을 빼면 그 차이(밀리초)가 나옵니다. 이를 1000(초)×60(분)×60(시)×24(일)로 나누면 "며칠 차이인지"를 계산할 수 있고, 이 원리로 D-Day 기능을 구현합니다.

날짜 더하기 자동 계산

setDate(date.getDate() + n)처럼 날짜에 숫자를 더하면 자바스크립트가 월말/월초를 넘어가는 계산까지 자동으로 처리해 줍니다. 예를 들어 16일에 10을 더해 26이 되는 것은 물론, 36처럼 그 달을 넘어가는 값이 나와도 다음 달 날짜로 알아서 환산됩니다.

JS js12.html
// 오늘 날짜를 원하는 형식으로 출력
function testDate02() {
    const date = new Date();         // 인자 없으면 현재 시각
    let year = date.getFullYear();   // 년도
    let month = date.getMonth() + 1; // 월: 0~11이므로 +1 필수
    let day = date.getDate();        // 일
    let week = date.getDay();        // 요일: 0(일)~6(토)
    const dayOfWeek = ["일", "월", "화", "수", "목", "금", "토"];
    console.log(`오늘날짜:${year}.${month}.${day}.${dayOfWeek[week]}`)
}

// 특정 날짜 만들기: 월이 0부터라 7월은 7-1로 적어 실수 방지
function testDate03() {
    const date = new Date(2026, 7 - 1, 18);
}

// 경과 날짜: setDate()로 더하면 월말을 넘어도 자동 계산
function testDate04() {
    const date = new Date();
    let inputVal = document.getElementById("inputDate").value;
    date.setDate(date.getDate() + parseInt(inputVal));
    document.getElementById("resultDate").value =
        date.toLocaleDateString();
}

// D-Day: getTime()은 1970년 1월 1일부터 흘러간
// 시간을 밀리초(ms) 단위로 반환함
function testDate05() {
    const nowDate = new Date();      // 오늘 날짜 객체
    let date = document.getElementById("d_day").value;
    const afterDate = new Date(date); // 수료일 날짜 객체
    // 두 시각의 차(ms)를 하루(1000*60*60*24ms)로 나눠 일수 계산
    let period = Math.ceil(
        (afterDate.getTime() - nowDate.getTime())
        / (1000 * 60 * 60 * 24));
    document.getElementById("period").value = period;
}
핵심 정리
  • getMonth()는 0~11 범위를 반환하므로 실제 월을 표시하려면 항상 +1을 해야 합니다.
  • getDay()는 요일을 0(일요일)~6(토요일) 숫자로 반환하므로 배열과 매핑해서 문자로 바꿔야 합니다.
  • 두 날짜의 차이를 구하려면 getTime()으로 밀리초 값을 구한 뒤 (1000*60*60*24)로 나누어 일(day) 단위로 변환합니다.
TIPsetDate(date.getDate() + n)처럼 계산했을 때 일(day) 값이 그 달의 마지막 날을 넘어가도 자바스크립트가 알아서 다음 달로 넘겨 계산해 줍니다.
실습 파일: js12
12

팝업창(window)

window.open()window.openere.target

한 줄 요약window.open으로 팝업(자식) 창을 열고 자식 창은 window.opener로 부모 문서에 접근해 값을 주고받으며, 이벤트 객체의 e.target은 이벤트가 실제 발생한 요소를 가리킨다.

쉽게 말하면window.open은 새 창을 여는 리모컨이에요. 크기·위치·메뉴 표시 여부를 옵션 문자열로 지정하고, opener로 “나를 연 원래 창”과 대화할 수도 있습니다. 요즘 브라우저는 팝업 차단이 기본이라 사용자가 클릭했을 때만 열려요.

window.open 세 인자

window.open(url, title, options)은 새로운 브라우저 창(팝업)을 여는 메서드입니다. 첫 번째 인자는 열 페이지 주소, 두 번째는 창의 이름(제목)입니다. 세 번째는 "width=400,height=400,top=300,left=200"처럼 콤마로 구분된 문자열로 창의 크기와 위치를 지정합니다.

opener로 부모 접근

팝업으로 열린 자식 창에서는 window.opener라는 특수한 참조를 통해 자신을 열어준 부모 창의 document에 접근할 수 있고, 반대로 부모 창에서는 팝업창 변수를 통해 자식 창을 제어할 수 있습니다. 이 구조 덕분에 부모-자식 창 사이에 값을 주고받는 팝업 연동 화면(예: 주소 검색 팝업)을 만들 수 있습니다. 창을 닫을 때는 window.self.close()를 사용하며, self/top/parent는 각각 현재 창/최상위 창/부모 프레임을 가리키는 창 계층 구조상의 참조입니다.

e.target의 의미

이벤트 리스너 함수의 첫 번째 매개변수(관례상 e)는 이벤트 객체이고, e.target은 "실제로 이벤트가 발생한 요소"를 가리킵니다. 버튼 클릭 이벤트라면 e.target은 클릭된 그 버튼 자신이 되므로, e.target.style이나 e.target.textContent로 클릭된 요소를 직접 조작할 수 있습니다.

JS js13.html
// 부모 페이지: 버튼 클릭 시 팝업창 열기
window.onload = function () {
    document.querySelector("#btn01").onclick = function (e) {
        // e는 이벤트 객체, e.target은 클릭된 요소(버튼 자신)
        e.target.style.backgroundColor = "red";
        e.target.textContent = "테스트"

        let url = "js13_pop.html"; // 팝업으로 열 페이지 주소
        let title = "팝업창페이지"; // 창의 이름(제목)
        // 콤마로 구분한 문자열로 크기(width/height)와
        // 위치(top/left)를 지정
        let prop = "width=400,height=400,top=300,left=200";

        window.open(url, title, prop); // 팝업(자식) 창 열기
    }
}
JS js13_pop.html
// 팝업(자식) 페이지
onload = function () {
    // window.opener: 이 팝업을 열어준 부모 창을 가리킴
    // 부모 창의 name="val01" 입력값을 읽어 화면에 표시
    let val =
        window.opener.document.getElementsByName("val01")[0].value;
    document.getElementById("val").textContent = val;

    // 창 닫기 버튼: self/top/parent는 브라우저 창
    // 계층구조상의 위치를 가리키는 참조
    document.getElementsByTagName("button")[1].onclick = function () {
        window.self.close();
    }
}
핵심 정리
  • window.open(url, title, options)의 세 번째 인자로 팝업의 크기(width/height)와 위치(top/left)를 지정합니다.
  • 팝업(자식) 창에서 window.opener로 자신을 연 부모 창의 document에 접근할 수 있습니다.
  • 이벤트 핸들러의 매개변수 e는 이벤트 객체이며, e.target은 실제로 이벤트가 발생한 DOM 요소를 가리킵니다.
TIP브라우저의 팝업 차단 기능 때문에 window.open이 사용자 클릭 등 실제 이벤트 안에서 호출되지 않으면 막힐 수 있습니다.
실습 파일: js13, pop
13

Location 객체

location.hrefsearchhashreplace()

한 줄 요약location 객체는 현재 문서의 URL을 protocol·host·pathname·search·hash 조각으로 읽어내고, href 대입·assign·replace로 페이지 이동까지 담당하는 전역 객체다.

쉽게 말하면location 객체는 브라우저 주소창의 조종간이에요. href를 읽으면 현재 주소, href에 대입하면 이동(뒤로가기 기록 남음), replace()는 기록 없이 갈아타기, reload()는 새로고침 버튼입니다.

URL을 조각으로 읽기

location 객체는 현재 브라우저 창에 열려 있는 문서의 URL 정보를 담고 있는 전역 객체입니다. URL은 여러 조각으로 이루어져 있어서 protocol(http:/https: 같은 통신 규약), host(도메인+포트), port(포트 번호만), pathname(도메인 뒤의 파일 경로)처럼 각 속성으로 조각을 따로 꺼낼 수 있습니다.

쿼리스트링과 해시

search는 물음표(?) 뒤에 붙는 쿼리스트링(예: ?id=hk), hash는 샵(#) 뒤에 붙는 해시값을 가져옵니다. 쿼리스트링은 서버에 데이터를 전달할 때 사용하고, 해시는 새로고침 없이 페이지 내 특정 위치로 이동하거나 자바스크립트로 데이터를 전달할 때 주로 사용됩니다.

페이지 이동 메서드

location.href에 새 주소를 대입하거나 location.assign(url)을 호출하면 방문 기록이 남아 뒤로 가기가 가능합니다. 반면 location.replace(url)는 현재 기록을 새 주소로 대체해버려 뒤로 가기 버튼으로 돌아올 수 없고, location.reload()는 새로고침(F5)입니다. 또한 iframe의 name 속성을 이용하면 subframe.location.href = "..."처럼 특정 iframe 영역만 골라 이동시킬 수 있습니다.

JS js14.html
// URL의 각 조각을 location의 속성으로 꺼내 출력해 봅니다.

// hash: # 뒤의 해시값 (예: #id=hk) — 서버로 전송되지 않음
document.write("hash:" + location.hash + "<br/>");

// search: ? 뒤의 쿼리스트링 (예: ?id=hk) — 서버로 전달되는 값
document.write("search:" + location.search + "<br/>");

// host: 도메인 + 포트 번호 (예: localhost:5500)
document.write("host:" + location.host + "<br/>");

// port: 포트 번호만 (예: 5500)
document.write("port:" + location.port + "<br/>");

// pathname: 도메인 뒤의 파일 경로 (예: /3.javascript/js14.html)
document.write("pathname:" + location.pathname + "<br/>");

// protocol: 통신 규약 (예: http:, https:, file:)
document.write("protocol:" + location.protocol + "<br/>");

// href: 전체 URL. 읽을 수도 있고, 새 주소를 대입하면 이동합니다.
document.write("href:" + location.href + "<br/>");

// --- 페이지 이동 메서드 3가지 ---
// location.assign(url);  // 이동 (방문 기록이 남아 뒤로 가기 가능)
// location.reload();     // 현재 페이지 새로고침 (F5 기능)
// location.replace(url); // 기록을 대체해 뒤로 가기 불가
HTML js14.html
<!-- 이동하면서 URL 끝의 # 뒤에 해시 데이터를 함께 보냅니다. -->
<a href="js14_location.html#id=hk&addr=seoul">요청</a>

<!-- name이 subframe인 iframe 영역만 지정 페이지로 이동 -->
<button onclick="subframe.location.href='js12_Date객체.html'">
    이동
</button>

<!-- 화면 안에 다른 웹페이지를 삽입하는 액자(프레임) 태그 -->
<iframe name="subframe" src="js13_pop.html" height="500px">
</iframe>
핵심 정리
  • search는 "?"로 시작하는 쿼리스트링, hash는 "#"으로 시작하는 해시값을 가져옵니다.
  • location.href = url 또는 location.assign(url)은 방문 기록이 남지만, location.replace(url)는 현재 기록을 대체해 뒤로 가기가 불가능합니다.
  • iframe의 name 속성을 이용하면 iframe이름.location.href로 특정 프레임만 골라서 이동시킬 수 있습니다.
TIP쿼리스트링(search)과 해시(hash)를 헷갈리기 쉬운데, 쿼리스트링은 서버로 전달되는 값이고 해시는 서버로 전송되지 않고 브라우저(클라이언트) 안에서만 쓰이는 값이라는 차이를 기억해야 합니다.
실습 파일: js14
14

DOM 탐색 속성

childNodeschildrenparentElementnextElementSibling

한 줄 요약DOM 탐색에서 Node 계열 속성은 공백·줄바꿈 텍스트 노드까지 세고 Element 계열 속성은 태그만 걸러내므로, 실무에서는 Element가 붙은 속성을 쓰는 것이 안전하다는 것이 핵심이다.

쉽게 말하면parentNode·children 같은 DOM 속성은 가계도 더듬기예요. 내 부모(parentNode), 내 자식들(children), 옆 형제(nextElementSibling)를 따라 문서 트리를 친척 관계처럼 타고 다닙니다.

Node와 Element의 차이

DOM 트리에서 특정 요소를 기준으로 부모·자식·형제를 찾아갈 때 가장 중요한 개념은 "Node"와 "Element"의 차이입니다. Node는 텍스트, 주석, 태그를 모두 포함하는 넓은 개념이고, Element는 그중 태그(요소)만을 의미합니다. 그래서 parentNode/childNodes/firstChild/lastChild는 줄바꿈이나 공백 같은 텍스트 노드까지 포함해 반환하고, parentElement/children/firstElementChild/lastElementChild는 순수하게 태그 요소만 걸러서 반환합니다.

7개 vs 3개의 비밀

실습 코드에서 div.childNodes.length는 7인데 div.children.length는 3입니다. div 안의 자식 태그는 <p> 3개뿐이지만, 태그 사이의 줄바꿈·공백까지 텍스트 노드로 계산되기 때문에 childNodes는 7개가 되는 것입니다. 인덱스로 자식에 접근할 때 이 차이를 모르면 엉뚱한 노드를 잡게 됩니다.

형제 탐색도 두 갈래

형제 요소를 탐색할 때도 previousSibling/nextSibling(Node 기준)과 previousElementSibling/nextElementSibling(Element 기준)이 구분되어 있습니다. 실무에서는 텍스트 노드까지 신경 쓸 일이 거의 없으므로, Element로 끝나는 속성을 사용하는 것이 훨씬 안전하고 예측 가능합니다.

HTML js15.html
<!-- 탐색의 기준이 되는 구조: div 안에 p 3형제 -->
<div>
    <p>child01</p>
    <p>child02</p>  <!-- 탐색의 기준 요소 -->
    <p>child03</p>
</div>
<button>부모탐색</button>
<button>자식탐색</button>
<button>자식탐색2</button>
<button>형제탐색</button>
JS js15.html
onload = function () {
    const btnEle = document.querySelectorAll("button");

    // [1] 부모 탐색: child02(p)에서 부모 div로 거슬러 올라가기
    btnEle[0].onclick = () => {
        const child02 = document.querySelectorAll("div > p")[1];
        const div = child02.parentNode;       // Node 기준 부모
        div.style.backgroundColor = "yellow";
        const divEle = child02.parentElement; // Element 기준 부모
        console.log(divEle.nodeName);         // "DIV"
    }

    // [2] 자식 탐색: Node 기준 vs Element 기준 개수 비교
    btnEle[1].onclick = () => {
        const div = document.querySelectorAll("div")[0];
        const divCn = div.childNodes; // 텍스트 노드 포함 → 7개
        console.log(divCn.length);    // (줄바꿈 공백까지 다 셈)
        const divEle = div.children;  // 태그만 → [p, p, p] 3개
        console.log(divEle.length);
        divEle[1].style.backgroundColor = "blue";
    }

    // [3] 첫째·막내 자식: Element 버전을 써야 태그만 반환
    btnEle[2].onclick = () => {
        const div = document.querySelectorAll("div")[0];
        // firstChild / lastChild 는 텍스트 노드가 걸릴 수 있음
        const fci = div.firstElementChild; // 첫 번째 자식 요소
        const lci = div.lastElementChild;  // 마지막 자식 요소
        console.log(fci.nodeName, lci.nodeName);
    }

    // [4] 형제 탐색: 바로 이전/다음 형제 "요소" 찾기
    btnEle[3].onclick = () => {
        const child02 = document.querySelectorAll("div > p")[1];
        const preP = child02.previousElementSibling; // child01
        const nextP = child02.nextElementSibling;    // child03
        console.log(preP.textContent, nextP.textContent);
    }
}
핵심 정리
  • parentNode/childNodes/firstChild/lastChild는 텍스트 노드(공백, 줄바꿈)까지 포함합니다.
  • parentElement/children/firstElementChild/lastElementChild는 태그 요소만 걸러서 반환합니다.
  • 같은 div라도 childNodes.length는 7, children.length는 3이 나올 수 있습니다(공백 텍스트 노드 때문).
  • previousElementSibling/nextElementSibling으로 바로 이전/다음 형제 "요소"를 안전하게 찾을 수 있습니다.
TIP실무에서는 대부분 Element가 붙은 탐색 속성을 사용합니다. Node 계열은 공백 텍스트까지 걸려서 인덱스 계산이 어긋나기 쉽습니다.
실습 파일: js15
15

DOM 요소 동적 생성

createElement()createTextNode()appendChild()innerHTML

한 줄 요약화면에 없던 요소를 만들 때는 createElement·createTextNode로 조각을 만들어 appendChild로 조립하는 정석 방식과, innerHTML에 템플릿 리터럴을 통째로 대입하는 간편 방식 두 갈래가 있다는 것이 핵심이다.

쉽게 말하면createElement는 조립식 가구 만들기예요. 부품을 만들고(createElement) → 내용을 채우고(textContent) → 원하는 위치에 조립(appendChild)합니다. 화면에 붙이기 전까지는 메모리에만 있는 ‘조립 중’ 상태예요.

노드 조립 3단계

"정석" 방식은 document.createElement()로 태그 노드를, document.createAttribute()로 속성 노드를, document.createTextNode()로 텍스트 노드를 각각 따로 만드는 것에서 시작합니다. 그다음 setAttributeNode()로 속성을 붙이고 appendChild()로 텍스트를 붙여서 요소를 하나하나 조립합니다. 코드는 길지만 각 조각을 세밀하게 제어할 수 있다는 장점이 있습니다.

화면에 붙여야 보인다

조립을 마친 요소는 아직 메모리에만 존재합니다. 마지막으로 완성된 요소를 appendChild()로 실제 화면의 부모 요소(#main) 안에 넣어야 비로소 브라우저에 나타납니다.

innerHTML 백틱 방식

이와 대조적으로 innerHTML에 백틱(템플릿 리터럴) 문자열을 통째로 대입하는 방법도 있습니다. 코드가 훨씬 짧고 직관적이지만, 문자열을 HTML로 매번 다시 파싱해야 하므로 성능이나 보안(XSS) 측면에서 정석 방식보다 불리할 수 있습니다. 두 방식 모두 같은 화면을 만들 수 있으므로 상황에 따라 적절히 선택해야 합니다.

JS js16.html
function eleCreate() {
    const val = "엘리먼트노드"; // 넣을 텍스트 데이터

    // ── [방법 1] 정석 조립 방식 ──
    // 1. 태그·속성·텍스트 노드를 각각 따로 생성
    const div = document.createElement("div");           // <div></div>
    const styleAttr = document.createAttribute("style"); // style=""
    const txt = document.createTextNode(val);

    // 2. 속성 노드에 값을 대입
    styleAttr.nodeValue = "color:red";

    // 3. div에 속성 조립 → <div style="color:red"></div>
    div.setAttributeNode(styleAttr);
    // 4. div에 텍스트 조립
    //    → <div style="color:red">엘리먼트노드</div>
    div.appendChild(txt);

    // 5. 완성된 div를 화면의 #main에 붙여야 실제로 보임
    document.querySelectorAll("#main")[0].appendChild(div);

    // ── [방법 2] innerHTML 백틱 방식 (비교용, 주석 처리) ──
    // 문자열을 통째로 대입 → 짧지만 파싱 비용·XSS에 주의
    /*
    document.querySelectorAll("#main")[0].innerHTML = `<div>
        <p style="color:blue;">${val}</p>
        <p>${val}</p>
    </div>`;
    */
}
핵심 정리
  • createElement로 태그를, createTextNode로 텍스트 노드를 각각 만든 뒤 appendChild로 조립합니다.
  • createAttribute + setAttributeNode 조합으로 속성을 동적으로 붙일 수 있습니다.
  • 조립을 마친 요소는 반드시 부모 요소에 appendChild로 붙여야 실제 화면에 나타납니다.
TIP간단한 구조는 innerHTML(템플릿 리터럴)이 편하지만, 사용자 입력값을 그대로 넣으면 XSS(스크립트 삽입) 위험이 있으므로 신뢰할 수 없는 데이터는 createElement/textContent 방식을 쓰는 것이 안전합니다.
실습 파일: js16
16

DOM으로 표 동적 생성

forEachcreateElement("tr")childElementCountappendChild()

한 줄 요약폼 입력값을 forEach로 한 번에 검증한 뒤 createElement로 tr·td를 조립해 tbody에 appendChild하면 표에 새 행이 동적으로 추가되며, childElementCount로 등록 개수까지 제한하는 실전형 예제다.

쉽게 말하면데이터 개수만큼 표의 행을 반복문으로 찍어 내는 실습이에요. 행 모양 도장을 하나 만들어 두고 데이터마다 쾅쾅 찍는 것과 같아요. 나중에 배우는 React의 .map() 리스트 렌더링과 같은 아이디어입니다.

입력값 한 번에 검증

회원정보 입력 폼에서 값을 받아 검증을 거친 뒤 표(table)에 새 행(tr)을 추가하는 예제입니다. 먼저 querySelectorAll("form[name=formTest] input[name]")로 폼 안의 입력창들을 한 번에 가져오고, forEach로 순회하면서 값이 비어있는지 검사합니다.

라벨을 DOM 탐색으로

흥미로운 부분은 누락된 입력창의 "이름표"를 찾는 방식입니다. input.parentNode(td)의 previousElementSibling(같은 tr 안의 th)의 textContent를 읽어 "아이디", "비밀번호" 같은 라벨 문자열을 자동으로 얻어냅니다. 앞서 배운 DOM 탐색 속성을 실전에서 활용하는 좋은 예입니다.

행 조립과 등록 제한

검증을 통과하면 createElement("tr")로 새 행을 만들고, 입력값 개수만큼 반복하며 createElement("td")로 셀을 만들어 textContent에 값을 채운 뒤 tr에 appendChild로 붙입니다. 완성된 tr은 최종적으로 tbody(#addtr)에 붙어 화면에 새 줄이 추가됩니다. 또한 childElementCount로 현재 등록된 행 개수를 세어 최대 10개까지만 추가되도록 제한하는 비즈니스 로직도 담고 있습니다.

HTML js17.html
<!-- 입력 폼: 각 행이 th(라벨) + td(입력창) 구조 -->
<form name="formTest">
    <table border="1">
        <caption>회원정보입력</caption>
        <tr>
            <th>아이디</th>
            <td><input type="text" name="id" /></td>
        </tr>
        <tr>
            <th>비밀번호</th>
            <td><input type="password" name="pw" /></td>
        </tr>
        <!-- 주소·전화번호 행도 같은 구조 -->
        <tr>
            <td colspan="2">
                <input type="button" value="추가"
                       onclick="tableVal()" />
            </td>
        </tr>
    </table>
</form>

<!-- 새 행(tr)이 추가될 목록 테이블 -->
<table border="1" id="ctb">
    <caption>회원정보목록</caption>
    <tbody id="addtr"></tbody>
</table>
JS js17.html
function tableVal() {
    // [1] 폼 안에서 name 속성이 있는 입력창 4개를 모두 가져옴
    const inputs = document
        .querySelectorAll("form[name=formTest] input[name]");

    // [2] 빈 입력창 개수(count)와 누락 항목 이름(msg)을 수집
    let count = 0;
    let msg = "";
    inputs.forEach((input) => {
        if (input.value == null || input.value == ""
            || input.value == undefined) {
            count++;
            // parentNode(td) → previousElementSibling(th)
            // → textContent : "아이디" 같은 라벨을 자동으로 얻음
            msg += input.parentNode
                .previousElementSibling.textContent + " ";
        }
    });

    // [3] 현재 목록(tbody#addtr)에 등록된 행(tr)의 개수
    const tbodyTrCount =
        document.querySelector("#addtr").childElementCount;

    if (count > 0) {
        // [4] 하나라도 비었으면 누락 항목을 알려주고 중단
        alert(`모두 입력하세요!!(${msg})`);
    } else if (tbodyTrCount < 10) {
        // [5] 새 행(tr)을 만들고, 입력값마다 td 셀을 조립
        const tr = document.createElement("tr");
        for (let i = 0; i < inputs.length; i++) {
            const td = document.createElement("td");
            td.textContent = inputs[i].value; // 셀에 입력값 채움
            tr.appendChild(td);               // 행에 셀 붙이기
        }
        // [6] 완성된 행을 tbody에 붙여야 화면에 새 줄이 보임
        document.querySelector("#addtr").appendChild(tr);
    } else {
        // [7] 이미 10개가 등록되어 있으면 추가하지 않음
        alert("10개까지만 입력 가능합니다.");
    }
}
핵심 정리
  • querySelectorAll + forEach 조합으로 여러 입력창의 값을 한 번에 검증할 수 있습니다.
  • createElement("tr")/createElement("td")로 행과 셀을 만들고 appendChild로 계층적으로 조립해 표에 추가합니다.
  • childElementCount로 특정 요소(tbody) 안의 자식 개수를 세어 등록 제한 같은 비즈니스 로직을 구현할 수 있습니다.
TIPinput.parentNode.previousElementSibling처럼 DOM 탐색 속성을 체이닝하면 "이 입력창이 어떤 항목인지"를 자바스크립트 변수 없이도 HTML 구조만으로 알아낼 수 있습니다.
실습 파일: js17
17

Cookie 사용하기

document.cookieexpiresencodeURIComponent()localStorage

한 줄 요약쿠키는 "이름=값;옵션" 문자열을 document.cookie에 대입해 저장·조회하고 만료일을 과거로 돌려 삭제하며, 더 큰 저장이 필요하면 sessionStorage·localStorage를 쓴다는 것이 핵심이다.

쉽게 말하면쿠키는 브라우저에 붙이는 포스트잇이에요. “이름=값; 유효기간” 형태로 붙여 두면 다음 방문 때도 남아 있습니다. “오늘 하루 안 보기” 팝업이 대표적인 쿠키 활용이에요.

쿠키 저장 규칙

쿠키(Cookie)는 브라우저가 사용자의 컴퓨터에 작은 데이터를 "이름=값" 형태의 문자열로 저장해두는 저장소입니다. document.cookie에 문자열을 대입하면 저장되는데, 이 문자열은 이름=값;expires=만료일;domain=도메인;path=경로;secure처럼 세미콜론으로 옵션을 이어붙인 형태입니다. 값에 한글이나 특수문자가 있으면 깨질 수 있어 encodeURIComponent()로 인코딩해 저장하고, 읽을 때 decodeURIComponent()로 디코딩합니다.

읽기와 삭제 트릭

document.cookie를 조회하면 여러 쿠키가 "이름1=값1; 이름2=값2" 형태의 한 문자열로 뭉쳐 나오므로, split(";")으로 쪼갠 뒤 trim()으로 공백을 제거하고 원하는 이름으로 시작하는 조각에서 값만 잘라내야 합니다. 만료 기간은 expires 옵션에 Date 객체를 toUTCString()으로 변환해 넣으며, 삭제는 별도 명령이 없어 만료일을 과거(-1일)로 지정해 다시 저장하는 트릭을 사용합니다.

웹 스토리지 대안

쿠키는 용량이 작고 매 요청마다 서버로 전송되는 한계가 있는데, 이를 보완하는 대안이 웹 스토리지입니다. sessionStorage는 탭을 닫으면 사라지고 localStorage는 직접 지우기 전까지 반영구적으로 유지되며, 둘 다 setItem/getItem으로 간단하게 사용할 수 있고 용량도 쿠키보다 훨씬 큽니다(약 5MB).

JS js18.html
// [저장] 쿠키는 "이름=값;옵션" 형태의 문자열 하나로 저장됨
function setCookie(name, value, expires, domain, path, secure) {
    let cookies = "";
    // 한글·특수문자가 깨지지 않게 인코딩해서 "이름=값" 조립
    cookies += name + "=" + window.encodeURIComponent(value);

    if (expires) {
        // 만료일: 오늘 날짜 + expires일 → UTC 형식 문자열로
        const date = new Date();
        date.setDate(date.getDate() + expires);
        cookies += ";expires=" + date.toUTCString();
    }
    if (domain) cookies += ";domain=" + domain; // 전송 도메인 제한
    if (path) cookies += ";path=" + path;       // 사용 가능 경로
    if (secure) cookies += ";secure";           // HTTPS에서만 전송

    // 대입하면 같은 이름의 쿠키 하나만 추가/갱신됨
    document.cookie = cookies;
}

// [조회] "이름1=값1; 이름2=값2" 문자열에서 원하는 값만 추출
function getCookie(name) {
    const cookieArray = document.cookie.split(";"); // 조각내기
    for (let i = 0; i < cookieArray.length; i++) {
        const cookie = cookieArray[i].trim(); // 좌우 공백 제거
        if (cookie.startsWith(name + "=")) {
            // "이름=" 바로 뒤부터 끝까지가 순수한 값
            const encodedValue = cookie.substring(name.length + 1);
            // 인코딩했던 값을 원래 문자로 되돌려 반환
            return decodeURIComponent(encodedValue);
        }
    }
    return null; // 해당 이름의 쿠키가 없으면 null
}

// [삭제] 만료일을 과거(-1일)로 재저장하면 즉시 삭제됨
function removeCookie(name) {
    setCookie(name, "", -1);
}

// [웹 스토리지] 쿠키보다 용량이 큼(약 5MB), setItem/getItem
sessionStorage.setItem("id2", "hk2"); // 탭을 닫으면 소멸
console.log(sessionStorage.getItem("id2"));
localStorage.setItem("id3", "hk3");   // 직접 지울 때까지 유지
console.log(localStorage.getItem("id3"));
핵심 정리
  • 쿠키는 "이름=값;expires=...;path=..." 형태의 문자열로 document.cookie에 저장/조회됩니다.
  • 쿠키 삭제는 별도 메서드가 없어 만료일을 과거로 설정해 재저장하는 방식으로 처리합니다.
  • sessionStorage는 탭을 닫으면 사라지고, localStorage는 지우기 전까지 유지되며, 둘 다 쿠키보다 용량이 큽니다(약 5MB).
TIPdocument.cookie는 대입할 때마다 전체를 덮어쓰는 게 아니라 해당 이름의 쿠키 하나만 추가/갱신한다는 점을 헷갈리지 말아야 합니다.
실습 파일: js18
18

AJAX(XMLHttpRequest) 통신

XMLHttpRequestopen()/send()readyStateJSON.parse()

한 줄 요약AJAX는 XMLHttpRequest 객체로 open → 콜백 등록 → send 순서를 밟아 새로고침 없이 데이터를 받아오고, readyState 4·status 200을 확인한 뒤 JSON.parse로 문자열 응답을 배열·객체로 바꿔 쓰는 통신 기술이다.

쉽게 말하면AJAX는 식당의 진동벨이에요. 주문을 넣고(send) 자리에서 다른 일을 하다가, 벨이 울리면(readyState 4 + status 200) 음식(responseText)을 받아옵니다. 페이지 전체를 새로고침(재입장)하지 않는 게 핵심입니다.

요청 준비와 전송

AJAX(Asynchronous JavaScript and XML)는 페이지 전체를 새로고침하지 않고 서버(또는 파일)로부터 데이터를 가져오는 기술이며, 그 핵심 도구가 브라우저 내장 객체인 XMLHttpRequest(xhr)입니다. new XMLHttpRequest()로 객체를 만든 뒤 xhr.open(방식, 주소, 비동기여부)로 요청을 준비하는데, GET 방식에서는 js/data.json?num=1&id=hk처럼 물음표 뒤에 파라미터를 붙여 전송하고, 세 번째 인자 true는 비동기 통신을 의미합니다. 준비가 끝나면 xhr.send()를 호출해야 비로소 실제 요청이 전송됩니다.

응답 감시 두 조건

비동기 통신이라 응답이 언제 도착할지 알 수 없으므로, xhr.onreadystatechange에 콜백을 등록해 통신 상태가 바뀔 때마다(연결 시작, 헤더 수신, 수신 중, 완료 단계마다) 자동으로 호출되게 감시합니다. 콜백 안에서는 xhr.readyState === 4(응답을 전부 받음)와 xhr.status == 200(요청 성공)을 반드시 함께 확인해야 하며, 두 조건이 모두 참일 때만 응답 데이터를 안전하게 사용할 수 있습니다.

문자열을 데이터로

서버(혹은 data.json 같은 정적 파일)가 보내주는 데이터는 xhr.responseText로 받는데, 이는 단순한 문자열이므로 JSON.parse()로 파싱해야 자바스크립트 배열·객체처럼 다룰 수 있습니다. data.json이 대괄호로 감싸인 배열([{...}, {...}, ...]) 구조이므로 파싱 결과인 data도 배열이 되어 data[0].id, data[0].name처럼 인덱스와 속성명으로 접근합니다.

JS js19.html
function ajaxGet() {
    // [1] 통신을 담당할 XMLHttpRequest 객체를 생성
    const xhr = new XMLHttpRequest();

    // [2] 서버로 보낼 파라미터(Key=Value 형태)를 정의
    let params = "num=1&id=hk";

    // [3] 요청 준비: GET 방식은 "주소?파라미터"로 붙여 보냄
    //     세 번째 인자 true = 비동기 통신
    xhr.open("GET", `js/data.json?${params}`, true);

    // [4] 통신 상태가 바뀔 때마다 실행될 콜백을 등록
    //     (연결 → 헤더 수신 → 수신 중 → 완료 단계마다 호출됨)
    xhr.onreadystatechange = function () {
        // readyState === 4 : 응답 수신 완료
        // status == 200   : 요청 성공 (HTTP 상태 코드)
        if (xhr.readyState === 4 && xhr.status == 200) {
            // [5] 응답(responseText)은 문자열이므로 JSON.parse로
            //     변환. data.json이 [ ... ] 배열이라 data도 배열
            let data = JSON.parse(xhr.responseText);

            // 배열이므로 인덱스 + 속성명으로 접근
            console.log(data[0].id, data[0].name);

            // [6] 받아온 값을 화면(#result)에 출력
            document.querySelector("#result").innerHTML =
                `<p>아이디: ${data[0].id}</p>
                 <p>이름: ${data[0].name}</p>
                 <p>주소: ${data[0].addr}</p>`;
        }
    }

    // [7] 준비된 요청을 실제로 전송
    xhr.send();
}

// 참고: js/data.json 내용 — 같은 모양의 객체 5개짜리 배열
// [ { "id": "hk", "name": "한경", "addr": "양평동" }, ... ]
핵심 정리
  • XMLHttpRequest 사용 순서는 객체 생성 → open(방식,주소,비동기여부) → onreadystatechange 콜백 등록 → send()입니다.
  • 응답을 안전하게 처리하려면 xhr.readyState === 4(수신 완료)와 xhr.status == 200(성공) 두 조건을 함께 검사해야 합니다.
  • 서버 응답(xhr.responseText)은 문자열이므로 JSON.parse()로 변환해야 자바스크립트 배열/객체로 다룰 수 있습니다.
  • GET 방식은 주소 뒤에 ?key=value&key2=value2 형태로 파라미터를 붙여서 전달합니다.
TIPonreadystatechange 콜백은 통신 과정 중 여러 번 호출되므로, readyState 체크를 빠뜨리면 아직 데이터가 다 도착하지 않은 시점에 responseText를 파싱하려다 오류가 날 수 있습니다.
실습 파일: js19, data.json

4. React

React & Next.js 커리큘럼(14개 레슨) 전체. 선언형 UI·Virtual DOM 개념과 Vite 개발 환경, JSX·컴포넌트·props, useState·이벤트 핸들러·배열 불변성 업데이트·useEffect, useRef·useMemo·useCallback·React.memo 성능 최적화, 커스텀 훅 4종, React.memo·useCallback 비교와 lazy/Suspense 코드 분할, createContext·Provider·useContext로 전역 상태를 공유하는 Context API, Next.js App Router의 파일 기반 라우팅·동적 라우트, Provider 없이 전역 상태를 다루는 Zustand, route.js로 나만의 백엔드를 만드는 API Routes, persist 미들웨어로 새로고침에도 유지되는 상태와 매수/매도가 있는 포트폴리오, async 서버 컴포넌트가 Suspense와 함께 데이터를 스트리밍하는 방식, Tailwind CSS + 전역 다크모드, 그리고 REST+WebSocket으로 실시간 차트를 그리는 마지막 단원까지 전부 복습합니다. 마지막에는 이 기술 전부를 총동원해 직접 만든 🎓 졸업 과제(StockDash 주식 대시보드) 카드가 있습니다.

01

React 시작하기 — 선언형 UI · Virtual DOM · Vite

선언형Virtual DOMVitecreateRootStrictMode

한 줄 요약React는 "어떻게 바꿀지"를 단계별로 지시하는 대신 "어떻게 보여야 하는지"만 선언하면, 메모리상의 Virtual DOM 두 개를 비교(diffing)해 바뀐 부분만 실제 DOM에 한 번에 반영해 주는 UI 라이브러리이고, 프로젝트는 webpack 기반 CRA 대신 ESM 기반의 빠른 Vite로 만든다.

쉽게 말하면명령형은 택시에서 “우회전, 직진, 좌회전” 길을 일일이 부르는 것, 선언형(React)은 “OO빌딩 가 주세요” 목적지만 말하는 거예요. 가는 방법(DOM 조작)은 기사(React)가 알아서 정하고, Virtual DOM 비교로 최단 경로만 골라 갑니다.

명령형 vs 선언형

Vanilla JS(명령형)는 ① DOM 요소를 직접 선택하고 ② 스타일을 직접 바꾸고 ③ 텍스트를 직접 수정하고 ④ 이벤트 리스너를 직접 연결하는 식으로 "어떻게 바꿀지"를 하나하나 지시합니다. 반면 React(선언형)는 const [clicked, setClicked] = useState(false)처럼 상태를 정의해 두고 "이 상태일 때 화면이 어떻게 보여야 하는지"만 기술합니다. 상태만 바꾸면 UI 갱신은 React가 알아서 처리합니다.

Virtual DOM — 어떻게 빠를 수 있는가

React는 실제 DOM을 직접 건드리지 않습니다. ① setState()가 호출되면 새 Virtual DOM을 메모리에 만들고 → ② 이전 Virtual DOM과 비교(Diffing)해 변경된 부분을 찾은 뒤 → ③ 실제 DOM에는 꼭 필요한 부분만 한 번에 반영합니다. 두 개의 가상 DOM(이전/변경)을 비교해서 영향을 받은 부분만 최종 한 번에 렌더링하기 때문에 불필요한 DOM 조작이 사라져 빠릅니다.

개발 환경 — CRA vs Vite, 그리고 진입점

구 방식인 CRA(Create React App)는 webpack 기반이라 빌드가 느리고 번들이 무겁습니다. 현재 업계 표준은 Vite — 브라우저가 필요한 코드만 직접 호출(ESM)하고, 바뀐 부분만 실시간으로 갈아 끼우는(HMR) 초고속 개발 환경입니다. 만들어진 앱의 진입점은 src/main.jsx로, React 18의 createRoot API가 index.html<div id="root">에 앱을 붙입니다. 예전 ReactDOM.render() 방식은 동시성 기능이 지원되지 않습니다.

SH 터미널 — Vite 프로젝트 생성
# CRA(구 방식): npx create-react-app my-app → webpack 기반이라 느리고 무거움
# 현재 업계 표준은 Vite (ESM 기반 · 설정 간단 · HMR 즉각 반응)
npm create vite@latest my-app   # 프레임워크 목록에서 React 선택
cd my-app
npm install                     # 의존성 설치
npm run dev                     # 개발 서버 실행 — 저장하면 바뀐 부분만 즉시 갱신(HMR)
JSX src/main.jsx
// ── 앱의 진입점(entry point) ──
// 브라우저의 index.html 안 <div id="root"></div> 에 React 앱을 붙입니다.
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'

// createRoot: React 18부터 도입된 렌더링 진입 API
// - 예전 방식 ReactDOM.render(<App />, ...)는 동시성 기능 지원 안 됨
// StrictMode: 개발 중 잠재적 문제를 잡아주는 검사용 래퍼 (배포 화면에는 영향 없음)
// - 개발 중에는 컴포넌트를 일부러 2번 렌더링해서 부작용을 드러나게 함
createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
핵심 정리
  • React는 선언형 — "결과가 어떻게 보여야 하는지"를 선언하면, 상태 변경 시 UI 갱신은 React가 담당합니다.
  • Virtual DOM 3단계: 상태 변경 → 이전 VDOM과 Diffing(비교) → 실제 DOM에는 변경된 부분만 최소 반영.
  • 프로젝트 생성은 npm create vite@latest — CRA보다 빌드가 빠르고(ESM) HMR이 즉각 반응합니다.
  • 진입점 main.jsx에서 createRoot(root요소).render(<App />)로 앱을 마운트합니다.
TIP개발 모드에서 console.log가 두 번씩 찍히는 것은 버그가 아니라 StrictMode가 잠재적 부작용을 찾으려고 컴포넌트를 일부러 2번 렌더링하기 때문입니다. 배포 빌드에서는 1번만 실행됩니다.
실습 파일: main.jsx
02

JSX · 컴포넌트 · props — 프로필 카드 목록

JSX컴포넌트props.map()+key조건부 렌더링

한 줄 요약화면을 대문자로 시작하는 함수(컴포넌트)로 만들고, 부모 App이 가진 team 배열을 .map()으로 순회하며 각 항목을 props로 자식 ProfileCard에 내려 카드 목록을 그리는 실습 — key에는 고유 id를 쓰고, 조건부 렌더링은 &&와 삼항연산자로 처리한다.

쉽게 말하면컴포넌트는 붕어빵 틀, props는 반죽과 팥이에요. ProfileCard라는 틀 하나를 만들어 두고 team 배열의 재료를 .map()으로 하나씩 부어 넣으면 카드(붕어빵)가 개수만큼 나옵니다. key는 각 붕어빵에 붙이는 번호표예요.

JSX와 컴포넌트

JSX는 JS 안에 HTML처럼 쓰는 확장 문법으로, 중괄호 { }를 열면 {team.length}처럼 자바스크립트 표현식을 그대로 쓸 수 있습니다. 컴포넌트는 "JSX를 반환하는 함수"이며 이름은 반드시 대문자로 시작해야 React가 컴포넌트로 인식합니다. return 안은 반드시 하나의 요소로 감싸야 하고(형제 2개 나란히 반환 불가, 태그가 싫으면 빈 태그 <>...</> Fragment 사용), 인라인 스타일은 style={{ fontFamily: '...' }}처럼 카멜케이스 속성의 객체로 씁니다.

props — 부모에서 자식으로, 읽기 전용

데이터(team 배열)는 부모 App이 소유하고, 자식 ProfileCard는 name={member.name}처럼 속성 형태로 받은 props를 표시만 합니다. 자식 쪽에서는 매개변수에서 ({ name, job, ... }) 구조분해로 낱개 변수로 꺼내 씁니다. props는 항상 위→아래 한 방향으로만 흐르는 읽기 전용 데이터라서 자식이 직접 수정하면 에러가 납니다.

리스트 렌더링(.map + key)과 조건부 렌더링

배열.map()으로 "데이터 1개 → 컴포넌트 1개"씩 변환해 목록을 그립니다. 이때 key는 React가 어떤 항목이 추가·삭제·이동됐는지 추적하는 기준이라 필수이며, 순서가 바뀌면 엉뚱한 항목을 재사용할 수 있는 index 대신 고유 id를 써야 안전합니다(빼면 "unique key prop" 콘솔 경고). 조건부 렌더링은 두 가지 — isLead && <JSX/>는 조건이 참일 때만 그리고, 조건 ? A : B 삼항연산자는 온라인/오프라인처럼 둘 중 하나를 골라 그립니다.

JSX src/App.jsx — 부모: 데이터 소유 + 목록 조립
import ProfileCard from './components/ProfileCard'

// 화면에 뿌릴 원본 데이터 — 같은 모양의 객체 4개짜리 배열
// 여기에 객체를 하나 추가하기만 하면 카드가 자동으로 하나 더 그려짐 (선언형의 장점)
const team = [
  { id: 1, name: '김민준', job: '프론트엔드', emoji: '👨‍💻', bgColor: '#e6f1fb', isOnline: true,  isLead: true  },
  { id: 2, name: '이서연', job: '디자이너',   emoji: '🎨', bgColor: '#eaf3de', isOnline: true,  isLead: false },
  { id: 3, name: '박지호', job: '백엔드',     emoji: '🔧', bgColor: '#faeeda', isOnline: false, isLead: false },
  { id: 4, name: '최수아', job: '기획자',     emoji: '📋', bgColor: '#f3e6fb', isOnline: false, isLead: false },
]

// 컴포넌트 = "JSX를 반환하는 함수". 이름은 반드시 대문자로 시작
function App() {
  // return 안은 반드시 '하나의 요소'로 감싸기 (안 되면 빈 태그 <>...</> 사용)
  return (
    <div style={{ padding: '2rem', fontFamily: 'sans-serif' }}>
      {/* JSX의 중괄호 { } 안에는 JS 표현식을 그대로 쓸 수 있음 */}
      <h1>우리 팀 소개 ({team.length}명)</h1>

      <div>
        {/* 리스트 렌더링: .map()으로 "데이터 1개 → 컴포넌트 1개"씩 변환
            - key: 항목 식별용 고유값. index가 아닌 고유 id를 사용해야 안전
            - name={...} 형태의 속성들이 자식에게 props로 전달됨 */}
        {team.map((member) => (
          <ProfileCard
            key={member.id}
            name={member.name}
            job={member.job}
            emoji={member.emoji}
            bgColor={member.bgColor}
            isOnline={member.isOnline}
            isLead={member.isLead}
          />
        ))}
      </div>
    </div>
  )
}

// 다른 파일(main.jsx)에서 import App 으로 가져다 쓸 수 있게 내보내기
export default App
JSX src/components/ProfileCard.jsx — 자식: props 받아 표시
// [Props 구조분해] props.name 대신 { name, job, ... }으로 낱개 변수로 즉시 꺼내 사용
// ⚠️ props는 '읽기 전용' — 자식이 직접 수정하면 에러. 데이터는 항상 부모가 소유
function ProfileCard({ name, job, emoji, bgColor, isOnline, isLead }) {
  return (
    // 인라인 스타일은 style={{ ... }} 객체 + 카멜케이스 (background-color → backgroundColor)
    <div style={{
      border: '1px solid #e5e7eb', borderRadius: '12px',
      padding: '20px', margin: '10px', display: 'inline-block',
      backgroundColor: bgColor || '#fff',   // bgColor가 안 오면 기본값 흰색
      minWidth: '160px', textAlign: 'center', verticalAlign: 'top',
    }}>
      {/* [조건부 렌더링 1: &&] isLead가 true일 때만 오른쪽 JSX를 그림 */}
      {isLead && <div style={{ fontSize: '0.85rem', color: 'orange' }}>⭐ 팀 리더</div>}

      <div style={{ fontSize: '2.5rem' }}>{emoji}</div>
      <h2 style={{ margin: '10px 0 4px', fontSize: '1.1rem' }}>{name}</h2>
      <p style={{ color: '#888', fontSize: '0.9rem', margin: 0 }}>{job}</p>

      {/* [조건부 렌더링 2: 삼항연산자] 조건 ? 참일때 : 거짓일때 — 둘 중 하나를 골라 그림 */}
      <p style={{ margin: '8px 0 0', fontSize: '0.8rem' }}>
        {isOnline
          ? <span style={{ color: 'green' }}>🟢 온라인</span>
          : <span style={{ color: 'gray' }}>⚪ 오프라인</span>}
      </p>
    </div>
  )
}

export default ProfileCard
핵심 정리
  • 컴포넌트 이름은 대문자 시작, return은 하나의 요소로 감싸기(Fragment <></> 가능), JSX의 { }에는 JS 표현식.
  • props는 부모→자식 단방향·읽기 전용 — 자식은 구조분해 ({ name, ... })로 받아 표시만 합니다.
  • 목록은 배열.map()으로 렌더링하고, key에는 index가 아닌 데이터 고유 id를 넣어야 순서 변경 시 안전합니다.
  • 조건부 렌더링: 있으면 그리기는 조건 && JSX, 둘 중 하나는 조건 ? A : B.
  • 인라인 스타일은 style={{ }} — 바깥 { }는 JS 표현식 삽입, 안쪽 { }는 객체 리터럴, 속성명은 카멜케이스.
TIP확장 과제 3가지 — ① team 배열에 내 정보를 추가하면 컴포넌트 수정 없이 카드가 하나 더 렌더됩니다(선언형의 장점). ② key를 지우면 "Each child in a list should have a unique key prop" 경고가 뜹니다. ③ {job === '디자이너' && <div>🎨</div>}처럼 && 조건을 여러 개 나열해도 각각 독립적으로 평가됩니다.
실습 파일: App.jsx, ProfileCard.jsx
03

state와 이벤트 — 카운터 & 할 일 목록(Todo)

useState이벤트 핸들러제어 컴포넌트배열 불변성useEffectlocalStorageStrictMode 함정

한 줄 요약props가 "부모가 내려주는 읽기 전용 값"이라면 state는 "컴포넌트가 스스로 기억하고 바꾸는 값"이다 — useState로 숫자·배열 상태를 만들고, 원본을 직접 고치지 않고 매번 새 배열/객체를 만들어 교체(불변성)하며, useEffect로 상태가 바뀔 때마다 localStorage에 자동 저장한다.

쉽게 말하면state는 컴포넌트가 갖고 있는 "메모장"이에요. 메모장을 고치면(setState) React가 알아서 화면을 다시 그려줍니다. 할 일 배열을 바꿀 때는 원본 메모장을 지우개로 고치는 대신, 새 종이에 통째로 다시 옮겨 적는다고 생각하면 불변성이 쉬워요. useEffect는 "메모장 내용이 바뀔 때마다 자동으로 사진 찍어 서랍(localStorage)에 넣어두기"입니다.

useState와 이벤트 핸들러

const [count, setCount] = useState(0)처럼 배열 구조분해로 [현재값, 변경함수]를 받습니다. setCount를 호출하면 컴포넌트 함수 전체가 다시 실행(리렌더링)되어 새 값 기준으로 화면이 다시 계산됩니다. 버튼 클릭 시 onClick={() => setCount(count - 1)}처럼 반드시 화살표 함수로 감싸서 넘겨야 합니다 — onClick={setCount(count - 1)}라고 쓰면 렌더링되는 순간 즉시 실행되어 버려 무한 루프 에러가 납니다.

배열 상태의 불변성(Immutability) 업데이트

state인 배열/객체는 push나 인덱스 대입처럼 원본을 직접 고치면 안 됩니다(React가 변경을 감지 못해 화면이 안 바뀔 수 있음). 대신 매번 "새 배열"을 만들어 통째로 교체합니다 — 추가는 [...todos, 새항목] 스프레드, 삭제는 todos.filter(t => t.id !== id)로 뺄 것만 걸러내기, 토글/수정은 todos.map(t => t.id === id ? {...t, done: !t.done} : t)로 대상만 새 객체로 바꿔치기하는 패턴을 씁니다.

제어 컴포넌트(Controlled Component)와 useEffect

입력창은 value={input}onChange={e => setInput(e.target.value)}를 함께 써서 "입력창의 값 = state 값"으로 React가 항상 통제합니다(제어 컴포넌트). useState(() => ...)처럼 함수를 넘기는 게으른 초기화는 첫 렌더링 때 딱 1번만 실행되어, 매 렌더마다 localStorage를 다시 읽는 낭비를 막습니다. useEffect(() => {...}, [todos])는 두 번째 인자(의존성 배열)에 넣은 값이 바뀔 때만 화면을 그린 뒤 실행되는 "부수 효과" 훅으로, 여기서는 todos가 바뀔 때마다 localStorage에 자동 저장합니다.

⚠️ StrictMode 함정 — useEffect를 "불러오기 1개 + 저장하기 1개"로 나누면 안 되는 이유

선생님이 보여주신 다른 방식은 useState([])로 빈 배열에서 시작한 뒤, 불러오기용 useEffect(() => { const saved = localStorage.getItem('todos'); if (saved) setTodos(JSON.parse(saved)) }, [])저장용 useEffect(() => { localStorage.setItem('todos', JSON.stringify(todos)) }, [todos])를 따로 두는 구조였습니다. 개발 모드의 StrictMode는 마운트를 일부러 한 번 더(마운트 → 언마운트 → 재마운트) 실행하기 때문에, 다음 순서로 기존 데이터가 통째로 날아가는 버그가 발생합니다.

① 첫 마운트: todos는 아직 초기값 []. 두 effect가 모두 실행됨 → 불러오기 effect가 "기존 데이터를 setTodos에 예약"하지만, 같은 타이밍에 저장 effect는 아직 반영 전인 []를 그대로 localStorage에 덮어써 버립니다.
② StrictMode의 재마운트: 불러오기 effect가 localStorage를 다시 읽는데, 방금 ①에서 이미 []로 덮어써진 뒤라 빈 배열만 읽혀 옵니다. 결과적으로 기존 할 일 목록이 화면에서 통째로 사라집니다.

우리가 쓴 게으른 초기화 useState(() => ...) + 저장용 useEffect 1개 구조는 애초에 "초기값이 []인 순간" 자체가 없기 때문에 이 문제가 생기지 않습니다 — todos는 컴포넌트가 실행되는 첫 순간부터 이미 기존 데이터를 쥐고 시작하므로, StrictMode가 몇 번을 재실행해도 저장 effect가 빈 배열로 덮어쓸 일이 없습니다.

JSX src/components/Counter.jsx
import { useState } from 'react'

function Counter() {
  // [현재값, 변경함수] = useState(초기값) — setCount 호출 시 Counter 전체가 리렌더링됨
  const [count, setCount] = useState(0)

  // count가 바뀔 때마다 다시 실행되어 최신 count 기준 색을 돌려줌
  const getColor = () => {
    if (count > 0) return '#185fa5' // 양수 → 파란색
    if (count < 0) return '#a32d2d' // 음수 → 붉은색
    return '#1a1a18'                // 0 → 검은색
  }

  return (
    <div style={{ textAlign: 'center', padding: '2rem' }}>
      <h1 style={{ fontSize: '4rem', color: getColor() }}>{count}</h1>
      <div>
        {/* ⚠️ onClick={setCount(count - 1)}이라 쓰면 렌더링 즉시 실행 → 무한 루프!
            "클릭하면 실행해줘"라는 화살표 함수로 한 번 감싸서 전달해야 함 */}
        <button onClick={() => setCount(count - 1)}>−</button>
        <button onClick={() => setCount(0)}>초기화</button>
        <button onClick={() => setCount(count + 1)}>+</button>
      </div>
      {/* 삼항 연산자로 조건부 텍스트 출력 */}
      <p>{count > 0 ? '양수입니다 😊' : count < 0 ? '음수입니다 😅' : '0입니다 😐'}</p>
    </div>
  )
}

export default Counter
JSX src/components/TodoApp.jsx — 배열 불변성 + useEffect
import { useState, useEffect } from 'react'

function TodoApp() {
  // 게으른 초기화: () => {...} 함수로 감싸야 최초 1번만 localStorage를 읽음
  const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem('todos')
    return saved ? JSON.parse(saved) : []
  })
  const [input, setInput] = useState('')       // 입력창 글자 (제어 컴포넌트)
  const [filter, setFilter] = useState('all')  // 'all' | 'active' | 'done'

  // todos가 바뀔 때마다(추가/삭제/토글) 화면을 그린 후 자동 저장
  useEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos))
  }, [todos])

  // ➊ 추가: 기존 배열을 스프레드로 복사 + 새 항목을 끝에 붙인 '새 배열'
  const addTodo = () => {
    if (!input.trim()) return
    setTodos([...todos, { id: Date.now(), text: input, done: false }])
    setInput('')
  }

  // ➋ 삭제: 지울 id만 빼고 걸러낸 '새 배열'
  const removeTodo = (id) => setTodos(todos.filter((t) => t.id !== id))

  // ➌ 토글: 대상 id만 {...복사, done: 반대값}으로 바꿔치기한 '새 배열'
  const toggleTodo = (id) =>
    setTodos(todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)))

  const filtered = todos.filter((t) => {
    if (filter === 'active') return !t.done
    if (filter === 'done') return t.done
    return true
  })

  return (
    <div>
      {/* value + onChange 세트 = React가 입력값을 완전히 통제하는 '제어 컴포넌트' */}
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyDown={(e) => e.key === 'Enter' && addTodo()}
      />
      <button onClick={addTodo} disabled={!input.trim()}>추가</button>

      <ul>
        {/* key에는 index가 아닌 고유 id — 목록 실습(react-02)과 동일한 규칙 */}
        {filtered.map((todo) => (
          <li key={todo.id}>
            <input type="checkbox" checked={todo.done} onChange={() => toggleTodo(todo.id)} />
            <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>{todo.text}</span>
            <button onClick={() => removeTodo(todo.id)}>✕</button>
          </li>
        ))}
      </ul>
    </div>
  )
}

export default TodoApp
핵심 정리
  • state는 useState로 만들고, set함수로 바꾸면 컴포넌트가 리렌더링됩니다 — props(부모→자식 읽기 전용)와 달리 컴포넌트가 스스로 소유·변경합니다.
  • 이벤트 핸들러는 onClick={() => setX(...)}처럼 화살표 함수로 감싸서 전달 — 바로 호출 형태로 쓰면 렌더링 즉시 실행돼 무한 루프가 납니다.
  • 배열/객체 state는 절대 직접 수정하지 않고 매번 새로 만들어 교체 — 추가는 스프레드 [...arr, x], 삭제는 filter, 수정은 map + 객체 스프레드 {...t, done: !t.done}.
  • 입력창은 value+onChange 세트로 묶는 제어 컴포넌트, useState(() => ...) 게으른 초기화는 최초 1번만 실행됩니다.
  • useEffect(fn, [의존성])는 의존성 배열 값이 바뀔 때만 화면 렌더 후 실행 — todos 변경 시마다 localStorage에 동기화하는 데 사용했습니다.
  • "불러오기 useEffect + 저장 useEffect" 2개로 나누면 StrictMode의 이중 마운트 때문에 저장 effect가 초기값 []로 먼저 덮어써 데이터가 날아갈 수 있음 — useState(() => ...) 게으른 초기화로 애초에 [] 상태 자체를 없애면 안전합니다.
TIPuseEffect의 두 번째 인자를 빼먹고 useEffect(() => {...})만 쓰면 매 렌더링마다 실행되고, [] 빈 배열을 넣으면 최초 1번만 실행됩니다. 배열 안에 넣은 값이 바뀔 때만 실행하고 싶다면 반드시 그 값을 의존성 배열에 넣어야 하며, 빠뜨리면 "오래된 값(stale)"을 참조하는 버그로 이어질 수 있습니다. localStorage처럼 "불러오기"와 "저장"을 분리하고 싶은 유혹이 들 때는 항상 StrictMode 이중 마운트를 의심해 보세요 — 자세한 원인은 위 ⚠️ 개념 블록과 기초 개념 사전의 관련 항목을 참고하세요.
실습 파일: Counter.jsx, TodoApp.jsx
04

성능 훅 3종 — useRef · useMemo · useCallback · React.memo

useRefuseMemouseCallbackReact.memo렌더링 최적화

한 줄 요약useRef는 "화면을 다시 그리지 않고" DOM에 직접 접근하거나 값을 보관하는 훅, useMemo는 무거운 계산 결과를 캐싱하는 훅, useCallback은 함수 자체를 캐싱하는 훅이며, 이 둘을 React.memo와 짝지으면 관련 없는 자식 컴포넌트의 불필요한 리렌더링을 막을 수 있다.

쉽게 말하면useState는 "고치면 화면을 다시 그려야 하는 칠판"이고, useRef는 "고쳐도 화면엔 안 보이는 뒷메모장"이에요. useMemo는 "어제 계산해 둔 답이 오늘도 똑같으면 다시 계산 안 하고 재활용하기", useCallback은 "매번 새 도장을 파지 않고 같은 도장을 계속 재사용하기"입니다. React.memo는 "자식에게 준 재료(props)가 어제랑 완전히 똑같으면 자식은 그리지 말고 그대로 두기"라는 규칙이에요.

useRef — 렌더링과 무관한 "손가락"과 "메모장"

const inputRef = useRef(null)처럼 만든 다음 JSX에서 <input ref={inputRef} />로 연결하면, inputRef.current로 실제 <input> DOM 엘리먼트에 직접 접근할 수 있습니다(inputRef.current.focus() 같은 명령형 조작). useRef의 또 다른 용도는 "렌더링을 유발하지 않는 값 보관함"입니다 — renderCount.current += 1처럼 값을 바꿔도 useState와 달리 화면이 다시 그려지지 않습니다. 다만 그 값은 다른 이유(예: setQuery)로 리렌더링이 일어날 때 최신값이 함께 화면에 반영됩니다.

useMemo — 무거운 "계산 결과" 캐싱

useMemo(() => 계산식, [의존성])은 컴포넌트가 리렌더링될 때마다 의존성 배열 값을 확인해서, 이전 렌더링과 값이 똑같으면 계산식을 다시 실행하지 않고 저장해 둔 이전 결과를 그대로 재사용합니다. 예를 들어 주식 목록을 필터링·정렬하는 연산은 종목이 많아질수록 비용이 커지는데, favorites(즐겨찾기)만 바뀌고 filter·sortBy는 그대로라면 굳이 다시 필터링·정렬할 필요가 없습니다 — 콘솔 로그를 넣어보면 별(★)을 눌러도 "재계산!" 로그가 안 찍히는 것으로 확인할 수 있습니다.

useCallback + React.memo — 자식 리렌더링을 막는 2단 방어

부모 컴포넌트가 리렌더링되면 그 안에서 만든 일반 함수는 매번 "새 함수"로(메모리 주소가 다르게) 다시 생성됩니다. 자식이 React.memo로 감싸져 있어도, props로 받은 함수가 매번 새 것이면 memo는 "props가 바뀌었다"고 오판해 자식을 다시 그립니다. useCallback(fn, [])으로 함수를 캐싱해 주소를 고정하면, memo가 이전 props와 새 props를 비교(얕은 비교)했을 때 함수까지 동일하다고 판단해 리렌더링을 건너뜁니다. 즉 useMemo/useCallback(캐싱)React.memo(비교 후 스킵)는 항상 짝을 이뤄야 효과가 있습니다 — 캐싱만 하고 자식을 memo로 감싸지 않으면 자식은 여전히 매번 다시 그려집니다.

JSX src/components/StockSearch.jsx — useRef
import { useState, useRef } from 'react'
import { STOCKS } from '../data/stocks'

function StockSearch() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  // 용도 ① DOM 접근: 실제 <input> 엘리먼트를 가리키는 '손가락' 변수
  const inputRef = useRef(null)
  // 용도 ② 렌더링과 무관한 값 보존: 화면을 다시 그리지 않고 조용히 숫자만 세는 '메모장'
  const renderCount = useRef(0)
  renderCount.current += 1 // 리렌더링마다 1씩 증가하지만, 이 증가 자체는 화면을 다시 그리지 않음!

  const handleSearch = (e) => {
    const q = e.target.value
    setQuery(q) // setState이므로 여기서 리렌더링 발생 → renderCount.current도 최신값으로 화면에 반영됨
    setResults(
      q.trim()
        ? STOCKS.filter(
            (s) =>
              s.symbol.toLowerCase().includes(q.toLowerCase()) ||
              s.name.toLowerCase().includes(q.toLowerCase())
          )
        : []
    )
  }

  const handleClear = () => {
    setQuery('')
    setResults([])
    inputRef.current.focus() // useState로는 불가능 — .current로 실제 DOM에 직접 접근해 focus() 호출
  }

  return (
    <div>
      <p>렌더링 횟수: {renderCount.current}</p>
      <input ref={inputRef} value={query} onChange={handleSearch} placeholder="종목 검색..." />
      <button onClick={handleClear}>지우기</button>
      <ul>
        {results.map((s) => (
          <li key={s.id}>{s.symbol} — {s.name}</li>
        ))}
      </ul>
    </div>
  )
}

export default StockSearch
JSX src/components/StockList.jsx — useMemo + useCallback
import { useState, useMemo, useCallback } from 'react'
import { STOCKS } from '../data/stocks'
import StockRow from './StockRow'

function StockList() {
  const [filter, setFilter] = useState('all')     // 'all' | 'up' | 'down'
  const [sortBy, setSortBy] = useState('symbol')   // 'symbol' | 'price' | 'change'
  const [favorites, setFavorites] = useState([])   // 즐겨찾기(★) 등록된 종목 심볼 배열

  // useMemo: filter나 sortBy가 안 바뀌면 아래 필터링·정렬 연산을 건너뛰고 이전 결과를 재사용
  const processedStocks = useMemo(() => {
    console.log('정렬/필터 재계산!') // filter·sortBy는 안 건드리고 별(★)만 눌러보면 이 로그가 안 찍힘!
    let result = [...STOCKS]
    if (filter === 'up') result = result.filter((s) => s.change >= 0)
    if (filter === 'down') result = result.filter((s) => s.change < 0)
    result.sort((a, b) => {
      if (sortBy === 'price') return b.price - a.price
      if (sortBy === 'change') return b.change - a.change
      return a.symbol.localeCompare(b.symbol)
    })
    return result
  }, [filter, sortBy]) // 의존성 배열 — 이 두 값이 바뀔 때만 위 함수를 다시 실행

  // useCallback: setFavorites만 쓰는 이 함수를 컴포넌트가 다시 그려져도 매번 새로 만들지 않고 고정
  const toggleFavorite = useCallback((symbol) => {
    setFavorites((prev) =>
      prev.includes(symbol) ? prev.filter((s) => s !== symbol) : [...prev, symbol]
    )
  }, []) // 빈 배열 — 최초 1번 만든 함수를 계속 재사용 (메모리 주소가 안 바뀜)

  return (
    <div>
      {/* 필터·정렬 버튼 영역 생략 */}
      <ul>
        {processedStocks.map((stock) => (
          <StockRow
            key={stock.id}
            stock={stock}
            isFavorite={favorites.includes(stock.symbol)}
            onToggleFavorite={toggleFavorite}
          />
        ))}
      </ul>
    </div>
  )
}

export default StockList
JSX src/components/StockRow.jsx — React.memo
import { memo } from 'react'

// memo(...)로 감싸면: 부모(StockList)가 리렌더링돼도 이 컴포넌트는
// "이전 props와 새 props가 완전히 같으면" 다시 그리지 않고 건너뜀
const StockRow = memo(function StockRow({ stock, isFavorite, onToggleFavorite }) {
  // ★을 눌러도 관련 없는 종목들은 이 로그가 안 찍혀야 memo가 제대로 동작하는 것!
  console.log(`${stock.symbol} 렌더링`)

  return (
    <li>
      <button onClick={() => onToggleFavorite(stock.symbol)}>
        {isFavorite ? '★' : '☆'}
      </button>
      <strong>{stock.symbol}</strong> {stock.name} — ${stock.price.toFixed(2)}
    </li>
  )
})

export default StockRow
핵심 정리
  • useRef(초기값).current 속성에 값을 담아두는 상자 — 값이 바뀌어도 화면을 다시 그리지 않는다(useState와 정반대). DOM 엘리먼트에 직접 접근(ref={...})하거나, 렌더와 무관한 카운터·타이머 id 등을 보관할 때 쓴다.
  • useMemo(() => 계산, [의존성])은 의존성이 안 바뀌면 계산을 건너뛰고 이전 "결과값"을 재사용 — 필터링·정렬처럼 비용이 큰 계산에 적합하다.
  • useCallback(fn, [의존성])은 의존성이 안 바뀌면 "함수 자체"를 재사용 — 자식에게 함수를 props로 내려줄 때, 그 자식이 React.memo로 감싸져 있다면 반드시 짝지어 써야 효과가 있다.
  • React.memo(컴포넌트)는 이전 props와 새 props를 얕은 비교(===)해서 완전히 같으면 그 컴포넌트의 리렌더링을 건너뛴다 — 객체·배열·함수는 내용이 같아도 매번 새로 만들면 ===가 false이므로, useMemo/useCallback으로 "같은 참조"를 유지해 줘야 한다.
  • 세 훅 모두 "무조건 빠르다"가 아니라 측정 후 필요한 곳에만 쓰는 최적화 도구 — 남용하면 오히려 캐시 비교 비용만 늘어날 수 있다.
TIP콘솔(F12)을 열어두고 별(★)을 클릭해 보면 세 훅의 효과를 눈으로 확인할 수 있습니다 — useMemo가 있으면 "정렬/필터 재계산!" 로그가 안 찍히고, useCallback+memo가 있으면 클릭한 종목의 "렌더링" 로그만 찍힙니다. 반대로 useMemouseCallback을 지워보면 갑자기 모든 종목의 로그가 다시 찍히는 걸 볼 수 있어요 — 눈에 안 보이던 "낭비"를 직접 확인하는 가장 좋은 방법입니다. useRef·useMemo·useCallback의 기초 개념이 헷갈린다면 기초 개념 사전 — useRef란?useMemo·useCallback이란?, React.memo란?을 참고하세요.
05

커스텀 훅 4종 — useDebounce · useInterval · useLocalStorage · useStockData

커스텀 훅useDebounceuseIntervaluseLocalStoragePromise·비동기

한 줄 요약이미 배운 useState·useEffect·useRef를 조합해 "입력 지연(디바운스)"·"주기적 반복(인터벌)"·"브라우저 자동 저장(로컬스토리지)"·"비동기 데이터 요청" 4가지 재사용 가능한 로직을 각각 독립된 함수(커스텀 훅)로 뽑아내고, StockDetail 컴포넌트 하나가 이 넷을 레고 블록처럼 조합해 동작한다.

쉽게 말하면지금까지 쓴 useState·useEffect는 리액트가 미리 만들어준 "기본 부품"이었다면, 커스텀 훅은 그 기본 부품들을 내가 원하는 조합으로 다시 조립한 "나만의 부품"이에요. useDebounce는 "입력이 멈출 때까지 기다리는 타이머", useInterval은 "정해진 간격마다 알아서 울리는 알람시계", useLocalStorage는 "useState인데 자동으로 서랍(브라우저 저장소)에도 저장해주는 버전", useStockData는 "서버에 물어보고 로딩·성공·실패를 대신 관리해주는 비서"라고 생각하면 됩니다.

커스텀 훅이란? — "use"로 시작하는 그냥 자바스크립트 함수

커스텀 훅은 새로운 문법이 아니라 이름 규칙 하나만 지킨 평범한 함수입니다. 요소를 하나씩 뜯어보면 이렇습니다.

  • 이름이 반드시 use로 시작useDebounce, useInterval처럼. 리액트와 린터(eslint-plugin-react-hooks)가 "이 함수는 안에서 useState/useEffect 같은 다른 훅을 호출해도 되는 함수"라고 인식하는 유일한 단서입니다.
  • 본질은 함수 조합 — 커스텀 훅 내부는 결국 useState·useEffect·useRef 같은 기존 훅을 몇 개 불러다 조합해 놓은 것뿐입니다. 새로운 능력이 생기는 게 아니라, 반복되는 조합을 함수 하나로 포장하는 것입니다.
  • 목적은 재사용 — 디바운스 로직을 검색창 컴포넌트마다 매번 새로 짜는 대신, useDebounce(value, 300) 한 줄로 어느 컴포넌트에서든 똑같이 쓸 수 있습니다.
  • 반환값은 자유useLocalStorage[value, setValue] 배열(useState와 같은 관례), useStockData{data, loading, error, refetch} 객체, useDebounce는 값 하나만 리턴합니다. 정해진 형식이 없고, 이 훅을 쓸 컴포넌트가 쓰기 편한 형태로 직접 설계하면 됩니다.

비유하면 useState·useEffect가 레고의 낱개 블록이라면, 커스텀 훅은 그 블록 몇 개를 미리 조립해서 "바퀴 달린 차체" 같은 완성된 부품으로 만들어 둔 것과 같아요 — 다음에 차를 또 만들 때는 바퀴부터 다시 끼울 필요 없이 이 부품을 그대로 가져다 쓰면 됩니다.

useDebounce — 한 줄씩 뜯어보는 "입력 지연" 로직

useDebounce(value, delay = 300)의 매개변수와 내부 동작을 요소별로 쪼개면 다음과 같습니다.

  • value — 사용자가 키를 칠 때마다 즉시 바뀌는 "실시간 값"(예: 입력창의 inputSymbol).
  • delay — 몇 ms를 기다렸다가 값을 확정할지(기본 300ms = 0.3초).
  • debouncedValue / setDebouncedValueuseState(value)로 만든, "지연 끝에 확정된 최종값"과 그 값을 바꾸는 함수.
  • useEffect 안의 setTimeout — delay(0.3초) 후에 setDebouncedValue(value)를 실행하도록 타이머를 "예약"만 해둠.
  • cleanup(return () => clearTimeout(timer)) — 0.3초가 지나기 전에 value가 또 바뀌면(사용자가 계속 타이핑) 방금 세운 예약을 취소함. 이게 디바운스의 핵심입니다 — 취소가 없으면 옛날 글자에 대한 요청까지 전부 실행돼 버립니다.
  • 의존성 [value, delay] — 둘 중 하나라도 바뀔 때마다 위 예약·취소 로직이 처음부터 다시 실행됨.

비유하면 엘리베이터의 "문 닫힘" 동작과 같아요 — 사람이 계속 타면(타이핑) 문 닫힘 타이머가 그때마다 다시 리셋되고, 한동안 아무도 안 타야(입력이 멈춰야) 비로소 문이 실제로 닫힙니다(값이 확정됨).

useInterval — useRef로 "항상 최신 함수"를 기억해두는 이유

그냥 useEffect 안에 setInterval(callback, delay)만 쓰면 문제가 생깁니다 — delay가 안 바뀌는 한 effect가 다시 실행되지 않으므로, 타이머가 실행하는 callback은 "타이머를 처음 만들었을 때의 낡은 함수"에 영원히 갇혀버립니다(stale closure와 같은 원리). useInterval은 이 문제를 아래처럼 해결합니다.

  • savedCallback = useRef(callback) — "항상 최신 함수를 적어두는 메모장"을 하나 따로 마련. useRef라서 값이 바뀌어도 화면이 다시 그려지지 않습니다.
  • useEffect(() => { savedCallback.current = callback }, [callback]) — 매 렌더링마다 이 메모장의 내용만 최신 함수로 갈아 끼움. 타이머 자체는 건드리지 않습니다.
  • setInterval(() => savedCallback.current(), delay) — 실제로 반복 실행되는 건 "지금 이 순간 메모장에 적힌 함수를 호출해라"라는 래퍼 함수. 그래서 타이머는 그대로 두면서도 실행되는 내용은 항상 최신 버전을 씁니다.
  • delay === null → return — 타이머 자체를 만들지 않고 즉시 종료 → "일시정지" 기능.
  • cleanup(clearInterval(id)) — delay가 바뀌거나 컴포넌트가 사라질 때 이전 타이머를 정리.

비유하면 알람시계의 "울릴 시각(delay)"은 그대로 두고 시계 자체는 안 바꾸되, "울릴 때 재생할 벨소리 파일(callback)"만 메모장을 통해 매번 최신 걸로 몰래 바꿔치기해두는 것과 같아요 — 시계를 매번 새로 사지(타이머를 재시작하지) 않아도 항상 최신 벨소리가 울립니다.

useLocalStorage — useState의 "자동 저장" 버전

사용법은 useState와 똑같이 [value, setValue]를 리턴하지만, 내부에서 브라우저 저장소(localStorage)와 자동으로 동기화합니다.

  • useState(() => {...}) 게으른 초기화기초 개념 사전의 useState 카드에서 다룬 그 패턴 그대로. localStorage 읽기+JSON.parse는 무거운 연산이라, 함수로 감싸 최초 렌더링 때 딱 1번만 실행되게 합니다.
  • localStorage.getItem(key) → JSON.parse — 저장소에 값이 있으면 문자열을 실제 자바스크립트 값으로 되돌려 리턴, 없으면 initialValue를 리턴.
  • try/catch — 저장소 접근이 막혀 있는 브라우저 환경 등 예외 상황에서도 앱이 죽지 않고 initialValue로 안전하게 대체.
  • useEffect([value, key]) — value나 key가 바뀔 때마다 JSON.stringify(value)로 문자열로 바꿔 localStorage.setItem으로 자동 저장.
  • return [value, setValue] — 호출하는 쪽 입장에서는 useState를 쓸 때와 완전히 똑같은 모양이라, 기존 useState('AAPL') 한 줄을 useLocalStorage('lastSymbol', 'AAPL')로 바꿔치기만 하면 자동 저장 기능이 생깁니다.

비유하면 원래 useState는 "칠판"이라 새로고침하면 내용이 지워지는데, useLocalStorage는 "칠판에 쓸 때마다 자동으로 사진을 찍어 서랍에 넣어두고, 다음에 칠판을 켤 때는 서랍 속 최신 사진을 먼저 옮겨 적어두는" 자동화된 칠판입니다.

useStockData — Promise 기반 요청과 로딩·성공·실패 3상태 관리

더미 API인 fakeFetch(symbol)Promise를 리턴합니다 — resolve(성공 결과) 또는 reject(에러) 둘 중 하나를 나중에 호출하는 "미리 주는 약속표"입니다.

  • state = {data, loading, error} — 데이터·로딩 여부·에러를 한 객체로 묶어서 관리.
  • load() 시작 시setState(prev => ({...prev, loading: true, error: null}))로 먼저 "로딩 중" 상태로 바꿔 화면에 스피너를 띄움.
  • fakeFetch(symbol).then(...) — 성공하면 data를 채우고 loading: false로 전환.
  • .catch(...) — 실패하면 error에 메시지를 채우고 역시 loading: false로 전환.
  • useEffect([symbol]) — symbol이 바뀔 때마다 자동으로 load()를 다시 호출(빈 값이면 데이터를 리셋하고 요청 자체를 안 보냄).
  • return {...state, refetch: load} — state를 펼쳐서 내보내고, "수동으로 다시 불러오기" 버튼이 쓸 load 함수도 refetch라는 이름으로 같이 내보냄.

비유하면 식당에 주문(load)을 넣으면 먼저 "조리중" 팻말(loading)을 걸어두고, 음식이 나오면(.then) 팻말을 내리고 요리를 내주며(data), 재료가 없으면(.catch) "품절" 팻말(error)로 바꿔 거는 것과 같아요.

StockDetail — 4개 훅을 조합했을 때의 전체 데이터 흐름

네 훅이 실제로 이어지는 순서는 이렇습니다: ① 처음 켜면 useLocalStorage가 지난번 조회한 종목을 자동 복원 → ② 사용자가 입력창에 타이핑하면 useDebounce가 300ms 동안 기다렸다가 입력이 멈춘 순간의 값만 통과시킴 → ③ 그 안정된 값으로 useStockData가 데이터를 요청해 data/loading/error를 관리 → ④ "5초마다 자동 갱신" 체크박스가 켜져 있으면 useInterval(refetch, autoRefresh ? 5000 : null)이 5초마다 refetch를 대신 호출.

2026-07-27에 5곳의 빈칸을 모두 실제 훅으로 채워 완성했고, 그 과정에서 autoRefreshuseState 대신 useLocalStorage로 한 단계 더 업그레이드했습니다(새로고침해도 "자동 갱신" 체크 상태가 유지되도록).

useStockData의 load — useCallback으로 감싸지 않으면 왜 무한 루프에 빠지는가

완성 과정에서 가장 헷갈렸던 지점이 바로 이 부분입니다. load 함수를 useCallback 없이 그냥 const load = () => {...}로 두면 무슨 일이 생기는지 순서대로 뜯어보면 이렇습니다.

  • 사실 ① — 함수도 매 렌더링마다 새로 만들어진다 — 컴포넌트(여기서는 useStockData 훅)가 리렌더링될 때마다 그 안의 load는 겉보기엔 같아 보여도 매번 "새로운 함수 객체"로 다시 생성됩니다.
  • 사실 ② — useEffect의 의존성 비교는 "참조"로 한다useEffect(..., [load])는 이전 렌더의 load와 이번 렌더의 load가 같은 메모리 주소인지만 비교하지, 함수 내용이 같은지는 보지 않습니다.
  • ① + ②가 겹치면 — load가 매 렌더링마다 새 주소를 가지므로, [load]를 의존성 배열에 넣는 순간 useEffect는 "load가 바뀌었다"고 오판해 매 렌더링마다 재실행됩니다. 그 안에서 load()setState → 리렌더링 → 새 load 생성 → effect 재실행 → ... 이 끝없이 반복되는 무한 루프에 빠집니다.
  • 해결책 — useCallback(fn, [symbol]) — "symbol이 바뀌지 않는 한 이전과 같은 함수(같은 주소)를 계속 재사용해라"고 리액트에게 지시하는 것. 이제 load의 주소는 symbol이 바뀔 때만 바뀌므로, [load]를 의존성 배열에 정직하게 넣어도 안전합니다.

비유하면 useCallback 없는 load는 "매번 새 도장을 파서 찍는" 상황이라 도장이 바뀔 때마다("바뀐 것 같으니 다시 확인해!") 검사관(useEffect)이 계속 호출되는 것과 같고, useCallback을 씌우면 "symbol이라는 원본이 바뀔 때만 새 도장을 파고, 그 전까지는 같은 도장을 재사용"하게 되어 검사관도 정말 필요할 때만 호출됩니다. 이 문제는 기초 개념 사전 — useMemo·useCallback이란?stale closure 카드에서 다룬 "참조 비교" 개념이 그대로 적용된 사례입니다.

JS src/hooks/useDebounce.js
// ── STEP 2: 디바운스 커스텀 훅 ──
// 개념: 실시간 입력값(value)을 일정 시간(delay) 동안 지연시켰다가, 입력이 멈추면 마지막 값만 반환함.
import { useState, useEffect } from 'react'

// [useDebounce] 실시간 입력값(value)과 지연시간(delay, 기본 300ms = 0.3초)을 인자로 받는 훅
export function useDebounce(value, delay = 300) {
    // 1. 0.3초 대기 후 확정될 최종 디바운스 값(debouncedValue)과 상태 변경 함수(setDebouncedValue) 생성
    const [debouncedValue, setDebouncedValue] = useState(value)

    useEffect(() => {
        // 2. [setTimeout] 지정한 delay(0.3초) 동안 기다렸다가, 입력이 멈추면 setDebouncedValue(value)를 실행하여 최종값 갱신
        const timer = setTimeout(() => setDebouncedValue(value), delay)

        // 3. [Cleanup 함수] 0.3초가 지나기 전에 사용자가 또 키보드를 치면 (value가 바뀌면)
        //    이전에 예약해둔 타이머를 취소(clearTimeout)하여 옛날 글자 요청이 실행되는 것을 방지함! (핵심!)
        return () => clearTimeout(timer)

    }, [value, delay]) // 4. 입력값(value)이나 지연시간(delay)이 바뀔 때마다 useEffect 재실행

    // 5. 0.3초 동안 타이핑이 멈춘 후 최종 확정된 디바운스 값을 밖으로 리턴
    return debouncedValue
}
JS src/hooks/useInterval.js
// ── STEP 3: 인터벌(주기 실행) 커스텀 훅 ──
// 개념: 지정한 주기(delay)마다 반복 함수(callback)를 안전하게 실행함. delay가 null이면 타이머 정지.
import { useEffect, useRef } from 'react'

// [useInterval] callback: 반복 실행할 함수 / delay: 반복 주기(밀리초, null이면 일시정지)
export function useInterval(callback, delay) {
    // 1. [useRef] 최신 callback 함수를 보관하는 메모장 생성 (값이 바뀌어도 타이머가 리셋되지 않음)
    const savedCallback = useRef(callback)

    // 2. [useEffect] callback 함수가 바뀔 때마다 메모장에 최신 함수를 기록 (타이머는 켜진 상태 유지)
    useEffect(() => {
        savedCallback.current = callback
    }, [callback])

    // 3. [useEffect] delay(주기)가 설정되면 실제 setInterval 타이머 동작
    useEffect(() => {
        // [정지 스위치] delay가 null이면 타이머를 실행하지 않고 즉시 종료 (일시정지 기능)
        if (delay === null) return

        // [setInterval] delay(예: 1000ms)마다 메모장에 적힌 최신 함수(savedCallback.current)를 실행
        const id = setInterval(() => savedCallback.current(), delay)

        // [Cleanup 함수] delay가 바뀌거나 컴포넌트가 꺼질 때 이전 타이머(setInterval)를 깨끗이 제거
        return () => clearInterval(id)
    }, [delay]) // delay가 변경될 때만 타이머를 새로 설정함
}
JS src/hooks/useLocalStorage.js
// ── STEP 4: localStorage 연동 커스텀 훅 ──
// 개념: useState처럼 [값, 변경함수]를 돌려주되, 브라우저 저장소(localStorage)에 자동으로 저장하고 읽어옴.
import { useState, useEffect } from 'react'

// [useLocalStorage] key: 저장소에 사용할 이름(예: 'lastSymbol') / initialValue: 저장소에 없을 때 쓸 기본값(예: 'AAPL')
export function useLocalStorage(key, initialValue) {

    // [useState(() => ...)] 게으른 초기화 (Lazy Initialization)
    // 💡 왜 useState() 안에 바로 값을 안 넣고 함수 (() => { ... }) 형태로 넣나요?
    // - localStorage에서 값을 읽어오고 문자열을 변환(JSON.parse)하는 작업은 무거운 연산입니다.
    // - useState(() => ...) 처럼 함수를 넣으면, 리액트가 이 읽기 작업을 "맨 처음 렌더링될 때 딱 1번만" 수행합니다!
    const [value, setValue] = useState(() => {
        try {
            // 1. [localStorage.getItem(key)] 브라우저 저장소에서 지정한 key 이름으로 저장된 글자(데이터)를 꺼내옴
            const item = localStorage.getItem(key)

            // 2. [삼항 연산자] 저장된 데이터(item)가 존재하면?
            //    - JSON.parse(item): 글자 형태의 데이터를 진짜 자바스크립트 데이터(객체/문자열)로 다시 변환해서 리턴!
            //    - 없으면? 준비해둔 기본값(initialValue)을 리턴!
            return item ? JSON.parse(item) : initialValue

        } catch (err) {
            // 3. [try-catch] 브라우저 저장소 읽기 실패 시(보안 제한 등), 경고를 출력하고 안전하게 기본값(initialValue) 리턴
            console.warn('localStorage 읽기 실패:', err)
            return initialValue
        }
    })

    // [useEffect] value(값)나 key가 변경될 때마다 브라우저 저장소에 자동으로 저장해주는 감시 장치
    useEffect(() => {
        try {
            // 1. [JSON.stringify(value)] 저장소에 보관할 수 있도록 자바스크립트 값(value)을 글자(문자열) 형태로 변환
            // 2. [localStorage.setItem(key, ...)] 브라우저 저장소에 key 이름으로 글자 데이터를 최종 저장!
            localStorage.setItem(key, JSON.stringify(value))
        } catch (err) {
            // 저장 용량 초과 등의 이유로 쓰기 실패 시 경고 출력
            console.warn('localStorage 쓰기 실패:', err)
        }
    }, [value, key]) // [value, key] 변수의 값이 바뀔 때마다 이 저장 로직이 자동으로 실행됨

    // 일반 useState처럼 똑같이 [현재값, 값변경함수] 세트를 배열 형태로 밖으로 리턴!
    return [value, setValue]
}
JS src/hooks/useStockData.js
// ── STEP 1: 데이터 패칭 커스텀 훅 ── (완성본 — load 함수를 useCallback으로 감싼 버전)
// 개념: 종목코드를 넣으면 { data, loading, error, refetch }를 돌려주는 훅.
import { useState, useEffect, useCallback } from 'react'

// (헬퍼는 제공됩니다) 더미 API — Promise로 비동기 결과를 돌려준다.
// [function] 함수 정의 / [symbol] 전달받는 주식 종목 코드
function fakeFetch(symbol) {
    // [Promise] 비동기 처리를 위한 객체 반환 (resolve: 성공 알림 함수, reject: 실패 알림 함수)
    return new Promise((resolve, reject) => {
        // [setTimeout] 실제 네트워크 통신처럼 0.8초~1.2초 지연(로딩)을 흉내냄
        setTimeout(() => {
            // symbol이 'ERROR'인 경우 실패(reject) 상태 흉내
            if (symbol === 'ERROR') {
                reject(new Error('종목을 찾을 수 없습니다.')) // 에러 전달
                return // 함수 실행 중단
            }

            // 주요 종목의 기본 주가 정의 객체
            const base = { AAPL: 182.52, TSLA: 248.5, MSFT: 378.85 }

            // [resolve] 비동기 작업 성공 알림 및 결과 데이터 객체 반환
            resolve({
                symbol, // 단축 속성명 (symbol: symbol 과 동일)
                price: base[symbol] || Math.random() * 200 + 100, // 기본가 또는 100~300 사이 랜덤 주가
                change: (Math.random() * 6 - 3).toFixed(2) * 1, // -3~+3 소수점 2자리 반올림 후 숫자 타입 변환 (* 1)
                volume: Math.floor(Math.random() * 10_000_000), // 0~1천만 사이 정수(Math.floor) 거래량
                updatedAt: new Date().toLocaleTimeString(), // 현재 시간을 현지 시각 문자열로 변환
            })
        }, 800 + Math.random() * 400) // 800ms + (0~400ms) = 0.8초~1.2초 지연
    })
}

export function useStockData(symbol) {
    // 1. [useState] 주식 데이터(data), 로딩 여부(loading), 에러 정보(error)를 한 객체로 관리하는 상태
    const [state, setState] = useState({ data: null, loading: true, error: null })

    // 2. [useCallback으로 감싼 load 함수] symbol이 바뀔 때만 새로 만들어지도록 함수 자체를 메모이제이션
    // 💡 왜 useCallback으로 감싸야 하나요?
    // - 감싸지 않으면 컴포넌트가 리렌더링될 때마다 load가 매번 "새로운 함수"로 다시 만들어집니다.
    // - 그 상태로 아래 useEffect의 의존성 배열에 [load]를 넣으면, "새 load 생성 → effect 재실행 → load() 호출 → setState → 리렌더링 → 또 새 load 생성"이 끝없이 반복되는 무한 루프에 빠집니다.
    // - useCallback(..., [symbol])로 감싸두면 symbol이 안 바뀌는 한 load는 "같은 함수"를 계속 재사용하므로, [load]를 의존성 배열에 정직하게 넣어도 안전합니다.
    const load = useCallback(() => {
        // [시작 전] 먼저 로딩 상태를 true로 만들고 에러를 리셋함 (화면에 '로딩 중...' 표시)
        setState((prev) => ({ ...prev, loading: true, error: null }))

        // [비동기 데이터 요청] fakeFetch(symbol) 실행
        fakeFetch(symbol)
            // [성공 시 .then] 데이터 도착! data 세팅, loading: false로 로딩 종료
            .then((data) => setState((prev) => ({ ...prev, data, loading: false, error: null })))
            // [실패 시 .catch] 에러 발생! 에러 메시지 세팅, loading: false로 로딩 종료
            .catch((err) => setState({ data: null, loading: false, error: err.message }))
    }, [symbol]) // symbol이 바뀔 때만 load 함수를 재생성

    // 3. [useEffect] load 함수 참조(즉, symbol)가 변경될 때마다 실행되는 감시 장치
    useEffect(() => {
        // [검색어 초기화 처리] symbol이 빈 값일 경우 (검색어를 다 지운 경우)
        if (!symbol) {
            setState({ data: null, loading: false, error: null }) // 데이터 리셋
            return // 데이터 요청(load)을 하지 않고 함수 즉시 종료
        }

        // symbol이 잘 존재하면 주식 데이터 로딩 함수 실행!
        load()
    }, [load]) // load가 useCallback으로 메모이제이션돼 있어 [load]를 넣어도 무한 루프 없이 안전함!

    // 4. [리턴값] 현재 상태 객체({ data, loading, error })를 펼쳐서 반환하고, 수동 새로고침 함수(refetch: load)도 함께 밖으로 반환!
    return { ...state, refetch: load }
}
JSX src/components/StockDetail.jsx — 완성본 (5곳 빈칸을 모두 채운 최종 상태)
// ── STEP 5: 네 개의 커스텀 훅을 '조합'한 컴포넌트 ──
// 개념: 직접 만든 커스텀 훅들(useStockData, useDebounce, useInterval, useLocalStorage)을 레고 블록처럼 조합하여 주식 상세 페이지 완성.
import { useStockData } from '../hooks/useStockData'
import { useDebounce } from '../hooks/useDebounce'
import { useInterval } from '../hooks/useInterval'
import { useLocalStorage } from '../hooks/useLocalStorage'

// useLocalStorage : 마지막에 조회한 종목을 저장하고 가져오는 기능
// useDebounce : 사용자가 타이핑하는 동안에는 기다렸다가 300ms(0.3초) 지연된 안정적인 입력값을 생성
// useInterval : autoRefresh 체크박스가 켜지면(true) 5초마다 refetch 실행, 꺼지면(false) null을 전달하여 타이머 일시정지!
// useStockData : 300ms 디바운스된 종목 코드로 주식 데이터(data), 로딩상태(loading), 에러(error), 수동재로딩(refetch)을 가져옴

export default function StockDetail() {
    // 1. [useLocalStorage] 사용자가 입력한 종목 코드를 브라우저 저장소에 'lastSymbol' 키로 자동 저장 및 복원 (기본값 'AAPL')
    const [inputSymbol, setInputSymbol] = useLocalStorage('lastSymbol', 'AAPL')

    // 2. [useLocalStorage] 5초마다 자동 갱신(체크박스)할지 여부를 브라우저 저장소에 'autoRefresh' 키로 자동 저장 및 복원 (기본값 false)
    //    → 원래는 useState(false)였지만, "새로고침해도 체크 상태가 유지되면 좋겠다"는 이유로 useLocalStorage로 한 단계 더 업그레이드함
    const [autoRefresh, setAutoRefresh] = useLocalStorage('autoRefresh', false)

    // 3. [useDebounce] 사용자가 타이핑하는 동안에는 기다렸다가 300ms(0.3초) 지연된 안정적인 입력값을 생성
    const debouncedSymbol = useDebounce(inputSymbol, 300)

    // 4. [useStockData] 300ms 디바운스된 종목 코드로 주식 데이터(data), 로딩상태(loading), 에러(error), 수동재로딩(refetch)을 가져옴
    const { data, loading, error, refetch } = useStockData(debouncedSymbol)

    // 5. [useInterval] autoRefresh 체크박스가 켜지면(true) 5초마다 refetch 실행, 꺼지면(false) null을 전달하여 타이머 일시정지!
    useInterval(refetch, autoRefresh ? 5000 : null)

    return (
        <div style={{ padding: '1.5rem', maxWidth: '480px' }}>
            <h2>종목 상세 조회</h2>

            {/* 종목 입력창 및 수동 새로고침 버튼 영역 */}
            <div style={{ display: 'flex', gap: '8px', marginBottom: '1rem' }}>
                <input
                    value={inputSymbol}
                    // e.target.value.toUpperCase(): 입력한 글자를 자동으로 대문자(예: aapl -> AAPL)로 변환
                    onChange={(e) => setInputSymbol(e.target.value.toUpperCase())}
                    placeholder="종목 코드 (ERROR 입력 시 에러)"
                    style={{ flex: 1, padding: '8px', borderRadius: '6px', border: '1px solid #ddd' }}
                />
                {/* 버튼 클릭 시 refetch 실행 / 로딩 중일 때는 클릭 못하게 disabled 처리 */}
                <button onClick={refetch} disabled={loading}>{loading ? '로딩 중...' : '새로고침'}</button>
            </div>

            {/* 5초마다 자동 갱신 체크박스 영역 */}
            <label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '1rem', cursor: 'pointer' }}>
                <input type="checkbox" checked={autoRefresh} onChange={(e) => setAutoRefresh(e.target.checked)} />
                <span style={{ fontSize: '13px' }}>5초마다 자동 갱신</span>
                {/* autoRefresh가 true일 때만 녹색 '● 활성' 문구 표시 */}
                {autoRefresh && <span style={{ fontSize: '12px', color: '#16a34a' }}>● 활성</span>}
            </label>

            {/* [로딩 상태 렌더링] loading이 true일 때만 로딩 스피너 표시 */}
            {loading && <div style={{ padding: '2rem', textAlign: 'center', border: '1px solid #eee', borderRadius: '8px', color: '#888' }}>⏳ {debouncedSymbol} 로딩 중...</div>}

            {/* [에러 상태 렌더링] error가 존재할 때만 빨간색 에러 상자 표시 */}
            {error && (
                <div style={{ padding: '1rem', borderRadius: '8px', background: '#fff0f0', border: '1px solid #ffcdd2', color: '#c62828' }}>
                    <strong>❌ 오류</strong>
                    <p style={{ margin: '4px 0 0', fontSize: '13px' }}>{error}</p>
                    <button onClick={refetch} style={{ marginTop: '8px', fontSize: '12px' }}>다시 시도</button>
                </div>
            )}

            {/* [데이터 성공 렌더링] data가 존재하고 로딩 중이 아닐 때 주식 상세 정보 카드 표시 */}
            {data && !loading && (
                <div style={{ padding: '1.25rem', borderRadius: '8px', border: '1px solid #e0e0e0', background: '#fafafa' }}>
                    <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
                        <div>
                            <h3 style={{ margin: 0, fontSize: '20px' }}>{data.symbol}</h3>
                            <p style={{ margin: '4px 0', fontSize: '12px', color: '#888' }}>업데이트: {data.updatedAt}</p>
                        </div>
                        <div style={{ textAlign: 'right' }}>
                            <div style={{ fontSize: '24px', fontWeight: 'bold' }}>${data.price.toFixed(2)}</div>
                            {/* 변동폭이 양수면 파란색(+), 음수면 빨간색 표시 */}
                            <div style={{ color: data.change >= 0 ? 'blue' : 'red', fontWeight: 'bold' }}>{data.change >= 0 ? '+' : ''}{data.change}%</div>
                        </div>
                    </div>
                    <div style={{ marginTop: '12px', fontSize: '13px', color: '#666' }}>거래량: {data.volume.toLocaleString()}</div>
                </div>
            )}
        </div>
    )
}
핵심 정리
  • 커스텀 훅은 새 문법이 아니라 use로 시작하는 평범한 함수 — 내부에서 기존 훅(useState/useEffect/useRef)을 조합해 재사용 가능한 로직 단위로 뽑아낸 것이다.
  • useDebounce는 setTimeout 예약 + value 변경 시 clearTimeout으로 예약을 취소하는 방식으로 "입력이 멈춘 순간의 값"만 통과시킨다.
  • useIntervaluseRef로 "항상 최신 콜백"을 저장해두는 트릭으로, 타이머를 재시작하지 않고도 stale closure 없이 최신 함수를 반복 실행한다.
  • useLocalStorageuseState(() => ...) 게으른 초기화로 저장소를 1번만 읽고, useEffect로 값이 바뀔 때마다 자동 저장한다 — 사용법은 useState와 동일한 [value, setValue].
  • useStockData는 Promise의 .then/.catch로 성공·실패를 나눠 처리하며, {data, loading, error} 3상태를 한 객체로 관리한다.
  • load 함수를 useCallback(fn, [symbol])으로 감싸지 않으면 매 렌더링마다 새 함수가 만들어져 useEffect([load])가 무한 재실행에 빠진다 — 함수를 useEffect 의존성에 정직하게 넣으려면 그 함수 자체를 useCallback으로 참조 고정해야 한다.
TIP이 카드는 5곳의 빈칸을 채워가는 실습 과정을 그대로 기록했습니다: ① useState('AAPL')useLocalStorage('lastSymbol', 'AAPL')(반환값 이름은 그대로 [inputSymbol, setInputSymbol]), ② const debouncedSymbol = inputSymbolconst debouncedSymbol = useDebounce(inputSymbol, 300), ③ const data = null, loading = false, ...const { data, loading, error, refetch } = useStockData(debouncedSymbol), ④ 주석 처리돼 있던 useInterval(refetch, autoRefresh ? 5000 : null) 줄의 주석을 해제, ⑤(추가 개선) autoRefreshuseState에서 useLocalStorage로 바꾸고, useStockData 내부의 loaduseCallback(..., [symbol])으로 감싸 useEffect 의존성 배열에 [load]를 안전하게 넣을 수 있게 완성했습니다. 훅 자체(use로 시작하는 함수)와 훅이 헷갈리면 기초 개념 사전 — 커스텀 훅이란?을 참고하세요.
06

React.memo·useCallback 비교 + lazy/Suspense 코드 분할

React.memouseCallbackReact.lazySuspense코드 분할

한 줄 요약같은 주식 목록을 memo 없는 버전과 memo+useCallback 버전으로 나란히 만들어 리렌더링 스킵 효과를 콘솔 로그로 직접 확인하고, HeavyChart·HeavyReport 두 탭 화면은 React.lazy + Suspense로 감싸 처음 화면을 열 때는 다운로드하지 않고 그 탭을 처음 클릭하는 순간에만 코드를 따로 받아오게(코드 분할) 만든 실습이다.

쉽게 말하면이번 실습은 두 가지를 "눈으로 비교"해 보는 데 초점이 맞춰져 있어요. 하나는 "리렌더링을 막았을 때와 안 막았을 때 콘솔 로그가 어떻게 다르게 찍히는가"(memo), 다른 하나는 "탭을 누르기 전까지는 그 화면의 코드조차 안 받아오다가, 누르는 순간 잠깐 로딩 스피너가 뜨면서 코드를 받아오는 모습"(lazy+Suspense)입니다.

RowWithoutMemo vs RowWithMemo — 같은 화면을 일부러 두 벌 만든 이유

두 컴포넌트는 렌더링 내용이 완전히 똑같습니다. 차이는 딱 하나, memo로 감쌌는지 여부뿐입니다.

  • function RowWithoutMemo({...}) — 일반 컴포넌트. 부모(MemoComparison)가 리렌더링될 때마다 무조건 같이 다시 그려집니다.
  • const RowWithMemo = memo(function RowWithMemo({...})) — 똑같은 내용을 memo(...)로 한 겹 감쌈. 부모가 리렌더링돼도 이 컴포넌트가 받는 props(symbol, price, change, onToggle)가 이전과 완전히 같으면 리렌더링을 건너뜁니다.
  • console.log(`[memo없음] ${symbol} 렌더링`) / console.log(`[memo있음] ${symbol} 렌더링`) — 눈에 안 보이는 "리렌더링 여부"를 콘솔 로그로 확인할 수 있게 만든 장치. 부모의 "부모 리렌더 유발" 버튼을 누르면 위쪽 로그는 매번 찍히고, 아래쪽 로그는 한 번 찍힌 뒤 더 이상 안 찍힙니다.

비유하면 이건 같은 그림을 그리는 화가 두 명을 나란히 세워둔 것과 같아요 — 한 명(RowWithoutMemo)은 "손님이 왔다"는 소식만 들리면 이유 불문 매번 새로 그리고, 다른 한 명(RowWithMemo)은 "지난번 주문서(props)랑 토씨 하나 안 다르면 그리지 않고 그냥 앉아 있는" 까다로운 화가입니다.

onToggle을 useCallback으로 감싸지 않으면 memo가 무력화되는 이유

이 실습에서 memo가 실제로 리렌더링을 막아주려면 onToggle 함수의 "포장 방식"이 중요합니다.

  • const onToggle = useCallback((symbol) => {...}, []) — 의존성 배열이 빈 []이므로, 컴포넌트가 처음 만들어질 때 딱 한 번 이 함수를 만들고 그 이후로는 같은 함수(같은 메모리 주소)를 계속 재사용합니다.
  • 만약 useCallback 없이 const onToggle = (symbol) => {...}로 썼다면 — "부모 리렌더 유발" 버튼을 눌러 tick이 바뀔 때마다 onToggle이 매번 새로 만들어진 함수가 됩니다. 함수는 객체와 같은 방식(참조)으로 비교되므로, memo는 "onToggle이 지난번과 달라졌다"고 판단해 RowWithMemo도 어쩔 수 없이 다시 그리게 됩니다.
  • 결론memo(자식을 방어) + useCallback(부모가 내려주는 함수의 참조를 고정)은 항상 한 세트로 붙어 다녀야 실제 효과가 납니다. 자세한 원리는 기초 개념 사전 — React.memo란? 카드를 참고하세요.

lazy(() => import('./HeavyChart')) — 처음 화면을 열 때 이 코드는 아직 안 옵니다

App.jsx 맨 위에서 HeavyChart·HeavyReport를 만드는 방식이 평소의 import와 다릅니다.

  • const HeavyChart = lazyWithDelay(() => import('./components/HeavyChart'))import('...')처럼 괄호를 붙여 함수처럼 호출하는 동적 import는, 파일 맨 위에 쓰는 평소의 정적 import와 달리 호출되는 그 순간에야 네트워크로 코드를 따로 받아옵니다.
  • lazy(...) — 이 동적 import 함수를 리액트에게 "이 컴포넌트는 아직 준비 안 됐을 수 있다"고 알려주는 포장지로 한 번 더 감쌈.
  • 실제 효과 — 사용자가 "⚡ memo 비교" 탭만 보다가 앱을 닫으면, HeavyChart·HeavyReport의 코드는 단 한 번도 네트워크로 받아오지 않습니다. "📈 차트" 탭을 처음 클릭하는 순간에만 비로소 그 코드가 별도 조각(청크)으로 다운로드됩니다.

자세한 동작 순서와 원리는 기초 개념 사전 — React.lazy + Suspense란? 카드에서 요소별로 정리했습니다.

lazyWithDelay — 로딩 스피너를 실제로 보이게 만드는 실습용 장치

내 컴퓨터에서 개발 서버로 테스트하면 파일이 이미 디스크에 있어서 다운로드가 너무 빨라 로딩 스피너가 거의 안 보입니다. 그래서 lazy를 바로 쓰지 않고 lazyWithDelay라는 헬퍼로 한 번 더 감쌌습니다 — new Promise((resolve) => setTimeout(() => resolve(factory()), 1000))로 "1초 뒤에야 진짜 import를 실행하겠다"는 인위적인 지연을 끼워 넣은 것입니다. 코드 분할이라는 실제 동작 자체에는 영향이 없고, 순전히 "로딩 중" 상태를 눈으로 확인하기 위한 학습용 장치입니다.

<Suspense fallback={<LoadingSpinner />}> 하나로 세 탭을 전부 감싼 이유

App.jsx는 Suspense 울타리 하나로 memo 탭까지 포함한 세 탭 전부를 감쌉니다. memo 탭(MemoComparison)은 애초에 lazy로 만들지 않았으므로 항상 즉시 준비돼 있어 fallback이 뜰 일이 없고, chart/report 탭은 lazy 컴포넌트라서 처음 클릭 시에만 fallback(⏳ 로딩 중)이 잠깐 나타났다가 다운로드가 끝나면 자동으로 실제 화면으로 바뀝니다. 하나의 Suspense가 "그 안에 있는 lazy 컴포넌트 중 지금 준비 안 된 게 있으면"만 반응하므로, 이미 준비된 컴포넌트에는 아무 영향이 없습니다.

JSX src/components/MemoComparison.jsx
// ── STEP 1: React.memo & useCallback 최적화 비교 실습 ──
// 개념: memo는 props가 같으면 리렌더링을 건너뜀. 단, useCallback으로 함수 참조를 고정해야 memo가 정상 작동함!
import { useState, memo, useCallback } from 'react'

// ❌ [React.memo 없음] 일반 컴포넌트
// 부모(MemoComparison)의 상태(tick)가 바뀔 때마다 무조건 자식도 같이 다시 그려짐! (불필요한 리렌더링)
function RowWithoutMemo({ symbol, price, change, onToggle }) {
    console.log(`[memo없음] ${symbol} 렌더링`) // 콘솔에 찍히는 로그로 리렌더링 확인 가능
    const isUp = change >= 0
    return (
        <div style={{ padding: '10px', marginBottom: '4px', border: '1px solid #ffcdd2', borderRadius: '6px' }}>
            <strong>{symbol}</strong> ${price.toFixed(2)}
            <span style={{ color: isUp ? 'blue' : 'red', marginLeft: '8px' }}>{isUp ? '+' : ''}{change}%</span>
            <button onClick={() => onToggle(symbol)} style={{ marginLeft: '8px' }}>★</button>
        </div>
    )
}

// ✅ [React.memo 적용] 최적화된 컴포넌트
// 💡 memo(function ...): 부모가 리렌더링되어도, 전달받는 props(symbol, price, change, onToggle)의 값이 변하지 않았다면 리렌더링을 완전히 막아줌!
const RowWithMemo = memo(function RowWithMemo({ symbol, price, change, onToggle }) {
    console.log(`[memo있음] ${symbol} 렌더링`) // props가 변경될 때만 로그가 찍힘!
    const isUp = change >= 0
    return (
        <div style={{ padding: '10px', marginBottom: '4px', border: '1px solid #c8e6c9', borderRadius: '6px' }}>
            <strong>{symbol}</strong> ${price.toFixed(2)}
            <span style={{ color: isUp ? 'blue' : 'red', marginLeft: '8px' }}>{isUp ? '+' : ''}{change}%</span>
            <button onClick={() => onToggle(symbol)} style={{ marginLeft: '8px' }}>★</button>
        </div>
    )
})

// 테스트용 더미 주식 목록 데이터
const stocks = [
    { symbol: 'AAPL', price: 182.52, change: 1.24 },
    { symbol: 'TSLA', price: 248.5, change: -2.15 },
    { symbol: 'MSFT', price: 378.85, change: 0.87 },
]

export default function MemoComparison() {
    // 부모의 리렌더링을 강제로 일으키기 위한 단순 숫자를 관리하는 상태
    const [tick, setTick] = useState(0)

    // ✅ [useCallback] 전달할 함수 메모리 고정
    // 💡 왜 useCallback으로 감싸야 하나요?
    // - 감싸지 않으면 부모가 리렌더링될 때마다 onToggle 함수가 새로 생성됩니다.
    // - 함수 주소가 새로워지면 RowWithMemo는 "onToggle이 바뀌었네?" 하고 memo를 작성했어도 다시 그려지게 됩니다!
    // - 의존성 배열 []을 비워두면, 컴포넌트가 처음 생성될 때 함수 주소를 고정해 주므로 memo가 정상 작동합니다!
    const onToggle = useCallback((symbol) => {
        console.log('토글:', symbol)
    }, [])

    return (
        <div style={{ padding: '1.5rem', maxWidth: '500px' }}>
            {/* 부모의 상태(tick)를 변경시키는 버튼 */}
            <div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '1rem' }}>
                <button onClick={() => setTick((t) => t + 1)} style={{ padding: '8px 16px' }}>부모 리렌더 유발 (tick: {tick})</button>
                <span style={{ fontSize: '12px', color: '#888' }}>콘솔을 열고 눌러보세요</span>
            </div>

            {/* ❌ memo가 없는 주식 목록 (부모 버튼 누르면 계속 로그가 찍히며 다시 그려짐) */}
            <h4 style={{ color: '#c62828' }}>❌ React.memo 없음</h4>
            {stocks.map((s) => (
                <RowWithoutMemo key={s.symbol} {...s} onToggle={onToggle} />
            ))}

            {/* ✅ memo + useCallback이 적용된 주식 목록 (부모 버튼 눌러도 로그 안 찍히고 리렌더링 완전히 스킵!) */}
            <h4 style={{ color: '#2e7d32', marginTop: '1rem' }}>✅ React.memo + useCallback (리렌더링 스킵 완료)</h4>
            {stocks.map((s) => (
                <RowWithMemo key={s.symbol} {...s} onToggle={onToggle} />
            ))}
        </div>
    )
}
JSX src/components/HeavyChart.jsx
// ── STEP 2a: lazy로 지연 로딩될 무거운 컴포넌트 ──
// 💡 [코드 분할 (Code Splitting)]
// - 처음 웹 사이트에 접속했을 때 사용자가 차트 탭을 안 누를 수도 있습니다.
// - 굳이 안 보는 차트 코드까지 처음부터 전부 다운로드하면 웹사이트 초기 화면 접속이 늦어집니다!
// - 이 컴포넌트를 React.lazy()로 감싸두면, 사용자가 '📈 차트' 탭을 처음 누르는 그 순간에만 네트워크를 통해 이 코드가 지연 다운로드됩니다.
export default function HeavyChart() {
    return (
        <div style={{ padding: '1.5rem', border: '1px solid #dbeafe', borderRadius: '8px', background: '#eff6ff' }}>
            <h3 style={{ marginTop: 0 }}>📈 차트 패널 (무겁다고 가정)</h3>
            <p style={{ color: '#555', fontSize: '14px' }}>
                이 컴포넌트는 lazy로 분할되어, 이 탭을 처음 눌렀을 때만 로드됩니다. (첫 로드 시 Suspense fallback이 잠깐 보임)
            </p>
        </div>
    )
}
JSX src/components/HeavyReport.jsx
// ── STEP 2b: lazy로 지연 로딩될 무거운 컴포넌트 (예시 2) ──
// 💡 HeavyChart와 마찬가지로, 이 리포트 패널 코드는 처음부터 다운받지 않고
//    '📑 리포트' 탭을 사용자가 클릭하는 순간 독립된 별도의 번들 조각(청크)으로 다운로드됩니다.
export default function HeavyReport() {
    return (
        <div style={{ padding: '1.5rem', border: '1px solid #dcfce7', borderRadius: '8px', background: '#f0fdf4' }}>
            <h3 style={{ marginTop: 0 }}>📑 리포트 패널 (무겁다고 가정)</h3>
            <p style={{ color: '#555', fontSize: '14px' }}>
                HeavyChart와 마찬가지로 별도 청크로 분리됩니다. 필요할 때만 네트워크로 받아오죠.
            </p>
        </div>
    )
}
JSX src/App.jsx
// ── STEP 3: lazy + Suspense 로 코드 분할 & 로딩 처리 ──
// 개념: lazy(() => import(...))로 필요할 때 다운로드(코드 분할), Suspense fallback으로 다운로드되는 동안 보여줄 스피너 지정.
import { lazy, Suspense, useState } from 'react'
import MemoComparison from './components/MemoComparison'

// 💡 [lazyWithDelay 헬퍼]
// 원래 lazy는 네트워크가 너무 빠르면 로딩 스피너가 눈 깜빡할 사이에 지나가서 잘 안 보입니다.
// 실습 시 로딩 스피너(Suspense fallback)를 눈으로 확실히 확인하기 위해 1초 강제 지연시간을 추가한 함수입니다.
const lazyWithDelay = (factory) =>
  lazy(() => new Promise((resolve) => setTimeout(() => resolve(factory()), 1000)))

// 💡 [React.lazy (지연 로딩)]
// - 처음 웹페이지에 들어왔을 때 HeavyChart, HeavyReport의 번들 코드를 다운로드받지 않습니다!
// - 사용자가 해당 탭을 클릭하는 순간 비로소 네트워크로 지연 다운로드받아 옵니다. (초기 로딩 속도 최적화!)
const HeavyChart = lazyWithDelay(() => import('./components/HeavyChart'))
const HeavyReport = lazyWithDelay(() => import('./components/HeavyReport'))

// 컴포넌트를 인터넷에서 받아오는 동안 화면에 띄워둘 로딩 표시 UI 컴포넌트
function LoadingSpinner({ label }) {
  return <div style={{ padding: '3rem', textAlign: 'center', border: '1px dashed #ddd', borderRadius: '8px', color: '#888' }}>⏳ {label || '로딩 중'}...</div>
}

export default function App() {
  // 현재 선택된 탭 상태 ('memo', 'chart', 'report')
  const [tab, setTab] = useState('memo')

  // 탭 목록 데이터
  const tabs = [
    { id: 'memo', label: '⚡ memo 비교' },
    { id: 'chart', label: '📈 차트 (lazy)' },
    { id: 'report', label: '📑 리포트 (lazy)' },
  ]

  return (
    <div style={{ maxWidth: '640px', margin: '0 auto', padding: '1.5rem', fontFamily: 'sans-serif' }}>
      <h1>🧩 React.memo · lazy/Suspense 실습</h1>

      {/* 탭 버튼 메뉴 영역 */}
      <div style={{ display: 'flex', gap: '8px', marginBottom: '1.5rem' }}>
        {tabs.map((t) => (
          <button key={t.id} onClick={() => setTab(t.id)} style={{ padding: '8px 16px', borderRadius: '20px', border: '1px solid #ddd', cursor: 'pointer', background: tab === t.id ? '#1a1a18' : '#fff', color: tab === t.id ? '#fff' : '#333' }}>
            {t.label}
          </button>
        ))}
      </div>

      {/* 💡 [<Suspense>] 울타리
          - 내부에 있는 lazy 컴포넌트(HeavyChart, HeavyReport)가 네트워크로 로드되는 동안
          - fallback={...} 에 지정된 LoadingSpinner UI를 화면에 대신 띄워줍니다.
          - 로드가 끝나면 자동으로 본래 컴포넌트를 보여줍니다. */}
      <Suspense fallback={<LoadingSpinner label={"컴포넌트 로딩"} />}>
        {tab === 'memo' && <MemoComparison />}
        {tab === 'chart' && <HeavyChart />}
        {tab === 'report' && <HeavyReport />}
      </Suspense>
    </div>
  )
}
핵심 정리
  • React.memo(컴포넌트)는 props가 이전과 완전히 같으면 리렌더링을 스킵하지만, 부모가 넘기는 함수를 useCallback으로 참조 고정해 두지 않으면 매번 "함수가 바뀌었다"고 오판해 무력화된다.
  • React.lazy(() => import('./Foo'))로 감싼 컴포넌트는 처음 화면을 열 때 다운로드되지 않고, 실제로 화면에 나타나야 하는 순간에만 네트워크로 따로 받아온다(코드 분할).
  • <Suspense fallback=>은 그 다운로드가 끝날 때까지 대신 보여줄 로딩 UI를 지정하고, 끝나면 자동으로 실제 컴포넌트로 교체해 준다 — 로딩 state를 직접 관리할 필요가 없다.
TIP콘솔을 열어두고 "부모 리렌더 유발" 버튼을 눌러보면 [memo없음] 로그는 클릭할 때마다 3줄씩 찍히지만 [memo있음] 로그는 최초 1번만 찍히고 더 이상 안 찍힙니다. 네트워크 탭(브라우저 개발자 도구)을 열어두고 "📈 차트"를 처음 눌러보면, 그 순간에야 HeavyChart 관련 청크 파일이 새로 로드되는 것도 확인할 수 있습니다. memo/lazy 문법 자체가 헷갈리면 기초 개념 사전 — React.memo란?React.lazy + Suspense란?을 참고하세요.
07

Context API — createContext · Provider · useContext로 전역 상태 공유

Context APIcreateContextProvideruseContextprop drilling

한 줄 요약테마(다크모드)와 관심목록이라는 두 가지 전역 데이터를 각각 Context로 만들어 <ThemeProvider>·<WatchlistProvider>로 앱 전체를 감싸면, Header와 StockGrid는 props를 한 줄도 안 받고도 useTheme()·useWatchlist()만 호출해 같은 데이터를 바로 꺼내 쓰고 함께 갱신한다.

쉽게 말하면props로 값을 넘기는 건 "옆 사람에게 전달해 달라"고 부탁하며 손에서 손으로 전달하는 릴레이와 같아요 — 중간에 안 쓰는 컴포넌트도 어쩔 수 없이 계속 받아서 넘겨줘야 합니다(prop drilling). Context는 "방송국 전파"에 가까워요 — Provider가 방송을 송출하면, 그 방송 범위 안의 어떤 컴포넌트든(중간 컴포넌트를 거치지 않고) 라디오(useContext)를 켜기만 하면 바로 값을 받을 수 있습니다.

왜 필요한가 — prop drilling 문제

다크모드 값을 App → Header 처럼 딱 한 단계만 내려주면 되는 거라면 props로도 충분합니다. 문제는 컴포넌트 트리가 깊어질 때입니다 — App → Layout → Sidebar → Menu → MenuItem처럼 5단계를 거쳐야 값이 필요한 곳까지 도달한다면, 중간의 Layout·Sidebar·Menu는 그 값을 전혀 쓰지 않는데도 "다음 단계에 넘겨주기 위해서만" props로 받아야 합니다. 이렇게 필요 없는 중간 컴포넌트까지 줄줄이 props를 뚫고 내려보내는 현상을 prop drilling이라고 부릅니다. 이번 실습의 테마·관심목록처럼 "앱 여러 곳에서 동시에 필요한 값"이 이 문제의 전형적인 사례입니다.

createContext → Provider → useContext, 3단계를 요소별로

한 줄에 여러 개념이 섞여 있어 헷갈리기 쉬운 부분이라, 세 단계를 각각 분리해서 봅니다.

  • const ThemeContext = createContext(null) — "빈 상자"를 하나 만드는 단계. 이 시점의 ThemeContext는 아직 아무 값도 담고 있지 않고, 그저 "이 이름으로 값을 주고받겠다"는 통로(채널)만 만들어진 상태입니다.
  • <ThemeContext.Provider value={theme}> — 이 상자에 실제 값(theme 객체)을 "채워 넣고", 그 값을 자식 트리 전체에 "방송"하기 시작하는 단계. value에 넣은 값이 바로 하위 어디서든 꺼내 쓸 수 있는 데이터입니다.
  • useContext(ThemeContext) — Provider가 방송 중인 값을 "수신"하는 단계. Provider로 감싸인 트리 안이라면 몇 단계 아래에 있든 상관없이 이 한 줄로 바로 값을 꺼낼 수 있습니다 — 중간 컴포넌트는 이 값의 존재조차 몰라도 됩니다.

비유하면 ①은 "방송 채널 번호를 하나 개설"하는 것, ②는 "그 채널로 실제 방송을 내보내기 시작"하는 것, ③은 "그 채널 번호로 라디오를 튼 사람은 누구든 방송을 들을 수 있는 것"과 같아요 — 방송국(Provider)과 청취자(useContext) 사이에 다른 사람이 손으로 전달해 줄 필요가 없습니다.

useTheme() 커스텀 훅 — useContext를 한 번 더 감싼 이유

ThemeContext.jsxuseContext(ThemeContext)를 컴포넌트에서 직접 쓰게 하지 않고, useTheme()이라는 자체 훅으로 한 번 더 감쌌습니다. 이렇게 하는 이유가 두 가지 있습니다.

  • 임포트 간소화 — 이 훅을 쓰는 컴포넌트(Header, StockGrid)는 ThemeContextuseContext를 따로 import할 필요 없이 useTheme 하나만 가져오면 됩니다.
  • 실수 방지 — if (!ctx) throw new Error(...) — 만약 개발자가 실수로 <ThemeProvider>로 감싸지 않은 곳에서 useTheme()을 호출하면, createContext(null)의 기본값인 null이 그대로 반환됩니다. 이때 null.isDark처럼 접근하면 알아보기 힘든 에러가 나는데, useTheme 내부에서 미리 null인지 검사해 "useTheme은 ThemeProvider 안에서 사용하세요"라는 명확한 에러 메시지를 대신 던져줍니다.

Provider 중첩(nesting) — App.jsx에서 두 Context를 함께 감싸는 법

이번 실습은 Context가 테마 하나만이 아니라 관심목록까지 2개입니다. App.jsx는 이 둘을 <ThemeProvider><WatchlistProvider>...</WatchlistProvider></ThemeProvider>처럼 중첩해서 감쌉니다. 감싸는 순서 자체에는 (지금처럼 두 Context가 서로 의존하지 않는 한) 특별한 의미가 없고, 두 Provider의 자식 트리 안이라면 useTheme()·useWatchlist() 둘 다 어디서든 자유롭게 호출할 수 있습니다. Context가 3개, 4개로 늘어나면 이렇게 Provider를 계속 겹겹이 감싸게 되는데, 이것이 나중에 배울 Zustand 같은 전역 상태 라이브러리가 필요해지는 이유 중 하나입니다.

Header와 StockGrid가 "같은" watchlist를 공유하는 원리

StockGrid에서 별(★) 버튼을 눌러 addSymbol/removeSymbol을 호출하면, Header의 "관심종목 N개" 배지도 리렌더링 없이 자동으로 갱신됩니다. props로 전달받은 게 아닌데도 즉시 반영되는 이유는, 두 컴포넌트가 useWatchlist()로 꺼내는 watchlist가 부모·자식 관계와 무관하게 같은 WatchlistProvider 안의 같은 state를 가리키기 때문입니다 — setWatchlist가 호출되면 그 state를 구독 중인 모든 컴포넌트(Header, StockGrid)가 함께 리렌더링됩니다.

JSX src/context/ThemeContext.jsx
// ── STEP 1: 테마 Context ──
// 개념: createContext(상자) → Provider(값 담기) → useContext(꺼내기)
import { createContext, useState, useContext } from 'react'

// 1) 빈 Context 상자 생성 — createContext()로 상자를 만들고, 기본값은 null로 설정
const ThemeContext = createContext(null)

export function ThemeProvider({ children }) {
    const [isDark, setIsDark] = useState(false)

    // Provider가 자식 트리에 공급할 실제 값 — 색상값까지 미리 계산해 한 객체로 묶어둠
    const theme = {
        isDark,
        toggle: () => setIsDark((d) => !d),
        bg: isDark ? '#1a1a2e' : '#ffffff',
        cardBg: isDark ? '#0d2137' : '#f8f9fa',
        text: isDark ? '#ccd6f6' : '#1a1a18',
        muted: isDark ? '#8892b0' : '#6c757d',
        border: isDark ? '#0f3460' : '#dee2e6',
        primary: isDark ? '#61dafb' : '#0066cc',
    }

    // 2) theme 값을 하위 트리 전체에 공급 — ThemeContext.Provider로 children을 감싼다
    return (
        <ThemeContext.Provider value={theme}>
            <div style={{ background: theme.bg, color: theme.text, minHeight: '100vh' }}>
                {children}
            </div>
        </ThemeContext.Provider>
    )
}

// 3) 커스텀 훅으로 useContext를 한 번 더 감싸서, 임포트를 간소화하고 에러를 명확하게 알려줌
export function useTheme() {
    const ctx = useContext(ThemeContext)
    if (!ctx) throw new Error('useTheme은 ThemeProvider 안에서 사용하세요')
    return ctx
}
JSX src/context/WatchlistContext.jsx
// ── STEP 2: 관심목록 Context ──
// 개념: 여러 컴포넌트가 공유하는 상태를 Context에 두고 add/remove로 조작.
import { createContext, useState, useContext, useCallback } from 'react'

const WatchlistContext = createContext(null)

export function WatchlistProvider({ children }) {
    const [watchlist, setWatchlist] = useState(['AAPL', 'TSLA'])

    // 종목 추가 함수(중복이면 무시) — useCallback으로 감싸 불필요한 재생성을 방지
    const addSymbol = useCallback((symbol) => {
        setWatchlist((prev) => (prev.includes(symbol) ? prev : [...prev, symbol]))
    }, [])

    // 종목 제거 함수(filter로 제거) — 역시 useCallback으로 감쌈
    const removeSymbol = useCallback((symbol) => {
        setWatchlist((prev) => prev.filter((s) => s !== symbol))
    }, [])

    // 조회 함수(제공됨) — watchlist 배열에 해당 종목이 있는지 확인
    const isWatching = (symbol) => watchlist.includes(symbol)

    // { watchlist, addSymbol, removeSymbol, isWatching } 4가지를 한 객체로 Provider에 공급
    return <WatchlistContext.Provider value={{ watchlist, addSymbol, removeSymbol, isWatching }}>
        {children}</WatchlistContext.Provider>
}

export function useWatchlist() {
    const ctx = useContext(WatchlistContext)
    if (!ctx) throw new Error('useWatchlist은 WatchlistProvider 안에서 사용하세요')
    return ctx
}
JSX src/components/header.jsx
// ── STEP 3: 두 Context를 '소비'하는 헤더 ──
// props를 하나도 받지 않는데도, useTheme/useWatchlist로 전역 값에 바로 접근한다.
// (이것이 Context의 핵심 — prop drilling 없이 원하는 곳에서 바로 꺼내 쓰기)
import { useTheme } from '../context/ThemeContext'
import { useWatchlist } from '../context/WatchlistContext'

export default function Header() {
    // useTheme() -> ThemeContext의 value 객체를 반환 -> 그중 필요한 4개만 구조분해로 꺼냄
    const { isDark, toggle, text, border } = useTheme()
    const { watchlist } = useWatchlist()

    return (
        <header
            style={{
                padding: '12px 1.5rem', borderBottom: `1px solid ${border}`,
                display: 'flex', justifyContent: 'space-between', alignItems: 'center',
            }}
        >
            <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
                <span style={{ fontSize: '20px' }}>📈</span>
                <h1 style={{ margin: 0, fontSize: '18px', color: text }}>StockPractice</h1>
                {/* watchlist.length — StockGrid에서 별을 누르면 이 숫자가 실시간으로 바뀜 */}
                <span
                    style={{
                        fontSize: '12px', padding: '2px 8px', borderRadius: '12px',
                        background: isDark ? '#0f3460' : '#e3f2fd', color: isDark ? '#61dafb' : '#0066cc',
                    }}
                >
                    관심종목 {watchlist.length}개
                </span>
            </div>

            <button
                onClick={toggle}
                style={{
                    padding: '6px 14px', borderRadius: '20px', border: `1px solid ${border}`,
                    background: 'none', color: text, cursor: 'pointer', fontSize: '14px',
                }}
            >
                {isDark ? '☀️ 라이트' : '🌙 다크'}
            </button>
        </header>
    )
}
JSX src/components/StockGrid.jsx
// ── STEP 4: 두 Context를 소비하는 종목 그리드 ──
// 테마 색상(useTheme)과 관심목록 조작(useWatchlist)을 함께 사용한다.
// Header와 StockGrid가 '같은' watchlist를 공유하므로, 여기서 별을 누르면 Header의 개수도 즉시 바뀐다.
import { useTheme } from '../context/ThemeContext'
import { useWatchlist } from '../context/WatchlistContext'
import { STOCKS } from '../data/stocks'

export default function StockGrid() {
    const { cardBg, text, muted, border, primary } = useTheme()
    const { isWatching, addSymbol, removeSymbol } = useWatchlist()

    return (
        <div>
            <h2 style={{ margin: '1rem 0 0.75rem' }}>전체 종목</h2>
            <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '12px' }}>
                {STOCKS.map((stock) => {
                    const watching = isWatching(stock.symbol)
                    const isUp = stock.change >= 0
                    return (
                        <div
                            key={stock.id}
                            style={{
                                padding: '14px', borderRadius: '10px', background: cardBg,
                                border: `1px solid ${watching ? primary : border}`, transition: 'border-color 0.2s',
                            }}
                        >
                            <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
                                <strong style={{ color: text }}>{stock.symbol}</strong>
                                {/* 클릭 시: 이미 관심목록이면 제거, 아니면 추가 */}
                                <button
                                    onClick={() => (watching ? removeSymbol(stock.symbol) : addSymbol(stock.symbol))}
                                    style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '18px', color: watching ? primary : muted }}
                                >
                                    {watching ? '★' : '☆'}
                                </button>
                            </div>
                            <p style={{ margin: '0 0 8px', fontSize: '12px', color: muted }}>{stock.name}</p>
                            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
                                <span style={{ fontSize: '18px', fontWeight: 'bold', color: text }}>${stock.price.toFixed(2)}</span>
                                <span style={{ fontSize: '13px', fontWeight: 'bold', color: isUp ? '#2e7d32' : '#c62828' }}>
                                    {isUp ? '+' : ''}{stock.change}%
                                </span>
                            </div>
                        </div>
                    )
                })}
            </div>
        </div>
    )
}
JSX src/App.jsx
// ── STEP 5: Provider 중첩으로 두 Context를 앱 전체에 공급 ──
// Provider로 감싼 하위 트리(Header, StockGrid)는 어디서든 useTheme/useWatchlist를 쓸 수 있다.
// 두 Context가 필요하니 Provider를 '중첩'해서 감싼다.
import { ThemeProvider } from './context/ThemeContext'
import { WatchlistProvider } from './context/WatchlistContext'
import Header from './components/header'
import StockGrid from './components/StockGrid'

export default function App() {
  return (
    <ThemeProvider>
      <WatchlistProvider>
        <Header />
        <main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
          <StockGrid />
        </main>
      </WatchlistProvider>
    </ThemeProvider>
  )
}
핵심 정리
  • Context는 3단계로 동작한다 — createContext()(빈 상자) → <Context.Provider value={...}>(값 방송) → useContext(Context)(값 수신). 중간 컴포넌트를 거치지 않고 원하는 곳에서 바로 값을 꺼낼 수 있어 prop drilling을 없앤다.
  • useContext를 직접 쓰지 않고 useTheme()처럼 자체 훅으로 감싸면, import가 간단해지고 Provider 밖에서 잘못 호출했을 때 명확한 에러를 던질 수 있다.
  • Context가 여러 개면 Provider를 중첩해서 감싼다 — 감싼 순서 자체는 서로 의존하지 않는 한 중요하지 않다.
  • 같은 Provider 안에서 useWatchlist()를 호출하는 모든 컴포넌트는 같은 state를 공유하므로, 한쪽에서 setWatchlist가 일어나면 props 없이도 다른 컴포넌트가 함께 리렌더링된다.
TIPContext를 쓰다 화면이 이상하게 리렌더링이 잦다고 느껴지면, Provider의 value에 매 렌더링마다 새로 만들어지는 객체({...} 리터럴)를 그대로 넘기고 있지 않은지 의심해보세요 — value가 바뀐 것으로 취급돼 그 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다. 지금 규모(실습용 컴포넌트 소수)에서는 문제가 되지 않지만, 실제 서비스에서는 useMemo로 value 객체를 감싸 참조를 고정하는 최적화를 쓰기도 합니다. createContext·Provider·useContext 자체가 헷갈리면 기초 개념 사전 — Context API란?을 참고하세요.
08

Next.js App Router — 파일 기반 라우팅 · layout · 동적 라우트

Next.jsApp Router파일 기반 라우팅동적 라우트서버 컴포넌트

한 줄 요약지금까지 쓰던 Vite 프로젝트와 달리 Next.js는 app/ 폴더 구조 자체가 곧 URL 구조이고(app/about/page.js/about), layout.js로 헤더·푸터 같은 공통 뼈대를 잡고, [id] 폴더 하나로 /blog/1·/blog/2 같은 무한한 동적 경로를 처리하며, 존재하지 않는 글은 notFound()not-found.js 화면을 띄운다.

쉽게 말하면지금까지 만든 React(Vite) 앱은 페이지 이동이 필요하면 직접 라우팅 코드를 짜거나 라이브러리를 설치해야 했어요. Next.js는 "폴더 이름을 그대로 주소창 경로로 쓰겠다"는 규칙 하나로 이 작업을 대신해줍니다 — app 폴더 아래 about이라는 폴더를 만들고 그 안에 page.js를 넣으면, 그 즉시 /about 주소가 생깁니다. 폴더 구조를 짜는 것 자체가 사이트 지도를 그리는 것과 같아요.

파일 기반 라우팅 — 폴더 구조가 곧 URL이다

Vite/React Router에서는 라우팅 설정을 코드로 직접 작성했지만, Next.js App Router는 src/app/ 아래의 폴더 구조 자체가 URL이 됩니다. 별도 라우팅 설정 파일이 없습니다.

  • src/app/page.js → 루트 경로 "/"
  • src/app/about/page.js"/about" (about 폴더를 만드는 것만으로 자동 생성)
  • src/app/about/career/page.js"/about/career" (폴더를 중첩하면 경로도 그만큼 깊어짐)
  • src/app/blog/page.js"/blog"
  • src/app/blog/[id]/page.js"/blog/1", "/blog/2" 등 무한한 동적 경로 (아래에서 자세히 다룸)

비유하면 Vite 시절의 라우팅은 "이 주소로 오면 이 화면을 보여줘"라고 지도를 직접 그려서 등록하는 것이었다면, Next.js는 "폴더 이름 자체가 곧 지번(地番)"이라 폴더만 만들면 주소가 저절로 생기는 것과 같아요.

layout.js — 페이지마다 반복되는 공통 뼈대를 한 곳에

app/layout.js는 모든 페이지가 공유하는 "틀"입니다. 헤더·네비게이션·푸터처럼 페이지가 바뀌어도 그대로 유지되는 부분을 여기 한 번만 작성해두면 됩니다.

  • export default function RootLayout({ children })children 자리에 지금 방문 중인 페이지(page.js의 내용)가 자동으로 들어옵니다. 사용자가 /about에 있든 /blog에 있든 <html>·헤더·푸터는 그대로고, {children} 자리만 바뀝니다.
  • <Link href="/about"> — 일반 <a href="/about">와 눈으로는 비슷해 보이지만, 클릭했을 때 페이지 전체를 새로고침하지 않고 필요한 부분만 자바스크립트로 교체하는 클라이언트 사이드 네비게이션입니다. 화면이 깜빡이지 않고 더 빠릅니다.
  • export const metadata = { title: '나의 블로그' } — 이 파일 안에서 export한 metadata는 브라우저 탭 제목 등에 자동 반영됩니다(직접 <title> 태그를 조작할 필요 없음).

서버 컴포넌트 vs 클라이언트 컴포넌트 — Next.js가 기본을 뒤집은 지점

지금까지 배운 React(Vite)는 컴포넌트가 전부 브라우저에서 실행되는 "클라이언트 컴포넌트"였습니다. Next.js는 이 기본을 뒤집어, 모든 컴포넌트가 기본적으로 서버에서 실행됩니다(서버 컴포넌트). 두 세계는 할 수 있는 일이 서로 다릅니다.

  • 서버 컴포넌트(기본값)에서 할 수 없는 것useState·useEffect 같은 Hooks(브라우저 이벤트를 다뤄야 하므로), Context API 같은 전역 상태 공유. 서버에서 한 번 그려져 HTML로 전송되고 끝이라, "이후 사용자 조작에 반응해 다시 그리는" 개념 자체가 없기 때문입니다.
  • 클라이언트 컴포넌트에서 할 수 없는 것(또는 하지 않는 것) — 컴포넌트 함수 자체를 async/await로 만드는 것(브라우저에서 리액트가 그 결과를 어떻게 그릴지 모름). 비동기 처리가 필요하면 useEffect 등 함수 내부에서 처리합니다.
  • 클라이언트 컴포넌트로 전환하려면 — 파일 맨 위에 'use client'를 선언합니다. 이 선언이 있는 파일(과 그 안에서 만든 컴포넌트)만 브라우저에서 실행되는 예전 방식의 React 컴포넌트가 됩니다.

비유하면 서버 컴포넌트는 "주방에서 미리 완성해서 내보내는 요리"(다 만들어진 HTML만 손님상에 도착), 클라이언트 컴포넌트는 "손님 테이블에서 즉석으로 조리하는 요리"(브라우저에서 계속 상태가 바뀌며 다시 그려짐)라고 생각하면 됩니다. 기초 개념 사전 — 서버 vs 클라이언트 컴포넌트에서 더 정리했습니다.

동적 라우트 [id] — 폴더 이름의 대괄호가 하는 일

app/blog/[id]/page.js처럼 폴더 이름을 대괄호로 감싸면, id 자리에 어떤 값이 와도(/blog/1, /blog/99, /blog/hello...) 전부 이 파일 하나가 처리하는 동적 세그먼트가 됩니다.

  • export default async function BlogPost({ params }) — Next.js 15+ 부터 params는 즉시 값이 아니라 Promise로 전달됩니다. 그래서 컴포넌트 자체를 async 함수로 선언해야 합니다(서버 컴포넌트라 async 선언이 가능함을 위 개념과 연결해서 보면 이해가 쉽습니다).
  • const { id } = await params — Promise인 params를 await로 풀어야 실제 id 문자열을 꺼낼 수 있습니다.
  • posts.find((p) => p.id === Number(id)) — URL에서 온 id는 항상 문자열이므로, 숫자로 저장된 posts의 id와 비교하려면 Number(id)로 형변환이 필요합니다. 이걸 빠뜨리면 "1" === 1이 항상 false라 글을 못 찾습니다.

notFound() + not-found.js — 존재하지 않는 글을 처리하는 정석 패턴

id로 글을 찾았는데 없다면, 직접 "글이 없습니다" JSX를 리턴하는 대신 Next.js가 제공하는 전용 장치를 씁니다.

  • import { notFound } from 'next/navigation' — Next.js가 제공하는 함수를 가져옴.
  • if (!post) { notFound() } — 글을 못 찾으면 이 함수를 호출. 아래에 return문이 있더라도, notFound()가 호출되는 순간 렌더링이 즉시 중단되고 Next.js가 자동으로 not-found.js 화면으로 전환합니다.
  • app/blog/[id]/not-found.js — "이 동적 라우트 폴더 안에서 notFound()가 호출되면 보여줄 전용 화면"으로, 같은 폴더에 이 파일 이름으로 두기만 하면 Next.js가 자동으로 연결합니다. 직접 조건부 렌더링으로 에러 UI를 만들 필요가 없습니다.

비유하면 직접 "죄송합니다" 문구를 코드 중간에 끼워 넣는 대신, "손님이 없는 방을 찾으면 자동으로 이 안내 데스크로 보내라"는 규칙을 폴더 구조로 미리 정해두는 것과 같아요 — 어떤 동적 라우트에서 호출하든 정해진 안내 화면으로 깔끔하게 연결됩니다.

JSX src/app/layout.js
// ── STEP 1: 루트 레이아웃 (공통 뼈대) ──
// 개념: layout.js는 하위 페이지 공통 UI. {children} 자리에 각 page.js가 들어감. Link로 이동.
//
// 💡 [서버 컴포넌트 vs 클라이언트 컴포넌트]
// - Next.js는 컴포넌트 기본 작성 방식이 '서버 컴포넌트'다.
// - 클라이언트 컴포넌트를 쓰려면 파일 맨 위에 'use client'를 선언해야 한다.
// * 서버 컴포넌트에서 사용할 수 없는 기능
//   - 전역 상태 공유(Context API, Zustand, Redux 등)를 사용할 수 없음
//   - Hooks(useState 등)를 사용할 수 없음(사용자·브라우저 이벤트 처리가 필요한 작업)
// * 클라이언트 컴포넌트에서 사용할 수 없는 기능
//   - 컴포넌트 자체를 async/await 비동기로 선언하는 것 (리액트가 어떻게 그려야 할지 모름)
//   - 대신 useEffect 등 함수 내부에서 비동기 처리를 함
import './globals.css'
import Link from 'next/link'

export const metadata = { title: '나의 블로그' }

export default function RootLayout({ children }) {
  return (
    <html lang="ko">
      <body style={{ margin: 0, fontFamily: 'sans-serif' }}>
        <header style={{ borderBottom: '1px solid #eee', padding: '1rem 2rem', display: 'flex', alignItems: 'center', gap: '2rem' }}>
          <strong style={{ fontSize: '1.2rem' }}>📝 나의 블로그</strong>
          <nav style={{ display: 'flex', gap: '1.5rem' }}>
            {/* Link는 새로고침 없는 클라이언트 네비게이션(<a> 대체) */}
            <Link href="/">홈</Link>
            <Link href="/about">소개</Link>
            <Link href="/blog">블로그</Link>
          </nav>
        </header>

        <main style={{ maxWidth: '780px', margin: '0 auto', padding: '2rem' }}>
          {/* 각 페이지 내용이 이 자리에 렌더링됨 */}
          {children}
        </main>

        <footer style={{ borderTop: '1px solid #eee', padding: '1.5rem 2rem', textAlign: 'center', color: '#aaa', fontSize: '14px' }}>
          © 2025 나의 블로그
        </footer>
      </body>
    </html>
  )
}
JSX src/app/page.js — 홈 "/"
// ── STEP 2: 홈 페이지 → URL "/" ──
// app/page.js는 루트 경로("/")에 매핑된다. (폴더 구조 = URL 구조)
import Link from 'next/link'

export default function Home() {
  return (
    <div>
      <h1>안녕하세요! 👋</h1>
      <p style={{ color: '#666', lineHeight: 1.7 }}>
        Next.js App Router로 만든 블로그입니다.<br />
        다양한 개발 이야기를 공유합니다.
      </p>
      <Link
        href="/blog"
        style={{
          display: 'inline-block', marginTop: '1rem', padding: '10px 20px',
          background: '#1a1a18', color: '#fff', borderRadius: '8px', textDecoration: 'none',
        }}
      >
        블로그 보러가기 →
      </Link>
    </div>
  )
}
JSX src/app/about/page.js — 정적 라우트 "/about"
// ── STEP 3: 정적 라우트 → URL "/about" ──
// app/about/ 폴더를 만들고 그 안에 page.js를 두면 자동으로 "/about" 경로가 생긴다. (별도 설정 불필요)
export default function About() {
    return (
        <div>
            <h1>소개</h1>
            <p style={{ color: '#666', lineHeight: 1.8 }}>
                안녕하세요, 프론트엔드 개발을 공부하고 있는 홍길동입니다.<br />
                React와 Next.js를 배우며 성장하고 있습니다.
            </p>
            <ul style={{ color: '#555', lineHeight: 2 }}>
                <li>📍 서울</li>
                <li>💻 React, Next.js, JavaScript</li>
                <li>🎯 풀스택 개발자를 목표로 공부 중</li>
            </ul>
        </div>
    )
}
JSX src/app/blog/page.js — 블로그 목록 "/blog"
// ── STEP 4: 블로그 목록 → "/blog" ──
// 개념: posts 배열을 map으로 렌더링, 각 글을 동적 경로(/blog/1)로 Link 연결.
import Link from 'next/link'
import { posts, tagStyle } from './posts'

export default function BlogList() {
    return (
        <div>
            <h1>블로그 ({posts.length}개)</h1>
            <ul style={{ listStyle: 'none', padding: 0 }}>
                {posts.map((post) => ( // {}로 하면 return 필요, ()면 리턴 생략 가능
                    <li key={post.id} style={{ borderBottom: '1px solid #eee', padding: '1.25rem 0' }}>
                        <span style={tagStyle(post.tag)}>
                            {post.tag}
                        </span>
                        <h2 style={{ margin: '6px 0 4px', fontSize: '1.1rem' }}>
                            {/* 동적 세그먼트: /blog/1, /blog/2, /blog/3 */}
                            <Link href={`/blog/${post.id}`}
                                style={{ textDecoration: 'none', color: '#1a1a18' }}>
                                {post.title}
                            </Link>
                        </h2>

                        <p style={{ color: '#aaa', fontSize: '13px', margin: 0 }}>{post.date}</p>
                    </li>))}
            </ul>
        </div>
    )
}
JSX src/app/blog/[id]/page.js — 동적 라우트 "/blog/:id"
// ── STEP 5: 동적 라우트 → "/blog/[id]" ──
// 개념: [id] 동적 세그먼트. Next 15+ 에서 params는 Promise → async 컴포넌트 + await params.
import Link from 'next/link'
import { posts, tagStyle } from '../posts'
import { notFound } from 'next/navigation' // 1. notFound 임포트

// 이 컴포넌트를 async 함수로 선언 (params는 Promise라 await가 필요)
export default async function BlogPost({ params }) {
    // params를 await로 풀어 id를 꺼냄
    const { id } = await params

    // id로 해당 글을 찾음 (URL의 id는 문자열이므로 숫자로 변환해 비교)
    const post = posts.find((p) => p.id === Number(id))

    // 2. 글을 찾지 못했으면 not-found.js 화면으로 즉시 전환
    if (!post) {
        notFound()
    }

    return (
        <article>
            <Link href="/blog" style={{ color: '#888', textDecoration: 'none', fontSize: '14px' }}>← 목록으로</Link>
            <span style={{ ...tagStyle(post.tag), marginTop: '1rem', display: 'block', width: 'fit-content' }}>{post.tag}</span>
            <h1 style={{ margin: '0.75rem 0 0.25rem' }}>{post.title}</h1>
            <p style={{ color: '#aaa', fontSize: '13px', marginBottom: '2rem' }}>{post.date}</p>
            <p style={{ lineHeight: 1.9, color: '#444', whiteSpace: 'pre-line' }}>{post.content}</p>
        </article>
    )
}
JSX src/app/blog/[id]/not-found.js
// src/app/blog/[id]/not-found.js
// 개념: notFound()가 호출되면 이 화면이 자동으로 대신 렌더링된다.
import Link from 'next/link'

export default function NotFound() {
    return (
        <div style={{ textAlign: 'center', padding: '3rem 0' }}>
            <h2>❌ 존재하지 않는 블로그 글입니다!</h2>
            <p style={{ color: '#666' }}>주소가 잘못되었거나 삭제된 글입니다.</p>
            <Link href="/blog" style={{ color: '#0066cc' }}>
                ← 블로그 목록으로 돌아가기
            </Link>
        </div>
    )
}
핵심 정리
  • Next.js App Router는 src/app/ 아래 폴더 구조 자체가 URL이다 — 별도 라우팅 설정 없이 폴더·page.js만 만들면 경로가 생긴다.
  • layout.js{children} 자리에 현재 페이지가 렌더링되고, <Link>는 새로고침 없는 클라이언트 사이드 네비게이션을 제공한다.
  • 모든 컴포넌트는 기본이 서버 컴포넌트 — Hooks·Context는 못 쓰지만 컴포넌트를 async로 선언할 수 있다. 브라우저 이벤트·상태가 필요하면 'use client'를 선언한다.
  • [id] 폴더는 동적 라우트를 만들고, Next 15+에서 params는 Promise라 async 컴포넌트 + await params로 꺼내야 한다.
  • 존재하지 않는 데이터는 직접 에러 UI를 그리지 말고 notFound() + 같은 폴더의 not-found.js로 위임하는 것이 Next.js의 정석 패턴이다.
TIPconst { id } = await params에서 await를 빠뜨리면(Next 15+ 기준) params가 Promise 객체 그대로 구조분해돼 idundefined가 되고, 이후 Number(undefined)NaN이라 어떤 글도 못 찾아 항상 not-found로 빠집니다. 페이지가 자꾸 "글을 찾을 수 없다"고 나온다면 이 await부터 의심하세요. 서버/클라이언트 컴포넌트 구분이 헷갈리면 기초 개념 사전 — 서버 vs 클라이언트 컴포넌트를, 폴더 구조와 URL 매핑 자체가 헷갈리면 파일 기반 라우팅이란?을 참고하세요.
09

Zustand — Provider 없는 전역 상태 관리 (create · set/get · 셀렉터 패턴)

Zustandcreateset/get셀렉터 패턴전역 상태

한 줄 요약Zustand는 create((set, get) => ({ ... }))로 컴포넌트 트리 밖에 저장소를 하나 만들면, 어떤 컴포넌트든 Provider로 감쌀 필요 없이 그 저장소를 바로 import해서 useStockStore((s) => s.watchlist)처럼 필요한 조각(셀렉터)만 구독하고, set·get으로 상태를 읽고 바꾸는 액션 함수까지 저장소 안에 함께 정의하는 전역 상태 관리 라이브러리다.

쉽게 말하면07단계에서 배운 Context API는 "이 건물(Provider) 안에서만 들리는 사내 방송"이라, 반드시 <Provider>로 트리를 감싸야 했죠. Zustand는 그 건물 자체가 없어요 — 회사 클라우드에 있는 공유 문서 같아서, 어느 컴포넌트든 파일을 열듯 import useStockStore 한 줄이면 바로 접근할 수 있습니다. 그리고 문서 전체를 다 읽는 게 아니라 "나는 이 시트(상태 조각)만 볼래" 하고 원하는 부분만 구독하면, 다른 시트가 바뀌어도 나는 리렌더링될 필요가 없어서 더 가볍습니다.

create((set, get) => ({ ... })) — 저장소를 만드는 문법 하나하나

이 한 줄에 낯선 이름 셋이 섞여 있어서 헷갈리기 쉽습니다. 역할별로 쪼개 보면 다음과 같습니다.

  • create — zustand 라이브러리가 주는 "저장소 생성 함수". 인자로 넘긴 함수가 리턴하는 객체 모양 그대로가 저장소의 상태+액션이 됩니다.
  • set — 상태를 갱신하는 함수. set({ watchlist: [...] })처럼 객체를 넘기면 그 키만 기존 상태에 병합되고, 나머지 상태는 그대로 남습니다.
  • get — 지금 이 순간의 최신 상태 전체를 읽는 함수. addToWatchlist 내부에서 get()으로 방금 전의 옛날 값이 아니라 항상 최신 watchlist를 확인합니다.

비유하면 create는 "저장소 건물을 짓는 공사", set은 "서류를 새로 갈아 끼우는 손", get은 "지금 캐비닛에 뭐가 들었는지 확인하는 눈"이라고 생각하면 역할이 분명해집니다.

셀렉터 패턴 — 저장소 전체가 아니라 "이 조각만" 구독하기

const watchlist = useStockStore((s) => s.watchlist)처럼 화살표 함수로 콕 집어 요청하는 것을 셀렉터(selector)라고 부릅니다.

  • s — 저장소 전체 상태({ watchlist, prices, selectedSymbol, ... } 전부).
  • s => s.watchlist — 그 전체 중 watchlist 조각 하나만 골라 리턴.
  • 이 조각이 바뀔 때만 그 컴포넌트가 리렌더링됩니다 — prices만 바뀌어도 watchlist만 구독한 컴포넌트는 전혀 영향받지 않습니다.

04단계의 useMemo·React.memo가 "이미 계산한 값을 아껴 쓰는" 최적화였다면, 셀렉터는 애초에 "관심 없는 변화엔 반응 자체를 안 하는" 최적화라 결이 다릅니다.

액션 함수를 컴포넌트가 아니라 저장소 안에 두는 이유

addToWatchlist·removeFromWatchlist 같은 함수는 컴포넌트 안이 아니라 저장소 자체에 정의돼 있습니다. AddStockForm.jsx는 이 함수를 가져다 호출만 할 뿐, "중복이면 추가하지 않는다" 같은 로직은 전혀 모릅니다.

addToWatchlist 내부에서 get()으로 현재 watchlist를 확인해 includes(symbol)이면 조기 리턴하는 것도 저장소 쪽 책임입니다 — 여러 컴포넌트가 같은 저장소를 쓰더라도 "중복 방지 규칙"은 한 곳에만 있으면 되므로, 컴포넌트가 몇 개로 늘어나도 로직이 흩어지지 않습니다.

setPrice의 [symbol]: price — 계산된 속성 이름

prices: { ...state.prices, [symbol]: price }에서 [symbol]처럼 객체 키 자리에 대괄호를 쓰면, symbol 변수에 들어있는 실제 값(예: 'NVDA')이 키 이름이 됩니다 — 대괄호가 없으면 symbol이라는 글자 그대로가 키가 되어버립니다.

...state.prices(스프레드)로 기존 가격들을 먼저 복사해오지 않으면, setprices 전체를 새 객체로 통째로 갈아 끼우면서 다른 종목 가격이 전부 사라집니다 — 기초 개념 사전 — 스프레드·rest에서 다룬 "복사 후 덧붙이기" 패턴이 여기서도 그대로 쓰입니다.

JS src/app/store/useStockStore.js
// ── STEP 1: Zustand 스토어 (전역 중앙 저장소) ──
// [핵심 개념]
// 1. create((set, get) => ({ ... })) : 중앙 저장소를 생성합니다.
// 2. set : 상태(state)를 새롭게 업데이트(변경)하는 함수입니다.
// 3. get : 현재 저장되어 있는 모든 최신 상태/함수 객체를 읽어오는 함수입니다.

import { create } from 'zustand'

const useStockStore = create((set, get) => ({
    // ──────────────────────────────────────────
    // 📌 [1] 전역 상태 (공유 데이터)
    // ──────────────────────────────────────────
    watchlist: ['AAPL', 'TSLA', 'MSFT'], // 관심종목 배열
    selectedSymbol: 'AAPL',               // 현재 선택된 종목 코드
    prices: { AAPL: 182.52, TSLA: 248.5, MSFT: 378.85 }, // 종목별 가격 객체

    // ──────────────────────────────────────────
    // 📌 [2] 액션 함수 (데이터 조작 리모컨)
    // ──────────────────────────────────────────

    // 1) 관심종목 추가
    addToWatchlist: (symbol) => {
        // get() : 현재 시점의 최신 상태 객체를 가져옵니다. ({ watchlist, prices, ... })
        const { watchlist } = get()

        // 이미 목록에 존재하는 종목이면 추가하지 않고 중단
        if (watchlist.includes(symbol)) return

        // set() : 기존 배열에 새 종목을 추가하여 watchlist 상태를 업데이트
        set({ watchlist: [...watchlist, symbol] })
    },

    // 2) 관심종목 제거
    removeFromWatchlist: (symbol) => {
        // set((state) => ... ) : state 매개변수에는 저장소의 전체 상태가 들어옵니다.
        // .filter((s) => s !== symbol) : 클릭한 symbol과 다른 종목들만 골라내어 새 배열 생성
        set((state) => ({
            watchlist: state.watchlist.filter((s) => s !== symbol)
        }))
    },

    // 3) 선택 종목 변경
    selectSymbol: (symbol) => set({ selectedSymbol: symbol }),

    // 4) 특정 종목 가격 갱신 (고급 문법 포함)
    setPrice: (symbol, price) => set((state) => ({
        prices: {
            ...state.prices,   // 기존 가격 정보들을 지우지 않고 복사해옴
            [symbol]: price    // [symbol] 대괄호: 변수에 들어있는 값(예: 'NVDA')을 객체의 키(Key)로 사용함
        }
    })),
}))

export default useStockStore
JSX src/app/components/AddStockForm.jsx
// ── STEP 2: 관심종목 추가 폼 컴포넌트 ──
// [핵심 개념]
// Next.js App Router에서는 브라우저 이벤트(클릭, 입력) 및 useState를 사용하는 컴포넌트에
// 최상단 'use client' 선언이 필수입니다.

'use client'
import { useState } from 'react'
import useStockStore from '@/app/store/useStockStore'

export default function AddStockForm() {
    // 1) 사용자 입력값을 관리하는 로컬 state (이 컴포넌트 내부에서만 사용)
    const [input, setInput] = useState('')

    // 2) [핵심 Zustand 사용법]
    // s => s.addToWatchlist : 스토어 전체 중 필요한 'addToWatchlist' 함수만 쏙 짚어서 구독(가져옴)
    const addToWatchlist = useStockStore((s) => s.addToWatchlist)

    // 3) 추가 버튼 클릭 또는 Enter 키 입력 시 실행되는 이벤트 핸들러
    const handleAdd = () => {
        // 공백만 입력했으면 아무것도 하지 않고 리턴
        if (!input.trim()) return

        // Zustand 스토어의 addToWatchlist 함수 호출 (입력값을 대문자로 변환해 전달)
        addToWatchlist(input.toUpperCase())

        // 입력창 비우기
        setInput('')
    }

    return (
        <div style={{ display: 'flex', gap: '8px', padding: '1rem' }}>
            <input
                value={input}
                onChange={(e) => setInput(e.target.value)}
                onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
                placeholder="종목 코드 입력 (예: NVDA)"
                style={{
                    flex: 1, padding: '8px 12px', borderRadius: '6px',
                    border: '1px solid #0f3460', background: '#0d2137', color: '#ccd6f6'
                }}
            />
            <button
                onClick={handleAdd}
                style={{
                    padding: '8px 16px', borderRadius: '6px',
                    background: '#61dafb', color: '#0a192f', border: 'none',
                    cursor: 'pointer', fontWeight: 'bold'
                }}
            >
                추가
            </button>
        </div>
    )
}
JSX src/app/components/WatchlistPanel.jsx
// ── STEP 3: 관심종목 목록 컴포넌트 ──
// [핵심 개념: 조각별 구독(Selector Pattern)]
// useStockStore((s) => s.상태이름) 형태로 내가 사용할 조각(상태/함수)만 각각 가져옵니다.
// 이렇게 하면 가져오지 않은 다른 상태가 바뀌어도 이 컴포넌트는 재렌더링되지 않아 성능이 최적화됩니다.

'use client'
import useStockStore from '@/app/store/useStockStore'

export default function WatchlistPanel() {
    // 1) Zustand 중앙 저장소에서 필요한 상태와 액션들을 하나씩 가져오기 (셀렉터 사용)
    const watchlist = useStockStore((s) => s.watchlist)             // 관심종목 배열
    const selectedSymbol = useStockStore((s) => s.selectedSymbol)   // 선택된 종목 코드
    const prices = useStockStore((s) => s.prices)                   // 가격 객체
    const selectSymbol = useStockStore((s) => s.selectSymbol)       // 선택 변경 함수
    const removeFromWatchlist = useStockStore((s) => s.removeFromWatchlist) // 삭제 함수

    return (
        <div style={{ padding: '1rem' }}>
            <h2>관심종목</h2>
            <ul style={{ listStyle: 'none', padding: 0 }}>
                {watchlist.map((symbol) => (
                    <li
                        key={symbol}
                        // 카드(li)를 클릭하면 Zustand 스토어의 selectedSymbol 상태를 변경
                        onClick={() => selectSymbol(symbol)}
                        style={{
                            display: 'flex', justifyContent: 'space-between', alignItems: 'center',
                            padding: '12px', marginBottom: '8px', borderRadius: '8px',
                            border: `1px solid ${selectedSymbol === symbol ? '#61dafb' : '#0f3460'}`,
                            cursor: 'pointer', backgroundColor: selectedSymbol === symbol ? '#123a2a' : '#0d2137',
                        }}
                    >
                        <div>
                            <strong style={{ color: '#ccd6f6' }}>{symbol}</strong>
                            <div style={{ fontSize: '12px', color: '#8892b0' }}>
                                {/* prices 객체에 해당 symbol 가격이 존재하면 표시, 없으면 '가격 없음' */}
                                {prices[symbol] ? `$${prices[symbol].toFixed(2)}` : '가격 없음'}
                            </div>
                        </div>

                        {/* ✕ 삭제 버튼 */}
                        <button
                            onClick={(e) => {
                                // e.stopPropagation(): 부모 li 태그의 onClick(종목선택) 이벤트로 퍼지는 것을 방지함
                                e.stopPropagation()
                                // Zustand 스토어의 removeFromWatchlist 함수 실행하여 종목 삭제
                                removeFromWatchlist(symbol)
                            }}
                            style={{ background: 'none', border: 'none', color: '#e94560', cursor: 'pointer', fontSize: '16px' }}
                        >
                            ✕
                        </button>
                    </li>
                ))}
            </ul>
        </div>
    )
}
핵심 정리
  • Zustand는 create((set, get) => ({...}))로 만든 저장소를 Provider 없이 어디서든 import해서 쓴다 — Context API와 달리 감싸는 트리 구조가 필요 없다.
  • 셀렉터(useStockStore(s => s.watchlist))로 필요한 조각만 구독하면, 구독하지 않은 다른 상태가 바뀌어도 리렌더링되지 않는다.
  • 액션 함수(addToWatchlist 등)는 컴포넌트가 아니라 저장소 안에 정의해, 로직이 여러 컴포넌트에 흩어지지 않게 한다.
  • set({ ... })은 넘긴 키만 병합하고, [symbol]: price처럼 대괄호로 감싸면 변수 값이 객체 키가 된다.
TIPWatchlistPanel의 삭제 버튼에 e.stopPropagation()이 없으면, 버튼 클릭이 부모 <li>onClick(selectSymbol)까지 같이 실행돼 "삭제하려고 눌렀는데 그 종목이 선택까지 되는" 이상한 동작이 생깁니다. Context API와 비교하며 보고 싶다면 기초 개념 사전 — Zustand란?을 참고하세요.
10

Next.js API Routes — route.js로 같은 프로젝트 안에 백엔드 두기 (GET · 동적 세그먼트 · fetch 연동)

API Routesroute.jsfetch동적 세그먼트Response.json

한 줄 요약Next.js는 app/api/.../route.js 파일에 GET처럼 HTTP 메서드 이름의 함수를 export하면 그 경로가 그대로 백엔드 API 엔드포인트가 되고, 프론트엔드 컴포넌트는 그 경로를 평범한 fetch('/api/...')로 호출해 데이터를 받아온다 — 별도 백엔드 서버 없이 한 프로젝트 안에서 프론트와 백엔드를 함께 만들 수 있다.

쉽게 말하면08단계까지는 page.js로 "화면"만 만들었다면, route.js는 화면이 아니라 "요청을 받고 답장을 써 주는 창구" 파일이에요. app/api/search/route.js처럼 api 폴더 밑에 두면, /api/search 주소로 요청이 왔을 때 이 파일 안의 GET 함수가 실행돼 답장을 보냅니다. fetch가 창구에 편지를 넣는 손님이라면, route.js는 그 편지를 뜯어 읽고 답장을 써서 돌려주는 창구 직원인 셈입니다.

route.js — page.js와 같은 규칙, 다른 역할

app/ 아래 폴더 구조가 곧 경로라는 08단계의 파일 기반 라우팅 규칙은 API에도 그대로 적용됩니다. 다른 점은 리턴하는 것입니다.

  • page.js — JSX(화면)를 리턴 → 브라우저에 보이는 페이지가 됨.
  • route.jsGET·POST 같은 HTTP 메서드 이름의 함수를 export하고, 그 함수가 Response 객체를 리턴 → 화면이 아니라 데이터를 응답하는 API 엔드포인트가 됨.
  • app/api/search/route.jsGET /api/search, app/api/stock/[symbol]/route.jsGET /api/stock/AAPL 처럼, 08단계에서 배운 [id] 동적 세그먼트 규칙도 API 경로에 동일하게 적용됩니다.

GET(request) — 요청에서 정보를 안전하게 꺼내는 3단계

/api/search?q=apple처럼 ? 뒤에 붙는 쿼리스트링을 읽어오는 코드 한 줄에 방어적 문법이 여러 개 겹쳐 있습니다. 하나씩 쪼개면:

  • new URL(request.url) — 요청 전체 주소에서 searchParams(쿼리파라미터 사물함)를 꺼낼 수 있는 형태로 바꿈.
  • searchParams.get('q')q= 뒤의 실제 값을 읽음. q가 아예 없으면 null.
  • ?.toLowerCase() — 옵셔널 체이닝. qnull이면 .toLowerCase()를 아예 호출하지 않고 그대로 undefined가 되어, 서버가 에러로 멈추는 것을 막음.
  • || '' — 그 결과가 여전히 없는 값(undefined/빈 문자열)이면 안전한 빈 문자열로 최종 확정.

이 네 조각이 합쳐진 searchParams.get('q')?.toLowerCase() || ''는 "값이 있으면 소문자로, 아예 없어도 절대 에러 내지 않고 빈 문자열로" 라는 뜻이 됩니다 — 기초 개념 사전 — 옵셔널 체이닝·??에서 배운 문법이 실전 서버 코드에서 이렇게 조합됩니다.

동적 세그먼트 + await params — 08단계 규칙이 API에도 그대로

app/api/stock/[symbol]/route.jsGET(request, { params })는 08단계 [id]/page.js에서 배운 것과 완전히 같은 패턴입니다.

  • { params } — 두 번째 인자로 동적 세그먼트 값이 담긴 객체가 들어옴.
  • const { symbol } = await params — Next.js 15+에서 params는 Promise라, 08단계와 마찬가지로 await로 풀어야 실제 symbol 문자열을 꺼낼 수 있음.

stock/[symbol]/route.jsFINNHUB_API_KEY 환경변수가 없으면 랜덤 더미 시세를, 있으면 실제 Finnhub API를 호출해 진짜 시세를 반환합니다 — 외부 API 키가 없는 환경에서도 앱이 멈추지 않고 개발을 계속할 수 있게 하는 방어적 설계입니다.

프론트엔드에서 내가 만든 API를 fetch로 호출하기

StockSearch.jsxhandleChange는 타자를 칠 때마다 방금 만든 /api/search를 직접 호출합니다.

  • fetch(\`/api/search?q=${encodeURIComponent(q)}\`) — 같은 프로젝트 안의 API Route라 https://... 전체 주소 없이 상대 경로만으로 호출 가능. encodeURIComponent는 한글·공백이 섞여도 URL이 깨지지 않게 변환.
  • await res.json() — 서버가 돌려준 JSON 텍스트를 자바스크립트 객체/배열로 변환.
  • setResults(data.results || []) — 결과 배열을 state에 저장해 드롭다운으로 렌더링.

비유하면 06단계까지는 우리 코드가 클라이언트 역할만 했다면, 이제는 같은 프로젝트 안에서 클라이언트(StockSearch.jsx)와 서버(route.js) 역할을 모두 우리가 직접 만들어 서로 통신시키는 셈입니다.

JS src/app/api/search/route.js
// ── STEP 1: 백엔드 검색 API Route (GET /api/search?q=...) ──
// [역할 설명]
// Next.js App Router에서 app/api/search/route.js 파일은
// 브라우저 화면(UI)을 보여주는 곳이 아니라, 데이터를 주고받는 "백엔드 API 서버" 역할을 합니다.

const STOCKS = [
    { symbol: 'AAPL', name: 'Apple Inc.', exchange: 'NASDAQ' },
    { symbol: 'TSLA', name: 'Tesla Inc.', exchange: 'NASDAQ' },
    { symbol: 'MSFT', name: 'Microsoft Corp.', exchange: 'NASDAQ' },
    { symbol: 'GOOGL', name: 'Alphabet Inc.', exchange: 'NASDAQ' },
    { symbol: 'AMZN', name: 'Amazon.com Inc.', exchange: 'NASDAQ' },
    { symbol: 'NVDA', name: 'NVIDIA Corp.', exchange: 'NASDAQ' },
    { symbol: 'META', name: 'Meta Platforms Inc.', exchange: 'NASDAQ' },
    { symbol: 'JPM', name: 'JPMorgan Chase & Co.', exchange: 'NYSE' },
    { symbol: 'V', name: 'Visa Inc.', exchange: 'NYSE' },
    { symbol: 'JNJ', name: 'Johnson & Johnson', exchange: 'NYSE' },
]

// 클라이언트(프론트엔드)에서 GET 방식으로 /api/search?q=... 요청을 보내면 자동 실행됩니다.
export async function GET(request) {
    // 📌 1) URL에서 쿼리파라미터(?q=...) 읽어오기
    // new URL(request.url)은 전달받은 전체 주소에서 searchParams(쿼리파라미터 사물함)를 추출합니다.
    const { searchParams } = new URL(request.url)

    // 📌 2) ?q= 뒤의 실제 값 안전하게 꺼내기
    // - searchParams.get('q'): 'q=' 뒤의 값을 읽음
    // - ?. (옵셔널체이닝): q가 없을 때 null 에러로 서버가 터지지 않게 방지
    // - .toLowerCase(): 대소문자 구분 없이 검색하기 위해 소문자로 통일
    // - || '': 값이 없으면 빈 문자열('')로 안전하게 처리
    const q = searchParams.get('q')?.toLowerCase() || ''

    // 📌 3) 조기 리턴 (Early Return)
    // 검색어 q가 비어있다면, 아래의 필터링 로직을 수행하지 않고 즉시 빈 결과를 응답하고 종료합니다.
    if (!q) return Response.json({ results: [] })

    // 📌 4) 배열 데이터 필터링
    // symbol(종목코드) 또는 name(회사명)에 검색어 q가 포함된 항목을 최대 5개까지 걸러냅니다.
    const results = STOCKS.filter(
        (s) => s.symbol.toLowerCase().includes(q) || s.name.toLowerCase().includes(q)
    ).slice(0, 5)

    // 📌 5) JSON 형태로 프론트엔드에 응답
    // Response.json()은 자바스크립트 객체를 브라우저가 이해할 수 있는 JSON 전송 포맷으로 응답합니다.
    return Response.json({ results })
}
JS src/app/api/stock/[symbol]/route.js
// ── STEP 2: 동적 API Route (GET /api/stock/[symbol]) ──
// [역할 설명]
// [symbol] 처럼 폴더명에 대괄호가 들어간 것을 "동적 라우트(Dynamic Route)"라고 합니다.
// 주소가 /api/stock/AAPL, /api/stock/NVDA 처럼 가변적으로 들어올 때 처리합니다.

export async function GET(request, { params }) {
    // 📌 1) 동적 경로 파라미터 symbol 꺼내기
    // Next.js 15 버전부터는 params가 비동기(Promise) 객체이므로 await params로 꺼내야 합니다.
    const { symbol } = await params

    // 📌 2) 환경변수(.env.local)에 등록된 보안 API 키 읽기
    const apiKey = process.env.FINNHUB_API_KEY

    // 📌 3) API 키가 없을 때 (더미 시세 데이터 반환)
    // Finnhub API 키가 등록되어 있지 않은 상태에서도 앱이 동작하도록 시뮬레이션 데이터를 제공합니다.
    if (!apiKey) {
        const price = parseFloat((150 + Math.random() * 50).toFixed(2))
        const change = parseFloat((Math.random() * 6 - 3).toFixed(2))
        return Response.json({
            symbol: symbol.toUpperCase(),
            price,
            change,
            timestamp: Date.now(),
            source: 'dummy' // 출처: 더미 데이터
        })
    }

    // 📌 4) API 키가 있을 때 (실제 Finnhub 증권 API 서버와 통신)
    try {
        const res = await fetch(
            `https://finnhub.io/api/v1/quote?symbol=${symbol}&token=${apiKey}`,
            { cache: 'no-store' } // 최신 시세를 얻기 위해 캐시를 사용하지 않음
        )
        if (!res.ok) throw new Error(`API 호출 실패 (status ${res.status})`)
        const data = await res.json()

        return Response.json({
            symbol: symbol.toUpperCase(),
            price: data.c,        // c: Current price (현재가)
            change: data.d,       // d: Change (변동금액)
            changePercent: data.dp, // dp: Change percent (변동률)
            timestamp: Date.now(),
            source: 'finnhub'     // 출처: Finnhub API
        })
    } catch (err) {
        return Response.json({ error: err.message }, { status: 500 })
    }
}
JSX src/app/components/StockSearch.jsx
// ── [StockSearch.jsx] 검색창 컴포넌트 ──
// 타자를 칠 때마다 /api/search에 요청 → 결과를 드롭다운으로 표시 → 클릭하면 관심종목에 추가

'use client'
import { useState, useRef } from 'react'
import useStockStore from '@/app/store/useStockStore'

export default function StockSearch() {
  const [query, setQuery] = useState('')       // 입력창 글자
  const [results, setResults] = useState([])   // 서버가 찾아준 결과 배열
  const inputRef = useRef(null)                // 검색 후 다시 포커스하기 위한 참조
  const addToWatchlist = useStockStore((s) => s.addToWatchlist)

  // 타자 칠 때마다 우리가 만든 백엔드 API를 호출
  const handleChange = async (e) => {
    const q = e.target.value
    setQuery(q)

    if (!q.trim()) {
      setResults([])
      return
    }

    // 상대 경로 하나로 "같은 프로젝트 안의 백엔드" 호출
    const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
    const data = await res.json()
    setResults(data.results || [])
  }

  // 드롭다운에서 하나를 클릭했을 때
  const handleSelect = (symbol) => {
    addToWatchlist(symbol)
    setQuery('')
    setResults([])
    inputRef.current?.focus() // 다음 검색을 바로 이어갈 수 있도록 커서 복원
  }

  return (
    <div style={{ position: 'relative', padding: '1rem' }}>
      <input
        ref={inputRef}
        value={query}
        onChange={handleChange}
        placeholder="종목 검색 (예: Apple, AAPL)"
        style={{
          width: '100%', padding: '10px 14px', borderRadius: '8px',
          border: '1px solid #0f3460', background: '#0a192f', color: '#ccd6f6', fontSize: '14px'
        }}
      />

      {results.length > 0 && (
        <ul style={{
          position: 'absolute', top: '100%', left: '1rem', right: '1rem',
          background: '#0d2137', border: '1px solid #0f3460', borderRadius: '8px',
          listStyle: 'none', padding: '4px', margin: 0, zIndex: 100
        }}>
          {results.map((r) => (
            <li
              key={r.symbol}
              onClick={() => handleSelect(r.symbol)}
              style={{ padding: '10px 12px', cursor: 'pointer', borderRadius: '6px', display: 'flex', justifyContent: 'space-between' }}
            >
              <span>
                <strong style={{ color: '#61dafb' }}>{r.symbol}</strong>
                <span style={{ color: '#8892b0', marginLeft: '8px', fontSize: '13px' }}>{r.name}</span>
              </span>
              <span style={{ fontSize: '11px', padding: '2px 6px', borderRadius: '4px', background: '#0a192f', color: '#8892b0' }}>
                {r.exchange}
              </span>
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}
핵심 정리
  • app/api/.../route.js는 화면이 아니라 GET 같은 HTTP 메서드 함수를 export해 데이터를 응답하는 백엔드 엔드포인트다.
  • new URL(request.url).searchParams로 쿼리스트링을, await params로 동적 세그먼트 값을 꺼낸다(08단계 [id]와 같은 규칙).
  • 프론트엔드는 절대경로 없이 fetch('/api/...')로 같은 프로젝트 안의 API를 바로 호출할 수 있다.
  • 외부 API 키가 없어도 더미 데이터로 동작하게 만드는 방어적 분기는, 실전에서 API 키 없이도 개발을 이어갈 수 있게 해주는 흔한 패턴이다.
TIP/api/search?q=apple을 호출했는데 결과가 항상 {"results":[]}로 빈 배열만 온다면, route.jsq 추출 로직이나 STOCKS.filter 조건부터 의심하세요 — 서버 콘솔(터미널)에는 브라우저 콘솔과 다른 에러가 찍힐 수 있습니다. API Routes 개념이 헷갈리면 기초 개념 사전 — API Routes란?을, Zustand와 헷갈리면 Zustand란?을 참고하세요.
11

Zustand persist + async 액션 — 새로고침에도 살아남는 저장소, 매수/매도 포트폴리오

persistlocalStorageasync 액션Promise.all포트폴리오

한 줄 요약create(persist((set, get) => ({ ... }), { name, partialize }))처럼 persist로 스토어를 한 겹 더 감싸면 지정한 상태 조각(watchlist·portfolio 등)이 자동으로 localStorage에 저장/복원되고, 스토어 안 액션 함수를 async로 선언해 fetch·Promise.all로 실제 API 통신까지 처리하며, 매수(buyStock)·매도(sellStock)로 평단가를 재계산하는 포트폴리오 로직까지 스토어 안에 함께 들어간다.

쉽게 말하면09단계의 저장소는 브라우저를 새로고침(F5)하면 다시 초기값으로 리셋되는 "화이트보드"였어요. persist는 그 화이트보드 내용을 자동으로 사진 찍어 서랍(localStorage)에 저장했다가, 다시 켤 때 그대로 복원해주는 "자동 저장 기능"입니다(게임의 세이브 파일과 같아요). 그리고 이번엔 저장소 안의 함수(액션)들이 직접 인터넷에 나가 시세를 물어보고(fetchPrice) 답을 기다렸다가(async/await) 돌아오는 일까지 하게 됩니다 — 09단계에서 컴포넌트가 하던 API 호출 역할 일부가 이제 저장소 쪽으로 옮겨온 셈이죠.

create(persist(fn, options)) — 스토어를 감싸는 두 겹 포장

09단계는 create((set, get) => ({...})) 한 겹이었다면, 이번엔 그 안에 persist가 한 겹 더 들어가 create(persist(fn, options)) 구조가 됩니다.

  • fn(set, get) => ({...}), 09단계와 똑같은 "상태+액션 정의" 함수. 저장소의 실제 내용물은 그대로.
  • persist(fn, options)fn이 만드는 저장소를 통째로 감싸서 "자동 저장/복원 기능"을 덧붙이는 zustand의 미들웨어 함수.
  • options{ name, partialize }처럼 persist의 동작 방식을 정하는 설정 객체.

비유하면 fn은 "화이트보드에 뭘 적을지 정하는 설계도"이고, persist(fn, options)는 그 화이트보드 전체를 "자동 사진 찍어 저장하는 액자"로 감싸는 것과 같아요 — 안에 적히는 내용(상태·액션)은 그대로인데, 겉에 저장 기능만 덧씌워집니다.

name과 partialize — 어디에, 무엇을 저장할지

persist의 옵션 두 개는 각각 다른 질문에 답합니다 — "어디에 저장?"과 "뭘 저장?".

  • name: 'stock-dashboard' — localStorage 안에서 이 저장소를 구분할 열쇠(key) 이름. 브라우저 개발자도구 → Application → Local Storage에서 이 이름으로 실제 저장된 값을 확인할 수 있습니다.
  • partialize: (state) => ({ watchlist, selectedSymbol, portfolio }) — 전체 상태 중 이 함수가 리턴하는 조각만 localStorage에 저장합니다.
  • 일부러 빼는 것도 있습니다 — prices·loading·error는 저장하지 않습니다. 시세는 오래되면 의미가 없어 새로고침할 때마다 새로 받아오는 게 맞고, 로딩/에러 상태를 그대로 저장하면 다음에 열었을 때 "로딩 중..."이 얼어붙은 채로 나타나는 이상한 화면이 될 수 있기 때문입니다.

비유하면 partialize는 "이사 갈 때 뭘 챙기고 뭘 버릴지 고르는 것"과 같아요 — 관심종목·포트폴리오(내가 직접 고른 것들)는 챙기고, 방금 확인한 시세나 "로딩 중" 팻말처럼 당장의 상황에만 유효한 것들은 새 집에서 다시 확인하면 되니 굳이 안 챙깁니다.

fetchPrice·fetchAllPrices — 저장소 액션이 async가 되는 순간

09단계 액션은 set({...}) 한 줄이면 끝나는 동기 함수였지만, fetchPrice는 API 응답을 기다려야 해서 async로 선언됩니다.

  • set({ loading: true, error: null }) — 요청을 시작하자마자 먼저 "로딩 중"으로 표시하고 이전 에러를 지움.
  • const res = await fetch(...) — 10단계에서 배운 /api/stock/[symbol]을 저장소 액션 안에서 직접 호출. 컴포넌트가 아니라 저장소가 API를 부르는 구조로 바뀜.
  • try...catch — 성공하면 prices를 갱신하고 로딩 해제, 실패하면 error에 메시지를 담고 로딩 해제 — 어느 쪽이든 loading: false는 꼭 실행되어야 "로딩 중"에 영원히 멈추지 않습니다.
  • fetchAllPricesPromise.all(watchlist.map((s) => fetchPrice(s)))로 관심종목 전체를 한 번에 병렬 요청 — 하나씩 순서대로(for) 기다리는 대신 모두 동시에 보내고 다 끝날 때까지만 기다려 훨씬 빠릅니다.

buyStock의 평단가 재계산 — 숫자 계산을 코드로 옮기면

buyStock(symbol, qty, price)은 "이미 보유 중이면 평단가를 다시 계산"하는 조건 분기가 핵심입니다.

  • totalQty = existing.qty + qty — 기존 수량과 새로 산 수량을 더한 총 수량.
  • totalCost = existing.qty * existing.avgPrice + price * qty — 기존에 들어간 돈(기존 수량×기존 평단가)과 이번에 들어간 돈(새 수량×새 가격)을 더한 총 매입원가.
  • avgPrice: totalCost / totalQty — 총 매입원가를 총 수량으로 나눈 새 평단가.

비유하면 이미 10주를 평균 170.5달러에 사둔 상태에서 5주를 200달러에 더 사면, "지금까지 낸 돈 전부(10×170.5 + 5×200)를 지금까지 산 주식 수(15)로 다시 나눠 평균을 새로 내는" 것과 같습니다 — sellStock은 반대로 remainingQty가 0 이하면 delete로 그 종목을 포트폴리오에서 아예 지우고, 남으면 평단가는 그대로 두고 수량만 줄입니다(판 가격은 평단가에 영향을 주지 않음).

JS src/app/store/useStockStore.js
// ── STEP 1: persist + async 액션 + 포트폴리오 스토어 ──
// [개념 한눈에 보기]
// 1. Zustand 스토어: 전역 데이터(상태)와 데이터를 변경하는 함수(액션)를 하나로 묶어 관리합니다.
// 2. persist: 브라우저를 새로고침(F5)해도 데이터가 사라지지 않고 localStorage에 자동 저장/복원됩니다.
// 3. partialize: localStorage에 저장할 데이터만 콕 찝어 필터링합니다. (로딩/에러/현재가 등 동적 데이터 제외)
// 4. get(): 스토어 내부에서 다른 상태값이나 액션 함수를 불러올 때 사용합니다.

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

// 📌 create와 persist로 전역 스토어 생성
const useStockStore = create(
  persist(
    (set, get) => ({
      // -------------------------------------------------------------
      // 📦 [상태 (State)]: 앱 전체에서 공유하는 데이터
      // -------------------------------------------------------------
      watchlist: ['AAPL', 'TSLA', 'MSFT'], // 관심 종목 코드 목록 (초기값)
      selectedSymbol: 'AAPL',               // 현재 화면에 선택된 종목 코드
      prices: {},                           // 종목별 실시간 시세 저장 객체 (예: { AAPL: 180.5, TSLA: 240.2 })
      loading: false,                       // API 통신 중 로딩 상태 표시 (true / false)
      error: null,                          // 에러 발생 시 에러 메시지 저장
      portfolio: { AAPL: { qty: 10, avgPrice: 170.5 } }, // 보유 중인 주식 내역 (종목코드: { 수량, 평단가 })

      // -------------------------------------------------------------
      // ⚡ [액션 (Actions)]: 상태를 변경하거나 API를 호출하는 함수들
      // -------------------------------------------------------------

      // 📌 1. 관심종목 추가
      addToWatchlist: (symbol) => {
        const { watchlist, fetchPrice } = get() // get()으로 기존 watchlist와 fetchPrice 함수 가져오기
        if (watchlist.includes(symbol)) return  // 이미 관심종목에 포함되어 있다면 중복 추가 방지

        // 관심종목 배열에 새 종목 추가
        set({ watchlist: [...watchlist, symbol] })

        // 종목을 추가하자마자 해당 종목의 최신 시세를 바로 API로 가져옴
        fetchPrice(symbol)
      },

      // 📌 2. 관심종목 삭제
      removeFromWatchlist: (symbol) =>
        set((state) => ({
          // filter를 사용해 클릭한 symbol만 제외한 새로운 배열로 관심종목 갱신
          watchlist: state.watchlist.filter((s) => s !== symbol),
        })),

      // 📌 3. 선택 종목 변경
      selectSymbol: (symbol) => set({ selectedSymbol: symbol }),

      // 📌 4. 단일 종목 시세 조회 (비동기 API 통신)
      fetchPrice: async (symbol) => {
        // API 요청 시작: 로딩 켜고 이전 에러 초기화
        set({ loading: true, error: null })
        try {
          // 백엔드 API Route (/api/stock/[symbol]) 호출
          const res = await fetch(`/api/stock/${symbol}`)
          if (!res.ok) throw new Error(`${symbol} 시세 조회 실패`)

          const data = await res.json()

          // 성공 시: 기존 prices 객체를 복사(...) 후 현재 종목의 최신 가격 갱신 및 로딩 해제
          set((state) => ({
            prices: { ...state.prices, [symbol]: data.price },
            loading: false,
          }))
        } catch (error) {
          // 실패 시: 에러 메시지 저장 및 로딩 해제
          set({ error: error.message, loading: false })
        }
      },

      // 📌 5. 관심종목 전체 병렬 조회
      fetchAllPrices: async () => {
        const { watchlist, fetchPrice } = get()
        // Promise.all: watchlist 안의 모든 종목 시세 조회를 동시에(병렬로) 처리하고 모두 끝날 때까지 기다림
        await Promise.all(watchlist.map((s) => fetchPrice(s)))
      },

      // 📌 6. 주식 매수 (신규 구매 또는 추가 구매 시 평단가 재계산)
      buyStock: (symbol, qty, price) => {
        const { portfolio } = get()
        const existing = portfolio[symbol] // 현재 이미 보유 중인 주식 내역인지 확인

        if (existing) {
          // 💡 이미 보유 중인 주식이면: 평단가 재계산 공식 적용
          // 1) 총 수량 = 기존 수량 + 새로 산 수량
          const totalQty = existing.qty + qty
          // 2) 총 매입원가 = (기존 수량 × 기존 평단가) + (새로 산 수량 × 새로 산 가격)
          const totalCost = existing.qty * existing.avgPrice + price * qty

          // 3) 새로운 평단가 = 총 매입원가 / 총 수량
          set((state) => ({
            portfolio: {
              ...state.portfolio,
              [symbol]: { qty: totalQty, avgPrice: totalCost / totalQty },
            },
          }))
        } else {
          // 💡 신규 등록 주식이면: 수량과 살 때 가격 그대로 저장
          set((state) => ({
            portfolio: {
              ...state.portfolio,
              [symbol]: { qty: qty, avgPrice: price },
            },
          }))
        }
      },

      // 📌 7. 주식 매도 (수량 차감 또는 전량 매도 시 종목 삭제)
      sellStock: (symbol, qty) => {
        const { portfolio } = get()
        const existing = portfolio[symbol]
        if (!existing) return // 보유 중인 종목이 없으면 아무 작업도 하지 않고 즉시 종료

        // 매도 후 남을 예상 수량 계산 (기존 수량 - 팔려는 수량)
        const remainingQty = existing.qty - qty

        // 1) 남은 수량이 0 이하라면: 내 포트폴리오 객체에서 해당 종목 아예 삭제
        if (remainingQty <= 0) {
          const newPortfolio = { ...portfolio }
          delete newPortfolio[symbol] // delete 키워드로 해당 종목 속성을 완벽히 제거
          set({ portfolio: newPortfolio })
        } else {
          // 2) 남은 수량이 1개 이상이면: 구매 평단가는 유지하고 수량(qty)만 차감
          set((state) => ({
            portfolio: {
              ...state.portfolio,
              [symbol]: { ...existing, qty: remainingQty },
            },
          }))
        }
      },
    }),

    // -------------------------------------------------------------
    // ⚙️ [persist 미들웨어 설정 (localStorage 저장 옵션)]
    // -------------------------------------------------------------
    {
      // 1) name: 브라우저 localStorage에 저장될 Key(열쇠) 이름입니다.
      name: 'stock-dashboard',

      // 2) partialize: 새로고침 후에도 '유지할 상태만 선택'하는 옵션입니다.
      // (loading, error, prices처럼 새로고침 시 다시 받아와야 하거나 초기화되어야 하는 데이터는 저장에서 제외됩니다.)
      partialize: (state) => ({
        watchlist: state.watchlist,
        selectedSymbol: state.selectedSymbol, // 선택된 종목 변수(selectedSymbol) 저장
        portfolio: state.portfolio,
      }),
    } // persist 괄호 종료
  ) // create 괄호 종료
)

export default useStockStore
JSX src/app/components/WatchlistPanel.jsx
// ── STEP 2: 관심종목 패널 (WatchlistPanel) ──
// [역할 설명]
// 1. Zustand 스토어의 관심종목(watchlist) 목록과 종목별 가격(prices)을 화면에 출력합니다.
// 2. 컴포넌트가 처음 마운트되면 useEffect를 통해 모든 관심종목의 최신 시세를 일괄 조회합니다 (fetchAllPrices).
// 3. 종목을 클릭하면 선택 상태(selectSymbol)가 변경되고, '✕'를 누르면 삭제(removeFromWatchlist)됩니다.
// 4. '+1주 매수' 버튼을 누르면 해당 종목을 1주 매수(buyStock)합니다.

'use client'
import { useEffect } from 'react'
import useStockStore from '@/app/store/useStockStore'

export default function WatchlistPanel() {
  // 📌 1) Zustand 스토어에서 필요한 상태 및 액션 함수들만 각각 개별 구독 (리렌더링 성능 최적화)
  const watchlist = useStockStore((s) => s.watchlist)
  const selectedSymbol = useStockStore((s) => s.selectedSymbol)
  const prices = useStockStore((s) => s.prices)
  const selectSymbol = useStockStore((s) => s.selectSymbol)
  const removeFromWatchlist = useStockStore((s) => s.removeFromWatchlist)
  const fetchAllPrices = useStockStore((s) => s.fetchAllPrices)
  const buyStock = useStockStore((s) => s.buyStock)

  // 📌 2) 최초 마운트 시 1회: 관심종목 전체 시세 일괄 조회
  // (가격 정보는 localStorage에 저장하지 않으므로, 앱이 켜질 때마다 최신 시세를 다시 불러옵니다.)
  useEffect(() => {
    fetchAllPrices()
  }, [fetchAllPrices])

  return (
    <div style={{ padding: '1rem' }}>
      <h2>관심종목</h2>
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {watchlist.map((symbol) => {
          const price = prices[symbol]
          return (
            <li
              key={symbol}
              onClick={() => selectSymbol(symbol)} // 클릭 시 선택된 종목 변경 (하이라이트 표시)
              style={{
                padding: '12px',
                marginBottom: '8px',
                borderRadius: '8px',
                border: `1px solid ${selectedSymbol === symbol ? '#61dafb' : '#0f3460'}`,
                cursor: 'pointer',
                backgroundColor: selectedSymbol === symbol ? '#123a2a' : '#0d2137',
              }}
            >
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
                <div>
                  <strong style={{ color: '#ccd6f6' }}>{symbol}</strong>
                  <div style={{ fontSize: '12px', color: '#8892b0' }}>
                    {/* price 시세 데이터가 들어오면 소수점 둘째 자리까지 표시, 들어오기 전엔 '로딩 중...' */}
                    {price ? `$${price.toFixed(2)}` : '로딩 중...'}
                  </div>
                </div>

                {/* 📌 관심종목 삭제 버튼 */}
                <button
                  onClick={(e) => {
                    e.stopPropagation() // 부모 <li>의 onClick(종목 선택) 이벤트 전파 방지
                    removeFromWatchlist(symbol)
                  }}
                  style={{
                    background: 'none',
                    border: 'none',
                    color: '#e94560',
                    cursor: 'pointer',
                    fontSize: '16px',
                  }}
                >
                  ✕
                </button>
              </div>

              {/* 📌 1주 매수 버튼 */}
              <button
                onClick={(e) => {
                  e.stopPropagation() // 부모 <li>의 onClick(종목 선택) 클릭 이벤트 전파 방지
                  // 시세 가격(price)이 유효하면 수량 1주를 현재 가격(price)으로 매수 실행
                  if (price) buyStock(symbol, 1, price)
                }}
                disabled={!price} // 아직 시세 데이터를 로딩 중이면 매수 버튼 비활성화
                style={{
                  marginTop: '8px',
                  width: '100%',
                  padding: '5px',
                  fontSize: '12px',
                  borderRadius: '6px',
                  background: '#61dafb22',
                  color: '#61dafb',
                  border: '1px solid #61dafb55',
                  cursor: price ? 'pointer' : 'not-allowed',
                }}
              >
                +1주 매수
              </button>
            </li>
          )
        })}
      </ul>
    </div>
  )
}
JSX src/app/components/PortfolioPanel.jsx
// ── STEP 3: 포트폴리오 패널 (PortfolioPanel) ──
// [역할 설명]
// 1. Zustand 스토어의 보유 주식(portfolio) 및 최신 시세(prices)를 바탕으로
//    총 평가금액, 총 원금, 총 손익($), 손익률(%)을 계산하여 보여줍니다.
// 2. 보유 종목별 수량, 평단가, 현재가, 개별 평가손익을 표시합니다.
// 3. '전량 매도' 버튼 클릭 시 sellStock을 호출해 해당 주식을 전량 매도 처리합니다.

'use client'
import useStockStore from '@/app/store/useStockStore'

export default function PortfolioPanel() {
  // 📌 1) Zustand 스토어에서 보유 자산(portfolio), 실시간 시세(prices), 매도 액션(sellStock) 구독
  const portfolio = useStockStore((s) => s.portfolio)
  const prices = useStockStore((s) => s.prices)
  const sellStock = useStockStore((s) => s.sellStock)

  // 📌 2) 객체 형태의 portfolio를 배열 형태로 변환: [ [symbol, {qty, avgPrice}], ... ]
  // (예: { AAPL: { qty: 10, avgPrice: 170.5 } } -> [ ['AAPL', { qty: 10, avgPrice: 170.5 }] ])
  const holdings = Object.entries(portfolio)

  // 📌 3) 총 평가금액(totalValue) 계산 (`reduce` 이용)
  // - holdings 배열을 돌면서 각 종목의 (현재가 × 수량)을 더해나갑니다.
  // - 최신 시세(prices[symbol])가 없으면 구매 평단가(h.avgPrice)를 대신 사용합니다.
  // - 맨 뒤의 0은 초기 합계 시작 금액(0원)입니다.
  const totalValue = holdings.reduce(
    (sum, [symbol, h]) => sum + (prices[symbol] || h.avgPrice) * h.qty,
    0
  )

  // 📌 4) 나의 총 매입원가(totalCost) 계산 (`reduce` 이용)
  // - holdings 배열을 돌면서 각 종목의 (구매 평단가 × 수량)을 더해나갑니다.
  // - [, h] 형태의 구조 분해로 첫 번째 인자인 종목코드(symbol)는 생략하고 내역 객체(h)만 가져옵니다.
  const totalCost = holdings.reduce(
    (sum, [, h]) => sum + h.avgPrice * h.qty,
    0
  )

  // 📌 5) 총 손익($) = 총 평가금액 - 총 매입원가
  const totalPnl = totalValue - totalCost

  // 📌 6) 총 손익률(%) = (총 손익 / 총 매입원가) × 100 (원금이 0보다 큰 경우에만 계산)
  const totalPnlPct = totalCost > 0 ? (totalPnl / totalCost) * 100 : 0

  return (
    <div style={{ padding: '1rem' }}>
      <h2>내 포트폴리오</h2>

      {/* 📌 총 자산 / 수익률 요약 카드 */}
      <div
        style={{
          padding: '12px',
          marginBottom: '1rem',
          borderRadius: '8px',
          background: '#0d2137',
          border: '1px solid #0f3460',
        }}
      >
        <div style={{ fontSize: '12px', color: '#8892b0' }}>총 평가금액</div>
        {/* toFixed(2): 소수점 둘째 자리까지 표시 */}
        <div style={{ fontSize: '22px', fontWeight: 'bold', color: '#ccd6f6' }}>
          ${totalValue.toFixed(2)}
        </div>
        {/* 이익이면 초록색(#64ffda), 손실이면 빨간색(#e94560) 표시 */}
        <div style={{ fontSize: '13px', color: totalPnl >= 0 ? '#64ffda' : '#e94560' }}>
          {totalPnl >= 0 ? '+' : ''}
          {totalPnl.toFixed(2)} ({totalPnlPct.toFixed(2)}%)
        </div>
      </div>

      {/* 보유 종목이 없을 때 보여줄 안내 메시지 */}
      {holdings.length === 0 && (
        <p style={{ color: '#8892b0', fontSize: '13px' }}>보유 종목이 없습니다.</p>
      )}

      {/* 📌 보유 종목 개별 카드 목록 */}
      {holdings.map(([symbol, h]) => {
        const cur = prices[symbol] || h.avgPrice // 현재 시세 (없으면 구매 평단가 사용)
        const pnl = (cur - h.avgPrice) * h.qty   // 종목별 평가손익($) = (현재가 - 평단가) × 수량
        const pnlPct = ((cur - h.avgPrice) / h.avgPrice) * 100 // 종목별 수익률(%)
        const isUp = pnl >= 0                    // 이익 여부 (양수: 초록색, 음수: 빨간색)

        return (
          <div
            key={symbol}
            style={{
              padding: '12px',
              marginBottom: '8px',
              borderRadius: '8px',
              background: '#0d2137',
              border: `1px solid ${isUp ? '#64ffda33' : '#e9456033'}`,
            }}
          >
            {/* 상단: 종목명 및 개별 수익률 */}
            <div style={{ display: 'flex', justifyContent: 'space-between' }}>
              <strong style={{ color: '#ccd6f6' }}>{symbol}</strong>
              <span style={{ color: isUp ? '#64ffda' : '#e94560', fontSize: '13px' }}>
                {isUp ? '+' : ''}
                {pnlPct.toFixed(2)}%
              </span>
            </div>

            {/* 중단: 수량, 평단가, 현재가 상세 내역 */}
            <div style={{ fontSize: '12px', color: '#8892b0', marginTop: '4px' }}>
              {h.qty}주 · 평균 ${h.avgPrice.toFixed(2)} · 현재 ${cur.toFixed(2)}
            </div>

            {/* 하단: 손익($)금액 및 전량 매도 버튼 */}
            <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '8px' }}>
              <span style={{ color: isUp ? '#64ffda' : '#e94560', fontSize: '13px' }}>
                {isUp ? '+' : ''}${pnl.toFixed(2)}
              </span>

              {/* 📌 전량 매도 버튼: 현재 보유 중인 수량(h.qty) 전체를 매도하도록 sellStock 호출 */}
              <button
                onClick={() => sellStock(symbol, h.qty)}
                style={{
                  padding: '3px 10px',
                  fontSize: '11px',
                  borderRadius: '4px',
                  border: '1px solid #e94560',
                  background: 'none',
                  color: '#e94560',
                  cursor: 'pointer',
                }}
              >
                전량 매도
              </button>
            </div>
          </div>
        )
      })}
    </div>
  )
}
핵심 정리
  • create(persist(fn, options))처럼 persist로 스토어를 감싸면 partialize가 고른 상태 조각만 localStorage에 자동 저장/복원된다.
  • prices·loading·error처럼 "새로고침하면 다시 받아와야 하는 값"은 partialize에서 일부러 뺀다.
  • 저장소 액션도 async로 선언해 fetch를 직접 호출할 수 있다 — fetchAllPricesPromise.all로 여러 종목을 동시에 병렬 조회한다.
  • buyStock은 총 매입원가÷총 수량으로 평단가를 재계산하고, sellStock은 수량이 0 이하가 되면 delete로 종목 자체를 지운다.
TIP새로고침해도 관심종목·포트폴리오는 남아있는데 시세가 항상 "로딩 중..."부터 다시 시작한다면 정상입니다 — partializeprices를 일부러 저장에서 뺐기 때문에, WatchlistPaneluseEffect(() => fetchAllPrices(), [])가 페이지를 열 때마다 최신 시세를 다시 받아옵니다. persist 자체가 헷갈리면 기초 개념 사전 — Zustand persist 미들웨어란?을 참고하세요.
12

서버 컴포넌트 + Suspense — async 컴포넌트로 데이터를 기다렸다가 화면을 스트리밍

서버 컴포넌트async 컴포넌트Suspense스트리밍스켈레톤

한 줄 요약'use client'가 없는 컴포넌트(StockInfo)를 async function으로 선언해 함수 안에서 바로 await로 데이터를 가져오게 만들고, 그 컴포넌트를 <Suspense fallback={<CardSkeleton />}>로 감싸면 데이터가 오는 동안은 스켈레톤 UI가, 준비되면 실제 내용이 화면에 나타난다.

쉽게 말하면08단계에서 "서버 컴포넌트는 함수 자체를 async로 만들 수 있다"고 개념만 배웠다면, 이번엔 그걸 실제로 "데이터를 기다리는" 용도로 처음 써봅니다. 그리고 06단계에서 배운 Suspense는 그때는 "아직 안 받아온 코드"를 기다리는 팻말이었는데, 이번엔 "아직 안 받아온 데이터"를 기다리는 팻말로 똑같은 도구가 재사용됩니다 — 팻말 자체는 그대로고, 기다리는 대상만 코드에서 데이터로 바뀐 거예요.

StockInfo — async 서버 컴포넌트, 요소별로 뜯어보면

export default async function StockInfo({ symbol }) { ... }이 낯설게 보이는 이유는 지금까지 만든 컴포넌트는 전부 동기 함수였기 때문입니다.

  • 파일 맨 위에 'use client'가 없음 — 08단계에서 배운 규칙대로, 아무 선언이 없으면 기본값인 서버 컴포넌트가 됩니다.
  • async function StockInfo(...) — 컴포넌트 함수 자체가 async. 클라이언트 컴포넌트였다면 "리액트가 이 비동기 결과를 언제 그려야 할지 알 수 없다"는 이유로 허용되지 않지만, 서버 컴포넌트는 서버에서 딱 한 번 실행되고 끝나므로 자연스럽게 동작합니다.
  • const profile = await getStockProfile(symbol) — 함수 본문 맨 위에서 바로 await. useEffect도, 로딩 state도 따로 만들 필요가 없습니다 — 데이터가 다 준비된 다음에야 이 함수의 나머지 부분(JSX를 그리는 부분)이 실행되기 때문입니다.

비유하면 클라이언트 컴포넌트의 useEffect+fetch 패턴은 "일단 빈 접시를 손님상에 내고, 요리가 되면 나중에 슬쩍 바꿔치기"하는 방식이었다면, async 서버 컴포넌트는 "주방에서 요리가 다 완성될 때까지 아예 내보내지 않는" 방식입니다.

Suspense의 두 번째 용도 — 코드를 기다리던 팻말이 데이터를 기다리는 팻말로

06단계 React.lazy + Suspense에서는 Suspense가 "아직 다운로드 안 된 컴포넌트 코드"를 기다렸습니다. 이번엔 감싸는 대상이 다릅니다.

  • <Suspense fallback={<CardSkeleton height="180px" />}> — 이 울타리 안에 있는 <StockInfo symbol="AAPL" />가 아직 데이터를 다 받아오지 못한 async 서버 컴포넌트라는 걸 감지.
  • 데이터 로딩 중fallback으로 지정한 CardSkeleton(회색 그라데이션 뼈대 UI)을 대신 표시.
  • 로딩 완료getStockProfileawait가 끝나 StockInfo가 실제 JSX를 반환하면, 리액트가 자동으로 스켈레톤을 내리고 실제 내용으로 교체(스트리밍).

즉 Suspense 자체는 "안에서 뭔가 준비 안 된 게 있으면 fallback을 보여준다"는 하나의 규칙만 갖고 있고, 그 "준비 안 된 것"이 06단계에서는 lazy 컴포넌트의 코드였고 이번엔 async 서버 컴포넌트의 데이터라는 점만 다릅니다.

한 화면 안에서 서버 컴포넌트와 클라이언트 컴포넌트가 섞이는 구조

page.js를 보면 왼쪽 asideStockSearch·WatchlistPanel·PortfolioPanel은 전부 파일 맨 위에 'use client'가 있는 클라이언트 컴포넌트(Zustand 구독·클릭 이벤트가 필요)이고, 오른쪽 mainStockInfo만 서버 컴포넌트입니다.

  • 서버/클라이언트 경계는 파일 단위로 정해집니다 — 한 페이지 안에 두 종류가 얼마든지 섞일 수 있고, page.js처럼 이들을 조립하는 부모는 그대로 서버 컴포넌트로 둘 수 있습니다.
  • 사용자 입력(검색, 매수/매도 클릭)이 필요한 부분은 클라이언트로, 데이터를 받아와 "보여주기만" 하면 되는 부분은 서버로 — 이 구분 기준이 08단계 개념 카드에서 이미 설명한 내용과 그대로 일치합니다.
JSX src/app/components/StockInfo.jsx
// ── STEP 1: async 서버 컴포넌트 ──
// 개념: 'use client' 없음 = 서버 컴포넌트. 함수 자체를 async로 만들고 안에서 await 가능.

// (헬퍼는 제공됨) 느린 API 흉내 (Suspense fallback 확인용)
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

async function getStockProfile(symbol) {
  await delay(2000)
  const names = { AAPL: 'Apple Inc.', TSLA: 'Tesla Inc.', MSFT: 'Microsoft Corp.' }
  return {
    name: names[symbol] || symbol,
    exchange: 'NASDAQ',
    industry: '기술',
    marketCap: '2.8T',
    description: `${symbol}은 글로벌 기업으로 혁신적인 제품과 서비스를 제공합니다.`,
  }
}

// StockInfo 자체가 async 함수 — 컴포넌트 본문에서 바로 await를 쓸 수 있다.
export default async function StockInfo({ symbol }) {
  const profile = await getStockProfile(symbol)
  return (
    <div style={{ padding: '1.25rem', borderRadius: '12px', border: '1px solid #0f3460', background: '#0d2137' }}>
      <h3 style={{ color: '#61dafb', marginTop: 0, marginBottom: '0.5rem' }}>{profile.name} ({symbol})</h3>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', fontSize: '13px' }}>
        {[
          ['거래소', profile.exchange],
          ['업종', profile.industry],
          ['시가총액', profile.marketCap],
        ].map(([label, value]) => (
          <div key={label}>
            <span style={{ color: '#8892b0' }}>{label}: </span>
            <span style={{ color: '#ccd6f6' }}>{value}</span>
          </div>
        ))}
      </div>
      <p style={{ color: '#8892b0', fontSize: '12px', marginTop: '0.75rem', lineHeight: 1.6 }}>{profile.description}</p>
    </div>
  )
}
JSX src/app/page.js
// ── STEP 2: Suspense + 서버 컴포넌트로 대시보드 완성 ──
// 개념: <Suspense fallback>로 async 서버 컴포넌트를 감싸면 로딩 중 스켈레톤 표시.
import { Suspense } from 'react'
import StockSearch from './components/StockSearch'
import WatchlistPanel from './components/WatchlistPanel'
import PortfolioPanel from './components/PortfolioPanel'
import StockInfo from './components/StockInfo'

function CardSkeleton({ height = '180px' }) {
  return (
    <div style={{ height, borderRadius: '12px', border: '1px solid #0f3460', background: 'linear-gradient(90deg, #0d2137 20%, #0e478d 40%, #0d2137 75%)', backgroundSize: '200% 100%', animation: 'shimmer 1.5s infinite' }} />
  )
}

export default function DashboardPage() {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', gap: '1rem', minHeight: '100vh', padding: '1rem' }}>
      <aside style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
        <StockSearch />
        <WatchlistPanel />
        <PortfolioPanel />
      </aside>

      <main style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
        {/* async 서버 컴포넌트를 Suspense로 감싸 로딩 동안 스켈레톤을 보여줌 */}
        <Suspense fallback={<CardSkeleton height="180px" />}>
          {/* 지금은 symbol="AAPL"로 고정 — 관심종목 클릭 시 그 symbol을 전달하도록 확장하는 건 다음 실습 */}
          <StockInfo symbol="AAPL" />
        </Suspense>

        <div style={{ padding: '1.25rem', borderRadius: '12px', border: '1px solid #0f3460', background: '#0d2137' }}>
          <p style={{ color: '#8892b0' }}>차트 영역 (13강에서 구현)</p>
        </div>
      </main>
    </div>
  )
}
핵심 정리
  • 파일 맨 위에 'use client'가 없는 컴포넌트는 서버 컴포넌트이며, 함수 자체를 async로 선언해 본문에서 바로 await로 데이터를 가져올 수 있다.
  • <Suspense fallback={...}>는 06단계의 코드 분할뿐 아니라, async 서버 컴포넌트의 데이터 로딩을 기다리는 데도 똑같이 쓰인다.
  • 한 페이지 안에서 클라이언트 컴포넌트(사용자 상호작용)와 서버 컴포넌트(데이터 표시)가 파일 단위로 섞여 쓰일 수 있다.
  • 현재는 symbol="AAPL"로 고정돼 있고, 관심종목을 클릭했을 때 그 symbol을 전달하도록 연결하는 것은 다음 실습 과제로 남아 있다.
TIPStockInfo가 화면에 뜨기까지 항상 2초쯤 걸리는 게 정상입니다 — getStockProfile 안의 delay(2000)이 실제 API 통신 지연을 흉내 내기 위해 일부러 넣어둔 코드라, Suspense의 스켈레톤 화면을 눈으로 확인하기 좋게 해줍니다. 서버/클라이언트 컴포넌트 구분이 헷갈리면 기초 개념 사전 — 서버 vs 클라이언트 컴포넌트를, Suspense 자체가 헷갈리면 React.lazy + Suspense란?을 참고하세요.
13

Tailwind CSS + 다크모드 — 인라인 style을 유틸리티 클래스로, 전역 테마 스위치 달기

Tailwind CSS v4@themedark:zustand persist반응형 그리드

한 줄 요약지금까지 style={{...}}로 한 줄씩 적던 스타일을 Tailwind의 className="bg-white dark:bg-stock-card" 같은 유틸리티 클래스로 바꾸고, Zustand에 theme 상태를 추가해 ThemeWrapper<html> 태그에 dark 클래스를 붙였다 뗐다 하는 방식으로 사이트 전체 다크/라이트 모드를 전환한다.

쉽게 말하면지금까지는 옷(스타일)을 style={{}}로 한 벌 한 벌 직접 재단해서 입혔다면, Tailwind는 미리 만들어진 라벨 스티커(className="px-3 py-2 rounded-lg")를 골라 붙이는 것에 가깝습니다. 다크모드는 방의 조명 스위치예요 — 10강에서 배운 Zustand가 "지금 스위치가 켜져 있나 꺼져 있나(theme)"를 기억하는 스위치 자체이고, ThemeWrapper는 그 기억을 보고 실제로 방(<html> 태그)의 전등을 켜고 끄는 손입니다.

Tailwind CSS란 — 클래스 이름이 곧 스타일

className="px-3 py-1 rounded-lg text-sm border"처럼, Tailwind는 미리 정의된 짧은 클래스 이름 하나하나가 CSS 속성 하나(또는 몇 개)에 대응하는 "유틸리티 CSS 프레임워크"입니다. px-3padding: 0.75rem, rounded-lgborder-radius: 0.5rem 같은 식으로, 직접 CSS 파일에 클래스를 정의하지 않고도 JSX 안에서 바로 스타일을 조합할 수 있습니다.

2강에서 <link rel="stylesheet">로 외부 CSS 파일을 연결했던 것과 달리, Tailwind는 globals.css@import "tailwindcss" 한 줄만 넣어두면 프로젝트 전체에서 이 유틸리티 클래스들을 즉시 쓸 수 있게 해주는 빌드 도구입니다.

@theme으로 커스텀 색상, @variant dark로 다크모드 스위치 켜기

  • @theme { --color-stock-cyan: #61dafb; ... }globals.css에서 프로젝트 전용 색상을 정의하면, Tailwind가 자동으로 bg-stock-cyan·text-stock-cyan 같은 클래스를 만들어줍니다. 지금까지 style={{ color: '#61dafb' }}로 반복해 적던 색상 코드를 이름 있는 클래스 하나로 재사용하는 것입니다.
  • @variant dark (&:where(.dark, .dark *)); — 이 한 줄이 있어야 <html>class="dark"가 붙어 있을 때만 dark: 접두사가 붙은 클래스(예: dark:bg-stock-bg)가 활성화됩니다. 즉 다크모드의 "스위치 규칙" 자체가 이 줄에서 정의됩니다.

Zustand의 theme 상태 + ThemeWrapper — 상태가 브라우저 DOM을 직접 조작하는 첫 사례

10강에서 만든 useStockStoretheme: 'dark'toggleTheme 액션을 추가하고, partialize에도 theme를 포함시켜 새로고침해도 마지막에 고른 테마가 유지되게 했습니다(persist의 원리는 기초 개념 사전 — Zustand persist란? 참고).

  • ThemeWrapper는 화면에 아무것도 그리지 않고 children만 그대로 반환하는 특이한 컴포넌트입니다 — 존재 이유는 오직 useEffect(() => { document.documentElement.classList.add/remove('dark') }, [theme]) 하나뿐입니다.
  • 지금까지의 useEffect는 대부분 "값을 가져오는" 용도(fetch, 구독)였다면, 이건 "리액트 상태(theme)가 바뀌었으니 리액트 바깥의 실제 브라우저 DOM(<html> 태그)을 직접 고쳐 쓰는" 용도입니다 — Tailwind의 dark: 클래스는 CSS 선택자라서, React가 아니라 실제 DOM에 class="dark"가 붙어 있어야만 작동하기 때문입니다.

dark: 변형으로 라이트/다크를 한 줄에 함께 쓰기 + 반응형 그리드

className="bg-white text-gray-900 dark:bg-stock-card dark:text-stock-light"처럼, 클래스 하나에 라이트 스타일과 dark: 스타일을 나란히 적어두면 매번 if (theme === 'dark') 분기를 짤 필요 없이 CSS 선택자가 알아서 골라 씁니다. page.jsgrid-cols-1 md:grid-cols-[300px_1fr]도 같은 원리(md: 변형)로, 화면이 좁으면 1열, md(768px) 이상이면 사이드바+메인 2열로 바뀝니다.

CSS src/app/globals.css
/* Tailwind v4 설정 — 파일 맨 위 한 줄로 유틸리티 클래스 전부 활성화 */
@import "tailwindcss";

/* <html>에 class="dark"가 있을 때만 dark: 접두사 클래스를 활성화하는 규칙 */
@variant dark (&:where(.dark, .dark *));

/* 커스텀 색 — bg-stock-card, text-stock-cyan 등 클래스를 자동 생성 */
@theme {
  --color-stock-bg: #1a1a2e;
  --color-stock-card: #0d2137;
  --color-stock-border: #0f3460;
  --color-stock-cyan: #61dafb;
  --color-stock-green: #64ffda;
  --color-stock-red: #e94560;
  --color-stock-muted: #8892b0;
  --color-stock-light: #ccd6f6;
}
JS src/app/store/useStockStore.js — theme 부분만 발췌
const useStockStore = create(
  persist(
    (set, get) => ({
      // 다크/라이트도 다른 상태와 똑같이 전역 상태로 관리
      theme: 'dark',
      toggleTheme: () => set((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark' })),

      // ...watchlist, prices, portfolio 등 10강의 나머지 상태 (그대로)
    }),
    {
      name: 'stock-dashboard',
      partialize: (state) => ({
        theme: state.theme, // 테마도 새로고침 후 그대로 유지되도록 저장 대상에 포함
        watchlist: state.watchlist,
        selectedSymbol: state.selectedSymbol,
        portfolio: state.portfolio,
      }),
    }
  )
)
JSX src/app/components/ThemeWrapper.jsx
'use client'
import { useEffect } from 'react'
import useStockStore from '@/app/store/useStockStore'

// 화면에는 아무것도 그리지 않고, theme가 바뀔 때마다 <html> 태그의 class만 조작한다
export default function ThemeWrapper({ children }) {
  const theme = useStockStore((s) => s.theme)

  useEffect(() => {
    const root = document.documentElement
    if (theme === 'dark') {
      root.classList.add('dark')
    } else {
      root.classList.remove('dark')
    }
  }, [theme])

  return children
}
JSX src/app/components/ThemeToggle.jsx
'use client'
import useStockStore from '@/app/store/useStockStore'

export default function ThemeToggle() {
  const theme = useStockStore((s) => s.theme)
  const toggleTheme = useStockStore((s) => s.toggleTheme)

  return (
    <button
      onClick={toggleTheme}
      className="px-3 py-1 rounded-lg text-sm border border-stock-border text-stock-muted
                 hover:text-stock-cyan hover:border-stock-cyan/50 transition-colors cursor-pointer"
    >
      {theme === 'dark' ? '🌙 다크' : '☀️ 라이트'}
    </button>
  )
}
핵심 정리
  • Tailwind는 className에 미리 정의된 짧은 클래스를 조합해 스타일을 적용하는 유틸리티 CSS 프레임워크로, globals.css@import "tailwindcss" 한 줄로 활성화된다.
  • @theme으로 프로젝트 전용 색상 클래스를, @variant dark<html>.dark일 때만 동작하는 dark: 접두사 규칙을 만든다.
  • Zustand의 theme 상태 + toggleTheme이 다크모드의 "기억"을 담당하고, ThemeWrapperuseEffect가 그 기억을 실제 <html> DOM에 반영하는 "실행"을 담당한다 — 역할이 나뉘어 있다.
  • bg-white dark:bg-stock-card처럼 한 클래스 안에 라이트·다크 스타일을 같이 적으면 조건 분기 없이 CSS가 알아서 전환하고, md:grid-cols-[300px_1fr]도 같은 방식으로 반응형을 만든다.
TIPStockSearch·WatchlistPanel·PortfolioPanel·StockInfo의 실습 로직 자체는 9~11강과 동일하고, style={{ color: '#8892b0' }} 같은 인라인 style이 className="text-stock-muted" 같은 Tailwind 클래스로만 바뀌었습니다 — 헷갈리면 두 강의 같은 컴포넌트를 나란히 비교해보세요. Zustand persist가 헷갈리면 기초 개념 사전 — Zustand persist란?을 참고하세요.
14

Recharts + WebSocket — REST로 초기 데이터, 실시간 소켓으로 계속 갱신 (커리큘럼 마지막 단원)

RechartsWebSocketREST순수 업데이터cleanup

한 줄 요약StockChart는 첫 화면은 fetch('/api/stock/[symbol]/chart')(REST)로 과거 60개 시세를 받아 그리고, 그 뒤로는 Finnhub WebSocket을 열어 실시간으로 들어오는 시세를 Recharts<LineChart>에 계속 이어 붙인다 — 미국 장이 닫혀 있거나 API 키가 없으면 서버가 자동으로 더미 데이터를 대신 준다.

쉽게 말하면REST로 초기 데이터를 받아오는 건 "지난 방송 다시듣기(VOD)"를 한 번 다운로드하는 것이고, WebSocket은 "라디오 생방송 채널에 계속 주파수를 맞춰두는 것"에 가깝습니다 — 전화를 한 번 걸어 대답을 듣고 끊는 게 아니라, 통화를 계속 연결해둔 채로 상대가 뭔가 말할 때마다(onmessage) 바로바로 알림을 받는 방식입니다.

초기 데이터 로드 — REST + cleanup으로 stale 응답 무시하기

useEffect(() => { ... }, [selectedSymbol]) 안에서 fetch로 초기 60개 시세를 받아 setChartData에 넣습니다. 여기서 let alive = truereturn () => { alive = false }가 중요한데, 사용자가 응답이 오기 전에 다른 종목을 빠르게 클릭하면 effect가 다시 실행되면서 이전 fetch의 cleanup이 먼저 호출됩니다 — 그 뒤 먼저 보낸(옛날) 요청의 응답이 나중에 도착해도 if (!alive) return이 걸러줘서, 화면에는 항상 가장 최근에 고른 종목의 데이터만 남습니다.

실시간 갱신 — WebSocket 구독/해제

  • new WebSocket('wss://ws.finnhub.io?token=...') — 서버와 계속 열려있는 양방향 연결을 하나 생성.
  • ws.onopen — 연결되자마자 { type: 'subscribe', symbol } 메시지를 보내 "이 종목 시세가 바뀔 때마다 알려줘"라고 요청.
  • ws.onmessage — 서버가 실제 체결가를 보낼 때마다 실행되는 콜백. 받은 값으로 pushPrice를 호출해 차트에 새 점을 추가.
  • cleanup(return () => {...}) — 컴포넌트가 사라지거나 종목이 바뀌면 unsubscribe 메시지를 보내고 ws.close()로 소켓을 정리 — 08~11강에서 봐온 "effect가 열어둔 건 반드시 cleanup에서 닫는다"는 원칙이 여기서도 그대로 적용됩니다.

setChartData 업데이터 "밖"에서 setPrice를 부르는 이유

pushPrice 안의 setChartData((prev) => [...prev.slice(-59), point])처럼, state 업데이터 함수(콜백)는 "이전 값을 받아 새 값을 계산해서 리턴"하는 순수 함수여야 합니다. 그 콜백 안에서 setPrice(...) 같은 다른 스토어의 상태 변경(부수 효과)까지 같이 실행하면, 리액트가 같은 업데이트를 두 번 실행하는 상황(예: StrictMode의 이중 호출)에서 setPrice도 의도치 않게 두 번 불릴 수 있습니다. 그래서 setPrice(selectedSymbol, newPrice)setChartData 콜백 바깥, pushPrice 함수 본문에서 한 번만 호출되도록 분리돼 있습니다.

Recharts로 라인 차트 그리기 + 장이 닫혀 있을 때의 폴백

<ResponsiveContainer>가 부모 박스 크기에 맞춰 차트 크기를 자동 조절하고, 그 안의 <LineChart data={chartData}><XAxis dataKey="time">·<YAxis>·<Tooltip>·<Line dataKey="price">를 조합해 넣으면 끝입니다 — 각 태그가 축 하나, 툴팁 하나, 선 하나를 담당하는 "조립식" 구조입니다. 그리고 chart/route.jsisUSMarketOpen()(뉴욕 시간대 기준 평일 9:30~16:00 판별, lib/market.js)으로 장이 닫혀 있거나 API 키가 없으면 generateDummyData로 만든 가짜 60개 시세를 대신 반환합니다 — 그래서 새벽에 실습해도 항상 차트가 정상적으로 그려집니다.

JS src/app/api/stock/[symbol]/chart/route.js
import { isUSMarketOpen } from '@/app/lib/market'

// API 키가 없거나 장이 닫혀 있을 때 대신 내보낼 가짜 60개 시세
function generateDummyData(symbol) {
  const now = Date.now()
  const oneMin = 60 * 1000
  const base = symbol === 'AAPL' ? 182 : symbol === 'TSLA' ? 250 : 150
  return Array.from({ length: 60 }, (_, i) => {
    const t = now - (59 - i) * oneMin
    const noise = (Math.random() - 0.5) * 3
    return {
      time: new Date(t).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }),
      price: parseFloat((base + noise + i * 0.05).toFixed(2)),
    }
  })
}

export async function GET(request, { params }) {
  const { symbol } = await params
  const apiKey = process.env.FINNHUB_API_KEY

  // 키가 없거나 미국장이 닫혀 있으면 더미 60개를 바로 반환
  if (!apiKey || !isUSMarketOpen()) {
    return Response.json({ symbol, data: generateDummyData(symbol) })
  }

  try {
    const res = await fetch(`https://finnhub.io/api/v1/quote?symbol=${symbol}&token=${apiKey}`, { cache: 'no-store' })
    if (!res.ok) throw new Error(`quote 실패 (${res.status})`)
    const q = await res.json()
    if (typeof q.c !== 'number' || q.c === 0) throw new Error('quote 없음')
    const point = { time: new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }), price: parseFloat(q.c.toFixed(2)) }
    return Response.json({ symbol, data: [point] })
  } catch (err) {
    // 실제 API 실패 시에도 더미 데이터로 안전하게 폴백
    return Response.json({ symbol, data: generateDummyData(symbol) })
  }
}
JSX src/app/components/StockChart.jsx — 초기 데이터 로드
const [chartData, setChartData] = useState([])
const [isLoading, setIsLoading] = useState(true)
const lastPriceRef = useRef(0)

// selectedSymbol이 바뀔 때마다 REST로 초기 60개 데이터를 다시 로드
useEffect(() => {
  let alive = true // 이 effect가 아직 "최신"인지 표시
  setIsLoading(true)
  fetch(`/api/stock/${selectedSymbol}/chart`)
    .then((r) => r.json())
    .then(({ data }) => {
      if (!alive) return // 그 사이 종목이 또 바뀌었다면(=최신이 아니면) 이 응답은 버림
      setChartData(data)
      if (data.length) lastPriceRef.current = data[data.length - 1].price
      setIsLoading(false)
    })
  return () => { alive = false } // cleanup: 다음 effect가 시작되기 전에 이 요청을 "낡은 것"으로 표시
}, [selectedSymbol])
JSX src/app/components/StockChart.jsx — 실시간 WebSocket 갱신
useEffect(() => {
  if (isLoading) return

  // 새 시세가 들어올 때마다 차트 배열 끝에 점을 추가 + 스토어 동기화
  const pushPrice = (price) => {
    const newPrice = parseFloat(price.toFixed(2))
    lastPriceRef.current = newPrice
    const point = { time: nowLabel(), price: newPrice }
    // setChartData 업데이터 콜백은 배열 계산만 순수하게 — 최근 60개로 유지
    setChartData((prev) => [...prev.slice(-59), point])
    // 다른 스토어 상태 변경은 업데이터 "밖"에서 한 번만
    setPrice(selectedSymbol, newPrice)
  }

  const token = process.env.NEXT_PUBLIC_FINNHUB_API_KEY
  if (token) {
    const ws = new WebSocket(`wss://ws.finnhub.io?token=${token}`)
    ws.onopen = () => ws.send(JSON.stringify({ type: 'subscribe', symbol: selectedSymbol }))
    ws.onmessage = (event) => {
      const msg = JSON.parse(event.data)
      if (msg.type !== 'trade' || !msg.data?.length) return
      pushPrice(msg.data[msg.data.length - 1].p)
    }
    return () => {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({ type: 'unsubscribe', symbol: selectedSymbol }))
        ws.close() // 컴포넌트가 사라지거나 종목이 바뀌면 소켓도 반드시 정리
      }
    }
  }
}, [selectedSymbol, isLoading, setPrice])
핵심 정리
  • 초기 데이터는 REST(fetch)로 한 번에 받고, 실시간 갱신은 WebSocket으로 계속 열린 연결을 통해 받는다 — 목적이 다른 두 통신 방식을 한 컴포넌트 안에서 함께 쓴다.
  • alive 플래그로 stale 응답을 거르고, WebSocket cleanup에서 unsubscribe+close로 연결을 반드시 정리한다 — 둘 다 "이 effect가 만든 건 이 effect가 책임지고 치운다"는 같은 원칙.
  • state 업데이터 콜백(setChartData(prev => ...))은 순수하게 값 계산만 하고, 다른 상태 변경(setPrice)은 그 바깥에서 한 번만 호출한다.
  • API 키가 없거나 미국 장이 닫혀 있으면 서버가 자동으로 더미 데이터를 반환해, 실습 시간과 무관하게 항상 정상적으로 동작한다.
TIP이 레슨은 React & Next.js 커리큘럼의 마지막 단원입니다 🎉 — 01강의 "선언형 UI"부터 여기까지, JSX·state·훅·Context·Zustand·Next.js 라우팅·서버 컴포넌트·Tailwind·실시간 통신까지 하나의 StockDash 앱에 전부 누적됐습니다. 전체 흐름이 헷갈리면 🧭 학습 여정 — React 패널에서 01~14단계를 처음부터 훑어보세요.
🎓

졸업 과제: StockDash 주식 대시보드 — 14개 단원 기술을 총동원해 직접 만든 풀스택 앱

캡스톤커스텀 훅API RoutesZustandWebSocketAI 협업

한 줄 요약수업 실습(lessons)과 별개로 진행한 졸업 과제 — 종목 검색·시세 카드·실시간 전광판·차트·뉴스·실적·추천까지 8개 기능을 갖춘 주식 대시보드를 직접 구현했고, 핵심 로직(커스텀 훅·API Route·Zustand 스토어)은 전부 내가 작성하고 UI/UX 개편만 AI에게 맡기는 역할 분담으로 완성했다.

쉽게 말하면14개 단원이 "부품 하나하나를 만들어보는 실습"이었다면, 이 과제는 그 부품들을 전부 꺼내 "진짜 자동차 한 대를 조립"해본 경험입니다 — 03강의 useEffect, 04강의 커스텀 훅, 09강의 API Route, 10강의 Zustand persist, 14강의 WebSocket이 한 앱 안에서 실제로 맞물려 돌아갑니다. 그리고 도색(스타일링)은 정비사(AI)에게 맡기되 "엔진(로직)은 절대 건드리지 마세요"라는 계약서를 먼저 쓰고 진행했습니다.

무엇을 직접 만들었나 — 기능 8개의 핵심 로직

종목 검색(useDebounce로 입력 지연 → /api/search 호출), 시세 카드(useStockData 커스텀 훅), 관심종목(Zustand + persist), 실시간 전광판·차트(useLiveTicker의 WebSocket 다중 구독), 기업/시장 뉴스·실적·추천(각각 별도 API Route) — 이 핵심 로직들은 전부 빈칸 채우기와 힌트 방식으로 직접 작성했습니다. 과정에서 버그 6건(전광판 애니메이션 인터리빙, 차트 레이스 컨디션, Hooks 규칙 위반 2건, 해외 종목 응답 검증 누락, recommendation 라우트 오사용)도 직접 잡았고, 전부 CODE_REVIEW.md에 Before/After로 기록해뒀습니다.

가장 애먹은 지점 — fetch 응답은 "데이터"가 아니라 "껍데기"다

/api/search를 만들 때 Finnhub 응답에 바로 .map()을 걸었다가 "그런 메서드가 없다"는 에러를 세 번 만나며 알아낸 것: fetch()가 주는 건 실제 데이터가 아니라 Response 객체(껍데기)라서 await res.json()으로 한 번 벗겨야 하고, Finnhub는 그마저도 배열을 바로 주지 않고 { count, result } 객체 안에 한 겹 더 감싸서 줍니다. 외부 API를 쓸 때는 "응답이 정확히 어떤 모양인지"부터 확인하는 습관이 이때 생겼습니다.

AI와의 역할 분담 — "로직 보존" 규칙을 먼저 정하고 시작

UI/UX 전면 개편(반응형·접근성·다크 테마 통일)은 AI(Claude Code)에게 맡기되, 시작 전에 "useEffect/fetch/상태 로직/API 응답 모양은 손대지 말고 스타일링만 허용"이라는 규칙을 먼저 정했습니다. 매 라운드가 끝날 때마다 브라우저로 직접 눌러보며 동작이 그대로인지 확인했고, AI 쪽에서도 diff 전수 대조와 eslint 결과 비교로 로직이 한 줄도 안 바뀌었음을 교차 검증했습니다. 작업 인수인계는 HANDOFF(맡길 때)/HANDBACK(돌려받을 때) 문서 한 쌍으로 관리해, 어느 세션이 이어받아도 현재 상태를 정확히 알 수 있게 했습니다.

제출 이후 추가 — "미국장이 닫혀 있으면 화면이 멈춘다" 문제와 코인 대응

과제를 마치고 나서 남은 아쉬움이 하나 있었습니다. 한국 시간 낮에는 미국장이 닫혀 있어서 실시간 전광판을 켜도 숫자가 하나도 안 움직인다는 것 — 실시간 기능을 만들어 놓고 정작 자랑할 수가 없죠. 그래서 24시간 거래되는 코인을 끼워 넣었습니다. 검색어에 bit/btc가 들어오면 Finnhub 주식 검색에는 안 걸리는 BINANCE:BTCUSDT를 결과 맨 앞에 직접 하나 붙이고, 차트 API에서는 심볼에 콜론(:)이 있으면 코인으로 판정해 "미국장 개장 여부" 검사를 건너뛰게 했습니다(if (!apiKey || (!isCrypto && !isUSMarketOpen()))). 외부 API의 심볼 명명 규칙 자체를 분기 조건으로 쓴 첫 경험입니다.

그 과정에서 드러난 7번째 버그 — 빠른 체결이 flash를 잡아먹는다

코인을 붙이자 곧바로 새 버그가 드러났습니다. 가격이 바뀌면 0.5초간 색이 번쩍이게 해 뒀는데, BTC는 체결이 0.5초보다 훨씬 빠르게 연달아 들어옵니다. 그러면 이전 체결이 걸어 둔 "flash 끄기" 타이머가 방금 켠 flash를 조기에 꺼 버려서 화면에 색이 거의 안 보였습니다. 미국 주식으로 테스트할 때는 체결 간격이 느려 절대 드러나지 않던 문제예요. 해결은 useRef심볼별 타이머 id를 기억해 두었다가, 새 체결이 오면 이전 타이머를 먼저 clearTimeout으로 취소하고 다시 거는 것 — 14강 WebSocket과 04강 useRef가 정확히 이 지점에서 만났습니다.

JS src/hooks/useLiveTicker.js — 심볼별 flash 타이머 경합 해결
// 심볼별로 "flash를 끄는 타이머 ID"를 기억해두는 상자
const flashTimersRef = useRef({})

// ...체결 메시지가 올 때마다
// 이 종목에 걸려있던 "이전 flash 끄기" 타이머가 아직 안 끝났다면 취소.
// (취소 안 하면 오래된 타이머가 방금 켠 flash를 조기에 꺼버린다)
clearTimeout(flashTimersRef.current[trade.s])

flashTimersRef.current[trade.s] = setTimeout(() => {
  // 0.5초 뒤 flash 해제
}, 500)
JS src/app/api/search/route.js — 직접 작성한 Finnhub 응답 파싱
const apiKey = process.env.FINNHUB_API_KEY
const APISTOCK = await fetch(
  `https://finnhub.io/api/v1/search?q=${q}&token=${apiKey}`,
  { cache: 'no-store' }
)
const data = await APISTOCK.json() // Response 껍데기를 진짜 데이터로
const STOCKS = data.result         // 진짜 배열은 { count, result } 한 겹 더 안에 있었음
let results = STOCKS
  .map((s) => ({ symbol: s.displaySymbol, name: s.description, type: s.type }))
  .slice(0, 5) // Finnhub 필드명을 프로젝트 공용 이름(symbol, name)으로 통일 + 상위 5개만
JS src/hooks/useLiveTicker.js — WebSocket 다중 종목 구독/해제
useEffect(() => {
  const ws = new WebSocket(`wss://ws.finnhub.io?token=${token}`)
  ws.onopen = () => {
    // 14강은 종목 1개 구독이었지만, 전광판은 관심종목 "여러 개"를 한 소켓으로 구독
    symbols.forEach((sym) => ws.send(JSON.stringify({ type: 'subscribe', symbol: sym })))
  }
  ws.onmessage = (event) => { /* 체결가로 prices 갱신 */ }
  return () => {
    // 정리도 종목 수만큼: 서버 쪽 구독까지 해제한 뒤에 소켓을 닫는다
    if (ws.readyState === WebSocket.OPEN)
      symbols.forEach((sym) => ws.send(JSON.stringify({ type: 'unsubscribe', symbol: sym })))
    ws.close()
  }
}, [symbols])
핵심 정리
  • 커리큘럼에서 배운 기술(커스텀 훅·API Routes·Zustand persist·서버/클라이언트 컴포넌트·Tailwind 다크모드·WebSocket)이 한 앱 안에서 전부 실전 조합됐다.
  • 외부 API 응답은 await res.json()으로 벗기고, 실제 데이터가 어느 깊이에 있는지(data.result) 확인한 뒤에 배열 메서드를 건다.
  • AI와 협업할 때는 "어디까지 맡기는지" 경계(로직 보존 규칙)를 먼저 문서로 정하고, 매 단계 결과를 직접 실행해보며 검증한다.
  • 직접 잡은 버그 6건은 전부 Before/After로 문서화 — 버그는 고치는 것보다 "왜 그랬는지 기록"이 다음에 더 큰 자산이 된다.
  • 제출 이후에도 코인(24시간 거래) 대응을 붙이며 테스트 환경이 달라져야만 드러나는 버그(빠른 체결 → flash 타이머 경합)를 만났다 — 느린 데이터로만 테스트하면 절대 안 보이는 종류의 문제다.
TIP이 과제의 판단 근거는 전부 제출 문서 3종에 있습니다 — README(기여도·핵심 코드 명세), CODE_REVIEW(버그 6건 Before/After), PROMPT_LOG(AI 협업 사례 19건). 소스 23개 파일에는 학습용 주석(파일 헤더·훅 요소별 해설)이 전면 정리돼 있어, 시간이 지난 뒤 다시 읽어도 코드 흐름을 따라갈 수 있습니다 — 저장소에 올라가 있는 것이 이 주석본이며, 제출용으로 주석을 걷어낸 버전은 따로 보관하지 않습니다(필요하면 언제든 다시 만들 수 있으니, 오래 남는 쪽은 읽기 좋은 주석본으로 통일했습니다).

🧭 학습 여정 — 과목별 복습

과목 탭을 하나씩 넘기며 카드를 볼 땐 이해가 돼도 "그래서 전체 흐름이 뭐지?"는 잘 안 잡힐 수 있습니다. 아래에서 과목을 고르면 그 과목의 레슨을 커리큘럼 순서 그대로, "이전에 뭘 배웠고 이번에 뭐가 새로 추가되는지"만 따라가며 복습할 수 있습니다.

💡 이 탭을 보는 법

HTML 레슨은 React와 달리 레슨마다 독립된 단일 파일이라 "파일이 파일을 부르는" 그림은 그릴 게 없습니다. 대신 여기서는 "이전 레슨에서 배운 태그·개념이 이번 레슨에서 어떻게 다시 쓰이고 확장되는지"를 순서대로 짚습니다. 상자 = 배운 개념 하나, 화살표 = "그걸 가지고 다음에 무엇을 하는지"라는 뜻입니다. 각 단계 끝의 전체 카드 보기를 누르면 그 레슨의 실제 코드와 자세한 설명으로 바로 이동합니다.

01

문서의 뼈대 — 아직 태그가 한 겹뿐인 단계

전체 카드 보기 →

모든 게 시작되는 지점입니다. DOCTYPE·html·head·body라는 고정된 뼈대 하나만 외우면 되고, 그 안에 h1~h6·p로 제목과 문단을 넣는 것뿐입니다. 아직 "요소끼리 어떻게 배치되는지"는 등장하지 않습니다 — 이건 바로 다음 단계의 주제입니다.

DOCTYPE·html문서 전체를 감싸는 뼈대
그 안에
head화면에 안 보이는 메타정보
그 안에
body실제로 그려지는 콘텐츠
제목·문단
h1~h6 · p숫자가 작을수록 큰 제목
핵심 요소
  • DOCTYPE → html → head/body — 이후 모든 HTML 실습 파일이 예외 없이 따르는 고정 순서.
  • head(안 보임)와 body(보임)의 구분은 02단계 이후 "화면에 어떻게 보이는지"를 이야기할 때 계속 전제로 깔립니다.
02

블록 vs 인라인 — "화면 배치" 감각의 시작

전체 카드 보기 →

01단계에서 배운 h1·p·div 같은 태그들이 화면에서 어떻게 자리를 차지하는지가 처음 등장합니다. 이 "block은 줄바꿈되고 가로 폭 전체를, inline은 내용 크기만큼만"이라는 감각은 이후 04단계(표를 div로 흉내내기)에서 CSS로 이 성질 자체를 바꿔볼 때 다시 쓰입니다.

div·h1·ul·liblock — 줄바꿈 + 가로 폭 전체
spaninline — 내용 크기만큼, 문장 속에 흐름
핵심 요소
  • block 요소는 구조·그룹, inline 요소는 문장 안의 일부를 꾸미는 용도.
  • 이 성질은 태그 종류로 고정된 게 아니라 CSS display 속성으로 나중에 바꿀 수 있다는 게 04단계로 이어지는 복선입니다.
03

목록 요소 — HTML+CSS+JS가 처음 한 파일에서 만나는 지점

전체 카드 보기 →

01~02단계는 순수 HTML(+태그 배치 감각)이었다면, 여기서 처음으로 같은 파일 안에 CSS 선택자JS 이벤트가 함께 등장합니다. ul>li 중첩 구조(HTML) → 자식 선택자로 숨김(CSS) → 클릭 시 display 토글(JS), 이 세 단계 조합이 06~07단계의 표 실습에서도 "구조를 먼저 만들고 CSS로 다듬는다"는 순서로 반복됩니다.

ul>li중첩된 서브메뉴 구조 (HTML)
CSS로
li>ul{display:none}처음엔 숨김
클릭 시 JS로
style.display="block"querySelectorAll로 찾아 펼침
핵심 요소
  • li 안에 ul을 넣으면 중첩(서브) 목록 — 02단계의 block 요소(ul·li)가 겹겹이 쌓인 구조.
  • "구조는 HTML, 초기 상태는 CSS, 상호작용은 JS"라는 3단 조합이 이후 실습 전반의 기본 패턴.
여기서 감 잡아두면 좋은 것 — 화면이 "왜 이렇게 보이는지" 헷갈릴 땐 항상 이 순서(HTML 구조 → CSS 초기 상태 → JS가 뭘 바꾸는지)로 나눠서 하나씩 확인하면 됩니다.
04

표 만들기 — 02단계의 "배치 성질"을 CSS로 다시 쓰는 단계

전체 카드 보기 →

table·tr·th/td로 진짜 표를 만드는 것과 별개로, 02단계에서 "block/inline은 태그 종류로 고정"이라고 배웠던 감각이 여기서 뒤집힙니다 — display: table 계열 속성을 주면 div도 표처럼 줄·칸을 맞춰 렌더링됩니다. 이때 처음 등장하는 @media는 06~07단계의 표 실습에는 없는, 04단계만의 반응형 확장입니다.

table>tr>th/td진짜 표 구조
영역 구분
thead/tbody/tfoot머리글·본문·꼬리글
병합
colspan가로로 여러 칸 합치기
div원래는 그냥 block(02단계)
display:table 계열
표처럼 보이는 divtable-row·table-cell
@media(좁아지면)
display:block세로로 풀림 (반응형)
핵심 요소
  • colspan으로 셀을 가로 병합하는 감각은 06~07단계에서 rowspan과 함께 실전 서식에 그대로 응용됩니다.
  • div로 만든 가짜 표는 접근성 도구에 진짜 표로 인식되지 않으므로, 진짜 데이터 표라면 table을 씁니다.
05

인용·목록·구분선 — 표를 잠시 떠나 "의미를 담는 태그"로

전체 카드 보기 →

04단계까지는 "구조를 어떻게 짜는가"에 집중했다면, 여기서는 잠깐 표를 떠나 같은 내용도 의미가 다르면 다른 태그를 쓴다는 시맨틱 감각을 다집니다. q(인용)·hr(구분)·address(연락처)는 전부 "이게 뭘 뜻하는지" 브라우저·검색엔진에 알려주는 태그로, 이 감각은 다음 06~07단계에서 표 태그를 고를 때도 이어집니다.

q짧은 인라인 인용, 따옴표 자동
구간 나눔
hr수평 구분선
마지막
a[target=_blank] · address새 탭 링크 + 연락처 의미
핵심 요소
  • q는 인라인, blockquote는 블록 — 02단계의 block/inline 구분이 여기서도 태그 선택 기준이 됩니다.
  • "의미가 맞는 태그를 고른다"는 감각은 06~07단계에서 표를 실제 양식(서식)으로 쓸 때도 그대로 적용됩니다.
06

인적사항 표 — 04단계의 병합을 실전 서식에 응용

전체 카드 보기 →

04단계에서 배운 colspan(가로 병합)에 rowspan(세로 병합)이 더해집니다. 두 병합을 조합하면 사진 칸처럼 여러 줄에 걸친 영역도 표 안에서 자연스럽게 표현할 수 있다는 게 이번 실습의 핵심이고, 이 조합은 다음 07단계에서 한 번 더 확장됩니다.

표 틀 (04단계)table>tr>td
세로 병합
rowspan="6"사진 칸 — 6개 행에 걸침
가로 병합
colspan="3""(한문)" 넓은 입력칸
핵심 요소
  • rowspan="N"은 세로로 N개 행 병합, colspan="N"은 가로로 N개 칸 병합 — 04단계에서 colspan만 봤다면 여기서 rowspan이 짝을 이룹니다.
  • rowspan으로 병합된 셀 아래 행은 그 열의 td를 아예 적지 않아야 표가 어긋나지 않습니다.
07

경력사항 표 — 병합 + 클래스 재사용으로 마무리

전체 카드 보기 →

06단계의 병합 감각에 CSS 클래스로 크기를 통일해 반복 배치하는 실전 패턴이 더해지며 HTML 여정이 마무리됩니다. 좁은 항목 칸(회사명·근무기간)과 넓은 서술 칸을 한 표에 섞고, 같은 표를 3번 반복해도 .qwe·.asd 클래스 덕분에 크기가 흐트러지지 않습니다 — 지금까지 배운 구조(표)·배치(병합)·재사용(클래스)이 한 실습에 모입니다.

항목 행 (좁은 칸×4)회사명·근무기간 등
colspan="3"
주요업무넓은 입력칸
colspan="4"
서술 행표 전체 너비
.qwe / .asd 클래스
3번 반복해도 크기 통일회사별 표를 나열
핵심 요소
  • colspan 합이 실제 열 개수(4)와 맞는지 항상 검산하는 습관이 표 실습 전체(04·06·07단계)에서 공통으로 중요합니다.
  • 여기까지가 HTML 7단계 — 다음은 CSS 여정에서 이 태그들에 본격적으로 스타일을 입히는 과정으로 이어집니다.
HTML 여정을 마치며 — 01(뼈대) → 02(배치) → 03(구조+상호작용) → 04(표+반응형) → 05(시맨틱) → 06~07(병합+재사용) 순서로 감각이 하나씩 쌓였습니다. 헷갈리는 카드가 있으면 이 순서를 되짚어 어느 단계 감각이 빠졌는지 확인해보세요.

기초 개념 사전

수업 진도와 상관없이, 자바스크립트·리액트의 핵심 용어와 자주 만나는 버그를 하나씩 독립적으로 찾아볼 수 있는 개인 학습용 사전입니다. 처음이라면 아래 읽는 순서부터 따라가 보세요. 맨 아래 💬 그룹 E에는 수업 중 실제로 막혀서 했던 질문과 답을 그대로 정리해뒀습니다.

🧭 처음이라면 이 순서대로 읽어보세요

1
자바스크립트가 아직 낯설다면 먼저

리액트 코드를 읽기 전에 필요한 최소한의 JS 문법입니다. 이 8개만 눈에 익어도 리액트 예제 코드가 훨씬 편하게 읽힙니다.

2
리액트가 처음이라면 이 순서로

컴포넌트 → JSX → props → state 순서로 읽으면, "왜 이런 문법이 필요한지"가 자연스럽게 이어집니다.

3
실습하다 막히는 개념들

react-02·react-03 실습에서 실제로 부딪혔던 개념들입니다. 코드가 이해 안 될 때 이 순서로 다시 짚어보세요.

4
성능 최적화가 궁금하다면

react-04·react-06에서 다루는 성능 최적화 개념입니다. useState/useEffect에 익숙해진 다음 보면 이해가 더 쉬워요.

5
전역 상태·Next.js 라우팅·백엔드 연동이 궁금하다면

react-07~14에서 다루는 개념입니다. prop drilling 없이 값을 공유하는 Context API와 Zustand(+새로고침에도 남는 persist), Next.js의 폴더 = URL 라우팅·서버/클라이언트 컴포넌트 구분, route.js로 같은 프로젝트 안에 백엔드를 두는 API Routes, async 서버 컴포넌트가 Suspense와 함께 데이터를 스트리밍하는 방식, Tailwind CSS 유틸리티 클래스와 전역 다크모드 스위치, 그리고 REST와 WebSocket의 차이까지 정리했습니다.

6
버그를 만났다면 (증상별로 바로 찾기)

"화면이 멈췄다", "저장한 데이터가 사라졌다", "값이 이상하게 옛날 값 같다" — 증상이 있다면 바로 여기부터 읽으세요.

A · JS 기초 문법 — 자바스크립트가 아직 낯설다면

01

변수와 스코프 — let · const · var

let/constvar블록 스코프

한 줄 요약변수는 데이터를 담는 상자이고, let·const는 중괄호 {} 블록 단위로만 유효한 반면 var는 함수 전체에서 유효해 의도치 않게 값이 새어나가는 문제가 있어 최신 코드는 let·const만 사용한다.

쉽게 말하면변수는 이름표가 붙은 상자예요. let은 내용물을 바꿀 수 있는 상자, const는 한 번 넣으면 못 바꾸는 봉인된 상자입니다. var는 옛날 상자라 방(블록) 밖에서도 존재가 새어나가서 요즘은 거의 안 씁니다.

let · const · var, 두 가지 기준으로 나눠보면

셋의 차이는 사실 서로 다른 두 가지 질문에 대한 답이 겹쳐서 헷갈립니다 — "값을 다시 넣을 수 있나?"와 "어디까지 유효한가?"를 따로 물어보면 훨씬 명확해집니다.

  • 재할당 가능 여부let은 가능(let x = 1; x = 2 OK), const는 불가능(const x = 1; x = 2는 에러), var는 가능.
  • 유효 범위(스코프)let·const{ } 블록 안에서만 존재(블록 스코프), var는 그 블록을 무시하고 함수 전체에서 존재(함수 스코프).

비유하면 const는 "뚜껑을 용접해 봉인한 상자"(내용물 자체는 못 바꿈), let은 "뚜껑을 열고 닫을 수 있는 상자"이고, var는 이 두 상자와 별개로 "방 벽을 무시하고 복도까지 삐져나오는 상자"라고 생각하면 됩니다 — 그래서 var는 안이 아니라 밖 어디서든 손이 닿아버려 위험합니다.

블록 스코프 vs 함수 스코프

let/const{ } 블록 단위로만 존재하는 블록 스코프라서 if문·for문 안에서 선언하면 그 밖에서는 접근할 수 없습니다. 반면 var는 함수 전체에서 유효한 함수 스코프라서, 블록 안에서 선언해도 밖으로 값이 새어나가는 예상치 못한 동작이 생길 수 있습니다. 그래서 최신 코드는 var를 쓰지 않습니다.

const는 "재할당"만 금지할 뿐, 배열/객체 내부 값은 여전히 바꿀 수 있습니다 — const arr = []; arr.push(1)은 가능하지만 arr = [](새 배열로 재할당)는 에러입니다. 이 구분은 뒤에 나올 "불변성" 개념과 바로 연결됩니다.

핵심 정리
  • let/const는 블록{} 스코프, var는 함수 스코프 — var는 블록 밖으로 값이 새어나갈 수 있어 지금은 쓰지 않는다.
  • const는 재할당만 금지 — 배열/객체 내부 값(속성, 요소)은 여전히 바꿀 수 있다.
  • 기본은 const, 값을 재할당해야 할 때만 let을 쓴다.
02

함수 선언 vs 화살표 함수

function=>암묵적 반환

한 줄 요약function으로 만드는 일반 함수와 화살표 함수(=>)는 거의 같은 역할을 하지만, 화살표 함수는 본문이 한 줄이면 return과 중괄호를 생략할 수 있어 리액트 이벤트 핸들러·콜백에서 즐겨 쓰인다.

쉽게 말하면화살표 함수는 function의 짧은 표기법이에요. function(x){ return x * 2 }x => x * 2로 줄여 쓰는 것뿐입니다. 요즘 리액트 코드는 거의 항상 화살표 함수를 씁니다.

x => x * 2, 세 조각으로 뜯어보면

const double = x => x * 2 한 줄에도 이름 붙이기·매개변수·반환값이 섞여 있습니다.

  • const double = — "이 함수에 double이라는 이름표를 붙인다"는 뜻(함수 자체는 이름이 없는 익명 함수).
  • x => — "x라는 매개변수를 하나 받는다"는 뜻. 매개변수가 없으면 () =>, 여러 개면 (a, b) =>처럼 괄호가 필요합니다.
  • x * 2 — 화살표 뒤에 오는 이 값이 return 없이도 자동으로 반환됩니다(암묵적 반환) — 딱 표현식 하나일 때만 가능합니다.

비유하면 화살표 함수는 "자판기 사용 설명서를 짧게 줄인 버전"이에요 — function(x){ return x*2 }가 "동전을 넣으면(x), 두 배로 만들어서 반환하시오"라고 풀어 쓴 설명서라면, x => x * 2는 그걸 한 줄로 압축한 것뿐, 하는 일은 완전히 같습니다.

화살표 함수 문법 정리

매개변수가 1개면 괄호를 생략할 수 있고(x => ...), 여러 개면 괄호가 필요합니다((a, b) => ...). 본문이 표현식 하나뿐이면 중괄호와 return을 생략할 수 있는데(암묵적 반환), 본문이 여러 줄이면 반드시 { }return을 명시해야 합니다.

onClick={() => setCount(count - 1)}처럼 "클릭하면 실행해줘"라는 뜻으로 화살표 함수를 이벤트 핸들러 자리에 감싸서 전달하는 패턴이 리액트에서 가장 자주 등장합니다 — 자세한 이유는 아래 무한 루프 항목 참고.

핵심 정리
  • 화살표 함수는 익명 함수를 짧게 쓰는 문법 — 본문 1줄이면 return 생략(암묵적 반환).
  • onClick 같은 이벤트 핸들러 자리에는 "실행 결과"가 아니라 "함수 자체"가 와야 한다.
  • 최신 리액트 코드는 거의 항상 화살표 함수를 사용한다.
03

배열 메서드 3형제 — map · filter · reduce

mapfilterreduceforEach

한 줄 요약map은 각 요소를 변형해 같은 길이의 새 배열을 반환하고, filter는 조건을 통과한 요소만 걸러 새 배열을, reduce는 배열 전체를 하나의 값으로 누적 계산한다 — 셋 다 원본 배열은 건드리지 않는다.

쉽게 말하면map은 "각 재료를 손질해서 새 접시에 담기", filter는 "체로 걸러서 통과한 것만 담기", reduce는 "재료를 전부 냄비에 넣고 졸여서 국물 하나로 만들기"예요. forEach는 그냥 "하나씩 확인만 하고 끝(반환값 없음)"입니다.

가장 헷갈리는 reduce, 매개변수 4개를 하나씩

arr.reduce((acc, item) => acc + item, 0)는 map/filter보다 조각이 많아 특히 헷갈립니다.

  • reduce(콜백, 0) — 첫 번째 자리는 매번 실행할 콜백 함수, 두 번째 자리(0)는 계산을 시작할 초기값입니다.
  • acc(누적값) — 지금까지 계산해 온 결과. 첫 실행 때는 초기값(0)에서 시작해, 실행할 때마다 콜백이 반환한 값으로 갱신됩니다.
  • item(현재 요소) — 배열을 순서대로 훑으며 지금 보고 있는 그 요소.

비유하면 reduce는 "롤링페이퍼 돌리기"예요 — 종이(acc)가 사람들(item) 손을 하나씩 거칠 때마다 한 줄씩 추가되어 점점 두꺼워지고, 마지막 사람을 거치면 완성된 종이 한 장(최종 결과)만 남습니다.

변환 · 선택 · 누적

map(item => ...)은 변환(길이 유지), filter(item => 조건)은 선택(길이가 줄어들 수 있음), reduce((누적, item) => ..., 초기값)은 배열 전체를 합계·개수 같은 값 하나로 누적합니다. 셋 다 콜백이 매번 새 배열/값을 "반환"할 뿐 원본 배열은 손대지 않기 때문에, 나중에 배우는 state 불변성 업데이트(불변성이란?)의 핵심 도구가 됩니다.

forEach는 그냥 순회용이라 반환값이 없어(undefined) 체이닝이 안 되고, 화면을 그리는 리스트 렌더링에는 항상 map을 씁니다.

핵심 정리
  • map: 변환(길이 유지) / filter: 선택(길이 줄어듦) / reduce: 누적(하나의 값).
  • 셋 다 원본 배열을 바꾸지 않고 새 배열/값을 반환한다 — todos.filter(...), todos.map(...) 참고.
  • forEach는 그냥 순회용, 반환값이 없어 리스트 렌더링에는 map을 쓴다.
04

구조분해 할당(Destructuring)

배열 구조분해객체 구조분해props

한 줄 요약구조분해 할당은 배열이나 객체의 값을 한 번에 여러 변수로 꺼내 담는 문법으로, useState의 [값, 변경함수]나 props의 {name, job}처럼 리액트 코드 어디서나 등장한다.

쉽게 말하면선물 상자를 열어서 안의 물건들을 이름표 붙여 바로 꺼내는 것과 같아요. const [a, b] = [1, 2]는 순서대로 꺼내기, const {name} = obj는 "name"이라고 적힌 물건만 콕 집어 꺼내기입니다.

{ job: userJob = "미정" }, 이름 바꾸기+기본값까지 한 줄에

객체 구조분해는 이름표를 다시 붙이거나(rename) 없을 때 쓸 값(기본값)까지 한 줄에 압축할 수 있어 처음엔 낯설게 보입니다.

  • job — obj 안에서 꺼낼 원래 키 이름. 이 이름은 obj 쪽 이름과 반드시 똑같아야 합니다.
  • : userJob — 꺼낸 값을 앞으로 userJob이라는 새 변수 이름으로 쓰겠다는 뜻(원래 이름 job은 이제 안 씀).
  • = "미정" — obj에 job 자체가 없을 때(undefined일 때)만 사용할 기본값.

비유하면 "택배 상자에서 물건을 꺼내면서, 동시에 다른 이름표를 새로 붙이고(rename), 그 물건이 아예 안 들어있으면 대신 쓸 예비 물건(기본값)을 미리 정해두는 것"과 같아요 — 세 가지 일을 한 줄에서 동시에 처리하는 것뿐, 순서대로 하나씩 읽으면 어렵지 않습니다.

배열 구조분해는 순서, 객체 구조분해는 이름

배열 구조분해는 "순서"가 기준입니다 — useState()가 [값, set함수] 배열을 반환하기 때문에 const [count, setCount] = useState(0)처럼 순서대로 이름을 붙입니다. 객체 구조분해는 "이름(키)"이 기준입니다 — function ProfileCard({ name, job })처럼 props 객체에서 필요한 키만 꺼내며, 순서는 상관없습니다. const { job = "미정" } = member처럼 기본값도 줄 수 있습니다.

핵심 정리
  • 배열 구조분해: 순서대로 이름 붙이기 — const [x, y] = arr.
  • 객체 구조분해: 키 이름으로 꺼내기, 순서 무관 — const {a, b} = obj.
  • useState()의 [값, set함수]와 props의 {name, ...} 모두 구조분해 문법이다.
관련 실습: react-02, react-03
05

스프레드 연산자(...)와 나머지 매개변수

전개 연산자rest불변성

한 줄 요약배열/객체 앞에 붙이는 ...은 "내용물을 통째로 펼쳐 복사"하는 스프레드 연산자로, [...todos, 새항목]처럼 원본을 안 건드리고 새 배열/객체를 만들 때(불변성 업데이트) 핵심적으로 쓰인다.

쉽게 말하면...은 "상자 안 내용물을 전부 꺼내 새 상자에 쏟아붓기"예요. [...todos, 새항목]은 기존 할 일들을 새 상자에 쏟고 하나를 더 얹는 것이고, {...t, done: true}는 기존 내용을 복사하면서 done 값만 바꿔치기하는 것입니다.

{...t, done: !t.done}, 복사와 덮어쓰기가 한 줄에

불변성 업데이트에서 가장 많이 쓰는 이 한 줄도, 사실은 서로 다른 두 동작이 이어붙은 것입니다.

  • {...t — t라는 객체의 모든 키·값을 통째로 복사해서 새 객체를 만들기 시작합니다.
  • , done: !t.done} — 그 복사본에서 done 키 하나만 새 값으로 덮어씁니다(나머지 키는 복사된 그대로 유지).

비유하면 "서류 뭉치를 통째로 복사기에 넣어 복사본을 만든 다음(...t), 그 복사본에서 도장 하나만 다시 찍는 것"(done: !t.done)과 같아요 — 원본 서류(t)는 손대지 않고 그대로 남아있습니다.

펼치기(스프레드) vs 모으기(rest)

값이 있는 자리의 ...은 "펼치기"입니다 — [...arr, x]는 arr을 복사하고 x를 추가한 새 배열, {...obj, key: 새값}은 obj를 복사하고 특정 키만 덮어쓴 새 객체를 만듭니다. 원본을 직접 고치지 않고 "복사본 + 변경"으로 새 값을 만들기 때문에 React state 불변성 규칙과 맞아떨어집니다(todos.map(t => ({...t, done: !t.done})) 패턴).

반대로 함수 매개변수 자리의 ...val은 "나머지 인자들을 배열로 모으는" rest 문법입니다 — 같은 ... 기호라도 위치(값 자리 vs 매개변수 자리)로 역할이 반대라는 점만 구분하면 됩니다.

핵심 정리
  • 배열/객체 앞의 ...: 내용물을 펼쳐서 복사 — [...arr], {...obj}.
  • {...t, done: !t.done}처럼 기존 값 복사 + 특정 키만 덮어쓰기로 불변성 업데이트를 한다.
  • 함수 매개변수 자리의 ...val은 반대로 "나머지 인자를 배열로 모으는" rest 문법이다.
06

삼항연산자와 단축 평가(&&)

삼항연산자&&조건부 렌더링

한 줄 요약조건 ? A : B는 "조건이 참이면 A, 거짓이면 B" 둘 중 하나를 고르는 삼항연산자이고, 조건 && A는 "조건이 참일 때만 A"를 고르는 단축 평가로, 둘 다 JSX 안에서 if문 대신 조건부 렌더링에 쓰인다.

쉽게 말하면삼항연산자(? :)는 "네/아니오 갈림길"이고, &&는 "통과했을 때만 보너스 지급"이에요. isOnline ? 온라인 : 오프라인은 항상 둘 중 하나를 보여주고, isLead && 리더뱃지는 리더일 때만 뱃지를 얹습니다.

물음표·콜론·&&, 기호별로 나눠보면

isOnline ? "온라인" : "오프라인"isLead && <뱃지 />도 기호 하나하나에 역할이 있습니다.

  • isOnline ? — "이 조건이 참인지 검사한다"는 신호.
  • "온라인" — 물음표 뒤, 콜론 앞: 참일 때 쓸 값.
  • : "오프라인" — 콜론 뒤: 거짓일 때 쓸 값. 삼항연산자는 이 둘 중 반드시 하나를 돌려줍니다.
  • isLead && <뱃지 /> — && 왼쪽이 참이면 오른쪽(뱃지)을 그대로 반환하고, 거짓이면 뱃지 자리에 아무것도 안 그립니다(콜론에 해당하는 "거짓일 때" 값이 아예 없는 것).

비유하면 삼항연산자는 "두 갈래 길 중 하나로 반드시 안내하는 이정표"이고, &&는 "통과 조건을 만족해야만 열리는 보너스 문"이에요 — 조건을 못 만족하면 그냥 아무 일도 안 일어납니다.

JSX 안에는 if문을 못 쓴다

JSX의 { }에는 값(표현식)만 들어갈 수 있고 if문 같은 "문장"은 넣을 수 없기 때문에, 조건부 렌더링은 항상 연산자로 처리합니다. 조건 ? A : B는 둘 중 반드시 하나, 조건 && A는 조건이 거짓이면 아무것도 렌더링하지 않습니다(정확히는 false를 반환하고, React는 false/null/undefined를 화면에 그리지 않습니다).

⚠️ 주의: && 왼쪽 값이 숫자 0이면 화면에 "0"이 그대로 찍히는 흔한 함정이 있습니다(0은 false와 달리 그대로 출력됨) — 조건을 count > 0처럼 명확한 불리언으로 쓰는 게 안전합니다.

핵심 정리
  • 조건 ? A : B: 둘 중 하나를 고르는 삼항연산자(if-else 대체).
  • 조건 && A: 조건이 참일 때만 그리는 단축 평가(if 없는 렌더링).
  • && 왼쪽이 숫자 0이면 "0"이 그대로 찍히는 함정 주의 — count > 0처럼 명확히 쓴다.
07

모듈 시스템 — import와 export

importexport모듈

한 줄 요약자바스크립트 파일(모듈)은 export로 다른 파일에 내보낼 값을 정하고 import로 다른 파일의 값을 가져와 쓰는데, React 프로젝트의 모든 파일(App.jsx, Counter.jsx 등)이 이 방식으로 서로 연결되어 있다.

쉽게 말하면모듈은 "각자 방을 따로 쓰는 팀원들"이에요. 방(파일) 안에서 만든 물건(변수·함수·컴포넌트)은 원래 그 방 안에서만 쓸 수 있는데, export라고 문에 붙여두면 다른 방에서 import로 빌려 갈 수 있습니다. React 프로젝트는 수십 개의 방(파일)이 이렇게 서로 빌리고 빌려주며 하나의 앱을 완성합니다.

import { useState } from 'react', 세 조각으로

매번 타이핑하는 이 한 줄도 조각내 보면 각각 뜻이 분명합니다.

  • import — "다른 파일(모듈)에서 뭔가를 가져온다"고 선언하는 키워드.
  • { useState } — 가져올 대상의 정확한 이름. 중괄호가 있다는 건 그 파일이 export { useState }처럼 "이름 붙여" 내보냈다는 뜻입니다.
  • from 'react'어디서 가져올지. 따옴표 안이 ./로 시작하면 내 프로젝트 파일, 아니면 설치된 라이브러리입니다.

비유하면 import/export는 "도서관에서 책 빌리기"예요 — export는 "이 책은 대출 가능"이라고 표지에 붙이는 스티커, import는 "그 책 제목(이름)과 서가 위치(경로)를 대고 빌려오는 것"입니다. 중괄호 없이 import App from './App.jsx'처럼 쓰면 "그 서가에서 대표 책 한 권을 통째로 빌려온다"(export default)는 뜻이 됩니다.

export default vs export { 여러 개 }

export default는 파일 하나당 딱 1개만 가능한 "그 파일의 대표 선수"(보통 컴포넌트 하나)이고, import할 때 이름을 자유롭게 바꿔 부를 수 있습니다(import Anything from './Foo'도 동작). 반면 export { useState, useEffect }처럼 이름 붙여 내보내는 것은 여러 개 가능하지만, import할 때는 정확히 같은 이름을 중괄호 { }로 감싸서 가져와야 합니다(import { useState } from 'react').

경로 규칙도 함께 기억하면 좋습니다 — import App from './App.jsx'처럼 ./로 시작하면 "같은 프로젝트 안의 내 파일"이고, import { useState } from 'react'처럼 ./가 없으면 "설치된 외부 라이브러리(node_modules)"를 의미합니다.

핵심 정리
  • export default: 파일당 1개, import 시 이름 자유. export { 이름 }: 여러 개 가능, import 시 정확히 같은 이름을 {}로.
  • './'로 시작하는 경로 = 내 프로젝트 파일, 없으면 = 설치된 라이브러리(node_modules).
  • 파일을 컴포넌트 단위로 쪼개고 import/export로 연결하는 것이 React 프로젝트의 기본 구조다.
08

옵셔널 체이닝(?.)과 null 병합 연산자(??)

?.??undefined 안전 접근

한 줄 요약?.(옵셔널 체이닝)은 앞의 값이 null이나 undefined이면 에러를 내지 않고 그냥 undefined를 반환해주는 안전장치이고, ??(null 병합 연산자)는 왼쪽 값이 null·undefined일 때만 오른쪽 기본값을 쓴다.

쉽게 말하면?.은 "문을 열기 전에 문이 있는지부터 확인하고, 없으면 그냥 포기하는" 안전한 손이에요. obj.a.b라고 쓰면 obj나 obj.a가 없을 때 바로 에러가 나서 앱이 멈추지만, obj?.a?.b라고 쓰면 중간에 없는 게 있으면 에러 대신 조용히 undefined만 돌려줍니다.

obj?.a?.b ?? 기본값, 물음표와 물음표는 다른 기호다

?.(물음표+점)과 ??(물음표 두 개)는 생김새가 비슷해 헷갈리지만 하는 일이 전혀 다릅니다.

  • obj?.a — obj가 null/undefined면 즉시 멈추고 undefined를 돌려줌(에러 방지용 "안전 접근").
  • ?.b — 앞 결과(obj?.a)가 또 null/undefined일 수 있으니 한 번 더 안전하게 접근.
  • ?? 기본값 — 지금까지 결과가 정확히 null/undefined일 때만 이 기본값으로 교체(0이나 빈 문자열은 그대로 유지 — || 와 다른 점).

비유하면 ?.는 "문 앞에 아무도 없으면 그냥 조용히 돌아오는 노크"이고, ??는 "정말 아무도 안 나왔을 때만(모른다는 대답조차 없을 때만) 대타를 세우는 것"이에요 — 0이라는 "대답은 있었다"는 신호까지 대타로 바꿔치기하진 않습니다.

실제로 이 사이트 코드에도 쓰인 패턴

target.closest("section.cat")?.id처럼(이 사이트 자체의 실제 코드) closest()가 아무것도 못 찾으면 null을 반환하는데, 그 뒤에 바로 .id를 붙이면 "null의 속성을 읽을 수 없다"는 에러가 납니다. ?.을 쓰면 앞이 null/undefined일 때 즉시 멈추고 undefined를 돌려주므로 에러 없이 넘어갈 수 있습니다. 함수 호출에도 쓸 수 있어서 obj.method?.()는 method가 존재할 때만 호출합니다.

??||와 비슷해 보이지만 다릅니다 — ||는 왼쪽이 0이나 빈 문자열처럼 "falsy"이기만 해도 오른쪽을 쓰지만, ??는 정확히 null 또는 undefined일 때만 오른쪽 기본값을 씁니다. 그래서 count || 10은 count가 0일 때도 10이 되어버리는 함정이 있지만, count ?? 10은 count가 0이면 0을 그대로 유지합니다.

핵심 정리
  • a?.b: a가 null/undefined면 에러 대신 undefined 반환(체이닝 중간에 없는 값이 있어도 안전).
  • a ?? b: a가 정확히 null/undefined일 때만 b 사용(0, ''도 그대로 유지 — ||와의 핵심 차이).
  • 값이 있을지 확실하지 않은 곳(검색 결과, DOM 탐색 결과 등)에 습관적으로 붙이면 "Cannot read property of undefined" 에러를 크게 줄일 수 있다.

B · React 핵심 개념 — 리액트가 처음이라면

09

컴포넌트란?

컴포넌트대문자 시작

한 줄 요약컴포넌트는 "JSX(화면 조각)를 반환하는 함수"로, 이름을 대문자로 시작해야 React가 HTML 태그가 아닌 내 컴포넌트로 인식하며, 여러 곳에서 재사용할 수 있는 화면의 최소 단위다.

쉽게 말하면컴포넌트는 붕어빵 틀이에요. 한 번 틀(함수)을 만들어 두면, 재료(props)만 바꿔서 여러 개의 붕어빵(화면)을 찍어낼 수 있습니다.

function ProfileCard(props) {...}, 세 부분으로

컴포넌트도 결국 함수이니, 일반 함수 보는 눈으로 그대로 뜯어보면 됩니다.

  • function ProfileCard — 함수 이름. 대문자로 시작해야 React가 "내가 만든 컴포넌트"로 인식합니다(소문자면 div같은 HTML 태그로 오해).
  • (props) — 이 함수가 받는 매개변수. 부모가 <ProfileCard name="김민준" />처럼 내려준 값들이 { name: "김민준" } 객체 하나로 여기 담깁니다.
  • return <div>...</div> — 이 함수가 "화면에 무엇을 그릴지" 돌려주는 결과물(JSX).

비유하면 컴포넌트는 붕어빵 틀(함수)이고, props는 그날그날 넣는 재료(팥·슈크림), return은 그 틀에서 실제로 찍혀 나오는 붕어빵(화면)입니다 — 틀은 하나여도 재료를 바꿔가며 여러 개를 찍어낼 수 있죠.

함수 컴포넌트와 이름 규칙

function ProfileCard(props) { return <div>...</div> } 형태로, 최근 리액트는 거의 전부 이 함수형 컴포넌트를 사용합니다. 이름은 반드시 대문자로 시작해야 하는데, 소문자로 쓰면 React가 실제 HTML 태그(div, span 등)로 착각해버립니다. 하나의 컴포넌트를 <ProfileCard />처럼 여러 번 호출해 카드 여러 개를 뿌릴 수 있습니다(react-02의 .map() 반복 렌더링과 연결).

핵심 정리
  • 컴포넌트 = JSX를 반환하는 함수, 이름은 대문자로 시작.
  • <MyComponent />처럼 여러 번 호출해 재사용할 수 있다.
  • 화면은 결국 컴포넌트들이 트리 구조로 조합된 것이다.
관련 실습: react-01, react-02
10

JSX란?

JSX중괄호 표현식Fragment

한 줄 요약JSX는 자바스크립트 안에 HTML과 비슷한 태그를 직접 쓸 수 있게 해주는 문법 확장으로, 중괄호 {}를 열면 그 안에 어떤 JS 표현식이든 그대로 끼워 넣을 수 있다.

쉽게 말하면JSX는 "자바스크립트 안에서 그리는 그림"이에요. <h1>{title}</h1>처럼 눈에 보이는 그대로 화면 구조를 쓸 수 있습니다.

<h1>{title}</h1>, HTML 부분과 JS 부분을 구분하기

JSX 한 줄 안에 "그냥 태그"와 "진짜 자바스크립트"가 섞여 있어서 어디까지가 뭔지 헷갈리기 쉽습니다.

  • <h1>, </h1> — HTML 태그처럼 생겼지만 실제로는 React.createElement 호출로 변환되는 화면 구조 표시입니다.
  • {title} — 중괄호 안은 순수 자바스크립트 영역. title이라는 변수의 현재 값이 그 자리에 그대로 출력됩니다.

비유하면 JSX는 "빈칸이 있는 편지지"예요 — 태그(<h1>)는 이미 인쇄된 편지지 틀이고, 중괄호 { }는 빈칸이라 매번 다른 값(title)을 채워 넣을 수 있는 자리입니다. 빈칸에는 변수든 계산식이든 "값이 나오는 것"이라면 뭐든 넣을 수 있어요.

{ }에는 표현식만, return은 하나로

중괄호 { } 안에는 변수, 함수 호출, 삼항연산자 등 "값을 만들어내는" 표현식만 넣을 수 있습니다(if/for 같은 문장은 불가 — 그래서 조건부 렌더링에 삼항/&&를 씁니다). return 안은 반드시 하나의 최상위 요소로 감싸야 하며, 굳이 태그를 추가하고 싶지 않으면 빈 태그 Fragment(<>...</>)를 씁니다. JSX는 결국 Vite/Babel이 React.createElement(...) 호출로 변환해주는 문법 설탕이라, 빌드 도구와 .jsx 확장자가 필요합니다.

핵심 정리
  • JSX의 {}는 어떤 JS "표현식"도 담을 수 있다(문장은 불가).
  • return은 반드시 하나의 요소로 감싸기 — 여러 개면 Fragment <>...</> 사용.
  • 결국 빌드 시 React.createElement 호출로 변환되는 문법 설탕이다.
관련 실습: react-02
11

props란?

props단방향읽기전용

한 줄 요약props는 부모 컴포넌트가 자식 컴포넌트에게 내려주는 데이터로, 자식은 이를 받아서 화면에 표시만 할 뿐 직접 수정할 수 없는 읽기 전용·단방향 데이터다.

쉽게 말하면props는 부모가 자식에게 건네는 "심부름 쪽지"예요. 자식은 쪽지 내용을 읽고 따를 뿐, 마음대로 고쳐 쓸 수는 없어요. 고치고 싶으면 부모에게 다시 부탁해야 합니다.

속성 → 객체 → 구조분해, props가 흘러가는 3단계

props는 부모가 쓰는 문법과 자식이 받는 문법이 서로 다른 모습이라 연결이 잘 안 보일 수 있습니다.

  • ① <ProfileCard name="김민준" job="개발자" /> — 부모가 태그 속성처럼 값을 적어 내려줍니다.
  • ② { name: "김민준", job: "개발자" } — React가 그 속성들을 자동으로 객체 하나로 묶어 자식 함수에 전달합니다.
  • ③ function ProfileCard({ name, job }) — 자식은 그 객체를 구조분해로 낱개 변수(name, job)로 풀어서 씁니다.

비유하면 부모가 심부름 쪽지에 항목을 하나씩 적으면(①), 그 쪽지 전체가 봉투에 담겨 전달되고(②), 자식은 봉투를 열어 항목별로 꺼내 확인하는 것(③)과 같아요 — 쪽지 내용은 읽을 수만 있고, 자식이 직접 줄을 그어 고칠 순 없습니다.

부모→자식, 한 방향으로만

<ProfileCard name="김민준" />처럼 속성 형태로 값을 내려주면, 자식 함수는 이를 하나의 객체 { name, job }로 받아 구조분해로 낱개 변수화합니다. props는 항상 부모 → 자식 한 방향으로만 흐르며, 자식이 값을 바꾸고 싶다면 부모가 내려준 "함수(콜백)"를 props로 받아 호출하는 식으로 간접적으로만 요청할 수 있습니다. props(남이 준 읽기 전용 값)와 state(내가 직접 소유·변경하는 값)의 구분이 리액트에서 가장 중요한 개념 중 하나입니다.

핵심 정리
  • props는 부모→자식으로만 흐르는 읽기 전용 데이터다.
  • 자식은 ({name, job}) 구조분해로 받아서 표시만 하고, 직접 수정하지 않는다.
  • 값을 바꾸고 싶으면 부모가 내려준 함수를 호출해 요청한다 — 데이터 소유자는 항상 부모다.
관련 실습: react-02
12

children prop과 컴포지션

children컴포지션

한 줄 요약children은 컴포넌트 태그 사이에 끼워 넣은 내용을 자동으로 받는 특별한 prop으로, <Card>내용</Card>처럼 컴포넌트를 마치 HTML 태그처럼 감싸 쓸 수 있게 해주며, 이렇게 컴포넌트를 조합하는 방식을 컴포지션이라고 부른다.

쉽게 말하면children은 "상자 안에 무엇을 넣을지는 상자를 여는 사람이 정하는" 특별한 props예요. <Card><p>안녕</p></Card>처럼 태그 사이에 뭔가를 넣으면, Card 컴포넌트는 그 내용을 props.children이라는 이름으로 자동으로 받아서 원하는 자리에 그려줄 수 있습니다.

<Card>내용</Card>, "이름표 없는 props"가 전달되는 과정

다른 props는 이름(name=...)을 붙여 전달하는데, children만 이름 없이 태그 사이에 그냥 씁니다 — 이 차이가 헷갈림의 원인입니다.

  • <Card><p>안녕</p></Card> — 여는 태그와 닫는 태그 사이에 적은 내용.
  • function Card({ children }) — 자식 컴포넌트는 그 내용을 children이라는 이름으로 자동 전달받습니다(직접 이름 붙일 필요 없음).
  • {children} — Card 내부에서 원하는 위치에 이 자리표시자를 넣으면, 그 자리에 안겨온 내용(<p>안녕</p>)이 그대로 그려집니다.

비유하면 children은 "속이 빈 선물 상자"예요 — Card는 상자(포장·테두리·그림자 같은 겉모습)만 만들고, 그 안에 무엇을 넣을지는 상자를 쓰는 사람이 그때그때 정합니다. 같은 상자(Card)에 매번 다른 선물(내용)을 넣어 재사용하는 거죠.

태그 사이의 내용 = props.children

일반 props는 <Card title="제목" />처럼 속성으로 전달하지만, 태그 사이(<Card>...여기...</Card>)에 적은 내용은 자동으로 props.children에 담겨 전달됩니다. function Card({ children }) { return <div className="card">{children}</div> }처럼 원하는 위치에 {children}을 넣어 그리면 됩니다. 이 방식을 "컴포지션(합성)"이라 부르는데, 똑같은 Card 컴포넌트 틀 안에 상황마다 다른 내용을 자유롭게 끼워 넣을 수 있어 재사용성이 높아집니다 — HTML의 <div>가 어떤 내용이든 담을 수 있는 것과 비슷한 원리입니다.

다음 커리큘럼의 Context API도 <ThemeContext.Provider>{children}</ThemeContext.Provider>처럼 Provider가 children을 감싸는 형태로 동작하므로, children 개념을 먼저 알아두면 그때 훨씬 이해가 쉽습니다.

핵심 정리
  • 태그 사이에 넣은 내용은 자동으로 props.children에 담긴다(다른 props처럼 이름 붙여 전달할 필요 없음).
  • children을 받아 원하는 자리에 {children}으로 그리면, 컴포넌트를 "내용을 담는 그릇"처럼 재사용할 수 있다(컴포지션).
  • 다음에 배울 Context API의 Provider 패턴도 children을 감싸는 동일한 방식이다.
13

state와 useState란?

useState리렌더링게으른 초기화

한 줄 요약state는 컴포넌트가 스스로 기억하고 직접 바꾸는 값으로, useState(초기값)을 호출하면 [현재값, 변경함수] 쌍을 돌려주며 변경함수를 호출할 때마다 그 컴포넌트가 다시 실행(리렌더링)되어 화면이 최신 값으로 갱신된다.

쉽게 말하면state는 컴포넌트 안의 "메모장"이에요. props가 남이 적어준 쪽지라면, state는 내가 직접 들고 다니며 수정하는 메모장입니다. 메모장을 고치면(setState) React가 알아서 화면을 다시 그려줘요.

useState / count / setCount, 셋의 역할이 다르다

const [count, setCount] = useState(0) 한 줄에 서로 다른 역할을 하는 세 가지가 섞여 있어서 헷갈리기 쉽습니다. 하나씩 나눠보면:

  • useState(0) — "0에서 시작하는 값이랑, 그 값을 바꿀 함수를 세트로 만들어줘"라고 리액트에 요청하는 훅(생성 도구)입니다. 이 훅 자체는 화면을 다시 그리지 않습니다.
  • count — 그 요청으로 받은 현재 값. 지금 화면에 보이는 숫자입니다(읽기 전용처럼 다루세요).
  • setCount — 값을 진짜로 바꾸고 화면을 다시 그리게 만드는 실행 함수. setCount(5)처럼 직접 호출해야만 count가 5로 바뀌고 컴포넌트가 다시 실행됩니다.

비유하면 useState는 "TV+리모컨 세트를 사 오는 일", count는 "지금 화면에 나오는 채널", setCount는 "리모컨의 채널 변경 버튼"입니다. 버튼(setCount)을 눌러야만 채널(count)이 바뀌고 화면이 바뀝니다 — 리모컨을 사 온 것(useState) 자체는 아무것도 바꾸지 않아요.

set함수를 호출해야만 리렌더링된다

setCount(새값)을 호출하면 리액트가 count를 바꾸고 컴포넌트 함수 전체를 처음부터 다시 실행(리렌더링)해 최신 값 기준으로 화면을 재계산합니다 — 변수를 count = 5처럼 직접 바꾸면 리렌더링이 안 일어나 화면이 그대로입니다. 여기서 "다시 그린다"는 건 브라우저가 F5로 통째로 새로고침되는 게 아니라, 바뀐 부분만 화면에서 다시 그려지는 것(리렌더링)을 뜻합니다.

useState(() => 복잡한계산)처럼 함수를 넘기는 게으른 초기화는 그 함수가 최초 렌더링 때 딱 1번만 실행되어, localStorage 읽기처럼 비용이 큰 계산을 매 렌더마다 반복하는 낭비를 막습니다.

핵심 정리
  • useState는 [값, 변경함수] 세트를 만드는 도구, count는 현재 값, setCount는 값을 바꾸고 리렌더링을 일으키는 실행 함수 — 셋의 역할이 다르다.
  • useState(초기값) → [현재값, 변경함수] 배열을 구조분해로 받는다.
  • set함수를 호출해야만 리렌더링이 일어난다 — 변수를 직접 바꾸면 화면이 갱신되지 않는다.
  • useState(() => ...) 게으른 초기화는 최초 1번만 실행되어 비싼 초기 계산의 낭비를 막는다.
14

리렌더링이란?

리렌더링Virtual DOMdiffing

한 줄 요약리렌더링은 state나 props가 바뀔 때 컴포넌트 함수가 처음부터 다시 실행되어 새로운 Virtual DOM을 만드는 과정으로, React는 이전 Virtual DOM과 비교(diffing)해서 실제로 바뀐 부분만 진짜 DOM에 반영한다.

쉽게 말하면리렌더링은 "설계도를 다시 그리는 것"이에요. state가 바뀌면 함수가 다시 실행되어 새 설계도를 그리지만, 실제 집(진짜 DOM)을 통째로 다시 짓지는 않고 달라진 벽지만 바꿔 답니다.

리렌더링 한 번은 사실 4단계로 이뤄진다

"다시 그린다"는 말이 뭉뚱그려져 있는데, 실제로는 순서가 있는 4단계입니다.

  • ① 트리거 — state 변경, props 변경, 또는 부모의 리렌더링 중 하나가 일어남.
  • ② 함수 재실행 — 그 컴포넌트 함수가 처음부터 다시 통째로 실행됨(설계도를 새로 그리는 단계).
  • ③ 새 Virtual DOM 생성 — 그 함수가 반환한 새 JSX가 메모리 안의 가상 트리로 만들어짐(아직 화면엔 안 그려짐).
  • ④ diffing 후 실제 DOM 반영 — React가 이전 가상 트리와 이번 것을 비교해, 정말 달라진 부분만 진짜 화면에 반영.

비유하면 이사 갈 때 "새 집 설계도부터 다시 그리고(②③), 기존 집과 비교해서 정말 바뀐 벽만 허물고 새로 세우는 것(④)"과 같아요 — 집 전체를 철거하고 새로 짓는 게 아니라서 빠릅니다.

리렌더링을 일으키는 것은 딱 셋

리렌더링을 유발하는 것은 오직 (1) state 변경, (2) props 변경, (3) 부모의 리렌더링(자식도 함께 재실행)뿐입니다 — 화면 밖 일반 변수를 바꾸는 것만으로는 리렌더링이 일어나지 않습니다. 리렌더링은 "함수 재실행"이지 "DOM 재생성"이 아니므로, 함수가 다시 실행되어 새 JSX(Virtual DOM 트리)를 반환하면 React가 이전 트리와 비교(diffing)해 실제로 달라진 노드만 진짜 DOM에 반영합니다.

핵심 정리
  • 리렌더링을 일으키는 것은 state/props 변경뿐이다(그리고 부모의 리렌더링).
  • 리렌더링 = 컴포넌트 함수 재실행 → 새 Virtual DOM → 이전 것과 diffing → 바뀐 부분만 실제 DOM 반영.
  • 일반 변수를 바꾸는 것만으로는 화면이 갱신되지 않는다 — 반드시 set함수를 통해야 한다.
15

불변성(Immutability)이란?

불변성배열 state참조 비교

한 줄 요약불변성은 배열이나 객체 state를 원본에서 직접 수정하지 않고, 항상 스프레드나 map/filter로 새 배열·새 객체를 만들어 통째로 교체하는 원칙으로, React가 "값이 바뀌었는지"를 빠르고 정확하게 감지할 수 있게 해준다.

쉽게 말하면불변성은 "메모장을 지우개로 고치지 않고, 새 종이에 통째로 다시 옮겨 적는 것"이에요. React는 "이전 메모장 = 새 메모장"인지 겉모습만 빠르게 비교하는데, 원본을 지우개로 고치면 겉모습(참조)이 그대로라 React가 "안 바뀌었네?" 하고 착각할 수 있습니다.

상황별로 쓰는 메서드가 다르다 — 표로 정리

"불변성 지키기"라는 말은 하나지만, 실제로는 상황(추가/삭제/수정)마다 쓰는 도구가 정해져 있습니다.

  • 추가[...todos, 새항목]. 기존 배열을 통째로 펼치고(...) 뒤에 하나 더 얹은 새 배열.
  • 삭제todos.filter(t => t.id !== id). 지우고 싶은 것만 "빼고" 나머지로 새 배열을 만듦.
  • 수정todos.map(t => t.id === id ? {...t, done: !t.done} : t). 모든 항목을 훑되, 대상 항목만 복사+덮어쓰기(스프레드)로 바꾸고 나머지는 그대로 통과시킴.

비유하면 원본 배열은 "박물관 전시품"이에요. 손대지 말고(원본 수정 금지), 항상 사진을 찍어 복사본을 만든 다음(스프레드/map/filter) 그 복사본에만 낙서를 하는 것과 같습니다 — 전시품 자체가 바뀐 적이 없어야, 관리인(React)이 "뭐가 바뀌었는지"를 안심하고 빠르게 확인할 수 있습니다.

추가=스프레드 / 삭제=filter / 수정=map+스프레드

React는 state가 "정말 바뀌었는지"를 값을 하나하나 뜯어보는 대신 참조(메모리 주소)가 달라졌는지로 빠르게 판단합니다 — 그래서 todos.push(x)처럼 원본 배열을 직접 바꾸면 참조는 그대로라 리렌더링을 놓칠 수 있습니다. 안전한 패턴은 셋뿐입니다 — 추가는 [...todos, 새항목], 삭제는 todos.filter(t => t.id !== id), 수정은 todos.map(t => t.id === id ? {...t, done: !t.done} : t). 객체도 마찬가지로 obj.age = 20 대신 {...obj, age: 20}으로 새 객체를 만들어 교체합니다.

핵심 정리
  • state인 배열/객체는 절대 원본을 직접 수정하지 않는다(push, 인덱스 대입, 속성 직접 대입 금지).
  • 추가=스프레드([...arr,x]) / 삭제=filter / 수정=map+객체스프레드({...t,key:값}) 세 패턴을 기억한다.
  • 이유: React가 "참조 비교"로 변경을 감지하기 때문 — 원본을 고치면 참조가 그대로라 리렌더링을 놓칠 수 있다.
관련 실습: react-03 TodoApp
16

key와 리스트 렌더링

key.map()고유 id

한 줄 요약배열을 map()으로 리스트 렌더링할 때 React는 key 속성으로 각 항목의 정체를 추적하므로, 항목이 추가·삭제·순서 변경될 때도 헷갈리지 않도록 index가 아닌 데이터의 고유 id를 key로 넣어야 한다.

쉽게 말하면key는 각 리스트 항목에 붙이는 "고유 번호표"예요. 순서 번호를 이름표로 쓰면, 중간에 항목을 빼거나 순서를 바꿨을 때 React가 헷갈려서 엉뚱한 항목의 상태를 재사용하는 사고가 날 수 있습니다.

key={todo.id} vs key={index}, 뭐가 다를까

둘 다 "에러는 안 나는" 코드라서 차이가 안 보이지만, 항목이 추가·삭제될 때 완전히 다르게 동작합니다.

  • key={todo.id} — 데이터 자체가 가진 고유하고 변하지 않는 번호표. 순서가 바뀌어도 각 항목을 정확히 계속 추적합니다.
  • key={index} — 배열 안에서의 자리 번호(0, 1, 2...)일 뿐이라, 중간 항목이 삭제되면 뒤 항목들의 번호가 전부 한 칸씩 당겨집니다.

비유하면 todo.id는 "각자에게 발급된 고유 학번"이고, index는 "지금 서 있는 줄의 번째 수"예요 — 줄에서 한 명이 빠지면 학번(id)은 그대로지만, 몇 번째로 서 있는지(index)는 뒷사람 전원이 바뀝니다. React는 key만 보고 "이건 아까 그 사람"을 판단하므로, index를 쓰면 사람이 자리바꿈했는데도 "그대로 있다"고 착각해 엉뚱한 상태(체크 여부 등)를 뒤집어 씌울 수 있습니다.

index를 key로 쓰면 안 되는 이유

key가 없으면 콘솔에 "Each child in a list should have a unique key prop" 경고가 뜹니다. index를 key로 쓰면 배열 중간에 항목을 추가·삭제·재정렬할 때 각 항목의 "인덱스"가 바뀌어버려서, React가 엉뚱한 항목에 이전 상태(체크박스 체크 여부 등)를 붙여버리는 사고가 날 수 있습니다 — 그래서 데이터 자체의 고유 id(todo.id, member.id)를 key로 써야 안전합니다. 또한 key는 React 내부 전용 속성이라 자식 컴포넌트 안에서 props.key로 읽을 수 없습니다.

핵심 정리
  • 배열.map()으로 리스트를 그릴 때는 각 항목에 고유한 key를 반드시 지정한다.
  • index를 key로 쓰면 항목 추가/삭제/재정렬 시 상태가 엉뚱한 항목에 붙는 사고가 날 수 있다 — 데이터의 고유 id를 쓴다.
  • key는 React 내부용 예약 속성이라 자식이 props.key로 읽을 수 없다.
관련 실습: react-02, react-03
17

제어 컴포넌트(Controlled Component)란?

제어 컴포넌트value+onChange

한 줄 요약제어 컴포넌트는 <input value={state} onChange={e=>setState(...)} />처럼 입력창의 현재 값을 항상 state와 동일하게 유지시켜, React가 "입력창에 뭐가 적혀 있는지"를 완전히 통제하는 패턴이다.

쉽게 말하면제어 컴포넌트는 "받아쓰기"예요. 사용자가 타이핑할 때마다(onChange) 그 글자를 state라는 공책에 그대로 옮겨 적고, 화면의 입력창(value)은 항상 그 공책 내용만 보여줍니다.

value·onChange·e.target.value, 각각 하는 일

<input value={input} onChange={e => setInput(e.target.value)} /> 한 줄에 "보여주기"와 "반응하기"가 같이 들어있어 헷갈리기 쉽습니다.

  • value={input} — 입력창에 지금 무엇을 표시할지 state가 결정. state를 안 바꾸면 아무리 타이핑해도 화면은 그대로입니다.
  • onChange={...} — 사용자가 타이핑할 때마다 실행할 함수를 지정. "언제 반응할지"를 정하는 자리입니다.
  • e.target.value — 그 이벤트 함수 안에서, 사용자가 방금 입력창에 실제로 쳐 넣은 글자를 꺼내는 표현식.

비유하면 제어 컴포넌트는 "받아쓰기 시험"이에요 — 학생이 한 글자 칠 때마다(onChange) 선생님이 그 글자(e.target.value)를 즉시 공책(state)에 옮겨 적고, 칠판에 걸린 화면(value)은 항상 그 공책 내용만 그대로 비춰줍니다. 선생님이 공책에 안 옮기면(setInput을 안 부르면) 학생이 뭘 쳐도 칠판은 안 바뀝니다.

value와 onChange는 항상 세트

value={input}은 입력창의 표시값을 state가 직접 결정한다는 뜻이라, state를 안 바꾸면 사용자가 타이핑해도 화면이 안 바뀝니다 — 그래서 onChange={e => setInput(e.target.value)}가 항상 짝으로 필요합니다. 반대 개념인 비제어 컴포넌트(ref로 필요할 때만 DOM에서 값을 꺼내오는 방식)도 있지만, 초급 단계에서는 항상 state로 값을 통제하는 제어 컴포넌트를 기본으로 삼습니다.

핵심 정리
  • value(표시값)와 onChange(변경 감지)는 항상 세트로 붙는다.
  • "화면의 입력값 = state 값"이 항상 같도록 React가 통제 → 빈 값 검증 등이 쉬워진다.
  • 반대 개념: ref로 필요할 때만 값을 꺼내는 비제어 컴포넌트도 있지만, 지금은 제어 컴포넌트를 기본으로 익힌다.
18

useEffect란?

useEffect의존성 배열부수효과

한 줄 요약useEffect(함수, [의존성])는 "화면을 그린 뒤에" 실행되는 부수 효과(side effect) 훅으로, 의존성 배열에 아무것도 안 넣으면 렌더링마다, 빈 배열 []을 넣으면 최초 1번만, 값을 넣으면 그 값이 바뀔 때만 실행된다.

쉽게 말하면useEffect는 "그림을 다 그린 다음에 처리할 뒷일"이에요. 화면을 먼저 그리고, 그 다음에 localStorage 저장이나 서버 요청처럼 화면 그리기와 상관없는 부수적인 일을 처리합니다. 의존성 배열은 "언제 이 뒷일을 다시 할지" 정하는 조건표입니다.

useEffect(fn, [dep]), 괄호 안 두 자리의 역할

같은 useEffect인데 두 번째 자리(의존성 배열)에 뭘 넣느냐에 따라 완전히 다르게 동작합니다.

  • fn (첫 번째 자리) — 화면을 그린 뒤에 실행할 "뒷일" 그 자체(localStorage 저장, 서버 요청 등).
  • [] (빈 배열) — "감시할 값이 없다" → 최초 1번만 실행.
  • [dep] (값이 있는 배열) — "이 값이 바뀌는지 감시하겠다" → dep가 바뀐 렌더링에서만 다시 실행.
  • 배열 자체를 생략 — "매번 감시" → 렌더링될 때마다 실행(거의 안 씀).

비유하면 의존성 배열은 "이 알림을 언제 다시 울릴지 정하는 조건표"예요 — 빈 배열은 "입학식 때 딱 한 번만 울리는 알람", [todos]는 "todos에 변화가 생길 때마다 우는 알람"인 셈입니다.

세 가지 패턴과 stale 값 주의

useEffect(fn) — 의존성 배열 자체가 없으면 렌더링마다 매번 실행(거의 안 씀). useEffect(fn, []) — 빈 배열이면 최초 마운트 시 딱 1번만 실행. useEffect(fn, [dep]) — 배열 안 값이 바뀔 때만 실행(todos가 바뀔 때마다 저장 등). effect 안에서 실제로 사용하는 값은 빠짐없이 의존성 배열에 넣어야 하며, 빠뜨리면 "오래된(stale) 값"을 계속 참조하는 버그로 이어집니다 — 자세한 내용은 stale closure 항목 참고.

핵심 정리
  • useEffect(fn, []): 최초 1번만. useEffect(fn, [dep]): dep가 바뀔 때만. useEffect(fn): 매 렌더링마다(거의 안 씀).
  • "화면을 그린 뒤" 실행되는 부수효과 훅 — localStorage 저장, 서버 요청 등을 여기서 처리한다.
  • effect 안에서 쓰는 값은 반드시 의존성 배열에 넣어야 stale 값 참조 버그를 피할 수 있다.
19

Hooks 사용 규칙 — 왜 조건문 안에서 부르면 안 될까?

Hooks 규칙최상위에서만조건문 금지

한 줄 요약useState·useEffect 같은 Hook은 반드시 컴포넌트 함수의 최상위에서, 매번 같은 순서로 호출해야 하며, if문이나 반복문·중첩 함수 안에서 조건부로 호출하면 안 된다는 것이 React의 핵심 규칙이다.

쉽게 말하면React는 "몇 번째 Hook 호출인지" 순서로 각 상태를 구분해요. 마치 사물함을 순서대로 배정하는 것과 같아서, 어떤 렌더링에서는 Hook을 3개 부르고 다음 렌더링에서는 조건에 따라 2개만 부르면, 사물함 번호가 밀려서 완전히 엉뚱한 상태가 엉뚱한 변수에 배정되어 버립니다.

"Hook을 조건부로" vs "값을 조건부로", 뭐가 다를까

둘 다 "조건에 따라 다르게 동작"하고 싶은 건 같은데, 하나는 금지고 하나는 정석입니다.

  • ❌ if (조건) { useState(0) } — Hook 호출 자체가 조건 안에 있음. 조건에 따라 어떤 렌더링에서는 Hook이 호출되고, 어떤 렌더링에서는 아예 안 됨 → React가 사물함(상태) 순서를 잃어버림.
  • ✅ const [x] = useState(0); if (조건) { ... x ... } — Hook은 무조건 매번 호출하고, 그 결과값(x)만 조건문 안에서 골라 씀 → Hook 호출 횟수·순서는 항상 동일.

비유하면 React는 "몇 번째로 배정된 사물함인지"만 보고 각 상태를 찾아갑니다(이름표가 아니라 순번표) — 어떤 날은 사물함을 3개 배정받고 어떤 날은 조건에 따라 2개만 배정받으면, 다음 사물함들의 번호가 전부 밀려서 완전히 남의 사물함(엉뚱한 상태)을 열어보게 됩니다. 그래서 사물함 개수(Hook 호출 횟수) 자체는 절대 흔들리면 안 되고, 사물함 "안"의 내용물을 어떻게 쓸지만 조건부로 정해야 합니다.

규칙 1: 최상위에서만 / 규칙 2: 매번 같은 순서로

if (조건) { const [x, setX] = useState(0) }처럼 조건문 안에서 Hook을 호출하면 안 됩니다 — Hook은 항상 컴포넌트 함수 맨 바깥 레벨에서, 조건 없이 호출해야 합니다. React는 Hook을 호출된 "순서"로 기억하기 때문에(이름이 아니라), 렌더링마다 호출되는 Hook의 개수와 순서가 달라지면 안 됩니다.

대신 조건부 로직이 필요하면, Hook 자체는 항상 호출하고 Hook이 반환한 값을 가지고 그 안에서 if 분기를 하면 됩니다. 예: const [count, setCount] = useState(0)은 무조건 호출하고, 그 다음 줄에서 if (count > 0) { ... }처럼 값만 조건부로 사용합니다. 컴포넌트가 아닌 일반 함수 안에서도 Hook을 호출하면 안 되며, 반드시 컴포넌트 함수나 커스텀 훅 안에서만 호출해야 합니다.

핵심 정리
  • Hook은 컴포넌트 함수 최상위에서만 호출 — if/for/중첩 함수 안에서 호출 금지.
  • React는 Hook을 "호출된 순서"로 구분하므로, 렌더링마다 호출 개수·순서가 같아야 한다.
  • 조건부 로직은 Hook 호출 자체가 아니라 Hook이 반환한 값을 사용하는 코드 안에서 처리한다.
20

useRef란?

useRefDOM 접근렌더링 안 됨

한 줄 요약useRef(초기값)는 .current에 값을 담아두는 상자를 만드는 훅으로, useState와 달리 값이 바뀌어도 화면을 다시 그리지 않으며, 실제 DOM 엘리먼트에 직접 접근할 때나 렌더링과 무관한 값을 보관할 때 쓴다.

쉽게 말하면useState가 "고치면 선생님이 칠판을 다시 써 주는 값"이라면, useRef는 "내 서랍 속 개인 메모장"이에요. 메모장 내용을 바꿔도 아무도 칠판을 다시 쓰지 않습니다(리렌더링 없음). 다만 서랍 자체는 계속 같은 자리에 있어서(.current), 필요할 때 언제든 열어볼 수 있어요.

useRef → ref 연결 → .current, 세 단계로

DOM에 접근할 때 쓰는 이 세 줄은 사실 "상자 만들기 → 상자를 태그에 꽂기 → 상자 속 내용물 쓰기"라는 순서가 있는 흐름입니다.

  • const inputRef = useRef(null) — 처음엔 비어있는(null) 상자를 하나 만듭니다.
  • <input ref={inputRef} /> — 이 상자를 특정 DOM 태그와 연결합니다. React가 렌더링 후 실제 input 엘리먼트를 상자 안에 넣어줍니다.
  • inputRef.current.focus() — 이제 상자 안(.current)에 진짜 DOM이 들어있으므로, .focus()처럼 브라우저 DOM API를 직접 호출할 수 있습니다.

비유하면 useRef는 "이름표만 붙은 빈 상자를 미리 주문"(①)하고, ref=로 "그 상자를 실제 물건(DOM) 앞에 가져다 놓고"(②), .current로 "상자를 열어 안의 물건을 직접 조작"(③)하는 3단계입니다. useState처럼 값을 "보고하는" 방식이 아니라, DOM에 직접 손을 뻗는 방식이라는 게 핵심 차이입니다.

용도 ① DOM 접근 — "손가락" 변수

const inputRef = useRef(null)로 만든 다음 JSX에서 <input ref={inputRef} />처럼 연결하면, React가 렌더링 후 실제 DOM 엘리먼트를 inputRef.current에 넣어줍니다. 이후 inputRef.current.focus()처럼 브라우저 DOM API를 직접 호출할 수 있습니다 — value/onChange로 값만 다루는 일반적인 React 방식(선언형)과 달리, "이 엘리먼트에 직접 명령"하는 명령형 코드입니다.

용도 ② 렌더링과 무관한 값 보존 — "비밀 메모장"

useState의 초기값처럼 useRef(0)로 시작해서 renderCount.current += 1처럼 아무 때나 값을 바꿀 수 있지만, 이 대입 자체는 리렌더링을 일으키지 않습니다. 그래서 "화면에 안 보여도 되지만 기억은 해둬야 하는 값"(예: 이전 값, 타이머 id, 렌더링 횟수 카운터)을 저장할 때 씁니다. 단, 그 값을 화면에 표시하려면 다른 이유로 리렌더링이 일어나야 최신값이 반영됩니다 — renderCount.current만 바꾸고 아무 setState도 안 하면 화면 숫자는 갱신되지 않습니다.

핵심 정리
  • useState: 값이 바뀌면 리렌더링 O / useRef: 값이 바뀌어도 리렌더링 X.
  • ref={변수}로 JSX 엘리먼트와 연결하면 변수.current로 실제 DOM에 접근(focus, scroll 등 명령형 조작).
  • 렌더링과 무관하게 "그냥 기억만 하면 되는 값"을 보관하는 용도로도 쓴다.
21

useMemo·useCallback이란? — 두 가지 캐싱

useMemouseCallback캐싱의존성 배열

한 줄 요약useMemo는 "계산 결과값"을 캐싱하고 useCallback은 "함수 자체"를 캐싱하는 훅으로, 둘 다 의존성 배열에 넣은 값이 안 바뀌면 다시 만들지 않고 이전 것을 그대로 재사용한다.

쉽게 말하면useMemo는 "어제 푼 수학 문제의 답을 적어두고, 문제가 안 바뀌었으면 다시 안 풀고 그 답을 재활용하기"예요. useCallback은 "매번 새 도장을 파지 않고, 같은 도장을 계속 재사용하기"입니다 — 함수도 자바스크립트에서는 값이라서, 컴포넌트가 다시 실행될 때마다 겉보기엔 똑같아도 "새로 만들어진 다른 도장"이 되어버리거든요.

둘 다 (fn, deps) 모양인데, 뭘 캐싱하는지가 다르다

useMemo(fn, deps)useCallback(fn, deps)는 모양이 똑같아서 더 헷갈립니다 — 딱 하나, "무엇을 저장해두는지"만 다릅니다.

  • useMemo(() => 계산(), deps) — fn을 실행한 결과값을 저장. deps가 안 바뀌면 fn을 다시 실행하지 않고 저장해둔 값을 그대로 돌려줍니다.
  • useCallback(fn, deps) — fn을 실행하지 않고, 함수 자체(참조)를 저장. deps가 안 바뀌면 새 함수를 만들지 않고 이전과 똑같은 함수 객체를 돌려줍니다.

비유하면 useMemo는 "어제 푼 수학 문제의 답안지를 서랍에 넣어두는 것"(결과물 보관)이고, useCallback은 "매번 새로 도장을 파지 않고 이미 파둔 도장 자체를 서랍에 넣어두는 것"(도구 자체 보관)입니다 — 하나는 "답"을, 하나는 "도장"을 아낀다는 차이입니다.

useMemo — 계산 결과 캐싱

const 결과 = useMemo(() => 무거운계산(), [dep1, dep2]) 형태로 씁니다. 컴포넌트가 리렌더링될 때마다 dep1·dep2가 이전과 같은지 비교해서, 같으면 함수를 실행하지 않고 저장해 둔 이전 반환값을 그대로 돌려줍니다. 필터링·정렬·합계 계산처럼 "데이터 양이 많아질수록 느려지는 연산"에 주로 씁니다.

useCallback — 함수 자체 캐싱

const 핸들러 = useCallback((인자) => {...}, [dep]) 형태입니다. useCallback이 없으면 컴포넌트가 리렌더링될 때마다 같은 내용의 함수라도 매번 "새 함수 객체"(다른 메모리 주소)가 만들어집니다. useCallback으로 감싸면 의존성이 안 바뀌는 한 처음 만든 함수 참조를 계속 그대로 돌려줍니다. 이게 중요한 이유는 React.memo와 함께 쓸 때 드러납니다 — 자세한 건 다음 항목 참고.

둘 다 "공짜 최적화"가 아니다

useMemo/useCallback 자체도 "이전 값과 비교하고 저장해 두는" 작업이라 약간의 비용이 듭니다. 계산이 아주 가볍거나(예: 숫자 하나 더하기) 자식이 React.memo로 감싸져 있지 않다면, 캐싱해도 체감 효과가 거의 없이 코드만 복잡해질 수 있습니다. "무거운 계산"이거나 "memo로 감싼 자식에게 넘기는 함수/객체"일 때 쓰는 것이 정석입니다.

핵심 정리
  • useMemo(fn, deps): fn의 반환값을 캐싱 — deps가 안 바뀌면 fn을 다시 실행하지 않는다.
  • useCallback(fn, deps): fn 자체(함수 참조)를 캐싱 — deps가 안 바뀌면 항상 같은 함수 객체를 돌려준다.
  • 둘 다 "무거운 계산" 또는 "React.memo로 감싼 자식에게 넘기는 값/함수"일 때 쓰는 최적화 도구다.
22

React.memo란? — 얕은 비교로 자식 리렌더링 스킵

React.memo얕은 비교렌더링 스킵

한 줄 요약React.memo(컴포넌트)로 감싸면, 부모가 리렌더링돼도 이전에 받은 props와 새로 받은 props가 얕은 비교(===) 기준으로 완전히 같을 때 그 컴포넌트의 리렌더링을 건너뛴다.

쉽게 말하면부모가 다시 그려질 때 자식들도 원래는 전부 같이 다시 그려져요. React.memo는 자식 앞에 세워둔 "검문소 문지기"예요 — "지난번이랑 받은 재료(props)가 토씨 하나 안 다르고 완전히 똑같아? 그럼 넌 안 그려도 돼, 통과!"라고 판단해 주는 역할입니다.

{a:1} === {a:1}이 false인 이유부터

React.memo를 이해하려면 먼저 "===가 뭘 비교하는지"부터 정확히 알아야 합니다.

  • 1 === 1 — 원시값(숫자·문자열·불리언)은 값 자체를 비교 → 같으면 true.
  • {a:1} === {a:1} — 객체·배열·함수는 메모리 주소(참조)를 비교 → 내용이 완전히 똑같아도 서로 다른 상자라서 false.
  • React.memo(Component) — 각 prop을 바로 이 === 방식으로 비교해서, 전부 true면 리렌더링을 건너뜁니다.

비유하면 원시값은 "숫자 1"과 "숫자 1"을 비교하는 것이라 언제나 같지만, 객체는 "내용물이 똑같은 상자 두 개"를 비교하는 것과 같아요 — 겉으로 봐선 똑같아 보여도 서로 다른 상자이므로 "다른 것"으로 판정됩니다. 그래서 부모가 렌더링될 때마다 새로 만든 객체/함수를 props로 내려주면, memo는 매번 "새 상자가 왔다"고 오판합니다.

얕은 비교(shallow comparison)란?

React.memo는 각 prop을 ===로 비교합니다. 숫자·문자열·불리언 같은 원시값은 값이 같으면 ===도 true지만, 객체·배열·함수는 내용이 같아도 참조(메모리 주소)가 다르면 ===가 false입니다. 예를 들어 { a: 1 } === { a: 1 }은 false예요 — 서로 다른 상자니까요. 그래서 부모가 리렌더링될 때마다 () => {...}처럼 매번 새로 만드는 함수를 그대로 props로 내려주면, 내용은 똑같아도 memo는 "달라졌다"고 오판해 리렌더링을 막지 못합니다.

왜 useCallback과 짝을 이뤄야 하는가

StockRow를 memo로 감싸도, 부모 StockList가 toggleFavoriteuseCallback 없이 매번 새로 만들어 넘기면 모든 StockRow가 매번 "함수가 바뀌었다"고 인식해 리렌더링됩니다. useCallback(fn, [])으로 함수 참조를 고정해야 비로소 memo가 "이 함수도 지난번과 똑같다"고 판단해 실제로 리렌더링을 스킵합니다. 정리하면 memo(자식 방어막) + useCallback/useMemo(부모가 내려주는 값 고정)가 항상 한 세트입니다.

핵심 정리
  • React.memo(컴포넌트)는 이전 props와 새 props를 얕은 비교해서 완전히 같으면 리렌더링을 건너뛴다.
  • 원시값(숫자·문자열·불리언)은 값이 같으면 통과하지만, 객체·배열·함수는 참조가 달라지면 매번 "바뀐 것"으로 취급된다.
  • 그래서 memo로 감싼 자식에게 객체/함수를 props로 넘길 때는 useMemo·useCallback으로 참조를 고정해 줘야 효과가 있다.
23

React StrictMode란?

StrictMode개발 모드 전용이중 렌더링

한 줄 요약StrictMode는 개발 모드에서만 컴포넌트를 의도적으로 한 번 더 마운트(마운트→언마운트→재마운트)시켜, 부수효과가 안전하게 정리되는지 미리 검증해주는 개발용 안전장치로, 배포 빌드에는 아무 영향이 없다.

쉽게 말하면StrictMode는 "리허설을 한 번 더 시켜보는 깐깐한 감독"이에요. 실제 공연(배포)에서는 한 번만 하면 되지만, 연습(개발 모드) 중에는 일부러 두 번 시켜봐서 숨은 문제를 미리 찾아냅니다.

마운트→언마운트→재마운트, 세 동작을 구분하면

"두 번 실행된다"는 말이 뭉뚱그려져 있어서 무섭게 느껴지는데, 실제로는 정해진 세 동작이 순서대로 일어나는 것뿐입니다.

  • 마운트 — 컴포넌트가 처음 화면에 나타남(useEffect 최초 실행 포함).
  • 언마운트 — StrictMode가 검사를 위해 바로 그 컴포넌트를 화면에서 내림(useEffect의 cleanup 함수 실행).
  • 재마운트 — 곧바로 다시 화면에 올림(useEffect가 또 한 번 실행) — 이 시점에 로그나 effect가 "두 번"처럼 보이는 것.

비유하면 이건 "리허설 감독이 배우를 무대에 세웠다가(마운트), 일부러 잠깐 퇴장시키고(언마운트), 바로 다시 세워보는 것"(재마운트)과 같아요 — 실제 공연(배포 빌드)에서는 이 리허설 과정 자체가 없어서 한 번만 등장합니다. 개발 중에만 일어나는 "미리 보는 예행연습"이라고 생각하면 됩니다.

왜 두 번 실행되는 것처럼 보이는가

개발 모드에서만 동작합니다 — 컴포넌트를 마운트 → 검사를 위해 바로 언마운트 → 즉시 재마운트하며, 그 과정에서 useEffect와 콘솔 로그가 두 번씩 찍히는 것처럼 보입니다(버그가 아니라 의도된 동작). 목적은 "이 컴포넌트를 두 번 마운트해도 똑같이 안전하게 동작하는가"를 미리 확인하는 것으로, 부수효과가 이전 상태에 의존하면(저장소 덮어쓰기 버그처럼) 이 단계에서 증상으로 드러납니다. 배포 빌드(npm run build)에서는 이 이중 실행이 전혀 일어나지 않습니다.

핵심 정리
  • StrictMode는 개발 모드 전용 검사 도구 — 컴포넌트를 일부러 한 번 더 마운트해 부수효과의 안전성을 미리 테스트한다.
  • 개발 중엔 console.log나 useEffect가 두 번씩 실행되는 것처럼 보이는데, 버그가 아니라 의도된 동작이다.
  • 배포 빌드에는 영향이 없다 — "두 번 실행돼도 문제없이 동작하는 코드"를 짜도록 유도하는 장치일 뿐이다.
관련 실습: react-01 — main.jsx
24

커스텀 훅(Custom Hook)이란?

커스텀 훅use 접두사로직 재사용

한 줄 요약커스텀 훅은 새로운 문법이 아니라 이름이 use로 시작하는 평범한 자바스크립트 함수로, 내부에서 useState·useEffect 같은 기존 훅을 조합해 여러 컴포넌트에서 반복되는 "상태+부수효과" 로직을 재사용 가능한 단위로 뽑아낸 것이다.

쉽게 말하면useState·useEffect가 레고의 낱개 블록이라면, 커스텀 훅은 그 블록 몇 개를 미리 조립해 둔 "완성된 부품"이에요. 다음에 비슷한 걸 또 만들 때는 블록을 처음부터 다시 끼울 필요 없이, 이 부품 하나를 그대로 가져다 쓰면 됩니다.

왜 이름이 반드시 use로 시작해야 하는가

커스텀 훅은 문법적으로 특별한 게 하나도 없는 그냥 함수입니다 — 그런데도 이름을 useDebounce처럼 use로 시작해야 하는 이유는, 그것이 리액트와 린터(eslint-plugin-react-hooks)가 "이 함수는 안에서 다른 훅을 호출해도 되는 함수"라고 구분하는 유일한 단서이기 때문입니다. 만약 getDebounce처럼 이름을 지으면 겉보기엔 똑같이 동작해도, 린터가 Hooks 규칙(최상위에서만 호출 등)을 검사해 주지 못해 실수를 미리 잡아내지 못합니다.

커스텀 훅이 하는 일 = 기존 훅의 "조합"

커스텀 훅 내부를 열어보면 결국 useState·useEffect·useRef 같은 리액트 기본 훅 몇 개를 불러다 조합해 놓았을 뿐입니다. 예를 들어 useDebounce는 useState(지연된 값 보관) + useEffect(타이머 예약·취소) 조합이고, useInterval은 useRef(최신 함수 보관) + useEffect(타이머 생성·정리) 조합입니다. 새로운 능력이 생기는 게 아니라, 자주 반복되는 조합을 함수 하나로 포장해서 이름을 붙여준 것뿐입니다.

재사용 단위는 "로직"이지 "상태"가 아니다

주의할 점 하나 — 같은 커스텀 훅을 두 컴포넌트에서 각각 호출하면, 두 컴포넌트는 완전히 독립된 자기만의 상태를 갖습니다(상태 자체가 공유되는 게 아님). 예를 들어 컴포넌트 A와 B가 각각 useDebounce(value, 300)을 호출하면, A의 debouncedValue와 B의 debouncedValue는 서로 아무 영향도 주지 않는 별개의 값입니다. 커스텀 훅이 재사용해 주는 것은 "값을 어떻게 계산하고 관리할지"에 대한 로직(코드)이지, 값 그 자체가 아닙니다.

핵심 정리
  • 커스텀 훅은 새 문법이 아니라 이름이 use로 시작하는 평범한 함수 — 그 이름 규칙만이 린터가 Hooks 규칙을 검사해 주는 단서다.
  • 내부에서는 결국 useState/useEffect/useRef 같은 기존 훅을 조합할 뿐이며, 목적은 반복되는 "상태+부수효과" 로직을 재사용 가능한 단위로 뽑아내는 것이다.
  • 같은 커스텀 훅을 여러 컴포넌트에서 써도 각 컴포넌트는 서로 독립된 상태를 갖는다 — 재사용되는 건 로직이지 상태가 아니다.
25

React.lazy + Suspense란? — 코드 분할로 초기 로딩 줄이기

React.lazySuspense코드 분할동적 import

한 줄 요약React.lazy(() => import('./Foo'))로 컴포넌트를 감싸면 그 컴포넌트의 코드는 처음 화면을 열 때 받아오지 않고 실제로 화면에 나타나야 하는 순간에만 네트워크로 따로 받아오며(코드 분할), 그 다운로드가 끝날 때까지 대신 보여줄 화면은 <Suspense fallback=>으로 지정한다.

쉽게 말하면보통은 앱을 열자마자 모든 페이지의 코드를 한꺼번에 다 받아옵니다 — 마치 이사할 때 앞으로 볼 일 없을지도 모르는 짐까지 트럭에 전부 싣는 것과 같아요. React.lazy는 "지금 당장 필요한 짐(첫 화면)만 먼저 옮기고, 나머지 방(다른 탭·페이지)은 문을 열 때 그제서야 옮겨온다"는 전략입니다. 그 옮겨오는 잠깐 동안 문 앞에 세워두는 "잠시만요" 팻말이 Suspense의 fallback입니다.

왜 코드를 나눠 받아야 하는가 — 번들(bundle)이라는 개념

기본적으로 Vite/웹팩 같은 빌드 도구는 import로 연결된 모든 컴포넌트 코드를 파일 하나(번들)로 합쳐서 브라우저에 보냅니다. 이 실습의 HeavyChart·HeavyReport처럼 사용자가 클릭하기 전에는 안 볼 수도 있는 화면까지 처음부터 전부 합쳐서 보내면, 정작 첫 화면(memo 탭)이 뜨는 데까지 걸리는 시간이 쓸데없이 길어집니다. 코드 분할(code splitting)은 이 번들을 여러 조각(청크)으로 쪼개, 당장 안 쓰는 조각은 나중에 필요할 때 따로 받아오게 만드는 기법입니다.

lazy(() => import('./Foo')) — 괄호 두 겹을 요소별로 뜯어보면

한 줄 안에 "리액트 함수 + 화살표 함수 + 동적 import 함수 호출"이 겹쳐 있어서 헷갈리기 쉬운 문법입니다. 하나씩 분리하면 이렇습니다.

  • lazy( ... ) — 리액트가 제공하는 함수. "이 컴포넌트는 코드가 당장 준비돼 있지 않을 수 있다"고 리액트에게 미리 알려주는 포장지 역할.
  • () => import('./HeavyChart') — lazy에게 건네는 화살표 함수. lazy는 이 함수를 미리 실행하지 않고 실제로 그 컴포넌트가 화면에 필요해지는 순간에만 호출합니다.
  • import('./HeavyChart') — 파일 맨 위에 쓰는 평소의 import Foo from './Foo'(정적 import)와 달리, 함수처럼 괄호를 붙여 호출하는 동적 import입니다. 호출되는 순간 그 파일의 코드를 네트워크로 따로 요청해서 받아오고, 다 받아오면 그 결과를 담은 Promise를 돌려줍니다.
  • const HeavyChart = lazy(...) — 결과로 만들어진 HeavyChart는 진짜 컴포넌트가 아니라, "필요한 순간 코드를 받아와 진짜 컴포넌트로 바꿔치기해 줄 준비가 된 대역 컴포넌트"입니다.

비유하면 정적 import는 "이삿짐을 미리 다 싸서 트럭에 실어두는 것"이고, import('./Foo')(동적 import)는 "필요할 때만 그 방에 가서 짐을 싸 오겠다는 약속표(Promise)를 써 두는 것"과 같아요.

<Suspense fallback={...}>...</Suspense> — 로딩 중 화면을 대신 보여주는 울타리

lazy로 만든 컴포넌트는 반드시 Suspense로 감싸야 합니다. 각 부분의 역할은 다음과 같습니다.

  • <Suspense> — 리액트가 제공하는 특수 컴포넌트. "이 안에 있는 자식들 중 아직 코드/데이터가 준비 안 된 게 있으면, 대신 fallback을 보여줘라"는 규칙을 가진 울타리.
  • fallback={<LoadingSpinner .../>} — 아직 준비가 안 됐을 때 대신 보여줄 UI. 이 실습에서는 "⏳ 로딩 중..." 문구를 가진 컴포넌트.
  • {tab === 'chart' && <HeavyChart />} — Suspense가 감시하는 진짜 자식. lazy 컴포넌트라서 아직 코드가 안 왔을 수 있음.

동작 순서: ① 사용자가 "📈 차트" 탭을 처음 클릭 → ② HeavyChart의 코드가 아직 다운로드되지 않았음을 Suspense가 감지 → ③ fallback(로딩 스피너)을 화면에 대신 표시 → ④ 다운로드가 끝나면 리액트가 자동으로 fallback을 내리고 실제 HeavyChart로 교체. 개발자가 로딩 상태를 직접 state로 관리할 필요가 없다는 점이 핵심입니다.

두 번째 용도 — "코드가 준비 안 됨"뿐 아니라 "데이터가 준비 안 됨"도 감지한다

Suspense 자체는 "안에 있는 자식 중 준비 안 된 게 있으면 fallback을 보여준다"는 규칙만 갖고 있을 뿐, 정확히 뭘 기다리는지는 신경 쓰지 않습니다. 그래서 lazy 컴포넌트의 코드 다운로드뿐 아니라, async 서버 컴포넌트가 함수 안에서 await로 데이터를 가져오는 동안에도 똑같이 fallback을 보여주는 용도로 쓸 수 있습니다(react-12 — 서버 컴포넌트 + Suspense 참고). 감싸는 방법(<Suspense fallback={...}>)은 완전히 동일하고, 그 안에 lazy(...) 컴포넌트가 들어가는지 async 서버 컴포넌트가 들어가는지만 다릅니다.

lazyWithDelay 헬퍼 — 실습에서 일부러 1초 늦춘 이유

로컬 개발 서버에서는 파일이 이미 내 컴퓨터 디스크에 있어서 다운로드가 거의 순식간에 끝나버려, 정작 Suspense의 fallback(로딩 스피너)이 눈 깜빡할 사이에 지나가 버립니다. 그래서 실습 코드는 이 함수로 lazy를 한 겹 더 감쌌습니다.

  • lazyWithDelay(factory) — factory 자리에 원래의 () => import('./Foo')를 그대로 받는 함수.
  • new Promise((resolve) => setTimeout(() => resolve(factory()), 1000)) — "1초(1000ms) 뒤에야 진짜 factory()를 실행해서 그 결과로 약속을 지키겠다"는 새 Promise를 만들어 돌려줌.
  • lazy(() => ...) — 결국 lazy에게 건네지는 건 "즉시 import하는 함수"가 아니라 "1초 기다렸다가 import하는 함수"로 바뀐 것.

즉 코드 분할이라는 실제 동작은 그대로 두고, 실습 중 로딩 스피너를 눈으로 확인할 수 있도록 인위적으로 지연시간만 끼워 넣은 학습용 장치입니다 — 실제 서비스 코드에는 이런 지연을 넣지 않습니다.

핵심 정리
  • lazy(() => import('./Foo'))로 감싼 컴포넌트는 처음 화면에 필요할 때가 아니라, 실제로 화면에 나타나야 하는 순간에만 코드를 따로 받아온다(코드 분할).
  • Suspense는 그 다운로드가 끝날 때까지 fallback UI를 대신 보여주고, 끝나면 자동으로 실제 컴포넌트로 교체해 준다 — 로딩 state를 직접 관리할 필요가 없다.
  • 정적 import(파일 맨 위)는 즉시 번들에 포함되고, 동적 import('...')(함수처럼 호출)만 별도 청크로 분리된다.
26

Context API란? — createContext · Provider · useContext

Context APIcreateContextprop drilling

한 줄 요약Context API는 props를 한 단계씩 계속 전달하지(prop drilling) 않고도, createContext()로 만든 "채널"에 <Context.Provider value={...}>로 값을 실어 보내면, 그 Provider 아래 어떤 컴포넌트든 useContext(Context) 한 줄로 바로 값을 꺼내 쓸 수 있게 해주는 React 내장 기능이다.

쉽게 말하면props로 값을 넘기는 건 옆 사람에게 손으로 전달해 달라고 부탁하는 릴레이와 같아서, 중간에 그 값이 필요 없는 사람도 어쩔 수 없이 계속 받아서 넘겨줘야 해요(prop drilling). Context는 방송 전파에 가까워요 — 방송국(Provider)이 전파를 내보내면, 그 전파가 닿는 범위 안에서는 누구든 라디오(useContext)만 켜면 중간 사람을 거치지 않고 바로 들을 수 있습니다.

세 단계를 요소별로 — 상자 만들기 → 값 담아 방송하기 → 꺼내 듣기

  • const MyContext = createContext(초기값) — "빈 상자(채널)"를 하나 만드는 단계. 아직 실제 값은 없고, 통로만 뚫어둔 상태.
  • <MyContext.Provider value={실제값}>{children}</MyContext.Provider> — 이 채널에 실제 데이터를 실어서, 그 아래 자식 트리 전체로 방송을 시작하는 단계.
  • const value = useContext(MyContext) — Provider가 보내는 방송을 원하는 컴포넌트에서 바로 수신하는 단계. props로 거치지 않고 몇 단계 아래든 즉시 접근 가능.

세 단계 중 하나라도 빠지면 동작하지 않습니다 — Provider로 감싸지 않은 곳에서 useContext를 호출하면 createContext에 넣어둔 초기값(보통 null)이 그대로 반환됩니다.

왜 굳이 이런 게 필요한가 — prop drilling

컴포넌트 트리가 A → B → C → D처럼 깊어질 때, 맨 아래 D에서만 필요한 값을 A가 갖고 있다면 B와 C는 그 값을 전혀 안 쓰는데도 "다음 단계로 넘겨주기 위해서만" props로 받아야 합니다. 이 현상을 prop drilling이라고 부르고, 중간 컴포넌트가 늘어날수록 유지보수가 번거로워집니다. Context는 B·C를 건너뛰고 A(Provider)에서 D(useContext)로 값을 직접 흘려보냅니다.

커스텀 훅으로 감싸는 관례 — useContext를 직접 노출하지 않는 이유

실무에서는 useContext(MyContext)를 컴포넌트에서 직접 쓰기보다, useTheme()처럼 자체 훅으로 한 번 감싸는 경우가 많습니다. 이렇게 하면 ① 사용하는 쪽은 Context 객체 자체를 몰라도 되고(import 간소화), ② Provider 밖에서 잘못 호출했을 때 if (!ctx) throw new Error(...)로 "Provider 안에서 쓰세요" 같은 명확한 에러를 미리 던져줄 수 있습니다.

핵심 정리
  • Context는 createContext(상자) → Provider(값 방송) → useContext(값 수신) 3단계로 동작하며, prop drilling 없이 원하는 곳에서 바로 값을 꺼내 쓰게 해준다.
  • Provider로 감싸지 않은 곳에서 useContext를 호출하면 createContext의 초기값(보통 null)이 그대로 반환된다.
  • useContext를 직접 노출하지 않고 자체 훅으로 감싸면 import가 간단해지고, Provider 밖 호출 시 명확한 에러를 던질 수 있다.
27

Zustand란? — Context API와 무엇이 다른가

Zustand전역 상태셀렉터

한 줄 요약Zustand는 create()로 컴포넌트 트리 밖에 저장소를 만들고 Provider로 감싸지 않아도 어디서든 import해서 쓰는 전역 상태 라이브러리로, Context API와 달리 셀렉터로 구독한 조각이 바뀔 때만 리렌더링된다는 점이 가장 큰 차이다.

쉽게 말하면Context API는 "이 건물(Provider) 안에서만 들리는 사내 방송"이라 반드시 감싸야 했다면, Zustand는 그냥 클라우드에 있는 공유 문서예요 — 아무 파일에서나 import 한 줄이면 열람할 수 있고, 문서 전체가 아니라 내가 보고 싶은 시트(상태 조각)만 구독하면 다른 시트가 바뀌어도 나는 신경 쓸 필요가 없습니다.

Context API와 나란히 비교하면

  • 감싸는 구조 — Context는 <Provider>로 트리를 감싸야 하고, 그 안에서만 useContext가 동작. Zustand는 감싸는 구조 자체가 없고 어디서든 저장소를 import.
  • 리렌더링 범위 — Context는 Provider의 value가 바뀌면 그 아래에서 useContext를 쓰는 컴포넌트 전부가 리렌더링. Zustand는 셀렉터로 구독한 조각이 바뀔 때만, 그 조각을 구독한 컴포넌트만 리렌더링.
  • 정의 위치 — Context는 값을 갱신하는 함수를 보통 Provider 컴포넌트 안에 둠. Zustand는 set/get을 쓰는 액션 함수를 저장소 정의 자체에 포함.

create · set · get — 저장소를 이루는 세 조각

create((set, get) => ({ ... })) 한 줄에 세 가지 역할이 섞여 있습니다. create는 저장소를 만드는 함수, set은 상태를 갱신하는 함수(넘긴 키만 병합), get은 지금 이 순간의 최신 상태를 읽는 함수입니다. set·get은 리액트 Hooks가 아니라 저장소 자체가 주는 도구라서, 컴포넌트 밖(예: 이벤트 핸들러가 아닌 순수 함수)에서도 상태를 갱신할 수 있습니다.

셀렉터 — 왜 useStockStore((s) => s.watchlist)처럼 쓰는가

저장소를 통째로 구독(useStockStore())하면 저장소 안 어떤 값이 바뀌어도 리렌더링됩니다. 화살표 함수로 원하는 조각만 콕 집으면(s => s.watchlist), 그 조각이 바뀔 때만 리렌더링되어 불필요한 렌더링을 줄일 수 있습니다.

핵심 정리
  • Zustand는 Provider 없이 create()로 만든 저장소를 어디서든 import해서 쓴다.
  • 셀렉터로 구독한 상태 조각이 바뀔 때만 리렌더링되어, Context보다 리렌더링 범위가 좁다.
  • 액션 함수(set/get 사용)를 저장소 정의 안에 두어 상태와 로직을 한 곳에 모은다.
관련 실습: react-09 — Zustand
28

Zustand persist 미들웨어란? — localStorage 자동 저장/복원

persistlocalStoragepartialize미들웨어

한 줄 요약persistcreate(persist(fn, options))처럼 Zustand 스토어를 감싸는 미들웨어로, options.partialize가 고른 상태 조각을 브라우저의 localStorage에 자동으로 저장하고, 페이지를 다시 열 때 그 값을 자동으로 복원해준다.

쉽게 말하면Zustand 스토어는 원래 "화이트보드"라서 새로고침(F5)하면 지웠다 다시 쓴 것처럼 초기값으로 돌아갑니다. persist는 그 화이트보드에 자동 저장 기능이 달린 액자를 씌우는 것과 같아요 — 내용이 바뀔 때마다 몰래 사진을 찍어 서랍(localStorage)에 넣어두고, 다음에 켤 때 그 사진을 보고 화이트보드를 그대로 복원합니다. 게임을 끄기 전 자동 저장이 되고, 다시 켜면 저장된 지점부터 이어지는 것과 같은 원리입니다.

create(persist(fn, options)) — 감싸는 함수 하나 더

import { persist } from 'zustand/middleware'로 가져온 persist는 원래의 스토어 정의 함수((set, get) => ({...}))를 통째로 받아, 그 위에 "자동 저장/복원" 기능을 덧씌운 새 버전을 돌려줍니다. 그 결과를 다시 create()에 넣으면 평소처럼 useStockStore((s) => s.watchlist)로 쓸 수 있는 훅이 만들어집니다 — 사용하는 컴포넌트 쪽 코드는 persist가 있든 없든 똑같습니다.

options.name과 options.partialize

  • name — localStorage 안에서 이 데이터를 찾을 때 쓰는 열쇠(key) 이름. 브라우저 개발자도구의 Application → Local Storage에서 이 이름으로 실제 저장된 JSON을 확인할 수 있습니다.
  • partialize: (state) => ({...}) — 저장소 전체 상태 중, 이 함수가 리턴하는 조각만 골라 저장합니다. 지정하지 않으면 상태 전체가 그대로 저장됩니다.

시세(prices)나 로딩/에러 상태처럼 "지금 이 순간에만 의미 있는" 값은 partialize에서 일부러 빼는 것이 일반적입니다 — 오래된 시세가 저장돼 있으면 다음에 열었을 때 그 자체로 잘못된 정보가 되기 때문입니다.

새로고침해도 이어지는 것 / 매번 새로 받아오는 것

사용자가 직접 고른 값(관심종목 목록, 선택한 종목, 보유 포트폴리오)은 "그 사람의 설정"이라 계속 유지되는 게 자연스럽지만, 서버에서 받아오는 값(실시간 시세)은 시간이 지나면 틀린 값이 되므로 매번 새로 요청하는 게 맞습니다. persist를 쓸 때는 항상 이 두 가지를 구분해서 partialize에 무엇을 담을지 정해야 합니다.

핵심 정리
  • persist(fn, options)로 스토어를 감싸면 지정한 상태 조각이 localStorage에 자동 저장·복원된다.
  • name은 localStorage에 저장될 키 이름, partialize는 저장할 상태 조각을 고르는 필터다.
  • 사용자가 직접 설정한 값은 저장하고, 시간이 지나면 낡아버리는 서버 응답값(시세 등)은 저장에서 빼는 것이 기본 원칙이다.
29

Tailwind CSS란? — 유틸리티 클래스로 짜는 스타일

Tailwind CSS유틸리티 클래스@themedark:

한 줄 요약Tailwind CSS는 px-3(padding), rounded-lg(border-radius)처럼 CSS 속성 하나하나에 대응하는 짧은 클래스 이름을 className에 조합해서 스타일을 만드는 "유틸리티 CSS 프레임워크"다.

쉽게 말하면직접 CSS를 쓰는 건 옷을 처음부터 재단해서 만드는 것이고, Tailwind는 이미 만들어진 라벨 스티커(bg-white, text-sm, rounded-lg)를 골라서 옷에 붙이는 것에 가깝습니다. 스티커 하나하나는 아주 단순하지만(색 하나, 크기 하나), 여러 개를 겹쳐 붙이면 원하는 스타일이 완성됩니다.

className에 스타일 이름을 직접 조합

className="px-3 py-1 rounded-lg text-sm border border-stock-border"처럼, 클래스 하나가 CSS 속성 한두 개에 대응합니다. 5강에서 <link rel="stylesheet">로 외부 CSS 파일을 따로 만들어 연결했던 것과 달리, globals.css@import "tailwindcss" 한 줄만 넣으면 이런 클래스들을 프로젝트 어디서든 즉시 쓸 수 있습니다 — 새 CSS 규칙을 따로 작성할 필요가 거의 없어집니다.

@theme으로 나만의 색상 클래스 만들기

@theme { --color-stock-cyan: #61dafb; }처럼 CSS 변수를 정의해두면, Tailwind가 자동으로 bg-stock-cyan·text-stock-cyan·border-stock-cyan 같은 클래스를 만들어줍니다. 색상 코드(#61dafb)를 여기저기 반복해서 적는 대신, 의미 있는 이름 하나로 프로젝트 전체에서 재사용하는 방식입니다.

다크모드는 어떻게? — @variant dark + html.dark

globals.css@variant dark (&:where(.dark, .dark *)); 한 줄을 추가하면, <html> 태그에 class="dark"가 붙어 있을 때만 dark:bg-stock-card 같은 dark: 접두사 클래스가 활성화됩니다. 즉 다크모드 전환은 별도의 마법이 아니라 "<html>에 클래스 하나를 붙였다 뗐다 하는" 아주 단순한 규칙이고, 그 클래스를 실제로 붙이고 떼는 역할은 React의 useEffect가 맡습니다(자세한 예시는 react-13 — Tailwind CSS + 다크모드 참고).

핵심 정리
  • Tailwind는 CSS 속성 하나하나에 대응하는 짧은 클래스를 className에 조합해 스타일을 만드는 유틸리티 프레임워크다.
  • @theme으로 프로젝트 전용 색상 등을 정의하면 그에 대응하는 클래스가 자동으로 생긴다.
  • @variant dark가 "<html>.dark일 때만 dark: 클래스 활성화"라는 다크모드 규칙 자체를 정의하고, 실제로 그 클래스를 붙이고 떼는 것은 별도의 React 코드(useEffect)가 담당한다.
30

WebSocket이란? — REST와 뭐가 다른가

WebSocketREST실시간 통신

한 줄 요약REST(fetch)는 요청 하나에 응답 하나를 받고 바로 연결을 끊는 "1회성 통신"이고, WebSocket은 new WebSocket(url)로 연결을 한 번 열어두면 그 뒤로 서버가 원할 때마다 계속 데이터를 보내주는 "상시 연결" 통신 방식이다.

쉽게 말하면REST는 편지를 보내고 답장을 기다리는 것에 가까워요 — 매번 다시 편지를 써서 부쳐야 합니다. WebSocket은 전화를 걸어서 통화 상태를 계속 유지해두는 것과 같습니다 — 한 번 연결해두면 상대방이 말할 때마다(onmessage) 바로 들리고, 나도 언제든 바로 말할 수 있습니다.

왜 실시간 시세엔 REST 대신 WebSocket을 쓸까

REST로 "실시간처럼" 보이게 하려면 setInterval로 몇 초마다 계속 fetch를 반복해야 합니다(폴링) — 매번 새 요청을 만들고 응답을 기다리는 비용이 들고, 가격이 안 바뀌었어도 계속 요청을 보내게 됩니다. WebSocket은 연결을 한 번만 맺어두면 서버가 "가격이 바뀔 때만" 알아서 보내주므로, 불필요한 요청 없이 진짜 실시간에 가깝게 동작합니다.

연결 → 구독 → 수신 → 해제, 4단계

  • new WebSocket('wss://...') — 연결 시작. http 대신 ws(암호화된 경우 wss) 프로토콜을 씁니다.
  • ws.onopen — 연결이 완료되면 실행. 보통 여기서 "이 종목 소식 받고 싶어요" 같은 구독 메시지를 보냅니다.
  • ws.onmessage — 서버가 데이터를 보낼 때마다(몇 번이든) 실행되는 콜백. REST의 .then()은 한 번만 실행되지만 이건 계속 반복 호출됩니다.
  • ws.close() — 더 이상 필요 없을 때 연결을 끊는 것. React에서는 useEffect의 cleanup 함수에서 호출해, 컴포넌트가 사라지거나 구독 대상이 바뀔 때 항상 정리되도록 합니다.
핵심 정리
  • REST는 요청·응답 한 번으로 끝나는 통신, WebSocket은 한 번 연결하면 계속 유지되는 양방향 통신이다.
  • 실시간성이 필요할 때 REST 폴링 대신 WebSocket을 쓰면 불필요한 반복 요청 없이 서버가 필요할 때만 데이터를 보내준다.
  • 연결(new WebSocket) → 구독(onopen) → 수신(onmessage) → 해제(close, cleanup에서)의 흐름을 기억해두면 된다.

D · Next.js 핵심 개념 — 파일 기반 라우팅이 처음이라면

31

서버 컴포넌트 vs 클라이언트 컴포넌트란? (Next.js)

서버 컴포넌트use clientNext.js

한 줄 요약Next.js의 모든 컴포넌트는 기본적으로 서버에서 실행되는 "서버 컴포넌트"라 Hooks·Context 같은 브라우저 전용 기능은 못 쓰는 대신 컴포넌트를 async로 선언할 수 있고, 사용자 상호작용이 필요한 부분만 파일 맨 위에 'use client'를 선언해 예전 방식의 "클라이언트 컴포넌트"로 전환한다.

쉽게 말하면서버 컴포넌트는 "주방에서 미리 완성해서 손님상에 내보내는 요리"예요 — 서버에서 한 번 그려져 완성된 HTML만 브라우저로 전달되고, 그 뒤로는 스스로 다시 그려지지 않습니다. 클라이언트 컴포넌트는 "손님 테이블에서 즉석으로 조리하는 요리"에 가까워요 — 브라우저에서 계속 상태를 갖고, 클릭 같은 이벤트에 반응해 즉석으로 다시 그려집니다.

지금까지 배운 React(Vite)와 무엇이 다른가

01~08강에서 만든 컴포넌트는 전부 브라우저에서 실행되는 "클라이언트 컴포넌트"였습니다 — useState로 상태를 갖고, 클릭하면 즉시 다시 그려지는 방식입니다. Next.js는 이 기본을 뒤집어 아무 선언도 안 하면 서버 컴포넌트가 됩니다. 서버 컴포넌트는 서버에서 딱 한 번 실행돼 결과 HTML만 브라우저로 보내지고, 그 이후로는 useState 같은 게 있을 수도 없습니다(애초에 "다시 실행해서 다시 그리는" 개념 자체가 없음).

서버 컴포넌트에서 할 수 없는 것 / 클라이언트 컴포넌트에서 할 수 없는 것

  • 서버 컴포넌트가 못 하는 것useState·useEffect 같은 Hooks, Context API·Zustand 같은 전역 상태 공유. 전부 "브라우저에서 사용자 이벤트에 반응해 다시 그리는" 능력을 전제로 하는데, 서버 컴포넌트는 그 능력 자체가 없습니다.
  • 클라이언트 컴포넌트가 (권장하지) 않는 것 — 컴포넌트 함수 자체를 async function으로 선언하는 것. 리액트가 브라우저에서 그 비동기 결과를 "언제, 어떻게" 그려야 할지 알 수 없기 때문입니다. 비동기 처리가 필요하면 useEffect 안에서 처리합니다.
  • 'use client' — 파일 맨 위에 이 한 줄을 선언하면, 그 파일의 컴포넌트는 클라이언트 컴포넌트로 전환되어 Hooks·이벤트 핸들러를 다시 쓸 수 있습니다.

동적 라우트 페이지가 async 컴포넌트일 수 있는 이유

react-08의 app/blog/[id]/page.jsexport default async function BlogPost({ params })처럼 컴포넌트 자체가 async 함수입니다 — 이게 가능한 이유가 바로 이 페이지가 서버 컴포넌트이기 때문입니다. 서버는 한 번 실행하고 끝이므로 "결과가 나올 때까지 기다렸다가(await) 그 결과로 HTML을 그리는" 방식이 자연스럽게 동작합니다. 클라이언트 컴포넌트였다면 이런 최상위 async 선언 자체가 불가능합니다.

핵심 정리
  • Next.js 컴포넌트는 기본이 서버 컴포넌트 — 서버에서 한 번 실행돼 HTML로만 전달되고, Hooks·Context 같은 "다시 그리는" 기능은 쓸 수 없다.
  • 서버 컴포넌트는 컴포넌트 자체를 async로 선언할 수 있다 — 동적 라우트에서 await params를 쓸 수 있는 이유가 이것이다.
  • 사용자 상호작용(useState, 이벤트 핸들러 등)이 필요하면 파일 맨 위에 'use client'를 선언해 클라이언트 컴포넌트로 전환한다.
32

파일 기반 라우팅이란? — 폴더 구조 = URL (Next.js App Router)

파일 기반 라우팅App Router동적 라우트Link

한 줄 요약Next.js App Router는 src/app/ 아래의 폴더·파일 이름 자체가 곧 URL이 되는 방식으로(about/page.js/about), 대괄호로 감싼 폴더([id])는 하나의 파일로 무한한 동적 경로를 처리하는 동적 라우트가 되며, 페이지 이동은 새로고침 없는 <Link>로 한다.

쉽게 말하면지금까지는 "이 주소로 오면 이 화면을 보여줘"라는 라우팅 지도를 코드로 직접 그려야 했다면, Next.js는 "폴더 이름이 곧 지번(주소)"이라는 규칙만 지키면 됩니다. blog라는 폴더를 만들면 그 자체로 /blog 주소가 생기고, 폴더 이름을 [id]처럼 대괄호로 감싸면 "이 자리엔 뭐가 오든 다 받는 자리"라는 뜻이 됩니다 — 번지수 끝자리만 비워둔 도로명 주소와 비슷해요.

정적 라우트 — 폴더 = 고정 주소

app/about/page.js를 만들면 그 즉시 /about 경로가 생깁니다. 라우터에 경로를 등록하는 코드가 따로 없습니다 — 폴더를 만들고 그 안에 page.js라는 정해진 이름의 파일을 두는 것 자체가 등록 행위입니다. 폴더를 중첩하면(about/career/page.js) 경로도 그만큼 깊어져 /about/career가 됩니다.

동적 라우트 — [id]가 "뭐든 받는 자리"인 이유

app/blog/[id]/page.js처럼 폴더 이름을 대괄호로 감싸면, 그 위치에 오는 어떤 값이든(1, 2, hello...) 전부 이 파일 하나가 처리합니다. 블로그 글이 수백 개여도 page.js 파일 하나만 있으면 되고, 실제로 어떤 글인지는 컴포넌트 안에서 params로 전달받은 id 값을 보고 직접 찾아서 판단합니다(react-08의 posts.find((p) => p.id === Number(id))처럼).

<Link href="..."> vs <a href="...">

둘 다 클릭하면 페이지를 이동시키지만 방식이 다릅니다.

  • <a href="/about"> — 브라우저의 기본 페이지 이동. 클릭하면 페이지 전체를 다시 요청해서 처음부터 새로 불러옵니다(전체 새로고침, 화면이 한 번 깜빡임).
  • <Link href="/about"> — Next.js가 제공하는 컴포넌트. 클릭하면 전체를 다시 불러오지 않고, 필요한 부분만 자바스크립트로 교체하는 클라이언트 사이드 네비게이션을 수행합니다. 화면이 깜빡이지 않고 체감 속도가 더 빠릅니다.

layout.js와의 관계 — 라우팅 되는 부분과 안 되는 부분

페이지를 이동해도 layout.js에 있는 헤더·푸터는 다시 그려지지 않고 그대로 유지됩니다 — 실제로 바뀌는 건 layout.js{children} 자리에 들어가는 각 page.js의 내용뿐입니다. "폴더 구조 = URL"이라는 규칙은 page.js에만 적용되고, layout.js는 그 경로와 그 하위 모든 경로에 공통으로 적용되는 틀이라는 점이 다릅니다.

핵심 정리
  • src/app/ 아래 폴더 구조 자체가 URL이다 — 폴더 안에 page.js를 두면 그 경로가 자동으로 생긴다.
  • [id]처럼 대괄호로 감싼 폴더는 동적 라우트가 되어, 파일 하나로 무한한 URL 패턴을 처리한다.
  • <Link>는 새로고침 없는 클라이언트 사이드 네비게이션을 제공하며, layout.js의 공통 UI는 페이지 이동에도 다시 그려지지 않는다.
33

API Routes란? — route.js로 프론트 안에 백엔드 두기 (Next.js)

API Routesroute.jsfetch

한 줄 요약app/api/.../route.js 파일에 GET 같은 HTTP 메서드 이름의 함수를 export하면, 그 경로가 화면이 아니라 데이터를 응답하는 백엔드 엔드포인트가 되어 프론트엔드 컴포넌트가 fetch('/api/...')로 호출할 수 있다.

쉽게 말하면page.js가 손님에게 보여줄 "매장 진열창"이라면, route.js는 손님이 안 보이는 "주문을 받고 답을 만들어 내보내는 주방/창구"예요. 같은 app/ 폴더 규칙을 쓰지만 하나는 화면을, 하나는 데이터를 돌려준다는 점만 다릅니다.

page.js와 같은 위치 규칙, 다른 리턴값

파일 기반 라우팅에서 배운 "폴더 구조 = 경로" 규칙은 그대로입니다. 다른 점은 page.js는 JSX(화면)를 리턴하고, route.jsGET·POST 같은 함수가 Response 객체(주로 Response.json({ ... }))를 리턴한다는 것입니다. app/api/stock/[symbol]/route.js처럼 동적 세그먼트 [symbol]도 페이지와 똑같이 await params로 꺼냅니다.

프론트에서 호출하는 법 — 절대경로가 필요 없는 이유

외부 API는 https://api.example.com/...처럼 전체 주소가 필요하지만, 내가 만든 API Route는 같은 프로젝트·같은 서버 안에 있으므로 fetch('/api/search?q=apple')처럼 상대경로만으로 호출할 수 있습니다. 응답은 await res.json()으로 실제 객체/배열로 변환해서 씁니다.

핵심 정리
  • route.js는 page.js와 같은 폴더 규칙을 쓰지만 화면 대신 Response를 리턴하는 백엔드 엔드포인트다.
  • 동적 세그먼트([symbol] 등)는 페이지와 동일하게 await params로 꺼낸다.
  • 같은 프로젝트 안의 API Route는 절대경로 없이 fetch('/api/...')로 바로 호출할 수 있다.

C · 실전에서 만나는 버그와 함정 — 실습하다 막혔다면

34

이벤트 핸들러 "즉시 실행" 무한 루프

onClick무한 루프화살표로 감싸기

한 줄 요약onClick={setCount(count-1)}처럼 매개변수가 있는 함수 호출을 그대로 이벤트 핸들러 자리에 쓰면 렌더링되는 순간 즉시 실행되어 state가 계속 바뀌고 다시 렌더링되는 무한 루프에 빠지므로, 반드시 onClick={() => setCount(count-1)}처럼 화살표 함수로 한 번 감싸야 한다.

쉽게 말하면onClick 자리에는 "지금 당장 하세요"가 아니라 "클릭하면 이걸 해주세요"라는 메모가 와야 해요. 그냥 실행문을 써버리면 "지금 당장 실행하고 결과를 넣어라"는 뜻이 되어, 렌더링될 때마다 즉시 실행 → state 변경 → 리렌더링 → 즉시 실행 …이 끝없이 반복됩니다.

onClick={fn(x)} vs onClick={() => fn(x)}, 괄호 위치 하나 차이

둘은 눈으로 보면 거의 똑같아 보이지만, "언제 실행되는지"가 완전히 다릅니다.

  • onClick={fn(x)}fn(x)이 코드가 읽히는 바로 그 순간(렌더링 시점)에 즉시 호출됩니다. onClick에는 그 결과값(보통 undefined)만 남아, 클릭과 무관하게 이미 실행이 끝나버린 상태입니다.
  • onClick={() => fn(x)} — 화살표 함수라는 "포장지"에 fn(x) 호출을 감싸 전달. 이 포장지 자체는 아직 실행되지 않고, 클릭이라는 사건이 실제로 일어나야 브라우저가 열어서 실행합니다.

비유하면 onClick={fn(x)}는 "초인종 옆에 '지금 당장 문 여세요'라고 써 붙여서, 안내문을 읽는 즉시(렌더링 시) 문이 열려버리는 것"이고, onClick={() => fn(x)}는 "초인종을 눌러야만 문이 열리도록 만든 정상적인 초인종"입니다 — state가 바뀌어 리렌더링될 때마다 안내문을 다시 읽으므로(다시 렌더링하므로), 첫 번째 방식은 읽을 때마다 문이 열리고 → state가 또 바뀌고 → 또 읽고… 끝없이 반복됩니다.

"함수를 전달"하는 것과 "함수를 실행한 결과를 전달"하는 것의 차이

onClick={setCount(count - 1)}은 이 코드가 평가되는 시점에 setCount(count - 1)이 즉시 호출되고, 그 반환값(undefined)이 onClick에 할당됩니다 — 클릭과 무관하게 렌더링될 때마다 실행됩니다. onClick={() => setCount(count - 1)}은 화살표 함수 자체를 전달하므로, 이 함수는 "클릭이라는 사건이 일어날 때"만 브라우저가 호출해 줍니다. 매개변수가 필요 없는 함수(addTodo처럼)는 onClick={addTodo}로 그대로 전달 가능하지만, 매개변수를 넘겨야 하는 호출(removeTodo(id)처럼)은 항상 화살표로 감싸야 합니다.

핵심 정리
  • onClick={fn(x)}는 렌더링 즉시 실행 → 무한 루프 위험. onClick={() => fn(x)}는 클릭 시에만 실행된다.
  • 매개변수 없는 함수는 onClick={fn} 그대로 전달 가능, 매개변수 있는 호출은 항상 화살표로 감싼다.
  • 클릭도 안 했는데 콘솔 로그가 계속 찍히거나 브라우저가 멈추면 이 패턴부터 의심한다.
관련 실습: react-03 Counter
35

useEffect 2개 + StrictMode = 저장한 데이터가 빈 값으로 사라지는 버그

StrictModeuseEffectlocalStorage게으른 초기화

한 줄 요약초기값을 빈 배열 []로 두고 "불러오기 useEffect"와 "저장 useEffect"를 따로 두면, StrictMode의 이중 마운트 때문에 저장 effect가 초기값(빈 배열)을 먼저 localStorage에 덮어써 버려서 기존 데이터가 통째로 사라지는 버그가 발생한다.

쉽게 말하면이 버그는 "빈 메모장을 서랍에 먼저 넣어버리는 사고"예요. 화면이 뜨자마자 "① 서랍(localStorage)에서 꺼내와 메모장에 옮겨 적기"와 "② 지금 메모장 내용을 서랍에 저장하기"가 동시에 실행되는데, 아직 ①이 옮겨 적기 전이라 메모장이 텅 비어 있어서 ②가 그 빈 메모장을 서랍에 덮어써 버립니다. StrictMode가 이 과정을 한 번 더 반복시켜서, 두 번째에는 아예 빈 서랍만 남습니다.

effect가 2개인데, 역할이 완전히 반대다

버그를 이해하려면 먼저 이 코드에 effect가 "왜 2개나 있는지"부터 역할별로 구분해야 합니다.

  • 불러오기 effect([]) — "localStorage → state" 방향. 최초 1번, todos를 채우는 역할.
  • 저장 effect([todos]) — "state → localStorage" 방향. todos가 바뀔 때마다, 지금 state를 저장소에 옮겨 적는 역할.

문제는 이 둘이 같은 렌더링 직후에 함께 실행된다는 점입니다 — "불러오기가 끝난 다음에 저장"이 아니라 "둘 다 일단 실행"되므로, 아직 아무것도 안 불러온 시점의 todos(초기값 [])를 저장 effect가 먼저 저장소에 써버릴 수 있습니다. 아래는 그 사고가 실제로 일어나는 순서입니다.

사고가 일어나는 순서

문제의 코드 형태: useState([])로 시작 + 불러오기용 useEffect(() => { const saved = localStorage.getItem('todos'); if (saved) setTodos(JSON.parse(saved)) }, []) + 저장용 useEffect(() => { localStorage.setItem('todos', JSON.stringify(todos)) }, [todos]) — effect를 2개로 나눈 구조입니다.

① 첫 마운트: todos는 아직 초기값 []. 두 effect가 같은 렌더 직후 함께 실행되며, 저장 effect가 "아직 반영 전인 []"를 그대로 localStorage에 씁니다. ② StrictMode의 재마운트: 불러오기 effect가 localStorage를 다시 읽는데, 이미 ①에서 []로 덮어써진 뒤라 빈 배열만 읽힙니다. 결과적으로 원래 있던 데이터가 화면과 저장소 양쪽에서 사라집니다.

해결책: 애초에 "초기값이 []인 순간"을 없애면 됩니다 — useState(() => { const saved = localStorage.getItem('todos'); return saved ? JSON.parse(saved) : [] }) 게으른 초기화로 시작하면 todos는 컴포넌트가 처음 실행되는 순간부터 이미 기존 데이터를 갖고 있으므로, 저장용 useEffect 1개만 있어도 StrictMode가 몇 번을 재실행하든 안전합니다.

핵심 정리
  • "불러오기 useEffect(빈 배열 의존성)" + "저장 useEffect" 조합은 초기 렌더의 빈 상태가 저장 effect에 의해 먼저 기록돼 버리는 타이밍 문제를 가진다.
  • StrictMode의 의도된 이중 마운트가 이 문제를 증폭시켜 기존 데이터를 완전히 날려버린다.
  • 해결: useState(() => localStorage에서 읽기)로 게으른 초기화하고, 저장용 useEffect 1개만 사용 — "초기값이 빈 값인 순간"을 아예 없애는 것이 근본 해결책이다.
TIP이 버그는 실제로 react-03 TodoApp 실습에서 다른 방식(effect 2개)으로 짰을 때 나올 수 있는 현상이었습니다 — 우리가 실제로 쓴 코드는 게으른 초기화를 써서 안전합니다. 자세한 실습 코드는 react-03의 ⚠️ 표시된 개념 블록을 참고하세요.
36

stale closure — 의존성 배열을 빠뜨렸을 때 생기는 "오래된 값" 버그

stale closure의존성 배열클로저

한 줄 요약useEffect나 이벤트 핸들러 안에서 사용하는 state 값을 의존성 배열에 빠뜨리면, 그 함수는 "만들어질 당시의 오래된 값"을 계속 기억(클로저)하고 있어서 실제 최신 state와 다른 값을 참조하는 stale closure 버그가 생긴다.

쉽게 말하면클로저는 "그 함수가 태어난 순간의 스냅사진"을 기억하는 습성이에요. useEffect(() => {...todos...}, [])처럼 의존성 배열에 todos를 안 넣으면, 이 effect는 "최초 렌더링 때 찍은 todos 사진"만 계속 들고 있어서, 실제로는 todos가 여러 번 바뀌었는데도 effect 안에서는 여전히 그 사진(빈 배열 등)만 보게 됩니다.

"렌더링마다 새로 만들어짐"과 "그 순간을 기억함", 두 가지가 겹쳐서 버그가 난다

stale closure는 사실 두 가지 사실이 조합되어 생기는 현상입니다 — 하나씩 따로 보면 이해가 쉽습니다.

  • 사실 ① — 컴포넌트가 리렌더링될 때마다 useEffect(() => {...})의 콜백 함수도 매번 새로 만들어집니다(이전 함수와는 다른, 그 순간 전용 함수).
  • 사실 ② — 자바스크립트 클로저는 함수가 만들어지는 그 순간의 변수 값을 사진 찍듯 기억합니다.
  • 의존성 배열이 [] — 최초 1번 만들어진 그 함수가 이후로도 계속 재사용되는데, 그 함수는 ①②에 따라 "최초 렌더링 때의 todos 사진" 하나만 영원히 들고 있습니다.

비유하면 매 렌더링마다 그 순간 화면을 촬영하는 카메라(effect 콜백)가 새로 하나씩 생기는데, 의존성 배열이 []면 "첫 번째 카메라만 계속 쓰겠다"고 정한 것과 같아요 — 그 카메라는 계속 첫 촬영 당시 사진(todos가 비어있던 순간)만 보여줄 뿐, 이후 실제 상황이 아무리 바뀌어도 새로 찍지 않습니다.

클로저는 "정의될 때의 변수"를 기억한다

자바스크립트의 클로저(closure)는 함수가 "자신이 정의될 때의 주변 변수들"을 기억하는 성질입니다 — useEffect 콜백도 하나의 함수이므로 렌더링마다 새로 만들어지고, 그 순간의 state 값들을 캡처합니다. 의존성 배열에 effect 안에서 쓰는 값(todos 등)을 넣지 않으면, effect는 리렌더링돼도 다시 실행되지 않으므로 "맨 처음 렌더링 때의 값"에 갇혀버려 최신 값과 어긋나는 stale(오래된) 값을 참조하게 됩니다. 예방법은 eslint-plugin-react-hooks의 exhaustive-deps 규칙을 켜두는 것이지만, 지금 단계에서는 "effect 안에서 쓰는 state/props는 전부 의존성 배열에 넣는다"는 원칙만 기억해도 충분합니다.

핵심 정리
  • 클로저는 함수가 만들어질 때의 변수 값을 "그 순간 그대로" 기억하는 성질이다.
  • effect/콜백 안에서 쓰는 값을 의존성 배열에서 빠뜨리면, 그 함수는 계속 오래된(stale) 값만 참조하게 된다.
  • effect 안에서 실제로 사용하는 값은 빠짐없이 의존성 배열에 넣는 것이 기본 원칙이다(exhaustive-deps).

E · 💬 내가 실제로 했던 질문들 — 수업 중 막혔던 지점 그대로

37

다크모드 Q&A — "dark 클래스 하나 붙이면 정말 싹 다 바뀌어?" (12강에서 했던 질문들)

Tailwind v4다크모드documentElement@variant

한 줄 요약12강 다크모드 실습에서 실제로 막혔던 질문 5개 — @variant에 뜨는 빨간 밑줄의 정체, dark: 접두사가 평소엔 왜 안 먹는지, ThemeWrapper가 어디서 뭘 하는지, 그리고 "감싼 범위 밖인 body까지 왜 바뀌는지"에 대한 답을 모았다.

쉽게 말하면다크모드의 핵심은 "건물 전체의 조명 스위치는 옥상(html 태그) 한 곳에만 있다"는 것입니다 — ThemeWrapper가 JSX에서 어디를 감싸고 있든, 내부에서는 document.documentElement(옥상)를 직접 조작하므로 건물 전체(body 포함)의 불이 한꺼번에 바뀝니다. dark:bg-... 클래스들은 "스위치가 켜졌을 때만 작동하는 예약 전구"라서 평소에는 있어도 안 켜져 있는 것뿐이에요.

Q. globals.css의 @variant에 빨간 밑줄 에러가 떠요

@variant는 Tailwind CSS v4에서 클래스 기반 다크모드를 정의하는 정식 문법입니다. VS Code의 기본 CSS 린터가 v4 신규 지시어를 아직 몰라서 밑줄을 그을 뿐, 실제 Next.js 빌드는 정상입니다. .vscode/settings.json"css.lint.unknownAtRules": "ignore"를 추가하면 에디터 경고만 사라집니다 — "에디터의 밑줄 ≠ 실제 에러"를 처음 체감한 사례.

Q. dark:가 붙은 클래스는 이미 다 적혀 있는데 왜 평소엔 적용이 안 돼요?

dark:는 "다크모드 전용 예약 조건"입니다. <html>dark 클래스가 없으면 일반 클래스(bg-white)만 적용되고, <html class="dark">가 되는 순간 globals.css@variant dark (&:where(.dark, .dark *)); 규칙에 따라 dark:bg-...들이 일반 클래스를 덮어씁니다 — 스위치가 켜져야만 동작하는 조건부 스타일입니다.

Q. ThemeWrapper로 감싼 부분만 바뀌는 거 아니에요? body는 왜 같이 바뀌죠?

일반적인 JSX 구조라면 감싼 하위 요소만 영향을 받는 게 맞습니다. 하지만 ThemeWrapper는 화면에 뭔가를 그리는 게 아니라, useEffect 안에서 브라우저 객체 document.documentElement(<html> 태그 자체)를 직접 선택해서 클래스를 넣었다 뺐다 합니다. <body>도 결국 <html>의 자식이므로, JSX에서 어디를 감쌌는지와 무관하게 페이지 최상단부터 전체가 바뀝니다 — "React 컴포넌트 경계"와 "DOM 직접 조작의 영향 범위"는 별개라는 걸 배운 질문.

핵심 정리
  • 에디터의 빨간 밑줄이 곧 빌드 에러는 아니다 — 린터가 최신 문법을 모르는 경우가 있다.
  • dark: 접두사는 <html class="dark">라는 스위치가 켜져야만 동작하는 조건부 클래스다.
  • document.documentElement 직접 조작은 컴포넌트가 JSX 어디에 있든 페이지 전체에 적용된다.
38

WebSocket·환경변수 Q&A — "웹소켓이 뭐야?"부터 "왜 차트가 안 움직여?"까지 (13강에서 했던 질문들)

WebSocketNEXT_PUBLIC_cleanup폴링 폴백

한 줄 요약13강 실시간 차트 실습에서 실제로 했던 질문들 — 왜 API 키에 NEXT_PUBLIC_을 붙여야 하는지, 웹소켓이 HTTP와 뭐가 다른지, cleanup의 unsubscribe/close가 뭘 하는지, 그리고 "연결은 됐는데 차트가 안 밀려나는" 미스터리의 원인(미국장 마감)까지.

쉽게 말하면HTTP는 "주문할 때만 대답하고 끊는 카운터 주문"이고, 웹소켓은 "전용 전화선을 계속 켜둔 채 새 소식이 생길 때마다 상대가 바로 말해주는 통화"입니다. 그런데 전화선이 멀쩡해도 상대(미국 주식시장)가 자고 있으면(장 마감) 아무 말이 없어요 — 그래서 낮에는 2초마다 "지금 얼마예요?"라고 직접 물어보는 폴링으로 갈아탑니다.

Q. 왜 웹소켓 API 키는 NEXT_PUBLIC_을 붙여야 해요?

일반 환경변수(FINNHUB_API_KEY)는 서버 쪽(API Route, 서버 컴포넌트)에서만 읽을 수 있습니다. 웹소켓은 브라우저(클라이언트 컴포넌트)에서 직접 연결해야 하므로, 변수명 앞에 NEXT_PUBLIC_이 붙어야만 Next.js가 그 값을 브라우저 번들에 포함시켜 줍니다. 대신 브라우저에 노출되는 만큼 보안에는 취약해진다는 트레이드오프가 있습니다.

Q. useEffect 리턴 안의 unsubscribe와 ws.close()는 뭐 하는 거예요?

웹소켓 전화선을 깔끔하게 끊는 정리(cleanup) 코드입니다. 종목을 AAPL에서 TSLA로 바꾸거나 페이지를 나갈 때, ① 이전 종목의 실시간 알림 구독을 서버에 취소 통보(unsubscribe 메시지)하고 ② 열려 있던 소켓 연결 자체를 닫습니다(ws.close()). 이걸 안 하면 메모리 누수에 더해, 이전 종목 시세가 새 종목 차트에 섞여 들어오는 버그가 생깁니다.

Q. 차트가 계속 옆으로 밀려나면서 갱신돼야 하는데 왜 안 움직여요?

범인은 코드가 아니라 시차였습니다 — Finnhub 웹소켓은 미국 주식시장 개장 시간(한국 기준 밤 11:30~아침 06:00)에만 실시간 체결 데이터를 보냅니다. 한국 낮에는 연결이 정상이어도 체결 소식 자체가 없어서 차트가 멈춰 보였던 것. 그래서 장 마감 시간대이거나 키가 없을 때는 2초마다 시세 API를 호출하는 폴링(setInterval) 폴백으로 전환해, 언제 실습해도 차트가 우측으로 밀려나게 보완했습니다 — "코드는 맞는데 왜 안 되지?"의 원인이 환경(장 운영시간)일 수도 있다는 교훈.

핵심 정리
  • 브라우저에서 읽어야 하는 환경변수만 NEXT_PUBLIC_을 붙인다 — 노출되어도 되는 값인지 먼저 판단할 것.
  • effect가 연 연결(웹소켓·타이머)은 cleanup에서 반드시 닫는다 — 구독 해제 통보 후 close 순서.
  • "연결은 정상인데 데이터가 안 온다"면 코드보다 먼저 데이터 공급자의 상태(장 운영시간 등)를 의심해 본다.
39

차트 데이터 Q&A — "slice(-59)가 뭐야?" "그 .p는 무슨 문법이야?" (13강에서 했던 질문들)

슬라이딩 윈도우slicetoFixedAPI 응답 구조

한 줄 요약실시간 차트의 데이터 처리 한 줄 한 줄에서 했던 질문들 — [...prev.slice(-59), point]가 만드는 슬라이딩 윈도우, msg.data[msg.data.length - 1].p의 .p가 문법이 아니라 API 응답의 속성명이라는 것, 그리고 parseFloat(price.toFixed(2))가 왕복 변환인 이유.

쉽게 말하면슬라이딩 윈도우는 "60칸짜리 회전초밥 레일"입니다 — 새 접시(시세)가 하나 들어올 때마다 가장 오래된 접시 하나를 치워서 레일 위 접시 수를 항상 60개로 유지해요. 그리고 .p는 자바스크립트의 숨은 문법이 아니라 그냥 Finnhub가 정한 필드 이름(price의 약자)일 뿐이라, "모르는 점 표기가 나오면 문법서가 아니라 API 문서를 찾아라"가 정답이었습니다.

Q. setChartData((prev) => [...prev.slice(-59), point])에서 -59가 어떻게 작용해요?

슬라이딩 윈도우(sliding window) 기법입니다 — 요소별로 쪼개면: ① prev.slice(-59)는 기존 배열에서 뒤쪽(최신) 59개만 남기고 가장 오래된 맨 앞 1개를 버립니다(음수 인덱스 = 뒤에서부터 세기). ② [...59개, point]로 그 뒤에 새 시세 1개를 덧붙입니다. ③ 결과적으로 배열이 무한히 늘어나 렉이 걸리는 걸 막고 항상 최신 60개로 유지됩니다.

Q. pushPrice(msg.data[msg.data.length - 1].p)의 .p는 무슨 문법이에요?

특별한 문법이 아니라 Finnhub 웹소켓 응답 객체의 속성 이름입니다. 응답 형식이 { type: 'trade', data: [{ s: 'AAPL', p: 182.5, v: 100 }] }라서, msg.data[msg.data.length - 1]로 가장 최신 체결 객체를 꺼낸 뒤 그 안의 가격 필드 .p(price)를 읽는 것뿐입니다. s(symbol)·v(volume)도 마찬가지 — 낯선 점 표기가 나오면 문법이 아니라 그 API의 응답 명세를 먼저 확인하면 됩니다.

Q. const newPrice = parseFloat(price.toFixed(2))는 뭐예요?

"반올림 → 타입 복구"의 왕복 변환입니다: ① price.toFixed(2)는 소수점 둘째 자리까지 반올림하지만 결과가 "182.50" 같은 문자열이 됩니다. ② 문자열인 채로 두면 차트 계산이 어긋나므로 parseFloat(...)로 다시 숫자 182.5로 되돌립니다. toFixed()를 쓸 때마다 "결과가 문자열"이라는 함정을 기억할 것 — 07강(String)에서 배운 타입 변환 감각이 실전에서 쓰인 지점입니다.

핵심 정리
  • [...prev.slice(-59), point] — 오래된 것 하나 버리고 새것 하나 추가, 배열 크기를 60개로 고정하는 슬라이딩 윈도우.
  • data[data.length - 1]은 배열의 맨 마지막(가장 최신) 요소를 가리키는 관용 표현.
  • 모르는 .속성이 나오면 자바스크립트 문법이 아니라 API 응답 명세부터 찾아본다.
  • toFixed()의 결과는 문자열 — 계산에 쓰려면 parseFloat로 숫자로 복구해야 한다.

✏️ 시험 대비 — 객관식 연습문제

지금까지 배운 프론트엔드 전 범위(HTML·CSS·JS·React/Next.js)의 객관식 문제를 한 문제씩 풀면서 고르는 즉시 정답과 해설을 확인합니다. 짧은 해설 아래의 🔎 자세한 해설을 펼치면 보기 ①~④를 하나씩 왜 맞고 왜 틀렸는지 짚어 주고, 마지막에 더 쉬운 말로 정리한 설명까지 붙습니다(틀린 문제는 자동으로 펼쳐집니다). 마음에 걸리는 문제는 ⭐ 즐겨찾기 · 🔥 어려움으로 표시해 두면 나중에 표시한 것만 · 틀린 것만 골라 다시 풀 수 있어요.

과목
범위
문제 수

⌨️ 키보드로도 풀 수 있어요 — 숫자 1~4로 보기 선택, Enter로 다음 문제, S 즐겨찾기, D 어려움 표시.

다음 학습 예정

강의자료 목차 기준의 React & Next.js 커리큘럼을 전부 완료했습니다. 새 학습 주제가 생기면 여기 표시됩니다.

강의자료

수업에서 사용한 원본 슬라이드와, 학습하며 직접 정리한 문서들입니다.