# 멀티테넌트 아키텍처 상세 설명 ## 개요 Infoway는 **서브도메인 기반 완전 격리형 멀티테넌트 시스템**입니다. 각 테넌트(기업)는 독립된 서브도메인, 데이터, 파일 저장소를 가지며, 다른 테넌트의 데이터에 접근할 수 없습니다. ## 핵심 개념 ### 1. ep_code (Enterprise Code) ``` 형식: ep + 타임스탬프 해시 (20자) 예시: ep87579064668809f3d632cc 특징: - 각 테넌트의 고유 식별자 - 생성 시점의 타임스탬프 기반 - 모든 데이터베이스 레코드에 필수 ``` ### 2. gp_code (Group Code) ``` 형식: 문자열 (기본값: "all") 예시: all, marketing, sales, dev 특징: - 테넌트 내부 그룹 구분 - 선택적 사용 (기본 "all") - 그룹별 권한 및 콘텐츠 분리 ``` ### 3. 서브도메인 매핑 ``` 서브도메인 → ep_code → 테넌트 ──────────────────────────────────────────────────────────── wizwin.dliveletter.kr → ep87579064668809f3d632cc → 위즈윈 parkjonggak... → ep801870536594c8ad1cd3b5 → 박종각 ``` ## 서브도메인 처리 플로우 ### 1단계: 초기 접속 ``` 사용자 접속: https://wizwin.dliveletter.kr/ ↓ Apache VirtualHost 처리 ↓ /www/infoway/_infoway/ 로 라우팅 ``` ### 2단계: PHP 자동 초기화 ```php // include/subdomain_init.php 자동 실행 function init_subdomain() { $host = $_SERVER['HTTP_HOST']; // wizwin.dliveletter.kr → wizwin 추출 if (preg_match('/^([^.]+)\.dliveletter\.kr$/', $host, $matches)) { $subdomain = $matches[1]; // 세션 캐시 확인 (5분 TTL) if (세션에 캐싱된 데이터) { return 캐싱된 ep_code; } // DB 조회 $sql = "SELECT ep_code FROM iw_enterprise WHERE ep_domain = '$subdomain' AND ep_exposed = '0'"; // 결과를 세션에 캐싱 $_SESSION['subdomain_wizwin'] = ep_code; $_SESSION['subdomain_wizwin_time'] = time(); // $_GET 자동 설정 $_GET['ep'] = ep_code; $_GET['gp'] = 'all'; return true; } } ``` ### 3단계: 전역 변수 설정 ```php // include/common.php $iw['store'] = $_GET['ep']; // ep87579064668809f3d632cc $iw['group'] = $_GET['gp']; // all $iw['type'] = $_GET['type']; // main, mcb, publishing... // 이후 모든 페이지에서 사용 ``` ### 4단계: 데이터 격리 적용 ```php // 모든 쿼리에 자동 적용되어야 함 $sql = "SELECT * FROM iw_member WHERE ep_code = '$iw[store]'"; // 필수! // 그룹 사용 시 if ($iw['group'] != 'all') { $sql .= " AND gp_code = '$iw[group]'"; } ``` ## 세션 캐싱 시스템 ### 캐시 전략 ```php 캐시 키: subdomain_{subdomain} 캐시 시간 키: subdomain_{subdomain}_time TTL: 300초 (5분) 캐싱 데이터: { 'ep_code': 'ep87579064668809f3d632cc', 'ep_corporate': '위즈윈' } ``` ### 캐시 무효화 ```php // 관리자가 서브도메인 정보 수정 시 호출 function clear_subdomain_cache() { foreach ($_SESSION as $key => $value) { if (strpos($key, 'subdomain_') === 0) { unset($_SESSION[$key]); } } } // 호출 시점: // - 테넌트 정보 수정 // - ep_domain 변경 // - ep_exposed 변경 ``` ### 캐싱의 장단점 ``` 장점: ✅ DB 쿼리 감소 (5분마다 1회) ✅ 응답 속도 향상 ✅ DB 부하 감소 단점: ⚠️ 최대 5분 지연 (정보 변경 시) ⚠️ 세션 만료 시 재조회 ⚠️ 서버 메모리 사용 (미미함) ``` ## 데이터 격리 규칙 ### 필수 규칙 🚨 ```php // ✅ 올바른 쿼리 (ep_code 필터) $sql = "SELECT * FROM iw_member WHERE ep_code = '$iw[store]' AND mb_code = '$iw[member]'"; // ❌ 잘못된 쿼리 (ep_code 누락) // 다른 테넌트의 데이터까지 조회됨! $sql = "SELECT * FROM iw_member WHERE mb_code = '$iw[member]'"; ``` ### INSERT 시 주의사항 ```php // 새 레코드 생성 시 ep_code 반드시 포함 $sql = "INSERT INTO iw_notice (ep_code, gp_code, nt_title, nt_content) VALUES ('$iw[store]', '$iw[group]', '$title', '$content')"; ``` ### UPDATE/DELETE 시 주의사항 ```php // 반드시 ep_code 조건 추가 $sql = "UPDATE iw_member SET mb_name = '$name' WHERE ep_code = '$iw[store]' // 필수! AND mb_code = '$mb_code'"; $sql = "DELETE FROM iw_notice WHERE ep_code = '$iw[store]' // 필수! AND nt_no = '$nt_no'"; ``` ## 파일 격리 ### 디렉토리 구조 ``` main/ ├── wizwin/ # wizwin 테넌트 │ ├── all/ # 전체 그룹 │ │ ├── _images/ │ │ └── _files/ │ ├── marketing/ # marketing 그룹 │ └── sales/ # sales 그룹 │ └── parkjonggak/ # parkjonggak 테넌트 └── all/ ``` ### 파일 업로드 경로 설정 ```php // 테넌트별 업로드 경로 생성 $row = sql_fetch("SELECT ep_nick FROM iw_enterprise WHERE ep_code = '$iw[store]'"); $upload_path = "/main/$row[ep_nick]"; if ($iw[group] == "all") { $upload_path .= "/all"; } else { $row = sql_fetch("SELECT gp_nick FROM iw_group WHERE ep_code = '$iw[store]' AND gp_code = '$iw[group]'"); $upload_path .= "/$row[gp_nick]"; } $upload_path .= "/_images"; // 쿠키에 저장 (CKEditor에서 사용) set_cookie("iw_upload", $upload_path, time()+36000); ``` ## Apache 설정 ### VirtualHost 설정 ```apache # wizwin.dliveletter.kr 서브도메인 ServerName wizwin.dliveletter.kr DocumentRoot /www/infoway/_infoway SSLEngine on SSLCertificateFile /etc/letsencrypt/live/dliveletter.kr/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/dliveletter.kr/privkey.pem AllowOverride All Require all granted ``` ### 자동 파라미터 설정 (선택) ```apache # RewriteRule로 자동 설정 가능 (현재는 PHP에서 처리) RewriteCond %{HTTP_HOST} ^([^.]+)\.dliveletter\.kr$ RewriteRule ^(.*)$ /index.php?subdomain=%1&path=$1 [L,QSA] ``` ## 서브도메인 관리 스크립트 ### 새 서브도메인 추가 ```bash cd scripts ./manage_subdomain.sh add newclient ep12345678901234567890ab ``` **처리 내용:** 1. Apache httpd.conf에 VirtualHost 추가 2. subdomain_init.php에 허용 목록 추가 3. Apache 설정 테스트 (`httpd -t`) 4. Apache 재시작 ### 서브도메인 제거 ```bash ./manage_subdomain.sh remove oldclient ``` ### 동기화 확인 ```bash ./subdomain_db_sync.sh ``` **확인 항목:** - DB의 ep_domain vs Apache 설정 - subdomain_init.php 동기화 - 불일치 항목 표시 ## 보안 고려사항 ### 1. 도메인 검증 ```php // subdomain_handler.php function is_allowed_domain($host, $ep_domain) { $allowed = array( 'www.dliveletter.kr', 'dliveletter.kr', $ep_domain . '.dliveletter.kr' ); if (!in_array($host, $allowed)) { // 허용되지 않은 도메인 접근 차단 header("Location: https://www.dliveletter.kr/"); exit; } } ``` ### 2. SQL Injection 방지 ```php // 서브도메인명도 이스케이프 필수 $subdomain_safe = mysql_real_escape_string($subdomain); $sql = "SELECT * FROM iw_enterprise WHERE ep_domain = '{$subdomain_safe}'"; ``` ### 3. 크로스 테넌트 공격 방지 ```php // 테넌트 A의 사용자가 테넌트 B의 ep_code를 전달하는 경우 // common.php에서 차단 if (isset($_GET['ep'])) { // 서브도메인으로 조회된 ep_code와 일치하는지 검증 if ($subdomain_ep && $_GET['ep'] != $subdomain_ep) { alert("잘못된 접근입니다."); } } ``` ## 트러블슈팅 ### 문제: 서브도메인 접속 시 메인 도메인으로 리다이렉트 ``` 원인: DB에 ep_domain이 없거나 ep_exposed = 1 해결: 1. DB 확인: SELECT * FROM iw_enterprise WHERE ep_domain = 'subdomain' 2. ep_exposed = 0 으로 변경 3. 세션 캐시 클리어: clear_subdomain_cache() ``` ### 문제: 다른 테넌트 데이터가 보임 ``` 원인: 쿼리에 ep_code 필터 누락 해결: 1. 해당 쿼리에 WHERE ep_code = '$iw[store]' 추가 2. 모든 쿼리 재검토 (grep "SELECT.*FROM iw_") ``` ### 문제: 세션 캐시가 업데이트되지 않음 ``` 원인: 5분 TTL로 인한 지연 해결: 1. clear_subdomain_cache() 호출 2. 또는 5분 대기 3. 긴급 시 세션 삭제: session_destroy() ``` ## 성능 최적화 ### 인덱스 추가 권장 ```sql -- 테넌트 격리 쿼리 최적화 CREATE INDEX idx_ep_code ON iw_member(ep_code); CREATE INDEX idx_ep_gp ON iw_notice(ep_code, gp_code); -- 서브도메인 조회 최적화 CREATE INDEX idx_ep_domain ON iw_enterprise(ep_domain); ``` ### 쿼리 최적화 예시 ```php // ❌ 느린 쿼리 (인덱스 미사용) SELECT * FROM iw_member WHERE mb_name LIKE '%홍길동%' AND ep_code = 'ep87579064668809f3d632cc'; // ✅ 빠른 쿼리 (인덱스 우선) SELECT * FROM iw_member WHERE ep_code = 'ep87579064668809f3d632cc' AND mb_name LIKE '%홍길동%'; ``` ## 참고 자료 - [include/subdomain_init.php](../../include/subdomain_init.php): 자동 초기화 구현 - [include/subdomain_handler.php](../../include/subdomain_handler.php): 검증 함수 - [include/common.php](../../include/common.php): 전역 변수 설정 - [scripts/manage_subdomain.sh](../../scripts/manage_subdomain.sh): 관리 스크립트 - [scripts/subdomain_db_sync.sh](../../scripts/subdomain_db_sync.sh): 동기화 도구 --- **마지막 업데이트**: 2024-11-05 **관련 문서**: [README.md](README.md), [architecture.md](architecture.md)