인터뷰에서 방향만 정하고 남겨 뒀던 후속 5건을 구현하고, 커밋 전 종합 점검에서 발견한 결함 2건을 함께 고쳤다. 기능 도입: - 업로드 이미지 축소 — `upload.image_max_width` / `image_max_height` / `image_quality` 는 저장·검증만 되고 리사이즈 코드가 0건이었다. 비율 유지, 상한 이내면 재인코딩하지 않음(불필요한 화질 손실 방지), 한계 미설정이면 무동작(기존 사이트 이미지 보호). PNG·GIF·WebP 투명도 보존. 실패해도 업로드는 계속되고 원본이 저장된다. - 로그인 2단계 인증 — `security.two_factor_auth` 는 설정 항목만 있고 구현도 화면 컨트롤도 없었다. 코어 IDV 인프라를 재사용해 purpose `login` 을 추가하고, 비밀번호 확인 통과 후 challenge 만 발행한다(토큰 미발급 + logout). `POST /api/auth/login/two-factor` 가 코드를 확인해야 토큰이 나가며, 로그인과 같은 요청 제한을 적용한다. challenge 의 purpose 를 대조해 다른 흐름의 challenge 로 로그인하는 것을 막는다. 코드 발송이 실패하면 실패로 알린다 — 2단계를 건너뛰고 로그인시키면 통제가 조용히 열린다. 결함 수정: - 게시글 수정 시 사용자 경로가 `attachment_ids` 를 Service 로 넘기지 않아, 검증과 권한은 통과하고 200 을 받는데 첨부만 조용히 연결되지 않았다. 관리자 경로는 넘기고 있어 같은 요청이 화면에 따라 다르게 동작했다. - 설정 화면 라디오가 `pointer-events-none` + Label click 으로만 상태를 바꿔, 키보드 방향키(change) 조작 시 화면 표시와 저장될 값이 어긋났다. 동일 패턴 4개 레이아웃 12개 라디오에 change 액션을 부여하고 params 를 click 과 동일하게 고정한다. - 주문완료 화면이 결제수단 확인보다 영수증 조회를 앞세워, PG 를 거치지 않는 주문에서도 매번 404 요청이 나갔다. - 개별 게시판 폼의 숫자 항목에 경계값 바인딩이 없어 환경설정 화면과 안내가 달랐다. 커밋 전 점검에서 발견: - 2단계 인증이 반쪽으로 들어가 있었다. enum 에 case 만 추가하고 코어 purpose 레지스트리와 ko/en 라벨을 빠뜨려, 목적 목록 API 에서 통째로 빠지고 `hasPurpose` 가 false 였으며 라벨 대신 i18n 키 원문이 반환됐다. 관리자가 이 목적의 메시지 템플릿·정책을 만들 수 없는 상태였다. 세 곳을 enum 기준으로 전수 대조하는 회귀 테스트를 함께 둔다. - 업로드 이미지 축소가 적용되지 않은 8번째 서비스(레이아웃 편집기 첨부)가 있었다. 열거형 커버리지 테스트는 내 조사 결과를 green 으로 고정할 뿐 전수를 증명하지 못한다 — 같은 규약을 조건으로 표현한 audit 룰이 첫 실행에서 검출했다. 재발 방지: - audit 룰 `upload-service-image-resize` (신규 업로드 서비스의 축소 누락 검출) - 트러블슈팅 사례 20 (코어 설정이 일부 경로에만 적용되는 증상의 진단 절차) - `sirsoft-ckeditor5` 의 코어 최소 요구 버전을 7.0.6 으로 상향 (신설 클래스 의존) 공개 이슈: gnuboard/g7
12 KiB
본인인증 메시지 템플릿 시스템 (Identity Messages)
알림 시스템과 분리된 IDV 전용 메시지 템플릿 시스템
TL;DR (5초 요약)
1. 알림 시스템(notification_*)과 완전 분리된 IDV 전용 템플릿 인프라
2. (provider_id, scope_type, scope_value) 매트릭스 키로 정의 식별 — scope_type: provider_default | purpose | policy
3. 발송 시 fallback 체인: policy:{key} → purpose:{key} → provider_default
4. 코어 시드 5건 (g7:core.mail × provider_default + 4 purposes) — 다국어 ko/en
5. MailIdentityProvider → IdentityMessageDispatcher → DbTemplateMail (메일 발송 인프라 재사용)
6. 운영자 편집 UI: 환경설정 > 본인인증 > 메시지 템플릿 (subject/body 다국어 + reset)
아키텍처 개요
MailIdentityProvider::requestChallenge()
├ identity_verification_logs 레코드 생성 (status=requested)
└ IdentityMessageDispatcher::dispatch()
├ IdentityMessageResolver::resolve(provider, purpose, policy_key)
│ ├ try (provider, policy:{key}) — 가장 구체적
│ ├ try (provider, purpose:{key}) — 목적 단위
│ └ try (provider, provider_default) — fallback
├ template.replaceVariables(data, locale)
├ Mail::send(DbTemplateMail) — 알림과 동일한 메일 wrapper, source='identity_message'
└ 성공/실패 → identity_verification_logs.status 갱신
알림 시스템과의 분리 지점:
notification_definitions/notification_templates미사용GenericNotification미사용- 별도 모델 / 서비스 / 시더 / 관리자 API / 관리자 UI
알림 시스템과의 공유 지점:
DbTemplateMail(메일 발송 wrapper) —source='identity_message'로 구분HasUserOverridestraitLocaleRequiredTranslatable/TranslatableField검증 규칙BaseApiResource/BaseApiCollection
핵심 테이블
identity_message_definitions
| 컬럼 | 설명 |
|---|---|
provider_id |
IDV 프로바이더 ID (예: g7:core.mail, kcp) |
scope_type |
provider_default / purpose / policy |
scope_value |
scope_type 별 식별자 (provider_default = 빈 문자열) |
name |
다국어 표시명 (운영자 식별용 JSON) |
description |
다국어 설명 |
channels |
활성 채널 (현재 ["mail"]) |
variables |
사용 가능 변수 메타데이터 |
extension_type / extension_identifier |
출처 추적 |
is_active / is_default |
활성/시드기본 여부 |
user_overrides |
운영자 수정 필드 보존 |
유니크: (provider_id, scope_type, scope_value)
identity_message_templates
| 컬럼 | 설명 |
|---|---|
definition_id |
FK → identity_message_definitions (CASCADE) |
channel |
발송 채널 (mail) |
subject |
다국어 제목 JSON (mail 채널만 의미) |
body |
다국어 본문 JSON (필수) |
is_active / is_default |
활성/시드기본 |
user_overrides |
운영자 수정 필드 보존 (subject/body/is_active) |
updated_by |
수정자 FK |
유니크: (definition_id, channel)
알림 시스템과 달리
recipients컬럼 없음 — IDV 메시지는 challenge target(이메일/전화)이 발송 대상이며, 정책별 수신자 분기 개념 없음.
identity_message_logs (Phase 2 — 미작업)
별도 발송 로그 테이블은 만들지 않음. identity_verification_logs.status (sent/failed)로 충분. 필요 시 향후 추가.
scope 해석 우선순위
IdentityMessageResolver::resolve(
string $providerId,
string $purpose,
?string $policyKey,
string $channel = 'mail',
): ?array // ['definition' => ..., 'template' => ...]
해석 순서:
policy:{policyKey}— 가장 구체적purpose:{purpose}— 목적 단위provider_default— 프로바이더 fallback- 없으면
null반환 → dispatcher가 skip
각 단계에서 definition.is_active=true AND template.channel=mail AND template.is_active=true 모두 통과해야 함.
발송 변수 (placeholder)
기본 정의가 노출하는 변수 메타:
| 변수 | 설명 | 적용 |
|---|---|---|
{code} |
인증 코드 (text_code 흐름) | signup / self_update / sensitive_action / login |
{action_url} |
검증 링크 URL (link 흐름) | password_reset |
{expire_minutes} |
만료까지 남은 분 | 모든 정의 |
{purpose_label} |
인증 목적 라벨 (다국어 해석) | 모든 정의 |
{app_name} |
사이트명 | 모든 정의 |
{site_url} |
사이트 URL | 모든 정의 |
{recipient_email} |
수신자 이메일 | 모든 정의 |
보안: 평문 코드는 메일 본문 외 어디에도 저장되지 않음 — identity_verification_logs.metadata는 code_hash만 보관. identity_message_logs 미작업이므로 DB 평문 노출 위험 없음.
코어 시드 5종
| provider_id | scope_type | scope_value | 흐름 |
|---|---|---|---|
| g7:core.mail | provider_default | '' |
text_code |
| g7:core.mail | purpose | signup | text_code |
| g7:core.mail | purpose | password_reset | link |
| g7:core.mail | purpose | self_update | text_code |
| g7:core.mail | purpose | sensitive_action | text_code |
시드 위치: database/seeders/IdentityMessageDefinitionSeeder.php
로그인 2단계 인증(purpose=login)은 전용 시드가 없어 provider_default 템플릿으로 발송됩니다. 전용 문구가 필요하면 관리자 화면에서 scope_type=purpose / scope_value=login 정의를 추가합니다 — 목적 목록에 login 이 등록되어 있으므로 선택할 수 있습니다.
클래스 계층
| 클래스 | 책임 |
|---|---|
App\Models\IdentityMessageDefinition |
정의 모델 — HasUserOverrides + 캐시 무효화 |
App\Models\IdentityMessageTemplate |
템플릿 모델 + IdentityMessageContentBehavior |
App\Models\Concerns\IdentityMessageContentBehavior |
다국어 fallback + 변수 치환 |
App\Repositories\IdentityMessageDefinitionRepository |
정의 쿼리 (인터페이스 의존) |
App\Repositories\IdentityMessageTemplateRepository |
템플릿 쿼리 |
App\Services\IdentityMessageDefinitionService |
캐시 + CRUD + 훅 |
App\Services\IdentityMessageTemplateService |
캐시 + 편집 + reset + preview |
App\Services\IdentityMessageResolver |
scope fallback 체인 해석 |
App\Services\IdentityMessageDispatcher |
변수 치환 + 메일 발송 + 훅 |
App\Extension\Helpers\IdentityMessageSyncHelper |
시더 동기화 + cleanup |
App\Mail\DbTemplateMail |
메일 wrapper (재사용) |
관리자 API
| 메서드 | URL | 권한 |
|---|---|---|
| GET | /api/admin/identity/messages/definitions |
core.admin.identity.messages.read |
| GET | /api/admin/identity/messages/definitions/{id} |
read |
| PATCH | /api/admin/identity/messages/definitions/{id} |
update |
| PATCH | /api/admin/identity/messages/definitions/{id}/toggle-active |
update |
| POST | /api/admin/identity/messages/definitions/{id}/reset |
update |
| PATCH | /api/admin/identity/messages/templates/{id} |
update |
| PATCH | /api/admin/identity/messages/templates/{id}/toggle-active |
update |
| POST | /api/admin/identity/messages/templates/{id}/reset |
update |
| POST | /api/admin/identity/messages/templates/preview |
read |
권한 카테고리: core.admin.identity.messages.{read,update} (config/core.php).
캐시 전략
| 키 | TTL | 무효화 |
|---|---|---|
identity_message.definition.{provider}.{scope_type}.{scope_value} |
g7_core_settings('cache.notification_ttl', 3600) |
모델 saved/deleted (booted) |
identity_message.definition.all_active |
동일 | 동일 |
identity_message.template.{definition_id}.{channel} |
동일 | 동일 |
태그 기반 일괄 무효화: Cache::flushTags(['identity_message']).
발송 훅
IdentityMessageDispatcher가 발화하는 훅 (플러그인이 가로채기/로깅/추가 발송에 활용):
| 훅 | 시점 |
|---|---|
core.identity.message.before_send |
발송 직전 |
core.identity.message.after_send |
발송 성공 |
core.identity.message.send_failed |
발송 실패 |
core.identity.message.resolve_failed |
정의/템플릿 미해석으로 skip |
모듈/플러그인 확장
Filter 훅: core.identity.filter_default_message_definitions
외부 IDV provider 플러그인(KCP/PortOne 등)이 자기 메시지 정의 기본값을 코어 reset 로직에 기여:
// 플러그인 Listener
public static function getSubscribedHooks(): array
{
return [
'core.identity.filter_default_message_definitions' => [
'method' => 'contributeDefinitions',
'priority' => 20,
'type' => 'filter',
],
];
}
public function contributeDefinitions(array $definitions, array $context = []): array
{
return array_merge($definitions, [
[
'provider_id' => 'kcp',
'scope_type' => IdentityMessageDefinition::SCOPE_PROVIDER_DEFAULT,
'scope_value' => '',
// ...
],
]);
}
알림 시스템의 core.notification.filter_default_definitions와 동형 패턴.
운영자 UI
코어 환경설정 → "본인인증" 탭 → "메시지 템플릿" 서브탭.
| 위치 | 파일 |
|---|---|
| 서브탭 partial | templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_messages.json |
| 편집 모달 | _modal_identity_message_template_form.json |
| i18n 키 | templates/_bundled/sirsoft-admin_basic/lang/partial/{ko,en}/admin.json admin.settings.identity.messages.* |
UI 구성:
- 정의 목록 카드 (provider, scope, name, is_active, is_default 배지)
- 인라인 토글 (is_active)
- 편집 모달 — 다국어 subject/body (HtmlEditor) + 변수 가이드 + 저장/reset
언어팩 다국어 보강
identity_messages 는 다국어 데이터 직접 보유 SSoT (config/core.php 의 각 entry 가 name/description/templates.subject/templates.body 의 ko/en 배열) — lang pack seed 대상.
흐름:
IdentityMessageDefinitionSeeder 실행 (코어)
↓
config('core.identity_messages') 로드 (getDefaultDefinitions 가 __common__ variables expand)
↓
applyFilters('seed.identity_messages.translations', $definitions)
↓
LanguagePackSeedInjector::injectIdentityMessages($definitions)
↓ 활성 코어 ja 언어팩의 seed/identity_messages.json 로드
↓ 복합 키 ({channel}.{scope_type}.{scope_value}) 매칭하여 ja 키 병합
↓
IdentityMessageSyncHelper::syncDefinition() — DB upsert
↑ user_overrides 마킹된 필드는 운영자 수정값 보존
모듈/플러그인 측: ModuleManager::syncModuleIdentityMessages / PluginManager::syncPluginIdentityMessages 가 동일 패턴 (seed.{id}.identity_messages.translations 필터 발화).
언어팩 패키지의 seed/identity_messages.json 은 IdentityMessageDefinitionSeeder 의 ko 데이터와 동일한 복합 키 ({channel}.{scope_type}.{scope_value}) 구조로 작성한다. 로케일 추가 시 동일 키 집합에 대해 해당 로케일 번역만 채워 넣으면 시더 재실행 시 자동 병합된다.
참고
- docs/backend/identity-policies.md — IDV 정책 시스템 (정책별 메시지는
policy_keyscope 사용) - docs/backend/notification-system.md — 알림 시스템 (별개 — IDV 메시지는 본 문서 시스템 사용)
- docs/extension/module-identity-settings.md — 모듈/플러그인 IDV 설정 통합