Tailwind를 쓰다보면 className이 태그안에 길게 늘여 쓰거나 비슷한 페이지에 반복되어 효율적이지도 않고 유지관리에도 쉽지 않다. 여러가지의 이유로 공통 UI 부분을 컴포넌트로 분리하고 이를 가져다 쓰는 방식을 사용하게 된다.
| 클래스 분리 방법
1) 공통 UI 컴포넌트로 분리하기
게시글 박스, 버튼, 타이틀, 날짜 같은 요소들을 반복해서 쓴다면 아래처럼 컴포넌트로 뽑는게 효율적이다.
예) components/PostItem.js
export default function PostItem({ title, author, date }) {
return (
<div className="border p-4 rounded shadow-sm hover:bg-gray-50">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="text-sm text-gray-500">{author} · {date}</p>
</div>
);
}
2) tailwind.config.js 확장 or CSS Modules, clsx 등을 활용해 클래스를 유틸리티화 하기
className이 너무 길다면 유틸을 써서 축약하거나 클래스를 별도 상수로 분리할 수 있다.
예) styles/classNames.js
export const card = 'border p-4 rounded shadow-sm hover:bg-gray-50';
export const title = 'text-lg font-semibold';
import { card, title } from '../styles/classNames';
<div className={card}>
<h2 className={title}>게시글 제목</h2>
</div>
3) 페이지 공통 구조 컴포넌트화하기
게시판 관련 페이지들이 비슷한 구조를 가지면 레이아웃으로 묶을 수도 있다.
예) components/PageWrapper.js
export default function PageWrapper({ title, children }) {
return (
<section className="max-w-3xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">{title}</h1>
{children}
</section>
);
}
그래서 앞으로 게시판의 UI 통일성을 주면서 유지관리가 효율적으로 되도록 계속 다듬어 나갈 것이다.
이번에 할 작업은 클래스네임 파일을 따로 만들어 관리하는 것 부터 해보려한다.
| 공통 클래스 묶음용 utils 파일 만들기
frontend/src/styles/classNames.js
export const cardWrapper = "bg-white rounded-2xl shadow p-6";
export const titleStyle = "text-2xl font-bold mb-4";
export const metaStyle = "text-sm text-gray-500 mb-6";
export const contentStyle = "text-base leading-relaxed whitespace-pre-wrap px-2 py-5";
export const buttonGroup = "flex gap-2 mt-6";
export const blueButton = "px-4 py-2 rounded bg-blue-500 text-white hover:bg-blue-600";
export const deleteButtonStyle = "px-4 py-2 rounded bg-red-500 text-white hover:bg-red-600";
export const grayButton = "px-4 py-2 text-white bg-gray-600 rounded hover:bg-gray-700";
| 게시판 상세 페이지 적용
위에 분리해둔 className.js를 import하고 return 안에 태그들에 길게 늘여썼던 클래스들을 아래와 같이 하니 코드가 훨씬 깔끔해졌다.
아래 코드를 적용하고 실행했을때 이전 작업에서 했던대로 보여진다면 잘 적용 된 것이다.
frontend/src/pages/PostDetailPage.js
import React, { useEffect, useState, useContext } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import axios from 'axios';
import { AuthContext } from '../context/AuthContext';
import {
cardWrapper,
titleStyle,
metaStyle,
contentStyle,
buttonGroup,
blueButton,
deleteButtonStyle,
grayButton,
} from '../styles/classNames';
const PostDetailPage = () => {
const { id } = useParams(); // URL 파라미터에서 post ID 추출
const { user } = useContext(AuthContext);
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const [ message, setMessage ] = useState('');
const navigate = useNavigate();
// 게시글 불러오기
useEffect(() => {
const fetchPost = async () => {
try {
const res = await axios.get(`http://localhost:5000/api/posts/${id}`);
setPost(res.data);
} catch (err) {
console.error('게시글 조회 실패:', err);
} finally {
setLoading(false);
}
};
fetchPost();
}, [id]);
// 2️⃣ 삭제 요청
const handleDelete = async () => {
if (!window.confirm('정말 삭제하시겠습니까?')) return;
try {
const token = localStorage.getItem('token');
await axios.delete(`http://localhost:5000/api/posts/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
navigate('/posts');
} catch (err) {
console.error(err);
setMessage('삭제 실패');
}
};
if (loading) return <p>로딩 중...</p>;
if (!post) return <p>게시글을 찾을 수 없습니다.</p>;
const isAuthor = user && post.author._id === user._id;
return (
<div className="container mx-auto p-4">
<div className={cardWrapper}>
<h2 className={titleStyle}>{post.title}</h2>
<div className={metaStyle}>
{post.author.username || '알 수 없음'} | {new Date(post.createdAt).toLocaleDateString('en-CA')}
</div>
<div className={contentStyle}>{post.content}</div>
<div className={buttonGroup}>
{isAuthor && (
<>
<Link to={`/posts/edit/${post._id}`}>
<button className={blueButton}>✏ 수정</button>
</Link>
<button onClick={handleDelete} className={deleteButtonStyle}>🗑 삭제</button>
</>
)}
<Link to="/posts" >
<button className={grayButton}>📋 목록</button>
</Link>
</div>
{message && <p>{message}</p>}
</div>
</div>
);
};
export default PostDetailPage;
| 게시글 수정 페이지 적용
className.js에 추가로 폼 입력 필드 스타일을 분리해준다.
export const whiteButton = "px-4 py-2 rounded border";
export const formStyle = "flex flex-col gap-4";
export const inputStyle = "w-full p-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-400";
export const textareaStyle = "w-full p-2 h-40 border border-gray-300 rounded resize-none focus:outline-none focus:ring-2 focus:ring-blue-400";
게시글 상세 페이지처럼 className.js를 import하고 className={}을 추가해주면 된다. 아마도 상세 페이지보다 작업이 더 수월할 것이다. 그리고 이전 작업에서 없었던 취소 버튼도 추가했다.
frontend/src/pages/EditPostPage.js
import React, { useState, useEffect, useContext } from 'react';
import axios from 'axios';
import { useParams, useNavigate } from 'react-router-dom';
import { AuthContext } from '../context/AuthContext';
import {
cardWrapper,
titleStyle,
buttonGroup,
blueButton,
inputStyle,
textareaStyle,
whiteButton,
formStyle,
} from '../styles/classNames';
const EditPostPage = () => {
const { id } = useParams(); // 게시글 id 파라미터
const navigate = useNavigate();
const { user } = useContext(AuthContext);
const [post, setPost] = useState({ title: '', content: '' });
const [message, setMessage] = useState('');
// 1️⃣ 기존 게시글 불러오기
useEffect(() => {
const fetchPost = async () => {
try {
const res = await axios.get(`http://localhost:5000/api/posts/${id}`);
if (res.data.author._id !== user._id) {
setMessage('자신의 게시글만 수정할 수 있습니다.');
} else {
setPost({ title: res.data.title, content: res.data.content });
}
} catch (err) {
console.error(err);
setMessage('게시글 정보를 불러오지 못했습니다.');
}
};
fetchPost();
}, [id, user]);
// 2️⃣ 입력값 변경 처리
const handleChange = (e) => {
setPost({ ...post, [e.target.name]: e.target.value });
};
// 3️⃣ 게시글 수정 요청
const handleSubmit = async (e) => {
e.preventDefault();
try {
const token = localStorage.getItem('token');
await axios.put(
`http://localhost:5000/api/posts/${id}`,
post,
{ headers: { Authorization: `Bearer ${token}` } }
);
navigate(`/posts/${id}`);
} catch (err) {
console.error(err);
setMessage('수정 실패');
}
};
return (
<div className="container mx-auto p-4">
<div className={cardWrapper}>
<h2 className={titleStyle}>게시글 수정 ✏️</h2>
<form onSubmit={handleSubmit} className={formStyle}>
<input
className={inputStyle}
type="text"
name="title"
value={post.title}
onChange={handleChange}
placeholder="제목"
required
/>
<textarea
className={textareaStyle}
name="content"
value={post.content}
onChange={handleChange}
placeholder="내용"
required
/>
<div className={buttonGroup}>
<button type="submit" className={blueButton}>저장</button>
<button type="button" onClick={() => navigate(-1)} className={whiteButton}>
취소
</button>
</div>
</form>
{message && <p>{message}</p>}
</div>
</div>
);
};
export default EditPostPage;
| 게시글 작성 페이지 적용
마찬가지로 적용하고 취소 버튼도 추가했다.
frontend/src/pages/CreatePostPage.js
import React, { useState, useContext } from 'react';
import axios from 'axios';
import { AuthContext } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import { blueButton, buttonGroup, cardWrapper, formStyle, inputStyle, textareaStyle, titleStyle } from '../styles/classNames';
const CreatePostPage = () => {
const { user } = useContext(AuthContext);
const navigate = useNavigate();
const [formData, setFormData] = useState({ title: '', content: '' });
const [message, setMessage] = useState('');
const handleChange = (e) => {
setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const token = localStorage.getItem('token');
const res = await axios.post(
'http://localhost:5000/api/posts',
formData,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
setMessage('글이 성공적으로 작성되었습니다.');
console.log('작성된 글: ', res.data)
navigate('/posts');
} catch (err) {
console.error(err);
setMessage('글 작성 실패 ❌');
}
};
if (!user) {
return <p>로그인이 필요한 기능입니다. 🔒</p>;
}
return (
<div className="container mx-auto p-4">
<div className={cardWrapper}>
<h2 className={titleStyle}>게시글 작성 ✍️</h2>
<form onSubmit={handleSubmit} className={formStyle}>
<input
className={inputStyle}
type="text"
name="title"
placeholder="제목"
onChange={handleChange}
required
/>
<textarea
className={textareaStyle}
name="content"
placeholder="내용"
onChange={handleChange}
rows="10"
cols="50"
required
/>
<div className={buttonGroup}>
<button type="submit" className={blueButton}>작성</button>
<button type="button" onClick={() => navigate(-1)} className="px-4 py-2 rounded border">
취소
</button>
</div>
</form>
<p>{message}</p>
</div>
</div>
);
};
export default CreatePostPage;
여기까지 해서 게시판은 완성 되었지만 그래도 뭔가가 더 필요해 보인다.
좀 더 간결하게, 가독성이나 재사용성, 유지보수도 더 수월하게 하는 작업들이 필요하다.
바로 반복되는 UI 조각을 작은 단위로 컴포넌트화 시키는 것이다.
'STUDY > Project' 카테고리의 다른 글
프론트엔드 빌드 도구 Vite로 마이그레이션하기 (0) | 2025.05.26 |
---|---|
React 컴포넌트화(Componentization) (0) | 2025.05.19 |
Tailwind UI 적용하기 (0) | 2025.05.13 |
Header 구성 변경 및 추가 - Sidebar (0) | 2025.05.12 |
페이지 라우팅 설정하기 (0) | 2025.05.11 |