Fullstack 91

heo4·2026년 8월 25일

Fullstack

목록 보기
46/71

풀스택

⚛️ React 비동기 처리 기초 정리

서버와 통신하는 코드를 짜려면 비동기 처리를 이해해야 한다. 동기/비동기의 차이부터 Promise, async/await 문법, 그리고 fetch API로 실제 데이터를 요청하는 방법까지 정리했다.

1. 동기(sync) vs 비동기(async) 처리

지금까지 작성한 코드는 위에서 아래로, 한 줄이 끝나야 다음 줄이 실행되는 방식이었다. 이런 방식을 동기(sync) 처리라고 한다. 하지만 서버에서 데이터를 받아오는 작업처럼 시간이 오래 걸리는 작업을 동기 방식으로 처리하면, 데이터가 도착할 때까지 화면 전체가 멈춰버린다.

function App() {
  const handleClick = () => {
    console.log("1. 시작");
    setTimeout(() => {
      console.log("2. 3초 후 실행됨");
    }, 3000);
    console.log("3. 끝");
  };

  return <button onClick={handleClick}>실행</button>;
}

export default App;

버튼을 클릭하면 콘솔에 1 → 3 → 2 순서로 출력된다. setTimeout처럼 시간이 걸리는 작업은 결과를 기다리지 않고 일단 다음 코드("3. 끝")를 먼저 실행한 뒤, 나중에 준비가 되면 그때 실행된다. 이렇게 "기다리지 않고 다음 코드를 먼저 진행하는 방식"을 비동기(async) 처리라고 한다.

서버에서 데이터를 받아오는 데는 짧게는 몇 백 밀리초, 길게는 몇 초가 걸릴 수 있다. 만약 이 시간 동안 동기적으로 코드가 멈춰버리면, 사용자는 그동안 버튼도 못 누르고 스크롤도 안 되는 "먹통" 화면을 보게 된다. 비동기 처리를 사용하면 데이터를 기다리는 동안에도 사용자는 다른 버튼을 누르거나 화면을 스크롤할 수 있다.


2. Promise 기본 개념

자바스크립트는 비동기 작업의 결과를 다루기 위해 Promise라는 객체를 사용한다. Promise는 "언젠가 끝날 작업의 결과를 담아두는 상자"라고 생각하면 된다.

function App() {
  const handleClick = () => {
    const myPromise = new Promise((resolve, reject) => {
      setTimeout(() => {
        const success = true;
        if (success) {
          resolve("데이터 가져오기 성공!");
        } else {
          reject("데이터 가져오기 실패...");
        }
      }, 2000);
    });

    myPromise
      .then((result) => console.log(result))
      .catch((error) => console.log(error));
  };

  return <button onClick={handleClick}>실행</button>;
}

export default App;

new Promise(...) 안에서 작업이 성공하면 resolve(값)을 호출하고, 실패하면 reject(값)을 호출한다. .then()은 resolve로 넘어온 값을 받아서 처리하고, .catch()는 reject로 넘어온 값을 받아서 처리한다. 위 코드는 버튼을 클릭하면 2초 뒤에 콘솔에 "데이터 가져오기 성공!"이 출력된다.


3. async/await 문법

.then()을 계속 이어 붙이는 방식은 처리할 작업이 많아질수록 코드가 옆으로 길게 늘어져 읽기 어려워진다. async/await는 Promise를 마치 동기 코드처럼 순서대로 읽히게 만들어주는 문법이다.

function App() {
  const getData = () => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve("데이터 가져오기 성공!");
      }, 2000);
    });
  };

  const handleClick = async () => {
    console.log("1. 요청 시작");
    const result = await getData();
    console.log("2. 결과:", result);
  };

  return <button onClick={handleClick}>실행</button>;
}

export default App;

함수 앞에 async를 붙이면 그 함수 안에서 await를 사용할 수 있다. await getData()는 "getData()가 resolve될 때까지 여기서 기다렸다가, 결과값을 result에 담아라"는 뜻이다. .then()을 쓰지 않아도 마치 동기 코드처럼 위에서 아래로 순서대로 읽을 수 있다는 점이 가장 큰 장점이다.


4. fetch API로 데이터 요청하기

fetch는 브라우저에 기본으로 내장된 함수로, 서버에 데이터를 요청할 때 사용한다. fetch가 돌려주는 값도 Promise이기 때문에, .then()이나 async/await로 결과를 받을 수 있다.

function App() {
  const handleClick = () => {
    fetch("https://jsonplaceholder.typicode.com/todos/1")
      .then((response) => response.json())
      .then((data) => console.log(data));
  };

  return <button onClick={handleClick}>데이터 요청</button>;
}

export default App;

fetch(주소)는 요청을 보내고 응답(response)을 돌려주는 Promise다. 응답 자체에는 아직 실제 데이터가 아니라 여러 정보가 섞여 있기 때문에, response.json()을 한 번 더 호출해서 우리가 사용할 수 있는 자바스크립트 객체로 변환해야 한다. 이 과정도 시간이 걸리는 비동기 작업이라 .then()이 한 번 더 필요하다.

같은 코드를 async/await로 바꾸면 다음과 같다.

function App() {
  const handleClick = async () => {
    const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
    const data = await response.json();
    console.log(data);
  };

  return <button onClick={handleClick}>데이터 요청</button>;
}

export default App;

await가 두 번 쓰인 것을 볼 수 있다. 첫 번째는 서버로부터 응답이 올 때까지, 두 번째는 그 응답을 자바스크립트 객체로 변환할 때까지 기다리는 것이다. 두 코드는 동작이 완전히 같지만, async/await 방식이 순서대로 읽히기 때문에 훨씬 이해하기 쉽다.


5. 실습 정리

실습 1) setTimeout으로 비동기 실행 순서 확인하기

버튼을 클릭하면 "1. 시작", "3. 끝"을 순서대로 출력하고, setTimeout으로 2초 뒤에 "2. 2초 후 실행됨"을 출력하는 코드를 작성한다. 콘솔에 어떤 순서로 로그가 찍히는지 확인한다.

function App() {
  const handleClick = () => {
    console.log("1. 시작");
    setTimeout(() => {
      console.log("2. 2초 후 실행됨");
    }, 2000);
    console.log("3. 끝");
  };

  return <button onClick={handleClick}>실행</button>;
}

export default App;

실습 2) Promise를 async/await로 바꿔보기

2초 뒤에 "완료!"를 resolve하는 getData 함수를 만들고, .then() 대신 async/await를 사용해서 결과를 콘솔에 출력한다.

function App() {
  const getData = () => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve("완료!");
      }, 2000);
    });
  };

  const handleClick = async () => {
    const result = await getData();
    console.log(result);
  };

  return <button onClick={handleClick}>실행</button>;
}

export default App;

실습 3) Promise 객체를 사용하지 않을 때 생기는 문제 확인하기

Promise 객체를 반환하지 않는 getData() 함수를 실행해보자. 형태만 보면 2초 뒤에 완료가 될 것 같지만, setTimeout()을 기다리지 않고 함수를 종료하기 때문에 undefined만 출력되고 "완료!"라는 메시지는 무시된다.

// (Promise 없음)
const getData = () => {
  let data;
  setTimeout(() => {
    data = "완료!"; // 2초 뒤에 실행됨
  }, 2000);
  return data; // 기다리지 않고 '바로' 실행됨 (결과는 undefined)
};

const handleClick = async () => {
  const result = await getData();
  console.log(result); // 2초 뒤가 아니라 즉시 undefined 출력됨
};

⚠️ await는 Promise 앞에서만 의미가 있다. Promise를 반환하지 않는 함수 앞에 await를 붙여도, 함수는 그냥 즉시 실행되고 끝나버린다. 진짜로 "기다리게" 하려면 그 함수 자체가 Promise를 반환해야 한다.


마무리

  • 동기는 "한 줄씩 순서대로, 끝나야 다음 줄", 비동기는 "오래 걸리는 작업은 기다리지 않고 다음 코드를 먼저 실행"하는 방식이다.
  • Promise는 비동기 작업의 결과를 담는 상자이며, 성공은 resolve, 실패는 reject로 표현한다.
  • async/await는 .then() 체인을 동기 코드처럼 순서대로 읽히게 만들어주는 문법일 뿐, Promise 자체를 대체하는 건 아니다.
  • fetch는 응답(response)과 실제 데이터(json) 두 단계 모두 비동기이기 때문에 await가 두 번 필요하다는 걸 기억하자.
  • await는 반드시 Promise를 반환하는 함수 앞에서만 의도한 대로 동작한다.

⚛️ React useEffect로 데이터 가져오기

화면이 처음 나타났을 때 자동으로 서버 데이터를 불러오는 패턴을 정리했다. useEffect + fetch + useState를 조합하는 방법과, 왜 useEffect에 async 함수를 직접 넣으면 안 되는지까지 다룬다.

1. 왜 useEffect 안에서 fetch를 호출할까

지난 섹션에서는 버튼을 클릭해야만 fetch가 실행됐다. 하지만 실제 서비스에서는 "화면이 처음 나타났을 때 자동으로 데이터를 불러오는" 경우가 훨씬 많다.

앞에서 배운 useEffect를 떠올려보면, 의존성 배열을 빈 배열([])로 두면 컴포넌트가 마운트될 때(처음 화면에 나타날 때) 딱 한 번만 실행됐다. 이 성질을 이용하면 "컴포넌트가 화면에 나타나자마자 서버에서 데이터를 가져오는" 패턴을 만들 수 있다.

import { useEffect } from "react";

function App() {
  useEffect(() => {
    console.log("컴포넌트가 화면에 나타났습니다. 여기서 데이터를 가져오면 됩니다!");
  }, []);

  return <div>할 일 목록</div>;
}

export default App;

2. 받아온 데이터를 useState에 저장하기

fetch로 받아온 데이터는 useEffect 함수가 끝나면 사라져 버린다. 화면에 계속 보여주려면, 받아온 데이터를 useState로 만든 state에 저장해야 한다.

import { useState, useEffect } from "react";

function App() {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/todos/1")
      .then((response) => response.json())
      .then((data) => setTodos(data));
  }, []);

  return (
    <div>
      <h1>할 일 목록</h1>
      <p>{JSON.stringify(todos)}</p>
    </div>
  );
}

export default App;

todos를 빈 배열([])로 초기화해둔 이유는, 데이터가 아직 도착하지 않은 순간에도 todos가 undefined가 아니라 항상 배열 형태를 유지하도록 하기 위해서다. 데이터가 도착하면 setTodos(data)가 호출되면서 화면이 새 데이터로 다시 렌더링된다.

useEffect에 async 함수를 바로 못 넣는 이유

useEffect에 전달하는 함수는 아무것도 반환하지 않거나, "정리(cleanup) 함수"만 반환해야 한다는 규칙이 있다. 그런데 async 함수는 항상 Promise를 반환하기 때문에 이 규칙과 충돌해 경고가 발생한다.

그래서 useEffect 바로 안에는 async 함수를 넣지 않고, 아래처럼 내부에 별도의 async 함수를 만들어서 호출하는 방식을 사용한다.

useEffect(() => {
  const getTodos = async () => {
    const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
    const data = await response.json();
    setTodos(data);
  };

  getTodos();
}, []);

3. /todos 전체 목록 가져와서 리스트로 준비하기

이번에는 하나의 항목이 아니라, /todos 전체 목록(배열)을 가져와본다. useState의 초기값도 배열([])로 맞춰준다.

import { useState, useEffect } from "react";

function App() {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    const getTodos = async () => {
      const response = await fetch("https://jsonplaceholder.typicode.com/todos");
      const data = await response.json();
      setTodos(data);
      console.log(data);
    };

    getTodos();
  }, []);

  return (
    <div>
      <h1>할 일 목록</h1>
      <p>불러온 항목 개수: {todos.length}</p>
    </div>
  );
}

export default App;

/todos는 200개의 할 일 데이터를 배열로 돌려준다. 앞에서 만든 Todo 앱과 다르게, 이번 데이터는 서버에서 받아온 것이라는 점만 다르고, state에 배열로 저장된 이후부터는 map()으로 리스트를 렌더링하는 앞서 배운 방법이 그대로 적용된다.

왜 의존성 배열을 빈 배열([])로 둘까

의존성 배열을 아예 생략하면, state가 업데이트되어 리렌더링될 때마다 useEffect가 다시 실행되면서 fetch도 계속 반복 호출된다. setTodos(data)가 호출되면 리렌더링이 일어나고, 그러면 또 fetch가 실행되고, 또 setTodos가 호출되는 무한 반복에 빠질 수 있다.

빈 배열은 "최초 마운트 시 딱 한 번만 실행하라"는 뜻이므로, 데이터를 "화면이 나타날 때 한 번만" 가져오고 싶을 때 정확히 맞는 설정이다.

⚠️ useEffect(() => {...})처럼 의존성 배열을 아예 생략한 채 그 안에서 setState를 호출하면, 렌더링 → useEffect 실행 → setState → 다시 렌더링 → useEffect 실행 ... 의 무한 루프에 빠지기 쉽다. 데이터를 한 번만 가져오고 싶다면 반드시 []를 붙이자.


4. 실습 정리

실습 1) useEffect 안에서 콘솔 로그 확인하기

useEffect의 의존성 배열을 []로 두고, 컴포넌트가 마운트될 때 콘솔에 로그가 한 번만 찍히는지 확인한다.

import { useEffect } from "react";

function App() {
  useEffect(() => {
    console.log("컴포넌트가 마운트되었습니다.");
  }, []);

  return <div>할 일 목록</div>;
}

export default App;

실습 2) /todos 전체 목록을 가져와 state에 저장하기

  1. todos라는 state를 빈 배열로 초기화한다.
  2. useEffect 안에서 async 함수를 정의하고 호출해서, https://jsonplaceholder.typicode.com/todos를 fetch로 요청한다.
  3. 받아온 데이터를 setTodos로 state에 저장하고, 콘솔에도 함께 출력한다.
import { useState, useEffect } from "react";

function App() {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    const getTodos = async () => {
      const response = await fetch("https://jsonplaceholder.typicode.com/todos");
      const data = await response.json();
      setTodos(data);
      console.log(data);
    };

    getTodos();
  }, []);

  return (
    <div>
      <h1>할 일 목록</h1>
      <p>불러온 항목 개수: {todos.length}</p>
    </div>
  );
}

export default App;

마무리

  • "화면이 처음 나타날 때 자동으로 데이터를 가져오는" 패턴은 useEffect(() => {...}, []) + 그 안에서 fetch 호출 + useState로 결과 저장, 이 세 가지 조합으로 완성된다.
  • useEffect에는 async 함수를 직접 넣을 수 없으므로, 내부에 별도의 async 함수를 선언해서 즉시 호출하는 패턴을 쓴다.
  • 의존성 배열을 빈 배열로 두지 않으면 fetch → setState → 리렌더링 → fetch의 무한 루프에 빠질 수 있다는 점을 꼭 기억하자.
  • 서버에서 받아온 배열도 일반 state 배열과 다르지 않으므로, map()으로 리스트를 렌더링하는 기존 방식이 그대로 적용된다.

⚛️ JSONPlaceholder로 API 연습하기

실제 서버 없이 API 연습을 할 수 있는 JSONPlaceholder를 정리했다. 주요 엔드포인트 종류부터, 중첩 객체 데이터를 다루는 법, 로딩 중 안전하게 화면을 보여주는 패턴까지 다룬다.

1. JSONPlaceholder란?

JSONPlaceholder는 실제 서버를 직접 만들지 않아도 연습용으로 사용할 수 있도록 가짜 데이터를 제공하는 무료 API다. 회원가입이나 API 키 발급 없이, 그냥 정해진 주소로 요청만 보내면 항상 똑같은 형식의 데이터를 돌려준다. 그래서 "서버에서 데이터를 받아와 화면에 그리는 연습"을 할 때 널리 사용된다.

fetch("https://jsonplaceholder.typicode.com/posts/1")
  .then((response) => response.json())
  .then((data) => console.log(data));

브라우저 주소창에 https://jsonplaceholder.typicode.com/posts/1을 직접 입력해도 같은 데이터를 확인할 수 있다.


2. 주요 엔드포인트 살펴보기

엔드포인트(endpoint)란 API에서 각각의 데이터를 요청할 수 있는 주소를 말한다. JSONPlaceholder는 여러 종류의 가짜 데이터를 아래와 같은 엔드포인트로 제공한다.

엔드포인트데이터개수
/posts게시글 (제목, 본문)100개
/todos할 일 (제목, 완료 여부)200개
/users사용자 (이름, 이메일, 주소, 회사 등)10개
/comments게시글 댓글500개
/albums앨범100개
/photos사진 (제목, 이미지 URL)5000개

/posts/1처럼 뒤에 id를 붙이면 그 항목 하나만 받아오고, id 없이 /posts만 요청하면 전체 목록을 배열로 받아온다. 앞서 사용한 /todos, /todos/1도 이 규칙을 따른 것이다.


3. 응답 데이터 구조 파악하기

화면에 무엇을 표시할지 정하려면, 먼저 각 엔드포인트가 어떤 필드(field)를 가지고 있는지 확인해야 한다. /users/1을 요청하면 아래와 같은 형태의 데이터가 돌아온다.

{
  "id": 1,
  "name": "Leanne Graham",
  "username": "Bret",
  "email": "Sincere@april.biz",
  "address": {
    "street": "Kulas Light",
    "city": "Gwenborough",
    "zipcode": "92998-3874"
  },
  "phone": "1-770-736-8031 x56442",
  "company": {
    "name": "Romaguera-Crona"
  }
}

name, email, phone처럼 바로 사용할 수 있는 값도 있고, address나 company처럼 객체 안에 객체가 들어있는 중첩 객체(nested object) 형태도 있다. 중첩된 값을 꺼낼 때는 user.address.city처럼 점(.)을 이어서 접근한다.

function App() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const getUser = async () => {
      const response = await fetch("https://jsonplaceholder.typicode.com/users/1");
      const data = await response.json();
      setUser(data);
    };

    getUser();
  }, []);

  if (!user) return <p>불러오는 중...</p>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>{user.address.city}</p>
    </div>
  );
}

export default App;

user를 null로 초기화하고 조건부 렌더링부터 하는 이유

데이터가 도착하기 전까지 user는 계속 null이기 때문에, 이 상태에서 바로 {user.name}을 렌더링하려고 하면 "null의 name을 읽을 수 없다"는 오류가 발생한다.

if (!user) return <p>불러오는 중...</p>;은 앞서 배운 조건부 렌더링으로, "데이터가 아직 없으면 안내 문구만 보여주고, 있으면 그 아래 코드를 실행하라"는 뜻이다. 이렇게 하면 데이터가 도착하기 전에는 오류 없이 안전하게 로딩 화면을 보여줄 수 있다.

⚠️ 서버에서 받아온 데이터를 다룰 땐 항상 "아직 데이터가 없을 수도 있다"는 걸 염두에 두고, 데이터가 준비되기 전 상태에서도 에러 없이 동작하도록 초기값과 조건부 렌더링을 함께 챙겨야 한다.


4. 이번 프로젝트에서 표시할 항목 정하기

JSONPlaceholder는 여러 종류의 데이터를 제공하지만, 하나의 화면에 모든 걸 담을 필요는 없다. /users 엔드포인트를 사용해서, 이름·이메일·회사 이름이 담긴 사용자 목록 화면을 만들어본다.

표시할 정보데이터 경로
이름user.name
이메일user.email
회사 이름user.company.name
도시user.address.city

/users로 요청하면 위 필드를 가진 사용자 객체 10개가 배열로 돌아오는데, 이 배열을 map()으로 리스트에 렌더링하는 건 앞서 배운 방식 그대로 적용하면 된다.


5. 실습 정리

실습 1) 여러 엔드포인트 직접 호출해보기

/posts/1, /users/1, /comments/1을 각각 fetch로 요청해서 콘솔에 출력하고, 세 응답에 각각 어떤 필드가 들어있는지 비교해본다.

function App() {
  const handleClick = async () => {
    const post = await (await fetch("https://jsonplaceholder.typicode.com/posts/1")).json();
    const user = await (await fetch("https://jsonplaceholder.typicode.com/users/1")).json();
    const comment = await (await fetch("https://jsonplaceholder.typicode.com/comments/1")).json();

    console.log("post:", post);
    console.log("user:", user);
    console.log("comment:", comment);
  };

  return <button onClick={handleClick}>세 가지 요청 보내기</button>;
}

export default App;

실습 2) /users/1의 중첩 객체 값 꺼내보기

/users/1을 fetch로 요청해서 state에 저장하고, 화면에 user.name, user.email, user.address.city, user.company.name을 각각 표시한다.

import { useState, useEffect } from "react";

function App() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const getUser = async () => {
      const response = await fetch("https://jsonplaceholder.typicode.com/users/1");
      const data = await response.json();
      setUser(data);
    };

    getUser();
  }, []);

  if (!user) return <p>불러오는 중...</p>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>{user.address.city}</p>
      <p>{user.company.name}</p>
    </div>
  );
}

export default App;

실습 3) /users 전체 목록을 받아 개수 확인하기

/users(전체 목록)를 fetch로 요청해서 state에 배열로 저장하고, 화면에 "불러온 사용자 수: N명" 형태로 배열의 길이를 표시한다.

import { useState, useEffect } from "react";

function App() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    const getUsers = async () => {
      const response = await fetch("https://jsonplaceholder.typicode.com/users");
      const data = await response.json();
      setUsers(data);
    };

    getUsers();
  }, []);

  return <p>불러온 사용자 수: {users.length}명</p>;
}

export default App;

마무리

  • JSONPlaceholder는 회원가입이나 서버 준비 없이 바로 fetch 연습을 할 수 있는 좋은 테스트용 API다.
  • /엔드포인트/id는 단일 항목, /엔드포인트는 전체 목록(배열)이라는 규칙을 기억해두면 어떤 엔드포인트든 헷갈리지 않는다.
  • 응답에 address, company처럼 중첩 객체가 있으면 user.address.city처럼 점을 이어서 접근한다.
  • 데이터가 도착하기 전(null 또는 빈 배열)에도 에러 없이 화면을 보여주려면, 초기값 설정과 조건부 렌더링(if (!user) return ...)을 함께 챙기는 습관을 들이자.

0개의 댓글