본문으로 건너뛰기

콘텐츠 서비스

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

책임

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

  • 공지사항, 공지사항의 수업 target, 공지 읽음 추적
  • Q&A 게시판과 댓글 스레드
  • 리뷰와 리뷰 팝업 토글

소스 경로

경로역할
lumie-backend/modules/content/src/main/java/com/lumie/content/adapter/in/webannouncement, Q&A, review용 공개 HTTP 컨트롤러
lumie-backend/modules/content/src/main/java/com/lumie/content/adapter/in/internal/ContentServiceAdapter.javainternal monolith API 구현
lumie-backend/modules/content/src/main/java/com/lumie/content/application/serviceannouncement, Q&A, review service
lumie-backend/modules/content/src/main/java/com/lumie/content/domain/entitycontent aggregate와 보조 table
lumie-backend/libs/internal-api/src/main/java/com/lumie/content/api/ContentService.javaannouncement 생성과 visibility check를 위한 internal 계약
lumie-backend/app/src/main/resources/db/migration/public/V18__rls_baseline.sqlannouncements, qna_boards, qna_comments, reviews, toggles 등의 기준선 테이블
lumie-backend/app/src/main/resources/db/migration/public/V20__rename_qna_is_it_answered.sqlqna_boards.is_it_answeredis_answered로 이름 변경하고 version 추가
lumie-backend/app/src/main/resources/db/migration/public/V24__add_version_to_domain_mutable_entities.sqlmutable content table에 version 추가
lumie-backend/app/src/main/resources/db/migration/public/V43__qna_boards_add_category_name.sqlQ&A category 데이터의 legacy 가져오기 지원
lumie-backend/app/src/main/resources/db/migration/public/V55__create_announcement_class_targets.sql수업 target visibility 행 생성
lumie-backend/app/src/main/resources/db/migration/public/V56__announcement_class_targets_tenant_safe_fk.sqlannouncement target FK를 테넌트-safe하게 변경
lumie-backend/app/src/main/resources/db/migration/public/V62__add_read_receipt_tables.sqlannouncement_reads 생성
lumie-backend/app/src/main/resources/db/migration/public/V65__repair_read_receipt_and_file_download_rls.sqlannouncement read에 대한 테넌트-safe RLS와 FK 동작 복구

공개 인터페이스

인터페이스진입점
AnnouncementsGET/POST /v1/announcements, GET /v1/announcements/important, GET /v1/announcements/{id}, GET /v1/announcements/{id}/readers, PATCH /v1/announcements/{id}, DELETE /v1/announcements/{id}
Q&AGET/POST /v1/qna, GET /v1/qna/unanswered, GET /v1/qna/statistics/unanswered-overdue, GET /v1/qna/user/{qnaUserId}, GET/PATCH/DELETE /v1/qna/{id}, POST /v1/qna/{id}/comments, DELETE /v1/qna/{qnaId}/comments/{commentId}
ReviewsPOST /v1/reviews, GET /v1/reviews, DELETE /v1/reviews/{id}, GET /v1/reviews/popup-setting, PUT /v1/reviews/popup-setting

내부 인터페이스

ContentService는 다른 모듈에서 동기식으로 소비되며, 현재 다음을 노출합니다.

  • createAnnouncement(...)
  • canUserViewAnnouncement(...)
  • canUserViewQna(...)

이 내부 API는 일반 CRUD가 아니라 운영적인 성격을 가집니다. 전체 content 수정이 아니라 visibility check와 소규모 announcement 생성 helper만 내보냅니다.

집계와 테이블

집계테이블참고
Announcementannouncements기본 공지 레코드
AnnouncementClassTargetannouncement_class_targets선택적 수업-target visibility 필터
AnnouncementReadannouncement_reads사용자별 읽음 영수증, idempotent insert
QnaBoardqna_boardsis_answered, author_last_read_at를 가진 질문 스레드
QnaCommentqna_comments관리자 또는 학생 댓글
Reviewreviews공개 review 텍스트
ReviewPopupSettingtoggles고정 ID로 관리되는 review popup 스위치

ReviewPopupSetting은 특수 사례입니다. content 전용 설정 table이 아니라 범용 toggles table에 review popup 상태를 저장합니다.

런타임 흐름

공지 가시성과 읽음 추적

반 대상 계약의 형태

private void validateClassTargets(List<Long> classIds) {
String tenantSlug = TenantContextHolder.getRequiredTenant();
for (Long classId : classIds) {
if (classService.getClass(tenantSlug, classId).isEmpty()) {
throw new BusinessException(ContentErrorCode.INVALID_CLASS_TARGET);
}
}
}

수업-target 공지사항은 수업 모듈을 통해 검증됩니다. content 모듈은 수업 aggregate 복사본이 아니라 수업 ID만 저장합니다.

핵심 동작

공지사항

  • staff는 수업 target을 붙일 수 있으며, 학생은 자신이 등록된 수업 중 하나를 target으로 삼은 공지나 target이 없는 공지만 볼 수 있습니다.
  • 학생 읽기는 announcementReadRepository.insertIfAbsent(...)를 호출하므로, 반복 조회가 중복 row를 만들지 않습니다.
  • 공지 응답은 의도적으로 student 호출자에게 classIds를 숨기고, staff 측 호출자에게만 이를 노출합니다.
  • 파일 링크는 FileService.replaceFileLinks(...), deleteFileLinksByEntity(...), deleteFilesByEntity(...)로 관리됩니다.

Q&A

  • QnaBoard.addComment(...)는 admin comment가 추가되면 thread를 answered로 표시합니다.
  • 마지막 admin comment를 제거하면 thread는 다시 unanswered가 됩니다.
  • 작성자가 thread를 읽으면 author_last_read_at이 갱신됩니다.
  • student 가시성은 작성자 본인에게만 허용됩니다. staff 가시성은 테넌트 내부에서 제한되지 않습니다.

대표 계약 예시

이 예시는 CreateAnnouncementRequest, AnnouncementResponse, AnnouncementController, AnnouncementCommandService, AnnouncementQueryService와 일치합니다.

Staff가 반 대상 공지를 생성

POST /v1/announcements

{
"authorId": 900,
"announcementTitle": "보강 일정 안내",
"announcementContent": "이번 주 토요일 10시에 보강 수업이 있습니다.",
"isItAssetAnnouncement": false,
"isItImportantAnnouncement": true,
"textbookFileIds": [],
"classIds": [2, 3]
}
{
"id": 1,
"authorId": 900,
"authorName": "원장",
"announcementTitle": "보강 일정 안내",
"announcementContent": "이번 주 토요일 10시에 보강 수업이 있습니다.",
"isItAssetAnnouncement": false,
"isItImportantAnnouncement": true,
"createdAt": "2026-06-14T09:00:00Z",
"updatedAt": "2026-06-14T09:00:00Z",
"classIds": [2, 3]
}

학생이 같은 공지를 읽음

GET /v1/announcements/1

{
"id": 1,
"authorId": 900,
"authorName": "원장",
"announcementTitle": "보강 일정 안내",
"announcementContent": "이번 주 토요일 10시에 보강 수업이 있습니다.",
"isItAssetAnnouncement": false,
"isItImportantAnnouncement": true,
"createdAt": "2026-06-14T09:00:00Z",
"updatedAt": "2026-06-14T09:00:00Z",
"classIds": []
}

student 응답은 responseClassIds(...)를 통해 의도적으로 classIds를 비웁니다. 현재 학생이 target 수업 중 하나에 등록되어 있지 않으면, getAnnouncement(...)는 별도 visibility 오류가 아니라 ANNOUNCEMENT_NOT_FOUND를 반환합니다.

의존성과 경계

의존성존재 이유
ClassServiceannouncement 수업 target과 student 수업 visibility 검증
StudentServiceQ&A 또는 announcement reader용 student 이름과 OWNER visibility 해석
StaffServicestaff author 이름 해석
FileServiceannouncement와 Q&A의 파일 링크 및 첨부 정리 관리

이 모듈은 student, staff, 수업 레코드를 소유하지 않습니다. foreign key와 유사한 ID만 저장하고, 이름이나 visibility는 쿼리 시점에 내부 API를 통해 해석합니다.

실패 모드

  • 잘못되었거나 존재하지 않는 수업 target은 INVALID_CLASS_TARGET을 던집니다.
  • 숨겨진 공지사항은 별도 permission 오류가 아니라 의도적으로 ANNOUNCEMENT_NOT_FOUND로 보고됩니다.
  • Q&A 읽기/쓰기는 aggregate나 reply가 없을 때 QNA_NOT_FOUND, COMMENT_NOT_FOUND로 실패합니다.
  • review popup 상태는 범용 toggles table row를 공유하므로, tenant에 그 row가 존재해야 이 경로가 동작합니다.

관측성

  • 모듈은 애플리케이션 서비스에서 create, update, delete, 쿼리 활동을 로그합니다.
  • 이 모듈에는 queue, retry, scheduled recovery loop가 없습니다.
  • pagination에 민감한 Q&A 읽기는 fetch-join pagination 대신 batch-fetch 친화 패턴을 사용합니다.

검증

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

예상 성공 신호:

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

관련 페이지