관리 메뉴

bright jazz music

todo-react-app(front) : fetch 이후 CORS 오류, useEffect, fetch, Promise 본문

Framework/ReactJs

todo-react-app(front) : fetch 이후 CORS 오류, useEffect, fetch, Promise

bright jazz music 2023. 5. 24. 09:19
//App.js

function App() {
  //기본 스테이트를 객체로 설정하였다.
  const [items, setItems] = useState([]);

  //백엔드에 보낼 요청 옵션
  const requestOptions = {
    method: "GET",
    headers: { "Content-Type": "application/json" },
  };

  //fetch를 통해 요청 발송
  fetch("http://localhost:8080/todo", requestOptions)
    .then((response) => response.json())
    .then(
      (response) => {
        setItems(response.data);
      },
      (error) => {}
    );
    
    
    //...

 

 

 

cors 오류 발생. cors를 가능케 하기 위해서는 백엔드에서 cors 방침 설정을 해줘야 한다.

 

백엔드 자바서버에서 WebMvcConfigurer를 구현한 클래스를 만들어서 설정해준다.

package com.example.reactspringtodo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    private final long MAX_AGE_SECS = 3600;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        //모든 경로에 대해
        registry.addMapping("/**")
                //Origin이 http://localhost:3000에 대해
                .allowedOrigins("http://localhost:3000")
                //GET, POST, PUT, PATCH, DELETE, OPTIONS 메서드를 허용한다.
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
                .allowedHeaders("*") //모든 헤더에 대해 허용
                .allowCredentials(true) //모든 인증정보를 허용
                .maxAge(MAX_AGE_SECS);

    }
    /*
    CORS
    :
    CORS는 웹 애플리케이션이 다른 도메인의 리소스에 접근할 수 있도록 하는 보안 메커니즘이다. 
    브라우저에서 실행되는 JavaScript를 사용하여 웹 애플리케이션이 다른 도메인의 리소스에 AJAX 요청을 보낼 때 CORS가 적용된다. 
    이는 웹 애플리케이션이 자신이 호스팅되는 도메인과 다른 도메인 간의 리소스 공유를 허용하는지 여부를 결정한다.
    */

    /*
    allowCredentials()
    :
    allowCredentials() 메서드는 Access-Control-Allow-Credentials 헤더 값을 설정하는 데 사용된다.
    이 헤더는 브라우저에게 요청 시에 사용자 인증 정보를 포함할 수 있는지 여부를 알려주는 역할을 한다.
    기본적으로 브라우저는 CORS 요청 시에는 인증 정보를 포함하지 않으며, 이를 Same-Origin Policy로 인해 접근할 수 없다.
    그러나 allowCredentials(true)를 사용하여 이 헤더를 설정하면 브라우저는 요청 시에 사용자 인증 정보를 포함할 수 있도록 허용된다.
    주의할 점은 Access-Control-Allow-Credentials 헤더를 사용하려면 Access-Control-Allow-Origin 헤더도 동일한 도메인 또는 *와 같은 와일드카드를 사용하여 설정되어야 한다.
    그렇지 않으면 브라우저는 Access-Control-Allow-Credentials 헤더를 무시하고 요청을 거부할 것이다.
    */

    /*
    maxAge()
    :
    maxAge() 메서드는 Access-Control-Max-Age 헤더의 값을 설정하는 데 사용된다.
    이 헤더는 브라우저에게 미리 요청을 캐시할 수 있는 최대 시간을 알려주는 역할을 한다.
    설정된 시간 동안은 브라우저는 동일한 요청을 반복해서 서버에 보내지 않고 캐시된 응답을 사용할 수 있다.
    이는 서버의 부하를 줄이고 응답 속도를 향상시키는 데 도움이 된다.

    maxAge() 메서드를 사용하여 Access-Control-Max-Age 헤더 값을 설정하면 브라우저는 해당 시간 동안 요청을 캐시하고, 그 이후에는 다시 서버에 요청을 보내야 한다.
    메서드의 인자로는 시간(초 단위)을 지정하게 되며, 0보다 큰 정수 값을 사용하여 지정한다.
    0이나 음수를 사용하면 캐시 시간이 없음을 의미하며, 매번 요청을 서버로 보내야 한다.
    * */

}

 

 

무한 렌더링

 

그러나 무한 루프에 빠짐(무한 렌더링)

 

이유는 fetch 요청 이후의 응답이 계속해서 상태를 바꾸기 때문이다. 이는 재 렌더링을 초래하고 다시 fetch 함수를 실행시킨다. 이것이 반복된다.

 

. fetch는 비동기 통신이므로 api 호출 후 응답을 기다리지 않는다. 따라서 응답이 오기 전에 다른 구성요소들을 화면에 렌더링한다. 그런데 그 이후에 응답이 도착하면 then 함수를 차례로 실행하다가 결국 setItems() 함수를 실행한다. 이는 state를 변경시킨다. 그러면 리액트가 상태가 바뀌었음을 인지하고 재렌더링을 위해 App() 함수를 다시 호출한다. App() 함수는 다시 fetch() 함수를 실행하고, 위와 같은 상황이 반복된다.

 

해결법: effect 훅 (useEffect() 함수)을 사용한다.

effect 훅인 useEffect() 함수를 이용하면 무한 루프에 빠지지 않고 처음 리스트를 불러오는 부분을 구현할 수 있다.

 

useEffect는 함수와 배열을 인자로 받는다.

useEffect(콜백함수, 디펜던시배열)

 

//App.js

function App() {
  //기본 스테이트를 객체로 설정하였다.
  const [items, setItems] = useState([]);

  //백엔드에 보낼 요청 옵션
  const requestOptions = {
    method: "GET",
    headers: { "Content-Type": "application/json" },
  };

  /*
  useEffect(콜백함수, 디펜던시배열) 
  :
  첫 렌더링(마운팅)이 일어났을 때, 그 이후에는 배열 안의 오브젝트 값이 변할 때마다 콜백함수를 부른다.
  따라서 렌더링 이후에 발생하는 효과인 것이다.

  빈 배열을 인자로 넘긴 이유:

  디펜던시 배열에 items를 넣었다고 가정하자. 다시 무한 루프에 빠진다.
   첫 렌더링 이후 items 내용이 바뀌고, 그 때문에 다시 렌더링이 되고 다시 useEffect가 실행되기 때문이다.
  이를 방지하기 위해 빈 배열을 넘긴 것이다.
  */
  useEffect(() => {
    //fetch를 통해 비동기 요청 발송: useEffect를 사용하지 않으면 응답이 도착한 후 setItems 때문에 무한 렌더링에 빠진다.
    fetch("http://localhost:8080/todo", requestOptions)
      .then((response) => response.json())
      .then(
        (response) => {
          setItems(response.data);
        },
        (error) => {}
      );
  }, []);
  
  //...

 

 

자바스크립트 Promise

 

fetch 메서드는 Promise를 반환한다.

Promise는 비동기 오퍼레이션에서 사용한다. 자바스크립트는 싱글 스레드 환경에서 동작하는 동기 프로그램이다. 만약 HTTP 요청을 백엔드에 보냇는데 백엔드가 이를 처리하는 데 1분이 걸리면, 내 브라우저는 1분간 아무 것도 할 수 없는 상태가 된다. 이를 극복하기 위해서 대부분의 자바스크립트 엔진은 현재의 자바스크립트 스레드 밖에서 이런 비동기 오퍼레이션(Web API)를 실행해 준다.

 

예) XMLHttpRequest를 이용한 HTTP 요청

  var oReq = new XMLHttpRequest();
  oReq.open("GET", "http://localhost:8080/todo");
  oReq.send();

위 방법은 fetch가 아닌 XMLHttpRequest 오브젝트를 이용해 GET 요청을 보내는 방법이다. 돌아오는 응답을 받기 위해서는 콜백 함수를 사용해야 한다. 응답은 아래와 같이 콜백함수인 onload에 할당할 수 있다.

 

  var oReq = new XMLHttpRequest();
  oReq.open("GET", "http://localhost:8080/todo");
  oReq.onload = function () {
    //콜백함수
    console.log("##oReq.response: ", oReq.response);
  };
  oReq.send();

 

이처럼 XMLHttpRequest를 사용하여 백엔드와 통합해도 된다. 본질적으로 기능은 같기 때문이다. 그러나 XMLHttpRequest를 사용하는 경우 콜백함수 내에서 또 다른 HTTP 요청을 날리고 그 두 번째 요청을 위한 콜백을 또 정의하는 과정에서 코드가 복잡해진다. 이를 콜백지옥이라고 부른다.

 

Promise

Promise는 콜백지옥을 피할 수 있는 방법 중 하나이다. Promise는 말 그대로 이 함수를 실행 후 Promise 오브젝트에 명시된 사항들을 실행시키겠다는 약속이다.

 

Promise는 세 가지 상태가 있다.

  1. Pending
  2. Fulfilled
  3. Rejected
//XMLHttpRequest를 이용하는 경우의 Promise 구현 예시
  
  function exampleFunction() {
    return new Promise((resolve, reject) => {
      var oReq = new XMLHttpRequest();
      oReq.open("GET", "http://localhost:8080/todo");
      //콜백함수

      oReq.onload = function () {
        console.log("##oReq.response: ", oReq.response);
        // Fulfilled 상태
        resolve(oReq.response);
      };

      oReq.onerror = function () {
        // Reject 상태
        reject(oReq.response);
      };

      // Pending 상태
      oReq.send();
    });
  }

  // exampleFunction() 함수의 사용예시
  exampleFunction()
    .then(
      (r) => console.log("Resolved" + r),
      (r) => console.log("Rejected " + r)
    )
    .catch((e) => console.log("Error " + e));

Pending은 오퍼레이션이 끝나기를 기다리는 상태. 오퍼레이션이 성공하여 끝나면 resolve 함수를 통해 이 오퍼레이션이 성공적으로 끝났음을 알리고 원하는 값을 전달할 수 있다. 이 때 resolve는 then의 매개변수로 넘어오는 함수를 실행한다.

 

마찬가지로 오퍼레이션 중 문제가 생기는 경우 then의 두 번째 인자로 넘어오는 reject 함수를 콜한다.

또는 실행 도중 예외가 발생하는 경우 catch 매개변수로 넘어오는 함수가 실행된다.

 

then이나 catch로 넘기는 함수들은 지금 당장 실행되는 것이 아니다. 그저 매개변수로 해야 할 일을 넘겨주는 것이다. 실제 이 함수들이 실행되는 시점은 resolve와 reject가 실행되는 시점이다.

 

 

 

Fetch API

 

Fetch는 자바스크립트가 제공하는 메서드로, API 서버로 http 요청을 송신 및 수신할 수 있도록 도와주는 메서드다.

Fetch는 url을 매개변수로 받거나 url과 options를 매개변수로 받을 수 있다.

Fetch 함수는 Promise 오브젝트를 반환한다. 따라서 then과 catch에 각각 onResolve, onReject, onError 콜백 함수를 전달해 응답을 처리할 수 있다.

 

url만 이용해 GET 메서드를 이용해 요청을 보내는 방법

  fetch("http://localhost:8080/todo") //GET 메서드를 사용해서 발송
    .then(
      (response) => {
        //수신 시 하려는 작업 (onResolve)
      },
      (rejected) => {
        // promise가 reject 됐을 때 하려는 작업 (onReject)
      }
    )
    .catch((e) => {
      //에러 발생 시 하려는 작업 (onError)
    });

    /*
    fetch는 첫 번째 매개변수로 uri를 받는다. uri만 넘기는 경우 디폴트로 GET을 이용한다.
    응답이 반환되면 (response) =>{} 실행
    거부되면 (reject) => {}
    에러가 발생하면 catch에서 실행할 함수에 (e)=>{} 를 넘긴다
    */

 

fetch에 매개변수 오브젝트를 전달하는 경우

  const options = {
    method: "POST",
    headers: [
      ["Content-Type", "application/json"]
    ],
    body: JSON.stringify(data)
  };

  fetch("http://localhost:8080/todo", options) //매개변수 오브젝트 전달
    .then(
      (response) => {
        //수신 시 하려는 작업 (onResolve)
      },
      (rejected) => {
        // promise가 reject 됐을 때 하려는 작업 (onReject)
      }
    )
    .catch((e) => {
      //에러 발생 시 하려는 작업 (onError)
    });

 

코드에 이렇게 직접 하드코딩해도 기능에는 문제가 없지만 동적으로 매개변수를 동적으로 입력받지 않으므로 확장성이 좋지 않다. 따라서 설정파일에서 애플리케이션이 사용할 백엔드 URI를 동적으로 가져오도록 구현해 이후 도메인이 바뀌는 경우에 대비한다.

 

1. /src/api-config.js 생성

// api-config.js

let backendHost;
const hostname = window && window.location && window.location.hostname;

if (hostname === "localhost") {
  backendHost = "http://localhost";
}

export const API_BASE_URL = `${backendHost}`;

 

2. /src/service/ApiService.js 생성

// ApiService.js

import { API_BASE_URL } from "../api-config";

export function call(api, method, request) {
  let options = {
    headers: new Headers({
      "Content-type": "application/json",
    }),
    url: API_BASE_URL + api,
    method: method,
  };

  if (request) {
    //GET method
    options.body = JSON.stringify(request);
  }

  return fetch(options.url, options)
    .then((response) => {
      if (response.status === 200) {
        return response.json();
      }
    })
    .catch((error) => {
      console.log("http error");
      console.log(error);
    });
}

 

만약 ApiService.js와 같이 따로 만들지 않으면 아래와 같은 함수를 반복해서 사용해야 한다.

//ApiService.js와 같이 따로 만들지 않으면 아래와 같은 함수를 반복해서 사용해야 한다.

add = (item) => {
  const requestOptions = {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(item),
  };

  fetch("http://localhost:8080/todo", requestOptions)
    .then((response) => response.json())
    .then((response) =>
      this.setState({ items: response.data })
    );
};

delete = (item) => {
  const requestOptions = {
    method: "DELETE",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(item),
  };
  
  fetch("http://localhost:8080/todo", requestOptions)
    .then((response) => response.json())
    .then((response) =>
      this.setState({ items: response.data })
    );
};

 

이제 App.js에 ApiService를 임포트하여 사용해보자

 

// App.js

import "./App.css";
import Todo from "./Todo";
import AddTodo from "./AddTodo";
import React, { useEffect, useState } from "react";
import { Paper, List, Divider, Container } from "@mui/material";
import { call } from "./service/ApiService";

function App() {
  //기본 스테이트를 객체로 설정하였다.
  const [items, setItems] = useState([]);
  
  useEffect(() => {
    //fetch를 통해 비동기 요청 발송: useEffect를 사용하지 않으면 응답이 도착한 후 setItems 때문에 무한 렌더링에 빠진다.
    console.log("App.js - useEffect-GET 아이템 리스트 가져오기");
    call("/todo", "GET", null).then((response) => setItems(response.data));
  }, []);

  const addItem = (item) => {
    console.log("App.js - useEffect-POST 아이템 추가하기");
    call("/todo", "POST", item).then((response) => setItems(response.data));
  };

  const deleteItem = (item) => {
    console.log("App.js - useEffect-DELETE 아이템 지우기");
    call("/todo", "DELETE", item).then((response) => setItems(response.data));
  };
  
  
  //...

 

뭐 이런식

아이템 생성, 삭제 를 통해. db의 todo 테이블에 찍히는 것을 볼 수 있다. 

 

 

내용 수정(editItem()) 

 

기존에는 Todo.js에서 값을 변경한 후 App.js에서 리스트를 재 렌더링 함으로써 리스트를 수정하였다. 그러나 백엔드 서버와 통합하면 API를 이용하는 경우에는, 1. 서버 데이터를 업데이트 한 후 2. 변경된 내용을 화면에 다시 출력하는 작업이 필요하다.

 

기존

//App.js
 const editItem = () => {
    // items 내부의 값을 변경했기 때문에 새 배열로 초기화해 화면을 다시 렌더링한다.
    setItems([...items]);
  };
//Todo.js
const editEventHandler = (event) => {
    //이벤트의 변화가 props.item.title을 변경시킨다.
    item.title = event.target.value;
    console.log("##event.item.title", item.title);
    //App.js의 editItem()를 실행한다. setItems([...items])를 통해 재 렌더링하게 하여 바뀐 item 내용을 표시하도록 한다.
    editItem(); 
  };
//Todo.js
  const turnOnReadOnly = (event) => {
    if (event.key === "Enter") {
      setReadOnly(true);
    }
  };

 

 

 

변경 후

//App.js
/*
  //백엔드와의 통합 이후에 사용하는 아이템 수정 코드

  기존에는 Todo.js에서 값을 변경한 후 App.js에서 리스트를 재 렌더링 함으로써 리스트를 수정하였다.
  그러나 백엔드 서버와 통합하면 API를 이용하는 경우에는, 
  1. 서버 데이터를 업데이트 한 후 
  2. 변경된 내용을 화면에 다시 출력하는 작업이 필요하다.
  */
  const editItem = (item) => {
    //백엔드와 통합하기 전의 editItem 함수는 매개변수를 받지 않았다.그러나 이제부터는 수정할 item을 받아야 한다
    // 따라서 Todo.js에서 editItem()을 사용할 때 item을 넘겨줘야 한다.
    call("/todo", "PUT", item).then((response) => setItems(response.data));
  };
//Todo.js
/*
  타이틀 변경을 위해 인풋필드에서 사용자의 입력을 받아올 때, 
  editEventHandler()에서 item을 바로 넘겨버리면 글자를 입력할 때마다 HTTP 요청을 보내게 된다. X
  사용자가 수정을 완료한 시점에서 http 요청을 보내야 한다. O
  수정을 완료한 시점은 인풋필드가 수정 가능한 상태에서 수정이 불가능한 상태로 바뀌는 시점이다.
  
  따라서 editEventHandler에서는 프론트엔드의 item 값만 업데이트하고, editItem()은 생략하여 HTTP 요청은 보내지 않는다.
  이후 사용자가 엔터키를 누르는 순간 실행되는 turnOnReadOnly()에서 HTTP요청을 보내는 editItem()을 실행한다.

   */
   
const editEventHandler = (event) => {
    setItem({...item, title: event.target.value});
}
//Todo.js
const turnOnReadOnly = (event) => {
    if (event.key === "Enter") {
      setReadOnly(true);
      //editEventHandler가 setItem()을 사용하여 변경한 item을 매개변수로 담아서 실행한다.
      //App.js의 editItem()이 호출된다.
      editItem(item);
    }
  };

 

 

 

체크 박스 수정

- 체크 상태가 업데이트 될 때마다 editItem()을 호출해 백엔드에 HTTP 요청을 보내면 된다.

 

기존

  const checkBoxEventHandler = (event) => {
    item.done = event.target.checked;
    console.log("##item.done", item.done);
    editItem();
  };

 

변경 후

  const checkBoxEventHandler = (event) => {
    item.done = event.target.checked;
    console.log("##item.done", item.done);
    // editItem();
    editItem(item); //변경된 체크 정보가 적용된 item을 담아서 editItem() 실행
  };

 

 

변경 전

 

정보를 변경-> editEventHandler -> 엔터를 눌러 수정 완료 turnOnReadOnly -> 

 

변경 후

 

 

 

 

 

 

 

 

 

 

해결해야 할 문제!

 

 

 

 

 

 

done 값이 null로 undefined 돼 있다. 프론트에서 체크를 눌러 정보가 변경되면 아래와 같은 오류가 발생한다.

 

react-dom.development.js:86 Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components

 

그리고 db의 값이 0 또는 1로 지정되며, 지정된 Todo 아이템의 경우 체크박스 정보를 바꿔도 더이상 오류가 발생하지 않는다.

Comments