출결을 다시 계산했다고 출결이 바뀐 것은 아니다

LearningX의 일일 출결 집계는 학생별 학습 상태를 계산해 조회용 출결 데이터에 저장한다. 영상 참여 시간과 완료 여부 같은 원본으로부터 만든 파생 상태다. 저장된 상태는 대시보드와 스마트 알림이 이용하지만, 매번 계산 결과가 달라지는 것은 아니다.

기존 작업은 학생의 학습요소를 모두 계산한 뒤 출결 변경 이벤트를 보냈다. 값이 그대로인 학생도 “상태가 바뀌었다”는 신호를 받았다. 이벤트 소비자는 출결 변경 이력을 남기고, 그 이력을 스마트 알림 생성·갱신에 사용한다. 같은 결과를 다시 저장한 사실이 후속 작업을 시작시키는 이유가 되어 있었다.

변경은 집계 자체를 건너뛰는 대신, 저장한 결과가 새 행이거나 실제 변경일 때만 이벤트를 보내도록 했다. 계산 완료와 상태 변경이라는 두 사건을 구분한 작은 수정이다.

저장 전 추측보다 저장 결과를 이용했다

모델의 저장 메서드는 updateOrCreate를 계속 사용한다. 같은 학생·과목·섹션·학습요소의 행을 갱신하거나 새로 만드는 호출이다. 반환된 모델에서 wasRecentlyCreated와 wasChanged()를 확인해 저장 결과의 변화를 알린다. 아래 판정은 실제 코드와 같다.

$model = PreComputedAttendances::updateOrCreate(
    $id, $preComputedAttendanceData
);
return $model->wasRecentlyCreated || $model->wasChanged();

집계 작업은 학생 한 명의 학습요소를 순회하며 한 번이라도 변화가 있으면 $hasChanges를 true로 남긴다. 순회가 끝난 뒤 학생·과목 단위 이벤트를 한 번 보낸다. 학습요소 하나마다 알림 갱신을 시작하는 구조로 바꾸지는 않았다.

$hasChanges = false;
foreach ($assignments as $assignment) {
    if (updatePrecomputedAttendanceTable($data)) {
        $hasChanges = true;
    }
}
if ($hasChanges) {
    event(new AttendanceStatusChannged($userLogin, $courseId));
}

실제 이벤트 클래스의 철자는 AttendanceStatusChannged다. 이름보다 계약이 중요했다. 새 행은 최초 상태를 알릴 필요가 있고, 기존 행이 같으면 소비자에게 새 일이 없다는 판단이다.

모델 변경과 의미 있는 출결 변경은 완전히 같지는 않다

코드는 저장 전에 과목·섹션·컴포넌트 ID를 정수로 변환한다. 숫자처럼 보이는 문자열과 정수가 섞이면 같은 식별자를 다른 타입으로 다루는 문제가 생길 수 있다. 이 정규화는 확인되지만 모든 상태 필드의 타입을 별도 정규화한 구현이라고 확대하지 않았다.

또한 wasChanged()는 변경 필드 목록을 지정하지 않는다. 출결 상태만 비교하는 규칙이 아니라 모델이 마지막 저장에서 변경됐다고 판단한 결과를 사용한다. timestamp나 부가 필드가 갱신될 때 소비자에게도 의미 있는 변화인지는 별도 점검해야 한다. 코드에는 “출결이 아닌 필드는 모두 제외”하는 판정이 없다.

후속 계약을 더 좁힌다면 완료 여부·출결 상태·참여 시간 중 어떤 변화가 알림을 갱신해야 하는지 먼저 정할 수 있다. 그러나 그것은 여기서 구현한 필드 제한이 아니다. 집계 시도만으로 무조건 이벤트를 보내던 문제를 저장 결과의 변화 판정으로 줄인 것이 이번 변경의 범위다.

이벤트 뒤에서도 실제로 처리할 데이터만 남겼다

다음 날의 스마트 알림 변경은 다른 위치에서 같은 질문을 다뤘다. LearningX 학습요소라는 이유로 모든 타입을 컴포넌트 로그 생성 단계에 보냈지만, 이 변경 당시 알림 경로가 실제로 처리하는 타입은 Commons와 화상강의였다. 지원하지 않는 타입까지 로그로 바꾼 뒤 나중에 버리는 일을 앞에서 걸러냈다.

학생의 수강 목록은 이미 DB 조회 결과를 캐시하고 있었다. 그래도 알림 항목마다 특정 학생을 다시 걸러내고 섹션 ID를 추출하는 계산은 반복됐다. 추가한 파생 캐시는 과목 ID → 학생 ID → 중복 없는 섹션 ID 배열의 형태다. 활성 학생 수강만 사용하며 studentview는 제외한다. 학생이 없으면 빈 배열을 반환하고, 활성 학생이 없는 과목도 빈 Collection을 저장해 “목록이 비었다”와 “아직 읽지 않았다”를 구분한다. DB 캐시와 파생 계산 캐시는 줄이는 일이 다르다.

배열 저장소에는 forever로 넣지만, 정상 큐 작업의 종료 뒤 AppServiceProvider의 Queue::after가 저장소를 비운다. 오래 실행되는 워커에서도 이 정상 종료 경계가 캐시 수명을 제한한다. 반면 한 작업 안에서는 처음 읽은 수강 목록을 계속 사용하므로 도중의 수강 변경이 즉시 반영되지는 않는다. 이 콜백만으로 모든 실패 경로에서도 캐시가 초기화된다고 보장할 수는 없다. 출결 변화 판정과 다른 최적화지만 후속 처리에 실제 필요한 입력과 그 수명을 함께 따진 변경이다.

이 경로의 회귀 테스트는 지원·비지원 타입이 섞여 있을 때 지원 콘텐츠의 알림만 생성되는지, soft delete 뒤 기존 알림이 지워지고 다시 생성되지 않는지를 검사한다. 이를 앞의 변화 없는 저장에서 이벤트가 몇 건 감소했는지를 측정한 결과로 가져오지 않았다.

사라진 것은 후속 신호이고 집계 호출은 남았다

이 변경에도 조회와 updateOrCreate 호출은 남는다. DB 쓰기가 전부 없어졌다거나 출결 계산 시간이 특정 비율로 줄었다는 결과는 없다. 확인한 것은 변화 없는 저장 결과에서 무조건 보내던 출결 이벤트를 조건부로 바꾸고, 알림에서 처리하지 못할 데이터를 일찍 제외한 구현이다.

줄어든 이벤트 수·큐 대기·알림 처리량은 별도 운영 측정이 필요하다. LearningX 대시보드처럼 파생 상태를 여러 소비자가 사용하는 기능에서는 처리 속도뿐 아니라 “이 신호가 소비자에게 새 정보를 주는가”를 먼저 묻는 것이 작업량을 줄이는 출발점이었다.