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

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

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

4. 컴포넌트 분석 (1): AdminStudyList.jsx

이 컴포넌트는 관리자 페이지의 핵심 기능 중 하나로, 서버에 저장된 모든 교육 데이터(장르, 주제, 예문, 음성)를 불러와 계층 구조(아코디언 UI)로 보여주고, 각 항목을 삭제할 수 있는 기능을 제공합니다.

4.1. 코드 전문 (주석 포함)

// React에서 필요한 훅(hook)들을 가져옵니다.
import { useEffect, useState } from "react"
// Redux와 상호작용하기 위한 훅들을 가져옵니다.
import { useDispatch, useSelector } from "react-redux";
// 페이지 이동을 위한 훅을 가져옵니다.
import { useNavigate } from "react-router-dom";
// Redux 슬라이스에 정의된 액션 생성자 함수들을 가져옵니다.
import { setGenres, setStudies, setExams, setAudios } from "../store/adminSlice";
// API 요청 함수들을 가져옵니다.
import { audioApi, examApi, genreApi, studyApi } from "../api/adminApi";

export default function AdminStudyList(props) {

    // [*] React, Redux, Router의 핵심 훅(Hook) 초기화
    const navigate = useNavigate(); // 페이지 이동 함수
    const dispatch = useDispatch(); // Redux 액션을 실행시키는 함수

    // useSelector: Redux 스토어의 상태(state)를 선택(select)하여 가져옵니다.
    // 스토어의 상태가 변경되면, 이 컴포넌트는 자동으로 리렌더링됩니다.
    const genres = useSelector(state => state.admin.genres);
    const studies = useSelector(state => state.admin.studies);
    const exams = useSelector(state => state.admin.exams);
    const audios = useSelector(state => state.admin.audios);

    // [*] 컴포넌트 자체의 상태(Local State) 관리
    // 이 상태들은 UI의 상호작용(클릭 등)을 제어하기 위해 사용됩니다.
    const [selectedGenreNo, setSelectedGenreNo] = useState(null); // 사용자가 클릭한 장르의 ID를 저장 (null이면 아무것도 선택 안됨)
    const [selectedStudyNo, setSelectedStudyNo] = useState(null); // 사용자가 클릭한 주제의 ID를 저장

    // [*] useEffect: 컴포넌트가 처음 마운트(생성)될 때 특정 작업을 수행합니다.
    useEffect(() => {
        // 컴포넌트가 화면에 처음 나타날 때 모든 데이터를 서버에서 불러옵니다.
        fetchAllData();
    }, []); // 두 번째 인자로 빈 배열 `[]`을 전달하면, 이 효과는 마운트 시 딱 한 번만 실행됩니다.

    // [1] 모든 데이터를 서버에서 조회하여 Redux 스토어에 저장하는 함수
    const fetchAllData = async () => {
        try {
            // Promise.all: 여러 개의 비동기 요청을 동시에 보내고, 모든 요청이 완료될 때까지 기다립니다.
            // 이렇게 하면 각 API를 순차적으로 기다릴 필요 없이 병렬로 처리하여 로딩 속도가 빨라집니다.
            const [genreRes, studyRes, examRes, audioRes] = await Promise.all([
                genreApi.getAll(),
                studyApi.getAll(),
                examApi.getAll(),
                audioApi.getAll()
            ])

            // dispatch: Redux의 리듀서를 호출하여 스토어의 상태를 업데이트합니다.
            // 서버에서 받아온 데이터를 각각의 상태에 저장합니다.
            dispatch(setGenres(genreRes.data));
            dispatch(setStudies(studyRes.data));
            dispatch(setExams(examRes.data));
            dispatch(setAudios(audioRes.data));

        } catch (e) {
            console.error("데이터 조회 실패:", e);
            alert("데이터 호출 중 오류가 발생했습니다.");
        }
    }

    // [2] 삭제 관련 함수들
    // [2-1] 장르 삭제
    const handleDeleteGenre = async (genreNo) => {
        // window.confirm: 사용자에게 정말 삭제할 것인지 확인받는 대화상자를 띄웁니다.
        if (!window.confirm("이 장르를 삭제하시겠습니까?")) return; // '취소'를 누르면 함수 종료

        try {
            await genreApi.delete(genreNo); // API를 통해 서버에 삭제 요청
            alert("장르가 삭제되었습니다.");
            fetchAllData(); // 삭제 후, 최신 데이터를 다시 불러와 화면을 갱신합니다.
        } catch (e) {
            console.error("장르 삭제 실패:", e);
            alert("장르 삭제에 실패했습니다. 하위 주제와 예문이 있는지 확인해주세요.");
        }
    }
    // (handleDeleteStudy, handleDeleteExam, handleDeleteAudio 함수들도 동일한 구조)
    // ...

    // [3] 데이터 필터링 함수들
    // Redux 스토어에 저장된 전체 데이터 목록에서 필요한 부분만 필터링하여 반환합니다.
    // [3-1] 특정 장르에 속한 주제들만 필터링
    const getStudiesByGenre = (genreNo) => {
        return studies.filter(study => study.genreNo == genreNo);
    };

    // [3-2] 특정 주제에 속한 예문들만 필터링
    const getExamsByStudy = (studyNo) => {
        return exams.filter(exam => exam.studyNo == studyNo);
    };

    // [3-3] 특정 예문에 속한 음성들만 필터링
    const getAudiosByExam = (examNo) => {
        return audios.filter(audio => audio.examNo == examNo);
    }

    // [4] 음성 언어 코드(1, 2)를 텍스트("한국어", "영어")로 변환하는 유틸리티 함수
    const getLangText = (lang) => {
        const langMap = { 1: "한국어", 2: "영어" };
        return langMap[lang] || '알 수 없는 언어코드입니다.';
    }

    // JSX: 화면에 렌더링될 UI 구조
    return (<>
        {/* ... */}
        {/* 장르 목록을 순회하며 화면에 표시 */}
        {genres.map(genre => (
            <div key={genre.genreNo} /* 각 항목은 고유한 key prop이 필요 */ >
                {/* 장르 헤더 */}
                <div
                    // 클릭 시 selectedGenreNo 상태를 변경하여 아코디언 UI를 토글(열고/닫고)합니다.
                    onClick={() => setSelectedGenreNo(selectedGenreNo === genre.genreNo ? null : genre.genreNo)}
                >
                    {/* ... */}
                </div>

                {/* 선택된 장르의 주제 목록 표시 */}
                {/* selectedGenreNo가 현재 장르의 genreNo와 같을 때만 이 부분이 렌더링됩니다. (조건부 렌더링) */}
                {selectedGenreNo === genre.genreNo && (
                    <div>
                        {/* ... */}
                        {/* 해당 장르의 주제 목록을 순회하며 화면에 표시 */}
                        {getStudiesByGenre(genre.genreNo).map(study => (
                            <div key={study.studyNo}>
                                {/* 주제 헤더 */}
                                <div
                                    // 클릭 시 selectedStudyNo 상태를 변경하여 하위 아코디언을 토글합니다.
                                    onClick={() => setSelectedStudyNo(selectedStudyNo === study.studyNo ? null : study.studyNo)}
                                >
                                    {/* ... */}
                                    <button onClick={(e) => {
                                        e.stopPropagation(); // 이벤트 버블링 방지. 이 버튼을 눌렀을 때 부모 div의 onClick이 실행되지 않도록 함.
                                        navigate(`/admin/study/edit/${study.studyNo}`); // 수정 페이지로 이동
                                    }}>
                                        수정
                                    </button>
                                    {/* ... */}
                                </div>

                                {/* 선택된 주제의 상세 정보 및 예문 목록 표시 */}
                                {selectedStudyNo === study.studyNo && (
                                    <div>
                                        {/* ... */}
                                        {/* 해당 주제의 예문 목록을 순회하며 화면에 표시 */}
                                        {getExamsByStudy(study.studyNo).map(exam => (
                                            <div key={exam.examNo}>
                                                {/* ... */}
                                            </div>
                                        ))}
                                    </div>
                                )}
                            </div>
                        ))}
                    </div>
                )}
            </div>
        ))}
        {/* ... */}
    </>)
}

4.2. 핵심 분석

  1. 데이터 흐름: ServerAPIRedux StoreComponent

    • 컴포넌트가 렌더링되면(useEffect), fetchAllData 함수가 adminApi의 함수들을 호출하여 서버에서 모든 데이터를 가져옵니다.
    • 가져온 데이터는 dispatch를 통해 Redux 스토어의 각 상태(genres, studies 등)에 저장됩니다.
    • useSelector는 스토어의 데이터가 변경된 것을 감지하고, 컴포넌트에 최신 데이터를 제공하여 화면을 다시 그리게(리렌더링) 합니다.
  2. 상태 관리의 두 종류

    • 전역 상태 (Global State, by Redux): genres, studies 등 여러 컴포넌트에서 공유되거나 앱의 핵심이 되는 데이터입니다. 앱이 살아있는 동안 계속 유지됩니다.
    • 지역 상태 (Local State, by useState): selectedGenreNo, selectedStudyNo 와 같이 특정 컴포넌트의 UI 상호작용만을 위해 사용되는 데이터입니다. 이 컴포넌트가 사라지면 함께 사라집니다.
  3. 효율적인 데이터 렌더링

    • 모든 데이터를 한 번에 불러와 Redux 스토어에 저장한 뒤, getStudiesByGenre, getExamsByStudy 같은 필터링 함수를 사용해 화면에 필요한 데이터를 조합하여 보여줍니다.
    • 이렇게 하면 사용자가 UI를 클릭할 때마다 서버에 다시 요청을 보낼 필요가 없어 반응성이 매우 빨라집니다. (데이터가 아주 많아지면 다른 전략이 필요할 수 있습니다.)
  4. 아코디언(Accordion) UI 구현

    • selectedGenreNoselectedStudyNo라는 지역 상태를 이용하여 구현합니다.
    • 사용자가 장르를 클릭하면 onClick 이벤트 핸들러가 setSelectedGenreNo를 호출하여 상태를 업데이트합니다.
    • React는 상태 변경을 감지하고 컴포넌트를 리렌더링합니다.
    • 이때 {selectedGenreNo === genre.genreNo && ...} 와 같은 조건부 렌더링 구문에 의해, 선택된 장르의 하위 목록만 화면에 보이게 됩니다. 다시 클릭하여 상태가 null이 되면 해당 부분은 렌더링되지 않습니다.
  5. e.stopPropagation()의 역할

    • 수정/삭제 버튼은 클릭 가능한 div 내부에 있습니다. 만약 stopPropagation이 없다면, 버튼을 클릭했을 때 버튼의 onClick 이벤트와 부모 divonClick 이벤트가 모두 실행되어 버립니다 (이벤트 버블링).
    • e.stopPropagation()은 이벤트가 부모 요소로 전파되는 것을 막아, 버튼 클릭 시에는 버튼 기능만 수행되도록 보장합니다.
반응형