문서 읽는 데 67분 · D4

D-4: 비동기 통신 실전 — 저장하고, 더 불러오고, 기다리게 하기

전체 23강 중 22강 · HTML·CSS·JS
난이도 · 입문

안녕하세요, 홍순구 튜터입니다. 지난 시간(D-3)에 우리는 드디어 브라우저 밖으로 나갔어요. fetch로 서버(json-server)에서 게시물을 받아 와, 고정돼 있던 화면을 살아 있는 데이터로 채웠죠. 화면과 데이터가 분리되면서, 인스타 클론이 진짜 앱의 모양을 갖추기 시작했어요.

그런데 지난 시간엔 딱 한 가지, 받아 오기(GET) 만 했어요. 진짜 인스타라면 내가 단 댓글이 서버에 저장돼서 새로고침해도 남아 있어야죠. 게시물도 한 번에 다 받는 게 아니라, 스크롤을 내릴 때마다 조금씩 더 불러와야 하고요. 서버 응답을 기다리는 동안엔 "불러오는 중" 같은 신호도 보여줘야 사용자가 멈춘 줄 알지 않아요.

오늘은 그 세 가지를 한꺼번에 얹어요. 댓글을 서버에 저장(POST) 하고, 스크롤에 맞춰 게시물을 더 불러오고(무한 스크롤), 기다리는 동안 로딩 표시를 띄워요. 지난 시간 깔아둔 fetch 위에, 진짜 앱다운 기능들을 하나씩 쌓는 거예요.

   지난 시간 (D-3)                          오늘 (D-4)
   ┌──────────────────────────┐          ┌──────────────────────────┐
   │ 게시물을 받아 오기(GET)만   │   ──▶    │ 댓글을 저장(POST)도 함     │
   │ 한 번에 8개 다 받음         │          │ 스크롤마다 조금씩 더 불러옴 │
   │ 받는 동안 화면은 텅 빔      │          │ 기다리는 동안 로딩 표시     │
   └──────────────────────────┘          └──────────────────────────┘

💡 오늘 수업의 핵심 — "GET으로 받기만 하던 fetch에 POST(저장)를 더하고, IntersectionObserver로 스크롤에 맞춰 더 불러오고, 로딩·에러 표시로 기다림과 실패를 사용자에게 알려준다." 🎯

🎯 학습 목표

  • HTTP 상태 코드(200·201·404·500)를 구분하고, 상태별로 다른 메시지를 내려줍니다.
  • 서버에서 데이터를 페이지 단위로 받아 옵니다(?_page · ?_per_page).
  • fetch로 데이터를 저장(POST) 합니다 — method · headers · body · JSON.stringify.
  • IntersectionObserver로 스크롤 끝을 감지해 무한 스크롤을 만듭니다.
  • 응답을 기다리는 동안 로딩 스피너를, 실패하면 에러 토스트를 보여줍니다.
  • AbortSignal로 응답이 너무 늦는 요청을 스스로 끊습니다.
  • 같은 요청이 중복으로 날아가지 않도록 빗장(플래그) 을 겁니다.
  • 댓글을 서버에 저장한 뒤, 응답을 화면에 즉시 반영합니다.

Step 1: "200? 404?" — HTTP 상태 코드 제대로 보기

지난 시간 마지막에, fetch 응답이 성공인지 response.ok로 확인했죠. 그때는 "200번대면 성공, 아니면 실패" 정도로만 짚고 넘어갔어요. 오늘은 그 번호(상태 코드) 를 조금 더 들여다볼게요. 서버는 응답할 때마다 "잘 됐어 / 못 찾았어 / 내가 탈났어" 같은 결과를 숫자로 함께 보내거든요.

식당에 비유하면, 주방이 주문에 답하는 방식이에요. "여기 음식이요(성공)", "그런 메뉴 없는데요(없음)", "주방에 불이 나서요(주방 문제)" — 손님은 이 한 마디만 들어도 무슨 상황인지 알죠. 상태 코드가 딱 그 역할이에요.

  2xx  성공
   ├ 200 OK        →  잘 됐어 (조회 성공)
   └ 201 Created   →  새로 만들었어 (저장 성공)

  4xx  요청한 쪽(클라이언트) 잘못
   ├ 400 Bad Request →  요청이 이상해
   └ 404 Not Found   →  그런 데이터 없어

  5xx  서버 쪽 잘못
   └ 500 Server Error →  내(서버)가 탈났어

크게 세 묶음이에요. 2로 시작하면 성공, 4로 시작하면 요청한 쪽 잘못(주소를 틀렸거나 없는 걸 달라고 했거나), 5로 시작하면 서버 잘못이에요. 첫 자리만 봐도 누구 책임인지 갈리는 거죠.

이걸 코드로 다뤄볼게요. 상태 코드 숫자를 받아서, 사람이 읽을 수 있는 한 줄 메시지로 바꿔주는 작은 함수를 api.js에 만들어요.

// instagram-clone-frontend/js/api.js
// 상태 코드(숫자)를 사람이 읽을 수 있는 한 줄 메시지로 바꿔요.
function describeStatus(status) {
  if (status >= 500) return "서버에 문제가 생겼어요. 잠시 후 다시 시도해 주세요.";
  if (status === 404) return "찾는 데이터가 없어요.";
  if (status >= 400) return "요청에 문제가 있어요.";
  return `알 수 없는 오류예요 (${status}).`;
}

위에서 아래로 차례로 걸러요. 500 이상이면 서버 문제, 404면 "없음", 그 밖의 400번대면 요청 문제로 보고 각각 다른 안내 문구를 돌려줘요. function 앞에 export가 없는 게 보이죠? 이 함수는 api.js 안에서만 쓰는 도우미라, 바깥에 공개하지 않았어요(C-5에서 배운 모듈이에요).

지난 시간 fetchPosts에서는 실패하면 그냥 서버 응답 오류: 404 같은 딱딱한 메시지를 던졌는데, 이제 이 함수를 거치면 사용자에게 보여줄 만한 말이 돼요. 이 메시지를 어디서 어떻게 보여줄지는 뒤에서(Step 7 에러 토스트) 이어가요.

💡 서버는 응답마다 상태 코드라는 숫자를 함께 보내요. 첫 자리로 성격이 갈려요 — 2xx 성공, 4xx 요청한 쪽 잘못, 5xx 서버 잘못. 이 숫자를 사람이 읽을 메시지로 바꾸는 describeStatus를 만들어, 실패할 때 친절하게 알려줄 준비를 해둬요.


Step 2: "한 번에 다 달라고?" — 페이지 단위로 받기

지난 시간 fetchPosts는 게시물 8개를 한 번에 받아 왔어요. 8개라 괜찮았지만, 진짜 인스타엔 게시물이 수천, 수만 개예요. 그걸 한 번에 다 받으면 어떻게 될까요? 응답이 한참 걸리고, 받아도 화면에 다 그리느라 버벅거려요. 사용자는 첫 화면 몇 개만 보면 되는데 말이죠.

그래서 서버는 보통 페이지 단위로 나눠 줘요. "1페이지에 3개씩, 2페이지에 3개씩" 이렇게요. 책을 한 권 통째로 주는 게 아니라, 읽는 만큼 한 장씩 넘겨주는 거예요. 우리 json-server도 이걸 지원해요. 주소 뒤에 물음표 옵션을 붙이면 돼요.

  http://localhost:3001/posts?_page=1&_per_page=3
                              └─ 1페이지 ─┘ └─ 한 페이지에 3개 ─┘

? 뒤에 _page(몇 페이지)와 _per_page(한 페이지에 몇 개)를 적어 보내요. 이 옵션들을 쿼리 스트링(query string) 이라고 해요. 그런데 한 가지 재밌는 게 있어요. 이렇게 페이지로 물어보면, json-server는 게시물 배열만 딱 주는 게 아니라 봉투에 담아서 줘요.

  { "data": [ ...게시물 3개... ],
    "next": 2,        ← 다음 페이지 번호 (없으면 null)
    "last": 3,        ← 마지막 페이지 번호
    "pages": 3,       ← 전체 페이지 수
    "items": 8 }      ← 전체 게시물 수

게시물은 data 안에 들어 있고, 그 옆에 next(다음 페이지가 몇 번인지)가 같이 와요. 이 next가 핵심이에요. 다음 페이지가 있으면 번호가, 없으면 null 이 와요. 그러니까 next만 보면 "더 받을 게 남았는지"를 알 수 있어요. 무한 스크롤에서 이걸 그대로 써먹어요.

이제 fetchPosts를 페이지를 받도록 고쳐요.

// instagram-clone-frontend/js/api.js
// 게시물을 "한 페이지씩" 가져와요. (무한 스크롤이 페이지 번호를 하나씩 올려 불러요)
export async function fetchPosts(page = 1, perPage = 3) {
  const url = `${BASE_URL}/posts?_page=${page}&_per_page=${perPage}`;
  const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
  if (!response.ok) {
    throw new Error(describeStatus(response.status));
  }
  return await response.json(); // { data, next, last, pages, items, ... }
}

지난 시간과 달라진 점을 짚어볼게요. 먼저 fetchPosts(page = 1, perPage = 3) — 페이지 번호와 개수를 매개변수로 받아요(C-2에서 배운 기본값이에요, 안 넘기면 1페이지·3개). 주소는 템플릿 리터럴로 ?_page=...&_per_page=...를 끼워 만들고요.

if (!response.ok)에서 실패면, 지난 시간처럼 딱딱한 문구가 아니라 Step 1에서 만든 describeStatus를 거친 친절한 메시지를 던져요. 그리고 마지막에 response.json()으로 푼 봉투 전체({ data, next, ... })를 그대로 돌려줘요. 게시물 배열만 빼서 주는 게 아니라요. 왜냐면 부르는 쪽에서 next도 봐야 하니까요.

fetch의 두 번째 인자 { signal: AbortSignal.timeout(8000) }는 "8초 안에 응답이 안 오면 이 요청을 스스로 끊어라"는 안전장치예요. 지금은 이 정도만 알아두고, 자세한 원리는 Step 6에서 다뤄요.

💡 게시물이 많으면 한 번에 다 받지 않고 페이지로 나눠 받아요. 주소에 ?_page=2&_per_page=3을 붙이면 돼요. json-server는 게시물을 data에 담고 next(다음 페이지 번호, 없으면 null)를 봉투에 함께 줘요. next로 "더 있는지"를 판단해요.


Step 3: "댓글을 저장한다?" — POST 첫 발걸음

드디어 지난 시간 예고했던 POST예요. 지금까지 우리가 한 fetch는 전부 "데이터 줘(GET)"였어요. 받기만 했죠. 그런데 댓글을 달면, 그 댓글을 서버에 새로 저장해야 해요. "이 데이터를 새로 만들어 줘"라는 요청, 그게 바로 POST예요(D-3 표에서 봤던 4동사 중 생성 담당이죠).

GET과 POST는 무엇이 다를까요? GET은 그냥 "주소"만 보내면 됐어요("이 주소의 데이터 줘"). 그런데 POST는 보낼 내용이 있어요. "이런 댓글을 저장해 줘"라고, 저장할 데이터를 함께 들려 보내야 하죠. 택배에 비유하면 GET은 빈손으로 가서 물건을 받아 오는 거고, POST는 상자에 물건을 담아 부치는 거예요.

   GET (받기)                         POST (보내고 저장)
   ┌─────────────┐                   ┌─────────────────────────┐
   │ 주소만 보냄   │                   │ 주소 + 방법(POST)        │
   │ "이거 줘"     │                   │     + 형식(JSON 이야)    │
   │              │                   │     + 내용(댓글 데이터)   │
   └─────────────┘                   └─────────────────────────┘

그래서 POST fetch에는 GET에 없던 두 번째 인자(옵션) 가 붙어요. 댓글을 저장하는 createComment 함수를 api.js에 만들어요.

// instagram-clone-frontend/js/api.js
// 댓글 한 줄을 서버에 "저장"해요. (REST 4동사 중 POST — 새 데이터 생성)
export async function createComment(postId, text) {
  const response = await fetch(`${BASE_URL}/comments`, {
    method: "POST",                                  // 1) 생성이니까 POST
    headers: { "Content-Type": "application/json" }, // 2) "JSON 보낼게요" 라고 알림
    body: JSON.stringify({ postId, username: "soongu_hong", text }), // 3) 객체 → JSON 문자열
  });
  if (!response.ok) {
    throw new Error(describeStatus(response.status));
  }
  return await response.json(); // 서버가 id 를 붙여 돌려줘요 (201 Created)
}

옵션 객체의 세 줄이 핵심이에요.

  • method: "POST" — "이건 받기(GET)가 아니라 저장(POST)이야"라고 알려요. 안 적으면 기본이 GET이라, 반드시 적어야 해요.
  • headers: { "Content-Type": "application/json" } — "내가 보내는 내용은 JSON 형식이야"라고 서버에 미리 알리는 거예요. 헤더(headers)는 택배 상자에 붙이는 운송장 같은 거예요. 안에 뭐가 들었는지 서버가 열기 전에 알 수 있게요.
  • body: JSON.stringify({ ... }) — 실제로 보낼 내용이에요. 그런데 자바스크립트 객체를 그대로 못 보내요. 네트워크로는 글자(문자열) 만 오갈 수 있거든요. 그래서 JSON.stringify로 객체를 JSON 문자열로 바꿔서 실어요. D-3에서 서버가 준 JSON을 response.json()으로 풀었다면, 이번엔 반대로 우리 객체를 JSON으로 싸는 거예요.

body에 담은 { postId, username, text }는 "몇 번 게시물에 / 누가 / 무슨 댓글을" 저장할지예요(username은 지금 로그인 기능이 없어서 임시로 고정해 뒀어요 — 진짜 로그인은 다음 모듈에서요).

저장이 성공하면 서버는 201 Created(새로 만들었어)로 답하고, 우리가 보낸 댓글에 id를 붙여서 돌려줘요. 그 돌려받은 댓글을 return해서, 화면에 바로 보여줄 때 쓸 거예요(Step 9에서요).

💡 데이터를 새로 저장하려면 POST 요청을 보내요. GET과 달리 옵션이 붙어요 — method: "POST"(저장이라고 알림), headers로 "JSON 보낼게요" 운송장, bodyJSON.stringify로 객체를 JSON 문자열로 싸서 실어요. 성공하면 서버가 201과 함께 id를 붙여 돌려줘요.


Step 4: "일단 빨리 응답해!" — 로딩 표시

지난 시간 과제에서 "불러오는 중..." 표시를 살짝 맛봤죠? 그게 바로 로딩 표시(loading) 예요. 오늘 제대로 만들어볼게요.

왜 필요할까요? 서버에 다녀오는 데는 시간이 걸려요. 그 사이 화면이 텅 비어 있으면, 사용자는 "앱이 멈췄나? 고장 났나?" 하고 불안해해요. 반대로 빙글빙글 도는 작은 표시 하나만 있어도 "아, 지금 불러오는 중이구나" 하고 기다려주죠. 실제 데이터는 1초도 안 걸려 와도, 그 1초의 느낌을 바꿔주는 게 로딩 표시예요.

먼저 화면에 띄울 스피너(빙글빙글 도는 동그라미)를 CSS로 만들어요. components.css에 추가해요.

/* instagram-clone-frontend/css/components.css */
.loading {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  padding: 1.5rem;
  color: var(--color-text-soft);
  font-size: 0.9rem;
}

.spinner {
  width: 1.25rem;
  height: 1.25rem;
  border: 2px solid var(--color-border);
  border-top-color: var(--ig-blue);
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

스피너의 원리가 재밌어요. .spinner는 정사각형에 border-radius: 50%로 동그라미를 만든 거예요. 그런데 테두리(border) 색을 보면, 전체는 연한 회색인데 위쪽 한 변만(border-top-color) 파란색이에요. 그 상태로 animation을 걸어 빙글빙글 돌리면, 파란 점 하나가 원을 따라 도는 것처럼 보여요. @keyframes spin이 "0도에서 360도까지 회전"을 정의하고, infinite로 끝없이 반복해요. 이미지 한 장 없이 CSS만으로 로딩 스피너가 완성돼요.

이제 이 스피너를 화면에 넣고 빼는 함수를 feed.js에 만들어요. 불러오기 시작할 때 띄우고, 끝나면 치우는 거죠.

// instagram-clone-frontend/js/feed.js
function showLoading() {
  const box = document.createElement("div");
  box.className = "loading";
  box.innerHTML = `<span class="spinner" aria-hidden="true"></span> 불러오는 중...`;
  feedMain.insertBefore(box, sentinel); // 감시병 바로 위에 끼워 넣어요
  return box;
}

D-1에서 배운 createElement로 로딩 상자를 만들고, 안에 스피너와 "불러오는 중..." 글자를 넣어요. 그리고 화면에 끼워 넣은 뒤, 그 상자를 return해요. 왜 돌려줄까요? 나중에 지우려면 그 상자를 손에 쥐고 있어야 하거든요. 데이터가 다 오면 box.remove()로 치울 거예요.

feedMain.insertBefore(box, sentinel)에서 sentinel(감시병)이라는 게 나오는데, 이건 다음 Step에서 만들 "스크롤 맨 끝 표시"예요. 로딩 스피너를 그 감시병 바로 위에 끼워서, 항상 게시물 목록 맨 아래에 뜨게 한 거예요. 감시병이 뭔지는 바로 다음 Step에서 풀어요.

💡 서버에 다녀오는 동안 화면이 비면 사용자가 불안해해요. 빙글빙글 도는 스피너로 "불러오는 중"을 알려줘요. 스피너는 동그라미 테두리의 한 변만 색을 다르게 주고 @keyframes로 회전시키면 돼요(이미지 없이 CSS만으로요). 불러오기 시작할 때 띄우고, 끝나면 remove()로 치워요.


Step 5: "스크롤 내릴 때마다 더" — IntersectionObserver 무한 스크롤

오늘의 가장 큰 산이에요. 무한 스크롤 — 스크롤을 끝까지 내리면 다음 게시물이 알아서 더 불러와지는 그 기능이요. 인스타, 유튜브, 페이스북 다 이걸 써요.

어떻게 만들까요? 가장 먼저 떠오르는 방법은 "스크롤 위치를 계속 재서, 맨 아래 근처면 더 불러오기"예요. 실제로 D-2에서 스크롤 위치를 throttle로 콘솔에 찍어봤죠. 그런데 이 방법은 스크롤할 때마다 위치를 계산해야 해서 번거롭고, 화면 크기마다 "맨 아래"의 기준도 달라져요.

다행히 브라우저가 더 똑똑한 도구를 줘요. IntersectionObserver(인터섹션 옵저버, 교차 관찰자) 예요. 이름이 길지만 하는 일은 단순해요. "이 요소가 화면에 보이기 시작하면 알려줘" 를 대신 감시해줘요. 우리가 위치를 일일이 계산할 필요 없이요.

그래서 작전은 이래요. 게시물 목록 맨 끝에 눈에 안 보이는 작은 감시병(sentinel) 요소를 하나 세워둬요. 사용자가 스크롤을 내려서 그 감시병이 화면에 들어오면 = "맨 아래까지 왔다"는 뜻이니, 다음 페이지를 불러와요.

   화면(뷰포트)
   ┌────────────────────┐
   │  게시물 1           │
   │  게시물 2           │
   │  게시물 3           │
   ├────────────────────┤  ← 스크롤 내리는 중...
   │ ▽ 감시병(sentinel)  │  ← 이게 화면에 보이는 순간!
   └────────────────────┘     "다음 페이지 불러와!" 신호

먼저 이 감시 도구를 infinite-scroll.js라는 새 파일에 담아요.

// instagram-clone-frontend/js/infinite-scroll.js
// sentinel: 감시할 요소 / onReach: 감시병이 보일 때 실행할 함수
export function setupInfiniteScroll(sentinel, onReach) {
  let loading = false; // 이미 불러오는 중이면 또 부르지 않게 막는 빗장

  const observer = new IntersectionObserver(async (entries) => {
    const entry = entries[0];
    if (!entry.isIntersecting) return; // 아직 화면 밖이면 아무것도 안 해요
    if (loading) return;               // 이미 불러오는 중이면 무시 (중복 요청 방지)

    loading = true;
    await onReach();   // 다음 페이지를 다 불러올 때까지 기다려요
    loading = false;   // 끝나면 빗장을 풀어 다음 감지를 허용
  });

  observer.observe(sentinel); // 이 순간부터 감시 시작
  return observer;
}

new IntersectionObserver(...)로 관찰자를 만들어요. 괄호 안에 넣은 함수가, 감시 대상의 상태가 바뀔 때마다 자동으로 불려요. 그 안에서 entry.isIntersecting을 봐요 — 이게 true면 "감시 대상이 지금 화면에 보인다"는 뜻이에요. 보일 때만 onReach()(다음 페이지 불러오기)를 실행하죠. 마지막에 observer.observe(sentinel)로 "이 감시병을 지켜봐"라고 등록하면, 그때부터 감시가 시작돼요.

중간의 loading이라는 빗장이 보이죠? 이건 중복 요청을 막는 장치인데, 자세한 이야기는 Step 8에서 따로 다뤄요. 지금은 "한 번 불러오는 동안엔 또 안 불러온다" 정도로만 봐두세요.

이제 feed.js에서 이 도구를 써요. 지난 시간 loadFeed(한 번에 다 불러오기)를 loadPage(한 페이지씩 불러오기) 로 바꿔요. 먼저 감시병을 세우고, 페이지 단위 로딩 함수를 만들어요.

// instagram-clone-frontend/js/feed.js
// 목록 맨 아래의 "감시병" — 이게 화면에 보이면 다음 페이지를 불러와요.
const sentinel = document.createElement("div");
sentinel.className = "scroll-sentinel";
feedMain.append(sentinel);

let currentPage = 1; // 다음에 불러올 페이지 번호
let hasMore = true;  // 더 받을 게 남았는지

async function loadPage() {
  const loadingBox = showLoading();
  try {
    const result = await fetchPosts(currentPage); // { data, next, ... }
    for (const post of result.data) {
      feedMain.insertBefore(renderPost(post), sentinel); // 감시병 위에 차례로
    }
    hasMore = result.next !== null; // next 가 null 이면 마지막 페이지
    currentPage += 1;
  } catch (error) {
    showToast(error.message); // 실패하면 사용자에게 알려요
  } finally {
    loadingBox.remove(); // 성공이든 실패든 스피너는 치워요
  }
}

loadPage를 따라가 볼게요. 먼저 showLoading()(Step 4)으로 스피너를 띄워요. 그다음 fetchPosts(currentPage)지금 페이지를 받아 와요. 받은 result.data(게시물 배열)를 for...of로 하나씩 돌면서 renderPost로 그려, 감시병 바로 위에 끼워 넣어요(insertBefore). 이렇게 하면 새 게시물이 항상 감시병보다 위에 쌓여서, 감시병은 늘 맨 아래에 남아요.

그다음 hasMore = result.next !== null — Step 2에서 봤던 next를 여기서 써먹어요. 다음 페이지가 있으면 next가 숫자라 hasMoretrue, 마지막 페이지면 nextnull이라 false가 돼요. 그리고 currentPage += 1로 다음에 불러올 페이지 번호를 하나 올려요.

try / catch / finally 구조도 눈여겨보세요. 정상이면 try가 흐르고, 실패하면 catch가 받아 토스트를 띄우고(Step 7), 성공이든 실패든 finally에서 스피너를 치워요. finally는 "무슨 일이 있어도 마지막엔 꼭 실행"되는 블록이라(C-6에서 배웠죠), 스피너가 화면에 영영 남는 사고를 막아줘요.

마지막으로 이 loadPage를 무한 스크롤에 연결하고, 첫 페이지를 불러와요.

// instagram-clone-frontend/js/feed.js
import { setupInfiniteScroll } from "./infinite-scroll.js";

// 감시병이 보일 때마다 다음 페이지를 불러와요.
setupInfiniteScroll(sentinel, async () => {
  if (!hasMore) return; // 다 불러왔으면 더 안 해요
  await loadPage();
});

loadPage(); // 첫 페이지를 바로 불러와요

setupInfiniteScroll에 감시병과 "감시병이 보이면 할 일"을 넘겨요. 할 일은 loadPage인데, 그 앞에 if (!hasMore) return을 둬서 더 받을 게 없으면 그냥 멈춰요. 안 그러면 마지막 페이지에서도 계속 서버에 물어보겠죠. 맨 아래 loadPage()는 페이지가 열리자마자 첫 페이지를 불러오는 거예요. 그 뒤로는 스크롤이 감시병에 닿을 때마다 setupInfiniteScroll이 알아서 다음 페이지를 불러와요.

💡 무한 스크롤은 IntersectionObserver로 만들어요. 목록 맨 끝에 눈에 안 보이는 감시병을 세우고, 그게 화면에 들어오면(isIntersecting) 다음 페이지를 불러와요. loadPage는 한 페이지씩 받아 감시병 위에 그리고, next로 더 있는지 판단해요. 스크롤 위치를 직접 계산할 필요가 없어요.


Step 6: "응답이 안 오면 끊는다" — AbortSignal 타임아웃

Step 2에서 fetchPosts 안에 슬쩍 넣어둔 한 줄, 기억나세요?

const response = await fetch(url, { signal: AbortSignal.timeout(8000) });

signal 옵션 이야기를 할 차례예요. 생각해보면 무서운 상황이 하나 있어요. 서버가 응답을 영영 안 주면 어떡하죠? await fetch(...)는 응답을 기다리는데, 응답이 안 오면 계속 기다려요. 스피너는 영원히 빙글빙글 돌고, 사용자는 하염없이 갇혀요. 네트워크가 아주 느리거나 서버가 멍하니 있을 때 실제로 생기는 일이에요.

그래서 필요한 게 요청을 중간에 끊는 장치예요. "8초 기다렸는데 안 오면 그냥 포기해"라고요. 이걸 해주는 게 AbortController(어보트 컨트롤러, 중단 제어기) 예요. 원래는 이렇게 직접 만들어요.

// (개념 설명용 — 직접 만들면 이런 모습이에요)
const controller = new AbortController();
fetch(url, { signal: controller.signal }); // 끊기 신호선을 연결
setTimeout(() => controller.abort(), 8000); // 8초 뒤 "끊어!" 명령

AbortController는 리모컨 같은 거예요. controller.signal이라는 신호선fetch에 연결해두면, 나중에 controller.abort()(리모컨의 정지 버튼)를 누를 때 그 요청이 끊겨요. 위에서는 setTimeout으로 8초 뒤에 자동으로 정지 버튼을 누르게 해뒀죠.

그런데 "몇 초 뒤에 끊기"는 워낙 자주 쓰는 패턴이라, 요즘 브라우저는 한 줄짜리 지름길을 줘요. 그게 우리가 쓴 AbortSignal.timeout(8000) 이에요. 리모컨을 만들고 타이머를 거는 세 줄을, 한 줄로 줄여준 거죠. "8초 안에 응답 없으면 알아서 끊어"라는 뜻이에요.

요청이 시간 초과로 끊기면, fetch는 오류를 던져요. 그러면 Step 5의 loadPage에 있는 try...catch가 그 오류를 받아서, 스피너를 치우고 사용자에게 알려요. 무한정 기다리는 대신, "응답이 늦네요" 하고 깔끔하게 손을 떼는 거예요. 이게 진짜 앱과 장난감 앱을 가르는 디테일이에요.

💡 서버가 응답을 영영 안 주면 사용자가 갇혀요. AbortController로 요청에 "끊기 신호선"을 연결하고, 일정 시간 뒤 abort()로 끊어요. 자주 쓰는 "N초 타임아웃"은 AbortSignal.timeout(8000) 한 줄이면 돼요. 끊기면 오류가 나고, try...catch가 받아내요.


Step 7: "서버가 늦거나, 끊기거나" — 에러 토스트

Step 5의 loadPage에서 catch (error)showToast(error.message)를 부르는 걸 봤죠. 이 토스트(toast) 를 만들 차례예요. 토스트는 화면 아래쪽에 잠깐 떴다가 스르륵 사라지는 알림이에요(빵 굽는 토스터에서 토스트가 톡 튀어나오는 모습에서 온 이름이에요).

왜 콘솔(console.error)이 아니라 화면에 띄울까요? 지난 시간엔 실패하면 콘솔에만 찍었어요. 그런데 콘솔은 개발자만 봐요. 진짜 사용자는 F12를 안 열어보죠. 사용자에게 "지금 뭔가 잘못됐어요"를 알리려면, 화면에 직접 보여줘야 해요.

먼저 실패에는 두 가지 종류가 있다는 걸 짚고 갈게요.

   ① 서버는 답했는데, 내용이 실패
      예: 404(없음), 500(서버 탈)
      →  response.ok 가 false  →  우리가 throw 로 오류를 던짐

   ② 서버가 아예 답을 못 함
      예: 서버 꺼짐, 인터넷 끊김, 시간 초과(Step 6)
      →  fetch 자체가 오류를 던짐

①은 서버가 "그런 거 없어(404)"처럼 실패를 응답한 경우예요. Step 2에서 if (!response.ok) throw ...로 우리가 직접 오류를 던졌죠. ②는 서버가 응답조차 못 한 경우고요(꺼졌거나, 끊겼거나, Step 6처럼 시간이 초과됐거나). 이땐 fetch 자체가 오류를 던져요. 두 경우 모두 loadPagetry...catch가 한 그물로 받아요. 그게 try...catch의 힘이에요 — 어디서 났든 오류는 다 catch로 모여요.

catch가 받은 오류 메시지를 화면에 띄우는 showToast를 만들어요.

// instagram-clone-frontend/js/feed.js
function showToast(message) {
  const toast = document.createElement("div");
  toast.className = "toast";
  toast.textContent = message;
  document.body.append(toast);
  setTimeout(() => toast.remove(), 3000); // 3초 뒤 스스로 사라져요
}

D-1에서 배운 createElement로 토스트 상자를 만들고, textContent에 메시지를 넣어요(innerHTML이 아니라 textContent인 이유는 D-1에서 배웠죠 — 글자만 안전하게 넣을 때요). document.body.append로 화면에 띄운 뒤, setTimeout으로 3초 뒤에 스스로 사라지게 해요. 사용자가 닫지 않아도 알아서 없어지니 깔끔하죠.

CSS는 Step 4 스피너 옆에 함께 넣어둬요. 화면 아래 가운데에 고정으로 떠 있게요.

/* instagram-clone-frontend/css/components.css */
.toast {
  position: fixed;
  left: 50%;
  bottom: 2rem;
  transform: translateX(-50%);
  padding: 0.75rem 1.25rem;
  background: #262626;
  color: #fff;
  border-radius: 8px;
  font-size: 0.9rem;
  z-index: 200;
}

position: fixed로 스크롤과 상관없이 화면에 고정하고(B-3에서 배운 그 fixed예요), left: 50% + transform: translateX(-50%)로 가로 가운데에 둬요. z-index: 200으로 다른 요소들 위에 뜨게 하고요. 이제 서버를 끄거나 주소를 틀리게 하면, 콘솔이 아니라 화면 아래에 검은 알림이 톡 떴다 사라져요. Step 1에서 만든 describeStatus의 친절한 메시지가 드디어 빛을 보는 순간이에요.

💡 실패는 두 종류예요 — 서버가 실패를 응답(404·500)하거나, 서버가 답조차 못 하거나(꺼짐·끊김·시간 초과). 둘 다 try...catch가 한 그물로 받아요. 받은 메시지를 콘솔이 아니라 화면에 토스트로 띄워야 사용자가 알아요. position: fixed로 고정하고, setTimeout으로 3초 뒤 스스로 사라지게 해요.


Step 8: "두 번 불러오지 마" — 중복 요청 막기

무한 스크롤엔 숨은 함정이 하나 있어요. 사용자가 스크롤을 빠르게 휙휙 내리면 어떻게 될까요? 감시병이 화면에 들어왔는데, 첫 페이지를 아직 다 못 불러온 사이에 또 보이고, 또 보여요. 그때마다 "다음 페이지 줘!"가 여러 번 날아가면, 같은 페이지를 중복으로 받거나 페이지 번호가 꼬여요.

그래서 Step 5의 infinite-scroll.js에 슬쩍 넣어둔 빗장(loading 플래그) 이 필요해요. 그 부분만 다시 볼게요.

// instagram-clone-frontend/js/infinite-scroll.js
let loading = false; // 이미 불러오는 중이면 또 부르지 않게 막는 빗장

const observer = new IntersectionObserver(async (entries) => {
  const entry = entries[0];
  if (!entry.isIntersecting) return;
  if (loading) return;               // 이미 불러오는 중이면 무시 (중복 요청 방지)

  loading = true;    // 불러오기 시작 → 빗장 잠금
  await onReach();
  loading = false;   // 다 불러옴 → 빗장 풀기
});

loading이라는 변수가 빗장 역할을 해요. 불러오기를 시작하면 loading = true문을 잠가요. 그 뒤에 감시병이 또 보여도, if (loading) return에서 걸려 그냥 무시돼요. await onReach()로 페이지를 다 불러온 다음에야 loading = false로 문을 열어, 그때부터 다음 감지를 받아요.

이걸 상태 빗장이라고 생각하면 쉬워요. 화장실 문을 잠그면(true) 다른 사람이 들어오려다 "사용 중"을 보고 돌아가고, 나오면서 문을 열면(false) 다음 사람이 들어오죠. "한 번에 하나의 요청만" 보장하는 거예요.

여기서 잠깐 — Step 6의 AbortSignal 타임아웃과 헷갈릴 수 있어요. 둘 다 "요청"을 다루지만 방향이 정반대예요.

   Step 6 (AbortSignal)        Step 8 (loading 빗장)
   "이미 날아간 요청"을          "새 요청이 날아가는 것"을
    너무 늦으면 끊기              아예 막기
   = 응답이 안 올 때 손절        = 중복으로 안 보내기

Step 6은 이미 보낸 요청이 안 끝날 때 끊는 거고, Step 8은 새로 보내는 요청이 겹치지 않게 막는 거예요. 하나는 사후 대응, 하나는 사전 예방이에요. 무한 스크롤처럼 요청이 자주 일어나는 곳에선 둘 다 챙겨야 안전해요.

💡 스크롤을 빠르게 내리면 감시병이 연달아 보여서 같은 페이지를 중복으로 불러올 수 있어요. loading 빗장을 잠그고(true) 불러오기가 끝나야 풀어서(false), "한 번에 하나의 요청"만 보장해요. Step 6의 타임아웃(늦은 요청 끊기)과는 다른, 중복을 사전에 막는 장치예요.


Step 9: "댓글을 짠! — 저장하고 화면에 띄우기"

자, 오늘의 대미예요. 지난 시간 가장 아쉬웠던 것 — 새로고침하면 사라지던 댓글을 끝내요. 이제 댓글을 서버에 POST로 저장하고, 그 응답을 화면에 바로 띄워요. Step 3에서 만든 createComment가 드디어 일할 시간이에요.

먼저 작은 준비가 하나 필요해요. 댓글을 저장하려면 "몇 번 게시물에 다는 댓글인지"를 알아야 하죠. 그래서 게시물을 그릴 때 그 게시물의 id를 요소에 적어둬요. 지난 시간 renderPost에 딱 한 줄을 더해요.

// instagram-clone-frontend/js/feed.js
export function renderPost(post) {
  const article = document.createElement("article");
  article.dataset.postId = post.id; // 어느 게시물인지 기억해 둬요 (댓글 POST 에 필요)
  // ... 나머지는 지난 시간 renderPost 그대로 (header · figure · 좋아요 · 댓글 폼) ...
  return article;
}

article.dataset.postId = post.id — 이게 HTML의 data-post-id 속성으로 박혀요. dataset은 "이 요소에 내가 쓰려고 붙여두는 메모지"라고 보면 돼요. 화면엔 안 보이지만, 나중에 "이 게시물 몇 번이지?"를 물어보면 알려줘요.

이제 댓글 폼 제출을 처리해요. 지난 시간 이 폼 제출은 댓글을 화면에만 추가했어요(저장은 안 했죠). 오늘은 서버에 먼저 저장하고, 성공하면 화면에 반영하도록 바꿔요.

// instagram-clone-frontend/js/feed.js
feed.addEventListener("submit", async (event) => {
  const form = event.target.closest(".comment-form");
  if (!form) return;
  event.preventDefault(); // 폼의 기본 동작(새로고침)을 멈춰요

  const article = form.closest("article");
  const input = form.querySelector(".comment-input");
  const text = input.value.trim();
  if (!text) return; // 빈 댓글은 무시

  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) {
    showToast("댓글을 저장하지 못했어요: " + error.message);
  }
});

앞부분은 지난 시간(D-2 이벤트 위임)과 같아요. submit 이벤트를 feed 한 곳에서 받아, 댓글 폼인지 확인하고, preventDefault로 새로고침을 막고, 입력값을 다듬어요(빈 댓글은 무시). 이벤트 위임 덕분에 무한 스크롤로 나중에 그려진 게시물의 댓글 폼도 그대로 잡혀요.

달라진 건 가운데 세 줄이에요. 먼저 아까 박아둔 article.dataset.postId를 꺼내 Number로 숫자로 바꿔요(dataset은 항상 글자라서요). 그다음이 핵심이에요.

  1. await createComment(postId, text) — 댓글을 서버에 POST로 저장하고, 서버가 id를 붙여 돌려준 댓글을 saved에 받아요. await저장이 끝날 때까지 기다려요.
  2. addComment(index, saved.text) — 저장이 성공한 다음에야 화면에 추가해요. addComment는 D-1에서 만든 그 함수를 그대로 재사용해요(댓글 한 줄을 <li>로 만들어 붙이는 함수죠).
  3. input.value = "" — 입력칸을 비워, 다음 댓글을 쓸 수 있게요.

여기서 중요한 설계가 보여요. 화면에 먼저 그리고 저장하는 게 아니라, 서버에 저장이 성공한 다음에 화면에 그려요. 그래서 만약 저장이 실패하면(서버 꺼짐 등) catch로 굴러떨어져 토스트만 뜨고, 화면엔 댓글이 안 생겨요. "화면엔 있는데 서버엔 없는" 거짓 댓글을 막는 거죠. 이제 댓글을 달고 새로고침해도, 서버에 저장됐으니 그대로 남아 있어요. 지난 시간의 가장 큰 아쉬움이 풀린 순간이에요.

💡 댓글 폼 제출을 "서버 저장 먼저, 화면 반영 나중" 순서로 바꿔요. dataset.postId로 몇 번 게시물인지 알아내고, await createComment로 저장한 뒤, 성공하면 D-1의 addComment로 화면에 추가하고 입력칸을 비워요. 저장이 실패하면 화면에 안 그려서 "거짓 댓글"을 막아요. 이제 새로고침해도 댓글이 남아요.


마무리

오늘 우리는 지난 시간 깔아둔 fetch 위에, 진짜 앱다운 기능 세 가지를 얹었어요. 받기만 하던 화면이 저장하고, 더 불러오고, 기다림과 실패를 사용자에게 알려주는 화면으로 자랐죠. 되짚어볼게요.

  • 상태 코드 — 서버 응답엔 숫자가 붙어요. 2xx 성공, 4xx 요청 잘못, 5xx 서버 잘못. describeStatus로 사람이 읽을 메시지로 바꿨어요.
  • 페이지네이션?_page · ?_per_page로 한 페이지씩 받고, next로 더 있는지 판단했어요.
  • POSTmethod · headers · body(JSON.stringify)로 댓글을 서버에 저장하고, 201과 함께 id를 돌려받았어요.
  • 로딩 스피너 — CSS만으로 빙글빙글 도는 표시를 만들어, 기다리는 동안 띄웠어요.
  • 무한 스크롤 — IntersectionObserver로 감시병을 지켜보다, 화면에 들어오면 다음 페이지를 불러왔어요.
  • AbortSignaltimeout(8000)으로 응답이 너무 늦는 요청을 스스로 끊었어요.
  • 에러 토스트 — 두 종류의 실패를 try...catch로 받아, 화면에 알림을 띄웠어요.
  • 중복 요청 방지loading 빗장으로 "한 번에 하나의 요청"을 지켰어요.

한 줄로 외워두세요. 저장은 POST로(createComment) → 더 불러오기는 감시병으로(IntersectionObserver) → 기다림과 실패는 표시로(스피너·토스트). 이 세 가지가 서버와 대화하는 모든 화면의 살림 도구예요.

다음 시간 예고

오늘까지 우리는 댓글을 username: "soongu_hong"으로 고정해서 저장했어요. 그런데 진짜 앱이라면, 댓글을 단 사람이 로그인한 나여야 하죠. 그러려면 "지금 로그인한 사람이 누구인지"를 브라우저가 기억하고 있어야 해요.

다음 시간(D-5)엔 브라우저에 데이터를 저장하는 localStorage를 배워요. 로그인 정보(토큰)를 브라우저에 저장해 뒀다가, 서버에 요청할 때마다 "이건 누구의 요청이다"를 자동으로 붙여 보내는 거예요. 오늘 POST로 댓글을 보낼 때 username을 고정했던 그 자리에, 진짜 로그인한 사용자가 들어가요. 그리고 개발자의 무기인 Chrome 개발자 도구(DevTools) 로 네트워크 요청을 들여다보고, 코드를 한 줄씩 멈춰가며 디버깅하는 법도 익혀요. 다음 시간에 만나요!


과제

오늘 배운 흐름(상태 코드 · 페이지네이션 · POST · 무한 스크롤 · 로딩 · 토스트)을 직접 익혀볼 차례예요. 모든 과제는 json-server를 켠 채로(npx json-server mock/db.json --port 3001), feed.html을 Live Server로 열고 화면과 콘솔(F12) · 네트워크 탭에서 결과를 확인하세요.

[구현] 페이지 크기를 바꿔 무한 스크롤 체감하기

지금 우리는 한 페이지에 3개씩 불러와요. 이 숫자를 바꿔보며 무한 스크롤의 동작을 눈으로 확인하는 과제예요.

  • feed.jsloadPage에서 fetchPosts(currentPage)fetchPosts(currentPage, 2)로 바꿔, 한 페이지에 2개씩 불러와보세요.
  • 게시물이 8개니, 2개씩이면 몇 페이지가 되는지 세어보고, 스크롤을 끝까지 내리며 페이지가 몇 번 더 불러와지는지 관찰하세요.
  • 네트워크 탭(F12 → Network)을 열어두면, 스크롤할 때마다 posts?_page=... 요청이 새로 나가는 게 보여요. 마지막 페이지 뒤엔 더 안 나가는 것도 확인하세요(hasMore가 막아줘요).

[구현] 빈 댓글·실패 상황 다뤄보기

댓글 저장이 실패하거나 입력이 비었을 때를 직접 만들어보는 과제예요.

  • 댓글 입력칸에 아무것도 안 쓰고 "게시"를 눌러보세요. if (!text) return 덕분에 아무 일도 안 일어나는 걸 확인하세요.
  • json-server를 끈 상태에서 댓글을 달아보세요. 저장이 실패해서 화면 아래에 토스트가 뜨고, 댓글은 화면에 안 생기는 걸 확인하세요(서버 저장 성공 후에만 화면에 그리니까요).
  • 서버를 다시 켜고 댓글을 단 뒤, mock/db.json을 열어 comments 배열에 내 댓글이 진짜 저장됐는지 눈으로 확인하세요.

[탐구] 로딩이 너무 빨라서 안 보인다면?

스피너가 깜빡하고 사라져서 잘 안 보일 수 있어요. 일부러 느리게 만들어 관찰하는 탐구예요.

  • json-server는 너무 빨라서 스피너가 순식간에 사라져요. api.jsfetchPosts에서 await fetch(...) 앞에, 일부러 1초 기다리는 줄을 넣어보세요: await new Promise((resolve) => setTimeout(resolve, 1000)); (C-7에서 배운 Promise예요).
  • 그 상태로 스크롤을 내리면, 스피너가 1초씩 또렷하게 보여요. 로딩 표시가 왜 필요한지 몸으로 느껴보세요.
  • 관찰이 끝나면 그 줄은 꼭 지우세요(진짜 앱에 일부러 느리게 만드는 코드를 두면 안 되니까요).

생각해볼 주제

정답을 적는 문제가 아니에요. 오늘 배운 것의 "왜"를 곱씹어보는 질문들이에요. 스스로 답을 만들어본 뒤, 예시답안과 비교해보세요.

1. 댓글을 화면에 먼저 그릴까, 저장 성공 후에 그릴까?

오늘 우리는 댓글을 서버에 저장이 성공한 다음에 화면에 그렸어요(await createCommentaddComment). 그래서 저장이 실패하면 화면에 댓글이 안 생기죠.

그런데 반대로 만들 수도 있어요. 댓글을 화면에 먼저 휙 띄우고, 저장은 뒤에서 조용히 하는 방식이요(잘 안 되면 그때 지우고요). 후자는 사용자가 기다림 없이 즉시 반응을 봐서 빠르게 느껴져요. 대신 저장이 실패하면 띄웠던 댓글을 다시 거둬들여야 하죠. 둘 중 어느 쪽이 더 나을까요? "정확함(저장된 것만 보여주기)"과 "빠른 느낌(일단 보여주기)" 사이에서, 댓글 같은 기능엔 무엇이 더 어울릴지 생각해보세요.

2. 무한 스크롤은 정말 좋기만 할까?

오늘 만든 무한 스크롤은 스크롤을 내릴수록 끝없이 콘텐츠가 나와요. 인스타·유튜브가 다 이걸 쓰는 데는 이유가 있죠 — 사용자가 더 오래 머물러요.

그런데 "끝이 없다"는 게 항상 좋을까요? 검색 결과나 쇼핑몰 상품처럼 "몇 번째 페이지에서 본 그거"를 다시 찾고 싶을 때, 무한 스크롤은 어떨까요? 또 맨 아래의 푸터(회사 정보·약관 링크)는 영영 못 보게 되진 않을까요? 무한 스크롤이 어울리는 화면과, 차라리 "1·2·3 페이지 버튼"이 나은 화면을 나눠 생각해보세요.

3. 상태 코드는 왜 숫자로 정해져 있을까?

서버는 실패를 그냥 "에러"라고 안 하고, 굳이 404·500 같은 숫자로 구분해서 보내요. 오늘 우리도 그 숫자로 메시지를 갈랐죠(describeStatus).

만약 모든 실패가 그냥 "오류"라는 한 가지였다면 어떨까요? 클라이언트(우리)는 "주소를 틀린 건지(404), 서버가 탈난 건지(500)"를 구분 못 해서, 사용자에게 똑같은 안내밖에 못 하겠죠. 전 세계 서버가 같은 번호 약속을 공유한다는 게, 왜 인터넷을 이렇게 매끄럽게 굴러가게 하는지 곱씹어보세요. 첫 자리(2·4·5)만으로 책임이 갈리는 설계의 똑똑함도요.

✅ 예시 답안정답 보기

여기 있는 답안은 "이것만 정답"이 아니라 모범 사례 중 하나예요. 같은 동작을 만드는 길은 여러 갈래라서, 여러분이 짠 코드가 모양이 조금 달라도 핵심 동작만 맞으면 충분히 잘 푼 거예요. 답안과 비교하면서 "왜 이렇게 했는지"를 곱씹는 게 진짜 공부예요.

모든 과제는 json-server를 켠 채로(npx json-server mock/db.json --port 3001) feed.html을 Live Server로 열고, 화면과 콘솔(F12) · 네트워크 탭에서 확인하세요.

과제 1: 페이지 크기를 바꿔 무한 스크롤 체감하기

핵심 접근

오늘 loadPagefetchPosts(currentPage)로 한 페이지를 불러와요. 그런데 fetchPosts는 두 번째 매개변수로 페이지당 개수도 받게 만들어뒀죠(fetchPosts(page = 1, perPage = 3)). 그러니 부르는 쪽에서 fetchPosts(currentPage, 2)라고 하면, 한 페이지에 2개씩 불러와요. 코드 한 군데, 숫자 하나만 바꾸면 무한 스크롤의 호흡이 달라져요.

예시 구현

// instagram-clone-frontend/js/feed.js
async function loadPage() {
  const loadingBox = showLoading();
  try {
    const result = await fetchPosts(currentPage, 2); // 한 페이지에 2개씩
    for (const post of result.data) {
      feedMain.insertBefore(renderPost(post), sentinel);
    }
    hasMore = result.next !== null;
    currentPage += 1;
  } catch (error) {
    showToast(error.message);
  } finally {
    loadingBox.remove();
  }
}

fetchPosts(currentPage)fetchPosts(currentPage, 2)로 딱 한 군데만 고쳤어요. 게시물이 8개니, 2개씩이면 4페이지가 돼요(8 ÷ 2 = 4). 스크롤을 끝까지 내리면, 첫 페이지를 포함해 총 4번 불러와지는 걸 볼 수 있어요. 마지막 4페이지를 받고 나면 nextnull이라 hasMorefalse가 되고, 그 뒤론 아무리 스크롤해도 더 안 불러와요.

네트워크 탭(F12 → Network)을 열어두면 더 또렷해요. 스크롤할 때마다 posts?_page=1, posts?_page=2... 요청이 하나씩 새로 나가요. 4페이지 뒤엔 요청이 멈추는 것도 확인하세요. 그게 hasMore가 막아주는 모습이에요.

채점 포인트

포인트 무엇을 보는가 배점 가중
perPage 전달 fetchPosts의 두 번째 인자로 개수를 넘겼는가
페이지 수 계산 게시물 8개를 2개씩 나누면 4페이지임을 이해했는가
요청 관찰 네트워크 탭에서 ?_page= 요청이 스크롤마다 나가는 걸 봤는가
멈춤 이해 마지막 페이지 뒤 hasMore가 요청을 막는 걸 확인했는가

흔한 실수

  • fetchPosts(2)로 적었다 → 첫 번째 인자는 페이지 번호예요. 2를 넣으면 "2페이지부터 불러오기"가 돼요. 개수는 두 번째 인자라 fetchPosts(currentPage, 2)로 적어야 해요.
  • 무한히 요청이 나간다hasMore = result.next !== null 줄을 지웠거나, setupInfiniteScrollif (!hasMore) return을 빠뜨리면 마지막 페이지에서도 계속 요청해요. 두 줄이 짝으로 동작해요.
  • 개수를 0으로 줬다fetchPosts(currentPage, 0)이면 한 페이지에 0개라 영영 안 나와요. 1 이상으로 주세요.

실무 개선 포인트 (심화)

  • 페이지 크기는 보통 더 큼: 실무에선 한 번에 10~20개씩 불러와요. 우리는 게시물이 8개뿐이라 무한 스크롤을 체감하려고 일부러 작게(3개) 줬어요. 진짜 서비스에선 "한 화면에 보이는 양 + 약간 여유"만큼 받아요.
  • 커서 방식: 우리는 페이지 번호(_page=1, 2, 3)로 나눴는데, 게시물이 실시간으로 추가되는 서비스에선 페이지 번호가 밀려서 중복·누락이 생겨요. 그래서 "마지막으로 본 게시물 id 다음부터"를 받는 커서(cursor) 방식을 많이 써요. 지금은 페이지 번호로 충분해요.

과제 2: 빈 댓글·실패 상황 다뤄보기

핵심 접근

이건 새 코드를 짜는 과제가 아니라, 오늘 만든 댓글 저장이 여러 상황에서 어떻게 동작하는지 직접 확인하는 과제예요. 두 가지를 봐요. 빈 입력을 막는 if (!text) return과, 저장이 실패했을 때 화면에 댓글이 안 생기는 흐름이요.

예시 관찰

오늘 댓글 폼 제출 코드를 다시 보면서 따라가 볼게요.

// instagram-clone-frontend/js/feed.js
feed.addEventListener("submit", async (event) => {
  const form = event.target.closest(".comment-form");
  if (!form) return;
  event.preventDefault();

  const article = form.closest("article");
  const input = form.querySelector(".comment-input");
  const text = input.value.trim();
  if (!text) return; // ← 빈 댓글은 여기서 멈춤

  const postId = Number(article.dataset.postId);
  try {
    const saved = await createComment(postId, text); // ← 저장 실패하면 catch로
    const index = [...document.querySelectorAll("article")].indexOf(article);
    addComment(index, saved.text);                    // ← 성공해야 여기 도달
    input.value = "";
  } catch (error) {
    showToast("댓글을 저장하지 못했어요: " + error.message);
  }
});

빈 댓글을 달면: 입력칸에 아무것도 안 쓰고 "게시"를 누르면, text가 빈 글자라 if (!text) return에서 멈춰요. createComment까지 가지도 않아요. 그래서 아무 일도 안 일어나죠. input.value.trim()trim()은 앞뒤 공백을 지우니, 스페이스만 잔뜩 친 경우도 빈 댓글로 걸러져요.

서버가 꺼진 상태에서 달면: createComment 안의 fetch가 갈 곳이 없어 오류를 던져요. 그러면 await createComment(...)에서 바로 catch로 굴러떨어져요. catch는 토스트만 띄우고, 그 아래 addCommentinput.value = ""실행되지 않아요. 그래서 화면에 댓글이 안 생기고, 입력칸의 글자도 그대로 남아요(사용자가 다시 시도할 수 있게요).

  빈 입력      →  if (!text) return  →  조용히 멈춤 (요청 안 보냄)
  서버 꺼짐    →  createComment 오류  →  catch  →  토스트만, 화면엔 댓글 X
  정상         →  201 저장 성공       →  addComment + 입력칸 비우기

서버를 다시 켜고 댓글을 단 뒤, mock/db.json을 열어 comments 배열을 보세요. 방금 단 댓글이 { postId, username, text, id } 모양으로 진짜 저장돼 있어요. json-server가 POST를 받아 파일에 적어준 거예요.

채점 포인트

포인트 무엇을 보는가 배점 가중
빈 입력 차단 빈 댓글에서 if (!text) return이 막는 걸 확인했는가
실패 시 화면 보호 서버 꺼짐 → 토스트만 뜨고 댓글은 안 생기는 걸 봤는가
저장 순서 이해 "서버 저장 성공 후에만 화면 반영"의 흐름을 설명할 수 있는가
영속 확인 db.json에서 저장된 댓글을 직접 확인했는가
trim 이해 공백만 친 입력도 빈 댓글로 걸러짐을 봤는가

흔한 실수

  • "화면엔 떴는데 새로고침하니 사라졌다" → 만약 addCommentcreateComment보다 먼저 불렀다면, 저장 안 된 댓글이 화면에만 떠요. 오늘 코드는 await createComment 다음에 addComment라, 이런 거짓 댓글이 안 생겨요.
  • 서버 꺼졌는데 댓글이 화면에 남는다addCommenttry 밖에 있거나 catch 위에 있으면 그래요. addComment는 반드시 await createComment 성공 뒤(같은 try 안)에 있어야 해요.
  • 토스트가 안 보인다showToastcatch에 없거나, components.css.toast 스타일이 빠지면 화면에 안 떠요. 콘솔엔 오류가 찍히는데 화면이 조용하면 이걸 의심하세요.

실무 개선 포인트 (심화)

  • 저장 중 버튼 잠그기: 저장이 끝나기 전에 "게시"를 여러 번 누르면 같은 댓글이 여러 번 저장될 수 있어요. 실무에선 요청을 보내는 동안 버튼을 잠깐 비활성화(disabled)해요. 무한 스크롤의 loading 빗장과 같은 아이디어예요.
  • 글자 수 제한: 진짜 댓글창엔 최대 글자 수가 있어요. text.length로 길이를 재서, 너무 길면 막거나 잘라요. 지금은 신경 쓰지 않아도 돼요.

과제 3: 로딩이 너무 빨라서 안 보인다면?

핵심 접근

json-server는 내 컴퓨터 안에 있어서 응답이 0.01초 만에 와요. 그래서 스피너가 깜빡하고 사라져 잘 안 보여요. 일부러 응답을 늦춰서 스피너를 또렷이 관찰하는 탐구예요. C-7에서 배운 "잠깐 기다리는 Promise"를 fetchPosts 앞에 끼우면 돼요.

예시 구현

// instagram-clone-frontend/js/api.js
export async function fetchPosts(page = 1, perPage = 3) {
  // ⚠ 관찰용으로 일부러 1초 지연 — 확인 끝나면 꼭 지우세요!
  await new Promise((resolve) => setTimeout(resolve, 1000));

  const url = `${BASE_URL}/posts?_page=${page}&_per_page=${perPage}`;
  const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
  if (!response.ok) {
    throw new Error(describeStatus(response.status));
  }
  return await response.json();
}

await new Promise((resolve) => setTimeout(resolve, 1000)) — 이 한 줄이 "1초 동안 멈췄다 가기"예요. C-7에서 setTimeout을 Promise로 감싸 "기다리는 약속"을 만들었던 그 방법이에요. await로 이 약속을 기다리니, fetch가 1초 늦게 시작돼요.

이 줄을 넣고 스크롤을 내리면, 다음 페이지를 불러올 때마다 스피너가 1초씩 또렷하게 빙글빙글 돌아요. "불러오는 중..." 글자도 같이요. 이제 로딩 표시가 왜 필요한지 몸으로 느껴지죠? 실제 사용자의 인터넷은 우리 내 컴퓨터보다 훨씬 느리니, 이 1초가 실전에선 흔한 일이에요.

관찰이 끝나면 그 줄은 꼭 지우세요. 진짜 앱에 일부러 느리게 만드는 코드를 두면 안 되니까요. 이건 어디까지나 "눈으로 확인하려고 잠깐 넣는" 실험 코드예요.

채점 포인트

포인트 무엇을 보는가 배점 가중
지연 삽입 setTimeout을 Promise로 감싼 지연을 fetch 앞에 넣었는가
스피너 관찰 지연 동안 스피너가 또렷이 도는 걸 봤는가
C-7 연결 "기다리는 Promise"가 C-7에서 배운 것임을 이해했는가
정리 관찰 후 실험 줄을 지웠는가

흔한 실수

  • setTimeout만 쓰고 await을 안 붙였다setTimeout(() => {}, 1000)만 있으면 fetch가 안 기다리고 그냥 지나가요. await new Promise(...)로 감싸야 진짜 멈춰요(C-7에서 배운 이유예요).
  • 실험 줄을 안 지웠다 → 모든 요청이 1초씩 느려져서, 앱이 답답해져요. 관찰이 끝나면 반드시 지우세요.
  • 로딩이 안 사라진다 → 지연과는 별개로, finallyloadingBox.remove()가 빠지면 스피너가 안 사라져요. 지연은 "더 오래 보이게"일 뿐, 치우는 건 finally의 몫이에요.

실무 개선 포인트 (심화)

  • 스켈레톤 UI: 실무에선 빙글빙글 스피너 대신, 게시물 모양의 회색 뼈대(스켈레톤)를 보여주기도 해요. "여기 곧 게시물이 들어올 거야"를 미리 보여줘서 기다림이 덜 답답해요. 원리는 같아요 — 불러오기 전 표시, 후 제거요.
  • 네트워크 느리게 흉내: 코드를 안 고치고도 느린 네트워크를 흉내 낼 수 있어요. Chrome 개발자 도구(Network 탭 → Throttling)에서 "Slow 3G"를 고르면 돼요. 다음 시간에 개발자 도구를 배우면 이 방법도 써봐요.

생각해볼 주제 1: 댓글을 화면에 먼저 그릴까, 저장 성공 후에 그릴까?

[문제 상황 요약]

오늘 우리는 댓글을 서버에 저장이 성공한 다음에 화면에 그렸어요(await createCommentaddComment). 그래서 저장이 실패하면 화면에 댓글이 안 생기죠. 그런데 반대로 댓글을 화면에 먼저 띄우고 저장은 뒤에서 하는 방식도 있어요. 둘 중 무엇이 더 나을까요?

[튜터의 가이드 및 해설]

이 질문의 핵심은 "정확함"과 "빠른 느낌" 사이의 선택이에요. 두 방식을 나란히 보면 트레이드오프가 또렷해져요.

  • Option A — 저장 성공 후 그리기 (오늘 우리 방식): await로 저장이 끝날 때까지 기다렸다가, 성공하면 화면에 그려요. 장점은 정확함이에요. 화면에 보이는 댓글은 전부 서버에 진짜 저장된 거라, "화면엔 있는데 새로고침하니 사라지는" 거짓 댓글이 없어요. 단점은 약간의 기다림이에요. 서버가 느리면, 댓글이 화면에 뜨기까지 사용자가 잠깐 기다려야 해요.

  • Option B — 화면에 먼저 그리고 저장 (낙관적 업데이트): 댓글을 화면에 즉시 띄우고, 저장은 뒤에서 조용히 해요. 장점은 빠른 느낌이에요. 누르자마자 댓글이 보이니 반응이 즉각적이죠. 단점은 거둬들이기예요. 만약 저장이 실패하면, 띄웠던 댓글을 다시 지우고 "저장 실패" 같은 안내를 해야 해요. 안 그러면 사용자는 저장된 줄 알았는데 사실은 안 된, 가장 나쁜 상황이 생겨요.

  Option A (저장 후 그리기)    정확함 ↑   빠른 느낌 ↓   실패 처리 단순(안 그리면 끝)
  Option B (먼저 그리기)       빠른 느낌 ↑  정확함 ↓    실패 시 거둬들이기 필요

현업에서는 보통 좋아요·댓글처럼 "거의 항상 성공하고, 실패해도 큰일 안 나는" 가벼운 동작엔 Option B(낙관적 업데이트)를 자주 써요. 반응이 빨라 사용자 경험이 좋거든요. 반대로 결제·예약처럼 "정확함이 생명인" 동작엔 Option A로 꼭 서버 응답을 받고 화면을 바꿔요. 잘못 보여주면 큰 사고니까요. 오늘 우리가 Option A로 만든 건, 처음 배우는 단계라 "저장된 것만 보여준다"는 안전한 흐름을 몸에 익히기 위해서예요.

🎯 면접관을 홀리는 핵심 멘트

"화면 반영을 서버 응답 전에 할지 후에 할지는 '빠른 반응'과 '정확함'의 트레이드오프입니다. 좋아요·댓글처럼 대부분 성공하고 실패해도 치명적이지 않은 동작은 낙관적 업데이트로 먼저 화면에 반영해 반응성을 높이되, 실패 시 되돌리는 처리를 함께 둡니다. 반대로 결제처럼 정확함이 중요한 동작은 반드시 서버 응답을 받은 뒤 화면을 바꿉니다. 기준은 '실패했을 때의 대가가 얼마나 큰가'입니다."


생각해볼 주제 2: 무한 스크롤은 정말 좋기만 할까?

[문제 상황 요약]

오늘 만든 무한 스크롤은 스크롤을 내릴수록 끝없이 콘텐츠가 나와요. 인스타·유튜브가 다 이걸 쓰죠. 그런데 "끝이 없다"는 게 항상 좋을까요? 무한 스크롤이 어울리는 화면과, 차라리 "1·2·3 페이지 버튼"이 나은 화면을 나눠 생각해봐요.

[튜터의 가이드 및 해설]

이 질문의 핵심은 "사용자가 무엇을 하러 왔는가" 예요. 화면의 목적에 따라 무한 스크롤이 약이 되기도, 독이 되기도 해요.

  • 무한 스크롤이 좋은 곳 — 둘러보는 화면: 인스타 피드, 유튜브 추천처럼 "딱히 뭘 찾는 건 아니고 그냥 구경하는" 화면이에요. 끝이 없으니 사용자가 계속 머물고, 다음 걸 보려고 버튼을 누르는 수고도 없어요. 시간 가는 줄 모르고 보게 되죠(서비스 입장에선 좋아요).

  • 무한 스크롤이 불편한 곳 — 찾으러 온 화면: 검색 결과나 쇼핑몰 상품 목록처럼 "특정한 걸 찾으러 온" 화면이에요. 여기선 문제가 생겨요. "아까 50번째쯤에서 본 그 상품"을 다시 보려면, 스크롤을 처음부터 다시 내려야 해요(몇 페이지였는지 표시가 없으니까요). 또 맨 아래의 푸터(회사 정보·약관·고객센터 링크)는 영영 못 봐요. 스크롤하면 계속 콘텐츠가 새로 나와서, 바닥에 도달할 수가 없거든요.

  둘러보는 화면(피드·추천)   →  무한 스크롤  →  오래 머묾, 수고 없음
  찾으러 온 화면(검색·쇼핑)   →  페이지 버튼  →  "그거 3페이지에 있었지" 다시 찾기 쉬움, 푸터 접근 가능

현업에서는 보통 둘을 섞어 써요. 피드·타임라인은 무한 스크롤, 검색 결과·관리자 목록은 페이지 번호 방식으로요. 어떤 곳은 "더 보기" 버튼이라는 절충안도 써요(자동으로 안 불러오고, 누를 때만 다음 걸 불러와서 푸터에도 닿을 수 있게요). 즉 "사용자가 둘러보러 왔나, 찾으러 왔나"를 기준으로 고르는 거예요. 무한 스크롤은 멋있어 보이지만, 모든 화면의 정답은 아니에요.

🎯 면접관을 홀리는 핵심 멘트

"무한 스크롤은 만능이 아닙니다. 피드·추천처럼 '둘러보는' 화면엔 체류 시간을 늘려 적합하지만, 검색 결과·상품 목록처럼 '특정 항목을 찾고 되돌아오는' 화면에선 위치 기억이 어렵고 푸터에 접근할 수 없는 단점이 있습니다. 그래서 둘러보는 화면은 무한 스크롤, 찾는 화면은 페이지네이션, 절충이 필요하면 '더 보기' 버튼으로 나눕니다. UI 패턴은 화면의 사용 목적에 맞춰 선택해야 합니다."


생각해볼 주제 3: 상태 코드는 왜 숫자로 정해져 있을까?

[문제 상황 요약]

서버는 실패를 그냥 "에러"라고 안 하고, 굳이 404·500 같은 숫자로 구분해 보내요. 오늘 우리도 그 숫자로 메시지를 갈랐죠(describeStatus). 만약 모든 실패가 그냥 "오류"라는 한 가지였다면 어땠을까요?

[튜터의 가이드 및 해설]

이 질문의 핵심은 "받는 쪽이 스스로 판단할 수 있는가" 예요. 숫자로 약속해 두면, 클라이언트가 서버에 다시 물어보지 않고도 무슨 상황인지 알 수 있어요.

  • 만약 모든 실패가 "오류" 하나라면: 클라이언트(우리)는 실패의 이유를 알 수 없어요. 주소를 틀린 건지(내 잘못), 서버가 탈난 건지(서버 잘못), 권한이 없는 건지 구분이 안 되죠. 그러면 사용자에게 늘 똑같은 "오류가 났어요"밖에 못 보여줘요. "없는 페이지예요"인지 "잠시 후 다시 시도하세요"인지 갈라줄 수가 없어요.

  • 숫자로 약속해 두면: 첫 자리만 봐도 책임이 갈려요. 4로 시작하면 요청한 쪽(나) 잘못, 5로 시작하면 서버 잘못이죠. 그래서 오늘 describeStatus404엔 "찾는 데이터가 없어요", 500엔 "서버에 문제가 생겼어요"처럼 다른 안내를 내줄 수 있었어요. 클라이언트가 서버에 "이거 왜 실패한 거야?"라고 다시 안 물어봐도, 숫자 하나로 바로 판단하는 거예요.

  실패 = "오류" 하나      →  이유 모름  →  사용자에게 늘 같은 안내밖에
  실패 = 숫자로 구분      →  4xx 내 잘못 / 5xx 서버 잘못  →  상황별 다른 안내 가능

여기에 더 큰 그림이 있어요. 이 숫자는 전 세계가 함께 쓰는 약속이에요. 우리 json-server도, 진짜 인스타 서버도, 어떤 나라의 어떤 서버도 "없으면 404, 서버 탈나면 500"을 똑같이 보내요. 그래서 우리가 만든 describeStatus어떤 서버를 만나도 그대로 통해요. 만약 서버마다 실패 표현이 제각각이었다면, 서버를 바꿀 때마다 클라이언트 코드를 다시 짜야 했겠죠. 공통 번호 약속 하나가, 전 세계 클라이언트와 서버가 서로 처음 만나도 대화가 되게 만드는 거예요.

🎯 면접관을 홀리는 핵심 멘트

"HTTP 상태 코드가 숫자로 표준화돼 있는 덕분에, 클라이언트는 서버에 다시 묻지 않고도 첫 자리만 보고 실패의 성격을 판단할 수 있습니다. 4xx는 요청한 쪽 문제, 5xx는 서버 문제로 갈려, 사용자에게 상황에 맞는 안내를 내릴 수 있죠. 더 중요한 건 이게 전 세계 공통 약속이라, 어떤 서버를 상대하든 같은 처리 코드가 통한다는 점입니다. 표준 덕분에 클라이언트와 서버가 서로 약속을 다시 맞추지 않아도 즉시 협업할 수 있습니다."

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.