도메인 설계
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(상담 관리)이 각각 다른 관점으로 본다.
⑤ 알림
메일과 문자를 보낸다. 실패해도 부른 쪽을 되돌리지 않는다.
자세히 →핵심 타입
Notification | 보낼 알림 한 건. 받는 사람이 없으면 만들 수 없다 |
NotificationSender | 발송 포트. 실패해도 예외를 던지지 않는다 |
AccountNotifications | 아이디 안내 · 재설정 링크. 문구는 원본 그대로 |
다루는 테이블
DB 를 보지 않는다. 바깥으로 내보내는 일만 한다.
원본 대응
mail/* · member/find_id_new_proc.php모듈
모듈 README 원문
notification 모듈
책임: SMS/이메일/카카오 알림톡 발송, 알림 템플릿 관리, 다른 모듈에서 발생한 이벤트를 알림으로 변환.
의존 관계:
- 사용 주체: 거의 모든 모듈 (identity, learning, assessment, commerce) 이 이벤트 발생 시 이 모듈을 호출
- 외부 서비스: SMS 게이트웨이 (예: NHN Toast / Aligo / Coolsms), 이메일 (SES / SendGrid), 카카오 알림톡 (비즈메시지)
관련 DB 테이블:
sms_content— SMS 템플릿 (제목/본문)sms_send— 발송 로그 (수신번호/시간/상태/응답)
특이사항: 이 모듈은 프론트에서 직접 호출하지 않음. 다른 모듈의 사이드이펙트로 호출됨. 따라서 UI 가 없어도 API 구현이 가능하고, 오히려 다른 모듈 구현과 함께 자연스럽게 등장함.
예상 API 엔드포인트
이벤트 기반 알림 (권장 방식)
POST /api/notification/events
도메인 이벤트를 받아서 적절한 알림 템플릿을 선택해 발송.
- Auth: 내부 서비스 토큰 (외부 노출 금지)
- Body:
{ eventType: | 'member.registered' | 'member.password_changed' | 'payment.completed' | 'payment.failed' | 'enrollment.activated' | 'exam.passed' | 'exam.all_completed' | 'certificate.issued' | 'certificate.shipped' | 'level_test.submitted' | 'consultation.requested'; memberId?: number; payload: Record<string, unknown>; } - 처리:
- eventType 에 매핑된 템플릿 조회 (
sms_content또는 상수) - memberId 로 수신자 정보 조회 (hp, email)
- 사용자 알림 수신 동의 확인
- 실제 발송 (내부적으로
POST /api/notification/sms/send등) sms_send에 발송 이력 기록
- eventType 에 매핑된 템플릿 조회 (
- Response:
{ dispatched: Array<{ channel: 'sms'|'email'|'kakao'; to: string; ok: boolean }> }
이벤트 기반 방식의 장점:
- 호출하는 쪽은 "결제 완료됐어" 만 알려주면 됨
- 채널 선택 로직이 한 곳에 집중
- A/B 테스트, 채널 변경이 쉬움
직접 발송 API (내부 호출용)
POST /api/notification/sms/send
- Auth: 내부 서비스 토큰
- Body:
{ to: string; // 전화번호 templateCode?: string; // sms_content.code body?: string; // templateCode 없을 때 직접 본문 variables?: Record<string, string>; // 템플릿 치환 변수 } - Response:
{ messageId: string; status: 'queued' | 'sent' | 'failed' } - 사이드이펙트:
sms_sendINSERT
POST /api/notification/email/send
- Body:
{ to: string; subject: string; bodyHtml?: string; bodyText?: string; templateCode?: string; variables?: Record<string, string>; }
POST /api/notification/kakao/send
카카오 알림톡 (비즈니스 메시지). 템플릿 사전 등록 필수.
- Body:
{ to: string; // 전화번호 templateCode: string; // 카카오에 사전 등록된 템플릿 ID variables: Record<string, string>; }
템플릿 관리 (관리자)
GET /api/notification/admin/templates
SMS/이메일 템플릿 목록.
- Auth: admin
- Response:
sms_content행 목록
POST /api/notification/admin/templates
템플릿 생성/수정.
POST /api/notification/admin/test-send
테스트 발송 (관리자가 자기 번호로 템플릿 미리보기).
발송 로그 조회
GET /api/notification/admin/logs
- Auth: admin
- Query:
?channel=sms&status=failed&from=...&to=... - Response:
sms_send행 목록 + 실패 사유 - 용도: 결제 완료됐는데 SMS 실패한 케이스 등 수동 개입 모니터링
⚠️ 구현 시 주의사항
1. 직접 호출 vs 큐 기반
- MVP: 이벤트 수신 시 동기 발송 (단순하지만 장애 시 손실 위험)
- 정석: 이벤트를 큐 (Redis / DB queue) 에 쌓고 워커가 처리 → 재시도 가능
- 결제 완료 같이 놓치면 안 되는 이벤트 는 반드시 큐 기반 권장
2. 수신 동의 관리
member테이블에sms_consent,email_consent,marketing_consent플래그 필요 (현재 없음 — 나중에 추가)- 법적 요구사항: 광고성 알림은 명시적 동의 필수, 거래성 알림 (결제/수강 확인 등) 은 동의 없이 가능
3. 발송량/비용
- SMS 는 건당 비용 (10~30원). 중복 발송 dedup 필수.
- 이메일은 무료에 가깝지만 bounce 관리 필요 (SES reputation)
4. 실패 재시도
- SMS 게이트웨이가 5xx 반환 시 exponential backoff 로 최대 3회 재시도
- 3회 실패 시 관리자 알림 큐에 넣기
5. 민감정보 로깅
sms_send.content에 OTP / 임시 비밀번호 등을 평문 저장 금지- 최소 마스킹 or 해시 저장
이벤트 → 알림 매핑 예시 (참고)
| 이벤트 | 채널 | 발송 시점 | 템플릿 예시 |
|---|---|---|---|
member.registered |
SMS + 이메일 | 즉시 | "WVC테솔 가입을 환영합니다" |
payment.completed |
SMS + 이메일 | 즉시 | "결제 완료: {productName} {amount}원" |
enrollment.activated |
카카오 알림톡 | 결제 완료 직후 | "강의실 오픈! 지금 수강 시작하세요" |
exam.passed |
SMS | 즉시 | "Unit {N} 합격! 축하합니다" |
exam.all_completed |
SMS + 이메일 | 즉시 | "최종 합격! 자격증이 곧 발급됩니다" |
certificate.issued |
이메일 | 즉시 | "자격증 PDF" 첨부 |
certificate.shipped |
SMS | 즉시 | "자격증 발송 완료, 송장번호 {code}" |
level_test.submitted |
이메일 | 즉시 | 결과 + 상담 예약 링크 |
consultation.requested |
카카오 알림톡 (관리자 번호) | 즉시 | 새 상담 요청 알림 |
⑥ 공용 모듈 — 무엇을 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영속성 공통 설정 | - |