2025-11-05 15:23:57 +09:00
|
|
|
# 동적 테이블 접근 시스템 개선 완료
|
|
|
|
|
|
|
|
|
|
> **작성일**: 2025-01-04
|
|
|
|
|
> **목적**: 화이트리스트 제거 및 동적 테이블 접근 시스템 구축
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 문제 상황
|
|
|
|
|
|
|
|
|
|
### 기존 시스템의 문제점
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// ❌ 기존 방식: 하드코딩된 화이트리스트
|
|
|
|
|
const ALLOWED_TABLES = [
|
|
|
|
|
"company_mng",
|
|
|
|
|
"user_info",
|
|
|
|
|
"dept_info",
|
|
|
|
|
"item_info", // 매번 수동으로 추가해야 함!
|
|
|
|
|
// ... 계속 추가해야 함
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// 문제:
|
|
|
|
|
// 1. 새 테이블 생성 시마다 코드 수정 필요
|
|
|
|
|
// 2. 동적 테이블 생성 기능과 충돌
|
|
|
|
|
// 3. 유지보수 어려움
|
|
|
|
|
// 4. 확장성 부족
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 발생한 에러
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
GET /api/data/item_info?page=1&size=100&userLang=KR
|
|
|
|
|
-> 400 Bad Request
|
|
|
|
|
-> 접근이 허용되지 않은 테이블입니다: item_info
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 개선된 시스템
|
|
|
|
|
|
|
|
|
|
### 1. 블랙리스트 방식으로 전환
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
/**
|
|
|
|
|
* 접근 금지 테이블 목록 (블랙리스트)
|
|
|
|
|
* 시스템 중요 테이블 및 보안상 접근 금지할 테이블만 명시
|
|
|
|
|
*/
|
|
|
|
|
const BLOCKED_TABLES = [
|
|
|
|
|
"pg_catalog",
|
|
|
|
|
"pg_statistic",
|
|
|
|
|
"pg_database",
|
|
|
|
|
"pg_user",
|
|
|
|
|
"information_schema",
|
|
|
|
|
"session_tokens", // 세션 토큰 테이블
|
|
|
|
|
"password_history", // 패스워드 이력
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// ✅ 장점:
|
|
|
|
|
// - 금지할 테이블만 명시 (시스템 테이블)
|
|
|
|
|
// - 비즈니스 테이블은 자유롭게 추가 가능
|
|
|
|
|
// - 코드 수정 불필요
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. 테이블명 검증 강화
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
/**
|
|
|
|
|
* 테이블 이름 검증 정규식
|
|
|
|
|
* SQL 인젝션 방지: 영문, 숫자, 언더스코어만 허용
|
|
|
|
|
*/
|
|
|
|
|
const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
|
|
|
|
|
|
|
|
// 검증 순서:
|
|
|
|
|
// 1. 정규식으로 형식 검증 (SQL 인젝션 방지)
|
|
|
|
|
// 2. 블랙리스트 확인 (시스템 테이블 차단)
|
|
|
|
|
// 3. 테이블 존재 여부 확인 (실제 존재하는 테이블만)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3. 자동 회사별 필터링
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// ✅ company_code 컬럼 자동 감지
|
|
|
|
|
if (userCompany && userCompany !== "*") {
|
|
|
|
|
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
|
|
|
|
|
if (hasCompanyCode) {
|
|
|
|
|
whereConditions.push(`company_code = $${paramIndex}`);
|
|
|
|
|
queryParams.push(userCompany);
|
|
|
|
|
paramIndex++;
|
|
|
|
|
console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 동작 방식:
|
|
|
|
|
// - company_code 컬럼이 있으면 자동으로 필터링 적용
|
|
|
|
|
// - 최고 관리자(company_code = "*")는 전체 데이터 조회 가능
|
|
|
|
|
// - 일반 사용자는 자기 회사 데이터만 조회
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 4. 공통 검증 메서드
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
/**
|
|
|
|
|
* 테이블 접근 검증 (공통 메서드)
|
|
|
|
|
*/
|
|
|
|
|
private async validateTableAccess(
|
|
|
|
|
tableName: string
|
|
|
|
|
): Promise<{ valid: boolean; error?: ServiceResponse<any> }> {
|
|
|
|
|
// 1. 테이블명 형식 검증 (SQL 인젝션 방지)
|
|
|
|
|
if (!TABLE_NAME_REGEX.test(tableName)) {
|
|
|
|
|
return { valid: false, error: { /* ... */ } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. 블랙리스트 검증
|
|
|
|
|
if (BLOCKED_TABLES.includes(tableName)) {
|
|
|
|
|
return { valid: false, error: { /* ... */ } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. 테이블 존재 여부 확인
|
|
|
|
|
const tableExists = await this.checkTableExists(tableName);
|
|
|
|
|
if (!tableExists) {
|
|
|
|
|
return { valid: false, error: { /* ... */ } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { valid: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 모든 메서드에서 재사용:
|
|
|
|
|
// - getTableData()
|
|
|
|
|
// - getTableColumns()
|
|
|
|
|
// - getRecordDetail()
|
|
|
|
|
// - createRecord()
|
|
|
|
|
// - updateRecord()
|
|
|
|
|
// - deleteRecord()
|
|
|
|
|
// - getJoinedData()
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 개선 효과
|
|
|
|
|
|
|
|
|
|
### Before (화이트리스트 방식)
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 1. item_info 테이블 생성
|
|
|
|
|
CREATE TABLE item_info (...);
|
|
|
|
|
|
|
|
|
|
// 2. 백엔드 코드 수정 필요 ❌
|
|
|
|
|
const ALLOWED_TABLES = [
|
|
|
|
|
// ...기존 테이블들
|
|
|
|
|
"item_info", // 수동으로 추가!
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const COMPANY_FILTERED_TABLES = [
|
|
|
|
|
// ...기존 테이블들
|
|
|
|
|
"item_info", // 또 추가!
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// 3. 서버 재시작 필요
|
|
|
|
|
// 4. 테스트
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### After (블랙리스트 방식)
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 1. item_info 테이블 생성
|
|
|
|
|
CREATE TABLE item_info (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
|
company_code VARCHAR(20) NOT NULL, -- 이 컬럼만 있으면 자동 필터링!
|
|
|
|
|
name VARCHAR(100),
|
|
|
|
|
...
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 2. 코드 수정 불필요 ✅
|
|
|
|
|
// 3. 서버 재시작 불필요 ✅
|
|
|
|
|
// 4. 즉시 사용 가능 ✅
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 보안 강화
|
|
|
|
|
|
|
|
|
|
### 1. SQL 인젝션 방지
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// ❌ 위험한 테이블명
|
|
|
|
|
"user_info; DROP TABLE users; --" -> 정규식 검증 실패
|
|
|
|
|
"../../etc/passwd" -> 정규식 검증 실패
|
|
|
|
|
"pg_user" -> 블랙리스트 차단
|
|
|
|
|
|
|
|
|
|
// ✅ 안전한 테이블명
|
|
|
|
|
"user_info" -> 통과
|
|
|
|
|
"item_info" -> 통과
|
|
|
|
|
"order_mng_001" -> 통과
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. 시스템 테이블 보호
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
const BLOCKED_TABLES = [
|
|
|
|
|
"pg_catalog", // PostgreSQL 카탈로그
|
|
|
|
|
"pg_statistic", // 통계 정보
|
|
|
|
|
"pg_database", // 데이터베이스 목록
|
|
|
|
|
"pg_user", // 사용자 정보
|
|
|
|
|
"information_schema", // 스키마 정보
|
|
|
|
|
"session_tokens", // 세션 토큰
|
|
|
|
|
"password_history", // 패스워드 이력
|
|
|
|
|
];
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3. 멀티테넌시 자동 적용
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 테이블에 company_code 컬럼이 있으면 자동으로:
|
|
|
|
|
|
|
|
|
|
// 일반 사용자 (company_code = "COMPANY_A")
|
|
|
|
|
SELECT * FROM item_info WHERE company_code = 'COMPANY_A';
|
|
|
|
|
|
|
|
|
|
// 최고 관리자 (company_code = "*")
|
|
|
|
|
SELECT * FROM item_info; -- 모든 회사 데이터 조회 가능
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 사용 예시
|
|
|
|
|
|
|
|
|
|
### 1. 새 테이블 생성
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
-- 회사별 데이터 격리가 필요한 테이블
|
|
|
|
|
CREATE TABLE product_catalog (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
|
company_code VARCHAR(20) NOT NULL, -- 자동 필터링 활성화
|
|
|
|
|
product_name VARCHAR(100),
|
|
|
|
|
price DECIMAL(10, 2),
|
|
|
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
-- 전역 공통 테이블 (회사별 격리 불필요)
|
|
|
|
|
CREATE TABLE global_settings (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
|
setting_key VARCHAR(50),
|
|
|
|
|
setting_value TEXT
|
|
|
|
|
);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. API 호출
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 프론트엔드에서 그냥 호출하면 끝!
|
|
|
|
|
const response = await apiClient.get("/api/data/product_catalog", {
|
|
|
|
|
params: { page: 1, size: 100 }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 백엔드에서 자동으로:
|
|
|
|
|
// 1. 테이블 존재 확인 ✓
|
|
|
|
|
// 2. company_code 컬럼 확인 ✓
|
|
|
|
|
// 3. 회사별 필터링 적용 ✓
|
|
|
|
|
// 4. 데이터 반환 ✓
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3. 동적 테이블 생성 (DDL API 연동)
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 1. DDL API로 테이블 생성
|
|
|
|
|
POST /api/ddl/tables
|
|
|
|
|
{
|
|
|
|
|
"tableName": "customer_feedback",
|
|
|
|
|
"columns": [
|
|
|
|
|
{ "name": "company_code", "type": "VARCHAR(20)", "nullable": false },
|
|
|
|
|
{ "name": "feedback_text", "type": "TEXT" },
|
|
|
|
|
{ "name": "rating", "type": "INTEGER" }
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. 즉시 데이터 조회 가능 (코드 수정 없음)
|
|
|
|
|
GET /api/data/customer_feedback
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 변경된 파일
|
|
|
|
|
|
|
|
|
|
### backend-node/src/services/dataService.ts
|
|
|
|
|
|
|
|
|
|
**변경 사항:**
|
|
|
|
|
- ❌ 제거: `ALLOWED_TABLES` 화이트리스트
|
|
|
|
|
- ❌ 제거: `COMPANY_FILTERED_TABLES` 하드코딩
|
|
|
|
|
- ✅ 추가: `BLOCKED_TABLES` 블랙리스트
|
|
|
|
|
- ✅ 추가: `TABLE_NAME_REGEX` 정규식 검증
|
|
|
|
|
- ✅ 추가: `validateTableAccess()` 공통 검증 메서드
|
|
|
|
|
- ✅ 추가: `checkColumnExists()` 컬럼 존재 확인 메서드
|
|
|
|
|
- ✅ 개선: 자동 회사별 필터링 로직
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 테스트 체크리스트
|
|
|
|
|
|
|
|
|
|
### 기본 기능
|
|
|
|
|
- [x] 기존 테이블 조회 정상 작동
|
|
|
|
|
- [x] 새로운 테이블 조회 정상 작동
|
|
|
|
|
- [x] 존재하지 않는 테이블 접근 시 적절한 에러
|
|
|
|
|
- [x] 블랙리스트 테이블 접근 시 차단
|
|
|
|
|
|
|
|
|
|
### 보안
|
|
|
|
|
- [x] SQL 인젝션 시도 차단
|
|
|
|
|
- [x] 시스템 테이블 접근 차단
|
|
|
|
|
- [x] 회사별 데이터 격리 정상 작동
|
|
|
|
|
- [x] 최고 관리자 전체 데이터 조회 가능
|
|
|
|
|
|
|
|
|
|
### 성능
|
|
|
|
|
- [x] company_code 컬럼 존재 여부 확인 성능 (캐싱 가능)
|
|
|
|
|
- [x] 테이블 존재 여부 확인 성능
|
|
|
|
|
- [x] 정규식 검증 성능 (충분히 빠름)
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 향후 개선 사항
|
|
|
|
|
|
|
|
|
|
### 1. 컬럼 존재 여부 캐싱
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 성능 최적화: 컬럼 정보 캐싱
|
|
|
|
|
private columnCache = new Map<string, Set<string>>();
|
|
|
|
|
|
|
|
|
|
private async checkColumnExists(
|
|
|
|
|
tableName: string,
|
|
|
|
|
columnName: string
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
// 캐시 확인
|
|
|
|
|
if (this.columnCache.has(tableName)) {
|
|
|
|
|
return this.columnCache.get(tableName)!.has(columnName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 테이블의 모든 컬럼 조회 및 캐싱
|
|
|
|
|
const columns = await this.getTableColumnsSimple(tableName);
|
|
|
|
|
const columnSet = new Set(columns.map(c => c.column_name));
|
|
|
|
|
this.columnCache.set(tableName, columnSet);
|
|
|
|
|
|
|
|
|
|
return columnSet.has(columnName);
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. 블랙리스트 패턴 매칭
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// pg_* 형태의 패턴 지원
|
|
|
|
|
const BLOCKED_TABLE_PATTERNS = [
|
|
|
|
|
/^pg_/, // pg_로 시작하는 모든 테이블
|
|
|
|
|
/^information_/, // information_으로 시작
|
|
|
|
|
/_password$/, // _password로 끝나는 테이블
|
|
|
|
|
];
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3. 테이블별 접근 권한 시스템
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// 향후: 사용자 역할별 테이블 접근 권한
|
|
|
|
|
interface TablePermission {
|
|
|
|
|
tableName: string;
|
|
|
|
|
roles: string[]; // ["ADMIN", "USER", "VIEWER"]
|
|
|
|
|
operations: string[]; // ["read", "write", "delete"]
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 결론
|
|
|
|
|
|
|
|
|
|
✅ **동적 테이블 접근 시스템 구축 완료**
|
|
|
|
|
|
|
|
|
|
- 화이트리스트 제거로 유지보수 부담 해소
|
|
|
|
|
- 블랙리스트 방식으로 보안 유지
|
|
|
|
|
- 자동 회사별 필터링으로 멀티테넌시 보장
|
|
|
|
|
- 새 테이블 추가 시 코드 수정 불필요
|
|
|
|
|
|
|
|
|
|
**이제 테이블을 만들 때마다 코드를 수정할 필요가 없습니다!**
|
|
|
|
|
|
2025-11-06 10:37:20 +09:00
|
|
|
|
2025-11-06 18:10:21 +09:00
|
|
|
|