Zum Hauptinhalt springen

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) 출석 세션을 조회하거나 생성합니다. 이미 같은 classIdsessionDate를 가진 세션이 있으면 기존 세션을 반환하고, 없으면 수업의 활성 학생에 대해 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 // 종료
}

비즈니스 로직

출석 세션 생성 프로세스

  1. 세션 생성: 기본 정보와 6자리 출석 코드 생성
  2. 학생 조회: StudentReadModel 읽기 모델에서 해당 학원의 활성 학생 목록 조회
  3. 출석 기록 생성: 모든 학생에 대해 기본 상태(ABSENT)로 출석 기록 생성
  4. 세션 활성화: 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)

빌드 및 배포 방법은 인프라 문서를 참고하세요.

관련 문서

Attendance Service는 학원의 출결 관리를 디지털화하여 효율적이고 정확한 출석 체크 시스템을 제공하는 핵심 서비스입니다.