Attendance 모듈
출석 세션 관리, 학생 체크인, 출결 통계를 담당하는 모듈입니다. 코드 기반 출석 체크, 관리자 출석 관리, 학생별 출결 통계 분석 기능을 제공합니다.
모듈 개요
- 배포: lumie-backend 모놀리스의
modules/attendance - 데이터베이스: PostgreSQL (멀티테넌트 RLS)
- 주요 의존성:
StudentReadModel(읽기 모델, 학생 정보 동기화)
주요 기능
출석 세션 관리
- 출석 세션 생성 및 관리
- 수업별 당일 출석 세션 온디맨드 생성
- 6자리 출석 코드 자동 생성
- 세션 상태 관리 (OPEN/CLOSED)
- 지각 기준 시간 설정
- 세션별 출석 기록 자동 생성
- 동일 수업/날짜 중복 세션 방지
학생 체크인
- 출석 코드 기반 셀프 체크인
- 실시간 출석 상태 판정 (출석/지각)
- 중복 체크인 방지
- 코드 만료 시간 검증
출결 관리
- 관리자 출석 상태 수정
- 일괄 출석 상태 변경
- 출석 기록 조회 및 관리
- 메모 기능
출결 통계
- 학생별 출석률 계산
- 출석/지각/결석/사유결석/미인증 통계
- 미인증(PENDING) 상태를 출석률 분모에서 제외
- 세션별 출석 현황
- 출결 기록 이력 관리
API 엔드포인트
출석 세션 관리 API
출석 세션 생성
POST /v1/attendance/sessions
Content-Type: application/json
{
"academyId": 1,
"name": "수학 1교시",
"sessionDate": "2024-01-15",
"subject": "수학",
"lateThresholdMinutes": 10
}
응답:
{
"id": 1,
"academyId": 1,
"name": "수학 1교시",
"sessionDate": "2024-01-15",
"subject": "수학",
"attendanceCode": "123456",
"codeExpiresAt": "2024-01-15T10:30:00",
"lateThresholdMinutes": 10,
"status": "OPEN",
"createdBy": 123,
"totalStudents": 25,
"presentCount": 0,
"absentCount": 25,
"lateCount": 0,
"excusedCount": 0
}
수업 당일 출석 세션 보장
POST /v1/attendance/classes/{classId}/sessions/ensure
수업의 오늘 날짜(KST) 출석 세션을 조회하거나 생성합니다. 이미 같은
classId와 sessionDate를 가진 세션이 있으면 기존 세션을 반환 하고,
없으면 수업의 활성 학생에 대해 PENDING 출석 기록을 생성합니다.
출석 세션 목록 조회
GET /v1/attendance/sessions?academyId=1&dateFrom=2024-01-01&dateTo=2024-01-31&status=OPEN&page=0&size=20
출석 세션 상세 조회
GET /v1/attendance/sessions/{id}
출석 코드 재생성
POST /v1/attendance/sessions/{id}/regenerate-code
출석 세션 종료
POST /v1/attendance/sessions/{id}/close
출석 세션 삭제
DELETE /v1/attendance/sessions/{id}
학생 체크인 API
출석 코드로 체크인
POST /v1/attendance/checkin
Content-Type: application/json
{
"code": "123456"
}
응답:
{
"message": "출석 체크가 완료되었습니다.",
"status": "PRESENT"
}
출석 기록 관리 API
세션별 출석 기록 조회
GET /v1/attendance/sessions/{sessionId}/records
응답:
[
{
"id": 1,
"sessionId": 1,
"studentId": 123,
"studentName": "김학생",
"status": "PRESENT",
"checkMethod": "CODE",
"checkedAt": "2024-01-15T09:05:00",
"memo": null
}
]
출석 상태 수정
PUT /v1/attendance/sessions/{sessionId}/records/{recordId}/status
Content-Type: application/json
{
"status": "EXCUSED",
"memo": "병원 진료"
}
일괄 출석 상태 변경
POST /v1/attendance/sessions/{sessionId}/records/bulk-update
Content-Type: application/json
{
"recordIds": [1, 2, 3, 4, 5],
"status": "PRESENT"
}
학생 출결 조회 API
학생 출석 기록 조회
GET /v1/attendance/students/{studentId}/records
응답:
[
{
"id": 1,
"sessionId": 1,
"sessionName": "수학 1교시",
"sessionDate": "2024-01-15",
"studentId": 123,
"studentName": "김학생",
"status": "PRESENT",
"checkMethod": "CODE",
"checkedAt": "2024-01-15T09:05:00",
"memo": null
}
]
학생 출석 통계 조회
GET /v1/attendance/statistics/students/{studentId}
응답:
{
"studentId": 123,
"totalSessions": 20,
"presentCount": 18,
"absentCount": 1,
"lateCount": 1,
"excusedCount": 0,
"attendanceRate": 95.0
}
도메인 모델
AttendanceSession 엔티티
@Entity
public class AttendanceSession extends BaseEntity {
private Long academyId; // 학원 ID
private String name; // 세션명
private LocalDate sessionDate; // 수업 날짜
private String subject; // 과목
private String attendanceCode; // 6자리 출석 코드
private Instant codeExpiresAt; // 코드 만료 시간
private Integer lateThresholdMinutes; // 지각 기준 (분)
private SessionStatus status; // 세션 상태 (OPEN/CLOSED)
private Long createdBy; // 생성자 ID
private List<AttendanceRecord> records; // 출석 기록들
// 비즈니스 메서드
public static AttendanceSession create(Long academyId, String name,
LocalDate sessionDate, String subject,
Integer lateThresholdMinutes, Long createdBy);
public void close();
public void regenerateCode();
public boolean isExpired();
public boolean isOpen();
}
AttendanceRecord 엔티티
@Entity
public class AttendanceRecord extends BaseEntity {
private AttendanceSession session; // 출석 세션
private Long studentId; // 학생 ID
private AttendanceStatus status; // 출석 상태
private Instant checkedAt; // 체크인 시간
private Long checkedBy; // 체크인 처리자
private String memo; // 메모
// 비즈니스 메서드
public static AttendanceRecord create(AttendanceSession session, Long studentId);
public void updateStatus(AttendanceStatus status, String memo);
public void checkIn(AttendanceStatus status, Long checkedBy);
}
AttendanceRecord.create()는 기본 상태를 PENDING으로 둡니다. 교사가
수동으로 상태를 확정하거나 학생이 출석 코드로 체크인하기 전까지는
미인증 상태이며, 통계의 출석률 계산에서는 PENDING을 제외합니다.
StudentReadModel 엔티티
@Entity
public class StudentReadModel {
private Long id; // 학생 ID
private Long userId; // 사용자 ID
private String name; // 학생명
private String phone; // 전화번호
private Long academyId; // 학원 ID
private Boolean isActive; // 활성 상태
}
값 객체 (Value Objects)
AttendanceStatus
public enum AttendanceStatus {
PRESENT, // 출석
ABSENT, // 결석
LATE, // 지각
EXCUSED // 사유결석
}
CheckMethod
public enum CheckMethod {
CODE, // 코드 체크인
MANUAL // 수동 처리
}
SessionStatus
public enum SessionStatus {
OPEN, // 진행중
CLOSED // 종료
}
비즈니스 로직
출석 세션 생성 프로세스
- 세션 생성: 기본 정보와 6자리 출석 코드 생성
- 학생 조회:
StudentReadModel읽기 모델에서 해당 학원의 활성 학생 목록 조회 - 출석 기록 생성: 모든 학생에 대해 기본 상태(ABSENT)로 출석 기록 생성
- 세션 활성화: OPEN 상태로 설정하여 체크인 가능하게 함
학생 체크인 프로세스
public CheckInResponse checkInByCode(Long userId, CheckInRequest request) {
// 1. 학생 정보 조회
StudentReadModel student = studentReadRepository.findByUserId(userId)
.orElseThrow(() -> new BusinessException(AttendanceErrorCode.STUDENT_NOT_FOUND));
// 2. 출석 코드로 세션 조회
AttendanceSession session = sessionRepository.findByAttendanceCode(request.code())
.orElseThrow(InvalidCodeException::new);
// 3. 세션 상태 검증
if (!session.isOpen()) {
throw new SessionClosedException(session.getId());
}
if (session.isExpired()) {
throw new BusinessException(AttendanceErrorCode.CODE_EXPIRED);
}
// 4. 출석 기록 조회
AttendanceRecord record = recordRepository.findBySessionIdAndStudentId(
session.getId(), student.getId())
.orElseThrow(() -> new BusinessException(AttendanceErrorCode.RECORD_NOT_FOUND));
// 5. 출석 상태 판정 (지각 기준 시간 기반)
LocalDateTime now = LocalDateTime.now();
LocalDateTime sessionStart = session.getSessionDate().atStartOfDay();
long minutesSinceStart = Duration.between(sessionStart, now).toMinutes();
AttendanceStatus status = minutesSinceStart > session.getLateThresholdMinutes()
? AttendanceStatus.LATE : AttendanceStatus.PRESENT;
// 6. 체크인 처리
record.checkIn(status, CheckMethod.CODE, userId);
recordRepository.save(record);
return new CheckInResponse("체크인이 완료되었습니다.", status);
}
출석률 계산 로직
public StudentAttendanceStatisticsResponse getStudentStatistics(Long studentId) {
List<AttendanceRecord> records = recordRepository.findByStudentIdWithSession(studentId);
int total = records.size();
int presentCount = (int) records.stream()
.filter(r -> r.getStatus() == AttendanceStatus.PRESENT).count();
int absentCount = (int) records.stream()
.filter(r -> r.getStatus() == AttendanceStatus.ABSENT).count();
int lateCount = (int) records.stream()
.filter(r -> r.getStatus() == AttendanceStatus.LATE).count();
int excusedCount = (int) records.stream()
.filter(r -> r.getStatus() == AttendanceStatus.EXCUSED).count();
// 출석률 계산 (출석 + 사유결석을 출석으로 인정)
double attendanceRate = total > 0
? Math.round((double) (presentCount + excusedCount) / total * 1000) / 10.0
: 0.0;
return new StudentAttendanceStatisticsResponse(
studentId, total, presentCount, absentCount, lateCount, excusedCount, attendanceRate);
}
출석 코드 생성
private static String generateCode() {
// 100000 ~ 999999 범위의 6자리 숫자 생성
int code = ThreadLocalRandom.current().nextInt(100000, 1000000);
return String.valueOf(code);
}
모듈 간 연동
StudentReadModel
학생 정보 조회를 위해 StudentReadModel 읽기 모델을 사용합니다.
// 학원의 활성 학생 목록 조회
List<StudentReadModel> activeStudents =
studentReadRepository.findByAcademyIdAndIsActiveTrue(academyId);
// 학생 이름 조회 (출석 기록 표시용)
Map<Long, StudentReadModel> studentMap = studentIds.stream()
.map(id -> studentReadRepository.findById(id).orElse(null))
.filter(Objects::nonNull)
.collect(Collectors.toMap(StudentReadModel::getId, Function.identity()));
데이터베이스 설계
주요 테이블
-- 출석 세션 테이블
CREATE TABLE attendance_session (
id BIGSERIAL PRIMARY KEY,
academy_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
session_date DATE NOT NULL,
subject VARCHAR(100),
attendance_code VARCHAR(6) NOT NULL,
code_expires_at TIMESTAMP,
late_threshold_minutes INTEGER DEFAULT 10,
status VARCHAR(20) NOT NULL DEFAULT 'OPEN',
created_by BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 출석 기록 테이블
CREATE TABLE attendance_record (
id BIGSERIAL PRIMARY KEY,
session_id BIGINT NOT NULL REFERENCES attendance_session(id),
student_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ABSENT',
check_method VARCHAR(20) NOT NULL DEFAULT 'MANUAL',
checked_at TIMESTAMP,
checked_by BIGINT,
memo TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 학생 읽기 모델 (student 모듈 이벤트 기반 복제)
CREATE TABLE student_read_model (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
phone VARCHAR(20),
academy_id BIGINT NOT NULL,
is_active BOOLEAN DEFAULT true
);
인덱스 전략
-- 성능 최적화를 위한 인덱스
CREATE INDEX idx_attendance_session_academy_date ON attendance_session(academy_id, session_date);
CREATE INDEX idx_attendance_session_code ON attendance_session(attendance_code);
CREATE INDEX idx_attendance_record_session ON attendance_record(session_id);
CREATE INDEX idx_attendance_record_student ON attendance_record(student_id);
CREATE INDEX idx_student_read_model_academy ON student_read_model(academy_id);
CREATE INDEX idx_student_read_model_user ON student_read_model(user_id);
설정
attendance 모듈은 독립적인 설정 파일이 없습니다. 학생 정보는 StudentReadModel 읽기 모델을 통해 조회합니다. 모든 설정은 app/src/main/resources/application.yaml에서 통합 관리됩니다.