본문 바로가기
STUDY/Project

게시판에 반복되는 Tailwind 공통 UI 클래스 분리하기

by Y.Choi 2025. 5. 16.
728x90
반응형

 

 

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 조각을 작은 단위로 컴포넌트화 시키는 것이다.

 

728x90
반응형