본문으로 건너뛰기

학생 서비스

이 페이지는 lumie-backend/modules/student를 다룹니다.

책임

student 모듈은 다음을 소유합니다.

  • auth 사용자에 연결된 테넌트 범위 student 레코드
  • activate, deactivate, delete, password reset, 로그인 ID change 같은 학생 수명주기 작업
  • XLSX/CSV 대량 import와 CSV 내보내기
  • 학생 검색과 통계
  • 모듈 간 후속 작업을 트리거하는 StudentRegisteredEvent

소스 경로

경로역할
lumie-backend/modules/student/src/main/java/com/lumie/student/adapter/in/web/StudentController.java공개 student HTTP API
lumie-backend/modules/student/src/main/java/com/lumie/student/adapter/in/internal/StudentServiceAdapter.javainternal monolith API 구현
lumie-backend/modules/student/src/main/java/com/lumie/student/application/service/StudentCommandService.java수명주기, 삭제, 대량 가져오기, event 발행
lumie-backend/modules/student/src/main/java/com/lumie/student/application/service/StudentQueryService.java조회, 내보내기, enrollment trend, dropout summary
lumie-backend/modules/student/src/main/java/com/lumie/student/application/service/StudentExcelParser.javaApache POI 기반 XLSX parser와 다중 인코딩 CSV parser
lumie-backend/modules/student/src/test/java/com/lumie/student/application/service/StudentExcelParserTest.javaUTF-8, x-windows-949, CSV edge case parser coverage
lumie-backend/modules/student/src/main/java/com/lumie/student/domain/entity/Student.javastudent aggregate
lumie-backend/libs/internal-api/src/main/java/com/lumie/student/api/StudentService.javainternal 조회 및 검증 계약
lumie-backend/libs/internal-api/src/main/java/com/lumie/student/api/StudentRegisteredEvent.javaafter-커밋 event 계약
lumie-backend/app/src/main/resources/db/migration/public/V18__rls_baseline.sql기준선 students table
lumie-backend/app/src/main/resources/db/migration/public/V91__normalize_student_parent_phone_and_drop_unique_index.sql학부모 연락처 빈 값 정규화 및 보호자-number uniqueness 완화

공개 인터페이스

인터페이스진입점
Student CRUDPOST /v1/students, GET /v1/students, GET /v1/students/{id}, PATCH /v1/students/{id}, DELETE /v1/students/{id}
Lifecycle and credentialsPOST /v1/students/{id}/deactivate, POST /v1/students/{id}/reactivate, POST /v1/students/{id}/reset-password, PATCH /v1/students/{id}/login-id
Batch 수명주기POST /v1/students/batch/deactivate, POST /v1/students/batch/reactivate, POST /v1/students/batch/delete
Bulk 가져오기 and 내보내기POST /v1/students/bulk, GET /v1/students/export.csv
StatisticsGET /v1/students/statistics/enrollment-trend, GET /v1/students/statistics/dropout-summary

학생 목록 필터

GET /v1/students는 기존 isActive, search, 페이지네이션, 정렬 파라미터와 함께 다음 선택 필터를 받습니다.

파라미터의미
classId해당 클래스에 활성 수강 등록이 있는 학생을 반환합니다. hasActiveEnrollment와 함께 전달되면 이 값이 우선합니다.
hasActiveEnrollment=true활성 클래스 수강 등록이 하나 이상 있는 학생을 반환합니다.
hasActiveEnrollment=false활성 클래스 수강 등록이 없는 학생을 반환하며, 미배정 보기에 사용합니다.

student 조회 서비스는 class 모듈의 internal API로 수강 ID를 얻으며, student 모듈은 class 도메인 entity를 직접 import하지 않습니다.

내부 인터페이스

StudentService는 다음을 내보냅니다.

  • student ID, phone, auth 사용자 ID 기준 조회
  • 테넌트 내부 student 검증
  • keyword 기반 join을 위한 user ID 검색
  • 다른 모듈이 쓰는 batch 조회 helper

현재 가장 중요한 소비자는 exam 모듈입니다. exam 모듈은 student phone 조회와 StudentRegisteredEvent backfill 경로를 사용합니다.

집계와 데이터 구조

집계테이블참고
Studentstudentsuser 연결, 로그인 ID mirror, 연락처, 학교, 출생 연도, 메모, active flag를 저장

Student의 중요한 불변식:

  • phone과 parent phone은 write 시 숫자만 남도록 정규화되며, 빈 parent_phone 값은 NULL로 저장됩니다.
  • user_login_id는 빠른 조회를 위해 auth 측 로그인 ID를 복제합니다.
  • delete는 auth 사용자를 삭제하는 방식으로 구현되며, DB cascade가 student row를 제거합니다.

런타임 흐름

등록 및 백필 흐름

이벤트 발행 지점의 형태

소스 앵커: lumie-backend/modules/student/src/main/java/com/lumie/student/application/service/StudentCommandService.javaStudentCommandService.registerStudent에서 event를 발행합니다.

eventPublisher.publishEvent(
new StudentRegisteredEvent(
saved.getId(),
saved.getPhone(),
tenantSlug,
TenantContextHolder.getRequiredTenantId()
)
);

이 event는 student row가 저장된 뒤 발행되며, downstream listener는 커밋 후에 이를 소비합니다. event는 tenantSlugtenantId를 모두 담으므로 재생된 listener도 전체 RLS tenant context를 복원할 수 있습니다. 대량 import도 성공적으로 저장된 student row마다 같은 event를 한 번씩 발행합니다.

핵심 동작

  • 학생 등록과 재활성화는 모두 MetricType.STUDENTS를 통해 quota check를 수행합니다.
  • 학생 phone은 값이 있을 때 unique하게 유지되며, 등록, 수정, 대량 import에서 숫자만 남긴 값이 11자리 모바일 번호여야 합니다. parent_phone은 선택 입력값이며 unique하지 않고 더 넓은 shared phone parser를 계속 사용하므로, 한 학부모 연락처가 여러 student row에 연결될 수 있습니다.
  • 단건 삭제는 student가 비활성 상태여야 하며, ClassService.dropActiveEnrollmentsForStudent(...)로 활성 enrollment를 제거한 뒤 auth 사용자를 삭제합니다.
  • 대량 import는 두 단계로 진행됩니다.
    • pass 1은 모든 row를 검증하고 모든 행 오류를 수집합니다.
    • pass 2는 유효한 row에 대해서만 auth 사용자와 student row를 생성합니다.
  • 가져오기 parser는 .xlsx 업로드와 UTF-8, x-windows-949, EUC-KR 순서로 decode하는 .csv 업로드를 받습니다. StudentExcelParser.parseCsvRecords(...)는 decode된 header가 학생 이름,학생 연락처와 일치할 때까지 이 charset들을 시도하며, 닫히지 않은 CSV quote field, 1,000개를 초과하는 data 행, 32개를 초과하는 CSV column은 거부합니다.
  • 대량 import는 free-text column에서 spreadsheet formula로 해석될 수 있는 값을 거부합니다. CSV export도 저장된 값이 formula처럼 시작하면 cell 앞에 apostrophe를 붙여 내보냅니다.
  • POST /v1/students/bulkIdempotency-Key를 요구합니다. StudentController는 byte size와 SHA-256 content hash로 upload fingerprint를 만든 뒤 IdempotencyService.executeOnce(...)에 위임합니다.
  • 대량 import는 컨트롤러 boundary에서 최소 INSTRUCTOR 권한을 요구합니다.
  • dropout summary는 전용 deactivated_at field가 없기 때문에 비활성 row의 updated_at를 기반으로 한 근사치입니다.

대표 계약 예시

이 대표 예시는 StudentExcelParser, StudentExcelParserTest, StudentController, BulkImportResult에 근거합니다.

템플릿 행 형태

첫 번째 worksheet 또는 CSV 파일은 다음 column으로 시작해야 합니다.

학생 이름,학생 연락처,학교명,출생 연도,학부모 연락처,메모
김철수,01012340001,한빛고,2008,01012340002,재원

StudentExcelParserTest는 header가 1행을 차지하므로 첫 데이터 행이 rowNumber = 2로 보고된다는 점을 확인합니다. CSV 업로드도 같은 행 번호를 사용합니다.

소스 앵커:

  • lumie-backend/modules/student/src/main/java/com/lumie/student/application/service/StudentExcelParser.java
  • lumie-backend/modules/student/src/test/java/com/lumie/student/application/service/StudentExcelParserTest.java

부분 성공 대량 가져오기 응답

POST /v1/students/bulk는 일부 row가 실패해도 200 OK를 반환합니다. 이 import는 async job 모델로 전환하지 않고 inline으로 실행되며, 행 수준 오류를 보고하기 때문입니다.

{
"totalRows": 2,
"successCount": 1,
"failureCount": 1,
"errors": [
{
"rowNumber": 3,
"column": "phone",
"value": "010-12",
"code": "STUDENT_502",
"message": "전화번호 형식이 올바르지 않습니다 (예: 010-1234-5678)"
}
]
}

rowNumber는 zero-based 배열 인덱스가 아니라 spreadsheet 행 label을 가리킵니다. failureCount가 0이 아니면, 호출자는 200 OK를 전체 성공으로 간주하지 말고 반드시 errors[]를 확인해야 합니다.

StudentExcelParserTest.parsesCsvWhenFilenameHasCsvExtension(...)는 UTF-8 경로를 검증하고, StudentExcelParserTest.parsesCp949CsvExportedFromKoreanExcel(...)는 한국 Excel CSV export에 쓰이는 x-windows-949 입력을 같은 parser가 수용함을 검증합니다. StudentExcelParser는 파일을 invalid template으로 거부하기 전에 EUC-KR도 fallback charset으로 시도합니다.

소스 앵커:

  • lumie-backend/modules/student/src/main/java/com/lumie/student/adapter/in/web/StudentController.java
  • lumie-backend/modules/student/src/main/java/com/lumie/student/application/dto/response/BulkImportResult.java

의존성과 경계

의존성존재 이유
AuthServiceauth 사용자 생성/삭제, 비밀번호 재설정, 로그인 ID 변경
BillingService학생 quota 검사
ClassService영구 삭제 전 활성 enrollment 제거

실패 모드

  • 등록과 수정은 숫자만 남긴 학생 phone이 11자리가 아니거나 parentPhone이 잘못된 경우 INVALID_PHONE_NUMBER로, 학생 연락처가 중복된 경우 DUPLICATE_PHONE으로 실패할 수 있습니다.
  • 등록은 student 행 저장 전에 AUTH_OP_FAILED 또는 quota 관련 오류로도 실패할 수 있습니다.
  • 삭제는 활성 학생이면 실패합니다.
  • 대량 import는 잘못된 template 형식, malformed CSV, 행 수 초과, CSV column 수 초과를 행-level insertion 전에 거부합니다. .csv 업로드에서는 파일 bytes가 UTF-8, x-windows-949, EUC-KR 중 어느 charset으로도 기대한 한국어 header로 decode되지 않는 경우도 여기에 포함됩니다.
  • 대량 import는 전화번호 형식 오류, 중복 전화번호, quota 소진, spreadsheet formula로 해석될 수 있는 텍스트, auth 생성 실패, 예상치 못한 insert 오류로 row별 실패할 수 있습니다.
  • 영구 삭제의 파괴적 단계는 auth 삭제이므로, auth 측 삭제가 실패하면 student row는 그대로 남습니다.

관측성과 할당량 동작

  • 수명주기과 batch 작업은 StudentCommandService에서 성공 수와 실패를 로그합니다.
  • 대량 import는 all-or-nothing 롤백 대신 행 수준 오류가 담긴 partial success를 반환합니다.
  • student 모듈도 quota를 인식하지만, staff 모듈과 마찬가지로 현재 billing internal adapter에서 제한 없는 허용 결과를 받습니다.

검증

./gradlew :modules:student:test
./gradlew :app:test --tests '*Student*'
cd lumie-document/docusaurus && npm run build

예상 성공 신호:

  • Gradle이 BUILD SUCCESSFUL로 끝납니다.
  • StudentExcelParserTestStudentCommandServiceTest가 실패 없이 통과합니다.
  • Docusaurus가 backend/student-svc에 대해 MDX 또는 broken-link 오류 없이 완료됩니다.

관련 페이지