도메인 설계

8개 도메인이 무엇을 책임지고 어떻게 이어지는지. 그림은 실제 build.gradle 과 어댑터에서 뽑은 값으로 그립니다.

도메인
8
계층
4
domain · app · infra · web
공용 모듈
6
shared/common-*
도메인 간 의존
2
적을수록 좋다

① 전체 지도 — 도메인과 실행 서버

서버는 둘이다. wvctesol-auth 가 신원을 확인해 토큰을 발급하고, wvctesol-api 는 그 토큰을 검증만 한다. 발급과 검증을 나눠 두면 API 서버가 늘어도 비밀키를 쥔 곳은 하나로 남는다.

100%휠로 확대 · 끌어서 이동

도메인 간 직접 의존은 두 개뿐이다 — commerce → catalog(주문이 가격을 읽는다), identity → notification(아이디 안내를 보낸다). 나머지는 서로를 모른다. 함께 봐야 하는 것은 shared/common-domain 에 둔다.

② 계층 — 화살표 방향이 규칙이다

100%휠로 확대 · 끌어서 이동

domain 은 스프링도 JPA 도 모른다. 그래서 테스트가 빠르고, 프레임워크를 바꿔도 규칙이 남는다. infrastructuredomain 의 포트를 구현하므로 화살표가 안쪽을 향한다 — 의존이 뒤집히는 지점이다.

계층별 의존 대상

계층의존하는 일
domainshared/common-domain 만스프링도 JPA 도 모른다. 순수 자바다
appdomain + 다른 도메인의 app/domain유스케이스와 트랜잭션 경계
infrastructuredomain + appJPA 어댑터. 레거시 표현 변환이 여기서 끝난다
webdomain + appHTTP 경계. infrastructure 를 모른다

webinfrastructure 를 모르는 것이 중요하다. 컨트롤러가 EntityManager 를 직접 쓰기 시작하면 계층이 무너진다 — 실제로 관리자 계정 조회를 만들 때 그렇게 썼다가 되돌렸다.

③ 수강권 — 도메인을 가로지르는 흐름

이 서비스에서 가장 중요한 상태는 수강권이다. 돈을 낸 사람만 강의를 보고 시험을 칠 수 있다. 그 값을 commerce 가 켜고 learning · assessment 가 읽는다 — 세 도메인이 member 한 테이블을 통해 이어진다.

100%휠로 확대 · 끌어서 이동
원본과 다른 점 — 원본은 결제가 회원 플래그를 건드리지 않았다. 관리자가 admin/member/mb_proc.php 에서 손으로 켰다. 카드 승인·입금 확인 시 자동으로 여는 것은 이쪽에서 정한 규칙이다.

④ 함께 보는 테이블

레거시 스키마를 그대로 쓰기 때문에 한 테이블을 여러 도메인이 본다. 이 지점이 도메인 경계가 흐려지기 쉬운 곳이라 그림으로 드러내 둔다.

100%휠로 확대 · 끌어서 이동

member 를 넷이 본다. 다만 쓰는 것은 identity 와 commerce 뿐이고 나머지는 읽기만 한다. apply 는 assessment(레벨테스트 신청)와 marketing(상담 관리)이 각각 다른 관점으로 본다.

⑤ 수강 · 커리큘럼

강의를 보여 주고 성적표를 만든다. 볼 자격이 있는지 먼저 묻는다.

자세히 →
wvctesol-api :8080API 16
100%휠로 확대 · 끌어서 이동

핵심 타입

CurriculumUnit유닛과 강의
ScoreReport평균의 분모는 합격한 유닛 수다 — 전체가 아니다
StudentInfo자료 노출 조건이 단계적이다
DocumentStorage자료 파일 포트. 저장소가 어디든 도메인은 모른다

다루는 테이블

원본 대응

myclass/*.php

모듈

learning
모듈 README 원문

learning 모듈

책임: 커리큘럼 구조, 본강의 영상 재생, 수강권 활성화, 진도 추적, 학생 정보 조회/수정.

의존:

  • identity — 로그인 사용자 컨텍스트 (대부분의 엔드포인트가 인증 필요)
  • catalog — course 메타데이터
  • commerce — 결제 완료 시 enrollment 활성화 (이벤트 수신자)

관련 DB 테이블:

  • course, unit, lesson — 커리큘럼 구조 (기존)
  • lecture_apply — 수강 신청/활성화 레코드
  • lecture_progress — 레슨 단위 진도 로그
  • lecture_progress_total — 사용자+코스 단위 진도 집계
  • member — 학생 정보 (읽기)

현재 UI 상태: 강의실의 뼈대는 DB 연동을 마쳤다.

  • 커리큘럼 · 강의 영상src/lib/curriculum.tsunit · 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 개가 맞다.
  • Quiz 칸quiz_result 를 읽어 점수 링크 또는 다음 차수 응시 버튼을 그린다(원본 myOnlineClass.php:113~135).

  • 성적표/my-class/result-of-course · /my-class/tec-result-of-coursegetCourseResult() 로 계산한다. 통과한 유닛만 점수가 잡히고 Unit Total 은 그 평균이다(원본 resultOfCourse.php:99~143).

  • 학생 정보/my-class/student-information · /my-class/tec-student-informationsrc/lib/student.tsmember 를 읽는다. 입학확인증 행은 결제+준비완료, 스캔본 행은 거기에 사이버승인+합격까지 만족해야 보인다(원본 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_apply WHERE member_id= AND status 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_progress INSERT/UPDATE (lesson 단위)
    • lecture_progress_total UPSERT (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_apply INSERT, 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
영속성 공통 설정
-