기술적 판단: 일반적인 페이징 방식에서는 무한스크롤을 선택한 이유가 페이지 이동 시마다 체크박스 상태를 관리하기 까다롭지만, 무한 스크롤은 단일 리스트 구조를 유지하므로 사용자 경험(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 전부터 감지) 같은 옵션을 통해 훨씬 정확하게 제어할 수 있습니다.
앞에서 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)을 다시 등록하는 로직을 추가하여야 합니다.
바로 강의 콘텐츠 관리 페이지였는데요. 테이블 구조가 여러 번 바뀌는 동안 임시방편으로 코드를 덧대다 보니, 어느새 손대기 무서운 스파게티 코드 가 되어 있었습니다. 이대로 두면 미래의 나 또는 동료들에게 큰 짐이 될 것 같아, 이번 기회에 백엔드부터 프론트엔드까지 전면적인 리팩토링을 단행했습니다.
아래는 현재 수정할 화면입니다.(주차가 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 등 기능별로 렌더러 함수를 쪼개서 조립하는 방식
제가 가져와야 하는 값은 영상/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를 활용하여 계산하도록 로직을 변경했습니다. 덕분에 무거운 쿼리는 가벼워졌고, 로직은 자바 코드로 명확히 드러나게 되었습니다.
<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>
<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 함수로 정의해 두었다!
/** 뷰 모드(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을 차지하고 있습니다.
새로고침 시 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 내부에도 메소드를 정의해서 로직을 캡슐화해 봤는데, 서비스 코드를 깔끔하게 만드는 데 정말 유용하다는 걸 새롭게 배웠습니다.
그동안 기능 한두 개 정도의 짧은 리팩토링만 해보다가, 이렇게 호흡이 긴 코드를 전체적으로 손본 건 거의 처음인 것 같습니다. 처음엔 막막했지만 기능별로 나누고 컴포넌트식으로 정리해 두니 눈에 훨씬 잘 들어와서 만족스럽습니다. 앞으로는 처음 개발할 때부터 관심사의 분리를 염두에 두고 설계하는 습관을 들여야겠습니다!