😱
[HOBBi] 게시글 작성 페이지 구현
2025.07.22
게시글 작성
게시글 작성 부분도 dnd-kit, 이미지, 취미태그, 제목, 내용 별로 취미 피드를 작성 하는 곳인데, 취미태그에 대한 데이터 가공 하는 법과 dnd-kit 부분이 어렵게 느껴져서 기록해보았습니다.
PC
![]() |
![]() |
모바일
![]() |
![]() |
사용자가 새로운 게시글을 작성할 수 있는 페이지
주요 기능
- 게시글 제목 입력
- 취미 태그 선택(최대 5개)
- 이미지 업로드(최대 5장)
- 게시글 내용 작성
- 게시글 작성 데이터 검증
- 게시글 작성 서버로 데이터 전송
- 성공/실패 시 적절한 피드백 제공
흐름
- 페이지 진입 시 취미 태그 선택 상태 초기화
- PostForm 컴포넌트에서 사용자 입력 수집
- 폼 제출 시 FormData로 서버로 전송
- 성공 시 작성된 게시글 상세 페이지로 이동
- 실패 시 에러 메시지 모달 표시
const router = useRouter();
- 게시글 작성 완료 후 상세 페이지 이동에 사용
const { openModal } = useModalStore();
- 성공/실패 메시지 표시에 사용
const resetSelections = useHobbyStore((state) => state.resetSelections);
- 페이지 진입 시 및 작성 완료 후 취미 태그 선택 상태 초기화
useEffect(() => {
resetSelections();
}, [resetSelections]);
- 페이지 진입 시 취미 태그 선택 상태 초기화
- 목적
- 이전에 선택된 취미 태그들이 남아있지 않도록 초기화
- 사용자가 깨끗한 상태에서 게시글 작성 시작
/*
* @param formData - 게시글 작성에 필요한 모든 데이터 (제목, 내용, 이미지, 취미 태그)
*/
const handleSubmit = async (formData: FormData) => {
try {
// ===== 게시글 작성 요청 =====
const { postId } = await postService.writePost(formData);
// ===== 성공 처리 =====
// 게시글 작성 완료 후에만 취미 태그 초기화
// (페이지 진입 시와 중복되지 않도록)
resetSelections();
// 성공 모달 표시 및 상세 페이지 이동
openModal({
title: '게시글이 등록되었습니다.',
message: '상세페이지로 이동합니다.',
confirmText: '확인',
onConfirm: () => {
// 모달 확인 버튼 클릭 시 작성된 게시글 상세 페이지로 이동
router.push(`/posts/${postId}`);
},
});
} catch (error: any) {
// ===== 에러 처리 =====
console.error('게시글 작성 실패:', error);
// 기본 에러 메시지
let errorMessage = '게시글 작성에 실패했습니다.';
// ===== 에러 타입별 메시지 분기 처리 =====
if (error.code === 'EXCEED_FILE_SIZE_LIMIT') {
// 파일 크기 초과 에러
errorMessage = '이미지 파일의 크기가 너무 큽니다. 50MB 이하의 파일을 업로드해주세요.';
} else if (error.message) {
// 서버에서 전달된 에러 메시지가 있는 경우
errorMessage = error.message;
}
// 에러 모달 표시
openModal({
title: '게시글 작성 실패',
message: errorMessage,
confirmText: '확인',
});
}
};
- 게시글 작성 폼 제출 핸들러
- PostForm 컴포넌트에서 전달받은 FormData를 처리
- 처리 과정
- 서버에 게시글 작성 요청
- 성공 시 취미 태그 초기화 및 성공 모달 표시
- 실패 시 에러 타입에 따른 적절한 메시지 표시
/*
* @param formData - 게시글 데이터 (이미지, 텍스트, 태그 등)
* @returns Promise<PostResponse> - 작성된 게시글 정보
*/
writePost: async (formData: FormData): Promise<PostResponse> => {
const options: RequestInit = {
method: 'POST',
body: formData, // FormData는 Content-Type을 자동으로 설정
};
return fetchApi('/post', options);
},
- 게시글 작성
- 새로운 게시글을 작성합니다. 이미지 파일과 텍스트 내용을 FormData로 전송
⭐️⭐️Post Form(취미 데이터 가공 하는 부분이 어려웠음)⭐️⭐️
게시글을 새로 작성하거나 기존 게시글을 수정할 수 있는 통합 폼 컴포넌트입니다.
주요 기능
- 게시글 제목 입력(최대 30자)
- 취미 태그 선택(최대 5개)
- 이미지 업로드(최대 5장, 드래그 앤 드롭 지원)
- 게시글 내용 입력(최소 10자, 최대 2000자)
- 실시간 유효성 검사
- 폼 제출 및 에러 처리
- 애니메이션 효과
사용 모드
- 작성 모드 :
initialData
가 없는 경우 - 수정 모드
initialData
가 있는 경우(기존 데이터로 폼 초기화)
데이터 흐름
- 사용자 입력 수집(제목, 내용, 태그, 이미지)
- 실시간 유효성 검사
- 폼 제출 시 FormData 생성
- 부모 컴포넌트의 onSubmit 함수 호출
/*
* @param initialData - 수정할 게시글 데이터 (수정 모드에서만 사용)
* @param onSubmit - 게시글 제출 함수 (FormData를 받아서 처리)
* @param submitButtonText - 게시글 제출 버튼 텍스트 (기본값: '게시하기')
*/
interface PostFormProps {
initialData?: PostDetail;
onSubmit: (formData: FormData) => Promise<void>;
submitButtonText?: string;
}
const PostFormSchema = z.object({
title: z.string().min(1, '제목을 입력해주세요.').max(30, '제목은 30자 이하로 입력해주세요.'),
content: z.string().min(10, '내용은 최소 10자 이상 입력해주세요.').max(2000, '내용은 2,000자 이하로 입력해주세요.'),
hobbyTags: z.array(z.string()).min(1, '태그를 선택해주세요.').max(5, '태그는 최대 5개까지 선택할 수 있습니다.'),
});
- title : 1-30자 필수
- content : 10-2000자 필수
- hobbyTags : 1-5개 필수
type PostFormError = Partial<Record<'title' | 'content' | 'hobbyTags', string>>;
- 폼 에러 타입 정의
const { selectedHobbyTags, setSelectedHobbyTags } = useHobbyStore();
- 취미 스토어에서 선택된 태그와 설정 함수 가져오기
- 전역 상태로 관리되어 다른 컴포넌트와 공유됨
const [title, setTitle] = useState(initialData?.title || '');
- 게시글 제목 상태
- 수정 모드일 경우 기존 제목으로 초기화
const [content, setContent] = useState(initialData?.content || '');
- 게시글 내용 상태
- 수정 모드일 경우 기존 내용으로 초기화
const [images, setImages] = useState<ImageFile[]>(
initialData?.postImageUrls.map((url) => ({
file: null, // 기존 이미지는 file이 null
preview: url, // 기존 이미지 URL을 preview로 사용
})) || [],
);
- 이미지 파일 배열 상태
- 수정 모드일 경우 기존 이미지 URL을 preview로 설정
- 새로 업로드된 이미지는 file과 preview 모두 설정
const [deletedImageUrls, setDeletedImageUrls] = useState<string[]>([]);
- 삭제된 이미지 URL 목록
- 수정 모드에서 기존 이미지를 삭제할 때 서버에 전송할 URL 목록
const [formError, setFormError] = useState<PostFormError>({});
- 폼 유효성 검사 에러 상태
useEffect(() => {
// initialData가 있고 취미 태그가 있는 경우에만 실행
if (initialData?.postHobbyTags) {
// 서버에서 받은 태그 배열을 HobbyTag 형식으로 변환
const convertedTags = initialData.postHobbyTags
.map((subCategory) => {
/**
* HOBBY_SUB_CATEGORIES 객체에서 현재 subCategory가 속한 mainCategory 찾기
*
* HOBBY_SUB_CATEGORIES 구조 예시:
* {
* 'SPORTS': ['축구', '농구', '야구'],
* 'ART': ['그림', '음악', '댄스'],
* ...
* }
*
* Object.entries() 사용하여 [키, 값] 쌍의 배열로 변환 후
* find()로 subCategory를 포함하는 카테고리 찾기
*/
const mainCategoryEntry = Object.entries(HOBBY_SUB_CATEGORIES).find(([, subCategories]) =>
subCategories.includes(subCategory),
);
// 메인 카테고리를 찾지 못한 경우 null 반환
if (!mainCategoryEntry) {
return null;
}
/**
* mainCategoryEntry에서 메인 카테고리 키 추출
* 예: ['SPORTS', ['축구', '농구', '야구']] => 'SPORTS'
*/
const [mainCategoryKey] = mainCategoryEntry;
/**
* HOBBY_MAIN_CATEGORIES에서 실제 표시될 메인 카테고리 이름 가져오기
*
* HOBBY_MAIN_CATEGORIES 구조 예시:
* {
* 'SPORTS': '스포츠',
* 'ART': '예술',
* ...
* }
*/
const mainCategory = HOBBY_MAIN_CATEGORIES[mainCategoryKey as keyof typeof HOBBY_MAIN_CATEGORIES];
// HobbyTag 객체 형식으로 반환
return {
mainCategory, // 예: '스포츠'
subCategory, // 예: '축구'
} as HobbyTag;
})
// null 값 필터링 및 타입 가드 적용
.filter((tag): tag is HobbyTag => tag !== null);
// 변환된 태그 배열을 상태로 설정
setSelectedHobbyTags(convertedTags);
}
}, [initialData?.postHobbyTags, setSelectedHobbyTags]);
- 게시글 수정 시 기존 취미 태그 데이터 처리
- 목적
- 초기 데이터(
initialData
)의 취미 태그를 컴포넌트의 상태로 변환 - 서버 데이터 형식을 클라이언트 상태 형식으로 변환
- 초기 데이터(
- 데이터 변환 과정
- 서버에서 받은 태그 배열 :
[’축구’, ‘농구’, ‘야구’]
- HOBBY_SUB_CATEGORIES에서 각 태그의 메인 카테고리 찾기
- 클라이언트 상태 형식으로 변환
[{ mainCategory: ‘스포츠’, subCategory: ‘축구’ }, …]
- 서버에서 받은 태그 배열 :
- 서버 데이터 :
[’축구’, ‘농구’, ‘야구’]
- 변환 결과 :
[ { mainCategory: '스포츠', subCategory: '축구' }, { mainCategory: '스포츠', subCategory: '농구' }, { mainCategory: '스포츠', subCategory: '야구' }, ];
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
const result = PostFormSchema.shape.title.safeParse(e.target.value);
setFormError((prev) => ({
...prev,
title: result.success ? undefined : result.error.errors[0].message,
}));
}, []);
- 제목 입력 변경 핸들러
const handleContentChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
const result = PostFormSchema.shape.content.safeParse(e.target.value);
setFormError((prev) => ({
...prev,
content: result.success ? undefined : result.error.errors[0].message,
}));
}, []);
- 내용 입력 변경 핸들러
useEffect(() => {
const result = PostFormSchema.shape.hobbyTags.safeParse(selectedHobbyTags.map((tag) => tag.subCategory));
setFormError((prev) => ({
...prev,
hobbyTags: result.success ? undefined : result.error.errors[0].message,
}));
}, [selectedHobbyTags]);
- 취미 태그 변경 시 유효성 검사
- selectedHobbyTags 가 변경될 때마다 실행되어 태그 개수에 대한 유효성을 검사합니다.
const handleImageUpload = useCallback((files: File[]) => {
const newImages = files.map((file) => ({
file,
preview: URL.createObjectURL(file), // 브라우저에서 미리보기 URL 생성
}));
setImages((prev) => [...prev, ...newImages]);
}, []);
- 이미지 업로드 핸들러
- 기능
- 선택된 파일들을 ImageFile 형식으로 변환
- 각 파일에 대해 미리보기 URL 생성
- 기존 이미지 배열에 새 이미지들 추가
const handleImageRemove = useCallback((index: number) => {
setImages((prev) => {
const newImages = [...prev]; // 기존 이미지 배열 복사
const removedImage = newImages[index]; // 삭제할 이미지 추출
// 기존 이미지 URL이 있다면 삭제 목록에 추가
// (수정 모드에서 기존 이미지를 삭제한 경우)
if (!removedImage.file && removedImage.preview) {
setDeletedImageUrls((prev) => [...prev, removedImage.preview]);
}
// 새로 업로드된 이미지인 경우 브라우저 캐시에서 제거
if (newImages[index].file) {
URL.revokeObjectURL(newImages[index].preview);
}
newImages.splice(index, 1);
return newImages;
});
}, []);
- 이미지 삭제 핸들러
- 기능
- 지정된 인덱스의 이미지 제거
- 기존 이미지 URL인 경우 삭제 목록에 추가
- 새로 업로드된 이미지인 경우 브라우저 캐시에서 제거
const createFormData = () => {
const formData = new FormData();
// 게시글 메타데이터 (JSON 형태로 직렬화)
const postData = {
title,
content,
hobbyTagNames: selectedHobbyTags.map((tag) => tag.subCategory),
deletedImageUrls, // 삭제된 이미지 URL 목록 추가
};
// JSON을 Blob으로 변환하여 FormData에 추가
const requestBlob = new Blob([JSON.stringify(postData)], {
type: 'application/json',
});
formData.append('request', requestBlob);
// 새로 업로드된 이미지 파일들을 FormData에 추가
images.forEach((image) => {
if (image.file) {
formData.append('imageFiles', image.file);
}
});
return formData;
};
- 서버 전송용 FormData 생성
- 기능
- 게시글 메타데이터를 JSON으로 직렬화
- 이미지 파일들을 FormData에 추가
- 삭제된 이미지 URL 목록 초함
const handleSubmit = useCallback(async () => {
// 전체 폼 데이터 유효성 검사
const result = PostFormSchema.safeParse({
title,
content,
hobbyTags: selectedHobbyTags.map((tag) => tag.subCategory),
});
if (!result.success) {
// 유효성 검사 실패 시 에러 메시지 설정
const fieldErrors: PostFormError = {};
result.error.errors.forEach((err) => {
if (err.path[0]) fieldErrors[err.path[0] as keyof PostFormError] = err.message;
});
setFormError(fieldErrors);
return;
}
try {
// FormData 생성 및 제출
const formData = createFormData();
await onSubmit(formData);
} catch (error) {
console.error('게시글 처리 실패:', error);
alert(error instanceof Error ? error.message : '게시글 처리에 실패했습니다.');
}
}, [title, content, selectedHobbyTags, images, deletedImageUrls, onSubmit]);
- 게시글 제출 핸들러
- 처리 과정
- 전체 폼 데이터 유효성 검사
- 유효성 검사 실패 시 에러 메시지 표시
- 유효성 검사 성공시 FormData 생성
- 부모 컴포넌트의 onSubmit 함수 호출
- 에러 발생 시 사용자에게 알림
Post Image Uploader
게시글 작성 시 이미지를 업로드하고 관리하는 종합적인 업로더 컴포넌트 입니다.
주요 기능
- 최대 5장까지 이미지 업로드(개수 제한)
- 이미지 미리보기 표시(실시간)
- 개별 이미지 삭제 버튼
- 이미지 순서 드래그 앤 드롭 변경(dnd-kit 라이브러리 활용)
- 다양한 이미지 형식 지원 **(JPEG, PNG, GIF, BMP, WebP, HEIC)
사용자 경험
- 직관적인 드래그 앤 드롭 인터페이스
- 실시간 이미지 미리보기
- 드래그 중 시각적 피드백
- 업로드 제한 시 업로드 버튼 자동 숨김
/*
* @param images - 현재 업로드된 이미지 파일 배열 (ImageFile 타입)
* @param onImageUpload - 새 이미지 파일 업로드 시 호출되는 콜백 함수
* @param onImageRemove - 특정 이미지 삭제 시 호출되는 콜백 함수
* @param onImageReorder - 이미지 순서 변경 시 호출되는 콜백 함수
*/
interface PostImageUploaderProps {
images: ImageFile[];
onImageUpload: (files: File[]) => void;
onImageRemove: (index: number) => void;
onImageReorder: (newImages: ImageFile[]) => void;
}
- Props 인터페이스
const sensors = useSensors(useSensor(PointerSensor));
- dnd-kit 센서 설정
- PointerSensor를 사용하여 마우스와 터치 이벤트를 모두 처리
- 모바일과 데스크톱 환경에서 모두 드래그 앤 드롭이 가능
const handleDragEnd = (event: any) => {
const { active, over } = event;
// ===== 유효성 검사 =====
if (!over || active.id === over.id) return;
// ===== 인덱스 계산 =====
const oldIndex = images.findIndex((_, i) => String(i) === active.id);
const newIndex = images.findIndex((_, i) => String(i) === over.id);
// ===== 순서 변경 처리 =====
if (oldIndex !== -1 && newIndex !== -1) {
const newImages = arrayMove(images, oldIndex, newIndex);
onImageReorder(newImages);
}
};
- 드래그 종료 시 순서 변경 처리
- 드래그가 완료되면 이미지 배열의 순서를 변경하고 상위 컴포넌트에 새로운 순서를 전달합니다.
- 처리 과정
- 드래그된 요소(
active
)와 드롭된 위치(over
)확인 - 유효하지 않은 드롭인 경우 처리 중단
- 기존 인덱스와 새로운 인덱스 계산
- arrayMove 함수로 배열 순서 변경
- 상위 컴포넌트에 새로운 배열 전달
- 드래그된 요소(
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
// ===== 업로드 제한 확인 =====
const remainingSlots = 5 - images.length;
const filesToAdd = Array.from(files).slice(0, remainingSlots);
onImageUpload(filesToAdd);
};
- 이미지 파일 선택 처리
- 처리 과정
- 선택된 파일들 확인
- 남은 슬롯 수 계산(최대 5장 제한)
- 제한에 맞게 파일 수 조정
- 상위 컴포넌트에 파일 전달
Sortable Image
드래그 가능한 이미지 컴포넌트
dnd-kit 의 useSortable
훅을 사용하여 드래그 앤 드롭 기능을 제공
주요 기능
- 드래그 시 시각적 피드백
- 이미지 미리보기 표시
- 삭제 버튼 제공
- 터치/마우스 이벤트 처리
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
dnd-kit에서 제공하는 드래그 관련 속성들
attributes
: 드래그 가능한 요소에 필요한 HTML 속성들listeners
: 마우스/터치 이벤트 리스너들setNodeRef
: DOM 요소 참조 함수transform
: 드래그 중 변환 정보transition
: 애니메이션 전환 정보isDragging
: 현재 드래그 중인지 여부