지난 Next.js, Spring Boot 어플리케이션 리버스 프록시 적용하기 포스트를 통해 Next.js에 리버스 프록시를 적용 및 배포하여 실제로 작동하는 것을 확인해보았습니다. 하지만 순수 React로 작성된 프론트앤드 앱의 경우 코드를 통해 리버스 프록시를 적용하는 것은 어려우며, 보통 Nginx 서버를 구성하고 proxy_pass
옵션을 부여해 목적지를 백엔드 서버 쪽으로 세팅하는 방법을 사용합니다.
location /api/users {
proxy_pass http://www.example.com/api/users;
}
클라우드타입은 별도의 Nginx 서버를 세팅하지 않아도 위와 같이 리버스 프록시가 작동하도록 하는 기능을 지원합니다. 새로운 서버를 구성하기 위해 리소스 비용을 지불하거나 백엔드 측의 코드를 수정하지 않아도 되기 때문에 매우 편리하게 이용할 수 있습니다.
실습은 아래의 React, Express 어플리케이션을 통해 진행됩니다. 저장소를 clone 하거나 fork 해주세요.
예시 프로젝트의 src/index.ts
를 살펴보면 일반적으로 CORS 관련 설정을 적용하는 데에 사용되는 app.use(cors());
코드가 주석 처리되어있습니다. Express 앱에서 별도의 CORS 설정을 해주지 않으면 오리진이 다른 클라이언트의 요청을 block 처리합니다.
import 'reflect-metadata';
import express from 'express';
// import cors from 'cors';
import bodyParser from 'body-parser';
import userRoutes from './routes/userRoutes';
import { AppDataSource } from './data-source';
const app = express();
const PORT = 3001;
// app.use(cors());
app.use(bodyParser.json());
AppDataSource.initialize().then(() => {
app.use('/api/users', userRoutes);
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
}).catch(error => console.log(error));
src/routes/userRoutes.ts
에는 /api/users
에 대한 CRUD 작업의 라우팅 관련 코드가 작성되어 있습니다.
import { Router } from 'express';
import { AppDataSource } from '../data-source';
import { User } from '../entity/User';
const router = Router();
router.get('/', async (req, res) => {
const userRepository = AppDataSource.getRepository(User);
const users = await userRepository.find();
res.json(users);
});
router.get('/:id', async (req, res) => {
const userRepository = AppDataSource.getRepository(User);
const user = await userRepository.findOneBy({ id: parseInt(req.params.id, 10) });
if (user) {
console.log(user);
res.json(user);
} else {
res.status(404).json({ message: 'User not found' });
}
});
router.post('/', async (req, res) => {
const userRepository = AppDataSource.getRepository(User);
const user = userRepository.create(req.body);
const result = await userRepository.save(user);
console.log(result);
res.json(result);
});
router.put('/:id', async (req, res) => {
const userRepository = AppDataSource.getRepository(User);
const user = await userRepository.findOneBy({ id: parseInt(req.params.id, 10) });
if (user) {
userRepository.merge(user, req.body);
const result = await userRepository.save(user);
console.log(result);
res.json(result);
} else {
res.status(404).json({ message: 'User not found' });
}
});
router.delete('/:id', async (req, res) => {
const userRepository = AppDataSource.getRepository(User);
const result = await userRepository.delete(req.params.id);
if (result.affected) {
console.log(result);
res.json({ message: 'User deleted' });
} else {
res.status(404).json({ message: 'User not found' });
}
});
export default router;
src/data-source.ts
는 인메모리 DB로 Sqlite3를 세팅하고 스키마 구성 및 테스트 데이터를 만드는 역할을 수행합니다. 필요에 따라 영속성 유지를 위해 외부의 데이터베이스로 변경도 얼마든지 가능합니다.
import { DataSource } from 'typeorm';
import { User } from './entity/User';
import { Table } from 'typeorm';
export const AppDataSource = new DataSource({
type: 'sqlite',
database: ':memory:',
synchronize: false,
logging: false,
entities: [User],
});
AppDataSource.initialize().then(async () => {
const queryRunner = AppDataSource.createQueryRunner();
await queryRunner.connect();
const hasTable = await queryRunner.hasTable('user');
if (!hasTable) {
await queryRunner.createTable(new Table({
name: 'user',
columns: [
{
name: 'id',
type: 'integer',
isPrimary: true,
isGenerated: true,
generationStrategy: 'increment',
},
{
name: 'name',
type: 'varchar',
},
{
name: 'email',
type: 'varchar',
},
],
}));
}
await queryRunner.release();
const userRepository = AppDataSource.getRepository(User);
const mockUser = new User();
mockUser.name = '테스트';
mockUser.email = 'admin@example.com';
await userRepository.save(mockUser);
console.log('Mock user created');
}).catch(error => console.log(error));
src/components/UsersList.jsx
는 사용자의 목록을 브라우저에 띄워주는 것과 동시에 CRUD 작업을 수행하기 위한 요청을 포함하고 있습니다. 그 중 fetch()
의 인수로 넘기는 apiPath
를 보면 외부의 API URL이 아닌 자기 자신을 호출하는 것을 확인할 수 있는데, 이는 추후 클라우드타입의 설정에서 리버스 프록시로 처리되는 부분입니다.
import { useState, useEffect } from 'react';
const apiPath = import.meta.env.VITE_API_PATH || '/api/users';
async function fetchUsers() {
const res = await fetch(apiPath);
if (!res.ok) {
throw new Error('Failed to fetch users');
}
return res.json();
}
async function createUser(user) {
const res = await fetch(apiPath, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(user),
});
if (!res.ok) {
throw new Error('Failed to create user');
}
return res.json();
}
async function updateUser(id, user) {
const res = await fetch(`${apiPath}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(user),
});
if (!res.ok) {
throw new Error('Failed to update user');
}
return res.json();
}
async function deleteUser(id) {
const res = await fetch(`${apiPath}/${id}`, {
method: 'DELETE',
});
if (!res.ok) {
throw new Error('Failed to delete user');
}
return res.json();
}
export default function Users() {
const [newUser, setNewUser] = useState({ name: '', email: '' });
const [editingUser, setEditingUser] = useState(null);
const [localData, setLocalData] = useState([]);
const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [savingId, setSavingId] = useState(null);
const [deletingId, setDeletingId] = useState(null);
const [showModal, setShowModal] = useState(false);
useEffect(() => {
async function loadUsers() {
try {
const users = await fetchUsers();
setLocalData(users);
} catch (error) {
setError(error.message || 'Error fetching users');
}
}
loadUsers();
}, []);
const handleCreateUser = async () => {
if (!newUser.name || !newUser.email) {
setShowModal(true);
return;
}
if (isSubmitting) return;
setIsSubmitting(true);
try {
const createdUser = await createUser(newUser);
setLocalData((prevData) => [...prevData, createdUser]);
setNewUser({ name: '', email: '' });
} catch (error) {
setError(error.message || 'Error creating user');
} finally {
setIsSubmitting(false);
}
};
const handleUpdateUser = async (id) => {
if (!editingUser.name || !editingUser.email) {
setShowModal(true);
return;
}
if (savingId === id) return;
setSavingId(id);
try {
const updatedUser = await updateUser(id, editingUser);
setLocalData((prevData) =>
prevData.map((user) => (user.id === id ? updatedUser : user))
);
setEditingUser(null);
} catch (error) {
setError(error.message || 'Error updating user');
} finally {
setSavingId(null);
}
};
const handleDeleteUser = async (id) => {
if (deletingId === id) return;
setDeletingId(id);
try {
await deleteUser(id);
setLocalData((prevData) => prevData.filter((user) => user.id !== id));
} catch (error) {
setError(error.message || 'Error deleting user');
} finally {
setDeletingId(null);
}
};
if (error)
return (
<div className='text-red-500 text-center mt-10'>
Failed to load: {error}
</div>
);
return (
<div className='container mx-auto p-4'>
<h1 className='text-3xl font-bold text-center my-4'>
Next.js Fetching Example
</h1>
<div className='mb-4 flex justify-start items-center space-x-2'>
<input
type='text'
placeholder='이름'
value={newUser.name}
onChange={(e) => setNewUser({ ...newUser, name: e.target.value })}
className='border p-2 mr-2 rounded'
/>
<input
type='email'
placeholder='이메일'
value={newUser.email}
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
className='border p-2 mr-2 rounded'
/>
<button
onClick={handleCreateUser}
className='bg-blue-500 text-white p-2 rounded'
disabled={isSubmitting}
>
{isSubmitting ? '등록 중...' : '등록'}
</button>
</div>
<ul className='space-y-4'>
{localData.map((user) => (
<li
key={user.id}
className='p-4 border rounded shadow-sm hover:shadow-md'
>
{editingUser && editingUser.id === user.id ? (
<div className='flex flex-col sm:flex-row items-center justify-start space-x-2'>
<input
type='text'
value={editingUser.name}
onChange={(e) =>
setEditingUser({ ...editingUser, name: e.target.value })
}
className='border p-2 mr-2 rounded'
/>
<input
type='email'
value={editingUser.email}
onChange={(e) =>
setEditingUser({ ...editingUser, email: e.target.value })
}
className='border p-2 mr-2 rounded'
/>
<div className='mt-2 sm:mt-0'>
<button
onClick={() => handleUpdateUser(user.id)}
className='bg-green-500 text-white p-2 rounded mr-2'
disabled={savingId === user.id}
>
{savingId === user.id ? '저장 중...' : '저장'}
</button>
<button
onClick={() => setEditingUser(null)}
className='bg-gray-500 text-white p-2 rounded'
disabled={savingId === user.id}
>
취소
</button>
</div>
</div>
) : (
<div className='flex justify-between items-center'>
<div>
<div className='text-lg font-semibold'>{user.name}</div>
<div className='text-gray-600'>{user.email}</div>
</div>
<div>
<button
onClick={() => setEditingUser(user)}
className='bg-yellow-500 text-white p-2 rounded mr-2'
disabled={isSubmitting || deletingId === user.id}
>
수정
</button>
<button
onClick={() => handleDeleteUser(user.id)}
className='bg-red-500 text-white p-2 rounded'
disabled={deletingId === user.id}
>
{deletingId === user.id ? '삭제 중...' : '삭제'}
</button>
</div>
</div>
)}
</li>
))}
</ul>
{showModal && (
<div className='fixed inset-0 flex items-center justify-center bg-black bg-opacity-50'>
<div className='bg-white p-6 rounded shadow-lg'>
<h2 className='text-2xl mb-4'>값을 입력하세요</h2>
<button
onClick={() => setShowModal(false)}
className='bg-blue-500 text-white p-2 rounded'
>
닫기
</button>
</div>
</div>
)}
</div>
);
}
클라우드타입의 프로젝트 페이지에서 ➕ 버튼을 누르고 Node를 선택한 후, 미리 fork 해놓은 express-crud-no-cors 를 선택합니다. 기타 설정은 아래를 참고하여 입력한 후 배포하기 버튼을 클릭합니다.
NODE_ENV
: production
3001
npx tsc
node dist/index
배포가 완료되면 접속하기 버튼을 누르고 주소창에 /api/users
경로를 추가하여 접속한 후 초기 데이터가 조회되는지 확인합니다.
Express 백엔드 앱의 배포가 완료되면 이어서 클라우드타입의 프로젝트 페이지의 ➕ 버튼을 누르고 React(vite)를 선택한 후, 미리 fork 해놓은 react-crud 를 선택합니다. 기타 설정은 아래를 참고하여 입력한 후 배포하기 버튼을 클릭합니다.
VITE_API_PATH
: /api/users
/api/users
경로 추가)http(s)://
를 포함한 호출 대상 서비스의 URLReact 프론트앤드 앱 배포가 완료되면 접속하기 버튼을 눌러 프록시가 잘 동작하는지 확인합니다. 정상 동작 한다면 CORS 에러가 발생하지 않고 테스트 데이터가 브라우저에 표시됩니다.
<React 프리뷰 URL>/api/users
를 입력하면 리버스 프록시에 의해 Express에 요청이 전달되어 그 응답값이 브라우저에 표시되는 것을 확인할 수 있습니다.