기존 84강좌는 수습용으로 유지하고, 헤더 셀렉트 박스로 과정을 전환한다. 과정 구성 (각 12강좌, 배우는 순서대로): - 초급 — 코딩 첫걸음: 프로그램의 개념→HTML→JS→변수·조건·반복·함수→에러읽기→첫 게임 - 중급 — 실전 웹 개발: 비동기·배열→API설계→Express CRUD→DB·트랜잭션→인증→React 실전→테스트→배포 - 고급 — 아키텍처·성능·보안: 레이어드·패턴→인덱스·격리→캐싱·최적화→OWASP·인증설계→관측·무중단배포 - 특급 — 대규모 시스템: 분산기초·CAP→메시지큐→MSA→샤딩→대용량 설계→k8s→SRE→시스템설계 3연작→ADR 구현: - trackContext(localStorage 유지) + 헤더 TrackSelect + 테스트 5개 - levelCatalog(lv1-~lv4- 슬러그, 기존과 충돌 없음) + 라우트·퀴즈 자동 연결 - LearnHubPage 과정 인식(과정별 인사말·진도), CertificatePage 분모 버그 선제 수정 - 퀴즈 240문항: quizzes.json 415→655 + 운영 DB 삽입(655/131 확인) 생성 파이프라인: 설계 4 → 작성 48(병렬) → 검수 4 에이전트. 검수 BLOCKER 2건(백틱 충돌·OpenAPI 표기) 수정 후 빌드 통과. 운영 검증: 4개 과정 전환·강좌 렌더·퀴즈 로드·선택 유지 브라우저 확인, 스모크 11/11. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
523 lines
30 KiB
JavaScript
523 lines
30 KiB
JavaScript
// 이 파일이 하는 일: "비동기 JavaScript: fetch와 Promise 정복" 코스 — 동기 코드가 화면을
|
|
// 얼리는 순간에서 출발해, 콜백 지옥 → Promise → async/await → fetch → 병렬 처리 →
|
|
// 실전 패턴(로딩·취소·타임아웃)까지 7개 섹션으로 안내하는 정적 학습 페이지.
|
|
// 프레이밍은 "서버와 대화하는 코드" — 기초 문법을 아는 학생이 실제로 돌아가는 웹 서비스를
|
|
// 만들 때 반드시 넘어야 하는 첫 고개인 비동기를, 환율 API 실습으로 손에 잡히게 만든다.
|
|
// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
|
|
// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
|
|
// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
|
|
|
|
import { Link } from 'react-router-dom';
|
|
|
|
// ── 예제 코드 상수들 ──
|
|
|
|
const CODE_FREEZE = `// 버튼을 누르면 3초 동안 '아무것도' 못 하는 코드 — 콘솔에서 직접 체험해 보세요
|
|
function freezeFor3Seconds() {
|
|
const start = Date.now();
|
|
while (Date.now() - start < 3000) {
|
|
// 3초 동안 CPU를 붙잡고 제자리 돌기
|
|
}
|
|
console.log('끝!');
|
|
}
|
|
|
|
// 이 함수가 도는 동안 클릭·스크롤·입력이 전부 멈춥니다.
|
|
// JavaScript는 '한 번에 한 가지 일'만 하는 싱글 스레드라서,
|
|
// 오래 걸리는 일을 그 자리에서 기다리면 화면 전체가 얼어붙어요.
|
|
// 네트워크 요청을 이런 식으로 기다렸다간? 서버가 느린 만큼 앱이 얼겠죠.`;
|
|
|
|
const CODE_KITCHEN = `주방(JS 엔진)에는 요리사가 '한 명'뿐입니다.
|
|
|
|
[주문서 더미(큐)] ← 끝난 일들의 쪽지(클릭 발생, 타이머 완료, 응답 도착)가 쌓이는 곳
|
|
↓ 한 장씩 집어서
|
|
[요리사(콜 스택)] ← 지금 만들고 있는 요리 딱 하나
|
|
↑ 기다리는 일은 위임
|
|
[홀 직원(브라우저)] ← 타이머, 네트워크 요청처럼 '언제 끝날지 모르는 일'을
|
|
요리사 대신 맡아 주는 손들
|
|
|
|
핵심: 요리사는 절대 오븐 앞에서 멍하니 기다리지 않아요.
|
|
"다 구워지면 주문서 더미에 쪽지 넣어 줘" 하고 다음 요리로 넘어갑니다.
|
|
요리사가 손이 빌 때마다 주문서 더미에서 쪽지를 집어 처리하는
|
|
이 순환 구조가 바로 '이벤트 루프'입니다.`;
|
|
|
|
const CODE_CALLBACK = `// 콜백: "이 일이 끝나면 이 함수를 불러 줘" — 함수를 인자로 넘기는 방식
|
|
setTimeout(function () {
|
|
console.log('B: 3초 뒤에 실행됩니다');
|
|
}, 3000);
|
|
|
|
console.log('A: 이 줄이 먼저 찍힙니다!');
|
|
|
|
// 출력 순서: A → (3초 뒤) → B
|
|
// setTimeout은 타이머를 브라우저(홀 직원)에게 맡기고 '즉시' 다음 줄로 넘어가요.
|
|
// 우리가 넘긴 함수는 쪽지에 적혀 있다가, 3초 뒤에 이벤트 루프가 집어 실행합니다.`;
|
|
|
|
const CODE_HELL = `// 환율을 받고 → 환전 계산하고 → 기록을 저장하는 3단계를 콜백으로 쓰면...
|
|
getRate('USD', function (rate) {
|
|
convert(1000, rate, function (result) {
|
|
saveHistory(result, function () {
|
|
console.log('완료!');
|
|
}, function (err) {
|
|
console.error('기록 실패', err); // 에러 처리 ①
|
|
});
|
|
}, function (err) {
|
|
console.error('계산 실패', err); // 에러 처리 ②
|
|
});
|
|
}, function (err) {
|
|
console.error('환율 실패', err); // 에러 처리 ③
|
|
});
|
|
|
|
// 오른쪽으로 계속 밀려나는 계단 모양 — 이른바 '콜백 지옥(callback hell)'.
|
|
// 단계가 하나 늘 때마다 들여쓰기가 한 층 깊어지고,
|
|
// 에러 처리는 세 군데로 흩어져서 하나라도 빠뜨리기 딱 좋습니다.`;
|
|
|
|
const CODE_PROMISE_STATE = `// Promise: "지금은 결과가 없지만, 나중에 꼭 주겠다"는 약속 객체
|
|
const promise = fetch('https://open.er-api.com/v6/latest/USD');
|
|
|
|
// 갓 만들어진 promise의 상태는 pending(대기 중).
|
|
// 시간이 지나면 둘 중 하나로 '단 한 번' 정착합니다.
|
|
//
|
|
// pending ──┬──→ fulfilled(이행) 성공! 결과값을 품는다
|
|
// └──→ rejected(거부) 실패! 에러를 품는다
|
|
//
|
|
// 한번 정착(settled)한 상태는 절대 다시 바뀌지 않아요.
|
|
// fulfilled였다가 rejected가 되는 일은 없습니다 — 그래서 '믿고 기다릴' 수 있어요.`;
|
|
|
|
const CODE_THEN = `// 콜백 지옥의 3단계를 then 체이닝으로 다시 쓰면
|
|
getRate('USD')
|
|
.then(function (rate) { return convert(1000, rate); }) // 1단계 → 2단계
|
|
.then(function (result) { return saveHistory(result); }) // 2단계 → 3단계
|
|
.then(function () { console.log('완료!'); })
|
|
.catch(function (err) {
|
|
// 위 어느 단계에서 실패해도 전부 여기 '한 곳'으로 모입니다
|
|
console.error('어딘가에서 실패:', err);
|
|
});
|
|
|
|
// 같은 일을 하는데 — 계단이 사라지고, 에러 처리가 catch 하나로 모였어요.
|
|
// 비밀은 then이 '항상 새 Promise를 돌려준다'는 것.
|
|
// 그래서 사슬(chain)처럼 계속 이어 붙일 수 있습니다.`;
|
|
|
|
const CODE_AWAIT = `// 같은 로직을 async/await로 — '동기처럼 읽히는' 비동기 코드
|
|
async function run() {
|
|
try {
|
|
const rate = await getRate('USD'); // Promise가 정착할 때까지 대기
|
|
const result = await convert(1000, rate);
|
|
await saveHistory(result);
|
|
console.log('완료!');
|
|
} catch (err) {
|
|
// then 체인의 catch와 같은 역할 — 실패가 한 곳으로 모입니다
|
|
console.error('어딘가에서 실패:', err);
|
|
}
|
|
}
|
|
|
|
// await는 마법이 아니라 '문법 설탕'이에요. 속은 여전히 Promise인데,
|
|
// then 체인을 위→아래로 읽히는 모양으로 바꿔 줄 뿐입니다.
|
|
// 규칙 두 가지: ① await는 async 함수 안에서만 ② await가 기다리는 건 Promise`;
|
|
|
|
const CODE_FETCH = `// fetch는 '2단계'로 동작합니다 — 응답 도착 확인 → 본문 해석
|
|
async function getUsdRate() {
|
|
const res = await fetch('https://open.er-api.com/v6/latest/USD'); // ① 응답 도착
|
|
|
|
if (!res.ok) { // ② 상태 코드 확인은 '습관'!
|
|
throw new Error('HTTP 오류: ' + res.status);
|
|
}
|
|
|
|
const data = await res.json(); // ③ 본문(JSON)을 마저 받아 해석 — 이것도 Promise
|
|
return data.rates.KRW; // 1달러 = ? 원
|
|
}
|
|
|
|
// 함정: fetch는 404·500 응답에도 reject되지 '않아요'!
|
|
// 서버와 연결 자체가 실패(네트워크 끊김 등)했을 때만 reject됩니다.
|
|
// 서버가 "그런 거 없는데요(404)"라고 '대답'한 건 fetch 입장에선 성공이거든요.
|
|
// 그래서 res.ok(상태 200~299) 확인은 선택이 아니라 습관이어야 합니다.`;
|
|
|
|
const CODE_POST = `// 옵션 객체로 메서드·헤더·본문을 지정하면 POST도 보낼 수 있어요
|
|
const res = await fetch('/api/favorites', {
|
|
method: 'POST', // 기본값은 GET
|
|
headers: {
|
|
'Content-Type': 'application/json', // "본문은 JSON이야"라고 알려주기
|
|
},
|
|
body: JSON.stringify({ currency: 'USD', amount: 1000 }), // 객체 → JSON 문자열
|
|
});
|
|
|
|
if (!res.ok) throw new Error('저장 실패: ' + res.status);
|
|
const saved = await res.json();
|
|
|
|
// 보낼 때는 JSON.stringify(객체 → 문자열), 받을 때는 res.json()(문자열 → 객체).
|
|
// 이 왕복 변환이 '서버와 대화'의 기본 문법입니다.`;
|
|
|
|
const CODE_PARALLEL = `// 방법 A: 순차 await — 앞 요청이 '끝나야' 다음이 출발 (직렬)
|
|
const usd = await fetch(BASE + '/USD').then(function (r) { return r.json(); });
|
|
const eur = await fetch(BASE + '/EUR').then(function (r) { return r.json(); });
|
|
const jpy = await fetch(BASE + '/JPY').then(function (r) { return r.json(); });
|
|
// 요청당 0.3초라면 → 합계 약 0.9초
|
|
|
|
// 방법 B: Promise.all — 셋을 '동시에' 출발시키고 전부 끝나길 기다림 (병렬)
|
|
const [usd2, eur2, jpy2] = await Promise.all([
|
|
fetch(BASE + '/USD').then(function (r) { return r.json(); }),
|
|
fetch(BASE + '/EUR').then(function (r) { return r.json(); }),
|
|
fetch(BASE + '/JPY').then(function (r) { return r.json(); }),
|
|
]);
|
|
// 동시에 달리므로 → 약 0.3초. '가장 느린 요청 하나'만큼만 걸려요.
|
|
|
|
// 서로 의존하지 않는 요청(앞 결과가 뒤 요청에 필요 없는 경우)이라면
|
|
// 병렬이 기본값이어야 합니다. console.time()으로 직접 재 보세요!`;
|
|
|
|
const CODE_ALLSETTLED = `// Promise.all의 성격: 하나라도 실패하면 '전체가' 실패(reject)
|
|
// USD·EUR은 성공했는데 JPY만 실패해도 → 성공한 둘의 결과까지 함께 버려집니다.
|
|
// "전부 있어야만 의미 있는 데이터"라면 이게 오히려 올바른 동작이에요.
|
|
|
|
// 부분 실패를 허용하고 싶다면 → Promise.allSettled
|
|
const results = await Promise.allSettled([pUsd, pEur, pJpy]);
|
|
|
|
results.forEach(function (r) {
|
|
if (r.status === 'fulfilled') {
|
|
console.log('성공:', r.value); // 성공한 것만 화면에 그리고
|
|
} else {
|
|
console.log('실패:', r.reason); // 실패한 통화엔 "조회 실패" 표시
|
|
}
|
|
});
|
|
|
|
// allSettled는 절대 reject되지 않아요 — 성공이든 실패든
|
|
// '전원의 결과 보고서'를 배열로 돌려줍니다.`;
|
|
|
|
const CODE_WIDGET = `// 실습 골격: 로딩·성공·실패 3상태를 갖춘 환율 검색 위젯 (바닐라 JS)
|
|
const box = document.querySelector('#rate-box');
|
|
|
|
async function loadRate(code) {
|
|
box.innerHTML = '<span class="spin"></span> 불러오는 중...'; // 상태 ① 로딩
|
|
try {
|
|
const res = await fetch('https://open.er-api.com/v6/latest/' + code);
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
const data = await res.json();
|
|
const krw = Math.round(data.rates.KRW);
|
|
box.textContent = '1 ' + code + ' = ' + krw + '원'; // 상태 ② 성공
|
|
} catch (err) {
|
|
box.innerHTML = '환율을 불러오지 못했어요. ' +
|
|
'<button id="retry">다시 시도</button>'; // 상태 ③ 실패
|
|
document.querySelector('#retry')
|
|
.addEventListener('click', function () { loadRate(code); }); // 재시도 = 같은 함수 재호출
|
|
}
|
|
}
|
|
|
|
loadRate('USD');
|
|
|
|
// 포인트: 화면은 언제나 로딩·성공·실패 '셋 중 하나'의 상태에 있습니다.
|
|
// 세 상태를 전부 코드로 표현했는지가 '되다 만 위젯'과 '완성된 위젯'의 차이예요.`;
|
|
|
|
const CODE_ABORT = `// AbortController — 이미 보낸 요청에 '그만!'을 외치는 리모컨
|
|
const controller = new AbortController();
|
|
|
|
const promise = fetch(url, { signal: controller.signal }); // 리모컨 신호선 연결
|
|
|
|
// 사용자가 검색 통화를 USD → EUR로 바꿨다면? 이전 요청은 취소
|
|
controller.abort(); // promise가 'AbortError'라는 이름의 에러로 reject됨
|
|
|
|
// ── 응용: 타임아웃 패턴 — 5초 안에 응답 없으면 포기 ──
|
|
const ctrl = new AbortController();
|
|
const timer = setTimeout(function () { ctrl.abort(); }, 5000);
|
|
|
|
try {
|
|
const res = await fetch(url, { signal: ctrl.signal });
|
|
clearTimeout(timer); // 제때 왔으면 타이머 해제
|
|
const data = await res.json();
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') {
|
|
// 느린 서버 — 무한 로딩 대신 "다시 시도" 버튼을 보여 줍시다
|
|
} else {
|
|
// 그 밖의 진짜 실패 (네트워크 끊김 등)
|
|
}
|
|
}`;
|
|
|
|
// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
|
|
// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다(React 입문 섹션 2). props만 바꿔 재사용.
|
|
function Section({ n, title, sub, children }) {
|
|
return (
|
|
<section className="step-card" id={`sec-${n}`}>
|
|
<div className="step-head">
|
|
<span className="step-num">{n}</span>
|
|
<div>
|
|
<h3>{title}</h3>
|
|
{sub && <span className="step-sub">{sub}</span>}
|
|
</div>
|
|
</div>
|
|
<div className="step-body">{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Code({ children }) {
|
|
return <pre className="code-block"><code>{children}</code></pre>;
|
|
}
|
|
|
|
const SECTIONS = [
|
|
{ n: 1, label: '왜 비동기인가' },
|
|
{ n: 2, label: '콜백 지옥' },
|
|
{ n: 3, label: 'Promise 3상태' },
|
|
{ n: 4, label: 'async/await' },
|
|
{ n: 5, label: 'fetch 실전' },
|
|
{ n: 6, label: 'Promise.all' },
|
|
{ n: 7, label: '로딩·취소·타임아웃' },
|
|
];
|
|
|
|
export default function Lv2AsyncJavascriptPage() {
|
|
return (
|
|
<div>
|
|
{/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
|
|
<div className="hero">
|
|
<div className="eyebrow">Course · 중급 과정</div>
|
|
<h1>비동기 JavaScript<br />— fetch와 Promise 정복</h1>
|
|
<p>
|
|
기초 문법을 배웠는데도 "진짜 서비스"가 안 만들어지는 이유의 절반은{' '}
|
|
<strong>비동기</strong>에 있어요. 서버는 언제 대답할지 모르는데, JavaScript는 한 번에
|
|
한 가지 일밖에 못 하니까요. 이 코스에서 콜백 지옥에서 <strong>async/await</strong>까지의
|
|
진화를 따라 걷고, 마지막엔 로딩 스피너·에러 메시지·재시도 버튼을 갖춘{' '}
|
|
<strong>환율 검색 위젯</strong>을 직접 만들어 봅니다.
|
|
</p>
|
|
<div className="chip-row">
|
|
<span className="chip">예상 소요 60분</span>
|
|
<span className="chip">실습: 환율 검색 위젯</span>
|
|
<span className="chip">선수 지식: JS 기초 문법</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 섹션 바로가기 */}
|
|
<nav className="pill-nav">
|
|
{SECTIONS.map((s) => (
|
|
<a key={s.n} href={`#sec-${s.n}`}>
|
|
<b>{s.n}</b>
|
|
{s.label}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
|
|
<Section n={1} title="왜 비동기인가" sub="동기 코드가 화면을 멈추게 하는 순간">
|
|
<p>
|
|
출발점은 체험이에요. 아래 코드를 브라우저 콘솔에 붙여 넣고 실행해 보세요. 함수가 도는
|
|
3초 동안 <strong>클릭도, 스크롤도, 입력도 전부 먹통</strong>이 됩니다. JavaScript는{' '}
|
|
<strong>싱글 스레드</strong> — 일꾼이 한 명뿐이라, 그 일꾼이 제자리에서 기다리면 화면을
|
|
그릴 사람이 없거든요.
|
|
</p>
|
|
<Code>{CODE_FREEZE}</Code>
|
|
<p>
|
|
그런데 웹 서비스의 핵심 일과는 <strong>네트워크 요청</strong>이에요. 서버가 0.1초 만에
|
|
대답할지, 3초 걸릴지, 영영 대답이 없을지 <strong>보내기 전엔 알 수 없죠.</strong> 이
|
|
"언제 끝날지 모르는 일"을 일꾼이 그 자리에서 기다리면 앱은 서버가 느린 만큼 얼어붙어요.
|
|
그래서 JavaScript는 다른 전략을 택합니다 — <strong>기다리지 않고, 맡기는</strong> 거예요.
|
|
</p>
|
|
<Code>{CODE_KITCHEN}</Code>
|
|
<div className="tip">
|
|
<b>이 비유를 기억하세요</b> 요리사(콜 스택)는 한 명, 홀 직원(브라우저)이 기다리는 일을
|
|
대신 맡고, 끝난 일은 주문서 더미(큐)에 쌓였다가 요리사 손이 빌 때 처리된다 — 이{' '}
|
|
<strong>이벤트 루프</strong> 그림 하나면 이 코스의 모든 문법(콜백·Promise·await)이
|
|
"주문서를 어떻게 예쁘게 쓰느냐"의 문제로 보이기 시작합니다.
|
|
</div>
|
|
</Section>
|
|
|
|
<Section n={2} title="콜백과 콜백 지옥" sub="'나중에 실행해 줘'의 가장 원시적인 표현">
|
|
<p>
|
|
"일을 맡겼으면, 끝났을 때 뭘 할지도 알려 줘야" 하죠. 가장 원시적인 방법이{' '}
|
|
<strong>콜백(callback)</strong> — 나중에 불러 달라고 <strong>함수를 인자로 넘기는</strong>{' '}
|
|
방식입니다. 이미 써 보셨을 거예요. <span className="icode">addEventListener</span>의 두
|
|
번째 인자, <span className="icode">setTimeout</span>의 첫 번째 인자가 전부 콜백입니다.
|
|
</p>
|
|
<Code>{CODE_CALLBACK}</Code>
|
|
<p>
|
|
한 단계짜리 일이면 콜백도 충분해요. 문제는 <strong>순차 작업</strong>입니다. "환율을
|
|
받아서 → 그걸로 환전 계산을 하고 → 결과를 기록한다"처럼 앞 결과가 다음 단계에 필요하면,
|
|
콜백 안에 콜백을, 그 안에 또 콜백을 넣을 수밖에 없어요.
|
|
</p>
|
|
<Code>{CODE_HELL}</Code>
|
|
<div className="warn">
|
|
<b>진짜 문제는 들여쓰기가 아니에요</b> 계단 모양은 보기 싫은 정도지만,{' '}
|
|
<strong>에러 처리가 세 군데로 흩어진 것</strong>은 사고로 이어집니다. 단계마다 실패
|
|
콜백을 따로 달아야 하고, 하나라도 깜빡하면 그 에러는 조용히 증발해요. "실패를 한 곳에
|
|
모을 수 없을까?" — 이 질문에 대한 답이 다음 섹션의 Promise입니다.
|
|
</div>
|
|
</Section>
|
|
|
|
<Section n={3} title="Promise의 3가지 상태" sub="pending → fulfilled 또는 rejected, 단 한 번">
|
|
<p>
|
|
<strong>Promise</strong>는 "지금은 결과가 없지만 나중에 꼭 주겠다"는{' '}
|
|
<strong>약속을 담은 객체</strong>예요. 콜백처럼 함수를 미리 찔러 넣는 게 아니라, 일단
|
|
약속 객체를 <strong>손에 받아 두고</strong> 거기에 "성공하면 이거, 실패하면 저거"를
|
|
붙이는 방식이죠. 약속의 일생은 세 상태로 요약됩니다.
|
|
</p>
|
|
<Code>{CODE_PROMISE_STATE}</Code>
|
|
<svg className="diagram" viewBox="0 0 640 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Promise 상태 전이 다이어그램. pending 상태에서 시작해 성공하면 fulfilled, 실패하면 rejected로 단 한 번 전이하며, 한번 정착한 상태는 다시 바뀌지 않는다.">
|
|
<rect x="40" y="80" width="150" height="60" rx="10" fill="var(--card)" stroke="var(--amber)" strokeWidth="2" />
|
|
<text x="115" y="106" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)">pending</text>
|
|
<text x="115" y="126" textAnchor="middle" fontSize="11" fill="var(--muted)">대기 중...</text>
|
|
|
|
<rect x="430" y="24" width="170" height="60" rx="10" fill="var(--card)" stroke="var(--teal)" strokeWidth="2" />
|
|
<text x="515" y="50" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)">fulfilled</text>
|
|
<text x="515" y="70" textAnchor="middle" fontSize="11" fill="var(--muted)">성공 · 결과값 보유 → then</text>
|
|
|
|
<rect x="430" y="136" width="170" height="60" rx="10" fill="var(--card)" stroke="var(--primary)" strokeWidth="2" />
|
|
<text x="515" y="162" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)">rejected</text>
|
|
<text x="515" y="182" textAnchor="middle" fontSize="11" fill="var(--muted)">실패 · 에러 보유 → catch</text>
|
|
|
|
{/* pending → fulfilled */}
|
|
<line x1="190" y1="96" x2="422" y2="60" stroke="var(--teal)" strokeWidth="2" />
|
|
<path d="M430 58 L418 56 L420 66 Z" fill="var(--teal)" />
|
|
<text x="300" y="60" textAnchor="middle" fontSize="11" fill="var(--teal)">성공 (resolve)</text>
|
|
|
|
{/* pending → rejected */}
|
|
<line x1="190" y1="124" x2="422" y2="160" stroke="var(--primary)" strokeWidth="2" />
|
|
<path d="M430 162 L420 154 L418 164 Z" fill="var(--primary)" />
|
|
<text x="300" y="164" textAnchor="middle" fontSize="11" fill="var(--primary)">실패 (reject)</text>
|
|
|
|
<text x="320" y="212" textAnchor="middle" fontSize="12" fill="var(--muted)">전이는 단 한 번 — 정착(settled)한 뒤엔 절대 되돌아가지 않는다</text>
|
|
</svg>
|
|
<p className="muted" style={{ fontSize: 12.5, textAlign: 'center', marginTop: 2 }}>그림: Promise는 pending에서 출발해 fulfilled 또는 rejected 중 하나로 단 한 번 정착한다</p>
|
|
<p>
|
|
약속에 후속 작업을 붙이는 문법이 <span className="icode">then</span>(성공 시)과{' '}
|
|
<span className="icode">catch</span>(실패 시)예요. 그리고{' '}
|
|
<span className="icode">then</span>은 <strong>항상 새 Promise를 반환</strong>하기 때문에,
|
|
콜백 지옥의 계단을 <strong>수평 사슬</strong>로 펼 수 있습니다.
|
|
</p>
|
|
<Code>{CODE_THEN}</Code>
|
|
<div className="tip">
|
|
<b>비유</b> Promise는 <strong>카페 진동벨</strong>이에요. 주문하면(요청) 음료 대신
|
|
진동벨(Promise)을 먼저 받죠. 벨을 손에 쥔 채 자리에서 딴 일을 하다가 — 울리면(fulfilled)
|
|
음료를 받고, 점원이 와서 "재료가 떨어졌어요" 하면(rejected) 환불 처리를 합니다. 벨이
|
|
두 번 다르게 울리는 일은 없어요. <strong>단 한 번, 둘 중 하나.</strong>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section n={4} title="async/await로 다시 쓰기" sub="then 체인을 '위에서 아래로' 읽히게">
|
|
<p>
|
|
then 체인은 훌륭하지만, 로직이 길어지면{' '}
|
|
<span className="icode">function</span>과 <span className="icode">return</span>이
|
|
반복되며 눈이 미끄러져요. 그래서 나온 최종 진화형이{' '}
|
|
<strong>async/await</strong> — 같은 Promise를 <strong>동기 코드처럼 읽히는 모양</strong>
|
|
으로 쓰는 문법입니다. 섹션 3의 체인을 그대로 옮겨 볼게요.
|
|
</p>
|
|
<Code>{CODE_AWAIT}</Code>
|
|
<p>
|
|
짚어 둘 것은 <strong>await가 '문법 설탕(syntactic sugar)'</strong>이라는 점이에요. 새
|
|
동작이 생긴 게 아니라, then 체인과 <strong>같은 일</strong>을 다르게 표기할 뿐입니다.{' '}
|
|
<span className="icode">await</span> 뒤의 Promise가 정착할 때까지 그 함수의 실행이
|
|
멈추는데 — 섹션 1의 <span className="icode">while</span> 루프와 달리{' '}
|
|
<strong>화면은 얼지 않아요.</strong> 함수만 잠들고, 일꾼(콜 스택)은 풀려나 다른 주문서를
|
|
처리하러 가거든요.
|
|
</p>
|
|
<p>
|
|
에러 처리는 우리가 아는 <span className="icode">try/catch</span> 그대로입니다.{' '}
|
|
<span className="icode">try</span> 블록 안 <strong>어느 await에서 실패해도</strong> 실행이{' '}
|
|
<span className="icode">catch</span>로 점프해요. 콜백 지옥에서 세 군데로 흩어졌던 에러
|
|
처리가, 여기선 익숙한 문법 한 곳으로 모입니다.
|
|
</p>
|
|
<div className="warn">
|
|
<b>자주 하는 실수</b> <span className="icode">await</span>를{' '}
|
|
<span className="icode">async</span> 없는 보통 함수 안에 쓰면 문법 에러가 납니다. 그리고{' '}
|
|
<span className="icode">async</span> 함수의 반환값은 <strong>항상 Promise</strong>예요 —{' '}
|
|
<span className="icode">return 42</span>를 해도 밖에서는{' '}
|
|
<span className="icode">Promise</span>로 감싸져 나오니, 호출하는 쪽에서도{' '}
|
|
<span className="icode">await</span>가 필요합니다. "async는 전염된다"고 기억해 두세요.
|
|
</div>
|
|
</Section>
|
|
|
|
<Section n={5} title="fetch로 진짜 API 호출" sub="response.ok 확인은 선택이 아니라 습관">
|
|
<p>
|
|
이제 도구가 갖춰졌으니 <strong>진짜 서버</strong>와 대화해 봅시다.{' '}
|
|
<span className="icode">fetch</span>는 브라우저에 내장된 HTTP 요청 함수로, Promise를
|
|
돌려줘요. 구조에서 헷갈리기 쉬운 건 <strong>await가 두 번</strong> 나온다는 점입니다 —
|
|
① 응답(헤더)이 도착하길 기다리고, ② 본문이 다 내려와 해석되길 또 기다립니다.
|
|
</p>
|
|
<Code>{CODE_FETCH}</Code>
|
|
<p>
|
|
주석의 함정을 꼭 기억하세요. <span className="icode">fetch</span>는{' '}
|
|
<strong>404나 500 같은 에러 응답에도 reject되지 않습니다.</strong> 서버가 "실패"라고
|
|
대답한 것과, 대답 자체가 안 온 것(네트워크 단절)을 구분하기 때문이에요. HTTP 수준의
|
|
실패를 잡으려면 <span className="icode">res.ok</span>(상태 코드 200~299면 true)를 확인해
|
|
직접 <span className="icode">throw</span>해야, 섹션 4에서 만든{' '}
|
|
<span className="icode">catch</span> 그물에 걸립니다.
|
|
</p>
|
|
<p>
|
|
데이터를 <strong>보내는</strong> 쪽도 옵션 객체 하나로 해결돼요. 메서드, 헤더, 본문을
|
|
지정하면 POST 요청이 됩니다.
|
|
</p>
|
|
<Code>{CODE_POST}</Code>
|
|
<div className="tip">
|
|
<b>직접 확인해 보기</b> 브라우저 콘솔에서{' '}
|
|
<span className="icode">await fetch('https://open.er-api.com/v6/latest/USD').then(r => r.json())</span>
|
|
를 실행해 보세요. 공개 환율 API라 키 없이도 응답이 옵니다.{' '}
|
|
<span className="icode">rates.KRW</span>가 오늘의 원/달러 환율이에요 — 섹션 7 실습에서 이
|
|
API를 그대로 씁니다. <span className="kbd">F12</span> → Network 탭에서 방금 나간 요청의
|
|
상태 코드와 응답 본문도 눈으로 확인해 보세요.
|
|
</div>
|
|
</Section>
|
|
|
|
<Section n={6} title="병렬 처리: Promise.all" sub="순차 await의 숨은 낭비를 걷어내기">
|
|
<p>
|
|
await가 편하다 보니 생기는 <strong>흔한 낭비</strong>가 있어요. 서로 상관없는 요청
|
|
세 개를 순서대로 <span className="icode">await</span>하면, 앞 요청이 끝나야 다음이{' '}
|
|
<strong>출발</strong>합니다. 달러·유로·엔 환율은 서로를 필요로 하지 않는데도요.
|
|
</p>
|
|
<Code>{CODE_PARALLEL}</Code>
|
|
<p>
|
|
<span className="icode">Promise.all</span>은 Promise 배열을 받아{' '}
|
|
<strong>전부 fulfilled되면</strong> 결과 배열을 (넣은 순서 그대로) 돌려줍니다. 단,
|
|
성격이 확실해요 — <strong>하나라도 rejected되면 전체가 즉시 reject</strong>됩니다.
|
|
"전원 성공 아니면 전체 실패"죠. 부분 실패를 살리고 싶을 땐{' '}
|
|
<span className="icode">Promise.allSettled</span>를 씁니다.
|
|
</p>
|
|
<Code>{CODE_ALLSETTLED}</Code>
|
|
<div className="tip">
|
|
<b>고르는 기준</b> 결과들이 <strong>한 몸</strong>이라 하나라도 빠지면 의미가 없다 →{' '}
|
|
<span className="icode">all</span>(빨리 실패하고 전체 재시도). 결과들이{' '}
|
|
<strong>각자 독립</strong>이라 되는 것만이라도 보여 주고 싶다 →{' '}
|
|
<span className="icode">allSettled</span>(통화별로 성공/실패 표시). 문법이 아니라{' '}
|
|
<strong>화면 요구사항</strong>이 답을 정합니다.
|
|
</div>
|
|
</Section>
|
|
|
|
<Section n={7} title="실전 패턴: 로딩·취소·타임아웃" sub="환율 위젯을 '완성품'으로 만드는 마지막 조각">
|
|
<p>
|
|
문법을 다 배워도 실전 코드엔 한 층이 더 있어요. 요청이 나가 있는 동안 사용자는{' '}
|
|
<strong>뭔가를 보고 있어야</strong> 하고, 실패하면 <strong>다음 행동</strong>(재시도)을
|
|
제안받아야 하죠. 그래서 서버와 대화하는 화면은 언제나{' '}
|
|
<strong>로딩 · 성공 · 실패</strong>라는 3상태 중 하나에 있습니다. 이번 코스의 실습
|
|
— 환율 검색 위젯의 골격이 바로 이 3상태 관리예요.
|
|
</p>
|
|
<Code>{CODE_WIDGET}</Code>
|
|
<p>
|
|
여기에 실무 감각 두 가지를 더하면 완성입니다. 첫째, <strong>취소</strong>. 사용자가
|
|
검색을 연타하면 이전 요청은 의미가 없어지는데, 늦게 도착한 옛 응답이 새 화면을 덮어쓰는
|
|
사고가 나요. 둘째, <strong>타임아웃</strong>. 서버가 영영 대답하지 않으면 로딩 스피너가
|
|
무한히 돕니다. 둘 다 <span className="icode">AbortController</span> 하나로 해결돼요.
|
|
</p>
|
|
<Code>{CODE_ABORT}</Code>
|
|
<div className="warn">
|
|
<b>AbortError는 '실패'가 아닐 수 있어요</b> 사용자가 검색어를 바꿔서{' '}
|
|
<strong>우리가 스스로</strong> 취소한 요청도 catch로 들어옵니다. 그걸 "에러 발생!"으로
|
|
표시하면 이상하겠죠. 그래서 catch 안에서{' '}
|
|
<span className="icode">err.name === 'AbortError'</span>를 구분하는 습관이 필요합니다 —
|
|
의도한 취소는 조용히, 진짜 실패만 에러 메시지로.
|
|
</div>
|
|
<div className="tip">
|
|
<b>실습 과제</b> 위 두 코드를 합쳐 보세요. 통화 코드 입력창 + 검색 버튼을 만들고 →
|
|
검색 시 로딩 스피너 → 성공하면 환율 표시 → 실패하면 에러 메시지와 재시도 버튼 →
|
|
5초 타임아웃까지. 완성하면 여러분은 <strong>실제 서비스의 데이터 화면</strong>과 똑같은
|
|
구조를 처음부터 끝까지 만들어 본 겁니다.
|
|
</div>
|
|
</Section>
|
|
|
|
<div className="step-card">
|
|
<div className="step-body">
|
|
<h3 style={{ marginBottom: 6 }}>⏳ 여기까지 왔다면</h3>
|
|
<p className="muted">
|
|
이제 "서버와 대화하는 코드"의 전체 지형이 손에 있어요. 이벤트 루프(주방), 콜백 지옥이
|
|
남긴 교훈, Promise의 3상태(진동벨), async/await라는 문법 설탕, fetch의 2단계와{' '}
|
|
<strong>res.ok 습관</strong>, Promise.all의 병렬 감각, 그리고 로딩·취소·타임아웃까지 —
|
|
환율 위젯 하나에 전부 담아 봤습니다. 그런데 서버에서 받아 온 데이터는 대부분{' '}
|
|
<strong>배열</strong>이에요. 받아 온 환율 목록을 골라내고(filter), 바꾸고(map),
|
|
합치는(reduce) 손맛이 다음 관문입니다. 다음{' '}
|
|
<Link to="/learn/lv2-array-data-wrangling"><strong>배열 메서드로 데이터 요리하기</strong></Link>{' '}
|
|
코스에서, fetch로 받아 온 데이터를 화면에 올리기 좋게 다듬는 기술을 이어서 익혀 봅시다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|