- r
- 데이터베이스
- 파이썬
- 빅데이터분석기사
- Django
- [멀티잇]데이터 시각화&분석 취업캠프(Python)
- AI 플랫폼을 활용한 데이터 분석
- unsub
- 빅분기
- 오라클
- SQL
- 데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- DB
- 빅데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- boostcourse
- 정보처리기사
- 프로그래머스
- 코딩테스트 python
- 부스트코스
- python
- 네이버부스트캠프
- Oracle
- Ai
- 인공지능기초다지기
- PY4E
- 이것이 취업을 위한 코딩테스트다 with 파이썬
- 코딩테스트
- boostcoures
- 기초다지기
- 이기적
- Today
- Total
매일공부
이메일 캠페인 발송 시스템 DB 설계 노트 본문
이메일 캠페인 도구 개발기 시리즈 (3/9). 전체 개요
여러 출처(기존 시스템 고객 데이터 + 신규 엑셀 명단)를 합쳐 이메일을 발송하고, 수신거부와 그룹별 타겟팅까지 지원하는 시스템을 설계하며 겪은 결정들을 정리한다.
요구사항
- 두 개의 서로 다른 원천에서 이메일 주소를 모은다 (기존 시스템 DB + 수기 관리 엑셀)
- 발송 대상을 기업 유형(분류)별로 나눠서 캠페인마다 다르게 타겟팅
- 수신거부는 이메일 주소 기준으로 통합 관리 (어느 원천이든 한 번 거부하면 끝)
- 캠페인은 예약 발송, 첨부파일 지원
1) 원본 데이터와 "발송용 데이터"를 분리했다
엑셀에서 옮겨온 원본 연락처 테이블(eco_contacts)과, 실제 발송 로직이 참조하는 구독자 테이블(subscribers)을 처음부터 분리했다.
CREATE TABLE eco_contacts (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(255), -- 원본은 중복/공백 허용
company_type_id INT,
...
is_valid BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE subscribers (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE, -- 발송용은 이메일 유일
eco_contact_id INT NULL,
company_type_id INT NULL,
unsubscribe_token VARCHAR(64) NOT NULL UNIQUE,
is_subscribed BOOLEAN NOT NULL DEFAULT TRUE,
unsubscribed_at DATETIME NULL
);
이렇게 나눈 이유:
- 원본 데이터(부서 공용 메일, 중복 행, 빈 값 포함)를 있는 그대로 보존해야 나중에 "이 사람이 원래 명부에 있었는지" 추적 가능
- 발송/수신거부 로직은 이메일 유일성이 반드시 보장돼야 하는데, 원본에 그 제약을 걸면 임포트 자체가 실패함
- 두 원천(기존 DB + 엑셀)을 합칠 때도, subscribers라는 통합 계층 하나만 있으면 되고 각 원본 테이블의 구조 차이를 신경 쓸 필요가 없음
2) 중복 이메일 처리: GROUP BY + INSERT IGNORE
원본에 중복 이메일(부서 공용 메일 등)이 있는 상태에서 subscribers로 옮길 때:
INSERT IGNORE INTO subscribers (email, eco_contact_id, company_type_id, unsubscribe_token)
SELECT ec.email, MIN(ec.id), MAX(ec.company_type_id), UUID()
FROM eco_contacts ec
WHERE ec.email IS NOT NULL AND ec.email != '' AND ec.is_valid = TRUE
GROUP BY ec.email;
- GROUP BY email로 같은 이메일을 한 그룹으로 묶어 대표 값만 사용
- 서로 다른 원천(기존 시스템 DB, 엑셀)을 순서대로 넣을 때 INSERT IGNORE를 안전망으로 사용 — UNIQUE 제약에 걸리는 행은 에러 없이 건너뜀
- 두 원천을 합칠 때는 순서를 정해두는 게 좋다 (예: 항상 A 원천 먼저, B 원천 나중) — 그래야 어느 쪽 값이 대표로 남는지 예측 가능
3) 발송 그룹은 "고정 카테고리 하나"로 단순화
처음엔 다대다 태그 구조(구독자가 여러 그룹에 속하고, 캠페인도 여러 그룹을 타겟팅)까지 고려했지만, 실제 발송 기준이 "기업 유형 하나"로 충분하다는 게 확인되면서 단순화했다.
CREATE TABLE company_types (
id INT PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
기존 시스템 쪽 분류(간단한 3종 구분)와 신규 데이터 쪽 분류(다수의 업종 구분)를 하나의 테이블에 합치되, ID 대역을 나눠서 관리했다 (예: 기존 쪽은 100번대, 신규 쪽은 1번대). 이렇게 하면 두 원천의 분류 체계가 섞여도 충돌 없이 공존한다.
교훈: 설계 초반에 "나중에 필요할 수도 있으니 유연하게"로 다대다 구조까지 만들었다가, 실제 요구사항을 다시 확인하고 단순한 단일 FK로 되돌린 경우다. 유연성은 실제 요구가 확인된 다음에 넣는 게 낫다 — 미리 만든 유연한 구조가 오히려 이해와 유지보수를 어렵게 만들 수 있다.
4) SMTP 인증 계정과 발신 주소는 별개 컬럼으로
회사 메일 시스템이 "개인 계정으로 로그인하되, 그룹 대표 메일 주소로 발신"하는 구조였다. 이걸 반영해 인증 정보와 표시 정보를 분리했다:
CREATE TABLE smtp_settings (
id INT NOT NULL DEFAULT 1, -- 항상 1행만 존재하도록 고정
smtp_host VARCHAR(255) NOT NULL,
smtp_port INT NOT NULL,
smtp_username VARCHAR(255) NOT NULL, -- 로그인용 개인 계정
smtp_password_encrypted TEXT NOT NULL,
from_email VARCHAR(255) NOT NULL, -- 실제 발신 표시 주소 (대표 메일링)
from_name VARCHAR(100)
);
메일 발송 코드에서도 이 둘을 구분해서 써야 한다:
server.login(smtp_username, password) # 인증
server.sendmail(from_email, recipient, msg.as_string()) # 봉투 발신자
msg['From'] = f"{from_name} <{from_email}>" # 헤더에 보이는 발신자
login()에 쓰는 계정과 sendmail()의 발신자가 달라도, 메일 서버가 그 계정에 대표 메일 별칭으로 보낼 권한을 부여했다면 정상 작동한다.
주의: id를 1로 고정해 테이블에 항상 최대 1행만 존재하게 만들었다. 담당자가 한 명일 때는 이 방식이 단순하지만, 담당자별로 다른 발송 계정을 써야 하는 상황이 되면 admin_id 같은 FK를 추가해 계정별 여러 행을 허용하는 구조로 확장해야 한다. 초기엔 단순하게 시작하고, 실제로 필요해지면 그때 확장하는 편이 낫다.
요약
- 원본 데이터와 발송용 데이터는 분리하고, 발송용에만 강한 제약(UNIQUE)을 건다
- 여러 원천을 합칠 때는 GROUP BY + INSERT IGNORE로 중복을 방어적으로 처리한다
- 유연한 구조(다대다 태그 등)는 실제 요구가 확인되기 전엔 과설계일 수 있다 — 단순한 구조로 시작하고 필요할 때 확장한다
- 인증 계정과 표시 정보(발신 주소 등)가 다를 수 있는 시스템은 이 둘을 애초에 별도 컬럼으로 설계한다
'IT > 작업기록' 카테고리의 다른 글
| 캠페인 상태 머신: 테스트 없이 실제 메일이 나가지 않도록 만들기 (0) | 2026.08.31 |
|---|---|
| Flask 첫 프로젝트에서 마주친 삽질들 (0) | 2026.08.31 |
| 사내용 이메일 캠페인 관리 툴을 Flask로 직접 만든 이유와 구조 (0) | 2026.08.31 |
| 작은 사내 도구, 기존 시스템에 붙일까 독립시킬까 — 판단 기준 정리 (0) | 2026.08.31 |
| [NAS 트러블슈팅] Log Center 종료 실패 에러, 원인은 예약된 PowerOff Task였다 (0) | 2026.08.31 |