도메인 설계

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

핵심 타입

Order주문. payment 테이블 한 행
Entitlement상품 코드 → 열어야 할 수강권 플래그
PaymentGatewayPG 포트. 도메인은 NICEPAY 를 모른다
OrderStateREADY · SUCCESS · CANCEL

다루는 테이블

원본 대응

products/*.php · NICEPAY/* · admin/payment/*

모듈

commerce
모듈 README 원문

commerce 모듈

책임: 수강 신청서 제출, 주문 생성, PG 결제 처리, 결제 완료 후 수강권 활성화, 주문 내역 조회, 환불.

의존:

  • identity — 주문자 식별
  • catalog — product 가격/정보
  • learning — 결제 완료 후 enrollment 생성 (이벤트 발행 대상)
  • notification — 결제 완료/실패 알림

관련 DB 테이블:

  • payment — 결제 트랜잭션
  • pay_event — 결제 상태 변경 이력
  • apply — 수강 신청서 (구매 폼 제출 데이터)
  • certificate_post — 자격증/아포스티유 등 부가 상품 주문

현재 UI 상태: /products/tesol, /products/tec, /products/tesol-tec 페이지의 EnrollmentFormSection 은 여전히 onSubmitaction 도 없는 디자인 목업이고 PG 연동도 없다. 이 모듈 구현 전에 폼 핸들러 + PG SDK 통합이 선행되어야 함.

다만 가격은 product 테이블로 일원화했다. 예전에는 화면마다 가격 문자열이 박혀 있었고 컴포넌트에 기본값까지 있어서, 값을 넘기지 않으면 어느 과정이든 TESOL 가격이 찍힐 수 있었다. 지금은 getProductPricing(code) 로 읽어 반드시 주입하며, 기본값을 없애 누락되면 타입 에러로 잡힌다. 주문할 상품 코드는 data-lecture 로 실어 두었으니 결제 연동 때 쓰면 된다.

이관 검증 중 발견해 바로잡은 마스터 데이터(정본은 원본 products/products_init.php$_PRODUCTS_LIST, 결제 처리 products_record.php 가 쓰던 값):

code 고친 내용
tec 정가 1,750,000 → 1,770,000 (products.php:203 의 표시 문구를 잘못 옮겨온 값이었다)
apostille 300,000 / 200,000 으로 정정 (150,000 / 없음 이었다)
signboard 200,000 / 150,000 으로 정정 (0 / 없음 이었다)
phonics 550,000 / 380,000 으로 신규 추가 (테이블에 아예 없었다)

PG 선택 전제: 토스페이먼츠 / 이니시스 / 카카오페이 중 하나. 이하 엔드포인트는 PG 를 추상화한 형태로 설계.


구현 상태 (2026-08-20)

신청 폼과 주문 생성은 붙였다. 카드 결제는 코드가 다 있고 상점키만 대기 중이다.

흐름 상태
신청 폼 제출 submitEnrollment() (src/actions/commerce.ts) — 목업이던 폼에 <form>name 을 붙였다
주문 생성 createOrder()payment INSERT. 주문번호는 원본과 같은 YmdHis+4자리
무통장 입금 ✅ 계좌 안내까지 끝까지 동작 (state='success', 수강권은 열지 않음)
카드 결제 🔑 NICEPAY 연동 코드 완비. .envNICEPAY_MID · NICEPAY_MERCHANT_KEY 를 넣으면 결제창이 열린다
승인 콜백 🔑 /api/commerce/nicepay/return — 인증 검증 → 승인(NextAppURL) → state='success' + 수강권 활성화
환불 refundPayment() — 카드는 PG 취소 호출 후, 무통장은 상태만 정리. 수강권 회수 포함
무통장 입금 확인 ✅ 관리자 화면에서 처리 → state='success' + 수강권 활성화

금액은 폼을 믿지 않는다. 서버가 product 테이블에서 다시 읽어 주문을 만든다(원본은 세션에 담아 대조했다).

수강권 활성화 정책 — 원본과 다르다

원본은 결제가 tMember 를 전혀 건드리지 않는다. products/·NICEPAY/ 어디에도 회원 플래그를 켜는 코드가 없고, 관리자가 admin/member/mb_proc.php 회원 수정 화면에서 직접 켰다.

지금은 이렇게 정했다:

  • 무통장 — 원본과 같다. 입금 확인이 사람 손을 타므로 자동으로 열지 않는다.
  • 카드 — 승인이 떨어지면 자동으로 연다. ENTITLEMENT_FIELDS 매핑(src/lib/commerce.ts)에 따라 pay_yn / pay_tec_yn 등을 Y 로 바꾼다. tesoltec 은 둘 다 켠다.

관리자 화면

원본 admin/payment/payment_list.php 에는 조회와 삭제만 있었다. 입금확인·환불은 새로 만든 기능이다.

  • /admin/login · /admin/payments (최근 100건, 상태·수단·키워드 필터)
  • 입금확인 — 무통장 ready 주문에만 뜬다. 누르면 success + 수강권 활성화.
  • 환불success 주문에 뜬다. 카드는 NICEPAY 취소(cancel_process.jsp, 성공코드 2001)를 부르고, 무통장은 상태만 정리한다(돈은 사람이 돌려준다). 둘 다 수강권을 회수한다.
  • 레거시 카드 결제 417 건은 TID 가 없어 자동 취소가 안 된다. 그때는 저장하지 않았기 때문이다. 화면에서 "NICEPAY 관리자에서 직접 취소해 주세요" 로 안내하고 상태는 건드리지 않는다.

관리자 인증admin 테이블을 그대로 쓴다. 비밀번호가 MySQL PASSWORD() 해시(* + SHA1(SHA1(pw)))로 넘어와 있어 같은 방식으로 검증하고, 로그인에 성공하면 bcrypt 로 갈아 끼운다(점진적 이전). 세션은 NextAuth 의 admin provider 로 만들고 role='admin' 으로 구분하며, src/proxy.ts/admin/* 을 막는다. 서버 액션은 미들웨어를 거치지 않으므로 액션 안에서도 role 을 다시 확인한다.

결제 상태

ready(대기) → success(완료) → cancel(취소).

무통장 주문은 입금 확인 전까지 ready 다. 원본은 신청 즉시 success 로 넣었지만(확인 UI 가 없었으니), 돈이 들어오지도 않은 주문을 완료로 두는 건 맞지 않다. 레거시 데이터(무통장 success 211 건)는 그대로 두었다.

스키마 보강

환불·입금확인을 위해 payment 에 컬럼을 더했다. 원본에 없던 기능이라 자리도 없었다.

컬럼 쓰임
tid PG 거래번호. 이게 없으면 카드 환불을 못 한다
cancel_amt · cancel_date · cancel_msg 취소 이력 (전체취소만 지원)
confirmed_date 무통장 입금을 확인한 시각

화면

  • /products/order/[orderNo] — 주문 완료 (원본 products_complete.php)
  • /products/order/[orderNo]/pay — 카드 결제창 호출 (원본 products_record01.php)

예상 API 엔드포인트

수강 신청서 제출

POST /api/commerce/applications

EnrollmentFormSection 의 폼 제출. 결제 전 단계의 신청서 레코드 생성.

  • Auth: 필수 (member)
  • Body:
    {
      productCode: 'tesol' | 'tec' | 'tesoltec';
      applicant: {
        name: string;
        phone: string;
        email: string;
        engName?: string;     // 자격증 영문 이름
        address?: string;
        birthDate?: string;
      };
      paymentMethod: 'card' | 'bank' | 'kakaopay';
      agreements: {
        terms: boolean;
        privacy: boolean;
        marketing?: boolean;
      };
    }
    
  • Response: { applicationId: number, orderId: number }
  • 사이드이펙트: apply INSERT, payment INSERT (status='pending'), pay_event 에 이력 남김

주문 / 결제

POST /api/commerce/orders

주문 단독 생성 (신청서 없이 바로 결제하는 경우).

  • Auth: 필수
  • Body: { productCode: string; couponCode?: string }
  • Response: { orderId: number, amount: number, paymentToken: string }
  • paymentToken: PG 로 넘길 주문 식별자 (보안)

POST /api/commerce/payments/prepare

PG 결제 시작 직전 서명/금액 검증.

  • Body: { orderId: number; paymentMethod: string }
  • Response: PG SDK 에 넘길 파라미터 (merchantId, amount, signed_hash 등)

POST /api/commerce/payments/confirm

PG 의 결제 성공 콜백 수신 엔드포인트. 가장 중요.

  • Auth: PG webhook 서명 검증
  • Body: PG 별 상이 (예: 토스페이먼츠의 paymentKey, orderId, amount)
  • 처리 순서:
    1. PG 서버에 결제 확정 API 호출 (amount 재검증)
    2. payment.status = 'paid' 업데이트
    3. pay_event INSERT (status 변경 이력)
    4. learning 모듈 내부 호출: POST /api/learning/enrollments 로 수강권 활성화
    5. notification 모듈 내부 호출: "결제 완료" SMS + 이메일 발송
  • 멱등성: PG 가 같은 webhook 을 여러 번 보낼 수 있으므로 pg_transaction_id 로 dedup
  • Response: { ok: true }

POST /api/commerce/payments/cancel

환불/취소 요청.

  • Auth: 필수 (본인 주문만) or admin
  • Body: { paymentId: number; reason: string }
  • 처리:
    1. PG 취소 API 호출
    2. payment.status = 'cancelled'
    3. lecture_apply 비활성화 (환불 기간 내면 수강권 회수)
    4. notification 환불 안내

주문 조회

GET /api/commerce/orders/me

내 주문 내역.

  • Auth: 필수
  • Query: ?page=1&size=10&status=paid|pending|cancelled
  • Response:
    {
      items: Array<{
        orderId: number;
        productName: string;
        amount: number;
        status: 'pending' | 'paid' | 'cancelled';
        paidAt: string | null;
        method: string;
      }>;
      total: number;
    }
    

GET /api/commerce/orders/[orderId]

주문 상세.

  • Auth: 필수 (본인) or admin
  • Response: 주문 + 결제 + 신청서 정보 조인

부가 상품 (자격증/아포스티유)

POST /api/commerce/certificates/apostille

아포스티유 공증 신청.

  • Auth: 필수
  • Body: { courseCode: string; shippingAddress: string; }
  • 사이드이펙트: certificate_post INSERT, 결제 플로우로 리다이렉트

POST /api/commerce/certificates/signboard

수료 현판 신청.


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

GET /api/commerce/admin/payments

전체 결제 목록 (필터, 통계).

POST /api/commerce/admin/refund

관리자 강제 환불.

GET /api/commerce/admin/revenue?from=...&to=...

매출 집계.


⚠️ 구현 시 주의사항

  1. PG webhook 멱등성: 결제 확정 API 는 반드시 멱등하게. pg_transaction_id 로 dedup.
  2. 금액 재검증: 클라이언트가 넘긴 금액은 절대 신뢰하지 않고 서버의 product.sale_price 로 재계산.
  3. 트랜잭션: payment 확정 → enrollment 활성화는 DB 트랜잭션으로 묶어야 함.
  4. 실패 복구: PG 는 성공했는데 enrollment 활성화가 실패하는 경우를 대비한 재시도 큐 필요 (notification 에 수동 개입 알림).
  5. 환불 정책: 수강 시작 후 환불 불가, 7일 이내 전액 등 정책을 DB 스키마 or 코드에 명시.

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