목적

LMS 프로젝트를 유지보수하며 가장 먼저 눈에 밟혔던 기술 부채가 있었습니다. 

바로 강의 콘텐츠 관리 페이지였는데요. 테이블 구조가 여러 번 바뀌는 동안 임시방편으로 코드를 덧대다 보니, 어느새 손대기 무서운 스파게티 코드 가 되어 있었습니다. 이대로 두면 미래의 나 또는 동료들에게 큰 짐이 될 것 같아, 이번 기회에 백엔드부터 프론트엔드까지 전면적인 리팩토링을 단행했습니다.

 

아래는 현재 수정할 화면입니다.(주차가 2주차 부터 시작하는건 데이터 오류이므로 무시..) 

현재화면

[ 교과정보 주차 설정 ]

필요한 테이블 ERD

문제점

문제점 및 고칠 부분은 크게 3가지가 있습니다.(보면 얼마나 심각했는지 감이 올것이다)

1. SQL - 거대한 UNION ALL 쿼리 

  • 서로 다른 도메인(영상, 과제, 시험)을 하나의 UNION ALL로 묶다 보니, 데이터가 없는 테이블도 억지로 컬럼 개수를 맞추기 위해 수십 개의 의미 없는 NULL 컬럼을 선언해야 했습니다. 이는 가독성을 해칠 뿐만 아니라, 필드 하나를 추가할 때마다 모든 UNION 쿼리를 수정해야 하는 유지보수의 악순환을 만들고 있습니다.

2. 백엔드 -  비대해진 컨트롤러 

  • 비즈니스 로직이 컨트롤러에 밀집되어 있어 '요청 수신과 응답 반환'이라는 컨트롤러의 역할 외에 너무 많은 책임을 지고 있었습니다. 이는 SRP(단일 책임 원칙)를 위반할 뿐만 아니라 코드 재사용성을 현저히 떨어뜨리는 요인이었습니다.

3.  프론트 - 하나의 함수에 수백줄의 HTML 백틱과 비즈니스 로직을 다 머금고 있음 

  • 렌더링 로직과 비즈니스 로직이 수천 줄의 백틱(`) 지옥에 섞여 있어, 사소한 UI 수정에도 많은 리소스가 소모되는 구조였습니다.

해결방안

1. SQL

  • 복잡한 서브쿼리와 UNION 연산을 줄여 DB 부하를 분산시키고, 데이터 조립의 주도권을 서버(Java)로 가져와 유연한 비즈니스 로직 처리가 가능하도록 수정합니다. 

2. 백엔드

  • 컨트롤러에 집중된 과도한 책임을 서비스 레이어로 이관하고, 관심사의 분리(SoC)를 위해 기능별로 메소드 세분화

3. 프론트 

  • 함수형 컴포넌트 구조 도입: renderViewMode, renderEditMode, renderContentItemBody 등 기능별로 렌더러 함수를 쪼개서 조립하는 방식

해결과정

SQL 

비대해진 단일 벌크 쿼리를 도메인별 모듈형 벌크 조회로 전환하여 쿼리 복잡도를 낮췄습니다. 

기존 SQL.XML]

주석은 잘 달아뒀지만 역시나 너무 헷갈리는 SQL입니다. 

제가 가져와야 하는 값은 영상/HTML, 과제/토론, 퀴즈/시험  이었습니다. 하지만 이게 공통되는 테이블이 있으면서 없어서 UNION ALL로 다 합쳐 버리니 쿼리가 매우 무거워졌습니다. 

 (select
            case when min(cl2.open_yn) = 'Y' then 'Y'
            else 'N' end
            from courses_lecture cl2
            where cl2.course_id = c.id ) as allOpenYn,

 

위 쿼리는 전체 공개 여부(allOpenYn)를  조회하는 서브 쿼리입니다. DB 서브쿼리로 판단하던걸 쿼리문 간소화를 통해 아래처럼 Java Stream API의 allMatch를 활용하여 계산하도록 로직을 변경했습니다. 덕분에 무거운 쿼리는 가벼워졌고, 로직은 자바 코드로 명확히 드러나게 되었습니다.

String allOpenYn = lessons.isEmpty() ? "N" : 
 			lessons.stream().allMatch(l -> "Y".equals(l.getLessonOpenYn())) ? "Y" : "N";
 		result.put("allOpenYn", allOpenYn);

 

[기존 SQL.XML]

새로운 필드 하나 추가하려면 UNION으로 연결된 모든 쿼리에 NULL 컬럼을 일일이 추가해야 하는 번거로움이 있는 상태. 

더보기
<select id="getCourseContentManageList" resultType="CourseContentInfoDto">
    /*getCourseContentManageList 강의개설 > 컨텐츠관리 > 리스트  */
    
    --  courses_lecture_detail (영상, HTML 등) 컨텐츠 조회
        SELECT
            cl.id AS lessonId,                                  -- 주차 ID -> courses_lecture.id
            cl.week AS lessonSort,                              -- 주차 정렬 -> courses_lecture.week
            COALESCE(lm.seq, 0) AS mappingSort,                 -- 주차 내 순서 -> courses_lecture_detail.seq 
            c.department_id AS departmentId,
            cl.courses_desc AS lessonName,                      -- 주차 이름 -> courses_lecture.courses_desc
            lm.id AS lessonMappingId,                           -- 매핑 ID -> courses_lecture_detail.id
            c.use_yn AS lessonDelYn,                            -- 삭제 여부 -> courses.use_yn으로 대체
            NULL AS clsDelYn,                                   -- clsDelYn 컬럼이 새 테이블에 없어 NULL 처리
            cl.open_yn AS lessonOpenYn,                         -- 공개 여부 -> courses.use_yn으로 대체
            lm.open_yn AS clsOpenYn,                            -- clsOpenYn 컬럼이 새 테이블에 없어 NULL 처리
            cl.session AS sessionSort,
            cl.date as sessionDate, --차시 기간
            cl."period" as sessionPeriod, -- 차시 교시
            cl.type as courseClsType, -- 수업방식
            (select
            case when min(cl2.open_yn) = 'Y' then 'Y'
            else 'N' end
            from courses_lecture cl2
            where cl2.course_id = c.id ) as allOpenYn,
            lm.st_date AS lessonStartAt,                        -- 주차 시작일 -> courses_lecture_detail.st_date
            lm.ed_date AS lessonEndAt,                          -- 주차 종료일 -> courses_lecture_detail.ed_date
            lm."type" AS contentType,                           -- 콘텐츠 타입 (영상/과제 등) -> lm.type 사용
            -- 콘텐츠 유형에 따라 CASE 문으로 분기 처리
            NULL AS title,                                      -- 과제 제목 (ca 컬럼이므로 NULL)
            lm.lecture_title AS contentName,                    -- 영상/HTML 제목
            lm.lecture_desc AS htmlBody,                        -- HTML 본문
            -- 날짜 정보
            NULL AS startAt,                                    -- 과제 시작일 (ca 컬럼이므로 NULL)
            CASE WHEN lm."type" = 'V' THEN lm.st_date ELSE NULL END AS movieStartAt,  -- 영상 시작일
            CASE WHEN lm."type" = 'V' THEN lm.ed_date ELSE NULL END AS movieEndAt,    -- 영상 종료일
            NULL AS endAt,                                      -- 과제 종료일 (ca 컬럼이므로 NULL)
            -- ID 정보
            NULL AS assignId,                                   -- 과제 ID (ca 컬럼이므로 NULL)
            lm.id AS contentId,                                 -- 콘텐츠 ID
            -- 과정 기간
            s.start_at AS courseStartAt,
            s.end_at AS courseEndAt,
            -- 토론 정보
            NULL AS disId,                                      -- 토론 ID (ca 컬럼이므로 NULL)
            NULL AS disTitle,                                   -- 토론 제목 (ca 컬럼이므로 NULL)
            NULL AS disStartAt,                                 -- 토론 시작일 (ca 컬럼이므로 NULL)
            NULL AS disEndAt,                                   -- 토론 종료일 (ca 컬럼이므로 NULL)
          -- 시험 정보 (임의의 값으로 설정)
            NULL AS examTitle,
            NULL::timestamp AS examStartAt, 
            NULL::timestamp AS examEndAt,
           NULL::bigint AS examId,
            c.member_id AS lecturerId
        FROM
            courses c
        JOIN courses_lecture cl ON c.id = cl.course_id
        LEFT JOIN courses_lecture_detail lm ON cl.id = lm.id and lm.delete_yn = 'N'
        LEFT JOIN semester s on c.year = s.year and c.semester_cd = s.semester_code
        WHERE
            c.id = #{courseId} and cl.delete_yn = 'N'
        UNION ALL
        --  courses_board (과제, 토론 등) 컨텐츠 조회
        SELECT
            cl.id AS lessonId,                                  -- 주차 ID -> courses_lecture.id
            cl.week AS lessonSort,                              -- 주차 정렬 -> courses_lecture.week
            99 AS mappingSort,                                  -- 주차 내 순서 -> (lm 컨텐츠 뒤에 정렬되도록 99 부여)
            c.department_id AS departmentId,
            cl.courses_desc AS lessonName,                      -- 주차 이름 -> courses_lecture.courses_desc
            cl.id AS lessonMappingId,                            -- 매핑 ID -> (lm 컬럼이므로 NULL)
            c.use_yn AS lessonDelYn,                            -- 삭제 여부 -> courses.use_yn으로 대체
            NULL AS clsDelYn,                                   -- clsDelYn 컬럼이 새 테이블에 없어 NULL 처리
            cl.open_yn AS lessonOpenYn,                         -- 공개 여부 -> courses.use_yn으로 대체
            NULL AS clsOpenYn,                                  -- clsOpenYn 컬럼이 새 테이블에 없어 NULL 처리
            cl.session AS sessionSort,
            cl.date as sessionDate, --차시 기간
            cl."period" as sessionPeriod, -- 차시 교시
            cl.type as courseClsType, -- 수업방식
            (select
            case when min(cl2.open_yn) = 'Y' then 'Y'
            else 'N' end
            from courses_lecture cl2
            where cl2.course_id = c.id ) as allOpenYn,
            NULL AS lessonStartAt,                              -- 주차 시작일 -> (lm 컬럼이므로 NULL)
            NULL AS lessonEndAt,                                -- 주차 종료일 -> (lm 컬럼이므로 NULL)
            ca."type" AS contentType,                             -- 콘텐츠 타입 (영상/과제 등) -> ca.type 사용
            -- 콘텐츠 유형에 따라 CASE 문으로 분기 처리
            CASE WHEN ca."type" = #{assignmentCode} THEN ca.title ELSE NULL END AS title,          -- 과제 제목
            NULL AS contentName,                                -- 영상/HTML 제목 (lm 컬럼이므로 NULL)
            NULL AS htmlBody,                                   -- HTML 본문 (lm 컬럼이므로 NULL)
            -- 날짜 정보
            CASE WHEN ca."type" = #{assignmentCode} THEN ca.st_date ELSE NULL END AS startAt,       -- 과제 시작일
            NULL AS movieStartAt,                               -- 영상 시작일 (lm 컬럼이므로 NULL)
            NULL AS movieEndAt,                                 -- 영상 종료일 (lm 컬럼이므로 NULL)
            CASE WHEN ca."type" = #{assignmentCode} THEN ca.ed_date ELSE NULL END AS endAt,         -- 과제 종료일
            -- ID 정보
            CASE WHEN ca."type" = #{assignmentCode} THEN ca.id ELSE NULL END AS assignId,           -- 과제 ID
            ca.id AS contentId,                                 -- 콘텐츠 ID (ca.id 사용)
            -- 과정 기간
            s.start_at AS courseStartAt,
            s.end_at AS courseEndAt,
            -- 토론 정보
            CASE WHEN ca."type" = #{discossionCode} THEN ca.id ELSE NULL END AS disId,              -- 토론 ID
            CASE WHEN ca."type" = #{discossionCode} THEN ca.title ELSE NULL END AS disTitle,        -- 토론 제목
            CASE WHEN ca."type" = #{discossionCode} THEN ca.st_date ELSE NULL END AS disStartAt,    -- 토론 시작일
            CASE WHEN ca."type" = #{discossionCode} THEN ca.ed_date ELSE NULL END AS disEndAt,      -- 토론 종료일
          -- 시험 정보 (임의의 값으로 설정)
            NULL AS examTitle,
            NULL::timestamp AS examStartAt, 
            NULL::timestamp AS examEndAt,
            NULL::bigint AS examId,
            c.member_id AS lecturerId
        FROM
            courses c
        JOIN courses_lecture cl ON c.id = cl.course_id
        JOIN courses_board ca ON cl.id = ca.course_lecture_id  
        LEFT JOIN semester s on c.year = s.year and c.semester_cd = s.semester_code
        WHERE
            c.id = #{courseId} and cl.delete_yn = 'N' and ca.delete_yn ='N'
        UNION ALL
        SELECT -- 시험정보 
           cl.id AS lessonId,                                  
            cl.week AS lessonSort,                              
            999 AS mappingSort,                                 -- 시험 콘텐츠 순서 (가장 뒤로)
            c.department_id AS departmentId,
            cl.courses_desc AS lessonName,                      
            cl.id AS lessonMappingId,                           
            c.use_yn AS lessonDelYn,                            
            NULL AS clsDelYn,                                   
            cl.open_yn AS lessonOpenYn,                         
            ce.open_yn AS clsOpenYn,                            -- 시험 공개 여부 사용
            cl.session AS sessionSort,
            cl.date AS sessionDate, 
            cl."period" AS sessionPeriod, 
            cl.type AS courseClsType, 
            (
                SELECT CASE WHEN min(cl2.open_yn) = 'Y' THEN 'Y' ELSE 'N' END
                FROM courses_lecture cl2 WHERE cl2.course_id = c.id
            ) AS allOpenYn,
            ce.start_at AS lessonStartAt,
            ce.end_at AS lessonEndAt,                            
            'E' AS contentType,                                 -- 콘텐츠 타입 'E'  
            NULL AS title,                                      
            NULL AS contentName,                                
            NULL AS htmlBody,                                   
            NULL AS startAt,                                    
            NULL AS movieStartAt,                               
            NULL AS movieEndAt,                                 
            NULL AS endAt,                                      
            NULL AS assignId,                                   
            ce.id AS contentId,                                 -- 시험 ID를 contentId로 사용
            s.start_at AS courseStartAt,
            s.end_at AS courseEndAt,
            NULL AS disId,                                      
            NULL AS disTitle,                                   
            NULL AS disStartAt,                                 
            NULL AS disEndAt,                                   
            ce.exam_type AS examTitle,                          -- F:기말, M:중간, Q:퀴즈
            ce.start_at AS examStartAt,
            ce.end_at AS examEndAt,
            ce.id AS examId,                                    -- 시험 ID
            c.member_id AS lecturerId
        FROM
            courses c
        JOIN courses_lecture cl ON c.id = cl.course_id
        LEFT JOIN semester s ON c.year = s.year AND c.semester_cd = s.semester_code
        JOIN course_exam ce ON cl.id = ce.course_lecture_id
        WHERE
            c.id = #{courseId}  AND cl.delete_yn = 'N' AND ce.delete_yn = 'N'
        ORDER BY
            lessonSort ASC, sessionSort ASC, mappingSort ASC;
</select>

수정된 SQL.XML

만약 새로운 콘텐츠 타입(예: 설문조사)이 추가된다면? 기존 쿼리는 모든 UNION 문에 컬럼을 추가해야 했지만, 이제는 독립된 select 하나만 만들고 fullList.addAll()만 하면 끝!  (서비스 수정부분에 있음) 

 

Step1 : 기본 차시(뼈대) 정보 가져오기 

<select id="selectLessonStructure" resultType="CourseContentInfoDto">
    /*기본 차시 정보 (selectLessonStructure)*/
    SELECT 
        cl.id AS lessonId,
        cl.week AS lessonSort,
        cl.session AS sessionSort,
        cl.courses_desc AS lessonName,
        cl.date AS sessionDate,
        cl.period AS sessionPeriod,
        cl.type AS courseClsType,
        cl.open_yn AS lessonOpenYn,
        c.member_id AS lecturerId,
        c.department_id AS departmentId
    FROM courses c
    JOIN courses_lecture cl ON c.id = cl.course_id
    WHERE c.id = #{courseId} AND cl.delete_yn = 'N'
</select>

 

Step2 : 콘텐츠가 하나도 없는 빈차시 정보 가져오기 

<select id="selectEmptySessions" resultType="CourseContentInfoDto">
    /* selectEmptySessions 콘텐츠가 하나도 없는 빈 차시 정보만 조회 */
    SELECT 
        cl.id AS lessonId,
        cl.week AS lessonSort,
        cl.session AS sessionSort,
        cl.courses_desc AS lessonName,
        cl.date AS sessionDate,
        cl.period AS sessionPeriod,
        cl.type AS courseClsType,
        cl.open_yn AS lessonOpenYn,
        c.member_id AS lecturerId,
        c.department_id AS departmentId,
        NULL AS contentType,
        0 AS mappingSort
    FROM courses c
    JOIN courses_lecture cl ON c.id = cl.course_id
    WHERE c.id = #{courseId} 
      AND cl.delete_yn = 'N'
      AND NOT EXISTS (SELECT 1 FROM courses_lecture_detail lm WHERE lm.id = cl.id AND lm.delete_yn = 'N')
      AND NOT EXISTS (SELECT 1 FROM courses_board ca WHERE ca.course_lecture_id = cl.id AND ca.delete_yn = 'N')
      AND NOT EXISTS (SELECT 1 FROM course_exam ce WHERE ce.course_lecture_id = cl.id AND ce.delete_yn = 'N')
</select>

 

Step 3: 도메인별로 쪼개기 

영상/HTML, 과제/토론, 퀴즈/시험 3개로 분리 시켰습니다. 

더보기
<select id="selectLectureDetails" resultType="CourseContentInfoDto">
    /*영상/HTML 상세 조회 (selectLectureDetails)*/
    SELECT 
        cl.id AS lessonId,
        cl.week AS lessonSort,    
        cl.session AS sessionSort, 
        lm.id AS lessonMappingId,
        lm.id AS contentId,
        lm.lecture_title AS contentName,
        lm.lecture_desc AS htmlBody,
        lm.st_date AS lessonStartAt,
        lm.ed_date AS lessonEndAt,
        lm.type AS contentType,
        lm.seq AS mappingSort,
        lm.open_yn AS clsOpenYn
    FROM courses_lecture cl
    JOIN courses_lecture_detail lm ON cl.id = lm.id
    WHERE cl.course_id = #{courseId} AND cl.delete_yn = 'N' AND lm.delete_yn = 'N'
</select>

<select id="selectBoardContents" resultType="CourseContentInfoDto">
    /*과제/토론 조회 (selectBoardContents)*/ 
    SELECT 
        cl.id AS lessonId,
        cl.week AS lessonSort,    
        cl.session AS sessionSort, 
        ca.id AS contentId,
        ca.id AS assignId,
        ca.id AS disId,
        ca.title AS title,
        ca.st_date AS startAt,
        ca.ed_date AS endAt,
        ca.type AS contentType,
        99 AS mappingSort
    FROM courses_lecture cl
    JOIN courses_board ca ON cl.id = ca.course_lecture_id
    WHERE cl.course_id = #{courseId} AND cl.delete_yn = 'N' AND ca.delete_yn = 'N'
</select>

<select id="selectExamDetails" resultType="CourseContentInfoDto">
    /*selectExamDetails 시험 */
    SELECT 
        cl.id AS lessonId,
        cl.week AS lessonSort,    
        cl.session AS sessionSort ,
        ce.id AS contentId,
        ce.id AS examId,
        ce.exam_type AS examTitle,
        ce.start_at AS examStartAt,
        ce.end_at AS examEndAt,
        ce.exam_type AS contentType,
        ce.open_yn AS clsOpenYn,
        999 AS mappingSort
    FROM courses_lecture cl
    JOIN course_exam ce ON cl.id = ce.course_lecture_id
    WHERE cl.course_id = #{courseId} 
      AND cl.delete_yn = 'N' 
      AND ce.delete_yn = 'N'
</select>

백엔드

기존 컨트롤러

정말 길고 복잡하며 가독성이 없는 코드입니다. 

아래 코드를 그대로 서비스단에 옮긴다고 해도 문제인게 너무 많은 기능들이 한 함수에 몰아넣어 있습니다

더보기
//개설강의 > 수강생관리 > 초기데이터 
@GetMapping("/learningManage/learningContentSet/getInitCourseLearningSet/{courseId}")
public ResponseEntity<?> getInitCourseLearningSet(@PathVariable Long courseId, HttpSession session, Locale lang,
        HttpServletRequest req) throws Exception { // throws Exception 추가 가능

    Map<String, Object> result = new HashMap<>();

    try {

        //////////////////////////////////////교과정보///////////////////////////////////////////////
            List<CommonCodeDTO> subjectTypeList = academicService.getCodeCommonList(80, lang.getLanguage()); //과목종별 
        List<CommonCodeDTO> semesterList = academicService.getCodeCommonList(1, lang.getLanguage()); //학기 
        List<DepartmentDto> topCollegeList =  learningService.getTopDepartments();
        List<SemesterDto> years = academicService.getSemesterYear(); // 연도
        List<CommonCodeDTO> courseType = systemService.getAllRoleCodeList(lang.getLanguage(),152); //수업방식
        result.put("courseType", courseType);
        CourseDetailDto courseInfo = null;

        if(courseId != 0) {
            courseInfo = academicService.getCourseInfo(courseId); //기본으로 뿌려주는 정보 
            List<DepartmentDto> targetDept = academicService.getTargetDept(courseId); //대상학과
            List<CoursesScheduleDto> courseSchedule = academicService.getCourseSchedule(courseId); //강의 시간 

            result.put("courseInfo" ,courseInfo);  
            result.put("courseSchedule" ,courseSchedule);  
            result.put("targetDept" ,targetDept);
            result.put("courseId" ,courseId);
           if (courseSchedule != null && !courseSchedule.isEmpty()) {
                // 1. 요일(weekday)을 기준으로 교시(period)들을 그룹화 (TreeMap: 요일 순으로 자동 정렬)
                Map<Integer, List<Integer>> scheduleMap = new TreeMap<>();
                for (CoursesScheduleDto schedule : courseSchedule) {
                    scheduleMap.computeIfAbsent(schedule.getWeekday(), k -> new ArrayList<>()).add(schedule.getPeriod());
                }

                // 2. 그룹화된 데이터를 "월: 1,2 교시" 형태의 문자열로 변환
                String[] dayNames = {"월", "화", "수", "목", "금", "토", "일"};
                StringJoiner finalScheduleString = new StringJoiner(", "); // 최종 문자열을 ", "로 연결

                for (Map.Entry<Integer, List<Integer>> entry : scheduleMap.entrySet()) {
                    int weekday = entry.getKey();
                    List<Integer> periods = entry.getValue();
                    Collections.sort(periods); // 교시 오름차순 정렬

                    // "1,2,3" 형태의 문자열로 변환
                    String periodsStr = periods.stream()
                                               .map(String::valueOf)
                                               .collect(Collectors.joining(","));

                    // weekday(1~7)를 배열 인덱스(0~6)에 맞게 -1 처리
                    String dayName = dayNames[weekday - 1]; 

                    finalScheduleString.add(String.format("%s: %s 교시", dayName, periodsStr));
                }

                // 3. ModelAndView에 "courseScheduleName"이라는 이름으로 추가
                result.put("courseScheduleName", finalScheduleString.toString());
            } else {
                 result.put("courseScheduleName", "시간 정보 없음"); // 데이터가 없을 경우
            }
        }

        result.put("years", years);
        result.put("subjectTypeList" ,subjectTypeList); //상위 대학 리스트 
        result.put("topCollegeList" ,topCollegeList);
        result.put("semesterList" ,semesterList);


        //////////////////////////////// 교과정보 주차설정 ////////////////////////////////////////////////////////// 
        List<CourseContentInfoDto> courseList = learningService.getCourseContentManageList(courseId); // 강의 리스트

        Map<String, Object> noCls = ScheduleTypeCode.NO_CLASS.toMap(lang.getLanguage());
        Map<String, Object> makeupCls = ScheduleTypeCode.MAKEUP_CLASS.toMap(lang.getLanguage());

        // schedule 설정을 위한 부분
        List<Map<String, Object>> scheduleOptions = new ArrayList<>();

        Map<String, Object> noClsFiltered = new HashMap<>();
        noClsFiltered.put("code", noCls.get("code"));
        noClsFiltered.put("label", noCls.get("label"));
        scheduleOptions.add(noClsFiltered);

        Map<String, Object> makeupClsFiltered = new HashMap<>();
        makeupClsFiltered.put("code", makeupCls.get("code"));
        makeupClsFiltered.put("label", makeupCls.get("label"));
        scheduleOptions.add(makeupClsFiltered);
        // schedule설정을 위한 부분

        System.out.println("courseList : " + courseList);

        if (courseList == null || courseList.isEmpty()) {
            // ========== 1. 콘텐츠가 하나도 없을 때의 처리 ==========
            result.put("groupedCourses", new HashMap<>()); // 비어있는 맵을 전달하여 th:if에서 걸러지도록 함
            result.put("maxLessonSort", 0);
            result.put("allOpenYn", "N");
            result.put("newContent", "Y"); // 새 콘텐츠 생성이 필요한 상태로 설정

            if (courseInfo != null) {
                LocalDateTime courseStartAt = courseInfo.getStartAt() != null ? courseInfo.getStartAt() : LocalDateTime.now();
                LocalDateTime courseEndAt = courseInfo.getEndAt() != null ? courseInfo.getEndAt() : LocalDateTime.now();

                long days = ChronoUnit.DAYS.between(courseStartAt.toLocalDate(), courseEndAt.toLocalDate()) + 1;
                long weeks = (days > 0) ? (long) Math.ceil(days / 7.0) : 0;

                result.put("weeks", weeks);
                result.put("departmentId", courseInfo.getDepartmentId());
                result.put("courseStartAt", courseStartAt);
                result.put("courseEndAt", courseEndAt);
            } else {
                result.put("weeks", 0);
                result.put("departmentId", null);
                result.put("courseStartAt", LocalDateTime.now());
                result.put("courseEndAt", LocalDateTime.now());
                }

            } else {
                // ========== 2. 콘텐츠가 하나 이상 있을 때의 처리 ==========
            LocalDateTime courseStartAt = courseList.get(0).getCourseStartAt();
            LocalDateTime courseEndAt = courseList.get(0).getCourseEndAt();
            long days = ChronoUnit.DAYS.between(courseStartAt.toLocalDate(), courseEndAt.toLocalDate()) + 1;
            long weeks = (days > 0) ? (long) Math.ceil(days / 7.0) : 0;

            result.put("weeks", weeks);
            result.put("departmentId", courseList.get(0).getDepartmentId());
            result.put("courseStartAt", courseStartAt);
            result.put("courseEndAt", courseEndAt);
            for (CourseContentInfoDto list : courseList) {
                if (list == null) continue;

                String type = list.getContentType();
                if (LessonMappingTypeCode.ASSIGNMENT.getCode().equals(type)) {
                    list.setIconType("ic-lesson-task");
                    list.setContentTypeText("assign"); 
                    list.setLessonStartAt(list.getStartAt());
                    list.setLessonEndAt(list.getEndAt());
                } else if (LessonMappingTypeCode.MEETING.getCode().equals(type)) {
                    list.setIconType("ic-lesson-live");
                    list.setContentTypeText("meeting");
                    list.setTitle(list.getContentName()); 
                } else if (LessonMappingTypeCode.VIDEO.getCode().equals(type)) {
                    list.setIconType("ic-lesson-video");
                    list.setContentTypeText("video");
                    list.setTitle(list.getContentName());
                } else if (LessonMappingTypeCode.QUIZ.getCode().equals(type)) {
                    list.setIconType("ic-lesson-quiz");
                    list.setContentTypeText("quiz");
                    list.setTitle("퀴즈"); 
                    list.setStartAt(list.getExamStartAt());
                    list.setEndAt(list.getExamEndAt());
                } else if (LessonMappingTypeCode.EXAM.getCode().equals(type)) {
                    list.setIconType("ic-lesson-quiz");
                    list.setContentTypeText("exam");
                    list.setStartAt(list.getExamStartAt());
                    list.setEndAt(list.getExamEndAt());
                    if(LessonMappingTypeCode.MIDTEREXAM.getCode().equals(list.getExamTitle())){
                        list.setTitle("중간고사");
                    }else {
                        list.setTitle("기말고사"); 
                    }
                } else if (LessonMappingTypeCode.DISCUSSION.getCode().equals(type)) {
                    list.setIconType("ic-lesson-discussion");
                    list.setContentTypeText("discu");
                    list.setTitle(list.getDisTitle());
                    list.setLessonStartAt(list.getDisStartAt());
                    list.setLessonEndAt(list.getDisEndAt());
                } else if (LessonMappingTypeCode.HTML.getCode().equals(type)) {
                    list.setIconType("ic-lesson-html");
                    list.setContentTypeText("html");
                    list.setTitle(list.getContentName());
                }
            }

            Map<Integer, Map<Integer, List<CourseContentInfoDto>>> groupedCourses = courseList.stream()
                    .filter(Objects::nonNull)
                    .collect(Collectors.groupingBy(
                            list -> list.getLessonSort() != null ? list.getLessonSort() : 0,
                            TreeMap::new,
                            Collectors.groupingBy(
                                    list -> list.getSessionSort() != null ? list.getSessionSort() : 1,
                                    TreeMap::new,
                                    Collectors.toList()
                            )
                    ));

            Integer maxLessonSort = courseList.stream()
                    .filter(Objects::nonNull)
                    .map(list -> list.getLessonSort() != null ? list.getLessonSort() : 0)
                    .max(Integer::compareTo).orElse(0);

            result.put("maxLessonSort", maxLessonSort);
            result.put("groupedCourses", groupedCourses);
            result.put("allOpenYn", courseList.get(0).getAllOpenYn());
            result.put("newContent", "N");
        }

        result.put("success", true);
        return ResponseEntity.ok(result);
    } catch (Exception e) {
        result.put("success", false);
        result.put("message", e.getMessage());
        return ResponseEntity.badRequest().body(result);
    }
}

수정된 서비스

아래 보면 여러 함수로 나뉜것을 확인할 수 있다. 

 

getCombinedCourseList, handleEmptyCourseList, handlePopulatedCourseList, formatScheduleString, calculateWeeks 크게 5개의 함수로 분리 하였다.  또한 위에 컨텐츠 타입별로 값 넣어주는 부분은 Dto 파일 내에 바로 updateMetaData 함수로 정의해 두었다!  

더보기

 

@Override
@Transactional
public Map<String, Object> getInitCourseLearningSet(Long courseId, Locale lang) {

    Map<String, Object> result = new HashMap<>();
    try {

        //////////////////////////////////////교과정보///////////////////////////////////////////////
        List<CommonCodeDTO> subjectTypeList = academicMapper.getCodeCommonList(80, lang.getLanguage()); //과목종별 
        List<CommonCodeDTO> semesterList = academicMapper.getCodeCommonList(1, lang.getLanguage()); //학기 
        List<DepartmentDto> topCollegeList =  learningMapper.selectTopDepartments();
        List<SemesterDto> years = academicMapper.getSemesterYear(); // 연도
        List<CommonCodeDTO> courseType = systemMapper.getAllRoleCodeList(lang.getLanguage(),152); //수업방식
        result.put("courseType", courseType);
        CourseDetailDto courseInfo = null;

        if(courseId != 0) {
            courseInfo = academicMapper.getCourseInfo(courseId, lang.getLanguage()); //기본으로 뿌려주는 정보 
            List<DepartmentDto> targetDept = academicMapper.getTargetDept(courseId); //대상학과
            List<CoursesScheduleDto> courseSchedule = academicMapper.getCourseSchedule(courseId); //강의 시간 
            String courseScheduleName = formatScheduleString(courseSchedule); 
            result.put("courseScheduleName", courseScheduleName);

            result.put("courseInfo" ,courseInfo);  
            result.put("courseSchedule" ,courseSchedule);  
            result.put("targetDept" ,targetDept);
            result.put("courseId" ,courseId);

        }

        result.put("years", years);
        result.put("subjectTypeList" ,subjectTypeList); //상위 대학 리스트 
        result.put("topCollegeList" ,topCollegeList);
        result.put("semesterList" ,semesterList);

        //////////////////////////////// 교과정보 주차설정 ////////////////////////////////////////////////////////// 

        List<CourseContentInfoDto> courseList = getCombinedCourseList(courseId, result);
        if (courseList == null || courseList.isEmpty()) {
            handleEmptyCourseList(result, courseInfo); //데이터가 없을 때  
        } else {
            handlePopulatedCourseList(result, courseList, courseInfo); //데이터가 있을 때 
        }

        Map<String, Object> noCls = ScheduleTypeCode.NO_CLASS.toMap(lang.getLanguage());
        Map<String, Object> makeupCls = ScheduleTypeCode.MAKEUP_CLASS.toMap(lang.getLanguage());

        // schedule 설정을 위한 부분
        List<Map<String, Object>> scheduleOptions = new ArrayList<>();

        Map<String, Object> noClsFiltered = new HashMap<>();
        noClsFiltered.put("code", noCls.get("code"));
        noClsFiltered.put("label", noCls.get("label"));
        scheduleOptions.add(noClsFiltered);

        Map<String, Object> makeupClsFiltered = new HashMap<>();
        makeupClsFiltered.put("code", makeupCls.get("code"));
        makeupClsFiltered.put("label", makeupCls.get("label"));
        scheduleOptions.add(makeupClsFiltered);
        // schedule설정을 위한 부분

        System.out.println("courseList : " + courseList);

        result.put("success", true);
        return result;
    } catch (Exception e) {
        result.put("success", false);
        result.put("message", e.getMessage());
        return result;
    } 
}

[getCombinedCourseList]

일단 수정된 쿼리문으로 데이터를 교체해 주어야 하기 떄문에 기존 값을 매핑해주었다.

기존 코드에서는 모든 값을 쿼리에서 가져와 형식에 맞게 배열로 매핑후 반환해 주었다면 이번 코드에서는 분리된 sql에서 값을 가져와 필요한 값을  스트림을 통해 계층구조로 재구성하여 반환해주었다. 

 

특히 아까 서브쿼리에서는 쿼리문으로 조회했던 allOpenYn 값을 아래에는 자바의 스트림으로 매칭해 값을 반환해주었다. 

private List<CourseContentInfoDto> getCombinedCourseList(Long courseId, Map<String, Object> result){
    List<CourseContentInfoDto> fullList = new ArrayList<>();
    // 1. 모든 차시(뼈대) 정보를 가져옴
    List<CourseContentInfoDto> lessons = learningMapper.selectLessonStructure(courseId);
    // 2.  allOpenYn 계산 (모든 차시의 lessonOpenYn이 'Y'인지 체크)
    String allOpenYn = lessons.isEmpty() ? "N" : 
        lessons.stream().allMatch(l -> "Y".equals(l.getLessonOpenYn())) ? "Y" : "N";
    result.put("allOpenYn", allOpenYn); // 결과 맵에 바로 저장
    fullList.addAll(learningMapper.selectLectureDetails(courseId));  // A. 영상, HTML 콘텐츠 가져오기 
    fullList.addAll(learningMapper.selectBoardContents(courseId)); // B. 과제, 토론 콘텐츠 가져오기 
    fullList.addAll(learningMapper.selectExamDetails(courseId));     // C. 시험, 퀴즈 콘텐츠 가져오기  
    List<CourseContentInfoDto> emptySessions = learningMapper.selectEmptySessions(courseId);     // D. 아무 콘텐츠도 없는 '빈 차시' 정보
    fullList.addAll(emptySessions);
    fullList.forEach(CourseContentInfoDto::updateMetaData);

    // 주차순 -> 차시순 -> 차시 내 순서(seq) 순으로 정렬
    return fullList.stream()
            .sorted(Comparator.comparing(CourseContentInfoDto::getLessonSort, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(CourseContentInfoDto::getSessionSort, Comparator.nullsLast(Integer::compareTo))
                .thenComparing(CourseContentInfoDto::getMappingSort, Comparator.nullsLast(Integer::compareTo)))
            .collect(Collectors.toList());
}

 

[handleEmptyCourseList & handlePopulatedCourseList]

해당 주차의 차시 데이터가 없을때와 있을 때의함수이다.  

더보기
//데이터가 없을 때 
private void handleEmptyCourseList(Map<String, Object> result, CourseDetailDto courseInfo) {
    // ==========  콘텐츠가 하나도 없을 때의 처리 ==========
    result.put("groupedCourses", new HashMap<>()); // 비어있는 맵을 전달하여 th:if에서 걸러지도록 함
    result.put("maxLessonSort", 0);
    result.put("allOpenYn", "N");
    result.put("newContent", "Y"); // 새 콘텐츠 생성이 필요한 상태로 설정

    if (courseInfo != null) {
        LocalDateTime courseStartAt = courseInfo.getStartAt() != null ? courseInfo.getStartAt() : LocalDateTime.now();
        LocalDateTime courseEndAt = courseInfo.getEndAt() != null ? courseInfo.getEndAt() : LocalDateTime.now();

        long weeks = calculateWeeks(courseStartAt, courseEndAt);
        result.put("weeks", weeks);
        result.put("departmentId", courseInfo.getDepartmentId());
        result.put("courseStartAt", courseStartAt);
        result.put("courseEndAt", courseEndAt);
    } else {
        result.put("weeks", 0);
        result.put("departmentId", null);
        result.put("courseStartAt", LocalDateTime.now());
        result.put("courseEndAt", LocalDateTime.now());
        }

}

//데이터가 있을 떄 
private void handlePopulatedCourseList(Map<String, Object> result, List<CourseContentInfoDto> courseList, CourseDetailDto courseInfo) {
      // ========== 2. 콘텐츠가 하나 이상 있을 때의 처리 ==========
    //날짜 결정 
    LocalDateTime courseStartAt = (courseInfo != null) ? courseInfo.getStartAt() : null;
    LocalDateTime courseEndAt = (courseInfo != null) ? courseInfo.getEndAt() : null;

    // 만약 학기 날짜가 없다면 오늘 날짜로 기본값 (에러 방지)
    if (courseStartAt == null) courseStartAt = LocalDateTime.now();
    if (courseEndAt == null) courseEndAt = LocalDateTime.now(); 
    long weeks = calculateWeeks(courseStartAt, courseEndAt);

    result.put("weeks", weeks);
    result.put("departmentId", courseList.get(0).getDepartmentId());
    result.put("courseStartAt", courseStartAt);
    result.put("courseEndAt", courseEndAt);
    for (CourseContentInfoDto list : courseList) {
        if (list == null) continue;
        list.updateMetaData();
    }

    Map<Integer, Map<Integer, List<CourseContentInfoDto>>> groupedCourses = courseList.stream()
            .filter(Objects::nonNull)
            .collect(Collectors.groupingBy(
                    list -> list.getLessonSort() != null ? list.getLessonSort() : 0,
                    TreeMap::new,
                    Collectors.groupingBy(
                            list -> list.getSessionSort() != null ? list.getSessionSort() : 1,
                            TreeMap::new,
                            Collectors.toList()
                    )
            ));

    Integer maxLessonSort = courseList.stream()
            .filter(Objects::nonNull)
            .map(list -> list.getLessonSort() != null ? list.getLessonSort() : 0)
            .max(Integer::compareTo).orElse(0);

    result.put("maxLessonSort", maxLessonSort);
    result.put("groupedCourses", groupedCourses); 
    result.put("newContent", "N");
}

[calculateWeeks]

위에 handleEmptyCourseList & handlePopulatedCourseList에서 사용하는 공통된 주차 계산 코드이다. 

// 주차 계산 
private long calculateWeeks(LocalDateTime courseStartAt, LocalDateTime courseEndAt) {
    long days = ChronoUnit.DAYS.between(courseStartAt.toLocalDate(), courseEndAt.toLocalDate()) + 1;
    return  (days > 0) ? (long) Math.ceil(days / 7.0) : 0;
}

 

[formatScheduleString]

이 부분은 위 캡쳐본에는 없지만 화면에 예를들어 화: 5 교시, 수: 6 교시, 목: 5,6 교시 이런식으로 표시된 수업 시간이 있다. courses_schedule에 수업 : 시간표 = 일 : 다 (일대다) 로 저장된 값을 요일과 교시로 포맷후 반환해주는 함수이다. 

더보기
// 학기, 수업방식
private String formatScheduleString(List<CoursesScheduleDto> courseSchedule) {
    if (courseSchedule != null && !courseSchedule.isEmpty()) {
        // 1. 요일(weekday)을 기준으로 교시(period)들을 그룹화 (TreeMap: 요일 순으로 자동 정렬)
        Map<Integer, List<Integer>> scheduleMap = new TreeMap<>();
        for (CoursesScheduleDto schedule : courseSchedule) {
            scheduleMap.computeIfAbsent(schedule.getWeekday(), k -> new ArrayList<>()).add(schedule.getPeriod());
        }

        // 2. 그룹화된 데이터를 "월: 1,2 교시" 형태의 문자열로 변환
        String[] dayNames = {"월", "화", "수", "목", "금", "토", "일"};
        StringJoiner finalScheduleString = new StringJoiner(", "); // 최종 문자열을 ", "로 연결 

        for (Map.Entry<Integer, List<Integer>> entry : scheduleMap.entrySet()) {
            int weekday = entry.getKey();
            List<Integer> periods = entry.getValue();
            Collections.sort(periods); // 교시 오름차순 정렬

            // "1,2,3" 형태의 문자열로 변환 
            String periodsStr = periods.stream()
                                       .map(String::valueOf)
                                       .collect(Collectors.joining(","));

            // weekday(1~7)를 배열 인덱스(0~6)에 맞게 -1 처리
            String dayName = dayNames[weekday - 1]; 

            finalScheduleString.add(String.format("%s: %s 교시", dayName, periodsStr));
        }
 
        return finalScheduleString.toString();
    } else {
        return "시간 정보 없음";
    }
}

[Dto > updateMetaData()]

상태값에 따른 메타데이터(아이콘, 타이틀 등) 가공 로직을 DTO 내부 메소드(updateMetaData)로 캡슐화했습니다. 덕분에 서비스 로직이 훨씬 간결해졌고 재사용성도 확보했습니다.

더보기
 public void updateMetaData() {
     if(this.contentType == null) return ; 

     switch(this.contentType) {
         case "A" -> { // ASSIGNMENT
                this.iconType = "ic-lesson-task";
                this.contentTypeText = "assign";
                this.lessonStartAt = this.startAt;
                this.lessonEndAt = this.endAt;
            }
            case "V" -> { // VIDEO
                this.iconType = "ic-lesson-video";
                this.contentTypeText = "video";
                this.title = this.contentName;
            }
            case "H" -> { // HTML
                this.iconType = "ic-lesson-html";
                this.contentTypeText = "html";
                this.title = this.contentName;
            }
            case "Q" -> { // QUIZ
                this.iconType = "ic-lesson-quiz";
                this.contentTypeText = "quiz";
                this.title = "퀴즈";
                this.startAt = this.examStartAt;
                this.endAt = this.examEndAt;
            }
            case "E", "M", "F" -> { // EXAM, MIDTER, FINAL 
                this.iconType = "ic-lesson-quiz";
                this.contentTypeText = "exam";
                this.startAt = this.examStartAt;
                this.endAt = this.examEndAt;
                this.title = "M".equals(this.examTitle) ? "중간고사" : 
                             "F".equals(this.examTitle) ? "기말고사" : "시험";
            }
            case "D" -> { // DISCUSSION
                this.iconType = "ic-lesson-discussion";
                this.contentTypeText = "discu";
                this.title = this.disTitle;
                this.lessonStartAt = this.disStartAt;
                this.lessonEndAt = this.disEndAt;
            }
            default -> { // 화상회의(Meeting) 등 나머지
                this.iconType = "ic-lesson-live";
                this.contentTypeText = "meeting";
                this.title = this.contentName;
            }
        }
 }

프론트

프론트는 1400줄 가량이 되기 때문에 굳이 복붙은 안하고 어떤 함수를 어떻게 분리시켰나 위주로만 작성하겠습니다 

위 이미지에는 없지만 이 버튼을 선택 toogle하면 

이렇게 편집, 상세화면 모드로 변경됩니다. 화면 그리는 모든 코드가 renderEditMode, renderViewMode  함수에 다 몰려있어 수정의 어려움이 있습니다.  

 

기존 - [편집모드 코드인 renderEditMode]

모든 화면을 그리는 수많은 벡틱과 심지어는 모달까지 호출하고 있습니다. 수정하려고 시도해도 보고 뒤돌면 어디였는지 위치를 까먹는 마법의 코드였습니다. 

더보기
/** 편집 모드(data-edit="on") HTML 및 관련 모달 HTML 생성 */
function renderEditMode(groupedCourses, courseType, courseId, lecturerId) {
    let html = '';
    let modalHtml = '';
    console.log("groupedCourses :",groupedCourses)
    if (!groupedCourses || !courseType) return { html, modalHtml };

    const sortedWeeks = Object.keys(groupedCourses).sort((a, b) => parseInt(a) - parseInt(b));

    for (const weekKey of sortedWeeks) {
        const weekData = groupedCourses[weekKey];
        if (!weekData || Object.keys(weekData).length === 0) continue;

        const firstSessionList = getNested(weekData, Object.keys(weekData)[0]);
        const weekInfo = getNested(firstSessionList, 0); // 주차 정보 (첫 차시의 첫 항목)
        if (!weekInfo) continue;

        html += `
            <div class="lec-box lec-week accordion-item">
                <div class="week-info-top accordion-header">
                    <div class="week-title">
                        <button type="button" class="btn-accordion" id="accordionHeader${weekKey}" aria-controls="accordionCollapse${weekKey}">
                            <span class="sr-only">열기</span>
                        </button>
                        <div class="week-info">${weekInfo.lessonName || `${weekKey}주차`}</div>
                    </div>
                    
                    <div class="lec-util">
                        <button type="button" class="icon-button ic-edit" onclick="openForm('sessionModifyModal_${weekInfo.lessonId}')">
                            <span class="sr-only">편집</span>
                        </button>
                        <button type="button" class="icon-button ic-trash" onclick="removeData('${weekInfo.lessonId}', 'session')">
                            <span class="sr-only">삭제</span>
                        </button>
                        <div class="form-check form-switch">
                            <label class="form-check-label" for="sessionSwitch_${weekInfo.lessonId}">공개</label>
                            <input class="form-check-input" type="checkbox" role="switch"
                                   id="sessionSwitch_${weekInfo.lessonId}"
                                   ${weekInfo.lessonOpenYn == 'Y' ? 'checked' : ''}
                                   onchange="changePublicYn('${weekInfo.lessonId}', 'session', this.checked)">
                        </div>
                    </div>
                    </div>
                
                <div class="accordion-collapse" id="accordionCollapse${weekKey}" aria-labelledby="accordionHeader${weekKey}">
            `;

        const sortedSessions = Object.keys(weekData).sort((a, b) => parseInt(a) - parseInt(b));
        for (const sessionKey of sortedSessions) {
            const sessionEntry = weekData[sessionKey];
            const sessionInfo = getNested(sessionEntry, 0); // 차시 정보
            if (!sessionInfo) continue;
			
			const currentLecturerId = sessionInfo.lecturerId || lecturerId; // 차시별 강사가 없을 경우, 전체 강사 ID 사용

			html += `
                <div class="lec-lesson">
                    <div class="lesson-info-top">
                        <div class="lesson-title">
                            <div class="lesson-info">${sessionInfo.lessonName || `${sessionKey}차시`}</div>
                            <div class="period-info">
                                ${sessionInfo.sessionDate ? formatArrayDate(sessionInfo.sessionDate) : ''}
                                ${sessionInfo.sessionPeriod ? ` - ${sessionInfo.sessionPeriod}교시` : ''}
                            </div>
                        </div>
                        </div>
            `;

            if (sessionInfo.courseClsType == 153 || sessionInfo.courseClsType == 154) {
                html += `<div class="no-class-notice"><span>휴강</span></div>`;
            } else {
                html += `
                <div class="lesson-info-list">
                    <div class="lesson-info-box dragdrop">
                        <div class="lesson-info-box dragdrop-box"
                             data-week="${sessionInfo.lessonSort}" 
                             data-session="${sessionInfo.sessionSort}"
                             data-lecture-id="${sessionInfo.lessonId}">
                `;
                
             // ... (sessionEntry.forEach 시작) ...
                sessionEntry.forEach(contentItem => {
                    //  contentId가 null이 아닌 모든 항목을 그리도록 수정
                    if (contentItem && contentItem.contentId != null) { 
                    
                    	const iconClass = (getNested(contentItem, 'iconType') || '').replace('ic-', '');
						
						// 편집 버튼/링크 생성
						let editButtonHtml = '';
						const contentType = contentItem.contentTypeText;
						const editUrlBase = `/admin/learning/learningManage/learningContentSet/AddCourseContentDetail`;
					    const examEditScript = `window.appFilters.save({ status: 'D', examId: ${contentItem.examId}, lectId: ${sessionInfo.lessonId}, week: ${sessionInfo.lessonSort}, lecturerId: ${currentLecturerId}, courseId: ${courseId}, hrefStatus: 'T' }); window.location.href='/admin/learning/courseExamManage/courseExamManageDetail/${contentItem.examId}';`;
							
						if (contentType === 'assign') { //과제 
							editButtonHtml = `<button type="button" class="icon-button ic-edit" 
													onclick="window.appFilters.save({ status: 'D',hrefStatus:'C', assignmentId: ${contentItem.assignId}, courseId: ${courseId} }); window.location.href='/admin/learning/assignmentManage/assignmentManageDetail';">
												<span class="sr-only">편집</span>
											  </button>`;
						} else if (contentType === 'discu') { //토론
							editButtonHtml = `<button type="button" class="icon-button ic-edit" 
													onclick="window.appFilters.save({ status: 'D', hrefStatus:'C', discussionId: ${contentItem.disId}, courseId: ${courseId} }); window.location.href='/admin/learning/discussionManage/discussionManageDetail';">
												<span class="sr-only">편집</span>
											  </button>`;
						} else if (contentType === 'exam' || contentType === 'quiz') {
							editButtonHtml = `<button type="button" class="icon-button ic-edit" onclick="${examEditScript}"><span class="sr-only">편집</span></button>`;
	                 	} else if (['html', 'video', 'meeting'].includes(contentType)) {
                            // 영상/HTML 등은 lessonMappingId를 사용
                            const videoHtmlParams = `/${courseId}/${sessionInfo.lessonId}/${weekInfo.lessonSort}/${contentItem.mappingSort}/${contentItem.lessonMappingId}`;
							editButtonHtml = `<button type="button" class="icon-button ic-edit" onclick="location.href='${editUrlBase}/${contentType}${videoHtmlParams}'"><span class="sr-only">편집</span></button>`;
						}

                        let publicToggleHtml = '';
                        if (contentType !== 'assign' && contentType !== 'discu' &&  contentType !== 'exam' &&  contentType !== 'quiz') {
                             const uniqueContentId = contentItem.lessonMappingId || contentItem.contentId;
                            
                            publicToggleHtml = `
                                <div class="form-check form-switch">
                                    <label class="form-check-label" for="clsOpenSwitch_${uniqueContentId}">공개</label>
                                    <input class="form-check-input" ${contentItem.clsOpenYn == 'Y' ? 'checked' : ''} onchange="changePublicYn('${uniqueContentId}','class', this.checked, '${contentItem.mappingSort}')" type="checkbox" role="switch" id="clsOpenSwitch_${uniqueContentId}">
                                </div>
                            `;
                        }

                        let handleHtml = '';
                     /*    if (contentType !== 'assign' && contentType !== 'discu' &&  contentType !== 'exam' &&  contentType !== 'quiz') {
                            handleHtml = '<span class="handle"></span>';
                        } */
						
                        html += `
                        <div class="item"
                             data-lecture-id="${contentItem.lessonId}" data-original-seq="${contentItem.mappingSort}">
                            <div class="item-info">
                                ${handleHtml} <div class="lesson-icon ${iconClass}">
                               		 <div class="lesson-icon-item ${getNested(contentItem, 'iconType') || ''}">
                               			<span class="sr-only">${getNested(contentItem, 'contentType') || ''}</span>
                                    </div>
                                </div>
                                <div class="lesson-name-box">
                                     <div class="lesson-name">${getNested(contentItem, 'title') || getNested(contentItem, 'contentName') || getNested(contentItem, 'disTitle') || ''}</div>
                                    <div class="lessong-name-detail">
                                        <span>
                                        ${contentItem.lessonStartAt && contentItem.lessonEndAt ? 
                                            `${formatDateTime(contentItem.lessonStartAt)} ~ ${formatDateTime(contentItem.lessonEndAt)}` : ''}  
                                        </span> 
                                    </div>
                                </div>
                            </div>
                            <div class="item-util">
                                <div class="lec-util"> 
                                    ${editButtonHtml}
                                     <button type="button" class="icon-button ic-trash" onclick="removeData('${ contentItem.contentId || contentItem.lessonMappingId}','${contentType}', '${contentItem.mappingSort}')">
                                        <span class="sr-only">삭제</span>
                                    </button>
                                    
                                    ${publicToggleHtml} 
                                    
                                </div>
                            </div>
                        </div>
                        `;
                    } // if (contentItem.contentId != null) 끝
                }); // forEach 끝
                
                const addUrlBase = `/admin/learning/learningManage/learningContentSet/AddCourseContentDetail`;
                const commonAddParams = `/${courseId}/${sessionInfo.lessonId}/${weekInfo.lessonSort}/${sessionKey}/0`;
                const commonSaveJs = `window.appFilters.save({ status: 'W', courseId: ${courseId}, lectId: ${sessionInfo.lessonId}, week: ${weekInfo.lessonSort}, session: ${sessionKey}, contentId: 0 })`;
	
	             // 각각의 스크립트 생성
	             const meetingScript = `${commonSaveJs}; window.location.href='${addUrlBase}/meeting${commonAddParams}';`;
	             const videoScript   = `${commonSaveJs}; window.location.href='${addUrlBase}/video${commonAddParams}';`;
	             const htmlScript    = `${commonSaveJs}; window.location.href='${addUrlBase}/html${commonAddParams}';`;	const examAddUrl = `window.appFilters.save({ status: 'W', lectId : ${sessionInfo.lessonId}, week : ${weekInfo.lessonSort}, lecturerId : ${weekInfo.lecturerId}, courseId: ${courseId}, hrefStatus : 'T' }); window.location.href='/admin/learning/courseExamManage/courseExamManageDetail/0';`;
				const assignAddScript = `window.appFilters.save({ status: 'W',hrefStatus:'C', assignmentId: 0, week : ${weekInfo.lessonSort}, session : ${sessionInfo.sessionSort}, courseId: ${courseId} }); window.location.href='/admin/learning/assignmentManage/assignmentManageDetail';`;
				const discuAddScript = `window.appFilters.save({ status: 'W', discussionId: 0,hrefStatus:'C', week : ${weekInfo.lessonSort}, session : ${sessionInfo.sessionSort}, courseId: ${courseId} }); window.location.href='/admin/learning/discussionManage/discussionManageDetail';`;
				 html += `
                        </div>
                    </div>
                </div>
				<div class="add-lesson mb-2">
					<div class="add-lesson-inner">
					<div class="add-lesson-item">
		            	<a href="javascript:void(0);" onclick="${meetingScript}" class="add-lesson-btn ic-lesson-video">
				                <span>실시간<br>화상강의</span>
				            </a>
				        </div>
				        <div class="add-lesson-item">
				            <a href="javascript:void(0);" onclick="${videoScript}" class="add-lesson-btn ic-lesson-video">
				                <span>영상</span>
				            </a>
				        </div>
				        <div class="add-lesson-item">
				            <a href="javascript:void(0);" onclick="${htmlScript}" class="add-lesson-btn ic-lesson-html">
				                <span>html</span>
				            </a>
				        </div>
						<div class="add-lesson-item">
							<a href="javascript:void(0);" onclick="${examAddUrl}" class="add-lesson-btn ic-lesson-quiz">
								<span>퀴즈/시험</span>
							</a>
						</div>
						<div class="add-lesson-item">
                            <a href="javascript:void(0);" onclick="${assignAddScript}" class="add-lesson-btn ic-lesson-task">
								<span>과제</span>
							</a>
						</div>
						<div class="add-lesson-item">
                            <a href="javascript:void(0);" onclick="${discuAddScript}" class="add-lesson-btn ic-lesson-discussion">
								<span>토론</span>
							</a>
						</div>
					</div>
				</div>
                `;
            }
            html += `</div>`; // .lec-lesson

            // --- 이 차시에 대한 수정 모달 HTML 생성 ---
			const courseTypeOptions = courseType.map(cls => 
				`<option value="${cls.id}" ${cls.id == sessionInfo.courseClsType ? 'selected' : ''}>${cls.codeName}</option>`
			).join('');
			
            modalHtml += `
            <section id="sessionModifyModal_${sessionInfo.lessonId}" class="modal fade in" aria-hidden="false" role="dialog">
                <div class="modal-dialog modal-sm modal-dialog-centered">
                    <div class="modal-content" tabindex="0">
                        <div class="modal-header">
                            <h2 class="modal-title">${weekKey}주차 ${sessionKey}차시 수정</h2>
                        </div>
                        <div class="modal-conts">
                            <div class="d-flex mb-2">
                                <h3 class="b-tit flex-fill me-4 mt-2" style="min-width: 60px;">차시명</h3>
                                <input type="text" class="form-control sessionDesc" value="${sessionInfo.lessonName || ''}" />
                            </div>
                            <div class="d-flex mb-2">
                                <h3 class="b-tit flex-fill me-4 mt-2" style="min-width: 60px;">공개여부</h3>
                                <select class="form-select sessionPublicYn">
                                    <option value="Y" ${sessionInfo.lessonOpenYn == 'Y' ? 'selected' : ''}>공개</option>
                                    <option value="N" ${sessionInfo.lessonOpenYn == 'N' ? 'selected' : ''}>비공개</option>
                                </select>
                            </div>
                            <div class="d-flex">
                                <h3 class="b-tit flex-fill me-4 mt-2" style="min-width: 60px;">수업방식</h3>
                                <select class="form-select sessionCourseClsType">
                                    ${courseTypeOptions}
                                </select>
                            </div>
                        </div>
                        <div class="modal-btn btn-wrap">
                            <button type="button" class="btn btn-primary" onclick="sessionModify('${sessionInfo.lessonId}')">설정</button>
                            <button type="button" class="btn close-modal" onclick="closeForm('sessionModifyModal_${sessionInfo.lessonId}')">취소</button>
                        </div>
                        <button type="button" class="btn-close close-modal" onclick="closeForm('sessionModifyModal_${sessionInfo.lessonId}')">
                            <span class="sr-only">닫기</span>
                        </button>
                    </div>
                </div>
                <div class="modal-back in"></div>
            </section>
            `;
        }
        html += `</div></div>`; // .accordion-collapse, .lec-box
    }
    
    return { html, modalHtml };
}

기존 -   [뷰 모드인 renderViewMode 함수]

편집모드보다는 당연히 짧지만 여전히 가독성은 별로입니다. 

더보기
/** 뷰 모드(data-edit="off") HTML 생성 */
function renderViewMode(groupedCourses) {
    let html = '';
    if (!groupedCourses) return html;
    
    const sortedWeeks = Object.keys(groupedCourses).sort((a, b) => parseInt(a) - parseInt(b));

    for (const weekKey of sortedWeeks) {
        const weekData = groupedCourses[weekKey];
        if (!weekData || Object.keys(weekData).length === 0) continue;

        const firstSession = getNested(weekData, Object.keys(weekData)[0]);
        const weekInfo = getNested(firstSession, 0);
        if (!weekInfo) continue;

        html += `
        <div class="lec-box lec-week">
            <div class="week-info-top">
                <div class="week-title">
                    <div class="week-info">${weekInfo.lessonName || `${weekKey}주차`}</div>
                    <div class="period-info">
                        ${weekInfo.lessonStartAt && weekInfo.lessonEndAt ? 
                            `${formatDateTime(weekInfo.lessonStartAt)} ~ ${formatDateTime(weekInfo.lessonEndAt)}` : ''}
                    </div>
                </div>
            </div>
        `;

        const sortedSessions = Object.keys(weekData).sort((a, b) => parseInt(a) - parseInt(b));
        for (const sessionKey of sortedSessions) {
            const sessionEntry = weekData[sessionKey];
            const sessionInfo = getNested(sessionEntry, 0);
            if (!sessionInfo) continue;

            html += `
            <div class="lec-lesson">
                <div class="lesson-info-top">
                    <div class="lesson-title">
                        <div class="lesson-info">${sessionInfo.lessonName || `${sessionKey}차시`}</div>
                        <div class="period-info">
                            ${sessionInfo.sessionDate ? formatArrayDate(sessionInfo.sessionDate) : ''}
                            ${sessionInfo.sessionPeriod ? ` - ${sessionInfo.sessionPeriod}교시` : ''}
                        </div>
                    </div>
                </div>
            `;

            if (sessionInfo.courseClsType == 153 || sessionInfo.courseClsType == 154) {
                html += `<div class="no-class-notice"><span>휴강</span></div>`;
            } else {
                html += `
                <div class="lesson-info-list">
                    <div class="lesson-info-box dragdrop">
                        <div class="lesson-info-box dragdrop-box">
                `;
                
                sessionEntry.forEach(contentItem => {
                    if (contentItem && contentItem.lessonMappingId != null) {
                    	const iconClass = (getNested(contentItem, 'iconType') || '').replace('ic-', '');
                        let statusLabel = '';
                        const startStr = contentItem.lessonStartAt || contentItem.startAt; 
                        const endStr = contentItem.lessonEndAt || contentItem.endAt;

                        if (startStr && endStr) {
                            const now = new Date();
                            const start = new Date(startStr);
                            const end = new Date(endStr);

                            if (now > end) {
                                statusLabel = '<span class="text-label label-end">종료</span>';
                            } else if (now >= start && now <= end) {
                                statusLabel = '<span class="text-label label-ing">진행중</span>';
                            } else if (now < start) {
                                statusLabel = '<span class="text-label label-wait">대기중</span>';
                            }
                        }
                    	
                        html += `
                        <div class="item">
                            <div class="item-info">
                                <div class="lesson-icon ${iconClass}">
                                	<div class="lesson-icon-item ${getNested(contentItem, 'iconType') || ''}">
                                		<span class="sr-only">${getNested(contentItem, 'contentType') || ''}</span>
                                    </div>
                                </div>
                                <div class="lesson-name-box">
                                    <div class="lesson-name">${getNested(contentItem, 'title') || getNested(contentItem, 'contentName') || ''}</div>
                                    <div class="lessong-name-detail">
                                        <span>
                                        ${contentItem.lessonStartAt && contentItem.lessonEndAt ? 
                                            `${formatDateTime(contentItem.lessonStartAt)} ~ ${formatDateTime(contentItem.lessonEndAt)}` : ''}
                                        </span> 
                                    </div>
                                </div>
                            </div>
                            <div class="item-util">
                           		 ${statusLabel}
                            </div>
                        </div>
                        `;
                    }
                });
                
                html += `
                        </div>
                    </div>
                </div>
                `;
            }
            html += `</div>`; // .lec-lesson
        }
        html += `</div>`; // .lec-box
    }
    return html;
}

 

수정 - [리팩토링 진행]

기능별로 조립 가능한 형태로 잘게 쪼개어, 이제는 특정 UI를 고치기 위해 수천 줄의 백틱 지옥을 헤매지 않아도 되도록 개선했습니다.

 

[getContentStatus]

우선 renderViewMode 함수에 있는 진행중, 대기중, 종료 라벨의 상태 함수를 따로 빼주었습니다. 

// 콘텐츠 상태 라벨 생성 (진행중/종료/대기) 
function getContentStatus(startStr, endStr) {
    if (!startStr || !endStr) return '';
    const now = new Date();
    const start = new Date(startStr);
    const end = new Date(endStr);

    if (now > end) return '<span class="text-label label-end">종료</span>';
    if (now >= start && now <= end) return '<span class="text-label label-ing">진행중</span>';
    return '<span class="text-label label-wait">대기중</span>';
}

 

   

[renderAddButtons & getContentAction]

renderEditMode 함수에 링크로 넘어가느 부분도 따로 빼두었습니다. 이 부분도 꽤 긴 HTML을 차지하고 있습니다. 

(각각 아래 이미지에 해당하는 부분입니다 )

더보기
// 하단 추가 버튼 영역 렌더링 
function renderAddButtons(courseId, sessionInfo, weekInfo, sessionKey, lecturerId) {
    const addUrlBase = `/admin/learning/learningManage/learningContentSet/AddCourseContentDetail`;
    const commonParams = `/${courseId}/${sessionInfo.lessonId}/${weekInfo.lessonSort}/${sessionKey}/0`;
    
    const save = (status, extra = {}) => {
        const state = { status, courseId, lectId: sessionInfo.lessonId, week: weekInfo.lessonSort, session: sessionKey, contentId: 0, ...extra };
        return `window.appFilters.save(${JSON.stringify(state)})`;
    };

    return `
        <div class="add-lesson mb-2">
            <div class="add-lesson-inner">
                <div class="add-lesson-item"><a href="javascript:void(0);" onclick='${save("W")}; location.href="${addUrlBase}/meeting${commonParams}"' class="add-lesson-btn ic-lesson-video"><span>실시간<br>화상강의</span></a></div>
                <div class="add-lesson-item"><a href="javascript:void(0);" onclick='${save("W")}; location.href="${addUrlBase}/video${commonParams}"' class="add-lesson-btn ic-lesson-video"><span>영상</span></a></div>
                <div class="add-lesson-item"><a href="javascript:void(0);" onclick='${save("W")}; location.href="${addUrlBase}/html${commonParams}"' class="add-lesson-btn ic-lesson-html"><span>html</span></a></div>
                <div class="add-lesson-item"><a href="javascript:void(0);" onclick='${save("W", { lecturerId, hrefStatus: "T" })}; location.href="/admin/learning/courseExamManage/courseExamManageDetail/0"' class="add-lesson-btn ic-lesson-quiz"><span>퀴즈/시험</span></a></div>
                <div class="add-lesson-item"><a href="javascript:void(0);" onclick='${save("W", { hrefStatus: "C", assignmentId: 0 })}; location.href="/admin/learning/assignmentManage/assignmentManageDetail"' class="add-lesson-btn ic-lesson-task"><span>과제</span></a></div>
                <div class="add-lesson-item"><a href="javascript:void(0);" onclick='${save("W", { hrefStatus: "C", discussionId: 0 })}; location.href="/admin/learning/discussionManage/discussionManageDetail"' class="add-lesson-btn ic-lesson-discussion"><span>토론</span></a></div>
            </div>
        </div>`;
}

//콘텐츠 타입별 편집 버튼의 스크립트(onclick)를 생성하는 함수
function getContentAction(item, sessionInfo, weekInfo, courseId, currentLecturerId) {
	 const contentType = item.contentTypeText;
	 const lectId = sessionInfo.lessonId;
	 const week = weekInfo.lessonSort;
	 
	 // 1. 과제 (Assignment)
	 if (contentType === 'assign') {
	     const state = { 
	         status: 'D', 
	         hrefStatus: 'C', 
	         assignmentId: item.assignId, 
	         courseId: courseId 
	     };
	     return `window.appFilters.save(${JSON.stringify(state)}); window.location.href='/admin/learning/assignmentManage/assignmentManageDetail';`;
	 }
	
	 // 2. 토론 (Discussion)
	 if (contentType === 'discu') {
	     const state = { 
	         status: 'D', 
	         hrefStatus: 'C', 
	         discussionId: item.disId, 
	         courseId: courseId 
	     };
	     return `window.appFilters.save(${JSON.stringify(state)}); window.location.href='/admin/learning/discussionManage/discussionManageDetail';`;
	 }
	
	 // 3. 시험 및 퀴즈 (Exam / Quiz)
	 if (contentType === 'exam' || contentType === 'quiz') {
	     const state = { 
	         status: 'D', 
	         examId: item.examId, 
	         lectId: lectId, 
	         week: week, 
	         lecturerId: currentLecturerId, 
	         courseId: courseId, 
	         hrefStatus: 'T' 
	     };
	     return `window.appFilters.save(${JSON.stringify(state)}); window.location.href='/admin/learning/courseExamManage/courseExamManageDetail/${item.examId}';`;
	 }
	
	 // 4. 영상, HTML, 화상회의 (video, html, meeting)
	 if (['video', 'html', 'meeting'].includes(contentType)) {
	     // 이 타입들은 별도의 필터 저장 없이 URL 파라미터로 처리하는 구조
	     const editUrl = `/admin/learning/learningManage/learningContentSet/AddCourseContentDetail/${contentType}/${courseId}/${lectId}/${week}/${item.mappingSort}/${item.lessonMappingId}`;
	     return `location.href='${editUrl}';`;
	 }
	
	 return ''; // 정의되지 않은 타입일 경우 빈 문자열 반환
}

 

[renderSessionModal]

마지막으로 모달도 따로 빼주었습니다.  

// 차시 수정 모달 렌더링 
function renderSessionModal(weekKey, sessionKey, sessionInfo, courseType) {
    const options = courseType.map(cls => `<option value="${cls.id}" ${cls.id == sessionInfo.courseClsType ? 'selected' : ''}>${cls.codeName}</option>`).join('');
    return `
        <section id="sessionModifyModal_${sessionInfo.lessonId}" class="modal fade in">
            <div class="modal-dialog modal-sm modal-dialog-centered">
                <div class="modal-content">
                    <div class="modal-header"><h2 class="modal-title">${weekKey}주차 ${sessionKey}차시 수정</h2></div>
                    <div class="modal-conts">
                        <div class="d-flex mb-2"><h3 class="b-tit" style="min-width:60px">차시명</h3><input type="text" class="form-control sessionDesc" value="${sessionInfo.lessonName || ''}" /></div>
                        <div class="d-flex mb-2"><h3 class="b-tit" style="min-width:60px">공개여부</h3><select class="form-select sessionPublicYn"><option value="Y" ${sessionInfo.lessonOpenYn == 'Y' ? 'selected' : ''}>공개</option><option value="N" ${sessionInfo.lessonOpenYn == 'N' ? 'selected' : ''}>비공개</option></select></div>
                        <div class="d-flex"><h3 class="b-tit" style="min-width:60px">수업방식</h3><select class="form-select sessionCourseClsType">${options}</select></div>
                    </div>
                    <div class="modal-btn btn-wrap">
                        <button type="button" class="btn btn-primary" onclick="sessionModify('${sessionInfo.lessonId}')">설정</button>
                        <button type="button" class="btn close-modal" onclick="closeForm('sessionModifyModal_${sessionInfo.lessonId}')">취소</button>
                    </div>
                    <button type="button" class="btn-close close-modal" onclick="closeForm('sessionModifyModal_${sessionInfo.lessonId}')"></button>
                </div>
            </div>
            <div class="modal-back in"></div>
        </section>`;
}

 

 

이로써 드디어 리팩토링이 완료되었습니다.  

 

마지막으로... : 트러블슈팅

발견했던 오류를 작성해보자면 

새로고침 시 getInitCourseLearningSet  API 가 3번이나 호출되는 오류가 있었습니다. 

원인부터 파악을 해보았을 때 이렇게 화면 초기화시 호출해주는 코드가 2개 있는것을 볼 수 있었습니다.  

 

그래서 중복되는 1번 코드를 지워줬고 3번의 호출 중 한번은 줄었지만 여전히 호출은 두번이 되는 상황이 지속되었습니다.

참고로 2번에 있는 departmentSelected 이벤트 핸들러 는 레이아웃의 공통 필터검색의 이벤트 핸들러 입니다. 

[공통 필터 이미지]

그래서 departmentSelected 이벤트 핸들러 코드를 확인해 보았습니다.코드의 맨 아래에 초기실행 순서 라면서 3개의 함수가 실행되는 것을 볼 수있었습니다

// ---------------- 초기 실행 순서 ----------------
await getLearningYears();		
await getTopDepartments();		
await loadCourses();

 

getLearningYears() 함수에서 연도 초기화 한번 하고 loadCourses() 함수에서 강의 초기화를 한번해서 메인화면코드에서 받아오는 콘솔값을 확인해보면 source 값을 source: "yearInit", source: "courseLoaded"    각각 두개를 받아오는것을 볼수있었습니다. 이는 페이지 로드 시 필터 컴포넌트들이 초기화되며 각각 이벤트를 발생시키는 이벤트 폭포현상이었습니다.

 

공통 컴포넌트의 규격을 유지하면서 문제를 해결하기 위해, 메인 페이지 리스너에서 이벤트 소스(source)를 판별하는 가드 로직 if (detail.source === 'yearInit' ) return; 을 추가하여 불필요한 API 호출을 차단했습니다 

결론적으로 한번만 호출되는거 확인 

이로인해 불필요한 네트워크 요청을 차단하여 서버 자원을 절약하고, 사용자에게 불필요한 '화면 깜빡임'을 제거했습니다. 

끝! 

생각보다 재미있고 배울 점이 많았던 리팩토링 시간이었습니다. 사실 전부터 계속 수정하고 싶었던 코드였는데, 매번 바쁘다는 핑계로 미루다 보니 어느새 손대기 무서운 복잡한 코드가 되어 있었습니다. 오랜만에 각 잡고 하나하나 뜯어보니 생각보다 더 얽혀 있어서 작업 시간이 꽤 걸렸습니다.

코드 길이가 드라마틱하게 줄어들 거라는 기대와 달리, 막상 다 고치고 보니 줄 수 자체는 큰 차이가 없었습니다. 하지만 단순히 짧은 코드보다 읽기 쉽고 유지보수가 가능한 코드가 되었다는 점이 정말 뿌듯한 것 같습니다. 

 

특히 이번에 DTO 내부에도 메소드를 정의해서 로직을 캡슐화해 봤는데, 서비스 코드를 깔끔하게 만드는 데 정말 유용하다는 걸 새롭게 배웠습니다.

 

그동안 기능 한두 개 정도의 짧은 리팩토링만 해보다가, 이렇게 호흡이 긴 코드를 전체적으로 손본 건 거의 처음인 것 같습니다. 처음엔 막막했지만 기능별로 나누고 컴포넌트식으로 정리해 두니 눈에 훨씬 잘 들어와서 만족스럽습니다. 앞으로는 처음 개발할 때부터 관심사의 분리를 염두에 두고 설계하는 습관을 들여야겠습니다!

 

 

 

'리팩토링' 카테고리의 다른 글

무한 스크롤 구현 및 성능 최적화(리팩토링)  (0) 2026.04.20

+ Recent posts