도메인 설계

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 10
100%휠로 확대 · 끌어서 이동

핵심 타입

ExamEligibility5단계 응시 자격. 원본이 두 곳에 중복해 두던 판정
Grader문항당 5점 · 합격 70점 · 복수정답은 집합 일치
LevelTestGrader객관식 q1~q10 + 주관식 q11~q20 통합 채점
EntitlementReadermember 를 직접 읽는 포트. identity 모듈에 의존하지 않고 자격만 가져온다

다루는 테이블

원본 대응

myclass/unitSloving*.php · tesolLab/leveltest*

모듈

assessment
모듈 README 원문

assessment 모듈

책임: 레벨테스트 (비로그인 가능), 본강의 퀴즈/유닛 시험, 최종 합격 판정, 문제은행 관리.

의존:

  • identity — 응시자 식별 (레벨테스트 제외)
  • learning — 수강권이 있는 강의의 시험만 응시 가능
  • notification — 합격/불합격 결과 알림

관련 DB 테이블:

  • exam, exam_question — 유닛/모듈별 시험
  • question_bank — 문제 풀
  • quiz_result, quiz_result_answer — 응시 결과/답안
  • level_test_list, level_test_list_right, level_test_examples, level_test_answer, level_test_poll, level_test_poll_answer — 레벨테스트 전용 (6개 테이블)

현재 UI 상태:

  • 유닛 시험은 구현됨. 응시 /my-class/unit-quiz/[unit]/[kind], 결과 /my-class/unit-result/[unit]/[kind] (TEC 은 tec- 접두어). 도메인 규칙은 src/lib/exam.ts, 제출은 서버 액션 src/actions/exam.ts 에 있고, 원본 myclass/unitSloving.php · quiz_proc.php 의 판정을 그대로 옮겼다.
    • 합격선 70점 (문항당 5점 × 20문항). exam.pass_score 도 70 으로 맞춰 두었다.
    • 차수: TESOL A/B/C, TEC A/B. 직전 유닛을 통과해야 다음 유닛이 열리고, 차수는 순서대로만 열린다.
    • 복수정답 문항은 checkbox 로 렌더하고, 고른 집합이 정답 집합과 완전히 일치해야 득점한다.
  • 남은 것: 합격/불합격 알림 발송(notification 모듈 대기), 그리고 레벨테스트. /tesol-lab/level-test 는 여전히 신청 폼(STEP 01)뿐이고, 원본 tesolLab/leveltest_form_v_01.php 의 객관식 15 + 주관식 5 응시 화면(STEP 02)이 없다.

예상 API 엔드포인트

레벨테스트 (비로그인 가능)

GET /api/assessment/level-test

레벨테스트 문제 세션 시작. 보통 랜덤 셔플된 N 개 문항.

  • Auth: 불필요 (비회원도 응시 가능)
  • Query: ?count=20 (기본 문항 수)
  • Response:
    {
      sessionId: string;   // 서버 세션 or JWT (정답 공개 방지)
      questions: Array<{
        id: number;
        text: string;
        choices: string[];
        imageUrl?: string;
      }>;
    }
    
  • 쿼리 대상: level_test_list (문제), level_test_examples (보기)
  • 보안: level_test_list_right (정답) 은 절대 노출하지 않음. 채점은 서버에서.

POST /api/assessment/level-test/submit

레벨테스트 답안 제출 + 즉시 채점.

  • Auth: 불필요
  • Body:
    {
      sessionId: string;
      answers: Array<{ questionId: number; choiceIndex: number }>;
      respondent?: {
        name: string;
        email: string;
        phone: string;
      };
    }
    
  • Response:
    {
      score: number;
      level: 'beginner' | 'intermediate' | 'advanced';
      recommendation: string;
      resultId: number;
    }
    
  • 사이드이펙트:
    • level_test_answer INSERT (응시 로그)
    • level_test_poll + level_test_poll_answer INSERT (통계용)
    • notification 으로 "상담 요청" 이벤트 (respondent 있는 경우)

GET /api/assessment/level-test/results/[resultId]

응시 결과 조회 (URL 공유 가능).

  • Auth: 불필요 (resultId 를 아는 사람만)
  • Response: 응시 결과 상세 + 상담 예약 CTA

본강의 시험/퀴즈

GET /api/assessment/exams?courseCode=TESOL

특정 강의에서 응시 가능한 시험 목록.

  • Auth: 필수 + learning 모듈의 수강권 검증
  • Response:
    {
      exams: Array<{
        id: number;
        unitNumber: number;
        title: string;
        questionCount: number;
        attemptCount: number;       // 내 응시 횟수
        maxAttempts: number;        // 보통 2~3회
        bestScore: number | null;
        passed: boolean;
        locked: boolean;            // 이전 유닛 미합격 시 잠금
      }>
    }
    

POST /api/assessment/exams/[examId]/start

시험 응시 시작. 세션 생성 + 시험 시간 타이머 시작.

  • Auth: 필수
  • Response:
    {
      attemptId: number;
      startedAt: string;
      durationSec: number;
      questions: Array<{
        id: number;
        text: string;
        choices: string[];
      }>;
    }
    
  • 사이드이펙트: quiz_result INSERT (status='in_progress')

POST /api/assessment/exams/attempts/[attemptId]/submit

시험 제출 및 채점.

  • Auth: 필수
  • Body: { answers: Array<{ questionId: number; choiceIndex: number }> }
  • Response:
    {
      score: number;
      passed: boolean;
      correctCount: number;
      totalCount: number;
      canRetry: boolean;
    }
    
  • 사이드이펙트:
    • quiz_result_answer INSERT (문항별)
    • quiz_result UPDATE (status='completed', score 저장)
    • 최종 유닛 합격 시 notification "유닛 합격" 이벤트
    • 전체 과정 합격 시 notification "최종 합격" 이벤트 + learning 모듈에 passDate 갱신 훅

시험 결과 조회

GET /api/assessment/results/me?courseCode=TESOL

내 전체 시험 결과 (unit 별 점수).

  • Auth: 필수
  • Response:
    {
      perUnit: Array<{
        unitNumber: number;
        bestScore: number;
        attempts: number;
        passed: boolean;
      }>;
      overall: {
        passed: boolean;
        passedAt: string | null;
      };
    }
    
  • 대체 대상: /my-class/result-of-course (현재 scores 하드코딩)

관리자용 (낮은 우선순위)

GET /api/assessment/admin/question-bank?courseCode=...

문제은행 관리. admin 권한 필요.

POST /api/assessment/admin/questions

문제 등록/수정.

GET /api/assessment/admin/attempts?memberId=...

특정 회원의 응시 로그 조회.

⑥ 공용 모듈 — 무엇을 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
영속성 공통 설정
-