본문 바로가기

CodeSoom- React 13기

[코드숨] 리액트 13기 -5강/비동기(REST API, async/await,fetch,Redux Thunk)

 

비동기 처리

비동기(Asynchronous)

웹 프론트엔드의 복잡성은 계속 증가하고 있습니다. 반면 JavaScript는 싱글 쓰레드 기반 이벤트 루프로 실행되기 때문에 동기 로직이 많아질수록 브라우저에서 앱의 사용성이 급격히 줄어들 겁니다. 따라서 현대의 JavaScript를 다루는 개발자라면 비동기와 동시성(concurrency), 나아가 병렬성(parallelism)에 대한 이해가 필수적입니다.

Promise

현재 JavaScript를 다루는 개발자라면 Promise에 대한 이해는 필수적입니다. 흔히 Promise에 대한 자료를 보면 callback hell을 해결해준다는 말이 많지만 이는 Promise의 진정한 가치는 아닙니다. 그러면 왜 사용할까요? callback의 호출 시점은 우리가 정할 수 없습니다. 반면 Promise는 원할 때 호출할 수 있습니다. then이 바로 그 시점이죠. 물론 resolve된 상태여야 합니다. 이번 강의에서는 Promise에 대해서 자세히 다루지 않고 깊은 이해가 없어도 진행엔 전혀 문제가 없습니다. 언젠가 비동기와 동시성에 제대로 공부하실 땐 아래 강의를 추천드립니다.

async/await

아무리 Promise가 callback보다 편하다지만 대부분의 사람은 비동기 코드를 이해하기 힘들고 작성하기도 쉽지 않습니다. 비동기 코드를 평소 익숙하게 작성하던 동기적 코드로 작성할 수 있다면 얼마나 편할까요? 그래서 나온 게 async/await 입니다. 처음 접하신 분들도 생각보다 쉽게 이해할 수 있으니 학습하실 것을 권해드립니다.

fetch

AJAX(Asynchronous JavaScript And XML)은 특정한 기술이 아닌 비동기적인 웹 애플리케이션 제작을 위한 기법을 나타냅니다. ES2015 이전엔 AJAX를 위해 사용하던 API인 XMLHttpReqeust가 브라우저에 내장되어 있었지만 ES2015부턴 Fetch API가 표준으로 등장하면서 이젠 fetch를 많이 사용합니다.

REST

REST API를 제대로 디자인하는 건 쉽지 않습니다. 하지만 웹 개발자라면 REST에 대해서 반드시 알고 있어야 합니다. 물론 위의 자료를 보고 지금은 모두 이해하려고 애쓰지 않아도 좋습니다. 자세히 몰라도 이 과정을 진행하는데 무리가 전혀 없습니다. 지금은 패스하셔도 됩니다. 하지만 언젠간 제대로 공부하셔야 합니다!

Redux에서 비동기 액션 실행하기

우리가 지금까지 작성한 코드들은 모두 동기 로직이었습니다. 액션이 dispatch 될 때 마다 상태가 즉시 업데이트되었습니다.

만약에 우리가 비동기적인 로직을 실행하고 싶다면 어떻게 할 수 있을까요? 예를 들면 특정한 서버로부터 데이터를 요청 같은 행위들이 있겠죠.

Redux Thunk middleware

Redux Thunk middleware는 Action creator가 액션을 반환하는 대신에 함수를 반환합니다. 그래서 특정 액션이 실행되는 것을 지연시키거나 특정한 조건이 충족될 때만 액션이 실행될 수 있도록 할 수 있습니다.

const INCREMENT_COUNTER = 'INCREMENT_COUNTER';

function increment() {
  return {
    type: INCREMENT_COUNTER,
  };
}

function incrementAsync() {
  return (dispatch) => {
    setTimeout(() => {
      // Yay! Can invoke sync or async actions with `dispatch`
      dispatch(increment());
    }, 1000);
  };
}

두 번째 파라미터인 getState를 이용하여 현재 상태를 불러올 수 있습니다. 그리고 아무것도 dispatch하지 않는다면 아무일도 일어나지 않습니다.

function incrementIfOdd() {
  return (dispatch, getState) => {
    const { counter } = getState();

    if (counter % 2 === 0) {
      return;
    }

    dispatch(increment());
  };
}

Redux Thunk 설치하기

npm i redux-thunk

스토어를 만들 때 두 번째 인자로 미들웨어에 등록해야 합니다.

import { createStore, applyMiddleware } from 'redux';

import thunk from 'redux-thunk';

import reducer from './reducer';

const store = createStore(reducer, applyMiddleware(thunk));
 

Sources

 


지난주 과제 제출한 파일에 +오늘 강의

오늘 강의에서는 컬러부분만 변경 및 생성

 

__mocks__>react-redux.js

export const useDispatch = jest.fn();

export const useSelector = jest.fn();

 

fixtures>restaurants.js

const restaurants = [
  {
    id: 1,
    name: '맛나분식',
    category: '분식',
    address: '서울시 강남구 역삼동',
  },
];

export default restaurants;

 

 


src 폴더 내부

services>__mocks__ >api.js

export async function fetchCategories() {
  return [];
}

// TODO:delete this!
export function xxx() {

}

 

services >api.js

export async function fetchCategories() {
  const url = 'https://eatgo-customer-api.ahastudio.com/categories';
  const response = await fetch(url); // fetch()로 api가져옴/response는 객체
  const data = await response.json();// .json으로 json을 꺼내 object로 가져옴

  // TODO:fetch GET /복수:categories /단수:categories/1
  // REST - CRUD => Read-복수:collection /단수:member,element
  return data;
}

// TODO:delete this!
export function xxx() {

}

 

actions.js

import { fetchCategories } from './services/api';

export function setRestaurants(restaurants) {
  return {
    type: 'setRestaurants',
    payload: {
      restaurants,
    },
  };
}

export function changeRestaurantField({ name, value }) {
  return {
    type: 'changeRestaurantField',
    payload: {
      name,
      value,
    },
  };
}

export function addRestaurant() {
  return {
    type: 'addRestaurant',
  };
}

export function loadRestaurants() {
  return async (dispatch) => {
    // TODO:fetch...
    // TODO:load restaurants from API server.
    // 1.API server확보
    // 2.fetch
    const restaurants = [];

    dispatch(setRestaurants(restaurants));
  };
}

export function setCategories(categories) {
  return {
    type: 'setCategories',
    payload: {
      categories,
    },
  };
}

export function loadCategories() {
  return async (dispatch) => {
    const categories = await fetchCategories();

    dispatch(setCategories(categories));
  };
}

 

App.jsx

import { useEffect } from 'react';

import { useDispatch } from 'react-redux';

import CategoriesContainer from './CategoriesContainer';
import RestaurantsContainer from './RestaurantsContainer';
import RestaurantsCreateContainer from './RestaurantsCreateContainer';

import {
  loadCategories,
  loadRestaurants,
} from './actions';

export default function App() {
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(loadCategories()); // redux-thunk를 통해 아래대신 이렇게 사용가능/action파일보기
    // loadCategories({ dispatch });
    dispatch(loadRestaurants());
    // loadRestaurants({ dispatch });
  }, []);

  return (
    <div>
      <h1>Restaurants</h1>
      <CategoriesContainer />
      <RestaurantsContainer />
      <RestaurantsCreateContainer />
    </div>
  );
}

 

App.test.jsx

import { useSelector, useDispatch } from 'react-redux';

import { render } from '@testing-library/react';

import App from './App';

jest.mock('react-redux');
jest.mock('./services/api');

describe('App', () => {
  const dispatch = jest.fn();

  useDispatch.mockImplementation(() => dispatch);

  useSelector.mockImplementation((selector) => selector({
    restaurants: [],
    restaurant: {},
    categories: [],
  }));

  const { queryByText } = render((
    <App />
  ));

  it('App이 랜더링된다', () => {
    expect(dispatch).toBeCalledTimes(2);

    expect(queryByText(/맛나분식/)).toBeNull();
  });
});

 

Categories.jsx

export default function Categories({ categories }) {
  return (
    <ul>
      {categories.map((category) => (
        <li key={category.id}>
          {category.name}
        </li>
      ))}
    </ul>
  );
}

 

Categories.test.jsx

import { render } from '@testing-library/react';

import Categories from './Categories';

describe('Categories', () => {
  const categories = [
    { id: 1, name: '한식' },
  ];

  const { getByText } = render((
    <Categories categories={categories} />
  ));

  it('입력한 레스토랑이 랜더링된다', () => {
    expect(getByText(/한식/)).not.toBeNull();
  });
});

 

CategoriesContainer.jsx

import { useSelector } from 'react-redux';

import Categories from './Categories';

export default function RestaurantsContainer() {
  const { categories } = useSelector((state) => ({
    categories: state.categories,
  }));

  return (
    <Categories categories={categories} />
  );
}

 

CategoriesContainer.test.jsx

import { useSelector } from 'react-redux';

import { render } from '@testing-library/react';

import CategoriesContainer from './CategoriesContainer';

jest.mock('react-redux');

describe('CategoriesContainer', () => {
  useSelector.mockImplementation((selector) => selector({
    categories: [
      { id: 1, name: '한식' },
    ],
  }));

  const { getByText } = render((
    <CategoriesContainer />
  ));

  it('레스토랑 리스트가 랜더링된다', () => {
    expect(getByText(/한식/)).not.toBeNull();
  });
});

 

index.jsx

import ReactDOM from 'react-dom';

import { Provider } from 'react-redux';

import App from './App';

import store from './store';

ReactDOM.render(
  (
    <Provider store={store}>
      <App />
    </Provider>
  ),
  document.getElementById('app'),
);

 

reducer.js

const initialRestaurant = {
  name: '',
  category: '',
  address: '',
};

const initialState = {
  newId: 100,
  restaurants: [],
  restaurant: initialRestaurant,
  categories: [],
};

const actionCreators = {
  setRestaurants: (state, action) => {
    const { restaurants } = action.payload;

    return {
      ...state,
      restaurants,
    };
  },

  changeRestaurantField: (state, action) => {
    const { name, value } = action.payload;

    return {
      ...state,
      restaurant: {
        ...state.restaurant,
        [name]: value,
      },
    };
  },

  addRestaurant: (state) => {
    const { newId, restaurants, restaurant } = state;

    return {
      ...state,
      newId: newId + 1,
      restaurants: [...restaurants, { ...restaurant, id: newId }],
      restaurant: initialRestaurant,
    };
  },

  setCategories: (state, action) => {
    const { categories } = action.payload;

    return {
      ...state,
      categories,
    };
  },

};

export default function reducer(state = initialState, action) {
  return !action || !actionCreators[action.type]
    ? state
    : actionCreators[action.type](state, action);
}

 

reducer.test.js

import reducer from './reducer';

import {
  setRestaurants,
  changeRestaurantField,
  addRestaurant,
  setCategories,
} from './actions';

import restaurants from '../fixtures/restaurants';

describe('reducer', () => {
  context('레스토랑이 입력된 경우', () => {
    describe('setRestaurants', () => {
      it('레스토랑의 리스트가 변경된다', () => {
        const initialState = {
          restaurants: [],
        };

        const state = reducer(initialState, setRestaurants(restaurants));

        expect(state.restaurants).not.toHaveLength(0);
      });
    });

    describe('changeRestaurantField', () => {
      it('입력한 레스토랑의 정보로 바뀐다', () => {
        const initialState = {
          restaurant: {
            name: '이름',
            category: '분류',
            address: '주소',
          },
        };

        const state = reducer(initialState, changeRestaurantField({
          name: 'address',
          value: '서울시 강남구 역삼동',
        }));

        expect(state.restaurant.address).toBe('서울시 강남구 역삼동');
      });
    });

    describe('addRestaurant', () => {
      it('레스토랑 리스트에 입력한 레스토랑을 추가하고 입력값을 초기화한다', () => {
        const initialState = {
          newId: 101,
          restaurants: [],
          restaurant: {
            name: '베리파스타',
            category: '이탈리안',
            address: '서울시 강남구 역삼동',
          },
        };

        const state = reducer(initialState, addRestaurant());

        expect(state.restaurants).toHaveLength(1);

        const restaurant = state.restaurants[state.restaurants.length - 1];
        expect(restaurant.id).toBe(101);
        expect(restaurant.name).toBe('베리파스타');

        expect(state.restaurant.name).toBe('');

        expect(state.newId).toBe(102);
      });
    });
  });

  context('레스토랑이 없을 경우', () => {
    describe('아무런 값이 없을 때', () => {
      it('아무런 동작을 하지 않는다', () => {
        const state = reducer();

        expect(state.restaurant.name).toBe('');
        expect(state.restaurant.category).toBe('');
        expect(state.restaurant.address).toBe('');
        expect(state.restaurants).toHaveLength(0);
      });
    });
  });

  //
  describe('setCategories', () => {
    it('categories를 바꾼다', () => {
      const categories = [
        { id: 1, name: '한식' },
      ];

      const initialState = {
        categories: [],
      };

      const state = reducer(initialState, setCategories(categories));

      expect(state.categories).toHaveLength(1);
    });
  });
});

 

RestaurantForm.jsx

export default function RestaurantForm({
  restaurant,
  onChange,
  onClick,
}) {
  function handleChange(event) {
    const { target: { name, value } } = event;
    onChange({ name, value });
  }

  return (
    <div>
      <input
        type="text"
        placeholder="이름"
        name="name"
        value={restaurant.name}
        onChange={handleChange}
      />
      <input
        type="text"
        placeholder="분류"
        name="category"
        value={restaurant.category}
        onChange={handleChange}
      />
      <input
        type="text"
        placeholder="주소"
        name="address"
        value={restaurant.address}
        onChange={handleChange}
      />
      <button
        type="button"
        onClick={onClick}
      >
        등록
      </button>
    </div>
  );
}

 

RestaurantForm.test.jsx

import { fireEvent, render } from '@testing-library/react';

import RestaurantForm from './RestaurantForm';

describe('RestaurantForm', () => {
  const restaurant = {
    name: '베리파스타',
    category: '이탈리안',
    address: '서울시 강남구',
  };

  const handleChange = jest.fn();
  const handleClick = jest.fn();

  function renderContainer() {
    return render(
      <RestaurantForm
        restaurant={restaurant}
        onChange={handleChange}
        onClick={handleClick}
      />,
    );
  }

  it('입력한 레스토랑이 추가되었는지 확인한다', () => {
    const { getByText, getByDisplayValue } = renderContainer();

    expect(getByDisplayValue('베리파스타')).not.toBeNull();
    expect(getByDisplayValue('이탈리안')).not.toBeNull();
    expect(getByDisplayValue('서울시 강남구')).not.toBeNull();
    expect(getByText(/등록/)).not.toBeNull();
  });

  it('수정한 레스토랑의 주소로 등록이 되었는지 확인한다', () => {
    const { getByText, getByDisplayValue } = renderContainer();

    fireEvent.change(getByDisplayValue('서울시 강남구'), {
      target: { value: '서울시 강남구 역삼동' },
    });

    expect(handleChange).toBeCalledWith({
      name: 'address',
      value: '서울시 강남구 역삼동',
    });

    fireEvent.click(getByText(/등록/));

    expect(handleClick).toBeCalled();
  });
});

 

Restaurants.jsx

export default function Restaurants({ restaurants }) {
  return (
    <ul>
      {restaurants.map((restaurant) => (
        <li key={restaurant.id}>
          {restaurant.name}
          {' '}
          |
          {' '}
          {restaurant.category}
          {' '}
          |
          {' '}
          {restaurant.address}
        </li>
      ))}
    </ul>
  );
}

 

Restaurants.test.jsx

import { render } from '@testing-library/react';

import Restaurants from './Restaurants';

import restaurants from '../fixtures/restaurants';

describe('Restaurants', () => {
  const { getByText } = render((
    <Restaurants restaurants={restaurants} />
  ));

  it('입력한 레스토랑이 랜더링된다', () => {
    expect(getByText(/맛나분식/)).not.toBeNull();
  });
});

 

RestaurantsContainer.jsx

import { useSelector } from 'react-redux';

import Restaurants from './Restaurants';

export default function RestaurantsContainer() {
  const { restaurants } = useSelector((state) => ({
    restaurants: state.restaurants,
  }));

  return (
    <Restaurants restaurants={restaurants} />
  );
}

 

RestaurantsContainer.test.jsx

import { useSelector } from 'react-redux';

import { render } from '@testing-library/react';

import RestaurantsContainer from './RestaurantsContainer';

import restaurants from '../fixtures/restaurants';

jest.mock('react-redux');

describe('RestaurantsContainer', () => {
  useSelector.mockImplementation((selector) => selector({
    restaurants,
  }));

  const { getByText } = render((
    <RestaurantsContainer />
  ));

  it('레스토랑 리스트가 랜더링된다', () => {
    expect(getByText(/맛나분식/)).not.toBeNull();
  });
});

 

RestaurantsCreateContainer.jsx

import { useDispatch, useSelector } from 'react-redux';

import RestaurantForm from './RestaurantForm';

import {
  changeRestaurantField,
  addRestaurant,
} from './actions';

export default function RestaurantsCreateContainer() {
  const dispatch = useDispatch();

  const { restaurant } = useSelector((state) => ({
    restaurant: state.restaurant,
  }));

  function handleChange({ name, value }) {
    dispatch(changeRestaurantField({ name, value }));
  }

  function handleClick() {
    dispatch(addRestaurant());
  }

  return (
    <RestaurantForm
      restaurant={restaurant}
      onChange={handleChange}
      onClick={handleClick}
    />
  );
}

 

RestaurantsCreateContainer.test.jsx

import { useDispatch, useSelector } from 'react-redux';

import { render, fireEvent } from '@testing-library/react';

import RestaurantsCreateContainer from './RestaurantsCreateContainer';

jest.mock('react-redux');

describe('RestaurantsCreateContainer', () => {
  const dispatch = jest.fn();

  useDispatch.mockImplementation(() => dispatch);

  useSelector.mockImplementation((selector) => selector({
    restaurant: {
      name: '베리',
      category: '이탈',
      address: '서울시',
    },
  }));

  function renderContainer() {
    return render(<RestaurantsCreateContainer />);
  }

  it('입력한 레스토랑의 정보가 추가되었는지 확인한다', () => {
    const { getByText, getByDisplayValue } = renderContainer();

    expect(getByDisplayValue('베리')).not.toBeNull();
    expect(getByDisplayValue('이탈')).not.toBeNull();
    expect(getByDisplayValue('서울시')).not.toBeNull();
    expect(getByText('등록')).not.toBeNull();
  });

  it('레스토랑의 주소가 바뀌면 변경되는지 확인한다', () => {
    const { getByText, getByDisplayValue } = renderContainer();

    fireEvent.change(getByDisplayValue('서울시'), {
      target: { value: '서울시 강남구 역삼동' },
    });

    fireEvent.click(getByText('등록'));

    expect(dispatch).toBeCalledWith({
      type: 'changeRestaurantField',
      payload: {
        name: 'address',
        value: '서울시 강남구 역삼동',
      },
    });
  });
});

 

store.js

import { createStore, applyMiddleware } from 'redux';

import thunk from 'redux-thunk';

import reducer from './reducer';

const store = createStore(reducer, applyMiddleware(thunk));

export default store;

 


test>create-restaurants_test.js

Feature('Create restaurant');

const restaurant = {
  name: '마녀주방',
  category: '한식',
  address: '서울시 강남구',
};

Scenario('이름, 분류 그리고 주소를 입력한 후 확인을 누르면 레스토랑이 추가가 됩니다.', ({ I }) => {
  I.amOnPage('/');

  const { name, category, address } = restaurant;

  I.fillField('input[name=name]', name);
  I.fillField('input[name=category]', category);
  I.fillField('input[name=address]', address);

  I.click('등록');

  I.see(name);
  I.see(category);
  I.see(address);
});

 

test>restaurant_test.js

Feature('Restaurant');

Scenario('앱 제목을 볼 수 있습니다.', ({ I }) => {
  I.amOnPage('/');

  I.see('Restaurants');
});