도메인 설계
8개 도메인이 무엇을 책임지고 어떻게 이어지는지. 그림은 실제 build.gradle 과 어댑터에서 뽑은 값으로 그립니다.
① 전체 지도 — 도메인과 실행 서버
서버는 둘이다. wvctesol-auth 가 신원을 확인해 토큰을 발급하고, wvctesol-api 는 그 토큰을 검증만 한다. 발급과 검증을 나눠 두면 API 서버가 늘어도 비밀키를 쥔 곳은 하나로 남는다.
도메인 간 직접 의존은 두 개뿐이다 — commerce → catalog(주문이 가격을 읽는다), identity → notification(아이디 안내를 보낸다). 나머지는 서로를 모른다. 함께 봐야 하는 것은 shared/common-domain 에 둔다.
② 계층 — 화살표 방향이 규칙이다
domain 은 스프링도 JPA 도 모른다. 그래서 테스트가 빠르고, 프레임워크를 바꿔도 규칙이 남는다. infrastructure 가 domain 의 포트를 구현하므로 화살표가 안쪽을 향한다 — 의존이 뒤집히는 지점이다.
계층별 의존 대상
| 계층 | 의존 | 하는 일 |
|---|---|---|
domain | shared/common-domain 만 | 스프링도 JPA 도 모른다. 순수 자바다 |
app | domain + 다른 도메인의 app/domain | 유스케이스와 트랜잭션 경계 |
infrastructure | domain + app | JPA 어댑터. 레거시 표현 변환이 여기서 끝난다 |
web | domain + app | HTTP 경계. infrastructure 를 모른다 |
web 이 infrastructure 를 모르는 것이 중요하다. 컨트롤러가 EntityManager 를 직접 쓰기 시작하면 계층이 무너진다 — 실제로 관리자 계정 조회를 만들 때 그렇게 썼다가 되돌렸다.
③ 수강권 — 도메인을 가로지르는 흐름
이 서비스에서 가장 중요한 상태는 수강권이다. 돈을 낸 사람만 강의를 보고 시험을 칠 수 있다. 그 값을 commerce 가 켜고 learning · assessment 가 읽는다 — 세 도메인이 member 한 테이블을 통해 이어진다.
admin/member/mb_proc.php 에서 손으로 켰다. 카드 승인·입금 확인 시 자동으로 여는 것은 이쪽에서 정한 규칙이다.④ 함께 보는 테이블
레거시 스키마를 그대로 쓰기 때문에 한 테이블을 여러 도메인이 본다. 이 지점이 도메인 경계가 흐려지기 쉬운 곳이라 그림으로 드러내 둔다.
member 를 넷이 본다. 다만 쓰는 것은 identity 와 commerce 뿐이고 나머지는 읽기만 한다. apply 는 assessment(레벨테스트 신청)와 marketing(상담 관리)이 각각 다른 관점으로 본다.
⑤ 수강 · 커리큘럼
강의를 보여 주고 성적표를 만든다. 볼 자격이 있는지 먼저 묻는다.
자세히 →핵심 타입
CurriculumUnit | 유닛과 강의 |
ScoreReport | 평균의 분모는 합격한 유닛 수다 — 전체가 아니다 |
StudentInfo | 자료 노출 조건이 단계적이다 |
DocumentStorage | 자료 파일 포트. 저장소가 어디든 도메인은 모른다 |
모듈 README 원문
learning 모듈
책임: 커리큘럼 구조, 본강의 영상 재생, 수강권 활성화, 진도 추적, 학생 정보 조회/수정.
의존:
identity— 로그인 사용자 컨텍스트 (대부분의 엔드포인트가 인증 필요)catalog— course 메타데이터commerce— 결제 완료 시 enrollment 활성화 (이벤트 수신자)
관련 DB 테이블:
course,unit,lesson— 커리큘럼 구조 (기존)lecture_apply— 수강 신청/활성화 레코드lecture_progress— 레슨 단위 진도 로그lecture_progress_total— 사용자+코스 단위 진도 집계member— 학생 정보 (읽기)
현재 UI 상태: 강의실의 뼈대는 DB 연동을 마쳤다.
커리큘럼 · 강의 영상 —
src/lib/curriculum.ts가unit·lesson을 읽는다./my-class/online(36 레슨) ·/my-class/tec-online(56 레슨) 의 표와 영상 모달,/my-class/lecture-video/[unit]/[lesson]이 모두 여기서 나온다. 화면에 있던units·videoData배열은 없앴다.- 이관하며 TESOL UNIT2 의 첫 레슨("What makes a Good Teacher?")이 화면 목록에서 빠져 있던 버그가 드러나 함께 고쳐졌다. 원본
_lib/_exam.php:83기준 3 개가 맞다.
- 이관하며 TESOL UNIT2 의 첫 레슨("What makes a Good Teacher?")이 화면 목록에서 빠져 있던 버그가 드러나 함께 고쳐졌다. 원본
Quiz 칸 —
quiz_result를 읽어 점수 링크 또는 다음 차수 응시 버튼을 그린다(원본myOnlineClass.php:113~135).성적표 —
/my-class/result-of-course·/my-class/tec-result-of-course가getCourseResult()로 계산한다. 통과한 유닛만 점수가 잡히고 Unit Total 은 그 평균이다(원본resultOfCourse.php:99~143).학생 정보 —
/my-class/student-information·/my-class/tec-student-information이src/lib/student.ts로member를 읽는다. 입학확인증 행은 결제+준비완료, 스캔본 행은 거기에 사이버승인+합격까지 만족해야 보인다(원본studentInformation.php:87,98). 비밀번호·이름 변경 폼은 원본에도 있던 목업이라 그대로 두고, 동작하지 않는다는 안내만 붙였다.
남은 하드코딩:
course-outline— 원본(courseOutline.php)도 정적 HTML 이고, 이 화면의 본체인 카테고리("언어 듣기" 등)와 교재 이미지 파일명이 DB 에 없다. 유닛·레슨명만 DB 로 바꾸면 한 화면이 두 소스로 쪼개지므로 그대로 두었다. 현재 값은 DB(36 레슨)와 일치한다.- 진도(
lecture_progress) — 데이터는 55,060 건 있지만 원본에서도 수강생 화면이 쓰지 않는다. 관리자 화면(admin/member/timeline_detail.php)과 통계 전용이다. 수강생 쪽에 붙이려면 영상 시청 추적을 새로 만드는 일이 된다.
예상 API 엔드포인트
수강 목록
GET /api/learning/my-classes
로그인 사용자가 현재 수강 가능한 강의 목록.
- Auth: 필수 (member)
- Query: 없음
- Response:
{ items: Array<{ courseCode: string; // 'TESOL' | 'TEC' | 'PHONICS' | 'TEACHING' courseName: string; isActive: boolean; // lecture_apply.status 기반 startedAt: string | null; endsAt: string | null; professor: string; // 하드코딩 메타 or 별도 테이블 unitCount: number; progressPct: number; // 0..100 from lecture_progress_total }> } - 쿼리 대상:
lecture_applyWHEREmember_id=ANDstatus IN ('active','pending') - 대체 대상 페이지:
/my-class(현재 하드코딩된classes배열)
커리큘럼/강의 구조
GET /api/learning/courses/[courseCode]/outline
특정 강의의 단원+레슨 아웃라인 (수강생용).
- Auth: 필수.
lecture_apply로 수강권 검증. - Response:
{ course: { code, name, name_kr }; units: Array<{ unitNumber: number; title: string; lessons: Array<{ lessonNumber: number; title: string; durationSec?: number; }>; }>; } - 대체 대상:
/my-class/course-outline(현재courseUnits하드코딩)
GET /api/learning/courses/[courseCode]/units
모듈/유닛 목록 (간략). course outline 의 경량 버전.
- Response:
{ units: Array<{ unitNumber, title, lessonCount }> } - 대체 대상:
/my-class/online(현재units하드코딩)
레슨 영상 재생
GET /api/learning/lessons/[lessonId]
개별 레슨 상세 (Vimeo 영상 URL 포함).
- Auth: 필수. lesson → unit → course → lecture_apply 체인으로 수강권 검증.
- Response:
{ id: number; title: string; content: string | null; vimeoId: string | null; vimeoUrl: string | null; unitNumber: number; lessonNumber: number; } - 주의: vimeo_url 은 expiring signed URL 방식으로 감싸는 것을 권장 (도메인 제한만으로는 부족).
진도 추적
POST /api/learning/progress
레슨 시청 진도 업데이트.
- Auth: 필수
- Body:
{ lessonId: number; watchedSec: number; completed: boolean; // 사용자가 명시적 "완료" 체크 or 일정 비율 이상 } - Response:
{ ok: true, progressPct: number } - 사이드이펙트:
lecture_progressINSERT/UPDATE (lesson 단위)lecture_progress_totalUPSERT (course 단위 집계 재계산)
GET /api/learning/progress/me?courseCode=TESOL
특정 강의의 내 진도 상세.
- Auth: 필수
- Response:
{ totalLessons: number; completedLessons: number; progressPct: number; perUnit: Array<{ unitNumber: number; completed: number; total: number }>; }
수강 등록
POST /api/learning/enrollments
수강권 활성화. 보통 commerce 모듈의 결제 완료 훅에서 내부 호출.
- Auth: 필수 (내부 서비스 토큰 or 세션)
- Body:
{ memberId: number; courseCode: string; source: 'payment' | 'admin' | 'free'; orderId?: number; // commerce.order 참조 periodDays?: number; // 기본 365 } - Response:
{ enrollmentId: number, activeUntil: string } - 사이드이펙트:
lecture_applyINSERT, notification 모듈에 "수강 시작" 이벤트 발행
학생 정보
GET /api/learning/student-info/me
/my-class/student-information 페이지용 집계 데이터.
- Auth: 필수
- Response:
{ profile: { loginId: string; name: string; email: string; phone: string; }; enrollments: Array<{ courseCode: string; examPeriod: { start: string; end: string }; paid: boolean; passed: boolean; passedAt: string | null; certificateIssued: boolean; }>; } - 대체 대상:
/my-class/student-information(현재personalInfo하드코딩) - 쿼리 대상:
member+lecture_apply+ (commerce.payment) + (assessment.quiz_result) 조인
PATCH /api/learning/student-info/me/password
비밀번호 변경. /my-class/student-information 페이지의 비밀번호 폼에서 호출.
- Auth: 필수
- Body:
{ current: string, next: string } - 사이드이펙트:
member.pw업데이트, bcrypt 해싱, notification 에 "비밀번호 변경" 이벤트 발행
⑥ 공용 모듈 — 무엇을 shared 에 두는가
기준은 하나다. 여러 도메인이 함께 봐야 하는 것만 둔다. 편해서 두는 게 아니다 — shared 가 커지면 모든 도메인이 그것에 묶인다.
| 모듈 | 담은 것 | 왜 여기 있는가 |
|---|---|---|
common-domain값 객체와 도메인 예외 | CourseCodeEntitlementsDomainException 외 4종 | Entitlements 는 identity 가 만들고 assessment · learning 이 판정에 쓴다. 한쪽에 두면 반대편이 그 도메인을 통째로 의존해야 한다. |
common-security요청 주체 | ActorRequireAdmin | 조회는 언제나 Actor 기준이다. 컨트롤러가 받은 파라미터로 남의 자료를 열지 않게 한다. |
common-web응답 포맷과 예외 변환 | ApiErrorPageResponseGlobalExceptionHandler | 예외 종류가 곧 상태 코드다. 컨트롤러마다 상태 코드를 정하지 않는다. |
common-utils레거시 표현 변환 | LegacyFormat | varchar(14) 'YYYYMMDDHHmmss' 날짜와 Y/N 플래그를 여기 한 곳에서만 다룬다. |
common-app유스케이스 공통 타입 | PageRequest | - |
common-infrastructure영속성 공통 설정 | - |