클라우드타입의 Rewrites 기능으로 CORS 문제 쉽게 해결하기(feat. React, Express)

조경민·2024년 7월 9일
0
post-thumbnail
post-custom-banner

지난 Next.js, Spring Boot 어플리케이션 리버스 프록시 적용하기 포스트를 통해 Next.js에 리버스 프록시를 적용 및 배포하여 실제로 작동하는 것을 확인해보았습니다. 하지만 순수 React로 작성된 프론트앤드 앱의 경우 코드를 통해 리버스 프록시를 적용하는 것은 어려우며, 보통 Nginx 서버를 구성하고 proxy_pass 옵션을 부여해 목적지를 백엔드 서버 쪽으로 세팅하는 방법을 사용합니다.

location /api/users {
    proxy_pass http://www.example.com/api/users;
}

클라우드타입은 별도의 Nginx 서버를 세팅하지 않아도 위와 같이 리버스 프록시가 작동하도록 하는 기능을 지원합니다. 새로운 서버를 구성하기 위해 리소스 비용을 지불하거나 백엔드 측의 코드를 수정하지 않아도 되기 때문에 매우 편리하게 이용할 수 있습니다.

실습

버전 정보

  • 프론트엔드
    • Node.js 18
    • Vite 5.3.1
    • React 18.3.1
  • 백엔드
    • Node.js 18
    • Express 4.19.2

준비 사항

GitHub 저장소

실습은 아래의 React, Express 어플리케이션을 통해 진행됩니다. 저장소를 clone 하거나 fork 해주세요.

따라하기

Express 백엔드 구성

  1. 예시 프로젝트의 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));
  2. 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;
  3. 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));

React 프론트엔드 구성

  1. 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>
      );
    }

백엔드 / 프론트엔드 배포 및 리버스 프록시 적용

  1. 클라우드타입의 프로젝트 페이지에서 ➕ 버튼을 누르고 Node를 선택한 후, 미리 fork 해놓은 express-crud-no-cors 를 선택합니다. 기타 설정은 아래를 참고하여 입력한 후 배포하기 버튼을 클릭합니다.

    • Node.js 버전: v18
    • 환경변수(Environment Variables)
      • NODE_ENV: production
    • Port: 3001
    • Build command: npx tsc
    • Start command: node dist/index
  2. 배포가 완료되면 접속하기 버튼을 누르고 주소창에 /api/users 경로를 추가하여 접속한 후 초기 데이터가 조회되는지 확인합니다.

  3. Express 백엔드 앱의 배포가 완료되면 이어서 클라우드타입의 프로젝트 페이지의 ➕ 버튼을 누르고 React(vite)를 선택한 후, 미리 fork 해놓은 react-crud 를 선택합니다. 기타 설정은 아래를 참고하여 입력한 후 배포하기 버튼을 클릭합니다.

    • Node.js 버전: v18
    • 환경변수(Environment Variables)
      • VITE_API_PATH: /api/users
    • Rewrites
      • 좌측 필드
        • 프론트엔드에서 호출할 원본 경로
      • 우측 필드
        • 프로젝트 내부: 드롭다운 버튼 클릭 후 프로젝트 내부에 배포된 목적지 서비스 선택(필요에 따라 경로를 추가 작성, 예제의 경우 /api/users 경로 추가)
        • 프로젝트 외부: http(s)://를 포함한 호출 대상 서비스의 URL
  4. React 프론트앤드 앱 배포가 완료되면 접속하기 버튼을 눌러 프록시가 잘 동작하는지 확인합니다. 정상 동작 한다면 CORS 에러가 발생하지 않고 테스트 데이터가 브라우저에 표시됩니다.

  5. <React 프리뷰 URL>/api/users를 입력하면 리버스 프록시에 의해 Express에 요청이 전달되어 그 응답값이 브라우저에 표시되는 것을 확인할 수 있습니다.

Reference

profile
Live And Let Live!
post-custom-banner

0개의 댓글