본문 바로가기
프로젝트/웹

[React] 등록 컴포넌트 : 한국어 교육 웹 서비스 '재밌는한국어' 프로젝트

by AI읽어주는남자 2025. 10. 16.
반응형

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. 핵심 분석

  1. 복잡한 폼을 위한 상태 설계

    • 이 컴포넌트는 useState를 사용하여 폼의 상태를 관리합니다. 특히 examList객체의 배열 [{}, {}, ...] 형태로, 동적으로 늘어나는 예문 목록을 효과적으로 관리합니다.
    • 각 예문(exam) 객체는 텍스트 값들, 단일 파일(imageFile), 그리고 파일 객체의 배열(audioFiles)을 속성으로 가집니다. 이처럼 중첩된 구조의 상태도 useState로 충분히 관리할 수 있습니다.
  2. 불변성을 지키는 상태 업데이트

    • React에서 상태를 업데이트할 때는 항상 불변성을 지켜야 합니다. 즉, 원본 상태(배열이나 객체)를 직접 수정하는 대신, 복사본을 만들어 변경한 뒤 새로운 상태로 설정해야 합니다.
    • handleStudyChange에서는 전개 연산자(...e)를 사용해 객체를 복사했고, handleExamChange에서는 const newList = [...e]를 통해 배열을 복사한 뒤 작업했습니다. 이것이 React 상태 업데이트의 정석입니다.
  3. 동적 키(Dynamic Key)를 사용한 핸들러

    • handleStudyChange 함수는 fieldvalue 두 개의 인자만 받습니다. 그리고 [field]: value 구문을 사용하여 어떤 입력 필드든 이 함수 하나로 처리할 수 있습니다. 예를 들어 onChange={(e) => handleStudyChange('themeKo', e.target.value)} 와 같이 호출합니다. 이렇게 하면 여러 개의 onChange 핸들러를 만들 필요가 없어 코드가 간결해집니다.
  4. 순차적인 비동기 처리 (handleSubmit)

    • handleSubmit 함수는 이 컴포넌트의 핵심 로직입니다. 데이터는 주제 → 예문 → 음성 순서로 의존성을 가집니다 (예문을 만들려면 주제 ID가 필요하고, 음성을 만들려면 예문 ID가 필요).
    • 따라서 async/awaitfor 루프를 사용하여 API 호출을 순차적으로 실행합니다.
      • await studyApi.create(...)로 주제를 생성하고 ID를 받습니다.
      • for 루프 안에서 await examApi.create(...)로 예문을 생성하고 ID를 받습니다.
      • 내부 for 루프에서 await audioApi.create(...)로 음성을 생성합니다.
    • 이러한 순차적 처리는 데이터의 관계를 정확하게 설정하기 위해 필수적입니다.
반응형