들어가며,
입사 초, LMS 온라인 강의 이수 처리 기능을 맡게 되었다.
당시에도 이 내용을 글로 정리하려고 했지만, 임시저장만 해둔 채로 어딘가로 사라져 버린 것 같다.
그래서 이번에는 기억을 다시 곱씹으며, 처음 이수 처리 기능을 설계했던 과정부터
2차년도 유지보수를 거치며 변경된 내용까지 정리해보려고 한다.
요구사항의 시작
“학생이 강의 전체 100% 중 80%를 수강하면 자동으로 이수 처리되게 해주세요.”
하지만 이 한 문장 안에는 생각보다 많은 결정 포인트가 숨어 있었다. 당시에는 기획자가 따로 없었고, 이수 처리 기준과 방식에 대한 모든 결정은 함께 개발하던 동료 개발자들과 직접 설계해야 했다.
어떻게 설계해야 할까?
강의 재생 시간을 어떤 기준으로 계산할지, 그리고 이수 처리 시점을 언제로 볼 것인지 등
“80%”라는 숫자는 정해져 있었지만, 그 80%를 어떻게 정의할 것인지는 전혀 정해져 있지 않았다.
이 부분을 두고 백엔드 개발자와 함께 여러 경우의 수를 놓고 꽤 많은 이야기를 나눴다.
이수 처리 기능은 단순히 퍼센트를 계산하는 문제가 아니라,
‘언제 수강을 완료했다고 판단할 것인가’에 대한 기준을 정하는 일이었다.
동시에, 강의 재생 중 어떤 시점에 백엔드로 진도 정보를 저장할지, 그리고 저장된 재생 시점을 기준으로 진도율을 어떻게 계산하고 화면에 실시간으로 반영할지에 대한 설계가 필요했다.
🤔 진도 저장 기준: 시간 단위로 접근하다.
진도 저장 기준을 시간 단위로 잡았다.
강의 재생 이벤트마다 요청을 보내기보다, 일정 시간 간격으로 재생 시점을 저장하는 방식이 서버 부하와 안정성 측면에 더 적절하다고 판단했다. 다만, 모든 강의에 동일한 기준을 적용하기에 영상 길이에 따른 차이가 컸다.
그래서 영상 길이에 따라 진도 저장 주기를 다르게 가져가는 방식을 택했다.
✅ 영상 길이에 따른 진도 저장 기준
1. 영상 길이가 600초를 초과하는 경우
- 기본 진도 저장 주기를 60초로 설정
- 긴 강의의 경우 잦은 요청은 불필요하다고 판단
- 일정 간격으로만 저장해도 이수 판단에 충분
2. 영상 길이가 600초 이하인 경우
- 진도 저장 주기를 10초로 축소
- 짧은 영상일수록 한 번의 저장 누락이 진도율에 미치는 영향이 크다고 판단
3. 영상 길이가 60초 이하인 경우
- 별도로 구현된 API를 호출해 강의 재생 완료 시 바로 이수처리
- 주기적 진도 저장보다 즉시 처리 방식이 합리적이라고 판단
✅ 이 기준을 선택한 이유
이렇게 구간을 나눈 이유는 비교적 명확했다.
긴 영상에서는 서버 요청 수를 최소화하고 짧은 영상에서는 이수 처리 정확도를 높이는 것이 더 중요하다고 판단했다.
⚡️ 실시간 진도율 업데이트 방식
실시간 진도율은 단순히 currentTime 값을 그대로 반영하는 방식이 아니라,
앞서 정한 진도 저장 주기(10초 / 60초)에 맞춰 누적 업데이트하는 구조로 구현했다.
영상 재생 중에는 매초 재생 상태를 체크하고, 누적된 재생 시간이 저장 주기에 도달했을 때만 백엔드에 진도 정보를 전송한다.
💥 영상 길이에 따른 저장 주기 설정
const INTERVAL_DEFAULT = 60000; // 1분
const INTERVAL_SHORT = 10000; // 10초
const SHORT_VIDEO_THRESHOLD = 600000; // 10분
const ONE_SECOND = 1000;
const getInterval = () => {
return dataRef.current.videoDuration <= SHORT_VIDEO_THRESHOLD
? INTERVAL_SHORT
: INTERVAL_DEFAULT;
};
영상 길이가 짧을수록 한 번의 저장 누락이 진도율에 미치는 영향이 크기 때문에 영상 길이에 따라 다른 저장 주기를 적용했다.
💥 진도 정보 전송 및 누적 방식
저장 주기에 도달하면, 현재 재생 시점을 기준으로 백엔드에 진도 정보를 전송한다.
const sendVideoProgress = async (currentPlayed, interval) => {
try {
await setTimeToVideoEdu({
projectNo: videoData.eduBaseInfo.c_project_no,
eduNo: videoData.eduBaseInfo.c_edu_no,
chasiNo: videoData.eduVideoInfo.c_no,
memberNo: cNo,
totalVideoTime: dataRef.current.videoDuration,
currentVideoTime: currentPlayed,
completionVideoTime: interval,
});
// 진도율 누적 (최대 영상 길이까지만)
setCompletionVideoTime((prev) => {
const newTime = prev + interval;
const maxTime = dataRef.current.videoDuration;
return Math.min(newTime, maxTime);
});
} catch (error) {
console.error("진도 전송 오류:", error);
}
};
서버에는 현재 재생 시점과 이번 주기에 해당하는 진도 시간만 전달한다.
2차년도 유지보수를 진행하며 기존에 작성했던 코드를 반복적으로 테스트하던 중,
특정 상황에서 문제가 발생하는 것을 확인했다.
setCompletionVideoTime((prev) => prev + interval)
해당 코드는 배속 재생(1.5배, 2배 등)을 사용할 경우 completionVideoTime이 실제 영상 길이를 초과하는 경우가 발생했다. 이 문제를 해결하기 위해, 진도 누적 시 최대값을 영상 전체 길이로 제한하는 로직을 추가했다.
💥 매초 재생 상태를 체크하는 인터벌 구조
const requestInterval = () => {
if (timeRef.current) clearInterval(timeRef.current);
const interval = getInterval();
let count = 0;
timeRef.current = setInterval(async () => {
if (!dataRef.current.isPlaying) return;
count += ONE_SECOND * playbackRateRef.current;
while (count >= interval) {
const currentPlayed = dataRef.current.playTime;
await sendVideoProgress(currentPlayed, interval);
count -= interval;
}
}, ONE_SECOND);
};
배속 재생을 고려해 playbackRate 를 반영했다.
해당 부분은 유지보수를 하며 리팩토링을 진행했는데 누적 시간이 저장 주기를 넘었을 때만 API를 호출하도록 변경했다. 또한, while 문을 사용해 배속 재생이나 일시적인 렌더 지연에도 누락 없이 진도를 저장하도록 했다.
🤔 while 문을 사용한 이유, 더 나은 방법은 없을까?
만약 if (count >= interval) 이었다면, 이러한 문제 상황을 마주할 수 있다.
if (count >= interval) {
await sendVideoProgress(...);
count -= interval;
}
예시) interval = 10초, playbackRate = 2 (2배 재생), 1초 마다 count += 2초
| 실제 시간 | count |
| 5초 | 10초 |
| 6초 | 12초 |
| 7초 | 14초 |
어떠한 순간에 count = 25초가 될 수 있다. (백그라운드, 렉, 배속 등등..)
이때, if 로 사용할 경우 10초짜리 요청 1번만 보내고 나머지 15초는 날아가버린다.
→ 진도 누락 발생!
✅ 그래서 while 을 쓰면 뭐가 달라질까?
누적된 시간이 interval 을 몇 번 넘었든, 넘은 횟수만큼 전부 처리된다.
같은 예시) count = 25초, interval = 10초
1. 25 >= 10 → 요청 1번, count = 15
2. 15 >= 10 → 요청 1번, count = 5
3. 5 < 10 → 종료
총 2번 요청되며, 진도 손실 없음
즉, 시간이 밀렸을 때도 진도 저장 요청을 빠짐없이 처리하기 위해 사용했다.
🤔 하지만, 더 나은 방법을 생각해보자.
count 에서 누적된 재생 시간(ms) 이 쌓이고 interval 을 몇 번 넘겼는지 계산하고 그만큼 API 호출, 나머지 시간만 다시 count 에 남긴다.
어찌보면 '몫과 나머지' 계산 문제라고 볼 수 있다.
const timesToCall = Math.floor(count / interval);
count = count % interval;
의도 자체가 수학적으로 명확하며, '누적 시간이 인터벌을 몇 번 넘겼는지 계산한다.' 라는 것이 바로 보인다.
또한, 무한루프 위험이 없다.
while은 조건을 잘못 짜면 언제든 사고가 날 수 있다!
interval 값이 0이거나 count가 줄어들지 않는 버그가 생긴다면..? 바로 무한루프 위험이 생긴다.
하지만, Math.floor 는 그럼 위험이 구조적으로 없다.
이 경험을 통해 느낀 점은, “동작하는 코드”와 “설명할 수 있는 코드”는 다르다는 것이었다.
while 문은 문제를 해결해주었지만, 의도를 한눈에 파악하기에는 다소 위험한 선택일 수 있었다.
반면, 몫과 나머지를 이용한 방식은 의도가 코드에 그대로 드러나며, 유지보수 관점에서도 더 안전한 선택이라는 생각이 들었다.
더 나아가, 운영 환경에서의 안정성을 다시 설계하다.
1차년도 개발이 “기능을 완성하는 것“에 초점이 맞춰져 있었다면,
2차년도 유지보수에서는 운영 환경에서의 신뢰도와 안정성이 중요한 과제였다.
실제 학생 사용 데이터와 운영 과정에서 드러난 문제들을 기반으로, 이수처리 로직과 학습 경험을 몇 가지 방향에서 개선했다.
1️⃣ 백그라운드 재생을 허용하지 않는 이수 기준 재정의
1차년도에는 학생이 강의를 백그라운드 탭으로 전환해도 이수 처리가 가능하도록 구현되어 있었다.
하지만 운영을 하며 다음과 같은 문제가 드러났다.
1. 강의를 틀어두고 실제로는 화면을 보지 않는 경우(백그라운드 재생, 다른 탭에서 작업 등)가 있을 수 있음
2. 학습 집중도 저하
3. 무엇보다, 이수 데이터의 자체의 신뢰성 문제
특히 이수 데이터의 신뢰성은 운영 측면에서 가장 중요한 이슈였다.
운영 중 종종 “강의를 전부 수강했는데 이수가 안 됐다“는 문의가 들어오곤 했다.
데이터를 확인해 보면, 누적 학습 시간이 실제 강의 길이에 비해 현저히 부족한 경우가 있었다.
이때 중요한 점은, 학생의 행동을 의심하기보다 시스템의 기준을 점검하는 것이 맞다는 판단이었다.
정말로 네트워크 문제였을 수도 있고, 재생은 되었지만 포커스를 잃은 상태일 수도,
더 나아가 사용자는 “틀어놓았다“는 사실을 “수강했다“고 인지했을 가능성도 있었다.
결국, 문제는 개인의 문제가 아니라 “이수로 인정할 기준이 충분히 명확했는가“였다.
✅ 설계 방향의 전환: 명확한 제한을 두다.
그래서 백그라운드 재생을 허용하는 방식 대신, 실제 학습 행위가 확인되는 상태만을 이수 조건으로 인정하는 방향으로 설계를 변경했다.
1. 화면 이탈 시 강의 자동 일시정지
2. 화면 복귀 전까지 재생을 제한하는 커버 오버레이 적용
이를 통해, 학생에게 명확한 학습 기준을 제공하고 운영 측면에서는 이수 데이터의 신뢰도를 확보할 수 있었다.
이러한 설계는 부정 시청을 막기 위한 조치이기도 했지만, 그보다 더 중요한 목적은 분쟁을 만들지 않는 시스템을 만들고 싶었다. “왜 이수가 안됐는지“를 설명할 수 있고, 기준이 명확해 학생과 운영자 모두 납득할 수 있으며 결과적으로 이수 데이터에 대한 신뢰를 지킬 수 있는 구조라고 생각했다.
2️⃣ 네트워크 오류를 고려한 진도 저장 안정화: 재시도 로직 설계 (Exponential Backoff + Jitter)
온라인 강의 특성상, 네트워크 끊김이나 서버 응답 지연은 피할 수 없는 문제였다.
1차년도에서는 진도 저장 API 호출이 실패할 경우 해당 구간의 학습 시간이 누락되는 문제가 있었다.
또한 여러가지 문제 상황(네트워크 일시 끊김, 서버 부하로 인한 응답 지연 또는 실패 등)을 고려하여 재시도 로직을 명시적으로 설계했다.
목표
1. 일시적인 오류로 인한 학습 시간 누락 방지
2. 서버에 과도한 부하를 주지 않으며 안정적으로 재요청
3. 실패가 지속될 경우 사용자에게 명확히 안내
설계 방향
1. API 실패 시 즉시 재요청하지 않음
2. 재시도 간격을 점점 늘리는 지수 백오프 적용
3. 동시에 여러 요청이 몰리는 상황을 방지하기 위해 Jitter(무작위 대기 시간) 추가
✅ 지수 백오프 적용
API 요청 실패 시, 즉시 재요청하지 않고 점진적으로 대기 시간을 늘리는 방식을 사용했다.
대기 시간 증가 방식
- 1번째 실패 → 0.5초 대기
- 2번째 실패 → 1초 대기
- 3번째 실패 → 2초 대기
- 4번째 실패 → 4초 대기
- 최대 5회까지 재시도
초기 대기 시간을 짧게 두고, 실패가 반복될수록 서버 부담을 줄이는 방향으로 설계했다.
✅ Jitter(무작위 지연) 적용
단순 지수 백오프만 적용할 경우, 여러 사용자가 동시에 재시도 타이밍에 도달하면 요청이 몰릴 수 있다.
이를 방지하기 위해 Jitter(무작위성)를 추가했다.
1. 다수의 클라이언트가 동시에 재시도하는 현상 방지
2. 서버 부하가 특정 시점에 집중되는 문제 완화
3. 재시도 요청을 시간 축 기준으로 고르게 분산
// 예시
const MAX_RETRY = 5;
const BASE_DELAY = 1000; // 1초
const retryWithBackoff = async (apiFn) => {
for (let attempt = 0; attempt < MAX_RETRY; attempt++) {
try {
await apiFn();
return; // 성공 시 종료
} catch (error) {
const delay =
BASE_DELAY * Math.pow(2, attempt) +
Math.random() * 500; // Jitter 추가
console.warn(
`진도 저장 실패, ${attempt + 1}번째 재시도 (${delay}ms 후)`
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
console.error("진도 저장 재시도 실패: 최대 재시도 횟수 초과");
};
3️⃣ 학습 진행 상황을 즉각적으로 인지할 수 있는 실시간 진도율 프로그래스바 제공
기존에는 진도율이 내부적으로만 계산되고 학생이 현재 어느 정도까지 수강했는지 체감하기 어려웠다.
따라서 학생 화면에 실시간 진도율 프로그래스바를 추가했다.
해당 기능은 단순한 UI 개선처럼 보이지만, 학습 경험과 이수율에 직접적인 영향을 줄 것으로 예상했다.
마무리하며
2차년도 유지보수의 핵심은 기능 추가가 아니라, 기존 설계를 얼마나 신뢰할 수 있게 만드는가였다.
학생의 학습 경험 개선, 부정 시청 방지, 그리고 다양한 네트워크 환경을 고려한 안정성 확보를 염두에 두고 단순히 “잘 동작한다“를 넘어서 “어떤 상황에서도 흔들리지 않는 구조“를 만드는 방향으로 재설계했다.
처음에는 이수처리가 된다는 결과에 집중했다면, 유지보수를 거치며 점점 그 결과를 어디까지 믿을 수 있는가를 고민하게 되었다. 이는 기능 하나를 고쳐서 해결할 수 있는 문제가 아니라, 설계 전반의 기준을 다시 정의해야만 해결할 수 있는 문제였다.
이 경험 이후로 프로젝트를 설계할 때 가장 중요하게 생각하게 된 것은, 정상 케이스가 아니라, 실패 케이스를 어떻게 다루는가이다. 실패를 전제로 설계하지 않으면, 결국 문제는 사용자에게 책임을 전가하게 된다.
그래서 이후의 프로젝트들에서는 왜 이방식이어야 하는지, 이 설계는 어떤 문제를 예방하기 위한 것인지를 명확하게 설명할 수 있는 개발자가 되기 위한 중요한 기준점이 되었다.
'개발 > Deep Dive' 카테고리의 다른 글
| [Deep Dive] 변수 (0) | 2022.08.21 |
|---|