React Γ Spring Boot νκ°
β’
νκ° κ΅κ³Όλͺ© : Node.js μ React νμ©
β’
μ΄μ : 100μ
β’
νκ° μ£Όμ :
μν(Product) κ΄λ¦¬ μμ€ν
λ¬Έμ κ°μ
Reactμ Spring Bootλ₯Ό νμ©νμ¬ μν κ΄λ¦¬ μμ€ν
μ μ§μ ꡬννμ¬ μ μΆνμμ€.
μ£Όμ ꡬν λ΄μ©
β’
λ°±μλ (Spring Boot): REST API μλ² κ΅¬μΆ λ° MySQL λ°μ΄ν°λ² μ΄μ€ μ°λ
β’
νλ‘ νΈμλ (React): Vite κΈ°λ° νλ‘μ νΈ μμ± λ° μν CRUD κΈ°λ₯ ꡬν
β’
μ€νμΌλ§: Tailwind CSS v4λ₯Ό νμ©ν UI λμμΈ
β’
λΌμ°ν
: React Routerλ₯Ό μ΄μ©ν νμ΄μ§ λ€λΉκ²μ΄μ
β’
HTTP ν΅μ : axiosλ₯Ό μ΄μ©ν λΉλκΈ° API νΈμΆ
νκ° κΈ°μ€
β’
μ½λμ μ νμ± λ° μμ±λ
β’
μꡬμ¬ν μΆ©μ‘± μ¬λΆ
β’
μ½λ κ°λ
μ± λ° κ΅¬μ‘°
β’
UI/UX νμ§
μ μΆ λ°©λ²
νλͺ© | νμΌλͺ
| λΉκ³ |
λ°±μλ (Spring Boot) | νκΈΈλ_product-server.zip | λλ GitHub Repository URL |
νλ‘ νΈμλ (React) | νκΈΈλ_product-client.zip | node_modules ν΄λ μ μΈ ν μμΆ λλ GitHub Repository URL |
λ¬Έμ 1 - νλ‘μ νΈ μμ± λ° λΌμ΄λΈλ¬λ¦¬ μ€μΉ λͺ λ Ήμ΄ μμ±
β’
λμ΄λ : β
ββββ (ν)
β’
λ°°μ : 20μ
μꡬμ¬ν
μλ λͺ
λ Ήμ΄λ₯Ό μμ±νμμ€.
1.
Vite κΈ°λ°μΌλ‘ React νλ‘μ νΈλ₯Ό μμ±νλ λͺ
λ Ήμ΄λ₯Ό μμ±νμμ€.
β’
νλ‘μ νΈλͺ
: product-client
β’
ν
νλ¦Ώ : react
2.
npmμ μ΄μ©νμ¬ μλ λΌμ΄λΈλ¬λ¦¬λ₯Ό κ°κ° μ€μΉνλ λͺ
λ Ήμ΄λ₯Ό μμ±νμμ€.
β’
axios
β’
react-router-dom
3.
npmμ μ΄μ©νμ¬ Tailwind CSS v4μ Vite νλ¬κ·ΈμΈμ μ€μΉνλ λͺ
λ Ήμ΄λ₯Ό μμ±νμμ€.
β’
tailwindcss, @tailwindcss/vite
4.
vite.config.jsμ Tailwind νλ¬κ·ΈμΈμ μΆκ°νμμ€.
5.
src/index.cssμ κΈ°μ‘΄ λ΄μ©μ λͺ¨λ μ§μ°κ³ Tailwind v4 import ν μ€λ‘ κ΅μ²΄νμμ€.
ννΈ
# Vite + React νλ‘μ νΈ μμ±
npm create vite@latest [νλ‘μ νΈλͺ
] -- --template react
# μμ±λ ν΄λλ‘ μ΄λ ν κΈ°λ³Έ ν¨ν€μ§ μ€μΉ
cd [νλ‘μ νΈλͺ
]
npm install
# λΌμ΄λΈλ¬λ¦¬ μ€μΉ
npm install [λΌμ΄λΈλ¬λ¦¬λͺ
]
# Tailwind v4 μ€μΉ
npm install tailwindcss @tailwindcss/vite
Bash
볡μ¬
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' // β μΆκ°
export default defineConfig({
plugins: [
react(),
tailwindcss(), // β μΆκ°
],
})
JavaScript
볡μ¬
/* src/index.css β κΈ°μ‘΄ λ΄μ© λͺ¨λ μμ ν μλ ν μ€λ§ μμ± */
@import "tailwindcss";
CSS
볡μ¬
λ¬Έμ 2 - Spring Boot λ°±μλ ꡬν
β’
λμ΄λ : β
β
β
ββ (μ€)
β’
λ°°μ : 25μ
μꡬμ¬ν
μλ μν(Product) λ°μ΄ν°λ₯Ό κ΄λ¦¬νλ REST API μλ²λ₯Ό μμ±νμμ€.
μν(Product) μμ±
νλλͺ
| νμ
| μ€λͺ
|
id | String | κΈ°λ³Έν€ (UUID) |
name | String | μνλͺ
|
price | int | κ°κ²© |
stock | int | μ¬κ³ μλ |
createdAt | LocalDateTime | λ±λ‘μΌμ |
ꡬν λͺ©λ‘
1.
Product Domain ν΄λμ€ μμ± (Lombok @Data, @NoArgsConstructor, @AllArgsConstructor μ¬μ©)
β’
idλ String νμ
μΌλ‘ μ μΈνκ³ , λ±λ‘ μ μλ²μμ UUIDλ₯Ό μ§μ μμ±νμ¬ μΈν
νλ€.
2.
ProductMapper μΈν°νμ΄μ€ μμ± (@Mapper μ΄λ
Έν
μ΄μ
μ¬μ©)
3.
ProductMapper.xml μμ± β μλ SQLμ λͺ¨λ ꡬννλ€.
SQL ID | κΈ°λ₯ |
selectAll | μ 체 μν μ‘°ν |
selectOne | νΉμ μν μ‘°ν |
insert | μν λ±λ‘ (λ±λ‘μΌμ μλ μ
λ ₯) |
update | μν μμ |
delete | μν μμ |
1.
ProductController μμ± β μλ REST API μλν¬μΈνΈλ₯Ό λͺ¨λ ꡬννλ€.
Method | URL | κΈ°λ₯ |
GET | /api/products | μ 체 μν μ‘°ν |
GET | /api/products/{id} | νΉμ μν μ‘°ν |
POST | /api/products | μν λ±λ‘ |
PUT | /api/products/{id} | μν μμ |
DELETE | /api/products/{id} | μν μμ |
1.
React(ν¬νΈ 5173)μμ νΈμΆν μ μλλ‘ CORS μ€μ μ μΆκ°νμμ€.
ννΈ
// com.aloha.product.domain.Product
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product {
private String id; // UUID (ex. "550e8400-e29b-41d4-a716-446655440000")
private String name;
private int price;
private int stock;
private LocalDateTime createdAt;
}
Java
볡μ¬
// com.aloha.product.mapper.ProductMapper
@Mapper
public interface ProductMapper {
List<Product> selectAll();
Product selectOne(String id);
int insert(Product product);
int update(Product product);
int delete(String id);
}
Java
볡μ¬
<!-- resources/mapper/ProductMapper.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"<http://mybatis.org/dtd/mybatis-3-mapper.dtd>">
<mapper namespace="com.aloha.product.mapper.ProductMapper">
<!-- μ 체 μ‘°ν -->
<select id="selectAll" resultType="com.aloha.product.domain.Product">
SELECT * FROM product ORDER BY created_at DESC
</select>
<!-- λ¨κ±΄ μ‘°ν -->
<select id="selectOne" parameterType="String" resultType="com.aloha.product.domain.Product">
SELECT * FROM product WHERE id = #{id}
</select>
<!-- λ±λ‘ β UUIDλ Controllerμμ μμ± ν product κ°μ²΄μ μΈν
νμ¬ μ λ¬ -->
<insert id="insert" parameterType="com.aloha.product.domain.Product">
INSERT INTO product (id, name, price, stock, created_at)
VALUES (#{id}, #{name}, #{price}, #{stock}, NOW())
</insert>
<!-- μμ -->
<update id="update" parameterType="com.aloha.product.domain.Product">
UPDATE product
SET name = #{name}, price = #{price}, stock = #{stock}
WHERE id = #{id}
</update>
<!-- μμ -->
<delete id="delete" parameterType="String">
DELETE FROM product WHERE id = #{id}
</delete>
</mapper>
XML
볡μ¬
// Controller 기본 ꡬ쑰
@RestController
@RequestMapping("/api/products")
@CrossOrigin(origins = "<http://localhost>:_____") // React(Vite) ν¬νΈ λ²νΈ μ±μ°κΈ°
public class ProductController {
@Autowired
private ProductMapper productMapper;
// GET μ 체 μ‘°ν
@GetMapping
public List<Product> getAll() {
return productMapper.________();
}
// GET λ¨κ±΄ μ‘°ν
@GetMapping("/{id}")
public Product getOne(@PathVariable String id) {
return productMapper.________(id);
}
// POST λ±λ‘ β UUIDλ₯Ό μ§μ μμ±νμ¬ idμ μΈν
@PostMapping
public Product create(@RequestBody Product product) {
product.setId(UUID.randomUUID().toString()); // UUID μμ±
productMapper.________(product);
return product;
}
// PUT μμ
@PutMapping("/{id}")
public Product update(@PathVariable String id, @RequestBody Product product) {
product.setId(id);
productMapper.________(product);
return productMapper.________(id);
}
// DELETE μμ
@DeleteMapping("/{id}")
public void delete(@PathVariable String id) {
productMapper.________(id);
}
}
Java
볡μ¬
# application.properties β DB μ°κ²° λ° MyBatis μ€μ
spring.datasource.url=jdbc:mysql://localhost:3306/[DBλͺ
]?useSSL=false&serverTimezone=Asia/Seoul
spring.datasource.username=root
spring.datasource.password=1234
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# MyBatis mapper xml μμΉ
mybatis.mapper-locations=classpath:mapper/*.xml
# μΉ΄λ©μΌμ΄μ€ μλ λ³ν (created_at β createdAt)
mybatis.configuration.map-underscore-to-camel-case=true
Plain Text
볡μ¬
CREATE TABLE product (
id VARCHAR(36) PRIMARY KEY, -- UUID λ¬Έμμ΄ (36μ)
name VARCHAR(100) NOT NULL,
price INT NOT NULL,
stock INT NOT NULL,
created_at DATETIME
);
SQL
볡μ¬
UUID.randomUUID().toString()μ "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" νμμ 36μ리 λ¬Έμμ΄μ λ°νν©λλ€.
λ¬Έμ 3 - React μ»΄ν¬λνΈ κ΅¬μ‘° λ° λΌμ°ν μ€μ
β’
λμ΄λ : β
β
βββ (μ€ν)
β’
λ°°μ : 20μ
μꡬμ¬ν
1.
src/pages/ ν΄λ μμ μλ μ»΄ν¬λνΈ νμΌμ μμ±νμμ€.
νμΌλͺ
| μν |
ProductList.jsx | μν λͺ©λ‘ νμ΄μ§ |
ProductDetail.jsx | μν μμΈ νμ΄μ§ |
ProductForm.jsx | μν λ±λ‘ / μμ νΌ νμ΄μ§ |
1.
src/components/ ν΄λ μμ Navbar.jsxλ₯Ό μμ±νκ³ μλ λ©λ΄λ₯Ό ꡬμ±νμμ€.
β’
"μν λͺ©λ‘" β /
β’
"μν λ±λ‘" β /products/new
2.
App.jsμμ react-router-domμ μ΄μ©νμ¬ μλ λΌμ°ν
μ μ€μ νμμ€.
κ²½λ‘ | λ λλ§ μ»΄ν¬λνΈ |
/ | ProductList |
/products/new | ProductForm (λ±λ‘ λͺ¨λ) |
/products/:id | ProductDetail |
/products/:id/edit | ProductForm (μμ λͺ¨λ) |
ννΈ
// App.js β λΌμ°ν
기본 ꡬ쑰
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Navbar from './components/Navbar';
import ProductList from './pages/ProductList';
// ... λλ¨Έμ§ import μΆκ°
function App() {
return (
<BrowserRouter>
<Navbar />
<Routes>
<Route path="/" element={<ProductList />} />
{/* λλ¨Έμ§ Route μΆκ° */}
</Routes>
</BrowserRouter>
);
}
export default App;
JavaScript
볡μ¬
// Navbar.jsx β Link μ»΄ν¬λνΈ μ¬μ©
import { Link } from 'react-router-dom';
function Navbar() {
return (
<nav>
<Link to="/">μν λͺ©λ‘</Link>
<Link to="/products/new">μν λ±λ‘</Link>
</nav>
);
}
JavaScript
볡μ¬
λ¬Έμ 4 - axiosλ₯Ό μ΄μ©ν API μ°λ λ° CRUD ꡬν
β’
λμ΄λ : β
β
β
β
β (μ)
β’
λ°°μ : 25μ
μꡬμ¬ν
(1) src/api/productApi.js νμΌ μμ±
axios μΈμ€ν΄μ€λ₯Ό μμ±νκ³ , μλ API ν¨μλ€μ μμ±νμμ€.
ν¨μλͺ
| HTTP λ©μλ | μ€λͺ
|
getProducts() | GET | μ 체 μν λͺ©λ‘ μ‘°ν |
getProduct(id) | GET | νΉμ μν μ‘°ν |
createProduct(data) | POST | μν λ±λ‘ |
updateProduct(id, data) | PUT | μν μμ |
deleteProduct(id) | DELETE | μν μμ |
(2) ProductList.jsx ꡬν
β’
μ»΄ν¬λνΈκ° μ²μ λ λλ§λ λ μ 체 μν λͺ©λ‘μ λΆλ¬μ νλ©΄μ μΆλ ₯νλ€.
β’
κ° μν ν(row)μ "μμΈλ³΄κΈ°" λ²νΌκ³Ό "μμ " λ²νΌμ νμνλ€.
β’
"μμ " λ²νΌ ν΄λ¦ μ β confirm() νμΈμ°½ ν μμ β λͺ©λ‘ μλ κ°±μ
(3) ProductDetail.jsx ꡬν
β’
URLμ :id νλΌλ―Έν°λ₯Ό μ½μ΄ ν΄λΉ μν μ 보λ₯Ό μ‘°ννμ¬ νμνλ€.
β’
"μμ " λ²νΌ ν΄λ¦ μ /products/:id/edit νμ΄μ§λ‘ μ΄λνλ€.
β’
"λͺ©λ‘μΌλ‘" λ²νΌ ν΄λ¦ μ / νμ΄μ§λ‘ μ΄λνλ€.
(4) ProductForm.jsx ꡬν
β’
λ±λ‘ λͺ¨λ(/products/new) : λΉ νΌμΌλ‘ μμ, μ μΆ μ createProduct() νΈμΆ
β’
μμ λͺ¨λ(/products/:id/edit) : κΈ°μ‘΄ λ°μ΄ν°λ₯Ό λΆλ¬μ νΌμ μ±μ΄ ν, μ μΆ μ updateProduct() νΈμΆ
β’
λ±λ‘/μμ μλ£ ν β λͺ©λ‘ νμ΄μ§(/)λ‘ μ΄λ
ννΈ
// productApi.js β axios μΈμ€ν΄μ€ λ° ν¨μ μμ
import axios from 'axios';
const api = axios.create({
baseURL: '<http://localhost:8080/api>'
});
export const getProducts = () => api.get('/products');
export const getProduct = (id) => api.get(`/products/${id}`);
export const createProduct = (data) => api.______('/products', data);
export const updateProduct = (id, data) => api.______(`/products/${id}`, data);
export const deleteProduct = (id) => api.______(`/products/${id}`);
JavaScript
볡μ¬
// ProductList.jsx β useEffect, useState μ¬μ© μμ
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getProducts, deleteProduct } from '../api/productApi';
function ProductList() {
const [products, setProducts] = useState([]);
const navigate = useNavigate();
// λͺ©λ‘ λΆλ¬μ€κΈ°
const fetchProducts = () => {
getProducts().then(res => setProducts(res.data));
};
useEffect(() => {
fetchProducts(); // μ»΄ν¬λνΈ λ§μ΄νΈ μ 1ν μ€ν
}, []);
// μμ μ²λ¦¬
const handleDelete = (id) => {
if (window.confirm('μ λ§ μμ νμκ² μ΅λκΉ?')) {
deleteProduct(id).then(() => fetchProducts()); // μμ ν λͺ©λ‘ κ°±μ
}
};
return (
<div>
{products.map(product => (
<div key={product.id}>
<span>{product.name}</span>
<button onClick={() => navigate(`/products/${product.id}`)}>μμΈλ³΄κΈ°</button>
<button onClick={() => handleDelete(product.id)}>μμ </button>
</div>
))}
</div>
);
}
JavaScript
볡μ¬
// ProductForm.jsx β λ±λ‘/μμ λͺ¨λ λΆκΈ° μ²λ¦¬ μμ
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { getProduct, createProduct, updateProduct } from '../api/productApi';
function ProductForm() {
const { id } = useParams(); // μμ λͺ¨λμ΄λ©΄ id μ‘΄μ¬, λ±λ‘ λͺ¨λμ΄λ©΄ undefined
const navigate = useNavigate();
const isEdit = !!id; // trueλ©΄ μμ λͺ¨λ
const [form, setForm] = useState({ name: '', price: '', stock: '' });
useEffect(() => {
if (isEdit) {
// μμ λͺ¨λ: κΈ°μ‘΄ λ°μ΄ν° λΆλ¬μ€κΈ°
getProduct(id).then(res => setForm(res.data));
}
}, [id]);
const handleChange = (e) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = (e) => {
e.preventDefault();
if (isEdit) {
updateProduct(id, form).then(() => navigate('/'));
} else {
createProduct(form).then(() => navigate('/'));
}
};
return (
<form onSubmit={handleSubmit}>
<input name="name" value={form.name} onChange={handleChange} placeholder="μνλͺ
" />
<input name="price" value={form.price} onChange={handleChange} placeholder="κ°κ²©" />
<input name="stock" value={form.stock} onChange={handleChange} placeholder="μ¬κ³ " />
<button type="submit">{isEdit ? 'μμ ' : 'λ±λ‘'}</button>
</form>
);
}
JavaScript
볡μ¬
λ¬Έμ 5 - Tailwind CSS μ μ© λ° μ ν¨μ± κ²μ¬
β’
λμ΄λ : β
β
β
ββ (μ€)
β’
λ°°μ : 10μ
μꡬμ¬ν
(1) Tailwind CSS v4 μ€μ μμ±
vite.config.jsμ @tailwindcss/vite νλ¬κ·ΈμΈμ λ±λ‘νμμ€.
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' // β μΆκ°
export default defineConfig({
plugins: [
react(),
tailwindcss(), // β μΆκ°
],
})
JavaScript
볡μ¬
src/index.cssμ κΈ°μ‘΄ λ΄μ©μ λͺ¨λ μ§μ°κ³ μλ ν μ€λ§ μμ±νμμ€.
/* src/index.css */
@import "tailwindcss";
CSS
볡μ¬
(2) UI μ€νμΌ μ μ© (Tailwind ν΄λμ€ νμ©)
β’
μν λͺ©λ‘μ ν
μ΄λΈ λλ μΉ΄λ(grid) ννλ‘ νμνλ€.
β’
λ²νΌμ λ°°κ²½μκ³Ό hover: ν¨κ³Όλ₯Ό μ μ©νλ€.
β’
νΌ μ
λ ₯ νλμ ν
λ리μ focus: μ€νμΌμ μ μ©νλ€.
(3) μ λ ₯ μ ν¨μ± κ²μ¬ μΆκ° (ProductForm.jsx)
handleSubmit ν¨μ λ΄λΆ μ μΆ μ μ μλ 쑰건μ κ²μ¬νμμ€.
νλ | 쑰건 |
μνλͺ
(name) | λΉ λ¬Έμμ΄ λΆκ° |
κ°κ²©(price) | 0 μ΄μμ μ«μ |
μ¬κ³ (stock) | 0 μ΄μμ μ μ |
ννΈ
// μ ν¨μ± κ²μ¬ μμ
const handleSubmit = (e) => {
e.preventDefault();
if (!form.name.trim()) {
alert('μνλͺ
μ μ
λ ₯νμΈμ.');
return;
}
if (form.price === '' || Number(form.price) < 0) {
alert('κ°κ²©μ 0 μ΄μμ μ«μλ₯Ό μ
λ ₯νμΈμ.');
return;
}
if (form.stock === '' || Number(form.stock) < 0) {
alert('μ¬κ³ λ 0 μ΄μμ μ«μλ₯Ό μ
λ ₯νμΈμ.');
return;
}
// κ²μ¬ ν΅κ³Ό ν API νΈμΆ
if (isEdit) { ... } else { ... }
};
JavaScript
볡μ¬
// Tailwind μ€νμΌ μμ
<button
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-700"
>
λ±λ‘
</button>
<input
className="border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
JavaScript
볡μ¬
μ±μ κΈ°μ€ μμ½
λ¬Έμ | λ΄μ© | λ°°μ |
λ¬Έμ 1 | νλ‘μ νΈ μμ± λ° λΌμ΄λΈλ¬λ¦¬ μ€μΉ λͺ
λ Ήμ΄ | 20μ |
λ¬Έμ 2 | Spring Boot REST API (Entity / Repository / Controller / CORS) | 25μ |
λ¬Έμ 3 | React μ»΄ν¬λνΈ κ΅¬μ‘° λ° λΌμ°ν
μ€μ | 20μ |
λ¬Έμ 4 | axios API μ°λ λ° CRUD κΈ°λ₯ ꡬν | 25μ |
λ¬Έμ 5 | Tailwind CSS μ μ© λ° μ
λ ₯ μ ν¨μ± κ²μ¬ | 10μ |
ν©κ³ | 100μ |
μ°Έκ³ β μμ μκ° μ§ν νλ‘μ νΈ (μ°Έμ‘°μ©, λ³΅μ¬ μ μΆ λΆκ°)
νλ‘μ νΈ | GitHub URL |
κ²μν | |
TodoList | |
λ‘κ·ΈμΈ | |
Tailwind |


