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

웹 개발 학습 노트

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

마지막 점검: 2026-10-02 HTML 7개 단원 CSS 12개 단원 JavaScript 18개 단원 React·Next.js 14개 단원(입문 + 레슨 13) + 졸업 과제 학습 여정 4/4과목 기초 개념 사전 39개 항목 시험 대비 83문제 강의자료·정리 문서 7건
Java 백엔드 학습 노트로 이동 → 새 노트북 · 개발환경 준비 →

복습 — 여기서 시작하세요

복습 준비됐어요

카드 앞면을 보고 먼저 떠올린 뒤 뒤집어서 확인하세요.

무엇을 복습할까요
몇 장 볼까요

복습 추천

완료 체크한 카드0/0
다른 방법으로 복습하기

다음 학습

오늘의 학습 목표

전체 진도 마일스톤

📊 전체 분량 한눈에 보기
7
HTML 단원
12
CSS 단원
18
JavaScript 단원
14
React·Next.js 레슨
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 파일을 받아두면, 캐시를 지우거나 기기를 옮겨도 가져오기로 그대로 복원할 수 있습니다.

🗺️ 큰 그림 — 처음이라면 여기부터

레슨마다 정리한 카드가 나무 한 그루씩이라면, 이 탭은 숲 전체를 위에서 내려다본 지도입니다. HTML부터 Next.js까지 "결국 뭘 배운 건지", "꼭 알아야 할 게 뭔지", "면접·실무에서는 뭘 물어보는지"를 처음 보는 사람 눈높이로 한 장에 모았습니다.

💡 이렇게 보세요 — 처음엔 1~2번(지도·타임라인)만 3분 동안 훑고, 3번의 레슨 표와 4번의 단계 카드는 제목과 한 줄 요약만 읽어 내려가세요. 그다음 헷갈리는 단계 하나만 펼쳐서 읽으면 됩니다.

  1. 지도 — 배운 것들이 웹 서비스의 어느 칸인지
  2. 타임라인 — 왜 이 순서로 배웠고, 백엔드 과정과 어떻게 이어지는지
  3. 예제 프로젝트 따라가기 — React 레슨 01~13이 각각 무엇을 하려고 만든 것이고 무엇이 바뀌었나
  4. 단계별 핵심 — 단계마다 꼭 알 것 · 헷갈리는 것 · 실무 · 면접 질문
  5. 계속 다시 나오는 큰 생각 5가지 — 과목은 달라도 같은 원리
  6. 비슷해서 헷갈리는 말 — 한 표로 정리
  7. 면접에서 이 과정을 설명한다면 — 30초 답변

더 자세히 보고 싶으면 각 카드 아래 링크로 해당 노트에 바로 갑니다. 같은 흐름을 레슨 단위로 자세히 쪼갠 건 학습 여정 탭이에요.

1먼저 지도 한 장 — 웹 서비스에서 "요청 한 번"이 지나가는 길

이 과정에서 배운 모든 것은 결국 아래 그림의 어느 한 칸입니다. 게시판에서 "글 목록" 버튼을 한 번 누르면 이 순서로 일이 일어나요. 새로운 걸 배울 때마다 "이건 그림의 어느 칸이지?"부터 물어보면 길을 잃지 않습니다.

프런트엔드 = ① 칸(사용자 쪽), 백엔드 = ②·③ 칸(서버 쪽)입니다. 프런트엔드 과정에서 ①을 다 만들어 봤고, 지금 백엔드 과정에서 ②와 ③을 만들고 있어요. 둘을 이어 붙이면 "풀스택" 한 서비스가 됩니다.

2지금까지 어디를 왔나 — 과정 전체 타임라인

순서에는 이유가 있습니다. 앞 단계가 뒤 단계의 재료가 돼요. (프런트엔드 날짜는 강의자료 날짜 기준)

  1. 6월 말HTML · CSS완료화면의 뼈대와 꾸밈. 웹의 "결과물"이 어떻게 생겼는지부터 본다.
  2. 7월 초JavaScript완료화면에 동작을 붙인다. 마지막 AJAX에서 처음으로 "서버에 데이터를 달라고" 요청한다.
  3. 7월 말React · Next.js + 졸업 과제완료화면을 부품(컴포넌트)으로 조립하는 현대식 방법. StockDash로 총정리.
  4. 8/3 ~ 8/28Java (기초 → 객체지향 → 활용 → 네트워크)완료서버 프로그램을 쓸 언어. 객체지향이 이후 스프링까지 전부의 바탕이 된다.
  5. 8/31 ~ 9/9SQL · 데이터베이스완료데이터를 꺼내고 저장하는 언어. 게시판의 모든 기능이 결국 SQL 한 줄로 끝난다.
  6. 9/10 ~ 9/21웹 기초: JDBC → JSP → Servlet → MVC2완료자바와 DB, 자바와 브라우저를 손으로 직접 잇는다. 불편함을 몸으로 겪는 단계.
  7. 9/22 ~ 9/28Spring MVC + MyBatis완료손으로 하던 반복 작업을 프레임워크가 대신한다. 같은 게시판을 더 짧고 정돈되게.
  8. 9/29 ~ 지금답변형 게시판 — 페이징 · 답글 · 트랜잭션 · AOP📍 지금 여기"돌아가는" 게시판에서 "실무에서 쓸 만한" 게시판으로. 여러 쿼리를 한 묶음으로, 공통 기능은 바깥으로.
  9. 예정단위 테스트(JUnit) · 파일 업로드/다운로드 · 일정관리(캘린더) 게시판예정지금 만든 Spring MVC 구조 위에 기능을 더 얹는다.
  10. 예정Spring Boot · Thymeleaf · Spring Security 종합 프로젝트예정XML 설정을 걷어 내고, 로그인·권한까지 갖춘 "요즘 방식" 스프링으로 마무리.

3예제 프로젝트 따라가기 — React 레슨 01~13과 졸업 과제

React·Next.js 수업은 레슨 폴더 13개로 진행됐고, 크게 두 덩어리예요. 01~06은 레슨마다 작은 앱을 따로 만들며 React의 기본 도구를 하나씩 익히고, 07에서 Next.js로 갈아탄 뒤 08~13은 주식 대시보드 앱 하나를 매 레슨 키워 갑니다. 그 끝이 졸업 과제 StockDash입니다.

레슨무엇을 만들었나이 레슨을 한 이유(새로 배운 도구)앞 레슨의 어떤 불편을 해결했나
01 jsx_props프로필 카드 목록화면을 함수(컴포넌트)로, 데이터는 props로 내려 주기JS로 표 하나 그리려고 createElement를 반복하던 일
02 state_events_todo카운터 · 할 일 목록state가 바뀌면 화면이 다시 그려짐, useEffect로 localStorage 저장01은 데이터가 고정 — 사용자가 바꿀 수 없음
03 pref_hooks주식 목록 · 검색useRef · useMemo · useCallback · React.memo — 다시 그리기 줄이기state가 바뀔 때마다 모든 자식이 다시 그려짐
04 custom_hooks종목 상세커스텀 훅 4개(useDebounce·useInterval·useLocalStorage·useStockData)로 로직 재사용같은 state+effect 코드가 화면마다 반복
05 memo_lazymemo 비교 · 무거운 차트memo 있을 때/없을 때 비교, lazy + Suspense 코드 분할무거운 화면 때문에 첫 로딩이 느림
06 context_api테마 · 관심 목록 · 정렬Context로 전역 값 공유props를 여러 층 거쳐 넘기는 prop drilling
07 next_routing블로그Next.js — 폴더 = 주소, layout, [id]React만으로는 페이지 주소 관리가 따로 필요
08 zustand_store주식 대시보드 시작Zustand 스토어, 필요한 값만 구독Context는 값이 바뀌면 쓰는 쪽 전부 다시 그림
09 api_routes+ 종목 검색 · 시세API Routes — 프로젝트 안의 작은 서버외부 API를 브라우저에서 바로 부르면 키가 노출됨
10 async_persist+ 매수·매도 포트폴리오persist · 비동기 액션새로고침하면 관심 목록이 사라짐
11 server_components+ 종목 정보 패널서버 컴포넌트 + Suspense 스트리밍데이터를 브라우저에서 다 받아야 화면이 나옴
12 tailwind_darkmode+ 디자인 · 다크 모드Tailwind 유틸리티 클래스, 전역 테마인라인 style이 길고 테마 전환이 어려움
13 recharts_websocket+ 실시간 차트Recharts + WebSocket(서버가 밀어 주는 실시간 값)새 시세를 보려면 계속 다시 요청해야 함(폴링)
졸업 과제StockDash위 도구를 직접 골라 조합— 면접에서 "왜 그 도구를 골랐나"로 설명할 수 있는 결과물

각 레슨을 개념서 탭에서 한 장씩 자세히 공부할 수 있어요 — 교안의 어느 슬라이드 개념을 익히려고 그 레슨 코드를 짰는지 연결해 두었습니다.

4단계별 핵심 — 꼭 알아야 할 것만

카드를 펼치면 왜 배우나 · 꼭 알 것 · 헷갈리는 것 · 실무에서는 · 면접 질문이 나옵니다. 면접 질문은 답을 펼치기 전에 먼저 소리 내어 답해 보세요.

01HTML — 화면의 뼈대와 의미완료 · 7단원태그로 "여기는 제목, 여기는 목록, 여기는 표"라고 내용의 구조와 의미를 표시한다.
쉽게 말하면

HTML은 집의 골조입니다. 벽지 색(CSS)이나 전등 스위치(JS)보다 먼저, "여기는 거실, 여기는 방"이라는 구조를 세우는 단계예요. 태그는 내용에 붙이는 이름표입니다.

꼭 알아야 할 것
  • 문서 구조: <html> > <head>(정보) + <body>(보이는 내용)
  • 블록 요소(div, p, h1: 한 줄 통째) vs 인라인 요소(span, a: 글자처럼 옆으로)
  • 목록 ul/ol/li, 표 table/tr/td, 병합 colspan/rowspan
  • id(문서에 하나) vs class(여러 개에 같은 이름표)
초보가 자주 헷갈리는 것
  • 태그는 "모양"이 아니라 "의미" — 글씨를 키우려고 h1을 쓰면 안 된다(크기는 CSS)
  • 블록 요소 안에 인라인은 OK, 인라인 안에 블록은 X
  • 표 병합은 "몇 칸을 차지하나"를 세는 것 — 병합된 칸만큼 다음 줄의 td를 빼야 한다
실무에서는

시맨틱 태그(header, nav, main, article)로 의미를 정확히 표시하면 검색 엔진(SEO)과 화면 낭독기(접근성)가 내용을 이해합니다. React에서 쓰는 JSX도 결국 HTML을 만들어 내는 문법이라 이 기초가 끝까지 갑니다.

면접에서는
블록 요소와 인라인 요소의 차이는?
블록 요소는 한 줄 전체를 차지하고 너비·높이를 지정할 수 있습니다. 인라인 요소는 내용만큼만 차지하며 옆으로 이어지고, 너비·높이가 적용되지 않습니다. 둘을 섞은 inline-block도 있습니다.
시맨틱 태그를 쓰는 이유는?
태그 이름만으로 내용의 역할이 드러나 코드 읽기가 쉬워지고, 검색 엔진과 스크린 리더가 페이지 구조를 이해해 SEO와 접근성이 좋아집니다.
02CSS — 꾸밈과 배치완료 · 12단원"누구에게(선택자)" "무엇을(속성)" 입힐지 정하고, 박스 모델·position·flex로 화면에 배치한다.
쉽게 말하면

CSS는 인테리어입니다. 그리고 화면의 모든 요소는 사실 상자예요. 상자의 내용(content) 바깥에 안쪽 여백(padding), 테두리(border), 바깥 여백(margin)이 차례로 둘러싸고 있죠. 배치는 이 상자들을 어디에 어떻게 줄 세울지 정하는 일입니다.

꼭 알아야 할 것
  • 작성 위치 3가지: 인라인 style="" / <style> / 외부 .css 파일(실무 기본)
  • 선택자: 태그 p, 클래스 .box, 아이디 #top, 자손 div p, 자식 div > p
  • 우선순위: 인라인 > id > class > 태그. 같으면 나중에 쓴 것이 이긴다
  • 박스 모델: content + padding + border + margin, box-sizing: border-box
  • position: static / relative / absolute / fixed, 겹침 순서는 z-index
  • flex: 부모에 display:flex, 가로 정렬 justify-content, 세로 정렬 align-items
초보가 자주 헷갈리는 것
  • width에 padding·border가 더해져 상자가 커진다 → border-box로 해결
  • absolute는 "가장 가까운 relative 등 위치 지정된 조상" 기준 — 없으면 페이지 기준
  • flex 속성은 부모에 준다(자식에 주는 게 아님)
  • "왜 내 CSS가 안 먹지?" → 대부분 우선순위에서 진 것
실무에서는

레이아웃은 거의 flex와 grid로 짭니다. 휴대폰 화면 대응(반응형, @media)은 기본이고, 요즘은 React와 함께 Tailwind CSS 같은 도구로 클래스 이름에 스타일을 바로 적기도 해요(13강). 그래도 그 안의 개념은 여기서 배운 박스·배치 그대로입니다.

면접에서는
CSS 우선순위(명시도)는 어떻게 정해지나요?
!important > 인라인 스타일 > id 선택자 > class·속성·가상 클래스 > 태그 선택자 순이고, 명시도가 같으면 나중에 선언된 규칙이 적용됩니다.
position의 relative와 absolute 차이는?
relative는 원래 자리를 기준으로 조금 옮기며 원래 자리도 차지합니다. absolute는 흐름에서 빠져 나와 가장 가까운 위치 지정 조상을 기준으로 배치됩니다. 보통 부모에 relative, 자식에 absolute를 줘서 함께 씁니다.
box-sizing: border-box는 무엇인가요?
width·height에 padding과 border까지 포함해서 계산하게 하는 설정입니다. 지정한 크기가 실제 크기와 같아져 레이아웃 계산이 쉬워집니다.
03JavaScript 기초 — 값 · 함수 · 객체 · 배열완료 · 1~11단원화면에 동작을 붙이는 언어의 문법. 변수에 값을 담고, 함수로 묶고, 객체·배열로 정리한다.
쉽게 말하면

HTML이 골조, CSS가 인테리어라면 JS는 전기 배선입니다. 버튼을 누르면 불이 켜지는 것처럼 "무언가 일어나면 → 무엇을 한다"를 적는 언어예요. 그 전에 먼저 값을 담고(변수), 일을 묶고(함수), 정리하는(객체·배열) 문법을 배웁니다.

꼭 알아야 할 것
  • const(다시 대입 X, 기본으로 사용) / let(바뀌는 값) / var(옛날 방식, 쓰지 않기)
  • ===(타입까지 비교) 사용, ==는 타입을 멋대로 바꿔 비교한다
  • 함수 선언 vs 화살표 함수 (a) => a * 2
  • 객체 { name: "kim" }, 배열 [1, 2, 3]
  • 배열 메서드 map(바꾸기) · filter(거르기) · reduce(합치기) · forEach
  • 스프레드 [...arr], 구조분해 const { a } = obj — React에서 매일 쓴다
초보가 자주 헷갈리는 것
  • input.value는 항상 문자열 → "1" + 1 = "11", 계산하려면 Number()
  • const 배열에도 push는 된다 — 변수에 다시 대입하는 것만 막는다
  • 클로저: 함수가 만들어질 때 주변 변수를 "기억"한다
  • Date의 월은 0부터(1월 = 0)
  • for...in(객체의 키) vs for...of(배열의 값)
실무에서는

React 코드는 사실 대부분 그냥 JavaScript입니다. 서버에서 받은 목록을 map으로 화면 조각으로 바꾸고, filter로 검색하고, 스프레드로 상태를 복사해 바꾸죠. 그래서 "React가 어렵다"의 상당수는 사실 이 단계의 배열·객체 문법이 덜 익은 경우예요.

면접에서는
var, let, const의 차이는?
var는 함수 단위 스코프이고 재선언이 가능하며 선언 전에 써도 undefined로 동작합니다. let·const는 블록 단위 스코프이고 선언 전에 쓰면 에러가 납니다. const는 재할당이 안 됩니다. 기본은 const, 바뀌어야 하면 let을 씁니다.
==와 ===의 차이는?
==는 비교 전에 타입을 자동 변환하고("1" == 1은 true), ===는 타입까지 같아야 true입니다. 예상치 못한 변환을 피하려고 ===를 씁니다.
클로저란?
함수가 자신이 만들어진 위치의 변수에 계속 접근할 수 있는 성질입니다. 함수가 끝난 뒤에도 그 변수를 기억하기 때문에 값을 숨기거나(캡슐화) 상태를 유지할 때 쓰입니다.
04JavaScript와 브라우저 — DOM · BOM · 쿠키완료 · 12~17단원JS로 화면 요소를 찾고(DOM 탐색), 바꾸고, 새로 만들고, 브라우저 창·주소·저장소를 다룬다.
쉽게 말하면

브라우저는 HTML을 읽어서 가계도 같은 나무(DOM 트리)를 만듭니다. JS는 이 나무에서 가지를 찾아(querySelector) 글자를 바꾸고, 새 가지를 붙이고(appendChild), 클릭 같은 사건(이벤트)에 반응해요. BOM은 페이지 바깥, 즉 브라우저 창·주소창·저장소를 다루는 도구입니다.

꼭 알아야 할 것
  • 찾기: document.querySelector(".box"), getElementById
  • 관계로 이동: parentElement, children, nextElementSibling
  • 만들기: createElement → 내용 채우기 → appendChild
  • 이벤트: addEventListener("click", 함수), 누른 대상은 e.target
  • BOM: window.open, location.href, 쿠키 document.cookie
초보가 자주 헷갈리는 것
  • 스크립트가 HTML보다 먼저 실행되면 요소를 못 찾는다(null) → defer나 body 끝에
  • innerHTML(태그로 해석) vs textContent(글자 그대로, 더 안전)
  • onclick="f()"와 onclick={f}(React)는 다르다 — 함수를 넘기는 것과 바로 실행하는 것
  • 쿠키(서버에도 자동 전송, 용량 작음) vs localStorage(브라우저에만, 더 큼)
실무에서는

React를 쓰면 DOM을 직접 고치는 코드는 거의 사라집니다 — React가 대신 해 주거든요. 그래서 이 단계는 "React가 무엇을 대신해 주는지"를 이해하는 바탕입니다. 직접 DOM을 만지던 불편함(표 하나 그리려고 createElement를 수십 번)을 겪어 봐야 React의 편리함이 보여요.

면접에서는
DOM이란?
브라우저가 HTML을 해석해 만든 객체 트리 구조로, JavaScript가 이 객체를 통해 문서의 내용·구조·스타일을 읽고 바꿀 수 있습니다.
이벤트 버블링이란?
자식 요소에서 일어난 이벤트가 부모 방향으로 차례로 전파되는 현상입니다. 이를 이용해 부모 하나에 리스너를 달고 e.target으로 실제 클릭된 자식을 구분하는 것을 이벤트 위임이라고 합니다.
쿠키, localStorage, sessionStorage의 차이는?
쿠키는 작고 요청마다 서버에 자동 전송되며 만료일을 정할 수 있습니다. localStorage는 브라우저에만 저장되고 직접 지울 때까지 남습니다. sessionStorage는 탭을 닫으면 사라집니다.
05AJAX — 처음으로 서버와 대화하기완료 · 18단원페이지를 새로 고치지 않고 서버에 데이터를 요청해 받아 온다. 프런트엔드와 백엔드가 만나는 지점.
쉽게 말하면

예전 웹은 데이터 하나 바뀌어도 페이지 전체를 새로 받아 왔어요. AJAX는 식당에서 "물만 좀 더 주세요"처럼 필요한 데이터만 따로 요청하는 방법입니다. 응답은 보통 JSON(자바스크립트 객체 모양의 글자)으로 와요. 지도 1번 그림의 ①→② 화살표가 바로 이것입니다.

꼭 알아야 할 것
  • 수업 방식: XMLHttpRequest → 요즘은 fetch()
  • 요청은 비동기 — 응답이 올 때까지 기다리지 않고 다음 코드가 먼저 실행된다
  • 응답 JSON 문자열 → JSON.parse로 객체로 / 보낼 땐 JSON.stringify
  • HTTP 메서드: GET(조회) · POST(생성) · PUT/PATCH(수정) · DELETE(삭제)
초보가 자주 헷갈리는 것
  • 요청 바로 다음 줄에서 결과를 쓰려고 하면 아직 비어 있다 → 콜백·then·await 안에서 써야 한다
  • 상태 코드: 200(성공), 404(주소 없음), 500(서버 에러)
  • 다른 주소(도메인)로 요청하면 CORS 에러 — 브라우저의 보안 규칙
실무에서는

요즘 웹은 거의 이 구조입니다. 프런트엔드(React)가 fetch로 요청하고, 백엔드(스프링)가 JSON을 돌려주는 REST API 방식이죠. 백엔드 과정에서 배우는 Controller가 바로 이 요청을 받는 쪽이에요. 프런트·백엔드가 협업할 때 주고받는 약속이 "어떤 주소로 무엇을 보내면 어떤 JSON이 오는가"(API 명세)입니다.

면접에서는
동기와 비동기의 차이는?
동기는 작업이 끝날 때까지 기다렸다가 다음으로 넘어가고, 비동기는 작업을 맡겨 두고 다음 코드를 먼저 실행한 뒤 결과가 오면 처리합니다. 네트워크 요청처럼 오래 걸리는 일은 비동기로 처리해 화면이 멈추지 않게 합니다.
REST API란?
자원을 URL로 표현하고(/posts/3), 그 자원에 무엇을 할지는 HTTP 메서드(GET·POST·PUT·DELETE)로 표현하는 API 설계 방식입니다. 응답은 주로 JSON입니다.
Promise와 async/await는?
Promise는 "나중에 완료될 결과"를 나타내는 객체이고, async/await는 Promise를 기다리는 코드를 동기 코드처럼 위에서 아래로 읽히게 쓰는 문법입니다.
06React 기초 — 컴포넌트 · props · state완료 · 1~3단원화면을 부품(컴포넌트)으로 나누고, 데이터(state)가 바뀌면 React가 화면을 알아서 다시 그린다.
쉽게 말하면

바닐라 JS는 "이 칸을 찾아서 글자를 바꿔라"를 하나하나 지시했죠(명령형). React는 "데이터가 이렇다면 화면은 이렇게 생겼다"만 적어 둡니다(선언형). 데이터가 바뀌면 React가 달라진 부분만 찾아 고쳐요. 화면은 레고 블록(컴포넌트)처럼 조립합니다.

꼭 알아야 할 것
  • 컴포넌트 = 화면 조각을 돌려주는 함수, 이름은 대문자로
  • JSX: JS 안에 HTML처럼 쓰는 문법 (class → className, { } 안에 JS)
  • props: 부모 → 자식으로 내려주는 값, 읽기 전용
  • state: 컴포넌트가 기억하는 값. const [n, setN] = useState(0), 바꾸면 다시 그려진다
  • useEffect: 화면이 그려진 뒤에 할 일(데이터 불러오기, 저장 등), 의존성 배열로 언제 실행할지
  • 목록은 map으로 그리고, 각 항목에 고유한 key
초보가 자주 헷갈리는 것
  • state를 직접 고치면(arr.push) 화면이 안 바뀐다 → 새 배열/객체를 만들어 set (불변성)
  • setN(n + 1) 직후에 n을 찍어도 아직 옛날 값
  • onClick={f}(넘기기) vs onClick={f()}(즉시 실행 → 무한 렌더링)
  • props vs state: 남이 준 값 vs 내가 기억하는 값
실무에서는

React는 국내외 프런트엔드 채용에서 가장 많이 요구되는 기술입니다. 실무의 대부분은 "서버에서 데이터를 받아 state에 넣고 → map으로 그리고 → 사용자 입력으로 state를 바꾸는" 반복이에요. 이 순환만 확실히 이해해도 대부분의 화면을 만들 수 있습니다.

면접에서는
Virtual DOM이 무엇이고 왜 쓰나요?
실제 DOM의 가벼운 복사본입니다. 상태가 바뀌면 새 가상 DOM을 만들어 이전과 비교(diff)하고, 달라진 부분만 실제 DOM에 반영해 불필요한 화면 조작을 줄입니다.
props와 state의 차이는?
props는 부모가 자식에게 전달하는 읽기 전용 값이고, state는 컴포넌트가 스스로 관리하는 바뀔 수 있는 값입니다. 둘 중 하나가 바뀌면 컴포넌트가 다시 렌더링됩니다.
리스트에 key가 필요한 이유는?
React가 어떤 항목이 추가·삭제·이동됐는지 구분하기 위해서입니다. 배열 인덱스를 key로 쓰면 순서가 바뀔 때 엉뚱한 항목이 갱신될 수 있어 고유 id를 씁니다.
useEffect의 의존성 배열은?
없으면 매 렌더링마다, 빈 배열이면 처음 한 번만, 값이 들어 있으면 그 값이 바뀔 때마다 실행됩니다. 반환한 함수는 정리(cleanup)로 실행됩니다.
07React 심화 — 성능 훅 · 커스텀 훅 · Context완료 · 4~7단원불필요한 다시 그리기를 줄이고, 반복 로직을 훅으로 묶고, 멀리 떨어진 컴포넌트끼리 값을 나눈다.
쉽게 말하면

React는 부모가 다시 그려지면 자식도 다 다시 그립니다. 대부분 괜찮지만 무거운 화면에선 낭비죠. memo·useMemo·useCallback은 "바뀐 게 없으면 지난번 결과를 재사용"하는 메모장이에요. Context는 props를 여러 층 거쳐 전달하는 대신(prop drilling), 아파트 방송 시스템처럼 어디서든 바로 꺼내 쓰게 합니다.

꼭 알아야 할 것
  • useRef: 값을 기억하되 바뀌어도 다시 그리지 않음 / DOM 직접 접근
  • useMemo(계산 결과 재사용) · useCallback(함수 재사용) · React.memo(컴포넌트 렌더 건너뛰기)
  • 커스텀 훅: use로 시작하는 함수에 state+effect 로직을 묶어 재사용
  • lazy + Suspense: 무거운 컴포넌트를 필요할 때 내려받기(코드 분할)
  • Context: createContext → Provider로 감싸기 → useContext로 꺼내기
초보가 자주 헷갈리는 것
  • 훅은 컴포넌트 맨 위에서만 호출 — 조건문·반복문 안에서 X
  • 최적화 훅은 필요할 때만 — 남용하면 오히려 코드만 복잡해진다
  • 의존성 배열을 빼먹으면 옛날 값을 계속 쓰는 버그(stale closure)
  • Context 값이 바뀌면 그걸 쓰는 컴포넌트가 전부 다시 그려진다 → Zustand가 등장하는 이유
실무에서는

테마(다크 모드)·로그인 사용자·언어 설정처럼 앱 전체가 쓰는 값은 Context나 상태 관리 도구에 둡니다. 데이터 불러오기·디바운스 같은 반복 로직은 커스텀 훅으로 빼서 여러 화면에서 재사용하고요. 성능 최적화는 "느리다"는 게 확인된 뒤에 하는 게 원칙입니다.

면접에서는
useMemo와 useCallback의 차이는?
useMemo는 계산 결과값을, useCallback은 함수 자체를 의존성이 바뀌기 전까지 재사용합니다. 주로 React.memo로 감싼 자식에게 넘기는 값·함수가 매번 새로 만들어지는 것을 막을 때 씁니다.
prop drilling이란? 어떻게 해결하나요?
깊은 자식에게 값을 주려고 중간 컴포넌트들이 쓰지도 않는 props를 계속 전달하는 문제입니다. Context API나 Zustand·Redux 같은 전역 상태 관리로 해결합니다.
커스텀 훅은 왜 만드나요?
여러 컴포넌트에 반복되는 상태+효과 로직을 하나의 함수로 묶어 재사용하고, 화면 코드와 로직을 분리해 읽기 쉽게 하기 위해서입니다.
08Next.js — 라우팅 · API · 서버 컴포넌트완료 · 8, 10, 12단원React 위에 "주소(페이지) 관리, 서버 쪽 코드, 서버에서 미리 그리기"를 얹은 프레임워크.
쉽게 말하면

React가 엔진이라면 Next.js는 그 엔진을 얹은 완성차입니다. 폴더를 만들면 그게 곧 주소가 되고(app/about/page.js → /about), 같은 프로젝트 안에 작은 서버(API Routes)도 둘 수 있어요. 일부 화면은 서버에서 미리 그려서 보내 줘 첫 화면이 빨라집니다.

꼭 알아야 할 것
  • App Router: 폴더 = URL, page.js(화면) · layout.js(공통 틀) · [id](동적 주소)
  • API Routes: app/api/.../route.js에 GET 함수 → 프런트가 fetch("/api/...")
  • 서버 컴포넌트(기본값, 서버에서 실행, DB·비밀 키 접근 가능) vs 클라이언트 컴포넌트("use client", state·이벤트 가능)
  • CSR(브라우저가 그림) vs SSR(서버가 그려서 보냄)
초보가 자주 헷갈리는 것
  • useState·onClick을 쓰는데 에러 → 파일 맨 위에 "use client"가 없어서
  • API 키는 서버 쪽(API Route·서버 컴포넌트)에만 — 브라우저 코드에 두면 누구나 볼 수 있다
  • React(라이브러리)와 Next.js(프레임워크)의 경계
실무에서는

신규 React 프로젝트의 상당수가 Next.js로 시작합니다. 검색 노출(SEO)이 중요한 서비스, 첫 화면 속도가 중요한 서비스에 특히요. API Routes는 간단한 중계 서버로 쓰고, 본격적인 업무 로직은 보통 별도 백엔드(스프링 등)에 둡니다 — 지금 백엔드 과정에서 배우는 쪽이죠.

면접에서는
CSR과 SSR의 차이와 장단점은?
CSR은 빈 HTML과 JS를 받아 브라우저가 화면을 그려서 이후 화면 전환이 빠르지만 첫 화면이 늦고 SEO에 불리합니다. SSR은 서버가 완성된 HTML을 보내 첫 화면과 SEO에 유리하지만 요청마다 서버가 일을 해야 합니다.
서버 컴포넌트와 클라이언트 컴포넌트는 어떻게 나누나요?
데이터를 가져와 보여 주기만 하는 부분은 서버 컴포넌트로, 클릭·입력·state가 필요한 부분은 "use client"를 붙인 클라이언트 컴포넌트로 만듭니다. 브라우저로 보내는 JS가 줄어 빨라집니다.
09상태 관리와 마무리 — Zustand · Tailwind · 실시간 차트완료 · 9, 11, 13, 14단원전역 상태를 가볍게 관리하고(Zustand), 새로고침에도 남기고(persist), 스타일·차트·실시간 데이터로 앱을 완성한다.
쉽게 말하면

Zustand 스토어는 앱 전체가 함께 쓰는 공용 냉장고입니다. Context처럼 감쌀 필요 없이 어디서든 열어서 필요한 칸만 꺼내 쓰고(셀렉터), 그 칸이 바뀔 때만 다시 그려져요. persist는 냉장고 내용을 localStorage에 적어 둬서 새로고침해도 남게 합니다. 실시간 시세는 WebSocket — 서버가 새 값을 먼저 "밀어" 줍니다.

꼭 알아야 할 것
  • create((set, get) => ({ ... }))로 스토어, useStore(s => s.watchlist)로 필요한 것만 구독
  • 비동기 액션: 스토어 안에서 fetch 후 set
  • Tailwind: className="p-4 flex dark:bg-gray-900"처럼 유틸리티 클래스로 스타일
  • 폴링(주기적으로 물어보기, Pull) vs WebSocket(서버가 밀어주기, Push)
초보가 자주 헷갈리는 것
  • 상태 관리 진화: props → Context → Zustand, 각 단계가 앞의 불편을 해결
  • 스토어 전체를 꺼내면(useStore()) 아무 값이 바뀌어도 다시 그려진다
  • persist는 서버 렌더링 때 localStorage가 없어서 첫 화면과 값이 어긋날 수 있다(hydration)
실무에서는

전역 상태 도구는 Redux, Zustand, Recoil 등 여러 가지인데 "어디서든 같은 값을 쓰고, 필요한 부분만 다시 그린다"는 원리는 같습니다. 요즘은 서버 데이터는 TanStack Query 같은 도구로, 화면 상태만 Zustand로 나누는 경우도 많아요. 실시간 기능(채팅·알림·시세)은 WebSocket이 기본입니다.

면접에서는
Context API 대신 Zustand를 쓴 이유는?
Context는 값이 바뀌면 그 Context를 쓰는 컴포넌트가 모두 다시 렌더링되고 Provider로 감싸야 합니다. Zustand는 Provider 없이 쓰고, 셀렉터로 필요한 값만 구독해 그 값이 바뀔 때만 렌더링되어 코드도 짧고 성능도 좋습니다.
WebSocket과 HTTP 요청의 차이는?
HTTP는 클라이언트가 요청해야만 서버가 응답하는 단방향 요청-응답이고, WebSocket은 연결을 한 번 맺으면 계속 유지하며 서버와 클라이언트가 언제든 서로 메시지를 보낼 수 있는 양방향 통신입니다.
10졸업 과제 StockDash — 배운 것을 한 앱에제출 완료Next.js + Zustand + Finnhub API로 만든 주식 대시보드. 면접에서 "직접 만든 것"으로 설명할 수 있는 결과물.
어떤 기술이 어디에 쓰였나
  • 컴포넌트·props — 종목 카드, 검색창, 관심 목록 패널로 화면을 나눔
  • Zustand + persist — 관심 종목·포트폴리오를 전역에 두고 새로고침에도 유지
  • API Routes — 시세·뉴스·검색 요청을 /api/... 중계 서버로 보내 REST용 Finnhub 키를 서버 쪽에 둠
  • lazy + Suspense — 뉴스 목록처럼 무거운 부분은 나중에 내려받고, 오는 동안 스켈레톤 표시 (layout.js는 서버 컴포넌트, 화면 page.js는 클라이언트 컴포넌트)
  • 커스텀 훅 — useDebounce(검색), useStockData, useLiveTicker(실시간 체결가)
  • Tailwind + 다크 모드, Recharts + WebSocket — 첫 차트는 REST로, 이후 점은 WebSocket으로 이어 붙이는 실시간 차트
면접에서 이렇게 설명하세요

"무엇을 만들었다"보다 "왜 그렇게 만들었다"가 중요합니다. 예: "REST 요청용 API 키가 브라우저에 노출되지 않도록 Next.js API Route를 중계 서버로 뒀다(WebSocket은 브라우저가 직접 연결해야 해서 공개용 키를 따로 썼다)", "Context는 값이 바뀔 때마다 하위 전체가 렌더링돼서 셀렉터 구독이 되는 Zustand를 골랐다".

백엔드와 이어 보기

지금은 데이터를 외부 API(Finnhub)에서 받지만, 백엔드 과정을 마치면 "관심 종목을 내 스프링 서버 + DB에 저장"하도록 확장할 수 있어요. 그게 곧 풀스택 포트폴리오입니다.

5계속 다시 나오는 큰 생각 5가지

과목 이름은 계속 바뀌었지만, 사실 같은 생각이 모양만 바꿔 반복됐습니다. 이 다섯 개를 잡으면 새 기술도 "아, 그거구나" 하고 이해돼요.

① 역할을 나눈다 (관심사 분리)한 곳이 모든 일을 하면 고치기 어렵다. 그래서 계속 쪼갰어요.
  • HTML(구조) / CSS(모양) / JS(동작)
  • 클래스 하나 = 책임 하나
  • DTO / DAO → MVC1 → MVC2
  • Controller / Service / DAO / Mapper
② 약속(인터페이스)과 구현을 떼어 놓는다"무엇을 하느냐"와 "어떻게 하느냐"를 분리하면 갈아 끼울 수 있어요.
  • 추상 클래스·인터페이스·다형성
  • JDBC 인터페이스 ↔ MariaDB 드라이버
  • 스프링 DI — 구현체는 스프링이 넣어 준다
③ 반복되는 일은 도구에 맡긴다직접 해 보고 → 불편함을 느끼고 → 도구가 대신하는 순서로 배웠어요.
  • 배열 → 컬렉션
  • JDBC 반복 코드 → MyBatis
  • Servlet 직접 작성 → DispatcherServlet
  • 로그 코드 복붙 → AOP / XML 설정 → Spring Boot
④ 요청 → 처리 → 응답네트워크도, 웹도, 스프링도 결국 이 세 박자예요.
  • 소켓: accept() → 읽기 → 쓰기
  • JS의 AJAX: 요청 보내고 응답 받기
  • Servlet: request → 처리 → response
  • forward / redirect = 응답을 어디로 보낼지
⑤ 데이터는 어디에, 언제까지 사나값을 "얼마나 오래, 누구와 공유할지"에 따라 담는 곳이 달라져요.
  • 지역변수(블록) < 필드(객체) < static(프로그램)
  • request < session < application (scope)
  • 쿠키·localStorage(브라우저) vs DB(영구)
  • 트랜잭션 = DB에 "확정"하는 순간을 정하기

6비슷해서 헷갈리는 말 — 한 표로

면접에서 "A와 B의 차이는?" 형태로 정말 자주 나옵니다. 왼쪽 두 칸만 보고 차이를 말해 본 뒤 오른쪽을 확인하세요.

헷갈리는 쌍한 줄 차이기억 요령
블록 / 인라인한 줄 통째 차지 / 내용만큼 옆으로div는 블록, span은 인라인
id / class문서에 하나 / 여러 요소에 같은 이름스타일은 class, 하나만 찾을 땐 id
margin / padding테두리 바깥 여백 / 테두리 안쪽 여백padding은 배경색이 칠해진다
relative / absolute원래 자리 기준 / 위치 지정된 조상 기준부모 relative + 자식 absolute 세트
var / let / const옛 방식(함수 스코프) / 바뀌는 값 / 안 바뀌는 값기본 const, 필요하면 let, var는 X
== / ===타입 변환 후 비교 / 타입까지 비교항상 ===
for...in / for...of객체의 키 / 배열의 값in = index(키), of = 값
innerHTML / textContent태그로 해석 / 글자 그대로사용자 입력은 textContent (보안)
쿠키 / localStorage서버로 자동 전송·작음 / 브라우저에만·큼로그인 세션 ID는 쿠키
동기 / 비동기끝날 때까지 기다림 / 맡겨 두고 다음 진행네트워크 요청은 비동기
props / state부모가 준 값(읽기 전용) / 내가 기억하는 값바꾸고 싶으면 state
useMemo / useCallback계산 결과 재사용 / 함수 재사용Memo = 값, Callback = 함수
useState / useRef바뀌면 다시 그림 / 바뀌어도 안 그림화면에 보이는 값이면 state
Context / ZustandProvider로 감싸고 전체 리렌더 / 감싸기 없이 골라 구독상태 관리 진화의 다음 단계
React / Next.js화면 라이브러리 / 라우팅·서버까지 갖춘 프레임워크엔진 vs 완성차
CSR / SSR브라우저가 그림 / 서버가 그려서 보냄SEO·첫 화면은 SSR 유리
서버 / 클라이언트 컴포넌트서버에서 실행(데이터) / 브라우저에서 실행(상호작용)state·onClick 쓰면 "use client"
폴링 / WebSocket주기적으로 물어봄(Pull) / 서버가 밀어줌(Push)실시간이면 WebSocket

7면접에서 "무엇을 배웠나요?"라고 물으면

아래는 이 과정을 30초로 말하는 예시입니다. 그대로 외우기보다 내 말로 바꿔서 연습하세요.

"HTML·CSS로 화면 구조와 배치를, JavaScript로 DOM 조작과 AJAX 통신을 익힌 뒤, React로 컴포넌트·state 기반 화면을 만들었습니다. 이후 Next.js의 App Router, API Routes, 서버 컴포넌트를 배우고, 졸업 과제로 Zustand로 전역 상태를 관리하고 WebSocket으로 실시간 시세를 받는 주식 대시보드를 만들었습니다. 이때 REST용 API 키를 숨기려고 API Route를 중계 서버로 두는 등 보안과 성능을 고려한 구조를 직접 결정했습니다. 지금은 자바·스프링 백엔드 과정을 이어서 듣고 있습니다."

💡 백엔드 과정의 큰 그림은 백엔드 학습 노트의 🗺️ 큰 그림 탭에 있어요. 두 탭의 1번 지도는 같은 그림이고, 칠해진 칸만 다릅니다.

📘 개념서 — 처음부터 차례대로

HTML·CSS·JS 교안과 React·Next.js 교안, 그리고 수업에서 실제로 짠 실습 파일과 레슨 코드를 한 권의 책으로 엮었습니다. 장마다 교안의 개념 → 그 개념을 익히려고 짠 수업 코드 → 핵심 정리 → 확인 문제 순서라서, 수학 개념서처럼 1장부터 차례대로 읽으면 됩니다.

한 장은 이렇게 생겼어요 — 🎯 이 장의 목표 → 🔗 왜 지금 배우나(앞 장과의 연결) → 개념(📎 교안 몇 쪽인지 표시) → 💻 수업 코드 예제("이 코드는 무엇을 보여 주려고 짠 것인지") → ⚠️ 교안 표현 바로잡기(있을 때만) → 핵심 정리 → 확인 문제 → 다음 장으로.

이렇게 공부하세요 — 확인 문제는 답을 펼치기 전에 먼저 소리 내어 답해 보세요. 막히면 그 장의 개념을 다시 읽고, 장 끝의 "이 장 다 읽었어요"를 누르면 목차에 ✓가 붙습니다(이 브라우저에만 저장). 전체 흐름이 궁금하면 🗺️ 큰 그림 탭을 먼저 보세요.

읽은 장
0 / 31장
목차 — 장 제목을 누르면 그 장으로 갑니다
1부 · HTML

01웹과 HTML 문서의 뼈대 — 태그·요소·속성, 문서 구조, DOM 트리

📎 교안 p.2, 5~7, 15~27📅 HTML·CSS 과정 · 6월 말💻 Front_end/1.html/html01_기본사용법.html
이 장의 목표
  • 브라우저가 서버에 요청하고, 서버가 HTML 문서로 응답하면, 브라우저가 그 문서를 화면으로 그린다는 흐름을 말할 수 있다.
  • 코드 한 줄에서 태그·요소·속성·내용을 각각 짚을 수 있다.
  • <!DOCTYPE html> → html → head/body 뼈대를 보지 않고 쓸 수 있고, head와 body에 무엇이 들어가는지 안다.
  • 브라우저가 HTML 글자를 DOM 트리(부모-자식으로 이어진 나무)로 바꾼다는 것을 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결

이 책의 출발점이에요. 앞으로 배울 것은 전부 이 장의 "문서" 위에 올라갑니다. 2부의 CSS는 이 문서에 옷을 입히고, 그다음 JavaScript는 이 문서의 DOM 트리를 움직이고, 마지막 React도 결국 이 HTML을 만들어 내는 도구예요. 그래서 맨 처음에는 "문서가 어떻게 생겼고, 브라우저가 그걸 어떻게 받아들이는지"부터 정확히 잡습니다.

개념

① 웹은 문서를 주고받는 일 — 요청과 응답

식당에 비유하면 브라우저는 손님, 서버는 주방입니다. 손님이 주소(URL)로 주문(request)하면 주방이 HTML 문서를 내어 주고(response), 브라우저는 "<table>이면 표 모양으로" 같은 약속대로 문서를 그려 줘요. 교안은 문서를 두 가지로 나눕니다. 정적 문서는 미리 만들어 둔 .html 파일이고, 동적 문서는 서버가 요청이 올 때마다 JSP 같은 프로그램으로 HTML을 새로 만들어 보내는 문서예요. 우리 1.html 폴더의 실습 파일은 모두 정적 문서라서 서버 없이 더블클릭만 해도 열립니다.

📎 교안 p.2 「Html의 동작 원리」

② 태그·요소·속성·내용

<p id="hello" title="html입문하기">Hello World!</p>를 뜯어 보면, <p>와 </p>가 시작과 끝을 표시하는 태그, 시작 태그 안의 id="hello"가 속성(이름="값"), 사이에 있는 글자가 내용입니다. 시작 태그부터 끝 태그까지 전체를 요소(element)라고 부르고, 이렇게 글에 태그를 붙이는 일을 마크업이라고 해요. 요소 안에 요소를 넣을 수 있는데, 나중에 연 것을 먼저 닫아야 합니다. br·img·meta·hr·input처럼 내용이 없는 빈 요소는 끝 태그가 없어요.

📎 교안 p.6~7 「요소의 구성」, p.16~20 「HTML 기본 문법」

③ 문서의 뼈대 — DOCTYPE, head, body

모든 문서는 <!DOCTYPE html> 한 줄로 시작하고, <html> 안에 <head>와 <body>를 둡니다. head는 화면에 안 보이는 정보(meta charset: 한글 깨짐 방지 인코딩, title: 탭 제목, link/style: CSS, script: JS)를, body는 화면에 그려질 내용을 담아요. 맨 위 DOCTYPE은 "이 문서를 최신 표준대로 해석하라"는 신호입니다. 빠지면 브라우저가 옛날 페이지와 호환되는 쿼크 모드(quirks mode)로 그려서 같은 CSS도 다르게 보일 수 있어요.

📎 교안 p.21~23 「기본 문서 구조」, p.26 「DTD 선언」

④ DOM 트리 — 브라우저 안에서 문서는 나무가 된다

브라우저는 HTML 글자를 읽으면서 요소마다 객체를 하나씩 만들고, 감싸는 쪽을 부모, 안에 든 쪽을 자식으로 이어 나무 모양(DOM 트리)을 만듭니다. html 아래에 head·body, body 아래에 header·main… 이런 식이에요. 5장의 CSS "자식 선택자", 3부 JS의 document.getElementById()가 모두 이 나무를 기준으로 요소를 찾기 때문에, 코드를 볼 때 들여쓰기를 나무의 가지로 읽는 습관이 중요합니다.

📎 교안 p.25 「HTML DOM 트리 구조」

⚠️ 교안 표현 바로잡기

교안 p.16~18의 규칙(요소·속성 이름은 소문자여야 한다, 속성값엔 반드시 따옴표, <input disabled />는 틀림, 빈 요소엔 /)은 엄격한 XHTML 기준입니다. HTML5에서는 태그 이름의 대소문자를 구분하지 않고, 공백 없는 값은 따옴표를 생략해도 되며, disabled처럼 값 없이 쓰는 불리언 속성이 오히려 표준이고, <br>의 /도 넣든 빼든 됩니다. 다만 "소문자 + 따옴표 + 제대로 닫기"는 읽기 좋은 습관이니 교안대로 써도 손해는 없어요. 또 p.26~27의 긴 DTD(HTML 4.01 Strict 등)는 옛 방식이고, HTML5는 <!DOCTYPE html> 한 줄이면 됩니다.

예제 — 수업 코드로 확인하기

html01 「Chapter 01」 — 문서의 최소 뼈대

이 코드는 모든 HTML 문서가 가진 고정 뼈대를 보여 주려고 만든 실습입니다. html01은 왼쪽 설명 카드의 [🚀 코드 적용하기]를 누르면 오른쪽 편집기에 예제가 들어가고, 바로 아래 미리보기에 결과가 그려지는 "놀이터(playground)" 파일이에요.

HTMLhtml01_기본사용법.html — Chapter 01 예제 코드
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>나의 첫 HTML 문서</title>
</head>
<body>
  <h1>환영합니다!</h1>
  <p>기본 구조 학습 중입니다.</p>
</body>
</html>

👀 관찰 포인트 — head 안의 title 글자는 본문이 아니라 탭 제목에 쓰이고, 화면에는 body 안의 h1·p만 보입니다. 편집기에서 h1을 h3으로 바꾸면 미리보기의 제목이 바로 작아지는 것도 확인해 보세요.

html01 파일 자체 — 실제 페이지의 뼈대와 엔티티

이 파일은 그 자체도 HTML 문서라서, 실제 페이지가 어떤 나무 모양으로 짜이는지와 태그를 "글자로" 화면에 보여 주는 법(엔티티)을 함께 보여 줍니다. 긴 CSS와 JS는 생략했어요.

HTMLhtml01_기본사용법.html — 파일의 큰 구조(발췌)
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>HTML5 기초 학습 가이드 & 실시간 Playground</title>
  <!-- …(생략: 구글 폰트 link, 긴 style) -->
</head>
<body>
  <header> <!-- …(생략: 로고) --> </header>
  <main class="main-container">
    <section class="guide-sidebar">
      <article class="chapter-card">
        <!-- …(생략: 제목·설명) -->
        <pre class="example-code" id="code-ch1">&lt;!DOCTYPE html&gt;
&lt;html&gt;
        <!-- …(생략) -->
      </article>
      <!-- …(생략: Chapter 02~06 카드) -->
    </section>
    <section class="playground"> <!-- …(생략: 편집기와 미리보기) --> </section>
  </main>
  <script> /* …(생략) */ </script>
</body>
</html>

👀 관찰 포인트 — ① body 밑에 header·main, main 밑에 section 둘, 그 밑에 article… 들여쓰기가 곧 DOM 트리의 가지입니다. ② pre 안에서 < 대신 &lt;를 쓴 이유: 그냥 <html>이라고 쓰면 브라우저가 진짜 태그로 해석해 버리니까, "글자 <"로 보이게 엔티티로 적은 거예요(교안 p.19). ③ lang="ko"(문서 언어 — 스크린리더·번역기가 사용)와 viewport(모바일 화면 폭 맞춤)는 교안 p.21의 뼈대에 요즘 기본으로 붙이는 두 줄입니다.

핵심 정리
  • 태그는 <p>·</p> 표시, 요소는 시작~끝 전체, 속성은 시작 태그 안의 이름="값", 내용은 그 사이.
  • 뼈대는 <!DOCTYPE html> → html → head(안 보이는 정보) + body(보이는 내용).
  • DOCTYPE은 "표준 모드로 그려라"는 신호. 빠지면 쿼크 모드가 된다.
  • 브라우저는 HTML을 DOM 트리로 바꾸고, CSS 선택자와 JS는 이 나무를 기준으로 요소를 찾는다.
  • 화면에 <를 글자로 보이려면 &lt;, &는 &amp;로 쓴다.
확인 문제
<a href="https://www.google.com">구글</a>에서 태그, 속성 이름, 속성값, 내용, 요소를 각각 짚어 보세요.
태그는 <a …>와 </a>, 속성 이름은 href, 속성값은 "https://www.google.com", 내용은 "구글", 요소는 <a부터 </a>까지 전체입니다.
<title>에 쓴 글자는 화면 어디에 보이나요?
본문이 아니라 브라우저 탭(그리고 즐겨찾기 이름)에 보입니다. head 안의 내용은 본문 화면에 그려지지 않아요.
<p>나는 <b>굵게</p></b>는 무엇이 잘못됐나요?
중첩 순서가 엇갈렸습니다. 나중에 연 b를 먼저 닫아야 해요: <p>나는 <b>굵게</b></p>. 브라우저가 알아서 고쳐 그리긴 하지만, 그 결과 DOM 트리가 내 의도와 달라질 수 있습니다.
html01의 pre 안에 &lt;h1&gt; 대신 그냥 <h1>이라고 쓰면 화면이 어떻게 될까요?
브라우저가 진짜 제목 요소로 해석해서 큰 글씨 제목이 생기고, "<h1>"이라는 글자 자체는 보이지 않습니다. 태그를 설명하는 글을 쓸 때 엔티티가 필요한 이유예요.
다음 장으로

뼈대를 세웠으니 이제 body를 채울 차례예요. 2장에서는 요소가 화면에서 한 줄을 통째로 차지하느냐(블록), 글자처럼 옆으로 흐르느냐(인라인)를 배웁니다. 이 구분은 7~9장의 CSS 레이아웃까지 계속 이어지는 가장 중요한 기준이에요.

더 자세히 → HTML 기본 사용법

1부 · HTML

02블록 요소와 인라인 요소 — 텍스트·링크·이미지

📎 교안 p.28~40, 43, 61📅 HTML·CSS 과정 · 6월 말💻 1.html/html02_블럭요소_인라인요소.html · test.html
이 장의 목표
  • 블록 요소와 인라인 요소를 화면 모양으로 구별하고, 각각 예를 세 개 이상 들 수 있다.
  • 제목·문단·줄바꿈·구분선·인용·주소·강조 태그를 목적에 맞게 고를 수 있다.
  • a(href, target)와 img(src, alt)를 쓰고, alt가 왜 필요한지 설명할 수 있다.
  • div와 span이 "의미 없는 묶음 상자"라는 것을 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결

1장에서 문서의 뼈대를 세웠어요. 이제 body 안에 진짜 내용을 넣습니다. 태그 이름을 외우기 전에 먼저 "이 태그는 블록인가, 인라인인가?"를 묻는 습관을 들이세요. 7장 박스 모델, 8장 position, 9장 flex가 모두 이 질문(CSS의 display)으로 돌아옵니다.

개념

① 블록 vs 인라인 — 문단과 단어

블록 요소는 문단처럼 한 줄을 통째로 차지하고, 다음 요소는 그 아래로 내려갑니다(h1~h6, p, div, ul/li, hr, blockquote, address, table). 인라인 요소는 문장 속 단어처럼 옆으로 이어집니다(a, span, b, strong, em, img, q). 원칙은 "블록 안에 인라인은 되지만, 인라인 안에 블록은 안 된다"예요. 그리고 p는 블록이지만 예외적으로 안에 다른 블록(div, p)을 넣을 수 없습니다.

📎 교안 p.28~31 「블록 요소 vs 인라인 요소」, p.33 「단락」

② 글을 의미대로 표시하는 태그

h1~h6은 제목의 단계(크기를 키우려고 쓰는 게 아니라 순서대로, h1은 한 문서에 한 번 권장), p는 문단, br은 줄바꿈(간격을 벌리려고 여러 번 쓰지 않기), hr은 주제가 바뀌는 구분선, blockquote는 긴 인용(블록), q는 짧은 인용(인라인, 따옴표 자동), address는 연락처입니다. 강조는 둘씩 짝이 있어요: b는 "보기만 굵게", strong은 "중요하다"는 의미, i는 "보기만 기울임", em은 "강조"라는 의미. 화면은 비슷해도 스크린리더와 검색엔진은 의미 쪽을 읽습니다.

📎 교안 p.32~37 「제목·단락·주소·구분선·인용문·텍스트」

③ 링크와 이미지

<a href="주소">는 다른 문서로 가는 문이에요. target="_blank"를 주면 새 탭에서 열립니다. <img src="경로" alt="설명">은 인라인이면서 빈 요소이고, alt는 이미지를 볼 수 없을 때 대신 읽히는 글입니다(로딩 실패, 시각장애인의 스크린리더). 꾸미기용 이미지라 읽을 필요가 없으면 alt=""로 비워 둡니다.

📎 교안 p.38~40 「이미지」·「링크(a태그)」

④ div와 span — 의미 없는 묶음 상자

div는 블록 묶음, span은 인라인 묶음입니다. 둘 다 아무 의미가 없어서, CSS로 꾸미거나 JS로 잡으려고 여러 요소를 한데 묶을 때 씁니다. 의미가 있는 묶음(머리말, 본문, 꼬리말)이라면 4장의 시맨틱 태그를 먼저 고려해요.

📎 교안 p.61 「그룹핑 — div요소, span요소」

⚠️ 교안 표현 바로잡기

교안의 블록/인라인 포함 규칙은 HTML4 시절 기준입니다. HTML5 표준은 이 구분 대신 "콘텐츠 모델"로 설명하고, 특히 <a>는 HTML5에서 블록 요소(div 등)도 감쌀 수 있습니다(교안 p.40의 "인라인 요소와 텍스트를 포함"은 옛 규칙). 화면 모양은 여전히 CSS의 display: block/inline으로 이해하면 돼요. 또 p.38 예시의 width="100px"처럼 HTML 속성에는 단위를 붙이지 않는 게 맞습니다(width="100"). 단위를 쓰고 싶으면 CSS로 지정하세요.

예제 — 수업 코드로 확인하기

html02 — 블록은 쌓이고, 인라인은 옆으로 흐른다

이 코드는 블록과 인라인이 화면에서 어떻게 다르게 놓이는지를 보여 주려고 만든 실습입니다. 두 h1에 적힌 문장이 곧 이 파일의 결론이에요.

HTMLhtml02_블럭요소_인라인요소.html — body
<div id="Container1">
    <h1>블럭요소: 구조를 나타내는 기능, 그룹을 나타내는 기능</h1>
    <div class="menu">
        <ul>
            <li>메뉴1</li>
            <li>메뉴2</li>
            <li>메뉴3</li>
        </ul>
    </div>
</div>

<div id="Container2">
    <h1>인라인요소: 형태가 없음, 블럭요소 내부에 담겨서 표현</h1>
    <div>
        <span>요소</span>
        <span>요소</span>
        <span>요소</span>
    </div>
</div>

👀 관찰 포인트 — 메뉴1~3(li, 블록)은 한 줄씩 아래로 쌓이고, span 세 개(인라인)는 "요소 요소 요소"로 한 줄에 나란히 나옵니다. span 사이의 띄어쓰기는 소스의 줄바꿈이 공백 한 칸으로 바뀐 것이에요(7장 inline-block 카드 사이 틈과 같은 원인). 개발자 도구(F12)에서 div에 마우스를 올리면 화면 폭 전체가, span에 올리면 글자만큼만 칠해지는 것도 확인해 보세요.

test.html — 교안 「실습1」: 실제 사이트를 태그로 옮기기

이 코드는 교안 p.43 「실습1」(lipsum.com 페이지를 보고 제목·인용·구분선·목록·링크·주소 태그로 따라 만들기)을 한 결과물입니다. 2장의 태그를 한 문서에 다 써 보는 연습이에요.

HTMLtest.html — body(발췌)
<h1>
    Lorem Ipsum
</h1>

<q>
    Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit...
</q>
<q>
    There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain...
</q>

<hr>

<ul>
    <li>
        Lorem ipsum dolor sit amet, consectetur adipiscing elit.
    </li>
    <!-- …(생략: li 2개 더, 그리고 같은 모양의 ul 3개) -->
</ul>

<hr>

<p>문장</p>
<a href="https://www.lipsum.com/" target="_blank" rel="noopener">Lorem Ipsum</a>

<address>help@lipsum.com</address>

👀 관찰 포인트 — q 두 개는 인라인이라 한 줄에 이어 붙고, 앞뒤에 따옴표가 자동으로 붙어요. 이 문장들은 길어서 의미로는 블록인 blockquote가 더 어울립니다. hr은 영역을 가르는 가로선, address는 블록이고 브라우저 기본 스타일로 기울임꼴이에요. 링크의 rel="noopener"는 새 탭에서 열린 페이지가 원래 탭을 조작하지 못하게 막는 설정입니다(요즘 브라우저는 target="_blank"에 기본으로 적용하지만, 적어 두는 게 좋은 습관).

핵심 정리
  • 블록은 한 줄을 통째로 차지하고 아래로 쌓인다. 인라인은 글자처럼 옆으로 흐른다.
  • 블록 안에 인라인은 OK, 인라인 안에 블록은 X. p 안에는 블록을 넣을 수 없다.
  • 태그는 모양이 아니라 의미로 고른다: 중요하면 strong, 긴 인용은 blockquote, 짧은 인용은 q.
  • a는 href, img는 src와 alt가 핵심 속성이다.
  • div(블록)·span(인라인)은 의미 없는 묶음 상자다.
확인 문제
<p> 안에 <div>를 넣으면 어떻게 될까요?
p 안에는 블록을 넣을 수 없어서, 브라우저는 <div>를 만나는 순간 p를 자동으로 닫아 버립니다. 그 결과 div는 p의 자식이 아니라 형제가 되고, 뒤에 남은 </p>는 빈 p를 하나 더 만들어요. 화면과 DOM 트리가 의도와 달라집니다.
span에 width: 200px를 줘도 폭이 변하지 않는 이유는?
인라인 요소에는 width/height가 적용되지 않기 때문입니다. 크기를 주려면 display: inline-block이나 block으로 바꿔야 해요(7장).
b와 strong은 둘 다 굵게 보이는데, 무엇이 다른가요?
b는 보기만 굵게, strong은 "이 부분이 중요하다"는 의미를 가집니다. 스크린리더와 검색엔진은 그 의미를 읽어요. 정말 중요한 내용이면 strong을 씁니다.
img에 alt가 필요한 이유 두 가지는?
① 이미지를 불러오지 못했을 때 그 자리에 대신 글자가 보입니다. ② 화면을 볼 수 없는 사용자의 스크린리더가 그 글을 읽어 줍니다. (검색엔진도 이미지 내용을 alt로 이해해요.)
다음 장으로

html02의 메뉴가 이미 ul/li였죠. 3장에서는 여러 항목을 줄 세우는 목록과, 칸을 나눠 데이터를 보여 주는 표를 배웁니다. 목록은 8장 드롭다운 메뉴의 재료가 되고, 표는 게시판 목록의 기본 틀이에요.

더 자세히 → 블록요소 vs 인라인요소실습 — 인용문·목록·구분선

1부 · HTML

03목록과 표 — ul/ol/dl, table·colspan·rowspan

📎 교안 p.41~42, 45~50📅 HTML·CSS 과정 · 6월 말💻 1.html/html03_목록요소.html · html04_표만들기.html · test2.html · test3.html
이 장의 목표
  • ul/ol/li, dl/dt/dd를 용도에 맞게 쓰고, 목록 안에 목록(중첩)을 만들 수 있다.
  • table → tr → th/td 구조와 caption·thead·tbody·tfoot·col의 역할을 안다.
  • colspan/rowspan으로 칸을 합칠 때 "행마다 칸 수 맞추기"를 계산할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

2장에서 블록과 인라인으로 글을 놓는 법을 배웠어요. 하지만 메뉴처럼 같은 종류를 여러 개 나열하거나, 회원 정보처럼 칸을 맞춰 보여 줄 때는 전용 구조가 필요합니다. 목록은 8장에서 CSS 드롭다운 메뉴로, 표는 3부 JS에서 "행을 동적으로 추가하는 표"로 다시 등장해요.

개념

① 목록 세 가지

ul은 순서 없는 목록(점), ol은 순서 있는 목록(번호), 각 항목은 li입니다. ul/ol의 바로 아래 자식은 li만 올 수 있고, 하위 메뉴를 만들려면 li 안에 또 ul을 넣어요. dl은 "용어(dt) — 설명(dd)" 짝으로 된 정의형 목록입니다. 교안이 말하듯 목록은 "주로 메뉴 작성 시" 쓰여요. 메뉴도 결국 링크들의 목록이니까요.

📎 교안 p.41~42 「리스트(List)」

② 표의 기본 구조

table 안에 행(tr)을 만들고, 행 안에 칸을 넣습니다. 제목 칸은 th(기본으로 굵게·가운데), 데이터 칸은 td예요. "열"을 만드는 태그는 따로 없고, 각 행 안에서 몇 번째 칸이냐가 곧 열입니다. 그 밖에 caption(표 제목, table 바로 다음 첫 자식), thead·tbody·tfoot(머리·본문·바닥 묶음), col/colgroup(열 단위로 너비 지정)이 있어요.

📎 교안 p.45~47 「테이블(Table)」

③ 칸 합치기 — colspan과 rowspan

colspan="3"은 가로로 3칸을, rowspan="6"은 세로로 6줄을 한 칸으로 합칩니다. 엑셀의 "셀 병합"과 같아요. 지켜야 할 규칙은 하나, 모든 행의 칸 수 합이 같아야 한다는 것입니다. 위에서 rowspan으로 내려온 칸이 있는 행은, 그 칸이 이미 자리를 차지하고 있으니 td를 그만큼 덜 씁니다.

📎 교안 p.45 「주요 속성 — rowspan, colspan」, p.48~49 「테이블 실습 1·2단계」

⚠️ 수업 코드에서 조심할 곳

수업 코드의 <table border="1">과 <col width="100" />의 border·width 속성은 HTML5에서 "쓰지 말 것(obsolete)"으로 분류된 옛 속성입니다. 브라우저가 아직 그려 주긴 하지만, 모양은 CSS로 주는 게 정식이에요: th, td { border: 1px solid black; }, col { width: 100px; }. 그리고 html04처럼 border-collapse: collapse;를 함께 써야 칸 테두리가 두 겹으로 보이지 않습니다.

예제 — 수업 코드로 확인하기

html03 — 목록 안의 목록, 클릭하면 펼치기

이 코드는 중첩 목록으로 "메뉴 - 하위 메뉴" 구조를 만들고, 하위 목록을 숨겼다가 클릭하면 펼치는 것을 보여 주려고 만든 실습입니다. 펼치는 부분은 JS가 맡았지만, 여기서는 HTML 구조에 집중하세요.

HTMLhtml03_목록요소.html — style · script · body(발췌)
<style type="text/css">
    li {
        list-style: none;
    }

    /* li요소의 자식요소를 선택 */
    li>ul {
        display: none;
    }

    b {
        cursor: pointer;
    }
</style>
<script type="text/JavaScript">
    //onload : 페이지가 로딩됐을 때 실행을 하겠다라는 이벤트속성
    window.onload = function () {
        document.querySelectorAll("b")[0].onclick=function () {
            document.querySelectorAll("li>ul")[0].style.display="block";
        }
        // …(생략: 두 번째 b도 같은 방식)
    }
</script>
…
<h1>비순차적 목록요소</h1>
<ul>
    <li>
        <b>JAVA</b>
        <ul class="submenu">
            <li>변수의 사용</li>
            <li>제어문</li>
            <li>객체지향[상속,은닉성,다형성]</li>
        </ul>
    </li>
    <!-- …(생략: 데이터베이스 항목도 같은 구조) -->
    <li>HTML</li>
    <li>CSS</li>
</ul>

👀 관찰 포인트 — 하위 ul은 반드시 li 안에 들어 있어요(ul 바로 아래엔 li만). CSS li>ul { display: none; } 때문에 처음엔 하위 목록이 안 보이고, "JAVA"를 클릭하면 JS가 display를 block으로 바꿔 펼칩니다. list-style: none은 목록의 점을 지우는 설정이고, >는 5장에서 배울 "자식 선택자"예요. 같은 메뉴를 8장에서는 JS 없이 CSS :hover만으로 펼칩니다.

html04 — 표의 구성 요소를 한 표에 모두

이 코드는 표의 여러 구성 요소(caption·col·thead·tbody·tfoot·colspan)를 한 표에 모두 써 보려고 만든 실습입니다. 같은 파일 위쪽의 "1.기본 표"와 비교하면 무엇이 추가됐는지 보여요.

HTMLhtml04_표만들기.html — 「2. 표의 다양한 구성 요소를 사용」
<table border="1">
    <caption>표의 제목</caption>
    <col width="100" />
    <col width="200" />
    <col width="100" />
    <thead>
        <tr>
            <th>번호</th>
            <th>제목</th>
            <th>작성일</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>1</td>
            <td>월드컵16강일정</td>
            <td>2026.07.07</td>
        </tr>
        <!-- …(생략: 2번 행) -->
    </tbody>
    <tfoot>
        <tr>
            <td colspan="3">2026년 북중미 월드컵</td>
        </tr>
    </tfoot>
</table>

👀 관찰 포인트 — caption은 표 위 가운데에, th는 굵고 가운데 정렬로 나옵니다. tfoot의 colspan="3"이 세 칸을 하나로 합쳐 문구가 표 폭 전체를 차지해요. 같은 파일 아래쪽 "div요소로 표만들기"는 display: table / table-row / table-cell로 div를 표처럼 보이게 하고, @media (max-width:600px)에서 전부 block으로 바꿔 좁은 화면에선 칸이 세로로 쌓이게 합니다. 표처럼 보이는 것(CSS)과 진짜 표(데이터 구조)는 다르다는 걸 보여 주는 부분이에요.

test2.html — 교안 「테이블 실습 1단계」: rowspan과 colspan 함께 계산하기

이 코드는 교안 p.48의 인적사항 표(왼쪽에 사진 칸)를 그대로 옮기며 rowspan과 colspan을 함께 계산해 보려고 만든 실습입니다. 2단계(p.49, 경력기술서)는 test3.html에서 colspan만으로 같은 표를 세 번 반복해 만들었어요. 3단계(p.50, 입사지원서 전체)는 저장소에 실습 파일이 없습니다.

HTMLtest2.html — 인적사항 입력 표(발췌)
<table border="1">
    <tbody>
        <tr>
            <td rowspan="6" class="asd"></td>
            <td>성명</td>
            <td colspan="3">(한문)</td>
        </tr>

        <tr>
            <td>주민번호</td>
            <td></td>
            <td>생년월일</td>
            <td>년 월 일(음력/양력)</td>
        </tr>

        <tr>
            <td>주소</td>
            <td colspan="3"></td>
        </tr>
        <!-- …(생략: 전화번호·핸드폰·가족사항 3행, 모두 td 4개) -->
    </tbody>
</table>

👀 관찰 포인트 — 칸 수를 세어 보세요. 1행 = 사진(rowspan="6") 1 + 성명 1 + (한문) colspan="3" 3 = 5칸. 2행은 사진 칸이 위에서 내려와 첫 자리를 차지하고 있으니 td를 4개만 씁니다(1+4=5). 3행은 주소 1 + colspan="3" = 4, 여기에 사진 칸을 더해 5. 모든 행이 5칸으로 맞아떨어져야 표가 반듯해요. .asd { width: 120px }가 사진 칸의 폭입니다.

핵심 정리
  • ul(점)·ol(번호)의 자식은 li만. 하위 목록은 li 안에 ul.
  • 표는 table > tr(행) > th/td(칸). 열 태그는 없고 칸 순서가 열이다.
  • colspan은 가로 병합, rowspan은 세로 병합. 모든 행의 칸 수 합은 같아야 한다.
  • 표는 데이터를 보여 줄 때만. 모양(테두리·너비)은 CSS로 준다.
확인 문제
4열짜리 표에서 어떤 행의 첫 칸에 colspan="2"를 줬다면, 그 행에는 td를 몇 개 더 써야 하나요?
2개입니다. 첫 칸이 2칸을 차지하니 2 + 1 + 1 = 4가 됩니다.
test2.html의 2행에 td를 5개 쓰면 어떻게 될까요?
사진 칸(위에서 내려온 rowspan) 1 + 5 = 6칸이 되어, 그 행만 오른쪽으로 칸 하나가 튀어나옵니다. rowspan이 지나가는 행은 그만큼 덜 써야 해요.
thead/tbody를 안 써도 표가 그려지는데, 왜 쓸까요?
머리·본문·바닥이라는 구조의 의미가 생기고, CSS나 JS로 영역을 골라 다루기 쉬워집니다(3부 JS에서 tbody에 행을 추가해요). 참고로 tbody를 안 써도 브라우저가 DOM 트리에 tbody를 자동으로 끼워 넣습니다.
다음 장으로

표가 데이터를 보여 주는 칸이라면, 4장의 폼은 사용자에게서 값을 입력받는 칸이에요. 실제로 수업 코드에서는 표 안에 입력칸을 넣어 줄을 맞춘 폼이 나옵니다.

더 자세히 → 목록(List) 요소표(Table) 만들기실습 — 인적사항 입력 표실습 — 경력사항 표

1부 · HTML

04폼과 HTML5 시맨틱 태그 — 입력받기, 의미로 나누기

📎 교안 p.51~67📅 HTML·CSS 과정 · 6월 말💻 HTML 단원 폼 실습 파일 없음 · 3.javascript/js17.html(폼) · 2-css/test02.html(시맨틱)
이 장의 목표
  • form의 action·method와 입력칸의 name·value가 서버로 name=value 짝으로 간다는 것을 설명할 수 있다.
  • input의 type(text, password, checkbox, radio, submit, button…), textarea, select, button, label을 골라 쓸 수 있다.
  • header·nav·main·section·article·aside·footer를 의미에 맞게 배치할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

1~3장은 모두 보여 주는 태그였어요. 폼은 사용자가 서버로 보내는 유일한 HTML 통로입니다(로그인, 회원가입, 검색). 시맨틱 태그는 div 덩어리에 "머리말·본문·꼬리말" 이름표를 붙이는 일이고요. 솔직히 말하면 HTML 단원 실습 폴더(1.html)에는 폼 실습 파일이 없고, 교안 p.59~60의 회원가입 폼 실습도 저장소에 남아 있지 않아요. 그래서 이 장은 ① 개념을 한 화면에 담은 최소 예시(수업 코드 아님)로 잡고, ② 폼이 실제로 처음 등장하는 수업 코드인 JS 과정의 js17.html, 그리고 시맨틱 태그로 짠 test02.html로 확인합니다.

개념

① form — 값을 담아 보내는 봉투

<form action="받을 주소" method="get|post"> 안에 입력칸을 넣고 제출하면, 브라우저가 각 칸을 name=입력값 짝으로 묶어 보냅니다. 그래서 name이 없는 칸은 전송되지 않아요(서버는 name을 열쇠로 값을 꺼냅니다). get은 값이 주소 뒤에 ?id=hong&pw=…처럼 붙어 보이고(검색에 적합), post는 요청 본문에 담겨 주소창에 안 보입니다(로그인·글쓰기). disabled 칸은 전송되지 않고, readonly 칸은 수정만 막고 전송은 돼요.

📎 교안 p.51 「폼(Form)」, p.54 「input 요소의 주요 속성」

② 입력 컨트롤들과 label

input은 type에 따라 모양이 바뀝니다: text, password(●●●로 가림), checkbox(여러 개 선택), radio(같은 name끼리 하나만 선택), submit(제출), reset(초기화), button(아무 일도 안 하는 버튼 — JS용), file, hidden. 여러 줄은 textarea, 고르기는 select/option(선택한 option의 value가 전송), 묶음은 fieldset/legend입니다. <button>은 폼 안에서 type을 안 쓰면 submit으로 동작하니 꼭 type을 적어요. <label for="아이디">를 입력칸의 id와 맞추면 글자를 눌러도 칸이 선택되고, 스크린리더가 "이 칸의 이름"을 읽어 줍니다.

📎 교안 p.52~58 「fieldset · input · textarea · select · 버튼 · label」

③ HTML5가 더한 입력 기능

새 type(email, url, tel, number, range, date, datetime-local, color 등)과 속성(required 필수 입력, placeholder 힌트, pattern 형식 검사, autofocus)이 생겨서, JS 없이도 브라우저가 기본 검사를 해 줍니다. 모바일에서는 tel이면 숫자 키패드, email이면 @ 키가 있는 키보드가 떠요.

📎 교안 p.66~67 「새로 추가된 폼요소」

④ 시맨틱 태그 — div에 이름표 붙이기

header(머리말), nav(주 메뉴), main(본문, 페이지에 하나), section(주제별 묶음), article(따로 떼어도 말이 되는 글), aside(곁가지 — 사이드바·광고), footer(꼬리말 — 저작권). 모두 기본 모양은 div와 똑같은 블록이에요. 달라지는 건 의미: 스크린리더가 "본문으로 건너뛰기"를 할 수 있고, 검색엔진과 동료 개발자가 구조를 바로 이해합니다. 그 밖에 figure/figcaption, mark(형광펜), time, details/summary(접었다 펴기 — 이 책의 확인 문제가 바로 이 태그예요)도 HTML5에서 생겼습니다.

📎 교안 p.62~65 「HTML5 — 새로운 구조 · 새로운 태그」

⚠️ 교안 표현 바로잡기

교안 p.51은 post를 "전송값이 노출되지 않는다"고 하지만, 주소창에 안 보일 뿐 암호화되는 건 아닙니다. 개발자 도구의 네트워크 탭에서 그대로 보이고, 값을 실제로 지켜 주는 건 HTTPS예요. get의 "전송 데이터 양이 적다"도 HTTP 규칙이 아니라 브라우저·서버의 주소 길이 제한 때문입니다. 또 p.66의 type="datetime"은 표준에서 빠져서 지금 브라우저에선 그냥 글자 칸으로 나와요. 날짜+시간은 datetime-local을 씁니다.

예제 — 수업 코드로 확인하기

최소 예시 — 교안 p.51~58을 한 화면에 (수업 코드 아님)

HTML 단원에 폼 실습 파일이 없어서, 개념 ①~③을 확인하려고 이 책에서 만든 최소 예시입니다. 저장소의 수업 코드가 아니라는 점을 기억하세요.

HTML최소 예시 — 수업 코드 아님
<form action="/join" method="post">
  <fieldset>
    <legend>회원가입</legend>
    <label for="uid">아이디</label>
    <input type="text" id="uid" name="id" required placeholder="영문 4자 이상">

    <label for="upw">비밀번호</label>
    <input type="password" id="upw" name="pw" required>

    성별
    <label><input type="radio" name="gender" value="M"> 남</label>
    <label><input type="radio" name="gender" value="F"> 여</label>

    <select name="area">
      <option value="seoul">서울</option>
      <option value="busan">부산</option>
    </select>
  </fieldset>
  <button type="submit">가입</button>
  <button type="reset">다시 쓰기</button>
</form>

👀 관찰 포인트 — [가입]을 누르면 요청 본문에 id=…&pw=…&gender=M&area=seoul처럼 name=value 짝이 실립니다. 라디오 둘은 name="gender"가 같아서 하나만 고를 수 있어요. "아이디" 글자를 클릭해도 칸에 커서가 들어가는 건 label for와 id가 같기 때문이고, 칸을 비우고 제출하면 required 때문에 브라우저가 막아 줍니다.

js17.html — 표 안에 입력칸을 넣은 폼 (JS 과정 수업 코드)

JS 과정에서 쓴 코드지만, 폼 + 표 + input type을 한 번에 쓴 수업 코드라서 HTML 부분만 미리 봅니다. 3장의 표 안에 4장의 입력칸을 넣어 줄을 맞췄어요.

HTML3.javascript/js17.html — 입력 폼 부분
<form name="formTest">
    <table border="1">
        <caption>회원정보입력</caption>
        <col width="100px">
        <col width="300px">
        <tr>
            <th>아이디</th>
            <td><input type="text" name="id" /></td>
        </tr>
        <tr>
            <th>비밀번호</th>
            <td><input type="password" name="pw" /></td>
        </tr>
        <!-- …(생략: 주소 type="text" name="addr") -->
        <tr>
            <th>전화번호</th>
            <td><input type="tel" name="phone" /></td>
        </tr>
        <tr>
            <td colspan="2"><input type="button" value="추가" onclick="tableVal()" /></td>
        </tr>
    </table>
</form>

👀 관찰 포인트 — ① 마지막 버튼이 type="button"이라 제출하지 않고 JS 함수 tableVal()만 실행합니다. submit이었다면 action이 없으니 같은 페이지로 다시 전송되며 새로고침돼 입력이 사라져요. ② 칸마다 name이 있어서 JS가 input[name]으로 이 칸들만 골라 값을 검사합니다. ③ type="tel"은 HTML5에서 추가된 타입이에요. ④ label이 없어서 "아이디" 글자를 눌러도 칸이 선택되지 않습니다. th 안 글자를 <label for>로 감싸면 좋아져요.

test02.html — 시맨틱 태그로 짠 페이지 뼈대

9장 Flexbox 종합 실습 파일인데, 레이아웃 뼈대를 div 대신 시맨틱 태그(header·main·aside·footer)로 짰습니다. 이름표만 봐도 어디가 무엇인지 보이죠.

HTML2-css/test02.html — body
<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>여기에 본문 내용이 채워집니다. Flexbox를 사용하면 좌우 사이드바와 본문의 높이가 자동으로 같아집니다.</p>
        </main>
        <aside class="right">Banner / Info</aside>
    </div>

    <footer>
        <p>© 2026 Flexbox Practice. All rights reserved.</p>
    </footer>
</div>

👀 관찰 포인트 — CSS 없이 열면 header·aside·main·footer가 그냥 위에서 아래로 쌓입니다. 시맨틱 태그는 모양이 아니라 의미를 바꾸는 태그라는 증거예요. 왼쪽·오른쪽 사이드는 같은 aside를 class로 구분했고, main은 한 페이지에 하나입니다. 1장의 html01 파일도 header·main·section·article로 짜여 있었죠. 이 뼈대를 옆으로 세우는 방법은 9장에서 배웁니다.

핵심 정리
  • 폼은 각 칸을 name=value로 묶어 action 주소로 보낸다. name이 없으면 안 간다.
  • get은 주소에 붙고, post는 본문에 실린다. 둘 다 HTTPS가 없으면 보호되지 않는다.
  • radio는 같은 name끼리 하나만 선택된다. <button>은 type을 꼭 쓴다(기본 submit).
  • label for = 입력칸 id. 클릭 범위와 접근성이 좋아진다.
  • 시맨틱 태그는 모양은 div와 같고, 의미(구조)를 전달한다.
확인 문제
로그인 폼에 method="get"을 쓰면 왜 안 될까요?
비밀번호가 주소 뒤에 그대로 붙어서 주소창, 방문 기록, 서버 접속 기록에 남습니다. 로그인은 post + HTTPS로 보내요.
입력칸에 name을 빼먹으면 어떤 일이 생기나요?
화면에는 멀쩡히 보이고 입력도 되지만, 제출할 때 그 칸의 값은 서버로 전송되지 않습니다. 서버는 name을 열쇠로 값을 꺼내기 때문이에요.
라디오 버튼 세 개 중 하나만 선택되게 하려면?
세 개 모두 같은 name을 주고, value는 서로 다르게 줍니다. 전송되는 건 선택된 하나의 name=value예요.
div 대신 header·nav·footer를 쓰면 화면이 달라지나요?
기본 모양은 똑같은 블록이라 달라지지 않습니다. 달라지는 건 의미예요. 스크린리더가 영역 단위로 건너뛸 수 있고, 검색엔진과 다른 개발자가 구조를 쉽게 이해합니다.
다음 장으로

여기까지가 1부입니다. HTML로 "무엇이 있는지"를 다 적었어요. 2부 5장부터는 CSS로 "어떻게 보일지"를 정합니다. 첫 질문은 세 가지예요: 스타일을 어디에 쓰나, 어떤 요소를 고르나(선택자), 규칙이 겹치면 누가 이기나(우선순위).

더 자세히 → DOM으로 표 동적 생성(js17 폼)실습 — 종합 스타일링(test02)

2부 · CSS

05CSS 작성 방식과 선택자 — 우선순위(구체성 점수)

📎 교안 p.68~89📅 HTML·CSS 과정 · 6월 말💻 2-css/css01_작성방식.html · css02_selector표현식.html · css03.html · css/css02.css · css/test.css
이 장의 목표
  • 인라인·내부·외부 스타일시트를 쓸 줄 알고, 각각 언제 쓰는지 말할 수 있다.
  • 타입·class·id·전체·자손·자식(>)·인접(+)·형제(~)·속성·가상 클래스·가상 요소 선택자를 읽고 쓸 수 있다.
  • 규칙이 겹칠 때 구체성 점수 (id, class, 태그)로 승자를 계산하고, 동점이면 나중 규칙이 이긴다는 것을 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결

1부에서 HTML로 뼈대와 의미를 만들었어요. 이제 모양을 입힙니다. CSS 규칙은 선택자 { 속성: 값; } 한 가지 모양뿐인데, 초보 때 겪는 문제의 대부분은 "왜 내 스타일이 안 먹지?"예요. 답은 거의 항상 둘 중 하나입니다. 선택자가 그 요소를 못 골랐거나, 다른 규칙과 겨뤄서 졌거나. 그래서 CSS 첫 장에서 이 두 가지를 정확히 잡습니다.

개념

① 세 가지 작성 위치

인라인은 요소의 style="…" 속성에 직접(그 요소 하나만), 내부는 head의 <style> 안에(그 문서 전체), 외부는 별도 .css 파일에 쓰고 <link rel="stylesheet" href="…">로 연결합니다(여러 문서가 공유 — 실무 기본). 외부 파일 맨 위의 @charset "UTF-8";은 파일 인코딩 선언이에요. @import로도 불러올 수 있지만, 파일을 하나 받은 뒤에야 다음 파일을 요청해서 느리기 때문에 link를 권장합니다.

📎 교안 p.69~72 「CSS 기본문법 — 인라인·내부·외부 스타일시트, @import」

② 선택자 — 누구에게 입힐까

p(태그 이름: 그 태그 전부), .c(class: 여러 요소에 붙일 수 있음), #a(id: 문서에 딱 하나), *(전부), A B(자손: A 안에 있으면 몇 단계 아래든), A>B(자식: 바로 한 단계 아래만), A+B(A 바로 다음 형제 하나), A~B(A 뒤에 오는 형제 전부), [title="값"](속성), A, B(그룹: 둘 다). 관계 선택자는 1장의 DOM 트리를 떠올리며 읽으면 쉬워요.

📎 교안 p.73~80, 83~85 「선택자(Selector)」

③ 가상 클래스와 가상 요소

가상 클래스(콜론 하나)는 요소의 상태나 위치로 고릅니다: :hover(마우스를 올렸을 때), :link/:visited(안 간 링크/간 링크), :active(누르는 순간), :first-child. 가상 요소(콜론 둘)는 HTML에 없는 조각을 새로 만들어 꾸밉니다: ::before/::after(앞뒤에 상자 추가, content 속성 필수), ::first-letter, ::first-line.

📎 교안 p.81~82 「가상 클래스·수도 클래스 선택자」

④ 우선순위 = 구체성 점수

같은 요소의 같은 속성에 규칙이 겹치면 선택자마다 점수 (id 개수, class·속성·가상 클래스 개수, 태그·가상 요소 개수)를 매겨 왼쪽 자리부터 비교합니다. 예: #c+p (1,0,1) > p[title] (0,1,1) > h1~p (0,0,2) > p (0,0,1) > * (0,0,0). 점수가 같으면 나중에 나온 규칙이 이깁니다. 인라인 style은 어떤 선택자보다 세고, !important는 그보다 더 세요(남발하면 나중에 아무도 못 이기니 최후의 수단). 그리고 부모에게서 물려받은(상속) 값은 점수가 없어서, 그 요소에 직접 걸린 규칙이 하나라도 있으면 그게 이깁니다.

📎 교안 p.87~89 「선택자의 우선순위」

⚠️ 교안 표현 바로잡기

① 교안 p.82는 :first-letter·:first-line·:before·:after를 가상(수도) 클래스로 묶었지만, 이 넷은 가상 요소입니다. CSS3부터 ::로 구분해요(옛 한 콜론 표기도 브라우저가 받아 주지만 새 코드는 ::after). ② p.88의 "In-line > id > class > type"은 자리의 무게 순서이지 개수를 더하는 게 아닙니다. class가 11개인 (0,11,0)도 id 하나 (1,0,0)를 못 이겨요. "마지막에 선언된 스타일이 우선"(p.69, 87)은 점수가 같을 때만 맞는 말입니다. ③ 교안 예시 곳곳의 magin은 margin 오타예요. 브라우저는 모르는 속성을 오류 없이 조용히 무시하니, 스타일이 안 먹으면 개발자 도구에서 줄이 그어진(무효) 속성부터 확인하세요.

예제 — 수업 코드로 확인하기

css01 + test.css — 세 가지 방식을 한 문서에

이 파일은 교안 p.69~71의 인라인·내부·외부 방식을 한 문서에 모두 넣어 무엇이 어디에 적용되는지 비교하려고 만든 실습입니다.

HTMLcss01_작성방식.html — head · body
<link rel='stylesheet' type='text/css' media='screen' href='css/test.css'>
<style>
    p {
        color: red;
        background-color: blue;
    }
</style>
…
<p style='color: aqua; background-color: black;'>in-line 방식</p>
<p>내부스타일방식</p>
<p><strong>외부스타일방식</strong></p>
CSScss/test.css — 외부 파일
@charset "UTF-8";

strong {
    color: chocolate;
}

👀 관찰 포인트 — 1번 문단은 style 속성(인라인)이 내부 p 규칙을 이겨 하늘색 글자·검은 배경. 2번은 내부 규칙대로 빨간 글자·파란 배경. 3번 문단 자체도 내부 규칙이지만, 안의 strong에만 외부 파일 규칙이 걸려 초콜릿색입니다. 만약 test.css에도 p { color: green; }이 있었다면? link가 style보다 위에 있으니 점수가 같은 두 규칙 중 나중에 나온 내부 style의 red가 이깁니다. "외부냐 내부냐"가 아니라 "누가 나중이냐"예요.

css03 — 교안 「예제1」: 요구사항 7개를 선택자로

이 코드는 교안 p.86 「예제1」의 HTML을 그대로 두고, 문장에 적힌 7가지 요구사항을 선택자 하나씩으로 푼 실습입니다.

CSScss03.html — style
p {
    background-color: pink;
}

#a {
    color: red;
}

.b {
    color: blue;
}

* {
    padding: 2px;
}

P>strong {
    color: yellow;
}

#c+p {
    color: green;
}

#c~p {
    color: green;
}

p[title="attr"] {
    font-size: 15pt;
}
HTMLcss03.html — 두 번째 div(발췌)
<p id="c">6.id=c 인 속성을 가진 p태그 다음에 인접한p태그에 <b>color:green</b> 적용하기</p>
<p>id=c인 속성을 가진 p태그 다음에 오는 p태그</p>
<p title="attr">7.title속성을 가진 p태그 <b>font-size:15pt</b> 로 적용하기</p>

👀 관찰 포인트 — #c+p는 바로 다음 p 하나만, #c~p는 뒤에 오는 p 전부를 고릅니다. 그래서 7번 문장(title="attr")까지 초록색이 돼요. 요구사항 6에는 +만 있으면 됐는데 ~까지 써서 범위가 넓어진 거죠. P>strong처럼 대문자 P로 써도 HTML 문서에서는 태그 이름 대소문자를 구분하지 않아 작동합니다. 2번 문장 안의 <b>에는 규칙이 없어서 부모 #a의 빨간색을 물려받아 빨갛게 보여요.

css02.css — 규칙이 겹칠 때 점수 계산하기

이 파일은 선택자 종류를 한 파일에 모아 두고 같은 p들에 규칙이 겹치게 해서 "누가 이기나"를 관찰하려고 만든 외부 스타일시트입니다(css02_selector표현식.html이 link로 불러옴).

CSScss/css02.css — 발췌
/*타입선택자 */
p {
    width: 800px;
    background-color: bisque;         /* (0,0,1) */
}

/*id선택자*/
#sub_title1 {
    font-size: 10px;
}

/*class선택자*/
.c {
    color: blue;
}

/* 자식선택자 */
#sub_title1>b {
    color: red;
}

h1 b {
    color: blueviolet;
}

h1+p {
    border: 1px solid red;
}

h1~p {
    width: 800px;
    background-color: aquamarine;     /* (0,0,2) */
}

p[title="주식관련기사"] {
    background-color: gold;           /* (0,1,1) */
}
/* …(생략: .a::after, a:link, a:visited, a:active) */
p:hover {
    background-color: yellow;         /* (0,1,1), 파일에서 더 아래 */
    font-weight: bold;
}
HTMLcss02_selector표현식.html — body(발췌)
<div id="container1">
    <h1 id="sub_title1">삼성전자 2·4분기 실적을 놓고 증권사들의 평가가 엇갈리고 있다.</h1>
    <p class="c">
        … <b>39만원</b>으로 낮춘 증권사도 나왔다. <span><b>실적</b></span> 호조에는 …
    </p>
    <!-- …(생략: p.c 두 개 더) -->
    <p title="주식관련기사"> … </p>
    <p class="a"> … </p>
</div>

👀 관찰 포인트 — title="주식관련기사" 문단의 배경 후보는 셋: p (0,0,1) bisque, h1~p (0,0,2) aquamarine, p[title=…] (0,1,1) gold → gold 승. 나머지 p들은 h1~p가 이겨 aquamarine이에요(p의 bisque는 어디에서도 보이지 않습니다). 마우스를 올리면 p:hover (0,1,1)가 p[title]과 동점인데 파일에서 더 아래라 노란색이 이깁니다. 첫 번째 p.c만 빨간 테두리인 건 h1+p가 바로 다음 하나만 고르기 때문이고요.

⚠️ 수업 코드에서 조심할 곳

css02.css의 #sub_title1>b와 h1 b는 아무 요소도 고르지 못합니다. h1#sub_title1 안에는 <b>가 없거든요. <b>는 모두 p 안에 있으니, 보려는 효과를 내려면 p b(p 안의 b 전부: 39만원·실적·KB증권은) 또는 p>b(p 바로 아래 b만: 39만원)로 써야 해요. 선택자가 아무것도 못 고르면 오류도 없이 그냥 아무 일도 안 일어난다는 것, 이게 "왜 안 먹지?"의 첫 번째 원인입니다.

핵심 정리
  • 작성 위치는 인라인(요소 하나)·내부(문서 하나)·외부(여러 문서). 실무는 외부 파일 + link.
  • 공백(자손)은 몇 단계 아래든, >(자식)는 바로 아래만. +는 바로 다음 하나, ~는 뒤에 오는 형제 전부.
  • 가상 클래스(:hover)는 상태로 고르고, 가상 요소(::after)는 없는 조각을 만든다.
  • 점수 (id, class·속성·가상 클래스, 태그·가상 요소)를 왼쪽 자리부터 비교하고, 동점이면 나중 규칙이 이긴다. 인라인 > 모든 선택자, !important > 인라인.
  • 상속받은 값은 점수가 없다. 직접 걸린 규칙이 항상 이긴다.
확인 문제
.box p { color: red; }와 #main p { color: blue; }가 같은 p에 걸리면 무슨 색이고, 각 점수는?
파란색입니다. #main p는 (1,0,1), .box p는 (0,1,1)이라 첫 자리(id)에서 이미 결판이 나요. 파일 순서는 상관없습니다.
css03에서 #c~p 규칙만 지우면 7번 문장은 무슨 색이 될까요?
기본 글자색(검정)입니다. #c+p는 바로 다음 p 하나만 초록으로 만들고, 7번 문장에 걸린 p[title="attr"]은 글자 크기만 바꾸니까요.
.test {color: red;} .test {color: blue;} .test {color: yellow;}가 차례로 있으면?
노란색입니다. 세 규칙의 점수가 모두 (0,1,0)으로 같으니 마지막에 나온 규칙이 이깁니다(교안 p.87).
a:hover는 콜론이 하나, p::after는 둘인 이유는?
:hover는 이미 있는 요소를 "마우스가 올라간 상태"로 고르는 가상 클래스이고, ::after는 HTML에 없는 조각을 새로 만드는 가상 요소라서 표기를 구분합니다.
다음 장으로

이제 "누구에게"를 정확히 고를 수 있어요. 6장에서는 고른 요소에 실제로 무엇을 입힐지, 그중 글자(글꼴·문단)와 바탕(배경)부터 배웁니다. 이번 장의 점수 계산은 8장에서 !important가 왜 필요했는지 설명할 때 다시 써요.

더 자세히 → CSS 작성 방식Selector(선택자) 표현식셀렉터 종합 실습외부 스타일시트 파일

2부 · CSS

06글꼴 · 문단 · 배경 — 글자와 바탕 꾸미기

📎 교안 p.90~95, 118~122📅 HTML·CSS 과정 · 6월 말💻 2-css/css04_font.html · css05.html · css06.html
이 장의 목표
  • 글꼴 속성 여섯 가지를 쓰고, font 축약형의 순서를 지켜 한 줄로 줄일 수 있다.
  • font-family는 쉼표로 대체 목록을 만들고, 쉼표가 빠지면 선언이 통째로 무효가 된다는 것을 안다.
  • text-align·text-indent·text-decoration·line-height로 문단을 다듬을 수 있다.
  • 배경 속성을 쓰고, 축약형의 위치 / 크기 표기를 읽을 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

5장에서 "누구에게"를 배웠으니 이번엔 "무엇을" 입힐 차례예요. 글자와 배경은 상자의 안쪽을 꾸미는 속성이고, 다음 7장에서는 상자 자체(크기·여백·테두리)를 다룹니다. 이 장에는 "문법 한 글자가 틀리면 브라우저가 줄 전체를 버린다"는 CSS의 성질을 보여 주는 수업 코드가 있어서, 그것도 함께 짚어요.

개념

① 글꼴 속성과 font 축약형

font-size(크기), font-weight(굵기), font-style(기울임), font-variant(작은 대문자), line-height(줄 높이), font-family(글꼴). 축약형은 font: [style variant weight] size[/line-height] family; 순서입니다. size와 family는 필수, 앞의 셋은 생략하거나 서로 순서를 바꿔도 되고, 줄 높이는 size 뒤에 /로 붙여요. 주의할 점: 축약형은 안 쓴 항목을 기본값으로 되돌립니다(예: 줄 높이를 안 쓰면 normal로 리셋).

📎 교안 p.90 「서체(font)」

② font-family는 "1지망, 2지망…" 목록

font-family: "맑은 고딕", 굴림, sans-serif;는 "맑은 고딕이 있으면 쓰고, 없으면 굴림, 그것도 없으면 아무 고딕 계열"이라는 뜻이에요. 쉼표가 목록을 나누는 기호이고, 공백이 들어간 이름("맑은 고딕")은 따옴표로 감쌉니다. 사용자 컴퓨터에 그 글꼴이 없을 수 있으니 맨 끝에는 sans-serif·serif 같은 일반 계열을 넣어요.

📎 교안 p.90 「font-family — 텍스트의 글꼴을 지정한다」

③ 문단 속성

text-align(left·right·center·justify 정렬), text-indent(첫 줄 들여쓰기, 음수면 내어쓰기), text-decoration(underline·line-through·overline, 링크 밑줄을 없앨 때 none), text-transform(대소문자), letter-spacing(자간). line-height는 줄 상자의 높이라서, 한 줄짜리 글에 상자 높이만큼 주면 글자가 세로 가운데에 옵니다(7·8장 수업 코드에서 쓰는 요령). vertical-align은 인라인 요소와 표 칸에만 먹어요.

📎 교안 p.91~93 「문단(Paragraph)」, p.94 「예제」

④ 배경

background-color, background-image: url(…), background-repeat(repeat·no-repeat·repeat-x·repeat-y), background-position(가로 세로), background-size(크기, cover는 꽉 채우고 넘치면 잘림, contain은 다 보이고 빈 곳이 생김), background-attachment(스크롤 고정). 축약형은 background: url(…) no-repeat 20px 50px / 100px 100px;처럼 쓰고, 위치와 크기는 붙여서 /로 구분합니다. 쉼표는 "배경 층을 하나 더"라는 뜻이에요(CSS3 여러 배경).

📎 교안 p.95 「배경(Background)」, p.118~122 「CSS3 배경 — gradient·size·origin·multi·clip」

⚠️ 교안 표현 바로잡기

교안 p.95의 축약형 예시 #ff0000 url(hankyung.jpg) no-repeat, 0px 10px;에서 쉼표는 틀렸습니다. 배경 축약형의 쉼표는 "배경 층 구분"이라 이 줄은 층 두 개로 읽히고, 색은 마지막 층에만 쓸 수 있어서 선언 전체가 무효가 돼요. 올바른 형태는 background: #ff0000 url(hankyung.jpg) no-repeat 0px 10px;입니다. "순서 상관없음"도 대체로 맞지만, 위치와 크기(위치 / 크기)는 붙여 써야 해요.

예제 — 수업 코드로 확인하기

css04 — 여섯 줄 vs 축약형 한 줄

이 코드는 같은 글꼴 스타일을 "하나씩 나열"과 "font 축약형 한 줄"로 비교하려고 만든 실습입니다. 주석에 축약형 순서 규칙을 적어 두었어요. 그런데 이 파일에는 일부러 넣은 건 아닌 문법 실수가 하나 있어서, 오히려 "무효 선언"을 관찰하기 좋은 예제가 됐습니다.

CSScss04_font.html — style
.c {
    font-size: 20px;
    font-weight: bold;
    font-style: italic;
    font-variant: small-caps;
    line-height: 50px;
    font-family: "맑은 고딕" 굴림;     /* ← 쉼표 없음: 이 줄은 무효 */
}

.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 순으로 작성해야 함
*/
.d {
    font-variant: small-caps;
}

👀 관찰 포인트 — 화면의 .c 문단은 20px·굵게·기울임·small-caps·줄 높이 50px이 적용되지만 글꼴은 기본 글꼴입니다. 첫 규칙에서는 font-family 한 줄만 버려지고, 두 번째 규칙(축약형)은 줄 전체가 버려져 화면에 아무 기여도 하지 않아요. 개발자 도구에서 두 선언에 경고 표시와 취소선이 보입니다. .d의 small-caps는 영문 소문자를 작은 대문자로 바꾸고, 한글에는 효과가 없어요.

css05 — 교안 「예제」: 같은 문제를 두 번 풀기

이 코드는 교안 p.94 「예제」(문장 6개를 정해진 글꼴·크기·색·정렬로, 인라인 방식으로 꾸미기)를 개별 속성으로 한 번, font 축약형으로 한 번 푼 실습입니다. hr 위아래가 같은 결과를 내야 해요.

HTMLcss05.html — 1·2·5번 문장(발췌)
<p style="font-weight: bold; font-size: 14px; color: blue;">1.오늘 점심은 어떤 메뉴일까?</p>
<p style="font-style: italic; font-size: 16px; font-family: '굴림'; color: #E8334D">2.오늘 점심은 어떤 메뉴일까?</p>
<!-- …(생략: 3·4번) -->
<p style="font-weight: bold; line-height: 50px; font-family: '돋움'; font-size: 16px; color:green;">5.오늘 점심은 어떤
    메뉴일까?</p>
<!-- …(생략: 6번) -->

<hr>
<p style="font: bold 14px sans-serif; color: blue;">1.오늘 점심은 어떤 메뉴일까?</p>
<p style="font: italic 16px '굴림'; color: #E8334D">2.오늘 점심은 어떤 메뉴일까?</p>
<!-- …(생략: 3·4번) -->
<p style="font: bold 16px/50px '돋움'; color:green;">5.오늘 점심은 어떤
    메뉴일까?</p>

👀 관찰 포인트 — 5번 축약형은 16px/50px로 크기와 줄 높이를 한 번에 씁니다. color·text-decoration·text-align은 font 축약형에 들어가지 않아서 따로 적었어요. 위쪽 1번은 font-family가 빠져 있어 교안의 요구(굵은 돋움)와 조금 다르고, 아래쪽에서는 sans-serif를 썼습니다. 인라인 방식은 연습엔 좋지만 같은 스타일을 매번 다시 적어야 해서, 실무에선 class + 외부 파일로 씁니다(5장).

css06 — background 축약형과 반투명 배경 이미지

이 코드는 background 축약형(#box1)과, ::after 가상 요소 + opacity로 반투명 배경 이미지를 깔기(#box2)를 보여 주려고 만든 실습입니다.

CSScss06.html — style
#box1 {
    border: 1px solid gray;
    width: 300px;
    height: 300px;
    /* …(생략: 같은 효과를 개별 속성으로 적어 둔 주석) */
    background: url("asd.jpg") no-repeat 20px 50px / 100px 100px;
}

#box2 {
    width: 300px;
    height: 300px;
    position: relative;
    z-index: 10;
}

#box2::after {
    content: "";
    width: 300px;
    height: 300px;
    background-image: url("asd.jpg");
    background-size: 300px 300px;
    opacity: 0.5;
    position: absolute;
    left: 0px;
    top: 0px;
    z-index: 0;
}

👀 관찰 포인트 — #box1: 이미지(반복 없음)를 100×100으로 줄여 왼쪽 20px·위 50px 자리에 한 장 놓습니다. #box2: ::after가 content: ""로 빈 상자를 만들고, position: absolute; left: 0; top: 0으로 #box2 전체(300×300)를 덮은 뒤 opacity: 0.5로 반투명해져요. 그런데 화면을 보면 이미지가 글 뒤가 아니라 위에 덮여 글이 흐릿합니다. 이유는 아래 상자에서 설명해요(position과 z-index는 8장에서 자세히).

⚠️ 수업 코드에서 조심할 곳

① css04: font-family: "맑은 고딕" 굴림;은 글꼴 이름 사이 쉼표가 없어 문법 오류이고, 브라우저는 잘못된 선언을 통째로 버립니다. 축약형 줄도 같은 이유로 전체가 무효예요. 고치려면 font-family: "맑은 고딕", 굴림, sans-serif; / font: bold italic small-caps 20px/50px "맑은 고딕", 굴림, sans-serif;. ② css06: #box2::after의 z-index: 0인 위치 지정 요소는 위치 지정이 없는 일반 글(p)보다 나중에(=위에) 그려집니다. #box2의 z-index: 10은 #box2 상자 전체를 바깥 형제와 비교할 때만 쓰이고, 안쪽 글과 ::after의 순서는 바꾸지 못해요. 글을 위로 올리려면 #box2::after { z-index: -1; }(#box2가 z-index로 자기만의 층 묶음을 만들었기 때문에 -1이어도 #box2 밖으로 가라앉지 않음) 또는 #box2 p { position: relative; z-index: 1; }로 고칩니다.

핵심 정리
  • font 축약형: [style variant weight] size[/line-height] family. size와 family 필수, 안 쓴 항목은 기본값으로 리셋.
  • font-family는 쉼표로 대체 목록을 만들고 끝에 일반 계열을 둔다. 쉼표가 빠지면 선언 전체가 무효.
  • CSS는 틀린 선언을 오류 없이 버린다. 안 먹으면 개발자 도구에서 취소선부터 확인.
  • 문단: text-align·text-indent·text-decoration·line-height(한 줄 세로 가운데 요령).
  • 배경 축약형은 이미지 반복 위치 / 크기. 쉼표는 배경 층 구분이다.
확인 문제
font: 20px bold "맑은 고딕";은 왜 무효일까요?
굵기(bold)가 크기 뒤에 왔기 때문입니다. style·variant·weight는 반드시 size 앞에 와야 해요. 크기 뒤는 전부 글꼴 이름으로 읽히는데 bold "맑은 고딕"은 올바른 글꼴 목록이 아니라서 줄 전체가 버려집니다.
font-family 목록 맨 끝에 sans-serif를 넣는 이유는?
앞에 적은 글꼴이 사용자 컴퓨터에 하나도 없을 때, 최소한 같은 계열(고딕류)의 기본 글꼴로 보이게 하려고요.
background-size의 cover와 contain은 어떻게 다른가요?
cover는 영역을 빈틈없이 채우되 넘치는 부분은 잘리고, contain은 이미지 전체가 보이되 남는 곳이 빈 공간으로 남습니다.
css06에서 글을 반투명 이미지 위로 또렷하게 올리는 방법 두 가지는?
① #box2::after { z-index: -1; }로 이미지를 글 아래로 내리기. ② #box2 p { position: relative; z-index: 1; }로 글을 위치 지정 요소로 만들어 이미지보다 위에 올리기.
다음 장으로

글자와 배경은 상자의 "안쪽"이었어요. 7장에서는 그 상자 자체의 치수 — 내용, 안쪽 여백, 테두리, 바깥 여백 — 를 다루는 박스 모델을 배웁니다. 레이아웃이 어긋나는 원인의 대부분이 이 계산에서 나와요.

더 자세히 → 폰트(Font)문단 스타일 실습배경(Background) 속성

2부 · CSS

07박스 모델 — content·padding·border·margin과 box-sizing

📎 교안 p.96~100, 104📅 HTML·CSS 과정 · 6월 말💻 2-css/css07.html · test01.html
이 장의 목표
  • 박스의 네 겹을 안쪽부터 말하고, 개발자 도구의 박스 그림을 읽을 수 있다.
  • margin/padding의 값 1~4개 축약(위·오른쪽·아래·왼쪽, 시계 방향)을 쓸 수 있다.
  • content-box와 border-box에서 상자의 겉크기를 계산할 수 있다.
  • margin: 0 auto 가운데 정렬, 세로 margin 겹침, display의 block·inline·inline-block 차이를 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결

2장에서 블록은 "상자", 인라인은 "글자"라고 했어요. 6장에서 상자 안쪽(글자·배경)을 꾸몄으니, 이제 상자의 치수를 다룹니다. "분명 300px로 줬는데 왜 324px이지?", "왜 두 카드가 옆에 안 서지?" 같은 질문의 답이 모두 이 장에 있어요.

개념

① 상자의 네 겹

택배 상자로 생각하면 content(내용물) → padding(뽁뽁이 — 안쪽 여백, 배경색이 여기까지 칠해짐) → border(상자 벽 — 테두리) → margin(상자와 상자 사이 간격 — 투명)입니다. 기본 설정에서 width/height는 content만의 크기예요. 개발자 도구(F12) → Computed 탭에 이 네 겹이 그림으로 나옵니다. 테두리는 border: 1px solid red;(두께 종류 색) 한 줄로 써요.

📎 교안 p.96~97 「상자(Box)」, p.100 「Border」

② 방향 축약과 margin의 두 가지 성질

값 4개는 위 오른쪽 아래 왼쪽(시계 방향), 3개는 위 / 좌우 / 아래, 2개는 위아래 / 좌우, 1개는 네 방향 모두입니다. margin에는 특별한 성질이 둘 있어요. 좌우를 auto로 주면 남는 공간을 반씩 나눠 블록이 가운데로 갑니다(너비가 정해져 있어야 남는 공간이 생김). 그리고 세로로 맞닿은 두 블록의 margin은 더해지지 않고 큰 쪽 하나로 합쳐집니다(margin 병합 — 위 20, 아래 30이면 사이는 30).

📎 교안 p.98 「Margin·Padding」, p.99 「Margin 병합 현상 · Margin을 이용한 중앙 정렬」

③ box-sizing — width가 어디까지냐

기본값 content-box에서는 겉폭 = width + padding×2 + border×2라서, padding을 늘릴수록 상자가 커집니다. border-box로 바꾸면 width가 곧 겉폭이 되고 padding·border는 안쪽에서 깎여요. 계산이 쉬워서 실무에선 * { box-sizing: border-box; }를 첫 줄에 두는 경우가 많습니다. 이 속성은 교안에는 없고 수업 코드에서 처음 나왔어요.

📎 교안 없음 — 수업 코드(css07)가 교재

④ display — 상자의 성격 바꾸기

block(한 줄 차지, 크기 지정 O), inline(옆으로 흐름, width/height 무시), inline-block(옆으로 흐르면서 크기도 지정 O), none(화면에서 사라지고 자리도 없어짐). 2장에서 태그마다 정해져 있던 블록/인라인 성격을 CSS로 바꿀 수 있다는 뜻이에요.

📎 교안 p.104 「레이아웃 — display」

예제 — 수업 코드로 확인하기

css07 — 겉크기가 같은 두 상자를 만들려면

이 코드는 content-box에서 겉크기를 손으로 맞추는 계산과, 그걸 대신해 주는 box-sizing: border-box를 비교하려고 만든 실습입니다. BOX1(width 300, padding 10, border 2)과 BOX2(width 274, padding 20, border 5)는 content-box라면 겉크기가 300+20+4 = 324, 274+40+10 = 324로 같아지도록 숫자를 고른 거예요.

CSScss07.html — style
main {
    border: 1px solid red;
    width: 1000px;
    /*위아래 양쪽*/
    margin: 0 auto;
}

h1 {
    text-align: center;
}

/* reset코드 */
* {
    margin: 0;
    padding: 0;
}

/* 공통 박스 스타일 */
.container {
    background-color: antiquewhite;
    padding: 20px;
    margin-bottom: 20px;
    border: 2px dashed cadetblue;
}

.content-box {
    width: 300px;
    height: 200px;
    background-color: aquamarine;
    border: 2px solid red;
    padding: 10px;
}

.border-box {
    width: 274px;
    height: 200px;
    background-color: aquamarine;
    border: 5px solid red;
    padding: 20px;
}
/* …(생략: 274px 계산 주석) */

/* box의 width 값 기준 설정 */
.content-box,
.border-box {
    box-sizing: border-box;
}

👀 관찰 포인트 — main은 width 1000px + margin: 0 auto로 화면 가운데에 옵니다. 그런데 * { margin: 0; }이 main 규칙보다 아래에 있는데도 main의 auto가 살아남아요. 5장의 점수 때문입니다: *는 (0,0,0), main은 (0,0,1). 순서보다 점수가 먼저예요. 개발자 도구에서 BOX1·BOX2를 각각 눌러 실제 폭을 확인해 보세요. 결과는 아래 상자처럼 300과 274로 다릅니다.

test01 — inline-block으로 카드 두 장 나란히

이 코드는 div(블록)를 inline-block으로 바꿔 옆으로 나란히 세우면서도 크기는 지정하는 실습입니다. inline-block의 대표적인 골칫거리(카드 사이 틈)를 없애는 요령도 들어 있어요.

HTMLtest01.html — style · body
<style>
    #big {
        font-size: 0;
    }

    #Card1 {
        display: inline-block;
        width: 250px;
        height: 150px;
        vertical-align: top;
        font-size: 16px;
        margin-right: 20px;
        border: 5px solid #2c3e50;
    }
    /* …(생략: #Card2 — margin-right만 없고 같음) */

    #card_inside {
        box-sizing: border-box;
        padding: 15px;
        text-align: center;
        line-height: 110px;
    }
</style>
…
<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>

👀 관찰 포인트 — 카드 겉폭은 content-box라 250 + 5×2 = 260px입니다. inline-block은 글자처럼 취급돼서 소스의 줄바꿈이 공백 한 칸의 틈으로 보이는데(2장 span 사이 띄어쓰기와 같은 원인), 부모 #big의 font-size: 0이 그 틈의 폭을 0으로 만들어요. 대신 font-size는 상속되니 카드에서 16px로 되돌립니다. vertical-align: top은 두 카드의 윗선을 맞추고, line-height: 110px은 한 줄 글자를 세로 가운데에 놓는 요령(6장)이에요.

⚠️ 수업 코드에서 조심할 곳

① css07: 마지막 규칙 .content-box, .border-box { box-sizing: border-box; }가 두 상자 모두를 border-box로 바꾸기 때문에, 실제 화면은 BOX1 300px, BOX2 274px로 크기가 다릅니다. 주석의 324 계산은 content-box일 때의 이야기예요. 마지막 규칙을 지우면 둘 다 324px로 같아지고, border-box를 쓸 거면 BOX2도 그냥 width: 300px로 두면 둘 다 300px이 됩니다. border-box의 장점이 바로 "274를 손으로 계산할 필요가 없다"는 거죠. ② test01: id="card_inside"를 두 번 썼어요. id는 문서에 하나뿐이어야 합니다. 브라우저가 CSS를 둘 다에 적용해 줘서 화면은 멀쩡하지만, JS의 getElementById("card_inside")는 첫 번째 것만 돌려줘요. 여러 개에 같은 스타일을 줄 때는 class="card_inside" + .card_inside { … }로 씁니다.

핵심 정리
  • 상자는 안쪽부터 content → padding → border → margin.
  • content-box(기본): 겉폭 = width + padding×2 + border×2. border-box: 겉폭 = width.
  • 값 4개는 위·오른쪽·아래·왼쪽(시계 방향). 좌우 auto는 가운데 정렬, 세로 margin은 큰 쪽 하나로 병합.
  • inline-block = 옆으로 흐르면서 크기 지정 가능. 소스 줄바꿈 틈은 부모 font-size: 0으로 없앤다.
  • id는 문서에 하나. 반복되는 스타일은 class.
확인 문제
width: 200px; padding: 20px; border: 5px; margin: 10px;인 상자의 겉폭은? content-box와 border-box 각각.
content-box는 200 + 40 + 10 = 250px, border-box는 200px입니다. 여기에 좌우 margin 20을 더한 "차지하는 공간"은 각각 270px, 220px이에요.
margin: 10px 20px;은 어느 방향에 얼마인가요?
위아래 10px, 좌우 20px입니다(값 2개 = 위아래 / 좌우).
위 상자의 margin-bottom: 30px, 아래 상자의 margin-top: 20px이면 두 상자 사이 간격은?
50px이 아니라 30px입니다. 세로로 맞닿은 margin은 큰 쪽 하나로 합쳐져요(교안 p.99).
css07에서 BOX1과 BOX2를 border-box로 둔 채 겉크기를 똑같이 300px로 맞추려면 무엇을 바꾸나요?
.border-box의 width를 274px에서 300px로 바꾸면 됩니다. border-box에서는 width가 곧 겉폭이라 padding·border 차이를 계산할 필요가 없어요.
다음 장으로

지금까지 요소는 모두 흐름대로 — 블록은 위에서 아래로, 인라인은 왼쪽에서 오른쪽으로 — 놓였어요. 8장에서는 position으로 요소를 흐름에서 빼내 원하는 좌표에 놓고, 서로 겹치게 하고, 그걸로 드롭다운 메뉴를 만듭니다.

더 자세히 → 박스 모델(Box Model)실습 — inline-block 카드

2부 · CSS

08position과 메뉴 만들기 — relative·absolute·z-index

📎 교안 p.108~111📅 HTML·CSS 과정 · 6월 말💻 2-css/css08.html
이 장의 목표
  • static·relative·absolute·fixed가 각각 무엇을 기준으로 움직이는지 말할 수 있다.
  • "부모 relative + 자식 absolute" 패턴으로 부모 안의 원하는 좌표에 자식을 놓을 수 있다.
  • z-index로 겹침 순서를 정하고, 위치 지정 요소에만 먹는다는 것을 안다.
  • :hover + display 전환으로 드롭다운 메뉴를 만들 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

7장까지는 요소가 정해진 흐름대로만 놓였어요. 하지만 말풍선, 겹쳐진 배지, 화면에 고정된 버튼, 마우스를 올리면 옆에 뜨는 하위 메뉴는 흐름만으로는 못 만듭니다. 이 장의 메뉴는 3장의 중첩 목록 + 5장의 자식 선택자·:hover·점수 계산 + 7장의 박스 크기가 한데 모이는 곳이라, 앞에서 배운 걸 점검하기에도 좋아요.

개념

① position 네 가지 — 기준점이 다르다

static(기본값, top/left를 줘도 무시), relative(자기 원래 자리 기준으로 살짝 이동, 원래 자리는 빈 채로 유지돼서 다른 요소가 당겨 오지 않음), absolute(흐름에서 빠져나와, 가장 가까운 "위치 지정된 조상"(static이 아닌 조상) 기준으로 이동, 그런 조상이 없으면 문서 첫 화면 기준), fixed(브라우저 화면 기준, 스크롤해도 그 자리에 고정). 좌표는 top·right·bottom·left로 줍니다.

📎 교안 p.108~109 「레이아웃 — position」

② 부모 relative + 자식 absolute

가장 많이 쓰는 조합이에요. 부모에 position: relative만 주고 이동은 시키지 않으면, 부모는 제자리에 있으면서 "자식들아, 내 왼쪽 위가 (0, 0)이야"라는 기준점 표시 역할만 합니다. 그러면 자식의 absolute; left: 200px; top: 10px은 "부모 상자 안에서" 좌표가 돼요. 부모의 relative를 빼먹으면 자식이 엉뚱하게 문서 전체 기준으로 날아갑니다.

📎 교안 p.109 그림(position: relative 안의 absolute), p.110~111 「예제」

③ z-index — 겹침 순서

요소가 겹치면 z-index 숫자가 큰 쪽이 위에 옵니다. 단위 없는 정수만 쓰고, 위치 지정된 요소(static이 아닌 요소)에만 먹어요. 그리고 z-index를 가진 위치 지정 요소는 자기만의 층 묶음을 만들어서, 그 안의 자식들은 바깥 요소와 직접 비교되지 않고 묶음 안에서만 순서를 다툽니다(6장 css06에서 #box2의 z-index:10이 안쪽 순서를 못 바꾼 이유).

📎 교안 p.108 「z-index」

④ :hover로 보였다 숨겼다

display: none은 요소를 지우고 자리까지 없앱니다. 평소엔 하위 메뉴를 none으로 숨겨 두고, 부모:hover > 하위메뉴 { display: block; }으로 마우스를 올렸을 때만 보이게 하면 JS 없이 드롭다운이 됩니다. 3장 html03에서는 클릭 + JS로 펼쳤던 것을 CSS만으로 하는 거예요.

📎 교안 p.81 「가상 클래스 — hover」, p.104 「display」

⚠️ 교안 표현 바로잡기

교안 p.108의 z-index: 5px;처럼 단위를 붙이면 무효가 되어 무시됩니다. z-index: 5;처럼 정수만 써요. 또 absolute를 "절대적 위치 기준"이라고만 하면 막연한데, 정확히는 "가장 가까운 위치 지정 조상(position이 static이 아닌 조상)의 안쪽 기준, 없으면 문서 첫 화면 기준"입니다.

예제 — 수업 코드로 확인하기

css08 ① — 원 세 개를 좌표로 겹치기

이 코드는 부모 relative + 자식 absolute로 좌표를 찍고, z-index로 겹침 순서를 정하는 것을 보여 주려고 만든 실습입니다. 마우스를 올린 원이 맨 위로 올라와요.

CSScss08.html — position 부분
#box,
p {
    border: 1px solid black;
}

#box {
    height: 600px;
    width: 600px;
    position: relative;
    margin-left: 100px;
}

#box>p {
    width: 200px;
    height: 200px;
    border-radius: 100px;
    text-align: center;
    line-height: 200px;
    font-size: 40px;
}

.myred {
    background-color: red;
    position: absolute;
    left: 200px;
    top: 10px;
    z-index: 100;
}
/* …(생략: .myblue — left 120px, top 100px, z-index 50 / .mygreen — left 280px, top 100px, z-index 10) */

.myred:hover,
.myblue:hover,
.mygreen:hover {
    color: white;
    font-weight: bold;
    font-size: 50px !important;
    z-index: 150;
}

👀 관찰 포인트 — 평소엔 빨강(100) > 파랑(50) > 초록(10) 순으로 겹치고, 올린 원은 z-index: 150으로 맨 위로 옵니다. border-radius: 100px(폭 200의 절반)이 사각형을 원으로, line-height: 200px이 글자를 세로 가운데로 보내요. 이제 5장의 점수 계산 실전: 글자 크기를 두고 #box>p (1,0,1)의 40px과 .myred:hover (0,2,0)의 50px이 겨루면 id가 있는 쪽이 이깁니다. 그래서 !important가 없으면 hover해도 글자가 커지지 않아요. 반대로 z-index는 #box>p에 없으니 .myred (0,1,0) vs .myred:hover (0,2,0)로 hover가 그냥 이깁니다.

css08 ② — CSS만으로 드롭다운 메뉴

이 코드는 3장의 중첩 목록을 CSS의 position과 :hover만으로 옆으로 펼쳐지는 메뉴로 바꾸는 실습입니다.

HTMLcss08.html — 메뉴 부분
<style>
    /* 메뉴만들기 */
    ul {
        margin: 0;
        padding: 0;
    }

    #menu_box li {
        list-style: none;
    }

    .sub_menu {
        display: none;
        width: 150px;
        position: absolute;
        left: 120px;
        top: 2px;
        background-color: brown;
    }

    .main_menu>li {
        position: relative;
        width: 100px;
        padding: 10px;
        margin-bottom: 5px;
        background-color: aqua;
    }

    .main_menu>li:hover>.sub_menu {
        display: block;
    }

    .sub_menu>li:hover {
        cursor: pointer;
        background-color: yellow;
    }
</style>
…
<div id="menu_box">
    <ul class="main_menu">
        <li>
            <b>메뉴01</b>
            <ul class="sub_menu">
                <li>서브메뉴01</li>
                <li>서브메뉴02</li>
                <li>서브메뉴03</li>
            </ul>
        </li>
        <!-- …(생략: 메뉴02, 메뉴03도 같은 구조) -->
    </ul>
</div>

👀 관찰 포인트 — 메인 메뉴 li의 겉폭은 100 + 10×2 = 120px(7장 계산)이라, 하위 메뉴의 left: 120px이 li 오른쪽 끝에 딱 붙습니다. 기준이 li가 되는 건 .main_menu>li에 position: relative가 있기 때문이에요. .main_menu>li:hover>.sub_menu는 점수 (0,3,1)로 .sub_menu (0,1,0)의 display: none을 이깁니다. 그리고 하위 메뉴가 li의 자식이라서, 마우스가 하위 메뉴로 옮겨 가도 li의 :hover가 유지돼 메뉴가 닫히지 않아요.

핵심 정리
  • relative = 내 원래 자리 기준(자리는 유지), absolute = 가장 가까운 위치 지정 조상 기준(흐름에서 빠짐), fixed = 화면 기준.
  • 부모 relative + 자식 absolute가 기본 패턴. 부모의 relative는 기준점 표시용.
  • z-index는 단위 없는 정수, 위치 지정 요소에만, 큰 수가 위.
  • display: none ↔ block을 :hover로 바꾸면 JS 없이 드롭다운.
  • hover 스타일이 안 먹으면 점수부터 계산하자. id가 낀 규칙은 class 몇 개로는 못 이긴다.
확인 문제
css08에서 #box의 position: relative를 지우면 원 세 개는 어디를 기준으로 놓일까요?
위치 지정된 조상이 하나도 없으니 문서 첫 화면의 왼쪽 위를 기준으로 놓입니다. #box의 margin-left: 100px이나 위쪽 h1과 상관없이 위치가 바뀌어요.
position: static인 요소에 z-index: 999를 주면?
무시됩니다. z-index는 위치 지정된(static이 아닌) 요소에만 먹어요.
css08의 font-size: 50px !important에서 !important를 지우면 hover했을 때 어떻게 될까요?
글자색이 흰색, 굵게, 맨 위로 올라오는 건 그대로지만 글자 크기는 40px 그대로입니다. #box>p (1,0,1)가 .myred:hover (0,2,0)보다 점수가 높기 때문이에요.
position: relative; left: 20px;로 옮긴 요소의 원래 자리는 어떻게 되나요?
빈 채로 그대로 남아 있습니다. 다른 요소가 그 자리로 당겨 오지 않아요. 반대로 absolute는 흐름에서 빠지니 그 자리를 다음 요소가 채웁니다.
다음 장으로

position은 요소를 하나씩 좌표로 찍는 방법이에요. 여러 요소를 한 줄로 세우고 남는 공간을 나눠 갖게 하는 데는 손이 많이 가죠. 9장의 Flexbox는 부모에게 한 줄만 선언하면 자식들을 줄 세우고 정렬해 주는, 지금 가장 많이 쓰는 레이아웃 방법입니다.

더 자세히 → Position 속성 & 메뉴 만들기

2부 · CSS

09Flexbox 레이아웃 — 줄 세우고 공간 나누기

📎 교안 없음 — 수업 코드가 교재📅 HTML·CSS 과정 · 6월 말💻 2-css/css09.html · test02.html
이 장의 목표
  • display: flex를 준 부모(컨테이너)와 그 직계 자식(아이템)을 구분할 수 있다.
  • flex-direction으로 주축(row·row-reverse·column)을 정할 수 있다.
  • justify-content(주축)와 align-items(교차축)로 정렬할 수 있다.
  • flex-grow로 남는 공간을 차지하게 해서 헤더-본문-푸터 페이지 틀을 만들 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

UI 교안의 레이아웃은 float(p.102~107)과 position(p.108~111)까지만 다루고 Flexbox는 교안에 없어요. 그래서 이 장은 수업 코드가 교재입니다. float는 옆으로 세운 뒤 "해제"를 챙겨야 하고, position은 하나씩 좌표를 찍어야 하죠. Flexbox는 부모에게 display: flex 한 줄이면 자식들이 줄을 서고, 남는 공간 나누기와 가운데 정렬까지 해 줍니다. React 과정에서도 그대로 쓰는 현대 레이아웃의 기본이에요.

개념

① 컨테이너와 아이템

display: flex는 부모에게 줍니다. 그러면 부모의 직계 자식들이 아이템이 되어 줄을 서요. 자식이 원래 블록(div)이어도 옆으로 섭니다. 손자는 아이템이 아니어서, 손자들도 줄 세우려면 그 자식에게 또 display: flex를 줍니다(flex 안에 flex — 중첩).

📎 교안 없음 — 수업 코드(css09)가 교재

② 주축과 교차축

flex-direction이 아이템이 줄 서는 방향, 즉 주축을 정합니다: row(기본, 왼→오), row-reverse(오→왼), column(위→아래), column-reverse. 주축과 직각인 방향이 교차축이에요. 방향을 column으로 바꾸면 주축이 세로가 되니, 아래 정렬 속성들이 움직이는 방향도 함께 바뀝니다.

📎 교안 없음 — 수업 코드(css09)가 교재

③ 정렬 — justify-content와 align-items

justify-content는 주축 방향 정렬입니다: flex-start, center, space-between(양 끝에 붙이고 사이를 균등하게), space-around 등. align-items는 교차축 방향 정렬입니다: 기본값 stretch(교차축으로 꽉 늘림), center, flex-start, flex-end. "가로·세로 정중앙"은 두 속성 모두 center면 끝나요.

📎 교안 없음 — 수업 코드(test02)가 교재

④ 공간 나누기 — flex-grow와 flex-shrink

flex-grow: 1을 준 아이템은 주축에서 남는 공간을 차지합니다(여러 개면 숫자 비율대로 나눔). 반대로 공간이 모자라면 아이템은 flex-shrink(기본 1)에 따라 줄어들어요. 그래서 flex 안에서는 width: 600px을 줘도 화면이 좁으면 600보다 작아질 수 있습니다.

📎 교안 없음 — 수업 코드(css09, test02)가 교재

예제 — 수업 코드로 확인하기

css09 — 방향 바꾸기, flex 안에 flex

이 코드는 flex-direction 값에 따라 아이템이 어떻게 줄 서는지, 그리고 flex 안에 flex를 넣어 헤더-본문-푸터 틀을 짜는 것을 보여 주려고 만든 실습입니다.

HTMLcss09.html — style · body(발췌)
<style>
    .box {
        width: 100px;
        height: 100px;
        background-color: aquamarine;
        margin: 10px;
    }

    #container1 {
        border: 1px solid red;
        /* flex를 적용한다는 의미의 정의 개념 */
        display: flex;
        flex-direction: row-reverse;
    }
    /* …(생략: #container2 — #container1과 똑같이 row-reverse) */

    #container3 {
        border: 1px solid red;
        display: flex;
        flex-direction: column;
    }

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

    #content>div {
        width: 600px;
        background-color: blue;
        margin: 10px;
    }

    #content {
        display: flex;
        flex-direction: row;
        min-height: 80vh;
    }
</style>
…
<div id="container1">
    <div class="box">1</div>
    <!-- …(생략: box 2~5) -->
</div>
<!-- …(생략: #container2) -->
<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>

👀 관찰 포인트 — row-reverse라서 상자가 오른쪽부터 5 4 3 2 1 순서로 섭니다. #container3은 column이라 header·content·footer가 위아래로 쌓이고, 그 안의 #content는 다시 row라 파란 칸 1·2·3이 옆으로 섭니다(flex 안에 flex). 파란 칸에 height를 주지 않았는데도 키가 min-height: 80vh를 꽉 채우는 건 align-items 기본값 stretch 때문이에요. 또 width: 600px 세 개 + 여백은 1860px이라 보통 화면보다 넓은데, flex-shrink 기본값 때문에 넘치지 않고 줄어듭니다. 참고로 #container1과 #container2는 규칙이 완전히 같아 두 줄이 똑같이 보여요. 비교해 보려면 하나를 row로 바꿔 보세요.

test02 — 종합 미션: Flexbox만으로 페이지 틀 완성

이 코드는 주축 정렬·교차축 정렬·남는 공간 차지를 한 페이지에서 모두 써 보는 종합 미션입니다. 파일 아래쪽 주석(48~68줄)에 요구사항 5개가 있고, 위쪽 CSS가 그것을 하나씩 푼 정답이 채워진 상태로 저장돼 있어요. HTML 뼈대(시맨틱 태그)는 4장에서 봤습니다.

CSStest02.html — style(미션 주석 제외)
/* 기본 설정 */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Malgun Gothic', sans-serif;
    background-color: #f5f6fa;
}

#wrapper {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
}

header {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

#content {
    display: flex;
    flex-direction: row;
    flex-grow: 1;
}

#content main {
    flex-grow: 1;
}

footer {
    display: flex;
    justify-content: center;
    align-items: center;
}

👀 관찰 포인트 — #wrapper는 column이라 주축이 세로예요. 그 안에서 #content의 flex-grow: 1은 헤더·푸터를 뺀 세로 남는 공간을 다 차지해서, 내용이 적어도 footer가 화면 맨 아래에 붙습니다. #content는 row라 주축이 가로이고, 그 안에서 main의 flex-grow: 1은 양쪽 aside(글자만큼 폭)를 뺀 가로 공간을 차지해요. 같은 flex-grow인데 부모의 주축 방향을 따라 늘어나는 방향이 다른 거죠. header는 space-between으로 로고는 왼쪽 끝·버튼은 오른쪽 끝, align-items: center로 세로 가운데입니다. footer는 두 속성 모두 center라 정중앙이에요. 맨 위의 box-sizing: border-box는 7장에서 본 그 설정입니다.

핵심 정리
  • display: flex는 부모에게. 줄 서는 건 직계 자식뿐(손자는 중첩 flex로).
  • flex-direction이 주축을 정한다: row(가로) / column(세로) / -reverse(거꾸로).
  • justify-content = 주축 정렬, align-items = 교차축 정렬(기본 stretch).
  • flex-grow: 1 = 주축의 남는 공간 차지. 공간이 모자라면 flex-shrink로 줄어든다.
  • 헤더-본문-푸터: 바깥 column + min-height: 100vh, 본문에 flex-grow: 1.
확인 문제
flex-direction: column인 컨테이너에서 아이템을 가로 가운데로 보내려면 어떤 속성을 쓰나요?
align-items: center입니다. column에서는 주축이 세로라서, 가로가 교차축이 되고 교차축 정렬은 align-items가 맡아요.
test02에서 #content의 flex-grow: 1을 지우면 화면이 어떻게 변할까요?
본문이 내용 높이만큼만 차지해서, footer가 화면 맨 아래가 아니라 본문 바로 밑(화면 중간쯤)으로 올라옵니다.
css09의 파란 칸들은 height가 없는데 왜 키가 #content를 꽉 채울까요?
align-items의 기본값이 stretch라서, 아이템이 교차축(여기선 세로)으로 컨테이너 높이만큼 늘어나기 때문입니다.
display: flex를 부모가 아니라 자식 div에 주면 어떻게 될까요?
그 자식 div의 안쪽 요소들이 줄을 섭니다. 자식 div와 그 형제들은 여전히 블록이라 위아래로 쌓여요. flex는 "내 자식들을 줄 세운다"는 선언입니다.
다음 장으로

여기까지가 2부입니다. HTML로 뼈대와 의미를, CSS로 모양과 배치를 만들었어요. 다음 장부터는 JavaScript로 동작을 더합니다. 3장의 클릭해서 펼치는 메뉴(html03), 4장의 표에 행을 추가하는 폼(js17)처럼, 1장에서 본 DOM 트리를 JS가 직접 고치는 일이 시작돼요.

더 자세히 → Flexbox 레이아웃 실습실습 — 종합 스타일링

3부 · JavaScript

10자바스크립트를 붙이는 법 — 작성 방식 · CORE/BOM/DOM · 대화창

📎 JS 교안 p.3~4 · UI 교안 p.9💻 Front_end/3.javascript/js01_작성방식.html · js02_대화창함수.html · js/test.js
이 장의 목표
  • 자바스크립트를 HTML에 붙이는 세 가지 방법(외부·내부·인라인)을 구별하고, 왜 외부 방식을 권장하는지 말할 수 있다.
  • JS가 다루는 세 영역 CORE·BOM·DOM이 각각 무엇인지 예를 들어 설명할 수 있다.
  • alert·confirm·prompt가 무엇을 돌려주는지(반환값) 알고, 그 값으로 흐름을 나눌 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

1부에서 HTML로 뼈대를 세우고, 2부에서 CSS로 겉모습을 입혔어요. 그런데 지금까지 만든 페이지는 "보기만" 할 수 있었죠. 버튼을 눌렀을 때 무언가 일어나게 하려면 동작을 맡는 언어가 필요합니다. 그게 자바스크립트예요. 이 장은 JS 코드를 문서 어디에, 어떻게 붙이는지, 그리고 JS가 손댈 수 있는 대상이 무엇인지 지도를 그리는 장입니다. 3부의 나머지 장(11~17장)은 이 지도의 칸을 하나씩 채워 나가요.

개념

① 작성 방식 3가지 — 외부 · 내부 · 인라인

CSS도 <link>(외부) / <style>(내부) / style="" 속성(인라인) 세 가지였죠. JS도 똑같습니다. 외부 방식은 <script src="js/test.js"></script>처럼 별도 .js 파일을 불러와요. 여러 페이지가 같은 파일을 재사용할 수 있어 가장 권장됩니다. 내부 방식은 <script> 태그 안에 직접 쓰고, 인라인 방식은 onclick="…"처럼 태그 속성 안에 코드를 넣어요. 인라인은 HTML과 JS가 뒤섞여 고치기 어려워서 원칙적으로 피합니다.

하나 더 — 브라우저는 HTML을 위에서 아래로 읽다가 <script>를 만나면 그 자리에서 실행해요. 그래서 <head> 안의 코드가 바로 <body>의 요소를 찾으면 아직 없어서 null이 나옵니다. 수업 코드가 코드를 함수로 감싸 클릭할 때 실행하거나 onload = function(){…} 안에 넣는 이유가 이것이에요(14·15장).

📎 JS 교안 p.3 「외부 방식 / 내부 방식 / 인라인 방식」

② JS의 3대 구성요소 — CORE · BOM · DOM

CORE(ECMAScript)는 언어 그 자체예요. 변수, if/for, 함수, 객체, 그리고 처음부터 들어 있는 내장 객체(Math·String·Array·Date·JSON)가 여기 속합니다. BOM(Browser Object Model)은 브라우저 창을 다뤄요 — window, 주소창 location, 방문 기록 history, navigator, screen. DOM(Document Object Model)은 창 안에 보이는 문서 내용, 즉 HTML 태그(Element)·글자(Text)·이벤트를 다룹니다.

이 책의 순서도 이 지도를 따라가요: CORE → 11~13장, DOM → 14장, BOM → 15장, 그리고 브라우저 저장소(16장)와 서버 통신(17장).

📎 JS 교안 p.3 「JS 3대 구성요소」 · UI 교안 p.9 「JavaScript — 스크립트 구성 CORE / BOM / DOM」

③ 대화창 함수 — 반환값이 서로 다르다

alert(메시지)는 확인 버튼 하나뿐이고 돌려주는 값이 없어요(undefined). confirm(메시지)는 확인/취소 중 무엇을 눌렀는지 true/false로 돌려줘서 "정말 삭제할까요?" 같은 분기에 씁니다. prompt(메시지, 기본값)는 입력창을 띄워 입력한 글자(문자열)를 돌려주고, 취소하면 null, 아무것도 안 쓰고 확인하면 빈 문자열 ""을 돌려줘요.

셋 다 창이 떠 있는 동안 페이지 전체가 멈춥니다. 그래서 값 확인(디버깅)은 보통 console.log()로 하고, F12 → Console 탭에서 봐요.

📎 JS 교안 p.4 「대화창 함수 — alert / confirm / prompt」

예제 — 수업 코드로 확인하기

한 파일 안에 세 가지 작성 방식

이 코드는 세 가지 작성 방식을 한 화면에서 비교하려고 만든 실습입니다. 화면의 CORE·BOM·DOM 글자를 클릭하면 각각 다른 방식으로 작성된 코드가 실행돼요. (글자 내용은 ②의 3대 구성요소를 정리한 것입니다.)

HTMLjs01_작성방식.html — 세 방식 모두 사용
<head>
    <script src='js/test.js'></script>   <!-- 외부 방식 -->
    <script>                               <!-- 내부 방식 -->
        function embedded() {
            alert("내부 작성 방식");
        }
    </script>
</head>
<body>
    <h1>자바스크립트 구성</h1>
    <dl>
        <dt onclick="var txt='inline방식입니다.';alert(txt);">CORE</dt>   <!-- 인라인 방식 -->
        <dd>문법(변수,제어문,클로져등..)</dd>
        <dd>내장객체(Math,String,Array,Object,Date,RegExp,JSON)</dd>
        <dt onclick="embedded()">BOM</dt>
        <dd>객체(Window,Document,History,Navigator,Screen,Location)</dd>
        <dt onclick="linked()">DOM</dt>
        <dd>객체(Node,Element,Event)</dd>
    </dl>
    <script>
        console.log("콘솔에 메시지 출력", "값1", "값2", "값3");
    </script>
</body>
JSjs/test.js — 외부 파일
function linked() {
    alert("외부작성방식");
}

👀 관찰 포인트 — CORE를 누르면 인라인 코드, BOM을 누르면 내부 embedded(), DOM을 누르면 다른 파일(test.js)에 있는 linked()가 실행됩니다. 외부 파일의 함수도 같은 페이지의 공용 공간(전역)에 합쳐지기 때문에 그냥 이름으로 부를 수 있어요. 마지막 console.log는 화면이 아니라 F12 콘솔에 콘솔에 메시지 출력 값1 값2 값3을 찍습니다.

confirm과 prompt의 반환값으로 분기하기

이 코드는 대화창이 돌려주는 값으로 프로그램 흐름을 나누는 법을 보여 주려고 만든 실습입니다. confirm은 if로, prompt는 switch로 나눠요.

JSjs02_대화창함수.html — confirmTest / promptTest
//확인,취소버튼 제공: 반환값 boolean
function confirmTest() {
    if (confirm("확인버튼을 누르면 true 반환")) {
        console.log("삭제를 진행합니다");
    } else {
        console.log("삭제를 취소합니다");
    }
}
//확인,취소버튼 제공 + 내용을 입력받을 수 있는 입력창 제공
function promptTest() {
    var txt = prompt("과목을 선택하세요(1:java,2:DB,3:JS)", "해당번호입력!");
    console.log("입력한 값:" + txt, typeof txt);

    switch (txt) {
        case "1": console.log("자바를 선택했습니다."); break;
        case "2": console.log("데이터베이스를 선택했습니다."); break;
        case "3": console.log("자바스크립트를 선택했습니다."); break;
        case null: console.log("취소했습니다"); break;
        default: console.log("다시 입력"); break;
    }

    document.getElementById("inputVal").textContent = txt;
}

👀 관찰 포인트 — 1을 입력하면 콘솔에 입력한 값:1 string이 나옵니다. 숫자를 쳤는데도 타입은 string이에요. 취소를 누르면 typeof txt가 "object"로 찍히는데, 이건 typeof null이 역사적인 이유로 "object"이기 때문입니다(11장). 마지막 줄 getElementById(…).textContent는 14장에서 배울 DOM 조작을 미리 맛보는 부분이에요.

⚠️ 수업 코드에서 조심할 곳

switch는 각 case를 ===(타입까지 같은지)로 비교합니다. prompt는 항상 문자열을 주니까 case 1:(숫자)로 쓰면 절대 맞지 않아요. js02가 case "1":처럼 따옴표를 붙인 이유입니다. 그리고 교안 p.3의 경고처럼 <script src="app.js">alert("test");</script>라고 쓰면 태그 안의 alert는 무시됩니다. 외부 파일과 직접 쓴 코드가 둘 다 필요하면 <script> 태그를 두 개로 나누세요.

핵심 정리
  • 작성 방식: 외부(src, 권장) · 내부(<script>) · 인라인(onclick 속성, 비권장).
  • 브라우저는 위에서 아래로 읽으며 실행한다 → <head>의 코드가 요소를 만질 때는 함수·onload 안에서.
  • CORE = 언어와 내장 객체, BOM = 브라우저 창(window·location·history), DOM = 문서 내용(document·Element·Event).
  • alert → 반환값 없음, confirm → true/false, prompt → 문자열 또는 null.
확인 문제
<script src="app.js">alert("hi");</script> 에서 alert는 실행될까요?
아니요. src가 있으면 태그 안에 쓴 코드는 무시됩니다. 직접 쓴 코드는 별도의 <script> 태그에 넣어야 해요.
prompt 창에서 아무것도 안 쓰고 확인을 누를 때와 취소를 누를 때, 돌려받는 값은 각각?
확인 → 빈 문자열 "", 취소 → null. js02에서는 ""는 default("다시 입력")로, null은 case null("취소했습니다")로 갑니다.
"다른 페이지로 이동", "버튼 글자 바꾸기", "배열 정렬"은 각각 CORE·BOM·DOM 중 어디에 속할까요?
페이지 이동은 주소창을 다루니 BOM(location), 버튼 글자는 문서 내용이니 DOM, 배열 정렬은 언어의 내장 객체이니 CORE(Array.sort)입니다.
다음 장으로

prompt에 1을 쳤는데 문자열 "1"이 돌아왔죠. 화면에서 받는 값은 전부 문자열이라 계산하려면 숫자로 바꿔야 합니다. 11장에서는 값을 담는 그릇(변수)과 타입, 그리고 형변환을 배웁니다.

더 자세히 → JS 작성방식 & 3대 구성요소대화창 함수

3부 · JavaScript

11변수와 타입, 형변환 — var·let·const부터 ===까지

📎 JS 교안 p.5, 8, 13💻 Front_end/3.javascript/js03_변수와타입.html · js06.html · js08.html
이 장의 목표
  • var·let·const의 차이(스코프·재선언·재할당)를 말할 수 있다.
  • 호이스팅과 TDZ 때문에 "선언 전에 쓰면" 각각 어떤 결과가 나오는지 예측할 수 있다.
  • input.value는 항상 문자열임을 알고 Number·parseInt·parseFloat로 알맞게 바꿀 수 있다.
  • ==와 ===의 차이를 알고 ===를 쓴다.
왜 지금 이걸 배우나 — 앞 장과의 연결

10장에서 prompt가 숫자 1이 아니라 문자열 "1"을 돌려줬어요. 입력창도 마찬가지라서, 두 입력값을 그냥 + 하면 "10" + "20" = "1020"이 됩니다. 이런 실수를 피하려면 값을 담는 그릇(변수)이 어떻게 동작하는지, 그 안의 값이 어떤 타입인지 정확히 알아야 해요. 이 장은 CORE의 가장 밑바닥입니다.

개념

① 선언 키워드 3가지와 스코프

스코프는 변수가 "보이는 범위"예요. 함수 밖에서 만든 변수는 어디서나 보이는 전역 변수, 함수 안에서 만든 변수는 그 함수 안에서만 보이는 지역 변수입니다.

var(ES5)는 함수 단위 스코프이고, 같은 이름으로 다시 선언해도 에러가 안 나요(실수로 덮어써도 모름). ES6(2015)에서 추가된 let·const는 블록 { } 단위 스코프이고 재선언이 금지됩니다. let은 값을 바꿀 수 있고(재할당 O), const는 못 바꿔요(재할당 X). 단, const arr = []에 arr.push(1)처럼 내용을 바꾸는 건 됩니다 — 금지된 건 arr = 다른것(그릇 바꾸기)뿐이에요. 습관: 기본은 const, 바뀌어야 하면 let, var는 쓰지 않기.

📎 JS 교안 p.5 「변수 선언 키워드 3가지 비교」

② 호이스팅과 TDZ — 선언 전에 쓰면?

JS는 코드를 실행하기 전에 그 범위의 선언을 먼저 훑어서 등록해 둡니다. 마치 선언이 맨 위로 끌어올려진 것처럼 보여서 호이스팅(hoisting)이라고 해요. var는 등록하면서 undefined를 미리 넣어 두기 때문에, 선언 줄보다 먼저 읽으면 에러 없이 undefined가 나옵니다. let·const도 등록은 되지만 선언 줄에 도달할 때까지 접근 금지 구역(TDZ, Temporal Dead Zone)에 있어서, 먼저 읽으면 ReferenceError가 납니다. 조용히 틀리는 것보다 바로 에러가 나는 쪽이 고치기 쉬워서 let·const가 더 안전해요.

📎 JS 교안 p.5 「var의 2가지 문제점 — ① 호이스팅 ② 재선언」

③ 동적 타입과 형변환

JS는 변수에 타입을 적지 않고, 들어 있는 값에 따라 타입이 정해져요(동적 타입). typeof로 확인하면 "number", "string", "boolean", "undefined", "object", "function" 등이 나옵니다. 함정 두 개: 배열도 typeof [] → "object", typeof null → "object". 타입이 마음대로 바뀌어 실수를 찾기 어렵다는 단점 때문에 타입을 적는 TypeScript가 등장했어요.

입력창의 value는 항상 문자열입니다. +는 한쪽이 문자열이면 숫자 덧셈 대신 이어 붙이기를 해요("10" + 20 → "1020"). 그래서 바꿔야 합니다. Number()는 전체가 숫자여야 바뀌고(Number("12px") → NaN), parseInt()는 앞에서부터 숫자인 데까지 정수로 읽어요(parseInt("12px") → 12, 하지만 parseInt("abc12") → NaN, parseInt("3.9") → 3 소수점 버림). parseFloat("3.14") → 3.14는 소수점을 살립니다. NaN은 "숫자가 아님"을 뜻하는 특별한 숫자 값이에요.

📎 JS 교안 p.5 「동적 타입 시스템과 typeof」 · p.8 「형변환 함수」

④ == 와 === — 비교는 항상 ===

==(동등)는 양쪽 타입이 다르면 알아서 맞춘 뒤 비교해요: 10 == "10" → true. ===(일치)는 타입까지 같아야 true: 10 === "10" → false. ==의 자동 변환 규칙은 복잡해서 예상 못 한 true가 나오기 쉬우니, 항상 ===, 다를 때는 !==를 씁니다. 타입이 다른 값을 비교하고 싶으면 먼저 직접 형변환한 뒤 ===로 비교하세요.

📎 JS 교안 p.13 「== vs === 비교 연산자」

⚠️ 교안 표현 바로잡기

교안 p.5는 「var 선언을 맨 위로 끌어올림 → let/const는 선언 전 사용 시 ReferenceError」라고 해서 let·const는 호이스팅이 안 되는 것처럼 읽힙니다. 정확히는 let·const도 호이스팅되지만 TDZ에 묶여 있어서 에러가 나는 거예요. 그리고 js06의 제목 「검증함수 eval()」은 정확하지 않습니다. eval은 무언가를 검증하는 함수가 아니라 문자열을 JS 코드로 실행하는 함수예요. 사용자 입력을 그대로 넣으면 아무 코드나 실행될 수 있어서(교안 p.8의 「보안 위험」) 실무에서는 쓰지 않습니다.

예제 — 수업 코드로 확인하기

전역·지역 변수, 호이스팅, 재선언

이 코드는 var의 두 가지 문제(호이스팅·재선언)를 눈으로 확인하려고 만든 실습입니다. 화면의 「1.전역변수」「2.지역변수」 글자를 클릭해 보세요.

JSjs03_변수와타입.html — 스코프와 var
var variable = 10; //전역변수

function test01() {
    variable = variable + 5;
    console.log(variable);
}

function test02() {
    //지역변수 선언
    //var 선언: 호이스팅현상 -> undefined
    console.log(variable);
    var variable = 10;
}
// …(생략: typeof 실습)

//재선언의 문제점
var a = 5;
var a = 10;
console.log(a);

👀 관찰 포인트 — test01은 누를 때마다 15, 20, 25…로 전역 변수가 커집니다. test02는 전역에 10이 있는데도 undefined가 찍혀요. 함수 안에 var variable이 있어서 지역 변수가 맨 위로 끌어올려졌고, 아직 값이 안 들어간 그 지역 변수를 읽었기 때문입니다. var a를 두 번 써도 에러 없이 10이 찍혀요. 같은 코드를 let으로 바꾸면 각각 ReferenceError, SyntaxError가 나서 실수를 바로 알려 줍니다.

입력값을 숫자로 바꾸기 — Number · parseInt · eval

이 코드는 "입력값은 문자열"이라는 사실과 형변환 함수의 차이를 보여 주려고 만든 실습입니다. 버튼 옆 입력창에 여러 값을 넣어 보세요.

JSjs06.html — 형변환 함수
function numTest() {
    const inputObj = document.getElementById("num1").value;
    // "1234"+20 -> concatenation이 발생
    //문자열을 +연산으로 만나면 문자열로 자동 변환해서 연산된다
    console.log(typeof inputObj.value, Number(inputObj.value) + 20);
}

//파라미터로 객체를 전달할 수 도 있다. 매개변수에는 인자의 객체가 전달됨.
function intTest(inputObj) {
    console.log(inputObj.nodeName)
    let paramVal = parseInt(inputObj.value);
    console.log(paramVal + 100);
}
// …(생략: floatTest)
function evalTest(inputObj) {
    let eValue = inputObj.value; //ex) "5+10"
    console.log(eval(eValue));
    // …(생략)
}
HTMLjs06.html — 버튼
<input type="text" name="int" id="int1" />
<button onclick="intTest(int1)">확인</button>

👀 관찰 포인트 — intTest에 12px를 넣으면 112, abc12를 넣으면 NaN이 나옵니다. onclick="intTest(int1)"은 id가 전역 변수처럼 잡히는 브라우저 기능 덕에 요소 객체가 넘어가는 건데(교안 p.8 실습 포인트), 이름이 겹치면 깨지기 쉬우니 실제로는 document.getElementById("int1")로 찾는 편이 안전해요. nodeName은 태그 이름 INPUT을 찍습니다.

⚠️ 수업 코드에서 조심할 곳

js06의 numTest는 inputObj에 이미 .value(문자열 "1234")를 담았는데, 다음 줄에서 또 inputObj.value를 읽어요. 문자열에는 value 속성이 없으니 undefined가 되고, 콘솔에 undefined NaN이 찍힙니다. console.log(typeof inputObj, Number(inputObj) + 20)으로 고치면 의도대로 string 1254가 나와요.

== 와 === 비교

이 코드는 자동 형변환을 하는 ==와 안 하는 ===의 차이를 보여 주려고 만든 실습입니다.

JSjs08.html — strTest02 (앞부분)
let numVal = 10;//숫자형
if (numVal == "10") {
    console.log("==연산자사용:값이 같습니다.")
}

if (numVal === "10") {
    console.log("===연산자사용:값이 같습니다.")
} else {
    console.log("===연산자사용:값이 다릅니다.")
}

//객체와 비교(String)
let strLit = "한경";
let strObj = new String("한경"); //new 객체 정의

if (strLit == strObj) {
    console.log("==같다");
}
// === : 타입변환을 하지 않고 비교한다.
if (strLit === strObj) {
    console.log("===같다");
} else {
    console.log("===같지않다");
}

👀 관찰 포인트 — 출력은 ==…같습니다 → ===…다릅니다 → ==같다 → ===같지않다 순서예요. new String("한경")은 문자열이 아니라 객체(typeof가 "object")라서 ===로는 다릅니다. 문자열은 new String 없이 따옴표로만 만드는 게 원칙이에요.

핵심 정리
  • var = 함수 스코프·재선언 O / let = 블록 스코프·재할당 O / const = 블록 스코프·재할당 X(내용 수정은 O).
  • 선언 전에 읽으면 var는 undefined, let·const는 TDZ 때문에 ReferenceError.
  • 입력값은 항상 문자열 → Number(전체가 숫자여야), parseInt(앞에서부터 정수), parseFloat(실수)로 바꾼다.
  • 비교는 항상 ===. typeof null과 typeof []는 둘 다 "object".
확인 문제
const arr = [1, 2]; 다음에 arr.push(3) 은 에러일까요? arr = [] 는요?
push는 그릇 안의 내용을 바꾸는 것이라 괜찮습니다. arr = []는 그릇 자체를 바꾸는 재할당이라 TypeError: Assignment to constant variable이 납니다.
console.log(x); let x = 1; 의 결과는? let을 var로 바꾸면?
let이면 TDZ라서 ReferenceError, var면 호이스팅으로 미리 등록된 undefined가 찍힙니다.
입력창 두 개에 5와 3을 넣고 a.value + b.value 를 하면? 8을 얻으려면?
"53"(문자열 이어 붙이기)이 됩니다. Number(a.value) + Number(b.value)처럼 먼저 숫자로 바꿔야 8이에요.
parseInt("12px") 와 Number("12px") 의 결과는 각각?
12와 NaN. parseInt는 앞에서부터 숫자인 데까지 읽고, Number는 전체가 숫자여야 합니다. 단 parseInt("abc12")는 첫 글자부터 숫자가 아니라서 NaN이에요.
다음 장으로

값 하나를 담는 법을 알았으니, 이제 여러 줄의 동작을 묶는 함수와 여러 값을 묶는 객체로 넘어갑니다. 12장에서는 함수 세 가지 모양, 객체 만드는 세 가지 방법, 클로저, 그리고 ES6 문법을 배워요.

더 자세히 → 변수와 타입형변환 함수String 객체(== vs ===)스코프

3부 · JavaScript

12함수와 객체 — 함수 3종, 객체 3방식, 클로저, ES6

📎 JS 교안 p.9~12💻 Front_end/3.javascript/js07.html · js09.html
이 장의 목표
  • 선언적 함수·익명 함수·화살표 함수를 쓰고, 선언 전에 부를 수 있는지(호이스팅) 차이를 말할 수 있다.
  • 객체를 리터럴·생성자 함수·프로토타입으로 만들고, 언제 무엇을 쓰는지 안다.
  • 클로저가 "함수가 태어난 곳의 변수를 기억하는 것"임을 카운터 예제로 설명할 수 있다.
  • 구조분해 할당·템플릿 리터럴·스프레드/나머지 매개변수를 읽고 쓸 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

11장에서 값 하나를 변수에 담았다면, 이번엔 동작을 묶는 함수와 값 여러 개를 묶는 객체예요. JS에서는 함수도 값이라서 변수에 담고 다른 함수에 넘길 수 있습니다. 14장의 onclick = function(e){…}, 17장의 .then(response => …)가 전부 이 성질을 이용해요. 나중에 배울 React 컴포넌트도 결국 함수 + 객체(props)라서, 이 장이 3부에서 가장 중요합니다.

개념

① 함수의 세 가지 모양

선언적 함수 function add(a, b) { return a + b; }는 통째로 호이스팅돼서 선언보다 위에서 불러도 동작해요. 익명 함수는 이름 없이 변수에 담는 방식 const add2 = function (a, b) { … };으로, 변수(const)라서 선언 줄 이후에만 부를 수 있습니다. 화살표 함수(ES6) const add3 = (a, b) => a + b;는 익명 함수를 짧게 쓴 것으로, 몸통이 한 줄이면 { }와 return을 생략할 수 있어요.

JS는 인자 개수가 매개변수와 달라도 에러를 내지 않습니다(모자라면 undefined). 함수를 값처럼 다른 함수에 넘기는 것을 콜백이라 해요: setInterval(() => {…}, 1000)은 "1초마다 이 함수를 불러 줘"라는 뜻입니다. 화살표 함수는 자기만의 this가 없고 바깥의 this를 그대로 쓰며, new로 객체를 만들 수 없어요.

📎 JS 교안 p.9 「함수의 종류 — 선언적 · 익명 · 화살표」

② 객체 만들기 3가지 방식

객체 리터럴 { subject: "javascript", credit: 1 }은 하나만 빠르게 만들 때 써요. obj.subject 또는 obj["subject"]로 읽고, 없는 키에 대입하면 추가, delete obj["credit"]으로 삭제됩니다. 생성자 함수는 같은 모양을 여러 개 만들 때 씁니다. 이름을 대문자로 시작하고(Info) new Info("JS")로 부르면, 함수 안의 this가 새로 만들어지는 객체를 가리켜요. this.subject는 밖에서 보이는 공개 속성, 함수 안의 let test는 밖에서 안 보이는 숨은 변수예요(getter로만 꺼냄).

프로토타입은 생성자로 만든 객체들이 함께 쓰는 공용 창고예요. Info.prototype.addFunc = function(){…}로 넣으면 모든 Info 객체가 메서드 하나를 공유하고, 이미 만든 객체에도 바로 적용됩니다. 자바의 클래스와 비슷한 역할이고, ES6의 class 문법도 이 프로토타입 위에 얹힌 문법이에요.

📎 JS 교안 p.10 「객체 만들기 3가지 방식」

③ 클로저 — 함수가 태어난 곳의 변수를 기억한다

클릭 수를 세려고 전역에 let count = 0을 두면 누구나 고칠 수 있어요. 대신 바깥 함수 안에 count를 두고 안쪽 함수를 돌려주면, 바깥 함수가 끝난 뒤에도 안쪽 함수가 count를 계속 붙잡고 있습니다. 이렇게 "자기가 만들어진 곳의 변수를 기억하는 함수"를 클로저라고 해요. 효과는 두 가지: 상태 유지(부를 때마다 누적)와 정보 은닉(밖에서 count에 직접 손 못 댐).

주의: 돌려받은 함수를 변수에 담아 두고 불러야 누적돼요. closureTest2()()처럼 매번 바깥 함수를 새로 부르면 count도 매번 새로 0부터 시작합니다.

📎 JS 교안 p.11 「클로저 — 상태 유지 패턴」

④ ES6 문법 — 템플릿 리터럴 · 구조분해 · 스프레드

템플릿 리터럴은 백틱(`)으로 감싸고 ${변수}로 값을 끼워 넣어요: `${subject}와${test}를 출력합니다.` — +로 이어 붙이는 것보다 읽기 쉽고 줄바꿈도 그대로 됩니다. 구조분해 할당은 객체나 배열에서 값을 한 번에 꺼내 변수로 만들어요: let { subject, test } = jsonObj;, let [a, b] = [1, 2];. 스프레드 ...는 배열을 펼쳐요: [...arr](새 배열에 복사), [...a, ...b](합치기). 함수 매개변수 자리의 ...val은 반대로 남은 인자를 배열로 모아요(나머지 매개변수).

📎 JS 교안 p.12 「ES6 핵심 신기능」

⚠️ 교안 표현 바로잡기

교안 p.12는 「배열 깊은복사: [...arr]」라고 하지만 [...arr]는 얕은 복사예요. 새 배열을 만들긴 하지만, 원소가 배열·객체면 그 안쪽은 원본과 공유합니다(13장에서 실험). 또 「가변 파라미터 function fn(...args)」를 spread로 소개하는데, 매개변수 자리의 ...은 펼치는 게 아니라 모으는 나머지 매개변수(rest parameter)입니다. 같은 기호지만 위치에 따라 역할이 반대예요.

예제 — 수업 코드로 확인하기

선언적 함수 · 익명 함수 · 화살표 콜백

이 코드는 함수 세 모양과 "함수를 값으로 넘기는" 콜백을 보여 주려고 만든 실습입니다.

JSjs07.html — func01 / func02
//선언적함수사용
function func01() {
    console.log("선언적 함수");
    let val = func01_2(5, 10);
    console.log("결과값:", val);
}
function func01_2(a, b) { //파라미터 선언
    console.log(a, ":", b)
    return a + b; //a+b의 결과값을 반환한다
}

//익명함수
let i = 0; //전역변수
const func02 = function () {
    console.log("익명함수");
    //화살표함수
    setInterval(() => {
        console.log(i++);
    }, 1000);
    // setInterval(test, 1000); //함수의 이름만 작성
}

👀 관찰 포인트 — 첫 버튼은 선언적 함수 → 5 : 10 → 결과값: 15를 찍어요. 두 번째 버튼을 누르면 1초마다 0, 1, 2…가 찍힙니다. setInterval(test, 1000)처럼 괄호 없이 이름만 넘겨야 해요. test()라고 쓰면 그 자리에서 실행된 결과값이 넘어갑니다. 버튼을 여러 번 누르면 타이머가 하나씩 더 생겨 숫자가 점점 빨리 올라가요(멈추려면 clearInterval).

객체 리터럴 + 구조분해 + 템플릿 리터럴

이 코드는 객체를 만들고 → 고치고 → ES6 문법으로 꺼내 쓰는 흐름을 한 번에 보여 주려고 만든 실습입니다.

JSjs07.html — 1.객체 리터럴 방식
pArray[0].onClick = () => {   // ⚠️ onClick(대문자 C) — 아래 상자 참고
    const jsonObj = {
        subject: "javascript",
        credit: 1,
        printout: () => jsonObj.subject + "," + jsonObj.credit + "학점"
    }
    console.log("메서드 실행", jsonObj.printout())

    jsonObj.subject = "JS" //update개념
    jsonObj["test"] = "value"; //insert개념
    delete jsonObj["credit"]; //delete개념
    console.log(jsonObj["subject"], jsonObj.credit, jsonObj.test)

    //-직접 구조 분해하고 할당하는 코드
    let subjectLit = jsonObj.subject;
    let testLit = jsonObj.test;
    //-구조분해할당을 사용하는 방법
    let { subject, test } = jsonObj;
    console.log(`${subject}와${test}를 출력합니다.`);
}

👀 관찰 포인트 — (오타를 고친 뒤) 출력은 메서드 실행 javascript,1학점 → JS undefined value → JS와value를 출력합니다.입니다. credit은 지웠으니 undefined예요. 구조분해 한 줄(let { subject, test } = jsonObj)이 그 위의 두 줄과 똑같은 일을 한다는 것을 비교해 보세요. 키 이름과 같은 이름의 변수가 만들어집니다.

생성자 함수 · 프로토타입 · 클로저

이 코드는 "같은 모양 여러 개(생성자)", "공용 메서드(프로토타입)", "숨긴 상태 유지(클로저)"를 차례로 보여 주려고 만든 실습입니다.

JSjs07.html — 2·3·4번 (onload 안)
function Info(subject) {
    this.subject = subject; //초기화
    this.credit = 2;
    this.printout = () => {
        return this.subject + "," + this.credit + "학점";
    }
    let test = "일반 변수 선언"; //this가 붙으면 전역인데, 이건 외부에서 접근X(은닉화) -> printout()에도 접근X
    this.getTest = () => test;//내부에서 접근 가능
}

//3.프로토타입 사용하기
pArray[2].onclick = function () {
    const infoA = new Info("database");
    Info.prototype.addFunc = function () {
        console.log(`기능추가:${this.subject}`);//this는 Info.prototype
    }
    const infoB = new Ifo("JAVA");   // ⚠️ 오타 — 아래 상자 참고
    infoA.addFunc();
    infoB.addFunc();
    console.log(infoA.addFunc === infoB.addFunc)
}

//클로저 함수 만들기
let closureTest2_1 = closureTest2();//함수 자체를 반환받아서 변수에 담아서 사용
function closureTest2() {
    let count = 0;
    return function () { //익명함수를 리턴함.이 익명함수가 클로저임
        count++;
        console.log(count);
    }
}
//완성형 클로저 함수
let closureTest3 = function () {
    let count = 0;
    return function () {
        count++;
        console.log(count);
    }
}(); //함수를 정의하자마자 그 자리에서 즉시 실행시켜서 결과값을 변수에 담는 방식

👀 관찰 포인트 — 오타를 고치면 기능추가:database → 기능추가:JAVA → true가 찍혀요. addFunc는 infoA를 만든 뒤에 추가했는데도 infoA가 쓸 수 있고, 두 객체가 같은 함수 하나를 공유합니다(===가 true). 반면 생성자 안에서 만든 printout은 객체마다 새로 만들어져요. 클로저는 closureTest2_1()을 세 번 부르면 1, 2, 3으로 누적되고, closureTest2()()를 세 번 부르면 1, 1, 1이 나옵니다.

⚠️ 수업 코드에서 조심할 곳 (js07.html)

① 50줄 pArray[0].onClick — JS는 대소문자를 구별해서 onClick은 이벤트가 아니라 그냥 새 속성이 됩니다. 클릭해도 아무 일도 안 일어나요. onclick(소문자)으로 고쳐야 1번 예제가 실행됩니다. ② 118줄 new Ifo("JAVA") 오타 → ReferenceError: Ifo is not defined로 함수가 멈춰서 그 아래 줄이 실행되지 않아요. Info로 고치세요. ③ 115줄 주석 「this는 Info.prototype」 → 실제 this는 메서드를 부른 객체입니다(infoA.addFunc()면 infoA). 그래서 this.subject가 database로 나와요. ④ 106줄 주석 「this가 붙으면 전역」 → 전역이 아니라 new로 만든 그 객체의 공개 속성이에요. ⑤ 4번(클로저) 클릭 핸들러는 본문이 전부 주석이라, 주석을 풀어야 동작합니다.

핵심 정리
  • 선언적 함수는 선언 전에 호출 가능, 익명·화살표 함수(변수에 담음)는 선언 후에만. 화살표 함수는 자기 this가 없고 생성자로 못 쓴다.
  • 객체: 하나면 리터럴, 같은 모양 여러 개면 생성자 + new, 공용 메서드는 prototype에.
  • 클로저 = 바깥 함수의 변수를 기억하는 안쪽 함수 → 상태 유지 + 정보 은닉.
  • ES6: `${}` 템플릿 리터럴, { a, b } = obj 구조분해, [...arr] 스프레드(얕은 복사), (...args) 나머지 매개변수.
확인 문제
hello(); function hello(){} 는 되는데 hi(); const hi = function(){}; 는 안 되는 이유는?
함수 선언문은 함수 통째로 호이스팅되지만, hi는 const 변수라 선언 줄 전에는 TDZ에 있어서 ReferenceError가 납니다(11장).
infoA.addFunc === infoB.addFunc 는 true인데, infoA.printout === infoB.printout 은?
false. printout은 생성자 안에서 this.printout = () => …로 객체마다 새로 만들어지고, addFunc는 prototype에 하나만 있어서 공유됩니다.
closureTest3() 을 세 번 부르면 무엇이 찍히나요?
1, 2, 3. 바깥 함수가 정의되자마자 딱 한 번 실행돼서(즉시 실행) count 하나가 만들어졌고, closureTest3에 담긴 안쪽 함수가 그 count를 계속 씁니다.
spreadTest(1, 2, 3, 4, 5, 6) 을 function spreadTest(...val) 로 받으면 val 은 무엇인가요?
배열 [1, 2, 3, 4, 5, 6]입니다. 매개변수 자리의 ...은 남은 인자를 배열로 모으는 나머지 매개변수예요(js09.html의 spreadTest).
다음 장으로

객체를 직접 만들어 봤으니, 이번엔 JS에 처음부터 들어 있는 객체들(String·Array·Math·Date)을 씁니다. 13장에서는 문자열 자르기, 배열 정렬과 복사, 반복문 4종, 날짜 계산을 배우고 로또 번호 생성기로 한데 묶어요.

더 자세히 → 함수·객체·클로저·ES6화살표 함수구조분해 할당스프레드와 나머지스코프

3부 · JavaScript

13내장 객체 — String · Array · 반복문 4종 · Date (+ 로또 실습)

📎 JS 교안 p.13~16💻 Front_end/3.javascript/js08.html · js09.html · js10.html · js11.html · js12.html
이 장의 목표
  • 문자열에서 위치를 찾고(indexOf) 잘라내고(substring·split) 다듬는(trim) 흐름을 짤 수 있다.
  • 배열 메서드(push·pop·shift·sort·join·slice)를 쓰고, sort에 비교 함수가 왜 필요한지 안다.
  • 참조 복사 · 얕은 복사 · 깊은 복사를 구별한다.
  • for·for...in·forEach·map/filter/reduce 중 목적에 맞는 반복을 고른다.
  • Date로 날짜를 계산한다(월은 0부터, setDate, getTime 차이).
왜 지금 이걸 배우나 — 앞 장과의 연결

12장에서 객체를 직접 만들었죠. JS에는 이미 만들어져 있는 객체들이 있어요 — 10장 지도의 CORE 내장 객체입니다. 사용자 입력(문자열)을 자르고, 목록(배열)을 정렬하고, 날짜를 계산하는 일은 거의 모든 화면에서 나와요. 마지막엔 이걸 전부 섞은 로또 번호 생성기로 "배운 걸 조립하는 연습"을 합니다.

개념

① String — 찾고, 자르고, 다듬기

문자열은 한 번 만들면 바뀌지 않아요(불변). 그래서 모든 문자열 메서드는 원본을 고치지 않고 새 문자열을 돌려줍니다. 합치기는 +, concat(), 템플릿 리터럴, 배열의 join(). 찾기는 indexOf(":") / lastIndexOf(".") — 위치(0부터 세는 인덱스)를 주고, 없으면 -1. 자르기는 substring(시작, 끝) — 끝 바로 앞까지, 끝을 생략하면 마지막까지. split(",")은 구분자로 나눠 배열로 주고, trim()은 앞뒤 공백을 지워요. match(/[0-9]/)는 정규식에 맞는 부분이 있으면 배열, 없으면 null을 줘서 "숫자 포함 여부" 검사에 씁니다.

📎 JS 교안 p.13 「String 주요 메서드 6가지」 「문자열 실전 활용 패턴」

② Array — 메서드와 복사 3단계

push(끝에 넣기)·pop(끝에서 꺼내기)·shift(앞에서 꺼내기)·sort·reverse는 원본을 바꾸고, slice·concat·map·filter는 새 배열을 돌려줘요. sort()는 기본이 글자(사전식) 비교라서 [1, 3, 2, 10].sort()가 [1, 10, 2, 3]이 됩니다. 숫자 크기순은 비교 함수 sort((a, b) => a - b)(음수면 a가 앞).

복사는 세 단계로 구별하세요. 참조 복사 b = a — 배열은 하나, 이름만 둘(b를 고치면 a도 바뀜). 얕은 복사 a.slice(), [...a] — 새 배열이지만 안에 든 배열·객체는 공유. 깊은 복사 — 안쪽까지 전부 새로 만듦(손으로 펼치거나, 최신 브라우저의 structuredClone(a)).

📎 JS 교안 p.14 「주요 메서드」 「얕은 복사 vs 깊은 복사」 「sort 비교함수 원리」

③ 반복문 4종 — 목적에 따라 고른다

for는 인덱스를 직접 다루고 break로 중간에 멈출 수 있어요. for...in은 객체의 키를 도는 용도라, 배열에 쓰면 인덱스가 문자열 "0", "1"로 나옵니다(배열 값을 돌려면 for...of). forEach는 모든 원소를 하나씩 처리하고 break가 안 돼요. map은 각 원소를 바꾼 새 배열, filter는 조건이 true인 원소만 모은 새 배열, reduce는 배열을 값 하나로 줄여요([1,2,3].reduce((a, b) => a + b) → 6). React에서 목록을 그릴 때 map을 매일 쓰게 됩니다.

📎 JS 교안 p.15 「Array 반복문 4종 비교」

④ Date — 월은 0부터, 계산은 밀리초로

new Date()는 지금 이 순간, new Date(2026, 7 - 1, 18)은 특정 날짜예요. 주의: getMonth()는 0~11이라 화면에 보일 땐 +1, 만들 땐 -1. getDay()는 요일 0(일)~6(토). setDate(getDate() + 10)처럼 날짜를 더하면 월말을 넘을 때 다음 달로 알아서 넘어가요. 두 날짜의 차이는 getTime()(1970-01-01부터 흐른 밀리초)끼리 빼서 1000 * 60 * 60 * 24로 나누면 일수가 됩니다.

📎 JS 교안 p.16 「Date 객체 — 날짜·시간 처리 5가지 패턴」

⚠️ 교안 표현 바로잡기

교안 p.14는 「얕은 복사 — let cc = aa → 같은 배열을 가리킴」이라고 하지만, cc = aa는 복사가 아니라 참조 복사(배열 하나에 이름 둘)예요. 얕은 복사는 slice()나 [...aa]처럼 새 배열을 만들되 안쪽 객체는 공유하는 것입니다. 「ES6 spread 연산자로 깊은 복사」도 [...arr]만 보면 얕은 복사예요. 원소가 숫자처럼 기본값이면 깊은 복사처럼 보일 뿐입니다. 교안의 [[...aa[0]], [...aa[1]], [...aa[2]]]는 안쪽 배열까지 손으로 펼쳤으니 이 2단 배열에 한해서는 깊은 복사가 맞아요. js09의 74줄·85줄 주석도 같은 기준으로 읽으세요.

예제 — 수업 코드로 확인하기

slice와 복사 — 원본이 바뀌는 경우, 안 바뀌는 경우

이 코드는 "복사했는데 원본이 왜 바뀌지?"를 직접 겪어 보려고 만든 실습입니다. 화면의 「slice()」 줄을 클릭하고 콘솔을 보세요.

JSjs09.html — sliceTest
function sliceTest() {
    const arrayObj = [[1, 2], [3, 4], [5, 6]]; //배열에 값이 객체임

    const arrayObj02 = arrayObj.slice(1, 3); //[[3,4],[5,6]]
    arrayObj02[0][0] = 10;//복사 받은 쪽에서 값을 변경 [[10,4],[5,6]]
    console.log(`복사받은쪽:${arrayObj02.toString()}`);
    console.log(`원본쪽:${arrayObj.toString()}`);

    const aa = [1, 2, 3, 4, 5];
    //얕은복사   ← 정확히는 "참조 복사"
    const cc = aa; //주소값만 복사 (실제값이 아니라 메모리 위치만 복사)
    cc[0] = 10;
    console.log("원본:", aa.toString())

    //스프레드 연산자를 사용한 깊은 복사(배열의 값이 기본타입일 경우)   ← 정확히는 "얕은 복사"
    const ee = [1, 2, 3, 4, 5];
    const ff = [...ee];
    ff[0] = 10;
    console.log("깊은 복사 원본:", ee.toString());
    // …(생략)
}

👀 관찰 포인트 — 원본쪽:1,2,10,4,5,6이 찍혀요. slice로 새 배열을 만들었는데도 원본이 바뀐 건, 안쪽 [3, 4] 배열은 복사되지 않고 공유됐기 때문입니다 — 이것이 얕은 복사예요. cc = aa는 원본: 10,2,3,4,5(참조 복사), [...ee]는 원소가 숫자라 원본 1,2,3,4,5가 그대로입니다.

로또 번호 생성기 — 배운 것 조립하기

이 코드는 Math.random · 생성자 함수 · 배열 메서드 · 스프레드 · DOM 출력을 한 문제에 섞어 써 보려고 만든 종합 실습입니다.

JSjs10.html — Lotto 생성자와 lottoPrint
function Lotto() {
    this.balls = [];
    this.makeBall = () => {
        //random(): 0~1사이의 실수를 랜덤하게 반환
        return Math.floor(Math.random() * 45) + 1;
    }
    this.lottoBalls = () => {
        let count = 0;
        while (count < 7) {
            let ball = this.makeBall(); //랜덤숫자생성
            //if (this.balls.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);

    let span1 = document.querySelectorAll("span")[1];
    let span2 = document.querySelectorAll("span")[3];
    span1.textContent = arr.join("-");
    span2.textContent = bonus;
}

👀 관찰 포인트 — Math.random()(0 이상 1 미만) × 45 → 버림 → +1 이면 1~45 정수. 중복이면 count를 올리지 않아 7개가 찰 때까지 다시 뽑아요. [...lotto.balls]로 복사한 뒤 pop()하니 원본 balls는 7개 그대로입니다. sort에 비교 함수를 빼면 10이 2보다 앞에 오는 걸 확인해 보세요. 같은 주제의 확장판 js10_당첨기능.html(표로 당첨 표시, 14장 미리보기)과 js10_효과.html(공 움직이기)도 있어요.

경과일과 D-Day 계산

이 코드는 입력값 형변환(11장) + Date 계산을 실제 기능으로 써 보려고 만든 실습입니다.

JSjs12.html — testDate04 / testDate05
function testDate04() {
    const date = new Date();
    // …(생략: 현재 날짜 표시)
    let inputVal = document.getElementById("inputDate").value;
    //16 + 10 => 26 만약에 36같은게 나오면 다음달로 자동 계산해서 해줌
    date.setDate(date.getDate() + parseInt(inputVal));
    document.getElementById("resultDate").value = date.toLocaleDateString();
}

function testDate05() {
    const nowDate = new Date(); //현재날짜 객체 생성
    // …(생략)
    let date = document.getElementById("d_day").value;
    const afterDate = new Date(date);//종료날짜 객체 생성
    //getTime메서드는 1970년 1월1일 기준부터 흘러간 시간을 밀리초 단위로 반환함
    let period = Math.ceil((afterDate.getTime() - nowDate.getTime()) / (1000 * 60 * 60 * 24));
    document.getElementById("period").value = period;
}

👀 관찰 포인트 — parseInt를 빼면 16 + "10"이 "1610"이 되어 1,610일 뒤로 날아가요. <input type="date">의 값 "2026-09-30"처럼 날짜만 있는 문자열은 UTC 자정으로 해석돼 한국 시간으론 오전 9시가 됩니다. 그래서 "지금"과의 차이가 딱 떨어지지 않고, Math.ceil(올림)로 남은 일수를 맞춰요.

⚠️ 수업 코드에서 조심할 곳

① js09.html 11~12줄: let arratObj로 선언하고 arrayObj.length를 읽어서, 페이지를 열자마자 ReferenceError: arrayObj is not defined가 납니다. 그 아래 최상위 코드(arrayObj2, arrayLit, console.log)는 실행되지 않아요. 다만 function 선언들은 실행 전에 미리 등록되므로 클릭 실습은 동작합니다. ② js11.html 19줄 for (const key in object): object라는 변수가 없어서 ReferenceError → 그 아래 forEach·map·filter가 전부 실행되지 않아 콘솔이 비어 있어요. for (const key in array) { console.log(array[key]); }로 고치면 됩니다. ③ js08.html strTest03: 마지막 "."은 「추출하기.」의 점(8번)이라 ":"(16번)보다 앞에 있어요. substring은 시작이 끝보다 크면 둘을 맞바꿔서 ". 관련 메서드:"를 잘라 오고, split(",") 결과가 1개뿐이라 splitVal[1].trim()에서 TypeError가 납니다. strVal.substring(sIdx + 1)(끝 생략)로 고치면 indexOf()메서드, substring()메서드가 나와요.

핵심 정리
  • 문자열은 불변: indexOf(없으면 -1) → substring(끝 직전까지) → split(배열) → trim.
  • sort() 기본은 사전식 → 숫자는 sort((a, b) => a - b). push/pop/shift/sort는 원본 변경, slice/map/filter는 새 배열.
  • 복사 3단계: b = a 참조 복사 / slice()·[...a] 얕은 복사 / 안쪽까지 새로 만드는 깊은 복사.
  • 반복: 멈춰야 하면 for, 객체 키는 for...in, 전부 처리는 forEach, 새 배열은 map/filter, 값 하나는 reduce.
  • getMonth()는 0~11, 날짜 차이는 getTime() 밀리초 차 ÷ 86,400,000.
확인 문제
[5, 1, 10].sort() 의 결과는?
[1, 10, 5]. 글자로 비교하면 "10"이 "5"보다 앞이에요. 크기순은 sort((a, b) => a - b) → [1, 5, 10].
a = [[1], [2]]; b = [...a]; b[0][0] = 99; 이후 a[0][0] 은?
99. 스프레드는 얕은 복사라 바깥 배열만 새로 만들고, 안쪽 [1] 배열은 a와 b가 공유합니다.
2026년 12월 25일 Date 객체를 숫자 방식으로 만들려면?
new Date(2026, 11, 25). 월은 0부터라 12월은 11이에요.
배열에서 짝수만 골라 새 배열을 만들 때 map 과 filter 중 무엇을 쓰나요?
filter(item => item % 2 === 0). map은 원소 개수가 그대로인 "바꾼" 배열을 만들고, filter가 "골라낸" 배열을 만듭니다(js11 37~39줄).
다음 장으로

지금까지는 결과를 대부분 콘솔에 찍었어요. 로또 예제의 마지막 줄처럼 화면의 요소를 찾아서 글자를 바꾸려면 DOM을 알아야 합니다. 14장에서는 요소 찾기, 만들어 붙이기, 이벤트, 그리고 표를 동적으로 만드는 법을 배워요.

더 자세히 → String 객체Array 배열Lotto 번호 생성기Array 반복문 4종Date 객체배열 메서드스프레드와 나머지

3부 · JavaScript

14DOM — 요소 찾기 · 만들어 붙이기 · 이벤트 · 표 동적 생성

📎 JS 교안 p.6~7, 17~18💻 Front_end/3.javascript/js04_DOM탐색메서드.html · js05.html · js15.html · js16.html · js17.html
이 장의 목표
  • HTML이 브라우저 안에서 노드 나무(DOM)로 바뀐다는 것과 Node와 Element의 차이를 설명할 수 있다.
  • 탐색 메서드 6가지가 몇 개를, 어떤 타입으로 돌려주는지 알고 골라 쓸 수 있다.
  • 관계 속성(parentNode·children·nextElementSibling 등)으로 이웃 요소를 찾을 수 있다.
  • createElement → appendChild로 요소를 만들어 붙이고, 이벤트 객체의 e.target을 쓸 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

11~13장은 주로 콘솔 안에서 계산했어요. 계산 결과를 사용자 화면에 보여 주려면 HTML 요소를 손에 쥐어야 합니다. DOM은 JS가 HTML을 붙잡는 손잡이예요. 10장 지도에서 DOM 칸을 채우는 장이고, 13장 로또 예제의 querySelectorAll("span")[1].textContent = …가 무슨 뜻인지 여기서 정확히 알게 됩니다.

개념

① DOM — 문서를 나무로 바꾼 것

브라우저는 HTML 글자를 읽어서 객체로 된 나무를 만들어요. 뿌리는 document, 태그 하나하나는 Element 노드, 태그 안의 글자는 Text 노드, id·style 같은 속성은 Attribute입니다. Node는 이 모두를 묶는 가장 넓은 이름이에요. 그래서 태그 사이의 줄바꿈·공백도 Text 노드로 잡힙니다. "노드 전부"가 필요한지 "태그만" 필요한지에 따라 속성이 둘씩 있는 이유예요: childNodes(노드 전부) / children(태그만), firstChild / firstElementChild.

📎 JS 교안 p.18 「DOM 객체 5종류」 「Node vs Element 차이」

② 찾기 — 메서드 6가지와 관계 속성

1개를 주는 것: getElementById("id"), querySelector("CSS선택자")(맞는 것 중 첫 번째). 못 찾으면 null. 여러 개를 주는 것: getElementsByName → NodeList, getElementsByTagName·getElementsByClassName → HTMLCollection, querySelectorAll → NodeList. 여러 개짜리는 [0]과 length를 쓸 수 있어 배열처럼 보이지만 진짜 배열이 아니에요(유사 배열). map·filter가 없고, HTMLCollection은 forEach도 없습니다. 필요하면 Array.from(목록)으로 바꾸거나 for문을 써요.

이미 잡은 요소에서 이웃으로 이동할 땐 관계 속성: 부모 parentNode/parentElement, 자식 children·firstElementChild·lastElementChild, 형제 previousElementSibling·nextElementSibling.

📎 JS 교안 p.6 「DOM 탐색 메서드 6가지」 · p.18 「DOM 탐색 속성 — 관계성 선택」

③ 바꾸기와 만들기

스타일은 요소.style.backgroundColor = "red" — CSS의 background-color가 카멜 표기로 바뀝니다. 내용은 textContent(글자를 그대로 넣음)와 innerHTML(글자를 HTML로 해석)이 있어요. 사용자 입력을 innerHTML에 넣으면 남이 심은 스크립트가 실행될 수 있으니(XSS) 사용자 값은 textContent로 넣습니다.

새 요소는 만들고 → 채우고 → 붙이는 3단계예요. document.createElement("tr")로 만들면 아직 메모리에만 있고, 글자를 넣은 뒤(createTextNode 또는 textContent) 부모.appendChild(자식)를 해야 화면에 나타나요. 속성은 setAttribute("style", "color:red")로 붙이는 게 보통이고, 수업의 createAttribute + setAttributeNode는 같은 일을 노드 단위로 하는 방식입니다. 이미 화면에 있는 요소를 appendChild하면 복사가 아니라 이동해요.

📎 JS 교안 p.7 「선택한 요소를 조작하는 방법」 · p.18 「DOM 요소 동적 생성」

④ 이벤트와 e.target

요소.onclick = function (e) {…}처럼 함수를 넣어 두면, 클릭이 일어날 때 브라우저가 그 함수를 부르면서 이벤트 정보 객체 e를 넣어 줍니다. e.target은 이벤트가 일어난 바로 그 요소예요(e.target.tagName, e.target.value). 이벤트 속성 이름은 모두 소문자(onclick)이고, 같은 일을 하는 addEventListener("click", 함수)는 한 요소에 여러 함수를 등록할 수 있어요. <head>에서 요소에 이벤트를 걸 땐 onload = function(){…} 안에서 해야 요소가 이미 만들어져 있습니다(10장).

📎 JS 교안 p.17 「이벤트 객체 e」 「onload 이벤트」

⚠️ 교안 표현 바로잡기

js04의 화면 설명과 주석은 여러 개짜리 반환값을 「배열」이라고 부르지만, 정확히는 NodeList·HTMLCollection 같은 유사 배열이에요. 교안 p.6 표가 적은 대로 getElementsByName은 NodeList를 돌려줍니다. 그래서 document.getElementsByTagName("span").map(…)은 TypeError가 나요. "인덱스로 꺼내 쓰는 목록"으로 기억하세요.

예제 — 수업 코드로 확인하기

id와 name으로 찾아서 바꾸기

이 코드는 "1개를 주는 메서드"와 "여러 개를 주는 메서드"의 차이, 그리고 textContent와 innerHTML의 차이를 보여 주려고 만든 실습입니다. 같은 연습을 js05.html이 문제 형태로 한 번 더 시켜요.

JSjs04_DOM탐색메서드.html — searchId / searchName
function searchId() {
    //id속성이름으로 탐색한 후 배경색을 지정
    const p = document.getElementById("idTest")
    console.log(typeof p);

    p.style.backgroundColor = "red";
    p.style.color = "white";
    p.textContent = "id로 탐색 가능";
    p.innerHTML = "<b> 태그 내부 html요소 접근</b>";
}

function searchName() {
    const inputs = document.getElementsByName("test02");
    console.log(inputs.length);
    //[input, input, input]
    inputs[0].value = "첫번째 요소";
    inputs[1].value = "두번째 요소";
    inputs[2].value = "세번째 요소";

    for (let index = 0; index < inputs.length; index++) {
        inputs[index].style.backgroundColor = "yellow";
    }
}

👀 관찰 포인트 — typeof p는 "object"예요. 요소도 객체입니다. textContent로 넣은 글자는 바로 다음 줄의 innerHTML이 덮어써서 화면엔 굵은 글씨만 남아요. 두 줄의 순서를 바꾸면 <b>가 태그가 아니라 글자 그대로 보이는 것도 확인해 보세요. getElementsByName은 length가 3인 목록이라 [0]처럼 꺼내서 써야 합니다.

관계 속성으로 부모·자식·형제 찾기

이 코드는 "하나를 잡은 뒤 나무를 타고 이웃으로 이동하는 법"과 Node/Element 차이를 보여 주려고 만든 실습입니다.

JSjs15.html — 부모·자식·형제 탐색
onload = function () {
    const btnEle = document.querySelectorAll("button");

    //부모요소 찾기
    btnEle[0].onclick = () => {
        const child02 = document.querySelectorAll("div > p")[1];
        console.log(child02.tagName);
        const div = child02.parentNode;
        div.style.backgroundColor = "yellow";
        // …(생략)
    }
    //자식요소 찾기
    btnEle[1].onclick = () => {
        const div = document.querySelectorAll("div")[0];
        const divCn = div.childNodes;//자식요소 구함
        console.log(divCn.length); //텍스트노드까지 7개 (줄바꿈 공백까지 다 침)
        const divEle = div.children; //자식요소 구함 [p,p,p]
        console.log(divEle.length);
        divEle[1].style.backgroundColor = "blue";
    }
    // …(생략: firstElementChild / lastElementChild)
    btnEle[3].onclick = () => {
        const child02 = document.querySelectorAll("div > p")[1];
        const preP = child02.previousElementSibling;
        const nextP = child02.nextElementSibling;
        console.log(preP.textContent, nextP.textContent)
    }
}

👀 관찰 포인트 — 대상 HTML은 <div> 안에 <p>child01~03</p> 세 줄이에요. childNodes.length는 7(p 3개 + 줄바꿈 Text 노드 4개), children.length는 3입니다. 형제 탐색은 child01 child03을 찍어요. 버튼이 <head>의 스크립트보다 뒤에 있어서 onload 안에서 이벤트를 거는 점도 보세요.

요소 만들어 붙이기 → 입력값으로 표에 행 추가

js16은 "만들기 → 채우기 → 붙이기" 3단계를 가장 기본 형태로, js17은 그 3단계를 실제 기능(회원정보 표)에 써 보려고 만든 실습입니다. js17은 관계 속성까지 함께 써요.

JSjs16.html — eleCreate (방법 1)
const val = "엘리먼트노드";
const div = document.createElement("div"); // <div></div>
const styleAttr = document.createAttribute("style"); // style=""
const txt = document.createTextNode(val);
styleAttr.nodeValue = "color:red";
div.setAttributeNode(styleAttr);   // <div style="color:red"></div>
div.appendChild(txt);              // <div style="color:red">엘리먼트노드</div>
document.querySelectorAll("#main")[0].appendChild(div);
JSjs17.html — tableVal (핵심부)
const inputs = document
    .querySelectorAll("form[name=formTest] input[name]");
let count = 0; // 누락된(입력하지 않은) 입력창의 개수를 세기 위한 변수
let msg = "";  // 입력하지 않은 항목 이름들을 모아둘 문자열 변수

inputs.forEach((input) => {
    if (input.value == null || input.value == "" || input.value == undefined) {
        count++;
        msg += input.parentNode.previousElementSibling.textContent + " ";
    }
});
const tbodyTrCount = document.querySelector("#addtr").childElementCount;

if (count > 0) {
    alert(`모두 입력하세요!!(${msg})`);
} else {
    if (tbodyTrCount < 10) {
        const tr = document.createElement("tr");
        for (let i = 0; i < inputs.length; i++) {
            const td = document.createElement("td"); // 새로운 <td> 셀을 생성
            td.textContent = inputs[i].value;       // <td> 내부에 사용자가 입력한 값을 채워 넣음
            tr.appendChild(td);                      // 방금 만든 <td> 셀을 <tr> 행의 자식으로 추가
        }
        document.querySelector("#addtr").appendChild(tr);
    } else {
        alert("10개까지만 입력 가능합니다.");
    }
}

👀 관찰 포인트 — 입력 표는 <tr><th>아이디</th><td><input></td></tr> 구조라서, input.parentNode(td) → .previousElementSibling(th) → .textContent로 "아이디" 같은 항목 이름을 얻어요. querySelectorAll이 준 NodeList라 forEach를 쓸 수 있습니다. 셀 값은 innerHTML이 아니라 textContent로 넣어서, 입력창에 <b>hi</b>를 쳐도 글자 그대로 보여요.

핵심 정리
  • DOM = HTML을 객체 나무로 바꾼 것. Node(줄바꿈 Text 포함) ⊃ Element(태그만).
  • 1개: getElementById, querySelector(없으면 null) / 여러 개: getElementsBy…(HTMLCollection, Name만 NodeList), querySelectorAll(NodeList) — 모두 유사 배열.
  • 바꾸기: style.카멜표기, 사용자 값은 textContent, HTML 조각은 innerHTML(XSS 주의).
  • 만들기: createElement → 채우기 → appendChild해야 화면에 보인다.
  • 이벤트 함수의 e.target = 이벤트가 일어난 요소. <head>의 코드는 onload 안에서 요소를 잡는다.
확인 문제
document.getElementById("없는id").style.color = "red" 를 실행하면?
못 찾으면 null이 돌아오고, null에서 style을 읽으려다 TypeError: Cannot read properties of null이 납니다. id 오타나, 요소가 만들어지기 전(onload 밖)에 찾았을 때 흔히 봐요.
js15에서 childNodes.length 는 7인데 children.length 는 3인 이유는?
childNodes는 태그 사이의 줄바꿈·공백 Text 노드 4개까지 세고, children은 Element(p 3개)만 세기 때문입니다.
document.getElementsByClassName("a").forEach(…) 는 왜 에러가 날까요?
돌려받은 HTMLCollection에는 forEach가 없어서 TypeError입니다. document.querySelectorAll(".a")(NodeList는 forEach 있음)를 쓰거나 Array.from(…)으로 바꾸세요.
createElement("td") 로 만든 셀이 화면에 안 보여요. 무엇을 빠뜨렸을까요?
appendChild로 화면에 있는 부모(tr → tbody)에 붙이지 않은 겁니다. 만든 직후의 요소는 메모리에만 있어요.
다음 장으로

DOM은 창 안의 문서였어요. 그 문서를 담은 창 자체 — 새 창 열기, 주소창, 다른 페이지로 이동 — 는 BOM이 맡습니다. 15장에서는 window와 location을 배워요.

더 자세히 → DOM 탐색 메서드DOM 탐색 속성DOM 요소 동적 생성DOM으로 표 동적 생성e.target

3부 · JavaScript

15BOM — window(팝업) · location

📎 JS 교안 p.17💻 Front_end/3.javascript/js13.html · js13_pop.html · js14.html · js14_location.html
이 장의 목표
  • window가 브라우저의 최상위(전역) 객체이고, alert·document가 사실 그 속성이라는 것을 설명할 수 있다.
  • window.open으로 팝업을 열고, 팝업에서 window.opener로 부모 창의 값을 읽을 수 있다.
  • location의 href·search·hash 등을 읽고, assign·replace·reload의 차이를 말할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

14장의 DOM은 창 안에 보이는 문서였어요. 이번엔 그 문서를 담은 브라우저 창, 주소창, 뒤로 가기를 다루는 BOM입니다. 10장 지도의 BOM 칸이에요. 특히 주소의 ?id=hk 부분(location.search)은 백엔드에서 GET 파라미터로 값을 받는 것과 그대로 이어집니다.

개념

① window — 모든 것의 꼭대기

브라우저 탭 하나마다 window 객체가 하나 있고, 그 탭의 JS 코드에서 가장 바깥 공간(전역)이 바로 window예요. 그래서 alert()는 window.alert(), document는 window.document, js07·js15의 onload = …는 window.onload = …를 줄여 쓴 겁니다. 최상위에서 var로 만든 변수나 function 선언도 window의 속성이 돼요(let·const는 아님). 페이지를 이동하면 새 문서에 새 window 환경이 생겨서, 이전 페이지의 변수는 전부 사라집니다(16장의 출발점).

📎 JS 교안 p.3 「BOM 주요 객체」 · p.17 「onload 이벤트」

② window.open — 팝업과 opener

window.open(url, name, features)는 새 창을 열고 그 창의 window를 돌려줘요. url은 열 페이지, name은 창의 이름(링크의 target과 같은 개념 — 같은 이름으로 다시 열면 새 창 대신 그 창에 로드), features는 "width=400,height=400,top=300,left=200"처럼 크기·위치를 쉼표로 적은 문자열입니다. 열린 팝업 쪽에서는 window.opener가 "나를 연 창"이라 opener.document로 부모의 요소를 읽고 쓸 수 있어요. window.close()(수업 코드는 window.self.close())로 스스로 닫습니다. 요즘 브라우저는 사용자 클릭 없이 연 팝업을 차단하니 클릭 이벤트 안에서 여세요.

📎 JS 교안 p.17 「window.open() — 팝업창 만들기」

③ location — 주소창 객체

주소 http://localhost:5500/3.javascript/js14.html?id=hk#top을 조각내면 protocol(http:) · host(localhost:5500, 포트는 port) · pathname(/3.javascript/js14.html) · search(?id=hk) · hash(#top)이고, 전체가 href예요. ? 뒤(쿼리 스트링)는 서버로 전송되지만 # 뒤(해시)는 서버로 가지 않고 브라우저 안에서만 쓰입니다(페이지 안 위치 이동, JS용 값).

이동은 세 가지: location.href = url 또는 location.assign(url)은 방문 기록이 남아 뒤로 가기로 돌아올 수 있고, location.replace(url)은 지금 기록을 바꿔치기해서 돌아올 수 없어요(로그인 후 이동 등). location.reload()는 새로고침입니다.

📎 JS 교안 p.17 「location 객체 — URL 정보 접근」 「location 이동 메서드 3가지」

⚠️ 교안 표현 바로잡기

교안 p.17은 window.open(url, title, prop)의 두 번째 인자를 「title」이라고 적었지만, 이건 창 제목이 아니라 창의 이름(name, target)이에요. 창 제목 표시줄에는 열린 문서의 <title>이 보입니다. 또 교안 예시처럼 "width=400px, …"로 단위를 붙이기보다, js13 코드처럼 숫자만("width=400,height=400,top=300,left=200") 쓰는 것이 표준 형식이에요.

예제 — 수업 코드로 확인하기

부모 창 ↔ 팝업 창 값 주고받기

이 두 파일은 창을 열고(open), 연 창을 거꾸로 참조하는(opener) 관계를 보여 주려고 만든 실습입니다. 덤으로 14장의 e.target도 씁니다.

JSjs13.html — 부모 창
window.onload = function () {
    document.querySelector("#btn01").onclick = function (e) {
        //파라미터 e는 현재 이벤트(onclick)을 의미함.
        e.target.style.backgroundColor = "red";
        e.target.textContent = "테스트"

        let url = "js13_pop.html";
        let title = "팝업창페이지";
        let prop = "width=400,height=400,top=300,left=200";

        window.open(url, title, prop);
    }
}
JSjs13_pop.html — 팝업 창
onload = function () {
    //팝업창을 열어준 부모페이지: window.opener
    let val = window.opener.document.getElementsByName("val01")[0].value;
    document.getElementById("val").textContent = val;

    //창닫기
    document.getElementsByTagName("button")[1].onclick = function () {
        window.self.close(); //self, top, parent 브라우저 창의 계층구조 위치
    }

    function childVal() {
        var val = document.getElementByName("childVal")[0].value;
        window.opener.document.getElementByName("val02")[0].value = val; //부모창의 val02
    }
}

👀 관찰 포인트 — 부모의 첫 입력창에 글자를 쓰고 「팝업창」을 누르면, 버튼이 빨개지고(e.target = 누른 버튼) 팝업에 그 글자가 나타나요. 팝업이 opener로 부모의 DOM을 직접 읽은 겁니다. 버튼을 두 번 눌러도 이름("팝업창페이지")이 같아서 창이 하나만 유지돼요.

⚠️ 수업 코드에서 조심할 곳 (js13_pop.html)

「전달」 버튼(자식 → 부모)은 지금 동작하지 않아요. ① childVal이 onload 함수 안에 선언돼 전역이 아니라서, onclick="childVal()"이 찾지 못해 ReferenceError: childVal is not defined가 납니다 → 함수를 onload 밖으로 꺼내세요. ② 꺼내도 getElementByName(s 빠짐)은 없는 메서드라 TypeError: … is not a function → getElementsByName으로 고쳐야 합니다. ③ js14.html의 <iframe> 안에 이 페이지를 넣으면 팝업으로 열린 게 아니라 window.opener가 null이라 12줄에서 TypeError가 나요. 팝업으로 열 때만 동작하는 페이지입니다.

주소를 조각내 읽고, 해시로 값 넘기기

js14는 location의 각 속성이 주소의 어느 부분인지 눈으로 확인하려고, js14_location은 해시(#) 뒤에 실어 보낸 값을 다음 페이지에서 꺼내는 법을 보여 주려고 만든 실습입니다.

HTMLjs14.html — location 속성 출력과 이동
<script type="text/javascript">
    document.write("hash:" + location.hash + "<br/>");
    document.write("search:" + location.search + "<br/>");
    document.write("host:" + location.host + "<br/>");
    // …(생략: port, pathname, protocol)
    document.write("href:" + location.href + "<br/>");
</script>

<!-- 다른 페이지로 이동하면서 URL 끝에 # 뒤의 데이터(해시 값)를 같이 보냅니다. -->
<a href="js14_location.html#id=hk&addr=seoul">요청</a>

<!-- 클릭 시 name이 'subframe'인 iframe 영역만 지정된 HTML 페이지로 이동시킵니다. -->
<button onclick="subframe.location.href='js12.html'">이동</button>
<iframe name="subframe" src="js13_pop.html" height="500px"></iframe>
JSjs14_location.html — 해시 값 꺼내기
// location.hash는 # 뒤의 문자열을 포함해 반환합니다. 앞의 #을 제외한 뒤 URLSearchParams로 key=value를 해석합니다.
const rawHash = location.hash;
const params = new URLSearchParams(rawHash.slice(1));
// …(생략)
for (const [key, value] of params) {
    const term = document.createElement('dt');
    const description = document.createElement('dd');
    term.textContent = key;
    description.textContent = value;
    paramList.append(term, description);
}

👀 관찰 포인트 — VS Code Live Server로 열면 host:127.0.0.1:5500, protocol:http:가 나오고, 파일을 더블클릭해서(file://) 열면 host가 비고 protocol:file:이 나와요. 「요청」을 누르면 hash가 #id=hk&addr=seoul이 되고, URLSearchParams가 id → hk, addr → seoul로 쪼개 줍니다(13장 구조분해 [key, value], 14장 createElement가 함께 쓰였죠). 「이동」 버튼은 iframe의 window가 가진 location만 바꿔요.

핵심 정리
  • window = 탭 하나의 전역 객체. alert·document·onload는 모두 그 속성.
  • window.open(url, 창이름, "width=…,height=…") → 팝업에서는 window.opener가 부모 창.
  • location: href(전체) · host · pathname · search(?, 서버로 감) · hash(#, 서버로 안 감).
  • assign/href = 기록 남김, replace = 기록 바꿔치기, reload = 새로고침.
확인 문제
location.href = "a.html" 과 location.replace("a.html") 로 이동한 뒤 뒤로 가기를 누르면?
href(또는 assign)는 기록이 남아 원래 페이지로 돌아가고, replace는 원래 페이지 기록이 a.html로 바뀌어서 그 전 페이지로 갑니다.
주소의 ?id=hk 와 #id=hk 중 서버(백엔드)가 받을 수 있는 것은?
?id=hk(쿼리 스트링)입니다. # 뒤의 해시는 요청에 실리지 않고 브라우저 안에서만 쓰여요.
js13에서 「팝업창」 버튼을 세 번 누르면 팝업이 몇 개 생기나요?
하나입니다. 두 번째 인자(창 이름)가 매번 "팝업창페이지"로 같아서, 이미 열린 그 창에 다시 로드돼요.
다음 장으로

페이지를 이동하면 JS 변수는 전부 사라진다고 했죠. 그럼 로그인 상태나 "오늘 하루 보지 않기"는 어떻게 기억할까요? 16장에서는 브라우저에 값을 남겨 두는 쿠키와 웹 스토리지를 배웁니다.

더 자세히 → 팝업창(window)Location 객체

3부 · JavaScript

16쿠키와 웹 스토리지 — 브라우저에 값 남겨 두기

📎 JS 교안 p.19💻 Front_end/3.javascript/js18.html (교안의 「js19_cookie.html」)
이 장의 목표
  • HTTP는 요청끼리 서로를 기억하지 못한다(무상태)는 것과, 쿠키가 그 문제를 어떻게 푸는지 설명할 수 있다.
  • document.cookie로 쿠키를 저장·읽기·삭제하는 함수를 읽고, expires로 수명을 정할 수 있다.
  • 쿠키 · sessionStorage · localStorage의 수명, 크기, 서버 전송 여부를 비교할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

15장에서 location으로 페이지를 옮기면 이전 페이지의 변수가 모두 사라진다는 걸 봤어요. 그런데 쇼핑몰은 페이지를 옮겨도 로그인이 유지되고, 팝업은 "오늘 하루 보지 않기"를 기억하죠. 브라우저 안에 값을 따로 보관하는 장소가 있기 때문입니다. 특히 쿠키는 백엔드의 세션(로그인)이 동작하는 바탕이라, 서버 쪽을 배울 때 다시 만나요.

개념

① 쿠키 — 브라우저가 들고 다니는 작은 메모

HTTP는 무상태예요. 서버는 요청이 올 때마다 "처음 보는 손님"처럼 대합니다. 그래서 서버나 JS가 이름=값 형태의 작은 글자 메모(쿠키)를 브라우저에 맡겨 두면, 브라우저는 같은 사이트로 요청을 보낼 때마다 그 쿠키를 자동으로 붙여 보내요. 서버는 그걸 보고 "아까 그 손님"임을 알아봅니다. 백엔드(Tomcat)의 로그인 세션도 JSESSIONID라는 쿠키로 손님을 구별해요. 쿠키 하나는 약 4KB로 작고, expires(만료 날짜)나 max-age(초)를 안 주면 브라우저를 닫을 때 사라집니다.

📎 JS 교안 p.19 「Cookie 개념」

② document.cookie 다루기

document.cookie = "user1=hk1;expires=…"처럼 대입하면 쿠키 한 개가 추가(같은 이름이면 덮어쓰기)돼요. 기존 쿠키 전체를 지우고 바꾸는 게 아니라는 점이 일반 변수와 다릅니다. 반대로 읽으면 옵션은 빠지고 "user1=hk1; id2=abc"처럼 이름=값만 ; 로 이어진 문자열 하나가 나와요. 그래서 원하는 값을 꺼내려면 13장의 split(";") → trim() → startsWith(이름 + "=") → substring을 씁니다. 삭제는 같은 이름으로 이미 지난 만료일을 주면 브라우저가 지워요. 값에 한글·;·공백이 있으면 깨지니 encodeURIComponent()로 바꿔 저장하고 decodeURIComponent()로 되돌립니다.

📎 JS 교안 p.19 「핵심 함수 구현 원리 (setCookie / getCookie / removeCookie)」

③ 웹 스토리지 — session과 local

쿠키보다 쓰기 쉬운 키-값 저장소예요. setItem(키, 값)·getItem(키)·removeItem(키)·clear() 네 개면 끝입니다. sessionStorage는 그 탭을 닫으면 사라지고, localStorage는 직접 지울 때까지 남아요. 둘 다 서버로 자동 전송되지 않고, 용량은 보통 수 MB(브라우저마다 다름)로 쿠키보다 훨씬 큽니다. 주의: 문자열만 저장돼요. setItem("n", 5) 후 getItem("n")은 "5"이고, 객체는 JSON.stringify로 바꿔 넣어야 합니다(17장).

📎 JS 교안 p.19 「Web Storage — Cookie보다 더 큰 저장소」

⚠️ 교안 표현 바로잡기

교안 p.19의 「4KB 제한 / 도메인당 약 20개」에서 20개는 오래된 기준이에요. 현재 쿠키 표준(RFC 6265)은 브라우저가 쿠키 하나에 최소 4096바이트, 도메인당 최소 50개, 전체 최소 3000개를 저장할 수 있어야 한다고 정하고, 실제 한도는 브라우저마다 다릅니다. 또 교안은 getCookie를 「indexOf(cookieName)로 탐색 → split("=")[1]」로 설명하는데, 이 방식은 "user1"을 찾다가 "user10=…"에도 걸리고, 값 안에 =가 있으면 잘려요. 수업 코드 js18은 startsWith(name + "=")와 substring으로 이 문제를 피했습니다.

예제 — 수업 코드로 확인하기

쿠키 저장 · 읽기 · 삭제 함수 직접 만들기

이 코드는 "한 줄 문자열"인 document.cookie를 이름별로 다루는 법을 보여 주려고 만든 실습입니다. 저장·읽기·삭제를 함수 세 개로 나눴어요.

JSjs18.html — setCookie / getCookie / removeCookie
function setCookie(name, value, expires, domain, path, secure) {
    let cookies = "";
    cookies += name + "=" + window.encodeURIComponent(value);

    if (expires) {
        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";

    document.cookie = cookies;
}

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을 반환합니다.
}

function removeCookie(name) {
    setCookie(name, "", -1);
}

👀 관찰 포인트 — 「쿠키추가」 → F12 → Application → Cookies에서 user1 = hk1과 하루 뒤 만료 시각을 확인하세요. removeCookie는 새 함수를 만들지 않고 setCookie에 -1일(어제)을 넘겨 재활용해요. 13장의 setDate(getDate() + n), startsWith/substring이 그대로 쓰였습니다.

sessionStorage와 localStorage 비교

이 코드는 두 스토리지의 사용법은 같고 수명만 다르다는 것을 보여 주려고 만든 실습입니다.

JSjs18.html — webStorageTest
sessionStorage.setItem("id2", "hk2"); // 저장하기 (Key: id2, Value: hk2)
const sessionVal = sessionStorage.getItem("id2"); // 읽어오기
alert("세션 스토리지에서 읽은 값: " + sessionVal);

localStorage.setItem("id3", "hk3"); // 저장하기 (Key: id3, Value: hk3)
const localVal = localStorage.getItem("id3"); // 읽어오기
alert("로컬 스토리지에서 읽은 값: " + localVal + "\n(이 데이터는 창을 닫아도 계속 유지됩니다!)");

// sessionStorage.removeItem("id2"); // id2라는 특정 데이터 하나만 삭제
// localStorage.clear();               // 로컬 스토리지 안의 모든 데이터 초기화 삭제

👀 관찰 포인트 — 버튼을 누른 뒤 탭을 닫고 같은 페이지를 다시 열어 Application 탭을 보세요. Session Storage의 id2는 사라지고 Local Storage의 id3는 남아 있어요. 쿠키와 달리 이 두 값은 요청에 실려 서버로 가지 않습니다(Network 탭의 요청 헤더에서 비교해 보세요).

⚠️ 실습할 때 조심할 곳

크롬 등 일부 브라우저는 파일을 더블클릭해 연 file:// 페이지의 쿠키를 저장하지 않을 수 있어서, 코드가 맞는데도 「저장된 쿠키가 없거나…」가 뜰 수 있어요. Live Server 같은 로컬 서버(http://)로 열어 실습하세요. 그리고 JS로 읽을 수 있는 쿠키·스토리지에는 비밀번호 같은 민감한 정보를 넣지 않습니다. 페이지에 악성 스크립트가 끼어들면(XSS) 그대로 읽혀요. 서버가 HttpOnly 옵션으로 굽는 세션 쿠키는 JS에서 아예 보이지 않습니다.

핵심 정리
  • HTTP는 무상태 → 쿠키는 브라우저가 보관하다가 같은 사이트 요청마다 자동 전송(세션·로그인의 바탕).
  • document.cookie = "이름=값;expires=…"는 한 개 추가/덮어쓰기, 읽으면 "a=1; b=2" 한 줄 → split·trim·startsWith로 꺼낸다. 삭제 = 지난 만료일.
  • sessionStorage(탭 닫으면 삭제) / localStorage(지울 때까지) — 서버로 안 가고, 문자열만 저장.
  • 민감 정보는 JS가 읽을 수 있는 저장소에 두지 않는다.
확인 문제
document.cookie = "a=1"; document.cookie = "b=2"; 다음에 document.cookie 를 읽으면?
"a=1; b=2". 대입은 덮어쓰기가 아니라 쿠키 하나를 추가하는 동작이에요. 같은 이름 a로 다시 대입하면 그때 a만 바뀝니다.
장바구니 임시 목록(탭을 닫으면 없어져도 됨), 다크 모드 설정(다음에 와도 유지), 서버가 매 요청마다 알아야 하는 로그인 표시 — 각각 어디에?
장바구니 임시 목록은 sessionStorage, 다크 모드는 localStorage, 로그인 표시는 요청마다 서버로 가야 하니 쿠키(실제로는 서버가 만든 세션 쿠키)입니다.
localStorage.setItem("n", 5); 이후 localStorage.getItem("n") + 1 의 결과는?
"51". 스토리지는 문자열로 저장하므로 "5" + 1이 이어 붙이기가 됩니다. Number(localStorage.getItem("n")) + 1로 바꿔야 6이에요(11장).
다음 장으로

지금까지 데이터는 코드 안에 있거나 브라우저 저장소에 있었어요. 하지만 진짜 데이터(회원, 게시글)는 서버의 DB에 있죠. 17장에서는 페이지를 새로 받지 않고 서버에서 데이터만 받아 오는 AJAX와 비동기 처리를 배웁니다.

더 자세히 → Cookie 사용하기

3부 · JavaScript

17AJAX와 비동기 — XMLHttpRequest · JSON · Promise · fetch

📎 JS 교안 p.20 (+ p.12 ⑥)💻 Front_end/3.javascript/js19.html · js/data.json (교안의 「js20_ajax.html」)
이 장의 목표
  • 동기·비동기의 차이와, AJAX(새로고침 없이 데이터만 받기)가 왜 필요한지 설명할 수 있다.
  • XMLHttpRequest의 open → onreadystatechange → send 흐름과 readyState 4·status 200의 뜻을 안다.
  • JSON 문자열과 JS 객체를 JSON.parse·JSON.stringify로 오갈 수 있다.
  • Promise의 then/catch 체이닝과 fetch의 두 단계 then을 읽고 쓸 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

16장까지 데이터는 코드 안이나 브라우저 저장소에 있었어요. 실제 서비스의 데이터는 서버에 있습니다. 데이터가 바뀔 때마다 페이지 전체를 새로 받으면 화면이 깜빡이고 느리죠. 그래서 데이터만 받아 와서 14장의 DOM으로 화면 일부만 바꾸는 방식이 AJAX예요. 백엔드의 Controller가 JSON을 돌려주면, 그걸 받는 쪽이 바로 이 장의 코드입니다 — 프런트엔드와 백엔드를 잇는 다리예요.

개념

① 동기와 비동기

동기는 줄 서기예요. 앞 일이 끝나야 다음 일을 합니다(10장 alert가 떠 있는 동안 페이지가 멈추는 것처럼). 비동기는 카페 진동벨이에요. 주문(요청)하고 벨을 받은 뒤 다른 일을 계속하다가, 벨이 울리면(응답 도착) 정해 둔 함수(콜백)가 실행됩니다. 네트워크는 느리고 언제 끝날지 모르니, 비동기로 해야 기다리는 동안 화면이 멈추지 않아요. AJAX(Asynchronous JavaScript And XML)는 이름엔 XML이 있지만 요즘 주고받는 형식은 거의 JSON입니다.

📎 JS 교안 p.20 「비동기 통신 3단계 진화」

② XMLHttpRequest — 브라우저의 통신 객체

순서는 네 단계예요. ① new XMLHttpRequest()로 만들고, ② open("GET", 주소, true)로 준비(true = 비동기), ③ onreadystatechange에 "상태가 바뀔 때마다 부를 함수"를 등록하고, ④ send()로 보냅니다. readyState는 진행 단계로 0(만들기만 함) → 1(open 완료) → 2(헤더 받음) → 3(본문 받는 중) → 4(완료). status는 서버의 대답 코드로 200은 성공, 404는 없음, 500은 서버 오류예요. 그래서 readyState === 4 && status === 200일 때 responseText(응답 문자열)를 씁니다.

📎 JS 교안 p.20 「Step 1 XMLHttpRequest」 「readyState 값 의미」

③ JSON — 주고받는 데이터의 글자 형식

네트워크로는 글자만 오갈 수 있어서, 객체를 정해진 글자 형식으로 바꿔 보내요. 그 형식이 JSON입니다. 12장의 객체 리터럴과 닮았지만 키는 반드시 큰따옴표, 함수는 못 넣어요. JSON.parse(문자열) → JS 객체/배열(받을 때), JSON.stringify(객체) → 문자열(보낼 때, 16장 localStorage에 객체를 넣을 때). js/data.json은 [ {"id": "hk", …}, … ] 꼴의 배열이라 parse 결과도 배열이고, data[0].id처럼 꺼냅니다.

📎 JS 교안 p.20 「JSON.parse(xhr.responseText) 로 객체 변환」

④ Promise와 fetch — 콜백 지옥에서 체이닝으로

"글 목록 받고 → 그중 하나 상세 받고 → 수정"처럼 비동기를 이어서 하면 콜백 안에 콜백이 계속 들어가 콜백 지옥이 됩니다. Promise(ES6)는 "결과는 나중에 줄게"라는 약속 객체예요. 성공하면 resolve(값) → .then(값 => …), 실패하면 reject(이유) → .catch(…). then에서 return한 값은 다음 then으로 넘어가서, 중첩 대신 아래로 쭉 이어 쓸 수 있어요(체이닝).

fetch(주소)는 브라우저가 기본 제공하는 Promise 기반 통신 함수입니다. 첫 then은 응답 전체(Response)를 받고, response.json()(이것도 Promise)을 return하면 다음 then에서 객체를 받아요. 같은 일을 async/await(ES2017)로 쓰면 const data = await (await fetch(url)).json();처럼 위에서 아래로 읽히는 코드가 됩니다.

📎 JS 교안 p.20 「Step 2 Promise」 「Step 3 fetch API」 「async / await」 · p.12 「⑥ Promise + fetch」

⚠️ 교안 표현 바로잡기

교안 p.20은 fetch를 「가장 간결」하다고만 소개하지만, 하나 꼭 알아야 할 차이가 있어요. fetch는 404·500 응답이어도 실패(reject)로 보지 않습니다. 네트워크 자체가 끊겼을 때만 catch로 가요. 그래서 실무에서는 첫 then에서 if (!response.ok) throw new Error(response.status)로 직접 확인합니다. js19의 ajaxFetch에는 이 확인이 없어서, 경로가 틀리면 에러 페이지 글자를 response.json()이 해석하다 SyntaxError가 나고, 원인이 헷갈리는 메시지가 catch에 찍혀요.

예제 — 수업 코드로 확인하기

1단계 — XMLHttpRequest로 JSON 받기

이 코드는 AJAX의 가장 원래 모습(요청 준비 → 응답 감시 → 전송 → 파싱 → DOM 출력)을 한 단계씩 보여 주려고 만든 실습입니다.

JSjs19.html — ajaxGet
function ajaxGet() {
    const xhr = new XMLHttpRequest();
    let params = "num=1&id=hk";
    xhr.open("GET", `js/data.json?${params}`, true);

    xhr.onreadystatechange = function () {
        if (xhr.readyState === 4 && xhr.status == 200) {
            let data = JSON.parse(xhr.responseText);
            console.log(data[0].id, data[0].name);
            document.querySelector("#result").innerHTML =
                `<p>아이디: ${data[0].id}</p>
                 <p>이름: ${data[0].name}</p>
                 <p>주소: ${data[0].addr}</p>`;
        }
    }
    xhr.send();
}
JSONjs/data.json — 받아 올 데이터 (5개 중 1개)
[
    {
        "id": "hk",
        "name": "한경",
        "addr": "양평동"
    },
    // …(생략: 같은 내용 4개 더)
]

👀 관찰 포인트 — F12 Network 탭에서 data.json?num=1&id=hk 요청과 응답 본문을 확인하세요. 페이지는 새로고침되지 않고 #result만 바뀝니다. 코드 순서는 open → 함수 등록 → send인데, 등록한 함수는 send 후 응답이 올 때 나중에 실행돼요. readyState가 바뀔 때마다 여러 번 불리니 if로 "완료 + 성공"만 거릅니다.

2단계 — XHR을 Promise로 감싸기

이 코드는 "콜백을 등록하는 방식"을 "약속 객체를 돌려주는 방식"으로 바꾸면 무엇이 좋아지는지 보여 주려고 만든 실습입니다.

JSjs19.html — promiseTest / ajaxPromise
function promiseTest(method, url = "", data = null) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open(method, url, true);
        xhr.onreadystatechange = function () {
            if (xhr.readyState === 4) {
                if (xhr.status == 200) {
                    resolve(xhr.responseText);
                } else {
                    reject("통신실패");
                }
            }
        }
        xhr.send();
    })
}

function ajaxPromise() {
    promiseTest("GET", "js/data.json")
        .then((response) => {
            const data = JSON.parse(response); // JSON 문자열을 JS 객체로 파싱합니다.
            // …(생략: #result에 출력 — ajaxGet과 같음)
            return data;
        })
        .then((res) => {
            console.log("체이닝된 데이터 출력:", res);
        })
        .catch((error) => {
            console.error("Promise 통신 중 실패:", error);
        });
}

👀 관찰 포인트 — 통신 코드(promiseTest)와 "받은 뒤 할 일"(then)이 분리됐어요. 첫 then이 return data한 값이 둘째 then의 res로 넘어가는 게 체이닝입니다. 주소를 "js/없는파일.json"으로 바꾸면 status가 404라 reject → catch로 가요. 매개변수 url = ""는 값을 안 넘기면 쓰는 기본값 매개변수(ES6)예요.

3단계 — fetch API

이 코드는 1·2단계를 브라우저 내장 함수 하나로 줄인 최신 방식을 보여 주려고 만든 실습입니다. React에서 서버 데이터를 받을 때도 이 모양을 그대로 씁니다.

JSjs19.html — ajaxFetch
function ajaxFetch() {
    fetch("js/data.json")
        .then((response) => {
            // response.json() 역시 Promise를 반환하므로 다음 .then에서 파싱된 결과를 받게 됩니다.
            return response.json();
        })
        .then((data) => {
            document.querySelector("#result").innerHTML =
                `<p>아이디: ${data[0].id}</p>
                 <p>이름: ${data[0].name}</p>
                 <p>주소: ${data[0].addr}</p>`;
        })
        .catch((error) => {
            console.error("Fetch 통신 중 실패:", error);
        });
}

👀 관찰 포인트 — XMLHttpRequest도, JSON.parse도 보이지 않아요. response.json()이 본문을 읽어 파싱까지 해 줍니다. 단 then이 두 번 필요한 이유(본문 읽기도 비동기)를 기억하세요. 첫 then에서 return을 빠뜨리면 둘째 then의 data는 undefined가 됩니다.

⚠️ 실습할 때 조심할 곳

① js19.html을 파일 더블클릭(file://)으로 열면 세 버튼 모두 동작하지 않아요. 브라우저 보안 정책(CORS) 때문에 file:// 페이지에서는 XHR·fetch로 파일을 읽을 수 없고, 콘솔에 CORS 관련 에러가 찍힙니다. VS Code Live Server 등으로 http://127.0.0.1:5500/…처럼 로컬 서버를 거쳐 여세요. ② ajaxGet이 붙인 ?num=1&id=hk는 아무 효과가 없어요. data.json은 그냥 정적 파일이라 서버가 파라미터를 무시하고 파일 전체를 돌려줍니다. 파라미터를 읽어 알맞은 데이터를 골라 주는 건 서버 프로그램(백엔드)의 일이에요.

핵심 정리
  • AJAX = 페이지 새로고침 없이 데이터만 비동기로 받아 DOM 일부를 바꾸는 방식. 데이터 형식은 주로 JSON.
  • XHR: new → open(방식, 주소, true) → onreadystatechange 등록 → send(), 처리 조건은 readyState === 4 && status === 200.
  • JSON.parse(글자 → 객체), JSON.stringify(객체 → 글자). JSON 키는 큰따옴표 필수.
  • Promise: resolve → then, reject → catch, then의 return 값이 다음 then으로.
  • fetch(url).then(r => r.json()).then(data => …) — 404에도 reject 안 되니 response.ok 확인. 실습은 로컬 서버(http://)에서.
확인 문제
readyState === 4 만 확인하고 status 는 확인하지 않으면 어떤 문제가 생길까요?
404·500 응답도 다 받으면 readyState가 4가 됩니다. 그러면 JSON이 아닌 에러 페이지 글자를 JSON.parse하다가 SyntaxError가 나요.
console.log("A"); 다음에 xhr.send() 를 하고, 응답 콜백 안에서 console.log("B"), send 다음 줄에서 console.log("C") 를 하면 출력 순서는?
A → C → B. 비동기라서 send 후 기다리지 않고 바로 다음 줄(C)을 실행하고, 응답이 도착했을 때 콜백(B)이 실행됩니다.
JSON.parse('{name:"hk"}') 는 성공할까요?
아니요, SyntaxError입니다. JSON은 키도 큰따옴표로 감싸야 해요: '{"name":"hk"}'.
fetch로 없는 주소를 요청해서 404가 왔을 때, catch 가 바로 실행되나요?
아니요. fetch는 응답을 받기만 하면 성공으로 보고 첫 then을 실행합니다. 그래서 response.ok(200~299면 true)를 확인해 직접 에러를 던져야 catch로 가요.
다음 장으로

3부 JavaScript가 끝났어요. 다음 부에서 배울 React는 이 3부 위에 서 있습니다. 화살표 함수·구조분해·스프레드(12장), map·filter(13장), 이벤트(14장), fetch(17장)가 매일 나와요. 백엔드 쪽에서는 Controller가 JSON을 돌려주게 되는데, 그 응답을 받는 코드가 바로 이 장의 fetch입니다.

더 자세히 → AJAX(XMLHttpRequest) 통신React에서 데이터 받기(useEffect)

4부 · React

18왜 React인가 — 명령형 vs 선언형, Virtual DOM, Vite

📎 React 교안 p.1~6💻 4.react/lessons/01_react_jsx_props (index.html · main.jsx)
이 장의 목표
  • 명령형("어떻게 바꿀지"를 지시)과 선언형("어떻게 보여야 하는지"만 적기)의 차이를 예를 들어 말할 수 있다.
  • state가 바뀌면 새 Virtual DOM을 만들고 → 이전 것과 비교(diffing)하고 → 바뀐 곳만 실제 DOM에 반영한다는 흐름을 설명할 수 있다.
  • Vite 프로젝트가 index.html의 #root → main.jsx의 createRoot → App 순서로 시작된다는 것을 따라갈 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

3부 JavaScript에서는 document.getElementById로 요소를 찾고 style·textContent를 직접 바꿨어요. 버튼 하나, 문단 하나일 땐 괜찮지만, 카드가 100장이고 상태가 10가지라면 "지금 어떤 요소를 어떻게 바꿔야 하는지"를 사람이 전부 기억해야 합니다. React는 그 일을 대신 맡아 주는 라이브러리예요. 이 장은 코드를 많이 짜기 전에 "왜 쓰는가"와 "프로젝트가 어디서부터 시작되는가"만 잡고 갑니다.

개념

① 명령형 vs 선언형 — 길을 일일이 부르기 vs 목적지만 말하기

택시를 탔을 때 "우회전, 직진, 다음 골목 좌회전…"을 하나하나 부르는 게 명령형, "○○역 가 주세요"라고 목적지만 말하는 게 선언형입니다. 교안은 명령형(Vanilla JS)을 ① DOM 요소 직접 선택 ② 스타일 직접 변경 ③ 텍스트 직접 수정 ④ 이벤트 리스너 직접 연결의 4단계로 정리해요. React에서는 const [clicked, setClicked] = useState(false)처럼 상태(state)를 두고, "clicked면 이렇게 보인다"는 화면만 적어 둡니다. 버튼은 setClicked(true)로 상태만 바꾸고, 화면을 실제로 고치는 일은 React가 합니다.

📎 React 교안 p.3 「왜 React인가? — 명령형 vs 선언형」

② Virtual DOM — 설계도 두 장을 비교해 바뀐 곳만 고친다

Virtual DOM은 화면 구조를 담은 자바스크립트 객체(설계도)예요. state가 바뀌면 React는 ① 새 설계도를 만들고 ② 이전 설계도와 비교해서(diffing) 달라진 곳을 찾고 ③ 그 부분만 실제 DOM(브라우저 화면)에 반영합니다. 실제 DOM을 고치면 브라우저가 배치와 그리기를 다시 계산해야 해서 비싸기 때문에, 고칠 곳을 먼저 메모리에서 계산해 최소한으로 손대는 거예요.

📎 React 교안 p.4~5 「Virtual DOM — 어떻게 빠를 수 있는가?」

③ Vite — 개발 서버이자 빌드 도구

JSX(다음 장)는 브라우저가 바로 읽지 못하는 문법이라 누군가 번역해 줘야 합니다. 그 일을 하는 게 Vite예요. npm create vite@latest로 프로젝트를 만들고 npm install → npm run dev를 하면 localhost:5173에 개발 서버가 뜹니다. 개발 중에는 브라우저가 import 문을 따라 필요한 파일만 직접 요청하는 방식(ESM)이라 시작이 빠르고, 파일을 저장하면 바뀐 모듈만 갈아 끼웁니다(HMR). 배포할 땐 npm run build로 dist/ 폴더에 묶음 파일을 만듭니다. 이 과정의 실습은 React 19.2 + Vite 8(각 레슨 package.json)입니다.

📎 React 교안 p.6 「개발 환경 세팅 — CRA vs Vite」, p.73 「실행 방법」

④ StrictMode — 개발 모드 전용 검사기

모든 레슨의 main.jsx는 <App />를 <StrictMode>로 감싸 둡니다. StrictMode는 개발 모드에서만 컴포넌트 함수를 일부러 두 번 실행하고, effect도 "붙였다 떼었다 다시 붙이기"를 해서 숨은 부작용을 드러내요. 그래서 앞으로 콘솔 로그가 두 번씩 찍히는 장면을 자주 보게 되는데, 버그가 아니라 이 검사 때문입니다. 배포 빌드에서는 아무 영향이 없어요. 20장에서 이 검사가 실제로 버그를 잡아내는 장면을 봅니다.

📎 교안 없음 — 수업 코드(01_react_jsx_props/src/main.jsx 주석)가 교재

⚠️ 교안 표현 바로잡기

교안 p.4 제목은 「어떻게 빠를 수 있는가」지만, Virtual DOM이 직접 DOM 조작보다 항상 빠른 것은 아닙니다. 바뀔 곳 하나만 정확히 고치는 손코딩이 이론상 가장 빠르고, Virtual DOM은 오히려 비교하는 비용이 더 듭니다. 장점은 "선언형으로 편하게 짜도 바뀐 곳만 반영되어 충분히 빠르다"는 데 있어요.

교안 p.6의 「빌드 속도 매우 빠름 (ESM 기반)」에서 ESM 덕분에 빠른 것은 주로 개발 서버 시작과 HMR입니다. 배포용 npm run build는 번들러가 파일을 묶어 만듭니다. 또 CRA는 「여전히 사용됨」으로 적혀 있지만, React 팀이 2025년 2월에 새 프로젝트에는 CRA를 쓰지 말라고(지원 중단) 발표했어요. 레슨 슬라이드의 「Vite · React 18」 표기와 달리 실제 실습 코드는 React 19입니다.

예제 — 수업 코드로 확인하기

명령형으로 화면 바꾸기 — 3부에서 짠 코드 다시 보기

이 코드는 3부에서 DOM 탐색 메서드를 익히려고 짠 실습이지만, 지금 보면 교안 p.3이 말하는 명령형 4단계가 그대로 들어 있습니다. React가 무엇을 대신해 주는지 비교하려고 다시 꺼냈어요.

HTML3.javascript/js04_DOM탐색메서드.html — 명령형
<script>
    function searchId() {
        //id속성이름으로 탐색한 후 배경색을 지정
        const p = document.getElementById("idTest")   // ① 요소 직접 선택
        console.log(typeof p);

        p.style.backgroundColor = "red";              // ② 스타일 직접 변경
        p.style.color = "white";
        p.textContent = "id로 탐색 가능";              // ③ 텍스트 직접 수정
        p.innerHTML = "<b> 태그 내부 html요소 접근</b>";
    }
    // …(생략)
</script>
<!-- …(생략) -->
<button onclick="searchId()">확인</button>           <!-- ④ 이벤트 직접 연결 -->
<p id="idTest">idTest</p>

👀 관찰 포인트 — 이 코드에는 "지금 문단이 빨간 상태인가?"를 기억하는 변수가 없어요. 화면 자체가 상태입니다. 그래서 "다시 원래대로" 버튼을 만들려면 바꾼 스타일을 하나하나 되돌리는 코드를 또 짜야 해요. React는 반대로 상태 변수 하나를 두고 "이 값이면 이렇게 보인다"만 적습니다. 다음 장 ProfileCard의 {isOnline ? … : …}가 바로 그 모양이에요.

React 앱이 시작되는 길 — index.html → main.jsx

이 두 파일은 "React가 빈 div 하나에 앱 전체를 붙인다"는 것을 보여 주는 진입점입니다. 실습 중엔 거의 손대지 않지만, 앞으로 모든 레슨이 이 구조로 시작해요.

HTML01_react_jsx_props/index.html — 빈 그릇
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
JSX01_react_jsx_props/src/main.jsx — 앱을 그릇에 붙이기
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

// createRoot: React 18부터 도입된 렌더링 진입 API
// StrictMode: 개발 중 잠재적 문제(부작용, 낡은 API 사용 등)를 잡아주는 검사용 래퍼
// - 개발 중에는 컴포넌트를 일부러 2번 렌더링해서 부작용을 드러나게 함 (console.log가 2번 찍히는 이유)
createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

👀 관찰 포인트 — index.html의 body에는 빈 <div id="root"> 하나뿐입니다. 눈에 보이는 화면은 전부 자바스크립트(App)가 그려 넣어요. 그리고 script가 가리키는 파일이 .jsx라서, 이 HTML을 파일 탐색기에서 더블클릭해 열면 아무것도 안 나옵니다. 반드시 npm run dev로 Vite 서버를 띄워 열어야 해요.

핵심 정리
  • 명령형은 "어떻게 바꿀지"(선택·스타일·텍스트·이벤트)를 직접 지시하고, 선언형(React)은 state + "이 state면 이렇게 보인다"만 적는다.
  • state 변경 → 새 Virtual DOM → 이전 것과 비교(diffing) → 바뀐 곳만 실제 DOM에 반영.
  • Vite: npm run dev(개발 서버, ESM·HMR) / npm run build(배포용 묶음). 실습 버전은 React 19.2 + Vite 8.
  • 시작 경로: index.html의 #root ← createRoot(...).render(<App />). StrictMode는 개발 모드에서만 두 번 실행해 부작용을 드러낸다.
확인 문제
js04의 searchId()를 React 방식으로 바꾸면, p.style.backgroundColor = "red" 같은 줄은 어디로 가나요?
사라집니다. 대신 clicked 같은 state를 하나 두고, JSX에 "clicked면 빨간 배경"이라고 조건으로 적어요. 버튼은 setClicked(true)로 state만 바꾸고, 실제 DOM의 스타일을 고치는 일은 React가 합니다.
Virtual DOM이 있으면 실제 DOM은 아예 안 건드리나요?
아니요. 마지막에는 반드시 실제 DOM을 고쳐야 화면이 바뀝니다. Virtual DOM은 무엇을 고칠지 계산하는 비교용 설계도이고, 실제 DOM에는 비교 결과로 찾은 바뀐 부분만 반영해요.
개발 중 콘솔에 같은 로그가 두 번씩 찍혀요. 코드가 잘못된 건가요?
대부분 main.jsx의 StrictMode 때문입니다. 개발 모드에서 일부러 두 번 실행해 부작용을 찾는 검사예요. 배포 빌드(npm run build)에서는 한 번만 실행됩니다. 다만 "두 번 실행되면 결과가 달라지는 코드"라면 그건 진짜 버그이니 고쳐야 해요(20장).
다음 장으로

"화면을 선언한다"는 말은 알겠는데, 그 선언을 어떤 문법으로 적을까요? 19장에서 JSX로 화면을 적고, 화면을 컴포넌트라는 부품으로 나누고, 부품에 재료를 넣는 통로인 props를 ProfileCard 실습으로 배웁니다.

더 자세히 → React 시작하기DOM 탐색 메서드StrictMode란?리렌더링이란?

4부 · React

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

📎 React 교안 p.7~9, 14~17💻 4.react/lessons/01_react_jsx_props
이 장의 목표
  • JSX에서 { } 안에 자바스크립트 표현식을 넣고, 단일 루트·style 객체 규칙을 지킬 수 있다.
  • 컴포넌트를 "JSX를 돌려주는, 대문자로 시작하는 함수"로 만들고 props를 구조분해로 받을 수 있다.
  • 배열을 .map()으로 목록 렌더링하고, key를 왜 데이터의 id로 주는지 설명할 수 있다.
  • &&와 삼항 연산자로 조건부 렌더링을 할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

18장에서 React는 "화면을 선언한다"고 했어요. 그 선언을 적는 문법이 JSX, 선언을 부품 단위로 나눈 것이 컴포넌트, 부품에 재료를 넣는 통로가 props입니다. 01 실습은 데이터(team 배열)는 App 한 곳에만 두고, 표시 부품(ProfileCard) 하나로 카드 5장을 찍어 냅니다. 3부에서 배운 map·구조분해·삼항·&&가 전부 그대로 쓰여요.

개념

① JSX — 자바스크립트 안에 쓰는 HTML 닮은 문법

JSX는 HTML처럼 생겼지만 사실은 자바스크립트예요. Vite가 이것을 함수 호출로 번역해 줍니다. 규칙 몇 가지만 기억하면 됩니다. ① 단일 루트: 형제 태그 두 개를 나란히 return할 수 없어 <div>나 빈 태그 <>...</>(Fragment)로 감싼다. ② { } 안에는 값이 되는 식만 쓴다 — 변수, 계산, 함수 호출, 삼항, &&, map은 되지만 if·for 문은 안 된다. ③ class는 className, 인라인 스타일은 style={{ backgroundColor: 'red' }}처럼 객체 + camelCase로 쓴다(바깥 { }는 JSX 표현식, 안쪽 { }는 객체).

📎 React 교안 p.15 「01 JSX · 컴포넌트 · props — 학습 목표」

② 컴포넌트 — 붕어빵 틀

컴포넌트는 JSX를 return하는 함수입니다. 붕어빵 틀 하나(ProfileCard)를 만들어 두면 재료만 바꿔 몇 개든 찍어 낼 수 있어요. 이름은 반드시 대문자로 시작해야 합니다. 소문자로 시작하면 React가 div·p 같은 HTML 태그로 착각해요. 보통 한 파일에 한 컴포넌트를 두고 export default로 내보내고, 쓰는 쪽에서 import합니다. 이렇게 만든 컴포넌트들이 App을 꼭대기로 하는 트리를 이룹니다.

📎 React 교안 p.7 「컴포넌트란? — UI를 조각으로 분리하다」

③ props — 부모가 자식에게 건네는 읽기 전용 재료

props는 함수의 매개변수와 같아요. 부모가 <ProfileCard name="김민준" job="프론트엔드" />라고 쓰면, 자식 함수는 { name: '김민준', job: '프론트엔드' } 객체 하나를 받습니다. 이것을 function ProfileCard({ name, job })처럼 구조분해로 바로 꺼내 쓰는 게 관례예요. 데이터는 언제나 부모 → 자식 한 방향으로만 흐르고, 자식은 받은 값을 바꾸지 않고 보여 주기만 합니다. 문자열은 따옴표로, 숫자·불리언·변수는 { }로 넘겨요(age={25}).

📎 React 교안 p.8~9 「Props — 부모에서 자식으로 데이터 전달」, p.16 「props 는 위에서 아래로 흐른다」

④ 목록 렌더링(map + key)과 조건부 렌더링(&& · ? :)

team.map(member => <ProfileCard … />)은 데이터 배열을 컴포넌트 배열로 바꾸는 일이에요. 이때 각 항목에 key를 붙여야 합니다. key는 형제들 사이의 이름표라서, 순서가 바뀌거나 중간이 삭제돼도 React가 "이 카드는 아까 그 카드"를 알아봐요. 배열 순서(index)를 key로 쓰면 순서가 바뀔 때 엉뚱한 항목을 재사용해 입력값이 섞이는 버그가 생길 수 있어서, 데이터 고유의 id를 씁니다. 조건부 렌더링은 조건 && <JSX/>(참일 때만 표시)와 조건 ? <A/> : <B/>(둘 중 하나)를 씁니다. 단, && 왼쪽이 숫자 0이면 화면에 0이 찍히니, 개수로 판단할 땐 list.length > 0 &&처럼 불리언으로 만드세요.

📎 React 교안 p.15 「학습 목표」, p.17 「01 확장 과제 — key 를 지우면?」

⚠️ 교안 표현 바로잡기

교안 p.9와 ProfileCard 주석은 「자식이 props를 직접 수정하면 에러 발생」이라고 하지만, 에러가 나는지는 경우마다 달라요. 개발 모드에서 props.name = '…'처럼 props 객체 자체를 고치면 React가 얼려 둔 객체라 TypeError가 나지만, 구조분해로 받은 지역 변수 name에 다시 대입하는 건 에러 없이 됩니다. 핵심은 에러가 아니라 "자식이 바꿔 봐야 부모의 데이터는 그대로이고, 다음 렌더에서 다시 덮어써진다"는 점이에요. 값을 바꾸고 싶으면 20장의 state와, 부모가 내려준 함수를 써야 합니다.

예제 — 수업 코드로 확인하기

부모 App — 데이터를 갖고, map으로 카드를 찍어 낸다

이 코드는 "데이터는 부모가 소유하고, props로 내려 목록을 그린다"는 01 실습의 핵심 흐름(교안 p.14)을 보여 주려고 만든 것입니다.

JSX01_react_jsx_props/src/App.jsx — 데이터 + 조립
import './App.css'
import ProfileCard from './components/ProfileCard'

const team = [
  { id: 1, name: '김민준', job: '프론트엔드', emoji: '👨‍💻', bgColor: '#e6f1fb', isOnline: true, isLead: true },
  { id: 2, name: '이서연', job: '디자이너', emoji: '🎨', bgColor: '#eaf3de', isOnline: true, isLead: false },
  // …(생략: 3~4번)
  { id: 5, name: '홍길동', job: '응원단장', emoji: '🎉', bgColor: '#f3e6fb', isOnline: true, isLead: false },
]

function App() {
  return (
    <div style={{ padding: '2rem', fontFamily: 'sans-serif' }}>
      <h1>우리 팀 소개 ({team.length}명)</h1>   {/* { } 안 = JS 표현식 */}

      <div>
        {team.map((member) => (
          <ProfileCard
            key={member.id}             // 이름표: 데이터 고유 id
            name={member.name}
            job={member.job}
            emoji={member.emoji}
            bgColor={member.bgColor}
            isOnline={member.isOnline}
            isLead={member.isLead}
          />
        ))}
      </div>
    </div>
  )
}

export default App

👀 관찰 포인트 — 5번 홍길동은 교안 p.17 확장 과제 1(「team 배열에 본인 정보 추가하기」)을 한 흔적이에요. 컴포넌트는 한 줄도 안 고치고 데이터만 한 줄 추가했는데 카드가 하나 더 생기고 제목도 "(5명)"으로 바뀝니다. 이게 선언형의 장점이에요.

자식 ProfileCard — props를 받아 보여 주기만 한다

이 코드는 props 구조분해, style 객체, 두 가지 조건부 렌더링을 한 부품 안에서 연습하려고 만든 것입니다.

JSX01_react_jsx_props/src/components/ProfileCard.jsx — 표시 부품
function ProfileCard({ name, job, emoji, bgColor, isOnline, isLead }) {
    return (
        <div
            style={{
                border: '1px solid #e5e7eb',
                borderRadius: '12px',
                // …(생략)
                backgroundColor: bgColor || '#fff', // 만약 bgColor가 전달되지 않았다면 기본값으로 흰색('#fff')을 사용합니다.
                // …(생략)
            }}
        >
            {isLead && <div style={{ fontSize: '0.85rem', color: 'orange' }}>⭐ 팀 리더</div>}
            {job === '디자이너' && <div>🎨 디자인</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>

            <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

👀 관찰 포인트 — ProfileCard에는 데이터가 한 글자도 없어요. 전부 매개변수로 받습니다. isLead &&는 김민준 카드에만 "⭐ 팀 리더"를, job === '디자이너' &&는 이서연 카드에만 "🎨 디자인"을 붙이고, 삼항은 온라인/오프라인 둘 중 하나를 고릅니다. 18장 js04처럼 "요소를 찾아서 바꾸는" 코드가 없다는 것도 비교해 보세요.

핵심 정리
  • JSX: 단일 루트(또는 <>...</>), { } 안엔 표현식만, className, style은 camelCase 객체.
  • 컴포넌트 = 대문자로 시작하는, JSX를 return하는 함수. 한 파일 한 컴포넌트 + export default.
  • props = 부모 → 자식 단방향, 읽기 전용. 자식은 ({ name, job })처럼 구조분해로 받는다.
  • 목록은 .map() + 데이터 고유 id로 key. 조건부는 &&(있거나 없거나) / ? :(둘 중 하나).
확인 문제
ProfileCard 안에서 key 값을 화면에 찍어 보려고 받으면 어떻게 될까요?
undefined입니다. key는 React가 목록 항목을 구별하려고 자기만 쓰는 특별한 속성이라 props로 전달되지 않아요. id가 화면에 필요하면 id={member.id}로 따로 넘겨야 합니다.
함수 이름을 profileCard(소문자)로 바꿔 쓰면 무슨 일이 생기나요?
JSX에서 소문자로 시작하는 태그는 HTML 태그로 취급되어 profileCard라는 알 수 없는 태그가 만들어지고, 우리가 만든 함수는 실행되지 않습니다. 카드 내용이 안 나오고 콘솔에 경고가 떠요. 컴포넌트 이름은 반드시 대문자로 시작합니다.
App에서 bgColor를 빼고 넘기면 카드 배경은 어떻게 되나요?
자식의 bgColor는 undefined가 되고, bgColor || '#fff' 때문에 흰색이 됩니다. 넘기지 않은 prop은 에러가 아니라 undefined라는 것, 그래서 기본값 처리가 필요하다는 걸 보여 주는 줄이에요.
다음 장으로

지금 카드는 보여 주기만 합니다. 버튼을 눌러도 아무것도 안 바뀌죠. props는 부모가 주는 읽기 전용 값이기 때문이에요. 20장에서는 컴포넌트가 스스로 기억하고 바꾸는 값(state), 클릭·입력 이벤트, 그리고 화면 그리기 뒤에 할 일을 맡는 useEffect를 배웁니다.

더 자세히 → JSX · 컴포넌트 · propsJSX란?컴포넌트란?props란?key와 리스트 렌더링구조분해 할당삼항연산자와 &&

4부 · React

20state · 이벤트 · useEffect — 카운터와 할 일 목록

📎 React 교안 p.10~11, 18~21💻 4.react/lessons/02_state_events_todo
이 장의 목표
  • useState로 상태를 만들고, set 함수로만 바꿔야 화면이 다시 그려진다는 것을 설명할 수 있다.
  • onClick에 함수를 넘기는 것과 함수를 호출한 결과를 넘기는 것을 구별할 수 있다.
  • 배열 state를 ...·filter·map으로 새 배열을 만들어 바꿀 수 있다(불변성).
  • useEffect가 첫 마운트에 1번 + 의존성 값이 바뀔 때마다 실행된다는 것과, StrictMode에서 두 번 보이는 이유를 말할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

19장의 ProfileCard는 받은 props를 보여 주기만 했어요. 화면이 변하려면 컴포넌트가 스스로 기억하는 값이 필요합니다. 그게 state예요. 18장에서 말한 "state만 바꾸면 화면은 React가"를 이번에 직접 해 봅니다. 그리고 할 일 목록을 새로고침해도 남게 하려면 "화면을 다 그린 뒤 저장소에 적어 두는" 일도 필요한데, 그 담당이 useEffect입니다.

개념

① state — 바뀌면 화면을 다시 그리는 메모장

const [count, setCount] = useState(0)은 "0으로 시작하는 메모장 하나와, 그 메모장을 고치는 펜"을 받는 문법이에요(배열 구조분해). setCount(1)을 부르면 React가 컴포넌트 함수 전체를 다시 실행하고(리렌더), 새로 나온 JSX를 18장의 방식으로 비교해 바뀐 곳만 고칩니다. 그냥 let count = 0; count++로는 안 돼요. React는 바뀐 걸 모르고, 함수가 다시 실행되면 0으로 처음부터 시작하니까요.

📎 React 교안 p.10 「useState — 컴포넌트에 기억을 부여하다」, p.20 「state 가 바뀌면 화면이 다시 그려진다」

② 이벤트 핸들러와 제어 컴포넌트

onClick에는 "눌렸을 때 실행할 함수"를 넘겨요. onClick={() => setCount(count - 1)}은 미니 함수를 넘기는 것이고, onClick={setCount(count - 1)}는 렌더하는 도중에 바로 호출해 버립니다. 그러면 state 변경 → 다시 렌더 → 또 호출…이 반복되어 React가 Too many re-renders 에러로 멈춰요. 입력창은 value={input} + onChange={(e) => setInput(e.target.value)}로 묶어서 state가 입력창의 진짜 값이 되게 합니다. 이것을 제어 컴포넌트라고 해요.

📎 React 교안 p.19 「02 state · 이벤트 · useEffect — 학습 목표」

③ 불변성 — 고치지 말고 새로 만들어 바꿔 끼운다

React는 "이전 state와 새 state가 같은 것(같은 참조)인가?"로 바뀌었는지 판단해요. todos.push(x) 후 setTodos(todos)를 하면 같은 배열이라 "안 바뀌었네" 하고 건너뜁니다. 그래서 추가는 [...todos, x], 삭제는 filter, 수정은 map + { ...t, done: !t.done }처럼 새 배열·새 객체를 만들어 넘겨요. 3부에서 배운 스프레드는 "얕은 복사"라서, 바꾸지 않은 항목은 원래 객체를 그대로 재사용합니다.

📎 React 교안 p.20 「배열 상태는 '불변성'으로 바꾼다」

④ useEffect — 화면을 그린 뒤에 하는 일

저장·타이머·서버 요청처럼 화면 그리기와 상관없는 일(부수 효과)은 useEffect에 넣어요. 실행 시점은 두 번째 인자(의존성 배열)로 정합니다. 없음 → 렌더할 때마다. [] → 첫 마운트 때 1번. [todos] → 첫 마운트 때 1번 + todos가 바뀔 때마다. effect가 함수를 return하면 그것이 cleanup(정리 함수)이고, 다음 effect가 실행되기 직전과 컴포넌트가 사라질 때 실행돼요(22장에서 본격적으로 씁니다). 개발 모드의 StrictMode는 마운트 직후 "정리했다가 다시 실행"을 한 번 더 해서, effect가 두 번 실행돼도 안전한지 검사합니다.

📎 React 교안 p.11 「useEffect — 렌더링 이후의 작업 처리」, p.20 「useEffect 의존성」

⚠️ 교안 표현 바로잡기

교안 p.11은 [count]를 「특정 값 변경 시」라고만 적었는데, 첫 마운트 때도 1번 실행됩니다. 교안 예제를 실행하면 아무것도 안 눌러도 document.title이 이미 「카운트: 0」인 이유예요. 또 p.11과 마스터 가이드의 「[] = 마운트 시 1번만」은 배포 빌드 기준이고, 개발 모드(StrictMode)에서는 두 번 실행되는 것이 정상입니다. 그러니 "1번만 실행되겠지"에 기대지 말고 두 번 실행돼도 결과가 같게 짜야 해요.

예제 — 수업 코드로 확인하기

Counter — 숫자 state 하나로 화면 바꾸기

이 코드는 "setState → 함수 재실행 → 새 화면" 사이클(교안 p.20)을 가장 작은 단위로 보여 주려고 만든 것입니다.

JSX02_state_events_todo/src/components/Counter.jsx — 단일 값 state
import { useState } from 'react'

function Counter() {
    const [count, setCount] = useState(0);

    // count가 바뀔 때마다 컴포넌트가 재실행되므로, getColor()도 새 count 기준으로 다시 계산됨
    const getColor = () => {
        if (count > 0) return '#185fa5' // 양수일 때 파란색
        if (count < 0) return '#a32d2d' // 음수일 때 붉은색
        return '#1a1a18'                // 0일 때 검은색
    }
    // …(생략: btnStyle)
    return (
        <div style={{ textAlign: 'center', padding: '2rem' }}>
            <h1 style={{ fontSize: '4rem', color: getColor(), margin: '0 0 1rem', transition: 'color .2s' }}>
                {count}
            </h1>
            <div>
                <button style={btnStyle} onClick={() => setCount(count - 1)}>−</button>
                <button style={btnStyle} onClick={() => setCount(0)}>초기화</button>
                <button style={btnStyle} onClick={() => setCount(count + 1)}>+</button>
            </div>
            <p style={{ marginTop: '1.2rem', color: '#888' }}>
                {count > 0 ? '양수입니다 😊' : count < 0 ? '음수입니다 😅' : '0입니다 😐'}
            </p>
        </div>
    )
}

👀 관찰 포인트 — 색을 바꾸는 코드가 "요소를 찾아서 color를 바꿔라"가 아니라 "count가 양수면 파랑"이라는 계산이라는 점을 보세요. +를 누르면 setCount → Counter 함수가 처음부터 다시 실행 → getColor()·삼항이 새 count로 다시 계산 → 바뀐 숫자·색·문구만 화면에 반영됩니다.

TodoApp — 배열 state + 불변성 + localStorage 저장

이 코드는 배열 state를 불변성으로 바꾸는 세 가지 패턴과, useEffect로 state를 저장소에 동기화하는 방법을 한 번에 연습하려고 만든 것입니다.

JSX02_state_events_todo/src/components/TodoApp.jsx — 배열 + 저장
import { useState, useEffect } from 'react'

function TodoApp() {
    // 게으른 초기화: 처음 렌더 때 딱 1번만 localStorage에서 읽어 시작값으로 삼는다
    const [todos, setTodos] = useState(() => {
        const saved = localStorage.getItem('todos')
        return saved ? JSON.parse(saved) : []
    })
    const [priority, setPriority] = useState('normal')
    const [input, setInput] = useState('')
    // …(생략: filter state)

    // 첫 마운트 1번 + todos가 바뀔 때마다: 화면을 그린 뒤 저장
    useEffect(() => {
        localStorage.setItem('todos', JSON.stringify(todos))
    }, [todos])

    const addTodo = () => {
        if (!input.trim()) return
        setTodos([...todos, { id: Date.now(), text: input, done: false, priority }])   // 추가: 새 배열
        setInput('');
    }
    const removeTodo = (id) => {
        setTodos(todos.filter((t) => t.id !== id));                                      // 삭제: filter
    }
    const toggleTodo = (id) => {
        setTodos(todos.map((t) => t.id === id ? { ...t, done: !t.done } : t))          // 수정: map + 새 객체
    }
    // …(생략)
            <input
                value={input}
                onChange={(e) => setInput(e.target.value)}
                onKeyDown={(e) => e.key === 'Enter' && addTodo()}
                placeholder="할 일을 입력하세요..."
                // …(생략)
            />

👀 관찰 포인트 — 할 일을 추가하고 새로고침해도 목록이 남아요. 개발자 도구(F12) → Application → Local Storage에서 todos 키를 열어 보면, 체크박스를 누를 때마다 값이 바로 바뀌는 게 보입니다. 맨 아래 todos.length > 0 &&(19장의 "0이 찍히는 함정" 피하기)도 찾아보세요.

⚠️ 수업 코드가 피해 간 함정 — StrictMode 이중 마운트 버그

저장소에서 읽는 것도 effect로 하고 싶어서 useState([])로 시작한 뒤 불러오기 effect useEffect(() => { … setTodos(JSON.parse(saved)) }, [])와 저장 effect [todos]를 따로 두면, 개발 모드에서 기존 목록이 통째로 사라집니다. 순서는 이래요. ① 첫 렌더의 todos는 []. ② 마운트 effect 실행: 불러오기가 기존 목록을 읽어 반영을 예약하지만, 바로 이어서 저장 effect가 아직 []인 todos를 저장소에 덮어씀. ③ StrictMode가 effect를 한 번 더 실행: 불러오기가 저장소를 다시 읽는데 이제 []뿐. ④ 결국 todos는 [].

수업 코드는 useState(() => …읽기…) 게으른 초기화로 "todos가 비어 있는 순간" 자체를 없애고 effect는 저장 하나만 둬서 이 함정을 피했어요. StrictMode를 끄는 게 답이 아니라, 두 번 실행돼도 안전한 구조로 짜는 게 답입니다.

핵심 정리
  • const [값, set값] = useState(초기값) — set 함수를 불러야 컴포넌트가 다시 실행되고 화면이 바뀐다.
  • 이벤트에는 함수를 넘긴다: onClick={() => setCount(count + 1)}. 입력창은 value + onChange로 state와 묶는다(제어 컴포넌트).
  • 배열·객체 state는 새로 만들어 교체: [...arr, x] / filter / map + {...obj}.
  • useEffect: 렌더 뒤 실행. []는 첫 마운트 1번, [x]는 첫 마운트 1번 + x가 바뀔 때마다. 개발 모드에선 StrictMode 때문에 마운트 때 두 번.
확인 문제
onClick={setCount(count + 1)}이라고 쓰면 어떻게 되나요?
버튼을 누르기도 전에, 렌더하는 도중 setCount가 호출됩니다. state가 바뀌니 다시 렌더 → 또 호출…이 끝없이 반복되어 Too many re-renders 에러가 나요. () => setCount(count + 1)처럼 함수로 감싸서 넘겨야 합니다.
todos.push(새항목); setTodos(todos)로 추가하면 화면은 어떻게 되나요?
바뀌지 않습니다. 배열 내용은 늘었지만 같은 배열(같은 참조)을 넘겼기 때문에 React가 "이전과 같다"고 보고 리렌더를 건너뛰어요. setTodos([...todos, 새항목])처럼 새 배열을 넘겨야 합니다.
TodoApp을 처음 열었을 때, 아무것도 안 눌렀는데 저장 effect가 실행되나요?
네. 의존성 배열이 있는 effect도 첫 마운트 때 1번은 실행됩니다. 그래서 처음 열자마자 현재 todos(저장소에서 읽어 온 값)가 다시 저장돼요. 값이 같으니 문제는 없습니다. 개발 모드라면 StrictMode 때문에 두 번 실행돼요.
useState(() => …) 대신 useState(JSON.parse(localStorage.getItem('todos')) || [])라고 쓰면 무엇이 다른가요?
처음 결과는 같지만, 이렇게 쓰면 괄호 안의 식이 렌더할 때마다(글자 하나 칠 때마다) 실행됩니다. React는 두 번째 렌더부터 그 값을 버리는데도 매번 저장소를 읽고 파싱하는 거예요. 함수로 넘기면(게으른 초기화) 첫 렌더에만 실행됩니다.
다음 장으로

"state가 바뀌면 컴포넌트 함수 전체가 다시 실행된다"는 걸 배웠어요. 그런데 그러면 함수 안의 무거운 계산도, 함수 안에서 만든 함수도 매번 새로 만들어지고, 자식 컴포넌트도 전부 다시 그려집니다. 21장에서는 다시 할 필요 없는 일을 건너뛰는 성능 훅 useRef · useMemo · useCallback과 React.memo를 배웁니다.

더 자세히 → state와 이벤트state와 useState불변성제어 컴포넌트"즉시 실행" 무한 루프useEffect란?StrictMode 이중 마운트 버그

4부 · React

21성능 훅 — useRef · useMemo · useCallback · React.memo

📎 React 교안 p.12~13, 22~25💻 4.react/lessons/03_pref_hooks
이 장의 목표
  • useRef로 DOM에 접근하고, 바꿔도 리렌더가 일어나지 않는 값을 보관할 수 있다.
  • useMemo는 계산된 값을, useCallback은 함수를 의존성이 바뀔 때까지 재사용한다는 것을 구별할 수 있다.
  • React.memo로 감싼 자식이 렌더를 건너뛰려면 props가 이전과 같은 참조여야 하고, 그래서 useCallback이 짝이라는 것을 콘솔 로그로 확인할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

20장에서 state가 바뀌면 컴포넌트 함수 전체가 다시 실행된다고 했어요. 대부분은 이래도 충분히 빠르지만, 무거운 정렬을 매번 다시 하거나 행 100개가 매번 다시 그려지는 건 낭비죠. 이 장의 네 도구는 모두 "다시 할 필요 없는 일을 건너뛰는" 장치입니다. 03 실습은 곳곳에 console.log를 심어 두고, 훅을 넣었다 빼 보며 언제 다시 실행되는지를 눈으로 확인하는 실습이에요.

개념

① useRef — 화면에 안 보이는 뒷메모장, 그리고 DOM을 가리키는 손가락

useRef(초기값)은 { current: 초기값 } 상자 하나를 주고, 리렌더가 돼도 같은 상자를 계속 돌려줘요. current를 바꿔도 리렌더가 일어나지 않습니다. 쓰임은 둘이에요. ① DOM 접근: <input ref={inputRef} />로 연결하면 inputRef.current가 실제 input 요소라서 .focus()를 부를 수 있어요(포커스·스크롤·영상 재생처럼 "물리적인" 조작). ② 렌더와 무관한 값 보관: 타이머 id, 렌더 횟수 등. 판단 기준은 간단해요 — 화면에 보여야 하는 값은 state, 기억만 하면 되는 값은 ref.

📎 React 교안 p.12 「useRef — DOM 접근과 값 보존」, p.25 「확장 과제 2」

② useMemo — 어제 계산해 둔 답을 다시 쓰기

useMemo(() => 계산, [의존성])은 계산 결과를 기억해 뒀다가, 다음 렌더에서 의존성이 이전과 같으면 계산을 건너뛰고 기억한 값을 돌려줍니다. 의존성 중 하나라도 바뀌면 그때 다시 계산해요. 비싼 필터·정렬에 쓰고, 덧셈 같은 가벼운 계산에는 쓰지 않습니다(비교하는 비용이 더 들 수 있어요).

📎 React 교안 p.13 「useMemo & useCallback — 연산·함수 재생성 방지」, p.24 「무엇을 캐싱하고 언제 갱신되나」

③ React.memo + useCallback — 검문소와 통행증

memo(컴포넌트)로 감싸면 부모가 다시 그려질 때 자식 앞에 검문소가 생겨요. 이전 props와 새 props를 하나씩 비교해서 전부 같으면 렌더를 건너뜁니다. 숫자·문자열은 값으로 비교되지만, 함수·객체·배열은 참조(주소)로 비교돼요. 그런데 부모 안에서 만든 함수는 렌더마다 새로 만들어져 주소가 달라지니, 검문소가 "바뀌었네" 하고 통과시켜 버립니다. useCallback(fn, [])은 함수를 기억해 두고 같은 주소를 계속 돌려주는 통행증이에요. 그래서 둘은 한 세트입니다. 이때 함수 안에서 최신 state가 필요하면 setFavorites((prev) => …)처럼 업데이터 함수를 써서, 의존성을 []로 두어도 최신 값을 읽게 합니다.

📎 React 교안 p.13, p.24 「핵심 연결」, p.25 「확장 과제 1 — useCallback 을 제거하면?」

⚠️ 교안 표현 바로잡기

교안 p.13은 「함수나 계산값이 매번 새로 만들어지면 자식 컴포넌트가 불필요하게 재렌더링됩니다」라고 하지만, 정확히는 memo로 감싸지 않은 자식은 props가 같든 다르든 부모가 렌더되면 무조건 다시 렌더돼요. "새 함수 때문에 다시 렌더된다"는 건 memo로 감싼 자식에게만 해당하는 이야기입니다. 그래서 useCallback 혼자서는 아무 효과가 없고, memo 자식에게 넘길 때 비로소 의미가 생겨요.

예제 — 수업 코드로 확인하기

StockSearch — useRef의 두 가지 쓰임

이 코드는 useRef의 두 용도(DOM 손가락 · 렌더 무관 메모장)를 한 컴포넌트에서 보여 주려고 만든 것입니다.

JSX03_pref_hooks/src/components/StockSearch.jsx — useRef
export default function StockSearch() {
    const [query, setQuery] = useState('')
    const [results, setResults] = useState([])

    const inputRef = useRef(null)      // 용도①: 실제 <input> 요소를 가리킬 손가락

    const renderCount = useRef(0)      // 용도②: 리렌더를 일으키지 않는 메모장
    renderCount.current += 1           // 이 함수가 실행될 때마다 1씩 증가
    // …(생략: handleSearch)

    const handleClear = () => {
        setQuery('')
        setResults([])
        inputRef.current.focus()       // 실제 DOM 요소에 포커스
    }

    return (
        <div style={{ padding: '1rem', maxWidth: '500px' }}>
            <p style={{ fontSize: '12px', color: '#888' }}>렌더링 횟수: {renderCount.current}</p>
            <div style={{ display: 'flex', gap: '8px' }}>
                <input
                    ref={inputRef}
                    value={query}
                    onChange={handleSearch}
                    // …(생략)
                />
                <button onClick={handleClear}>지우기</button>
            </div>
            {/* …(생략: 결과 목록) */}
        </div>
    )
}

👀 관찰 포인트 — 글자를 칠 때마다(setQuery) "렌더링 횟수"가 올라가지만, renderCount.current += 1 자체는 리렌더를 일으키지 않아요. 숫자가 화면에 보이는 건 다른 state 때문에 렌더될 때 같이 얹혀 나오기 때문입니다. 이걸 useState로 바꾸면 렌더 중 setState → 다시 렌더 → 또 setState… 무한 루프가 됩니다(교안 p.25). 참고로 개발 모드에선 StrictMode가 함수를 두 번 실행하므로 숫자가 실제보다 크게 늘어 보일 수 있어요. 렌더 중에 ref를 바꾸는 건 관찰용 장치일 뿐, 실제 코드에선 피하는 게 좋습니다.

StockList + StockRow — useMemo · useCallback · memo 3총사

이 코드는 "별(★) 하나 눌렀을 때 무엇이 다시 실행되고 무엇이 건너뛰어지는가"를 콘솔 로그로 확인하려고 만든 실습입니다.

JSX03_pref_hooks/src/components/StockList.jsx — 상태 + 캐싱
export default function StockList() {
    const [filter, setFilter] = useState('all')
    const [sortBy, setSortBy] = useState('symbol')
    const [favorites, setFavorites] = useState([])

    const processedStocks = useMemo(() => {
        console.log('정렬/필터 재계산!')
        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])      // 별(favorites)은 의존성에 없다

    const toggleFavorite = useCallback((symbol) => {
        setFavorites((prev) =>
            prev.includes(symbol) ? prev.filter((s) => s !== symbol) : [...prev, symbol]
        )
    }, [])                    // 처음 만든 함수 참조를 계속 재사용
    // …(생략)
                {processedStocks.map((stock) => (
                    <StockRow
                        key={stock.id}
                        stock={stock}
                        isFavorite={favorites.includes(stock.symbol)}
                        onToggleFavorite={toggleFavorite}
                    />
                ))}
JSX03_pref_hooks/src/components/StockRow.jsx — memo로 감싼 행
const StockRow = memo(function StockRow({ stock, isFavorite, onToggleFavorite }) {
    console.log(`${stock.symbol} 렌더링`)
    // …(생략)
            <button onClick={() => onToggleFavorite(stock.symbol)} /* …(생략) */>
                {isFavorite ? '★' : '☆'}
            </button>
    // …(생략)
})

👀 관찰 포인트 — F12 콘솔을 열고 AAPL의 별을 누르면 "AAPL 렌더링" 한 줄만 찍히고 "재계산!"은 안 찍힙니다(개발 모드에선 StrictMode 때문에 같은 로그가 두 번 보일 수 있어요). 이제 하나씩 빼 보세요. ① useMemo를 빼면 별을 누를 때마다 "정렬/필터 재계산!"만 추가로 찍히고, 행 렌더 로그는 여전히 한 줄이에요. 새 배열이 만들어져도 그 안의 stock 객체는 STOCKS 원본과 같은 참조라서 StockRow의 검문소를 통과하기 때문입니다. ② useCallback을 빼면 별 하나만 눌러도 10개 종목 전부 "렌더링" 로그가 찍혀요(교안 p.25). onToggleFavorite가 매번 새 주소라 검문소가 다 뚫린 거죠. 즉 행을 지킨 건 useMemo가 아니라 memo + useCallback입니다.

핵심 정리
  • useRef: .current를 바꿔도 리렌더 X. DOM 접근 + 렌더와 무관한 값 보관. "화면에 보일 값 = state, 기억만 할 값 = ref".
  • useMemo = 값 캐싱, useCallback = 함수 캐싱. 둘 다 의존성 배열이 바뀔 때만 새로 만든다.
  • React.memo: props를 하나씩 비교(함수·객체는 참조로)해서 같으면 자식 렌더를 건너뛴다. 함수 prop은 useCallback으로 고정해야 효과가 있다 — 둘은 한 세트.
  • 남용 금지: 느린 곳을 확인했을 때만 쓴다(교안 p.13).
확인 문제
StockList에서 useMemo를 지웠는데도 별을 누를 때 행 렌더 로그가 한 줄뿐인 이유는?
StockRow가 받는 stock은 매번 새로 만든 배열 안에 들어 있지만, 객체 자체는 STOCKS의 원래 객체(같은 참조)예요. onToggleFavorite도 useCallback으로 고정돼 있고요. 그래서 클릭한 종목(isFavorite만 바뀜)을 빼면 props가 모두 같아 memo가 건너뜁니다. useMemo가 아낀 건 정렬 계산뿐이에요.
toggleFavorite의 의존성 배열이 []인데, 어떻게 최신 favorites를 알 수 있나요?
함수 안에서 favorites 변수를 직접 읽지 않고 setFavorites((prev) => …) 업데이터 함수를 쓰기 때문이에요. React가 prev에 항상 최신 state를 넣어 주므로, 함수를 한 번만 만들어도 오래된 값을 쓰는 문제(stale closure)가 없습니다.
StockRow에서 memo(...)만 벗기고 useCallback은 그대로 두면?
별 하나만 눌러도 10개 행이 전부 다시 렌더됩니다. memo가 없으면 검문소 자체가 없어서, 부모가 렌더되면 자식은 props와 상관없이 무조건 다시 렌더되기 때문이에요. useCallback은 memo가 있을 때만 의미가 있습니다.
다음 장으로

지금까지 useState·useEffect·useRef·useCallback을 배웠어요. 그런데 "state + effect" 조합을 여러 컴포넌트에서 똑같이 쓰고 싶다면? 20장의 "localStorage에서 읽고 바뀌면 저장" 패턴처럼요. 22장에서는 이런 조합을 use로 시작하는 함수(커스텀 훅)로 꺼내 재사용합니다.

더 자세히 → 성능 훅 3종useRef란?useMemo·useCallbackReact.memo란?리렌더링이란?stale closure

4부 · React

22커스텀 훅 — 반복되는 state + effect를 함수로 꺼내기

📎 React 교안 p.26~29💻 4.react/lessons/04_custom_hooks
이 장의 목표
  • 커스텀 훅이 use로 시작하는 평범한 함수이고, 안에서 다른 훅을 써서 로직을 재사용한다는 것을 설명할 수 있다.
  • cleanup(정리 함수)이 다음 effect 직전과 언마운트 때 실행된다는 것을 useDebounce·useInterval로 설명할 수 있다.
  • 같은 훅을 두 번 부르면 state가 두 벌 생긴다(공유되지 않는다)는 것을 안다.
  • 네 개의 훅을 StockDetail 한 곳에서 조합하는 흐름을 따라갈 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

20장 TodoApp에서 "저장소에서 읽어 시작하고(useState(() => …)) + 바뀌면 저장(useEffect)"을 짰죠. 다른 컴포넌트에서도 쓰려면 그 코드를 통째로 복사해야 해요. 커스텀 훅은 이런 "state + effect" 묶음을 함수로 꺼낸 것입니다. 04의 useLocalStorage가 정확히 TodoApp의 패턴을 꺼낸 것이고, 21장의 useRef·useCallback도 실전에서 다시 등장해요.

개념

① 커스텀 훅 — 새 문법이 아니라 이름 규칙 하나

커스텀 훅은 이름이 use로 시작하고, 안에서 다른 훅을 부르는 함수예요. 이름 규칙이 중요한 이유는 React와 ESLint가 이 이름으로 "훅 규칙"을 검사하기 때문입니다. 훅은 컴포넌트(또는 다른 훅)의 맨 위에서만 불러야 하고, if·반복문 안에서 부르면 안 돼요. 돌려주는 모양은 자유라서 useLocalStorage는 useState처럼 [값, 바꾸는 함수]를, useStockData는 { data, loading, error, refetch }를 돌려줍니다. 중요한 점: 커스텀 훅은 로직을 재사용하는 것이지 state를 공유하는 게 아니에요. 두 번 부르면 state도 두 벌입니다.

📎 React 교안 p.27 「04 커스텀 훅 — 학습 목표」, p.28 「훅 4개를 레고처럼 조합한다」

② cleanup — 다음 일을 시작하기 전에 뒷정리

useEffect가 함수를 return하면 React가 그것을 보관해 뒀다가 ① 의존성이 바뀌어 effect가 다시 실행되기 직전, ② 컴포넌트가 화면에서 사라질 때 실행해요. 타이머·구독·연결처럼 "켜 둔 것"을 끄는 자리입니다. useDebounce는 글자를 칠 때마다 이전 타이머를 clearTimeout으로 지워서, 입력이 멈춘 뒤 마지막 값 하나만 반영되게 해요. 20장의 StrictMode 재실행(정리 → 다시 실행)도 cleanup이 제대로 있으면 타이머가 하나만 남아 안전합니다. 웹소켓을 닫는 ws.close()도 같은 원리예요(Q&A Q10).

📎 React 교안 p.27 「useDebounce : cleanup 으로 마지막 값만 반영」

③ useRef로 "최신 콜백" 들고 있기 — useInterval

타이머 effect의 의존성에 콜백을 넣으면, 콜백이 바뀔 때마다 타이머가 지워졌다 새로 생겨서 5초가 다 차기 전에 리셋될 수 있어요. useInterval은 콜백을 ref에 최신으로 적어 두기만 하고, 타이머는 delay가 바뀔 때만 다시 겁니다. 타이머는 매번 savedCallback.current()를 불러 항상 최신 함수를 실행해요. 또 delay가 null이면 타이머를 아예 걸지 않는 "정지 스위치"를 둬서, 훅을 if 안에서 부르지 않고도 켜고 끌 수 있게 했어요.

📎 React 교안 p.27 「useInterval : ref 로 최신 콜백 유지, null=정지」

④ useStockData — 로딩·에러·데이터를 한 묶음으로

서버에서 데이터를 받을 땐 늘 "로딩 중인가 / 에러가 났나 / 데이터가 왔나" 세 가지 상태가 필요해요. useStockData는 이것을 { data, loading, error } state 하나로 묶고, 다시 불러오는 refetch까지 돌려줍니다. 불러오는 함수 load를 useCallback(…, [symbol])로 감싸서 symbol이 바뀔 때만 새 함수가 되게 하고, effect의 의존성에 [load]를 정직하게 넣어도 무한 실행이 없게 했어요(교안 p.29 과제 2).

📎 React 교안 p.27 「useStockData : 로딩/에러/refetch 캡슐화」, p.29 「확장 과제 2」

예제 — 수업 코드로 확인하기

useDebounce · useInterval — cleanup이 하는 일

이 두 훅은 "cleanup으로 이전 타이머를 지운다"는 것과 "ref로 최신 콜백을 들고 있는다"는 것을 보여 주려고 만든 것입니다.

JS04_custom_hooks/src/hooks/useDebounce.js — 멈춘 뒤 마지막 값만
import { useState, useEffect } from 'react'

export function useDebounce(value, delay = 300) {
    const [debouncedValue, setDebouncedValue] = useState(value)

    useEffect(() => {
        const timer = setTimeout(() => setDebouncedValue(value), delay)
        return () => clearTimeout(timer)     // cleanup: 다음 글자가 오면 이전 예약 취소
    }, [value, delay])

    return debouncedValue
}
JS04_custom_hooks/src/hooks/useInterval.js — 주기 실행
import { useEffect, useRef } from 'react'

export function useInterval(callback, delay) {
    const savedCallback = useRef(callback)

    useEffect(() => {
        savedCallback.current = callback     // 최신 콜백을 메모장에 기록 (타이머는 그대로)
    }, [callback])

    useEffect(() => {
        if (delay === null) return           // 정지 스위치
        const id = setInterval(() => savedCallback.current(), delay)
        return () => clearInterval(id)       // cleanup: delay가 바뀌거나 사라질 때 타이머 제거
    }, [delay])
}

👀 관찰 포인트 — 입력창의 AAPL을 지우고 TSLA를 빠르게 치면 "⏳ TSLA 로딩 중"이 한 번만 나옵니다. return () => clearTimeout(timer)를 지우면 지우는 중간값(AAP, AA, A)과 T, TS, TSL, TSLA가 각각 0.3초 뒤에 차례로 반영되어 요청이 여러 번 나가요. 디바운스가 "지연"만 되고 "합치기"는 안 되는 거죠.

useLocalStorage · useStockData를 StockDetail에서 조합하기

이 코드는 TodoApp의 저장 패턴을 훅으로 꺼낸 모습과, 훅 4개를 레고처럼 끼워 한 화면을 완성하는 과정(교안 p.28)을 보여 주려고 만든 것입니다.

JS04_custom_hooks/src/hooks/useLocalStorage.js — useState처럼 쓰는 자동 저장
export function useLocalStorage(key, initialValue) {
    const [value, setValue] = useState(() => {          // 20장 TodoApp의 게으른 초기화와 같은 모양
        try {
            const item = localStorage.getItem(key)
            return item ? JSON.parse(item) : initialValue
        } catch (err) {
            console.warn('localStorage 읽기 실패:', err)
            return initialValue
        }
    })

    useEffect(() => {                                    // 20장 TodoApp의 저장 effect와 같은 모양
        try {
            localStorage.setItem(key, JSON.stringify(value))
        } catch (err) {
            console.warn('localStorage 쓰기 실패:', err)
        }
    }, [value, key])

    return [value, setValue]                             // useState와 똑같은 모양으로 돌려줌
}
JS04_custom_hooks/src/hooks/useStockData.js — 로딩·에러·refetch
export function useStockData(symbol) {
    const [state, setState] = useState({ data: null, loading: true, error: null })

    const load = useCallback(() => {
        setState((prev) => ({ ...prev, loading: true, error: null }))
        fakeFetch(symbol)                                // 0.8~1.2초 뒤 응답하는 가짜 API
            .then((data) => setState((prev) => ({ ...prev, data, loading: false, error: null })))
            .catch((err) => setState({ data: null, loading: false, error: err.message }))
    }, [symbol])

    useEffect(() => {
        if (!symbol) {
            setState({ data: null, loading: false, error: null })
            return
        }
        load()
    }, [load])

    return { ...state, refetch: load }
}
JSX04_custom_hooks/src/components/StockDetail.jsx — 4개 훅 조합
export default function StockDetail() {
    const [inputSymbol, setInputSymbol] = useLocalStorage('lastSymbol', 'AAPL')
    const [autoRefresh, setAutoRefresh] = useLocalStorage('autoRefresh', false)
    const debouncedSymbol = useDebounce(inputSymbol, 300)
    const { data, loading, error, refetch } = useStockData(debouncedSymbol)
    useInterval(refetch, autoRefresh ? 5000 : null)
    // …(생략: 화면)
}

👀 관찰 포인트 — StockDetail 본문은 다섯 줄이고 effect·타이머·저장소 코드가 하나도 없어요. 전부 훅 안에 숨었습니다. useLocalStorage를 두 번 불렀는데 종목과 체크박스가 서로 다른 state라는 것도 확인하세요. 새로고침해도 둘 다 유지되고, ERROR를 입력하면 에러 상자가, 체크박스를 켜면 5초마다 "업데이트" 시각이 바뀝니다. 체크박스(autoRefresh)를 useLocalStorage로 기억하는 건 교안 p.29 과제 1을 한 결과예요.

⚠️ 수업 코드에서 조심할 곳

useStockData의 effect에는 cleanup이 없어요. 가짜 API의 응답 시간이 0.8~1.2초로 들쭉날쭉해서, AAPL 요청이 나간 뒤 곧바로 TSLA로 바꾸면 TSLA 응답이 먼저 오고 AAPL 응답이 늦게 도착해 화면을 AAPL로 덮어쓸 수 있습니다(응답 순서 역전). 해결은 13강에서 쓰는 "취소표" 패턴이에요. effect 안에서 let alive = true로 두고, 응답이 오면 alive일 때만 setState하고, cleanup에서 alive = false로 바꾸면 이미 지나간 요청의 응답은 무시됩니다.

핵심 정리
  • 커스텀 훅 = use로 시작 + 안에서 훅 사용. 맨 위에서만 호출(조건문·반복문 X). 돌려주는 모양은 자유.
  • 훅을 두 번 부르면 state가 두 벌 — 공유되는 건 코드(로직)이지 값이 아니다.
  • cleanup은 다음 effect 직전 + 언마운트 때 실행 → 타이머·구독·연결을 끄는 자리.
  • 바뀌는 콜백은 ref에 최신으로 기록하고, 타이머는 delay가 바뀔 때만 다시 건다(useInterval). null로 끄기.
확인 문제
두 컴포넌트에서 useLocalStorage('theme', 'light')를 각각 부르면 둘의 state는 공유되나요?
아니요. 각자 자기 useState를 가집니다. 저장소의 같은 키에 둘 다 쓰기는 하지만, 한쪽에서 값을 바꿔도 다른 쪽 화면은 (새로고침 전까지) 그대로예요. 여러 컴포넌트가 같은 값을 함께 보려면 24장의 Context가 필요합니다.
자동 갱신을 if (autoRefresh) useInterval(refetch, 5000)처럼 쓰면 안 되는 이유는?
훅을 조건문 안에서 부르면, 렌더마다 호출되는 훅의 개수·순서가 달라질 수 있어요. React는 훅을 호출 순서로 구별하기 때문에 state가 엉키고 에러가 납니다. 그래서 useInterval은 항상 부르되, delay에 null을 넘기면 멈추도록 설계했어요.
개발 모드에서 StockDetail을 처음 열면 가짜 요청이 두 번 나가는 이유는?
StrictMode가 마운트 직후 effect를 "정리 → 다시 실행"하기 때문이에요. useStockData의 effect가 두 번 실행되어 load()가 두 번 불립니다. 배포 빌드에선 한 번이에요. 같은 종목이라 지금은 티가 안 나지만, 두 응답 중 늦게 온 것이 화면에 남는 구조라는 점은 위의 "조심할 곳"과 같은 문제입니다.
다음 장으로

21장에서 memo·useCallback을 썼지만 useMemo와 섞여 있어 효과를 따로 보기 어려웠죠. 23장에서는 같은 행을 memo 없는 버전과 있는 버전 두 벌로 나란히 그려 차이만 비교하고, 렌더 최적화와는 다른 축인 코드 분할(lazy/Suspense)을 배웁니다.

더 자세히 → 커스텀 훅 4종커스텀 훅이란?Hooks 사용 규칙useEffect란?useRef란?WebSocket Q&A(cleanup)

4부 · React

23React.memo 비교와 lazy/Suspense 코드 분할

📎 React 교안 p.30~33💻 4.react/lessons/05_memo_lazy
이 장의 목표
  • memo 없음/있음 두 목록의 콘솔 로그를 비교해, React.memo가 "언제 렌더할지"를 줄인다는 것을 설명할 수 있다.
  • memo의 비교가 prop 하나하나의 얕은 비교라서, 원시값은 값으로·함수와 객체는 참조로 비교된다는 것을 안다.
  • lazy(() => import(...))가 "언제 다운로드할지"를 줄이고, Suspense의 fallback이 그동안의 임시 화면이라는 것을 설명할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

21장에서 memo와 useCallback을 StockList 안에서 썼지만, useMemo까지 섞여 있어서 "memo 덕분인지 다른 것 덕분인지" 헷갈렸죠. 05는 똑같은 행을 memo 없는 버전·있는 버전 두 벌로 만들어 한 화면에 나란히 놓고 차이만 봅니다. 그리고 지금까지는 "렌더를 덜 하기"였다면, 이번엔 처음에 내려받는 코드를 덜 받기라는 다른 종류의 최적화도 배워요.

개념

① React.memo의 비교 방식 — prop마다 "같은 것인가?"

memo는 이전 props와 새 props를 prop 하나씩 비교해요(얕은 비교). 숫자·문자열 같은 원시값은 값이 같으면 같고, 함수·객체·배열은 같은 참조일 때만 같습니다. 05에서는 {...s}로 symbol·price·change를 원시값으로 펼쳐 넘기고, 함수 onToggle은 useCallback으로 고정했어요. 그래서 부모가 다시 그려져도 모든 prop이 같아 건너뜁니다. 단, memo는 props만 봐요. 자기 state가 바뀌거나 읽고 있는 Context가 바뀌면 memo여도 다시 렌더됩니다(24장).

📎 React 교안 p.31 「05 React.memo · lazy / Suspense — 학습 목표」, p.32 「① React.memo — 부모 리렌더 시」

② 렌더 최적화 vs 번들 최적화

npm run build를 하면 앱의 모든 코드가 몇 개의 JS 파일(번들)로 묶여요. 앱이 커질수록 이 파일이 커지고, 사용자는 첫 화면을 보기 전에 그걸 다 받아야 합니다. 교안은 이렇게 정리해요 — React.memo는 "언제 렌더할지"를, lazy는 "언제 다운로드할지"를 줄인다. 같은 "최적화"라도 줄이는 대상이 전혀 다릅니다.

📎 React 교안 p.32 「렌더 최적화 vs 번들 최적화」

③ lazy + Suspense — 필요할 때 받아 오고, 그동안은 대기 화면

const HeavyChart = lazy(() => import('./components/HeavyChart'))처럼 동적 import로 감싸면, 빌드할 때 HeavyChart가 별도 파일(청크)로 떨어져 나가고, 그 컴포넌트가 처음 화면에 나와야 할 때 내려받습니다. 받는 동안엔 가장 가까운 <Suspense fallback={…}>가 fallback(⏳)을 대신 보여 줘요. 한 번 받으면 기억해 두므로 다음부턴 바로 나옵니다. lazy로 불러올 파일은 export default로 컴포넌트를 내보내야 하고, lazy 선언은 컴포넌트 함수 바깥(파일 맨 위)에 둡니다.

📎 React 교안 p.32 「② lazy / Suspense — 코드 분할」, p.33 「확장 과제 2」

⚠️ 교안 표현 바로잡기

교안 p.33은 Slow 3G에서 「HeavyChart-xxxx.js 청크를 새로 요청」하는 것을 관찰하라고 하는데, 이 이름은 npm run build로 만든 배포 결과에서 보여요(dist/assets/, 확인은 npm run preview). npm run dev 개발 서버는 원래 파일을 하나씩 따로 보내기 때문에 Network 탭에 HeavyChart.jsx로 보입니다. 또 App.jsx의 lazyWithDelay(1초 강제 지연)는 fallback을 눈으로 보려고 넣은 관찰용 장치이니 실제 앱에서는 쓰지 않아요.

예제 — 수업 코드로 확인하기

MemoComparison — 같은 행을 두 벌 그려 비교하기

이 코드는 memo가 있을 때와 없을 때의 차이만 보이도록, 내용이 완전히 같은 두 컴포넌트를 나란히 놓으려고 만든 것입니다.

JSX05_memo_lazy/src/components/MemoComparison.jsx — 렌더 비교
function RowWithoutMemo({ symbol, price, change, onToggle }) {
    console.log(`[memo없음] ${symbol} 렌더링`)
    // …(생략: 화면)
}

const RowWithMemo = memo(function RowWithMemo({ symbol, price, change, onToggle }) {
    console.log(`[memo있음] ${symbol} 렌더링`)
    // …(생략: 위와 똑같은 화면)
})

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)             // 부모 리렌더를 일으키기 위한 숫자

    const onToggle = useCallback((symbol) => {
        console.log('토글:', symbol)
    }, [])

    return (
        <div style={{ padding: '1.5rem', maxWidth: '500px' }}>
            <button onClick={() => setTick((t) => t + 1)} style={{ padding: '8px 16px' }}>부모 리렌더 유발 (tick: {tick})</button>
            {/* …(생략) */}
            {stocks.map((s) => (
                <RowWithoutMemo key={s.symbol} {...s} onToggle={onToggle} />
            ))}
            {/* …(생략) */}
            {stocks.map((s) => (
                <RowWithMemo key={s.symbol} {...s} onToggle={onToggle} />
            ))}
        </div>
    )
}

👀 관찰 포인트 — 처음 열 때는 두 목록 모두 로그가 찍혀요(첫 렌더는 건너뛸 이전 값이 없으니까요). 그 뒤 "부모 리렌더 유발"을 누르면 [memo없음] 3줄만 매번 찍히고 [memo있음]은 조용합니다. 이제 useCallback을 벗겨 그냥 화살표 함수로 바꿔 보세요. [memo있음]도 매번 찍힙니다(교안 p.33 과제 1). 원시값 세 개는 같아도 onToggle 하나가 새 주소라서 검문소가 뚫린 거예요.

App — lazy로 나누고 Suspense로 기다리기

이 코드는 무거운 탭 두 개를 처음 번들에서 빼 두었다가, 눌렀을 때 받아 오는 코드 분할을 보여 주려고 만든 것입니다.

JSX05_memo_lazy/src/App.jsx — lazy · Suspense
import { lazy, Suspense, useState } from 'react'
import MemoComparison from './components/MemoComparison'      // 일반 import → 처음부터 포함

// 실습용: fallback을 눈으로 보려고 1초 강제 지연
const lazyWithDelay = (factory) =>
  lazy(() => new Promise((resolve) => setTimeout(() => resolve(factory()), 1000)))

const HeavyChart = lazyWithDelay(() => import('./components/HeavyChart'))     // 별도 청크
const HeavyReport = lazyWithDelay(() => import('./components/HeavyReport'))   // 별도 청크

function LoadingSpinner({ label }) {
  return <div style={{ /* …(생략) */ }}>⏳ {label || '로딩 중'}...</div>
}

export default function App() {
  const [tab, setTab] = useState('memo')
  // …(생략: 탭 버튼)
      <Suspense fallback={<LoadingSpinner label={"컴포넌트 로딩"} />}>
        {tab === 'memo' && <MemoComparison />}
        {tab === 'chart' && <HeavyChart />}
        {tab === 'report' && <HeavyReport />}
      </Suspense>
  // …(생략)
}

👀 관찰 포인트 — "📈 차트" 탭을 처음 누르면 ⏳ "컴포넌트 로딩..."이 1초 보이고 패널이 나옵니다. 다른 탭에 갔다가 다시 누르면 바로 나와요. 이미 받아 둔 것을 기억하기 때문이에요. "⚡ memo 비교" 탭은 일반 import라 fallback 없이 즉시 나옵니다. lazy와 Suspense가 && 조건부 렌더링과 어떻게 함께 쓰이는지도 보세요.

핵심 정리
  • React.memo = props를 하나씩 얕게 비교해 같으면 렌더 생략. 원시값은 값으로, 함수·객체는 참조로 비교된다.
  • memo와 useCallback은 한 세트. 함수 prop이 매번 새로 만들어지면 memo는 무력화된다.
  • memo는 "언제 렌더할지", lazy는 "언제 다운로드할지"를 줄인다.
  • lazy(() => import('./X')) + <Suspense fallback={…}>. 한 번 받으면 기억됨. lazy 선언은 컴포넌트 바깥에.
확인 문제
RowWithMemo에 style={{ color: 'red' }} prop을 하나 더 넘기면 memo는 계속 효과가 있을까요?
없어집니다. {{ color: 'red' }}는 부모가 렌더될 때마다 새 객체로 만들어지기 때문에, 내용이 같아도 참조가 달라 "바뀌었다"고 판단돼요. 객체 prop을 고정하려면 부모에서 useMemo로 만들거나 컴포넌트 바깥 상수로 빼야 합니다.
"📈 차트" 탭을 두 번째 누를 때는 왜 ⏳가 안 보이나요?
lazy가 처음 받아 온 컴포넌트를 기억(캐시)하기 때문이에요. 1초 지연(lazyWithDelay)도 다운로드를 시작할 때 한 번만 걸리므로, 두 번째부터는 기다릴 것이 없어 바로 렌더됩니다.
const HeavyChart = lazy(…)를 App 함수 안으로 옮기면 무슨 문제가 생길까요?
App이 렌더될 때마다 새 lazy 컴포넌트가 만들어져, React는 매번 "다른 종류의 컴포넌트"로 보고 기존 것을 버리고 새로 붙여요. 그 안의 state가 매번 초기화되고 fallback도 다시 보일 수 있습니다. 그래서 lazy는 파일 맨 위에 둡니다.
다음 장으로

지금까지 데이터는 늘 props로 부모 → 자식 한 칸씩 내려갔어요. 테마처럼 앱 곳곳에서 쓰는 값을 깊은 곳까지 내려보내려면, 쓰지도 않는 중간 컴포넌트들이 전달만 해야 합니다. 24장에서는 그 문제(prop drilling)를 Context API로 없앱니다.

더 자세히 → memo 비교 + lazy/SuspenseReact.memo란?useMemo·useCallbacklazy + Suspense란?import와 export

4부 · React

24Context API — prop drilling 없애기

📎 React 교안 p.34~37, 46💻 4.react/lessons/06_context_api
이 장의 목표
  • prop drilling이 무엇이고 왜 불편한지 설명할 수 있다.
  • createContext → Provider(value) → useContext 3단계로 여러 컴포넌트가 값을 공유하게 할 수 있다.
  • Provider 밖에서 쓰면 바로 에러를 내는 소비용 커스텀 훅(useTheme 등)을 이유와 함께 만들 수 있다.
  • Context 값이 바뀌면 그 Context를 읽는 컴포넌트가 다시 렌더된다는 것과, 그것이 08강 Zustand로 넘어가는 이유임을 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결

19장에서 props는 부모 → 자식으로 한 칸씩만 흐른다고 했어요. 22장에서는 커스텀 훅을 두 군데서 불러도 state가 공유되지 않는다는 것도 봤죠. 그렇다면 다크 모드처럼 앱 여기저기서 같은 값을 함께 봐야 할 때는 어떻게 할까요? 06은 테마·관심목록·정렬 기준 세 가지 공유값을 Context로 만들고, 22장의 커스텀 훅 모양(useTheme())으로 꺼내 씁니다.

개념

① prop drilling — 손에서 손으로 전달만 하는 릴레이

App이 가진 theme을 맨 아래 StockGrid가 쓰려면 App → Layout → Sidebar → StockGrid로 한 칸씩 넘겨야 해요. 중간의 Layout·Sidebar는 theme을 쓰지도 않는데 전달만 합니다. 단계가 깊어질수록 props 목록이 길어지고, 이름 하나 바꾸려 해도 중간 파일을 전부 고쳐야 해요. 이것이 prop drilling입니다.

📎 React 교안 p.36 「prop drilling 을 Context 가 없앤다」

② 3단계 — 상자 만들기 → 상자에 담아 공급 → 꺼내기

① createContext(null)로 빈 상자를 만든다(null은 Provider가 없을 때 돌려줄 기본값). ② <ThemeContext.Provider value={theme}>로 감싸면 그 아래 트리 전체에 값이 공급된다. ③ 아래 어디서든 useContext(ThemeContext)로 가장 가까운 Provider의 value를 꺼낸다. 중간 컴포넌트는 아무것도 몰라도 됩니다. 참고로 React 19부터는 .Provider 없이 <ThemeContext value={theme}>로 써도 되는데, 수업 코드의 .Provider 방식도 그대로 동작해요.

📎 React 교안 p.35 「06 Context API — 학습 목표」, p.36 「createContext → Provider → useContext (3단계)」

③ 소비용 커스텀 훅과 Provider 중첩

매번 useContext(ThemeContext)를 쓰는 대신 useTheme() 같은 훅으로 감싸 두면 import가 간단해지고, if (!ctx) throw new Error(…)로 Provider로 감싸는 걸 깜빡했을 때 바로 알아챌 수 있어요. 안 그러면 null을 받아 한참 뒤에 "null의 속성을 읽을 수 없음" 같은 엉뚱한 에러가 납니다. Context가 여러 개면 Provider를 겹겹이 중첩해 감싸요. 06의 세 번째 Context(SortContext)는 교안 p.37 과제 1을 한 결과입니다.

📎 React 교안 p.37 「06 확장 과제 1·2」

④ Context의 한계 — 값이 바뀌면 읽는 쪽이 모두 다시 렌더

Provider의 value가 바뀌면 그 Context를 useContext로 읽는 컴포넌트가 전부 다시 렌더돼요(memo로 감싸도 마찬가지). 06의 Provider들은 렌더될 때마다 value={{ sortBy, setSortBy }}처럼 새 객체를 넘기므로, 상태가 바뀌면 읽는 쪽이 모두 다시 그려집니다. 그리고 theme 객체를 통째로 담았기 때문에 그중 색 하나만 쓰는 컴포넌트도 함께 다시 렌더돼요. 값이 자주 바뀌는 큰 상태라면 "필요한 조각만 구독"하는 Zustand(08강)가 이 문제를 풉니다.

📎 React 교안 p.36 「한계: … → 08강 Zustand」, p.46 「Context API의 한계 — 왜 Zustand인가?」

⚠️ 교안 표현 바로잡기

교안 p.36은 「값이 자주 바뀌면 하위 전체가 리렌더」, p.46은 「Provider 하위 전체 리렌더링」이라고 하지만, 정확히는 그 Context를 읽는(useContext) 컴포넌트가 다시 렌더됩니다. 06의 ThemeProvider처럼 children으로 받은 컴포넌트는, 부모(App)가 다시 렌더되지 않는 한 Context를 읽지 않으면 다시 렌더되지 않아요. 문제의 핵심은 "하위 전체"가 아니라 "읽는 쪽은 value의 일부만 써도 전부 다시 렌더된다"는 점이고, 그래서 Zustand의 "골라서 구독"이 필요해집니다.

예제 — 수업 코드로 확인하기

ThemeContext — 3단계 + 소비용 훅, 그리고 Provider 중첩

이 코드는 createContext → Provider → useContext 3단계와 "Provider 밖 사용을 막는 훅"을 가장 단순한 값(테마)으로 연습하려고 만든 것입니다.

JSX06_context_api/src/context/ThemeContext.jsx — 테마 공유
import { createContext, useState, useContext } from 'react'

const ThemeContext = createContext(null)                 // ① 빈 상자 (기본값 null)

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

    const theme = {
        isDark,
        toggle: () => setIsDark((d) => !d),
        bg: isDark ? '#1a1a2e' : '#ffffff',
        cardBg: isDark ? '#0d2137' : '#f8f9fa',
        text: isDark ? '#ccd6f6' : '#1a1a18',
        // …(생략: muted, border, primary)
    }

    return (
        <ThemeContext.Provider value={theme}>            {/* ② 아래 트리에 공급 */}
            <div style={{ background: theme.bg, color: theme.text, minHeight: '100vh' }}>
                {children}
            </div>
        </ThemeContext.Provider>
    )
}

export function useTheme() {
    const ctx = useContext(ThemeContext)                  // ③ 가장 가까운 Provider의 value
    if (!ctx) throw new Error('useTheme은 ThemeProvider 안에서 사용하세요')
    return ctx
}
JSX06_context_api/src/App.jsx — Provider 중첩
export default function App() {
  return (
    <ThemeProvider>
      <WatchlistProvider>
        <SortProvider>
          <Header />
          <main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
            <StockGrid />
          </main>
        </SortProvider>
      </WatchlistProvider>
    </ThemeProvider>
  )
}

👀 관찰 포인트 — App은 Header와 StockGrid에 props를 하나도 넘기지 않아요. 그런데 Header의 🌙 버튼(toggle)을 누르면 StockGrid 카드 색까지 함께 바뀝니다. 둘 다 useTheme()으로 같은 상자에서 꺼내 쓰기 때문이에요. App에서 <ThemeProvider>를 지워 보면 화면 대신 "useTheme은 ThemeProvider 안에서 사용하세요" 에러가 나와 원인을 바로 알 수 있습니다(교안 p.37 과제 2).

SortContext — 멀리 떨어진 Header와 StockGrid가 한 값을 함께 쓰기

이 코드는 "부모-자식 관계가 아닌 두 컴포넌트가 같은 state를 읽고 바꾸는" Context의 진짜 쓸모를 보여 주려고 만든 것입니다.

JSX06_context_api/src/context/SortContext.jsx — 정렬 기준 공유
const SortContext = createContext(null)

export function SortProvider({ children }) {
  const [sortBy, setSortBy] = useState('symbol')
  return (
    <SortContext.Provider value={{ sortBy, setSortBy }}>
      {children}
    </SortContext.Provider>
  )
}

export function useSort() {
  const ctx = useContext(SortContext)
  if (!ctx) {
    throw new Error('useSort는 SortProvider 내부에서 사용해야 합니다.')
  }
  return ctx
}
JSX06_context_api/src/components/header.jsx · StockGrid.jsx — 바꾸는 쪽과 읽는 쪽
// header.jsx — 정렬 기준을 "바꾸는" 쪽
export default function Header() {
    const { isDark, toggle, text, border, primary } = useTheme()
    const { watchlist } = useWatchlist()
    const { sortBy, setSortBy } = useSort()
    // …(생략)
                <button onClick={() => setSortBy('price')} /* …(생략) */>
                    가격
                </button>
    // …(생략)
}

// StockGrid.jsx — 정렬 기준을 "읽어서" 목록을 그리는 쪽
export default function StockGrid() {
    const { cardBg, text, muted, border, primary } = useTheme()
    const { isWatching, addSymbol, removeSymbol } = useWatchlist()
    const { sortBy, setSortBy } = useSort()

    const sortedStocks = [...STOCKS].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)
    })
    // …(생략)
}

👀 관찰 포인트 — Header의 "가격" 버튼을 누르면 StockGrid의 카드 순서가 바뀌고, StockGrid 쪽 "가격높은순" 버튼도 같이 강조됩니다. 반대로 StockGrid 버튼을 눌러도 Header 버튼이 따라 바뀌어요. 정렬 기준이 어느 한 컴포넌트가 아니라 SortProvider 한 곳에 있기 때문입니다. 마찬가지로 카드의 ☆를 누르면(WatchlistContext) Header의 "관심종목 N개"가 바로 갱신돼요.

핵심 정리
  • prop drilling = 쓰지도 않는 중간 컴포넌트가 props를 전달만 하는 문제.
  • Context 3단계: createContext(기본값) → <X.Provider value={…}> → useContext(X)(가장 가까운 Provider의 값).
  • 소비용 훅(useTheme)에 if (!ctx) throw를 넣어 Provider 누락을 즉시 드러낸다. Context가 여럿이면 Provider를 중첩.
  • value가 바뀌면 그 Context를 읽는 컴포넌트가 모두 다시 렌더 → 자주 바뀌는 큰 상태는 08강 Zustand로.
확인 문제
createContext(null)의 null은 언제 쓰이나요?
위쪽에 그 Context의 Provider가 하나도 없을 때 useContext가 돌려주는 기본값이에요. 06은 이 null을 이용해, 소비용 훅에서 if (!ctx) throw로 "Provider로 감싸는 걸 잊었다"를 바로 알려 줍니다.
테마를 토글하면 StockGrid가 다시 렌더되는 이유는? StockGrid를 memo로 감싸면 막을 수 있나요?
StockGrid가 useTheme()으로 ThemeContext를 읽고 있기 때문이에요. Context 값이 바뀌면 읽는 컴포넌트는 memo로 감싸도 다시 렌더됩니다. memo는 props만 비교하기 때문이에요(23장).
WatchlistContext는 addSymbol을 useCallback으로 감쌌는데, 그래도 관심목록이 바뀌면 읽는 쪽이 모두 다시 렌더되는 이유는?
Provider가 value={{ watchlist, addSymbol, removeSymbol, isWatching }}로 매번 새 객체를 넘기기 때문이에요. 함수 하나하나는 고정돼도 그것을 담은 상자(value 객체)가 새 것이라, React는 "value가 바뀌었다"고 봅니다. 게다가 이 경우엔 실제로 watchlist 값이 바뀌었으니 다시 렌더되는 게 맞아요.
그럼 이제 props는 안 쓰고 전부 Context로 하면 되나요?
아니요. 바로 아래 자식에게 주는 값은 props가 더 명확해요(어디서 오는지 코드에 보이니까요). Context는 테마·로그인 사용자·관심목록처럼 여러 곳이 함께 보는 값에 씁니다.
다음 장으로

여기까지가 React 코어(01~06강)입니다. 다음부터는 07강 Next.js로 넘어가 폴더 구조가 곧 URL이 되는 라우팅을 배우고, 08강에서는 이 장에서 본 Context의 한계를 Zustand로 보완합니다. "필요한 조각만 구독한다"는 말이 왜 중요한지, 이 장의 ④를 기억해 두세요.

더 자세히 → Context APIContext API란?props란?children과 컴포지션커스텀 훅이란?Zustand란?

5부 · Next.js와 주식 대시보드

25Next.js와 App Router — 폴더가 곧 주소가 된다

📎 React 교안 p.38~44💻 4.react/lessons/07_next_routing
이 장의 목표
  • React(라이브러리)와 Next.js(프레임워크)가 각각 맡는 일을 구분해 말할 수 있다.
  • CSR과 SSR이 "HTML을 어디서 완성하느냐"의 차이라는 것을 설명할 수 있다.
  • 언제 파일 맨 위에 'use client'를 붙여야 하는지 판단할 수 있다.
  • app/ 폴더 구조만 보고 URL을 맞히고, layout.js·[id]·await params의 역할을 설명할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

01~06 레슨은 Vite로 만든 React 앱이었어요. 화면이 하나뿐이고, 페이지를 나누거나 서버에서 무언가를 하려면 따로 도구를 붙여야 했죠. 07에서는 Next.js로 갈아타면서 작은 블로그 예제로 Next의 규칙만 먼저 익힙니다. 이 규칙(폴더 = 주소, layout, 서버 컴포넌트)이 바닥이 되어, 08부터 13까지는 주식 대시보드 앱 StockDash 하나를 매 레슨 키워 갑니다(교안 p.45). 즉 이 장은 그 앱을 올릴 땅을 고르는 장이에요.

개념

① React는 부품, Next.js는 부품이 들어간 완성 키트

React는 컴포넌트·JSX·state·Hook처럼 "화면을 조립하는 부품"만 줍니다. Next.js는 그 React를 안에 품고, 그 위에 파일 기반 라우팅, 서버 렌더링, 서버 컴포넌트, API Routes 같은 "앱을 완성하는 설비"를 더한 프레임워크예요. 그래서 01~06에서 짠 React 코드(useState, props, map 등)는 Next 안에서도 그대로 동작합니다. 새로 배울 것은 Next가 정한 규칙뿐이에요. 수업 프로젝트는 Next 16 · React 19입니다.

📎 React 교안 p.38 「무엇이 React이고, 무엇이 Next.js인가?」

② CSR vs SSR — HTML을 누가 완성하나

식당에 비유하면 CSR(Vite React)은 밀키트 배달이에요. 브라우저가 거의 빈 HTML과 JS 묶음을 받아서 브라우저가 직접 조리(렌더링)합니다. JS가 도착하기 전엔 흰 화면이죠. SSR은 완성된 요리 배달이에요. 서버가 HTML을 다 만들어서 보내니 받자마자 내용이 보이고, 검색엔진도 내용을 읽을 수 있어요(SEO). Next.js는 기본적으로 서버 쪽에서 HTML을 완성해 보냅니다.

📎 React 교안 p.39 「CSR vs SSR — 렌더링 위치의 차이」

③ 기본은 서버 컴포넌트, 상호작용이 필요하면 'use client'

App Router에서 컴포넌트는 따로 말하지 않으면 서버 컴포넌트입니다. 서버에서만 실행되므로 useState·useEffect·onClick처럼 브라우저에서 사용자와 주고받는 기능은 쓸 수 없어요. 그런 기능이 필요한 파일은 맨 위에 'use client'를 적어 클라이언트 컴포넌트로 만듭니다. 경험 법칙: state·effect·이벤트 핸들러가 보이면 'use client'.

07 블로그 코드에는 'use client'가 한 군데도 없어요. 글을 보여 주기만 하고 state도 클릭 핸들러도 없기 때문입니다. 메뉴의 <Link>는 클릭으로 이동하는데도 괜찮은 이유는, next/link 자체가 Next가 미리 만들어 둔 클라이언트 컴포넌트라서 서버 컴포넌트가 그냥 가져다 쓸 수 있기 때문이에요.

📎 React 교안 p.40 「'use client' — React와 Next.js의 실행 경계」

④ 폴더 = URL, layout = 공통 액자, [id] = 빈칸 주소

app/ 아래 폴더 경로가 그대로 주소가 되고, 그 폴더의 page.js가 화면이 됩니다. app/page.js → /, app/about/page.js → /about, app/about/career/page.js → /about/career. 라우팅 설정 파일은 따로 없어요.

layout.js는 액자입니다. 헤더·푸터를 한 번만 그려 두고, 주소에 맞는 page.js가 {children} 자리에 끼워져요. 메뉴는 <a> 대신 <Link>를 써서 페이지 전체를 새로 받지 않고 children 부분만 갈아 끼웁니다.

폴더 이름을 [id]처럼 대괄호로 지으면 동적 세그먼트예요. blog/[id]/page.js 파일 하나가 /blog/1, /blog/2, … 를 모두 처리하고, 실제 값은 params로 받습니다. Next 15부터 params는 Promise라서 const { id } = await params로 꺼내야 해요. 이때 id는 항상 문자열입니다.

📎 React 교안 p.41~43 「Next 라우팅 실습 흐름」「폴더 구조가 그대로 URL이 된다」

⚠️ 교안 표현 바로잡기

교안 p.40은 클라이언트 컴포넌트를 「브라우저에서 실행」이라고 설명하지만, 정확히는 "브라우저에서도 실행된다"입니다. 'use client' 컴포넌트도 첫 HTML은 서버에서 한 번 그려서 보내고(SSR), 브라우저가 JS를 받은 뒤 그 HTML에 이벤트를 연결합니다(이 과정을 hydration이라고 해요). 이 사실은 28장에서 "새로고침하면 값이 깜빡이는" 문제를 이해할 때 다시 나옵니다. 또 p.39의 「Next.js 기본 동작 = SSR」도 넓은 뜻이에요. /about처럼 매번 같은 페이지는 요청마다 만들지 않고 빌드할 때 HTML을 미리 만들어 둡니다(정적 렌더링). 공통점은 "브라우저가 아니라 서버 쪽에서 HTML을 완성한다"는 것입니다.

예제 — 수업 코드로 확인하기

layout.js — 모든 페이지를 감싸는 액자

이 코드는 "layout이 한 번 그리고, page는 children 자리에 끼워진다"는 것을 보여 주려고 만든 실습입니다. 홈·소개·블로그 어디로 가도 헤더가 그대로인 이유가 여기 있어요.

JSXsrc/app/layout.js — 공통 뼈대(서버 컴포넌트)
import './globals.css'
import Link from 'next/link'

export const metadata = { title: '나의 블로그' }   // <head>의 <title>을 Next가 만들어 줌

export default function RootLayout({ children }) {
  return (
    <html lang="ko">
      <body style={{ margin: 0, fontFamily: 'sans-serif' }}>
        <header style={{ /* …(생략) */ }}>
          <strong style={{ fontSize: '1.2rem' }}>📝 나의 블로그</strong>
          <nav style={{ display: 'flex', gap: '1.5rem' }}>
            {/* Link를 사용해 새로고침 없이 상단 메뉴를 부드럽게 이동 */}
            <Link href="/">홈</Link>
            <Link href="/about">소개</Link>
            <Link href="/blog">블로그</Link>
          </nav>
        </header>

        {/* 현재 URL 주소에 맞는 page.js 가 {children} 자리에 꽂힌다 */}
        <main style={{ maxWidth: '780px', margin: '0 auto', padding: '2rem' }}>
          {children}
        </main>
        {/* …(생략: 공통 footer) */}
      </body>
    </html>
  )
}

👀 관찰 포인트 — <html>·<body>를 직접 쓰는 곳은 루트 layout 하나뿐입니다. 메뉴를 눌러 이동해도 헤더는 다시 그려지지 않고 <main> 안만 바뀝니다. 개발자 도구 Network 탭을 열어 두고 <Link>를 누르면, 문서 전체를 새로 받지 않는 것을 확인할 수 있어요.

blog/[id]/page.js — 파일 하나로 모든 글 주소 처리

이 코드는 동적 세그먼트 [id]와 await params를 보여 주려고 만든 실습입니다. 목록(blog/page.js)에서 href={`/blog/${post.id}`}로 링크를 만들면 이 파일 하나가 모든 글을 받아요.

JSXsrc/app/blog/[id]/page.js — 글 상세
import Link from 'next/link'
import { posts, tagStyle } from '../posts'
import { notFound } from 'next/navigation' // 404 페이지로 연결해주는 Next.js 내장 함수

export default async function BlogPost({ params }) {
    // /blog/2 로 들어오면 await params → { id: '2' }  (문자열!)
    const { id } = await params

    // URL의 id(문자열)를 Number()로 숫자로 바꿔서 posts 배열에서 찾기
    const post = posts.find((p) => p.id === Number(id))

    // 없으면 같은 폴더의 not-found.js 화면(404)으로
    if (!post) {
        notFound()
    }

    return (
        <article>
            <Link href="/blog" style={{ /* …(생략) */ }}>← 목록으로</Link>
            {/* …(생략: 태그·날짜) */}
            <h1 style={{ margin: '0.75rem 0 0.25rem' }}>{post.title}</h1>
            <p style={{ lineHeight: 1.9, color: '#444', whiteSpace: 'pre-line' }}>{post.content}</p>
        </article>
    )
}

👀 관찰 포인트 — /blog/2는 2번 글, /blog/999는 not-found.js의 "존재하지 않는 블로그 글입니다!" 화면이 나옵니다. 함수 앞에 async가 붙어 있는 것도 보세요. 서버 컴포넌트라서 컴포넌트 자체를 async로 만들 수 있어요(29장에서 본격적으로 씁니다). 확장 과제로 만든 about/career/page.js는 설정 없이 폴더만 만들어 /about/career가 생긴 예입니다.

핵심 정리
  • Next.js = React + 라우팅·서버 렌더링·서버 컴포넌트·API Routes. React 문법은 그대로 쓴다.
  • CSR은 브라우저가, SSR은 서버가 HTML을 완성한다. Next는 서버 쪽에서 완성해 보낸다.
  • App Router의 기본은 서버 컴포넌트. state·effect·이벤트가 필요하면 파일 맨 위에 'use client'.
  • app/의 폴더 경로 = URL, page.js = 그 주소의 화면, layout.js = {children}을 감싸는 공통 틀.
  • [id] 폴더는 동적 세그먼트. Next 15+에서는 const { id } = await params, 값은 문자열.
확인 문제
app/stock/[symbol]/page.js 파일을 만들면 어떤 주소들이 생기고, /stock/AAPL에서 symbol 값은 무엇인가요?
/stock/아무값 모양의 주소가 전부 이 파일로 옵니다. /stock/AAPL이면 await params의 결과가 { symbol: 'AAPL' }이고, 값은 문자열 'AAPL'입니다.
[id]/page.js에서 Number(id)를 빼고 p.id === id로 비교하면 어떻게 될까요?
posts의 id는 숫자 1인데 URL에서 온 id는 문자열 '1'이라 === 비교가 항상 false입니다. 모든 글이 404로 나와요.
layout.js 메뉴를 <a href="/blog">로 바꾸면 무엇이 달라지나요?
주소는 똑같이 바뀌지만 브라우저가 문서 전체를 새로 받아 레이아웃까지 처음부터 다시 그립니다. 클라이언트 컴포넌트에 있던 state도 모두 초기화돼요. <Link>는 children 부분만 바꿉니다.
07 블로그 코드에 'use client'가 하나도 없는 이유는?
글을 보여 주기만 하고 useState·useEffect·onClick 같은 브라우저 상호작용이 없기 때문입니다. 그래서 전부 서버 컴포넌트로 충분해요. 08부터 버튼과 입력창이 생기면서 'use client'가 등장합니다.
다음 장으로

Next의 땅을 다졌으니 이제 그 위에 주식 대시보드 StockDash를 짓기 시작합니다. 첫 레슨(08)에서는 여러 컴포넌트가 같은 관심 종목 목록을 나눠 쓰도록, 06에서 배운 Context 대신 Zustand 스토어를 둡니다(26장).

더 자세히 → Next.js App Router파일 기반 라우팅이란?서버 vs 클라이언트 컴포넌트

5부 · Next.js와 주식 대시보드

26Zustand 전역 상태 — Provider 없이, 필요한 조각만 구독

📎 React 교안 p.45~52💻 4.react/lessons/08_zustand_store
이 장의 목표
  • Context API로 전역 상태를 다룰 때의 한계를 한 문장으로 말할 수 있다.
  • create((set, get) => ({ ... }))로 상태와 액션을 한 스토어에 담을 수 있다.
  • set(객체), set(state => ...), get()을 구분해서 쓸 수 있다.
  • 셀렉터 useStockStore(s => s.watchlist)로 필요한 값만 구독하는 이유를 설명할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

06(24장)에서 Context로 prop drilling을 없앴고, 07(25장)에서 Next로 옮겨 왔어요. 이제부터 08~13은 주식 대시보드 StockDash 하나를 이어서 키웁니다(교안 p.45). 08은 그 첫 회차로, 종목 추가 폼과 관심 목록이 같은 데이터를 쓰도록 Zustand 스토어 useStockStore.js를 만들어요. 이 파일은 앱의 심장이라서 09(검색) → 10(저장·시세·포트폴리오) → 12(테마) → 13(차트 가격)까지 레슨마다 한 줄씩 자라납니다.

개념

① Context의 한계 — "일부만 골라 듣기"가 안 된다

Context는 방송국 같아요. Provider가 value를 송출하면 useContext로 그 채널을 튼 컴포넌트는 value가 바뀔 때마다 전부 다시 그려집니다. 관심 목록만 보는 컴포넌트도 가격이 바뀌면 같이 다시 그려지는 거죠. 게다가 Context가 늘면 Provider를 계속 겹쳐 감싸야 해요. 상태관리의 흐름은 props → Context → Zustand(조각별 구독) → persist(저장)로 이어집니다.

📎 React 교안 p.46 「Context API의 한계 — 왜 Zustand인가?」, p.51 「상태관리는 이렇게 진화해 왔다」

② create((set, get) => ({ ... })) — 상태와 액션을 한 상자에

Zustand는 컴포넌트 밖에 공용 창고를 하나 만들어요. create에 넘기는 함수가 돌려준 객체에 상태(watchlist, prices…)와 그것을 바꾸는 액션(addToWatchlist…)을 함께 둡니다. Provider로 감쌀 필요가 없고, 결과물 useStockStore 자체가 훅이에요.

창고를 다루는 도구는 둘입니다. set은 바꾸기: set({ selectedSymbol: 'TSLA' })처럼 바꿀 부분만 주면 나머지 상태와 합쳐집니다(merge). 이전 값을 바탕으로 바꿀 땐 set((state) => ({ ... })). get은 읽기: 액션 안에서 지금 상태(또는 다른 액션)를 꺼낼 때 씁니다. 배열·객체는 [...배열, 새값], { ...객체, [키]: 값 }처럼 새로 만들어서 넣어야 바뀐 걸 알아챕니다(불변성).

📎 React 교안 p.47 「Zustand 스토어 — 설계 패턴 3단계」, p.50 「Zustand 스토어 도입」

③ 셀렉터 — 필요한 조각만 구독한다

useStockStore((s) => s.watchlist)처럼 함수를 넘기면 그 컴포넌트는 고른 값이 바뀔 때만 다시 그려집니다. 그래서 관심 목록만 고른 컴포넌트는 prices가 바뀌어도 가만히 있어요. 이게 Context와의 결정적 차이입니다. s => s.watchlist.length처럼 계산한 값(파생 값)을 고를 수도 있어요. 그리고 훅을 쓰므로 스토어를 쓰는 컴포넌트에는 'use client'가 필요합니다.

📎 React 교안 p.48 「선택적 구독 — 불필요한 리렌더링 완전 차단」, p.52 확장 과제

⚠️ 교안 표현 바로잡기

① p.46 「상태 변경 시 Provider 하위 전체 리렌더링」은 정확히는 "그 Context를 useContext로 읽는 컴포넌트 전부"가 다시 그려진다는 뜻입니다. 진짜 한계는 값의 일부만 골라 구독할 방법이 없다는 점이에요.

② p.47의 사용 예 const { watchlist, addToWatchlist } = useStockStore()에는 「필요한 것만 구독!」이라는 주석이 붙어 있지만, 셀렉터 없이 부르면 스토어 전체를 구독합니다. p.48의 ❌ 예시와 같은 코드예요. 수업 코드처럼 useStockStore((s) => s.watchlist)로 하나씩 고르세요.

③ p.48의 useStockStore(selector, shallow)는 Zustand 4 방식입니다. 수업 프로젝트의 Zustand 5에서는 훅이 두 번째 비교 함수를 받지 않아, s => ({ a: s.a, b: s.b })처럼 매번 새 객체를 돌려주는 셀렉터는 무한 리렌더 에러를 낼 수 있어요. 여러 값을 한 번에 고르려면 import { useShallow } from 'zustand/react/shallow' 후 useStockStore(useShallow((s) => ({ ... })))로 감싸거나, 수업 코드처럼 값마다 따로 구독합니다.

예제 — 수업 코드로 확인하기

StockDash의 심장 — useStockStore.js (08 버전)

이 코드는 상태와 액션을 한 스토어에 두고 set·get으로 다루는 법을 보여 주려고 만든 실습입니다. 세 가지 바꾸는 방식(get 후 set(객체), set(state => …), 단순 set(객체))이 모두 들어 있어요.

JSsrc/app/store/useStockStore.js — 전역 스토어
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] 액션
    addToWatchlist: (symbol) => {
        const { watchlist } = get()              // 지금 상태 읽기
        if (watchlist.includes(symbol)) return   // 중복이면 중단
        set({ watchlist: [...watchlist, symbol] }) // 새 배열로 교체 (나머지 상태는 유지)
    },

    removeFromWatchlist: (symbol) => {
        set((state) => ({
            watchlist: state.watchlist.filter((s) => s !== symbol)
        }))
    },

    selectSymbol: (symbol) => set({ selectedSymbol: symbol }),

    setPrice: (symbol, price) => set((state) => ({
        prices: {
            ...state.prices,   // 기존 가격 정보들을 지우지 않고 복사해옴
            [symbol]: price    // 변수 값(예: 'NVDA')을 객체의 키로 사용
        }
    })),
}))

export default useStockStore

👀 관찰 포인트 — setPrice는 이 레슨에선 아무도 부르지 않아요. 13강에서 실시간 차트가 새 체결가를 받을 때 이 액션을 씁니다. 스토어 하나가 레슨을 건너며 계속 쓰인다는 것을 기억해 두세요.

props 없이 연결된 두 컴포넌트

이 코드는 부모가 props를 넘기지 않아도 두 컴포넌트가 스토어로 이어진다는 것을 보여 주려고 만든 실습입니다.

JSXsrc/app/page.js — 조립만 한다
// 두 컴포넌트는 props를 주고받지 않지만 같은 Zustand 스토어로 연결된다.
import AddStockForm from './components/AddStockForm'
import WatchlistPanel from './components/WatchlistPanel'

export default function Home() {
  return (
    <div style={{ maxWidth: 420, margin: '0 auto', padding: '1rem' }}>
      <h1 style={{ padding: '0 1rem' }}>📈 StockDash</h1>
      <AddStockForm />
      <WatchlistPanel />
    </div>
  )
}
JSXcomponents/AddStockForm.jsx · WatchlistPanel.jsx — 셀렉터로 구독
'use client'
import { useState } from 'react'
import useStockStore from '@/app/store/useStockStore'

export default function AddStockForm() {
    const [input, setInput] = useState('')
    const [msg, setMsg] = useState('')
    const watchlist = useStockStore((s) => s.watchlist)
    const addToWatchlist = useStockStore((s) => s.addToWatchlist)
    // …(생략: handleAdd에서 중복이면 setMsg, 아니면 addToWatchlist(symbol))

// ── WatchlistPanel.jsx ──
export default function WatchlistPanel() {
    // 파생 셀렉터: 관심종목 개수(length)만 뽑아오기
    const count = useStockStore((s) => s.watchlist.length)
    const watchlist = useStockStore((s) => s.watchlist)
    const selectedSymbol = useStockStore((s) => s.selectedSymbol)
    const prices = useStockStore((s) => s.prices)
    // …(생략: selectSymbol, removeFromWatchlist 구독 + 목록 렌더)

👀 관찰 포인트 — 폼에서 NVDA를 추가하면 page.js를 거치지 않고 목록이 바로 늘어납니다. 같은 종목을 또 넣으면 폼이 "이미 있는 종목: NVDA"를 띄우는데(확장 과제 1), 스토어 안의 includes 검사도 그대로 남겨 이중 안전장치로 둔 점을 보세요. 참고로 WatchlistPanel은 watchlist 자체도 구독하고 있어서, length 셀렉터는 여기선 렌더 횟수를 줄이진 않아요 — "파생 값을 고르는 연습"(확장 과제 2)입니다.

핵심 정리
  • Zustand 스토어 = 상태 + 액션을 담은 공용 창고. Provider 없이 useStockStore 훅으로 어디서나 쓴다.
  • set은 바꾸기(부분만 주면 병합), get은 액션 안에서 지금 상태 읽기.
  • 배열·객체는 새로 만들어 넣는다: [...arr, x], filter, { ...obj, [key]: v }.
  • useStockStore((s) => s.값)으로 필요한 조각만 구독하면, 그 값이 바뀔 때만 다시 그려진다.
  • 훅을 쓰는 컴포넌트는 'use client'. 여러 값을 객체로 고를 땐 Zustand 5에선 useShallow.
확인 문제
set({ watchlist: [...] })를 부르면 selectedSymbol과 prices는 사라지나요?
사라지지 않습니다. Zustand의 set은 넘긴 객체를 기존 상태와 합칩니다(얕은 병합). 넘긴 키만 바뀌어요.
watchlist.push(symbol); set({ watchlist })로 바꾸면 화면이 갱신될까요?
갱신되지 않을 수 있습니다. 같은 배열에 넣었으니 참조가 그대로라서, 셀렉터가 "이전 값과 같다"고 판단해 다시 그리지 않아요. 반드시 [...watchlist, symbol]처럼 새 배열을 만들어야 합니다.
useStockStore((s) => s.watchlist)만 구독한 컴포넌트는 setPrice가 불릴 때 다시 그려지나요?
아니요. 그 컴포넌트가 고른 값(watchlist)은 그대로이므로 다시 그려지지 않습니다. 셀렉터 없이 useStockStore()로 전체를 구독했다면 다시 그려져요.
AddStockForm.jsx 맨 위의 'use client'를 지우면 어떻게 될까요?
서버 컴포넌트가 되어 useState와 useStockStore(둘 다 훅)를 쓸 수 없다는 에러가 납니다. 스토어는 브라우저에서 동작하는 상태라서 이를 쓰는 컴포넌트는 클라이언트 컴포넌트여야 해요.
다음 장으로

지금 StockDash의 가격은 코드에 적어 둔 숫자이고, 종목도 손으로 입력해야 해요. 진짜 시세를 가져오려면 외부 주식 API와 비밀 키가 필요한데, 키를 브라우저 코드에 넣으면 누구나 볼 수 있죠. 27장에서는 Next 프로젝트 안에 작은 서버(API Routes)를 만들어 이 문제를 풉니다.

더 자세히 → Zustand 전역 상태Zustand란?Context API란?불변성

5부 · Next.js와 주식 대시보드

27API Routes — 프로젝트 안의 작은 서버

📎 React 교안 p.53~56💻 4.react/lessons/09_api_routes
이 장의 목표
  • route.js가 화면이 아니라 데이터(JSON)를 돌려주는 파일이라는 것을 설명할 수 있다.
  • export async function GET(request)과 Response.json()으로 엔드포인트를 만들 수 있다.
  • 쿼리스트링(?q=)과 동적 세그먼트([symbol])에서 값을 꺼내는 방법을 구분할 수 있다.
  • API 키를 서버 쪽 환경변수에 두어 브라우저에 노출하지 않는 이유를 말할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

26장(08)의 StockDash는 가격이 코드에 박힌 숫자였고 종목도 손으로 쳐야 했어요. 09에서는 같은 앱에 종목 검색창(StockSearch)을 붙이고, 진짜 시세를 줄 백엔드 주소를 만듭니다. 외부 주식 API(Finnhub)는 비밀 키가 필요하니, 키를 쥐고 대신 물어봐 줄 서버 쪽 코드가 필요하죠. 교안 p.54의 말처럼 시세 API를 먼저 만들어 둬야, 다음 회차(10)에서 스토어가 이 주소를 불러 가격을 채울 수 있습니다.

개념

① route.js — page.js와 같은 규칙, 다른 결과물

25장의 "폴더 = 주소" 규칙이 그대로 적용됩니다. 다만 폴더 안에 page.js 대신 route.js를 두면 그 주소는 화면이 아니라 데이터를 돌려줘요. app/api/search/route.js → /api/search. 파일에서 HTTP 메서드 이름(GET, POST …)으로 함수를 export하면, 그 방식의 요청이 올 때 Next가 실행해 줍니다. 응답은 웹 표준 Response.json(객체)로 만들어요. 같은 폴더에 page.js와 route.js를 함께 둘 수는 없습니다.

📎 React 교안 p.53 「API Routes 실습 흐름」, p.54 「Next.js API Routes (백엔드)」

② 값을 받는 두 길 — 쿼리스트링과 동적 세그먼트

검색어처럼 선택적인 값은 쿼리스트링으로 받아요. /api/search?q=app이면 new URL(request.url).searchParams.get('q')로 'app'을 꺼냅니다. "어떤 종목"처럼 주소의 일부인 값은 동적 세그먼트로 받아요. app/api/stock/[symbol]/route.js에서 GET(request, { params })의 두 번째 인자로 들어오고, 25장과 똑같이 await params로 꺼냅니다.

📎 React 교안 p.54 학습 목표 「쿼리스트링 · 동적 API 라우트 [symbol] + await params」

③ 키는 서버에만 — 환경변수와 더미 폴백

프로젝트 루트의 .env.local에 FINNHUB_API_KEY=…를 적으면 서버 코드에서 process.env.FINNHUB_API_KEY로 읽습니다. route.js는 서버에서만 실행되니 이 키는 브라우저로 가지 않아요. 브라우저는 /api/stock/AAPL만 부르고, 서버가 키를 붙여 Finnhub에 대신 물어본 뒤 결과만 돌려줍니다(우체국 창구 직원 같은 역할). 수업 코드는 키가 없으면 가짜 시세(source: 'dummy')를 돌려줘서, 키 없이도 앱이 멈추지 않게 했어요.

📎 React 교안 p.55 「요청은 어디로 흘러가나」

예제 — 수업 코드로 확인하기

검색 API — 쿼리스트링 받기

이 코드는 route.js의 기본형(GET → Response.json)과 쿼리스트링 읽기를 보여 주려고 만든 실습입니다. 아직 외부 API 없이, 파일 안의 배열에서 찾아요.

JSsrc/app/api/search/route.js — GET /api/search?q=...
const STOCKS = [
    { symbol: 'AAPL', name: 'Apple Inc.', exchange: 'NASDAQ' },
    { symbol: 'TSLA', name: 'Tesla Inc.', exchange: 'NASDAQ' },
    // …(생략: 8개 더)
]

export async function GET(request) {
    // 1) URL에서 쿼리파라미터(?q=...) 읽기
    const { searchParams } = new URL(request.url)
    const q = searchParams.get('q')?.toLowerCase() || ''

    // 2) 검색어가 없으면 바로 빈 결과
    if (!q) return Response.json({ results: [] })

    // 3) 종목코드나 회사명에 q가 들어간 것 최대 5개
    const results = STOCKS.filter(
        (s) => s.symbol.toLowerCase().includes(q) || s.name.toLowerCase().includes(q)
    ).slice(0, 5)

    return Response.json({ results })
}

👀 관찰 포인트 — 브라우저 주소창에 localhost:3000/api/search?q=app을 직접 치면 화면 대신 {"results":[{"symbol":"AAPL",…}]} JSON이 보입니다. 이게 "화면이 아니라 데이터를 돌려주는 주소"예요.

시세 API — 동적 세그먼트 + 키 분기

이 코드는 [symbol] 값 꺼내기, 환경변수 키 읽기, 키 유무에 따른 분기를 한 번에 보여 주려고 만든 실습입니다.

JSsrc/app/api/stock/[symbol]/route.js — GET /api/stock/AAPL
export async function GET(request, { params }) {
    // Next.js 15부터 params는 Promise → await params
    const { symbol } = await params

    // .env.local 에 등록된 키 (서버에서만 읽힘)
    const apiKey = process.env.FINNHUB_API_KEY

    // 키가 없으면 더미 시세
    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'
        })
    }

    // 키가 있으면 실제 Finnhub 호출
    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,
            timestamp: Date.now(),
            source: 'finnhub'
        })
    } catch (err) {
        return Response.json({ error: err.message }, { status: 500 })
    }
}

👀 관찰 포인트 — 키 없이 /api/stock/aapl을 여러 번 새로고침하면 가격이 매번 바뀌고 "source":"dummy"가 붙어 옵니다. Finnhub의 이상한 필드 이름(c, d, dp)을 price, change로 번역해서 돌려주는 것도 서버의 일이에요. 화면 쪽 코드는 Finnhub 형식을 몰라도 됩니다.

화면에서 내 API 부르기 — StockSearch

이 코드는 클라이언트 컴포넌트가 같은 프로젝트의 API를 상대경로로 부르는 법을 보여 주려고 만든 실습입니다.

JSXsrc/app/components/StockSearch.jsx — 입력할 때마다 검색
const handleChange = async (e) => {
    const q = e.target.value
    setQuery(q)

    if (!q.trim()) {          // 다 지웠으면 요청하지 않고 결과 비우기
      setResults([])
      return
    }

    // 같은 프로젝트의 API라서 주소가 '/api/...'로 시작 (도메인 생략)
    const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
    const data = await res.json()
    setResults(data.results || [])
}
// …(생략: handleSelect에서 addToWatchlist(symbol) → 08의 스토어로 연결)

👀 관찰 포인트 — 검색 결과를 클릭하면 26장의 addToWatchlist가 불려 관심 목록에 들어갑니다. "화면(StockSearch) → 내 서버(/api/search) → 스토어(useStockStore)"로 08과 09가 이어졌어요.

⚠️ 수업 코드에서 조심할 곳

① 09의 WatchlistPanel에 있는 "더미/실시간" 배지는 const data = { source: 'dummy' }로 값을 직접 적어 둔 것이라 실제 API 응답과 상관없이 항상 "더미"로 나옵니다. 응답의 source를 보여 주려면 /api/stock/…를 실제로 불러 그 값을 써야 해요. ② StockSearch는 글자를 칠 때마다 요청을 보내서, 응답이 보낸 순서와 다르게 도착하면 옛 검색어의 결과가 나중에 덮어쓸 수 있어요. 졸업 과제(31장)는 useDebounce로 "타이핑이 멈춘 뒤 한 번만" 요청하도록 고쳤습니다.

핵심 정리
  • app/api/…/route.js = 데이터 주소(엔드포인트). export async function GET(request) → Response.json(…).
  • 쿼리스트링은 new URL(request.url).searchParams.get('이름'), 동적 세그먼트는 GET(request, { params }) + await params.
  • 오류는 Response.json({ error }, { status: 500 })처럼 상태 코드와 함께 돌려준다.
  • process.env.FINNHUB_API_KEY는 서버에서만 읽힌다 → 키는 브라우저로 가지 않는다.
  • 화면에서는 같은 프로젝트의 API를 fetch('/api/…') 상대경로로 부른다.
확인 문제
/api/stock/nvda를 요청하면 route.js 안의 symbol과 응답의 symbol은 각각 무엇인가요?
await params로 꺼낸 값은 주소 그대로 'nvda'이고, 응답에서는 symbol.toUpperCase()를 거쳐 'NVDA'가 됩니다.
검색어를 쿼리스트링으로, 종목 코드를 동적 세그먼트로 받은 이유는?
검색어는 있을 수도 없을 수도 있는 옵션 값이라 ?q=가 자연스럽고, 종목 코드는 "어떤 자원(종목)의 시세냐"를 가리키는 주소의 일부라서 /api/stock/AAPL처럼 경로에 넣는 것이 자연스럽습니다.
'use client' 컴포넌트 안에서 process.env.FINNHUB_API_KEY를 읽으면 값이 나올까요?
나오지 않습니다(undefined). NEXT_PUBLIC_으로 시작하지 않는 환경변수는 브라우저 코드에 들어가지 않아요. 바로 그 덕분에 키가 숨겨집니다. 31장의 WebSocket처럼 브라우저가 직접 써야 하는 키만 NEXT_PUBLIC_을 붙입니다.
다음 장으로

시세를 주는 주소는 생겼지만, 아직 스토어가 이 주소를 부르지 않아요. 그리고 새로고침하면 추가한 관심 종목이 사라집니다. 28장에서는 스토어에 비동기 액션(fetchPrice)을 넣어 이 API를 부르고, persist로 관심 목록과 포트폴리오를 브라우저에 저장합니다.

더 자세히 → Next.js API RoutesAPI Routes란?

5부 · Next.js와 주식 대시보드

28persist와 비동기 액션 — 새로고침에도 남는 스토어, 매수·매도 포트폴리오

📎 React 교안 p.57~60💻 4.react/lessons/10_zustand_async_persist
이 장의 목표
  • 스토어 액션 안에서 async/await fetch로 API를 부르고 결과를 set할 수 있다.
  • get()으로 스토어 안의 다른 액션을 부를 수 있다.
  • persist와 partialize로 저장할 상태만 골라 localStorage에 남길 수 있다.
  • 평단가 재계산·부분 매도를 불변 업데이트로 짜고, 평가금액·손익은 컴포넌트에서 계산할 수 있다.
  • persist를 Next에서 쓸 때 생길 수 있는 hydration 불일치가 무엇인지 설명할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

26장(08)에서 만든 스토어와 27장(09)에서 만든 시세 API가 아직 서로 모르는 사이예요. 또 새로고침하면 관심 목록이 초기값으로 돌아갑니다. 10은 같은 StockDash 앱에서 ① 스토어가 /api/stock/[symbol]을 불러 가격을 채우고(async), ② 관심 목록·포트폴리오를 저장하고(persist), ③ 매수·매도 포트폴리오 패널을 새로 붙입니다. 교안 p.51의 상태관리 진화 4단계 중 마지막 "persist + async"가 이 장이에요.

개념

① 비동기 액션 — 액션은 그냥 함수라서 async여도 된다

Zustand는 비동기를 위한 특별한 도구가 필요 없어요. 액션을 async 함수로 만들고 요청 전·성공·실패 세 시점에 set을 부르면 됩니다: 시작할 때 loading: true, 응답이 오면 prices 갱신, 실패하면 error 저장. 또 액션 안에서 get()으로 다른 액션을 꺼내 부를 수 있어서, "관심 종목을 추가하자마자 그 종목 시세를 가져오기"를 addToWatchlist 안에서 fetchPrice(symbol) 한 줄로 해결해요. 여러 종목은 Promise.all로 동시에 부릅니다.

📎 React 교안 p.57 「persist · async · 포트폴리오 실습 흐름」, p.58 「async 액션에서 fetch → set」

② persist + partialize — 저장할 것만 골라 금고에

create(persist((set, get) => ({...}), { name, partialize }))처럼 스토어 정의를 persist로 한 번 더 감싸면, 상태가 바뀔 때마다 localStorage의 name 키에 JSON으로 저장하고 다음에 열 때 복원합니다. partialize는 저장 목록을 고르는 필터예요. 관심 목록·선택 종목·포트폴리오는 남기고, 시세(prices)·loading·error는 뺍니다. 시세는 오래된 값을 보여 주면 오해를 부르니 켤 때마다 새로 받는 게 맞기 때문이에요(교안 p.60 확장 과제 2가 이 장단점을 다룹니다).

📎 React 교안 p.58~59 「Zustand 심화 · persist · async」, p.60 확장 과제

③ 매수·매도 — 저장은 최소로, 계산은 화면에서

포트폴리오에는 종목별 수량(qty)과 평단가(avgPrice)만 저장해요. 추가 매수 시 평단가 = (기존 수량×기존 평단 + 새 수량×새 가격) ÷ 총 수량. 전량 매도면 그 종목을 지우는데, 원본을 건드리지 않도록 복사본에서 delete합니다. 평가금액·손익·수익률은 저장하지 않고 PortfolioPanel이 portfolio와 prices를 받아 reduce로 그때그때 계산해요. 저장한 값끼리 어긋날 일이 없게 하는 습관입니다.

📎 React 교안 p.58 「buy/sell 평단가 재계산·부분매도」, p.60 확장 과제 1

⚠️ 꼭 알아 둘 함정 — persist와 hydration

25장에서 본 것처럼 'use client' 컴포넌트도 첫 HTML은 서버에서 그려집니다. 그런데 서버에는 localStorage가 없으니 서버는 초기값(AAPL·TSLA·MSFT, AAPL 10주)으로 HTML을 만들어요. 브라우저에서는 persist가 localStorage 값을 스토어를 만들 때 바로 복원하므로, 저장된 목록이 초기값과 다르면 첫 렌더가 서버 HTML과 달라집니다. 그러면 React가 hydration 불일치 경고를 띄우거나 화면이 잠깐 깜빡일 수 있어요. 해결책은 "저장값은 브라우저에서 마운트된 뒤에 반영"하는 것입니다. 예를 들어 persist 옵션에 skipHydration: true를 주고 클라이언트 컴포넌트의 useEffect에서 useStockStore.persist.rehydrate()를 부르거나, 마운트 여부를 확인한 뒤 저장값을 그리는 방법이 있어요. 수업 코드에는 이 처리가 없으니, 콘솔에 hydration 경고가 보이면 이 원인부터 의심하세요.

예제 — 수업 코드로 확인하기

스토어가 API를 부르고, 골라서 저장한다

이 코드는 async 액션, get()으로 다른 액션 부르기, persist + partialize를 한 파일에서 보여 주려고 만든 실습입니다. 08 스토어와 나란히 놓고 무엇이 늘었는지 보세요.

JSsrc/app/store/useStockStore.js — 10 버전(발췌)
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

const useStockStore = create(
  persist(
    (set, get) => ({
      watchlist: ['AAPL', 'TSLA', 'MSFT'],
      selectedSymbol: 'AAPL',
      prices: {},                           // 08과 달리 비어 있음 → API로 채운다
      loading: false,
      error: null,
      portfolio: { AAPL: { qty: 10, avgPrice: 170.5 } },

      addToWatchlist: (symbol) => {
        const { watchlist, fetchPrice } = get() // get()으로 다른 액션도 꺼낸다
        if (watchlist.includes(symbol)) return
        set({ watchlist: [...watchlist, symbol] })
        fetchPrice(symbol)                      // 추가하자마자 시세 조회
      },

      fetchPrice: async (symbol) => {
        set({ loading: true, error: null })     // ① 시작
        try {
          const res = await fetch(`/api/stock/${symbol}`)   // 27장에서 만든 API
          if (!res.ok) throw new Error(`${symbol} 시세 조회 실패`)
          const data = await res.json()
          set((state) => ({                     // ② 성공
            prices: { ...state.prices, [symbol]: data.price },
            loading: false,
          }))
        } catch (error) {
          set({ error: error.message, loading: false })   // ③ 실패
        }
      },

      fetchAllPrices: async () => {
        const { watchlist, fetchPrice } = get()
        await Promise.all(watchlist.map((s) => fetchPrice(s)))
      },
      // …(생략: removeFromWatchlist, selectSymbol, buyStock, sellStock)
    }),
    {
      name: 'stock-dashboard',                  // localStorage 키 이름
      partialize: (state) => ({                 // 저장할 것만 고르기
        watchlist: state.watchlist,
        selectedSymbol: state.selectedSymbol,
        portfolio: state.portfolio,
      }),
    }
  )
)

👀 관찰 포인트 — 개발자 도구 → Application → Local Storage에서 stock-dashboard 키를 열어 보세요. watchlist·selectedSymbol·portfolio만 있고 prices와 loading은 없습니다. 그래서 WatchlistPanel은 마운트될 때 useEffect(() => { fetchAllPrices() }, [fetchAllPrices])로 시세를 다시 받아요. 참고로 loading은 하나뿐이라 여러 종목을 동시에 받을 때 먼저 끝난 요청이 false로 바꿔 버리는 한계가 있습니다.

평단가 재계산과 화면에서의 손익 계산

이 코드는 "저장은 수량·평단가만, 손익은 화면에서 계산"이라는 설계를 보여 주려고 만든 실습입니다.

JSuseStockStore.js — buyStock (발췌)
buyStock: (symbol, qty, price) => {
  const { portfolio } = get()
  const existing = portfolio[symbol]

  if (existing) {
    const totalQty = existing.qty + qty
    const totalCost = existing.qty * existing.avgPrice + price * qty
    set((state) => ({
      portfolio: {
        ...state.portfolio,
        [symbol]: { qty: totalQty, avgPrice: totalCost / totalQty },
      },
    }))
  } else {
    set((state) => ({
      portfolio: { ...state.portfolio, [symbol]: { qty: qty, avgPrice: price } },
    }))
  }
},
JSXsrc/app/components/PortfolioPanel.jsx — 파생 계산
const portfolio = useStockStore((s) => s.portfolio)
const prices = useStockStore((s) => s.prices)
const sellStock = useStockStore((s) => s.sellStock)

// { AAPL: {qty, avgPrice} } → [ ['AAPL', {qty, avgPrice}] ]
const holdings = Object.entries(portfolio)

// 시세가 아직 없으면 평단가로 대신 계산
const totalValue = holdings.reduce(
  (sum, [symbol, h]) => sum + (prices[symbol] || h.avgPrice) * h.qty,
  0
)
const totalCost = holdings.reduce(
  (sum, [, h]) => sum + h.avgPrice * h.qty,
  0
)
const totalPnl = totalValue - totalCost
const totalPnlPct = totalCost > 0 ? (totalPnl / totalCost) * 100 : 0
// …(생략: 카드 렌더, 전량 매도 버튼 onClick={() => sellStock(symbol, h.qty)})

👀 관찰 포인트 — 매수 버튼을 누르면 portfolio만 바뀌는데도 총 평가금액·손익이 같이 바뀝니다. 계산 결과를 따로 저장하지 않았기 때문에 가능한 일이에요. 새로고침하면 보유 종목은 그대로 남고, 시세만 다시 받아 손익이 새로 계산됩니다.

핵심 정리
  • 비동기 액션 = async 함수 + 시작/성공/실패 시점의 set. 다른 액션은 get().액션()으로 부른다.
  • create(persist(fn, { name, partialize })) — name은 localStorage 키, partialize는 저장할 상태 고르기.
  • 금방 낡는 값(시세)과 일시적인 값(loading·error)은 저장하지 않고 켤 때 다시 받는다.
  • 저장은 최소(수량·평단가), 파생 값(평가금액·손익)은 컴포넌트에서 reduce로 계산.
  • Next에서 persist는 서버 HTML(초기값)과 브라우저 첫 렌더(저장값)가 달라 hydration 경고를 낼 수 있다.
확인 문제
AAPL을 10주, 평단 $170.5에 보유 중입니다. $200에 5주를 더 사면 새 평단가는?
(10×170.5 + 5×200) ÷ 15 = (1,705 + 1,000) ÷ 15 = 2,705 ÷ 15 ≈ $180.33. 수량은 15주가 됩니다.
partialize에 prices를 넣으면 무엇이 좋아지고 무엇이 나빠지나요?
새로고침 직후 마지막 시세가 바로 보여 "로딩 중…" 깜빡임이 줄어듭니다. 대신 하루 지난 가격이 진짜 현재가처럼 보일 수 있어 오해를 부릅니다(교안 p.60).
sellStock에서 delete portfolio[symbol]; set({ portfolio })처럼 원본에서 바로 지우면?
같은 객체를 다시 넣은 것이라 portfolio를 구독한 컴포넌트가 "안 바뀌었다"고 보고 화면을 갱신하지 않을 수 있습니다. 수업 코드처럼 { ...portfolio }로 복사한 뒤 복사본에서 지워야 합니다.
NVDA를 추가한 뒤 새로고침했더니 콘솔에 hydration 경고가 떴습니다. 왜일까요?
서버는 localStorage를 볼 수 없어 초기 목록(AAPL·TSLA·MSFT)으로 HTML을 만들었고, 브라우저는 저장된 목록(NVDA 포함)으로 첫 렌더를 해서 둘이 달라졌기 때문입니다.
다음 장으로

지금까지 StockDash의 컴포넌트는 전부 'use client'였어요. 그런데 25장에서 "기본은 서버 컴포넌트"라고 했죠. 29장에서는 종목 정보 패널을 서버 컴포넌트로 만들고, 데이터가 늦게 와도 나머지 화면은 먼저 보이도록 Suspense로 감쌉니다.

더 자세히 → persist + async 액션persist 미들웨어란?불변성

5부 · Next.js와 주식 대시보드

29서버 컴포넌트와 Suspense 스트리밍 — 서버에서 미리 받아 그리기

📎 React 교안 p.61~64💻 4.react/lessons/11_server_components
이 장의 목표
  • 서버 컴포넌트와 클라이언트 컴포넌트가 각각 할 수 있는 일·없는 일을 표로 말할 수 있다.
  • async 서버 컴포넌트 안에서 await로 데이터를 받아 그릴 수 있다.
  • 서버 컴포넌트가 스토어를 못 읽을 때 값을 props나 URL로 넘기는 방법을 안다.
  • <Suspense fallback>이 페이지를 부분부분 먼저 보내는(스트리밍) 원리를 설명할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

25장에서 "App Router의 기본은 서버 컴포넌트"라고 배웠지만, 26~28장(08~10)의 StockDash는 버튼과 스토어 때문에 전부 클라이언트 컴포넌트였어요. 11에서는 같은 앱의 화면을 2열로 바꿔서, 왼쪽(검색·관심 목록·포트폴리오)은 그대로 클라이언트에 두고 오른쪽에 종목 정보 패널 StockInfo를 서버 컴포넌트로 새로 붙입니다. 오른쪽 아래에는 "차트 영역 (13강에서 구현)"이라는 빈자리도 미리 만들어 둬요.

개념

① 서버 컴포넌트 — 주방에서 완성해서 내보내는 요리

'use client'가 없는 컴포넌트는 서버에서만 실행돼요. 그래서 할 수 있는 일과 없는 일이 분명합니다. 할 수 있는 일: 컴포넌트 함수를 async로 만들고 안에서 await로 데이터 받기, API 키·DB 같은 서버 비밀 쓰기(브라우저로 코드가 안 감). 할 수 없는 일: useState·useEffect·onClick, 그리고 Zustand 같은 훅. 반대로 클라이언트 컴포넌트는 상태·이벤트는 되지만 컴포넌트 자체를 async로 만들 수 없고, 키를 쓰면 노출돼요. 그래서 대시보드는 둘을 섞어서 짓습니다.

📎 React 교안 p.62 「서버 컴포넌트 · Suspense」, p.63 「서버 컴포넌트 vs 클라이언트 컴포넌트」

② 섞는 규칙 — 서버 컴포넌트는 스토어를 못 읽는다

서버 컴포넌트(page.js)는 클라이언트 컴포넌트(WatchlistPanel)를 자식으로 품을 수 있어요. 하지만 StockInfo는 서버 컴포넌트라 useStockStore(s => s.selectedSymbol)을 부를 수 없습니다. 그래서 종목 코드를 props로 받아야 해요. 사용자가 왼쪽에서 종목을 눌렀을 때 오른쪽이 따라오게 하려면? 교안 p.64의 답처럼 URL의 ?symbol=을 다리로 씁니다. 클라이언트(WatchlistPanel)가 router.push('/?symbol=TSLA')로 주소를 바꾸면, 서버의 page.js가 searchParams(Next 15+에선 Promise라 await)로 그 값을 읽어 StockInfo에 넘겨요.

📎 React 교안 p.61 「서버 컴포넌트 + Suspense 실습 흐름」, p.64 확장 과제 2

③ Suspense — 다 될 때까지 기다리지 않고, 된 것부터 보낸다

StockInfo는 데이터를 받느라 2초가 걸려요. Suspense가 없으면 페이지 전체가 2초 동안 안 나옵니다. <Suspense fallback={<CardSkeleton />}>로 감싸면 Next는 먼저 왼쪽 패널과 스켈레톤이 든 HTML을 보내고, StockInfo가 준비되면 그 부분의 HTML을 같은 응답에 이어서 흘려보내(스트리밍) 스켈레톤과 바꿔 끼웁니다. Suspense는 "여기까지는 늦어도 된다"는 경계선이에요. 그동안 왼쪽 패널은 막히지 않고 정상 동작합니다.

📎 React 교안 p.62 학습 목표 「Suspense fallback으로 async 로딩 UX 처리」, p.64 확장 과제 1

예제 — 수업 코드로 확인하기

async 서버 컴포넌트 StockInfo

이 코드는 "컴포넌트 자체가 async이고 안에서 await로 데이터를 받는다"는 서버 컴포넌트의 특징을 보여 주려고 만든 실습입니다. delay(2000)은 느린 API를 흉내 내서 Suspense를 눈으로 보기 위한 장치예요.

JSXsrc/app/components/StockInfo.jsx — 'use client' 없음 = 서버 컴포넌트
// (헬퍼) 느린 API 흉내 (Suspense fallback 확인용)
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

async function getStockProfile(symbol) {
  await delay(2000)
  // 서버의 fetch는 브라우저가 아니므로 상대경로가 아닌 절대 URL이 필요
  const res = await fetch(`http://localhost:3000/api/stock/${symbol}/profile`)
  const data = await res.json()
  return data
}

export default async function StockInfo({ symbol }) {
  const profile = await getStockProfile(symbol);
  return (
    <div style={{ /* …(생략) */ }}>
      <h3 style={{ color: '#61dafb', marginTop: 0, marginBottom: '0.5rem' }}>{profile.name} ({symbol})</h3>
      {/* …(생략: 거래소·업종·시가총액·설명) */}
    </div>
  )
}

👀 관찰 포인트 — useEffect도 useState도 없이 "받고 → 그린다"가 위에서 아래로 끝납니다. 개발자 도구의 Network 탭을 보면 브라우저는 /api/stock/…/profile을 부르지 않아요. 서버가 서버 안에서 불렀기 때문입니다.

page.js — 왼쪽은 클라이언트, 오른쪽은 Suspense로 감싼 서버

이 코드는 Suspense 경계와 URL로 값 넘기기를 보여 주려고 만든 실습입니다.

JSXsrc/app/page.js — 서버 컴포넌트(조립)
import { Suspense } from 'react'
import StockSearch from './components/StockSearch'
import WatchlistPanel from './components/WatchlistPanel'
import PortfolioPanel from './components/PortfolioPanel'
import StockInfo from './components/StockInfo'
// …(생략: CardSkeleton — 반짝이는 빈 카드)

export default async function DashboardPage({ searchParams }) {
  const { symbol } = await searchParams      // /?symbol=TSLA → 'TSLA'
  const symbolVal = symbol || "AAPL"
  return (
    <div style={{ display: 'grid', gridTemplateColumns: '300px 1fr', /* …(생략) */ }}>
      <aside style={{ /* …(생략) */ }}>
        <StockSearch />        {/* 'use client' */}
        <WatchlistPanel />     {/* 'use client' */}
        <PortfolioPanel />     {/* 'use client' */}
      </aside>

      <main style={{ /* …(생략) */ }}>
        <Suspense fallback={<CardSkeleton />}>
          <StockInfo symbol={symbolVal} />
        </Suspense>

        <div style={{ /* …(생략) */ }}>
          <p style={{ color: '#8892b0' }}>차트 영역 (13강에서 구현)</p>
        </div>
      </main>
    </div>
  )
}
JSXsrc/app/components/WatchlistPanel.jsx — 클라이언트가 URL을 바꾼다(발췌)
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
// …(생략)
  const router = useRouter()
// …(생략)
              onClick={() => {
                selectSymbol(symbol) // 클릭 시 선택된 종목 변경 (하이라이트 표시)
                router.push(`/?symbol=${symbol}`)
              }}

👀 관찰 포인트 — 처음 열면 왼쪽은 바로 보이고 오른쪽만 약 2초간 스켈레톤이 보이다 교체됩니다. delay(4000)으로 늘려도 왼쪽은 그동안 정상 동작해요(확장 과제 1). 관심 종목을 누르면 주소가 /?symbol=TSLA로 바뀌며 오른쪽이 TSLA로 다시 그려집니다. 이때 스켈레톤 없이 이전 내용이 잠깐 남아 있다 바뀔 수 있는데, 종목마다 스켈레톤을 다시 보고 싶다면 <Suspense key={symbolVal}>처럼 key를 주어 경계를 새로 만들면 됩니다.

⚠️ 수업 코드에서 조심할 곳

11의 StockInfo는 http://localhost:3000/api/…로 자기 프로젝트의 API Route를 다시 HTTP로 부릅니다. 주소에 포트까지 박혀 있어서 3001 포트로 띄우거나 배포하면 깨지고, 서버가 서버에게 한 번 더 요청하는 셈이라 낭비예요. 서버 컴포넌트는 이미 서버에서 돌기 때문에, Next 공식 문서도 서버 컴포넌트에서는 Route Handler를 거치지 말고 데이터 로직(함수)을 직접 부르라고 권합니다. 예를 들어 Finnhub를 부르는 함수를 lib/에 두고 route.js와 StockInfo가 함께 import하는 식이죠. (12·13의 StockInfo는 이 fetch를 없애고 더미 데이터를 돌려주는 함수로 바뀌었습니다.)

핵심 정리
  • 서버 컴포넌트: async·await 가능, 비밀 키 안전, 훅·이벤트 불가. 클라이언트 컴포넌트: 그 반대.
  • 서버 컴포넌트는 스토어를 못 읽는다 → 값은 props로, 사용자 선택을 따라가려면 URL(?symbol=)로.
  • Next 15+의 searchParams도 params처럼 Promise → await searchParams.
  • <Suspense fallback>은 느린 부분의 경계. 나머지는 먼저 보내고, 준비된 부분을 이어서 스트리밍한다.
확인 문제
StockInfo.jsx 맨 위에 'use client'를 붙이면 어떻게 될까요?
클라이언트 컴포넌트는 컴포넌트 함수 자체를 async로 만들 수 없어 에러가 납니다(교안 p.63 「async 컴포넌트 ✕」). 또 키를 쓰는 코드가 브라우저로 내려가게 돼요.
StockInfo 안에서 useStockStore((s) => s.selectedSymbol)로 선택 종목을 읽으면 안 되는 이유와 대안은?
서버 컴포넌트는 훅을 부를 수 없고, Zustand 스토어는 브라우저에 있는 상태라 서버가 볼 수 없습니다. 대안은 ① 부모가 props로 넘기기, ② URL ?symbol=로 넘기고 page.js가 searchParams로 읽기, ③ StockInfo를 클라이언트 컴포넌트로 바꾸고 useEffect로 fetch하기입니다.
page.js 자체도 async 서버 컴포넌트인데, 그 안에 'use client' 컴포넌트를 넣어도 되나요?
됩니다. 서버 컴포넌트는 클라이언트 컴포넌트를 자식으로 렌더링할 수 있어요. 반대 방향은 조심해야 해요. 클라이언트 컴포넌트 파일에서 import한 컴포넌트는 클라이언트 쪽 코드가 되어 버려 async·서버 비밀을 쓸 수 없습니다. 서버 컴포넌트로 남기려면 서버 쪽 부모가 children이나 props로 넘겨줘야 해요.
다음 장으로

StockDash의 뼈대(상태·서버·저장·서버 컴포넌트)가 다 섰어요. 그런데 파일마다 style={{ ... }}가 길게 늘어져 있고 같은 색이 여기저기 반복됩니다. 30장에서는 이 인라인 스타일을 Tailwind CSS로 바꾸고, 스토어에 테마를 넣어 다크 모드 토글을 답니다.

더 자세히 → 서버 컴포넌트 + Suspense서버 vs 클라이언트 컴포넌트

5부 · Next.js와 주식 대시보드

30Tailwind CSS와 다크 모드 — 클래스 이름이 곧 스타일

📎 React 교안 p.65~67💻 4.react/lessons/12_tailwind_darkmode
이 장의 목표
  • 유틸리티 클래스(p-5 rounded-xl border)로 스타일을 조합하는 방식을 설명할 수 있다.
  • Tailwind v4가 설정 파일 대신 CSS(@import·@theme)로 설정한다는 것을 안다.
  • md: 같은 반응형 접두사와 dark: 변형이 언제 켜지는지 말할 수 있다.
  • 스토어의 theme → ThemeWrapper → <html class="dark">로 이어지는 다크 모드 흐름을 설명할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

08~11(26~29장)을 거치며 StockDash 컴포넌트마다 style={{ padding: '12px', borderRadius: '8px', background: '#0d2137', … }} 같은 객체가 쌓였어요. 같은 색 코드가 파일마다 복사돼 있고, 라이트/다크 전환은 엄두도 못 냅니다. 12는 같은 앱의 모양을 Tailwind로 갈아입히고, 26장의 스토어에 theme 상태 하나를 더해 앱 전체를 한 번에 바꾸는 다크 모드 토글을 붙여요. 관심 목록도 개별 카드 컴포넌트(StockCard)로 나눕니다.

개념

① 유틸리티 클래스 — 레고 블록처럼 조합

Tailwind는 CSS 속성 하나짜리 클래스를 잔뜩 미리 만들어 둡니다. p-5(padding), rounded-xl(둥근 모서리), border, text-xs… 이걸 className에 나열하면 CSS 파일을 따로 짜지 않아도 돼요. 앞에 md:를 붙이면 화면 폭 768px 이상에서만 적용되고, grid-cols-[300px_1fr]처럼 대괄호 안에 임의 값도 넣을 수 있습니다. 그래서 grid-cols-1 md:grid-cols-[300px_1fr]은 "휴대폰은 1열, 태블릿 이상은 300px 사이드바 + 나머지"예요.

📎 React 교안 p.66 「인라인 스타일 → 유틸리티 클래스 · 반응형」, p.67 확장 과제 1

② Tailwind v4 — 설정은 CSS 안에서

v4에는 tailwind.config.js가 없어도 됩니다. globals.css에서 @import "tailwindcss"; 한 줄로 불러오고(빌드는 postcss.config.mjs의 @tailwindcss/postcss가 담당), @theme 안에 --color-이름 변수를 선언하면 그 이름의 클래스가 자동으로 생겨요. --color-stock-card: #0d2137; → bg-stock-card, text-stock-card, border-stock-card. 흩어져 있던 색 코드를 이름 붙은 팔레트 한 곳으로 모으는 거죠.

📎 React 교안 p.66 핵심 개념 「@theme 커스텀 색 → 유틸 자동 생성」

③ 클래스 기반 다크 모드 — 스위치는 <html>에 하나

dark:bg-stock-card는 "다크일 때만 이 배경"이라는 조건부 클래스예요. 기본 설정에서는 다크 여부를 운영체제 설정(prefers-color-scheme)으로 판단하는데, 우리는 버튼으로 바꾸고 싶으니 "조상에 .dark 클래스가 있으면 다크"로 규칙을 바꿉니다. 흐름은 이래요: 버튼(ThemeToggle) → 스토어 toggleTheme() → theme 변경 → ThemeWrapper의 useEffect가 document.documentElement(=<html>)에 dark 클래스를 붙이거나 뗌 → 페이지 안 모든 dark: 클래스가 한꺼번에 켜지거나 꺼짐. theme는 28장의 partialize에 추가해 새로고침해도 유지돼요.

📎 React 교안 p.65 「Tailwind · 다크모드 토글 실습 흐름」, p.66 「@variant 클래스 기반 다크모드」

⚠️ 교안 표현 바로잡기

교안 p.66과 수업 코드, 학습 Q&A는 다크 모드 규칙을 @variant dark (&:where(.dark, .dark *));로 쓰고 Q&A는 이를 「정식 문법」이라고 했지만, Tailwind v4 공식 문서의 표기는 @custom-variant dark (&:where(.dark, .dark *));입니다. @custom-variant는 새 변형을 정의하는 지시어이고, @variant는 원래 CSS 규칙 안에서 이미 있는 변형을 적용할 때 쓰는 지시어예요(예: .card { @variant dark { … } }). 수업 코드는 이 줄로 다크 모드가 동작했지만, 새로 작성할 때는 공식 표기를 쓰세요. VS Code에 빨간 밑줄이 뜨는 건 어느 쪽이든 에디터 기본 CSS 검사기가 Tailwind 지시어를 모르기 때문이고, 수업에서는 .vscode/settings.json에 "css.lint.unknownAtRules": "ignore"를 넣어 숨겼습니다.

예제 — 수업 코드로 확인하기

globals.css — 불러오기, 다크 규칙, 팔레트

이 코드는 Tailwind v4를 CSS 파일 하나로 설정하는 법을 보여 주려고 만든 실습입니다.

CSSsrc/app/globals.css — Tailwind v4 설정
@import "tailwindcss";

/* 클래스 기반 다크모드 (<html>.dark 일 때 dark: 유틸리티가 동작)
   ※ 공식 표기는 @custom-variant dark (&:where(.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;
}
/* …(생략: shimmer 애니메이션) */

👀 관찰 포인트 — 08~11에서 인라인 style에 반복해 적던 #0d2137, #61dafb가 여기 한 번만 있습니다. 이제 컴포넌트에서는 색 코드 대신 bg-stock-card, text-stock-cyan 같은 이름만 써요.

스토어 → ThemeWrapper → <html class="dark">

이 코드는 전역 상태(theme)를 실제 DOM 클래스로 옮기는 다리를 보여 주려고 만든 실습입니다. 화면엔 아무것도 그리지 않고 children만 돌려주는 "투명 컴포넌트"예요.

JSuseStockStore.js — theme 추가(발췌)
      theme: 'dark',
      toggleTheme: () => set((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark' })),
      // …(생략: 10강 상태·액션)
    {
      name: 'stock-dashboard',
      partialize: (state) => ({
        theme: state.theme,          // 테마도 저장
        watchlist: state.watchlist,
        selectedSymbol: state.selectedSymbol,
        portfolio: state.portfolio,
      }),
    }
JSXsrc/app/components/ThemeWrapper.jsx — <html>에 .dark 붙이기
'use client'

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

export default function ThemeWrapper({ children }) {
  const theme = useStockStore((s) => s.theme)

  useEffect(() => {
    // document.documentElement: 웹 페이지의 가장 최상단 태그인 <html> 요소
    const root = document.documentElement
    if (theme === 'dark') {
      root.classList.add('dark')
    } else {
      root.classList.remove('dark')
    }
  }, [theme]) // theme 상태가 변경될 때마다 실행

  return children
}
JSXsrc/app/layout.js · page.js — dark: 와 반응형(발췌)
// layout.js
<body className="bg-white text-gray-900 dark:bg-stock-bg dark:text-stock-light min-h-screen">
  <ThemeWrapper>
    {/* …(생략: 헤더 + <ThemeToggle />) */}
    {children}
  </ThemeWrapper>
</body>

// page.js
<main className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-4 max-w-7xl mx-auto p-4">

👀 관찰 포인트 — 🌙/☀️ 버튼을 누르면 개발자 도구 Elements 탭에서 <html lang="ko" class="dark">의 class가 생겼다 사라지는 게 보입니다. ThemeWrapper가 감싸지 않은 <body>까지 바뀌는 이유는 클래스를 <html>에 직접 붙이기 때문이에요. 창을 768px보다 좁히면 2열이 1열로 바뀝니다. 한계도 하나 보세요: .dark는 useEffect가 돈 뒤에 붙으므로, 새로고침 직후 아주 잠깐 라이트 화면이 보였다가 다크로 바뀔 수 있어요(28장의 hydration 이야기와 같은 뿌리입니다).

핵심 정리
  • Tailwind = 속성 하나짜리 클래스를 className에 조합. md:는 768px 이상, [...]는 임의 값.
  • v4는 CSS에서 설정: @import "tailwindcss", @theme { --color-이름: 값 } → bg-이름 등 자동 생성.
  • 버튼으로 바꾸는 다크 모드는 "조상에 .dark" 규칙으로 정의한다. 공식 표기는 @custom-variant dark (…).
  • theme는 스토어 상태 + persist 저장, ThemeWrapper가 <html>의 class로 옮긴다.
확인 문제
@theme에 --color-stock-gold: #ffd700;을 추가하면 어떤 클래스를 쓸 수 있나요?
bg-stock-gold, text-stock-gold, border-stock-gold처럼 색을 받는 유틸리티 전부에 stock-gold라는 이름이 생깁니다. dark:text-stock-gold, bg-stock-gold/20(투명도)도 됩니다.
폭 500px 휴대폰에서 grid-cols-1 md:grid-cols-[300px_1fr]은 몇 열인가요?
1열입니다. md:는 768px 이상에서만 켜지므로 500px에서는 grid-cols-1만 적용돼요.
다크 모드에서 새로고침해도 다크가 유지되는 이유는?
partialize에 theme를 넣어 localStorage에 저장했기 때문입니다. 스토어가 저장값 'dark'로 복원되면 ThemeWrapper의 effect가 다시 <html>에 dark를 붙여요.
globals.css에서 다크 규칙 줄을 지우면 토글 버튼은 어떻게 될까요?
<html>에 dark 클래스가 붙어도 dark: 유틸리티가 반응하지 않습니다. 대신 기본 규칙대로 운영체제의 다크 설정을 따라가요. 그래서 이 한 줄이 토글과 화면을 잇는 열쇠입니다.
다음 장으로

오른쪽 아래의 "차트 영역 (13강에서 구현)" 자리가 아직 비어 있어요. 31장에서는 이 자리를 Recharts 라인 차트로 채우고, WebSocket으로 실시간 체결가를 받아 선이 계속 자라게 만들어 StockDash를 완성합니다. 그리고 이 모든 기술이 졸업 과제에서 어디에 쓰였는지 지도를 그려 봅니다.

더 자세히 → Tailwind CSS + 다크모드Tailwind CSS란?다크모드 Q&A

5부 · Next.js와 주식 대시보드

31Recharts와 WebSocket, 그리고 졸업 과제 StockDash

📎 React 교안 p.68~71💻 4.react/lessons/13_recharts_websocket💻 4.react/stock-dashboard
이 장의 목표
  • Recharts로 [{ time, price }] 배열을 라인 차트로 그릴 수 있다.
  • "초기 데이터는 REST로, 이후 갱신은 실시간으로" 나누는 이유를 설명할 수 있다.
  • WebSocket의 연결 → 구독 → 수신 → 정리 4단계와, 정리(cleanup)에서 연결 중인 소켓도 닫아야 하는 이유를 말할 수 있다.
  • 폴링(Pull)과 WebSocket(Push)의 차이, NEXT_PUBLIC_ 키가 노출된다는 사실을 안다.
  • 졸업 과제 StockDash에서 각 레슨의 기술이 어디에 쓰였는지 짚을 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결

13은 08부터 키워 온 StockDash의 마지막 회차예요(교안 p.69 「마지막 회차 — 앱 완성」). 11(29장)에서 비워 둔 "차트 영역" 자리를 StockChart로 채웁니다. 이 컴포넌트는 지금까지 배운 것을 거의 다 씁니다: 27장의 API Route로 초기 데이터를 받고, 26장 스토어의 selectedSymbol을 구독하고 setPrice로 가격을 돌려놓고, 30장의 Tailwind·다크 클래스로 칠해요. 그 다음엔 이 13개 레슨을 스스로 다시 조합해 만든 졸업 과제를 레슨별로 해부합니다.

개념

① Recharts — 차트도 컴포넌트로 조립한다

Recharts는 차트를 JSX 부품으로 그려요. <ResponsiveContainer>(부모 폭에 맞춤) 안에 <LineChart data={chartData}>를 두고, <XAxis dataKey="time">·<Line dataKey="price">처럼 배열 원소의 어떤 키를 축/선에 쓸지만 알려 주면 됩니다. 데이터 모양은 [{ time: '오후 02:15', price: 182.3 }, …]. 그래서 차트를 움직이게 하는 일은 결국 이 배열 state를 바꾸는 일이에요. 새 점이 오면 [...prev.slice(-59), point]로 가장 오래된 점 하나를 버리고 새 점을 붙여 항상 60개를 유지합니다(슬라이딩 윈도우).

📎 React 교안 p.69 「Recharts : ResponsiveContainer + LineChart」

② 초기 데이터는 REST, 이후는 실시간 — effect 두 개

실시간 연결은 "지금부터 생기는 체결"만 알려 줘요. 처음부터 빈 차트에 점 하나씩 찍히면 볼품없으니 effect 1에서 /api/stock/[symbol]/chart로 최근 60개를 먼저 받아 선을 그려 둡니다(종목이 빨리 바뀌면 늦게 온 옛 응답을 버리도록 alive 취소표 사용). 로딩이 끝나면 effect 2가 실시간 갱신을 시작해요. 두 effect의 의존성에 selectedSymbol이 있어서, 종목을 바꾸면 cleanup으로 옛 연결을 정리하고 새로 시작합니다.

📎 React 교안 p.68 「Recharts · 실시간 시세 실습 흐름」

③ 폴링(Pull) vs WebSocket(Push), 그리고 정리 4단계

폴링은 2초마다 "가격 줘?" 하고 묻는 방식이라 간격만큼 늦고 요청이 낭비될 수 있어요. WebSocket은 전화선을 하나 열어 두고 서버가 체결될 때마다 밀어 주는 방식입니다. 수업 코드는 NEXT_PUBLIC_FINNHUB_API_KEY가 있으면 WebSocket, 없으면 2초 폴링으로 폴백해요. WebSocket은 ① new WebSocket(url) 연결 → ② onopen에서 subscribe 전송 → ③ onmessage로 체결 수신 → ④ cleanup에서 정리.

정리 규칙이 중요합니다. 소켓이 OPEN일 때만 unsubscribe를 보낼 수 있어요(연결 중에 send하면 에러). 하지만 close()는 상태와 상관없이 항상 불러야 합니다. 아직 CONNECTING인 소켓을 그냥 두면 잠시 뒤 연결이 열리면서 onopen이 이미 떠난 종목을 구독해 버려요. 종목을 빠르게 연달아 바꿀 때가 그렇고, 개발 모드의 StrictMode는 마운트 때 effect를 "실행 → 정리 → 재실행"으로 한 번 더 돌리므로 졸업 과제의 useLiveTicker처럼 마운트하자마자 소켓을 여는 코드는 첫 소켓이 거의 항상 CONNECTING 상태에서 정리됩니다. 그래서 수업 코드도 if (OPEN) unsubscribe 바깥에서 ws.close()를 부릅니다.

📎 React 교안 p.70 「실시간 데이터: 당겨오나 밀어주나」, p.69 「WebSocket 4단계」

④ NEXT_PUBLIC_ 키와 렌더 안전

WebSocket은 브라우저가 직접 Finnhub에 연결하므로 서버 전용 키(27장)를 못 써요. 그래서 NEXT_PUBLIC_이 붙은 키를 쓰는데, 이 값은 빌드할 때 브라우저용 JS에 그대로 박혀서 누구나 볼 수 있습니다. 교안 p.70의 경고처럼 실서비스에서는 서버가 WebSocket을 대신 연결해 중계(프록시)해야 안전해요. 또 하나: setChartData(prev => …) 업데이터 함수는 순수해야 해서, 그 안에서 스토어의 setPrice를 부르면 "Cannot update a component while rendering a different component" 에러가 납니다. 그래서 pushPrice는 두 호출을 나란히 둡니다(교안 p.71 확장 과제 1).

📎 React 교안 p.70 ⚠️ 주의, p.71 확장 과제

⑤ 졸업 과제 StockDash — 어느 레슨의 기술이 어디에

졸업 과제(stock-dashboard, Next 16 · React 19 · Zustand 5 · Recharts 3 · Tailwind v4)는 13개 레슨을 따라 친 게 아니라 필요한 도구를 골라 다시 조합한 앱이에요. 지도처럼 짚어 보면:

  • 07 App Router → app/layout.js는 'use client' 없는 서버 컴포넌트, app/api/stock/[symbol]/chart/route.js에서 동적 세그먼트 + await params.
  • 08 Zustand → store/useWatchlistStore.js에 관심 목록과 테마. 단, 선택 종목은 스토어가 아니라 page.js의 useState로 들고 자식에게 onSelect로 넘깁니다(상태 끌어올리기).
  • 09 API Routes → /api/search, /api/quote, /api/recommendation, /api/earnings, /api/news/…, /api/stock/[symbol]/chart. Finnhub REST 키 FINNHUB_API_KEY는 전부 이 route.js들 안에만 있어요.
  • 10 persist → persist(…, { name: 'watchlist-storage' })로 관심 목록·테마 저장.
  • 11 Suspense → 쓰긴 했지만 뜻이 달라요. page.js가 'use client'라 서버 컴포넌트 데이터 스트리밍이 아니라, 05강의 lazy(() => import(…)) + <Suspense>로 NewsList 코드를 나눠 받는 용도입니다. 데이터는 클라이언트에서 fetch('/api/…')로 받아요.
  • 12 Tailwind·다크 → 같은 @theme 팔레트와 다크 규칙, ThemeWrapper 그대로 + 차트 색을 CSS 변수로 테마에 연동.
  • 13 Recharts·WebSocket → StockChart(REST 초기 → WebSocket, 13강 패턴), 상단 전광판 TickerBoard의 useLiveTicker 훅(여러 종목 동시 구독). 둘 다 NEXT_PUBLIC_FINNHUB_API_KEY를 씁니다.
  • 04 커스텀 훅도 다시 등장 → useDebounce(검색 요청 줄이기, 27장의 숙제 해결), useStockData(시세 카드).

📎 교안 없음 — 졸업 과제 코드와 README가 교재

⚠️ 학습 자료 바로잡기

① 학습 Q&A와 마스터 가이드는 미국장 시간을 「한국 기준 밤 11:30~아침 06:00」으로 적었는데, 이는 서머타임이 아닐 때(11월 첫 일요일~3월 둘째 일요일)만 맞아요. 서머타임 기간에는 밤 10:30~아침 05:00입니다. 수업의 isUSMarketOpen()은 America/New_York 시간대로 계산해서 서머타임을 자동으로 따라가요(공휴일은 모름). ② 마스터 가이드 13.3은 if (token && isUSMarketOpen())일 때 WebSocket을 쓴다고 적었지만, 실제 13 코드는 if (token)만 봅니다. 그래서 키가 있으면 장이 닫혀 있어도 WebSocket을 열고, 체결이 없으니 차트가 멈춰 보여요. 교안 p.71 확장 과제 2가 바로 이걸 isUSMarketOpen으로 막는 과제입니다.

예제 — 수업 코드로 확인하기

StockChart — 실시간 갱신 effect (13)

이 코드는 WebSocket 4단계와 폴링 폴백, 그리고 각각의 정리를 보여 주려고 만든 실습입니다. 초기 데이터 effect(alive 취소표)가 끝난 뒤에만 시작해요.

JSXsrc/app/components/StockChart.jsx — 2단계 effect(발췌)
  useEffect(() => {
    if (isLoading) return // 초기 데이터 로드가 끝날 때까지 대기

    const pushPrice = (price) => {
      const newPrice = parseFloat(price.toFixed(2))
      lastPriceRef.current = newPrice
      const point = { time: nowLabel(), price: newPrice }
      setChartData((prev) => [...prev.slice(-59), point]) // 최신 60개 유지
      setPrice(selectedSymbol, newPrice) // 스토어 갱신은 업데이터 '밖'에서!
    }

    // NEXT_PUBLIC_ 이 붙어야 브라우저에서 읽을 수 있음 (= 브라우저에 노출됨)
    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) // 가장 최근 체결의 p(가격)
      }
      ws.onerror = (err) => console.log('websocket 오류:', err)

      // ④ 정리
      return () => {
        if (ws.readyState === WebSocket.OPEN) {   // 열려 있을 때만 구독 해제 가능
          ws.send(JSON.stringify({ type: 'unsubscribe', symbol: selectedSymbol }))
        }
        ws.close()   // CONNECTING이어도 반드시 닫는다
      }
    } else {
      // 폴링 폴백: 2초마다 27장의 시세 API 호출
      const id = setInterval(async () => {
        try {
          const res = await fetch(`/api/stock/${selectedSymbol}`, { cache: 'no-store' })
          const q = await res.json()
          if (typeof q.price === 'number' && q.price !== 0) pushPrice(q.price)
        } catch (err) {
          console.warn('[StockChart] 폴링 실패:', err.message)
        }
      }, 2000)

      return () => clearInterval(id) // 타이머 정리
    }
  }, [selectedSymbol, isLoading, setPrice])

👀 관찰 포인트 — 키 없이 실행하면 초기 60개 선이 그려진 뒤 2초마다 오른쪽 끝에 점이 붙으며 선이 왼쪽으로 밀립니다. 왼쪽 관심 목록의 가격도 같이 바뀌는데, setPrice로 스토어를 갱신했기 때문이에요. 개발자 도구 Network → WS 탭(키가 있을 때)에서 종목을 바꿀 때마다 옛 연결이 닫히고 새 연결이 열리는 것도 확인해 보세요. 13의 page.js에서 StockInfo는 아직 symbol="AAPL" 고정이라 차트만 선택 종목을 따라갑니다.

졸업 과제 — 키는 어디에 숨고, Suspense는 무엇을 기다리나

이 두 조각은 "REST 키는 API Route 뒤에 숨기고, 브라우저가 직접 여는 WebSocket만 공개 키를 쓴다"는 설계와 클라이언트 페이지에서의 lazy + Suspense를 보여 주려고 골랐습니다.

JSstock-dashboard/src/app/api/search/route.js — 서버 전용 키(발췌)
export async function GET(request) {
    const { searchParams } = new URL(request.url)
    const q = searchParams.get('q')?.toLowerCase().trim() || ''
    if (!q) return Response.json({ results: [] })

    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()   // { count, result: [...] } 형태의 객체
    const STOCKS = data.result
    let results = STOCKS
        .map((s) => ({ symbol: s.displaySymbol, name: s.description, type: s.type }))
        .slice(0, 5)
    // …(생략: 'bit'/'btc' 검색 시 비트코인 심볼 추가)
    return Response.json({ results })
}
JSXstock-dashboard/src/app/page.js — 클라이언트 페이지 + lazy(발췌)
'use client' // useState를 쓰므로 클라이언트 컴포넌트 선언
import { useState, lazy, Suspense } from 'react'
// …(생략: StockSearch, StockQuoteCard, TickerBoard, StockChart 등 import)

// NewsList의 코드는 실제로 렌더링될 때 내려받는다 (코드 분할)
const NewsList = lazy(() => import('@/components/NewsList'))

export default function DashboardPage() {
  const [selectedSymbol, setSelectedSymbol] = useState(null)

  return (
    <main id="main-content" className="mx-auto max-w-7xl space-y-8 px-4 py-6 sm:px-6 sm:py-8 lg:space-y-10 lg:px-8">
      <TickerBoard selectedSymbol={selectedSymbol} />
      {/* …(생략: 검색 섹션) */}
      <StockSearch onSelect={setSelectedSymbol} />
      <WatchlistPanel onSelect={setSelectedSymbol} selectedSymbol={selectedSymbol} />
      {/* …(생략: 시세·투자의견·실적 카드) */}
      <StockChart symbol={selectedSymbol} />

      {/* …(생략: 기업 뉴스 섹션) */}
      <Suspense fallback={<Skeleton variant="list" className="h-48 w-full" />}>
        <NewsList mode="market" />
      </Suspense>
    </main>
  )
}

👀 관찰 포인트 — 프로젝트 전체에서 FINNHUB_API_KEY는 app/api/ 아래 route.js에만, NEXT_PUBLIC_FINNHUB_API_KEY는 StockChart.jsx와 useLiveTicker.js 두 곳에만 나옵니다. 그리고 여기의 Suspense는 29장처럼 "서버 데이터"를 기다리는 게 아니라 NewsList의 JS 조각이 도착하기를 기다려요. 같은 <Suspense>라도 감싼 것이 무엇이냐에 따라 기다리는 대상이 다릅니다.

핵심 정리
  • Recharts: ResponsiveContainer > LineChart data > XAxis/YAxis/Line dataKey. 차트를 움직이는 건 배열 state.
  • 초기 60개는 REST(effect 1, alive 취소표), 이후는 WebSocket 또는 폴링(effect 2). slice(-59)로 60개 유지.
  • WebSocket 정리: OPEN이면 unsubscribe, 그리고 항상 close(). 폴링 정리: clearInterval.
  • NEXT_PUBLIC_ 값은 브라우저 번들에 들어가 공개된다. 실서비스는 서버 중계가 필요.
  • 졸업 과제: REST 키는 API Route 뒤, WebSocket만 공개 키. page.js는 클라이언트 컴포넌트이고 Suspense는 lazy 코드 분할용.
확인 문제
cleanup을 if (ws.readyState === WebSocket.OPEN) { unsubscribe; ws.close() }처럼 close까지 if 안에 넣으면 어떤 문제가 생기나요?
아직 연결 중(CONNECTING)인 소켓은 닫히지 않고 남습니다. 잠시 뒤 연결되면 onopen이 옛 종목을 구독해서, 종목을 바꿀수록 소켓과 구독이 쌓이고 옛 종목 가격이 차트에 섞여요. 종목을 빠르게 바꿀 때, 그리고 마운트하자마자 소켓을 여는 훅(졸업 과제의 useLiveTicker)은 개발 모드 StrictMode에서 첫 실행부터 이 상황을 만납니다.
WebSocket에는 NEXT_PUBLIC_ 키를 쓰는데, 검색·시세에는 왜 서버 전용 키를 쓰나요?
검색·시세는 요청/응답 한 번이라 서버(API Route)가 대신 물어봐 줄 수 있어서 키를 숨길 수 있습니다. WebSocket은 브라우저가 Finnhub에 직접 연결하는 구조라 브라우저가 키를 알아야 해요. 숨기려면 서버가 WebSocket을 중계해야 합니다.
pushPrice에서 setPrice를 setChartData((prev) => { setPrice(…); return … })처럼 업데이터 안으로 옮기면?
업데이터는 렌더 중에 실행되는 순수 함수여야 하는데, 그 안에서 다른 컴포넌트(스토어 구독자)를 갱신하게 되어 "Cannot update a component while rendering a different component" 에러가 납니다(교안 p.71).
졸업 과제의 WatchlistPanel은 const { watchlist, removeSymbol } = useWatchlistStore()로 스토어를 씁니다. 26장 기준으로 개선할 점은?
셀렉터 없이 부르면 스토어 전체를 구독해서, 테마를 토글할 때도 관심 목록 패널이 다시 그려집니다. useWatchlistStore((s) => s.watchlist)처럼 값마다 고르거나 useShallow로 감싸면 필요한 변경에만 반응해요.
졸업 과제의 StockChart 초기 로딩 effect에는 13의 alive 취소표가 없습니다. 어떤 상황에서 문제가 될 수 있나요?
종목을 아주 빠르게 A → B로 바꾸면, 나중에 도착한 A의 응답이 B 화면의 차트를 덮어쓸 수 있습니다. fetch 전에 setChartData([])로 비우는 것만으로는 "늦게 온 옛 응답"을 막지 못해요. 13처럼 let alive = true + cleanup에서 alive = false를 두면 해결됩니다.
다음 장으로

React·Next.js 과정이 여기서 끝납니다. 처음 JSX 한 줄에서 출발해 실시간 주식 대시보드까지 왔어요. 졸업 과제를 설명할 때는 "무엇을 썼나"보다 "왜 그 도구를 그 자리에 골랐나"(왜 선택 종목은 useState인지, 왜 키는 API Route 뒤에 있는지, 왜 Suspense가 lazy와 함께 있는지)를 이 장의 지도로 말해 보세요.

더 자세히 → Recharts + WebSocket졸업 과제 StockDashWebSocket이란?WebSocket·환경변수 Q&A차트 데이터 Q&AReact.lazy + Suspense

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>

<!-- ↓ 여기부터는 별도 예제입니다. 실제로는 위 <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는 한 문단을 의미한다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

HTML이 '프로그래밍 언어가 아니다'라고 하는 이유는?
계산하거나 판단하지 않기 때문입니다. 조건문·반복문·변수가 없죠. HTML은 "이 부분은 제목이고 저 부분은 문단이다"라고 의미를 표시하는 마크업 언어입니다. 동작은 JS가, 겉모습은 CSS가 맡습니다.
태그를 의미에 맞게 쓰는 것(시맨틱)이 왜 중요한가?
사람이 아닌 것들이 읽기 때문입니다. 검색 엔진은 <h1>을 보고 이 페이지의 주제를 판단하고, 스크린 리더는 태그 구조로 탐색합니다. <div>에 글자 크기만 키운 제목은 기계에게는 그냥 상자일 뿐입니다.

💼 실무·코딩테스트에서는시맨틱 마크업은 SEO와 접근성의 기본이고, 실무 코드 리뷰에서 자주 지적됩니다. "모든 것을 div로 만드는 것"을 divitis라고 부르며 안티패턴으로 봅니다. <header>·<nav>·<main>·<article>을 쓰는 습관을 들이세요.

TIPh 태그를 글씨 크기 조절 용도로만 남용하지 말고, 실제 문서 구조(제목의 위계)를 나타낼 때만 사용하는 것이 시맨틱 마크업의 기본입니다.
실습 파일: html01
02

블록요소 vs 인라인요소

divspanblockinline

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

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

블록 요소란

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

인라인 요소란

인라인 요소는 자신만의 줄 바꿈이 없고 내용물 크기만큼만 공간을 차지하며, 보통 블록 요소 안에 놓여 문장의 일부처럼 흘러갑니다. span이 대표적인 인라인 요소입니다. 실습 파일 제목의 “형태가 없음”은 인라인 요소가 자기만의 상자 크기(width·height)를 갖지 않고 내용만큼만 차지한다는 뜻입니다. “블록 요소 내부에 담겨서”도 문법상 반드시 그래야 한다는 규칙이 아니라, 대개 문단(p)이나 div 같은 블록 안에서 글자처럼 쓰인다는 의미로 이해하면 됩니다.

두 컨테이너 비교

실습 파일에서는 Container1에 블록 요소인 div와 ul/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 등).
  • 블록 요소는 구조/그룹을 만드는 용도, 인라인 요소는 문장 안의 일부를 꾸미는 용도로 쓰인다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

블록 요소와 인라인 요소의 가장 실질적인 차이는?
줄을 차지하느냐입니다. 블록은 한 줄을 통째로 쓰고 width·height·상하 margin이 먹지만, 인라인은 내용만큼만 차지하고 크기 지정과 상하 여백이 무시됩니다.
인라인 요소에 위아래 여백을 주고 싶으면 어떻게 하나?
display: inline-block으로 바꾸면 됩니다 — 줄 안에 있으면서도 크기와 여백을 가질 수 있죠. 요즘은 아예 flex나 grid로 배치하는 경우가 더 많습니다.

💼 실무·코딩테스트에서는"왜 margin-top이 안 먹지?"는 CSS 입문자가 가장 자주 겪는 문제이고, 원인은 대개 인라인 요소입니다. <span>·<a>에 여백을 주려다 막히면 display부터 확인하세요.

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만 골라 처음에는 화면에서 숨겨둔 것입니다.

클릭으로 펼치기 — 미리보기(아직 안 배운 JS)

※ 이 부분은 아직 배우지 않은 JavaScript입니다. onload·querySelectorAll·onclick은 JS 단원(DOM 요소 찾기 무렵)에서 제대로 배우니, 지금은 “HTML 구조 + CSS로 숨김 + JS로 보이기”라는 흐름만 눈에 익혀 두세요. 또 이 코드는 display를 "block"으로 바꾸기만 하므로 한 번 펼치면 다시 접히지 않습니다(진짜 토글은 현재 상태를 확인해 none ↔ block을 번갈아 넣어야 함). JavaScript의 window.onload로 페이지 로딩이 끝난 뒤, b 태그(굵은 글씨, 여기서는 클릭 가능한 제목 역할)를 클릭하면 querySelectorAll("li>ul")로 찾은 해당 서브메뉴의 style.display를 "block"으로 바꿔 숨겨져 있던 목록을 펼쳐 보입니다. 아코디언 메뉴의 기본 원리를 맛보는 예제입니다.

ol · ul · dl 한눈에

실습 파일은 ul만 쓰지만 목록 태그는 세 가지입니다. 순서가 중요한 라면 끓이는 법은 <ol><li>물 끓이기</li><li>면 넣기</li></ol>(1. 2. 번호), 순서 상관없는 장보기 목록은 <ul><li>우유</li><li>계란</li></ul>(• 점), 용어와 설명의 짝은 <dl><dt>HTML</dt><dd>웹 문서의 뼈대</dd></dl>처럼 씁니다.

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
// ※ 미리보기: 아직 안 배운 JS (JS 단원에서 자세히 배움)
// ※ display를 "block"으로 바꾸기만 해서 펼치기만 되고 다시 접히지는 않는다
// 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으로 숨겨 둔 것을 JavaScript로 display:block으로 바꾸면 클릭 시 펼쳐지는 메뉴가 된다(이 예제는 펼치기만 하고, 접기까지 하려면 none ↔ block 토글이 필요).
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

ul과 ol을 고르는 기준은 '점이냐 숫자냐'가 아니라 무엇인가?
순서에 의미가 있느냐입니다. 조리 순서·순위처럼 바뀌면 뜻이 달라지면 ol, 메뉴·태그처럼 순서가 상관없으면 ul이죠. 겉모습은 CSS로 얼마든지 바꿀 수 있으니 의미로 고르는 것이 맞습니다.
dl(정의 목록)은 언제 쓰나?
용어와 설명이 짝을 이룰 때입니다 — dt가 용어, dd가 설명이죠. FAQ, 용어집, 상품 사양표처럼 "이름: 값" 구조에 잘 맞습니다.

💼 실무·코딩테스트에서는실무에서 내비게이션 메뉴는 거의 항상 ul+li로 만듭니다 — 스크린 리더가 "항목 5개 목록"이라고 읽어 주기 때문입니다. CSS로 점을 없애고 가로로 눕히더라도 구조는 목록으로 두는 것이 표준입니다.

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은 표를 머리글·본문·꼬리글 영역으로 의미상 구분해 줍니다. tfoot의 colspan="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 태그 없이도 표처럼 보이게 만들 수 있다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

colspan과 rowspan의 차이를 말해보세요.
colspan은 가로로(열 방향) 칸을 합치고, rowspan은 세로로(행 방향) 합칩니다. 헷갈리면 "col=열을 몇 개 먹을까"로 기억하세요. 합친 만큼 다른 칸의 td를 지워야 표가 안 깨집니다.
thead·tbody·th를 쓰는 것이 겉모습 때문만은 아닌 이유는?
스크린 리더가 표를 읽을 때 기준이 되기 때문입니다. th가 있어야 "이 칸은 어느 항목의 값인지" 알려 줄 수 있죠. 또 thead는 인쇄할 때 페이지마다 반복되는 등 실용적 이점도 있습니다.

💼 실무·코딩테스트에서는표를 레이아웃 용도로 쓰는 것은 옛날 방식이고 지금은 금기입니다 — 표는 데이터를 담을 때만 쓰고, 배치는 flex·grid로 합니다. 다만 실제 데이터 표는 여전히 table이 정답이니 구조를 제대로 아는 것이 중요합니다.

TIPdiv로 만든 가짜 표는 스크린리더 등 접근성 도구에는 실제 표로 인식되지 않을 수 있으므로, 진짜 데이터 표라면 table 태그를 쓰는 것이 원칙입니다.
실습 파일: html04
05

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

qhraddresstarget="_blank"

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

쉽게 말하면워드에서 하던 ‘따옴표 인용·글머리 기호 목록·수평선 넣기’를 태그로 해 본 실습이에요. q는 문장 속에 남의 말을 따옴표로 감싸 넣는 짧은 인용, ul은 글머리 기호(•) 목록, 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="https://www.lipsum.com/" target="_blank" rel="noopener">Lorem Ipsum</a>
<!-- address: 연락처 정보를 뜻하는 시맨틱 태그 -->
<address>help@lipsum.com</address>
핵심 정리
  • q는 짧은 인라인 인용문에 자동으로 따옴표를 붙여주는 태그이다.
  • hr은 콘텐츠를 시각적/의미적으로 구분하는 수평 구분선이다.
  • a 태그의 target="_blank"는 링크를 새 탭에서 열고, address는 연락처 정보를 의미상 표시하는 시맨틱 태그이다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

blockquote와 q의 차이는?
blockquote는 문단 단위의 긴 인용(블록), q는 문장 속 짧은 인용(인라인)입니다. cite 속성으로 출처 URL을 함께 적을 수 있다는 점도 알아 두면 좋습니다.
hr을 '선 긋기'로만 이해하면 무엇을 놓치나?
의미상 주제가 바뀌는 지점을 뜻한다는 것입니다. 단순히 선을 그리고 싶다면 CSS border가 맞고, hr은 내용의 전환을 표시할 때 씁니다.

💼 실무·코딩테스트에서는이런 구분이 사소해 보여도 접근성과 SEO에서 실제로 차이를 만듭니다. 실무에서는 디자인 시스템을 만들 때 "이 컴포넌트는 어떤 태그를 써야 의미가 맞나"를 정해 두고 팀 전체가 따릅니다.

TIPq는 인라인, blockquote는 블록 요소라는 차이를 기억해두면 인용문의 길이나 용도에 맞게 올바른 태그를 고를 수 있습니다.
실습 파일: test
06

실습 — 인적사항 입력 표

rowspancolspanborder-collapse

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

쉽게 말하면이력서 맨 위 인적사항 칸을 표로 그려 보는 실습이에요. 엑셀에서 “셀 병합”을 하듯이, 사진 칸은 위아래로 여러 줄을 합치고(rowspan), 주소처럼 긴 칸은 옆으로 여러 칸을 합칩니다(colspan). 이미 합쳐진 자리에는 칸을 또 적지 않는다는 것만 기억하면 됩니다.

세로 병합 rowspan

이 실습은 표의 셀 병합 기능인 rowspan과 colspan을 실제 서식(인적사항 입력 양식)에 적용하는 연습입니다. 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>
        <!-- (발췌라 2행 생략) 실습 파일에는 같은 구조의 행이 2개 더 있습니다:
             핸드폰·결혼유무 행, 가족사항·주거사항 행 → 사진 칸까지 합쳐 모두 6행 -->
    </tbody>
</table>
CSS test2.html
table {
    border-collapse: collapse; /* 이중 테두리를 하나로 합침 */
    width: 700px;
}

/* 사진 칸(rowspan 셀)의 가로 폭 고정 */
.asd {
    width: 120px;
}
핵심 정리
  • rowspan="N"은 해당 셀을 세로로 N개 행만큼 병합한다.
  • colspan="N"은 해당 셀을 가로로 N개 칸만큼 병합한다.
  • rowspan/colspan을 조합하면 실제 서류 양식처럼 불규칙한 셀 배치를 만들 수 있다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

첫 행에서 rowspan="6"을 준 뒤, 둘째 행의 첫 <td>가 '사진 칸'이 아니라 '주민번호'인 이유는?
사진 칸이 이미 아래 행들의 첫 번째 열 자리까지 차지하고 있기 때문입니다. 그래서 아래 행에서는 그 열의 td를 아예 적지 않고 나머지 셀부터 씁니다. 만약 빈 <td></td>를 하나 더 적으면 그 행만 칸이 하나 많아져 오른쪽으로 삐져나옵니다.
첫 행이 <td>를 3개만 적었는데도 다른 행(4~5칸)과 폭이 맞는 이유는?
칸 수는 적은 td 개수가 아니라 차지하는 칸 수로 셉니다. 사진 칸 1 + 성명 1 + colspan="3"인 (한문) 3 = 5칸입니다. 둘째 행은 사진 칸이 위에서 내려와 1칸을 차지하고, 적은 td 4개를 더해 역시 5칸이 됩니다. 셋째 행(주소)도 1 + 1 + 3 = 5칸이죠.

💼 실무·코딩테스트에서는요즘 화면 배치(레이아웃)는 표가 아니라 CSS flex·grid로 하고, table은 진짜 표 데이터(성적표·거래 내역)에만 씁니다. 다만 이메일 본문·인쇄용 서식처럼 CSS 지원이 약한 곳에서는 지금도 rowspan/colspan 표로 양식을 짜는 일이 많습니다.

TIProwspan으로 셀을 병합하면 그 아래 행에서는 해당 열의 td를 아예 적지 않아야 표가 어긋나지 않는다는 점에 주의합니다(이 파일에서도 병합된 첫 칸 이후 각 행은 나머지 셀부터 시작합니다).
실습 파일: test2
07

실습 — 경력사항 표

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

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

쉽게 말하면이력서의 경력사항 칸처럼 행과 열이 얽힌 표를 만드는 실습이에요. 가로 셀 병합(colspan)을 쓰면 “주요업무”나 “업무상세내용”처럼 길게 적어야 하는 칸을 옆 칸들과 하나로 합쳐 넓힐 수 있습니다(이 실습에는 세로 병합 rowspan은 나오지 않습니다 — 인적사항 표 참고).

한 표 안의 두 가지 행

하나의 경력사항 표 안에서 위쪽은 회사명·주요사업처럼 좁은 항목별 입력 칸으로, 아래쪽은 자유 서술 칸으로 구성합니다. 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)를 미리 정의해두면 반복되는 여러 표에서 열 너비·행 높이를 일관되게 재사용할 수 있다.
  • 같은 구조의 표를 여러 번 복사해 나열하면 회사별 경력을 항목별로 반복 입력하는 양식을 만들 수 있다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

(실습 코드에는 없는 개선점) 이 경력 표에 “경력사항 1” 같은 제목을 붙일 때, 표 바깥 제목보다 caption이 권장되는 이유는?
표의 제목을 표 자체가 갖게 되기 때문입니다. 바깥의 <h3>로 제목을 달면 스크린 리더는 그 둘이 연결된 줄 모릅니다. caption은 표 안에 있어 "이 표는 무엇에 관한 것"이라고 확실히 알려 줍니다.
“주요업무” 행은 td를 2개만 적었는데도 4열짜리 표가 깨지지 않는 이유는?
칸 수는 적은 td 개수가 아니라 차지하는 칸 수로 셉니다. “주요업무” 1칸 + colspan="3"인 입력칸 3칸 = 4칸이라 위쪽 행(td 4개)과 폭이 맞습니다. 아래 두 행은 colspan="4" 한 칸이 혼자 4칸을 차지하죠. 만약 colspan="2"로 잘못 적으면 그 행만 한 칸이 모자라 오른쪽이 비어 보입니다.

💼 실무·코딩테스트에서는이력서·사양표 같은 실제 데이터 표는 실무에서 계속 만들게 됩니다. 병합이 많아지면 유지보수가 어려워지므로, 정말 필요한지 한 번 더 생각하고 가능하면 단순한 표 여러 개로 나누는 것이 좋습니다.

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">로 연결하며 여러 문서에서 재사용할 수 있습니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

인라인·내부·외부 스타일 중 실무에서 외부를 쓰는 이유는?
재사용과 캐싱 때문입니다. 여러 페이지가 같은 CSS 파일 하나를 공유하면 브라우저가 한 번만 받아 캐시하죠. 또 디자인 수정이 한 곳에서 끝납니다. 인라인은 우선순위가 높아 나중에 덮어쓰기가 어려워지는 문제도 있습니다.
CSS의 '캐스케이딩'이 무엇을 뜻하는지 설명해보세요.
여러 규칙이 겹칠 때 우선순위에 따라 하나가 이기는 것입니다. 구체성(specificity) → 나중에 선언된 것 순으로 판단하죠. 그래서 같은 선택자면 아래 것이 이깁니다. 이 규칙을 알면 "왜 내 스타일이 안 먹지?"의 대부분이 설명됩니다.

💼 실무·코딩테스트에서는"스타일이 안 먹는다"는 실무에서 매일 겪는 일이고, 원인은 대개 구체성 싸움입니다. 개발자 도구에서 취소선이 그어진 속성을 보면 누가 이겼는지 바로 보이죠. !important로 이기려는 습관은 나중에 더 큰 문제를 만듭니다.

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)를 쓰면 선택되지 않는 중첩 구조
             (단, css02.css에는 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만
   (※ 이 문서의 h1 안에는 b가 없어서 선택되는 요소 0개) */
#sub_title1>b {
    color: red;
}

/* 자손 선택자(공백): h1 하위의 모든 b (몇 단계든)
   (※ 마찬가지로 h1 안에 b가 없어 선택되는 요소 0개) */
h1 b {
    color: blueviolet;
}

/* 속성 선택자: title 값이 정확히 일치하는 p만 */
p[title="주식관련기사"] {
    background-color: gold;
}
핵심 정리
  • id 선택자(#id)는 문서에서 단 하나의 요소만 가리켜야 하고, class 선택자(.class)는 여러 요소에 재사용할 수 있습니다.
  • 자손 선택자(공백으로 연결)는 하위의 모든 후손을 선택하고, 자식 선택자(>)는 바로 한 단계 아래 자식만 선택합니다.
  • 속성 선택자([attr="value"])는 title="주식관련기사"처럼 특정 속성값을 가진 요소만 정확히 골라냅니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

id 선택자보다 class 선택자를 권하는 이유는?
id는 문서에 하나뿐이라 재사용이 안 되고, 구체성이 너무 높아 나중에 덮어쓰기가 어렵기 때문입니다. class는 여러 요소에 붙일 수 있고 조합도 자유롭죠. 그래서 스타일링은 class, JS로 특정 요소를 잡을 때만 id를 쓰는 편입니다.
자손 선택자(A B)와 자식 선택자(A > B)의 차이는?
자손은 몇 단계 아래든 모두, 자식은 바로 한 단계 아래만 선택합니다. 중첩이 깊은 구조에서 의도치 않게 안쪽 요소까지 잡히는 사고가 흔한데, 그때 >로 범위를 좁히면 해결됩니다.

💼 실무·코딩테스트에서는실무에서는 BEM 같은 클래스 이름 규칙이나 CSS Module·Tailwind로 선택자 충돌 자체를 줄입니다. 선택자를 길게 중첩할수록 구체성이 올라가 나중에 손대기 어려워지므로, 가능한 한 평평하게 쓰는 것이 원칙입니다.

수업 파일 주의원본 h1#sub_title1 안에는 <b>가 없어서 #sub_title1>b와 h1 b는 아무 요소도 고르지 못합니다(문법은 맞지만 대상이 없음). 반대로 <b>가 들어 있는 곳은 p 안인데, css02.css에는 p b나 p>b 규칙이 없어 그 b들도 색이 바뀌지 않습니다. 이 예제에서 실제로 눈에 보이게 적용되는 규칙은 p(너비·배경), #sub_title1(글자 10px), .c(파란 글자), p[title="주식관련기사"](금색 배경)입니다. 차이를 직접 보려면 p b { color: red; }와 p>b { color: red; }를 각각 넣어 보세요 — 앞의 것은 39만원·실적·KB증권은 세 곳 모두, 뒤의 것은 p 바로 아래의 39만원 하나만 빨개집니다. (원본 css에는 이 밖에 *, h1+p, h1~p, ::after, :link·:visited·:active·:hover 규칙도 있어, 실제 화면의 p 배경은 h1~p의 aquamarine으로 보입니다. 이 나머지 규칙은 외부 스타일시트 카드에 발췌해 두었습니다.)
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를 다르게 지정하는 것도 함께 연습합니다.

규칙이 겹치면 누가 이기나 — 구체성(명시도) 점수

한 요소에 여러 규칙이 겹치면 브라우저는 선택자마다 점수를 (id 개수, class·속성·가상 클래스 개수, 태그·가상 요소 개수) 세 칸으로 매겨 비교합니다. #a = (1,0,0), .b·[title="attr"]·:hover = (0,1,0), p = (0,0,1), * = (0,0,0)입니다. 섞어 쓰면 칸별로 더합니다 — p[title="attr"]는 (0,1,1), #c+p는 (1,0,1). 왼쪽 칸부터 비교해서 큰 쪽이 이기므로 class를 아무리 많이 붙여도 id 하나를 못 이깁니다. 점수가 완전히 같으면 나중에 쓴 규칙이 이깁니다. (태그에 직접 쓴 style="..."은 이 점수보다 위, !important는 그보다도 위입니다.)

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만 선택하며, 손자 이하의 후손은 포함하지 않습니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

2번 문단에 class="b"를 더 붙여 <p id="a" class="b">로 만들면 글자는 빨강일까 파랑일까?
빨강입니다. #a는 (1,0,0), .b는 (0,1,0)이라 왼쪽 칸(id 개수)에서 이미 승부가 납니다. .b가 CSS에서 더 아래에 있어도 소용없습니다 — “나중에 쓴 규칙이 이긴다”는 점수가 완전히 같을 때만 적용됩니다.
5번 문단의 <strong>을 <span>으로 한 번 감싸면 노란 글자가 유지될까?
아니요. P>strong은 p의 바로 아래 자식인 strong만 고르는데, 감싸면 strong이 p의 손자가 되기 때문입니다. 몇 단계 아래든 고르고 싶으면 공백을 쓴 자손 선택자 p strong으로 바꿉니다.

💼 실무·코딩테스트에서는전체 선택자 *는 실무에서 * { box-sizing: border-box; } 같은 초기화(reset) 용도로 주로 씁니다. 인접 형제 선택자는 li + li { margin-top: 8px; }처럼 첫 항목만 빼고 사이 간격을 줄 때 유용합니다. 가상 클래스(:hover)·가상 요소(::after)는 외부 스타일시트 카드의 css02.css 발췌에서 다룹니다.

TIP이 예제에서 #c 뒤의 형제 p는 두 개(“다음에 오는 p태그”, title="attr" p)입니다. #c+p는 첫 번째만, #c~p는 두 개 모두를 고르는데, 둘 다 같은 color: green이라 결과가 구분되지 않습니다(두 문단 모두 초록). 차이를 보려면 #c~p 규칙을 지워 보세요 — title p만 원래 검은 글자로 돌아갑니다. 또는 두 규칙의 색을 서로 다르게 바꿔 보면 됩니다.
실습 파일: css03
04

폰트(Font)

font 축약형20px/50pxfont-family

한 줄 요약여섯 개의 폰트 속성은 font 축약형 한 줄로 합칠 수 있으며, 순서는 [style·variant·weight] size[/line-height] family이다(앞 셋은 생략 가능·셋끼리 순서 자유, size와 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-size와 line-height는 슬래시(/)로 함께 표기한다는 점도 이 예제에서 확인할 수 있습니다. 축약형에서 font-size와 font-family는 필수이고(둘 중 하나라도 빠지면 줄 전체가 무시됨), 앞쪽 style·variant·weight는 생략할 수 있으며 셋끼리는 순서를 바꿔도 됩니다. 단, 이 수업 예제는 글꼴 사이에 쉼표가 빠져 있어서 실제로는 두 규칙 모두 글꼴이 적용되지 않습니다(아래 “수업 파일 주의” 참고).

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 순으로 작성해야 함
*/
/* ↑ 수업 메모 보충: 1번 그룹(weight·style·variant)은 생략 가능하고
     셋끼리는 순서를 바꿔도 된다. 2번의 size와 3번의 family는 필수. */
핵심 정리
  • font 축약형의 순서는 [style·variant·weight] size[/line-height] family입니다. 앞의 셋은 생략할 수 있고 셋끼리는 순서를 바꿔도 되지만, size와 family는 필수이고 size → family 순서를 어기면 줄 전체가 적용되지 않습니다.
  • font-size와 line-height는 슬래시(/)로 함께 표기합니다 (예: 20px/50px).
  • font-family에 여러 값을 쉼표(,)로 구분해 나열하면 앞의 글꼴이 없을 때 다음 글꼴로 대체(fallback)됩니다. 쉼표가 없으면 선언 전체가 무효입니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

font-family에 여러 폰트를 나열하는 이유는?
앞의 것이 없으면 다음 것을 쓰는 대체 목록이기 때문입니다. 사용자 PC에 그 폰트가 없을 수 있으니, 마지막에 sans-serif 같은 일반 계열을 꼭 넣어 최소한의 모양을 보장합니다.
px·em·rem의 차이를 설명해보세요.
px는 고정, em은 부모 크기 기준(중첩되면 곱해져 누적), rem은 루트(html) 크기 기준입니다. em의 누적이 예측하기 어려워서, 실무에서는 rem을 기본으로 쓰는 경우가 많습니다.

💼 실무·코딩테스트에서는사용자가 브라우저 기본 글자 크기를 키웠을 때 px는 따라 커지지 않아 접근성 문제가 됩니다. 그래서 본문 글자는 rem으로 두는 것이 권장됩니다. 웹폰트는 로딩 성능에도 영향을 주므로 실무에서 신경 쓰는 부분입니다.

수업 파일 주의원본 css04_font.html의 font-family: "맑은 고딕" 굴림;은 글꼴 이름 사이에 쉼표가 없어 문법 오류입니다. 브라우저는 잘못된 선언을 통째로 버리므로 “앞이 없으면 뒤로 대체”가 일어나지 않고, 기본 글꼴로 표시됩니다. 축약형 font: bold italic small-caps 20px/50px "맑은 고딕" 굴림;도 같은 이유로 줄 전체가 무효라서, 실제 화면은 방법 1의 나머지 개별 속성(크기·굵기·기울임·small-caps·줄 간격)만 적용된 결과입니다. 올바르게 쓰려면 font-family: "맑은 고딕", 굴림, sans-serif; / font: bold italic small-caps 20px/50px "맑은 고딕", 굴림, sans-serif;처럼 쉼표로 구분합니다.
TIP공백이 포함된 한글 폰트 이름("맑은 고딕")은 반드시 따옴표로 감싸야 하지만, 공백이 없는 이름(굴림)은 따옴표 없이도 동작합니다.
실습 파일: css04
05

문단 스타일 실습

인라인 stylefont 축약형text-decoration

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

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

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

이 파일은 인라인 스타일을 이용해 문단(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 축약형에 포함되지 않는 속성은 별도로 계속 작성해야 한다는 점도 확인할 수 있습니다.

결과가 완전히 같지는 않습니다. ① 1번은 축약형 쪽에만 sans-serif가 들어가서, 글꼴을 지정하지 않은 위쪽 1번(브라우저 기본 글꼴)과 모양이 다를 수 있습니다. ② 축약형은 적지 않은 하위 속성(font-style·font-variant·line-height 등)을 초기값으로 되돌립니다. 이 예제는 위쪽에서도 그 속성들을 따로 바꾸지 않아 눈에 띄는 차이가 없지만, 예컨대 부모에서 물려받은 line-height가 있었다면 축약형 쪽은 normal로 초기화됩니다. 그래서 5번처럼 줄 간격이 필요하면 16px/50px처럼 축약형 안에 함께 적어야 합니다.

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>

<!-- 줄 간격: line-height -->
<p style="font-weight: bold; line-height: 50px; font-family: '돋움';
    font-size: 16px; color:green;">5.오늘 점심은 어떤 메뉴일까?</p>

<!-- 오른쪽 정렬: text-align + 밑줄 -->
<p style="font-weight: bold; text-align: right; text-decoration: underline;
    font-family: '돋움'; font-size: 20px;">6.오늘 점심은 어떤 메뉴일까?</p>

<hr>
<!-- [뒷부분] 같은 문단을 font 축약형으로 다시 작성
     거의 같지만, 1번은 sans-serif가 추가됐고
     축약형은 안 적은 하위 속성(line-height 등)을 초기값으로 되돌린다 -->
<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>
<p style="font: 15px '돋움'; text-indent: 10px;">
    4.오늘 점심은 어떤 메뉴일까?</p>
<!-- 줄 간격은 size 뒤에 /로 붙여 축약형 안에 넣는다 -->
<p style="font: bold 16px/50px '돋움'; color:green;">
    5.오늘 점심은 어떤 메뉴일까?</p>
<p style="font: bold 20px '돋움'; text-align: right; text-decoration: underline;">
    6.오늘 점심은 어떤 메뉴일까?</p>
핵심 정리
  • 개별 속성 나열과 font 축약형은 같은 값을 넣으면 같은 결과지만, 축약형은 적지 않은 하위 속성(line-height 등)을 초기값으로 되돌린다는 점이 다릅니다.
  • text-decoration(밑줄), text-indent(들여쓰기), text-align(정렬)은 font 축약형에 포함되지 않는 별도 속성입니다.
  • 인라인 스타일은 style 속성 안에서 세미콜론(;)으로 여러 선언을 구분합니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

line-height에 단위 없이 숫자만 쓰는 것이 권장되는 이유는?
자식 요소가 각자의 글자 크기에 비례해 계산하기 때문입니다. line-height: 1.6이면 자식이 큰 글자여도 알맞은 줄 간격이 되지만, 24px로 고정하면 큰 글자에서 줄이 겹칩니다.
text-align: center가 안 먹는 흔한 이유는?
인라인 요소라 폭이 내용만큼이기 때문입니다. 자기 폭 안에서는 이미 가운데죠. 블록으로 만들고 폭을 준 뒤 정렬하거나, 부모에 text-align을 주는 것이 맞습니다.

💼 실무·코딩테스트에서는타이포그래피는 읽기 쉬움을 좌우합니다. 실무 디자인 시스템에서는 글자 크기·줄 간격·자간을 토큰으로 정해 두고 전체가 따르게 하죠. 줄 길이(한 줄에 45~75자)도 가독성의 중요한 변수입니다.

TIPfont 축약형을 쓰면 코드가 짧아지지만 순서 규칙을 지키지 않으면 적용되지 않습니다: [style·variant·weight] size[/line-height] family — 앞의 style·variant·weight는 생략 가능하고 셋끼리는 순서 자유, size와 family는 필수이며 이 둘은 반드시 size → family 순서입니다(폰트 카드 참고).
실습 파일: css05
06

배경(Background) 속성

background::afteropacityz-index

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

쉽게 말하면background 속성은 방 벽지 고르기예요. 색만 칠할지(color), 그림 벽지를 붙일지(image), 무늬를 반복할지(repeat), 어느 위치에 붙일지(position), 얼마나 크게 붙일지(size)를 정합니다. (스크롤할 때 배경을 고정할지 정하는 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:absolute로 #box2 위에 겹쳐 배치합니다. 의도는 “글 뒤에 깔리는 배경”이지만, z-index:0인 위치 지정 요소는 위치 지정이 없는 일반 글(p)보다 나중에(=위에) 그려지기 때문에 실제로는 반투명 이미지가 글을 덮습니다. 글 뒤로 보내려면 ::after에 z-index:-1을 주거나, p에 position:relative; z-index:1을 줘야 합니다(아래 “수업 파일 주의” 참고).

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(absolute)의 left/top 기준점이 되도록
       static이 아닌 position 값을 준다 */
    position: relative;
    z-index: 10;    /* #box2 상자 전체를 '형제'들보다 위로.
                       안쪽 글과 ::after의 앞뒤 순서는 바꾸지 못한다 */
}

/* 가상 요소: #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;               /* ⚠ 0이면 위치 지정 없는 글(p)보다 '위'에 그려짐
                                 → 글 뒤로 보내려면 -1 */
}
HTML css06.html
<h1>배경속성</h1>
<!-- 축약형 배경: 테두리 안 지정 위치에 100x100 이미지 -->
<div id="box1"></div>
<!-- ::after 반투명 이미지가 글 '위'에 덮여 보인다(z-index:0이라서) -->
<div id="box2">
    <p class="c">
        korea8일 삼성전자 목표주가를 기존 55만원에서 ...
    </p>
</div>
핵심 정리
  • background 축약형은 이미지, 반복 방식, 위치를 먼저 쓰고 슬래시(/) 뒤에 크기를 붙이는 순서로 작성합니다.
  • ::after 가상 요소는 반드시 content 속성이 있어야 화면에 실제로 렌더링됩니다.
  • opacity로 반투명 효과를 만들고 position:absolute + z-index로 원래 요소 위(또는 뒤)에 겹치게 배치할 수 있습니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

background-size의 cover와 contain은 어떻게 다른가?
cover는 영역을 꽉 채우되 넘치는 부분은 잘리고, contain은 이미지 전체가 보이되 빈 공간이 생깁니다. 배경 사진은 보통 cover, 로고처럼 잘리면 안 되는 것은 contain입니다.
background와 img 태그 중 무엇을 언제 쓰나?
의미 있는 이미지(상품 사진·인물)는 img, 장식용 배경은 CSS가 원칙입니다. img는 alt로 대체 텍스트를 제공할 수 있고 검색에도 잡히지만, 배경은 그렇지 않습니다.

💼 실무·코딩테스트에서는이미지는 웹 성능의 가장 큰 요인입니다. 실무에서는 WebP·AVIF 포맷, 반응형 이미지(srcset), 지연 로딩(loading="lazy")을 씁니다. 배경 이미지는 지연 로딩이 어렵다는 점도 선택의 기준이 됩니다.

수업 파일 주의원본 css06.html은 #box2::after에 z-index:0을 줘서, 반투명 이미지가 글 뒤가 아니라 위에 덮입니다(글이 이미지에 가려 흐리게 보임). 같은 상자 안에서는 “위치 지정 없는 일반 내용(p) → 위치 지정 + z-index 0” 순서로 그려지기 때문입니다. #box2의 z-index:10은 #box2 상자 전체를 바깥 형제들과 비교할 때만 쓰이고, 안쪽 글과 ::after의 순서는 바꾸지 못합니다. 글을 위로 올리려면 둘 중 하나를 고칩니다: ① #box2::after { z-index: -1; } ② #box2 p { position: relative; z-index: 1; }
TIPz-index는 그 요소 자신이 position:relative/absolute/fixed/sticky처럼 static이 아닌 값을 가졌을 때(또는 flex·grid의 자식일 때)만 동작합니다. 부모에게 position이 있어도 자식 본인이 static이면 z-index는 무시됩니다. #box2의 position:relative는 z-index 때문이라기보다, absolute인 ::after가 #box2의 왼쪽 위를 기준(left:0, top:0)으로 놓이게 하려는 것입니다.
실습 파일: 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)를 나란히 비교합니다. 기본값(content-box)이라면 BOX1은 300+20+4 = 324px, BOX2는 274+40+10 = 324px로 겉크기가 같아집니다. 즉 274px는 padding·border 차이(총 26px)를 content-box 기준으로 손으로 빼 준 값입니다. 그런데 파일 마지막 규칙이 두 박스 모두에 box-sizing: border-box를 걸기 때문에, 실제 화면에서는 width가 곧 겉크기가 되어 BOX1은 300px, BOX2는 274px로 서로 달라집니다. border-box를 쓰면 이런 수작업 계산이 필요 없으니, 두 박스 모두 그냥 width: 300px으로 두면 똑같이 300px가 됩니다. 또한 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
   (content-box일 때만 맞는 계산: 둘 다 겉크기 324px) */
.border-box {
    width: 274px;
    height: 200px;
    background-color: aquamarine;
    border: 5px solid red;
    padding: 20px;
}

/* 위 수작업 계산을 브라우저가 대신 해 주는 설정 */
/* width 안에 padding+border까지 포함해서 계산한다 */
/* ⚠ 둘 다 border-box가 되므로 결과는 BOX1 300px, BOX2 274px
   (같게 하려면 BOX2도 width: 300px로 두면 된다) */
.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는 좌우 마진을 자동 계산해 블록 요소를 가운데 정렬시키는 관용적 기법입니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

box-sizing: border-box가 왜 거의 항상 권장되나?
지정한 width가 패딩·테두리를 포함한 최종 크기가 되기 때문입니다. 기본값(content-box)에서는 width:100px에 패딩 20을 주면 실제로는 140px이 되어 계산이 꼬이죠. border-box면 100px 그대로입니다.
margin이 겹치는(collapse) 현상은 언제 생기나?
세로로 인접한 블록 요소끼리입니다. 위 요소의 margin-bottom 20과 아래의 margin-top 30이 만나면 50이 아니라 30이 되죠. 가로 margin은 겹치지 않고, flex·grid 안에서도 일어나지 않습니다.

💼 실무·코딩테스트에서는* { box-sizing: border-box }는 거의 모든 실무 프로젝트의 첫 줄입니다. margin collapse는 "여백이 예상과 다르다"의 단골 원인이라, flex/grid의 gap으로 간격을 주면 이 문제 자체가 사라집니다.

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 안의 p 세 개를 '원'으로 만드는 규칙
   200x200 정사각형 + 반지름 100px(=절반) → 동그라미 */
#box>p {
    width: 200px;
    height: 200px;
    border-radius: 100px;
    text-align: center;
    line-height: 200px;      /* 글자를 세로 가운데로 */
    font-size: 40px;
}

/* 빨간 원: 문서 흐름에서 빠져 #box 기준으로 이동 */
.myred {
    background-color: red;
    position: absolute;
    left: 200px;             /* #box 왼쪽에서 200px */
    top: 10px;               /* #box 위에서 10px */
    z-index: 100;            /* 숫자가 클수록 위에 쌓인다 */
}
/* .myblue(left:120px, top:100px, z-index:50),
   .mygreen(left:280px, top:100px, 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
<!-- 원 세 개: #box(relative) 안의 absolute p들 -->
<div id="box">
    <p class="myred">red</p>
    <p class="myblue">blue</p>
    <p class="mygreen">green</p>
</div>

<!-- 메인 메뉴: 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으로 보여주는 패턴으로 구현합니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

position의 relative·absolute·fixed를 기준점으로 구분해보세요.
relative는 원래 자리 기준으로 이동(자리는 그대로 차지), absolute는 가장 가까운 position이 지정된 조상 기준, fixed는 뷰포트(화면) 기준입니다. 스크롤해도 fixed만 따라옵니다.
absolute를 썼는데 엉뚱한 곳에 붙는다면 무엇을 확인해야 하나?
부모에 position: relative가 있는지입니다. 없으면 계속 위로 올라가고, 끝까지 없으면 초기 컨테이닝 블록(문서 맨 위에서 시작하는 뷰포트 크기의 사각형) 기준이 됩니다 — 흔히 “페이지 왼쪽 위 기준”으로 보이죠(body 자체가 기준이 되는 것은 아닙니다). "absolute를 쓰려면 부모에 relative"가 세트로 기억해야 할 공식입니다.

💼 실무·코딩테스트에서는드롭다운·툴팁·모달이 전부 position으로 만들어집니다. 실무에서는 z-index 충돌이 자주 문제가 되는데, 쌓임 맥락(stacking context)을 이해하면 해결이 빨라집니다. 요즘은 Popover API나 라이브러리로 해결하기도 합니다.

TIP!important는 다른 모든 우선순위 규칙을 무시하고 강제 적용되므로, 남용하면 이후 다른 스타일로 덮어쓰기가 매우 어려워집니다.
실습 파일: css08
09

Flexbox 레이아웃 실습

display:flexflex-directionrow-reversecolumn

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

쉽게 말하면Flexbox는 정리 도우미예요. 컨테이너에 display:flex만 켜면 아이템들이 한 줄로 서고, flex-direction 한 줄로 “거꾸로 서 주세요(row-reverse)”, “세로로 쌓아 주세요(column)” 같은 지시를 끝냅니다. “균등한 간격으로, 가운데 맞춰 주세요” 같은 정렬(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는 바깥쪽(세로 레이아웃)과 안쪽(가로 레이아웃)에 중첩해서 적용할 수 있다는 것이 이 예제의 핵심입니다.

하나 더 볼 점: 안쪽 div 세 개는 각각 width: 600px(+ 좌우 margin 10px씩)이라 합치면 1860px인데, 화면이 그보다 좁아도 줄이 넘치지 않고 세 칸이 같이 좁아집니다. flex 아이템은 기본값이 flex-shrink: 1이라 공간이 모자라면 width보다 작게 줄어들기 때문입니다. 줄어들지 않게 하려면 flex-shrink: 0을 줍니다(그러면 오른쪽으로 넘칩니다).

CSS css09.html
/* 박스 1~5 공통 모양 */
.box {
    width: 100px;
    height: 100px;
    background-color: aquamarine;
    margin: 10px;
}
/* flex 선언 → 이 요소는 컨테이너, 자식들은 아이템이 된다 */
#container1 {
    border: 1px solid red;
    display: flex;
    flex-direction: row-reverse; /* 가로 배치 + 순서 뒤집기 */
}
/* #container2: #container1과 완전히 같은 규칙(같은 결과 반복) */
#container2 {
    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;                /* 기본 flex-shrink:1 → 공간이 모자라면 이보다 줄어든다 */
    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>
<!-- #container2: 위와 같은 박스 1~5 (같은 row-reverse) -->
<div id="container2">
    <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는 가로 주축의 시작점을 오른쪽으로 뒤집어 아이템을 오른쪽부터 1,2,3… 순으로 채웁니다(그래서 왼쪽에서 읽으면 5,4,3,2,1). 이때 justify-content의 flex-start도 오른쪽 끝이 됩니다.
  • flex-direction:column은 주축을 세로로 바꿔 아이템을 위에서 아래로 쌓습니다.
  • min-height:80vh처럼 뷰포트 단위(vh)를 쓰면 화면 높이에 비례한 최소 높이를 줄 수 있습니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

flex에서 main axis와 cross axis가 뒤바뀌는 때는?
flex-direction: column일 때입니다. 기본(row)은 주축이 가로라 justify-content가 좌우 정렬이지만, column이면 주축이 세로가 되어 위아래 정렬이 됩니다. 이걸 모르면 정렬 속성이 반대로 동작하는 것처럼 보입니다.
flex: 1 이 무엇을 줄여 쓴 것인지 아나요?
flex-grow:1; flex-shrink:1; flex-basis:0%입니다. "남는 공간을 균등하게 나눠 가져라"는 뜻이죠. flex-basis: 0이라 원래 내용 크기를 무시하고 완전히 균등해지는 것이 핵심입니다.

💼 실무·코딩테스트에서는Flexbox는 현대 웹 레이아웃의 기본기입니다. 1차원(한 줄) 배치는 flex, 2차원(행·열 동시)은 grid가 맞습니다. 면접에서 "flex와 grid를 언제 각각 쓰나"를 자주 묻습니다.

TIPflex-direction을 바꾸면 justify-content/align-items가 작동하는 축(주축/교차축)도 함께 바뀌므로, 정렬 속성을 쓸 때는 항상 현재 방향을 먼저 확인해야 합니다.
실습 파일: css09
10

실습 — inline-block 카드 나란히 놓기

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

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

쉽게 말하면div는 원래 한 줄을 통째로 차지해서 위아래로 쌓여요. inline-block은 “글자처럼 옆으로 줄 서되, 상자처럼 크기는 정할 수 있게” 바꿔 주는 설정이라 카드 두 장을 나란히 놓을 수 있습니다. 그런데 글자처럼 취급되다 보니 HTML 소스의 줄바꿈이 띄어쓰기 한 칸처럼 끼어드는데, 부모 글자 크기를 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_inside는 text-align: center와 line-height: 110px로 한 줄 텍스트를 카드 가운데에 배치합니다. box-sizing: border-box도 적혀 있지만, 이 요소에는 width·height가 없어서 사실상 효과가 없습니다(border-box는 “지정한 width/height 안에 padding·border를 포함”시키는 설정이라, 지정한 크기가 없으면 바뀌는 게 없음). 그래도 카드 밖으로 넘치지 않는 이유는 따로 있습니다 — 폭은 auto라서 padding을 줘도 부모(카드) 폭에 맞춰지고, 높이는 위아래 padding 15px×2 + 줄 높이 110px = 140px로 카드 높이 150px 안에 들어가기 때문입니다.

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;
}

/* 카드 내부: 글자를 가로·세로 가운데로 */
#card_inside {
    box-sizing: border-box;  /* width/height가 없어 여기선 사실상 효과 없음 */
    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으로 높이가 다른 요소들도 위쪽 기준으로 나란히 정렬할 수 있습니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

inline-block은 inline·block과 무엇이 다른가?
block은 한 줄을 통째로 차지하고 크기 지정이 되고, inline은 옆으로 흐르지만 width/height가 먹지 않습니다. inline-block은 그 둘의 장점을 합쳐 옆으로 나란히 흐르면서 width/height도 지정할 수 있습니다. 이 예제의 카드 두 장이 250×150으로 나란히 서는 이유입니다.
부모 #big에 font-size:0을 주는 이유와, 카드에 font-size:16px를 다시 주는 이유는?
inline-block 사이의 소스 줄바꿈·공백이 글자 한 칸 폭의 틈으로 보이기 때문에, 부모 글자 크기를 0으로 만들어 그 틈의 폭을 0으로 없앱니다. 그런데 font-size는 자식에게 상속되므로 그대로 두면 카드 안 글자도 0이 되어 안 보입니다. 그래서 카드에서 16px로 되돌립니다.

💼 실무·코딩테스트에서는개발자 도구를 능숙하게 쓰는 것이 프론트엔드 실력의 절반입니다. 추측으로 코드를 고치는 대신 실제로 무엇이 적용됐는지 보고 고치면 훨씬 빠릅니다. 실무에서는 도구에서 값을 바꿔 보고 확정된 뒤 코드에 옮기는 흐름이 일반적입니다.

수업 파일 주의원본 test01.html은 id="card_inside"를 카드 두 장에 두 번 썼습니다. id는 문서에서 유일해야 하므로(선택자 카드 참고) 규칙 위반입니다. 브라우저가 관대하게 CSS를 둘 다에 적용해 줘서 화면은 멀쩡해 보이지만, JS의 getElementById("card_inside")는 첫 번째 것 하나만 돌려줍니다. 여러 개에 같은 스타일을 줄 때는 class="card_inside" + .card_inside { ... }로 쓰는 것이 맞습니다.
TIPfont-size:0 트릭은 예전부터 쓰이던 방법이며, 최신 레이아웃에서는 flexbox의 gap 속성으로 더 간단하게 요소 사이 간격 문제를 해결할 수 있습니다.
실습 파일: test01
11

실습 — 종합 스타일링

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

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

쉽게 말하면지금까지 배운 flexbox 정렬 도구(방향·양쪽 끝 배치·가운데 정렬·남는 공간 채우기)만으로 머리글·본문·바닥글이 있는 한 페이지 뼈대를 짜 보는 종합 실습이에요. 요리로 치면, 배운 칼질을 다 써서 한 상을 차려 보는 시간입니다.

전체 뼈대 세우기

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

두 축의 정렬

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

남는 공간 차지하기

#content는 display: 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 예시).
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

#content의 flex-grow: 1을 지우면 본문 내용이 적을 때 화면이 어떻게 달라지나?
footer가 화면 맨 아래가 아니라 본문 바로 밑으로 딸려 올라옵니다. #wrapper는 min-height: 100vh로 화면 높이만큼 길지만, 남는 세로 공간을 가져갈 아이템이 없으면 그 공간은 맨 아래에 빈 채로 남기 때문입니다. flex-grow: 1이 그 빈 공간을 #content에게 몰아 줘서 footer를 바닥으로 밀어냅니다.
header의 justify-content: space-between을 center로 바꾸면 로고와 로그인 버튼은 어디에 놓이나?
둘이 붙어서 가로 가운데에 모입니다. space-between은 남는 가로 공간을 아이템 사이에 몰아 줘서 양 끝으로 벌리고, center는 남는 공간을 양 바깥에 똑같이 나눠 줍니다. 세로 위치는 align-items: center가 맡으므로 그대로입니다.

💼 실무·코딩테스트에서는이 실습의 min-height: 100vh + 세로 flex + 본문 flex-grow: 1 조합은 “내용이 적어도 푸터를 화면 맨 아래에 붙이는” 레이아웃으로 실무에서 그대로 쓰입니다. 헤더의 justify-content: space-between + align-items: center도 로고는 왼쪽, 메뉴·버튼은 오른쪽인 내비게이션 바의 기본 공식입니다.

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를 각각 연결해서 사용합니다.

link 태그 읽는 법

rel="stylesheet"는 “이 파일은 스타일시트”라는 뜻이고, href는 css 파일의 경로입니다. 수업 파일에 붙은 type="text/css"는 생략해도 되는 옛 습관이고, media="screen"은 “화면에 표시할 때만 적용(인쇄할 때는 제외)”이라는 조건입니다. 연결된 css 파일 맨 위의 @charset "UTF-8";은 CSS 파일 자체의 문자 인코딩을 지정해 한글 주석과 내용이 깨지지 않도록 합니다.

css02.css는 어디서 보나

css02.css는 여러 선택자 문법을 모아 둔 파일입니다. 타입·id·class·자식/자손·속성 선택자 부분은 Selector 표현식 카드에, 선택자 우선순위(구체성)는 셀렉터 종합 실습 카드에 있으므로, 이 카드에는 그 두 카드에 실리지 않은 나머지 규칙(전체 선택자, 형제 선택자, 가상 요소 ::after, 가상 클래스 :link·:visited·:active·:hover)만 원본에서 발췌했습니다.

재사용되는 test.css

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

HTML css01_작성방식.html · css02_selector표현식.html — <head>
<!-- css01_작성방식.html: test.css 연결 -->
<link rel='stylesheet' type='text/css' media='screen' href='css/test.css'>

<!-- css02_selector표현식.html: css02.css 연결 -->
<link rel='stylesheet' type='text/css' media='screen' href='css/css02.css'>
<!-- href는 이 HTML 파일 위치 기준 상대경로: 같은 폴더의 css 폴더 안 파일 -->
CSS css/css02.css — 발췌(css-02·03 카드에 없는 규칙)
/* 파일 자체의 인코딩 지정: 한글 주석·내용 깨짐 방지 */
@charset "UTF-8";

/* (p·#sub_title1·.c·#sub_title1>b·h1 b·p[title] 규칙은 css-02 카드 참고) */

/* 전체 선택자: 모든 요소에 글꼴과 바깥·안쪽 여백 10px */
* {
    font-family: '굴림';
    margin: 10px;
    padding: 10px;
}

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

/* 일반 형제 선택자: h1 뒤에 오는 '모든' 형제 p
   (p 규칙보다 구체성이 높아 bisque 대신 aquamarine 배경이 된다) */
h1~p {
    width: 800px;
    background-color: aquamarine;
}

/* 가상 요소: class="a"인 요소 안의 맨 끝에 없던 상자를 만든다
   content가 없으면 상자가 생기지 않는다(빈 문자열이라도 필수) */
.a::after {
    content: "";
    display: block;
    width: 800px;
    height: 200px;
    background-color: pink;
}

/* 가상 클래스: 링크의 '상태'에 따라 스타일 */
a:link {                 /* 아직 방문하지 않은 링크 */
    background-color: chartreuse;
}

a:visited {              /* 이미 방문한 링크 */
    background-color: blue;
}

a:active {               /* 마우스로 누르고 있는 순간 */
    color: red;
    font-size: 20px;
}

p:hover {                /* 마우스를 올렸을 때만 */
    background-color: yellow;
    font-weight: bold;
}
핵심 정리
  • <link rel="stylesheet" href="경로">를 <head> 안에 작성하면 외부 .css 파일의 모든 규칙이 해당 문서에 그대로 적용됩니다.
  • @charset "UTF-8";은 CSS 파일 자체의 문자 인코딩을 지정해 한글이 깨지지 않도록 합니다.
  • 하나의 외부 css 파일(test.css)을 여러 HTML 파일에서 재사용할 수 있으며, css01과 css02는 각기 다른 외부 파일을 연결해 사용합니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

CSS 파일을 여러 개로 나눌 때의 장단점은?
장점은 관리가 쉬워지는 것, 단점은 요청 수가 늘어 초기 로딩이 느려질 수 있다는 것입니다. 그래서 실무에서는 개발할 때는 나누고 빌드할 때 합치는 방식(번들링)을 씁니다.
@import와 link 태그 중 무엇이 나은가?
<link>입니다. @import는 CSS를 다 받은 뒤에야 다음 파일을 요청해 순차적으로 느려지지만, link는 병렬로 받습니다. 성능 차이가 분명해서 @import는 지양됩니다.
css02.css의 p:hover(가상 클래스)와 .a::after(가상 요소)의 차이는?
가상 클래스는 '상태'를 고르고(마우스 올림·방문한 링크), 가상 요소는 '없던 요소'를 만들어 냅니다. 그래서 콜론 개수도 다르죠 — 상태는 : 하나, 만들어 내는 것은 :: 둘입니다. ::after로 만든 상자가 안 보이면 먼저 content를 확인하세요 — 빈 값이라도 content: "";이 있어야 생깁니다.

💼 실무·코딩테스트에서는현대 프론트엔드는 Vite·Webpack 같은 번들러가 이 문제를 자동으로 처리합니다. 다만 왜 번들링이 필요한가를 알아야 설정을 이해할 수 있죠 — 요청 수 줄이기, 캐싱, 코드 분할이 핵심 목적입니다.

TIP외부 스타일시트의 href 경로는 HTML 파일 위치를 기준으로 한 상대경로이므로, 파일이나 폴더 구조를 옮기면 경로가 깨지지 않는지 항상 확인해야 합니다.
실습 파일: css02.css, test.css

3. JavaScript

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

01

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

외부·내부·인라인COREBOMDOM

한 줄 요약자바스크립트는 외부·내부·인라인 세 가지 방식으로 문서에 작성할 수 있고, 브라우저에서 쓰는 JS는 언어 자체(CORE = ECMAScript)와 브라우저가 제공하는 API(BOM·DOM)로 나눠 볼 수 있다.

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

세 가지 작성 방식

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

왜 외부 방식인가

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

3대 구성요소 — 언어 + 브라우저 API

세 축 중 언어 자체는 CORE(ECMAScript) 하나입니다. 문법·제어문·클로저와 Math·String·Array·JSON 같은 내장객체가 여기에 속하고, 브라우저 밖(Node.js 등)에서도 똑같이 동작합니다. 나머지 둘은 언어가 아니라 브라우저가 JS에게 빌려주는 기능(API)입니다. BOM(Browser Object Model)은 브라우저 창 자체를 다루는 객체들(window, history, navigator, screen, location)이고, DOM(Document Object Model)은 HTML 문서를 객체 트리(Node, Element, Event)로 다루는 API입니다. 실습 파일은 Document를 BOM 목록에 넣었는데, document는 window.document로 꺼내 쓰기 때문에 창(BOM)에서 출발하는 것은 맞지만 그 자체는 DOM의 입구(최상위 객체)입니다. document.getElementById() 같은 DOM 기능은 모두 여기서 시작합니다.

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(문법+내장객체)가 JS 언어 자체(ECMAScript)이고, BOM(window 관련 객체)과 DOM(문서 요소 관련 객체)은 브라우저가 제공하는 API이다. document는 DOM의 입구다.
  • console.log()는 여러 값을 콤마로 나열해 한 번에 출력할 수 있다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

JS를 body 끝에 두거나 defer를 쓰는 이유는?
HTML 파싱이 끝나기 전에 스크립트가 실행되면 요소를 못 찾기 때문입니다. head에 그냥 두면 DOM이 아직 없어 null이 나오죠. defer는 다운로드는 병렬로, 실행은 파싱 후라 가장 좋은 선택입니다.
HTML·CSS·JS의 역할을 한 문장씩으로 나눠보세요.
HTML은 구조와 의미, CSS는 겉모습, JS는 동작입니다. 이 분리를 관심사의 분리라 하고, 지켜야 각각을 독립적으로 수정할 수 있습니다. 다만 React 같은 현대 도구는 컴포넌트 단위로 다시 묶는 방향으로 갔습니다.

💼 실무·코딩테스트에서는스크립트 로딩 방식은 성능에 직결됩니다. async는 순서 보장이 없고 defer는 순서를 지키죠. 실무에서 Lighthouse 성능 점수를 볼 때 반드시 등장하는 항목입니다.

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()의 반환값은 두 가지뿐입니다: 확인을 누르면 문자열(숫자를 입력해도 "1", "2" 같은 문자열이지 숫자가 아님), 취소를 누르면 null. 예제의 typeof txt도 확인 때는 "string"이지만, 취소 때는 "object"가 찍힙니다 — typeof null이 "object"로 나오는 것은 JS 초창기부터 남아 있는 유명한 예외이므로, null인지 확인할 때는 txt === null처럼 직접 비교합니다.

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), 취소 → null (typeof null은 "object"로 찍힌다)
    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()는 확인을 누르면 문자열(숫자를 입력해도 문자열), 취소하면 null을 돌려주므로 숫자 비교가 필요하면 형변환이 필요하다.
  • switch문의 case는 값과 타입까지 정확히 일치(===)해야 매칭된다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

alert·confirm·prompt가 실무에서 거의 안 쓰이는 이유는?
브라우저를 완전히 멈추기(블로킹) 때문입니다. 그 창을 닫기 전까지 페이지의 모든 동작이 정지하죠. 또 모양을 전혀 꾸밀 수 없고 모바일에서 어색합니다. 그래서 직접 만든 모달을 씁니다.
confirm의 반환값을 어떻게 활용하나?
true/false를 돌려주므로 if (confirm("삭제할까요?")) { ... }처럼 분기에 씁니다. 이 "확인을 받고 진행"하는 흐름 자체는 실무에서도 그대로이고, 다만 UI만 커스텀 모달로 바뀝니다.

💼 실무·코딩테스트에서는학습용으로는 결과를 바로 볼 수 있어 편리하지만, 실무 코드에 남아 있으면 리뷰에서 지적됩니다. 디버깅은 console.log, 사용자 확인은 커스텀 모달이 표준입니다.

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는 재할당 자체를 막아 상수를 선언할 수 있게 해줍니다.

오해하기 쉬운 점: let·const도 호이스팅은 됩니다. 다만 선언 줄에 도달하기 전까지는 TDZ(Temporal Dead Zone, 일시적 사각지대)라는 “아직 쓸 수 없는” 상태라서, 그 사이에 접근하면 undefined가 아니라 ReferenceError가 납니다(코드 맨 아래 주석 참고). var처럼 조용히 undefined를 주는 대신 에러로 실수를 바로 알려 주는 것입니다.

동적 타입과 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은 재선언 시 에러로 막아준다)

// (보충) let/const도 호이스팅되지만 선언 줄 전까지는 TDZ → 접근하면 에러
// console.log(x); // ReferenceError: Cannot access 'x' before initialization
// let x = 1;
핵심 정리
  • var는 함수 스코프이며 재선언이 가능하고 호이스팅으로 인해 선언 전 참조 시 undefined가 나온다.
  • let은 재선언을 막고, const는 재할당을 막는다 (모두 블록 스코프).
  • typeof 연산자로 변수의 현재 타입(number, string, object, function 등)을 확인할 수 있다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

var 대신 let·const를 쓰라는 이유를 두 가지 말해보세요.
① var는 함수 스코프라 블록 밖에서도 접근돼 예상치 못한 곳에서 값이 바뀝니다. ② 호이스팅으로 선언 전에 undefined로 접근이 되어 버그를 숨깁니다. let·const는 블록 스코프이고 선언 전 접근 시 에러를 냅니다.
const로 선언한 객체의 속성은 바꿀 수 있을까?
바꿀 수 있습니다. const는 변수가 가리키는 대상을 재할당할 수 없다는 뜻이지 내용을 얼리는 게 아닙니다. 자바의 final과 완전히 같은 성질이죠. 내용까지 막으려면 Object.freeze()가 필요합니다.

💼 실무·코딩테스트에서는기본은 const, 재할당이 필요할 때만 let이 현대 JS의 관례입니다. var는 사실상 쓰지 않습니다. 면접에서 호이스팅과 TDZ(일시적 사각지대)를 자주 묻습니다.

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 속성으로 여러 개를 찾아 배열과 유사한 형태(NodeList)로 반환합니다. 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>";
    // ⚠ 바로 윗줄(innerHTML)이 textContent 결과를 곧바로 덮어써서
    //   화면에는 마지막 굵은 글씨만 남는다 → 두 방식의 차이는 안 보임.
    //   차이를 보려면 textContent에도 "<b>굵게?</b>"를 넣어 보자:
    //   textContent는 <b>를 글자 그대로 보여 주고, innerHTML은 굵게 만든다.
}

function searchName() {
    // 2. name 속성으로 탐색 → 여러 개(NodeList) 반환
    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)
    // "p:last-child > span" = 부모(div)의 '마지막 자식'인 p,
    //   그 p의 '바로 아래 자식' span들 → 5번 문단의 span 3개
    //   (:last-child는 "p 중 마지막"이 아니라 "부모의 마지막 자식이면서 p"라는 뜻)
    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 마크업까지 해석해서 삽입한다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

querySelector와 getElementById 중 무엇을 쓰나?
둘 다 쓰지만 querySelector가 더 범용적입니다 — CSS 선택자를 그대로 쓸 수 있으니까요. getElementById가 미세하게 빠르지만 실무에서 체감되는 차이는 아닙니다. 일관성 있게 하나를 쓰는 편이 낫습니다.
querySelectorAll이 돌려주는 것은 배열인가?
아닙니다. NodeList입니다. forEach는 되지만 map·filter는 없죠. 배열 메서드를 쓰려면 [...nodeList]나 Array.from()으로 변환해야 합니다. 이걸 모르면 "왜 map이 없지?"에서 막힙니다.

💼 실무·코딩테스트에서는DOM 조작은 프론트엔드의 뿌리입니다. React를 쓰면 직접 할 일이 줄지만, React가 내부에서 무엇을 대신해 주는지 알아야 성능 문제를 이해할 수 있습니다. DOM 접근은 비싼 연산이라 반복문 안에서 계속 조회하면 느려집니다.

TIP여러 개를 돌려주는 메서드는 모두 진짜 배열이 아니라서 map·filter를 바로 쓸 수 없습니다. 다만 종류가 둘로 나뉩니다. getElementsByTagName·getElementsByClassName은 HTMLCollection이라 forEach도 없어서 for문으로 돌아야 하고, getElementsByName·querySelectorAll은 NodeList라 forEach까지는 바로 됩니다. 어느 쪽이든 Array.from(...)이나 [...목록]으로 바꾸면 모든 배열 메서드를 쓸 수 있습니다.
실습 파일: 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()을 사용하며, 이 두 함수는 문자열을 앞에서부터 읽다가 숫자가 아닌 문자를 만나면 거기서 멈추고 그때까지의 숫자만 돌려준다는 특징이 있습니다. 그래서 숫자 뒤에 글자가 붙은 parseInt("12px")는 12지만, 맨 앞부터 숫자가 아닌 parseInt("abc12")는 NaN입니다(Number("12px")는 문자열 전체가 숫자여야 해서 NaN).

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 엘리먼트 객체 자체가 매개변수로 전달된다
// (⚠ id가 전역 변수처럼 잡히는 옛 브라우저 동작에 기댄 것 — 비권장,
//    실무에서는 document.getElementById("int1")로 찾는다)
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()은 문자열을 코드로 실행해주지만 보안상 실무에서는 사용을 지양한다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

'1' + 1 과 '1' - 1 의 결과가 다른 이유는?
+는 문자열 연결의 의미도 있어 '11'이 되고, -는 숫자 연산뿐이라 0이 됩니다. JS의 암묵적 형변환이 만드는 대표적 혼란이죠. 그래서 명시적으로 Number()·parseInt()를 쓰는 습관이 안전합니다.
== 대신 === 를 쓰라는 이유는?
==는 타입을 맞춰 가며 비교해서 0 == '0', null == undefined가 true가 됩니다. 규칙이 복잡해 예측이 어렵죠. ===는 타입까지 같아야 true라 훨씬 예측 가능합니다.

💼 실무·코딩테스트에서는=== 사용은 거의 모든 팀의 린트 규칙입니다. JS의 형변환 규칙은 면접 단골이기도 하죠 — [] + {} 같은 문제가 나오는 이유입니다. 실무에서는 애초에 그런 상황을 만들지 않는 것이 정답입니다.

TIPonclick="intTest(int1)"이 동작하는 것은 브라우저가 id가 붙은 요소를 같은 이름의 전역 변수(window.int1)로도 만들어 주는 옛 호환 동작 덕분입니다. 그래서 함수 안에서는 엘리먼트 객체를 받아 .value로 값을 꺼냅니다. 다만 이 동작은 규칙처럼 의지하면 안 되는 비권장 방식입니다 — 같은 이름의 변수가 있으면 가려지고, 코드만 봐서는 int1이 어디서 왔는지 알 수 없습니다. 요소는 document.getElementById("int1")(또는 querySelector("#int1"))로 명시적으로 찾으세요.
실습 파일: js06
06

함수·객체·클로저·ES6

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

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

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

함수의 세 형태

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

그런데 아래 예제의 Info 생성자는 this.printout = () => ...처럼 화살표 함수를 메서드로 쓰고도 잘 동작합니다. 이유는 화살표 함수가 만들어지는 순간의 바깥 this를 붙잡아 두기 때문입니다. 생성자 함수 안에서 바깥 this는 new로 지금 만들어지고 있는 객체이므로, printout은 언제 어떻게 호출되든 그 객체를 가리킵니다. 반면 객체 리터럴 { printout: () => this.subject }에서는 바깥 this가 그 객체가 아니라(전역 등) 원하는 값이 안 나옵니다 — 그래서 예제의 jsonObj도 this 대신 jsonObj.subject라고 이름을 직접 썼습니다. “화살표 함수는 메서드에 부적합”은 주로 이 객체 리터럴·prototype 메서드 경우를 말합니다.

객체 만드는 세 방법

첫째, { 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}`); // 템플릿 리터럴
};

// 1-2. 객체 리터럴: { key: value }로 직접 나열 (원본은 첫 번째 p 클릭 핸들러 안)
const jsonObj = {
    subject: "javascript",
    credit: 1,
    printout: () => jsonObj.subject + "," + jsonObj.credit + "학점"
};
jsonObj.subject = "JS";     // update
jsonObj["test"] = "value";  // insert
delete jsonObj["credit"];   // delete

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

// 5. 클로저: 함수가 자신의 스코프 변수를 기억해 상태 유지
function closureTest2() {
    let count = 0;          // 반환된 함수가 계속 기억하는 변수
    return function () {    // 이 익명함수가 클로저
        count++;
        console.log(count); // 호출할 때마다 1, 2, 3... 누적
    };
}
// 함수 자체를 변수에 담아두고 호출해야 count가 유지된다
let closureTest2_1 = closureTest2();
closureTest2_1(); // 1
closureTest2_1(); // 2  ← count가 누적된다
// (원본에서는 이 호출이 네 번째 p의 onclick 안에 주석으로만 있다)
핵심 정리
  • 함수는 선언적 함수, 익명함수, 화살표함수로 나뉘며 화살표함수는 자신의 this를 만들지 않는다.
  • 객체는 리터럴 방식, 생성자 함수(new+this) 방식, 프로토타입 공유 방식으로 만들 수 있다.
  • 클로저는 함수가 자신의 스코프 변수를 기억해서 상태를 계속 유지시키는 기법이다.
  • 구조분해할당({a,b}=obj)과 템플릿 리터럴(${})은 ES6에서 추가된 문법이다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

클로저를 한 문장으로 설명해보세요.
함수가 자기가 만들어진 환경의 변수를 기억하는 것입니다. 함수를 밖으로 꺼내 나중에 호출해도 그때 그 변수에 접근할 수 있죠. 이걸로 private 변수를 만들거나 상태를 가둬 둘 수 있습니다.
화살표 함수와 일반 함수의 this가 어떻게 다른가?
일반 함수의 this는 호출 방식에 따라 정해지지만, 화살표 함수는 자기 this가 없어 바깥 것을 그대로 씁니다. 그래서 콜백 안에서 this가 바뀌는 문제를 화살표 함수로 해결합니다.

💼 실무·코딩테스트에서는클로저는 React 훅의 동작 원리 그 자체입니다 — useState가 값을 기억하는 것도, stale closure 버그가 생기는 것도 전부 클로저 때문이죠. 면접 최빈출 주제이기도 합니다.

수업 파일 주의위 코드는 원본 js07.html에서 핵심 줄만 뽑아 바로 실행되는 순서로 정리한 것입니다. 원본을 그대로 열면 다음 세 곳이 기대와 다르게 동작합니다. ① 첫 번째 p에 pArray[0].onClick(대문자 C)으로 연결해서, 이벤트 속성 이름(onclick, 전부 소문자)과 달라 클릭해도 객체 리터럴 예제가 실행되지 않습니다. ② 세 번째 p의 new Ifo("JAVA")는 Info 오타라 ReferenceError가 나고 그 아래 addFunc() 호출까지 가지 못합니다. ③ 네 번째 p의 onclick 안은 전부 주석이라 클릭해도 아무 일도 없습니다 — 클로저를 확인하려면 closureTest2_1(); 줄의 주석을 풀어야 합니다.
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]/)처럼 정규표현식을 넣으면 숫자 포함 여부 같은 패턴 검사도 가능합니다. 단, 수업 예제 strTest03은 끝 위치를 lastIndexOf(".")로 잡았는데 문자열의 "."가 앞쪽에 하나뿐이라 시작 > 끝이 되어 엉뚱한 부분이 잘리고, 결국 splitVal[1]이 없어 에러가 납니다. 아래 코드의 “고친 버전”처럼 substring(sIdx + 1)로 끝까지 자르면 의도대로 두 메서드 이름이 나옵니다.

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("당신의 회사명은?", "");
    // ⚠ 취소를 누르면 prompt가 null을 돌려줘서 null.match(...) →
    //   TypeError: Cannot read properties of null (reading 'match')
    //   막으려면 if (strVal && strVal.match(/[0-9]/)) 처럼 먼저 null을 걸러 낸다
    if (strVal.match(/[0-9]/)) alert("숫자는 포함하면 안되요");
}

// [3] 위치 찾기 → 잘라내기 → 나누기 → 공백 제거
function strTest03() {
    let strVal =
        "문자열 추출하기. 관련 메서드:indexOf()메서드, substring()메서드";
    let sIdx = strVal.indexOf(":");     // ":"가 처음 나온 위치
    let eIdx = strVal.lastIndexOf("."); // "."가 마지막 나온 위치
    // ⚠ "."는 앞쪽 "추출하기." 하나뿐이라 eIdx=8, sIdx=16 (시작 > 끝)
    // 시작 인덱스+1부터 끝 인덱스 전까지 추출하려던 의도지만,
    // substring은 두 값을 바꿔서 ". 관련 메서드:"를 잘라 온다
    let result = strVal.substring(sIdx + 1, eIdx);
    let splitVal = result.split(",");   // ","가 없어 요소 1개짜리 배열
    let strVal01 = splitVal[0].trim();  // 앞뒤 불필요한 공백 제거
    let strVal02 = splitVal[1].trim();  // ⚠ splitVal[1]이 undefined → TypeError
}

// [3] 고친 버전: ":" 다음부터 끝까지 자른 뒤 ","로 나눈다
function strTest03Fixed() {
    let strVal =
        "문자열 추출하기. 관련 메서드:indexOf()메서드, substring()메서드";
    let sIdx = strVal.indexOf(":");
    let result = strVal.substring(sIdx + 1); // 끝 인덱스 생략 = 문자열 끝까지
    let splitVal = result.split(",");        // ["indexOf()메서드", " substring()메서드"]
    let strVal01 = splitVal[0].trim();       // "indexOf()메서드"
    let strVal02 = splitVal[1].trim();       // "substring()메서드"
}
핵심 정리
  • ==는 타입을 자동 변환해서 비교하고, ===는 타입까지 같아야 참이 된다.
  • indexOf/lastIndexOf로 위치를 찾고 substring/split으로 문자열을 잘라내거나 나눈다.
  • 템플릿 리터럴(백틱+${})은 문자열 결합보다 가독성이 좋아 실무에서 많이 쓰인다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

문자열이 불변이라는 것이 JS에서도 성립하나?
성립합니다. str[0] = 'x'는 조용히 무시되죠. 모든 문자열 메서드는 원본을 바꾸지 않고 새 문자열을 반환합니다. 자바의 String과 같은 성질입니다.
slice와 substring의 차이는?
slice는 음수 인덱스를 지원해 slice(-3)으로 뒤에서 3글자를 얻을 수 있습니다. substring은 음수를 0으로 취급하고, 시작>끝이면 두 값을 바꿔 버립니다. 실무에서는 slice가 예측 가능해 선호됩니다.

💼 실무·코딩테스트에서는문자열 처리는 코딩테스트와 실무 모두에서 매일 쓰입니다. JS는 split·join·replace·trim·padStart 같은 편의 메서드가 풍부하니, 직접 구현하기 전에 있는지 먼저 찾아보는 습관이 좋습니다.

수업 파일 주의원본 js08.html의 strTest03()은 버튼을 누르면 TypeError: Cannot read properties of undefined (reading 'trim')이 나고 화면에 아무것도 출력되지 않습니다(Node로 같은 코드를 실행해 확인). 원인은 두 가지가 겹친 것입니다. ① "."가 문자열 앞쪽(“추출하기.”)에만 있어서 eIdx(8)가 sIdx(16)보다 작고, substring은 시작>끝이면 두 값을 바꿔 ". 관련 메서드:"를 잘라 옵니다. ② 그 결과에는 ","가 없어 split(",")이 요소 1개짜리 배열을 만들고, splitVal[1]이 undefined입니다. 고치려면 strVal.substring(sIdx + 1)처럼 끝 인덱스를 빼서 문자열 끝까지 자르면 됩니다.
TIPnew String("값")으로 만든 문자열은 겉보기엔 같아 보여도 타입이 object이므로, ===로 리터럴 문자열과 비교하면 항상 false가 나온다는 점이 자주 헷갈리는 포인트입니다.
실습 파일: js08
08

Array 배열

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

한 줄 요약배열은 리터럴 방식으로 만들고, 숫자 정렬에는 비교 함수가 필요하며, 단순 대입은 같은 배열을 함께 쓰는 참조 공유라서 slice나 스프레드 연산자로 새 배열을 만드는 얕은 복사를 해야 원본이 보호된다(안쪽 객체까지 떼어 내려면 깊은 복사 structuredClone).

쉽게 말하면배열은 번호 붙은 사물함 한 줄이에요. 번호(인덱스)는 0부터 시작하고, push/pop은 맨 뒤에 넣기/빼기, shift는 맨 앞에서 빼기, slice는 일부 칸을 골라 새 줄로 복사하는 도구입니다. (맨 앞에 넣는 unshift, 중간 칸을 직접 수술하는 splice도 있지만 이 실습 코드에서는 실행하지 않고 목록에 이름만 나옵니다.)

리터럴과 크기순 정렬

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

넣고 빼는 메서드

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

참조 공유 → 얕은 복사 → 깊은 복사

복사는 세 단계로 구분합니다. ① 참조 공유(대입): const cc = aa;는 복사가 아니라 메모리 주소만 건네주는 것이라 cc와 aa는 같은 배열 하나를 가리킵니다. 그래서 cc를 바꾸면 aa도 바뀝니다. ② 얕은 복사: slice()나 스프레드([...ee])는 새 배열을 만들어 첫 단계 값들을 옮겨 담습니다. 숫자·문자 같은 기본값 배열이라면 복사본을 바꿔도 원본이 유지되지만, 안에 든 값이 객체/배열이면 그 안쪽은 여전히 공유합니다. ③ 깊은 복사: structuredClone(arr)은 안쪽 객체까지 전부 새로 만들어 완전히 떼어 냅니다. 또한 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"를 꺼냄
}

// slice(), 참조 공유 vs 복사
function sliceTest() {
    // slice(시작, 끝): 시작~끝 전까지를 '새 배열'로 추출 (원본 배열은 그대로)
    const arrayObj = [[1, 2], [3, 4], [5, 6]]; // 값이 배열(객체)인 배열
    const arrayObj02 = arrayObj.slice(1, 3);   // [[3,4],[5,6]]
    arrayObj02[0][0] = 10; // 복사본 안쪽 배열을 고치면...
    console.log(`복사받은쪽:${arrayObj02.toString()}`); // 10,4,5,6
    console.log(`원본쪽:${arrayObj.toString()}`);      // 1,2,10,4,5,6 ← 원본도 바뀜(얕은 복사)

    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]];

    // 스프레드 연산자 = 얕은 복사 (slice()와 같은 결과)
    // 안에 객체가 있으면 그 객체는 공유 → 그땐 structuredClone(ee)
    const ee = [1, 2, 3, 4, 5];
    const ff = [...ee];

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

// rest 파라미터: 넘어온 인자들을 val 배열 하나로 모아 받음
function spreadTest(...val) {
    // ⚠ 원본 그대로: i 앞에 let이 빠져 i가 '암묵적 전역 변수'가 된다
    //   (strict mode에서는 ReferenceError) → for (let i = 0; ...)로 쓰는 게 맞다
    for (i = 0; i < val.length; i++) {
        console.log(val[i]);
    }
}
핵심 정리
  • sort()는 기본이 사전식 정렬이므로 숫자 크기순 정렬은 비교함수 (a,b)=>a-b가 필요하다.
  • 단순 대입(=)은 참조 공유(같은 배열), slice()나 스프레드(...)는 얕은 복사(새 배열이지만 안쪽 객체는 공유), structuredClone()은 깊은 복사(안쪽까지 전부 새로)이다.
  • ...val 형태의 rest 파라미터로 개수가 정해지지 않은 인자를 배열로 받을 수 있다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

배열을 복사할 때 [...arr] 로 충분한 경우와 아닌 경우는?
[...arr]는 언제나 얕은 복사(새 배열, 첫 단계만 복사)입니다. 원시값 배열이면 그걸로 충분하지만, 객체가 든 배열이면 안쪽 객체는 원본과 공유됩니다. 복사본의 객체를 고치면 원본도 바뀌죠. 깊은 복사가 필요하면 structuredClone()이나 각 요소를 새로 만들어야 합니다.
push와 concat 중 React에서 써야 하는 것은?
concat(또는 스프레드)입니다. push는 원본을 직접 바꿔 React가 변화를 감지하지 못하죠. 불변성을 지켜 새 배열을 만들어야 리렌더링이 일어납니다.

💼 실무·코딩테스트에서는불변성은 React의 핵심 규칙이고, 그 뿌리가 이 배열 메서드 구분입니다. 원본을 바꾸는 것(push·splice·sort)과 새로 만드는 것(map·filter·concat·slice)을 나눠 외워 두면 실수가 줄어듭니다.

TIP스프레드·slice는 얕은 복사입니다. 배열 안의 값이 배열/객체(참조타입)라면 그 내부는 여전히 주소를 공유하므로, 예를 들어 const a = [{n:1}]; const b = [...a]; b[0].n = 9; 하면 a[0].n도 9가 됩니다. 안쪽까지 떼어 내려면 structuredClone(a)(깊은 복사)를 씁니다.
수업 파일 주의원본 js09.html은 배열을 arratObj(오타)로 선언하고 바로 다음 줄에서 arrayObj.length를 읽습니다. 그래서 페이지를 열면 콘솔에 ReferenceError: arrayObj is not defined가 나고, 같은 <script>에서 그 아래 줄(arrayObj2·arrayLit 출력)은 실행되지 않습니다. 함수 선언은 미리 올려지기(호이스팅) 때문에 버튼으로 부르는 정렬·push 예제는 동작합니다. 위 코드는 이름을 arrayObj로 맞춘 것입니다(2026-10-02 실제 페이지에서 확인).
실습 파일: js09
09

Lotto 번호 생성기

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

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

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

① js10.html — 생성자 함수로 뽑기

js10.html은 function 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)로 중복 여부를 검사합니다.

① js10.html — 보너스 분리와 출력

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

② js10_당첨기능.html — 당첨 확인

js10_당첨기능.html은 makeLotto()와 isCheck()로 중복 없는 번호를 채우고, 입력한 매수만큼 userBall()로 로또 배열을 만든 뒤 win()으로 당첨번호와 하나씩 비교해 일치하면 td.setAttribute("bgcolor", "orange")로 당첨 칸을 표시합니다. 이때 innerHTML 대신 createElement·createTextNode·appendChild()로 표의 행과 셀을 직접 만들어 붙이는 DOM 생성 방식을 사용합니다. 아래 발췌 코드의 user[j][k]는 “j번째 로또의 k번째 숫자”입니다.

③ js10_효과.html — 움직이는 공

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에 출력
    // 문서의 span 4개 중 [1] = 번호 자리, [3] = 보너스 자리 (아래 HTML 참고)
    document.querySelectorAll("span")[1].textContent = arr.join("-");
    document.querySelectorAll("span")[3].textContent = bonus;
}
HTML js10.html — 출력 자리
<span>로또번호:</span>              <!-- [0] 라벨 -->
<span>예시)1-3-4-15-20-22</span>     <!-- [1] ← 정렬된 6개 번호로 교체 -->
<span>보너스번호:</span>            <!-- [2] 라벨 -->
<span>예시) 21</span>               <!-- [3] ← 보너스 번호로 교체 -->
JS js10_당첨기능.html — 당첨 확인 (발췌)
var lotto = new Array(6); // 당첨번호 6개 (makeLotto로 채움)
var user = null;          // 사용자 로또들 [[1,2,3,4,5,6], [3,4,5,6,7,8], ...]

// 당첨번호에 같은 숫자가 있는지 확인
function win(lottos, userNum) {
    var bool = false;
    for (var i = 0; i < lottos.length; i++) {
        if (lottos[i] == userNum) bool = true;
    }
    return bool;
}

// printview() 안의 출력 부분 (앞부분 생략: 당첨번호·사용자 번호 생성)
var testTbody = document.getElementById("test"); // <tbody id="test">
for (var j = 0; j < user.length; j++) {          // j: 몇 번째 로또(행)
    // innerHTML 없이 표의 행(tr)·셀(td)을 직접 만들어 붙이기
    var tr = document.createElement("tr");
    for (var k = 0; k < user[0].length; k++) {   // k: 그 로또의 몇 번째 숫자(칸)
        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"); // 당첨 칸 표시
        }
    }
    testTbody.appendChild(tr);
}
JS js10_효과.html — 움직이는 공 (발췌)
// ~~는 소수점을 버리는 트릭(Math.floor와 유사)
function rnum() { return ~~(Math.random() * 500); }

var balls;
window.onload = function () {
    // class="ball"인 공 45개 (HTML에 미리 있음)
    balls = document.getElementsByClassName("ball");
    for (var i = 0; i < balls.length; i++) {
        balls[i].innerHTML = i + 1; // 공에 1~45 번호 쓰기
        // transform 값을 난수로 바꿔 공을 흩어 놓는다
        balls[i].style.transform =
            "translate(" + rnum() + "px," + rnum() + "px)";
    }
    // (생략: 공 클릭 시 6개까지 선택, 0.5초마다 위치를 다시 바꾸는 셔플)
};
핵심 정리
  • Math.floor(Math.random()*45)+1 공식으로 1~45 사이의 정수 난수를 만든다.
  • includes()나 indexOf()==-1로 배열 내 중복값 여부를 검사해서 중복 없는 번호를 뽑는다.
  • pop()/sort()/join()을 조합해 보너스 분리 → 정렬 → "1-2-3" 형식 문자열 완성까지 한 흐름으로 처리한다.
  • createElement/createTextNode/appendChild로도 innerHTML 없이 요소를 직접 만들어 화면에 붙일 수 있다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

Math.random()으로 1~45 정수를 만들려면?
Math.floor(Math.random() * 45) + 1입니다. Math.random()은 0 이상 1 미만이라 45를 곱하면 0~44.99…, 버림하면 0~44, 여기에 1을 더해 1~45가 되죠. +1을 빠뜨리면 0이 나옵니다.
중복 없는 6개를 뽑는 방법 두 가지는?
① Set에 넣으며 크기가 6이 될 때까지 반복 ② 1~45 배열을 섞어 앞 6개를 취하기. 두 번째가 중복 검사 없이 한 번에 끝나 깔끔합니다.

💼 실무·코딩테스트에서는난수와 중복 제거는 실무에서 추첨·샘플링·테스트 데이터 생성에 쓰입니다. 다만 보안이 필요한 곳(토큰·비밀번호)에는 Math.random()을 쓰면 안 됩니다 — 예측 가능하기 때문에 crypto.getRandomValues()를 써야 합니다.

TIP~~(물결 두 개, 비트 NOT 연산자를 두 번 적용)는 Math.floor()와 비슷하게 소수점을 버리고 정수로 만드는 트릭으로, js10_효과.html과 js10_당첨기능.html에서 Math.floor() 대신 사용된 것을 볼 수 있습니다.
실습 파일: js10, 당첨기능, 효과
10

Array 반복문 4종 비교

forforEach()map()filter()

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

쉽게 말하면이 카드의 4종은 수업 파일 순서대로 ① for(수동 운전: 인덱스 직접 제어) ② for...in / for...of(향상된 for문: 원본은 for...in을 썼지만 배열 값을 꺼낼 땐 for...of가 맞음) ③ forEach(배열 전용 셔틀) ④ map·filter(돌면서 새 배열을 만들어 주는 가공·선별 공장)입니다. for...in은 원래 객체의 key를 도는 열차라 배열에 태우면 값 대신 "0", "1" 같은 번호가 나와요.

for문과 forEach

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

map은 가공, filter는 선별

map과 filter는 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를 순회(주로 객체 속성용)
// ⚠ 원본 그대로: object라는 변수가 없어 ReferenceError → 아래 줄들이 실행 안 됨
for (const key in object) {
    console.log(key);
}
// ✅ 고친 버전: 배열의 '값'을 차례로 꺼낼 때는 for...of
for (const item of array) {
    console.log(item); // 1, 2, 3, 4, 5
}

// [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)을 순회할 때 쓰며, 배열에 쓰면 값이 아니라 "0", "1" 같은 인덱스 문자열이 나옵니다. 배열의 값을 차례로 꺼낼 때는 for-of문을 씁니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

for·forEach·for...of·map을 언제 각각 쓰나?
인덱스가 필요하면 for, 단순 순회는 for...of나 forEach, 변환해서 새 배열을 만들면 map입니다. 판단 기준은 "결과로 무엇을 원하는가"입니다.
forEach 안에서 break를 쓸 수 없는 이유는?
forEach는 콜백 함수를 매번 호출하는 구조라, 그 안의 break는 어디를 빠져나갈지 대상이 없기 때문입니다. 중간에 멈춰야 하면 for·for...of를 쓰거나 some·find로 바꿔야 합니다.

💼 실무·코딩테스트에서는"중간에 멈출 수 있는가"가 반복문 선택의 실질적 기준이 되는 경우가 많습니다. 실무에서는 map·filter·reduce로 의도를 드러내는 방식이 선호되지만, 성능이 중요한 대량 반복은 for가 여전히 빠릅니다.

TIPmap은 "무조건 배열의 길이가 그대로 유지되는 변형", filter는 "조건에 따라 길이가 줄어드는 선별"이라고 구분해서 외우면 헷갈리지 않습니다.
수업 파일 주의위 [2]의 첫 번째 for (const key in object)는 편집기 자동완성으로 들어간 틀이라 object라는 변수가 없습니다. 원본 js11.html을 열면 콘솔에 ReferenceError: object is not defined가 나고, 같은 <script>의 그 아래(forEach·map·filter 예제)가 실행되지 않습니다(2026-10-02 실제 페이지에서 확인). 배열 값을 차례로 꺼내려면 for (const item of array) { console.log(item); }처럼 for...of를 씁니다. for...in은 객체의 키를 도는 문법이라 배열에 쓰면 값이 아니라 "0", "1" 같은 인덱스 문자열이 나옵니다.
실습 파일: 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 기능을 구현합니다.

함정 하나: <input type="date">의 값 같은 "2026-10-05"(날짜만 있는 형식)을 new Date()에 넣으면 UTC 0시로 해석됩니다. 한국(UTC+9)에서는 그날 오전 9시가 되죠. 그래서 지금 시각과 빼서 Math.ceil로 올림하면, 오전 9시 전에 계산할 때 D-Day가 하루 더 크게 나올 수 있습니다(예: 10월 4일 오전 8시에 10월 5일까지 → 25시간 → 2일). 현지 0시로 맞추려면 new Date(date + "T00:00")처럼 시각을 붙이고(시각이 있고 시간대 표기가 없으면 현지 시간으로 해석), 오늘 쪽도 setHours(0, 0, 0, 0)으로 0시에 맞춘 뒤 빼면 정확합니다.

날짜 더하기 자동 계산

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); // 수료일 날짜 객체
    // ⚠ "2026-10-05" 같은 날짜만 문자열은 UTC 0시 = 한국 오전 9시로 해석됨
    //   → 오전 9시 전에 계산하면 하루 어긋날 수 있다 (위 설명 참고)
    // 두 시각의 차(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) 단위로 변환합니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

Date의 월(month)이 0부터 시작하는 것이 왜 문제인가?
사람이 쓰는 숫자와 한 칸 어긋나기 때문입니다. new Date(2026, 0, 1)이 1월 1일이죠. 이걸 모르면 한 달이 밀린 날짜가 조용히 만들어집니다. 일(day)은 1부터라 더 헷갈립니다.
날짜 계산을 직접 구현하면 왜 위험한가?
윤년·시간대·서머타임이 얽히기 때문입니다. "30일 뒤"를 +30*24*60*60*1000으로 계산하면 서머타임 전환일에 한 시간이 어긋납니다. 그래서 실무에서는 라이브러리를 씁니다.

💼 실무·코딩테스트에서는실무에서 날짜는 date-fns나 Day.js를 쓰고, 서버와는 UTC·ISO 8601 문자열로 주고받는 것이 표준입니다. "저장은 UTC, 표시할 때만 현지 시간"이 원칙입니다.

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, 창이름, options)은 새로운 브라우저 창(팝업)을 여는 메서드입니다. 첫 번째 인자는 열 페이지 주소, 두 번째는 창의 이름(target 이름)입니다 — 화면에 보이는 제목이 아니라(제목은 열리는 페이지의 <title>), <a target="...">처럼 창을 구별하는 이름이라서 같은 이름으로 다시 열면 새 창을 만들지 않고 그 창에 페이지를 다시 엽니다. 수업 코드는 이 값을 title이라는 변수에 담았을 뿐입니다. 세 번째는 "width=400,height=400,top=300,left=200"처럼 콤마로 구분된 문자열로 창의 크기와 위치를 지정합니다.

opener로 부모 접근

팝업으로 열린 자식 창에서는 window.opener라는 특수한 참조를 통해 자신을 열어준 부모 창의 document에 접근할 수 있고, 반대로 부모 창에서는 window.open()이 돌려주는 열린 창 객체를 const popup = window.open(...)처럼 변수에 받아 두면 popup.close() 등으로 자식 창을 제어할 수 있습니다(이 실습 코드는 반환값을 받지 않아서 부모 → 자식 방향 제어는 하지 않고, 자식 → 부모 방향만 보여 줍니다). 이 구조 덕분에 부모-자식 창 사이에 값을 주고받는 팝업 연동 화면(예: 주소 검색 팝업)을 만들 수 있습니다. 창을 닫을 때는 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 = "팝업창페이지"; // 창 이름(target 이름) — 화면 제목이 아님
        // 콤마로 구분한 문자열로 크기(width/height)와
        // 위치(top/left)를 지정
        let prop = "width=400,height=400,top=300,left=200";

        window.open(url, title, prop); // 팝업(자식) 창 열기
        // (반환값 = 열린 창 객체. 받아 두면 부모에서 제어 가능:
        //  const popup = window.open(...); popup.close();)
    }
}
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, 창이름, options)의 두 번째 인자는 화면 제목이 아니라 창을 구별하는 이름이고, 세 번째 인자로 팝업의 크기(width/height)와 위치(top/left)를 지정합니다.
  • 팝업(자식) 창에서 window.opener로 자신을 연 부모 창의 document에 접근할 수 있습니다.
  • 이벤트 핸들러의 매개변수 e는 이벤트 객체이며, e.target은 실제로 이벤트가 발생한 DOM 요소를 가리킵니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

window.open으로 연 창이 차단되는 이유는?
사용자 동작(클릭) 없이 자동으로 열려 했기 때문입니다. 브라우저는 클릭 핸들러 안에서 즉시 호출된 경우만 허용하죠. setTimeout 안이나 비동기 응답 뒤에 열면 팝업 차단에 걸립니다.
팝업이 실무에서 잘 안 쓰이는 이유는?
차단되기 쉽고 모바일에서 어색하기 때문입니다. 요즘은 같은 페이지 안의 모달로 처리하거나, 외부 결제처럼 꼭 필요할 때만 새 탭을 씁니다.

💼 실무·코딩테스트에서는OAuth 로그인·결제창처럼 팝업이 여전히 필요한 곳이 있습니다. 이때 반드시 클릭 이벤트 안에서 열어야 차단되지 않는다는 것이 실무 지식입니다.

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.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로 특정 프레임만 골라서 이동시킬 수 있습니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

location.href 대입과 location.replace의 차이는?
href 대입은 히스토리에 남아 뒤로 가기가 되고, replace는 현재 기록을 덮어써 뒤로 못 갑니다. 로그인 후 리다이렉트처럼 뒤로 가면 안 되는 곳에 replace를 씁니다.
URL의 쿼리스트링을 다루는 현대적인 방법은?
URLSearchParams입니다. 문자열을 직접 자르는 대신 params.get('id')로 안전하게 꺼낼 수 있고, 인코딩도 알아서 처리해 줍니다.

💼 실무·코딩테스트에서는SPA에서는 location 대신 라우터(React Router·Next.js router)를 씁니다 — location.href를 쓰면 페이지 전체가 새로고침되어 SPA의 장점이 사라지기 때문입니다. 이 차이를 아는 것이 중요합니다.

TIP쿼리스트링(search)과 해시(hash)를 헷갈리기 쉬운데, 쿼리스트링은 서버로 전달되는 값이고 해시는 서버로 전송되지 않고 브라우저(클라이언트) 안에서만 쓰이는 값이라는 차이를 기억해야 합니다.
14

DOM 탐색 속성

childNodeschildrenparentElementnextElementSibling

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

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

Node와 Element의 차이

DOM 트리에서 특정 요소를 기준으로 부모·자식·형제를 찾아갈 때 가장 중요한 개념은 "Node"와 "Element"의 차이입니다. Node는 텍스트, 주석, 태그를 모두 포함하는 넓은 개념이고, Element는 그중 태그(요소)만을 의미합니다. 그래서 childNodes/firstChild/lastChild는 줄바꿈이나 공백 같은 텍스트 노드까지 포함해 반환하고, children/firstElementChild/lastElementChild는 순수하게 태그 요소만 걸러서 반환합니다. 부모 쪽은 사정이 다릅니다. 텍스트 노드는 자식을 가질 수 없어서 부모가 텍스트 노드인 경우는 없으므로, parentNode와 parentElement는 보통 같은 요소를 돌려줍니다(차이가 나는 건 <html>의 부모처럼 부모가 요소가 아닌 document일 때뿐 — parentNode는 document, parentElement는 null).

7개 vs 3개의 비밀

실습 코드에서 div.childNodes.length는 7인데 div.children.length는 3입니다. div 안의 자식 태그는 <p> 3개뿐이지만, 태그 사이의 줄바꿈·공백까지 텍스트 노드로 계산되기 때문에 childNodes는 7개(텍스트 4 + p 3)가 되는 것입니다. 주석도 노드라서, div 안에 <!-- --> 주석을 하나 넣으면 주석 1개와 그 뒤 공백 텍스트 1개가 늘어 9개가 됩니다(그래서 아래 HTML은 원본처럼 div 안에 주석을 넣지 않았습니다). 인덱스로 자식에 접근할 때 이 차이를 모르면 엉뚱한 노드를 잡게 됩니다.

형제 탐색도 두 갈래

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

HTML js15.html
<!-- 탐색의 기준이 되는 구조: div 안에 p 3형제, 가운데 child02가 기준 요소
     (div 안에는 주석을 넣지 않는다 — 주석도 childNodes에 세어진다) -->
<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 기준 부모 (여기선 parentElement와 같은 div)
        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);
    }
}
핵심 정리
  • childNodes/firstChild/lastChild는 텍스트 노드(공백, 줄바꿈)와 주석까지 포함합니다.
  • children/firstElementChild/lastElementChild는 태그 요소만 걸러서 반환합니다. 부모가 텍스트 노드일 수는 없으므로 parentNode와 parentElement는 보통 같은 요소입니다.
  • 같은 div라도 childNodes.length는 7, children.length는 3이 나올 수 있습니다(공백 텍스트 노드 때문).
  • previousElementSibling/nextElementSibling으로 바로 이전/다음 형제 "요소"를 안전하게 찾을 수 있습니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

children과 childNodes의 차이는?
children은 요소만, childNodes는 텍스트 노드와 주석까지 포함합니다. 그래서 childNodes를 쓰면 줄바꿈 공백이 텍스트 노드로 잡혀 예상보다 개수가 많아집니다.
이 예제에서 child02.firstChild와 div.firstChild는 각각 무엇인가?
둘 다 태그가 아니라 텍스트 노드입니다. child02.firstChild는 p 안의 글자 "child02" 텍스트 노드이고, div.firstChild는 <div> 바로 뒤의 줄바꿈+공백 텍스트 노드입니다. 그래서 첫 번째 p를 원하면 div.firstElementChild를 씁니다.

💼 실무·코딩테스트에서는실무에서 위로 거슬러 올라가는 탐색은 parentElement를 여러 번 잇기보다 element.closest("선택자")를 자주 씁니다. 예컨대 목록 전체에 클릭 리스너를 하나만 달고 e.target.closest("li")로 “클릭된 항목”을 찾는 이벤트 위임 패턴이 대표적이죠. (innerHTML과 textContent의 차이·XSS는 DOM 탐색 메서드 카드 참고)

TIP실무에서는 대부분 Element가 붙은 탐색 속성을 사용합니다. Node 계열은 공백 텍스트까지 걸려서 인덱스 계산이 어긋나기 쉽습니다.
실습 파일: js15
15

DOM 요소 동적 생성

createElement()createTextNode()appendChild()innerHTML

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

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

노드 조립 3단계

수업 코드의 조립 방식은 document.createElement()로 태그 노드를, document.createAttribute()로 속성 노드를, document.createTextNode()로 텍스트 노드를 각각 따로 만드는 것에서 시작합니다. 그다음 setAttributeNode()로 속성을 붙이고 appendChild()로 텍스트를 붙여서 요소를 하나하나 조립합니다. 다만 속성을 노드로 만드는 createAttribute·setAttributeNode는 옛 방식(레거시)이라 요즘은 거의 쓰지 않습니다. 지금은 같은 일을 이렇게 짧게 씁니다: const div = document.createElement("div"); div.style.color = "red";(또는 div.setAttribute("style", "color:red")) div.textContent = val; main.append(div); — 원리(만들기 → 채우기 → 붙이기)는 같습니다.

화면에 붙여야 보인다

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

innerHTML 백틱 방식

이와 대조적으로 innerHTML에 백틱(템플릿 리터럴) 문자열을 통째로 대입하는 방법도 있습니다. 코드가 훨씬 짧고 직관적입니다. 성능은 쓰는 방식에 달려 있습니다 — 문자열을 HTML로 파싱하는 비용이 있지만, 많은 요소를 한 번에 대입하면 오히려 빠를 수 있고, 반대로 innerHTML +=를 반복하면 기존 내용까지 매번 다시 만들어 느려지고 붙어 있던 이벤트 리스너도 사라집니다. 확실한 약점은 보안(XSS)입니다. 사용자 입력처럼 믿을 수 없는 값이 섞이면 노드 조립 방식(textContent)을 써야 합니다.

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

    // ── [방법 1] 노드 조립 방식 (createAttribute·setAttributeNode는 레거시 —
    //    요즘은 div.style.color = "red" 또는 div.setAttribute("style", "color:red")) ──
    // 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 조합으로도 속성을 붙일 수 있지만 레거시 방식이고, 지금은 setAttribute()나 style 속성, append()를 씁니다.
  • 조립을 마친 요소는 반드시 부모 요소에 appendChild로 붙여야 실제 화면에 나타납니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

createElement로 만든 요소가 화면에 안 보이는 이유는?
아직 DOM에 붙이지 않았기 때문입니다. 만들기만 해서는 메모리에만 존재하죠. appendChild·append로 부모에 연결해야 화면에 나타납니다.
요소를 100개 추가할 때 성능을 챙기는 방법은?
DocumentFragment(또는 append(...요소들))에 모아 한 번에 붙이는 것이 기본입니다. 데이터가 믿을 수 있는 값뿐이라면 문자열을 다 만든 뒤 innerHTML에 한 번만 대입해도 됩니다(위 설명처럼 += 반복은 금물). 하나씩 붙이면서 중간중간 크기·위치 값을 읽으면 그때마다 레이아웃을 다시 계산(리플로우)하게 되어 느려집니다.

💼 실무·코딩테스트에서는이 "모아서 한 번에" 원리가 React의 핵심입니다 — Virtual DOM으로 변경을 모아 실제 DOM 조작을 최소화하죠. 직접 DOM을 다뤄 본 경험이 있어야 React가 무엇을 해결했는지 실감할 수 있습니다.

TIP간단한 구조는 innerHTML(템플릿 리터럴)이 편하지만, 사용자 입력값을 그대로 넣으면 XSS(스크립트 삽입) 위험이 있으므로 신뢰할 수 없는 데이터는 createElement/textContent 방식을 쓰는 것이 안전합니다.
실습 파일: js16
16

DOM으로 표 동적 생성

forEachcreateElement("tr")childElementCountappendChild()

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

쉽게 말하면입력 폼에 회원정보를 채우고 “추가”를 누를 때마다 목록 표에 한 줄(행)씩 붙여 나가는 실습이에요. 행 모양 도장을 하나 만들어 두고 버튼을 누를 때마다 한 번씩 쾅 찍는 것과 같아요(최대 10줄). 행 안의 칸(td)은 입력값 개수만큼 반복문으로 만듭니다. 데이터 배열 전체를 한꺼번에 행으로 찍어 내는 방식은 나중에 배우는 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) 안의 자식 개수를 세어 등록 제한 같은 비즈니스 로직을 구현할 수 있습니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

표를 동적으로 만들 때 insertRow·insertCell을 쓰는 이점은?
표 구조에 맞게 tbody·tr·td가 자동으로 처리되기 때문입니다. createElement로 직접 만들면 tbody를 빠뜨려 구조가 어긋나는 실수가 생길 수 있습니다.
데이터 배열로 표를 만들 때 반복문 안에서 무엇을 조심해야 하나?
매 반복마다 DOM을 조회하거나 붙이지 않는 것입니다. 참조는 반복문 밖에서 한 번 얻고, 행은 모아서 한 번에 붙이는 것이 좋습니다.

💼 실무·코딩테스트에서는"데이터 → 화면"의 반복 렌더링이 프론트엔드의 본질입니다. 이걸 직접 해 보면 React의 map과 key가 왜 필요한지 자연스럽게 이해됩니다 — 같은 문제를 선언적으로 푸는 것이니까요.

TIPinput.parentNode.previousElementSibling처럼 DOM 탐색 속성을 체이닝하면 "이 입력창이 어떤 항목인지"를 자바스크립트 변수 없이도 HTML 구조만으로 알아낼 수 있습니다.
실습 파일: js17
17

Cookie 사용하기

document.cookieexpiresencodeURIComponent()localStorage

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

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

쿠키 저장 규칙

쿠키(Cookie)는 브라우저가 사용자의 컴퓨터에 작은 데이터를 "이름=값" 형태의 문자열로 저장해두는 저장소입니다. document.cookie에 문자열을 대입하면 저장되는데, 이 문자열은 이름=값;expires=만료일;domain=도메인;path=경로;secure처럼 세미콜론으로 옵션을 이어붙인 형태입니다. expires를 빼면 세션 쿠키가 되어 브라우저를 닫으면 사라지므로 “다음 방문 때도 기억”하려면 만료일이 필요합니다. domain은 생략하면 쿠키를 만든 그 호스트에만 보내지고, 지정하면 오히려 그 도메인의 하위 도메인(서브도메인)까지 범위가 넓어집니다(예: domain=example.com → shop.example.com에도 전송). 값에 한글이나 특수문자가 있으면 깨질 수 있어 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);

    // expires가 없으면 '세션 쿠키' → 브라우저를 닫으면 사라짐
    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).
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

쿠키·localStorage·sessionStorage를 어떻게 구분해 쓰나?
쿠키는 서버로 자동 전송되어 인증에 쓰이고(용량 4KB), localStorage는 브라우저에만 영구 보관, sessionStorage는 탭을 닫으면 사라집니다. 서버가 알아야 하면 쿠키, 클라이언트만 쓰면 스토리지가 기준입니다.
민감한 정보를 localStorage에 넣으면 안 되는 이유는?
JS로 언제든 읽을 수 있어 XSS에 취약하기 때문입니다. 토큰이 탈취되면 그대로 사용되죠. 그래서 인증 토큰은 HttpOnly 쿠키에 두어 JS가 접근하지 못하게 하는 것이 권장됩니다.

💼 실무·코딩테스트에서는인증 토큰을 어디에 저장할 것인가는 실무에서 계속 논쟁되는 주제입니다. XSS 위험(localStorage)과 CSRF 위험(쿠키)의 맞바꿈이라, HttpOnly + SameSite 쿠키가 현재의 일반적 권장안입니다.

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()로 파싱해야 자바스크립트 배열·객체처럼 다룰 수 있습니다. 예제는 파싱한 값을 템플릿 리터럴로 innerHTML에 넣는데, 서버에서 온 값도 사용자가 입력한 값일 수 있으므로 앞에서 본 XSS 주의(DOM 탐색 메서드 카드)가 그대로 적용됩니다 — 믿을 수 없는 값이면 textContent로 넣으세요. 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)에 출력
            // ⚠ 서버 응답을 innerHTML에 그대로 끼우면 값 속 <script>·<img onerror> 등이
            //   HTML로 실행될 수 있다(XSS). 믿을 수 없는 데이터면 textContent로 넣자
            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 형태로 파라미터를 붙여서 전달합니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

비동기 통신이 필요한 이유를 한 문장으로 말해보세요.
서버 응답을 기다리는 동안 화면이 멈추지 않게 하기 위해서입니다. 동기로 기다리면 그 몇 초 동안 클릭도 스크롤도 안 되죠. 비동기는 요청만 보내 두고 다른 일을 하다가 응답이 오면 처리합니다.
XMLHttpRequest 대신 fetch를 쓰는 이유는?
Promise 기반이라 async/await와 자연스럽게 어울리기 때문입니다. XHR은 콜백 중첩이 생기죠. 다만 fetch는 404·500에서도 reject되지 않아 response.ok를 직접 확인해야 하는 함정이 있습니다.

💼 실무·코딩테스트에서는AJAX는 현대 웹의 출발점입니다 — 페이지 전체를 새로고침하지 않고 데이터만 갱신하는 방식이죠. 실무에서는 fetch나 axios를 쓰고, React에서는 TanStack Query 같은 도구로 캐싱·재시도까지 관리합니다.

TIPonreadystatechange 콜백은 통신 과정 중 여러 번 호출되므로, readyState 체크를 빠뜨리면 아직 데이터가 다 도착하지 않은 시점에 responseText를 파싱하려다 오류가 날 수 있습니다.
실행 주의js19.html을 파일 탐색기에서 더블클릭해 file:///C:/... 주소로 열면 data.json 요청이 브라우저 보안 정책(CORS, 같은 출처 정책)에 막혀 콘솔에 에러가 나고 아무것도 출력되지 않습니다. AJAX는 웹 서버를 통해 열어야 동작하므로, VS Code의 Live Server 확장(마우스 오른쪽 → Open with Live Server)처럼 로컬 서버로 http://127.0.0.1:5500/... 주소에서 열어 실습하세요.
실습 파일: js19, data.json

4. React

React & Next.js 커리큘럼(입문 1 + 레슨 13 = 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로, createRoot API(React 18에서 도입, 이 프로젝트는 React 19)가 index.html의 <div id="root">에 앱을 붙입니다. 예전 ReactDOM.render() 방식은 동시성 기능이 지원되지 않았고, React 19에서는 아예 제거되어 쓸 수 없습니다(아래 코드 주석의 "React 18부터"는 도입 시점을 말합니다).

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 />)로 앱을 마운트합니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

'선언형 UI'가 명령형과 무엇이 다른지 설명해보세요.
명령형은 "어떻게 바꿀지"를 일일이 지시하고(element.textContent = ...), 선언형은 "결과가 어떤 모습이어야 하는지"만 적습니다. 상태가 바뀌면 React가 알아서 화면을 맞춰 주죠. 그래서 DOM을 직접 만질 일이 사라집니다.
Virtual DOM이 빠른 이유를 정확히 말해보세요.
DOM 조작 자체가 빨라지는 게 아니라, 조작 횟수를 줄이는 것입니다. 메모리상의 가벼운 객체로 바뀐 부분만 계산한 뒤 실제 DOM에 한 번에 반영하죠. "모아서 한 번에"가 핵심이고, 직접 잘 짠 코드보다 항상 빠른 것은 아닙니다.

💼 실무·코딩테스트에서는면접에서 "Virtual DOM이 왜 빠른가"를 물으면 "가상이라서 빠르다"가 아니라 "실제 DOM 접근을 최소화하기 때문"이라고 답해야 합니다. 실무에서는 Vite의 빠른 개발 서버가 생산성에 큰 차이를 만듭니다.

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: '...' }}처럼 카멜케이스 속성의 객체로 씁니다. 이 실습은 인라인 style만 쓰지만, CSS 클래스를 붙일 때는 HTML의 class 대신 className(예: <div className="card">)이라고 써야 합니다 — class는 자바스크립트 예약어이고, JSX 속성 이름은 DOM 속성 이름(element.className)을 따르기 때문입니다.

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

데이터(team 배열)는 부모 App이 소유하고, 자식 ProfileCard는 name={member.name}처럼 속성 형태로 받은 props를 표시만 합니다. 자식 쪽에서는 매개변수에서 ({ name, job, ... }) 구조분해로 낱개 변수로 꺼내 씁니다. props는 항상 위→아래 한 방향으로만 흐르는 읽기 전용 데이터입니다. 자식이 props 객체 자체를 수정하려 하면(props.name = '...') 개발 모드에서는 React가 props 객체를 얼려(freeze) 두었기 때문에 에러가 납니다. 구조분해로 꺼낸 지역 변수에 다시 대입하는 것(name = '...')은 에러는 아니지만 부모의 데이터는 전혀 바뀌지 않으므로 의미가 없습니다 — 값을 바꿔야 하면 부모가 바꾸는 함수를 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 표현식 삽입, 안쪽 { }는 객체 리터럴, 속성명은 카멜케이스.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

props를 자식이 직접 수정하면 안 되는 이유는?
데이터 흐름이 한 방향(위→아래)이어야 추적이 가능하기 때문입니다. 자식이 마음대로 바꾸면 값이 어디서 변했는지 알 수 없게 되죠. 바꿔야 하면 부모가 함수를 내려 주고 자식은 그것을 호출합니다.
JSX에서 class 대신 className을 쓰는 이유는?
class가 자바스크립트의 예약어이기 때문입니다. 그래서 React는 DOM 속성 이름(element.className)을 그대로 따르기로 했죠. 같은 이유로 <label>의 for는 htmlFor가 됩니다.

💼 실무·코딩테스트에서는컴포넌트를 어떻게 나눌지가 React 실무의 핵심 감각입니다. 기준은 "재사용되는가"와 "한 가지 일만 하는가"죠. 너무 잘게 쪼개면 오히려 복잡해지므로 균형이 중요합니다.

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])는 첫 마운트 때 1번 실행되고, 이후에는 두 번째 인자(의존성 배열)에 넣은 값이 바뀔 때마다 화면을 그린 뒤 다시 실행되는 "부수 효과" 훅으로, 여기서는 todos가 바뀔 때마다 localStorage에 자동 저장합니다.

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

localStorage 저장을 구현하는 흔한 다른 방식은 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를 다시 읽는데, 방금 ①에서 이미 []로 덮어써진 뒤라 빈 배열만 읽혀 옵니다. 결과적으로 기존 할 일 목록이 화면에서 통째로 사라집니다. (setState 예약이 언제 반영되는지까지 단계별로 따라간 설명은 StrictMode 저장소 덮어쓰기 버그 카드에 있습니다.)

우리가 쓴 게으른 초기화 useState(() => ...) + 저장용 useEffect 1개 구조는 애초에 "초기값이 []인 순간" 자체가 없기 때문에 이 문제가 생기지 않습니다 — todos는 컴포넌트가 실행되는 첫 순간부터 이미 기존 데이터를 쥐고 시작하므로, StrictMode가 몇 번을 재실행해도 저장 effect가 빈 배열로 덮어쓸 일이 없습니다. 해결법은 하나 더 있습니다 — A) 이 실습처럼 게으른 초기화로 "읽기"를 effect 밖으로 빼는 방법과, B) 저장 effect를 없애고 추가·삭제 같은 이벤트 핸들러 안에서 바로 저장하는 방법입니다. A는 코드가 짧고 저장 위치가 한 곳이라 간단하고, B는 "사용자 행동 → 저장"이 직접 이어져 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, [의존성])는 첫 마운트에 1번 + 이후 의존성 배열 값이 바뀔 때마다 화면 렌더 후 실행 — todos 변경 시마다 localStorage에 동기화하는 데 사용했습니다.
  • "불러오기 useEffect + 저장 useEffect" 2개로 나누면 StrictMode의 이중 마운트 때문에 저장 effect가 초기값 []로 먼저 덮어써 데이터가 날아갈 수 있음 — useState(() => ...) 게으른 초기화로 애초에 [] 상태 자체를 없애면 안전합니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

state를 직접 수정하지 않고 setState를 쓰는 이유는?
React가 변화를 감지하지 못하기 때문입니다. arr.push()로 원본을 바꾸면 참조가 그대로라 "안 바뀌었다"고 판단해 리렌더링이 일어나지 않죠. 새 배열·새 객체를 만들어 넘겨야 합니다.
setCount(count+1)을 연속으로 세 번 부르면 3이 증가할까?
아닙니다. 1만 증가합니다. 셋 다 같은 시점의 count를 읽기 때문이죠. 3을 더하려면 함수형 업데이트 setCount(prev => prev + 1)를 써야 직전 값을 기준으로 계산됩니다.

💼 실무·코딩테스트에서는함수형 업데이트는 실무에서 반드시 필요한 패턴입니다 — 비동기 콜백이나 연속 갱신에서 오래된 값을 쓰는 버그를 막아 주죠. 면접에서도 "setState는 왜 비동기인가"와 함께 자주 나옵니다 — 핵심은 배칭(batching)입니다. React는 한 이벤트 안에서 일어난 여러 setState를 바로바로 반영하지 않고 모아 두었다가 한 번에 처리해 한 번만 다시 그립니다. 그래서 setCount(count + 1)를 연달아 세 번 불러도 셋 다 같은 옛 count를 보고 1만 늘고, setCount(prev => prev + 1)는 앞의 결과를 이어받아 3이 늘어납니다.

TIPuseEffect의 두 번째 인자를 빼먹고 useEffect(() => {...})만 쓰면 매 렌더링마다 실행되고, [] 빈 배열을 넣으면 최초 1번만 실행됩니다. [todos]처럼 값을 넣으면 첫 마운트에 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를 비교(얕은 비교)했을 때 함수까지 동일하다고 판단해 리렌더링을 건너뜁니다. 즉 memo로 감싼 자식에게 함수·객체·배열을 props로 넘길 때에 한해 useMemo/useCallback(참조 고정)과 React.memo(비교 후 스킵)가 짝을 이뤄야 효과가 있습니다 — 이때 캐싱만 하고 자식을 memo로 감싸지 않으면 자식은 여전히 매번 다시 그려집니다. 반대로 props가 숫자·문자열 같은 원시값뿐이라면 memo 하나만으로도 리렌더링을 건너뛸 수 있고, 무거운 계산을 아끼는 useMemo도 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로 감싸져 있다면 짝지어 써야 memo가 효과를 낸다(원시값 props만 넘길 때는 memo만으로도 충분).
  • React.memo(컴포넌트)는 이전 props와 새 props를 얕은 비교(===)해서 완전히 같으면 그 컴포넌트의 리렌더링을 건너뛴다 — 객체·배열·함수는 내용이 같아도 매번 새로 만들면 ===가 false이므로, useMemo/useCallback으로 "같은 참조"를 유지해 줘야 한다.
  • 세 훅 모두 "무조건 빠르다"가 아니라 측정 후 필요한 곳에만 쓰는 최적화 도구 — 남용하면 오히려 캐시 비교 비용만 늘어날 수 있다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

useMemo와 useCallback의 차이를 한 문장으로 말해보세요.
useMemo는 계산 결과(값)를 기억하고, useCallback은 함수 자체를 기억합니다. 사실 useCallback(fn, deps)는 useMemo(() => fn, deps)와 같죠 — 함수를 반환하는 useMemo의 축약형입니다.
useRef가 state와 결정적으로 다른 점은?
값이 바뀌어도 리렌더링이 일어나지 않는다는 것입니다. 그래서 DOM 참조나 타이머 id처럼 화면과 무관한 값을 담는 데 씁니다. 화면에 보여야 하는 값은 반드시 state여야 합니다.

💼 실무·코딩테스트에서는성능 훅을 남용하면 오히려 느려집니다 — 비교 비용과 메모리가 들기 때문이죠. 실무 원칙은 "먼저 측정하고, 문제가 확인되면 적용"입니다. React DevTools Profiler로 실제 병목을 확인한 뒤 쓰는 것이 맞습니다.

TIP콘솔(F12)을 열어두고 별(★)을 클릭해 보면 세 훅의 효과를 눈으로 확인할 수 있습니다 — useMemo가 있으면 "정렬/필터 재계산!" 로그가 안 찍히고, useCallback+memo가 있으면 클릭한 종목의 "렌더링" 로그만 찍힙니다. 반대로 하나씩 지워 보면 차이가 드러납니다. useMemo를 지우면 별을 누를 때마다 "정렬/필터 재계산!" 로그가 추가로 찍히지만, 정렬 결과 배열 안의 종목 객체는 원본 STOCKS의 것을 그대로 쓰기 때문에(같은 참조) 행 렌더링 로그는 여전히 클릭한 종목 하나만 찍힙니다. useCallback을 지우면 매 렌더링마다 toggleFavorite 함수 주소가 새로 바뀌어 memo가 뚫리므로 모든 종목의 "렌더링" 로그가 한꺼번에 찍힙니다 — 눈에 안 보이던 "낭비"를 직접 확인하는 가장 좋은 방법입니다. 단, 이 실습은 main.jsx에서 <StrictMode>가 켜져 있어 개발 모드에서는 컴포넌트 함수가 일부러 두 번씩 실행됩니다 — 그래서 콘솔 로그가 두 배로(두 번째 것은 흐린 글씨로) 보이거나, "렌더링 횟수"(renderCount)가 1씩이 아니라 2씩 늘어 보일 수 있습니다. 버그가 아니며 배포(build) 결과에서는 한 번만 실행됩니다(StrictMode란? 참고). 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 / setDebouncedValue — useState(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를 대신 호출.

autoRefresh도 useState 대신 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: 데이터 패칭 커스텀 훅 ──
// 개념: 종목코드를 넣으면 { 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
// ── 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)
    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으로 예약을 취소하는 방식으로 "입력이 멈춘 순간의 값"만 통과시킨다.
  • useInterval은 useRef로 "항상 최신 콜백"을 저장해두는 트릭으로, 타이머를 재시작하지 않고도 stale closure 없이 최신 함수를 반복 실행한다.
  • useLocalStorage는 useState(() => ...) 게으른 초기화로 저장소를 1번만 읽고, useEffect로 값이 바뀔 때마다 자동 저장한다 — 사용법은 useState와 동일한 [value, setValue].
  • useStockData는 Promise의 .then/.catch로 성공·실패를 나눠 처리하며, {data, loading, error} 3상태를 한 객체로 관리한다.
  • load 함수를 useCallback(fn, [symbol])으로 감싸지 않으면 매 렌더링마다 새 함수가 만들어져 useEffect([load])가 무한 재실행에 빠진다 — 함수를 useEffect 의존성에 정직하게 넣으려면 그 함수 자체를 useCallback으로 참조 고정해야 한다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

커스텀 훅을 만드는 기준은 무엇인가?
여러 컴포넌트에서 같은 로직이 반복될 때입니다. 훅은 상태를 가진 로직을 재사용하는 수단이죠. 이름을 use로 시작해야 React가 훅으로 인식하고 규칙 검사를 해 줍니다.
useDebounce가 해결하는 문제는 무엇인가?
너무 잦은 실행입니다. 검색창에 한 글자 칠 때마다 API를 부르면 낭비가 크죠. 디바운스는 입력이 멈춘 뒤 일정 시간이 지나야 한 번 실행하게 만듭니다. 서버 부하와 요금을 크게 줄여 줍니다.

💼 실무·코딩테스트에서는커스텀 훅은 React 코드 재사용의 표준입니다. 실무에서 useDebounce·useLocalStorage·useFetch는 거의 모든 프로젝트에 있죠. 로직과 화면을 분리하는 좋은 훈련이기도 합니다.

TIP기존 컴포넌트에 커스텀 훅을 끼워 넣을 때는 반환 모양만 맞추면 됩니다 — 예를 들어 useState('AAPL') 한 줄을 useLocalStorage('lastSymbol', 'AAPL')로 바꿔도 [inputSymbol, setInputSymbol]을 쓰는 나머지 코드는 그대로 둘 수 있습니다. 훅 자체(use로 시작하는 함수)와 훅이 헷갈리면 기초 개념 사전 — 커스텀 훅이란?을 참고하세요.
정리 작업 메모
  • 실습 기록(2026-07-27) — StockDetail의 빈칸 5곳을 채워 완성했다: ① useState('AAPL') → useLocalStorage('lastSymbol', 'AAPL')(반환값 이름은 그대로 [inputSymbol, setInputSymbol]), ② const debouncedSymbol = inputSymbol → useDebounce(inputSymbol, 300), ③ const data = null, loading = false, ... → const { data, loading, error, refetch } = useStockData(debouncedSymbol), ④ 주석 처리돼 있던 useInterval(refetch, autoRefresh ? 5000 : null) 줄의 주석 해제, ⑤(추가 개선) autoRefresh를 useState(false)에서 useLocalStorage('autoRefresh', false)로 바꾸고, useStockData의 load를 useCallback(..., [symbol])으로 감싸 [load]를 의존성 배열에 안전하게 넣었다.
  • 정리 — 본문·코드 제목에 섞여 있던 빈칸 이력("완성본(5곳 빈칸…)", 날짜, "원래는 useState(false)였지만…" 주석)을 이 메모로 옮기고, 코드 발췌는 수업 파일과 같게 맞췄다.
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로 감싼 자식에게 함수(또는 객체·배열)를 props로 넘길 때는 memo(자식을 방어) + useCallback(부모가 내려주는 함수의 참조를 고정)을 한 세트로 써야 실제 효과가 납니다. 이 실습의 나머지 props(symbol·price·change)는 원시값이라 그것만 있었다면 memo 단독으로도 충분했을 것이고, 짝이 필요한 이유는 onToggle 하나 때문입니다. 자세한 원리는 기초 개념 사전 — 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를 직접 관리할 필요가 없다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

React.memo를 붙였는데도 자식이 계속 리렌더링된다면 왜일까?
props로 넘기는 함수나 객체가 매번 새로 만들어지기 때문입니다. React.memo는 얕은 비교를 하는데, 함수는 매 렌더마다 새 참조가 되어 "바뀌었다"고 판단되죠. useCallback으로 함수를 고정해야 효과가 납니다.
코드 분할(lazy/Suspense)이 성능을 개선하는 원리는?
처음에 필요 없는 코드를 나중에 받게 하는 것입니다. 첫 화면에 안 보이는 페이지까지 한 번에 받으면 초기 로딩이 느려지죠. 필요한 시점에 받으면 첫 화면이 빨라집니다.

💼 실무·코딩테스트에서는memo로 감싼 자식에게 함수·객체 props를 넘길 때는 React.memo와 useCallback/useMemo를 짝으로 써야 의미가 있다는 것이 실무의 핵심입니다. 이 경우 한쪽만 쓰면 효과가 없죠(원시값 props만 넘긴다면 memo만으로도 충분합니다). 코드 분할은 페이지가 많은 앱에서 초기 로딩을 크게 줄여 줍니다.

TIP콘솔을 열어두고 "부모 리렌더 유발" 버튼을 눌러보면 [memo없음] 로그는 클릭할 때마다 3줄씩 찍히지만 [memo있음] 로그는 최초 1번만 찍히고 더 이상 안 찍힙니다. 네트워크 탭(브라우저 개발자 도구)을 열어두고 "📈 차트"를 처음 눌러보면, 그 순간에야 HeavyChart 관련 파일이 새로 로드되는 것도 확인할 수 있습니다 — 개발 서버(npm run dev, Vite dev)에서는 HeavyChart.jsx 같은 개별 모듈 파일이 그대로 요청되고, 하나로 묶인 "청크" 파일(assets/HeavyChart-xxxx.js 같은 이름)은 npm run build 결과물(npm run preview로 확인)에서 보입니다. 또 이 실습은 main.jsx에서 <StrictMode>가 켜져 있어, 개발 모드에서는 컴포넌트가 일부러 두 번씩 실행되므로 로그가 두 배로(두 번째 것은 흐린 글씨로) 찍힐 수 있습니다 — 버그가 아닙니다(StrictMode란? 참고). 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.jsx는 useContext(ThemeContext)를 컴포넌트에서 직접 쓰게 하지 않고, useTheme()이라는 자체 훅으로 한 번 더 감쌌습니다. 이렇게 하는 이유가 두 가지 있습니다.

  • 임포트 간소화 — 이 훅을 쓰는 컴포넌트(Header, StockGrid)는 ThemeContext와 useContext를 따로 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 전달 없이 자동으로 갱신됩니다. 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 없이도 다른 컴포넌트가 함께 리렌더링된다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

Context를 쓰면 props drilling이 사라지는 원리는?
중간 컴포넌트를 거치지 않고 바로 꺼내 쓰기 때문입니다. Provider가 값을 트리 전체에 뿌려 두면, 필요한 컴포넌트가 useContext로 직접 가져오죠. 중간 단계는 그 값을 몰라도 됩니다.
Context의 값이 바뀌면 어떤 컴포넌트가 리렌더링되나?
그 Context를 구독하는 모든 컴포넌트입니다. 값 일부만 바뀌어도 전체가 다시 그려지죠. 그래서 자주 바뀌는 값과 안 바뀌는 값은 Context를 나누는 것이 좋고, 이 한계 때문에 Zustand 같은 도구를 쓰기도 합니다.

💼 실무·코딩테스트에서는Context는 전역 상태 관리 도구가 아니라 '값 전달' 도구라는 것이 중요합니다. 테마·언어·로그인 정보처럼 자주 안 바뀌는 값에 적합하고, 자주 바뀌는 상태는 성능 때문에 별도 라이브러리를 씁니다.

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'를 선언합니다. 이 선언은 "클라이언트 경계"를 긋는 것이라, 그 파일과 그 파일이 import하는 모듈까지 브라우저에서도 실행되는 예전 방식의 React 컴포넌트가 됩니다. 단, "브라우저에서만" 실행되는 것은 아닙니다 — 클라이언트 컴포넌트도 첫 HTML은 서버가 미리 그려 보내고(SSR), 브라우저가 JS를 받은 뒤 그 HTML에 상태·이벤트를 연결합니다(hydration, 하이드레이션).

비유하면 서버 컴포넌트는 "주방에서 미리 완성해서 내보내는 요리"(다 만들어진 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의 정석 패턴이다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

파일 기반 라우팅의 장단점을 말해보세요.
장점은 폴더 구조만 봐도 URL을 알 수 있다는 것이고, 단점은 규칙을 외워야 한다는 것입니다(page.js·layout.js·[id]). 설정 파일에 라우트를 나열하는 방식보다 파일을 옮기면 URL이 바뀌는 점도 조심해야 합니다.
layout.js가 page.js와 다른 점은?
페이지가 바뀌어도 layout은 유지된다는 것입니다. 그래서 헤더·사이드바처럼 공통인 부분을 두죠. 다시 그려지지 않고 유지되므로 layout 안에 둔 React 상태(state)도 보존됩니다(예: 헤더의 입력값·열림 상태가 페이지를 옮겨도 그대로).

💼 실무·코딩테스트에서는Next.js App Router는 현재 React 생태계의 주류입니다. 실무에서는 서버 컴포넌트와 함께 쓰이며, SEO가 중요한 서비스에서는 사실상 표준 선택지가 되었습니다.

TIPconst { id } = await params에서 await를 빠뜨리면(Next 15+ 기준) params가 Promise 객체 그대로 구조분해돼 id가 undefined가 되고, 이후 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 카드에서 배운 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(스프레드)로 기존 가격들을 먼저 복사해오지 않으면, set이 prices 전체를 새 객체로 통째로 갈아 끼우면서 다른 종목 가격이 전부 사라집니다 — 기초 개념 사전 — 스프레드·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처럼 대괄호로 감싸면 변수 값이 객체 키가 된다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

Zustand가 Context보다 나은 점은 무엇인가?
Provider로 감쌀 필요가 없고, 필요한 값만 골라 구독할 수 있다는 것입니다. Context는 값이 바뀌면 구독자 전체가 리렌더링되지만, Zustand는 셀렉터로 고른 값이 바뀔 때만 다시 그려지죠.
셀렉터를 쓰는 것과 스토어 전체를 가져오는 것의 차이는?
리렌더링 범위가 다릅니다. useStore()로 전체를 가져오면 어느 값이 바뀌든 리렌더링되지만, useStore(s => s.count)처럼 고르면 그 값이 바뀔 때만 다시 그려집니다.

💼 실무·코딩테스트에서는전역 상태 관리 도구 선택은 실무 논의의 단골입니다. Redux는 무겁고 보일러플레이트가 많아, 요즘은 Zustand·Jotai 같은 가벼운 도구가 선호되죠. 서버 데이터는 TanStack Query로 따로 다루는 것이 최근 흐름입니다.

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 App Router·09 Zustand 카드까지는 page.js로 "화면"만 만들었다면, route.js는 화면이 아니라 "요청을 받고 답장을 써 주는 창구" 파일이에요. app/api/search/route.js처럼 api 폴더 밑에 두면, /api/search 주소로 요청이 왔을 때 이 파일 안의 GET 함수가 실행돼 답장을 보냅니다. fetch가 창구에 편지를 넣는 손님이라면, route.js는 그 편지를 뜯어 읽고 답장을 써서 돌려주는 창구 직원인 셈입니다.

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

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

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

GET(request) — 요청에서 정보를 안전하게 꺼내는 네 조각

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

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

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

동적 세그먼트 + await params — 08 App Router 카드의 규칙이 API에도 그대로

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

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

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

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

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

  • fetch(`/api/search?q=${encodeURIComponent(q)}`) — 같은 프로젝트 안의 API Route라 https://... 전체 주소 없이 상대 경로만으로 호출 가능. encodeURIComponent는 한글·공백이 섞여도 URL이 깨지지 않게 변환.
  • await res.json() — 서버가 돌려준 JSON 텍스트를 자바스크립트 객체/배열로 변환.
  • setResults(data.results || []) — 결과 배열을 state에 저장해 드롭다운으로 렌더링.
  • 개선 포인트 — 지금은 한 글자 칠 때마다 요청이 나가고(디바운스 없음), 먼저 보낸 요청의 응답이 늦게 도착하면 옛 검색 결과가 최신 결과를 덮어쓸 수도 있습니다(응답 순서 처리 없음). 실무라면 react-05의 useDebounce로 입력이 멈춘 뒤 한 번만 요청하고, react-14의 alive 플래그 패턴처럼 이미 지난 요청의 응답은 무시하도록 보완합니다.

비유하면 09 Zustand 카드까지는 우리 코드가 클라이언트 역할만 했다면, 이제는 같은 프로젝트 안에서 클라이언트(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 App Router 카드의 [id]와 같은 규칙).
  • 프론트엔드는 절대경로 없이 fetch('/api/...')로 같은 프로젝트 안의 API를 바로 호출할 수 있다.
  • 외부 API 키가 없어도 더미 데이터로 동작하게 만드는 방어적 분기는, 실전에서 API 키 없이도 개발을 이어갈 수 있게 해주는 흔한 패턴이다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

프론트엔드 프로젝트 안에 API를 두면 무엇이 좋은가?
배포와 관리가 하나로 합쳐집니다. 별도 서버 없이 같은 프로젝트에서 백엔드 로직을 둘 수 있죠. 또 API 키를 서버 쪽에 숨길 수 있어, 브라우저에 노출하면 안 되는 비밀을 안전하게 쓸 수 있습니다.
route.js에서 GET 함수를 export하면 무슨 일이 일어나나?
그 경로로 온 GET 요청을 처리하는 핸들러가 됩니다. 파일 위치가 곧 URL이 되고, 함수 이름이 HTTP 메서드가 되죠 — POST·PUT·DELETE도 같은 방식입니다.

💼 실무·코딩테스트에서는API 키 숨기기가 실무에서 API Routes를 쓰는 가장 흔한 이유입니다. 브라우저 코드에 키를 넣으면 누구나 볼 수 있으니, 서버를 한 단계 거쳐 호출하는 프록시 역할을 하게 합니다.

TIP/api/search?q=apple을 호출했는데 결과가 항상 {"results":[]}로 빈 배열만 온다면, route.js의 q 추출 로직이나 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 Zustand 카드의 저장소는 브라우저를 새로고침(F5)하면 다시 초기값으로 리셋되는 "화이트보드"였어요. persist는 그 화이트보드 내용을 자동으로 사진 찍어 서랍(localStorage)에 저장했다가, 다시 켤 때 그대로 복원해주는 "자동 저장 기능"입니다(게임의 세이브 파일과 같아요). 그리고 이번엔 저장소 안의 함수(액션)들이 직접 인터넷에 나가 시세를 물어보고(fetchPrice) 답을 기다렸다가(async/await) 돌아오는 일까지 하게 됩니다 — 10 API Routes 카드에서 컴포넌트가 하던 API 호출 역할 일부가 이제 저장소 쪽으로 옮겨온 셈이죠.

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

09 Zustand 카드는 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 Zustand 카드의 액션은 set({...}) 한 줄이면 끝나는 동기 함수였지만, fetchPrice는 API 응답을 기다려야 해서 async로 선언됩니다.

  • set({ loading: true, error: null }) — 요청을 시작하자마자 먼저 "로딩 중"으로 표시하고 이전 에러를 지움.
  • const res = await fetch(...) — 10 API Routes 카드에서 배운 /api/stock/[symbol]을 저장소 액션 안에서 직접 호출. 컴포넌트가 아니라 저장소가 API를 부르는 구조로 바뀜.
  • try...catch — 성공하면 prices를 갱신하고 로딩 해제, 실패하면 error에 메시지를 담고 로딩 해제 — 어느 쪽이든 loading: false는 꼭 실행되어야 "로딩 중"에 영원히 멈추지 않습니다.
  • fetchAllPrices — Promise.all(watchlist.map((s) => fetchPrice(s)))로 관심종목 전체를 한 번에 병렬 요청 — 하나씩 순서대로(for) 기다리는 대신 모두 동시에 보내고 다 끝날 때까지만 기다려 훨씬 빠릅니다.
  • 주의 — loading 하나를 여러 요청이 같이 씀 — 병렬로 실행된 fetchPrice들이 모두 같은 loading 하나를 켜고 끄기 때문에, 가장 먼저 도착한 응답이 loading: false로 바꿔 버립니다. 나머지 종목은 아직 받는 중인데 화면은 "로딩 끝"으로 보이는 것이죠(error도 마지막에 실패한 것 하나만 남음). 수업 코드는 그대로 두고, 개선한다면 ① fetchAllPrices에서 시작할 때 한 번 loading: true, Promise.all이 끝난 뒤 한 번 false로 바꾸거나, ② loading을 { AAPL: true, TSLA: false }처럼 종목별 상태로 두는 방법이 있습니다.

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로 그 종목을 포트폴리오에서 아예 지우고, 남으면 평단가는 그대로 두고 수량만 줄입니다(판 가격은 평단가에 영향을 주지 않음).

persist + Next.js — 하이드레이션 불일치란?

Next.js는 클라이언트 컴포넌트도 첫 HTML을 서버에서 미리 그려 보내고, 브라우저는 JS를 받은 뒤 그 HTML에 상태·이벤트를 연결합니다. 이 연결 과정을 하이드레이션(hydration)이라 하는데, 이때 React는 "브라우저에서 처음 그린 결과가 서버가 보낸 HTML과 같다"고 가정합니다. 그런데 서버에는 localStorage가 없어 초기값(['AAPL', 'TSLA', 'MSFT'])으로 그리고, 브라우저는 persist가 localStorage에서 복원한 값(예: 사용자가 추가한 종목)으로 그리면 두 결과가 달라집니다 — 이것이 하이드레이션 불일치이고, 개발 모드 콘솔에 경고가 뜨거나 화면 일부가 어긋날 수 있습니다. 이 실습(persist + Next)에 실제로 해당하는 문제입니다.

  • 흔한 대응 — mounted 플래그 — const [mounted, setMounted] = useState(false), useEffect(() => setMounted(true), [])를 두고 if (!mounted) return null(또는 로딩 표시)로, 브라우저에 마운트된 뒤에만 저장값을 그리게 합니다. 서버와 브라우저의 첫 렌더가 똑같이 "빈 화면"이 되어 불일치가 사라집니다.
  • skipHydration 옵션 — persist 옵션에 skipHydration: true를 주면 스토어가 만들어질 때 localStorage를 자동으로 읽지 않습니다. 대신 컴포넌트의 useEffect 안에서 useStockStore.persist.rehydrate()를 직접 호출해 "마운트 뒤에 복원"하도록 시점을 미룹니다(여기서 hydration은 "저장값을 스토어에 다시 채운다"는 뜻).
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를 직접 호출할 수 있다 — fetchAllPrices는 Promise.all로 여러 종목을 동시에 병렬 조회한다.
  • buyStock은 총 매입원가÷총 수량으로 평단가를 재계산하고, sellStock은 수량이 0 이하가 되면 delete로 종목 자체를 지운다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

persist 미들웨어가 하는 일을 한 문장으로 말해보세요.
스토어의 상태를 localStorage에 자동으로 저장하고, 새로고침하면 다시 불러옵니다. 직접 useEffect로 저장·복원 코드를 쓰지 않아도 되죠.
persist를 쓸 때 서버 렌더링에서 조심할 점은?
서버에는 localStorage가 없어 서버가 그린 화면과 브라우저가 복원한 화면이 달라질 수 있습니다(하이드레이션 불일치). 그래서 복원이 끝난 뒤에 렌더링하거나 skipHydration 같은 옵션을 씁니다.

💼 실무·코딩테스트에서는장바구니·다크모드·최근 본 상품처럼 새로고침에도 남아야 하는 상태에 씁니다. 다만 민감한 정보는 localStorage에 두면 안 된다는 원칙은 여기서도 그대로 적용됩니다.

TIP새로고침해도 관심종목·포트폴리오는 남아있는데 시세가 항상 "로딩 중..."부터 다시 시작한다면 정상입니다 — partialize가 prices를 일부러 저장에서 뺐기 때문에, WatchlistPanel의 useEffect(() => { fetchAllPrices() }, [fetchAllPrices])가 페이지를 열 때마다 최신 시세를 다시 받아옵니다. persist 자체가 헷갈리면 기초 개념 사전 — Zustand persist 미들웨어란?을 참고하세요.
12

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

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

한 줄 요약'use client'가 없는 컴포넌트(StockInfo)를 async function으로 선언해 함수 안에서 바로 await로 데이터를 가져오게 만들고, 그 컴포넌트를 <Suspense fallback={<CardSkeleton />}>로 감싸면 데이터가 오는 동안은 스켈레톤 UI가, 준비되면 실제 내용이 화면에 나타난다. 이때 스트리밍이란 페이지 전체를 다 만들 때까지 기다리지 않고, 뼈대 HTML(스켈레톤 포함)이 먼저 도착하고 준비된 조각(StockInfo)이 같은 응답으로 이어서 전송되는 방식을 말한다.

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

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

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

  • 파일 맨 위에 'use client'가 없음 — 08 App Router 카드에서 배운 규칙대로, 아무 선언이 없으면 기본값인 서버 컴포넌트가 됩니다.
  • 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 />}> — 이 울타리 안에 있는 <StockInfo symbol={symbolVal} />가 아직 데이터를 다 받아오지 못한 async 서버 컴포넌트라는 걸 감지.
  • 데이터 로딩 중 — fallback으로 지정한 CardSkeleton(회색 그라데이션 뼈대 UI)을 대신 표시.
  • 로딩 완료 — getStockProfile의 await가 끝나 StockInfo가 실제 JSX를 반환하면, 리액트가 자동으로 스켈레톤을 내리고 실제 내용으로 교체. 서버는 스켈레톤이 든 뼈대 HTML을 먼저 보내고, StockInfo가 준비되면 그 조각을 이어서 보내는데 이것이 스트리밍입니다(2초 동안 화면 전체가 하얗게 멈춰 있지 않음).

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

한 화면 안에서 서버 컴포넌트와 클라이언트 컴포넌트가 섞이는 구조

page.js를 보면 왼쪽 aside의 StockSearch·WatchlistPanel·PortfolioPanel은 전부 파일 맨 위에 'use client'가 있는 클라이언트 컴포넌트(Zustand 구독·클릭 이벤트가 필요)이고, 오른쪽 main의 StockInfo만 서버 컴포넌트입니다.

  • 서버/클라이언트 경계는 파일 단위로 정해집니다 — 한 페이지 안에 두 종류가 얼마든지 섞일 수 있고, page.js처럼 이들을 조립하는 부모는 그대로 서버 컴포넌트로 둘 수 있습니다.
  • 사용자 입력(검색, 매수/매도 클릭)이 필요한 부분은 클라이언트로, 데이터를 받아와 "보여주기만" 하면 되는 부분은 서버로 — 이 구분 기준이 서버 vs 클라이언트 컴포넌트 개념 카드에서 이미 설명한 내용과 그대로 일치합니다.
  • 선택한 종목을 서버 컴포넌트에 넘기는 방법 — URL searchParams — 서버 컴포넌트는 브라우저의 Zustand 스토어(selectedSymbol)를 읽을 수 없습니다(스토어는 브라우저 메모리에만 있음). 그래서 이 실습은 URL을 통로로 씁니다: 클라이언트 컴포넌트인 WatchlistPanel이 종목을 클릭하면 selectSymbol(symbol)과 함께 router.push(`/?symbol=${symbol}`)(next/navigation의 useRouter)로 주소를 바꾸고, 서버 컴포넌트인 page.js가 const { symbol } = await searchParams로 그 값을 읽어(없으면 "AAPL") StockInfo에 넘깁니다. params처럼 searchParams도 Next 15+에서는 Promise라 await가 필요합니다.
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)
  // 서버 컴포넌트의 fetch는 브라우저가 아니라 서버에서 직접 실행되므로 상대경로가 아닌
  // 절대 URL이 필요합니다. dev 서버 기본 포트(3000)에 맞춰뒀으니, 다른 포트로 실행 중이면 맞춰 바꾸세요.
  const res = await fetch(`http://localhost:3000/api/stock/${symbol}/profile`)
  const data = await res.json()
  return data
}

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 async function DashboardPage({ searchParams }) {
  const { symbol } = await searchParams
  const symbolVal = symbol || "AAPL"
  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' }}>
        <Suspense fallback={<CardSkeleton />}>
          <StockInfo symbol={symbolVal} />
        </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 서버 컴포넌트의 데이터 로딩을 기다리는 데도 똑같이 쓰인다.
  • 한 페이지 안에서 클라이언트 컴포넌트(사용자 상호작용)와 서버 컴포넌트(데이터 표시)가 파일 단위로 섞여 쓰일 수 있다.
  • 서버 컴포넌트는 Zustand의 selectedSymbol을 읽을 수 없으므로, 관심종목 클릭 시 router.push('/?symbol=…')로 URL을 바꾸고 page.js가 await searchParams로 읽어 StockInfo에 넘긴다(없으면 AAPL).
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

서버 컴포넌트가 클라이언트 컴포넌트보다 유리한 점은?
JS 번들에 포함되지 않아 브라우저가 받을 코드가 줄어듭니다. 또 DB나 파일 시스템에 직접 접근할 수 있고, API 키를 노출하지 않고 데이터를 가져올 수 있죠.
서버 컴포넌트에서 useState를 쓸 수 없는 이유는?
서버에서 한 번 실행되고 끝나기 때문입니다. 상태가 바뀌어 다시 그려지는 상호작용 자체가 없죠. 클릭·입력이 필요하면 'use client'를 붙여 클라이언트 컴포넌트로 만들어야 합니다.

💼 실무·코딩테스트에서는서버 컴포넌트는 React의 가장 큰 패러다임 전환입니다. 실무 판단 기준은 "상호작용이 필요한가" — 필요 없으면 서버, 필요하면 클라이언트로 두고 클라이언트 경계를 최대한 아래로 내리는 것이 원칙입니다.

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")를 골라 붙이는 것에 가깝습니다. 다크모드는 방의 조명 스위치예요 — 11 Zustand persist 카드에서 다룬 Zustand 저장소가 "지금 스위치가 켜져 있나 꺼져 있나(theme)"를 기억하는 스위치 자체이고, ThemeWrapper는 그 기억을 보고 실제로 방(<html> 태그)의 전등을 켜고 끄는 손입니다.

Tailwind CSS란 — 클래스 이름이 곧 스타일

className="px-3 py-1 rounded-lg text-sm border"처럼, Tailwind는 미리 정의된 짧은 클래스 이름 하나하나가 CSS 속성 하나(또는 몇 개)에 대응하는 "유틸리티 CSS 프레임워크"입니다. px-3은 좌우 패딩만 0.75rem(padding-left·padding-right, 위아래는 py-, 네 방향 전부는 p-3), rounded-lg는 border-radius: 0.5rem 같은 식으로, 직접 CSS 파일에 클래스를 정의하지 않고도 JSX 안에서 바로 스타일을 조합할 수 있습니다.

CSS 단원(CSS 작성 방식)에서 <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)가 활성화됩니다. 즉 다크모드의 "스위치 규칙" 자체가 이 줄에서 정의됩니다. 수업 코드는 이 @variant 표기로 작성했고 실습에서 정상 동작했지만, Tailwind v4 공식 문서가 안내하는 표기는 @custom-variant dark (&:where(.dark, .dark *));입니다(새 변형을 "정의"할 때는 @custom-variant, @variant는 CSS 규칙 안에서 기존 변형을 "적용"할 때 쓰는 지시어). 새로 작성한다면 공식 표기를 쓰는 것이 안전합니다.

Zustand의 theme 상태 + ThemeWrapper — 상태가 브라우저 DOM을 직접 조작하는 첫 사례

11 Zustand persist 카드(레슨 10)에서 만든 useStockStore에 theme: '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"가 붙어 있어야만 작동하기 때문입니다.
  • 한계 — 첫 화면이 번쩍일 수 있음(FOUC) — useEffect는 화면이 한 번 그려진 뒤에 실행되므로, 서버가 보낸 첫 HTML에는 dark 클래스가 없다가 JS가 실행된 다음에야 붙습니다. 그래서 새로고침 때 라이트 화면이 잠깐 보였다가 다크로 바뀌는 "번쩍임"이 생길 수 있습니다. 흔한 해결법은 <head>에 작은 인라인 스크립트를 넣어 화면을 그리기 전에 저장된 테마를 읽고 클래스를 먼저 붙이는 것입니다(다크모드 Q&A 참고).

dark: 변형으로 라이트/다크를 한 줄에 함께 쓰기 + 반응형 그리드

className="bg-white text-gray-900 dark:bg-stock-card dark:text-stock-light"처럼, 클래스 하나에 라이트 스타일과 dark: 스타일을 나란히 적어두면 매번 if (theme === 'dark') 분기를 짤 필요 없이 CSS 선택자가 알아서 골라 씁니다. page.js의 grid-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(11번 카드)의 나머지 상태 (그대로)
    }),
    {
      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(수업 코드 표기, 공식 문서 표기는 @custom-variant dark)로 <html>.dark일 때만 동작하는 dark: 접두사 규칙을 만든다.
  • Zustand의 theme 상태 + toggleTheme이 다크모드의 "기억"을 담당하고, ThemeWrapper의 useEffect가 그 기억을 실제 <html> DOM에 반영하는 "실행"을 담당한다 — 역할이 나뉘어 있다.
  • bg-white dark:bg-stock-card처럼 한 클래스 안에 라이트·다크 스타일을 같이 적으면 조건 분기 없이 CSS가 알아서 전환하고, md:grid-cols-[300px_1fr]도 같은 방식으로 반응형을 만든다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

Tailwind가 인라인 style과 다른 점은?
미리 정의된 디자인 토큰을 쓰고, 상태·반응형을 표현할 수 있다는 것입니다. 인라인 style로는 :hover나 미디어쿼리를 쓸 수 없지만, Tailwind는 hover:·md: 접두사로 전부 표현됩니다.
dark: 접두사 하나로 다크모드가 되는 원리는?
상위에 dark 클래스가 있을 때만 적용되는 CSS 규칙이 함께 생성되기 때문입니다. 루트에 클래스 하나를 토글하면 그 아래 dark: 규칙이 일제히 활성화되죠. CSS 캐스케이딩을 이용한 것입니다.

💼 실무·코딩테스트에서는Tailwind는 호불호가 갈리지만 실무 채택률이 매우 높습니다 — 클래스 이름을 짓지 않아도 되고, 사용하지 않는 CSS가 빌드에서 제거되어 파일 크기가 작아지기 때문이죠. 면접에서 장단점을 균형 있게 말할 수 있으면 좋습니다.

TIPStockSearch·WatchlistPanel·PortfolioPanel·StockInfo의 실습 로직 자체는 10~12 카드(레슨 09~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개, 장이 열려 있고 키가 있으면 현재가 1점), 그 뒤로는 Finnhub WebSocket을 열어 실시간으로 들어오는 시세를 Recharts의 <LineChart>에 계속 이어 붙인다 — 브라우저용 키가 없으면 WebSocket 대신 2초마다 REST를 다시 부르는 폴링으로 대신한다.

쉽게 말하면REST로 초기 데이터를 받아오는 건 "지난 방송 다시듣기(VOD)"를 한 번 다운로드하는 것이고, WebSocket은 "라디오 생방송 채널에 계속 주파수를 맞춰두는 것"에 가깝습니다 — 전화를 한 번 걸어 대답을 듣고 끊는 게 아니라, 통화를 계속 연결해둔 채로 상대가 뭔가 말할 때마다(onmessage) 바로바로 알림을 받는 방식입니다.

초기 데이터 로드 — REST + cleanup으로 stale 응답 무시하기

useEffect(() => { ... }, [selectedSymbol]) 안에서 fetch로 초기 시세(더미 경로면 60개, 실제 API 경로면 현재가 1점)를 받아 setChartData에 넣습니다. 여기서 let alive = true와 return () => { 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()로 소켓을 정리. 이때 unsubscribe는 연결이 열려 있을 때(readyState === OPEN)만 보낼 수 있지만, close()는 if 바깥에서 항상 호출합니다 — 아직 연결 중(CONNECTING)인 소켓도 닫아야 하기 때문입니다. useEffect란?·커스텀 훅 4종(useInterval)에서 봐온 "effect가 열어둔 건 반드시 cleanup에서 닫는다"는 원칙이 여기서도 그대로 적용됩니다.
  • 키가 없을 때의 폴백 — 폴링 — NEXT_PUBLIC_FINNHUB_API_KEY가 없으면 WebSocket을 열지 않고, setInterval로 2초마다 /api/stock/[symbol]을 다시 불러 같은 pushPrice로 점을 추가합니다. cleanup은 clearInterval(id)입니다.

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.js는 isUSMarketOpen()(뉴욕 시간대 기준 평일 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 [loadedSymbol, setLoadedSymbol] = useState(null)
const isLoading = loadedSymbol !== selectedSymbol
const lastPriceRef = useRef(0)

// selectedSymbol이 바뀔 때마다 REST로 초기 데이터를 다시 로드
// (장 마감·서버 키 없음 → 더미 60개 / 장 개장 + 키 있음 → 현재가 1점)
useEffect(() => {
  let alive = true // 이 effect가 아직 "최신"인지 표시

  async function loadChart() {
    try {
      const response = await fetch(`/api/stock/${selectedSymbol}/chart`)
      if (!response.ok) throw new Error('차트 데이터를 불러오지 못했습니다.')
      const { data = [] } = await response.json()
      if (!alive) return // 그 사이 종목이 또 바뀌었다면(=최신이 아니면) 이 응답은 버림
      setChartData(data)
      lastPriceRef.current = data.at(-1)?.price ?? 0
    } catch {
      if (!alive) return
      setChartData([]) // 실패하면 현재 종목의 빈 차트로 확정
      lastPriceRef.current = 0
    } finally {
      if (alive) setLoadedSymbol(selectedSymbol) // 로딩 완료 기록
    }
  }

  loadChart()
  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)
    }
    ws.onerror = (err) => console.log('websocket 오류:', err)

    return () => {
      // 구독 해제 메시지는 연결이 열려 있을 때만 보낼 수 있다
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({ type: 'unsubscribe', symbol: selectedSymbol }))
      }
      // close()는 if 바깥 — 아직 연결 중(CONNECTING)인 소켓도 반드시 정리
      ws.close()
    }
  } else {
    // 키가 없으면 폴링 폴백: 2초마다 현재가 REST API를 다시 호출
    const id = setInterval(async () => {
      try {
        const res = await fetch(`/api/stock/${selectedSymbol}`, { cache: 'no-store' })
        const q = await res.json()
        if (typeof q.price === 'number' && q.price !== 0) pushPrice(q.price)
      } catch (err) {
        console.warn('[StockChart] 폴링 실패:', err.message)
      }
    }, 2000)

    return () => clearInterval(id) // 타이머 정리
  }
}, [selectedSymbol, isLoading, setPrice])
핵심 정리
  • 초기 데이터는 REST(fetch)로 한 번에 받고, 실시간 갱신은 WebSocket으로 계속 열린 연결을 통해 받는다 — 목적이 다른 두 통신 방식을 한 컴포넌트 안에서 함께 쓴다.
  • alive 플래그로 stale 응답을 거르고, WebSocket cleanup에서 unsubscribe+close로 연결을 반드시 정리한다 — 둘 다 "이 effect가 만든 건 이 effect가 책임지고 치운다"는 같은 원칙.
  • state 업데이터 콜백(setChartData(prev => ...))은 순수하게 값 계산만 하고, 다른 상태 변경(setPrice)은 그 바깥에서 한 번만 호출한다.
  • API 키가 없거나 미국 장이 닫혀 있으면 서버가 자동으로 더미 데이터를 반환해, 실습 시간과 무관하게 항상 정상적으로 동작한다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

REST와 WebSocket을 함께 쓰는 이유는?
REST는 초기 데이터를 한 번에 받기 좋고, WebSocket은 이후 변화를 실시간으로 받기 좋기 때문입니다. 소켓만으로 과거 데이터를 다 받으려면 비효율적이고, REST만으로 실시간을 하려면 계속 폴링해야 하죠.
useEffect에서 WebSocket을 열 때 반드시 해야 할 일은?
정리 함수에서 연결을 닫는 것입니다. 안 닫으면 컴포넌트가 사라져도 연결이 남아 메모리 누수와 중복 수신이 생기죠. return () => socket.close()가 필수입니다.

💼 실무·코딩테스트에서는실시간 기능(주식 시세, 채팅, 알림)은 실무에서 계속 요구됩니다. 연결 끊김 재시도, 백오프, 하트비트까지 다루게 되면 라이브러리(Socket.IO 등)를 쓰죠. 정리(cleanup)를 빠뜨리는 것이 가장 흔한 버그입니다.

TIP이 레슨은 React & Next.js 커리큘럼의 마지막 단원입니다 🎉 — 01 React 시작하기 카드의 "선언형 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 state·이벤트의 useEffect, 05 커스텀 훅, 10 API Routes, 11 Zustand persist, 14 WebSocket이 한 앱 안에서 실제로 맞물려 돌아갑니다. 그리고 도색(스타일링)은 정비사(AI)에게 맡기되 "엔진(로직)은 절대 건드리지 마세요"라는 계약서를 먼저 쓰고 진행했습니다.

무엇을 직접 만들었나 — 기능 8개의 핵심 로직

종목 검색(useDebounce로 입력 지연 → /api/search 호출), 시세 카드(useStockData 커스텀 훅), 관심종목(Zustand + persist), 실시간 전광판·차트(useLiveTicker의 WebSocket 다중 구독), 기업/시장 뉴스·실적·추천(각각 별도 API Route) — 이 핵심 로직들은 전부 빈칸 채우기와 힌트 방식으로 직접 작성했습니다. 과정에서 만난 한계·오류 6건도 직접 잡아 CODE_REVIEW.md에 Before/After로 기록해뒀습니다 — 각각 무엇이었는지 한 줄씩:

  1. 검색 API의 응답 처리 — fetch()가 준 Response 객체에 바로 .filter()를 걸고, 선언 안 한 변수(symbol·apiKey)를 썼다. await res.json() 후 data.result에서 배열을 꺼내도록 고침(아래 "가장 애먹은 지점").
  2. 렌더링 타이밍 레이스(StockQuoteCard) — symbol prop은 이미 바뀌었는데 effect가 아직 안 돌아 data가 null인 한 프레임이 생겨 data.symbol에서 크래시. if (!data) return null 가드 추가. ("레이스 컨디션"은 두 작업의 실행 순서·타이밍에 따라 결과가 달라지는 문제)
  3. 해외 종목 응답 검증 누락 — 무료 플랜이 지원하지 않는 종목(D.BK)은 현재가 c가 없는 응답이 오는데 그대로 "성공"으로 넘겨 toFixed에서 에러. 서버에서 typeof data.c !== 'number'면 404를 돌려주게 고침.
  4. 컴포넌트 안에 정의한 컴포넌트(StockChart의 CustomTooltip) — 실시간 갱신으로 부모가 다시 그려질 때마다 툴팁 컴포넌트가 새로 만들어짐. 파일 최상단(컴포넌트 밖)으로 옮김.
  5. Hooks 규칙 위반(StockQuoteCard) — 조건부 return들 뒤에서 useWatchlistStore()를 호출해, 렌더마다 훅 호출 개수가 달라질 수 있었음. 모든 훅을 조건부 return 위로 올림(Hooks 규칙).
  6. recommendation 라우트 오사용 — 저장 실수로 recommendation/route.js가 earnings/route.js와 같은 코드로 덮어써져, 투자의견 카드가 실적 배열을 받아 에러 없이 빈 화면만 그림. 원래 로직으로 복구.

가장 애먹은 지점 — 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번 카드(레슨 13)는 종목 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건은 CODE_REVIEW.md에 Before/After로 문서화했고, 제출 이후 드러난 7번째(flash 타이머 경합)는 이 카드에 기록했다 — 버그는 고치는 것보다 "왜 그랬는지 기록"이 다음에 더 큰 자산이 된다.
  • 제출 이후에도 코인(24시간 거래) 대응을 붙이며 테스트 환경이 달라져야만 드러나는 버그(빠른 체결 → flash 타이머 경합)를 만났다 — 느린 데이터로만 테스트하면 절대 안 보이는 종류의 문제다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

이 프로젝트에서 가장 어려웠던 부분을 스스로 정리해보세요.
정답이 있는 질문은 아닙니다. 다만 면접에서 반드시 물어보는 질문이라 미리 답을 준비해 두면 좋습니다 — 어떤 문제가 있었고, 어떤 선택지를 검토했고, 왜 그 방법을 골랐고, 결과가 어땠는지 순서로 정리하면 설득력이 생깁니다.
전역 상태·서버 데이터·실시간 데이터를 각각 어떻게 다뤘는지 말해보세요.
전역 상태는 Zustand(+persist)로 포트폴리오를, 서버 데이터는 Next.js API Routes를 통한 fetch로, 실시간은 WebSocket으로 시세를 받았습니다. "각 데이터의 성격에 맞는 도구를 골랐다"는 설명이 핵심입니다.

💼 실무·코딩테스트에서는포트폴리오 프로젝트는 "무엇을 만들었나"보다 "왜 그렇게 만들었나"를 묻습니다. 기술 선택의 근거, 마주친 문제와 해결 과정, 지금이라면 다르게 할 부분까지 말할 수 있으면 좋은 인상을 줍니다.

TIP이 과제의 판단 근거는 전부 제출 문서 3종에 있습니다 — README(기여도·핵심 코드 명세), CODE_REVIEW(버그 6건 Before/After), PROMPT_LOG(AI 협업 사례 19건). 소스 파일에는 학습용 주석(파일 헤더·훅 요소별 해설)이 정리돼 있어, 시간이 지난 뒤 다시 읽어도 코드 흐름을 따라갈 수 있습니다.
정리 작업 메모
  • 보관 정책 — 저장소에 올라가 있는 것은 학습용 주석을 정리한 주석본(소스 23개 파일)이며, 제출용으로 주석을 걷어낸 버전은 따로 보관하지 않는다(필요하면 다시 만들 수 있으니 오래 남는 쪽은 읽기 좋은 주석본으로 통일).
  • 버그 목록 — 이 카드의 6건은 CODE_REVIEW.md의 사례 1~6에 맞췄다. 프로젝트 README의 요약 문장은 예전에 CODE_REVIEW와 사례 구성이 달랐는데(전광판·차트 사례, Hooks 규칙 위반 두 건), 2026-10-04에 README를 CODE_REVIEW의 사례 1~6에 맞춰 고쳤다. 7번째(flash 타이머 경합)는 제출 이후라 CODE_REVIEW에는 없고 useLiveTicker.js 코드에만 있다.

🧭 학습 여정 — 과목별 복습

과목 탭을 하나씩 넘기며 카드를 볼 땐 이해가 돼도 "그래서 전체 흐름이 뭐지?"는 잘 안 잡힐 수 있습니다. 아래에서 과목을 고르면 그 과목의 레슨을 커리큘럼 순서 그대로, "이전에 뭘 배웠고 이번에 뭐가 새로 추가되는지"만 따라가며 복습할 수 있습니다.

💡 이 탭을 보는 법

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(병합+재사용) 순서로 감각이 하나씩 쌓였습니다. 헷갈리는 카드가 있으면 이 순서를 되짚어 어느 단계 감각이 빠졌는지 확인해보세요.

기초 개념 사전

수업 진도와 상관없이, 자바스크립트·리액트의 핵심 용어와 자주 만나는 버그를 하나씩 독립적으로 찾아볼 수 있는 개인 학습용 사전입니다. 처음이라면 아래 읽는 순서부터 따라가 보세요. 맨 아래 💬 그룹 F에는 수업 중 실제로 막혀서 했던 질문과 답을 그대로 정리해뒀습니다.

🧭 처음이라면 이 순서대로 읽어보세요

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는 이름표가 그 상자에 고정되어 다른 상자로 못 옮깁니다 — 단, 상자 속 물건(배열의 요소, 객체의 속성)은 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 = [](새 배열로 재할당)는 에러입니다. 이 구분은 뒤에 나올 "불변성" 개념과 바로 연결됩니다.

스스로 확인에 나오는 용어 세 가지

  • 호이스팅(hoisting) — 변수·함수 선언이 그 스코프의 맨 위로 "끌어올려진 것처럼" 동작하는 현상. var는 선언만 올라가고 값은 undefined로 시작해, 선언 줄보다 위에서 읽어도 에러 없이 undefined가 나옵니다.
  • TDZ(Temporal Dead Zone, 일시적 사각지대) — let·const도 선언은 올라가지만, 선언 줄에 도달하기 전까지는 접근하면 ReferenceError가 나는 구간.
  • 클로저(closure) — 함수가 자신이 만들어질 때 주변에 있던 변수를 기억해 두었다가, 나중에 다른 곳에서 실행돼도 그 변수를 계속 쓰는 것. (setTimeout에 넘긴 화살표 함수가 바깥 i를 기억하는 것이 예)
핵심 정리
  • let/const는 블록{} 스코프, var는 함수 스코프 — var는 블록 밖으로 값이 새어나갈 수 있어 지금은 쓰지 않는다.
  • const는 재할당만 금지 — 배열/객체 내부 값(속성, 요소)은 여전히 바꿀 수 있다.
  • 기본은 const, 값을 재할당해야 할 때만 let을 쓴다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

var의 호이스팅과 let의 TDZ가 어떻게 다른가?
var는 선언 전에 접근하면 undefined가 나와 버그가 조용히 지나가고, let·const는 ReferenceError를 냅니다. 이 구간을 TDZ(일시적 사각지대)라 하고, 에러를 내는 쪽이 더 안전합니다.
반복문에서 var를 쓰면 생기는 고전적인 문제는?
클로저가 마지막 값 하나만 기억하는 문제입니다. for (var i=0; i<3; i++) setTimeout(()=>console.log(i))는 3을 세 번 출력하죠. let은 반복마다 새 바인딩을 만들어 0,1,2가 제대로 나옵니다.

💼 실무·코딩테스트에서는이 var/let 반복문 문제는 면접 최빈출입니다. 실무에서는 var를 아예 안 쓰므로 만날 일이 없지만, 클로저와 스코프를 이해했는지 확인하는 좋은 문제라 계속 출제됩니다.

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)}처럼 "클릭하면 실행해줘"라는 뜻으로 화살표 함수를 이벤트 핸들러 자리에 감싸서 전달하는 패턴이 리액트에서 가장 자주 등장합니다 — 자세한 이유는 아래 무한 루프 항목 참고.

"짧은 표기"라는 점 말고 진짜 차이가 하나 있습니다 — 화살표 함수에는 자기 this가 없습니다. 일반 함수는 호출 방식에 따라 this가 정해지지만, 화살표 함수는 자신이 만들어진 바깥 코드의 this를 그대로 씁니다. 같은 이유로 자기 arguments도 없고, new로 생성자처럼 호출할 수도 없습니다. 함수 컴포넌트와 Hooks를 쓰는 지금의 React 코드에서는 this를 쓸 일이 거의 없어 이 차이가 드러나지 않지만, 객체의 메서드나 예전 클래스 컴포넌트 코드에서는 중요합니다.

핵심 정리
  • 화살표 함수는 익명 함수를 짧게 쓰는 문법 — 본문 1줄이면 return 생략(암묵적 반환).
  • onClick 같은 이벤트 핸들러 자리에는 "실행 결과"가 아니라 "함수 자체"가 와야 한다.
  • 최신 리액트 코드는 거의 항상 화살표 함수를 사용한다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

화살표 함수를 쓰면 안 되는 경우를 하나 말해보세요.
객체의 메서드입니다. 화살표 함수는 this가 없어 바깥(보통 전역)을 가리키므로 그 객체를 참조할 수 없죠. 생성자 함수로도 쓸 수 없고(new 불가), arguments도 없습니다.
콜백에 화살표 함수를 쓰는 것이 편한 이유는?
this가 바깥 것을 그대로 유지하기 때문입니다. 예전에는 const self = this나 .bind(this)로 해결하던 문제가 저절로 사라지죠. React 클래스 컴포넌트 시절 가장 반가웠던 변화입니다.

💼 실무·코딩테스트에서는현대 JS는 화살표 함수가 기본이고, 일반 함수는 메서드나 this가 필요할 때만 씁니다. 면접에서 this 바인딩 규칙은 여전히 단골 주제입니다.

03

배열 메서드 3형제 — map · filter · reduce

mapfilterreduceforEach

한 줄 요약map은 각 요소를 변형해 같은 길이의 새 배열을 반환하고, filter는 조건을 통과한 요소만 걸러 새 배열을, reduce는 배열 전체를 하나의 값으로 누적 계산한다 — 셋 다 원본 배열은 건드리지 않는다.

쉽게 말하면map은 "각 재료를 손질해서 새 접시에 담기", filter는 "체로 걸러서 통과한 것만 담기", reduce는 "재료를 전부 냄비에 넣고 졸여서 국물 하나로 만들기"예요. forEach는 그냥 "하나씩 확인만 하고 끝(반환값 없음)"입니다.

가장 헷갈리는 reduce, 네 조각을 하나씩

arr.reduce((acc, item) => acc + item, 0)는 map/filter보다 조각이 많아 특히 헷갈립니다.

  • 콜백 (acc, item) => acc + item — reduce의 첫 번째 인자. 배열 요소마다 한 번씩 실행되는 함수입니다.
  • 초기값 0 — reduce의 두 번째 인자. 계산을 시작할 값입니다.
  • acc(누적값) — 지금까지 계산해 온 결과. 첫 실행 때는 초기값(0)에서 시작해, 실행할 때마다 콜백이 반환한 값으로 갱신됩니다.
  • item(현재 요소) — 배열을 순서대로 훑으며 지금 보고 있는 그 요소. (콜백은 세 번째·네 번째로 index와 배열 전체도 받을 수 있지만, 필요 없으면 생략합니다.)

비유하면 reduce는 "롤링페이퍼 돌리기"예요 — 종이(acc)가 사람들(item) 손을 하나씩 거칠 때마다 한 줄씩 추가되어 점점 두꺼워지고, 마지막 사람을 거치면 완성된 종이 한 장(최종 결과)만 남습니다.

변환 · 선택 · 누적

map(item => ...)은 변환(길이 유지), filter(item => 조건)은 선택(길이가 줄어들 수 있음), reduce((누적, item) => ..., 초기값)은 배열 전체를 합계·개수 같은 값 하나로 누적합니다. 셋 다 콜백이 매번 새 배열/값을 "반환"할 뿐 원본 배열은 손대지 않기 때문에, 나중에 배우는 state 불변성 업데이트(불변성이란?)의 핵심 도구가 됩니다.

forEach는 그냥 순회용이라 반환값이 없어(undefined) 체이닝이 안 되고, 화면을 그리는 리스트 렌더링에는 항상 map을 씁니다.

핵심 정리
  • map: 변환(길이 유지) / filter: 선택(길이 줄어듦) / reduce: 누적(하나의 값).
  • 셋 다 원본 배열을 바꾸지 않고 새 배열/값을 반환한다 — todos.filter(...), todos.map(...) 참고.
  • forEach는 그냥 순회용, 반환값이 없어 리스트 렌더링에는 map을 쓴다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

map·filter·reduce 중 나머지 둘을 만들 수 있는 것은?
reduce입니다. 누적값에 조건부로 넣으면 filter가 되고, 변환해 넣으면 map이 되죠. 그만큼 가장 일반적이고 가장 어렵습니다. 실무에서는 의도가 분명한 map·filter를 우선하고 reduce는 꼭 필요할 때만 씁니다.
이 세 메서드가 원본을 바꾸지 않는다는 점이 왜 중요한가?
React의 불변성 규칙과 맞아떨어지기 때문입니다. 새 배열을 돌려주므로 그대로 state에 넣으면 리렌더링이 일어나죠. 반대로 push·sort는 원본을 바꾸므로 state에 그대로 쓰면 안 됩니다.

💼 실무·코딩테스트에서는이 세 메서드는 React 코드의 절반을 차지합니다. 특히 map으로 리스트를 그리는 패턴은 매일 쓰죠. sort는 원본을 바꾸므로 [...arr].sort()로 복사 후 정렬하는 습관이 필요합니다.

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, ...} 모두 구조분해 문법이다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

구조분해에서 기본값은 언제 적용되나?
값이 undefined일 때만입니다. null이나 0·빈 문자열은 기본값이 적용되지 않죠. 이 구분을 모르면 "기본값이 왜 안 먹지?"에서 막힙니다.
props를 구조분해로 받으면 무엇이 좋아지나?
이 컴포넌트가 무엇을 쓰는지 한눈에 보입니다. function Card({ title, desc })를 보면 시그니처만으로 필요한 props를 알 수 있죠. props.title로 매번 쓰는 것보다 짧기도 합니다.

💼 실무·코딩테스트에서는구조분해는 React 코드의 기본 문법입니다 — props, state, 훅 반환값이 전부 이 방식이죠. const [a, setA] = useState()도 배열 구조분해입니다.

관련 실습: 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 문법이다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

스프레드로 만든 복사본이 '얕은 복사'인 이유는?
한 단계만 새로 만들기 때문입니다. {...obj}는 최상위 속성만 복사하고, 안에 있는 객체는 주소를 그대로 가져오죠. 그래서 중첩된 객체를 고치면 원본도 바뀝니다.
중첩 객체의 state를 업데이트할 때 어떻게 해야 하나?
바뀌는 경로를 따라 각 단계를 새로 만들어야 합니다 — {...state, user: {...state.user, name: '새이름'}}처럼요. 깊어지면 번거로워서 Immer 같은 라이브러리를 쓰기도 합니다.

💼 실무·코딩테스트에서는불변성 업데이트는 React 실무의 필수 기술이고, 중첩이 깊어질수록 실수가 잦습니다. 그래서 state 구조를 얕게 설계하는 것이 애초에 좋은 방법이고, 이것이 상태 설계의 중요한 원칙입니다.

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처럼 명확히 쓴다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

JSX에서 && 로 조건부 렌더링할 때 조심할 점은?
왼쪽이 0이면 0이 화면에 그대로 출력됩니다. {items.length && <List/>}에서 배열이 비면 0이 찍히죠. items.length > 0 &&처럼 명시적으로 boolean을 만들어야 안전합니다.
삼항연산자와 &&를 언제 각각 쓰나?
둘 중 하나를 보여줄 때는 삼항, 있으면 보여주고 없으면 아무것도 안 할 때는 &&입니다. 삼항을 중첩하면 읽기 어려워지므로, 세 갈래 이상이면 함수로 빼는 편이 낫습니다.

💼 실무·코딩테스트에서는0이 화면에 찍히는 버그는 React 실무에서 정말 자주 나옵니다. 특히 배열 길이나 개수를 조건으로 쓸 때 발생하죠. 린트 규칙으로 잡는 팀도 많습니다.

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)"를 의미합니다. 예외가 하나 있습니다 — Next.js 레슨에서 보이는 import useStockStore from '@/app/store/useStockStore'의 @/는 라이브러리가 아니라 프로젝트의 src 폴더를 가리키는 별칭입니다(jsconfig.json의 "paths": { "@/*": ["./src/*"] } 설정). ../../를 여러 번 쓰지 않고 어느 파일에서든 같은 경로로 가져올 수 있게 해 줍니다.

핵심 정리
  • export default: 파일당 1개, import 시 이름 자유. export { 이름 }: 여러 개 가능, import 시 정확히 같은 이름을 {}로.
  • './'로 시작하는 경로 = 내 프로젝트 파일, 없으면 = 설치된 라이브러리(node_modules). 단 '@/'는 jsconfig paths로 정한 프로젝트 src 별칭이다.
  • 파일을 컴포넌트 단위로 쪼개고 import/export로 연결하는 것이 React 프로젝트의 기본 구조다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

default export와 named export를 어떻게 구분해 쓰나?
파일당 주된 것 하나는 default, 여러 개를 내보내면 named가 관례입니다. named는 이름이 고정되어 자동완성과 리팩터링에 유리하고, default는 가져올 때 이름을 마음대로 지을 수 있어 오히려 혼란이 생기기도 합니다.
import 순서가 실행 순서에 영향을 주나?
줍니다. 모듈은 import된 순서대로 한 번씩 평가되고, 같은 모듈은 캐시되어 한 번만 실행됩니다. 그래서 사이드 이펙트가 있는 모듈은 import 위치가 동작에 영향을 줄 수 있습니다.

💼 실무·코딩테스트에서는실무에서는 named export를 선호하는 팀이 늘고 있습니다 — 이름이 강제되어 일관성과 검색성이 좋기 때문이죠. 배럴 파일(index.js에서 모아 export)은 편하지만 번들 크기에 영향을 줄 수 있어 주의가 필요합니다.

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" 에러를 크게 줄일 수 있다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

?. 와 && 체이닝의 차이는?
결과가 다릅니다. a && a.b는 a가 0이면 0을 반환하지만, a?.b는 undefined를 반환하죠. 또 ?.가 훨씬 짧고 읽기 쉽습니다.
|| 대신 ?? 를 쓰는 것이 나은 경우는?
0이나 빈 문자열이 유효한 값일 때입니다. count || 10은 count가 0이면 10으로 바꿔 버리지만, count ?? 10은 null·undefined일 때만 10을 씁니다.

💼 실무·코딩테스트에서는??와 ||의 차이는 실무에서 0을 유효값으로 다루는 곳(수량·가격·점수)에서 버그를 만듭니다. 최신 코드베이스에서는 ??를 기본으로 쓰는 추세입니다.

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 />처럼 여러 번 호출해 재사용할 수 있다.
  • 화면은 결국 컴포넌트들이 트리 구조로 조합된 것이다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

컴포넌트를 나누는 기준을 두 가지 말해보세요.
① 재사용되는가 ② 한 가지 책임만 지는가입니다. 파일이 길다고 무조건 쪼개는 게 아니라, 독립적으로 이해되고 테스트될 수 있는 단위인지를 봅니다. 너무 잘게 쪼개면 파일 사이를 오가느라 오히려 복잡해집니다.
컴포넌트 이름이 대문자로 시작해야 하는 이유는?
JSX가 소문자를 HTML 태그로 해석하기 때문입니다. <button>은 진짜 버튼 태그, <Button>은 내가 만든 컴포넌트로 구분되죠. 소문자로 쓰면 <profilecard> 같은 알 수 없는 HTML 태그로 그려져 내 컴포넌트가 실행되지 않고, 개발 모드 콘솔에 "The tag <profilecard> is unrecognized in this browser" 같은 경고가 뜹니다.

💼 실무·코딩테스트에서는컴포넌트 설계는 React 실무 실력의 핵심입니다. 면접에서 "컴포넌트를 어떤 기준으로 나누나요"를 자주 묻고, 정답보다 기준을 갖고 판단하는지를 봅니다.

관련 실습: 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(...) 호출로 변환해주는 문법 설탕이라, 브라우저가 바로 읽을 수 없어 이 변환을 해 줄 빌드 도구가 필요합니다. 확장자는 도구 설정에 따라 다릅니다 — Vite 레슨(01~06)은 .jsx를 쓰지만, Next.js 레슨은 page.js·layout.js처럼 .js 파일 안에 JSX를 그대로 씁니다(Next.js가 .js 안의 JSX도 변환해 줌).

핵심 정리
  • JSX의 {}는 어떤 JS "표현식"도 담을 수 있다(문장은 불가).
  • return은 반드시 하나의 요소로 감싸기 — 여러 개면 Fragment <>...</> 사용.
  • 결국 빌드 시 React.createElement 호출로 변환되는 문법 설탕이다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

JSX가 결국 무엇으로 변환되는지 아나요?
React.createElement() 호출(또는 최신 변환에서는 jsx())입니다. 즉 JSX는 문법 설탕일 뿐 결과는 평범한 JS 객체죠. 이걸 알면 JSX 안에서 왜 JS 표현식만 되고 문(statement)은 안 되는지 설명됩니다.
JSX가 하나의 부모로 감싸져야 하는 이유는?
함수는 값을 하나만 반환할 수 있기 때문입니다. 요소 둘을 나란히 반환하려면 배열이나 감싸는 요소가 필요하죠. 불필요한 div를 만들기 싫으면 Fragment(<>...</>)를 씁니다.

💼 실무·코딩테스트에서는"JSX는 JS다"라는 이해가 있으면 헷갈림이 크게 줄어듭니다 — 중괄호 안에 표현식만 들어가는 것, if 대신 삼항을 쓰는 것, class가 className인 것이 전부 같은 이유에서 나옵니다.

관련 실습: 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}) 구조분해로 받아서 표시만 하고, 직접 수정하지 않는다.
  • 값을 바꾸고 싶으면 부모가 내려준 함수를 호출해 요청한다 — 데이터 소유자는 항상 부모다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

props가 '읽기 전용'이라는 규칙이 왜 있는가?
데이터 흐름을 예측 가능하게 만들기 위해서입니다. 자식이 props를 바꿀 수 있으면 값이 어디서 변했는지 추적이 불가능해지죠. 단방향 흐름 덕분에 버그를 위에서 아래로 따라가며 찾을 수 있습니다.
children prop이 특별한 이유는?
태그 사이에 넣은 내용이 자동으로 전달되기 때문입니다. <Card>내용</Card>의 "내용"이 props.children이 되죠. 이걸로 레이아웃 컴포넌트를 만들면 재사용성이 크게 올라갑니다.

💼 실무·코딩테스트에서는props 설계가 컴포넌트의 API입니다. 너무 많으면 쓰기 어렵고, 너무 적으면 유연하지 않죠. 실무에서는 필수와 선택을 명확히 구분하고 TypeScript로 타입을 명시하는 것이 표준입니다.

관련 실습: 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을 감싸는 동일한 방식이다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

컴포지션이 상속보다 권장되는 이유는?
더 유연하기 때문입니다. 상속은 부모가 정한 틀에 갇히지만, 컴포지션은 필요한 것을 조립할 수 있죠. React 공식 문서도 "상속 대신 컴포지션"을 명시적으로 권장합니다.
children 말고 여러 슬롯을 만들려면 어떻게 하나?
props로 JSX를 직접 넘기면 됩니다 — <Layout header={<Header/>} sidebar={<Nav/>}/>처럼요. JSX도 그냥 값이라 props로 전달할 수 있다는 것이 핵심입니다.

💼 실무·코딩테스트에서는컴포지션 패턴은 디자인 시스템을 만들 때 특히 중요합니다. <Modal>에 header·body·footer 슬롯을 두는 식이죠. props를 계속 늘리는 대신 조립할 수 있게 만드는 것이 좋은 설계입니다.

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번만 실행되어 비싼 초기 계산의 낭비를 막는다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

어떤 값을 state로 두고 어떤 값을 두지 말아야 하나?
화면에 영향을 주고 시간에 따라 바뀌는 값만 state입니다. 다른 state로 계산할 수 있는 값은 state로 두지 말고 렌더링 때 계산하세요 — 중복 state는 동기화가 어긋나는 버그를 만듭니다.
useState의 초기값에 함수를 넘기는 것과 값을 넘기는 것의 차이는?
값을 넘기면 매 렌더마다 계산되고, 함수를 넘기면 첫 렌더에서 한 번만 실행됩니다. 초기값 계산이 무거우면 useState(() => expensiveCalc())로 지연 초기화를 씁니다.

💼 실무·코딩테스트에서는"state를 최소한으로"가 React 상태 설계의 제1원칙입니다. 파생 값을 state로 두면 두 값이 어긋나는 순간이 반드시 옵니다. 면접에서도 상태 설계 감각을 자주 물어봅니다.

14

리렌더링이란?

리렌더링Virtual DOMdiffing

한 줄 요약리렌더링은 state나 props가 바뀔 때 컴포넌트 함수가 처음부터 다시 실행되어 새로운 Virtual DOM을 만드는 과정으로, React는 이전 Virtual DOM과 비교(diffing)해서 실제로 바뀐 부분만 진짜 DOM에 반영한다.

쉽게 말하면리렌더링은 "설계도를 다시 그리는 것"이에요. state가 바뀌면 함수가 다시 실행되어 새 설계도를 그리지만, 실제 집(진짜 DOM)을 통째로 다시 짓지는 않고 달라진 벽지만 바꿔 답니다.

리렌더링 한 번은 사실 4단계로 이뤄진다

"다시 그린다"는 말이 뭉뚱그려져 있는데, 실제로는 순서가 있는 4단계입니다.

  • ① 트리거 — state 변경, props 변경, 부모의 리렌더링, 또는 구독 중인 Context·외부 저장소(Zustand) 값 변경 중 하나가 일어남.
  • ② 함수 재실행 — 그 컴포넌트 함수가 처음부터 다시 통째로 실행됨(설계도를 새로 그리는 단계).
  • ③ 새 Virtual DOM 생성 — 그 함수가 반환한 새 JSX가 메모리 안의 가상 트리로 만들어짐(아직 화면엔 안 그려짐).
  • ④ diffing 후 실제 DOM 반영 — React가 이전 가상 트리와 이번 것을 비교해, 정말 달라진 부분만 진짜 화면에 반영.

비유하면 이사 갈 때 "새 집 설계도부터 다시 그리고(②③), 기존 집과 비교해서 정말 바뀐 벽만 허물고 새로 세우는 것(④)"과 같아요 — 집 전체를 철거하고 새로 짓는 게 아니라서 빠릅니다.

리렌더링을 일으키는 것 — 기본 셋 + 구독 둘

리렌더링을 유발하는 것은 기본적으로 (1) state 변경, (2) props 변경, (3) 부모의 리렌더링(자식도 함께 재실행)입니다. 여기에 "구독"으로 생기는 경우가 둘 더 있습니다 — (4) useContext로 읽고 있는 Context 값이 바뀔 때(Context API), (5) Zustand 같은 외부 저장소에서 셀렉터로 꺼낸 값이 바뀔 때(Zustand). 이 둘은 부모가 props를 안 넘겨줘도 그 값을 구독한 컴포넌트만 다시 그립니다. 반대로 화면 밖 일반 변수나 useRef의 .current를 바꾸는 것만으로는 리렌더링이 일어나지 않습니다. 리렌더링은 "함수 재실행"이지 "DOM 재생성"이 아니므로, 함수가 다시 실행되어 새 JSX(Virtual DOM 트리)를 반환하면 React가 이전 트리와 비교(diffing)해 실제로 달라진 노드만 진짜 DOM에 반영합니다.

핵심 정리
  • 리렌더링을 일으키는 것은 state/props 변경, 부모의 리렌더링, 그리고 구독 중인 Context 값·외부 저장소(Zustand) 값의 변경이다.
  • 리렌더링 = 컴포넌트 함수 재실행 → 새 Virtual DOM → 이전 것과 diffing → 바뀐 부분만 실제 DOM 반영.
  • 일반 변수를 바꾸는 것만으로는 화면이 갱신되지 않는다 — 반드시 set함수를 통해야 한다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

리렌더링이 일어나는 경우를 말해보세요(기본 셋 + 구독 둘).
① 자신의 state가 바뀔 때 ② props가 바뀔 때 ③ 부모가 리렌더링될 때 — 여기에 ④ useContext로 읽는 Context 값이 바뀔 때 ⑤ Zustand 같은 외부 저장소에서 구독한 값이 바뀔 때입니다. 특히 세 번째 때문에 props가 안 바뀌어도 자식이 다시 그려지고, 이걸 막는 것이 React.memo입니다(단, ④⑤는 memo로도 막히지 않습니다).
리렌더링이 곧 DOM 갱신을 뜻하나?
아닙니다. 리렌더링은 컴포넌트 함수를 다시 실행해 Virtual DOM을 만드는 것이고, 실제 DOM은 달라진 부분만 바뀝니다. 그래서 리렌더링이 몇 번 더 일어난다고 반드시 느려지는 것은 아닙니다.

💼 실무·코딩테스트에서는"리렌더링 = 무조건 나쁨"이 아니라는 것이 중요합니다. 실무에서는 측정 없이 최적화하지 않는 것이 원칙이고, React DevTools Profiler로 실제 병목을 확인한 뒤 대응합니다.

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가 변화를 감지하는 방식이 왜 불변성을 요구하나?
이전 값과 새 값을 Object.is로 비교하기 때문입니다. 원본을 직접 고치면 주소가 같아 "안 바뀌었다"고 판단하죠. 모든 속성을 일일이 비교하면 느리므로 참조만 비교하는 대신 불변성을 요구하는 맞바꿈입니다.
배열 state에 항목을 추가하는 올바른 방법은?
setItems([...items, newItem])처럼 새 배열을 만들어야 합니다. items.push(newItem)은 원본을 바꿀 뿐 참조가 그대로라 화면이 갱신되지 않습니다.

💼 실무·코딩테스트에서는불변성은 React 버그의 1순위 원인입니다. "값은 바뀐 것 같은데 화면이 안 바뀐다"면 거의 항상 이 문제죠. 원본을 바꾸는 메서드(push·splice·sort·reverse)를 따로 외워 두면 예방됩니다.

관련 실습: 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로 읽을 수 없다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

key로 배열 인덱스를 쓰면 안 되는 경우는?
목록의 순서가 바뀌거나 중간에 추가·삭제될 때입니다. 인덱스를 key로 쓰면 다른 항목이 같은 key를 갖게 되어 React가 엉뚱한 요소를 재사용하죠. 입력값이 다른 행으로 옮겨 가는 버그가 대표적입니다.
key가 하는 일을 한 문장으로 말해보세요.
이전 목록과 새 목록의 항목을 짝지어 주는 식별자입니다. key가 같으면 같은 항목으로 보고 재사용하고, 다르면 새로 만듭니다. 그래서 고유하고 안정적인 값(보통 id)이어야 합니다.

💼 실무·코딩테스트에서는key 경고를 무시하다 실제 버그를 만나는 경우가 실무에서 흔합니다. 특히 입력 폼이 있는 목록에서 인덱스 key를 쓰면 값이 뒤섞이죠. 서버에서 온 id를 쓰는 것이 가장 안전합니다.

관련 실습: 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로 필요할 때만 값을 꺼내는 비제어 컴포넌트도 있지만, 지금은 제어 컴포넌트를 기본으로 익힌다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

제어 컴포넌트에서 value만 주고 onChange를 빠뜨리면?
입력이 안 됩니다. value가 state에 고정되어 있는데 바꿀 방법이 없기 때문이죠. React가 경고도 띄웁니다. 읽기 전용이 의도라면 readOnly를 명시해야 합니다.
비제어 컴포넌트(useRef)를 쓰는 것이 나은 경우는?
입력값을 실시간으로 볼 필요가 없을 때입니다. 제출할 때만 읽으면 되는 폼이라면 매 글자마다 리렌더링할 이유가 없죠. 또 파일 입력은 제어 컴포넌트로 만들 수 없습니다.

💼 실무·코딩테스트에서는폼 관리는 React 실무의 큰 주제입니다. 필드가 많아지면 리렌더링이 부담되어 React Hook Form 같은 라이브러리를 쓰는데, 이것이 비제어 방식을 활용해 성능을 얻는 대표적 예입니다.

18

useEffect란?

useEffect의존성 배열부수효과

한 줄 요약useEffect(함수, [의존성])는 "화면을 그린 뒤에" 실행되는 부수 효과(side effect) 훅으로, 의존성 배열에 아무것도 안 넣으면 렌더링마다, 빈 배열 []을 넣으면 최초 1번만, 값을 넣으면 첫 마운트에 1번 + 이후 그 값이 바뀔 때마다 실행된다. effect가 return한 정리(cleanup) 함수는 다음 effect 직전과 화면에서 사라질 때 실행된다.

쉽게 말하면useEffect는 "그림을 다 그린 다음에 처리할 뒷일"이에요. 화면을 먼저 그리고, 그 다음에 localStorage 저장이나 서버 요청처럼 화면 그리기와 상관없는 부수적인 일을 처리합니다. 의존성 배열은 "언제 이 뒷일을 다시 할지" 정하는 조건표입니다.

useEffect(fn, [dep]), 괄호 안 두 자리의 역할

같은 useEffect인데 두 번째 자리(의존성 배열)에 뭘 넣느냐에 따라 완전히 다르게 동작합니다.

  • fn (첫 번째 자리) — 화면을 그린 뒤에 실행할 "뒷일" 그 자체(localStorage 저장, 서버 요청 등).
  • [] (빈 배열) — "감시할 값이 없다" → 최초 1번만 실행.
  • [dep] (값이 있는 배열) — "이 값이 바뀌는지 감시하겠다" → 첫 마운트에 1번 실행하고, 이후에는 dep가 바뀐 렌더링마다 다시 실행.
  • 배열 자체를 생략 — "매번 감시" → 렌더링될 때마다 실행(거의 안 씀).

비유하면 의존성 배열은 "이 알림을 언제 다시 울릴지 정하는 조건표"예요 — 빈 배열은 "입학식 때 딱 한 번만 울리는 알람", [todos]는 "입학식 때 한 번 울리고, 그 뒤로 todos에 변화가 생길 때마다 우는 알람"인 셈입니다.

세 가지 패턴과 stale 값 주의

useEffect(fn) — 의존성 배열 자체가 없으면 렌더링마다 매번 실행(거의 안 씀). useEffect(fn, []) — 빈 배열이면 최초 마운트 시 딱 1번만 실행. useEffect(fn, [dep]) — 첫 마운트에 1번 + 이후 배열 안 값이 바뀔 때마다 실행(todos가 바뀔 때마다 저장 등). effect 안에서 실제로 사용하는 값은 빠짐없이 의존성 배열에 넣어야 하며, 빠뜨리면 "오래된(stale) 값"을 계속 참조하는 버그로 이어집니다 — 자세한 내용은 stale closure 항목 참고.

정리(cleanup) 함수 — effect가 연 것은 effect가 닫는다

effect 함수가 return () => { ... }로 함수를 돌려주면, React는 그것을 정리 함수로 기억해 두었다가 ① 다음 effect가 다시 실행되기 직전(의존성 값이 바뀌었을 때)과 ② 컴포넌트가 화면에서 사라질 때(언마운트) 자동으로 실행합니다. 타이머·이벤트 구독·WebSocket처럼 "켜 두면 계속 돌아가는 것"을 끄는 자리입니다.

JSX 정리 함수 예시
useEffect(() => {
  const id = setInterval(() => console.log('tick'), 1000) // 켜기
  return () => clearInterval(id) // 정리: 다음 effect 직전·언마운트 때 끄기
}, [])

정리 함수가 없으면 컴포넌트가 사라져도 타이머가 계속 돌거나, 의존성이 바뀔 때마다 타이머가 하나씩 더 쌓여 로그가 두 배, 세 배로 찍히는 버그가 생깁니다.

핵심 정리
  • useEffect(fn, []): 최초 1번만. useEffect(fn, [dep]): 첫 마운트에 1번 + 이후 dep가 바뀔 때마다. useEffect(fn): 매 렌더링마다(거의 안 씀).
  • effect가 return한 정리 함수는 다음 effect 직전과 언마운트 때 실행된다 — 타이머·구독·소켓은 여기서 끈다.
  • "화면을 그린 뒤" 실행되는 부수효과 훅 — localStorage 저장, 서버 요청 등을 여기서 처리한다.
  • effect 안에서 쓰는 값은 반드시 의존성 배열에 넣어야 stale 값 참조 버그를 피할 수 있다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

의존성 배열을 빈 배열로 두면 언제 실행되나?
마운트될 때 딱 한 번입니다(StrictMode 개발 모드에서는 두 번). 배열을 아예 생략하면 매 렌더마다 실행되고, 값을 넣으면 그 값이 바뀔 때마다 실행됩니다.
useEffect의 정리 함수는 언제 실행되나?
다음 effect가 실행되기 직전과 컴포넌트가 사라질 때입니다. 그래서 구독·타이머·소켓을 정리하는 자리죠. 빠뜨리면 메모리 누수와 중복 실행이 생깁니다.

💼 실무·코딩테스트에서는useEffect 남용은 React의 흔한 안티패턴입니다. 공식 문서에도 "아마 effect가 필요 없을 것"이라는 문서가 따로 있죠 — 렌더링 중 계산할 수 있는 것이나 이벤트 핸들러에서 하면 될 일을 effect로 옮기지 않는 것이 중요합니다.

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이 반환한 값을 사용하는 코드 안에서 처리한다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

훅을 조건문 안에서 부르면 왜 안 되나?
React가 훅을 호출 순서로 구분하기 때문입니다. 이름표가 아니라 "몇 번째로 불린 훅"으로 값을 기억하죠. 조건에 따라 호출 개수가 달라지면 순서가 밀려 다른 훅의 값을 가져오게 됩니다.
조건부로 로직을 실행하고 싶으면 어떻게 하나?
훅은 항상 부르고, 안에서 조건을 검사합니다 — useEffect(() => { if (조건) ... })처럼요. 또는 컴포넌트를 분리해서 조건에 따라 다른 컴포넌트를 렌더링합니다.

💼 실무·코딩테스트에서는훅 규칙은 ESLint 플러그인이 자동으로 검사해 주므로 실무에서 어길 일은 적습니다. 다만 왜 그런 규칙인지를 알면 커스텀 훅을 만들 때 실수하지 않고, 면접에서도 좋은 답이 됩니다.

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 등 명령형 조작).
  • 렌더링과 무관하게 "그냥 기억만 하면 되는 값"을 보관하는 용도로도 쓴다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

useRef의 값을 바꿔도 화면이 안 바뀌는 것이 왜 장점인가?
리렌더링을 유발하지 않고 값을 기억할 수 있기 때문입니다. 타이머 id, 이전 값, 스크롤 위치처럼 화면과 무관하지만 유지되어야 하는 값에 딱 맞죠. state로 두면 불필요한 리렌더링이 계속 일어납니다.
ref로 DOM을 직접 조작하는 것이 허용되는 경우는?
React로 표현할 수 없는 것입니다 — 포커스 이동, 스크롤, 미디어 재생, 캔버스 같은 것들이죠. 반면 내용이나 스타일 변경은 state로 해야 합니다.

💼 실무·코딩테스트에서는"React 방식으로 할 수 없을 때만 ref"가 원칙입니다. 실무에서는 포커스 관리(모달 열릴 때 첫 입력칸으로)가 가장 흔한 용도이고, 접근성과도 직결됩니다.

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가 안 바뀌면 항상 같은 함수 객체를 돌려준다.
  • 쓰는 곳은 세 가지 — ① 무거운 계산 결과 재사용(useMemo), ② React.memo로 감싼 자식에게 넘기는 객체/함수의 참조 고정, ③ useEffect 의존성 배열에 넣을 함수의 참조 고정(useCallback) — 예: react-05의 useStockData는 load를 useCallback(fn, [symbol])으로 감싸야 useEffect(..., [load])가 무한 재실행되지 않는다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

useMemo를 언제 쓰면 오히려 손해인가?
계산이 가벼울 때입니다. 비교하고 저장하는 비용이 계산 비용보다 클 수 있으니까요. 또 메모리도 차지합니다. 그래서 측정해서 느린 것이 확인된 곳에만 쓰는 것이 맞습니다.
useCallback이 필요한 대표적인 상황은?
자식이 React.memo로 감싸져 있을 때입니다. 함수를 props로 내려 주면 매 렌더마다 새 참조가 되어 memo가 무력화되죠. useCallback으로 참조를 고정해야 효과가 납니다. 또 useEffect 의존성에 함수가 들어갈 때도 필요합니다.

💼 실무·코딩테스트에서는성능 최적화는 마지막에 하는 것이 원칙입니다. React 공식 문서도 "대부분의 경우 필요 없다"고 말하죠. 다만 React Compiler가 도입되면 이런 수동 최적화가 줄어들 전망입니다.

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는 "달라졌다"고 오판해 리렌더링을 막지 못합니다.

함수·객체 props를 넘길 때 useCallback과 짝을 이뤄야 하는 이유

StockRow를 memo로 감싸도, 부모 StockList가 toggleFavorite를 useCallback 없이 매번 새로 만들어 넘기면 모든 StockRow가 매번 "함수가 바뀌었다"고 인식해 리렌더링됩니다. useCallback(fn, [])으로 함수 참조를 고정해야 비로소 memo가 "이 함수도 지난번과 똑같다"고 판단해 실제로 리렌더링을 스킵합니다. 정리하면 함수·객체·배열을 props로 넘길 때에 한해 memo(자식 방어막) + useCallback/useMemo(부모가 내려주는 값 고정)가 한 세트입니다. props가 숫자·문자열 같은 원시값뿐이라면 memo 하나만으로도 리렌더링을 건너뜁니다.

핵심 정리
  • React.memo(컴포넌트)는 이전 props와 새 props를 얕은 비교해서 완전히 같으면 리렌더링을 건너뛴다.
  • 원시값(숫자·문자열·불리언)은 값이 같으면 통과하지만, 객체·배열·함수는 참조가 달라지면 매번 "바뀐 것"으로 취급된다.
  • 그래서 memo로 감싼 자식에게 객체/함수를 props로 넘길 때는 useMemo·useCallback으로 참조를 고정해 줘야 효과가 있다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

React.memo의 '얕은 비교'가 무엇을 뜻하는지 설명해보세요.
props의 각 값을 Object.is로 한 단계만 비교한다는 뜻입니다. 객체나 배열은 내용이 같아도 참조가 다르면 "바뀌었다"고 판단하죠. 그래서 인라인 객체나 함수를 props로 넘기면 memo가 소용없습니다.
memo를 모든 컴포넌트에 붙이면 어떻게 될까?
비교 비용이 계속 추가되어 오히려 느려질 수 있습니다. 특히 props가 자주 바뀌는 컴포넌트는 비교만 하고 결국 다시 그리므로 순수한 낭비죠. 리스트의 항목처럼 자주 반복되고 props가 잘 안 바뀌는 곳이 적합합니다.

💼 실무·코딩테스트에서는React.memo + useCallback + useMemo는 함께 묶어 이해해야 합니다. memo로 감싼 자식에게 함수·객체 props를 넘기는데 참조를 고정하지 않으면 memo가 효과를 못 내죠(원시값 props만이라면 memo 단독으로도 충분합니다). 실무에서는 Profiler로 실제 렌더링 횟수를 확인한 뒤 적용합니다.

23

React StrictMode란?

StrictMode개발 모드 전용이중 렌더링

한 줄 요약StrictMode는 개발 모드에서만 컴포넌트 함수를 한 번 더 실행(이중 렌더)하고, 컴포넌트를 의도적으로 한 번 더 마운트(마운트→언마운트→재마운트)시켜, 렌더 로직이 순수한지·부수효과가 안전하게 정리되는지 미리 검증해주는 개발용 안전장치로, 배포 빌드에는 아무 영향이 없다.

쉽게 말하면StrictMode는 "리허설을 한 번 더 시켜보는 깐깐한 감독"이에요. 실제 공연(배포)에서는 한 번만 하면 되지만, 연습(개발 모드) 중에는 일부러 두 번 시켜봐서 숨은 문제를 미리 찾아냅니다.

마운트→언마운트→재마운트, 세 동작을 구분하면

"두 번 실행된다"는 말이 뭉뚱그려져 있어서 무섭게 느껴지는데, 실제로는 정해진 세 동작이 순서대로 일어나는 것뿐입니다.

  • 마운트 — 컴포넌트가 처음 화면에 나타남(useEffect 최초 실행 포함).
  • 언마운트 — StrictMode가 검사를 위해 바로 그 컴포넌트를 화면에서 내림(useEffect의 cleanup 함수 실행).
  • 재마운트 — 곧바로 다시 화면에 올림(useEffect가 또 한 번 실행) — 이 시점에 로그나 effect가 "두 번"처럼 보이는 것.

비유하면 이건 "리허설 감독이 배우를 무대에 세웠다가(마운트), 일부러 잠깐 퇴장시키고(언마운트), 바로 다시 세워보는 것"(재마운트)과 같아요 — 실제 공연(배포 빌드)에서는 이 리허설 과정 자체가 없어서 한 번만 등장합니다. 개발 중에만 일어나는 "미리 보는 예행연습"이라고 생각하면 됩니다.

왜 두 번 실행되는 것처럼 보이는가

개발 모드에서만 동작합니다 — 컴포넌트를 마운트 → 검사를 위해 바로 언마운트 → 즉시 재마운트하며, 그 과정에서 useEffect가 두 번 실행되는 것처럼 보입니다. 여기에 컴포넌트 함수 본문을 두 번 호출하는 "이중 렌더"까지 더해져 콘솔 로그도 두 번씩 찍힙니다(둘 다 버그가 아니라 의도된 동작, 아래 표 참고). 목적은 "이 컴포넌트를 두 번 마운트해도 똑같이 안전하게 동작하는가"를 미리 확인하는 것으로, 부수효과가 이전 상태에 의존하면(저장소 덮어쓰기 버그처럼) 이 단계에서 증상으로 드러납니다. 배포 빌드(npm run build)에서는 이 이중 실행이 전혀 일어나지 않습니다.

"두 번"이라는 말에는 사실 성격이 다른 세 가지가 섞여 있습니다(React 19 기준, 모두 개발 모드에서만).

무엇이 두 번?어떻게 보이나무엇을 잡아내나
① 이중 렌더 — 컴포넌트 함수 본문본문의 console.log가 두 번(두 번째는 흐린 글씨), useRef 카운터가 2씩 증가렌더 중에 외부 값을 바꾸는 등 순수하지 않은 렌더 로직
② 업데이터·초기화 함수setX(prev => ...)의 업데이터, useState(() => ...)의 초기화 함수, useMemo 계산 함수가 두 번 호출이 함수들 안에 부수효과(저장·요청·push 등)를 넣은 실수
③ effect 재마운트마운트 직후 effect 실행 → cleanup → effect 다시 실행cleanup 누락(구독·타이머 중복), 이전 상태에 기대는 effect(저장소 덮어쓰기 버그)
핵심 정리
  • StrictMode는 개발 모드 전용 검사 도구 — 컴포넌트를 일부러 한 번 더 마운트해 부수효과의 안전성을 미리 테스트한다.
  • 개발 중엔 console.log나 useEffect가 두 번씩 실행되는 것처럼 보이는데, 버그가 아니라 의도된 동작이다.
  • 배포 빌드에는 영향이 없다 — "두 번 실행돼도 문제없이 동작하는 코드"를 짜도록 유도하는 장치일 뿐이다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

StrictMode가 개발 모드에서 두 번 렌더링하는 이유는?
순수하지 않은 코드를 드러내기 위해서입니다. 같은 입력에 두 번 실행해도 결과가 같아야 정상인데, 부수 효과가 있으면 결과가 달라져 버그가 드러나죠. 프로덕션에서는 한 번만 실행됩니다.
effect가 두 번 실행되는 것이 알려 주는 문제는?
정리(cleanup)를 제대로 안 했다는 것입니다. 구독이나 타이머를 정리하지 않으면 두 번째 실행에서 중복이 생기죠. 즉 StrictMode는 메모리 누수를 미리 잡아 주는 장치입니다.

💼 실무·코딩테스트에서는"StrictMode 때문에 두 번 호출된다"고 끄는 것은 잘못된 대응입니다. 두 번 실행해서 문제가 생긴다면 실제로 코드에 문제가 있는 것이니까요. 실무에서 중복 API 호출을 발견하는 데 큰 도움이 됩니다.

관련 실습: 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 같은 기존 훅을 조합할 뿐이며, 목적은 반복되는 "상태+부수효과" 로직을 재사용 가능한 단위로 뽑아내는 것이다.
  • 같은 커스텀 훅을 여러 컴포넌트에서 써도 각 컴포넌트는 서로 독립된 상태를 갖는다 — 재사용되는 건 로직이지 상태가 아니다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

커스텀 훅이 컴포넌트 간에 '상태를 공유'하나?
공유하지 않습니다. 로직만 재사용될 뿐 상태는 각 컴포넌트마다 독립적으로 새로 만들어집니다. 상태를 진짜 공유하려면 Context나 전역 상태 라이브러리가 필요합니다. 이 오해가 아주 흔합니다.
커스텀 훅 이름이 use로 시작해야 하는 이유는?
React와 린트 도구가 훅으로 인식하게 하기 위해서입니다. 그래야 훅 규칙(조건문 안에서 호출 금지 등)을 검사해 주죠. 규칙일 뿐 아니라 읽는 사람에게 "이건 훅이다"라고 알리는 신호이기도 합니다.

💼 실무·코딩테스트에서는커스텀 훅은 React에서 로직을 재사용하는 유일하고 표준적인 방법입니다. 실무에서는 API 호출·폼 관리·미디어 쿼리·로컬 저장소를 훅으로 만들어 프로젝트 전체가 공유합니다.

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('...')(함수처럼 호출)만 별도 청크로 분리된다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

lazy로 나눈 컴포넌트는 언제 실제로 다운로드되나?
그 컴포넌트가 처음 렌더링되려는 순간입니다. 그래서 첫 화면에 필요 없는 것을 나누면 초기 로딩이 빨라지죠. 다만 처음 열 때 잠깐 기다리는 시간이 생기므로, Suspense의 fallback으로 로딩 표시를 해 줍니다.
어디를 기준으로 코드를 나누는 것이 효과적인가?
라우트(페이지) 단위가 가장 일반적입니다. 사용자가 실제로 방문한 페이지의 코드만 받게 되니까요. 그 외에 모달·차트처럼 무겁고 가끔 쓰는 것도 좋은 후보입니다.

💼 실무·코딩테스트에서는초기 번들 크기는 사용자 이탈률과 직결됩니다. 실무에서는 번들 분석 도구로 무엇이 큰지 확인한 뒤 코드 분할을 적용하죠. Next.js는 페이지 단위 분할이 기본으로 되어 있습니다.

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 밖 호출 시 명확한 에러를 던질 수 있다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

Context를 언제 쓰고 언제 쓰지 말아야 하나?
테마·언어·로그인 정보처럼 자주 안 바뀌고 여러 곳에서 필요한 값에 씁니다. 자주 바뀌는 값은 구독자 전체가 리렌더링되어 성능 문제가 생기므로, 전용 상태 라이브러리가 낫습니다.
Provider 밖에서 useContext를 부르면 어떻게 되나?
createContext(기본값)의 기본값이 반환됩니다. 에러가 안 나서 버그를 알아채기 어렵죠. 그래서 실무에서는 기본값을 undefined로 두고 커스텀 훅에서 없으면 에러를 던지게 만듭니다.

💼 실무·코딩테스트에서는Context = 전역 상태 관리라는 오해가 흔합니다. Context는 전달 수단일 뿐 상태 관리 기능(선택적 구독·미들웨어)은 없죠. 이 구분을 아는 것이 도구 선택의 출발점입니다.

C · 전역 상태 · 스타일 · 실시간 도구 — 앱을 키울 때 쓰는 것들

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가 아니라 저장소 자체가 주는 도구입니다. 그래서 만들어진 훅에도 같은 도구가 붙어 있어, useStore.getState()·useStore.setState({...})로 React 바깥(일반 JS 모듈, 예: API 유틸 함수)에서도 상태를 읽고 쓸 수 있습니다(이때는 구독이 아니라 그 순간 값을 읽는 것이라, 화면 갱신이 필요한 컴포넌트에서는 평소처럼 useStore(selector)를 씁니다).

셀렉터 — 왜 useStockStore((s) => s.watchlist)처럼 쓰는가

저장소를 통째로 구독(useStockStore())하면 저장소 안 어떤 값이 바뀌어도 리렌더링됩니다. 화살표 함수로 원하는 조각만 콕 집으면(s => s.watchlist), 그 조각이 바뀔 때만 리렌더링되어 불필요한 렌더링을 줄일 수 있습니다.

핵심 정리
  • Zustand는 Provider 없이 create()로 만든 저장소를 어디서든 import해서 쓴다.
  • 셀렉터로 구독한 상태 조각이 바뀔 때만 리렌더링되어, Context보다 리렌더링 범위가 좁다.
  • 액션 함수(set/get 사용)를 저장소 정의 안에 두어 상태와 로직을 한 곳에 모은다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

Zustand가 Provider 없이 동작하는 이유는?
스토어가 React 트리 밖의 모듈 수준에 존재하기 때문입니다. Context는 트리를 타고 값을 내려 주지만, Zustand는 그냥 import해서 쓰는 객체죠. 그래서 컴포넌트 밖에서도 접근할 수 있습니다.
셀렉터로 값을 고르는 것이 왜 성능에 유리한가?
고른 값이 바뀔 때만 리렌더링되기 때문입니다. 스토어에 값이 열 개 있어도 내가 쓰는 하나만 구독하면 나머지가 바뀌어도 영향이 없죠. Context에는 없는 선택적 구독이 핵심 차이입니다.

💼 실무·코딩테스트에서는상태를 어디에 둘 것인가는 실무 설계의 큰 주제입니다. 지역 상태(useState) → 상위로 올리기 → Context → 전역 라이브러리 순으로 필요한 만큼만 올리는 것이 원칙이고, 처음부터 전역에 두면 관리가 어려워집니다.

관련 실습: react-09 — Zustand
28

Zustand persist 미들웨어란? — localStorage 자동 저장/복원

persistlocalStoragepartialize미들웨어

한 줄 요약persist는 create(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는 저장할 상태 조각을 고르는 필터다.
  • 사용자가 직접 설정한 값은 저장하고, 시간이 지나면 낡아버리는 서버 응답값(시세 등)은 저장에서 빼는 것이 기본 원칙이다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

persist가 저장하는 시점은 언제인가?
상태가 바뀔 때마다 자동으로 localStorage에 씁니다. 직접 useEffect로 저장할 필요가 없죠. 불러오기는 앱이 시작될 때 한 번 일어납니다.
스토어의 일부만 저장하고 싶으면 어떻게 하나?
partialize 옵션으로 저장할 필드를 고릅니다. 로딩 상태나 임시 값처럼 저장할 필요 없는 것을 빼면 저장 용량과 복원 시 혼란을 줄일 수 있죠.

💼 실무·코딩테스트에서는새로고침에도 남아야 하는 상태는 실무에서 흔한 요구입니다(장바구니·필터·테마). 다만 서버와 동기화가 필요한 데이터는 localStorage가 아니라 서버가 진짜 출처여야 한다는 원칙을 지켜야 합니다.

29

Tailwind CSS란? — 유틸리티 클래스로 짜는 스타일

Tailwind CSS유틸리티 클래스@themedark:

한 줄 요약Tailwind CSS는 px-3(좌우 padding 0.75rem), 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 속성 한두 개에 대응합니다(예: px-3은 좌우 패딩만 0.75rem, py-1은 위아래 패딩만 0.25rem, 네 방향 전부는 p-3). CSS 단원(CSS 작성 방식)에서 <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 + 다크모드 참고).

표기 주의 — 수업 코드는 @variant dark (...)로 작성했고 실습에서 정상 동작했지만, Tailwind v4 공식 문서가 안내하는 표기는 @custom-variant dark (&:where(.dark, .dark *));입니다. 또 useEffect는 화면이 그려진 뒤에 실행되므로 새로고침 때 라이트 화면이 잠깐 번쩍일 수 있는데(FOUC), <head>의 인라인 스크립트로 먼저 클래스를 붙이는 해결법은 다크모드 Q&A를 참고하세요.

핵심 정리
  • Tailwind는 CSS 속성 하나하나에 대응하는 짧은 클래스를 className에 조합해 스타일을 만드는 유틸리티 프레임워크다.
  • @theme으로 프로젝트 전용 색상 등을 정의하면 그에 대응하는 클래스가 자동으로 생긴다.
  • @variant dark(공식 표기 @custom-variant dark)가 "<html>.dark일 때만 dark: 클래스 활성화"라는 다크모드 규칙 자체를 정의하고, 실제로 그 클래스를 붙이고 떼는 것은 별도의 React 코드(useEffect)가 담당한다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

Tailwind의 유틸리티 클래스가 '중복'처럼 보이는데 왜 괜찮은가?
빌드할 때 실제로 쓰인 클래스만 남기고, 같은 클래스는 CSS에 한 번만 정의되기 때문입니다. HTML에 여러 번 써도 CSS 파일은 커지지 않죠. 오히려 사용하지 않는 CSS가 쌓이는 문제가 사라집니다.
Tailwind를 쓰면 무엇이 불편해지나?
클래스가 길어져 HTML이 지저분해 보이고, 익숙해지기까지 시간이 걸립니다. 그래서 반복되는 묶음은 컴포넌트로 추출하거나 @apply로 정리하죠. 다만 컴포넌트 추출이 더 권장되는 방식입니다.

💼 실무·코딩테스트에서는Tailwind 채택률은 계속 오르고 있습니다 — 클래스 이름을 짓는 고민이 사라지고, 디자인 토큰이 강제되어 일관성이 생기기 때문이죠. 면접에서는 장단점을 균형 있게 말하는 것이 좋습니다.

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에서)의 흐름을 기억해두면 된다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

REST 폴링 대신 WebSocket을 쓰면 무엇이 좋아지나?
서버가 필요할 때 먼저 보낼 수 있습니다. 폴링은 변화가 없어도 계속 물어봐야 하죠 — 낭비가 크고 지연도 생깁니다. WebSocket은 연결을 유지하며 변화가 생긴 순간 밀어 줍니다.
WebSocket 연결이 끊어지면 어떻게 대응해야 하나?
재연결 로직이 필요합니다. 네트워크는 언제든 끊기니까요. 실무에서는 점점 간격을 늘리는 재시도(지수 백오프)와 하트비트로 살아 있는지 확인하는 처리를 넣습니다.

💼 실무·코딩테스트에서는실시간 기능은 요구가 계속 늘고 있습니다. 다만 모든 것을 실시간으로 할 필요는 없고, 갱신 빈도가 낮으면 폴링이 더 단순하고 안정적일 수 있습니다. SSE(Server-Sent Events)라는 중간 선택지도 있습니다.

D · Next.js 핵심 개념 — 파일 기반 라우팅이 처음이라면

31

서버 컴포넌트 vs 클라이언트 컴포넌트란? (Next.js)

서버 컴포넌트use clientNext.js

한 줄 요약Next.js의 모든 컴포넌트는 기본적으로 서버에서 실행되는 "서버 컴포넌트"라 Hooks·Context 같은 브라우저 전용 기능은 못 쓰는 대신 컴포넌트를 async로 선언할 수 있고, 사용자 상호작용이 필요한 부분만 파일 맨 위에 'use client'를 선언해 예전 방식의 "클라이언트 컴포넌트"로 전환한다.

쉽게 말하면서버 컴포넌트는 "주방에서 미리 완성해서 손님상에 내보내는 요리"예요 — 서버에서 한 번 그려져 완성된 HTML만 브라우저로 전달되고, 그 뒤로는 스스로 다시 그려지지 않습니다. 클라이언트 컴포넌트는 "손님 테이블에서 즉석으로 조리하는 요리"에 가까워요 — 브라우저에서 계속 상태를 갖고, 클릭 같은 이벤트에 반응해 즉석으로 다시 그려집니다.

지금까지 배운 React(Vite)와 무엇이 다른가

Vite로 만든 레슨 01~06(02~07 카드)의 컴포넌트는 전부 브라우저에서 실행되는 "클라이언트 컴포넌트"였습니다 — useState로 상태를 갖고, 클릭하면 즉시 다시 그려지는 방식입니다. 레슨 07(08 App Router 카드)부터 쓰는 Next.js는 이 기본을 뒤집어 아무 선언도 안 하면 서버 컴포넌트가 됩니다. 서버 컴포넌트는 서버에서 딱 한 번 실행돼 결과 HTML만 브라우저로 보내지고, 그 이후로는 useState 같은 게 있을 수도 없습니다(애초에 "다시 실행해서 다시 그리는" 개념 자체가 없음).

서버 컴포넌트에서 할 수 없는 것 / 클라이언트 컴포넌트에서 할 수 없는 것

  • 서버 컴포넌트가 못 하는 것 — useState·useEffect 같은 Hooks, Context API·Zustand 같은 전역 상태 공유. 전부 "브라우저에서 사용자 이벤트에 반응해 다시 그리는" 능력을 전제로 하는데, 서버 컴포넌트는 그 능력 자체가 없습니다.
  • 클라이언트 컴포넌트가 (권장하지) 않는 것 — 컴포넌트 함수 자체를 async function으로 선언하는 것. 리액트가 브라우저에서 그 비동기 결과를 "언제, 어떻게" 그려야 할지 알 수 없기 때문입니다. 비동기 처리가 필요하면 useEffect 안에서 처리합니다.
  • 'use client' — 파일 맨 위에 이 한 줄을 선언하면, 그 파일의 컴포넌트는 클라이언트 컴포넌트로 전환되어 Hooks·이벤트 핸들러를 다시 쓸 수 있습니다. 이 선언은 "클라이언트 경계"를 긋는 것이라 그 파일이 import하는 모듈도 함께 클라이언트 쪽 코드가 됩니다.
  • 클라이언트 컴포넌트도 첫 HTML은 서버가 그린다 — "브라우저에서만 실행된다"는 뜻이 아닙니다. 첫 요청 때 서버가 클라이언트 컴포넌트도 미리 HTML로 그려 보내고(SSR), 브라우저가 JS를 받은 뒤 그 HTML에 상태·이벤트를 연결합니다(hydration, 하이드레이션). 서버 컴포넌트와의 차이는 "그 뒤에도 브라우저에서 다시 실행되며 상태를 갖느냐"입니다.

동적 라우트 페이지가 async 컴포넌트일 수 있는 이유

react-08의 app/blog/[id]/page.js는 export default async function BlogPost({ params })처럼 컴포넌트 자체가 async 함수입니다 — 이게 가능한 이유가 바로 이 페이지가 서버 컴포넌트이기 때문입니다. 서버는 한 번 실행하고 끝이므로 "결과가 나올 때까지 기다렸다가(await) 그 결과로 HTML을 그리는" 방식이 자연스럽게 동작합니다. 클라이언트 컴포넌트였다면 이런 최상위 async 선언 자체가 불가능합니다.

핵심 정리
  • Next.js 컴포넌트는 기본이 서버 컴포넌트 — 서버에서 한 번 실행돼 HTML로만 전달되고, Hooks·Context 같은 "다시 그리는" 기능은 쓸 수 없다.
  • 서버 컴포넌트는 컴포넌트 자체를 async로 선언할 수 있다 — 동적 라우트에서 await params를 쓸 수 있는 이유가 이것이다.
  • 사용자 상호작용(useState, 이벤트 핸들러 등)이 필요하면 파일 맨 위에 'use client'를 선언해 클라이언트 컴포넌트로 전환한다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

'use client'를 붙이면 그 파일만 클라이언트가 되나?
그 파일과 거기서 import하는 것들이 모두 클라이언트 번들에 들어갑니다. 그래서 클라이언트 경계를 최대한 아래(잎)로 내리는 것이 중요하죠. 위쪽에 붙이면 그 아래 전부가 클라이언트가 되어 서버 컴포넌트의 이점이 사라집니다.
서버 컴포넌트에서 onClick을 쓸 수 없는 이유는?
함수는 직렬화해서 브라우저로 보낼 수 없기 때문입니다. 서버 컴포넌트의 결과는 브라우저로 전송되는 데이터인데, 함수는 그 형태로 담을 수 없죠. 상호작용은 반드시 클라이언트 컴포넌트에서 합니다.

💼 실무·코딩테스트에서는서버/클라이언트 경계 설계가 Next.js App Router 실무의 핵심 기술입니다. 데이터를 가져오는 것은 서버, 상호작용은 클라이언트로 나누고, 클라이언트 컴포넌트에 서버 컴포넌트를 children으로 넘기는 패턴을 알아 두면 유용합니다.

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는 페이지 이동에도 다시 그려지지 않는다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

page.js와 layout.js와 loading.js는 각각 무엇을 하나?
page.js는 그 경로의 화면, layout.js는 하위 경로가 공유하는 껍데기(페이지가 바뀌어도 유지), loading.js는 그 구간이 로딩 중일 때 보여줄 화면입니다. 파일 이름이 곧 역할입니다.
[id] 폴더가 뜻하는 것은?
동적 세그먼트입니다 — /posts/1, /posts/2가 모두 이 경로로 잡히고, const { id } = await params로 값을 받습니다(Next.js 15부터 params가 Promise라서 params.id로 바로 읽으면 안 되고, 컴포넌트를 async로 만들어 await해야 합니다). 대괄호가 "여기는 변한다"는 표시인 셈이죠.

💼 실무·코딩테스트에서는파일 규칙을 외우는 것이 Next.js 학습의 첫 관문입니다. 익숙해지면 폴더 구조만 봐도 사이트 구조가 보이는 것이 큰 장점이죠. 실무에서는 라우트 그룹 (name)으로 URL에 영향 없이 폴더를 정리하는 기법도 자주 씁니다.

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.js는 GET·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/...')로 바로 호출할 수 있다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

API Routes에서 환경변수를 안전하게 쓸 수 있는 이유는?
서버에서만 실행되기 때문입니다. NEXT_PUBLIC_ 접두사가 없는 환경변수는 브라우저 번들에 포함되지 않죠. 그래서 API 키·DB 비밀번호를 여기서 쓸 수 있습니다.
클라이언트에서 외부 API를 직접 부르지 않고 내 API를 거치는 이유는?
① 키를 숨기고 ② CORS 문제를 피하고 ③ 응답을 가공하거나 캐싱할 수 있기 때문입니다. 이런 중간 계층을 BFF(Backend For Frontend)라고 부릅니다.

💼 실무·코딩테스트에서는API 키 노출은 실무에서 실제로 자주 일어나는 사고입니다. 브라우저 개발자 도구에서 다 보이므로, 키가 필요한 호출은 반드시 서버를 거쳐야 합니다. NEXT_PUBLIC_ 접두사의 의미를 정확히 아는 것이 중요합니다.

E · 실전에서 만나는 버그와 함정 — 실습하다 막혔다면

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} 그대로 전달 가능, 매개변수 있는 호출은 항상 화살표로 감싼다.
  • 클릭도 안 했는데 콘솔 로그가 계속 찍히거나 브라우저가 멈추면 이 패턴부터 의심한다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

onClick={handleClick()} 이 무한 루프를 만드는 과정을 설명해보세요.
괄호를 붙이면 렌더링 중에 즉시 실행됩니다. 그 함수가 state를 바꾸면 리렌더링 → 또 즉시 실행 → 또 state 변경이 반복되죠. onClick={handleClick}처럼 함수 자체를 넘겨야 클릭할 때만 실행됩니다.
인자를 넘겨야 할 때는 어떻게 하나?
화살표 함수로 감쌉니다 — onClick={() => handleClick(id)}. 이러면 클릭 시점에 실행될 함수를 만들어 넘기는 것이 되죠. 괄호를 바로 붙이는 것과 완전히 다른 의미입니다.

💼 실무·코딩테스트에서는React 입문자가 반드시 한 번은 겪는 버그입니다. "Too many re-renders" 에러를 만나면 핸들러에 괄호를 붙였는지부터 확인하세요. 원인을 알면 다시는 안 틀리는 종류의 실수입니다.

관련 실습: react-03 Counter
35

useEffect 2개 + StrictMode = 저장한 데이터가 빈 값으로 사라지는 버그

StrictModeuseEffectlocalStorage게으른 초기화

한 줄 요약초기값을 빈 배열 []로 두고 "불러오기 useEffect"와 "저장 useEffect"를 따로 두면, 첫 마운트 때 저장 effect가 초기값(빈 배열)을 먼저 localStorage에 덮어쓰고, StrictMode의 재마운트에서 불러오기 effect가 그 빈 배열을 다시 읽어 와 기존 데이터가 통째로 사라지는 버그가 발생한다.

쉽게 말하면이 버그는 "빈 메모장을 서랍에 먼저 넣어버리는 사고"예요. 화면이 뜨자마자 "① 서랍(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개로 나눈 구조입니다.

localStorage에 ['우유 사기']가 저장돼 있다고 하고, StrictMode(개발 모드)에서 순서대로 따라가 보면:

  1. 첫 렌더 — todos는 초기값 []로 화면이 그려집니다.
  2. 불러오기 effect — localStorage에서 ['우유 사기']를 읽고 setTodos(['우유 사기'])를 예약합니다. setState는 바로 반영되지 않으므로 이 순간 todos는 여전히 []입니다.
  3. 저장 effect — 같은 렌더의 todos, 즉 []를 localStorage에 씁니다. 저장소가 []로 덮어써집니다.
  4. StrictMode 재마운트 — 검사를 위해 두 effect를 한 번 더 실행합니다. 불러오기 effect가 localStorage를 다시 읽는데, 3에서 이미 []가 됐으므로 setTodos([])를 예약합니다. 저장 effect는 다시 []를 씁니다.
  5. 예약된 업데이트 처리 — setTodos(['우유 사기']) 다음에 setTodos([])가 적용되어 마지막 값인 []가 최종 상태가 됩니다. 원래 데이터가 화면과 저장소 양쪽에서 사라집니다.

StrictMode가 없으면 왜 괜찮아 보이나: 4단계가 없으므로, 3에서 저장소가 잠깐 []가 되더라도 곧 2에서 예약한 ['우유 사기']로 다시 렌더되고 저장 effect가 그 값을 다시 써서 원래대로 돌아옵니다. 즉 운영(배포) 환경에서는 대체로 동작하지만, "잠깐이라도 저장소가 빈 값으로 덮어써지는 순간"이 있는 취약한 구조입니다(그 사이 탭이 닫히거나 에러가 나면 데이터가 사라질 수 있음). StrictMode는 그 약점을 개발 중에 눈에 보이게 만든 것입니다.

해결책: 애초에 "초기값이 []인 순간"을 없애면 됩니다 — useState(() => { const saved = localStorage.getItem('todos'); return saved ? JSON.parse(saved) : [] }) 게으른 초기화로 시작하면 todos는 컴포넌트가 처음 실행되는 순간부터 이미 기존 데이터를 갖고 있으므로, 저장용 useEffect 1개만 있어도 StrictMode가 몇 번을 재실행하든 안전합니다.

핵심 정리
  • "불러오기 useEffect(빈 배열 의존성)" + "저장 useEffect" 조합은 초기 렌더의 빈 상태가 저장 effect에 의해 먼저 기록돼 버리는 타이밍 문제를 가진다.
  • StrictMode의 의도된 재마운트에서 불러오기 effect가 이미 덮어써진 빈 값을 다시 읽어, 숨어 있던 문제가 "데이터가 완전히 사라짐"으로 드러난다(StrictMode가 없으면 대체로 원래대로 돌아오지만 취약한 구조다).
  • 해결: useState(() => localStorage에서 읽기)로 게으른 초기화하고, 저장용 useEffect 1개만 사용 — "초기값이 빈 값인 순간"을 아예 없애는 것이 근본 해결책이다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

StrictMode에서 effect가 두 번 실행되어 데이터가 사라진 원리를 설명해보세요.
저장 effect와 불러오기 effect의 실행 순서 때문입니다. 초기 빈 상태로 저장이 먼저 일어나면 이미 저장돼 있던 값을 빈 값으로 덮어쓰죠. 두 번 실행되면서 이 순서 문제가 드러난 것입니다.
이런 버그를 예방하는 방법은?
불러오기가 끝나기 전에는 저장하지 않도록 플래그를 두거나, 저장 로직을 effect가 아니라 이벤트 핸들러로 옮기는 것입니다. 후자가 더 근본적인 해결이죠 — 사용자 행동으로 생긴 변화는 그 자리에서 저장하면 됩니다.

💼 실무·코딩테스트에서는StrictMode가 잡아 준 진짜 버그의 사례입니다. 개발 모드에서만 보인다고 끄면, 운영에서는 대체로 동작하지만 저장소가 잠깐 빈 값이 되는 취약한 구조가 그대로 남죠. "두 번 실행해도 괜찮은가"는 effect를 작성할 때마다 자문해야 할 질문입니다.

TIP이 버그는 실제로 react-03 TodoApp 실습에서 다른 방식(effect 2개)으로 짰을 때 나올 수 있는 현상이었습니다 — 우리가 실제로 쓴 코드는 게으른 초기화를 써서 안전합니다. 자세한 실습 코드는 react-03의 ⚠️ 표시된 개념 블록을 참고하세요.
36

stale closure — 의존성 배열을 빠뜨렸을 때 생기는 "오래된 값" 버그

stale closure의존성 배열클로저

한 줄 요약useEffect·useCallback·useMemo 안에서 사용하는 state 값을 의존성 배열에 빠뜨리면, 그 함수는 "만들어질 당시의 오래된 값"을 계속 기억(클로저)하고 있어서 실제 최신 state와 다른 값을 참조하는 stale closure 버그가 생긴다. (일반 이벤트 핸들러에는 의존성 배열이 없고 렌더마다 새로 만들어져 보통 최신 값을 보지만, 핸들러 안에서 setTimeout 등으로 나중에 실행되는 코드는 그 핸들러가 만들어진 렌더의 값을 본다.)

쉽게 말하면클로저는 "그 함수가 태어난 순간의 스냅사진"을 기억하는 습성이에요. useEffect(() => { const id = setInterval(() => console.log(count), 1000); return () => clearInterval(id) }, [])처럼 의존성 배열에 count를 안 넣으면, 이 타이머는 "최초 렌더링 때 찍은 count 사진"만 들고 있어서, 버튼을 눌러 화면의 count가 1, 2, 3으로 바뀌어도 콘솔에는 매초 0만 찍힙니다.

"렌더링마다 새로 만들어짐"과 "그 순간을 기억함", 두 가지가 겹쳐서 버그가 난다

stale closure는 사실 두 가지 사실이 조합되어 생기는 현상입니다 — 하나씩 따로 보면 이해가 쉽습니다.

  • 사실 ① — 컴포넌트가 리렌더링될 때마다 useEffect(() => {...})의 콜백 함수도 매번 새로 만들어집니다(이전 함수와는 다른, 그 순간 전용 함수).
  • 사실 ② — 자바스크립트 클로저는 함수가 만들어지는 그 순간의 변수 값을 사진 찍듯 기억합니다.
  • 의존성 배열이 [] — 최초 1번 만들어진 그 함수가 이후로도 계속 재사용되는데, 그 함수는 ①②에 따라 "최초 렌더링 때의 count 사진" 하나만 영원히 들고 있습니다. 위의 setInterval 콜백이 매초 0만 찍는 이유가 이것입니다.

비유하면 매 렌더링마다 그 순간 화면을 촬영하는 카메라(effect 콜백)가 새로 하나씩 생기는데, 의존성 배열이 []면 "첫 번째 카메라만 계속 쓰겠다"고 정한 것과 같아요 — 그 카메라는 계속 첫 촬영 당시 사진(count가 0이던 순간)만 보여줄 뿐, 이후 실제 상황이 아무리 바뀌어도 새로 찍지 않습니다. 고치려면 의존성 배열에 count를 넣거나(바뀔 때마다 타이머를 정리하고 새로 만듦), 값을 바꾸는 쪽이라면 setCount(prev => prev + 1)처럼 함수형 업데이트를 씁니다.

클로저는 "정의될 때의 변수"를 기억한다

자바스크립트의 클로저(closure)는 함수가 "자신이 정의될 때의 주변 변수들"을 기억하는 성질입니다 — useEffect 콜백도 하나의 함수이므로 렌더링마다 새로 만들어지고, 그 순간의 state 값들을 캡처합니다. 의존성 배열에 effect 안에서 쓰는 값(todos 등)을 넣지 않으면, effect는 리렌더링돼도 다시 실행되지 않으므로 "맨 처음 렌더링 때의 값"에 갇혀버려 최신 값과 어긋나는 stale(오래된) 값을 참조하게 됩니다. 예방법은 eslint-plugin-react-hooks의 exhaustive-deps 규칙을 켜두는 것이지만, 지금 단계에서는 "effect 안에서 쓰는 state/props는 전부 의존성 배열에 넣는다"는 원칙만 기억해도 충분합니다.

핵심 정리
  • 클로저는 함수가 만들어질 때의 변수 값을 "그 순간 그대로" 기억하는 성질이다.
  • effect/콜백 안에서 쓰는 값을 의존성 배열에서 빠뜨리면, 그 함수는 계속 오래된(stale) 값만 참조하게 된다.
  • effect 안에서 실제로 사용하는 값은 빠짐없이 의존성 배열에 넣는 것이 기본 원칙이다(exhaustive-deps).
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

stale closure가 생기는 원리를 클로저로 설명해보세요.
effect 안의 함수가 만들어진 시점의 값을 기억하기 때문입니다. 의존성 배열을 비워 두면 첫 렌더 때의 값에 갇힌 함수가 계속 쓰이죠. 화면의 값은 바뀌었는데 그 함수만 옛날 값을 보고 있는 상태가 됩니다.
의존성 배열을 채우기 어려울 때 쓸 수 있는 방법은?
함수형 업데이트(setX(prev => ...))로 이전 값을 인자로 받으면 클로저에 의존하지 않게 됩니다. 또는 useRef에 최신 값을 담아 참조하는 방법도 있습니다.

💼 실무·코딩테스트에서는stale closure는 React에서 가장 이해하기 어려운 버그 중 하나이고, 타이머·이벤트 리스너·소켓 콜백에서 자주 나옵니다. ESLint의 exhaustive-deps 규칙을 켜 두면 상당수를 미리 막을 수 있습니다.

F · 💬 내가 실제로 했던 질문들 — 수업 중 막혔던 지점 그대로

37

다크모드 Q&A — "dark 클래스 하나 붙이면 정말 싹 다 바뀌어?" (Tailwind·다크모드 실습에서 했던 질문들)

Tailwind v4다크모드documentElement@variant

한 줄 요약13 Tailwind + 다크모드 카드(레슨 12) 실습에서 실제로 막혔던 질문 3개 — @variant에 뜨는 빨간 밑줄의 정체, dark: 접두사가 평소엔 왜 안 먹는지, ThemeWrapper가 어디서 뭘 하는지, 그리고 "감싼 범위 밖인 body까지 왜 바뀌는지"에 대한 답을 모았다.

쉽게 말하면다크모드의 핵심은 "건물 전체의 조명 스위치는 옥상(html 태그) 한 곳에만 있다"는 것입니다 — ThemeWrapper가 JSX에서 어디를 감싸고 있든, 내부에서는 document.documentElement(옥상)를 직접 조작하므로 건물 전체(body 포함)의 불이 한꺼번에 바뀝니다. dark:bg-... 클래스들은 "스위치가 켜졌을 때만 작동하는 예약 전구"라서 평소에는 있어도 안 켜져 있는 것뿐이에요.

Q. globals.css의 @variant에 빨간 밑줄 에러가 떠요

@variant는 Tailwind CSS v4 전용 지시어이고, 수업 코드의 @variant dark (&:where(.dark, .dark *));는 실습에서 정상 동작했습니다. 다만 v4 공식 문서가 클래스 기반 다크모드에 안내하는 표기는 @custom-variant dark (&:where(.dark, .dark *));이므로(새 변형 "정의"는 @custom-variant), 새로 작성한다면 이쪽을 쓰는 것이 안전합니다. 밑줄은 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 어디에 있든 페이지 전체에 적용된다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

dark 클래스 하나로 전체가 바뀌는 것이 CSS의 어떤 성질 덕분인가?
후손 선택자입니다. .dark .box { ... }처럼 조상에 클래스가 있을 때만 적용되는 규칙을 미리 만들어 두는 것이죠. 그래서 루트에 클래스 하나만 붙이면 그 아래 전부가 일제히 바뀝니다.
다크모드 설정을 새로고침 후에도 유지하려면?
localStorage에 저장하고 시작할 때 읽어 클래스를 붙입니다. 이때 화면이 그려지기 전에 적용해야 흰 화면이 번쩍이는 현상을 막을 수 있어, <head>의 인라인 스크립트로 처리하는 것이 일반적입니다.

💼 실무·코딩테스트에서는다크모드는 이제 기본 요구사항입니다. OS 설정 따라가기(prefers-color-scheme)와 사용자 선택 저장을 함께 지원하는 것이 표준이고, 이 학습 노트도 그렇게 되어 있습니다.

38

WebSocket·환경변수 Q&A — "웹소켓이 뭐야?"부터 "왜 차트가 안 움직여?"까지 (실시간 차트 실습에서 했던 질문들)

WebSocketNEXT_PUBLIC_cleanup폴링 폴백

한 줄 요약14 Recharts + WebSocket 카드(레슨 13) 실시간 차트 실습에서 실제로 했던 질문들 — 왜 API 키에 NEXT_PUBLIC_을 붙여야 하는지, 웹소켓이 HTTP와 뭐가 다른지, cleanup의 unsubscribe/close가 뭘 하는지, 그리고 "연결은 됐는데 차트가 안 밀려나는" 미스터리의 원인(미국장 마감)까지.

쉽게 말하면HTTP는 "주문할 때만 대답하고 끊는 카운터 주문"이고, 웹소켓은 "전용 전화선을 계속 켜둔 채 새 소식이 생길 때마다 상대가 바로 말해주는 통화"입니다. 그런데 전화선이 멀쩡해도 상대(미국 주식시장)가 자고 있으면(장 마감) 아무 말이 없어요 — 그래서 낮에는 2초마다 "지금 얼마예요?"라고 직접 물어보는 폴링으로 갈아탑니다.

Q. 왜 웹소켓 API 키는 NEXT_PUBLIC_을 붙여야 해요?

일반 환경변수(FINNHUB_API_KEY)는 서버 쪽(API Route, 서버 컴포넌트)에서만 읽을 수 있습니다. 웹소켓은 브라우저(클라이언트 컴포넌트)에서 직접 연결해야 하므로, 변수명 앞에 NEXT_PUBLIC_이 붙어야만 Next.js가 그 값을 브라우저 번들에 포함시켜 줍니다. 대신 이렇게 하면 누구나 개발자 도구로 키를 볼 수 있습니다. 이 실습은 Finnhub 무료·읽기 전용(시세 조회만 되는) 키라서, 노출되어도 피해가 작다는 걸 알고 연습용으로 일부러 감수한 것입니다. 결제·쓰기 권한이 있는 진짜 비밀 키라면 절대 NEXT_PUBLIC_을 붙이지 않고, 브라우저는 우리 서버(API Route)에만 연결하고 서버가 비밀 키로 외부 API에 대신 접속하는 서버 프록시 구조로 만듭니다.

Q. useEffect 리턴 안의 unsubscribe와 ws.close()는 뭐 하는 거예요?

웹소켓 전화선을 깔끔하게 끊는 정리(cleanup) 코드입니다. 종목을 AAPL에서 TSLA로 바꾸거나 페이지를 나갈 때, ① 이전 종목의 실시간 알림 구독을 서버에 취소 통보(unsubscribe 메시지)하고 ② 열려 있던 소켓 연결 자체를 닫습니다(ws.close()). 이걸 안 하면 메모리 누수에 더해, 이전 종목 시세가 새 종목 차트에 섞여 들어오는 버그가 생깁니다.

Q. 차트가 계속 옆으로 밀려나면서 갱신돼야 하는데 왜 안 움직여요?

범인은 코드가 아니라 시차였습니다 — Finnhub 웹소켓은 미국 주식시장 개장 시간(한국 기준 밤 11:30~아침 06:00, 미국 서머타임 기간인 3월 중순~11월 초에는 밤 10:30~아침 05:00)에만 실시간 체결 데이터를 보냅니다. 한국 낮에는 연결이 정상이어도 체결 소식 자체가 없어서 차트가 멈춰 보였던 것. 그래서 장 마감 시간대이거나 키가 없을 때는 2초마다 시세 API를 호출하는 폴링(setInterval) 폴백으로 전환해, 언제 실습해도 차트가 우측으로 밀려나게 보완했습니다 — "코드는 맞는데 왜 안 되지?"의 원인이 환경(장 운영시간)일 수도 있다는 교훈.

핵심 정리
  • 브라우저에서 읽어야 하는 환경변수만 NEXT_PUBLIC_을 붙인다 — 노출되어도 되는 값인지 먼저 판단할 것.
  • effect가 연 연결(웹소켓·타이머)은 cleanup에서 반드시 닫는다 — 구독 해제 통보 후 close 순서.
  • "연결은 정상인데 데이터가 안 온다"면 코드보다 먼저 데이터 공급자의 상태(장 운영시간 등)를 의심해 본다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

환경변수에 NEXT_PUBLIC_ 접두사를 붙이면 무엇이 달라지나?
브라우저 번들에 포함되어 클라이언트에서 읽을 수 있게 됩니다. 반대로 말하면 누구나 볼 수 있다는 뜻이죠. 그래서 비밀 키에는 절대 붙이면 안 됩니다. 이 실습에서 Finnhub 키에 붙인 것은 무료·읽기 전용 키라 노출을 알고 감수한 연습용 선택이고, 실제 서비스라면 서버 프록시(API Route)를 거치게 합니다.
차트가 안 움직일 때 점검할 순서를 말해보세요.
① 소켓이 연결됐는가(개발자 도구 Network → WS) ② 메시지가 오고 있는가 ③ state가 갱신되는가(불변성 지켰는지) ④ 차트가 그 state를 보고 있는가. 어느 단계에서 끊겼는지를 좁혀 가는 것이 요령입니다.

💼 실무·코딩테스트에서는"안 움직인다"는 증상은 원인이 여러 단계에 걸쳐 있습니다. 실무 디버깅의 핵심은 추측하지 말고 단계별로 확인하는 것이죠 — 브라우저 개발자 도구의 Network 탭에서 WS 프레임을 직접 보는 것이 가장 확실합니다.

39

차트 데이터 Q&A — "slice(-59)가 뭐야?" "그 .p는 무슨 문법이야?" (실시간 차트 실습에서 했던 질문들)

슬라이딩 윈도우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()를 쓸 때마다 "결과가 문자열"이라는 함정을 기억할 것 — JS 07 String 객체 카드에서 배운 타입 변환 감각이 실전에서 쓰인 지점입니다.

핵심 정리
  • [...prev.slice(-59), point] — 오래된 것 하나 버리고 새것 하나 추가, 배열 크기를 60개로 고정하는 슬라이딩 윈도우.
  • data[data.length - 1]은 배열의 맨 마지막(가장 최신) 요소를 가리키는 관용 표현.
  • 모르는 .속성이 나오면 자바스크립트 문법이 아니라 API 응답 명세부터 찾아본다.
  • toFixed()의 결과는 문자열 — 계산에 쓰려면 parseFloat로 숫자로 복구해야 한다.
🤔 스스로 확인

답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.

slice(-59)가 무엇을 하는지 설명해보세요.
배열의 뒤에서 59개를 잘라 냅니다. 음수 인덱스는 끝에서부터 세는 것이죠. 실시간 차트에서 최근 N개만 유지해 메모리와 렌더링 비용을 제한할 때 씁니다.
.p 같은 짧은 속성 이름이 실시간 데이터에서 쓰이는 이유는?
전송량을 줄이기 위해서입니다. 초당 수십 건이 오가면 속성 이름 길이도 무시할 수 없죠. 그래서 거래소 API들이 p(price), q(quantity) 같은 축약 키를 씁니다. 읽기 어려운 대신 빠릅니다.

💼 실무·코딩테스트에서는실시간 데이터는 양이 많아 "버리는 전략"이 필요합니다. 최근 N개만 유지하거나, 일정 간격으로 샘플링하거나, 화면 갱신 빈도를 제한(스로틀링)하죠. 안 그러면 메모리가 계속 늘고 브라우저가 느려집니다.

✏️ 시험 대비 — 객관식 연습문제

지금까지 배운 프론트엔드 전 범위(HTML·CSS·JS·React/Next.js)의 객관식 문제를 한 문제씩 풀면서 고르는 즉시 정답과 해설을 확인합니다. 짧은 해설 아래의 🔎 자세한 해설을 펼치면 보기 ①~④를 하나씩 왜 맞고 왜 틀렸는지 짚어 주고, 마지막에 더 쉬운 말로 정리한 설명까지 붙습니다(틀린 문제는 자동으로 펼쳐집니다). 마음에 걸리는 문제는 ⭐ 즐겨찾기 · 🔥 어려움으로 표시해 두면 나중에 표시한 것만 · 틀린 것만 골라 다시 풀 수 있어요.

과목
범위
문제 수

⌨️ 키보드로도 풀 수 있어요 — 숫자 1~4로 보기 선택, Enter로 다음 문제, S 즐겨찾기, D 어려움 표시.

학습 확장 주제

강의자료 목차 기준의 React & Next.js 커리큘럼을 모두 완료했습니다. 이후 새 수업·심화 실습·개인 프로젝트 주제를 이곳에서 이어 관리합니다.

강의자료

수업에서 사용한 원본 슬라이드와, 학습하며 직접 정리한 문서들입니다.