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에서 통합 관리됩니다.
에러 처리
비즈니스 예외
public enum AttendanceErrorCode implements ErrorCode {
// 404 Not Found
SESSION_NOT_FOUND("ATTENDANCE_001", "출석 세션을 찾을 수 없습니다", 404),
RECORD_NOT_FOUND("ATTENDANCE_002", "출석 기록을 찾을 수 없습니다", 404),
STUDENT_NOT_FOUND("ATTENDANCE_003", "학생 정보를 찾을 수 없습니다", 404),
// 400 Bad Request
INVALID_CODE("ATTENDANCE_004", "유효하지 않은 출석 코드입니다", 400),
CODE_EXPIRED("ATTENDANCE_005", "출석 코드가 만료되었습니다", 400),
SESSION_CLOSED("ATTENDANCE_006", "종료된 세션입니다", 400),
DUPLICATE_RECORD("ATTENDANCE_007", "이미 출석 처리된 학생입니다", 409);
}
글로벌 예외 처리
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
ErrorCode errorCode = e.getErrorCode();
return ResponseEntity.status(errorCode.getStatus())
.body(new ErrorResponse(errorCode.getCode(), e.getMessage(), LocalDateTime.now()));
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<ErrorResponse> handleIllegalStateException(IllegalStateException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("ILLEGAL_STATE", e.getMessage(), LocalDateTime.now()));
}
}
보안 설정
보안 설정은 모놀리스 app 모듈의 SecurityConfig에서 통합 관리됩니다. JwtAuthenticationFilter가 모든 요청의 JWT를 검증하며, 공개 엔드포인트(/v1/login, /v1/register, /actuator/**)는 SecurityConfig에서 permitAll()로 허용됩니다.
자세한 내용은 인증 & 멀티테넌시를 참고하세요.
모니터링
헬스 체크
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
endpoint:
health:
show-details: always
probes:
enabled: true
주요 메트릭
- 출석 세션 생성/종료 횟수
- 학생 체크인 성공/실패율
- 출석 코드 재생성 횟수
- 출석 상태 수정 횟수
로그 예시
{
"timestamp": "2024-01-15T09:05:00.000Z",
"level": "INFO",
"logger": "com.lumie.attendance.application.service.AttendanceRecordCommandService",
"message": "Student 123 checked in to session 1 with status PRESENT",
"tenantSlug": "inst-demo",
"userId": "123",
"sessionId": "1",
"status": "PRESENT"
}
테스트
단위 테스트 예시
@ExtendWith(MockitoExtension.class)
class AttendanceRecordCommandServiceTest {
@Mock
private AttendanceRecordRepository recordRepository;
@Mock
private AttendanceSessionRepository sessionRepository;
@InjectMocks
private AttendanceRecordCommandService service;
@Test
void shouldCheckInSuccessfully() {
// Given
AttendanceSession session = AttendanceSession.create(1L, "수학",
LocalDate.now(), "수학", 10, 1L);
AttendanceRecord record = AttendanceRecord.create(session, 123L);
when(sessionRepository.findByAttendanceCode("123456"))
.thenReturn(Optional.of(session));
when(recordRepository.findBySessionIdAndStudentId(any(), any()))
.thenReturn(Optional.of(record));
// When
CheckInResponse response = service.checkInByCode(123L,
new CheckInRequest("123456"));
// Then
assertThat(response.status()).isEqualTo(AttendanceStatus.PRESENT);
verify(recordRepository).save(record);
}
}
성능 최적화
배치 조회 최적화
// N+1 문제 해결을 위한 배치 조회
@Query("SELECT r FROM AttendanceRecord r JOIN FETCH r.session WHERE r.studentId = :studentId")
List<AttendanceRecord> findByStudentIdWithSession(@Param("studentId") Long studentId);
인덱스 활용
- 출석 코드 조회:
idx_attendance_session_code - 세션별 출석 기록:
idx_attendance_record_session - 학생별 출석 기록:
idx_attendance_record_student
배포
배포
attendance-svc는 독립적으로 배포되지 않습니다. lumie-backend.jar에 포함되어 단일 Spring Boot 애플리케이션으로 배포됩니다.
- 네임스페이스:
lumie-backend - Replica: 3
- 포트: 8080 (HTTP only)
빌드 및 배포 방법은 인프라 문서를 참고하세요.
관련 문서
- 백엔드 아키텍처 - 헥사고날 아키텍처
- 멀티테넌시 - 테넌트별 데이터 분리
- Student 모듈 - 학생 정보 연동
- PostgreSQL - 데이터베이스 설정
Attendance Service는 학원의 출결 관리를 디지털화하여 효율적이고 정확한 출석 체크 시스템을 제공하는 핵심 서비스입니다.