서로 다른 두 고객 데이터소스를 하나의 구독자 목록으로 통합하기
이메일 캠페인 도구 개발기 시리즈 (7/9). 전체 개요
문제
캠페인 수신 대상은 두 갈래로 나뉜다.
- 친환경 사업 고객: 이 앱 자체 테이블 eco_contacts에 이름/회사/부서/직책/연락처 저장
- SW 사업 고객(그룹 100/101/102): 영업 관리 시스템이 소유한 salesdb.customer (같은 MySQL 서버의 다른 스키마)에 데이터가 있음
subscribers 테이블 하나로 발송 대상(이메일 + 구독 여부 + 그룹)을 관리하지만, 이메일 주소만으로는 "이 사람이 누구인지" 알 수 없어서 관리 화면에서 이름/소속을 같이 보여줘야 했다.
설계: LEFT JOIN이 아니라 상관 서브쿼리
subscribers에는 eco_contact_id FK가 있어 eco_contacts는 그냥 LEFT JOIN하면 된다. 문제는 SW 고객 쪽 — subscribers에는 salesdb.customer로의 FK가 없고 이메일로만 연결된다. 그런데 salesdb.customer.email에는 UNIQUE 제약이 없어서, 그냥 LEFT JOIN을 쓰면 같은 이메일의 고객 행이 여러 개일 경우 구독자 목록이 중복 표시될 수 있다.
-- 이름
(SELECT sc.name FROM salesdb.customer sc
WHERE sc.email = s.email COLLATE utf8mb4_general_ci
LIMIT 1) AS sw_name,
-- 소속명 (belong 테이블 조인)
(SELECT b.group_name FROM salesdb.customer sc
JOIN salesdb.belong b ON b.group_id = sc.group_id
WHERE sc.email = s.email COLLATE utf8mb4_general_ci
LIMIT 1) AS sw_group
SELECT 절 서브쿼리는 컬럼 하나만 반환할 수 있어서, "이름"과 "소속명"을 각각 별도 서브쿼리로 분리했다. LIMIT 1로 항상 1건만 가져오게 강제해 중복 표시를 막는다.
겪은 문제들
1. SELECT command denied
배포된 DB 계정(unsub_user)에 애초에 salesdb.customer 접근 권한이 없었다. 코드 문제가 아니라 DB 권한 문제 — DBA가 GRANT SELECT ON salesdb.customer TO 'unsub_user'@'...'로 해결. 이 앱은 salesdb를 읽기 전용으로만 쓰므로 SELECT 권한만 부여했다(의도적으로 쓰기 권한은 요청하지 않음 — 아래 "왜 읽기 전용인가" 참고).
2. Illegal mix of collations
권한 부여 후 sw_name 서브쿼리에서 콜레이션 불일치 에러가 났다. subscribers.email은 utf8mb4_uca1400_ai_ci, salesdb.customer.email은 utf8mb4_general_ci로 스키마가 서로 다르게 만들어져 있었던 것. 두 컬럼을 그냥 =로 비교하면 MySQL이 어느 콜레이션을 써야 할지 정할 수 없어 거부한다. WHERE sc.email = s.email COLLATE utf8mb4_general_ci처럼 비교 시점에 콜레이션을 명시적으로 지정해서 해결했다.
이런 종류의 에러는 두 테이블이 원래 다른 시스템/시점에 만들어졌을 때 자주 발생한다. 스키마를 통일할 수 없다면(다른 시스템 소유 테이블이라) 비교하는 쪽에서 COLLATE를 명시하는 게 가장 안전하다.
3. 왜 salesdb는 의도적으로 읽기 전용인가
SW 고객 정보를 이 앱의 수정 화면에서 편집 가능하게 만들 수도 있었지만, salesdb는 영업/판매 관리 시스템이 소유한 운영 데이터라 이 앱에서 직접 고쳐서 그 시스템에 영향을 주는 상황을 의도적으로 피했다. SW 고객 정보를 고쳐야 하면 그 시스템(영업 관리)에서 수정해야 한다 — 다른 시스템의 데이터 소유권 경계를 코드 레벨에서 지키는 것도 설계의 일부였다.
FK CASCADE/RESTRICT가 섞여 있을 때의 삭제 UX
그룹(company_types) 관리 기능을 만들면서 FK 제약이 테이블마다 다르다는 걸 발견했다:
- subscribers, campaign_target_types → ON DELETE RESTRICT: 참조 행이 있으면 DB가 삭제를 거부한다. IntegrityError를 잡아 "다른 데이터에서 참조 중이라 삭제할 수 없습니다"로 안내.
- eco_contacts → ON DELETE CASCADE: 그룹을 삭제하면 해당 그룹의 eco_contacts 행이 전부 조용히 같이 삭제된다. 에러가 안 나기 때문에 코드로는 막을 수 없는 부분 — 대신 목록 화면에서 그룹별 연결 건수를 미리 보여주고, 삭제 확인창에 "연결된 eco_contacts(N건)는 이 그룹과 함께 자동으로 삭제됩니다"라고 명시해서 관리자가 실수로 데이터를 지우지 않게 경고했다.
교훈: FK 제약이 RESTRICT/CASCADE로 섞여 있는 스키마에서는 "삭제가 실패하는 경우"뿐 아니라 "삭제가 조용히 다른 데이터까지 같이 지우는 경우"도 UI에서 사전에 알려줘야 한다. 에러 핸들링만으로는 CASCADE를 막을 수 없다.
트랜잭션: 두 테이블을 같이 쓸 때는 명시적 rollback이 필수
구독자 추가 시 연락처 정보(이름/회사 등)를 입력하면 eco_contacts를 먼저 INSERT하고 그 id를 subscribers.eco_contact_id에 연결한다. 두 INSERT를 같은 커밋되지 않은 트랜잭션에서 실행하고 마지막에 한 번에 commit한다.
이메일 중복으로 subscribers INSERT가 IntegrityError를 던지면, 반드시 conn.rollback()을 명시적으로 호출해야 한다. 원래는 단일 INSERT라 rollback 없이 catch만 해도 문제가 없었는데, 두 테이블을 같이 쓰게 되면서 rollback을 빠뜨리면 방금 만든 eco_contacts 행이 고아 데이터로 남는다 — "테이블이 하나 늘어나면 에러 처리 방식도 다시 점검해야 한다"는 사례.