반응형
5. 컴포넌트 분석 (2): AdminStudyCreate.jsx
이 컴포넌트는 새로운 교육 콘텐츠(장르, 주제, 여러 개의 예문, 파일 등)를 한 번에 등록하기 위한 복잡한 폼(Form) 페이지입니다. 여러 종류의 입력(텍스트, 선택, 파일)과 동적으로 추가/삭제되는 필드들을 관리하는 방법을 보여주는 좋은 예시입니다.
5.1. 코드 전문 (주석 포함)
// React 훅들을 가져옵니다.
import { useEffect, useState } from "react";
// React Router와 Redux 훅들을 가져옵니다.
import { useNavigate } from "react-router-dom"
import { useDispatch, useSelector } from "react-redux"
// API와 Redux 액션들을 가져옵니다.
import { audioApi, genreApi, studyApi, examApi } from "../api/adminApi";
import { setGenres, setLoading, setError } from "../store/adminSlice";
export default function AdminStudyCreate(props) {
// [*] 훅 초기화
const navigate = useNavigate();
const dispatch = useDispatch();
// Redux 스토어에서 장르 목록을 가져옵니다. (장르 선택 <select> 박스를 채우기 위해)
const genres = useSelector(state => state.admin.genres);
// [*] 컴포넌트의 지역 상태(Local State) 정의
// 이 페이지는 대부분의 데이터를 Redux가 아닌 자체 상태로 관리합니다.
// 왜냐하면 이 데이터들은 '등록'이 완료되기 전까지는 임시적인 값들이기 때문입니다.
// [상태 1] 장르 관련 상태
const [newGenreName, setNewGenreName] = useState(""); // 새로 생성할 장르의 이름
const [selectedGenreNo, setSelectedGenreNo] = useState(""); // 선택된 기존 장르의 ID
// [상태 2] 주제(Study) 관련 상태
const [studyData, setStudyData] = useState({
themeKo: "", themeJp: "", themeCn: "", themeEn: "", themeEs: "",
commenKo: "", commenJp: "", commenCn: "", commenEn: "", commenEs: "",
});
// [상태 3] 예문(Exam) 관련 상태 (배열로 관리)
// 사용자가 '예문 추가' 버튼을 누를 때마다 이 배열에 새로운 객체가 추가됩니다.
const [examList, setExamList] = useState([
{ // 초기값으로 하나의 빈 예문 객체를 가집니다.
examKo: "", examRoman: "", examJp: "", examCn: "", examEn: "", examEs: "",
imageFile: null, // 단일 이미지 파일
audioFiles: [] // 여러 음성 파일 (객체 배열: [{lang, file}, ...])
}
])
// [*] 컴포넌트 마운트 시 장르 목록 불러오기
useEffect(() => {
fetchGenres();
}, []);
// [1-1] 장르 목록을 서버에서 불러와 Redux 스토어에 저장하는 함수
const fetchGenres = async () => {
try {
const r = await genreApi.getAll();
dispatch(setGenres(r.data)); // 다른 페이지에서도 장르 목록을 쓸 수 있도록 Redux에 저장
} catch (e) { /*...*/ }
};
// [1-2] 새 장르를 생성하는 함수
const handleCreateGenre = async () => {
if (!newGenreName.trim()) { /*...*/ return; }
try {
await genreApi.create({ genreName: newGenreName });
alert("장르가 생성되었습니다.");
setNewGenreName(""); // 입력 필드 초기화
fetchGenres(); // 장르 목록을 새로고침하여 방금 추가한 장르를 <select>에 반영
} catch (e) { /*...*/ }
}
// [2] 주제 입력(input, textarea) 값이 변경될 때 호출되는 핸들러
const handleStudyChange = (field, value) => {
// `setStudyData`에 함수를 전달하는 방식 (updater function)
// 이전 상태(e)를 받아서 새로운 상태 객체를 반환합니다.
setStudyData(e => ({
...e, // ...e : 기존 studyData 객체의 모든 속성을 복사 (불변성 유지)
[field]: value // [field]: 변경된 속성의 키(key)를 동적으로 설정 (예: 'themeKo')하고 새로운 값(value)을 할당
}));
};
// [3] 예문 관련 핸들러들
// [3-1] '예문 추가' 버튼 클릭 시
const handleAddExam = () => {
// 기존 examList 배열에 새로운 빈 예문 객체를 추가하여 상태를 업데이트합니다.
setExamList(e => [...e, { /* 새로운 빈 예문 객체 */ }]);
};
// [3-2] '삭제' 버튼 클릭 시
const handleRemoveExam = (index) => {
// `filter`를 사용하여 특정 인덱스(index)의 항목을 제외한 새로운 배열을 만듭니다.
// `_`는 현재 항목(사용하지 않음), `i`는 인덱스를 의미합니다.
setExamList(e => e.filter((_, i) => i !== index));
};
// [3-3] 예문 입력(input) 값이 변경될 때
const handleExamChange = (index, field, value) => {
setExamList(e => {
const newList = [...e]; // 1. 기존 배열을 복사 (불변성)
newList[index] = { // 2. 특정 인덱스의 객체를 새로 만듦
...newList[index], // 2-1. 해당 객체의 기존 속성들을 모두 복사
[field]: value // 2-2. 변경된 속성만 새로운 값으로 덮어쓰기
};
return newList; // 3. 새로 만들어진 배열을 반환하여 상태를 업데이트
})
};
// [4] 이미지 파일 선택 핸들러 (handleExamChange와 구조 동일)
const handleImageFileChange = (index, file) => { /*...*/ }
// [5] 음성 파일 추가/삭제 핸들러
const handleAddAudioFile = (examIndex, lang, file) => {
setExamList(e => {
const newList = [...e];
// audioFiles 배열에 새로운 음성 파일 객체를 추가(push)합니다.
// (push는 원본 배열을 변경하지만, newList가 복사본이므로 괜찮습니다.)
newList[examIndex].audioFiles.push({ lang, file });
return newList;
})
}
// ... (handleRemoveAudioFile도 filter를 사용하여 구현)
// [6] 전체 데이터 제출 전 유효성 검사 함수
const validateData = () => { /*...*/ }
// [7] '교육 등록' 버튼 클릭 시 실행되는 메인 함수
const handleSubmit = async () => {
if (!validateData()) return; // 유효성 검사 실패 시 중단
try {
dispatch(setLoading(true)); // 로딩 상태 시작 (스피너 표시 등에 사용)
// --- 등록 프로세스 (순차적 진행) ---
// 1. 주제(Study) 생성 API 호출
const studyResponse = await studyApi.create({
...studyData, // 현재까지 입력된 주제 데이터
genreNo: parseInt(selectedGenreNo) // 선택된 장르 ID 추가
});
const createdStudyNo = studyResponse.data; // API는 생성된 study의 ID를 반환
console.log("주제 생성 완료, studyNo:", createdStudyNo);
// 2. 각 예문(Exam)을 순회하며 생성
for (let i = 0; i < examList.length; i++) {
const exam = examList[i];
// 2-1. 예문 생성 API 호출
const examResponse = await examApi.create({
...exam, // 현재 예문의 텍스트 데이터
studyNo: createdStudyNo, // 방금 생성된 주제(study)의 ID를 포함
imageFile: exam.imageFile // 이미지 파일 포함
});
const createdExamNo = examResponse.data; // 생성된 예문의 ID
console.log(`Exam ${i + 1} 생성 완료, examNo:`, createdExamNo);
// 3. 해당 예문의 각 음성(Audio) 파일을 순회하며 생성
for (let j = 0; j < exam.audioFiles.length; j++) {
const audioFile = exam.audioFiles[j];
// 3-1. 음성 생성 API 호출
await audioApi.create({
lang: audioFile.lang, // 언어 코드
examNo: createdExamNo, // 방금 생성된 예문(exam)의 ID를 포함
audioFile: audioFile.file // 실제 음성 파일
})
console.log(`Audio ${j + 1} 생성 완료`);
}
}
alert('교육이 성공적으로 등록되었습니다.')
navigate('/admin/study'); // 등록 완료 후 목록 페이지로 이동
} catch (e) {
console.error("교육 등록 실패: ", e);
alert("교육 등록 중 오류가 발생했습니다.")
dispatch(setError(e.message)); // 에러 상태를 Redux에 저장
} finally {
// 성공하든 실패하든 항상 실행
dispatch(setLoading(false)); // 로딩 상태 종료
}
}
// JSX 렌더링 부분
return (<>
{/* ... */}
{/* `examList` 배열을 map 함수로 순회하며 각 예문 입력 폼을 동적으로 생성 */}
{examList.map((exam, examIndex) => (
<div key={examIndex}>
{/* ... */}
</div>
))}
{/* ... */}
</>)
}
5.2. 핵심 분석
복잡한 폼을 위한 상태 설계
- 이 컴포넌트는
useState를 사용하여 폼의 상태를 관리합니다. 특히examList는 객체의 배열[{}, {}, ...]형태로, 동적으로 늘어나는 예문 목록을 효과적으로 관리합니다. - 각 예문(
exam) 객체는 텍스트 값들, 단일 파일(imageFile), 그리고 파일 객체의 배열(audioFiles)을 속성으로 가집니다. 이처럼 중첩된 구조의 상태도useState로 충분히 관리할 수 있습니다.
- 이 컴포넌트는
불변성을 지키는 상태 업데이트
- React에서 상태를 업데이트할 때는 항상 불변성을 지켜야 합니다. 즉, 원본 상태(배열이나 객체)를 직접 수정하는 대신, 복사본을 만들어 변경한 뒤 새로운 상태로 설정해야 합니다.
handleStudyChange에서는 전개 연산자(...e)를 사용해 객체를 복사했고,handleExamChange에서는const newList = [...e]를 통해 배열을 복사한 뒤 작업했습니다. 이것이 React 상태 업데이트의 정석입니다.
동적 키(Dynamic Key)를 사용한 핸들러
handleStudyChange함수는field와value두 개의 인자만 받습니다. 그리고[field]: value구문을 사용하여 어떤 입력 필드든 이 함수 하나로 처리할 수 있습니다. 예를 들어onChange={(e) => handleStudyChange('themeKo', e.target.value)}와 같이 호출합니다. 이렇게 하면 여러 개의onChange핸들러를 만들 필요가 없어 코드가 간결해집니다.
순차적인 비동기 처리 (
handleSubmit)handleSubmit함수는 이 컴포넌트의 핵심 로직입니다. 데이터는 주제 → 예문 → 음성 순서로 의존성을 가집니다 (예문을 만들려면 주제 ID가 필요하고, 음성을 만들려면 예문 ID가 필요).- 따라서
async/await와for루프를 사용하여 API 호출을 순차적으로 실행합니다.await studyApi.create(...)로 주제를 생성하고 ID를 받습니다.for루프 안에서await examApi.create(...)로 예문을 생성하고 ID를 받습니다.- 내부
for루프에서await audioApi.create(...)로 음성을 생성합니다.
- 이러한 순차적 처리는 데이터의 관계를 정확하게 설정하기 위해 필수적입니다.
반응형
'프로젝트 > 웹' 카테고리의 다른 글
| [React] 기타 컴포넌트 : 한국어 교육 웹 서비스 '재밌는한국어' 프로젝트 (1) | 2025.10.16 |
|---|---|
| [React] 수정 컴포넌트 : 한국어 교육 웹 서비스 '재밌는한국어' 프로젝트 (0) | 2025.10.16 |
| [React] 전체 목록 컴포넌트 : 한국어 교육 웹 서비스 '재밌는한국어' 프로젝트 (0) | 2025.10.16 |
| [React] 관리자단 슬라이스 : 한국어 교육 웹 서비스 '재밌는한국어' 프로젝트 (0) | 2025.10.16 |
| [React] API 계층 분석 : 한국어 교육 웹 서비스 '재밌는한국어' 프로젝트 (0) | 2025.10.16 |
| [React] 프로젝트 구조 : 한국어 교육 웹 서비스 '재밌는한국어' 프로젝트 (0) | 2025.10.16 |