# [Next.js + TS] 6. 데이터 가져오기

[Next.js + TS] 6. 데이터 가져오기

5편까지는 lib/memos.ts의 배열을 동기적으로 읽었습니다.
이번 편은 서버 컴포넌트에서 데이터를 기다리는(async) 방식으로 바꿉니다.
목표: async 페이지 · 로딩 UI · 에러 UI.


이 편에서 할 일

  1. React의 useEffect + fetch와 Next 서버 fetch의 차이 이해하기
  2. getMemos / getMemoById를 async로 바꾸기
  3. 목록·상세 페이지를 async로 고치기
  4. loading.tsx / error.tsx 맛보기

DB나 외부 API를 당장 붙이지 않아도 됩니다.
“가져오는 동안 기다린다” 흐름만 잡으면 됩니다.


왜 useEffect로 안 가져오나

Vite/CRA에서 흔했던 패턴:

"use client";

useEffect(() => {
  fetch("/api/memos")
    .then((res) => res.json())
    .then(setMemos);
}, []);

문제는 초기 흐름이 이렇게 됩니다.

  1. 빈 화면(또는 로딩) 먼저 그림
  2. JS 실행
  3. 그제야 요청
  4. 다시 그림

App Router 서버 컴포넌트에서는:

export default async function MemosPage() {
  const memos = await getMemos();
  return ...
}
  1. 서버에서 데이터를 기다림
  2. 완성된 HTML을 보냄

목록처럼 첫 화면에 꼭 필요한 데이터는 서버에서 가져오는 쪽이 자연스럽습니다.
클릭 이후 추가 로드, 검색어 디바운스 같은 건 클라이언트 fetch가 맞는 경우도 많습니다.


1) 데이터 함수를 async로

lib/memos.ts를 아래처럼 바꿉니다.

import type { Memo } from "@/types/memo";

const memos: Memo[] = [
  {
    id: "1",
    title: "Next.js 시작",
    content: "App Router로 프로젝트 만들었다.",
    createdAt: "2026-07-29",
  },
  {
    id: "2",
    title: "TypeScript props",
    content: "MemoCard에 타입을 붙였다.",
    createdAt: "2026-07-29",
  },
];

function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function getMemos(): Promise<Memo[]> {
  await delay(800); // 로딩 UI를 보기 위한 가짜 대기
  return memos;
}

export async function getMemoById(id: string): Promise<Memo | undefined> {
  await delay(500);
  return memos.find((memo) => memo.id === id);
}

포인트:

  • export const memos 직접 노출 대신 함수로만 가져오게 바꿈
  • Promise<Memo[]>로 반환 타입을 명시
  • delay는 실습용입니다. 실제 DB/API 자리라고 생각하면 됩니다

서버 컴포넌트에서는 자기 프로젝트의 /api를 다시 fetch하기보다,
이런 데이터 함수를 직접 await 하는 방식을 먼저 쓰는 게 좋습니다.


2) 목록 페이지를 async로

app/memos/page.tsx:

import MemoCard from "@/components/MemoCard";
import { getMemos } from "@/lib/memos";

export default async function MemosPage() {
  const memos = await getMemos();

  return (
    <main>
      <h1>메모 목록</h1>

      <section>
        {memos.map((memo) => (
          <MemoCard key={memo.id} memo={memo} />
        ))}
      </section>
    </main>
  );
}

바뀐 점:

  • async function
  • await getMemos()
  • 더 이상 import { memos }가 아님

3) 상세도 동일하게

app/memos/[id]/page.tsx:

import { notFound } from "next/navigation";
import { getMemoById } from "@/lib/memos";

type MemoDetailPageProps = {
  params: Promise<{ id: string }>;
};

export default async function MemoDetailPage({ params }: MemoDetailPageProps) {
  const { id } = await params;
  const memo = await getMemoById(id);

  if (!memo) {
    notFound();
  }

  return (
    <main>
      <h1>{memo.title}</h1>
      <p>{memo.content}</p>
      <time dateTime={memo.createdAt}>{memo.createdAt}</time>
    </main>
  );
}

getMemoById 앞에 await만 추가하면 됩니다.
(이미 async 페이지였다면 그대로 두면 됩니다.)


4) 로딩 UI: loading.tsx

데이터 기다리는 동안 보여줄 파일을 만듭니다.

경로: app/memos/loading.tsx

export default function MemosLoading() {
  return (
    <main>
      <p>메모를 불러오는 중...</p>
    </main>
  );
}

/memos로 이동하면 잠깐 이 문구가 보였다가 목록이 뜹니다.
(delay(800) 덕분에 눈에 보입니다.)

loading.tsx는 같은 세그먼트(memos)의 page.tsx를 감싸는 즉시 로딩 UI입니다.
상세(memos/[id])에도 따로 두고 싶으면:

app/memos/[id]/loading.tsx

를 추가하면 됩니다.


5) 에러 UI: error.tsx

경로: app/memos/error.tsx

에러 경계는 클라이언트 컴포넌트여야 합니다.

"use client";

type MemosErrorProps = {
  error: Error;
  reset: () => void;
};

export default function MemosError({ error, reset }: MemosErrorProps) {
  return (
    <main>
      <h1>메모를 불러오지 못했습니다</h1>
      <p>{error.message}</p>
      <button type="button" onClick={() => reset()}>
        다시 시도
      </button>
    </main>
  );
}

일부러 에러를 내려면 getMemos 안에 잠깐 넣어 볼 수 있습니다.

export async function getMemos(): Promise<Memo[]> {
  await delay(800);
  throw new Error("샘플 에러입니다");
  // return memos;
}

확인 후 throw는 지우세요.


외부 API를 fetch할 때는

진짜 HTTP API라면 서버 컴포넌트에서 이렇게 씁니다.

export default async function MemosPage() {
  const res = await fetch("https://api.example.com/memos", {
    next: { revalidate: 60 }, // 60초 캐시 예시
  });

  if (!res.ok) {
    throw new Error("메모를 가져오지 못했습니다");
  }

  const memos: Memo[] = await res.json();
  // ...
}

지금 시리즈는 로컬 배열 + delay같은 async 흐름을 연습합니다.
나중에 API URL만 바꿔 끼우면 됩니다.


자주 하는 실수

1. 서버 페이지인데 useEffect로 다시 fetch
→ 초기 목록이면 async + await를 먼저

2. await 없이 Promise를 map에 넣음
const memos = await getMemos() 필수

3. loading.tsx'use client' + useState로 직접 로딩 관리
→ 세그먼트 loading.tsx만으로도 충분한 경우가 많음

4. error.tsx를 서버 컴포넌트로 작성
error.tsx"use client" 필요


이 편에서 가져갈 것

  • 첫 화면 데이터는 서버 컴포넌트에서 await하는 편이 자연스럽다
  • 데이터 함수는 async + Promise<타입>으로 계약한다
  • loading.tsx는 기다리는 동안의 UI
  • error.tsx는 실패한 동안의 UI (클라이언트)
  • 지금은 가짜 delay, 나중엔 DB/API로 교체하면 된다

다음 편 예고

[Next.js + TS] 7. 폼과 서버 액션 입문

  • 메모 추가 폼 만들기
  • Server Action으로 데이터 넣기
  • 목록이 갱신되는 흐름 맛보기

읽기만 하던 앱에 쓰기를 붙입니다.


시리즈 목차

  1. 왜 Next.js + TypeScript인가
  2. 프로젝트 만들고 폴더 구조 이해하기
  3. TypeScript로 컴포넌트 타입 잡기
  4. 라우팅과 레이아웃
  5. Server Component vs Client Component
  6. 데이터 가져오기 ← 지금
  7. 폼과 서버 액션 입문
  8. 정리 + 다음 학습 로드맵