본문 바로가기

프로젝트 목록 UI

프로젝트 목록을 조회하고 생성·삭제 동작을 백엔드 API와 연결합니다.

앞 글에서 로그인과 라우팅을 연결했습니다. 이 글에서는 로그인한 뒤 처음 보게 되는 프로젝트 목록 화면을 만듭니다. 목록을 조회하고, 새 프로젝트를 만들고, 기존 프로젝트를 지우는 세 동작을 앞 절의 GET /projects, POST /projects, DELETE /projects/:projectId API에 연결합니다.

목록 화면에서 가장 놓치기 쉬운 것은 요청의 상태입니다. 목록 데이터만 상태로 두면, 아직 응답을 기다리는 중인지, 요청이 실패했는지, 정말로 프로젝트가 하나도 없는지를 화면에서 구별할 수 없습니다.

API 성공 뒤 목록을 갱신하고 실패·대기 상태를 별도로 보여줍니다

초기 []·GET 대기·성공 목록 적용·실패 목록 유지에서 실제 원 코드의 상태와 미구현 UI 표시를 구별한다.

Nest 프로젝트 UI · 요청과 화면 상태

API 성공 뒤 목록을 갱신하고 실패·대기 상태를 별도로 보여줍니다

현재 간략화 코드는 GET·POST·DELETE 성공 뒤 목록을 변경합니다. 아래 성공 흐름은 구현되어 있으며 로딩·화면 오류·중복 제출 억제는 추가할 UI 상태입니다.

조회 대기·실패를 빈 목록과 구분하기 현재 GET 성공은 서버 목록을 적용하고 실패는 로그만 남겨 목록을 바꾸지 않는다. 처음의 빈 배열은 대기나 실패에도 남을 수 있으며 화면에 표시할 요청 상태는 별도 추가해야 한다. 성공 실패 초기 목록 projects = [] GET /projects 응답을 기다림 요청 성공? Promise 성공/실패 서버 목록 적용 setProjects(data) 0건도 성공 결과 console.error 목록은 갱신 안 함 현재는 화면 오류 없음
조회 대기·실패를 빈 목록과 구분하기 현재 GET 성공은 서버 목록을 적용하고 실패는 로그만 남겨 목록을 바꾸지 않는다. 처음의 빈 배열은 대기나 실패에도 남을 수 있으며 화면에 표시할 요청 상태는 별도 추가해야 한다. 성공 실패 초기 목록 projects = [] GET /projects 응답을 기다림 요청 성공? 응답 성공 / 실패 서버 목록 적용 setProjects(data) 0건도 성공 결과 console.error 목록은 갱신 안 함 화면 오류 없음
목록 읽기

조회 · GET /projects

  1. 페이지 진입

    useEffect가 조회를 시작합니다.

  2. 성공 응답

    setProjects(response.data)로 서버 목록을 표시합니다.

  3. 실패 응답

    현재는 console.error만 남깁니다. 초기 빈 배열이 “프로젝트 없음”으로 보일 수 있으므로 화면 오류 상태를 추가합니다.

목록 쓰기

생성 · POST /projects

  1. 제출 조건

    기본 submit을 막고 trim한 이름이 비면 요청하지 않습니다. 서버 DTO의 최소 3자 검증과는 별도입니다.

  2. API 성공 이후

    응답 항목을 목록에 추가하고 입력을 비운 뒤 새 프로젝트 editor로 이동합니다.

  3. API 실패 이후

    현재는 로그만 남기며 성공 갱신·이동을 하지 않습니다. 오류 안내와 재제출 상태를 추가합니다.

항목 제거

삭제 · DELETE /projects/:id

  1. 사용자 확인

    confirm을 취소하면 요청을 보내지 않습니다.

  2. API 성공 이후

    삭제 응답을 기다린 뒤 해당 ID를 목록에서 제외합니다.

  3. API 실패 이후

    목록을 그대로 두고 현재는 console.error만 남깁니다. 화면 오류와 다시 시도할 동작을 추가합니다.

화면 계약

추가할 상태 · pending / error / empty

조회 중인 화면, 실패한 화면, 성공했지만 0건인 화면을 구분합니다. 생성·삭제는 진행 중 표시와 해당 action의 중복 제출 억제를 추가합니다.

겹친 응답이 최신 목록을 덮어쓰지 않도록 갱신 순서·재조회·함수형 state 갱신을 설계합니다. 이 동시성 제어는 현재 간략화 코드에 구현되어 있지 않습니다.

확인 순서: 요청 시작 → 서버 성공/실패 → state 갱신 또는 오류 안내 → 필요한 페이지 이동. 버튼 클릭과 서버 성공을 같은 사건으로 취급하지 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 현재 GET 성공은 서버 목록을 적용하고 실패는 로그만 남겨 목록을 바꾸지 않는다. 처음의 빈 배열은 대기나 실패에도 남을 수 있으며 화면에 표시할 요청 상태는 별도 추가해야 한다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
3현재 간략화한 코드는 GET·POST·DELETE 성공 뒤 목록을 변경합니다. 아래 성공 흐름은 구현되어 있으며 로딩·화면 오류·중복 제출 억제는 추가할 UI 상태입니다.
48겹친 응답이 최신 목록을 덮어쓰지 않도록 갱신 순서·재조회·함수형 state 갱신을 설계합니다. 이 동시성 제어는 현재 간략화한 코드에 구현되어 있지 않습니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
빈 배열로 시작

현재 목록 예제는 초기 빈 배열로 렌더합니다.

목록: [] · 요청 상태 UI: 별도 추가 필요목록 출발 상태가 설정됩니다.
GET 대기

GET이 진행되어도 현재 목록 배열은 아직 바뀌지 않습니다.

목록: [] · 대기: 배열만으로 구분 불가네트워크 작업과 표시 배열이 분리됩니다.
성공 적용 또는 실패 유지

성공은 서버 목록을 적용하고 실패는 로그만 남기므로 처음의 빈 배열도 유지될 수 있습니다.

성공: 서버 목록 · 실패: 기존 목록 유지 · 오류 UI: 현재 없음성공 분기에서만 표시 자료가 교체됩니다.

API 성공 뒤 목록을 갱신하고 실패·대기 상태를 별도로 보여줍니다

초기 []·GET 대기·성공 목록 적용·실패 목록 유지에서 실제 원 코드의 상태와 미구현 UI 표시를 구별한다.

Nest 프로젝트 UI · 요청과 화면 상태

API 성공 뒤 목록을 갱신하고 실패·대기 상태를 별도로 보여줍니다

현재 간략화 코드는 GET·POST·DELETE 성공 뒤 목록을 변경합니다. 아래 성공 흐름은 구현되어 있으며 로딩·화면 오류·중복 제출 억제는 추가할 UI 상태입니다.

조회 대기·실패를 빈 목록과 구분하기 현재 GET 성공은 서버 목록을 적용하고 실패는 로그만 남겨 목록을 바꾸지 않는다. 처음의 빈 배열은 대기나 실패에도 남을 수 있으며 화면에 표시할 요청 상태는 별도 추가해야 한다. 성공 실패 초기 목록 projects = [] GET /projects 응답을 기다림 요청 성공? Promise 성공/실패 서버 목록 적용 setProjects(data) 0건도 성공 결과 console.error 목록은 갱신 안 함 현재는 화면 오류 없음
조회 대기·실패를 빈 목록과 구분하기 현재 GET 성공은 서버 목록을 적용하고 실패는 로그만 남겨 목록을 바꾸지 않는다. 처음의 빈 배열은 대기나 실패에도 남을 수 있으며 화면에 표시할 요청 상태는 별도 추가해야 한다. 성공 실패 초기 목록 projects = [] GET /projects 응답을 기다림 요청 성공? 응답 성공 / 실패 서버 목록 적용 setProjects(data) 0건도 성공 결과 console.error 목록은 갱신 안 함 화면 오류 없음
목록 읽기

조회 · GET /projects

  1. 페이지 진입

    useEffect가 조회를 시작합니다.

  2. 성공 응답

    setProjects(response.data)로 서버 목록을 표시합니다.

  3. 실패 응답

    현재는 console.error만 남깁니다. 초기 빈 배열이 “프로젝트 없음”으로 보일 수 있으므로 화면 오류 상태를 추가합니다.

목록 쓰기

생성 · POST /projects

  1. 제출 조건

    기본 submit을 막고 trim한 이름이 비면 요청하지 않습니다. 서버 DTO의 최소 3자 검증과는 별도입니다.

  2. API 성공 이후

    응답 항목을 목록에 추가하고 입력을 비운 뒤 새 프로젝트 editor로 이동합니다.

  3. API 실패 이후

    현재는 로그만 남기며 성공 갱신·이동을 하지 않습니다. 오류 안내와 재제출 상태를 추가합니다.

항목 제거

삭제 · DELETE /projects/:id

  1. 사용자 확인

    confirm을 취소하면 요청을 보내지 않습니다.

  2. API 성공 이후

    삭제 응답을 기다린 뒤 해당 ID를 목록에서 제외합니다.

  3. API 실패 이후

    목록을 그대로 두고 현재는 console.error만 남깁니다. 화면 오류와 다시 시도할 동작을 추가합니다.

화면 계약

추가할 상태 · pending / error / empty

조회 중인 화면, 실패한 화면, 성공했지만 0건인 화면을 구분합니다. 생성·삭제는 진행 중 표시와 해당 action의 중복 제출 억제를 추가합니다.

겹친 응답이 최신 목록을 덮어쓰지 않도록 갱신 순서·재조회·함수형 state 갱신을 설계합니다. 이 동시성 제어는 현재 간략화 코드에 구현되어 있지 않습니다.

확인 순서: 요청 시작 → 서버 성공/실패 → state 갱신 또는 오류 안내 → 필요한 페이지 이동. 버튼 클릭과 서버 성공을 같은 사건으로 취급하지 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 현재 GET 성공은 서버 목록을 적용하고 실패는 로그만 남겨 목록을 바꾸지 않는다. 처음의 빈 배열은 대기나 실패에도 남을 수 있으며 화면에 표시할 요청 상태는 별도 추가해야 한다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
3현재 간략화한 코드는 GET·POST·DELETE 성공 뒤 목록을 변경합니다. 아래 성공 흐름은 구현되어 있으며 로딩·화면 오류·중복 제출 억제는 추가할 UI 상태입니다.
48겹친 응답이 최신 목록을 덮어쓰지 않도록 갱신 순서·재조회·함수형 state 갱신을 설계합니다. 이 동시성 제어는 현재 간략화한 코드에 구현되어 있지 않습니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
빈 배열로 시작

현재 목록 예제는 초기 빈 배열로 렌더합니다.

목록: [] · 요청 상태 UI: 별도 추가 필요목록 출발 상태가 설정됩니다.
GET 대기

GET이 진행되어도 현재 목록 배열은 아직 바뀌지 않습니다.

목록: [] · 대기: 배열만으로 구분 불가네트워크 작업과 표시 배열이 분리됩니다.
성공 적용 또는 실패 유지

성공은 서버 목록을 적용하고 실패는 로그만 남기므로 처음의 빈 배열도 유지될 수 있습니다.

성공: 서버 목록 · 실패: 기존 목록 유지 · 오류 UI: 현재 없음성공 분기에서만 표시 자료가 교체됩니다.

위 그림은 목록 배열 하나만 두었을 때의 세 단계를 보여 줍니다. 화면은 빈 배열로 시작하고, GET을 기다리는 동안에도 배열은 그대로 비어 있습니다. 응답이 성공하면 서버의 목록이 적용되지만, 실패해서 로그만 남기면 처음의 빈 배열이 계속 남습니다. 세 경우 모두 배열이 같으므로 요청의 상태를 따로 두어야 화면에 다르게 보여 줄 수 있습니다.


ProjectsPage

src/pages/ProjectsPage.tsx
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import api, { getErrorMessage } from '../api/axiosInstance';
import { useAuth } from '../context/AuthContext';

interface ProjectSummary {
  id: string;
  name: string;
  ownerId: string;
  createdAt: string;
  updatedAt: string;
}

type LoadState = 'loading' | 'ready' | 'error';

export default function ProjectsPage() {
  const { user, logout } = useAuth();
  const navigate = useNavigate();
  const [projects, setProjects] = useState<ProjectSummary[]>([]);
  const [loadState, setLoadState] = useState<LoadState>('loading');
  const [reloadKey, setReloadKey] = useState(0);
  const [newProjectName, setNewProjectName] = useState('');
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');

  useEffect(() => {
    let active = true;
    api
      .get<ProjectSummary[]>('/projects')
      .then(({ data }) => {
        if (!active) return;
        setProjects(data);
        setLoadState('ready');
      })
      .catch(() => {
        if (active) setLoadState('error');
      });
    return () => {
      active = false;
    };
  }, [reloadKey]);

  const retry = () => {
    setLoadState('loading');
    setReloadKey((key) => key + 1);
  };

  const handleCreate = async () => {
    const name = newProjectName.trim();
    if (!name) return;
    setBusy(true);
    setError('');
    try {
      const { data } = await api.post<ProjectSummary>('/projects', { name });
      setNewProjectName('');
      await navigate(`/project/${data.id}`); // 만든 프로젝트의 에디터로 이동합니다.
    } catch (err) {
      setError(getErrorMessage(err, 'Failed to create project'));
    } finally {
      setBusy(false);
    }
  };

  const handleDelete = async (projectId: string) => {
    if (!window.confirm('Are you sure you want to delete this project?')) return;
    setBusy(true);
    setError('');
    try {
      await api.delete(`/projects/${projectId}`);
      setProjects((current) => current.filter((project) => project.id !== projectId));
    } catch (err) {
      setError(getErrorMessage(err, 'Failed to delete project'));
    } finally {
      setBusy(false);
    }
  };

  return (
    <main>
      <h2>Welcome, {user?.nickname ?? user?.email}!</h2>
      <button type="button" onClick={logout}>Logout</button>

      <h3>Create New Project</h3>
      <form
        onSubmit={(event) => {
          event.preventDefault();
          void handleCreate();
        }}
      >
        <input
          value={newProjectName}
          onChange={(event) => setNewProjectName(event.target.value)}
          placeholder="Project name (3+ characters)"
          required
        />
        <button type="submit" disabled={busy}>Create</button>
      </form>
      {error && <p role="alert">{error}</p>}

      <h3>Your Projects</h3>
      {loadState === 'loading' && <p>Loading projects...</p>}
      {loadState === 'error' && (
        <p role="alert">
          Failed to load projects. <button type="button" onClick={retry}>Retry</button>
        </p>
      )}
      {loadState === 'ready' && projects.length === 0 && <p>No projects yet. Create one!</p>}
      {loadState === 'ready' && projects.length > 0 && (
        <ul>
          {projects.map((project) => (
            <li key={project.id}>
              <Link to={`/project/${project.id}`}>{project.name}</Link>{' '}
              ({new Date(project.updatedAt).toLocaleDateString()}){' '}
              <button type="button" disabled={busy} onClick={() => void handleDelete(project.id)}>
                Delete
              </button>
            </li>
          ))}
        </ul>
      )}
    </main>
  );
}

요청 상태로 화면 고르기

loadState는 목록 조회의 상태를 loading, ready, error 중 하나로 나타냅니다. 처음에는 loading으로 시작하고, 응답이 성공하면 ready, 실패하면 error가 됩니다. 화면은 이 값을 보고 로딩 문구, 오류와 다시 시도 버튼, 빈 목록 안내, 프로젝트 목록 중 하나를 보여 줍니다. 그래서 “No projects yet”는 서버가 실제로 0건을 돌려준 경우에만 나타납니다.

effect 안의 active 변수는 화면을 떠난 뒤에 돌아온 응답이 상태를 바꾸지 않게 합니다. 응답을 기다리는 동안 사용자가 에디터로 이동하면, 정리 함수가 active를 false로 바꾸므로 늦게 온 응답은 무시됩니다. 다시 시도 버튼은 상태를 loading으로 바꾼 뒤 reloadKey를 하나 올려 같은 effect를 다시 실행합니다.

생성과 삭제는 서버의 성공 응답을 받은 뒤에만 화면을 바꿉니다. 생성에 성공하면 새 프로젝트의 에디터로 이동하므로 목록에 따로 추가하지 않습니다. 목록 화면으로 돌아오면 컴포넌트가 새로 그려지면서 서버의 updatedAt 내림차순 목록을 다시 받아 옵니다. 삭제에 성공하면 해당 항목만 목록에서 뺍니다. 두 요청이 진행되는 동안에는 busy로 버튼을 잠가 같은 요청을 두 번 보내지 않게 합니다.

입력의 trim() 검사는 빈 이름만 막습니다. 이름이 3자 이상이어야 한다는 규칙은 서버의 CreateProjectDto가 검사하며, 거부되면 그 메시지가 error로 화면에 표시됩니다. 로그아웃 버튼을 누르면 user가 null이 되고, 이 화면을 감싼 PrivateRoute가 로그인 화면으로 보냅니다.


정리

  • 목록 데이터와 별도로 요청 상태를 두어야 로딩 중, 조회 실패, 실제로 빈 목록을 화면에서 구별할 수 있습니다.
  • effect의 정리 함수에서 active를 끄면, 화면을 떠난 뒤에 돌아온 응답이 상태를 바꾸지 않습니다.
  • 생성과 삭제는 성공 응답을 받은 뒤에만 화면을 바꾸고, 요청 중에는 버튼을 잠가 중복 요청을 막습니다.
  • 실패한 요청은 서버가 돌려준 메시지를 화면에 보여 주며, 이름 길이 같은 규칙은 서버 DTO가 검사합니다.

연습 문제

1. 취소한 삭제와 실패한 삭제

프로젝트 하나가 표시된 상태에서 삭제 확인 창을 취소한 경우와, 확인을 누른 뒤 DELETE 요청이 실패한 경우를 비교해 보세요. 각 경우 HTTP 요청이 나갔는지, 목록과 화면에 무엇이 보이는지 설명해 보세요.

해설 보기

확인 창을 취소하면 handleDelete()가 바로 돌아오므로 요청이 나가지 않고, 목록과 화면은 그대로입니다.

확인을 누른 뒤 요청이 실패하면 catch로 이동하므로 filter()가 실행되지 않아 항목은 목록에 남습니다. 대신 error에 서버 메시지나 Failed to delete project가 들어가 화면에 표시됩니다. 두 경우 모두 항목이 남지만, 사용자는 오류 문구로 두 경우를 구별할 수 있습니다.

2. 두 글자 이름의 프로젝트

이름 칸에 ab를 입력하고 Create를 눌렀습니다. 어떤 요청이 나가고 화면에는 무엇이 보일까요? 에디터로 이동할까요?

해설 보기

trim() 결과가 비어 있지 않으므로 POST /projects가 나갑니다. 서버의 ValidationPipe가 @MinLength(3) 규칙으로 요청을 거부해 400을 돌려주고, 응답 본문의 message는 name must be longer than or equal to 3 characters를 담은 배열입니다.

요청이 실패했으므로 navigate()는 실행되지 않고 화면에 머뭅니다. getErrorMessage()가 배열을 문자열로 합쳐 error에 넣으므로 그 메시지가 표시되고, 입력한 이름도 지워지지 않고 남습니다.

3. 서버가 꺼진 상태에서 열린 목록

백엔드 서버를 끈 채로 목록 화면을 열었다가, 서버를 다시 켠 뒤 Retry를 눌렀습니다. 각 시점에 화면에 무엇이 보일까요? loadState 없이 목록 배열만 썼다면 어떻게 보였을까요?

해설 보기

처음에는 Loading projects...가 보이고, 연결이 실패하면 loadState가 error가 되어 오류 문구와 Retry 버튼이 보입니다. Retry를 누르면 다시 로딩 문구가 보이고, 이번에는 응답이 성공해 목록이나 빈 목록 안내가 나타납니다.

목록 배열만 썼다면 실패한 뒤에도 배열이 비어 있으므로 “No projects yet”가 표시됩니다. 사용자는 서버 오류를 프로젝트가 없는 것으로 오해하고 새 프로젝트를 만들려 할 수 있으며, 다시 시도할 방법도 없습니다.

다음 글 코드 에디터와 파일 목록에서는 프로젝트의 파일을 Monaco Editor로 열고, 입력을 멈추면 자동으로 저장하는 에디터 화면을 만듭니다.