현재 관리자모드 좌측 메뉴의 "웹진관리"는 웹진 콘텐츠 제작(카테고리/웹진 CRUD, WebzineAdminController, views/admin/webzine_list.php)만 담당한다. 여기에 이메일 뉴스레터 기능(구독자 관리 + 발송)을 추가해, 방문자가 웹진 하단에서 구독 신청/취소를 할 수 있고, 관리자는 구독자를 관리하며 특정 웹진을 구독자들에게 메일로 발송할 수 있게 한다. 프로젝트는 프레임워크 없는 커스텀 라우터(public/index.php의 flat if-elseif 체인) + PDO 직접 사용 구조이며, 메일 발송 관련 코드/라이브러리는 전무하다(Composer/vendor 없음).

사용자 확인 사항:

  • 발송 이메일 본문은 웹진의 기존 썸네일 이미지 1장(이미지 클릭 시 새 창으로 실제 웹진 페이지 이동) + 하단에 텍스트 박스(구독안내/구독취소 안내)로 구성. 캔버스 디자인 전체를 이메일 HTML로 재현하지 않는다.
  • 발송은 PHP 기본 mail() 함수로 처리(신규 라이브러리 도입 없음). 환경설정 화면에 "보내는 이메일 화이트리스트 등록 안내" 문구를 안내용으로 표시.

1. DB 스키마 (database/migration_webzine_newsletter.php, 기존 database/migration_webzine.php 스타일의 단독 실행 스크립트)

 
CREATE TABLE webzine_subscriber_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
-- webzine_categories와 동일한 형태(단일 FK, 그룹 없이도 동작)
 
CREATE TABLE webzine_subscribers (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
group_id INT NULL,
name VARCHAR(100) NOT NULL,
phone VARCHAR(30) NULL,
email VARCHAR(190) NOT NULL,
source ENUM('request','collected') DEFAULT 'request', -- 구분: 구독신청/수집
status ENUM('active','cancelled') DEFAULT 'active', -- 상태: 구독중/구독취소
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_tenant_email (tenant_id, email),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES webzine_subscriber_groups(id) ON DELETE SET NULL
);
 
CREATE TABLE webzine_newsletter_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL UNIQUE,
sender_name VARCHAR(100) NULL,
sender_email VARCHAR(190) NULL,
subject_format VARCHAR(255) NULL, -- 예: "[{사이트명}] {제목}"
footer_text TEXT NULL, -- 웹진 하단 노출 문구 (미입력 시 구독 박스 미노출)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
);
 
CREATE TABLE webzine_sends (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
webzine_id INT NOT NULL,
title VARCHAR(255) NOT NULL, -- 발송제목
sent_by_name VARCHAR(100) NULL, -- 로그인 담당자 스냅샷
recipient_count INT DEFAULT 0,
success_count INT DEFAULT 0,
fail_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 발송일
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (webzine_id) REFERENCES webzines(id) ON DELETE CASCADE
);
 
CREATE TABLE webzine_send_recipients (
id INT AUTO_INCREMENT PRIMARY KEY,
send_id INT NOT NULL,
subscriber_id INT NULL,
group_name VARCHAR(100) NULL,
name VARCHAR(100) NOT NULL,
phone VARCHAR(30) NULL,
email VARCHAR(190) NOT NULL,
status ENUM('sent','failed') DEFAULT 'sent',
error_message VARCHAR(255) NULL,
FOREIGN KEY (send_id) REFERENCES webzine_sends(id) ON DELETE CASCADE
);

구독자는 그룹 1개만 소속(단순 FK, webzine_categories/webzines 관계 재사용). email에 tenant 단위 UNIQUE를 걸어 공개 구독신청 시 upsert(재구독 시 status만 되돌림)가 가능하게 한다.

2. 좌측 메뉴 구조 변경 (views/admin/layout_header.php:350)

현재 단일 <a class="nav-item">웹진관리</a> 를, 이미 존재하는 게시판관리/쇼핑몰관리와 동일한 .nav-group / .nav-item-parent / .nav-submenu 패턴으로 교체:

  • 웹진관리 (기존 admin/webzine 그대로 연결)
  • 구독관리 (admin/webzine/subscribers)
  • 웹진발송 (admin/webzine/send)

isActive('webzine', $curr)가 세 URL 모두를 이미 포함하므로 부모 active 판정에 그대로 재사용 가능.

3. 라우팅 추가 (public/index.php, 기존 webzine 라우트 블록 근처 482줄 부근)

관리자용 (신규 컨트롤러 App\Controllers\NewsletterAdminController):

  • admin/webzine/subscribers  subscribers() (검색+목록, ?sub=list|groups)
  • admin/webzine/subscribers/save  subscriberSave() (등록/수정, POST)
  • admin/webzine/subscribers/delete/(\d+)  subscriberDelete($id)
  • admin/webzine/subscribers/group-save  groupSave()
  • admin/webzine/subscribers/group-delete/(\d+)  groupDelete($id)
  • admin/webzine/send  send() (?tab=compose|history|settings)
  • admin/webzine/send/settings-save  sendSettingsSave()
  • admin/webzine/send/execute  sendExecute() (AJAX, JSON in/out — init/batch/finish 액션)

공개용 (WebzineController에 메서드 추가):

  • webzine/subscribe  subscribe() (POST, 구독 신청)
  • webzine/unsubscribe  unsubscribe() (POST, 구독 취소)

4. 컨트롤러 설계

NewsletterAdminController (신규, WebzineAdminController 생성자와 동일한 레벨 체크 >= 5 복제)

  • subscribers(): $_GET name/phone/email/group_id 조건으로 WHERE tenant_id=? + 동적 AND 절 구성 (TenantAdminController::members() 패턴 재사용, %like% 바인딩). 상단에 COUNT(*) 전체 건수, 그룹 목록(select용) 조회. sub=groups일 땐 webzine_subscriber_groups 목록 렌더(카테고리 관리 탭과 동일 구조).
  • subscriberSave(): id 유무로 insert/update. 신규 등록 시 source='collected'(관리자 직접 등록), 이메일 중복 시 에러 리다이렉트.
  • subscriberDelete($id): tenant 스코프 delete.
  • groupSave() / groupDelete($id): webzine_subscriber_groups CRUD (webzine 카테고리 저장/삭제 메서드 그대로 패턴 복제).
  • send():
    • tab=compose(기본): 발행된 웹진 목록(select), 구독자 검색+목록(그룹/성명/전화/이메일 + "구독취소자 제외" 체크박스, 기본 체크 → status='active'만), 전체 건수.
    • tab=history: webzine_sends 목록(제목/날짜/건수) 좌측, ?id=로 선택된 발송의 webzine_send_recipients 상세를 우측에 렌더.
    • tab=settings: webzine_newsletter_settings 조회(없으면 빈 폼 기본값), 화이트리스트 안내 정적 텍스트 블록 포함.
  • sendSettingsSave(): upsert webzine_newsletter_settings (insert-or-update, TenantSetting::updateSettings 패턴처럼 존재 여부 확인 후 분기).
  • sendExecute(): JSON 액션 3종, 세션 인증 재확인 후 처리
    • action=init: webzine_id, title, subscriber_ids[] 받아 유효성 확인(웹진 존재+thumbnail_path 필수 — 없으면 에러 JSON) 후 webzine_sends insert, send_id+총 건수 반환.
    • action=batch: send_id + 이번 배치 subscriber_ids[] 받아 각 구독자에게 mail() 발송(제목은 subject_format 치환, 본문은 아래 5번 템플릿), 결과를 webzine_send_recipients에 insert, 배치별 성공/실패 카운트 JSON 반환.
    • action=finish: webzine_sends recipient_count/success_count/fail_count 합계 업데이트.
    • 배치 크기는 서버가 아닌 클라이언트가 나누어 호출(예: 15건씩) — PHP max_execution_time 이슈 회피, 프론트에 진행률(N/전체) 표시.

메일 발송 헬퍼는 NewsletterAdminController 내 private 메서드 sendOne($settings, $webzine, $subscriber)로 구현: mail($to, $subject, $htmlBody, $headers), Content-Type: text/html; charset=UTF-8, From: {sender_name} <{sender_email}> 헤더 구성.

WebzineController 추가 메서드

  • subscribe(): POST(name/phone/email/agree) → webzine_subscribers INSERT ... ON DUPLICATE KEY UPDATE name=?, phone=?, status='active' (tenant_id+email UNIQUE 활용), source='request'. JSON 또는 리다이렉트+플래시 메시지 반환(모달에서 fetch 사용 예정이므로 JSON 응답 채택, BaseController::json() 재사용).
  • unsubscribe(): POST(name/email) → 일치하는 구독자 status='cancelled'로 UPDATE, JSON 응답.

5. 공개 화면 (구독 신청/취소 박스 + 발송 이메일)

웹진 하단 구독 박스 — templates/dark_blue/webzine/view.php

canvas-wrapper 이후, 댓글 섹션 이전에 삽입. 컨트롤러(WebzineController::view)에서 webzine_newsletter_settings를 조회해 newsletterSettings 로 뷰에 전달, footer_text가 있을 때만 박스 렌더:

  • 박스 안에 footer_text 텍스트 + "웹진구독"/"구독취소" 두 버튼.
  • 두 모달은 webzine_list.php .modal-overlay/.modal-content CSS를 그대로 재사용(신규 스타일 불필요).
  • 웹진구독 모달: 성명/전화번호/이메일 입력 + "신청한 정보는 웹진 구독신청용으로만 사용됩니다." 안내문 + 이용동의 체크박스(필수) + 확인/취소. 확인 시 fetch(TENANT_BASE_URL + '/webzine/subscribe', {method:'POST'}) → 성공 시 alert 후 모달 닫기.
  • 구독취소 모달: 성명/이메일 입력 + 확인/취소. 확인 시 fetch(.../webzine/unsubscribe) → "구독취소 신청되었습니다" alert.

발송 메일 본문 (PHP로 생성하는 순수 HTML 문자열, 신규 뷰 파일 불필요 — 컨트롤러 내 템플릿 문자열)

 
<a href="{TENANT_BASE_URL}/webzine/view/{id}" target="_blank">
<img src="{절대경로 thumbnail_path}" style="max-width:100%;">
</a>
<div style="margin-top:20px; padding:15px; border:1px solid #ddd;">
{footer_text}
</div>

발송 전 선택 웹진에 thumbnail_path가 없으면 컴포즈 화면에서 발송 버튼 비활성화 + 안내 문구("웹진관리에서 썸네일을 먼저 등록해주세요").

6. 관리자 화면 (신규 뷰)

  • views/admin/webzine_subscribers.php: webzine_list.php의 tab(.board-tabs)/modal 패턴 재사용. 목록 tab: 검색폼(성명/전화/이메일/그룹 select) GET + 우상단 "전체 N건" / "그룹관리"(→?sub=groups) 버튼. 테이블: No/성명/전화번호/이메일/신청일/구분(뱃지)/상태(뱃지)/수정/삭제. 수정은 webzine_list.php openManageModal 패턴처럼 모달에 값 채워 subscribers/save로 POST. 그룹관리 tab: webzine_list.php의 카테고리관리 tab 그대로 복제(이름 입력+추가, 목록+삭제).
  • views/admin/webzine_send.php: .board-tabs로 발송/발송내역/환경설정 3탭.
    • 발송 tab: 발송제목/발송일(오늘 date input)/로그인 담당자(readonly, 세션 닉네임) + 웹진선택 select(발행된 웹진만, 옆에 "미리보기" 버튼 window.open(webzine view url)) + 구독자 검색폼(그룹/성명/전화/이메일 + 구독취소자 제외 체크, 기본 체크) + 체크박스 목록(posts.php checkAll/.post-checkbox 패턴 재사용, name=subscriber_ids[]) + 하단 전체선택/전체취소(선택 초기화) 버튼 + 우측 "[웹진발송]" 버튼.
    • 발송 진행: 버튼 클릭 시 확인창 → JS가 sendExecute action=init 호출 → 진행률 모달(모달 오버레이 재사용, 프로그레스 바 + "N / 전체 건 발송중...") 표시하며 action=batch 반복 호출 → 완료 시 action=finish 호출 후 결과 요약(성공/실패 건수) 표시, "발송내역 보기" 링크 제공.
    • 발송내역 tab: 좌측 카드형 목록(제목/날짜/건수, 클릭 시 ?tab=history&id=N), 우측에 선택된 발송의 수신자 상세 테이블(그룹명/성명/전화/이메일/상태).
    • 환경설정 tab: 보내는사람/보내는이메일/제목형식/웹진하단문구 입력 폼 + "보내는 이메일 화이트리스트 등록 안내" 정적 안내 박스(각 메일 서비스에 발신 주소를 스팸함으로 분류되지 않도록 등록해두라는 안내 문구, 편집 불가 텍스트).

검증 방법

  • PHP 문법 오류 없는지 php -l로 신규/수정 파일 확인.
  • XAMPP 로컬 서버로 관리자 로그인 후:
    1. 좌측 메뉴에 웹진관리 하위 3개 메뉴가 정상 노출/이동되는지 확인.
    2. 구독관리에서 그룹 추가 → 구독자 수동 등록(수집) → 검색/수정/삭제 동작 확인.
    3. 웹진 하나에 썸네일 지정 → 공개 웹진 페이지 하단 구독 박스 노출 확인(환경설정에 하단문구 저장 후) → 웹진구독 모달로 신규 구독 신청 → 구독관리 목록에 반영되는지 확인 → 구독취소 모달로 취소 → 상태가 "구독취소"로 바뀌는지 확인.
    4. 웹진발송에서 환경설정 저장 → 구독중 대상자 선택 → 발송 실행 → 진행률 모달 동작 및 발송내역에 결과 기록되는지 확인(로컬은 mail() 미설정으로 실제 수신은 안 될 수 있음 — DB 기록/실패 카운트 로직 위주로 검증).