Skip to main content

Content 모듈

Content 모듈은 학원 운영에 필요한 다양한 콘텐츠를 관리합니다. 공지사항, Q&A 게시판, 상담 예약, 리뷰, 일정 관리, 교재 관리 등의 기능을 제공합니다.

모듈 개요

  • 배포: lumie-backend 모놀리스의 modules/content
  • 데이터베이스: PostgreSQL (멀티테넌트 RLS)
  • 주요 의존성: TenantService(internal-api)

주요 기능

공지사항 관리

  • 일반 공지사항 및 자산 공지사항 구분
  • 중요 공지사항 설정
  • 학원별 공지사항 타겟팅
  • 공지사항 CRUD 및 목록 조회

Q&A 게시판

  • 질문 등록 및 답변 관리
  • 댓글 시스템 (학생/관리자)
  • 미답변 질문 필터링
  • 제목, 내용, 학생 정보 기반 키워드 검색
  • 사용자별 질문 조회

상담 예약 시스템

  • 일정 생성 및 관리
  • 예약 상태 관리 (대기/확정/취소/완료)
  • 관리자별 일정 조회
  • 예약 가능한 일정 필터링

리뷰 관리

  • 리뷰 조회 및 삭제
  • 리뷰 팝업 설정 관리

교재 관리

  • 교재 정보 CRUD
  • 과목별 교재 분류
  • 교재 상태 관리 (활성/비활성)

API 엔드포인트

공지사항 API

공지사항 목록 조회

GET /v1/announcements?isAsset=false&page=0&size=20

중요 공지사항 조회

GET /v1/announcements/important

공지사항 생성

POST /v1/announcements
Content-Type: application/json

{
"authorId": 1,
"announcementTitle": "새학기 개강 안내",
"announcementContent": "2024년 새학기가 시작됩니다...",
"isItAssetAnnouncement": false,
"isItImportantAnnouncement": true,
"academyIds": [1, 2, 3]
}

공지사항 수정

PUT /v1/announcements/{id}
Content-Type: application/json

{
"announcementTitle": "수정된 제목",
"announcementContent": "수정된 내용",
"isItAssetAnnouncement": false,
"isItImportantAnnouncement": false,
"academyIds": [1, 2]
}

Q&A API

Q&A 목록 조회

GET /v1/qna?keyword=수학&page=0&size=20

keyword는 선택값이며 Q&A 제목, 내용, 학생 정보 검색에 사용됩니다. isAnswered=true|false와 함께 전달하면 답변 상태와 검색어를 동시에 적용합니다.

미답변 Q&A 조회

GET /v1/qna/unanswered?page=0&size=20

사용자별 Q&A 조회

GET /v1/qna/user/{userId}?page=0&size=20

Q&A 생성

POST /v1/qna
Content-Type: application/json

{
"qnaUserId": 123,
"qnaTitle": "수업 시간 문의",
"qnaContent": "다음 주 수업 시간이 변경되나요?"
}

댓글 추가

POST /v1/qna/{qnaId}/comments
Content-Type: application/json

{
"adminId": 1,
"commentContent": "네, 다음 주 화요일 수업은 오후 3시로 변경됩니다."
}

예약 관리 API

일정 생성

POST /v1/schedules
Content-Type: application/json

{
"date": "2024-03-15",
"timeSlotId": 3
}

날짜 범위별 일정 조회

GET /v1/schedules/date-range?startDate=2024-03-01&endDate=2024-03-31

예약 가능한 일정 조회

GET /v1/schedules/available?startDate=2024-03-01&endDate=2024-03-31

예약 생성

POST /v1/reservations
Content-Type: application/json

{
"scheduleId": 1,
"studentId": 123,
"adminId": 1,
"consultationContent": "진로 상담"
}

예약 상태 변경

PUT /v1/reservations/{id}/status
Content-Type: application/json

{
"status": "CONFIRMED"
}

교재 관리 API

교재 목록 조회

GET /v1/textbooks?subject=수학&page=0&size=20

활성 교재 목록

GET /v1/textbooks/active

교재 생성

POST /v1/textbooks
Content-Type: application/json

{
"academyId": 1,
"name": "고등수학 기본서",
"description": "고등학교 1학년 수학 기본서",
"author": "김수학",
"publisher": "수학출판사",
"isbn": "978-89-123-4567-8",
"subject": "수학",
"gradeLevel": "고1",
"price": 25000,
"coverImagePath": "/images/textbook1.jpg"
}

리뷰 관리 API

리뷰 목록 조회

GET /v1/reviews?page=0&size=20

리뷰 팝업 설정 조회

GET /v1/reviews/popup-setting

리뷰 팝업 설정 변경

PUT /v1/reviews/popup-setting
Content-Type: application/json

{
"isReviewPopupOn": true
}

도메인 모델

Announcement 엔티티

@Entity
public class Announcement extends BaseEntity {
private Long authorId; // 작성자 ID
private String announcementTitle; // 제목
private String announcementContent; // 내용
private Boolean isItAssetAnnouncement; // 자산 공지사항 여부
private Boolean isItImportantAnnouncement; // 중요 공지사항 여부
private Set<Long> academyIds; // 대상 학원 ID 목록

public static Announcement create(Long authorId, String title, String content,
Boolean isAsset, Boolean isImportant, Set<Long> academyIds);
public void update(String title, String content, Boolean isAsset, Boolean isImportant);
public void markImportant();
public void unmarkImportant();
}

QnaBoard 엔티티

@Entity
public class QnaBoard extends BaseEntity {
private Long qnaUserId; // 질문자 ID
private String qnaTitle; // 제목
private String qnaContent; // 내용
private Boolean isItAnswered; // 답변 완료 여부
private List<QnaComment> comments; // 댓글 목록

public static QnaBoard create(Long userId, String title, String content);
public void update(String title, String content);
public void markAsAnswered();
public void addComment(QnaComment comment);
}

QnaComment 엔티티

@Entity
public class QnaComment extends BaseEntity {
private QnaBoard qnaBoard; // 소속 Q&A
private Long studentId; // 학생 ID (학생 댓글인 경우)
private Long adminId; // 관리자 ID (관리자 댓글인 경우)
private String commentContent; // 댓글 내용

public static QnaComment createByStudent(QnaBoard qnaBoard, Long studentId, String content);
public static QnaComment createByAdmin(QnaBoard qnaBoard, Long adminId, String content);
public boolean isAdminComment();
public boolean isStudentComment();
}

Schedule 엔티티

@Entity
public class Schedule extends BaseEntity {
private Long adminId; // 담당 관리자 ID
private LocalDate date; // 일정 날짜
private Integer timeSlotId; // 시간대 ID
private Boolean isAvailable; // 예약 가능 여부
private List<Reservation> reservations; // 예약 목록

public static Schedule create(Long adminId, LocalDate date, Integer timeSlotId);
public void update(LocalDate date, Integer timeSlotId);
public void open();
public void close();
public boolean canAcceptReservation();
public boolean hasReservation();
public int getConfirmedCount();
}

Reservation 엔티티

@Entity
public class Reservation extends BaseEntity {
private Schedule schedule; // 예약 일정
private Long studentId; // 학생 ID
private Long adminId; // 담당 관리자 ID
private String consultationContent; // 상담 내용
private ReservationStatus status; // 예약 상태

public static Reservation create(Schedule schedule, Long studentId,
Long adminId, String content);
public void confirm();
public void cancel();
public void complete();
public boolean isPending();
public boolean isConfirmed();
public boolean isCancelled();
public boolean isCompleted();
}

Textbook 엔티티

@Entity
public class Textbook extends BaseEntity {
private Long academyId; // 소속 학원 ID
private String name; // 교재명
private String description; // 설명
private String author; // 저자
private String publisher; // 출판사
private String isbn; // ISBN
private String subject; // 과목
private String gradeLevel; // 학년
private Integer price; // 가격
private String coverImagePath; // 표지 이미지 경로
private TextbookStatus status; // 상태 (ACTIVE/INACTIVE)

public static Textbook create(Long academyId, String name, String description, ...);
public void update(String name, String description, ...);
public void activate();
public void deactivate();
public boolean isActive();
}

비즈니스 로직

예약 상태 전이 규칙

private void validateStatusTransition(ReservationStatus current, ReservationStatus target) {
Map<ReservationStatus, Set<ReservationStatus>> allowedTransitions = Map.of(
PENDING, Set.of(CONFIRMED, CANCELLED),
CONFIRMED, Set.of(COMPLETED, CANCELLED),
CANCELLED, Set.of(), // 취소된 예약은 상태 변경 불가
COMPLETED, Set.of() // 완료된 예약은 상태 변경 불가
);

if (!allowedTransitions.get(current).contains(target)) {
throw new ContentException(ContentErrorCode.INVALID_STATUS_TRANSITION,
String.format("Cannot transition from %s to %s", current, target));
}
}

Q&A 자동 답변 완료 처리

public void addComment(QnaComment comment) {
this.comments.add(comment);
// 관리자가 댓글을 달면 자동으로 답변 완료 처리
if (!this.isItAnswered && comment.getAdminId() != null) {
this.markAsAnswered();
}
}

일정 예약 가능 여부 확인

public boolean canAcceptReservation() {
if (!this.isAvailable) {
return false;
}
// 이미 확정되거나 대기 중인 예약이 있으면 불가
return reservations.stream()
.noneMatch(r -> r.isConfirmed() || r.isPending());
}

모듈 간 연동

content 모듈은 StudentServiceTenantService를 주입받아 in-process 메서드 호출로 학생·테넌트 정보를 검증합니다. 네트워크 오버헤드 없이 동일 JVM 내에서 처리됩니다.

Academy Service 연동

@Component
@RequiredArgsConstructor
public class AcademyInternalAdapter implements ValidateStudentPort {

private final StudentService studentService;

@Override
public boolean validateStudent(Long studentId) {
String tenantSlug = TenantContextHolder.getRequiredTenant();
StudentService.ValidateStudentResult result =
studentService.validateStudent(tenantSlug, studentId);
return result.valid();
}

@Override
public Optional<StudentService.StudentData> getStudent(Long studentId) {
String tenantSlug = TenantContextHolder.getRequiredTenant();
return studentService.getStudent(tenantSlug, studentId);
}
}

Tenant Service 연동

@Component
@RequiredArgsConstructor
public class TenantInternalAdapter implements ValidateTenantPort {

private final TenantService tenantService;

@Override
public boolean validateTenant(String slug) {
TenantService.ValidateTenantResult result =
tenantService.validateTenant(slug);
return result.valid();
}
}

데이터베이스 설계

주요 테이블 구조

-- 공지사항 테이블
CREATE TABLE announcement (
id BIGSERIAL PRIMARY KEY,
author_id BIGINT NOT NULL,
announcement_title VARCHAR(255) NOT NULL,
announcement_content TEXT,
is_it_asset_announcement BOOLEAN DEFAULT false,
is_it_important_announcement BOOLEAN DEFAULT false,
academy_ids BIGINT[] DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Q&A 게시판 테이블
CREATE TABLE qna_board (
id BIGSERIAL PRIMARY KEY,
qna_user_id BIGINT NOT NULL,
qna_title VARCHAR(255) NOT NULL,
qna_content TEXT,
is_it_answered BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Q&A 댓글 테이블
CREATE TABLE qna_comment (
id BIGSERIAL PRIMARY KEY,
qna_board_id BIGINT NOT NULL REFERENCES qna_board(id),
student_id BIGINT,
admin_id BIGINT,
comment_content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_comment_author CHECK (
(student_id IS NOT NULL AND admin_id IS NULL) OR
(student_id IS NULL AND admin_id IS NOT NULL)
)
);

-- 일정 테이블
CREATE TABLE schedule (
id BIGSERIAL PRIMARY KEY,
admin_id BIGINT NOT NULL,
date DATE NOT NULL,
time_slot_id INTEGER NOT NULL,
is_available BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 예약 테이블
CREATE TABLE reservation (
id BIGSERIAL PRIMARY KEY,
schedule_id BIGINT NOT NULL REFERENCES schedule(id),
student_id BIGINT NOT NULL,
admin_id BIGINT,
consultation_content TEXT,
status VARCHAR(20) DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 교재 테이블
CREATE TABLE textbook (
id BIGSERIAL PRIMARY KEY,
academy_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
author VARCHAR(100),
publisher VARCHAR(100),
isbn VARCHAR(20),
subject VARCHAR(50),
grade_level VARCHAR(20),
price INTEGER,
cover_image_path VARCHAR(500),
status VARCHAR(20) DEFAULT 'ACTIVE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

인덱스 전략

-- 성능 최적화를 위한 인덱스
CREATE INDEX idx_announcement_important ON announcement(is_it_important_announcement);
CREATE INDEX idx_announcement_asset ON announcement(is_it_asset_announcement);
CREATE INDEX idx_qna_board_user ON qna_board(qna_user_id);
CREATE INDEX idx_qna_board_answered ON qna_board(is_it_answered);
CREATE INDEX idx_schedule_date ON schedule(date);
CREATE INDEX idx_schedule_admin ON schedule(admin_id);
CREATE INDEX idx_reservation_student ON reservation(student_id);
CREATE INDEX idx_reservation_schedule ON reservation(schedule_id);
CREATE INDEX idx_textbook_academy ON textbook(academy_id);
CREATE INDEX idx_textbook_subject ON textbook(subject);
CREATE INDEX idx_textbook_status ON textbook(status);

설정

content 모듈은 독립적인 설정 파일이 없습니다. 모든 설정은 app/src/main/resources/application.yaml에서 통합 관리됩니다. 모듈 간 통신(StudentService, TenantService)은 in-process 메서드 호출로 처리됩니다.

에러 처리

도메인 예외

public enum ContentErrorCode implements ErrorCode {
// 404 Not Found
ANNOUNCEMENT_NOT_FOUND("CONTENT_001", "공지사항을 찾을 수 없습니다", 404),
QNA_NOT_FOUND("CONTENT_002", "Q&A를 찾을 수 없습니다", 404),
COMMENT_NOT_FOUND("CONTENT_003", "댓글을 찾을 수 없습니다", 404),
SCHEDULE_NOT_FOUND("CONTENT_004", "일정을 찾을 수 없습니다", 404),
RESERVATION_NOT_FOUND("CONTENT_005", "예약을 찾을 수 없습니다", 404),
TEXTBOOK_NOT_FOUND("CONTENT_006", "교재를 찾을 수 없습니다", 404),
REVIEW_NOT_FOUND("CONTENT_007", "리뷰를 찾을 수 없습니다", 404),

// 409 Conflict
DUPLICATE_RESERVATION("CONTENT_008", "이미 예약이 존재합니다", 409),

// 400 Bad Request
SCHEDULE_NOT_AVAILABLE("CONTENT_009", "예약할 수 없는 일정입니다", 400),
SCHEDULE_FULL("CONTENT_010", "일정이 가득 찼습니다", 400),
INVALID_RESERVATION_STATUS("CONTENT_011", "잘못된 예약 상태입니다", 400),
INVALID_DATE_RANGE("CONTENT_012", "잘못된 날짜 범위입니다", 400);
}

글로벌 예외 처리

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException ex) {
ErrorCode errorCode = ex.getErrorCode();
log.warn("Business exception: {} - {}", errorCode.getCode(), ex.getMessage());

return ResponseEntity
.status(errorCode.getStatus())
.body(new ErrorResponse(
errorCode.getCode(),
ex.getMessage(),
LocalDateTime.now()
));
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidationException(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});

log.warn("Validation failed: {}", errors);

return ResponseEntity
.badRequest()
.body(new ValidationErrorResponse(
"VALIDATION_FAILED",
"입력값 검증에 실패했습니다",
errors,
LocalDateTime.now()
));
}
}

보안 설정

보안 설정은 모놀리스 app 모듈의 SecurityConfig에서 통합 관리됩니다. JwtAuthenticationFilter가 모든 요청의 JWT를 검증하며, 공개 엔드포인트는 SecurityConfig에서 permitAll()로 허용됩니다.

자세한 내용은 인증 & 멀티테넌시를 참고하세요.

멀티테넌시 설정

Hibernate 멀티테넌시 설정은 app 모듈의 JpaConfig에서 통합 관리됩니다. TenantConnectionProviderTenantIdentifierResolverlibs/common에 구현되어 있으며, content-svc를 포함한 모든 모듈이 동일한 스키마 라우팅 메커니즘을 공유합니다.

자세한 내용은 멀티테넌시를 참고하세요.

모니터링

헬스 체크

GET /actuator/health

{
"status": "UP",
"components": {
"db": {"status": "UP"}
}
}

주요 메트릭

  • 공지사항 생성/조회 수
  • Q&A 등록/답변 수
  • 예약 생성/상태 변경 수
  • 교재 등록/조회 수
  • internal-api 호출 응답 시간
  • 데이터베이스 연결 풀 상태

로그 예시

{
"timestamp": "2024-01-15T10:30:00.000Z",
"level": "INFO",
"logger": "com.lumie.content.application.service.AnnouncementCommandService",
"message": "Announcement created with id: 123",
"tenantSlug": "inst-demo",
"userId": "456",
"userRole": "ADMIN"
}

배포

content-svc는 독립적으로 배포되지 않습니다. lumie-backend.jar에 포함되어 단일 Spring Boot 애플리케이션으로 배포됩니다.

  • 네임스페이스: lumie-backend
  • Replica: 3
  • 포트: 8080 (HTTP only)

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

관련 문서

Content Service는 학원 운영의 다양한 콘텐츠를 체계적으로 관리하여 효율적인 학원 운영을 지원하는 핵심 서비스입니다.