React + Node.js 풀스택 앱 배포하기 – 완성까지 한번에!

프로젝트 소개 및 목표

React + Node.js 풀스택 앱 배포하기는 프론트엔드부터 백엔드, 그리고 실제 서버 배포까지 전체 개발 사이클을 경험할 수 있는 실전 프로젝트입니다. 이 가이드에서는 간단한 할 일 관리(Todo) 앱을 만들고, AWS EC2 또는 Heroku를 통해 실제 운영 환경에 배포하는 전 과정을 다룹니다. 포트폴리오에 추가할 수 있는 실무 수준의 프로젝트를 완성하며, RESTful API 설계, 데이터베이스 연동, 환경 변수 관리, CI/CD 파이프라인 구축까지 현업에서 필요한 핵심 기술들을 익힐 수 있습니다. 이 프로젝트를 완료하면 풀스택 개발자로서의 기본기를 탄탄히 다질 수 있습니다.

필요한 기술 스택

이 프로젝트를 진행하기 위해 필요한 기술 스택은 다음과 같습니다:

  • 프론트엔드: React 18, React Router, Axios, Tailwind CSS
  • 백엔드: Node.js, Express.js, MongoDB (또는 PostgreSQL)
  • 인증: JWT (JSON Web Token)
  • 배포: AWS EC2, Nginx, PM2 (또는 Heroku)
  • 도구: Git, GitHub, Postman, VS Code

Node.js 16 이상과 npm 또는 yarn이 설치되어 있어야 하며, Git과 GitHub 계정, 그리고 AWS 또는 Heroku 계정이 필요합니다.

프로젝트 셋업

먼저 프로젝트 디렉토리를 생성하고 초기 설정을 진행합니다. 모노레포 구조로 클라이언트와 서버를 함께 관리하겠습니다.

# 프로젝트 루트 디렉토리 생성
mkdir fullstack-todo-app
cd fullstack-todo-app

# Git 초기화
git init

# 클라이언트(React) 생성
npx create-react-app client

# 서버 디렉토리 생성
mkdir server
cd server
npm init -y

# 서버 의존성 설치
npm install express mongoose dotenv cors jsonwebtoken bcryptjs
npm install --save-dev nodemon

프로젝트 루트에 .gitignore 파일을 생성하여 node_modules/, .env, build/ 등을 추가합니다. 이제 본격적인 개발을 시작할 준비가 완료되었습니다.

단계별 구현 과정

1단계: 백엔드 API 서버 구축

먼저 Express 서버의 기본 구조를 설정합니다. server/index.js 파일을 생성합니다:

const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
require('dotenv').config();

const app = express();
const PORT = process.env.PORT || 5000;

// 미들웨어
app.use(cors());
app.use(express.json());

// MongoDB 연결
mongoose.connect(process.env.MONGODB_URI, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
})
.then(() => console.log('MongoDB 연결 성공'))
.catch(err => console.error('MongoDB 연결 실패:', err));

// 라우트
app.use('/api/auth', require('./routes/auth'));
app.use('/api/todos', require('./routes/todos'));

app.listen(PORT, () => {
  console.log(`서버가 포트 ${PORT}에서 실행 중입니다`);
});

server/.env 파일에 환경 변수를 설정합니다:

MONGODB_URI=mongodb://localhost:27017/fullstack-todo
JWT_SECRET=your-super-secret-jwt-key-change-in-production
PORT=5000

2단계: 데이터 모델 및 인증 구현

server/models/User.js에 사용자 모델을 생성합니다:

const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
  },
  password: {
    type: String,
    required: true,
  },
  name: {
    type: String,
    required: true,
  },
}, { timestamps: true });

// 비밀번호 해싱
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 10);
  next();
});

// 비밀번호 검증
userSchema.methods.comparePassword = async function(candidatePassword) {
  return await bcrypt.compare(candidatePassword, this.password);
};

module.exports = mongoose.model('User', userSchema);

server/models/Todo.js에 할 일 모델을 생성합니다:

const mongoose = require('mongoose');

const todoSchema = new mongoose.Schema({
  title: {
    type: String,
    required: true,
  },
  description: String,
  completed: {
    type: Boolean,
    default: false,
  },
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true,
  },
}, { timestamps: true });

module.exports = mongoose.model('Todo', todoSchema);

server/middleware/auth.js에 JWT 인증 미들웨어를 작성합니다:

const jwt = require('jsonwebtoken');

module.exports = (req, res, next) => {
  try {
    const token = req.header('Authorization')?.replace('Bearer ', '');
    
    if (!token) {
      return res.status(401).json({ message: '인증 토큰이 없습니다' });
    }
    
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.userId = decoded.userId;
    next();
  } catch (error) {
    res.status(401).json({ message: '유효하지 않은 토큰입니다' });
  }
};

3단계: API 라우트 구현

server/routes/auth.js에 회원가입 및 로그인 라우트를 작성합니다:

const express = require('express');
const jwt = require('jsonwebtoken');
const User = require('../models/User');

const router = express.Router();

// 회원가입
router.post('/register', async (req, res) => {
  try {
    const { email, password, name } = req.body;
    
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(400).json({ message: '이미 존재하는 이메일입니다' });
    }
    
    const user = new User({ email, password, name });
    await user.save();
    
    const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, {
      expiresIn: '7d',
    });
    
    res.status(201).json({ token, user: { id: user._id, email, name } });
  } catch (error) {
    res.status(500).json({ message: '서버 오류가 발생했습니다' });
  }
});

// 로그인
router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    const user = await User.findOne({ email });
    if (!user || !(await user.comparePassword(password))) {
      return res.status(401).json({ message: '이메일 또는 비밀번호가 틀렸습니다' });
    }
    
    const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, {
      expiresIn: '7d',
    });
    
    res.json({ token, user: { id: user._id, email: user.email, name: user.name } });
  } catch (error) {
    res.status(500).json({ message: '서버 오류가 발생했습니다' });
  }
});

module.exports = router;

server/routes/todos.js에 할 일 CRUD 라우트를 작성합니다:

const express = require('express');
const Todo = require('../models/Todo');
const auth = require('../middleware/auth');

const router = express.Router();

// 모든 할 일 조회
router.get('/', auth, async (req, res) => {
  try {
    const todos = await Todo.find({ user: req.userId }).sort({ createdAt: -1 });
    res.json(todos);
  } catch (error) {
    res.status(500).json({ message: '할 일을 불러오는데 실패했습니다' });
  }
});

// 할 일 생성
router.post('/', auth, async (req, res) => {
  try {
    const { title, description } = req.body;
    const todo = new Todo({ title, description, user: req.userId });
    await todo.save();
    res.status(201).json(todo);
  } catch (error) {
    res.status(500).json({ message: '할 일 생성에 실패했습니다' });
  }
});

// 할 일 수정
router.put('/:id', auth, async (req, res) => {
  try {
    const todo = await Todo.findOneAndUpdate(
      { _id: req.params.id, user: req.userId },
      req.body,
      { new: true }
    );
    if (!todo) return res.status(404).json({ message: '할 일을 찾을 수 없습니다' });
    res.json(todo);
  } catch (error) {
    res.status(500).json({ message: '할 일 수정에 실패했습니다' });
  }
});

// 할 일 삭제
router.delete('/:id', auth, async (req, res) => {
  try {
    const todo = await Todo.findOneAndDelete({ _id: req.params.id, user: req.userId });
    if (!todo) return res.status(404).json({ message: '할 일을 찾을 수 없습니다' });
    res.json({ message: '할 일이 삭제되었습니다' });
  } catch (error) {
    res.status(500).json({ message: '할 일 삭제에 실패했습니다' });
  }
});

module.exports = router;

4단계: React 프론트엔드 구현

클라이언트 디렉토리로 이동하여 필요한 패키지를 설치합니다:

cd client
npm install axios react-router-dom
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

client/src/services/api.js에 API 통신 설정을 작성합니다:

import axios from 'axios';

const API = axios.create({
  baseURL: process.env.REACT_APP_API_URL || 'http://localhost:5000/api',
});

// 요청 인터셉터: 토큰 자동 추가
API.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

export const authAPI = {
  register: (data) => API.post('/auth/register', data),
  login: (data) => API.post('/auth/login', data),
};

export const todoAPI = {
  getAll: () => API.get('/todos'),
  create: (data) => API.post('/todos', data),
  update: (id, data) => API.put(`/todos/${id}`, data),
  delete: (id) => API.delete(`/todos/${id}`),
};

export default API;

client/src/components/TodoList.js에 메인 컴포넌트를 작성합니다:

import React, { useState, useEffect } from 'react';
import { todoAPI } from '../services/api';

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');

  useEffect(() => {
    fetchTodos();
  }, []);

  const fetchTodos = async () => {
    try {
      const { data } = await todoAPI.getAll();
      setTodos(data);
    } catch (error) {
      console.error('할 일 조회 실패:', error);
    }
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      await todoAPI.create({ title, description });
      setTitle('');
      setDescription('');
      fetchTodos();
    } catch (error) {
      console.error('할 일 생성 실패:', error);
    }
  };

  const toggleComplete = async (todo) => {
    try {
      await todoAPI.update(todo._id, { completed: !todo.completed });
      fetchTodos();
    } catch (error) {
      console.error('할 일 수정 실패:', error);
    }
  };

  const deleteTodo = async (id) => {
    try {
      await todoAPI.delete(id);
      fetchTodos();
    } catch (error) {
      console.error('할 일 삭제 실패:', error);
    }
  };

  return (
    

할 일 관리

setTitle(e.target.value)} className="w-full px-4 py-2 border rounded" required />