브라우저에서 기본적으로 API를 요청할 때, 브라우저의 현재 주소와 API의 주소 도메인이 일치해야만 데이터를 접근할 수 있게 되어있다.
만약 다른 도메인에서 API를 요청해서 사용할 수 있게 해주려면 CORS 설정이 필요하다.
CORS
교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS) 는 추가 HTTP 헤더를 사용하며, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제이다.
출처
웹 콘텐츠의 출처(origin) 는 접근할 때 사용하는 URL의 스킴(프로토콜), 호스트(도메인), 포트로 정의된다. 두 객체의 스킴, 호스트, 포트가 모두 일치하는 경우 같은 출처를 가졌다고 말한다.
만약 자신이 실제 서비스가 되는 상용 앱을 운영 중이라면, 자신이 구축한 클라이언트 뒤의 서버와 연결되어 있는 DB에는 라이브 데이터(live data) 가 쌓일 것이다.
라이브 데이터(live data)
실제 서비스되고 있는 앱의 데이터베이스에 적재되고 있는 데이터를 의미한다. 유저 및 상품, 결제 등 다양한 정보들을 예로 들 수 있다.
이런 라이브 데이터는 민감성이 높은 데이터들이 위주이기 때문에 보안이 무엇보다 중요하다.
그러나 서비스 및 프로젝트가 모든 출처의 접근을 허락한다면 이러한 보안성이 현저히 낮아지고, 해킹의 위험에 그대로 노출된다.
따라서 모든 도메인을 허용해서는 안되고, 특정 도메인을 허용하도록 구현해야 한다.
프론트엔드 개발자가 백엔드 개발자에게 프론트엔드 개발 서버 도메인을 허용해달라고 요청을 해야하고, 백엔드 개발자는 응답 헤더에 필요한 값들을 담아서 전달해줘야 한다.
서버에서 적절한 응답 헤더를 받지 못하면 브라우저에서 에러가 발생하기 때문이다.
위의 정석적인 과정 없이 React 라이브러리, 혹은 webpack Dev server에서 제공하는 proxy 기능을 사용하면 CORS 정책을 우회할 수 있다.
이는 별도의 응답 헤더를 받을 필요 없이 브라우저는 React 앱으로 데이터를 요청하고, 해당 요청을 백엔드로 전달하게 된다.
여기서 React 앱이 서버로부터 받은 응답 데이터를 다시 브라우저로 전달하는 방법을 쓰기 때문에 브라우저는 CORS 정책을 위반한지 모르게 된다.
React 앱에서 브라우저를 통해 API를 요청할 때, proxy를 통해 백엔드 서버로 요청을 우회하여 보내게 된다.
그러면 백엔드 서버는 응답을 React 앱으로 보내고, React 앱은 받은 응답을 백엔드 서버 대신 브라우저에게 전달한다.
이렇게 되면 출처가 같아지기 때문에 브라우저는 이 사실을 눈치채지 못하고 허용하게 된다.
CRA를 통해 만든 르액티 프로젝트에서는 package.json
에서 "proxy"
값을 설정하여 쉽게 적용할 수 있도록 구성이 되어있다.
// my-app/src/package.json
{
"name": "my-app",
"version": "0.1.0",
"private": true,
"dependencies": {
"bootstrap": "^5.2.3",
"http-proxy-middleware": "^2.0.6",
"react": "^18.2.0",
"react-bootstrap": "^2.7.0",
"react-dom": "^18.2.0",
"react-scripts": "^5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:3080" // 우회할 API 주소
}
proxy는 보통 맨 밑에 작성하여 금방 찾을 수 있도록 한다.
package.json 변경사항을 적용하기 위해서는 클라이언트 재실행 이 필요하다.
// my-app/src/services/BookService.js
export const getAllBooks = async () => {
const response = await fetch("http://localhost:3080/api/books");
return await response.json();
};
export const getAllBooks = async () => {
const response = await fetch("/api/books"); // 도메인 부분 제거
return await response.json();
};
webpack dev server 에서 제공하는 proxy는 전역적인 설정이기 때문에, 종종 해당 방법이 충분히 적용되지 않는 경우가 생기기도 한다.
그래서 수동으로 proxy를 적용해줘야 하는 경우가 있는데, 이 때 http-proxy-middleware
라이브러리를 사용한다.
📍 http-proxy-middleware 라이브러리 설치
npm install http-proxy-middleware --save
React App의 src 파일 안에서 setupProxy.js
파일을 생성하고, 안에서 설치한 라이브러리 파일을 불러온 다음 아래와 같이 작성한다.
파일명은 정해진 것이기 때문에 다르게 작성하지 않도록 유의한다.
// my-app/src/setupProxy.js
const { createProxyMiddleware } = require("http-proxy-middleware");
module.exports = function (app) {
app.use(
"/api",
createProxyMiddleware({
target: "http://localhost:3080",
changeOrigin: true,
})
);
app.use(
"/api2",
createProxyMiddleware({
target: "http://localhost:3070",
changeOrigin: true,
})
);
};
아니면 아래와 같이 하나로 합쳐서 작성도 가능하다.
// my-app/src/setupProxy.js
const { createProxyMiddleware } = require("http-proxy-middleware");
module.exports = function (app) {
app.use(
["/api", "/api2"],
createProxyMiddleware({
target: "http://localhost:3080",
changeOrigin: true,
router: {
"/api2": "http://localhost:3070",
},
})
);
};
api2와 관련된 fetch 함수를 만들어준다.
// my-app/src/services/TodoService.js
export const getAllTodos = async () => {
const response = await fetch("/api2/todos");
return await response.json();
};
export const createTodo = async (data) => {
const response = await fetch("/api2/todo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ todo: data }),
});
return await response.json();
};
CreateBook 컴포넌트와 유사하게 만들어준다.
// my-app/src/components/CreateTodo.js
const CreateTodo = ({ onChangeForm, handleSubmit }) => {
return (
<div className="form-wrapper">
<div className="form">
<form>
<div className="input-group">
<label>todo</label>
<input
type="text"
onChange={(e) => onChangeForm(e)}
name="todo"
placeholder="todo"
/>
</div>
<div className="input-group">
<label>category</label>
<input
type="text"
onChange={(e) => onChangeForm(e)}
name="category"
placeholder="category"
/>
</div>
<button className="submit-button" onClick={() => handleSubmit()}>
Submit
</button>
</form>
</div>
</div>
);
};
export default CreateTodo;
마찬가지로 BookTable 컴포넌트와 유사하게 만들어준다.
// my-app/src/components/TodoTable.js
const TodoTable = ({ todos }) => {
if (todos.length === 0) return null;
return (
<div className="table-wrapper">
<div className="table-box">
<h2>My Todos</h2>
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Id</th>
<th>Todo</th>
<th>Category</th>
</tr>
</thead>
<tbody>
{todos.map((todo, index) => {
return (
<tr key={index} className={index % 2 === 0 ? "odd" : "even"}>
<td>{index + 1}</td>
<td>{todo.todo}</td>
<td>{todo.category}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default TodoTable;
마찬가지로 Get all Todos 버튼을 클릭할 때 투 두 목록이 보일 수 있도록 만들었다.
// my-app/src/components/DisplayBoard.js
const DisplayBoard = ({
numberOfBooks,
getAllBook,
numberOfTodos,
getAllTodo,
}) => {
return (
<div className="display-wrapper">
<div className="display-box">
<div className="display-board">
<h4>생성된 책 개수</h4>
<div className="number">{numberOfBooks}</div>
</div>
<div className="display-board">
<h4>생성된 할 일 개수</h4>
<div className="number">{numberOfTodos}</div>
</div>
<div className="get-button">
<button onClick={() => getAllBook()}>Get all Books</button>
<button onClick={() => getAllTodo()}>Get all Todos</button>
</div>
</div>
</div>
);
};
export default DisplayBoard;
위에서 만든 컴포넌트들을 내려주었다.
// my-app/src/App.js
import { useState } from 'react';
import './App.css';
import Header from './components/Header';
import BookTable from './components/BookTable';
import TodoTable from "./components/TodoTable";
import DisplayBoard from "./components/DisplayBoard";
import CreateBook from "./components/CreateBook";
import CreateTodo from "./components/CreateTodo";
import { getAllBooks, createBook } from "./services/BookService";
import { getAllTodos, createTodo } from "./services/TodoService";
import Footer from "./components/Footer";
function App() {
const [bookShelf, setBookShelf] = useState({});
const [books, setBooks] = useState([]);
const [numberOfBooks, setNumberBooks] = useState(0);
const [todoList, setTodoList] = useState({});
const [todos, setTodos] = useState([]);
const [numberOfTodos, setNumberTodos] = useState(0);
const handleBookSubmit = () => {
createBook(bookShelf).then(() => {
setNumberBooks(numberOfBooks + 1);
});
};
const handleTodoSubmit = () => {
createTodo(todoList).then(() => {
setNumberTodos(numberOfTodos + 1);
});
};
const getAllBook = () => {
getAllBooks().then((data) => {
setBooks(data);
setNumberBooks(data.length);
});
};
const getAllTodo = () => {
getAllTodos().then((data) => {
setTodos(data);
setNumberTodos(data.length);
});
};
const handleOnChangeBookForm = (e) => {
let inputData = bookShelf;
if (e.target.name === "book") {
bookShelf.book = e.target.value;
} else if (e.target.name === "category") {
bookShelf.category = e.target.value;
} else if (e.target.name === "author") {
bookShelf.author = e.target.value;
}
setBookShelf(inputData);
};
const handleOnChangeTodoForm = (e) => {
let inputData = todoList;
if (e.target.name === "todo") {
todoList.todo = e.target.value;
} else if (e.target.name === "category") {
todoList.category = e.target.value;
}
setTodoList(inputData);
};
return (
<div className="main-wrapper">
<div className="main">
<Header />
<CreateBook
bookShelf={bookShelf}
onChangeForm={handleOnChangeBookForm}
handleSubmit={handleBookSubmit}
/>
<CreateTodo
todoList={todoList}
onChangeForm={handleOnChangeTodoForm}
handleSubmit={handleTodoSubmit}
/>
<DisplayBoard
numberOfBooks={numberOfBooks}
getAllBook={getAllBook}
numberOfTodos={numberOfTodos}
getAllTodo={getAllTodo}
/>
<BookTable books={books} />
<TodoTable todos={todos} />
<Footer />
</div>
</div>
);
}
export default App;
서버 응답도 잘 넘어온다.
위 실습 콘텐츠의 출처는 코드스테이츠에 있습니다.