E-2: 에러 핸들링 & 디버깅 — 실패를 구조화하고, 빠르게 진단하기
목차 38
안녕하세요, 홍순구 튜터입니다. 지난 시간(E-1)에 우리는 클래스를 배우면서, PostCard를 상속받아 광고 카드 AdPostCard를 만들었죠. extends(확장하다) 한 단어로 부모의 능력을 통째로 물려받고, 다른 부분만 고쳐 적는 그 상속이요. 그때 제가 마무리에서 살짝 흘린 말이 있어요. "그 extends가 다음 시간에 아주 실용적인 곳에서 다시 등판한다"고요.
오늘이 그날이에요. 이번엔 부모가 PostCard가 아니라 Error 예요. JavaScript가 기본으로 주는 에러 객체를 상속받아서, 우리만의 에러 클래스를 만들어요. 왜 그래야 하냐면 — 지금 우리 api.js가 실패를 너무 뭉뚱그려 던지고 있거든요.
D-4에서 fetch가 실패하면 throw new Error(설명문자열)로 던지게 해뒀어요. 그런데 받는 쪽에서 보면 "뭔가 실패했다"는 것만 알지, 상태 코드가 404인지 500인지, 어떤 요청이 실패했는지를 알 길이 없어요. 그래서 "404면 이렇게, 401이면 저렇게" 같은 분기를 짤 수가 없죠. 오늘은 그 실패를 체계적으로 담아 던지고, 받는 쪽에서 똑똑하게 처리하는 법을 배워요.
지난 시간 (E-1) 오늘 (E-2)
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ class AdPostCard │ │ class ApiError │
│ extends PostCard │ ──▶ │ extends Error │
│ 부모: 우리가 만든 카드 │ │ 부모: JS 기본 에러 객체 │
│ throw new Error(문자열) │ │ throw new ApiError(코드…) │
└─────────────────────────────┘ └─────────────────────────────┘
그리고 도구도 한 단계 깊어져요. D-5에서 익힌 DevTools 중단점, 기억하시죠? 줄 번호를 클릭해 멈춰 세우던 그거요. 오늘은 "조건이 맞을 때만 멈추는" 조건부 중단점과, 느린 네트워크·오프라인을 흉내 내는 Network Throttling까지 배워요. 에러를 만드는 쪽과 잡는 쪽, 양쪽을 한꺼번에 끌어올리는 시간이에요.
💡 오늘 수업의 핵심 — "실패를 뭉뚱그려 던지지 말고, 상태 코드·요청 주소·사용자 메시지를 담은 ApiError로 구조화해 던진다. 받는 쪽은 instanceof로 종류를 가려 처리한다. 그리고 조건부 중단점·네트워크 스로틀링으로 에러를 빠르게 재현하고 추적한다." 🎯
🎯 학습 목표
try-catch-finally로 실패할 수 있는 코드를 감싸고, 성공·실패와 무관하게 뒷정리하는 흐름을 익힙니다.- 표준
Error객체의name·message·stack을 읽고,TypeError도Error의 자식임을 확인합니다. class ApiError extends Error로 커스텀 에러를 만들어, 개발자용message와 사용자용userMessage를 구분합니다.api.js를 리팩토링해 실패를ApiError로 던지고, 받는 쪽에서instanceof로 가려 처리합니다.- DevTools의 조건부 중단점으로 특정 상황에서만 코드를 멈춰 세웁니다.
- Network Throttling으로 느린 네트워크·오프라인을 흉내 내, 에러·로딩 동작을 직접 관찰합니다.
- 개발 환경과 프로덕션 환경에서 에러 로깅 전략이 어떻게 달라지는지 이해합니다.
Step 1: "왜 에러를 구조화하나?" — 지금 api.js의 한계
새 코드를 짜기 전에, 먼저 "지금 뭐가 불편한가"부터 몸으로 느껴볼게요. 우리 api.js는 D-4부터 서버와 데이터를 주고받았어요. 게시물을 받아오고(fetchPosts), 댓글을 저장하고(createComment). 그 두 함수 안에는 실패를 대비한 코드가 이미 들어 있었죠.
지금까지의 실패 처리 — 한 줄로 뭉뚱그리기
D-4 시절의 fetchPosts는 이런 모습이었어요. 응답이 성공(200번대)이 아니면, 짧은 설명 문자열 하나를 담아 던졌어요.
// (D-4 시절의 모습 — 오늘 이걸 바꿔요)
if (!response.ok) {
throw new Error(describeStatus(response.status));
}
describeStatus는 상태 코드 숫자를 사람이 읽을 한 줄로 바꿔주는 함수예요. 이 함수는 오늘도 그대로 써요. 코드베이스 api.js에 들어 있어요.
// instagram-clone-frontend/js/api.js
// 상태 코드(숫자)를 사람이 읽을 수 있는 한 줄 메시지로 바꿔요.
// 200번대(성공)가 아닐 때, 화면에 보여줄 말을 골라요.
function describeStatus(status) {
if (status >= 500) return "서버에 문제가 생겼어요. 잠시 후 다시 시도해 주세요.";
if (status === 404) return "찾는 데이터가 없어요.";
if (status >= 400) return "요청에 문제가 있어요.";
return `알 수 없는 오류예요 (${status}).`;
}
여기까지는 좋아요. 문제는 던질 때예요. throw new Error(describeStatus(...))로 던지면, 받는 쪽 손에 들어오는 건 사람이 읽을 문장 하나뿐이에요. "찾는 데이터가 없어요"라는 글자는 있는데, 정작 그게 404인지, 어떤 주소를 부르다 실패했는지는 어디에도 안 담겨 있죠.
비유하면 이래요. 택배가 반송됐는데 상자에 "배달 실패"라고만 적혀 있는 거예요. 주소가 틀린 건지(404), 받는 사람이 부재중인지(다른 이유), 물류센터에 불이 난 건지(500) 알 수가 없어요. 그러면 무엇을 다시 시도해야 할지 판단을 못 하죠. 받는 쪽이 똑똑하게 행동하려면, 반송 사유가 코드로 구조화돼 상자에 붙어 있어야 해요.
try-catch-finally — 실패를 감싸는 안전망
그러면 던진 에러는 누가 받을까요? 우리 feed.js의 loadPage 함수예요. 거기엔 이미 try-catch-finally라는 안전망이 쳐져 있어요. 본격적으로 에러를 구조화하기 전에, 이 세 글자의 뜻부터 짚고 갈게요.
// instagram-clone-frontend/js/feed.js — loadPage() 의 뼈대
async function loadPage() {
const loadingBox = showLoading();
try {
const result = await fetchPosts(currentPage); // 실패할 수 있는 코드
// ...성공했을 때 화면에 그리기...
} catch (error) {
// ...실패했을 때 — 에러를 잡아서 처리...
} finally {
loadingBox.remove(); // 성공이든 실패든 스피너는 치워요
}
}
세 부분의 역할을 나눠볼게요.
try { }— "여기 안의 코드는 실패할 수도 있어요"라고 표시하는 구역이에요.fetchPosts가 던진 에러는 이 구역 밖으로 튀어나가지 않고, 바로 아래catch로 흘러가요.catch (error) { }—try안에서 에러가 나면 여기로 와요. 던져진 에러 객체가error라는 이름으로 손에 들어오죠. 화면이 멈추지 않고, 우리가 정한 방식으로 실패를 처리할 수 있어요.finally { }— 성공하든 실패하든 무조건 마지막에 한 번 실행돼요. 여기선 로딩 스피너를 치우는 일을 맡았어요. 성공했어도 스피너는 사라져야 하고, 실패했어도 사라져야 하니까요.
finally가 왜 따로 필요한지 잠깐 짚을게요. 만약 스피너 제거를 try 끝에만 적으면, 중간에 에러가 나서 catch로 빠지는 순간 그 줄을 건너뛰어요. 그러면 로딩 스피너가 영원히 돌게 되죠. finally는 "어느 길로 가든 반드시 들르는 정거장"이라, 뒷정리에 딱이에요.
fetchPosts(currentPage)
│
┌─────────┴─────────┐
▼ ▼
성공 (200) 실패 (throw)
│ │
try 나머지 실행 catch (error) 로 점프
│ │
└─────────┬─────────┘
▼
finally { } ← 어느 길로 왔든 반드시 들름 (스피너 제거)
자, 안전망은 갖춰져 있어요. 그런데 catch로 잡은 에러가 "글자 한 줄"뿐이라, 받아도 할 수 있는 게 별로 없다는 게 오늘의 출발점이에요. 이제부터 그 에러를 똑똑하게 만들어볼게요.
💡
try는 실패할 수 있는 코드를 감싸고,catch는 던져진 에러를 잡고,finally는 성공·실패와 무관하게 뒷정리를 해요. 지금api.js는 실패를 글자 한 줄로 뭉뚱그려 던져서, 받는 쪽이 "뭐가, 왜" 실패했는지 알 수 없는 게 한계예요.
Step 2: "에러도 그냥 객체다" — 표준 Error 톺아보기
커스텀 에러를 만들기 전에, 부모가 될 표준 Error부터 뜯어볼게요. "에러"라고 하면 뭔가 무섭게 들리지만, 정체를 알고 나면 별거 아니에요. 에러는 그냥 "에러 정보를 담은 객체" 예요. E-1에서 배운 그 객체요. new로 만들 수도 있고, 속성을 꺼내 읽을 수도 있어요.
E-1에서 콘솔 데모용으로 쓰던 js/class-demo.js, 기억하시죠? DOM을 안 건드리고 콘솔만 출력해서 node js/class-demo.js로도 돌려볼 수 있던 그 파일이요. 거기 맨 끝에 E-2용 섹션을 이어 붙였어요. 한 줄씩 따라가 볼게요.
name · message · stack — 에러 객체의 세 가지 정보
// instagram-clone-frontend/js/class-demo.js
// 커스텀 에러를 만들기 전에, JavaScript 가 기본으로 주는 Error 부터 뜯어봐요.
// Error 는 그냥 "에러 정보를 담은 객체"예요. new 로 만들 수 있어요(아직 던지진 않았어요).
const basicError = new Error("무언가 잘못됐어요");
console.log("name:", basicError.name); // "Error" — 에러의 종류 이름
console.log("message:", basicError.message); // "무언가 잘못됐어요" — 우리가 넣은 설명
console.log("stack:", basicError.stack); // 어디서 났는지 추적 (형식은 환경마다 달라요)
new Error("무언가 잘못됐어요")로 에러 객체를 하나 만들었어요. 여기서 중요한 건 — 만들기만 했지 아직 던지지(throw) 않았어요. 에러를 만드는 것과 던지는 건 별개예요. 만든 객체에는 세 가지 정보가 들어 있어요.
name— 에러의 종류 이름이에요. 기본은"Error". 콘솔에 찍힐 때Error: 무언가 잘못됐어요처럼 맨 앞에 붙는 그 글자죠.message—new Error(...)의 괄호 안에 넣은 설명이에요. 우리가 적은 "무언가 잘못됐어요"가 여기 담겨요.stack— 이 에러가 어느 파일, 몇 번째 줄에서 났는지 추적 정보예요. 디버깅할 때 "범인이 여기 있다"고 알려주는 발자국이죠. 형식은 브라우저·환경마다 조금씩 달라요.
TypeError도 Error의 자식이다
JavaScript에는 종류별 에러가 여럿 있어요. TypeError(타입이 틀렸을 때), SyntaxError(문법이 깨졌을 때) 같은 거요. 그런데 이것들이 전부 Error의 자식 이에요. E-1에서 배운 상속, 바로 그 관계예요.
// 종류별 에러도 다 Error 의 자식이에요 — name 만 다르고 구조는 같아요.
const typeError = new TypeError("숫자가 와야 하는데 글자가 왔어요");
console.log(typeError.name); // "TypeError"
console.log(typeError instanceof Error); // true — TypeError 도 결국 Error
typeError.name은 "TypeError"로 다르지만, typeError instanceof Error는 true 예요. E-1 Step 7에서 배운 instanceof(~의 인스턴스인가) 기억나시죠? 프로토타입 체인을 거슬러 올라가며 확인하는 그 연산자요. TypeError도 결국 체인을 타고 올라가면 Error가 나와요. 그래서 "모든 에러는 Error다"라고 말할 수 있는 거예요.
이게 왜 중요하냐면 — 잠시 후 우리가 만들 ApiError도 Error를 상속하니까, apiError instanceof Error가 true가 돼요. 그래서 표준 에러처럼 똑같이 던지고 잡을 수 있죠. 우리 커스텀 에러가 "정식 에러 가문의 일원"으로 인정받는 셈이에요.
cause — 진짜 원인을 매달아 두기
마지막으로 비교적 최근(ES2022, 2022년 표준)에 추가된 cause(원인)를 볼게요. 에러를 다시 던질 때, "이 에러를 일으킨 진짜 원인"을 함께 매달아두는 기능이에요.
// ES2022: cause — "이 에러를 일으킨 진짜 원인"을 체인으로 매달아요.
// 바깥 에러는 사람이 읽을 메시지, cause 는 디버깅용 원본 에러를 품어요.
try {
try {
JSON.parse("{ 깨진 JSON }"); // 여기서 SyntaxError 가 나요
} catch (parseError) {
// 원본(parseError)을 cause 로 감싸 다시 던져요.
throw new Error("프로필을 불러오지 못했어요", { cause: parseError });
}
} catch (error) {
console.log("바깥 메시지:", error.message); // 프로필을 불러오지 못했어요
console.log("원인(cause):", error.cause.name); // "SyntaxError" — 진짜 원인이 남아 있어요
}
흐름을 따라가 볼게요. 안쪽에서 JSON.parse가 깨진 JSON을 만나 SyntaxError를 던져요. 그걸 잡은 다음, 우리는 사용자에게 보여줄 부드러운 메시지("프로필을 불러오지 못했어요")로 새 에러를 던지되, 원본 SyntaxError를 { cause: parseError }로 매달아요.
이러면 바깥에서는 두 가지를 다 가질 수 있어요. error.message로는 사용자에게 보여줄 친절한 말을, error.cause로는 "진짜 범인은 SyntaxError였다"는 디버깅 단서를요. 친절함과 추적 가능성, 둘 다 챙기는 거죠. 이 "사용자용 메시지 따로, 개발자용 정보 따로"라는 생각이 바로 다음 Step에서 만들 ApiError의 핵심 아이디어예요.
💡 에러는 무서운 게 아니라 그냥 객체예요 —
name(종류) ·message(설명) ·stack(어디서 났나)을 담죠.TypeError도Error의 자식이라instanceof Error가 true. ES2022의cause는 원본 에러를 매달아, 사용자용 메시지와 개발자용 원인을 함께 챙기는 길을 열어줘요.
Step 3: "나만의 에러 만들기" — class ApiError extends Error
이제 주인공을 만들 차례예요. 표준 Error는 message 하나만 담을 수 있어서, "상태 코드 404", "이 주소를 부르다 실패"처럼 우리가 원하는 정보를 더 담지 못해요. 그래서 Error를 상속받아, 우리 입맛에 맞는 정보를 더한 에러 클래스를 만들어요. 이름은 ApiError(API 에러)예요.
E-1에서 AdPostCard가 PostCard를 상속받았던 그 패턴 그대로예요. 이번엔 부모만 바뀌었을 뿐이죠 — 우리가 만든 카드가 아니라, JavaScript가 기본으로 주는 Error가 부모예요. 새 파일 js/api-error.js를 만들어요. 전체가 짧으니 통째로 볼게요.
// instagram-clone-frontend/js/api-error.js
// E-2: API 요청이 실패했을 때 "뭐가, 왜" 실패했는지 담는 나만의 에러 클래스예요.
// 표준 Error 를 상속(extends)해서, 거기에 상태 코드·요청 주소·사용자용 메시지를 더해요.
// (상속은 E-1 에서 PostCard → AdPostCard 로 이미 해 봤죠. 이번엔 부모가 Error 예요.)
export class ApiError extends Error {
// statusCode: 서버가 돌려준 HTTP 상태 코드 (404, 500 …)
// url: 어떤 요청이 실패했는지 (개발자가 추적할 때 써요)
// userMessage: 화면(토스트)에 보여줄 사람 친화 메시지
constructor(statusCode, url, userMessage) {
// super 로 부모(Error)를 먼저 깨워요. 여기 넣은 문자열이 error.message 가 돼요.
// message 는 "개발자용" — 상태 코드와 주소를 담아 콘솔에서 바로 알아보게 해요.
super(`API ${statusCode} 응답 — ${url}`);
this.name = "ApiError"; // 콘솔에 "ApiError: ..." 로 찍혀요 (그냥 Error 아님)
this.statusCode = statusCode; // 분기 처리에 써요 (예: 401 이면 다시 로그인)
this.url = url;
this.userMessage = userMessage; // 사용자에게 보여줄 한 줄
}
}
한 줄씩 뜯어볼게요.
extends Error — "Error의 모든 것을 물려받는 새 클래스"라는 선언이에요. 덕분에 ApiError로 만든 객체도 name · message · stack을 다 갖게 되고, instanceof Error도 true가 돼요. 표준 에러처럼 던지고 잡을 수 있는 정식 에러가 되는 거죠.
super(...) — E-1에서 배운 그 super예요. 자식 constructor에서 this를 건드리기 전에 반드시 부모를 먼저 깨워야 한다고 했죠. 여기서는 super(\API ${statusCode} 응답 — ${url}`)로 부모 Error를 불러요. 이 괄호 안에 넣은 문자열이 곧 error.message가 돼요. 그래서 message에는 "API 404 응답 — http://localhost:3001/posts..."`처럼 개발자가 콘솔에서 바로 알아볼 정보를 담아요.
그다음 네 줄이 우리가 더하는 추가 정보예요.
this.name = "ApiError"— 콘솔에 찍힐 때ApiError: ...로 나오게 해요. 그냥Error가 아니라 "우리 API에서 난 에러구나"를 한눈에 알 수 있죠.this.statusCode— 404인지 500인지 401인지, 숫자 그대로 담아둬요. 받는 쪽이 "401이면 다시 로그인" 같은 분기를 짤 수 있는 핵심 재료예요.this.url— 어떤 주소를 부르다 실패했는지. 개발자가 추적할 때 써요.this.userMessage— 화면(토스트)에 보여줄 사람 친화 메시지예요.
message vs userMessage — 오늘의 핵심 한 줄
여기서 오늘 가장 중요한 설계 결정이 나와요. 에러가 메시지를 두 개 갖고 있다는 점이에요.
하나의 ApiError 객체
┌──────────────────────────────────────────────────────┐
│ message: "API 500 응답 — .../posts?_page=2" │ ← 개발자용 (콘솔·로그)
│ 상태 코드·주소가 그대로 → 추적에 유리 │
├──────────────────────────────────────────────────────┤
│ userMessage: "서버에 문제가 생겼어요. 잠시 후..." │ ← 사용자용 (화면 토스트)
│ 부드러운 한 줄 → 사용자를 안심시킴 │
└──────────────────────────────────────────────────────┘
왜 굳이 두 개로 나눌까요? 사용자에게 "API 500 응답 — http://localhost:3001/posts?_page=2"라고 보여주면 어떨까요? 깜짝 놀라겠죠. 무슨 말인지도 모르고, 우리 서버 주소까지 그대로 노출돼요. 반대로 개발자에게 "서버에 문제가 생겼어요"만 보여주면, 정작 디버깅에 필요한 상태 코드·주소가 없어 답답하고요.
그래서 같은 에러 하나에 두 얼굴을 담아요. message는 개발자가 콘솔에서 추적할 정보, userMessage는 사용자가 화면에서 볼 안심 메시지. 이 둘을 나누는 순간, 받는 쪽에서 "누구에게 무엇을 보여줄지"를 깔끔하게 가를 수 있어요. 이 이분법이 Step 8의 로깅 전략까지 그대로 이어지니, 꼭 기억해두세요.
💡
class ApiError extends Error로 표준 에러를 상속받아,statusCode·url·userMessage를 더했어요.super(...)에 넣은 개발자용 문자열은message가 되고,userMessage는 사용자에게 보여줄 부드러운 한 줄이에요. 한 에러에 개발자용·사용자용 두 얼굴을 담는 게 오늘의 설계 핵심이에요.
Step 4: "이제 ApiError로 던지자" — api.js 리팩토링
부모(ApiError)를 만들었으니, 이제 api.js가 실패할 때 이걸로 던지게 바꿔요. D-4 시절의 throw new Error(...)를 throw new ApiError(...)로 갈아끼우는 작업이에요. E-1에서 renderPost를 PostCard로 옮길 때처럼, 화면 동작은 그대로예요. 바뀌는 건 실패가 났을 때 던지는 상자의 내용물뿐이죠.
먼저 파일 맨 위에 ApiError를 가져와요. E-1에서 익힌 import로요.
// instagram-clone-frontend/js/api.js
import { getToken, getCurrentUser } from "./auth.js";
import { ApiError } from "./api-error.js";
이제 두 함수의 던지는 줄을 바꿔요. 먼저 게시물을 받아오는 fetchPosts예요.
// instagram-clone-frontend/js/api.js — fetchPosts() 의 실패 처리
export async function fetchPosts(page = 1, perPage = 3) {
const url = `${BASE_URL}/posts?_page=${page}&_per_page=${perPage}`;
const response = await fetch(url, {
headers: authHeaders(),
signal: AbortSignal.timeout(8000),
});
if (!response.ok) {
// 200번대가 아니면(404·500 등) 에러를 위로 던져요. 부르는 쪽이 토스트로 알려요.
// 상태 코드·주소를 ApiError 에 담아 던지면, 받는 쪽이 "뭐가, 왜"로 분기할 수 있어요.
throw new ApiError(response.status, url, describeStatus(response.status));
}
return await response.json(); // { data, next, last, pages, items, ... }
}
핵심은 if (!response.ok) 안의 한 줄이에요. 예전엔 throw new Error(describeStatus(...))로 글자만 던졌는데, 이제는 throw new ApiError(response.status, url, describeStatus(response.status))로 던져요.
세 가지를 한 번에 담은 거예요. response.status(상태 코드 숫자), url(실패한 주소), 그리고 describeStatus(...)(사용자에게 보여줄 한 줄). Step 1에서 봤던 describeStatus 함수가 여기서 세 번째 인자인 userMessage로 쏙 들어가죠. 던지는 순간, "뭐가(상태 코드), 어디서(주소), 사용자에겐 뭐라고(메시지)"가 전부 상자에 담겨요.
다음은 댓글을 저장하는 createComment예요. 여기엔 작은 손질이 하나 더 있어요.
// instagram-clone-frontend/js/api.js — createComment()
export async function createComment(postId, text) {
const username = getCurrentUser() ?? "guest"; // 로그인 안 했으면 guest
const url = `${BASE_URL}/comments`;
const response = await fetch(url, {
method: "POST", // 1) 생성이니까 POST
headers: authHeaders({ "Content-Type": "application/json" }), // 2) JSON + 토큰
body: JSON.stringify({ postId, username, text }), // 3) 객체 → JSON 문자열
});
if (!response.ok) {
throw new ApiError(response.status, url, describeStatus(response.status));
}
return await response.json(); // 서버가 id 를 붙여 돌려줘요 (201 Created)
}
여기서 작은 변화 하나를 짚을게요. 예전엔 fetch 안에 주소를 직접 적었는데, 이제 const url = \${BASE_URL}/comments`;로 **변수를 따로 빼냈어요**. 왜냐하면 그래야 fetch에도, 실패 시 ApiError에도 같은 url`을 넘길 수 있거든요. 주소를 두 군데에 따로 적으면 한쪽만 고쳤을 때 어긋날 위험이 있어요. 한 곳에 적어 두 군데서 쓰는 게 안전해요.
두 함수 모두 똑같은 모양으로 던지죠? if (!response.ok) { throw new ApiError(...) }. 이렇게 던지는 쪽의 규칙을 통일해두면, 받는 쪽도 한 가지 패턴으로 처리할 수 있어요. 그 "받는 쪽"이 바로 다음 Step이에요.
json-server와 Live Server를 켜고 feed.html을 열어보세요. 게시물도, 무한 스크롤도, 댓글도 전부 예전 그대로 잘 동작해요. 정상 흐름에서는 throw가 일어나지 않으니까요. 화면은 똑같은데 내부가 구조화됐다 — E-1의 리팩토링과 같은 정신이에요. 진짜 효과는 실패가 났을 때 다음 Step에서 드러나요.
💡
throw new Error(문자열)을throw new ApiError(상태코드, url, 사용자메시지)로 바꿨어요. 두 함수 모두 같은 모양으로 던져서, 받는 쪽이 한 패턴으로 처리할 수 있게 했죠.createComment의url을 변수로 뺀 건, fetch와 ApiError가 같은 주소를 쓰게 하려는 안전장치예요.
Step 5: "받는 쪽은 instanceof로 가린다" — catch 전략
던지는 쪽을 똑똑하게 만들었으니, 받는 쪽도 똑똑해질 차례예요. feed.js의 loadPage로 돌아가서, catch 안을 채워볼게요. Step 1에서 봤던 그 안전망에, 이제 진짜 처리 로직이 들어가요.
// instagram-clone-frontend/js/feed.js — loadPage() 의 try-catch-finally
async function loadPage() {
const loadingBox = showLoading();
try {
const result = await fetchPosts(currentPage); // { data, next, ... }
result.data.forEach((post, index) => {
const card = index === 2 ? new AdPostCard(post) : new PostCard(post);
feedMain.insertBefore(card.render(), sentinel); // 감시병 위에 차례로
});
hasMore = result.next !== null; // next 가 null 이면 마지막 페이지
currentPage += 1;
} catch (error) {
if (error instanceof ApiError) {
// 사용자에겐 친화 메시지만 보여줘요. (상태 코드·주소 같은 속사정은 감춰요)
showToast(error.userMessage);
// 개발자에겐 전체를 콘솔에 남겨요. message(상태·주소) + stack 까지 다 찍혀요.
// 운영 환경이라면 이 자리에서 { statusCode, userMessage } 만 추려 로그 서버로 보내요.
console.error("게시물 로딩 실패:", error);
} else {
// ApiError 가 아닌 예상 밖의 에러(코드 버그 등)는 따로 처리해요.
showToast("알 수 없는 오류가 발생했어요.");
console.error(error);
}
} finally {
loadingBox.remove(); // 성공이든 실패든 스피너는 치워요
}
}
catch 안의 첫 줄이 오늘의 핵심이에요. if (error instanceof ApiError). Step 2에서 배운 instanceof로 "이 에러가 우리가 만든 ApiError인가?"를 물어요. 답이 갈리는 데는 분명한 이유가 있어요.
ApiError일 때 — 두 얼굴을 각각 쓴다
error instanceof ApiError가 true면, 우리가 예상한 실패예요. 서버가 404·500을 돌려줬거나, 요청에 문제가 있던 경우죠. 이때 Step 3에서 담아둔 두 얼굴이 제 역할을 해요.
showToast(error.userMessage)— 사용자에게는userMessage만 보여줘요. "서버에 문제가 생겼어요. 잠시 후 다시 시도해 주세요" 같은 부드러운 한 줄이요. 상태 코드나 주소 같은 속사정은 감춰요.console.error("게시물 로딩 실패:", error)— 개발자에게는 에러 객체 전체를 콘솔에 남겨요.message(상태·주소)도,stack(어디서 났나)도 다 찍히죠.
같은 에러 하나를 받아서, 사용자에게는 친절하게, 개발자에게는 상세하게 — 두 갈래로 갈라 처리하는 거예요. message와 userMessage를 나눠둔 보람이 여기서 나와요.
ApiError가 아닐 때 — 예상 밖의 에러
else 가지는 ApiError가 아닌 에러를 위한 거예요. 예를 들어 우리 코드 어딘가에 오타가 있어서 TypeError가 나는 경우요. 이건 서버 응답 문제가 아니라 우리 코드의 버그라, 사용자용 메시지가 따로 준비돼 있지 않아요. 그래서 "알 수 없는 오류가 발생했어요"라는 일반 메시지를 보여주고, 콘솔엔 에러를 그대로 남겨 개발자가 추적하게 해요.
이렇게 "예상한 에러(ApiError)"와 "예상 못한 에러(그 외)"를 한 catch에서 가르는 게 실무 에러 처리의 기본 골격이에요. 던지는 쪽이 ApiError로 규칙을 통일해뒀기 때문에, 받는 쪽이 instanceof 한 줄로 깔끔하게 갈라낼 수 있는 거죠.
catch (error)
│
▼
error instanceof ApiError ?
┌──────────┴──────────┐
true false
(예상한 실패) (예상 밖 — 코드 버그 등)
│ │
├ showToast(userMessage) ├ showToast("알 수 없는 오류...")
└ console.error(error 전체) └ console.error(error)
던지는 쪽과 받는 쪽은 한 쌍
오늘 우리가 한 일을 멀리서 보면, 던지는 쪽(api.js)과 받는 쪽(feed.js)이 짝을 이뤄 움직여요. api.js가 "실패는 ApiError로, 상태·주소·메시지를 담아 던진다"고 약속하면, feed.js는 "ApiError면 두 얼굴을 각각 쓰고, 아니면 일반 처리한다"고 받아요. 한쪽만 잘해선 안 되고, 양쪽이 같은 규칙을 알아야 매끄럽게 돌아가는 거죠.
이 try-catch 짝은 fetch에만 쓰는 게 아니에요. 실패할 수 있는 모든 비동기 작업에 같은 모양으로 쳐둘 수 있어요. 다음 시간(E-3)엔 코드를 필요할 때 불러오는 import()(동적 임포트)를 배우는데, 네트워크로 코드를 받아오다 실패할 수도 있어요. 그때도 바로 오늘 배운 이 try-catch 패턴으로 똑같이 감싸 처리해요. 비동기가 있는 곳엔 에러 처리가 따라다닌다 — 이 감각을 오늘 가져가시면 돼요.
💡 받는 쪽은
if (error instanceof ApiError)로 종류를 가려요. ApiError면 사용자에겐userMessage를, 개발자 콘솔엔 전체를 남기고, 그 외(코드 버그 등)는 일반 메시지로 처리해요. 던지는 쪽(api.js)과 받는 쪽(feed.js)이 같은 규칙을 공유하는 한 쌍이라는 게 핵심이에요.
Step 6: "조건이 맞을 때만 멈춰" — 조건부 중단점
여기서부터는 코드를 더 짜지 않고, 진단 도구 를 익혀요. 에러를 구조화했으니, 이제 그 에러를 빠르게 재현하고 추적하는 법이 필요하죠. 첫 번째 도구는 D-5에서 배운 중단점(breakpoint)의 고급판이에요.
D-5에서 우리는 Sources 탭에서 줄 번호를 클릭해 중단점을 걸었어요. 코드를 안 고치고도 그 줄에서 실행을 멈춰, 변수 값을 들여다보던 그 도구요. 그런데 기본 중단점에는 불편한 점이 하나 있어요. 걸어둔 줄을 지나갈 때마다 무조건 멈춰요.
loadPage의 catch에 중단점을 걸었다고 해볼게요. 그런데 무한 스크롤로 페이지를 10번 불러오는 동안, 9번은 정상이고 딱 1번만 500 에러가 났다면? 기본 중단점은 catch를 지나는 그 한 번에서만 멈추긴 하지만, 만약 "500일 때만" 보고 싶은데 404·400도 다 섞여 있다면 매번 멈춰서 일일이 확인해야 해요. 번거롭죠.
조건부 중단점 거는 법
그래서 있는 게 조건부 중단점(conditional breakpoint) 이에요. "이 조건이 참일 때만 멈춰라"라고 조건식을 달아두는 중단점이죠. 거는 법은 이래요.
Sources 탭 — feed.js
┌────────────────────────────────────────────────────────────┐
│ 55 │
│ 56 } catch (error) { │
│ 57 ▸ if (error instanceof ApiError) { ← 이 줄 거터를│
│ 58 showToast(error.userMessage); 우클릭! │
│ 59 console.error("게시물 로딩 실패:", error); │
└────────────────────────────────────────────────────────────┘
│ 우클릭하면 뜨는 메뉴
▼
┌──────────────────────────────────┐
│ Add breakpoint │
│ Add conditional breakpoint… ◀ │ ← 이걸 골라요
│ Add logpoint… │
└──────────────────────────────────┘
│ 조건식 입력칸이 떠요
▼
┌──────────────────────────────────┐
│ error.statusCode === 500 │ ← 조건식 입력
└──────────────────────────────────┘
순서를 정리하면 — Sources 탭에서 feed.js를 열고, 멈추고 싶은 줄의 줄 번호(거터)를 우클릭해요. 메뉴에서 "Add conditional breakpoint"(조건부 중단점 추가)를 고르면 조건식 입력칸이 떠요. 거기에 error.statusCode === 500이라고 적으면 돼요.
이러면 catch를 지나가더라도, error.statusCode가 정확히 500일 때만 멈춰요. 404·400일 때는 그냥 통과하죠. 조건을 만족해서 멈추는 조건부 중단점은 보통 거터에 주황색으로 표시돼요(기본 중단점의 파란색과 구분돼요). 우리가 Step 3에서 statusCode를 에러에 담아둔 덕분에, 이런 정밀한 조건을 걸 수 있는 거예요.
이미 걸어둔 중단점을 조건부로 바꾸고 싶으면, 그 중단점을 우클릭해 "Edit breakpoint"(중단점 편집)를 고르면 조건식을 나중에 달거나 고칠 수 있어요.
멈춘 다음엔?
조건이 맞아 실행이 멈추면, 그때부터는 D-5에서 익힌 그대로예요. 오른쪽 Scope(스코프) 패널에서 그 순간의 변수들을 통째로 볼 수 있어요. error 객체를 펼쳐서 statusCode · url · userMessage · message · stack이 실제로 뭐가 담겼는지 두 눈으로 확인하는 거죠. Step over로 한 줄씩 넘기며 showToast가 실행되는 순간 화면에 토스트가 뜨는 것도 볼 수 있고요.
console.log를 코드에 끼워 넣었다 지우는 것보다 훨씬 깔끔해요. 코드를 한 글자도 안 건드리고, "딱 이 상황일 때만" 멈춰서 모든 걸 들여다보니까요.
💡 조건부 중단점은 줄 거터를 우클릭 → "Add conditional breakpoint"로 걸고,
error.statusCode === 500같은 조건식을 달아요. 그 조건이 참일 때만 멈추니(주황색 표시), 여러 번 반복되는 코드에서 "딱 그 상황"만 골라 잡을 수 있어요. 기존 중단점은 "Edit breakpoint"로 조건을 추가할 수 있고요.
Step 7: "느린 네트워크를 흉내 내자" — Network Throttling
조건부 중단점으로 "에러가 났을 때"를 잡는 법을 배웠어요. 그런데 한 가지 문제가 있어요 — 에러를 어떻게 일부러 내지? 우리 json-server는 잘 돌아가니까, 평소엔 실패가 안 나거든요. 토스트나 로딩 스피너가 제대로 뜨는지 확인하려면, 실패 상황을 인위적으로 만들어내야 해요.
그럴 때 쓰는 게 DevTools의 Network Throttling(네트워크 스로틀링) 이에요. throttle은 "조절하다, 죄다"라는 뜻인데, 여기선 네트워크 속도를 일부러 느리게 죄거나 아예 끊어버리는 기능이에요. 실제로 느린 네트워크 장비를 사지 않고도, 지하철·엘리베이터·산속처럼 신호가 약한 환경을 브라우저에서 흉내 낼 수 있죠.
프리셋 고르기
DevTools의 Network 탭을 열면, 상단에 속도를 고르는 드롭다운이 있어요(기본값은 "No throttling", 제한 없음). 거길 누르면 미리 준비된 프리셋들이 나와요.
Network 탭 상단
┌─────────────────────────────────────────────────────────┐
│ 🔴 ● │ Preserve log │ Disable cache │ [No throttling ▾] │
└──────────────────────────────────────┬──────────────────┘
│ 누르면
▼
┌─────────────────────────────────────────────────────┐
│ No throttling (제한 없음, 기본) │
│ Fast 4G ~9 Mbps · 지연 165ms │
│ Slow 4G ~1.4 Mbps · 지연 562ms │
│ 3G 더 느림 │
│ Offline 네트워크 완전 차단 ◀ 에러 시연용 │
│ ───────────────────────────── │
│ Add… 커스텀 프로파일 직접 만들기 │
└─────────────────────────────────────────────────────┘
각 프리셋의 의미를 보면 — Fast 4G는 빠른 4G 환경(약 9Mbps, 지연 165ms), Slow 4G는 느린 4G 환경(약 1.4Mbps, 지연 562ms)을 흉내 내요. 이 Slow 4G는 웹 성능을 재는 표준 도구들이 "느린 환경 기준"으로 삼는 대표값이라, 우리 사이트가 답답한 네트워크에서 어떻게 보이는지 점검할 때 자주 써요. 3G는 그보다 더 느리고요.
맨 아래 "Add…"를 고르면 커스텀 프로파일을 직접 만들 수 있어요. 다운로드 속도(Download)·업로드 속도(Upload)·지연(Latency)을 숫자로 직접 입력해서, "내가 원하는 딱 그 느림"을 정의하는 거죠. 특정 지연에서만 나타나는 문제를 재현할 때 유용해요.
Offline으로 에러 시연하기
오늘 우리가 쓸 건 맨 아래쪽의 Offline(오프라인) 이에요. 네트워크를 완전히 끊어버리는 모드라, fetch가 서버에 닿지 못하고 실패해요. 우리 에러 처리가 진짜로 동작하는지 보기에 딱이죠.
해보세요. 피드 페이지를 연 상태에서 Network 탭을 "Offline"으로 바꾸고, 스크롤을 내려 다음 페이지를 불러오게 해보세요. 그러면 fetchPosts의 fetch가 실패하고, Step 5에서 짠 catch가 그 실패를 잡아요.
이때 화면에서 두 가지가 보여요. 먼저 D-4에서 만든 무한 스크롤(infinite-scroll.js)이 다음 페이지를 요청하면서 showLoading이 로딩 스피너를 띄워요. 그런데 네트워크가 끊겨 fetch가 실패하면, catch로 빠지면서 토스트가 떠요 — 정상 흐름이라면 ApiError가 아닌 네트워크 자체 에러(TypeError)라 "알 수 없는 오류가 발생했어요" 쪽이 뜨죠. 그리고 finally가 돌아 스피너는 깔끔하게 사라지고요. Step 1에서 finally를 따로 둔 이유가 여기서 눈에 보여요. 실패해도 스피너가 영원히 돌지 않잖아요.
확인이 끝나면 드롭다운을 "No throttling"으로 꼭 되돌려 놓으세요. Offline인 걸 잊고 "왜 안 되지?" 하며 한참 헤매는 건 개발자들의 단골 실수거든요.
💡 Network Throttling은 느린 네트워크를 흉내 내는 도구예요. 2026년 프리셋은 Fast 4G(~9Mbps)·Slow 4G(~1.4Mbps)·3G·Offline이고, Add…로 커스텀 프로파일도 만들 수 있어요. Offline으로 바꾸면 fetch가 실패해, 우리가 만든 토스트·스피너·
finally동작을 직접 관찰할 수 있죠. 확인 후엔 "No throttling"으로 되돌리는 걸 잊지 마세요.
Step 8: "개발자에겐 전부, 사용자에겐 한 줄" — 에러 로깅 전략
Step 5에서 catch 안에 두 줄을 적었던 거 기억하시죠? showToast(error.userMessage)와 console.error("게시물 로딩 실패:", error). 별것 아닌 것 같은 이 두 줄이 사실 에러 로깅 전략의 핵심이에요. 이번 Step에서 그 의미를 제대로 짚어볼게요.
같은 에러, 두 청중
에러가 났을 때 그 정보를 받을 청중은 둘이에요. 사용자 와 개발자. 그런데 둘이 원하는 게 완전히 달라요.
하나의 ApiError
│
┌────┴───────────────────────────────┐
▼ ▼
사용자 (화면 토스트) 개발자 (콘솔·로그)
showToast(error.userMessage) console.error("...", error)
│ │
"서버에 문제가 생겼어요" message(상태·주소) + stack 전체
부드러운 한 줄, 안심 추적에 필요한 모든 단서
사용자는 "지금 뭐가 잘못됐고, 내가 뭘 하면 되는지"만 알면 돼요. 그래서 userMessage 한 줄이면 충분하죠. "서버에 문제가 생겼어요. 잠시 후 다시 시도해 주세요"처럼요. 상태 코드 500이 뭔지, 우리 서버 주소가 뭔지는 사용자에게 아무 의미가 없고, 오히려 불안하게 만들어요.
반대로 개발자는 "정확히 어디서, 왜 났는지"를 다 알아야 고칠 수 있어요. 그래서 console.error에는 에러 객체 전체를 넘겨요. message(상태 코드·주소), stack(어느 줄에서 났나)까지 전부 콘솔에 찍히죠. Step 3에서 message와 userMessage를 나눠 담아둔 게, 여기서 두 청중에게 각각 맞는 정보를 주는 토대가 된 거예요.
개발 환경 vs 프로덕션 환경
지금 우리는 내 컴퓨터에서 개발 중이라 console.error로 충분해요. F12를 열면 바로 보이니까요. 그런데 진짜 서비스가 세상에 나가면(프로덕션 환경) 사정이 달라져요. 사용자는 F12를 열어보지 않고, 에러가 나도 개발자에게 알려주지 않아요. 그래서 에러를 수집해 모아주는 서버(로그 서버)로 자동으로 보내야 해요.
이때 중요한 트레이드오프가 하나 있어요. feed.js의 주석에 이미 적어둔 내용이에요.
// instagram-clone-frontend/js/feed.js — catch 안 (Step 5 에서 본 그 줄)
// 개발자에겐 전체를 콘솔에 남겨요. message(상태·주소) + stack 까지 다 찍혀요.
// 운영 환경이라면 이 자리에서 { statusCode, userMessage } 만 추려 로그 서버로 보내요.
console.error("게시물 로딩 실패:", error);
핵심은 두 번째 주석 줄이에요. 프로덕션에서는 에러를 통째로 보내지 않아요. stack에는 우리 코드의 내부 구조가, url에는 내부 서버 주소가 그대로 담겨 있어요. 이런 민감한 정보를 외부 로그 서버로 무심코 흘려보내면 보안에 구멍이 생길 수 있죠. 그래서 보낼 때는 { statusCode, userMessage }처럼 꼭 필요한 정보만 추려서 보내는 게 안전해요.
여기에 트레이드오프가 있어요. 정보를 적게 보내면 안전하지만, 나중에 버그를 추적할 단서도 줄어들죠. 너무 많이 보내면 추적은 쉽지만 민감 정보가 샐 위험이 커지고요. 그래서 실무에서는 "stack은 보내되 개인정보·인증 토큰은 가린다" 같은 균형점을 팀마다 정해요. 정답이 하나로 정해진 게 아니라, 무엇을 지키고 무엇을 포기할지 저울질하는 결정인 거예요.
지금 단계에서 외울 건 단순해요. ApiError가 message(개발자용)와 userMessage(사용자용)를 나눠 담은 그 이분법이, 곧 로깅 전략의 토대가 된다는 점이요. 에러를 잘 설계해두면, 누구에게 무엇을 보여줄지가 자연스럽게 풀려요.
💡 같은 에러를 두 청중에게 다르게 줘요 — 사용자에겐
userMessage한 줄, 개발자 콘솔엔message+stack전체. 프로덕션에선 민감정보(stack·내부 url)를 빼고{ statusCode, userMessage }만 로그 서버로 보내는 게 안전과 추적성 사이의 균형이에요.ApiError의 두 얼굴이 이 전략의 토대예요.
Step 9: "전부 한자리에" — 종합과 에러 추적 흐름
오늘 배운 걸 하나의 흐름으로 꿰어볼게요. 마침 우리 feed.js에는 같은 패턴이 두 곳에 들어 있어요. 하나는 Step 5에서 본 게시물 로딩(loadPage), 다른 하나는 댓글 저장이에요. 둘이 똑같은 모양으로 에러를 다루는지 확인해볼게요.
댓글 저장도 같은 패턴
feed.js 맨 아래엔 댓글 폼을 제출했을 때 동작하는 코드가 있어요. 댓글을 서버에 저장(POST)하는 부분인데, 여기에도 똑같은 try-catch가 쳐져 있어요.
// instagram-clone-frontend/js/feed.js — 댓글 폼 제출 catch
const postId = Number(article.dataset.postId);
try {
const saved = await createComment(postId, text); // 1) 서버에 저장(POST)
const index = [...document.querySelectorAll("article")].indexOf(article);
addComment(index, saved.text); // 2) 응답을 화면에 추가
input.value = ""; // 3) 입력칸 비우기
} catch (error) {
if (error instanceof ApiError) {
showToast(error.userMessage); // 같은 패턴 — 받는 쪽은 userMessage 만 보여줘요
} else {
showToast("댓글을 저장하지 못했어요.");
}
console.error("댓글 저장 실패:", error);
}
보세요. loadPage와 판박이예요. createComment가 던진 에러를 catch로 받고, if (error instanceof ApiError)로 가른 다음, ApiError면 userMessage를, 아니면 일반 메시지를 토스트로 띄워요. 그리고 콘솔엔 전체를 남기죠.
이게 패턴화의 힘이에요. 던지는 쪽(api.js)이 규칙을 통일했으니, 받는 쪽은 어디서든 같은 모양으로 처리할 수 있어요. 새로운 fetch 가 생겨도 이 패턴을 그대로 복사하면 되죠. (과제에서 다른 fetch 호출에 직접 적용해볼 거예요.)
전체 흐름 한눈에
오늘의 여정을 하나의 그림으로 정리할게요. 에러가 태어나서, 잡히고, 사용자에게 알려지고, 개발자가 추적하기까지의 전 과정이에요.
① 던지기 (api.js)
fetch 실패 → throw new ApiError(statusCode, url, userMessage)
│
② 잡기 (feed.js)
try { fetchPosts() } catch (error) { ... } finally { 스피너 제거 }
│
③ 가르기
error instanceof ApiError ?
true → showToast(userMessage) + console.error(전체)
false → showToast("알 수 없는...") + console.error(전체)
│
④ 추적 (DevTools)
조건부 중단점(statusCode===500) + Offline 스로틀링으로 재현
①에서 ④까지가 오늘 배운 전부예요. 실패를 구조화해 던지고(ApiError), 안전망으로 잡고(try-catch-finally), 종류를 가려 두 청중에게 알리고(instanceof), 도구로 추적하기(조건부 중단점·스로틀링).
직접 해보세요. json-server와 Live Server를 켠 다음, Network 탭을 Offline으로 바꾸고 댓글을 달아보세요. 서버에 저장이 안 되니 createComment가 실패하고, 방금 본 댓글 catch가 그 실패를 잡아 토스트를 띄워요. 콘솔엔 "댓글 저장 실패:"와 함께 에러가 남고요. 확인이 끝나면 Offline을 풀고 다시 정상 동작을 보세요.
마지막으로 한 가지만 덧붙일게요. 지금은 우리가 짠 코드를 그대로 브라우저에서 돌리니까, 콘솔의 stack에 feed.js:62처럼 원본 파일과 줄 번호가 그대로 찍혀요. 그런데 진짜 서비스에 배포할 땐 코드를 잘게 압축(minify)해서 한 줄로 뭉쳐버리는 경우가 많아요. 그러면 stack이 알아볼 수 없게 되죠. 이걸 다시 원본 위치로 되돌려주는 장치가 있는데, 다음 시간에 만날 도구가 그걸 해줘요. 에러 추적은 코드를 배포한 뒤에도 이어진다는 것만 기억해두세요.
💡 게시물 로딩과 댓글 저장이 똑같은
try-catch-instanceof패턴을 써요. 던지는 쪽이 규칙을 통일했기에 가능한 일이죠. 전체 흐름은 던지기(ApiError) → 잡기(try-catch-finally) → 가르기(instanceof) → 추적(중단점·스로틀링)이에요. 배포 후의 추적은 다음 시간 도구로 이어져요.
마무리
오늘 우리는 "실패를 다루는 법"을 배웠어요. 잘 돌아가는 코드를 짜는 것만큼이나, 안 돌아갈 때 무너지지 않는 코드를 짜는 게 중요하거든요. 되짚어볼게요.
- try-catch-finally — 실패할 수 있는 코드를
try로 감싸고, 던져진 에러를catch로 잡고, 성공·실패와 무관한 뒷정리를finally로 했어요. - 표준 Error 톺아보기 — 에러도 그냥 객체예요.
name·message·stack을 읽고,TypeError도Error의 자식임을,cause로 원인을 매다는 법을 확인했어요. - class ApiError extends Error — E-1의
extends로 표준 에러를 상속받아,statusCode·url·userMessage를 더했어요. 개발자용message와 사용자용userMessage를 나눈 게 핵심이었죠. - api.js 리팩토링 —
throw new Error(문자열)을throw new ApiError(...)로 바꿔, 실패에 상태 코드·주소·메시지를 담아 던졌어요. 화면 동작은 그대로였고요. - instanceof 받기 전략 — 받는 쪽은
error instanceof ApiError로 예상한 실패와 예상 밖 에러를 가르고, 두 청중에게 각각 맞는 정보를 줬어요. - 조건부 중단점 — D-5의 기본 중단점을 확장해,
error.statusCode === 500같은 조건이 맞을 때만 멈추는 정밀 디버깅을 익혔어요. - Network Throttling — Fast 4G·Slow 4G·Offline 프리셋으로 느린 네트워크를 흉내 내, Offline으로 에러·로딩·
finally동작을 직접 관찰했어요. - 에러 로깅 전략 — 사용자에겐
userMessage한 줄, 개발자에겐 전체. 프로덕션에선 민감정보를 빼고 보내는 균형을 짚었어요.
오늘의 큰 그림은 이거예요. 던지는 쪽과 받는 쪽이 ApiError라는 공통 규칙으로 짝을 이뤄, 실패를 구조화해 던지고 똑똑하게 받아 처리한다. 그리고 DevTools의 조건부 중단점·스로틀링으로 그 실패를 빠르게 재현하고 추적한다. 코드를 짜는 능력에 더해, "안 될 때 진단하는 능력"까지 손에 넣은 거예요.
다음 시간 예고
다음 시간(E-3)의 주제는 모듈 시스템 심화와 빌드 도구예요. 우리는 C-5부터 import / export로 코드를 파일별로 나눠 쓰고 있었죠. 다음 시간엔 코드를 미리 다 불러오지 않고 필요할 때 불러오는 동적 import()를 배워요. 그런데 코드를 네트워크로 받아오다 실패할 수도 있겠죠? 그때도 바로 오늘 배운 try-catch로 똑같이 감싸 처리해요. 비동기가 있는 곳엔 에러 처리가 따라다닌다는 오늘의 감각이 그대로 이어지는 거예요.
그리고 Step 9에서 살짝 흘린 이야기 — 배포할 때 코드를 잘게 압축하면 stack이 알아볼 수 없게 된다고 했죠. 다음 시간엔 Vite라는 빌드 도구를 만나는데, Vite가 만들어주는 소스맵(source map) 덕분에 압축된 코드에서도 원본 파일·줄 번호를 그대로 추적할 수 있어요. 오늘 디버깅 도구가 다음 시간 빌드 도구와 만나는 지점이죠. (Vite와 모듈 시스템은 다음 과목에서 본격적으로 더 깊게 다뤄요.)
에러를 두려워하던 단계는 이제 지났어요. 에러는 친구이고, 오늘 우리는 그 친구를 구조화하고 진단하는 법까지 배웠어요. 다음 시간에 만나요!
과제
오늘 배운 ApiError·try-catch·DevTools 도구를 직접 굴려볼 차례예요. json-server를 켠 채(npx json-server mock/db.json --port 3001) Live Server로 진행하세요.
[구현] ApiError에 인증 만료(401) 분기 더하기
ApiError에는 statusCode가 담겨 있어요. 이걸 활용해, 서버가 401(인증 만료)을 돌려줬을 때 사용자를 로그인 페이지로 안내하는 분기를 더해보세요.
feed.js의catch안if (error instanceof ApiError)가지에서,error.statusCode === 401인지 한 번 더 갈라보세요. 401이면 "다시 로그인이 필요해요" 토스트를 띄우고, 잠시 뒤index.html로 보내는 식으로요.- 401을 일부러 내긴 어려우니,
describeStatus에 401 케이스를 추가하거나, 잠깐 테스트용으로if (!response.ok)조건을 손봐서 흐름만 확인해도 좋아요. - 여유가 있다면
ApiError에timestamp(에러가 난 시각) 속성을 하나 더해보세요. constructor에서this.timestamp = new Date().toISOString()한 줄이면 돼요. 콘솔에서 언제 났는지 보이나요?
[구현] 다른 fetch 호출에도 같은 패턴 적용하기
오늘 우리는 게시물 로딩과 댓글 저장, 두 곳에 같은 try-catch-instanceof 패턴을 썼어요. 세 번째 fetch 호출을 직접 찾아 같은 패턴을 입혀보세요.
- 우리 앱에서 서버와 통신하는 곳을 찾아보세요(예: 좋아요를 서버에 저장하는 기능을 새로 만든다면, 그 fetch 부분이요).
- 거기에
try-catch를 두르고, 실패 시throw new ApiError(...)로 던진 뒤 받는 쪽에서instanceof로 갈라 토스트를 띄워보세요. - Offline 스로틀링으로 그 기능을 실패시켜, 토스트가 제대로 뜨는지 확인하세요.
[탐구] 커스텀 스로틀링 프로파일 만들어 관찰하기
Step 7에서 "Add…"로 커스텀 프로파일을 만들 수 있다고 했죠. 직접 만들어 특정 지연에서의 동작을 관찰해보세요.
- Network 탭의 스로틀링 드롭다운에서 "Add…"를 골라, Download·Upload·Latency를 직접 입력한 프로파일을 만들어보세요(예: 지연을 아주 크게).
- 그 프로파일로 피드를 불러오면서,
AbortSignal.timeout(8000)(8초 안에 응답이 없으면 요청을 끊는 장치)이 언제 작동하는지 관찰해보세요. 지연을 8초보다 크게 주면 어떤 에러가 날까요? - 그때 뜨는 토스트가 ApiError 쪽인지 "알 수 없는 오류" 쪽인지 확인하고, 왜 그런지 한 문장으로 적어보세요.
생각해볼 주제
정답을 적는 문제가 아니에요. 오늘 배운 것의 "왜"를 곱씹어보는 질문들이에요. 스스로 답을 만들어본 뒤, 예시답안과 비교해보세요.
1. 에러를 "삼킬까" vs "다시 던질까"
catch 안에서 에러를 잡았을 때, 두 가지 선택이 있어요. 하나는 거기서 처리하고 끝내는 것(삼키기), 다른 하나는 처리하면서도 throw error로 위로 다시 던지는 것(re-throw)이죠.
그런데 catch (error) { }처럼 잡기만 하고 아무것도 안 하는 "빈 catch"는 위험하다고들 해요. 에러가 조용히 사라져 버그를 숨기거든요. 반대로 모든 에러를 무조건 다시 던지면 어떻게 될까요? 어떤 상황에서 삼키는 게 맞고, 어떤 상황에서 다시 던지는 게 맞을지, 우리 loadPage의 catch를 예로 생각해보세요.
2. 사용자에게 어디까지 보여줄까?
우리는 사용자에게 userMessage만 보여주고, 상태 코드·주소·stack은 감췄어요. 그런데 "감추는 것"에도 정도가 있어요. 너무 두루뭉술한 "오류가 발생했어요"는 사용자가 뭘 해야 할지 모르게 만들고, 너무 자세한 메시지는 내부 정보를 흘리거나 사용자를 겁먹게 하죠.
사용자에게 보여줄 메시지의 적정선은 어디일까요? 그리고 개발자 로그에는 어디까지 담아야 추적은 되면서도 개인정보·보안 사고를 막을 수 있을지, 그 둘 사이의 경계를 고민해보세요.
3. 예상한 에러와 예상 못한 에러, 어떻게 구분할까?
오늘 우리는 instanceof ApiError로 "서버가 돌려준 4xx·5xx(예상한 에러)"와 "코드 버그 같은 예상 못한 에러"를 갈랐어요. 그런데 이 구분이 늘 깔끔한 건 아니에요.
예를 들어 네트워크가 끊겨서 난 에러는 우리 코드 버그도 아니고, 우리가 만든 ApiError도 아니에요(TypeError로 떨어지죠). 이런 "제3의 에러"는 어느 쪽으로 분류해야 할까요? 그리고 예상한 에러와 예상 못한 에러를 한 catch에서 처리하는 것과, 아예 분리하는 것 중 무엇이 더 나을지 생각해보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안이에요. 정답이 하나만 있는 건 아니에요. 토스트 문구나 변수 이름은 자유롭게 바꿔도 좋아요. 중요한 건 실패를 ApiError로 구조화해 던졌는가, 받는 쪽에서 instanceof 로 종류를 가렸는가, 그리고 브라우저에서 직접 실패를 일으켜 확인했는가 예요.
과제 예시답안
🎯 [과제 1 예시답안] ApiError에 인증 만료(401) 분기 더하기
핵심 접근
ApiError 에는 이미 statusCode 가 담겨 있어요. 그래서 feed.js 의 catch 안 if (error instanceof ApiError) 가지 안에서 error.statusCode === 401 인지 한 번 더 갈라주기만 하면 돼요. 핵심은 순서 예요 — 먼저 "우리가 만든 에러가 맞나(instanceof)" 를 확인하고, 그 안에서 "그게 401인가" 를 묻는 2단 분기죠. 401을 일부러 내긴 어려우니 describeStatus 에 401 케이스를 더해 흐름만 확인해요.
예시 구현
// instagram-clone-frontend/js/api.js — describeStatus 에 401 한 줄 추가 (과제용 예시)
function describeStatus(status) {
if (status >= 500) return "서버에 문제가 생겼어요. 잠시 후 다시 시도해 주세요.";
if (status === 404) return "찾는 데이터가 없어요.";
if (status === 401) return "다시 로그인이 필요해요.";
if (status >= 400) return "요청에 문제가 있어요.";
return `알 수 없는 오류예요 (${status}).`;
}
// instagram-clone-frontend/js/feed.js — loadPage() 의 catch (과제용 예시)
} catch (error) {
if (error instanceof ApiError) {
if (error.statusCode === 401) {
// 인증이 만료됐어요 — 안내 토스트를 띄우고, 잠시 뒤 로그인 페이지로
showToast(error.userMessage);
console.error("인증 만료:", error);
setTimeout(() => {
location.href = "index.html";
}, 1500);
return; // 아래 일반 처리로 안 흘러가게 막아요
}
showToast(error.userMessage);
console.error("게시물 로딩 실패:", error);
} else {
showToast("알 수 없는 오류가 발생했어요.");
console.error(error);
}
}
setTimeout(..., 1500) 으로 1.5초 정도 토스트를 보여준 뒤 옮기는 게 포인트예요. 곧장 location.href 로 넘기면 사용자가 "다시 로그인이 필요해요" 를 읽을 새도 없이 화면이 바뀌거든요. 그리고 401 처리 끝에 return 을 둬서, 아래 일반 ApiError 처리로 토스트가 한 번 더 뜨지 않게 막아요.
보너스로 ApiError constructor 에 시각 도장을 한 줄 더할 수 있어요.
// instagram-clone-frontend/js/api-error.js — constructor 끝에 한 줄 (과제용 예시)
constructor(statusCode, url, userMessage) {
super(`API ${statusCode} 응답 — ${url}`);
this.name = "ApiError";
this.statusCode = statusCode;
this.url = url;
this.userMessage = userMessage;
this.timestamp = new Date().toISOString(); // 에러가 난 시각 — "2026-06-11T..." 형식
}
console.error("...", error) 로 콘솔에서 펼쳐 보면 timestamp 가 같이 찍혀요. "이 에러가 정확히 몇 시에 났지?" 를 추적할 때 단서가 하나 늘어나는 거죠.
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| 2단 분기 순서 | instanceof ApiError 안에서 statusCode === 401 을 갈랐는가 (밖에서 statusCode 먼저 보면 위험) |
상 |
| statusCode 활용 | Step 3에서 담아둔 statusCode 를 분기 재료로 실제로 썼는가 |
상 |
| 안내 후 이동 | 토스트를 보여줄 시간을 두고(setTimeout) 로그인 페이지로 넘겼는가 |
중 |
| 흐름 확인 | describeStatus 401 추가 등으로 401 흐름을 실제로 돌려봤는가 | 중 |
| timestamp 보너스 | constructor 에 toISOString() 한 줄 추가 + 콘솔 확인 |
하 |
흔한 실수
instanceof밖에서error.statusCode === 401을 먼저 본다 — 네트워크 끊김 같은 에러는ApiError가 아니라statusCode속성 자체가 없어요. 그러면undefined === 401이라 false 로 흘러가긴 하는데, "ApiError 가 아닌데 statusCode 를 읽는다" 는 발상 자체가 위험해요. 반드시instanceof로 종류를 먼저 확정한 뒤 속성을 읽어요.location.href를 토스트 직후 바로 호출 — 화면이 즉시 넘어가 사용자가 안내 문구를 못 봐요.setTimeout으로 잠깐 보여준 뒤 옮겨요.- 401 처리 끝에
return을 안 둠 — 401 토스트를 띄우고도 아래 일반 ApiError 처리로 흘러가 토스트가 두 번 떠요. 갈래를 끝냈으면 빠져나와요.
실무 개선 포인트 (심화)
- 실제 서비스에서 401은 보통 한 곳에서 가로채요. 모든 화면의
catch마다 401 분기를 복붙하는 대신,api.js의 fetch 래퍼 안에서 "401이면 토큰을 비우고 로그인으로 보낸다" 를 한 번만 처리하는 식이죠. 같은 분기가 여러catch에 반복되기 시작하면 "한곳으로 모을 때" 라는 신호예요. - 401을 받았을 때 무조건 로그아웃시키기 전에, 저장해둔 갱신 토큰(refresh token)으로 새 토큰을 한 번 받아오는 흐름을 끼우기도 해요. 사용자는 로그인이 풀린 줄도 모르게 다시 정상 동작하죠. 지금 단계에선 "로그인으로 보내기" 로 충분하고, 이런 자동 복구가 있다는 것만 알아두면 돼요.
🎯 [과제 2 예시답안] 다른 fetch 호출에도 같은 패턴 적용하기
핵심 접근
오늘 게시물 로딩·댓글 저장 두 곳에 쓴 패턴을 세 번째 fetch 에 그대로 입히는 과제예요. 새로 만들 건 없고 복사예요 — api.js 에 좋아요 저장 함수를 하나 추가하면서 if (!response.ok) throw new ApiError(...) 규칙을 똑같이 따르고, 받는 쪽에서 try-catch + instanceof 로 가르면 끝이에요. 던지는 쪽 규칙이 통일돼 있어서 받는 쪽이 한 모양으로 처리되는 게 오늘의 핵심을 몸으로 확인하는 거죠.
예시 구현
// instagram-clone-frontend/js/api.js — 좋아요 저장 함수 추가 (과제용 예시)
export async function likePost(postId) {
const url = `${BASE_URL}/likes`;
const response = await fetch(url, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ postId }),
});
if (!response.ok) {
// 게시물·댓글과 똑같은 규칙으로 던져요 — 받는 쪽이 한 패턴으로 처리하게
throw new ApiError(response.status, url, describeStatus(response.status));
}
return await response.json();
}
// instagram-clone-frontend/js/like.js — 좋아요 버튼 클릭 처리 (과제용 예시)
import { likePost } from "./api.js";
import { ApiError } from "./api-error.js";
async function saveLike(postId) {
try {
await likePost(postId); // 서버에 저장(POST)
showToast("좋아요를 눌렀어요.");
} catch (error) {
if (error instanceof ApiError) {
showToast(error.userMessage); // 예상한 실패 — 사용자용 한 줄
} else {
showToast("좋아요를 저장하지 못했어요."); // 네트워크 끊김 등
}
console.error("좋아요 저장 실패:", error);
}
}
url 을 변수로 먼저 빼낸 것도 그대로 따라했어요 — fetch 와 ApiError 가 같은 주소를 쓰게 하려는 안전장치죠(Step 4의 createComment 와 같은 손질). 함수가 완성되면 Network 탭을 Offline 으로 바꾸고 좋아요를 눌러보세요. fetch 가 서버에 닿지 못해 실패하고, catch 의 else 가지가 "좋아요를 저장하지 못했어요" 토스트를 띄워요. 확인 후엔 No throttling 으로 꼭 되돌리고요.
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| 던지는 쪽 규칙 통일 | 새 함수도 if (!response.ok) throw new ApiError(...) 모양을 그대로 따랐는가 |
상 |
| 받는 쪽 instanceof | try-catch 안에서 instanceof ApiError 로 두 갈래를 나눴는가 |
상 |
| url 변수 분리 | 주소를 변수로 빼 fetch 와 ApiError 가 같은 url 을 쓰게 했는가 | 중 |
| Offline 검증 | 스로틀링으로 실제 실패시켜 토스트가 뜨는 걸 봤는가 | 중 |
| 패턴 일관성 | 게시물·댓글 catch 와 같은 모양(메시지만 다름)으로 맞췄는가 | 하 |
흔한 실수
- 새 함수만
throw new Error(문자열)로 던짐 — 한 함수만 옛날 방식이면 받는 쪽instanceof ApiError가 false 로 떨어져else로 새요. "예상한 실패" 인데도 "알 수 없는 오류" 취급을 받죠. 던지는 쪽 규칙은 전부 통일해야 받는 쪽이 깔끔해져요. catch없이 fetch 만 호출 — 비동기 작업은 실패할 수 있어요.try-catch없이 두면 실패가 그대로 콘솔에 "Uncaught (in promise)" 로 튀고 사용자에겐 아무 안내도 안 가요. 비동기가 있는 곳엔 에러 처리가 따라다닌다는 감각이에요.- Offline 으로 바꾸고 안 되돌림 — 확인은 됐는데 그 뒤 다른 기능이 다 실패해서 "왜 안 되지?" 하고 한참 헤매요. Step 7에서 짚은 단골 실수죠.
실무 개선 포인트 (심화)
- 같은
try-catch-instanceof블록이 세 곳, 네 곳으로 늘면 "이 처리를 함수 하나로 묶을 때" 라는 신호예요.handleApiCall(작업, 실패메시지)같은 작은 도우미를 만들어, 안에서instanceof분기와 토스트·콘솔을 한 번만 적어두고 모든 fetch 가 그걸 거치게 하면 중복이 사라져요. - 좋아요처럼 빠르게 반응해야 하는 동작은 "먼저 화면을 바꾸고, 저장은 뒤에서" 하는 낙관적 업데이트(optimistic update)를 자주 써요. 하트를 즉시 채워 보여주고 서버 저장이 실패하면 그때 되돌리는 거죠. 이때도 "되돌리는 분기" 가 바로 오늘 배운
catch안에 들어가요.
🎯 [과제 3 예시답안] 커스텀 스로틀링 프로파일 만들어 관찰하기
핵심 접근
코드를 짜는 과제가 아니라 관찰하고 이유를 적는 탐구예요. 핵심 질문은 하나예요 — 응답이 8초보다 늦어 AbortSignal.timeout(8000) 이 요청을 끊으면, 그때 뜨는 토스트가 ApiError 쪽일까 "알 수 없는 오류" 쪽일까? 정답을 미리 적어두면, "알 수 없는 오류가 발생했어요" 쪽 이에요. 타임아웃으로 끊긴 에러는 우리가 만든 ApiError 가 아니거든요. 직접 만들어 눈으로 확인하는 게 이 과제의 전부예요.
관찰 절차
1) Network 탭 → 스로틀링 드롭다운 → "Add…" 선택
2) 커스텀 프로파일 입력 (지연을 8초보다 크게)
┌────────────────────────────────────────┐
│ Profile Name : Very Slow │
│ Download : 100 kbps │
│ Upload : 100 kbps │
│ Latency : 10000 ms ← 8초보다 큼 │
└────────────────────────────────────────┘
3) 그 프로파일을 고른 채 피드를 스크롤해 다음 페이지 로딩
4) 8초가 지나면 AbortSignal.timeout 이 요청을 끊음 → catch 진입
5) 화면 토스트와 콘솔 메시지를 확인
지연(Latency)을 10초로 주면 응답이 오기까지 10초가 걸리는데, fetchPosts 의 AbortSignal.timeout(8000) 이 8초에서 먼저 요청을 끊어요. 그 순간 fetch 가 실패하면서 catch 로 빠지죠.
정답 포인트 — 왜 "알 수 없는 오류" 쪽일까?
타임아웃으로 끊긴 에러는
ApiError가 아니라 브라우저가 던지는TimeoutError(DOMException 계열)라서,error instanceof ApiError가 false 가 되어else가지로 흘러가요.
여기서 꼭 구분할 게 있어요. ApiError 는 서버가 응답을 돌려줬는데 그게 4xx·5xx 일 때 우리가 직접 던지는 거예요(if (!response.ok) throw new ApiError(...)). 그런데 타임아웃은 서버가 응답을 아예 안 준 경우라 if (!response.ok) 줄까지 가지도 못해요. fetch 자체가 끊겨버리거든요. 그러니 ApiError 가 던져질 자리가 없고, 대신 abort 계열 에러가 catch 로 떨어져 else 로 가는 거죠.
Offline 모드로 끊었을 때 "알 수 없는 오류" 가 떴던 것(Step 7)과 같은 이유예요 — 네트워크 자체가 실패하는 에러는 전부 ApiError 밖이에요. 한 문장으로 적자면: "응답이 와야 ApiError 도 던져진다. 응답이 안 오면(타임아웃·오프라인) else 가지다."
채점 포인트
| 포인트 | 설명 | 배점 가중 |
|---|---|---|
| 결과 정확 | "알 수 없는 오류" 쪽이 뜬다고 관찰했는가 | 상 |
| 이유 한 문장 | 타임아웃 abort 는 ApiError 가 아니라는 걸 자기 말로 적었는가 | 상 |
| 커스텀 프로파일 | Add… 로 지연을 8초보다 크게 직접 입력했는가 | 중 |
| Offline 과 연결 | Step 7의 Offline 결과와 같은 원리임을 짚었는가 | 하 |
흔한 실수
- "타임아웃이니까 500 ApiError 가 뜨겠지" 라고 예상 — 그럴듯하지만 틀려요. 500은 서버가 "내가 문제다" 라고 응답을 돌려준 경우예요. 타임아웃은 응답 자체가 없는 거라 상태 코드가 없어요. 상태 코드가 없으니
ApiError도 없죠. - 지연을 8초보다 작게 주고 "왜 안 끊기지?" 함 — 7초 지연이면 8초 타임아웃 안에 응답이 들어와요. 끊김을 보려면 지연이 8초를 넘어야 해요.
- 콘솔을 안 봄 — 토스트만 보면 "알 수 없는 오류" 만 알 수 있어요. 콘솔의
console.error(error)를 펼쳐 보면 에러 이름이TimeoutError인 걸 두 눈으로 확인할 수 있어요. 관찰 과제일수록 콘솔까지 봐요.
실무 개선 포인트 (심화)
- 타임아웃도 사용자에겐 "예상 가능한 실패" 예요. 그래서 실무에선 abort 계열 에러를 잡아
ApiError와 비슷하게 "네트워크가 불안정해요. 다시 시도해 주세요" 같은 전용 메시지로 안내하기도 해요.catch안에서error.name === "TimeoutError"를 한 번 더 갈라주는 식이죠. "알 수 없는 오류" 보다 사용자가 뭘 해야 할지 분명해져요. - 타임아웃 8초는 한 번에 실패로 단정하지 않고 한두 번 자동으로 다시 시도(retry)하는 흐름과 자주 묶여요. 단, 댓글 저장 같은 "쓰기" 동작은 무심코 재시도하면 같은 댓글이 두 번 달릴 수 있어서, 재시도는 주로 "읽기" 동작에만 안전하게 적용해요.
생각해볼 주제 예시답안
1. 에러를 "삼킬까" vs "다시 던질까"
[문제 상황 요약]
catch 안에서 에러를 잡았을 때 두 갈래가 있어요. 하나는 거기서 처리하고 끝내는 것(삼키기), 다른 하나는 처리하면서도 throw error 로 위로 다시 던지는 것(re-throw)이죠. 특히 catch (error) { } 처럼 잡기만 하고 아무것도 안 하는 "빈 catch" 는 에러를 조용히 사라지게 해 버그를 숨긴다고들 해요. 그럼 반대로 모든 에러를 무조건 다시 던지면 늘 옳을까요?
[튜터의 가이드 및 해설]
먼저 셋을 구분해볼게요. "빈 catch", "삼키기(처리하고 끝)", "다시 던지기(re-throw)" 는 다 달라요.
- 빈 catch (
catch (e) {}) — 거의 항상 나빠요. 에러가 났는데 아무도 모르게 사라져요. 토스트도 안 뜨고 콘솔에도 안 남아서, 사용자는 "왜 안 되지?" 하고 개발자는 "에러가 안 났는데?" 하며 서로 못 만나요. 디버깅 지옥의 단골 원인이죠. - Option A — 삼키기(처리하고 끝) — 에러를 잡아 토스트를 띄우고 콘솔에 남긴 뒤 거기서 마무리해요. 우리
loadPage의catch가 정확히 이거예요. 장점은 그 자리에서 사용자에게 깔끔히 안내하고 흐름을 멈춘다는 것. 단점은 더 바깥에서 이 실패를 알 길이 없다는 거예요. - Option B — 다시 던지기(re-throw) — 잡아서 로그를 남기거나 약간 손질한 뒤
throw error로 위로 올려보내요(Step 2의cause로 감싸 던지는 것도 이 갈래죠). 장점은 더 바깥의 처리기가 최종 결정을 내릴 수 있다는 것. 단점은 받아줄 바깥catch가 없으면 결국 "Uncaught" 로 터진다는 거예요.
기준은 "여기가 이 에러의 최종 책임자인가" 예요. loadPage 는 화면에 토스트를 띄우는 가장 바깥 처리기라, 여기가 종점이에요. 그래서 삼키는 게 맞아요. 반대로 api.js 안쪽에서 JSON 파싱이 깨졌다면, 거기서 사용자 토스트를 띄우는 건 책임 범위를 넘는 거예요. 그 자리에선 원인을 cause 로 매달아 다시 던지고, 토스트 결정은 바깥 loadPage 에 맡기는 게 깔끔해요.
현업에서는 보통 이렇게 정리해요. 안쪽 깊은 함수는 "정보를 더해 다시 던지기", 가장 바깥의 UI 경계 함수는 "삼키고 사용자에게 안내하기". 빈 catch 는 어디서도 쓰지 않아요 — 정말 무시해도 되는 에러라면, 최소한 console.debug 로 "여긴 일부러 무시함" 이라는 흔적이라도 남겨요.
🎯 면접관을 홀리는 핵심 멘트
"에러를 삼킬지 다시 던질지는 '여기가 이 에러의 최종 책임자인가' 로 갈라요. UI 경계 함수는 잡아서 사용자에게 안내하고 끝내고, 안쪽 깊은 함수는 cause 로 원인을 매달아 다시 던져 바깥이 결정하게 합니다. 단 잡기만 하고 아무것도 안 하는 빈 catch 는 에러를 조용히 숨겨 버그를 만드니, 무시할 거라면 무시한다는 흔적이라도 남겨요."
2. 사용자에게 어디까지 보여줄까?
[문제 상황 요약]
우리는 사용자에게 userMessage 만 보여주고 상태 코드·주소·stack 은 감췄어요. 그런데 "감추기" 에도 정도가 있어요. 너무 두루뭉술한 "오류가 발생했어요" 는 사용자가 뭘 해야 할지 모르게 만들고, 너무 자세한 메시지는 내부 정보를 흘리거나 사용자를 겁먹게 하죠. 사용자 메시지의 적정선은 어디고, 개발자 로그엔 어디까지 담아야 할까요?
[튜터의 가이드 및 해설]
사용자 메시지부터 볼게요. 좋은 사용자 메시지는 두 가지를 담아요 — 무엇이 잘못됐는지(아주 거칠게) 와 사용자가 뭘 하면 되는지 예요.
- Option A — 두루뭉술 ("오류가 발생했어요") — 안전하긴 한데 다음 행동을 못 알려줘요. 사용자는 새로고침을 해야 할지, 기다려야 할지, 로그인을 다시 해야 할지 몰라 막막해요.
- Option B — 행동을 담은 메시지 ("서버에 문제가 생겼어요. 잠시 후 다시 시도해 주세요") — 우리
describeStatus가 정확히 이 방향이에요. 원인은 거칠게만 말하고("서버 문제"), 할 일을 알려줘요("잠시 후 다시"). 404면 "찾는 데이터가 없어요", 401이면 "다시 로그인이 필요해요" 처럼 상황별로 행동이 달라지죠.
피해야 할 건 그 반대 극단이에요 — API 500 응답 — http://localhost:3001/posts?_page=2 를 사용자에게 그대로 보여주는 것. 우리 서버 주소가 노출되고(보안), 사용자는 무슨 말인지 모르고(혼란), 겁만 먹어요. 이게 바로 message 와 userMessage 를 나눈 이유죠.
이제 개발자 로그예요. 여기선 정반대로 많이 담아요 — message(상태·주소), stack(어느 줄), timestamp(언제). 추적엔 다다익선이거든요. 단 하나 예외가 있어요. 개인정보와 인증 토큰 이에요. 비밀번호, 주민번호, Authorization 헤더의 토큰 같은 건 로그에 절대 남기면 안 돼요. 로그 파일이 유출되면 그게 그대로 사고가 되니까요.
현업에서는 보통 이렇게 균형을 잡아요. 사용자에겐 "원인은 거칠게 + 행동은 분명하게", 개발자 로그엔 "추적 정보는 최대한, 단 민감정보는 마스킹". 그리고 Step 8에서 짚었듯 프로덕션에서 외부 로그 서버로 보낼 땐 { statusCode, userMessage } 처럼 더 줄여서 보내요 — 내부 url 이나 stack 까지 외부로 흘리면 그것도 정보 노출이거든요. 어디까지 보여줄지는 정답이 하나가 아니라, 무엇을 지키고 무엇을 포기할지 매번 저울질하는 결정이에요.
🎯 면접관을 홀리는 핵심 멘트
"사용자 메시지는 원인은 거칠게, 다음 행동은 분명하게 — '서버에 문제가 생겼어요, 잠시 후 다시 시도해 주세요' 처럼요. 상태 코드나 내부 주소를 노출하면 보안 구멍이자 사용자 혼란입니다. 개발자 로그는 반대로 stack·상태·시각까지 최대한 담되 비밀번호와 인증 토큰만은 마스킹해요. 그래서 ApiError 에 message 와 userMessage 를 처음부터 나눠 담는 설계가 이 경계를 깔끔하게 만들어 줍니다."
3. 예상한 에러와 예상 못한 에러, 어떻게 구분할까?
[문제 상황 요약]
오늘 우리는 instanceof ApiError 로 "서버가 돌려준 4xx·5xx(예상한 에러)" 와 "코드 버그 같은 예상 못한 에러" 를 갈랐어요. 그런데 이 구분이 늘 깔끔하진 않아요. 네트워크가 끊겨서 난 에러는 우리 코드 버그도 아니고, 우리가 만든 ApiError 도 아니거든요(TypeError 나 TimeoutError 로 떨어지죠). 이런 "제3의 에러" 는 어느 쪽으로 분류해야 할까요?
[튜터의 가이드 및 해설]
에러를 셋으로 나눠보면 머리가 맑아져요.
- 예상한 에러 (ApiError) — 서버가 응답을 돌려줬는데 4xx·5xx 인 경우. 우리가
if (!response.ok) throw new ApiError(...)로 직접 던진 거라userMessage가 준비돼 있어요. → 사용자에게 친화 메시지. - 제3의 에러 (네트워크 끊김·타임아웃) —
TypeError(오프라인),TimeoutError(타임아웃) 처럼 fetch 자체가 실패한 경우. 우리 버그도 아니고 서버 응답도 없어요. → 사용자에게 "연결을 확인해 주세요" 류 안내가 적합. - 예상 못한 에러 (코드 버그) —
card.rnder()같은 오타로 난TypeError. 이건 진짜 우리 잘못이라 사용자용 메시지가 없어요. → "알 수 없는 오류" + 콘솔에 남겨 개발자가 고쳐야 함.
까다로운 건 제3과 예상 못한 에러가 둘 다 ApiError 가 아니라는 점 이에요. 오늘 우리 else 가지는 이 둘을 한데 묶어 "알 수 없는 오류" 로 처리했어요. 강의 단계에선 이게 적정해요 — 단순하고, 사용자에겐 어차피 둘 다 "지금 안 된다" 는 사실이 중요하니까요.
- Option A — 한 catch 에서 다 처리(지금 방식) —
instanceof ApiError로 한 번만 갈라요. 단순하고 읽기 쉬워요. 단점은 네트워크 끊김과 코드 버그가 같은 메시지를 받아, 사용자가 "다시 시도하면 되는지" 를 구분 못 해요. - Option B — 제3의 에러를 따로 분리 —
else if (error.name === "TypeError" || error.name === "TimeoutError")로 네트워크 계열을 한 번 더 갈라 "연결을 확인해 주세요" 를 보여줘요. 사용자 경험은 좋아지지만catch가 길어지고, 브라우저마다 에러 이름이 미묘하게 달라 분류가 깨질 위험도 있어요.
현업에서는 보통 세 갈래로 정리 해요. ApiError(서버가 말해준 실패) → 친화 메시지, 네트워크 계열(끊김·타임아웃) → "연결 확인" 안내 + 재시도 유도, 그 외(진짜 버그) → "알 수 없는 오류" + 에러 리포팅 서버로 자동 전송. 핵심은 "사용자가 다음에 뭘 하면 되는가" 가 다르면 갈래도 다르게 둔다는 거예요. 네트워크 끊김은 "다시 시도", 코드 버그는 "우리가 고쳐야 함" 이라 사용자 행동이 다르니, 여유가 되면 나누는 게 맞아요. 다만 오늘처럼 둘을 묶는 것도 출발점으로는 충분한 모범 사례예요.
🎯 면접관을 홀리는 핵심 멘트
"에러는 세 갈래예요 — 서버가 돌려준 ApiError, 네트워크 끊김·타임아웃 같은 제3의 에러, 그리고 코드 버그. 구분 기준은 타입이 아니라 '사용자가 다음에 뭘 하면 되는가' 입니다. 네트워크 계열은 '다시 시도', 버그는 '우리가 고쳐야 함' 이라 행동이 달라요. instanceof 한 번으로 ApiError 만 갈라 친화 메시지를 주고, 나머지는 일단 묶되 네트워크 계열을 분리할 가치가 생기면 그때 갈래를 늘립니다."