지난 무한스크롤 구현 글에서...

2024.07.30 - [jQuery(+ajax +js)] - 무한 스크롤 구현: 엑셀 필터형 데이터 조회기

기술적 판단: 일반적인 페이징 방식에서는 무한스크롤을 선택한 이유가 페이지 이동 시마다 체크박스 상태를 관리하기 까다롭지만, 무한 스크롤은 단일 리스트 구조를 유지하므로 사용자 경험(UX)과 데이터 정규화 측면에서 더 유리하다고 판단했다.

 

라고 작성했었습니다. 

 

하지만 지금와서 생각해보니 일반적인 페이지네이션 페이지가 무한스크롤에 비해 체크박스에 대해서도 더 관리가 쉬울 것 같다는 생각이 들었습니다. 

하지만 무한스크롤을 사용한 김에 유지하고 리팩토링을 진행하겠습니다. 

 

[기존 무한스크롤]

Scroll 이벤트의 단점 

리팩토링을 위해 자료를 찾아보던 중 이제는 무한스크롤을 개발할 때 scroll 이벤트를 잘 사용하지 않는다는 것을 알게 되었습니다. 

사용하지 않는 이유 : 성능 

현재 잘 사용하지 않는 이유는 성능 문제 때문입니다.

기존의 제가 작성한 코드를 분석해보면 3가지 치명적인 단점이 있습니다. 

// 컨테이너 요소의 스크롤 이벤트 핸들러 등록(사용)
$('.researcher-container').on('scroll', function() {
    // 컨테이너 내부의 스크롤 위치와 높이를 사용하여 스크롤이 거의 맨 아래에 도달했는지 확인
    if ($('.researcher-container').scrollTop() + $('.researcher-container').height() > $('.researcher-container')[0].scrollHeight - 100) {
        scrollType = 'researcherScroll';  
        loadResearchers();
    }			    
});


//기관명 컨테이너 요소임 
$('.instit-container').on('scroll', function() {
    // 컨테이너 내부의 스크롤 위치와 높이를 사용하여 스크롤이 거의 맨 아래에 도달했는지 확인
    if ($('.instit-container').scrollTop() + $('.instit-container').height() > $('.instit-container')[0].scrollHeight - 100) {
        scrollType = 'applicantNmscroll';  
        loadResearchers();
    }
});

1. 과한 업무량으로 인한 이벤트 폭주

사용자가 마우스 휠을 한 번만 까닥해도 scroll 이벤트는 수십 번 발생합니다.

  • 현재 코드의 문제: 사용자가 스크롤을 내리는 동안 if문 안의 계산식(scrollTop() + height() > scrollHeight - 100)이 초당 수십 번씩 실행됩니다.
  • IO(Intersection Observer)의 장점: 브라우저가 알아서 감시하다가, 딱 바닥에 닿았을 때 단 한 번만 알려줍니다. CPU가 훨씬 편안해집니다.

2. 레이아웃 스래싱

코드에 있는 .scrollTop(), .height(), .scrollHeight는 모두 브라우저에게 지금 요소 크기랑 위치 계산해서 당장 보고해! 라고 명령하는 함수들입니다.

  • 현재 코드의 문제: 스크롤이 일어날 때마다 브라우저는 화면을 다시 그리는 도중에 이 값들을 계산하기 위해 하던 일을 멈춰야 합니다. 이게 쌓이면 화면이 미세하게 버벅거리는 현상이 생깁니다.
  • IO의 장점: IO는 이런 수치 계산을 자바스크립트 메인 스레드가 아닌 브라우저 내부에서 최적화된 방식으로 처리합니다.

3. "매직 넘버(-100)의 불안정성"

코드 끝에 있는 - 100은 바닥에 닿기 100px 전에 미리 불러와라 라는 의미입니다. 

  • 현재 코드의 문제:  화면 크기가 아주 작거나, 컨테이너 높이가 유동적일 때 이 '100px'이라는 기준은 가끔 무시되거나 오작동할 수 있습니다. 
  • IO의 장점: threshold: 0.5 (요소의 절반이 보일 때) 또는 rootMargin: '200px' (보이기 200px 전부터 감지) 같은 옵션을 통해 훨씬 정확하게 제어할 수 있습니다.

그래서 Intersection Observer(IO) 가 뭘까?

MDN 을 참고해보면 

https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API

 

Intersection Observer란 우리가 지정한 특정 요소가 화면에 나타나거나 사라지는 순간을 감지하여 동작하게 만드는 웹 API입니다.

 

이름만 봐도 해당 API의 동작원리를 알 수 있습니다. 

 

Intersection = 교차, 겹침 
Observer = 관찰자, 감시자 

 

Observer 는 소프트웨어 디자인 패턴 중 '옵저버 패턴'에서 온 용어입니다. 

직접 매번 가서 확인하는 게 아니라, 어떤 사건이 일어날 때까지 가만히 지켜보고 있다가 보고하는 역할을 합니다. 

 

비유해보자면

Scroll 방식은 사장이 문 앞에서 눈 안떼고 계속 쳐다보는것(성능저하) 이라 하면

Intersection Observer 방식 문에 센서를 달아두고 사람이 오면 알람이 울리게 하는 것입니다. 

 

그래서  Intersection Observer 의 뜻을 합쳐서 보면 

"두 영역이 교차(Intersection)하는지 가만히 지켜보는 녀석(Observer)"

 

입니다. 

Intersection Observer 사용 방법  

const options = {
  root: document.querySelector("#scrollArea"),
  rootMargin: "0px",
  scrollMargin: "0px",
  threshold: 1.0,
};

const observer = new IntersectionObserver(callback, options);

 

MDN 문서에 나와있는 샘플 코드는 Intersection Observer(IO)를 만들기 위한 가장 표준적인 설계도 입니다.

각 옵션의 역할입니다. 

1. root

  • 기준이 되는 화면(뷰포트)
  • 보통은 브라우저 화면(null), 따로 지정하면 그 요소 기준으로 판단

2. rootMargin

  • 기준 화면의 여백을 늘리거나 줄임
  • "10px" → 위아래좌우 10px 확장
  • 양수 = 더 빨리 감지 / 음수 = 더 늦게 감지
</div>
    <div id="researcher-container"></div>
</div>

3. scrollMargin

  • 스크롤 컨테이너(부모 스크롤 영역)의 여백 조정
  • rootMargin과 거의 동일한 개념인데, 중첩 스크롤에 적용

4. threshold

  • 요소가 얼마나 보여야 콜백 실행할지 기준
  • 0 → 조금이라도 보이면 실행
  • 0.5 → 50% 보일 때 실행
  • [0, 0.5, 1] → 단계별로 실행

Intersection Observer 리팩토링 적용 

기존에 컨테이너 요소 하나씩만 작성된 부분에 대해서 

</div>
    <div id="researcher-container"></div>
</div>
.
.
.
</div>
    <div id="instit-container"></div
</div>

 

researcher-sentinel, instit-sentinel 요소도 추가해줍니다. (참고로 스크롤 두개 있음) 

</div>
    <div id="researcher-container"></div>
    <div id="researcher-sentinel" style="height: 10px;"></div>
</div>
.
.
.
</div>
    <div id="instit-container"></div>
    <div id="instit-sentinel" style="height: 10px;"></div>
</div>

 

여기서 각 sentinel 감시 요원의 역할을 가지고 있습니다. 

즉, 리스트의 가장 마지막에 위치하여 "여기까지 읽었으면 다음 내용을 가져와!"라는 표식 역할을 합니다.

 


아래 코드는 Intersection Observer 를 적용한 스크립트 입니다. 

// 연구자 리스트용 Observer
const researcherObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting && !loading) {
            scrollType = 'researcherScroll';
            loadResearchers();
        }
    });
}, {
    root: document.querySelector('.researcher-container'), // 연구자 전용 컨테이너
    threshold: 0.1 
});

//  기관 리스트용 Observer
const institObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting && !loading) {
            scrollType = 'applicantNmscroll';
            loadResearchers();
        }
    });
}, {
    root: document.querySelector('.instit-container'), // 기관 전용 컨테이너
    threshold: 0.1 
});

// 감시 시작
researcherObserver.observe(document.getElementById('researcher-sentinel'));
institObserver.observe(document.getElementById('instit-sentinel'));

 

설정된 옵션은 root, threshold 입니다. 

 

  • root (기준 틀): { root: document.querySelector('.researcher-container') }로로 설정하여 전체 화면이 아니라 특정 박스 내부의 스크롤을 감시합니다. 
    • 참고로 root를 null로 해도 실행되긴 합니다. 이유는 감시 요원이 사용자 눈(화면)에 들어오는 순간이 박스 바닥에 닿는 순간과 거의 일치하기 때문입니다.
    • 하지만 박스 안에서만 일어나는 정교한 스크롤을 제어할 때는 root에 해당 컨테이너를 직접 넣어주는 것이 안전합니다. 
  • threshold (민감도)
    • 완전히 바닥에 닿기보다는 아주 살짝(10%)만 스쳐도 데이터를 불러오는 것이 무한 스크롤의 연속성을 살리기에 적합해보여 0.1로 설정했습니다. 
  • rootMargin scrollMargin 은 설정하지 않았으며 기본값 0px 가 적용됩니다. 
if (entry.isIntersecting && !loading) {
    scrollType = 'researcherScroll';
    loadResearchers();
}

 

 

감시 요원이 컨테이너 안에 들어왔으면서(isIntersecting) 지금 이미 데이터를 가져오는 중이 아닐 때 (중복 호출 방지, !loading )

위의 두 조건이 맞으면 즉시 loadResearchers()를 실행해 서버에서 데이터를 받아옵니다.

 

[감시시작] 

researcherObserver.observe(document.getElementById('researcher-sentinel'));
institObserver.observe(document.getElementById('instit-sentinel'));

 

앞에서 new IntersectionObserver(...)를 통해 감시관(Observer)을 고용하고 어떤 일을 할지(Callback) 교육까지 시켰다면, 이 부분은 실제로 업무를 시작하게 만드는 단계입니다.

  • researcherObserver: 우리가 미리 만들어둔 '연구자용 감시관' 객체입니다.
  • .observe(): "이제부터 감시를 시작해!"라는 실행 명령(메서드)입니다.
  • document.getElementById('researcher-sentinel'): 감시할 구체적인 대상(타겟)입니다.(리스트 맨 밑에 심어둔 감시자 빈박스) 

마지막으로 

더 이상 불러올 데이터가 없을 때는 observer.unobserve(target)를 호출하여 감시를 종료합니다. 이는 불필요한 교차 감지 연산을 중단해 성능을 아낄 뿐만 아니라, 사용하지 않는 감시자를 제거함으로써 브라우저의 메모리 누수를 방지하고 자원을 효율적으로 관리하기 위함입니다.

 

코드는 loadResearchers 함수의 AJAX 성공 로직에 추가해줍니다. 

success: function(data) { 
    var resultData = data.data;
    if (data && resultData && resultData.length > 0) {
        // ... 데이터 렌더링 로직  ...
     
    } else {
        // 데이터가 더 이상 없을 때(마지막 페이지) 
        if (scrollType == "researcherScroll") {
            researcherObserver.unobserve(document.getElementById('researcher-sentinel'));
            console.log("연구자: 모든 데이터를 읽어 감시를 종료합니다.");
        } else if (scrollType == "applicantNmscroll") {
            institObserver.unobserve(document.getElementById('instit-sentinel'));
            console.log("기관: 모든 데이터를 읽어 감시를 종료합니다.");
        }
        loading = false;
    }
}

 

여기서 이렇게 까지 했는데 문제가 생겼습니다. 

데이터 소진 시 리소스 절약을 위해 unobserve()로 감시를 종료했는데, 이후 사용자가 새로운 키워드로 검색을 시도해도 무한 스크롤이 작동하지 않는 현상을 발견했습니다.

[감시 종료 후 새로운 키워드 검색 시 무한스크롤 작동 안함]

 

nobserve는 해당 요소에 대한 관찰을 영구적으로 중단하므로, 검색어 변경 등으로 리스트가 초기화될 때는 반드시 .observe()를 통해 관찰을 재개해야 함을 깨달았습니다.

 

이를 해결하기 위해 검색 이벤트 핸들러 내에 감시 대상(Sentinel)을 다시 등록하는 로직을 추가하여야 합니다.

이를 통해 API의 생명주기와 자원 관리의 중요성을 다시금 체감했습니다.

 

researcherObserver.observe(document.getElementById('researcher-sentinel')); 

를 추가해줍니다. 

//연구자 필터
$('#ResearcherSearch').on('keyup', function() {
    .....
    researcherObserver.observe(document.getElementById('researcher-sentinel'));  
    .... 
});

 

[해결]

[동작안함 해결]

결과 및 성능 측정 결과 비교

[Intersection Observer 사용 - 리팩토링 후 결과]

 

비교를 위해 처음에 올려뒀지만 리팩토링 전 scroll 이벤트를 사용한 gif 를 다시 올려보자면 

[Scroll 이벤트 사용 - 리팩토링 전 무한스크롤]

 

데스크탑 환경에서는 체감이 적을 수 있으나, 저사양 모바일 기기네트워크 지연이 발생하는 환경에서는 Scroll 이벤트 방식보다 훨씬 안정적인 프레임 유지가 가능합니다.

 

성능 측정 

[리팩토링 후 - IntersectionObserve API 사용 코드]

[IntersectionObserve API 사용 코드]

 

 

[리팩토링  - Scroll 이벤트 사용]

[Scroll 이벤트 사용]

 

 

리팩토링 전 (Scroll 이벤트):

  • 스크롤 발생 시마다 계산 로직이 실행되어 Scripting 시간이 128ms에 달함. (기록 시간 대비 높은 점유율)

리팩토링 후 (Intersection Observer):

  • 브라우저 최적화 API를 활용하여 Scripting 시간을 84ms로 약 34% 절감.

결과적으로 단순한 API 교체만으로 자바스크립트 연산 시간(Scripting)을 약 34% 절감(128ms → 84ms)했습니다.

사용자 기기의 CPU 자원을 그만큼 아껴 더 쾌적한 브라우징 환경을 제공합니다. 

 

느낀점 

이전까지는 기능을 구현하고 화면에 데이터가 잘 나오게 하는 것에만 집중했습니다. 하지만 이번 리팩토링을 통해 사용자 눈에는 보이지 않지만 시스템 내부에서 돌아가는 성능의 중요성을 실감했습니다.

 

당연하게 사용했던 Scroll 이벤트가 브라우저에게 얼마나 큰 부담을 주는지 직접 수치로 확인하며, '동작하는 코드'와 '좋은 코드'의 차이를 배울 수 있었습니다.

앞으로는 기능을 구현하기에 앞서 시스템 자원을 얼마나 아끼고 효율적으로 사용할 수 있을지 한 번 더 고민하는 개발자가 되겠습니다.

 

단순 기능 구현을 넘어, 브라우저 렌더링 원리(Layout Thrashing)에 기반한 성능 최적화와 자원 관리를 고민한 무한 스크롤 리팩토링 기록입니다."

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

newLMS 교과정보 주차 설정(리팩토링 )  (0) 2026.04.20

 

 

목적

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

CI/CD 란? 

애플리케이션 개발 단계를 자동화하여 더 빠르고 안정적으로 사용자에게 제공하는 방법이다. 

  • CI (Continuous Integration, 지속적 통합): 개발자가 작성한 코드를 Git과 같은 중앙 저장소에 통합할 때마다 자동으로 빌드 및 테스트하여 코드의 품질을 검증하는 과정 
  • CD (Continuous Deployment, 지속적 배포): CI 과정을 통과한 코드를 실제 운영 서버까지 자동으로 배포하고 실행하는 과정

GitLab 설정: Jenkins 연동 준비

-> Jenkins가 GitLab 프로젝트에 접근할 수 있도록 개인용 Access Token을 발급

  1. gitlab Access Token 발급하기
    1. 왼쪽 상단 Edit Profile → 왼쪽 네비게이션 바 Access Tokens 클릭 → 토큰 이름 설정 및 체크 → ‘Create personal access token’ 클릭

하면 아래처럼 나오는데 복사해서 사용 (만료기한 설정가능 , 최대1년)

 
  1. jenkins 프로젝트 생성 및 설정
    1. Jenkins 관리 → System Configuration → System → credentials → add → Jenkins → username :깃랩이름 , password : 발급받은 엑세스 토큰 → 깃랩과 젠킨스 간에 연동 완료!

Jenkins 관리->Security->Credentials 에서도 추가, 확인 가능 (global 클릭 시 추가도 가능)

Jenkins 설정: GitLab 연동 및 Webhook 설정

  1. webhooks 설정
    1. Jenkins와 연동할 Github Repository → Settings → Webhooks → 아래같은 식으로 작성

시크릿키 발급은 아래 Jenkins → new Item 부분에 정의

 

연동확인 테스트는 test → push events를 눌렀을 때

Hook executed successfully: HTTP 200 라고 뜨면 성공한것

 

Jenkins 프로젝트 생성 및 빌드 스크립트 작성

Jenkins → new Item → freestyle project로 생성

후에 설정은 아래처럼 !

Build Steps 에서는 Jenkins가 수행해야 할 구체적인 작업을 작성

  • Maven Version : 사용하려는 Maven 버전
  • Goals :
    • package -D maven.test.skip=true : package 목표를 사용하여 프로젝트를 빌드하고 keywert_batch-0.0.1-SNAPSHOT.jar 파일을 생성
  • SOURCE_PATH : 빌드 후 생성된 JAR 파일의 경로
  • TARGET_PATH : 배포할 경로
  • ssh root@114.202.2.226 mv "$SOURCE_PATH" "$TARGET_PATH/ROOT.jar" : jar 파일을 배포경로로 ROOT.jar라는 이름으로 옮김
  • ssh root@114.202.2.226 "java -jar $TARGET_PATH/ROOT.jar" : 해당 서버에 배치작업 실행

이런식으로 하면 깃허브 main에 push, merge할 때마다 배치가 수행된다.

추가로 시크릿키 발급 받는건 아래로 쭉 내리다보면

빌드유발고급 을 선택하면 아래처럼 시크릿키를 발급받을 수 있는 칸이 나온다.

Generate 클릭하면 시크릿키 발급 완료 !

발급 받은 시크릿키는 깃랩 웹훅 설정할 때 시크릿키 설정칸에 입력

 

 

+ Recent posts